关于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容器)的内存容量之类的东西会自动地进行垃圾回收,但最终发现并没有这样便利的事情。
如果有了解这个区域应该如何进行设置的人能够读到这里的话,请告诉我一下,我会很感激。