9.7. Практический stack
Цели
После этого материала вы сможете:
- прочитать полноценный
compose.yamlиз пяти сервисов и объяснить каждое решение в нём; - обосновать выбор
stop_grace_period, лимитов и параметров healthcheck конкретными числами; - собрать три окружения из одной базы без дублирования;
- проверить работоспособность stack не запуском, а измерением каждого свойства;
- назвать, что осталось за рамками Compose и требует других инструментов.
Предварительные знания
Весь раздел 09, а также:
Рабочий пример — resources/examples/compose-stack/. Урок разбирает именно его.
Ключевые термины
| Термин | Объяснение |
|---|---|
stack | Набор связанных сервисов, описанный одним проектом Compose |
роль | Назначение container'а: api, worker, migrate |
мост | Сервис, состоящий в двух сетях и связывающий уровни |
ворота | Сервис, чьё успешное завершение открывает запуск другим |
Теория
Из чего состоит система
┌───────── edge ─────────┐
клиент ──────► │ api │
└───────────┬────────────┘
│
┌───────────────── backend (internal) ─────────────────┐
│ │ │
│ migrate ──ворота──► api, worker │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ db ◄──────────────── db cache │
│ │
│ нет маршрута наружу: ни обновлений, ни внешних API │
└──────────────────────────────────────────────────────┘
| Сервис | Роль | Сети | Публикуется |
|---|---|---|---|
api | HTTP-интерфейс, ставит задачи в очередь | edge, backend | Да, и только в dev |
worker | Разбирает очередь, пишет результаты | backend | Нет |
migrate | Одноразовая задача: схема базы | backend | Нет |
db | PostgreSQL | backend | Нет |
cache | Redis: очередь задач | backend | Нет |
tools | Отладка, профиль tools | backend | Нет |
Один образ на три роли
api, worker и migrate собираются из одного образа и различаются только командой:
x-app-service: &app-service
build:
context: .
target: runtime
image: compose-stack:local
# ...
services:
api:
<<: *app-service # CMD из образа
worker:
<<: *app-service
command: ["python", "-m", "worker.main"]
migrate:
<<: *app-service
command: ["python", "/app/migrations/migrate.py"]
| Выгода | Пояснение |
|---|---|
| Код и зависимости совпадают | Невозможна ситуация «worker на старой версии» |
| Одна сборка вместо трёх | Быстрее и меньше места |
| Одна точка обновления | Правка requirements.txt действует на всё |
| Общие настройки заданы один раз | Якорь &app-service |
Плата — образ содержит код всех трёх ролей. Для этого размера приложения выигрыш в согласованности перевешивает.
Порядок запуска
db (healthy) ──► migrate (завершился с 0) ──► api, worker
cache (healthy) ─────────────────────────────►
migrate:
depends_on:
db:
condition: service_healthy
api:
depends_on:
migrate:
condition: service_completed_successfully
cache:
condition: service_healthy
Цепочка строгая, и каждое звено осмысленно:
| Звено | Что защищает |
|---|---|
db → migrate | Миграции не начнутся до готовности базы |
migrate → api | Приложение не увидит старую схему |
migrate → worker | То же для обработчика очереди |
cache → api, worker | Очередь доступна с первого запроса |
Провал migrate останавливает запуск всего stack — это желаемое поведение (урок 9.4).
Числа, а не умолчания
Каждый параметр в примере обоснован, а не скопирован.
| Параметр | Значение | Обоснование |
|---|---|---|
db.healthcheck.start_period | 30s | Первый запуск инициализирует каталог данных |
db.healthcheck.start_interval | 1s | Готовность обнаруживается быстро, не дожидаясь interval |
db.stop_grace_period | 30s | База должна закрыть файлы и записать буферы |
worker.stop_grace_period | 45s | Задача идёт до 30 секунд плюс запас |
api.stop_grace_period | 20s | Дозавершение активных HTTP-запросов |
api memory | 512M | Измеренный пик около 150 MiB, запас втрое |
worker memory | 256M | Один процесс, без веб-сервера |
cache memory | 128M | Плюс maxmemory 96mb в production |
migrate.restart | "no" | Перезапуск завершённой задачи — ошибка |
Строка про worker.stop_grace_period — самая важная. Значение по умолчанию в 10 секунд убило бы обработчик на середине задачи с кодом 137 (урок 6.11).
Три окружения
| Файл | Как применяется | Что задаёт |
|---|---|---|
compose.yaml | Всегда | Сервисы, сети, volumes, зависимости |
compose.override.yaml | Автоматически | Публикация на loopback, bind mount кода, LOG_LEVEL=DEBUG |
compose.prod.yaml | Только явно через -f | Реплики, лимиты, read-only, снятие публикации |
docker compose up -d # разработка
docker compose -f compose.yaml -f compose.prod.yaml up -d # production
docker compose --profile tools up -d # плюс отладка
Ключевая деталь: -f отменяет автоматический подхват compose.override.yaml, поэтому production не получит чужие локальные настройки (урок 9.6).
В production обязательны теги:
api:
ports: !reset [] # иначе порты из override сложатся с этими
volumes: !override [] # иначе bind mount кода останется
Что делает stack безопаснее
| Решение | Что предотвращает |
|---|---|
backend: internal: true | Выход базы и очереди в интернет |
| Пароль через secret | Утечку в docker inspect и /proc/<pid>/environ |
Публикация на 127.0.0.1 | Доступ к базе из локальной сети |
read_only: true в production | Закрепление атакующего в файловой системе |
USER 10001 в образе | Работу от root |
| Liveness без проверки базы | Каскадный перезапуск всех реплик |
Последняя строка не про безопасность в узком смысле, но предотвращает самый разрушительный сценарий: лавину перезапусков в момент, когда база и так недоступна.
Внутренний механизм
Почему migrate не мешает повторному up
Сервис с restart: "no" завершается и остаётся в состоянии exited (0). При следующем docker compose up Compose видит, что условие service_completed_successfully уже выполнено... но только если container не пересоздавался.
При изменении образа или конфигурации migrate запускается заново. Отсюда требование: миграции обязаны быть идемпотентными. В примере это CREATE TABLE IF NOT EXISTS и ON CONFLICT DO NOTHING.
Как api попадает в две сети
api объявляет networks: [edge, backend] и получает по интерфейсу в каждой. Маршрут по умолчанию берётся из сети с наибольшим приоритетом; здесь приоритеты не заданы, и это не важно — backend всё равно internal и маршрута наружу не даёт (урок 8.2).
Сервисы db и cache состоят только в backend, поэтому из edge их имена не разрешаются вовсе.
Команды и примеры
Запуск
cd resources/examples/compose-stack
make up
Ожидаемый вывод:
создан secrets/db_password.txt
готово: .env и secrets/db_password.txt на месте
[+] Building 24.1s (18/18) FINISHED
[+] Running 8/8
✔ Network compose-stack_backend Created
✔ Network compose-stack_edge Created
✔ Volume compose-stack_db-data Created
✔ Volume compose-stack_cache-data Created
✔ Container compose-stack-cache-1 Healthy
✔ Container compose-stack-db-1 Healthy
✔ Container compose-stack-migrate-1 Exited
✔ Container compose-stack-api-1 Started
✔ Container compose-stack-worker-1 Started
api: http://127.0.0.1:8000/
Строка compose-stack-migrate-1 Exited — не ошибка, а нормальное состояние одноразовой задачи. Compose дождался её завершения с кодом 0 и только затем запустил api и worker.
Порядок в выводе отражает цепочку зависимостей: сначала Healthy у базы и очереди, потом Exited у миграций, потом приложения.
Что получилось
cd resources/examples/compose-stack
docker compose ps --format 'table {{.Service}}\t{{.Status}}\t{{.Ports}}'
Ожидаемый вывод:
SERVICE STATUS PORTS
api Up 40 seconds (healthy) 127.0.0.1:8000->8000/tcp
cache Up 55 seconds (healthy) 127.0.0.1:6379->6379/tcp
db Up 55 seconds (healthy) 127.0.0.1:5432->5432/tcp
migrate Exited (0) 45 seconds ago
worker Up 40 seconds (healthy)
Публикация везде на 127.0.0.1 — из локальной сети сервисы недоступны. У worker портов нет вовсе: он ничего не слушает.
docker network inspect compose-stack_backend --format ' internal: {{.Internal}}'
docker volume ls --filter label=com.docker.compose.project=compose-stack --format ' volume: {{.Name}}'
Ожидаемый вывод:
internal: true
volume: compose-stack_cache-data
volume: compose-stack_db-data
Миграции выполнились первыми
cd resources/examples/compose-stack
docker compose logs migrate --no-log-prefix | python3 -c "
import json, sys
for line in sys.stdin:
line = line.strip()
if line.startswith('{'):
r = json.loads(line)
print(f\" [{r['level']}] {r['message']}\")
"
docker inspect "$(docker compose ps -aq migrate)" --format ' код выхода: {{.State.ExitCode}}'
Ожидаемый вывод:
[INFO] миграции: начало
[INFO] миграции: применены, версия схемы 1
код выхода: 0
Проверим, что схема действительно создана:
docker compose exec -T db psql -U postgres -d appdb -c '\dt' 2>/dev/null | sed 's/^/ /'
Ожидаемый вывод:
List of relations
Schema | Name | Type | Owner
--------+----------------+-------+----------
public | results | table | postgres
public | schema_version | table | postgres
(2 rows)
Очередь работает от постановки до записи
cd resources/examples/compose-stack
echo "═══ ставим три задачи ═══"
for i in 1 2 3; do
curl -s -X POST "http://127.0.0.1:8000/tasks?payload=задача-$i&duration=0.3" \
| python3 -m json.tool --compact | sed 's/^/ /'
done
echo "═══ ждём обработки ═══"
sleep 6
curl -s "http://127.0.0.1:8000/results?limit=3" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\" результатов: {d['count']}\")
for item in d['items']:
print(f\" {item['id']} {item['payload']}\")
"
echo "═══ что делал worker ═══"
docker compose logs worker --no-log-prefix --tail 6 | python3 -c "
import json, sys
for line in sys.stdin:
line = line.strip()
if line.startswith('{'):
print(' ' + json.loads(line)['message'])
"
Ожидаемый вывод:
═══ ставим три задачи ═══
{"queued":1785412843104,"queue_length":1}
{"queued":1785412843312,"queue_length":1}
{"queued":1785412843521,"queue_length":2}
═══ ждём обработки ═══
результатов: 3
1785412843521 задача-3
1785412843312 задача-2
1785412843104 задача-1
═══ что делал worker ═══
задача 1785412843104: начата
задача 1785412843104: завершена
задача 1785412843312: начата
задача 1785412843312: завершена
задача 1785412843521: начата
задача 1785412843521: завершена
Полный путь пройден: HTTP-запрос → Redis → worker → PostgreSQL → HTTP-ответ. Три сервиса, две сети, ни одного опубликованного наружу порта кроме api.
Обратите внимание на queue_length в первых двух ответах: единица означает, что worker забрал предыдущую задачу быстрее, чем пришла следующая.
Изоляция сетей
cd resources/examples/compose-stack
probe() {
docker compose exec -T "$1" python - "$2" "$3" <<'PY' 2>/dev/null
import socket, sys
host, port = sys.argv[1], int(sys.argv[2])
s = socket.socket(); s.settimeout(3)
try:
addr = socket.gethostbyname(host)
except socket.gaierror:
print(" имя не разрешается"); sys.exit(0)
try:
s.connect((addr, port)); print(f" ok ({addr})")
except OSError as e:
print(f" {type(e).__name__}")
finally:
s.close()
PY
}
echo "═══ сети сервисов ═══"
for s in api worker db cache; do
printf ' %-7s %s\n' "$s" \
"$(docker inspect "$(docker compose ps -q $s)" \
--format '{{range $n, $v := .NetworkSettings.Networks}}{{$n}} {{end}}')"
done
echo "═══ доступность ═══"
printf ' api → db: '; probe api db 5432 | tr -d '\n'; echo
printf ' api → cache: '; probe api cache 6379 | tr -d '\n'; echo
printf ' worker → db: '; probe worker db 5432 | tr -d '\n'; echo
echo "═══ выход наружу из backend ═══"
docker compose exec -T db sh -c 'ip route 2>/dev/null | grep -c "^default"' \
| xargs printf ' маршрутов по умолчанию у db: %s\n'
docker compose exec -T api sh -c 'ip route 2>/dev/null | grep -c "^default"' \
| xargs printf ' маршрутов по умолчанию у api: %s\n'
Ожидаемый вывод:
═══ сети сервисов ═══
api compose-stack_backend compose-stack_edge
worker compose-stack_backend
db compose-stack_backend
cache compose-stack_backend
═══ доступность ═══
api → db: ok (172.32.0.3)
api → cache: ok (172.32.0.2)
worker → db: ok (172.32.0.3)
═══ выход наружу из backend ═══
маршрутов по умолчанию у db: 0
маршрутов по умолчанию у api: 1
У db маршрута наружу нет — сеть internal. У api он есть, потому что тот состоит ещё и в edge.
Это ровно тот компромисс, который делает схему рабочей: приложение может обращаться к внешним сервисам, база — нет.
Пароль не попадает в метаданные
cd resources/examples/compose-stack
pw="$(cat secrets/db_password.txt)"
echo "═══ поиск пароля в переменных окружения ═══"
for s in api worker db; do
n="$(docker inspect "$(docker compose ps -q $s)" --format '{{json .Config.Env}}' | grep -c "$pw" || true)"
printf ' %-7s вхождений: %s\n' "$s" "$n"
done
echo "═══ что вместо него ═══"
docker inspect "$(docker compose ps -q api)" --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -i password | sed 's/^/ /'
echo "═══ где секрет на самом деле ═══"
docker compose exec -T api ls -l /run/secrets/ | tail -1 | sed 's/^/ /'
docker compose exec -T api sh -c 'wc -c < /run/secrets/db_password' \
| xargs printf ' размер файла: %s байт\n'
Ожидаемый вывод:
═══ поиск пароля в переменных окружения ═══
api вхождений: 0
worker вхождений: 0
db вхождений: 0
═══ что вместо него ═══
DB_PASSWORD_FILE=/run/secrets/db_password
═══ где секрет на самом деле ═══
-rw------- 1 root root 32 Jul 31 11:02 db_password
размер файла: 32 байт
В переменной лежит путь, а не значение. Приложение читает файл по соглашению _FILE, официальный образ PostgreSQL — тоже (урок 9.5).
Liveness не зависит от базы
cd resources/examples/compose-stack
req() { curl -s -m 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:8000$1"; }
echo "═══ база работает ═══"
printf ' /healthz=%s /readyz=%s health=%s\n' \
"$(req /healthz)" "$(req /readyz)" "$(docker compose ps --format '{{.Health}}' api)"
curl -s http://127.0.0.1:8000/readyz | python3 -m json.tool --compact | sed 's/^/ /'
echo "═══ останавливаем базу ═══"
docker compose stop db > /dev/null
sleep 20
printf ' /healthz=%s /readyz=%s health=%s\n' \
"$(req /healthz)" "$(req /readyz)" "$(docker compose ps --format '{{.Health}}' api)"
curl -s http://127.0.0.1:8000/readyz | python3 -m json.tool --compact | sed 's/^/ /'
echo "═══ возвращаем базу ═══"
docker compose start db > /dev/null
for _ in $(seq 60); do [ "$(req /readyz)" = "200" ] && break; sleep 1; done
printf ' /readyz=%s (api не перезапускался)\n' "$(req /readyz)"
Ожидаемый вывод:
═══ база работает ═══
/healthz=200 /readyz=200 health=healthy
{"status":"ready","checks":{"app":"ok","db":"ok","cache":"ok"}}
═══ останавливаем базу ═══
/healthz=200 /readyz=503 health=healthy
{"status":"degraded","checks":{"app":"ok","db":"OperationalError","cache":"ok"}}
═══ возвращаем базу ═══
/readyz=200 (api не перезапускался)
Средний блок — суть решения. База недоступна, /readyz честно возвращает 503 с указанием, что именно сломалось. При этом /healthz отвечает 200, container остаётся healthy, и никакой перезапуск не запускается.
Если бы HEALTHCHECK проверял /readyz, все реплики api пошли бы на перезапуск одновременно — в момент, когда база восстанавливается и нагружать её нечем (урок 6.10).
Восстановление произошло само: api не перезапускался, соединение к базе открывается на каждый запрос.
Graceful shutdown worker'а
cd resources/examples/compose-stack
echo "═══ ставим долгую задачу ═══"
curl -s -X POST "http://127.0.0.1:8000/tasks?payload=долгая&duration=6" \
| python3 -m json.tool --compact | sed 's/^/ /'
sleep 2
wid="$(docker compose ps -q worker)"
start="$(date +%s.%N)"
docker compose stop worker > /dev/null
end="$(date +%s.%N)"
printf ' время остановки: %.1f c\n' "$(awk -v a="$start" -v b="$end" 'BEGIN{print b-a}')"
printf ' код выхода: %s\n' "$(docker inspect "$wid" --format '{{.State.ExitCode}}')"
docker compose logs worker --no-log-prefix --tail 5 | python3 -c "
import json, sys
for line in sys.stdin:
line = line.strip()
if line.startswith('{'):
print(' ' + json.loads(line)['message'])
"
docker compose start worker > /dev/null
Ожидаемый вывод:
═══ ставим долгую задачу ═══
{"queued":1785412901447,"queue_length":1}
время остановки: 4.2 c
код выхода: 0
задача 1785412901447: начата
получен SIGTERM — завершаю после текущей задачи
задача 1785412901447: завершена
остановлен штатно, обработано задач: 1
Последовательность правильная: сигнал получен во время задачи, задача доведена до конца, выход с кодом 0.
Остановка заняла 4.2 секунды из отведённых 45 — grace period не был исчерпан. Если бы стояло значение по умолчанию в 10 секунд, при задаче в 30 секунд worker был бы убит SIGKILL с кодом 137.
Три окружения из одной базы
cd resources/examples/compose-stack
summary() {
printf '\n %s\n' "$1"; shift
"$@" config 2>/dev/null | python3 -c "
import sys, yaml
class L(yaml.SafeLoader): pass
L.add_multi_constructor('!', lambda l, s, n:
l.construct_scalar(n) if isinstance(n, yaml.ScalarNode)
else (l.construct_sequence(n) if isinstance(n, yaml.SequenceNode)
else l.construct_mapping(n)))
cfg = yaml.load(sys.stdin, Loader=L)
print(' сервисы:', ' '.join(sorted(cfg['services'])))
for name in ('api', 'worker'):
s = cfg['services'][name]
ports = [str(p.get('published')) for p in s.get('ports') or []]
vols = len(s.get('volumes') or [])
dep = s.get('deploy') or {}
lim = (dep.get('resources') or {}).get('limits') or {}
print(f\" {name:<7} LOG={s['environment'].get('LOG_LEVEL'):<8} \"
f\"ports={ports or '[]'} vols={vols} \"
f\"repl={dep.get('replicas', '—')} ro={s.get('read_only', False)} \"
f\"mem={lim.get('memory', '—')}\")
"
}
echo "═══ сравнение окружений ═══"
summary "разработка (compose.yaml + override):" docker compose
summary "production (-f compose.yaml -f compose.prod.yaml):" \
docker compose -f compose.yaml -f compose.prod.yaml
summary "разработка + профиль tools:" docker compose --profile tools
Ожидаемый вывод:
═══ сравнение окружений ═══
разработка (compose.yaml + override):
сервисы: api cache db migrate worker
api LOG=DEBUG ports=['8000'] vols=3 repl=— ro=False mem=512M
worker LOG=DEBUG ports=[] vols=2 repl=— ro=False mem=256M
production (-f compose.yaml -f compose.prod.yaml):
сервисы: api cache db migrate worker
api LOG=WARNING ports=[] vols=0 repl=2 ro=True mem=512M
worker LOG=WARNING ports=[] vols=0 repl=2 ro=True mem=256M
разработка + профиль tools:
сервисы: api cache db migrate tools worker
api LOG=DEBUG ports=['8000'] vols=3 repl=— ro=False mem=512M
worker LOG=DEBUG ports=[] vols=2 repl=— ro=False mem=256M
Три конфигурации, один базовый файл. В production сняты публикация и bind mount, появились реплики и read-only корень; профиль добавил шестой сервис.
Обратите внимание на vols=0 у production: тег !override заменил список целиком. Без него bind mount кода из compose.override.yaml... не попал бы вовсе — потому что -f этот файл не подхватывает. Тег нужен на случай, если производная конфигурация всё же включает override.
Полная проверка
cd resources/examples/compose-stack
./check.sh
echo "КОД: $?"
Ожидаемый вывод:
═══ Запуск ═══
SERVICE STATUS
api Up 12 seconds (healthy)
cache Up 28 seconds (healthy)
db Up 28 seconds (healthy)
migrate Exited (0) 18 seconds ago
worker Up 12 seconds (healthy)
═══ 1. Миграции выполнились до старта приложения ═══
код migrate: 0
"message": "миграции: начало"
"message": "миграции: применены, версия схемы 1"
✓ migrate завершился успешно
═══ 2. Изоляция сетей ═══
сети сервисов:
api compose-stack_backend compose-stack_edge
worker compose-stack_backend
db compose-stack_backend
cache compose-stack_backend
✓ api → db
✓ worker → db
✓ api → cache
✓ backend изолирована (internal)
✓ у db нет маршрута наружу
═══ 3. Пароль не утекает в метаданные ═══
вхождений пароля в docker inspect: 0
✓ пароля нет в переменных окружения
-rw------- 1 root root 32 Jul 31 11:14 db_password
═══ 4. Приложение работает ═══
/healthz → 200
/readyz → 200
✓ обе пробы отвечают
═══ 5. Очередь: api ставит задачу, worker обрабатывает ═══
результатов в базе: 0 → 3
✓ worker обработал задачи и записал в базу
═══ 6. Liveness не зависит от базы ═══
база остановлена: /healthz=200 /readyz=503 health=healthy
✓ api остался healthy — каскадного отказа нет
✓ readiness честно сообщает о деградации
база возвращена: /readyz=200
✓ работа восстановилась без перезапуска api
═══ 7. Graceful shutdown worker'а ═══
остановка заняла 3.4 c, код выхода 0
"message": "остановлен штатно, обработано задач: 1"
✓ worker завершился штатно, а не по SIGKILL
═══ 8. Production-конфигурация отличается правильно ═══
api в production: ports=0 volumes=0 replicas=2 read_only=True
✓ публикация снята тегом !reset
✓ bind mount убран тегом !override
✓ реплики и read-only заданы
═══ 9. Профиль tools не активен по умолчанию ═══
по умолчанию: api cache db migrate worker
✓ tools не запускается по умолчанию
✓ профиль включает tools
═══ ИТОГ ═══
все проверки пройдены
КОД: 0
Девять групп утверждений подтверждены измерением. Ни одно из них не проверяется словами «должно работать».
Уборка
cd resources/examples/compose-stack
make clean
docker volume ls --filter label=com.docker.compose.project=compose-stack --format '{{.Name}}' | wc -l \
| xargs printf ' volumes осталось: %s\n'
Ожидаемый вывод:
volumes осталось: 0
Практическое упражнение
Задание. Расширьте stack тремя компонентами, не нарушив ни одного свойства из проверки.
- Добавьте сервис
scheduler, ставящий задачу в очередь раз в 10 секунд. - Обеспечьте, чтобы при двух репликах
schedulerзадача ставилась один раз за интервал. - Добавьте профиль
adminс сервисом, дающим доступ к базе, и убедитесь, что без профиля он не запускается. - Добавьте в production-конфигурацию ограничение числа PID и
pids_limitдляworker. - Убедитесь, что
check.shпо-прежнему проходит целиком. - Докажите, что при остановке
cacheприложение возвращает503на/readyz, но остаётсяhealthy.
Подсказки
Подсказка 1
Требование 2 решается распределённой блокировкой в Redis: SET key NX EX с временем жизни меньше интервала.
Подсказка 2
scheduler можно собрать из того же образа — добавьте ещё одну роль и команду.
Подсказка 3
Для требования 4 понадобится и deploy.resources.limits.pids, и проверка /sys/fs/cgroup/pids.max.
Решение
Показать решение
cd resources/examples/compose-stack
cp -r . /tmp/stack-ext && cd /tmp/stack-ext
# ── Требования 1–2: планировщик с блокировкой ──
mkdir -p scheduler
cat > scheduler/__init__.py <<'PY'
"""Планировщик практического stack."""
PY
cat > scheduler/main.py <<'PY'
"""Ставит задачу раз в интервал. При нескольких репликах — ровно один раз.
Защита от дублирования — распределённая блокировка в Redis:
SET key NX EX. Срок жизни ключа меньше интервала, иначе следующий
запуск оказался бы заблокирован остатком предыдущего.
"""
from __future__ import annotations
import json
import logging
import os
import signal
import sys
import time
import redis
sys.path.insert(0, "/app")
from app.logging_config import configure # noqa: E402
from app.settings import Settings # noqa: E402
settings = Settings.from_env()
configure(settings.log_level)
logger = logging.getLogger("scheduler")
INTERVAL = float(os.environ.get("SCHEDULE_INTERVAL", "10"))
LOCK_KEY = os.environ.get("SCHEDULE_LOCK", "scheduler:tick")
REPLICA = os.environ.get("HOSTNAME", "?")[:12]
_stop = False
def _handle(signum: int, _frame: object) -> None:
global _stop
logger.info("получен %s — останавливаюсь", signal.Signals(signum).name)
_stop = True
def main() -> int:
signal.signal(signal.SIGTERM, _handle)
signal.signal(signal.SIGINT, _handle)
client = redis.Redis.from_url(settings.redis_url, decode_responses=True)
delay = 0.25
for _ in range(60):
try:
client.ping()
break
except redis.ConnectionError:
time.sleep(delay)
delay = min(delay * 2, 3.0)
else:
logger.error("Redis недоступен")
return 1
logger.info("планировщик запущен", extra={"extra_fields": {
"replica": REPLICA, "interval": INTERVAL}})
while not _stop:
# Срок жизни меньше интервала: иначе следующий тик будет заблокирован
got = client.set(LOCK_KEY, REPLICA, nx=True, ex=max(1, int(INTERVAL) - 1))
if got:
task = {"id": int(time.time() * 1000), "payload": "по расписанию", "duration": 0.2}
client.rpush(settings.queue_name, json.dumps(task, ensure_ascii=False))
logger.info("задача поставлена", extra={"extra_fields": {
"replica": REPLICA, "task_id": task["id"]}})
else:
logger.debug("пропуск: блокировка занята", extra={"extra_fields": {"replica": REPLICA}})
# Дробим ожидание, чтобы SIGTERM не ждал полного интервала
slept = 0.0
while slept < INTERVAL and not _stop:
time.sleep(0.2)
slept += 0.2
logger.info("планировщик остановлен штатно")
return 0
if __name__ == "__main__":
sys.exit(main())
PY
# Образ должен содержать новую роль
python3 - <<'PY'
import pathlib
p = pathlib.Path("Dockerfile")
t = p.read_text()
t = t.replace("COPY migrations/ ./migrations/\nCOPY tests/ ./tests/",
"COPY migrations/ ./migrations/\nCOPY scheduler/ ./scheduler/\nCOPY tests/ ./tests/")
t = t.replace("COPY --chown=10001:10001 migrations/ ./migrations/",
"COPY --chown=10001:10001 migrations/ ./migrations/\n"
"COPY --chown=10001:10001 scheduler/ ./scheduler/")
p.write_text(t)
PY
# ── Требования 1–3: новые сервисы ──
python3 - <<'PY'
import pathlib
p = pathlib.Path("compose.yaml")
t = p.read_text()
addition = '''
# Требования 1–2: планировщик с распределённой блокировкой
scheduler:
<<: *app-service
command: ["python", "-m", "scheduler.main"]
networks: [backend]
environment:
<<: *app-env
SCHEDULE_INTERVAL: "${SCHEDULE_INTERVAL:-10}"
depends_on:
migrate:
condition: service_completed_successfully
cache:
condition: service_healthy
stop_grace_period: 15s
deploy:
resources:
limits:
memory: 128M
cpus: "0.25"
# Требование 3: доступ к базе только по профилю
admin:
image: postgres:17-alpine
profiles: [admin]
restart: "no"
networks: [backend]
secrets:
- db_password
environment:
PGHOST: db
PGUSER: "${DB_USER:-postgres}"
PGDATABASE: "${DB_NAME:-appdb}"
entrypoint: ["sh", "-c"]
command:
- 'PGPASSWORD="$$(cat /run/secrets/db_password)" psql -c "SELECT count(*) AS results FROM results"'
depends_on:
db:
condition: service_healthy
'''
marker = "\n # ── Инструменты отладки: только по профилю ──"
t = t.replace(marker, addition + marker)
p.write_text(t)
PY
# ── Требование 4: лимит PID в production ──
python3 - <<'PY'
import pathlib
p = pathlib.Path("compose.prod.yaml")
t = p.read_text()
t = t.replace(""" worker:
environment:
LOG_LEVEL: WARNING
volumes: !override []
deploy:
replicas: 2
resources:
limits:
memory: 256M
cpus: "0.5\"""",
""" worker:
environment:
LOG_LEVEL: WARNING
volumes: !override []
deploy:
replicas: 2
resources:
limits:
memory: 256M
cpus: "0.5"
pids: 128
scheduler:
environment:
LOG_LEVEL: WARNING
volumes: !override []
deploy:
resources:
limits:
memory: 128M
cpus: "0.25"
pids: 64""")
p.write_text(t)
PY
# scheduler в разработке тоже монтирует код
python3 - <<'PY'
import pathlib
p = pathlib.Path("compose.override.yaml")
t = p.read_text().rstrip() + """
scheduler:
environment:
LOG_LEVEL: DEBUG
volumes:
- ./app:/app/app:ro
- ./scheduler:/app/scheduler:ro
"""
p.write_text(t)
PY
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Требование 1: планировщик ставит задачи ═══\n'
docker compose up -d --build > /tmp/ext-up.log 2>&1 || { tail -5 /tmp/ext-up.log; exit 1; }
for _ in $(seq 90); do
[ "$(docker compose ps --format '{{.Health}}' api)" = "healthy" ] && break
sleep 1
done
sleep 25
n="$(docker compose logs scheduler --no-log-prefix 2>/dev/null | grep -c 'задача поставлена' || true)"
printf ' задач поставлено за ~25 c при интервале 10 c: %s\n' "$n"
[ "$n" -ge 2 ] && ok "планировщик работает" || bad "поставлено: $n"
printf '\n═══ Требование 2: две реплики, одна задача за интервал ═══\n'
docker compose up -d --scale scheduler=2 > /dev/null 2>&1
sleep 3
docker compose logs scheduler --no-log-prefix > /dev/null 2>&1
base="$(docker compose logs scheduler --no-log-prefix 2>/dev/null | grep -c 'задача поставлена' || true)"
sleep 32
now="$(docker compose logs scheduler --no-log-prefix 2>/dev/null | grep -c 'задача поставлена' || true)"
placed=$((now - base))
printf ' реплик: %s\n' "$(docker compose ps -q scheduler | wc -l)"
printf ' задач за ~32 c (ожидается 3, не 6): %s\n' "$placed"
[ "$placed" -ge 2 ] && [ "$placed" -le 4 ] \
&& ok "блокировка предотвратила дублирование" || bad "поставлено $placed"
printf ' какие реплики ставили:\n'
docker compose logs scheduler --no-log-prefix 2>/dev/null | python3 -c "
import json, sys, collections
c = collections.Counter()
for line in sys.stdin:
line = line.strip()
if line.startswith('{'):
r = json.loads(line)
if r.get('message') == 'задача поставлена':
c[r.get('replica', '?')] += 1
for k, v in c.items():
print(f' {k}: {v}')
"
printf '\n═══ Требование 3: профиль admin ═══\n'
def_s="$(docker compose config --services | tr '\n' ' ')"
printf ' по умолчанию: %s\n' "$def_s"
echo "$def_s" | grep -q admin && bad "admin активен без профиля" \
|| ok "admin не запускается по умолчанию"
printf ' с профилем: '
docker compose --profile admin run --rm -T admin 2>/dev/null | tr -d '\r' | tr '\n' ' '
echo
docker compose --profile admin config --services | grep -q admin \
&& ok "профиль включает admin" || bad "профиль не сработал"
printf '\n═══ Требование 4: лимит PID в production ═══\n'
docker compose down > /dev/null 2>&1
docker compose -f compose.yaml -f compose.prod.yaml up -d > /dev/null 2>&1
for _ in $(seq 90); do
[ -n "$(docker compose -f compose.yaml -f compose.prod.yaml ps -q worker)" ] && break
sleep 1
done
sleep 8
wid="$(docker compose -f compose.yaml -f compose.prod.yaml ps -q worker | head -1)"
sid="$(docker compose -f compose.yaml -f compose.prod.yaml ps -q scheduler | head -1)"
w_pids="$(docker exec "$wid" cat /sys/fs/cgroup/pids.max 2>/dev/null | tr -d '\r')"
s_pids="$(docker exec "$sid" cat /sys/fs/cgroup/pids.max 2>/dev/null | tr -d '\r')"
printf ' worker pids.max=%s, scheduler pids.max=%s\n' "$w_pids" "$s_pids"
[ "$w_pids" = "128" ] && [ "$s_pids" = "64" ] && ok "лимиты PID применены" \
|| bad "worker=$w_pids scheduler=$s_pids"
docker compose -f compose.yaml -f compose.prod.yaml down > /dev/null 2>&1
printf '\n═══ Требование 6: остановка cache ═══\n'
docker compose up -d > /dev/null 2>&1
for _ in $(seq 90); do
[ "$(docker compose ps --format '{{.Health}}' api)" = "healthy" ] && break
sleep 1
done
req() { curl -s -m 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:8000$1"; }
printf ' cache работает: /healthz=%s /readyz=%s\n' "$(req /healthz)" "$(req /readyz)"
docker compose stop cache > /dev/null 2>&1
sleep 20
hz="$(req /healthz)"; rz="$(req /readyz)"; hs="$(docker compose ps --format '{{.Health}}' api)"
printf ' cache остановлен: /healthz=%s /readyz=%s health=%s\n' "$hz" "$rz" "$hs"
curl -s http://127.0.0.1:8000/readyz | python3 -m json.tool --compact | sed 's/^/ /'
[ "$hz" = "200" ] && [ "$hs" = "healthy" ] && [ "$rz" = "503" ] \
&& ok "liveness не пострадал, readiness сообщил о деградации" \
|| bad "healthz=$hz readyz=$rz health=$hs"
docker compose start cache > /dev/null 2>&1
for _ in $(seq 40); do [ "$(req /readyz)" = "200" ] && break; sleep 1; done
printf '\n═══ Требование 5: полная проверка stack ═══\n'
./check.sh > /tmp/ext-check.log 2>&1
check_rc=$?
grep -E '^ [✓✗]' /tmp/ext-check.log | sed 's/^/ /' | tail -20
[ "$check_rc" -eq 0 ] && ok "check.sh прошёл целиком" || bad "check.sh вернул $check_rc"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
docker compose down -v --remove-orphans > /dev/null 2>&1
cd /tmp && rm -rf /tmp/stack-ext
exit "$fail"
Ожидаемый вывод (сокращённо):
═══ Требование 1: планировщик ставит задачи ═══
задач поставлено за ~25 c при интервале 10 c: 3
✓ планировщик работает
═══ Требование 2: две реплики, одна задача за интервал ═══
реплик: 2
задач за ~32 c (ожидается 3, не 6): 3
✓ блокировка предотвратила дублирование
какие реплики ставили:
a3f8c91e2d47: 4
b7e2f14a9c03: 2
═══ Требование 3: профиль admin ═══
по умолчанию: api cache db migrate scheduler worker
✓ admin не запускается по умолчанию
с профилем: results ------- 7 (1 row)
✓ профиль включает admin
═══ Требование 4: лимит PID в production ═══
worker pids.max=128, scheduler pids.max=64
✓ лимиты PID применены
═══ Требование 6: остановка cache ═══
cache работает: /healthz=200 /readyz=200
cache остановлен: /healthz=200 /readyz=503 health=healthy
{"status":"degraded","checks":{"app":"ok","db":"ok","cache":"ConnectionError"}}
✓ liveness не пострадал, readiness сообщил о деградации
═══ Требование 5: полная проверка stack ═══
✓ migrate завершился успешно
✓ api → db
✓ backend изолирована (internal)
✓ пароля нет в переменных окружения
✓ обе пробы отвечают
✓ worker обработал задачи и записал в базу
✓ api остался healthy — каскадного отказа нет
✓ worker завершился штатно, а не по SIGKILL
✓ публикация снята тегом !reset
✓ tools не запускается по умолчанию
✓ check.sh прошёл целиком
═══ ИТОГ ═══
все требования выполнены
Все требования выполнены.
Обратите внимание на распределение по репликам в требовании 2: 4 и 2 — блокировку выигрывала то одна, то другая. Суммарно ровно столько тиков, сколько интервалов, а не вдвое больше.
Три решения, определяющие качество.
Срок жизни блокировки — INTERVAL - 1, а не INTERVAL. Равный интервалу срок означал бы, что ключ ещё жив в момент следующего тика, и одна итерация из нескольких терялась бы. Меньший срок гарантирует, что к моменту очередного тика блокировка свободна, а дублирование внутри одного интервала по-прежнему исключено.
Требование 2 проверяется приростом счётчика, а не абсолютным значением. Логи содержат записи, накопленные с первого запуска, включая период с одной репликой. Разность now - base измеряет ровно тот отрезок, когда реплик было две, — иначе результат зависел бы от того, сколько времени стенд работал до проверки.
Требование 5 запускает исходный check.sh без правок. Соблазн подправить проверку под изменившийся stack велик, но тогда она перестанет отвечать на свой вопрос. Девять групп утверждений остались прежними, и их прохождение доказывает, что расширение ничего не сломало.
Чего решение не делает. Блокировка в Redis не защищает от «раздвоения мозга»: при недоступности Redis обе реплики просто перестанут ставить задачи, а при разделении сети между узлами обе могли бы поставить. Для одного host это неважно; в кластере нужен планировщик оркестратора. Не покрыт и случай, когда реплика умирает после захвата блокировки, но до постановки задачи — тик будет пропущен, и его никто не восстановит.
Проверка результата
cd resources/examples/compose-stack
make up
sleep 5
curl -s localhost:8000/readyz | python3 -m json.tool --compact
curl -s -X POST "localhost:8000/tasks?payload=проверка&duration=0.2" > /dev/null
sleep 4
curl -s localhost:8000/results?limit=1 | python3 -m json.tool --compact
make clean
Ожидается "status":"ready" и появление результата в базе.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
| Отдельный Dockerfile для worker'а | Кажется чище | Расхождение версий кода; одна роль — одна команда |
restart: always у сервиса миграций | Скопировали у приложения | Задача перезапускается бесконечно |
| Неидемпотентные миграции | Не учли повторный up | Второй запуск ломает схему |
stop_grace_period по умолчанию у worker'а | Не задумывались | Задача обрывается, код 137 |
| Публикация порта базы без адреса | Для удобства отладки | Доступна всей локальной сети; 127.0.0.1: |
HEALTHCHECK на /readyz | Кажется более полной проверкой | Каскадный перезапуск при отказе базы |
Пароль в environment | Быстрее | Виден в inspect; использовать secret |
| Bind mount кода в production | Файлы одни и те же | volumes: !override [] |
Отсутствие !reset у ports в prod | Ожидают замену | Списки складываются |
| Лимиты «на глаз» | Не измеряли | Измерить пик, умножить с запасом |
Контрольные вопросы
На понимание:
- Почему
api,workerиmigrateсобираются из одного образа? - Что защищает каждое звено цепочки
db → migrate → api? - Почему у
workerstop_grace_periodбольше, чем уapi? - Почему
backendобъявленаinternal, аedge— нет? - Что произойдёт со stack при провале миграции?
На применение:
- Как добавить сервис, не нарушив изоляцию базы?
- Как снять публикацию портов в production-конфигурации?
- Как убедиться, что пароль не попал в метаданные container'ов?
На диагностику:
apiперезапускается циклически после отказа базы. Где ошибка в конфигурации?- При повторном
docker compose upмиграции падают. Первая версия?
Краткое резюме
- Один образ обслуживает три роли; роль задаётся командой, а не отдельным Dockerfile.
- Цепочка
db → migrate → apiстроится условиямиservice_healthyиservice_completed_successfully. - Провал миграции останавливает запуск всего stack — это желаемое поведение.
- Миграции обязаны быть идемпотентными: повторный
upвыполнит их снова. stop_grace_periodподбирается по длительности работы, а не берётся по умолчанию.backendобъявленаinternal: у базы и очереди нет маршрута наружу.apiсостоит в двух сетях и служит единственным мостом между уровнями.- Пароль приходит secret'ом; в переменной лежит путь, а не значение.
HEALTHCHECKпроверяет только сам процесс; readiness отдельным endpoint'ом.- Три окружения собираются из одной базы;
-fотменяет автоматический override. - В production обязательны
!resetдляportsи!overrideдляvolumes. - Работоспособность подтверждается измерением каждого свойства, а не запуском.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Compose Specification | https://docs.docker.com/reference/compose-file/ | Все использованные ключи |
| Compose: services | https://docs.docker.com/reference/compose-file/services/ | depends_on, healthcheck, deploy |
| Compose: secrets | https://docs.docker.com/reference/compose-file/secrets/ | Передача пароля файлом |
| Compose: merge | https://docs.docker.com/reference/compose-file/merge/ | !reset, !override |
| Compose: profiles | https://docs.docker.com/compose/how-tos/profiles/ | Опциональные сервисы |
| Docker Hub: postgres | https://hub.docker.com/_/postgres | POSTGRES_PASSWORD_FILE, PGDATA |
| Docker Hub: redis | https://hub.docker.com/_/redis | Параметры --save, --maxmemory |
| FastAPI: deployment | https://fastapi.tiangolo.com/deployment/docker/ | fastapi run в container |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление