Kubernetes 上で学習ワークロードを回す際、Kubeflow Trainer(学習ジョブを定義して動かす道具)と Kueue(それをいつ動かすか制御する道具)はよく組み合わせて使われます。本書は「Kubernetes は触れるが、この 2 つは初めて」という人を想定し、それぞれが何をするものなのか、どう関連するのかを、順を追って説明していきます。
付録Aとして、マニフェストも記述しています。
前提バージョン
-
Kubeflow Trainer v2 系(TrainJob):
- v1 の
PyTorchJob/TFJob/MPIJobを廃し、単一のTrainJobAPI に統一した世代。 - API グループは
trainer.kubeflow.org/v1alpha1。ネット上には v1(Training Operator)の記事も多く残っているため、PyTorchJobなどが出てきたら v1 の情報だと判断する。
- v1 の
-
Kueue v0.17 系:
- 今回は v0.17 を使用する。最新は公式リリースを参照のこと。
- API は
kueue.x-k8s.io/v1beta2。v1beta1 はサポート終了済み。 - 古い記事の v1beta1 YAML はそのまま貼ると
no matches for kind ...で弾かれる。
-
Kubernetes:
- Trainer v2.2 の CRD は k8s 1.33+ 推奨であり、古い API サーバーだと CEL 検証でエラーととなる可能性が高い(§10 参照)。
用語の最短定義
- ワークロード(WL):1 本の学習ジョブ(TrainJob)。
- admit:Kueue が「今このジョブを動かしてよい」と認めること。
- クォータ:ある資源(cpu / memory / gpu)を同時にどれだけ使ってよいかの枠。
1. 2 つの道具の役割分担
まず全体像です。両者は守備範囲が違います。
-
Kubeflow Trainer:
- 「何を・どう学習するか」を担当します。
- 学習コード・使うイメージ・何ノードで分散するか・各ノードのリソース——こうした「学習ジョブそのもの」を定義し、実際に Pod 群へ展開して動かします。
-
Kueue:
- 「それをいつ・どの順で動かすか」を担当します。
- クラスタの資源には限りがあるので、投入されたジョブを列に並べ、枠が空いた順に通します。
言い換えると、Trainer が「ジョブを作る」係、Kueue が「作られたジョブを通す/待たせる」係です。この 2 つは独立して使うこともできますが、組み合わせると「分散学習ジョブを、資源の枠と優先度に従って自動で捌く」という、ML プラットフォームの中核ができあがります。
以降、まず Trainer 単体(§2)、次に Kueue 単体(§3〜§6)、そして両者の合流点(§7)という順で見ていきます。
2. Kubeflow Trainer とは
v1 の乱立から v2 の統一へ
旧世代(Training Operator v1)は、フレームワークごとに別々の CRD がありました。PyTorch なら PyTorchJob、TensorFlow なら TFJob、MPI なら MPIJob……という具合で、実質「フレームワーク別 Operator の寄せ集め」でした。v2(Kubeflow Trainer)はこれを 単一の TrainJob API に統一し、フレームワークの違いは後述の Runtime 側に押し込みました。利用者から見ると「学習ジョブは常に TrainJob 1 種類」になります。
3 つの CRD と「関心の分離」
Trainer v2 は 3 つのオブジェクトでできています。ここが Trainer 理解の中心です。
-
TrainJob:
- 利用者が投入する「今回の学習」の指定。
- どの Runtime を使うか(
runtimeRef)、何ノードで回すか(numNodes)、各ノードのリソース(resourcesPerNode)、学習コードなどを書く。
-
ClusterTrainingRuntime、TrainingRuntime:
- 学習の「やり方」を定義した 再利用テンプレート。
- ベースイメージ、分散フレームワークの設定(torch / MPI)、Pod のテンプレートなどを持つ。
-
ClusterTrainingRuntimeはクラスタ全体で共有、TrainingRuntimeは namespace 単位。
ここには 関心の分離という設計思想があります。プラットフォーム管理者が「学習基盤のやり方」を Runtime として整備し、AI 実践者は難しい分散設定を書かずに、TrainJob から Runtime を参照して「今回はこのコードを 3 ノードで」とだけ指定すればよい——という分担です。この「管理者がテンプレート、利用者が 1 回の指定」という構図は、後で出てくる Kueue の「管理者がクォータ、利用者が投入」とちょうど対応します。
JobSet の上に立つ ── TrainJob は何に化けるのか
TrainJob と Runtime を Trainer コントローラが合成すると、内部的には JobSet という Kubernetes オブジェクトが作られます。JobSet は「複数の Pod を “ひとかたまり(gang)” として一緒に起動する」ための土台で、分散学習のように「全ノードが揃って初めて意味がある」ワークロードに向いています。JobSet はさらに、numNodes の数だけ Pod(node-0, node-1, …)を作ります。図の下段がそれです。
そして分散学習で本来いちばん面倒な、各ノードへの rank / world-size の割り当てや master への接続情報の配布(torchrun や MPI のセットアップ)は、Trainer が自動でやってくれます。利用者は「何ノードか」を指定するだけで、分散の“配線”を手書きする必要がありません。これが v1 の PyTorchJob を手書きしていた時代からの、いちばん大きな改善です。
要するに TrainJob は、「Runtime(やり方)+ 今回の指定」から、JobSet → node Pod 群へ展開される、と押さえておけば十分です。
3. なぜ Kueue が必要か
ここから Kueue 側です。素の Kubernetes には「今すぐ動かす」しかありません。具体例で見ます。
CPU が実質 5 コア使えるノードが 1 台あるとします。ここへ、それぞれ 4 コアを要求する学習ジョブ A・B・C を続けて投入すると、A は置けて Running、B と C は残り 1 コアでは足りず Pending になります。問題は、B と C の Pod がすでに作られてしまい、Pending のまま居座り続けることです。「誰を先に通すか」「A が終わったら次にどちらを動かすか」という 順番待ちと配分の概念が、そもそも存在しないのです。
「ResourceQuota があるのでは?」と思うかもしれませんが、あれは 上限を超えたら作成を拒否(エラー) するもので、「待たせて、空いたら通す」ではありません。学習ジョブは投げるたびに弾かれても困りますし、順番待ちの列にもなりません。
ML の学習は 1 本が数時間〜数日 GPU / CPU を占有します。だからこそ「溢れさせて Pending の山を作る」のではなく、「列に並ばせ、空いた分だけ順に通す」制御が効きます。Kueue はこの Kubernetes に欠けているレイヤ=ジョブキューイングと配分制御 を後付けするものです。先の例なら、B と C は Pod すら作られず控え、A が終わって枠が空いた瞬間に B が自動で動き出します。
4. 最重要の考え方 ── 「入場」と「配置」は別レイヤ
Kueue でつまずく人のほぼ全員が、ここを飛ばしています。kube-scheduler が Pod をノードに置く役割は、Kueue を入れても一切変わりません。 Kueue はその上に「入場ゲート」を足すだけです。
-
kube-scheduler(配置 / scheduling):
- すでに存在する Pod を「どのノードに置くか」決める、Kubernetes 標準の担当。
-
Kueue(入場 / admission):
- ジョブが「そもそも今 Pod を作ってよいか」を決める、後付けの担当。
仕組み:Kueue は「suspend」を握っているだけ
Kueue の動作原理は拍子抜けするほど単純です。管理下に入ったジョブは、まず suspend(停止)状態 で作られ、その間は Pod が 1 つも作られません。Kueue はクォータの空きを監視し、順番が来て枠を確保できた時点で suspend を解除します。すると初めて Pod が生成され、あとは kube-scheduler が配置します。
§2 で見たとおり、TrainJob は内部で JobSet に展開されます。Kueue が握っているのは、まさにこの JobSet の suspend です。「難しい制御」ではなく、「動かしてよくなるまで停止ボタンを押し続けている」だけ、と捉えてください。
この二層構造が生む、最大の実務的帰結
入場と配置が別レイヤということは、ジョブが動かない理由も 2 段階に分かれることを意味します。
- 入場前(Kueue がまだ admit していない → Pod が存在しない)
- 入場後(admit されたが kube-scheduler が置けない → Pod はあるが Pending)
この切り分けがデバッグの土台となります(→ §9・§10)。
5. 4 つの Kueue オブジェクト
Kueue 側も 4 つのオブジェクトで回ります。管理者がクォータ(ClusterQueue)と資源の種類(ResourceFlavor)を握り、利用者は自分の namespace の窓口(LocalQueue)から並ぶ、という分担です(§2 の Trainer の分担と同じ形です)。
-
ResourceFlavor(資源の種類):
- 「どんなノードか」を表す。
- 最小構成では中身を空にして「全ノードを 1 種類とみなす」で始め、GPU / CPU を別枠にしたくなった段階で
nodeLabelsを足して複数 flavor に分ける。
-
ClusterQueue(クォータの箱+入場ルール) :
- Kueue の本体。
- 「どの資源を・どの flavor で・いくつまで(
nominalQuota)」持てるかを定義し、入場の可否と順序を決める。 - クラスタ全体の共有プールと考えれば良い。
- 複数チームで枠を融通し合う
cohortもあるが、初めは 1 ClusterQueue から始めるのが安全。
-
LocalQueue(窓口):
- namespace 内に置く、ClusterQueue への参照にすぎない。
- 直接 ClusterQueue に投げさせないのは 権限分離のためで、利用者には自分の namespace の LocalQueue だけを触らせる。
-
Workload:
- TrainJob を投入した瞬間に Kueue が自動生成する内部オブジェクト。
- 直接書かないが、デバッグで最も見る相手で、次の状態を持つ。
Created → QuotaReserved(枠を確保)→ Admitted(入場)と進み、Admitted になって初めて Pod(= JobSet の node Pod 群)が生成されます。実行中は PodsReady、完了で Finished。高優先ジョブに押し出されると Evicted になり、消えずに列へ戻ります(→ §8)。kubectl get workloads で見えるのがこの状態です。
6. 投入から Node 配置までの流れ(Kueue 視点)
利用者は自分の namespace の LocalQueue に投げ、クォータ判定は ClusterQueue(枠を持つ箱)で行われます。ゲートを通れば Pod が生成され、そこから先は通常の Kubernetes です。
ゲートが見るのは、コンテナの requests(limits ではない) です。TrainJob の要求総量は numNodes × resourcesPerNode.requests。例えば numNodes: 2、requests が cpu: 4 なら要求は cpu: 8 で、ClusterQueue の cpu 枠が 5 なら、他に何も動いていなくても待機します。「limits を小さくすれば通る」は効きません。判定は requests だからです。
7. Trainer と Kueue はどう連携するか
ここが本書の合流点です。両者の接続は、Kueue 側の TrainJob 連携 と、TrainJob 側の ラベル 1 つで成立します。
仕組みはこうです。Kueue で TrainJob(trainer.kubeflow.org/trainjob)を認識できるように設定しておくと、TrainJob が投入されたとき Kueue はその Workload を生成し、入場ゲートにかけます。ここで効くのが §4 の suspend で、Kueue が admit するまで、Trainer は JobSet(= node Pod 群)を作りません。admit された瞬間に suspend が外れ、Trainer が JobSet を展開し、Pod が生まれ、kube-scheduler が配置します。クォータ判定に使う要求量は、TrainJob の numNodes × resourcesPerNode.requests が podSets として Kueue に渡されます。
利用者がやることは、TrainJob に 2 つのラベルを付けるだけです。
metadata:
labels:
kueue.x-k8s.io/queue-name: lq-team-a # ← Kueue 管理下に入れる(annotation ではなく label)
kueue.x-k8s.io/priority-class: high # ← 優先度(任意、§8)
投入したら、まず Workload を見ます。
$ kubectl get workloads -n team-a # 列は版により異なる(見るべきは ADMITTED)
NAME QUEUE ADMITTED AGE
trainjob-lora-sft-abcde lq-team-a True 25s
ADMITTED=True なら suspend が外れ、node Pod が生成されているはずです。逆に、TrainJob を投げたのに get workloads に何も出てこない場合、それは「Kueue が管理していない」サインで、ほぼ確実に queue-name ラベルの付け忘れか綴り間違い(annotation に書いてしまった等)です。
8. 優先度とプリエンプション
「探索用ジョブが枠を埋めているが、本番の重要な学習を今すぐ割り込ませたい」。これを実現するのが優先度とプリエンプション(横取り)で、学習ワークロード制御で最も価値が出る部分です。
枠が低優先ジョブで満杯のところへ高優先ジョブが来ると、Kueue は低優先を evict(退避) して枠を空け、高優先を admit します。退避された低優先ジョブは 消えずにキューへ戻り、空きが出れば再び動き出します(§5 のライフサイクル図の Evicted → QuotaReserved ループ)。設定は 3 つのパーツで構成されます。
-
WorkloadPriorityClass:- 優先度の定義。
-
valueが大きいほど優先(例:high=1000、low=100)。 - Kubernetes 標準の
PriorityClassとは別物で、「キュー内での優先度」を表す。
-
ClusterQueue の
preemption:- 横取りの許可。
-
withinClusterQueue: LowerPriorityであれば「同じ ClusterQueue 内の低優先ジョブを追い出してよい」となる(Neverなら横取りしない)。
-
TrainJob のラベル:
-
kueue.x-k8s.io/priority-classでそのジョブの優先度を指定。
-
退避は「計算のやり直し」なので乱発するとムダが増えます。学習を チェックポイントから再開できるようにしておくと、割り込みで失う計算を最小化できます。定義については付録A-2・A-4 に記載しています。
9. 「動かない」には 2 種類ある
§4 の二層構造の、実務における最大の帰結です。ジョブが動かないとき、まずこの切り分けから始めると原因の半分は数分で割れます。
① 入場前の待機(左)
- Kueue がまだ admit していない状態で、Pod はそもそも存在しない。
- Workload は
ADMITTED=Falseで列に並んでいる。 - 原因はほぼ「クォータ不足」か「ClusterQueue が Inactive」で、
get workloadsとget clusterqueueを見る。
② 入場後の Pending(右)
- admit は済んだのに kube-scheduler が置けない状態で、Pod は存在しており
Pendingとなっている。 - 原因は「実容量不足」「taint / affinity の不一致」など標準の配置問題で、
describe podの Events を見る。
最頻出の罠:nominalQuota > 実 allocatable
これが②の典型例です。
ノードの実際の allocatable が cpu 5 なのにnominalQuotaを 7 に設定し、cpu 6 のジョブを投げると、Kueue は「枠に収まる」と admit します。しかし実際のノードには 5 コアしかないため kube-scheduler が配置できず、Workload はADMITTED=Trueなのに Pod はPendingになります。
$ kubectl describe pod -n team-a <pod名>
...
Events:
Warning FailedScheduling ... 0/1 nodes are available: 1 Insufficient cpu.
「admit されたのに動かない」という状況になったら、まずこれを疑ってください。
対処法としては nominalQuota を実際の allocatable に合わせることです。具体的にはkubectl describe node の Allocatable から常駐システム Pod の分を引いた値を枠にします。単一ノードで実 5 コアなら、枠は 7 ではなく 5 が正解となります。
10. デバッグの型
まず 「未 admit の Pending か、admit 後の Pending か」 を切り分けます。次の順で見れば、たいていの「動かない」状態について状況を判断することができます。
ステップ 1:Workload は admit されているか?
-
kubectl get workloads -n team-aを見ます。 -
ADMITTED=False(または Workload はあるのに Pod が無い)なら 入場前です。 -
kubectl get clusterqueue cluster-queue -o wide(PENDING/ADMITTED の件数・空き枠)とkubectl describe clusterqueue cluster-queue(Active条件の理由)を確認します。 -
Active=Falseはほぼ ResourceFlavor 名のスペル不一致です。
ステップ 2:Pod は Pending か?
- Workload が
ADMITTED=Trueなら入場済み。 -
kubectl get pods -n team-aで Pod があってPendingなら 配置の問題で、describe podの Events を見ます。 - 前述の
Insufficient cpuや taint / affinity の不一致はここに出ます。
ステップ 3:経緯を追う。
-
kubectl describe workload -n team-a <workload名>でQuotaReserved/Admitted/Evictedのイベントが時系列で読めます。
その他のハマりどころ
-
Kueue の API バージョンのズレ:
- v1beta1 の YAML を 0.17 に適用して
no matches for kindで弾かれる。→ すべてv1beta2に。
- v1beta1 の YAML を 0.17 に適用して
-
Trainer CRD の CEL 検証エラー
undefined field 'namespace':-
namespaceは CEL 予約語。 - Trainer v2.2 の CRD は k8s 1.33+ 前提で生成されているため、古い API サーバー(例:1.31)が弾く。→ CRD を手修正せず、k8s を 1.33+ に上げる。
-
-
queue-nameラベルの付け忘れ / タイポ:- Kueue 管理外になり
get workloadsに出てこない。 - label であって annotation ではない点に注意。
- Kueue 管理外になり
付録A. 最小マニフェスト
A-1. Kueue 基盤(namespace / ResourceFlavor / ClusterQueue / LocalQueue、v1beta2)
apiVersion: v1
kind: Namespace
metadata:
name: team-a
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: ResourceFlavor
metadata:
name: default-flavor
# spec を空にすると「全ノードを同一 flavor とみなす」= 最小構成。
# GPU / CPU を分けたくなったら nodeLabels / tolerations を足して複数 flavor 化する。
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: ClusterQueue
metadata:
name: cluster-queue
spec:
namespaceSelector: {} # {} = 全 namespace から利用可
resourceGroups:
- coveredResources: ["cpu", "memory"]
flavors:
- name: default-flavor
resources:
- name: cpu
nominalQuota: "5" # ノードの「実 allocatable」に合わせる
- name: memory
nominalQuota: 8Gi
preemption:
withinClusterQueue: LowerPriority # 低優先ジョブの追い出しを許可(§8 で使用)
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: LocalQueue
metadata:
namespace: team-a
name: lq-team-a
spec:
clusterQueue: cluster-queue
適用後、kubectl get clusterqueue cluster-queue -o wide で Active=True を確認する。
A-2. 優先度クラス(WorkloadPriorityClass、v1beta2)
apiVersion: kueue.x-k8s.io/v1beta2
kind: WorkloadPriorityClass
metadata:
name: high
value: 1000
description: "重要な実験・本番学習"
---
apiVersion: kueue.x-k8s.io/v1beta2
kind: WorkloadPriorityClass
metadata:
name: low
value: 100
description: "試行・探索"
A-3. 学習の“やり方”(ClusterTrainingRuntime、torch 分散)
apiVersion: trainer.kubeflow.org/v1alpha1
kind: ClusterTrainingRuntime
metadata:
name: torch-distributed
labels:
trainer.kubeflow.org/framework: torch
spec:
mlPolicy:
numNodes: 1 # TrainJob 側の numNodes で上書きされる
torch:
numProcPerNode: auto # rank / world-size は Trainer が自動設定
template:
spec:
replicatedJobs:
- name: node # ← 複製ジョブ名は node(TrainJob の当て先)
template:
metadata:
labels:
trainer.kubeflow.org/trainjob-ancestor-step: trainer
spec:
template:
spec:
containers:
- name: node
image: pytorch/pytorch:2.7.1-cuda12.8-cudnn9-runtime
# 実際に利用する際は、自身の環境に合わせて更新する
replicatedJobsの ancestor ラベル(trainer.kubeflow.org/trainjob-ancestor-step: trainer)や volume など、必要に応じて拡張してご利用ください。
A-4. TrainJob(Kueue 管理下)
apiVersion: trainer.kubeflow.org/v1alpha1
kind: TrainJob
metadata:
name: lora-sft
namespace: team-a
labels:
kueue.x-k8s.io/queue-name: lq-team-a # Kueue 管理下に入れる
kueue.x-k8s.io/priority-class: high # 優先度(任意)
spec:
runtimeRef:
apiGroup: trainer.kubeflow.org
kind: ClusterTrainingRuntime
name: torch-distributed # A-3 の Runtime を参照
trainer:
numNodes: 1
resourcesPerNode: # ここの requests がクォータ消費の実体
requests:
cpu: "4"
memory: 6Gi







