nginx не умеет reload. Он умеет fork

от автора

Конфиг, который есть примерно у всех:

server {    listen 8080;    keepalive_timeout 300s;    location /api { proxy_pass http://backend; }}

Деплой делает nginx -s reload. Команда возвращает ноль, nginx -T показывает новый конфиг, в error.log — ровно одна строка reconfiguring. А keep-alive соединение, открытое секундой раньше, в этот момент закрывается. Вот со стенда:

keep-alive соединение KA: ('127.0.0.1', 50734) -> ('127.0.0.1', 8081)  KA, запрос 1 (до reload):          X-Config=v1  KA, запрос 2 (ТОТ ЖЕ сокет):       !! соединение закрыто сервером  новый сокет, запрос 3:             X-Config=v2

Само по себе это не ошибка: сервер вправе закрыть простаивающий keep-alive когда угодно, и корректный клиент переподключится. Ошибку даёт гонка — FIN уходит в тот момент, когда клиент уже записал в сокет следующий запрос. Идемпотентный он повторит, POST — нет.

У одного из четырёх прокси в этом тесте второй запрос в том же сокете возвращает v2. Не угадаете, у кого, и причина не та, о которой вы подумали.

«Бесшовность» reload при этом не маркетинг: ни одного оборванного запроса нет. А вот документация — источник половины недоразумения:

Старые рабочие процессы закрывают listen сокеты и продолжают обслуживать старых клиентов. После обслуживания всех клиентов старые рабочие процессы завершаются.

«Продолжают обслуживать старых клиентов» читается как «ваше соединение доживёт». На самом деле простаивающее keep-alive соединение закрывается немедленно — вы это только что видели. Что считается клиентом, которого дообслуживают, а что нет, не сказано нигде.

Ответ лежит в трёх строках кода. И когда он находится, выясняется, что он не про nginx: Apache и HAProxy, написанные в другие десятилетия и другими механизмами, ведут себя клетка в клетку так же. А Traefik — нет. Проверял на четырёх, по фиксированным тегам.

Весь разбор — по фиксированным тегам:

проект

версия

откуда бинарь

nginx/nginx

release-1.31.3 (073ab5db)

сборка из тега

apache/httpd

2.4.68 (736bb657)

сборка из тега

traefik/traefik

v3.7.10 (2a234935)

сборка из тега

haproxy

2.6.12-1+deb12u3

пакет Debian, разбора по коду нет

Все замеры ниже — из одного прогона стенда: один docker build, один docker run.

Мастер не перечитывает конфиг. Он строит новый

nginx -s reload посылает мастеру SIGHUP. Обработчик выставляет флаг ngx_reconfigure, и на следующей итерации мастер приходит сюда — src/os/unix/ngx_process_cycle.c:223:

            ngx_log_error(NGX_LOG_NOTICE, cycle->log, 0, "reconfiguring");            cycle = ngx_init_cycle(cycle);            if (cycle == NULL) {                cycle = (ngx_cycle_t *) ngx_cycle;                continue;            }            ngx_cycle = cycle;            ccf = (ngx_core_conf_t *) ngx_get_conf(cycle->conf_ctx,                                                   ngx_core_module);            ngx_start_worker_processes(cycle, ccf->worker_processes,                                       NGX_PROCESS_JUST_RESPAWN);

Ключевое — в первой исполняемой строке. ngx_init_cycle не правит текущий cycle, а собирает новый целиком: свой пул памяти, свой массив модулей, свои ngx_listening_t, своя конфигурация каждого модуля. Старый cycle при этом никуда не девается — на него по-прежнему смотрят живые рабочие процессы.

Дальше ngx_start_worker_processes форкает новых воркеров с флагом NGX_PROCESS_JUST_RESPAWN (и там же, на :236, — cache manager с cache loader). Не сигналит существующим, а форкает новых поверх старых. Секунду-другую в системе живут два поколения воркеров с двумя разными конфигами одновременно.

Что делает мастер nginx при SIGHUP

nginx: reload — это fork

Флаг JUST_RESPAWN нужен ровно для одного — чтобы через пару строк мастер не выстрелил себе в ногу. ngx_signal_worker_processes рассылает QUIT всем детям подряд, и единственное, что отличает новых от старых, — этот флаг (:485):

        if (ngx_processes[i].just_spawn) {            ngx_processes[i].just_spawn = 0;            continue;        }

Один проход: новых пропустили и заодно сняли метку, старым отправили QUIT. Списка поколений процессов nginx не ведёт — «старый» здесь означает буквально «тот, кому мы ещё не сняли флаг в этом цикле». Поколения самих циклов он хранит — ngx_old_cycles, src/core/ngx_cycle.c:22, — но к рассылке сигналов это не относится.

Между форком и рассылкой сигнала стоит ngx_msleep(100) с комментарием /* allow new processes to start */ (:238–239). Сто миллисекунд, жёстко зашитые в код, без конфигурируемого параметра. Мастер даёт новым воркерам успеть добраться до accept(), прежде чем старые перестанут слушать. Если ваши воркеры инициализируются дольше ста миллисекунд — а с большим числом зон разделяемой памяти или тяжёлым Lua это реально, — окно, в котором слушают уже не все, вы получаете бесплатно.

При этом ничего не теряется: слушающий сокет воркеру не принадлежит. Его открыл мастер, воркер получил копию дескриптора по наследству, и ngx_close_socket(ls[i].fd) в ngx_close_listening_sockets (src/core/ngx_connection.c:1177) закрывает только копию. Сокет в ядре жив, очередь цела, SYN эти сто миллисекунд копятся в backlog — разбирать их временно некому. Верно это, впрочем, до listen ... reuseport; и до переполнения очереди.

Старый воркер закрывает не всё

Воркер получает QUIT через канал и приходит в src/os/unix/ngx_process_cycle.c:729:

            ngx_quit = 0;            ngx_log_error(NGX_LOG_NOTICE, cycle->log, 0,                          "gracefully shutting down");            ngx_setproctitle("worker process is shutting down");            if (!ngx_exiting) {                ngx_exiting = 1;                ngx_set_shutdown_timer(cycle);                ngx_close_listening_sockets(cycle);                ngx_close_idle_connections(cycle);                ngx_event_process_posted(cycle, &ngx_posted_events);            }

Две строки подряд, и вторая — та, из-за которой закрылось соединение в самом начале. src/core/ngx_connection.c:1461:

voidngx_close_idle_connections(ngx_cycle_t *cycle){    ngx_uint_t         i;    ngx_connection_t  *c;    c = cycle->connections;    for (i = 0; i < cycle->connection_n; i++) {        /* THREAD: lock */        if (c[i].fd != (ngx_socket_t) -1 && c[i].idle) {            c[i].close = 1;            c[i].read->handler(c[i].read);        }    }}

Проход по всей таблице соединений, и решает всё один флаг — c[i].idle. Обратите внимание, что функция ничего не закрывает сама: она выставляет close = 1 и вызывает обработчик чтения соединения. Что случится дальше — решает протокол.

git grep 'c->idle = 1' по тегу даёт ровно пять мест, и набор неочевидный: дважды HTTP/1.x keep-alive (ngx_http_request.c:3497, :3538), по разу QUIC и HTTP/2 — и, что интереснее всего, соединение к бэкенду, вернувшееся в keepalive-пул (ngx_http_upstream_keepalive_module.c:368). ngx_close_idle_connections идёт по той же таблице cycle->connections и не различает, клиентское это соединение или к бэкенду, так что каждый reload сбрасывает ещё и пул к апстримам. Вот вторая половина эффекта, обещанная в начале: на бэкенде это пачка новых TCP и TLS сразу после деплоя.

А теперь то, ради чего стоило считать эти пять мест. Флаг c->idle — это не «соединение сейчас простаивает». Это «соединение согласно решать свою судьбу само», и раздаётся такое согласие по протоколам, а не по состоянию.

WebSocket и SSE поверх HTTP/1.1 флага не получают вообще. Для nginx это не простаивающее соединение, а выполняющийся прямо сейчас запрос через proxy_pass. ngx_close_idle_connections проходит мимо, и старый воркер стоит ровно столько, сколько живёт стрим.

HTTP/2 — наоборот. c->idle = 1 стоит в ngx_http_v2_init (ngx_http_v2.c:310), то есть взводится при создании соединения и в src/http/v2/ не сбрасывается нигде. Под проход попадают все h2-соединения, включая те, где стримы активны прямо сейчас, — а значит и gRPC от клиента, потому что это тот же h2. QUIC устроен так же (ngx_event_quic.c:347). Разбирается это уже в обработчике протокола, ngx_http_v2.c:358:

    if (c->close) {        c->close = 0;        if (c->error) {            ngx_http_v2_finalize_connection(h2c, 0);            return;        }        if (!h2c->processing) {            ngx_http_v2_finalize_connection(h2c, NGX_HTTP_V2_NO_ERROR);            return;        }        if (!h2c->goaway) {            h2c->goaway = 1;

Нет активных стримов — соединение финализируется. Есть — уходит GOAWAY, и соединение живёт дальше, дообслуживая начатое, но не принимая новых стримов.

Соединения, до которых очередь так и не дошла, живут дальше — в старом воркере, со старым конфигом, потому что новый конфиг лежит в другом cycle, до которого этому процессу не дотянуться. И уйдёт воркер не по таймеру, а вот так (:712):

        if (ngx_exiting) {            if (ngx_event_no_timers_left() == NGX_OK) {                ngx_log_error(NGX_LOG_NOTICE, cycle->log, 0, "exiting");                ngx_worker_process_exit(cycle);            }        }

Пока в дереве таймеров хоть что-то есть — воркер стоит. Верхнюю границу ставит ngx_set_shutdown_timer (src/core/ngx_cycle.c:1437), но первым делом там стоит if (ccf->shutdown_timeout) (:1443), а дефолт — ноль (src/core/nginx.c:1146). Ноль означает, что таймер не добавляется вообще: без явного worker_shutdown_timeout верхней границы у старого воркера нет.

Отсюда — правило, которое стоит запомнить целиком: соединение, открытое до reload, новый конфиг не увидит. Доработает начатое на старом, или закроется, или — если это h2 — получит GOAWAY и дотянет свои стримы. Все три ветки ведут в одну точку: новую конфигурацию это соединение не увидит ни в одной из них.

Оговорюсь про границы: первое из двух мест выставляет флаг условно, if (clcf->keepalive_min_timeout == 0) (ngx_http_request.c:3496). Дефолт нулевой (ngx_http_core_module.c:3938), поэтому замер верен, но с ненулевым keepalive_min_timeout наблюдаемое смещается — правило устоит и даже станет злее.

Здесь я остановлюсь: у nginx набралось ещё пять мест, где reload ведёт себя не так, как написано в документации, — начиная с ветки if (ngx_new_binary), при которой SIGHUP не доходит до чтения конфига вовсе. Всё это отдельная статья, и она будет. Здесь важно другое: контракт, который мы только что вывели, — не свойство nginx.

Что видно снаружи

worker_processes 1, чтобы эффект не размазался. Апстрим отвечает сразу на / и через шесть секунд на /slow; версия конфига едет в заголовке X-Config. Судьбу keep-alive соединения вы видели в самом начале — вот остаток того же прогона: дерево процессов и запрос, который был в полёте, когда пришёл SIGHUP.

Если будете воспроизводить на worker_processes auto — контракт тот же, но повторяемость падает: судьба конкретного соединения зависит от того, в каком воркере оно живёт, и с первого раза вы можете попасть не туда.

-- процессы ДО --                     -- процессы ПОСЛЕ --   84    1  nginx: master process        84    1  nginx: master process   85   84  nginx: worker process        85   84  nginx: worker process is shutting down                                         96   84  nginx: worker process  запрос в полёте (начат до reload): X-Config=v1  body=slow-response-- процессы через 3 c --  остались 84 и 96, воркер 85 ушёл

Всё построчно ложится на код: pid мастера не изменился, воркер 85 переименовался через ngx_setproctitle (:732), рядом появился 96 от NGX_PROCESS_JUST_RESPAWN (:235), простаивающий keep-alive закрыт по c[i].idle (ngx_connection.c:1473), а исчез 85 только после ответа апстрима — ngx_event_no_timers_left() (:713).

Отдельно стоит последняя строка вывода. Конфиг уже сменился, новый сокет уже получает v2, а запрос, начатый полутора секундами раньше, спокойно доезжает на v1. Если в новом конфиге вы поменяли апстрим или сняли заголовок — «уже применилось» и «уже применилось ко всем» это разные события, и второе наступает на worker_shutdown_timeout позже.

Apache приходит к тому же контракту другой дорогой

Сразу оговорюсь, какой Apache мерим: mpm_event, а это не префорк. Документация называет его «hybrid multi-process multi-threaded server»: процессы, внутри фиксированный пул потоков и отдельный listener-поток, который раскладывает соединения по воркерам. Внутри он не похож на nginx ничем — у nginx воркер сам крутит цикл событий и сам обслуживает соединение, а listener_may_exit у httpd вообще глобальная переменная процесса, а не поле цикла.

Тем интереснее, что в server/mpm/event/event.c:3427 родитель делает ровно то же, что мастер nginx, только счётчиком вместо флага.

    ++retained->mpm->my_generation;    ap_scoreboard_image->global->running_generation = retained->mpm->my_generation;    if (!retained->mpm->is_ungraceful) {        ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00493)                     AP_SIG_GRACEFUL_STRING " received.  Doing graceful restart");        /* wake up the children...time to die.  But we'll have more soon */        for (i = 0; i < num_buckets; i++) {            ap_mpm_podx_killpg(all_buckets[i].pod, active_daemons_limit,                               AP_MPM_PODX_GRACEFUL);        }

Поколение инкрементируется и публикуется в scoreboard, старым детям через pod уходит AP_MPM_PODX_GRACEFUL, новые рождаются в поколении N+1 с новым конфигом. Где у nginx булев just_spawn, там у Apache монотонный счётчик; смысл тот же — отличить тех, кто читал старый конфиг, от тех, кто читал новый.

Graceful restart в Apache httpd

httpd: поколения вместо флага

Между этим сигналом и конкретным соединением — четыре звена, и все в том же файле. Ребёнок в своём цикле читает pod (ap_mpm_podx_check, :2769), видит AP_MPM_PODX_GRACEFUL (:2781), зовёт signal_threads(ST_GRACEFUL) (:2786), тот будит листенер (wakeup_listener(), :636), а тот выставляет глобальный флаг listener_may_exit = 1 (:586).

Следующей же строкой, :587, идёт disable_listensocks() — и вот тут стоит остановиться, потому что это прямой ответ на nginx-абзац про слушающий сокет. Функция (:475) не закрывает ничего вообще:

    if (event_pollset) {        for (i = 0; i < num_listensocks; i++) {            apr_pollset_remove(event_pollset, &listener_pollfd[i]);        }    }    ap_scoreboard_image->parent[ap_child_slot].not_accepting = 1;

Сокеты убираются из pollset, и факт отказа от accept публикуется в scoreboard. Задача та же, что у nginx, — перестать разбирать очередь, не тронув очередь, — но решение мягче (ни одного close, даже копии дескриптора) и наблюдаемее: родитель видит not_accepting без всякого ps.

process_socket (:1005) разбирает пять состояний; нам нужна ветка CONN_STATE_WRITE_COMPLETION (:1189) — момент, когда ответ уже дописан в сеть (:1236):

        if (c->keepalive != AP_CONN_KEEPALIVE || c->aborted) {            cs->pub.state = CONN_STATE_LINGER;            goto lingering_close;        }        if (c->data_in_input_filters) {            goto process_connection;        }        if (listener_may_exit) {            cs->pub.state = CONN_STATE_LINGER;            goto lingering_close;        }        /* Fall through */        cs->pub.state = CONN_STATE_KEEPALIVE;

Развилка: вернуться в CONN_STATE_KEEPALIVE и ждать следующего запроса или уйти в CONN_STATE_LINGER и закрыться. Третья проверка, listener_may_exit, — это и есть graceful. И вот здесь, в отличие от nginx, документация не юлит: в списке улучшений mpm_event начиная с 2.4.24 прямым текстом стоит «Force gracefully finishing processes to close their connections in keep-alive state».

Тот же ngx_close_idle_connections, только в профиль: nginx закрывает простаивающие соединения одним проходом сразу, Apache — каждое в тот момент, когда оно становится простаивающим. Разная машинерия, разное время, одинаковый контракт.

Тот же стенд, тот же сценарий, httpd -k graceful:

-- процессы ДО --                       -- процессы ПОСЛЕ --   201    1  httpd (родитель)              201    1  httpd (родитель)   203  201  httpd (поколение N)           203  201  httpd (поколение N)                                           223  201  httpd (поколение N+1)keep-alive соединение KA: ('127.0.0.1', 60168) -> ('127.0.0.1', 8082)  KA, запрос 1 (до reload):          X-Config=v1  body=fast-response  KA, запрос 2 (ТОТ ЖЕ сокет):       !! соединение закрыто сервером  новый сокет, запрос 3:             X-Config=v2  body=fast-response  запрос в полёте (начат до reload): X-Config=v1  body=slow-response-- процессы через 3 c --  остались 201 и 223, ребёнок 203 ушёл

Клетка в клетку то же, что у nginx. В error.log при этом появляется AH00493: SIGUSR1 received. Doing graceful restart — печатает её ровно тот ap_log_error из вырезки выше (APLOGNO(00493), :3431). Код, лог и ps сходятся в одной точке.

Симметрия идёт и дальше: у httpd есть точный аналог worker_shutdown_timeoutGracefulShutdownTimeout, — и дефолт выражен ровно так же, ap_graceful_shutdown_timeout = 0; /* unlimited */ (server/mpm_common.c:170). Совпадает не только поведение, но и значение по умолчанию, и даже комментарий к нему.

Traefik: одна запись в поле структуры

Теперь третий, и он ломает картину.

Новая динамическая конфигурация от провайдера (Docker, Kubernetes, файл) проходит агрегацию и попадает в TCPEntryPoint.SwitchRouter (pkg/server/server_entrypoint_tcp.go:360). Тот делает четыре подмены подряд: HTTP (:368), HTTPS (:377) и TCP (:379) кладут новое значение в safe.Safe, а HTTP/3 (:382) — иначе, своим мьютексом и не обработчик, а матчер TLS-конфига. Разберём первую.

Весь файл — тридцать пять строк: конструктор и три метода. Интересны два, pkg/middlewares/handler_switcher.go:21 и :32:

func (h *HTTPHandlerSwitcher) ServeHTTP(rw http.ResponseWriter, req *http.Request) {handlerBackup := h.handler.Get().(http.Handler)handlerBackup.ServeHTTP(rw, req)}
// UpdateHandler safely updates the current http.ServeMux with a new one.func (h *HTTPHandlerSwitcher) UpdateHandler(newHandler http.Handler) {h.handler.Set(newHandler)}

А Set — это pkg/safe/safe.go:25:

// Set sets a new value.func (s *Safe) Set(value any) {s.lock.Lock()defer s.lock.Unlock()s.value = value}

Всё. Взяли sync.RWMutex на запись, присвоили поле, отпустили. Ни fork, ни новой горутины, ни нового http.Server. Слушающий сокет — поле e.listener, заполненное один раз; Start крутит на нём Accept() до самого Shutdown (:241, выход по e.inShutdown на :244). Больше того, http.Server настоящего сокета не касается вообще: между ними стоит псевдослушатель httpForwarder, отдающий соединения из канала (:83). Поэтому подмена обработчика и сокет — две несвязанные вещи.

Подмена обработчика в Traefik

traefik: конфиг — это указатель

Следствие: соединение, открытое до применения конфига, остаётся открытым, и следующий запрос в нём уходит уже в новый обработчик. Не «новое соединение получит новый конфиг» — то же самое TCP-соединение, та же четвёрка адресов.

Тот же стенд, тот же сценарий. Reload тут дёргать нечем, поэтому конфиг просто переписывается на диске — файловый провайдер с watch: true подхватывает сам. Ждать приходится дольше, и это не подгонка: у провайдеров троттлинг, providersThrottleDuration по умолчанию две секунды (cmd/configuration.go:27). Подмена указателя мгновенна, но изменение файла должно до неё доехать.

-- процессы ДО --                       -- процессы ПОСЛЕ --   245    1  traefik --configFile=...      245    1  traefik --configFile=...keep-alive соединение KA: ('127.0.0.1', 47226) -> ('127.0.0.1', 8083)  KA, запрос 1 (до reload):          X-Config=v1  body=fast-response  KA, запрос 2 (ТОТ ЖЕ сокет):       X-Config=v2  body=fast-response  новый сокет, запрос 3:             X-Config=v2  body=fast-response  запрос в полёте (начат до reload): X-Config=v1  body=slow-response

Сравните с выводом nginx и Apache выше. Один и тот же pid 245 до и после — ни форка, ни нового поколения. Тот же сокет 47226, в котором только что был v1, вторым запросом отдаёт v2. Соединение не разорвано, клиент ничего не переподключал.

Стоит сразу очертить, что именно поменялось. Из четырёх подмен живого соединения касается одна — HTTP-обработчик на :368. Про TCP-свитчер исходник говорит прямым текстом, pkg/tcp/switcher.go:23: // Switch sets the new TCP handler to use for new connections. Соединение, которое уже прошло e.switcher.ServeTCP(...) на :287 и уехало в httpForwarder, TCP-маршрут и решение о TLS-терминации больше не пересматривает — они приняты один раз на входе. HTTP/3-подмена меняет матчер TLS-конфига, который дёргается на ClientHello, то есть тоже только для новых. Точная формулировка: под живым соединением в Traefik меняется дерево HTTP-обработчиков и не меняется ничего ниже — TCP-маршрут, SNI, TLS.

И тут же видна граница. net/http зовёт ServeHTTP по разу на запрос, из горутины соединения, — значит и h.handler.Get() отрабатывает на входе в запрос, а не на входе в соединение. Поэтому запрос в полёте доработал на v1 — ровно как в nginx и Apache. Расхождение возникает только в паузе между запросами.

Бэкенд-сторону Traefik я не мерил: если serversTransport сервиса не изменился, пул к апстримам должен переехать в новое дерево обработчиков как есть — но это гипотеза, для проверки нужен отдельный стенд, и такой клетки в таблице ниже нет.

Команды reload у Traefik при этом нет вовсе: обработчик сигналов сервера (pkg/server/server_signals.go:14, unix-сборка) знает только SIGUSR1, и тот ротирует access-логи. Единственный SIGHUP во всём дереве живёт у файлового провайдера (pkg/provider/file/file.go:97) — и даже он не форкает, а зовёт p.applyConfiguration(configurationChan), то есть перечитывает файл в тот же канал, дальше обычным путём до Set. Рестарта процесса требует только статическая конфигурация — точки входа и провайдеры: e.listener создаётся один раз, а сменить порт, не пересоздав сокет, нельзя.

Четвёртый: HAProxy

Раз стенд стоит, в него легко добавить четвёртого — тем более что он ломает удобное объяснение «дело в языке». HAProxy перезагружается через haproxy -f conf -sf <старый pid>: поднимается новый процесс, старому передаётся эстафета. Это дистрибутивный бинарь 2.6.12 из Debian, а не сборка из тега, поэтому утверждений о его коде здесь не будет — только наблюдение, и оно совпадает с nginx и Apache клетка в клетку: новый процесс 312, старый 284 доживает и через три секунды уходит, простаивающий keep-alive закрыт, запрос в полёте доработал на v1.

Полный вывод стенда по HAProxy

-- процессы ДО --          -- процессы ПОСЛЕ --        -- через 3 c --   284  1  haproxy -f c       284  1  haproxy -f c        312  1  haproxy -f c -sf 284                              312  1  haproxy -f c -sf 284  KA, запрос 1 (до reload):          X-Config=v1  body=fast-response  KA, запрос 2 (ТОТ ЖЕ сокет):       !! соединение закрыто сервером  новый сокет, запрос 3:             X-Config=v2  body=fast-response  запрос в полёте (начат до reload): X-Config=v1  body=slow-response-- процессы ДО --          -- процессы ПОСЛЕ --        -- через 3 c --   284  1  haproxy -f c       284  1  haproxy -f c        312  1  haproxy -f c -sf 284                              312  1  haproxy -f c -sf 284  KA, запрос 1 (до reload):          X-Config=v1  body=fast-response  KA, запрос 2 (ТОТ ЖЕ сокет):       !! соединение закрыто сервером  новый сокет, запрос 3:             X-Config=v2  body=fast-response  запрос в полёте (начат до reload): X-Config=v1  body=slow-response

Третья наблюдаемая картина процессов — ни воркеров nginx, ни поколений Apache, — и тот же результат. Оговорка: стенд запускает haproxy без master-worker и без -x; в юните Debian стоит -Ws, и дерево процессов там выглядит иначе — ближе к nginx. На клетки таблицы это не влияет, -x про listen-сокеты, а не про keep-alive.

Четыре реализации, два контракта

Что происходит с одним соединением

реализации расходятся ровно в одной клетке

Всё, что ниже, измерено на HTTP/1.1 с keep-alive — сырой запрос в сокет, listen без http2. Для h2 средняя клетка у nginx другая: там GOAWAY вместо закрытия, и это разбиралось выше.

запрос в полёте

idle keep-alive **

новый сокет

nginx

старый конфиг

закрыт

новый конфиг

httpd

старый конфиг

закрыт

новый конфиг

haproxy *

старый конфиг

закрыт

новый конфиг

traefik

старый конфиг

живёт, новый конфиг

новый конфиг

HAProxy 2.6.12 из Debian — только наблюдение, по исходникам не разбирался. * HTTP/1.1. Клетку для HTTP/2 никто не мерил — см. последний раздел.

Из двенадцати клеток различается одна — и это интереснее, чем если бы различались все двенадцать.

Первый столбец одинаков по разным причинам: nginx держит старый cycle в отдельном процессе, Apache — старый конфиг в ребёнке старого поколения, Traefik — старый http.Handler в локальной переменной handlerBackup. Разные механизмы, один наблюдаемый результат.

А второй столбец — тут легко ошибиться, и я сначала ошибся. Соблазнительно сказать: конфигурация привязана к процессу, процесс нельзя обновить, значит всё, что за него держится, обязано отцепиться. Первая половина верна: привязка к процессу делает отставку старой конфигурации обязательной. Вторая — нет, и опровержение стоит двумя экранами выше, в цитате из документации httpd. «Force gracefully finishing processes to close their connections in keep-alive state» — это список улучшений mpm_event начиная с 2.4.24. До 2.4.24 у Apache была ровно та же процессная модель, ровно те же поколения — и keep-alive он не рвал. Архитектура не менялась, поведение изменилось.

Второй контрпример есть прямо в этой статье: nginx не рвёт WebSocket и SSE. Процесс привязан точно так же, а соединение живёт неограниченно долго. Если бы «привязка к процессу ⇒ отцепиться обязано», стримы рвались бы первыми.

Значит, три проекта сошлись в одной клетке не потому, что иначе не умели, а потому, что у всех троих цена одна и та же: пока держится хоть одно соединение, старый процесс со старым конфигом стоит в памяти. Насколько агрессивно закрывать простаивающее — уже выбор, и все трое сделали его одинаково. Traefik в эту точку не приходит просто потому, что у него этой цены нет: отставлять нечего, конфигурация не привязана к процессу вообще.

Заодно это снимает вопрос «а не в языке ли дело». Не в языке: сравнение идёт внутри одного и того же httpd — до 2.4.24 и после.

Практические выводы:

  • Долгоживущие соединения и nginx плохо сочетаются с частым reload — но по-разному. Простаивающий keep-alive рвётся. А WebSocket и SSE не рвутся вовсе — и именно поэтому держат старого воркера сколько угодно. Инциденты «после reload воркеры не уходят» — это про стримы, а не про keep-alive.

  • worker_shutdown_timeout — не про аккуратность, а про существование верхней границы. Без него старый воркер живёт столько же, сколько самый длинный запрос. При стриминге — неограниченно.

  • «Traefik не рвёт соединения» не бесплатно. Клиент может уехать в другой бэкенд за тем же HTTP-роутером посреди сессии, без переподключения и без единого события на его стороне. Сменить entryPoint, TLS-контекст или TCP-маршрут он при этом не может — они зафиксированы на входе.

Если унести из статьи одно: «reload» — имя операции, а не её описание. В nginx, Apache и HAProxy конфигурацию читает процесс, которого в момент старта вашего запроса ещё не существовало. В Traefik соединение тоже никуда не переезжает — но по противоположной причине: переезжать нечему, конфигурация там к соединению не привязана вообще.

Чего я не знаю

Добавьте строку в таблицу. Стенд лежит здесь — один docker build и один docker run, сценарий одинаковый для всех. Больше всего не хватает Envoy и Caddy: у первого есть hot restart с передачей сокетов между процессами, у второго — API-перезагрузка без рестарта, и по моей же гипотезе они должны разъехаться по разным строкам. Прогоните и принесите вывод в формате продукт версия | что стало с idle keep-alive | сколько жил старый процесс. Добавить в стенд новый прокси — это один файл в targets/: четыре переменные и пять коротких функций, самый маленький из существующих занимает 47 строк.

И h2-клетка. Стенд говорит по HTTP/1.1. У nginx для HTTP/2 средняя клетка точно другая — GOAWAY вместо закрытия, — а вот что там у Apache, HAProxy и Traefik, я не мерил. И это не «поправить строчку»: нужен h2-клиент, который умеет держать соединение и слать запросы поштучно, — сырым сокетом, как сейчас, тут не обойтись.

И контрпример. Утверждение «соединение, открытое до reload, новый конфиг не увидит никогда» проверено на nginx 1.31.3 с одним воркером и дефолтными таймаутами. Если у вас есть конфигурация, где это не так, — покажите, перепроверю и поправлю текст.

И последнее — «а у меня 1.24 из дистрибутива, у меня так же?». Так же: ветка ngx_reconfigure не менялась четырнадцать лет, проверяется одной командой.

$ git log -s --date=short --format='%h %ad %s' \      -L 211,244:src/os/unix/ngx_process_cycle.c release-1.31.3 | head -5db402276 2012-02-28 Added msleep() on reload to allow new processes to start.4413fad0 2009-08-10 cache loader process19298ec1 2009-03-30 introduce cache manager instead of cache cleaner52859f2f 2009-03-23 a prelimiary proxy cache supportb1dfe478 2004-12-21 nginx-0.1.13-RELEASE import

Верхний коммит — тот самый, что добавил msleep(). Диапазон 211,244 тут важен: это ровно блок if (ngx_reconfigure), от самого if до закрывающей скобки. Возьмёте шире — зацепите соседнюю if (ngx_quit), которую трогали в 2020-м, и получите другой ответ на другой вопрос.

Стенд, три файла claims-*.tsv со всеми проверяемыми утверждениями о коде и скрипт, который их проверяет, — в kotru21/nginx-traefik-httpd.

ссылка на оригинал статьи https://habr.com/ru/articles/1067686/