现象

为什么个别 Go 业务服务,Memory 总是提示这么高,经常达到容器限额,以至于被动 OOM Kill,是不是有什么安全隐患?

发现个别业务服务内存占用挺高,触发告警,且通过 Grafana 发现在凌晨(没有什么流量)的情况下,内存占用量依然拉平,没有打算下降的样子,高峰更是不得了,像是个内存炸弹:

并且我所观测的这个服务,早年还只是 100MB。现在随着业务迭代和上升,目前已经稳步 4GB,容器限额 Limits 纷纷给它开道,但我想总不能是无休止的增加资源吧,这是一个大问题。

有的业务服务,业务量小,自然也就没有调整容器限额,因此得不到内存资源,又超过额度,就会进入疯狂的重启怪圈:

重启将近 300 次,非常不正常了,更不用提所接受到的告警通知。

容器的内存指标

容器 OOM 的判别标准是 container_memory_working_set_bytes(当前工作集)。

而 container_memory_working_set_bytes 是由 cadvisor 提供的,对应下述指标:

从结论上来讲,Memory 换算过来是 4GB+,石锤。接下来的问题就是 Memory 是怎么计算出来的呢,显然和 RSS 不对标。

一般系统内存过高的情况下,可以通过 free -m 查看当前系统的内存使用情况:

container_memory_working_set_bytes 指标的组成实际上是 RSS + Cache。

Cache过高

Cache 高的情况,常见于进程有大量文件 IO,占用 Cache 可能就会比较高,猜测也与 Go 版本、Linux 内核版本的 Cache 释放、回收方式有较大关系。

而各业务模块常见功能,如:

  • 批量图片解压缩。
  • 批量二维码生成。
  • 批量上传渲染后图片。
  • 批量 PDF 生成。

只要是涉及有大量文件 IO 的服务,基本上是这个问题的老常客了,写这类服务基本写一个中一个,因为这是一个混合问题,像其它单纯操作为主的业务服务就很 “正常”,不会出现内存居高不下。

我们肯定是希望知道 Cache 中有什么,为什么占用了那么大的空间,此时我们可以通过 Linux pmap 来查看该容器进程的内存映射情况:

在上图中,我们发现了大量的 mapping 为 anon 的内存映射,最终 totals 确实达到了容器 Memory 相当的量,那么 anon 又是什么呢。实质上 anon 行表示在磁盘上没有对应的文件,也就是没有实际存在的载体,是 anonymous。

既然存在如此多的 anon,结合先前的考虑,我们知道出现这种情况的服务都是文件处理型服务,包含大量的批量生成图片、生成 PDF 等资源消耗型的任务,也就是会瞬间申请大量的内存,使得系统的空闲内存触及全局最低水位线(global wmark_min),而在触及全局最低水位线后,会尝试进行回收,实在不行才会触发 cgroup OOM 的行为。

那么更进一步思考的是两个问题,一个是 cgroup 达到 Limits 前的尝试释放仍然不足以支撑所需申请的连续内存段,而另外一个问题就是为什么 Cache 并没有释放:

通过上图,可以肯定该服务在凌晨 00:00-06:00 是没有什么流量的,但是 container_memory_working_set_bytes 指标依旧稳定不变,排除 RSS 的原因,那配合指标的查看基本确定是该 cgroup 的 Cache 没有释放。

而 Cache 的占用高,主要考虑是由于其频繁操作文件导致,因为在 Linux 中,在第一次读取文件时会将一份放到系统 Cache,另外一份则放入进程内存中使用。关键点在于当进程运行完毕关闭后,系统 Cache 是不会马上回收的,需要经过系统的内存管理后再适时释放。

cache是操作系统内核的机制,同容器/业务/时间点无关,并不会说到了凌晨自动释放。

另外,如果压力降下来之后,只要系统未处于内存紧缺的状态,这部分cache是不会释放的。类似于拿内存换文件的io性能。毕竟【空闲】的内存放在那里,不用就浪费了。

可以看到,节点上真正意义上free的内存只有263M,但available的内存有13G.

available的内存包含了这里的free和部分buff/cache,当需要时,是可以随时用的.

手动清理Cache

在发现是系统内存占用高后,就会有读者会提到,为什么不 “手动清理 Cache”,因为 Cache 高的话,可以通过 drop_caches 的方式来清理:

1.清理 page cache:

1
echo 1 > /proc/sys/vm/drop_caches

2.清理 dentries 和 inodes:

1
echo 2 > /proc/sys/vm/drop_caches

3.清理 page cache、dentries 和 inodes:

1
echo 3 > /proc/sys/vm/drop_caches

但新问题又出现了,因为我们的命题是在容器中,在 Kubernetes 中,若执行 drop_caches 相关命令,将会对 Node 节点上的所有其他应用程序产生影响,尤其是那些占用大量 IO 并由于缓冲区高速缓存而获得更好性能的应用程序,可能会产生 “负面” 后果。

我想这并不是一个好办法。

Linux内核参数调整

memcg 是 Linux 内核中管理 cgroup 内存的模块,但实际上在 Linux 3.10.x 的低内核版本中存在不少实现上的 BUG,其中最具代表性的是 memory cgroup 中 kmem accounting 相关的问题(在低版本中属于 alpha 特性):

  • slab 泄露:具体可详见该文章 SLUB: Unable to allocate memory on node -1 中的介绍和说明。

  • memory cgroup 泄露:在删除容器后没有回收完全,而 Linux 内核对 memory cgroup 的总数限制是 65535 个,若频繁创建删除开启了 kmem 的 cgroup,就会导致无法再创建新的 memory cgroup。

当然,为什么出现问题后绝大多数是由 Kubernetes、Docker 的相关使用者发现的呢(从 issues 时间上来看),这与云原生的兴起,这类问题与内部容器化的机制相互影响,最终开发者 “发现” 了这类应用频繁出现 OOM,于是开始进行排查。

调整内核参数

关闭 kmem accounting:

1
cgroup.memory=nokmem

也可以通过 kubelet 的 nokmem Build Tags 来编译解决:

1
kubelet GOFLAGS="-tags=nokmem"

但需要注意,kubelet 版本需要在 v1.14 及以上。

升级内核版本

升级 Linux 内核至 kernel-3.10.0-1075.el7 及以上就可以修复这个问题,详细可见 slab leak causing a crash when using kmem control group,其在发行版中 CentOS 7.8 已经发布。

RSS过高

RSS过高原因有服务创建大量对象,内存泄露,goroutine泄露等,此处不再赘述,主要讲容器环境对服务的影响.

madvise 策略变更

在 Linux 系统中,在 Go Runtime 中通过系统调用 madvise(addr, length, advise) 方法,能够告诉内核如何处理从 addr 开始的 length 字节。

重点之一就是 ”如何处理“,在 Linux 下 Go 语言中目前支持两种策略,分别是:

  • MADV_FREE:内核会在进程的页表中将这些页标记为“未分配”,从而进程的 RSS 就会变小。OS 后续可以将对应的物理页分配给其他进程。
  • MADV_DONTNEED:内核只会在页表中将这些进程页面标记为可回收,在需要的时候才回收这些页面。

Go 语言官方恰好就在 2019 年的 Go1.12 做了如下调整。

Go1.12 以前,Go Runtime 在 Linux 上默认使用的是 MADV_DONTNEED 策略。

1
2
    // 没有任何奇奇怪怪的判断
    madvise(v, n, _MADV_DONTNEED)

从整体效果来看,进程 RSS 可以下降的比较快,但从性能效率上来看差点。

Go1.12-Go1.15,当前 Linux 内核版本 >=4.5 时,Go Runtime 在 Linux 上默认使用了性能更为高效的 MADV_FREE 策略。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    var advise uint32
    if debug.madvdontneed != 0 {
        advise = _MADV_DONTNEED
    } else {
        advise = atomic.Load(&adviseUnused)
    }
    if errno := madvise(v, n, int32(advise)); advise == _MADV_FREE && errno != 0 {
        // MADV_FREE was added in Linux 4.5. Fall back to MADV_DONTNEED if it is
        // not supported.
        atomic.Store(&adviseUnused, _MADV_DONTNEED)
        madvise(v, n, _MADV_DONTNEED)
    }

从整体效果来看,进程RSS 不会立刻下降,要等到系统有内存压力了才会释放占用,RSS 才会下降。

故事往往不是那么的美好,显然在 Go1.12 起针对 madvise 的 MADV_FREE 策略的调整非常 “片面”。

结合社区里所遇到的案例可得知,该次调整带来了许多问题:

  • 引发用户体验的问题:Go issues 上总是出现以为内存泄露,但其实只是未满足条件,内存没有马上释放的案例。
  • 混淆统计信息和监控工具的情况:在 Grafana 等监控上,发现容器进程内存较高,释放很慢,告警了,很慌。
  • 导致与内存使用有关联的个别管理系统集成不良:例如 Kubernetes HPA ,或者自定义了扩缩容策略这类模式,难以评估。
  • 挤压同主机上的其他应用资源:并不是所有的 Go 程序都一定独立跑在单一主机中,自然就会导致同一台主机上的其他应用受到挤压,这是难以评估的。

Go1.16 修改内容如下:

1
2
3
4
5
6
7
8
9
func parsedebugvars() {
    // defaults
    debug.cgocheck = 1
    debug.invalidptr = 1
    if GOOS == "linux" {
        debug.madvdontneed = 1
    }
  ...
}

直接指定回了 debug.madvdontneed = 1,简单粗暴。

参考

为什么容器内存占用居高不下,频频 OOM

为什么容器内存占用居高不下,频频 OOM(续)

Go 应用内存占用太多,让排查?(VSZ篇)