背景#

这个问题发生在 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: 3autoscaling.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 眼里都是一次需要「修复」的漂移。

组合出一个稳定的抖动循环#

flowchart TD A["CPU 约 70%,持续高于 40% 的 target"] --> B["HPA 扩容:spec.replicas 3 → 5"] B --> C["新 pod 启动中
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。

三个容易踩的坑#

改动合入前,我们把几种「只改一半」的组合推演了一遍,每一种都会翻车:

  1. 对所有 Deployment 一刀切忽略 /spec/replicas:配合 RespectIgnoreDifferences 之后,sync 会永远保留 live 值——其余四十多个没开 HPA 的服务,以后在 git 里改 replicaCount 将永远不生效。必须按 name 定点忽略。
  2. 保留 Replace=true:replace 是整对象替换,普通的 ignoreDifferences 挡不住它,忽略规则形同虚设。
  3. 只改 chart、不改 policy:kubectl replace 拿到一份缺 replicas 的 manifest,这个字段会被 API 默认成 1——比打回 3 更糟。K8s 文档在迁移建议里也专门提醒了这一点:“the removal of spec.replicas may 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:replicasRespectIgnoreDifferences 的实现方式是把 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 selfHealReplace/Force 把 git 值强制写回,和 HPA 形成了 3 ↔ 5 的抖动循环。修复必须两边一起动:chart 在 autoscaling 开启时不渲染 replicas;ArgoCD 去掉 Replace/Force,按名加 /spec/replicasignoreDifferences 并开 RespectIgnoreDifferences——而且要先改 policy 再改 chart,否则缺失的字段会被 API 默认成 1。这类双写冲突没有报错、只有抖动,事件流和 managedFields 是最有效的定位工具。对压测来说,教训是拓扑必须进每一档的记录;扩缩容指标怎么对准真正的瓶颈、带弹性层的系统怎么做容量测试,是我接下来想继续整理的题目。

参考资料#

相关文章#