↓ Перейти к основному содержимому

Восстановление и запуск системы микросервисов в Docker Compose

·2341 слово·12 минут
Для описанных примеров используйте Docker Engine 20.10 или новее и Docker Compose 2.17.0 или новее. Действия в статье выполнялись с Docker Engine 29.3.0 и Docker Compose 5.1.1.

Предположим, вам нужно быстро развернуть группу связанных контейнеров. Скорее всего, вы примените 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. Проверьте используемую версию!
Решение для 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.

Документация образа на Docker Hub

Теперь добавим проверку состояния контейнера — будем периодически выполнять команду 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
Эта проверка показывает, можно ли открыть TCP-соединение, но не гарантирует готовность приложения к приему запросов. При необходимости отредактируйте команду.

При встраивании мы будем использовать идентификатор после &. Для имени поля-расширения главное, чтобы оно начиналось с 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