.NET Tutorial
Senior7 мин чтенияВопрос 8 из 12

Развёртывание без простоя: rolling update, graceful shutdown, миграции БД по схеме expand/contract?

Почему это сложно

Балансировщик

Под v1

Под v1

Под v2

Одна БД:
схема должна подходить
и v1, и v2

Одна очередь:
сообщения v1 и v2

Балансировщик

Под v1

Под v1

Под v2

Одна БД:
схема должна подходить
и v1, и v2

Одна очередь:
сообщения v1 и v2

Во время rolling update работают обе версии

Миграция, применённая перед деплоем, должна работать со старым кодом; новый код должен работать со схемой и сообщениями, которые ещё может производить старый код. Плюс откат: если v2 откатывается к v1, схема уже изменена.

Graceful shutdown

Endpoints /балансировщикПод (ASP.NETCore)Kubernetesудалить подисключить под из endpoints (асинхронно)preStop: sleep 10 с (ждём,пока балансировщикиузнают)SIGTERMперестать приниматьновые соединениядождаться текущихзапросов, остановитьBackgroundService потокенувыход (доterminationGracePeriodSeconds-)
Endpoints /балансировщикПод (ASP.NETCore)Kubernetesудалить подисключить под из endpoints (асинхронно)preStop: sleep 10 с (ждём,пока балансировщикиузнают)SIGTERMперестать приниматьновые соединениядождаться текущихзапросов, остановитьBackgroundService потокенувыход (доterminationGracePeriodSeconds-)
Корректная остановка пода
C#
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(25));

public sealed class OrderConsumer(IMessageQueue queue) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var msg = await queue.ReceiveAsync(stoppingToken);   // при остановке новые сообщения не берём
            await HandleAsync(msg, CancellationToken.None);       // текущее — доработать (или сделать идемпотентным)
            await queue.AckAsync(msg);
        }
    }
}
YAML
spec:
  terminationGracePeriodSeconds: 40
  containers:
    - name: api
      lifecycle:
        preStop:
          exec: { command: ["sleep", "10"] }     # или sleep-действие в новых версиях Kubernetes
      readinessProbe:
        httpGet: { path: /health/ready, port: 8080 }

Без preStop-паузы под получает SIGTERM раньше, чем Ingress и kube-proxy перестали на него маршрутизировать, — часть запросов получает ошибки соединения.

Expand → Migrate → Contract

Задача: переименовать колонку customers.phone в phone_number (или изменить её формат).

Релиз 1 — EXPAND
ADD COLUMN phone_number NULL
код пишет в обе колонки,
читает из старой

BACKFILL
UPDATE ... пачками по 10 000
phone_number = phone

Релиз 2 — MIGRATE
код читает из новой,
пишет в обе (для отката)

Релиз 3 — CONTRACT
код больше не использует phone
DROP COLUMN phone

Релиз 1 — EXPAND
ADD COLUMN phone_number NULL
код пишет в обе колонки,
читает из старой

BACKFILL
UPDATE ... пачками по 10 000
phone_number = phone

Релиз 2 — MIGRATE
код читает из новой,
пишет в обе (для отката)

Релиз 3 — CONTRACT
код больше не использует phone
DROP COLUMN phone

Переименование колонки за несколько релизов

На каждом шаге предыдущая версия кода продолжает работать с текущей схемой, поэтому любой релиз можно откатить.

Backfill пачками, чтобы не держать долгие блокировки
UPDATE customers SET phone_number = phone
WHERE id IN (
    SELECT id FROM customers
    WHERE phone_number IS NULL AND phone IS NOT NULL
    LIMIT 10000
);
-- повторять до 0 затронутых строк, с паузами; следить за репликацией и нагрузкой

Опасные операции и безопасные альтернативы (PostgreSQL)

Опасно Почему Безопасно
ADD COLUMN x NOT NULL без default Ошибка или перезапись таблицы ADD COLUMN x NULL → backfill → SET NOT NULL (через CHECK ... NOT VALID + VALIDATE)
CREATE INDEX Блокирует запись на время построения CREATE INDEX CONCURRENTLY (вне транзакции)
ALTER COLUMN TYPE Перезапись таблицы под эксклюзивной блокировкой Новая колонка + backfill + переключение
ADD FOREIGN KEY Проверка всей таблицы под блокировкой NOT VALID + отдельно VALIDATE CONSTRAINT
RENAME COLUMN / DROP COLUMN за один релиз Старый код упадёт Expand/contract
Любой DDL при долгих транзакциях Очередь блокировок останавливает все запросы к таблице SET lock_timeout = '3s' и повтор

Миграции EF Core в процессе деплоя

CI: dotnet ef migrations bundle
или idempotent SQL-скрипт

Отдельный шаг деплоя:
Kubernetes Job / шаг пайплайна
(до выкатки нового кода)

Rolling update
приложения

Не делать: Database.Migrate()
при старте каждого пода

CI: dotnet ef migrations bundle
или idempotent SQL-скрипт

Отдельный шаг деплоя:
Kubernetes Job / шаг пайплайна
(до выкатки нового кода)

Rolling update
приложения

Не делать: Database.Migrate()
при старте каждого пода

Где применять миграции
  • Не применяйте миграции при старте приложения в нескольких экземплярах: гонки, долгий старт, права DDL у приложения.
  • Ревьюйте сгенерированный SQL (migrations script): EF Core может сгенерировать переименование как DROP + ADD (потеря данных) или перестроить таблицу.
  • Разделяйте схемные миграции (быстрые, совместимые) и миграции данных (backfill — фоновой задачей, пачками).

Контракты сообщений и API

1. Потребители учатся
понимать новое поле
(необязательное)

2. Производитель
начинает его отправлять

3. Потребители
начинают на него полагаться

4. Старое поле удаляется
(когда в очередях
не осталось старых сообщений)

1. Потребители учатся
понимать новое поле
(необязательное)

2. Производитель
начинает его отправлять

3. Потребители
начинают на него полагаться

4. Старое поле удаляется
(когда в очередях
не осталось старых сообщений)

Порядок обновления производителя и потребителя

Feature flags: отделить деплой от релиза

Код новой функциональности выкатывается выключенным, включается постепенно (1% → 10% → 100%) и мгновенно выключается при проблемах — без отката деплоя. Это снимает напряжение «большого релиза» и упрощает откат: откатывается флаг, а не схема БД.

Вопросы на засыпку

Как откатить релиз, если миграция уже применена?

Если миграции следовали expand/contract, предыдущая версия кода совместима с новой схемой — откатывается только код. «Down»-миграции в продакшене опасны (могут потерять данные, записанные новой версией) и применяются редко; обычно исправляют ошибку новым релизом (fix forward).

Почему потребители сообщений обновляются раньше производителей?

Если производитель начнёт отправлять сообщения нового формата раньше, чем потребители научатся их понимать, старые потребители упадут или отправят сообщения в DLQ. Обратный порядок безопасен: новый потребитель понимает и старый, и новый формат.

Что такое blue-green деплой и чем он помогает с БД?

Две полные среды (blue — текущая, green — новая), переключение трафика целиком. Код переключается мгновенно и легко откатывается, но БД обычно общая — поэтому требования к совместимости схемы те же, что и при rolling update.

Как удалить колонку, которую EF Core всё ещё знает в старой версии?

Сначала релиз, где модель EF больше не содержит свойства (код не читает и не пишет колонку; если колонка NOT NULL без default — сначала сделать её nullable или задать default). После того как старая версия больше нигде не запущена — миграция с DROP COLUMN.

Итог

Развёртывание без простоя основано на совместимости: старая и новая версии одновременно работают с одной схемой, очередями и API. Обеспечьте graceful shutdown (readiness, preStop, токены отмены), меняйте схему по шаблону expand → migrate → contract с backfill пачками и неблокирующими DDL, применяйте миграции отдельным шагом до выкатки кода, обновляйте потребителей раньше производителей и отделяйте деплой от релиза через feature flags.