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.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 | от системы контроля версий до неизменяемой истории, принудительных правил и ревью двух людей | от изменений в обход правил |
Откат по метрикам: что делает контроллер
Откат у обоих контроллеров переключает трафик и масштаб, а желаемое состояние не трогает.
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 не примет.
Что стоит запомнить
- Развёртывание по 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 не поможет между процессами; повторный запуск должен безопасно переживать уже применённую миграцию.