✻ Урок 4.5 · Тема 4: Docker и Compose
Compose: «Заметки» и PostgreSQL
Содержание урока
Зачем это нужно
В уроке 4.4 ты запускал PostgreSQL и «Заметки» двумя длинными командами docker run: сеть, том, пароль, порты, порядок запуска. Всё это жило в твоей голове и в истории терминала. Через неделю ты не вспомнишь ни флагов, ни того, что базу надо запускать первой. Коллега, которому ты скажешь «подними у себя», не сможет повторить это без часа расспросов.
На работе такой стенд описывают одним текстовым файлом compose.yml. Его кладут в git рядом с кодом, и любой человек поднимает всё одной командой. Инструмент, который читает этот файл и запускает контейнеры, называется Docker Compose («compose» по-английски «составить»: из нескольких частей он составляет одно работающее целое). Это дополнение к Docker, отдельно ставить ничего не нужно. Без него каждый раз пришлось бы вспоминать и вводить десяток длинных команд.
Второй урок этой темы про готовность. Контейнер с базой «запущен» и база «готова принимать запросы» это разные состояния (как «лампочка в чайнике горит» и «вода уже закипела»), между ними несколько секунд. Если приложение стартует в этот промежуток, оно не находит базу. Compose умеет ждать, но только если ты объяснишь ему, что значит «готова».
Шаг проекта: «Заметки» и PostgreSQL описаны в compose.yml, пароль лежит в файле .env (обычный текстовый файл со строками вида ИМЯ=значение, который Compose читает сам; в git он не попадает, поэтому пароль не утекает в репозиторий), приложение стартует только после того, как база прошла проверку готовности (проверка готовности, healthcheck, - это команда, которую Docker периодически запускает внутри контейнера и по ответу решает, здоров ли сервис; разберём ниже).
Что нужно знать
- Урок 1.4: процессы и сигналы: что такое сигнал
SIGTERM(вежливая просьба завершиться) иSIGKILL(принудительное завершение). Их посылаетdocker compose stop. - Урок 2.1: адреса и порты: чем адрес
127.0.0.1(только эта машина) отличается от0.0.0.0(все сетевые интерфейсы). - Урок 3.1: git:
.gitignore(список файлов, которые git не трогает) и коммит (сохранённый снимок изменений). - Урок 4.2: Dockerfile: образ «Заметок» собирается из
Dockerfileв корне репозитория; в нём уже естьENV HOST=0.0.0.0иHEALTHCHECK. - Урок 4.3: тома и сети: именованный том живёт отдельно от контейнера, а в пользовательской сети контейнеры находят друг друга по имени.
- Урок 4.4: SQL и PostgreSQL:
STORE=postgres,DATABASE_URL,/readyz,psql. Стартовое состояние этого урока: репозиторий~/notesс приложением v4,Dockerfile,requirements.txtиdb/schema.sql.
Картина целиком
Представь ресторан. Раньше ты шёл на кухню и по одному диктовал повару, что делать: «поставь воду, потом нарежь лук, потом включи плиту». Забыл шаг, и обед испорчен. Compose это меню и заказ: ты один раз записал на бумаге, какие блюда нужны и как они связаны, отдал листок, и кухня сама всё сделала в правильном порядке. Завтра можно отдать тот же листок другому повару.
Листок в нашем случае это compose.yml. Написан он на формате YAML: это способ записать настройки текстом, где вложенность показывается отступами (разберём ниже, правил всего три). Каждое «блюдо» из меню называется сервисом (service): это описание одного контейнера (из какого образа, с какими настройками). У нас два сервиса: notes (приложение) и db (база). Что на листке написано, видно на схеме:
flowchart TB
subgraph DIR["~/notes: каталог проекта в git"]
F1["compose.yml: описание стенда"]
F2[".env: пароль, в git не попадает"]
F3[".env.example: заготовка без пароля, в git"]
F4["Dockerfile: сборка образа приложения"]
end
DIR -->|"docker compose up -d"| NET
subgraph NET["сеть notes-net"]
APP["сервис notes<br>ждёт, пока db станет healthy"] -->|"db:5432, имя вместо IP"| DB["сервис db<br>PostgreSQL"]
DB --- VOL[("том notes_pgdata<br>данные базы")]
end
CURL["curl с твоей машины"] -->|"127.0.0.1:8080"| APP
Порт 5432 наружу не открыт.
Дальше по порядку: что такое Compose и формат YAML, из которого состоит файл; как сервисы находят друг друга; как открыть порт наружу и не открыть лишнего; как объяснить Compose, что значит «база готова»; где хранить пароль; где живут данные и почему смена пароля иногда «не работает».
Теория
Compose: зачем нужен и что описывает файл
Настоящий сервис почти никогда не состоит из одного контейнера. У «Заметок» их уже два: приложение и база. Дальше добавятся nginx (веб-сервер, который принимает запросы снаружи и передаёт их приложению, тема 2 и урок 4.6) и мониторинг (программы, которые следят за состоянием сервиса и рисуют графики, тема 8). Каждый контейнер надо создать с правильной сетью, томом, переменными и портами, причём в нужном порядке. Командами docker run это превращается в длинную простыню, которую легко перепутать и невозможно проверить глазами. Compose решает это тем, что вся простыня превращается в один читаемый файл.
Чертёж мебели из магазина. Вместо того чтобы устно объяснять сборщику каждую операцию, ты даёшь бумагу: «шкаф, две полки, три ящика, ручки такие-то». Сборщик сам решает, с чего начать. Аналогия ломается в одном: чертёж однократный, а Compose умеет сверять чертёж с тем, что уже стоит в комнате. Если ты поменял в чертеже одну ручку, он переделает только ящик с этой ручкой.
Compose (Docker Compose, встроенный в Docker плагин) читает файл compose.yml и приводит Docker к описанному там состоянию: создаёт сеть, тома, контейнеры и запускает их. Такой подход называется декларативным (declarative): ты описываешь, что должно получиться, а не какие команды выполнить. Поэтому команду docker compose up -d можно повторять сколько угодно: если всё уже как в файле, ничего не произойдёт, а если файл изменился, Compose пересоздаст только затронутое. Ключ -d (detach) означает «в фоне»: без него команда займёт терминал и будет печатать логи.
В файле три секции верхнего уровня:
services: контейнеры, которые надо запустить. У нас два:notesиdb. Сервис (service) это одно «блюдо» из меню: описание того, как запустить контейнер (образ, порты, переменные). Само имя сервиса важно: оно станет адресом внутри сети (разберём ниже).volumes: именованные тома, то есть хранилища, которыми управляет Docker и которые переживают удаление контейнеров (урок 4.3).networks: сети. Если ничего не описать, Compose сам создаст одну общую сеть, и все сервисы окажутся в ней.
Двух вещей в файле нет. Ключа version: (раньше им обозначали формат): современный Compose его игнорирует и ругается, если он есть. И команда пишется через пробел, docker compose: это плагин Docker. Старая docker-compose через дефис, которую встретишь в старых статьях, не используется.
Проект. Всё, что Compose создаёт за один запуск, он объединяет в проект (project): группу контейнеров, сетей и томов под одним именем. Имя по умолчанию равно имени каталога, где лежит compose.yml. У нас каталог notes, значит проект называется notes, и все имена получают этот префикс:
flowchart LR
P["проект: notes"] --> N1["notes-db-1<br>имя контейнера"]
S["сервис: db"] --> N1
C["номер копии: 1"] --> N1
P --> N2["notes_pgdata<br>имя тома"]
V["том: pgdata"] --> N2
Число 1 в конце это номер копии: сервис можно запустить в нескольких экземплярах (одинаковых копиях, чтобы выдержать больше запросов), у нас он один. Сеть мы назовём вручную, notes-net, чтобы позже к ней подключился мониторинг (урок 8.2), а не получить автоматическое notes_default.
Посмотрим на примере. Сравни знакомую команду из урока 4.4 с записью в compose.yml. Каждая часть docker run нашла своё место в файле:
docker run -d --name db --network notes-net -> сервис "db" в секции services, сеть в networks
-e POSTGRES_USER=notes -e POSTGRES_DB=notes -> ключ environment:
-v notes-pgdata:/var/lib/postgresql -> ключ volumes: у сервиса + секция volumes:
postgres:18 -> ключ image:
--restart / --health-cmd (если были) -> ключи restart: и healthcheck:
Прикинь сам: Ты запустил
docker compose up -dв каталоге~/notes, потом переименовал каталог в~/notes2и запустил снова. Сколько наборов контейнеров получится?
Два: имя проекта берётся из имени каталога, поэтому Compose создаст второй набор (notes2-db-1 и так далее) рядом со старым.
Осторожно: Compose не заменяет Dockerfile: файл Dockerfile описывает, как собрать один образ, а compose.yml описывает, как запустить несколько контейнеров вместе. И Compose не кластерный оркестратор вроде Kubernetes (тема 5): он работает на одной машине.
Главное:
compose.ymlописывает весь стенд (сервисы, тома, сети), а имя проекта берётся из имени каталога и входит в имена контейнеров и томов.
Файл написан на YAML. Научимся его читать.
Проверь понимание: ты запустил
docker compose up -dв каталоге~/notes, потом переименовал каталог в~/notes2и снова запустилup -d. Что произойдёт?
Ответ
Имя проекта возьмётся из нового имени каталога (notes2), и Compose создаст второй, параллельный набор контейнеров и томов (notes2-db-1, notes2_pgdata). Старый стенд останется работать и будет занимать те же порты и то же явное имя сети. Чтобы имя не зависело от каталога, задают ключ name: в файле или флаг -p.
YAML: как читать compose.yml
Файл написан на языке разметки YAML. Это способ записать структурированные данные текстом: настройки, списки, вложенность. Ты будешь писать YAML в Compose, Kubernetes, CI (урок 3.3) и Ansible (инструмент, который по такому же текстовому описанию настраивает серверы, тема 7), поэтому стоит один раз понять его правила.
Оглавление книги с вложенными пунктами: подпункт «принадлежит» пункту, потому что сдвинут вправо. YAML делает то же самое, только сдвиг это значимая часть записи. Аналогия ломается в строгости: в оглавлении лишний пробел ничего не испортит, а в YAML сдвинутая на один пробел строка означает другое.
Три конструкции покрывают почти всё:
ключ: значение(после двоеточия обязателен пробел). Напримерimage: postgres:18.- Вложенность задаётся отступом пробелами: строки с одинаковым отступом на одном уровне, больший отступ значит «внутри». Принято два пробела. Табуляция запрещена, только пробелы.
- Список: каждый элемент с новой строки, начинается с
-. Короткий список можно записать в квадратных скобках в одну строку:["CMD-SHELL", "pg_isready"].
Комментарий начинается с # и идёт до конца строки. Компьютер его игнорирует, человеку он объясняет, зачем строка нужна.
Вот как это выглядит на деле. Кусок нашего файла и то, как его читает Compose:
services: # ключ верхнего уровня: «вот мои сервисы»
db: # внутри services есть ключ db (отступ 2): это имя сервиса
image: postgres:18 # внутри db (отступ 4): ключ image со значением postgres:18
ports: # ключ ports, значением будет список
- "127.0.0.1:8080:8080" # первый (и единственный) элемент списка
Прочитай как дерево: services содержит db, db содержит image и ports, а ports это список из одной строки. Если сдвинуть image на два пробела влево, он окажется рядом с db, а не внутри, и Compose скажет, что у services странный ключ image.
Прикинь сам: Строка
POSTGRES_DB:notesнаписана без пробела после двоеточия. Сколько ключей Compose увидит в ней?
Ни одного: без пробела получается одна строка-скаляр POSTGRES_DB:notes, а не ключ со значением, и Compose выдаст ошибку.
Осторожно: кавычки. Записи вроде 8080:8080 без кавычек YAML иногда пытается прочитать как число в особом формате, поэтому порты всегда пишут в кавычках. Второе: key:value без пробела это не пара ключ-значение, а просто строка.
Главное: в YAML вложенность задаётся пробелами, после двоеточия обязателен пробел, а порты и похожие на числа значения пишут в кавычках.
Теперь посмотрим, как сервисы находят друг друга.
Проверь понимание: что не так в записи
environment:и следующей строкиPOSTGRES_DB:notesс отступом 6, еслиenvironment:стоит с отступом 4?
Ответ
Две проблемы. Отступ 6 уже правильный (внутри environment), но после двоеточия нет пробела, поэтому POSTGRES_DB:notes читается как одна строка, а не как пара «ключ: значение». Правильно POSTGRES_DB: notes.
Сеть проекта и DNS: как сервисы находят друг друга
Приложению нужен адрес базы. IP-адрес контейнера выдаётся при создании и меняется при каждом пересоздании, поэтому вписать его в настройки нельзя. Нужно имя, которое не меняется.
Внутренний телефонный справочник офиса: ты набираешь «бухгалтерия», а не длинный номер, и справочник сам подставляет актуальный. Если бухгалтерию переселили, ты продолжаешь звонить туда же. Аналогия ломается на границе офиса: справочник работает только внутри, снаружи слово «бухгалтерия» ничего не значит.
Когда ты запускаешь стенд, Compose создаёт сеть (у нас notes-net) и подключает к ней все сервисы. В Docker для пользовательских сетей работает встроенный DNS-сервер (урок 4.3; DNS это «справочник имён», переводящий имя в IP). Он живёт по адресу 127.0.0.11 внутри каждого контейнера. Шаги, когда notes открывает соединение с db:
- Приложение просит у системы адрес по имени
db. - Запрос попадает во встроенный DNS Docker.
- DNS находит контейнер сервиса
dbв этой же сети и отвечает его текущим IP. - Приложение подключается к этому IP, порт 5432.
Имя, по которому можно достучаться, это имя сервиса из compose.yml (у нас db), а не имя контейнера notes-db-1 (хотя и оно работает).
Теперь на числах. Проверим на настоящем стенде (он появится в практике). Команда docker compose exec notes getent hosts db выполняет getent hosts db внутри контейнера notes: утилита getent спрашивает у системы адрес по имени.
172.19.0.2 db
Слева IP-адрес (у тебя будет другой: Docker выбирает подсеть сам), справа имя. Значит, внутри контейнера notes слово db превратилось в адрес контейнера с базой. Именно поэтому в DATABASE_URL написано @db:5432: «хост db, порт 5432».
Прикинь сам: Сервис в
compose.ymlназванdatabase, а вDATABASE_URLнаписано@db:5432. Сколько адресов найдёт DNS по имениdb?
Ни одного: имя в адресе должно совпадать с именем сервиса, то есть @database:5432. Хост localhost тоже не подойдёт, это петля самого контейнера.
Осторожно: самая частая ошибка: писать в DATABASE_URL хост localhost или 127.0.0.1. У каждого контейнера своя собственная сеть, и localhost внутри контейнера notes это сам контейнер notes, где никакой базы нет. В логах будет Connection refused (отказ в соединении: по адресу никто не слушает). Второе заблуждение: «IP можно один раз посмотреть и вписать». После down и up он изменится.
Главное: сервисы находят друг друга по имени сервиса через встроенный DNS Docker, а
localhostвнутри контейнера это он сам.
Внутри сети порты открывать не нужно. Наружу их открывают отдельно.
Проверь понимание: сервис в
compose.ymlназванdatabase, а неdb. Что надо поменять вDATABASE_URL?
Ответ
Хост: postgresql://notes:пароль@database:5432/notes. Имя в адресе всегда равно имени сервиса из compose.yml, поэтому при переименовании сервиса надо править и все места, где к нему обращаются.
Порты: что открыто наружу, а что только внутри сети
Сервисам между собой порты открывать не нужно: внутри сети notes-net контейнеры видят порты друг друга. Но человеку на хосте (твоя машина) нужен вход в приложение: curl с ноутбука. Для этого порт публикуют (publish): просят Docker пробросить порт контейнера на порт хоста.
Офис с внутренними телефонами и одним городским номером. Сотрудники звонят друг другу по короткому номеру без ограничений (внутренние порты). Внешний номер выдают только тому, кому нужно принимать звонки снаружи (опубликованные порты). Аналогия ломается на том, что в Docker «внешний номер» можно привязать к разным «дверям» здания, и от этого зависит, кто его достанет.
В compose.yml формат записи "адрес:порт_хоста:порт_контейнера". У нас "127.0.0.1:8080:8080":
flowchart LR
A["127.0.0.1<br>адрес хоста, где открыть порт"] --- X(("127.0.0.1:8080:8080"))
B["8080 (первый)<br>порт на хосте, куда стучится curl"] --- X
C["8080 (второй)<br>порт внутри контейнера, там слушает app.py"] --- X
Адрес решает, кто сможет достучаться. 127.0.0.1 (loopback, «петля», урок 2.1) значит «только с этой же машины». Если адрес не написать ("8080:8080"), порт откроется на 0.0.0.0, то есть на всех сетевых интерфейсах, и его увидят из локальной сети и из интернета, если машина туда смотрит. Docker при этом обходит правила файрвола ufw (урок 2.7), потому что добавляет свои правила раньше: «закрыл в ufw» не значит «закрыто».
Разберём пример. Колонка PORTS в docker compose ps показывает, что получилось:
notes-db-1 ... 5432/tcp
notes-notes-1 ... 127.0.0.1:8080->8080/tcp
У db только 5432/tcp: порт объявлен образом, но стрелки -> нет, значит на хост он не опубликован, к базе можно попасть только из сети notes-net. У notes есть стрелка: порт 8080 контейнера доступен на 127.0.0.1:8080 хоста.
Прикинь сам: В
docker compose psуdbнаписано5432/tcp, а уnotes127.0.0.1:8080->8080/tcp. Сколько портов доступно с хоста?
Один: 8080 (и только с самого хоста). У db нет стрелки ->, значит порт на хост не опубликован.
Осторожно: ключ expose и инструкцию EXPOSE из Dockerfile: они только документируют порт и ничего не открывают наружу. И желание опубликовать порт базы «чтобы удобнее было подключаться DBeaver’ом»: это открывает базу всем, кто достанет до хоста, и конфликтует с PostgreSQL, если он уже стоит на хосте. Заходить в базу можно через docker compose exec db psql.
Главное: адрес в записи порта решает, кто достучится:
127.0.0.1только с этой машины, а порт базы наружу лучше не публиковать.
Сервисы запускаются, но запуск не значит готовность.
Проверь понимание: ты хочешь смотреть базу с ноутбука через
psql. Как сделать это безопасно?
Ответ
Вариант первый: не публиковать порт совсем и заходить командой docker compose exec db psql -U notes -d notes (входим в контейнер). Вариант второй, если клиент обязательно снаружи: публиковать только на loopback, "127.0.0.1:5432:5432", а с другой машины ходить через SSH-туннель. Запись "5432:5432" без адреса открыла бы базу всем.
Готовность не равна запуску: healthcheck и depends_on
Контейнер db становится running через долю секунды после старта, но в этот момент внутри ещё работает скрипт инициализации PostgreSQL: он создаёт каталог данных, пользователя и базу. Пока это идёт, сервер не принимает соединений. Приложение, которое стартовало в этот момент, получит отказ. Compose по умолчанию не знает, что «запущен» и «готов» разные вещи, и ему надо это объяснить.
Ресторан утром: свет горит, двери открыты (контейнер запущен), но повара ещё разжигают плиту, заказы принимать нельзя (база не готова). Официант, который сразу понёс заказ на кухню, вернётся с отказом. Правильный официант смотрит на табличку «Открыто». Аналогия ломается в том, что табличку вешает не человек, а специальная проверка, которая сама регулярно спрашивает кухню «готовы?».
Две части:
- Healthcheck (проверка здоровья) у сервиса
db: команда, которую Docker сам запускает внутри контейнера каждые несколько секунд. Код выхода0значит «здоров», любой другой «нет». Для PostgreSQL есть готовая утилитаpg_isready: она спрашивает сервер, готов ли он принимать соединения. depends_onс условиемservice_healthyу сервисаnotes: «не запускай меня, покаdbне станет здоровой».
У проверки состояние проходит такие стадии:
stateDiagram-v2
[*] --> starting: старт контейнера, start_period 10 с
starting --> healthy: проверка вернула код 0
starting --> starting: неудачи в start_period не считаются
starting --> unhealthy: неудач подряд не меньше retries
healthy --> unhealthy: неудач подряд не меньше retries
Первая проверка идёт через interval (5 с) после старта, а не сразу.
Параметры:
interval: как часто проверять (у нас 5 секунд; первая проверка идёт черезintervalпосле старта, а не сразу).timeout: сколько ждать ответа одной проверки, потом считать её неудачной.retries: сколько неудач подряд превращают контейнер вunhealthy(у нас 10).start_period: время на разгон, в течение которого неудачи не считаются, пока хоть раз не пройдёт успешно.
Строка test: ["CMD-SHELL", "pg_isready -U notes -d notes"] значит «выполни команду в оболочке контейнера». -U notes это пользователь, -d notes база.
Посмотрим на примере. Реальное состояние проверки, посмотрели командой docker inspect на работающем notes-db-1:
docker inspect --format '{{json .State.Health}}' notes-db-1
Сокращённый результат:
{"Status":"healthy","FailingStreak":0,"last":{"ExitCode":0,"Output":"/var/run/postgresql:5432 - accepting connections\n"}}
Status это итог (healthy), FailingStreak число неудач подряд (0), ExitCode: 0 значит, что последняя проверка успешна, а Output это то, что напечатал pg_isready: «принимаю соединения».
Считаем время до healthy. В замерах на реальном стенде контейнер становится healthy примерно через 5,2 секунды и при первом запуске (пустой том), и при повторном (том с данными). Причина не в PostgreSQL, а в проверке: первая проверка стартует через interval = 5 секунд, и только она может сделать контейнер healthy. Поэтому чем меньше interval, тем быстрее Compose заметит, что база готова, но тем чаще вы её дёргаете. Худший случай для unhealthy без start_period: 10 неудач по 5 секунд, около 50 секунд (со start_period в 10 с будет около 60, см. ниже).
Прикинь сам:
interval5 с,retries10,start_period10 с. Через сколько секунд после старта контейнера база успеет статьhealthy, если она готова через 5,2 с, и сколько секунд ждали бы доunhealthyпри полностью неработающей базе?
Первая проверка в 5 с, ещё неуспешна, вторая в 10 с даёт healthy. При неработающей базе неудачи не считаются 10 с, потом 10 попыток по 5 с, то есть unhealthy примерно через 60 секунд.
Осторожно: три вещи.
Главное:
depends_onсservice_healthyждёт готовности базы только при запуске стека, а потом каждый контейнер живёт по своей политикеrestart.
Для базы нужен пароль. Где его хранить?
- Простой
depends_on: [db]без условия гарантирует только порядок создания контейнеров, но не готовность. - Это защита только при старте. Если база упала позже,
depends_onничего не перезапустит. Приложение должно само пережить потерю базы. Наше именно так и устроено: соединение открывается на каждый запрос, поэтому, когда база вернулась, заметки снова работают без перезапуска. healthyне значит «всё хорошо у приложения». У самогоnotesесть проверка/healthzиз Dockerfile: она отвечает «процесс жив» и не смотрит на базу. Про базу приложению отвечает/readyz.
Раздельные проверки: жив и готов. Это важное различие, к нему вернёмся в Kubernetes (урок 5.7). Liveness (жив ли процесс) проверяет /healthz: если нет, процесс надо перезапускать. Readiness (готов ли принимать работу) проверяет /readyz: если нет, надо просто не слать на него трафик и ждать. Если база недоступна, перезапуск приложения ей не поможет, поэтому /healthz остаётся 200, а /readyz становится 503.
Строка restart: unless-stopped в compose.yml это политика перезапуска: если контейнер упал сам, Docker поднимет его снова, но не будет этого делать, если ты остановил его командой docker compose stop.
Проверь понимание:
dbсталhealthy, потом упал и перезапустился. Перезапустит ли Composenotesиз-заdepends_on?
Ответ
Нет. depends_on работает только при запуске стека командой up. Дальше каждый контейнер живёт по своей политике restart. Восстанавливать соединение с базой должно само приложение (у нас оно открывается на каждый запрос, поэтому /readyz сам вернётся в 200).
Переменные и секреты: файл .env
В compose.yml нужен пароль базы. Если вписать его прямо в файл, он уедет в git вместе с файлом и станет виден каждому, кто имеет доступ к репозиторию, навсегда: git помнит всю историю. Значит, пароль надо хранить отдельно, а в compose.yml оставить только ссылку на него.
Шаблон письма «Уважаемый {имя}». Сам шаблон можно показывать всем, а конкретные имена подставляются при отправке из отдельной таблицы. compose.yml это шаблон, .env таблица значений. Аналогия ломается на безопасности: таблица .env тоже лежит на диске открытым текстом, мы лишь убрали её из git.
Compose автоматически читает файл .env, если он лежит рядом с compose.yml. Файл состоит из строк ИМЯ=значение. Везде, где в compose.yml встречается ${ИМЯ}, Compose при запуске подставляет значение. Вот полезные формы:
${VAR}: подставить значение. Если переменной нет, Compose подставит пустую строку и напишет предупреждение.${VAR:-значение}: если переменная не задана или пуста, взять значение по умолчанию. У нас${APP_VERSION:-dev}.${VAR:?сообщение}: если не задана, остановиться с ошибкой и показать сообщение.
Важная тонкость, которую путают почти все: .env нужен самому Compose для подстановки внутрь compose.yml, а не контейнерам. В контейнер попадает только то, что перечислено в ключе environment: сервиса. Поэтому в environment у db мы пишем POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}: «возьми из .env и отдай контейнеру».
Второй файл, .env.example, содержит те же ключи с заглушкой CHANGE_ME вместо пароля. Он идёт в git, чтобы коллега видел, какие переменные нужны. Настоящий .env добавляют в .gitignore, и git его не видит.
Вот как это выглядит на деле. У нас в .env три переменные. Вот как docker compose config (команда показывает итоговый файл после подстановки) показала результат для db и notes. Пароль у тебя будет другой:
db:
environment:
POSTGRES_DB: notes
POSTGRES_PASSWORD: 2759a4d04b4340e8e336c5d9288bf32d
POSTGRES_USER: notes
...
notes:
environment:
APP_VERSION: dev
DATABASE_URL: postgresql://notes:2759a4d04b4340e8e336c5d9288bf32d@db:5432/notes
STORE: postgres
APP_VERSION в .env пустой, поэтому сработало значение по умолчанию dev. Пароль в POSTGRES_PASSWORD и в DATABASE_URL одинаковый: один должен быть у базы, другой нужен приложению, чтобы туда войти. Разобрать postgresql://notes:пароль@db:5432/notes можно так: пользователь notes, пароль, хост db, порт 5432, база notes.
Пароль вставляется в адрес, поэтому его символы не должны иметь в адресе особого смысла. Команда openssl rand -hex 16 даёт 32 символа 0-9a-f, они безопасны. Формат -base64 может дать /, +, =, и адрес развалится.
Прикинь сам: В
.envпарольabc/def+1, а он подставляется вDATABASE_URL. Сколько символов в пароле имеют особый смысл в адресе, и что это даст?
Два: / и +. Разбор адреса сломается, и приложение выдаст ошибку аутентификации или разбора. Пароль генерируют из символов 0-9a-f, например openssl rand -hex 16.
Осторожно: четыре ловушки.
Главное:
.envчитает сам Compose для подстановки вcompose.yml, он не должен попадать в git, а.env.exampleс заглушкой в git кладут.
Следующий вопрос: где живут данные базы и что с ними делают down и down -v.
.envпопал в git (git add .без.gitignore). Удалить файл новым коммитом мало: он остался в истории. Пароль надо считать утёкшим и менять.- Команду
docker compose configнельзя вставлять в чат и тикеты: она печатает пароль. .envне попадает в образ, если он в.dockerignore(урок 4.2).- Это компромисс, а не решение: пароль всё равно лежит открытым текстом. Нормальное хранение секретов разберём в теме 9, пока это осознанный долг.
Проверь понимание: в
.envпарольabc/def+1. Приложение пишетpassword authentication failedили странную ошибку разбора адреса. Почему?
Ответ
Пароль подставляется в DATABASE_URL, а символы /, @, :, ?, #, % в адресе имеют особый смысл (разделители). Разбор адреса ломается. Пароль надо генерировать без таких символов (openssl rand -hex 16) или кодировать в адресе процентами (%2F, %2B).
Тома и данные: где живёт база и что делают down и down -v
Контейнер по своей природе одноразовый: удалил, и всё внутри пропало (урок 4.3). Для базы данных это катастрофа, поэтому её файлы хранят в томе (volume), который живёт отдельно от контейнера.
Ноутбук и внешний диск. Ноутбук (контейнер) можно выбросить и купить такой же, а фотографии остаются на диске (том). Аналогия ломается на команде down -v: она выбрасывает и диск тоже.
В compose.yml у сервиса db строка pgdata:/var/lib/postgresql значит «подключи том pgdata в каталог /var/lib/postgresql контейнера». Имя тома объявлено в секции volumes:. Реальное имя в Docker получит префикс проекта: notes_pgdata. Поэтому это не тот том notes-pgdata (с дефисом), который ты создавал руками в уроке 4.4: Compose его не знает, и заметки из урока 4.4 в новом стенде не появятся.
Жизненный цикл стенда:
stateDiagram-v2
[*] --> Работает: up -d
Работает --> Остановлен: stop
Остановлен --> Работает: start
Работает --> Удалён: down (том notes_pgdata жив, данные целы)
Работает --> УдалёнСТомом: down -v (контейнеры и том удалены, данных нет)
Команды, которые ты будешь использовать каждый день:
| Команда | Что делает |
|---|---|
docker compose up -d |
создать и запустить недостающее, пересоздать изменившееся |
docker compose ps |
состояние сервисов и проверок здоровья |
docker compose logs db |
журнал сервиса (--tail=30 покажет только последние 30 строк) |
docker compose exec db psql ... |
выполнить команду внутри работающего контейнера |
docker compose stop db / start db |
остановить или запустить один сервис, не удаляя |
docker compose down |
удалить контейнеры и сеть, тома остаются |
docker compose down -v |
то же плюс удалить тома: данные пропадают |
docker compose config |
итоговый файл после подстановки |
Каталог данных в образе postgres:18. Образ postgres:18 хранит данные в /var/lib/postgresql (в версии 17 и старше путь был /var/lib/postgresql/data). Том монтируется в родительский каталог: так работает pg_upgrade при обновлении на новую мажорную версию. Если по привычке смонтировать том в .../data, PostgreSQL 18 будет писать данные в анонимный том, и после пересоздания контейнера они исчезнут.
Теперь на числах. Что делает образ при старте, видно в журнале docker compose logs db. Первый запуск на пустом томе: образ создаёт каталог, пользователя notes и базу notes (вывод длинный, в нём есть строки creating subdirectories ... ok и CREATE DATABASE). Повторный запуск на томе, где данные уже есть, начинается совсем иначе:
db-1 | PostgreSQL Database directory appears to contain a database; Skipping initialization
«Каталог уже содержит базу, пропускаю инициализацию». Вот почему переменные POSTGRES_USER, POSTGRES_DB и особенно POSTGRES_PASSWORD действуют только при самом первом создании. Если ты потом поменяешь пароль в .env, том об этом не узнает, и база останется со старым паролем. Это классическая поломка, её мы разберём в «Сломай и почини».
Прикинь сам: На стенде в томе 1000 заметок. Сколько заметок останется после
docker compose down, а сколько послеdocker compose down -v?
После down все 1000: том жив. После down -v ноль: том удалён вместе с контейнерами.
Осторожно, частое заблуждение: что down удаляет данные (нет, только контейнеры и сеть) и что down -v безобидно «почистит лишнее» (нет, удаляет базу без возможности отката). Флаг -v на общем стенде или проде без свежей резервной копии опасен.
Главное:
downудаляет контейнеры и сеть, но не тома, аdown -vстирает и тома со всеми данными без вопросов.
Иногда на своей машине нужно поменять стенд, не трогая основной файл.
Проверь понимание: ты поменял
POSTGRES_PASSWORDв.env, выполнилdocker compose up -d, приложение не подключается. Данные важны. Что делать:down -vили что-то другое?
Ответ
down -v удалит данные, поэтому нельзя. База хранит старый пароль в томе. Выход: вернуть старый пароль в .env либо сменить пароль внутри базы командой docker compose exec db psql -U notes -d notes -c "ALTER USER notes PASSWORD 'новый'" (внутри контейнера вход по сокету идёт без пароля) и держать .env в согласии с ней.
Несколько файлов: compose.override.yml
Иногда нужно временно поменять стенд, не трогая основной файл: подставить на своей машине другой порт, включить отладку. Для этого Compose умеет читать несколько файлов и накладывать один на другой.
Если рядом с compose.yml лежит compose.override.yml, Compose сам подхватит его и объединит с основным: значения из второго заменяют значения из первого, а новые ключи добавляются. Например, override с depends_on: db: condition: service_started заменит условие service_healthy, хотя compose.yml не менялся.
Разберём пример. Как узнать, что файлов два, если ты не знаешь про override? Команда docker compose ls показывает проекты и файлы, из которых они собраны:
NAME STATUS CONFIG FILES
notes running(2) /home/ubuntu/notes/compose.yml,/home/ubuntu/notes/compose.override.yml
Второй файл в колонке CONFIG FILES это и есть подсказка. docker compose config покажет уже объединённый результат, но не скажет, из какого файла пришла строка.
Прикинь сам:
compose.ymlзадаёт порт127.0.0.1:8080:8080, а приложение открыто на 9090. Сколько файлов участвует в настройке и как это узнать?
Как минимум два: рядом лежит compose.override.yml. Его покажет колонка CONFIG FILES в docker compose ls, а итог покажет docker compose config.
Осторожно: забытый override, из-за которого «в compose.yml всё правильно, а работает иначе». Всегда сверяй docker compose ls и docker compose config, если файл и поведение не совпадают. Это пригодится в «Сломай и почини».
Главное: Compose сам подхватывает
compose.override.ymlи накладывает его поверх основного файла, поэтому при странностях смотриdocker compose lsиconfig.
Теперь выясним, когда up -d пересоздаёт контейнер.
Проверь понимание: в
compose.ymlпорт"127.0.0.1:8080:8080", а приложение открыто на другом. С чего начать поиск?
Ответ
С docker compose ls (какие файлы участвуют) и docker compose config (итоговый порт). Скорее всего, есть compose.override.yml, который добавляет или заменяет порт.
Как Compose решает, что пересоздавать
Ты правишь файл и запускаешь docker compose up -d снова и снова. Если бы каждый запуск перезапускал всё, стенд то и дело терял бы соединения, а без пересоздания изменения не применялись бы. Compose должен отличать «ничего не менялось» от «поменялось вот это».
Прораб сверяет чертёж со стройкой. Если стена уже стоит как на чертеже, он её не трогает. Если на чертеже теперь окно шире, он ломает только эту стену. Аналогия ломается в одном месте: прораб видит стройку глазами, а Compose сверяет не вид, а «отпечаток» настроек, который он записал в контейнер при создании.
При создании контейнера Compose записывает в его метки (labels, пары «имя-значение», которые Docker хранит рядом с контейнером) хеш конфигурации сервиса: короткий отпечаток, вычисленный из всех настроек. При следующем up -d он:
- Читает
compose.ymlи подставляет значения из.env. - Считает новый отпечаток для каждого сервиса.
- Сравнивает его с записанным в уже существующем контейнере.
- Совпало: контейнер не трогает. Не совпало: останавливает старый, создаёт новый с теми же томами и запускает.
- Порядок при этом соблюдает по
depends_on: сначала база, потом приложение.
Для build: . есть особенность. Compose не следит за исходниками, он использует уже собранный образ, если тот есть. Поэтому после правки app.py надо явно добавить --build: без него получишь старый код в свежем контейнере.
Посмотрим на примере. Ты сменил в .env только APP_VERSION. Запрос плана без выполнения работы (--dry-run показывает, что Compose сделал бы, но ничего не делает):
Container notes-db-1 Running
Container notes-notes-1 Recreate
Container notes-notes-1 Recreated
Container notes-db-1 Waiting
Container notes-db-1 Healthy
Container notes-notes-1 Starting
Container notes-notes-1 Started
Читается так: db Running (не менялся, оставлен как есть), notes Recreate (у него изменилась переменная, а значит и отпечаток), перед стартом Compose снова подождал Healthy у базы. Данные не пострадали: том остался прежним.
Обрати внимание на порядок строк: Compose планирует действия с учётом зависимостей, поэтому даже при пересоздании одного приложения он заново убеждается, что база здорова. Если бы db была остановлена, в плане появилась бы строка Start для неё, и только потом Recreate для notes. Так up -d каждый раз приводит весь стенд к описанному в файле состоянию, а не только тот сервис, который ты правил.
Прикинь сам: Ты сменил в
.envтолькоAPP_VERSION. Сколько контейнеров из двух пересоздастdocker compose up -dи какой останется нетронутым?
Один: notes, у него изменилась переменная и отпечаток конфигурации. db не менялся и останется Running.
Осторожно: думают, что up -d всегда «применяет последнее». Он применяет изменения конфигурации, но не пересобирает образ сам. И думают, что restart перечитывает .env: docker compose restart только перезапускает существующий контейнер с прежними настройками, а новые значения подтянет именно up -d.
Главное: Compose сравнивает отпечаток конфигурации каждого сервиса и пересоздаёт только изменившиеся, но образ из
buildсам не пересобирает.
Разберём команды, которые управляют жизненным циклом стенда.
Проверь понимание: ты поправил строку в
app.pyи сделалdocker compose up -d. Изменение не видно. Почему и что делать?
Ответ
Настройки сервиса не менялись, поэтому Compose не пересоздавал контейнер, а образ уже собран и заново не собирается. Нужно docker compose up -d --build: он пересоберёт образ, отпечаток изменится, контейнер пересоздастся.
Жизненный цикл стенда: какая команда что делает
У стенда из нескольких контейнеров есть несколько состояний: создан, запущен, остановлен, удалён. Новичок часто путает команды и то удаляет больше, чем хотел, то не понимает, почему «остановил, а порт занят».
Кафе. Закрыть на перерыв: свет выключен, но столы и посуда на местах (stop). Открыть снова (start). Закрыть насовсем: вынести столы и снять вывеску (down). Закрыть насовсем и ещё выбросить склад продуктов (down -v). Аналогия не работает в одном: склад (том) выбрасывается только по явной просьбе, Compose сам его не тронет.
Все команды выполняются в каталоге с compose.yml и действуют на весь стенд целиком.
| Команда | Что делает | Данные в томах |
|---|---|---|
docker compose up -d |
создаёт недостающее (сеть, тома, контейнеры) и запускает в фоне | создаются, если не было |
docker compose ps |
показывает контейнеры стенда и их статус (Up, healthy) |
не трогает |
docker compose logs сервис |
печатает то, что сервис писал о себе | не трогает |
docker compose stop |
останавливает контейнеры (SIGTERM, потом SIGKILL), они остаются | целы |
docker compose start |
запускает остановленные контейнеры снова | целы |
docker compose down |
останавливает и удаляет контейнеры и сеть | целы |
docker compose down -v |
то же плюс удаляет именованные тома | удаляются |
Вот как это выглядит на деле. Ты выполнил docker compose down и снова up -d. Контейнеры создались заново (новые идентификаторы), но том notes_pgdata остался, поэтому база на месте и заметки живы. Если бы ты выполнил down -v, том исчез бы вместе с заметками, и up -d поднял бы пустую базу. Отличается всего один флаг.
Прикинь сам: Из семи команд таблицы (
up -d,ps,logs,stop,start,down,down -v) сколько стирают данные в томах?
Одна: down -v. stop и down оставляют тома целыми, а ps и logs ничего не меняют.
Осторожно, частое заблуждение: что stop и down это одно и то же. После stop контейнеры существуют и занимают свои имена; после down их нет. Второе: что down удаляет данные. Не удаляет, это делает только -v.
Главное:
stopостанавливает контейнеры и оставляет их,downудаляет контейнеры и сеть, а тома стирает толькоdown -v.
Когда стенд работает не так, нужен порядок диагностики.
Проверь понимание: перед вводом
docker compose down -vна чужом стенде что нужно проверить?
Ответ
Что в томах нет данных, которые жалко потерять: у базы это заметки, пользователи, история. -v стирает тома без вопросов и необратимо. Если данные нужны, сначала сделай дамп (pg_dump, урок 4.4) или хотя бы убери -v.
Логи и вход в контейнер: чем диагностировать стенд
Когда стенд из двух контейнеров работает не так, надо понять, кто виноват: приложение, база или связь между ними. Нужны инструменты, которые показывают, что происходит внутри, не разбирая систему на части.
Бортовой журнал и люк для техника. Журнал (логи) записывает, что происходило, пока ты не смотрел. Люк (exec) позволяет зайти внутрь и проверить руками. Аналогия ломается на том, что журнал контейнера пропадает вместе с ним: удалил контейнер, потерял и журнал.
Всё, что процесс в контейнере печатает в стандартный вывод (stdout) и вывод ошибок (stderr), Docker собирает и хранит. Поэтому приложения в контейнерах пишут логи на экран, а не в файл. Команды:
docker compose logs сервиспоказывает накопленное. Без имени сервиса вы увидите журналы всех вместе, каждая строка с префиксомdb-1 |илиnotes-1 |.- Ключ
--tail=30оставляет последние 30 строк,-f(follow) следит за новыми в реальном времени, выход по Ctrl+C. docker compose exec сервис командазапускает новый процесс внутри уже работающего контейнера. Так мы заходим вpsqlв базе или спрашиваем адрес у DNS.docker compose ps -aпоказывает все контейнеры проекта, в том числе остановленные и не запущенные (Created,Exited).
Теперь на числах. Порядок диагностики, когда curl вернул 503:
flowchart TD
A["1. docker compose ps<br>оба Up? db healthy?"] --> B["2. docker compose logs notes<br>WARNING, password, refused"]
B --> C["3. docker compose logs db<br>FATAL, Skipping initialization"]
C --> D["4. docker compose exec notes getent hosts db<br>видит ли приложение имя db?"]
D --> E["5. docker compose config<br>какие значения реально подставились?"]
От шага к шагу круг сужается: живы ли, что говорят, видят ли друг друга, с какими настройками.
От шага к шагу мы сужаем круг: сначала «живы ли», потом «что говорят», потом «видят ли друг друга», потом «с какими настройками». Например, Connection refused в логе приложения при работающем db говорит про порядок старта или неверный хост, а password authentication failed говорит, что до базы дошли, но пароль не подошёл.
Прикинь сам:
curlвернул 503, в логеnotesнаписаноpassword authentication failed, аdocker compose psпоказывает оба контейнераUp. Сколько шагов диагностики уже пройдено и что проверять дальше?
Три: ps, логи приложения и вывод об аутентификации. Дальше сравнивают POSTGRES_PASSWORD и DATABASE_URL в docker compose config: сеть и порядок старта в порядке.
Осторожно: ищут причину в неверном месте: читают лог приложения, а ошибка в логе базы, и наоборот. Вторая ошибка: делать docker compose exec в контейнер, которого нет (упавший контейнер надо смотреть через logs и ps -a).
Главное: диагностируй по порядку:
ps, логи приложения, логи базы, DNS (getent hosts), итоговыйconfig.
Проверь понимание: в логе
notesестьpassword authentication failed for user "notes", аdocker compose psпоказывает оба сервисаhealthy. Куда смотреть дальше?
Ответ
Пароль дошёл до базы и был отвергнут, значит сеть и порядок старта в порядке. Сравнить POSTGRES_PASSWORD и DATABASE_URL в docker compose config, потом в логе db найти строку Skipping initialization (том уже с данными, пароль остался старым). Проверка healthy тут не помогает: pg_isready не проверяет пароль.
Практика
Стартовое состояние: репозиторий ~/notes из урока 4.4 (приложение v4, Dockerfile, requirements.txt, db/schema.sql, .gitignore). Команды выполняются на машине, где стоит Docker.
Подготовка: убрать стенд из docker run
В уроке 4.4 у тебя работают контейнеры db и notes, они занимают порт 8080 и имя сети notes-net. Убери их, иначе новый стенд с теми же именами не поднимется. Разбор команд:
docker rm -f имя ...: удалить контейнер, даже если он работает (-fзначит «принудительно», то есть остановить и удалить). Печатает имена удалённых.2>/dev/null: выбросить сообщения об ошибках (например, если контейнера нет).2это поток ошибок,/dev/null«мусорная корзина».docker network rm notes-net: удалить сеть, которую ты создавал руками в уроке 4.3. Compose создаст свою с тем же именем и будет вести сама. Сеть удаляется, только если в ней нет контейнеров.docker ps -a: список всех контейнеров, включая остановленные.
cd ~/notes
docker rm -f notes db 2>/dev/null
docker network rm notes-net 2>/dev/null
docker ps -a
Том notes-pgdata из урока 4.4 не трогаем: он тебе ещё может понадобиться.
Что должно получиться:
notes
db
notes-net
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Как читать вывод: первые три строки это то, что удалено (два контейнера и сеть). Последняя строка это заголовок пустой таблицы: контейнеров не осталось. Если у тебя другие имена, посмотри их в docker ps -a и подставь свои.
Задание 1. База в Compose и healthcheck
Цель: описать db в compose.yml, вынести пароль в .env, увидеть переход starting -> healthy.
Предскажи: сколько секунд db будет в статусе starting при первом запуске: 0, около 5 или больше минуты? Изменится ли это при втором запуске, когда данные уже есть?
Ответ
Около 5 секунд, и оба раза одинаково. Инициализация базы занимает 1-2 секунды, но статус healthy ставит первая проверка, а она стартует только через interval (5 секунд). При втором запуске быстрее не становится по той же причине. Это замерено на реальном стенде: 5,2 секунды и в первый, и во второй раз.
Шаги.
- Создай
.env.exampleи.env, закрой.envот git. Разбор команд:
cat > файл <<'EOT' ... EOT: записать текст между метками в файл (heredoc, урок 1.6). Кавычки вокругEOTотключают подстановки внутри.PW=$(openssl rand -hex 16):$(...)выполняет команду и подставляет её вывод;openssl rand -hex 16выдаёт 16 случайных байт в виде 32 символов0-9a-f; результат кладём в переменнуюPW.sed "s/CHANGE_ME/${PW}/g" .env.example > .env:sedчитает.env.example, заменяет везде (g) словоCHANGE_MEна пароль, результат пишет в.env. Один пароль попадает и вPOSTGRES_PASSWORD, и вDATABASE_URL, поэтому они совпадают. Безsed -i, чтобы команда работала одинаково на Linux и macOS.chmod 600 .env: права «читать и писать только владельцу» (урок 1.3).echo ".env" >> .gitignore: дописать строку в конец файла (>>добавляет,>затирает).git check-ignore -v .env: показать, какое правило игнорирует файл.
cat > .env.example <<'EOT'
POSTGRES_PASSWORD=CHANGE_ME
DATABASE_URL=postgresql://notes:CHANGE_ME@db:5432/notes
APP_VERSION=
EOT
PW=$(openssl rand -hex 16)
sed "s/CHANGE_ME/${PW}/g" .env.example > .env
chmod 600 .env
echo ".env" >> .gitignore
git check-ignore -v .env
Результат (номер строки в .gitignore у тебя может быть другой):
.gitignore:2:.env .env
Читается как файл:строка:правило путь: правило .env в строке 2 файла .gitignore сработало для файла .env.
- Создай
compose.ymlс одним сервисом. Ниже комментарии объясняют каждую строку:
services:
db:
# готовый образ PostgreSQL, версия закреплена, latest не используем
image: postgres:18
environment:
# при первом старте образ создаст базу и пользователя с этими именами
POSTGRES_DB: notes
POSTGRES_USER: notes
# значение берётся из .env, в самом файле пароля нет
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
# том pgdata подключается в каталог данных postgres:18 (не в .../data)
- pgdata:/var/lib/postgresql
healthcheck:
# готова ли база принимать соединения; код выхода 0 значит «здорова»
test: ["CMD-SHELL", "pg_isready -U notes -d notes"]
interval: 5s # как часто проверять
timeout: 3s # сколько ждать ответа одной проверки
retries: 10 # неудач подряд до статуса unhealthy
start_period: 10s # первые 10 секунд неудачи не считаются
# поднимать после падения, но не после ручной остановки
restart: unless-stopped
volumes:
# объявляем том; Docker создаст его как notes_pgdata (префикс проекта)
pgdata:
networks:
default:
# явное имя сети вместо notes_default: к ней позже подключится мониторинг
name: notes-net
После блока: три секции верхнего уровня (services, volumes, networks), сервис один, db. Пароль в файле не написан, только ссылка ${POSTGRES_PASSWORD}. Секция networks: default: настраивает ту самую сеть по умолчанию, которую Compose создал бы сам, и переименовывает её.
- Запусти и следи за статусом.
docker compose psэто список контейнеров проекта;sleep 8ждёт 8 секунд.
docker compose up -d
docker compose ps
sleep 8
docker compose ps
Что должно получиться (первым идёт вывод up -d, время создания у тебя будет своё):
Network notes-net Creating
Network notes-net Created
Volume notes_pgdata Creating
Volume notes_pgdata Created
Container notes-db-1 Creating
Container notes-db-1 Created
Container notes-db-1 Starting
Container notes-db-1 Started
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
notes-db-1 postgres:18 "docker-entrypoint.s…" db 1 second ago Up Less than a second (health: starting) 5432/tcp
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
notes-db-1 postgres:18 "docker-entrypoint.s…" db 9 seconds ago Up 8 seconds (healthy) 5432/tcp
Как читать вывод: блок Creating/Created/Starting/Started это план Compose: сеть, том, контейнер, запуск. В таблице колонки NAME (имя контейнера, проект-сервис-номер), IMAGE, COMMAND (что запущено внутри), SERVICE (имя из compose.yml), CREATED, STATUS, PORTS. Главное в STATUS: сначала (health: starting), проверка ещё не прошла, потом (healthy). 5432/tcp без стрелки -> значит: порт объявлен образом, но на хост не опубликован.
Если
docker compose upзавершился ошибкой, вставь нейросети текст ошибки и свойcompose.yml(без пароля). Проверь ответ командойdocker compose config: нейросеть любит предлагать старый ключversion:и менять отступы наугад.
Объясни себе:
- Почему в
compose.ymlнет ни одного настоящего пароля, а контейнер всё же его получил? - Что делает
start_periodи что было бы без него на медленном диске?
Типичные ошибки:
yaml: line 9: found character that cannot start any token: в отступах табуляция, нужны пробелы.level=warning msg="The \"POSTGRES_PASSWORD\" variable is not set. Defaulting to a blank string.":.envлежит не рядом сcompose.ymlили команда запущена не из каталога проекта. Сделайpwdиls -a. Дальше база откажется стартовать с пустым паролем.network notes-net was found but has incorrect label com.docker.compose.network set to "" (expected: "default"): сеть осталась от ручногоdocker network createиз урока 4.3 (в начале Compose пишет ещё предупреждениеa network with name notes-net exists but was not created by compose). Удали её командойdocker network rm notes-netи повториup.
Задание 2. Добавляем приложение и порядок старта
Цель: подключить notes, дождаться базы через service_healthy, записать и прочитать заметку.
Предскажи: что покажет docker compose ps сразу после up -d: будет ли notes в списке и в каком состоянии? Что случилось бы с простым depends_on: [db]?
Ответ
С condition: service_healthy Compose сам подождёт db, и up -d вернётся, когда база healthy и notes создан. У самого notes будет своя проверка из Dockerfile: сначала health: starting, потом healthy. Без условия notes создался бы сразу вслед за db, приложение стартовало бы раньше готовой базы, и в его журнале появилось бы предупреждение об отказе соединения (см. «Сломай и почини», сценарий 2).
Шаги.
- Добавь сервис
notesвcompose.ymlпервым в секцииservices(db,volumesиnetworksостаются как в задании 1):
services:
notes:
# образ собирается из Dockerfile в корне репозитория (урок 4.2)
build: .
environment:
# хранилище в PostgreSQL (в образе по умолчанию HOST=0.0.0.0 и порт 8080)
STORE: postgres
# строка подключения из .env: хост «db» это имя сервиса
DATABASE_URL: ${DATABASE_URL}
# версия приложения; если в .env пусто, будет «dev»
APP_VERSION: ${APP_VERSION:-dev}
ports:
# только с этой машины; наружу порт не открыт
- "127.0.0.1:8080:8080"
depends_on:
db:
# ждать не просто запуска db, а статуса healthy
condition: service_healthy
restart: unless-stopped
db:
# ... без изменений
Новое здесь: build: . вместо image: (Compose соберёт образ сам из Dockerfile в текущем каталоге, образ получит имя notes-notes: проект и сервис), ports из теории и depends_on с условием.
- Соберём образ и запустим. Ключ
--buildзаставляет пересобрать образ перед запуском (в образе нуженpsycopgизrequirements.txt). Compose печатает много строк сборки; главное, что она закончилась и появились строки проHealthy.
docker compose up -d --build
docker compose ps
- Проверь приложение и базу. Разбор:
curl -sтихий режим;-iпечатает заголовки ответа;| head -n 1оставляет первую строку (статус);-X POST -d '...'отправляет POST с телом (JSON);docker compose exec db psql ...заходит в контейнерdbи выполняет SQL (-cэто «выполни команду», урок 4.4). Запросselect id, text from notes order by idвыводит заметки по порядку номеров. Паузаsleep 2нужна, потому что приложение стартует за секунду после контейнера.
sleep 2
curl -s -i http://127.0.0.1:8080/readyz | head -n 1
curl -s -X POST http://127.0.0.1:8080/notes -d '{"text":"первая заметка из compose"}'
echo
curl -s http://127.0.0.1:8080/notes
echo
docker compose exec db psql -U notes -d notes -c 'select id, text from notes order by id'
Что должно получиться:
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
notes-db-1 postgres:18 "docker-entrypoint.s…" db 6 seconds ago Up 5 seconds (healthy) 5432/tcp
notes-notes-1 notes-notes "python app.py" notes 6 seconds ago Up Less than a second (health: starting) 127.0.0.1:8080->8080/tcp
HTTP/1.1 200 OK
{"id": 1}
[{"id": 1, "text": "первая заметка из compose", "created_at": "2026-09-30T12:14:16.783985+00:00"}]
id | text
----+---------------------------
1 | первая заметка из compose
(1 row)
Как читать вывод: в ps две строки. notes появился после того, как db стал healthy, и в порте у него есть стрелка: 127.0.0.1:8080->8080/tcp. Через несколько секунд его статус тоже станет (healthy): так отработал HEALTHCHECK из Dockerfile (в нём проверка /healthz). HTTP/1.1 200 OK от /readyz значит «приложение видит базу». {"id": 1} это ответ на POST (код 201, номер новой заметки), в нём есть пробел после двоеточия: так отвечает наше приложение. Время в created_at у тебя будет своё. Таблица внизу это тот же результат, прочитанный прямо из базы.
Приложение видит базу по имени db: это DNS Docker в сети notes-net, тот же механизм, что в уроке 4.3. Проверить можно так: docker compose exec notes getent hosts db покажет IP базы (пример в теории).
Объясни себе:
- Почему в
DATABASE_URLхостdb, а неlocalhostи не127.0.0.1?
Типичные ошибки:
WARNING база пока недоступна: connection failed: connection to server at "127.0.0.1", port 5432 failed: Connection refusedв логеnotes(docker compose logs notes): вDATABASE_URLвместоdbстоитlocalhost. Внутри контейнераlocalhostэто сам контейнер.ModuleNotFoundError: No module named 'psycopg': образ собран до появленияrequirements.txt. Запустиdocker compose up -d --build.Bind for 127.0.0.1:8080 failed: port is already allocated: порт занят старым контейнером (проверьdocker ps, не остался лиnotesиз урока 4.4) или сервисом на хосте. Найди владельца:sudo ss -ltnp | grep 8080.- Пустой ответ
curlсразу послеup -d: приложение ещё не открыло порт. Подожди секунду и повтори.
Задание 3. Жизненный цикл: где живут данные
Цель: увидеть на практике разницу down и down -v, поведение приложения при остановленной базе.
Предскажи: после docker compose down и нового up -d заметка останется? А после down -v? Что вернёт /readyz, если остановить только db, и что вернёт /notes?
Ответ
После down заметка останется: том notes_pgdata не удалялся. После down -v том удалён, база создаётся заново пустой, GET /notes вернёт []. Если остановить db, /readyz вернёт 503 (приложение не достаёт базу), /healthz останется 200 (процесс жив), а GET /notes вернёт 500 {"error": "storage"}.
Шаги. Разбор новых частей: docker compose stop db останавливает только один сервис; -o /dev/null отбрасывает тело ответа, а -w "readyz=%{http_code}\n" печатает после ответа нужное поле, здесь HTTP-код; docker volume ls | grep notes_pgdata показывает, что том на месте (grep оставляет строки с этим словом).
# 1. пересоздаём контейнеры, том остаётся
docker compose down
docker volume ls | grep notes_pgdata
docker compose up -d
sleep 2
curl -s http://127.0.0.1:8080/notes
echo
# 2. останавливаем только базу
docker compose stop db
curl -s -o /dev/null -w "readyz=%{http_code}\n" http://127.0.0.1:8080/readyz
curl -s -o /dev/null -w "healthz=%{http_code}\n" http://127.0.0.1:8080/healthz
curl -s -w ' %{http_code}\n' http://127.0.0.1:8080/notes
docker compose start db
sleep 6
curl -s -o /dev/null -w "readyz=%{http_code}\n" http://127.0.0.1:8080/readyz
# 3. сносим вместе с данными
docker compose down -v
docker compose up -d
sleep 2
curl -s http://127.0.0.1:8080/notes
echo
Что должно получиться (без строк Container ... Stopping/Removing/Starting, которые Compose печатает при каждой команде):
local notes_pgdata
[{"id": 1, "text": "первая заметка из compose", "created_at": "2026-09-30T12:14:16.783985+00:00"}]
readyz=503
healthz=200
{"error": "storage"} 500
readyz=200
[]
Как читать вывод: первая строка показывает, что после down том жив. Следующая строка это заметка, пережившая down. Дальше три ответа при остановленной базе: приложение живо (healthz=200), но не готово (readyz=503) и не может отдать заметки (500). Потом база вернулась, и приложение само выздоровело (readyz=200), без перезапуска. В конце []: после down -v том пересоздан пустым.
Если не уверен, какая команда удаляет данные, спроси нейросеть про
downиdown -v. Но проверь на пустом тестовом стенде: нейросеть иногда путаетstop,downиdown -v.
Объясни себе:
- Почему
/healthzостался 200, а/readyzстал 503? Какую из двух проб надо использовать для перезапуска, а какую для снятия трафика?
Типичные ошибки:
no configuration file provided: not found: ты не в каталоге сcompose.yml. Перейди в~/notesили укажи файл флагом-f.dependency failed to start: container notes-db-1 is unhealthy:dbне прошла проверку за отведённые попытки. Смотриdocker compose logs db.
Задание 4. Шаг проекта: стенд «Заметки» в git
Цель: зафиксировать в репозитории compose.yml и .env.example, убедиться, что пароль не попал в историю.
Предскажи: что покажет git status после git add compose.yml .env.example .gitignore: попадёт ли в список .env? А что покажет docker compose config про пароль?
Ответ
.env в список не попадёт (он в .gitignore). docker compose config печатает итоговый файл уже с подставленным паролем: это удобно для отладки, но вывод нельзя вставлять в тикеты и чаты.
Шаги. Разбор: docker compose config --quiet проверяет файл и ничего не печатает при успехе, поэтому && echo ... (выполнить, если предыдущая команда успешна) даёт сообщение; git status --short краткий список изменений; git ls-files список файлов под контролем git, grep -c '^.env$' считает строки, равные .env; || true нужен потому, что grep -c при нуле совпадений возвращает код ошибки 1, а нам нужно просто увидеть 0.
docker compose config --quiet && echo "compose.yml корректен"
git add compose.yml .env.example .gitignore
git status --short
git ls-files | grep -c '^.env$' || true
git commit -m "Compose: Заметки и PostgreSQL, пароль в .env"
Что должно получиться:
compose.yml корректен
A .env.example
M .gitignore
A compose.yml
0
(после этого git commit печатает свою сводку).
Как читать вывод: буква в первой колонке git status --short это статус в индексе: A новый файл добавлен, M файл изменён. Файла .env в списке нет, а 0 в конце подтверждает, что среди файлов git его нет. Если попробовать git add .env, git откажет: The following paths are ignored by one of your .gitignore files: .env. Так и задумано, не используй -f.
Состояние проекта после урока: compose.yml (сервисы notes и db), .env.example, .env в .gitignore, сеть notes-net, том notes_pgdata, приложение на 127.0.0.1:8080, база только внутри сети. Открытый долг: пароль лежит в .env открытым текстом, для платформы он закроется в уроке 9.2. Сверить себя можно с эталоном.
Объясни себе:
- Если
.envслучайно попал в коммит, достаточно лиgit rm? Что делать с уже утёкшим паролем?
Типичные ошибки:
- Пароль в
DATABASE_URLне совпадает сPOSTGRES_PASSWORD: приложение получитpassword authentication failed. Генерируй оба значения одной подстановкой, как в задании 1. fatal: pathspec '.env.example' did not match any files: файл не создан или ты не в~/notes.
Сломай и почини
Скачай скрипт поломки и запусти нужный сценарий. Читать скрипт не нужно: цель в том, чтобы найти причину по симптомам. Перед этим закоммить рабочее состояние (задание 4). Скрипт запускается без sudo, из каталога ~/notes, и работает с твоим стендом (docker должен быть доступен тебе без sudo, как в предыдущих уроках).
curl -fsSL -o /tmp/break-4.5.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/4.5/break.sh
cd ~/notes
bash /tmp/break-4.5.sh 1 # номер сценария 1, 2 или 3
Сценарии: 1 - смена пароля на существующем томе, 2 - приложение стартует раньше готовой базы, 3 - порт 5432 занят. Вернуть стенд в рабочее состояние можно командой bash /tmp/break-4.5.sh fix. Она безопасна при повторном запуске. Сценарии 1 и 2 могут пересоздать том, данные стенда при этом потеряются: это учебный стенд. Для каждого сценария пройди четыре шага: симптом, гипотезы, проверки, исправление. Начни с сценария 1.
Симптом
- Сценарий 1: ты сменил пароль в
.envи выполнилdocker compose up -d. Оба контейнера вdocker compose pshealthy, ноcurl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/readyzдаёт 503, аGET /notesдаёт 500. - Сценарий 2: после
down -vиup -dв логеnotesесть предупреждение про недоступную базу, хотя потом всё как будто работает. - Сценарий 3:
up -dне запускает стенд,docker compose ps -aпоказывает контейнеры в состоянииCreated, а неUp.
Гипотезы
Запиши до проверок, что может быть причиной: неверный пароль, база не готова, база не та, сеть или DNS, порт занят, лишний конфигурационный файл.
Проверки
Все команды из каталога ~/notes.
docker compose ps -a
docker compose logs --tail=30 notes
docker compose logs --tail=30 db
docker compose config | grep -E 'PASSWORD|DATABASE_URL|condition|5432'
docker compose ls
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
sudo ss -ltnp | grep 5432
Разбор незнакомого: docker compose logs --tail=30 сервис последние 30 строк журнала сервиса; docker compose ls проекты и файлы конфигурации (мы делали это в теории про override); docker ps --format 'table ...' тот же список контейнеров, но только с нужными колонками; ss -ltnp (утилита из урока 2.1) кто слушает порты на хосте.
Исправление
Разбор всех сценариев
Сценарий 1. Новый пароль на старом томе. Симптом в логе notes (docker compose logs notes). Пароль внутри базы остался старым, а приложению теперь отдают новый:
notes-1 | WARNING база пока недоступна: connection failed: connection to server at "172.19.0.2", port 5432 failed: FATAL: password authentication failed for user "notes"
notes-1 | ERROR ошибка хранилища: connection failed: connection to server at "172.19.0.2", port 5432 failed: FATAL: password authentication failed for user "notes"
(IP-адрес у тебя будет другой). В логе db при перезапуске есть строка PostgreSQL Database directory appears to contain a database; Skipping initialization, а ниже сообщения об отказе входа с деталью Connection matched file ".../pg_hba.conf" line 128: "host all all all scram-sha-256". Обрати внимание: и db, и notes в ps остаются healthy. Проверка db не проверяет пароль, проверка notes смотрит /healthz, а не /readyz.
Причина: образ postgres применяет POSTGRES_PASSWORD только при создании пустого каталога данных. Том уже содержит базу со старым паролем, новое значение из .env она игнорирует. Исправление зависит от ценности данных.
Данные не нужны (учебный стенд): docker compose down -v && docker compose up -d. Пересоздаётся пустой том, и пароль применяется заново.
Данные нужны: не удалять том. Вариант первый, вернуть старый пароль в .env (скрипт сохранял копию, fix её вернёт). Вариант второй, поменять пароль внутри базы на тот, что в .env. Внутри контейнера вход в psql по сокету идёт без пароля:
docker compose exec db psql -U notes -d notes -c "ALTER USER notes PASSWORD 'значение POSTGRES_PASSWORD из .env'"
Приложение открывает соединение на каждый запрос, поэтому /readyz вернётся в 200 сразу, без перезапуска.
Сценарий 2. Приложение раньше базы. Скрипт создал compose.override.yml, который заменил в depends_on условие service_healthy на service_started (просто порядок запуска). Основной compose.yml не изменился, это видно по docker compose ls: в CONFIG FILES два файла, а docker compose config показывает condition: service_started. Симптом в логе notes:
notes-1 | WARNING база пока недоступна: connection failed: connection to server at "172.19.0.2", port 5432 failed: Connection refused
notes-1 | Is the server running on that host and accepting TCP/IP connections?
notes-1 | INFO started host=0.0.0.0 port=8080
Причина: контейнер db запущен, а PostgreSQL ещё инициализирует свежий том. Наше приложение не падает: оно записывает предупреждение и повторяет подключение при запросах, поэтому /readyz может секунду отвечать 503 и потом сам становится 200 (это делает версия v4 из урока 4.4). Другое приложение на его месте могло бы уйти в цикл перезапусков. Поэтому надёжный порядок задаёт Compose, а не терпение приложения. Исправление: удалить лишний compose.override.yml (или вернуть в нём condition: service_healthy), затем docker compose up -d. Проверка: docker compose down && docker compose up -d, в логе notes нет предупреждения, а notes создаётся после Healthy.
Сценарий 3. Порт 5432 занят. Скрипт запустил на хосте второй контейнер PostgreSQL break-4-5-pg, который опубликовал порт 5432, а compose.override.yml добавил db публикацию 5432:5432. Ошибка при up:
Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint notes-db-1 (4532b06c5f8b...): Bind for 0.0.0.0:5432 failed: port is already allocated
(формулировка зависит от версии Docker: в новых Linux-версиях может быть failed to bind host port ... address already in use, в старых Bind for 0.0.0.0:5432 failed: port is already allocated). Контейнеры notes-db-1 и notes-notes-1 в docker compose ps -a остаются в состоянии Created. Найди владельца порта: docker ps покажет контейнер break-4-5-pg с 0.0.0.0:5432->5432/tcp, а sudo ss -ltnp | grep 5432 на Linux покажет процесс docker-proxy или локальный PostgreSQL. Исправление в реальной жизни: убрать ports у db (приложению порт наружу не нужен) либо публиковать на другой порт, 127.0.0.1:15432:5432. Останавливать чужой сервис ради этого не нужно.
Возврат стенда. bash /tmp/break-4.5.sh fix удаляет контейнер break-4-5-pg, свой compose.override.yml, возвращает .env и поднимает стенд.
ИИ в помощь
Нейросеть хорошо переводит docker run в compose.yml и находит ошибки отступов, но версии образов, имена сервисов и пароли она подставляет сама. Общие правила: ИИ-помощник.
Задача: перевести команды запуска в compose.yml.
Переведи эти команды docker run в compose.yml для современного Docker Compose, без ключа version:
<вставь команды docker run>.
Пароль возьми из переменной ${POSTGRES_PASSWORD} из файла .env, не пиши его в файл.
Добавь healthcheck для PostgreSQL и depends_on с условием service_healthy для приложения.
Проверь ответ: выполни docker compose config: ошибки отступов и неизвестные ключи видны сразу. Сверь, что порт базы не опубликован, а порт приложения привязан к 127.0.0.1.
Задача: найти причину, почему приложение не видит базу.
Стенд из двух сервисов (notes и db). Приложение пишет connection refused.
Вот docker compose ps и логи notes: <вставь>.
Перечисли причины от самой вероятной и скажи, какой командой проверить каждую.
Проверь ответ: проверь каждую гипотезу командой, не принимай на веру. Типичная ошибка нейросети: предложить localhost в DATABASE_URL или down -v, который удалит данные.
Задача: проверить, что секреты не попали в git.
Вот мой .gitignore и вывод git status --short: <вставь>.
Попадёт ли .env в коммит? Что делать, если файл уже закоммичен?
Проверь ответ: сам выполни git check-ignore -v .env. Типичная ошибка нейросети: считать, что удаление файла новым коммитом убирает пароль из истории.
Словарик урока
| Термин | Простыми словами |
|---|---|
| Docker Compose | Инструмент, который читает compose.yml и поднимает несколько связанных контейнеров одной командой |
compose.yml |
Файл с описанием стенда: сервисы, тома, сети |
| Декларативный подход | Описываешь желаемый результат, а не последовательность команд |
| YAML | Формат текстового файла с настройками: ключ: значение, списки через -, вложенность отступами |
| Сервис (service) | Описание одного контейнера в compose.yml; его имя становится адресом в сети |
| Проект (project) | Группа контейнеров, сетей и томов одного запуска Compose; имя по умолчанию равно имени каталога |
| Сеть по умолчанию (default) | Сеть, которую Compose создаёт сам, если не описать свою |
| DNS Docker | Встроенный справочник имён: превращает имя сервиса в текущий IP контейнера |
| Публикация порта | Проброс порта контейнера на порт хоста; 127.0.0.1:8080:8080 значит «только с этой машины» |
| Healthcheck | Команда, которую Docker периодически запускает в контейнере; код 0 значит «здоров» |
healthy / unhealthy |
Итог проверок: последняя успешна или неудач подряд набралось retries |
depends_on |
Порядок запуска сервисов; с condition: service_healthy ждёт готовности |
pg_isready |
Утилита PostgreSQL: отвечает, готов ли сервер принимать соединения |
| Liveness и readiness | «Жив ли процесс» (перезапускать) и «готов ли работать» (пускать трафик) |
.env |
Файл ИМЯ=значение рядом с compose.yml; из него Compose подставляет ${ИМЯ} |
.env.example |
Заготовка .env без настоящего пароля; идёт в git |
Подстановка ${VAR:-x} |
Взять значение переменной или x, если она не задана |
| Том (volume) | Хранилище данных, которое живёт отдельно от контейнера |
down / down -v |
Удалить контейнеры и сеть / плюс тома (данные пропадают) |
restart: unless-stopped |
Политика: перезапускать контейнер после падения, кроме ручной остановки |
compose.override.yml |
Файл, который Compose сам накладывает поверх compose.yml |
Loopback (127.0.0.1) |
Адрес «только эта машина» |
Вопросы с собеседований
Раздел для повторения: ответь вслух, потом открой ответ. Вопросы с пометкой «часто» задают почти на каждом собеседовании по теме урока: начни с них. Короткие вопросы с пометкой «на скорость» тренируй на время: ответ за 30 секунд.
1. [junior] [часто] Зачем нужен Docker Compose, если есть docker run?
Ответ
Запуск стека через docker run превращается в длинные команды с портами, томами, сетями и переменными, которые легко забыть или перепутать. Compose описывает весь стек в файле compose.yml: сервисы, тома, сети, переменные. docker compose up -d поднимает всё сразу, docker compose down убирает. Файл лежит в git, значит стенд воспроизводим, а сервисы в одной сети видят друг друга по имени. Это инструмент для одного хоста, оркестратором вроде Kubernetes он не является.
Что хотят услышать: декларативное описание стека, воспроизводимость, файл в git, сеть и DNS по имени сервиса, границы применения (один хост).
Красный флаг: «Compose это то же самое, что Kubernetes».
2. [junior] [часто] Приложение в Compose падает при старте с connection refused к базе, хотя depends_on: db указан. Почему и как чинишь?
Ответ
depends_on без условия гарантирует только порядок запуска контейнеров. Контейнер базы уже running, а PostgreSQL ещё инициализируется. Я добавляю healthcheck на db (проверка pg_isready, которая отвечает «база принимает соединения») и depends_on с condition: service_healthy. Дополнительно приложение должно уметь повторять подключение.
Что хотят услышать: разница «запущен» и «готов», service_healthy, ретраи в приложении, проверка на уровне протокола (pg_isready), а не «контейнер жив».
Красный флаг: «поставлю sleep 30 в entrypoint».
3. [junior] [часто] Чем docker compose down отличается от down -v и когда второе опасно?
Ответ
down удаляет контейнеры и сеть, тома остаются, данные целы. down -v удаляет ещё и именованные тома, то есть саму базу. Опасно везде, где данные не восстановить: на общем стенде, на проде, если нет свежей резервной копии.
Что хотят услышать: том живёт независимо от контейнера, -v необратим, резервная копия перед деструктивными операциями.
Красный флаг: «down -v на всякий случай, чтобы чище».
4. [junior] [на скорость] Ты сменил пароль в .env и перезапустил стек, а приложение пишет password authentication failed. Что происходит?
Ответ
Образ PostgreSQL берёт POSTGRES_PASSWORD только при инициализации пустого каталога данных. Том уже с данными, поэтому старый пароль остался. Если данные не нужны, down -v и заново. Если нужны, ALTER USER внутри базы и синхронизация .env.
Что хотят услышать: инициализация только на пустом томе, лог Skipping initialization, осторожность с -v на данных, смена пароля именно в базе.
Красный флаг: «пересоздам контейнер», не понимая, что дело в томе.
5. [junior] Почему у db в Compose обычно нет ports, а у приложения порт публикуют как 127.0.0.1:8080:8080?
Ответ
Сервисы одной сети общаются по имени сервиса без публикации портов. Порт на хост нужен только тем, к кому ходят снаружи, и его лучше привязать к loopback (127.0.0.1, «только эта машина»): запись 8080:8080 откроет порт на всех интерфейсах. Docker при этом публикует порты в обход ufw. Публикация 5432 открывает базу всей сети и конфликтует с локальным PostgreSQL.
Что хотят услышать: DNS по имени в сети, минимальная поверхность, loopback, Docker и файрвол.
Красный флаг: «открою 5432 наружу, чтобы удобно было подключаться DBeaver’ом» без ограничений.
6. [middle] Коллега закоммитил .env с паролем базы в репозиторий. Твои действия?
Ответ
Считаю пароль скомпрометированным: сначала меняю его в базе (ALTER USER) и во всех местах использования. Потом убираю файл из индекса (git rm --cached .env), добавляю в .gitignore. Историю чищу (git filter-repo, инструмент, который переписывает историю коммитов) только если репозиторий приватный и это согласовано, но смена пароля важнее, чем чистка. Если репозиторий публичный или есть форки, файл уже считается утёкшим. Потом добавляю сканер секретов в CI (урок 3.4).
Что хотят услышать: ротация (замена пароля) первична, история git не удаляет утечку, сканер в CI, .env.example.
Красный флаг: «удалю файл новым коммитом, и всё».
7. [middle] После docker compose up -d контейнер db в статусе unhealthy. Как разбираешься?
Ответ
Смотрю docker compose logs db: причина обычно там (пустой пароль, неверный путь тома, нехватка диска). Потом запускаю проверку вручную: docker compose exec db pg_isready -U notes -d notes и смотрю её историю в docker inspect (поле State.Health). Проверяю, что параметры проверки не слишком жёсткие: start_period, retries, interval. Отдельно смотрю, нет ли пустой переменной из-за неверного .env (предупреждение variable is not set).
Что хотят услышать: логи первыми, ручной запуск проверки, docker inspect (State.Health), предупреждение про пустую переменную.
Красный флаг: «перезапущу и посмотрю, помогло ли».
8. [junior] [на скорость] Приложение внутри контейнера не может подключиться к localhost:5432, а с хоста psql -h localhost работает. Почему?
Ответ
localhost внутри контейнера это сам контейнер, а не хост и не соседний сервис. В сети Compose база доступна по имени сервиса: db:5432. То, что с хоста работает, значит, что порт где-то опубликован на хост, что как раз обычно нежелательно.
Что хотят услышать: у каждого контейнера своя сеть, DNS по имени сервиса, хост db.
Красный флаг: «пропишу IP контейнера в конфиг» (IP меняется при пересоздании).
9. [middle] После обновления образа PostgreSQL с 17 на 18 в Compose база оказалась пустой. Что могло пойти не так?
Ответ
В 17 том монтировали в /var/lib/postgresql/data, в 18 каталог данных стал внутри /var/lib/postgresql (с подкаталогом версии). Если оставить старый путь монтирования, новый образ запишет данные в анонимный том, и после пересоздания они пропадут. К тому же мажорная версия PostgreSQL не читает каталог предыдущей: нужен pg_upgrade (утилита обновления каталога данных) или дамп и восстановление (pg_dump/pg_restore, выгрузка базы в файл и загрузка обратно).
Что хотят услышать: несовместимость мажорных версий, pg_dump/pg_restore, проверка пути тома по документации образа, резервная копия до обновления.
Красный флаг: «просто поменяю тег образа».
10. [middle] Ты изменил одну переменную в .env и хочешь применить её. Что перезапустится и как проверить заранее?
Ответ
docker compose up -d пересоздаст только сервисы, у которых изменилась итоговая конфигурация. Проверяю заранее: docker compose config показывает итог подстановки, docker compose up -d --dry-run перечисляет планируемые действия (в выводе будет Recreate у изменившегося сервиса). Если поменялся пароль db, помню про проблему тома (вопрос 2).
Что хотят услышать: декларативность, пересоздание по разнице конфигурации, config, --dry-run, осторожность с секретами в выводе.
Красный флаг: «делаю down и up всегда, на всякий случай».
11. [middle] /readyz приложения отдаёт 503, а /healthz 200. Кого и как перезапускать, и что бы ты изменил в стенде?
Ответ
Процесс жив, но не готов: не видит базу. Перезапускать приложение бессмысленно, надо смотреть базу и сеть (docker compose ps, логи db, pg_isready). Проба «жив ли процесс» (liveness) не должна зависеть от внешних систем: иначе временный сбой базы вызовет лавину перезапусков. Проба «готов ли принимать работу» (readiness) должна, и по ней снимают трафик.
Что хотят услышать: разделение liveness и readiness, каскадные перезапуски, диагностика по слоям (приложение, сеть, база).
Красный флаг: «сделаю одну проверку /health, которая ходит во всё».
12. [middle] Как держать разные настройки Compose для разработки и прода?
Ответ
Общее описание держу в compose.yaml, различия выношу в отдельные файлы. Файл compose.override.yaml подхватывается автоматически, удобен для разработки: проброс портов, монтирование кода. Для прода указываю файлы явно: docker compose -f compose.yaml -f compose.prod.yaml up -d, поздние файлы дополняют и переопределяют ранние. Итог смотрю той же командой с теми же файлами: docker compose -f compose.yaml -f compose.prod.yaml config (без -f она покажет конфигурацию по умолчанию с dev override). Секреты в файлы не кладу, они идут из .env или из секретов окружения.
Что хотят услышать: override-файлы, -f несколько раз, docker compose config, секреты отдельно.
Красный флаг: Два отдельных независимых compose-файла с копипастой.
13. [junior] Чем restart: unless-stopped отличается от always и on-failure?
Ответ
no (по умолчанию) не перезапускает. on-failure перезапускает только при ненулевом коде выхода, можно ограничить числом попыток. always перезапускает контейнер всегда, в том числе после перезапуска Docker. unless-stopped ведёт себя так же, но если я остановил контейнер вручную, после перезапуска демона он не поднимется. Для сервисов обычно ставлю unless-stopped. Политика не заменяет healthcheck и не лечит причину падения: контейнер в бесконечном цикле падений лишь маскирует ошибку, смотреть надо docker logs.
Что хотят услышать: четыре политики, разница при ручной остановке, политика не лечит причину.
Красный флаг: Ставить always и считать, что сервис теперь надёжен.
Проверено на версиях
Прогонялось на Docker Desktop (macOS, arm64): Docker Engine 29.6.2, Docker Compose 5.3.1, образы postgres:18 (PostgreSQL 18.6) и python:3.13-slim (Python 3.13.15), psycopg 3.3.6, приложение app.py v4 из project/notes/versions/v4.py. Задания 1-4 и три сценария break.sh выполнены по-настоящему, shellcheck без замечаний. Для параллельного прогона имена ресурсов и порт хоста были другими (префикс t4-5, порт 18045): в тексте они заменены на notes и 8080, выравнивание таблиц пересчитано. На Linux-хосте с Docker Engine, Ubuntu 24.04 и 26.04 стенд не прогонялся: формулировка ошибки занятого порта для Linux взята из документации и помечена в разборе как зависящая от версии, sudo ss -ltnp на Linux не проверялся.
Итог урока: ты умеешь
- описать стенд из двух сервисов в
compose.ymlбез ключаversion:и прочитать YAML по отступам - объяснить, как сервисы находят друг друга по имени и почему
localhostвнутри контейнера не работает - настроить healthcheck
pg_isreadyиdepends_onсcondition: service_healthy - вынести пароль в
.env, держать.env.exampleв git и проверитьgit check-ignore - отличать
downотdown -vи объяснить, где живут данные PostgreSQL - объяснить, почему смена
POSTGRES_PASSWORDне действует на существующем томе, и починить - найти лишний
compose.override.ymlпоdocker compose lsиdocker compose config - диагностировать
connection refused,password authentication failedиport is already allocatedв Compose-стенде - публиковать порт только на loopback и не открывать базу наружу
Дальше: Урок 4.6: Compose: nginx и TLS перед «Заметками»
Проверь себя
Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.
Тест работает с включённым JavaScript.