golangとDockerとOOM

golangで書いたプログラムをDockerで動かしOOMが発生した際になるべく情報を残して殺される方法を紹介します。

2020/08/16追記: この記事の内容はgolangに関してはやや現実的ではなくなってしまいました。 詳しくは続編を参照してください。

TL;DR

  • golang製のプログラムは仮想メモリ(VSZ)の確保に失敗するとgoroutineのダンプを吐いて死ぬ
  • DockerのOOMはRSSベースで検出時にSIGKILLを投げてくる
  • Docker利用時にVSZで制限をかけるスクリプトを書いた
  • golang製のプログラムはlinux-amd64において最低でも101MBのVSZを要求する
    • VSZの制限がそれより小さいと当然起動できない
    • 実際のRSSは3MB程度で起動する

Background

コンテナ内で動いているプロダクション上のgolang製のプログラムが時々OOMに殺されていました。

何も言わずに殺されているのでせめてJavaのようにスレッドダンプを出して死ねないかとの当然の要望が。

たしかgoroutineのダンプが出せたよなと思って調べたところ、 mmap()システムコールが_ENOMEMを返せばgoroutineのダンプとともに死ぬことがわかりました。

DockerのOOMはLinuxカーネルのcgroupという機能によってページイン(RSSが増える)のタイミングで検出されます。

RSSっていうのは物理メモリのことで、Linuxでは仮想メモリに値が書き込まれた時に初めて割り当てられます。

なおcgroupによるOOMの補足時にはSIGKILLが送られてくるのでプロセス側ではあがく余地は一切ありません。

まとめるとOOMの時に、golang製のプログラムは仮想メモリの確保が失敗することを期待しているのに対し、 Dockerでは仮想メモリの確保には成功してしまい制限を超えた物理メモリを割り当てた後にSIGKILLが飛んできます。

このミスマッチがメモリ制限下のDockerでgolang製のプログラムを動かす際の不幸を生んでいることがわかりました。

これに対応するためRSSの制限をVSZに読み替えて適用するラッパースクリプト wrap-limit-vを書きました。

#!/bin/sh

set -eu

cgroup_rsslimit="/sys/fs/cgroup/memory/memory.limit_in_bytes"

if [ -r "$cgroup_rsslimit" ] ; then
  rsslimit=`cat "$cgroup_rsslimit"`
  vszlimit=`expr $rsslimit / 1024`
  ulimit -v $vszlimit
fi

exec "$@"

コードを見てわかるとおりcgroupのRSS制限値であるmemory.limit_in_bytesをKB単位に読み替えてulimit -vに渡してVSZを制限してから目的のプログラムをexecで起動します。

Dockerfileからの使い方としてはENTRYPOINTの先頭にこのwrap-limit-vを突っ込むだけです。

# もともとのENTRYPOINT
#ENTRYPOINT ["/opt/bin/my_prog"]
ENTRYPOINT ["/opt/bin/wrap-limit-v", "/opt/bin/my_prog"]

メモリ枯渇をハンドルできるプログラムにとってはRSSで制限するよりもVSZで制限するほうが理に適っています。 なのでこのwrap-limit-vはそういったプログラムにも有用だと考えられます。

ということで浮かれ気分でこのスクリプトを導入したところ、 いままで動いていた別のプログラムがOOMでまったく実行できなくなりました。

図らずもwrap-limit-vがちゃんと機能しオペレーションが詳細を把握できることは証明できました。 なおその際のエラー(の代表的なメッセージ)はこんな感じ。

runtime: out of memory: cannot allocate 8192-byte block (0 in use)

要約すると「いままでVSZを0バイト使ってて新たに8Kバイトを確保しようとしたら失敗ちゃった(テヘペロ」です。

コンテナ実行側は docker run --memory 64m 相当の設定になっていました。 つまりVSZとしても64MBは割り当て可能なはずです。

なんでわずか8KBすらも確保できないのよ! と突っ込みたいところですが、冷静にgolangのソースコードを調べます。 するとgolangのランタイムのヒープとして使うメモリは64ビット環境では64MB単位で確保してそれを細かく分割して払い出すことがわかりました。

さらに細かく見ていくとlinux-amd64環境ではこのヒープ管理にヒープアリーナとして追加で32MBを必要とします。 加えてメタデータなどの管理用に2MB+αを確保するので合計35MB程度が必要です。

実はgolang製のプログラムが必要とするVSZはこれだけではありません。 さまざまなシステム管理領域として約2MBを確保してました。

つまりlinux-amd64環境で動くgolang製のプログラムは最低でも101MBの仮想メモリ(VSZ)を確保する必要があるということです。

ただしコレは物理メモリに対する要件ではありません。 「メモリ情報を表示したのちに1時間time.Sleep()するだけのプログラム」を実行してprocfsから概算した物理メモリ(RSS)は3MB以内でした。 これにはプログラム自身を載せる物理メモリやOSとして必須なものも含まれています。 加えてgolangのランタイムのヒープとして払い出されたメモリは8KBに過ぎません。

このようにVSZとRSSの乖離がかなり大きいという事情を鑑みると cgroupがRSSで制限をかけることにも一定の理解はできます。

以上golangのメモリの使い方の調査報告でした。 なおこの記事に上げた数字はOSやCPUの種類等で、 さらにgolangのバージョンによっても変わることに留意してください。