Skip to content
Published on

データベースOperatorを作る — バックアップ・フェイルオーバー・スケールをコードで

Authors

はじめに — なぜデータベースが最も難しいのか

ステートレスなアプリケーションはKubernetesがうまく扱います。Deploymentにreplicasを書けばPodが立ち、死ねば再び立ち、どのPodが死んでも同じです。ところがデータベースは違います。

データベースのPodは互いに対等ではありません。一つは書き込みを受けるプライマリで、残りは読み取り専用レプリカです。Podを消すと中のデータも一緒に消えることがあります。プライマリが死ぬと誰かを昇格させなければなりませんが、誤って昇格させるとデータが分裂します(split-brain)。スケールダウンは単にPodを減らすのではなく、データの再配置を伴います。バックアップなしの運用は想像できません。

このすべての手順こそ、人間のDBAが頭の中に持っていた運用知識です。この記事ではその知識を、Kubebuilderとcontroller-runtimeを使ってOperatorにコード化する過程を段階的に追います。実際のプロダクションデータベースエンジンをすべて実装するわけではありませんが、Operatorでステートフルシステムを運用する核心的パターンを手に馴染ませることが目標です。

参考までに、2026年現在Kubebuilderは Kubernetes 1.36 / Go 1.26 をサポートし、controller-runtimeはv0.24系、controller-toolsはv0.21系です。かつてサイドカーとして付けていたkube-rbac-proxyは削除され、代わりにcontroller-runtimeのメトリクスサーバーが提供するWithAuthenticationAndAuthorizationを使います。

設計 — CRスペックを先に決める

Operator開発はコードではなくAPI設計から始まります。ユーザーに何を宣言させるかを先に決めなければなりません。私たちのDatabase CRはこのような形です。

apiVersion: db.example.com/v1alpha1
kind: Database
metadata:
  name: orders-db
spec:
  replicas: 3
  version: "16.3"
  storage:
    size: 50Gi
    storageClassName: fast-ssd
  backup:
    enabled: true
    schedule: "0 3 * * *"
    retention: 14
    destination: s3://my-backups/orders-db
status:
  phase: Running
  primary: orders-db-0
  readyReplicas: 3
  lastBackupTime: "2026-06-15T03:00:12Z"

設計原則は小さなAPIです。ユーザーが知るべき最小限のつまみだけを公開します。replicas、version、storage、backupで十分です。内部実装の詳細(StatefulSetの名前、レプリケーションプロトコルなど)は絶対にspecに公開しません。それはOperatorの責務であり、ユーザーの関心事ではありません。

Go型では次のように定義します。Kubebuilderマーカー(kubebuilderコメント)がCRDのOpenAPIスキーマと検証を生成します。

// DatabaseSpec defines the desired state of Database.
type DatabaseSpec struct {
	// +kubebuilder:validation:Minimum=1
	// +kubebuilder:validation:Maximum=9
	// +kubebuilder:default=1
	Replicas int32 `json:"replicas"`

	// +kubebuilder:validation:Required
	Version string `json:"version"`

	Storage StorageSpec `json:"storage"`

	// +optional
	Backup *BackupSpec `json:"backup,omitempty"`
}

type StorageSpec struct {
	Size             string  `json:"size"`
	StorageClassName *string `json:"storageClassName,omitempty"`
}

type BackupSpec struct {
	Enabled     bool   `json:"enabled"`
	Schedule    string `json:"schedule"`
	Retention   int32  `json:"retention"`
	Destination string `json:"destination"`
}

// DatabaseStatus defines the observed state of Database.
type DatabaseStatus struct {
	Phase          string      `json:"phase,omitempty"`
	Primary        string      `json:"primary,omitempty"`
	ReadyReplicas  int32       `json:"readyReplicas,omitempty"`
	LastBackupTime *metav1.Time `json:"lastBackupTime,omitempty"`
	Conditions     []metav1.Condition `json:"conditions,omitempty"`
}

replicasの範囲を1から9に制限したのは意図的です。奇数推奨(クォーラム)と無理なスケールを防ぐための検証です。APIの段階で防げるミスはAPIで防ぐのがよいのです。

reconcile — 核心のループ

Operatorの心臓はReconcile関数です。この関数は「Database CRがこういう状態であってほしい」という宣言を受け、現実をその宣言に合わせます。最も重要な原則は冪等性です。同じ入力で何度呼んでも結果が同じでなければなりません。

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

	// 1. 対象CRを取得
	var db dbv1alpha1.Database
	if err := r.Get(ctx, req.NamespacedName, &db); err != nil {
		// すでに削除されていれば無視
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// 2. 削除処理(finalizer)
	if !db.DeletionTimestamp.IsZero() {
		return r.reconcileDelete(ctx, &db)
	}
	if !controllerutil.ContainsFinalizer(&db, dbFinalizer) {
		controllerutil.AddFinalizer(&db, dbFinalizer)
		if err := r.Update(ctx, &db); err != nil {
			return ctrl.Result{}, err
		}
	}

	// 3. desiredなリソースを順に保証
	if err := r.reconcileHeadlessService(ctx, &db); err != nil {
		return ctrl.Result{}, err
	}
	if err := r.reconcileStatefulSet(ctx, &db); err != nil {
		return ctrl.Result{}, err
	}
	if err := r.reconcileBackupCronJob(ctx, &db); err != nil {
		return ctrl.Result{}, err
	}

	// 4. 状態更新
	if err := r.updateStatus(ctx, &db); err != nil {
		return ctrl.Result{}, err
	}

	return ctrl.Result{}, nil
}

各reconcileXxx関数は「望ましいオブジェクトを記述し、なければ作り、異なれば合わせる」という同一のパターンに従います。controller-runtimeのCreateOrUpdateヘルパーがこのパターンをきれいに表現します。

func (r *DatabaseReconciler) reconcileStatefulSet(ctx context.Context, db *dbv1alpha1.Database) error {
	sts := &appsv1.StatefulSet{
		ObjectMeta: metav1.ObjectMeta{
			Name:      db.Name,
			Namespace: db.Namespace,
		},
	}

	_, err := controllerutil.CreateOrUpdate(ctx, r.Client, sts, func() error {
		// 望ましいスペックを埋める(冪等)
		sts.Spec.Replicas = &db.Spec.Replicas
		sts.Spec.ServiceName = db.Name + "-headless"
		sts.Spec.Selector = &metav1.LabelSelector{
			MatchLabels: labelsFor(db),
		}
		sts.Spec.Template = podTemplateFor(db)
		sts.Spec.VolumeClaimTemplates = pvcTemplatesFor(db)

		// owner reference: CR削除時にStatefulSetもGC
		return controllerutil.SetControllerReference(db, sts, r.Scheme)
	})
	return err
}

ここでowner referenceが核心です。StatefulSetの所有者をDatabase CRに指定すると、CRが削除されるときKubernetesのガベージコレクタがStatefulSetを自動的に整理します。さらにStatefulSetの変更イベントが再びreconcileをトリガーするようwatchを掛けておきます。

StatefulSet・Service・PVC — なぜこの三つか

ステートフルなデータベースをKubernetesで運用するとき、この三つのリソースが基本骨格です。

+------------------------------------------+
|              Database CR                 |
+-------------------+----------------------+
                    | reconcile
        +-----------+-----------+-----------+
        v           v           v
  StatefulSet   Headless     CronJob
   (Pod N個)    Service      (バックアップ)
        |           |
        v           v
  安定した識別子   DNSレコード
  db-0, db-1     db-0.db-headless
  db-2 ...       (Pod別の固定アドレス)
        |
        v
  VolumeClaimTemplate
   -> Podごとに専用PVC
   -> Pod再起動でもデータ維持

StatefulSetを使う理由は三つです。第一に安定したネットワーク識別子です。Pod名がdb-0、db-1のように順に固定され、ヘッドレスServiceと結合してdb-0.db-headlessのような固定DNSを持ちます。レプリカがプライマリを探すときこの安定したアドレスが必須です。

第二に順次的な作成・削除です。db-0がReadyになった後db-1が立ちます。データベースクラスタのブートストラップでは通常db-0をプライマリとして初期化し、残りがそこに付く順序が自然です。

第三に安定したストレージです。VolumeClaimTemplateで各Podに専用PVCが付き、Podが再スケジュールされても同じPVCが付いてきます。データが保存されます。

ヘッドレスServiceはclusterIPがNoneのServiceで、ロードバランシングの代わりにPod別のDNSレコードを提供します。

apiVersion: v1
kind: Service
metadata:
  name: orders-db-headless
spec:
  clusterIP: None
  selector:
    app: orders-db
  ports:
    - port: 5432
      name: postgres

書き込み用Serviceは別に置き、プライマリだけを指すようセレクタにroleラベルを追加します。Operatorがフェイルオーバー時にこのラベルを移してトラフィックを新しいプライマリに送ります。

定期バックアップ — CronJobの作成

バックアップはOperatorが作るCronJobに委譲できます。spec.backupがオンならバックアップCronJobを、オフなら削除する形でreconcileします。

func (r *DatabaseReconciler) reconcileBackupCronJob(ctx context.Context, db *dbv1alpha1.Database) error {
	name := db.Name + "-backup"
	cj := &batchv1.CronJob{
		ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: db.Namespace},
	}

	// バックアップがオフならCronJobを整理
	if db.Spec.Backup == nil || !db.Spec.Backup.Enabled {
		err := r.Delete(ctx, cj)
		return client.IgnoreNotFound(err)
	}

	_, err := controllerutil.CreateOrUpdate(ctx, r.Client, cj, func() error {
		cj.Spec.Schedule = db.Spec.Backup.Schedule
		cj.Spec.JobTemplate.Spec.Template.Spec = backupPodSpec(db)
		return controllerutil.SetControllerReference(db, cj, r.Scheme)
	})
	return err
}

バックアップPodはpg_dumpや物理バックアップツールを実行し、結果をspec.backup.destination(例:S3)へアップロードします。保持ポリシー(retention)はバックアップスクリプトが古いバックアップを整理するよう実装します。

代替として、CronJobに依存せずOperator内に独自スケジューラを置く方式もあります。requeueタイミングを計算して次のバックアップ時刻にreconcileを再呼び出しするのです。ただしCronJobを使うほうが単純で、Kubernetesがスケジューリングとリトライを代わりに行ってくれるので推奨します。

フェイルオーバーとリーダー選出 — 概念

プライマリが死んだときの処理がデータベースOperatorの最も難しい部分です。核心的リスクはsplit-brainです。旧プライマリが一時的なネットワーク分断で「死んだように見えてから」復活すると、二つのプライマリが同時に書き込みを受けてデータが分裂します。

安全なフェイルオーバーの原則は次の通りです。

フェイルオーバー手順(安全弁付き)
 1. プライマリのヘルス監視(例:5秒ごとのlivenessプローブ)
 2. N回連続失敗 -> 「疑い」状態に転換(即時昇格禁止)
 3. フェンシング(fencing):旧プライマリの書き込みを遮断
    - 書き込みServiceセレクタから旧プライマリを除去
    - またはネットワーク/ストレージレベルで隔離
 4. 最も先行したレプリカを選択(レプリケーション遅延が最小)
 5. そのレプリカをプライマリに昇格
 6. 書き込みServiceラベルを新プライマリへ移動
 7. 残りのレプリカを新プライマリに再接続
 8. status.primaryを更新、イベントを記録

リーダー選出自体は自作するより検証済みの合意メカニズムに委譲するほうが安全です。実際のプロダクションOperatorは次のいずれかを使います。

  • Raft/合意ライブラリ内蔵:etcdやConsulのように合意アルゴリズムが内蔵されたシステム。
  • Patroniのような専用HAエージェント:Zalando Postgres Operatorがこの方式。Patroniがetcd/Kubernetesを分散ロックストアとして使いリーダーを選出します。
  • KubernetesのLeaseオブジェクト:Kubernetes自体のリーダー選出メカニズム(coordination.k8s.io/Lease)を活用。

自分でsplit-brain防止を実装するのは分散システムの博士論文級の難易度であると認めなければなりません。だから「データベースのフェイルオーバーを新たに実装するな」というのが現場の定説です。私たちのOperatorも合意はPatroniやエンジン内蔵メカニズムに任せ、Operatorはその状態を観察してKubernetesリソース(Serviceラベルなど)を調整する役割に集中するのが現実的です。

バージョンアップグレード — 順次ローリング

バージョンアップグレードは一度にすべてのPodを変えてはいけません。可用性を維持しながら一台ずつ交換しなければなりません。StatefulSetのRollingUpdate戦略が基本の枠組みを提供しますが、データベースは追加の制約があります。

アップグレード順序(Postgres系基準)
 1. すべてのレプリカが健康か確認(不健康なら中断)
 2. バックアップを一度強制実行(ロールバック対策)
 3. レプリカから先にアップグレード(db-2 -> db-1 ...)
    - 一台アップグレード -> Ready確認 -> レプリケーション追いつき確認 -> 次
 4. 最後にプライマリ:
    - レプリカ一つを新バージョンに昇格(計画されたフェイルオーバー)
    - 旧プライマリを新バージョンで再起動後レプリカとして合流
 5. status.versionを更新

核心は各ステップ間のヘルスゲートです。一つのPodを上げた後やみくもに次へ進むのではなく、レプリケーションが追いついたかを確認して進めます。Operatorのreconcileはこの進行状態をstatusに記録し、一ステップが終わるたびにrequeueで次のステップを進めます。

// アップグレードを段階的に進める擬似コード
func (r *DatabaseReconciler) reconcileUpgrade(ctx context.Context, db *dbv1alpha1.Database) (ctrl.Result, error) {
	target := db.Spec.Version
	// 最も高いインデックスの旧バージョンレプリカを探す
	pod, found := r.findOutdatedReplica(ctx, db, target)
	if !found {
		return ctrl.Result{}, nil // すべて最新
	}
	if !r.isHealthyAndCaughtUp(ctx, db) {
		// まだ追いつき中 -> しばらく後に再確認
		return ctrl.Result{RequeueAfter: 15 * time.Second}, nil
	}
	if err := r.upgradePod(ctx, pod, target); err != nil {
		return ctrl.Result{}, err
	}
	// 一台処理したので再びreconcile
	return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}

マイナーバージョンのスキップを禁止する検証を置くのがよいです。例えばPostgresでメジャーバージョンアップグレードはpg_upgradeのような別の手順が必要なので、Operatorが「16から18への直接ジャンプ」を拒否して17経由を強制するか、明示的な手順を要求すべきです。

status — ヘルスを外へ公開する

reconcileが終わるたびにstatusを更新し、クラスタのユーザーや他のツールがデータベースの状態を知れるようにします。標準パターンはmetav1.Conditionの配列です。

func (r *DatabaseReconciler) updateStatus(ctx context.Context, db *dbv1alpha1.Database) error {
	var sts appsv1.StatefulSet
	if err := r.Get(ctx, types.NamespacedName{Name: db.Name, Namespace: db.Namespace}, &sts); err != nil {
		return client.IgnoreNotFound(err)
	}

	db.Status.ReadyReplicas = sts.Status.ReadyReplicas
	if sts.Status.ReadyReplicas == db.Spec.Replicas {
		db.Status.Phase = "Running"
		meta.SetStatusCondition(&db.Status.Conditions, metav1.Condition{
			Type:    "Ready",
			Status:  metav1.ConditionTrue,
			Reason:  "AllReplicasReady",
			Message: "all database replicas are ready",
		})
	} else {
		db.Status.Phase = "Progressing"
		meta.SetStatusCondition(&db.Status.Conditions, metav1.Condition{
			Type:    "Ready",
			Status:  metav1.ConditionFalse,
			Reason:  "ReplicasNotReady",
			Message: "waiting for replicas to become ready",
		})
	}
	return r.Status().Update(ctx, db)
}

重要な原則:statusはOperatorだけが書く。 ユーザーはspecを書きstatusを読みます。statusをspecのように入力チャネルとして使うのはよくあるアンチパターンです。またstatusサブリソースを有効化し(Kubebuilderマーカーで)specとstatusの更新が衝突しないようにします。

kubectlで見やすいよう出力カラムもマーカーで定義できます。

// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="Replicas",type=integer,JSONPath=`.status.readyReplicas`
// +kubebuilder:printcolumn:name="Primary",type=string,JSONPath=`.status.primary`
// +kubebuilder:subresource:status

観測 — メトリクスとイベント

運用可能なOperatorは自分の状態を観測可能にします。controller-runtimeはreconcile回数、キュー深さ、処理時間といったメトリクスを標準提供します。ここにドメインメトリクス(例:フェイルオーバー回数、バックアップ成功/失敗)を追加します。

var (
	failoverTotal = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "database_failover_total",
			Help: "Number of failovers performed per database",
		},
		[]string{"database", "namespace"},
	)
)

func init() {
	metrics.Registry.MustRegister(failoverTotal)
}

また重要な出来事はKubernetesイベントとして残します。ユーザーがkubectl describeで何があったかを追跡できます。

r.Recorder.Event(db, corev1.EventTypeNormal, "FailoverCompleted",
	fmt.Sprintf("promoted %s to primary", newPrimary))

運用シナリオ — 実際にどう回るか

作られたOperatorが現場でどんなことを経験するかをシナリオで整理します。

シナリオ1. ノード障害でプライマリPodが消滅
  -> スケジューラが別ノードにPodを再作成、PVCが付いてくる
  -> その間HAエージェントがレプリカを昇格、Operatorが書き込みServiceラベルを移動
  -> 旧Podが新ノードで立つとレプリカとして合流

シナリオ2. ディスクが満杯
  -> statusに警告conditionを公開、イベント発生
  -> ユーザーがspec.storage.sizeを増加 -> OperatorがPVC拡張(許可時)

シナリオ3. 負荷増加で読み取りレプリカ追加が必要
  -> spec.replicas 3 -> 5 -> StatefulSetスケール -> 新レプリカがベースバックアップで同期

シナリオ4. 定期バックアップ失敗(S3権限の期限切れ)
  -> バックアップJob失敗、status.lastBackupTime未更新
  -> アラートルールが「X時間以上バックアップなし」を検知して呼び出し

これらのシナリオが人間の深夜対応なしで回ることがOperatorの価値です。ただしシナリオ4のように自動化が失敗したときに人を呼ぶ経路(アラート)が必ず一緒になければなりません。静かに失敗する自動化が最も危険です。

既存Operator vs 自作 — トレードオフ

ここまで読むと一つが明らかになります。データベースOperatorをまともに作るのは非常に難しい。 ならばわざわざ作る必要があるでしょうか?

観点自作既存Operator(CloudNativePGなど)
初期コスト非常に高い低い(helm install)
split-brain安全性自分で検証が必要(危険)数年のプロダクション検証済み
バックアップ/PITR自分で実装内蔵、検証済み
カスタマイズ無限提供範囲内
保守永遠に自分たちの責任コミュニティが分担
学習価値非常に高い低い

結論は明確です。プロダクションデータベースなら既存Operatorを使ってください。 CloudNativePG、Zalando Postgres Operator、Strimziはsplit-brain、バックアップ整合性、バージョンアップグレードといった落とし穴をすでに数年かけて磨いてきました。これを再発明するのはほぼ常に損です。

自作する価値があるのは二つの場合です。第一に学習目的 — Operatorパターンを体得するのにデータベースほどの教材はありません。第二に既存ソリューションのない社内固有のステートフルシステム — 特殊な内部データストアやレガシーエンジンをKubernetesに移すときです。

落とし穴 — 自作するなら必ず出会うもの

[ ] PVCをむやみに削除 — owner referenceにPVCを縛るとCR削除時にデータが蒸発。
     データ保存が必要ならPVCはGC対象から除外し別途ポリシーで。
[ ] reconcileでブロッキング — 長い作業(バックアップなど)をreconcile内で同期的に
     待つとワーカーキューが詰まる。別のJob/CronJobに委譲すること。
[ ] 冪等性違反 — reconcile呼び出しごとに新しいバックアップJobを作ると無限増殖。
     常に「すでにあるか」を先に確認。
[ ] statusを入力として使用 — split-brainの判断をstatusだけに依存すると危険。
[ ] 無限requeue — エラー時に即時再試行すると暴走。指数バックオフを活用。
[ ] 過度なRBAC — データベースOperatorだからとcluster-adminを要求しないこと。
[ ] バージョンスキップ放置 — メジャーバージョンジャンプを防がないとデータ破損。

特に一つ目、PVCとデータ保存は最も致命的です。「Operatorを消したらプロダクションデータが一緒に飛んだ」という事故は実際に起こります。CR削除とデータ削除を分離するポリシー(例:deletionPolicy: Retain)を必ず置いてください。

おわりに

データベースOperatorを作る旅は、reconcileループの真の威力と同時に限界を示します。StatefulSet・Service・PVCを調整し、バックアップをCronJobに委譲し、statusでヘルスを公開するパターンは、どんなステートフルOperatorにもそのまま適用されます。これがこの記事から持ち帰る核心の筋肉です。

しかしsplit-brainとデータ整合性という分散システムの深い沼の前では謙虚でなければなりません。プロダクションデータベースは検証済みの既存Operatorに任せ、自作は学習や本当に代替のない社内システムに限定するのが賢明です。次の記事ではこうした判断を含め、良いOperatorと悪いOperatorを分けるベストプラクティスとアンチパターンを整理します。

参考資料