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)

问题

鉴于以下几点,我得出没有内存泄漏的结论是否正确:

  1. runtime.MemStats.Alloc 值很低
  2. runtime.Memstats.NumGC 是恒定的(没有未完成的 goroutine)
  3. runtime.Memstats.TotalAlloc 值很大,因为它是服务启动以来的累积数字,但大部分内存已被释放。
  4. 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

10 回复

如果你从主机清理缓存,你会注意到它突然被释放,然后又开始增长。

更多关于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,如下图所示:

container_memory_max_usage_bytes

我们最终也将 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 内存情况:

Service running on EKS Kubernetes

注意:正如文中所述,我们原以为在 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。

问题分析

从你的数据可以看出:

  1. Alloc值很低(8-12MB),说明Go堆内存使用正常
  2. HeapReleased值显示只有部分内存返还给OS(3.1MB)
  3. 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)
}

关键点

  1. 不要设置GOMEMLIMIT:在容器环境中,让Go自行管理内存通常更好
  2. 使用madvdontneed=1:这个GODEBUG参数告诉Linux内核立即回收未使用的内存页
  3. ballast技术:通过保持一个大的内存分配,促使Go运行时更积极地返还空闲内存给操作系统
  4. 监控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堆内存使用的效率。

回到顶部