Тема 04

CI/CD и деплой

Путь от git push до прода и то, что на этом пути обычно идёт не так. Стадии пайплайна и кэширование, rolling, blue-green и canary, откат релиза и миграции базы, которые не откатываются так же просто, как бинарь, feature flags и semver. Потом GitLab CI на практике: граф джобов, правила, кэш и артефакты, сервисы, раннеры и сборка образа. И поставка: один артефакт по дайджесту от сборки до прода, выкатка из CI против GitOps, секреты через OIDC, подпись, SBOM и проверка образа при выкатке.

Что реально проверяют этой секцией

Различаешь ли ты деплой и релиз. Кандидат, который говорит «выкатили — не взлетело — откатили», и кандидат, который говорит «зальём под фичефлагом, миграция обратно совместима по expand-contract, откатывать будем сам бинарь, а не схему» — второй прошёл через инцидент, а не пересказал статью.

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

Тема, где легко отвечать общими словами и легко на этом провалиться. Интервьюеру не нужен пересказ вкладок GitLab, он хочет понять, задумывался ли ты о трёх вещах: сколько стоит ошибка на каждой стадии, как быстро ты её откатишь и что делать, если откатить нельзя, потому что миграция уже удалила колонку. Всё остальное сводится к синтаксису, а он гуглится.

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

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

Переименование колонки 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, что в релизе есть миграция. Этого хватает, чтобы в момент инцидента за минуту ответить на три вопроса: что развёрнуто, когда это развернули и можно ли откатывать. Без этого расследование начинается с археологии по чатам, а это самые дорогие двадцать минут инцидента.»

4.2GitLab CI на практике

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

Что сможешь объяснить после этой главы
  • Как needs превращает стадии в граф и откуда два пайплайна на ветке с открытым merge request.
  • Почему GOCACHE кладут внутрь каталога проекта и почему он экономит больше, чем кэш модулей.
  • Почему go test -race падает на alpine и какое регулярное выражение покрытия врёт.
  • Откуда у сервиса Postgres имя хоста и как собрать образ без Docker-демона.
  • Почему resource_group не гарантирует, что последней выкатится свежая версия.

Примеры сняты на одноразовом репозитории shop/orders (chi, pgx v5.11.0, unit-тесты и интеграционный тест на Postgres, Go 1.27.1) через npx gitlab-ci-local@4.75.1, дальше просто gitlab-ci-local, с --no-color и без служебных строк, в Docker Desktop 29.8 на 4 CPU. Это не настоящий раннер: где он ведёт себя иначе, есть оговорка, остальное сверено с документацией GitLab 19.3. Порядок стадий и принципы кэширования разобраны в главе 4.1.

Стадии и граф needs

Джобы одной стадии идут параллельно, следующая стадия ждёт, пока все джобы предыдущей закончатся успешно. Без stages GitLab берёт .pre, build, test, deploy, .post, джоб без stage попадает в test. Ключ needs перечисляет, кого ждать: джоб стартует, как только они закончились, и порядок стадий на него не действует. needs: [] запускает джоб сразу. В списке до 50 джобов, можно и из своей стадии.

stages: [build, test]

compile:
  stage: build
  image: alpine:3.24
  script: sleep 5

lint:
  stage: test
  image: alpine:3.24
  needs: []
  script: echo lint

unit:
  stage: test
  image: alpine:3.24
  script: echo unit
$ gitlab-ci-local --timestamps \
    --file .gitlab-ci-needs.yml
[04:58:21  756 μs] lint    starting alpine:3.24 (test)
[04:58:21 3.34 ms] compile starting alpine:3.24 (build)
…
[04:58:22  538 ms] compile $ sleep 5
[04:58:22  594 ms] lint    finished in 594 ms
[04:58:27  5.62 s] compile finished in 5.62 s
[04:58:27 1.06 ms] unit    starting alpine:3.24 (test)
…

lint закончился раньше, чем проснулся compile, а unit ждал его. В orders проверкам нужны только модули, у всех needs: [deps], а build ждёт все пять. Схема нарисована по gitlab-ci-local --list, который печатает стадию, when и needs каждого джоба.

Пайплайн push в main: стадии и needs .pre lint test build deploy deps модули, 2,3 с lint golangci-lint, 15 с vet go vet, 5,9 с needs: [deps]: стартуют вместе сразу после deps unit -race, junit, 11 с integration postgres, 9,5 с vulncheck govulncheck, 15 с needs: все пять build bin/orders, 3,3 с container BuildKit, 5,6 с deploy-staging resource_group deploy-production тег, when: manual compat: [1.26.8] schedule, needs: [] в пайплайн push в main не входит (rules); у джобов время с тёплым кэшем
Граф вместо конвейера. Стадии задают колонки, а порядок запуска задают needs: пять проверок стартуют сразу после deps, build ждёт их все, выкатка ждёт образ. Джобы, которые rules не пустили в этот пайплайн, остаются в файле, но не создаются.

rules, типы пайплайнов и workflow

Состав пайплайна решается при его создании. rules проверяются по порядку до первого совпадения, и оно добавляет джоб со своим when (по умолчанию on_success) или не добавляет при never; не совпало ничего, и джоба нет. if, changes и exists внутри правила складываются через «и». only и except устарели, мешать их с rules в одном пайплайне документация не советует.

CI_PIPELINE_SOURCE равна push для веток и тегов, в других случаях это merge_request_event, schedule, web, api, trigger, parent_pipeline и ещё несколько значений. CI_COMMIT_BRANCH нет у merge request и тегов, CI_COMMIT_TAG есть только у тегов. В orders compat (parallel:matrix по GO_VERSION) пускается правилом $CI_PIPELINE_SOURCE == "schedule", deploy-staging правилом $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH, deploy-production правилом $CI_COMMIT_TAG с when: manual. Таблица собрана из gitlab-ci-local --list-all, другие типы пайплайна заданы через --variable и --unset-variable CI_COMMIT_BRANCH:

Джобpush в mainpush в веткуmerge requestтеграсписание на main
depscontainerдадададада
compat ×2нетнетнетнетда
deploy-stagingданетнетнетда
deploy-productionнетнетнетmanual, allow_failure falseнет

У расписания на main есть CI_COMMIT_BRANCH, и ночной прогон совместимости заодно выкатит staging, если не добавить && $CI_PIPELINE_SOURCE == "push". rules:changes в merge request сравнивает с целевой веткой, в пайплайне ветки с предыдущим коммитом. У новой ветки, тега, расписания и ручного запуска совпадение есть всегда, если не задать базу в changes:compare_to. gitlab-ci-local сравнивает рабочее дерево с origin/main: после echo "# local edit" >> Dockerfile джоб с changes: [Dockerfile] сменил never на on_success без коммита.

Push в ветку с открытым merge request для GitLab два события: push ветки и обновление merge request. Если правила пускают оба, на коммит создаются два пайплайна. Обычно виновато - when: always в конце списка или джобы без rules рядом с джобами, у которых есть правило для merge request. workflow:rules решает, создавать ли пайплайн вообще; вот рецепт документации с правилом для тегов:

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
      when: never
    - if: $CI_COMMIT_BRANCH
    - if: $CI_COMMIT_TAG

Второе правило отбрасывает пайплайн ветки с открытым merge request: CI_OPEN_MERGE_REQUESTS хранит до четырёх ссылок вида shop/orders!7 и известна до создания пайплайна. Без четвёртого правила, которого в документации нет, тег пайплайн не создаёт. Merge train и пайплайны на результате слияния разобраны в разделе Git, глава 4.2. С GitLab 19.2 то же делает бета-настройка Skip branch pipelines for merge requests.

Этот рецепт локально не проверить

gitlab-ci-local 4.75.1 берёт из workflow:rules только variables. Файл с единственным правилом - when: never отработал целиком (hello > workflow did not stop me, PASS hello), а с --variable 'CI_OPEN_MERGE_REQUESTS=shop/orders!7' список джобов не пустеет.

cache и artifacts

Кэш хранит у раннера или в S3 то, что можно скачать или пересобрать, и отдаёт любому джобу с тем же ключом, хоть из следующего пайплайна. Гарантий нет: раннер без общего хранилища его не увидит. Артефакт хранит результат джоба в GitLab до expire_in (по умолчанию 30 дней) и достаётся джобам этого пайплайна. Пути обоих считаются от CI_PROJECT_DIR и выйти за него не могут. В образе golang модули лежат в /go/pkg/mod, а проект в /builds/shop/orders:

$ gitlab-ci-local --file .gitlab-ci-cache-outside.yml
deps-outside $ go env GOMODCACHE
deps-outside > /go/pkg/mod
…
WARNING: processPath: artifact path is not a subpath of project directory: /go/pkg/mod
deps-outside cache created in '.gitlab-ci-local/cache/gomod-outside' in 256 ms
…
$ find .gitlab-ci-local/cache/gomod-outside -type f | wc -l
       0

Джоб зелёный, кэш «создан» и пуст; такое же предупреждение есть в исходниках GitLab Runner. Поэтому каталоги Go переносят внутрь проекта:

variables:
  GOPATH: $CI_PROJECT_DIR/.go
  GOCACHE: $CI_PROJECT_DIR/.go-build
  TOOLS: -modfile=tools/go.mod

.go-cache:
  cache:
    - key: gobuild-$CI_JOB_NAME_SLUG
      paths: [.go-build/]
    - &gomod-cache
      key:
        files: [go.sum, tools/go.sum]
        prefix: gomod
      paths: [.go/pkg/mod/]
      policy: pull

deps:
  stage: .pre
  cache:
    - <<: *gomod-cache
      policy: pull-push
  script:
    - go mod download
    - go mod download $TOOLS

Каталоги с точкой в начале шаблон ./... пропускает. Инструменты CI закреплены в отдельном tools/go.mod, чтобы их зависимости не поднимали версии у сервиса. cache:key:files берёт до двух файлов; с GitLab 18.4 ключ считается по их содержимому, а по коммитам его считает files_commits. Модули записывает один deps, остальные получают .go-cache через extends и только читают. Кэш компилятора у каждого джоба свой: unit собирает с -race, vet с тегом, и общий ключ достался бы последнему записавшему. Три прогона одного коммита (кэш копируется docker cp, джобы делят 4 CPU, сравнивай столбцы):

Джоббез кэшамодули и GOCACHEтолько модули
deps7,5 с2,3 с2,3 с
lint / vet37 / 37 с15 / 5,9 с39 / 39 с
unit / integration46 / 34 с11 / 9,5 с45 / 33 с
vulncheck / build32 / 8,0 с15 / 3,3 с29 / 6,7 с
пайплайн1,18 мин32 с1,08 мин

Почти весь выигрыш дал GOCACHE, ценой места: кэш компилятора unit занял 284 МБ, модули 176 МБ. В GOCACHE лежат и результаты тестов (✓ internal/price (cached)), поэтому интеграционный тест идёт с -count=1. К ключам GitLab добавляет -protected или -non_protected, и обычная ветка кэш main не видит. cache:fallback_keys не спасёт, суффикс получают и они, а общий кэш через cache:unprotect: true даёт любой ветке подменить кэш main.

Артефакт build (paths: [bin/orders], expire_in: 1 day) забирает container. Джоб без needs получает артефакты всех ранних стадий (сужает dependencies), с needs только перечисленных. artifacts:reports загружаются при любом исходе джоба. Кэш распаковывается раньше артефактов, и при общем пути артефакт его перезапишет.

Кэш и артефакты между джобами и пайплайнами Пайплайн 1, холодный: 1,18 мин deps скачивает модули unit компилирует с нуля build go build container берёт bin/orders push gomod pull gomod push gobuild-unit bin/orders needs: [build] Кэш раннера или S3 gomod + SHA(go.sum, tools/go.sum) gobuild-unit, gobuild-build, … Артефакты в GitLab build: bin/orders, 1 day unit: report.xml, coverage.xml pull gomod pull gomod и gobuild-unit новый bin/orders needs: [build] deps модули уже есть unit тесты (cached) build 3,3 с вместо 8,0 container свой bin/orders Пайплайн 2, тот же go.sum: 32 с
Кэш и артефакты. Модули и кэш компилятора достаются следующему пайплайну с тем же ключом, а бинарь уходит в GitLab артефактом и достаётся джобу, который назвал build в needs.

Джобы Go: линтер, тесты, покрытие, govulncheck

golangci-lint берут из образа golangci/golangci-lint:v2.13.2-alpine. Конфиг v2 начинается с version: "2", набор по умолчанию: errcheck, govet, ineffassign, staticcheck, unused. Таймаута по умолчанию в v2 нет (в v1.64 была минута), и --timeout 5m ставит предел короче таймаута джоба. go test сам запускает лишь часть проверок vet, поэтому джоб vet гоняет полный go vet, заодно с -tags integration.

unit:
  stage: test
  extends: .go-cache
  needs: [deps]
  variables:
    CGO_ENABLED: "1"
  before_script:
    - apk add --no-cache gcc musl-dev
  script:
    - go tool $TOOLS gotestsum --junitfile report.xml -- -race -coverprofile=coverage.out ./...
    - go tool cover -func=coverage.out | tail -n 1
    - go tool $TOOLS gocover-cobertura < coverage.out > coverage.xml
  coverage: '/total:\s+\(statements\)\s+\d+.\d+%/'
  artifacts:
    when: always
    expire_in: 1 week
    reports:
      junit: report.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

Детектору гонок нужен cgo, а везде, кроме macOS, и C-компилятор. В golang:1.27.1-alpine его нет, Go выключает cgo, и джоб без before_script и CGO_ENABLED падает с go: -race requires cgo; enable cgo by setting CGO_ENABLED=1. В debian-образе golang:1.27.1 gcc по его Dockerfile уже есть, но сжатым он весит 316 МБ против 75 МБ. На alpine установка отнимает около 3 секунд на джоб, а детектор с musl работает: тест с гонкой упал с WARNING: DATA RACE. С -race покрытие считается в режиме atomic, -covermode=count Go отвергает.

По JUnit XML от gotestsum --junitfile GitLab показывает упавшие тесты в merge request. Процент покрытия он ищет выражением coverage в логе и берёт последнее совпадение. Выражение из документации для одиночного go test -cover совпадает со строкой каждого пакета:

unit > ✓  internal/price (cached) (coverage: 100.0% of statements)
unit > ∅  cmd/orders (55ms) (coverage: 0.0% of statements)
unit > ∅  internal/store (53ms) (coverage: 0.0% of statements)
…
unit > total:						(statements)	14.8%
…
 PASS  unit 0.0% coverage

С выражением по строке total: тот же джоб даёт 14.8% coverage. Отчёт Cobertura от gocover-cobertura v1.5.0 подсвечивает покрытые строки в diff merge request. JUnit и Cobertura gitlab-ci-local не обрабатывает.

govulncheck v1.8.0 сверяет зависимости с vuln.go.dev и ищет по графу вызовов путь до уязвимой функции. 17 сентября 2026 на main он нашёл GO-2026-5970 в golang.org/x/text@v0.29.0, но джоб прошёл: Your code is affected by 0 vulnerabilities. This scan also found 1 vulnerability in packages you import. В ветке с pgx v5.5.3:

vulncheck > Vulnerability #2: GO-2024-2606
…
vulncheck >       #1: internal/store/store.go:47:86: store.Store.Total calls pgxpool.poolRow.Scan, which eventually calls pgconn.MultiResultReader.Close
…
vulncheck > Your code is affected by 2 vulnerabilities from 1 module.
…
vulncheck finished in 7.45 s  FAIL 3

«Affected» значит, что найден путь от твоего кода до уязвимой функции: на уровне -scan по умолчанию, symbol, код выхода 3 дают только такие находки. Уязвимость в импортированном, но не вызываемом пакете печатается справкой, а с -scan package тот же джоб упал бы. С -format json код всегда 0, тесты проверяются только с -test. База пополняется и без твоих коммитов, новое в ней ловит джоб по расписанию.

services: Postgres рядом с тестом

integration:
  stage: test
  extends: .go-cache
  needs: [deps]
  services:
    - postgres:18-alpine
  variables:
    POSTGRES_DB: orders
    POSTGRES_USER: orders
    POSTGRES_PASSWORD: orders
    DATABASE_URL: postgres://orders:orders@postgres:5432/orders?sslmode=disable
  script:
    - go test -tags integration -count=1 ./internal/store/...

Имя хоста сервиса раннер берёт из имени образа без тега, заменяя / на __ или -: postgres:18-alpine становится postgres, другое имя задаёт alias. До before_script раннер ждёт открытия портов из образа до 30 секунд (wait_for_services_timeout), а не дождавшись, пишет probably didn't start properly и идёт дальше. У Postgres порт не откроется раньше времени: на время инициализации entrypoint держит временный сервер с listen_addresses=''.

$ gitlab-ci-local --file .gitlab-ci-services.yml
db-check started service image: postgres:18-alpine with aliases: db, postgres in 143 ms
db-check service image: postgres:18-alpine healthcheck passed in 1.2 s
db-check $ getent hosts db
db-check > 172.24.0.2        db  db
db-check $ echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD POSTGRES_DB=$POSTGRES_DB"
db-check > POSTGRES_PASSWORD=orders POSTGRES_DB=
db-check $ PGPASSWORD=$POSTGRES_PASSWORD psql -h db -U postgres -d orders -Atc 'select current_database()'
db-check > orders

Здесь у сервиса alias: db, пароль в variables джоба, POSTGRES_DB в services:variables. Переменные джоба достались и сервису, а services:variables только ему; переменные из настроек проекта, по документации, до сервиса не доходят. Друг друга сервисы видят только в сети на джоб, её включает флаг FF_NETWORK_PER_BUILD, а gitlab-ci-local создаёт её всегда.

Переменные

vars > CI_COMMIT_SHA=dff08a8ce4cd87b3bfeffc557058ea6661cd62dc
vars > CI_COMMIT_SHORT_SHA=dff08a8c
vars > CI_COMMIT_REF_NAME=Feature/ORD-42_discount
vars > CI_COMMIT_REF_SLUG=feature-ord-42-discount
vars > CI_DEFAULT_BRANCH=main
vars > CI_PIPELINE_SOURCE=push
vars > CI_PROJECT_DIR=/builds/shop/orders

CI_COMMIT_REF_SLUG делается из имени ветки: нижний регистр, всё кроме a-z и 0-9 заменено на -, до 63 байт; её ставят в имена хостов и ключи кэша. Одноимённые переменные перекрываются от старших к младшим: политики, переменные пайплайна (ручной запуск пайплайна или джоба, расписание, API, trigger), проекта, группы, инстанса, dotenv, variables джоба, variables файла, выкатки, предопределённые. Переменная проекта перекрывает одноимённую из YAML, переменная пайплайна даже предопределённую, поэтому с GitLab 17.7 вместо них советуют inputs. В YAML значения берут в кавычки: VAR1: 012345 прочитается как восьмеричное 5349. Секреты задают в настройках:

СвойствоЧто делаетОграничение
Protectedтолько пайплайны защищённых веток и тегов (merge request по настройке с 18.1)кто пушит в такую ветку, получит значение
Masked[MASKED] в логе; видимость по умолчанию с 18.3одна строка, от 8 символов; base64 маску обходит
Masked and hiddenзначение скрыто и в настройках (GA в 17.6)только при создании переменной
Type: Fileзначение во временном файле, в переменной путьдля переменных из YAML недоступно

С GitLab 18.6 Expand variable reference по умолчанию выключен, $ в значении остаётся символом. OIDC-токены id_tokens разобраны в главе 4.3. gitlab-ci-local берёт секреты из .gitlab-ci-local-variables.yml, защищённость и маскирование не воспроизводит.

include, extends, !reference и компоненты

include берёт файл из того же репозитория (local), другого проекта с ref (project), по HTTPS (remote, с 17.9 сверка integrity), из шаблонов GitLab (template) или компонент (component). Включённое разбирается раньше основного файла и сливается с ним, одноимённые ключи основного главнее. Параметры передаются через spec:inputs с типом, умолчанием, вариантами и регулярным выражением; $[[ inputs.… ]] подставляется при создании пайплайна.

# ci/lint.yml
spec:
  inputs:
    version:
      default: v2.13.2
    timeout:
      default: 5m
      regex: ^\d+m$
---
lint:
  stage: lint
  image: golangci/golangci-lint:$[[ inputs.version ]]-alpine
  script:
    - golangci-lint run --timeout $[[ inputs.timeout ]]

.gitlab-ci-include-inputs.yml подключает его пунктом - local: ci/lint.yml с inputs: timeout: 10m, а .gitlab-ci-include-bad-input.yml с timeout: 90s:

$ gitlab-ci-local --preview --file .gitlab-ci-include-inputs.yml
…
    - golangci-lint run --timeout 10m
$ gitlab-ci-local --list --file .gitlab-ci-include-bad-input.yml
This GitLab CI configuration is invalid: `ci/lint.yml`: `timeout` input: `90s` does not match required regex: ^\d+m$.

В templates/go-lint.yml тот же файл становится компонентом $CI_SERVER_FQDN/shop/orders/go-lint@1.0.0; версия задаётся тегом, SHA или веткой, а для опубликованных в CI/CD Catalog (GA с 17.0) ещё @1 и @~latest. По @$CI_COMMIT_SHA он сработал и в gitlab-ci-local.

extends сливает джоб с шаблоном: ключи джоба главнее, словари вроде variables объединяются, списки заменяются целиком. Для шаблона .go с variables: {GOFLAGS: -trimpath} и before_script: [go version] и джоба с extends: .go, своим CGO_ENABLED и своим before_script --preview показал обе переменные и один apk add, а go version пропал. Список собирают через !reference, который, в отличие от YAML-якорей, работает и между файлами:

unit-reference:
  extends: .go
  before_script:
    - !reference [.go, before_script]    # в --preview: go version, затем apk add
    - apk add --no-cache gcc musl-dev
  script: go test -race ./...

Раннеры, executors и теги

gitlab-runner сам ходит в GitLab за работой: в цикле шлёт POST /api/v4/jobs/request и выполняет полученный джоб через executor. Соединение открывает раннер, входящий доступ ему не нужен.

ExecutorГде идёт джобЧто учесть
dockerновый контейнер из image, сервисы рядомкэш у каждого раннера свой, пока нет S3
kubernetesпод на джоб: build, helper и по контейнеру на сервискэш в томе /cache пода или из хранилища
docker-autoscaler, instanceмашины, которые раннер создаёт и удаляет через fleetingзамена устаревшему Docker Machine
shellна хосте под пользователем раннераmaintenance mode, окружение не чистится

Джоб достаётся только раннеру, у которого есть все его tags; без тегов его возьмёт раннер с Run untagged jobs, иначе джоб висит в pending. interruptible: true разрешает отменить джоб при новом коммите в ветку; по умолчанию (auto_cancel:on_new_commit: conservative) пайплайн отменяется, пока не стартовал джоб с interruptible: false, поэтому у выкаток orders стоит false. timeout не превышает таймаут раннера. retry повторяет до двух раз, retry:when сужает повтор до отказов инфраструктуры (в 19.1 появились runner_external_dependency_failure и runner_interrupted), а повтор на script_failure прячет нестабильные тесты. Ничего из этого, как и resource_group, gitlab-ci-local не исполняет.

Сборка образа в CI

Джоб docker-executor сам живёт в контейнере, и сборщик ему нужно откуда-то взять. BuildKit, кэш слоёв и мультиплатформенную сборку разбирает статья «Docker», глава 2.2.

СпособКак устроенЦена
Docker-in-Dockerсервис docker:29.8.1-dind; с DOCKER_TLS_CERTDIR: "/certs" и томом /certs/client клиент ходит на tcp://docker:2376раннер с privileged = true
проброс docker.sockджоб говорит с демоном хостаroot на хосте раннера
BuildKit rootless, buildahдемон на время сборки или без демона, без privilegedнужны вызовы для user namespace
container:
  stage: build
  image:
    name: moby/buildkit:v0.33.0-rootless
    entrypoint: [""]
  needs: [build]
  services:
    - name: registry:3
      alias: registry
  variables:
    BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
  script:
    - buildctl-daemonless.sh build
        --frontend dockerfile.v0
        --local context=.
        --local dockerfile=.
        --output type=image,name=registry:5000/orders:$CI_COMMIT_SHORT_SHA,push=true,registry.insecure=true
    - wget -qO- http://registry:5000/v2/orders/tags/list

Без entrypoint: [""] образ запустит демон вместо скрипта. Dockerfile копирует артефакт bin/orders в distroless-образ. Вместо реестра GitLab здесь сервис registry:3; в GitLab образ назывался бы $CI_REGISTRY_IMAGE:…, а before_script писал бы ~/.docker/config.json. gitlab-ci-local по умолчанию запускает джобы от 0:0, раннер так не делает, отсюда --no-umask:

$ gitlab-ci-local --no-umask container
…
container > [rootlesskit:parent] error: failed to start the child: fork/exec /proc/self/exe: operation not permitted
…
container finished in 3.68 s  FAIL 1
$ gitlab-ci-local --no-umask --privileged container
…
container > #7 pushing manifest for registry:5000/orders:dff08a8c@sha256:84da9a4b305ba5d8e69a34375054dcb1d873ae948e9159f9800fd35e3c22bb02 done
…
container > {"name":"orders","tags":["dff08a8c"]}
container finished in 5.62 s

Rootless-демону нужны свой user namespace и монтирования, а встроенный в Docker Engine профиль seccomp пускает их только при CAP_SYS_ADMIN. Docker Desktop тут ни при чём: так будет на любом docker-раннере с настройками по умолчанию, и buildah 1.43.2 падает так же; что делать и что стало с kaniko, в вопросе 3. Подпись образа, SBOM и выкатка по дайджесту разобраны в главе 4.3.

environment, manual и resource_group

deploy-production:
  stage: deploy
  image: alpine:3.24
  needs: [container]
  rules:
    - if: $CI_COMMIT_TAG
      when: manual
  environment:
    name: production
    url: https://orders.example.com
  resource_group: production
  interruptible: false
  script:
    - echo "deploy orders:$CI_COMMIT_SHORT_SHA ($CI_COMMIT_TAG) to $CI_ENVIRONMENT_NAME"

environment создаёт окружение, если его нет, и пишет выкатку в его историю; джоб получает CI_ENVIRONMENT_NAME. Защищённые окружения и одобрения выкаток есть в Premium и Ultimate, сама выкатка в главе 4.3. У when: manual два умолчания. На уровне джоба это allow_failure: true: пайплайн зелёный без нажатия, а для следующих стадий незапущенный джоб считается успешным. Внутри rules это allow_failure: false, и пайплайн стоит в статусе blocked, пока джоб не запустят. resource_group держит один слот на все пайплайны проекта, два джоба с production разом не пойдут, но порядок в режиме по умолчанию unordered не обещан (вопрос 2). gitlab-ci-local группу не соблюдает, из окружения он только подставляет CI_ENVIRONMENT_NAME.

Соответствие с GitHub Actions

GitHub ActionsGitLab CI
workflow в .github/workflows/пайплайн из .gitlab-ci.yml и include
on: push, pull_request, scheduleCI_PIPELINE_SOURCE, workflow:rules, rules; расписания в интерфейсе
job, runs-onджоб, tags
step: run, usesстроки script; шаги run пока экспериментальные
needs, стадий нетneeds поверх стадий
actions/cache, restore-keyscache, fallback_keys
upload-artifact, download-artifactartifacts, скачиваются по needs и стадиям
strategy.matrix, до 256 джобовparallel:matrix, до 200
environments, protection rulesenvironment, защищённые окружения
concurrency: по умолчанию один ожидающий, прежний отменяется; queue: max копит очередьresource_group: ожидающие копятся
reusable workflows, workflow_callкомпоненты и include с spec:inputs

Вопросы

4
Суть: кэш пустеет молча. Проверяют путь (внутри ли CI_PROJECT_DIR), ключ, политику, общее ли хранилище у раннеров и защищённость ветки. И кэшируют GOCACHE вместе с модулями.

Что читать в логе

ПризнакПричина
джоб зелёный, следующий снова go: downloadingпуть вне проекта: /go/pkg/mod, ~/.cache/go-build; в логе not a subpath of project directory
ключи в логах джобов разныеразные key:files или prefix; новый go.sum в ветке даёт новый ключ
у всех policy: pullкэш никто не записывает
в ветке пусто, в main работаетсуффиксы -protected и -non_protected, их получают и fallback_keys; общий кэш даёт unprotect: true

Какой кэш важнее

Пайплайн orders без кэша шёл 1,18 минуты, с кэшем модулей 1,08, с модулями и GOCACHE 32 секунды: модули приходят через прокси за секунды, а компиляция повторяется в каждом джобе. Ключ GOCACHE делают свой на джоб, иначе джобы с -race и без перезаписывают друг друга.

Бинарь через кэш

Бинарь, переданный через cache, джоб на другом раннере не найдёт, а джоб другого пайплайна с тем же ключом получит чужой. Результат сборки передают артефактом.

Суть: resource_group не пускает две выкатки одновременно, но порядок не задаёт: в режиме unordered выкатка первого пайплайна может пройти после второго и перезаписать её. Чинят режимом группы и настройкой Prevent outdated deployment jobs.

Как это выходит

Пайплайны A (коммит 1) и B (коммит 2) идут почти одновременно. У B тесты быстрее, его deploy первым занимает слот. deploy из A ждёт и получает слот следом: в unordered он достаётся любому готовому джобу. На проде коммит 1.

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

СредствоЭффект
process_mode: oldest_firstвыкатки по номеру пайплайна: A, потом B
newest_first, newest_ready_firstсначала свежая; выкатка должна быть идемпотентной
Prevent outdated deployment jobsвыкатка старше прошедшей падает с failed outdated deployment job
interruptible: falseавтоотмена не оборвёт выкатку на полпути

Режим меняют только через API: PUT /projects/:id/resource_groups/production с process_mode=oldest_first (по документации, здесь не запускалось). Prevent outdated deployment jobs считает возраст по времени старта джоба, а не по коммиту: если ручную выкатку из старого пайплайна нажали после создания нового, заблокированной окажется выкатка нового.

Ручной джоб не спасает

С when: manual на уровне джоба пайплайн зеленеет без выкатки, и через неделю кто-то жмёт кнопку в старом пайплайне. Ручные выкатки объявляют в rules и включают защиту от устаревших выкаток.

Суть: dind без privileged не работает. BuildKit rootless и buildah обходятся без Docker-демона и привилегий, но создают user namespace и монтируют, а это запрещают профиль seccomp Docker по умолчанию и AppArmor узла. Kaniko в архиве с июня 2025, GitLab предлагает вместо него BuildKit rootless.

Что показал прогон

На Docker Desktop 29.8 с профилем seccomp по умолчанию moby/buildkit:v0.33.0-rootless упал с fork/exec /proc/self/exe: operation not permitted, quay.io/buildah/stable:v1.43.2 с unshare(CLONE_NEWUSER): Operation not permitted. С --privileged BuildKit собрал и отправил образ за 5,6 секунды. С профилем из исходников Engine 29.8 BuildKit упал так же, как со встроенным. Без privileged оба сборщика прошли шаг RUN с seccomp=unconfined и с профилем по умолчанию, где разрешены ещё unshare, setns, keyctl, sethostname, pivot_root, семейство mount и clone без проверки флагов. В ВМ Docker Desktop нет AppArmor, а на Ubuntu и Debian docker-default ещё и сам запрещает mount.

Варианты

УсловияРешение
свой docker-раннерBuildKit rootless и свой профиль seccomp в security_opt только с нужными вызовами, при AppArmor и его профиль; seccomp:unconfined документация не советует
kubernetes executorseccomp пода обычно Unconfined, монтирование режет AppArmor узла: app_armor_profile в build_container_security_context
раннеры GitLab.comони privileged, BuildKit rootless работает
менять ничего нельзяотдельный раннер для сборки образов и джоб с его тегом

--oci-worker-no-process-sandbox не даёт шагам RUN своё пространство PID, и шаг сборки может убить любой процесс в контейнере демона, о чём BuildKit предупреждает в логе демона. Этот лог buildctl-daemonless.sh выводит, только если демон не поднялся.

Kaniko

GoogleContainerTools/kaniko в архиве, последний push 3 июня 2025, образы gcr.io/kaniko-project/executor не обновляются. Chainguard поддерживает форк chainguard-dev/kaniko (теперь chainguard-forks/kaniko, тег v1.25.19 от 27 августа 2026): исправления безопасности без крупных новых функций и без публичных образов. Документация GitLab отсылает с kaniko к Docker, buildah и Podman и описывает переход на BuildKit rootless.

Суть: через services:: Postgres стартует рядом с джобом под именем из образа или alias, раннер ждёт порт до 30 секунд. Нестабильность дают медленный старт, переменные, не дошедшие до сервиса, и кэш результатов тестов.

Конфигурация

integration:
  services:
    - name: postgres:18-alpine
      alias: db
  variables:
    POSTGRES_DB: orders
    POSTGRES_USER: orders
    POSTGRES_PASSWORD: orders
    DATABASE_URL: postgres://orders:orders@db:5432/orders?sslmode=disable
  script:
    - go test -tags integration -count=1 ./internal/store/...

С таким джобом gitlab-ci-local дождался порта за 1,21 с, и тест прошёл.

Где ломается

  • connection refused. Раннер ждёт только порт и не дольше wait_for_services_timeout, потом пишет предупреждение и продолжает. Тест подключается с повтором под context.WithTimeout.
  • Сервис умер на старте. Пароль задан переменной проекта в интерфейсе, а такие в сервис не передаются. Без POSTGRES_PASSWORD образ пишет You must specify POSTGRES_PASSWORD и выходит, а в gitlab-ci-local это выглядело так: healthcheck failed, затем pg_isready в джобе ответил postgres:5432 - no response.
  • Сервис не видит другой сервис. Раннер без FF_NETWORK_PER_BUILD.
  • Тест «прошёл» со сломанной схемой. Без -count=1 результат мог прийти из GOCACHE.
  • Чужие данные в базе. go test ./... гоняет пакеты параллельно на одной базе: схема на пакет или -p 1.
Testcontainers в том же джобе

Testcontainers ищет Docker-демон, а в docker executor он появляется только с dind или пробросом сокета, то есть с привилегиями. Сервисы GitLab обходятся без демона.

4.3Поставка: артефакт, выкатка, цепочка поставки

После зелёных тестов остаётся понять, какие байты попадут в кластер и кто за них ручается. Всё держится на дайджесте образа: к нему привязаны версия в бинаре, опись зависимостей, подпись, проверка при выкатке и запись в репозитории окружений. Рвётся эта цепочка в нескольких местах, и ни одно из них не пишет ошибку в лог.

Что сможешь объяснить после этой главы
  • Почему копия образа в другой реестр сохраняет дайджест, но теряет подпись.
  • Откуда в версии бинаря +dirty и (devel) и почему -X молча не срабатывает.
  • Что ловят helm lint, helm template и серверный dry-run у Helm и у kubectl.
  • Что делает helm upgrade --rollback-on-failure, когда выкатка не удалась.
  • Как не дать двум копиям мигратора выполнить одну миграцию дважды.
  • Почему govulncheck и сканер образа по-разному оценивают одну зависимость.

Прогоны: Docker Engine 29.8.0 (BuildKit 0.33), два реестра registry:3 — сборочный localhost:5093 и prod localhost:5094, Go 1.27.1, Helm 4.3.0, kind 0.33.0 с Kubernetes 1.37.0, cosign 3.1.3, oras 1.3.4, govulncheck 1.8.0, trivy 0.74.0, PostgreSQL 18.6. Сервис api выдаёт JWT через github.com/golang-jwt/jwt/v5 v5.2.1. В кластер ничего не записывалось: поведение выкатки сверено с исходниками и документацией.

Один дайджест на все окружения

Собрать один раз значит больше не пересобирать: stage и prod получают те же байты под разными именами. Сборка кладёт index в сборочный реестр, продвижение копирует его дальше:

$ docker buildx build --progress=plain --build-arg VERSION=v1.4.2 --sbom=true \
    -t localhost:5093/api:e441080 --push .
…
#17 exporting manifest sha256:21b397df49a462891626ef28ab67bd24fed11356b0849db8188a0010a1914301 done
#17 exporting attestation manifest sha256:c74e83d8238cf2502d51077462e57c6cee01bf2427d7486e0d98c6c7584c2fde done
#17 exporting manifest list sha256:e9646e3580e8557fc7a6609baf5b8585a9bfcd652268c0cb5fff1ded9d7e77f4 done
…
$ D=sha256:e9646e3580e8557fc7a6609baf5b8585a9bfcd652268c0cb5fff1ded9d7e77f4
$ docker buildx imagetools create -t localhost:5094/shop/api:v1.4.2 localhost:5093/api@$D
#1 [internal] pushing localhost:5094/shop/api
#1 0.000 copying sha256:c74e83d8… from localhost:5093/api@sha256:e9646e35… to localhost:5094/shop/api
#1 0.000 copying sha256:21b397df… from localhost:5093/api@sha256:e9646e35… to localhost:5094/shop/api
#1 0.068 pushing sha256:e9646e3580e8557fc7a6609baf5b8585a9bfcd652268c0cb5fff1ded9d7e77f4 to localhost:5094/shop/api:v1.4.2
#1 DONE 0.1s
$ docker buildx imagetools inspect localhost:5094/shop/api:v1.4.2 --format '{{.Manifest.Digest}}'
sha256:e9646e3580e8557fc7a6609baf5b8585a9bfcd652268c0cb5fff1ded9d7e77f4

imagetools create с одним источником-index делает точную копию: дочерние манифесты и тот же index. Дайджест считается по байтам JSON, поэтому копия даёт тот же хеш в любом реестре. Правка index даёт новый: с --annotation index:org.opencontainers.image.version=v1.4.2 вышел f26a0897…. Пересборка того же коммита тоже: index cb8f1ba6… и e9646e35… при одном манифесте образа 21b397df…, разошлась attestation (статья «Docker», глава 2.2). Поэтому в манифест выкатки пишут дайджест, а теги остаются ярлыками для людей.

Версия внутри бинаря

Бинарь из этого образа о коммите не знает:

$ docker run --rm --entrypoint /api localhost:5093/api:e441080 -version
{"version":"v1.4.2","module":"(devel)","go":"go1.27.1"}

version пришла из -ldflags "-X main.version=${VERSION}", остальное сервис читает через debug.ReadBuildInfo(): info.Main.Version и настройки vcs.revision, vcs.time, vcs.modified из info.Settings. С Go 1.24 go build ставит в Main.Version версию из тега git. Что окажется в полях, зависит от места сборки:

СборкаMain.Versionvcs.revisionvcs.modified
checkout на теге v1.4.2v1.4.2e4410809…false
то же плюс неотслеживаемый .go/v1.4.2+dirtye4410809…true
git clone --depth 1 --no-tagsv0.0.0-20260916100000-e44108090081e4410809…false
-buildvcs=false или Docker без .git(devel)нетнет

Вторая строка в CI частая: Go считает дерево изменённым, если git status --porcelain непустой, а неотслеживаемые файлы туда попадают. Кэш модулей внутри каталога проекта (зачем он там, в главе 4.2) делает каждую сборку +dirty, пока каталог не внесён в .gitignore.

В Docker-сборке версию передают через --build-arg в -X, и у линковщика своя ловушка:

var a = "dev"

var b = strings.ToLower("DEV")

const c = "dev"

func main() { fmt.Println(a, b, c) }
$ go build -ldflags '-X main.a=A -X main.b=B -X main.c=C' -o x .
$ ./x
A dev dev
$ go build -ldflags '-X main.Version=v1.4.2' -o api ./cmd/api
$ ./api -version
{"version":"dev","module":"v1.4.2",…}

-X меняет только строковую переменную без инициализатора или с константной строкой. Константа, переменная с вызовом функции и опечатка в имени молча дают код выхода 0 и прежнее значение. Поэтому пайплайн запускает собранный бинарь с -version и сверяет ответ с тегом.

Проверки до записи в кластер

В чарте charts/api дайджест приходит из CI, и шаблон образа проверяет его формат:

{{- define "api.image" -}}
{{- $d := required "image.digest is required" .Values.image.digest -}}
{{- if not (regexMatch "^sha256:[a-f0-9]{64}$" $d) -}}
{{- fail (printf "image.digest must be sha256:<64 hex>, got %q" $d) -}}
{{- end -}}
{{ .Values.image.repository }}@{{ $d }}
{{- end -}}
$ helm lint --strict charts/api -f charts/api/values.prod.yaml --set image.digest=v1.4.2
level=INFO msg="funcMap fail" message="image.digest must be sha256:<64 hex>, got \"v1.4.2\""
…
1 chart(s) linted, 0 chart(s) failed
$ helm template api charts/api -f charts/api/values.prod.yaml --set image.digest=v1.4.2
Error: execution error at (api/templates/job-migrate.yaml:17:20): image.digest must be sha256:<64 hex>, got "v1.4.2"

helm lint рендерит в режиме, где required и fail только пишут в лог (LintMode в pkg/engine), и выходит с кодом 0 даже с --strict. Значения проверяет уже helm template. Но и отрендеренный манифест API-сервер может отвергнуть: в values.prod-bad.yaml переопределены только requests, а лимит 128Mi остался из общего values (kubectl apply --dry-run=client это пропустил); в charts/api-typo в пробе опечатка paht:

$ helm template api charts/api -f values.prod-bad.yaml --set image.digest=$D > prod-bad.yaml
$ helm template api charts/api-typo -f charts/api/values.prod.yaml --set image.digest=$D > prod-typo.yaml
$ kubectl --kubeconfig ./kubeconfig apply --dry-run=server -f prod-bad.yaml
job.batch/api-migrate-e9646e3580e8 created (server dry run)
The Deployment "api" is invalid: spec.template.spec.containers[0].resources.requests: Invalid value: "256Mi": must be less than or equal to memory limit of 128Mi
$ kubectl --kubeconfig ./kubeconfig apply --dry-run=server -f prod-typo.yaml
job.batch/api-migrate-e9646e3580e8 created (server dry run)
Error from server (BadRequest): error when creating "prod-typo.yaml": Deployment in version "v1" cannot be handled as a Deployment: strict decoding error: unknown field "spec.template.spec.containers[0].readinessProbe.httpGet.paht"
$ helm upgrade --install api charts/api --kubeconfig ./kubeconfig -f values.prod-bad.yaml \
    --set image.digest=$D --dry-run=server | grep -E 'STATUS|DESCRIPTION'
STATUS: pending-install
DESCRIPTION: Dry run complete

--dry-run=server у Helm и kubectl значит разное. Helm ходит в кластер за lookup и проверкой владения, а объекты не отправляет (pkg/action/install.go). Валидацию полей Helm 4 оставляет API-серверу при записи, так что чарт с опечаткой тоже прошёл. kubectl отправляет объект с dryRun=All: сервер валидирует его, прогоняет admission и сравнивает с живым объектом, но в etcd не пишет.

ПроверкаЛовитПропускает
helm lintсинтаксис, имена объектовrequired, fail, всё, что знает API
helm templaterequired, fail, ошибки шаблоновнеизвестные поля, лимиты меньше запросов
helm --dry-run=serverто же плюс lookup и конфликт владениявалидацию объектов в API
kubectl apply --dry-run=server, kubectl diffполя, валидацию, admission, неизменяемые поля живых объектовто, что случится после записи: пробы, pull образа

kubectl diff выходит с кодом 1 при разнице и 2 при ошибке и показывает, что допишет сервер: для нового Deployment progressDeadlineSeconds: 600, revisionHistoryLimit: 10.

Выкатка из CI: что происходит внутри helm upgrade

Что делает helm upgrade --install --rollback-on-failure из главы 4.1, по исходникам Helm 4.3.0 и kubectl 1.37 (без прогона):

  1. Блокировка. Если релиза нет, идёт установка. Если последняя ревизия в pending-install, pending-upgrade или pending-rollback, команда падает с another operation (install/upgrade/rollback) is in progress: замком служит статус записи релиза в Secret вида sh.helm.release.v1.api.v7.
  2. Применение. Ревизия пишется в pending-upgrade, объекты применяются: у новых релизов Helm 4 через server-side apply, у обновления тем же способом, что в прошлой ревизии.
  3. Ожидание. --rollback-on-failure сам включает --wait=watcher: статус считает kstatus, Deployment готов, когда обновлённые, готовые и доступные реплики догнали spec.replicas. Deployment с истёкшим progressDeadlineSeconds получает Failed, и ожидание кончается раньше --timeout (по умолчанию 5m).
  4. Неудача. Ревизия получает failed, и Helm новой ревизией откатывается на последнюю в deployed или superseded. Если не удалась первая установка, релиз удаляется.
  5. Сигналы. На SIGTERM и SIGINT Helm отменяет контекст и идёт тем же путём с откатом. После SIGKILL ревизия остаётся в pending-upgrade, и следующая выкатка падает на шаге 1, пока релиз не разберут через helm history и helm rollback.

kubectl rollout status по умолчанию ждёт бесконечно (--timeout=0s) и падает с deployment "api" exceeded its progress deadline, когда контроллер отметил просроченный progressDeadlineSeconds. При дефолтах срок Deployment (600 с) длиннее таймаута Helm (300 с): Helm падает по таймауту, пока Deployment ещё в процессе, без причины от контроллера. Поэтому progressDeadlineSeconds ставят меньше --timeout. Откат Helm возвращает манифесты, но не базу (expand/contract, глава 4.1).

Миграции отдельным шагом

Миграцию запускают до выкатки кода отдельной Job из того же образа. helm template … --show-only templates/job-migrate.yaml с prod-values рендерит её так:

apiVersion: batch/v1
kind: Job
metadata:
  # имя от дайджеста: повторный прогон того же артефакта не создаёт вторую миграцию
  name: api-migrate-e9646e3580e8
spec:
  backoffLimit: 2
  activeDeadlineSeconds: 900
  ttlSecondsAfterFinished: 86400
  podReplacementPolicy: Failed
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: localhost:5094/shop/api@sha256:e9646e3580e8557fc7a6609baf5b8585a9bfcd652268c0cb5fff1ded9d7e77f4
          args: ["migrate", "up"]

Шаблон пода Job неизменяем, отсюда имя от дайджеста. podReplacementPolicy: Failed (стабильно с Kubernetes 1.34) не создаёт замену, пока старый под завершается. Но документация Job предупреждает: даже при parallelism: 1 и restartPolicy: Never программа иногда стартует дважды, а есть ещё второй пайплайн и init-контейнеры. Защищаться приходится в миграторе. В миграции 00002 перед ALTER TABLE стоит pg_sleep(3), чтобы окно было видно:

$ export GOOSE_DRIVER=postgres GOOSE_MIGRATION_DIR=goose \
    GOOSE_DBSTRING="postgres://postgres:pg@localhost:55433/g?sslmode=disable"
$ goose up & goose up & wait
2026/09/17 05:16:18 OK   00002_add_contact_email.sql (3.01s)
2026/09/17 05:16:18 goose: successfully migrated database to version: 2
2026/09/17 05:16:18 goose run: ERROR 00002_add_contact_email.sql: failed to run SQL migration: failed to execute SQL query "ALTER TABLE users ADD COLUMN contact_email text;": ERROR: column "contact_email" of relation "users" already exists (SQLSTATE 42701)
$ DB="postgres://postgres:pg@localhost:55433/m?sslmode=disable"
$ migrate -path migrate -database "$DB" up & sleep 0.3; migrate -path migrate -database "$DB" up &
$ sleep 1.5; docker exec dv-c3-pg psql -U postgres -d m -Atc "select pid, granted, left(query, 30)
    from pg_locks join pg_stat_activity using (pid) where locktype = 'advisory'"
108|t|SELECT pg_sleep(3);
ALTER TABL
109|f|SELECT pg_advisory_lock($1)
$ wait
2/u add_contact_email (3.017183958s)
no change

CLI goose 3.28.0 блокировки не берёт: обе копии начали миграцию, вторая упала на готовой колонке. С UPDATE вместо DDL миграция молча выполнилась бы дважды. golang-migrate 4.20.1 берёт pg_advisory_lock: вторая копия ждала (granted = f) и ответила no change. В goose блокировку включают в библиотеке:

locker, err := lock.NewPostgresSessionLocker(lock.WithLockTimeout(1, 300))
if err != nil {
	log.Fatal(err)
}
p, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("migrations"),
	goose.WithSessionLocker(locker))

Две такие копии дали applied 1 migrations и applied 0 migrations; вторая тем временем опрашивала pg_try_advisory_lock. Порядок в пайплайне: Job миграции, ожидание, выкатка. Раз Job лежит в том же чарте, что и Deployment, в самом релизе её выключают условием в шаблоне, а отдельным шагом до выкатки рендерят только её (--show-only), применяют и ждут kubectl wait --for=condition=complete. Хук pre-upgrade делает похожее внутри релиза, но это компромисс: миграция намертво привязана к выкатке чарта, и её провал помечает весь релиз как неудавшийся.

GitOps: кластер забирает состояние сам

В push-модели CI сам применяет манифесты, и у раннера есть токен с правом писать в prod. В pull-модели в кластере работает контроллер, Argo CD или Flux: он читает репозиторий окружений и приводит к нему живые объекты, а CI только коммитит новый дайджест.

Push из CI против pull из кластера Push CI держит доступ к кластеру CI-джоба deploy helm upgrade --install токен в CI API-сервер prod-кластера применяет манифесты Deployment api image: …@sha256:e9646e35 --wait: Helm следит за статусом объектов, пока они не готовы или не истёк --timeout Pull CI пишет только в git CI-джоба release git commit image.digest репозиторий окружений prod/api/values.yaml prod-кластер git fetch Argo CD / Flux сравнивает git и живые объекты Deployment api OutOfSync, затем sync …@sha256:e9646e35 kubectl scale руками: живой объект разошёлся с git selfHeal (Argo CD) или очередная сверка (Flux) возвращает как в git
Push против pull. В push-модели право писать в prod есть у CI. В pull-модели CI меняет строку в репозитории окружений, а контроллер в кластере применяет коммит и откатывает ручные правки.

Argo CD 3.5 сравнивает отрендеренные манифесты Application с живыми объектами и помечает расхождение OutOfSync; git опрашивается раз в 120 с плюс до 60 с. prune удаляет лишние объекты, selfHeal возвращает ручные правки; при повторах пауза растёт от 2 с до 5 минут (код с 2.14; в документации «5 с»). После неудачной синхронизации автосинхронизация для того же коммита не повторяется. Helm-чарты Argo CD только рендерит: Secret релиза нет, helm list пуст, хуки pre-install и pre-upgrade становятся PreSync.

Flux 2.9. GitRepository забирает репозиторий, Kustomization на каждом интервале делает server-side apply в режиме dry-run, находит дрейф и исправляет его. У HelmRelease дрейф ищется, только если задан driftDetection.mode (warn или enabled), а откат после неудачного обновления по умолчанию выключен: нужен upgrade.remediation.retries больше нуля или remediateLastFailure: true.

Репозиторий окружений держат отдельно от кода: правка манифестов не запускает сборку, право писать в prod дают не всем, и CI не зацикливается на своих коммитах (документация Argo CD). Дайджест может коммитить и контроллер: ImageUpdateAutomation во Flux по маркеру # {"$imagepolicy": "flux-system:api:digest"} (нужен digestReflectionPolicy), объект ImageUpdater в Argo CD Image Updater 1.x.

kubectl edit в prod под GitOps живёт до следующей сверки

Реплики, поднятые руками, Argo CD с selfHeal вернёт через секунды, Flux — на следующем интервале. Во время инцидента правят коммитом или временным исключением (ignoreDifferences, аннотация kustomize.toolkit.fluxcd.io/reconcile: disabled).

Доступ CI без долгоживущих ключей

Статический токен в переменной CI работает, пока его не отзовут, из любого места, куда утёк. GitLab вместо этого выдаёт джобе ID-токен: JWT с подписью RS256, aud из конфигурации и сроком жизни в таймаут джобы (без таймаута 5 минут). Сверено по docs.gitlab.com на сентябрь 2026 (GitLab 19.x), без прогона. id_tokens появились в 15.7, CI_JOB_JWT* убраны в 17.0.

deploy-prod:
  environment: production
  id_tokens:
    VAULT_ID_TOKEN:
      aud: https://vault.example.com
  script:
    - export VAULT_TOKEN="$(vault write -field=token auth/jwt/login role=api-prod jwt=$VAULT_ID_TOKEN)"
    - export DB_PASSWORD="$(vault kv get -field=password secret/api/prod/db)"

Получатель решает по claims: project_id, project_path, ref, ref_protected, environment, environment_protected; sub по умолчанию имеет вид project_path:shop/api:ref_type:branch:ref:main. Vault проверяет подпись по oidc_discovery_url инстанса (auth/jwt/config), роль связывает claims с политикой:

$ vault write auth/jwt/role/api-prod - <<EOF
{
  "role_type": "jwt", "policies": ["api-prod"], "token_explicit_max_ttl": 60,
  "user_claim": "user_email", "bound_audiences": "https://vault.example.com",
  "bound_claims": { "project_id": "22", "ref_protected": "true", "environment": "production" }
}
EOF

В AWS так же: OIDC-провайдер, условие на sub в trust policy, aws sts assume-role-with-web-identity в джобе. Условие StringLike на project_path:shop/* пустит любой проект группы с любой ветки, включая ветку с правкой .gitlab-ci.yml, поэтому роль prod привязывают к проекту, защищённой ссылке и окружению. secrets:vault есть только в Premium и Ultimate, ручной вход работает везде.

Уязвимости: govulncheck и сканер образа

Сначала закрепляют входы: go.sum, база по дайджесту (глава 2.2 статьи «Docker»), include в GitLab по ref на SHA или удалённый с integrity (17.9), actions в GitHub по полному SHA. В марте 2025 злоумышленник переставил теги tj-actions/changed-files с v1 по v45.0.7 на свой коммит.

В jwt v5.2.1 есть GO-2025-3553 (CVE-2025-30204) в Parser.ParseUnverified. Пока сервис только подписывает токены, govulncheck отвечает Your code is affected by 0 vulnerabilities. С обработчиком на jwt.Parse код выхода 3:

$ govulncheck ./...
=== Symbol Results ===

Vulnerability #1: GO-2025-3553
    Excessive memory allocation during header parsing in
    github.com/golang-jwt/jwt
  More info: https://pkg.go.dev/vuln/GO-2025-3553
  Module: github.com/golang-jwt/jwt/v5
    Found in: github.com/golang-jwt/jwt/v5@v5.2.1
    Fixed in: github.com/golang-jwt/jwt/v5@v5.2.2
    Example traces found:
      #1: cmd/api/main.go:75:22: api.main calls jwt.Parse, which eventually calls jwt.Parser.ParseUnverified

trivy (образ aquasec/trivy:0.74.0) на образе без этого вызова отвечает по версии модуля. Платформу пришлось указать: по умолчанию он искал в index linux/amd64.

$ trivy image -q --scanners vuln --platform linux/arm64 localhost:5094/shop/api:v1.4.2
…
api (gobinary)
==============
Total: 1 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 1, CRITICAL: 0)
…
│ github.com/golang-jwt/jwt/v5 │ CVE-2025-30204 │ HIGH     │ fixed  │ v5.2.1            │ 5.2.2         │ golang-jwt/jwt: jwt-go allows excessive memory allocation │

Пакеты Debian дали 0 находок, с --exit-code 1 --severity HIGH,CRITICAL trivy вернул 1. govulncheck -mode=binary на бинаре с -ldflags "-s -w" нашёл jwt.Parser.ParseUnverified, а на сборке без -s не нашёл ничего: без таблицы символов точность падает до go.mod (internal/vulncheck/binary.go).

govulncheckgovulncheck -mode=binarytrivy image
Смотритграф вызовов исходниковсимволы в бинареверсии модулей и пакетов ОС
Лишние находкимало: нужен путь вызовабольше, с -s весь модулькаждая уязвимая версия

SBOM и подпись

--sbom=true запустил при сборке docker/buildkit-syft-scanner, и опись легла в attestation внутри index, поэтому приехала в prod-реестр вместе с дайджестом. docker buildx imagetools inspect localhost:5094/shop/api:v1.4.2 --format '{{json .SBOM}}' отдаёт SPDX 2.3 от syft 1.51.0: шесть пакетов Debian (base-files, ca-certificates, media-types, netbase, tzdata, tzdata-legacy), stdlib go1.27.1, jwt v5.2.1 и example.com/api с версией UNKNOWN. Сканируется только финальная стадия, если другие не включены через BUILDKIT_SBOM_SCAN_STAGE. Provenance рядом хранит ревизию e4410809… (buildx читает git на клиенте) и дайджесты базовых образов.

Здесь cosign 3.1.3 подписывает ключом из файла и без журнала прозрачности. Флаг --tlog-upload в cosign 3 устарел, вместо него передают конфигурацию подписи без сервисов.

$ cosign generate-key-pair
$ cosign signing-config create --out signing-config.json
$ cat signing-config.json
{"mediaType":"application/vnd.dev.sigstore.signingconfig.v0.2+json","rekorTlogConfig":{},"tsaConfig":{}}
$ cosign sign --key cosign.key --signing-config signing-config.json -y localhost:5093/api@$D
…
Pushing signature to: localhost:5093/api
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:5093/v2/api/referrers/$D
404
$ docker buildx imagetools create -t localhost:5094/shop/api:v1.4.2 localhost:5093/api@$D
…
$ cosign verify --key cosign.pub --insecure-ignore-tlog localhost:5094/shop/api:v1.4.2
…
Error: no signatures found
$ oras cp -r --from-plain-http --to-plain-http localhost:5093/api@$D localhost:5094/shop/api:v1.4.2
Copying 1e3d6c664e17 application/vnd.dev.sigstore.bundle.v0.3+json
…
$ cosign verify --key cosign.pub --insecure-ignore-tlog localhost:5094/shop/api:v1.4.2
…
Verification for localhost:5094/shop/api:v1.4.2 --

Подпись хранится отдельным манифестом типа application/vnd.dev.sigstore.bundle.v0.3+json, его subject указывает на index. Такие связи отдаёт OCI referrers API, registry:3 отвечает на него 404, и cosign записал список ссылок под тегом sha256-e9646e35…. Тег stage в том же репозитории подпись видит, копия в другом реестре не видит, пока ссылки не скопирует oras cp -r (флаг помечен Preview). Без --insecure-ignore-tlog cosign 3 требует корень Sigstore из TUF, и без выхода к Sigstore проверка на этом упала.

Один дайджест: образ, опись и подпись по дороге в prod CI сборочный реестр localhost:5093/api prod-реестр localhost:5094/shop/api 1. сборка и push buildx --sbom=true 3. подпись cosign sign …@sha256 4–5. продвижение imagetools, oras cp -r index e9646e35… теги e441080 и stage manifest linux/arm64 21b397df… attestation c74e83d8… 2. SBOM (SPDX) и provenance подпись: отдельный манифест subject: e9646e35… список под тегом sha256-e9646e35… index e9646e35… тег v1.4.2, дайджест тот же manifest linux/arm64 21b397df… attestation c74e83d8… приехала внутри index после imagetools create: no signatures found после oras cp -r подпись на месте 6. admission в prod-кластере verifyImageSignatures подпись сошлась: под создан нет подписи: запрос отклонён Подпись ссылается на дайджест снаружи и в другой реестр сама не едет.
Путь одного дайджеста. Опись лежит внутри index, подпись — отдельным манифестом со ссылкой на него. Копия index в prod-реестр сохраняет дайджест и опись, подпись приходится копировать отдельно, а при создании пода проверка ищет её у того же дайджеста.

Keyless убирает файл с ключом. cosign берёт у CI OIDC-токен, Fulcio выдаёт по нему сертификат на 10 минут с личностью подписанта, Rekor фиксирует время подписи, и проверка проходит после истечения сертификата. На GitLab.com токен лежит в SIGSTORE_ID_TOKEN с aud: sigstore, проверка задаёт подписанта: --certificate-identity "https://gitlab.com/shop/api//.gitlab-ci.yml@refs/heads/main" и --certificate-oidc-issuer "https://gitlab.com". Для Self-Managed документация GitLab отправляет к собственному Sigstore. Не прогонялось: публичные Fulcio и Rekor из песочницы закрыты.

Проверка подписи при выкатке и уровни SLSA

Подпись проверяет admission-вебхук при создании пода: Kyverno 1.19 с ImageValidatingPolicy (стабильна с 1.17, когда устарело правило verifyImages в ClusterPolicy); policy-controller 0.15 от Sigstore, который проверяет только namespace с меткой policy.sigstore.dev/include: "true" и по умолчанию отклоняет образы, не попавшие ни под одну политику; Connaisseur 3.12 для cosign, Notary v1 и Notation.

apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
  name: api-signed-by-ci
spec:
  validationActions: [Deny]
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: [v1]
        operations: [CREATE, UPDATE]
        resources: [pods]
  matchImageReferences:
    - glob: "registry.gitlab.com/shop/api*"
  attestors:
    - name: ci
      cosign:
        keyless:
          identities:
            - issuer: https://gitlab.com
              subject: https://gitlab.com/shop/api//.gitlab-ci.yml@refs/heads/main
  validations:
    - expression: >-
        images.containers.map(image, verifyImageSignatures(image, [attestors.ci])).all(e, e > 0)
      message: образ не подписан CI из main

Манифест сверен со схемой CRD Kyverno 1.19.1, выражение взято из документации. mutateDigest и verifyDigest по умолчанию включены: тег в поде Kyverno заменит дайджестом. Подпись говорит, кто подписал, provenance — как и из чего собрано, а уровни доверия к этому описывает SLSA. Утверждена v1.2 (ноябрь 2025), к треку сборки она добавила трек исходников.

УровеньЧто требуетОт чего защищает
Build L1provenance генерирует платформа сборки; неподписанный provenance BuildKit из джобы ближе всего сюдаот ошибок выпуска; подделать легко
Build L2выделенная платформа подписывает provenance, потребитель проверяетот подмены после сборки
Build L3сборки изолированы друг от друга, ключи подписи недоступны шагамот подмены во время сборки
Source L1–L4от системы контроля версий до неизменяемой истории, принудительных правил и ревью двух людейот изменений в обход правил

Откат по метрикам: что делает контроллер

Стратегии и метрики разобраны в главе 4.1. Откат у обоих контроллеров переключает трафик и масштаб, а желаемое состояние не трогает.

Argo Rollouts 1.10. AnalysisRun снимает измерения и проваливается, когда неудач больше failureLimit (по умолчанию 0). Rollout уходит в abort: вес канарейки обнуляется, трафик идёт на stable ReplicaSet, её поды гасятся через abortScaleDownDelaySeconds (30 с; без маршрутизации трафика сразу). Фаза становится Degraded, а spec.template по-прежнему описывает новую версию: выйдя из abort, Rollout снова пойдёт к ней, полностью откатывает только возврат шаблона.

Flagger 1.45. Твой Deployment служит канарейкой, рядом Flagger держит api-primary. Когда неудач набралось threshold, rollback в scheduler.go отдаёт весь трафик primary, ставит на паузу автомасштабирование, масштабирует канарейку в ноль и ставит фазу Failed. Deployment с новым образом остаётся, следующая правка шаблона запускает новый анализ.

Откат канарейки не откатывает git

В репозитории окружений после автоматического отката записан новый дайджест. Для Rollout с причиной RolloutAborted Argo CD покажет здоровье Degraded, а синхронизировать нечего: живой шаблон совпадает с git. Откат завершает коммит с прежним дайджестом, и делать его должен пайплайн по событию об откате.

Вопросы

4
Суть: тег — изменяемый указатель, поэтому продвигают дайджест. Образ собирают один раз, окружения получают копию того же index, в values пишут дайджест, а перед prod сверяют его с дайджестом, прошедшим stage.

Откуда берётся другой образ

  • Пересборка. Две сборки коммита e441080 дали index cb8f1ba6… и e9646e35…. Здесь различалась только attestation, но новая база под тем же тегом изменит и сам образ.
  • Перезаписанный тег. Другой пайплайн или ручной push, если в реестре не включена защита тегов.

Как продвигать

$ D=$(docker buildx imagetools inspect localhost:5093/api:e441080 --format '{{.Manifest.Digest}}')
$ docker buildx imagetools create -t localhost:5094/shop/api:v1.4.2 localhost:5093/api@$D
$ oras cp -r --from-plain-http --to-plain-http localhost:5093/api@$D localhost:5094/shop/api:v1.4.2
$ docker buildx imagetools inspect localhost:5094/shop/api:v1.4.2 --format '{{.Manifest.Digest}}'
sha256:e9646e3580e8557fc7a6609baf5b8585a9bfcd652268c0cb5fff1ded9d7e77f4

Дайджест со stage передаётся как данные (артефакт джобы, коммит в репозиторий окружений), prod-джоба его не вычисляет. Чарт падает на helm template, если пришёл тег. Политика с verifyDigest не пустит под с образом по тегу, только если выключен mutateDigest.

Копия образа не переносит подпись

Отсюда oras cp -r в блоке выше: без него проверка в prod-реестре отвечает no signatures found. Другой путь — один репозиторий на все окружения, там подпись видна у любого тега.

Суть: без блокировки несколько копий мигратора выполняют одну миграцию. DDL упадёт на второй копии, UPDATE и INSERT пройдут дважды молча. Нужны одна точка запуска до выкатки кода и блокировка в самом миграторе, потому что даже Job может стартовать дважды.

Что показал прогон

Две копии goose up (CLI 3.28.0) начали одну миграцию, вторая упала с column "contact_email" of relation "users" already exists. golang-migrate 4.20.1 ждал pg_advisory_lock и ответил no change, goose через Provider с NewPostgresSessionLocker дал applied 0.

Как устроить

  • Одна точка запуска. Job из того же образа по дайджесту; CI ждёт её завершения и только потом выкатывает Deployment. Из init-контейнеров миграцию убирают.
  • Блокировка в миграторе. Сессионный advisory lock Postgres снимается сам, когда процесс умер и соединение закрылось. Job при parallelism: 1 иногда стартует дважды, бывает и второй пайплайн.
Ожидание блокировки тоже кончается

NewPostgresSessionLocker по умолчанию пробует pg_try_advisory_lock раз в 5 секунд до 60 раз. Миграция дольше пяти минут уронит вторую копию, поэтому долгие переносы данных выносят из миграций.

Суть: оба правы, вопросы разные. govulncheck проверяет, вызывает ли код уязвимую функцию, trivy — есть ли уязвимая версия в образе. Дешевле всего обновить зависимость; если нельзя, исключение оформляют VEX-документом с обоснованием, а гейт не выключают.

Почему расходятся

С jwt v5.2.1 govulncheck ответил affected by 0 vulnerabilities, trivy нашёл CVE-2025-30204 и вернул 1. После добавления jwt.Parse govulncheck вернул 3: «не вызывается» верно только для текущего коммита.

Исключение через VEX

$ govulncheck -format openvex ./... > vex.json
$ trivy image -q --scanners vuln --platform linux/arm64 --vex vex.json localhost:5094/shop/api:v1.4.2 | grep Total
Total: 1 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 1, CRITICAL: 0)

govulncheck пишет статус not_affected с обоснованием vulnerable_code_not_in_execute_path, но продукт у него Unknown Product, и trivy утверждение не применил. С продуктом pkg:golang/github.com/golang-jwt/jwt/v5@v5.2.1 находка ушла в Suppressed Vulnerabilities; GO-2025-3553 trivy сам сопоставил с CVE через псевдонимы. VEX хранят в репозитории, чтобы обоснование видел ревьюер.

-s -w лишает govulncheck точности

govulncheck -mode=binary на бинаре с -ldflags "-s -w" нашёл уязвимый символ, который код не вызывает, а на сборке без -s не нашёл ничего: без таблицы символов уязвимыми считаются все известные символы модуля. Точный ответ даёт режим по исходникам.

Суть: CI подписывает дайджест, лучше keyless через OIDC-токен джобы, чтобы ключ не жил в переменных. Admission в кластере проверяет подпись и личность подписанта (issuer и subject с refs/heads/main) и требует дайджест. Дальше закрывают обходы: незащищённые ветки, namespace без проверки, вебхук с Ignore.

Цепочка

  1. Сборка и подпись в одной джобе на защищённой ветке, подпись по дайджесту.
  2. Продвижение копирует index и подпись (oras cp -r) или всё живёт в одном репозитории.
  3. Политика в кластере, например ImageValidatingPolicy из раздела выше: attestor с issuer: https://gitlab.com и subject с путём к .gitlab-ci.yml и веткой.

Где обходят

  • Широкая личность. subjectRegExp на весь проект пропускает подпись из любой ветки, включая ветку с правкой пайплайна.
  • Не все namespace. policy-controller проверяет только namespace с меткой policy.sigstore.dev/include: "true".
  • failurePolicy: Ignore. Вебхук лёг, и поды создаются без проверки. С Fail, умолчанием admissionregistration.k8s.io/v1, лёгший вебхук блокирует создание подов, включая замену подов с упавшего узла.
Серверный dry-run упирается в тот же вебхук

kubectl apply --dry-run=server проходит admission и вызывает подходящие вебхуки: в API v1 у вебхука только sideEffects: None или NoneOnDryRun, а Some Kubernetes 1.37 не примет.