nginx -s reload может не применить конфиг

от автора

Про reload в nginx есть два вида заблуждений: то, чего боятся зря, и то, о чём не подозревают. Я взял пять ходовых утверждений, померил каждое на стенде и получил результаты по обе стороны — три подтвердились, два развалились.

Это вторая часть. В первой мы разбирали, что происходит с уже открытым соединением в момент перезагрузки конфигурации, и вывели правило: соединение, открытое до reload, нового конфига не увидит — ни в nginx, ни в httpd, ни в HAProxy; исключением оказался только Traefik. Здесь это правило придётся переформулировать: держится старого конфига не соединение, а процесс.

Всё меряно на nginx, собранном из тега release-1.31.3 (коммит 073ab5d), одним и тем же бинарём во всех пяти опытах; куски кода Traefik — из v3.7.10.

Среда: Ubuntu 22.04.5, ядро 6.8.0, два ядра CPU, 4 ГБ памяти, net.core.somaxconn 4096, всё в одном контейнере — клиент, nginx и бэкенд общаются через loopback. Это важно для §5, где речь про очередь соединений: на петле нет RTT, а нагрузчик отбирает процессорное время у самого сервера. Где это меняет выводы — сказано на месте.

Стенд, который всё воспроизводит, — github.com/kotru21/nginx-traefik-httpd, каталог part2/, запуск в один шаг. Полный вывод прогона, из которого взяты все числа ниже, лежит там же в PART2-sample.txt — можно сверять построчно.

#

что проверял

результат

1

reload во время бинарного апгрейда

конфиг молча не применяется

2

уходящий воркер и keepalive_min_timeout

правило устояло и стало злее

3

пул keepalive к апстримам

сбрасывается целиком

4

reuseport ломает бесшовность

не ломает

5

reload создаёт паузу в accept, SYN копятся

паузы нет вообще

Одна общая оговорка на всё сразу: меряно по HTTP/1.1. Для HTTP/2 средняя клетка другая — там уходящий воркер шлёт GOAWAY, и в первой части это разобрано отдельно; QUIC не трогал вовсе. Всё, что ниже про соединения от клиента, читайте с этой поправкой.

Начнём с того, о чём не подозревают.

1. Ноль на выходе, старый конфиг в памяти

Начну с вывода, потому что он короткий: пока идёт бинарный апгрейд nginx, systemctl reload nginx не применяет конфиг так, как вы думаете. В лучшем случае — к половине процессов сервера, в худшем — вообще никуда. Код возврата ноль в обоих случаях, в error.log пусто.

Какой из двух случаев ваш — решает одна строчка в юните:

systemctl cat nginx | grep ExecReload

Увидеть там можно одно из двух:

ExecReload=/usr/sbin/nginx -g 'daemon on; master_process on;' -s reloadExecReload=/bin/kill -s HUP $MAINPID

Первую я взял из пакета nginx-common Ubuntu 22.04 — она адресует сигнал по pid-файлу. Вторая форма ходит по руководствам и самописным юнитам и адресует его по тому pid, который systemd запомнил при старте сервиса. Во время бинарного апгрейда это разные процессы, и ни один из них не делает того, чего вы ждёте. Дальше два опыта, по одному на вариант.

Напомню обстановку. Бинарный апгрейд — штатный способ подменить исполняемый файл nginx без потери соединений: USR2 старому мастеру, тот поднимает второго, оба принимают запросы, потом старому уходит QUIT. Между первым и последним шагом проходит время — минуты, потому что кто-то смотрит на графики. Всё это время в системе два независимых мастера с собственными копиями конфигурации.

Опыт 1а: nginx -s reload, он же ExecReload в Debian

Ответы помечены заголовками X-Config (какой конфиг) и X-Worker: $pid (какой процесс).

1. запущен nginx, мастер M1 = 10, конфиг v12. USR2, затем меняем конфиг на диске   pid-файл -> 12 (новый мастер)   .oldbin  -> 103. nginx -s reload — как это делает любая автоматика   код возврата: 0   вывод: пусто4. 30 запросов   конфиг=v1    воркер=11     M1 (старый мастер)   запросов: 27   конфиг=v2    воркер=15     M2 (новый мастер)    запросов: 3

Команда вернула ноль, ничего не написала, и двадцать семь запросов из тридцати ответил старый конфиг.

Механика простая. nginx -s reload адресует сигнал по pid-файлу, а тот после USR2 принадлежит новому мастеру: старый переименовал свой в .oldbin. Новый мастер честно перечитал конфигурацию и поднял воркера с v2 — вон он, 15, три запроса. А старый мастер и его воркер 11 живут дальше со старым конфигом: сигнал до них не дошёл, и никто им ничего не сказал.

Двадцать семь из тридцати — это не «половина», и это не константа. Шесть прогонов подряд:

прогон  старый конфиг : новый  1        28 : 2  2        26 : 4  3        25 : 5  4        11 : 19  5        22 : 8  6        26 : 4итого      138 : 42  из 180

Перекос в сторону старого мастера устойчивый — 77 % на круг, — но доля пляшет от 37 % до 93 % за прогон. Ждать здесь ровных 50/50 не стоит: оба мастера сидят на одном унаследованном сокете, и кого из ожидающих разбудит ядро — не вопрос справедливости. У самих разработчиков nginx есть об этом комментарий в ngx_event_accept.c: с EPOLLEXCLUSIVE ядро «обычно будит только тот процесс, который первым добавил слушающий сокет в свой epoll», и им пришлось периодически переподключать сокет, чтобы остальные воркеры вообще получали соединения. В нашем опыте эта ветка не задействована (она включается при worker_processes > 1), но она хорошо показывает, чего от планировщика пробуждений ждать не надо.

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

Опыт 1б: kill -s HUP $MAINPID, он же сигнал старому мастеру

Второй вариант юнита адресует сигнал по $MAINPID. Это старый мастер: systemd знает только тот процесс, который запустил сам, а нового ему никто не показывал. К тому же выводу приходит и человек, который разобрался в опыте 1а и решил послать HUP старому мастеру руками — логика ровно та же.

Не помогает. Совсем.

1. запущен nginx, старый мастер M1 = 20, конфиг v1    20       1 nginx: master process .../sbin/nginx -c .../nginx.conf    21      20 nginx: worker process2. USR2 — бинарный апгрейд, поднимается второй мастер   новый мастер M2 = 23, pid-файл старого уехал в .oldbin: True    20       1 nginx: master process .../sbin/nginx -c .../nginx.conf    21      20 nginx: worker process    23      20 nginx: master process .../sbin/nginx -c .../nginx.conf    24      23 nginx: worker process3. меняем конфиг на диске: v1 → v24. HUP старому мастеру M1 = 20   воркеры до HUP:    ['21', '24']   воркеры после HUP: ['21', '24', '27']   новые воркеры:     ['27']5. 40 запросов — кто каким конфигом отвечает   конфиг=v1    воркер=21     M1 (старый мастер)   запросов: 9   конфиг=v1    воркер=24     M2 (новый мастер)    запросов: 29   конфиг=v1    воркер=27     M1 (старый мастер)   запросов: 2   <-- родился ПОСЛЕ смены конфига

(вывод как есть, сокращены только пути к бинарю и конфигу)

Решающая строка — последняя. Воркер 27 порождён уже после того, как на диске лежал v2, порождён тем самым мастером, которому пришёл HUP, — и отдаёт v1. Конфиг не перечитан вообще.

Одного этого мало: мало ли почему воркер отдал старое. Нужен контроль. Убиваем M2 — это снимает флаг ngx_new_binary — и повторяем тот же HUP тому же мастеру с тем же файлом на диске:

6. контроль: убиваем M2 (снимает ngx_new_binary) и повторяем тот же HUP   конфиг=v2    воркер=30     запросов: 20

Тот же сигнал, тот же процесс, тот же файл — противоположный результат. Разница ровно в одном флаге.

Почему так в коде

Ветка в главном цикле мастера:

/* src/os/unix/ngx_process_cycle.c:211–225 */if (ngx_reconfigure) {    ngx_reconfigure = 0;    if (ngx_new_binary) {        ngx_start_worker_processes(cycle, ccf->worker_processes,                                   NGX_PROCESS_RESPAWN);        ngx_start_cache_manager_processes(cycle, 0);        ngx_noaccepting = 0;        continue;    }    ngx_log_error(NGX_LOG_NOTICE, cycle->log, 0, "reconfiguring");    cycle = ngx_init_cycle(cycle);

Числа в заголовке вырезки — это границы самой вырезки, а не строка с интересным местом. То есть sed -n '211,225p' src/os/unix/ngx_process_cycle.c на теге release-1.31.3 даст ровно то, что выше, до символа. Так во всей статье.

ngx_new_binary взведён у мастера, который сделал USR2 и ещё не отпустил потомка. Пока флаг стоит, SIGHUP до ngx_init_cycle не доходит: воркеры перезапускаются на существующем цикле — том, что был построен при старте, — и continue (строка 214, если считать от начала файла).

Заметьте, что даже строчки reconfiguring в error.log не появится: ngx_log_error стоит после ветки. А nginx -s reload в любом случае вернёт ноль: он посылает сигнал и не ждёт результата.

SIGHUP во время бинарного апгрейда: воркер, рождённый после смены конфига, отдаёт старый конфиг

SIGHUP во время бинарного апгрейда: воркер, рождённый после смены конфига, отдаёт старый конфиг

И это задокументировано

Документация по управлению nginx описывает HUP одной строкой в таблице сигналов:

HUP — изменение конфигурации, обновление изменившейся временной зоны (только для FreeBSD и Linux), запуск новых рабочих процессов с новой конфигурацией, плавное завершение старых рабочих процессов

А через несколько экранов, в разделе про обновление исполняемого файла на лету, — вот это:

Послать старому главному процессу сигнал HUP. Старый главный процесс, не перечитывая конфигурации, запустит новые рабочие процессы.

Обе строки — на одной странице. Вторую читают, когда откатывают бинарный апгрейд; первую — все остальные. Так что это не баг: это документированное поведение, живущее в разделе, куда попадают по другому поводу.

Как понять, что вы в этом состоянии, и как из него выйти

Диагноз — две команды:

ls /run/nginx.pid.oldbin 2>/dev/null && echo "идёт бинарный апгрейд"ps -C nginx -o pid,ppid,cmd | grep master

(путь берётся из директивы pid — в юните Ubuntu это /run/nginx.pid; суффикс .oldbin приписывается к нему.)

Два мастера в выводе и существующий .oldbin — значит reload сейчас в лучшем случае доедет до одного из них.

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

завершить апгрейд: QUIT старому мастеру   после USR2                       pid-файл=39    .oldbin=37    мастеров=2 ['37', '39']   после QUIT старому               pid-файл=39    .oldbin=нет   мастеров=1 ['39']   nginx -s reload: код 0   отвечают: {'v2': 20}откатить апгрейд: HUP старому, затем QUIT новому   после USR2                       pid-файл=49    .oldbin=47    мастеров=2 ['47', '49']   после HUP + QUIT новому          pid-файл=47    .oldbin=нет   мастеров=1 ['47']   nginx -s reload: код 0   отвечают: {'v2': 20}

В обоих случаях .oldbin исчезает сам, мастер остаётся один — и вот теперь reload применяется ко всему, 20 запросов из 20.

Обратите внимание на вторую строчку отката: HUP старому мастеру там нужен не для конфигурации (мы только что видели, что конфиг он не перечитает), а чтобы поднять его воркеров обратно перед тем, как убрать нового. Ровно тот случай, ради которого эта ветка в коде и написана.

Практически это значит одно: проверка на .oldbin должна стоять перед reload’ом, а не после инцидента. Одна строчка в деплой-скрипте:

[ -e /run/nginx.pid.oldbin ] && { echo "идёт бинарный апгрейд, reload отложен"; exit 1; }

2. Уходящий воркер отвечает на запросы, которых при reload не было

Правило из первой части — «соединение, открытое до reload, нового конфига не увидит» — держалось на флаге c->idle. Уходящий воркер вызывает ngx_close_idle_connections, та идёт по всей таблице соединений и закрывает помеченные:

/* src/core/ngx_connection.c:1461–1478 */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);        }    }}

Idle keep-alive соединение помечено — значит будет закрыто, клиент переподключится и попадёт на новый конфиг. Просто.

Только флаг ставится условно:

/* src/http/ngx_http_request.c:3496–3509, ngx_http_set_keepalive() */if (clcf->keepalive_min_timeout == 0) {    c->idle = 1;    ngx_reusable_connection(c, 1);}if (clcf->keepalive_min_timeout > 0    && clcf->keepalive_timeout > clcf->keepalive_min_timeout){    hc->keepalive_timeout = clcf->keepalive_timeout                            - clcf->keepalive_min_timeout;} else {    hc->keepalive_timeout = 0;}

Дефолт нулевой (ngx_conf_merge_msec_value(conf->keepalive_min_timeout, prev->keepalive_min_timeout, 0), src/http/ngx_http_core_module.c:3938), поэтому обычно ветка срабатывает и всё идёт как описано в первой части. Но keepalive_min_timeout ставят: это защита от классической гонки на закрытии keep-alive — клиент пишет в сокет следующий запрос ровно тогда, когда сервер решил соединение закрыть, и получает обрыв на ровном месте.

Документация про reload здесь честна, и даже слишком:

Задаёт таймаут, в течение которого keep-alive соединение с клиентом не будет закрыто на стороне сервера для повторного использования или при плавном завершении рабочих процессов.

«При плавном завершении рабочих процессов» — это и есть reload. Директива появилась в 1.27.4, и написано ровно то, что происходит. Не написано только следствие, а оно куда занятнее самой формулировки.

Опыт

Открываем keep-alive соединение, делаем в нём запрос, делаем reload, делаем в том же сокете второй запрос. Дважды: с дефолтом и с keepalive_min_timeout 5s.

A. дефолт: keepalive_min_timeout 0     воркеры до reload:        ['12']     запрос 1 (до reload):     конфиг=v1 воркер=12     воркеры после reload:     ['15']     запрос 2 в том же сокете: !! соединение закрыто сервером     контроль, новый сокет:    конфиг=v2 воркер=15B. keepalive_min_timeout 5s     воркеры до reload:        ['23']     запрос 1 (до reload):     конфиг=v1 воркер=23     воркеры после reload:     ['23 (уходит)', '26']     запрос 2 в том же сокете: конфиг=v1 воркер=23   (СТАРЫЙ ВОРКЕР)       заголовок Connection:    close     контроль, новый сокет:    конфиг=v2 воркер=26     через 0.0 c: воркеры ['26'] — 23 ушёл

Вариант A — то, что описано в первой части: соединение закрыли, воркер 12 ушёл сразу.

Вариант B — то самое следствие. Воркер 23 остался жив в состоянии worker process is shutting down: его соединение не помечено флагом idle, ngx_close_idle_connections прошла мимо, а незакрытое соединение — это причина не выходить. И этот воркер обслужил запрос, которого в момент reload ещё не существовало, обслужил по старому конфигу. $pid в ответе не оставляет места для интерпретаций: это тот же процесс.

Документация обещала «соединение не будет закрыто». Оно не только не закрыто — оно продолжает работать по конфигурации, которую вы уже заменили.

Две дорожки: с дефолтным keepalive_min_timeout соединение закрывается на reload, с ненулевым — доживает и получает ещё один ответ по старому конфигу

Две дорожки: с дефолтным keepalive_min_timeout соединение закрывается на reload, с ненулевым — доживает и получает ещё один ответ по старому конфигу

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

Сколько это тянется — вопрос, на который надо отвечать замером, и ответ оказался у́же, чем я думал. Смотрите на последние две строки вывода: заголовок Connection: close и воркер, ушедший сразу после ответа.

Механика в двух местах. Первое — ngx_http_finalize_connection, где ветка с ненулевым keepalive_min_timeout стоит до проверки на ngx_exiting и потому срабатывает даже в уходящем воркере:

/* src/http/ngx_http_request.c:2987–2999, ngx_http_finalize_connection() */if (r->keepalive    && clcf->keepalive_min_timeout > 0){    ngx_http_set_keepalive(r);    return;}if (!ngx_terminate     && !ngx_exiting     && r->keepalive     && clcf->keepalive_timeout > 0){    ngx_http_set_keepalive(r);

Второе — фильтр заголовков, который в уходящем воркере keep-alive снимает:

/* src/http/ngx_http_header_filter_module.c:204–206 */if (r->keepalive && (ngx_terminate || ngx_exiting)) {    r->keepalive = 0;}

Итого: соединение переживает reload и доживает до keepalive_min_timeout; если за это время приходит запрос — уходящий воркер его обслужит, по старому конфигу, и ровно один: ответ придёт с Connection: close, сокет закроется, воркер уйдёт. Если запрос не приходит — соединение закроется по таймеру.

То есть окно ограничено сверху значением директивы, а не поведением клиента. Это хорошая новость: бесконечно удерживать старый конфиг открытым соединением нельзя. Плохая — внутри окна ваш «уже выкаченный» конфиг для этого клиента ещё не существует.

3. Пул к бэкенду обнуляется целиком

Флаг c->idle ставится не только клиентским соединениям. Вот ngx_http_upstream_free_keepalive_peer — момент, когда соединение к бэкенду возвращается в пул:

/* src/http/modules/ngx_http_upstream_keepalive_module.c:364–369 */c->write->handler = ngx_http_upstream_keepalive_dummy_handler;c->read->handler = ngx_http_upstream_keepalive_close_handler;c->data = item;c->idle = 1;c->log = ngx_cycle->log;

Тот же флаг, та же таблица cycle->connections. ngx_close_idle_connections направление соединения не различает: для неё это просто ячейка с idle = 1. Значит на reload закрывается весь пул к апстримам.

Сначала — а есть ли у вас пул

Раньше здесь стояла бы оговорка «если вы прописали keepalive N». С версии 1.29.7 она не нужна: пул включён по умолчанию, тридцать два соединения на воркер. В коде это видно прямо:

/* src/http/modules/ngx_http_upstream_keepalive_module.c:536–555 *//* skip implicit upstreams */if (uscfp[i]->srv_conf == NULL) {    continue;}kcf = ngx_http_conf_upstream_srv_conf(uscfp[i],                                    ngx_http_upstream_keepalive_module);if (kcf->max_cached == 0) {    continue;}ngx_conf_init_msec_value(kcf->time, 3600000);ngx_conf_init_msec_value(kcf->timeout, 60000);ngx_conf_init_uint_value(kcf->requests, 1000);if (kcf->max_cached == NGX_CONF_UNSET_UINT) {    kcf->local = 1;    kcf->max_cached = 32;}

Обратите внимание на первую строку: skip implicit upstreams. Пул по умолчанию достаётся явному блоку upstream {}, а proxy_pass прямо на адрес идёт мимо. Разница видна невооружённым глазом — 400 запросов, считаем TCP-подключения со стороны бэкенда:

явный upstream, без keepalive    принято бэкендом    6 TCP, живых 6явный upstream, keepalive 16     принято бэкендом    6 TCP, живых 6proxy_pass прямо на адрес        принято бэкендом  400 TCP, живых 0

Первая и вторая строки одинаковые: keepalive 16 ничего не поменял, потому что пул и так был. От прогона к прогону эти числа гуляют в пределах единиц — сколько именно соединений понадобится на четыреста запросов, зависит от того, как лягут восемь параллельных клиентов. Третья строка не гуляет: четыреста подключений на четыреста запросов, ни одного переиспользования.

Так что дальше читайте не как «вот что бывает у тех, кто настраивал keepalive», а как «вот что бывает у всех, у кого в конфиге есть upstream {}».

Проверить это наивно — легко и бесполезно:

после прогрева: живых соединений к бэкенду 8, принято всего 8--- nginx -s reload ---после reload:   живых 0, закрыто 8

Ничего не доказано. Воркер сменился, старый вышел, все его сокеты закрылись бы и без всякого флага.

Опыт, который что-то доказывает

Нужно посмотреть на пул, пока старый воркер жив. Для этого его надо чем-то удержать: запустить запрос в локейшн, где бэкенд отвечает через восемь секунд, и делать reload, пока запрос в полёте. Бэкенд при этом считает свои TCP-подключения и печатает счётчик каждые 200 мс.

пул прогрет:      живых к бэкенду 6   воркеры: ['483']медленный пошёл:  живых 6             (пул + сам медленный запрос)--- nginx -s reload ---+ 8.22 c: живых к бэкенду  1   воркеры: ['483 (уходит)', '501']+ 8.62 c: живых к бэкенду  1   воркеры: ['483 (уходит)', '501']+ 9.22 c: живых к бэкенду  1   воркеры: ['483 (уходит)', '501']+ 9.62 c: живых к бэкенду  1   воркеры: ['483 (уходит)', '501']

Воркер 483 жив и работает — он держит незавершённый запрос. А пул к бэкенду ужался с шести соединений до одного, и это одно — сокет самого медленного запроса, он не в пуле, он занят. Закрыл пул не выход процесса, а ngx_close_idle_connections.

Операционное следствие: на каждый reload бэкенд получает пачку новых TCP-подключений числом с размер пула, помноженный на число воркеров. С TLS до бэкенда — ещё и пачку хендшейков. Если апстрим — это условный Java-сервис с прогревом на соединение или пул к базе за ним, всплеск будет виден на его графиках и не будет объясняться ничем в логах nginx: там про reload одна строчка reconfiguring.

Оценить масштаб можно так. Лимит пула считается на блок upstream и на воркер — не на сервер целиком. Значит верхняя граница числа переустановок на один reload это «воркеры × upstream-блоки × лимит»: восемь воркеров и один апстрим с дефолтными 32 дают до 256, а восемь воркеров и десяток апстримов у какого-нибудь API-гейтвея — уже до двух с половиной тысяч. Реальное число меньше: пулы редко бывают полными. Но порядок величины задаётся так, и на сотне reload’ов в день от автоматики с сертификатами он перестаёт быть теоретическим.

Пул к бэкенду до и после reload при живом воркере

Пул к бэкенду до и после reload при живом воркере

Развилка. Дальше — то, чего боятся зря. Оба следующих пункта я брал как «сейчас докажу неприятное», и оба развалились. Разбирать их стоит именно поэтому: объяснение, почему утверждение не подтвердилось, оказалось интереснее самого утверждения.

4. reuseport бесшовность не ломает

Логика страшилки безупречна, и я сам в неё верил, пока не полез мерить. С listen ... reuseport ядро раскладывает входящие соединения по нескольким слушающим сокетам, у каждого своя accept-очередь. В nginx клоны создаёт ngx_clone_listening:

/* src/event/ngx_event.c:471–479 */if (!ls[i].reuseport || ls[i].worker != 0) {    continue;}if (ngx_clone_listening(cycle, &ls[i]) != NGX_OK) {    return NGX_CONF_ERROR;}/* cloning may change cycle->listening.elts */

А воркер вешает событие только на свой клон:

/* src/event/ngx_event.c:805–813, ngx_event_process_init() */    for (i = 0; i < cycle->listening.nelts; i++) {#if (NGX_HAVE_REUSEPORT)        if (ls[i].reuseport && ls[i].worker != ngx_worker) {            continue;        }#endif        c = ngx_get_connection(ls[i].fd, cycle->log);

Отсюда вывод: у каждого воркера своя очередь; воркер на reload уходит; значит уносит очередь с недоразобранными SYN. Соединения теряются.

Не теряются.

                        без reuseport      с reuseportнагрузка 60 потоков     99 999 / потерь 0  99 718 / потерь 0залп 800 × 8 раундов     6 400 / потерь 0   6 400 / потерь 0

reuseport при этом был включён по-настоящему — четыре отдельных слушающих сокета против одного в контроле:

$ ss -lntp | grep 8090            # worker_processes 4, listen ... reuseportLISTEN 0 511 0.0.0.0:8090 users:(("nginx",pid=23,fd=8),...,("nginx",pid=19,fd=8))LISTEN 0 511 0.0.0.0:8090 users:(("nginx",pid=23,fd=7),...,("nginx",pid=19,fd=7))LISTEN 0 511 0.0.0.0:8090 users:(("nginx",pid=23,fd=6),...,("nginx",pid=19,fd=6))LISTEN 0 511 0.0.0.0:8090 users:(("nginx",pid=23,fd=5),...,("nginx",pid=19,fd=5))

Присмотритесь к владельцам. Каждым из четырёх сокетов владеет пять процессов: мастер (19) и все четыре воркера (2023). Не «у каждого воркера свой сокет» — у каждого воркера все четыре, просто событие он вешает на один. Дескрипторы, кстати, у всех одинаковые — fd=5..8: это и есть наследство от общего fork.

Это и есть ответ. Клоны открывает мастер, до fork. Воркер получает копии всех дескрипторов по наследству и на выходе закрывает только копии — счётчик ссылок в ядре не доходит до нуля, потому что мастер свои держит всё время.

Решающая проверка — inode’ы слушающих сокетов мастера до и после reload:

мастер 309, воркеры: ['310', '311', '312', '313']inode'ы мастера ДО:     [457608, 457609, 457610, 457611]воркеры ПОСЛЕ:          ['316', '317', '318', '319']inode'ы мастера ПОСЛЕ:  [457608, 457609, 457610, 457611]пережили reload:        [457608, 457609, 457610, 457611]

Воркеры сменились полностью — все четыре. Сокеты те же самые: те же объекты в ядре, с теми же очередями и всем, что в них накопилось. ngx_init_cycle при перезагрузке ищет в новой конфигурации listen с тем же адресом и переиспользует дескриптор:

/* src/core/ngx_cycle.c:535–540 */if (ngx_cmp_sockaddr(nls[n].sockaddr, nls[n].socklen,                     ls[i].sockaddr, ls[i].socklen, 1)    == NGX_OK){    nls[n].fd = ls[i].fd;    nls[n].previous = &ls[i];

Новый цикл забирает fd из старого. Сокет не пересоздаётся — он просто продолжает жить.

Мастер владеет всеми клонами reuseport, воркер закрывает только копии

Мастер владеет всеми клонами reuseport, воркер закрывает только копии

Есть, впрочем, случай, где сокеты всё-таки закрываются: если reload уменьшает worker_processes. Клонов ровно столько, сколько воркеров, — уменьшили число, лишние клоны стали не нужны. (При увеличении клоны, наоборот, добавляются, и терять нечего — если вы на reload наращиваете число воркеров, этот абзац не про вас.)

inode'ы мастера ДО  (4 воркера):  [1792825, 1792826, 1792827, 1792828]inode'ы мастера ПОСЛЕ (2 воркера): [1792825, 1792826]закрыто клонов:                    2

Два слушающих сокета закрыты — вместе со всем, что успело накопиться в их accept-очередях. Здесь потери ожидаемы, и здесь они единственный раз за весь разбор появились:

шесть прогонов, worker_processes 4 → 2, ~16 тыс. соединений в секунду  четыре прогона:  0 потерь  один прогон:    15 reset на 98 769   (0,02 %)  один прогон:     1 reset на 77 272   (0,001 %)  итого:          16 на 561 862контроль, worker_processes не меняется (те же 4):  пять прогонов:   0 потерь на ~470 тысяч

Считать из этого «частоту 0,003 %» нельзя: потери не размазаны, а кучкуются. Правильная формулировка — потери появились в двух прогонах из шести, в четырёх их нет. Похоже не на фоновый уровень, а на редкое совпадение по времени: соединение должно успеть встать в очередь клона ровно перед тем, как этот клон закроют.

Контрольных прогонов пять против шести — просто потому, что контроль набирался попутно, шагом 1 того же опыта, и одного прогона там не хватило до пары. Если хотите ровное сравнение, шестой добирается одной командой.

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

5. Пауза в accept и арифметика backlog

Второе, чего боятся зря, и оно ходит по интернету в связке с советом «поднимите backlog перед деплоем». Рассуждение: reload — это «старые воркеры перестали принимать, новые ещё не начали»; в этом окне SYN копятся в очереди; при backlog=511 и высоком RPS очередь переполняется; клиент получает таймаут или RST.

Первая половина — просто неправда. Порядок в коде такой:

/* src/os/unix/ngx_process_cycle.c:234–243 */ngx_start_worker_processes(cycle, ccf->worker_processes,                           NGX_PROCESS_JUST_RESPAWN);ngx_start_cache_manager_processes(cycle, 1);/* allow new processes to start */ngx_msleep(100);live = 1;ngx_signal_worker_processes(cycle,                            ngx_signal_value(NGX_SHUTDOWN_SIGNAL));

Новые воркеры форкаются первыми. Потом мастер спит сто миллисекунд — комментарий в коде объясняет зачем — и только потом рассылает QUIT старым. Все эти сто миллисекунд обе смены сидят на одном и том же слушающем сокете и обе делают accept. Момента, когда никто не принимает, в этой последовательности нет.

Одна оговорка: это верно, пока listen не менялся и сокет переиспользуется (мы это видели в §4 — новый цикл забирает fd из старого). Если вы поменяли адрес или порт, старый сокет закрывается, новый открывается, и окно появляется — но это уже не «reload рвёт соединения», а «вы переехали на другой порт».

Замер это подтверждает. Задержка «установить соединение и получить ответ», по 100-миллисекундным корзинам вокруг reload:

без reuseport, 67 941 соединение  корзина    n      p50      p99      max   ошибок   -300 мс 1569     2.30     7.09    11.24      0   -200 мс 1592     2.28     7.10     8.77      0   -100 мс 1782     1.97     6.98     9.17      0     +0 мс 1617     2.20     6.63     9.55      0   <-- reload   +100 мс 1631     2.20     7.21    10.48      0   +200 мс 1786     1.96     6.72    12.53      0   +300 мс 1739     2.02     6.39     8.76      0  весь прогон: p50 2.12 мс, max 13.21 мс, ошибок 0с reuseport, 65 177 соединений     +0 мс 1514     2.38     7.10    12.43      0   <-- reload  весь прогон: p50 2.22 мс, max 14.65 мс, ошибок 0

Распределение плоское поперёк reload в обоих режимах. Ни всплеска в p99, ни хвоста в max, ни одной ошибки на 133 тысячи соединений.

А очередь-то наполняется?

Задержка плоская — но это замер закрытой петли на 40 потоков, а страшилка говорит про переполнение accept-очереди. Сорока одновременными соединениями при backlog=511 очередь не наполнить никогда, так что этот замер вторую половину утверждения не проверяет вообще.

Проверим её отдельно и с перекосом в пользу страшилки: backlog=16 — в тридцать раз меньше дефолта, — залпы по 500 одновременных connect(), четыре раунда, и всё это время следим за фактической глубиной очереди (Recv-Q слушающего сокета в /proc/net/tcp).

somaxconn=4096, запрошенный backlog=16без reload: пик очереди 0 (по раундам [0, 0, 0, 0]), 2000 соединений, потерь 0с reload  : пик очереди 1 (по раундам [1, 0, 0, 0]), 2000 соединений, потерь 0

Очередь не набирается. Пятьсот клиентов ломятся одновременно, а глубина очереди не превышает единицы: один воркер разбирает её быстрее, чем ядро успевает складывать. Reload на это не влияет никак.

Оговорка, без которой этот замер нечестен: всё происходит на loopback, где RTT около нуля, и клиенты живут на той же машине, что и сервер, — то есть конкурируют с nginx за те же два ядра. В сети с настоящим RTT и тысячами клиентов очередь наполнить можно, и somaxconn бывает нужно крутить. Но это ровно то, что я и утверждаю:

Арифметика backlog — про нагрузку, а не про reload. Если вы видите всплеск отказов на reload, backlog его не вылечит: искать надо в другом месте.

Не применить конфиг можно по-разному

Первая часть кончалась тем, что у Traefik reload’а нет вовсе: конфигурация приезжает от провайдеров, роутер меняется атомарной подменой указателя, и никакие процессы никуда не уходят. Соблазнительно решить, что раз нет reload — нет и класса проблем, описанного в пункте 1.

Не так. Между «конфиг изменился на диске» и «конфиг применён» у Traefik стоит дроссель:

// pkg/provider/aggregator/aggregator.go:44–59return func(configurationChan chan<- dynamic.Message, pool *safe.Pool) error {rc := newRingChannel()pool.GoCtx(func(ctx context.Context) {for {select {case <-ctx.Done():returncase msg := <-rc.out():configurationChan <- msgtime.Sleep(providerThrottleDuration)}}})return prd.Provide(rc.in(), pool)}

Одно сообщение проходит, дальше горутина спит — по умолчанию две секунды (cmd/configuration.go:27). В коде переменная называется providerThrottleDuration, в единственном числе; ключ конфигурации и флаг — во множественном: providers.providersThrottleDuration. Грепать надо по обоим. Всё, что придёт за время сна, ложится в кольцевой буфер, а буфер — на одно сообщение:

// pkg/provider/aggregator/ring_channel.go:7–9// RingChannel implements a channel in a way that never blocks the writer.// Specifically, if a value is written to a RingChannel when its buffer is full then the oldest// value in the buffer is discarded to make room (just like a standard ring-buffer).

Читается это так: time.Sleep стоит после отправки, значит окно открывается не само по себе, а сразу за любым предыдущим изменением. Одиночная правка на спокойной системе проедет мгновенно. А вот вторая правка в течение двух секунд после первой будет ждать; и если за окно пришло несколько — промежуточные выбрасываются, применится только последняя.

Логика правильная, кому нужен промежуточный конфиг. Но это ровно тот случай, когда «файл на диске уже новый» и «сервер уже отвечает по-новому» — не одно и то же, а провайдеров, которые сыплют событиями пачками, хватает: раскатка в Kubernetes, перезапись нескольких файлов подряд, ACME. Дроссель отключается нулём — тогда maybeThrottledProvide возвращает prd.Provide как есть, без обёртки. Отдельные провайдеры могут назначить себе свою длительность, объявив ThrottleDuration(); в v3.7.10 таких ровно два — ACME и Tailscale. Все остальные, включая файловый, Docker и Kubernetes, живут с общим значением.

Механизмы разные, операционная поверхность одна: «команда вернула успех» и «конфиг применён» — разные события. У nginx между ними может встать бинарный апгрейд, у Traefik — дроссель. И то и другое проверяется одинаково: снаружи, запросом.

Почему обе стороны — это одно и то же

Три подтвердившихся пункта и два опровергнутых выглядят как разные истории, но объяснение у них общее, и оно ровно то, с чего начиналась первая часть.

Слушающий сокет принадлежит мастеру. Воркер работает с копией дескриптора, полученной по наследству. Поэтому его уход ничего не рвёт: ни при reuseport, ни без него, ни в момент, когда новые ещё поднимаются. Отсюда оба опровержения.

Клиентское соединение принадлежит воркеру. Оно живёт в его памяти, обслуживается его конфигурацией, и уносится вместе с ним. Поэтому старый конфиг живёт ровно столько, сколько живёт процесс, — и никакой reload его оттуда не выковыряет. Отсюда пункты 2 и 3: и запрос, пришедший в старое соединение через минуту после reload, и пул к бэкенду, который никто не просил трогать, — следствия одного решения.

А пункт 1 стоит особняком: он не про архитектуру, а про то, что у reload есть состояние, в котором он ничего не применяет и не сообщает об этом. И, судя по Traefik, это свойство не nginx, а самой операции: она асинхронная, а выглядит синхронной.

Что с этим делать

Коротко и по делу.

Проверять, что reload применился. Ноль на выходе nginx -s reload не значит ничего. Проверять надо снаружи — запросом, который вернёт что-нибудь из нового конфига. Самое дешёвое — версия конфига в заголовке:

add_header X-Config-Version "9f2c1ab" always;

Значение должно попадать в файл на этапе сборки конфига — envsubst, шаблон Ansible, sed в деплой-скрипте, что у вас есть. Написать туда $CI_COMMIT_SHA и надеяться, что nginx подставит переменную окружения, нельзя: $ в конфиге — это nginx-переменная, и на неизвестном имени сервер просто не поднимется.

$ nginx -tnginx: [emerg] unknown "ci_commit_sha" variablenginx: configuration file /etc/nginx/nginx.conf test failed

Дальше после каждого reload:

curl -sI localhost | grep X-Config-Version

Эта же проверка ловит и «конфиг не прошёл валидацию, мастер откатился», и оба состояния из пункта 1.

Развести во времени reload и бинарный апгрейд. Пока в системе два мастера, reload доедет в лучшем случае до одного из них. Проверка на .oldbin перед reload’ом стоит одну строчку и снимает весь класс.

Знать про пул к бэкенду. Если бэкенду дорого принимать соединения, а reload у вас частый, всплеск на его графиках — отсюда. Лечится не настройками nginx, а тем, что вы перестаёте делать reload по любому поводу.

Помнить, что старый конфиг живёт в процессе. Без worker_shutdown_timeout уходящий воркер живёт столько, сколько живёт его самое долгое соединение, — а с keepalive_min_timeout ещё и дольше. Директива ставит на это верхнюю границу; выбирать её стоит, понимая, что вы ограничиваете не только время выхода, но и время жизни предыдущей конфигурации.

Стенд

Все пять опытов лежат в github.com/kotru21/nginx-traefik-httpd в каталоге part2/. nginx собирается из того же тега, что и здесь.

PART2=1 ./run-in-docker.sh

Каждый опыт — отдельный файл, печатает сырой вывод и вердикт; можно гонять по одному. Нумерация файлов совпадает с нумерацией разделов здесь:

раздел

файл

1. бинарный апгрейд

exp1_new_binary.py

2. keepalive_min_timeout

exp2_keepalive_min_timeout.py

3. пул к бэкенду

exp3_upstream_pool.py

4. reuseport

exp4_reuseport.py

5. пауза в accept и backlog

exp5_accept_latency.py

Полный вывод одного прогона всех пяти — part2/PART2-sample.txt. Все числа в статье взяты оттуда, кроме отдельно оговорённых серий из нескольких прогонов; если ваши цифры разойдутся, сравнивать удобно построчно.

Рядом — claims-part2.tsv и claims-part2-traefik.tsv: реестры всех утверждений о коде из этой статьи, тридцать шесть штук. В каждой строке файл, утверждение и якорь — литеральная строка, а не номер строки, потому что номера уезжают между релизами. verify_claims.py проходит по реестру и падает, если хоть один якорь не нашёлся.

Номера строк в заголовках вырезок — границы вырезки на запиненном теге, проверяются в лоб:

git -C nginx show release-1.31.3:src/os/unix/ngx_process_cycle.c | sed -n '211,225p'

Отступ у длинных вырезок снят целиком, одинаково для всех строк, — кроме тех, где в блок попадает #if: препроцессор живёт в нулевой колонке, и сдвигать его нельзя. Такие вырезки оставлены как есть.

Если у вас получатся другие числа — особенно по reuseport со сменой worker_processes, на другом ядре или под большей нагрузкой — присылайте вывод. Шестнадцать сброшенных соединений на полмиллиона мне самому не нравятся как результат.


Из пяти проверок три подтвердились, две нет. Соотношение показательное: страшилки про reload в nginx в основном про слушающий сокет, и они не работают, потому что сокет мастера. А то, что действительно стоит знать, — про процесс и его память, и про него как раз почти не пишут.

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