Довідник з конфігурації
OxPHP налаштовується через змінні середовища, а також через один необов’язковий файл: .oxphpdeny — правила заборони шляхів, що читаються з кореня DOCUMENT_ROOT. Кожне налаштування має типове значення, тож розгортання взагалі без конфігурації працює одразу.
Булеві значення
Змінні, позначені як булеві, приймають фіксований канонічний набір значень без урахування регістру й з обрізанням пробілів:
- істинні:
on,true,1,yes - хибні:
off,false,0,no
Будь-яке непорожнє значення поза цим набором — наприклад, друкарська помилка на кшталт ture — одразу зупиняє запуск з помилкою, у якій названо змінну. Так помилкову конфігурацію буде виявлено ще до надходження трафіку, а не тоді, коли прапорець мовчки перемкнеться не в той бік.
Незадана змінна або порожнє присвоєння (FOO=) повертається до задокументованого типового значення. Порожнє значення навмисно трактується як незадане: підстановка в Docker Compose / Kubernetes на кшталт FOO=${FOO} дає FOO=, коли змінна на хості відсутня, і через це сервер не повинен відмовлятися стартувати.
Сервер
| Змінна | Типове значення | Опис |
|---|---|---|
LISTEN_ADDR |
0.0.0.0:80 |
Адреса й порт основного HTTP-сервера |
DOCUMENT_ROOT |
/var/www/html/public |
Кореневий каталог для віддачі файлів і PHP-скриптів |
ENTRY_FILE |
(не задано) | Єдиний канонічний вхідний скрипт. Не задано — пряме зіставлення файлів. *.php — фронт-контролер. Не .php — статичний запасний варіант (SPA). З WORKER_MODE_ENABLED=true — bootstrap-скрипт воркера. Розв’язується відносно DOCUMENT_ROOT (відносні шляхи й .. дозволені; абсолютні шляхи використовуються як є). Див. Маршрутизація |
WORKER_MODE_ENABLED |
false |
Вмикає постійний режим воркерів. Потребує, щоб ENTRY_FILE вказував на .php-скрипт. Булева — див. Булеві значення |
MAX_CONNECTIONS |
10000 |
Максимальна кількість одночасних TCP-підключень. Також є стелею для типового QUEUE_MAX_WAITING (його половина), тож некоректне значення — це помилка запуску, а не тихий перехід до запасного варіанта. Зменшення не змінює типового QUEUE_CAPACITY, яке обчислюється лише з кількості воркерів, — тримайте його вищим за PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING, див. Запас для циклу прийняття підключень |
TOKIO_WORKERS |
CPU / 2 (мін. 1) | Потоки асинхронного введення-виведення. 1 — однопотоковий режим, N > 1 — фіксована кількість потоків, не задано — автоматично (CPU / 2, мін. 1). Див. Розмір асинхронного середовища виконання |
Розмір асинхронного середовища виконання
TOKIO_WORKERS типово дорівнює половині доступних ядер, і друга половина не простоює: вона належить пулу PHP-воркерів. Обидва набори потоків змагаються за ті самі ядра, тож розширити середовище виконання, не забравши тактів у застосунку, неможливо.
Підняти його до повної кількості ядер — це програш усюди, де пул уже має розмір, близький до цього числа. На 4-ядерній машині з пулом із 4 воркерів, що віддавав стиснуті відповіді, перехід від 2 потоків середовища виконання до 4 коштував 13% швидкості на JSON-тілі в 1,6 КБ і 26% на тілі в 5 КБ, та ще й спалив більше CPU, бо потоків стало більше, ніж ядер.
Піднімайте його там, де баланс інший: пул малий відносно машини або робота обмежена введенням-виведенням, а не PHP (проксійовані upstream-сервери, статичні файли, потокові відповіді), — тоді роботу виконує середовище виконання, а пул переважно чекає. Хоч би що ви змінили, стежте не лише за швидкістю, а й за пулом: oxphp_busy_workers порівняно з oxphp_workers_current покаже, куди пішли ядра. Зростання швидкості при дедалі більшій зайнятості пулу — справжній виграш; зростання швидкості при спорожнінні пулу зазвичай означає, що робота переїхала, а не що її стало виконано більше.
TOKIO_WORKERS і PHP_WORKERS обидва типово беруть половину доступних ядер, тож їхня сума дорівнює кількості ядер. Змініть одне — і сума зсунеться, тому кожне з них варто вимірювати, а не вгадувати.
PHP-воркери
| Змінна | Типове значення | Опис |
|---|---|---|
EXECUTOR |
sapi |
Бекенд виконання PHP. sapi — для виконання PHP, stub — для бенчмарків без PHP |
PHP_WORKERS |
CPU / 2 (мін. 1) | Розмір пулу воркерів. N — фіксований пул, MIN:MAX — динамічне масштабування, 0 — автоматично |
PHP_WORKERS_IDLE_SECONDS |
30 |
Скільки секунд динамічний воркер може простоювати, перш ніж його буде виведено з пулу (лише в динамічному режимі). Воркер виводиться в момент, коли в нього немає нічого в обробці, тож нічого з того, що він обслуговує, не обривається, — а воркер, що тримає запит, який ніколи не завершується (наприклад, відкритий потік), такого моменту не досягає й не виводиться взагалі |
QUEUE_CAPACITY |
Початкова кількість воркерів × 128 | Максимальна кількість запитів, що очікують у черзі PHP. Запити, які надходять до заповненої черги, чекають на місце (див. QUEUE_WAIT_TIMEOUT_MS). Для динамічних пулів (MIN:MAX) початкова кількість воркерів — це мінімум. 0 — автоматично |
QUEUE_WAIT_TIMEOUT_MS |
1000 |
Найдовший час, який запит може провести в очікуванні PHP-воркера, перш ніж його буде відхилено з 529. Це стеля, а не фіксоване значення. Один дедлайн, проставлений під час надходження, охоплює обидва очікування, з якими може зіткнутися запит: на місце в черзі та всередині черги на воркер. Під перевантаженням сервер сам скорочує бюджет нижче цієї стелі й подовжує його назад, коли черга звільняється; oxphp_admission_wait_budget_us публікує чинне значення, oxphp_admission_wait_budget_ceiling_us — налаштоване (див. Адаптивний бюджет очікування). Дедлайн контролюється з обох боків: коли він минає, запит отримує відповідь 529, а воркер, що дістається до запиту з простроченим дедлайном, відмовляється його виконувати, тож запит у черзі, який ніхто не забрав, не чекає безкінечно. На те, скільки потім виконується обробник, він не впливає. Одночасно чекати можуть щонайбільше QUEUE_MAX_WAITING запитів; понад це запити відхиляються негайно. 0 — відхиляти негайно щоразу, коли черга заповнена; це прибирає очікування на місце в черзі, але не очікування всередині черги. Зменшуйте його, коли застосунок звертається назад до цього самого сервера через HTTP (внутрішньому виклику потрібен воркер, якого тримає зовнішній, тож очікування марне), або коли балансувальник навантаження перед сервером має власний коротший тайм-аут. Клієнт, що закриває підключення під час очікування, одразу звільняє своє місце — як на HTTP/1.1, так і на HTTP/2; той, що закривається, вже перебуваючи в черзі, зберігає своє місце, доки воркер не дістанеться до нього або бюджет не вичерпається, і тоді отримує 499, а не зараховується як відмова через перевантаження. Чого сервер не бачить, то це клієнта, який перестає чекати, не закриваючи підключення. Такий клієнт тримає своє місце, доки його не буде допущено або бюджет не вичерпається, і якщо воркер звільниться раніше, його скрипт виконається ні для кого, тож тримати бюджет нижчим за тайм-аут усього, що стоїть перед сервером, усе одно варто |
QUEUE_MAX_WAITING |
Початкова кількість воркерів × 128, але не більше за MAX_CONNECTIONS / 2 |
Максимальна кількість запитів, одночасно відкладених в очікуванні місця в черзі. Понад це запити відхиляються негайно, а не чекають. Кожен очікувач тримає підключення й повністю буферизоване тіло запиту весь час, поки чекає, тож це обмеження на утримувані ресурси, а не на очікування, які окупляться. Обмеження MAX_CONNECTIONS / 2 на типове значення стосується лише цієї частини черги; запити в черзі й ті, що виконуються, тримають підключення так само, тож запас, який сервер реально зберігає для прийняття й відхилення, визначається співвідношенням PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING і MAX_CONNECTIONS — див. нижче. Підбирайте його за латентністю власного обробника — теж нижче. 0 — автоматично, ніколи не менше за 1 |
QUEUE_MAX_WAITING_BYTES |
67108864 (64 MiB) |
Максимальна сумарна кількість байтів тіл запитів, яку можуть тримати відкладені запити. Запит, чиє тіло вивело б суму за цю межу, негайно відхиляється з 529, а не чекає; запит без тіла з цієї причини ніколи не відхиляється. QUEUE_MAX_WAITING обмежує той самий набір у запитах, що нічого не каже про їхній розмір, — тіла буферизуються повністю ще до того, як запит потрапить у чергу, тож, якщо зупиняти їх лише лічильником, відкладений набір може тримати QUEUE_MAX_WAITING × 10 MiB. Збільшуйте для застосунку з великою кількістю завантажень, який має поглинати сплески великих тіл, а не скидати їх; зменшуйте в контейнері з обмеженою пам’яттю. 0 — автоматично |
Розмір набору очікувачів
Скільки запитів може корисно чекати, залежить від того, як швидко пул їх обробляє. З W воркерами й обробником, що триває T мілісекунд, пул допускає W / T запитів за мілісекунду, тож бюджет у B мілісекунд може впустити приблизно W × B / T очікувачів. Усе, що понад це, чекає весь бюджет і все одно отримує відмову, увесь цей час тримаючи підключення й буферизоване тіло запиту.
Швидкість обслуговування на старті невідома, тож типове значення не може цього обчислити й навмисно щедре. Це пасує швидким обробникам, коли пул розгрібає глибоку чергу з великим запасом у межах бюджету, і надто багато для повільних: з 8 воркерами, обробником у 200 мс і типовою стелею в 1 с (бюджет, чинний щоразу, коли пул не скидає навантаження) вчасно може бути допущено лише близько 40 запитів, тоді як типове значення відкладає до 1024.
QUEUE_MAX_WAITING=40Значення, близьке до W × B / T, перетворює надлишок на негайний 529 замість 529 секундою пізніше. Скорочення QUEUE_WAIT_TIMEOUT_MS досягає того самого через інший множник. Одне врівноважує інше, і для повільного обробника менший бюджет зазвичай кращий важіль, бо він також обмежує, скільки клієнт чекає на відмову. B тут — чинний бюджет, який під перевантаженням є скороченим сервером значенням, а не налаштованою стелею. Прочитайте oxphp_admission_wait_budget_us, перш ніж підбирати розмір набору очікувачів за числом із конфігураційного файлу.
Набір обмежено вдруге, у байтах. Кожен очікувач тримає тіло свого запиту в пам’яті весь час очікування, тож обмеження в кількості запитів віддає обсяг утримуваної пам’яті на розсуд трафіку: ті самі 1024 очікувачі нічого не коштують на GET без тіла й гігабайти — на завантаженнях. QUEUE_MAX_WAITING_BYTES обмежує цю суму безпосередньо — понад неї запит із тілом одразу отримує відмову замість відкладення, тоді як запити без тіла чекають як зазвичай. Застосунку з великою кількістю завантажень, який має поглинати сплески, а не скидати їх, потрібне більше значення; контейнеру з жорстким лімітом пам’яті — менше. Відмови за кожним обмеженням рахуються окремо в oxphp_admission_refused_total (waiting_full — за кількістю, waiting_bytes — за пам’яттю), тож метрика підказує, яке налаштування крутити.
Адаптивний бюджет очікування
QUEUE_WAIT_TIMEOUT_MS — це стеля. Поки робота, яку допускає очікування, завершується для клієнтів, що вже пішли, сервер сам скорочує бюджет, і то круто, — це варто знати, перш ніж обирати стелю:
- Униз: бюджет зменшується вдвічі щоразу, коли чверть або більше з того, що почав пул, дісталося клієнтам, які вже пішли; вимірюється у вікні щонайменше з восьми розпочатих запитів, а не за фіксований інтервал.
- Нижня межа: одна шістдесят четверта стелі (15,6 мс за типового значення) або 10 мс, залежно від того, що більше, але ніколи не вище за саму стелю.
- Угору: бюджет повертається кроками по восьмій частині стелі, і лише поки черга спостерігається порожньою, а клієнтам, які пішли, дісталося менше чверті з того, що почав пул. Звичайний потік закритих вкладок і клієнтських тайм-аутів не тримає його внизу, а сервер, чия черга ніколи не спорожнюється, лишається на скороченому бюджеті, доки навантаження не спаде.
Оператор, який задав 1000, має очікувати, що під час епізоду чинним буде близько 15, тож пишіть алерти на очікування відносно пари oxphp_admission_wait_budget_us / oxphp_admission_wait_budget_ceiling_us, а не відносно налаштованого числа.
Скорочення запускають клієнти, які закривають підключення. Сукупність клієнтів, що перестають чекати без закриття, не дає жодного сигналу й лишає чинною стелю.
Застосунок, що звертається назад до цього самого сервера через HTTP, марнує свої очікування, хоч би якими довгими вони були: внутрішньому виклику потрібен воркер, якого тримає зовнішній. oxphp_admission_wait_wasted_total рахує такі очікування, але лише поки бюджет на стелі. Там, де бюджет знизився, лічильник замовкає, тож 0 у ньому сам по собі не доводить, що такого патерну немає. Те, чи знизиться бюджет, теж не є властивістю самого виклику: застосунок, що викликає сам себе і чиї власні клієнти чекають щедро (внутрішній cron, sidecar із довгим тайм-аутом), зберігає свою стелю.
QUEUE_WAIT_TIMEOUT_MS=0 цього не виправляє. Воно прибирає очікування на місце в черзі, але не очікування всередині черги, тож, якщо QUEUE_CAPACITY не настільки мала, щоб черга була заповнена, внутрішній виклик допускається в чергу, яку ніхто не розбирає, і не отримує відповіді взагалі.
Запас для циклу прийняття підключень
Запит тримає своє підключення — і один із дозволів MAX_CONNECTIONS — від моменту надходження до моменту відповіді, хоч би що він робив у проміжку. Це охоплює три окремі групи, бо місце в черзі звільняється тієї миті, коли воркер забирає запит, ще до запуску скрипту:
- ті, що виконуються у воркері: щонайменше
PHP_WORKERS, а в режимі воркерів більше, бо один потік мультиплексує файбери; - ті, що в черзі, — до
QUEUE_CAPACITY; - відкладені на етапі допуску — до
QUEUE_MAX_WAITING.
Лише третя група має стелю, похідну від MAX_CONNECTIONS. Тримайте PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING нижче за MAX_CONNECTIONS. Понад це характер збою змінюється на гірше: цикл прийняття бере дозвіл на підключення, перш ніж почати його обслуговувати, тож щойно шлях PHP утримує їх усі, цикл зупиняється, і клієнт, що надходить у цей момент, не отримує жодної відповіді замість 529 — чого балансувальник навантаження не може відрізнити від мертвого екземпляра, тоді як health-перевірка на INTERNAL_ADDR лишається зеленою, бо не проходить через жодне з цих обмежень.
Зупинка потрапляє в лог. Прийняття, якому доводиться чекати на дозвіл, пише WARN із назвою бюджету, а INFO про те, скільки цикл простояв, записується тієї миті, коли дозвіл надходить. Звіти обмежено за частотою, щоб сервер, який коливається навколо стелі, не перетворив власний лог на причину збою: зупинку, що починається за кілька секунд після вже зареєстрованої, спочатку притримано й зареєстровано, коли це вікно закривається, якщо вона ще триває; та, що завершується в межах вікна, не залишає жодного рядка. Метрики бачать кожну: oxphp_accept_stalled (gauge, доступний для читання, поки зупинка триває, бо внутрішній слухач не проходить через MAX_CONNECTIONS) і oxphp_accept_stalls_total (лічильник); див. довідник метрик. Клієнти, що надходять, усе одно не отримують відповіді замість помилки, але лог і метрики фіксують зупинку.
Сервер перевіряє це на старті й попереджає, коли сума досягає бюджету, називаючи кожне значення; oxphp config --check повідомляє те саме, не змінюючи свого вердикту чи коду виходу. Вважайте, що прибрати попередження — необхідно, але не достатньо: неактивні keep-alive підключення, запити статичних файлів і рукостискання в процесі теж тримають дозволи, і жодне з них на старті не порахувати.
Зменшення MAX_CONNECTIONS — звичайний шлях до попередження, бо типове QUEUE_CAPACITY обчислюється з кількості воркерів і не зменшується слідом — 7 воркерів і MAX_CONNECTIONS=1000 дають 7 + 896 + 500 проти бюджету в 1000. Зверніть увагу, яке налаштування крутити: саме лише збільшення MAX_CONNECTIONS попередження не прибирає, поки QUEUE_MAX_WAITING лишається типовим, бо це типове значення — половина MAX_CONNECTIONS і зростає разом із ним — за 7 воркерів сума встановлюється на 1799, і умова знімається лише від MAX_CONNECTIONS=1800 і вище. Зменште QUEUE_CAPACITY або задайте QUEUE_MAX_WAITING явно, а тоді збільште бюджет.
Для динамічного пулу (MIN:MAX) доданок тих, що виконуються, — це мінімум, та сама кількість, з якої обчислюються типові значення черги, тож пул, що виріс до максимуму, тримає більше, ніж показує сума.
Порівняння рахує підключення, тоді як бюджет витрачають запити, тож розгортання з переважно HTTP/2-трафіком цілком законно може перебувати вище за нього: одне підключення несе до H2_MAX_CONCURRENT_STREAMS запитів, тож ту саму чергу тримає лише частка підключень. Там попередження очікуване. Великий пул на стандартному бюджеті теж його досягає — автоматично підібраний пул із 39 воркерів дає 39 + 4992 + 4992 проти 10 000 — і на це варто зреагувати, а не ігнорувати.
Статичні й динамічні воркери
Задайте PHP_WORKERS одним числом для фіксованого пулу:
PHP_WORKERS=8 # Fixed 8 workers
PHP_WORKERS=0 # Auto-detect: CPU / 2 (min 1)Задайте PHP_WORKERS як MIN:MAX для автоматичного масштабування:
PHP_WORKERS=2:16 # Scale between 2 and 16 workers
PHP_WORKERS=4:0 # 4 minimum, auto-detect maximum (CPU × 2)
PHP_WORKERS=0:16 # auto-detect minimum (CPU / 4, min 1), 16 maximumУ динамічному режимі OxPHP додає воркерів, коли всі зайняті, і прибирає їх, коли воркери простоюють довше за PHP_WORKERS_IDLE_SECONDS.
Як визначається кількість CPU
Кожне типове значення на цій сторінці, виражене через CPU (TOKIO_WORKERS, а також PHP_WORKERS — незадане, 0 або з 0 з будь-якого боку MIN:MAX), рахує CPU, на яких процесу дозволено виконуватися: його маску affinity (taskset, docker run --cpuset-cpus), знижену до квоти CPU контейнера, якщо її задано (docker run --cpus; cpu.max у cgroup v2, cpu.cfs_quota_us у v1). Квота округлюється вниз і рахується щонайменше як 1, тож --cpus=1.5 рахується як 1.
Два види обмежень не враховуються, і контейнер, стримуваний лише одним із них, отримує розмір за всіма CPU, які бачить:
- Вага CPU:
docker run --cpu-shares,cpu.weightу cgroup v2,cpu.sharesу v1. Вона визначає, як процесорний час, за який точиться конкуренція, ділиться із сусідами. Це не стеля, і жодна кількість CPU з неї не випливає. - Обмеження, яке застосовується поза контейнером. Платформа може продавати частку машини й застосовувати обмеження там, де контейнер його не бачить. Наприклад, на DigitalOcean App Platform екземпляр, проданий як 1 vCPU, як було помічено, працював у пісочниці gVisor без квоти (
cpu.cfs_quota_usдорівнює-1) і з 8 видимими CPU, тож типові значення дали 4 PHP-воркери, 4 потоки середовища виконання й чергу на 512.
На такому тарифі задайте PHP_WORKERS і TOKIO_WORKERS явно — відповідно до того, що тариф насправді надає. У режимі воркерів також звірте PHP_WORKERS × WORKER_MAX_MEMORY_MIB з пам’яттю тарифу. Цей поріг — тригер перезапуску, який воркер перевіряє у своєму циклі, а не стеля для того, що може виділити його купа PHP, тож пул може утримувати стільки й більше, перш ніж будь-який воркер буде через це перезапущено, а зверху ще OPcache і сам сервер; з типовим 0 порогу немає взагалі. Та сама пісочниця може приховувати й обмеження пам’яті: контейнер App Platform, описаний вище, жодного не бачив.
На старті сервер логує кількість, те, що її визначило, і підсумкові PHP_WORKERS і TOKIO_WORKERS. Ось як це виглядає з docker run --cpus=1 на машині з 14 CPU:
{"timestamp":"2026-10-05T18:55:39.801254Z","level":"INFO","fields":{"message":"CPU count for default sizing","cpus":1,"cpu_source":"cgroup quota","host_cpus":14,"php_workers":"1 (auto)","tokio_workers":"1 (auto)"}}cpu_sourceдорівнюєhost, коли ні маска, ні квота не знизили кількість,affinityабоcgroup quota— коли одна з них її знизила, іunknown— коли сервер не зміг цього визначити, бо не вдалося прочитати маску або кількість онлайн-CPU. Якщо сервер узагалі не зміг порахувати CPU,cpusдорівнює 4.host_cpus— це кількість CPU, які система повідомляє як онлайн (/sys/devices/system/cpu/onlineу Linux); маска чи квота її не знижують. Поле пропускається, якщо його не вдається прочитати. Середовище виконання контейнерів, яке переписує цей файл так, щоб він перелічував лише CPU контейнера, змушує звужену маску читатися якhost.(auto)позначає значення, повністю залишене на типове: змінну не задано або вона порожня, або — дляTOKIO_WORKERS— вона не є числом.
cpu_source зі значенням host на тарифі, який продав вам менше CPU, ніж показує cpus, — це саме описаний вище випадок.
Режим воркерів
| Змінна | Типове значення | Опис |
|---|---|---|
WORKER_MAX_MEMORY_MIB |
0 |
Максимальний обсяг пам’яті в MiB на воркер, після якого він перезапускається. 0 — без обмежень |
Задайте WORKER_MODE_ENABLED=true і вкажіть в ENTRY_FILE bootstrap-скрипт воркера (наприклад, ENTRY_FILE=worker.php або ENTRY_FILE=../worker.php). Тоді PHP-воркери живуть між запитами, зберігаючи стан ініціалізації (автозавантажувачі, підключення до бази даних) у пам’яті. Воркери перезапускаються автоматично, коли перевищують WORKER_MAX_MEMORY_MIB або отримують три поспіль запити, що зазнали збою, а також на вимогу, коли застосунок викликає Worker::scheduleExit(); що зараховується до третього випадку, див. у розділі Перезапуск. Налаштування WORKER_MAX_REQUESTS із попередніх релізів застаріле й ігнорується: не задавайте жодного з них або перейдіть на Worker::scheduleExit().
Застаріле: INDEX_FILE і WORKER_FILE
Застарілі змінні INDEX_FILE і WORKER_FILE досі розбираються для зворотної сумісності. Якщо їх задано, вони пишуть на старті рядок логу рівня WARN і відображаються на нову модель:
| Застаріле | Сучасний еквівалент |
|---|---|
INDEX_FILE=index.php |
ENTRY_FILE=index.php |
INDEX_FILE=index.html |
ENTRY_FILE=index.html |
WORKER_FILE=/path/worker.php |
WORKER_MODE_ENABLED=true ENTRY_FILE=/path/worker.php |
Якщо задано і старі, і нові, перевагу мають ENTRY_FILE / WORKER_MODE_ENABLED. Мігруйте, коли вам зручно; застарілі форми буде видалено в одному з майбутніх релізів.
SAPI / PHP
| Змінна | Типове значення | Опис |
|---|---|---|
SUPERGLOBALS_ENABLED |
true |
Описувати запит для PHP, щоб той побудував $_SERVER і $_GET перед виконанням скрипту. Задайте хибне значення, щоб пропустити цю роботу в застосунку, який не читає жодного з них. Воно впливає лише на ці два: $_POST, $_FILES і $_COOKIE будуються з тіла запиту й заголовка Cookie, які передаються PHP у будь-якому разі, а php://input — це потік, а не суперглобальна змінна. Повну таблицю див. у SUPERGLOBALS_ENABLED |
Тайм-аути
| Змінна | Типове значення | Опис |
|---|---|---|
HEADER_TIMEOUT_SECONDS |
5 |
Максимум секунд на отримання HTTP-заголовків після підключення (захист від Slowloris) |
DRAIN_TIMEOUT_SECONDS |
25 |
Максимум секунд очікування на підключення в обробці під час коректного завершення. Запит, що на момент дедлайну перебуває всередині блокувального виклику, може втримати процес довше (див. Коректне завершення роботи) |
Час виконання PHP обмежується власною ini-директивою PHP max_execution_time (і set_time_limit() під час виконання), а не змінною середовища OxPHP. Ця межа не поширюється на асинхронне завдання: потік пулу ASYNC_WORKERS не тримає дедлайну виконання, тож ні директива, ні set_time_limit() не обмежують того, що виконує завдання, диспетчеризоване через oxphp_async(). Натомість обмежуйте таке завдання аргументом тайм-ауту oxphp_async_await(); див. Асинхронні проміси.
Обмеження частоти запитів
| Змінна | Типове значення | Опис |
|---|---|---|
RATE_LIMIT |
0 (вимкнено) |
Максимальна кількість запитів з одного IP за часове вікно. 0 вимикає обмеження частоти |
RATE_WINDOW_SECONDS |
60 |
Тривалість вікна обмеження частоти в секундах |
Безпека
| Змінна | Типове значення | Опис |
|---|---|---|
FRAME_OPTIONS |
SAMEORIGIN |
Захист від клікджекінгу. SAMEORIGIN дозволяє вбудовування у фрейм лише сторінкам того самого походження, DENY блокує будь-яке вбудовування, off вимикає захист (використовуйте, коли керуєте вбудовуванням через власний CSP). Будь-яке інше значення повертається до типового SAMEORIGIN із попередженням на старті. Встановлює і X-Frame-Options, і Content-Security-Policy: frame-ancestors у кожній відповіді. Значення заголовків, те, як серверні заголовки поступаються встановленим застосунком, і поради щодо вибору значення див. нижче в розділі Захист від клікджекінгу |
TRUSTED_PROXIES |
(не задано) | Довірені мережі зворотних проксі: перелік через кому, що поєднує CIDR, окремі IP-адреси та ключові слова private і cloudflare (наприклад, private,cloudflare). Якщо задано, OxPHP визначає справжню IP-адресу клієнта із заголовків Forwarded (RFC 7239) або X-Forwarded-For за алгоритмом «крайній правий недовірений»; з cloudflare запит, чий ланцюжок переспрямування показує, що він надійшов з edge-вузла Cloudflare, бере її натомість із CF-Connecting-IP. Також обробляє X-Forwarded-Proto і X-Forwarded-Host для $_SERVER['HTTPS'], REQUEST_SCHEME, SERVER_NAME і SERVER_PORT. Не задано — функцію вимкнено |
PHP_DENY_PATHS |
(не задано) | Glob-шаблони через кому, .php-файли за якими ніколи не повинні виконуватися через прямий URI (наприклад, /uploads/**,/cache/**,/admin/legacy.php). Шаблони можуть охоплювати цілі каталоги або окремі файли. Діє в режимах прямого зіставлення — Traditional і SPA; у режимах Framework і Worker, які ніколи не виконують довільні .php-файли напряму, ігнорується з попередженням на старті. Також охоплює скрипти, досягнуті через розв’язання індексу каталогу (/uploads/ → uploads/index.php). Для прямих .php-URI зіставлення відбувається до звернення до диска, тож заборонені шляхи дають ту саму відповідь незалежно від того, чи існує файл (жодного оракула існування). Стара назва PHP_DENY_DIRS приймається як застарілий псевдонім і пише на старті WARN. Див. Список заборони виконання PHP |
PHP_DENY_FALLBACK |
404 |
Що повертати в разі збігу з PHP_DENY_PATHS або з правилом заборони .oxphpdeny. Або HTTP-статус 400–599 (разом з ERROR_PAGES_DIR для власного HTML), або шлях URI з префіксом / до запасного PHP-скрипту всередині DOCUMENT_ROOT. Запасний скрипт отримує OXPHP_DENIED_PATH і OXPHP_DENIED_PATTERN у $_SERVER. Перевіряється на старті: скрипт має існувати, після канонікалізації лежати всередині DOCUMENT_ROOT і сам не повинен збігатися з PHP_DENY_PATHS (захист від циклу). Правило .oxphpdeny може охоплювати сам скрипт; у режимі воркерів правила заборони .oxphpdeny приймають лише код статусу. Див. Правила заборони шляхів |
SYMLINK_ALLOW_PATHS |
(не задано) | Список абсолютних шляхів через кому, у межах яких символьним посиланням дозволено виходити за DOCUMENT_ROOT. Кожен запис має вже існувати на диску; відносні й відсутні шляхи зупиняють запуск. Не задано — виходи символьних посилань заборонено. Див. Дозволені шляхи для символьних посилань |
Спеціальне значення private розгортається в усі приватні мережі RFC-1918, loopback і link-local адреси (IPv4 та IPv6): 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, ::1/128, fc00::/7, fe80::/10.
Захист від клікджекінгу
Клікджекінг — це атака, під час якої ворожа сторінка вбудовує ваш сайт у невидимий <iframe> і хитрощами змушує користувача натиснути те, чого він не бачить: кнопку «так, видалити мій обліковий запис», покупку в один клік, OAuth-запит «авторизувати». Захист полягає в тому, щоб повідомити браузеру, хто, якщо хтось узагалі, може вбудовувати ваші сторінки у фрейм. Цим керує FRAME_OPTIONS.
Вбудовування регулюють два заголовки — застарілий X-Frame-Options (його розуміють усі браузери) і сучасний Content-Security-Policy: frame-ancestors (який переважає, коли присутні обидва), — тож OxPHP надсилає обидва, і політика діє однаково в старих і нових браузерах. Одне значення FRAME_OPTIONS відповідає відповідній парі:
FRAME_OPTIONS |
X-Frame-Options |
Content-Security-Policy |
Хто може вбудовувати ваші сторінки |
|---|---|---|---|
SAMEORIGIN (типово) |
SAMEORIGIN |
frame-ancestors 'self' |
Лише сторінки того самого походження |
DENY |
DENY |
frame-ancestors 'none' |
Ніхто, навіть ваші власні сторінки |
off |
(не надсилається) | (не надсилається) | Будь-хто — сервер не обмежує вбудовування |
Вибір значення. SAMEORIGIN — типове: воно блокує вбудовування з іншого походження, на яке насправді спирається клікджекінг, і водночас дозволяє вашим власним сторінкам вбудовувати одна одну — що багато застосунків роблять цілком законно (попередній перегляд в адмінці, віджети дашбордів, платіжні компоненти на тому самому походженні). Обирайте DENY, коли на вашому сайті ніщо й ніколи не має вбудовуватися у фрейм, навіть самим сайтом, — це найсуворіший варіант. Обирайте off лише тоді, коли керуєте вбудовуванням самі через повноцінний Content-Security-Policy, який встановлює ваш застосунок, — див. нижче.
Вбудовування зовнішніми походженнями. Жодне зі значень X-Frame-Options не може назвати конкретне дозволене походження (ALLOW-FROM вилучено зі стандарту). Щоб дозволити названій третій стороні вбудовувати ваші сторінки, задайте FRAME_OPTIONS=off і нехай ваш застосунок надсилає власний Content-Security-Policy з явним списком frame-ancestors, наприклад header("Content-Security-Policy: frame-ancestors 'self' https://partner.example.com");.
Заголовки застосунку мають перевагу. Серверні заголовки — це запасний варіант, який застосовується лише тоді, коли відповідь не містить такого заголовка: застосунок, що встановлює власний X-Frame-Options або Content-Security-Policy через PHP header(), зберігає його недоторканим. Обидва заголовки вбудовування трактуються як одна політика, тож сервер ніколи не суперечить застосунку:
- Якщо ваш застосунок встановлює
X-Frame-Options, OxPHP пропускає свій запаснийframe-ancestors(серверний CSP переважив би вибір вашого застосунку в сучасних браузерах). - Якщо ваш застосунок встановлює
Content-Security-Policy, що містить директивуframe-ancestors, OxPHP пропускає свій запаснийX-Frame-Options(суворіший сервернийX-Frame-Optionsблокував би зайве в застарілих браузерах, які ігнорують CSP).
Те саме правило пріоритету стосується X-Content-Type-Options, який OxPHP встановлює в nosniff у кожній відповіді: значення, встановлене застосунком, зберігається дослівно. Зауважте, що nosniff — єдине значення, яке щось робить: застосунок, що перевизначає його будь-чим іншим, мовчки вимикає захист від MIME-sniffing.
TLS
| Змінна | Типове значення | Опис |
|---|---|---|
TLS_CERT |
(не задано) | Шлях до TLS-сертифіката в кодуванні PEM. Щоб увімкнути TLS, мають бути задані і TLS_CERT, і TLS_KEY |
TLS_KEY |
(не задано) | Шлях до приватного TLS-ключа в кодуванні PEM |
TLS_MIN_VERSION |
1.2 |
Мінімальна прийнятна версія протоколу TLS: 1.2 або 1.3. Перевіряється на старті (і командою oxphp config --check), навіть коли TLS не ввімкнено, — будь-яке інше значення, включно з байтами не в UTF-8, є фатальною помилкою запуску. Порожнє значення трактується як незадане |
HTTP/2
| Змінна | Типове значення | Опис |
|---|---|---|
H2_MAX_CONCURRENT_STREAMS |
PHP_WORKERS_MAX × 4 (мін. 32) |
Максимальна кількість одночасно відкритих потоків на одне HTTP/2-підключення |
H2_MAX_PENDING_RESET |
20 |
Максимальна кількість кадрів RST_STREAM у черзі, після якої підключення закривається (захист від Rapid Reset) |
H2_MAX_HEADER_LIST_BYTES |
65536 |
Максимальний загальний обсяг декодованих заголовків на запит у байтах |
H2_KEEPALIVE_INTERVAL_SECS |
20 |
Секунди між кадрами HTTP/2 PING; 0 вимикає |
H2_KEEPALIVE_TIMEOUT_SECS |
10 |
Скільки секунд чекати на відповідь на PING, перш ніж закрити підключення |
Статичні файли
| Змінна | Типове значення | Опис |
|---|---|---|
STATIC_MAX_AGE |
30d |
Cache-Control: max-age для статичних файлів. Приймає: 30s, 5m, 2h, 30d, 1w, 1y, просто секунди (3600) або off, щоб вимкнути заголовок. Замінює застарілий STATIC_CACHE_TTL. |
STATIC_REVALIDATE |
off |
Булева — див. Булеві значення. Задайте істинне значення, щоб увімкнути повторну перевірку mtime для кешу вмісту в пам’яті: час модифікації файлу перевіряється щонайбільше раз на 3 секунди для кожного файлу (не для кожного запиту), а застарілі записи вилучаються автоматично, тож зміни стають видимими протягом 3 секунд. Замінює застарілий STATIC_CACHE (де off мало протилежне значення). |
Стиснення
| Змінна | Типове значення | Опис |
|---|---|---|
COMPRESSION_ENCODINGS |
br,zstd,gzip |
Кодування вмісту, які пропонує сервер, через кому: br (або brotli), zstd, gzip. Невідома назва — помилка запуску. Задайте все значення як off (або none), щоб повністю вимкнути стиснення. Це не елемент списку, тож br,off — помилка, а не перемикач. Порожнє присвоєння відновлює типове значення. Порядок, указаний тут, ігнорується: кешовані статичні файли віддають перевагу Brotli, вартість якого сплачується один раз, а все інше — Zstandard. Див. Стиснення |
COMPRESSION_BROTLI_LEVEL |
5 |
Якість Brotli (від 0 до 11). Замінює застарілий COMPRESSION_LEVEL |
COMPRESSION_ZSTD_LEVEL |
6 |
Рівень стиснення Zstandard (від 0 до 19) |
COMPRESSION_GZIP_LEVEL |
6 |
Рівень стиснення Gzip (від 0 до 9) |
COMPRESSION_LEVEL |
(не задано) | Застаріла назва COMPRESSION_BROTLI_LEVEL; пише на старті WARN. COMPRESSION_LEVEL=0 зберігає своє давнє значення й вимикає всі кодування, а не лише Brotli. Явний COMPRESSION_BROTLI_LEVEL має перевагу над ненульовим значенням, але не над 0, яке вимикає стиснення, хоч би що ще було задано |
Кодування пропонується, коли COMPRESSION_ENCODINGS його містить і його рівень не 0; будь-якої з цих умов окремо досить, щоб його прибрати. Рівень поза своїм діапазоном або нечислове значення зупиняє запуск з помилкою, у якій названо змінну. Значення не обрізаються до меж діапазону.
Логування
| Змінна | Типове значення | Опис |
|---|---|---|
LOG_LEVEL |
info |
Детальність логу: trace, debug, info, warn, error |
ACCESS_LOG |
(не задано) | Лог доступу для кожного запиту: all — кожен запит, error — лише 4xx/5xx, не задано — вимкнено |
ACCESS_LOG приймає all або error. Залиште його незаданим, щоб повністю вимкнути лог доступу.
Клієнт, що закриває підключення до того, як сервер відповів, логується на рівні debug, а не error. Це найпоширеніша подія, яку бачить публічний слухач (скасований fetch, закрита вкладка, проксі, у якого спрацював тайм-аут читання), і вона не є збоєм сервера, тож за типового LOG_LEVEL її не видно. Рахуйте такі події через oxphp_request_cancelled_total{reason="client_abort"} або підніміть LOG_LEVEL=debug, щоб бачити окремі підключення. Тайм-аути, які контролює сам сервер (HEADER_TIMEOUT_SECONDS), лишаються на рівні warn, а всі інші збої підключень — на рівні error.
Вони незалежні. LOG_LEVEL задає детальність власної діагностики сервера, тож LOG_LEVEL=warn з ACCESS_LOG=error повідомляє про попередження й помилки і логує кожен запит 4xx/5xx. Щоб вимкнути лише лог доступу, потрібна директива RUST_LOG, що називає його ціль: RUST_LOG=warn,access_log=off, де warn замінює той LOG_LEVEL, з яким ви працювали. RUST_LOG, якщо його задано й він коректно розбирається, замінює LOG_LEVEL, а не нашаровується на нього. Див. Логування доступу.
Спостережуваність
| Змінна | Типове значення | Опис |
|---|---|---|
INTERNAL_ADDR |
(не задано) | Адреса внутрішнього сервера (/health, /metrics, /config). Якщо не задано, внутрішній сервер не запускається. Значення лише з портом (:9090) прив’язується до 127.0.0.1; щоб відкрити його за межі хоста, явно вкажіть 0.0.0.0:9090 |
INTERNAL_ALLOW_IPS |
(не задано) | Список дозволених CIDR/IP через кому для внутрішнього сервера. Клієнт поза списком отримує 403 на /metrics, /config і шляхах плагінів; health-перевірки (/health, /healthz, /readyz, /startupz та їхні довгі форми) лишаються доступними. Не задано/порожньо — дозволено всім. Loopback не додається неявно — вкажіть 127.0.0.1/32, щоб зберегти доступ із localhost. Некоректний список зупиняє запуск |
ERROR_PAGES_DIR |
(не задано) | Каталог із власними сторінками помилок на ім’я {status}.html (наприклад, 404.html, 503.html) |
MAX_QUERY_BODY |
524288 |
Максимальний розмір тіла запиту в байтах для внутрішніх ендпоінтів запитів (512 KiB) |
TRACE_CONTEXT |
false |
Булева — див. Булеві значення. Якщо істинне, вмикає поширення W3C Trace Context: читає заголовки traceparent/tracestate і передає їх у PHP через $_SERVER |
OpenTelemetry
| Змінна | Типове значення | Опис |
|---|---|---|
OTEL_ENABLED |
false |
Вмикає експорт спанів OpenTelemetry. Автоматично встановлює TRACE_CONTEXT=true. Булева — див. Булеві значення |
OTEL_EXPORTER_OTLP_PROTOCOL |
grpc |
Протокол експорту: grpc або http/protobuf |
OTEL_EXPORTER_OTLP_ENDPOINT |
http://localhost:4317 (gRPC) або http://localhost:4318 (HTTP) |
Ендпоінт OTLP-колектора. З http/protobuf це базовий URL: спани надсилаються на /v1/traces під ним |
OTEL_EXPORTER_OTLP_TIMEOUT |
10000 |
Тайм-аут експорту в мілісекундах. Через gRPC він також обмежує підключення до колектора, включно з TLS-рукостисканням, тож тримайте його більшим за час, якого це потребує; спроба, якій спершу потрібно підключитися, може тривати до подвоєного тайм-ауту. Через gRPC у Linux він також визначає, як швидко закривається підключення, що перестало приймати дані, — див. Помилки експорту спанів у журналі |
OTEL_EXPORTER_OTLP_HEADERS |
(не задано) | Заголовки автентифікації: key=value,key2=value2 |
OTEL_SERVICE_NAME |
oxphp |
Назва сервісу в експортованих спанах |
OTEL_SERVICE_VERSION |
(не задано) | Атрибут версії сервісу |
OTEL_RESOURCE_ATTRIBUTES |
(не задано) | Додаткові атрибути ресурсу: env=prod,region=us-east-1 |
OTEL_TRACES_SAMPLER |
parentbased_traceidratio |
Стратегія семплювання: always_on, always_off, traceidratio, parentbased_always_on, parentbased_always_off, parentbased_traceidratio |
OTEL_TRACES_SAMPLER_ARG |
1.0 |
Частка семплювання (0.0–1.0) для семплерів на основі частки |
Некоректні або позадіапазонні значення OTEL_TRACES_SAMPLER_ARG обрізаються до [0.0, 1.0] і логуються на рівні warn. Невідомі значення OTEL_TRACES_SAMPLER повертаються до parentbased_traceidratio і логуються.
APM
| Змінна | Типове значення | Опис |
|---|---|---|
OTEL_APM_ENABLED |
false |
Вмикає APM: автоматичне інструментування, перехоплення помилок і PHP SDK для трасування. Потребує OTEL_ENABLED=true. Булева — див. Булеві значення |
OTEL_APM_SLOW_QUERY_MS |
100 |
Поріг повільного запиту в мілісекундах. Запити до бази даних, що його перевищують, отримують атрибут спану oxphp.db.slow=true |
OTEL_APM_DB_CAPTURE_PARAMS_ENABLED |
false |
Записувати параметри прив’язки в атрибут спану db.params. Вимикайте в продакшні, якщо параметри можуть містити чутливі дані. Булева — див. Булеві значення |
OTEL_APM_STACKTRACE_MAX_BYTES |
8192 |
Максимальний розмір атрибута exception.stacktrace у байтах. Понад межу стектрейс обрізається з кінця з маркером …(truncated). 0 вимикає обрізання |
OTEL_APM_MESSAGE_MAX_BYTES |
4096 |
Максимальний розмір атрибута exception.message у байтах (типове значення відповідає ліміту New Relic на значення атрибута). Понад межу повідомлення обрізається з кінця з маркером …(truncated). 0 вимикає обрізання |
Коли APM увімкнено, OxPHP автоматично перехоплює 34 внутрішні функції PHP (PDO, mysqli, cURL, Redis, Memcached, файлове введення-виведення), щоб створювати дочірні спани. PHP-функції oxphp_apm_*() реєструються незалежно від того, чи ввімкнено APM. Коли його вимкнено, функції спанів нічого не роблять і повертають контрольні значення. Виняток — oxphp_apm_trace(): вона все одно виконує свій колбек і повертає його значення, тож вимкнення APM ніколи не змінює того, що обчислює ваш застосунок.
Асинхронні воркери
| Змінна | Типове значення | Опис |
|---|---|---|
ASYNC_WORKERS |
0 (вимкнено) |
Кількість виділених потоків асинхронних воркерів. Коли 0, асинхронні функції (oxphp_async тощо) зареєстровано, але під час виклику вони кидають OxPHP\Async\AsyncException. Задайте додатне значення, щоб увімкнути виконання фонових завдань |
ASYNC_QUEUE_CAPACITY |
ASYNC_WORKERS × 64 |
Максимальна кількість завдань, що очікують в асинхронній черзі. 0 — автоматично (воркери × 64) |
ASYNC_MAX_FIBERS |
256 |
Обмеження на кількість одночасних файберів асинхронних завдань на один воркер. Глобальний для процесу ліміт завдань в обробці (у черзі + виконуються) дорівнює ASYNC_MAX_FIBERS × ASYNC_WORKERS; диспетчеризація понад нього негайно відхиляється з OxPHP\Async\AsyncException, тож композиція fan-out не може спричинити взаємне блокування |
Пул асинхронних воркерів обробляє фонові завдання в режимі «запустив і забув», що диспетчеризуються з PHP. Він окремий від пулу PHP-воркерів і не потрібен для звичайної обробки запитів.
Некоректне значення в будь-якій із цих трьох змінних (наприклад, ASYNC_WORKERS=8x) — помилка запуску: повернення до типового значення мовчки вимкнуло б пул або налаштувало його неправильно. Точно порожнє значення трактується як незадане.
Хуки середовища виконання
| Змінна | Типове значення | Опис |
|---|---|---|
RUNTIME_HOOKS |
(вимкнено) | Опційна заміна блокувальних вбудованих функцій PHP реалізаціями, що призупиняють файбер. 1/true/all вмикає всі категорії хуків; список через кому вмикає окремі категорії (наприклад, RUNTIME_HOOKS=sleep,streams) |
| Категорія | Що перехоплює |
|---|---|
sleep |
Нативні sleep() і usleep() призупиняють поточний файбер точно так, як oxphp_sleep()/oxphp_usleep(), зокрема в тих контекстах, де ці дві теж не можуть призупинитися й натомість чекають на потоці воркера (див. Де хуки sleep не призупиняють виконання) |
streams |
Два очікування призупиняють поточний файбер замість того, щоб займати потік воркера: блокувальне читання з потоку сокета tcp:// і stream_select(). Читання охоплює клієнтів, що блокуються на одному сокеті, — fsockopen(), stream_socket_client(), обгортки потоків HTTP, mysqlnd (PDO_MySQL, mysqli), phpredis; stream_select() охоплює цикли, що чекають на кількох одразу. Змінювати код не потрібно в жодному з випадків. Клієнтів, що чекають якось інакше, це не стосується (див. нижче) |
net |
Очікування нижче рівня PHP-потоків, де хук читання streams їх не бачить, призупиняють поточний файбер замість того, щоб займати потік воркера: встановлення підключення через stream_socket_client(), fsockopen() і кожен клієнт, побудований на PHP-потоках (серед них mysqlnd, тобто PDO_MySQL і mysqli, та phpredis), TLS-рукостискання, читання з потоків ssl:///tls:// після активації шифрування, записи, яким доводиться чекати на місце в буфері відправлення, і читання з потоків на сокетах unix://, udp:// і udg://, на потоках stream_socket_pair() і на потоках, які socket_export_stream() створює із сокетів, що не є TCP. Потребує, щоб поруч було вказано streams, і працює лише в Linux (див. Коли net лишається вимкненим). Не охоплено: очікування всередині libcurl, libpq (pgsql, pdo_pgsql) і libmemcached, які викликають poll() із власних бібліотек; розв’язання DNS; stream_socket_recvfrom(); записи на звичайному (не TLS) потоці з нескінченним тайм-аутом (default_socket_timeout=-1); і встановлення постійних підключень (pfsockopen(), STREAM_CLIENT_PERSISTENT і постійні режими клієнтів, побудованих на них) |
Хуки діють усередині файберів запитів у режимі воркерів і файберів асинхронних завдань. Поза файбером (контекст запиту в режимах traditional/framework/SPA, CLI) зберігається оригінальна нативна поведінка, включно з помилками перевірки аргументів. Коли хуки sleep увімкнено, сторонній код, що викликає sleep(), перестає займати потік воркера скрізь, де файбер можна призупинити (а це не скрізь; див. нижче), без жодних змін у коді. Скасування асинхронного завдання під час перехопленого sleep розмотує його з OxPHP\Async\AsyncException, а перехоплений sleep(), що призупиняється, повертає 0; значення, яке нативна функція повертає в разі переривання сигналом, виникає лише на блокувальному шляху нижче.
Як перевірити, що налаштування підхопилося
Сервер повідомляє встановлені категорії, у вигляді розібраного набору, у двох місцях. На внутрішньому сервері:
$ curl -s localhost:9090/config | jq .runtime_hooks
[
"sleep",
"streams",
"net"
]І з PHP — для розгортання, чий внутрішній сервер недоступний або не ввімкнений:
<?php
echo json_encode(oxphp_server_info()['runtime_hooks']); // ["sleep","streams","net"]Обидва читають те саме значення й повідомляють набір, а не рядок: RUNTIME_HOOKS=all, RUNTIME_HOOKS=1 і RUNTIME_HOOKS=sleep, streams, net усі повертаються як ["sleep","streams","net"]. Порожній список означає, що жодного хука не встановлено. Таку відповідь ви отримаєте для незаданої змінної, для RUNTIME_HOOKS=0, для значення, що називає лише категорії, невідомі цій збірці, і для змінної з помилкою в назві. Значення, що змішує одне з іншим, — інша справа: RUNTIME_HOOKS=sleep,bogus читається як ["sleep"], тож непорожній список сам по собі не свідчить, що кожну назву було зрозуміло. Кожна нерозпізнана назва отримує власний рядок у лозі сервера на старті, і цей рядок — єдине місце, де їх названо. Перевіряйте після кожної зміни налаштування: нерозпізнана змінна середовища ніде не є помилкою, тож «помилка в назві» інакше проявиться лише як латентність.
Список повідомляє, у яких вбудованих функцій замінено обробники, а це відбувається на старті незалежно від режиму маршрутизації. Там, де файберів немає взагалі, заміни делегують обробникам, які вони замінили, тож сервер у традиційному режимі повідомляє ["sleep","streams","net"], поводячись при цьому повністю нативно. Процес php CLI повідомляє ["sleep","streams"]: рушій є частиною цього виконуваного файлу, тож net не може відрізнити очікування PHP від власних очікувань програми й лишається вимкненим. Читайте список як «цей процес налаштований для хуків», а режим маршрутизації — як відповідь на те, чи може щось ними скористатися.
У межах streams усе ще вужче: частина, що стосується читання із сокета, може окремо не встановитися на платформі, де таблицю операцій потоку неможливо зробити доступною для запису, тоді як stream_select() і точки входу бази даних лишаються перехопленими. Категорія повідомляється в будь-якому разі; єдиний сигнал — рядок у лозі сервера про те, що читання із сокета лишаються блокувальними. Цей рядок, як і рядок про нерозпізнану категорію та рядки, які пише net (див. нижче), підпорядковується логуванню помилок PHP: коли error_log не задано (типово), він потрапляє у власний лог сервера як warn, а LOG_LEVEL=error його приховує.
Коли net лишається вимкненим
net повідомляється лише тоді, коли він справді діє. Вказаний без streams, він лишається вимкненим, і про це каже рядок у лозі сервера. На платформі, що не є Linux, там, де власний код сервера неможливо відрізнити від коду PHP на старті, або там, де poll() PHP не прив’язаний до того, який перехоплює сервер, він лишається вимкненим із рядком про те, що встановлення підключень і очікування TLS лишаються блокувальними. Тож net, відсутній у списку після того, як ви його запросили, — це і є відповідь, а не прогалина у звіті.
Єдине, чого список не каже, — де живе OpenSSL. Очікування, які робить OpenSSL (TLS-рукостискання, включно з тим, яким завершується підключення ssl://, а також читання й записи на TLS-потоці після активації шифрування), паркуються лише тоді, коли OpenSSL вбудовано в PHP, як в офіційних образах. Якщо OpenSSL завантажено як окреме спільне розширення (extension=openssl), ці очікування, наскільки може судити сервер, не належать самому PHP, тож вони займають потік воркера, тоді як встановлення підключень, unix:// та інші читання все одно призупиняються, а net усе одно повідомляється.
Де хуки sleep не призупиняють виконання
Там, де перемикання файбера заборонено, перехоплені sleep()/usleep() передають виклик вбудованій функції, яку вони замінили, а та чекає на потоці воркера й повертає те, що повертає. Тож sleep(), перерваний там сигналом, повертає кількість секунд, які він не доспав, як і поза файбером. (Перевірка аргументу в будь-якому разі належить хуку: він відхиляє будь-що більше за 4294967295, що неперехоплена вбудована функція приймає й обрізає.) Перемикання заблоковано всюди, де вихід із поточного фрейму був би небезпечним, і перелік нижче не вичерпний:
- усередині обробника
declare(ticks)і всередині диспетчеризації сигналів pcntl (в обох випадках це справа самого PHP); - поки будуються вхідні дані запиту;
- усередині
FILTER_CALLBACK, досягнутого зfilter_input_array(), якому передано масив визначень для окремих полів (див. примітку під$_GETу розділі Суперглобальні змінні); - усередині обходів під час демонтажу, які запускають деструктори після того, як запит зазнав збою (після фатальної помилки або після запису в уже скасований запит), тож
__destruct(), що засинає для затримки перед повтором, робить це, утримуючи потік.
Звичайне завершення запиту до них не належить: деструктори й функції завершення виконуються там із дозволеним перемиканням. oxphp_sleep() і oxphp_usleep() у кожному з цих місць теж ідуть блокувальним шляхом, тож звернення до них не є способом це обійти.
Файбер, запущений планувальником у просторі користувача (AMPHP, Revolt або простий new Fiber), — інший випадок: перемикання там не заблоковано, і планувальник нормально призупиняє та відновлює власні файбери, але OxPHP не може відновити контекст, який йому не належить, тож хук відступає з тієї самої причини, що й хук streams. Ідеться про файбер, запущений усередині запиту; запит у режимі воркерів сам є файбером, яким керує OxPHP, тож хуки до нього застосовуються нормально. Код усередині файбера, який запустив сам застосунок, спить на потоці воркера, і цю частку навантаження слід планувати так, ніби хук вимкнено.
Що охоплює хук streams
Хук streams робить кооперативними блокувальне читання з PHP-потоку сокета й очікування всередині stream_select(). Перш ніж на нього розраховувати, перевірте, що ваш клієнт чекає одним із цих двох способів або одним зі способів, які додає категорія net (нижче). Кілька поширених клієнтів так не роблять, і для них нічого не змінюється:
-
ext/curl.
curl_exec()іcurl_multi_*працюють із сокетами самостійно, нижче рівня PHP-потоків, тож хук їх ніколи не бачить. Це стосується кожного HTTP-клієнта, побудованого на curl, — серед них Guzzle з обробником за замовчуванням. Клієнта на curl можна перемкнути на обробник обгортки потоків, який таки проходить через PHP-потоки. -
socket_select(). Це ext/sockets, інший API на сирих дескрипторах, і його не перехоплено.stream_select()— перехоплено. Те саме стосується очікування всерединіstream_socket_accept(), яке теж не перехоплено. -
Потоки
unix://,udp://іudg://, пари сокетів, потоки зsocket_export_stream()на сокетах, що не є TCP, а такожssl:///tls://після активації шифрування. Їхні таблиці операцій приватні для PHP або для OpenSSL, тож хукstreamsдо них не дістається. Категоріяnet— дістається: коли її вказано, читання потоку (fread(),fgets()тощо) на будь-якому з них призупиняє файбер так само, як наtcp://. Безnetвони, як і раніше, тримають потік воркера (докиstream_socket_enable_crypto()не завершиться успішно, читання SSL-потоку делегуються звичайному читанню із сокета й призупиняються в будь-якому разі).stream_socket_recvfrom()— це не читання потоку: вона викликаєrecvfrom()напряму, без власного очікування PHP, яке можна було б призупинити, тож на сокетіudp://чиudg://вона тримає потік воркера, доки не надійде датаграма, навіть ізnet.socket_export_stream()створює потік того транспорту, який використовує сокет: UDP-, unix- або датаграмний unix-сокет стає потокомudp://,unix://абоudg://, TCP-сокет — звичайним потокомtcp://(який уже охоплює сам лишеstreams), а сокет будь-якого іншого типу — загальним потоком, який має пара сокетів. -
Підключення й розв’язання DNS. З
netвстановлення підключення через транспорти потоків PHP призупиняє файбер:stream_socket_client(),fsockopen()і клієнти, побудовані на них.ftp_connect()іsocket_connect()підключаються самостійно, поза рівнем потоків, і не охоплені. Розв’язання DNS теж не призупиняється: ім’я хоста розв’язується на потоці воркера ще до початку підключення, тож повільний резолвер цей потік тримає. Клієнт із власною мережевою бібліотекою (pdo_pgsqlіpgsqlчерез libpq, розширенняmemcachedчерез libmemcached) підключається й чекає всередині цієї бібліотеки, якої не бачить жодна з категорій. Клієнт, що тримає підключення відкритим, у будь-якому разі виграє на кожному запиті.Встановлення постійного підключення — виняток, і воно охоплює більше, ніж
pfsockopen()іSTREAM_CLIENT_PERSISTENT: PDO_MySQL зPDO::ATTR_PERSISTENT, mysqli з префіксом хостаp:і phpredis ізpconnect()теж відкривають свої потоки як постійні, і встановлення їхніх підключень тримає потік воркера на час очікування, як і до появиnet. Причина — список постійних ресурсів. Постійний потік заноситься до нього під своїм ключем ще до підключення, тож другий файбер, що запитує той самий ключ, поки перший чекає, знайшов би потік без дескриптора, прийняв би його за мертве підключення й закрив би його під файбером, який ще підключається. Деякі клієнти дають кожному підключенню власний ключ (mysqlnd так і робить), і таким способом ці потоки ніхто не знайде, але сервер вирішує за тим, чи є потік постійним, а не за тим, хто міг би його знайти, тож вони теж тримають потік воркера. Вартість — це встановлення підключення, яке постійне підключення сплачує один раз, коли його вперше відкривають, а не читання й записи на ньому. -
MySQL через
localhost. Клієнт MySQL читаєlocalhostяк «використай unix-сокет», а це не потікtcp://, тож хукstreamsйого не бачить. Пишіть у DSN127.0.0.1або вкажітьnet, який охоплює потокиunix://. Це стосується і PDO_MySQL, і mysqli, і це легко пропустити, бо все й далі працює, просто без виграшу. -
Записи. Лише з
streamsчекають тільки читання. Готовність до читання стабільна, доки дескриптором володіє один файбер і ніщо інше його не вичитує, тоді як місце в буфері відправлення надає й забирає віддалена сторона, тож файбер, розбуджений за готовністю до запису, може виявити, що вікно знову закрилося на момент виконання запису, після чого PHP блокує потік на весь свій тайм-аут у будь-якому разі. Тому запис, що заповнює буфер сокета, поводиться точно так, як без хука. На практиці це коштує небагато: час забирає очікування відповіді, а не передавання запиту ядру. (Записи все ж переглядаються, але лише заради одного: щоб знати, обмін якого файбера зараз несе підключення, — див. примітку про спільні підключення нижче. На підключенні, яким не користується жоден інший файбер, — а у звичайному випадку це кожне підключення, — запис іде нативним шляхом без змін.)З вказаним
netзапис, якому доводиться чекати на місце, теж призупиняє файбер, а дескриптор перевіряється знову після кожного пробудження, тож вікно, що тим часом закрилося, коштує ще одного призупинення, а не потоку воркера. Виняток — звичайний потік із нескінченним тайм-аутом (default_socket_timeout=-1): PHP надсилає на нього блокувальнимsend()і ніколи не чекає сам, тож призупиняти нічого, і запис тримає потік воркера так само, як без хука. TLS-потік із нескінченним тайм-аутом — інша справа: власний цикл OpenSSL чекає вpoll()без обмеження, і це очікування призупиняється.
У цих межах хук зберігає нативний контракт: тайм-аути сокетів (stream_set_timeout(), default_socket_timeout) діють без змін, читання з вичерпаним тайм-аутом, як і раніше, повідомляє timed_out через stream_get_meta_data(), а ідентичність потоку не змінюється, тож socket_import_stream() і подібні функції працюють далі. Одне обмеження щодо тайм-ауту: дедлайн перевіряється лише раз на такт планувальника, тож спрацьовує не раніше за наступний такт — у найкращому разі через 100 мкс у режимі воркерів і 50 мкс в асинхронному пулі, а під навантаженням довше, бо такт триває стільки, скільки виконується те, що зараз виконує воркер. Асинхронний пул робить довші паузи, коли нічого не відбувається, але не довші за дедлайн, який він тримає: дедлайн читання чи запису скорочує паузу так, щоб вона закінчилася саме на ньому, тож довше очікування не робить тайм-аут пізнішим. З default_socket_timeout у 60 секунд більшість розгортань цього не помітять.
stream_select() перехоплюється так: хук чекає на дескриптори, названі в трьох його масивах, а тоді передає виклик PHP з тайм-аутом, встановленим у нуль, тож PHP, як і раніше, вирішує все, що ви можете спостерігати: кількість, що повертається, переписування масивів до готових потоків, попередження й помилки аргументів. Очікування пропускається — і виклик виконується точно так, як без хука, — щоразу, коли масиви містять щось, чого хук не бере на себе: потік читання з уже буферизованими даними (на який stream_select() відповідає з буфера, не дивлячись на дескриптор), потік, що взагалі не має дескриптора, елемент, який не є живим потоком (скажімо, закритий потік, залишений у масиві, — PHP відповідає на це помилкою, а не очікуванням), дескриптор, за готовністю якого ядро взагалі не стежитиме (звичайний файл — типовий випадок, і він вважається готовим тієї миті, коли ви питаєте), або дескриптор із номером від FD_SETSIZE і вище, який власний select() PHP відхиляє одразу. Остання межа — реальна стеля, а не формальність: зайнятий воркер може тримати понад 1024 відкриті дескриптори, і stream_select(), що називає один із них, завершується помилкою однаково з хуком і без нього.
Спільні підключення між конкурентними файберами
Одне підключення, спільне для конкурентних файберів, безпечне для клієнтів, яких захищає OxPHP, і нічого не дає. Це звичайна форма застосунку в режимі воркерів, а не крайній випадок: WordPress, Laravel і Symfony відкривають клієнтів бази даних і кешу один раз під час завантаження воркера й передають ті самі клієнти кожному запиту, і, не переписавши їхній рівень доступу до даних, від них не можна вимагати окремого підключення для кожного файбера.
Клієнтський протокол — це послідовність обмінів: записати команду, прочитати відповідь, — і на підключенні ніщо не позначає, де один обмін закінчується. Файбер, призупинений на читанні, призупинений посеред обміну, і команда другого файбера, що потрапляє туди, ламає протокол. Два клієнти справляються з цим по-різному, і жоден добре: mysqlnd відстежує стан свого підключення й відхиляє команду, ще нічого не надіславши, тоді як phpredis такої перевірки не має, і два файбери читають відповіді одне одного — дані одного запиту повертаються іншому без жодної помилки.
Тож файбер захоплює підключення, перш ніж ним скористатися, на обох рівнях, де це має відбуватися: на рівні операцій сокета, які тримають байти в порядку, і на рівні клієнтських точок входу PDO та mysqli, бо відмова mysqlnd відбувається до будь-якого введення-виведення, і нічого на рівні сокета не встигне її запобігти. Інший файбер, що звертається до того самого підключення, чекає, доки його не звільнять на рівні клієнта, де утримується лише ідентичність підключення; на рівні сокета він не чекає, а отримує відмову — так само, як відмову через тайм-аут сокета, — бо файбер, призупинений усередині операції на чужому потоці, тримав би вказівник, який власник може звільнити. phpredis теж захищено на рівні клієнта, метод за методом, саме з цієї причини. На рівні клієнта захоплюється саме підключення, а не PHP-об’єкт, що його тримає, тож постійне підключення, досяжне через кілька об’єктів PDO, рахується як одне, а підключення, відкрите через PDO::connect(), — який повертає власний підклас драйвера, а не PDO, — охоплюється, як і будь-яке інше.
Отже, виграш хука належить підключенням, які файбер відкриває для себе: асинхронному завданню, що робить власні HTTP-виклики чи запити до бази даних, запиту, що відкриває власного клієнта. Спільне підключення отримує назад потік воркера на час очікування, тож інші запити можуть виконувати роботу, не пов’язану з цим підключенням; власні ж обміни на ньому виконуються один за одним, точно як із вимкненим хуком. Варто знати чотири межі:
-
Файбер, який хоч раз виконав запит, тримає підключення до кінця свого запиту, бо кінець запиту — перший момент, що гарантовано настає після кінця обміну. Він тримає його й тоді, коли призупинений на чомусь іншому, тож запит, що виконав запит до бази, а тоді чекає на власну роботу, якій потрібне те саме підключення, чекає на самого себе; ці двоє роз’єднуються на межі, описаній нижче, а не просуваються далі.
-
Очікування завжди обмежене: меншим із
max_execution_timeіdefault_socket_timeout. Якщо не задано жодного, межа становить 30 секунд, бо серверний SAPI бере типові значення рушія: 30 для першого й 60 для другого; 60 відdefault_socket_timeoutвона становить лише тоді, колиmax_execution_timeдорівнює 0.max_execution_timeчитається таким, яким його зараз має запит, боset_time_limit()— це спосіб, у який запит заявляє, скільки він може виконуватися;default_socket_timeoutчитається таким, з яким процес стартував, бо це типовий дедлайн операції сокета, і запит, що звужує його для власного виклику (звична для бібліотеки річ, як і забути його повернути), не повинен скорочувати цю межу для запитів, що йдуть за ним на тому самому воркері. Понад межу виклик повертається до незахищеної поведінки й пояснює причину в лозі сервера: для PDO і mysqli його передають клієнтові, чия власна відмова виконати команду посеред обміну — це помилка, яку застосунок уже обробляє, тоді як для phpredis, який такої відмови не має й натомість прочитав би чужу відповідь, кидаєтьсяRedisExceptionі нічого не надсилається.Один виклик PDO — виняток і тут, і для коду, що виконується поза будь-яким файбером (нижче):
PDO::rollBack()на постійному підключенні, яке тримає інший файбер, виявивши на ньому відкриту транзакцію, нічого не надсилає й повертаєfalse, бо ця транзакція належить власникові, іROLLBACK, надісланий між його командами, завершив би її. Помилка, яку він повідомляє так, як велить режим помилок, — це помилка, яку підключення вже зберігає, і вона може бути помилкою власника. Власний демонтаж об’єкта PDO теж прив’язано до власника. Поки запит чи завдання тримає постійне підключення, знищення дескриптора на ньому відкочує відкриту там транзакцію й запускає очищення драйвера наприкінці запиту, лише якщо власник був останнім, хто користувався цим дескриптором (створив його або відтоді викликав через нього один із методів PDO, які захищає OxPHP), хоч би де дескриптор знищувався, включно зі збирачем циклів PHP, що працює в іншому запиті. Будь-який інший дескриптор пропускає обидві дії. На підключенні, яке ніхто не тримає, кожен дескриптор виконує обидві, як і раніше.Конфлікт на рівні сокета не має власної межі, бо ніколи не чекає: операція одразу завершується невдачею так, як за тайм-ауту, тож
stream_get_meta_data()повідомляєtimed_out. Це може коштувати більше, ніж сам виклик: клієнт вільний сприйняти відхилену операцію як щось гірше за невдалу команду, і mysqlnd так і робить з обох боків обміну. Запис, що нічого не надсилає, позначає підключення як втрачене й закриває його, читання, що повертається ні з чим, позначає його втраченим на місці, і кожна наступна команда на ньому отримує відповідь із цього стану, не торкаючись мережі. Втрачається все підключення, а не один виклик, — доти, доки щось тримає дескриптор. Захоплення на рівні клієнта, описане вище, — це те, що взагалі не дає командам захищеного клієнта дійти до такої відмови; протокол, написаний вручну на сирому сокеті, має трактувати відхилену операцію будь-якого типу так само, як закрите підключення. Тож два файбери, кожен з яких тримає те, на що чекає інший, роз’єднуються на цій межі, а не чекають одне на одного вічно.Код, що виконується поза будь-яким файбером, узагалі не може чекати на захоплення, і до нього застосовується лише рівень сокета. У режимі воркерів із
RUNTIME_HOOKS=streamsце насамперед деструктор, який цикл воркера запускає між запитами, коли збирає цикли, поки інші запити призупинені. Операція сокета, яку він виконує і яка мала б чекати, на підключенні, власник якого призупинений на ньому в очікуванні своєї відповіді, одразу завершується невдачею так, як за тайм-ауту, і нічого не надсилає; у будь-який інший момент вона виконується. Рівень клієнта там не застосовується, бо його захоплення триває до кінця запиту власника: воно відмовляло б такому коду в підключенні, яким власник у цей момент не користується, а так буває більшість часу. Чим обертається відмова — справа клієнта, як описано вище. phpredis завершує виклик невдачею, і власник зберігає свою відповідь. mysqlnd або відхиляє команду на підставі власного стану, або, коли обмін власника залишив цей стан готовим, отримує відмову в записі й закриває підключення, і запит власника завершується так, як описано в пункті 4. Тож деструктор — погане місце, щоб узагалі користуватися спільним клієнтом. -
Деякі випадки навмисно не охоплено. Об’єкт запиту чи результату, збережений між запитами, перед яким не було захопленого виклику:
PDOStatement::execute()на запиті, підготовленому в попередньому запиті, поводиться так, наче захоплення немає взагалі, і це коштує більше, ніж сам виклик: доходячи до сокета, поки будь-який файбер призупинений на підключенні, він отримує там відмову, а для mysqlnd відмова означає втрату підключення, як описано вище, — і на читанні, що отримує наступний рядок небуферизованого результату, і на записі, що надсилає команду. Протокол, написаний вручну на сирому сокеті (записати команду, призупинитися на чомусь іншому, прочитати відповідь пізніше), бо рівень сокета відхиляє лише тоді, коли власник призупинений на самій відповіді, а між цими двома точками підключення перехоплює інший файбер; три клієнти вище захищені тут своїм захопленням на рівні клієнта, якого протокол, написаний вручну, не має.Постійні підключення охоплено; див. Постійні підключення PDO нижче.
-
Закриття спільного підключення, поки інший запит призупинений на читанні з нього, завершує той запит із
500і рядком у лозі, що пояснює, що сталося. Захоплення розводить два обміни; воно не може зберегти підключення живим, а відповідь призупиненого запиту — на підключенні, якого вже не існує, тож чесна відповідь — завершити його, а не повернути те, що тепер лежить у звільненій пам’яті. Які виклики можуть до цього призвести, залежить від клієнта, і чекає лише один із них:mysqli::close()іmysqli_close()— захоплені виклики, тож вони чекають на власника й закривають підключення, щойно це очікування вичерпано, тоді якRedis::close()теж захоплений, але кидаєRedisExceptionзамість закриття. Усе інше закриває одразу —fclose()на сирому потоці і, що важливо, PDO, у якого взагалі немає методуclose(): підключення звільняється, коли зникає останнє посилання на дескриптор (unset($pdo), повторне присвоєння або вихід з області видимості), а цей шлях виконується всередині власного демонтажу об’єктів рушія, де жодне захоплення не враховується перед закриттям підключення. Тож допоміжна функція перепідключення, що повторно присвоює спільний дескриптор PDO, завершує запити, призупинені на старому підключенні, без обмеженого очікування, яке отримує її еквівалент дляmysqli. Запит дізнається про це одразу під час закриття, а не на власному дедлайні читання, яким для mysqlnd був биmysqlnd.net_read_timeout— за замовчуванням доба. Застосунки, що закривають спільне підключення, щоб примусово перепідключитися (звичайний приклад —$wpdb->check_connection()у WordPress), мають очікувати, що запити, призупинені на ньому в цей момент, завершаться помилкою, а не повернуть хибні дані.
Ще одна межа: хук streams під планувальником файберів у просторі користувача (AMPHP, Revolt) повертається до блокувального введення-виведення. Файбер, запущений таким планувальником, працює у власному контексті, який планувальник OxPHP не може відновити, тож хук це виявляє й іде нативним шляхом, а не псує жоден із планувальників. Ідеться про файбер, який планувальник у просторі користувача запускає всередині запиту. Власний файбер запиту — той, яким керує OxPHP, і в режимі воркерів це справжній Fiber (Fiber::getCurrent() усередині запиту повертає його), тож бібліотеки, яким потрібно лише розрізняти конкурентні запити, працюють без жодного запасного шляху. З тієї самої причини Revolt відмовляється запускати свій цикл подій зсередини запиту в режимі воркерів.
Постійні підключення PDO
PDO::ATTR_PERSISTENT тримає підключення драйвера в пулі, спільному для всього воркера, і PDO звертається до цього пулу у два кроки, які не витримують перемежовування: шукає підключення в пулі, перш ніж підключатися, і реєструє те, що створив, після цього. Два конструктори, що перекриваються, обидва не знаходять запису в пулі, і друга реєстрація замінює перший запис, закриваючи те підключення під тим запитом, що читає на ньому свою відповідь. Перекриття — звичайний випадок, бо до static $pdo ??= new PDO(...) доходить кожен запит, що надходить до завершення першого підключення.
Тому конструктори, що запитують постійне підключення до одного джерела даних, виконуються по одному на воркер, і другий знаходить те, що зареєстрував перший. Джерело даних — те, яке визначає PDO (псевдонім із pdo.dsn.* у php.ini рахується як DSN, який він називає), і конструктори до різних джерел, як правило, одне на одного не чекають, тож конструктор, що застряг, підключаючись до сервера, який приймає підключення й ніколи не відповідає, не затримує конструкторів до справного. Два випадки чекають більше, а не менше:
- DSN, однакові до двокрапки. DSN порівнюється лише до першої двокрапки після префікса драйвера (хост IPv6 — один зі способів її мати), бо PDO об’єднує DSN, користувача й пароль у свій ключ пулу через двокрапки й нічого не екранує, тож DSN, розширений
:…, може називати той самий запис пулу, що й DSN без цього розширення. Джерела, однакові до цієї двокрапки, ділять одне очікування, включно із застряглим: два хости IPv6 з однаковою початковою групою цифр чекають одне на одного так, як раніше чекали всі конструктори на воркері. - DSN
uri:і DSN, що не є рядками. Їх неможливо визначити, не повторивши того, що з ними робить PDO, тож такий конструктор чекає, доки на його воркері не залишиться жодного постійного конструктора до будь-якого джерела даних, що виконується, а всі вони чекають, поки виконується він. Поки він чекає, він нічого не тримає, тож не затримує інших, але поки виконується конструктор до сервера, що ніколи не відповідає, він не може знайти такого моменту, і коли його очікування вичерпано, він продовжує без серіалізації. Тож випадок із застряглим сервером, описаний вище, усе ж зачіпає DSNuri:і об’єктні DSN, а той, що називає сам застряглий сервер, затримує кожен постійний конструктор на своєму воркері на весь час свого підключення, точно як раніше.
Це очікування обмежене так само, як описані вище, але те, чим воно обертається, коли вичерпане, — не помилка, яку кидає клієнт: конструктор, чий бюджет закінчився, продовжує без серіалізації й повідомляє про це в лозі сервера, а два конструктори, що так перекрилися, замінюють записи одне одного в пулі, як і раніше. Це завершує запит, що читає свою відповідь на заміненому підключенні, і, оскільки дескриптор із пулу звільняється незалежно від того, скільки об’єктів PDO ще на нього вказують, може завершити процес воркера, коли один із цих об’єктів згодом буде знищено. Усередині власного файбера планувальника в просторі користувача цей бюджет вичерпується одразу: такий контекст неможливо призупинити, тож конструктор, що виявляє інший конструктор у процесі, здається негайно.
Інший крок — перевірка живучості, яку PDO робить, перш ніж віддати підключення з пулу: ping на самому підключенні. Надісланий, поки власник призупинений в очікуванні своєї відповіді, він отримав би відмову, PDO вважав би підключення мертвим і відкрив би друге, і те, що застосунок мав намір розділити, вже не було б спільним. Тож підключення, яке тримає інший файбер, повідомляється живим без ping, але лише конструкторові, який залишив би його точно таким, як є (коли драйвер не надає перевірки живучості, як-от pdo_sqlite, та сама відповідь заміняє власну перевірку PDO). PDO записує режим помилок, прапорець автокоміту й інші свої параметри на дескриптор із пулу, який віддає, і на підключенні, що використовується, ці записи лягли б на власника. Тому підключення стає спільним лише з конструктором, який не записав би на нього нічого нового:
- кожен параметр, який він передає, — один із тих, що PDO зберігає на самому дескрипторі (
PDO::ATTR_ERRMODE,PDO::ATTR_CASE,PDO::ATTR_ORACLE_NULLS,PDO::ATTR_DEFAULT_FETCH_MODE; записPDO::ATTR_PERSISTENT, що називає пул, не рахується), і - кожен має те значення, яке вже має підключення.
Параметр, про який треба повідомити драйвер, відхиляється незалежно від значення — включно з PDO::ATTR_AUTOCOMMIT і PDO::ATTR_STRINGIFY_FETCHES — на кожному драйвері, зокрема сторонніх. Конструкторові, що не відповідає будь-якій із цих умов, повідомляють, що підключення мертве, і він отримує власне, а лог сервера каже про це щонайбільше раз на секунду. Для ключа пулу, чиї параметри містять PDO::ATTR_EMULATE_PREPARES або PDO::ATTR_STRINGIFY_FETCHES, як у Laravel, це означає: спільне між запитами й між файберами, що виконують конструктор до запиту до бази, але конструктор, що надходить, поки інший запит тримає підключення, відкриває власне. Захоплення триває від першого запиту власника до бази до кінця його запиту.
Підключення, яким ніхто не користується, перевіряється як і раніше, включно з тим, чий власник позбувся останнього дескриптора, тож справді мертве підключення все одно замінюється. Ця перевірка — така сама команда, як і будь-яка інша, і захоплюється так само: запит до бази, що надходить через захищеного клієнта, поки вона виконується, чекає на неї у звичайних межах. Непокриті випадки, описані вище, лишаються непокритими й тут.
Увімкнення й вартість
1, true і all вмикають усі категорії, включно зі streams і net. Тож розгортання, що вже задає RUNTIME_HOOKS=1 заради хуків sleep, після оновлення почне перехоплювати сокети без жодної власної правки; перелічіть категорії явно (RUNTIME_HOOKS=sleep або RUNTIME_HOOKS=sleep,streams, щоб зберегти те, що вмикала 0.12.0), якщо вам потрібно не це. Увімкнення streams також патчить на старті один запис таблиці операцій потоків PHP у пам’яті; сторінку повертають у той стан, у якому її знайшли, але на платформі, де її початковий захист визначити неможливо, вона лишається доступною для запису — невелика втрата захищеності, про яку повідомляє лог сервера. Увімкнення net на тих самих умовах патчить такі самі записи в таблицях операцій інших транспортів сокетів і OpenSSL і змушує бібліотеку-міст відповідати на poll() для викликів, які робить власний код PHP; кожен інший викликач, включно з власним кодом сервера, передається напряму до libc.
Вартість, виміряна: близько 3–5 мкс на кожен цикл обміну із сокетом на воркері, де більше нічого не призупинено, близько 5–6 мкс із 64 файберами, призупиненими на дескрипторах, і близько 7–11 мкс із 200. Готовність визначається через набір, який ядро зберігає між очікуваннями, тож ця цифра залежить від того, скільки дескрипторів стали готовими, а не від того, скільки їх чекає. Неактивний воркер чекає на ці дескриптори, а не спить фіксований інтервал, помічаючи готовність лише на наступному такті, і це дуже важливо: сліпий сон коштував натомість приблизно 2 мс на цикл обміну.
Широкий stream_select() має накладні витрати, яких немає у вузькому випадку, бо кожен дескриптор, названий у виклику, реєструється перед очікуванням і знімається після нього. Виміряні без самого очікування — кожен дескриптор уже готовий до читання, тож виклик повертається одразу й лишаються тільки накладні витрати, — вони становлять близько 6 мкс проти 5 мкс без хука для одного дескриптора, 56 мкс проти 9 мкс для 64 і 130–150 мкс проти 16–21 мкс для 200: приблизно 0,65 мкс на дескриптор.
Сприймайте це як фіксовану вартість виклику, а не як сповільнення тієї самої роботи. stream_select(), що справді чекає, — а саме для цього пишуть цикл select, — її затьмарює: мілісекунда очікування робить навіть цифру для 200 дескрипторів приблизно десятою частиною виклику, а купує вона потік воркера, який неперехоплений виклик тримає все очікування. Винятком є дві форми, і для них категорію краще залишити вимкненою: виклик на багатьох дескрипторах, який майже ніколи не чекає, бо тут немає часу потоку, який можна повернути; і запит, який сам є циклом подій, де потокові й так більше нічого виконувати. Клієнти, що чекають на одному підключенні, — mysqlnd, phpredis, обгортки потоків HTTP, — перебувають на верхньому рядку таблиці, де накладні витрати становлять близько мікросекунди.
RUNTIME_HOOKS проти oxphp_sleep()
Хук не замінює власний примітив OxPHP — вони охоплюють різні випадки:
- Використовуйте
oxphp_sleep()/oxphp_usleep()у коді, який пишете ви. Вони призупиняють файбер у режимі воркерів без жодного прапорця середовища, аoxphp_sleep()приймає дробові секунди (oxphp_sleep(0.25)) — точність, якої нативнийsleep()(цілі секунди) виразити не може. На них теж поширюються місця, де файбер не може призупинитися: там вони тримають потік воркера точно так, як перехоплені вбудовані функції. - Увімкніть
RUNTIME_HOOKS=sleepдля коду, який ви не можете редагувати, — наприклад, фреймворку чи сторонньої бібліотеки, що напряму викликає нативніsleep()/usleep(). Він вимкнений за замовчуванням, нічого не робить поза файбером і зберігає нативний контракт повернення: перехопленийsleep(), як і раніше, приймаєint, повертає0, коли призупиняється, і те, що повертає вбудована функція, коли переходить до запасного шляху. Перевірка аргументу — його власна, як описано вище.
Якщо покладатися на RUNTIME_HOOKS у власних обробниках, їхня кооперативність залежатиме від налаштування розгортання, а не від коду; там віддавайте перевагу явному oxphp_sleep().
Спільний стан
Примітиви конкурентності в межах процесу (OxPHP\Shared\Counter, Map, Channel, Mutex, Once, Pool, Atomic, Flag, Registry). Огляд API див. у розділі Спільний стан.
| Змінна | Типове значення | Опис |
|---|---|---|
SHARED_ENABLED |
true |
Булева — див. Булеві значення. Головний перемикач усієї підсистеми OxPHP\Shared\*. Задайте false — і жоден з її класів не буде зареєстровано: new OxPHP\Shared\Counter() кидає Error: Class "OxPHP\Shared\Counter" not found, а /__ox_shared/* і метрики oxphp_shared_* відсутні. У коді, який має працювати в обох випадках, використовуйте перевірку class_exists('OxPHP\Shared\Counter'). Не перевіряйте через interface_exists('OxPHP\Shared\Shareable'): вона лишається істинною, бо розширення реєструє цей інтерфейс у будь-якому разі |
SHARED_MAX_ENTRIES |
100000 |
Глобальне обмеження кількості живих Shared-об’єктів (кожен Counter, Map, Channel, … рахується як один). Створення об’єкта понад нього завершується CapacityException. Ключі всередині Map до нього не зараховуються; обмежуйте Map через maxEntries |
SHARED_MAX_BYTES |
1073741824 (1 GiB) |
Глобальне обмеження оціночного обсягу пам’яті всіх Shared-об’єктів, яке перевіряється лише під час створення об’єкта (CapacityException). Наявні об’єкти можуть рости й понад нього, тож загальний обсяг може показувати більше за обмеження |
SHARED_SOFT_LIMIT_RATIO |
— | Застаріла й ігнорується; якщо її задати, на старті пишеться WARN. Вона ніколи ні на що не впливала: ніщо не скидається, коли використання наближається до SHARED_MAX_BYTES / SHARED_MAX_ENTRIES, які лише відмовляють у створенні нового Shared-об’єкта, щойно їх досягнуто. Для раннього попередження налаштуйте алерт на oxphp_shared_capacity_saturation, що публікується, поки ввімкнено SHARED_METRICS_ENABLED (див. Спостережуваність спільного стану) |
SHARED_METRICS_ENABLED |
true |
Булева. Перемикає Prometheus-експозицію oxphp_shared_* |
SHARED_INTROSPECTION_ENABLED |
true |
Булева. Перемикає API інтроспекції /__ox_shared/* на внутрішньому сервері |
SHARED_INTROSPECTION_PREVIEW_ENABLED |
true |
Булева. Перемикає попередній перегляд значень у відповідях інтроспекції (вимикайте, коли попередній перегляд може розкрити чутливі дані) |
SHARED_CYCLE_DETECT_DEPTH |
16 |
Глибина BFS під час перевірки на цикли. Збільшуйте для глибоких легітимних графів |
SHARED_CYCLE_DETECT_EDGES |
10000 |
Кількість ребер, що обходяться під час перевірки на цикли. Збільшуйте для щільних легітимних графів |
SHARED_MAX_VALUE_SIZE |
1048576 (1 MiB) |
Обмеження розміру одного значення. Вставлення більшого значення одразу завершується помилкою |
SHARED_MAX_CHANNEL_BYTES |
67108864 (64 MiB) |
Обмеження загального обсягу корисного навантаження на канал |
SHARED_POISON_STRICT |
— | Застаріла й ігнорується; якщо її задати, на старті пишеться WARN. Вона ніколи ні на що не впливала, і немає глобальної політики «отруєння», яку вона могла б задавати: виняток, кинутий усередині замикання Shared\Mutex, ніколи не «отруює» м’ютекс, а те, чи вимикає невдала фабрика Shared\Once комірку назавжди, обирається для кожного екземпляра; передайте Once\FailureMode::Poison конструктору (див. Shared\Once) |
SHARED_LOCK_DIAGNOSTICS |
warn (strict у debug-збірці бінарника) |
Міжпотоковий детектор взаємних блокувань для Shared\Mutex: off, warn (логувати й рахувати цикли) або strict (також розривати їх через DeadlockException). Лише в нижньому регістрі; будь-яке інше значення мовчки дає типове. Див. Детектор взаємних блокувань |
SHARED_LOCK_POLL_INTERVAL_MS |
100 |
Як часто детектор взаємних блокувань виконує сканування, у мілісекундах. Не використовується, коли SHARED_LOCK_DIAGNOSTICS=off |
SHARED_PREVIEW_STRING_LIMIT |
256 |
Обрізання кожного рядка в попередньому перегляді /__ox_shared/preview, у байтах (на межі символу) |
SHARED_PREVIEW_ARRAY_LIMIT |
20 |
Кількість елементів, що вибираються в попередньому перегляді /entry?id=… |
Профілювання
Семплювальний профілювальник, що видає трейси у форматах xhprof / speedscope. Формати виводу й інтеграцію з переглядачами див. у розділі Профілювання.
| Змінна | Типове значення | Опис |
|---|---|---|
PROFILER_ENABLED |
false |
Булева — див. Булеві значення. Головний перемикач. Усі інші змінні PROFILER_* все одно розбираються на старті, тож друкарські помилки виявляються одразу |
PROFILER_SAMPLE_RATE |
0.0 |
Імовірність (0.0–1.0), що запит буде профільовано. Значення поза діапазоном обрізаються до його меж |
PROFILER_INTERNAL |
false |
Булева. Якщо істинне, запити до внутрішнього сервера (/health, /metrics, ендпоінти плагінів) також можуть потрапити до вибірки |
PROFILER_AUTH_TOKEN |
(не задано) | Секрет, який мають нести явні тригери (заголовок X-OxPHP-Profile, cookie OXPROF, параметр запиту __oxprof), і bearer-токен для маршрутів /__profiler/* на внутрішньому сервері. Не задано або порожньо — токен не потрібен: проходить будь-яке непорожнє значення тригера, а маршрути /__profiler/* відповідають без bearer. Семплювання через PROFILER_SAMPLE_RATE і PHP-функції OxPHP\Profile\* його ніколи не перевіряють: за PROFILER_ENABLED=true скрипт, що викликає OxPHP\Profile\start(), профілює власний запит без жодного токена |
PROFILER_MAX_SPANS |
50000 |
Обмеження кількості спанів профілю на запит. Профілі, що його перевищують, обрізаються |
PROFILER_MAX_DEPTH |
0 (без обмеження) |
Максимальна глибина вкладеності записаних викликів; глибший виклик і, крім випадку самовиклику глибше за 32 фрейми, усе, що він викликає, у профілі відсутні. Їхній час — ні: він показується як власний час виклику на межі. Відкинуті виклики все одно зараховуються до PROFILER_MAX_SPANS. Обмежується значенням 32 — глибиною, яку відстежує спостерігач рушія; за PROFILER_ENABLED=true більше значення на старті повідомляється як обрізане |
PROFILER_OUTPUT_DIR |
/tmp/oxphp-profiles |
Каталог для файлів профілів на диску |
PROFILER_OUTPUT_FORMATS |
xhprof,speedscope |
Список форматів виводу через кому для запису на диск |
PROFILER_DISK_MAX_PER_SEC |
10 |
Обмеження кількості файлів профілів, що записуються на диск за секунду |
PROFILER_RETENTION_COUNT |
100 |
Максимальна кількість файлів профілів, що зберігаються в PROFILER_OUTPUT_DIR. Старіші файли видаляються |
PROFILER_EXPORT_URL |
(не задано) | Віддалений ендпоінт, на який профілі надсилаються методом POST. Якщо задано, запис на диск усе одно відбувається, якщо тільки PROFILER_OUTPUT_FORMATS не порожній |
PROFILER_EXPORT_FORMAT |
xhprof |
Формат передавання для надсилань на PROFILER_EXPORT_URL |
PROFILER_EXPORT_AUTH_TOKEN |
(не задано) | Необов’язковий bearer-токен, що надсилається з кожним запитом експорту |
PROFILER_EXPORT_XHGUI |
(автовизначення) | Булева. Примусово вмикає XHGui-сумісне обгортання корисного навантаження експорту. Не задано — автовизначення, коли шлях PROFILER_EXPORT_URL закінчується на /run/import (підказки в хості чи параметрах запиту не враховуються) |
PROFILER_EXPORT_BUGGREGATOR |
(автовизначення) | Булева. Примусово вмикає конверт Buggregator. Не задано — автовизначення, коли шлях PROFILER_EXPORT_URL закінчується на /api/profiler/store. Конверт завжди містить xhprof, тож PROFILER_EXPORT_FORMAT для нього ігнорується (значення, відмінне від xhprof, дає попередження, не фатальне). Взаємовиключна з PROFILER_EXPORT_XHGUI — увімкнення обох є помилкою запуску |
PROFILER_EXPORT_APP_NAME |
(не задано) | app_name Buggregator для групування за проєктами |
PROFILER_EXPORT_TAGS |
(не задано) | tags Buggregator у форматі key=value,key2=value2; некоректний елемент, порожній ключ або дубльований ключ — помилка запуску |
Приклади конфігурацій
Розробка
LISTEN_ADDR=127.0.0.1:8080
DOCUMENT_ROOT=./public
LOG_LEVEL=debug
ACCESS_LOG=all
PHP_WORKERS=1
INTERNAL_ADDR=127.0.0.1:9090Продакшн (Framework)
LISTEN_ADDR=0.0.0.0:80
DOCUMENT_ROOT=/var/www/html/public
ENTRY_FILE=index.php
PHP_WORKERS=8
QUEUE_CAPACITY=1024
LOG_LEVEL=warn
ACCESS_LOG=error
MAX_CONNECTIONS=10000
INTERNAL_ADDR=127.0.0.1:9090
RATE_LIMIT=100
RATE_WINDOW_SECONDS=60
TRUSTED_PROXIES=private
HEADER_TIMEOUT_SECONDS=5
DRAIN_TIMEOUT_SECONDS=25
COMPRESSION_BROTLI_LEVEL=5
COMPRESSION_ZSTD_LEVEL=6
COMPRESSION_GZIP_LEVEL=6
STATIC_MAX_AGE=30dПродакшн (режим воркерів)
LISTEN_ADDR=0.0.0.0:80
DOCUMENT_ROOT=/var/www/html/public
WORKER_MODE_ENABLED=true
ENTRY_FILE=../worker.php
PHP_WORKERS=8
WORKER_MAX_MEMORY_MIB=128
QUEUE_CAPACITY=1024
LOG_LEVEL=warn
ACCESS_LOG=error
INTERNAL_ADDR=127.0.0.1:9090TLS
LISTEN_ADDR=0.0.0.0:443
TLS_CERT=/etc/ssl/oxphp/cert.pem
TLS_KEY=/etc/ssl/oxphp/key.pem
DOCUMENT_ROOT=/var/www/html/public
ENTRY_FILE=index.phpПерегляд чинної конфігурації
Коли внутрішній сервер працює, зверніться до ендпоінту /config, щоб побачити розв’язану конфігурацію:
curl -s http://localhost:9090/config | jq .{
"access_log": "all",
"async_in_flight_cap": 0,
"async_max_fibers": 256,
"async_queue_capacity": 0,
"async_workers": 0,
"brotli_level": 5,
"deny_file": {"loaded": false},
"document_root": "/var/www/html/public",
"drain_timeout_seconds": 25,
"entry_file": "/var/www/html/public/index.php",
"executor_type": "sapi",
"gzip_level": 6,
"header_timeout_seconds": 5,
"listen_addr": "0.0.0.0:443",
"log_level": "warn",
"max_connections": 10000,
"max_query_body": 524288,
"php_workers": "8",
"plugins": {
"apm": {
"db_capture_params": false,
"enabled": true,
"hooks_registered": 34,
"slow_query_ms": 100
},
"otel": {
"enabled": true,
"protocol": "grpc",
"service_name": "oxphp"
}
},
"queue_capacity": 1024,
"queue_max_waiting": 1024,
"queue_max_waiting_bytes": 67108864,
"queue_wait_timeout_ms": 1000,
"rate_limit": 100,
"rate_window_seconds": 60,
"runtime_hooks": [],
"static_max_age": 2592000,
"static_revalidate": false,
"superglobals_enabled": true,
"tls_enabled": true,
"tls_min_version": "1.2",
"tokio_workers": 4,
"trace_context": true,
"trusted_proxies": false,
"worker_max_memory_mib": 0,
"worker_mode_enabled": false,
"zstd_level": 6
}Відповідь /config вичищає кілька ключів, які містить внутрішнє представлення Config: шляхи до TLS-сертифіката й ключа ніколи не видаються (tls_enabled показує, чи активний TLS), а internal_addr і error_pages_dir вилучено — це топологія розгортання й шляхи файлової системи, які допомагають зловмисникові й не потрібні збирачам метрик.
Промпти для вашого агента
Ці промпти розраховані на агента з підключеним MCP-сервером документації OxPHP — потрібні сторінки він прочитає сам.
Налаштуй OxPHP під мій проєкт. Прочитай сторінку `operations/configuration` через MCP-сервер документації OxPHP.
Подивися, що запускає цей репозиторій, — фреймворк, кількість воркерів, яку він може собі дозволити, форми запитів, які він обслуговує, — і дай мені блок змінних середовища для продакшну з обґрунтуванням кожного обраного значення.Переглянь мою конфігурацію OxPHP. Прочитай сторінку `operations/configuration` через MCP-сервер документації OxPHP.
Порівняй змінні середовища, які задає цей репозиторій, із типовими значеннями й скажи мені, які з моїх перевизначень нічого не роблять, які ризиковані, а які типові значення мені слід було перевизначити, але я цього не зробив.Див. також
- Маршрутизація — режими маршрутизації й поведінка
ENTRY_FILE - Health-перевірки — ендпоінти внутрішнього сервера
- Метрики — довідник Prometheus-сумісних метрик
- Коректне завершення роботи — як
DRAIN_TIMEOUT_SECONDSвпливає на завершення - TLS — налаштування TLS і вимоги до сертифікатів
- Обмеження частоти запитів — подробиці обмеження частоти для кожного IP
- Режим воркерів — архітектура постійних PHP-воркерів
- Стиснення — вибір кодека й рівні для кожного кодування
- Статичні файли — кешування й віддача файлів
- Розподілене трасування та APM — експорт OTel, автоматичне інструментування й PHP SDK для трасування