Dockerコンテナのメモリ制限とかGolangのGCまわりのおはなし

## はじめに

最近、趣味コードでGolangを書くことがちょくちょくあります。

あくまで私個人の話ですが、GolangはBetter C言語としてみるとメモリ安全性や付属ライブラリの豊富さにとても助けられますし、仕事で書くことが多いScalaと比べるとコンパイルの速さやIDEの軽さに癒やされます。
もうよっぽどのことがない限りC言語でプログラムを書くことなんてないんじゃなかろうか、そんな希望を与えてくれます。

ところでGolangにはGCがあります。
それだけでC言語で書くときよりも楽ができるわけですが制約された環境下ではGCによるストップ・ザ・ワールドが怖くなるケースもあると思います。
今回はストップ・ザ・ワールドではないですが、GolangのGCに関係する話をちょっと紹介します。

## 今回使った環境

- ホスト (ビルドなど)
  - 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プログラム

まず、以下のコードをご覧ください。

```go:nogc.go
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では最適化オプションをいじらなくても大丈夫でした。 [^optimize]
[^optimize]: 最適化が実験の邪魔をする場合は[go build時のgcflags引数](https://golang.org/cmd/compile/#hdr-Command_Line) に `-N` とかつけて最適化させないようにするつもりでした。

まずはこのコードをホストOS (macOS) で動かしてみます。

```console
$ 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バイナリにビルドしてから、Alpine Linuxコンテナ on Docker Desktopで動かします。

```console
$ 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秒で終わりました。ホストOSでの実行と比べて多少オーバーヘッドはありますが無事に実行されました。
環境変数つけるだけでクロスコンパイルできるGolangはホント楽ですね。

さて、ここからが本題です。今度はコンテナに使用メモリ量の制限をつけて実行します。

だいぶ厳し目に「 **ユーザーランドのメモリは10MBまで** 」「 **スワップはもちろん使用禁止** 」で実行します。

```console
$ 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](https://golang.org/pkg/runtime/#MemStats) の `NextGC` の説明に「ヒープ使用量がNextGCの値を超えないようにGCする」とあるんですが、使用できるメモリがわずか10MBなんていう環境は想定してないのでしょう。
残念ながらGCが間に合わずメモリ不足で死んでしまったようです。南無南無。

### 解決策1: GCを自分で呼ぶ

プログラム内でGCを呼び出すことで「スワップ禁止」「10MB」のような超絶劣悪なメモリ環境であってもGCを自分で呼ぶことで生き延びることができます。

```go:gc.go
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する
	}
}
```

```console
$ 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
```

先ほどと比べて3倍程度の時間がかかりましたがなんとか生き延びることができました。

### 解決策2: 環境変数 GOGC を使いGCを呼びまくる

先程リンクした [runtime](https://golang.org/pkg/runtime/#hdr-Environment_Variables) のページに環境変数 `GOGC` についての説明があります。最後のGC後に生きているデータの `GOGC` ％のメモリが消費されたらGCを実行する、という処理をしてくれるらしく、これを設定することでGCのトリガーを設定できます。

そこで劣悪メモリ環境でGCをガンガン発生させるために、わかりやすく `GOGC=1` (1%) で先程のプログラムを実行します。環境変数で指定できるため、プログラムの再ビルドは不要です。

```console
$ 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](https://golang.org/pkg/runtime/debug/#SetGCPercent)で動的に変更することも可能です。

## 閑話休題：OOM Killerを無効にしてDockerコンテナを動かしてみる

最初に劣悪メモリ環境で実行したときはOOM Killerで殺されましたが、[Docker runの引数](https://docs.docker.com/engine/reference/commandline/run/#options)で `--oom-kill-disable` を渡すことでDockerコンテナ上のプロセスがOOM Killerで殺されるのを無効化することができます。

`no-oom-killer` というコンテナを `--oom-kill-disable` つきで最初の実験のように10MBメモリ制限で実行してみます。

```console
$ 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` でコンテナの状態を見ると、

```console
$ 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](https://stackoverflow.com/a/48618727)の回答を見ただけでちゃんと調べてはないのですが、cgroupsのmemoryについての説明で「OOM Killerが無効化されているときにはcgroupsのOOM-waitqueueに入ってメモリが取れるようになるまで待ち続ける」とあり、とはいえコンテナ内の唯一のアプリケーションで全部メモリ食っちゃってるのでもうどうしようもない状態に陥ってしまっているようです。この状態からGC実行できたりしないのかな。。。

## 終わりに

今回Golangのアプリを省メモリ環境でいじめてみたのは単なるGolangへのいやがらせではなく、Kubernetes上でGolangアプリを動かすことを考えていての疑問から実験したものです。

Kubernetesでアプリケーションを動かすときにはPodで利用する最大メモリサイズを指定するとそのサイズを見て1 NodeへのPod配置数を制限してくれるのでできればちゃんと設定したいのですが、GolangのアプリケーションはJVMなんかと違って最大ヒープサイズを渡せないので、どうやって値決めたらいいんだろう、と疑問に思ったのです。

実験をする前はなんとなくOS（正確にはDockerコンテナ）のメモリ容量とかみてよしなにGCが働いてくれるんじゃないかな、とか妄想してたんですがやっぱりそんな虫のいい話はありませんでした。

もしこのへんどうやって設定するのがいいのかご存知の方がここまで読んでいただいた中にいらっしゃったら教えていただけると助かります。