GitOps 漂移、Prune 与资源所有权:避免自动修复变成自动事故
凌晨扩容时,值班工程师把 Deployment 的副本数从 6 改成 12,几十秒后又被 GitOps 控制器改回 6。他再次执行 kubectl scale,控制器再次恢复;随后为了“止住自动覆盖”,有人给副本字段加了 diff 忽略。流量恢复后,HPA 已经接管副本数,但团队再也看不出一次未授权修改是否碰过同一字段。真正的问题不是漂移太多,而是没有先确认谁是合法 writer、哪个字段由谁维护、临时接管何时结束。
同一周,另一个团队从 Git 删除旧 Service,以为它会自然消失;控制器却因未开启 prune 保留了对象。排查时误开全局删除,又把两个应用共享的 Namespace 和证书一起清掉。Git 文件消失、对象进入 inventory、控制器决定 prune、API Server 接受 delete、垃圾回收处理 dependent,是五个不同动作。任何一步的所有权判断错误,都可能让“自动保持一致”变成稳定而持续的误删。
先把期望、现场与资源清单分成三本账
desired 是指定 source revision 经固定渲染器生成的对象集合;live 是 API Server 此刻返回的对象;inventory 是某个调谐单元认定由自己管理的对象身份集合。三者都不能由另一者推导。Git 中有 Deployment,不代表目标 API 已接受它;live 中有 Service,不代表它仍属于当前 Application;Git 中删除 ConfigMap,也不代表控制器具备或已经执行删除权。
跨 API 版本保持稳定的逻辑身份至少用 group/kind/namespace/name 描述,apiVersion 作为渲染和迁移证据另行保存;若把 version 放进长期主键,同一对象从旧 API 升级后会被误判为“一删一建”。工程记录还要保存 source revision、rendered hash、目标集群标识、调谐对象 generation、live UID 与 resourceVersion。UID 很重要:同名对象删除重建后 name 未变,实际对象已经换代。只比较 YAML 文本会把默认值、状态字段、列表排序和 webhook 注入混在一起;只看 Synced 又可能漏掉 inventory 中等待删除或已失联的对象。
不同实现维护清单的方式不同。Argo CD 用 resource tracking 关联 Application 与对象,Flux 在 Kustomization.status.inventory 记录已应用对象,Fleet 最终还涉及 Bundle、BundleDeployment 与 Helm release。Kubernetes ownerReferences 不是 GitOps inventory,Helm release Secret 也不是 SSA 字段所有权。排查时要先问“哪一本账在说归属”,不能把带有某个 label 就当成完整所有权证据。
source revision --render--> desired object set
| |
| +--diff--> live object set
| |
+--reconciler status--> inventory <----+
object tracking: GitOps tracking / inventory
field ownership: managedFields / field manager
deletion graph: ownerReferences / propagation policy
deletion blockers: finalizers and responsible controllers
human authority: repository, RBAC, on-call and change record在隔离集群建立可观察的漂移实验
实验环境需要 Kubernetes、kubectl 和一种已安装的 GitOps 控制器。产品安装和仓库认证应沿用团队固定版本入口;这里新增的是一个独立 namespace、一套低权限写入身份和一组无外部依赖的对象。不要在共享生产 namespace 验证 prune,也不要拿真实证书、PVC、LoadBalancer 或 CRD 当第一个删除样本。
先创建 namespace 与最小 Deployment,再让目标 Application 或 Kustomization 指向同一份仓库目录。控制器的 ServiceAccount 只应操作实验 namespace 中批准的 GVR;实验人员需要读取对象、Event 与 managedFields,但不需要读取 Secret data。下面的对象故意让 replicas 固定为 2,便于观察人工写入和自动调谐之间的关系。
apiVersion: apps/v1
kind: Deployment
metadata:
name: drift-lab
namespace: gitops-drift-lab
labels:
app.kubernetes.io/name: drift-lab
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: drift-lab
template:
metadata:
labels:
app.kubernetes.io/name: drift-lab
spec:
containers:
- name: app
image: registry.k8s.io/pause:3.10
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 50m
memory: 32Mi首次调谐后保存 commit SHA、控制器解析的 revision、inventory、Deployment UID 和 managedFields。预期的正向证据是 desired 与 live 的业务字段一致、对象出现在当前调谐单元清单中、Deployment 达到 Available,并且下一次 reconcile 不产生重复 patch。Pod Running 只证明容器状态,不能代替这些关联证据。
用 managedFields 找到真正的字段 writer
Server-Side Apply 按字段维护 ownership。.metadata.managedFields 中的 manager、operation、apiVersion、time 和 fieldsV1 能说明谁声明过哪些字段;它不会告诉你该主体是否经过组织授权,也不会自动解决两个 writer 的设计冲突。一个对象可以由 GitOps 管理模板和镜像,由 HPA 管理副本,由 Deployment controller 管理 status,这本来就是正常协作。字段冲突、共享字段和 --force-conflicts 的服务端语义以 Kubernetes 的 Server-Side Apply 说明为准。
先做一次人工 SSA 修改,明确指定 manager,而不是用来源不明的 patch。若控制器同样声明 spec.replicas,下一轮可能恢复值或产生 SSA conflict,具体行为取决于实现、apply 模式和强制策略。证据应同时查看 live 值、manager 和控制器 condition。
kubectl apply --server-side --field-manager=incident-operator -f - <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
name: drift-lab
namespace: gitops-drift-lab
spec:
replicas: 4
YAML
kubectl get deployment drift-lab -n gitops-drift-lab \
-o jsonpath='{.spec.replicas}{"\n"}'
kubectl get deployment drift-lab -n gitops-drift-lab \
-o json | jq '.metadata.managedFields[] | {manager,operation,time,fieldsType}'
kubectl get events -n gitops-drift-lab \
--field-selector involvedObject.name=drift-lab --sort-by=.lastTimestamp预期有两种可接受结果:自动修复开启且 ownership 明确时,副本恢复为 2,并留下新一轮调谐证据;严格 SSA 冲突时,控制器拒绝夺取字段并报告 conflict。不可接受的是流水线自动加 --force-conflicts。这个选项会覆盖值并取得字段所有权,应被视作正式接管:记录旧 manager、字段路径、批准人、影响对象、恢复 revision 和退出时间。
HPA 是常见的合法 writer。项目决定由 HPA 管理副本后,应从 desired 中移除固定 replicas,而不是长期忽略整段 spec。先让 GitOps 的 apply 配置放弃该字段,再确认 HPA 能稳定写入且下一轮调谐不会恢复固定值;managedFields 可辅助定位 writer,但 manager 名称本身不等于组织授权。最后撤销临时 ignore,让 ownership 设计存在于声明中,而不是藏在控制器的全局例外里。
漂移不是一种故障,而是一组来源不同的差异
差异可能来自 API 默认值、mutating webhook、合法协作控制器、非确定渲染、人工应急、旧 field manager、越权写入或目标对象已经被替换。处理顺序应固定:冻结 source revision 与 live resourceVersion,定位第一个不同字段,再查 tracking、inventory、managedFields、Event 和 audit writer。没有 writer 证据前,不执行强制 sync。
diff ignore 只能压掉已证明的规范化噪声。每条规则至少绑定对象范围、JSON pointer 或 manager、合法 writer、业务原因、失效信号、负责人和复核日期。忽略 /spec、RBAC rules、container image、Service selector、webhook policy、securityContext 或 Secret 引用,会同时隐藏真实越权和供应链篡改。Argo CD 的 ignoreDifferences 默认影响差异展示;只有配合 RespectIgnoreDifferences=true 才会改变 sync 预处理语义,两者不能在评审中混称为“忽略”。
反向实验可以让一个 webhook 或测试 controller 写入无害 annotation,确认规则只排除该字段;随后人工修改镜像或 Service selector,差异必须仍然出现。如果第二个修改也被隐藏,立即撤销 ignore、暂停自动同步并缩小规则。例外到期后要删除,而不是把临时止血积累成永久盲区。
高频 OutOfSync 还可能是两个控制器反复争写。判断标准不是日志数量,也不是单独观察持续变化的 resourceVersion,而是同一路径的字段值、managedFields manager 与审计主体交替出现。此时扩大 reconcile 并发只会增加 API 写放大。正确动作是暂停其中一个 writer,固定安全 revision,设计字段或对象边界,再单向恢复。
Prune 先回答清单差异,再回答是否允许删除
prune 的输入不是“Git 里没这个文件”,而是“旧 inventory 中存在、当前 desired 中消失的对象”。若 inventory 丢失、tracking 方法变化、Application 改名、Kustomization 被重建或 Helm release identity 改变,同一个 live 对象可能成为 orphan,也可能被新旧清单同时认领。删除前必须保存旧清单、新清单和候选集合,按 GVK、namespace、name、UID 去重。
Argo CD 自动同步默认不等于自动 prune;allowEmpty 又是允许整个 Application 渲染为空后继续删除的额外风险开关,具体组合见 Automated Sync Policy。关键对象可用 Prune=confirm,PruneLast=true 可把删除放到其他资源健康后的末尾阶段,相关开关见 Sync Options。Flux 的 prune: true 依据 Kustomization inventory 清理,单对象可用 kustomize.toolkit.fluxcd.io/prune: Disabled;删除 Kustomization 时的 deletionPolicy 是另一条生命周期入口,不能用它推断普通 revision 变化的行为,字段定义应与 Flux Kustomization API 对照。
生产删除门至少拦截以下对象:Namespace、CRD、ClusterRoleBinding、Webhook、PVC、PV、LoadBalancer Service、ExternalSecret 生成物、带 finalizer 的外部资产,以及任何被多个团队引用的共享资源。候选中出现未知 owner、未知 UID、inventory 大幅缩减、渲染结果为空、目标集群身份变化或 API discovery 异常时,停止 prune。停止不是失败,而是删除证据不足时的正确状态。
# 只读盘点;先保存输出,再决定是否允许删除。
kubectl get application -n argocd demo-api -o json \
| jq '{revision:.status.sync.revision,resources:.status.resources}'
kubectl get kustomization -n flux-system demo-api -o json \
| jq '{generation:.metadata.generation,observed:.status.observedGeneration,inventory:.status.inventory}'
kubectl get namespace,crd,clusterrolebinding,pvc,service -A \
-l app.kubernetes.io/managed-by \
-o custom-columns='KIND:.kind,NS:.metadata.namespace,NAME:.metadata.name,UID:.metadata.uid'这三条命令只提供调查起点:Application status、Flux inventory 与约定标签各自可能遗漏 tracking marker、Helm release 或其他控制器持有的对象。不能把标签查询结果当成完整删除清单;候选对象还要逐个核对 live UID、Argo tracking ID、Flux inventory、Helm ownership、managedFields 和外部引用。
真正执行前先暂停该实验调谐单元,在静止状态保存旧 inventory、当前 desired 和候选删除集合。评审通过后,只恢复这一个实验单元或触发一次受控同步,让控制器删除可重建 ConfigMap;随后观察 inventory 更新、API delete Event 与对象消失,再确认其他业务对象未受影响。反例把共享对象加入候选时,删除保护应在恢复调谐前拒绝。不能依赖“删错后再 Git revert”,因为外部负载均衡器、PVC 或 CRD 数据的重建并不等于恢复。
删除传播、OwnerReference 与 Finalizer 是另一套状态机
Kubernetes garbage collector 根据 owner reference 和 propagation policy 处理 dependent。Background deletion 先移除 owner,再异步清理 dependent;Foreground deletion 让 owner 保持 terminating,直到受阻塞 dependent 被清理;Orphan 则移除 owner 关系、保留 dependent。owner reference 还受 scope 规则约束,非法跨 namespace 关系会产生 OwnerRefInvalidNamespace Event;作用域、合法关系和传播方式见 Garbage Collection。
finalizer 表示删除前还有控制器负责的清理动作。对象出现 deletionTimestamp 但长期存在时,应找出 finalizer 对应 controller、外部资产和失败 Event。直接 patch 掉 finalizer 只会绕过清理责任,可能留下云负载均衡器、DNS、存储快照、远端账号或计费资源。只有责任系统已停用、外部状态已人工核对并有批准记录时,才把手工移除当作灾难恢复动作;API 的删除流程见 Finalizers。
kubectl get service demo-public -n gitops-drift-lab -o json \
| jq '{uid:.metadata.uid,owners:.metadata.ownerReferences,finalizers:.metadata.finalizers,deletionTimestamp:.metadata.deletionTimestamp}'
kubectl get events -A \
--field-selector reason=OwnerRefInvalidNamespace --sort-by=.lastTimestamp
kubectl get namespace gitops-drift-lab -o json \
| jq '{phase:.status.phase,finalizers:.spec.finalizers,conditions:.status.conditions}'GitOps tracking annotation、SSA manager、owner reference 和 finalizer 可以同时存在,却解决不同问题。Application 认为自己管理某个 Service,不代表它是 Service 的 Kubernetes owner;某 controller 拥有 spec.selector,也不代表它能删除整个对象;finalizer 只是让删除等待责任方完成清理,并不授予对象所有权。架构评审中把这些关系画在同一张对象关系表上,能避免“有 owner 标签所以可以删”的危险推断。
共享资源必须有单一写入口和显式停止条件
Namespace、CRD、ClusterRole、公共证书、IngressClass、StorageClass 和共享 ConfigMap 不应散落在多个应用清单中。平台仓库负责 cluster-scoped 与共享层,应用仓库只引用稳定接口;共享对象单独建立 Application、Kustomization 或 Bundle,并设置独立权限、变更窗口和删除保护。这样应用退役不会顺带回收平台资产。
Argo CD 的 FailOnSharedResource=true 能阻止两个 Argo Application 跟踪同一对象,但看不到 Flux、Fleet、Helm、operator 或人工 SSA 的全部关系;多实例还需用不同 installationID 隔离 tracking identity,见 Resource Tracking。Fleet 遇到 Helm ownership metadata 冲突时,不应直接开启 takeOwnership;先确认旧 release 是否仍工作、对象是否可拆分,再设计迁移。Flux 与其他 applier 共管时,则以 inventory、managedFields 和审计三方交叉确认。
自动调谐必须在以下条件暂停:未知 manager 开始写高风险字段;同一字段值与审计 writer 周期性交替;inventory 突然大幅减少;共享资源进入 prune;目标 cluster identity 与批准记录不一致;finalizer controller 不可用;渲染为空或对象 UID 大面积变化;API 429、5xx 或 admission timeout 使删除结果不确定。resourceVersion 只能证明对象发生过写入,不能单独证明某个字段由谁改动,必须与 managedFields、审计和字段值一起判断。暂停后保留观察能力,禁止把控制器直接卸载,因为卸载语义可能继续触发 finalizer 或删除。
资源接管采用单写切换:冻结旧 source,并暂停旧 Application/Kustomization/Bundle 的整个调谐循环,而不只是关闭 self-heal 或 prune;只读导出 inventory、UID 和 managedFields;候选 writer 先 render/diff,不写入;逐对象移交字段或经审查执行 force ownership transfer;首次 reconcile 禁止批量 prune;业务正反验证完成后,再撤销旧凭证、tracking 标记和 RBAC。两套控制器同时自动修复同一对象不是高可用,而是竞争写入。
权限与凭证决定一次误判能扩散多远
控制器若使用 cluster-admin,仓库写权限就可能间接变成集群管理员权限。共享平台中应让调谐对象通过 spec.serviceAccountName、AppProject destination/resource allowlist 或产品等价机制落到最小权限主体。应用 writer 只管理批准 namespace 与 GVR,cluster-scoped 对象由平台 writer 单独处理;删除权限也要单列,不应因为能 patch Deployment 就自动获得删除 Namespace 和 PVC 的能力。
只读排障仍可能接触仓库 URL、集群地址、namespace、客户标识、Secret key 名、diff 中的配置值和完整 kubeconfig。支持包、Event、日志与 kubectl get -o yaml 输出在进入工单前应脱敏;Secret 只检查 metadata、键集合 hash 或版本,不打印 data。审计记录关联 Git commit、控制器主体、Kubernetes request UID、目标对象 UID 和 field manager,不保存 Token、私钥或解密后 manifest。
应急账号需要明确到期时间。临时开放 sync override、force conflicts 或 cluster-admin 后,先固定安全 revision,记录被接管字段和对象;事件结束后撤销 RoleBinding、短期 token 和外部信任,并执行服务端拒绝测试。仅删除本地 kubeconfig 不等于撤销集群端权限。
容量与成本治理要观察写放大和删除积压
漂移率不是越低越好。完全没有 drift 可能意味着 diff 被过度忽略,持续高漂移则可能是非确定渲染或 writer 竞争。至少跟踪每个调谐单元的对象数、desired/live 差异数、prune 候选数、orphan 数、SSA conflict、stuck finalizer、reconcile 次数、API patch/delete、429 和队列年龄。指标按集群、租户和风险级别聚合,避免对象名或客户标识形成高基数与敏感标签。
一次大目录改名可能让数千对象同时从旧 inventory 消失并进入新 inventory。容量演练要覆盖批量 prune、控制器恢复后的 reconcile storm、API 限流、webhook 延迟和 watch relist。停止阈值应同时限制单次删除对象数、删除比例、cluster-scoped 对象数、外部资产数和错误率,而不是只限制并发线程。
成本清单还包括 orphan LoadBalancer、PVC、云磁盘、DNS、证书、快照、历史 release Secret 和失去 owner 的 namespace。每月把 GitOps inventory 与云资产、存储和账单标签对账;工具控制面卸载后仍要扫描遗留对象。自动删除节省的人力,不能抵消一次不可恢复的数据损失,因此高价值资源宁可进入人工确认队列。
用处置矩阵完成回滚、清理与长期治理
发现差异后,团队只在五种动作中选择:把合法现场改动回写 Git;恢复 Git 中已批准值;把字段移交给合法 controller;缩小并限时保留 ignore;暂停自动调谐并调查。动作必须带 source revision、字段路径、writer、负责人、截止时间和恢复判据。没有这些信息的“先强制同步看看”不能进入生产操作手册。
实验清理时先暂停 Application/Kustomization 的调谐,保存 inventory、UID、managedFields 和 Event;确认测试对象没有外部资产后,再选择“由原控制器明确删除”或“保留并移交”其中一条路径。Argo CD 删除 Application 前要核对 resources-finalizer.argocd.argoproj.io 与 propagation policy;Flux 删除 Kustomization 前要核对 deletionPolicy,不能用同一条删除命令推断两者结果。若选择保留 workload,必须先为它指定新 tracking/inventory、字段 writer 和后续 patch 入口,再移除旧调谐对象。最后删除实验 namespace,撤销 repo/cluster credential、ServiceAccount、RoleBinding 和临时审计访问,并确认旧身份被服务端拒绝。
长期责任也要拆开:应用团队维护 desired 与业务验证;平台团队维护控制器、共享资源和删除保护;安全团队维护凭证、审计与高风险授权;值班团队维护暂停、接管和恢复路径。每季度抽取一个共享对象和一次已关闭例外,演练漂移发现、ownership 解释、prune 拒绝、资源接管与凭证撤销。真正稳定的 GitOps 不是永远自动覆盖,而是每一次自动写入和自动删除都能回答谁授权、改了什么、何时停止、怎样恢复。
