はじめに こんにちは、タイミーでエンジニアをしている徳富( @yannkazu1 )です。 クラウドネイティブ会議2026 で発表された「 ペアーズ本番環境でのcgroup-aware化との死闘録 」がめちゃくちゃ面白かったので、自分の手でも体感したくなりました。 GoのGOMAXPROCSがコンテナのCPU制限を無視するって、実際に見るとどうなるのか? 過剰並列のスループット低下って、数字で見るとどのくらいインパクトがあるのか? スロットリングとスレッド数の関係を自分の目でたしかめたい! 自分で動かして数字を見ないと腑に落ちないタイプなので、 ローカルのMac環境で全部再現してみました。 発表の要約 ペアーズのバックエンド pairs-main はGo製でAmazon EKS上で稼働。48コアのNodeで limits.cpu: 5000m (5コア)のPodが動いていたが、 GoのGOMAXPROCSがデフォルトで48 (=Node全体のコア数)になっていた。これにより以下の問題が発生: 過剰並列 : 5コアしか使えないのに48スレッドが走る → Goスケジューラのオーバーヘッド増大 CPUスロットリング : cgroupのクォータ(CPU時間の上限)をスレッドが共食い → 全スレッドが同時に停止 監視の死角 : CPU使用率は正常に見えるが、実際はスロットリングで断続的に停止 同じ問題がHAProxy( nbthread=48 、CPU制限1コア)でも発生していた。 これらをcgroup-awareな設定(GOMAXPROCS=5, nbthread=1)に修正したところ、大幅に改善した、という話でした。 用語の整理 ここから先で出てくる「コア」「GOMAXPROCS」「クォータ」「スロットリング」あたりがピンと来なくても大丈夫です。記事全体で繰り返し登場するので、最初にざっくり整理しておきます(すでに馴染みがある方はスキップでOK)。 CPUコア・プロセス・スレッド 用語 ざっくりした意味 CPUコア 計算を実行する物理的な実体。1コア = 同時に1つの処理を進められる プロセス 動いているプログラム1つ分の単位 スレッド プロセス内で実際にCPUに割り当てられる作業の単位。1プロセスは複数スレッドを持てる ざっくり言うと、 コアの数 = 同時に進められるスレッドの数の上限 です。8コアのCPUなら、ある一瞬に進行できるのは最大8スレッドまで。それ以上のスレッドを立ち上げた場合は、OSが順番にコアを割り当て直しながら回します(= コンテキストスイッチ)。 コンテナと cgroup 用語 ざっくりした意味 コンテナ 同じサーバー上で複数のアプリを互いに干渉しないように動かす仕組み(Docker や Kubernetes の中身)。実体はホストのカーネルをそのまま使う 「namespaces で見える範囲を、cgroup で使える量を制限したプロセス(群)」 にすぎず、VM のように専用カーネルを持つわけではない cgroup (Control Groups) Linuxカーネルの機能で「このプロセス群はCPUをここまで・メモリはここまで」と上限を設定する仕組み CPU制限 「このコンテナはCPU 1コア分まで」のような上限設定。実体は cgroup の cpu.max ファイル コンテナの「CPU 0.5コアまで」という設定は、Linuxカーネルが cgroup を通じて「100msのうち50msまでしかCPUを使わせない」という形で強制します。この 100msの枠を「ピリオド」、その中で使ってよい時間量を「クォータ」 と呼びます( cpu.max: 50000 100000 なら「100msのうち50ms使える = 0.5コア相当」)。 CFS スケジューラ Linux のデフォルトの CPU スケジューラを CFS(Completely Fair Scheduler) と呼びます。先ほどの「ピリオド」「クォータ」は、CFS が持つ 帯域制御(Bandwidth Controller) という機能の用語で、cgroup の cpu.max の値を実際にスレッドへ適用する(=クォータを使い切ったら停止させる)のはこの CFS の仕事です。 つまり「cgroup が制限値を持ち、CFS がそれを実施する」という分担関係。後の実験で出てくる nr_periods (CFS が時間を区切る単位の総数)や nr_throttled (CFS が停止させたピリオドの数)も、この CFS 帯域制御の統計を見ています。 Goroutine と GOMAXPROCS(Go特有の話) 用語 ざっくりした意味 goroutine Goの軽量スレッド。OSスレッドより遥かに軽く、1プロセスで数万〜数百万個立ち上げられる OSスレッド OSが実際にCPUにスケジュールするスレッド。コアを取り合うのはこちら GOMAXPROCS Goランタイムが同時に走らせるOSスレッドの数の上限。デフォルトはホストのCPUコア数 goroutine を何万個立ち上げても、Goランタイムは GOMAXPROCS 個の OSスレッドの上にそれらを多重化して実行します。つまり同時に CPU を握っているのは最大でも GOMAXPROCS 個。この割り当てを管理するのが Goスケジューラ です。 ポイントは、 コンテナのCPU制限が下がってもデフォルトの GOMAXPROCS はホストのCPU数のまま ということ。これがそもそも今回のテーマで、後の実験でその挙動を実際に確かめます。 過剰並列 CPU 制限よりも多くのスレッド(や goroutine、ワーカー)を同時に走らせている状態 を指します。たとえば 5 コア相当の CPU 制限に対して GOMAXPROCS=48 なら、約 9.6 倍の過剰並列。実際に走れるのは制限分のスレッドだけなので、残りはスケジューラの上で順番待ちをしつつ、共有クォータを早食いし合うことになります。 Go の GOMAXPROCS に限った話ではなく、HAProxy の nbthread 、Nginx の worker_processes 、Puma の workers など、 「並列数のデフォルトがホスト CPU 数に依存する」設定はすべて同じ構造で過剰並列を起こします 。 CPUスロットリング cgroupでCPU 0.5コア分に制限されたコンテナが、たくさんのスレッドでCPUを一気に使おうとすると、Linuxカーネルが 「クォータを使い切ったので、次のピリオドまで全スレッド一時停止」 と強制的にブロックします。これが CPUスロットリング です。 スロットリングが頻発すると、レスポンスが断続的に止まったり、スループットが落ちたりします。その結果、「なぜか遅延がスパイクする」原因になっているケースが多いです。発生状況は /sys/fs/cgroup/cpu.stat に出力されており、本記事では以下の3指標を追います: nr_periods : スケジューラの計測単位(ピリオド = 100ms)の総数 nr_throttled : そのうちスロットリングが起きたピリオドの数(回数) throttled_usec : スロットリングで実際にCPUが止められた累積時間(マイクロ秒) 「回数」だけでなく「 累積停止時間 」も見るのが重要だ、というのが発表の山場の一つで、後の実験3でその違いがハッキリ出ます。 Thundering Herd スロットリングで停止していた全スレッドが、 次のピリオドのリセットで一斉に走り出し、また一瞬でクォータを食い潰して同時に止まる 、というサイクルが繰り返される状態を 「Thundering Herd(雷鳴の群れ)」 と呼びます。元はソケット accept など I/O 文脈の用語ですが、cgroup の帯域制御下でも同じ構造の問題が起きます。スレッド数が多いほど被害が大きくなるのは、ここに端を発しています。実験4でその挙動を観察します。 cgroup-aware プログラムやライブラリが cgroup の制限( cpu.max など)を自分で読み取り、その値に合わせて並列度を調整する 設計のことを 「cgroup-aware」 と呼びます。Go 1.25 以降のランタイムや uber-go/automaxprocs は cgroup-aware に GOMAXPROCS を設定します。逆に Go 1.24 以前のように cgroup を見ずにホストの CPU 数だけ見る挙動は「cgroup-aware ではない」状態で、今回の過剰並列はそこから生まれています。 この記事で検証すること # 検証テーマ 発表でのポイント 1 GOMAXPROCSのデフォルト値 コンテナのCPU Limitを無視してホストのCPU数になる 2 過剰並列のパフォーマンス影響 GOMAXPROCSが大きすぎるとスループットが低下する 3 CPUスロットリングの発生 スレッド数が多いほどクォータを早く消費し、停止時間が増える 4 スレッド数とスロットリングの相関 スレッド数に比例して throttled_usec が増加する 1. ローカル環境構築(Mac) 前提条件 macOS (Apple Silicon / Intel 両対応) Docker Desktop がインストール済み なぜDockerで検証できるのか cgroup(Control Groups)は Linuxカーネルの機能 で、macOS 自体には存在しません。しかし Docker Desktop は内部で Linux VM を動かしており、コンテナはその Linux 上で動作します。 ┌─────────────────────────────────────────────┐ │ macOS │ │ ┌────────────────────────────────────────┐ │ │ │ Docker Desktop (Linux VM) │ │ │ │ ┌──────────────────────────────────┐ │ │ │ │ │ コンテナ │ │ │ │ │ │ /sys/fs/cgroup/cpu.max ← ここ! │ │ │ │ │ │ /sys/fs/cgroup/cpu.stat │ │ │ │ │ └──────────────────────────────────┘ │ │ │ └────────────────────────────────────────┘ │ └─────────────────────────────────────────────┘ Docker の --cpus フラグは Kubernetes の limits.cpu と同じく cgroup の cpu.max に変換されます。つまり Kubernetes と同じ仕組みをローカルで再現 できます。 Docker Kubernetes cgroup v2 --cpus=0.5 limits.cpu: 500m cpu.max: 50000 100000 --cpus=1.0 limits.cpu: 1000m cpu.max: 100000 100000 --cpus=5.0 limits.cpu: 5000m cpu.max: 500000 100000 セットアップ手順 Step 1: Docker Desktop のインストール Docker Desktop for Mac からインストール。 docker --version # Docker version 27.x.x, build xxxxxxx Step 2: 検証用 Go アプリケーション 本記事の検証コードは以下のリポジトリにまとめています: hirosi1900day/cgroup-throttling-lab git clone https://github.com/hirosi1900day/cgroup-throttling-lab.git cd cgroup-throttling-lab 3つのモードを持つGoアプリケーションを書きました。 モード 用途 info GOMAXPROCSの値とcgroupの設定を表示 benchmark CPU負荷をかけてスループットを計測 throttle-demo CPU負荷をかけてスロットリングの Before/After を表示 コード解説 各パートを順に見ていきます。 1. CPU負荷を発生させる関数 // cpuIntensiveWork はCPU負荷をかける計算処理 // 平方根と三角関数を1万回ループし、意図的にCPUを使い切る func cpuIntensiveWork() float64 { result := 0.0 for i := 0 ; i < 10000 ; i++ { result += math.Sqrt( float64 (i)) * math.Sin( float64 (i)) } return result } この関数が実験の要です。 math.Sqrt と math.Sin の計算を1万回繰り返すことで、 純粋なCPU負荷 を発生させます。I/O待ちが一切ないので、GOMAXPROCS(=ワーカースレッド数)の影響がダイレクトに現れます。 2. infoモード — GoランタイムとcgroupのCPU設定を表示 func showRuntimeInfo() { // runtime.GOMAXPROCS(0) は「現在の値を返し、変更しない」 // ← これがコンテナのCPU制限と一致しているかがポイント fmt.Printf( "GOMAXPROCS: %d \n " , runtime.GOMAXPROCS( 0 )) fmt.Printf( "NumCPU: %d \n " , runtime.NumCPU()) // --- cgroup のCPU制限を直接読む --- // /sys/fs/cgroup/cpu.max は cgroup v2 のCPU制限ファイル // 中身は "クォータ ピリオド" の形式(例: "100000 100000") // Kubernetes の limits.cpu や Docker の --cpus がここに反映される if data, err := os.ReadFile( "/sys/fs/cgroup/cpu.max" ); err == nil { fmt.Printf( "cpu.max: %s" , string (data)) } // /sys/fs/cgroup/cpu.stat はCPUスロットリングの統計情報 // nr_periods: CFSスケジューラのピリオド(100ms)の総数 // nr_throttled: スロットリングが発生したピリオドの数 // throttled_usec: スロットリングでCPUが停止した累積時間(μs) if data, err := os.ReadFile( "/sys/fs/cgroup/cpu.stat" ); err == nil { fmt.Printf( "cpu.stat: \n %s" , string (data)) } } このモードでは、 GoランタイムがcgroupのCPU制限を認識しているか を見ます。Go 1.24以前では、 GOMAXPROCS がホストのCPU数のままなのが確認できるはずです。 3. benchmarkモード — スループットの計測 func runBenchmark() { // 環境変数でベンチマーク時間とgoroutine数を制御可能にしている duration := 10 * time.Second // BENCH_DURATION で変更可 goroutines := 100 // BENCH_GOROUTINES で変更可 // --- ここからが計測のコア --- var totalOps atomic.Int64 // goroutine間で安全にカウントを共有 var wg sync.WaitGroup // 全goroutineの完了を待つ // タイマーで終了を通知するチャネル done := make ( chan struct {}) go func () { <-time.After(duration) close (done) // ← 全goroutineに「終了」を伝える }() // goroutines個のgoroutineを起動し、それぞれが独立にCPU負荷をかける // これらのgoroutineは GOMAXPROCS 個のワーカースレッドに // Goスケジューラによって割り当てられる for i := 0 ; i < goroutines; i++ { wg.Add( 1 ) go func () { defer wg.Done() localOps := int64 ( 0 ) // goroutineローカルでカウント(競合を避ける) for { select { case <-done: totalOps.Add(localOps) // 最後にまとめて加算 return default : cpuIntensiveWork() // CPU負荷をかけ続ける localOps++ } } }() } wg.Wait() // Ops/sec = 単位時間あたりの処理回数 // この値が高いほどスループットが良い opsPerSec := float64 (totalOps.Load()) / elapsed.Seconds() } 100個のgoroutineが cpuIntensiveWork() を呼び続け、それらがGOMAXPROCS個のOSスレッド上でスケジュールされる構造。CPU制限がある環境では、スレッドが多いほどcgroupのクォータを早く使い切る、スロットリングでスループットが落ちるわけです。 (脱線)ベンチマークコードの工夫 — キャッシュコヒーレンシの話 cgroup の検証とは直接関係ないですが、このベンチマークコードには「計測自体が結果を歪めないための工夫」が入っています。せっかくなので解説します。 select + default でノンブロッキングに終了チェックしつつCPU処理を回し続ける、というのはGoの定番パターンなので軽く触れるだけにして、本題はカウンタの設計です。 localOps := int64(0) // goroutineローカル(普通のint) for { select { case <-done: totalOps.Add(localOps) // ← 終了時に1度だけatomic操作 return default: cpuIntensiveWork() localOps++ // ← 普通のインクリメント。超高速 } } ループ内では localOps++ (普通の int インクリメント)だけを使い、終了時に1度だけ totalOps.Add(localOps) ( atomic 操作)で合算しています。 「毎回 totalOps.Add(1) でいいのでは?」と思うかもしれませんが、それだと100個の goroutine が同じメモリアドレスに毎ループ書き込み合い、 キャッシュコヒーレンシ(Cache Coherency) のオーバーヘッドで性能が大きく落ちます。 キャッシュコヒーレンシとは まず前提として、CPUがデータにアクセスする仕組みを整理しておきます。 CPU のメモリ階層 CPUが変数やデータを読み書きするとき、毎回メインメモリ(DRAM)まで取りに行くのは遅すぎます。そこで CPUは メモリ階層(Memory Hierarchy) という多段のキャッシュ構造を持っています: ┌─────────────────────────────────────────────────┐ │ CPU コア │ │ ┌───────────┐ │ │ │ レジスタ │ ← 最速(~0.3ns)、数十〜数百個 │ │ └─────┬─────┘ │ │ ┌─────┴─────┐ │ │ │ L1 キャッシュ│ ← 32〜64KB / コア、~1ns │ │ │ (データ+命令)│ │ │ └─────┬─────┘ │ │ ┌─────┴─────┐ │ │ │ L2 キャッシュ│ ← 256KB〜1MB / コア、~3-10ns │ │ └─────┬─────┘ │ │ │ ┌──────────┐ │ │ │ │ TLB │ ← 仮想→物理アドレス │ │ │ │ │ 変換のキャッシュ │ │ │ └──────────┘ │ └────────┼────────────────────────────────────────┘ ┌─────┴─────┐ │ L3 キャッシュ│ ← 数MB〜数十MB、全コア共有、~10-30ns └─────┬─────┘ ┌─────┴──────────────────┐ │ メインメモリ(DRAM) │ ← 数GB〜数百GB、~50-100ns └─────┬──────────────────┘ ┌─────┴──────────────────┐ │ ストレージ(SSD/HDD) │ ← ~10,000ns (SSD) 〜 10,000,000ns (HDD) └───────────────────────┘ 階層 容量 レイテンシ 特徴 レジスタ 数百バイト ~0.3ns CPUが直接演算する場所 L1キャッシュ 32〜64KB/コア ~1ns データ用と命令用に分離。コアごとに専有 L2キャッシュ 256KB〜1MB/コア ~3-10ns コアごとに専有(アーキテクチャによる) L3キャッシュ 数MB〜数十MB ~10-30ns 全コア共有 。ここがコア間のデータの橋渡し TLB 数百〜数千エントリ ~1ns(ヒット時) 仮想アドレス→物理アドレスの変換キャッシュ メインメモリ 数GB〜 ~50-100ns L1の50〜100倍遅い CPUが localOps++ を実行するとき、その変数がレジスタや L1 にあれば 1ns 以下で完了します。しかし L1 にない(キャッシュミス)と L2 → L3 → メインメモリと順にたどる必要があり、最悪100nsかかる。 L1ヒットとメインメモリアクセスでは約100倍の速度差 があるわけです。 TLB(Translation Lookaside Buffer) は少し役割が違って、仮想メモリのアドレス変換を高速化するキャッシュです。プロセスが使うメモリアドレス(仮想アドレス)を実際の物理アドレスに変換するにはページテーブルを引く必要がありますが、毎回引くとメモリアクセスが2倍になるので、よく使う変換結果を TLB にキャッシュしています。TLB ミスが発生すると ページテーブルウォーク が走り、数十nsの追加コストがかかります。goroutine が大量のスタックやヒープを使うワークロードでは、TLB ミスもパフォーマンスに効いてきます。 この前提を踏まえると、マルチコアでのキャッシュ一貫性がなぜ重要かがわかります。 キャッシュコヒーレンシ問題 マルチコアCPUでは、各コアが独自の L1/L2キャッシュ を持っています。あるコアが変数を更新すると、他のコアが持つ同じ変数のキャッシュラインは 古い値(stale) になります。これを放置すると各コアが異なる値を見てしまうため、ハードウェアレベルで一貫性を保つ仕組みが必要です。これが キャッシュコヒーレンシプロトコル (代表的なものに MESI プロトコル )です。 MESI プロトコルでは、キャッシュラインは以下の4状態を遷移します: 状態 意味 M odified 自コアだけが変更済みの値を持つ E xclusive 自コアだけが持っているが、メモリと同じ値 S hared 複数コアが同じ値を持っている(読み取り専用) I nvalid 他コアが更新したので、このキャッシュラインは無効 atomic 変数への書き込みが発生すると: 書き込むコアがキャッシュラインの 排他的所有権 を要求 他の全コアの同じキャッシュラインが Invalid に変わる(無効化) 次にそのコアが同じ変数にアクセスすると、 キャッシュミス → メモリ(or 他コアのキャッシュ)から再取得 これが毎ループ・100 goroutine で発生すると: [NG] 毎回 atomic(キャッシュラインのピンポン) コア1: totalOps.Add(1) → キャッシュライン取得 (Exclusive) → 値を更新 (Modified) → 他の全コアのキャッシュラインが Invalid に コア2: totalOps.Add(1) → Invalid なので再取得が必要(キャッシュミス!) → コア1から転送 → Exclusive → Modified → 他の全コアのキャッシュラインが Invalid に コア3: totalOps.Add(1) → また Invalid → また再取得...(以下ピンポン状態) → 実際のCPU計算ではなく、キャッシュの同期にCPU時間が消える このキャッシュラインの奪い合いは 「キャッシュラインバウンシング」 や 「false sharing」 (同じキャッシュラインに別の変数が乗っている場合)とも呼ばれ、マルチスレッドプログラミングの有名なパフォーマンス落とし穴です。 一方、ローカルカウンタなら: [OK] ローカルカウンタ + 最後に1回だけ atomic コア1: localOps++ → 自コアのレジスタ or L1キャッシュだけ。他コアに影響なし コア2: localOps++ → 同上。各goroutineが独立したメモリを触る ... (終了時だけ totalOps.Add → atomic 操作は10秒間で合計たった100回) 「 ベンチマークそのもののコストでベンチマーク結果が歪む 」のを防ぐテクニックです。cgroup のスロットリングを正確に測るなら、計測のオーバーヘッドは極力削っておきたい。 4. throttle-demoモード — スロットリングの観測 func runThrottleDemo() { // GOMAXPROCS個のワーカーを起動(= OSスレッド数と一致させる) numWorkers := runtime.GOMAXPROCS( 0 ) // Before: スロットリング前の統計を記録 // cpu.stat の nr_throttled, throttled_usec を確認 fmt.Println( "--- Before ---" ) readCgroupStat() // numWorkers個のgoroutineでCPU負荷をかける // GOMAXPROCS=8 なら8本、GOMAXPROCS=1 なら1本 // → スレッド数の違いがスロットリングにどう影響するかを観測 for i := 0 ; i < numWorkers; i++ { go func () { for { cpuIntensiveWork() // 全スレッドでCPU全開 } }() } // 5秒間 CPU負荷をかけた後... // After: スロットリング後の統計を記録 // Before との差分が「この5秒間で発生したスロットリング」 fmt.Println( "--- After ---" ) readCgroupStat() } GOMAXPROCS の値がそのままワーカー数になります。GOMAXPROCS=8 なら8スレッドが同時にCPUを使おうとするので、共有クォータを一瞬で食い潰します。Before/After の throttled_usec の差分で、 実際にどれだけCPUが止められたか がわかります。 Dockerfile # ビルドステージ: Go 1.24 でコンパイル FROM golang:1.24-bookworm AS builder WORKDIR /app COPY go.mod ./ COPY main.go ./ RUN go build -o /app/cgroup-bench . # 実行ステージ: 軽量なイメージで実行 FROM debian:bookworm-slim COPY --from=builder /app/cgroup-bench /usr/local/bin/cgroup-bench ENTRYPOINT ["cgroup-bench"] CMD ["info"] Go バージョンについて Go の最新安定版 : 1.26.3(2026年5月時点) container-aware GOMAXPROCS が導入されたバージョン : Go 1.25 本記事で使うバージョン : Go 1.24(1.25直前の最終版) Go 1.25以降ではランタイムがcgroupの cpu.max を自動で読み取り、GOMAXPROCSをCPU制限に合わせて設定します。今回は 問題が発生していた当時の挙動を再現 するため、あえてGo 1.24を使用しています。 main.go 全文(クリックで展開) ```go package main import ( "encoding/json" "fmt" "math" "os" "runtime" "strconv" "sync" "sync/atomic" "time" ) type Result struct { GOMAXPROCS int `json:"gomaxprocs"` NumCPU int `json:"num_cpu"` CPULimit string `json:"cpu_limit"` Duration time.Duration `json:"duration_ns"` DurationStr string `json:"duration"` TotalOps int64 `json:"total_ops"` OpsPerSec float64 `json:"ops_per_sec"` GoroutineCount int `json:"goroutine_count"` } func cpuIntensiveWork() float64 { result := 0.0 for i := 0; i < 10000; i++ { result += math.Sqrt(float64(i)) * math.Sin(float64(i)) } return result } func main() { mode := "benchmark" if len(os.Args) > 1 { mode = os.Args[1] } switch mode { case "benchmark": runBenchmark() case "info": showRuntimeInfo() case "throttle-demo": runThrottleDemo() } } func showRuntimeInfo() { fmt.Println("=== Go Runtime Information ===") fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0)) fmt.Printf("NumCPU: %d\n", runtime.NumCPU()) fmt.Printf("GOVERSION: %s\n", runtime.Version()) envGOMAXPROCS := os.Getenv("GOMAXPROCS") if envGOMAXPROCS == "" { fmt.Println("ENV GOMAXPROCS: (not set — using default)") } else { fmt.Printf("ENV GOMAXPROCS: %s\n", envGOMAXPROCS) } fmt.Println("\n=== cgroup CPU Information ===") if data, err := os.ReadFile("/sys/fs/cgroup/cpu.max"); err == nil { fmt.Printf("cpu.max: %s", string(data)) } if data, err := os.ReadFile("/sys/fs/cgroup/cpu.weight"); err == nil { fmt.Printf("cpu.weight: %s", string(data)) } if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("cpu.stat:\n%s", string(data)) } } func runBenchmark() { duration := 10 * time.Second if d := os.Getenv("BENCH_DURATION"); d != "" { if parsed, err := time.ParseDuration(d); err == nil { duration = parsed } } goroutines := 100 if g := os.Getenv("BENCH_GOROUTINES"); g != "" { if parsed, err := strconv.Atoi(g); err == nil { goroutines = parsed } } maxprocs := runtime.GOMAXPROCS(0) var totalOps atomic.Int64 var wg sync.WaitGroup done := make(chan struct{}) go func() { <-time.After(duration) close(done) }() start := time.Now() for i := 0; i < goroutines; i++ { wg.Add(1) go func() { defer wg.Done() localOps := int64(0) for { select { case <-done: totalOps.Add(localOps) return default: cpuIntensiveWork() localOps++ } } }() } wg.Wait() elapsed := time.Since(start) ops := totalOps.Load() opsPerSec := float64(ops) / elapsed.Seconds() fmt.Printf("GOMAXPROCS=%d Ops/sec=%.2f Total=%d\n", maxprocs, opsPerSec, ops) jsonData, _ := json.Marshal(Result{ GOMAXPROCS: maxprocs, OpsPerSec: opsPerSec, TotalOps: ops, }) fmt.Printf("JSON: %s\n", string(jsonData)) if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("\ncpu.stat:\n%s", string(data)) } } func runThrottleDemo() { fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0)) fmt.Println("\n--- Before ---") if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("%s", string(data)) } numWorkers := runtime.GOMAXPROCS(0) duration := 5 * time.Second if d := os.Getenv("DEMO_DURATION"); d != "" { if parsed, err := time.ParseDuration(d); err == nil { duration = parsed } } var wg sync.WaitGroup stop := make(chan struct{}) go func() { <-time.After(duration) close(stop) }() for i := 0; i < numWorkers; i++ { wg.Add(1) go func() { defer wg.Done() for { select { case <-stop: return default: cpuIntensiveWork() } } }() } wg.Wait() fmt.Println("\n--- After ---") if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil { fmt.Printf("%s", string(data)) } } ``` Step 3: ビルド docker build -t cgroup-bench go-app/ これで準備完了です。 2. 実験と結果 検証環境: - macOS(Apple Silicon) - Docker Desktop - Docker VM: 11コア (ここがKubernetesの「48コアNode」に相当) 実験1: GOMAXPROCS はコンテナの CPU 制限を無視する 何を確認するか 発表では、コンテナの limits.cpu: 5000m に対して GOMAXPROCS が 48(ノードのコア数)になっていたことが、問題の発端でした。まずは、 GoランタイムがcgroupのCPU制限を見ていない という状態をローカルで確認します。 実行コマンド # A: CPU制限なし docker run --rm cgroup-bench info # B: CPU制限 1コア docker run --rm --cpus=1.0 cgroup-bench info # C: CPU制限 0.5コア docker run --rm --cpus=0.5 cgroup-bench info 実際の結果 A: CPU制限なし === Go Runtime Information === GOMAXPROCS: 11 ← Docker VMの全CPUコア数 NumCPU: 11 GOVERSION: go1.24.13 ENV GOMAXPROCS: (not set — using default) === cgroup CPU Information === cpu.max: max 100000 ← "max" = 上限なし B: CPU制限 1コア( --cpus=1.0 ) === Go Runtime Information === GOMAXPROCS: 11 ← 制限をかけたのに11のまま! NumCPU: 11 GOVERSION: go1.24.13 ENV GOMAXPROCS: (not set — using default) === cgroup CPU Information === cpu.max: 100000 100000 ← cgroupには1コア分の制限が正しく設定されている C: CPU制限 0.5コア( --cpus=0.5 ) === Go Runtime Information === GOMAXPROCS: 11 ← まだ11のまま! NumCPU: 11 === cgroup CPU Information === cpu.max: 50000 100000 ← cgroupには0.5コア分の制限が設定されている 結果を見てみる CPU制限 cpu.max(cgroup) GOMAXPROCS 過剰並列の倍率 なし max 100000 (無制限) 11 - 1コア 100000 100000 11 11倍 0.5コア 50000 100000 11 22倍 完全に無視してます。cgroupには cpu.max として正しくCPU制限が設定されているのに、 Go 1.24のランタイムは一切見ていない 。GOMAXPROCSは常にホスト(Docker VM)のCPU数=11がデフォルト。 発表の本番環境では48コアNodeで limits.cpu: 5000m だったので、 GOMAXPROCS=48(約10倍の過剰並列) が起きていた。ローカルでも同じ構造の問題を再現できました。 cpu.max の読み方 : クォータ ピリオド の形式。ピリオド(デフォルト100ms=100000μs)のうち、クォータ分だけCPUを使える。 50000 100000 なら「100msのうち50ms使用可能 = 0.5コア分」。 実験2: 過剰並列はスループットを低下させる 何を確認するか 発表では GOMAXPROCS を48→5に変えたらスループットが大幅改善、Goスケジューラの CPU使用率が50%以上減ったとのこと。同じ体験をローカルでも数字で見てみます。 実行コマンド CPU制限1コアの環境で、100個のgoroutineを10秒間走らせます。変えるのはGOMAXPROCSだけ。 # GOMAXPROCS=1(CPU制限に一致 = 適切) docker run --rm --cpus=1.0 \ -e GOMAXPROCS=1 -e BENCH_DURATION=10s -e BENCH_GOROUTINES=100 \ cgroup-bench benchmark # GOMAXPROCS=8(CPU制限の8倍 = 過剰並列) docker run --rm --cpus=1.0 \ -e GOMAXPROCS=8 -e BENCH_DURATION=10s -e BENCH_GOROUTINES=100 \ cgroup-bench benchmark 実際の結果 GOMAXPROCS=1(適切な並列数): GOMAXPROCS=1 Ops/sec=21503.63 Total=215525 cpu.stat: nr_periods 101 nr_throttled 56 throttled_usec 43791 GOMAXPROCS=8(過剰並列): GOMAXPROCS=8 Ops/sec=6832.54 Total=68646 cpu.stat: nr_periods 102 nr_throttled 101 throttled_usec 70703432 結果を見てみる 指標 GOMAXPROCS=1 GOMAXPROCS=8 差分 Ops/sec(スループット) 21,504 6,833 68.2% 低下 nr_throttled / nr_periods 56/101 (55%) 101/102 ( 99% ) ほぼ全ピリオドで停止 throttled_usec(累積停止時間) 43,791μs (0.04秒) 70,703,432μs (70.7秒) 1,614倍 正直、ここまで差が出るとは思っていませんでした。 GOMAXPROCS を1→8にするだけで、 スループットが約3分の1に落ちる 10秒間のテストで 累計70.7秒ものCPU停止 (8スレッドが各約8.8秒ずつ止まった計算) スロットリング率99% — ほぼ毎ピリオドで全スレッドが止められている 発表で説明されていた「 クォータをスレッドが共食いする 」現象そのものです。 ┌──────── 1ピリオド (100ms) ────────┐ GOMAXPROCS=1 の場合: [████████ 実行 ████████][░░ 停止 ░░] ← 1スレッドで穏やかに使う GOMAXPROCS=8 の場合: [█ 8スレッド一斉実行 █][░░░░░░░░░░░░░░░░░░░░░░ 長時間停止 ░░░░░░░░░░░░░░░░░░░░░░] ↑ クォータ枯渇 ↑ 全スレッドが同時にスロットリング 実験3: スロットリングの深刻度はスレッド数で変わる 何を確認するか 発表で「 時間も見れば、ピリオドの%が同じでも深刻度の違いが分かる 」と指摘されていました。これ、実際に nr_throttled (回数)は同じくらいなのに throttled_usec (停止時間)には大きな差が出るということなので、自分の目で見てみます。 実行コマンド CPU制限0.5コア(かなり厳しい制限)でGOMAXPROCS=8 vs 1 を比較。 # 過剰並列(GOMAXPROCS=8, CPU=0.5コア) docker run --rm --cpus=0.5 -e GOMAXPROCS=8 -e DEMO_DURATION=5s cgroup-bench throttle-demo # 適切な並列(GOMAXPROCS=1, CPU=0.5コア) docker run --rm --cpus=0.5 -e GOMAXPROCS=1 -e DEMO_DURATION=5s cgroup-bench throttle-demo 実際の結果 GOMAXPROCS=8(過剰並列): --- After --- nr_periods 52 nr_throttled 51 ← 98%のピリオドでスロットリング throttled_usec 39039180 ← 39秒のCPU停止 GOMAXPROCS=1(適切): --- After --- nr_periods 51 nr_throttled 50 ← 98%のピリオドでスロットリング(ほぼ同じ!) throttled_usec 2644221 ← 2.6秒のCPU停止 結果を見てみる 指標 GOMAXPROCS=8 GOMAXPROCS=1 差分 nr_throttled / nr_periods 51/52 (98%) 50/51 (98%) ほぼ同じ throttled_usec 39,039,180μs (39秒) 2,644,221μs (2.6秒) 14.8倍 数字を自分で並べてみて、初めて深刻さがわかりました。 nr_throttled の割合(スロットリング率)だけ見るとどっちも98%で全く同じに見えます。でも throttled_usec (実際の停止時間)には14.8倍もの差がある。 これが発表で言われていた「CPU使用率だけでは気づけない」「監視の死角」の正体です。 なぜCPU使用率では見えないのか ここをもう少し掘り下げます。実はこの実験、 どちらのケースもCPU使用率は約100% と表示されます。「え、GOMAXPROCS=8 のほうが遅いのにCPU使用率は同じ?」と思うかもしれませんが、これにはカラクリがあります。 CPU使用率の計算式は本質的にこうです: $$ \text{CPU使用率} = \frac{\text{消費したCPU時間}}{\text{割り当てクォータ}} $$ 今回の実験では --cpus=0.5 なので、1ピリオド(100ms)あたりのクォータは 50ms です。 GOMAXPROCS=1 GOMAXPROCS=8 クォータ 50ms / period 50ms / period 消費CPU時間 50ms(使い切る) 50ms(使い切る) CPU使用率 ≈100% ≈100% 消費ペース 1スレッド × 50ms = 50msかけて徐々に 8スレッド × 6.25ms = 約6msで一気に 残りの時間 50ms間は停止(穏やか) 94ms間 全スレッド凍結 どちらもクォータ50msを使い切るので、CPU使用率は同じ100%です。しかし 消費のペースがまるで違います 。 1ピリオド(100ms)の内訳: GOMAXPROCS=1: |███████████████████████████░░░░░░░░░░░░░░░░░░░| ← 1スレッドで50ms実行 →← 50ms 待機 → CPU使用率: 50/50 = 100% レイテンシ: 安定 GOMAXPROCS=8: |████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░| ←6ms→←───────── 94ms 全スレッド凍結 ──────────→ CPU使用率: 50/50 = 100% レイテンシ: スパイク発生 GOMAXPROCS=1 は1スレッドで50msを穏やかに消費するので、処理は途切れつつも比較的均等に進みます。一方 GOMAXPROCS=8 は8スレッドが同時にCPUを要求するため、わずか約6msでクォータを食い尽くし、 残りの94msは全スレッドが完全に凍結 します。 つまりCPU使用率100%の裏で起きていることが全く異なるのに、 集約メトリクスではその違いが消えてしまう 。これが「監視の死角」の本質です。 まとめると: nr_throttled率が同じでも、 8スレッドが同時に止まる のと 1スレッドだけ止まる のでは深刻度がまるで違う CPU使用率は クォータを消費した量 しか示さず、 消費のペース(バースト性)を一切反映しない throttled_usec を合わせて監視しないと、スロットリングの実態はつかめない 実験4: スレッド数と停止時間の相関 何を確認するか スレッド数を段階的に増やしたとき、 throttled_usec が比例して増えるのか。発表スライドの「 クォータはスレッド間で共有 」「 スレッドが多いほど早く消費 」という説明を、グラフで体感してみます。 実行コマンド for MAXPROCS in 1 2 4 8 16; do echo "--- GOMAXPROCS=$MAXPROCS ---" docker run --rm --cpus=1.0 -e GOMAXPROCS=$MAXPROCS -e DEMO_DURATION=5s \ cgroup-bench throttle-demo 2>&1 \ | grep -E "nr_periods|nr_throttled|throttled_usec" | tail -3 echo "" done 実際の結果 --- GOMAXPROCS=1 --- nr_periods 51 nr_throttled 20 throttled_usec 21885 --- GOMAXPROCS=2 --- nr_periods 51 nr_throttled 50 throttled_usec 5075001 --- GOMAXPROCS=4 --- nr_periods 51 nr_throttled 50 throttled_usec 15053306 --- GOMAXPROCS=8 --- nr_periods 52 nr_throttled 51 throttled_usec 35847841 --- GOMAXPROCS=16 --- nr_periods 51 nr_throttled 51 throttled_usec 50338309 結果を見てみる GOMAXPROCS nr_throttled スロットリング率 throttled_usec 累積停止時間 1 20 / 51 39% 21,885 0.02秒 2 50 / 51 98% 5,075,001 5.1秒 4 50 / 51 98% 15,053,306 15.1秒 8 51 / 52 98% 35,847,841 35.8秒 16 51 / 51 100% 50,338,309 50.3秒 停止時間 (秒) 50 ┤ ● GOMAXPROCS=16 │ ╱ 40 ┤ ╱ │ ╱ 35 ┤ ● ╱ GOMAXPROCS=8 │ ╱ ╱ 20 ┤ ╱ │ ╱ 15 ┤ ● ╱ GOMAXPROCS=4 │ ╱ ╱ 10 ┤ ╱ │ ╱ 5 ┤ ● ╱ GOMAXPROCS=2 │ ╲╱ 0 ┤● GOMAXPROCS=1 └──┬──┬──┬──┬──┬──┬── 1 2 4 8 12 16 GOMAXPROCS GOMAXPROCS=1〜8の範囲ではほぼ線形に比例しています。 GOMAXPROCS=1 → 0.02秒(ほぼ停止なし) GOMAXPROCS=16 → 50.3秒(5秒のテストで累計50秒分の停止!) ただし GOMAXPROCS=16 では、同時にCPUを使えるスレッド数が Docker VM の物理CPU数(11コア)で頭打ちになるため、純粋な線形モデルの予測(75秒)より低い50.3秒に飽和しています。16スレッド中、同時に実行できるのは最大11スレッドなので、停止時間は $\min(n, 11) \times P - Q$ に近づきます。 GOMAXPROCS=8 以下では物理CPU数の制約を受けないため、きれいに $n \times P - Q$ の線形モデルと一致しています(8スレッド時の予測35秒 vs 実測35.8秒)。 発表でいう Thundering Herd 問題 そのもので、クォータリセットで全スレッドが一斉に再開 → 共有クォータを瞬殺 → 全スレッド同時停止、のサイクルが繰り返される。 3. 考察: なぜスレッドを増やすと「遅くなる」のか 実験4の結果を改めて見ると、 throttled_usec がスレッド数にほぼ比例して増えています。「スレッドを増やすほど損をする」って、直感に反しますが、CFS 帯域制御の仕組みから数式で説明できます。 CFS 帯域制御の数理 — クォータ消費のモデル cgroup の CPU 制限は「1ピリオド(100ms)あたり $Q$ だけ CPU を使える」というクォータ制です。 --cpus=1.0 なら $Q = 100\text{ms}$ です。 $n$ 本のスレッドが同時にフル稼働すると、CPU 時間は $n$ 倍の速度で消費されます。つまり: クォータ枯渇までの時間 : $\frac{Q}{n}$ 残りのピリオド : 全 $n$ スレッドが同時に停止 1スレッドあたりの停止時間 : $\text{ピリオド} - \frac{Q}{n}$ 累積停止時間 (= throttled_usec ): $n \times \left(\text{ピリオド} - \frac{Q}{n}\right)$ -cpus=1.0 ($Q = 100\text{ms}$、ピリオド $= 100\text{ms}$)で $n = 8$ の場合: クォータ枯渇: $\frac{100}{8} = 12.5\text{ms}$ で使い切る 各スレッドの停止: $100 - 12.5 = 87.5\text{ms}$ 1ピリオドあたりの累積停止: $8 \times 87.5 = 700\text{ms}$ 5秒間(50ピリオド)なら $50 \times 700\text{ms} = 35\text{秒}$。実験4の GOMAXPROCS=8 の結果(35.8秒)とほぼ一致します。 USL で見るとスループット悪化も説明がつく この現象をスケーリング法則の観点から見ると、Neil Gunther の USL(Universal Scalability Law) が当てはまります: $$ S(n) = \frac{n}{1 + \alpha(n-1) + \beta \cdot n(n-1)} $$ パラメータ 意味 cgroup 環境での具体例 $\alpha$ 競合 — 直列化ペナルティ Go スケジューラのロック競合、ランキュー管理 $\beta$ 一貫性 — スレッド間の協調コスト CFS クォータの共有消費 + 全スレッド一斉停止 USL で効いてくるのは $\beta$ の項です。$\beta \cdot n(n-1)$ は $n 2 $ オーダーで増大するため、ある閾値を超えるとスループットが ピークから減少に転じます 。実験2の「GOMAXPROCS=8 で 68% 低下」は、この retrograde(逆行)領域に入った結果です。 スループット ↑ ● ピーク(GOMAXPROCS=1) │ ╱╲ │ ╱ ╲ ← USL の retrograde 領域 │ ╱ ╲ │╱ ╲ ● GOMAXPROCS=8(68%低下) └──────────→ スレッド数 cgroup制限下では「スレッド1本」がピーク。 増やすほどクォータの奪い合いで損をする。 通常の並列プログラミングでは「コア数まではスケールする」のが常識ですが、cgroup でリソースが制限された環境では スレッド1本がすでに最適解 という直感に反する結果になる。CPU 時間の総量が固定されたゼロサム環境なので、スレッドを増やすほど「クォータの奪い合い → 一斉停止 → 再開 → また枯渇」のサイクルが重くなるだけ。 「I/O 待ちがある場合はどうなのか?」 ここまでの実験は cpuIntensiveWork() による 純粋な CPU バウンド処理 です。「I/O 待ちがあるならスレッドを増やしたほうがいいんじゃ?」と思いますよね。一般論としてはその通りで、スレッドが I/O で待っている間は CPU クォータを消費しないので、CPU 数より多いスレッドが有効な場面はあります。 ただし Go の場合は話が別 です。Goランタイムには以下の仕組みがあるので、GOMAXPROCS を I/O のために増やす必要は基本的にないです: I/O の種類 Goランタイムの挙動 GOMAXPROCS への影響 ネットワーク I/O netpoller が非同期処理。goroutine は待つが OS スレッドはブロックしない 影響なし ブロッキング syscall (ファイル I/O, CGO 等) ランタイムが GOMAXPROCS とは 別に追加の OS スレッドを自動生成 影響なし つまり Go では、ネットワーク I/O は goroutine レベルで多重化され、ブロッキング I/O は GOMAXPROCS の枠外で処理される。GOMAXPROCS が制御するのは CPU を実際に使うスレッドの数 だけなので、I/O の多寡に関わらず GOMAXPROCS = CPU 制限 が正解です。 DB クエリや API 呼び出しを大量に行う Web サービスでも、GOMAXPROCS=5(CPU 制限に一致)で大幅に改善した事例があるのは、この仕組みがあるからです。 一方、Go 以外のランタイムでは、それぞれ事情が違うので整理しておきます: ランタイム 並列の仕組み cgroup の影響 Java スレッドプール(ForkJoinPool 等)で並列化。 Runtime.availableProcessors() の値を基準にプールサイズが決まることが多い スレッドプールサイズを CPU limit より大きい値にするとスロットリング発生 Node.js メインスレッドはシングル。 UV_THREADPOOL_SIZE (デフォルト4)で fs/dns 等のブロッキング I/O を処理。CPU バウンド処理は worker_threads で並列化 worker_threads の数を CPU limit より大きい値にするとスロットリング発生 Ruby (CRuby) GVL(Global VM Lock)があるため、スレッドを増やしても CPU バウンド処理は並列実行されない 。Puma 等の Web サーバーは workers (fork によるマルチプロセス)で並列化 Puma の workers を CPU limit より大きい値にするとスロットリング発生 4. 発表の内容をローカルで再現できたか? 全検証結果まとめ # 発表のポイント ローカル検証の結果 再現 1 GOMAXPROCSはcgroupのCPU制限を考慮しない(Go 1.24以前) --cpus=0.5 でも GOMAXPROCS=11(ホストCPU数)のまま 再現 2 limits.cpu は cgroup の cpu.max (クォータ/ピリオド)に変換される --cpus=0.5 → cpu.max: 50000 100000 を確認 再現 3 過剰並列はスループットを低下させる GOMAXPROCS 1→8 で Ops/sec が 68.2% 低下 (21,504 → 6,833) 再現 4 クォータはスレッド間で共有され、多いほど早く消費される スレッド数と throttled_usec がほぼ線形に比例 再現 5 スロットリング時は全スレッドが同時に停止する GOMAXPROCS=16で5秒間に累計50.3秒分の停止を確認 再現 6 CPU使用率だけではスロットリングに気づけない nr_throttled 率は同じ98%でも throttled_usec に14.8倍の差 再現 7 GOMAXPROCSをCPU制限に合わせると改善する GOMAXPROCS=1 で停止時間が 1/1,614 に改善 再現 すべて手元で再現できました。 Docker と Go 1.24 だけでここまで体験できるのは、やってみてよかったと素直に思います。 個人的に印象に残った数字 比較 過剰並列の場合 適切な並列の場合 倍率 Ops/sec(スループット) 6,833 21,504 3.1倍の差 throttled_usec(停止時間) 70.7秒 0.04秒 1,614倍の差 GOMAXPROCS=16の停止時間 50.3秒 - 5秒のテストで50秒停止 本番環境との対応関係 発表 ローカル検証 48コアNode 11コア Docker VM limits.cpu: 5000m --cpus=0.5 GOMAXPROCS=48(デフォルト) GOMAXPROCS=11(デフォルト) 過剰並列倍率: 約10倍 過剰並列倍率: 最大22倍 /sys/fs/cgroup/cpu.max 同じパス(Docker内Linux) cpu.stat の nr_throttled 同じメトリクス 本番ではさらにHAProxy( nbthread=48 , CPU制限1コア = 48倍の過剰並列 )でも同じ問題が起きていたそうで、Goに限った話ではないということがわかります。 まとめ 1. 並列設定を cgroup-aware にする GOMAXPROCS に限らず、 コンテナ内で動くすべてのプロセスの並列設定 は確認したほうがいいです。 ソフトウェア 並列設定 対処 Go GOMAXPROCS Go 1.25+ で自動対応 / 1.24以前は uber-go/automaxprocs Ruby (Puma) WEB_CONCURRENCY / workers CPU制限に合わせて明示指定。cgroup非対応の auto 設定に注意 Java スレッドプールサイズ JDK 10+ は availableProcessors() が cgroup 認識。ライブラリ側も要確認 Node.js worker_threads 数 CPU バウンド処理の並列数を CPU 制限に合わせる HAProxy nbthread 手動でCPU制限に合わせて設定 Nginx worker_processes auto はcgroup非対応の場合あり、明示指定が安全 2. スロットリングを監視する CPU使用率だけじゃなくて、 スロットリングのメトリクスもセットで見る 。これを怠ると実験3で見たような死角にハマります。 メトリクス Linux Datadog 停止時間 throttled_usec kubernetes.cpu.cfs.throttled.seconds 停止ピリオド数 nr_throttled kubernetes.cpu.cfs.throttled.periods 3. throttled_usec まで見る 今回の実験を通して一番の収穫は、 nr_throttled の割合が同じ 98% でも、 throttled_usec に 14.8倍の差がある と自分の手で確認できたこと。スロットリング率だけ見ても、 実際にどれだけ止まっているかは見えない 。 CPUをもっと知りたくなった方へ — 個人的なおすすめ本 今回の検証を通して「もっとCPUの中身を理解したくなった」という方に、個人的に強くおすすめしたい一冊があります。 「プログラマーのためのCPU入門 — CPUは如何にしてソフトウェアを高速に実行するか」 パイプライン、スーパースカラ、分岐予測、キャッシュ、メモリオーダリングといった、 普段は意識しないけれど性能に直結するCPU内部のメカニズム が、プログラマーの目線で一通り整理されている本です。本記事の脱線で触れたキャッシュコヒーレンシまわりも、この本を読むとより腑に落ちると思います。 「なぜこのコードは速いのか/遅いのか」を、ハードウェア寄りの視点から考えられるようになる本なので、cgroup の挙動の先を覗いてみたい方にぴったりです。 参考 ペアーズ本番環境でのcgroup-aware化との死闘録(発表スライド) — 本記事のベースとなった発表 クラウドネイティブ会議2026 セッションページ Container-aware GOMAXPROCS | Go 1.25 Release Notes uber-go/automaxprocs — Go 1.24以前で使えるcgroup-aware GOMAXPROCS Kubernetes CPU limits and requests: A deep dive | Datadog 検証コード 本記事の検証に使ったコードは以下のリポジトリにあります: git clone https://github.com/hirosi1900day/cgroup-throttling-lab.git cd cgroup-throttling-lab docker build -t cgroup-bench go-app/ ./scripts/run_experiments.sh # 全実験を一括実行