Nginx DNS-баг в Kubernetes: решение с resolve в 1.27.3

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 молча отсутствует.

Как это работает на практике

Сквозной процесс теперь выглядит так:

  1. Nginx стартует, резолвит hostname headless-сервиса и получает A-записи для всех трёх подов Gunicorn.

  2. Эти IP помещаются в разделяемую память зоны; nginx балансирует нагрузку между ними через keepalive-соединения.

  3. Каждые 5 секунд (или в соответствии с настроенным DNS TTL) nginx в фоне повторно резолвит DNS.

  4. Начинается смена подов — будь то rolling-деплой, событие масштабирования HPA или перепланирование на другой узел: Kubernetes создаёт новые поды и завершает старые.

  5. CoreDNS обновляет A-записи, отражая изменения.

  6. На следующем цикле резолвинга nginx добавляет новые IP подов и убирает устаревшие.

  7. Небольшое окно после завершения пода, до того как DNS-изменения распространятся, всё ещё существует, но proxy_next_upstream прозрачно обрабатывает его, повторяя упавшие запросы на здоровых подах.

  8. Любой запрос, попавший на завершающийся под, перенаправляется через proxy_next_upstream.

Результат: нулевое или почти нулевое количество 502-х во время той активности кластера, которая раньше их вызывала, — при этом с той эффективностью соединений, которую мы не получали от костыля с переменной.

Итог

Если вы запускаете nginx в Kubernetes и используете headless-сервисы — или рассматриваете их для балансировки нагрузки на уровне приложения — nginx 1.27.3+ с директивой resolve является правильной конфигурацией. Старый костыль с переменной был разумным хаком, но он менял одну видимую проблему на несколько более тихих.

Более широкий урок, с которым мы сталкивались не раз: иногда чистого решения ещё не существует, и лучшее, что можно сделать, — чётко понять компромиссы своего обходного пути. Когда правильный инструмент наконец появляется, вы точно знаете, зачем его использовать.

Если вы на NGINX Plus, это поведение доступно начиная с R2 — открытый исходный код просто догоняет. Для всех остальных, кто запускает стандартный nginx в Kubernetes-кластерах, стоит обновиться и сделать всё правильно.

© 2026 meganuke