AI时代的Kubernetes教程36招-第33招-GitOps、Argo CD和声明式交付怎么把集群变成可审计系统?

GitOps落地:以声明式思想治理Kubernetes漂移

Git 里写的是期望,集群里跑的是事实,中间那段差距谁负责?

[读者] 平台工程师,管着数字人多环境 K8s
[痛点] 生产手改、Git 与集群长期漂移、发布靠人肉
[现在读] 要引 GitOps,却不清楚声明式要声明到什么程度
[读完] 能搭出可审计、可回滚、可自愈的 Git 到集群收敛链路
[旧方案] Helm 本地渲染 + kubectl apply,紧急时直接 kubectl edit
    |
    v
[新需求] 环境变更全走 PR、Review、审计,多环境可对齐
    |
    v
[冲突] 集群里有 Git 不知道的字段,apply 覆盖不全,回滚等于重放
    |
    v
[后果] 漂移越久越不敢动,发布窗口变成事故窗口

我是老李,带一个不大的平台团队,负责数字人业务线的 dev、staging、prod 三套 Kubernetes 环境。数字人这套服务的镜像不大,但依赖不少:TTS 网关、渲染 worker、会话状态缓存,每个环境的副本数和资源规格都不一样。

过去我们的做法很朴素:deploy 目录下放一套 Helm chart,CI 里 helm template 渲染出 YAML,再 kubectl apply 到目标集群。遇到线上告警,值班同学 SSH 上去 kubectl edit 改几个参数,第二天再补一次提交——经常补不上。

现在决定把 deploy/gitops 作为唯一入口,Git 保存期望状态,由 Argo CD 或 Flux 持续比较 Git 与集群实际状态并 Reconcile,所有环境变更走 PR、Review 和审计。

为什么 Helm 明明渲染出了完整 YAML,集群里还是会漂移?

如果 Git 是唯一事实源,那紧急手改这条路该被什么替代?

引入 GitOps 之后,我们付出的代价到底是速度,还是别的什么?

01、故事

那次大促前一天晚上,数字人会话超时率抬头。值班同学判断是渲染 worker 副本不够,kubectl edit deployment,把 replicas 从 6 调到 12。告警压下去了。

第二天要补提交,才发现 Git 里写的是 6,集群里跑的是 12。CI 再执行一次 apply,12 被改回 6,告警复现。那天上午我们做的事,不是发版,是给前一天的救火擦屁股。

任务是让环境变更全部落到 Git:谁改的、改了什么、什么时候合入、能不能回滚,一条链上都能查到。听起来只是把一个 kubectl 换成一次 PR。

真正做起来才发现不一样。ConfigMap 里有人手加过一个超时参数,Git 里从来没有;Service 的 annotation 被云厂商 controller 反复改写,每次 apply 都打架;有个环境连 namespace 都是手建的,Git 里只有一个空目录。

这些差异,在 push 模型下全都不可见。CI 跑完那一刻它就不再看集群了,集群后来长成什么样,CI 不知道,Git 更不知道。

GitOps 要接的,正是这段没人管的空白。

手改一时快
翌日账难平
差异埋暗处
出事才现形

02、问题

旧方案失效,不是一个点,是三处同时失效。

第一处,推送模型天生无状态。CI 执行完就结束,它不持有「集群应该长什么样」的持续判断,所以漂移对它而言是隐形的。

第二处,kubectl apply 的合并依赖 last-applied-configuration 这个客户端注解。一旦有人 kubectl edit,或者别的 controller 往同一对象写入,三方合并的输入就变了,结果不再确定。同一份 YAML,在不同时间 apply,可能得到不同结果。

第三处,回滚能力是假的。所谓回滚,是把旧 commit 重新 apply 一次,它会把这段时间所有手工补丁一起抹掉——包括那些救了命的补丁。

业务上的表现很直接:dev 有效果、prod 没生效,排查要从 Git 和集群两头翻;值班同学不敢改,因为不知道改了之后谁来收场;一次大促下来,环境之间已经不可比了。

所以这一篇的完成标准,我定了三条,都要求可验证:

第一条,任意环境的差异,能在一个界面或一条命令里看到,并且指明差在哪个字段,而不是只说一句「不一致」。

第二条,手动改动集群后,在约定周期内被拉回 Git 值,或者至少被明确标记为漂移,而不是安静地留在那里。

第三条,生产变更必须来自合入的 commit,带 PR 记录与审计轨迹。

推送只管去
落地无人问
标准若模糊
收敛无从谈

03、原理

Kubernetes 的声明式,核心不是 YAML 这种格式,而是「提交期望状态,由控制器持续把实际状态驱向期望状态」这个循环。YAML 只是载体,循环才是机制。

这个循环要成立,必须回答一个问题:谁拥有哪个字段。

Server-Side Apply(SSA)把合并逻辑从客户端搬到 apiserver,用 managedFields 记录每个字段由哪个 manager 拥有。不同 manager 修改同一字段会冲突,需要显式 force。这是理解后面所有故障的地基。

第一个反直觉判断:GitOps 的关键不是自动部署,而是持续比较。CI/CD 解决的是「怎么把东西推上去」,GitOps 解决的是「上去之后跟 Git 不一致怎么办」。前者是一次性动作,后者是永久运行的控制循环。把 GitOps 当成「更时髦的 CI」,就一定会漏掉后一半。

第二个反直觉判断:同步本身不难,难的是字段所有权。哪些字段归 Git,哪些归 HPA,哪些归云厂商的 controller,哪些是 apiserver 自动填的默认值——这些必须在设计阶段划清。划不清,Reconcile 就会变成一场反复覆盖的拉锯,两边都在改,谁也没赢。

还有一个容易被忽略的点:Argo CD 与 Flux 都支持 Server-Side Apply,但默认行为与开关方式并不相同。Argo CD 需要通过同步选项显式开启 SSA,Flux 的 kustomize-controller 走的是服务端应用路径。具体字段名与默认值,需按当前官方文档核验。

声明非命令
循环才是核
字段谁做主
冲突早注定

04、架构

[输入] 开发者 PR 合入 deploy/gitops/
    |
    v
[模块] Git 仓库(唯一事实源)+ Argo CD / Flux 控制器
    |
    v
[数据/状态] Desired State(Chart / Kustomize Overlay)与 Cluster Live State
    |
    v
[处理] 周期比较 diff,计算 Patch,Server-Side Apply 收敛
    |
    v
[输出] 集群被拉向 Git 值 + 同步状态 / 事件 / 审计记录

链路的边界要说清楚。GitOps 管的是声明式资源本身的状态收敛,它不管镜像怎么构建、不管业务代码怎么发版、不管节点怎么扩容。把 GitOps 当成万能发布系统,边界就糊了。

收益有四个:审计(每次变更对应一次 commit)、回滚(回滚是一个 revert 加一次 Reconcile)、一致性(多环境共用 base,差异只在 overlay)、自愈(漂移被自动纠正)。

代价也实在:多一套控制器和它的权限体系;必须接受「Git 是唯一入口」的纪律,任何绕过都是给自己挖坑;对不可变字段和 CRD 的处理更敏感,某些资源改不动就是改不动,只能删了重建。

适用条件:资源能被声明式表达;团队接受 PR 流程;有可用的 Git 仓库与网络连通。三者缺一,先补条件,别急着上控制器。

一源定期望
一控察实际
一差生一补
一审计一行

05、实战一次

先把最小闭环跑通,不追求一次性做到生产级。环境:kind 集群 + kubectl + 一个 Git 仓库 + Argo CD。版本以各自当前稳定版为准。

未在当前环境实测,以下为预期结果。

先建集群与目录:

kind create cluster --name gitops-demo
mkdir -p deploy/gitops/base deploy/gitops/overlays/dev

deploy/gitops/base/kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml
  - service.yaml

deploy/gitops/base/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: digital-human-render
spec:
  replicas: 2
  selector:
    matchLabels:
      app: digital-human-render
  template:
    metadata:
      labels:
        app: digital-human-render
    spec:
      containers:
        - name: render
          image: registry.example.com/digital-human/render:1.0.0
          ports:
            - containerPort: 8080

deploy/gitops/overlays/dev/kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: dev
resources:
  - ../../base
patches:
  - target:
      kind: Deployment
      name: digital-human-render
    patch: |-
      - op: replace
        path: /spec/replicas
        value: 4

提交推送到 Git 仓库后,安装 Argo CD 并注册仓库:

kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl -n argocd get pods

创建 Application,把 overlay 目录交给它:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: digital-human-dev
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/example/platform-deploy.git
    targetRevision: main
    path: deploy/gitops/overlays/dev
  destination:
    server: https://kubernetes.default.svc
    namespace: dev
  syncPolicy:
    syncOptions:
      - CreateNamespace=true

如果选 Flux,等价声明是一份 Kustomization 资源,指向同一个 overlay 路径,interval 决定比较周期。字段名以当前官方文档核验:

apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: digital-human-dev
  namespace: flux-system
spec:
  interval: 10m
  prune: true
  sourceRef:
    kind: GitRepository
    name: platform-deploy
  path: ./deploy/gitops/overlays/dev

验证第一层,集群里的副本数应该等于 overlay 的值:

kubectl -n dev get deploy digital-human-render -o jsonpath='{.spec.replicas}'

示例输出:

4

验证第二层,制造一次漂移:

kubectl -n dev scale deploy digital-human-render --replicas=9

这时如果只开了同步、没开自愈,界面会显示漂移但不会自己收;如果开了自愈,下一个比较周期后副本数回到 4。这两种表现,正是后面排查的起点。

先跑通最小
再谈自愈强
未经实测处
只作预期看

06、排查

现象一: Application 长期 OutOfSync,但把 Git 里的 YAML 和集群对象放在一起看,肉眼看不出差别。

怀疑:是不是 Git 里少写了字段。检查:先看同步状态里的具体字段差异,再看集群对象的完整形态。

kubectl -n dev get deploy digital-human-render -o yaml --show-managed-fields

证据:差异集中在某个 annotation 上,而且它在 managedFields 里的 manager 不是我们。

根因:这个 annotation 由云厂商的 controller 或某个 operator 在运行时写入,Git 里永远不会有它的最新值。

修复:要么让它进入 ignoreDifferences 列表,要么让 controller 不再写这个字段。二选一,不能含糊。

错误尝试: 我当时的第一个反应是,把这个 annotation 带着当时的值一起写进 Git,让两边对齐。

为什么错:它是运行时动态改写的字段,写进去只能对齐这一秒。下一轮 controller 再写一次,两边又开始打架,而且这次是 Git 主动参与打架,问题更隐蔽。

现象二: 手动把副本数改大之后,界面显示漂移,但一直没有被拉回 Git 值。

怀疑:自愈没开,或者比较周期没到。检查 Application 的 syncPolicy 配置,以及控制器的比较周期。

证据:syncPolicy 里只有 automated 的 prune,没有 selfHeal。prune 管的是删除多余资源,selfHeal 管的是纠正被改动的资源,两者不是一回事。

根因:配置漏项,不是能力缺失。

修复:补上 selfHeal,并明确它作用的范围。

现象三: 同步报错,提示字段冲突,涉及两个不同的 field manager。

怀疑:有另一个控制器在写同一字段。检查 managedFields 里的 manager 列表,确认谁在写。

证据:同一字段上有两个 manager,一个来自 GitOps 控制器,一个来自集群内其他组件。

根因:字段所有权没有划分。修复:先判断这个字段到底该归谁,归别人的就从 Git 的期望里去掉,归 Git 的才考虑强制覆盖。

现象莫轻信
怀疑须落证
一错亦为镜
根因方定心

07、优化

基于上一章的三条证据,做四件事,形成 V2。

第一件,把渲染职责收进控制器。

根因:CI 里 helm template 再 apply,等于在 Git 之外又多了一层渲染结果,这层结果不受版本控制,也无人审计。修改:Git 里只保留 Chart 与 values,或者直接用 Kustomize base 加 overlay,渲染交给 Argo CD 或 Flux。原因:让「被同步的内容」和「Git 里的内容」是同一份东西,中间不再有黑盒。新行为:控制器自己渲染、自己比较、自己收敛。验证:对同一个路径,界面里看到的差异应该只剩 Git 与集群之间真实存在的差异。

第二件,显式开启 Server-Side Apply。

根因:客户端 apply 依赖 last-applied-configuration,合并结果对第三方写入不友好。修改:在同步选项里开启 SSA。原因:把合并交给 apiserver,字段所有权有据可查。新行为:冲突从「悄悄覆盖」变成「明确报错」。验证:制造一次双 manager 写同字段,观察是否出现冲突提示。

第三件,开 selfHeal 与 prune,但分环境放开。

根因:漂移无人纠正,多余资源无人清理。修改:dev 先同时打开两者,staging 紧随其后,prod 先只开 selfHeal,prune 等观察一段时间。原因:prune 会删资源,删错在生产上不可逆,自愈是无损纠正,风险等级不同。新行为:dev 的漂移在下一个周期被自动收敛。验证:手动 scale 之后观察副本数是否回到 Git 值。

第四件,把字段所有权写进文档。

根因:冲突的根源是没人说清楚某个字段归谁。修改:为每类资源列一张表,标明 Git 管哪些字段、集群内控制器管哪些字段、ignoreDifferences 覆盖哪些。原因:这张表是后续所有争议的裁决依据。新行为:新增字段时先过表,再提 PR。验证:下一次字段冲突时,能直接在表里查到答案,而不是重新排查一遍。

改在根因上
不在表层补
一处改对后
收敛自成章

08、演进

[同一输入]
    |
    +--[V1] CI 渲染 + kubectl apply,无持续比较,漂移靠人发现
    |        代价:无审计、回滚等于重放、环境不可比
    |
    +--[V2] Git 为源 + 控制器 Reconcile,SSA + selfHeal,漂移被收敛
    |        代价:多一套控制器、必须守纪律、字段冲突会显性报错
    |
[Trade-off]
得到:审计、可回滚、多环境一致性、自愈
失去:手改的即时性,以及绕开流程的便利
边界:声明式表达得清楚、字段所有权划得清,才成立

正确性上,V1 依赖人的自觉,V2 依赖控制循环,后者可重复。稳定性上,V1 的故障是隐形的,V2 的故障是显性的——冲突会报错,报错虽然烦,但至少你知道它在哪。

复杂度上,V2 明显更高:多一套控制器、多一套权限、多一张字段归属表。成本主要在前期设计和后期纪律,不在机器资源。

适用范围上,V1 适合少量环境、变更不频繁、人手紧张的团队;V2 适合多环境、多角色参与、需要审计的场景。这恰好是数字人业务线的现状。

遗留问题也诚实列出来:不可变字段的变更依然要删了重建,GitOps 帮不上;数据库迁移这类非声明式操作,仍然需要独立的流程,不能被塞进 Reconcile。

旧路求其快
新路求其准
得失常相伴
边界自权衡

09、洞见

9.1 GitOps 真正的对手是隐式状态

前面的所有故障,追到底都不是同步失败,而是有一批字段在 Git 之外被写入:云厂商的 annotation、控制器注入的标签、apiserver 填的默认值。它们构成了系统的隐式状态。

反直觉判断:漂移不是有人手滑造成的意外,而是多个控制器各自尽责的必然结果。你越是想让 Git 覆盖一切,冲突就越多。

正确的姿势不是消灭隐式状态,而是把它显性化:列出来、分级、决定谁让路。

9.2 先划字段所有权,再谈自动同步

自动同步是一把放大器。所有权清楚时,它放大一致性;所有权不清时,它放大冲突,而且是每十分钟放大一次。

反直觉判断:上 GitOps 之前最该做的准备,不是买工具、不是搭流水线,而是花半天时间把字段归属写成一张表。这张表决定了后面所有的争吵要不要重来一遍。

9.3 回滚不等于 revert

在推送模型里,回滚是把旧 YAML 再 apply 一次,它会把这段时间的临时补丁一并抹掉。在 GitOps 里,回滚是 revert 一个 commit,然后由控制器向集群方向收敛。

差别在于:前者是重放,后者是声明。声明式回滚的前提是,这段时间的关键改动都已经进了 Git——如果还有手改没补,回滚照样会抹掉它们。所以纪律本身就是回滚能力的一部分。

9.4 自愈是能力,也是风险

selfHeal 打开之后,集群会在你不知情的时候被改回去。绝大多数时候这是好事,但有一个例外:事故现场。

如果值班同学为了止血手动改了一个参数,而自愈在几分钟后把它改回来,止血就变成了反复出血。所以生产环境的自愈要么配告警,要么配一条「临时冻结」的操作路径,让人和控制器之间有一个明确的交接。

隐态最难缠
权属须先分
回滚非重放
自愈亦双刃

10、系统落地

原来有什么:一套 Helm chart 加一个 CI 任务,推送式发布,deploy 目录是唯一入口但只是名义上的。

本篇新增了什么:deploy/gitops 目录体系,base 与 overlay 分离;一个持续运行的控制器;一份字段所有权清单;一条 PR 到集群的可审计链路。

现在能做什么:数字人业务的 dev 与 staging 已经能做到改 Git 即改环境,漂移在下一个比较周期被收敛,回滚是一次 revert 加一次同步,任何一次生产变更都能追到人和时间。

还缺什么:prod 的 prune 还没开;镜像 tag 目前仍是人工写入,没有和构建流水线打通;字段所有权表只覆盖了最常用的几类资源,CRD 还没纳入。

下一步怎么走:先把 prod 的 selfHeal 稳住再谈 prune;把镜像 tag 的更新做成一次自动提交,让构建结果也能走 PR 流程;把字段所有权表扩到全部自研 CRD。

原有是散点
新增是闭环
能做什么事
缺什么再说

11、小结

Q1 → 不是 Helm 渲染不全,是集群里存在 Git 之外的隐式状态,推送模型看不见它
Q2 → 手改的替代品不是更快的手改,而是一条从 PR 到 Reconcile 的收敛路径,止血要走可审计的临时通道
Q3 → 失去的是绕开流程的便利和手改的即时性,换到的是审计、可回滚与多环境一致
状态 → deploy/gitops 已成为唯一入口,dev 与 staging 具备持续收敛与自愈能力

三问皆有答
一链已贯通
能力可交付
边界仍须知

12、作业

12.1 理解题:为什么说 GitOps 的核心是持续比较,而不是自动部署?

参考答案:自动部署只解决把内容推上去这一个动作,动作结束关系就断了。持续比较建立的是一个长期存在的控制循环,它持续判断期望状态与实际状态是否一致,并在不一致时收敛。漂移必然发生,所以需要的是循环而不是动作。

12.2 实战题:给数字人服务加一个新的环境变量,要求只影响 dev,写出最小改动路径。

参考答案:不动 base,只在 deploy/gitops/overlays/dev 下新增一份 patch,target 指向对应工作负载,patch 内容为新增 env 条目;提交 PR,合入后由控制器收敛到 dev 集群。base 保持纯净,staging 与 prod 不受影响。

12.3 排障题:同步时提示字段冲突,涉及两个 field manager,如何定位并处理?

参考答案:先用 show-managed-fields 查看该对象的 managedFields,找出争抢同一字段的两个 manager,确认另一个属于哪个控制器。然后判断这个字段的归属:归别人,就从 Git 的期望里移除;归 Git,才考虑强制覆盖。不要不问归属直接 force。

12.4 架构判断题:生产环境该不该同时打开 selfHeal 和 prune?

参考答案:建议分开评估、分步放开。selfHeal 是无损纠正,风险较低,可以较早开启;prune 会删除资源,删错不可逆,建议先在 dev 与 staging 验证,确认资源跟踪的 label 或 annotation 可靠之后,再在生产小范围开启。两者的风险等级不同,不该捆绑决策。

理解须落地
实战见真章
排障靠证据
判断定取舍

13、思考

回到最初那个问题:为什么 Helm 渲染出了完整 YAML,集群还是会漂移。

答案不在渲染这一步,而在渲染之后。集群是一个多方共同写入的运行系统,任何一方都在按自己的职责改动它。声明式的意义,是承认这种多方写入是常态,然后为每个字段划出边界,让收敛这件事可持续。

GitOps 落地最难的部分从来不是装一个控制器,而是接受一个事实:从今天起,任何绕过 Git 的改动,都只是暂时的、会消失的、需要被显性记录的技术债。

把这条线划清楚,后面所有关于工具的选择、同步策略的取舍、回滚流程的设计,都会变得清晰。

一念辨边界
一证定根因
一改知取舍
一役见真章

posted @ 2026-10-12 09:33  李福春  阅读(2)  评论(0)    收藏  举报