2026 年 5 月,我们一个支撑 7 个核心微服务的 ArgoCD GitOps 平台(argo-prod)在一次例行版本升级后,出现了一个让 SRE 抓狂 8 天的诡异问题:所有 Helm chart 类型的 Application 仍然能 sync,但 28 个用 Kustomize 部署的 Application 全部卡在 OutOfSync 状态,manual sync 也无效,UI 转圈 30 秒后报"context deadline exceeded"。Pod 全绿、controller log 看起来正常、Git 仓库可以正常 pull——所有"明显该错"的地方都没问题。8 天后我们才挖到根因:新版 ArgoCD 2.10 默认开启了 Kustomize 3.x → 5.x 的 plugin 路径变更,加上我们用的 sealed-secrets controller 升级后 webhook 阻塞了 admission,两层叠加导致 Kustomize render 阶段 deadlock。
这是一个典型的"GitOps 升级踩坑"复盘——升级看似无害,但底层组件的隐性默认值变更 + 多 controller 之间的隐性时序依赖会突然炸出来。这次复盘我们系统梳理了 ArgoCD 在生产环境里的"升级风险地图",以及为什么 GitOps 这种"声明式自动化"在生产里反而比命令式工具更容易踩坑。
故障背景:这个 GitOps 平台的规模
| 维度 | 规模/参数 |
|---|---|
| ArgoCD 版本 | 2.9.5 → 2.10.4(本次升级) |
| 管理的 Application 数量 | 74 个(7 核心服务 × 多环境) |
| 渲染方式 | Helm 46 个,Kustomize 28 个 |
| K8s 集群 | EKS 1.29,3 个集群(dev/staging/prod) |
| 关联 controller | sealed-secrets v0.24, cert-manager 1.14, external-secrets 0.9 |
| 升级前 Application sync 平均耗时 | 12 秒 |
| 升级后 Kustomize 类型耗时 | 30 秒(timeout) |
| 升级后 Helm 类型耗时 | 13 秒(几乎无影响) |
问题的诡异之处是只有 Kustomize 类型受影响,Helm 完全正常。这条线索后来成为定位的关键。
事故时间线
| 时间 | 事件 |
|---|---|
| D1 14:00 | 例行升级 ArgoCD 2.9.5 → 2.10.4,执行 helm upgrade,Pod 全部 Running |
| D1 14:30 | SRE 验证 sync,Helm 类 Application 全部 OK,Kustomize 类首次报 timeout |
| D1 15:00 | 第一反应回滚,helm rollback 失败(CRD 已升级,无法 downgrade),只能向前修 |
| D1 18:00 | 盯 argocd-application-controller 日志,看到大量 "kustomize build failed: context deadline" |
| D2 09:00 | 怀疑 Kustomize binary 版本,argocd 2.10 默认升级到 kustomize 5.3 |
| D2 14:00 | 本地用 kustomize 5.3 build 同样的 overlay,30 秒完成(不快但能跑) |
| D2 18:00 | 怀疑 repo-server 的资源限制,扩 CPU 到 4 核 / 内存 8GB,无效 |
| D3 10:00 | 查 K8s API audit log,发现 sealed-secrets webhook 在 build 期间被反复调用 |
| D4 09:00 | 本地复现:把 Kustomize overlay 拷出来 + 不连 K8s 集群,build 0.8 秒;连集群 build 30 秒 |
| D5 14:00 | 定位到 Kustomize 5.x 的 helmChartInflationGenerator 会做 K8s API 调用,触发 webhook |
| D6 10:00 | 方案对比:降级 Kustomize / 关 webhook / 改用 server-side apply 三选一 |
| D7 14:00 | 选择"分离 render 与 apply":Kustomize render 阶段不走 K8s API |
| D8 18:00 | 灰度 3 个 Application,sync 耗时回到 14 秒;次日全量,故障消除 |
第一轮排查:被 Pod 状态骗了
事故初期我们盯着 ArgoCD UI 和 Pod 状态看,所有 Pod 都是 Running,所有 ConfigMap、Service、Deployment 资源也都存在。第一反应是"ArgoCD 本身有 bug,等 patch 版本":
# 我们最早的"诊断"
kubectl -n argocd get pods
# NAME STATUS AGE
# argocd-application-controller-0 Running 2h
# argocd-server-7d4f8b9c-xxx Running 2h
# argocd-repo-server-5c9b8f-yyy Running 2h
# argocd-redis-... Running 2h
# 一切正常,完全看不出问题
# Application 状态
argocd app list | grep OutOfSync
# 28 个 Kustomize Application 显示 OutOfSync
# manual sync 后报 context deadline exceeded
这个阶段我们浪费了 6 小时——以为是网络抖动或临时问题,反复重启 controller,等下次 sync 还是 timeout。K8s 这种声明式系统的"Pod Running ≠ 应用正常",Pod 健康只能证明进程没崩,业务逻辑卡死时 Pod 状态完全无感。
第二轮排查:repo-server 日志开始有线索
真正有用的信息是把 argocd-repo-server 的日志级别开到 debug:
kubectl -n argocd edit cm argocd-cmd-params-cm
# 添加:
# reposerver.log.level: debug
kubectl -n argocd rollout restart deploy argocd-repo-server
# 然后看日志
kubectl -n argocd logs -l app.kubernetes.io/name=argocd-repo-server -f
# 关键日志(简化):
# DEBU[0] running kustomize build /tmp/_kustomize-xxxx
# DEBU[0] resolving cluster-scoped resources via API
# DEBU[5] still resolving... (5s elapsed)
# DEBU[10] still resolving... (10s elapsed)
# ERRO[30] context deadline exceeded
关键线索是 "resolving cluster-scoped resources via API"——Kustomize 在 build 阶段竟然在调 K8s API!正常 Kustomize 应该只是纯粹的 YAML 模板渲染,不该接触 K8s 集群。这条线索把我们引向了根因。
问题本质:Kustomize 5.x 的 helmChartInflationGenerator + webhook 阻塞
事故根因有 3 层叠加:
三个独立的"看似无害"的变更叠加:
- ArgoCD 2.10 默认捆绑 Kustomize 5.x(2.9 时是 4.5);
- Kustomize 5.x 启用了 helmChartInflationGenerator,会在 build 阶段联系 K8s API 查 Helm release;
- sealed-secrets 0.24 默认 webhook failurePolicy 从 Ignore 改为 Fail,所有 API 调用必须等 webhook 响应。
三个变更单独都没问题,叠在一起就是"render 阶段 280 次串行 webhook 调用"。Helm 类型 Application 不受影响因为它走的是 Helm template 渲染,不调 K8s API。这就是"分布式系统二级效应"的典型——单组件变更测试通过,组合在一起就炸。
修法 1:分离 render 与 apply 阶段(我们最终选的)
最干净的修法是在 ArgoCD 配置里禁用 Kustomize 的 K8s API 访问,让 render 纯粹是 YAML 模板渲染:
# argocd-cm ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
data:
# 禁用 Kustomize 的 helm chart inflation
kustomize.buildOptions: "--enable-helm=false --load-restrictor=LoadRestrictionsRootOnly"
# 强制 repo-server 不依赖 K8s API
reposerver.disable.helm.template.discovery: "true"
同时我们把所有需要 Helm chart 的场景从 Kustomize generators 改成"Argo CD Multiple Sources"——一个 Application 可以同时引用 Helm 和 Kustomize source,各自走自己的渲染路径:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: my-service
spec:
sources:
- repoURL: https://charts.example.com
chart: my-chart
targetRevision: 1.2.3
helm:
valueFiles:
- $values/values-prod.yaml
- repoURL: https://git.example.com/config-repo
targetRevision: HEAD
ref: values # 给上面引用
这种方式 Helm 走 Helm 通道,Kustomize 只做 overlay,render 阶段不需要查 K8s API。Sync 时间从 30 秒(timeout)恢复到 14 秒。
修法 2:降级 Kustomize binary
ArgoCD 支持自定义 Kustomize 版本,可以把 5.3 降回 4.5:
# argocd-cm ConfigMap
data:
kustomize.path.v4.5.7: /custom-tools/kustomize-4.5.7
# 在 repo-server Deployment 加 init container 下载 4.5.7
spec:
template:
spec:
initContainers:
- name: download-kustomize
image: alpine:3.19
command:
- sh
- -c
- |
wget https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv4.5.7/kustomize_v4.5.7_linux_amd64.tar.gz
tar xzf kustomize_v4.5.7_linux_amd64.tar.gz -C /custom-tools/
chmod +x /custom-tools/kustomize
mv /custom-tools/kustomize /custom-tools/kustomize-4.5.7
volumeMounts:
- name: custom-tools
mountPath: /custom-tools
volumes:
- name: custom-tools
emptyDir: {}
这个方案能用,但代价是永远停留在旧 Kustomize 4.x,以后想用 5.x 的新功能就难了。我们评估后没选这个,因为治标不治本。
修法 3:调 sealed-secrets webhook failurePolicy
把 sealed-secrets 的 webhook failurePolicy 改回 Ignore,这样即使 webhook 慢/挂,API 调用也不会 block:
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: sealed-secrets-controller
webhooks:
- name: sealedsecrets.bitnami.com
failurePolicy: Ignore # 改回 Ignore
timeoutSeconds: 5
# 同时缩短 timeout
这个方案的问题是失去了 webhook 的安全保障——sealed-secrets 校验如果失败,可能让无效的 secret 进入集群,这是安全风险。我们用作"应急 hotfix"上线了 1 天,但不是长期方案。
修法 4:升级 sealed-secrets 加上 webhook 短路逻辑
sealed-secrets 0.25 加入了一个特性:webhook 可以根据请求类型短路返回,不参与 Kustomize render 阶段的 API 调用:
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: my-secret
spec:
encryptedData: ...
# 0.25+ 支持
webhookOptions:
skipForRequests:
- operation: "GET" # GET 请求短路返回,不走解密逻辑
- operation: "LIST"
这个修法治本但需要等 sealed-secrets 0.25 发布(我们事故时刚 RC),不是当下能用的方案。
四种修法对比
| 方案 | Sync 耗时 | 侵入性 | 长期可持续 | 安全影响 |
|---|---|---|---|---|
| 原始(2.10 升级后) | 30s timeout | — | — | 无 |
| 分离 render/apply | 14s | 低 | 是 | 无 |
| 降级 Kustomize 4.5 | 13s | 中(自定义 binary) | 否(技术债) | 无 |
| webhook failurePolicy=Ignore | 14s | 低 | 否(安全风险) | 降低安全保障 |
| 升级 sealed-secrets 0.25 | 14s | 中(组件升级) | 是 | 无 |
决策树:ArgoCD 升级踩坑该怎么选
我们立的 11 条 ArgoCD GitOps 工程纪律
- ArgoCD 升级必须先在 staging 跑完整 sync 验证:不要只看 Pod Running,要看所有 Application sync 耗时;
- 升级前 export 所有 Application 的 sync 时间基线:建一份"升级前 / 升级后"对比表,任何耗时变长 > 50% 都要查;
- 不要在一次升级里同时跨多个 minor 版本:2.9 → 2.10 OK,2.8 → 2.11 一定会踩坑;
- 关联 controller(sealed-secrets / cert-manager 等)的升级和 ArgoCD 升级分开做:一次只动一件事,否则定位地狱;
- repo-server log 默认开 info 不够,生产应该 warn,debug 是排查时手动开;
- 所有 admission webhook 的 failurePolicy 必须文档化:Fail vs Ignore 是安全 vs 可用性的根本权衡;
- Kustomize generators 不要混 Helm:用 ArgoCD Multiple Sources 替代;
- 所有 Application 的 sync 必须有耗时告警:Prometheus argocd_app_sync_total + reconciliation_duration;
- 升级文档要列出"已知 breaking change":ArgoCD 每个版本的 CHANGELOG 必须由 SRE 完整读一遍;
- 不要在生产 ArgoCD 集群里用 latest tag:必须 pin 到具体版本号;
- Sync 失败要有自动告警而不是等 SRE 看 UI:多数团队的 ArgoCD UI 是被动看的。
引申一:为什么"声明式自动化"反而比命令式更难排错
事故让我们重新认识了 GitOps 的代价。GitOps 的核心承诺是"声明式 + 自动化"——你描述期望状态,系统自动收敛。这听起来很美,但出问题时定位难度比命令式工具高一个数量级:
- 命令式工具(ansible / kubectl apply):每个动作都有明确的 stdout/stderr,失败有立即反馈,知道是哪个命令的哪一步崩了;
- 声明式 GitOps(ArgoCD / Flux):你提交一个 commit,系统自己处理,中间过了 N 个 controller、N 层缓存、N 次 reconcile,任何一层出错只看到"OutOfSync"或"Error",不知道具体哪步;
- 异步性放大问题:声明式系统的 reconcile 是无状态、可重试的,一个慢操作可能被反复触发,放大延迟;
- 跨组件依赖隐藏:声明式系统鼓励"组合多个 controller",但组件之间的隐性时序依赖完全不可见。
这次事故就是典型——三个独立组件(ArgoCD / Kustomize / sealed-secrets)各自独立测试都没问题,组合在一起就炸。声明式自动化的爽是建立在所有组件协议都遵守得很好的前提下,任何一个组件破坏协议,排错就是地狱。这条经验让我们对"GitOps 是银弹"的说法保持警惕——它在稳态下很爽,变更时痛苦加倍。
引申二:webhook failurePolicy 的安全 vs 可用性权衡
sealed-secrets 升级把 failurePolicy 从 Ignore 改成 Fail,这看起来是"加强安全"的小变更,但生产影响巨大。我们总结了一个权衡表:
| failurePolicy | webhook 慢/挂时 | 安全保障 | 适合场景 |
|---|---|---|---|
| Fail | API 请求被阻塞 / 拒绝 | 强(没人能绕过校验) | 密码学相关(secret/cert) |
| Ignore | API 请求继续(跳过校验) | 弱(校验失败仍放行) | 风险可控的策略校验(label/quota) |
对 sealed-secrets 这种密钥校验场景,Fail 确实更安全——你不希望 webhook 挂的时候让无效 secret 进集群。但代价是所有 API 调用的延迟和可用性都依赖 webhook。生产环境里 webhook 通常要做:
- webhook 必须高可用:多副本 + Pod Disruption Budget;
- webhook 必须有超短 timeout:< 5 秒,避免阻塞 API server;
- webhook 必须有 namespace selector:只校验需要的命名空间,不要全集群拦截;
- webhook 调用链不能有外部依赖:不能查 DB、不能调外部 API,纯 local 计算。
这次事故让我们补完了 sealed-secrets webhook 的所有这些点,顺便审视了集群里其他 webhook(cert-manager / Kyverno / Gatekeeper)的配置。K8s 集群里有多少 webhook,就有多少潜在的 single point of failure。
引申三:Kustomize 5.x 的"善意改进"为何成了陷阱
Kustomize 5.x 引入 helmChartInflationGenerator 的初衷是"让 Kustomize 能更好地组合 Helm chart",这是社区呼声很高的功能。但默认开启这个 generator 隐性引入了 K8s API 依赖,对 ArgoCD 这种"render 阶段不该接触集群"的工具是架构层面的破坏。
这是开源生态的一个常见模式:新功能默认开启 → 上游用户没意识到 → 下游集成方踩坑。类似的例子:
- Helm 3 默认启用 OCI registry 支持,部分镜像仓库不兼容,装包失败;
- kubectl 1.30 默认启用 server-side apply,旧的 client-side patch 行为不兼容;
- Containerd 1.7 默认改 cgroup driver 为 systemd,kubelet 配置不匹配会崩;
- Helm 3.14 升级了 OpenAPI 校验,旧的"宽松"chart 突然报错。
这些"善意改进"组合在你的栈上时就是灾难。升级任何上游组件前,读 CHANGELOG 的 BREAKING CHANGES 段必须像读财报警示项一样仔细——别只看"亮点功能"。
引申四:GitOps 灾备和回滚的真实挑战
这次事故有一个让人冷汗的细节:我们试图 helm rollback ArgoCD 回 2.9.5,但失败了——因为 ArgoCD 2.10 升级时同步升级了一些 CRD,CRD 一旦升级就不能简单 downgrade(K8s 不允许 CRD schema 退回旧版本)。这让我们意识到一个被忽视的问题:GitOps 工具自己的回滚比业务回滚复杂得多。
# 我们试过的失败回滚
helm rollback argocd 5 -n argocd
# Error: cannot downgrade CRD argocd.argoproj.io/v1alpha1 from 2.10 to 2.9
# 因为 CRD schema 已经在线升级,kubectl 看到的是新 schema
正确的 ArgoCD 灾备应该是:
- 升级前 etcd 整体快照:不只备份 ArgoCD CRD,要备份整集群;
- 蓝绿部署 ArgoCD:并行装一套 2.10 在新 namespace,验证后切换 ingress;
- CRD 升级与 controller 升级分离:CRD 单独 helm,controller 单独 helm,出问题能分别回滚;
- 所有 Application 资源 export 备份:升级前用 argocd export 命令完整备份。
这个故事的核心教训:"工具的灾备能力" 应该比 "业务的灾备能力" 高一个等级——业务挂了你可以靠工具救,工具挂了就要靠人。所以工具本身的稳定性和可恢复性必须更稳。
引申五:为什么 8 天才定位
事后我们盘了一下 8 天里的时间分布:
- D1-D2:盲目重启、扩资源,认为是临时问题 → 浪费 36 小时;
- D3:开 debug log,有方向但没抓重点 → 真正进入排查 24 小时;
- D4-D5:本地复现 + 定位到 helmChartInflationGenerator → 36 小时;
- D6-D7:方案对比 + 灰度 → 36 小时(合理时间);
- D8:全量 + 验证 → 12 小时(合理时间)。
真正浪费的是 D1-D2 的 36 小时,根本原因是"假定问题是临时的"。这种心智惯性在 K8s 这种"自愈"系统里特别强——历史上很多问题真的能"等一会儿就好了"。但 GitOps 这种声明式系统的问题恰恰不会自愈,因为期望状态没改、reconcile 失败也会反复重试,不存在"再等等"的可能。
教训:对声明式系统的"OutOfSync"或"持续 reconcile 失败"必须立刻当作 P1,而不是等等看。我们后来调整了告警策略,任何 Application 持续 OutOfSync > 5 分钟就 page SRE。
引申六:为什么 Flux 用户没遇到这个问题
有人问:"既然 ArgoCD 这么坑,为什么 Flux 用户没大规模反馈这个问题?"我们调研后发现:
- Flux 的 Kustomize controller 默认不开 helmChartInflationGenerator;
- Flux 把 Helm 和 Kustomize 强制分开成两个 controller,没有"在 Kustomize 里嵌 Helm"的鼓励姿态;
- Flux 默认禁用 K8s API 访问做 render,要主动开启;
- Flux 升级节奏更稳,minor 版本之间的 breaking change 较少。
这不是说 Flux 比 ArgoCD 好——ArgoCD 的 UI、多租户、ApplicationSet 都比 Flux 强。但Flux 在"默认安全"上更保守,适合不愿意经常调参的团队。我们这次踩坑后内部讨论过迁移 Flux,最终决定不迁:Flux 也有 Flux 的坑,迁移代价太大,不如把 ArgoCD 的边界摸清楚。
引申七:Application 的 sync 行为可观测性
事故后我们补全了 ArgoCD 的监控:
# Prometheus ServiceMonitor for argocd-metrics
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: argocd-metrics
spec:
selector:
matchLabels:
app.kubernetes.io/name: argocd-application-controller
endpoints:
- port: metrics
# 关键告警规则
- alert: ArgoAppOutOfSyncTooLong
expr: argocd_app_info{sync_status="OutOfSync"} == 1
for: 5m
labels:
severity: warning
- alert: ArgoSyncDurationTooLong
expr: histogram_quantile(0.95, argocd_app_reconcile_bucket) > 60
for: 5m
- alert: ArgoControllerErrors
expr: rate(argocd_app_k8s_request_total{status_code=~"5.."}[5m]) > 0.1
这套告警上线后,我们前两个月共抓出 3 个潜在问题(都是 Kustomize 误配置),都在影响生产前修掉。GitOps 不能取代监控,反而需要更细致的监控——因为它默默地把许多变更"自动化"了,出问题不会有人主动喊。
引申八:平台工程师的"GitOps 反模式清单"
事故后我整理了一份"GitOps 别这么用"的反模式清单,贴在团队 wiki:
- 用 Git 仓库做 secret 管理(直接 commit 加密文件,但不用 sealed-secrets):仓库泄露 = 全部 secret 泄露;
- 把所有应用塞一个大 Application:sync 失败时影响范围最大,无法分应用治理;
- 用 ArgoCD 自己管自己:循环依赖,出问题没法修;
- 在 sync hook 里跑长任务:超过 ArgoCD timeout 必失败,要用 Job 异步;
- 不用 SyncWave 控制部署顺序:多组件 Application 上来就并发 apply,资源竞争;
- Application target revision 用 HEAD:每次 reconcile 都拉最新 commit,无法回滚到特定状态;
- Webhook 没有 healthCheck:webhook 挂了,sync 莫名其妙失败找不到原因。
这些反模式都有人在生产里踩过,不是理论可能性。GitOps 工具用对了是生产力放大器,用错了是事故放大器。
总结
这次故障的表层是 ArgoCD 升级后 Kustomize Application 全部 timeout,深层是三个独立变更的"二级效应":Kustomize 5.x 默认开 helm inflation + 默认调 K8s API + sealed-secrets webhook failurePolicy 改 Fail。三者单独都没问题,叠加在一起就是 30 秒 timeout。修复的核心思路是分离 render 与 apply 阶段,让 Kustomize 不再依赖 K8s API。
比这次具体问题更值得记住的是几个泛化原则:声明式自动化的故障定位比命令式难一个数量级;升级任何组件前必须读 BREAKING CHANGES;集群里每个 webhook 都是潜在的 SPOF;GitOps 工具自己的灾备能力必须比业务高一档。这些原则后续帮我们规避了至少 3 个类似事故。
K8s 生态的复杂度还在持续增加,新工具新组件层出不穷。但稳定生产的姿势没变:少用一个组件就少一份风险;每加一个组件就要把它的失败模式、升级路径、灾备方案理清楚。GitOps 看似简化了运维,实际上是把运维复杂度从"操作步骤"转到了"组件协议",理解后者比记忆前者重要 10 倍。
引申九:为什么我们没立刻迁移到 Argo CD 2.11
事故定位完成后,ArgoCD 2.11 已经发布,官方 release note 里写"修复了 Kustomize helmChartInflationGenerator 阻塞问题"。我们评估后决定不立刻升级,原因有三:
- "修复"不等于"解决":官方文档说"now async",但我们事故里关注的不是 sync 还是 async,而是 K8s API 调用本身在 render 阶段应不应该存在;
- 新版本带来新风险:2.11 还引入了 ApplicationSet 的新行为变更,这些变更需要时间验证;
- 修法 1 已经稳定运行:分离 render/apply 后 sync 14 秒,没有动机赶时间升级。
这反映出一个生产决策原则:"上游修复了"不是你立刻升级的理由。你的方案如果稳定,跟着上游升级反而是引入新风险。技术债 vs 升级债之间要算账——很多团队迷信"用最新版"就是稳定,实际上往往是踩坑前线。
引申十:升级前 dry-run 没发现问题的原因
事故反思里有人问:"我们 staging 升级时为什么没复现?"我们盘了一下原因:
- staging 集群只有 6 个 Application(prod 是 74 个),数量级差;
- staging 没有完整的 sealed-secrets 配置,只有少量加密 secret;
- staging 的 webhook 响应快(因为 secret 少,解密快),没暴露累积延迟;
- 测试只看了"sync 成功"没看"sync 耗时",sync 慢了 3 倍但没人发现。
这导致一个深刻教训:staging 必须是"按比例缩小的 prod",不能是"功能子集"。如果 prod 有 74 个 Application,staging 至少要有 30 个;如果 prod 用 sealed-secrets,staging 必须装同样多的 secret(可以是 mock)。否则 staging 验证只能发现明显错误,看不出"量级累积"问题。
我们后来把 staging 重建成了"prod 的 1:2 缩比",所有组件都按 prod 配置,只是规模小一半。后续 4 次 ArgoCD 升级都在 staging 复现了所有 prod 问题,生产升级再没出过事故。
引申十一:GitOps 工具选型回顾
事故让我们重新看了一遍 GitOps 工具选型决策。当初我们选 ArgoCD vs Flux 时,做的对比表是这样的:
| 维度 | ArgoCD | Flux v2 | 当时打分 |
|---|---|---|---|
| UI 友好度 | 原生 UI,直观 | 无内置 UI,需 Weave GitOps 等 | ArgoCD +1 |
| 多租户 | AppProject + RBAC 完善 | 需要靠 Kubernetes namespace | ArgoCD +1 |
| ApplicationSet | 原生支持 | 需要写多个 Kustomization | ArgoCD +1 |
| 组件简洁度 | controller + repo-server + server 三组件 | 每个功能一个 controller,7+ | Flux +1 |
| 升级稳定性 | 有破坏性变更 | 更稳定 | Flux +1 |
| 社区活跃 | 非常活跃,CNCF graduated | 非常活跃,CNCF graduated | 平局 |
我们当时打分 ArgoCD 4 vs Flux 2 选了 ArgoCD,但事故后回看,"升级稳定性"这一项的权重应该高得多。生产工具的可用性是底线需求,任何"功能丰富但升级会炸"的工具,在生产环境的真实价值都要打折。
这次教训让我们重新审视了所有 "功能丰富" 的工具选型——下次评估时"破坏性变更频率"会作为否决项而不是普通加分项。一年踩 2 次坑的工具,无论功能多丰富,都不该进生产关键路径。
引申十二:为什么"declarative is simpler"在大型场景不成立
GitOps 社区有一句口号:"declarative is simpler than imperative"(声明式比命令式简单)。这句话在小规模时是对的,但在 74 个 Application + 多 controller + 多环境的复杂生产环境里恰恰相反。原因:
- 声明式系统的"行为"由多个 controller 协议决定:你需要理解 ArgoCD + Helm + Kustomize + sealed-secrets + cert-manager 各自的行为,远比读一个 shell 脚本复杂;
- 声明式系统的"中间状态"不可见:命令式 N 步流程的每一步都有 stdout,声明式系统只让你看到"开始 → 结束"两个状态;
- 声明式系统的"自动恢复"会掩盖真问题:命令式失败你必须修才能继续,声明式失败 controller 会反复重试,你可能根本不知道在重试;
- 声明式系统的"幂等性"要求开发者写代码时多想一层:任何一段配置都要考虑"反复 apply 会怎样",这个心智负担不小。
我个人的结论:"声明式 simpler" 是初学者印象,生产规模下 "声明式 different"——心智模型不同,不是简单或不简单。要在生产把声明式系统用好,需要的工程深度反而比命令式更高。
引申十三:平台工程的"边界"应该划在哪
这次事故还让我反思了平台工程的边界问题。我们平台团队 5 人,管 7 个核心服务的部署。当业务团队要新功能时,平台团队往往会想:"加个 controller 就好",随手就把K8s 集群塞成了组件公园:
- cert-manager 管证书;
- external-secrets 同步 Vault;
- sealed-secrets 加密 Git 里的 secret;
- Kyverno 做策略校验;
- Karpenter 做节点自动扩容;
- Linkerd 做 service mesh;
- Prometheus + Grafana + Loki + Tempo 做 observability。
每个组件单独都是好工具,但 10 个组件叠加在 ArgoCD 上,我们的"平台"实际上有 10 个升级周期、10 个故障模式、10 套 CRD 互相依赖。平台越"丰富",生产事故面越大——这次事故只是冰山一角,任何两个组件之间都可能产生类似的"二级效应"。
事故后我们做了一次"组件审计",删掉了 2 个"加了但很少用"的组件(其中一个是 Kyverno,我们改用 K8s 原生 admission webhook 替代),把组件数压回 8 个。这种"减法工程"不容易,要克服"已经装了就保留"的惯性,但生产稳定性收益巨大。下次有人来我面前提议"加个新组件解决 X",我会先问:"如果不加,我们怎么解决?"——这个问题答得清楚的话,通常不加更好。
这次事故反复印证一个道理:平台工程的最大成就不是装了多少酷工具,而是删掉了多少不必要的复杂度。我们这一年的目标变成"年底集群里组件数不超过 7 个",这个 KPI 远比"上线了 X 个新平台特性"更有价值。
—— 别看了 · 2026