Skip to content
Published on

Operatorのベストプラクティスとアンチパターン

Authors

はじめに — Operatorは作るよりも飼い慣らすのが難しい

Operatorを初めて作るときは、reconcile関数が動くだけで感動します。しかしプロダクションで半年ほど回してみると気づきます。作ることは始まりで、飼い慣らすことが本番だと。無限ループでAPIサーバーを叩き、RBACが広すぎてセキュリティ監査で指摘され、アップグレード一度で全ワークロードが揺れる経験をして初めて、「よく作られたOperator」の基準が手につかめます。

この記事はその基準を整理します。前の二つの記事で何を作れるか(カタログ)とどう作るか(データベースOperator)を見たなら、今回の記事はどうよく作るかです。良いOperatorの特徴と避けるべきアンチパターンをコードとともに押さえ、セキュリティ・テスト・運用・成熟度まで一周します。

良いOperatorの四つの特徴

1. 小さなAPI

良いOperatorはユーザーに最小限のつまみだけを与えます。specが肥大化すると、ユーザーは何を埋めるべきか分からず、Operatorはすべての組み合わせを検証しなければならず、後方互換を壊さずに進化させるのが難しくなります。

# 悪い例 — 内部実装が漏れた肥大なAPI
spec:
  statefulSetName: my-db-sts
  podAntiAffinityWeight: 100
  replicationProtocol: streaming
  walSegmentSize: 16MB
  checkpointTimeout: 300s
  # ... 数十個の低レベルオプション

# 良い例 — 意図だけを宣言
spec:
  replicas: 3
  version: "16.3"
  storage: { size: 50Gi }
  highAvailability: true

原則は「**何を(what)**望むかだけを受け取り、**どう(how)**はOperatorが決める」です。ユーザーがwalSegmentSizeを知らなければならないなら、その抽象化は失敗しています。上級ユーザー向けの逃げ道が必要なら、別のadvancedフィールドやアノテーションに分離しつつ、既定の経路は単純に保ちます。

2. 冪等なreconcile

reconcileは同じ入力に対して何度呼んでも同じ結果を出さなければなりません。これがコントローラーパターンの根幹です。reconcileはいつでも、どんな理由でも(再起動、イベント重複、定期リシンク)再び呼ばれうるからです。

// 悪い例 — 呼び出しごとに新リソースを作る(非冪等)
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	job := newBackupJob()             // 毎回新しい名前のJobを生成
	r.Create(ctx, job)                // reconcile 100回 = Job 100個
	return ctrl.Result{}, nil
}

// 良い例 — 望ましい状態を宣言し、なければ作る
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	desired := desiredBackupJob(req)
	_, err := controllerutil.CreateOrUpdate(ctx, r.Client, desired, mutateFn)
	return ctrl.Result{}, err
}

冪等性を点検する簡単なテスト:「reconcileを二回連続で呼んだとき、二回目の呼び出しが何も変えないか?」そうなら冪等です。そうでなければどこかで毎回変更を作っており、これは無限reconcileループの種です。

3. 観測可能性

運用者がOperatorの内部を覗けなければデバッグは不可能です。良いOperatorは三つのチャネルで自分をさらけ出します。

観測の三チャネル
 1. status.conditions  — 現在の状態と理由を宣言的に
 2. Kubernetesイベント  — 出来事のタイムライン(kubectl describe)
 3. Prometheusメトリクス — reconcile回数/遅延/エラー、ドメイン指標

特にstatus.conditionsのReasonとMessageは人が読めるものでなければなりません。「Ready=False, Reason=ImagePullBackOff, Message=cannot pull registry.example.com/db:16.3」のように具体的であってこそ、運用者が次の行動を決められます。

4. 安全なアップグレード

Operator自体のアップグレードと、Operatorが管理するリソースのアップグレード、両方が安全でなければなりません。CRDスキーマは後方互換を維持し、conversion webhookでバージョン間の変換を提供します。管理対象ワークロードは一度に全部変えず段階的に(カナリア/ローリング)交換します。

安全なアップグレードの条件
 - CRD: 新フィールドはoptional + default、既存フィールドの意味は不変
 - 複数のAPIバージョンが共存(v1alpha1, v1beta1, v1)+ conversion
 - 管理ワークロード: ヘルスゲートを通過してから次のステップ
 - ロールバック経路の存在(バックアップ、旧バージョンイメージの保存)

アンチパターン7選

アンチパターン1: reconcileでの副作用の乱発

reconcileは「現在の状態を望ましい状態へ収束」させる関数であるべきです。ところがreconcileの中でメールを送り、外部APIを呼び、Slack通知を撃つコードをよく見ます。reconcileは何十回も再呼び出しされうるので、こうした副作用は重複して発生します。

// 悪い例 — reconcileごとに通知爆弾
func (r *Reconciler) Reconcile(...) (ctrl.Result, error) {
	r.slackNotify("deployment started!")  // 再呼び出しごとにSlackを荒らす
	// ...
}

外部の副作用は「状態が実際に遷移したとき」だけ一度発生すべきです。statusに「すでに通知済み」のようなフラグを置き、遷移時点でのみトリガーする形で冪等にします。

アンチパターン2: statusをspecのように使う

statusはOperatorが観察した結果を書く場所で、specはユーザーが望むことを書く場所です。ユーザーがstatusに値を書くようにしたり、Operatorが入力をstatusから読んだりすると、両者の役割が混ざってデバッグ地獄になります。

正しい役割分離
  spec   : ユーザーが書く -> Operatorが読む(desired)
  status : Operatorが書く -> ユーザーが読む(observed)

壊れたパターン(避けること)
  - ユーザーがstatus.replicasを編集できるようにする
  - Operatorがstatus.targetVersionを入力として信頼する

statusサブリソースを有効化するとspecとstatusの更新が分離され、衝突と権限の混乱を減らせます。

アンチパターン3: 無限requeue

エラーや条件不一致のたびに無条件で即時requeueすると、コントローラーが毎秒数千回APIサーバーを叩く暴走が発生します。これはクラスタ全体を脅かします。

// 悪い例 — エラー時に即時無限再試行
if err != nil {
	return ctrl.Result{Requeue: true}, nil  // CPU/API暴走
}

// 良い例 — エラーを返すとcontroller-runtimeが指数バックオフ
if err != nil {
	return ctrl.Result{}, err  // 自動バックオフ再試行
}

// 意図的な遅延も明示的に
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil

原則:エラーはそのまま返してcontroller-runtimeの指数バックオフに任せ、正常だが「しばらく後にまた見たいとき」だけRequeueAfterを明示します。無条件のRequeue: trueはほぼ常に間違いです。

アンチパターン4: 広範なRBAC

Operatorがcluster-adminやワイルドカード権限(*)を要求するのは最もよくあるセキュリティアンチパターンです。Operatorが侵害されるとクラスタ全体が一緒に侵害されます。

// 悪い例 — すべてに対するすべての権限
// +kubebuilder:rbac:groups="*",resources="*",verbs="*"

// 良い例 — 必要なリソースに必要な動詞だけ
// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch
// +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch
// +kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete

KubebuilderのRBACマーカーはコードで正確に必要な権限だけを宣言しマニフェストを生成させます。最小権限の原則をコードの近くで強制する良いツールです。定期的に「この動詞は本当に必要か」を監査してください。

アンチパターン5: クラスタスコープの乱用

すべてのネームスペースを監視するクラスタスコープOperatorは強力ですが危険です。一つのテナントのCRがバグを引き起こすとクラスタ全体が影響を受け、RBACも自然に広がります。可能ならネームスペーススコープで始め、本当に必要なときだけクラスタスコープに拡張してください。

スコープ選択ガイド
 - 単一チーム/ネームスペース対象 -> ネームスペーススコープ推奨
 - マルチテナントプラットフォーム -> クラスタスコープ + 強い隔離が必要
 - watch対象を特定ネームスペースに制限するとメモリ/負荷も削減

アンチパターン6: webhookの強結合

バリデーティング/ミューテーティングwebhookは強力ですが、webhookが死ぬと関連リソースの作成・修正がすべてブロックされます。Operator Pod一つの障害がクラスタのAPI動作を麻痺させる単一障害点になりえます。

webhookの安全設計
 - failurePolicyを慎重に: Failは安全だが可用性リスク、
   Ignoreは可用性優先だが検証回避リスク
 - webhook対象範囲をnamespaceSelector/objectSelectorで狭める
 - webhook自身のネームスペースは除外(自己ブートストラップのデッドロック防止)
 - webhookを複数レプリカでHA構成

核心はwebhookがなくても核心機能が動くよう設計することです。webhookは便宜(検証/既定値)であって必須依存になってはいけません。

アンチパターン7: キャッシュされない直接読み取りの乱発

controller-runtimeのクライアントは既定でinformerキャッシュから読みます。ところが毎reconcileでキャッシュを迂回してAPIサーバーに直接問い合わせる(例:ラベルなしList)と、APIサーバー負荷が爆発します。

// 悪い例 — 毎回全PodをAPIサーバーから直接List
pods := &corev1.PodList{}
r.APIReader.List(ctx, pods)  // キャッシュ迂回 + セレクタなし

// 良い例 — キャッシュから、セレクタで絞って
pods := &corev1.PodList{}
r.List(ctx, pods,
	client.InNamespace(req.Namespace),
	client.MatchingLabels{"app": req.Name})

マルチテナンシーとセキュリティ

プラットフォームチームが作るOperatorは複数チームで共有されます。一つのテナントが別のテナントのリソースに影響を与えられないよう隔離しなければなりません。

脅威防御
テナント間CR干渉ネームスペース隔離、RBACでCRアクセス制限
リソース枯渇(noisy neighbor)ResourceQuota、LimitRange、CRに上限
権限昇格Operator SAの最小権限、委譲時に検証
シークレット露出シークレットをstatusに絶対書かない、ログマスキング
悪意ある入力webhook/スキーマ検証で危険なspecを拒否

特にシークレットをstatusやイベント、ログに露出しないことはよく見落とされるミスです。デバッグの便宜で接続文字列をstatusに書いて、RBACが緩いユーザーにパスワードが漏れる事故が起こります。

ユーザー権限を偽装しないこと

プラットフォームOperatorがよく陥る罠の一つは、自分の強いサービスアカウントでユーザーが要求した作業をそのまま代行することです。こうなると権限の弱いユーザーがOperatorを通して自分にはできないことを迂回実行する権限昇格の通路が開きます。安全なパターンはSubjectAccessReviewで「このユーザーが本当にこの作業の権限を持つか」を先に確認することです。

// ユーザーが要求した作業に対する権限を委譲検証
sar := &authzv1.SubjectAccessReview{
	Spec: authzv1.SubjectAccessReviewSpec{
		User: requestingUser,
		ResourceAttributes: &authzv1.ResourceAttributes{
			Namespace: ns,
			Verb:      "create",
			Resource:  "databases",
			Group:     "db.example.com",
		},
	},
}
if err := r.Create(ctx, sar); err != nil {
	return err
}
if !sar.Status.Allowed {
	// ユーザーに権限なし -> Operatorも代わりに実行を拒否
	return fmt.Errorf("user %s is not allowed to create databases", requestingUser)
}

このパターンは特にセルフサービスプラットフォームで重要です。Operatorは「権限の代理人」ではなく「権限の検問所」であるべきです。

リソース効率 — キャッシュと並行性

大規模クラスタでOperatorはともすればメモリを食う怪物になります。すべてのPodをキャッシュすると数万個のオブジェクトがメモリに乗ります。

効率化テクニック
 - キャッシュ範囲を制限: 特定ネームスペース/ラベルだけwatch
 - predicateでイベントをフィルタ: 関心のない変更はreconcileしない
 - MaxConcurrentReconcilesで並行性を調節(高すぎるとAPI暴走、
   低すぎると処理遅延)
 - field/labelセレクタでList範囲を縮小

predicateは特に強力です。例えばgenerationが変わった変更(spec変更)だけreconcileし、statusだけ変わったイベントを無視すれば不要なreconcileを大幅に減らせます。

// spec変更(generation増加)だけreconcile
builder.WithPredicates(predicate.GenerationChangedPredicate{})

テスト文化

Operatorは分散システムと相互作用するためテストが厄介ですが、だからこそより重要です。

テストピラミッド
 1. ユニットテスト: reconcileロジック、ヘルパー関数(fake client活用)
 2. envtest: 本物のAPIサーバー + etcdでreconcile統合検証
    (controller-runtimeが提供、kube-apiserverバイナリ使用)
 3. e2e: kindクラスタに実際にデプロイしてシナリオ検証
 4. カオス/障害注入: Pod強制削除、ネットワーク分断時の動作

特にenvtestはOperatorテストの核心です。mockではなく本物のAPIサーバーを立ててCR作成 -> reconcile -> リソース作成の全過程を検証します。「冪等性テスト」(reconcile二回呼び出し後に変化なしを確認)と「削除テスト」(finalizer整理を確認)を必ず含めてください。

SRE観点の運用 — アラートとランブック

Operatorも一つのサービスです。運用可能であるためにはSLI/SLO、アラート、ランブックが必要です。

Operatorの運用指標の例
 - reconcileエラー率(controller_runtime_reconcile_errors_total)
 - reconcile遅延(ワークキュー待機時間)
 - ワークキュー深さ(滞った作業が積もるか)
 - ドメインSLI(例:フェイルオーバー成功率、バックアップ鮮度)

アラートルールの例
 - reconcileエラー率が5分間高い -> 警告
 - ワークキューが増え続ける -> コントローラー停止を疑う
 - バックアップが24時間以上ない -> 呼び出し

アラートには必ずランブックリンクを付けてください。深夜3時に呼び出された当番者が「このアラートが出たら何を確認して何をするか」を即座に分かるようにすべきです。Operatorが自動化できなかった例外こそ人が介入する地点で、ランブックがその橋です。

デバッグ — Operatorが仕事をしないときに見る順序

運用中で最もよくある呼び出しは「CRを作ったのに何も起こらない」です。診断順序を体得しておけばほとんど早く原因を見つけられます。

 1. CRが実際に存在しspecが正しいか
    kubectl get database orders-db -o yaml
 2. status.conditionsが何を言っているか
    -> Reason/Messageに手がかりがある場合がほとんど
 3. Operator Podが生きていてreconcileしているか
    kubectl logs -n operator-system deploy/db-operator
 4. RBACが阻んでいないか(Forbiddenログ)
    kubectl auth can-i create statefulsets --as=system:serviceaccount:operator-system:db-operator
 5. ワークキューが滞っているか(メトリクス確認)
    workqueue_depth, reconcile_errors_total
 6. イベントタイムライン
    kubectl describe database orders-db

最もよくある原因は三つです。第一に、RBAC不足でOperatorがリソースを作れず静かに失敗。第二に、predicateが厳しすぎてイベントがreconcileをトリガーしない。第三に、finalizerが掛かっているのに整理ロジックがエラーを返して削除が永遠に終わらない。この三つをまず疑えば半分は解けます。

finalizerデッドロック — 削除が終わらないとき

finalizer整理ロジックが恒久的に失敗すると(例:すでに消えた外部リソースを消そうとしてエラー)、CRがTerminatingで止まります。整理ロジックは**「すでになければ成功とみなす」**よう冪等に書かなければなりません。

func (r *Reconciler) reconcileDelete(ctx context.Context, db *dbv1alpha1.Database) (ctrl.Result, error) {
	// 外部リソース整理 — すでになくてもエラーとみなさない
	if err := r.cleanupExternalBackupBucket(ctx, db); err != nil {
		if !isNotFound(err) {
			return ctrl.Result{}, err // 本当のエラーだけ再試行
		}
	}
	// 整理完了 -> finalizer除去 -> Kubernetesが実際に削除
	controllerutil.RemoveFinalizer(db, dbFinalizer)
	return ctrl.Result{}, r.Update(ctx, db)
}

緊急時にはfinalizerを強制的に外して(kubectl patchでfinalizersを空にする)削除を進めることもできますが、これは外部リソースが孤児として残りうる最後の手段です。

Operator成熟度ロードマップ

CNCF Capability Levelをロードマップとして活用できます。

Level 1: 基本インストール
  - CRでインストール/設定。helmでデプロイ可能。

Level 2: 円滑なアップグレード
  - 管理対象のバージョンアップグレードを安全に行う。

Level 3: フルライフサイクル
  - バックアップ/復元、スケール、障害復旧を自動化。

Level 4: 深いインサイト
  - メトリクス/アラート/ログで観測可能。性能分析を提供。

Level 5: オートパイロット
  - オートスケール、自動チューニング、異常検知、自己治癒。

すべてのOperatorがLevel 5を目指す必要はありません。ほとんどの社内OperatorはLevel 2-3で十分です。重要なのは自分のOperatorが今どのレベルで、次のレベルへ行くのに何が必要かを知ることです。

総合チェックリスト

[API設計]
[ ] specが小さく意図(what)中心か
[ ] 新フィールドがoptional + defaultで後方互換を守るか
[ ] 複数APIバージョン + conversion戦略があるか

[reconcile]
[ ] 冪等か(二回呼び出しの二回目が無変化)
[ ] 外部副作用が遷移時点だけ一度発生するか
[ ] エラーは返してバックオフに任せるか(無限requeueなし)
[ ] finalizerで整理ロジックを保証するか

[観測]
[ ] status.conditionsが人に読めるか
[ ] 重要な出来事をeventに残すか
[ ] ドメインメトリクスを公開するか

[セキュリティ]
[ ] RBACが最小権限か(ワイルドカードなし)
[ ] シークレットをstatus/event/logに露出しないか
[ ] 可能な限りネームスペーススコープか

[効率]
[ ] キャッシュ/watch範囲を制限したか
[ ] predicateで不要なreconcileをフィルタするか
[ ] MaxConcurrentReconcilesを適切に設定したか

[テスト/運用]
[ ] envtestで統合検証するか
[ ] 冪等性/削除テストがあるか
[ ] アラートルールとランブックがあるか

おわりに

良いOperatorと悪いOperatorを分けるのは華やかな機能ではなく基本です。冪等なreconcile、小さなAPI、最小RBAC、観測可能性 — この平凡に見える原則が、深夜3時の障害とセキュリティ監査の指摘と終わりなき保守の負債を分けます。

三編にわたるOperatorの旅を整理するとこうです。最初の記事でOperatorで何を作れるかカタログを見て、二つ目の記事でデータベースOperatorでどう作るか手を動かし、この記事でどうよく作るか基準を立てました。Operatorは運用知識をコードに移す強力なツールですが、そのコードもまた誰かが運用しなければならないもう一つのシステムであることを忘れないでください。よく作られたOperatorは運用負担を減らし、誤って作られたOperatorは新たな負担を作ります。この記事のチェックリストがその分かれ道で良いほうを選ぶ助けになることを願います。

参考資料