Представь типичную инфру 2026 года. Бэкенды живут в контейнерах, оркестратор поднимает и гасит реплики по нагрузке, IP-адреса меняются каждые пару минут, а Consul или Docker рисуют тебе актуальную карту сервисов. И посреди этого стоит твой nginx с прибитым гвоздями статическим upstream:
Код: Выделить всё
upstream backend {
server 10.0.0.1:9000;
server 10.0.0.2:9000;
keepalive 64;
}
Reload не бесплатный. Команда nginx -s reload (или systemctl reload) запускает мягкую перезагрузку: мастер читает новый конфиг, поднимает новые воркеры, а старым говорит "доработайте текущие соединения и умрите". Звучит гладко, но на дистанции это так:
- обнуляются все счётчики stub_status и внутренние метрики воркеров - твой Prometheus видит пилу на графиках;
- старые воркеры висят в состоянии shutting down, держа долгие keepalive и WebSocket-соединения, и при частых reload их накапливается десятки (так называемые shutting-down worker leak);
- прогретые кэши (open_file_cache, ssl_session_cache в памяти воркера, lua shared-структуры) сбрасываются;
- если конфиг внезапно невалиден - reload молча откатится на старый, и ты не сразу заметишь, что деплой бэкендов не доехал.

nginx resolver и переменная в proxy_pass: DNS как точка истины
Самый старый и самый коварный трюк. По умолчанию nginx резолвит имена бэкендов один раз при старте/reload и кэширует IP навсегда. Для контейнеров это катастрофа: имя app в Docker-сети сегодня указывает на 172.18.0.5, завтра на 172.18.0.9, а nginx про это не узнает.
Лечится двумя способами. Первый - старый, через переменную в proxy_pass. Как только адрес апстрима задан переменной, nginx обязан резолвить его в рантайме через директиву resolver:
Код: Выделить всё
location /api/ {
resolver 127.0.0.11 valid=10s ipv6=off; # встроенный DNS Docker
set $upstream_app http://app:9000;
proxy_pass $upstream_app$request_uri;
}
Поэтому в современном nginx (параметр resolve в open-source появился в 1.27.3 и доехал до stable 1.30) правильнее резолвить прямо в upstream-блоке:
Код: Выделить всё
resolver 127.0.0.11 valid=10s ipv6=off;
upstream backend {
zone backend 256k; # ОБЯЗАТЕЛЬНО: пиры в shared memory
server app.internal:9000 resolve max_fails=2 fail_timeout=5s;
keepalive 32;
}
server {
location /api/ { proxy_pass http://backend; }
}
Angie API upstream: добавить бэкенд без reload одним PATCH
DNS - это всё равно опосредованно: ты управляешь не nginx, а зоной. Хочется прямого рычага - REST-ручки, которая меняет состав upstream немедленно. В мире nginx это исторически было привилегией платного nginx Plus (его /api позволяет POST/PATCH/DELETE серверов). В 2026 живая бесплатная альтернатива - Angie, форк nginx с детальным /status JSON по http/upstreams/zones и динамическим управлением апстримами через REST.
Сначала включаем API. Эндпоинт api отдаёт и метрики, и (в Angie PRO) запись в конфиг:
Код: Выделить всё
http {
upstream backend {
zone backend 1m; # zone обязателен для динамики
server 10.0.0.1:9000;
}
server {
listen 127.0.0.1:8080;
location /status/ { api /status/; } # чтение метрик
location /config/ { # запись (PRO)
api /config/;
allow 10.0.0.0/8;
deny all;
}
}
}
Код: Выделить всё
$ curl -s http://127.0.0.1:8080/status/http/upstreams/backend
{
"peers": {
"10.0.0.1:9000": {
"server": "10.0.0.1:9000",
"state": "up",
"selected": { "current": 3, "total": 1841 },
"health": { "fails": 0, "unavailable": 0 }
}
}
}
Код: Выделить всё
# добавить бэкенд без reload
$ curl -s -X PUT \
http://127.0.0.1:8080/config/http/upstreams/backend/servers/10.0.0.2:9000/ \
-d '{ "weight": 2, "max_fails": 2, "fail_timeout": "5s" }'
{ "success": "Created", ... }
Код: Выделить всё
# мягкий вывод: drain
$ curl -X PATCH http://127.0.0.1:8080/config/http/upstreams/backend/servers/10.0.0.1:9000/ \
-d '{ "drain": true }'
# окончательное удаление пира
$ curl -X DELETE http://127.0.0.1:8080/config/http/upstreams/backend/servers/10.0.0.1:9000/
Service discovery: Docker-метки в Angie и Consul-template для всех остальных
REST-ручку всё равно надо кому-то дёргать. В динамичной инфре этим занимается service discovery. Два практичных пути.
1. Нативное автообнаружение по Docker-меткам (Angie 1.10+). Это, пожалуй, главная фича 2026 для тех, кто живёт в docker-compose. Angie сам коннектится к Docker-сокету, слушает поток событий (контейнер поднялся/упал) и по меткам контейнера сам наполняет upstream - без внешних скриптов и без reload:
Код: Выделить всё
http {
docker_endpoint unix:/var/run/docker.sock;
upstream backend {
zone backend 1m; # пиры из Docker кладутся сюда
}
}
Код: Выделить всё
services:
app:
image: myapp:latest
labels:
- "angie.network=app_net"
- "angie.http.upstreams.backend.port=9000"
- "angie.http.upstreams.backend.weight=2"
- "angie.http.upstreams.backend.max_fails=2"
2. Consul-template - универсальный, но через reload. Когда источник истины - это Consul (k8s, мультидата-центр, классический nginx без Angie), используют связку consul-template. Это отдельный демон: он подписывается на каталог Consul, при изменении состава сервиса рендерит .conf из шаблона и дёргает reload:
Код: Выделить всё
upstream backend {
zone backend 1m;
{{ range service "app" }}
server {{ .Address }}:{{ .Port }} max_fails=2 fail_timeout=5s;
{{ else }}
server 127.0.0.1:9000 down; # заглушка, чтоб конфиг был валиден
{{ end }}
}
Код: Выделить всё
consul-template \
-template "backend.ctmpl:/etc/nginx/upstream.conf:nginx -t && nginx -s reload"
balancer_by_lua: полностью динамический выбор апстрима на OpenResty
Когда логика выбора бэкенда сложнее, чем "round-robin по списку" - канареечный роутинг по заголовку, шардирование по user_id, выбор дата-центра по гео - на сцену выходит OpenResty и фаза balancer_by_lua. Идея радикальная: upstream-блок становится почти пустым, а реального пира на каждый запрос выбирает Lua через ngx.balancer.set_current_peer. Это самый гибкий механизм Lua-балансировки, но и самый низкоуровневый.
Код: Выделить всё
upstream dynamic {
server 0.0.0.1; # фейк-пир: обязателен, реальный выберет Lua
balancer_by_lua_block {
local balancer = require "ngx.balancer"
-- сюда твою логику discovery: из shared dict, из Consul, из DNS
local peers = ngx.shared.peers
local addr = peers:get("app") or "10.0.0.1:9000"
local host, port = addr:match("^(.-):(%d+)$")
local ok, err = balancer.set_current_peer(host, tonumber(port))
if not ok then
ngx.log(ngx.ERR, "set_current_peer failed: ", err)
return ngx.exit(502)
end
balancer.set_more_tries(2) -- разрешить ретрай на другом пире
}
keepalive 16;
}
Код: Выделить всё
-- worker_init или таймер, НЕ в самом balancer-фазе на каждый запрос
local resolver = require "resty.dns.resolver"
local r = resolver:new{ nameservers = {"127.0.0.11"}, timeout = 2000 }
local answers = r:query("app.internal", { qtype = r.TYPE_A })
-- сложить answers[idx].address в ngx.shared.peers по таймеру раз в N секунд
Когда что: короткая матрица выбора
- resolver + resolve в upstream - бэкенды за внутренним DNS (Docker, k8s headless, Consul DNS). Чистый open-source, no-reload. Грабля: TTL/кэш, нужен zone.
- Angie Docker-метки - docker-compose/Swarm, хочешь zero-config discovery без скриптов. Бесплатно, no-reload.
- Angie API (/config PATCH) - есть свой оркестратор/агент, нужен прямой императивный контроль и drain. Write-API - это PRO.
- consul-template - источник истины Consul, классический nginx. Universal, но через reload (ставь debounce + nginx -t).
- balancer_by_lua - нестандартная логика выбора (канарейка, шардинг, гео). Максимум гибкости, максимум ответственности.
Повтори руками, 15 минут. Нужен Docker.
- Подними Angie: docker run -d --name angie -p 8080:8080 -v $PWD/angie.conf:/etc/angie/angie.conf:ro -v /var/run/docker.sock:/var/run/docker.sock:ro docker.angie.software/angie:latest
- В конфиге включи docker_endpoint unix:/var/run/docker.sock; и пустой upstream backend { zone backend 1m; } плюс location /status/ { api /status/; }
- Запусти два контейнера-эхо с метками: docker run -d --label "angie.http.upstreams.backend.port=80" --label "angie.network=bridge" hashicorp/http-echo -text=one (повтори для two).
- Проверь, что оба пира появились БЕЗ reload: curl -s localhost:8080/status/http/upstreams/backend - в peers два адреса.
- Погаси один контейнер docker stop ... и снова дёрни /status - пир исчез сам. Никакого nginx -s reload ты не вызывал ни разу.
- Бонус: замени метки на upstream через DNS - добавь resolver 127.0.0.11 valid=10s; и server tasks.app resolve; в zone-upstream, отмасштабируй сервис и смотри, как меняется список.
Контрольные вопросы
- Почему reload - это не бесплатная операция, и какие три конкретных побочных эффекта он даёт на нагруженном шлюзе?
- Зачем upstream с параметром resolve обязательно требует директиву zone, и что произойдёт без неё?
- Чем drain отличается от немедленного DELETE пира в Angie API, и в каком сценарии drain критичен?
- Почему в фазе balancer_by_lua нельзя резолвить домен через lua-resty-dns на каждый запрос, и куда вынести discovery?
Статический upstream хорош для статичной инфры, но автоскейл и контейнеры требуют менять состав бэкендов без reload, который обнуляет метрики и плодит зомби-воркеры. Четыре инструмента 2026: resolver + resolve в upstream (DNS-истина, чистый open-source), нативное Angie-discovery по Docker-меткам (zero-config для compose), Angie API с PATCH/drain (императивный контроль, write - PRO), consul-template (универсально, но через reload) и balancer_by_lua для нестандартной логики выбора. Выбирай по источнику истины и требуемой гибкости - и держи софт обновлённым, динамика динамикой, а CVE никто не отменял.