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

9.7. Практический stack

Цели

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

  • прочитать полноценный compose.yaml из пяти сервисов и объяснить каждое решение в нём;
  • обосновать выбор stop_grace_period, лимитов и параметров healthcheck конкретными числами;
  • собрать три окружения из одной базы без дублирования;
  • проверить работоспособность stack не запуском, а измерением каждого свойства;
  • назвать, что осталось за рамками Compose и требует других инструментов.

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

Весь раздел 09, а также:

Рабочий пример — resources/examples/compose-stack/. Урок разбирает именно его.

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

ТерминОбъяснение
stackНабор связанных сервисов, описанный одним проектом Compose
рольНазначение container'а: api, worker, migrate
мостСервис, состоящий в двух сетях и связывающий уровни
воротаСервис, чьё успешное завершение открывает запуск другим

Теория

Из чего состоит система

text
                     ┌───────── edge ─────────┐
   клиент  ──────►   │         api            │
                     └───────────┬────────────┘
                                 │
   ┌───────────────── backend (internal) ─────────────────┐
   │                             │                        │
   │   migrate ──ворота──►  api, worker                   │
   │      │                      │       │                │
   │      ▼                      ▼       ▼                │
   │     db  ◄────────────────  db     cache              │
   │                                                      │
   │   нет маршрута наружу: ни обновлений, ни внешних API  │
   └──────────────────────────────────────────────────────┘
СервисРольСетиПубликуется
apiHTTP-интерфейс, ставит задачи в очередьedge, backendДа, и только в dev
workerРазбирает очередь, пишет результатыbackendНет
migrateОдноразовая задача: схема базыbackendНет
dbPostgreSQLbackendНет
cacheRedis: очередь задачbackendНет
toolsОтладка, профиль toolsbackendНет

Один образ на три роли

api, worker и migrate собираются из одного образа и различаются только командой:

yaml
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

Плата — образ содержит код всех трёх ролей. Для этого размера приложения выигрыш в согласованности перевешивает.

Порядок запуска

text
db (healthy) ──► migrate (завершился с 0) ──► api, worker
cache (healthy) ─────────────────────────────►
yaml
  migrate:
    depends_on:
      db:
        condition: service_healthy

  api:
    depends_on:
      migrate:
        condition: service_completed_successfully
      cache:
        condition: service_healthy

Цепочка строгая, и каждое звено осмысленно:

ЗвеноЧто защищает
dbmigrateМиграции не начнутся до готовности базы
migrateapiПриложение не увидит старую схему
migrateworkerТо же для обработчика очереди
cacheapi, workerОчередь доступна с первого запроса

Провал migrate останавливает запуск всего stack — это желаемое поведение (урок 9.4).

Числа, а не умолчания

Каждый параметр в примере обоснован, а не скопирован.

ПараметрЗначениеОбоснование
db.healthcheck.start_period30sПервый запуск инициализирует каталог данных
db.healthcheck.start_interval1sГотовность обнаруживается быстро, не дожидаясь interval
db.stop_grace_period30sБаза должна закрыть файлы и записать буферы
worker.stop_grace_period45sЗадача идёт до 30 секунд плюс запас
api.stop_grace_period20sДозавершение активных HTTP-запросов
api memory512MИзмеренный пик около 150 MiB, запас втрое
worker memory256MОдин процесс, без веб-сервера
cache memory128MПлюс 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, снятие публикации
bash
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 обязательны теги:

yaml
  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 их имена не разрешаются вовсе.


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

Запуск

bash
cd resources/examples/compose-stack
make up

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

text
создан 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 у миграций, потом приложения.

Что получилось

bash
cd resources/examples/compose-stack
docker compose ps --format 'table {{.Service}}\t{{.Status}}\t{{.Ports}}'

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

text
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 портов нет вовсе: он ничего не слушает.

bash
docker network inspect compose-stack_backend --format '  internal: {{.Internal}}'
docker volume ls --filter label=com.docker.compose.project=compose-stack --format '  volume: {{.Name}}'

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

text
  internal: true
  volume: compose-stack_cache-data
  volume: compose-stack_db-data

Миграции выполнились первыми

bash
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}}'

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

text
  [INFO] миграции: начало
  [INFO] миграции: применены, версия схемы 1
  код выхода: 0

Проверим, что схема действительно создана:

bash
docker compose exec -T db psql -U postgres -d appdb -c '\dt' 2>/dev/null | sed 's/^/  /'

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

text
              List of relations
   Schema |      Name      | Type  |  Owner   
  --------+----------------+-------+----------
   public | results        | table | postgres
   public | schema_version | table | postgres
  (2 rows)

Очередь работает от постановки до записи

bash
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'])
"

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

text
═══ ставим три задачи ═══
  {"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 забрал предыдущую задачу быстрее, чем пришла следующая.

Изоляция сетей

bash
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'

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

text
═══ сети сервисов ═══
  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.

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

Пароль не попадает в метаданные

bash
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'

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

text
═══ поиск пароля в переменных окружения ═══
  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 не зависит от базы

bash
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)"

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

text
═══ база работает ═══
  /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'а

bash
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

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

text
═══ ставим долгую задачу ═══
  {"queued":1785412901447,"queue_length":1}
  время остановки: 4.2 c
  код выхода: 0
  задача 1785412901447: начата
  получен SIGTERM — завершаю после текущей задачи
  задача 1785412901447: завершена
  остановлен штатно, обработано задач: 1

Последовательность правильная: сигнал получен во время задачи, задача доведена до конца, выход с кодом 0.

Остановка заняла 4.2 секунды из отведённых 45 — grace period не был исчерпан. Если бы стояло значение по умолчанию в 10 секунд, при задаче в 30 секунд worker был бы убит SIGKILL с кодом 137.

Три окружения из одной базы

bash
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

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

text
═══ сравнение окружений ═══

  разработка (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.

Полная проверка

bash
cd resources/examples/compose-stack
./check.sh
echo "КОД: $?"

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

text
═══ Запуск ═══
  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

Девять групп утверждений подтверждены измерением. Ни одно из них не проверяется словами «должно работать».

Уборка

bash
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'

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

text
  volumes осталось: 0

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

Задание. Расширьте stack тремя компонентами, не нарушив ни одного свойства из проверки.

  1. Добавьте сервис scheduler, ставящий задачу в очередь раз в 10 секунд.
  2. Обеспечьте, чтобы при двух репликах scheduler задача ставилась один раз за интервал.
  3. Добавьте профиль admin с сервисом, дающим доступ к базе, и убедитесь, что без профиля он не запускается.
  4. Добавьте в production-конфигурацию ограничение числа PID и pids_limit для worker.
  5. Убедитесь, что check.sh по-прежнему проходит целиком.
  6. Докажите, что при остановке cache приложение возвращает 503 на /readyz, но остаётся healthy.

Подсказки

Подсказка 1

Требование 2 решается распределённой блокировкой в Redis: SET key NX EX с временем жизни меньше интервала.

Подсказка 2

scheduler можно собрать из того же образа — добавьте ещё одну роль и команду.

Подсказка 3

Для требования 4 понадобится и deploy.resources.limits.pids, и проверка /sys/fs/cgroup/pids.max.

Решение

Показать решение
bash
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"

Ожидаемый вывод (сокращённо):

text
═══ Требование 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 это неважно; в кластере нужен планировщик оркестратора. Не покрыт и случай, когда реплика умирает после захвата блокировки, но до постановки задачи — тик будет пропущен, и его никто не восстановит.

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

bash
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Ожидают заменуСписки складываются
Лимиты «на глаз»Не измерялиИзмерить пик, умножить с запасом

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

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

  1. Почему api, worker и migrate собираются из одного образа?
  2. Что защищает каждое звено цепочки db → migrate → api?
  3. Почему у worker stop_grace_period больше, чем у api?
  4. Почему backend объявлена internal, а edge — нет?
  5. Что произойдёт со stack при провале миграции?

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

  1. Как добавить сервис, не нарушив изоляцию базы?
  2. Как снять публикацию портов в production-конфигурации?
  3. Как убедиться, что пароль не попал в метаданные container'ов?

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

  1. api перезапускается циклически после отказа базы. Где ошибка в конфигурации?
  2. При повторном docker compose up миграции падают. Первая версия?

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

  1. Один образ обслуживает три роли; роль задаётся командой, а не отдельным Dockerfile.
  2. Цепочка db → migrate → api строится условиями service_healthy и service_completed_successfully.
  3. Провал миграции останавливает запуск всего stack — это желаемое поведение.
  4. Миграции обязаны быть идемпотентными: повторный up выполнит их снова.
  5. stop_grace_period подбирается по длительности работы, а не берётся по умолчанию.
  6. backend объявлена internal: у базы и очереди нет маршрута наружу.
  7. api состоит в двух сетях и служит единственным мостом между уровнями.
  8. Пароль приходит secret'ом; в переменной лежит путь, а не значение.
  9. HEALTHCHECK проверяет только сам процесс; readiness отдельным endpoint'ом.
  10. Три окружения собираются из одной базы; -f отменяет автоматический override.
  11. В production обязательны !reset для ports и !override для volumes.
  12. Работоспособность подтверждается измерением каждого свойства, а не запуском.

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

ИсточникСсылкаЧто подтверждает
Compose Specificationhttps://docs.docker.com/reference/compose-file/Все использованные ключи
Compose: serviceshttps://docs.docker.com/reference/compose-file/services/depends_on, healthcheck, deploy
Compose: secretshttps://docs.docker.com/reference/compose-file/secrets/Передача пароля файлом
Compose: mergehttps://docs.docker.com/reference/compose-file/merge/!reset, !override
Compose: profileshttps://docs.docker.com/compose/how-tos/profiles/Опциональные сервисы
Docker Hub: postgreshttps://hub.docker.com/_/postgresPOSTGRES_PASSWORD_FILE, PGDATA
Docker Hub: redishttps://hub.docker.com/_/redisПараметры --save, --maxmemory
FastAPI: deploymenthttps://fastapi.tiangolo.com/deployment/docker/fastapi run в container

Навигация

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

Markdown на GitHub ↗