Graceful reload: что реально происходит при nginx -s reload
Команда nginx перезагрузка конфига - это
Код: Выделить всё
nginx -s reloadЧто делает мастер по SIGHUP по шагам:
- Перечитывает и парсит конфиг. Если в нём синтаксическая ошибка - мастер ругается в error_log и остаётся на старом конфиге. Текущие воркеры продолжают жить, ничего не падает. Это ключевой момент: битый конфиг при reload не роняет прод, в отличие от рестарта.
- Если конфиг валиден - заново открывает лог-файлы и слушающие сокеты (если listen изменился), применяет новые настройки.
- Запускает новый набор воркеров с новым конфигом. Они сразу начинают принимать новые соединения.
- Старым воркерам шлёт сигнал на graceful shutdown. Те перестают принимать новые коннекты, но дорабатывают уже открытые - текущие запросы, медленные загрузки, websocket-сессии. Когда все их соединения закрылись - старый воркер тихо умирает.
Код: Выделить всё
$ ps -eo pid,ppid,user,comm | grep nginx
812 1 root nginx: master process
9001 812 nginx nginx: worker process # новый воркер
9002 812 nginx nginx: worker process # новый воркер
8730 812 nginx nginx: worker process is shutting down # старый, дорабатывает
8731 812 nginx nginx: worker process is shutting down
Код: Выделить всё
worker_shutdown_timeoutГрабли тут две. Первая: если процессов "is shutting down" накапливается всё больше от reload к reload - значит у тебя залипают долгие коннекты, ставь разумный worker_shutdown_timeout. Вторая: reload не перечитывает изменения, требующие смены user, или некоторые настройки, заданные на старте мастера - такое требует полноценного рестарта.

Бинарный апгрейд на лету: USR2 + QUIT без потери соединений
Reload меняет конфиг, но что если надо обновить сам бинарник nginx (новая версия, пересобранный с модулем)? Рестарт сервиса оборвёт всё. Здесь работает фокус с двумя сигналами - это родная фишка nginx, придуманная как раз для нулевого даунтайма.
Идея: мастер умеет породить второго мастера с новым бинарником, который унаследует те же слушающие сокеты через файловые дескрипторы. Два nginx одновременно слушают один порт, и ядро раскидывает по ним новые соединения.
Порядок такой:
Код: Выделить всё
# 1. Заменили бинарник на диске на новую версию (apt upgrade / положили вручную)
# 2. Шлём старому мастеру USR2 - он стартует НОВЫЙ мастер из нового бинарника
kill -USR2 $(cat /run/nginx.pid)
# Старый pid-файл переименован в nginx.pid.oldbin, новый мастер записал свой pid
$ ls /run/nginx*
/run/nginx.pid /run/nginx.pid.oldbin
# 3. Теперь два мастера + два набора воркеров слушают порт. Проверяем версию/логи.
# 4. Старым воркерам говорим "хватит принимать новое" сигналом WINCH старому мастеру
kill -WINCH $(cat /run/nginx.pid.oldbin)
# Старые воркеры дорабатывают текущее и гаснут, новые держат весь трафик.
# 5а. Всё ок -> добиваем старого мастера
kill -QUIT $(cat /run/nginx.pid.oldbin)
# 5б. Что-то сломалось -> откат: будим старые воркеры обратно
kill -HUP $(cat /run/nginx.pid.oldbin) # старый мастер снова поднимает воркеры
kill -QUIT $(cat /run/nginx.pid) # гасим новый мастер
Тестируй ДО деплоя: nginx -t, gixy и crossplane в nginx ci cd
Код: Выделить всё
nginx -s reload1. nginx -t - синтаксис и базовая валидация. Это первое, что должно стоять в любом nginx ci cd пайплайне. Проверяет, что конфиг парсится, файлы существуют, директивы на месте:
Код: Выделить всё
$ nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Код: Выделить всё
nginx -tКод: Выделить всё
nginx -t2. gixy - линтер безопасности. nginx gixy - это не про синтаксис (он и так валиден), а про логические дыры: SSRF через
Код: Выделить всё
proxy_passКод: Выделить всё
aliasКод: Выделить всё
add_headerКод: Выделить всё
internalКод: Выделить всё
$ gixy /etc/nginx/nginx.conf
==================== Results ===================
Problem: [http_splitting] Possible HTTP-Splitting vulnerability.
Severity: HIGH
Description: Using variables that can contain "\n" may lead to http injection.
Pseudo config:
location ~ /v1/((?<action>[^.]*)\.json) {
add_header X-Action $action;
}
==================== Summary ===================
Total issues: Unspecified: 0 Low: 0 Medium: 0 High: 1
3. crossplane - парсер конфига в JSON. Это инструмент от команды nginx: он превращает любой конфиг (со всеми include) в структурированный JSON и обратно. Зачем в деплое - чтобы программно проверять инварианты, которые ни gixy, ни nginx -t не ловят. Например, политика компании: "везде должен быть server_tokens off" или "ни один proxy_pass не идёт на http без TLS внутрь периметра".
Код: Выделить всё
$ crossplane parse /etc/nginx/nginx.conf | \
jq '.config[].parsed[] | select(.directive=="server_tokens")'
{
"directive": "server_tokens",
"line": 12,
"args": ["off"]
}
Конфиг как код: nginx конфиг git, шаблоны и docker
Главный сдвиг мышления: конфиг nginx - это не файлы на сервере, которые кто-то правит вживую. Это исходники в репозитории. nginx конфиг git - база всего: любое изменение проходит через ветку, ревью и историю. Эталонный ngx-trace-gateway именно так и устроен - модульная структура (nginx.conf подключает events.conf и http.conf, тот тянет 14 файлов globals/* по порядку плюс sites-enabled/*), и всё это лежит в git. Когда конфиг разбит на маленькие осмысленные файлы, diff в PR читаем, а конфликты при мерже редки.
Шаблонизация. Один и тот же конфиг для dev/staging/prod не должен копироваться вручную - меняются только апстримы, домены, лимиты. Тут два рабочих подхода:
- Ansible + Jinja2 - шаблоны nginx.conf.j2 с переменными окружения, рендер на деплое. Классика для статичной инфраструктуры.
- consul-template - рендерит конфиг из сервис-дискавери Consul и сам дёргает reload при изменении набора бэкендов. Живые апстримы без ручного редактирования.
Docker как способ доставки. Контейнер фиксирует версию nginx и набор модулей намертво. Вот компактный docker-compose в духе эталона:
Код: Выделить всё
services:
gateway:
image: openresty/openresty:latest
volumes:
- ./conf:/usr/local/openresty/nginx/conf:ro # конфиг ТОЛЬКО на чтение
- ./logs:/var/log/nginx # логи на хост
- ./cache:/var/cache/nginx # кэш на хост, переживает рестарт
ports:
- "80:80"
- "443:443"
healthcheck:
test: ["CMD", "nginx", "-t"] # здоров = конфиг валиден
interval: 30s
timeout: 5s
retries: 3
restart: unless-stopped
Откат при ошибке в этой модели тривиален: git revert на предыдущий тег образа, передеплой. История в git - твоя машина времени.
resty CLI для Lua. Если в гейтвее есть Lua-логика, синтаксис .lua-файлов нужно проверять до деплоя. Утилита resty (идёт с OpenResty) запускает Lua прямо в окружении nginx без поднятия сервера:
Код: Выделить всё
$ resty -e 'local cjson = require "cjson"; print(cjson.encode({ok=true}))'
{"ok":true}
Практика: безопасный пайплайн деплоя одним скриптом
Собираем всё в воспроизводимую цепочку lint -> test -> reload -> проверка. Это шаблон деплой-шага, который кладётся в репозиторий рядом с конфигом и вызывается из CI:
Код: Выделить всё
#!/usr/bin/env bash
set -euo pipefail # любая ошибка останавливает деплой немедленно
CONF=/etc/nginx/nginx.conf
echo ">> 1/5 gixy: аудит безопасности"
gixy "$CONF" # SSRF, http-splitting, обход alias
echo ">> 2/5 crossplane: проверка инвариантов политики"
crossplane parse "$CONF" | \
jq -e '[.. | objects | select(.directive=="server_tokens" and .args[0]=="off")] | length > 0'
echo ">> 3/5 nginx -t: синтаксис и загрузка модулей"
nginx -t
echo ">> 4/5 reload без даунтайма"
nginx -s reload
echo ">> 5/5 smoke-проверка живого ответа"
sleep 1
curl -fsS -o /dev/null -w "status=%{http_code}\n" http://127.0.0.1/healthz
echo ">> deploy OK"
Код: Выделить всё
set -euo pipefailКод: Выделить всё
nginx -s reloadТипичные грабли деплоя
- reload вместо restart там, где нужен restart. Смена user, добавление модуля через load_module, изменение числа listen-сокетов в части случаев требуют полного рестарта. reload их молча проигнорирует, и ты будешь час искать, почему "конфиг применился, а поведение старое".
- nginx -t в чужом окружении. Прогнал тест на ноуте, где собран другой nginx без нужного модуля - получил ложную ошибку или, хуже, ложный успех. Тестируй в том же образе, что катишь в прод.
- Залипшие "is shutting down". Долгие соединения держат старые воркеры живыми после reload. Без worker_shutdown_timeout они копятся, жрут память. Поставь таймаут.
- add_header исчезает. Любой add_header в дочернем блоке (location) отменяет все унаследованные add_header родителя. gixy это ловит - игнорировать его warning тут опасно, можно потерять заголовки безопасности.
- Конфиг правят на сервере мимо git. Ssh, vim, перезагрузка - и репозиторий разошёлся с реальностью. Следующий деплой из git затрёт горячую правку. Единственный источник правды - репозиторий, сервер только разворачивает.
- Забыли USR2.pid.oldbin. При бинарном апгрейде поспешили с QUIT старому мастеру до проверки нового - потеряли возможность мгновенного отката.
Повтори по шагам, нужен docker:
- Подними контейнер: (подложи минимальный nginx.conf с location /healthz { return 200 "ok"; }).
Код: Выделить всё
docker run -d --name gw -p 8080:80 -v $PWD/conf:/etc/nginx:ro openresty/openresty:latest - Установи линтеры: . Прогони
Код: Выделить всё
pip install gixy-next crossplane- убедись, что чисто.Код: Выделить всё
gixy conf/nginx.conf - Сломай конфиг намеренно: убери точку с запятой в любой директиве. Запусти - увидь, что тест падает с указанием строки. Это твой предохранитель в CI.
Код: Выделить всё
docker exec gw nginx -t - Почини, затем измени тело /healthz на "ok2" и выполни . Проверь
Код: Выделить всё
docker exec gw nginx -s reload- ответ сменился без рестарта контейнера.Код: Выделить всё
curl localhost:8080/healthz - Посмотри процессы во время reload: - найди мастер и воркеры. На долгом запросе поймай строку "is shutting down".
Код: Выделить всё
docker exec gw ps aux | grep nginx - Собери bash-скрипт из практики и прогони его целиком. Сломай инвариант (убери server_tokens off) и убедись, что до reload дело не доходит.
- Почему с битым синтаксисом конфига не приводит к даунтайму, а полный рестарт - привёл бы?
Код: Выделить всё
nginx -s reload - Что делает сигнал USR2 и в какой момент бинарного апгрейда у тебя ещё есть возможность мгновенного отката без потери соединений?
- Чем задача gixy принципиально отличается от задачи nginx -t? Приведи пример дыры, которую поймает gixy, но пропустит nginx -t.
- Зачем в docker-compose эталона конфиг монтируется ro, а логи и кэш - на хост? Что сломается, если кэш оставить внутри контейнера?
Нулевой даунтайм при деплое nginx - это не магия, а две встроенные механики (graceful reload по SIGHUP и бинарный апгрейд через USR2/QUIT) плюс дисциплина вокруг них. Дисциплина = конфиг живёт в git, проходит цепочку gixy -> crossplane -> nginx -t -> reload -> smoke-проверка, и катится через иммутабельный docker-образ с healthcheck nginx -t. Сделай этот пайплайн один раз - и деплой перестанет быть событием, требующим геройства. Именно так и должна выглядеть скучная, надёжная эксплуатация шлюза.