コンテナを意識したGOMAXPROCS

Container-aware GOMAXPROCS by Michael Pratt and Carlos Amedee

Go 1.25には、新しいコンテナを意識した GOMAXPROCS のデフォルト値が導入されました。これにより、多くのコンテナワークロードでより適切なデフォルトの振る舞いが得られ、テールレイテンシに影響しかねないスロットリングを避けられるほか、Goを標準設定のままで本番運用に適した状態に近づけます。 この記事では、Goがどのようにゴルーチンをスケジュールするか、そのスケジューリングがコンテナレベルのCPU制御とどう関わるか、そしてコンテナのCPU制御を意識することでGoの性能がどう向上するかを順に説明します。

GOMAXPROCS

Goの強みの一つは、ゴルーチンによる組み込みの、使いやすい並行性です。 意味論的な観点から見ると、ゴルーチンはオペレーティングシステムのスレッドとよく似ており、シンプルなブロッキングコードを書けます。 一方で、ゴルーチンはオペレーティングシステムのスレッドよりも軽量であるため、その場で作成・破棄するコストがはるかに低く済みます。

Goの実装として各ゴルーチンを専用のオペレーティングシステムのスレッドにマッピングすることもできたはずですが、Goはランタイムスケジューラによってスレッドを交換可能にすることで、ゴルーチンを軽量に保っています。 Goが管理するスレッドはどれでも任意のゴルーチンを実行できるため、新しいゴルーチンを作成しても新しいスレッドを作成する必要はなく、あるゴルーチンを起こしても別のスレッドを起こす必要が必ずしもあるわけではありません。

とはいえ、スケジューラが存在すればスケジューリングに関する疑問も生まれます。 例えば、ゴルーチンを実行するのに正確には何個のスレッドを使うべきでしょうか。 もし1,000個のゴルーチンが実行可能な状態にあるなら、それらを1,000個の異なるスレッドにスケジュールすべきでしょうか。

ここで登場するのが GOMAXPROCS です。 意味論的には、 GOMAXPROCS はGoが使うべき「利用可能な並列性」をGoランタイムに伝えるものです。 より具体的に言うと、 GOMAXPROCS はゴルーチンを一度に実行するために使用するスレッドの最大数です。

つまり、 GOMAXPROCS=8 で1,000個の実行可能なゴルーチンがある場合、Goは8個のスレッドを使って一度に8個のゴルーチンを実行します。 多くの場合、ゴルーチンはごく短い時間だけ実行されてからブロックし、その時点でGoは同じスレッド上で別のゴルーチンの実行に切り替えます。 また、Goは自発的にブロックしないゴルーチンをプリエンプトし、すべてのゴルーチンに実行の機会が与えられるようにします。

Go 1.5からGo 1.24まで、 GOMAXPROCS はデフォルトでマシン上のCPUコアの総数になっていました。 この記事において「コア」とは、より正確には「論理CPU」を意味することに注意してください。 例えば、ハイパースレッディングが有効な物理CPUを4個搭載したマシンには、論理CPUが8個あることになります。

これは「利用可能な並列性」のデフォルト値として一般的にうまく機能します。なぜなら、それはハードウェアの利用可能な並列性に自然に一致するからです。 つまり、コアが8個あるときにGoが8個を超えるスレッドを同時に実行すると、オペレーティングシステムは、Goがゴルーチンをスレッドに多重化するのと同じように、それらのスレッドを8個のコアに多重化しなければならなくなります。 このスケジューリングの追加の層は必ずしも問題になるわけではありませんが、不要なオーバーヘッドではあります。

コンテナオーケストレーション

Goのもう一つの中心的な強みは、コンテナを介してアプリケーションをデプロイできる利便性です。コンテナオーケストレーションプラットフォーム上にデプロイする場合には、Goが使用するコア数の管理が特に重要になります。 Kubernetesのようなコンテナオーケストレーションプラットフォームは、マシンのリソース一式を受け取り、要求されたリソースに基づいて利用可能なリソースの中でコンテナをスケジュールします。 クラスタのリソース内にできるだけ多くのコンテナを詰め込むには、プラットフォームがスケジュールされた各コンテナのリソース使用量を予測できる必要があります。 私たちは、Goにコンテナオーケストレーションプラットフォームが設定するリソース利用の制約に従ってほしいと考えています。

例として、Kubernetesの文脈で GOMAXPROCS 設定の効果を見てみましょう。 Kubernetesのようなプラットフォームは、コンテナが消費するリソースを制限する仕組みを提供します。 KubernetesにはCPUリソースリミットという概念があり、これは特定のコンテナやコンテナ群にどれだけのコアリソースが割り当てられるかを、基盤となるオペレーティングシステムに伝えます。 CPUリミットを設定すると、LinuxのコントロールグループのCPU帯域幅リミットが作成されることになります。

Go 1.25より前は、Goはオーケストレーションプラットフォームが設定したCPUリミットを認識していませんでした。 代わりに、デプロイ先のマシンのコア数に GOMAXPROCS を設定していました。 CPUリミットが設定されている場合、アプリケーションはそのリミットで許可されている以上のCPUを使おうとするかもしれません。 アプリケーションがリミットを超えないようにするため、Linuxカーネルはそのアプリケーションをスロットリングします。

スロットリングは、CPUリミットを超えてしまうコンテナを制限するための大雑把な仕組みです。それはスロットリング期間の残り時間の間、アプリケーションの実行を完全に一時停止させます。 スロットリング期間は通常100msなので、低めの GOMAXPROCS 設定によるソフトなスケジューリングの多重化効果に比べて、スロットリングはテールレイテンシに大きな影響を与えることがあります。 アプリケーション自体にそれほど並列性がなくても、ガベージコレクションのようなGoランタイムが行うタスクによってCPUスパイクが発生し、それがスロットリングを引き起こすことがあります。

新しいデフォルト値

私たちは可能な限りGoに効率的で信頼できるデフォルト値を提供してほしいと考えています。そこでGo 1.25では、 GOMAXPROCS がデフォルトでコンテナ環境を考慮するようにしました。 Goプロセスが、CPUリミットが設定されたコンテナ内で実行されている場合、そのCPUリミットがコア数より小さければ、 GOMAXPROCS はそのCPUリミットの値をデフォルトとします。

コンテナオーケストレーションシステムは実行中にコンテナのCPUリミットを調整することがあるため、Go 1.25はCPUリミットを定期的にチェックし、変更があれば GOMAXPROCS を自動的に調整します。

これらのデフォルトの挙動はいずれも、 GOMAXPROCS が他の方法で指定されていない場合にのみ適用されます。 GOMAXPROCS 環境変数を設定すること、あるいは runtime.GOMAXPROCS を呼び出すことは、これまでと変わらず動作します。 runtime.GOMAXPROCS のドキュメントに、新しい挙動の詳細が記載されています。

わずかに異なるモデル

GOMAXPROCS とコンテナのCPUリミットはどちらも、プロセスが使用できるCPUの最大量に制限を課しますが、そのモデルは微妙に異なります。

GOMAXPROCS は並列性のリミットです。 GOMAXPROCS=8 であれば、Goは一度に8個を超えるゴルーチンを実行することは決してありません。

対照的に、CPUリミットはスループットのリミットです。 つまり、これはある一定の実時間の期間内に使用される合計CPU時間を制限します。 デフォルトの期間は100msです。 つまり「CPUリミット8」というのは、実際には実時間100msごとにCPU時間800msというリミットのことです。

このリミットは、8個のスレッドを100ms全体にわたって継続的に実行することでも満たせ、これは GOMAXPROCS=8 と等価です。 一方で、16個のスレッドをそれぞれ50msずつ実行し、残りの50msはそれぞれのスレッドをアイドルまたはブロックさせておくことでも満たせます。

言い換えると、CPUリミットはコンテナが実行できるCPUの総数を制限するものではありません。 それが制限するのは合計のCPU時間だけです。

多くのアプリケーションは100ms周期でのCPU使用量が比較的一定しているため、新しい GOMAXPROCS のデフォルト値はCPUリミットにかなりよく一致しており、コアの総数を使うよりも間違いなく優れています。 ただし、特に使用量の変動が激しいワークロードでは、 GOMAXPROCS がCPUリミットの平均を超える短期間のスレッドのスパイクを防いでしまうため、この変更によってレイテンシが増加する可能性がある点には注意が必要です。

さらに、CPUリミットはスループットのリミットであるため、小数部分を持つことがあります(例えば2.5 CPUなど)。 一方、 GOMAXPROCS は正の整数でなければなりません。 そのため、Goはリミットを有効な GOMAXPROCS の値に丸める必要があります。 Goは、CPUリミットを最大限利用できるように、常に切り上げます。

CPUリクエスト

Goの新しい GOMAXPROCS のデフォルト値はコンテナのCPUリミットに基づいていますが、コンテナオーケストレーションシステムは「CPUリクエスト」という制御も提供しています。 CPUリミットがコンテナの使用できる最大CPUを指定するのに対し、CPUリクエストはそのコンテナに常時保証される最小のCPUを指定します。

CPUリクエストは設定するもののCPUリミットは設定しないコンテナを作ることはよくあります。そうすることで、他のコンテナからの負荷がないために本来アイドル状態になるはずのマシンのCPUリソースを、CPUリクエストを超えて利用できるようになるからです。 残念ながら、これはGoがCPUリクエストに基づいて GOMAXPROCS を設定できないことを意味します。もしそうしてしまうと、追加のアイドルリソースの利用が妨げられてしまいます。

CPUリクエストを持つコンテナも、マシンがビジー状態のときにリクエストを超えて使用しようとすると、依然として制約を受けます。 リクエストを超えることに対する重み付けベースの制約は、CPUリミットによる期間ベースのハードなスロットリングよりも「ソフト」ですが、高い GOMAXPROCS によるCPUスパイクはそれでもアプリケーションの挙動に悪影響を及ぼす可能性があります。

CPUリミットを設定すべきか

GOMAXPROCS が高すぎることによって引き起こされる問題や、コンテナのCPUリミットを設定することでGoが適切な GOMAXPROCS を自動的に設定できるようになることを学んだので、次に浮かぶ当然の疑問は、すべてのコンテナにCPUリミットを設定すべきかどうかということでしょう。

自動的に妥当な GOMAXPROCS のデフォルト値を得るという観点では良い助言かもしれませんが、CPUリミットを設定すべきかどうかを決める際には、リミットを避けることでアイドルリソースの利用を優先するか、リミットを設定することで予測可能なレイテンシを優先するかなど、考慮すべき他の要因もたくさんあります。

GOMAXPROCS と実効CPUリミットの不一致による最悪の挙動は、 GOMAXPROCS が実効CPUリミットよりも著しく大きい場合に発生します。 例えば、128コアのマシン上で2 CPU分を受け取る小さなコンテナなどです。 こうしたケースこそ、明示的にCPUリミットを設定するか、あるいは代わりに GOMAXPROCS を明示的に設定することを検討する最も価値のある場面です。

まとめ

Go 1.25は、コンテナのCPUリミットに基づいて GOMAXPROCS を設定することで、多くのコンテナワークロードに対してより適切なデフォルトの振る舞いを提供します。 そうすることで、テールレイテンシに影響を与えかねないスロットリングを回避し、効率を改善し、全体としてGoがそのままの状態で本番運用に対応できるよう努めています。 go.mod でGoのバージョンを1.25.0以上に設定するだけで、この新しいデフォルト値を利用できます。

この実現につながった長い議論に貢献してくれたコミュニティの皆さん、そして特に、長年にわたって同様の挙動をユーザーに提供してきたUberのgo.uber.org/automaxprocsのメンテナーからのフィードバックに感謝します。

By Michael Pratt and Carlos Amedee