Главная/Docker Compose/Практика

Раздел 9. Практические задания

Задания выполняются в реальной системе. Разбор открывайте только после самостоятельной попытки.

Обозначения: [обяз.] — обязательное, [доп.] — дополнительное, [★] — повышенной сложности, [диаг.] — диагностическое.

Подготовка:

bash
mkdir -p ~/docker-course/09-compose && cd ~/docker-course/09-compose
# docker pull принимает РОВНО один образ:
# `docker pull a b` отвечает «docker pull requires 1 argument»
for img in python:3.13-slim postgres:17-alpine redis:8-alpine; do docker pull -q "$img"; done
docker compose version
docker network ls && docker volume ls | head -3    # зафиксируйте состояние

Задание 1. Первый Compose-файл [обяз.]

Постановка. Напишите compose.yaml из двух сервисов: HTTP-приложение и Redis. Требуется:

  1. Имя проекта задано в файле, а не наследуется от каталога.
  2. Приложение доступно с host по опубликованному порту.
  3. Приложение обращается к Redis по имени сервиса.
  4. Ключ version отсутствует.

Ожидаемый результат. Работающий стек и вывод docker compose ps с именами по шаблону проекта.

Проверка:

bash
docker compose ps --format 'table {{.Name}}\t{{.Service}}\t{{.Status}}'
docker compose config --format json | python3 -c 'import json,sys; print(json.load(sys.stdin)["name"])'

Разбор — в уроке 9.1.


Задание 2. Имя проекта и изоляция [обяз.]

Постановка. Покажите четыре вещи:

  1. Имя проекта по умолчанию равно имени каталога.
  2. Ключ name: его переопределяет.
  3. Флаг -p переопределяет и ключ.
  4. Два проекта с одинаковыми именами сервисов работают одновременно и не конфликтуют.

Дополнительно объясните, почему COMPOSE_PROJECT_NAME слабее ключа name:.

Ожидаемый результат. Четыре наблюдения и объяснение приоритета.

Проверка:

bash
docker ps --format '{{.Names}}' | sort
docker network ls --format '{{.Name}}' | grep -c default

Разбор — в уроке 9.1.


Задание 3. exec против run [обяз.]

Постановка. На работающем сервисе покажите:

  1. exec выполняется в существующем container'е — совпадает hostname.
  2. run создаёт новый container со своим PID 1.
  3. run не публикует порты без --service-ports.
  4. run без --rm оставляет остановленный container.

Ожидаемый результат. Четыре различия, подтверждённые выводом команд.

Проверка:

bash
docker ps -a --filter label=com.docker.compose.project=<проект> --format '{{.Names}}\t{{.Status}}'

Разбор — в уроке 9.1.


Задание 4. Изоляция сетей [обяз.]

Постановка. Постройте три сервиса — proxy, api, db — и две сети так, чтобы:

  1. proxy и api видели друг друга;
  2. api и db видели друг друга;
  3. proxy не видел db — ни по имени, ни по адресу;
  4. db не имела выхода в интернет.

Различайте gaierror и таймаут в проверках.

Ожидаемый результат. Карта сетей и четыре подтверждённых утверждения.

Проверка:

bash
docker inspect <container> --format '{{range $n, $v := .NetworkSettings.Networks}}{{$n}} {{end}}'
docker network inspect <проект>_<сеть> --format '{{.Internal}}'

Разбор — в уроке 9.3.


Задание 5. Healthcheck и порядок запуска [обяз.]

Постановка. Воспроизведите и устраните гонку при старте:

  1. Сервис с depends_on: [db] без условия падает с ECONNREFUSED.
  2. Измерьте, сколько секунд база реально поднимается.
  3. Добавьте healthcheck со start_period и условие service_healthy.
  4. Покажите, что теперь соединение устанавливается с первой попытки.
  5. Покажите, что без start_period та же база успевает получить unhealthy.

Ожидаемый результат. Пять наблюдений; пункт 5 — на втором экземпляре той же базы.

Проверка:

bash
docker inspect <container> --format '{{.State.Health.Status}} {{.State.Health.FailingStreak}}'

Разбор — в уроке 9.4.


Задание 6. Миграции как отдельный сервис [доп.]

Постановка. Реализуйте паттерн: dbmigrateapi. Требуется показать:

  1. migrate стартует только после service_healthy у базы;
  2. api стартует только после service_completed_successfully у migrate;
  3. при провале миграции container api не создаётся вовсе;
  4. повторный up выполняет миграции снова, но схема не ломается.

Ожидаемый результат. Четыре подтверждения; пункт 3 — подсчётом container'ов, а не только кодом возврата.

Проверка:

bash
docker ps -a --filter label=com.docker.compose.service=api -q | wc -l

Разбор — в уроке 9.4.


Задание 7. Секреты вместо переменных [доп.]

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

  1. через environment — найдите пять мест, где он виден;
  2. через Compose secret — покажите, что ни в одном из них его нет;
  3. используйте соглашение _FILE у официального образа postgres;
  4. реализуйте поддержку _FILE в своём приложении.

Ожидаемый результат. Таблица утечек и рабочая конфигурация без них.

Проверка:

bash
docker inspect <container> --format '{{json .Config.Env}}' | grep -c "<пароль>"
docker compose exec <сервис> ls -l /run/secrets/

Разбор — в уроке 9.5.


Задание 8. Три окружения из одной базы [доп.]

Постановка. Соберите compose.yaml, compose.override.yaml и compose.prod.yaml так, чтобы:

  1. разработка подхватывалась автоматически: bind mount, отладочный порт;
  2. production снимал публикацию и bind mount, добавлял лимиты и реплики;
  3. compose.yaml не содержал настроек, специфичных для окружения;
  4. общие фрагменты задавались один раз.

Покажите, почему для пункта 2 нужны теги !reset и !override.

Ожидаемый результат. Три конфигурации и демонстрация того, что без тегов порты складываются.

Проверка:

bash
docker compose -f compose.yaml -f compose.prod.yaml config | grep -c published

Разбор — в уроке 9.6.


Задание 9. Диагностика: стек не поднимается [диаг.]

Постановка. Дан файл:

yaml
version: "3.8"

services:
  web:
    build: .
    container_name: web
    ports:
      - 8000:8000
    environment:
      - DATABASE_URL=postgresql://postgres:secret@localhost:5432/appdb
      - REDIS_URL=redis://cache:6379/0
    depends_on:
      - db
    networks:
      - frontend

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - ./pgdata:/var/lib/postgresql/data

  cache:
    image: redis:8-alpine
    networks:
      - frontend

networks:
  frontend:

Приложение не запускается, а после docker compose down данные базы исчезают. Найдите все дефекты, объясните каждый и предложите исправленный файл.

Ожидаемый результат. Список дефектов с доказательством каждого и рабочая конфигурация.

Разбор

В этом файле семь независимых дефектов. Разберём по порядку срабатывания.

Дефект 1. Ключ version устарел.

bash
cd ~/docker-course/09-compose && mkdir -p diag9 && cd diag9
# (файл из условия сохранён как compose.yaml)
docker compose config 2>&1 | grep -i 'version' | head -2 | sed 's/^/  /'

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

text
  WARN[0000] /путь/к/проекту/09-compose/diag9/compose.yaml: the attribute
  `version` is obsolete, it will be ignored, please remove it to avoid potential confusion

Не ошибка, но предупреждение при каждом запуске. Удалить (урок 9.1).

Дефект 2. localhost в DATABASE_URL.

Внутри container'а localhost — это сам container, а не db. Симптом: ECONNREFUSED мгновенно.

bash
docker compose run --rm -T web python -c "
import socket
s = socket.socket(); s.settimeout(2)
try:
    s.connect(('localhost', 5432)); print('  ok')
except OSError as e:
    print(f'  {type(e).__name__}: localhost — это сам container')
" 2>/dev/null

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

text
  ConnectionRefusedError: localhost — это сам container

Исправление: @db:5432 (урок 8.1).

Дефект 3. web и db в разных сетях.

web объявил networks: [frontend], db не объявил ничего и остался в default. Связи между ними нет.

bash
docker compose up -d > /dev/null 2>&1
docker network ls --filter name=diag9 --format '  сеть: {{.Name}}'
for s in web db cache; do
    printf '  %-6s %s\n' "$s" \
        "$(docker inspect "$(docker compose ps -q $s 2>/dev/null)" \
           --format '{{range $n, $v := .NetworkSettings.Networks}}{{$n}} {{end}}' 2>/dev/null)"
done

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

text
  сеть: diag9_default
  сеть: diag9_frontend
  web    diag9_frontend 
  db     diag9_default 
  cache  diag9_frontend 

Две сети вместо одной, db в стороне. Правило: либо сети объявляют все сервисы, либо ни один (урок 9.3).

Дефект 4. depends_on без условия.

Даже после исправления сетей приложение стартует раньше, чем PostgreSQL примет соединения. Нужны healthcheck и condition: service_healthy (урок 9.4).

Дефект 5. Bind mount для каталога данных базы.

bash
ls -ldn ./pgdata 2>/dev/null | awk '{print "  владелец каталога: " $3 ":" $4}'
docker compose run --rm -T db id 2>/dev/null | sed 's/^/  процесс в container: /'

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

text
  владелец каталога: 1000:1000
  процесс в container: uid=70(postgres) gid=70(postgres) groups=70(postgres)

UID не совпадают — PostgreSQL не сможет писать в каталог (урок 7.5). Named volume наследует владельца из образа и этой проблемы не создаёт.

Он же — причина второй жалобы: данные «исчезают». На самом деле они остаются в ./pgdata, но при следующем запуске база не может их прочитать либо инициализируется заново.

Дефект 6. container_name: web.

bash
docker compose up -d --scale web=2 2>&1 | tail -2 | sed 's/^/  /'

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

text
  Error response from daemon: Conflict. The container name "/web" is already
  in use by container "..."

Масштабирование невозможно, и два проекта на одной машине конфликтуют (урок 9.2).

Дефект 7. Пароль в открытом виде и порт без кавычек.

POSTGRES_PASSWORD: secret попадёт в docker inspect и в репозиторий. Запись - 8000:8000 без кавычек противоречит рекомендации документации.

Исправленный файл:

yaml
name: diag9

services:
  web:
    build: .
    ports:
      - "127.0.0.1:8000:8000"
    environment:
      DATABASE_URL: "postgresql://postgres@db:5432/appdb"
      DATABASE_PASSWORD_FILE: /run/secrets/db_password
      REDIS_URL: "redis://cache:6379/0"
    secrets:
      - db_password
    networks: [backend]
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: appdb
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    networks: [backend]
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
      interval: 3s
      timeout: 3s
      retries: 10
      start_period: 30s
      start_interval: 1s

  cache:
    image: redis:8-alpine
    networks: [backend]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 3s
      retries: 10
      start_period: 10s

networks:
  backend:

volumes:
  db-data:

secrets:
  db_password:
    file: ./secrets/db_password.txt

Проверка исправления:

bash
mkdir -p secrets && head -c 24 /dev/urandom | base64 | tr -d '\n' > secrets/db_password.txt
docker compose up -d > /dev/null 2>&1
sleep 25
docker compose ps --format 'table {{.Service}}\t{{.Status}}' | sed 's/^/  /'
docker compose exec -T web python -c "
import socket
print('  web → db:', socket.gethostbyname('db'))
print('  web → cache:', socket.gethostbyname('cache'))
" 2>/dev/null

docker compose exec -T db psql -U postgres -d appdb -q -c 'CREATE TABLE t (id int); INSERT INTO t VALUES (1);'
docker compose down > /dev/null 2>&1
docker compose up -d > /dev/null 2>&1
sleep 25
printf '  записей после down/up: %s\n' \
    "$(docker compose exec -T db psql -U postgres -d appdb -tAc 'SELECT count(*) FROM t' | tr -d ' \r')"
docker compose down -v > /dev/null 2>&1

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

text
  SERVICE   STATUS
  cache     Up 25 seconds (healthy)
  db        Up 25 seconds (healthy)
  web       Up 12 seconds
  web → db: 172.33.0.3
  web → cache: 172.33.0.2
  записей после down/up: 1

Данные пережили down — это и было второй жалобой.

Сводка дефектов:

ДефектСимптомИсправление
1Ключ versionПредупреждениеУдалить
2localhost вместо dbECONNREFUSED мгновенноИмя сервиса
3Сети объявлены части сервисовgaierrorОбъявить всем или никому
4depends_on без условияПадение через разservice_healthy плюс healthcheck
5Bind mount для данных базыПрава, потеря данныхNamed volume
6container_nameНет масштабированияУбрать
7Пароль в открытом видеУтечка в inspectCompose secret

Обратите внимание: дефекты 2 и 3 дают разные ошибки — ECONNREFUSED и gaierror. Различение типа отказа сразу указывает, какой из них перед вами (урок 8.6).


Задание 10. Полный production-stack [★]

Постановка. Соберите систему из пяти сервисов и подтвердите девять свойств.

Состав: api (HTTP), worker (очередь), migrate (одноразовая задача), db, cache.

Требования:

  1. Один образ обслуживает api, worker и migrate; различаются командой.
  2. Цепочка запуска: db healthy → migrate completed → api и worker.
  3. Две сети; db и cache недоступны из внешней сети и не имеют выхода в интернет.
  4. Пароль передаётся secret'ом; в docker inspect его нет.
  5. HEALTHCHECK проверяет liveness; остановка базы не делает api нездоровым.
  6. Полный путь работает: HTTP → очередь → worker → база → HTTP.
  7. worker завершается штатно с кодом 0 при остановке во время задачи.
  8. Три окружения из одной базы; в production нет публикации и bind mount.
  9. Профиль с опциональным сервисом, не запускающимся по умолчанию.

Скрипт проверки возвращает ненулевой код при любом расхождении.

Подсказки

Подсказка 1

Требование 1 удобнее выполнить якорем &app-service с переопределением command.

Подсказка 2

Для требования 5 нужны два разных endpoint'а: /healthz и /readyz.

Подсказка 3

Требование 7 требует stop_grace_period больше длительности задачи.

Подсказка 4

В production для снятия публикации нужен ports: !reset [], обычное переопределение списки складывает.

Решение

Показать решение

Готовая реализация — resources/examples/compose-stack/. Разберите её файлы, затем воспроизведите проверку:

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

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

text
═══ 1. Миграции выполнились до старта приложения ═══
    код migrate: 0
  ✓ migrate завершился успешно

═══ 2. Изоляция сетей ═══
  ✓ api → db
  ✓ worker → db
  ✓ api → cache
  ✓ backend изолирована (internal)
  ✓ у db нет маршрута наружу

═══ 3. Пароль не утекает в метаданные ═══
    вхождений пароля в docker inspect: 0
  ✓ пароля нет в переменных окружения

═══ 4. Приложение работает ═══
  ✓ обе пробы отвечают

═══ 5. Очередь: api ставит задачу, worker обрабатывает ═══
    результатов в базе: 0 → 3
  ✓ worker обработал задачи и записал в базу

═══ 6. Liveness не зависит от базы ═══
    база остановлена: /healthz=200 /readyz=503 health=healthy
  ✓ api остался healthy — каскадного отказа нет
  ✓ readiness честно сообщает о деградации
  ✓ работа восстановилась без перезапуска api

═══ 7. Graceful shutdown worker'а ═══
    остановка заняла 3.4 c, код выхода 0
  ✓ worker завершился штатно, а не по SIGKILL

═══ 8. Production-конфигурация отличается правильно ═══
    api в production: ports=0 volumes=0 replicas=2 read_only=True
  ✓ публикация снята тегом !reset
  ✓ bind mount убран тегом !override
  ✓ реплики и read-only заданы

═══ 9. Профиль tools не активен по умолчанию ═══
  ✓ tools не запускается по умолчанию
  ✓ профиль включает tools

═══ ИТОГ ═══
  все проверки пройдены
КОД: 0

Разбор каждого решения — в уроке 9.7.

Три места, где чаще всего ошибаются при самостоятельной реализации.

stop_grace_period у worker. Значение по умолчанию — 10 секунд. Задача, идущая 30 секунд, будет оборвана SIGKILL, и требование 7 не выполнится. Симптом: код выхода 137 вместо 0. Число берётся из максимальной длительности задачи плюс запас, а не из головы.

HEALTHCHECK на /readyz вместо /healthz. Конфигурация будет работать и выглядеть правильнее — до первой остановки базы. Требование 5 проверяет именно этот случай, и оно провалится: api станет unhealthy, хотя процесс жив и отвечает.

Отсутствие !reset у ports в production. Обычное переопределение списки складывает, а не заменяет. Требование 8 покажет ports=1 вместо ports=0, и production окажется с публикацией, которую вы считали снятой.

Что стоит сделать сверх задания. Добавьте в check.sh десятую группу: измерьте пиковое потребление памяти под нагрузкой и сравните с заданными лимитами. Это превратит лимиты из скопированных чисел в обоснованные (урок 6.13).


Очистка после раздела

bash
docker compose down -v --remove-orphans 2>/dev/null || true
# ВНИМАНИЕ: НЕ `docker ps -aq | xargs -r docker rm -f`.
# Такая строка удаляет ВСЕ container'ы на машине, включая чужие:
# базу коллеги, кластер kind, работающий стенд. Удаляем только
# созданные из образов этого раздела.
for img in python:3.13-slim postgres:17-alpine redis:8-alpine; do
    docker ps -aq --filter "ancestor=$img" | xargs -r docker rm -f
done
docker network prune -f
docker volume prune -f
docker image prune -f
docker network ls && docker volume ls | head -3

Сравните с состоянием, зафиксированным в начале раздела.

Вторая строка удаляет все container'ы на машине, третья и четвёртая — все неиспользуемые сети и volumes. На рабочей машине выполняйте выборочно (урок 7.6).


Критерии завершения

Раздел закрыт, когда выполнены обязательные задания 1–5 и вы можете без подсказок ответить на вопросы из MAIN.md раздела.

Дальше: Quiz 09, затем Проект 3. Multiservice stack.


Навигация

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

Markdown на GitHub ↗