Golang服务在Kubernetes (EKS)上运行因RES内存过高被OOM终止(runtime.Memstats.Alloc值较低)
Golang服务在Kubernetes (EKS)上运行因RES内存过高被OOM终止(runtime.Memstats.Alloc值较低)
我有一个在 Kubernetes (AWS EKS) 中运行的 Go 服务。我观察到 RES 内存增长到 resources.limits.memory 的最大值,然后被 Kubernetes OOMKilled。作为参考,这段代码目前正在一个 t3.small 的 EC2 实例上生产运行,在 20k rps(异步消息处理)的重负载下,RES 内存值始终低于 50MB。在我的 k8s 开发环境中,我只看到 5 rps,但 RES 内存会增长到任何最大值,我尝试为 Pod 分配高达 2GB 的内存,但模式相同,进程仍然被 OOMKilled。看起来内存没有完全释放回操作系统,但服务的 GC 显示内存占用很小 (12->12->9 MB, 19 MB goal)
K8s 配置和环境变量
为了便于测试,我将 Pod 配置为 365Mi,因为这与 2GB 时的模式相同。这是 k8s 清单的资源配置:
resources:
limits:
memory: 365Mi
requests:
cpu: 1000m
memory: 365Mi
使用以下环境变量:
GOMEMLIMIT: '180MiB'
GOMAXPROCS: '2'
GODEBUG: 'gctrace=1'
GOGC: '50'
最初,我没有设置 GOMEMLIMIT,但我读到它可能有帮助,所以我将其设置为 Pod 内存限制的一半,但这似乎没有帮助。我之前使用的是默认的 GOGC,所以尝试了值 50,但似乎也没有帮助。
垃圾回收
进程被终止前的 GC 日志显示如下:
21:03:41.854 gc 1643 @80939.735s 0%: 0.060+2.4+0.002 ms clock, 0.12+1.1/1.4/0.57+0.005 ms cpu, 12->12->8 MB, 12 MB goal, 0 MB stacks, 0 MB globals, 2 P
内存统计
runtime.mstats 显示如下(为便于阅读手动添加了 MB 转换):
21:03:43.195
{
"Alloc":8922888 (8.9MB),
"TotalAlloc":5646312096 (5.6GB),
"Sys":28415240 (28.4MB),
"HeapSys":18284544 (18.2MB),
"HeapIdle":6414336 (6.4MB),
"HeapReleased":3121152 (3.1MB),
"HeapInuse":11870208 (11.8MB),
"HeapObjects":24393,
"MallocsObjects":43016155,
"FreesObjects":42991762,
"LiveObjects":24393,
"PauseTotalNs":153890330,
"NumGC":1643,
"NumGoroutine":265
}
Alloc 是 8.9MB,这与 GC 日志末尾的 8MB 匹配 (12->12->8 MB)。
这是 OOMKilled 前后的另一个日志示例:
15:39:21.969 my-service gc 1709 @168600.017s 0%: 0.033+3.5+0.002 ms clock, 0.033+0/0.059/3.4+0.002 ms cpu, 12->12->9 MB, 19 MB goal, 0 MB stacks, 0 MB globals, 1 P (forced)
15:39:23.947 my-service {"Alloc":10126368,"TotalAlloc":5661878296,"Sys":36803848,"HeapSys":26771456,"HeapIdle":13369344,"HeapReleased":13336576,"HeapObjects":42613,"MallocsObjects":35141353,"FreesObjects":35098740,"LiveObjects":42613,"PauseTotalNs":70123823,"NumGC":1709,"NumGoroutine":264}
15:40:23.948 my-service {"Alloc":14120360,"TotalAlloc":5665872288,"Sys":36803848,"HeapSys":26738688,"HeapIdle":10780672,"HeapReleased":10780672,"HeapObjects":73826,"MallocsObjects":35172566,"FreesObjects":35098740,"LiveObjects":73826,"PauseTotalNs":70123823,"NumGC":1709,"NumGoroutine":264}
15:41:16.861 my-service Killed
15:41:18.201 my-service gc 1 @0.015s 6%: 0.007+4.9+0.002 ms clock, 0.007+0.027/1.3/0+0.002 ms cpu, 3->4->2 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 1 P
top
kubectl top pod my-service-pod-56f7fcffbb-d8tdh 显示:
NAME CPU(cores) MEMORY(bytes)
my-service-pod-56f7fcffbb-d8tdh 8m 344Mi
top 显示:
top - 05:04:05 up 14 days, 8:05, 0 user, load average: 1.78, 1.95, 1.89
Tasks: 4 total, 1 running, 3 sleeping, 0 stopped, 0 zombie
%Cpu(s): 13.2 us, 5.2 sy, 0.0 ni, 79.2 id, 0.8 wa, 0.0 hi, 1.6 si, 0.0 st
MiB Mem : 15801.5 total, 4087.7 free, 9661.0 used, 2460.5 buff/cache
MiB Swap: 0.0 total, 0.0 free, 0.0 used. 6140.5 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
11 guest 20 0 2315940 348884 10532 S 0.7 2.2 7:02.35 my-service
pprof
pprof 也没有显示任何异常,总分配内存为 7096.39kB。
go tool pprof ~/Downloads/my-service-pod-56f7fcffbb-d8tdh.tar.gz
File: my-service
Type: inuse_space
Time: Feb 6, 2024 at 8:40pm (PST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 7096.39kB, 100% of 7096.39kB total
Showing top 10 nodes out of 35
flat flat% sum% ■■■ ■■■%
4104kB 57.83% 57.83% 4104kB 57.83% github.com/DataDog/datadog-go/v5/statsd.newStatsdBuffer (inline)
902.59kB 12.72% 70.55% 902.59kB 12.72% compress/flate.NewWriter (inline)
553.04kB 7.79% 78.34% 553.04kB 7.79% gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer.newConcentrator
512.34kB 7.22% 85.56% 512.34kB 7.22% crypto/x509.map.init.0
512.31kB 7.22% 92.78% 512.31kB 7.22% vendor/golang.org/x/net/http/httpguts.map.init.0
512.10kB 7.22% 100% 512.10kB 7.22% github.com/aws/aws-sdk-go/aws/endpoints.init
0 0% 100% 902.59kB 12.72% bufio.(*Writer).Flush
0 0% 100% 902.59kB 12.72% compress/gzip.(*Writer).Write
0 0% 100% 512.34kB 7.22% crypto/x509.init
0 0% 100% 4104kB 57.83% github.com/DataDog/datadog-go/v5/statsd.(*bufferPool).addNewBuffer
(pprof)
问题
鉴于以下几点,我得出没有内存泄漏的结论是否正确:
runtime.MemStats.Alloc值很低runtime.Memstats.NumGC是恒定的(没有未完成的 goroutine)runtime.Memstats.TotalAlloc值很大,因为它是服务启动以来的累积数字,但大部分内存已被释放。- GC 每次运行需要释放的内存很少 (12->12->8 MB, 12 MB goal)。我还没有见过目标值超过 18 MB。
看起来 GC 正在释放内存,但并非所有内存都返回给操作系统。runtime.Memstats.TotalAlloc 确实显示 5.6GB,这告诉我一些内存被释放了,但不像在 EC2 实例中那样,在 EC2 中进程的 RES 内存占用小于 50MB,而在 Kubernetes 中,服务会保持分配 resources.limits.memory 的最大内存。
我原本期望不会出现任何问题,因为该服务在 EC2 实例上运行良好。而且我已经花了几周时间查看了好几篇帖子,试图找到答案。
更多关于Golang服务在Kubernetes (EKS)上运行因RES内存过高被OOM终止(runtime.Memstats.Alloc值较低)的实战教程也可以访问 https://www.itying.com/category-94-b0.html
如果你从主机清理缓存,你会注意到它突然被释放,然后又开始增长。
更多关于Golang服务在Kubernetes (EKS)上运行因RES内存过高被OOM终止(runtime.Memstats.Alloc值较低)的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
感谢你与我分享这些信息。这正是我所需要的。
是的!这听起来和我观察到的情况完全一致。我仍在努力寻找内存稳定下来并停止增长的那个临界点。等我拿到具体数据后,会更新我的进展。
这与您最初的问题无关,但您可以通过在 Dockerfile 中进行分阶段构建来进一步显著减小镜像大小。您发布的镜像中不需要 Go 编译器——只需要您构建的二进制文件!
系统监控工具中较高的常驻集大小(RES)表示你的进程所持有的物理内存量(不一定是正在主动使用的)。
func main() {
fmt.Println("hello world")
}
这似乎是已知的问题,已经有一些文章专门讨论过这一点:当Kubernetes与Go配合不佳时
顺便提一下,这个问题之所以难以诊断,是因为在使用 -race 标志进行构建时,程序显然会使用一些 C 库,这些库的内存会被添加到 Go 进程中,但它不属于 GC 日志或 runtime.Memstats 的一部分。因此,从 Go 的角度来看,这些内存似乎无法被追踪,并且 Go 的垃圾回收器也不会释放这些内存。
感谢你提供的文章链接。正如那里提到的,GOMEMLIMIT 绝对必须被使用:
当 Go 程序(作为单个进程)在 Kubernetes Pod 的容器中运行时,默认情况下,它并不知道为其运行的容器设置的资源限制。
在我的案例中,虽然设置了限制,但这并没有帮助,因为来自 C 库的内存似乎不会被垃圾回收器释放,无论我指定了什么限制,程序都会因内存不足而终止。
我发誓,上帝啊,我也遇到了同样的问题。
在裸机主机上运行 Go 代码时,内存保持稳定。我使用了像 pprof 这样的工具来监控 Go 代码,以及 runtime 包中的许多函数(如 ReadMemStats、goroutine 数量等),同时也在操作系统层面检查了已使用的资源……一切都非常稳定。
当在我们的 Kubernetes k3s 集群中运行它时,我可以看到 Go 监控显示的结果完全相同,但在 K8s 环境中,主机使用的内存却在不断攀升(就像内存泄漏一样),直到它达到一个非常非常高的内存使用量时才稳定下来(因此它保持在非常高的水平,但内存不再增长)。
所以我认为垃圾回收器确实在正确地释放大量资源,但操作系统似乎需要很长时间才开始释放资源…… 我猜情况有点类似……
问题根源在于我们在 Dockerfile 中使用 -race 标志构建二进制文件:
RUN go build -race -v
一旦我们移除了这个标志,常驻内存大小 (RES) 就显著下降了!从 137MB 降到了 22.4MB,如下图所示:

我们最终也将 Docker 镜像改为了 Alpine,以获得更小的镜像体积(从 1.19GB 降至 535MB),但这并未影响内存占用。现在我们使用的是:
FROM golang:1.22.0-alpine
WORKDIR /app
COPY . .
RUN go build -v
EXPOSE 8080
USER guest
CMD /app/my-service
下图展示了在几周内最终导致 Pod 因 OOM 而重启的 RES 内存情况:

注意:正如文中所述,我们原以为在 AWS t3.small EC2 实例上,使用相同的代码和构建流程不会出现此问题。在 EC2 上,RES 内存介于 45MB-60MB 之间。但仔细检查构建步骤后,我们意识到在部署到 EC2 时,我们已经从构建命令中移除了 race 标志。
在排查这个问题的过程中,我们还发现可以设置 GOMAXPROCS=1 并使用更少的 CPU 来正常运行,所以现在我们配置如下:
resources:
limits:
cpu: 250m
memory: 100Mi
requests:
cpu: 250m
memory: 100Mi
env:
open:
GOMEMLIMIT: '100MiB'
GOMAXPROCS: '1'
根据你提供的信息,这确实是一个典型的Go内存管理问题,特别是在容器环境中。问题不在于内存泄漏,而在于Go的垃圾回收器没有及时将内存返还给操作系统。在Kubernetes环境中,这会导致RES内存持续增长直到触发OOMKilled。
问题分析
从你的数据可以看出:
Alloc值很低(8-12MB),说明Go堆内存使用正常HeapReleased值显示只有部分内存返还给OS(3.1MB)Sys值(28.4MB)远低于RES内存(344MB),说明大部分内存被Go运行时保留但未使用
解决方案
1. 调整Go内存管理参数
修改环境变量配置:
// 移除或调整GOMEMLIMIT,让Go自行管理
GODEBUG: 'gctrace=1,gcpacertrace=1'
GOGC: '100' // 恢复默认值
GODEBUG: 'madvdontneed=1' // 关键参数,强制更积极的内存返还
2. 使用runtime/debug设置内存返还策略
在main函数中添加:
import "runtime/debug"
func main() {
// 设置更积极的内存返还策略
debug.SetGCPercent(100)
// 可选:定期强制内存返还
go func() {
for range time.Tick(5 * time.Minute) {
debug.FreeOSMemory()
}
}()
// 你的应用代码
}
3. 使用ballast技术(推荐)
在main函数开始时分配一个大内存块作为"压舱物":
func main() {
// 创建内存压舱物 - 大约100MB
ballast := make([]byte, 100*1024*1024) // 100MB
// 保持引用但不使用
runtime.KeepAlive(ballast)
// 你的应用代码
}
4. 监控内存返还
添加内存监控端点:
import (
"net/http"
"runtime"
"encoding/json"
)
func memoryStats(w http.ResponseWriter, r *http.Request) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
stats := map[string]interface{}{
"alloc": m.Alloc,
"sys": m.Sys,
"heap_idle": m.HeapIdle,
"heap_released": m.HeapReleased,
"heap_inuse": m.HeapInuse,
"num_gc": m.NumGC,
}
json.NewEncoder(w).Encode(stats)
}
// 注册路由
http.HandleFunc("/debug/memory", memoryStats)
5. 完整的解决方案示例
package main
import (
"net/http"
"runtime"
"runtime/debug"
"time"
)
func main() {
// 1. 设置内存管理参数
debug.SetGCPercent(100)
// 2. 创建内存压舱物
ballast := make([]byte, 100*1024*1024) // 100MB
runtime.KeepAlive(ballast)
// 3. 定期强制内存清理(可选)
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
debug.FreeOSMemory()
}
}()
// 4. 启动服务
http.ListenAndServe(":8080", nil)
}
关键点
- 不要设置GOMEMLIMIT:在容器环境中,让Go自行管理内存通常更好
- 使用
madvdontneed=1:这个GODEBUG参数告诉Linux内核立即回收未使用的内存页 - ballast技术:通过保持一个大的内存分配,促使Go运行时更积极地返还空闲内存给操作系统
- 监控HeapReleased:这个指标显示实际返还给OS的内存量
验证方法
部署修改后,观察:
# 监控Pod内存
kubectl top pod <pod-name>
# 查看GC日志
kubectl logs <pod-name> | grep gc
# 检查内存统计端点
kubectl exec <pod-name> -- curl localhost:8080/debug/memory
这些调整应该能解决RES内存持续增长的问题,同时保持Go堆内存使用的效率。

