关于Docker容器的内存限制和Golang垃圾回收的话题

首先

最近,我经常用Golang编写有趣的代码。

说实话,Golang在我个人看来就像是更好的C语言,它极大地提高了内存安全性和丰富的附属库,并且与我经常使用的Scala相比,它的编译速度和轻量级的IDE给我带来了舒适和愉悦。

除非万不得已,否则我几乎没有写C语言的程序的必要,Golang给予了我这样的希望。

顺便提一下,Golang有垃圾回收机制。
这样就可以比使用C语言时更轻松地进行编程,但在受限制的环境下,由于垃圾回收导致的停顿也是让人担忧的情况。
虽然这次不是停顿的情况,但我想稍微介绍一下与Golang的垃圾回收相关的话题。

这次使用的环境。

    • ホスト (ビルドなど)

macOS 10.14.2
Golang 1.11.4
Docker Desktop CE 2.0.0.0-mac81 (29211)

ゲスト (Docker)

Alpine Linux 3.8 (latest)

在一个只有10MB内存的环境下运行的Golang程序。

首先,请查看以下代码。

package main

func main() {
    sum := 0
    for i := 0; i < 10000; i++ {
        for _, v := range make([]int, 100000) {
            sum += v
        }
    }
}

基本上是一段”僅分配1萬次切片而不執行任何操作的程式碼”。
在編譯時的優化可能會導致全部被刪除,但我在本次測試中使用的Golang 1.11.4版本無需調整優化選項也可正常運行。首先,我們來在主機OS(macOS)上運行這段程式碼。

$ go build nogc.go

$ time ./nogc
./nogc  1.21s user 0.20s system 111% cpu 1.269 total

只用了1.2秒就结束了。可以说这个没有任何额外负担的时间是最快的。

接下来,我们将在Docker Desktop上的Alpine Linux容器中试运行一下。

在主机OS上将Linux二进制文件构建后,通过Docker桌面上的Alpine Linux容器来运行。

$ GOOS=linux GOARCH=amd64 go build nogc.go

$ docker run --rm -v $PWD:/app alpine:latest time /app/nogc
real    0m 2.32s
user    0m 1.85s
sys 0m 0.65s

运行时间为2.3秒。与在主机操作系统上执行相比,存在一些额外开销,但成功地完成了执行。
仅通过设置环境变量,就能够轻松地进行Golang的交叉编译,真是太方便了。

好的,接下来进入正题。这次我们将对容器设置使用内存的限制并进行执行。

以相当严格的方式执行:“用户空间的内存限制为10MB”,“禁止使用交换空间”。

$ docker run --rm -m 10M --memory-swappiness=0 -v $PWD:/app alpine:latest time /app/nogc
Command terminated by signal 9
real    0m 2.09s
user    0m 0.72s
sys 0m 2.92s

主人被OOM Killer给杀了,虽然这是一个拥有GC的Golang程序。。。

runtime.Memstats 的 NextGC 解释中说:“为了确保堆使用量不超过 NextGC 的值进行垃圾回收”,但估计并没有考虑到只有10MB可用内存的环境。
不幸的是,GC 没有及时进行,导致内存不足而死机了。南无南无。

解决方案1:自己呼叫GC。

在程序中调用垃圾收集器(GC),即使在超糟糕的内存环境下,比如“禁止交换内存”或“10MB”,通过自行调用GC可以继续生存。

package main

import "runtime"

func main() {
    sum := 0
    for i := 0; i < 10000; i++ {
        for _, v := range make([]int, 100000) {
            sum += v
        }
        runtime.GC() // 明示的にGCする
    }
}
$ docker run --rm -m 10M --memory-swappiness=0 -v $PWD:/app alpine:latest time /app/gc
real    0m 6.42s
user    0m 2.97s
sys 0m 2.54s

虽然花费了大约三倍的时间,但终于设法活了下来。

解决方案2: 使用环境变量 GOGC 以频繁调用垃圾回收

请参阅之前提供的 runtime 页面,其中有关于 GOGC 环境变量的解释。该变量可以设置在最后一次 GC 后,当存活数据消耗的内存达到 GOGC% 时触发 GC 的处理。通过设置该变量,您可以自定义 GC 的触发条件。

为了在恶劣的内存环境下频繁触发垃圾收集器,我们可以通过设置环境变量 GOGC=1(1%)来执行之前的程序。由于可以通过环境变量进行指定,因此无需重新构建程序。

$ docker run --rm -m 10M --memory-swappiness=0 -v $PWD:/app -e GOGC=1 alpine:latest time /app/nogc
real    0m 5.42s
user    0m 2.83s
sys 0m 2.46s

花了5.4秒的时间,但似乎在10MB的狭小世界中勉强存活下来了。

顺便说一下,这个参数不仅可以通过环境变量传递,还可以通过runtime/debug的SetGCPercent方法动态修改。

闲聊一下:禁用OOM Killer并尝试运行Docker容器。

在初始恶劣的内存环境下运行时,被OOM Killer杀死了。但是,通过将 –oom-kill-disable 参数传递给Docker run命令,可以禁用Docker容器中的进程被OOM Killer杀死的功能。

我要尝试以10MB的内存限制来运行一个名为no-oom-killer的容器,带有–oom-kill-disable参数,就像第一个实验一样。

$ docker run --rm -m 10M --memory-swappiness=0 --oom-kill-disable \
-v $PWD:/app --name no-oom-killer alpine:latest time /app/nogc

那个进程没有崩溃,但完全停止了运行。

当使用docker stats命令查看容器的状态时,

$ docker stats no-oom-killer
CONTAINER ID        NAME                CPU %               MEM USAGE / LIMIT   MEM %               NET I/O             BLOCK I/O           PIDS
08abead0b3ac        no-oom-killer       0.00%               9.902MiB / 10MiB    99.02%              898B / 0B           0B / 0B             6

尽管CPU没有进行任何工作,但内存已经满了。

只是看了Stack Overflow的回答并没有仔细调查,但在关于cgroups内存的解释中提到了「当OOM Killer被禁用时,会一直在cgroups的OOM-waitqueue中等待直到有可用内存为止」,然而由于容器内的唯一应用程序已经占满了所有内存,似乎已经陷入无法解决的状态。从这种状态中能否执行垃圾回收呢…。

最后

这次对Golang应用进行省内存环境的操作,并不仅仅是对Golang的恶作剧,而是基于对在Kubernetes上运行Golang应用的疑问,进行的实验。

当在Kubernetes上运行应用程序时,我们使用Pod来指定所需的最大内存大小,它会根据此大小限制Pod在一个节点上的部署数量。因此,我们希望能够正确设置这个值。但是,与像JVM这样的应用程序不同,Golang应用程序不能传递最大堆大小。所以,我想知道应该如何确定这个值。

在进行实验之前,我有点儿幻想,以为操作系统(确切来说是Docker容器)的内存容量之类的东西会自动地进行垃圾回收,但最终发现并没有这样便利的事情。

如果有了解这个区域应该如何进行设置的人能够读到这里的话,请告诉我一下,我会很感激。

如果最优化妨碍了实验,我打算在go build时的gcflags参数中添加-N来阻止最优化。
广告
将在 10 秒后关闭
bannerAds