Почему две 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.
- `replicas` отвечает за количество контейнеров, а не за маршрутизацию запросов;
- Docker DNS отвечает за service discovery, но клиент обязан повторно запросить имя;
- Nginx отвечает за балансировку, пассивное исключение peer и retry;
- две реплики на одном хосте не переживут отказ хоста, Docker Engine или общего 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 по умолчанию.
- Проверьте resolver из контейнера: `docker compose exec nginx cat /etc/resolv.conf`; для общей custom network там ожидаем `nameserver 127.0.0.11`.
- Спросите имя несколько раз: `docker compose exec nginx nslookup api 127.0.0.11` — команда должна показывать текущие адреса реплик.
- Снимите реальные IP: `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' vector-api-1 vector-api-2`.
- Выведите загруженный конфиг: `docker compose exec nginx nginx -T`; наличие одной директивы `resolver` без `server ... resolve` ничего не меняет.
- Сопоставьте `$upstream_addr` из access log с `docker inspect`. Если ручной `nginx -s reload` заменяет старый IP, диагноз практически закрыт.
Мой выбор в 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 МБ для двух реплик заведомо достаточна и на требования к железу не влияет.
Условный стенд «Вектор»: как проявился и исчез 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.
- Тест изменения состава: `docker compose up -d --scale api=1`, затем `--scale api=2` без reload Nginx.
- Тест нового IP: `docker rm -f vector-api-1`, затем `docker compose up -d --scale api=2 api`.
- Тест отказа процесса: `docker kill vector-api-2`; отдельно проверяется остановка с SIGTERM.
- На каждом шаге сохраняются DNS-ответ, `$upstream_addr`, `$upstream_status` и `$upstream_connect_time`.
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.
- healthcheck проверяет готовность приложения, но не управляет пулом Nginx;
- restart возвращает завершившийся контейнер, но не лечит зависший процесс автоматически;
- retry перекрывает короткое DNS-окно, но не гарантирует безопасный повтор бизнес-операции;
- keepalive экономит соединения, однако сам по себе не обновляет DNS.
Как проверить схему и где перестать называть её 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 оркестрацию.
- `nginx -v` показывает 1.30.4 либо более новую исправленную версию, а `nginx -t` проходит;
- Nginx и API находятся в одной custom network, DNS доступен по `127.0.0.11`;
- `nginx -T` показывает `zone`, `resolver valid=...` и `server ... resolve`;
- масштабирование и force-recreate меняют состав upstream без reload;
- одна реплика выдерживает 100% целевой нагрузки;
- мониторинг различает DNS timeout, connect error, upstream HTTP 5xx и клиентский 502.
Частые вопросы
Достаточно ли добавить `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 и внешний уровень маршрутизации.
Если после масштабирования или deploy Nginx периодически отдаёт 502, я и команда «АйТи-Фреш» разберём фактические DNS-ответы, загруженный upstream и таймауты прямо на вашем стенде. Настроим предсказуемое переключение, проверим его контролируемым отказом и честно скажем, достаточно ли Compose или уже пора разносить сервис по узлам.
Бесплатная консультация →

