9.4. Healthchecks и зависимости
Цели
После этого материала вы сможете:
- написать корректный
healthcheckи объяснить каждый его параметр; - объяснить, почему
depends_onбез условия не даёт никаких гарантий; - применять
service_healthy,service_completed_successfullyи флагиrestart,required; - реализовать миграции как отдельный сервис, завершающийся до старта приложения;
- объяснить, почему приложение всё равно обязано переживать недоступность зависимости;
- избегать healthcheck, которые проверяют не то и вызывают каскадные отказы.
Предварительные знания
- 9.2. Справочник по services;
- 5.8. HEALTHCHECK;
- 6.10. FastAPI — liveness против readiness.
Ключевые термины
| Термин | Объяснение |
|---|---|
starting | Состояние здоровья до первой успешной проверки |
start_period | Окно, в котором неудачи не засчитываются |
start_interval | Учащённый интервал проверок внутри start_period |
service_healthy | Условие: зависимость прошла healthcheck |
service_completed_successfully | Условие: зависимость завершилась с кодом 0 |
экспоненциальная задержка | Растущая пауза между повторными попытками |
Теория
Синтаксис healthcheck
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 | из образа | Команда проверки |
interval | 30s | Пауза между проверками |
timeout | 30s | Сколько ждать завершения команды |
retries | 3 | Неудач подряд до статуса unhealthy |
start_period | 0s | Окно, в котором неудачи не считаются |
start_interval | 5s | Интервал внутри start_period |
disable | false | Отключить проверку из образа |
Форма 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 — здоров, любой другой — нет.
Три состояния и переходы
создан ──► starting ──┬──► healthy ──┬──► unhealthy
│ │ │
│ └────────┘
└──► unhealthy (после start_period)
| Состояние | Когда |
|---|---|
starting | С момента запуска до первой удачной проверки или конца start_period |
healthy | Последняя проверка успешна |
unhealthy | retries неудач подряд |
Ключевое свойство start_period: неудачи внутри этого окна не увеличивают счётчик retries. Первая же удачная проверка переводит container в healthy немедленно, не дожидаясь конца окна.
Без start_period медленно стартующая база успеет получить unhealthy до того, как поднимется.
Параметр start_interval дополняет его: внутри окна старта проверки идут чаще, поэтому готовность обнаруживается быстро, а в установившемся режиме interval остаётся редким.
depends_on: чего он не делает
services:
api:
depends_on:
- db # НЕ гарантирует, что база готова
Такая запись означает только: container db запущен раньше. Запущен — не значит принимает соединения.
| Что происходит | Время |
|---|---|
| Container базы создан и запущен | 0.1 с |
| PostgreSQL начал инициализацию | 0.2 с |
| Каталог данных создан, идёт запуск | 1–5 с |
| База принимает соединения | 3–15 с |
Приложение, стартовавшее «после» базы, всё это время получает ECONNREFUSED.
Симптом узнаваем: проблема воспроизводится не всегда. На быстрой машине база успевает подняться, на загруженной — нет (урок 8.6).
Условия depends_on
services:
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfully
optional-cache:
condition: service_started
required: false
| Условие | Значение |
|---|---|
service_started | Container запущен (умолчание) |
service_healthy | Healthcheck зависимости прошёл |
service_completed_successfully | Зависимость завершилась с кодом 0 |
| Флаг | Значение |
|---|---|
restart: true | Перезапустить этот сервис, если зависимость была перезапущена |
required: false | Не прерывать запуск, если зависимости нет в конфигурации |
service_healthy требует, чтобы у зависимости был healthcheck. Без него Compose откажется запускаться с явной ошибкой — и это хорошо: молчаливое игнорирование условия было бы хуже.
Миграции как отдельный сервис
Классическая задача: схема базы должна быть обновлена до старта приложения, ровно один раз, а не в каждой реплике.
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 недостаточно и с условиями
Даже идеальная цепочка условий решает только задачу старта. После него:
| Событие | Что произойдёт |
|---|---|
| База перезапущена | Приложение потеряет соединения |
| Сеть моргнула | Запросы упадут |
База стала unhealthy | Compose ничего не сделает — это не оркестратор |
| База обновлена | Пауза в обслуживании |
Приложение обязано уметь переживать недоступность зависимости. Минимум:
| Приём | Что даёт |
|---|---|
| Повторы с экспоненциальной задержкой | Переживает короткую недоступность |
| Проверка соединения перед выдачей из пула | Ловит разорванные соединения |
| Разрешение имени при каждом соединении | Переживает смену адреса (урок 8.4) |
| Разделение liveness и readiness | Не даёт убить работающее приложение (урок 6.10) |
depends_on экономит секунды на старте. Устойчивость обеспечивает код.
Каким должен быть healthcheck
| Требование | Почему |
|---|---|
| Дешёвый | Выполняется каждые interval секунд бесконечно |
| Быстрый | Должен укладываться в timeout |
| Проверяет себя, а не зависимости | Иначе отказ базы уронит все реплики разом |
| Не требует лишних пакетов | curl часто отсутствует в slim-образах |
| Осмысленный | exit 0 не проверяет ничего |
Третий пункт — самый важный. Healthcheck, обращающийся к базе, превращает недоступность базы в лавину перезапусков: все реплики становятся unhealthy одновременно, перезапускаются и добивают восстанавливающуюся базу.
Проверка живости отвечает на вопрос «жив ли процесс», а не «работает ли вся система».
Проверка без curl
В python:*-slim нет curl и wget. Рабочие варианты:
# 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 плюс приложение, завершающееся при неисправности.
Команды и примеры
Гонка при старте
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/^/ /'
Ожидаемый вывод:
naive-1 | naive: НЕ ГОТОВО — ConnectionRefusedError ([Errno 111] Connection refused)
naive-1 exited with code 1
depends_on: [db] отработал: container базы был запущен первым. Но PostgreSQL ещё инициализировался, и порт не принимал соединений.
Проверим, сколько времени на самом деле нужно базе:
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
Ожидаемый вывод:
база приняла соединения через 4.3 c
Четыре секунды — окно, в котором наивная зависимость гарантированно падает. На загруженной машине оно больше, на быстрой может сократиться до секунды — отсюда «иногда работает».
Healthcheck и service_healthy
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
Ожидаемый вывод:
correct-1 | correct: соединение установлено за 3 мс
correct-1 exited with code 0
код выхода стека: 0, всего заняло 6.8 c
Compose дождался статуса healthy и только затем запустил зависимый сервис. Соединение установилось мгновенно.
Обратите внимание на общее время: 6.8 секунды против мгновенного падения в наивном варианте. Ожидание — это плата, и она оправдана.
Как меняется состояние здоровья
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
Ожидаемый вывод:
═══ состояние по секундам ═══
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
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
Ожидаемый вывод:
состояние на 9-й секунде: unhealthy
неудач подряд: 4
состояние на 13-й секунде: healthy
Без start_period тот же сервис успел получить unhealthy — три неудачи подряд при retries: 3. Позже он восстановился, но зависимые сервисы с условием service_healthy уже не дождались бы его в срок.
Это ровно та ошибка, из-за которой в конфигурациях появляются огромные retries вместо правильного start_period.
Миграции как отдельный сервис
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
Ожидаемый вывод:
═══ успешные миграции ═══
код стека: 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 не должен проверять зависимости
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
Ожидаемый вывод:
═══ база работает ═══
good healthy
bad healthy
═══ останавливаем базу ═══
good healthy
bad unhealthy
═══ приложения при этом отвечают ═══
good / → 200
bad / → 200
Оба приложения работают и отвечают на запросы — последний блок это подтверждает. Но bad помечен unhealthy, потому что его проверка зависит от базы.
В оркестраторе это означало бы перезапуск всех реплик bad одновременно — в момент, когда база и так недоступна. Перезапуск ничего не чинит, а нагрузка на восстанавливающуюся базу удваивается.
Проверка good отвечает на правильный вопрос: жив ли процесс.
Приложение обязано повторять попытки
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
Ожидаемый вывод:
код: 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 не нужен: он делает старт предсказуемым и ускоряет его. Вывод в том, что он не заменяет устойчивости приложения — а обратное неверно.
Практическое упражнение
Задание. Постройте стек с корректной цепочкой зависимостей и докажите семь утверждений.
dbимеет healthcheck сstart_period; переход вhealthyпроисходит после реальной готовности.- Без
start_periodтот же сервис успевает получитьunhealthy— показать. migrateвыполняется после готовности базы и завершается с кодом0.apiстартует только после успешного завершенияmigrate.- Провал
migrateне даётapiзапуститься вовсе. - Healthcheck
apiпроверяет только себя: остановка базы не делает егоunhealthy. apiпереживает перезапуск базы без собственного перезапуска.
Подсказки
Подсказка 1
Для пункта 7 приложение должно устанавливать соединение при каждом обращении, а не хранить его с момента старта.
Подсказка 2
Пункт 5 проверяется отсутствием container'а api, а не только кодом возврата.
Подсказка 3
Для пункта 2 достаточно второго сервиса с тем же образом и healthcheck без start_period.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Пункты 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 до восстановления.
Проверка результата
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 | Второй запуск ломает схему |
Контрольные вопросы
На понимание:
- Что гарантирует
depends_onбез условия и чего не гарантирует? - Что делает
start_periodи чем отличается от увеличенияretries? - Почему первая успешная проверка завершает
start_periodдосрочно? - Почему healthcheck не должен проверять базу данных?
- Что делает Compose, когда работающий сервис становится
unhealthy?
На применение:
- Как написать healthcheck для Python-сервиса без
curl? - Как выполнить миграции ровно один раз до старта приложения?
- Как сделать так, чтобы провал миграции остановил запуск стека?
На диагностику:
- Приложение падает при старте через раз. Первая версия?
- Все реплики стали
unhealthyодновременно. Где искать причину?
Краткое резюме
healthcheckзадаётсяtest,interval,timeout,retries,start_period,start_interval.- Код возврата
0означает «здоров», любой другой — «нет». CMDвыполняется без shell,CMD-SHELL— через/bin/sh -c.- Внутри
start_periodнеудачи не увеличивают счётчикretries. - Первая успешная проверка завершает окно старта досрочно.
depends_onбез условия гарантирует только порядок запуска container'ов.service_healthyтребует наличия healthcheck у зависимости.service_completed_successfullyдаёт паттерн миграций: провал останавливает стек.- Миграции должны быть идемпотентны — повторный
upвыполнит их снова. - Healthcheck проверяет сам процесс; проверка зависимостей вызывает каскадный отказ.
- Compose не перезапускает сервисы, ставшие
unhealthyпосле старта. - Устойчивость обеспечивает приложение: повторы, проверка соединений, разрешение имён.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Compose: healthcheck | https://docs.docker.com/reference/compose-file/services/#healthcheck | Все параметры, формы test |
| Compose: depends_on | https://docs.docker.com/reference/compose-file/services/#depends_on | Условия, флаги restart и required |
| Compose: startup order | https://docs.docker.com/compose/how-tos/startup-order/ | Почему порядок не равен готовности |
| Dockerfile: HEALTHCHECK | https://docs.docker.com/reference/dockerfile/#healthcheck | Проверка на уровне образа |
Docker: docker inspect | https://docs.docker.com/reference/cli/docker/inspect/ | Поле .State.Health и журнал проверок |
| Kubernetes: probes | https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ | Различие liveness и readiness |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Environment и secrets
Главное оглавление