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

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

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

Вы подняли две реплики, остановили одну — и получили 504 или секундные задержки. Вторая реплика работает, но Nginx продолжает обращаться к старому IP. Я, Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш», покажу, откуда берётся залипание, какой конфиг я выбираю и что реально получилось при проверке отказа на Docker-стенде.

Две реплики — это количество процессов, а не обещание доступности

В строке deploy.replicas: 2 нет ни проверки вашего API, ни правил повторной отправки запроса. Локальный Docker Compose действительно умеет запускать несколько экземпляров сервиса: совет «replicas работает только в Swarm» для актуального Compose устарел. Но команда docker compose up не превращает один сервер в кластер с непрерывным восстановлением заданного числа реплик. Перезапуск завершившегося контейнера обеспечивает отдельно настроенная restart policy. Балансировку HTTP в нашей схеме выполняет Nginx.

Compose создаёт для проекта пользовательскую bridge-сеть. Реплики доступны соседним контейнерам по имени сервиса app; в нашем стенде DNS возвращает два IP. При пересоздании адрес может измениться, имя остаётся прежним. Отследить изменение и переподключиться должен потребитель имени — здесь Nginx. Docker прямо описывает эту ответственность в [документации сетей Compose](https://docs.docker.com/compose/how-tos/networking/). Копирование endpoint_mode: vip из примера Swarm не добавляет локальному Compose сетевую инфраструктуру Swarm.

Я разделяю три задачи: найти актуальные адреса, выбрать доступную реплику и решить, допустимо ли повторить конкретный запрос. Пока они свалены в слово «балансировка», проверка заканчивается двумя зелёными контейнерами в docker ps. Для меня это только начало. Нужно остановить одну реплику и посмотреть одновременно клиентский ответ, выбранный upstream и время обработки: HTTP 200 вполне способен скрывать регулярные походы на давно мёртвый адрес.

Две реплики на одном хосте помогают пережить часть отказов приложения. Отказ самого хоста выводит из строя обе реплики и расположенный рядом Nginx.
Памятка: Две реплики — это количество процессов, а не обещание доступности — схема
Памятка: Две реплики — это количество процессов, а не обещание доступности

Где залипает IP и почему reload временно помогает

Вот конфигурация, с которой удобно воспроизвести проблему. Имя app выглядит динамическим, но эта запись задаёт статически разрешаемый upstream: ```nginx upstream app_pool { server app:8080 max_fails=1 fail_timeout=3s; } ``` При загрузке конфигурации Nginx получает адреса имени. Если их несколько, появляются несколько upstream-серверов, а не один случайно выбранный IP. Автоматического отслеживания последующих изменений DNS здесь нет.

Допустим, при старте получены 172.20.0.2 и 172.20.0.3. Первую реплику остановили, Docker DNS уже возвращает только второй адрес. Статический пул Nginx продолжает содержать оба. После ошибки первый адрес временно исключается, затем снова получает шанс. fail_timeout задаёт окно учёта ошибок и период исключения; это не частота фоновых healthcheck. Проверкой становится очередной пользовательский запрос. Такое поведение описано в [документации пассивных проверок Nginx](https://nginx.org/en/docs/http/load_balancing.html).

Отсюда важная поправка к популярному объяснению: остановка одной из двух реплик не обязана давать 50% ошибок. Стандартное proxy_next_upstream error timeout уже допускает повтор на другом сервере. Внешне всё может оставаться зелёным, а отдельные запросы будут ждать таймаут подключения. Ещё неприятнее последовательная замена контейнеров: Nginx способен остаться со списком адресов, которых уже вообще нет. Успешный первый эксперимент этого не исключает.

Reload перечитывает конфигурацию и заново разрешает статическое имя. Поэтому кажется, будто проблема вылечена. Я допускаю такой приём для восстановления сервиса прямо сейчас, но постоянный reload после каждого изменения контейнеров требует отдельной надёжной автоматизации. Одна добавленная директива resolver тоже не переключает статический upstream в динамический режим. Для выбранного ниже решения необходим параметр resolve у server; именно он включает отслеживание адресов.

Проверяйте адреса в access log через $upstream_addr. Вывод nginx -T показывает конфигурацию, а не текущий состав разрешённых DNS-адресов.
Почему две replicas в Docker Compose не дают failover за Nginx — схема

Стенд «Вектор»: воспроизводим проблему без клиентской легенды

Для разбора я собрал лабораторный стенд «Вектор». Название условное; это выполненный технический эксперимент, не история внедрения у названного заказчика. Один Linux-хост, три контейнера, Docker Engine 29.6.0, Docker Compose v5.1.4, Nginx 1.30.4 из образа nginx:1.30.4-alpine. Версия Nginx выпущена 15 июля 2026 года. Это конкретная проверенная сборка, а не рекомендация навсегда закрепиться на ней: обновления безопасности проверяйте по [официальной истории выпусков](https://nginx.org/en/CHANGES-1.30).

Вместо бизнес-приложения две реплики Nginx возвращают hostname контейнера. Так видно распределение запросов, а база данных и прикладной код не мешают исследовать сетевой механизм. Всего я отправил 400 GET: для каждого варианта конфигурации 40 до остановки и 160 после. Целевой темп — 10 запросов в секунду, максимум восемь одновременно. Это проверка поведения при отказе, не замер пропускной способности; требования к CPU и памяти настоящего приложения из неё выводить нельзя.

Файл compose.yaml ниже запускает две реплики и публикует только gateway на случайном свободном порту loopback. У app нет container_name: фиксированное имя несовместимо с масштабированием. Одинаковый опубликованный порт хоста у двух реплик тоже конфликтовал бы. Порт 8080 доступен gateway внутри общей сети без ports и без обязательного expose. Ограничения описаны в [спецификации сервисов Compose](https://docs.docker.com/reference/compose-file/services/). ```yaml services: app: image: nginx:1.30.4-alpine restart: unless-stopped deploy: replicas: 2 volumes: - ./backend.conf:/etc/nginx/conf.d/default.conf:ro healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/"] interval: 1s timeout: 1s retries: 10 gateway: image: nginx:1.30.4-alpine restart: unless-stopped ports: - "127.0.0.1::80" volumes: - ./gateway.conf:/etc/nginx/nginx.conf:ro depends_on: app: condition: service_healthy ```

Рядом сохраняю backend.conf. Endpoint намеренно примитивный; выдавать hostname публичным пользователям в рабочем приложении не требуется. Проверка готовности здесь совпадает с проверкой HTTP-ответа, а для настоящего API я использую отдельный endpoint с осмысленным критерием готовности обслуживать запросы. Частота healthcheck в одну секунду выбрана для короткого эксперимента, а не как стандарт для всех сервисов. ```nginx server { listen 8080; location / { default_type text/plain; return 200 "$hostname\n"; } } ```

Все приведённые IP и задержки относятся к этому прогону. У вас Docker может назначить другую подсеть, а задержки будут зависеть от хоста и характера отказа.
Цифры и версии: Стенд «Вектор»: воспроизводим проблему без клиентской легенды — схема
Цифры и версии: Стенд «Вектор»: воспроизводим проблему без клиентской легенды

Мой выбор: динамический upstream и ограниченные повторы

Для небольшой установки на одном сервере я выбираю штатный динамический upstream Nginx. Его проще сопровождать, чем собственный обработчик Docker events с генерацией конфигов. В открытом Nginx параметр resolve доступен начиная с 1.27.3, выпущенной 26 ноября 2024 года; покупать Plus ради этой функции уже не требуется. Это подтверждает [официальное объявление Nginx](https://blog.nginx.org/blog/dynamic-dns-resolution-open-sourced-in-nginx). Версию проверяйте внутри контейнера, а не по установленному на хосте пакету.

Ниже полный gateway.conf, заменяющий /etc/nginx/nginx.conf. Один worker оставлен специально для понятного эксперимента. Для статического контрольного прогона я убирал zone, resolver, resolver_timeout и слово resolve, сохраняя остальные настройки. При смене конфигурации выполняю nginx -t, затем nginx -s reload внутри gateway. Журнал содержит время, запрос, итоговый статус, адреса upstream, их статусы и длительности: ```nginx worker_processes 1; events { worker_connections 1024; } http { log_format timing '$msec|$request|$status|$upstream_addr|$upstream_status|$upstream_response_time|$request_time'; access_log /var/log/nginx/access.log timing; error_log /var/log/nginx/error.log warn; upstream app_pool { zone app_pool 64k; resolver 127.0.0.11 valid=2s ipv6=off; resolver_timeout 1s; server app:8080 resolve max_fails=1 fail_timeout=3s; } server { listen 80; location / { proxy_pass http://app_pool; proxy_connect_timeout 1s; proxy_read_timeout 2s; proxy_send_timeout 2s; proxy_next_upstream error timeout; proxy_next_upstream_tries 2; proxy_next_upstream_timeout 3s; } } } ```

Zone хранит конфигурацию и состояние пула в общей памяти и нужна для resolve. Resolver 127.0.0.11 — встроенный DNS Docker внутри контейнера пользовательской сети; на Nginx, установленном непосредственно на хосте, этот адрес автоматически не заработает. Публичный DNS имя app тоже не знает. ipv6=off соответствует IPv4-стенду, в IPv6-сети копировать его не следует. Назначение адреса подтверждено в [документации Docker DNS](https://docs.docker.com/engine/network/#dns-services), требования resolve — в [справочнике upstream](https://nginx.org/en/docs/http/ngx_http_upstream_module.html#resolve).

Здесь три разных таймера. valid=2s переопределяет срок DNS-кэша, resolver_timeout=1s ограничивает ожидание DNS, fail_timeout=3s относится к пассивному исключению upstream. Ни один не обещает переключение ровно за две секунды. Две попытки в proxy_next_upstream_tries включают первую; proxy_next_upstream_timeout ограничивает окно допуска повторов, а не полную длительность ответа. Read timeout задаёт ожидание между чтениями. Мои короткие значения подходят мгновенному тестовому ответу: для отчётов, загрузок и медленного API их надо пересчитать, иначе вы сами создадите таймауты. [Справочник proxy](https://nginx.org/en/docs/http/ngx_http_proxy_module.html).

Сначала настройте обновление адресов и наблюдаемость. Выбор между round-robin и least_conn можно отложить: смена алгоритма не исправляет устаревший список IP.

Что получилось: старый IP исчез, но четыре запроса потерялись

Для повторения сохраните три файла в отдельном каталоге. Эти команды показывают версии, проверяют конфигурацию и DNS, затем останавливают только одну реплику. Docker stop выбран сознательно: ручная остановка подавляет автоматический перезапуск, поэтому restart policy не маскирует эксперимент. Это поведение описано в [документации Docker](https://docs.docker.com/engine/containers/start-containers-automatically/). Нагрузку запускайте из второго терминала; одиночный curl ниже — только проверка доступности. ```sh docker compose up -d docker compose exec gateway nginx -v docker compose exec gateway nginx -t docker compose exec gateway nslookup app 127.0.0.11 gateway_address=$(docker compose port gateway 80) curl -sS "http://$gateway_address/" victim_id=$(docker compose ps -q app | head -n 1) docker stop -t 2 "$victim_id" docker compose exec gateway nslookup app 127.0.0.11 docker compose logs --since 30s gateway # После проверки вернуть остановленную реплику: docker start "$victim_id" ```

В моём прогоне останавливалась реплика 172.20.0.2, живая имела адрес 172.20.0.3. До остановки обе конфигурации распределили 40 запросов ровно 20/20. Со статическим upstream после stop клиент получил 160 ответов HTTP 200 из 160. Однако восемь запросов сначала использовали умерший адрес. Новые обращения к нему наблюдались даже через 6,3 и 11,3 секунды после завершения stop; максимальная клиентская длительность составила 1020 мс. Вот реальная строка: первый upstream закончился 504, второй вернул 200, пользователь увидел успех. ```text 1788597850.226|GET /?phase=static_after&i=110 HTTP/1.1|200|172.20.0.2:8080, 172.20.0.3:8080|504, 200|1.000, 0.002|1.001 ```

После включения динамического upstream результат оказался менее красивым, чем обещают рецепты «добавьте resolve». Из 160 запросов после остановки 156 получили HTTP 200, четыре — HTTP 504. Умерший IP использовали пять запросов. Последний из них начался примерно через 1,05 секунды после stop; начиная со следующего, стартовавшего через 1,15 секунды, оставшиеся 151 запрос прошли только на живую реплику и завершились успешно. Повторного reload после остановки не было. Это наблюдаемые времена выбора upstream, не точное измерение момента обновления DNS.

Я оставляю эти четыре ошибки в результате. Без отдельной отладочной трассировки нельзя честно объявить установленной их внутреннюю причину. Эксперимент подтверждает прекращение длительного использования старого IP, но не гарантирует бесшовное переключение уже начатых запросов. Поэтому динамический пул я выбираю для корректного сопровождения состава реплик, а допустимость переходных ошибок проверяю отдельно. Если вам обещают ноль ошибок только по наличию resolve, попросите протокол остановки под запросами, а не снимок двух работающих контейнеров.

В этом тесте серии запускались до остановки и после её завершения. Отказ посреди передачи ответа, POST, streaming и пересоздание с гарантированно новым IP здесь не испытывались.

Что ещё нужно, чтобы это можно было оставить в работе

Healthcheck в Compose полезен, но сам по себе не удаляет unhealthy-контейнер из Docker DNS и не перезапускает живой процесс. Depends_on с service_healthy управляет ожиданием при запуске, а не постоянной маршрутизацией. Если приложение зависло, но контейнер работает, имя может продолжать разрешаться. Это следует из [механизма healthcheck Docker](https://github.com/moby/moby/blob/master/daemon/health.go) и [правил запуска Compose](https://docs.docker.com/compose/how-tos/startup-order/). Для исключения по прикладной готовности потребуется соответствующая проверка у балансировщика или механизм discovery, который учитывает readiness.

С повторами я осторожен. Для корректно реализованного GET повтор обычно допустим; создание платежа или заказа требует прикладной идемпотентности. Не добавляйте non_idempotent ради исчезновения ошибок из графика. После отправки POST, PATCH или LOCK upstream Nginx по умолчанию ограничивает повтор; до отправки запроса ошибка подключения ещё может позволить другую попытку. После начала передачи ответа клиенту переключение тоже не исправит уже оборванный ответ. Эти ограничения зафиксированы в [правилах proxy_next_upstream](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_next_upstream). Для операций записи я сначала проектирую ключ идемпотентности и хранение результата.

Отдельно проверяю сам DNS: valid не является гарантией удаления IP при любой ошибке резолвера. В Nginx 1.30.4 NXDOMAIN убирает адреса данного имени, а при других ошибках разрешения прежний набор может сохраняться до следующей попытки; это видно в [исходном коде upstream zone](https://github.com/nginx/nginx/blob/release-1.30.4/src/http/modules/ngx_http_upstream_zone_module.c). Но для небольшой установки я не начинаю с отдельного кластера DNS. Сначала добиваюсь наблюдаемого восстановления на штатном Docker DNS и проверяю, что оставшаяся реплика выдерживает всю рабочую нагрузку.

Мой порядок внедрения простой: рабочий динамический пул, журнал попыток, контролируемый отказ, затем проверка настоящих пользовательских операций. Обе реплики должны обходиться без незаменимого состояния на локальном диске или в памяти одного процесса. Если требуется переживать отказ сервера, план нужен уже для нескольких хостов, входного балансировщика, данных и сессий. Для небольшого внутреннего сервиса Compose вполне уместен. Просто пределы доступности должны быть записаны и проверены, а не выведены из цифры 2 в YAML.

Устранение залипания DNS — конкретное улучшение. Нулевые потери запросов и доступность при отказе хоста требуют отдельных решений и отдельных испытаний.
Порядок действий: Что ещё нужно, чтобы это можно было оставить в работе — схема
Порядок действий: Что ещё нужно, чтобы это можно было оставить в работе

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

Нужен ли Nginx Plus для динамического DNS?

Нет. Параметр server resolve доступен в открытом Nginx начиная с 1.27.3. Для показанного решения также нужны zone и resolver. Стенд проверен на Nginx 1.30.4.

Достаточно добавить resolver 127.0.0.11?

Нет. Статическая запись server app:8080 от этого не начнёт автоматически обновлять адреса. В приведённой конфигурации нужен параметр resolve, а upstream должен использовать общую память zone.

Почему restart: unless-stopped не решает проблему?

Эта политика управляет перезапуском контейнера, но не обновляет список адресов Nginx. При ручном docker stop автоматический перезапуск подавляется; статус unhealthy сам по себе тоже не запускает перезапуск.

Проверим переключение ваших реплик
Если после остановки реплики у вас растут 502/504 или задержки, я помогу проверить конфигурацию и воспроизвести отказ на стенде. Пришлите compose.yaml и конфигурацию Nginx — предложу исправления и сценарий приёмки.
Бесплатная консультация →
© ООО «АйТи-Фреш» · Москва · Все статьи