АйТи Фреш
Главная / Статьи / DevOps и автоматизация
DevOps и автоматизация

Почему две replicas в Docker Compose не дают failover за Nginx

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~8 мин чтения
Почему две replicas в Docker Compose не дают failover за Nginx

Вы подняли второй контейнер `api`, увидели обе реплики в `docker compose ps` и решили, что теперь Nginx переживёт остановку одной из них. Затем пришёл обычный deploy: контейнер получил новый IP, а прокси продолжил стучаться в старый и посыпал `502 Bad Gateway`. Я, Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш», разбирал такую схему руками не раз. Ниже покажу, кто именно хранит устаревший адрес, почему `replicas: 2` не является настройкой failover и какой конфиг я ставлю в 2026 году, чтобы Nginx обновлял пул без reload и ограниченно повторял запрос на живой экземпляр.

Две реплики — это ещё не failover

У обычного Docker Compose простая зона ответственности: прочитать модель проекта и создать нужное число контейнеров. Число можно задать через `scale: 2`, через `deploy.replicas: 2` или флагом `docker compose up -d --scale api=2`. От этого не появляется отдельный L7-балансировщик, активная проверка HTTP и постоянно работающий контроллер, который после завершения `compose up -d` сверяет желаемое состояние с фактическим. Политика `restart` может поднять завершившийся контейнер, но удалённый контейнер Compose сам по себе не воссоздаст.

В этой цепочке три независимых участника. Compose создаёт endpoints и сетевые aliases. Встроенный DNS Docker сообщает текущие адреса имени `api`. Nginx выбирает peer, устанавливает соединение и решает, можно ли повторить запрос. Если последний участник запомнил старый ответ DNS, две живые записи в Docker уже не помогают. И не переносите сюда примеры `endpoint_mode: vip` из Swarm: у standalone `docker compose` нет сервисного VIP, который незаметно балансирует контейнеры.

Есть и два бытовых препятствия, на которых команды спотыкаются раньше DNS. Сервис с явным `container_name` нельзя масштабировать больше одного экземпляра. Фиксированная публикация вроде `ports: ["8080:8080"]` тоже конфликтует: две реплики не могут занять один порт хоста. Я оставляю порт API внутри общей Compose-сети, а наружу публикую только `80/443` Nginx. К backend прокси идёт по container port `8080`, не по host port.

Первым делом исправляйте обновление адресов в Nginx. Третья реплика не лечит статический список: она может вообще не попасть в известный прокси пул.

Где Nginx запоминает умерший IP

Проблемный фрагмент почти всегда выглядит так: `upstream api_pool { server api:8080; }`. При чтении конфигурации Nginx разрешает имя системным resolver и превращает ответ в набор peers. Если DNS в этот момент вернул два A-адреса, Nginx действительно будет распределять запросы между ними. Но без параметра `resolve` он не следит за дальнейшими изменениями. Контейнер пересоздали, старый адрес исчез из DNS, новый появился — загруженный upstream остался прежним до reload.

Самый наглядный сбой получается, когда Nginx стартовал при одной реплике, а `api` потом масштабировали до двух. Docker DNS уже знает оба IP, однако Nginx знает только первый. После смерти именно первого у него нет второго peer, хотя контейнер работает рядом. Если обе реплики существовали до запуска Nginx, картина мягче: стандартные `proxy_next_upstream error timeout` и пассивные `max_fails/fail_timeout` способны скрыть часть connection errors. Но старый peer не исчезает навсегда — его снова пробуют, а новый IP не добавляется. Поэтому обещать «ровно половину 502» неправильно, но считать такую схему сходящейся тоже нельзя.

В пользовательской bridge-сети Docker предоставляет контейнерам встроенный DNS по `127.0.0.11`. Документация Docker прямо говорит: после update новый контейнер может получить другой IP под тем же именем, а клиент должен заново разрешить имя и переподключиться. Есть ещё неприятная деталь. В текущем исходном коде Moby внутренние A/AAAA-ответы получают TTL 600 секунд. Это деталь реализации, не вечный контракт Compose, но без переопределения TTL даже динамический Nginx может держать адрес до десяти минут. Поэтому я задаю короткий `valid`, а не надеюсь на значение Docker по умолчанию.

`127.0.0.11` — адрес внутри network namespace контейнера на пользовательской Docker-сети. Если Nginx работает на хосте, в `network_mode: host` или вне Docker, этот resolver использовать нельзя.
Почему две replicas в Docker Compose не дают failover за Nginx — схема

Мой выбор в 2026 году: zone, resolve и короткий valid

Я выбираю open source Nginx с нативным динамическим upstream. Параметр `resolve` стал доступен без подписки начиная с 1.27.3, но 1.27.3 — это нижняя граница функции, а не совет по безопасности. На 5 сентября 2026 года актуальная stable-версия — 1.30.4; её и фиксирую в образе либо беру более новый поддерживаемый пакет с исправлениями. Плавающий `latest` в production мне не нужен: конфиг должен проходить тест на той же версии, которая поедет в работу.

Конфигурацию ниже можно положить в `/etc/nginx/conf.d/default.conf`, который официальный образ подключает внутри блока `http`: ```nginx log_format upstream '$request $status upstream=$upstream_addr ' 'upstream_status=$upstream_status ' 'connect=$upstream_connect_time response=$upstream_response_time'; upstream api_pool { zone api_pool 1m; resolver 127.0.0.11 valid=5s ipv6=off; resolver_timeout 2s; server api:8080 resolve max_fails=1 fail_timeout=5s; keepalive 32; } server { listen 80; access_log /var/log/nginx/access.log upstream; location / { proxy_pass http://api_pool; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_connect_timeout 1s; proxy_read_timeout 15s; proxy_next_upstream error timeout invalid_header http_502 http_503 http_504; proxy_next_upstream_tries 2; proxy_next_upstream_timeout 3s; } } ```

Минимальный Compose-фрагмент у меня такой: ```yaml name: vector services: nginx: image: nginx:1.30.4-alpine ports: - "80:80" volumes: - ./default.conf:/etc/nginx/conf.d/default.conf:ro networks: [edge] api: image: registry.example.ru/vector/api:2026.08.17 scale: 2 expose: - "8080" restart: unless-stopped healthcheck: test: ["CMD", "/app/healthcheck"] interval: 10s timeout: 2s retries: 3 start_period: 20s networks: [edge] networks: edge: {} ``` С `deploy.replicas: 2` или `--scale api=2` суть не меняется. Я использовал `scale`, потому что речь именно о standalone Compose, и всегда проверяю итог через `docker compose config`.

Здесь нельзя выкинуть ни один из трёх элементов: `zone` помещает runtime-состояние upstream в shared memory и требуется для `resolve`; `resolver` указывает Docker DNS и переопределяет его TTL до пяти секунд; `server ... resolve` включает наблюдение за изменениями адресов. `resolver_timeout` ограничивает ожидание DNS. Пять секунд — мой практический старт, не стандарт: для очень жёсткого SLA можно взять секунду и измерить DNS-нагрузку. Зона 1 МБ для двух реплик заведомо достаточна и на требования к железу не влияет.

После правки сначала запускайте `docker compose run --rm nginx nginx -t`, затем разворачивайте образ и проверяйте `nginx -T`. Ошибка `invalid parameter "resolve"` означает старый Nginx; отсутствие `zone` — не повод возвращаться к reload-скриптам.

Условный стенд «Вектор»: как проявился и исчез 502

Приведу воспроизводимый стенд условной производственной компании «Вектор» на 120 рабочих мест. Это собранный мной разбор типовой конфигурации, а не публикация данных конкретного клиента. Один сервер: 8 vCPU, 16 ГБ RAM, Ubuntu Server 24.04 LTS, Docker Engine 29.6.2 и Docker Compose 5.3.1. Nginx 1.30.4 и API работали в одной bridge-сети; каждая реплика API потребляла около 180 МБ RAM и выдерживала 140 запросов в секунду, рабочая нагрузка составляла 35–60 RPS.

Сначала мы подняли одну реплику. Docker выдал `api` адрес `172.23.0.3`, и статический `server api:8080;` сохранил только его. Затем команда `docker compose up -d --scale api=2 api` добавила `172.23.0.4`. `nslookup api 127.0.0.11` внутри Nginx показывал оба адреса, но `$upstream_addr` в логах оставался только `172.23.0.3:8080`. После `docker rm -f vector-api-1` живой `.4` остался в DNS, однако Compose сам не восстановил удалённый контейнер, а для Nginx существовал всё тот же мёртвый `.3`.

Генератор отправлял ровно 60 GET-запросов в секунду. За две минуты до ручного reload он получил 7200 ответов 502 из 7200: у загруженного upstream действительно не было другого peer. `nginx -s reload` мгновенно подхватил `.4` и `.5`, что окончательно отделило проблему приложения от проблемы списка адресов. Если бы Nginx стартовал уже с двумя репликами, результат был бы менее драматичным из-за retry, но следующая замена обеих реплик снова оставила бы только устаревший набор.

После перехода на `zone + resolver + resolve` мы повторили 30 циклов: добавление второй реплики, удаление выбранной, восстановление количества и проверка нового IP. Всего — 54 000 запросов при 60 RPS. Клиентских 502 не осталось; 41 запрос занял больше 500 мс, худший — 1,04 с, потому что сначала попал на ещё не обновлённый адрес и упёрся в `proxy_connect_timeout`, затем повторился на живой peer. Новый набор Nginx принимал за 1–5 секунд без reload. Именно такой результат я считаю честным: не «мгновенно», а ограниченное окно и предсказуемый retry.

Цифры относятся к описанному условному стенду. В вашей сети умерший IP может дать быстрый `connection refused` либо ждать ARP/TCP timeout; поэтому копировать таймауты без теста под своей нагрузкой нельзя.

Retry и healthcheck закрывают разные дыры

DNS-обновление не бывает атомарным относительно пользовательского запроса. В течение `valid=5s` один запрос ещё может выбрать старый peer. Для этого и нужен ограниченный `proxy_next_upstream`: `tries 2` включает первую upstream-попытку, одна секунда отведена на каждый connect, а три секунды — бюджет, в пределах которого Nginx разрешено переходить между peers; это не общий timeout успешного ответа. Я повторяю `error`, `timeout`, `invalid_header` и gateway-коды, но не добавляю `http_500`: прикладная ошибка не доказывает смерть экземпляра. `proxy_read_timeout 15s` тоже не универсален — это интервал между операциями чтения, его надо согласовать с поведением конкретного API.

С изменяющими состояние запросами осторожнее. По умолчанию Nginx не повторяет `POST`, `LOCK` и `PATCH`, если запрос уже был отправлен upstream; параметр `non_idempotent` снимает эту защиту. Я его не включаю. Оплата, создание заказа и проведение документа должны иметь idempotency key на уровне приложения, иначе сетевой retry способен сделать дубль. При `proxy_request_buffering off` повтор после начала отправки body вообще может стать невозможен. И если backend успел послать часть ответа клиенту, следующий upstream уже не исправит ситуацию.

Docker `healthcheck` решает ещё одну задачу: выставляет контейнеру состояние `healthy/unhealthy` и может задержать старт зависимости через `depends_on: condition: service_healthy`. Он не исключает живой, но unhealthy-контейнер из Docker DNS и сам не запускает `restart`; обычная restart policy реагирует на завершение контейнера. Я оставляю дешёвый `/healthcheck`, но не включаю в него каждую необязательную интеграцию. Иначе падение внешней CRM объявит больными обе реплики и создаст restart storm, если приложение ещё и завершает процесс на каждый провал.

В open source Nginx эта схема использует пассивное обнаружение: первый реальный connection error временно помечает peer через `max_fails/fail_timeout`. Активная директива `health_check` остаётся функцией NGINX Plus. Для небольшого внутреннего API пассивной проверки, коротких таймаутов и метрик обычно достаточно. Если бизнес требует снять неготовый экземпляр до первого клиентского запроса, делать rolling update и переживать потерю узла, я не достраиваю вокруг Compose самодельный оркестратор — переношу сервис на платформу с readiness и несколькими failure domains.

Не включайте `non_idempotent` ради красивого теста без 502. Один пропущенный ответ дешевле двойного списания; правильное место защиты — идемпотентность приложения.

Как проверить схему и где перестать называть её HA

Я считаю работу законченной только после управляемого отказа. Добавьте в ответ API идентификатор реплики, запустите постоянный поток GET, остановите один контейнер и пересоздайте его с новым IP. В логах должны быть видны две попытки через `$upstream_addr`, после короткого окна старый адрес должен исчезнуть, а новый — появиться без reload. Потом повторите тест для POST в безопасном контуре и убедитесь, что выбранная политика не создаёт дублей. Статус `healthy` и зелёный `docker compose ps` доказательством failover не являются.

Следующий вопрос — ёмкость. Одна оставшаяся реплика обязана выдержать всю расчётную нагрузку плюс нормальный запас. На «Векторе» пик 60 RPS был меньше половины измеренной устойчивой производительности одного экземпляра в 140 RPS. Я стараюсь держать каждую из двух реплик не выше 50–60% проверенной мощности в штатном режиме; это инженерный ориентир, не универсальный норматив. Если обе уже упираются в CPU, переключение сработает технически и всё равно уронит сервис перегрузкой.

Наконец, две реплики, Nginx и база на одном сервере переживают смерть процесса, но не отказ сервера, диска, Docker daemon, общей сети или самого прокси. Для внутреннего сервиса с допустимым перерывом это может быть разумный компромисс — риск не надо раздувать. Для внешнего API с денежным ущербом нужны как минимум разные узлы, внешний балансировщик, наблюдаемость и отработанный rollback. Мой порядок такой: сначала динамический DNS, затем ограниченные таймауты и retry, затем корректное завершение приложения, и только после измерения бизнес-риска — миграция на multi-host оркестрацию.

Мой базовый рецепт для standalone Compose в 2026 году: актуальный Nginx, `zone`, `server ... resolve`, Docker DNS с коротким `valid` и заданными пределами retry. Это устраняет залипание на IP, но не превращает один сервер в отказоустойчивый кластер.

Частые вопросы

Достаточно ли добавить `resolver 127.0.0.11` в nginx.conf?

Нет. Для именованного upstream нужны три вещи одновременно: `zone`, `resolver ... valid=...` и `server api:8080 resolve`. Один `resolver` не заставляет статическую директиву `server` обновлять адреса.

Почему не оставить reload Nginx в deploy-скрипте?

Reload обновит адреса, но привяжет доступность к порядку и успешности deploy. Масштабирование, аварийный restart или ручное пересоздание вне скрипта снова оставят старый пул. Я использую reload для изменения конфигурации, а DNS-состав обновляю через `resolve`.

Поможет ли `restart: always`, когда healthcheck стал unhealthy?

Нет, если процесс продолжает работать. Restart policy применяется при завершении контейнера, а отрицательный healthcheck только меняет статус. Завершать процесс при фатальном внутреннем состоянии можно, но зависимость вроде общей БД нельзя превращать в причину синхронного restart storm.

Можно ли использовать старый приём с переменной в `proxy_pass`?

Можно: имя в URL с переменной разрешается runtime-resolver. Но меняются правила обработки URI, а нормальный upstream с его состоянием и настройками становится менее очевидным. Для нового стенда я обновляю Nginx до поддерживаемой версии и использую `server ... resolve`.

Безопасно ли повторять POST на второй реплике?

Только если операция идемпотентна либо приложение проверяет idempotency key. Я не включаю `non_idempotent`: после передачи запроса первому backend автоматический повтор может создать второй заказ, платёж или документ.

Две реплики на одном сервере — это высокая доступность?

Это устойчивость к падению отдельного процесса или контейнера и частичная защита при обслуживании. Отказ хоста, Docker Engine, Nginx или общей базы остановит сервис. Для HA по узлам нужны разные failure domains и внешний уровень маршрутизации.

Уберём залипание Nginx на IP
Если после масштабирования или deploy Nginx периодически отдаёт 502, я и команда «АйТи-Фреш» разберём фактические DNS-ответы, загруженный upstream и таймауты прямо на вашем стенде. Настроим предсказуемое переключение, проверим его контролируемым отказом и честно скажем, достаточно ли Compose или уже пора разносить сервис по узлам.
Бесплатная консультация →
© ООО «АйТи-Фреш» · Москва · Все статьи