Go 程序在 K8s 里 CPU 被打满:GOMAXPROCS 没感知容器 limits 与 automaxprocs 修复
Go 程序在 K8s 里 CPU 被打满:GOMAXPROCS 没感知容器 limits 与 automaxprocs 修复
你的 Go 服务在物理机上跑得好好的,一上 Kubernetes,同样的负载 CPU 却莫名被限流(throttling),P99 延迟飙高,GC 也变频繁。查了半天代码没问题,问题出在一个你从没设过的运行时参数:GOMAXPROCS。这篇讲清楚它为什么在容器里会算错,以及怎么一行修复。
先复现问题:GOMAXPROCS 看到的是宿主机核数
GOMAXPROCS 决定 Go 运行时能同时执行用户级代码的操作系统线程数(P 的数量)。默认值等于 runtime.NumCPU()。
关键在于:runtime.NumCPU() 读的是宿主机的 CPU 核数,而不是容器的 CPU limit。
假设你的节点是 64 核,但给 Pod 设了:
resources:
limits:
cpu: "2" # 只给 2 核
requests:
cpu: "2"
Go 运行时启动时看到的是 64,于是 GOMAXPROCS=64:
package main
import (
"fmt"
"runtime"
)
func main() {
// 在 2 核 limit 的容器里,这会打印 64(宿主机核数)
fmt.Println("NumCPU:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
}
为什么这会导致 CPU 被限流
CPU limit 在 Linux 上是靠 CFS(完全公平调度器)配额实现的。cpu: "2" 的含义是:每 100ms 的调度周期内,这个容器最多用 200ms 的 CPU 时间(2 核 × 100ms)。
现在 Go 以为自己有 64 个核,就并行跑 64 个线程,一瞬间把 CPU 时间片全部烧光。CFS 一看配额超了,直接冻结(throttle)整个 cgroup 剩余时间片,你的所有 goroutine 集体卡住,直到下个周期。
结果就是:
- 延迟毛刺:请求处理到一半被 CFS 冻结,P99 忽高忽低。
- GC 抖动:GC 的并行标记也开了 64 个 worker,加剧配额消耗。
- 上下文切换暴涨:64 个线程抢 2 核,调度开销巨大。
看是否被限流,读 cgroup 统计:
# cgroup v2
cat /sys/fs/cgroup/cpu.stat
# nr_throttled 12345 ← 被限流的次数,持续增长就是中招了
# throttled_usec 6789000
nr_throttled 持续增长,基本可以确诊。
朴素修复:手动设 GOMAXPROCS
最直接的办法是启动时手动对齐 limit:
func main() {
runtime.GOMAXPROCS(2) // 硬编码等于 CPU limit
// ...
}
但这很脆弱:改了 Deployment 的 limit 忘了改代码,又不一致了。而且 limit 常是小数(cpu: "1500m" = 1.5 核),硬编码也不好写。
也可以用环境变量,让 Deployment 单点控制:
env:
- name: GOMAXPROCS
value: "2"
GOMAXPROCS 环境变量会被运行时读取。但你还是得手动保证它和 limits.cpu 一致,两处维护。
正确修复:automaxprocs 自动对齐
Uber 的 automaxprocs 会在程序启动时读取 cgroup 的 CPU quota,自动把 GOMAXPROCS 设成正确的值。用法只有一行——匿名 import:
package main
import (
"fmt"
"runtime"
_ "go.uber.org/automaxprocs" // 副作用:init 时读 cgroup 自动设 GOMAXPROCS
)
func main() {
// 现在在 2 核 limit 的容器里,这里是 2 而不是 64
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
}
安装:
go get go.uber.org/automaxprocs
它的原理:读取 /sys/fs/cgroup/cpu.max(v2)或 cpu.cfs_quota_us / cpu.cfs_period_us(v1),算出 quota/period 向下取整,设为 GOMAXPROCS。启动日志会打印:
maxprocs: Updating GOMAXPROCS=2: determined from CPU quota
Go 1.25 起可能不再需要它
从 Go 1.25 开始,运行时原生感知 cgroup CPU limit,GOMAXPROCS 默认会按容器 quota 计算。也就是说升级到 1.25+ 后,automaxprocs 大多数场景可以去掉。
在升级前,或者需要兼容老版本时,automaxprocs 仍是最稳的做法。验证你的 Go 版本行为:
// Go 1.25+ 在容器里直接打印对齐后的值,无需任何库
fmt.Println(runtime.GOMAXPROCS(0))
一个容易忽略的边界:limit 小于 1 核
如果 cpu: "500m"(0.5 核),quota/period = 0.5,向下取整是 0。automaxprocs 会兜底设成 1(GOMAXPROCS 最小为 1),不会设成 0 把程序卡死。这是合理的——你至少需要 1 个 P 才能跑代码。但要意识到:0.5 核的 limit 下,单个 P 也可能频繁被 CFS 限流,这种超小配额本身就不适合 CPU 密集型 Go 服务。
小结
GOMAXPROCS默认等于runtime.NumCPU(),而后者在容器里读的是宿主机核数,不是 CPU limit。- 核数被高估 → Go 并行度过高 → 一瞬间烧光 CFS 配额 → 整个 cgroup 被 throttle,表现为延迟毛刺和 GC 抖动。
- 确诊看
/sys/fs/cgroup/cpu.stat的nr_throttled是否持续增长。 - 修复首选
import _ "go.uber.org/automaxprocs",一行自动对齐;或用GOMAXPROCS环境变量手动同步 limit。 - Go 1.25+ 运行时已原生感知 cgroup limit,升级后大多可去掉这个库。
- 一句话记忆点:容器里的 Go,
NumCPU说谎——它报的是整台机器,不是分给你的那几核。
更多推荐
所有评论(0)