はじめに
「CPU使用率は60%なのに、なぜ応答時間が跳ねるのか?」 — 本番運用の経験がある方なら一度は直面した問いでしょう。答えはたいていスケジューラにあります。CPUが余っていても、自分のスレッドがランキューで待つ時間(スケジューリング遅延)が長ければ、ユーザーから見れば遅いのです。特にKubernetesのCPU limitsによるスロットリング、ノイジーネイバー、コンテキストスイッチの暴増は、使用率グラフにはほとんど現れない常連の犯人です。
この記事では、CPUスケジューラが解こうとする問題の本質から始め、15年以上既定だったCFSの原理と限界、そしてカーネル6.6から既定スケジューラとなったEEVDFを整理します。後半ではcgroupによるCPU制御とKubernetesの関係、低レイテンシワークロードの分離、APIサーバーのテールレイテンシを段階的に改善する実践シナリオを扱います。
スケジューリング問題の本質 — 三兎を追う
CPUスケジューラは本質的に、3つの目標間のトレードオフを扱います。
公平性 (Fairness)
「全員が重みに比例してCPUを受け取る」
/\
/ \
/ \
/ ?? \
/ \
/----------\
レイテンシ (Latency) スループット (Throughput)
「起床したタスクが 「コンテキストスイッチを減らし
すぐ実行される」 キャッシュを温かく保つ」
- 公平性: 同じ優先度のタスクは同じ量のCPU時間を受け取るべきで、niceが異なれば重みの比率どおりに受け取るべきです。
- レイテンシ: I/O待ちから起床したタスク(キー入力、ネットワーク応答)は、できるだけ早くCPUを掴めるほど体感の応答性が良くなります。
- スループット: タスクを頻繁に切り替えると、コンテキストスイッチのコストとキャッシュ汚染で全体の仕事量が減ります。
レイテンシを下げるには頻繁にプリエンプトする必要があり、スループットを上げるには長く実行する必要があります。両者は正面衝突します。CFSからEEVDFへの転換は、この衝突を「公平性を維持しながらレイテンシ要求を一級市民にする」方向で解いた物語です。
CFSの原理 — vruntimeと赤黒木
CFS(Completely Fair Scheduler)は2007年のカーネル2.6.23から既定スケジューラでした。核心となるアイデアは「理想的な完全公平CPU」のシミュレーションです。タスクがN個あれば、各タスクはCPUの1/Nずつを同時に受け取るのが理想ですが、実際のCPUは一度に1つしか実行できないため、各タスクがこれまでに受け取ったCPU時間を記録し、最も少ないタスクを次に実行することで近似します。
その「これまでに受け取った時間」がvruntime(virtual runtime)です。
ランキューの赤黒木 (vruntime順にソート)
[ T3: 220ms ]
/ \
[ T1: 180ms ] [ T5: 300ms ]
/ \ \
[ T7: 150ms ] [ T2: 200ms ] [ T4: 350ms ]
^
|
最左ノード = vruntime最小 = 次の実行対象
(O(1)でキャッシュ済み、挿入/削除はO(log N))
動作ルールは以下のとおりです。
1. 実行可能なタスクはvruntimeをキーに赤黒木でソートされます。
2. スケジューラは常に最も左(=vruntimeが最小)のタスクを選びます。
3. タスクが実行されている間vruntimeが増加し、十分大きくなると木の右側へ押しやられ、他のタスクに順番が回ります。
4. 眠ってから起床したタスクはvruntimeが小さい(=受け取りが少ない)ため優先的に実行される傾向があります。ただし、古すぎる値で木を独占しないよう最小値付近に補正されます。
nice値(優先度)はvruntimeが増加する速度に反映されます。
vruntime増加量 = 実際の実行時間 x (基準重み 1024 / タスクの重み)
nice 0 -> 重み 1024 (実時間がそのまま累積)
nice -5 -> 重み 3121 (vruntimeが約1/3の速度で増加 -> CPU 3倍)
nice 5 -> 重み 335 (vruntimeが約3倍の速度で増加 -> CPU 1/3)
nice1段階の差 = CPU時間が約1.25倍の差になるよう重みテーブルを設計
高優先度のタスクはvruntimeがゆっくり増えるため木の左側に長くとどまり、結果としてCPUを多く受け取ります。優雅な設計です。
CFSの限界 — 公平だがレイテンシには鈍感
CFSは「長期的には公平」ですが、「短期的にいつ実行されるか」の保証は弱いものでした。
第一に、レイテンシ要求を表現する方法がありませんでした。オーディオ処理スレッドとバッチコンパイル作業が同じnice 0なら、CFSは両者を同等に扱います。オーディオスレッドに必要なのは「CPUを多く」ではなく「少しでも頻繁に、時間どおりに」ですが、niceは量しか調節できず、タイミングを調節できません。
第二に、プリエンプトのタイミングがヒューリスティックに依存していました。起床したタスクが現在のタスクをプリエンプトするかを決めるwakeup granularity、最小タイムスライスなど、チューニングノブが多く、ワークロードごとに良い値が異なるため「マジック定数」論争が絶えませんでした。
これを補おうとするlatency-niceパッチシリーズが数年議論されましたが、CFS構造の上にレイテンシ優先度を継ぎ足す方式はきれいにまとまりませんでした。結局コミュニティはアルゴリズム自体を変える道を選びます。
EEVDF — カーネル6.6の新しい既定スケジューラ
EEVDF(Earliest Eligible Virtual Deadline First)は1995年の学術論文で提案されたアルゴリズムで、Peter Zijlstraがカーネルに実装し、6.6(2023年10月)からfairクラスの内部アルゴリズムとしてCFSを置き換えました。外から見れば依然としてSCHED_OTHER/SCHED_NORMALですが、内部の選択ロジックが変わったのです。
核心概念は2つです。
まずeligible(資格)の概念です。各タスクが受け取るべき取り分に対して実際に受け取った量を追跡し、すでに取り分を超えて受け取ったタスク(lagが負)は当面選択資格がありません。これが公平性を担います。
次にvirtual deadline(仮想デッドライン)です。資格のあるタスクの中では、仮想デッドラインが最も早いタスクを選びます。仮想デッドラインはおおよそ「現時点 + そのタスクのタイムスライスを重みで割った値」です。
EEVDFの選択ロジック:
1) eligibleフィルタ: lag >= 0 のタスクのみ候補
(取り分を超えて受け取ったタスクは除外 -> 公平性を保証)
2) deadline選択: 候補の中でvirtual deadlineが最も早いタスクを実行
短いスライスを要求したタスク -> デッドラインが近い
-> 頻繁に、早く選ばれる (低レイテンシ)
長いスライスを要求したタスク -> デッドラインが遠い
-> 選ばれる頻度は低く、長く実行
(高スループット)
=> 受け取る総量は重みどおりに保ちながら、
「細かく頻繁に」vs「太く時々」をタスクごとに選択可能
ここで重要な進展があります。タイムスライスの長さが意味を持つようになったことで、「CPU総量は同じでいいから、もっと頻繁に実行されたい」というレイテンシ要求をsched_setattrのスライス要求として表現できる基盤ができました(カーネル6.12からsched_runtimeフィールドでスライスのヒントを指定可能)。CFS時代のヒューリスティックなノブが多数削除され、アルゴリズム的根拠のある選択に置き換えられたのも大きな収穫です。
CFSとEEVDFを比較すると以下のとおりです。
| 項目 | CFS | EEVDF |
| --- | --- | --- |
| 選択基準 | vruntime最小 | eligible中virtual deadline最小 |
| 公平性 | vruntimeの収束で保証 | lag追跡で保証(より厳密) |
| レイテンシ表現 | 不可(niceは量のみ調節) | スライス長で表現可能 |
| プリエンプト決定 | wakeup granularityヒューリスティック | デッドライン比較で決定 |
| チューニングノブ | 多い(マジック定数論争) | 大幅に削減 |
| 理論的根拠 | 直観的な公平キューイング | 1995年論文のレイテンシ限界証明 |
運用観点での体感の変化は、「起床したレイテンシ重視タスクがより予測可能に早く実行される」こと、そして「カーネルを6.6+へ上げるとスケジューラの挙動が微妙に変わりうるため、性能リグレッションテストが必要」という点です。
現在のカーネルのスケジューラ機能を確認
uname -r # 6.6以上ならEEVDF
cat /sys/kernel/debug/sched/features | tr ' ' '\n' | head
タスクごとのスケジューリング統計
cat /proc/1234/sched | head -20 # vruntime、スライス、待ち時間など
スケジューリングクラスの体系 — fairは複数のうちの1つ
EEVDF/CFSは実は全体像の一部です。Linuxスケジューラは優先度の異なる複数クラスの階層構造で、上位クラスに実行可能なタスクがあれば下位クラスはまったくCPUを受け取れません。
優先度 高
+------------------+
| stop | CPUホットプラグなどカーネル内部専用
+------------------+
| deadline (DL) | SCHED_DEADLINE: runtime/deadline/period保証
+------------------+
| realtime (RT) | SCHED_FIFO / SCHED_RR: 静的優先度 1-99
+------------------+
| fair (EEVDF) | SCHED_OTHER / SCHED_BATCH: 一般タスク全部
+------------------+
| idle | SCHED_IDLE: 他に誰もいないときだけ
+------------------+
優先度 低
タスクのクラス/優先度の確認と変更
chrt -p 1234 # 現在のポリシー確認
chrt -f -p 50 1234 # SCHED_FIFO 優先度50へ
chrt -d --sched-runtime 5000000 --sched-deadline 10000000 \
--sched-period 10000000 -p 0 1234 # deadlineクラス
RTタスクがCPUを独占しないようにする安全装置
sysctl kernel.sched_rt_runtime_us # 既定 950000 (1秒あたり95%)
実務上の注意: SCHED_FIFOを誤って使うと、優先度99の暴走スレッドがシステム全体を止めることがあります。RTを使う際は必ずsched_rt_runtime_usの安全装置を確認し、可能ならウォッチドッグを置くのが良いでしょう。
cgroupによるCPU制御 — Kubernetesとのつながり
コンテナ時代のスケジューリングは、タスク単位ではなくcgroup単位の制御が中心です。cgroup v2のCPUコントローラが提供する2つの軸は以下のとおりです。
| インターフェース | 意味 | Kubernetes対応 |
| --- | --- | --- |
| cpu.weight (1-10000、既定100) | 競合時の相対的な配分比率 | requests.cpuから換算 |
| cpu.max (quota period) | 周期あたりの絶対上限 | limits.cpuから換算 |
| cpu.stat | 使用量とスロットル統計 | スロットリング診断の核心 |
cpu.weightは「競合するときだけ」働く比率の概念で、cpu.maxは「余っていても」強制的に切る絶対上限です。この違いが運用では決定的に重要です。
cgroup v2 CPU制御の直接実習
mkdir /sys/fs/cgroup/demo
echo "+cpu" > /sys/fs/cgroup/cgroup.subtree_control
重み200 (既定100の兄弟より競合時に2倍)
echo 200 > /sys/fs/cgroup/demo/cpu.weight
100ms周期あたり50ms = CPU 0.5個の上限
echo "50000 100000" > /sys/fs/cgroup/demo/cpu.max
現在のシェルをこのcgroupに入れて負荷テスト
echo $$ > /sys/fs/cgroup/demo/cgroup.procs
KubernetesのCPU limitsはcpu.maxのクォータとして実装されています(CFS bandwidth controlと呼ばれてきたメカニズムで、EEVDF以降も同様に動作します)。問題はスロットリングです。マルチスレッドのアプリケーションが8スレッドで同時に走ると、100ms周期のクォータを数十ミリ秒で使い果たし、残りの周期の間すべて停止します。CPU使用率の平均は低いのにp99レイテンシが跳ねる、典型的なパターンです。
スロットリング診断: コンテナcgroupのcpu.stat
cat /sys/fs/cgroup/kubepods.slice/.../cpu.stat
nr_periods -- 経過した周期数
nr_throttled -- クォータ枯渇で停止させられた周期数
throttled_usec -- 累積停止時間
nr_throttled / nr_periods 比率が1%を超えただけでもテールレイテンシに影響
対応の選択肢は3つです。limitsを引き上げるか外してweight(requests)ベースの保護に頼る、アプリケーションのワーカー数をクォータに合わせる(例: GoのGOMAXPROCS、JavaのActiveProcessorCount)、あるいはレイテンシ重視PodにGuaranteed QoS + static CPU managerで専用コアを与える、です。
CPUアフィニティと分離 — 低レイテンシのレシピ
マイクロ秒単位のレイテンシが重要なワークロード(トレーディング、リアルタイムメディア、パケット処理)は、「スケジューラがうまくやってくれる」ことを期待する代わりに、コアを丸ごと切り出します。
1) ブートパラメータでCPU 2-5を一般スケジューリングから分離
(GRUB_CMDLINE_LINUXに追加)
isolcpus=2-5 nohz_full=2-5 rcu_nocbs=2-5
isolcpus: スケジューラのロードバランシング対象から除外
nohz_full: そのCPUでタスクが1つだけならタイマーティック停止
rcu_nocbs: RCUコールバック処理を他のCPUへ移管
2) IRQも分離コアを避けるように
echo 3 > /proc/irq/default_smp_affinity # CPU 0,1のみ (マスク 0b0011)
3) ワークロードを分離コアに固定
taskset -c 2-5 ./latency-critical-app
または実行中に変更
taskset -cp 2-5 1234
4) 確認: 分離コアでティックが止まったか
cat /proc/interrupts | grep -i "local timer"
最近のカーネルでは、ブートパラメータの代わりにcpuset cgroupの分離パーティションを使う方法もあります。
cgroup v2 cpusetで動的な分離パーティションを構成
mkdir /sys/fs/cgroup/rt-part
echo "+cpuset" > /sys/fs/cgroup/cgroup.subtree_control
echo 2-5 > /sys/fs/cgroup/rt-part/cpuset.cpus
echo isolated > /sys/fs/cgroup/rt-part/cpuset.cpus.partition
分離はタダではありません。分離されたコアは一般ワークロードが使えないため全体の使用率が下がり、設定を誤れば分離コアが遊んで残りが過負荷になる逆効果も起きます。「測定で正当化される場合にのみ」使う道具です。
コンテキストスイッチとランキュー遅延の観測
スケジューリング問題の測定は2つの指標が中心です。コンテキストスイッチの頻度と、ランキューで待った時間(スケジューリング遅延)です。
システム全体のコンテキストスイッチ頻度
vmstat 1 # cs列
プロセスごとの自発/非自発スイッチ
pidstat -w -p 1234 1
voluntary: I/O待ちなど自ら譲った (正常なパターン)
nonvoluntary: スライス枯渇/プリエンプト (競合のシグナル)
perf schedで精密な記録/分析
perf sched record -- sleep 10
perf sched latency --sort max # タスクごとの最大スケジューリング遅延
perf sched timehist # タイムライン: wait time、sch delay列
eBPFでランキュー遅延ヒストグラム (BCC runqlat)
runqlat 10 1
またはbpftraceワンライナー
bpftrace -e 'tracepoint:sched:sched_wakeup { @qt[args->pid] = nsecs; }
tracepoint:sched:sched_switch /@qt[args->next_pid]/
{ @lat = hist(nsecs - @qt[args->next_pid]); delete(@qt[args->next_pid]); }'
解釈の基準を簡単に言うと、ランキュー遅延のp99が数ミリ秒を超えるのにCPU使用率が高くなければcgroupスロットリングやコアの偏りを疑い、使用率も高ければ本当のCPU不足なので増設かワークロード移動が答えです。非自発スイッチが暴増していれば同じコアを奪い合うスレッドが多いということで、アフィニティやワーカー数の調整を検討します。
NUMAバランシング — メモリまで考慮したスケジューリング
マルチソケットサーバーでは、スケジューラはCPUを選ぶだけでなく「どのメモリの近くで実行するか」も決定します。リモートNUMAノードのメモリアクセスはローカルより数十パーセント遅いためです。
NUMAトポロジーの確認
numactl --hardware
lscpu | grep -i numa
自動NUMAバランシングの状態 (ページフォルトベースでタスク/メモリを近づける)
sysctl kernel.numa_balancing
タスクごとのNUMA統計
cat /proc/1234/numa_maps | head
numastat -p 1234 # ノードごとのメモリ分布
明示的な配置 (DBのような大メモリワークロードで)
numactl --cpunodebind=0 --membind=0 ./db-server
自動NUMAバランシングはほとんどのワークロードに利益がありますが、ページフォルトベースのサンプリングコストがあるため、大容量メモリのDBでは無効化して明示的に配置するケースも多いです。これも測定が先です。
実践シナリオ — APIサーバーのテールレイテンシ改善
状況: Kubernetesで動くGoのAPIサーバー。平均応答5ms、しかしp99が150msまで跳ねます。ノードのCPU使用率は55%。段階的に診断します。
ステップ1、まずスロットリングを確認します。使用率が低いのにレイテンシが跳ねるなら、第一容疑者です。
Podのcgroupパスを探してcpu.statを確認
kubectl exec api-pod -- cat /sys/fs/cgroup/cpu.stat
nr_throttledがnr_periodsの8%だったと仮定 -> 容疑者確定
ステップ2、原因を解釈します。limitsが2 CPUなのに、GoランタイムのGOMAXPROCSがノードのコア数である16になっていました。16個のワーカーが同時に走ると200msのクォータ(100ms周期 x 2)を瞬時に使い果たし、周期の終わりまで全停止します。
ステップ3、対策を1つずつ適用して測定します。
対策A: GOMAXPROCSをクォータに合わせる (Go 1.25+はコンテナ認識を自動化、
それ以前はautomaxprocsライブラリまたはenvで)
kubectl set env deployment/api GOMAXPROCS=2
対策B: それでもスロットルが残るならlimitsの引き上げか撤廃を検討
(requestsは維持 -> cpu.weightの保護はそのまま)
対策C: ノード単位の競合が疑われるならrunqlatで隣人の影響を確認
runqlat -p $(pgrep api-server) 10 1
ステップ4、結果を記録します。このパターンでは対策Aだけでnr_throttledがゼロ近くまで下がり、p99が安定するケースが多いです。残った尻尾はGCやネットワーク側の可能性があるので、同じ方法で次の区間を絞り込みます。
核心の教訓: limitsベースのスロットリングは「平均には見えず、テールにだけ見える」問題であり、cpu.statのnr_throttledが最も正直な指標です。
もう一歩先へ — PSIとsched_ext
最後に、スケジューリングの観測と実験のための最新ツールを2つ紹介します。
第一に、PSI(Pressure Stall Information)です。ランキュー遅延をeBPFで直接測るのが精密ですが、PSIは「実行可能だったのにCPUを待っていた時間の割合」をカーネルが常時集計し、ファイルとして提供してくれます。cgroup単位でも提供されるため、PodごとのCPU圧迫の要約指標として非常に実用的です。
システム全体のCPU圧迫
cat /proc/pressure/cpu
some avg10=4.21 avg60=2.10 ... 直近10秒間に一部のタスクが
CPU待ちに費やした時間が4.21%という意味
cgroup(Pod)単位のCPU圧迫
cat /sys/fs/cgroup/kubepods.slice/cpu.pressure
メモリ/IO圧迫も同じ形式 (マルチリソースのボトルネック切り分けに有用)
cat /proc/pressure/memory /proc/pressure/io
「CPU使用率は低いのにcpu.pressureのsomeが高い」という組み合わせは、スロットリングやコアの偏りの強力なシグナルです。使用率より圧迫(pressure)をアラート基準にする方が、テールレイテンシとはるかによく相関します。
第二に、sched_extです。カーネル6.12にマージされた拡張スケジューラクラス(SCHED_EXT)で、スケジューリングポリシー自体をeBPFプログラムとして書き、実行中にロード/差し替えできるフレームワークです。
| 項目 | fair (EEVDF) | sched_ext |
| --- | --- | --- |
| ポリシー決定の主体 | カーネル内蔵アルゴリズム | ユーザーが書いたBPFプログラム |
| 差し替え方法 | カーネルビルド/再起動 | ランタイムでロード/アンロード |
| 失敗時の安全装置 | 該当なし | ウォッチドッグが既定スケジューラへ自動復帰 |
| 適した用途 | 汎用の既定値 | ゲーム/レイテンシ特化、実験、ワークロード特注 |
ゲームワークロードに特化したポリシーや、特定サービスのキャッシュ局所性に合わせたポリシーを、カーネルの再ビルドなしに実験でき、BPF検証器とウォッチドッグのおかげで失敗してもシステムは落ちません。汎用サーバーの既定値を変えることは当面ないでしょうが、「スケジューラをワークロードに合わせる」という選択肢が生まれたことは知っておく価値があります。
落とし穴とアンチパターン
- nice値でレイテンシ問題を解こうとすること。niceはCPU量の配分であり、応答タイミングの保証ではありません。レイテンシが問題ならEEVDFスライス、RTクラス、分離を検討すべきです。
- CPU limitsを「安全装置」として全Podにかけること。競合がなくてもスロットルがかかり、テールレイテンシを害します。weight(requests)の保護とlimits上限の役割の違いを理解して選択すべきです。
- コンテナ内のランタイムがホストのコア数を見ること(GOMAXPROCS、Javaスレッドプール)。クォータとワーカー数の不一致はスロットリングの最頻出原因です。
- SCHED_FIFOをウォッチドッグなしで使うこと。暴走時にシステムハングを引き起こします。
- isolcpusを過剰に確保すること。分離コアはアイドルでも一般タスクが使えません。
- カーネルのメジャーアップグレード(特に6.6前後)でスケジューラの変化を性能テストなしに通すこと。
- 平均CPU使用率だけを見ること。ランキュー遅延とスロットルカウンタがなければテール問題は見えません。
運用チェックリスト
- [ ] カーネルバージョンとスケジューラ(6.6+でEEVDFか)を把握しているか
- [ ] cpu.statのnr_throttled/throttled_usecをPod指標として収集しているか
- [ ] ランキュー遅延(runqlat/perf sched)を測定する手段が用意されているか
- [ ] コンテナランタイムのワーカー数がCPUクォータと整合しているか
- [ ] limitsをかけるPodとかけないPodの基準が文書化されているか
- [ ] RTクラス使用時にsched_rt_runtime_usの安全装置を確認したか
- [ ] 低レイテンシワークロードの分離(専用コア、IRQアフィニティ)が測定で正当化されているか
- [ ] NUMAトポロジーと自動バランシング設定を認識しているか
- [ ] 非自発コンテキストスイッチの暴増に対するアラートがあるか
- [ ] スケジューラ関連の変更は一度に1つずつ、前後のp99とともに記録しているか
おわりに
CFSは「全員に公平に」という単純で強力な原則で15年を耐え、EEVDFはその公平性を維持しながら「いつ実行されるか」というレイテンシの次元をアルゴリズムの中へ取り込みました。運用者にとってスケジューラはブラックボックスではありません。cpu.stat、runqlat、perf schedという3つの窓から覗き込めば、「CPUは余っているのに遅い」というミステリーの答えはたいていその中にあります。次回はスケジューラと対をなす分離技術、cgroupsとネームスペースでコンテナの実体を解剖します。
参考資料
- CFS設計文書: https://docs.kernel.org/scheduler/sched-design-CFS.html
- EEVDFスケジューラ文書: https://docs.kernel.org/scheduler/sched-eevdf.html
- LWN: An EEVDF CPU scheduler for Linux: https://lwn.net/Articles/925371/
- CFS bandwidth control (cpu.maxのメカニズム): https://docs.kernel.org/scheduler/sched-bwc.html
- SCHED_DEADLINE文書: https://docs.kernel.org/scheduler/sched-deadline.html
- cgroup v2公式文書: https://docs.kernel.org/admin-guide/cgroup-v2.html
- sched(7) manページ — スケジューリングポリシー全般: https://man7.org/linux/man-pages/man7/sched.7.html
- NO_HZ (ティックレス) 文書: https://docs.kernel.org/timers/no_hz.html
- perf公式Wiki: https://perf.wiki.kernel.org/
- BCCツール集 (runqlatなど): https://github.com/iovisor/bcc
- Kubernetesコンテナリソース管理: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
- LWN: Completing the EEVDF scheduler: https://lwn.net/Articles/969062/
현재 단락 (1/194)
「CPU使用率は60%なのに、なぜ応答時間が跳ねるのか?」 — 本番運用の経験がある方なら一度は直面した問いでしょう。答えはたいていスケジューラにあります。CPUが余っていても、自分のスレッドがランキ...