Про 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 |
уходящий воркер и |
правило устояло и стало злее |
|
3 |
пул keepalive к апстримам |
сбрасывается целиком |
|
4 |
|
не ломает |
|
5 |
reload создаёт паузу в |
паузы нет вообще |
Одна общая оговорка на всё сразу: меряно по 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 в любом случае вернёт ноль: он посылает сигнал и не ждёт результата.
И это задокументировано
Документация по управлению 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 в ответе не оставляет места для интерпретаций: это тот же процесс.
Документация обещала «соединение не будет закрыто». Оно не только не закрыто — оно продолжает работать по конфигурации, которую вы уже заменили.
Правило из первой части не сломалось — оно стало шире. Формулировать его теперь надо так: старого конфига держится не соединение, а процесс. Пока жив уходящий воркер, всё, что попадает в его сокеты, обслуживается по старым правилам — включая запросы, которых при 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’ов в день от автоматики с сертификатами он перестаёт быть теоретическим.
Развилка. Дальше — то, чего боятся зря. Оба следующих пункта я брал как «сейчас докажу неприятное», и оба развалились. Разбирать их стоит именно поэтому: объяснение, почему утверждение не подтвердилось, оказалось интереснее самого утверждения.
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) и все четыре воркера (20–23). Не «у каждого воркера свой сокет» — у каждого воркера все четыре, просто событие он вешает на один. Дескрипторы, кстати, у всех одинаковые — 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 из старого. Сокет не пересоздаётся — он просто продолжает жить.
Есть, впрочем, случай, где сокеты всё-таки закрываются: если 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. бинарный апгрейд |
|
|
2. |
|
|
3. пул к бэкенду |
|
|
4. |
|
|
5. пауза в accept и backlog |
|
Полный вывод одного прогона всех пяти — 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/