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 пустой) он:
- Запускает
initdbдля инициализации кластера. - Стартует временный Postgres только на UNIX-сокете (без TCP) — пока никто извне не подключится.
- Создаёт
POSTGRES_USER,POSTGRES_DB, выставляетPOSTGRES_PASSWORD. - Если в
/docker-entrypoint-initdb.d/есть файлы — выполняет их по алфавиту:*.sql— черезpsql -f file.sql.*.sql.gz— распаковка + psql.*.sh— выполняются как shell-скрипты (имеют доступ к переменнымPOSTGRES_USER,PGDATABASE).
- Останавливает временный сервер, перезапускает Postgres на TCP
0.0.0.0:5432.
Ключевой момент: init scripts выполняются только при первой инициализации. Если в data dir уже что-то есть (PG_VERSION файл присутствует) — скрипты пропускаются.
Правильный 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 всё применится». Не применится.
Что произойдёт:
- Первый раз
docker compose up— все 30 миграций применились. Хорошо. - Разработчик добавил
V031__add_phone.sql.docker compose up— миграция не применилась, потому что data dir уже инициализирован. Init scripts пропущены. - Разработчик в недоумении удаляет volume (
docker volume rm pg-data). Запускает снова — все 31 миграций применились. Но удалили все данные.
Это не теория, я видел это в реальных проектах. Команда из 5 человек, через 3 месяца разработки боятся docker compose down -v, потому что «вдруг не поднимется обратно». Это не нормально.
Никогда не используй /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:
- Postgres стартует.
- Healthcheck
pg_isreadyкрутится 5-10 секунд, пока Postgres готов. - Flyway стартует (
condition: service_healthy), подключается к Postgres. - Flyway смотрит
flyway_schema_history, видит уже применённые миграции, применяет недостающие в правильном порядке. - Flyway выходит со статусом 0 (успех) или ненулевым (ошибка миграции).
Когда добавляешь V004__add_orders.sql — docker compose up flyway (или docker compose up) применит только её. Data dir не трогается, существующие данные сохранены.
Зависимость 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 — следующий шаг (тесты) увидит уже мигрированную базу.
Попробуй сам
- Создай директорию
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 пользователя.
- Запусти Postgres с
-v $(pwd)/init:/docker-entrypoint-initdb.d:roи пустым volumelab-pg-data. - Проверь, что таблица
usersсоздана и 3 пользователя есть. - Добавь файл
init/04-add-column.sqlсALTER TABLE users ADD COLUMN phone TEXT;. Перезапусти контейнер:docker stop pg && docker start pg. Проверь, что колонка НЕ появилась (init scripts не выполнились повторно). - Удали контейнер и volume:
docker rm -f pg && docker volume rm lab-pg-data. Запусти заново — теперь все 4 файла выполнятся, колонкаphoneбудет на месте. - Бонус: настрой Flyway в compose, перенеси DDL из
init/вmigrations/V001__...V003__.... Сделайdocker compose down -v && docker compose up. ДобавьV004__add_phone.sql, сделайdocker compose up flyway— миграция применится без удаления volume.