4.1Пайплайн и стратегии выкатки
Сопоставишь проверки в pipeline с ценой и классом ошибки, затем сравнишь rolling, blue-green и canary. Научишься планировать миграцию схемы так, чтобы старая и новая версии приложения могли работать рядом.
Стадии пайплайна и почему они идут именно в таком порядке
Базовая последовательность: lint → test → build → scan → deploy. Порядок
подчинён принципу fail fast: дешёвые проверки впереди дорогих, чтобы
разработчик узнал об ошибке через 40 секунд, а не через 12 минут. Второе правило:
каждая стадия проверяет свой класс проблем и не дублирует соседнюю.
| Стадия | Что проверяет | Инструменты в Go | Порядок величины |
|---|---|---|---|
| lint / vet | стиль, очевидные баги, забытый err, тени переменных, неправильный Printf, устаревшие API | gofumpt, go vet, golangci-lint (staticcheck, errcheck, ineffassign, gosec) | секунды - 2 мин |
| unit-тесты | логика в изоляции, гонки данных | go test ./... -race -shuffle=on -count=1 | 1–5 мин |
| интеграционные | реальные SQL-запросы, миграции, контракты с брокером | testcontainers, docker-compose, отдельный тег сборки | 3–15 мин |
| build | собирается ли вообще под все целевые платформы; воспроизводимость | multi-stage Dockerfile, CGO_ENABLED=0, -trimpath, -ldflags с версией | 1–5 мин |
| scan | уязвимости в зависимостях и в базовом образе, утёкшие секреты, лицензии | govulncheck, trivy image, gitleaks, генерация SBOM | 1–3 мин |
| publish | образ в registry под неизменяемым тегом, подпись, SBOM-аттестация | docker push, cosign sign | < 1 мин |
| deploy | реально ли поехало: rollout status, smoke-тесты, метрики | helm upgrade --rollback-on-failure (в Helm 3 --atomic), kubectl rollout status, Argo CD | 1–10 мин |
Два нюанса про порядок. Первый: сканирование ставят после
сборки, потому что сканировать надо тот самый артефакт, который поедет в прод, вместе
с базовым образом, а одного go.sum мало. Второй: lint и unit-тесты можно
запускать параллельно, они не зависят друг от друга, и это бесплатно экономит минуты.
Последовательность нужна там, где есть зависимость по данным (сборка → сканирование образа)
или по стоимости (не поднимать testcontainers, если код не компилируется).
Взрослый пайплайн различает блокирующие и информационные проверки. Блокируют
обычно: компиляция, unit-тесты, -race, критические уязвимости с известным
эксплойтом, утёкшие секреты. Не блокируют, но заводят задачу: падение покрытия на пару
процентов, новые «medium»-уязвимости без патча, дубли кода. Смысл разделения простой:
если пайплайн падает по причинам, которые команда не может починить сегодня, его
начинают обходить, и тогда перестают работать и блокирующие проверки тоже.
Кэширование: почему пайплайн идёт 15 минут вместо трёх
В Go-проекте время пайплайна почти целиком складывается из трёх вещей: скачивание модулей, компиляция и сборка образа. Все три кэшируются, каждая по-своему.
GOMODCACHE(~/go/pkg/mod) хранит скачанные модули. Ключ кэша: хешgo.sum. Промах означает заново скачать все модули по сети.GOCACHE(~/.cache/go-build) хранит результаты компиляции и результаты тестов. Главный и самый недооценённый кэш: без него каждый прогон пересобирает всё дерево зависимостей от корня. Ключ: хешgo.sumплюс что-нибудь от коммита, с fallback на префикс (restore-keys), чтобы частичное совпадение тоже помогало. Это схема GitHub Actions: там кэш под существующим ключом не перезаписывается. В GitLab кэш перезаливается после каждого успешного джоба, коммит в ключе не нужен, аfallback_keysищутся по полному имени, без префиксов.- Слои Docker. Кэш слоя инвалидируется, как только меняется контекст любой предыдущей инструкции. Отсюда золотое правило Dockerfile: сначала копируем то, что меняется редко.
# Правильный порядок: go.mod/go.sum отдельным слоем, он переживает большинство коммитов
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download # слой меняется только при правке зависимостей
COPY . .
# без ARG ${VERSION} ниже развернётся в пустую строку; объявлен здесь, а не после FROM,
# иначе новое значение VERSION сбросит и слой с модулями
ARG VERSION=dev
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
--mount=type=cache (BuildKit) отличается от обычного слоя тем, что кэш
не попадает в образ и переживает инвалидацию слоя. Но и в экспорт кэша он не входит,
а эфемерный раннер начинает с пустого builder. Наружу выносят слои:
docker buildx build --cache-from type=registry,ref=…:cache
--cache-to type=registry,ref=…:cache,mode=max, тогда они живут в registry
и доступны любому раннеру. Поэтому go mod download здесь обычный слой: модули
уезжают в registry вместе с ним, а cache mount компилятора выручает локально и на постоянном раннере.
- Разделить unit и интеграционные тесты. Unit гоняют на каждый push, интеграционные
на PR и на merge в main. Ночью полный прогон с
-raceи длинными тестами. - Не гонять
-raceна каждый push. Он замедляет тесты в 2–20 раз и поднимает расход памяти впятеро-вдесятеро. Гонять его на всём наборе каждый раз избыточно: хватит на PR и по расписанию. - Не отключать кэш тестов бездумно.
-count=1явно отказывается от кэша результатов; он нужен для флакающих интеграционных тестов, а unit-ам честный кэш экономит минуты. Компромисс:-count=1только в интеграционном джобе. - Распараллелить тесты. Пакеты
go testи так гоняет параллельно (-p, по умолчанию GOMAXPROCS), а внутри пакета параллельность включаетt.Parallel(). Упирается это не в CPU, а в общие ресурсы: одна БД на все тесты сериализует всё. - Убрать лишнюю матрицу. Собирать внутренний сервис под три версии Go и четыре ОС, когда он едет в один и тот же distroless, значит впустую жечь минуты.
- Собирать один раз. Артефакт, прошедший тесты, должен ехать в stage и в prod тот же самый, по digest. Пересборка под каждое окружение сама по себе порождает «на stage работало».
- Мерить. Прежде чем оптимизировать, посмотреть на длительность стадий: почти всегда основное время сидит в одном месте, и это редко то, что кажется.
Стратегии деплоя: rolling, blue-green, canary
Все три отвечают на вопрос «как заменить версию, не уронив пользователей», но цена у каждой своя: rolling медленно откатывается, blue-green требует вдвое больше железа, canary упирается в сложность и наблюдаемость.
| Rolling | Blue-green | Canary | |
|---|---|---|---|
| Лишние ресурсы | +maxSurge (обычно 25 %) | ×2 на время выкатки | поды под вес канарейки (Argo Rollouts); Flagger поднимает её в полный размер |
| Скорость отката | минуты (обратный rolling) | секунды (переключить роутер) | секунды (снять канарейку) |
| Сколько увидит плохую версию | растёт линейно, до 100 % | 100 % сразу | 1–5 % |
| Сколько версий в проде | две, вперемешку | две, но трафик у одной | две, с контролируемым сплитом |
| Требования к приложению | graceful shutdown, обратная совместимость схемы | то же + обе версии на одной БД | то же + метрики с лейблом версии |
| Сложность | из коробки в Deployment | средняя (два стека, переключатель) | высокая (сплит трафика + анализ) |
| Когда выбирают | дефолт для большинства сервисов | редкие рискованные релизы, монолит, жёсткое окно | высоконагруженные сервисы на критическом пути |
Есть и четвёртая опция, о которой вспоминают редко: shadow / dark launch. Копия production-трафика зеркалируется на новую версию, ответы отбрасываются. Пользователи не видят ничего, а ты получаешь реальную нагрузку и реальные данные. Без изоляции побочных эффектов (своя база, заглушки внешних вызовов) годится только для чтения: списание денег зеркалировать в боевое окружение нельзя.
Автоматический анализ канарейки
Канарейка, которую разглядывает человек, называется «выкатили и посмотрели». Взрослый вариант: контроллер (Argo Rollouts, Flagger) на каждом шаге делает паузу, гоняет в Prometheus набор запросов и сверяет результат с порогом. Сам запрос при этом строят так, чтобы он сравнивал канарейку с базовой версией, а не смотрел на абсолютное значение.
# фрагмент spec Canary во Flagger; поле query в метрике ещё работает, но помечено устаревшим:
# сейчас запрос выносят в MetricTemplate и ссылаются на него через templateRef
analysis:
interval: 60s
threshold: 3 # три неудачные проверки за анализ -> откат
metrics:
- name: error-rate # доля 5xx у канарейки, максимум 1 %
thresholdRange: { max: 1 }
query: |
100 * sum(rate(http_requests_total{app="api",version="canary",code=~"5.."}[2m]))
/ sum(rate(http_requests_total{app="api",version="canary"}[2m]))
- name: latency-p99 # p99 канарейки не хуже базовой более чем на 20 %
thresholdRange: { max: 1.2 }
query: |
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{version="canary"}[5m])))
/ histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{version="stable"}[5m])))
Потому что абсолютные пороги дают ложные срабатывания на любом всплеске нагрузки: ночью p99 всегда лучше, в пик хуже, и в понедельник утром порог 300 мс сорвёт откат идеально исправного релиза. Сравнение «канарейка против stable в один и тот же момент» само нормализует всё внешнее: и нагрузку, и тормозящую базу, и соседний сервис. Отсюда же требование к метрикам: у каждой метрики должен быть лейбл версии, иначе сравнивать нечего. Мешает ещё размер выборки: при 1 % трафика на канарейке и 200 rps это 2 запроса в секунду; статистика по ошибкам набирается медленно, и первые шаги приходится держать дольше, чем хочется.
Feature flags: разделение деплоя и релиза
Деплой и релиз не одно и то же. Деплой означает «новый код лежит на серверах». Релиз означает «пользователи видят новое поведение». Feature flag разрывает связь между ними: код едет в прод выключенным, иногда за недели до включения, и включается отдельной операцией без пересборки и передеплоя.
| Canary | Feature flag | |
|---|---|---|
| Единица переключения | версия артефакта целиком | отдельная фича внутри одной версии |
| Где живёт логика | в инфраструктуре (роутер, service mesh, ingress) | в коде приложения (if flag.Enabled(ctx, "new-pricing")) |
| Кому включают | случайной доле трафика | конкретному сегменту: юзеру, тенанту, региону, проценту по хешу id |
| Скорость отката | секунды, но откатывается весь релиз | секунды, откатывается одна фича, остальные изменения остаются |
| Стоимость | инфраструктурная сложность | ветвление в коде = техдолг |
Флаги сильно различаются по времени жизни, и путают их постоянно:
- Release toggle живёт недели и умирает сразу после полного раската. Про него обычно и спрашивают.
- Ops toggle / kill switch живёт по-разному: обычный ops toggle снимают, когда новая функция обкаталась под нагрузкой, а kill switch остаётся навсегда: «отключить рекомендации», «перестать ходить в необязательный сервис». Часть механики деградации под нагрузкой.
- Experiment toggle держится до конца A/B-теста и требует стабильного разбиения пользователей по хешу.
- Permission toggle вообще не флаг, а бизнес-логика тарифов; удалять его не надо, но и в систему флагов класть не стоит.
Каждый флаг ставит в коде развилку, и n флагов дают до 2ⁿ путей исполнения, из которых тестируются два. Через год в таком коде уже не понять, какая ветка живая. Поэтому у зрелых команд есть дисциплина: у флага есть владелец и дата смерти прямо в описании; в CI стоит проверка «флаг старше 60 дней даёт предупреждение, старше 90 роняет сборку»; флаг выпиливают отдельной задачей в том же спринте, что и раскатка, а не «когда-нибудь потом». И последнее: значение флага логируется и попадает в трейс, иначе баг «воспроизводится только у части пользователей» не разобрать, ведь ты не знаешь, какой код исполнялся у конкретного запроса.
Откат релиза и почему миграции БД - главная сложность
Откат кода стоит дёшево и почти везде решён: старый ReplicaSet на месте,
kubectl rollout undo возвращает прежнюю версию обратным rolling за минуты,
переключение blue-green - за секунды.
Проблема в том, что у базы данных нет rollback. Схема хранит состояние, которое
версия кода N+1 уже изменила, а версия N про него ничего не знает. Если миграция
переименовала колонку, откат кода уронит сервис на первом же запросе:
column "email" does not exist.
Решение называется expand / contract. Идея: любое изменение схемы разбивается на несколько релизов так, чтобы на каждом шаге схема была совместима и с текущей, и с предыдущей версией кода.
- Только аддитивные изменения в одном релизе с кодом.
ADD COLUMN(nullable, без дефолта),CREATE TABLE,CREATE INDEX CONCURRENTLYбезопасны.DROP,RENAME, сужение типа, добавлениеNOT NULLвсегда едут отдельным поздним релизом. - Схему и код деплоят по отдельности. Расширяющая миграция накатывается
до выкатки кода, удаляющая - после того, как код перестал трогать старое
(как отдельный шаг пайплайна или Job в кластере), а не в
init-контейнере каждого пода, иначе десять реплик стартуют и десять раз конкурентно пытаются мигрировать одну базу. - Совместимость N и N+1 обязательна в обе стороны. Во время rolling update обе версии кода работают одновременно с одной схемой. Длится это минуты, а не секунду.
- Необратимое делают последним и с задержкой. Между «код перестал использовать колонку» и «колонку удалили» должно пройти столько времени, сколько живёт твоё окно отката (обычно неделя). Данные перед удалением уезжают в архивную таблицу, а не в никуда.
- Долгие блокировки. В PostgreSQL
ALTER TABLEберётACCESS EXCLUSIVE; на большой таблице под нагрузкой это очередь из всех запросов и мгновенная деградация. Отсюда практика:SET lock_timeoutперед DDL и ретрай,CREATE INDEX CONCURRENTLY, UPDATE пачками. - Down-миграции почти всегда иллюзия. Их пишут, но на проде применяют редко:
downдляDROP COLUMNвернёт колонку, но не данные. На деле откатываются иначе: накатывают новую «исправляющую» миграцию вперёд (roll forward).
Отсюда и практическое определение «быстрого отката»: откатывают код, а не базу.
Остальное держится на дисциплине миграций, которая делает такой откат безопасным.
Плюс несколько технических условий: неизменяемые теги образов (откатываться есть куда),
сохранённая история релизов (revisionHistoryLimit, helm history),
автоматический откат по провалу smoke-тестов (helm upgrade --rollback-on-failure,
Argo Rollouts) и отрепетированный откат. Процедура, которую никто ни разу
не выполнял, в три часа ночи не работает.
Окружения и конфигурация per-env
Классическая тройка dev → stage → prod нужна не сама по себе, а как
последовательность фильтров, где каждый следующий ближе к бою и дороже в поломке.
- dev живёт быстро и грязно: можно ломать, данные синтетические, деплой на каждый push.
- stage / pre-prod должен быть максимально похож на прод по версиям зависимостей, конфигурации и, желательно, по объёму данных (обезличенных). Здесь гоняют интеграционные и нагрузочные тесты и репетируют миграции.
- На prod реальные пользователи и реальные деньги.
Отдельно упомяни эфемерные окружения на PR: неймспейс, поднятый на время ревью и удаляемый при мерже. Это дороже, но убирает вечную очередь «кто сейчас занимает stage».
Правило конфигурации простое и старое (12-factor): артефакт один, конфигурация внешняя.
Один и тот же образ по digest едет во все окружения; отличается только то, что подаётся
снаружи: переменные окружения, ConfigMap, Secret, values-файл. Как только появляется
image: app:prod и app:stage, ты тестировал не то, что выкатил.
# values.prod.yaml - только различия: реплики, лимиты, хосты;
# image.digest - тот же артефакт, что проверен на stage
helm upgrade api ./charts/api \
--install --rollback-on-failure --timeout 5m \
--values values.yaml \
--values values.prod.yaml \
--set image.digest=sha256:9f3c2a1...
Дрейф окружений. Stage постепенно расходится с продом: там другая версия Postgres, там выключена какая-то фича, там вдвое меньше данных, и «на stage всё работало» перестаёт что-либо значить. Лечится тем, что различия между окружениями сведены в один короткий файл, который можно прочитать глазами, а всё остальное общее. На втором месте секреты в values-файлах в git. На третьем месте прод-доступ у CI без ограничений: пайплайн, которому разрешено всё, работает как учётка, которой разрешено всё, и она рано или поздно утекает.
Semver, теги и changelog: релизная гигиена
Semver читается как MAJOR.MINOR.PATCH: мажор ломает контракт,
минор добавляет возможности, не ломая совместимость, патч чинит. Это обещание потребителю,
а не украшение. В Go оно вшито в систему модулей: модуль версии 2 и выше обязан иметь суффикс пути
(example.com/lib/v2), потому что go-модули должны уметь держать
в одной сборке v1 и v2 одновременно.
- Библиотеки версионируются по semver строго: у них есть внешние потребители.
- Внутренние сервисы часто нумеруют иначе, по дате или по номеру сборки
(
2026.08.26-9f3c2a1), потому что «мажорная версия сервиса» никому ничего не говорит: наружу у сервиса торчит один контракт, его API, и версионируется он отдельно. Честный ответ на собесе: «библиотеки версионирую по semver, сервисы неизменяемым тегом с git SHA плюс человекочитаемая метка». - Тег в git и тег образа должны совпадать и указывать на один коммит. Тег
неизменяем: перевыкладывать
v1.4.2с другим содержимым нельзя, это ломает всё, что построено на кэше и на доверии. latestв проде запрещён: непонятно, что развёрнуто, невозможно откатиться,imagePullPolicy: Alwaysпревращает перезапуск пода в случайное обновление версии.- Версия внутри бинаря.
-ldflags="-X main.version=...", а дальше версия и git SHA торчат в/version, в логах при старте и в лейбле метрикиbuild_info. Без этого на вопрос «какая версия сейчас в проде» найдётся несколько противоречивых ответов. - Changelog. С Keep a Changelog и Conventional Commits он генерируется
автоматически (
git-cliff, release-please), а из типов коммитов заодно выводится следующая semver-версия. Ценность не в файле, а в том, что за минуту находишь, что поменялось между тем, что работало, и тем, что сломалось.
Вопросы
8lint → test → build → scan → publish → deploy,
и порядок продиктован ценой ошибки: дешёвые проверки впереди дорогих (fail fast),
а зависимые - после того, от чего зависят (сканировать надо собранный образ,
а не исходники). Каждая стадия ловит свой класс проблем; дублирование между ними -
трата времени, а дырка между ними - инцидент.Стадии и их зона ответственности
- Pre-commit / pre-push (локально). Формат и быстрый lint. Формально не часть CI, но именно здесь бесплатно ловится большая часть тривиального шума.
- lint / vet. Отвечает на вопрос «код вообще написан осмысленно?».
go vetловит настоящие баги (неправильныйPrintf, копирование мьютекса, недостижимый код),golangci-lintдобавляет staticcheck, errcheck (проигнорированные ошибки), gosec. Секунды или минуты, поэтому первым. - Unit-тесты. Логика в изоляции. Обязательно с
-raceхотя бы на PR: гонка, найденная здесь, стоит минуту, а в проде обойдётся в ночь.-shuffle=onловит тесты, которые зависят от порядка,-count=1ловит те, что проходят только из кэша. - Интеграционные тесты. Настоящий Postgres и брокер (testcontainers). Здесь и только здесь проверяются миграции, реальный SQL и контракты. Дороже, значит после unit.
- build. Собирается ли артефакт под целевую платформу; multi-stage Dockerfile;
CGO_ENABLED=0для статического бинаря в distroless;-trimpathи-ldflagsдля воспроизводимости и версии внутри бинаря. - scan.
govulncheck(в отличие от простого аудита go.sum он смотрит, вызывается ли уязвимая функция вообще, и ложных срабатываний резко меньше),trivyпо образу (уязвимости базового слоя),gitleaksпо истории (утёкшие ключи), SBOM. После build, потому что сканируем тот самый образ. - publish. Пуш в registry под неизменяемым тегом (git SHA), подпись
(
cosign), аттестация SBOM. С этого момента артефакт заморожен. - deploy.
helm upgrade --rollback-on-failure/ Argo CD sync, затем обязательная проверка «поехало ли»:kubectl rollout status, smoke-тесты по критичным маршрутам, взгляд на метрики. Ненулевой код запускает автоматический откат.
Что можно распараллелить
lint, unit-тесты и сборка образа не зависят друг от друга, их запускают параллельно
и экономят минуты на каждом прогоне. Последовательность обязательна только там,
где есть настоящая зависимость: build → scan образа → publish → deploy.
Сборку образа можно запускать одновременно с тестами, но
публиковать только после их успеха, тогда время сборки прячется за временем тестов.
Гейты: блокирующее и информационное
Блокируют мерж: компиляция, unit-тесты, -race, критические уязвимости
с патчем, найденные секреты. Только предупреждают: просадка покрытия, medium-уязвимости
без исправления, дубликаты. Логика в том, что пайплайн, который падает по причинам,
которые сегодня не починить, начинают обходить, и тогда обходят вообще всё.
Отдельным гейтом стоит ручной approval на prod-деплой: для многих команд это единственное
место, где человек ещё принимает решение.
«У пайплайна есть две метрики, которые я реально смотрю: время до обратной связи (сколько ждёт разработчик после push) и доля флакающих прогонов. Вторая важнее: как только тесты начинают падать случайно, люди перестают читать красный цвет и просто нажимают retry, а пайплайн из системы контроля превращается в ритуал. Флакающий тест либо чинится, либо помечается и выносится из блокирующих, но не оставляется падать “иногда”.»
GOMODCACHE), компиляция
(GOCACHE - самый недооценённый кэш, он же кэширует результаты тестов)
и пересборка docker-слоёв из-за неправильного порядка COPY.
Все три лечатся кэшем; дальше идут параллелизм и разделение быстрых и медленных тестов.Три кэша
| Кэш | Что лежит | Ключ | Что даёт |
|---|---|---|---|
GOMODCACHE~/go/pkg/mod | исходники зависимостей | хеш go.sum | модули не качаются заново по сети |
GOCACHE~/.cache/go-build | объектные файлы и результаты тестов | go.sum + коммит, с restore-keys на префикс | пересобираются только изменившиеся пакеты |
| Docker layers | готовые слои образа | контекст каждой инструкции | минус время сборки образа целиком |
Отдельно про restore-keys: точное совпадение ключа бывает
редко (кроме хеша go.sum, в ключе коммит), но частичное восстановление кэша от предыдущей
сборки той же ветки почти так же полезно - компилятору не придётся пересобирать
неизменившиеся пакеты. Это схема GitHub Actions: кэш под существующим ключом там
не перезаписывается. В GitLab кэш перезаливается после каждого успешного джоба.
Docker: порядок инструкций решает всё
# Плохо: любая правка .go инвалидирует всё, включая go mod download
COPY . .
RUN go mod download && go build -o /out/app ./cmd/app
# Хорошо: зависимости отдельным слоем, он переживает почти все коммиты
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -o /out/app ./cmd/app
--mount=type=cache из BuildKit держит отдельный от слоёв кэш: он не
попадает в образ и не инвалидируется вместе со слоем. Но и в экспорт кэша он не входит,
так что на эфемерном раннере он пуст. В registry выносят слои:
--cache-from type=registry,ref=repo/app:cache --cache-to type=registry,ref=repo/app:cache,mode=max
(mode=max сохраняет и промежуточные слои, а не одни финальные). Поэтому модули
здесь качаются обычным слоем и уезжают в registry вместе с ним.
Плюс не забыть .dockerignore: без него в контекст сборки уезжают
.git, vendor и локальные артефакты, и сборка тратит время
на их передачу.
Остальные рычаги, по убыванию эффекта
- Разделить быстрые и медленные тесты. Unit гоняются на каждый push, интеграционные
на PR, а полный прогон с
-race, длинными и нагрузочными уезжает в ночь по расписанию. Обычно это самая большая единичная экономия. - Распараллелить тесты.
t.Parallel()внутри пакета и параллельные пакеты по умолчанию (go test -p N). Ограничитель обычно не CPU, а разделяемый ресурс: одна тестовая БД на всех сериализует прогон. Лечится схемой на пакет или контейнером на пакет. - Не отключать кэш тестов бездумно.
-count=1нужен там, где тесты трогают внешний мир; для чистых unit-ов кэш Go честный (он учитывает содержимое файлов, env и аргументы) и экономит минуты. - Убрать лишнюю матрицу. Три версии Go × четыре ОС для внутреннего сервиса, который всегда едет в один distroless, жгут время впустую.
- Собирать один раз. Артефакт, прошедший тесты, едет в stage и prod по digest, без пересборки. Это и быстрее, и правильнее.
- Взять раннер помощнее. Иногда самое дешёвое ускорение даёт не тонкая настройка, а машина с большим числом ядер и SSD: Go отлично параллелит компиляцию.
- Кэшировать то, что не должно кэшироваться. Результат
scanна старом кэше уязвимостей хуже, чем скан вовсе не запускать: он даёт ложное спокойствие. - Хранить
vendor/ради скорости. Vendor даёт воспроизводимость и сборку без сети, но раздувает репозиторий и диффы; сейчас вместо него берут кэш модулей плюс приватный прокси (GOPROXY, Athens/Artifactory), который заодно защищает от «зависимость удалили из интернета». - Класть секреты в build args. Они остаются в истории слоёв.
Для сборки с приватными модулями есть
--mount=type=secret. - Оптимизировать вслепую. Прежде чем что-то менять, надо увидеть разбивку по стадиям: обычно основное время сидит в одном месте, и оно почти никогда не то, на которое думали.
Как работает каждая
- Rolling. Deployment заменяет поды пачками в пределах
maxSurge/maxUnavailable. Некоторое время в проде обе версии вперемешку, и трафик распределяется между ними случайно. Работает из коробки, настраивать нечего. Откат идёт таким же rolling в обратную сторону, то есть минуты. - Blue-green. Рядом с работающим стеком (blue) поднимается полный второй стек (green) той же ёмкости. Его прогревают, гоняют по нему smoke-тесты - живого трафика на нём нет. Потом одно переключение роутера (селектор Service, вес в Ingress, target group балансировщика), и весь трафик у green. Откат переключает обратно, те же секунды. Blue держат ещё какое-то время, потом гасят.
- Canary. Новая версия получает малую долю реального трафика (1 % → 5 % → 25 % → 50 % → 100 %) с паузами между шагами. На каждой паузе сравниваются метрики канарейки и стабильной версии; при отклонении срабатывает автоматический откат. Делается весами в Ingress/Gateway API, service mesh или просто соотношением числа подов, если балансировка равномерная.
Сравнение по тому, что действительно важно
| Критерий | Rolling | Blue-green | Canary |
|---|---|---|---|
| Ресурсы | +25 % | ×2 | поды под вес канарейки (Argo Rollouts); Flagger поднимает её в полный размер |
| Время отката | минуты | секунды | секунды |
| Радиус поражения | растёт до 100 % | сразу 100 % | 1–5 % |
| Обнаружение проблемы | по факту, вручную | по факту, вручную | автоматически, по метрикам |
| Сложность настройки | нулевая | средняя | высокая |
| Требует | совместимость N/N+1 | то же + ресурсы + общая БД | то же + сплит трафика + метрики с лейблом версии |
Когда что выбирают
- Rolling остаётся дефолтом для подавляющего большинства stateless-сервисов. Если релизы частые, изменения небольшие, а тесты приличные - этого достаточно, и усложнять не надо.
- Blue-green берут, когда нужен предсказуемый мгновенный откат и когда две версии вперемешку недопустимы: монолит с состоянием в памяти, жёсткое релизное окно, регуляторные требования «одна версия в момент времени». Платить придётся двойным железом на время выкатки, а в облаке это часто дороже, чем кажется.
- Canary держат для высоконагруженных сервисов на критическом пути, где даже пять минут деградации у всех стоят денег. Осмыслен только при достаточном трафике: на 5 rps 1 % канарейки не наберёт статистики за разумное время, и решать придётся по шуму.
- Shadow / dark launch нужен, когда хочется реальную нагрузку без риска: копия трафика льётся на новую версию, ответы отбрасываются. Без изоляции побочных эффектов (своя база, заглушки внешних вызовов) годится только для чтений.
- База данных одна на все версии. Ни одна стратегия не отменяет expand/contract: во время выкатки обе версии кода живут на одной схеме. Blue-green тут особенно коварен - иллюзия «полностью изолированного второго стека» заканчивается ровно на строке подключения к БД.
- Состояние в памяти и кэши. Пользователь, чья сессия лежала в памяти старого пода, при переключении её потеряет. Если сервис не stateless - бесшовной выкатки не будет ни при какой стратегии.
- Асинхронные потребители. Канарейка по HTTP-трафику ничего не говорит о consumer-е Kafka: сообщения разбираются обеими версиями, и «1 % трафика» там не настраивается весами роутера.
- Долгие соединения. WebSocket и gRPC-стримы не переключаются вместе с роутером - они живут, пока их не закроют. Blue-green с «мгновенным переключением» на деле растягивается на время жизни соединений.
Структура ответа, которую ждут: какой сервис и почему именно канарейка («критический путь, 3k rps, откат вручную занимал 4 минуты, а это 700 тысяч затронутых запросов»); как делили трафик (веса в nginx-ingress / Argo Rollouts); по каким метрикам принимали решение (доля 5xx и p99 канарейки против stable, плюс бизнес-метрика, конверсия оформления); сколько держали каждый шаг и почему именно столько (набор статистики); реальный случай, когда откатило - и что оказалось причиной. Последний пункт ценнее всех остальных: он показывает, что канарейка была настоящая, а не в презентации.
Что сравнивать
- Доля ошибок. 5xx у канарейки против 5xx у stable. Отдельно смотрят
рост 4xx: он часто означает сломанный контракт API, а не «пользователи стали
ошибаться». Для gRPC считают по
grpc_code, потому что там всё «200». - Latency. p99 и p95 канарейки к p99/p95 stable, как отношение: «не хуже, чем в 1.2 раза». Абсолютные миллисекунды в этой роли бесполезны.
- Насыщение. CPU, память (не подросло ли потребление вдвое - привет, будущий OOMKill), CFS-троттлинг, число горутин, использование пула БД. Утечка горутин видна на канарейке за минуты и не видна нигде больше.
- Ошибки исходящих вызовов. Новая версия может начать долбить зависимость в N раз чаще - по своим метрикам она при этом «зелёная».
- Бизнес-метрика. Самая важная и самая забываемая: конверсия, число успешных оплат, доля пустых поисковых выдач. Технически безупречный релиз, обнуливший конверсию, остаётся провалом, которого не видно ни в одном 5xx.
Как принимается решение
strategy:
canary:
steps:
- setWeight: 1
- pause: { duration: 10m } # долго: на 1 % статистика набирается медленно
- analysis: { templates: [{templateName: error-rate}, {templateName: latency-ratio}] }
- setWeight: 10
- pause: { duration: 10m }
- analysis: { templates: [{templateName: error-rate}, {templateName: latency-ratio}, {templateName: saturation}] }
- setWeight: 50
- pause: { duration: 15m }
- setWeight: 100
- Несколько замеров подряд. Один плохой интервал даёт шум, три неудачи
за анализ уже сигнал. Считают их не подряд, а в сумме: Flagger откатывает, когда неудач
набралось
threshold, Argo Rollouts - когда их большеfailureLimit. - Минимальный объём выборки. Пока по канарейке не прошло, скажем, 1000 запросов, любые доли ошибок недостоверны. Хорошие фреймворки умеют не выносить вердикт до набора выборки.
- Прогрев. Сразу после старта пода метрики обычно хуже: холодные пулы, холодный кэш процессора, первые циклы GC - обычные эффекты холодного старта. Этот интервал надо исключать, иначе будешь откатывать каждый релиз.
- Автоматический откат по провалу - и автоматическое продвижение по успеху. Канарейка, которую надо руками двигать дальше, в пятницу вечером застревает на 10 % до понедельника.
- Метрики должны различать версии. Нет лейбла вроде
version="canary"|"stable"или хотя бы имени workload, по которому их разделить (так делают встроенные метрики Flagger), - сравнивать физически нечего. Лейбл обычно добавляет service mesh или downward API через переменную окружения. - Baseline лучше, чем stable-как-есть. Строгий подход поднимает рядом третий набор подов старой версии, запущенных одновременно с канарейкой, и тогда сравниваются два одинаково «холодных» набора, а эффект прогрева уходит.
- Sticky-трафик искажает выборку. Если канарейке достаётся 1 % пользователей, а не 1 % запросов, и среди них попался тяжёлый клиент, метрики поедут без всякой вины релиза.
- Канарейка не ловит медленные эффекты. Утечка памяти, растущая по часу, деградация от роста таблицы, ошибка в фоновой задаче раз в сутки - всё это пройдёт канарейку успешно. Отсюда практика держать долгий последний шаг (50 % на несколько часов) для рискованных релизов.
Что даёт разделение деплоя и релиза
- Мелкие мержи вместо длинных веток. Недоделанная фича живёт в main за выключенным флагом, так что feature branch на три недели и мучительный мерж в конце не нужны. Это, собственно, и есть trunk-based development.
- Включение в удобный момент. Релиз в 10 утра понедельника при полной команде, а не в момент, когда пайплайн закончил.
- Точечный откат. Сломалась одна фича - выключили её. Остальные пять изменений этого релиза остаются в проде, чего не даёт откат версии.
- Таргетинг. Включить сначала внутренним сотрудникам, потом одному тенанту, потом 5 % по хешу user_id, потом всем. Canary так не умеет: он не знает, кто пользователь.
- Kill switch. Необязательную функциональность под нагрузкой можно выключить, это часть механики управляемой деградации.
Canary против флага, по пунктам
| Canary | Feature flag | |
|---|---|---|
| Что переключает | версию артефакта | поведение внутри версии |
| Где реализовано | роутер, ingress, service mesh | код приложения + сервис флагов |
| Гранулярность | весь релиз | одна фича |
| Кому | случайной доле запросов | конкретному сегменту |
| Знает про пользователя | нет | да |
| Что защищает | от плохого билда (паника, утечка, регресс latency) | от плохой фичи (логика, продуктовая гипотеза) |
Они не конкурируют: в зрелой команде работают оба. Canary страхует инфраструктурный риск релиза, флаг закрывает продуктовый.
// Флаг решается один раз на границе запроса,
// чтобы внутри одного запроса поведение не «мигало» между вызовами.
func (h *Handler) Price(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := UserFrom(ctx)
if h.flags.Enabled(ctx, "new-pricing", flag.User(u.ID), flag.Tenant(u.TenantID)) {
span := trace.SpanFromContext(ctx)
span.SetAttributes(attribute.Bool("flag.new_pricing", true)) // видно в трейсе
h.newPricing(w, r)
return
}
h.oldPricing(w, r)
}
Каждый флаг удваивает число путей исполнения. Десять живых флагов дают до 1024 комбинаций, из которых тестируются две: «всё выключено» и «всё включено». Дальше начинается знакомое: код обрастает ветками, которые никто не решается удалить («вдруг где-то включено»), тесты покрывают только одну сторону условия, а баг воспроизводится «только у некоторых». Отдельно стоят флаги, влияющие на данные: неделю писали в новом формате, потом флаг выключили - и в базе теперь два формата, которые кто-то должен разгребать.
- У флага есть владелец и дата смерти в момент создания. Не «потом решим».
- Автоматика в CI: флаг старше 60 дней ловит предупреждение в PR, старше 90
роняет сборку. Инструменты вроде
piranhaумеют даже сами вырезать из кода ветку устаревшего флага. - Задача на удаление создаётся вместе с задачей на раскатку - и попадает в тот же спринт, а не в бэклог.
- Инвентаризация: дашборд «живые флаги, их возраст, кто владелец, какой процент включения». Флаг на 100 % три месяца уже не флаг, а мёртвый код.
- Разные политики для разных типов. Release toggle обязан умереть; kill switch живёт легально и вечно; их надо явно различать, иначе автоматика будет ругаться на нужное.
- Отказ по умолчанию безопасный. Сервис флагов недоступен - значит, приложение работает по значению по умолчанию из локального кэша, а не падает и не блокируется на сетевом вызове в каждом хендлере.
rollout undo возвращает прежнюю версию за минуты. Настоящая проблема в том, что
у схемы БД нет отката: миграция уже удалила колонку, и старый код на неё падает.
Поэтому «быстрый rollback» - это не кнопка, а дисциплина совместимости:
любое изменение схемы разбивается на аддитивные шаги (expand/contract) так, чтобы
на каждом шаге схема работала и с текущей, и с предыдущей версией кода.Что нужно для быстрого отката кода
- Неизменяемые теги. Откатываться надо на конкретный digest, а не на
latest, содержимое которого поменялось. - Сохранённая история.
revisionHistoryLimitв Deployment,helm history, предыдущий коммит в GitOps-репозитории. - Автоматика.
helm upgrade --rollback-on-failure --timeoutоткатывает сам при неуспехе; Argo Rollouts откатывает по анализу метрик; в самом простом варианте хватает шага CI «еслиkubectl rollout statusвернул ошибку, делайrollout undo». - Репетиции. Процедура, которую никто не выполнял, в три часа ночи не работает. Откат надо проверять на stage регулярно и держать в runbook.
- Обратная совместимость API. Если N+1 начал возвращать поле, на которое уже полагается фронтенд, откат сервиса ломает фронтенд. Совместимость нужна и со схемой БД, и с потребителями, и с сообщениями в очереди, которые старая версия должна уметь прочитать.
Почему база - главная сложность
Три причины, и они разной природы:
- Состояние необратимо. Код работает как функция, его можно подменить.
Данные хранят состояние:
DROP COLUMNуничтожил информацию, и «откат миграции» вернёт колонку, но не содержимое. Down-миграции создают опасную иллюзию симметрии. - Две версии кода работают одновременно. Не «в момент переключения», а минуты - весь rolling update. Значит, схема обязана быть совместима с обеими сразу; это не пожелание, а требование.
- Миграции блокируют.
ALTER TABLEв PostgreSQL берётACCESS EXCLUSIVE: если он встал за долгим запросом, за ним выстраивается очередь всех запросов к таблице - сервис ложится, хотя формально «просто мигрируем».
Expand / contract на примере
-- Релиз 1 (EXPAND): только добавляем. Откат кода безопасен.
ALTER TABLE users ADD COLUMN contact_email text; -- NULL, без DEFAULT
CREATE INDEX CONCURRENTLY idx_users_contact_email ON users (contact_email);
-- Релиз 2: код пишет в обе колонки, читает старую. Схема не меняется.
-- Релиз 3 (BACKFILL): переносим данные пачками, не одним UPDATE на 50 млн строк
UPDATE users SET contact_email = email
WHERE contact_email IS NULL AND id IN (
SELECT id FROM users WHERE contact_email IS NULL AND email IS NOT NULL
ORDER BY id LIMIT 5000
); -- в цикле, с паузами
-- Релиз 4: код читает новую колонку, всё ещё пишет в обе.
-- Здесь живём несколько дней: окно, в котором откат ещё возможен.
-- Релиз 5 (CONTRACT): код перестаёт писать в email, и только после его выкатки
-- колонку удаляют. Точка невозврата
ALTER TABLE users DROP COLUMN email;
Пять релизов вместо одного ALTER TABLE ... RENAME выглядят
бюрократией ровно до первого раза, когда переименование колонки положило прод
на двадцать минут.
- Миграция и код деплоятся отдельно. Расширяющая миграция едет до кода, удаляющая - после него, отдельным шагом пайплайна или Job в кластере, не в init-контейнере каждого пода (иначе десять реплик конкурентно мигрируют одну базу; нужен хотя бы advisory lock).
- В одном релизе только аддитивное.
ADD COLUMNnullable,CREATE TABLE,CREATE INDEX CONCURRENTLY. Всё разрушающее уезжает отдельным поздним релизом. - С
NOT NULLиDEFAULTосторожнее. ДобавишьNOT NULLк существующей колонке, и Postgres проверит всю таблицу под эксклюзивной блокировкой.DEFAULTв новой колонке до Postgres 11 переписывал таблицу целиком, а сейчас переписывает, только если значение волатильное, например случайный UUID. Делается в три шага: колонка nullable → backfill → constraint черезNOT VALID+VALIDATE CONSTRAINT. lock_timeoutперед DDL и ретрай. Лучше миграция, которая не смогла взять блокировку за 3 секунды и честно упала, чем миграция, положившая таблицу на десять минут.- Roll forward вместо rollback. На проде обычно быстрее и безопаснее накатить исправляющую миграцию вперёд, чем пытаться «отмотать» состояние.
- Проверять миграции на копии прода. На таблице в тысячу строк всё мгновенно; на пятидесяти миллионах начинается совсем другой разговор.
Сообщения в очереди и изменённые кэши. Если N+1 начал публиковать события в новом формате, а ты откатился на N - в топике лежат сообщения, которые старый консьюмер не понимает, и он либо падает, либо молча их теряет. Поэтому правило совместимости распространяется и на схемы событий (Avro/Protobuf с реестром схем и проверкой совместимости в обе стороны: старый консьюмер должен читать новые сообщения, а BACKWARD, режим Confluent Schema Registry по умолчанию, проверяет только обратное). Так же и с Redis: изменили формат значения по ключу, откатились, и старый код читает мусор. Лечится версионированием: новый формат кладётся под новым префиксом ключа, а не поверх старого.
app:stage и app:prod, вы выкатили не то, что тестировали.Зачем каждое окружение
- dev даёт быструю обратную связь: синтетические данные, всё можно ломать. Деплой на каждый push, никаких гейтов.
- stage / pre-prod повторяет прод настолько, насколько это по карману: те же версии Postgres и брокера, та же схема конфигурации, обезличенные данные сопоставимого объёма. Здесь гоняются интеграционные и нагрузочные тесты и репетируются миграции.
- prod обслуживает живых пользователей. Отдельный кластер или как минимум отдельный неймспейс с жёстким RBAC, отдельные креды, отдельный доступ.
- Эфемерные окружения на PR: неймспейс поднимается на время ревью и удаляется при мерже. Дороже, но снимает вечную очередь «кто занял stage».
Как это выглядит на практике
charts/api/
values.yaml # общее: пробы, порты, стратегия, лимиты по умолчанию
values.dev.yaml # 1 реплика, DEBUG, тестовая БД, HPA выключен
values.stage.yaml # 2 реплики, INFO, стейджевая БД, сниженные лимиты
values.prod.yaml # 12 реплик, INFO, prod-хосты, PDB, anti-affinity, HPA
helm upgrade api ./charts/api --install --rollback-on-failure \
-f charts/api/values.yaml -f charts/api/values.prod.yaml \
--set image.digest=sha256:9f3c2a1... # тот же образ, что проверен на stage
Источники конфигурации выстраиваются от общего к частному: значения по умолчанию в коде (чтобы приложение стартовало без единой переменной) → общий values → per-env values → Secret из хранилища → переменные окружения пода. Приложение при старте обязано провалидировать конфигурацию и упасть с внятным сообщением, если чего-то нет: «нет DSN» на старте лучше, чем nil-паника через час на первом запросе к базе.
- Дрейф окружений. Stage потихоньку расходится с продом: другая версия БД, выключенная фича, вдвое меньше данных - и фраза «на stage работало» перестаёт что-либо значить. Лечится тем, что все различия сведены в один короткий файл, который можно прочитать глазами.
- Секреты в git. Приватный репозиторий не хранилище секретов. SOPS/Sealed Secrets держат шифротекст в git, External Secrets Operator берёт секреты из настоящего хранилища: Vault, облачного секрет-менеджера.
- Разные образы для разных окружений. Пересборка на каждый env даёт отдельный класс багов «в prod-сборке другая версия зависимости».
- Прод-креды в CI без ограничений. Пайплайн, который может всё, работает как учётка, которая может всё. Нужны OIDC-федерация вместо долгоживущих ключей, отдельная роль на окружение и ручной approval на prod.
- Тестовые данные в проде и наоборот. Дамп прода на dev без обезличивания превращается в утечку персональных данных, оформленную как удобство.
- Забытые окружения. Три «временных» стенда, за которые платят год; в одном из них крутится версия с известной уязвимостью и доступом в прод-базу.
Semver
MAJOR.MINOR.PATCH: мажор ломает контракт,
минор добавляет возможности, не ломая совместимости, патч чинит, не трогая контракт.
Плюс пререлизы (1.5.0-rc.1) и метаданные сборки (+build.42).
В Go это не соглашение, а часть системы модулей: начиная с v2 мажорная версия
входит в путь импорта (example.com/lib/v2) - именно для того,
чтобы v1 и v2 могли сосуществовать в одной сборке. Отсюда следствие:
бампнуть мажор в Go-библиотеке значит переименовать модуль, и относиться
к этому надо соответственно.
Оговорка про сервисы: semver придуман для библиотек с внешними потребителями.
У внутреннего сервиса весь контракт упирается в его API, его и версионируют отдельно
(/v1/, /v2/), а сам сервис часто маркируют
датой и SHA: 2026.08.26-9f3c2a1. Это нормальный, зрелый ответ.
Теги и версия внутри бинаря
# Версия зашивается в бинарь при сборке
go build -trimpath \
-ldflags "-s -w -X main.version=v1.4.2 -X main.commit=$(git rev-parse --short HEAD) \
-X main.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o app ./cmd/app
# Образ тегируется неизменяемо: версией и git SHA
docker tag app repo/app:v1.4.2
docker tag app repo/app:9f3c2a1 # git SHA, по нему деплоим
# repo/app:latest в проде не используется
- Тег git и тег образа указывают на один коммит и никогда не переписываются.
Перевыложить
v1.4.2с другим содержимым значит сломать всё, что построено на кэше и на доверии; в registry для этого естьimmutable tags, и их стоит включить. - Деплоить лучше по digest (
@sha256:...), а не по тегу: digest нельзя подменить в принципе. latestв проде запрещён. Непонятно, что развёрнуто; невозможно откатиться; сimagePullPolicy: Alwaysобычный перезапуск пода превращается в незапланированное обновление версии.- Версия должна отдаваться наружу: эндпоинт
/version, строка в логе при старте, лейбл в метрикеbuild_info- и тогда «какая версия в проде» перестаёт быть вопросом с тремя противоречивыми ответами, а на дашборде можно рисовать аннотации деплоев.
Changelog и автоматизация
Conventional Commits (feat:, fix:, feat!:)
дают машине достаточно информации, чтобы (1) вывести следующую semver-версию
и (2) сгенерировать changelog по разделам. Из инструментов берут git-cliff,
release-please, semantic-release. Сам файл пишут по Keep a Changelog:
разделы Added / Changed / Deprecated / Removed / Fixed / Security и секция Unreleased сверху.
Смысл changelog не в красоте, а в скорости диагностики: в момент инцидента ты смотришь на диапазон версий между «работало» и «не работает» и сразу видишь, какие изменения туда попали. Отдельная строка, которая должна быть в каждом релизе с миграцией: «содержит миграцию БД, откат ограничен». Она спасает дежурного от попытки откатить то, что откатывать нельзя.
«Минимум, который я считаю обязательным для любого сервиса: неизменяемый тег
с git SHA, версия внутри бинаря и в метрике build_info, аннотации
деплоев на графиках Grafana и отметка в changelog, что в релизе есть миграция. Этого хватает,
чтобы в момент инцидента за минуту ответить на три вопроса: что развёрнуто,
когда это развернули и можно ли откатывать. Без этого расследование начинается
с археологии по чатам, а это самые дорогие двадцать минут инцидента.»
Что стоит запомнить
- Дешёвые проверки ставят раньше дорогих, но интеграционные проверки нужны для контрактов с реальными зависимостями.
- Стратегия выкладки задаёт, как быстро можно переключить трафик и какой объём пользователей подвергнуть риску.
- Expand/contract сначала добавляет совместимую схему, затем переводит код и только потом удаляет старую структуру.
Частые ошибки
- Откат бинаря не откатывает автоматически уже применённую миграцию.
- Canary без метрики и автоматического или ручного решения о продолжении остаётся просто частичной выкладкой.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Сверь версии кода и схемы
Миграция сначала добавляет новую колонку, а позже отдельная миграция удаляет старую. Какие версии запросов совместимы сразу после первой миграции и когда можно запускать вторую?
-- migration 1
ALTER TABLE customers ADD COLUMN display_name text;const readV1 = SELECT customer_name FROM customers WHERE id = $1
const readV2 = SELECT display_name FROM customers WHERE id = $1
const writeCompat = UPDATE customers SET customer_name=$1, display_name=$1 WHERE id=$2
Ответ и разбор
После первой миграции обе версии читают существующую схему, потому что customer_name ещё есть, а display_name уже создана. Сначала выкатай совместимый writer, который заполняет обе колонки, и выполни backfill; затем переведи все readers на display_name. Удалять customer_name можно отдельной миграцией только после вывода старых экземпляров и проверки данных.
Код-ревью
2. Сделай миграцию обратно совместимой
Во время rolling deploy в кластере ещё есть старая версия приложения. Найди риск в миграции.
ALTER TABLE orders DROP COLUMN customer_name;
ALTER TABLE orders ADD COLUMN display_name text NOT NULL;
Ответ и разбор
Старая версия перестанет работать сразу после удаления колонки, а новый NOT NULL может не примениться к существующим строкам. Разбей изменение: добавь nullable колонку, выкатай совместимый код и заполни данные, проверь результат, затем отдельно добавь ограничение и позже удали старую колонку после вывода старого кода.
Применение
3. Выбери стратегию для проверки гипотезы
Релиз меняет алгоритм расчёта тарифа. Ошибка влияет на ограниченную группу пользователей, а результат можно измерить по доле перерасчётов и жалоб. Какую стратегию выкладки выбрать и какие условия задать до старта?
Ответ и разбор
Подойдёт canary: сначала направь ограниченную долю трафика на новую версию, сравни заранее выбранные метрики с контрольной группой, задай пороги остановки и способ вернуть трафик. Проверь совместимость состояния и данных до начала; сам процент трафика не ограничит ущерб, если запросы не изолированы по пользователям.
Применение
4. Разложи проверки по стоимости
Pipeline сейчас запускает полный интеграционный набор на Postgres, затем линтер и unit-тесты. Предложи порядок для быстрой обратной связи и объясни, что не нужно убирать.
Ответ и разбор
Поставь быстрые lint и unit проверки перед сборкой и интеграционным набором; затем собери артефакт, просканируй его и выкатай со smoke-проверкой. Интеграционные проверки оставь: они находят ошибки SQL, миграций и межпроцессного контракта, которые unit-тесты не доказывают. Порядок может учитывать независимый параллельный запуск.