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 編です。
- #1 CNI 深さ — Calico / Cilium / eBPF
- #2 RBAC / ServiceAccount 深さ — Aggregated ClusterRole / Impersonation / IRSA / Workload Identity
- #3 Admission Controller — OPA Gatekeeper / Kyverno
- #4 CRD と Operator パターン — controller-runtime ← この記事
- #5 オブザーバビリティ — Prometheus / Grafana / Loki / OpenTelemetry
- #6 GitOps — ArgoCD / Flux
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 例 #
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 になります。
apiVersion: myteam.example.com/v1
kind: Widget
metadata:
name: my-first-widget
namespace: default
spec:
size: medium
replicas: 3kubectl get widgets
kubectl get wg my-first-widget -o yamlCRD 定義の核心要素 #
上のマニフェストで運用面で重要な部分をいくつか押さえます。
scope: NamespacedvsCluster— このオブジェクトが namespace 内に住むのか、クラスタ全域に住むのか。一度決めると変えにくいのでドメインの肌理を見て決定します。ConfigMap・Pod は Namespaced、Node・StorageClass は Cluster。schema.openAPIV3Schema— オブジェクトの形を OpenAPI スキーマとして定義。K8s API サーバが admission 段階でこのスキーマでマニフェストを検証します。enum、minimum、requiredのような制約をここで表現します。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 コントローラの核心パターンは無限ループです。
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 はほぼ次の形を持ちます。
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 です。
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 に残っています。
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 つあります。
| ツール | 特徴 |
|---|---|
| Kubebuilder | Kubernetes プロジェクト公式ツール。CRD scaffolding、Makefile、kustomize 統合。controller-runtime をもっとも直接的に包む。 |
| Operator SDK | Red Hat のツール。Kubebuilder をベースとする + Helm / Ansible ベースの Operator オプションを追加。 |
骨格を作って reconcile 関数だけ埋める流れは 2 つのツールがほぼ同じです。Go で直接書く本格 Operator は Kubebuilder がより標準に近く、Helm chart で始めて Operator に移すマイグレーションシナリオは Operator SDK が滑らかです。
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 で構成するオブザーバビリティスタックを扱います。