この投稿では、controller-runtimeの標準的な排他制御の限界と、それを解決するBatchKeyパターンの実装方法について解説します。特に、CRDを用いたキューシステムのような、複数のリソースを協調して処理する必要があるケースでの活用方法を、具体的なコード例とともに説明していきます。
この投稿で知れること
- controller-runtimeの標準的な排他制御の仕組みと限界
- キューシステムにおける排他制御の課題
- BatchKeyパターンの基本的な考え方
- Field IndexerとMapFuncを使った実装手順
標準的な排他制御の仕組み
controller-runtimeは通常、namespace + nameの組み合わせで排他制御を行います。これにより、同じリソースに対する複数の変更が同時に実行されることを防ぎ、データの整合性を保っています。
// 標準的なReconcile - namespace+name単位の排他制御
func (r *StandardReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// req.NamespacedName は { Namespace: "default", Name: "my-resource" }
// "default/my-resource" に対してのみ排他制御される
var resource MyResource
if err := r.Get(ctx, req.NamespacedName, &resource); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// この処理は "default/my-resource" に対してのみ実行される
// 他の "default/other-resource" は並列処理される
return ctrl.Result{}, nil
}
この方式は多くの場合で問題なく動作しますが、もっと広い範囲で排他制御したいときはどうしたらいいのでしょうか。
例えばJobキューシステム
CRDを使って簡単なJobキューシステムを実装する場合を考えてみます。各Jobはspec.queue
フィールドでキューを指定し、spec.next
フィールドでキュー内の順序を表現するという設計です。
apiVersion: example.com/v1
kind: Job
metadata:
name: job1
spec:
queue: "queue-a" # キューA
next: "job2" # 次に実行するJob
command: ["echo", "step1"]
---
apiVersion: example.com/v1
kind: Job
metadata:
name: job2
spec:
queue: "queue-a" # 同じキューA
next: "job3" # 次に実行するJob
command: ["echo", "step2"]
---
apiVersion: example.com/v1
kind: Job
metadata:
name: job3
spec:
queue: "queue-a" # 同じキューA
next: "" # 最後のJob(nextなし)
command: ["echo", "step3"]
なぜ標準的な制御では問題が起きるのか?
標準的な制御方式では、各Jobが個別にReconcileされるため、以下のような問題が発生します。
spec.nextによる順序制御において、同じキューのJobが並列実行され、spec.nextの順序が無視されるという問題があります。また、Job間の依存関係を個別に制御するのが難しくなります。
// 標準的制御の場合の問題例
Job1 (queue: "queue-a", next: "job2") ──→ 個別処理(並列実行)
Job2 (queue: "queue-a", next: "job3") ──→ 個別処理(並列実行)← spec.nextが無視される
Job3 (queue: "queue-a", next: "") ──→ 個別処理(並列実行)
つまり、同じキューに属するJobであっても、それぞれが独立して処理されてしまい、spec.next
で定義した実行順序を守ることができないというわけです。
BatchKeyパターンとは?
BatchKeyパターンは、標準的なnamespace/name
キーの代わりに、任意のフィールド値をキーとして使用し、同じキー値を持つリソース群をまとめて処理する実装パターンです。
キューシステムの例では、spec.queue
の値をキーとしてJobをグループ化し、キュー単位で排他制御&一括処理を行います。
// BatchKeyパターンによるキューシステムの実現
spec.queue="queue-a" → BatchKey: "spec.queue:queue-a"
spec.queue="queue-b" → BatchKey: "spec.queue:queue-b"
// 各キューは独立してReconcileされる
JobController.Reconcile("spec.queue:queue-a") {
// queue-aのすべてのJobを取得
// spec.nextに従った順次実行制御
// job1 → job2 → job3 の順序で処理
}
標準的制御とBatchKey制御の比較
標準的制御では、排他制御の単位が「namespace + name」で、処理対象は1つのリソース、並列度はリソース数分になります。一方、BatchKey制御では、排他制御の単位が任意のキー値で、処理対象は同じキー値のリソース群、並列度はキー値の種類数分になります。
BatchKeyパターンの処理フロー
BatchKeyパターンは、以下の3つのコンポーネントで構成されます。
-
FieldIndexer: 逆引き用インデックスを設定します。特定のフィールド値でリソースを高速検索できるようにします。
-
MapFunc: フィールド値をキーとしたバッチキーを生成します。標準的な
namespace/name
キーを任意のキーに変換します。 -
Reconcile: キーに基づいてリソースを一括取得・処理します。同じキー値を持つ全リソースを一度に処理します。
視覚的に見ると、以下のような流れになります。
Job Resources MapFunc Reconcile Queue Reconcile Logic
┌──────────────────────────┐ ┌──────────────────┐ ┌──────────────────────┐ ┌─────────────────────────┐
│ Job1 (queue:"queue-a", │─►│ spec.queue: │──►│ spec.queue:queue-a │──►│ List all Jobs │
│ next:"job2") │ │ queue-a │ │ │ │ with queue="queue-a" │
│ Job2 (queue:"queue-a", │─►│ spec.queue: │ │ │ │ │
│ next:"job3") │ │ queue-a │ │ │ │ │
│ Job3 (queue:"queue-a", │─►│ spec.queue: │ │ │ │ │
│ next:"") │ │ queue-a │ │ │ │ │
└──────────────────────────┘ └──────────────────┘ └──────────────────────┘ └─────────────────────────┘
実装手順
それでは、実際にBatchKeyパターンを実装する手順を見ていきます。
Step 1: Field Indexerの設定
まず、cmd/main.go
でField Indexerを設定します。これにより、Reconcilerが特定のフィールドでの検索できるようになります。次の例では、Job.Spec.Queue
を検索キーにしています。
// spec.queueの逆引き検索用のField Indexerを設定
const idxQueue = "spec.queue:queue-a" // プレフィックス付きでインデックス
if err := mgr.GetFieldIndexer().IndexField(context.Background(), &myapi.Job{}, idxQueue,
func(rawObj client.Object) []string {
job := rawObj.(*myapi.Job)
// プレフィックス付きの値でインデックス
return []string{"spec.queue:" + job.Spec.Queue}
}); err != nil {
setupLog.Error(err, "spec.queueのField Indexer作成に失敗")
os.Exit(1)
}
Step 2: コントローラーの実装
次に、internal/controller/job_controller.go
でコントローラーを実装します。
まず定数を定義します:
// spec.queue用のField Indexer定数
const idxQueue = "spec.queue:queue-a"
// バッチキー用のプレフィックス
const prefixQueue = "spec.queue:"
つづいて、Reconcile
メソッドを次のようなかんじで実装します。
func (r *JobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// req.Name は "spec.queue:<実際のキュー値>" (バッチキー)
queueKey := req.Name
l := log.FromContext(ctx).WithName(queueKey)
l.Info(fmt.Sprintf("キュー %s のJobをReconcile中", queueKey))
// Field Indexerを使って指定されたキュー値を持つ全Jobをリスト
// プレフィックス付きの値で検索するため、req.Nameをそのまま使用
var jobs myapi.JobList
if err := r.List(ctx, &jobs,
client.MatchingFields{idxQueue: queueKey}); err != nil {
l.Error(err, "キュー値でのJobリストに失敗", "queue", queueKey)
return ctrl.Result{}, err
}
l.Info(fmt.Sprintf("キュー %s で %d 個のJobを発見", queueKey, len(jobs.Items)))
// 指定されたキュー値を持つ各Jobを処理
for _, job := range jobs.Items {
l.Info("Job処理中", "name", job.Name, "namespace", job.Namespace, "queue", job.Spec.Queue)
// TODO: ここにビジネスロジックを実装
// 例: spec.nextに従った順序制御など
}
return ctrl.Result{}, nil
}
最後にSetupWithManagerメソッドの実装です。
func (r *JobReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
// JobリソースをWatchし、MapFuncでバッチキーを作成
Watches(&myapi.Job{},
handler.EnqueueRequestsFromMapFunc(func(_ context.Context, obj client.Object) []reconcile.Request {
job := obj.(*myapi.Job)
// プレフィックス付きのバッチキーを作成: Namespaceは空、Nameにプレフィックス付きのqueue値を含む
key := types.NamespacedName{Name: prefixQueue + job.Spec.Queue}
return []reconcile.Request{{NamespacedName: key}}
})).
WithOptions(controller.Options{
MaxConcurrentReconciles: 8, // 異なるqueue値は並列処理可能(最大8)
}).
Named("job").
Complete(r)
}
まとめ
この投稿では、controller-runtimeの標準的な排他制御の限界と、それを解決するBatchKeyパターンについて解説しました。
BatchKeyパターンは、標準的なnamespace/name
単位の制御を拡張し、任意のフィールド値でリソースをグループ化して処理する強力な手法です。特にキューシステムのような、複数のリソースを協調して処理する必要があるケースで威力を発揮します。
実装のポイントは、Field Indexerによる逆引き検索の準備、MapFuncでのプレフィックス付きキー生成、そしてReconcileでの一括処理という3つのステップです。Field Indexerでもプレフィックス付きの値でインデックスすることで、実装をシンプルに保つことができるというわけです。
最後までお読みくださりありがとうございました。Twitter では、Qiita に書かない技術ネタなどもツイートしているので、よかったらフォローしてもらえると嬉しいです→Twitter@suin