压测中副本数怎么都上不去:一次 ArgoCD selfHeal 与 HPA 抢 spec.replicas 的排查
目录
背景#
这个问题发生在 2026-07-09,我在一个 perf 环境里做容量压测,目标是找出交易链路上一个 Java 服务的吞吐上限。环境是共享的 EKS 集群,部署完全走 GitOps:ArgoCD 自动 sync,git 是唯一入口;被测服务开了 HPA。文中服务名统一替换为 pay-ms,环境、集群信息也做了模糊处理,配置片段保持原样只改名字。
被测服务在 Helm values 里的相关配置:
pay_ms:
replicaCount: 3
resources:
requests: { cpu: 4000m, memory: 4Gi }
limits: { cpu: 4000m, memory: 4Gi }
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 10
targetCPUUtilizationPercentage: 40
HPA 侧(kubectl describe hpa 摘要):
Min replicas: 3 Max replicas: 10
Metric: container CPU (pay-ms), target 40% of request
Behavior:
Scale Up: stabilization 60s; Max(Percent 100%/15s, Pods 4/15s)
Scale Down: stabilization 600s; Pods 1/60s
values 里同时写着 replicaCount: 3 和 autoscaling.enabled: true。当时没人觉得这有什么问题——后面会看到,矛盾就埋在这里。
现象:副本数卡住,新 pod 反复消失#
压力逐档往上加之后,出现了三件对不上的事:
- 副本数从来没有超过 5,而
maxReplicas明明是 10; - 事件里能看到新 pod 被创建,但过一会儿就没了,从来攒不出稳定的新副本;
- 每个 pod 的实测 CPU 约 70%(request 4000m),远超 40% 的 target,HPA 却像没在干活。
kubectl get hpa 的快照长这样(TARGETS 里的 CPU 读数在几次采样之间跳得很厉害):
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
pay-ms Deployment/pay-ms cpu: 42%/40% 3 10 3 4h10m
第一反应是资源不够或者调度不上,但集群里没有 Pending 的 pod,10 的上限也从来没接近过。「卡在 5」不是任何一个显式配置能解释出来的数字。
排查:是谁把副本数按回 3 的#
与其对着配置猜,不如盯住 spec.replicas 这个字段本身:它明显在变,那就搞清楚每一次变化是谁写的。几组观察拼出了证据链:
| 观察 | 推断 |
|---|---|
spec.replicas 连续三次采样都是 3,但 8 秒前 HPA 刚把它扩到 5,当时 CPU 约 70% |
有外力在持续把副本数按回 3 |
HPA 事件里只有 New size: 4(26 分钟内 9 次)和 New size: 5(18 分钟内 4 次),从来没有 New size: 3 |
缩回 3 不是 HPA 干的 |
kubectl top:三个 pod 分别用 2763m / 2807m / 2853m(request 4000m,约 70%) |
负载真实存在,HPA 本该继续扩容 |
新 pod 的事件先是 Readiness probe failed: ... connection refused,几秒后就是 Killing |
新副本还没启动完就被杀,从来没活到 Ready |
Deployment 的 managedFields 里,f:spec.f:replicas 的 owner 是 argocd-application-controller;Application 的 reconciledAt 就在每次「被按回 3」前不久 |
那个外力就是 ArgoCD |
其中第二条是关键判断依据:HPA 对自己发起的每一次伸缩——包括缩容——都会记录 SuccessfulRescale 事件,而且这个 HPA 的 scale-down stabilization 是 600s,不可能在几秒内自己缩回去。事件里从来没出现过缩到 3,spec.replicas 却反复精确地回到 3,而 3 恰好是 git 里的 replicaCount。写回 3 的不是 HPA,是 ArgoCD。
根因:两处配置叠在一起#
Helm 模板无条件渲染 replicas#
deployment 模板里的 replicas 没有任何条件保护:
spec:
replicas: {{ .Values.pay_ms.replicaCount }}
即便 autoscaling.enabled: true、HPA 资源也被渲染出来了,git 里的期望状态仍然永远声明 replicas: 3,等于给了 ArgoCD 一个「标准答案」去覆盖 HPA 的调整。这套 chart 有四十多个服务的 deployment 模板,全部这么写,没有一个判断 autoscaling 的条件。perf 环境目前只有 pay-ms 开了 HPA,所以只有它中招——但任何服务一旦开 autoscaling,都会踩进同一个坑。
ArgoCD 的 sync 策略又特别强硬#
这个环境的 Application 是这么配的:
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- Prune=true
- PruneLast=true
- Replace=true
- Force=true
三个设置叠加起来:
selfHeal: true:检测到 live 状态和 git 有漂移就主动改回来,不需要任何人触发 sync;Replace=true+Force=true:sync 不走默认的kubectl apply(三方合并),而是等价于kubectl replace --force的整对象强制替换,ArgoCD 文档对这两个选项都标注了破坏性警告;ignoreDifferences为空:没有任何字段被排除。
于是 HPA 的每一次扩容,在 ArgoCD 眼里都是一次需要「修复」的漂移。
组合出一个稳定的抖动循环#
readiness initialDelaySeconds 30s,尚未 Ready"] C --> D["ArgoCD reconcile:
live replicas=5 ≠ git replicas=3"] D --> E["selfHeal + Replace/Force:
把 spec.replicas 强制写回 3"] E --> F["Deployment controller 杀掉最新的 2 个 pod"] F --> A
三个表象都能从这个循环里读出来。「上不去 5」:5 从来不是上限,只是 HPA 每次都被打回 3 重新起步,按扩容策略走一两步(到 4、到 5)就被打断,永远攒不到 6。「新增的副本又重建了」:就是 3 → 5 → 3 → 5 的抖动本身。「看不到稳定的新 pod」:应用是 Spring Boot,readiness 的 initialDelaySeconds 是 30s,新 pod 从创建到 Ready 要几十秒,而下一轮 reset 往往等不了那么久,新副本从来活不到 Ready。
修复:chart 和 ArgoCD policy 一起改#
chart 侧:autoscaling 开启时不渲染 replicas#
spec:
{{- if not .Values.pay_ms.autoscaling.enabled }}
replicas: {{ .Values.pay_ms.replicaCount }}
{{- end }}
git 的期望状态里不再有 replicas 字段,ArgoCD 就不再管理它,HPA 得以完全掌控。这也是 K8s 官方文档的建议:交给 HPA 管理的 workload,manifest 里应当移除 spec.replicas。四十多个模板这次全部统一加了条件判断,和各服务 HPA 的渲染条件精确互补(渲染 HPA ⟺ 省略 replicas)——不这么做,以后每开一个 HPA 就是再打一次地鼠。
ArgoCD 侧:去掉 Replace/Force,定点忽略 /spec/replicas#
spec:
ignoreDifferences:
- group: apps
kind: Deployment
name: pay-ms # 按名字定点忽略,每开一个 HPA 加一条
jsonPointers:
- /spec/replicas
syncPolicy:
syncOptions:
- RespectIgnoreDifferences=true
ignoreDifferences 默认只影响 diff 的计算(决定 app 是否 OutOfSync),sync 的时候还是会把渲染结果原样写进去;要让 sync 阶段也保留 live 值,必须再加 RespectIgnoreDifferences=true。同时把 Replace=true / Force=true 从 syncOptions 里去掉,回到默认的 apply。
三个容易踩的坑#
改动合入前,我们把几种「只改一半」的组合推演了一遍,每一种都会翻车:
- 对所有 Deployment 一刀切忽略
/spec/replicas:配合RespectIgnoreDifferences之后,sync 会永远保留 live 值——其余四十多个没开 HPA 的服务,以后在 git 里改replicaCount将永远不生效。必须按name定点忽略。 - 保留
Replace=true:replace 是整对象替换,普通的ignoreDifferences挡不住它,忽略规则形同虚设。 - 只改 chart、不改 policy:
kubectl replace拿到一份缺replicas的 manifest,这个字段会被 API 默认成 1——比打回 3 更糟。K8s 文档在迁移建议里也专门提醒了这一点:“the removal ofspec.replicasmay incur a one-time degradation of Pod counts as the default value of this key is 1”。
第三条还推出一个上线顺序约束:先合 Application policy 的改动,确认生效之后再合 chart 的改动,否则中间窗口里会看到副本短暂掉底到 1。policy 是否生效可以直接查:
kubectl get application services-perf -n argocd \
-o jsonpath='{.spec.syncPolicy.syncOptions}'
# 应不再包含 Replace/Force,应包含 RespectIgnoreDifferences
修完怎么确认#
先用 helm template 确认渲染输出里没有 replicas: 字段;再加压观察,spec.replicas 能稳定停在 HPA 的计算值、不再回落,HPA 事件里开始出现 New size: 6/7/…。后来几轮压测里,HPA 确实能一路扩到十几个副本了。
有一个反直觉的点:修复之后,managedFields 里 argocd-application-controller 可能仍然持有 f:spec.f:replicas。RespectIgnoreDifferences 的实现方式是把 live 值回写进要 apply 的对象,写入值等于 live 值,是无害的 no-op。所以别拿「ArgoCD 不再拥有这个字段」当修复成功的判断依据,要看行为:负载下副本数是否稳定停在 HPA 的期望值。
两个控制器抢同一个字段时,集群里不会有任何报错,只有抖动。定位这类问题,最硬的证据是事件流(谁发过
SuccessfulRescale)和 managedFields(谁拥有这个字段),而不是对着配置猜。
这个冲突对压测数据的污染是无声的#
回头看,这个冲突并不是这轮压测才出现的,只是平时没人盯着副本数看。它对压测的破坏方式很隐蔽:700/s 以上的每一档,数字只是「有点差」,报告不会告诉你被测拓扑一直在 3 和 5 之间抖。后来在另一个环境还发现一个服务同样开着 HPA,每次 sync 被按回 1 个副本,跑了很久都没人发现——那个环境后来确认根本不需要 HPA,直接关掉了事。
修复之外,我们给压测 harness 加了一个探针:每一档压测期间轮询记录副本数,报告把「步内副本数发生过变化」的档自动标成无效拓扑读数。
对比压测必须记录每一档运行期间的拓扑,否则这类污染是无声的——你只会得到一组「说不上哪里不对」的数字。
几个想继续讨论的 scalability 问题#
问题本身修完了,但它引出的几个问题,我觉得比修复更值得聊。
spec.replicas 到底应该归谁管#
GitOps 的默认说法是「git 是唯一的期望状态」,但有些字段天生属于运行时:replicas 归 HPA,resources 可能归 VPA,某些 annotation 归外部 controller。这类字段写进 git,等于给声明式系统埋一个双写冲突。K8s 对「多个写者共管一个对象」给出的机制答案是 Server-Side Apply 的 field management:每个字段都有明确的 owner,冲突是显式的——这次排查里,managedFields 也确实是最硬的证据之一。值得盘一遍的问题:平台上还有哪些字段属于这一类?它们都被排除在外了吗?
控制循环冲突没有报错,怎么提前发现#
K8s 本质是一堆互相独立的调和循环,每个 controller 都认为自己的期望状态是对的;两个写者冲突时,没有任何组件会报错,表现就是高频抖动。事后看,「同一份渲染结果里既有固定 replicas 的 Deployment、又有想接管它的 HPA」这种矛盾,完全可以在 CI 里对 helm template 的输出加一条规则查出来,或者做成准入校验。这次我们只做到了 chart 保护加流程约定(以后开 HPA 必须同步改对应环境的 Application policy),规则化的检查还没做。
CPU 型 HPA 对非 CPU 瓶颈是盲的#
修好之后 HPA 能自由扩容了,同一轮压测里马上看到了另一面:有一段时间真正的瓶颈在下游数据库 proxy 的 CPU,吞吐已经崩了,HPA 看到的却是应用 CPU 高,把副本从 12 一路加到 18——每个新 pod 只是往已经饱和的 proxy 再多开一批连接,有害无益。指标选错了层,扩容动作就和瓶颈无关,甚至相反。延迟型、队列深度型的自定义指标(以及 KEDA 这类事件驱动扩缩容)是我还没试的方向。
弹性层让容量数字变得可疑#
HPA 在容量压测里既是被测对象,又是噪声源:扩容临时状态(新 pod 冷启动、连接池重建)会直接污染当档读数。这次之后我给自己的约束是:对比轮固定拓扑(minReplicas = maxReplicas),弹性只留给「找极限」的轮次;并且任何一档的报告,都必须附带这一档期间的拓扑记录。
小结#
副本数卡在 5 的直接原因,是 Helm 模板无条件渲染 replicas,加上 ArgoCD selfHeal 用 Replace/Force 把 git 值强制写回,和 HPA 形成了 3 ↔ 5 的抖动循环。修复必须两边一起动:chart 在 autoscaling 开启时不渲染 replicas;ArgoCD 去掉 Replace/Force,按名加 /spec/replicas 的 ignoreDifferences 并开 RespectIgnoreDifferences——而且要先改 policy 再改 chart,否则缺失的字段会被 API 默认成 1。这类双写冲突没有报错、只有抖动,事件流和 managedFields 是最有效的定位工具。对压测来说,教训是拓扑必须进每一档的记录;扩缩容指标怎么对准真正的瓶颈、带弹性层的系统怎么做容量测试,是我接下来想继续整理的题目。
参考资料#
- Horizontal Pod Autoscaling — Migrating Deployments and StatefulSets to horizontal autoscaling
- Argo CD — Sync Options(Replace、Force、RespectIgnoreDifferences)
- Argo CD — Diffing Customization(ignoreDifferences)
- Kubernetes — Server-Side Apply(field management)