平均CPUは低いのに遅い: cgroup v2 の cpu.max スロットリングを診断する

cgroup v2 の cpu.max は周期ごとに CPU 時間の上限をかけます。平均使用率が上限を下回っていても、マルチスレッド処理が周期の前半で割り当てを使い切ると止まります。cpu.stat を読むだけで確かめる方法をまとめます。

linuxcgroupcpuschedulerperformance

コンテナに CPU 1 コアの上限をかけていて、ダッシュボード上の平均使用率はそれよりかなり低い。それなのにリクエストのレイテンシだけがときどき大きく跳ねる、ということがあります。こうしたときに確認しておきたいのが、cgroup の CPU 帯域制御 (bandwidth control) によるスロットリングです。

この記事は Linux カーネルドキュメント 7.3.0 版の Control Group v2 と CFS Bandwidth Control に基づいて、cpu.max が何を制限するのか、平均が低くても止まるのはなぜか、cpu.stat を読んでそれをどう確かめるかを扱います。診断部分はファイルを読むだけで、設定は変更しません。

cpu.max は「周期あたりの CPU 時間」を制限する

cgroup v2 の cpu.max はルート以外の cgroup にある読み書き可能なファイルで、形式は次のとおりです。

$MAX $PERIOD

グループが $PERIOD ごとに最大 $MAX の CPU 時間を使えるという意味で、単位はマイクロ秒です。デフォルトは max 100000 で、$MAX の位置の max は上限なしを表します。数値を 1 つだけ書き込むと $MAX だけが更新されます。

ここで大事なのは、$MAX が壁時計の時間ではなく、グループ内の全スレッドが全 CPU で使った CPU 時間の合計だという点です。sched-bwc ドキュメントの例もそう説明しています。周期 500ms に割り当て 1000ms なら「2 CPU 分」、周期 50ms に割り当て 10ms なら CPU 1 個の 20% です。つまり cpu.max が 100000 100000 なら、よく言う「1 コアの上限」になります。

sched-bwc ドキュメントによると、割り当てはグループのスレッドが実行可能になるにつれて CPU ごとのランキューへスライス単位で渡されます。割り当てがすべて配られたあとにさらに要求したスレッドはスロットリングされ、割り当てが補充される次の周期まで実行されません。

平均が低くても止まる理由

この定義からそのまま導ける計算があります。cpu.max が 50000 100000 (0.5 CPU) のグループで、8 個のスレッドが 8 個の CPU で同時に動いているとします。理想的に計算すると、50ms の割り当ては壁時計で約 6.25ms で使い切られ、その周期の残り約 93.75ms のあいだ、グループのスレッドは待つことになります。

このグループの平均使用率は 0.5 CPU 以下のままです。しかし待ちの区間に届いたリクエストは次の周期まで処理されません。秒単位で平均をとるダッシュボードには、周期内のこうした停止は現れません。上の数字は定義をそのまま当てはめた単純な計算で、実際には後述するスライスの配分によってタイミングが多少ずれることがあります。

上限は平均ではなく周期ごとにかかり、同時に動くスレッドが多いほど、割り当ては壁時計の上で速く尽きます。CPU 時間と壁時計時間の違いになじみがなければ、real・user・sys 時間の記事が参考になります。

スロットリングがかかる 2 つの経路

sched-bwc ドキュメントは、グループがスロットリングされるケースを 2 つ挙げています。

  1. グループが 1 周期のうちに自分の割り当てを使い切った場合
  2. 親の割り当てがその周期のうちに使い切られた場合

2 つ目の場合、子に割り当てが残っていても、親の割り当てが補充されるまで実行できません。また階層の中では、子の上限の合計が親の上限を超えることが明示的に許されています。そのため子 1 つの cpu.max だけを見て「余裕がある」と判断すると、原因を見落とすことがあります。

cpu.stat で見るフィールド

cpu.stat は読み取り専用の flat-keyed ファイルで、cpu コントローラが有効でなくても存在します。ただし含まれるフィールドが変わります。

フィールド いつ現れるか 何を数えるか
usage_usec, user_usec, system_usec 常に 子孫 cgroup を含む全プロセスの CPU 時間
nr_periods コントローラ有効時 経過した制御周期の数
nr_throttled コントローラ有効時 グループがスロットリングされた回数
throttled_usec コントローラ有効時 グループのエンティティがスロットリングされた合計時間
nr_bursts, burst_usec コントローラ有効時 バーストが起きた周期の数と、割り当てを超えて使った累積時間

読むときの注意点がいくつかあります。

  • 下の 5 つの帯域統計は fair-class スケジューラで動くプロセスだけを数えます。一方、usage_usec など上の 3 つはすべてのプロセスを数えます。
  • 帯域統計の 5 つは階層的ではありません。この cgroup 自身の上限によるスロットリングだけを数え、祖先 cgroup の上限から引き継いだスロットリングは含みません。
  • 祖先から引き継いだスロットリングまで含めて見るには、cpu.stat.local の throttled_usec を読みます。7.3.0 のドキュメントは、この値をその cgroup 自身のランキューが実際に受けたスロットリング時間であり、祖先の上限から引き継いだものを含みうると説明しています。この記事ではこれをローカルランキューのスロットリング時間と呼びます。値が増えていれば、この cgroup のローカルランキューがスロットリングを受けたことはわかります。ただしランキュー単位で積み上がる時間なので、リクエストのレイテンシを直接測る値でも、1 回の壁時計上の停止の長さでもありません。このファイルがないカーネルもありうるので、まず存在を確認してください。
  • cgroup v1 の sched-bwc 統計は throttled_time をナノ秒で報告しますが、cgroup v2 のドキュメントは cpu インターフェースファイルの時間はすべてマイクロ秒だと明記しています。2 つのバージョンの数値を換算せずに比べると 1000 倍ずれます。

nr_throttled は累積値なので、それだけでは判断しにくい値です。一定の間隔で 2 回読み、差を見るほうが役に立ちます。sched-bwc ドキュメントは、CPU を使い続ける処理が上限に張り付いている場合、nr_periods と nr_throttled はほぼ等しくなると予想されると書いています。逆にほとんど待機していて、ときどき負荷が集中するサービスでは、2 つの差が小さくても throttled_usec が増え続けているかどうかを見る必要があります。

読み取り専用の診断スクリプト

次のスクリプトは cgroup のファイルを読むだけです。指定した PID が属する cgroup v2 のパスを /proc/<PID>/cgroup の 0:: 行から求め、マウントのルートまで含めて祖先をさかのぼって cpu.max を表示したあと、一定時間の cpu.stat の変化量を計算します。間隔が正の整数でない、/proc/<PID>/cgroup を読めない、絶対パスの 0:: 行がちょうど 1 つではない、CG_ROOT か cgroup のパスが後述の境界チェックを通らない、そのパスの cpu.stat を読めない、計測中に cgroup ディレクトリが消えたか別のディレクトリに置き換わった、読んだ値が空か数値でない、必須のカウンタが 2 回目の読み取りで減っている、といった場合は推測で進めずにエラーで止まります。カウンタが減るのは cgroup がリセットされたか作り直されたことを意味するので、その区間は使えません。

ディレクトリが置き換わったかどうかは、1 回目の読み取りの前と 2 回目の読み取りの後に ls -di が示す cgroup ディレクトリの inode 番号を比べて判断します。-d と -i は標準的な ls のオプションですが、inode 番号を cgroup ディレクトリの識別子として使うのは Linux の cgroupfs を前提にした仮定です。作り直したディレクトリには別の inode 番号が付くことを当てにしており、この記事が引用するカーネルドキュメントはこの点を扱っていません。そのため、スクリプトはカウンタが減った場合も別に拒否します。

cgroup2 ファイルシステムが /sys/fs/cgroup にマウントされていることを前提にしています。環境によって異なることがあるので、先に findmnt -t cgroup2 で確認し、違う場合は CG_ROOT に絶対パスを指定して実行してください。

スクリプトはどちらのパスも与えられたままには信用しません。CG_ROOT はファイルシステムを読むだけの cd -P と pwd -P で実パスに解決し、その結果が / 以外の既存のディレクトリで、cgroup.controllers があるときだけ先に進みます。cgroup v2 のドキュメントによれば、このファイルはすべての cgroup にあります。0:: のパスに . や .. の要素があれば拒否し、cgroup のディレクトリも同じ方法で実パスに解決して、存在しないか、CG_ROOT 自体でもその下でもなければ止まります。CG_ROOT の外の cgroup ファイルは読みません。

独自の cgroup 名前空間を持つコンテナの中では、/proc/<PID>/cgroup は cgroupns ルートからの相対パスを示すので、PID の cgroup が 0::/ と見えることがあります。その名前空間の中でマウントした cgroup2 は、cgroupns ルートをファイルシステムのルートにします。cgroupns ルートは名前空間を作った時点の cgroup なので、たいていはホストのルート cgroup ではなく、自分の cpu.max を持ちえます。そのため、スクリプトは CG_ROOT 自体の cpu.max も読めれば表示しますが、それより上にはさかのぼりません。名前空間のルートより上にあるホスト側 cgroup の上限は、中からは見えません。

#!/bin/sh
# 使い方: sh cpu-throttle.sh [PID] [秒]
pid=${1:-self}
secs=${2:-10}
root=${CG_ROOT:-/sys/fs/cgroup}

die() { echo "エラー: $*" >&2; exit 1; }
isnum() { case $1 in ''|*[!0-9]*) return 1 ;; esac; return 0; }
field() { awk -v k="$2" '$1 == k { print $2 }' "$1" 2>/dev/null; }
dirid() { ls -di "$1" 2>/dev/null | awk 'NR == 1 { print $1 }'; }
realdir() { (cd -P -- "$1" 2>/dev/null && pwd -P); }

# 0 で始まらない正の整数だけを受け付ける (シェルの算術は 010 を 8 進数として読む)。
case $secs in
  ''|0*|*[!0-9]*) die "秒には正の整数を指定してください: $secs" ;;
esac

# CG_ROOT: 絶対パスで、実パスに解決すると / 以外の既存の cgroup v2 ディレクトリであること。
case $root in
  /*) ;;
  *) die "CG_ROOT は絶対パスで指定してください: $root" ;;
esac
r=$(realdir "$root") || die "CG_ROOT が既存のディレクトリではありません: $root"
[ "$r" != / ] || die "CG_ROOT に / は指定できません"
[ -e "$r/cgroup.controllers" ] || die "CG_ROOT が cgroup v2 のディレクトリではありません: $root"
root=$r

# 空でない絶対パスの cgroup v2 パスがちょうど 1 つあり、. や .. の要素を含まないことを確認する。
f=/proc/$pid/cgroup
[ -r "$f" ] || die "$f を読めません"
rel=$(awk 'index($0, "0::") == 1 { n++; p = substr($0, 4) } END { if (n == 1) print p }' "$f")
case $rel in
  /*) ;;
  *) die "$f から cgroup v2 のパス (0::/...) を 1 つに特定できません" ;;
esac
case $rel/ in
  */./*|*/../*) die "cgroup のパスに . か .. が含まれています: $rel" ;;
esac
cg=$(realdir "$root$rel") || die "cgroup のディレクトリが存在しません: $root$rel"
# 実パスに解決した cgroup は CG_ROOT 自体かその下でなければならない。
case $cg in
  "$root"|"$root"/*) ;;
  *) die "cgroup $cg が CG_ROOT $root の外にあります" ;;
esac
[ -r "$cg/cpu.stat" ] || die "$cg/cpu.stat を読めません"
echo "cgroup: $cg"

# ディレクトリの識別子 (inode 番号)。2 回目の読み取りの後にもう一度比べる。
id1=$(dirid "$cg")
isnum "$id1" || die "$cg の inode 番号を読めません"

# 自分と祖先の上限。CG_ROOT まで含め、それより上にはさかのぼらない。
# ホストのルート cgroup には cpu.max がないが、CG_ROOT にマウントされた cgroup 名前空間のルートにはありうる。
d=$cg
while :; do
  [ -r "$d/cpu.max" ] && echo "  $(cat "$d/cpu.max")  burst=$(cat "$d/cpu.max.burst" 2>/dev/null)  <- $d"
  if [ "$d" = "$root" ] || [ "$d" = "/" ]; then break; fi
  d=$(dirname "$d")
done

p1=$(field "$cg/cpu.stat" nr_periods)
if [ -z "$p1" ]; then
  if [ "$cg" = "$root" ] && [ ! -e "$cg/cpu.max" ]; then
    echo "nr_periods がない: CG_ROOT に cpu.max がない (ホストのルート cgroup か、cpu コントローラが無効な名前空間のルート) ため、ここで診断する帯域の上限はありません。"
  else
    echo "nr_periods がない: この cgroup では cpu コントローラが有効になっていません。"
  fi
  exit 0
fi
t1=$(field "$cg/cpu.stat" nr_throttled)
u1=$(field "$cg/cpu.stat" throttled_usec)
c1=$(field "$cg/cpu.stat" usage_usec)
l1=$(field "$cg/cpu.stat.local" throttled_usec)

sleep "$secs"

p2=$(field "$cg/cpu.stat" nr_periods)
t2=$(field "$cg/cpu.stat" nr_throttled)
u2=$(field "$cg/cpu.stat" throttled_usec)
c2=$(field "$cg/cpu.stat" usage_usec)
l2=$(field "$cg/cpu.stat.local" throttled_usec)

id2=$(dirid "$cg")
[ "$id2" = "$id1" ] || die "計測中に $cg が消えたか、別のディレクトリに置き換わりました (inode $id1 -> ${id2:-なし})"

for v in "$p1" "$p2" "$t1" "$t2" "$u1" "$u2" "$c1" "$c2"; do
  isnum "$v" || die "$cg/cpu.stat の値がないか数値ではありません (cgroup が消えた可能性があります)"
done

# カウンタが減っていればリセットか作り直しが起きているので、この区間は無効。
if ! { [ "$p2" -ge "$p1" ] && [ "$t2" -ge "$t1" ] && [ "$u2" -ge "$u1" ] && [ "$c2" -ge "$c1" ]; }; then
  die "cpu.stat のカウンタが減っています (cgroup のリセットか作り直しの可能性)。この区間は破棄します"
fi

echo "${secs} 秒間:"
echo "  $((p2 - p1)) 周期のうちスロットリング $((t2 - t1)) 回"
echo "  スロットリング時間 $(((u2 - u1) / 1000)) ms (この cgroup 自身の上限による)"
if isnum "$l1" && isnum "$l2" && [ "$l2" -ge "$l1" ]; then
  echo "  ローカルランキューのスロットリング時間 $(((l2 - l1) / 1000)) ms (cpu.stat.local、祖先を含みうる、壁時計上の停止時間ではない)"
elif isnum "$l1" && isnum "$l2"; then
  echo "  ローカルランキューのスロットリング時間: 取得できず (cpu.stat.local throttled_usec が減少、リセットか作り直し)"
else
  echo "  ローカルランキューのスロットリング時間: 取得できず (2 回とも数値の cpu.stat.local throttled_usec がない)"
fi
echo "  CPU 使用 $(((c2 - c1) / 1000)) ms = 平均 $(((c2 - c1) / secs / 10000))% CPU"

最後の行のパーセントは CPU 1 個を 100% とした値で、整数除算なので小数点以下は切り捨てられます。出力は次のように読みます。

  • 平均使用率が上限よりかなり低いのにスロットリング回数が増え続けるなら、区間の平均は低く見えても、一部の周期でこの cgroup 自身の上限によるスロットリングが起きていることになります。ただしカウンタには周期内の時刻情報がないため、毎周期の前半で割り当てを使い切ったのか、一部の周期の後半で使い切ってそのあと処理がなかったのかは区別できません。周期前半での枯渇は仮説にとどめ、より細かいトレースや、リクエスト・負荷のタイミングとの突き合わせで確かめてください。
  • 自分の throttled_usec はほとんど増えないのに cpu.stat.local のローカルランキューのスロットリング時間が増えるなら、祖先 cgroup の上限を疑います。先頭に表示した祖先の cpu.max 一覧と突き合わせてください。この値はローカルランキューがスロットリングを受けたことを示すもので、リクエストのレイテンシそのものではありません。
  • nr_periods の行がなければ、帯域統計はありません。ルート以外の cgroup なら、cpu コントローラが有効ではないということです。PID が CG_ROOT にあり、そこに cpu.max がない場合は、スクリプトがその旨を表示します。cpu.max を持たないホストのルート cgroup か、cpu コントローラが無効な名前空間のルートのどちらかで、いずれにしてもこの診断で測る帯域の上限がそもそもありません。

CPU を待っている時間全般は、同じディレクトリの cpu.pressure でも確認できます。このファイルは cgroup 内のすべてのプロセスを対象に計算されます。読み方は PSI の記事にまとめています。

直すときの選択肢と、その代償

診断と違い、設定の変更は動いている処理にすぐ影響します。その cgroup を作ったツール (コンテナランタイムやサービスマネージャなど) があるなら、そのツールの設定から変えるほうが安全です。以下は、カーネルドキュメントが説明する挙動から導かれる選択肢です。

割り当てを増やす。 いちばん単純な方法です。ただし上の計算のとおり、同時に動くスレッドが多いと、平均使用率に合わせた割り当てでは周期内の停止が残ることがあります。

周期を変える。 sched-bwc ドキュメントの例は、周期を長くするとバーストの余裕が大きくなり、短くするとバーストの余裕を失う代わりにレイテンシの応答が安定すると説明しています。同じ比率でも 50000 100000 と 5000 10000 は挙動が異なります。同じドキュメントの v1 インターフェースの説明では、割り当てと周期の最小値を 1ms、周期の最大値を 1s としています。

cpu.max.burst を使う。 デフォルトは 0 で、範囲は 0 から $MAX までです。sched-bwc ドキュメントはバーストを、使わずに残った帯域を設定値まで蓄積しておき、割り当てを超えて使えるようにする機能として説明し、0 なら蓄積せず従来どおりの挙動だとしています。その代償として、システムのほかの利用者への干渉が大きくなることも述べています。実際にバーストが起きたかどうかは cpu.stat の nr_bursts と burst_usec で確認できます。

上限ではなく重みを使う。 cpu.weight (デフォルト 100、範囲 1〜10000) は、アクティブな兄弟 cgroup のあいだで CPU を重みの比率で分けます。その時点で CPU を使える子だけが分配に参加する work-conserving な方式なので、兄弟が使っていない分を使えます。競合時の比率だけを決めたく、絶対的な上限が必須でないなら、こちらのほうが合うかもしれません。

スライスとコア数の多いマシン

sched-bwc によると、カーネルはグローバルな割り当てを CPU ごとの「silo」へスライス単位で移します。スライスの大きさは /proc/sys/kernel/sched_cfs_bandwidth_slice_us で、デフォルトは 5ms です。大きくすると移す際のオーバーヘッドが減り、小さくするとより細かく消費できます。

CPU に割り当てられたスライスは期限切れになりません。その CPU 上のスレッドがすべて実行不能になると、1ms を残して残りはグローバルプールに戻されることがあります。この 1ms はコンパイル時の値 min_cfs_rq_runtime で決まります。そのため、スレッドが多く、CPU を使い続けるわけではない処理は、各 CPU に残っていたスライスの分だけ、通常は CPU あたり最大 1ms ほど、一時的に割り当てを超えることがあります。ドキュメントは、それでも 1 周期より長い時間の窓で見れば平均使用量は割り当てに厳密に制限されると説明しています。cpu.stat の数値が定義どおりにぴったり合わないときは、この挙動を思い出してください。

範囲と限界

  • cgroup v2 のドキュメントは、cpu コントローラがまだリアルタイムプロセスの帯域制御をサポートしていないと警告しています。この記事の cpu.max の挙動は、fair-class スケジューラで動くプロセス (と、対応するコールバックを実装した BPF スケジューラ) に当てはまります。SCHED_FIFO、SCHED_RR、SCHED_DEADLINE のプロセスは別の扱いになります。
  • ドキュメントによると、CPU の分配は時間だけを基準に定義されており、CPU がどの周波数で動いたかは考慮されません。同じ割り当てでこなせる仕事量は周波数によって変わりえます。
  • この記事はカーネルドキュメント 7.3.0 版 (2026-10-22 確認) に基づいています。cpu.stat.local などのファイルは古いカーネルにはないことがあります。動かしているカーネルのドキュメントと、実際に存在するファイルをあわせて確認してください。
  • 本文の 0.5 CPU・8 スレッドの例は定義を当てはめた計算であり、測定値ではありません。

出典

ブログへ