Предположим, вам нужно быстро развернуть группу связанных контейнеров. Скорее всего, вы примените Docker Compose, так? Перечисляем контейнеры, собираем их в единую сеть и используем самый простой вариант — обращение по hostname. Например, мы подняли nginx и несколько сервисов, между которыми распределяется нагрузка. Однако в один день после обновления образов и пересоздания контейнеров nginx начинает всем отдавать 502 (Bad Gateway). Что произошло?
С подобной ситуацией я столкнулся в процессе внедрения Labforge — микросервисной платформы для развертывания виртуальных машин в среде Proxmox. Обновление сервисов приводило к потере запросов и ошибкам от nginx.
Конечное решение можно увидеть в коммитах проекта (Docker Compose — 67af0ad, nginx — 9440d6c). Далее мы разберем, почему так получается и как это можно аккуратно решить при помощи проверок сервисов и полей-расширений Docker Compose.
Анатомия сети. Откуда берутся hostname?#
Попробуем поднять простую сеть из двух контейнеров:
services:
a:
image: debian:trixie
container_name: container_a
command: tail -f /dev/null
hostname: container_a
b:
image: debian:trixie
container_name: container_b
command: tail -f /dev/null
hostname: container_bУстановим dnsutils и попробуем посмотреть, откуда контейнер А «знает» контейнер Б:
$: docker exec -it container_a sh -c 'apt-get update && apt-get install dnsutils -y'
# большой вывод
$: docker exec -it container_a nslookup container_b
Server: 127.0.0.11
Address: 127.0.0.11#53
$: Non-authoritative answer:
Name: container_b
Address: 172.18.0.3
$: docker exec -it container_a nslookup container_b 8.8.8.8
Server: 8.8.8.8
Address: 8.8.8.8#53
** server can't find container_b: NXDOMAINКонтейнеру отвечает внутренний DNS Docker — 127.0.0.11, а внешние сервисы, очевидно, ничего не знают о нашем сервисе. По умолчанию контейнеры обращаются к этому DNS и получают от него адреса хостнеймов. Исключение — например, network_mode: host, когда контейнер включен в сеть хоста.
В сети Compose контейнер можно найти по имени сервиса, имени контейнера и его hostname. В нашем примере сервис называется b, а имя контейнера и hostname совпадают — container_b. При пересоздании контейнера Docker обновляет соответствие имён и IP-адреса. При этом новый контейнер может получить тот же адрес, что и старый.
Если мы добавим сюда больше контейнеров и попробуем пересоздать несколько из них, они могут запуститься в другом порядке и получить новые IP-адреса. Это может произойти и без изменения порядка запуска:
Обновленный docker-compose.yml, если вы хотите повторить опыт
services:
a:
image: debian:trixie
container_name: container_a
command: tail -f /dev/null
hostname: container_a
b:
# наше приложение выключается стандартные 10 секунд
image: debian:trixie
container_name: container_b
command: tail -f /dev/null
hostname: container_b
c:
image: debian:trixie
container_name: container_c
stop_grace_period: 5s
command: tail -f /dev/null
hostname: container_c
d:
image: debian:trixie
container_name: container_d
stop_grace_period: 8s
command: tail -f /dev/null
hostname: container_d$: docker exec -it container_a nslookup container_b
Server: 127.0.0.11
Address: 127.0.0.11#53
Non-authoritative answer:
Name: container_b
Address: 172.18.0.3
$: docker compose up -d --force-recreate b c d
[+] up 3/3
✔ Container container_b Started 10.7s
✔ Container container_c Started 10.7s
✔ Container container_d Started 10.7s
$: docker exec -it container_a nslookup container_b
Server: 127.0.0.11
Address: 127.0.0.11#53
Non-authoritative answer:
Name: container_b
Address: 172.18.0.5Таким образом, приходим к выводу, что контейнеру требуется повторно разрешать имя другого контейнера при его пересоздании. Изучим на примере Labforge, как я минимизировал воздействие этого эффекта.
nginx: переход от маршрутизации по хостнейму к upstream#
В духе лучших неудачных практик проксирование трафика выполнялось простым указанием имени контейнера:
location /api/cluster/ {
proxy_pass http://cluster:8000;
}Как раз это и приводит к «запоминанию» адреса: nginx подставляет полученный IP-адрес и после его не меняет до перезапуска контейнера. В качестве альтернативы можно указать для nginx резолвер и время, пока его записи валидны. Добавим в http:
http {
#<...>
resolver 127.0.0.11 valid=5s ipv6=off;
resolver_timeout 2s;
}Здесь мы указываем в качестве резолвера Docker DNS и задаем срок действия записей — 5 секунд, после чего они будут повторно запрошены. Помимо основных опций, мы отключаем IPv6 и указываем тайм-аут в две секунды. Таким образом, если контейнер изменит IP-адрес, nginx через некоторое время обновит запись и продолжит проксирование трафика.
Кроме этого, потребуется изменить конфигурацию сервера.
Решение для nginx ниже версии 1.27.3
Переопределите ваши бэкенды как переменные, используя приведенный выше пример:
location /api/cluster/ {
set $clusterbackend "cluster:8000";
proxy_pass http://$clusterbackend;
}Определить resolver все равно требуется. Этот подход тоже заставляет nginx делать DNS-запросы, но лучше обновиться и сделать так, как описано ниже.
Лучшим вариантом будет определить бэкенды как upstream: их можно назвать таким же именем, поскольку они имеют более высокий приоритет, но из proxy_pass нужно удалить порт. На примере /api/cluster:
http {
#<...>
resolver 127.0.0.11 valid=5s ipv6=off;
resolver_timeout 2s;
upstream cluster {
zone cluster 64k;
server cluster:8000 resolve;
}
#<...>
server {
#<...>
location /api/cluster/ {
proxy_pass http://cluster;
}
}
}Обратите внимание: у upstream не указывается порт, однако, если вы ранее использовали URL, вы можете его дописать, например http://grafana/grafana/api/live, при условии, что вы определили upstream «grafana».
Кроме того, для каждого upstream создается своя зона памяти на 64 килобайта для хранения данных между рабочими процессами.
Благодаря описанным правкам nginx восстанавливает работу раньше следующей перезагрузки. В условиях периодических обновлений сервиса без частого пересоздания контейнеров оставшееся время простоя оказалось приемлемым. Однако в схеме остаются другие сервисы: если нет доступа к управлению логикой разрешения адресов или таких сервисов слишком много для внесения правок, можно воспользоваться обходным вариантом — перезапускать зависимые сервисы с помощью проверок состояния контейнеров (healthcheck) и указания зависимостей (depends_on). Проверки состояния позволяют определить условия, при которых контейнеры можно считать готовыми к работе, а механизм зависимостей — перезапускать сервисы при обновлении или перезапуске зависимости через Compose.
Добавляем проверку состояния контейнера и зависимые состояния#
Healthcheck — механизм Docker, который позволяет определять состояние контейнера и в зависимости от этого состояния выполнять действия. В нашем случае он будет использован для последовательного старта сервисов. Дело в том, что при простом указании сервиса в depends_on контейнер будет запущен следом за своей зависимостью, в то время как она может быть еще не инициализирована до конца. Это не полностью относится к проблеме статьи, но поясняет, почему я настаиваю на такой реализации.
Рассмотрим реализацию на примере базы данных Labforge:
services:
postgresql:
image: postgres:15-alpine
restart: unless-stopped
hostname: postgres
container_name: labforge-db
env_file:
- ../docker.env
volumes:
- ../pgdata:/data/postgres
- ./db-script.sh:/docker-entrypoint-initdb.d/db-script.sh:roЧтобы данные PostgreSQL сохранялись в указанном каталоге, нужно задать переменную окружения PGDATA (для текущей конфигурации — /data/postgres). В данном случае она указана в docker.env.
Теперь добавим проверку состояния контейнера — будем периодически выполнять команду pg_isready.
services:
postgresql:
image: postgres:15-alpine
restart: unless-stopped
hostname: postgres
container_name: labforge-db
env_file:
- ../docker.env
volumes:
- ../pgdata:/data/postgres
- ./db-script.sh:/docker-entrypoint-initdb.d/db-script.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] # команда проверки
interval: 10s # интервал проверки
retries: 5 # количество попыток
start_period: 30s # льготный период для ошибок при запуске
timeout: 10s # время на выполнение команды проверкиДалее мы можем «привязать» другой контейнер к запуску и перезапуску этого контейнера. Например, Keycloak:
services:
postgresql:
image: postgres:15-alpine
restart: unless-stopped
hostname: postgres
container_name: labforge-db
env_file:
- ../docker.env
volumes:
- ../pgdata:/data/postgres
- ./db-script.sh:/docker-entrypoint-initdb.d/db-script.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
keycloak:
image: quay.io/keycloak/keycloak:26.2.5
restart: unless-stopped
container_name: keycloak
hostname: keycloak
command: start-dev
volumes:
- ./keycloak/jq:/usr/bin/jq:ro
env_file:
- ../docker.env
depends_on:
postgresql:
condition: service_healthy
restart: true # перезапускать ли контейнер при явном обновлении или перезапуске зависимости через ComposeПоле condition может принимать следующие значения:
service_started— требование запуска контейнера; то же самое будет применено, если написать вdepends_onтолько имя сервиса.service_healthy— наш случай: требование запуска контейнера и прохождения имhealthcheckдо здорового статуса. Если контейнер получит статусunhealthy, команда запуска завершится с ошибкой.service_completed_successfully— сервис должен успешно завершить работу перед запуском контейнера.
Теперь, если мы запустим Docker Compose, сначала будет запущена база, затем Compose дождется ее статуса healthy, после чего будет создан и запущен контейнер Keycloak. Однако при большом количестве контейнеров текущая реализация выглядит громоздкой и повторяющейся. Например, если у нас есть сервис, который зависит от пяти других, получится конструкция вида:
services:
service_a:
# <...>
depends_on:
service_b:
condition: service_healthy
restart: true
service_c:
condition: service_healthy
restart: true
service_d:
condition: service_healthy
restart: true
service_e:
condition: service_healthy
restart: true
service_f:
condition: service_healthy
restart: trueТакая же ситуация с проверками контейнеров: если у нас много однотипных веб-серверов, придется для каждого писать блок healthcheck и при необходимости его править. По этой причине для Labforge я усовершенствовал данную конструкцию.
Избавляемся от повторений: добавляем поля-расширения#
Поля-расширения — это инструмент для создания повторяемых модулей конфигурации Docker Compose. Это могут быть как значения отдельного поля, например общие параметры env, так и самостоятельные блоки, например healthcheck.
Сервисы в Labforge используют общий образ на базе Debian, не содержащий curl. Кроме того, у сервисов нет эндпоинта для проверки готовности, поэтому при помощи bash и команды timeout реализуем простую проверку прослушивания порта. Для этого в начало файла добавляем:
x-generic-service-check: &port-healtcheck
test: timeout 5s bash -c ':> /dev/tcp/127.0.0.1/8000' || exit 1
interval: 10s
timeout: 10s
retries: 5При встраивании мы будем использовать идентификатор после &. Для имени поля-расширения главное, чтобы оно начиналось с x-. Теперь мы можем применить поле к нескольким контейнерам, например:
x-generic-service-check: &port-healtcheck
test: timeout 5s bash -c ':> /dev/tcp/127.0.0.1/8000' || exit 1
interval: 10s
timeout: 10s
retries: 5
services:
auth-service:
profiles: ['app']
image: auth-service
container_name: labforge-auth
hostname: auth
restart: unless-stopped
env_file:
- ../docker.env
- ../auth.env
healthcheck:
<<: *port-healtcheck
cluster-manager:
profiles: ['app']
image: cluster-manager
container_name: labforge-cluster
hostname: cluster
restart: unless-stopped
env_file:
- ../docker.env
- ../cluster.env
healthcheck:
<<: *port-healtcheckСитуация с модификацией depends_on аналогична, но стоит учесть один факт.
<<: *field, а для множественного встраивания используется массив — <<: [*field1, *field2, *field3].Исходя из этого, зависимость от сервисов вместе с нашей проверкой может выглядеть примерно так:
x-postgresql-dependency: &restart-on-db
postgresql:
condition: service_healthy
restart: true
x-keycloak-dependency: &restart-on-keycloak
keycloak:
condition: service_healthy
restart: true
x-generic-service-check: &port-healtcheck
test: timeout 5s bash -c ':> /dev/tcp/127.0.0.1/8000' || exit 1
interval: 10s
timeout: 10s
retries: 5
services:
postgresql:
image: postgres:15-alpine
restart: unless-stopped
hostname: postgres
container_name: labforge-db
env_file:
- ../docker.env
volumes:
- ../pgdata:/data/postgres
- ./db-script.sh:/docker-entrypoint-initdb.d/db-script.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
keycloak:
image: quay.io/keycloak/keycloak:26.2.5
restart: unless-stopped
container_name: keycloak
hostname: keycloak
command: start-dev
volumes:
- ./keycloak/jq:/usr/bin/jq:ro
env_file:
- ../docker.env
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8080;echo -e 'GET /health/ready HTTP/1.1\r\nhost: http://localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"]
interval: 20s
timeout: 15s
retries: 5
depends_on:
<<: *restart-on-db # одиночная зависимость
auth-service:
profiles: ['app']
image: auth-service
container_name: labforge-auth
hostname: auth
restart: unless-stopped
env_file:
- ../docker.env
- ../auth.env
healthcheck:
<<: *port-healtcheck
depends_on:
<<: [*restart-on-db, *restart-on-keycloak] # множественное определениеЗаключение#
Теперь вы знаете, как упорядочить процесс запуска контейнеров и чем может быть плоха навигация по хостнеймам, если не выполняется повторная проверка записей. На момент написания статьи финальный docker-compose.yml выглядит следующим образом:
Финальный docker-compose.yml
x-postgresql-dependency: &restart-on-db
postgresql:
condition: service_healthy
restart: true
x-rabbitmq-dependency: &restart-on-rabbitmq
rabbitmq:
condition: service_healthy
restart: true
x-keycloak-dependency: &restart-on-keycloak
keycloak:
condition: service_healthy
restart: true
x-auth-dependency: &restart-on-auth
auth-service:
condition: service_healthy
restart: true
x-cluster-dependency: &restart-on-cluster
cluster-manager:
condition: service_healthy
restart: true
x-deployment-dependency: &restart-on-deployment
deployment-api:
condition: service_healthy
restart: true
x-kroki-dependency: &restart-on-kroki
kroki:
condition: service_healthy
restart: true
x-loki-dependency: &restart-on-loki
loki:
condition: service_healthy
restart: true
x-generic-service-check: &port-healtcheck
test: timeout 5s bash -c ':> /dev/tcp/127.0.0.1/8000' || exit 1
interval: 10s
timeout: 10s
retries: 5
services:
postgresql:
image: postgres:15-alpine
restart: unless-stopped
hostname: postgres
container_name: labforge-db
env_file:
- ../docker.env
volumes:
- ../pgdata:/data/postgres
- ./db-script.sh:/docker-entrypoint-initdb.d/db-script.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
keycloak:
image: quay.io/keycloak/keycloak:26.2.5
restart: unless-stopped
container_name: keycloak
hostname: keycloak
command: start-dev
volumes:
- ./keycloak/jq:/usr/bin/jq:ro
env_file:
- ../docker.env
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8080;echo -e 'GET /health/ready HTTP/1.1\r\nhost: http://localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"]
interval: 20s
timeout: 15s
retries: 5
depends_on:
<<: *restart-on-db
redis:
image: redis:8.2.0-bookworm
restart: unless-stopped
container_name: redis
hostname: redis
nginx:
profiles: ['app']
image: labforge-nginx-prod
container_name: labforge-nginx
hostname: nginx
ports:
- 443:443
- 80:80
restart: unless-stopped
websocket:
profiles: ['app']
image: labforge-websockify
container_name: labforge-websockify
hostname: websockify
restart: unless-stopped
env_file:
- ../docker.env
- ../websockify.env
depends_on:
<<: [*restart-on-auth, *restart-on-cluster]
auth-service:
profiles: ['app']
image: auth-service
container_name: labforge-auth
hostname: auth
restart: unless-stopped
env_file:
- ../docker.env
- ../auth.env
healthcheck:
<<: *port-healtcheck
depends_on:
<<: [*restart-on-db, *restart-on-keycloak]
cluster-manager:
profiles: ['app']
image: cluster-manager
container_name: labforge-cluster
hostname: cluster
restart: unless-stopped
env_file:
- ../docker.env
- ../cluster.env
healthcheck:
<<: *port-healtcheck
depends_on:
<<: [*restart-on-db, *restart-on-auth]
deployment-api:
profiles: ['app']
image: deployment-api
container_name: labforge-deployment
hostname: deployment
restart: unless-stopped
env_file:
- ../docker.env
- ../deployment.env
healthcheck:
<<: *port-healtcheck
depends_on:
<<: [*restart-on-db, *restart-on-auth]
frontend:
profiles: ['app']
image: frontend-server
container_name: labforge-frontend
hostname: frontend
restart: unless-stopped
env_file:
- ../docker.env
- ../frontend.env
healthcheck:
<<: *port-healtcheck
depends_on:
<<: [*restart-on-auth, *restart-on-keycloak]
kroki-proxy:
profiles: ['app']
image: kroki-proxy
container_name: labforge-kroki-proxy
hostname: kroki
restart: unless-stopped
env_file:
- ../docker.env
- ../kroki.env
healthcheck:
<<: *port-healtcheck
depends_on:
<<: [*restart-on-auth, *restart-on-kroki]
jobservice:
profiles: ['app']
image: jobservice
container_name: labforge-jobservice
hostname: jobservice
restart: unless-stopped
env_file:
- ../docker.env
- ../jobservice.env
depends_on:
<<: [*restart-on-db, *restart-on-rabbitmq, *restart-on-auth, *restart-on-cluster, *restart-on-deployment, *restart-on-loki]
jobservice-api:
profiles: ['app']
image: jobservice-api
container_name: labforge-jobservice-api
hostname: jobservice-api
restart: unless-stopped
env_file:
- ../docker.env
- ../jobservice-api.env
healthcheck:
<<: *port-healtcheck
depends_on:
<<: [*restart-on-db, *restart-on-rabbitmq, *restart-on-auth, *restart-on-deployment]
kroki:
profiles: ['app']
image: yuzutech/kroki
container_name: labforge-kroki
hostname: kroki-server
restart: unless-stopped
healthcheck:
<<: *port-healtcheck
grafana:
profiles: ['app']
image: labforge-grafana:12.2.0-17567790421-ubuntu
container_name: labforge-grafana
restart: unless-stopped
hostname: grafana
user: root
env_file:
- ../docker.env
- ../auth.env
volumes:
- ./grafana/grafana.ini:/etc/grafana/grafana.ini:ro
- ./grafana/provisioning/:/etc/grafana/provisioning/:ro
- ./grafana/dashboards/:/etc/grafana/dashboards/:ro
- ../grafana:/var/lib/grafana
depends_on:
<<: [*restart-on-keycloak, *restart-on-loki]
nginx-exporter:
profiles: ['app']
image: nginx/nginx-prometheus-exporter:1.4
container_name: labforge-nginx-exporter
restart: unless-stopped
hostname: nginx-exporter
command: --nginx.scrape-uri=http://nginx:8080/stub_status
prometheus:
profiles: ['app']
image: prom/prometheus:v3.5.0
user: root
volumes:
- ./prometheus/:/etc/prometheus/:ro
- ../prometheus_data/:/prometheus/
container_name: labforge-prometheus
hostname: prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=7d
restart: unless-stopped
cadvisor:
profiles: ['app']
image: gcr.io/cadvisor/cadvisor:v0.55.1
container_name: labforge-cadvisor
hostname: cadvisor
privileged: true
volumes:
- /etc/localtime:/etc/localtime:ro
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
devices:
- /dev/kmsg:/dev/kmsg
environment:
CADVISOR_HEALTHCHECK_URL: http://localhost:9092/healthz
command:
- '-port=9092'
restart: unless-stopped
rabbitmq:
profiles: ['app']
image: rabbitmq:3.13-rc-management-alpine
container_name: labforge-rabbitmq
hostname: rabbitmq
ports:
- 15672:15672
restart: unless-stopped
env_file:
- ../docker.env
volumes:
- ./rabbitmq/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 3s
timeout: 3s
retries: 5
loki:
image: grafana/loki:3.4.1
container_name: labforge-loki
hostname: loki
user: root
restart: unless-stopped
command:
- --config.file=/etc/loki/config.yaml
volumes:
- ./loki/config.yaml:/etc/loki/config.yaml:ro
- ../loki-data:/loki
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1
interval: 10s
timeout: 10s
retries: 3
alloy:
image: grafana/alloy:v1.12.0
container_name: labforge-alloy
restart: unless-stopped
hostname: alloy
privileged: true
volumes:
- ./alloy/config.alloy:/etc/alloy/config.alloy:ro
- /var/run:/var/run:ro
command:
- run
- /etc/alloy/config.alloy