Skip to content
Published on

CRD 設計とバージョニング — スキーマ・検証・Conversion Webhook

Authors

はじめに — CRD はコードではなく API 契約です

最初にオペレーターを作るとき、私たちはたいていコントローラのロジックに集中します。reconcile ループをどう書くか、外部システムをどう呼ぶか、と。しかし時間が経つと、足を引っ張るのはコントローラではなく CRD そのもの であることが多いのです。

理由は単純です。CRD はユーザーが手で書く YAML のスキーマであり、一度デプロイされてクラスタにオブジェクトが溜まり始めると、そのスキーマは事実上、恒久的な 公開 API 契約 になります。コントローラのコードはいつでもリファクタリングして再デプロイできますが、すでに etcd に保存された数千のカスタムリソースや、ユーザーが GitOps リポジトリにコミットしたマニフェストは、簡単には変更できません。

だからこそ CRD 設計は、ライブラリ API を設計するのと同じ重みで扱うべきです。フィールドを一つ間違えると、それは v1alpha1、v1beta1、v1 を通じて何年も付きまとい、互換性の負債を積み上げます。本記事では 2026 年現在のツールスタック(Kubebuilder、Kubernetes 1.36 / Go 1.26、controller-runtime v0.24.x、controller-tools v0.21.x)を基準に、最初から長く生き残る CRD を設計しバージョニングする方法を扱います。

本記事で扱う内容は以下のとおりです。

  • OpenAPI v3 スキーマ設計の原則と CEL ベースの検証
  • v1alpha1 から v1 への多バージョン戦略と storage version
  • Hub-and-Spoke の conversion webhook 実装
  • 後方互換、deprecation、フィールド進化の戦略
  • additionalPrinterColumns と subresource(status/scale)
  • 大規模 CRD における性能上の考慮点
  • 宣言的 API 設計のベストプラクティス、マイグレーション、落とし穴

CRD スキーマ設計の原則

OpenAPI v3 structural schema

Kubernetes 1.16 以降、すべての CRD は structural schema を要求します。これは、すべてのフィールドの型が OpenAPI v3 で明示されていなければならず、任意のキー・値を許す x-kubernetes-preserve-unknown-fields のような抜け道を乱用してはならない、という意味です。structural schema があってこそ、server-side apply、フィールドの pruning、そして後述する CEL 検証が機能します。

Kubebuilder を使うと、Go 型にマーカーを付けてこのスキーマを自動生成します。以下は架空のデータベースオペレーター向けの型定義です。

package v1beta1

import (
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// DatabaseSpec はユーザーが宣言する desired state です。
type DatabaseSpec struct {
	// Engine は使用するデータベースエンジンです。
	// 作成後は変更できません。
	// +kubebuilder:validation:Enum=postgres;mysql;mariadb
	// +kubebuilder:validation:Required
	Engine string `json:"engine"`

	// Version はエンジンのバージョンです。
	// +kubebuilder:validation:Required
	// +kubebuilder:validation:Pattern=`^[0-9]+\.[0-9]+$`
	Version string `json:"version"`

	// Replicas はレプリカ数です。デフォルトは 1 です。
	// +kubebuilder:validation:Minimum=1
	// +kubebuilder:validation:Maximum=9
	// +kubebuilder:default=1
	Replicas int32 `json:"replicas,omitempty"`

	// StorageGB はインスタンスあたりのストレージサイズ(GB)です。
	// +kubebuilder:validation:Minimum=10
	// +kubebuilder:default=20
	StorageGB int32 `json:"storageGB,omitempty"`

	// BackupPolicy は任意のバックアップ設定です。
	// +optional
	BackupPolicy *BackupPolicy `json:"backupPolicy,omitempty"`
}

type BackupPolicy struct {
	// Schedule は cron 形式のバックアップ間隔です。
	// +kubebuilder:validation:Required
	Schedule string `json:"schedule"`

	// RetentionDays はバックアップ保持日数です。
	// +kubebuilder:validation:Minimum=1
	// +kubebuilder:default=7
	RetentionDays int32 `json:"retentionDays,omitempty"`
}

ここで注目すべきは、各マーカーが生成される CRD の OpenAPI スキーマに翻訳されることです。+kubebuilder:default=1 はスキーマの default: 1 になり、API サーバーが admission の段階で自動的に埋めます。

required・default・optional の意味

三つを明確に区別する必要があります。

分類マーカー動作使う場面
必須+kubebuilder:validation:Required空なら拒否意味あるデフォルトがない中核フィールド
デフォルト+kubebuilder:default=N空なら自動補完妥当なデフォルトが存在するフィールド
任意+optional空を許容、補完しない本当に任意の機能

デフォルトを与えられるフィールドをわざわざ Required にしないでください。ユーザーが毎回同じ値を書くボイラープレートが増え、後でそのフィールドを任意に変えるのも難しくなります。逆に中核となる識別子(上の例の engine)にはデフォルトを持たせないでください。誤ったデフォルトでおかしなリソースが作られるより、明示的な拒否のほうがましです。

不変フィールドと CEL 検証

engine のようなフィールドは作成後に変更されてはいけません。エンジンを postgres から mysql に変えるのは、新しいリソースを作るのと変わらないからです。かつてはこの不変性を validating webhook で直接検査する必要がありましたが、いまでは CEL(Common Expression Language)検証x-kubernetes-validations)でスキーマの中に宣言できます。

// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="engine は変更できません"
Engine string `json:"engine"`

CEL は単純な不変性を超えて、フィールド間の関係も表現できます。以下は型全体に適用する複合ルールです。

// +kubebuilder:validation:XValidation:rule="self.engine != 'mariadb' || self.replicas == 1",message="mariadb は単一レプリカのみ対応します"
// +kubebuilder:validation:XValidation:rule="!has(self.backupPolicy) || self.replicas >= 1",message="バックアップを有効化するには最低 1 レプリカが必要です"
type DatabaseSpec struct {
	// ... フィールド ...
}

CEL 検証は webhook なしで API サーバーの中で実行されるため、別途サーバーを立てる必要がなく、ネットワークホップもありません。2026 年時点でほぼすべての同期的な検証ロジックは CEL に移せるので、validating webhook は外部システムの照会が必要な場合にのみ残すことが推奨されます。

生成される CRD スキーマは次のような形です。

openAPIV3Schema:
  type: object
  properties:
    spec:
      type: object
      required:
        - engine
        - version
      properties:
        engine:
          type: string
          enum: [postgres, mysql, mariadb]
          x-kubernetes-validations:
            - rule: "self == oldSelf"
              message: "engine は変更できません"
        replicas:
          type: integer
          minimum: 1
          maximum: 9
          default: 1
      x-kubernetes-validations:
        - rule: "self.engine != 'mariadb' || self.replicas == 1"
          message: "mariadb は単一レプリカのみ対応します"

多バージョン戦略

なぜバージョンが複数必要なのか

API は進化します。v1alpha1 で「このフィールドは本当はオブジェクトであるべきだった」と気づき、v1beta1 で構造を変え、v1 で安定化します。Kubernetes の API コンベンションは、この進化のための明確な段階を提供します。

バージョン意味互換性の約束推奨用途
v1alpha1実験的いつでも壊れる、予告なく削除初期フィードバックの収集
v1beta1ベータ妥当な deprecation 期間を保証本番への試験導入
v1安定強い後方互換の約束一般提供

中核となるルールはただ一つです。一つの CRD のすべてのバージョンは、互いに無損失で変換できなければなりません。 etcd にはただ一つの表現(storage version)でのみ保存され、他のバージョンへのリクエストは変換を経るからです。

storage version と served version

CRD では各バージョンが二つのフラグを持ちます。

  • served: このバージョンで API リクエストを受け付けるか
  • storage: このバージョンで etcd に保存するか(ちょうど一つだけ true)

以下は三つのバージョンを同時に提供しつつ v1beta1 を storage に使う CRD の一部です。

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: databases.example.com
spec:
  group: example.com
  names:
    kind: Database
    plural: databases
  scope: Namespaced
  versions:
    - name: v1alpha1
      served: true
      storage: false
      schema:
        openAPIV3Schema: { }
    - name: v1beta1
      served: true
      storage: true
      schema:
        openAPIV3Schema: { }
    - name: v1
      served: false
      storage: false
      schema:
        openAPIV3Schema: { }

リクエストと保存の流れは次のとおりです。

ユーザーが v1alpha1 で GET
  API サーバーが etcd から v1beta1(storage)で読む
  conversion webhook: v1beta1 → v1alpha1 変換
  ユーザーに v1alpha1 で応答


ユーザーが v1 で CREATE
  conversion webhook: v1 → v1beta1(storage)変換
  etcd に v1beta1 で保存

バージョン昇格の流れ

新しいバージョンを導入し古いバージョンを撤去する標準手順は次のとおりです。

ステップ 1: v1beta1 を追加(served=true, storage=false)
        storage はまだ v1alpha1
ステップ 2: storage を v1beta1 に切り替え(storage=true)
        storage-version-migrator で既存オブジェクトを再保存
ステップ 3: v1alpha1 を served=false に
        新規リクエストを受け付けなくなる
ステップ 4: CRD status.storedVersions から v1alpha1 を削除
        すべてのオブジェクトが v1beta1 で再保存された後にのみ
ステップ 5: v1alpha1 のスキーマ定義を削除

ここで最も頻発する間違いは、ステップ 2 の再保存(storage migration)を飛ばすことです。storage version だけ変えて既存オブジェクトを書き直さないと、etcd には依然として古いバージョンで保存されたオブジェクトが残ります。後で古いバージョンのスキーマを消すと、これらのオブジェクトが読めなくなります。

conversion webhook の実装

変換戦略: Hub-and-Spoke

バージョンが N 個あるとき、すべての組み合わせを変換するには N×(N-1) 個の変換関数が必要です。controller-runtime はこれを減らす Hub-and-Spoke パターンを提供します。一つのバージョンを Hub に指定し、残り(Spoke)は Hub との変換のみ実装すればよいのです。すると任意の二バージョン間の変換は、常に Hub を経由します。

   v1alpha1 (Spoke)        v1 (Spoke)
        │                     │
        │  ConvertTo/From     │  ConvertTo/From
        ▼                     ▼
        └────► v1beta1 (Hub) ◄┘

Hub は通常 storage version に指定します。変換が storage を経ると、追加の変換コストが減るからです。

Hub バージョンの定義

Hub はマーカーインターフェースを一つ実装するだけです。

package v1beta1

import "sigs.k8s.io/controller-runtime/pkg/conversion"

// Hub はこのバージョンが変換ハブであることを示します。
func (*Database) Hub() {}

// コンパイル時にインターフェース充足を保証します。
var _ conversion.Hub = (*Database)(nil)

Spoke バージョンの ConvertTo / ConvertFrom

Spoke バージョン(v1alpha1)は、Hub への変換と Hub からの変換を実装します。

package v1alpha1

import (
	"sigs.k8s.io/controller-runtime/pkg/conversion"
	v1beta1 "example.com/api/v1beta1"
)

// ConvertTo は v1alpha1 を Hub(v1beta1)に変換します。
func (src *Database) ConvertTo(dstRaw conversion.Hub) error {
	dst := dstRaw.(*v1beta1.Database)

	// メタデータはそのままコピーします。
	dst.ObjectMeta = src.ObjectMeta

	// 同一のまま保たれるフィールド。
	dst.Spec.Engine = src.Spec.Engine
	dst.Spec.Version = src.Spec.Version
	dst.Spec.Replicas = src.Spec.Replicas

	// v1alpha1 の storageGB(int) が v1beta1 でも同一と仮定。
	dst.Spec.StorageGB = src.Spec.StorageGB

	// v1alpha1 になかったフィールド: 妥当なデフォルトで埋める。
	if src.Spec.Backup != "" {
		dst.Spec.BackupPolicy = &v1beta1.BackupPolicy{
			Schedule:      src.Spec.Backup,
			RetentionDays: 7,
		}
	}

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

// ConvertFrom は Hub(v1beta1)を v1alpha1 に変換します。
func (dst *Database) ConvertFrom(srcRaw conversion.Hub) error {
	src := srcRaw.(*v1beta1.Database)

	dst.ObjectMeta = src.ObjectMeta
	dst.Spec.Engine = src.Spec.Engine
	dst.Spec.Version = src.Spec.Version
	dst.Spec.Replicas = src.Spec.Replicas
	dst.Spec.StorageGB = src.Spec.StorageGB

	// v1beta1 の構造化バックアップを v1alpha1 の単純な文字列に縮約。
	if src.Spec.BackupPolicy != nil {
		dst.Spec.Backup = src.Spec.BackupPolicy.Schedule
	}

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

ここで重要な洞察が二つあります。第一に、ダウンコンバージョン(Hub → 旧バージョン)では情報損失が避けられない場合があります。 上の例では v1beta1 の BackupPolicy.RetentionDays が v1alpha1 へ下がるときに失われます。このような損失を防ぐには、失われる情報をアノテーションに保存するラウンドトリップパターンを使うこともあります。第二に、すべての変換はラウンドトリップが一貫していなければなりません。 A から B へ行き再び A へ戻ったとき原本と同じでなければならず、controller-runtime はこれを検証する fuzz テストユーティリティを提供します。

conversion webhook の登録

CRD に webhook 変換戦略を宣言します。Kubebuilder はこの部分をほとんど生成してくれます。

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: databases.example.com
spec:
  conversion:
    strategy: Webhook
    webhook:
      conversionReviewVersions:
        - v1
      clientConfig:
        service:
          namespace: db-operator-system
          name: db-operator-webhook
          path: /convert
          port: 443
        caBundle: <cert-manager が注入>

マネージャーのコードでは、各型について変換 webhook を設定します。

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

caBundle は通常 cert-manager の CA Injector が注入します。webhook がダウンすると変換が失敗し、変換に依存するすべてのリクエスト(旧バージョンの GET でさえ)が止まるため、webhook の可用性はコントロールプレーンの可用性に直結します。

後方互換と deprecation

安全な変更と危険な変更

スキーマ変更には無害なものと破壊的なものがあります。

変更安全か備考
任意フィールドの追加安全既存オブジェクトに影響なし
デフォルトの追加おおむね安全既存オブジェクトの意味が変わらぬよう注意
enum 値の追加安全旧クライアントは知らないだけ
フィールドの削除危険新バージョンのみ、conversion で保存
必須への変更危険既存オブジェクトが無効化される恐れ
型の変更非常に危険新バージョン + conversion 必須
enum 値の削除危険その値を使うオブジェクトが壊れる

黄金律はこれです。同じバージョンの中で破壊的変更を決して行わないでください。 フィールド削除や型変更が必要なら、必ず新しい API バージョンを作り、conversion webhook で橋を架けます。

deprecation のシグナル

古いバージョンを撤去するときは、ユーザーに前もって警告すべきです。CRD バージョンに deprecation マーカーを付けられます。

versions:
  - name: v1alpha1
    served: true
    storage: false
    deprecated: true
    deprecationWarning: "example.com/v1alpha1 Database は deprecated です。v1beta1 を使用してください。"

こうすると kubectl でそのバージョンを扱う際に警告が出力され、ユーザーにマイグレーションの時間を与えます。

additionalPrinterColumns と subresource

printer columns

kubectl get databases を打ったときに表示される列を定義できます。運用者にとって最も重要な情報を露出させましょう。

// +kubebuilder:printcolumn:name="Engine",type=string,JSONPath=`.spec.engine`
// +kubebuilder:printcolumn:name="Replicas",type=integer,JSONPath=`.spec.replicas`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
type Database struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`
	Spec   DatabaseSpec   `json:"spec,omitempty"`
	Status DatabaseStatus `json:"status,omitempty"`
}

status subresource

+kubebuilder:subresource:status マーカーは status を別の subresource にします。これは単なる利便性ではなく、重要な意味を持ちます。

  • ユーザーは spec のみを変更し、コントローラは status のみを変更します。両者は衝突しません。
  • status の更新は metadata.generation を上げないため、コントローラは spec の変更(generation 増加)と自身の status 書き込みを区別できます。
// +kubebuilder:subresource:status
type Database struct { /* ... */ }

type DatabaseStatus struct {
	// Phase は人間が読むための要約状態です。
	Phase string `json:"phase,omitempty"`

	// Conditions は標準の状態条件リストです。
	// +optional
	// +patchMergeKey=type
	// +patchStrategy=merge
	Conditions []metav1.Condition `json:"conditions,omitempty"`

	// ObservedGeneration は最後に処理した spec 世代です。
	ObservedGeneration int64 `json:"observedGeneration,omitempty"`
}

scale subresource

レプリカを持つリソースなら、scale subresource を露出させて kubectl scale と HPA を動かせます。

// +kubebuilder:subresource:scale:specpath=.spec.replicas,statuspath=.status.replicas,selectorpath=.status.labelSelector
type Database struct { /* ... */ }

こうすると標準の kubectl scale database/mydb --replicas=3 がそのまま動き、HorizontalPodAutoscaler がこの CRD を対象にできます。

大規模 CRD の性能上の考慮点

CRD が数千、数万のオブジェクトに拡大すると、設計上の決定が性能に直接影響します。

  • オブジェクトを小さく保ちましょう。 etcd のオブジェクトサイズ上限は 1.5MB ですが、実際にはそれよりはるかに小さくすべきです。大きなデータ(例: 設定ファイル全体)を CR にインライン化せず、ConfigMap や外部ストアを参照してください。
  • status にログやイベントを蓄積しないでください。 status に際限なく成長する配列を置くと、更新のたびにオブジェクト全体が書き直されます。Conditions のようにサイズが固定された構造を使いましょう。
  • 不要な watch とインデックスを避けましょう。 コントローラはすべてのオブジェクトをメモリにキャッシュ(informer)するため、オブジェクトが多く大きいとメモリが線形に増加します。label/field selector で watch 範囲を狭めてください。
  • conversion webhook のコストを意識しましょう。 storage migration や大量 list の際、すべてのオブジェクトが webhook を通過します。変換関数は軽量に保ち、外部呼び出しを入れないでください。
  • printer column の計算を単純に。 複雑な JSONPath や priority の高い列が多いと、list 応答のシリアライズが遅くなります。

API 設計のベストプラクティス

宣言的であること

spec は「何を望むか(desired state)」を記述すべきで、「何をせよ(命令)」を含めてはなりません。たとえば spec.restart: true のような命令的フィールドはアンチパターンです。一度実行されると無意味になり、誰がいつ消したか追跡もできません。代わりに意図を宣言させましょう。

悪い:  spec.restartNow: true        (命令、一回限り)
良い:  spec.version: "16.2"         (意図、コントローラが reconcile)
良い:  spec.paused: true            (状態、持続的な意味)

API 表面を小さく

最初からすべてのオプションを露出しようとする誘惑を抑えてください。一度追加したフィールドは永遠にサポートせねばなりません。そのフィールドが本当に必要か、妥当なデフォルトで代替できないか自問しましょう。小さな API は学びやすく、進化させやすく、壊す余地が少ないのです。

spec と status の明確な分離

spec はユーザーの入力で、status はコントローラの出力です。この二つを決して混ぜないでください。コントローラが spec に値を書き込んだ瞬間、ユーザーの GitOps リポジトリとクラスタの実状態がずれ始めます。

Conditions を標準形式で

状態の表現は metav1.Condition 標準に従ってください。typestatusreasonmessagelastTransitionTime を持つこの構造は、kubectl、ダッシュボード、他のコントローラがみな理解する共通言語です。

マイグレーション

すでに運用中の CRD に新しいバージョンを導入し storage を移す作業には、慎重な順序が必要です。以下は storage version migrator を活用した実践手順です。

# 1. 現在保存されているバージョンを確認。
kubectl get crd databases.example.com -o jsonpath='{.status.storedVersions}'

# 2. 新バージョンを served=true でデプロイ(storage はまだ旧バージョン)。
kubectl apply -f crd-with-new-version.yaml

# 3. storage を新バージョンに切り替え。
kubectl patch crd databases.example.com --type=merge \
  -p '{"spec":{"versions":[{"name":"v1beta1","storage":true}]}}'

# 4. すべてのオブジェクトを新 storage バージョンで再保存(no-op パッチでトリガー)。
kubectl get databases.example.com --all-namespaces -o name | \
  xargs -I{} kubectl patch {} --type=merge -p '{}'

# 5. status.storedVersions から旧バージョンが消えたか確認。
kubectl get crd databases.example.com -o jsonpath='{.status.storedVersions}'

# 6. 旧バージョンを served=false に、その後スキーマを削除。

本番では、ステップ 4 を直接 patch で回すより、Kubernetes の storage-version-migrator コントローラやそれに準ずるツールを使うほうが安全です。数万のオブジェクトを一度に patch すると API サーバーに負荷が集中するからです。

落とし穴まとめ

最後に、現場で繰り返し遭遇する落とし穴を整理します。

  • storage migration を忘れる落とし穴。 storage version だけ変えて既存オブジェクトを再保存しないと、旧スキーマを消した瞬間にオブジェクトが読めなくなります。status.storedVersions が単一値になるまで旧バージョンのスキーマを消さないでください。
  • 無損失変換を壊す落とし穴。 ラウンドトリップテストなしで conversion をデプロイすると、ダウンコンバージョンで静かにデータが消えます。controller-runtime の fuzz ベースのラウンドトリップテストを CI に入れましょう。
  • 同じバージョンを破壊的に変える落とし穴。 v1beta1 でフィールドの型を変えて再デプロイすると、既存オブジェクトがデコードできないか、pruning でデータが飛びます。破壊的変更は常に新バージョンへ。
  • webhook を単一障害点にする落とし穴。 conversion webhook が死ぬと旧バージョンの GET さえ失敗します。レプリカを二つ以上置き、PodDisruptionBudget と適切な readiness probe を設定しましょう。
  • 命令的フィールドの落とし穴。 spec.actionspec.restartNow のような一回限りの命令は reconcile モデルと衝突します。すべてを desired state で表現しましょう。
  • status に spec を混ぜる落とし穴。 コントローラが spec を変更すると GitOps ドリフトが生じます。コントローラは status のみを書きます。
  • 巨大オブジェクトの落とし穴。 大きなデータを CR にインライン化すると informer のメモリと etcd の双方が苦しみます。参照に切り出しましょう。
  • defaulting の意味変更の落とし穴。 すでにデプロイ済みのフィールドのデフォルトを変えると、その値を明示しなかった既存オブジェクトの意味が変わります。デフォルト変更は事実上の動作変更だと覚えておきましょう。

おわりに

CRD 設計は最初こそコントローラの付属物のように見えますが、時間が経つほどシステムで最も変えにくい部分になります。良い出発点は明確です。structural schema と CEL で検証をスキーマの中に収め、バージョン進化を最初から念頭に置き、conversion を Hub-and-Spoke で単純に保ち、すべての変換をラウンドトリップテストで守ることです。

何より、CRD をコードではなく API 契約 として扱ってください。一度ユーザーに公開したフィールドは取り戻すのが難しいのです。小さく始め、宣言的に設計し、進化の道を前もって整えておけば、オペレーターは何年経っても壊れず、ユーザーとともに成長できます。

References