TrueAsync ServerProduction

Production

Хороший сервер відрізняється від демо не тим, як він працює. А тим, як він відмовляє. Усі працюють, коли настрій добрий; production починається там, де приходить поганий день: сплеск трафіку, повільні клієнти, розгортання в найневдаліший момент. Цей розділ цілком про погані дні.

Таймаути: ніхто не чекає вічно

З першої серії ми взяли залізне правило: будь-яке очікування повинне мати обмеження. Погляньмо, де у з'єднання взагалі є очікування. Клієнт надсилає запит, це раз. Приймає відповідь, це два. Висить між запитами на keep-alive, це три. Кожен етап отримує своє обмеження:

php
$config
    ->setReadTimeout(30)       // запит надсилається довше за 30 секунд? до побачення
    ->setWriteTimeout(60)      // а відповідь забирають довше за хвилину? теж
    ->setKeepAliveTimeout(15); // простійне з'єднання живе щонайбільше 15 секунд

Таймаут читання, це не тільки про повільний інтернет. Є атака з чудовою назвою, slowloris: клієнт надсилає запит по байту за хвилину й займає з'єднання назавжди. Сотня таких клієнтів, і сервер без таймаутів готовий. З таймаутом це просто сотня скинутих з'єднань.

Єдиний законний виняток вам уже знайомий із розділу про SSE: для потоків таймаут запису вимикають, setWriteTimeout(0).

Ліміти: відмовляти дешево

Тепер про перевантаження. Питання не «якщо», питання «коли». І тієї миті в сервера є рівно два шляхи. Шлях перший: прийняти всіх, накопичити чергу, загальмувати й наприкінці роздати таймаути всім, зокрема й тим, кого можна було обслужити. Шлях другий: одразу сказати зайвим «зайнято» й обслужити решту так, ніби нічого не сталося.

Другий шлях налаштовується так:

php
$config
    ->setMaxConnections(10_000)          // жорсткий ліміт з'єднань
    ->setMaxInflightRequests(1_000)      // запитів, що обробляються конкурентно
    ->setMaxBodySize(10 * 1024 * 1024);  // тіло більше за 10 МіБ? 413

Придивіться до setMaxInflightRequests. Упізнаєте? Це наш давній знайомий, ліміт конкурентності: POOL_MAX, concurrency на групах, тепер на рівні цілого сервера. Зайвий запит отримує миттєвий 503 Retry-After: 1. Миттєвий, це ключове слово: клієнт дізнається правду за мілісекунду й повторить пізніше, замість того щоб висіти в черзі хвилину в очікуванні таймауту.

Є й третя лінія оборони, найелегантніша. Сервер сам вимірює, скільки запити чекають у черзі. Очікування систематично зростає? Отже, ми не встигаємо, і сервер тимчасово перестає приймати нові з'єднання, поки не надолужить. Алгоритм зветься CoDel, але ви знаєте його під іншою назвою: це backpressure з розділу про канали, застосований до accept(). Інтернет, це виробник, обробники, це споживач, і виробника пригальмовують. Тут є одне налаштування:

php
$config->setBackpressureTargetMs(20); // який час очікування в черзі вважати нормальним

Стиснення

Хороша новина: стиснення відповідей уже працює. Сервер домовляється з клієнтом через Accept-Encoding і обирає найкращий із доступних кодеків: zstd, brotli, gzip. Зазвичай тут нема чого робити, але три важелі варто знати:

php
$config
    ->setCompressionMinSize(1024)   // не стискати дрібне: накладні витрати дорожчі
    ->setZstdLevel(3)               // баланс швидкість/коефіцієнт для кодека
    ->setCompressionMimeTypes([...]); // білий список: текст стискається, jpeg уже ні

Плюс один важіль на боці відповіді: $res->setNoCompression() для точок, де стиснення шкідливе. Помічники SSE, до речі, викликають його самі; буферизувальний gzip убив би всю миттєвість доставки.

Логи

Поки все гаразд, сервер мовчить. Погодьтеся, про біду ми воліємо почути не від користувачів:

php
use TrueAsync\LogSeverity;

$config
    ->setLogSeverity(LogSeverity::WARN)
    ->setLogStream(STDERR);

WARN покриває невдалі TLS-рукостискання, скинутих клієнтів, проковтнуті винятки. INFO додає життєвий цикл: старт, порти, воркери. Приймач, це звичайний потік PHP: stderr для systemd і Docker, файл, будь-що.

Плавна зупинка

І останній поганий день, який насправді хороший: розгортання. Картина, гідна олії: у сервера тисяча живих з'єднань, сотня з них у розпал запису в базу, і тут приходить нова версія. kill -9? Насолоджуйтеся сотнею обірваних транзакцій і натовпом користувачів із поламаними завантаженнями.

Плавна версія виглядає так:

php
$config->setShutdownTimeout(30);

Отримавши stop() чи SIGTERM, сервер спершу перестає приймати нові з'єднання, а поточним дає дожити свій вік, до тридцяти секунд. Хто не встиг, того буде скасовано. І тут востаннє в цій серії окупається дисципліна scope: скасування кооперативно проходить деревом кожного запиту, блоки finally звільняють ресурси, пул відкочує незакриті транзакції. Graceful shutdown не писали як фічу. Він випав з архітектури.

Для довгоживучих з'єднань за балансувальником навантаження є ще один штрих, setMaxConnectionAgeMs(): з'єднання, старше за задане, плавно закривається (Connection: close, GOAWAY), щоб клієнти перерозподілялися між машинами замість того, щоб роками висіти на одній.

Ось тепер це production. Перевантаження зустрічає швидка відмова, повільні клієнти впираються в таймаути, розгортання не рве транзакції, а сервер сам повідомляє про свої проблеми. Лишається один останній рубіж для всієї серії: досі з нашим сервером говорили лише браузери й curl. Час впустити інших співрозмовників, сусідні сервіси, їхньою рідною мовою.