コンテナに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 limit | GOMAXPROCS の初期値 |
|---|---|
| 制限なし | 16(NumCPUと同じ) |
| 1 | 1 |
| 1.5 | 2 |
| 2.5 | 3 |
| 20 | 16(NumCPUで頭打ち) |
CPU requestsは見ない
Kubernetesにはrequestsとlimitsがあります。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行が要らなくなりました。
| automaxprocs | Go 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を付けなければ、GOMAXPROCSはNumCPU()のまま。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は標準機能に置き換えられる

