voidlearn
 

4.1Пайплайн и стратегии выкатки

Сопоставишь проверки в pipeline с ценой и классом ошибки, затем сравнишь rolling, blue-green и canary. Научишься планировать миграцию схемы так, чтобы старая и новая версии приложения могли работать рядом.

Стадии пайплайна и почему они идут именно в таком порядке

Базовая последовательность: lint → test → build → scan → deploy. Порядок подчинён принципу fail fast: дешёвые проверки впереди дорогих, чтобы разработчик узнал об ошибке через 40 секунд, а не через 12 минут. Второе правило: каждая стадия проверяет свой класс проблем и не дублирует соседнюю.

СтадияЧто проверяетИнструменты в GoПорядок величины
lint / vetстиль, очевидные баги, забытый err, тени переменных, неправильный Printf, устаревшие APIgofumpt, go vet, golangci-lint (staticcheck, errcheck, ineffassign, gosec)секунды - 2 мин
unit-тестылогика в изоляции, гонки данныхgo test ./... -race -shuffle=on -count=11–5 мин
интеграционныереальные SQL-запросы, миграции, контракты с брокеромtestcontainers, docker-compose, отдельный тег сборки3–15 мин
buildсобирается ли вообще под все целевые платформы; воспроизводимостьmulti-stage Dockerfile, CGO_ENABLED=0, -trimpath, -ldflags с версией1–5 мин
scanуязвимости в зависимостях и в базовом образе, утёкшие секреты, лицензииgovulncheck, trivy image, gitleaks, генерация SBOM1–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 CD1–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 компилятора выручает локально и на постоянном раннере.

Чем ещё ускоряют Go-пайплайн
  • Разделить 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 по умолчанию 0 % 25 % 50 % 75 % 100 % поды меняются пачками в пределах maxSurge и maxUnavailable (по умолчанию по 25 %); откат - обратный rolling, минуты Blue-green два стека 0 % 0 % · v2 поднят 0 % · прогрет 100 % сразу 100 % красная черта - одно переключение роутера: откат такой же мгновенный, но ресурсов нужно ×2, и обе версии делят одну БД Canary по метрикам 0 % 1 % 10 % 50 % 100 % между шагами - пауза и проверка метрик канарейки против базовой версии; плохо стало - откат затрагивает 1 % пользователей Синее - старая версия, зелёное - новая. Разница между стратегиями не в том, «как обновить», а в том, «сколько пользователей увидит плохую версию до отката».
Три стратегии в одной системе координат. Rolling дешёвый, но откат такой же медленный, как выкатка. Blue-green даёт мгновенный откат за двойную цену. Canary ограничивает размер поражения, но требует, чтобы ты умел за пять минут отличить плохую версию от хорошей по графикам.
RollingBlue-greenCanary
Лишние ресурсы+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 разрывает связь между ними: код едет в прод выключенным, иногда за недели до включения, и включается отдельной операцией без пересборки и передеплоя.

CanaryFeature 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. Идея: любое изменение схемы разбивается на несколько релизов так, чтобы на каждом шаге схема была совместима и с текущей, и с предыдущей версией кода.

Переименование колонки email → contact_email без окна недоступности 1. EXPAND 2. ДВОЙНАЯ ЗАПИСЬ 3. BACKFILL + ЧТЕНИЕ 4. CONTRACT Схема ADD COLUMN contact_email NULL без NOT NULL и DEFAULT обе колонки существуют схема не меняется UPDATE пачками по 5000 строк, пока старых не 0 DROP COLUMN email отдельный релиз, через дни после (3) Код N: знает только старую колонку N+1: пишет в обе, читает старую N+2: пишет в обе, читает новую N+3: только новая колонка откат на предыдущую версию кода безопасен: схема понимает обе точка невозврата Инвариант каждого шага: схема совместима и с текущей версией кода, и с предыдущей. Пока он держится, rollback - обычная операция.
Expand / contract. Одно логическое изменение «переименовать колонку» превращается в четыре фазы: первые три откатываются независимо, и только финальный 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-версия. Ценность не в файле, а в том, что за минуту находишь, что поменялось между тем, что работало, и тем, что сломалось.

Вопросы

8
Суть: lint → test → build → scan → publish → deploy, и порядок продиктован ценой ошибки: дешёвые проверки впереди дорогих (fail fast), а зависимые - после того, от чего зависят (сканировать надо собранный образ, а не исходники). Каждая стадия ловит свой класс проблем; дублирование между ними - трата времени, а дырка между ними - инцидент.

Стадии и их зона ответственности

  1. Pre-commit / pre-push (локально). Формат и быстрый lint. Формально не часть CI, но именно здесь бесплатно ловится большая часть тривиального шума.
  2. lint / vet. Отвечает на вопрос «код вообще написан осмысленно?». go vet ловит настоящие баги (неправильный Printf, копирование мьютекса, недостижимый код), golangci-lint добавляет staticcheck, errcheck (проигнорированные ошибки), gosec. Секунды или минуты, поэтому первым.
  3. Unit-тесты. Логика в изоляции. Обязательно с -race хотя бы на PR: гонка, найденная здесь, стоит минуту, а в проде обойдётся в ночь. -shuffle=on ловит тесты, которые зависят от порядка, -count=1 ловит те, что проходят только из кэша.
  4. Интеграционные тесты. Настоящий Postgres и брокер (testcontainers). Здесь и только здесь проверяются миграции, реальный SQL и контракты. Дороже, значит после unit.
  5. build. Собирается ли артефакт под целевую платформу; multi-stage Dockerfile; CGO_ENABLED=0 для статического бинаря в distroless; -trimpath и -ldflags для воспроизводимости и версии внутри бинаря.
  6. scan. govulncheck (в отличие от простого аудита go.sum он смотрит, вызывается ли уязвимая функция вообще, и ложных срабатываний резко меньше), trivy по образу (уязвимости базового слоя), gitleaks по истории (утёкшие ключи), SBOM. После build, потому что сканируем тот самый образ.
  7. publish. Пуш в registry под неизменяемым тегом (git SHA), подпись (cosign), аттестация SBOM. С этого момента артефакт заморожен.
  8. 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, а пайплайн из системы контроля превращается в ритуал. Флакающий тест либо чинится, либо помечается и выносится из блокирующих, но не оставляется падать “иногда”.»

Суть: сначала измерить, потом чинить. В Go-проекте время почти всегда уходит в три места: скачивание модулей (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 - дёшево, но откат такой же медленный, как выкатка, и плохую версию постепенно увидят все. Blue-green - мгновенный откат за двойные ресурсы, но 100 % пользователей получают новую версию одномоментно. Canary - ограничивает размер поражения до 1–5 %, но требует сплита трафика и метрик, по которым можно принять решение.

Как работает каждая

  • 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 или просто соотношением числа подов, если балансировка равномерная.

Сравнение по тому, что действительно важно

КритерийRollingBlue-greenCanary
Ресурсы+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, плюс бизнес-метрика, конверсия оформления); сколько держали каждый шаг и почему именно столько (набор статистики); реальный случай, когда откатило - и что оказалось причиной. Последний пункт ценнее всех остальных: он показывает, что канарейка была настоящая, а не в презентации.

Суть: сравнивать надо канарейку со стабильной версией в один и тот же момент времени, а не с абсолютным порогом. Тогда автоматически нормализуется всё внешнее: суточный профиль нагрузки, тормозящая база, проблемы у соседнего сервиса. Набор метрик: доля ошибок, latency (p95/p99), насыщение ресурсов и хотя бы одна бизнес-метрика. Решение принимается несколько раз подряд, а не по одному замеру.

Что сравнивать

  1. Доля ошибок. 5xx у канарейки против 5xx у stable. Отдельно смотрят рост 4xx: он часто означает сломанный контракт API, а не «пользователи стали ошибаться». Для gRPC считают по grpc_code, потому что там всё «200».
  2. Latency. p99 и p95 канарейки к p99/p95 stable, как отношение: «не хуже, чем в 1.2 раза». Абсолютные миллисекунды в этой роли бесполезны.
  3. Насыщение. CPU, память (не подросло ли потребление вдвое - привет, будущий OOMKill), CFS-троттлинг, число горутин, использование пула БД. Утечка горутин видна на канарейке за минуты и не видна нигде больше.
  4. Ошибки исходящих вызовов. Новая версия может начать долбить зависимость в N раз чаще - по своим метрикам она при этом «зелёная».
  5. Бизнес-метрика. Самая важная и самая забываемая: конверсия, число успешных оплат, доля пустых поисковых выдач. Технически безупречный релиз, обнуливший конверсию, остаётся провалом, которого не видно ни в одном 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 % на несколько часов) для рискованных релизов.
Суть: флаги разделяют деплой и релиз. Код едет в прод выключенным, а включается отдельным действием - без пересборки, без деплоя, за секунды, для выбранного сегмента пользователей. Отличие от canary принципиальное: canary переключает версию артефакта целиком средствами инфраструктуры, флаг переключает одну фичу внутри одной версии средствами кода. Цена флага - ветвление, то есть техдолг, который надо погашать по расписанию.

Что даёт разделение деплоя и релиза

  • Мелкие мержи вместо длинных веток. Недоделанная фича живёт в main за выключенным флагом, так что feature branch на три недели и мучительный мерж в конце не нужны. Это, собственно, и есть trunk-based development.
  • Включение в удобный момент. Релиз в 10 утра понедельника при полной команде, а не в момент, когда пайплайн закончил.
  • Точечный откат. Сломалась одна фича - выключили её. Остальные пять изменений этого релиза остаются в проде, чего не даёт откат версии.
  • Таргетинг. Включить сначала внутренним сотрудникам, потом одному тенанту, потом 5 % по хешу user_id, потом всем. Canary так не умеет: он не знает, кто пользователь.
  • Kill switch. Необязательную функциональность под нагрузкой можно выключить, это часть механики управляемой деградации.

Canary против флага, по пунктам

CanaryFeature 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 живёт легально и вечно; их надо явно различать, иначе автоматика будет ругаться на нужное.
  • Отказ по умолчанию безопасный. Сервис флагов недоступен - значит, приложение работает по значению по умолчанию из локального кэша, а не падает и не блокируется на сетевом вызове в каждом хендлере.
Суть: откат кода стоит копейки - старый ReplicaSet на месте, 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 начал возвращать поле, на которое уже полагается фронтенд, откат сервиса ломает фронтенд. Совместимость нужна и со схемой БД, и с потребителями, и с сообщениями в очереди, которые старая версия должна уметь прочитать.

Почему база - главная сложность

Три причины, и они разной природы:

  1. Состояние необратимо. Код работает как функция, его можно подменить. Данные хранят состояние: DROP COLUMN уничтожил информацию, и «откат миграции» вернёт колонку, но не содержимое. Down-миграции создают опасную иллюзию симметрии.
  2. Две версии кода работают одновременно. Не «в момент переключения», а минуты - весь rolling update. Значит, схема обязана быть совместима с обеими сразу; это не пожелание, а требование.
  3. Миграции блокируют. 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 COLUMN nullable, 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: изменили формат значения по ключу, откатились, и старый код читает мусор. Лечится версионированием: новый формат кладётся под новым префиксом ключа, а не поверх старого.

Суть: окружения - это последовательность фильтров, где каждый следующий ближе к бою. Главное правило конфигурации одно: артефакт один и тот же во всех окружениях, различия - только снаружи (переменные, ConfigMap, Secret, values-файл). Как только появляются образы 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 - обещание потребителю (ломающее / добавляющее / чинящее), неизменяемые теги - возможность откатиться, changelog - возможность быстро сузить круг подозреваемых.

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-тесты не доказывают. Порядок может учитывать независимый параллельный запуск.