GitOps 状态证据模型与选型:从提交到真实请求
发布 PR 已经合并,控制台显示 Synced,Deployment 也有 Ready 副本,用户请求却仍然打到旧版本。团队先怀疑镜像缓存,又怀疑服务网格,最后才发现控制器同步的是另一个目录生成的旧 digest。三个绿色状态都是真的,但它们证明的是三件不同的事,没有任何一个状态把 Git 提交、渲染对象、运行镜像和真实请求连在一起。
另一类事故更安静:工程师临时提高资源限制,GitOps 控制器数分钟后把字段改回去;大家为消除告警配置了 ignore,几个月后一次未授权镜像变更也被同一规则隐藏。GitOps 不是“Git 里有 YAML”或“控制器自动 apply”,而是一套持续比较期望与现实的状态系统。只有先固定证据对象、写入所有者和停止条件,自动化才会缩短恢复时间,而不是更快地复制错误。
七层事实回答七个不同问题
GitOps 变更至少经过七层,每层都有自己的权威对象:
| 层 | 权威证据 | 能证明 | 不能证明 |
|---|---|---|---|
| Source | commit SHA、tag、OCI digest、fetch condition | 控制器读到了哪个来源 | 渲染结果正确 |
| Artifact | source artifact digest、缓存生成时间 | 下游消费了哪份内容 | 目标 API 接受内容 |
| Render | renderer 版本、参数、对象清单与 hash | 候选生成了哪些对象 | live 状态已经变化 |
| Desired/Live | server diff、resourceVersion、managedFields | 期望和现实怎样不同 | 业务请求正确 |
| Inventory | tracking ID、status inventory、Helm ownership | 哪个声明单元认为对象归自己 | 字段没有被其他 manager 争写 |
| Health | condition、observedGeneration、controller health | 产品定义的就绪条件成立 | 数据、SLO 和用户结果正确 |
| Runtime | request ID、版本、响应、数据不变量 | 真实业务路径消费了哪个状态 | 下一轮调谐仍会保持 |
OpenGitOps 的版本化与持续调谐原则要求期望状态可追踪、控制器持续收敛,但不会替团队定义业务验收。OpenGitOps Principles 因此证据链应保存:
source revision
-> artifact digest
-> renderer name/version + manifest set hash
-> target cluster + reconciler object/generation
-> inventory + live resourceVersion
-> workload version + runtime request evidence链中任何一项缺失,都只能给出局部判断。尤其不要用时间接近代替身份关联;缓存、重试、队列和多集群时钟会让“差不多同时”失去证明力。
先在隔离集群建立人工基线
第一次试验不要直接开启 auto-sync 和 prune。准备一个可销毁的本地集群、一个只含测试清单的仓库和独立 namespace。先人工创建最小工作负载,记录对象与字段所有者:
kubectl create namespace gitops-lab
kubectl -n gitops-lab create configmap release-evidence \
--from-literal=version=manual-v1 \
--field-manager=manual-baseline
kubectl -n gitops-lab get configmap release-evidence -o yaml
kubectl -n gitops-lab get configmap release-evidence \
-o jsonpath='{range .metadata.managedFields[*]}{.manager}{"\t"}{.operation}{"\n"}{end}'预期先看到 version=manual-v1,并在 managedFields 中看到人工 manager。这个对象稍后由控制器接管;若实验一开始就不知道谁拥有字段,出现冲突时就无法区分正常 ownership transfer 与危险强制覆盖。
测试仓库使用虚构地址和无生产权限身份。仓库内容至少包含 ConfigMap、Deployment、Service 和一个返回版本号的 HTTP 容器。镜像固定 digest;ConfigMap 保存 source revision 的短标识;响应同时返回构建版本和运行配置版本。这样真实请求才能与 Git、镜像和配置三种身份对账。
安装控制器前做四项选择
先确认问题确实适合持续调谐。若团队还不能固定制品身份、不能从仓库重现目标对象、没有唯一写入 owner,或变更本身不可幂等且没有补偿,部署控制器不会带来可靠交付。先把人工流程变成可审计的声明、渲染和验证链,再让控制器接管其中可重复的部分。一次性数据修复和外部系统副作用仍应由受控 Job 或 runbook 执行,完成事实再回写发布记录。
控制器位于哪里
中央 Argo CD 常由管理集群访问多个目标 API,集群凭证和中央网络成为共享故障域;每集群 Flux 让故障更局部,但每个集群都要维护控制器、source 身份和升级节奏;Fleet 采用 manager 与下游 agent 的两阶段模型,适合下游主动连接的网络。不要先部署再补拓扑图,拓扑会决定权限、容量和灾备。
谁渲染
Helm、Kustomize、Jsonnet 或自定义 plugin 可以在 CI 预渲染,也可以由控制器运行。控制器渲染减少中间产物搬运,却扩大 repo-server/plugin 的代码执行和密钥边界;CI 预渲染更容易审查对象集合,但仍要防止控制器再次用不同版本二次渲染。权威 renderer、版本和参数必须只有一套。
谁拥有写入
CI、GitOps 控制器、Helm release、Operator 和人工 kubectl 可能同时接触对象。Kubernetes Server-Side Apply 按字段记录 manager;冲突意味着本次 apply 正在修改另一 manager 拥有的字段。--force-conflicts 会取得所有权,只能用于经过评审的接管事务,不能成为自动重试参数。Server-Side Apply
哪些动作允许自动发生
sync、self-heal、prune、允许空应用、generator 删除、hook 重跑和镜像自动提交是不同能力。小团队可以先自动 fetch/render/diff,只在人工批准后 sync;再逐步开放自愈;最后才评估 prune。把所有开关一次打开,会让第一个 path 或 selector 错误直接扩大为删除事故。
正向实验必须证明新状态真的被消费
控制器接入后提交 manual-v1 -> git-v2,按顺序保存证据:
# 1. 只执行所选产品对应的一条,读取控制器实际观察到的 revision
# Argo CD
kubectl -n argocd get application.argoproj.io demo \
-o jsonpath='{.status.sync.revision}{"\n"}{.status.sync.status}{"\n"}'
# Flux Kustomization
kubectl -n flux-system get kustomization.kustomize.toolkit.fluxcd.io demo \
-o jsonpath='{.status.lastAppliedRevision}{"\n"}{range .status.conditions[*]}{.type}{"="}{.status}{" "}{.reason}{"\n"}{end}'
# 2. live 对象字段已改变,并查看当前 field manager
kubectl -n gitops-lab get configmap release-evidence \
-o jsonpath='{.data.version}{"\n"}{range .metadata.managedFields[*]}{.manager}{"\n"}{end}'
# 3. 工作负载使用预期镜像身份
kubectl -n gitops-lab get deployment demo \
-o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
# 4. 真实请求返回构建与配置版本
kubectl -n gitops-lab port-forward service/demo 18080:80
curl -fsS http://127.0.0.1:18080/version成功判据不是某一条命令为绿色,而是控制器 observed revision 对应目标提交、ConfigMap 为 git-v2、Deployment 引用预期 digest、响应同时返回对应构建和配置身份。若响应仍旧,继续查 Service endpoint、滚动状态、缓存与流量路由,不能回头把 Synced 当最终结论。
项目接入时把这组关联写入发布记录:release_id、source SHA、artifact digest、renderer 版本、目标集群、控制器对象 UID/generation、运行镜像 digest、验证 request ID。日志和指标只使用受控短标识,不把真实仓库 URL、Token 或完整配置作为标签。
反向实验按单变量制造证据断点
来源不可达
暂时把测试 Source 指向不存在的 revision,或撤销测试仓库凭证。预期 Source 层出现认证或 revision 错误,旧 artifact 可能继续存在,运行工作负载不应被误删。恢复正确凭证后,应消费最新 revision,而不是重复应用队列中的每个历史状态。
渲染错误
提交一个 schema 正确但 renderer 无法解析的值,预期 Source 仍可 Ready,Render/Reconcile 层失败,live 对象保持旧版本。错误证据应包含 renderer、版本和目标 path,不打印解密 Secret 或完整仓库内容。
API 拒绝
提交目标集群不支持的 API 或让控制器身份失去某个测试资源的写权限。预期本地渲染可能成功,server validation/apply 返回明确的 GVK 或 RBAC 拒绝。恢复权限前不要赋予 cluster-admin;用 kubectl auth can-i --as=system:serviceaccount:... 证明最小补权。
人工漂移
kubectl -n gitops-lab patch configmap release-evidence \
--type merge -p '{"data":{"version":"manual-drift"}}'
kubectl -n gitops-lab get configmap release-evidence -o yaml关闭 self-heal 时,预期 diff/OutOfSync 可见但字段暂不恢复;开启后应回到 Git 值,并能从 managedFields、Event 或审计识别写入者。若字段来回变化,说明存在第二个 manager 或非确定渲染,不能用缩短 reconcile interval 掩盖。
删除候选
先删除无业务影响的测试 ConfigMap 声明,保持 prune 关闭,验证对象成为候选但仍保留;审核 inventory 与删除面后再允许删除。关键 Namespace、PVC、CRD 和集群范围 RBAC 不参与第一轮自动 prune 试验。
Desired、Live、Diff 和 Ignore 怎样取舍
desired 是目标 revision 经权威 renderer 生成的对象;live 是目标 API 返回的当前对象。两者差异可能来自 Kubernetes defaulting、mutating webhook、HPA、其他控制器、人工应急、旧 manager 或未授权写入。正确流程是先识别 writer,再决定回写 Git、移交字段、恢复调谐或批准限时 ignore。
kubectl diff 的退出码 0 表示无差异,1 表示存在差异,其他值表示命令失败;CI 必须区分。kubectl diff 服务端 dry-run 能纳入目标 API 的 schema、defaulting 和支持 dry-run 的 admission,但仍不能证明异步控制器收敛和业务结果。
ignore 规则要同时记录合法 writer、精确字段路径、为什么产生差异、何时失效和谁复核。忽略整个 spec、镜像、RBAC、selector、证书引用或安全策略,会把真实攻击与正常 mutation 一起隐藏。忽略显示差异和放弃同步某字段在不同产品里也可能是两种配置,不能混为一谈。
Inventory、Field Ownership 和 Garbage Collection 不是同一层
GitOps inventory 回答“哪个声明单元认为对象属于自己”;SSA managedFields 回答“哪个 manager 管理哪些字段”;Kubernetes ownerReferences、propagation policy 和 finalizer 回答“删除 owner 后 dependent 怎样处理”;CODEOWNERS、RBAC 和团队制度回答“谁有权改变声明”。
一个对象可以被 Argo CD tracking、Flux inventory 或 Helm release 识别,同时字段又由 Operator 和 webhook 修改。接管时要逐层核对:
kubectl -n gitops-lab get deployment demo \
-o jsonpath='{.metadata.annotations}{"\n"}{.metadata.labels}{"\n"}'
kubectl -n gitops-lab get deployment demo \
-o jsonpath='{range .metadata.managedFields[*]}{.manager}{"\t"}{.operation}{"\n"}{end}'
kubectl -n gitops-lab get deployment demo \
-o jsonpath='{range .metadata.ownerReferences[*]}{.kind}{"/"}{.name}{"\n"}{end}'
kubectl -n gitops-lab get deployment demo \
-o jsonpath='{.metadata.finalizers}{"\n"}'对象卡在 Terminating 时,finalizer 可能代表外部清理责任。不了解责任就手删 finalizer,会留下云负载均衡、卷、DNS、证书或外部记录。删除策略必须在隔离环境验证 foreground、background、orphan 和产品 inventory 的组合语义。
Hook 和 Job 需要业务级幂等
CRD、controller、迁移 Job、应用和验证 Job 需要显式依赖;文件名或 YAML 顺序不是门禁。Argo CD phase/wave、Flux dependsOn 与 health check、Fleet Bundle 依赖表达的完成条件不同,升级时也可能改变。
Job 可能因 controller retry、Pod 重建、人工重试或恢复后补偿而再次执行。每个有副作用的任务至少设置幂等键、activeDeadlineSeconds、失败策略、资源限制、完成事实、补偿动作和历史保留。Kubernetes 提醒已完成 Job 长期保留会给 API Server 带来压力,可用 TTL 清理;但先把必要证据送入受控审计存储。Kubernetes Jobs
apiVersion: batch/v1
kind: Job
metadata:
name: schema-migration-v2
namespace: gitops-lab
spec:
backoffLimit: 1
activeDeadlineSeconds: 300
ttlSecondsAfterFinished: 3600
template:
spec:
restartPolicy: Never
serviceAccountName: migration-runner
containers:
- name: migration
image: example.com/demo/migrator@sha256:<digest>
args: ["apply", "--change-id", "schema-v2"]
resources:
requests: { cpu: 50m, memory: 64Mi }
limits: { cpu: 200m, memory: 128Mi }change-id 应由数据库或外部系统记录为权威完成事实,不能用 Job 名称推断已执行。回滚清单还要回答 schema 是否向后兼容、旧应用是否能继续运行,以及不可逆变更怎样前向修复。
权限和凭证按阶段拆开
Source controller 只需读仓库或 OCI;renderer 不应获得生产数据库凭证;applier 只写批准的 API group、namespace 和对象;密钥控制器只读取允许的外部路径;多租户管理员权限与应用仓库写权限分离。仓库可写不应自动等于集群管理员。
接入前用服务端授权检查证明允许和拒绝:
SA='system:serviceaccount:gitops-system:reconciler'
kubectl auth can-i get deployments -n gitops-lab --as="$SA"
kubectl auth can-i patch deployments -n gitops-lab --as="$SA"
kubectl auth can-i delete namespaces --as="$SA"
kubectl auth can-i get secrets -A --as="$SA"前两项按产品需要设为允许,后两项在普通租户场景应拒绝。若产品架构确需更高权限,使用 AppProject、impersonation、namespace 边界、准入策略和独立控制器实例缩小爆炸半径,不把共享 cluster-admin 当作方便的默认值。
repo credential、cluster credential、SOPS key、External Secrets 身份、Webhook Token 和通知凭证都要有 owner、轮换周期、撤销动作与审计。日志只保留 credential 标识和失败类型,不打印 URL 中的用户名、Authorization Header、Secret data、解密文件或完整 diff。
选型先定故障域,再映射到产品
先填写约束,再讨论产品。下面每一行都对应事故时必须接受的代价;任何一行回答不清,功能勾选再多也不能形成架构结论。
| 决策维度 | 选择信号 | 必须接受的代价 | 验证动作 |
|---|---|---|---|
| 控制面位置 | 需要统一应用模型、集中审计与跨集群视图,或希望每个集群独立恢复 | 中央控制面扩大网络与凭证故障域;每集群控制器增加升级和观测数量 | 隔离管理集群网络,确认目标业务是否继续运行;从一个空集群重建单集群控制面 |
| 网络方向 | 管理面能够主动访问目标 API,或下游只能穿过 NAT 主动连出 | push 模型保存集中 cluster credential;pull/agent 模型要治理注册身份和离线积压 | 阻断双向链路,记录离线状态、恢复洪峰和凭证撤销路径 |
| 租户边界 | 平台统一托管,或租户需要独立仓库、身份、配额和故障域 | 共享实例配置简单但爆炸半径大;独立实例消耗更多资源和维护人力 | 用允许/拒绝 RBAC 实验证明跨 namespace、Secret 和集群范围资源不可越权 |
| 渲染与扩展 | 只用受控 Helm/Kustomize,或必须运行自定义 plugin | plugin 扩大代码执行、网络、缓存和明文 Secret 暴露面 | 用无网络、只读文件系统和最小身份运行一次渲染,并扫描日志与临时目录 |
| 对象所有权 | 控制器直接管理声明对象,或需要 Operator、HPA、webhook 共同写字段 | 多 writer 需要精确 ignore 和字段移交;强制接管可能覆盖合法状态 | 比较 managedFields,制造单字段冲突,证明停止条件而不是自动 force |
| 集合与扇出 | 少量显式应用,或按集群标签、目录和生成器批量派生 | generator 或 selector 减项会放大删除面 | 缩小测试集合但关闭 prune,核对候选对象、批次上限和审批人 |
| 支持与生命周期 | 接受上游自行维护,或必须使用平台厂商支持矩阵 | 下游发行版存在版本滞后和能力差异;上游升级责任由团队承担 | 从目标发行渠道读取 CRD、控制器与 Kubernetes 兼容关系,并演练跨版本恢复 |
| 退出方式 | 业务对象保留后移交,或由旧平台负责受控删除 | 保留对象要转移 manager;删除对象要处理 inventory、finalizer 和外部资源 | 暂停调谐、撤销凭证,等待两个原周期并确认旧身份无写入 |
这些约束可以映射到常见实现:集中管理、强项目边界和统一 UI/API 通常指向 Argo CD;每集群拉取、组件化状态和 Git bootstrap 通常指向 Flux;Rancher 下游集群与两阶段 Bundle 分发通常指向 Fleet;必须遵循 OpenShift 支持链时优先使用 OpenShift GitOps Operator。映射只是起点,仍要用上表的故障注入、恢复和退出动作证明所选拓扑。
渐进发布是另一个决策轴。Argo Rollouts 直接管理 Rollout、AnalysisRun 与路由,Flagger 围绕目标工作负载生成 Canary 资源并连接指标 provider;二者都不能替代 source、render、inventory 与基础调谐。选择取决于现有路由和指标接口、对象所有权、派生资源接受度及失败补偿,而不是“是否支持 canary”。
容量和成本要压测恢复洪峰
稳定状态的 reconcile QPS 通常不是最危险场景。Git 恢复、Registry 恢复、管理集群重启、网络分区结束或大批 PR 合并后,控制器会同时 fetch、render、list/watch、dry-run、apply、写 status 和发送遥测。容量矩阵至少包含声明单元数、每单元对象数、渲染耗时、对象字节、目标 API latency、admission 延迟、drift 比例、Job 数和离线集群比例。
记录 source fetch、render、queue depth/age、reconcile duration/result、API 429/5xx、SSA conflict、inventory/prune candidate、last successful revision、业务错误率与控制器 CPU/RSS。指标标签按产品、集群分组和结果分类,不把任意仓库 path、对象名或 commit 作为无限基数标签。
成本包括管理集群、控制器副本、Git/OCI 流量、Registry、日志指标 Trace、集群连接、商业支持和平台维护人力。提高 reconcile 频率会增加 Git、API Server 和遥测成本,也可能让双写冲突更剧烈;频率应由恢复目标、变更频率和容量压测决定。
灾备必须从空控制面重建
Git 保存期望状态,但控制器配置、租户边界、repo/cluster credential、签名信任、解密身份、custom health/diff、插件和集群登记不一定都在同一仓库。灾备清单按事实源、管理控制面、目标集群和外部依赖四层建立。
演练从新的管理 namespace 或空集群开始:安装固定版本 CRD/controller,恢复最小身份,先以 suspend/read-only 模式读取 source,比较将生成的对象和删除面,再逐批恢复调谐。恢复期间禁止两个控制器写同一对象;旧控制面断开前保留回滚入口。最后验证 source revision、inventory、workload version、真实请求和孤儿资源。
多集群恢复需要限流和分批。离线集群恢复后若同时补偿全部 revision,可能压垮 Git、Registry、API Server 和观测后端。设置最大并发、批次大小、队列年龄和错误预算;一个故障域超过阈值时停止后续集群,不把“最终会收敛”当成无限等待理由。
清理与退出先暂停,再归还所有权
实验结束先暂停 source 或 reconcile,确认没有新写入,再按产品语义选择保留或删除业务对象。不同工具卸载差异很大:有的删除控制器和 CRD 但保留 workload,有的删除 CRD 会触发已部署资源清理;不能复用一条通用 helm uninstall。
通用核对顺序是:
导出 source revision、应用对象、inventory、项目/RBAC、凭证标识和 custom 配置。关闭 auto-sync、self-heal、image automation、generator 与渐进发布推进。将关键对象移交新 manager,比较 managedFields,确认旧 manager 不再写入。
处理 Application/Bundle/Kustomization 的删除策略、finalizer、hook 和 orphan 选择。删除测试 workload、namespace、webhook、CRD 和控制器,复查 stuck Terminating 与 cluster-scoped 残留。撤销 repo/cluster/decryption/notification credential,删除临时本地文件和端口转发。
核销负载均衡、卷、快照、托管集群、日志索引和商业席位。
退出后等待至少两个原 reconcile 周期,确认对象不再被恢复、API 审计没有旧 service account 写入、真实业务由新控制面或人工基线稳定管理。资源仍在不等于退出失败;关键是旧工具不再拥有写入权、凭证和隐性费用。
source、artifact、render、desired/live、inventory、health 和 runtime 是否有独立证据并可关联。环境晋级是否移动同一不可变制品,而不是重建镜像或复制 live YAML。CI、GitOps、Helm、Operator 和人工 kubectl 的对象级、字段级写入边界是否明确。
sync、self-heal、prune、allow-empty、generator 删除、hook 重跑和镜像提交是否分别审批。ignore 是否绑定合法 writer、精确字段、失效信号、owner 和到期时间。repo、cluster、解密和租户管理身份是否分离,并通过允许/拒绝实验验证。
Hook/Job 是否具有幂等键、deadline、补偿、完成事实和证据保留。容量是否覆盖断网恢复和大批提交,不只测稳定状态平均值。灾备能否从空控制面重建,退出能否归还资源所有权并撤销全部凭证与费用。
