IP бэкенда изменился: настраиваем динамический DNS в nginx
Ситуация знакомая: DNS-запись бэкенда уже указывает на новый IP, `dig` показывает правильный ответ, а nginx продолжает отправлять запросы на старый сервер. Я, Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш», разберу, почему TTL сам по себе здесь не помогает, как включить динамический `resolve` в актуальном nginx и как проверить переключение до того, как оно станет аварией. Примеры — из переезда веб-сервисов производства печатей и штампов «ШтампЦех» (42 рабочих места), где после смены IP бэкенда заказов nginx полдня отправлял клиентов на выключенную машину.
Почему обычный upstream не следит за TTL
В классической конфигурации имя из директивы server разрешается во время чтения конфигурации — при запуске или reload nginx. Полученный IP становится частью конфигурации upstream. Дальше рабочие процессы используют этот адрес и не спрашивают DNS после истечения TTL. Поэтому команда dig api.backend.example уже может показывать новый IP, а $upstream_addr в журнале nginx — все еще старый.
Важно разделять системное разрешение имени при загрузке конфигурации и встроенный асинхронный DNS-резолвер nginx. Директива resolver относится ко второму механизму. Если просто добавить ее рядом с обычным upstream, но не поставить параметр resolve, периодического обновления адреса не появится. TTL учитывается только тогда, когда nginx действительно использует свой динамический резолвер.
Та же ловушка встречается в простой записи без именованной группы:
location / {
proxy_pass http://api.backend.example:8080;
}Хотя здесь указан домен, значение proxy_pass статично. Имя разрешается при загрузке конфигурации, а последующая смена A- или AAAA-записи не заставляет nginx автоматически заменить адрес. Перезапуск контейнера или nginx -s reload случайно исправляет проблему, но это не управление DNS, а повторный разбор конфигурации.
- DNS TTL определяет срок жизни ответа в кэше резолвера, но не обязывает любое приложение повторять запрос.
- Обычный `server hostname:port;` в upstream фиксирует разрешенные адреса до следующей загрузки конфигурации.
- Директива `resolver` без `resolve` не превращает статический upstream в динамический.
Что нужно для динамического resolve
Для open-source nginx удобный штатный вариант доступен начиная с версии 1.27.3. Именно тогда параметр resolve у директивы server, а также resolver и resolver_timeout внутри upstream стали доступны без коммерческой подписки. В старом пакете nginx 1.24 или 1.26 строка с resolve завершит проверку конфигурации ошибкой. Я поэтому начинаю не с редактирования файла, а с проверки реального бинарника:
nginx -v
nginx -VНомер репозитория или образа в документации развертывания может не совпадать с тем, что фактически запущено.
Динамическому upstream нужны три элемента: hostname с параметром resolve, общая зона памяти zone и адрес DNS-сервера в resolver. Согласно документации модуля upstream, resolver может быть задан либо в блоке http, либо внутри самого upstream — второй вариант удобнее, когда разным группам нужны разные DNS-серверы. Общая зона хранит конфигурацию и состояние группы между рабочими процессами. Размер 64k подходит как отправная точка для небольшой группы, но для множества имен и адресов его следует рассчитывать и проверять отдельно.
DNS-сервер должен быть доступен именно из сетевого пространства nginx. На обычном Linux это может быть корпоративный DNS или локальный резолвер. В контейнере адрес 127.0.0.53 обычно указывает на сам контейнер, а не на systemd-resolved хоста. В пользовательской сети Docker встроенный DNS доступен по 127.0.0.11. Я не копирую адрес из чужого примера: сначала смотрю /etc/resolv.conf внутри того процесса или контейнера, где работает nginx.
- Версия open-source nginx — не ниже 1.27.3.
- В upstream объявлена директива `zone`.
- У сервера с доменным именем указан параметр `resolve`.
- Настроенный DNS доступен с хоста или из контейнера nginx.
- Для частной зоны выбран корпоративный DNS, который ее обслуживает.
Рабочая конфигурация upstream
Для небольшого HTTP-бэкенда я использую такую базовую конфигурацию:
http {
upstream app_backend {
zone app_backend 64k;
resolver 10.10.0.53 10.10.0.54 ipv6=off;
resolver_timeout 5s;
server api.backend.example:8080 resolve
max_fails=2 fail_timeout=10s;
keepalive 32;
keepalive_timeout 15s;
}
server {
listen 80;
server_name service.example.ru;
location / {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://app_backend;
}
}
}После единственного reload, необходимого для внедрения этой конфигурации, последующие изменения IP будут обрабатываться без ручного вмешательства.
Без параметра valid nginx кэширует ответ на время, указанное в DNS TTL. Если написать resolver 10.10.0.53 valid=30s;, срок кэширования будет принудительно переопределен. Это полезно, когда зона отдает неоправданно длинный TTL, но значение не должно выбираться наугад: слишком большое увеличивает время переключения, слишком маленькое создает лишнюю нагрузку на DNS. resolver_timeout — это время ожидания ответа (по умолчанию 30s), а не интервал обновления.
Если DNS возвращает несколько A-записей, одно доменное имя представляет несколько адресов, и nginx может распределять запросы между ними. Во время миграции нельзя одновременно публиковать старый и новый IP, если один из серверов еще не готов принимать рабочий трафик. Параметры max_fails и fail_timeout обеспечивают пассивную реакцию на ошибки, но если в группе всего один server, они игнорируются и не заменяют продуманное переключение.
Для HTTPS-бэкенда одного изменения схемы недостаточно: сервер может выбирать сертификат и виртуальный хост по SNI. Имя следует задать явно:
location / {
proxy_pass https://app_backend;
proxy_ssl_server_name on;
proxy_ssl_name api.backend.example;
proxy_set_header Host api.backend.example;
}DNS отвечает за адрес назначения, а proxy_ssl_name и Host — за имя, которое увидят TLS- и HTTP-уровни бэкенда.
- `zone app_backend 64k;` — без зоны разделяемой памяти `nginx -t` откажет с сообщением «resolving names at run time requires upstream ... to be in shared memory».
- `resolver` без `valid` кэширует ответ на DNS TTL; `valid=` принудительно задаёт срок кэша.
- `ipv6=off` (или `ipv4=off` с версии 1.23.1) отключает поиск AAAA- или A-записей соответственно; по умолчанию nginx ищет оба типа.
- `resolver_timeout` по умолчанию равен 30s — для рабочего сервиса я сокращаю его до 5s, чтобы зависший DNS не держал выбор адреса.
- `max_fails`/`fail_timeout` игнорируются, если в группе один сервер: такой сервер никогда не считается недоступным.
Переменная в proxy_pass как альтернативный прием
В старых версиях nginx и в простых конфигурациях применяют другой прием: доменное имя помещают в переменную. Когда значение proxy_pass содержит переменную и не совпадает с именем объявленной upstream-группы, nginx разрешает hostname через заданный resolver во время обработки запросов. Ответы при этом кэшируются: DNS-запрос не отправляется на каждый HTTP-запрос.
Минимальная конфигурация выглядит так:
http {
resolver 10.10.0.53 10.10.0.54 valid=30s ipv6=off;
resolver_timeout 5s;
server {
listen 80;
set $backend api.backend.example:8080;
location / {
proxy_set_header Host api.backend.example;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://$backend$request_uri;
}
}
}Переменная $request_uri здесь явно сохраняет исходный путь вместе с аргументами. Если до proxy_pass выполняется rewrite, требуемый URI нужно определить отдельно, а не механически копировать этот пример.
У способа с переменной меньше возможностей управления группой: нет привычного набора серверов с весами, резервными узлами и общим runtime-состоянием. Кроме того, использование переменной меняет правила обработки URI и ограничивает некоторые автоматические преобразования, например proxy_redirect default. Для одного меняющего адрес сервиса прием работоспособен, но на поддерживаемом nginx 1.27.3 и новее я предпочитаю именованный upstream с resolve: его легче читать, расширять и диагностировать.
Отдельно о том, что увидит администратор, если резолвер не задан. В варианте с переменной конфигурация проходит nginx -t без замечаний, а проблема проявляется только на запросе: клиент получает 502, в error log появляется no resolver defined to resolve api.backend.example. Если resolver задан, но DNS не отвечает или имени нет в зоне, запись будет другой — api.backend.example could not be resolved (3: Host not found) или (110: Operation timed out), и клиент также получает 502. Поэтому после перевода на переменную я обязательно делаю тестовый запрос, а не ограничиваюсь проверкой синтаксиса.
- `resolve` в upstream — основной вариант для актуального nginx и балансировки нескольких адресов.
- Переменная в `proxy_pass` — совместимый прием для простого одиночного назначения или старой версии.
- Статический `proxy_pass http://hostname;` без переменной не дает динамического обновления.
Как проверить переключение до продакшена
Сначала я проверяю, какой ответ и TTL возвращает тот же DNS-сервер, который указан в nginx:
dig @10.10.0.53 api.backend.example A +noall +answer
dig @10.10.0.54 api.backend.example A +noall +answerЕсли nginx находится в контейнере, команды нужно выполнить из него либо из диагностического контейнера в той же сети. Проверка только с ноутбука администратора ничего не доказывает: split DNS, VPN и разные /etc/resolv.conf могут давать разные ответы.
Затем проверяю синтаксис и делаю один контролируемый graceful reload для включения новой схемы:
nginx -t
systemctl reload nginxЕсли nginx работает в контейнере, используется предусмотренная образом команда отправки сигнала или перезапуска конфигурации. Цель не в полном отказе от reload как операции, а в том, чтобы больше не выполнять его при каждой смене адреса бэкенда.
Фактический адрес назначения лучше видеть в access log. На время испытания добавляю отдельный формат:
log_format upstream_dns '$time_iso8601 status=$status '
'upstream=$upstream_addr '
'upstream_status=$upstream_status '
'request_time=$request_time '
'upstream_time=$upstream_response_time';
access_log /var/log/nginx/upstream-dns.log upstream_dns;После изменения тестовой DNS-записи я наблюдаю журнал командой tail -F /var/log/nginx/upstream-dns.log. Новый IP должен появиться после окончания TTL или заданного valid, без reload nginx.
Нужно учитывать соединения keepalive. Обновление DNS меняет набор адресов для новых выборов upstream, но уже установленное соединение к старому серверу не обязано оборваться в момент истечения TTL. Поэтому миграцию я провожу с периодом перекрытия: поднимаю новый узел, публикую его в DNS, жду TTL и максимальное разумное время жизни соединений, проверяю журналы и только потом выключаю старый узел.
- Заранее снизить TTL и дождаться истечения прежнего длинного TTL.
- Проверить DNS из сетевого пространства nginx.
- Развернуть новый бэкенд до публикации его адреса.
- Отследить `$upstream_addr`, ответы и ошибки соединения.
- Оставить старый узел доступным на период перекрытия.
- После миграции вернуть нормальный TTL.
Кейс «ШтампЦех»: nginx в Docker и резолвер 127.0.0.11
У «ШтампЦеха» 42 рабочих места: менеджеры принимают заказы на печати и штампы через веб-кабинет, макетчики забирают из него файлы оттисков, а цех видит очередь изготовления. Кабинет работает в Docker Compose: контейнер nginx принимает HTTPS, контейнер orders с приложением живёт в той же пользовательской сети. При обновлении стека контейнер orders пересоздали, он получил новый адрес в сети Docker, а nginx, поднятый раньше, продолжал слать запросы на старый. Менеджеры видели 502 примерно половину рабочего дня, пока кто-то не перезапустил весь стек.
В исходной конфигурации было proxy_pass http://orders:8080; — имя разрешилось один раз при старте nginx. Правка заняла несколько строк: встроенный DNS Docker в пользовательской сети отвечает по адресу 127.0.0.11, его и указываем, а IPv6 отключаем, потому что сеть проекта только IPv4:
upstream orders_backend {
zone orders_backend 64k;
resolver 127.0.0.11 valid=10s ipv6=off;
resolver_timeout 5s;
server orders:8080 resolve;
}Образ nginx на хосте был 1.26, поэтому сначала обновили его до стабильной ветки 1.28, где resolve уже есть. Для контейнера, который обновить нельзя, тот же результат даёт переменная: resolver 127.0.0.11 valid=10s ipv6=off; в http и set $orders orders:8080; proxy_pass http://$orders; в location.
Проверку я делал так же, как описано выше, только изнутри контейнера: docker compose exec nginx cat /etc/resolv.conf показал nameserver 127.0.0.11, затем принудительно пересоздал orders и смотрел $upstream_addr в журнале. Новый адрес появился в течение десяти секунд, ошибок 502 не было. Итог для клиента: обновления приложения больше не требуют ночного перезапуска nginx, а макетчики перестали терять загрузку файлов при выкладке.
- Резолвер `127.0.0.11` доступен только в пользовательских сетях Docker, а не в сети `bridge` по умолчанию.
- Имя сервиса Compose (`orders`) резолвится встроенным DNS Docker, внешний DNS его не знает.
- `valid=10s` уменьшает окно после пересоздания контейнера; при смене IP раз в месяц можно ставить больше.
- Если контейнер `orders` остановлен, запрос завершится 502 с записью `could not be resolved` — это нужно мониторить.
Типовые ошибки и мой итоговый чек-лист
Ошибка invalid parameter "resolve" почти всегда означает старый бинарник (до 1.27.3) или иной вариант сборки, чем ожидал администратор. Сообщение no resolver defined to resolve names at run time in upstream "app_backend" на этапе nginx -t указывает, что ни в http, ни в самом upstream не найден resolver. Если забыта zone, проверка остановится с текстом resolving names at run time requires upstream "app_backend" ... to be in shared memory. Все три условия проверяются до изменения рабочей DNS-записи.
Если адрес не меняется при корректном синтаксисе, я последовательно проверяю TTL, доступность UDP и TCP 53, ответы всех перечисленных DNS-серверов и наличие нужной зоны. Отдельно смотрю AAAA-запись: nginx по умолчанию разрешает и IPv4, и IPv6. Параметр ipv6=off оправдан, когда инфраструктура действительно работает только по IPv4; скрывать им неисправную IPv6-маршрутизацию как постоянное решение не стоит.
Мой практический выбор прост. Для nginx 1.27.3 и новее — upstream с zone, server ... resolve и доверенным resolver. Для старого сервера, который нельзя обновить сразу, — переменная в proxy_pass с обязательной проверкой URI. В обоих случаях мониторинг должен следить не только за DNS-ответом, но и за $upstream_addr, долей 502/504 и временем ответа: правильная запись в зоне еще не означает успешного подключения к приложению.
- Проверена фактическая версия nginx.
- Динамическая группа размещена в `zone`.
- Параметр `resolve` стоит у каждого изменяемого hostname.
- DNS-сервер доступен и обслуживает нужную зону.
- TTL или `valid` соответствует допустимому времени переключения.
- При HTTPS настроены SNI и `Host`.
- В журнале виден `$upstream_addr`.
- Старый и новый бэкенды перекрываются по времени миграции.
Частые вопросы
Почему nginx не обновляет IP обычного upstream после истечения TTL?
Потому что hostname обычного upstream разрешается при загрузке конфигурации. TTL не запускает повторное разрешение, если у сервера не включен динамический `resolve`.
Достаточно ли добавить директиву resolver?
Нет. Для динамического upstream нужны `zone`, параметр `resolve` у сервера и доступный `resolver`. Одна директива `resolver` поведение статического upstream не меняет.
С какой версии resolve работает в бесплатном nginx?
Параметр `server ... resolve` и директивы резолвера в upstream доступны в open-source nginx начиная с версии 1.27.3. До нее эта возможность upstream была коммерческой.
Будет ли nginx обращаться к DNS на каждый запрос?
Нет. Встроенный резолвер кэширует ответы на срок DNS TTL либо на период, заданный параметром `valid`.
Почему после смены DNS nginx некоторое время обращается к старому IP?
Еще не закончился TTL или `valid`, либо nginx повторно использует уже открытое keepalive-соединение. Старый сервер следует оставлять доступным на период безопасного перекрытия.
Какой resolver указывать в Docker?
В пользовательской сети Docker обычно используется встроенный DNS `127.0.0.11`. Но адрес нужно проверять внутри конкретного контейнера; loopback-адрес резолвера хоста из контейнера обычно недоступен.
Что означает ошибка «could not be resolved» в error log?
`resolver` задан, но DNS-сервер не вернул адрес: имени нет в зоне, сервер недоступен или истёк `resolver_timeout`. Клиент получает 502. Проверьте `dig @адрес_резолвера имя` из того же контейнера или хоста, где работает nginx.
Источники
- Документация nginx: модуль ngx_http_upstream_module — Параметр resolve у server (требование зоны разделяемой памяти, resolver в http или upstream), директивы zone, resolver, resolver_timeout, keepalive_timeout: https://nginx.org/ru/docs/http/ngx_http_upstream_module.html
- Документация nginx: модуль ngx_http_core_module — Директива resolver: кэширование по TTL, параметры valid, ipv4=off/ipv6=off, resolver_timeout: https://nginx.org/ru/docs/http/ngx_http_core_module.html#resolver
- Документация nginx: модуль ngx_http_proxy_module — proxy_pass с переменными: поиск имени среди групп серверов, иначе через resolver; правила передачи URI: https://nginx.org/ru/docs/http/ngx_http_proxy_module.html#proxy_pass
- nginx CHANGES — Changes with nginx 1.27.3 (26 Nov 2024): параметр resolve у server в upstream, директивы resolver и resolver_timeout в upstream: https://nginx.org/en/CHANGES
- Официальный анонс nginx 1.27.3 — Рассылка nginx-announce, 26 ноября 2024 года: https://mailman.nginx.org/pipermail/nginx-announce/2024/WSOA5BERDSWSFOBY6H5VO7SICBG6R5B5.html
- Docker Docs: Networking overview — Раздел DNS services: встроенный DNS-сервер 127.0.0.11 для контейнеров в пользовательских сетях: https://docs.docker.com/engine/network/#dns-services
