Compose: база, приложение, тома и порядок старта

выкладка≈ 17 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Выкат на сервер — это одна команда: docker compose up -d. Она поднимает базу, хранилище файлов, одноразовую миграцию, API, сайт и прокси, через несколько секунд возвращается и печатает, что всё создано и запущено. А первый же запрос отвечает пятисоткой, потому что API успел сходить в базу раньше, чем та открыла порт. В прошлой статье собирался образ одного сервиса; теперь их несколько, и появляется то, чего у одиночного контейнера не было: общая сеть, порядок старта, чужая готовность и данные, которые обязаны пережить пересоздание контейнера. Разберём, что compose обещает на самом деле, чем «запущен» отличается от «готов» и где в действительности лежит то, что вы считаете базой.

Разделы темы:·

Compose-файл — не сценарий запуска, а описание того, что должно быть: какие сервисы, из каких образов, с какими переменными и томами. docker compose up -d приводит машину к этому описанию: чего нет — создаёт, что разошлось с файлом — пересоздаёт, а совпадающее не трогает. Поэтому выкат — это тот же up -d ещё раз, а не «снести и поднять»: у сервисов, чьё описание и образ не менялись, остаётся тот самый контейнер. Проверено на стеке этой статьи: после второго up -d у базы и приложения совпали и идентификатор контейнера, и время старта.

Имя сервиса — это адрес. Все контейнеры проекта попадают в одну сеть, и docker держит в ней DNS: строка подключения приложения к базе выглядит как postgres://app@db:5432/app_db, где db — имя сервиса из файла, а не хост машины. Наружу из этой сети не видно ничего, кроме того, что опубликовано строкой ports; про публикацию портов и про то, почему её нет у API, разобрано в «Порты: слушать нужное, публиковать одно».

Второе имя — имя проекта. Строка name: shop (а без неё — имя папки, из которой запущена команда) становится префиксом у всего: контейнер shop-db-1, сеть shop_default, том shop_pgdata. Отсюда предупреждение в боевом файле автора: имя проекта менять нельзя, потому что на сервере тома уже созданы под старым префиксом, и с новым compose заведёт другие — пустые. База, фото и сертификаты «пропадут» при первом же up, хотя на диске всё на месте.

Начнём с версии, которую пишут первой. Два сервиса, у приложения — depends_on с базой, чтобы та стартовала раньше.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Этот файл работает: up -d поднимет оба контейнера, приложение найдёт базу по имени db. И в нём уже есть ошибка, которая на ноутбуке не проявляется месяцами, а на сервере после перезагрузки встречает вас пятисоткой на главной. Ошибка — в строке depends_on, и чтобы её увидеть, нужно разобрать, что эта строка обещает.

Имя проекта по умолчанию берётся из имени папки и приводится к нижнему регистру: папка MyStack даёт контейнер mystack-a-1. Папка с русским именем даёт «project name must not be empty» — в имени проекта разрешены только латиница, цифры, дефис и подчёркивание.

depends_on: [db] обещает ровно одно: контейнер базы будет создан и запущен раньше контейнера приложения. Про то, что процесс внутри успел открыть порт, прочитать конфигурацию и принять первое соединение, не сказано ничего — и для базы это как раз неправда. При первом старте образ postgres выполняет initdb: создаёт кластер, накатывает начальные скрипты, перезапускает сервер. На пустом томе это несколько секунд, а приложение к этому моменту давно запущено.

Поэтому у зависимости есть условие, и их три. service_started — то самое поведение по умолчанию: дождаться, пока контейнер запустится. service_healthy — дождаться, пока проба готовности сервиса скажет «здоров»; о самой пробе — свой раздел дальше. service_completed_successfully — дождаться, пока контейнер выйдет с нулевым кодом; так ждут одноразовую джобу вроде миграции. Из условий compose строит граф и стартует параллельно всё, что друг от друга не зависит; круг в зависимостях он не запускает вовсе, а печатает dependency cycle detected: a -> b -> a.

Смоделируем этот граф. В модели ниже у базы есть проба, и она становится здоровой через четыре тика после старта; миграция — одноразовая, живёт два тика; приложение ждёт обоих. Вторым запуском — тот же стек, но у приложения условие «запущен». Прогноз: на каком тике стартует api в каждом случае и в каком состоянии будет база в этот момент?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

С условиями по готовности цепочка вытягивается в линию: база здорова на четвёртом тике, миграция стартует там же и выходит на шестом, приложение — следом за ней, на здоровой базе. Достаточно поменять одно условие, и линия распадается: api стартует на нулевом тике, одновременно с базой, потому что «запущен» она к этому моменту уже. Обратите внимание, что миграция при этом ждёт как ждала — условие ставится у того, кто ждёт, а не у того, кого ждут.

Теперь настоящий compose. Стек-миниатюра из трёх сервисов: база с пробой, миграция одной командой psql и приложение, которому подложили один файл вместо собранного образа. Прогноз: что команда напишет напротив каждого из трёх контейнеров?

запись с машины: macOS arm64, Docker Desktop 4.91.0 (engine 29.8.0, Docker Compose v5.5.1), образы postgres:18-alpine и node:24-slim; первый запуск на пустом томе, последний кадр вывода терминала, подсказки docker CLI выключены · 16 сентября 2026
Первый запуск стека-миниатюры на пустом томе, вывод терминала последним кадром
# Стек-миниатюра: база, одноразовая миграция и приложение.
# Образ приложения здесь не собирается — в готовый node подкладывается один файл.
name: shop

services:
  db:
    image: postgres:18-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app_db
      POSTGRES_PASSWORD: secret
    volumes:
      # том, а не папка контейнера: данные переживут пересоздание контейнера.
      # Точка монтирования — та, что объявил образ, а не .../data: у 18-й версии
      # PGDATA лежит в /var/lib/postgresql/18/docker
      - pgdata:/var/lib/postgresql
    healthcheck:
      # проба выполняется ВНУТРИ контейнера базы, поэтому psql-клиент здесь уже есть
      test: ['CMD-SHELL', 'pg_isready -U app -d app_db']
      interval: 2s
      timeout: 5s
      retries: 10
      # первый старт делает initdb — пока он идёт, отказы пробы не в счёт
      start_period: 20s

  migrate:
    image: postgres:18-alpine
    # одноразовая джоба: перезапускать вышедшую по нулю нечего
    restart: 'no'
    environment:
      PGPASSWORD: secret
    command:
      ['psql', '-h', 'db', '-U', 'app', '-d', 'app_db', '-c', 'create table if not exists notes (id int)']
    depends_on:
      db:
        condition: service_healthy

  api:
    image: node:24-slim
    restart: unless-stopped
    command: ['node', '/app/main.mjs']
    volumes:
      - ./main.mjs:/app/main.mjs:ro
    depends_on:
      db:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully

volumes:
  pgdata:
docker compose up -d

Три сервиса — три разных слова. У базы Healthy: у неё объявлена проба, и compose дождался, пока та ответит. У миграции Exited: одноразовый контейнер отработал и вышел с нулём, а ждали именно выхода. У приложения Started: пробы у него нет, и «готов» для него значит «процесс запущен». Время в кадре закрыто маской — от запуска к запуску оно скачет, — но соотношение стоит назвать: у сети и тома это сотые доли секунды, а у всех трёх контейнеров число почти одинаковое, и это не их работа. На машине автора весь up занимал около семи секунд, из которых шесть — общее ожидание базы.

В боевом файле та же конструкция, только ждать приходится четверых. Хранилище файлов со своей пробой, миграция схемы и одноразовая джоба, которая заводит бакет, — и всё это до того, как API примет первый запрос.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

up -d ждёт только те условия, которые кто-то объявил: сервис, которого никто не ждёт, команда не проверяет вовсе. Флаг --wait добавляет ожидание для всех сервисов проекта — команда не вернётся, пока каждый не станет готов. Ловушка в слове «готов»: для сервиса без пробы это по-прежнему «запущен», и в кадре --wait напротив нашего приложения стоит Healthy — там же, где без флага стояло Started. Проверено на том же стеке.

Поэтому --wait — не проверка выката. В скрипте выката автора после up -d идут обычные запросы curl к боевому домену: главная и страница приложения должны ответить 200, а /trpc/auth.me без токена — 401. Именно такая проверка ловит случай «контейнер жив, а отвечает не то», который ни одно условие compose поймать не может.

Повторный up -d запускает вышедшую одноразовую джобу заново — тот же контейнер стартует второй раз. Для миграции это и нужно, но джоба обязана переживать повторный запуск.

Вернём условие по умолчанию и посмотрим, что при этом видит приложение. Стек тот же, проба у базы на месте — её просто никто не спрашивает. Приложение здесь одноразовое: открыть соединение на порт базы и сказать, что получилось. Прогноз: что оно напечатает и с каким кодом выйдет?

запись с машины: macOS arm64, Docker Desktop 4.91.0 (engine 29.8.0, Docker Compose v5.5.1); стек поднят с нуля, последний кадр вывода терминала, подсказки docker CLI выключены · 16 сентября 2026
Условие service_started: приложение стартует сразу за контейнером базы
# Та же пара «база и приложение», но условие ожидания другое.
# Проба у базы объявлена — просто её здесь никто не спрашивает.
name: shop-early

services:
  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app_db
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U app -d app_db']
      interval: 2s
      timeout: 5s
      retries: 10
      start_period: 20s

  api:
    image: node:24-slim
    command: ['node', '/app/check.mjs']
    volumes:
      - ./check.mjs:/app/check.mjs:ro
    depends_on:
      db:
        # «запущен», а не «готов»: контейнер базы создан, процесс в нём стартовал —
        # и на этом ожидание кончилось
        condition: service_started
docker compose run --rm api

ECONNREFUSED — отказ в соединении. Это важная подробность: имя db разрешилось, значит сеть поднята и контейнер базы существует; отказал порт, который ещё никто не слушает. Ошибка в имени сервиса выглядит иначе — ENOTFOUND (проверено запросом на несуществующее имя). Первое означает «рано», второе — «не туда», и путать их дорого: во втором случае ждать можно вечно.

Дальше обычно говорят: ну и пусть падает, у сервиса же restart: unless-stopped, docker поднимет его снова — и рано или поздно приложение попадёт в открытый порт. Так и есть, но цена у этого лечения своя: docker перезапускает упавший контейнер не сразу, а с паузой, и пауза растёт с каждым падением (у контейнера, падавшего пять раз подряд, она выросла с четверти секунды до почти двух). Прогноз: кто раньше начнёт обслуживать запросы — приложение, которое падает и перезапускается, или то, которое дождалось готовности?

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Перезапуск проиграл ожиданию: третья пауза уже длиннее, чем базе осталось подниматься, и приложение начинает работать на седьмой секунде вместо пятой. На боевой машине разрыв больше: база после перезагрузки сервера или восстановления из бэкапа поднимается не пять секунд, а минуту, пауза докера к этому времени дорастает до своего потолка, и API лежит ещё долго после того, как всё готово. И это не считая журнала: три падения со стеком ошибки выглядят как авария, и настоящая авария в таком журнале от шума старта уже неотличима.

Условие service_healthy решает вопрос старта — и только его. База может уйти на перезагрузку через час после того, как всё поднялось, и приложение обязано пережить это без падения: соединение переоткрывается, запрос повторяется, ответ клиенту — честная ошибка, а не тишина. Разница в том, что повтор внутри кода — это поведение работающего сервиса, а условие в compose — гарантия порядка при старте. Одно другое не заменяет: пока приложение крутит повторы, оно не отвечает и на проверку здоровья, о которой речь в «Логи, по которым можно чинить».

Ждать чужой готовности скриптом внутри контейнера (wait-for-it и подобные) — та же ошибка, что и падать: ожидание уезжает в образ приложения, где о соседях знать не должны. Условие живёт в compose, где стек описан целиком.

Проба готовности — это команда, которую docker выполняет внутри контейнера, и решает не её вывод, а код выхода: ноль — здоров, любой другой — нет. Пробу своего API вы уже видели — она лежит не в compose, а в образе и едет вместе с ним; здесь тот же механизм со стороны docker, и следствий из него два. Первое: команда должна быть в образе. Привычная проба curl -f http://localhost:8000/health в контейнере с node:24-slim не сработает никогда — там нет ни curl, ни wget (проверено), оболочка вернёт 127, и контейнер навсегда останется больным. Для такого образа пробу пишут тем, что в нём есть: node -e "fetch(...)". Второе: форма записи. Список после CMD выполняется без оболочки, а CMD-SHELL отдаёт строку в sh — и только там работают трубы, && и подстановка переменных.

Остальные поля — про расписание: interval — как часто спрашивать, timeout — сколько ждать ответа, retries — сколько отказов подряд сделают контейнер больным, start_period — сколько времени в начале отказы не считать. Всё это описывает, когда спрашивать. А вот что именно вы спрашиваете, не описывает никто, и здесь проба умеет врать.

Спросим базу о готовности двумя путями сразу. Слева — так, как пишут в healthcheck: pg_isready внутри контейнера ходит через файловый сокет. Справа — так, как придёт приложение: тот же pg_isready, но по TCP. Первую пару вопросов задаёт скрипт, который образ postgres выполняет во время инициализации, вторую — тот же скрипт после того, как база поднялась. Прогноз: совпадут ли ответы?

запись с машины: macOS arm64, Docker Desktop 4.91.0 (engine 29.8.0), образ postgres:18-alpine; вывод сценария целиком · 16 сентября 2026
pg_isready через сокет и по сети в двух точках старта базы
#!/bin/sh
# Чем отвечает база на вопрос «ты готова?» в двух точках старта.
# Вопрос один и тот же — pg_isready, — но задаётся двумя путями: через сокет
# внутри контейнера (так пишут healthcheck) и по TCP (так приходит приложение).
# Первую пару задаёт init-probe.sh: образ postgres выполняет файлы из
# /docker-entrypoint-initdb.d на временном сервере, пока идёт инициализация.
# Вторую — этот сценарий, когда база поднялась по-настоящему.
set -e
docker rm -f proba-db >/dev/null 2>&1 || true
docker run -d --name proba-db -e POSTGRES_USER=app -e POSTGRES_DB=app_db -e POSTGRES_PASSWORD=secret \
  -v "$PWD/init-probe.sh:/docker-entrypoint-initdb.d/00-probe.sh:ro" postgres:18-alpine >/dev/null

# ждём настоящий сервер: временный слушает только сокет, и по сети его не видно
until docker exec proba-db pg_isready -h 127.0.0.1 -U app -d app_db >/dev/null 2>&1; do sleep 0.5; done

docker logs proba-db 2>&1 | grep '^ПРОБА' | sed 's/^ПРОБА //'
docker exec proba-db pg_isready -U app -d app_db >/dev/null 2>&1 &&
  echo "база поднялась | через сокет: код 0" || echo "база поднялась | через сокет: код $?"
docker exec proba-db pg_isready -h 127.0.0.1 -U app -d app_db >/dev/null 2>&1 &&
  echo "база поднялась | по сети:     код 0" || echo "база поднялась | по сети:     код $?"

docker rm -f proba-db >/dev/null
sh probe.sh

Во время инициализации проба отвечает «готова», а по сети базы нет. Так и задумано в образе: initdb заканчивается запуском временного сервера, на котором выполняются начальные скрипты, и этот сервер слушает только файловый сокет — в журнале контейнера есть строка listening on Unix socket и ни одной про TCP. Попади проверка здоровья в это окно, docker объявит контейнер здоровым, зависимые сервисы получат своё «готово» и придут к закрытому порту — ровно к тому ECONNREFUSED, от которого условие service_healthy и защищало.

Лечится это тем же правилом, что и любая проверка: спрашивать нужно тем же путём, каким придёт работа. Для базы в контейнере это pg_isready -h 127.0.0.1 -U app -d app_db — те же права, тот же порт, тот же стек протоколов. В боевом файле автора стоит проба через сокет, и в замерах она не соврала ни разу: первая проверка приходила примерно через пять секунд после старта контейнера, а окно инициализации на пустом томе держалось с 2,7-й по 3,3-ю секунду — к первому вопросу оно успевало закрыться (проверено 21.09.2026). Пять секунд, а не две, потому что внутри start_period у docker свой шаг — start_interval, по умолчанию пятисекундный; объявленный interval вступает в силу после периода запуска. Но это везение, а не защита: том больше, диск медленнее, начальных скриптов десяток, start_interval выставлен в секунду — и проба попадёт прямо в окно.

Пока идёт start_period, отказы не в счёт: контейнер стоит в состоянии starting, счётчик отказов держится на нуле. Проверено пробой, которая всегда отвечает отказом: при start_period в 12 секунд первый отказ засчитан на 12,2-й секунде, а больным контейнер стал на следующем тике, когда отказов подряд стало столько, сколько написано в retries. Одна удачная проба счётчик обнуляет.

А дальше начинается то, чего от docker обычно ждут, но не получают: больной контейнер он не перезапускает. В том же опыте контейнер простоял unhealthy десяток секунд, оставаясь running, и счётчик перезапусков не сдвинулся с нуля. Политика restart — про упавший процесс, проба — про ответ на вопрос; это две разные настройки, и вторая сама по себе не чинит ничего. Что она делает — так это держит зависимых: если база не становится здоровой, up заканчивается строкой вида dependency failed to start: container shop-db-1 is unhealthy и ненулевым кодом, а приложение не стартует вовсе. Это правильнее, чем поднять его к неработающей базе.

Проба пишется от имени того, кто будет пользоваться сервисом. У приложения это собственная ручка здоровья, и она обязана отвечать только за себя: ходить из неё в базу — значит уронить весь стек от одного медленного запроса.

Контейнер — вещь одноразовая: up после смены образа удаляет старый вместе со всем, что тот записал в свою файловую систему. Поэтому данные базы живут не в контейнере, а в томе — отдельном хранилище, которое docker ведёт сам. Строка - pgdata:/var/lib/postgresql связывает имя тома слева с путём внутри контейнера справа; всё, что процесс пишет по этому пути, оказывается в томе и переживает и пересоздание контейнера, и смену образа.

Где этот том физически? docker volume inspect shop_pgdata отвечает: /var/lib/docker/volumes/shop_pgdata/_data. На Linux-сервере это настоящий путь на диске — можно зайти и посмотреть. На ноутбуке с macOS или Windows такого пути нет: docker работает в виртуальной машине, и каталог находится внутри неё, а не в вашей файловой системе. Отсюда правило бэкапа: снимать не папку, а дамп базы её собственными средствами — об этом «Бэкапы, обновление, разбор падения».

И вот тут ждёт ловушка. Точку монтирования нельзя выбрать «на глаз»: она должна совпасть с тем каталогом, куда процесс действительно пишет. У образа postgres:18-alpine переменная PGDATA указывает на /var/lib/postgresql/18/docker, а сам образ объявляет VOLUME /var/lib/postgresql — и привычное по старым версиям монтирование в /var/lib/postgresql/data промахивается мимо обоих. Вот чем это кончается, проверено на трёх вариантах 21.09.2026:

Строка в volumesЧто оказалось в томе pgdata и что со стеком
pgdata:/var/lib/postgresqlкаталог 18/docker — данные базы; ровно то, чего и ждали
pgdata:/var/lib/postgresql/dataпусто, и стека нет: контейнер базы выходит с кодом 1. Образ 18-й версии сам ищет лишний том на этом пути и вместо старта печатает, что с 18+ данные лежат в каталоге с номером мажора
pgdata:/pgdata — любая точка мимо PGDATAпусто, а база работает. Данные ушли в анонимный том, который docker создал из VOLUME образа и назвал 64-значным хешем

Громкий промах — подарок: образ проверяет ровно тот путь, в который монтировали по привычке, и падает на первом же запуске, а не через полгода. Но стережёт он один путь, а класс ошибки остался прежним, и третья строка — про него. Там промах не виден ничем: база работает, данные на месте, перезапуск их не теряет — анонимный том никуда не девается, и пересоздание контейнера подключает тот же самый (проверено на up -d --force-recreate: имя анонимного тома не изменилось). Заметно станет в день переезда на другой сервер или при попытке снять бэкап тома: скопируется пустая папка. А в docker volume ls к этому времени будет лежать десяток томов с именами-хешами, и какой из них чей, уже не скажет никто.

Две команды, которые важно не перепутать. docker compose down останавливает и удаляет контейнеры и сеть проекта, а тома оставляет: после него том shop_pgdata на месте и с данными (проверено). docker compose down -v удаляет ещё и тома проекта — это и есть команда «стереть базу», и на сервере её набирают по памяти чаще, чем хотелось бы.

Кроме томов есть монтирование папки или файла с машины: - ./Caddyfile:/etc/caddy/Caddyfile:ro кладёт в контейнер конфигурацию прокси прямо из репозитория. Удобно — и ровно здесь случается авария, которую тяжело понять. Монтирование файла привязано к его inode, а rsync при выкате не правит файл на месте, а создаёт новый и переименовывает его поверх старого. Inode меняется, контейнер продолжает читать прежний файл, и правки конфигурации просто не доезжают — при том что выкат отработал без единой ошибки.

В скрипте выката автора из-за этого стоит отдельная проверка: md5sum файла внутри контейнера сравнивается с файлом на диске, и контейнер пересоздаётся только при расхождении. Второй выход — монтировать папку, а не файл: тогда меняется содержимое папки, а не её inode. Что именно этот конфиг делает, разбирает «Обратный прокси, HTTPS и заголовки».

Имя тома в docker volume ls начинается с имени проекта: shop_pgdata. Том с именем-хешем вместо имени — анонимный: его завёл образ, а не вы, и что в нём лежит, придётся выяснять через docker inspect контейнера.

Последнее, что связывает сервисы между собой, — переменные. Их в compose-файле два разных вида, и путать их дорого. ${MINIO_ROOT_PASSWORD} подставляет сам compose, когда читает файл: до того, как контейнер создан, и значение берётся из окружения той команды, которую вы набрали, или из файла .env. А environment: — это уже переменные внутри контейнера, которые увидит процесс.

Если подставлять нечего, compose подставит пустую строку. Он предупредит об этом строкой level=warning msg="The \"MINIO_ROOT_PASSWORD\" variable is not set. Defaulting to a blank string." — и продолжит: ни сборка, ни запуск не остановятся. В выводе выката, где рядом идут логи сборки образов, такая строка теряется мгновенно, а результат — сервис с пустым паролем или, как было у автора, образ, собранный с адресом сайта по умолчанию.

Откуда берётся .env — вторая половина той же истории. Compose ищет его рядом с compose-файлом, а у автора файл лежит в deploy/, тогда как .env.prod — в корне репозитория. Поэтому каждая команда выката пишется как docker compose --env-file .env.prod -f deploy/docker-compose.prod.yml …: забыли флаг — получили полный набор предупреждений и стек на пустых значениях. Пустая переменная в проде — сюжет уже знакомый: пустой SMTP_HOST не роняет API, а включает режим «ссылка печатается в журнал», и письма в проде не уходят ни к кому.

И третья ловушка — доллар, который нужен не compose, а оболочке внутри контейнера. Compose съедает $ всюду, в том числе в строке command: echo $PASS превратится в echo с пустой строкой ещё до запуска, потому что переменную ищут в окружении compose, а не контейнера. Оболочке доллар передают удвоением: $$PASS. Проверено на паре строк: при PASS: secret в environment первая печатает пустоту, вторая — secret.

Всё это разом видно в куске боевого файла — том самом, где заводится бакет для загруженных файлов. Одноразовая джоба ждёт хранилище по условию service_healthy, ходит в него паролем из окружения выката и передаёт этот пароль оболочке удвоенным долларом.

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Две детали этого куска стоят объяснения. Образы запинованы по контрольной сумме, а не по тегу: latest через полгода приедет другим мажором, упавшая джоба не выйдет с нулём — и API, который ждёт её по service_completed_successfully, не стартует вовсе. А политика доступа к бакету задана своим JSON с единственным разрешением s3:GetObject: готовая политика download выдала бы анонимам ещё и s3:ListBucket, то есть перечисление всех загруженных фотографий. Как файлы туда попадают, разбирает «Загрузка файлов».

У сервиса со сборкой есть третий вид переменных — build.args. Они доезжают только до сборки образа, и это не придирка к терминологии: переменные с префиксом NEXT_PUBLIC_ подставляются в бандл на сборке и превращаются в строковые литералы. Положить такую переменную в environment бесполезно: процесс её увидит, а собранный код — нет, там уже стоит значение, вшитое в момент сборки. Поменять адрес сайта или идентификатор счётчика можно только новым образом.

Проверять подстановку удобно командой docker compose config: она печатает файл с уже подставленными значениями и теми же предупреждениями. Секреты в этом выводе видны целиком, поэтому на общий экран его лучше не выводить.

  • Compose-файл описывает желаемое состояние стека, а up -d приводит машину к нему: совпадающие сервисы остаются тем же контейнером, а вышедшая одноразовая джоба запускается заново.
  • Имя сервиса — адрес в сети проекта, имя проекта — префикс контейнеров, сети и томов. Переименование проекта означает новые пустые тома.
  • depends_on без условия обещает только порядок запуска. Готовности ждут условиями service_healthy и service_completed_successfully, а условие ставится у того, кто ждёт.
  • Перезапуск упавшего приложения не заменяет ожидания: пауза между попытками растёт, и работать оно начинает позже, чем если бы честно дождалось, — да ещё и оставляет в журнале ложные тревоги.
  • Проба готовности — команда внутри контейнера, и решает её код выхода. Спрашивать она обязана тем же путём, каким придёт работа: pg_isready через сокет отвечает «готова» ещё на временном сервере initdb, когда по сети базы нет.
  • Данные живут в томе, и точка монтирования должна совпасть с тем каталогом, куда пишет процесс: промах оставляет именованный том пустым, а данные — в анонимном, и заметно это становится при переезде или бэкапе. Привычное .../data образ postgres 18 ловит сам и не стартует; на остальных путях предупреждать некому.

1/4В compose-файле у сервиса api написано depends_on: [db]. Что это гарантирует?

2/4У сервиса нет healthcheck. Что напишет напротив него docker compose up -d --wait?

3/4У postgres:18-alpine том объявлен строкой pgdata:/var/lib/postgresql/data. Что будет?

4/4В command сервиса написано sh -c 'echo $PASS', а в environment задано PASS: secret. Что напечатает контейнер?

Условия вместо порядка

Модель из раздела о порядке старта, но условий в ней нет: и миграция, и приложение ждут только «запущен», поэтому весь стек стартует на нулевом тике. Поправьте поле ждёт у migrate и api так, чтобы миграция шла на здоровой базе, а приложение — на здоровой базе и вышедшей миграции. Остальной код не трогайте.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Условие — это пара «чьё состояние» и «какое», и нужные состояния уже описаны в модели: у базы с пробой это здоров, у одноразовой джобы — вышел.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь вывод другой: база стартует на нулевом тике и становится здоровой на четвёртом, миграция стартует на четвёртом и выходит на шестом, приложение стартует на шестом — и в строке его старта стоит «база здоров». В compose-файле эти же три строки выглядят как condition: service_healthy у базы и condition: service_completed_successfully у миграции.

Когда контейнер становится больным

Функция статусы должна повторять логику docker: пока не кончился start_period, отказы не считаются и контейнер стоит в starting; после этого больным он становится, когда отказов подряд набралось retries; удачная проба обнуляет счётчик и делает контейнер здоровым. Сейчас она объявляет контейнер больным с первого же отказа и никогда не выздоравливает. Исправьте её, не трогая данные.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Нужен счётчик отказов подряд и проверка, кончился ли период запуска. Внутри периода ответы не меняют ничего: статус остаётся starting.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

На этих данных контейнер стоит в starting четыре тика подряд: три из них в периоде запуска, а на тике 3 отказ уже засчитан, но до retries ему не хватает второго. Дальше: здоровеет на тике 4, переживает одиночный отказ на тике 5, становится больным на тике 6 — когда отказов подряд стало два — и выздоравливает на тике 8. Настоящий docker при этом контейнер не перезапускает: он просто помечен больным, и зависимые сервисы до него не доходят.

Что переживёт остановку

Стек из статьи поднят на сервере, в базе — боевые данные. Что окажется на диске и в базе после каждого из четырёх действий по отдельности: docker compose down; docker compose down -v; правка строки name: с shop на shop-prod и up -d; замена строки тома на pgdata:/var/lib/postgresql/data и up -d?

down — контейнеры и сеть проекта удалены, тома целы: shop_pgdata остаётся на месте вместе с данными, и следующий up -d поднимает базу с ними же.

down -v — вместе с контейнерами удалены и тома проекта. Данных больше нет нигде, кроме бэкапа; следующий up запустит initdb на пустом томе, и вы получите чистую базу.

Новое имя проекта — compose заведёт новый набор: контейнеры shop-prod-*, сеть shop-prod_default и пустой том shop-prod_pgdata. Стек поднимется, база будет пустой, а старый том останется лежать нетронутым под прежним именем — его нужно либо переименовать, либо восстановить в новый из дампа.

Новая точка монтирования — база не поднимется вовсе: /var/lib/postgresql/data образ 18-й версии проверяет отдельно и, найдя там том, выходит с кодом 1, не тронув ни его, ни свои данные. Стек встанет на dependency failed to start: container shop-db-1 is unhealthy, база уйдёт в перезапуск по кругу — restart: unless-stopped поднимает её снова и снова, — а том shop_pgdata останется цел — верните строку обратно, и данные вернутся вместе с ней. Повезло: с любой другой точкой мимо PGDATA база поднялась бы пустой и молча.