GitOps 与 Argo CD 深度实战:从声明式 Diff 引擎、Sync Wave 编排到资源健康判定的工程全解

在 Kubernetes 的世界里,"把应用部署上去"这件事早已不是问题,真正难的是持续保证集群里跑的东西和 Git 里写的东西一致。kubectl apply 是一次性的,Helm upgrade 是命令式的,CI 里的脚本推送是一次性的——只要没有人盯着,漂移(drift)几乎是必然发生的。GitOps 的价值就在于把"部署"这个动作,改造成一个永不停止的收敛过程。而 Argo CD 是目前这套范式里工程化程度最高的实现。本文不谈"GitOps 是什么",而是拆开 Argo CD 的内部机制:它如何计算差异、如何编排下发顺序、如何判定一个资源是否真的健康,以及这些机制在生产中应该怎么调。

一、收敛循环:不是部署,是持续对账

Argo CD 的核心是一个标准的 Kubernetes 控制器,但它 reconcile 的对象是 Application 这个 CRD。每个 Application 指向三样东西:Git 仓库地址与目标路径(期望状态)、目标集群与命名空间(作用范围)、同步策略(手动/自动、是否 prune、是否 self-heal)。

控制器的循环逻辑可以抽象成这样:

func (c *appController) reconcile(app *v1alpha1.Application) error {
    // 1. 拉取并渲染期望状态
    target, err := c.render(app.Spec.Source)      // Kustomize/Helm/Plugin/Directory
    if err != nil {
        return c.setCondition(app, "ComparisonError", err)
    }

    // 2. 从集群读取实际状态
    live, err := c.clusterCache.GetManagedLiveObjs(app)

    // 3. 计算差异 -> Sync Status
    diff := diff.Diff(target, live, app.Spec.IgnoreDifferences)
    app.Status.Sync.Status = diffStatus(diff)     // Synced / OutOfSync / Unknown

    // 4. 计算健康度 -> Health Status
    app.Status.Health = health.Evaluate(live, c.healthOverride)

    // 5. 如果开启自动同步且 OutOfSync -> 触发 Sync
    if app.Spec.SyncPolicy.Automated != nil && diff.HasDiff() {
        return c.sync(app, diff)
    }
    return nil
}

这里有个容易被忽略的架构细节:状态比较(diff)和状态应用(sync)是两条完全独立的路径。默认 3 分钟的 reconcile 周期只做比较,不做下发。这意味着即使你不开启 auto-sync,Argo CD 也已经是一个持续运行的漂移检测器——把 argocd_app_sync_status 指标接进 Prometheus,你就有了一个覆盖全集群的配置漂移告警系统。这一点在生产上的价值,往往比自动部署本身还大。

二、渲染管线:从 Git 到 Manifest

Argo CD 不自己发明模板语言,而是通过 CMP(Config Management Plugin)机制外挂渲染器。默认支持三种:

  • Directory:直接读 YAML,零魔法,适合已经用 CI 渲染好的场景;
  • Kustomize:无模板,靠 overlay 叠加,Argo CD 原生支持 kustomize build --enable-helm;
  • Helm:支持 valuesFiles、parameters 动态覆盖,也支持通过 $values 引用外部 values 文件。

生产上一个高频需求是"一个 Application 对应多个 Helm release 的差异化配置",用 ApplicationSet 的 matrix generator 比手写几十个 Application 清晰得多:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: platform-addons
spec:
  goTemplate: true
  generators:
    - matrix:
        generators:
          - clusters:
              selector: { matchLabels: { env: prod } }
          - list:
              elements:
                - chart: ingress-nginx
                  repo: https://kubernetes.github.io/ingress-nginx
                - chart: cert-manager
                  repo: https://charts.jetstack.io
  template:
    metadata:
      name: '{{.name}}-{{.chart}}'
    spec:
      project: platform
      source:
        repoURL: '{{.repo}}'
        chart: '{{.chart}}'
        helm:
          valuesObject:
            replicaCount: '{{ index .metadata.labels "cluster-size" }}'
      destination:
        server: '{{.server}}'
        namespace: kube-addons
      syncPolicy:
        automated: { prune: true, selfHeal: true }

注意 valuesObject 而不是 values:前者是结构化对象,能做类型校验,后者是字符串拼接,出错了只能靠眼睛看。

三、Diff 引擎:三路合并与"看得懂的差异"

Argo CD 的 diff 不是简单的 git diff,而是三路合并(three-way merge):期望状态(target)、实际状态(live)、以及 kubectl.kubernetes.io/last-applied-configuration 注解里记录的"上次应用的状态"(last applied)。三路合并的意义在于区分两种变更:

  1. 期望状态里删掉的字段 → 说明是用户主动移除,应该同步删除;
  2. 期望状态里没有、但 live 里被别人(比如 mutating webhook、HPA 控制器)改过的字段 → 不该被覆盖回去。

这就是为什么 Argo CD 不会把 HPA 改的 spec.replicas 给覆盖掉。但如果你的 Deployment 里压根没写 replicas 而集群里有,三路合并也无法判断——这时就会看到那个经典的 OutOfSync 抖动。解决办法不是关掉自动同步,而是把不该管的字段显式声明出来:

# argocd-cm 中的全局配置
data:
  application.resourceTrackingMethod: annotation+label
  resource.compareoptions: |
    # 忽略聚合层写入的字段
    ignoreAggregatedRoles: true
    # 忽略 status 子资源(默认已忽略)
    ignoreResourceStatusField: crd
    # 忽略带特定注解的资源
    ignoreDifferencesOnResourceUpdates: false
---
# Application 级别的细粒度忽略
spec:
  ignoreDifferences:
    - group: apps
      kind: Deployment
      name: api-gateway
      jsonPointers: ["/spec/replicas"]      # HPA 托管
    - group: "*"
      kind: "*"
      managedFieldsManagers: ["argocd-controller"]  # 忽略自己写入的字段

另一个生产必备配置是 Server-Side Diff:把 diff 阶段改成 kubectl apply --server-side --dry-run=server,让 API Server 上的准入控制和默认值填充参与进来。这样能避免"本地看着没差异、apply 之后一堆字段被 webhook 改写"导致的永久 OutOfSync:

spec:
  syncPolicy:
    syncOptions:
      - ServerSideApply=true
      - ApplyOutOfSyncOnly=true
      - RespectIgnoreDifferences=true
      - CreateNamespace=true      # 替代 --create-namespace

ApplyOutOfSyncOnly=true 在大 Application 上收益明显:只对真正有差异的资源发 apply 请求,避免每次全量 PATCH 打爆 API Server 的审计日志。

四、Sync Wave 与 Hook:下发的顺序问题

Kubernetes 里资源之间是有依赖的,但 apply 本身不保证顺序。Argo CD 用两种机制解决:

Sync Wave(波次):通过注解给资源编号,数字小的先下发,同波次内按 Kind 的预定义顺序(Namespace → CRD → ServiceAccount → ConfigMap → ... → Deployment → Job)。

apiVersion: batch/v1
kind: Job
metadata:
  name: db-migrate
  annotations:
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/sync-wave: "-1"
    argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: registry.internal/api:v2.3.0
          command: ["/bin/migrate", "--up"]

Hook 阶段:PreSync(如数据库迁移、备份)、Sync(默认)、PostSync(如冒烟测试、通知)、SyncFail(失败回滚)。Job 类型的 hook 会阻塞后续波次直到成功,hook-delete-policy 控制清理时机。

工程上一个重要经验:不要用 hook 做长耗时的等待。hook Job 的超时由 spec.syncPolicy.retry 和 controller 的 timeout.reconciliation 共同约束,一个跑 20 分钟的迁移 Job 会让整个 Application 卡在 Progressing。更合理的做法是把迁移做成应用启动的一部分(initContainer + 幂等锁),或者用独立的 Argo Workflows 编排,而不是塞进 sync hook。

五、健康判定:Argo CD 最有价值也最容易被低估的部分

一个资源 apply 成功不等于它就绪。Argo CD 内置了一套按 Kind 分类的健康检查逻辑:Deployment 看 readyReplicas == replicas 且 observedGeneration >= generation;Service 看是否分配到 ClusterIP/Ingress 看是否有 address;StatefulSet 看 currentRevision == updateRevision。

但对于 CRD——比如你自己的 Operator 管理的资源,或者 cert-manager 的 Certificate——内置逻辑只能返回 Progressing。这时要用 Lua 脚本扩展健康检查,写在 argocd-cm 里:

local hs = {}
-- 自定义 CRD Foo 的健康判定
if obj.status ~= nil then
  if obj.status.phase ~= nil then
    if obj.status.phase == "Running" then
      hs.status = "Healthy"
      hs.message = "Foo is running"
      return hs
    end
    if obj.status.phase == "Failed" then
      hs.status = "Degraded"
      hs.message = obj.status.message or "Foo failed"
      return hs
    end
  end
  -- 关键:检查 observedGeneration,避免读到旧状态
  if obj.metadata.generation ~= nil and obj.status.observedGeneration ~= nil
     and obj.status.observedGeneration < obj.metadata.generation then
    hs.status = "Progressing"
    hs.message = "waiting for controller to observe latest spec"
    return hs
  end
end
hs.status = "Progressing"
hs.message = "no status yet"
return hs

这段脚本里最重要的不是 phase 判断,而是 observedGeneration 比较。这是 Kubernetes 控制器协议的通用约定:控制器处理完最新 spec 后才会更新 status.observedGeneration。不比较它,你会经常看到"资源明明已经失败了,Argo CD 还显示 Healthy"——因为读到的是上一轮的 status。

健康判定的输出直接决定了 argocd_app_health_status 指标和 UI 上的树状视图,也决定了自动回滚(配合 Rollout 的 AnalysisTemplate)能否触发。把它写对,GitOps 才真正闭环。

六、生产落地的几个硬经验

  1. 把 Application 也纳入 GitOps 管理(App of Apps / ApplicationSet)。否则 Application 自身就是最大的漂移源——人手 kubectl apply -f app.yaml 改一次,之后谁也不知道 Git 里的对不对。
  2. prune 要谨慎开启。prune: true 意味着 Git 里删掉的资源会被真实删除。生产环境建议配合 PruneLast=true 和 PrunePropagationPolicy=foreground,并且对 CRD 这类"删了就带走所有实例"的资源单独拆 Application。
  3. 仓库分片。Argo CD 对每个 repo 有独立的缓存和拉取锁,几千个 Application 挤在一个 repo 里会导致 reconcile 排队。argocd_app_reconcile_duration 的 P99 超过 30s 就该拆了。
  4. 用 sync-window 控制变更窗口。金融/支付类业务通常需要"工作日 10:00-16:00 禁止自动同步",这是 ApplicationSet 配置项而非外部调度器能干净解决的事:
spec:
  syncPolicy:
    syncOptions: [ApplyOutOfSyncOnly=true]
  # Project 级别窗口
# AppProject:
#   spec.syncWindows:
#     - kind: deny
#       schedule: '0 2 * * 1-5'   # UTC
#       duration: 8h
#       applications: ["prod-*"]
#       manualSync: false
  1. 不要把所有环境塞进一个 Application。dev/staging/prod 用同一个 Application + 不同 targetRevision 看似优雅,但一旦 auto-sync 配错,一次 push 就直连生产。用 ApplicationSet 的 cluster generator 生成独立 Application,天然隔离 blast radius。

七、结语

Argo CD 表面上是"一个能看 UI 的 kubectl apply",实质上是一套把声明式语义真正落地的收敛系统:三路合并解决了"谁的改动算数",Sync Wave 解决了"谁先谁后",健康判定解决了"什么叫成功"。理解这三层,你会发现很多"Argo CD 不好用"的抱怨,其实是把 GitOps 当成了一个更花哨的 CI 部署步骤——而它真正的价值在于持续对账这件事本身。

在 LLM/数据平台这类组件多、配置复杂、变更频繁的栈里,把状态收敛交给控制器,人只负责维护 Git 里的期望状态,是目前工程上最可靠的做法之一。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部