- コンテナにCPU制限をかけても、Goランタイムはデフォルトではそれを認識しないため、ホスト全体のコア数を基準にスレッドを作成し、レイテンシを増やす可能性がある
- GoのGCはほとんどの場合アプリケーションと並行して動作するが、Sweep TerminationとMark Terminationでは、すべてのゴルーチンを停止する**stop-the-world(STW)**区間が必要になる
- LinuxのCFSはコア数を1秒あたりのCPU時間に分割して配分し、
--cpus=4はコンテナに毎秒4秒分のCPU時間を与える方式である
- 16コアのホストで4コア制限のコンテナを実行すると、Goが16個のOSスレッドにゴルーチンを載せられるため、CPU quotaを使い切った後にSTWが長くなる可能性がある
GOMAXPROCSをコンテナのCPU制限に合わせると、例ではGCサイクルが2.5ms未満から1ms未満に短縮され、STWは約26μsまで減少した
コンテナのCPU制限とGoランタイムの不一致
- コンテナでGoアプリケーションを実行する際、CPU制限はホストCPUをすべて消費しないようにするための仕組みである
- 問題は、GoランタイムがコンテナのCPU制限をデフォルトでは認識しない点にある
- この不一致により、ランタイムは実際のquotaより多くのCPUを使えると判断し、高いレイテンシにつながる可能性がある
Go GCでSTWが発生する箇所
- Goのガベージコレクタは、ほとんどの時間アプリケーションと並行実行される
- ただしGCの過程には、すべてのゴルーチンを停止しなければならない区間が2つある
- Mark Phaseの前にwrite barrierを適用するために停止する段階がSweep Terminationである
- Mark Phaseの後にwrite barrierを取り除くために再び停止する段階がMark Terminationである
- STW区間は通常、数十マイクロ秒程度である
- 例のアプリケーションはメモリを大量に割り当てるシンプルなWebアプリケーションで、ソースコードはgo-cfs-blogにある
- コンテナは4 CPU制限で実行される
docker run --cpus=4 -p 8080:8080 $(ko build -L main.go)
runtime/traceパッケージでtraceを収集し、go tool traceで分析できる
- この実行ではGCサイクルは2.5ms未満だったが、そのうちほぼ10%がSTW区間だった
- レイテンシに敏感なアプリケーションでは、この程度の割合でも問題になり得る
DockerのCPU制限とLinux CFSの動作方式
- Dockerの
--cpus CPU制限はhard limitである
--cpu-sharesも設定できるが、ホストがCPU制約を受けている場合にのみ強制される
- ホストに余裕があれば、コンテナは割り当てられたCPUコアより多く使用できる
- ホストが制約状態になると、アプリケーションは制限を受ける
- LinuxのCompletely Fair Scheduler(CFS)はLinux 2.6.23で導入され、Linux 6.6以前までデフォルトスケジューラだった
- CFSはproportional share schedulerであり、プロセスのweightを使用可能なCPUコア数に比例させる
- 4個のCPUコアを使用できるプロセスのweightは4である
- 2個のCPUコアを使用できるプロセスのweightは2である
- CFSはCPU時間を細かく分けて配分する
- 4コアシステムは毎秒4秒分のCPU時間を配分できる
- コンテナにCPUコア数を割り当てることは、Linuxスケジューラに
n CPU分の時間を要求するのと同じである
--cpus=4は、コンテナが毎秒4秒分のCPU時間を受け取ることを意味する
なぜSTWが長くなるのか
- Goランタイムは起動時にCPUコアごとに1つのOSスレッドを作成する
- 16コアのマシンでは、CGroupのCPU制限に関係なく16個のOSスレッドを作成できる
- ランタイムはこれらのOSスレッド上にゴルーチンをスケジュールする
- コンテナのCPU制限が4コアであっても、Goは16個のOSスレッド全体にゴルーチンを載せることができる
- この状態では、ランタイムは毎秒16秒分のCPU時間を使えると期待することになる
- STW時間が長くなるのは、Linuxスケジューラが再び実行してくれるのを待っているスレッド上のゴルーチンまで、すべて停止しなければならないためである
- コンテナがCPU quotaをすでに使い切った後は、それらのスレッドはスケジュールされない
GOMAXPROCSをCPU quotaに合わせる
- Goでは
GOMAXPROCS環境変数で、ランタイムが使用するCPUスレッド数を制限できる
- CPU quotaが4のコンテナでは、
GOMAXPROCS=4もあわせて指定する
docker run --cpus=4 -e GOMAXPROCS=4 -p 8080:8080 $(ko build -L main.go)
- 同じアプリケーションと同じ負荷で
GOMAXPROCSをCPU quotaに合わせると、GC時間が短くなった
- traceではGCサイクルが1ms未満に短縮され、STW区間は26μsだった
- これは
GOMAXPROCS制限がない場合のSTW時間と比べて約1/10の水準である
GOMAXPROCSは、コンテナが使用できるCPUコア数に設定すべきである
- fractional CPUを割り当てる場合は切り捨てる
- 1 CPU未満を割り当てる場合は切り上げる
- 計算式は
GOMAXPROCS=max(1, floor(CPUs))である
- Uberのautomaxprocsは、コンテナのcgroupsからこの値を自動計算するオープンソースライブラリである
- Goランタイムでこれを標準サポートするためのGitHub Issueが開かれている
コンテナ化されたGoサービスで確認すべき点
- CPU制限を設定するだけでは不十分で、Goランタイムがその制限を反映するようにGOMAXPROCSも合わせる必要がある
- 自分で計算するのが難しければ、automaxprocsのようなライブラリでcgroupsベースの値を自動設定できる
- レイテンシに敏感なGoサービスでは、GC traceでSTW時間を確認し、CPU quotaとランタイム設定が食い違っていないか点検すべきである
1件のコメント
Hacker Newsのコメント
複数の言語で共通して見られる問題は、アプリケーションが /proc/cpuinfo を見てマシンのコア数を検出するという点である。
しかし Docker コンテナや他のコンテナ技術の中では、このファイルはコンテナホストとまったく同じように見え、コンテナに実際にいくつ割り当てられているかに関係なく、すべてのコアを列挙する。
しばらくの間、Docker がジョブに割り当てられた「Docker CPU」だけを列挙する偽の /proc/cpuinfo を作れないかと考えていたが、考え直してみると、いくつもの理由でうまくいかなさそうだ。
制限されるのは、それらのコアを どれだけ長く使えるか である。
例外もあり、ドキュメントはこちら: https://kubernetes.io/docs/tasks/administer-cluster/cpu-mana...
nprocだけを使い、他のコンテナでもbundle install -j $(nproc)のように使われているのを見た。これは CPU 割り当て を尊重するので、求めていた機能を提供する。
任意のアプリケーションが可能なときに nproc を使うのかは分からない。
「現在のプロセスで利用可能な処理ユニット数を出力する。これはオンラインのプロセッサ数より少ないことがある。この情報を取得できない場合は、インストールされているプロセッサ数を出力する」
https://www.gnu.org/software/coreutils/manual/html_node/npro...
https://www.flamingspork.com/blog/2020/11/25/why-you-should-...
Go は起動時点で CPU マスク の数を見て、その後は再び見ない。
Kubernetes ではプロセス実行中に見える CPU が変わることがあるため、問題になる。
lxcfs は cgroup の値を推論して /proc をエミュレートする FUSE ファイルシステムで、アプリケーションやライブラリがコンテナ内で動いていることを意識しなくても済むようにする。
たとえば /proc/uptime はホストではなくコンテナの稼働時間を反映すべきであり、/proc/cpuinfo は cpu.max と cpuset.cpus の組み合わせのうち、より低い上限を CPU 数として反映する。
CPU 数の推論は
sched_getaffinityシステムコールでも可能で、この方式は /proc/cpuinfo に依存しない。そのため、使っているライブラリによっては厄介なことになる可能性がある。
この説明は微妙に誤っている。
Docker の観点では、CFS cgroup 拡張には調整可能なノブがいくつかある:
cfs_quota_us,cfs_period_us(一般的なデフォルトは 1 秒ではなく 100ms)、そして shares である。shares を設定すると重み付き比例スケジューリングが適用されるが、競合があるときにしか意味がない。
最初の 2 つの値は厳格な quota を強制する。
Docker の
--cpuフラグの代わりに--cpu-sharesを使って、たいてい役に立たない quota 強制を避けるほうがよい。Linux の文書によれば、
cpu.sharesは同じ階層内の各グループの重みであり、cpu.cfs_period_usは帯域幅判定のためのスケジューラ周期で、デフォルト値は 100000us または 100ms である。cpu.cfs_quota_usは各cfs_period_usの間に現在のグループが実行できる最大時間であり、この値はシステム全体の CPU にまたがる合算時間なので、CPU 2 個をフルに使わせるにはcfs_period_usの 2 倍に設定する必要がある。--cpuフラグを使わず代わりに…」という表現は、これといった条件もなく強すぎる。決して「たいてい役に立たない」とは言えない。
shares と quota は異なるユースケース向けなので、自分のユースケースを理解し、それに応じて選べばよい。
--cpuを使うとアプリケーションがそれを検出できるということがある。おそらく cpuset を使うためだと思われる。
quota を使うと検出できないため、必要以上に多くのスレッドが作られる可能性が高い。
この部分をもっと明確にしてみます。
症状はこのように現れると考えていますが、表現はもっとはっきりさせる必要がありそうです。
アプリケーションが正しく動作しなければならない。
CPU limits の代わりに CPU reservations を使えば、このような調整は不要: https://home.robusta.dev/blog/stop-using-cpu-limits
CPU reservations も実質的には limits だが、暗黙の上限であり、同時に保証として宣言されるもの
なので Go ランタイムには利用可能なすべての CPU を使わせておき、CPU 競合が発生したら Linux スケジューラが宣言された reservations に従って制限すればよい
保証されていない 余剰 CPU を使える状態に慣れたくないから
ノードに他の Pod が増えていくと、ついさっきまで問題なく動いていた Pod が突然遅くなることがある
limits を使えば同じ動作をシミュレートでき、適切なキャパシティプランニングで備えられる
唯一の方法ではないが、最も単純な方法ではある
この議論には興味があるが、リンク先の記事は CPU をすべての Pod に保証するには limit が必要だと人々が考えている、という点しか扱っていないように見える
この記事自体が間違っているわけではなく、全体としてはコンテンツマーケティング寄りだが、断定が広すぎて limits を設定する妥当な理由をいくつも無視している
同じところの記事には単純に間違っているものもある: https://home.robusta.dev/blog/containers-dont-use-chroot
わずかな利益のためにバースト容量をすべて使い切るワークロードもあるし、決められた時間内に終わればよい cronjob より HTTP サーバーのバースト容量を優先すべき場合もある
開発者がアプリの要求量が増えたのに requests を更新せず、余剰 CPU 時間が突然足りなくなって障害が発生したこともあった
理論上は最低保証されるリソースだが、同じホストで忙しいコンテナ同士が一緒に動くと、テールレイテンシと平均レイテンシが異常に増加することがある
4 コアの EC2 インスタンスでは、CPU 使用率 50% のときと 90% のときでレイテンシはかなり違う
reservations でも同様で、各コンテナが自分の reservation を保証されていても、同じホスト上の他の多忙なプロセスのせいで相対的な CPU 使用率が非常に高くなる
OOMKiller に殺される可能性がある
CPU とメモリの limits の両方がないと Guaranteed QoS クラスを得られないので、ある時点で Pod が退避させられる可能性もある
コンテナと cgroup を使っていて、CFS スケジューラ には何度もやられた
新しいスケジューラが何なのか気になる
ここで本番クラスターに使ったことがある人はいる?
もうほぼ 20 年もコアを無駄にしている: https://people.ece.ubc.ca/sasha/papers/eurosys16-final29.pdf
コンテナにリソース制限がかかっているのに、コンテナ内プロセスである Go が利用可能な並列性の量を計算するとき、その制限に使われている OS 機能を確認しないことが問題
GOMAXPROCS に加えて、最近の Go リリースには GOMEMLIMIT もある
https://github.com/KimMachineGun/automemlimit を使えば、https://github.com/uber-go/automaxprocs と同様にこの上限を自動設定できる
去年、前職でプラットフォームエンジニアとしてオンプレミス Kubernetes クラスターと CI/CD パイプライン基盤を管理していたときにこれを見つけた
実 CPU と割り当て CPU の不一致が、とくに CPU スロットリング のような問題を引き起こすのは見ていたが、クラスター内のすべての Go デプロイに効くスケーラブルな解決策を見つけるのは難しかった
何百ものプロジェクトの全開発者に autoprocs 依存関係を入れさせるのは選択肢ではなかった
代案として、すべての CPU request/limit を整数にそろえ、Kubernetes マニフェストでその値を GOMAXPROCS 環境変数に入れる方法も煩雑で現実的ではなかった
結局、多数のスレッドを使う一部のアプリケーションにだけ GOMAXPROCS 変数を適用して改善を得たが、プロジェクトごとに CPU 要求量の変動が大きいマイクロサービスアーキテクチャの全デプロイに適用可能な解決策はまだ見つけられていない
GOMAXPROCS を制限すると、プロセスにトラフィックが集中し、キューイングが単純なときに 深刻なレイテンシ問題 が起きることがある
平均的にプロセスがどれだけの時間を使うかという考えとは関係なく、ハードウェアが返す値で GOMAXPROCS を設定するのが実際には最善
Docker や Go に詳しくない立場からすると、この挙動が意図されたものなのか気になる
Go チームは CGroups limit を認識するようにできるのか?
他のランタイムも同じように動くのか?
それとも containerd のようなランタイムのことを言っていたのか?
Scala でのことだった
一時停止をより短くする GC 手法もある
たとえば、一時停止中にやるべき処理を並行して実行したうえで、安全点で再度繰り返す方式だ
狙いは、並行処理のおかげで安全点での作業が「やることがない」という単純な確認に変わることだ
作業を二重にすると、GC のスループットは悪化する可能性がある
この記事ではコンテナについて述べているが、問題は Go が期待より少ない CPU 時間にしかアクセスできないときには常に起こるように思える
CPU を使う別のプロセスがあるシステムで Go を実行しても、同じことが起きるのではないか?
さらに、Go プログラムを 2 つ同時に実行するだけでもそうならないだろうか?