Продакшен
Хороший сервер отличается от демо не тем, как он работает. Тем, как он отказывает. Работают под настроение все; продакшен начинается там, где приходит плохой день: всплеск трафика, медленные клиенты, деплой в самый неподходящий момент. Эта глава целиком про плохие дни.
Таймауты: никто не ждёт вечно
Из первой серии мы вынесли железное правило: любое ожидание должно иметь предел. Посмотрим, где у соединения вообще есть ожидания. Клиент присылает запрос — раз. Забирает ответ — два. Висит между запросами на keep-alive — три. На каждый этап свой предел:
$config
->setReadTimeout(30) // запрос присылается дольше 30 секунд? до свидания
->setWriteTimeout(60) // и ответ забирается дольше минуты? тоже
->setKeepAliveTimeout(15); // пустое соединение живёт максимум 15 секундТаймаут чтения — это не только про медленный интернет. Есть атака с прекрасным именем slowloris: клиент присылает запрос по байту в минуту и занимает соединение вечно. Сто таких клиентов, и сервер без таймаутов кончился. С таймаутом это просто сто разорванных соединений.
Единственное законное исключение вы уже знаете из главы про SSE: стримам таймаут записи отключают, setWriteTimeout(0).
Лимиты: отказать дёшево
Теперь про перегрузку. Вопрос не «если», вопрос «когда». И в этот момент у сервера ровно два пути. Путь первый: принимать всех, копить очередь, замедляться, и в итоге таймауты получают все, включая тех, кого можно было обслужить. Путь второй: лишним отвечать «занят» сразу, а остальных обслуживать как ни в чём не бывало.
Второй путь настраивается так:
$config
->setMaxConnections(10_000) // жёсткий предел соединений
->setMaxInflightRequests(1_000) // одновременно обрабатываемых запросов
->setMaxBodySize(10 * 1024 * 1024); // тело больше 10 МиБ? 413Присмотритесь к setMaxInflightRequests. Узнаёте? Это же наш старый знакомый, предел конкурентности: POOL_MAX, concurrency у групп, теперь вот на уровне целого сервера. Лишний запрос получает мгновенный 503 Retry-After: 1. Мгновенный — это и есть ключевое слово: клиент узнаёт правду за миллисекунду и повторит позже, вместо того чтобы минуту висеть в очереди за таймаутом.
Есть и третий рубеж, самый элегантный. Сервер сам измеряет, сколько запросы ждут в очереди. Ожидание систематически растёт? Значит, мы не успеваем, и сервер временно перестаёт принимать новые соединения, пока не разгребёт. Алгоритм называется CoDel, но вы его знаете под другим именем: это обратное давление из главы про каналы, применённое к accept(). Интернет — производитель, обработчики — потребитель, и производителя притормаживают. Настройка одна:
$config->setBackpressureTargetMs(20); // какое время в очереди считать нормойКомпрессия
Хорошая новость: сжатие ответов уже работает. Сервер сам договаривается с клиентом через Accept-Encoding и выбирает лучший из доступных кодеков: zstd, brotli, gzip. Обычно тут делать нечего, но три рычага знать полезно:
$config
->setCompressionMinSize(1024) // мелочь не сжимаем: накладные расходы дороже
->setZstdLevel(3) // баланс скорость/степень на каждый кодек
->setCompressionMimeTypes([...]); // белый список: текст жмётся, jpeg уже нетПлюс один рычаг со стороны ответа: $res->setNoCompression() для эндпоинтов, где сжатие вредно. SSE-хелперы, кстати, вызывают его сами, буферизующий gzip убил бы всю мгновенность доставки.
Логи
Пока всё хорошо, сервер молчит. Согласитесь, о плохом хотелось бы узнавать не от пользователей:
use TrueAsync\LogSeverity;
$config
->setLogSeverity(LogSeverity::WARN)
->setLogStream(STDERR);WARN — упавшие TLS-рукопожатия, оборванные клиенты, поглощённые исключения. INFO добавит жизненный цикл: старт, порты, воркеры. Приёмник — обычный PHP-стрим: stderr для systemd и Docker, файл, что угодно.
Культурная остановка
И последний плохой день, который на самом деле хороший: деплой. Картина маслом: в сервере тысяча живых соединений, сотня из них посреди записи в базу, и тут приезжает новая версия. kill -9? Получите сто оборванных транзакций и толпу пользователей с битыми загрузками.
Культурная версия выглядит так:
$config->setShutdownTimeout(30);Получив stop() или SIGTERM, сервер сначала перестаёт принимать новых, а текущим даёт дожить, до тридцати секунд. Кто не успел, будет отменён. И вот тут, в последний раз в этой серии, окупается дисциплина scope: отмена кооперативно пройдёт по дереву каждого запроса, finally освободят ресурсы, пул откатит незакрытые транзакции. Graceful shutdown не написан как фича. Он получился из архитектуры.
Для долгоживущих соединений за балансировщиком есть ещё один штрих, setMaxConnectionAgeMs(): соединение старше заданного возраста культурно закрывается (Connection: close, GOAWAY), чтобы клиенты перераспределялись по машинам, а не висели годами на одной.
Вот теперь это продакшен. Перегрузка встречает быстрый отказ, медленные клиенты упираются в таймауты, деплой не рвёт транзакции, а о проблемах сервер рассказывает сам. Остался последний рубеж всей серии: до сих пор с нашим сервером разговаривали браузеры и curl. Пора впустить других собеседников — соседние сервисы, на их родном языке.