docker-compose: поднимаем приложение вместе с базой
docker-compose: поднимаем приложение вместе с базой
docker-compose - это дирижёр. Он поднимает несколько сервисов одним файлом.
Пример compose
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: myapp
ports:
- "5432:5432"
volumes:
- dbdata:/var/lib/postgresql/data
backend:
build: ./backend
depends_on:
- db
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/myapp?sslmode=disable
ports:
- "8080:8080"
frontend:
build: ./frontend
ports:
- "8081:80"
volumes:
dbdata: {}
Запуск:
docker compose up -d --build
docker compose ps
docker compose logs -f backend
Что compose делает молча, пока ты не спросил
В файле выше нет ни слова про сеть, но сеть создалась. Это не магия, а набор соглашений, привязанных к имени проекта. По умолчанию имя проекта - это имя каталога, в котором лежит compose-файл.
docker compose ls # список проектов и их compose-файлов
docker network ls | grep default
# myapp_default bridge local ← сеть создана под проект
docker volume ls | grep dbdata
# myapp_dbdata ← том тоже с префиксом проекта
Из этого вытекают три практических следствия:
- Все сервисы одного проекта попадают в одну сеть
<project>_defaultи резолвят друг друга по имени сервиса. Внутри контейнераbackendхостdb- это адрес контейнера БД, а не «локалхост». - Тома и контейнеры получают префикс проекта. Скопировал папку проекта в
myapp-copyи поднял оттуда - получил второй набор томов и пустую БД. Симптом «данные пропали» на самом деле означает «данные в другом томе». - Имя проекта можно задать явно:
docker compose -p myapp up -dилиCOMPOSE_PROJECT_NAME=myappв.env. На сервере это обязательная гигиена, иначе после переименования каталога деплой поднимет пустой проект рядом со старым.
docker compose down --remove-orphans # убрать контейнеры, которых уже нет в файле
--remove-orphans нужен, когда сервис удалили из compose-файла: сам по себе его контейнер не исчезнет и продолжит занимать порт.
Порты: ports открывает наружу, а сеть работает и без него
Частая ошибка новичка - публиковать порты всем сервисам «чтобы работало». Внутри сети compose публикация не нужна вообще: backend дойдёт до db:5432 без единой строки ports.
services:
db:
image: postgres:16-alpine
ports:
- "127.0.0.1:5432:5432" # доступ только с самой машины
cache:
image: redis:7-alpine
expose:
- "6379" # документируем порт, наружу не публикуем
Обратная сторона той же медали: localhost внутри контейнера - это сам контейнер. Строка подключения postgres://postgres@localhost:5432 в контейнере backend даст connection refused, даже когда БД жива и здорова. Правильный хост - имя сервиса. Если же из контейнера нужно достучаться до процесса на хост-машине, используется специальное имя host.docker.internal.
Переменные: .env, environment и env_file - три разных механизма
Их постоянно путают, потому что все три «про переменные». Разница принципиальная:
Механизм Что делает Видно ли внутри контейнера
──────────── ───────────────────────────────────── ──────────────────────────
.env рядом подставляет ${VAR} в текст самого нет, если не подставил
с compose compose-файла (интерполяция) руками в environment
environment: передаёт переменную в контейнер да
env_file: читает файл и передаёт всё в контейнер да
То есть .env - это шаблонизатор compose-файла, а не способ передать конфиг в приложение. Отсюда классический баг: в .env лежит JWT_SECRET, внутри контейнера его нет, потому что в environment его никто не прописал.
services:
backend:
build: ./backend
environment:
PORT: ${PORT:-8080} # значение по умолчанию, если переменной нет
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} # падаем сразу
env_file:
- .env.common # базовый набор
Приоритет при конфликте (от сильного к слабому): флаг docker compose run -e VAR=..., затем environment: в сервисе, затем env_file:, и в самом низу ENV из Dockerfile. То есть точечное переопределение в environment всегда сильнее файла.
Про то, где хранить сами секреты (и почему environment светит их в docker inspect), - следующий урок про переменные, тома и сети.
depends_on и healthcheck
depends_on гарантирует порядок запуска, но не то, что сервис готов. PostgreSQL может стартовать, но ещё не принимать соединения.
services:
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
backend:
build: ./backend
depends_on:
db:
condition: service_healthy
Три условия depends_on
У condition три значения, и третье решает задачу миграций:
services:
migrate:
build: ./backend
command: ["/app/migrate", "up"]
depends_on:
db:
condition: service_healthy
backend:
build: ./backend
depends_on:
migrate:
condition: service_completed_successfully # ждём код выхода 0
db:
condition: service_healthy
service_started контейнер запущен (дефолт, почти бесполезен)
service_healthy healthcheck вернул healthy
service_completed_successfully контейнер завершился с кодом 0 (миграции, seed)
Если миграция упала, backend не поднимется вообще - это лучше, чем сервис, работающий на схеме старой версии.
Почему depends_on не отменяет retry в коде
depends_on работает один раз, на старте. Но БД может уйти в рестарт в середине рабочего дня, а на проде переключиться на реплику. Приложение обязано уметь переподключаться само - и на старте, и в процессе работы.
// Ждём БД с экспоненциальной задержкой вместо мгновенного падения
func waitForDB(ctx context.Context, dsn string, attempts int) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
// Пул закрываем, если так и не дождались: за ним стоят горутина
// обслуживания соединений и захваченные ресурсы, а наружу мы отдадим
// только ошибку - последняя ссылка на пул потеряется.
ok := false
defer func() {
if !ok {
db.Close()
}
}()
delay := 200 * time.Millisecond
for i := 1; i <= attempts; i++ {
if err = db.PingContext(ctx); err == nil {
ok = true
return db, nil
}
slog.Warn("db not ready", slog.Int("attempt", i), slog.String("err", err.Error()))
time.Sleep(delay)
delay *= 2
}
return nil, fmt.Errorf("db unreachable after %d attempts: %w", attempts, err)
}
<?php
declare(strict_types=1);
// Тот же приём: пробуем подключиться, увеличивая паузу между попытками
function waitForDb(string $dsn, string $user, string $password, int $attempts = 8): PDO
{
$delayMs = 200;
$last = null;
for ($i = 1; $i <= $attempts; $i++) {
try {
return new PDO($dsn, $user, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
} catch (PDOException $e) {
$last = $e;
error_log(sprintf('db not ready, attempt %d: %s', $i, $e->getMessage()));
usleep($delayMs * 1000);
$delayMs *= 2;
}
}
throw new RuntimeException('db unreachable after '.$attempts.' attempts', 0, $last);
}
Такой код полезен вдвойне: он делает depends_on необязательным для корректности и убирает целый класс флаки-падений в CI, где сервисный контейнер БД стартует медленнее раннера.
Почему изменения в коде не попали в контейнер
Самая частая жалоба на compose звучит так: «я поправил handler, перезапустил, ничего не изменилось». Причина в том, что up не пересобирает образ, если он уже существует.
docker compose up -d # образ есть → берём как есть, код старый
docker compose up -d --build # пересобрать перед запуском
docker compose build --no-cache api # игнорировать кэш слоёв целиком
docker compose up -d --force-recreate # пересоздать контейнер (новый env, тот же образ)
docker compose up -d --pull always # подтянуть свежий образ из registry
docker compose up -d --no-deps api # поднять только api, не трогая db
Три разных «ничего не изменилось» и их лечение:
Симптом Причина Лечение
──────────────────────────────────────── ──────────────────── ─────────────────────
Правки кода не видны образ не пересобран up -d --build
Новая переменная в .env не подхватилась контейнер не пересоздан up -d (не restart!)
Обновил тег latest в registry, тот же код образ закэширован up -d --pull always
Для разработки лучше не пересобирать образ на каждое сохранение, а смонтировать исходники bind mount и держать внутри live-reload. Как это устроено - в уроке про тома и bind mount, а сама сборка Go-образа разбиралась в multi-stage.
stop, down, down -v: что именно теряется
Команда Контейнеры Сеть Именованные тома Образы
────────────────── ────────── ──── ──────────────── ──────
stop остановлены живёт целы целы
down удалены удалена целы целы
down -v удалены удалена УДАЛЕНЫ целы
down --rmi local удалены удалена целы удалены собранные
stop плюс start - самый быстрый способ освободить ресурсы и вернуться к тому же состоянию. down безопасен для данных, пока тома именованные (dbdata), а не анонимные. А вот down -v - это «удали базу»: полезно, когда нужна чистая схема, и катастрофа, когда команду скопировали из статьи на прод-сервере.
Разбор случая: пароль в .env поменяли, БД не пускает
Реальная последовательность, которая съедает полдня у любой новой команды:
- В
.envпоменялиPOSTGRES_PASSWORDсpostgresна нормальный. docker compose up -d, иbackendполучаетpassword authentication failed for user "postgres".- Пароль в обоих местах одинаковый, глазами всё сходится.
Причина в том, что официальный образ postgres применяет POSTGRES_PASSWORD только при инициализации пустого каталога данных. Том dbdata уже существует, инициализация не повторяется, в БД живёт старый пароль. Compose тут ни при чём.
docker compose exec db env | grep POSTGRES_PASSWORD # новая переменная на месте
docker compose logs db | grep -i "database system is ready"
# в логах нет строк про initdb → значит инициализация не повторялась
# Вариант dev: снести том и переинициализировать
docker compose down -v && docker compose up -d
# Вариант prod: сменить пароль внутри живой БД
docker compose exec db psql -U postgres -c "ALTER USER postgres PASSWORD 'new-secret';"
Тот же механизм объясняет, почему скрипты из /docker-entrypoint-initdb.d «не выполняются»: они запускаются один раз на пустом томе.
Один проект, разные окружения: override и профили
Compose автоматически сливает docker-compose.yml с docker-compose.override.yml, если тот лежит рядом. Базовый файл держит общую топологию, override - dev-удобства.
# docker-compose.override.yml - применяется автоматически, в Git обычно не коммитят
services:
backend:
volumes:
- ./backend:/app # исходники с хоста
environment:
LOG_LEVEL: debug
ports:
- "2345:2345" # порт дебаггера
Для прода набор файлов задаётся явно, и порядок важен: каждый следующий файл переопределяет предыдущий.
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
docker compose -f docker-compose.yml -f docker-compose.prod.yml config # проверить результат
Профили решают другую задачу - сервисы, которые нужны не всегда:
services:
backend:
build: ./backend
adminer:
image: adminer
profiles: ["tools"] # без --profile tools не поднимается
seeder:
build: ./backend
command: ["/app/seed"]
profiles: ["tools"]
docker compose up -d # только backend
docker compose --profile tools up -d # backend + adminer + seeder
Так один compose-файл обслуживает и разработчика с Adminer, и сервер, где лишний веб-интерфейс к БД - это дыра в безопасности.
Полезные команды compose
docker compose up -d --build # поднять с пересборкой
docker compose down # остановить и удалить контейнеры
docker compose down -v # + удалить volumes (осторожно!)
docker compose ps # статус сервисов
docker compose logs -f backend # логи конкретного сервиса
docker compose exec db psql -U postgres # зайти в БД
docker compose restart backend # перезапустить один сервис
Мини-задание
- Создай
docker-compose.ymlсdb(postgres) иbackend(любой HTTP-сервер) - Добавь healthcheck для postgres (
pg_isready) - Убедись, что backend стартует только после готовности БД
- Проверь через
docker compose ps- оба сервисаhealthy/running - Вынеси пароль БД в
.envи подставь через${POSTGRES_PASSWORD:?not set}- убедись, что без переменной compose падает с внятной ошибкой - Сравни
docker compose configс исходным файлом: найди, где подставились значения - Убери
portsу сервисаdbи проверь, что backend всё равно достаёт БД по имениdb - Поменяй значение в
.env, сделайdocker compose restart backendи убедись, что переменная НЕ изменилась, а послеup -dизменилась - Добавь сервис
adminerсprofiles: ["tools"]и проверь, что обычныйupего не поднимает
Итог
- Compose поднимает несколько сервисов одним файлом. Сервисы видят друг друга по имени.
depends_on+condition: service_healthy- правильный способ управлять порядком запуска.docker compose down -vудаляет данные - используй осознанно.
Типичная ошибка
Забывать depends_on с healthcheck и получать «connection refused» при старте backend. Или использовать docker compose down -v и удалять данные БД, которые нужны.
Мини-практика (10-15 минут)
Создай compose-файл с тремя сервисами: db (postgres), adminer (веб-интерфейс для БД, образ adminer, порт 8081:8080) и backend (nginx:alpine на порту 8080:80). Подними всё через docker compose up -d. Открой localhost:8081 - увидишь Adminer. Подключись к базе (хост: db, порт 5432). Посмотри логи каждого сервиса. Потом docker compose down.