Перейти к содержанию

Как откатить миграцию в prod

Runbook для ситуации: миграция применилась в prod, после деплоя сервис падает / корраптит данные / ведёт себя неверно — нужно вернуть схему к предыдущему состоянию. Это последнее средство, применяется только после того, как другие варианты (hotfix кода, быстрый forward-fix миграции) не сработали.

Reference по написанию миграций, expand-contract и advisory lock — в ../conventions/db-pgx.md и add-migration.md. Про типовые сбои migrate CLI — ../troubleshooting/migration-fails.md.

Содержание

Главное правило: в prod нет down

Down-миграции в prod не применяются. Причины:

  • Down удаляет колонку/таблицу с данными — это необратимая потеря.
  • Down не знает про зависимые объекты, добавленные другими миграциями (FK, view'ы, функции) — DROP ... CASCADE порвёт связи.
  • schema_migrations после migrate down 1 разжимается на один номер назад — если потом применить ту же up-миграцию, advisory lock и уникальные constraint'ы на исходные данные могут выдать ошибку, и сервис не стартует.

Откат в prod — это forward-migration, которая возвращает схему в нужное состояние. Не migrate down.

Единственное исключение — dev/staging, где данные можно потерять: там migrate -path ./migrations -database "$DATABASE_URL" down 1 — штатный инструмент при отладке самой миграции перед merge.

Когда откатывать

  • Миграция добавила NOT NULL constraint, который ломается на существующих NULL-строках → сервис не стартует после deploy.
  • Миграция удалила колонку, которую всё ещё читает старая версия сервиса (rolling deploy не завершён).
  • Миграция ввела новый FK, и массовые inserts теперь нарушают его на production-данных.
  • Index создан CONCURRENTLY без IF NOT EXISTS, и второй запуск CI-стадии попытался создать снова — advisory lock предотвратил, но миграция зависла.
  • Backfill перезаписал данные, которые нельзя было трогать (fired trigger, неверное условие WHERE).

Когда НЕ откатывать

  • Сервис падает не из-за схемы, а из-за кода — откатывай код, не миграцию.
  • Миграция добавила nullable-колонку → чуть больше размер таблицы, но ничего не ломается. Оставь, удалишь отдельной cleanup-миграцией позже.
  • Hotfix-миграция уже готова и быстрее применить её, чем откат. См. §Стратегия A.
  • Backfill ещё не закончился — не прерывай его посередине, это оставит схему в смешанном состоянии. Дождись, потом решай.

1. Остановить дальнейший ущерб

Перед любым SQL-вмешательством остановить поток записи, который делает ситуацию хуже:

  • Кэш код-деплой — если новая версия сервиса пишет «битые» данные через свежую схему, откати деплой до предыдущего образа (kubectl rollout undo deployment/<service>). Это делается до отката миграции.
  • Останови проблемный worker — если backfill-скрипт / CronJob продолжает портить данные, заскейль его в 0 (kubectl scale cronjob/<name> --replicas=0 или удали schedule).
  • Включи read-only mode, если сервис поддерживает (MAINTENANCE_MODE=true env-переменная) — запретить записи, пока разбираешься.

Запомни: rollback кода и rollback миграции — разные операции. Код откатывается быстрее. Миграция — только если её эффект действительно нужно обратить.

2. Зафиксировать состояние

-- какие миграции применены
SELECT version, dirty FROM schema_migrations ORDER BY version DESC LIMIT 10;

-- структура проблемной таблицы
\d <schema>.<table>

-- сколько строк, сколько с NULL в новой колонке, etc.
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). Без этого через день ты не вспомнишь, что было «до».

Если есть pg_dump / логический бэкап свежее проблемной миграции — проверь, что он реально свежий:

# посмотри список бэкапов в backup-бакете
aws s3 ls s3://<backup-bucket>/<service>/ --recursive | tail -5

Восстановление целиком из бэкапа — последний вариант, убивает всю активность с момента бэкапа. Используется только при необратимой corruption'е.

3. Выбрать стратегию отката

Ситуация Стратегия
Миграция добавила constraint/index, который мешает — данные целы A: forward-fix (новая миграция, отменяющая constraint)
Миграция удалила колонку, которую читает старый код B: вернуть код + compatibility-миграция, восстанавливающая колонку
Миграция зависла (advisory lock, dirty state) C: ручной DDL + migrate force N, см. ../troubleshooting/migration-fails.md
Backfill перезаписал данные неверно A + восстановление из audit-trail / бэкапа

Стратегия A: forward-fix миграция

Самый частый и безопасный путь. Пишем новую миграцию с номером N+1, которая отменяет то, что сделала проблемная миграция N.

Пример: миграция 042_add_review_status_not_null.up.sql добавила NOT NULL, но на существующих строках status IS NULL → сервис падает при update.

-- 043_review_status_drop_not_null.up.sql
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('review_migrations'));

ALTER TABLE review.reviews ALTER COLUMN status DROP NOT NULL;

COMMIT;
-- 043_review_status_drop_not_null.down.sql
-- restore NOT NULL; requires prior backfill of NULLs
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('review_migrations'));
ALTER TABLE review.reviews ALTER COLUMN status SET NOT NULL;
COMMIT;

Порядок действий:

  1. Создай пару файлов 043_*.up.sql / 043_*.down.sql в migrations/.
  2. Локально прогони make migrate-up — убедись, что миграция применяется и работает то, что было сломано.
  3. Открой PR с описанием инцидента в description (короткое «что случилось, почему откатываем, как проверили»).
  4. Merge и деплой по стандартному CI/CD.
  5. После того, как схема восстановлена — разбирайся, почему исходная миграция сломала prod (тесты на pre-prod данные, missing backfill, и т.п.) и фиксируй это в задаче на пост-мортем.

Этот путь не трогает schema_migrations руками, не использует migrate force, не требует down.sql прошлой миграции. Именно его применяй в 90% случаев.

Стратегия B: revert кода + compatibility-миграция

Когда миграция DROP COLUMN удалила поле, а старая версия сервиса (или rolling deploy в процессе) всё ещё читает его → 500 на column "X" does not exist.

Правильный expand-contract бы это исключил (см. ../conventions/db-pgx.md), но если ошибка случилась:

  1. Откати код до версии, которая не читает поле (kubectl rollout undo). Это немедленно останавливает 500.
  2. Убедись, что новая схема совместима с откаченным кодом (старый код без поля — ну и без, всё ок).
  3. Напиши compatibility-миграцию, которая возвращает колонку nullable:
-- 044_restore_review_old_field.up.sql
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('review_migrations'));

ALTER TABLE review.reviews ADD COLUMN old_field TEXT;
-- данные восстановить из бэкапа отдельным скриптом
COMMIT;
  1. Восстанови данные в колонке, если они нужны — см. §5.

Не пытайся «просто накатить код обратно с удалённой колонкой» — после DROP COLUMN данных больше нет, сервис будет работать с пустым полем, что тоже может сломать бизнес-логику.

Стратегия C: ручной DDL под advisory lock

Используется, когда миграция частично применилась и schema_migrations показывает dirty=true. Full flow — в ../troubleshooting/migration-fails.md. Кратко, адаптированно для отката:

  1. Открой проблемную up.sql, посмотри, какие DDL успели применится.
  2. Откройсь в psql под advisory lock:
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('<service>_migrations'));
  1. Выполни обратные DDL-операции, явно. Пример (миграция добавила колонку и index, но упала на SET NOT NULL):
DROP INDEX IF EXISTS review.idx_reviews_status;
ALTER TABLE review.reviews DROP COLUMN IF EXISTS status;
COMMIT;
  1. Сбрось dirty-флаг и пометь миграцию как не применённую:
DELETE FROM schema_migrations WHERE version = 42;
-- или сбрось к предыдущей
INSERT INTO schema_migrations (version, dirty) VALUES (41, false)
  ON CONFLICT (version) DO UPDATE SET dirty = false;

Важно: в разных версиях golang-migrate структура таблицы чуть разная. Сначала посмотри:

\d schema_migrations
  1. Напиши исправленную версию миграции с тем же номером (если она ещё не замерджена в main), либо новую миграцию N+1 — не редактируй уже применённую, если она уже в main.

Ручное вмешательство в schema_migrationsтолько как аварийный механизм. В штатных случаях — Стратегия A.

4. Проверить schema_migrations

После любого отката:

SELECT version, dirty FROM schema_migrations ORDER BY version DESC LIMIT 5;

Ожидания:

  • dirty = false для всех строк. Если dirty=true — миграция «висит», следующие стартапы сервиса будут падать.
  • Максимальный version соответствует последней миграции в migrations/ репо.
  • Между номерами нет пропусков (migrate up на пустой БД должен накатить все последовательно).

Проверь, что сервис стартует:

kubectl rollout restart deployment/<service>
kubectl logs -f deployment/<service>
# ожидаем: "migrations applied", "server starting on :808X", без ERROR

5. Восстановить данные, если backfill испортил

Если миграция включала UPDATE, который перезаписал данные неверно — восстановление зависит от того, что у тебя есть:

a) Audit-trail таблица

Если в сервисе есть history-таблица (review.reviews_history с триггером на INSERT/UPDATE/DELETE) — источник истинных данных до миграции:

UPDATE review.reviews r
   SET status = h.status
  FROM review.reviews_history h
 WHERE h.review_id = r.id
   AND h.changed_at = (
       SELECT MAX(changed_at) FROM review.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.updated) всё ещё в Kafka (retention 7 дней). Можно re-play'нуть последнее событие до миграции и получить payload старого состояния.

Стратегия выбора:

Что доступно Используй
Audit-trail a — самый точный и быстрый
Только pg_dump b — медленнее, но всегда доступно
Audit нет, но есть Kafka-события c — точечное восстановление по ID
Ничего Данные утрачены. Инцидент = DLP. Фиксируй в incident log.

6. Incident log

После отката заведи запись (в сервис-репо, docs/incidents/ или в системе инцидентов команды):

# 2026-04-19 review-service: откат миграции 042

## Что случилось

Миграция 042 добавила `NOT NULL` на `review.reviews.status` без
backfill NULL-строк. После деплоя сервис падал в 100% PUT /v1/reviews
с ошибкой.

## Как починили

Стратегия A: миграция 043 сняла `NOT NULL`. Деплой 043 + rollout
сервиса.

## Почему не поймали раньше

Тест миграции на staging проходил, потому что на staging нет
исторических reviews с `status IS NULL` (данные обнулены 2 недели назад).

## Что сделаем

- Тест миграций прогоняется на snapshot'е prod-схемы + sample данных
  (задача: ...).
- Чеклист PR для миграций: проверить backfill для `SET NOT NULL` на
  существующих данных.

Без этой записи следующая аналогичная миграция повторит ту же ошибку через полгода.

Частые ошибки при откате

  • migrate down в prod. Удаляет колонки с данными, отключает advisory lock механизм, оставляет schema_migrations в состоянии, которое потом ломает up.
  • Редактирование уже применённой миграции. 042_add_review_status.up.sql теперь делает что-то другое, но на prod применилась старая версия. Любой сервис, который прогоняет migrate up с нуля (новый environment, после восстановления БД), получит другое состояние. Пиши новую миграцию N+1.
  • Ручной DELETE FROM schema_migrations без сверки схемы. БД и schema_migrations рассинхронизированы, следующие миграции пойдут не в ту ветку DDL.
  • Забыл advisory lock в ручных DDL. Параллельный pod того же сервиса пытается накатить ту же миграцию — оба падают друг на друга.
  • Не откатил код до миграции. Миграция применена в обратную сторону, а код всё ещё использует новые поля → немедленно 500.
  • Восстановление из бэкапа без остановки writes. Бэкап накатывается поверх live-данных → потеряны все записи с момента бэкапа.

Чеклист

  • Определил, нужен ли реально откат схемы, или достаточно rollback кода.
  • Остановил поток «битых» записей (rollback кода / read-only mode / CronJob scale=0).
  • Зафиксировал schema_migrations, структуру таблицы и счётчики строк.
  • Выбрал стратегию (A / B / C) и обосновал выбор.
  • Миграция отката имеет advisory lock в начале up.sql.
  • Прогнал миграцию локально на snapshot-копии prod-данных.
  • PR создан с описанием инцидента, approve от lead.
  • После деплоя: schema_migrations чистый (dirty=false), сервис стартует, readiness 200.
  • Данные восстановлены (если нужно) из audit-trail / бэкапа / events.
  • Incident log написан.
  • Post-mortem запланирован: почему не поймали на pre-prod.

Связанные разделы