Kubernetes Operator - 解説
このドキュメントでは、Kubernetes Operatorの実装における設計判断、技術的詳細、パフォーマンス最適化、そしてよくある間違いとその回避策について詳しく解説します。
---
目次
---
1. 実装の設計判断とその理由
1.1 なぜcontroller-runtimeを使うのか
選択肢の比較:
| アプローチ | メリット | デメリット | 適用場面 |
|---|---|---|---|
| **client-go直接使用** | 完全な制御、軽量 | 大量のボイラープレート、エラーハンドリングが複雑 | 特殊なユースケース、既存システムへの組み込み |
| **controller-runtime** | 抽象化されたAPI、ベストプラクティスが組み込み | やや重い、内部動作の理解が必要 | 標準的なOperator開発(推奨) |
| **Kubebuilder** | scaffoldingツール、プロジェクト構造の標準化 | controller-runtime依存 | 新規プロジェクト立ち上げ |
| **Operator SDK** | 追加のヘルパー、OLM統合 | Red Hat色が強い、学習コスト | エンタープライズ環境 |
推奨:controller-runtime + Kubebuilder
理由:
- コミュニティ標準: Kubernetes SIGが公式にサポート
- 抽象化のバランス: 低レベルすぎず、高レベルすぎない
- 拡張性: カスタマイズが必要な場合でも柔軟に対応可能
- 豊富なエコシステム: 多くのOperatorがこのパターンを採用
1.2 Status Subresourceの設計判断
なぜStatusを分離するのか:
// +kubebuilder:subresource:status
type MyApp struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec MyAppSpec `json:"spec,omitempty"` // ユーザーが編集
Status MyAppStatus `json:"status,omitempty"` // Operatorのみが更新
}
メリット:
Statusの設計原則:
type MyAppStatus struct {
// 1. Conditions: 標準化されたステータス表現(Kubernetes標準)
Conditions []metav1.Condition `json:"conditions,omitempty"`
// 2. Observable Facts: 観測可能な事実のみ記録
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
AvailableReplicas int32 `json:"availableReplicas,omitempty"`
// 3. Generation Tracking: Specとの同期状態を追跡
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// 4. Timestamp: デバッグ用
LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"`
}
1.3 Owner Referenceによるガベージコレクション
設計判断:すべての子リソースにOwner Referenceを設定
func (r *Reconciler) createDeployment(ctx context.Context, app *MyApp) error {
dep := &appsv1.Deployment{...}
// Owner Referenceを設定(必須)
if err := ctrl.SetControllerReference(app, dep, r.Scheme); err != nil {
return err
}
return r.Create(ctx, dep)
}
Owner Reference設定の効果:
kubectl describeでの可視化: 親子関係が明確に表示される注意点:
// ❌ 間違い:namespace間のOwner Referenceは不可
// MyApp (namespace: app-ns)
// Deployment (namespace: default) // これはエラーになる
// ✅ 正しい:同一namespace内のみ
// MyApp (namespace: app-ns)
// Deployment (namespace: app-ns)
// クラスタースコープリソースの場合
// ClusterRole → RoleBinding (異なるnamespace) は可能
1.4 Idempotency(冪等性)の保証
設計原則:Reconcile関数は何度実行しても同じ結果になるべき
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// ❌ 間違い:毎回リソースを作成しようとする
dep := &appsv1.Deployment{...}
if err := r.Create(ctx, dep); err != nil {
return ctrl.Result{}, err // AlreadyExistsエラーで失敗
}
// ✅ 正しい:既存チェック → 作成 or 更新
found := &appsv1.Deployment{}
err := r.Get(ctx, types.NamespacedName{...}, found)
if err != nil {
if apierrors.IsNotFound(err) {
// 存在しない → 作成
return r.Create(ctx, dep)
}
return ctrl.Result{}, err
}
// 存在する → 必要なら更新
if !reflect.DeepEqual(found.Spec, dep.Spec) {
found.Spec = dep.Spec
return r.Update(ctx, found)
}
return ctrl.Result{}, nil // 変更なし
}
Idempotencyのベストプラクティス:
import "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
func (r *Reconciler) reconcileDeployment(ctx context.Context, app *MyApp) error {
dep := &appsv1.Deployment{...}
// Server-Side Applyを使用(推奨)
result, err := controllerutil.CreateOrUpdate(ctx, r.Client, dep, func() error {
// 望ましい状態を設定
dep.Spec.Replicas = &app.Spec.Replicas
dep.Spec.Template.Spec.Containers[0].Image = app.Spec.Image
// Owner Referenceを設定
return ctrl.SetControllerReference(app, dep, r.Scheme)
})
if err != nil {
return err
}
log.Info("Deployment reconciled", "result", result)
// result: Created, Updated, Unchanged
return nil
}
---
2. Reconciliation Loopの詳細動作
2.1 Reconciliation Loopのライフサイクル
┌──────────────────────────────────────────────────────────┐
│ Watch Events │
│ (Create, Update, Delete on MyApp, Deployment, Service) │
└─────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────┐
│ Workqueue │ ← Rate Limiting
│ (key: ns/name) │ ← Deduplication
└────────┬────────┘
│
▼
┌──────────────────────┐
│ Reconcile(ctx, req) │
└──────────┬───────────┘
│
┌────────────┴────────────┐
│ │
▼ ▼
Success Error
return return
(Result{}, nil) (Result{}, err)
│ │
▼ ▼
Remove from queue Retry with
exponential backoff
2.2 Watchの仕組み
SetupWithManagerの詳細:
func (r *MyAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&examplev1.MyApp{}). // Primary Watch: MyApp自体の変更
Owns(&appsv1.Deployment{}). // Secondary Watch: DeploymentのOwner参照
Owns(&corev1.Service{}). // Secondary Watch: ServiceのOwner参照
Watches( // Custom Watch: 任意のリソース
&source.Kind{Type: &corev1.ConfigMap{}},
handler.EnqueueRequestsFromMapFunc(r.findObjectsForConfigMap),
builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
).
WithEventFilter(predicate.GenerationChangedPredicate{}). // Filter無駄なイベント
WithOptions(controller.Options{
MaxConcurrentReconciles: 3, // 並列実行数
}).
Complete(r)
}
各Watchの動作:
- Owns (Secondary Watch):
// 例:Deploymentが手動で削除された場合
// 1. Deploymentの削除イベント発生
// 2. Owner ReferenceからMyAppを特定
// 3. MyAppのReconcileが呼ばれる
// 4. Reconcile内でDeploymentが存在しないことを検出
// 5. Deploymentを再作成
- Custom Watches:
func (r *Reconciler) findObjectsForConfigMap(cm client.Object) []reconcile.Request {
// このConfigMapを参照しているMyAppを検索
var myAppList MyAppList
r.List(context.Background(), &myAppList)
var requests []reconcile.Request
for _, app := range myAppList.Items {
// app.Spec.ConfigMapRefと一致するか確認
if app.Spec.ConfigMapRef == cm.GetName() {
requests = append(requests, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: app.Name,
Namespace: app.Namespace,
},
})
}
}
return requests
}
2.3 Requeueの戦略
Reconcile関数の戻り値パターン:
// パターン1: 成功、再実行不要
return ctrl.Result{}, nil
// パターン2: 成功、一定時間後に再実行
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
// パターン3: 成功、すぐに再実行(キューの末尾に追加)
return ctrl.Result{Requeue: true}, nil
// パターン4: エラー、Exponential Backoffで再実行
return ctrl.Result{}, fmt.Errorf("failed to reconcile: %w", err)
// パターン5: エラーだが再実行不要(Status subresourceに記録)
myApp.Status.Conditions = append(myApp.Status.Conditions, ...)
r.Status().Update(ctx, myApp)
return ctrl.Result{}, nil // エラーを返さない
Requeueのユースケース:
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
myApp := &MyApp{}
if err := r.Get(ctx, req.NamespacedName, myApp); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// ケース1: 外部APIの状態を定期的にチェック
if myApp.Spec.ExternalServiceEnabled {
status, err := r.checkExternalService(myApp)
if err != nil {
// 一時的なエラー → 30秒後に再試行
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}
// 定期的な監視継続
return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil
}
// ケース2: Deploymentのロールアウト完了を待つ
deployment := &appsv1.Deployment{}
if err := r.Get(ctx, req.NamespacedName, deployment); err != nil {
return ctrl.Result{}, err
}
if deployment.Status.UpdatedReplicas != *deployment.Spec.Replicas {
// ロールアウト中 → 10秒後に再チェック
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
// ケース3: 恒久的なエラー(ユーザー設定ミス)
if myApp.Spec.Replicas > 100 {
// Statusに記録し、エラーを返さない(無限リトライを避ける)
meta.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
Type: "Invalid",
Status: metav1.ConditionTrue,
Reason: "ReplicasExceedsLimit",
Message: "Replicas must be <= 100",
})
r.Status().Update(ctx, myApp)
return ctrl.Result{}, nil
}
return ctrl.Result{}, nil
}
---
3. パフォーマンス最適化
3.1 Cacheの活用
controller-runtimeのCache機構:
// Getは常にCacheから読み取る(デフォルト)
myApp := &MyApp{}
err := r.Get(ctx, req.NamespacedName, myApp)
// → APIサーバーへのHTTPリクエストは発生しない
// → Informerが保持しているCacheから取得
// Listもキャッシュを使用
var myAppList MyAppList
err := r.List(ctx, &myAppList)
// → namespace内のすべてのMyAppをCacheから取得
Cache最適化のベストプラクティス:
// 1. 必要なNamespaceのみキャッシュ
mgr, err := ctrl.NewManager(cfg, ctrl.Options{
Cache: cache.Options{
DefaultNamespaces: map[string]cache.Config{
"production": {},
"staging": {},
},
},
})
// 2. 特定のリソースのみキャッシュ
mgr, err := ctrl.NewManager(cfg, ctrl.Options{
Cache: cache.Options{
ByObject: map[client.Object]cache.ByObject{
&corev1.Pod{}: {
// Labelセレクタでフィルタリング
Label: labels.SelectorFromSet(labels.Set{
"app": "myapp",
}),
},
},
},
})
// 3. Cacheを使わずAPI Serverに直接問い合わせ(特殊ケース)
directClient, err := client.New(cfg, client.Options{})
realTimeMyApp := &MyApp{}
err = directClient.Get(ctx, req.NamespacedName, realTimeMyApp)
// → APIサーバーへの直接リクエスト(Cacheをバイパス)
3.2 Rate Limitingの設定
デフォルトのRate Limiter:
// workqueue.DefaultControllerRateLimiter()の内部実装
rateLimiter := workqueue.NewMaxOfRateLimiter(
// 1. Item-level exponential backoff
workqueue.NewItemExponentialFailureRateLimiter(5*time.Millisecond, 1000*time.Second),
// 2. Bucket rate limiter (全体のスループット制限)
&workqueue.BucketRateLimiter{Limiter: rate.NewLimiter(rate.Limit(10), 100)},
)
カスタムRate Limiterの設定:
func (r *MyAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&examplev1.MyApp{}).
WithOptions(controller.Options{
// 並列実行数を制限
MaxConcurrentReconciles: 5,
// カスタムRate Limiter
RateLimiter: workqueue.NewItemExponentialFailureRateLimiter(
100*time.Millisecond, // 初回リトライ: 100ms
60*time.Second, // 最大リトライ間隔: 60s
),
}).
Complete(r)
}
Rate Limitingのシナリオ別設定:
| シナリオ | 推奨設定 | 理由 |
|---|---|---|
| **高頻度更新** (例: メトリクス収集) | `MaxConcurrentReconciles: 10`, 短いbackoff | スループット重視 |
| **重い処理** (例: バックアップ) | `MaxConcurrentReconciles: 1-3`, 長いbackoff | API負荷軽減 |
| **外部API連携** | カスタムRate Limiter (外部APIのレート制限に合わせる) | 外部サービス保護 |
3.3 Predicates(イベントフィルタリング)
不要なReconcileを防ぐ:
import "sigs.k8s.io/controller-runtime/pkg/predicate"
func (r *MyAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&examplev1.MyApp{}).
WithEventFilter(predicate.Funcs{
// Createイベント: 常に処理
CreateFunc: func(e event.CreateEvent) bool {
return true
},
// Updateイベント: Generationが変わった時のみ処理
UpdateFunc: func(e event.UpdateEvent) bool {
oldGen := e.ObjectOld.GetGeneration()
newGen := e.ObjectNew.GetGeneration()
// Generation変更なし = Status更新のみ → Skip
if oldGen == newGen {
return false
}
return true
},
// Deleteイベント: 常に処理
DeleteFunc: func(e event.DeleteEvent) bool {
return true
},
// Generic: 常に処理
GenericFunc: func(e event.GenericEvent) bool {
return true
},
}).
Complete(r)
}
組み込みPredicatesの活用:
// 1. GenerationChangedPredicate: Spec変更時のみ
predicate.GenerationChangedPredicate{}
// 2. ResourceVersionChangedPredicate: ResourceVersion変更時のみ
predicate.ResourceVersionChangedPredicate{}
// 3. AnnotationChangedPredicate: Annotation変更時のみ
predicate.AnnotationChangedPredicate{}
// 4. LabelChangedPredicate: Label変更時のみ
predicate.LabelChangedPredicate{}
// 5. 複数のPredicateを組み合わせ
predicate.And(
predicate.GenerationChangedPredicate{},
predicate.NewPredicateFuncs(func(object client.Object) bool {
// カスタムロジック: 特定のLabelを持つリソースのみ
return object.GetLabels()["reconcile"] == "enabled"
}),
)
3.4 Batch処理とDebouncing
頻繁な更新をまとめる:
type MyAppReconciler struct {
client.Client
Scheme *runtime.Scheme
// Debounce用のMap
lastReconcile sync.Map // map[types.NamespacedName]time.Time
}
func (r *MyAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// Debouncing: 最後のReconcileから5秒以内なら Skip
if lastTime, ok := r.lastReconcile.Load(req.NamespacedName); ok {
if time.Since(lastTime.(time.Time)) < 5*time.Second {
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}
}
// Reconcile処理
// ...
// 最終実行時刻を記録
r.lastReconcile.Store(req.NamespacedName, time.Now())
return ctrl.Result{}, nil
}
---
4. よくある間違いと回避策
4.1 Status更新による無限ループ
問題のコード:
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
myApp := &MyApp{}
r.Get(ctx, req.NamespacedName, myApp)
// 毎回Statusを更新 → Watch発火 → 再度Reconcile → 無限ループ
myApp.Status.LastReconcile = metav1.Now()
r.Status().Update(ctx, myApp)
return ctrl.Result{}, nil
}
解決策1: 変更がある場合のみ更新
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
myApp := &MyApp{}
r.Get(ctx, req.NamespacedName, myApp)
// 元のStatusを保存
originalStatus := myApp.Status.DeepCopy()
// Reconciliationロジック
r.reconcileDeployment(ctx, myApp)
// Statusが変わった場合のみ更新
if !reflect.DeepEqual(originalStatus, &myApp.Status) {
myApp.Status.LastReconcile = metav1.Now()
if err := r.Status().Update(ctx, myApp); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
解決策2: GenerationChangedPredicateを使用
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&MyApp{}).
WithEventFilter(predicate.GenerationChangedPredicate{}). // Status更新はSkip
Complete(r)
}
4.2 リソースリークの防止
問題:Owner Reference設定忘れ
// ❌ 間違い
func (r *Reconciler) createService(ctx context.Context, app *MyApp) error {
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: app.Name,
Namespace: app.Namespace,
},
Spec: corev1.ServiceSpec{...},
}
// Owner Reference未設定 → 親削除時に子が残る
return r.Create(ctx, svc)
}
解決策:必ずOwner Referenceを設定
// ✅ 正しい
func (r *Reconciler) createService(ctx context.Context, app *MyApp) error {
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: app.Name,
Namespace: app.Namespace,
},
Spec: corev1.ServiceSpec{...},
}
// Owner Reference設定(必須)
if err := ctrl.SetControllerReference(app, svc, r.Scheme); err != nil {
return fmt.Errorf("failed to set owner reference: %w", err)
}
return r.Create(ctx, svc)
}
検証方法:
# 子リソースのOwner Referenceを確認
kubectl get deployment my-app -o jsonpath='{.metadata.ownerReferences}'
# 期待される出力
[{"apiVersion":"example.com/v1","kind":"MyApp","name":"my-app","uid":"...","controller":true}]
4.3 Contextのキャンセル未対応
問題:長時間実行される処理でContextを確認しない
// ❌ 間違い
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
for i := 0; i < 1000; i++ {
// Contextのキャンセルを確認しない
time.Sleep(1 * time.Second)
r.processItem(i)
}
return ctrl.Result{}, nil
}
解決策:定期的にContext確認
// ✅ 正しい
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
for i := 0; i < 1000; i++ {
// Contextがキャンセルされていないか確認
select {
case <-ctx.Done():
return ctrl.Result{}, ctx.Err()
default:
// 処理続行
}
time.Sleep(1 * time.Second)
if err := r.processItem(ctx, i); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
func (r *Reconciler) processItem(ctx context.Context, i int) error {
// 外部API呼び出し時も必ずContextを渡す
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com", nil)
resp, err := http.DefaultClient.Do(req)
// ...
return nil
}
4.4 並行実行の競合
問題:共有状態への安全でないアクセス
// ❌ 間違い
type Reconciler struct {
client.Client
cache map[string]string // 複数のGoroutineから同時アクセス → Race Condition
}
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
r.cache[req.Name] = "processing" // Race!
// ...
return ctrl.Result{}, nil
}
解決策1: sync.Mapを使用
// ✅ 正しい
type Reconciler struct {
client.Client
cache sync.Map // thread-safe
}
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
r.cache.Store(req.Name, "processing")
// ...
return ctrl.Result{}, nil
}
解決策2: Mutexで保護
type Reconciler struct {
client.Client
mu sync.Mutex
cache map[string]string
}
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
r.mu.Lock()
r.cache[req.Name] = "processing"
r.mu.Unlock()
// ...
return ctrl.Result{}, nil
}
---
5. 発展的学習への道筋
5.1 Advanced Topics
1. Webhooks(Admission Controllers)
// Validating Webhook: リソース作成・更新時のバリデーション
func (r *MyApp) ValidateCreate() error {
if r.Spec.Replicas > 100 {
return fmt.Errorf("replicas must be <= 100")
}
return nil
}
// Mutating Webhook: リソースの自動修正
func (r *MyApp) Default() {
if r.Spec.Port == 0 {
r.Spec.Port = 8080 // デフォルト値設定
}
}
2. Finalizers(削除前処理)
const myAppFinalizer = "myapp.example.com/finalizer"
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
myApp := &MyApp{}
if err := r.Get(ctx, req.NamespacedName, myApp); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// 削除処理
if !myApp.ObjectMeta.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(myApp, myAppFinalizer) {
// クリーンアップ処理(例: 外部リソースの削除)
if err := r.cleanupExternalResources(ctx, myApp); err != nil {
return ctrl.Result{}, err
}
// Finalizerを削除
controllerutil.RemoveFinalizer(myApp, myAppFinalizer)
if err := r.Update(ctx, myApp); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
// Finalizerを追加
if !controllerutil.ContainsFinalizer(myApp, myAppFinalizer) {
controllerutil.AddFinalizer(myApp, myAppFinalizer)
if err := r.Update(ctx, myApp); err != nil {
return ctrl.Result{}, err
}
}
// 通常のReconcile処理
return r.reconcile(ctx, myApp)
}
3. Multi-tenancy対応
// Namespace-scopedなOperatorで複数テナントを管理
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
// 特定のNamespaceのみ監視
return ctrl.NewControllerManagedBy(mgr).
For(&MyApp{}).
WithEventFilter(predicate.NewPredicateFuncs(func(object client.Object) bool {
// "tenant-"で始まるnamespaceのみ処理
return strings.HasPrefix(object.GetNamespace(), "tenant-")
})).
Complete(r)
}
5.2 推奨リソース
公式ドキュメント:
実践的な学習:
- 既存Operatorのコード読解:
- ハンズオンチュートリアル:
- コミュニティ参加:
5.3 次のステップ
レベル別学習計画:
初級(1-3ヶ月):
- [ ] Kubebuilderで簡単なOperatorを実装
- [ ] Envtestでのテスト作成
- [ ] Kindでのローカルデバッグ
- [ ] CRD設計のベストプラクティス理解
中級(3-6ヶ月):
- [ ] Webhooks(Validating/Mutating)の実装
- [ ] Finalizersを使った削除前処理
- [ ] Prometheus Metricsの統合
- [ ] E2Eテストの作成
上級(6-12ヶ月):
- [ ] 複雑なステートフルアプリケーションの管理
- [ ] Multi-cluster対応
- [ ] OSSへのコントリビューション
- [ ] カスタムSchedulerの実装
- Idempotency(冪等性): 何度実行しても同じ結果
- Owner Reference: ライフサイクルの自動管理
- Status Subresource: 望ましい状態と現在の状態の分離
- パフォーマンス最適化: Cache, Rate Limiting, Predicates
- エラーハンドリング: 一時的エラーと恒久的エラーの区別
---
まとめ
Kubernetes Operatorの実装は、以下の重要なポイントを押さえることで、本番環境で安定稼働するものになります:
これらの原則を理解し、実践することで、エンタープライズグレードのOperatorを開発できるようになります。