Learning Platform
Глоссарий Troubleshooting
Урок 14.04 · 24 мин
Средний
dockerpostgresmigrationsdata-engineering

Init scripts и миграции

Когда впервые запускаешь Postgres-контейнер, может возникнуть желание положить туда DDL-скрипт, который создаст схему, таблицы, начальные данные. Официальный образ это поддерживает: есть специальная директория /docker-entrypoint-initdb.d/, скрипты из неё автоматически выполняются при первом запуске. Кажется удобным.

Но как только проект уходит в реальную разработку, init scripts превращаются в anti-pattern. Этот урок — про то, как правильно ими пользоваться (краткие seed data), и почему миграции должны идти через отдельный контейнер с Flyway / Liquibase / Alembic.


Первый скрипт: shebang, chmod +x, аргументы

Как работает /docker-entrypoint-initdb.d/

Внутри образа postgres живёт скрипт docker-entrypoint.sh. При первом запуске (когда PGDATA пустой) он:

  1. Запускает initdb для инициализации кластера.
  2. Стартует временный Postgres только на UNIX-сокете (без TCP) — пока никто извне не подключится.
  3. Создаёт POSTGRES_USER, POSTGRES_DB, выставляет POSTGRES_PASSWORD.
  4. Если в /docker-entrypoint-initdb.d/ есть файлы — выполняет их по алфавиту:
    • *.sql — через psql -f file.sql.
    • *.sql.gz — распаковка + psql.
    • *.sh — выполняются как shell-скрипты (имеют доступ к переменным POSTGRES_USER, PGDATABASE).
  5. Останавливает временный сервер, перезапускает Postgres на TCP 0.0.0.0:5432.

Ключевой момент: init scripts выполняются только при первой инициализации. Если в data dir уже что-то есть (PG_VERSION файл присутствует) — скрипты пропускаются.

Жизненный цикл init scripts
docker run первый разdata volume пустой, нет PG_VERSION файла
initdbСоздаём структуру кластера PG, временный сервер на UNIX socket
POSTGRES_DB / USER создаютсяpostgres-bootstrap читает env, выполняет CREATE ROLE и CREATE DATABASE
скрипты из initdb.d выполняютсяpsql -f 01-schema.sql, psql -f 02-seed.sql, bash 03-extension.sh — в алфавитном порядке
docker stop && docker startdata volume сохранился, PG_VERSION на месте
init scripts ПРОПУСКАЮТСЯEntrypoint видит непустой data dir, переходит сразу к запуску postgres. Любые изменения в initdb.d не применятся!

Правильный use case: seed data для dev

Используй init scripts для одноразовой инициализации: создание роли только для read-only пользователя, установка расширений, начальные справочные данные (countries, currencies).

Пример. Файл init/01-extensions.sql:

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
CREATE EXTENSION IF NOT EXISTS pgcrypto;

Файл init/02-read-only-user.sql:

CREATE ROLE readonly LOGIN PASSWORD 'readonly';
GRANT CONNECT ON DATABASE warehouse TO readonly;
GRANT USAGE ON SCHEMA public TO readonly;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT ON TABLES TO readonly;

Файл init/03-seed.sh:

#!/bin/bash
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
  CREATE TABLE IF NOT EXISTS countries (
    code CHAR(2) PRIMARY KEY,
    name TEXT NOT NULL
  );

  INSERT INTO countries (code, name) VALUES
    ('RU', 'Russia'),
    ('US', 'United States'),
    ('DE', 'Germany')
  ON CONFLICT (code) DO NOTHING;
EOSQL

Запуск:

docker run -d \
  --name pg \
  -e POSTGRES_USER=de \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_DB=warehouse \
  -p 5432:5432 \
  -v pg-data:/var/lib/postgresql/data \
  -v $(pwd)/init:/docker-entrypoint-initdb.d:ro \
  postgres:16

Bind-mount :ro (read-only) — хорошая практика, защищает от случайной записи в init-директорию.

В compose это выглядит так:

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: de
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: warehouse
    volumes:
      - pg-data:/var/lib/postgresql/data
      - ./init:/docker-entrypoint-initdb.d:ro
    ports:
      - "5432:5432"

volumes:
  pg-data:

Anti-pattern: миграции в init scripts

Junior часто кладёт в /docker-entrypoint-initdb.d/ файлы вроде migrations/V001__create_users.sql, V002__add_email_index.sql. Логика: «при запуске Postgres всё применится». Не применится.

Что произойдёт:

  1. Первый раз docker compose up — все 30 миграций применились. Хорошо.
  2. Разработчик добавил V031__add_phone.sql. docker compose upмиграция не применилась, потому что data dir уже инициализирован. Init scripts пропущены.
  3. Разработчик в недоумении удаляет volume (docker volume rm pg-data). Запускает снова — все 31 миграций применились. Но удалили все данные.

Это не теория, я видел это в реальных проектах. Команда из 5 человек, через 3 месяца разработки боятся docker compose down -v, потому что «вдруг не поднимется обратно». Это не нормально.

DANGER

Никогда не используй /docker-entrypoint-initdb.d/ для миграций, которые добавляются со временем. Только для первичной инициализации, которая никогда не меняется (extensions, базовые seed-данные, role’и).


Правильно: отдельный контейнер с миграциями

Решение — выделить миграции в отдельный шаг pipeline’а. Это может быть:

  • Flyway — Java-based миграции, формат V<version>__<name>.sql. Стандарт в Java/Kotlin-мире, но отлично работает с любым языком приложения.
  • Liquibase — то же, но с поддержкой XML/YAML/JSON-формата миграций (если не любишь сырой SQL).
  • Alembic — Python-нативный, идёт со SQLAlchemy. Стандарт для Python-приложений.
  • golang-migrate — Go, простой CLI, формат 001_create_users.up.sql / 001_create_users.down.sql.
  • db-migrate, knex, sequelize — для Node.js.

Все они работают по одной схеме: ведут таблицу schema_migrations (или flyway_schema_history) в самой базе, при запуске проверяют, какие миграции уже применены, применяют недостающие.

Пример с Flyway в compose:

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: de
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: warehouse
    volumes:
      - pg-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U de -d warehouse"]
      interval: 5s
      timeout: 3s
      retries: 5

  flyway:
    image: flyway/flyway:10
    command: -url=jdbc:postgresql://postgres:5432/warehouse -user=de -password=secret -connectRetries=10 migrate
    volumes:
      - ./migrations:/flyway/sql:ro
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  pg-data:

Структура файлов:

project/
  docker-compose.yml
  migrations/
    V001__create_users.sql
    V002__add_email_index.sql
    V003__add_phone.sql

Что происходит при docker compose up:

  1. Postgres стартует.
  2. Healthcheck pg_isready крутится 5-10 секунд, пока Postgres готов.
  3. Flyway стартует (condition: service_healthy), подключается к Postgres.
  4. Flyway смотрит flyway_schema_history, видит уже применённые миграции, применяет недостающие в правильном порядке.
  5. Flyway выходит со статусом 0 (успех) или ненулевым (ошибка миграции).

Когда добавляешь V004__add_orders.sqldocker compose up flyway (или docker compose up) применит только её. Data dir не трогается, существующие данные сохранены.

Migration container pattern
docker compose upЗапуск всех сервисов в правильном порядке через depends_on
postgres стартуетПостгрес поднимается, начинается healthcheck pg_isready
healthy через 5-10spg_isready возвращает success — Postgres готов принимать соединения
flyway-контейнер стартуетdepends_on: service_healthy запускает миграции только после готовности базы
flyway смотрит historySELECT version FROM flyway_schema_history — какие миграции уже применены
применяет недостающиеV004, V005 — выполняются по порядку. Каждая в транзакции (если поддерживается)
flyway exit 0Контейнер flyway завершается со статусом 0 — это нормально для одноразового init-job'а
app стартуетdepends_on: service_completed_successfully (compose v2) на flyway — приложение поднимается только когда миграции применены

Зависимость app от завершения миграций

В Compose v2 есть полезный condition service_completed_successfully — приложение стартует только после успешного завершения миграционного контейнера:

services:
  postgres:
    image: postgres:16
    # ...

  flyway:
    image: flyway/flyway:10
    # ...
    depends_on:
      postgres:
        condition: service_healthy

  app:
    image: my-etl:latest
    depends_on:
      flyway:
        condition: service_completed_successfully
    environment:
      DATABASE_URL: postgresql://de:secret@postgres:5432/warehouse

Теперь порядок гарантирован: postgres healthy -> flyway run + exit -> app start. Если миграция упала — flyway exit code != 0, app не стартует.


Alembic-пример (для Python ETL)

В Python-DE-проектах часто Alembic. Подход тот же — отдельный init-контейнер. Используем тот же app-образ, но другой command:

services:
  postgres:
    image: postgres:16
    # ...

  migrate:
    image: my-etl:latest  # тот же образ, что app
    command: alembic upgrade head
    environment:
      DATABASE_URL: postgresql://de:secret@postgres:5432/warehouse
    depends_on:
      postgres:
        condition: service_healthy

  app:
    image: my-etl:latest
    depends_on:
      migrate:
        condition: service_completed_successfully
    environment:
      DATABASE_URL: postgresql://de:secret@postgres:5432/warehouse

Тот же образ — экономия на сборке. Alembic читает версии из alembic/versions/, применяет недостающие.


Что делать с CI

В CI этот же паттерн работает идеально. GitHub Actions:

- name: Run migrations
  run: docker compose run --rm migrate

- name: Run integration tests
  run: docker compose run --rm tests

docker compose run --rm migrate запустит контейнер migrate, применит миграции, выйдет. --rm уберёт контейнер после.

Если миграция упадёт — run вернёт ненулевой код, CI job упадёт. Если pass — следующий шаг (тесты) увидит уже мигрированную базу.


Попробуй сам

  1. Создай директорию init/ с тремя файлами:
    • 01-extensions.sql: CREATE EXTENSION IF NOT EXISTS pgcrypto;
    • 02-users.sql: CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT);
    • 03-seed.sh: shell-скрипт, который инсертит 3 пользователя.
  2. Запусти Postgres с -v $(pwd)/init:/docker-entrypoint-initdb.d:ro и пустым volume lab-pg-data.
  3. Проверь, что таблица users создана и 3 пользователя есть.
  4. Добавь файл init/04-add-column.sql с ALTER TABLE users ADD COLUMN phone TEXT;. Перезапусти контейнер: docker stop pg && docker start pg. Проверь, что колонка НЕ появилась (init scripts не выполнились повторно).
  5. Удали контейнер и volume: docker rm -f pg && docker volume rm lab-pg-data. Запусти заново — теперь все 4 файла выполнятся, колонка phone будет на месте.
  6. Бонус: настрой Flyway в compose, перенеси DDL из init/ в migrations/V001__...V003__.... Сделай docker compose down -v && docker compose up. Добавь V004__add_phone.sql, сделай docker compose up flyway — миграция применится без удаления volume.

Проверка знанийKnowledge check
Почему класть миграции (новые DDL, которые добавляются со временем) в /docker-entrypoint-initdb.d/ — это anti-pattern, и как правильно организовать миграции в Docker-стеке?
ОтветAnswer
Init scripts из /docker-entrypoint-initdb.d/ выполняются ТОЛЬКО при первой инициализации Postgres — когда data dir пустой и нет файла PG_VERSION. При следующих стартах entrypoint видит существующий кластер и пропускает init scripts полностью. Это значит, что новые миграции, добавленные в эту директорию, никогда не применятся, пока ты не удалишь volume — а с ним и все данные. Правильно: отдельный контейнер для миграций (Flyway, Liquibase, Alembic, golang-migrate). Этот контейнер при старте подключается к Postgres, ведёт служебную таблицу (flyway_schema_history / alembic_version), применяет недостающие миграции и выходит. В compose: - postgres: с healthcheck pg_isready - migrate: depends_on postgres condition: service_healthy, command: применить миграции - app: depends_on migrate condition: service_completed_successfully При добавлении новой миграции docker compose up применит её к существующей базе без удаления volume. /docker-entrypoint-initdb.d/ оставляем для одноразовой инициализации: CREATE EXTENSION, базовые role'и, справочные seed-данные, которые никогда не меняются.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. В каком случае entrypoint Postgres-образа НЕ выполнит скрипты из /docker-entrypoint-initdb.d/?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5