Skip to content
Published on

Operator の本番アップグレードとマイグレーション — 無停止で進化させる

Authors

はじめに

Operator は一度デプロイして終わりのコンポーネントではありません。CRD スキーマは進化し、コントローラのロジックはバグ修正と機能追加で絶えず変わり、管理するワークロードの数は数百から数千へと増えていきます。問題は、これらの変化がすべて「すでに稼働している本番環境の上で」起きることです。データベース Operator のアップグレードを誤れば数百の DB インスタンスが同時に揺らぎ、CRD のストレージバージョンを誤って変更すれば既存の CR をまったく読めなくなることもあります。

本記事では 2026 年時点の Kubebuilder エコシステム(Kubernetes 1.36 / Go 1.26 対応、controller-runtime v0.24.x、controller-tools v0.21.x)を前提に、Operator をほぼ無停止でアップグレード・マイグレーションする実践的なパターンを扱います。単に kubectl apply をやり直すレベルではなく、コントローラ自体のローリング、CRD スキーマの進化、管理ワークロードの段階的移行、ロールバック可能性の維持、そして障害が起きたときの対応までを一連の流れとして編み込みます。

本記事が扱う範囲を先にまとめておきます。

1. Operator 自体のアップグレード   - コントローラ Deployment のローリング、リーダー選出、graceful shutdown
2. CRD スキーマの進化              - マルチバージョン CRD、conversion webhook、storage version
3. 管理ワークロードの安全な展開    - 段階的/パーティションロールアウト、observedGeneration、status conditions
4. ロールバック戦略               - ダウングレードの落とし穴、不可逆な変換、フィールド消失
5. マルチバージョン運用           - 複数 API バージョンの同時配信、deprecation ポリシー
6. カナリア Operator             - 一部ネームスペースにのみ新バージョンをデプロイ
7. バージョン互換性              - K8s 1.36 / Go 1.26、controller-runtime v0.24.x、version skew
8. 大規模 CR マイグレーション      - 一回限りのバッチジョブ、storage version migrator、レート制限
9. 障害対応                     - ロールアウトの途中で問題が起きたとき
10. チェックリスト               - デプロイ前後の点検項目

Operator のアップグレードが通常のアプリと異なる理由

一般的なステートレス Web アプリケーションは、Deployment をローリングアップデートすれば終わりです。古い Pod が新しい Pod に置き換わり、トラフィックが新バージョンへ流れればよいのです。しかし Operator は違います。

第一に、Operator は 状態を外部に置きます。 本当の状態は etcd に保存された CR(Custom Resource)と、それが管理する実際のワークロードです。コントローラ Pod 自体はほぼステートレスですが、コントローラが解釈するデータ(CRD スキーマ)は変わります。

第二に、Operator は 継続的に reconcile します。 新バージョンのコントローラが起動すると、既存のすべての CR を再 reconcile し始めます。このときロジックが変わっていれば、すべてのワークロードが同時に影響を受けかねません。

第三に、CRD はクラスタ全体のリソースです。 CRD を変更すると、その CRD を使うすべてのネームスペース、すべての CR に影響します。ネームスペース単位で隔離されません。

次の表は両者の違いをまとめたものです。

項目通常のステートレスアプリOperator
状態の場所外部 DBetcd の CR + 管理ワークロード
アップグレード単位Deployment のローリングコントローラ + CRD + 管理ワークロード
スキーマ変更の影響範囲アプリ内部に限定クラスタ全体の CRD
同時実行複数レプリカで並列処理リーダー 1 つだけが reconcile
ロールバックの難度イメージを戻せば終わりストレージバージョン、データ消失を考慮

この違いがあるため、Operator のアップグレードでは「コントローラ」「スキーマ」「管理ワークロード」という 3 つの軸を別々に、しかし整合性を保って回す必要があります。

1. コントローラ自体を安全にローリングする

リーダー選出: アクティブな reconciler はただ 1 つ

複数のレプリカを起動しても、2 つのコントローラが同じ CR を同時に reconcile すると競合します。controller-runtime はリーダー選出(leader election)を標準で提供します。HA のためにレプリカは 2〜3 個起動しつつ、リーダー 1 つだけが実際に reconcile し、残りはスタンバイで待機します。

package main

import (
	"crypto/tls"
	"flag"
	"os"

	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/healthz"
	"sigs.k8s.io/controller-runtime/pkg/log/zap"
	metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
	"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
)

func main() {
	var enableLeaderElection bool
	var probeAddr string
	flag.BoolVar(&enableLeaderElection, "leader-elect", true,
		"Enable leader election for controller manager.")
	flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081",
		"The address the probe endpoint binds to.")
	flag.Parse()

	ctrl.SetLogger(zap.New(zap.UseDevMode(false)))

	mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
		Scheme: scheme,
		Metrics: metricsserver.Options{
			BindAddress:   ":8443",
			SecureServing: true,
			// 2026: kube-rbac-proxy は削除。認証/認可をマネージャに内蔵。
			FilterProvider: filters.WithAuthenticationAndAuthorization,
			TLSOpts: []func(*tls.Config){
				func(c *tls.Config) { c.MinVersion = tls.VersionTLS13 },
			},
		},
		HealthProbeBindAddress: probeAddr,
		LeaderElection:         enableLeaderElection,
		LeaderElectionID:       "db-operator.example.com",
		// リーダー停止時にスタンバイが素早く引き継げるよう lease を調整。
		LeaseDuration: durationPtr(15 * time.Second),
		RenewDeadline: durationPtr(10 * time.Second),
		RetryPeriod:   durationPtr(2 * time.Second),
		// 終了時にリーダーシップを即座に返却し、ダウンタイムを減らす。
		LeaderElectionReleaseOnCancel: true,
	})
	if err != nil {
		os.Exit(1)
	}

	_ = mgr.AddHealthzCheck("healthz", healthz.Ping)
	_ = mgr.AddReadyzCheck("readyz", healthz.Ping)

	if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
		os.Exit(1)
	}
}

ここで重要なのは LeaderElectionReleaseOnCancel: true です。このオプションを有効にすると、コントローラが SIGTERM を受けて終了する際にリーダー lease を即座に返却します。すると、スタンバイのレプリカは LeaseDuration を待たずにリーダーとなり、reconcile を引き継ぎます。この一行が、ローリングアップデート中の reconcile の空白を数秒単位に縮めます。

Deployment のローリング戦略

コントローラはアクティブなリーダー 1 つだけが働くため、レプリカを増やしてもスループットは上がりません。代わりに可用性のために次のように設定します。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: db-operator-controller-manager
  namespace: db-operator-system
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: manager
          image: registry.example.com/db-operator:v1.4.0
          args:
            - --leader-elect=true
            - --health-probe-bind-address=:8081
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8081
            initialDelaySeconds: 15
            periodSeconds: 20
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8081
            initialDelaySeconds: 5
            periodSeconds: 10
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              memory: 512Mi
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 5"]

maxUnavailable: 0maxSurge: 1 を併用すると、新しい Pod が Ready になった後にのみ既存の Pod が終了します。terminationGracePeriodSeconds: 30 は SIGTERM 後に進行中の reconcile が片付くための時間を与えます。preStop の短い sleep は、エンドポイント伝播の遅延による競合状態を緩和します。

Graceful shutdown

controller-runtime の ctrl.SetupSignalHandler() は SIGTERM/SIGINT を受け取るとマネージャのコンテキストをキャンセルします。このとき、進行中の reconcile はコンテキストのキャンセルを検知してきれいに抜ける必要があります。reconcile 関数内で外部呼び出し(例: DB API、クラウド SDK)を行う際は、常に ctx を渡して終了シグナルが伝播するようにします。

func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	log := ctrl.LoggerFrom(ctx)

	var db examplev1.Database
	if err := r.Get(ctx, req.NamespacedName, &db); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// 外部呼び出しに ctx を渡すと終了時に即キャンセルされ、graceful shutdown が保証される。
	if err := r.provisioner.EnsureReady(ctx, &db); err != nil {
		if ctx.Err() != nil {
			// 終了中なら次のリーダーが再処理するよう静かに返す。
			return ctrl.Result{}, nil
		}
		log.Error(err, "failed to ensure database ready")
		return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
	}

	return ctrl.Result{}, nil
}

2. CRD スキーマの進化 — マルチバージョンと conversion webhook

最も厄介なのが CRD スキーマの変更です。フィールドの追加程度なら互換性がありますが、フィールド名を変えたり構造を再編したりすると既存の CR と互換性がなくなります。このときマルチバージョン CRD(multi-version CRD)と conversion webhook を使います。

served version と storage version

CRD の各バージョンは 2 つの属性を持ちます。

  • served: このバージョンで API リクエストを受けられるか(kubectl やクライアントが使用可能)
  • storage: etcd に実際に保存されるバージョン(ただ 1 つだけが true)

核となるルールはこうです。etcd には常にただ 1 つの storage version でのみ保存され、別のバージョンで来たリクエストは conversion webhook を経て storage version へ変換されて保存されます。読み取り時は逆方向に変換されます。

              +----------------------------------------------+
              |                 API Server                    |
  v1alpha1 -->|  served:true   -+                             |
  v1beta1  -->|  served:true    +-> conversion webhook -> etcd| (storage: v1)
  v1       -->|  served:true,   |                             |
              |  storage:true --+                             |
              +----------------------------------------------+

hub-and-spoke 変換モデル

複数のバージョンが互いに変換すると、変換関数が N x N に膨れ上がります。Kubebuilder は hub-and-spoke モデルを推奨します。1 つのバージョンを「hub」と定め、残りすべてのバージョン(spoke)は hub との変換のみを実装します。すると v1alpha1 から v1beta1 へ変換する場合も v1alpha1 -> hub -> v1beta1 の経路を辿ります。

   v1alpha1 (spoke)              v1 (hub, storage)              v1beta1 (spoke)
        |                            ^   |                            |
        |  ConvertTo(hub) -----------+   +---- ConvertFrom(hub) <-----+
        +---------- ConvertFrom(hub) <--- ConvertTo(hub) -------------+

hub バージョン(ここでは v1)には Hub() マーカーメソッドだけを置きます。

package v1

// Hub はこの型が変換 hub であることを示す。追加の実装は不要。
func (*Database) Hub() {}

spoke バージョン(v1beta1)は hub との双方向変換を実装します。

package v1beta1

import (
	"sigs.k8s.io/controller-runtime/pkg/conversion"
	dbv1 "github.com/example/db-operator/api/v1"
)

// ConvertTo はこの spoke(v1beta1)を hub(v1)へ変換する。
func (src *Database) ConvertTo(dstRaw conversion.Hub) error {
	dst := dstRaw.(*dbv1.Database)

	dst.ObjectMeta = src.ObjectMeta

	// 単純なフィールドはそのままコピー。
	dst.Spec.Engine = src.Spec.Engine
	dst.Spec.Replicas = src.Spec.Replicas

	// v1beta1 の StorageGB(int) を v1 の Storage(構造体)へ再編。
	dst.Spec.Storage = dbv1.StorageSpec{
		SizeGB:    src.Spec.StorageGB,
		ClassName: "standard", // 新規フィールドのデフォルト値
	}

	// status も変換。
	dst.Status.Phase = src.Status.Phase
	dst.Status.ObservedGeneration = src.Status.ObservedGeneration
	return nil
}

// ConvertFrom は hub(v1)をこの spoke(v1beta1)へ変換する。
func (dst *Database) ConvertFrom(srcRaw conversion.Hub) error {
	src := srcRaw.(*dbv1.Database)

	dst.ObjectMeta = src.ObjectMeta

	dst.Spec.Engine = src.Spec.Engine
	dst.Spec.Replicas = src.Spec.Replicas

	// v1 の Storage 構造体を v1beta1 の StorageGB(int)へ縮約。
	// 注意: ClassName 情報は v1beta1 で表現できないため失われる。
	dst.Spec.StorageGB = src.Spec.Storage.SizeGB

	dst.Status.Phase = src.Status.Phase
	dst.Status.ObservedGeneration = src.Status.ObservedGeneration
	return nil
}

ここで注目すべきは ConvertFrom のコメントです。v1 の ClassName は v1beta1 に対応するフィールドがないため変換の過程で失われます。この不可逆性は後のロールバック戦略で決定的な落とし穴になるので、変換コードを書く時点で明示的に記録しておく必要があります。

conversion webhook の登録と CRD マニフェスト

webhook を有効化するには、マネージャに webhook サーバーを立てて型に登録します。

func (r *Database) SetupWebhookWithManager(mgr ctrl.Manager) error {
	return ctrl.NewWebhookManagedBy(mgr).
		For(r).
		Complete()
}

CRD マニフェストには conversion 戦略を webhook に指定します。

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: databases.example.com
spec:
  group: example.com
  names:
    kind: Database
    plural: databases
  scope: Namespaced
  conversion:
    strategy: Webhook
    webhook:
      conversionReviewVersions: ["v1"]
      clientConfig:
        service:
          namespace: db-operator-system
          name: db-operator-webhook-service
          path: /convert
  versions:
    - name: v1alpha1
      served: true
      storage: false
      deprecated: true
      deprecationWarning: "example.com/v1alpha1 Database is deprecated; use v1"
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
    - name: v1beta1
      served: true
      storage: false
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object

CRD 進化の安全な順序

スキーマを進化させるときは順序が重要です。新バージョンを追加する流れは次のとおりです。

ステップ 1: v1 を served:true, storage:false で追加(まだ保存は v1beta1)
            conversion webhook をデプロイ + 変換関数を検証
ステップ 2: コントローラの新バージョンをデプロイ(v1 を認識)
ステップ 3: storage version を v1 に切り替え(v1beta1 storage:false, v1 storage:true)
ステップ 4: storage version migrator で既存 CR を v1 として再保存
ステップ 5: v1alpha1/v1beta1 を deprecated 表記し、十分な猶予期間の後 served:false
ステップ 6: 参照がなくなったら旧バージョンを削除

storage version を変える前に、必ず conversion webhook が安定して動作することを確認しなければなりません。webhook が失敗すると、その CRD に対するすべての読み書きが止まり、クラスタ運用の直接的な障害になります。

3. 管理ワークロードの安全な段階的ロールアウト

コントローラが新しいロジックに変わると、すべての管理ワークロードを一度に変えたくなる誘惑があります。しかしそうすると、新ロジックにバグがあったとき全体が同時に崩れます。Operator 自身が段階的(パーティション)ロールアウトを主導するようにするのが安全です。

observedGeneration で進行を追跡

metadata.generation は spec が変わるたびに増加します。コントローラは処理を終えた generation を status.observedGeneration に記録します。この 2 つを比較すれば「観測した spec をすべて処理したか」がわかります。

func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var db examplev1.Database
	if err := r.Get(ctx, req.NamespacedName, &db); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// すでに最新の generation を処理済みなら追加作業なし。
	if db.Status.ObservedGeneration == db.Generation &&
		meta.IsStatusConditionTrue(db.Status.Conditions, "Ready") {
		return ctrl.Result{}, nil
	}

	// ... 実際の reconcile ロジック ...

	db.Status.ObservedGeneration = db.Generation
	meta.SetStatusCondition(&db.Status.Conditions, metav1.Condition{
		Type:               "Ready",
		Status:             metav1.ConditionTrue,
		ObservedGeneration: db.Generation,
		Reason:             "Reconciled",
		Message:            "database is ready",
	})
	if err := r.Status().Update(ctx, &db); err != nil {
		return ctrl.Result{}, err
	}
	return ctrl.Result{}, nil
}

パーティションベースの漸進的ロールアウト

Operator が新しいワークロードのバージョンを適用するとき、一度に N% だけ変えて結果を確認してから次のステップへ進むパターンです。CR の spec にロールアウトポリシーを置き、コントローラがそれを解釈します。

// RolloutPolicy は CR の spec に定義され、段階的移行を制御する。
type RolloutPolicy struct {
	// MaxUnavailable: 同時に置き換え可能なインスタンスの割合(%)。
	MaxUnavailable int `json:"maxUnavailable"`
	// Partition: このインデックス未満は新バージョンに変えない(漸進的に拡大)。
	Partition int `json:"partition"`
	// Paused: true ならロールアウトを止め、現状を維持する。
	Paused bool `json:"paused"`
}

func (r *DatabaseReconciler) rolloutManagedPods(
	ctx context.Context, db *examplev1.Database, desiredImage string,
) (bool, error) {
	pods, err := r.listManagedPods(ctx, db)
	if err != nil {
		return false, err
	}

	// 一時停止状態なら何も変えない。
	if db.Spec.Rollout.Paused {
		return false, nil
	}

	// インデックスの降順で、partition 以上のものだけを対象にする。
	updating := 0
	maxConcurrent := percentToCount(db.Spec.Rollout.MaxUnavailable, len(pods))

	for i := len(pods) - 1; i >= db.Spec.Rollout.Partition; i-- {
		p := pods[i]
		if podImage(p) == desiredImage {
			continue // すでに新バージョン
		}
		if !isHealthy(p) {
			// 直前に変えた Pod がまだ健全でなければ、これ以上進めない。
			return true, nil
		}
		if updating >= maxConcurrent {
			return true, nil // 同時置換の上限に到達、次の reconcile で継続
		}
		if err := r.recreatePodWithImage(ctx, p, desiredImage); err != nil {
			return false, err
		}
		updating++
	}

	// すべての対象が新バージョンなら完了。
	return updating > 0, nil
}

このパターンの核心は 2 つです。第一に、健全でない Pod があれば進行を止める。 新バージョンに問題があれば最初の Pod で止まるので、被害が局所化されます。第二に、Partition を運用者が調整しながら漸進的に拡大できます。最初は Partition を高くして少数だけを変え、安定が確認できたら 0 まで下げて全体を移行します。

reconcile の一時停止

問題を検知したら、ただちに reconcile を止めたいときがあります。annotation で一時停止スイッチを置くパターンが一般的です。

func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var db examplev1.Database
	if err := r.Get(ctx, req.NamespacedName, &db); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// 運用者が緊急停止をかけたら reconcile をスキップ。
	if db.Annotations["example.com/reconcile-paused"] == "true" {
		ctrl.LoggerFrom(ctx).Info("reconcile paused by annotation")
		return ctrl.Result{}, nil
	}
	// ...
	return ctrl.Result{}, nil
}

kubectl annotate database mydb example.com/reconcile-paused=true の一回で、その CR の自動調整が止まります。障害時にコントローラが誤った状態を押し進め続けるのを防ぐ安全弁です。

4. ロールバック戦略 — 戻せないものを認識する

アップグレードが誤ったら戻さなければなりません。ところが Operator のロールバックは、単にコントローラのイメージを以前のタグに戻すだけでは終わりません。戻せないものが随所に潜んでいます。

落とし穴 1: storage version を上げた後のダウングレード

CRD の storage version を v1beta1 から v1 に上げた後、一部の CR がすでに v1 として保存されたとします。ここでコントローラを v1 を知らない旧バージョンに戻すと、旧バージョンのコントローラは etcd に v1 として保存されたオブジェクトを読めない可能性があります。conversion webhook が依然として生きていて v1 を v1beta1 に変換してくれるなら読み取りはできますが、webhook まで一緒にロールバックしてしまうとデータにアクセスできなくなります。

[正しいロールバック順序 — ストレージバージョンを上げた場合]

  1. conversion webhook はそのまま維持(決して先に下げない)
  2. コントローラだけを以前のバージョンにロールバック
  3. 旧バージョンが認識するバージョンへ storage version を戻すか慎重に判断
  4. すでに v1 として保存された CR があれば migrator で旧バージョンに再保存が必要

落とし穴 2: 削除されたフィールドのデータ消失

新しいスキーマであるフィールドを削除した場合、そのフィールドは conversion の過程で永遠に消えます。先の ConvertFromClassName が失われたのを思い出してください。v1 -> v1beta1 へ一度変換されて保存されると、再び v1 に上げても ClassName は復元されません(デフォルト値で埋められるだけです)。

落とし穴 3: 変換の不可逆性

conversion が双方向だからといって、往復(round-trip)が常に無損失とは限りません。変換関数を書くときは round-trip テストを必ず入れ、無損失かを検証しなければなりません。

func TestConversionRoundTrip(t *testing.T) {
	original := &v1beta1.Database{
		Spec: v1beta1.DatabaseSpec{
			Engine:    "postgres",
			Replicas:  3,
			StorageGB: 100,
		},
	}

	// v1beta1 -> v1 -> v1beta1
	hub := &v1.Database{}
	if err := original.ConvertTo(hub); err != nil {
		t.Fatal(err)
	}
	roundTripped := &v1beta1.Database{}
	if err := roundTripped.ConvertFrom(hub); err != nil {
		t.Fatal(err)
	}

	// v1beta1 が表現できるフィールドは無損失でなければならない。
	if roundTripped.Spec.StorageGB != original.Spec.StorageGB {
		t.Errorf("StorageGB lost: got %d want %d",
			roundTripped.Spec.StorageGB, original.Spec.StorageGB)
	}
}

ロールバック可能性を保証する最も現実的な方法は、ストレージバージョンの切り替えとコントローラのアップグレードを分離することです。コントローラを先に十分に運用して安定性を確認した後、しばらく経ってからストレージバージョンを上げます。そうすればコントローラのロールバックが storage version と絡まりません。

5. マルチバージョンの同時運用と deprecation

現実では複数のチームが互いに異なる API バージョンで CR を作っておきます。v1alpha1 で作った CR もあれば、v1beta1 で作ったものもあります。served version を複数維持すれば、これらを同時にサポートできます。

deprecation は突然行ってはいけません。Kubernetes の API deprecation ポリシーに倣って段階を置きます。

段階状態動作
正常served、無表記自由に使用
利用自粛served + deprecated 表記使用時に警告メッセージ
配信停止served:falseAPI リクエスト拒否、etcd データは保持
削除versions から削除もはや存在しない

deprecated: truedeprecationWarning を CRD に設定すると、そのバージョンを使う kubectl コマンドごとに警告が出力されます。利用者に移行を促す最も穏やかな方法です。served:false に変える前には、必ずそのバージョンを参照する CR がもう存在しないか(または storage version migrator ですべて最新バージョンに再保存されたか)を確認します。

6. カナリア Operator — 一部にのみ新バージョンを適用

CRD はクラスタ全体なのでカナリアは難しいですが、コントローラの処理範囲はネームスペースで絞れます。 新バージョンのコントローラを特定のネームスペースだけを見るように起動し、既存のコントローラは残りを担当させる方式です。

controller-runtime のマネージャのキャッシュをネームスペースに限定します。

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
	Scheme: scheme,
	Cache: cache.Options{
		// このコントローラは canary ネームスペースだけを監視する。
		DefaultNamespaces: map[string]cache.Config{
			"team-canary": {},
		},
	},
	// カナリアは別のリーダー選出 ID を使い、既存コントローラと衝突しないようにする。
	LeaderElection:   true,
	LeaderElectionID: "db-operator-canary.example.com",
})

ただし注意点があります。2 つのバージョンのコントローラが同じ CRD を共有するため、新バージョンが CRD スキーマの変更を伴うなら、旧バージョンのコントローラもそのスキーマに耐えられなければなりません。したがってカナリアは「スキーマはそのままでロジックだけが変わる」アップグレードに特に適しています。スキーマまで変わるなら、新スキーマが旧バージョンと互換性のある(フィールド追加のような)場合に限定するのが安全です。

7. バージョン互換性 — K8s、Go、controller-runtime

2026 年時点のバージョンマトリクスをまとめると次のとおりです。

構成要素推奨バージョン備考
Kubernetes1.36最新のサポート対象
Go1.26Kubebuilder スキャフォールディング基準
controller-runtimev0.24.xマネージャ/クライアント API
controller-toolsv0.21.xCRD/RBAC マーカー生成

アップグレード時に最も多い誤りは、controller-runtime のバージョンを上げながら client-go/apimachinery のバージョンを合わせないことです。controller-runtime の各マイナーバージョンは特定の client-go バージョンを前提とするので、go.mod でこれらを一緒に合わせる必要があります。また Kubernetes の version skew ポリシー上、コントロールプレーンとクライアントライブラリのバージョン差は制限されます。古すぎる client-go でビルドされた Operator を最新の API サーバーに接続すると、一部の API が動作しないことがあります。

[互換性点検の順序]

  1. go.mod で controller-runtime, client-go, apimachinery のバージョンを整列
  2. 対象クラスタの K8s バージョンがサポート範囲内か確認
  3. 廃止予定(deprecated)API を使っていないか点検(例: 旧 admissionregistration バージョン)
  4. controller-tools を上げた後 CRD を再生成しスキーマ diff を確認
  5. envtest で統合テストを回し回帰を確認

アップグレード後は必ず CRD マニフェストを再生成し、既存との diff を取るべきです。controller-tools のバージョンが変わると生成される OpenAPI スキーマが微妙に変わることがあり、それが意図しない検証変更を引き起こす可能性があるためです。

8. 大規模 CR マイグレーション — バッチで安全に

storage version を上げた後は、既存の CR を新しい storage version として再保存しなければなりません。放置すると etcd には依然として旧バージョンで保存されたオブジェクトが残り、後で旧バージョンを削除する際に妨げになります。オブジェクトを一度読んで書き直す(no-op update)と、API サーバーが storage version として再保存します。

数千の CR を一度に触ると、API サーバーと conversion webhook に負荷が集中します。レート制限を置いた一回限りのマイグレーションジョブで処理します。

package main

import (
	"context"
	"time"

	"golang.org/x/time/rate"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"sigs.k8s.io/controller-runtime/pkg/client"
)

// migrateStorageVersion はすべての Database CR を一度ずつ no-op 更新し、
// 現在の storage version として再保存する。
func migrateStorageVersion(ctx context.Context, c client.Client) error {
	// 毎秒 10 件に制限(API サーバー/webhook を保護)。
	limiter := rate.NewLimiter(rate.Limit(10), 1)

	var continueToken string
	for {
		var list examplev1.DatabaseList
		opts := []client.ListOption{client.Limit(100)}
		if continueToken != "" {
			opts = append(opts, client.Continue(continueToken))
		}
		if err := c.List(ctx, &list, opts...); err != nil {
			return err
		}

		for i := range list.Items {
			if err := limiter.Wait(ctx); err != nil {
				return err
			}
			db := &list.Items[i]

			// no-op annotation のトグルで update を誘発し再保存。
			if db.Annotations == nil {
				db.Annotations = map[string]string{}
			}
			db.Annotations["example.com/storage-migrated-at"] =
				time.Now().UTC().Format(time.RFC3339)

			if err := c.Update(ctx, db); err != nil {
				// 競合は次のラウンドで再試行されるようログだけ残す。
				continue
			}
		}

		if list.Continue == "" {
			break
		}
		continueToken = list.Continue
	}
	return nil
}

var _ = metav1.ObjectMeta{} // import 使用の明示

Kubernetes はこの作業を自動化する storage version migrator も提供します。StorageVersionMigration リソースを作ると、コントロールプレーン側でそのリソースのすべてのオブジェクトを storage version として再保存してくれます。ただしマイグレーション中は conversion webhook が安定して立っていなければならず、大規模クラスタでは負荷をモニタリングしながら進める必要があります。

マイグレーションジョブは次の原則を守ります。

1. レート制限は必須          - API サーバー/webhook の過負荷を防止(毎秒 N 件)
2. ページネーション         - List に Limit + Continue を使い、メモリ爆発を防止
3. 冪等性                   - 途中で落ちても再実行すればよいよう設計
4. 競合の許容               - 同時更新の競合は次のラウンドで再試行
5. 進行の可視化            - 処理件数/失敗件数をメトリクスで公開
6. webhook ヘルス確認       - 変換が失敗したら即座に中断

9. 障害対応 — ロールアウトの途中で問題が起きたら

アップグレードの途中で問題を検知したときの標準対応手順をまとめます。核心は「これ以上の拡散を止め、可逆なものから戻す」です。

[障害対応プレイブック]

症状の検知
   |
   v
1) ロールアウトを即停止
   - CR に reconcile-paused=true annotation(拡散を遮断)
   - または Rollout.Paused=true で段階的移行を中断
   |
   v
2) 影響範囲の把握
   - status conditions / observedGeneration でどこまで進んだか確認
   - 新バージョンに変わったワークロード数を集計
   |
   v
3) 可逆性の判断
   - ストレージバージョンをまだ上げていないか? -> コントローライメージのロールバックで十分
   - すでに上げたか? -> conversion webhook を維持しつつ慎重にロールバック
   |
   v
4) コントローラのロールバック
   - Deployment イメージを直前の正常タグに
   - ただし CRD/webhook をむやみに一緒に下げない
   |
   v
5) データ整合性の確認
   - 消失しうるフィールド(ClassName など)が影響を受けたか点検
   - 必要ならバックアップから復旧
   |
   v
6) 事後分析
   - round-trip テストの欠落、webhook ヘルス未点検などの原因を記録

最も重要な原則は、CRD と conversion webhook をコントローラと一緒にむやみにロールバックしないことです。コントローラはほぼステートレスでロールバックが安全ですが、CRD/webhook は etcd データへのアクセス可能性に直接関わるので、誤って下げるとすべての CR の読み取りが止まります。

10. デプロイ前後のチェックリスト

[アップグレード前]
[] go.mod の controller-runtime/client-go/apimachinery バージョン整列を確認
[] 対象 K8s バージョンがサポート範囲(1.36)内か確認
[] CRD 再生成後、既存とのスキーマ diff を確認
[] conversion 関数の round-trip テスト通過
[] envtest 統合テスト通過
[] リーダー選出 + LeaderElectionReleaseOnCancel 設定を確認
[] Deployment maxUnavailable=0 / maxSurge=1 を確認
[] ロールバック手順の文書化 + バックアップ確認
[] 新スキーマが旧バージョンのコントローラと互換か(カナリア可否)を判断

[アップグレード中]
[] 新バージョンを served:true, storage:false で先に追加
[] conversion webhook のヘルス確認
[] コントローラのローリング後、リーダー引き継ぎが正常動作するか確認
[] 管理ワークロードは partition で漸進的に拡大
[] status conditions / observedGeneration で進行を追跡

[アップグレード後]
[] storage version の切り替えはコントローラ安定後に別途
[] storage version migrator で既存 CR を再保存(レート制限)
[] 旧バージョンを deprecated 表記し猶予期間を付与
[] メトリクス(reconcile エラー率、webhook 遅延)が正常範囲か確認
[] 参照のなくなった旧バージョンを served:false -> 削除

おわりに

Operator アップグレードの本質は「3 つの時間軸を分離すること」です。コントローラのロジック、CRD スキーマ、管理ワークロードは、それぞれ異なる速度で、異なる可逆性を持って変わります。これらを一度に変えようとする誘惑に打ち勝ち、可逆なもの(コントローラ)から先に回して安定性を確認した後、不可逆なもの(ストレージバージョン、フィールド削除)を最後に慎重に適用することが、無停止での進化の核心です。

特に覚えておくべきは 3 つです。第一に、リーダー選出と LeaderElectionReleaseOnCancel でコントローラのローリングの空白を最小化してください。第二に、conversion webhook は etcd データアクセスの生命線なので、storage version の切り替え前後で絶対にむやみに下げないでください。第三に、すべての変換関数に round-trip テストを入れ、不可逆な損失をビルド段階で捕まえてください。この 3 つを守るだけで、ほとんどの本番アップグレード事故は防げます。

参考資料