load-tester Все курсы

✻ Урок 5.3 · Тема 5: Docker и учебный стенд

Docker Compose: поднимаем «Магазин» с базой и кэшем

⏱ 3.5 ч

Зачем это нужно

Я давно занимаюсь нагрузкой и хорошо помню один случай. Коллега прислал замеры: «У меня магазин отвечает за 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.

тема 5 урок 5.3 3.5 ч курс 0/0 ← → уроки