上个月一个做订单聚合的 Go 服务迁到 K8s 之后,运行不到两天就被 OOM Kill 了三次。监控上看堆内存并没有一直涨,Go 侧的业务指标也正常,GC 日志里甚至能看见 GC 一直在跑。查了半天才搞清楚:这不是内存泄漏,是 GOGC 和 GOMEMLIMIT 这两个参数没配合好,容器 cgroup 给的 8G 限额,Go runtime 根本不知道。
如果你也在排查 Go 服务的内存问题,或者需要一台内存配置合适、内存策略稳定的机器先把服务跑起来,可以先看看这几家的报价和技术说明:neburst(面向开发者的可负担高性能云)他们的定位就是给开发者交付高性能且稳定的云资源,适合拿来做这类内存/CPU 密集型的 Go 服务压测;rabisu(高性能计算基础设施)主打让高性能计算对小团队也可及,选机器时可以按内存和算力维度对比。
这类问题之所以难查,是因为 Go 的 GC 默认行为和容器环境是矛盾的。我把它踩过的坑和调参过程完整写下来,希望同样在跑 Go 服务的同学能少走两天的弯路。

先说结论:Go 的内存不是不会回收,是回收得太晚
Go 的垃圾回收是分代并发回收(并发标记 + 混合写屏障),默认 GOGC=100,含义是:堆内存占用比上次 GC 结束时增长 100% 就触发新一轮 GC。也就是说,理论上堆可以在两个 100% 之间自由伸缩,一个 2G 的堆,峰值可能摸到接近 4G。
裸机上这没什么问题机器有 32G 内存,Go 冲到 8G 再回收也无所谓。但放到容器里就不一样了。K8s 给 Pod 配了 limits.memory: 8Gi,cgroup 会在 RSS 触到 8G 的瞬间发 SIGKILL。而 Go runtime 在 1.19 之前完全不知道自己被 cgroup 限制了多少,它照着 GOGC=100 的老规矩把堆撑到远超容器限额才想起来回收而这时候容器已经被内核杀了,GC 根本来不及跑。
我那台机器的表现正是这样:kubectl describe pod 里 Last State 是 OOMKilled,退出码 137;容器里 Go 的日志最后一行是正常的业务输出,没有任何 panic,也没有 runtime: out of memory。因为它不是 Go 自己申请内存失败被杀,是 cgroup 在 RSS 越界时直接 SIGKILL 的。

排查路径:先分清是堆涨了还是 RSS 涨了
很多人一看到容器内存高就直接去翻业务代码找泄漏,这个顺序其实反了。第一步应该看两个数字:Go 堆内存(HeapAlloc / HeapInuse)和进程 RSS。这两个数的差值就是线索。
打开 pprof 看 heap 是最直接的:
curl http://127.0.0.1:6060/debug/pprof/heap?debug=1 | head -40
如果 HeapAlloc 稳定在 300M 左右不涨,但 RSS 一路涨到 7G,那就不是 Go 堆的问题,而是下面这几类:
- GC 没跑完,堆还是上一轮的量堆对象已死但没被回收,说明 GC 触发得太晚或者标记太慢;
- 内存碎片Go 的堆向操作系统归还内存比较保守,RSS 里有一大块是 runtime 留着没还给内核的;
- cgo / 非 Go 内存cgo 分配的内存不计入 Go 堆,但在 RSS 里,这部分只有 GODEBUG=madvdontneed=1 之类的手段能管;
- goroutine 栈每个 goroutine 初始 8KB 栈,几万个 goroutine 也能堆出几百 MB。
想看 GC 全貌,加两个环境变量重启即可:
GODEBUG=gctrace=1 会打印每一轮 GC 的堆大小、目标堆大小和耗时,GODEBUG=scavtrace=1 则能看到后台 scavenger 有没有把内存还给内核。我那次的 gctrace 输出很能说明问题:
gc 1 @0.8s 0%: 4->4->0 MB, 8 MB goal, 4 P
gc 5 @120.4s 2%: 768->762->512 MB, 1024 MB goal, 4 P
注意最后那个 goal:GC 的目标是 1024MB,但容器限额是 8GB,看起来还远。问题是这 8GB 里 Go 只占了 1GB,剩下的 7GB 是 RSS 里的非堆部分(cgo 缓冲、goroutine 栈、页缓存),cgroup 看的是 RSS 总量,不是 Go 堆。所以「GC goal 没超」完全不代表「容器安全」。

真正的解法:GOMEMLIMIT 而不是 GOGC
Go 1.19 之后引入了 GOMEMLIMIT,它是一个软内存上限:当 runtime 认为自己的总内存占用(Go 堆 + runtime 自身 + goroutine 栈)接近这个值时,会更激进地触发 GC,把内存压下来。它是软限,超了不会被立刻杀掉,而是让 GC 更拼命的干活,直到落回限制以下。
容器里的标准做法是把它设成 cgroup 限额的 80%~90%:
env:
- name: GOMEMLIMIT
value: 6GiB # 容器 limits.memory 是 8Gi,留 20% 给非 Go 内存
设完之后最明显的变化是 gctrace 里的 goal 不再随堆自由翻倍,而是被压在一个稳定的线附近,RSS 也随之收敛。这才是容器环境下治内存的正确旋钮。
而 GOGC 是相对值(堆增长百分比),它跟容器限额没有任何关系。我第一次调优时犯的错就是把 GOGC 从 100 调到 20,觉得「回收勤一点就不会 OOM」结果 GC 频率翻了几倍,CPU 直接飙到 90%,接口 P99 从 40ms 涨到 300ms,内存问题没解决,延迟先崩了。GOGC 调小是拿 CPU 换内存,在 CPU 紧张的容器里往往是笔亏本买卖。
正确的组合是:GOMEMLIMIT 定上限,GOGC 保持默认 100。如果两者冲突(比如 GOMEMLIMIT 设得太紧,Go 会疯狂 GC),runtime 会优先保内存、牺牲 CPU;这时候可以适当把 GOGC 调高到 200~500,让 GC 更保守地等待,把省下来的 CPU 还给业务。官方文档里明确写了 GOMEMLIMIT 和 GOGC 存在优先级冲突时的取舍逻辑,这一点很多人在博客里看的是旧版本的说法。

代码里也能动态设置
如果不想动部署配置,也可以用 debug.SetMemoryLimit() 在程序里动态设。它返回的是上一次生效的限制值,可以在启动时根据机器实际可用内存做计算,比写死环境变量灵活:
limit := debug.SetMemoryLimit(memoryLimitBytes)
debug.SetGCPercent(100)
另外建议把 /debug/pprof/ 和 /metrics 端点只开在内网,并且给 pprof 端点加一层鉴权这玩意儿能拖垮进程,也能被用来翻内存布局。
收尾:三个可以直接抄走的检查项
第一,任何跑在容器里的 Go 服务,必须设 GOMEMLIMIT,并且留出 15%~20% 余量给非 Go 内存。第二,别再用「调小 GOGC」当作内存优化的第一手段,那是 CPU 换内存。第三,监控里要同时盯 RSS 和 Go 堆两个指标,只盯堆会漏掉今天这种事故。
顺带说一句,配套的代码分析工具也很关键。像 GoLand 这类 IDE 直接集成了内存和性能分析视图,pprof 的火焰图能直接在 IDE 里下钻,比在命令行里看 go tool pprof 的 SVG 直观很多,看火焰图定位分配点会快得多。
我这套参数调完,同一个镜像跑满 72 小时,RSS 稳定在 4.2G 左右,GC 频率是原来的 1/3,接口 P99 反而从 40ms 降到了 22ms。CPU 那块儿省下来的时间,比之前想象的多。


喜欢
顶
难过
囧
围观
无聊





