voidlearn
 

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

Свяжешь один собранный образ с digest, версией бинаря, SBOM, подписью и развёртыванием. Разберёшь, что именно проверяют инструменты цепочки поставки и как избежать параллельного применения одной миграции.

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

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

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

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

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

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

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

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

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

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

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

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

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

var a = "dev"

var b = strings.ToLower("DEV")

const c = "dev"

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

SBOM и подпись

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Вопросы

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

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

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

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

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

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

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

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

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

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

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

Как устроить

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

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

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

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

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

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

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

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

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

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

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

Цепочка

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

Где обходят

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

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

Что стоит запомнить

  • Развёртывание по digest закрепляет содержимое образа независимо от перемещаемого tag.
  • Подпись и SBOM должны быть связаны с тем же артефактом, который проверяет кластер.
  • Проверка на стороне build не заменяет проверку происхождения и политики при deploy.

Частые ошибки

  • Копирование образа в другой registry может перенести manifest, но не связанные с ним подписи и attestations.
  • Если мигратор запускается в нескольких репликах, защита должна быть общей и транзакционной.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Проследи подпись при копировании образа

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

$ cosign sign --key cosign.key localhost:5093/api@$D
$ 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 localhost:5093/api@$D localhost:5094/shop/api:v1.4.2
$ cosign verify --key cosign.pub --insecure-ignore-tlog localhost:5094/shop/api:v1.4.2
Verification for localhost:5094/shop/api:v1.4.2
Ответ и разбор

imagetools create переносит и назначает тег manifest/index образа, но подпись cosign хранится отдельной ссылкой OCI и не приехала. Поэтому digest совпадает, а verification не находит подпись. oras cp -r копирует referrers; после этого проверка на целевом образе проходит. При настоящей проверке также сравнивай digest субъекта подписи и публичный ключ доверия.

Код-ревью

2. Закрепи образ при выкладке

Job разворачивает образ по перемещаемому тегу. Какую ошибку воспроизводимости это создаёт и что должно прийти из сборки?

image: registry.example.test/api:latest
Ответ и разбор

Тег latest может указывать на разные байты в разные моменты, поэтому rollback и аудит не фиксируют артефакт. Передавай из сборки digest и разворачивай registry.example.test/api@sha256:.... Отдельно проверяй подпись и policy, чтобы digest не был просто произвольной строкой.

Применение

3. Разбери версии Go-бинаря

В release-логе бинарь сообщает (devel), хотя pipeline передал -X main.version=v1.4.2. Назови проверки, чтобы найти, почему значение не применилось.

Ответ и разбор

Проверь полный import path переменной для -X, пакет, имя переменной и что она объявлена как строковая переменная, а не константа. Убедись, что linker flags дошли до финального go build, а запускается именно собранный артефакт, не локальная или повторно собранная копия. Для Go build info и пользовательской версии могут быть разные источники.

Применение

4. Защити миграцию от двух реплик

Две реплики мигратора стартуют при deploy одновременно и обе видят миграцию как ещё не применённую. Как сделать применение однократным в PostgreSQL?

Ответ и разбор

Сериализуй миграцию общей для всех реплик защитой в базе: транзакционной advisory lock или блокировкой строки миграционного состояния с корректной изоляцией. Проверка версии и запись результата должны быть частью той же защищённой критической секции. Локальный mutex не поможет между процессами; повторный запуск должен безопасно переживать уже применённую миграцию.