Go 服务在 K8s 里 P99 抖到 830ms、CPU 却只用 30%:GOMAXPROCS 和 CPU limit 的坑我踩了两年

🔑 关键词:GOMAXPROCS,K8s CPU limit,CFS throttling,Go 性能调优,automaxprocs

📖 摘要:同一个 500m CPU limit 的 Pod,Java 里 availableProcessors() 返回 1,Go 里 runtime.NumCPU() 返回 64。这不是段子,是 CFS 带宽控制和 Go 调度器默认值打架的结果。这篇记录一次线上 P99 从 120ms 抖到 830ms 的完整排查过程,附带 cgroup v1/v2 的看指标命令和四套能落地的改法。

上周三凌晨 2 点 17 分被值班电话薅起来,告警是订单查询接口 P99 从 120ms 飙到 830ms,持续了 6 分钟然后自己恢复了。爬起来开 Grafana 看 CPU 使用率,整个 Deployment 的容器 CPU 一直趴在 0.15 核上下,而我们给的 limit 是 500m。CPU 只用了 30%,延迟炸了 6 倍,这两个数字摆在一起本身就是不合理的。第一反应是下游数据库或者 Redis 抖了,翻慢查询日志、连接池 active 数、Redis 的 latency percentile,全干净。第二天又复现了一次,这回我学乖了,直接进容器看 cgroup 文件。

图片

先说我在排查过程中犯的一个蠢错,避免你们也走这条路。kubectl exec 进容器敲 nproc,输出是 64。我当时还愣了一下,500m 的 Pod 里 nproc 凭什么给 64?去掉 CPU limit 再跑一次还是 64,说明这不是 limit 的影响。后来才想明白,Linux 上 nproc 读的是 sched_getaffinity() 返回的 CPU 亲和掩码,Docker 和 K8s 默认根本不设置 cpuset(除非你开了 static CPU manager policy),所以亲和掩码就是宿主机全部 64 个逻辑核。真正限制 CPU 的是 CFS 的带宽控制,也就是 quota 和 period 这一对参数,跟亲和掩码是两套完全独立的机制。容器里想看真实限制,cgroup v2 是 cat /sys/fs/cgroup/cpu.max,输出类似 50000 100000(前一个是 quota 微秒,后一个是 period 微秒);cgroup v1 要分别看 /sys/fs/cgroup/cpu/cpu.cfs_quota_us 和 cpu.cfs_period_us。搞不清自己是 v1 还是 v2 的,cat /proc/self/cgroup 看一眼路径就明白了。

图片

500m 的 limit 换算过来就是每 100ms 的周期里给你 50ms 的 CPU 时间。关键点在超了之后会发生什么:整个 cgroup 里所有线程会被冻结到下一个 period 开始,注意是整个 cgroup,不是一个线程。这意味着在一个 100ms 的窗口里,你可能前 30ms 把 50ms 的额度用光了(多核并行,64 个线程一起跑很容易做到),剩下 70ms 整个进程组直接被按在地上摩擦,包括正在处理 HTTP 请求的那几个 goroutine。这就是 P99 尖刺而不是平均值恶化的原因。同一个 Pod 里如果还跑着 sidecar,那 sidecar 也跟着一起冻。我那次拉 container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total 算出来是 0.62,也就是 62% 的周期都被 throttle 过,这个比例已经高得离谱了。健康的值我一般盯在 5% 以下,超过 10% 就该查。

图片

那 Go 在这里到底干了什么坏事?runtime 启动的时候会确定 P 的数量,也就是 GOMAXPROCS,默认值取自 CPU 核数,而这个核数在 Linux 上是靠 sched_getaffinity 数出来的,不是读 cgroup quota。所以它老老实实数了 64。64 个 P 意味着运行队列最多可以并行调度到 64 个 M(内核线程)上,这些线程同时去抢那 50ms/100ms 的额度,几十毫秒内就撞墙了。有意思的是 Java 这边早就处理过:JDK 8u191 引入 -XX:+UseContainerSupport 之后(后来默认开启),Runtime.getRuntime().availableProcessors() 会去读 cgroup 的 quota/period,一个 500m 的 Pod 里返回的是 1,具体数字看 JDK 版本和 cgroup 版本有没有被正确识别。所以同一个团队里,写 Java 的同事可能完全没遇到过这个问题,写 Go 的天天遇到,然后互相觉得对方在说玄学。顺便说一句,.NET 从 Core 3.0 开始也读了 cgroup,Environment.ProcessorCount 是准的。这块 Go 是落后的那个。

改法有几条,按我实际用过的顺序说。第一条,Go 1.25 之前,直接引 go.uber.org/automaxprocs,import _ "go.uber.org/automaxprocs" 一行,它在 init 阶段读 cgroup 帮你把 GOMAXPROCS 设对,500m 会算成 1。这个库被用得极广,稳定性没问题。第二条,升级到 Go 1.25+,容器感知的 GOMAXPROCS 已经内置了,默认开启,想关掉可以用 GODEBUG=containermaxprocs=0 回退。第三条,简单粗暴用环境变量 GOMAXPROCS=2 硬编码,我早期干过,缺点是服务一旦改了资源规格,你得记得同步改 YAML,我忘过一次,很难受。第四条也是最容易被忽略的:就算 GOMAXPROCS 设对了,limit 设太小照样会被 throttle。GOMAXPROCS=1 只能保证你不会用 64 个线程去抢额度,但抢不抢得到 50ms 是另一回事,突发流量来的时候一个周期照样能打穿。所以我现在的看法是,CPU limit 这玩意儿本身就是个需要权衡的东西,它保证了公平但也制造了尾延迟。

图片

我们最后的做法是把所有 Go 服务(大概 40 多个 Deployment)的 CPU limit 全部去掉,只保留 request,HPA 按 request 的 80% 作为扩容阈值。跑到现在 8 个月,整个集群的节点 CPU 平均利用率从 22% 涨到 51%,P99 尖刺类的告警少了差不多七成。代价当然有:你得盯着节点的 allocatable,防止某个内存泄漏或者死循环的服务把整台节点的 CPU 吃干,然后同节点的邻居全遭殃。我们加了 Node 级别的 CPU 压力告警和 Pod 的 priorityClass 兜底,暂时没翻过车。如果你所在的环境有硬性的资源配额要求,limit 删不掉,那就退一步,把 limit 设成 request 的 2 到 3 倍,别贴着 request 设,给突发留点空间。

图片

最后顺嘴提一个同源的坑,思路一模一样,只是换成了内存。Go 的 GOGC 默认 100,意思是堆内存比上次 GC 后翻倍就触发 GC,这个逻辑完全不知道容器的内存上限。一个 512Mi limit 的 Pod,堆涨到 400Mi 的时候,加上 goroutine 栈、GC 本身的辅助内存、runtime 的一些常驻开销,就有可能被 OOMKilled,然后你去看 pprof 发现堆才 400Mi,一脸问号。Go 1.19 引入的 GOMEMLIMIT 就是干这个的,设成 limit 的 80% 到 90%(比如 512Mi 的 Pod 设 GOMEMLIMIT=450MiB),注意它是软限制,不保证不死,但是能把 GC 提前触发,比被内核一刀切掉强。还有个小细节,GOMEMLIMIT 支持 MiB 这种带单位的写法,别写成纯数字,纯数字是按字节算的,我第一次写错单位把 GC 搞得疯狂触发,CPU 打满,又排查了半天。

图片

给个能直接抄的检查清单:进容器 cat /sys/fs/cgroup/cpu.max 确认 quota;在程序启动日志里打一行 runtime.GOMAXPROCS(0) 确认它跟你的 limit 大致匹配;Prometheus 里配一条 throttle 比例超过 10% 的告警;Go 版本低于 1.25 的加 automaxprocs;内存这边补一个 GOMEMLIMIT。这几步做完,至少你不会再像我一样凌晨两点对着一个 CPU 只用 30% 的监控面板发呆。当然,别照抄我把 limit 全删的做法,你们集群的负载形态跟我这边不一定一样,先小范围跑两周。

🏷️ 标签: