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-топология: backend + db + frontend в одной сети, named volume для БД

Внутри compose сервисы видят друг друга по имени: `db`, `backend` и т.д.

Что compose делает молча, пока ты не спросил

В файле выше нет ни слова про сеть, но сеть создалась. Это не магия, а набор соглашений, привязанных к имени проекта. По умолчанию имя проекта - это имя каталога, в котором лежит compose-файл.

docker compose ls          # список проектов и их compose-файлов
docker network ls | grep default
# myapp_default   bridge   local     ← сеть создана под проект
docker volume ls | grep dbdata
# myapp_dbdata                       ← том тоже с префиксом проекта

Из этого вытекают три практических следствия:

  1. Все сервисы одного проекта попадают в одну сеть <project>_default и резолвят друг друга по имени сервиса. Внутри контейнера backend хост db - это адрес контейнера БД, а не «локалхост».
  2. Тома и контейнеры получают префикс проекта. Скопировал папку проекта в myapp-copy и поднял оттуда - получил второй набор томов и пустую БД. Симптом «данные пропали» на самом деле означает «данные в другом томе».
  3. Имя проекта можно задать явно: 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" # документируем порт, наружу не публикуем
Короткая форма `"5432:5432"` слушает `0.0.0.0`, то есть все интерфейсы. Хуже того, Docker пишет свои правила в iptables **до** правил UFW, поэтому «у меня закрыт файрвол» не спасает. На VPS публикуй только 80/443, а порты БД и кэша либо не публикуй вовсе, либо привязывай к `127.0.0.1`. Подробнее про firewall на сервере - в уроке про [деплой на VPS](./10-deploy-masterhost.md).

Обратная сторона той же медали: 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 всегда сильнее файла.

Команда печатает итоговый файл после подстановки всех переменных и слияния override-файлов. Если непонятно, откуда взялось значение или почему сервис поднимается не в той сети - смотри вывод `docker compose config`, а не сам файл. Есть ещё `docker compose config --services` и `--volumes` для быстрой сверки.

Про то, где хранить сами секреты (и почему 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: [db]` ждёт только запуска контейнера, а не готовности сервиса. Используй `condition: service_healthy` + healthcheck. Как писать сами пробы (HTTP, TCP, свой скрипт), почему `start_period` спасает сервисы с долгими миграциями и что делать, когда в образе нет `wget` - в уроке про [отладку и healthcheck](./08-debug-health-size.md).

Три условия 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
`docker compose restart backend` перезапускает **существующий** контейнер с его старыми переменными, портами и сетями. Любое изменение compose-файла или `.env` применяется только через `docker compose up -d`: compose сравнит желаемую конфигурацию с фактической и пересоздаст то, что разошлось.

Для разработки лучше не пересобирать образ на каждое сохранение, а смонтировать исходники 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 поменяли, БД не пускает

Реальная последовательность, которая съедает полдня у любой новой команды:

  1. В .env поменяли POSTGRES_PASSWORD с postgres на нормальный.
  2. docker compose up -d, и backend получает password authentication failed for user "postgres".
  3. Пароль в обоих местах одинаковый, глазами всё сходится.

Причина в том, что официальный образ 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  # проверить результат
Скалярные значения (`image`, `command`, `user`) заменяются. А `ports`, `volumes` и `expose` **складываются**: если в базовом файле уже есть `"8080:8080"`, а в override добавили `"3000:8080"`, контейнер получит оба маппинга, а не второй вместо первого. Чтобы убрать унаследованный порт, придётся выносить его из базового файла. Всегда сверяйся с `docker compose 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.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, вести прогресс.