11.3. Healthchecks и readiness
Цели
После этого материала вы сможете:
- посчитать, сколько времени трафик идёт на неисправный экземпляр, и уменьшить это время;
- объяснить, почему при остановке readiness должен отказать раньше, чем приложение перестанет принимать запросы;
- рассчитать
stop_grace_periodиз времени слива трафика и длительности запроса; - оценить цену ошибки в liveness-пробе через арифметику каскадного отказа;
- решить, какие зависимости включать в readiness, а какие нет;
- понимать, кто побеждает при конфликте
HEALTHCHECKв образе, Compose и оркестраторе.
Предварительные знания
- 6.10. FastAPI — реализация обеих проб;
- 9.4. Healthchecks и зависимости — синтаксис и параметры;
- 5.8. HEALTHCHECK.
Механика проб разобрана в перечисленных уроках. Здесь — решения, которые принимают при эксплуатации.
Ключевые термины
| Термин | Объяснение |
|---|---|
drain | Период, когда экземпляр помечен неготовым, но ещё обслуживает запросы |
detection time | Время от отказа до исключения экземпляра из балансировки |
startup probe | Отдельная проба для медленного старта |
degraded mode | Работа с частью функций при отказе необязательной зависимости |
failureThreshold | Число неудач подряд до признания отказа |
Теория
Три пробы и что стоит за каждой
| Проба | Вопрос | Отказ означает | Цена ошибки |
|---|---|---|---|
startup | Приложение закончило запуск? | Ещё не готово, ждём | Низкая |
liveness | Процесс жив и работоспособен? | Перезапустить | Очень высокая |
readiness | Готов принимать трафик? | Убрать из балансировки | Средняя |
В Docker и Compose штатно есть только одна проба — HEALTHCHECK. Она соответствует liveness. Readiness реализуют отдельным endpoint'ом, который опрашивает балансировщик или оркестратор.
Роль startup в Compose играет параметр start_period: неудачи внутри окна не засчитываются (урок 9.4).
Арифметика обнаружения отказа
Сколько времени трафик идёт на сломанный экземпляр?
detection time = interval × failureThreshold + timeout
При типичных значениях:
interval | retries | timeout | Время до исключения |
|---|---|---|---|
| 30 с | 3 | 30 с | до 120 с |
| 10 с | 3 | 3 с | до 33 с |
| 5 с | 3 | 2 с | до 17 с |
| 2 с | 3 | 1 с | до 7 с |
Первая строка — значения по умолчанию Docker. Две минуты на обнаружение отказа: за это время экземпляр успеет отвергнуть тысячи запросов.
Уменьшать интервал бесконечно нельзя: проба выполняется процессом внутри container'а и тратит ресурсы (урок 9.4). Разумный компромисс для веб-сервиса — interval: 5s, retries: 3, timeout: 2s.
Цена ошибки в liveness
Ошибка в readiness убирает экземпляр из балансировки — обратимо и дёшево.
Ошибка в liveness перезапускает его. Посчитаем цену на трёх репликах, когда liveness ошибочно проверяет базу и база отказала:
t=0 база отказала
t=15 все три реплики получили unhealthy
t=15 все три перезапускаются одновременно
t=15..45 трафик не обслуживается никем
t=45 реплики поднялись, начали переподключение к базе
t=45 база (восстанавливающаяся) получает лавину подключений
t=60 liveness снова падает — база не справилась
цикл повторяется
Отказ базы на 30 секунд превратился в полную недоступность сервиса на минуты, а перезапуски мешают базе восстановиться.
Правило: liveness проверяет только то, что чинится перезапуском. Отказ базы перезапуском не чинится.
| Что проверять в liveness | Что не проверять |
|---|---|
| Процесс отвечает на HTTP | База данных |
| Цикл событий не заблокирован | Кэш |
| Основной поток жив | Внешние API |
| Внутренние очереди не переполнены необратимо | Очередь сообщений |
Что включать в readiness
Readiness отвечает на вопрос «есть ли смысл слать сюда трафик».
| Зависимость | В readiness | Почему |
|---|---|---|
| Основная база | Да | Без неё большинство запросов упадёт |
| Кэш (Redis) | Обычно нет | Работа возможна медленнее |
| Очередь для фоновых задач | Нет | Не влияет на обработку запросов |
| Внешний платёжный API | Нет | Отказ у них не должен снимать нас с балансировки |
| Миграции применены | Да, при старте | Без схемы работать нельзя |
Ключевой критерий: если эта зависимость отказала, лучше ли будет, если трафик пойдёт на другой экземпляр? Для базы, общей для всех реплик, ответ «нет» — снимать с балансировки бессмысленно, все реплики в одинаковом положении.
Отсюда уточнение: readiness полезен для локальных проблем экземпляра (не прогрет пул, идёт перезагрузка конфигурации), а не для общих отказов инфраструктуры.
Degraded mode
Третий вариант между «готов» и «не готов»: работать с частью функций.
@app.get("/readyz")
async def readyz(response: Response):
checks = {}
critical_ok = True
checks["db"] = await check_db()
if checks["db"] != "ok":
critical_ok = False # без базы работать нельзя
checks["cache"] = await check_cache()
# кэш необязателен: отмечаем, но готовность не теряем
if not critical_ok:
response.status_code = 503
return {"status": "ready" if critical_ok else "degraded", "checks": checks}
Такой ответ полезен и человеку, и мониторингу: он говорит не «сломано», а что именно сломано.
Слив трафика при остановке
Самая недооценённая часть. Наивная последовательность теряет запросы:
ПЛОХО:
SIGTERM ──► сервер сразу перестаёт принимать ──► клиенты получают отказ
(балансировщик ещё не знает, что мы уходим)
Правильная последовательность:
ХОРОШО:
t=0 SIGTERM
t=0 readiness начинает отвечать 503 ← мы говорим «не шлите сюда»
t=0..D приложение ПРОДОЛЖАЕТ принимать запросы ← балансировщик ещё шлёт
t=D балансировщик убрал нас из пула
t=D перестаём принимать новые соединения
t=D.. дозавершаем текущие запросы
t=D+R закрываем ресурсы, выходим с кодом 0
Где D — время обнаружения балансировщиком, R — длительность самого долгого запроса.
Отсюда формула:
stop_grace_period > D + R + запас
│ └── максимальная длительность запроса
└────── interval × failureThreshold readiness-пробы
При interval: 5s, retries: 3 и запросах до 10 секунд: 15 + 10 + 5 = 30 секунд.
Значение по умолчанию — 10 секунд. Его почти всегда недостаточно (урок 6.11).
Кто побеждает при конфликте
HEALTHCHECK можно задать в трёх местах:
| Место | Приоритет | Комментарий |
|---|---|---|
Dockerfile | Низший | Разумное умолчание для образа |
compose.yaml | Перекрывает образ | Настройка под окружение |
| Оркестратор | Полностью заменяет | Kubernetes игнорирует HEALTHCHECK вовсе |
Третья строка важна при переходе на Kubernetes: HEALTHCHECK из образа там не используется — пробы описываются в манифесте Pod (раздел 18).
Отсюда практика: держать пробу в образе как умолчание, а параметры задавать снаружи. Отключить пробу из образа можно значением NONE:
healthcheck:
disable: true
Внутренний механизм
Что происходит при unhealthy в Docker
Docker не перезапускает нездоровый container (урок 9.4). Он лишь меняет статус в docker inspect и генерирует событие health_status: unhealthy.
Реакция — задача внешнего инструмента: оркестратора, docker events с обработчиком или Swarm.
Отсюда важное следствие: в чистом Docker Compose liveness-проба почти бесполезна как средство восстановления. Её ценность — в depends_on: service_healthy при старте и как источник сигнала для мониторинга.
Стоимость самой пробы
Проба запускается как отдельный процесс внутри container'а. При interval: 2s это 30 запусков интерпретатора в минуту.
Для Python это заметно: старт интерпретатора занимает десятки миллисекунд и десяток мегабайт. Лёгкая альтернатива — проверка через уже работающий процесс, если приложение умеет отвечать на сигнал, либо простая команда без Python.
Команды и примеры
Сколько времени трафик идёт на сломанный экземпляр
mkdir -p /tmp/probes && cd /tmp/probes
cat > app.py <<'PY'
"""Сервис, который можно сломать по требованию."""
import json
import os
import signal
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
_healthy = True
_ready = True
_server = None
_draining = False
DRAIN = float(os.environ.get("DRAIN_SECONDS", "0"))
class H(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/healthz":
self._json(200 if _healthy else 503, {"alive": _healthy})
elif self.path == "/readyz":
ok = _ready and not _draining
self._json(200 if ok else 503,
{"ready": ok, "draining": _draining})
elif self.path == "/break":
globals()["_healthy"] = False
self._json(200, {"broken": True})
else:
self._json(200, {"pid": os.getpid(), "ts": time.time()})
def _json(self, code, payload):
body = json.dumps(payload).encode()
self.send_response(code)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
def on_term(signum, _frame):
"""Слив трафика: сначала перестаём быть ready, потом закрываемся."""
global _draining
print(f"[{time.time():.1f}] SIGTERM: начинаю слив, readiness → 503", flush=True)
_draining = True
def finish():
if DRAIN:
time.sleep(DRAIN)
print(f"[{time.time():.1f}] слив завершён, закрываю приём", flush=True)
_server.shutdown()
threading.Thread(target=finish, daemon=True).start()
if __name__ == "__main__":
signal.signal(signal.SIGTERM, on_term)
_server = HTTPServer(("0.0.0.0", 8000), H)
print(f"[{time.time():.1f}] запущен, DRAIN={DRAIN}", flush=True)
_server.serve_forever()
print(f"[{time.time():.1f}] остановлен штатно", flush=True)
sys.exit(0)
PY
cat > compose.yaml <<'EOF'
name: probes
x-base: &base
image: python:3.13-slim
volumes:
- ./app.py:/app.py:ro
command: ["python", "-u", "/app.py"]
services:
# Умолчания Docker: interval 30s, retries 3, timeout 30s
defaults:
<<: *base
ports:
- "127.0.0.1:8601:8000"
healthcheck:
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)"]
# Настроенные значения
tuned:
<<: *base
ports:
- "127.0.0.1:8602:8000"
healthcheck:
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)"]
interval: 5s
timeout: 2s
retries: 3
start_period: 10s
start_interval: 1s
EOF
docker compose up -d > /dev/null 2>&1
for _ in $(seq 60); do
[ "$(docker compose ps --format '{{.Health}}' tuned)" = "healthy" ] && break
sleep 1
done
sleep 2
echo "═══ параметры проб ═══"
for svc in defaults tuned; do
docker inspect "$(docker compose ps -q $svc)" --format \
" $svc: interval={{.Config.Healthcheck.Interval}} timeout={{.Config.Healthcheck.Timeout}} retries={{.Config.Healthcheck.Retries}}" \
| sed 's/000000000ns/s/g; s/0000000ns/00ms/g'
done
echo "═══ ломаем оба и засекаем время до unhealthy ═══"
for svc in defaults tuned; do
port=$([ "$svc" = "defaults" ] && echo 8601 || echo 8602)
curl -s -m 3 "http://127.0.0.1:$port/break" > /dev/null
done
start="$(date +%s)"
detected_d=0; detected_t=0
for i in $(seq 140); do
[ "$detected_d" -eq 0 ] && [ "$(docker compose ps --format '{{.Health}}' defaults)" = "unhealthy" ] \
&& detected_d=$(( $(date +%s) - start ))
[ "$detected_t" -eq 0 ] && [ "$(docker compose ps --format '{{.Health}}' tuned)" = "unhealthy" ] \
&& detected_t=$(( $(date +%s) - start ))
[ "$detected_d" -ne 0 ] && [ "$detected_t" -ne 0 ] && break
sleep 1
done
printf ' defaults: обнаружено через %s с\n' "${detected_d:-не обнаружено}"
printf ' tuned: обнаружено через %s с\n' "${detected_t:-не обнаружено}"
Ожидаемый вывод:
═══ параметры проб ═══
defaults: interval=30s timeout=30s retries=3
tuned: interval=5s timeout=2s retries=3
═══ ломаем оба и засекаем время до unhealthy ═══
defaults: обнаружено через 92 с
tuned: обнаружено через 16 с
Полторы минуты против шестнадцати секунд. Разница — четыре строки конфигурации.
За 92 секунды сервис под нагрузкой в 100 запросов в секунду отвергнет около девяти тысяч запросов, оставаясь для балансировщика исправным.
Docker не перезапускает нездоровый container
cd /tmp/probes
echo "═══ статус после обнаружения ═══"
for svc in defaults tuned; do
cid="$(docker compose ps -q $svc)"
printf ' %-9s health=%-10s status=%-8s перезапусков=%s\n' \
"$svc" \
"$(docker inspect "$cid" --format '{{.State.Health.Status}}')" \
"$(docker inspect "$cid" --format '{{.State.Status}}')" \
"$(docker inspect "$cid" --format '{{.RestartCount}}')"
done
echo "═══ события Docker ═══"
timeout 3 docker events --since 3m --filter event=health_status --format ' {{.Actor.Attributes.name}}: {{.Status}}' 2>/dev/null | head -4 || true
Ожидаемый вывод:
═══ статус после обнаружения ═══
defaults health=unhealthy status=running перезапусков=0
tuned health=unhealthy status=running перезапусков=0
═══ события Docker ═══
probes-tuned-1: health_status: unhealthy
probes-defaults-1: health_status: unhealthy
Оба помечены unhealthy, оба продолжают работать, ни одного перезапуска.
Docker сообщил о проблеме событием — и на этом его роль закончилась. Реагировать должен внешний инструмент.
Практический вывод: в Compose liveness-проба служит источником сигнала, а не средством восстановления. Для автоматического перезапуска нужен оркестратор либо restart: unless-stopped в сочетании с приложением, которое завершается при неисправимой ошибке.
cd /tmp/probes && docker compose down > /dev/null 2>&1
Слив трафика: наивный вариант теряет запросы
cd /tmp/probes
cat > compose.drain.yaml <<'EOF'
name: drain
x-base: &base
image: python:3.13-slim
volumes:
- ./app.py:/app.py:ro
command: ["python", "-u", "/app.py"]
services:
# Без слива: закрывается сразу по SIGTERM
naive:
<<: *base
environment:
DRAIN_SECONDS: "0"
ports:
- "127.0.0.1:8611:8000"
stop_grace_period: 30s
# Со сливом: 8 секунд обслуживает, будучи не-ready
draining:
<<: *base
environment:
DRAIN_SECONDS: "8"
ports:
- "127.0.0.1:8612:8000"
stop_grace_period: 30s
EOF
docker compose -f compose.drain.yaml up -d > /dev/null 2>&1
sleep 5
load() { # load <порт> <секунд> — считает успешные и неудачные запросы
local port="$1" dur="$2" ok=0 err=0 deadline
deadline=$(( $(date +%s) + dur ))
while [ "$(date +%s)" -lt "$deadline" ]; do
if curl -s -m 2 -o /dev/null "http://127.0.0.1:$port/" 2>/dev/null; then
ok=$((ok + 1))
else
err=$((err + 1))
fi
sleep 0.05
done
echo "$ok $err"
}
for svc in naive draining; do
port=$([ "$svc" = "naive" ] && echo 8611 || echo 8612)
echo "═══ $svc ═══"
# Нагрузка идёт всё время остановки
load "$port" 14 > /tmp/probes/load-$svc.txt &
load_pid=$!
sleep 3
printf ' readiness до остановки: %s\n' \
"$(curl -s -m 2 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$port/readyz")"
docker compose -f compose.drain.yaml stop "$svc" > /dev/null 2>&1 &
stop_pid=$!
sleep 2
printf ' readiness во время слива: %s\n' \
"$(curl -s -m 2 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$port/readyz" 2>/dev/null || echo 'нет ответа')"
wait $stop_pid 2>/dev/null
wait $load_pid 2>/dev/null
read -r ok err < /tmp/probes/load-$svc.txt
printf ' запросов: успешных=%s неудачных=%s\n' "$ok" "$err"
docker compose -f compose.drain.yaml logs "$svc" --no-log-prefix 2>/dev/null | tail -3 | sed 's/^/ /'
done
docker compose -f compose.drain.yaml down > /dev/null 2>&1
Ожидаемый вывод:
═══ naive ═══
readiness до остановки: 200
readiness во время слива: нет ответа
запросов: успешных=54 неудачных=141
[1785412901.4] SIGTERM: начинаю слив, readiness → 503
[1785412901.4] остановлен штатно
═══ draining ═══
readiness до остановки: 200
readiness во время слива: 503
запросов: успешных=196 неудачных=0
[1785412918.2] SIGTERM: начинаю слив, readiness → 503
[1785412926.2] слив завершён, закрываю приём
[1785412926.2] остановлен штатно
Первый вариант потерял 141 запрос из 195. Второй — ни одного.
Разница в восьми секундах, в течение которых приложение отвечает 503 на /readyz, но продолжает обслуживать обычные запросы. За это время балансировщик успевает убрать экземпляр из пула.
Строка «readiness во время слива: 503» — ключевая. Приложение говорит «не шлите сюда», но продолжает принимать то, что уже отправлено.
Расчёт stop_grace_period
cd /tmp/probes
cat > grace-calc.py <<'PY'
"""Расчёт stop_grace_period из параметров балансировщика и приложения."""
from __future__ import annotations
def compute(interval: float, failures: int, max_request: float,
margin: float = 5.0) -> dict[str, float]:
"""
interval — период readiness-пробы балансировщика
failures — сколько неудач подряд до исключения из пула
max_request — длительность самого долгого запроса
margin — запас
"""
detection = interval * failures
total = detection + max_request + margin
return {
"обнаружение балансировщиком": detection,
"дозавершение запросов": max_request,
"запас": margin,
"stop_grace_period": total,
}
SCENARIOS = [
("веб-API, короткие запросы", 5, 3, 2),
("веб-API, отчёты до 30 с", 5, 3, 30),
("worker очереди, задача до 60 с", 0, 0, 60),
("умолчания Docker (interval 30s)", 30, 3, 2),
]
print(f"{'сценарий':<34} {'обнаруж.':>9} {'запрос':>8} {'grace':>8}")
print("─" * 62)
for name, interval, failures, req in SCENARIOS:
r = compute(interval, failures, req)
print(f"{name:<34} {r['обнаружение балансировщиком']:>8.0f}с "
f"{r['дозавершение запросов']:>7.0f}с {r['stop_grace_period']:>7.0f}с")
print()
print("Умолчание Docker: 10 с")
print("Оно достаточно только для сценария с короткими запросами")
print("и быстрой пробой — и то без запаса.")
PY
python3 grace-calc.py
Ожидаемый вывод:
сценарий обнаруж. запрос grace
──────────────────────────────────────────────────────────────
веб-API, короткие запросы 15с 2с 22с
веб-API, отчёты до 30 с 15с 30с 50с
worker очереди, задача до 60 с 0с 60с 65с
умолчания Docker (interval 30s) 90с 2с 97с
Умолчание Docker: 10 с
Оно достаточно только для сценария с короткими запросами
и быстрой пробой — и то без запаса.
Последняя строка таблицы показывает, как параметры пробы влияют на время остановки: медленная проба требует девяноста семи секунд grace period, иначе экземпляр будет убит, пока балансировщик ещё шлёт на него трафик.
Отсюда связь, которую легко упустить: ускорение пробы удешевляет остановку.
Каскадный отказ: арифметика
cd /tmp/probes
cat > cascade.py <<'PY'
"""Что происходит при liveness, зависящей от общей базы."""
from __future__ import annotations
REPLICAS = 3
DETECTION = 15 # секунд до unhealthy
RESTART = 20 # секунд на перезапуск и прогрев
DB_OUTAGE = 30 # база недоступна столько
def simulate(liveness_checks_db: bool) -> None:
label = "liveness проверяет базу" if liveness_checks_db else "liveness проверяет только процесс"
print(f"\n── {label} ──")
print(f" реплик: {REPLICAS}, отказ базы: {DB_OUTAGE} с")
if not liveness_checks_db:
print(f" t=0 база отказала")
print(f" t=0 readiness → 503 у всех реплик (честно)")
print(f" t=0 liveness → 200, перезапусков нет")
print(f" t={DB_OUTAGE} база вернулась")
print(f" t={DB_OUTAGE} readiness → 200, трафик восстановлен")
print(f" ИТОГО недоступность: {DB_OUTAGE} с (равна отказу базы)")
return
t = 0
print(f" t=0 база отказала")
t += DETECTION
print(f" t={t} все {REPLICAS} реплики → unhealthy ОДНОВРЕМЕННО")
print(f" t={t} все {REPLICAS} перезапускаются")
t += RESTART
print(f" t={t} реплики поднялись, лавина подключений к базе")
if t < DB_OUTAGE:
print(f" t={t} база ещё недоступна → liveness падает снова")
t += DETECTION + RESTART
print(f" t={t} второй круг перезапусков")
print(f" ИТОГО недоступность: не менее {t} с при отказе базы в {DB_OUTAGE} с")
print(f" усиление: ×{t / DB_OUTAGE:.1f}")
simulate(liveness_checks_db=False)
simulate(liveness_checks_db=True)
PY
python3 cascade.py
Ожидаемый вывод:
── liveness проверяет только процесс ──
реплик: 3, отказ базы: 30 с
t=0 база отказала
t=0 readiness → 503 у всех реплик (честно)
t=0 liveness → 200, перезапусков нет
t=30 база вернулась
t=30 readiness → 200, трафик восстановлен
ИТОГО недоступность: 30 с (равна отказу базы)
── liveness проверяет базу ──
реплик: 3, отказ базы: 30 с
t=0 база отказала
t=15 все 3 реплики → unhealthy ОДНОВРЕМЕННО
t=15 все 3 перезапускаются
t=35 реплики поднялись, лавина подключений к базе
ИТОГО недоступность: не менее 35 с при отказе базы в 30 с
усиление: ×1.2
Даже при благоприятном раскладе недоступность превысила длительность исходного отказа. При более медленном восстановлении базы или более долгом старте приложения цикл повторяется, и усиление растёт кратно.
Ключевое слово в выводе — ОДНОВРЕМЕННО. Реплики независимы, но проверяют общий ресурс, поэтому отказывают синхронно. Это превращает частичную деградацию в полную недоступность.
Degraded mode
cd /tmp/probes
cat > degraded.py <<'PY'
"""Readiness с разделением обязательных и необязательных зависимостей."""
import json
import os
import socket
from http.server import BaseHTTPRequestHandler, HTTPServer
CRITICAL = {"db": ("db", 5432)} # без них работать нельзя
OPTIONAL = {"cache": ("cache", 6379)} # без них медленнее, но можно
def reachable(host: str, port: int, timeout: float = 1.0) -> bool:
s = socket.socket()
s.settimeout(timeout)
try:
s.connect((host, port))
return True
except OSError:
return False
finally:
s.close()
class H(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/healthz":
# Liveness: только процесс
self._json(200, {"alive": True})
return
checks = {}
critical_ok = True
for name, (host, port) in CRITICAL.items():
ok = reachable(host, port)
checks[name] = "ok" if ok else "недоступен"
critical_ok = critical_ok and ok
for name, (host, port) in OPTIONAL.items():
checks[name] = "ok" if reachable(host, port) else "недоступен (необязателен)"
degraded = any("недоступен" in v for v in checks.values())
status = "ready" if critical_ok and not degraded else \
"degraded" if critical_ok else "not_ready"
self._json(200 if critical_ok else 503, {"status": status, "checks": checks})
def _json(self, code, payload):
body = json.dumps(payload, ensure_ascii=False).encode()
self.send_response(code)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
print(f"запущен, pid={os.getpid()}", flush=True)
HTTPServer(("0.0.0.0", 8000), H).serve_forever()
PY
cat > compose.degraded.yaml <<'EOF'
name: degraded
services:
api:
image: python:3.13-slim
volumes:
- ./degraded.py:/app.py:ro
command: ["python", "-u", "/app.py"]
ports:
- "127.0.0.1:8620:8000"
db:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: x
tmpfs:
- /var/lib/postgresql/data:size=256m
cache:
image: redis:8-alpine
EOF
docker compose -f compose.degraded.yaml up -d > /dev/null 2>&1
sleep 12
show() { curl -s -m 5 -w ' [HTTP %{http_code}]' "http://127.0.0.1:8620/readyz" \
| python3 -c "
import json, sys
raw = sys.stdin.read()
body, _, code = raw.rpartition(' [HTTP ')
d = json.loads(body)
print(f\" {d['status']:<10} {code.rstrip(']')} {d['checks']}\")
"; }
echo "═══ всё работает ═══"
show
echo "═══ кэш недоступен (необязательная зависимость) ═══"
docker compose -f compose.degraded.yaml stop cache > /dev/null 2>&1
sleep 3
show
echo "═══ база недоступна (обязательная) ═══"
docker compose -f compose.degraded.yaml stop db > /dev/null 2>&1
sleep 3
show
echo "═══ liveness при этом ═══"
printf ' /healthz: HTTP %s\n' "$(curl -s -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8620/healthz)"
docker compose -f compose.degraded.yaml down > /dev/null 2>&1
Ожидаемый вывод:
═══ всё работает ═══
ready 200 {'db': 'ok', 'cache': 'ok'}
═══ кэш недоступен (необязательная зависимость) ═══
degraded 200 {'db': 'ok', 'cache': 'недоступен (необязателен)'}
═══ база недоступна (обязательная) ═══
not_ready 503 {'db': 'недоступен', 'cache': 'недоступен (необязателен)'}
═══ liveness при этом ═══
/healthz: HTTP 200
Три состояния вместо двух. При отказе кэша сервис остаётся в балансировке — он работает, просто медленнее. При отказе базы честно уходит из пула.
/healthz отвечает 200 во всех случаях: процесс жив, перезапуск ничего не починит.
Приоритет: образ, Compose, оркестратор
cd /tmp/probes
cat > Dockerfile.hc <<'EOF'
FROM python:3.13-slim
COPY app.py /app.py
HEALTHCHECK --interval=30s --timeout=10s --retries=5 \
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)"
CMD ["python", "-u", "/app.py"]
EOF
docker build -q -f Dockerfile.hc -t probes:hc . > /dev/null
cat > compose.prio.yaml <<'EOF'
name: prio
services:
from-image:
image: probes:hc
overridden:
image: probes:hc
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz',timeout=1).status==200 else 1)"]
interval: 3s
timeout: 1s
retries: 2
disabled:
image: probes:hc
healthcheck:
disable: true
EOF
docker compose -f compose.prio.yaml up -d > /dev/null 2>&1
sleep 6
for svc in from-image overridden disabled; do
cid="$(docker compose -f compose.prio.yaml ps -q $svc)"
hc="$(docker inspect "$cid" --format '{{if .Config.Healthcheck}}{{json .Config.Healthcheck.Test}} interval={{.Config.Healthcheck.Interval}}{{else}}(нет){{end}}')"
printf ' %-12s %s\n' "$svc" "$(echo "$hc" | sed 's/000000000ns/s/' | cut -c1-90)"
done
docker compose -f compose.prio.yaml down > /dev/null 2>&1
docker rmi -f probes:hc > /dev/null 2>&1
cd /tmp && rm -rf /tmp/probes
Ожидаемый вывод:
from-image ["CMD-SHELL","python -c \"import urllib.request,sys; ..."] interval=30s
overridden ["CMD","python","-c","import urllib.request,sys; ..."] interval=3s
disabled ["NONE"]
Три поведения: наследование из образа, переопределение в Compose, полное отключение через disable: true (превращается в ["NONE"]).
Обратите внимание на первую строку: HEALTHCHECK CMD в Dockerfile без квадратных скобок превращается в CMD-SHELL — то есть выполняется через оболочку. Это лишний процесс на каждую пробу (урок 5.8).
Практическое упражнение
Задание. Настройте пробы и остановку так, чтобы ни один запрос не потерялся, и докажите это измерением.
Требования:
- Liveness проверяет только процесс; остановка базы не делает container
unhealthy. - Readiness различает три состояния:
ready,degraded,not_ready. - При
SIGTERMreadiness отказывает раньше, чем приложение перестаёт принимать запросы. - Под непрерывной нагрузкой остановка не теряет ни одного запроса — измерить.
stop_grace_periodрассчитан по формуле, расчёт записан.- Время обнаружения отказа измерено и составляет менее 20 секунд.
Подсказки
Подсказка 1
Для пункта 4 нагрузку нужно подавать всё время остановки и считать успешные и неудачные ответы.
Подсказка 2
Пункт 3 проверяется опросом /readyz в момент, когда обычные запросы ещё проходят.
Подсказка 3
Пункт 6 — цикл опроса docker inspect с засечкой времени.
Решение
Показать решение
mkdir -p /tmp/probefull && cd /tmp/probefull
cat > app.py <<'PY'
"""Сервис с корректными пробами и сливом трафика при остановке."""
from __future__ import annotations
import json
import os
import signal
import socket
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
# Обязательные и необязательные зависимости (требование 2)
CRITICAL: dict[str, tuple[str, int]] = {"db": ("db", 5432)}
OPTIONAL: dict[str, tuple[str, int]] = {"cache": ("cache", 6379)}
# Требование 5: слив = interval × retries readiness-пробы балансировщика
DRAIN_SECONDS = float(os.environ.get("DRAIN_SECONDS", "6"))
REQUEST_SECONDS = float(os.environ.get("REQUEST_SECONDS", "1"))
_draining = False
_server: ThreadingHTTPServer | None = None
_inflight = 0
_lock = threading.Lock()
def reachable(host: str, port: int, timeout: float = 1.0) -> bool:
s = socket.socket()
s.settimeout(timeout)
try:
s.connect((host, port))
return True
except OSError:
return False
finally:
s.close()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_GET(self) -> None:
if self.path == "/healthz":
# Требование 1: liveness знает только про процесс
self._json(200, {"alive": True, "pid": os.getpid()})
return
if self.path == "/readyz":
self._readyz()
return
# Обычный запрос: занимает время, как настоящая работа
global _inflight
with _lock:
_inflight += 1
try:
time.sleep(REQUEST_SECONDS * 0.1)
self._json(200, {"ok": True, "pid": os.getpid()})
finally:
with _lock:
_inflight -= 1
def _readyz(self) -> None:
# Требование 3: во время слива сразу 503, зависимости не опрашиваем
if _draining:
self._json(503, {"status": "draining", "inflight": _inflight})
return
checks: dict[str, str] = {}
critical_ok = True
for name, (host, port) in CRITICAL.items():
ok = reachable(host, port)
checks[name] = "ok" if ok else "недоступен"
critical_ok = critical_ok and ok
degraded = False
for name, (host, port) in OPTIONAL.items():
ok = reachable(host, port)
checks[name] = "ok" if ok else "недоступен"
degraded = degraded or not ok
if not critical_ok:
status, code = "not_ready", 503
elif degraded:
status, code = "degraded", 200
else:
status, code = "ready", 200
self._json(code, {"status": status, "checks": checks})
def _json(self, code: int, payload: dict[str, object]) -> None:
body = json.dumps(payload, ensure_ascii=False).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args: object) -> None:
pass
def on_sigterm(signum: int, _frame: object) -> None:
"""Слив: readiness отказывает сразу, приём закрывается позже."""
global _draining
print(f"[{time.time():.2f}] SIGTERM: readiness → 503, слив {DRAIN_SECONDS} с", flush=True)
_draining = True
def close_later() -> None:
time.sleep(DRAIN_SECONDS)
print(f"[{time.time():.2f}] слив завершён, inflight={_inflight}", flush=True)
# Дожидаемся текущих запросов
deadline = time.monotonic() + REQUEST_SECONDS + 2
while _inflight > 0 and time.monotonic() < deadline:
time.sleep(0.05)
print(f"[{time.time():.2f}] закрываю приём", flush=True)
if _server is not None:
_server.shutdown()
threading.Thread(target=close_later, daemon=True).start()
def main() -> int:
global _server
signal.signal(signal.SIGTERM, on_sigterm)
signal.signal(signal.SIGINT, on_sigterm)
_server = ThreadingHTTPServer(("0.0.0.0", 8000), Handler)
_server.daemon_threads = True
print(f"[{time.time():.2f}] запущен, DRAIN={DRAIN_SECONDS}", flush=True)
_server.serve_forever()
print(f"[{time.time():.2f}] остановлен штатно", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
PY
cat > compose.yaml <<'EOF'
name: probefull
services:
api:
image: python:3.13-slim
volumes:
- ./app.py:/app.py:ro
command: ["python", "-u", "/app.py"]
environment:
DRAIN_SECONDS: "6" # interval(2) × retries(3) readiness-пробы
REQUEST_SECONDS: "1"
ports:
- "127.0.0.1:8700:8000"
# Требование 1: HEALTHCHECK обращается к /healthz, не к /readyz
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz',timeout=1).status==200 else 1)"]
interval: 3s
timeout: 2s
retries: 3
start_period: 10s
start_interval: 1s
# Требование 5: слив(6) + запрос(1) + запас(5) = 12, взято 20
stop_grace_period: 20s
db:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: x
tmpfs:
- /var/lib/postgresql/data:size=256m
command: ["postgres", "-c", "fsync=off"]
cache:
image: redis:8-alpine
EOF
cat > GRACE.md <<'TXT'
# Расчёт stop_grace_period
Формула: `grace > detection + max_request + margin`
| Слагаемое | Значение | Откуда |
|---|---:|---|
| detection: обнаружение балансировщиком | 6 с | interval 2 с × retries 3 |
| max_request: самый долгий запрос | 1 с | измерено на нагрузке |
| margin: запас | 5 с | на неравномерность и планировщик |
| **Итого минимум** | **12 с** | |
| **Задано** | **20 с** | округление вверх с двукратным запасом на пики |
DRAIN_SECONDS = 6 с — ровно detection: столько приложение продолжает
принимать запросы после того, как объявило себя неготовым.
Условие пересмотра: при изменении interval или retries readiness-пробы
балансировщика пересчитать detection и обновить оба значения.
TXT
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
ready() { curl -s -m 4 "http://127.0.0.1:8700/readyz" 2>/dev/null \
| python3 -c "import json,sys; print(json.load(sys.stdin)['status'])" 2>/dev/null; }
rcode() { curl -s -m 4 -o /dev/null -w '%{http_code}' "http://127.0.0.1:8700/readyz" 2>/dev/null; }
health() { docker compose ps --format '{{.Health}}' api 2>/dev/null; }
printf '\n═══ Запуск ═══\n'
docker compose up -d > /dev/null 2>&1
for _ in $(seq 60); do [ "$(health)" = "healthy" ] && break; sleep 1; done
printf ' health=%s readyz=%s\n' "$(health)" "$(ready)"
[ "$(health)" = "healthy" ] && [ "$(ready)" = "ready" ] && ok "стек поднялся" || bad "не поднялся"
printf '\n═══ Требование 2: три состояния readiness ═══\n'
printf ' всё работает: %-10s HTTP %s\n' "$(ready)" "$(rcode)"
docker compose stop cache > /dev/null 2>&1
sleep 3
printf ' кэш недоступен: %-10s HTTP %s\n' "$(ready)" "$(rcode)"
s_deg="$(ready)"; c_deg="$(rcode)"
docker compose stop db > /dev/null 2>&1
sleep 3
printf ' база недоступна: %-10s HTTP %s\n' "$(ready)" "$(rcode)"
s_nr="$(ready)"; c_nr="$(rcode)"
[ "$s_deg" = "degraded" ] && [ "$c_deg" = "200" ] \
&& ok "degraded остаётся в балансировке" || bad "degraded: $s_deg/$c_deg"
[ "$s_nr" = "not_ready" ] && [ "$c_nr" = "503" ] \
&& ok "not_ready уходит из балансировки" || bad "not_ready: $s_nr/$c_nr"
printf '\n═══ Требование 1: liveness не зависит от базы ═══\n'
sleep 12
printf ' база остановлена уже 15 с, health=%s\n' "$(health)"
printf ' /healthz: HTTP %s\n' "$(curl -s -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8700/healthz)"
[ "$(health)" = "healthy" ] && ok "container остался healthy — каскада не будет" \
|| bad "health=$(health)"
docker compose start db cache > /dev/null 2>&1
for _ in $(seq 40); do [ "$(ready)" = "ready" ] && break; sleep 1; done
printf ' после возврата зависимостей: %s\n' "$(ready)"
printf '\n═══ Требование 6: время обнаружения отказа ═══\n'
docker compose exec -T api sh -c 'kill -STOP 1' 2>/dev/null &
start="$(date +%s)"
detected=0
for _ in $(seq 40); do
[ "$(health)" = "unhealthy" ] && { detected=$(( $(date +%s) - start )); break; }
sleep 1
done
printf ' обнаружено через %s с (interval 3 × retries 3 = 9 + timeout)\n' "${detected:-не обнаружено}"
[ "$detected" -gt 0 ] && [ "$detected" -lt 20 ] && ok "менее 20 с" || bad "получено: $detected"
docker compose exec -T api sh -c 'kill -CONT 1' 2>/dev/null || \
docker compose restart api > /dev/null 2>&1
for _ in $(seq 60); do [ "$(health)" = "healthy" ] && break; sleep 1; done
printf '\n═══ Требования 3–4: слив трафика без потерь ═══\n'
cat > load.sh <<'LOADSH'
#!/usr/bin/env bash
# Непрерывная нагрузка: считает успешные и неудачные ответы
ok=0; err=0
deadline=$(( $(date +%s) + ${1:-20} ))
while [ "$(date +%s)" -lt "$deadline" ]; do
if curl -s -m 3 -o /dev/null -f "http://127.0.0.1:8700/" 2>/dev/null; then
ok=$((ok + 1))
else
err=$((err + 1))
fi
sleep 0.05
done
echo "$ok $err"
LOADSH
chmod +x load.sh
./load.sh 22 > /tmp/probefull/load.txt &
load_pid=$!
sleep 4
printf ' до остановки: readyz=%s\n' "$(ready)"
docker compose stop api > /dev/null 2>&1 &
stop_pid=$!
sleep 2
r_mid="$(rcode)"
code_mid="$(curl -s -m 3 -o /dev/null -w '%{http_code}' "http://127.0.0.1:8700/" 2>/dev/null)"
printf ' через 2 с после SIGTERM: readyz=HTTP %s, обычный запрос=HTTP %s\n' "$r_mid" "$code_mid"
[ "$r_mid" = "503" ] && [ "$code_mid" = "200" ] \
&& ok "readiness отказал, но запросы обслуживаются (требование 3)" \
|| bad "readyz=$r_mid запрос=$code_mid"
wait $stop_pid 2>/dev/null
wait $load_pid 2>/dev/null
read -r n_ok n_err < /tmp/probefull/load.txt
printf ' запросов за время теста: успешных=%s неудачных=%s\n' "$n_ok" "$n_err"
[ "$n_err" -eq 0 ] && ok "ни одного потерянного запроса (требование 4)" \
|| bad "потеряно $n_err запросов"
cid="$(docker compose ps -aq api)"
printf ' код выхода: %s\n' "$(docker inspect "$cid" --format '{{.State.ExitCode}}')"
docker compose logs api --no-log-prefix 2>/dev/null | tail -4 | sed 's/^/ /'
[ "$(docker inspect "$cid" --format '{{.State.ExitCode}}')" = "0" ] \
&& ok "остановка штатная" || bad "код выхода не 0"
printf '\n═══ Требование 5: расчёт записан ═══\n'
head -12 GRACE.md | sed 's/^/ /'
grace="$(docker inspect "$cid" --format '{{.HostConfig.StopTimeout}}' 2>/dev/null)"
printf ' stop_grace_period применён: %s с\n' "${grace:-?}"
[ -s GRACE.md ] && [ "${grace:-0}" = "20" ] \
&& ok "расчёт задокументирован и применён" || bad "grace=$grace"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все шесть требований выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
docker compose down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/probefull
exit "$fail"
Ожидаемый вывод:
═══ Запуск ═══
health=healthy readyz=ready
✓ стек поднялся
═══ Требование 2: три состояния readiness ═══
всё работает: ready HTTP 200
кэш недоступен: degraded HTTP 200
база недоступна: not_ready HTTP 503
✓ degraded остаётся в балансировке
✓ not_ready уходит из балансировки
═══ Требование 1: liveness не зависит от базы ═══
база остановлена уже 15 с, health=healthy
/healthz: HTTP 200
✓ container остался healthy — каскада не будет
после возврата зависимостей: ready
═══ Требование 6: время обнаружения отказа ═══
обнаружено через 13 с (interval 3 × retries 3 = 9 + timeout)
✓ менее 20 с
═══ Требования 3–4: слив трафика без потерь ═══
до остановки: readyz=ready
через 2 с после SIGTERM: readyz=HTTP 503, обычный запрос=HTTP 200
✓ readiness отказал, но запросы обслуживаются (требование 3)
запросов за время теста: успешных=312 неудачных=0
✓ ни одного потерянного запроса (требование 4)
код выхода: 0
[1785413044.12] SIGTERM: readiness → 503, слив 6.0 с
[1785413050.13] слив завершён, inflight=1
[1785413050.21] закрываю приём
[1785413050.22] остановлен штатно
✓ остановка штатная
═══ Требование 5: расчёт записан ═══
# Расчёт stop_grace_period
Формула: `grace > detection + max_request + margin`
...
stop_grace_period применён: 20 с
✓ расчёт задокументирован и применён
Все шесть требований выполнены.
Обратите внимание на строку слив завершён, inflight=1: в момент закрытия приёма один запрос был в обработке, и приложение дождалось его перед выходом. Именно это отличает ноль потерянных запросов от «почти ноль».
Три решения, определяющие качество.
Во время слива /readyz возвращает 503 не опрашивая зависимости. Опрос базы занял бы до секунды на таймауте, и балансировщик мог бы получить ответ позже, чем нужно. Флаг _draining проверяется первым — ответ мгновенный, слив начинается сразу.
Отказ имитируется kill -STOP 1, а не остановкой container'а. Остановленный container не имеет статуса unhealthy — он просто exited, и измерять было бы нечего. SIGSTOP замораживает процесс: он жив для Docker, но не отвечает — ровно то состояние, которое должна обнаруживать liveness-проба.
Нагрузка идёт всё время теста, включая период до SIGTERM. Запуск нагрузки одновременно с остановкой измерял бы только фазу слива и пропустил бы потери на границе. Четыре секунды до сигнала дают базовую линию: если бы неудачи были и там, вывод «слив работает» оказался бы неверным.
Чего решение не делает. Настоящего балансировщика в схеме нет — его роль играет предположение, что за DRAIN_SECONDS он успеет среагировать. Проверить это можно только с реальным прокси, опрашивающим /readyz. Не проверяется и поведение при нескольких репликах: слив одной не должен перегружать оставшиеся, а это зависит от запаса мощности, а не от кода приложения.
Проверка результата
docker run -d --name pr --health-cmd='python -c "import sys; sys.exit(0)"' \
--health-interval=3s --health-retries=2 --health-timeout=2s \
python:3.13-slim sleep 60 > /dev/null
sleep 8
docker inspect pr --format 'health={{.State.Health.Status}} проверок={{len .State.Health.Log}}'
docker rm -f pr > /dev/null
Ожидается health=healthy и несколько записей в журнале проверок.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Умолчания interval: 30s, retries: 3 | Не задали своих | До двух минут на обнаружение отказа |
| Liveness проверяет базу | Кажется полнее | Каскадный перезапуск всех реплик |
| Readiness проверяет внешний API | «Полная проверка» | Их отказ снимает нас с балансировки |
При SIGTERM сразу закрывают приём | Кажется правильным | Балансировщик ещё шлёт; запросы теряются |
stop_grace_period по умолчанию | Не считали | 10 с редко покрывает слив плюс запрос |
Ждут, что Docker перезапустит unhealthy | По аналогии с оркестратором | Docker только меняет статус |
HEALTHCHECK в образе считают действующим в Kubernetes | Логично предположить | Там пробы описываются в манифесте |
HEALTHCHECK CMD без списка | Короче писать | Превращается в CMD-SHELL: лишний процесс |
| Бинарное readiness вместо трёх состояний | Проще | Отказ кэша выводит из строя весь сервис |
Проба на Python при interval: 1s | Хотят быстрее реагировать | Старт интерпретатора 60 раз в минуту |
Контрольные вопросы
На понимание:
- По какой формуле считается время обнаружения отказа?
- Почему liveness не должна проверять базу? Опишите последствия по шагам.
- Почему при остановке readiness должен отказать раньше закрытия приёма?
- Как рассчитывается
stop_grace_period? - Что делает Docker с container'ом в состоянии
unhealthy?
На применение:
- Как реализовать три состояния readiness и что даёт
degraded? - Как измерить время обнаружения отказа?
- Как отключить
HEALTHCHECK, заданный в образе?
На диагностику:
- При деплое теряется часть запросов. Первая версия?
- Все реплики перезапускаются одновременно раз в несколько минут. Где искать?
Краткое резюме
- Docker даёт одну пробу —
HEALTHCHECK; она соответствует liveness. - Время обнаружения:
interval × retries + timeout; умолчания дают до двух минут. - Ошибка в readiness обратима, ошибка в liveness вызывает перезапуск.
- Liveness проверяет только то, что чинится перезапуском; база к этому не относится.
- Проверка общей зависимости в liveness роняет все реплики одновременно.
- Readiness полезен для локальных проблем экземпляра, а не для общих отказов.
- Три состояния —
ready,degraded,not_ready— точнее двух. - При
SIGTERMreadiness отказывает сразу, приём закрывается через время слива. stop_grace_period > detection + max_request + margin.- Ускорение пробы уменьшает и время обнаружения, и требуемый grace period.
- Docker не перезапускает
unhealthy— только меняет статус и шлёт событие. - Kubernetes игнорирует
HEALTHCHECKиз образа; пробы задаются в манифесте.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Dockerfile: HEALTHCHECK | https://docs.docker.com/reference/dockerfile/#healthcheck | Параметры, форма CMD против CMD-SHELL |
| Compose: healthcheck | https://docs.docker.com/reference/compose-file/services/#healthcheck | disable, переопределение образа |
Docker: docker events | https://docs.docker.com/reference/cli/docker/system/events/ | События health_status |
Compose: stop_grace_period | https://docs.docker.com/reference/compose-file/services/#stop_grace_period | Время до SIGKILL |
| Kubernetes: probes | https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ | Три пробы, failureThreshold |
| Kubernetes: Pod termination | https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination | Последовательность слива трафика |
| FastAPI: lifespan | https://fastapi.tiangolo.com/advanced/events/ | Обработка старта и остановки |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Resource limits и память Python
Главное оглавление