目次
18 章

CRD と Operator パターン

K8s API をユーザードメインのオブジェクトで拡張する2つの軸を扱います。CustomResourceDefinition で新しいオブジェクト種類を定義し、controller-runtime ベースの Operator が第1章で見た reconcile loop をそのオブジェクトの上に掛けて K8s の宣言的モデルをドメインまで拡張します。ownerReference・finalizer・status subresource の3つの標準パターンと Kubebuilder・Operator SDK のビルドツールまでを一連の流れで整理します。

第17章 Admission Controller で Gatekeeper の ConstraintTemplate、Kyverno の ClusterPolicy のようなオブジェクトがどれも K8s 本体の標準リソースではなく、2つのツールが CRD で定義した新しいオブジェクト種類 であるという点を一度押さえておきました。本章のテーマはその CRD 自体です。ここまで扱ったオブジェクトはすべて K8s がビルトインで持っている標準リソースでした — Pod、Deployment、Service、ConfigMap、Secret、NetworkPolicy など。しかし K8s API の本当の魅力は その上に自分のドメインのオブジェクトを追加できる という点です。PostgreSQL クラスタ、Redis マスター-replica トポロジー、Kafka ブローカーグループのようなドメイン概念が K8s の中で1級オブジェクトになり、kubectl get postgrescluster で照会され、マニフェストで宣言され、コントローラによって自動的に運用されます。この拡張の2つの軸が CustomResourceDefinition (新しいオブジェクト種類の定義) と Operator (そのオブジェクトを運用するコントローラ) です。

本章の終わりには 第1章 Kubernetes とは の reconcile loop モデルが K8s 本体の標準リソースを越えてユーザードメインオブジェクトまで拡張された形 が手に入ります。第4章 Deployment コントローラと第18章 Operator が同じパターンの別のインスタンスであるという点がその出発点です。

K8s API 拡張の2つの道 #

K8s は自分の API を拡張する道を2つ提供します。

特徴
CRD (CustomResourceDefinition)マニフェストで新しいオブジェクト種類を登録。K8s API サーバーがそのオブジェクトを etcd に保存し標準オブジェクトのように扱う
Aggregation Layer別の API サーバーを起動して K8s API サーバーがそちらへ呼び出しを委譲。より柔軟だが運用コストが大きい

運用クラスタで圧倒的によく使われる道は CRD です。Aggregation Layer は metrics-server のように K8s コアと非常に深く連携する場合以外にはほとんど使われません。本章のテーマは CRD です。

CRD — 新しいオブジェクト種類の定義 #

CRD はそれ自体が K8s の1つのオブジェクト種類です。CRD 1枚を適用するとその瞬間からクラスタに新しいオブジェクト種類が登録され、kubectl でそのオブジェクトを作って照会できるようになります。

最も単純な CRD の例 #

widget-crd.yaml — 新しいオブジェクト種類 'Widget' を定義
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: widgets.myteam.example.com
spec:
  group: myteam.example.com
  scope: Namespaced
  names:
    plural: widgets
    singular: widget
    kind: Widget
    shortNames: ["wg"]
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                size:
                  type: string
                  enum: ["small", "medium", "large"]
                replicas:
                  type: integer
                  minimum: 1
                  maximum: 10
              required: ["size"]
            status:
              type: object
              properties:
                phase:
                  type: string
      subresources:
        status: {}

この CRD を適用したら、そのクラスタで次のマニフェストが valid になります。

my-widget.yaml — 新しい Widget オブジェクト1個
apiVersion: myteam.example.com/v1
kind: Widget
metadata:
  name: my-first-widget
  namespace: default
spec:
  size: medium
  replicas: 3
標準kubectlで照会可能
kubectl get widgets
kubectl get wg my-first-widget -o yaml

CRD 定義の核心要素 #

上のマニフェストで運用面で重要な部分をいくつか押さえます。

  • scope: Namespaced vs Cluster — このオブジェクトがネームスペースの中に住むのか、クラスタ全域に住むのかを定めます。一度定めると変えにくいのでドメインの肌合いを見て決定します。ConfigMap・Pod は Namespaced、Node・StorageClass は Cluster。
  • schema.openAPIV3Schema — オブジェクトの形を OpenAPI スキーマで定義します。K8s API サーバーが admission 段階でこのスキーマでマニフェストを検証します。enumminimumrequired のような制約をここで表現します。
  • subresources.status: {}status フィールドを別の subresource として分離します。コントローラが spec を触らずに status だけを更新できるようにする K8s 標準パターンです。この一行が非常に重要です — 後の §「status subresource」でもう一度押さえます。
  • versions — CRD は複数のバージョンを同時にサポートでき、1つのバージョンが storage: true で実際の etcd 保存形式を決定します。バージョン変更時には storage マイグレーションが必要です。

CRD だけでは足りない — Operator の役割 #

CRD を登録しただけではオブジェクトに意味がありません。Widget オブジェクトを作ってもそれ自体では etcd に保存されたマニフェストにすぎず、どんな動作も起きません。そのオブジェクトを見て実際に何かを作って運用するコントローラ が必要です。

このコントローラを K8s コミュニティでは Operator と呼びます。Operator は本質的に次の2つの部分の束です。

  • CRD — 新しいドメインオブジェクトの形の定義
  • Custom Controller — そのオブジェクトを見て reconcile ループを回すコード

このモデルが強力な理由は K8s の核心パラダイム — 宣言的 desired state + コントローラの reconcile loop — をユーザードメインまで拡張するからです。私たちは「PostgreSQL マスター1個 + replica 3個のクラスタが欲しい」とマニフェストで宣言するだけで、Operator が 第8章 StatefulSet、Service、PVC、ConfigMap、Secret、backup CronJob まで自動的に作って管理します。

Reconcile loop — コントローラの本質 #

K8s コントローラの核心パターンは無限ループです。第1章 で図で見た reconcile loop の正確な形です。

Reconcile loopの疑似コード
loop forever:
  observed_state = K8s APIから実際の状態を照会
  desired_state = マニフェストから望む状態を読む
  if observed_state != desired_state:
    K8s API呼び出しで差を狭める
  sleep until 次のトリガー

この単純なループがビルトインコントローラ (第4章 Deployment / ReplicaSet、StatefulSet、Job など) とユーザー定義 Operator の共通モデルです。Operator を書くということは 「自分のドメインオブジェクトに対してこの reconcile 関数をどう実装するか」 を定める仕事です。第4章で Deployment コントローラの reconcile が ReplicaSet と Pod の差を埋める様子を見たなら、本章の Operator は同じパターンを Widget のようなユーザー定義オブジェクトに適用します。

controller-runtime — Operator の標準骨格 #

最初から K8s API client を直接呼び出して reconcile ループを書くことは可能ですがボイラープレートが非常に大きいです。controller-runtime は Kubernetes プロジェクト自体が管理する Go ライブラリで、Operator の骨格を標準化したツールです。Kubebuilder と Operator SDK がこの controller-runtime を包んだ上位ツールです。

Reconciler の形 #

controller-runtime ベースの Operator はほぼ次の形を持ちます。

WidgetReconciler — 単純化した骨格
package controller

import (
    "context"

    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/reconcile"
    appsv1 "k8s.io/api/apps/v1"
    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

    myteamv1 "myteam.example.com/api/v1"
)

type WidgetReconciler struct {
    client.Client
}

func (r *WidgetReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
    // 1. desired state — Widgetオブジェクトを読む
    var widget myteamv1.Widget
    if err := r.Get(ctx, req.NamespacedName, &widget); err != nil {
        return reconcile.Result{}, client.IgnoreNotFound(err)
    }

    // 2. observed state — Widgetが作るべきDeploymentを読む
    var deploy appsv1.Deployment
    err := r.Get(ctx, client.ObjectKey{
        Namespace: widget.Namespace,
        Name:      widget.Name,
    }, &deploy)

    if err != nil && client.IgnoreNotFound(err) == nil {
        // なければ作る
        newDeploy := buildDeployment(&widget)
        if err := r.Create(ctx, newDeploy); err != nil {
            return reconcile.Result{}, err
        }
    } else if err == nil {
        // あればdesired stateと比較後に更新
        if needsUpdate(&deploy, &widget) {
            updateDeployment(&deploy, &widget)
            if err := r.Update(ctx, &deploy); err != nil {
                return reconcile.Result{}, err
            }
        }
    }

    // 3. status更新
    widget.Status.Phase = "Ready"
    if err := r.Status().Update(ctx, &widget); err != nil {
        return reconcile.Result{}, err
    }

    return reconcile.Result{}, nil
}

この関数が Widget オブジェクト1個につき一度ずつ (または変更があるたびに) 呼び出されます。controller-runtime が watch / queue / retry / leader election のような基盤インフラをすべて処理してくれるので、Operator 開発者は上の reconcile ロジックにだけ集中すればよいです。

運用の3つの標準パターン #

Operator を書くときほぼ常に出会う3つのパターンがあります。K8s API の標準メカニズムなのでよく知っておくとずっと役立ちます。

1. ownerReference — 子オブジェクトの自動整理 #

Widget が Deployment を作ったなら、Widget が削除されるときその Deployment も一緒に削除されるべきです。いちいちコードで処理せずに K8s の garbage collector に自動的に処理させる メカニズムが ownerReference です。第4章 で Deployment が ReplicaSet を、ReplicaSet が Pod を ownerReference でつないだモデルがそのままユーザードメインに適用されます。

子オブジェクト生成時にownerReferenceを付着
deploy := &appsv1.Deployment{
    ObjectMeta: metav1.ObjectMeta{
        Name:      widget.Name,
        Namespace: widget.Namespace,
        OwnerReferences: []metav1.OwnerReference{
            *metav1.NewControllerRef(&widget, myteamv1.GroupVersion.WithKind("Widget")),
        },
    },
    Spec: ...
}

この ownerReference が付着した Deployment は親 Widget が削除される瞬間 K8s の garbage collector によって自動的に削除されます。明示的な削除ロジックを Operator コードに入れる必要がありません。

2. finalizer — 外部リソース整理の hook #

オブジェクトが削除されるとき K8s 内の子オブジェクトは ownerReference で自動整理されますが、K8s 外のリソース はそうではありません。クラウド LB、S3 バケット、RDS インスタンスのような外部リソースを作った Operator なら、オブジェクト削除時にその外部リソースも一緒に整理しなければなりません。この hook を掛けるメカニズムが finalizer です。

finalizer が登録されたオブジェクトを削除しようとすると K8s API サーバーはオブジェクトをすぐには消さず metadata.deletionTimestamp フィールドだけを埋めます。そのオブジェクトは「削除進行中」状態になり、finalizer リストが空になるまで etcd に残ります。

finalizerを活用した外部リソース整理
const widgetFinalizer = "widget.myteam.example.com/finalizer"

func (r *WidgetReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
    var widget myteamv1.Widget
    if err := r.Get(ctx, req.NamespacedName, &widget); err != nil {
        return reconcile.Result{}, client.IgnoreNotFound(err)
    }

    // 削除進行中か
    if !widget.DeletionTimestamp.IsZero() {
        if controllerutil.ContainsFinalizer(&widget, widgetFinalizer) {
            // 外部リソースの整理
            if err := cleanupExternalResources(&widget); err != nil {
                return reconcile.Result{}, err
            }
            // 整理が終わったらfinalizerを除去 → K8sがオブジェクトを本当に削除
            controllerutil.RemoveFinalizer(&widget, widgetFinalizer)
            if err := r.Update(ctx, &widget); err != nil {
                return reconcile.Result{}, err
            }
        }
        return reconcile.Result{}, nil
    }

    // 削除中でなければfinalizerを保証
    if !controllerutil.ContainsFinalizer(&widget, widgetFinalizer) {
        controllerutil.AddFinalizer(&widget, widgetFinalizer)
        if err := r.Update(ctx, &widget); err != nil {
            return reconcile.Result{}, err
        }
    }

    // 一般reconcile進行
    ...
}

finalizer のあるオブジェクトは外部リソースの整理が終わるまで本当には削除されないので、運用者がオブジェクトを強制的に消そうとしてもクラウドリソースが取り残されません。

3. status subresource — spec と status の分離 #

CRD に subresources.status: {} を書いた一行の意味がここで完成します。この subresource のある CRD は次の2つの動作が分離されます。

  • ユーザー (または GitOps ツール) は spec だけを修正。status の修正は無視される。
  • コントローラは r.Status().Update()status だけを修正します。spec は触りません。

この分離が重要な理由は GitOps との衝突を防ぐからです。 第20章 GitOps で扱う ArgoCD が git のマニフェストをクラスタに同期するとき、status フィールドはコントローラが埋める値なので git にはありません。status subresource が分離されていれば ArgoCD は status を見ずに spec だけを比較するので、コントローラが status を更新しても ArgoCD が「drift」と認識しません。

Operator ビルドツール — Kubebuilder vs Operator SDK #

controller-runtime を直接使うよりさらに上位の抽象化を提供するツールが2つあります。

ツール特徴
KubebuilderKubernetes プロジェクト公式ツール。CRD scaffolding、Makefile、kustomize 統合。controller-runtime を最も直接的に包む
Operator SDKRed Hat のツール。Kubebuilder をベースにする + Helm / Ansible ベースの Operator オプションを追加

骨格を作って reconcile 関数だけを埋める流れは2つのツールがほぼ同じです。Go で直接書く本格的な Operator は Kubebuilder がより標準に近く、Helm チャートから始めて Operator へ移るマイグレーションシナリオは Operator SDK が滑らかです。

Kubebuilderで新しいOperatorプロジェクトを開始
kubebuilder init --domain example.com --repo github.com/myteam/widget-operator
kubebuilder create api --group myteam --version v1 --kind Widget

この2つのコマンドで CRD 定義、controller 骨格、マニフェスト、Dockerfile、Makefile がすべて生成されます。その後 controller.go の Reconcile 関数を埋めるのが実際の作業の本体です。

運用でよく出会う Operator #

本章のモデルを知れば運用クラスタにすでに入っている様々な Operator の意図と動作が一行で読めます。よく出会う事例を短く押さえておきます。

  • cert-manager第10章 Ingress で押さえた証明書自動発行ツール。CertificateIssuerClusterIssuer という CRD を定義し、Let’s Encrypt との ACME チャレンジの流れを reconcile で自動化します。
  • AWS Load Balancer Controller第10章 の ALB Ingress Controller も Operator です。Ingress オブジェクトと AWS ALB の一貫性を reconcile で維持します。
  • External Secrets Operator第29章 シークレット運用 で扱うツール。ExternalSecret CRD が AWS Secrets Manager・Vault・GCP SM の値を K8s Secret へ同期します。
  • CloudNativePG・Zalando Postgres Operator第8章 StatefulSet の上に載って PostgreSQL クラスタのセットアップ・バックアップ・フェイルオーバーを自動化します。DB のような状態性ワークロードの運用自動化で Operator の価値が最も大きい領域です。
  • Karpenter第13章 オートスケーリング の EKS ノード自動化ツール。NodePoolNodeClass CRD の上に reconcile を回してノードを動的に起動して整理します。

運用クラスタのマニフェストディレクトリで apiVersion: postgres-operator.crunchydata.com/v1beta1 のような見慣れない API グループに出会ったら、それはほぼ常にどれかの Operator が定義した CRD です。本章のモデルで一行で読めます — 「この CRD はどの Operator が提供し、その Operator の reconcile がどんなリソースたちを作って管理するか」。

いつ Operator を書くべきか #

CRD と Operator は強力なツールですがすべてのドメインオブジェクトに必要なわけではありません。次の条件が集まるとき Operator の価値が大きいです。

  • 状態のあるワークロードの運用自動化 — DB クラスタのセットアップ、バックアップ、フェイルオーバー、アップグレードのように人が定期的に回していた手順
  • 複数の K8s オブジェクトの束を1つのドメインオブジェクトに抽象化 — Deployment + Service + Ingress + PDB + HPA + ServiceMonitor などを束ねて1つのオブジェクトに
  • 外部リソースと K8s オブジェクトの一貫性の維持 — クラウド LB、DNS レコードのようなものを K8s オブジェクトと連携させて自動同期
  • ドメイン知識のコード化 — 「PostgreSQL primary が死んだら最も同期 lag が小さい replica を promote」のような運用ノウハウ

逆に、Helm チャート1枚で終わる単純な束 なら Operator を書く必要はありません。Operator はコード保守コストが大きいツールなので、本当に自動化の価値が大きいところにだけ導入するのがよいです。

練習問題 #

  1. 自分のクラスタにインストールされた CRD を確認します (kubectl get crd)。標準 K8s オブジェクト (Pod、Service など) のほかにどんな CRD たちがあるかを表に整理し、各 CRD がどの Operator が定義したものか (cert-manager、ALB Controller、Karpenter、ArgoCD など) をマッピングします。その Operator の reconcile がどんなリソースたちを作って管理するかを §「運用でよく出会う Operator」のモデルで一段落ずつ整理します。
  2. 本文の widget-crd.yaml をそのまま適用した後、Widget オブジェクト1個を作ってみます。CRD は登録されていますがそれを見るコントローラがないのでオブジェクトだけが etcd に保存され何の動作も起きません。この「オブジェクトはあるのにコントローラがない」状態が §「CRD だけでは足りない」のモデルとどう接続されるかを自分の表現で一段落で整理します。
  3. status subresource のある CRD とない CRD の違いをシミュレーションで書いてみます。ArgoCD が git マニフェストをクラスタに同期するとき、status フィールドがコントローラによって更新されている間 git にはその値がない状態です。status subresource が分離されているときとそうでないときで ArgoCD の drift 検出がどう違って動作するかを 第20章 GitOps と接続して一段落で整理します。

一行まとめ: CRD が K8s API を新しいオブジェクト種類で拡張し、controller-runtime ベースの Operator がそのオブジェクトの上に 第1章 の reconcile loop を掛けて K8s の宣言的モデルをユーザードメインまで引き込む。運用の3つの標準パターンは ownerReference (子の自動整理)・finalizer (外部リソース整理 hook)・status subresource (GitOps 衝突防止) だ。cert-manager・External Secrets・CloudNativePG・Karpenter のような運用クラスタの核心ツールがどれもこのパターンの上に立っている。

次の章 #

本章まで K8s API 自体の深さ — CNI・RBAC・Admission・CRD — を辿ってきました。これだけが K8s オブジェクトモデルの拡張メカニズムです。次の章は視点を一段移します — このすべてのコンポーネントが回るクラスタをどう観測するか です。

第19章 可観測性 ではクラスタとワークロードのメトリクス・ログ・トレースの3つの次元を扱います。Prometheus + Grafana のメトリクススタック、Loki のログ集計、OpenTelemetry の分散トレース、そしてその上に載る kube-state-metrics・node_exporter のような標準エクスポータまでを一連の流れで整理します。第11章 resources.requests / limits第12章 Health check第13章 オートスケーリング のシグナルがすべてこの可観測性スタックを経て運用者に届くという点が本章群の最後の接点になります。

X