はじめに — CPUは40%なのにp99が3秒です
キャパシティ会議でもっともよく出てくる一文があります。「CPU使用率が40%しかないのでCPUの問題ではありません」。この一文は三つの状況で間違っています。そしてその三つが、この記事のすべてです。
top -bn1 | head -4
# top - 14:22:31 up 9 days, 4:12, 1 user, load average: 3.21, 3.04, 2.88
# Tasks: 214 total, 2 running, 212 sleeping, 0 stopped, 0 zombie
# %Cpu(s): 31.2 us, 4.1 sy, 0.0 ni, 42.6 id, 0.4 wa, 0.0 hi, 0.5 si, 21.2 st
最後の列にある 21.2 st が、この記事の出発点です。アイドルは42.6%に見えますが、21.2%はすでに他者に奪われた時間であり、アプリケーションが使えたはずのCPUはその分だけ減っています。そしてここに現れない三つ目の原因がもう一つあります。コンテナのスロットリングは、この画面のどこにも表示されません。
steal timeの定義 — ハイパーバイザーが与えなかった時間
st は vCPUが実行できる状態だったにもかかわらず、ハイパーバイザーが物理CPUを割り当てなかったために待たされた時間の割合です。ゲストカーネルの内側で起きたことではなくゲストの外側で起きたことであり、ゲストはそれを事後に通知されるだけです。
動作原理は準仮想化インターフェースです。KVMではゲストカーネルが MSR_KVM_STEAL_TIME を通じて共有メモリ構造体を登録し、ハイパーバイザーがvCPUをスケジュールアウトした累積ナノ秒をそこに書き込みます。ゲストカーネルはその値を読み、/proc/stat の8番目のフィールドに反映します。Xenもrunstate情報で同じことを行います。
head -2 /proc/stat
# cpu 9284712 1832 1204831 88213904 42193 0 18922 1928374 0 0
# cpu0 1160589 229 150604 11026738 5274 0 2365 241046 0 0
# user nice system idle iowait irq softirq steal guest guest_nice
8番目の数字がstealの累積tickです。top や mpstat は、この値の差分を見せているだけです。
mpstat -P ALL 1 3 | tail -5
# 14:23:03 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
# 14:23:03 all 30.51 0.00 4.02 0.38 0.00 0.51 21.34 0.00 0.00 43.24
# 14:23:03 0 29.90 0.00 3.96 0.50 0.00 0.99 22.28 0.00 0.00 42.37
# 14:23:03 1 31.14 0.00 4.08 0.26 0.00 0.03 20.40 0.00 0.00 44.09
CPUごとに均等に21%前後であればホスト全体が過密であり、特定のvCPUだけに偏っているなら、そのvCPUが配置された物理コアに競合相手がいます。後者はインスタンスを起動し直して別のホストへ移すだけで解決することが多いです。
ここで、もっともよくある誤解を押さえておきます。stが高いからといって、自分のプロセスがCPUを多く使っているわけではありません。むしろ逆です。使いたかったのに使えなかった時間であり、原因はインスタンスの外側にあります。アプリケーションコードをどれだけ最適化しても、この数値は下がりません。
二つ目の誤解も重要です。stが0だからといってハイパーバイザーの競合がないという意味ではありません。stealの会計は、ハイパーバイザーがその情報をゲストに公開している場合にだけ機能します。VMware環境のゲストはたいてい st が0に見え、実際の競合はホスト側の %RDY(CPU ready)指標でしか確認できません。オンプレミスの仮想化で「stが0だから問題ない」と結論づけるのが、代表的な誤診です。
# 準仮想化クロックとstealの会計が有効かどうかを確認する
grep -o 'kvm\|xen\|vmware\|hyperv' /sys/hypervisor/type 2>/dev/null
systemd-detect-virt
# kvm
grep -E 'CONFIG_PARAVIRT_TIME_ACCOUNTING' /boot/config-$(uname -r)
# CONFIG_PARAVIRT_TIME_ACCOUNTING=y
stは何パーセントから問題なのか、そして何ができるのか
絶対的な基準はありませんが、運用上の経験則は比較的一貫しています。
- 1%未満: 正常です。共有インフラでは0のほうがむしろ珍しいです。
- 1~5%: 観察対象です。レイテンシに敏感なサービスなら、すでにp99に影響が出ている可能性があります。
- 5~10%が継続: 対処対象です。スループットが実際にその分だけ削られます。
- 10%超が継続: ただちに移すべきです。そのホストではどんなチューニングも効きません。
重要なのは瞬間値ではなく継続性です。バックアップウィンドウや隣人のバッチ処理のせいで数分だけ跳ねるのはよくあることです。アラートは5分または15分平均で仕掛けたほうが誤検知が減ります。
対応は四つあります。
# 1) インスタンスの再配置 — 停止してから起動すると別の物理ホストに配置されます
# (再起動は同じホストに残るので効果がありません)
aws ec2 stop-instances --instance-ids i-0abc123def4567890
aws ec2 start-instances --instance-ids i-0abc123def4567890
再起動と停止・起動の違いが肝心です。ゲスト内での reboot は物理ホストを変えません。クラウドAPIを通じた停止と起動でこそ、配置がやり直されます。
残りの三つは構造的な手当てです。物理コア全体を占有する大きさのインスタンスへ上げれば、隣人そのものがいなくなります。専用ホストやベアメタルインスタンスはハイパーバイザーの共有を丸ごとなくす代わりに、コストが大きくなります。そしてレイテンシに敏感なワークロードを、stの低いインスタンスファミリーへ分離配置するのも実用的な選択です。
一つ落とし穴があります。stが高いときにvCPU数を増やすのは、しばしば逆効果です。ホストがすでに過密なら、vCPUが増えた分だけスケジューリングの機会を待つことになり、ゲスト内ではスピンロックの競合まで増えます。コア数よりもインスタンスサイズ、つまりホストの占有比率を上げるほうが正解です。
バースタブルインスタンスのクレジット枯渇 — 同じ症状、違う原因
二つ目の原因です。AWSのT系列や、他クラウドの類似等級は、ベースライン性能(baseline)とCPUクレジットという概念で動きます。ベースライン以下で使えばクレジットが貯まり、ベースラインを超えて使えばクレジットを消費します。
| インスタンス | vCPU | vCPUあたりのベースライン性能 | 1時間あたりの獲得クレジット |
|---|---|---|---|
| t3.nano | 2 | 5% | 6 |
| t3.micro | 2 | 10% | 12 |
| t3.small | 2 | 20% | 24 |
| t3.medium | 2 | 20% | 24 |
| t3.large | 2 | 30% | 36 |
| t3.xlarge | 4 | 40% | 96 |
| t3.2xlarge | 8 | 40% | 192 |
クレジット1個は、vCPU一つを100%の性能で1分間使える量です。t3.mediumは1時間あたり24個を稼ぎ、vCPUが2個あるので、持続可能な使用量は2 かける 20%、つまりvCPU一つ基準で40%に相当します。それ以上を使い続ければ、残高は必ず0に向かいます。
症状が特徴的です。最初の数時間は問題なく動き、ある時点から性能が階段状に落ちて回復しません。負荷はそのままなのに、応答時間だけが数倍になります。standardモードでクレジットが尽きると、ハイパーバイザーがベースライン性能へ強制的に制限し、ゲストはそれをsteal timeとして認識します。つまり st は上がりますが、原因は隣人ではなく自分の予算です。
見分ける方法は簡単です。stealが時間とともに不規則に上下するなら隣人であり、クレジット残高が0に達した時刻からstealが階段のように上がって固定されるならクレジットです。
aws cloudwatch get-metric-statistics \
--namespace AWS/EC2 --metric-name CPUCreditBalance \
--dimensions Name=InstanceId,Value=i-0abc123def4567890 \
--start-time 2026-07-25T00:00:00Z --end-time 2026-07-26T00:00:00Z \
--period 300 --statistics Average \
--query 'sort_by(Datapoints, &Timestamp)[].[Timestamp,Average]' --output text | tail -6
# 2026-07-25T09:00:00Z 142.3
# 2026-07-25T10:00:00Z 88.1
# 2026-07-25T11:00:00Z 31.4
# 2026-07-25T12:00:00Z 0.0
# 2026-07-25T13:00:00Z 0.0
# 2026-07-25T14:00:00Z 0.0
12時から0です。レイテンシのグラフがちょうどその時刻から悪化していれば、診断は終わりです。
選択肢は三つです。unlimitedモードへ切り替えるとクレジットがなくてもバーストし続け、超過分は追加課金されます(CPUSurplusCreditsCharged 指標で実際の費用を確認できます)。継続的にベースラインを超えて使っているなら、M系列やC系列の固定性能インスタンスへ移すほうが結局は安く済みます。バーストが本当に間欠的なら、T系列を維持したうえでクレジット残高をアラートに仕掛けるのが正解です。
# unlimitedへ切り替える
aws ec2 modify-instance-credit-specification \
--instance-credit-specifications \
'InstanceId=i-0abc123def4567890,CpuCredits=unlimited'
費用の判断基準はこうです。unlimitedモードの超過課金が常時発生しているなら、その金額と、一段上の固定性能インスタンスとの差額を比べてください。常時バースト状態では、たいてい後者のほうが安く、性能も予測可能です。
コンテナのCFSクォータスロットリング — topにまったく現れない三つ目の原因
もっとも重要な節です。前の二つの原因は、少なくとも st という痕跡を残します。スロットリングは何の痕跡も残しません。
動作はこうです。cgroupにCPUの上限がかかっていると、カーネルは周期(既定で100ms)ごとにクォータを配分します。そのグループのタスクが周期の中でクォータを使い切ると、カーネルは残りの時間のあいだ、そのグループ全体をランキューから外します。次の周期が始まるまで、何も実行されません。
この状態で何が起きるのかを見てみます。CPU limitが1コアのPodにスレッドが8個あります。8個が同時に回ると、100msのクォータを12.5msで使い切ります。残りの87.5msのあいだ、このPodは完全に停止します。リクエストが一つ、よりによってその区間にかかると、応答に87.5msが上乗せされます。平均CPU使用率はきっかり100%(1コアのうち1コア)にすら見えません。12.5%前後に見えます。
そのため、こういうグラフになります。平均CPU使用率は低く、stealも0で、ロードアベレージも静かなのに、p99だけが規則的に跳ねます。スロットリングされたタスクはランキューから外れるため、ロードアベレージにも計上されません。
確認は cpu.stat というファイル一つで足ります。
# cgroup v2 (コンテナの中から。cgroupネームスペースがprivateならそのまま見えます)
cat /sys/fs/cgroup/cpu.max
# 100000 100000
cat /sys/fs/cgroup/cpu.stat
# usage_usec 4128374621
# user_usec 3719283746
# system_usec 409090875
# nr_periods 86400
# nr_throttled 41287
# throttled_usec 2914837261
cpu.max の二つの数字はクォータと周期で、単位はマイクロ秒です。100000 100000 は100msの周期に100msのクォータ、つまり1コアです。制限がなければ最初の値が max になります。
読むべき比率は一つです。
スロットリング比率 = nr_throttled / nr_periods = 41287 / 86400 = 47.8%
周期あたりの平均停止時間 = throttled_usec / nr_throttled = 2914837261 / 41287 = 70.6ms
全周期の半分でスロットリングが発生し、発生するたびに平均70msずつ止まっていました。p99レイテンシの正体はここにあります。実務の目安としては、比率が5%を超えたら調査対象、10%を超えたら対処対象です。
cgroup v1ならファイルが違います。
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
# 100000
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us
# 100000
cat /sys/fs/cgroup/cpu/cpu.stat
# nr_periods 86400
# nr_throttled 41287
# throttled_time 2914837261000
v1の throttled_time はナノ秒、v2の throttled_usec はマイクロ秒です。1000倍の違いなので、ダッシュボードを移すときによく間違える箇所です。
ホストからPod単位で洗い出すには、こうします。
for f in /sys/fs/cgroup/kubepods.slice/*/*/cpu.stat; do
awk -v p="${f%/cpu.stat}" '
/^nr_periods/ {np=$2}
/^nr_throttled/{nt=$2}
END {if (np > 0 && nt/np > 0.05) printf "%5.1f%% %s\n", nt*100/np, p}
' "$f"
done | sort -rn | head -5
# 47.8% /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice
# 12.3% /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod4d22b71a.slice
Prometheusでは cAdvisor の指標を使います。
sum by (pod) (rate(container_cpu_cfs_throttled_periods_total[5m]))
/ sum by (pod) (rate(container_cpu_cfs_periods_total[5m]))
container_cpu_cfs_throttled_seconds_total だけを見るダッシュボードをよく見かけますが、その値は「どれだけ長く止まったか」であり、本当に必要なのは「何回のうち何回止まったか」です。二つを一緒に仕掛ける必要があります。
カーネルのバージョンも確認すべき値です。5.4より前のカーネルにはCPUスライスの期限処理にバグがあり、クォータを使い切っていないのにスロットリングが発生していました。5.4でこの期限ロジックが削除され、症状は大きく減りました。古いノードで説明のつかないスロットリングが見えるなら、まずカーネルを確認してください。
uname -r
# 6.8.0-45-generic
5.14からは cpu.max.burst が追加され、使わなかったクォータを一定の上限まで積み立てておき、瞬間的に超過して使えるようになりました。短いスパイク性のワークロードに効果が大きいです。
# 最大50msまで積み立てを許可する
echo 50000 | sudo tee /sys/fs/cgroup/kubepods.slice/.../cpu.max.burst
三つの原因を見分ける表とKubernetes CPU limit論争
ここまでの三つを一つの表にまとめます。症状は似て見えますが、診断のポイントと対応はすべて違います。
| 原因 | 実際に起きていること | topのst | cpu.statの変化 | ロードアベレージ | 対応 |
|---|---|---|---|---|---|
| ハイパーバイザー競合 | 隣のVMが物理CPUを占有 | 5%以上 | 変化なし | 影響は小さい | 停止後に起動、大きいインスタンス、専用ホスト |
| クレジット枯渇 | 自分のバースト予算を使い切る | 階段状に上昇 | 変化なし | 影響は小さい | unlimitedへ切り替え、M/C系列へ移行 |
| CFSクォータスロットリング | cgroupの上限で強制停止 | 0 | nr_throttledが増加 | 上がらない | limitの引き上げ・削除、並列度の調整、burst |
三行目の「ロードアベレージが上がらない」が特に反直観的です。スロットリングされたタスクはランキューから削除されるので、待ち行列に計上されません。ロードもCPU使用率もstealもすべて静かなのに、サービスだけが遅いのです。この組み合わせが出たら cpu.stat を見てください。
さて論争です。KubernetesでCPU limitをそもそも外そうという主張があります。双方の根拠を正確に見る必要があります。
削除を支持する論拠。CPUは圧縮可能な資源です。メモリと違って不足しても死なず、遅くなるだけです。そしてrequestはすでに cpu.weight(v1の cpu.shares)へ変換され、競合時の比例配分を保証しています。つまりノードが忙しいときはrequestの比率どおりに分け合い、ノードが空いているときはlimitがなければ余ったCPUをそのまま使えます。limitがあると、ノードにCPUが余っている状況でも強制的に止まります。これは純粋な無駄です。
維持を支持する論拠。limitがなければ、一つのPodの暴走が同じノードの隣人を実際に遅くします。weightは競合時の最小の取り分を保証するだけで上限を置かないため、スレッドを数百個作るPodはコンテキストスイッチとキャッシュ汚染によって隣人の実効性能を下げます。また、limitがなければ性能特性がノードの余裕次第で変わり、キャパシティ計画と負荷テスト結果の再現性が崩れます。マルチテナントのクラスタなら、隔離は交渉の対象ではありません。そしてCPUマネージャのstaticポリシーで専用コアの割り当てを受けるにはGuaranteed QoS、つまりlimitがrequestと同じである必要があります。
均衡点は次のように整理できます。
- requestは必ず設定します。これは論争の対象ではありません。スケジューリングの配置も競合時の取り分も、すべてここから出てきます。
- limitを外す候補は、レイテンシに敏感で信頼できる自社サービスです。外す場合も、ノード全体のCPU使用率と隣のPodのレイテンシを一緒に観察する必要があります。
- limitを維持する対象は、バッチ処理、信頼度の低いワークロード、マルチテナントの名前空間です。ここでは予測可能性が効率より重要です。
- limitを維持するなら、並列度に合わせて設定します。limit 1コアにスレッド8個は、構造的にスロットリングを生みます。
- ランタイムに上限を伝えます。これは単一の手当てとしては効果が最大です。
5番を補足します。JVMやGoのランタイムは、既定でホスト全体のコア数を見ます。64コアのノードでCPU limitが1のコンテナの中にあるGoプログラムは GOMAXPROCS を64に設定し、64個のスレッドが1コア分のクォータを分け合って、ただちにスロットリングに引っかかります。
# Go: 自動調整ライブラリを使うか、環境変数で直接指定する
GOMAXPROCS=1 ./myapp
# JVM: コンテナ認識は既定で有効だが、確認が必要です
java -XX:+PrintFlagsFinal -version | grep -E 'ActiveProcessorCount|UseContainerSupport'
# intx ActiveProcessorCount = -1
# bool UseContainerSupport = true
# 明示的に指定する
java -XX:ActiveProcessorCount=2 -jar app.jar
JVMのコンテナ認識は、CPU limitをコア数に換算するときに切り上げを使います。limitが1.5コアなら2として見ます。小数点のlimitを使っているなら、この丸めがスロットリングを生んでいないか確認する必要があります。
ノード次元の選択肢も二つあります。kubeletの --cpu-manager-policy=static は、整数のCPUを要求したGuaranteed Podに専用コアを排他的に割り当て、CFSクォータそのものを回避します。--cpu-cfs-quota-period を100msから10ms程度へ縮めると、一度の停止時間が短くなり、レイテンシスパイクの振幅が小さくなります。ただし周期が短くなるとスケジューリングのオーバーヘッドが増え、このフラグはまだ安定機能ではないので、検証してから適用する必要があります。
再発防止 — ダッシュボードに何を仕掛けるか
三つの原因はそれぞれ違う指標で捕まるので、三つとも仕掛ける必要があります。一つでも抜けると、その原因は永遠に見えません。
# 1) steal — ノード単位、5分平均
avg by (instance) (rate(node_cpu_seconds_total{mode="steal"}[5m])) > 0.05
# 2) スロットリング比率 — Pod単位
sum by (namespace, pod) (rate(container_cpu_cfs_throttled_periods_total[5m]))
/ sum by (namespace, pod) (rate(container_cpu_cfs_periods_total[5m])) > 0.10
# 3) CPUの逼迫 — PSI。スロットリングと競合の両方を捕捉します
avg by (instance) (rate(node_pressure_cpu_waiting_seconds_total[5m])) > 0.20
PSIを一緒に仕掛ける理由があります。cpu.pressure は「実行できたはずなのにできなかった時間」を直接測るため、stealであれスロットリングであれランキューの競合であれ、原因と無関係に結果を捕まえます。cgroup単位でも存在します。
cat /sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod9a1c8f2e.slice/cpu.pressure
# some avg10=68.42 avg60=61.03 avg300=44.18 total=182937461283
# full avg10=52.11 avg60=47.88 avg300=33.02 total=91827364512
some が68%だというのは、直近10秒のうち6.8秒のあいだ、このPodのいずれかのタスクがCPUを待っていたという意味です。スロットリング比率47.8%と合わせて見れば、同じ事実を二つの角度から確認していることになります。
定期点検スクリプトとして残しておけば、障害のときに記憶をたどらずに済みます。
#!/usr/bin/env bash
# cpu-triage.sh — CPUレイテンシの三つの原因を一度に確認します
set -u
echo "== 1. steal time (5秒平均) =="
mpstat 5 1 | awk '/Average/ {printf " %%steal = %s\n", $(NF-3)}'
echo "== 2. 仮想化環境 =="
printf ' '; systemd-detect-virt
echo "== 3. cgroupスロットリング (v2) =="
if [ -r /sys/fs/cgroup/cpu.stat ]; then
awk '/^nr_periods/{np=$2} /^nr_throttled/{nt=$2} /^throttled_usec/{tu=$2}
END {
if (np > 0)
printf " 比率 %.1f%% (%d/%d), 発生時の平均 %.1fms\n",
nt*100/np, nt, np, (nt>0 ? tu/nt/1000 : 0)
}' /sys/fs/cgroup/cpu.stat
printf ' cpu.max = %s\n' "$(cat /sys/fs/cgroup/cpu.max 2>/dev/null)"
fi
echo "== 4. CPUの逼迫 (PSI) =="
[ -r /proc/pressure/cpu ] && sed 's/^/ /' /proc/pressure/cpu
おわりに — 使用率が低いことはCPUが暇だという意味ではありません
一つだけ覚えるならこれです。CPU使用率は「どれだけ使ったか」であって「どれだけ使えたか」ではありません。使いたかったのに使えなかった時間は、steal、クレジット枯渇、スロットリングという三つの形で現れ、そのうちスロットリングは使用率グラフのどこにも痕跡を残しません。
判断の順序を圧縮すると、こうなります。
mpstat -P ALL 1で%stealをまず見ます。5分平均で5%を超えるならインスタンスの問題で、ゲストの中でできることはありません。- stealが特定の時刻から階段状に上がって固定されたなら、クレジット残高を確認します。隣人ではなく予算の問題です。
- stealが0なのにレイテンシが跳ねるなら、
cpu.statのnr_throttledを見ます。使用率とロードアベレージはこの問題を見せてくれません。 - スロットリングが確認できたら、limitを上げる前にランタイムの並列度から合わせます。
GOMAXPROCSやActiveProcessorCountの調整のほうが、たいてい効果が大きいです。 - CPU limitの削除は万能薬ではありません。requestを正確に設定するのが先であり、削除は信頼できるワークロードに限定して、観測と一緒に進めます。
- アラートはsteal、スロットリング比率、PSIの三つをすべて仕掛けます。三つの原因は互いを覆い隠してはくれません。
현재 단락 (1/140)
キャパシティ会議でもっともよく出てくる一文があります。「CPU使用率が40%しかないのでCPUの問題ではありません」。この一文は三つの状況で間違っています。そしてその三つが、この記事のすべてです。