GOMAXPROCSをコンテナのCPU制限に合わせる—Go 1.25の自動調整

GOMAXPROCSをコンテナのCPU制限に合わせる—Go 1.25の自動調整 | mohablog

コンテナにCPU limitを設定しても、Go 1.24までのランタイムはそれを見ていませんでした。ホストの論理CPU数をそのままGOMAXPROCSにして、制限を超えて走ろうとしてはCPUスロットリングを食らう。Go 1.25はこの挙動を標準で変えました。

変更点はruntimeがcgroupのCPU帯域制限を読むこと。go.uber.org/automaxprocsが長年やってきた処理が、ついに標準ライブラリに入りました。手元のmacは18コア。この環境とGo 1.25の公式ドキュメントで、実際の値を突き合わせます。

目次

コンテナのCPU limitとGOMAXPROCSがずれると何が起きるか

GOMAXPROCSは、Goランタイムが同時にGoコードを実行するOSスレッド数の上限。Go 1.24まではこの初期値がruntime.NumCPU()、つまり起動したマシンの論理CPU数でした。8コアのノードなら8。ここにCPU limitが絡むと、値がずれます。

CPU limitはcgroupの帯域制限に翻訳される

KubernetesのPodにlimits.cpu: "1"を書くと、LinuxカーネルのcgroupにCPU帯域制限として設定されます。100msごとに使えるCPU時間が決まる仕組み。1コア分の制限なら、100msの窓で100ms相当までしかCPUを回せません。

ところがGoは8コア分の並列度でスケジュールし続ける。制限を超えた瞬間、カーネルが介入します。

スロットリングは実行そのものを止める

公式ブログ “Container-aware GOMAXPROCS” は、スロットリングをこう説明しています。

Throttling is a blunt mechanism for restricting containers that would otherwise exceed their CPU limit: it completely pauses application execution for the remainder of the throttling period.

制限を使い切ると、その100msの窓が終わるまでアプリの実行が完全に止まる。ソフトにスケジュールが混み合うのとは違い、丸ごと一時停止です。GOMAXPROCSが制限より大きいほど、窓の前半でCPUを使い切って後半で固まる時間が増える。結果としてp99などのテールレイテンシが跳ねます。

GCのCPUスパイクで表面化する

この問題が厄介なのは、平常時は表に出にくいところ。以前、8コアのノードにCPU limitを1で切ったPodを載せたとき、通常のリクエストは捌けていました。ところがGCが走るたびにGOMAXPROCS=8ぶんのスレッドがCPUを取りにいき、スロットリングでリクエストが数十ミリ秒単位で待たされる。GOMAXPROCSを手で1に落としたら収まりました。Go 1.25なら、この手当てが要りません。

Go 1.25の新デフォルト cgroupのCPU limitを見る

Go 1.25のリリースノートは、この変更を “Container-aware GOMAXPROCS” というセクションで説明しています。中身はcgroupのlimitを採るか、NumCPUを採るか。それだけです。

CPU limitが論理CPUより低ければそちらを採用

If the CPU bandwidth limit is lower than the number of logical CPUs available, GOMAXPROCS will default to the lower limit.

16コアのマシンでもCPU limitが4なら、GOMAXPROCSの初期値は4。逆にlimitが論理CPU数より大きい、あるいはlimit自体がなければ、従来どおりruntime.NumCPU()が使われます。低い方を採るだけの規則です。

端数は切り上げる

CPU limitは整数とは限りません。--cpus=2.5のような小数もある。このときGoは切り上げます。公式ブログの表現はこう。

Go always rounds up to enable use of the full CPU limit.

CPU limitが2.5ならGOMAXPROCSは3。2.1でも3。制限の端数ぶんも使い切れるよう、多めに丸める判断です。0.5のように1未満でも下限は1。ホストが16コアのときの対応関係を並べます。

CPU limitGOMAXPROCS の初期値
制限なし16(NumCPUと同じ)
11
1.52
2.53
2016(NumCPUで頭打ち)

CPU requestsは見ない

Kubernetesにはrequestslimitsがあります。Goが見るのはlimitsだけ。リリースノートも明記しています。

The Go runtime does not consider the “CPU requests” option.

requestsはスケジューラがPodを配置するときの下限保証で、cgroupの帯域制限にはなりません。だからruntimeからは見えない。requestsに並列度を合わせたいなら、後述の手動設定が要ります。

手元で挙動を確かめる

package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println("NumCPU     :", runtime.NumCPU())
	fmt.Println("GOMAXPROCS :", runtime.GOMAXPROCS(0))
}

runtime.GOMAXPROCS(0)は値を変えずに現在値を返す呼び方。18コアのmacで走らせた結果です。

NumCPU     : 18
GOMAXPROCS : 18

macOSやWindowsでは変化しない

この自動調整はLinuxのcgroupを前提にしています。リリースノートも “On Linux, the runtime considers the CPU bandwidth limit of the cgroup” と書くとおり、macOSやWindowsでは効きません。手元のmacで並列度を絞りたくてもGOMAXPROCSは18のまま。cgroupのあるLinuxコンテナに載せて初めて動く仕組みです。

Linuxコンテナで制限を変える

Linux上でdocker run --cpus=1.5のようにCPU limitを渡すと、同じバイナリの出力が変わります。ホストが8コアでも、cgroupのlimitが1.5なら切り上げで2。

$ docker run --cpus=1.5 myapp
NumCPU     : 8
GOMAXPROCS : 2

runtime.NumCPU()はホストの論理CPU数(8)を返したまま、GOMAXPROCSだけがlimit由来の2に落ちる。NumCPUとGOMAXPROCSが食い違うのが、コンテナ対応が効いているサインです。

automaxprocsから何が変わるか

cgroupを読んでGOMAXPROCSを合わせる処理は、Uberのgo.uber.org/automaxprocsが以前から提供していました。ブランクインポート1行を足すだけのライブラリ。Go 1.25で、その1行が要らなくなりました。

automaxprocsGo 1.25標準
導入import _ "go.uber.org/automaxprocs" を追加不要(標準で有効)
反映タイミング起動時のみ起動時 + 実行中に定期更新
limit変更への追従なし(再起動が必要)あり
依存サードパーティ標準ライブラリ

差が出るのは実行中の追従。automaxprocsは起動時にcgroupを読んで終わりでした。Go 1.25は動き続けます。

CPU limitが動いたら追従する

KubernetesはPodのCPU limitを稼働中に書き換えることがあります(In-place Pod Resize)。automaxprocsだと起動時の値のまま。Go 1.25は違います。リリースノートいわく “the runtime periodically updates GOMAXPROCS if the number of logical CPUs available or the cgroup CPU bandwidth limit change”。全OS共通で、論理CPU数かcgroup limitが変わったら定期的にGOMAXPROCSを更新します。

この定期更新だけを止めたいならGODEBUG=updatemaxprocs=0。起動時の反映は残しつつ、実行中の再計算をやめます。

自動調整を切る・戻す

自動調整が邪魔になる場面もあります。ベンチマークで並列度を固定したいとき、あるいは意図的に論理CPU数ぶん使いたいとき。無効化の入口はGODEBUG。

# cgroup由来のlimit反映を切り、NumCPUに戻す
GODEBUG=containermaxprocs=0 ./app

# 実行中の定期更新だけ止める
GODEBUG=updatemaxprocs=0 ./app

どちらもデフォルトは有効。containermaxprocs=0で完全に旧挙動へ戻ります。

環境変数や手動設定が勝つ

GOMAXPROCS環境変数を設定するか、runtime.GOMAXPROCS(n)を呼ぶと、cgroup反映とlimit追従はどちらも自動で無効になります。手で決めた値が優先。ここでアンチパターンが1つ。

DockerfileにENV GOMAXPROCS=1と焼き込むと、確かにその瞬間は制限に合います。ただCPU limitを後で2に増やしても、バイナリは1のまま。自動追従も殺してしまう。何も設定せず標準に任せた方が、limit変更に追従します。

SetDefaultGOMAXPROCSで既定値へ戻す

一時的に手動で固定したあと、標準の既定値へ戻したい。Go 1.25の新関数runtime.SetDefaultGOMAXPROCS()がそれをやります。GOMAXPROCS環境変数が未設定だったかのように、cgroupやNumCPU由来の既定値へ再設定する関数。

fmt.Println("起動時       :", runtime.GOMAXPROCS(0))
runtime.GOMAXPROCS(2)          // 手動で 2 に固定
fmt.Println("手動で2に固定 :", runtime.GOMAXPROCS(0))
runtime.SetDefaultGOMAXPROCS() // 既定値へ戻す
fmt.Println("既定値へ戻す  :", runtime.GOMAXPROCS(0))

環境変数GOMAXPROCS=2を付けて18コアのmacで走らせた結果です。

起動時       : 2
手動で2に固定 : 2
既定値へ戻す  : 18

起動時は環境変数の2。手動設定も2。SetDefaultGOMAXPROCS()を呼んだ瞬間、環境変数を無視して既定の18へ戻りました。cgroupのあるLinuxなら、この18の位置にlimit由来の値が入ります。

そもそもCPU limitを設定すべきか

公式ブログには “Should I set a CPU limit?” というセクションがあります。GOMAXPROCSがlimitを見る以上、limitを付けるかどうかがそのまま並列度を決めます。

limitを付けない選択

CPU limitを付けなければ、GOMAXPROCSNumCPU()のまま。cgroupの帯域制限がないので、スロットリングも起きません。ノードのCPUが空いていれば、Podはrequestsを超えてバーストできます。突発的な負荷を捌きたいワークロードでは、こちらが向く場面もあります。

requestsに並列度を合わせたいとき

limitを外すとGoはrequestsを見ないので、GOMAXPROCSはノードのフルコア数。requests相当に抑えたいなら、GOMAXPROCS環境変数かruntime.GOMAXPROCS()で明示するしかありません。limitとrequestsのどちらに合わせるかは、テールレイテンシを削りたいか、スループットを出したいかで変わります。

まとめ

Go 1.25で標準に入ったContainer-aware GOMAXPROCS。押さえる点は次のとおり。

  • runtimeがcgroupのCPU limitを読み、論理CPU数より低ければGOMAXPROCSをそちらに合わせる
  • 端数は切り上げ。CPU limit 2.5ならGOMAXPROCS=3、下限は1
  • 見るのはlimitだけで、Kubernetesのrequestsは無視する
  • Linuxのcgroup前提。macOSやWindowsでは効かない
  • 実行中のlimit変更にも定期更新で追従する(updatemaxprocs=0で停止)
  • 旧挙動へ戻すならGODEBUG=containermaxprocs=0、既定値へ戻すならruntime.SetDefaultGOMAXPROCS()
  • automaxprocsは標準機能に置き換えられる
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次