Как откатить миграцию в prod
Runbook для ситуации: миграция применилась в prod (через PreSync-Job Atlas), после деплоя сервис падает / корраптит данные / ведёт себя неверно — нужно вернуть схему в рабочее состояние. Это последнее средство, применяется только после того, как другие варианты (rollback кода, быстрый fix-forward миграции) не сработали.
Как писать миграции, про Atlas-модель (versioned SQL, atlas.sum,
atlas_schema_revisions, PreSync-Job, роль <svc>_migrator) и
expand-contract — в add-migration. Про типовые сбои
самой миграции — ../troubleshooting/migration-fails.
Содержание
- Главное правило: в prod нет down
- Когда откатывать
- Когда НЕ откатывать
- 1. Остановить дальнейший ущерб
- 2. Зафиксировать состояние
- 3. Выбрать стратегию отката
- Стратегия A: fix-forward миграция
- Стратегия B: revert кода + compatibility-миграция
- Стратегия C: упавшая (failed) ревизия
- 4. Проверить atlas migrate status
- 5. Восстановить данные, если backfill испортил
- 6. Incident log
- Частые ошибки при откате
- Чеклист
- Связанные разделы
Главное правило: в prod нет down
Atlas — versioned, forward-only. В каталоге migrations/ нет
down-файлов вообще: миграция — это одна NNNN_name.sql вперёд. Откат в
prod — это новая миграция, которая возвращает схему в нужное
состояние, а не «отмотать назад».
Почему не делаем reverse-DDL автоматом:
- Down удалил бы колонку/таблицу с данными — необратимая потеря.
- Down не знает про зависимые объекты, добавленные позже (FK, view,
функции, триггеры) —
DROP ... CASCADEпорвёт связи. atlas_schema_revisions— это журнал применённого. Ручная отмотка записи без сверки реальной схемы рассинхронизирует журнал и БД, и следующийatlas migrate applyпойдёт не в ту ветку DDL.
Единственное исключение — dev / staging, где данные можно потерять. Там при отладке самой миграции до merge можно сгенерировать обратный план:
# ТОЛЬКО dev/staging. Планирует обратные DDL по dev-базе и откатывает
# последнюю применённую версию. В prod не запускаем.
atlas migrate down --env k8s --dev-url "docker://postgres/16/dev"В prod команда atlas migrate down не используется — только
fix-forward.
Когда откатывать
- Миграция добавила
NOT NULL/CHECK, который ломается на существующих строках → сервис не стартует / падают записи после деплоя. - Миграция удалила колонку, которую всё ещё читает старая версия сервиса (rolling deploy не завершён — нарушен expand-contract).
- Миграция ввела новый FK, и записи теперь нарушают его на production-данных.
- Backfill перезаписал данные, которые нельзя было трогать (fired
trigger, неверное условие
WHERE).
Когда НЕ откатывать
- Сервис падает не из-за схемы, а из-за кода — откатывай код, не
миграцию (
kubectl rollout undo). - Миграция добавила nullable-колонку → чуть больше размер таблицы, но ничего не ломается. Оставь, удалишь отдельной cleanup-миграцией позже.
- Fix-forward уже готов и быстрее, чем откат. См. §Стратегия A.
- Backfill ещё не закончился — не прерывай его посередине, это оставит схему в смешанном состоянии. Дождись, потом решай.
1. Остановить дальнейший ущерб
Перед любым SQL-вмешательством останови поток записи, который делает ситуацию хуже:
- Rollback кода — если новая версия сервиса пишет «битые» данные
через свежую схему, откати деплой до предыдущего образа
(
kubectl rollout undo deployment/<service> -n core). Это делается до работы со схемой. - Останови проблемный worker — если backfill-Job / CronJob
продолжает портить данные, заскейль в 0
(
kubectl scale cronjob/<name> --replicas=0 -n coreили suspend). - Read-only mode, если сервис поддерживает (
MAINTENANCE_MODE=true) — запретить записи, пока разбираешься.
Rollback кода и rollback схемы — разные операции. Код откатывается быстрее. Схему трогаем только если её эффект действительно нужно обратить.
2. Зафиксировать состояние
Миграции применяет роль <svc>_migrator через сессионный PgBouncer-алиас
<db>_migrate (creds — секрет <svc>-migrator). Под ней и подключайся:
# журнал ревизий Atlas — источник правды о применённом
atlas migrate status --env k8s-- журнал ревизий напрямую (последние записи)
SELECT version, description, type, applied, total, error IS NOT NULL AS failed
FROM atlas_schema_revisions
ORDER BY version DESC LIMIT 10;
-- структура проблемной таблицы
\d <schema>.<table>
-- счётчики до вмешательства
SELECT count(*) FROM <schema>.<table>;
SELECT count(*) FROM <schema>.<table> WHERE <new_column> IS NULL;
-- длинные транзакции, держащие lock
SELECT pid, state, xact_start, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND xact_start < NOW() - INTERVAL '30 seconds';Запиши результаты в incident log (см. §6) — без этого через день не вспомнишь, что было «до».
Если есть свежий логический бэкап (pgBackRest / pg_dump) новее
проблемной миграции — проверь, что он реально свежий. Восстановление
целиком из бэкапа убивает всю активность с момента бэкапа; только при
необратимой corruption’е.
3. Выбрать стратегию отката
| Ситуация | Стратегия |
|---|---|
| Миграция добавила constraint/index, который мешает — данные целы | A: fix-forward миграция, отменяющая constraint |
| Миграция удалила колонку, которую читает старый код | B: rollback кода + compatibility-миграция, возвращающая колонку |
Миграция упала посередине → ревизия failed в журнале | C, см. ../troubleshooting/migration-fails |
| Backfill перезаписал данные неверно | A + восстановление из audit-trail / бэкапа |
Стратегия A: fix-forward миграция
Самый частый и безопасный путь. Пишем новую миграцию с номером N+1, которая отменяет то, что сделала проблемная миграция N.
Пример: 042_add_review_status_not_null.sql добавила NOT NULL, но на
существующих строках status IS NULL → сервис падает при update.
atlas migrate new review_status_drop_not_null --env ci-- migrations/043_review_status_drop_not_null.sql
ALTER TABLE reviews ALTER COLUMN status DROP NOT NULL;atlas migrate hash --env ci # обновить atlas.sum
atlas migrate lint --env ci --latest 1Порядок действий:
- Создай файл
043_*.sqlчерезatlas migrate new, обновиatlas.sumчерезatlas migrate hash. - Локально прогони
atlas migrate applyна dev-копии — убедись, что фикс работает. - Открой PR с описанием инцидента в description (короткое «что
случилось, почему откатываем, как проверили»).
atlas migrate lintв CI обязан быть зелёным. - Merge → build миграционного образа → Argo подтянет тег → PreSync-Job применит 043 перед раскаткой Deployment.
- После восстановления схемы разбирайся, почему исходная миграция сломала prod (нет backfill, не тестировали на prod-подобных данных), фиксируй в пост-мортеме.
Этот путь не трогает atlas_schema_revisions руками, не использует
reverse-DDL, forward-only. Именно его применяй в 90% случаев.
Стратегия B: revert кода + compatibility-миграция
Когда миграция DROP COLUMN удалила поле, а старая версия сервиса (или
rolling deploy в процессе) всё ещё читает его → 500 на
column "X" does not exist. Правильный expand-contract это исключил бы
(см. add-migration),
но если уже случилось:
-
Откати код до версии, которая не читает поле (
kubectl rollout undo). Это немедленно останавливает 500. -
Убедись, что новая схема совместима с откаченным кодом.
-
Напиши compatibility-миграцию, которая возвращает колонку nullable:
-- migrations/044_restore_review_old_field.sql ALTER TABLE reviews ADD COLUMN old_field TEXT; -- данные восстановить из бэкапа отдельным backfill-Job (см. §5) -
Восстанови данные в колонке, если нужны — см. §5.
После DROP COLUMN данных в колонке больше нет — «просто вернуть старый
код» недостаточно, его надо сопроводить миграцией, возвращающей колонку.
Стратегия C: упавшая (failed) ревизия
Используется, когда PreSync-Job упал посередине миграции: часть DDL
применилась, в atlas_schema_revisions ревизия помечена как failed
(error IS NOT NULL, applied < total). Полный flow — в
../troubleshooting/migration-fails.
Кратко:
-
atlas migrate status --env k8s— увидишь версию в состоянии failed и на каком statement она упала. -
Предпочтительно — fix-forward. Если оставшиеся DDL безопасно до-применить, исправь причину и дай PreSync-Job повторить: Atlas продолжит с упавшего statement. (Миграции пишем так, чтобы шаги были идемпотентны —
IF NOT EXISTS/IF EXISTS.) -
Если до-применить нельзя — отмени вручную уже применённые DDL под advisory-lock, затем приведи журнал в соответствие реальной схеме:
# пометить версию как НЕ применённую (после ручной отмены её DDL) atlas migrate set <prev-version> --env k8satlas migrate setпереписывает журнал ревизий под фактическое состояние. Делать только после того, как схема физически приведена к<prev-version>, иначе журнал и БД разойдутся.
Ручное вмешательство в журнал — только аварийный механизм. В штатных случаях — Стратегия A.
4. Проверить atlas migrate status
После любого отката:
atlas migrate status --env k8s
# ожидаем: "Migration Status: OK", No pending migrations,
# Current — последняя версия из migrations/ репо, без failed-ревизий.Ожидания:
- Нет ревизий в состоянии failed.
- Current-версия = последняя миграция в
migrations/. - Нет pending-миграций (всё применено).
Проверь, что сервис стартует:
kubectl rollout restart deployment/<service> -n core
kubectl logs -f deployment/<service> -n core
# ожидаем старт без ERROR, readiness 200Сам сервис миграции не прогоняет — это делает PreSync-Job. Стартап сервиса только проверяет, что схема на месте.
5. Восстановить данные, если backfill испортил
Если миграция/Job включали UPDATE, который перезаписал данные неверно — восстановление зависит от того, что есть:
a) Audit-trail таблица
Если в сервисе есть history-таблица (reviews_history с триггером) —
источник истинных данных до миграции:
UPDATE reviews r
SET status = h.status
FROM reviews_history h
WHERE h.review_id = r.id
AND h.changed_at = (
SELECT MAX(changed_at) FROM reviews_history
WHERE review_id = r.id
AND changed_at < '2026-04-19 12:00:00'::timestamptz -- до миграции
);b) Логический бэкап
pg_restore на отдельную БД, INSERT INTO ... SELECT FROM с fetch’ем
нужных строк. Никогда не накатывай бэкап поверх живой prod-БД — только
через staging-копию.
c) Outbox/events
Если изменения публиковались через outbox, прошлые события
(kazmaps.review.review) всё ещё в Kafka (retention 7 дней). Можно
re-play’нуть последнее событие до миграции и получить payload старого
состояния.
| Что доступно | Используй |
|---|---|
| Audit-trail | a — самый точный и быстрый |
| Только бэкап | b — медленнее, но всегда доступно |
| Audit нет, но есть Kafka-события | c — точечно по ID |
| Ничего | Данные утрачены. Инцидент = DLP. Фиксируй в incident log. |
6. Incident log
После отката заведи запись (в сервис-репо docs/incidents/ или в системе
инцидентов команды):
# 2026-04-19 review-service: откат миграции 042
## Что случилось
Миграция 042 добавила `NOT NULL` на `reviews.status` без backfill
NULL-строк. После деплоя сервис падал в 100% PUT /v1/reviews.
## Как починили
Стратегия A: миграция 043 сняла `NOT NULL`. Build образа → Argo →
PreSync-Job применил 043 → rollout сервиса.
## Почему не поймали раньше
`atlas migrate lint` не ловит data-зависимые падения: на staging нет
исторических reviews с `status IS NULL`.
## Что сделаем
- Тест миграций на snapshot prod-схемы + sample данных.
- Чеклист PR: backfill для `SET NOT NULL` на существующих данных.Без этой записи следующая аналогичная миграция повторит ту же ошибку.
Частые ошибки при откате
atlas migrate downв prod. Планирует reverse-DDL, дропает объекты с данными. В prod только fix-forward.- Редактирование уже применённой миграции. Файл
042_*.sqlтеперь делает другое, ноatlas.sumи журнал хранят старый хеш → на новом окружении (fresh DB, после restore) получишь другое состояние, аatlas migrate validateупадёт на checksum mismatch. Пиши новую миграцию N+1. - Ручной правёж
atlas_schema_revisionsбез сверки схемы. БД и журнал расходятся, следующий apply идёт не в ту ветку DDL. Используйatlas migrate setи только после физического приведения схемы. - Забыл advisory lock в ручных DDL при Стратегии C. Параллельный PreSync-Job/pod пытается то же — оба падают.
- Не откатил код до миграции. Схему вернул, а код всё ещё использует новые поля → 500.
- Восстановление из бэкапа без остановки writes. Бэкап накатывается поверх live-данных → потеряны записи с момента бэкапа.
Чеклист
- Определил, нужен ли реально откат схемы, или достаточно rollback кода.
- Остановил поток «битых» записей (rollback кода / read-only / worker scale=0).
- Зафиксировал
atlas migrate status, журнал ревизий, структуру таблицы и счётчики строк. - Выбрал стратегию (A / B / C) и обосновал выбор.
- Fix-forward миграция создана через
atlas migrate new+atlas migrate hash,atlas migrate lintзелёный. - Прогнал миграцию локально на snapshot-копии prod-данных.
- PR создан с описанием инцидента, approve от lead.
- После деплоя:
atlas migrate statusчистый (нет failed/pending), сервис стартует, readiness 200. - Данные восстановлены (если нужно) из audit-trail / бэкапа / events.
- Incident log написан.
- Post-mortem запланирован: почему не поймали на pre-prod.
Связанные разделы
add-migration— Atlas-модель, формат миграций, expand-contract, роль migrator, чего не делать.../conventions/db-pgx— rolling-deploy совместимость, expand-contract на уровне кода.../troubleshooting/migration-fails— failed-ревизия, advisory lock, checksum mismatch, baseline.../troubleshooting/db-slow-query— если после миграции запросы затормозили, но схема цела.