DNS-баг, который бьёт в самый неподходящий момент — и почему старое решение лишь усугубляло ситуацию
Есть особая категория инфраструктурных багов, которые особенно тяжело отлаживать: они проявляются только под нагрузкой. В спокойном состоянии всё работает нормально. Но стоит выкатить релиз или получить всплеск трафика, который заставляет автоскейлер вступить в работу, — и вы начинаете гоняться за 502-ми, которые сами по себе проходят через несколько минут. Первая мысль — виновато приложение. Потом — деплой. Потом — кластер. В конце концов — вы сами.
Именно с таким сценарием мы столкнулись в Simon AI. Виновником оказалось кое-что фундаментальное в том, как nginx работает с DNS, — и чтобы решить проблему по-настоящему, пришлось ждать фичи, которую наконец открыли в конце 2024 года.
Вот что мы обнаружили, что пробовали и как в итоге всё починили.
Проблема: nginx не умеет забывать
По умолчанию nginx резолвит (разрешает) имена хостов в блоках proxy_pass и upstream один раз — при запуске — и больше никогда, пока не перезагрузить конфигурацию. IP-адреса кэшируются бессрочно, вне зависимости от того, что говорит DNS.
Для классической инфраструктуры это не проблема: IP-адреса бэкендов не меняются, и никто ничего не замечает.
В Kubernetes это беда.
Pod IP-адреса в Kubernetes эфемерны по своей природе. Каждый раз, когда под перезапускается, перепланируется, масштабируется вверх или вниз или заменяется при rolling-деплое, он получает новый IP. Если nginx закэшировал старый адрес, он продолжает отправлять туда запросы — к поду, которого уже не существует, — пока сам не перезапустится. В итоге получаем ошибки 502 Bad Gateway именно тогда, когда кластер делает то, что и должен делать.
Это не баг Kubernetes и не баг nginx сам по себе. Это несоответствие (impedance mismatch) между предположениями nginx о статической инфраструктуре и динамической реальностью кластера.
Деплои — наиболее очевидный триггер, но любой кластер с автоскейлингом подвержен той же проблеме. Когда HPA масштабирует нагрузку в ответ на трафик, поды непрерывно создаются и удаляются. Ирония в том, что это происходит наиболее интенсивно в периоды высокой нагрузки — именно тогда, когда устаревшие upstream IP-адреса и упавшие соединения причиняют максимальный ущерб. При деплое у вас хотя бы есть чёткое событие, с которым можно соотнести 502-е. При автоскейлинге вы просто видите периодические ошибки во время пиков трафика без очевидной причины.
Небольшое отступление: ClusterIP и Headless Services
Чтобы понять, почему наша конфигурация была особенно уязвима, полезно разобраться в том, как устроен DNS в Kubernetes.
Большинство сервисов Kubernetes используют ClusterIP — стабильный виртуальный IP, который никогда не меняется. DNS резолвится в этот VIP, а kube-proxy берёт на себя маршрутизацию запросов к реальным подам за ним. Поскольку VIP стабилен, кэширование DNS на старте nginx здесь не является проблемой: IP, закэшированный при запуске, по-прежнему актуален.
Headless-сервисы работают иначе. Когда вы указываете clusterIP: None, Kubernetes полностью отказывается от виртуального IP. Вместо этого DNS возвращает набор A-записей — по одной на каждый под. Клиент (в нашем случае nginx) видит IP-адреса подов напрямую и сам выполняет балансировку нагрузки.
Это мощный подход: вы получаете балансировку на уровне приложения, keepalive-соединения к конкретным подам и богатую наблюдаемость через upstream-логирование nginx. Но цена такого подхода в том, что A-записи постоянно меняются по мере того, как поды приходят и уходят. А nginx, кэширующий DNS на старте, об этих изменениях ничего не знает.
В нашем стеке Django/Gunicorn мы используем headless-сервисы именно потому, что хотим задействовать upstream-возможности nginx: keepalive, least_conn, retry-логику через proxy_next_upstream. Поэтому мы находились прямо в зоне уязвимости.
Стандартный костыль (и почему он неполноценен)
Если вы гуглили «nginx Kubernetes DNS 502», вы почти наверняка нашли стандартный обходной путь: использовать переменную в proxy_pass, что вынуждает nginx соблюдать директиву resolver и перерезолвить DNS по истечении TTL.
resolver kube-dns.kube-system.svc.cluster.local valid=10s;
location / {
set $backend "my-service.default.svc.cluster.local:8000";
proxy_pass http://$backend;
}
Это работает. Nginx перестаёт кэшировать DNS навсегда. Но у подхода есть существенный подводный камень: как только вы используете переменную в proxy_pass, вы полностью выходите из модуля upstream.
А это означает потерю:
-
Keepalive-соединений — резко растёт количество переподключений и сокетов в состоянии TIME_WAIT, что при высокой нагрузке может стать узким местом масштабируемости.
-
Алгоритмов балансировки нагрузки — ни
least_conn, ни взвешенного распределения, ни логики failover. -
Проверок состояния —
max_failsиfail_timeoutне применяются. -
Retry-логики —
proxy_next_upstreamне может перенаправить на другой сервер, когда определён только один эндпоинт и возвращается единственный IP-адрес.
По сути, вы меняете одну видимую проблему на набор более тихих и коварных. Мы использовали этот костыль какое-то время, прежде чем заметили, что поведение соединений вниз по стеку не соответствует нашим ожиданиям.
Что изменилось в Nginx 1.27.3
26 ноября 2024 года nginx выпустил версию 1.27.3 и открыл исходный код функции, которая более десяти лет была эксклюзивом NGINX Plus: параметр resolve для блоков server внутри upstream.
Сама команда nginx написала в анонсе: «С ростом популярности микросервисов и распространением NGINX в Kubernetes-окружениях» они решили сделать динамическое разрешение DNS доступным в открытой версии.
Суть resolve проста: nginx в фоне отслеживает DNS-записи для вашего upstream-хоста и автоматически обновляет пул upstream при изменении IP-адресов — без перезапуска и без костылей.
Ключевое отличие в том, что эта функция работает внутри блока upstream, а не заменяет его. Поэтому вы сохраняете всё: keepalive-соединения, балансировку нагрузки, проверки состояния, retry-логику. В апреле 2025 года функция вошла в стабильный релиз nginx 1.28.0.
Как мы это реализовали
Вот итоговый шаблон конфигурации, который мы применили в наших Helm-чартах для сервисов Django/Gunicorn.
Шаг 1: Headless-сервис для обнаружения подов через DNS
apiVersion: v1
kind: Service
metadata:
name: myapp-api-gunicorn-headless
spec:
clusterIP: None
ports:
- port: 8000
targetPort: gunicorn
protocol: TCP
name: gunicorn
selector:
app: myapp-api-gunicorn
Шаг 2: Nginx upstream с resolve, zone и keepalive
resolver kube-dns.kube-system.svc.cluster.local valid=5s ipv6=off;
resolver_timeout 5s;
upstream app_server {
zone app_server 64k;
server myapp-api-gunicorn-headless.mynamespace.svc.cluster.local:8000
resolve fail_timeout=5s max_fails=3;
keepalive 32;
keepalive_timeout 60s;
}
Здесь стоит отметить два важных момента. Директива zone (разделяемая память) обязательна при использовании resolve — nginx использует её для синхронизации состояния upstream между рабочими процессами. Без неё nginx откажется стартовать с понятным сообщением об ошибке. Директива resolver, указывающая на CoreDNS (kube-dns.kube-system.svc.cluster.local), тоже обязательна: nginx должен знать, куда отправлять DNS-запросы. Флаг ipv6=off предотвращает лишние AAAA-запросы в кластерах только с IPv4.
Шаг 3: Location для проксирования с retry-логикой
location @proxy_to_app {
proxy_http_version 1.1;
proxy_set_header Connection ""; # Обязательно для keepalive upstream
proxy_connect_timeout 300ms;
proxy_read_timeout 310s;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
proxy_next_upstream_tries 10;
proxy_next_upstream_timeout 30s;
proxy_pass http://app_server;
}
Конфигурация proxy_next_upstream обеспечивает мгновенный failover: если запрос попадает на под, который находится в процессе завершения, nginx повторяет попытку на здоровом поде, не возвращая 502 клиенту.
Шаг 4: Фиксируем версию nginx в Helm
frontend:
image:
repository: nginx
tag: "1.27" # Предполагается наличие 1.27.3, иначе используйте 1.28
Просто, но легко упустить. Ниже версии 1.27.3 директива resolve молча отсутствует.
Как это работает на практике
Сквозной процесс теперь выглядит так:
-
Nginx стартует, резолвит hostname headless-сервиса и получает A-записи для всех трёх подов Gunicorn.
-
Эти IP помещаются в разделяемую память зоны; nginx балансирует нагрузку между ними через keepalive-соединения.
-
Каждые 5 секунд (или в соответствии с настроенным DNS TTL) nginx в фоне повторно резолвит DNS.
-
Начинается смена подов — будь то rolling-деплой, событие масштабирования HPA или перепланирование на другой узел: Kubernetes создаёт новые поды и завершает старые.
-
CoreDNS обновляет A-записи, отражая изменения.
-
На следующем цикле резолвинга nginx добавляет новые IP подов и убирает устаревшие.
-
Небольшое окно после завершения пода, до того как DNS-изменения распространятся, всё ещё существует, но
proxy_next_upstreamпрозрачно обрабатывает его, повторяя упавшие запросы на здоровых подах. -
Любой запрос, попавший на завершающийся под, перенаправляется через
proxy_next_upstream.
Результат: нулевое или почти нулевое количество 502-х во время той активности кластера, которая раньше их вызывала, — при этом с той эффективностью соединений, которую мы не получали от костыля с переменной.
Итог
Если вы запускаете nginx в Kubernetes и используете headless-сервисы — или рассматриваете их для балансировки нагрузки на уровне приложения — nginx 1.27.3+ с директивой resolve является правильной конфигурацией. Старый костыль с переменной был разумным хаком, но он менял одну видимую проблему на несколько более тихих.
Более широкий урок, с которым мы сталкивались не раз: иногда чистого решения ещё не существует, и лучшее, что можно сделать, — чётко понять компромиссы своего обходного пути. Когда правильный инструмент наконец появляется, вы точно знаете, зачем его использовать.
Если вы на NGINX Plus, это поведение доступно начиная с R2 — открытый исходный код просто догоняет. Для всех остальных, кто запускает стандартный nginx в Kubernetes-кластерах, стоит обновиться и сделать всё правильно.