ステータス:ドラフト(2026-10-07更新、2026-10-09規模テストの実測値補強)。 公式ドキュメントで確認した記述がベースであり、**第8節の隔離環境テストと末尾の「kopfオペレーター直接テスト」(規模テスト含む)、「LWS直接テスト」**のみが直接確認した値です。残りはまだ実測していません。 記事末尾の「確認したバージョンと日付」と「確認できなかったこと」を先にお読みください。 作成ルール:実測データを基本とし、測定できなかったものは「未実測」と表記します。
目次
- 1. なぜDeployment単体では不十分なのか
- 2. LWS、KServe、DynamoのAPIの構造
- 3. オペレーターのコアパターン
- 4. Go(controller-runtime/kubebuilder)対Python(kopf)
- 5. CRDスキーマ設計の原則
- 6. ロールアウト戦略
- 7. GitOps(Argo CD)との衝突
- 8. 隔離環境で確認した動作
- 9. 実際に試してみる
- 10. 面接で出そうな質問
- 11. よくある誤解
- 外部の根拠
- 参考資料(実際に参照したもの)
- 確認したバージョンと日付(2026-10-05)
LLMサービングのデプロイには、Deploymentでは表現しきれない要件が揃っています。複数ノードにまたがるリーダーとワーカー、重みのロードという事前段階、比率を守るべきロールアウト、GPUトポロジー、単一のPodではなくまとまりを単位とするスケールです。KubernetesはこれをCRD(Custom Resource Definition)とオペレーターで拡張します。この記事では、そのAPIの現在の姿とオペレーターのコアパターンを整理し、隔離された検証環境で確認した動作を根拠として添えます。プロダクション規模のGoオペレーターを運用した経験は私にはないという点を先にお断りしておきます。
この記事を読んだ後に面接で説明できるようになること
- DeploymentでLLMサービングを表現することが難しい理由と、LeaderWorkerSet(LWS)、KServe、Dynamoがそれぞれどのような抽象化を追加するのか。
- 調整(reconcile)ループの冪等性、レベルベースのトリガー、オーナー参照とfinalizer、ステータス条件、バックオフ、リーダー選出がなぜそのように設計されているのか。
- CRDのバージョン移行手順、ロールアウト戦略、Argo CDとオペレーターが同じフィールドをめぐって競合する問題の解決方法。
1. なぜDeployment単体では不十分なのか
- スケールの単位がグループである。 モデルレプリカ1つが複数ノードのGPUにまたがる場合、レプリカはリーダーPodとワーカーPodのまとまりとなります。1つのPodが終了すると集合通信が破綻するため、LWSはグループ全体を再作成するポリシー(
RecreateGroupOnPodRestart)をCRDのデフォルト値に設定します。 - 事前段階が長大かつ高コストである。 第8部で見たように、重みのロードに数分かかります。GPUが1枚だけの環境では、
strategy: Recreateが使われることもあります。新しいPodを先に起動するローリングアップデートには余剰のGPUが必要となるためです。 - 調整されたロールアウト。 プリフィルとデコードを分離したサービングでは、アップデート中も両ロールの容量比率を維持する必要があり、LWSのDisaggregatedSetがこのためのAPIです。
- トポロジーとギャングスケジューリング。 グループのPodは、同じトポロジードメイン内ですべて一緒にスケジュールされる必要があります。Dynamoのドキュメントでは、マルチノード環境におけるGroveとKAI Scheduler(デフォルト)、またはLWSとVolcanoの組み合わせが案内されています。
2. LWS、KServe、DynamoのAPIの構造
| プロジェクト(確認したバージョン) | CRD(APIグループ/バージョン) | 注目すべきフィールド |
|---|---|---|
| LWS v0.11.1 | LeaderWorkerSet (leaderworkerset.x-k8s.io/v1) |
replicas(グループ数)、 leaderWorkerTemplate.size, restartPolicy, rolloutStrategy.rollingUpdateConfiguration(maxUnavailable デフォルト1、 maxSurge デフォルト0、 partition), groupIdentity(変更不可)、scaleサブリソース |
| LWS v0.11.1 | DisaggregatedSet (disaggregatedset.x-k8s.io/v1) |
roles[]ごとにLWS1つ、ロールアウトをロール全体で調整 |
| KServe v0.21.0 | InferenceService(v1beta1), ServingRuntime(v1alpha1) |
predictor など、ランタイムが対応モデルフォーマットを宣言 |
| KServe v0.21.0 | LLMInferenceService(v1alpha1、ストレージは v1alpha2) |
model, template, worker(マルチノードの場合はLWS)、 prefill, router, rolloutStrategy, kvCacheOffloading(v1alpha2のみ) |
| Dynamo v1.5.0 | DynamoGraphDeployment(nvidia.com、ストレージは v1beta1) |
components[](v1alpha1では services マップ)、 restart, topologyConstraint |
| Dynamo v1.5.0 | DynamoComponentDeployment, DynamoGraphDeploymentRequest, DynamoGraphDeploymentScalingAdapter, DynamoModel |
コンポーネントのデプロイ、SLAベースのリクエスト(プロファイリング後にDGDを生成)、HPA用のscale、モデル登録 |
KServeのドキュメントでは、 InferenceService を従来の予測モデル用に位置づけ、プリフィル/デコードの分離とマルチノード、プレフィックス認識ルーティングは LLMInferenceService が担うとしています(0.20のページを開きました)。DynamoのCRD名とフィールドは、v1.5.0タグのCRD YAMLから直接読み取りました。
DynamoGraphDeployment (dgd) ← 사용자가 선언: 컴포넌트 목록, 복제 수, 재시작 id
│ 오퍼레이터가 만듦
▼
DynamoComponentDeployment (dcd) ← 컴포넌트마다, 이름에 worker hash 접미사
│ 배포 모드에 따라
├─▶ Deployment (단일 노드)
└─▶ Grove PodCliqueSet 또는 LWS (다중 노드, 갱 스케줄링)
3. オペレーターのコアパターン
API Server ─watch▶ Informer(로컬 캐시) ─▶ WorkQueue(키 중복 제거, 백오프) ─▶ Reconcile(키)
▲ │
└────── 변경(자식 생성, status 기록) ◀── 관측 → 비교 → 행동 → 보고 ◀────┘
レベルベースの調整。 controller-runtimeのソース(v0.25.2)のコメントには、調整はイベントによってではなく、apiserverやキャッシュから読み取った実際のステートによって駆動されると記載されています。 Request には名前とネームスペースしか含まれておらず何が変更されたかは分からないため、Pod削除イベントを受け取った場合でも調整関数は「Podがない」という状態を読み取ってそれを把握します。kubebuilderの書籍のGood practicesの章でも、イベントごとにロジックを書くとリソースが詰まり人間の手が必要になると警告しています。kopfはハンドラーが作成、変更、削除という原因別に呼び出されるモデルであるため、監視していない子リソースの変化はタイマーで補う必要があります(第9節)。ただし、稼働停止中の変更は起動時にすべてのオブジェクトをリストして最後の状態のみを処理するレベルベースで扱われます。
冪等性。 「作成せよ」ではなく「望ましい姿で存在させよ」という方針で記述します。すでに一致している場合は何も書き込まないことで、下位オブジェクトの resourceVersion がそのまま維持され、不要な監視イベントが発生しなくなります。リーダー選出が行われていても、一時的にリーダーが2つ存在する可能性があるため必須です。
オーナー参照とfinalizer。 Kubernetesのドキュメントによると、子リソースの ownerReferences は同じネームスペース内のオーナー名とUIDを指し示し、ネームスペースをまたぐ参照は設計上禁止されており、ガベージコレクターが OwnerRefInvalidNamespace の警告イベントを残します。finalizerが存在する場合、削除リクエストは deletionTimestamp が付与されるだけで、コントローラーがクリーンアップを完了してfinalizerを削除するまで実際には削除されません。kubebuilderの書籍では、削除が更新に置き換わるため、finalizerの追加とクリーンアップロジックの両方を冪等に書くよう求めています。
ステータスと条件。 status.conditions と observedGeneration により、状態を標準形式で公開します。DynamoのDGDも conditions, observedGeneration, state, rollingUpdate をstatus内に保持します。Argo CDのドキュメントでは、CRDのstatus形式はまちまちであるためカスタムヘルスチェックが必要であり、 observedGeneration を用いることで調整が完了する前の誤判定を防ぐことができると案内されています。
リトライとバックオフ。 調整関数がエラーを返すとワークキューが指数バックオフで再キューイングし、 RequeueAfter は指定時間後に再キューイングを行い、 Requeue フィールドは非推奨(deprecated)です。 TerminalError は再キューイングしません。「まだ準備できていない」という状態はエラーではなく、遅延付きの再キューイングとして表現するのが適切です。
リーダー選出。 controller-runtimeのマネージャーは LeaderElection, LeaderElectionID, LeaderElectionNamespace オプションでLeaseを取得します(Dynamoの main.go で確認済みであり、LMCacheオペレーターのインストール用YAMLにもleader-election Roleが存在します)。Kubernetesのドキュメントでは、Leaseがノードのハートビートやコントロールプレーンのリーダー選出に使用されると説明されています。
Webhook検証。 単純なルールであればCRDスキーマのCEL(x-kubernetes-validations)で十分ですが、複雑なルールやインジェクションにはWebhookを使用します。Dynamoは検証Webhookにおいて field.ErrorList を使って複数の独立したエラーをまとめて返すルールをドキュメント化しており、LMCacheオペレーターは変異WebhookによってvLLMのPodに接続設定をインジェクションし、Webhook証明書をcert-managerで発行します。
4. Go(controller-runtime/kubebuilder)対Python(kopf)
| 項目 | Go: controller-runtime v0.25.2, kubebuilder v4.16.0 | Python: kopf 1.44.6 (2026-06-03) |
|---|---|---|
| モデル | 型付きクライアント、インフォーマーキャッシュ、ワークキュー、マネージャー | デコレーターハンドラー、ステータスをオブジェクトのアノテーションやstatusに保存 |
| コード生成 | controller-gen でCRDを生成(Dynamoはv0.17.3) |
CRD YAMLを手動で記述 |
| 重複防止 | Leaseリーダー選出 | KopfPeeringと優先度 |
| エコシステム | DynamoオペレーターはKubebuilderで作成されたとREADMEに記載されており、変換は conversion.Convertible インターフェースで実装。LWS、KServe、LMCacheのCRDもcontroller-genで生成されている |
ステータスをオブジェクトに持たせ、 kopf.adopt などのツールを提供。kopf自身はKubernetesクライアントライブラリではないと表明している |
私の判断です。変異Webhookや大規模な監視が必要となるアップストリーム級のCRDではGoエコシステムがデフォルトであり、1〜2チームが使用する内部自動化ツールであればチームが最も使い慣れている言語が有利です。私にはGoオペレーターを運用した経験がないため、上記の比較はドキュメントとコードを読み込んだ結果に基づいています。
5. CRDスキーマ設計の原則
- スペックは小さく、一度きりのコマンドはステータスに。
restartNow: trueのような命令型のフィールドは、実行後に意味が曖昧になります。DynamoのDGDはrestart.idを変更すると再起動し、CELの遷移ルールoldSelf.hasValue() || !has(self.restart)によって作成時点ではrestartを空欄にしておくように強制します。 - デフォルト値はスキーマに記述する。 LWSは
sizeデフォルト1、restartPolicyデフォルトRecreateGroupOnPodRestartをCRDに持たせます。一方、LMCacheのLMCacheEngineのisolatedIPCはGPUベンダーによって異なるため静的なデフォルト値がなく、オペレーターが計算するため、オペレーターのアップグレードによってデフォルトの挙動が変わり、エンジンPodが再起動される可能性があるとドキュメントで警告されています。 - CELによる検証。 DynamoのDCDは
minAvailable <= replicasとminAvailable,typeの変更不可をCELで制限し、KServeのLLMInferenceServiceはreplicasとscalingの同時指定を禁止します。既存のオブジェクトを破壊しない検証のラチェッティング(ratcheting)は、Kubernetes 1.33からstableになります。 - バージョンと変換。
served(リクエストを受け付けるか)とstorage(etcdに書き込む表現、正確に1つ)は異なる軸です。スキーマが同じであれば変換戦略Noneで十分ですが、構造が変更される場合は変換Webhookが必要になります。Dynamoはv1alpha1(非推奨)からv1beta1(ストレージ)への移行に伴い、servicesマップがcomponentsのリストに変更され、変換Webhook、ハブ・スポーク規則、表現できないフィールドをスパースアノテーションに保存する規則がCONVERSION.mdに文書化されています。KServeのLLMInferenceServiceにはCRDに変換設定がなくデフォルト戦略Noneとなっており、kvCacheOffloadingはv1alpha2にのみ存在します(スキーマを比較して確認しました)。ストレージバージョンの移行にはStorageVersionMigrationAPIが存在しますが、Kubernetesのドキュメントで1.35からBetaと表記されるようになった一方で、デフォルトで有効になるか否かについてはドキュメント内で記述が食い違っており確認できませんでした。DynamoはCluster APIのmigratorを流用しています。
6. ロールアウト戦略
- LWS: ローリングアップデートがグループ単位であり、
maxUnavailable,maxSurgeはグループ数を基準とします(両方とも0にはできません)。partitionにより一部の序数のみを新しいテンプレートに昇格させるカナリ―になり、Hash モードではサポートされません。maxSurgeには追加の GPU グループが必要です。DisaggregatedSet はロール間の容量比率を維持して一緒にロールアウトを行い、partition はサポートしていません。 - Dynamo: ワーカー入力のハッシュを DGD アノテーション(
nvidia.com/current-worker-hash-v2)に置き、ハッシュが変更された場合は新しいサフィックスを持つ DCD を作成してロールアウトを行い、完了した後に初めてアノテーションをコミットします。オペレーターのアップグレードによってロールアウトが引き起こされないようにするための v1 から v2への移行ルールもあります。status.rollingUpdateは、シングルノードおよび非 Grove デプロイのみをサポートしています。 - KServe:
canaryTrafficPercentにより新しいリビジョンへトラフィックの一部を送り、最後に正常だったリビジョンを記憶してロールバックを行います。異常なリビジョンには送信しません。サーバーレスデプロイモードでのみサポートされます。 - モデルバージョンの切り替えとブルーグリーン: 新しいバージョンを別グループとして立ち上げ、モデルのロードと readiness の通過を確認してからトラフィックを移行する順序です。上記ツールの共通原則を筆者がまとめた表現であり、単一ドキュメントの引用ではありません。
7. GitOps(Argo CD)との競合
競合は、Git に記述されているフィールドをオペレーターや HPA が変更するときに発生します。 replicas を Git に記述すると HPA と競合します(HPA が増やすと Argo が戻し、さらに HPA が増やす)。解決手段は次の通りです。
ignoreDifferences:jsonPointers,jqPathExpressions,managedFieldsManagersにより比較から除外します。システム全体はargocd-cmのresource.customizations.ignoreDifferences.<그룹>_<종류>で指定します。RespectIgnoreDifferences=true: デフォルトでは比較にのみ適用され、同期時には望ましい状態がそのまま適用されるため、同期時からも除外するにはこのオプションが必要です。すでに存在するリソースにのみ効果があります。- サーバーサイド diff (v3.1.0以降で stable): サーバーサイド Apply のドライラン結果と比較するため、アドミッションコントローラーが diff 計算に参加します。リソースを最初に作成する際には実行されません。
- カスタムヘルスチェック (Lua): オペレーター CR の status をアプリの健康状態に反映させます。
よくある罠はヘルスチェックです。アプリの健康状態が下位リソースの状態によって決まり、同期がその健康状態を待つ構造になっている場合、組み込みのチェックが特定のリソース(例:一度失敗した CronJob)を Degraded と判定すると、同期が健康状態を待ったまま失敗し、新しい変更が反映されないことがあります。チェックルールは Lua で再定義することで解決できます(この動作がどのバージョンで発生するかは確認していません)。ApplicationSet でも、子アプリケーションの自動化をオフにしても親テンプレートが再びオンにしてしまうという 2 つのコントローラー間の競合が発生し、その解決策は 1 つのフィールドのみに名前とパスを絞って例外を設定することです。
8. 隔離された環境で確認した動作
以下は、隔離された検証環境(kwok クラスターまたは個人の VM)において実際の Kubernetes コントローラーで確認した内容であり、筆者のプロダクションでの障害記録ではありません。
- オーナー参照 UID のエラー uid が間違っている子は、ガベージコレクターが親はすでに削除されていると見なして数秒以内に削除します。コントローラーが親の uid をキャッシュしたまま、親と同じ名前で再作成された場合、古い uid で作成された子は作成されるやいなや消滅します。ネームスペースをまたぐ参照は apply が通過して警告イベントだけが残り、そのイベントはデフォルトで 1 時間経つと消えます。オペレーターを先に削除して CR を削除すると Terminating のまま停止するため、finalizer を手動で外すと外部のクリーンアップをスキップしてしまうため、CR を先に削除してからオペレーターを片付ける必要があります。
- ストレージバージョンの移行 戦略
Noneでは、新しいフィールドのデフォルト値が保存時に埋められるため、古い表現のオブジェクトには再書き込みを行うまで表示されず、コントローラーが同じ種類を 2 通りに扱ってしまう可能性があります。storedVersionsに残っているバージョンをspec.versionsから外すと、API サーバーがリクエストを拒否することは検証用 Pod で確認しました。Webhook サーバーを起動できなかったため、変換 Webhook はテストできていません。 - 調整ループ Go 컨트롤러를 실행할 수단이 없어 셸 스크립트 조정기로 관측, 비교, 행동, 보고와 멱등성(
resourceVersion不変)を採点し、informer キャッシュとワークキューは模倣しません。リーダー選挙の検証では、2つのインスタンスが同じフィールドを使用しようとするとサーバーサイド Apply が競合を通知するという形で、リーダーが 2 つ存在する区間の危険性を示しています。
制限 上記は検証によって確認された Kubernetes 自体の動作であり、特定のオペレーター実装や Go 製の本番オペレーターの障害対応ではありません。
9. 実際にやってみる
ローカルの kind クラスター 1 つでのみ行います。すべてのコマンドが kind-op-lab コンテキストを明示するため、他のクラスターには影響しません。終わったら最後の行でクラスターごと削除します。
kind create cluster --name op-lab
kubectl --context kind-op-lab create ns op-lab
cat <<'EOF' | kubectl --context kind-op-lab apply -f -
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata: {name: modelcaches.labs.example.dev}
spec:
group: labs.example.dev
scope: Namespaced
names: {kind: ModelCache, plural: modelcaches, singular: modelcache, shortNames: [mc]}
versions:
- name: v1
served: true
storage: true
subresources: {status: {}}
additionalPrinterColumns:
- {name: Phase, type: string, jsonPath: .status.phase}
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [model]
properties:
model: {type: string}
replicas: {type: integer, default: 1, minimum: 0, maximum: 8}
x-kubernetes-validations:
- {rule: "self.model.size() > 0", message: "model must not be empty"}
status:
type: object
x-kubernetes-preserve-unknown-fields: true
EOF
最小調整ループ(op.py)は、1 つの CR に 1 つの ConfigMap をオーナー参照で結びつけておきます。kopf は pip install kopf kubernetes でインストールします。
import kopf, kubernetes as k8s
@kopf.on.resume("labs.example.dev", "v1", "modelcaches")
@kopf.on.create("labs.example.dev", "v1", "modelcaches")
@kopf.on.update("labs.example.dev", "v1", "modelcaches")
@kopf.timer("labs.example.dev", "v1", "modelcaches", interval=30, idle=10)
def reconcile(spec, name, namespace, patch, **_):
cm = {"apiVersion": "v1", "kind": "ConfigMap",
"metadata": {"name": f"{name}-cfg"},
"data": {"model": spec["model"], "replicas": str(spec.get("replicas", 1))}}
kopf.adopt(cm) # ownerReferences: 이 CR의 이름과 uid
api = k8s.client.CoreV1Api()
try:
api.create_namespaced_config_map(namespace, cm)
except k8s.client.ApiException as e:
if e.status != 409:
raise
api.patch_namespaced_config_map(f"{name}-cfg", namespace, cm) # 멱등
patch.status["phase"] = "Ready"
@kopf.on.delete("labs.example.dev", "v1", "modelcaches")
def cleanup(name, logger, **_):
logger.info("%s: 외부 자원 정리 자리", name) # 삭제 핸들러가 있으면 kopf 가 finalizer 를 붙인다
kubectl config current-context # kind-op-lab 인지 확인(kind 가 만들면서 바꿉니다)
kopf run --standalone --namespace op-lab op.py &
kubectl --context kind-op-lab -n op-lab apply -f - <<'EOF'
apiVersion: labs.example.dev/v1
kind: ModelCache
metadata: {name: demo}
spec: {model: tiny-llm}
EOF
kubectl --context kind-op-lab -n op-lab get mc,cm -o wide
kubectl --context kind-op-lab -n op-lab get cm demo-cfg -o jsonpath='{.metadata.ownerReferences}'
# 에지 대 레벨 관찰: 자식을 지워도 CR 이벤트가 없으므로 타이머(30초 주기)가 되살립니다
kubectl --context kind-op-lab -n op-lab delete cm demo-cfg && sleep 40 && kubectl --context kind-op-lab -n op-lab get cm
# CEL 검증: spec.model 을 빈 문자열로 넣은 CR 을 적용하면 "model must not be empty" 로 거절됩니다
# 정리
kill %1; kind delete cluster --name op-lab
続いて試すこと: ownerReferences の uid を意図的に間違えて書いた ConfigMap が削除されるか確認し、CR 削除中に deletionTimestamp と finalizer のリストを確認します。このスケルトンは子の変更を監視しないため、タイマーで補完した学習用です。
10. 面接で問われそうな質問
- LLM サービングに Deployment の代わりに LWS を使用する理由は? レプリカがリーダーとワーカーのセットであり、スケール、ローリングアップデート、障害復旧、トポロジ配置をグループ単位で行う必要があるためです。1つの Pod が失敗すると集合通信が途切れるため、グループ全体を再作成するポリシーがデフォルトになっています。
- 調整ループがイベントを取り逃がしても問題ない理由は? リクエストにイベントの内容が含まれておらず、毎回現在の状態を読み取って差分を埋めるためです。コントローラーが一度死んでから生き返っても、最初からやり直して終わっていない部分を処理するため、すべての書き込みがべき等である必要があります。
- ownerReference と finalizer の違いは? 前者は子が親を指すことでガベージコレクターにクリーンアップを任せる仕組みであり、uid が間違っていると子は削除されます。後者は外部リソースのクリーンアップのために削除を遅らせる仕組みであり、コントローラーなしで外すとクリーンアップがスキップされます。
- CRD を v1alpha1 から v1beta1 に昇格させる手順は? 新しいバージョンを served として追加し、必要に応じて変換 Webhook をアタッチし、storage を移行して既存のオブジェクトをすべて再書き込みした後に
storedVersionsを整理し、古いバージョンを停止します。戦略Noneは、フィールドの形状が同じ場合にのみ安全です。 - Argo CD がオペレーターによって変更されたフィールドを戻してしまう場合は? Git からそのフィールドを外し、それが難しい場合は
ignoreDifferencesで比較から外し、RespectIgnoreDifferencesで同期から除外します。健康状態は CR 用の Lua チェックによってobservedGenerationを反映させます。
11. よくある誤解
- 調整関数はイベントハンドラーである:キーを受け取って現在の状態を再度読み取る関数です。
- リーダー選挙があれば同時に 2 箇所で動作しない:瞬間的に 2 つ存在することがあります。
- finalizer を外せば Terminating が安全に解決される:外部のクリーンアップがスキップされます。
- フィールドの追加は常に互換性がある:戦略
Noneでは古い表現は新しいデフォルト値を持つことができません。 - Argo CD が緑色であればモデルがサービング中である:CR の健康状態はカスタムチェックがあって初めて反映されます。
外部の根拠
| 主張 | 資料 | 確認した内容 |
|---|---|---|
| 調整はイベントではなく現在の状態によって駆動される | controller-runtime pkg/reconcile/reconcile.go の Request·Reconciler コメント |
Request には名前とネームスペースのみが含まれており、イベントの内容はありません。調整はレベルベースであり、apiserver やキャッシュから読み取った実際の状態で駆動されると記載されており、Pod が削除されたという事実も削除イベントからではなく状態を読み取る過程で知ることになるという例が挙げられています。 Requeue フィールドは非推奨であり、 RequeueAfter を使用するように記載されている |
| イベントごとのロジックはアンチパターン | kubebuilder ブック、Good practices | イベントごとに調整ロジックを書くとオペレーターパターンから外れ、リソースがブロックされて手動作業が必要になる可能性があると警告しており、再調整が何度実行されても同じ結果を出すように書くよう求めている |
| コントローラーは停止していた時間を認識していない | Kubernetes コミュニティドキュメント(sig-api-machinery、controllers.md) | 遷移ではなく現在の状態を監視する必要があり、インフォーマーが定期的に再同期を行い、ワークキューにはキーのみを入れて重複を排除し、エラーは指数バックオフで再キューイングする |
| kopf の監視範囲とタイマー | kopf ドキュメント(handlers、timers、continuity、errors) | ハンドラーは diff で検出した本質的な変化にのみ反応し、監視対象外の子の変化についての記述はない。タイマーはオブジェクトが存在する間周期的に実行され、停止中の変更は再起動時に列挙されて最後の状態のみが処理され、通常エラーの再試行間隔のデフォルト値は 60 秒 |
| 「level-triggered」という用語の出処 | 上記の controller-runtime のコメントおよびコミュニティドキュメント | kubernetes.io のコントローラー概念ページでは制御ループと「現在の状態を望ましい状態に近づける」については説明しているが、この用語自体は使用していない。本稿ではこの用語を controller-runtime と kubebuilder ブックに帰属させる |
参考資料(実際に参照したもの)
- https://lws.sigs.k8s.io/docs/overview/ 、/concepts/leaderworkerset/ 、/concepts/leaderworkerset/failure-handling/ 、/concepts/leaderworkerset/rollout-strategy/ 、/concepts/disaggregatedset/ 、/reference/disaggregatedset.v1/ 、および https://github.com/kubernetes-sigs/lws (v0.11.1 CRD YAML)
- https://kserve.github.io/website/docs/model-serving/generative-inference/llmisvc/llmisvc-overview 、/concepts/architecture/control-plane-llmisvc 、/model-serving/predictive-inference/rollout-strategies/canary 、および https://github.com/kserve/kserve (v0.21.0 CRD YAML)
- https://docs.nvidia.com/dynamo/latest/ 、https://github.com/ai-dynamo/dynamo (v1.5.0: オペレーターディレクトリの
README.md,api/CONVERSION.md,internal/dynamo/worker-hash.md、CRD YAML、docs/fern/pages/kubernetes/installation/multinode-orchestration.md) - https://docs.lmcache.ai/mp/operator.html
- https://book.kubebuilder.io/reference/good-practices.html 、/reference/using-finalizers.html 、/multiversion-tutorial/tutorial.html
- https://github.com/kubernetes-sigs/controller-runtime (v0.25.2
pkg/reconcile/reconcile.go) - https://kopf.readthedocs.io/en/stable/ 、/peering/ 、/errors/ 、/continuity/ 、/configuration/
- https://kubernetes.io/docs/concepts/extend-kubernetes/operator/ , /concepts/overview/working-with-objects/owners-dependents/ , /finalizers/ , /concepts/architecture/leases/ , /tasks/extend-kubernetes/custom-resources/custom-resource-definitions/ , /custom-resource-definition-versioning/ , /tasks/manage-kubernetes-objects/storage-version-migration/ , /reference/command-line-tools-reference/feature-gates/
- https://argo-cd.readthedocs.io/en/stable/user-guide/diffing/ , /sync-options/ , /diff-strategies/ , /operator-manual/health/
kopf オペレーターの直接テスト
第9節の最小限の調整ループ(op.py、修正なしでそのまま)を、使い捨てのシングルノード k3s(v1.36.5、4 vCPU 仮想マシン)で kopf 1.44.6 を使って実行し、第3節のパターンを確認しました。呼び出し回数をカウントするためにログを1行追加したバージョン(op2.py)を、重複実行とエラーのテストに使用しました。
| 確認事項 | 結果 |
|---|---|
CR作成から子ConfigMapが作成されるまで、 status.phase=Ready まで |
それぞれ0.1秒 |
| 子オブジェクトの所有者参照 | CRの名前とUID、 controller: true, blockOwnerDeletion: true |
| スキーマのデフォルト値 | replicas を指定しない場合、1が設定されてConfigMapに "replicas":"1" |
| CEL検証 | 空の model は model must not be empty, model 、欠落は Required value, replicas: 99 は8以下のルールにより拒否される(APIサーバーの段階) |
| finalizer | kopf.zalando.org/KopfFinalizerMarker が付与される |
| 子ConfigMapを削除した際の復旧時間(5回、開始位相を分散) | 2.5、15.9、24.9、17.8、26.9秒 |
| 子の内容を変更した際の元の値への復旧 | 29.9秒 |
| CRのspec変更がConfigMapに反映されるまで | 0.1秒 |
調整が3回実行される間(95秒)、子とCRの resourceVersion |
どちらも不変(べき等) |
| オペレーター停止中のspec変更と子の削除、再起動後 | 0.6秒でConfigMapを復元(replicas=5を反映) |
| オペレーターなしでのCR削除 | CRが deletionTimestamp がついたまま残り、子ConfigMapもそのまま。オペレーターを起動すると0.6秒後にCRが消え、子はガベージコレクションにより0.1秒後に消える |
- 復旧遅延の大きさは実装方式によって決まります。 子を削除した際の復旧時間が2.5〜26.9秒とばらついたのは、kopfのCRハンドラーが監視していない子オブジェクトの削除ではトリガーされず、タイマー(このテストでは
interval=30,idle=10)によってのみ復旧するためです。controller-runtimeのOwns()は子オブジェクトの削除イベントによって所有者を直ちに再調整するため、遅延は監視の伝播時間近くなります(公式ドキュメントの記述によるもので、直接測定はしていません)。どちらの方式も、現在の状態を読み取って差分を埋めるレベルベースの調整であり、イベントを取り逃した場合でも復旧するという特性がテストによって確認されました。 - オペレーターが存在しない場合、finalizerが削除をブロックします。 CRの削除が完了しないのは正常な動作であり、オペレーターを削除する前にCRを先に削除するか、finalizerを手動でクリーンアップする必要があります。今回のテストでも、クリーンアップの段階で同じ理由により一度停止しました(オペレーターが停止した状態で全CRの削除を実行)。
- 重複実行(リーダー選挙なし)。 同じCRを2つのオペレーターが同時に調整しました。50秒間、インスタンスAが6回、Bが6回、同じタイミングで同じ処理を行いました。kopfのピアリング(
KopfPeering)を有効にし、優先度を100と50に設定すると、Aが6回、Bが2回となり、BはPausing operations in favour ofのログを残して停止しました。優先度の高いAを正常終了させると、Bが0.2秒以内に処理を再開しました(異常終了時の引き継ぎ遅延は測定しておらず、ピアリングのlifetimeである60秒が上限であると推測します)。
エラー処理とリトライ間隔。 調整関数が常に失敗するようにしたCRを100秒間放置し、呼び出し時刻を記録しました(kopfのデフォルト値)。
| 発生した例外 | 100秒間の呼び出し回数 | 間隔 |
|---|---|---|
通常の例外(RuntimeError) |
4回 | 作成ハンドラーが失敗して60秒後にリトライ、タイマーも別途失敗して60秒後にリトライ(呼び出し時刻 +0、+10、+60、+70秒) |
kopf.TemporaryError(delay=5) |
20回 | 5秒ごと |
kopf.PermanentError |
4回 | 作成ハンドラーはリトライしない(0 succeeded; 1 failed)。ただし、 タイマーは動き続けるため 、30秒ごとに同じエラーが発生する(+10、+40、+70秒) |
- 通常例外のリトライ間隔である60秒は、kopfドキュメントのデフォルト値と同じです。「まだ準備ができていない」状態は
TemporaryErrorで表現し、間隔を短く設定するのが適切です。 PermanentErrorは、そのハンドラーのみを停止させます。 同じCRにタイマーがアタッチされている場合、specが間違ったまま30秒ごとに再評価が続けられるため、永続的なエラーをstatusの条件に残し、タイマー側でもスキップするように設計する必要があります。- 制限事項。 最初にVMで実施したスケールテストがCR 20個で停止した原因は、ディスクや同時実行数ではなく、テストスクリプトにありました。オペレーターを同じシェルでバックグラウンドジョブとして起動した後、20個ごとに引数なしの
waitを呼び出し、終了しないオペレーターを待機していました。別の環境で再測定した結果は、以下の「スケールテスト」に記載しています。呼び出し時刻はログ上の時刻であり、Goオペレーターとの直接比較は行っていません。
スケールテスト(CR 10〜300個)。 ノートPC(Apple M1 Max)のDocker Desktop(仮想マシンCPU 10コア、メモリ 8GB)上にシングルノードのkindクラスター(Kubernetes v1.36.1)を作成し、上記の op.py を変更せずに、kopf 1.44.6をクラスター外のプロセスとして実行しました。CRごとに、監視がCRを最初に認識した時刻から status.phase=Ready が記載された最初のイベントを受信するまでの時間を遅延として測定し、各条件でウォームアップ1回を除外して3回繰り返した中央値を算出しました。
| CR数 | 単一マニフェストによる適用: 全体処理 | 遅延中央値 / p95 / 最大 | kubectl 20개씩 병렬: 전체 처리 | 遅延中央値 / p95 / 最大 |
|---|---|---|---|---|
| 10 | 0.17秒 | 0.030 / 0.041 / 0.043秒 | 0.22秒 | 0.082 / 0.095 / 0.096秒 |
| 50 | 0.56秒 | 0.029 / 0.043 / 0.044秒 | 1.08秒 | 0.127 / 0.187 / 0.196秒 |
| 100 | 1.96秒 | 0.049 / 0.064 / 0.086秒 | 2.10秒 | 0.073 / 0.127 / 0.180秒 |
| 300 | 3.61秒 | 0.032 / 0.063 / 0.115秒 | 7.28秒(2回) | 0.102 / 0.188 / 0.254秒 |
- 300個まではオペレーターがボトルネックになることはありませんでした。 全体処理時間のほぼ大部分はkubectlがCRを登録する時間であり(300個のマニフェスト送信に3.57秒、最後のReadyまで3.61秒)、CR単体の遅延は個数に関わらず中央値0.03〜0.13秒、実行ごとの最大値も0.36秒以下でした。
- オペレーターのCPU使用量はCR1つあたり7.5〜8.6ミリ秒であり、個数に比例していました。 処理区間の平均は1コアの31〜64%、0.1秒単位のピーク時は55〜85%でした。RSSは待機状態の約100MiBから、CR100個で101〜109MiB、300個で111〜115MiBでした。すべての処理が完了した後の30秒タイマーのみが動作している状態では、CR100個で1.4〜1.6%でした。
- kubectl 을 20개씩 동시에 띄우는 쪽이 오히려 느렸습니다. 300個の送信にかかる時間は7.2秒対3.6秒です。kubectlプロセスを起動するコストが1つのリクエストよりも大きく、20個が同時に到着するとオペレーター内部で順番待ちが発生するため、遅延の中央値も2〜4倍になりました。遅延を決定づけたのは総数ではなく、リクエストの集中度でした。
- 最初にVMで20個で停止した原因は、テストスクリプトにありました。 スクリプトはオペレーターを
setsid nohup kopf run … &で同じシェル上で起動した後、20個ごとに引数なしのwaitを呼び出しましたが、引数なしのwaitはそのシェルのすべての子プロセス、つまり終了しないオペレーターの終了も待機してしまいます。同じ構成をLinuxコンテナ(bash 5.2、util-linux 2.41)とkindで再度実行したところ、どちらも同様に停止し(kindではCR 20個のまま60秒間そのまま)、待機対象をkubectlのPIDに限定したところ、300個まで一度もブロックされませんでした。 - 制限事項。 同じDocker環境内で他のクラスターが同時に動作していたため、etcdの遅延警告やAPIサーバーの再起動が発生しました。生成区間に警告が1つでも発生した実行結果は無効として除外しました(並列300個のデータが2回分である理由です)。シングルノード環境、オペレーターをPodとして実行した場合、300個を超える正常な状態のCPUや1000個以上については測定していません(未測定)。
LWSの直接テスト
使い捨てのシングルノードk3s(v1.36.5、4 vCPU、4GiB仮想マシン)にLWS v0.11.1をインストールし、 busybox sleep のPodを使用してグループの構造、復旧、ローリングアップデート、スケールを確認しました。GPUやモデルは使用していないためモデルのロード時間は含まれておらず、時間は0.5秒間隔で測定しました。
| 確認事項 | 結果 |
|---|---|
| インストール | マニフェスト適用からコントローラーの準備完了まで17秒。CRDは LeaderWorkerSet のほかに DisaggregatedSet, DisaggregatedSetRoleScaler も同時にインストールされる |
| グループの作成 | replicas: 2, size: 3 の場合、6つのPodすべてが4.8秒でRunningになります。名前はリーダーが demo-0、ワーカーが demo-0-1, demo-0-2、2番目のグループが demo-1, demo-1-1, demo-1-2 |
| 作成されたサブリソース | リーダー用StatefulSetが1つ(demo、グループ数分)、 グループごとにワーカー用StatefulSetが1つ(demo-0, demo-1)、すべてのグループで共有されるヘッドレスサービスが1つ(demo)。Podの所有者はリーダーが StatefulSet/demo、ワーカーが StatefulSet/demo-0 |
| Podのラベル | leaderworkerset.sigs.k8s.io/ 配下に name, group-index, worker-index, group-key(グループごとに固有のハッシュ)、 template-revision-hash |
| インジェクションされた環境変数 | LWS_LEADER_ADDRESS(例: demo-1.demo.default、ワーカーも 自グループのリーダーのアドレス)、 LWS_GROUP_SIZE=3, LWS_WORKER_INDEX(リーダーは0、ワーカーは1から) |
| デフォルト値 | rolloutStrategy: RollingUpdate, maxSurge: 0, maxUnavailable: 1, partition: 0(第6節の記述と一致)。 startupPolicy: LeaderCreated, networkConfig.subdomainPolicy: Shared, restartPolicy: RecreateGroupOnPodRestart |
-
ワーカーが1つ死ぬと、グループ全体が再作成されます(デフォルトポリシー)。グループ0のワーカーを強制削除すると、約2.0秒後に同じグループの Pod 3つ(リーダー1つとワーカー2つ)すべてが新しいUID になり、グループ1のPod 3つのUIDはそのまま維持されました。マルチノード推論において1つのランクが失われた場合に残りが不要になる構造を、グループ単位の再起動が反映したものです。
-
restartPolicy: Noneの場合、削除されたPodのみが再作成されます(約1.9秒)。同じテストにおいて、新しいUIDを持ったのはそのワーカー1つだけでした。グループを自動的に復活させることのメリットとコストのどちらが大きいかはモデルのロード時間に依存しますが、今回のテストにはロード時間が存在しないため、そのコストを示すことはできません(未測定)。 -
ローリングアップデートは、ordinal(順序数)が大きいグループから1つずつ進行しました。 テンプレートの値を変更すると、1秒時点で
demo-1グループが新しいリビジョンでPendingになり、3秒にdemo-1がRunningになり、demo-0が新しいリビジョンに置き換わり中で、4秒時点ですべて新しいリビジョンでRunningになりました(監視ループ終了まで8秒)。モデルのロードがないため1グループあたり数秒で完了しましたが、実際のサービングでは1つのグループの準備が完了するまで次のグループを待つため、大幅に長くなることが予想されます(推測)。 -
スケールサブリソースが機能します。 replicas を 3 に増やすと、9個の Pod が 2.5 秒で Running になり、1 に減らすと
demo-0,demo-0-1,demo-0-2のみが残りました(番号が大きいグループから順に削除)。 -
LWS はギャング・スケジューリングを行いません。 Pod ごとに 1 CPU を要求するサイズ 4 のグループ2つ(計8個)を、CPU の空きが 0.8 であるノードに作成したところ、リーダー1つのみが Running で残りの7つは
Insufficient cpuの状態で Pending となっていました。グループ数を 1 に減らしてもリーダーのみが Running で、ワーカー3つは Pending のままでした。リーダーがリソースを握ったままワーカーを待っている様子であり、ギャング・スケジューリングツール(Kueue、Volcano)が必要とされる理由はまさにこうした点にあります。第10章の検証では、両ツールが1つの作業のみを受け入れることで、このようなデッドロックを回避していました。 -
partitionによってカナリアリリースが行われます。 グループ4つ(size: 2)でpartition: 2にテンプレートの値を変更すると、序数 2 以上のグループ(can-2,can-3)のみが 5 秒以内に新しいリビジョンになり、序数 0 と 1 は古いリビジョン(ハッシュ 7596)のまま残りました。続いてpartition: 0に下げると、残りの2つのグループも 4 秒以内に新しいリビジョンに移行しました。第6節の記述(番号が大きいグループから順に、partition 以上のもののみ更新)と一致しています。 -
maxSurge: 1の場合、グループが一時的に1つ増えてから減少します。 グループ4つでテンプレートを変更すると、can-4(Pod 2個)が追加で生成され、Pod 数が 8個から最大 10個になり、置き換えが完了すると再び 8個に戻りました(全体で約 9 秒)。maxUnavailable: 0であっても同様の挙動であり、置き換え中も Running グループが毎回 4個以上ありました。1〜2秒間隔の観察では、maxUnavailableが 1 の場合と 0 の場合の差を見分けることはできませんでした。追加のグループが生じるということは、実際の GPU 環境においてグループ1つ分の余剰 GPU が存在しなければならないことを意味します(ここではsleepPod であるためコストはかかりませんでした)。 -
両方が 0 である場合、アドミッション Webhook によって拒否されます。
maxSurge: 0からmaxUnavailable: 0に変更しようとしたところ、spec.rolloutStrategy.rollingUpdateConfiguration.maxUnavailable: Invalid value: 0: must not be 0 when maxSurge is 0エラーによって阻止されました。
制限事項: 仮想マシン1台、Pod が行う処理は sleep、イメージは事前に取得済みの busyboxであり、プローブ(準備状態検査)はありません。ノード障害によるグループの復旧および DisaggregatedSet は検証していません(未計測)。
確認したバージョンと日付 (2026-10-05)
LWS v0.11.1(2026-10-01), KServe v0.21.0(2026-09-25, ドキュメントは 0.20 ページ), Dynamo v1.5.0(2026-09-21), controller-runtime v0.25.2(2026-10-01), kubebuilder v4.16.0(2026-09-10), kopf 1.44.6(2026-06-03), Argo CD v3.5.3(2026-09-14), LMCache v0.5.5, Kubernetes v1.37.1(2026-09-23).
確認できなかった項目。 StorageVersionMigration のデフォルトの有効性(Kubernetes のドキュメント内で記述が食い違っている)、kopf の CR 1,000個以上・複数ノードにおける性能の限界(300個までは上記の規模検証で測定しました)、コントローラー単位のテストツール(envtest)の詳細、ValidatingAdmissionPolicy と Webhook の得失、KServe の最新ドキュメントと v0.21.0 の間の差異、Dynamo の DGD デプロイモード別の正確なサブリソース生成パス(CRD の説明とドキュメントから図式化し、コードパスは追跡していません)。第9章の kopf のスケルトンは私がドキュメントを基に作成したものであり、実際のクラスターで実行して検証したものではありません。