K8s 高級 #4 CRD と Operator パターン — controller-runtime

K8s 高級シリーズの 4 番目の記事です。これまで扱ってきたオブジェクトはすべて K8s がビルトインで持っている標準リソースでした — Pod、Deployment、Service、ConfigMap、Secret、NetworkPolicy など。しかし K8s API の本当の魅力は その上に私たちのドメインのオブジェクトを追加できる 点です。PostgreSQL クラスタ、Redis マスター・replica トポロジ、Kafka ブローカーグループのようなドメイン概念が K8s 内で 1 級オブジェクトになり、kubectl get postgrescluster で照会されてマニフェストで宣言されコントローラによって自動運用されます。この拡張の 2 軸が CustomResourceDefinition(新しいオブジェクト種定義)と Operator(そのオブジェクトを運用するコントローラ)です。

このシリーズは K8s 高級 6 編です。

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 — このオブジェクトが namespace 内に住むのか、クラスタ全域に住むのか。一度決めると変えにくいのでドメインの肌理を見て決定します。ConfigMap・Pod は Namespaced、Node・StorageClass は Cluster。
  • schema.openAPIV3Schema — オブジェクトの形を OpenAPI スキーマとして定義。K8s API サーバが admission 段階でこのスキーマでマニフェストを検証します。enumminimumrequired のような制約をここで表現します。
  • subresources.status: {}status フィールドを別途 subresource として分離。コントローラが spec を触らずに status だけ更新できるようにする K8s 標準パターンです。この 1 行が非常に重要です。
  • 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 が StatefulSet、Service、PVC、ConfigMap、Secret、backup CronJob まで自動で作って管理します。

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

K8s コントローラの核心パターンは無限ループです。

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

この単純なループがビルトインコントローラ(Deployment、StatefulSet、Job など)とユーザー定義 Operator の共通モデルです。Operator を書くということは 「私のドメインオブジェクトに対してこの reconcile 関数をどう実装するか」 を決めることです。

controller-runtime — Operator の標準骨格 #

最初から K8s API client を直接呼んで reconcile ループを書くこともできますが boilerplate が非常に大きいです。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 個あたり 1 度ずつ(または変更があるたびに)呼び出されます。controller-runtime が watch / queue / retry / leader election のような基盤インフラをすべて処理してくれるので、Operator 開発者は上の reconcile ロジックだけに集中すればよいです。

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

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

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

Widget が Deployment を作ったら、Widget が削除されるときにその Deployment も一緒に削除されなければなりません。1 つ 1 つコードで処理せずに K8s の garbage collector が自動で処理させる メカニズムが 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: {} と書いた 1 行の意味がここで完成します。この subresource がある CRD は次の 2 つの動作が分離されます。

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

この分離が重要な理由は 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 chart で始めて 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 を書くべきか #

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

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

逆に Helm chart 1 枚で完結する単純な構成 なら Operator を書く必要はありません。Operator はコードの維持コストが大きいツールなので、本当に自動化の価値が大きい場面にのみ導入する方が良いです。

締めくくり #

K8s API の拡張メカニズムである CRD とその上に乗る Operator パターンを整理しました。CRD 1 枚で新しいオブジェクト種を登録でき、controller-runtime ベースの Operator がそのオブジェクトに reconcile ループを掛けて K8s の宣言的モデルをユーザードメインまで拡張するという流れを追いました。ownerReference で子オブジェクトを自動整理し、finalizer で外部リソース整理 hook を掛け、status subresource で GitOps との衝突を防ぐ 3 つの標準パターンまで押さえました。次の記事ではこれらすべてのコンポーネントが回るクラスタをどう観測するか — Prometheus / Grafana / Loki / OpenTelemetry で構成するオブザーバビリティスタックを扱います。

X