Главная/Docker Compose/Урок

9.4. Healthchecks и зависимости

Цели

После этого материала вы сможете:

  • написать корректный healthcheck и объяснить каждый его параметр;
  • объяснить, почему depends_on без условия не даёт никаких гарантий;
  • применять service_healthy, service_completed_successfully и флаги restart, required;
  • реализовать миграции как отдельный сервис, завершающийся до старта приложения;
  • объяснить, почему приложение всё равно обязано переживать недоступность зависимости;
  • избегать healthcheck, которые проверяют не то и вызывают каскадные отказы.

Предварительные знания

Ключевые термины

ТерминОбъяснение
startingСостояние здоровья до первой успешной проверки
start_periodОкно, в котором неудачи не засчитываются
start_intervalУчащённый интервал проверок внутри start_period
service_healthyУсловие: зависимость прошла healthcheck
service_completed_successfullyУсловие: зависимость завершилась с кодом 0
экспоненциальная задержкаРастущая пауза между повторными попытками

Теория

Синтаксис healthcheck

yaml
services:
  api:
    image: myapp
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 30s
      start_interval: 2s
ПараметрУмолчаниеНазначение
testиз образаКоманда проверки
interval30sПауза между проверками
timeout30sСколько ждать завершения команды
retries3Неудач подряд до статуса unhealthy
start_period0sОкно, в котором неудачи не считаются
start_interval5sИнтервал внутри start_period
disablefalseОтключить проверку из образа

Форма test бывает трёх видов:

ФормаПримерКак выполняется
["CMD", ...]["CMD", "pg_isready", "-U", "postgres"]Напрямую, без shell
["CMD-SHELL", "..."]["CMD-SHELL", "pg_isready -U postgres || exit 1"]Через /bin/sh -c
Строкаpg_isready -U postgresЭквивалент CMD-SHELL
["NONE"]Отключает проверку из образа

CMD-SHELL нужен, когда в команде есть конвейеры, подстановки или логические операторы. В остальных случаях CMD предпочтительнее: на один процесс меньше.

Результат определяется кодом возврата: 0 — здоров, любой другой — нет.

Три состояния и переходы

text
   создан ──► starting ──┬──► healthy ──┬──► unhealthy
                         │              │        │
                         │              └────────┘
                         └──► unhealthy (после start_period)
СостояниеКогда
startingС момента запуска до первой удачной проверки или конца start_period
healthyПоследняя проверка успешна
unhealthyretries неудач подряд

Ключевое свойство start_period: неудачи внутри этого окна не увеличивают счётчик retries. Первая же удачная проверка переводит container в healthy немедленно, не дожидаясь конца окна.

Без start_period медленно стартующая база успеет получить unhealthy до того, как поднимется.

Параметр start_interval дополняет его: внутри окна старта проверки идут чаще, поэтому готовность обнаруживается быстро, а в установившемся режиме interval остаётся редким.

depends_on: чего он не делает

yaml
services:
  api:
    depends_on:
      - db          # НЕ гарантирует, что база готова

Такая запись означает только: container db запущен раньше. Запущен — не значит принимает соединения.

Что происходитВремя
Container базы создан и запущен0.1 с
PostgreSQL начал инициализацию0.2 с
Каталог данных создан, идёт запуск1–5 с
База принимает соединения3–15 с

Приложение, стартовавшее «после» базы, всё это время получает ECONNREFUSED.

Симптом узнаваем: проблема воспроизводится не всегда. На быстрой машине база успевает подняться, на загруженной — нет (урок 8.6).

Условия depends_on

yaml
services:
  api:
    depends_on:
      db:
        condition: service_healthy
        restart: true
      migrate:
        condition: service_completed_successfully
      optional-cache:
        condition: service_started
        required: false
УсловиеЗначение
service_startedContainer запущен (умолчание)
service_healthyHealthcheck зависимости прошёл
service_completed_successfullyЗависимость завершилась с кодом 0
ФлагЗначение
restart: trueПерезапустить этот сервис, если зависимость была перезапущена
required: falseНе прерывать запуск, если зависимости нет в конфигурации

service_healthy требует, чтобы у зависимости был healthcheck. Без него Compose откажется запускаться с явной ошибкой — и это хорошо: молчаливое игнорирование условия было бы хуже.

Миграции как отдельный сервис

Классическая задача: схема базы должна быть обновлена до старта приложения, ровно один раз, а не в каждой реплике.

yaml
services:
  db:
    image: postgres:17-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
      interval: 2s
      retries: 30

  migrate:
    image: myapp
    command: ["python", "-m", "app.migrate"]
    depends_on:
      db:
        condition: service_healthy
    restart: "no"                 # одноразовая задача

  api:
    image: myapp
    depends_on:
      migrate:
        condition: service_completed_successfully

Цепочка получается строгой: база готова → миграции выполнены и завершились с кодом 0 → приложение стартует.

СвойствоЗначение
Выполняется один разДа, независимо от числа реплик api
Провал миграции останавливает запускДа: ненулевой код не удовлетворяет условию
Повторный up выполняет миграции сноваДа — поэтому они должны быть идемпотентны
restart у сервиса миграцийНе задают: перезапуск завершившейся задачи — ошибка

depends_on недостаточно и с условиями

Даже идеальная цепочка условий решает только задачу старта. После него:

СобытиеЧто произойдёт
База перезапущенаПриложение потеряет соединения
Сеть моргнулаЗапросы упадут
База стала unhealthyCompose ничего не сделает — это не оркестратор
База обновленаПауза в обслуживании

Приложение обязано уметь переживать недоступность зависимости. Минимум:

ПриёмЧто даёт
Повторы с экспоненциальной задержкойПереживает короткую недоступность
Проверка соединения перед выдачей из пулаЛовит разорванные соединения
Разрешение имени при каждом соединенииПереживает смену адреса (урок 8.4)
Разделение liveness и readinessНе даёт убить работающее приложение (урок 6.10)

depends_on экономит секунды на старте. Устойчивость обеспечивает код.

Каким должен быть healthcheck

ТребованиеПочему
ДешёвыйВыполняется каждые interval секунд бесконечно
БыстрыйДолжен укладываться в timeout
Проверяет себя, а не зависимостиИначе отказ базы уронит все реплики разом
Не требует лишних пакетовcurl часто отсутствует в slim-образах
Осмысленныйexit 0 не проверяет ничего

Третий пункт — самый важный. Healthcheck, обращающийся к базе, превращает недоступность базы в лавину перезапусков: все реплики становятся unhealthy одновременно, перезапускаются и добивают восстанавливающуюся базу.

Проверка живости отвечает на вопрос «жив ли процесс», а не «работает ли вся система».

Проверка без curl

В python:*-slim нет curl и wget. Рабочие варианты:

yaml
# Python — есть в любом Python-образе
test: ["CMD", "python", "-c",
       "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz',timeout=2).status==200 else 1)"]

# PostgreSQL — входит в образ
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]

# Redis — входит в образ
test: ["CMD", "redis-cli", "ping"]

# Проверка TCP-порта без утилит (bash)
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8000"]

Последний вариант работает только в образах с bash: в sh из BusyBox или dash поддержки /dev/tcp нет.


Внутренний механизм

Кто выполняет проверку

Команду запускает демон Docker внутри container'а — как отдельный процесс, аналогично docker exec. Отсюда следствия:

СледствиеПояснение
Команда должна существовать в образеcurl не появится сам
127.0.0.1 означает сам containerИменно это и нужно для liveness
Ресурсы проверки учитываются в лимитахТяжёлая проверка отнимает CPU у приложения
Результат виден в docker inspectПоле .State.Health

История последних проверок хранится в .State.Health.Log — до пяти записей с кодом и выводом. Это первое место, куда стоит смотреть при unhealthy.

Что Compose делает со статусом

СобытиеРеакция Compose
Зависимость стала healthyЗапускает зависимые сервисы
Сервис стал unhealthy после запускаНичего
Сервис unhealthy при up --waitВозвращает ненулевой код

Вторая строка удивляет: Compose не перезапускает нездоровые container'ы. Это делают оркестраторы. Ближайший аналог — restart: unless-stopped плюс приложение, завершающееся при неисправности.


Команды и примеры

Гонка при старте

bash
mkdir -p /tmp/hc && cd /tmp/hc

cat > check.py <<'PY'
"""Пробует подключиться к базе один раз и сообщает результат."""
import socket
import sys
import time

host, port = sys.argv[1], int(sys.argv[2])
label = sys.argv[3] if len(sys.argv) > 3 else host
t0 = time.monotonic()
s = socket.socket()
s.settimeout(3)
try:
    s.connect((host, port))
    print(f"{label}: соединение установлено за {(time.monotonic()-t0)*1000:.0f} мс", flush=True)
    sys.exit(0)
except OSError as exc:
    print(f"{label}: НЕ ГОТОВО — {type(exc).__name__} ({exc})", flush=True)
    sys.exit(1)
finally:
    s.close()
PY

cat > compose.yaml <<'EOF'
name: hc

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb

  # depends_on без условия: ждёт только запуска container'а
  naive:
    image: python:3.13-slim
    volumes:
      - ./check.py:/check.py:ro
    depends_on:
      - db
    command: ["python", "/check.py", "db", "5432", "naive"]
EOF

docker compose down -v > /dev/null 2>&1
docker compose up --abort-on-container-exit > /tmp/hc/out.txt 2>&1
grep -E 'naive' /tmp/hc/out.txt | sed 's/^/  /'

Ожидаемый вывод:

text
  naive-1  | naive: НЕ ГОТОВО — ConnectionRefusedError ([Errno 111] Connection refused)
  naive-1 exited with code 1

depends_on: [db] отработал: container базы был запущен первым. Но PostgreSQL ещё инициализировался, и порт не принимал соединений.

Проверим, сколько времени на самом деле нужно базе:

bash
cd /tmp/hc
docker compose down -v > /dev/null 2>&1
docker compose up -d db > /dev/null 2>&1
start="$(date +%s.%N)"
for i in $(seq 60); do
    if docker compose exec -T db pg_isready -U postgres -d appdb > /dev/null 2>&1; then
        end="$(date +%s.%N)"
        printf '  база приняла соединения через %.1f c\n' \
            "$(awk -v a="$start" -v b="$end" 'BEGIN{print b-a}')"
        break
    fi
    sleep 0.5
done
docker compose down -v > /dev/null 2>&1

Ожидаемый вывод:

text
  база приняла соединения через 4.3 c

Четыре секунды — окно, в котором наивная зависимость гарантированно падает. На загруженной машине оно больше, на быстрой может сократиться до секунды — отсюда «иногда работает».

Healthcheck и service_healthy

bash
cd /tmp/hc
cat > compose.yaml <<'EOF'
name: hc

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s
      start_interval: 1s

  correct:
    image: python:3.13-slim
    volumes:
      - ./check.py:/check.py:ro
    depends_on:
      db:
        condition: service_healthy
    command: ["python", "/check.py", "db", "5432", "correct"]
EOF

docker compose down -v > /dev/null 2>&1
start="$(date +%s.%N)"
docker compose up --abort-on-container-exit --exit-code-from correct > /tmp/hc/out.txt 2>&1
code=$?
end="$(date +%s.%N)"
grep -E 'correct' /tmp/hc/out.txt | sed 's/^/  /'
printf '  код выхода стека: %s, всего заняло %.1f c\n' "$code" \
    "$(awk -v a="$start" -v b="$end" 'BEGIN{print b-a}')"
docker compose down -v > /dev/null 2>&1

Ожидаемый вывод:

text
  correct-1  | correct: соединение установлено за 3 мс
  correct-1 exited with code 0
  код выхода стека: 0, всего заняло 6.8 c

Compose дождался статуса healthy и только затем запустил зависимый сервис. Соединение установилось мгновенно.

Обратите внимание на общее время: 6.8 секунды против мгновенного падения в наивном варианте. Ожидание — это плата, и она оправдана.

Как меняется состояние здоровья

bash
cd /tmp/hc
cat > slow.py <<'PY'
"""Сервис, который становится готов через 8 секунд после старта."""
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

READY_AFTER = 8.0
_started = time.monotonic()


class H(BaseHTTPRequestHandler):
    def do_GET(self):
        ready = (time.monotonic() - _started) >= READY_AFTER
        self.send_response(200 if ready else 503)
        self.end_headers()
        self.wfile.write(b"ok\n" if ready else b"starting\n")

    def log_message(self, *a):
        pass


print("сервер запущен, готовность через 8 c", flush=True)
HTTPServer(("0.0.0.0", 8000), H).serve_forever()
PY

cat > compose.yaml <<'EOF'
name: hc

services:
  slow:
    image: python:3.13-slim
    volumes:
      - ./slow.py:/slow.py:ro
    command: ["python", "-u", "/slow.py"]
    healthcheck:
      test:
        - CMD
        - python
        - -c
        - |
          import sys, urllib.request
          try:
              r = urllib.request.urlopen("http://127.0.0.1:8000/", timeout=2)
              sys.exit(0 if r.status == 200 else 1)
          except Exception:
              sys.exit(1)
      interval: 2s
      timeout: 3s
      retries: 3
      start_period: 15s
      start_interval: 1s
EOF

docker compose down > /dev/null 2>&1
docker compose up -d > /dev/null 2>&1
cid="$(docker compose ps -q slow)"

echo "═══ состояние по секундам ═══"
for i in $(seq 14); do
    printf '  %2ss  %s\n' "$i" "$(docker inspect "$cid" --format '{{.State.Health.Status}}')"
    sleep 1
done

echo "═══ журнал проверок ═══"
docker inspect "$cid" --format '{{range .State.Health.Log}}  код={{.ExitCode}} {{.Output}}{{end}}' \
    | head -6

Ожидаемый вывод:

text
═══ состояние по секундам ═══
   1s  starting
   2s  starting
   3s  starting
   4s  starting
   5s  starting
   6s  starting
   7s  starting
   8s  starting
   9s  healthy
  10s  healthy
  11s  healthy
  12s  healthy
  13s  healthy
  14s  healthy
═══ журнал проверок ═══
  код=1 
  код=1 
  код=1 
  код=0 
  код=0 

Восемь секунд в состоянии starting, затем healthy. Заметьте: start_period был 15 секунд, но переход произошёл на девятой — первая успешная проверка завершает окно старта досрочно.

В журнале видны неудачи с кодом 1 — они не привели к unhealthy, потому что случились внутри start_period. Это и есть его назначение.

Что было бы без start_period

bash
cd /tmp/hc
python3 - <<'PY'
import pathlib, re
p = pathlib.Path("compose.yaml")
t = p.read_text()
t = t.replace("      start_period: 15s\n", "")
t = t.replace("      start_interval: 1s\n", "")
p.write_text(t)
PY

docker compose down > /dev/null 2>&1
docker compose up -d > /dev/null 2>&1
cid="$(docker compose ps -q slow)"
sleep 9
printf '  состояние на 9-й секунде: %s\n' \
    "$(docker inspect "$cid" --format '{{.State.Health.Status}}')"
printf '  неудач подряд: %s\n' \
    "$(docker inspect "$cid" --format '{{.State.Health.FailingStreak}}')"
sleep 4
printf '  состояние на 13-й секунде: %s\n' \
    "$(docker inspect "$cid" --format '{{.State.Health.Status}}')"
docker compose down > /dev/null 2>&1

Ожидаемый вывод:

text
  состояние на 9-й секунде: unhealthy
  неудач подряд: 4
  состояние на 13-й секунде: healthy

Без start_period тот же сервис успел получить unhealthy — три неудачи подряд при retries: 3. Позже он восстановился, но зависимые сервисы с условием service_healthy уже не дождались бы его в срок.

Это ровно та ошибка, из-за которой в конфигурациях появляются огромные retries вместо правильного start_period.

Миграции как отдельный сервис

bash
cd /tmp/hc
cat > migrate.py <<'PY'
"""Идемпотентные миграции: создают таблицу и версию схемы."""
from __future__ import annotations

import os
import socket
import sys
import time

DB_HOST = os.environ.get("DB_HOST", "db")
DB_PORT = int(os.environ.get("DB_PORT", "5432"))
FAIL = os.environ.get("MIGRATE_FAIL", "") == "1"


def wait_port(host: str, port: int, timeout: float = 30.0) -> bool:
    """Повторы даже при service_healthy: зависимость может отвалиться."""
    deadline = time.monotonic() + timeout
    delay = 0.2
    while time.monotonic() < deadline:
        s = socket.socket()
        s.settimeout(2)
        try:
            s.connect((host, port))
            return True
        except OSError:
            time.sleep(delay)
            delay = min(delay * 2, 3.0)      # экспоненциальная задержка
        finally:
            s.close()
    return False


def main() -> int:
    print("миграции: старт", flush=True)
    if not wait_port(DB_HOST, DB_PORT):
        print("миграции: база недоступна", flush=True)
        return 1

    if FAIL:
        print("миграции: ОШИБКА (имитация)", flush=True)
        return 1

    time.sleep(1)          # имитация работы
    print("миграции: схема обновлена", flush=True)
    return 0


if __name__ == "__main__":
    sys.exit(main())
PY

cat > compose.yaml <<'EOF'
name: hc

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s
      start_interval: 1s

  migrate:
    image: python:3.13-slim
    volumes:
      - ./migrate.py:/migrate.py:ro
    environment:
      MIGRATE_FAIL: "${MIGRATE_FAIL:-0}"
    command: ["python", "-u", "/migrate.py"]
    depends_on:
      db:
        condition: service_healthy
    restart: "no"

  api:
    image: python:3.13-slim
    volumes:
      - ./check.py:/check.py:ro
    depends_on:
      migrate:
        condition: service_completed_successfully
    command: ["python", "/check.py", "db", "5432", "api"]
EOF

echo "═══ успешные миграции ═══"
docker compose down -v > /dev/null 2>&1
docker compose up --abort-on-container-exit --exit-code-from api > /tmp/hc/out.txt 2>&1
echo "  код стека: $?"
grep -E 'миграции|api:' /tmp/hc/out.txt | sed 's/^/  /'

echo
echo "═══ миграции падают ═══"
docker compose down -v > /dev/null 2>&1
MIGRATE_FAIL=1 docker compose up --abort-on-container-exit --exit-code-from api > /tmp/hc/out2.txt 2>&1
echo "  код стека: $?"
grep -iE 'миграции|api:|dependency' /tmp/hc/out2.txt | head -4 | sed 's/^/  /'
printf '  api вообще запускался: %s\n' \
    "$(grep -c 'api-1' /tmp/hc/out2.txt || echo 0)"
docker compose down -v > /dev/null 2>&1

Ожидаемый вывод:

text
═══ успешные миграции ═══
  код стека: 0
  migrate-1  | миграции: старт
  migrate-1  | миграции: схема обновлена
  api-1      | api: соединение установлено за 2 мс
═══ миграции падают ═══
  код стека: 1
  migrate-1  | миграции: старт
  migrate-1  | миграции: ОШИБКА (имитация)
  service "migrate" didn't complete successfully: exit 1
  api вообще запускался: 0

Второй блок — главное свойство паттерна: провал миграции не дал приложению стартовать вовсе. Приложение не увидело базу со старой схемой и не начало писать в неё некорректные данные.

Обратите внимание на строку api вообще запускался: 0 — container даже не создавался.

Healthcheck не должен проверять зависимости

bash
cd /tmp/hc
cat > appsrv.py <<'PY'
"""Приложение с раздельными пробами: liveness и readiness."""
import os
import socket
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

DB_HOST = os.environ.get("DB_HOST", "db")
DB_PORT = int(os.environ.get("DB_PORT", "5432"))


def db_reachable() -> bool:
    s = socket.socket()
    s.settimeout(1)
    try:
        s.connect((DB_HOST, DB_PORT))
        return True
    except OSError:
        return False
    finally:
        s.close()


class H(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/healthz":          # liveness: только процесс
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"alive\n")
        elif self.path == "/readyz":         # readiness: и зависимости
            ok = db_reachable()
            self.send_response(200 if ok else 503)
            self.end_headers()
            self.wfile.write(b"ready\n" if ok else b"db unavailable\n")
        else:
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"ok\n")

    def log_message(self, *a):
        pass


print("приложение запущено", flush=True)
HTTPServer(("0.0.0.0", 8000), H).serve_forever()
PY

cat > compose.yaml <<'EOF'
name: hc

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
      interval: 2s
      retries: 30
      start_period: 5s

  # ПРАВИЛЬНО: healthcheck проверяет только сам процесс
  good:
    image: python:3.13-slim
    volumes:
      - ./appsrv.py:/appsrv.py:ro
    command: ["python", "-u", "/appsrv.py"]
    healthcheck:
      test:
        - CMD
        - python
        - -c
        - |
          import sys, urllib.request
          sys.exit(0 if urllib.request.urlopen(
              "http://127.0.0.1:8000/healthz", timeout=2).status == 200 else 1)
      interval: 2s
      retries: 3
      start_period: 10s

  # НЕПРАВИЛЬНО: healthcheck зависит от базы
  bad:
    image: python:3.13-slim
    volumes:
      - ./appsrv.py:/appsrv.py:ro
    command: ["python", "-u", "/appsrv.py"]
    healthcheck:
      test:
        - CMD
        - python
        - -c
        - |
          import sys, urllib.request
          sys.exit(0 if urllib.request.urlopen(
              "http://127.0.0.1:8000/readyz", timeout=2).status == 200 else 1)
      interval: 2s
      retries: 3
      start_period: 10s
EOF

docker compose down -v > /dev/null 2>&1
docker compose up -d > /dev/null 2>&1
sleep 16

echo "═══ база работает ═══"
for s in good bad; do
    printf '  %-5s %s\n' "$s" \
        "$(docker inspect "$(docker compose ps -q $s)" --format '{{.State.Health.Status}}')"
done

echo "═══ останавливаем базу ═══"
docker compose stop db > /dev/null 2>&1
sleep 12
for s in good bad; do
    printf '  %-5s %s\n' "$s" \
        "$(docker inspect "$(docker compose ps -q $s)" --format '{{.State.Health.Status}}')"
done

echo "═══ приложения при этом отвечают ═══"
for s in good bad; do
    printf '  %-5s / → %s\n' "$s" \
        "$(docker compose exec -T $s python -c "
import urllib.request
print(urllib.request.urlopen('http://127.0.0.1:8000/', timeout=2).status)" 2>/dev/null)"
done

docker compose down -v > /dev/null 2>&1

Ожидаемый вывод:

text
═══ база работает ═══
  good  healthy
  bad   healthy
═══ останавливаем базу ═══
  good  healthy
  bad   unhealthy
═══ приложения при этом отвечают ═══
  good  / → 200
  bad   / → 200

Оба приложения работают и отвечают на запросы — последний блок это подтверждает. Но bad помечен unhealthy, потому что его проверка зависит от базы.

В оркестраторе это означало бы перезапуск всех реплик bad одновременно — в момент, когда база и так недоступна. Перезапуск ничего не чинит, а нагрузка на восстанавливающуюся базу удваивается.

Проверка good отвечает на правильный вопрос: жив ли процесс.

Приложение обязано повторять попытки

bash
cd /tmp/hc
cat > resilient.py <<'PY'
"""Клиент с повторами и экспоненциальной задержкой."""
from __future__ import annotations

import os
import socket
import sys
import time

HOST = os.environ.get("DB_HOST", "db")
PORT = int(os.environ.get("DB_PORT", "5432"))


def connect_with_retry(attempts: int = 8) -> bool:
    delay = 0.25
    for i in range(1, attempts + 1):
        s = socket.socket()
        s.settimeout(2)
        try:
            s.connect((HOST, PORT))
            print(f"  попытка {i}: успех", flush=True)
            return True
        except OSError as exc:
            print(f"  попытка {i}: {type(exc).__name__}, жду {delay:.2f} c", flush=True)
            time.sleep(delay)
            delay = min(delay * 2, 5.0)
        finally:
            s.close()
    return False


if __name__ == "__main__":
    sys.exit(0 if connect_with_retry() else 1)
PY

cat > compose.yaml <<'EOF'
name: hc

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb

  # Без depends_on вообще — устойчивость обеспечивает код
  resilient:
    image: python:3.13-slim
    volumes:
      - ./resilient.py:/resilient.py:ro
    command: ["python", "-u", "/resilient.py"]
EOF

docker compose down -v > /dev/null 2>&1
docker compose up --abort-on-container-exit --exit-code-from resilient > /tmp/hc/out.txt 2>&1
echo "  код: $?"
grep -E 'попытка' /tmp/hc/out.txt | sed 's/^/  /'
docker compose down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/hc

Ожидаемый вывод:

text
  код: 0
  resilient-1  |   попытка 1: ConnectionRefusedError, жду 0.25 c
  resilient-1  |   попытка 2: ConnectionRefusedError, жду 0.50 c
  resilient-1  |   попытка 3: ConnectionRefusedError, жду 1.00 c
  resilient-1  |   попытка 4: ConnectionRefusedError, жду 2.00 c
  resilient-1  |   попытка 5: успех

Ни depends_on, ни healthcheck — только повторы в коде. И этого оказалось достаточно.

Вывод не в том, что depends_on не нужен: он делает старт предсказуемым и ускоряет его. Вывод в том, что он не заменяет устойчивости приложения — а обратное неверно.


Практическое упражнение

Задание. Постройте стек с корректной цепочкой зависимостей и докажите семь утверждений.

  1. db имеет healthcheck с start_period; переход в healthy происходит после реальной готовности.
  2. Без start_period тот же сервис успевает получить unhealthy — показать.
  3. migrate выполняется после готовности базы и завершается с кодом 0.
  4. api стартует только после успешного завершения migrate.
  5. Провал migrate не даёт api запуститься вовсе.
  6. Healthcheck api проверяет только себя: остановка базы не делает его unhealthy.
  7. api переживает перезапуск базы без собственного перезапуска.

Подсказки

Подсказка 1

Для пункта 7 приложение должно устанавливать соединение при каждом обращении, а не хранить его с момента старта.

Подсказка 2

Пункт 5 проверяется отсутствием container'а api, а не только кодом возврата.

Подсказка 3

Для пункта 2 достаточно второго сервиса с тем же образом и healthcheck без start_period.

Решение

Показать решение
bash
mkdir -p /tmp/hcfull && cd /tmp/hcfull

cat > app.py <<'PY'
"""Приложение: раздельные пробы, повторы, соединение при каждом запросе."""
from __future__ import annotations

import os
import socket
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

DB_HOST = os.environ.get("DB_HOST", "db")
DB_PORT = int(os.environ.get("DB_PORT", "5432"))


def db_reachable(attempts: int = 3) -> bool:
    """Соединение устанавливается заново при каждом вызове (пункт 7)."""
    delay = 0.2
    for _ in range(attempts):
        s = socket.socket()
        s.settimeout(1.5)
        try:
            s.connect((DB_HOST, DB_PORT))
            return True
        except OSError:
            time.sleep(delay)
            delay = min(delay * 2, 2.0)
        finally:
            s.close()
    return False


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/healthz":
            # Liveness: только процесс, о базе НЕ знает (пункт 6)
            self._respond(200, b"alive\n")
        elif self.path == "/readyz":
            ok = db_reachable(attempts=1)
            self._respond(200 if ok else 503,
                          b"ready\n" if ok else b"db unavailable\n")
        elif self.path == "/query":
            ok = db_reachable()
            self._respond(200 if ok else 503,
                          b"query ok\n" if ok else b"db unavailable\n")
        else:
            self._respond(200, b"service\n")

    def _respond(self, code: int, body: bytes) -> None:
        self.send_response(code)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass


if __name__ == "__main__":
    print("приложение слушает 0.0.0.0:8000", flush=True)
    HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
PY

cat > migrate.py <<'PY'
"""Миграции: ждут базу с экспоненциальной задержкой, идемпотентны."""
from __future__ import annotations

import os
import socket
import sys
import time

HOST = os.environ.get("DB_HOST", "db")
PORT = int(os.environ.get("DB_PORT", "5432"))
FAIL = os.environ.get("MIGRATE_FAIL", "0") == "1"


def wait(timeout: float = 30.0) -> bool:
    deadline = time.monotonic() + timeout
    delay = 0.2
    while time.monotonic() < deadline:
        s = socket.socket()
        s.settimeout(2)
        try:
            s.connect((HOST, PORT))
            return True
        except OSError:
            time.sleep(delay)
            delay = min(delay * 2, 3.0)
        finally:
            s.close()
    return False


def main() -> int:
    print("MIGRATE: начало", flush=True)
    if not wait():
        print("MIGRATE: база недоступна", flush=True)
        return 1
    if FAIL:
        print("MIGRATE: ОШИБКА", flush=True)
        return 1
    time.sleep(0.5)
    print("MIGRATE: успешно", flush=True)
    return 0


if __name__ == "__main__":
    sys.exit(main())
PY

cat > compose.yaml <<'EOF'
name: hcfull

x-healthcheck-http: &http-healthcheck
  test:
    - CMD
    - python
    - -c
    - |
      import sys, urllib.request
      sys.exit(0 if urllib.request.urlopen(
          "http://127.0.0.1:8000/healthz", timeout=2).status == 200 else 1)
  interval: 2s
  timeout: 3s
  retries: 3

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
      interval: 2s
      timeout: 3s
      retries: 5
      start_period: 20s       # пункт 1
      start_interval: 1s

  # Пункт 2: тот же образ, healthcheck без start_period
  db-no-start-period:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
      PGPORT: "5433"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb -p 5433"]
      interval: 1s
      timeout: 2s
      retries: 2

  # Пункты 3–5
  migrate:
    image: python:3.13-slim
    volumes:
      - ./migrate.py:/migrate.py:ro
    environment:
      MIGRATE_FAIL: "${MIGRATE_FAIL:-0}"
    command: ["python", "-u", "/migrate.py"]
    depends_on:
      db:
        condition: service_healthy
    restart: "no"

  api:
    image: python:3.13-slim
    volumes:
      - ./app.py:/app.py:ro
    command: ["python", "-u", "/app.py"]
    depends_on:
      migrate:
        condition: service_completed_successfully
      db:
        condition: service_healthy
        restart: true
    healthcheck:
      <<: *http-healthcheck    # пункт 6: только liveness
      start_period: 10s
EOF

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }
health() { docker inspect "$(docker compose ps -q "$1")" --format '{{.State.Health.Status}}' 2>/dev/null; }

printf '\n═══ Пункты 1 и 2: start_period ═══\n'
docker compose down -v > /dev/null 2>&1
docker compose up -d db db-no-start-period > /dev/null 2>&1
for i in $(seq 8); do
    printf '    %ss  с start_period: %-9s без: %s\n' "$i" "$(health db)" "$(health db-no-start-period)"
    sleep 1
done
h1="$(health db)"; h2="$(health db-no-start-period)"
[ "$h1" != "unhealthy" ] && ok "с start_period не помечен unhealthy (пункт 1)" \
    || bad "db стал unhealthy несмотря на start_period"
[ "$h2" = "unhealthy" ] && ok "без start_period стал unhealthy (пункт 2)" \
    || bad "ожидался unhealthy, получено $h2"

# Дожидаемся готовности
for _ in $(seq 40); do [ "$(health db)" = "healthy" ] && break; sleep 1; done
printf '    db в состоянии: %s\n' "$(health db)"

printf '\n═══ Пункты 3–4: цепочка миграций ═══\n'
docker compose down -v > /dev/null 2>&1
docker compose up -d api > /tmp/hcfull/up.log 2>&1
for _ in $(seq 60); do [ "$(health api)" = "healthy" ] && break; sleep 1; done
docker compose logs migrate --no-log-prefix 2>/dev/null | sed 's/^/    /'
mcode="$(docker inspect "$(docker compose ps -aq migrate)" --format '{{.State.ExitCode}}')"
printf '    код migrate: %s\n' "$mcode"
[ "$mcode" = "0" ] && ok "migrate завершился успешно (пункт 3)" || bad "код migrate: $mcode"
[ "$(health api)" = "healthy" ] && ok "api запущен и healthy (пункт 4)" || bad "api: $(health api)"

printf '\n═══ Пункт 5: провал migrate ═══\n'
docker compose down -v > /dev/null 2>&1
MIGRATE_FAIL=1 docker compose up -d api > /tmp/hcfull/fail.log 2>&1
rc=$?
printf '    код команды up: %s\n' "$rc"
grep -io "didn't complete successfully" /tmp/hcfull/fail.log | head -1 | sed 's/^/    /'
api_containers="$(docker ps -a --filter label=com.docker.compose.project=hcfull \
                  --filter label=com.docker.compose.service=api -q | wc -l)"
printf '    container api создан: %s\n' "$api_containers"
[ "$rc" != "0" ] && [ "$api_containers" -eq 0 ] \
    && ok "api не запущен вовсе (пункт 5)" || bad "api создан несмотря на провал migrate"

printf '\n═══ Пункты 6–7: остановка и перезапуск базы ═══\n'
docker compose down -v > /dev/null 2>&1
docker compose up -d api > /dev/null 2>&1
for _ in $(seq 60); do [ "$(health api)" = "healthy" ] && break; sleep 1; done
api_id_before="$(docker compose ps -q api)"

req() { docker compose exec -T api python -c "
import urllib.request, sys
try:
    r = urllib.request.urlopen('http://127.0.0.1:8000$1', timeout=3)
    print(r.status)
except urllib.error.HTTPError as e:
    print(e.code)
except Exception as e:
    print(type(e).__name__)
" 2>/dev/null | tr -d '\r\n'; }

printf '    база работает:  /healthz=%s /readyz=%s /query=%s health=%s\n' \
    "$(req /healthz)" "$(req /readyz)" "$(req /query)" "$(health api)"

docker compose stop db > /dev/null 2>&1
sleep 10
hz="$(req /healthz)"; rz="$(req /readyz)"; hs="$(health api)"
printf '    база остановлена: /healthz=%s /readyz=%s health=%s\n' "$hz" "$rz" "$hs"
[ "$hs" = "healthy" ] && ok "healthcheck не зависит от базы (пункт 6)" || bad "api стал $hs"
[ "$rz" = "503" ] && ok "readiness честно сообщает о проблеме" || bad "/readyz=$rz"

docker compose start db > /dev/null 2>&1
for _ in $(seq 40); do [ "$(req /query)" = "200" ] && break; sleep 1; done
api_id_after="$(docker compose ps -q api)"
printf '    база возвращена:  /query=%s\n' "$(req /query)"
[ "$(req /query)" = "200" ] && ok "работа восстановилась (пункт 7)" || bad "/query=$(req /query)"
[ "$api_id_before" = "$api_id_after" ] && ok "api не перезапускался" \
    || bad "container api пересоздан"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все семь утверждений подтверждены" || echo "  ЕСТЬ ПРОВАЛЫ"

docker compose down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/hcfull
exit "$fail"

Ожидаемый вывод:

text
═══ Пункты 1 и 2: start_period ═══
    1s  с start_period: starting   без: starting
    2s  с start_period: starting   без: starting
    3s  с start_period: starting   без: unhealthy
    4s  с start_period: starting   без: unhealthy
    5s  с start_period: healthy    без: unhealthy
    6s  с start_period: healthy    без: unhealthy
    7s  с start_period: healthy    без: healthy
    8s  с start_period: healthy    без: healthy
  ✓ с start_period не помечен unhealthy (пункт 1)
  ✓ без start_period стал unhealthy (пункт 2)
    db в состоянии: healthy

═══ Пункты 3–4: цепочка миграций ═══
    MIGRATE: начало
    MIGRATE: успешно
    код migrate: 0
  ✓ migrate завершился успешно (пункт 3)
  ✓ api запущен и healthy (пункт 4)

═══ Пункт 5: провал migrate ═══
    код команды up: 1
    didn't complete successfully
    container api создан: 0
  ✓ api не запущен вовсе (пункт 5)

═══ Пункты 6–7: остановка и перезапуск базы ═══
    база работает:  /healthz=200 /readyz=200 /query=200 health=healthy
    база остановлена: /healthz=200 /readyz=503 health=healthy
  ✓ healthcheck не зависит от базы (пункт 6)
  ✓ readiness честно сообщает о проблеме
    база возвращена:  /query=200
  ✓ работа восстановилась (пункт 7)
  ✓ api не перезапускался

Все семь утверждений подтверждены.

Обратите внимание на строку без: unhealthy на третьей секунде, сменяющуюся на healthy на седьмой: сервис восстановился сам. Но окно, в котором он был помечен нездоровым, реально — и зависимый сервис с условием service_healthy в это время ждал бы дольше или получил бы отказ.

Три решения, определяющие качество.

Пункт 2 проверяется вторым экземпляром той же базы, а не рассуждением. Оба сервиса используют один образ и стартуют одновременно; отличается ровно один параметр. Это превращает утверждение «start_period нужен» в наблюдаемый факт: две колонки, одна строка расхождения.

Пункт 5 проверяет отсутствие container'а, а не только код возврата. Ненулевой код мог бы означать, что api запустился и упал сам. Подсчёт container'ов с меткой сервиса доказывает, что Compose даже не начал его создавать — это принципиально иное поведение и именно то, ради чего нужен service_completed_successfully.

Пункт 7 сравнивает ID container'а до и после. Восстановление работы могло бы объясняться перезапуском api — тогда заслуга принадлежала бы Docker, а не коду приложения. Совпадение ID доказывает, что тот же процесс пережил недоступность базы и восстановился сам.

Чего решение не делает. Флаг restart: true в depends_on объявлен, но его действие не проверяется: он срабатывает, когда зависимость перезапускает Compose (например, при up с изменённой конфигурацией), а не при ручном stop/start. Не проверяется и поведение при полном отказе базы дольше таймаутов приложения — там повторы исчерпаются, и /query будет честно отдавать 503 до восстановления.

Проверка результата

bash
mkdir -p /tmp/hcc && cd /tmp/hcc
cat > compose.yaml <<'EOF'
name: hcc
services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: x
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 2s
      retries: 20
      start_period: 10s
  app:
    image: alpine:3.21
    command: ["echo", "база готова"]
    depends_on:
      db:
        condition: service_healthy
EOF
docker compose up --abort-on-container-exit --exit-code-from app
docker compose down -v
cd /tmp && rm -rf /tmp/hcc

Ожидается вывод база готова и код 0.

Типичные ошибки

ОшибкаПричинаИсправление
depends_on без условияКажется достаточнымЖдёт запуска, не готовности
Огромный retries вместо start_periodСимптом лечится, причина нетstart_period для медленного старта
Healthcheck проверяет базуКажется «более полным»Каскадный отказ всех реплик
curl в healthcheck slim-образаПривычный инструментЕго нет; использовать python -c
test: ["CMD", "sh", "-c", "..."]Не знают про CMD-SHELLЕсть готовая форма
interval: 1s для тяжёлой проверкиХотят быстрее реагироватьПроверка отнимает ресурсы у приложения
restart: always у сервиса миграцийСкопировали у приложенияЗадача перезапускается бесконечно
Полагаются только на depends_onСчитают вопрос закрытымЗависимость может отвалиться позже
Ожидают, что Compose перезапустит unhealthyПо аналогии с оркестраторомCompose этого не делает
Неидемпотентные миграцииНе учли повторный upВторой запуск ломает схему

Контрольные вопросы

На понимание:

  1. Что гарантирует depends_on без условия и чего не гарантирует?
  2. Что делает start_period и чем отличается от увеличения retries?
  3. Почему первая успешная проверка завершает start_period досрочно?
  4. Почему healthcheck не должен проверять базу данных?
  5. Что делает Compose, когда работающий сервис становится unhealthy?

На применение:

  1. Как написать healthcheck для Python-сервиса без curl?
  2. Как выполнить миграции ровно один раз до старта приложения?
  3. Как сделать так, чтобы провал миграции остановил запуск стека?

На диагностику:

  1. Приложение падает при старте через раз. Первая версия?
  2. Все реплики стали unhealthy одновременно. Где искать причину?

Краткое резюме

  1. healthcheck задаётся test, interval, timeout, retries, start_period, start_interval.
  2. Код возврата 0 означает «здоров», любой другой — «нет».
  3. CMD выполняется без shell, CMD-SHELL — через /bin/sh -c.
  4. Внутри start_period неудачи не увеличивают счётчик retries.
  5. Первая успешная проверка завершает окно старта досрочно.
  6. depends_on без условия гарантирует только порядок запуска container'ов.
  7. service_healthy требует наличия healthcheck у зависимости.
  8. service_completed_successfully даёт паттерн миграций: провал останавливает стек.
  9. Миграции должны быть идемпотентны — повторный up выполнит их снова.
  10. Healthcheck проверяет сам процесс; проверка зависимостей вызывает каскадный отказ.
  11. Compose не перезапускает сервисы, ставшие unhealthy после старта.
  12. Устойчивость обеспечивает приложение: повторы, проверка соединений, разрешение имён.

Официальные источники

ИсточникСсылкаЧто подтверждает
Compose: healthcheckhttps://docs.docker.com/reference/compose-file/services/#healthcheckВсе параметры, формы test
Compose: depends_onhttps://docs.docker.com/reference/compose-file/services/#depends_onУсловия, флаги restart и required
Compose: startup orderhttps://docs.docker.com/compose/how-tos/startup-order/Почему порядок не равен готовности
Dockerfile: HEALTHCHECKhttps://docs.docker.com/reference/dockerfile/#healthcheckПроверка на уровне образа
Docker: docker inspecthttps://docs.docker.com/reference/cli/docker/inspect/Поле .State.Health и журнал проверок
Kubernetes: probeshttps://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/Различие liveness и readiness

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Environment и secrets
Главное оглавление

Markdown на GitHub ↗