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