GitOps 仓库与环境晋级:让同一不可变制品穿过评审、验证与回滚
预发布环境刚验证通过,发布人便把 staging 目录复制到 production,顺手保留了生产副本数和入口域名。合并后,生产控制器确实显示 Synced,运行镜像却不是预发布验证过的那一个:预发布引用可变 Tag,生产同步时 Registry 已把 Tag 指向第二次构建。团队晋级的是一批看起来相似的 YAML,不是已经验证过的制品身份。
另一场事故中,值班工程师为止血直接修改 Deployment 环境变量,GitOps 控制器几分钟后又把它改回。团队暂停自动同步后修好了线上,却没有把变更回写事实源;下一次正常发布重新覆盖补丁,事故再次出现。紧急操作并非不能做,危险的是运行态、仓库和审计记录从此各说各话。
先把晋级对象从 YAML 改成制品身份
GitOps 环境晋级不是把一个集群的 live state 复制到另一个集群,也不是在生产阶段重新构建源码。可靠链路把应用构建与环境声明分开:CI 对源码 Commit 构建一次,产生镜像 Digest、Chart Digest、SBOM、签名和测试证据;GitOps 仓库只决定哪个环境引用哪个不可变身份,以及该环境有哪些显式参数。
一条可审计链路应能连续回答:
source commit
-> image/chart digest
-> staging desired commit
-> staging controller observed revision
-> staging runtime evidence
-> production promotion PR
-> production desired commit
-> production observed revision
-> production runtime evidence其中任何一个箭头断开,都不能用“PR 已合并”或“控制器 Ready”补齐。Tag 可以留给人阅读,但部署字段必须钉住 Digest;Chart version 也要关联实际包 Digest,因为版本名本身不能阻止仓库内容被替换。晋级记录至少保存制品身份、来源 Commit、目标环境、声明 Commit、审批人、渲染摘要和运行验证编号。
用状态机阻止跳级和重复晋级
环境晋级不是一个“复制并合并”按钮,而是一组只能凭证据前进的状态。候选制品进入 CANDIDATE 后,先完成来源、签名、扫描和渲染检查;预发布控制器观察到指定 revision,且真实请求与数据不变量通过后,才进入 VERIFIED;生产 PR 通过环境权限、删除面和配置差异评审后进入 APPROVED;生产控制器观察到合并 revision 时进入 RECONCILING;只有生产运行证据命中同一制品身份,才进入 ACCEPTED。任何门失败都进入 REJECTED 或 HALTED,不能靠重新触发流水线把旧的绿色结果套到新 Commit 上。
CANDIDATE
-> VERIFIED(staging revision + artifact digest + runtime evidence)
-> APPROVED(production diff + policy + approver)
-> RECONCILING(production desired revision observed)
-> ACCEPTED(live imageID + runtime invariant + SLO)
任一门失败 -> HALTED -> 修复后产生新的候选身份状态转换要使用稳定的关联键,例如 application + artifact digest + target environment + promotion attempt。制品相同但生产参数、渲染器版本或目标集群变化时,应产生新的 attempt,不能复用上一轮审批;同一个 webhook 或队列消息被重复投递时,则必须命中同一 attempt 并返回已有结果,不能再创建第二个生产 PR。审批有效期也要绑定输入摘要,超过变更窗口、证据过期或目标环境已前进后自动失效。
仓库拓扑先服务唯一环境身份
monorepo、每环境一仓、每团队一仓和每应用一仓都能工作,关键不在仓库数量,而在一个环境是否只有一个可定位、可授权、可重放的期望状态入口。相同 GVK/namespace/name 若可由两个目录、两个分支或多个 source 同时生成,审批人看到的目录就不再等于控制器最终消费的对象集合。
小团队常从一个环境仓库开始:
gitops-env/
apps/
checkout/
base/
overlays/
dev/
staging/
production/
clusters/
dev-a/
staging-a/
production-a/
policies/
promotion/
checkout.yamlapps 表达应用及环境差异,clusters 绑定具体集群入口,promotion 记录可被机器检查的晋级状态。环境身份不要只靠分支名推断;控制器对象还应显式绑定仓库 URL、固定 path、目标 cluster identity 和 namespace。production 目录只能由生产控制器读取,预发布控制器不能通过修改参数越权指向生产,生产控制器也不应扫描整个仓库后自行猜测环境。
规模扩大后,每环境一仓可以把写权限、保留策略和故障域隔开,但跨仓 PR、共同 base 和变更追踪会更复杂;每团队或每应用一仓降低了单仓争用,却会增加 webhook、凭据、控制器 source、策略同步和审计成本。拓扑选择应以“对象归属是否唯一、环境权限能否分离、一次晋级能否关联”为准,而不是以目录看起来是否整齐为准。Flux 的仓库结构指南给出了这些拓扑的典型取舍;Argo CD 多 source 适合少量独立来源合成,不应拿来替代应用边界,相同对象被后一个 source 覆盖时必须让门禁拒绝。
环境分支也不是天然的晋级模型。长期存在的 staging、production 分支若反复 merge、cherry-pick 和回合并,容易让共同 base 漂移,并使“哪个 Commit 接受过哪组运行证据”难以追踪。若组织确实采用环境分支,控制器必须固定读取明确分支,晋级只能移动已验证 Commit 或制品引用,冲突解决后的新树还要重新渲染和验证;禁止让同一个浮动分支既代表候选又代表已批准生产。对多数团队,单主干加环境目录或独立环境仓更容易把差异、权限和审计关联固定下来。
用环境身份隔开写权限与消费权限
仓库写权限和集群写权限是两条独立信任链。开发者可以提交候选 PR,但不能直接合并生产目录;晋级机器人可以修改指定镜像 Digest,却不能改 ClusterRole、Webhook、Namespace 或 Secret 引用;生产控制器只需读取批准路径,并以受限 ServiceAccount 写入被授权的 namespace。
最小角色通常包括:服务团队维护应用 base 与运行验证;平台团队维护 cluster binding、策略和控制器;发布审批人决定生产晋级;安全或合规角色审查权限扩大、镜像信任和例外。CODEOWNERS、受保护分支和 CI policy 约束“谁能改变事实源”,AppProject、Flux impersonation 或等价 RBAC 约束“事实源能改变哪些集群对象”。只做其中一层,会让仓库维护者间接获得集群管理员能力,或让高权限控制器消费未经授权的声明。
凭据也按环境分开:只读 deploy key 或 workload identity 读取仓库,Registry 身份只拉取指定项目镜像,集群 ServiceAccount 只管理明确 API group 与 namespace。不要把个人 PAT、通用 kubeconfig 或长期云密钥写进仓库 URL、控制器 Secret 模板和流水线参数。轮换后要验证旧凭据已拒绝,而不只是新凭据可用。
把不可变制品写进晋级合同
环境声明应只改变制品身份和必要的环境参数。以下 Kustomize 片段用 Digest 固定镜像;实际 Digest 由受保护流水线从构建输出写入候选分支:
# apps/checkout/overlays/staging/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
images:
- name: registry.example.com/checkout
newName: registry.example.com/checkout
digest: sha256:8b6f0b6c0c0e1111111111111111111111111111111111111111111111111111
patches:
- path: environment-patch.yaml生产 PR 不复制 staging 目录,只把已经验证的 Digest 写入 production overlay,并保留生产独有副本数、域名、资源预算和外部依赖引用。机器检查应拒绝 :latest、未固定 Chart constraint、远程 base 浮动分支和部署阶段的 docker build。若使用 Helm,晋级合同同时记录 Chart version、Chart Digest、values schema 版本和镜像 Digest;若使用 OCI 声明包,则记录 OCI Digest 与签名验证结果。
制品的来源证据不能随环境重建。SBOM、签名和扫描报告应以 Digest 为 Subject,预发布与生产读取同一份关联证据。生产参数可以不同,但差异要进入结构化评审:副本数变化是容量决策,域名变化是路由决策,ServiceAccount 变化是权限决策,不能被“只是环境配置”一笔带过。
正向实验:用 PR 晋级同一个 Digest
在隔离仓库准备 staging 与 production 两个 overlay,二者初始都引用 Digest A。候选流水线只修改 staging 为 Digest B,等待预发布控制器消费,再把运行证据写入晋级记录。下面的脚本演示门禁关注的核心不变量,yq v4 和 git 版本应由项目工具锁定文件固定:
set -eu
STAGING=apps/checkout/overlays/staging/kustomization.yaml
PRODUCTION=apps/checkout/overlays/production/kustomization.yaml
EXPECTED='sha256:8b6f0b6c0c0e1111111111111111111111111111111111111111111111111111'
staging_digest="$(yq -r '.images[] | select(.name == "registry.example.com/checkout") | .digest' "$STAGING")"
production_before="$(yq -r '.images[] | select(.name == "registry.example.com/checkout") | .digest' "$PRODUCTION")"
test "$staging_digest" = "$EXPECTED"
test "$production_before" != "$EXPECTED"
export EXPECTED
yq -i '(.images[] | select(.name == "registry.example.com/checkout") | .digest) = strenv(EXPECTED)' "$PRODUCTION"
git diff --check
git diff -- "$PRODUCTION"
test "$(yq -r '.images[] | select(.name == "registry.example.com/checkout") | .digest' "$PRODUCTION")" = "$EXPECTED"预期证据不是脚本退出 0 就结束。PR 中应只出现 production 制品身份变化和明确的环境参数变化;合并后,生产控制器的 observed revision 必须等于该 merge Commit;live Pod 的 imageID 必须解析到 Digest B;真实请求、关键数据不变量和 SLO 门禁必须通过。最后把 source Commit -> Digest B -> staging Commit/observed revision -> runtime evidence -> production Commit/observed revision -> runtime evidence 保存为一条关联记录。
若控制器仍报告旧 revision,或新 Pod 的 imageID 与声明 Digest 不同,应停止晋级并检查 source 缓存、镜像平台 Manifest、准入重写和工作负载是否真正 rollout。不要通过再次合并空 Commit 来制造绿色状态。
反向实验:证明目录复制与可变 Tag 会破坏证据
反例故意让 staging 使用 checkout:candidate,生产也复制同一 Tag。先记录 Tag 解析得到 Digest B,再模拟 Registry 把 Tag 移到 Digest C;不需要真的推送镜像,也能用两份解析结果证明部署输入已经漂移:
set -eu
mkdir -p .promotion-lab
printf '%s\n' 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' > .promotion-lab/staging-resolved.txt
printf '%s\n' 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc' > .promotion-lab/production-resolved.txt
set +e
cmp -s .promotion-lab/staging-resolved.txt .promotion-lab/production-resolved.txt
rc=$?
set -e
test "$rc" -ne 0
printf 'EXPECTED_REJECTION tag resolved to different digests\n'
rm -rf .promotion-lab预期 cmp 非零,并输出 EXPECTED_REJECTION。这份证据说明即使两个环境 YAML 完全相同,也没有消费同一制品。真实项目把两次 Registry 解析响应、Manifest Digest 和部署 imageID 一并保存;门禁发现 Tag 或 Digest 不一致时必须拒绝生产 PR,而不是重新拉取“最新 candidate”继续。
另一个反例是把 staging 整目录覆盖 production。门禁应列出除允许字段外的全部差异,并对 namespace、ServiceAccount、Ingress/Gateway host、PVC、资源配额和外部 Secret 引用做拒绝测试。预期结果是 PR 在控制器同步前失败,证据中明确指出对象身份或敏感字段越过环境边界。
紧急变更必须经过暂停、止血与回写
线上止血需要明确的临时所有权转移。先暂停目标声明单元的自动调谐或只暂停受影响对象,记录 source revision、live resourceVersion、managedFields、操作人、工单和失效时间,再执行最小 live patch。临时权限只能覆盖指定对象和字段,并设置自动失效;若控制器仍有 self-heal 写权,人工 patch 可能立即被覆盖;若直接全局停掉控制器,其他应用又失去漂移修复。暂停范围必须与故障半径一致。
止血后立即从 live patch 提炼声明变更,开紧急 PR 回写对应环境路径。回写不是把 kubectl get -o yaml 导出的对象覆盖到仓库,而是以“暂停前 desired、当前 live、拟回写 desired”做三方比较,去掉 status、默认值、运行时 annotation、managedFields 和其他合法 writer 的字段。评审要区分三类字段:应永久进入 Git 的业务配置、只在事故窗口存在且必须撤销的临时参数、由 HPA/Operator/Webhook 等合法 writer 管理而不应回写的运行字段。合并后恢复调谐,验证下一次 reconcile 不再反复写入,并删除临时例外。
若紧急修复无效,先回退 live patch,再恢复暂停前 revision;不要让失败补丁进入 Git 后依赖下一轮控制器覆盖。审计记录应保留“暂停前状态、人工变化、Git 回写 Commit、恢复调谐时间、旧临时权限撤销”五段证据。紧急通道的价值是缩短止血,不是建立一条永久绕过评审的第二发布路径。
回滚是新的期望状态,不是恢复旧现场
可逆的应用回滚通常是新建一个 PR,把 production 的制品引用从 Digest B 改回已验证的 Digest A。这个回滚 Commit 是新的事实源 revision,控制器会从当前 live state 向旧制品收敛;它不会让资源的 UID、resourceVersion、Job 副作用、外部数据和 Secret 版本回到过去。
回滚前检查旧镜像和 Chart 是否仍保留、签名与漏洞策略是否仍接受、旧版本能否读取已经迁移的数据、API/CRD/storage version 是否向后兼容、配置 schema 是否仍匹配。数据库迁移、消息格式、对象存储写入和第三方调用若不可逆,必须执行前向修复或业务补偿,不能只改 Digest。
回滚完成证据包括:生产 desired Commit 指向 Digest A;控制器 observed revision 追上回滚 Commit;新建 Pod imageID 为 Digest A;旧 ReplicaSet、Job、PVC 和外部副作用符合保留策略;真实请求和数据不变量恢复。若只是控制器显示 Synced,而请求仍失败,回滚尚未完成。
项目接入从一份机器可读晋级记录开始
把晋级合同放进仓库,而不是只存在于流水线 UI。一个最小记录可包含:
schemaVersion: promotion/v1
promotionId: checkout-8b6f-production-001
state: VERIFIED
artifact:
image: registry.example.com/checkout
digest: sha256:8b6f0b6c0c0e1111111111111111111111111111111111111111111111111111
sourceCommit: 4f3c2b1
verifiedIn:
environment: staging
desiredRevision: 7a6b5c4
renderedHash: sha256:<rendered-object-set-digest>
runtimeEvidence: evidence/staging/checkout-8b6f.json
target:
environment: production
path: apps/checkout/overlays/production
approval:
inputHash: sha256:<promotion-input-digest>
expiresAt: "<change-window-end>"这是一份由项目 JSON Schema 约束的仓库文档,不冒充 Kubernetes 内置对象。PR Job 验证状态转换、Digest 格式、预发布 observed revision、渲染摘要、证据签名、允许修改路径、审批输入摘要和失效时间;合并 Job 不调用控制器管理 API,只让生产控制器按自己的拉取周期消费 Git。这样 CI 负责“提出并证明候选”,Git 负责“记录已批准期望状态”,控制器负责“持续调谐”,运行验证负责“证明业务结果”。
接入现有项目时先挑一个无状态、可快速回退的服务,建立单环境 read-only diff,再开放 staging 写入,最后开放 production PR 晋级。第一批不要包含 CRD、Namespace、共享 RBAC、PVC 和不可逆 Job。等对象 inventory、回滚和紧急回写都演练通过,再扩大到共享资源与有状态服务。
用分层证据排查晋级停在哪里
PR 没有出现时,检查制品发布是否输出固定 Digest、机器人是否只有候选分支写权、分支保护是否允许其创建 PR。PR 已合并但 source 未更新时,检查 webhook/轮询、仓库凭据、代理、CA、path 和 revision;不要先重启控制器。source 已更新但 live 未变时,比较渲染 hash、控制器 condition、inventory、RBAC、admission 和 apply 冲突。
live 已变但请求失败时,排查重心转向 rollout、readiness、Service endpoint、路由、配置、数据兼容和容量。环境晋级系统必须把 source、render、live 与 runtime 四类失败分开统计,否则“发布失败率”会把仓库网络故障、策略拒绝和业务回归混成一个数字。
晋级应在以下任一条件停止:渲染对象集合与已审查 hash 不同;目标控制器仍观察旧 revision;出现未解释 drift、共享 ownership 或 prune 候选;准入、Job、数据迁移或真实请求未完成;错误率、不可用实例、队列年龄或 API 限流超过该服务自己的预算;回滚路径已经被不可逆变更关闭。
清理实验、回收凭据并计算长期成本
实验结束先暂停自动晋级,确认没有待合并 PR 和机器人分支,再删除测试 overlay 或恢复初始 Digest。观察控制器的 prune 预览和 inventory,按明确保留策略清理 Deployment、Service、ConfigMap 与测试 namespace;PVC、LoadBalancer、DNS、云密钥和外部数据库不能从 Git 文件消失推断已删除。最后撤销仓库 deploy key、Registry token、集群 ServiceAccount binding 和临时审批权限,并用旧凭据做一次拒绝验证。
长期成本由仓库与 PR 数量、控制器 source 数量、Registry 与证据保留、渲染和策略计算、跨区域复制、审计存储及人工审批共同组成。每环境一仓强化隔离,也增加凭据、webhook、备份和策略同步;单仓降低平台对象数量,却放大评审队列与 blast radius。容量评估应观察 PR 等待时间、source 拉取 P95、从 merge 到 observed revision 的延迟、失败重试、Registry 读取量和证据存储增长,不用“支持多少仓库”这种脱离对象规模的单值承诺。
治理上为应用声明、环境入口、晋级机器人、控制器、策略和证据库分别指定 owner。定期演练 Tag 漂移拒绝、错误环境路径拒绝、预发布证据缺失、生产控制器离线恢复、紧急回写和旧 Digest 回滚。退出某个仓库或控制器前,先冻结自动写入和 prune,导出 source revision、inventory 与字段所有权,确认新 owner 接管后再撤销旧凭据;否则“仓库归档”可能只是把仍在运行的生产对象变成无人调谐的遗留状态。
