✻ Урок 5.3 · Тема 5: Docker и учебный стенд
Docker Compose: поднимаем «Магазин» с базой и кэшем
Содержание урока
Зачем это нужно
Я давно занимаюсь нагрузкой и хорошо помню один случай. Коллега прислал замеры: «У меня магазин отвечает за 80 миллисекунд». У меня тот же тест показывал 300. Два дня мы искали причину в коде. А дело было в стенде (так называют набор программ, на котором гоняют тесты): у него два рабочих процесса, у меня один. Замеры нельзя было сравнивать. С тех пор я первым делом учусь поднимать свою копию магазина, на которой можно ломать, и поднимать её одинаково.
«Магазин» это четыре программы: сам магазин, заглушка оплаты (изображает платёжную систему), база PostgreSQL и кэш Redis. Запускать их четырьмя docker run (урок 5.1) значит помнить порядок, сеть и пароли. Ошибся в одном флаге, и стенд не такой, как у коллеги.
Docker Compose описывает весь стенд одним файлом compose.yaml и поднимает одной командой, знакомой по уроку 2.1: docker compose up -d --build --wait (--build пересобирает образы, --wait ждёт, пока всё запустится). Для тестировщика это три плюса. Стенд одинаков у всех. Настройки узких мест называют «ручками» (число воркеров, размер пула соединений). Они лежат в файле .env, и ты меняешь их без правки кода: на этом стоит тема 11. А стенд сносится и поднимается за минуту, и каждый прогон начинается с чистого листа.
Шаг проекта: ты разберёшь project/shop/compose.yaml, поймёшь, почему магазин ждёт базу, пересоздашь стенд с другим числом воркеров и научишься читать поломки. Если стенд поведёт себя странно (на это ссылается и урок 8.1), ответ ищи в «Типичных ошибках» ниже.
Что нужно знать
- Образ, контейнер,
docker run, порты, тома: урок 5.1. Dockerfile и сборка образа «Магазина»: урок 5.2. - Как «Магазин» устроен как веб-сервис (запрос, база, кэш): урок 2.3. Что такое таблица и запрос SQL: урок 2.4.
- Файлы YAML и
curl: урок 1.2 и урок 1.4. YAML мы разберём ниже, с нуля. - Глубже про Compose: урок DevOps «Docker Compose». Для нашего курса хватит этого урока.
Картина целиком
Compose работает как режиссёр спектакля. В пьесе (compose.yaml) записано, какие актёры (сервисы) выходят на сцену, что у каждого в руках (настройки) и кто за кем выходит. Актёры между собой не договариваются: режиссёр сам говорит каждому, когда выходить, и ждёт, пока предыдущий будет готов.
Для «Магазина» пьеса такая: сначала выходят база, Redis и оплата. Магазин ждёт, пока все трое скажут «готов», и выходит последним. Заминка из-за базы: при первом запуске её заполняют сидом (seed), то есть тестовыми данными: пользователи, товары, заказы. Это около 40 секунд, и остальные ждут.
flowchart TD
PG["postgres<br/>база, ждёт сид"] --> SH["shop<br/>порт 8000"]
RD["redis<br/>кэш и сессии"] --> SH
PAY["payment<br/>заглушка оплаты"] --> SH
SH --> YOU["curl localhost:8000"]
Стрелка значит «магазин зависит от»: без базы, кэша и оплаты он не ответит ни на один запрос. Все четыре контейнера живут в одной сети Compose и находят друг друга по имени сервиса: адрес базы в настройках магазина postgres:5432, а не localhost.
Теория
YAML за три минуты
Открываешь compose.yaml, а там отступы, дефисы и двоеточия: как заклинание. На деле правил три. Файл написан на YAML, формате для настроек, который удобно читать человеку.
Первое правило: вложенность задают отступы пробелами (табы нельзя). Строка с большим отступом принадлежит строке выше. Второе: пара «ключ: значение» через двоеточие, например cpus: "1.0" читается «настройка cpus равна 1.0». Третье: список это строки с дефисом (- что-то) или значения в квадратных скобках [a, b]. Всё, что после #, комментарий.
services:
shop:
image: shop-shop
ports: ["127.0.0.1:8000:8000"]
Прикинь сам: что значит
"127.0.0.1:8000:8000"в этом списке?
Это «адрес на твоей машине : порт хоста : порт контейнера», как -p 127.0.0.1:8080:80 в уроке 5.1. Адрес 127.0.0.1 значит «слушай только свою машину»: с чужого компьютера в этот порт не зайти.
Осторожно: лишний пробел меняет смысл файла или ломает его (mapping values are not allowed here). Копируй блоки целиком.
Главное: в YAML структуру задают отступы пробелами, а дальше только «ключ: значение» и списки.
Читать файл умеем. Из чего он состоит?
Сервис, проект и сеть
Магазин запущен, база тоже, docker ps показывает обоих. Но магазин пишет «Connection refused» и базу не находит. Почему?
Сервис (service) это описание одного вида контейнеров, например «магазин» или «база». Проект это набор сервисов из одного файла. В compose.yaml первая строка name: shop задаёт имя проекта, и от него зависят все имена, которые создаст Compose. Контейнеры называются «проект-сервис-номер»: shop-shop-1, shop-postgres-1, shop-redis-1, shop-payment-1. Сеть shop_default, том shop_pgdata.
Внутри сети работает DNS, знакомая по уроку 1.4 телефонная книга: магазин спрашивает «где postgres?», и ему отвечают адрес контейнера базы.
shop-shop-1 (172.18.0.5) --> спрашивает DNS: "где postgres?" --> 172.18.0.2
shop-shop-1 --> TCP на 172.18.0.2:5432 --> shop-postgres-1
Адреса 172.18.x.x меняются при каждом пересоздании, поэтому полагаться можно только на имя: postgres:5432, redis:6379.
А localhost это слово «у меня»: у тебя дома твоя квартира, у соседа его. Так и внутри контейнера: localhost это сам контейнер, а не твоя машина. Если в DATABASE_URL написать localhost, магазин будет стучаться сам в себя, и тогда Connection refused. Это самая частая ошибка новичка.
Между контейнерами порты публиковать не нужно: ports: нужен только для того, чтобы зайти с твоей машины (curl, браузер). Поэтому у PostgreSQL и Redis в стенде ports: нет: к ним ходит магазин по сети Compose, а ты сам заходишь через docker compose exec (psql, redis-cli).
Главное: сервисы в сети Compose находят друг друга по имени сервиса, а
localhostвнутри контейнера означает сам контейнер.
Теперь откроем сам файл.
Разбор compose.yaml по сервисам
Файл «Магазина» длиннее, но повторяет пяток ключей. Начнём с простого.
redis:
image: redis:8.10.2
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 12
Образ готовый, из реестра, порт на хост не опубликован (Redis без пароля, наружу ему выходить незачем). Новый ключ healthcheck: (проверка здоровья): Docker сам, раз в 5 секунд (interval), запускает внутри контейнера redis-cli ping, и ответ должен прийти за 3 секунды (timeout). Ответил PONG (код 0): контейнер здоров, статус healthy. После 12 неудач подряд (retries) статус unhealthy.
С базой интереснее. Через environment: образ postgres:18.6 получает имя базы, пользователя и пароль, все три shop (учебные реквизиты). Ключ command: добавляет серверу флаги: shared_preload_libraries=pg_stat_statements (считает тяжёлые запросы, пригодится в теме 11), log_min_duration_statement=200 (в лог попадают запросы дольше 200 мс), max_connections=100. Лимиты cpus и mem_limit разберём в уроке 5.4.
Важнее всего volumes:. Именованный том pgdata:/var/lib/postgresql хранит данные вне контейнера. А папка ./db/init:/docker-entrypoint-initdb.d:ro содержит 01-schema.sql и 02-seed.sql: при первом старте на пустом томе PostgreSQL выполняет их по порядку. Они создают таблицы и заливают 10 000 товаров и 200 000 заказов. Это те самые 40 секунд сида. Если том уже содержит базу, файлы не запускаются.
Проверка здоровья у базы с хитростью: pg_isready -h 127.0.0.1. Во время сида PostgreSQL поднимает временный сервер без TCP, поэтому проверка по TCP-адресу падает, пока сид не закончился. Параметр start_period: 30s (период разгона) значит: первые 30 секунд неудачи не считаются в retries. А retries: 60 при интервале 5 с это пять минут терпения.
Оплата собирается из кода: build: ./payment строит образ по Dockerfile из этой папки, как в уроке 5.2. Запись ${PAYMENT_DELAY_MS:-50} значит «возьми переменную, а если её нет, подставь 50» (откуда она берётся, скажу через раздел). Проверку здоровья делает python -c "import urllib.request; ...": в образе slim нет curl, зато есть Python.
И наконец, магазин. Здесь знакомо всё, кроме одного блока:
shop:
build: ./shop
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql://shop:shop@postgres:5432/shop}
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-1}
...
depends_on:
postgres: {condition: service_healthy}
redis: {condition: service_healthy}
payment: {condition: service_healthy}
Что делает depends_on, узнаем в следующем разделе. А команда docker compose config печатает конфигурацию после подстановок (${...:-50} становится "50") и проверяет файл на опечатки и кривые отступы. Я после любой правки выполняю docker compose config -q (проверка без вывода).
Прикинь сам: в сервисе
paymentнетdepends_onи нетcpus. Что это значит для старта и для ресурсов?
Без depends_on оплата стартует сразу сама по себе: ей никто не нужен. Без cpus и mem_limit её ограничивает только мощность машины.
Осторожно: up собирает образ только если его ещё нет. Изменил код, а стенд ведёт себя по-старому? Нужен флаг --build: многие про него забывают и тестируют старый код.
Главное: сервис описывают через
imageилиbuild, настройки и порты, аhealthcheckзадаёт, как проверить, что контейнер готов.
Как же магазин узнаёт, что база готова?
depends_on и healthcheck: кто ждёт кого
Пока PostgreSQL заливает сид, контейнер уже работает, а подключиться к базе ещё нельзя. Если магазин стартует именно сейчас, он упадёт.
Это как в ресторане: «зал открыт» и «кухня готова» разные вещи. Официант, который выходит по расписанию, а не по сигналу кухни, примет заказы, которые некому готовить.
Сигнал даёт healthcheck: у контейнера появляется статус starting (идёт start_period или ещё не было успеха), healthy (проверка прошла) или unhealthy (кончились retries). А depends_on с condition: service_healthy говорит Compose: «не запускай сервис, пока те, от кого он зависит, не станут healthy». Без условия depends_on ждёт лишь запуска контейнера, а не готовности.
Передвинь ползунок «Сид базы» (сколько секунд база заливает данные) и переключи условие service_healthy.
С условием магазин стартует после зелёной полоски базы. Без него стартует сразу: его пул подключений (урок 2.3) ждёт базу 60 секунд, и если сид дольше, магазин падает с Exited (3). Из-за restart: unless-stopped Docker поднимает его снова, и так по кругу, пока база не станет доступна.
Прикинь сам: убрали
depends_on, сид идёт 30 секунд. Что случится приdocker compose up -dна чистой машине и почему через минуту всё может заработать само?
Пул магазина ждёт базу до 60 секунд, база успела за 30, и магазин спокойно продолжит. Будь сид 90 секунд, он упал бы с Application startup failed и ушёл в цикл перезапусков. Это ненадёжно, и исправляют healthcheck с depends_on.
Вот как это выглядит в терминале на чистой машине (нет тома shop_pgdata):
$ docker compose up -d --build --wait
[+] Running 6/6
✔ Network shop_default Created 0.1s
✔ Volume "shop_pgdata" Created 0.0s
✔ Container shop-redis-1 Healthy 6.2s
✔ Container shop-payment-1 Healthy 6.2s
✔ Container shop-postgres-1 Healthy 41.8s
✔ Container shop-shop-1 Healthy 47.9s
Внизу порядок готовности: Redis и оплата (6 секунд), база (42, это сид) и после неё магазин (48). Флаг --wait не отдаёт терминал, пока все не станут healthy. Скрипт «подними стенд и запусти тест» без него бил бы по неготовому магазину, и первые секунды отчёта были бы сплошными ошибками. Предел ожидания задаёт --wait-timeout 300. Когда том уже есть, база поднимается за 6 секунд.
Осторожно: healthy не значит «работает правильно», проверка проверяет только то, что ей велели. Адрес /healthz (урок 2.1) отвечает «процесс жив» и вернёт ok, даже если база ушла. А /readyz отвечает {"status":"ready"} только когда доступны Postgres и Redis.
Главное: «запущен» не значит «готов»:
healthcheckопределяет готовность, аdepends_onсservice_healthyзаставляет ждать её.
Порядок старта понятен. А откуда магазин берёт настройки?
.env и подстановка ${ПЕРЕМЕННАЯ:-значение}
Ты хочешь прогнать тест с двумя воркерами вместо одного, потом с тремя. Править для этого compose.yaml в git и не забыть вернуть плохая идея, поэтому такие настройки живут в отдельном файле.
Рядом с compose.yaml Compose сам ищет файл .env (скрытый, урок 1.1) и подставляет его значения в ${...}. В 2.1 ты сделал cp .env.example .env: шаблон лежит в git, а .env твоя рабочая копия. Правило ${ИМЯ:-запасное}: возьми ИМЯ из .env, а если её нет или она пустая, запасное значение. Поэтому стенд работает даже без .env.
Часть ручек заложена как узкие места, их ты будешь трогать в теме 11. WEB_CONCURRENCY=1 задаёт число воркеров (при одном работает одно ядро), DB_POOL_MAX=5 соединения с базой на воркер, BCRYPT_ROUNDS=12 тяжесть хэширования пароля при входе. Остальные (CACHE_ENABLED, BUG_N_PLUS_ONE, LEAK_ENABLED (1 включает намеренную утечку памяти по 10 КБ на запрос, по умолчанию выключена), PAYMENT_*) описаны в .env.example, а починки в project/shop/README.md. Учить их сейчас не нужно.
Как значение доезжает до программы, показывает схема.
flowchart TD
A[".env<br/>WEB_CONCURRENCY=2"] --> B["compose.yaml<br/>подстановка с запасным 1"]
B --> C["Переменная в контейнере<br/>WEB_CONCURRENCY=2"]
C --> D["entrypoint.sh<br/>--workers 2"]
Пусть в .env стоит WEB_CONCURRENCY=2. Compose раскроет ${WEB_CONCURRENCY:-1} в "2", а entrypoint.sh подставит --workers "2" в команду сервера. Ошибёшься в имени (WEB_CONCURENCY), и молча сработает запасное значение: сообщения об ошибке не будет.
Прикинь сам: ты поменял
DB_POOL_MAX=5на20в.envи выполнилdocker compose restart shop. Применилась ли настройка?
Нет. Переменные считываются при создании контейнера, а restart запускает тот же контейнер со старыми. Нужна команда docker compose up -d: Compose заметит изменение и пересоздаст контейнер. Проверка: docker compose exec shop env | grep DB_POOL_MAX.
Главное: настройки живут в
.env, подставляются через${ИМЯ:-запасное}, а применяются только после пересоздания контейнера командойup -d.
Настройки ясны. Теперь о лишнем.
profiles: часть стенда, которая включается отдельно
Запустил docker compose up и видишь четыре контейнера, хотя в файле описано тринадцать сервисов. Куда делись девять?
Это мониторинг (Prometheus, Grafana, Loki и другие). Он занимает память и процессор и в нагрузочных тестах влиял бы на результат. Поэтому у каждого стоит profiles: [monitoring], и пока профиль не включён, они не стартуют. Команда docker compose --profile monitoring up -d запускает их вместе с остальными: так ты поднимешь мониторинг в уроке 7.1. Сейчас этого делать не нужно.
Осторожно: отключённый сервис не сломан и не удалён, он просто не создаётся. Если docker compose ps его не показывает, это нормально.
Главное: сервисы с профилем стартуют только когда ты включил профиль, а без него стенд состоит из четырёх сервисов ядра.
Запускать научились, теперь убирать.
Тома и «снести стенд»: down и down -v
Тест закончился, и ты хочешь всё «как новенькое». Что именно сотрётся, а что останется? Команд три, с разным размахом.
docker compose stop только останавливает: контейнеры, сеть и том остаются. docker compose down удаляет контейнеры и сеть, но том shop_pgdata остаётся: следующий up найдёт в нём базу, скрипты из db/init не запустит, и база поднимется за секунды. docker compose down -v удаляет и том: следующий up снова сеет с нуля, около 40 секунд.
Для тестировщика это рычаг: down -v даёт чистую базу, down сохраняет накопленное. Побочный эффект: правки в db/init/*.sql не подействуют, пока том жив. Новички правят SQL, перезапускают стенд, и ничего не меняется. Лечит down -v.
Прикинь сам: ты хочешь прогнать тест на «чистой» базе с исходными 200 000 заказов. Какую команду выберешь:
downилиdown -v?
down -v: только она удаляет том, и следующий запуск зальёт сид заново. Обычный down оставит все данные, включая заказы, которые натворил прошлый тест.
Осторожно: down -v стирает всю базу. На учебном стенде это хорошо, на настоящей катастрофа, поэтому на боевых серверах -v не пишут. И docker compose down не то же, что docker rm: оно работает с проектом целиком и читает compose.yaml из текущей папки (~/learning/load-tester/project/shop).
Главное:
downубирает контейнеры и оставляет данные,down -vстирает и данные, и следующий запуск начнёт с нового сида.
Осталось собрать команды на каждый день.
Команды на каждый день
Команды выполняются из папки с compose.yaml и называют сервисы по имени (shop), а не контейнеры. Состояние: ps. Логи вживую: logs -f shop (выход Ctrl+C). Зайти внутрь: exec shop sh. Перезапуск с прежней конфигурацией: restart shop. Остановить и запустить без удаления: stop, start.
Главное: стенд управляется десятком команд из папки с
compose.yaml, и называют они сервисы по имени.
Вернусь к началу. Теперь копия магазина у тебя и у коллеги поднимается одной командой, с теми же версиями и настройками, и перед распродажей ты пересоздашь её за минуту. Дальше практика.
Практика
Все команды выполняй из папки стенда. Если стенд уже поднят с урока 2.1, это нормально: часть шагов покажет текущее состояние, а ту, где важен чистый старт, ты сделаешь через down -v.
cd ~/learning/load-tester/project/shop
1. Прочитай файл и проверь его
docker compose config -q && echo "файл в порядке"
docker compose config --services
Разбор: -q (quiet) только проверка, без вывода; && выполняет вторую команду, только если первая успешна. --services печатает список сервисов.
файл в порядке
payment
shop
redis
postgres
Как читать вывод: четыре сервиса «ядра». Сервисы мониторинга не показаны: профиль monitoring не включён. Чтобы увидеть их тоже, выполни docker compose --profile monitoring config --services: в списке станет тринадцать строк.
2. Посмотри состояние стенда
docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
shop-payment-1 shop-payment "uvicorn main:app --h…" payment 2 hours ago Up 2 hours (healthy) 127.0.0.1:8001->8001/tcp
shop-postgres-1 postgres:18.6 "docker-entrypoint.s…" postgres 2 hours ago Up 2 hours (healthy) 5432/tcp
shop-redis-1 redis:8.10.2 "docker-entrypoint.s…" redis 2 hours ago Up 2 hours (healthy) 6379/tcp
shop-shop-1 shop-shop "./entrypoint.sh" shop 2 hours ago Up 2 hours (healthy) 127.0.0.1:8000->8000/tcp
Как читать вывод: колонка STATUS главная: Up ... (healthy) значит и работает, и проверка здоровья проходит. Другие варианты: (health: starting) проверка ещё не успела пройти, (unhealthy) проверка не проходит, Exited (N) процесс завершился с кодом N. Колонка SERVICE это имя для команд Compose, NAME имя контейнера, PORTS опубликованные порты.
Безопасность стенда. Строка 127.0.0.1:8000->8000 значит, что порт открыт только для твоей машины: так в compose.yaml записано ${BIND_ADDR:-127.0.0.1}:8000:8000. У PostgreSQL и Redis в колонке PORTS только 5432/tcp и 6379/tcp без стрелки: на хост они не опубликованы. Стенд учебный: у Redis нет пароля, база shop/shop, Grafana пускает анонимно с правами администратора, а /admin/config у оплаты не требует авторизации. Всё это безопасно, пока порты привязаны к 127.0.0.1.
Если Ubuntu у тебя в Multipass, а браузер на Mac, есть тонкость: localhost на Mac и localhost в виртуалке это два разных компьютера, и порт 127.0.0.1:3000 виртуалки с Mac не виден. Безопасный способ дотянуться до него: SSH-туннель. SSH (программа для входа на другой компьютер) умеет не только открывать командную строку, но и пересылать порт: всё, что приходит на порт 3000 твоего Mac, он по зашифрованному соединению передаёт на 127.0.0.1:3000 виртуалки. Стенд при этом остаётся закрытым для всех остальных. Выполни на Mac (не в виртуалке) один раз:
ls ~/.ssh/id_ed25519.pub || ssh-keygen -t ed25519
multipass exec lab -- bash -c "echo '$(cat ~/.ssh/id_ed25519.pub)' >> ~/.ssh/authorized_keys"
Первая строка проверяет, есть ли у тебя на Mac SSH-ключ (пара «закрытый и открытый ключ» из урока 3.2, только теперь на самом Mac), и если нет, создаёт его (на вопросы жми Enter). Вторая дописывает открытую половину ключа в список разрешённых ключей пользователя ubuntu в виртуалке lab: multipass exec lab -- выполняет команду внутри неё, а $(cat ...) подставляет текст ключа. Теперь сам туннель, тоже на Mac:
ssh -N -L 3000:127.0.0.1:3000 -L 8000:127.0.0.1:8000 ubuntu@$(multipass info lab | awk '/IPv4/{print $2}')
-L 3000:127.0.0.1:3000 значит «порт 3000 Mac пересылай на 127.0.0.1:3000 виртуалки», таких -L можно дать несколько. -N говорит «командная строка не нужна, только пересылка». $(multipass info lab | awk ...) достаёт IP-адрес виртуалки из строки IPv4. Пока команда работает, терминал занят, а Grafana открывается на http://localhost:3000 самого Mac; закрыть туннель: Ctrl+C.
Есть и запасной вариант: строка BIND_ADDR=0.0.0.0 в .env и docker compose up -d. Тогда порты стенда откроются всем, кто может достучаться до виртуалки по сети, а кто это, зависит от сетевых настроек Multipass и твоего Mac: в обычной настройке это только сам Mac, но если виртуалку подключили к домашней сети напрямую (режим bridged), её видят все устройства в этой сети. Grafana с правами администратора и /admin/config тогда доступны им всем. Поэтому туннель лучше, а BIND_ADDR=0.0.0.0 включай, только если точно знаешь, кто видит машину, и никогда на машине с публичным IP-адресом или в чужой сети (кафе, офисный Wi-Fi).
Теперь убедись, что магазин реально готов, а не только жив:
curl -s localhost:8000/healthz; echo
curl -s localhost:8000/readyz; echo
{"status":"ok"}
{"status":"ready"}
/healthz отвечает «процесс жив», /readyz «База и Redis доступны, я готов принимать запросы».
3. Загляни в базу
В 2.4 ты писал SQL. Теперь посмотрим, что именно залил сид:
docker compose exec postgres psql -U shop -d shop -c "SELECT count(*) AS orders FROM orders;"
docker compose exec postgres psql -U shop -d shop -c "SELECT count(*) AS products FROM products;"
Разбор: docker compose exec postgres запускает команду внутри сервиса postgres; psql -U shop -d shop консольный клиент (пользователь shop, база shop); -c "..." выполнить один запрос и выйти.
orders
--------
200000
(1 row)
products
----------
10000
(1 row)
Как читать вывод: 200 000 заказов и 10 000 товаров ровно такие, как обещано в описании стенда. Это результат 02-seed.sql: он выполнился при первом запуске.
4. Смотри логи
docker compose logs --tail 5 shop
Разбор: --tail 5 последние пять строк. Для слежения добавь -f (и выход по Ctrl+C).
shop-shop-1 | {"ts": "2026-10-03T11:14:02.318412+00:00", "level": "INFO", "msg": "Запрос завершён", "method": "GET", "route": "/healthz", "path": "/healthz", "status": 200, "duration_ms": 0.8, "request_id": "5f0c...", "trace_id": "9a1e..."}
shop-shop-1 | {"ts": "2026-10-03T11:14:07.320127+00:00", "level": "INFO", "msg": "Запрос завершён", ...
Как читать вывод: слева имя контейнера. Дальше JSON-строки: время, уровень, сообщение, метод, маршрут, статус, длительность в миллисекундах и номер запроса request_id и номер трассы trace_id (по нему связывают лог с трейсом, тема 7). Через каждые 5 секунд идёт запрос /healthz: это та самая проверка здоровья из compose.yaml. Сделай запрос и найди его в потоке:
curl -s localhost:8000/api/categories | head -c 100; echo
docker compose logs --tail 2 shop
Для JSON-строк пригодится jq (урок 1.2): docker compose logs --no-log-prefix shop | tail -3 | jq .status.
5. Поменяй настройку и пересоздай контейнер
Увеличим число воркеров. Сначала проверь, что сейчас:
docker compose exec shop env | grep WEB_CONCURRENCY
docker compose exec shop cat /proc/1/cmdline | tr '\0' ' '
WEB_CONCURRENCY=1
/usr/local/bin/python3.14 /usr/local/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 --no-access-log
Вторая команда читает «паспорт» главного процесса контейнера из /proc/1/cmdline (файл ядра с командой запуска; части в нём разделены нулевыми символами, tr заменяет их пробелами). Обычное ps здесь не работает: в образе slim этой программы нет. Теперь поменяй значение в .env и примени:
sed -i.bak 's/^WEB_CONCURRENCY=.*/WEB_CONCURRENCY=2/' .env && rm .env.bak
grep WEB_CONCURRENCY .env
docker compose up -d --wait
Разбор: sed -i.bak 's/старое/новое/' заменяет текст в файле (^ начало строки, .* всё до конца; .bak это резервная копия, без суффикса sed -i на macOS не работает, а на Linux он необязателен), rm .env.bak убирает копию; grep показывает результат; up -d --wait пересоздаёт изменившиеся сервисы и ждёт их готовности.
WEB_CONCURRENCY=2
[+] Running 4/4
✔ Container shop-redis-1 Healthy 0.4s
✔ Container shop-payment-1 Healthy 0.4s
✔ Container shop-postgres-1 Healthy 0.4s
✔ Container shop-shop-1 Healthy 8.1s
Как читать вывод: Compose заметил, что поменялась конфигурация только у shop, и пересоздал только его: остальные контейнеры не тронуты (у них время 0.4s потому, что они остались как были, и только проверены). Новый магазин поднялся за 8 секунд. Проверь, что настройка применилась:
docker compose exec shop env | grep WEB_CONCURRENCY
docker compose logs --tail 20 shop | grep -i "worker\|Started"
WEB_CONCURRENCY=2
shop-shop-1 | INFO: Started parent process [1]
shop-shop-1 | INFO: Started server process [8]
shop-shop-1 | INFO: Started server process [9]
Появилось два серверных процесса: два воркера. Что это даёт под нагрузкой, мы увидим в теме 11; сейчас важно, что ты доказал применение настройки (а не поверил на слово). Верни исходное значение: sed -i.bak 's/^WEB_CONCURRENCY=.*/WEB_CONCURRENCY=1/' .env && rm .env.bak && docker compose up -d --wait. На время упражнений стенд лучше держать в исходном состоянии, чтобы следующие темы получали ожидаемые числа.
Контейнер в
unhealthyилиExited, а причины в «Типичных ошибках» нет? Скопируйdocker compose psиdocker compose logs --tail 50 <сервис>, спроси нейросеть, что значит каждая строка. Ответ проверь поexec ... envи логам самого сервиса.
6. Перезапуск, остановка и снос
docker compose restart shop
docker compose stop
docker compose ps -a
docker compose start
docker compose ps
[+] Restarting 1/1
✔ Container shop-shop-1 Started 1.3s
...
NAME IMAGE ... STATUS
shop-payment-1 shop-payment Exited (0) 3 seconds ago
shop-postgres-1 postgres:18.6 Exited (0) 2 seconds ago
shop-redis-1 redis:8.10.2 Exited (0) 3 seconds ago
shop-shop-1 shop-shop Exited (0) 3 seconds ago
Как читать вывод: после stop все четыре контейнера остановлены, код 0 значит «завершились чисто» (спасибо exec из урока 5.2: остановка шла быстро). Руками остановленные контейнеры политика restart: unless-stopped сама не поднимает (в этом её смысл). Контейнеры не удалены: start их поднимает с теми же данными. Проверь, что данные на месте: после start docker compose exec postgres psql -U shop -d shop -c "SELECT count(*) FROM orders;" вернёт те же 200 000 (или больше, если ты делал заказы).
Теперь проверь down и down -v. Внимание: вторая команда сотрёт базу стенда. На учебном стенде это безопасно, но следующий подъём займёт около минуты:
docker compose down
docker volume ls | grep shop
docker compose down -v
docker volume ls | grep shop
[+] Running 5/5
✔ Container shop-shop-1 Removed 0.3s
...
✔ Network shop_default Removed 0.2s
local shop_pgdata
[+] Running 1/1
✔ Volume shop_pgdata Removed 0.0s
Как читать вывод: после down том shop_pgdata остался (его показал grep), после down -v исчез (последний grep ничего не нашёл). Теперь подними чистый стенд и засеки время:
time docker compose up -d --build --wait
✔ Container shop-postgres-1 Healthy 41.2s
✔ Container shop-shop-1 Healthy 47.0s
real 0m52.1s
Это тот же холодный старт, что в уроке 2.1: сид идёт около 40 секунд. Это число понадобится тебе в 5.4.
7. Сохрани результат
mkdir -p ~/perf-lab/05-docker
cd ~/learning/load-tester/project/shop
docker compose ps > ~/perf-lab/05-docker/compose-ps.txt
docker compose config > ~/perf-lab/05-docker/compose-config.txt
cat > ~/perf-lab/05-docker/stand-start.sh <<'EOF'
#!/bin/sh
# Поднять чистый стенд и дождаться готовности. Аргумент "clean" стирает данные.
set -eu
cd ~/learning/load-tester/project/shop
if [ "${1:-}" = "clean" ]; then docker compose down -v; fi
docker compose up -d --build --wait --wait-timeout 300
curl -s localhost:8000/readyz; echo
EOF
chmod +x ~/perf-lab/05-docker/stand-start.sh
cd ~/perf-lab
git add 05-docker
git commit -m "Compose: снимок стенда и скрипт запуска (урок 5.3)"
git push
Скрипт stand-start.sh пригодится дальше: он объединяет всё, что ты выучил, в одну команду. ./stand-start.sh clean даёт чистый стенд, ./stand-start.sh просто гарантирует, что стенд поднят. set -eu и cd уже знакомы по уроку 1.5; ${1:-} значит «первый аргумент или пустая строка» (без :- при set -u скрипт без аргумента упал бы).
Типичные ошибки
Этот раздел можно держать открытым в соседней вкладке, когда стенд ведёт себя странно.
| Текст ошибки | Причина | Что делать |
|---|---|---|
no configuration file provided: not found |
ты не в папке с compose.yaml |
cd ~/learning/load-tester/project/shop или docker compose -f ~/learning/load-tester/project/shop/compose.yaml ... |
dependency failed to start: container shop-postgres-1 is unhealthy |
база не стала healthy за отведённое время или упала при старте | docker compose logs postgres: ищи FATAL/ERROR в сиде; часто мало места на диске или битый том: docker compose down -v и заново |
Bind for 127.0.0.1:8000 failed: port is already allocated (или address already in use) |
порт 8000 уже занят: на твоей машине висит другая программа или контейнер | sudo ss -ltnp \| grep 8000; останови чужое или поменяй порт хоста в ports: на "127.0.0.1:8080:8000" (тогда стенд будет на localhost:8080) |
services.shop Additional property cpuz is not allowed |
опечатка в имени ключа в compose.yaml |
исправь написание и проверь docker compose config -q |
yaml: line 12: mapping values are not allowed in this context |
неправильные отступы или лишнее двоеточие | проверь отступы в указанной строке (только пробелы) |
магазин: psycopg_pool.PoolTimeout / Connection refused |
в DATABASE_URL указан localhost или 127.0.0.1 вместо postgres |
верни postgres в .env (или удали строку, чтобы вернулось значение по умолчанию) |
магазин Exited (3) или Restarting, в логах Application startup failed |
база не успела подняться (если убрали depends_on) или недоступна; Docker перезапускает магазин по кругу |
docker compose logs shop, docker compose ps; убедись, что postgres healthy |
поменял SQL в db/init, а в базе старые данные |
скрипты инициализации выполняются только на пустом томе | docker compose down -v и up снова |
| изменил код, а стенд ведёт себя по-старому | образ не пересобран | docker compose up -d --build |
изменил .env, а настройка не подействовала |
сделан restart, а не пересоздание; или опечатка в имени переменной |
docker compose up -d; проверь docker compose exec shop env \| grep ИМЯ |
permission denied while trying to connect to the Docker daemon socket |
пользователь не в группе docker (см. урок 5.1) |
groups, при необходимости sudo usermod -aG docker $USER и новая сессия |
Error response from daemon: ... no space left on device |
кончилось место на диске | docker system df покажет, кто занял; docker image prune убирает ненужные образы |
exec: "ps": executable file not found in $PATH |
в образе slim нет программы ps |
читай docker compose exec shop cat /proc/1/cmdline или используй docker top |
| после перезагрузки компьютера контейнеры не поднялись | Docker (Docker Desktop или служба docker) не запущен: политика restart работает только при живом демоне; или контейнеры остановлены руками (stop, down) |
запусти Docker и проверь docker compose ps; остановленное руками поднимай docker compose up -d |
Последняя строка заслуживает пояснения. У долгоживущих сервисов стенда в compose.yaml стоит restart: unless-stopped: если процесс упал или контейнер убит из-за нехватки памяти (урок 5.4), Docker сам запустит его снова, и в docker compose ps ты увидишь Up 5 seconds, а не Exited. «Unless-stopped» значит «всегда, кроме случая, когда ты остановил контейнер сам»: после docker compose stop контейнер остаётся остановленным, даже если перезапустить Docker. Падение при этом не исчезает, просто становится тихим, поэтому за перезапусками следят: счётчик docker inspect --format '{{.RestartCount}}' shop-shop-1 и правило Prometheus increase(container_oom_events_total[10m]) > 0 (метрику отдаёт cAdvisor из профиля monitoring; проверь в Prometheus, что она есть в твоей версии). Тихий цикл «упал, поднялся, упал» хуже упавшего сервиса, если за ним никто не смотрит. Что такое алерты, в теме 7.
Сломай и почини
Поломка. Нарочно сломай настройку подключения. В .env замени имя сервиса базы на localhost:
cd ~/learning/load-tester/project/shop
sed -i.bak 's#^DATABASE_URL=.*#DATABASE_URL=postgresql://shop:shop@localhost:5432/shop#' .env && rm .env.bak
docker compose up -d
sleep 20
docker compose ps
curl -s -m 5 localhost:8000/readyz; echo
Что ты увидишь? Найди причину по логам, не по подсказке, и исправь. Критерий готовности: curl localhost:8000/readyz возвращает {"status":"ready"}.
Сначала найди причину сам:
ps,logs,exec ... env. Потом спроси нейросеть и сверь её версию со своей, а не наоборот.
Разбор
Скорее всего docker compose ps покажет shop-shop-1 как Up ... (health: starting) (или Restarting: политика restart: unless-stopped поднимает упавший магазин снова), а curl не вернёт ничего или Connection refused. Логи docker compose logs --tail 30 shop расскажут: пул соединений не может подключиться (connection failed: connection to server at "localhost" ... Connection refused). Подсказка в тексте: адрес localhost: внутри контейнера это он сам. Через минуту магазин упадёт с Application startup failed (пул ждёт 60 секунд), Docker перезапустит его, и так по кругу: docker inspect --format '{{.RestartCount}}' shop-shop-1 будет расти.
Исправление: вернуть имя сервиса.
sed -i.bak 's#^DATABASE_URL=.*#DATABASE_URL=postgresql://shop:shop@postgres:5432/shop#' .env && rm .env.bak
docker compose up -d --wait
curl -s localhost:8000/readyz; echo
Метод диагностики для всех подобных случаев: ps (в каком состоянии контейнер), logs (что он сам говорит), exec ... env (какие настройки он реально получил), и только потом гипотезы. Если не уверен в исходном значении, сравни с .env.example: diff .env .env.example.
ИИ в помощь
Нейросеть читает compose.yaml и логи быстрее новичка, но стенда она не видит: давай ей файл и вывод целиком. Общие правила на странице ИИ-помощник.
Задача: разобрать незнакомый compose.yaml или его кусок.
Я учусь Docker Compose (Docker Compose v2, Ubuntu 24.04). Вот сервис из compose.yaml:
<вставь блок сервиса shop, без паролей>
Объясни каждый ключ: environment, depends_on с condition: service_healthy,
healthcheck, restart. Что произойдёт, если база стартует медленно?
Как сервисы находят друг друга по имени?
Проверь ответ: сверь с выводом docker compose config и docker compose ps на стенде. Типичная ошибка: нейросеть пишет устаревший ключ version: или старый синтаксис depends_on без condition, и тогда Compose не ждёт готовности базы.
Задача: найти причину, почему сервис не поднялся.
Стенд «Магазин», docker compose up -d --wait. Сервис shop в статусе unhealthy.
Вывод docker compose ps:
<вставь вывод>
Последние 50 строк docker compose logs shop:
<вставь логи>
Объясни, что значит каждая строка ошибки, и предложи порядок проверок
от самой вероятной причины. Для каждой дай команду.
Проверь ответ: выполни предложенные команды и подтверди причину в логах. Нейросеть может советовать docker compose down -v: эта команда удалит том с базой (200 000 заказов придётся сеять заново), не запускай её без понимания.
Не отправляй в чат содержимое .env целиком: замени пароли и токены на <пароль> и пришли только нужные строки.
Словарик урока
| Термин | Простыми словами |
|---|---|
| Docker Compose | Инструмент, который запускает несколько контейнеров по описанию в одном файле |
compose.yaml |
Файл с описанием сервисов, сети, томов и настроек стенда |
| YAML | Формат настроек: структура задаётся отступами пробелами, списки через дефис |
| Сервис (service) | Описание одного вида контейнеров в Compose |
| Проект Compose | Набор сервисов из одного файла; его имя (name: shop) входит в имена контейнеров |
| Сеть Compose | Общая сеть проекта, где сервисы находят друг друга по имени |
| Имя сервиса как адрес | postgres, redis: адрес контейнера внутри сети; localhost внутри контейнера это он сам |
healthcheck |
Периодическая проверка «готов ли контейнер» |
| healthy / unhealthy / starting | Статусы проверки здоровья |
depends_on |
Порядок запуска сервисов |
service_healthy |
Условие: ждать не запуска, а готовности (healthy) |
.env |
Файл со значениями переменных, которые Compose подставляет в compose.yaml |
${ИМЯ:-значение} |
Подстановка с запасным значением |
profiles |
Сервисы, которые запускаются только при включении профиля |
--build |
Пересобрать образы перед запуском |
--wait |
Ждать, пока сервисы не станут запущенными или healthy (где есть проверка здоровья) |
down / down -v |
Снести проект, сохранив / стерев данные в томах |
| Сид (seed) | Начальная загрузка данных в базу при первом запуске |
Вопросы с собеседований
Раздел для повторения: ответь вслух, потом открой ответ. Вопросы [на скорость] нужно уметь отвечать за 30 секунд.
1. [junior] [часто] Для чего нужен Docker Compose?
Ответ
Чтобы описать многоконтейнерное приложение (сервисы, сеть, тома, переменные) одним файлом и управлять им парой команд: up, down, logs. Окружение получается воспроизводимым: у каждого разработчика и тестировщика одинаковые версии и настройки.
Что хотят услышать: «один файл на весь стенд», воспроизводимость, сеть между сервисами.
Красный флаг: «Compose нужен для боевого кластера на сотни серверов» (для этого оркестраторы вроде Kubernetes).
2. [junior] [часто] Чем docker compose down отличается от down -v?
Ответ
down удаляет контейнеры и сеть, но оставляет тома, поэтому данные базы сохраняются. down -v удаляет и тома: следующий запуск начнёт с пустой базы и заново выполнит скрипты инициализации.
Что хотят услышать: тома переживают down, -v деструктивен.
Красный флаг: «down удаляет и данные».
3. [junior] [часто] Контейнеры в Compose запущены, но приложение не может подключиться к базе по localhost. Почему?
Ответ
localhost внутри контейнера означает сам контейнер. Сервисы Compose находят друг друга по имени сервиса через встроенный DNS проекта: адрес базы postgres:5432. Нужно исправить хост в строке подключения.
Что хотят услышать: контейнер имеет свою сеть, адресация по имени сервиса.
Красный флаг: «открою порт 5432 на хосте и всё заработает» (порты хоста для связи контейнеров не нужны).
4. [junior] Что делает docker compose up -d --build --wait?
Ответ
up создаёт и запускает сервисы; -d в фоне; --build пересобирает образы из кода (без него использовался бы старый образ, если он есть); --wait не возвращает управление, пока сервисы не станут запущенными (у кого есть healthcheck, healthy) или не упадут. Готовность самого магазина всё равно проверь отдельно: /readyz, поэтому скрипт не начнёт тест на неготовом стенде.
Что хотят услышать: значение каждого флага, зачем --wait.
Красный флаг: «--build нужен только при первом запуске».
5. [junior] Зачем нужна секция healthcheck, если контейнер и так запущен?
Ответ
«Процесс запущен» не значит «сервис готов»: база может заливать данные, приложение может не успеть открыть порт. Проверка здоровья периодически выполняет команду внутри контейнера и выставляет статус healthy/unhealthy. На неё опирается depends_on и --wait.
Что хотят услышать: различие «запущен» и «готов», связь с зависимостями.
Красный флаг: считает, что Up в docker ps гарантирует готовность.
6. [middle] Чем отличается depends_on без условия от depends_on с condition: service_healthy?
Ответ
Без условия Compose дожидается только запуска зависимости (контейнер создан и процесс стартовал), не готовности. С service_healthy Compose ждёт статуса healthy, то есть успешной проверки. Для баз данных нужна вторая форма, иначе приложение может стартовать, пока база ещё заливает данные.
Что хотят услышать: две формы, пример с базой, упоминание гонки на старте.
Красный флаг: «depends_on гарантирует, что приложение найдёт базу готовой» без condition.
7. [middle] Ты поменял переменную в .env и сделал docker compose restart. Почему настройка не применилась и как применить?
Ответ
Переменные окружения фиксируются при создании контейнера. restart перезапускает тот же контейнер с прежними значениями. Нужно docker compose up -d: Compose увидит изменение конфигурации и пересоздаст контейнер. Проверить результат: docker compose exec сервис env.
Что хотят услышать: создание против перезапуска, проверка через env.
Красный флаг: «пересоберу образ» (образ тут ни при чём).
8. [middle] Почему изменения в SQL-файлах инициализации базы не попадают в запущенный стенд?
Ответ
Образ PostgreSQL выполняет скрипты из /docker-entrypoint-initdb.d только когда каталог данных пуст, то есть при первом старте на новом томе. Если том уже есть, скрипты пропускаются. Чтобы изменения подействовали, нужно удалить том (docker compose down -v) и запустить заново, понимая, что все данные будут потеряны.
Что хотят услышать: «только на пустом томе», down -v, осознание потери данных.
Красный флаг: предлагает «зайти и руками выполнить SQL» как постоянное решение, не понимая причины.
9. [middle] Зачем в стенде нужны профили (profiles)?
Ответ
Профиль позволяет держать в одном файле необязательные сервисы (мониторинг), которые не запускаются по умолчанию и не отбирают ресурсы, а включаются явно: docker compose --profile monitoring up -d. Для нагрузочных тестов это ещё и чистота эксперимента: ты знаешь, что работает на стенде.
Что хотят услышать: необязательные части, экономия ресурсов, явное включение.
Красный флаг: «профиль это настройки пользователя».
10. [на скорость] Как проверить, что в compose.yaml нет ошибок?
Ответ
docker compose config -q: проверяет файл и подстановки, при ошибке выдаёт сообщение. Без -q печатает итоговую конфигурацию.
11. [на скорость] Что делает ${WEB_CONCURRENCY:-1}?
Ответ
Берёт значение переменной WEB_CONCURRENCY из .env или окружения, а если её нет или она пустая, подставляет 1.
12. [на скорость] Как посмотреть логи одного сервиса Compose и следить за ними?
Ответ
docker compose logs -f shop: имя сервиса, флаг -f следит за новыми строками, выход Ctrl+C. Для последних строк --tail 50.
13. [на скорость] Остался ли том после docker compose down?
Ответ
Да. Тома удаляет только down -v (или docker volume rm).
Проверено на версиях
Ubuntu 24.04 LTS, Docker Engine 29.x, плагин docker compose (v2 и новее), compose.yaml стенда из project/shop: образы postgres:18.6, redis:8.10.2, python:3.14.8-slim. Октябрь 2026. Время старта и вид вывода up зависят от диска и версии Compose: смотри на статусы, а не на цифры.
Итог урока: ты умеешь
- Объяснить, зачем нужен Compose, и прочитать
compose.yaml«Магазина» (сервисы,imageиbuild,environment,ports,volumes,healthcheck). - Объяснить, почему сервисы обращаются друг к другу по имени, а не по
localhost. - Объяснить, чем «запущен» отличается от «готов», и как это решают
healthcheckсdepends_on: service_healthy. - Менять настройки стенда через
.env, применять их черезup -dи проверятьexec ... env. - Выбирать между
restart,stop,downиdown -vи знать, что при каждом происходит с данными. - Читать
docker compose ps,logs -fиexec, находить причину по логам. - Расшифровать типичные ошибки из таблицы:
no configuration file,unhealthy,port is already allocated,localhostвместо имени сервиса. - Назвать переменные
.env, которые пригодятся в теме 11, и знать, где их описание.
Дальше: урок 5.4. Ресурсы контейнеров: лимиты, docker stats и почему стенд не прод, где ты выяснишь, что значат строки cpus и mem_limit из этого файла, и увидишь, как контейнер умирает от нехватки памяти.
Проверь себя
Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.
Тест работает с включённым JavaScript.