https://zenn.dev/koron/articles/b96cccfa82c0c1

og-base_z4sxah.png

TL;DR

前置き: GoとOOMのこれまで

以下はGo 1.16での調査結果です。Goのバージョンが異なった場合は事情が異なる可能性があります。

Goでプログラムを書く際に、使用メモリ量を気にしなければならないシーンはGCのおかげでそう多くはありません。実際それは間違いではないのですが、運用まで視野に入れるとそうは言ってられないことがあるのもまた現実です。昨今はコンテナの利用が当たり前になったことに伴い、OOMによりプロセスが強制的に終了させられることもあり、それを避けるために一定量以下のメモリで動くことが重視されたりもします。

筆者は過去にこのGoとDockerとOOMに関連して以下の2つの記事を書いています。これらの記事ではGoのメモリ確保における前提とDockerの前提がそれぞれVSSとRSSの違いを理由にうまく機能しないこと、およびそれを緩和させる方法について紹介しました。

本記事の主題はこの2つの記事の延長線上で発覚した問題とその部分的な解決方法です。興味があればこの2記事も併せて読んでみてください。

問題: それでもOOMに殺される

先の記事で紹介した「物理メモリのサイズ(RSS)を監視し一定ラインを超えたらお行儀よく終了(graceful shutdown)する戦略」はある時期までは上手く機能していました。しかしある頃から頻繁にgraceful shutdownしたりOOM Killerに殺されるようになりました。ログなどをよく調べてみると、とあるAPIリクエストが大きなJSONを返した後にOOMが発生する確率が高いことがわかりました。大きなJSONを返せるようにしてしまったのは明らかに設計ミスなのですが、本記事ではそれには言及しません。

GoにはGCがあるのにおかしいですね。対象としているのは普通(?)のWeb APIサーバーですので、リクエスト間のメモリに依存関係はなく、GCが機能していれば、たとえ大きなJSONを返したところでそのリクエストさえ終わってしまえば回収・再利用可能なメモリであり、OOMに陥ることなどないと期待されます。

にも拘わらずOOMが発生しているということは、GCされていないと逆説的に結論できます。ここで考えてみれば、GoのGCがいつ実行されるかは正確には知らないな、ということに気が付き調べてみたのです。

調査: GoはいつGCするのか?

GoのGCは内部的にruntime.gcStart()関数を呼ぶことで開始します。それを呼んでいる箇所は3箇所ありました。以下にその3箇所のコードを示します。

このコードを見るだけでもGCを開始するトリガーにはTick, Heap, Timeの3種類の戦略があることがわかります。このうちTickはruntime.GC()の呼び出しで使うものでした。

HeapトリガーGC

Heapトリガー戦略のGCを詳しく追っていくと最終的にruntime.gcSetTriggerRatio()の次のコード(mgc.go#L831)にたどり着きます。