GitOps 升级、迁移与退出:控制器生命周期的工程化治理
一次看似普通的 GitOps 控制器升级后,所有 Pod 都很快恢复 Ready,几十个 Application 却突然出现 diff:新版内置 Helm 改变了渲染结果,custom health 的字段判断不再适配,旧 API 还被某个插件悄悄生成。团队只保存了镜像 tag,没有保存 chart、CRD、renderer、启动参数和插件 digest,回滚时才发现旧控制器已经无法读取升级后的 CRD 状态。
另一支团队迁移到新控制器时采用“双跑观察”。旧控制器仍开着 self-heal,新控制器也获得写权限,二者对同一个 Deployment 的副本数来回覆盖;随后有人直接删除旧 CRD,finalizer 和 inventory 一起消失,留下的工作负载既没有明确 owner,也无法判断未来应由谁 prune。控制器生命周期的核心不是安装命令,而是版本契约、字段所有权和退出责任。
版本基线必须能重建同一套控制面
“Argo CD 3.x”“Flux 2.x”或“随平台最新版”都不是可恢复基线。一次发布记录至少固定控制器镜像 digest、安装 manifest 或 chart 版本、chart appVersion、CLI 完整版本、CRD bundle、Kubernetes 版本、Helm/Kustomize/plugin 版本、启动参数、feature gate、custom health/diff、RBAC、NetworkPolicy 与 extra components。社区 chart 版本和产品版本不是同一个编号,不能互相替代。
先从运行环境生成机器可比较的清单:
mkdir -p lifecycle-baseline
kubectl version -o yaml > lifecycle-baseline/kubernetes-version.yaml
kubectl -n argocd get deploy,statefulset -o yaml > lifecycle-baseline/argocd-workloads.yaml
kubectl get crd -o json > lifecycle-baseline/crds.json
kubectl -n argocd get cm,secret \
-l app.kubernetes.io/part-of=argocd \
-o jsonpath='{range .items[*]}{.kind}{"\t"}{.metadata.name}{"\t"}{.metadata.resourceVersion}{"\n"}{end}' \
> lifecycle-baseline/argocd-config-inventory.txt
argocd version > lifecycle-baseline/argocd-version.txt
sha256sum lifecycle-baseline/* > lifecycle-baseline/SHA256SUMSSecret 只记录名称、类型、版本或外部 secret version,不导出 data。镜像要记录 digest 而非仅 tag;远程 base、Helm dependency 和插件二进制也要可寻址。预期证据是每个运行组件都能反查到受评审的安装输入,控制器、CLI、CRD 和 renderer 没有未知来源。发现 latest、浮动 chart dependency、未签名插件、手工 patch 或无法解释的启动参数时,应停止升级。
产品兼容窗口会变化,应在每次变更时查询目标 release notes、支持矩阵和逐跳升级说明。Argo CD 明确区分 patch、minor 与 major 的兼容预期,跨多个 minor 需要逐段阅读升级说明;Flux 的升级流程也要求以目标发行的安装文档为准。Argo CD Upgrade Overview;Flux Upgrade
CRD 与控制器升级要遵守可读、可写、可存储顺序
CRD 不是普通 YAML。它同时定义 served version、storage version、schema、conversion、subresource 和删除入口。安全顺序通常是:先备份;安装仍将旧版本标为 served、同时引入新版本 schema 的过渡 CRD,并在产品提供 conversion webhook 时先验证转换链;确认 CRD Established 且旧新版本可往返读取;升级 controller;按产品和 Kubernetes 版本支持的方式迁移存量对象;验证旧版本不再被调用;最后才移除旧 served version。storage version 是单独的存储契约,不能靠删除版本字段“完成迁移”。具体顺序仍要服从目标产品的逐跳说明。
先盘点存储版本和对象数量:
kubectl get crd -o json | jq -r '
.items[] |
[.metadata.name,
([.spec.versions[] | select(.served == true) | .name] | join(",")),
([.spec.versions[] | select(.storage == true) | .name] | join(",")),
(.status.storedVersions | join(","))] | @tsv' \
> lifecycle-baseline/crd-versions.tsv
kubectl get crd applications.argoproj.io -o yaml
kubectl get applications.argoproj.io -A --no-headers | wc -l
kubectl wait --for=condition=Established crd/applications.argoproj.io --timeout=60sKubernetes 在 CRD storage version 改变后,不会自动把 etcd 中的旧对象重写为新版本;一次普通读取也不会完成迁移。删除旧版本前必须执行适合该集群版本的 storage migration,并核对 .status.storedVersions 与存量对象。CustomResourceDefinition Versioning
反过来先升级只会写新字段的 controller、再保留旧 schema,可能导致对象被拒绝;先删除旧 CRD version,则可能让旧 controller 无法读取。CRD conversion webhook 不可用、Established=False、存量对象无法以新版本往返转换、storedVersions 仍含待删除版本,任一情况都应停止推进。不要用 kubectl replace --force 重建 CRD,也不要在不了解外部清理责任时移除 finalizer。
弃用治理要扫描仓库、渲染结果和运行调用
只对 Git 仓库执行 grep apiVersion 会漏掉 Helm 模板、Kustomize remote base、Config Management Plugin、operator 生成对象和集群中的存量资源。升级门禁应同时检查四层:声明源、固定版本 renderer 的输出、目标 API dry-run/admission、API Server 的 deprecated warning、audit annotation 与指标。
# 分别使用与目标控制器一致的 renderer 固定输出,避免拼接时丢失 YAML 文档边界。
helm template payments ./charts/payments --include-crds > rendered-helm.yaml
kustomize build clusters/staging > rendered-kustomize.yaml
# 服务端验证会经过目标 API schema 与 admission,但不真正持久化。
kubectl apply --server-side --dry-run=server -f rendered-helm.yaml
kubectl apply --server-side --dry-run=server -f rendered-kustomize.yaml
# 查看服务端实际提供的 API;不可用 grep 结果替代 discovery。
kubectl api-resources --verbs=list --namespaced -o wide > api-resources.txt
kubectl get --raw /metrics | grep apiserver_requested_deprecated_apis || truekubectl apply --dry-run=server 成功只证明当前目标集群接受清单,不证明下一 Kubernetes minor 仍接受,也不证明业务可用。若一批输出同时包含新 CRD 和依赖它的 CR,dry-run 不会先持久化 CRD 供后续 CR 使用,应先在可销毁环境安装并确认 CRD,再验证 CR;不要把“找不到 GVK”误判成业务清单错误。Kubernetes 对弃用 API 会返回 warning,并可通过审计和 apiserver_requested_deprecated_apis 暴露调用;迁移指南应与目标 minor 一起读取。Kubernetes Deprecation Policy;Deprecated API Migration Guide
建立“弃用债务表”:API/字段/flag、生产调用者、替代项、最后可用版本、负责人、修复 commit 和阻断日期。实验发现仓库无旧 API、但指标仍增长时,优先查插件、operator 和存量客户端;不要用关闭 warning 或过滤日志消除证据。目标版本已经移除 API、conversion 丢字段、admission 行为变化无法解释时,停止控制器和 Kubernetes 升级。
备份必须能够在隔离环境恢复并对账
备份对象分四层:Git/OCI 等事实源;GitOps 控制面 CR、Project、RBAC、配置与凭证元数据;目标集群的 release ownership、PVC 和外部资源;独立审计与最后成功 revision。Redis、队列或状态页通常不能替代 Kubernetes 对象和事实源。
Argo CD 的 argocd admin export/import 是专用入口,但错误 namespace 下 export 可能不报错,因此必须比较对象数和关键摘要。Argo CD Disaster Recovery Flux bootstrap 可以由 Git 重建控制器,flux uninstall 又会保留已调谐 workload;这意味着恢复还需重新建立 Git/OCI、KMS 和集群身份,并对账 inventory。Flux installation
一次合格的恢复演练不是“文件能解压”,而是:在隔离集群安装旧基线,恢复 CRD/CR 和配置,使用测试身份读取固定 revision,保持 prune/self-heal/correctDrift 关闭,比较 rendered object identity、live UID、managedFields、tracking annotation、Helm release 与候选删除。预期证据包括对象数一致、关键 hash 一致、所有 Application/Source 可解析到记录 revision、首次 diff 没有未知大批删除。
空导出、密钥无法解封、插件制品已消失、CRD version 不可读、候选删除超阈值、目标对象已有新 owner 时,停止恢复写入。备份文件要加密、限权、保留校验和并定期删除;包含 repository 或 cluster credential 的导出应按高敏资产处理,不能进入普通工单附件。
先在 canary 控制面完成正向升级实验
canary 不应只是一台新 Pod。它需要与生产相同的 renderer、插件、身份路径和一组代表性应用,包括 Helm、Kustomize、CRD、hook、Secret 引用、custom health、大对象和失败对象。只要变更涉及 CRD schema、served/storage version 或 conversion,canary 就必须使用独立管理集群,因为同一 Kubernetes 集群中的实例和 shard 共享 CRD,无法同时验证两套 CRD 契约;只有 CRD 兼容性已经单独证明、测试目标仅是 controller/render 行为时,才可使用隔离实例或 shard。无论采用哪种路径,都不得与生产控制器同时写同一对象。
正向实验按以下顺序执行:
保存旧版 golden render、diff、health、inventory 和真实请求结果。安装目标 CRD 与 canary controller,CLI 使用相同完整版本。对固定 revision 重新 render,并对差异逐项解释。
只接管一个可销毁 namespace,prune 默认关闭。执行一次正常提交,关联 source revision、render hash、managedFields、运行对象和业务响应。轮换测试凭证,证明新身份成功、旧身份明确被拒绝。
执行回滚,确认旧版仍能读取当前 CRD 和对象。
预期结果不是“所有 diff 为零”。工具升级可能带来有意的 defaulting、排序或 health 变化,但每一项都应能归因,且不可造成对象身份改变、字段丢失或意外 prune。停止条件包括:未知渲染差异、controller crash loop、API 429 显著增长、旧新版本对 health 判断相反、回滚版本无法读取对象、真实请求失败。此时保留 canary 证据并回到旧基线,不把 ignoreDifferences 当成通用修复。
用破坏性反例证明门禁真的会阻断
在可销毁环境保留一个目标版本不再接受的 API、一个缺失 CRD 的自定义资源和一个旧字段,再升级 canary。正确表现应是预检或 server-side dry-run 拒绝,控制器 condition 指向明确 GVK/字段,后续 cohort 不推进,目标 workload 保持旧稳定版本。
apiVersion: policy/v1beta1
kind: PodDisruptionBudget
metadata:
name: deprecated-api-probe
namespace: lifecycle-lab
spec:
minAvailable: 1
selector:
matchLabels:
app: probe这段清单只用于目标集群已经移除该 API 时的负向探针,不能提交到共享生产事实源。执行 kubectl apply --server-side --dry-run=server -f deprecated.yaml,预期得到资源类型无法识别或版本不再 served 的错误;若流水线仍标绿,说明它没有使用目标 API discovery,或错误地吞掉了退出码。
再构造一次 conversion webhook 不可达或缺少 renderer dependency 的故障,观察首个异常出现在哪一层。停止条件是任何预检未阻断、控制器继续 prune、错误消息泄漏 Secret、后续 cohort 被自动放行。实验后删除探针、恢复 webhook/依赖、确认队列清空和告警恢复,并核对没有创建半成品对象。
并行迁移只能并行观察,写入权必须唯一
从 Argo CD 迁到 Flux、从 Fleet 迁到 Argo CD,或者拆分一个中央实例时,先冻结对象边界:source/revision、渲染对象身份、live UID、managedFields、ownerReference、finalizer、tracking marker、inventory、Helm release Secret、PVC 和外部资源。关闭自动 prune/self-heal/correctDrift 只能阻止部分自动动作,不等于控制器只读;还要冻结事实源、阻断人工同步入口,并在保存最后状态后通过缩容旧 reconciler 或撤销其目标写权限完成 fencing。旧界面和导出证据可以继续供查询,旧控制器不能继续持有 workload 写权。
候选控制器先用只读目标身份 render/diff,不写集群。两套工具对默认值、ignore、health、namespace 创建、Helm ownership 和 SSA field manager 的解释可能不同;全部解释后,按 cohort 转交。SSA 冲突意味着字段属于其他 manager,force 会抢占字段所有权,只能作为有记录的 transfer;两套 Helm controller 也不能共享同一 release name/namespace。新旧控制器可并行生成差异和观测状态,但同一 cohort 的目标 API 写凭证不能并行有效。
一个可执行的迁移状态机是:冻结 -> 保存旧证据 -> 候选只读 diff -> fenced 旧写入 -> 新身份最小写授权 -> 单 cohort 接管 -> 业务判定 -> 撤销旧身份 -> 扩大 cohort -> 删除旧控制对象。每个状态都有回退边。旧控制器恢复写入前,必须先 fenced 新控制器;新控制器接管前,必须先 fenced 旧控制器。审计日志仍出现双主体 patch、旧身份还能换取新 token 或无法区分真实 writer 时,立即停止。
首批接管保持 prune 关闭,核对新 inventory 后再打开删除。预期证据是 live UID 不因迁移被无故重建、字段 owner 变化符合清单、旧 controller 不再 patch、真实请求成功、旧凭证拒绝。若出现来回 patch、generation 持续增长、Helm release ownership 冲突、finalizer 卡住或候选删除扩大,回到只读状态。
回滚窗口由数据格式和外部副作用决定
镜像能回退不代表系统可回退。回滚窗口受 CRD storage version、数据库 schema、controller 写入的新字段、cache 格式、插件协议、凭证轮换和外部 hook 副作用共同限制。升级前应写明最后可逆点:何时旧 controller 仍能读取 CR,何时旧 renderer 还能生成同一对象,何时旧凭证仍处于并行有效期,何时外部迁移已经不可逆。
回滚包至少包含旧安装清单和镜像 digest、旧 CRD 的只读基线、兼容说明、导出备份、插件制品、凭证切换步骤以及恢复旧监控的配置。旧 CRD 文件用于比较和隔离恢复,不代表可以覆盖当前集群:已经迁移 storage version、写入新字段或移除旧 served version 后,必须先走受支持的反向转换或从备份恢复。不要为恢复旧控制器重新启用已泄漏的 token;不要回滚 controller 却保留只被新版理解的 feature gate。
决策时间应小于实际回滚窗口。例如旧新凭证并行 30 分钟、数据库 hook 10 分钟后不可逆,那么观察窗口不能设置一小时。超过可逆点后,策略应改为前向修复或从备份恢复,而不是继续宣称可以“一键回滚”。
卸载前先决定每个工作负载是删除、保留还是转交
不同产品的卸载语义差异很大。Argo CD 的 Application finalizer 与 propagation 决定是否级联删除工作负载,ApplicationSet 还有资源保留策略;Flux 官方 flux uninstall 删除 Flux controllers、相关 RBAC、Flux CRD/CR 和 finalizer,默认还处理安装 namespace,但保留已调谐 workload 与 Helm release;这些对象随后处于无人调谐状态,不等于已转交。Fleet 官方警告直接删除 CRD 会连带删除已部署 workload,应先让仍在运行的 controller 按产品顺序清理 Cluster、GitRepo 和 Bundle。Flux Uninstall;Fleet Uninstall
Operator 管理路径还要区分“卸载管理器”和“删除被管理实例”。Red Hat OpenShift GitOps 明确指出,只卸载 Operator 不会删除它创建的 Argo CD instances;单集群路径要先处理 Application 与实例,再删除 GitOpsService cluster,最后卸载 Operator。RHACM GitOps add-on 则要先迁移 Application authority,再分别删除 GitOpsCluster 和 managed cluster 上的 add-on 对象。把这两条路径混成一次 OperatorHub 删除,会遗留仍可写的控制面或下游 agent。Removing OpenShift GitOps
因此先为每个对象标记 delete、retain-as-orphan 或 transfer-to-new-owner。PVC、数据库、LoadBalancer、DNS、证书和外部云资源通常不能跟随通用 cascade 决策。保留 workload 时,要给它们新 inventory/field manager/Helm owner,或者留下明确接受无人调谐的风险记录;删除 workload 时,要先验证数据备份、finalizer 外部清理和传播策略。
卸载顺序通常是:暂停新的 Git 更新;关闭自动删除;转交或清理业务对象;删除产品 CR;等待 finalizer 正常完成;撤销 repo/cluster/agent/KMS 身份;删除 controller;最后处理 CRD、webhook、RBAC、namespace 和存储。直接删 namespace 或 CRD 不是捷径,它会让控制器失去完成清理的机会。
残留扫描与供应链核销决定退出是否结束
控制器 Pod 消失只是开始。使用标签、API discovery 和云资产账单扫描残留:
# API discovery 用于确认产品 API 是否仍被服务;无输出才是完全卸载后的预期之一。
kubectl api-resources --api-group=argoproj.io -o name || true
kubectl api-resources --api-group=kustomize.toolkit.fluxcd.io -o name || true
kubectl api-resources --api-group=helm.toolkit.fluxcd.io -o name || true
kubectl api-resources --api-group=fleet.cattle.io -o name || true
kubectl get clusterrole,clusterrolebinding -o name | grep -E 'argocd|flux|fleet' || true
kubectl get validatingwebhookconfiguration,mutatingwebhookconfiguration -o name | grep -E 'argocd|flux|fleet' || true
kubectl get ns -o json | jq -r '.items[] | select(.spec.finalizers | length > 0) | .metadata.name'
kubectl get pods,jobs,secrets,serviceaccounts -A -l app.kubernetes.io/part-of=argocd
# 仅在 discovery 仍显示对应 API 时分别执行,避免一个缺失 GVR 掩盖其他残留。
kubectl get applications.argoproj.io -A || true
kubectl get kustomizations.kustomize.toolkit.fluxcd.io -A || true
kubectl get gitrepos.fleet.cattle.io -A || true还要核销 Git deploy key、PAT/GitHub App、OCI token、cluster credential、client certificate、cloud role trust、SOPS/KMS identity、webhook、DNS、LoadBalancer、PVC、对象存储、日志索引、指标抓取和告警路由。删除 Kubernetes Secret 不能证明 provider 端 token 已撤销;移除 controller 也不能证明旧镜像、chart 和插件不再进入供应链。
负面证据同样重要:旧主体访问仓库和目标 API 应失败,旧 webhook 不再收到事件,旧 controller 指标不再增长,旧事实源提交不再改变集群。发现任何旧身份仍可写、旧 field manager 仍 patch、namespace 卡在 Terminating、CRD 仍有 CR、云账单继续增长时,退出尚未结束。
把生命周期责任写进团队运行制度
平台团队维护版本基线、安装制品、CRD 顺序、canary 与回滚包;应用团队维护可重复渲染、健康条件、hook 幂等和业务探针;安全团队维护制品签名、凭证轮换撤销、审计与备份加密;SRE 维护容量、故障注入、恢复和变更窗口;产品或采购负责人维护商业支持期限、许可证和替代路线。
每个 minor 升级建立固定证据集:逐跳说明、兼容矩阵、弃用债务、golden render、server-side dry-run、canary 结果、真实请求、回滚演练、凭证负测和成本变化。每次迁移建立对象 ownership 清单和 cohort 状态;每次退出建立残留与账单核销表。责任人缺失、备份未恢复、回滚窗口不明、旧凭证无法撤销时,变更不能开始。
长期治理的目标不是永远不升级,而是让升级、迁移和退出都成为可暂停的状态机:任何时刻都知道事实源在哪里、谁拥有写权、哪些格式已经不可逆、回到哪个基线需要什么证据。做到这一点,GitOps 控制器才不会从交付工具变成无法更换的隐性基础设施。
