有名テック企業の技術ブログを、ひとつのフィードで。
フィード
31件
はじめに こんにちは、タイミーでエンジニアをしている徳富(@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(=ワーカースレッド数)の影響がダイレクトに現れます。 <st