KubernetesのCRDを設計する際、CRDの一部をユニオン型にしたいことがあります。
例えば、次のようなRecord型を定義を、CRDでやりたいときはどうしたらいいでしょうか。
type Record = A | Txt | Srv;
type A = { type: "A", ipAddresses: string[] };
type Txt = { type: "TXT", values: string[] };
type Srv = { type: "SRV", port: number, priority: number; target: string, weight: number};
この型宣言はTypeScriptですが、Kubernetes API上のJSONスキーマやGoの型システムはTypeScriptの型システムほど柔軟ではないので、一定の制約のもとでユニオン型を実現するためのひとつのパターンが「tagless union」パターンだと考えています。
このパターンは、JSONスキーマとしてもGoの型としても、そこそこ実用的なレベルでユニオン型っぽいことを実現できます。
Goの型定義で「tagless union」を表現する
tagless unionは、typeのような識別子フィールド(descriminator)を持たず、特定のフィールドが存在すること自体で型を決めるパターンです。この考え方は、Goの型定義と非常に相性が良いです。例えば、上のRecord型をtagless unionで表現するとこうなります:
type RecordSpec struct {
A *A
Txt *Txt
Srv *Srv
}
ポイントはいくつかあります。
- メンバー型ごとにフィールドを定義する。
- そのフィールドはポインターにする。
まず、GoにはA | Txt | Srvのような表現がないので、メンバー型ごとにフィールドを定義します。TypeScriptだと
type RecordSpec = {
a?: A
txt?: Txt
srv?: Srv
}
と同等の表現になります。TypeScript視点では、txtとsrvのどちらも持つオブジェクト(構造体)が有り得る型になっているので、悪い設計に見えますが、Goにはユニオン型がないのでいたしかたないです。
次に、フィールドはポインターにします。これはCRDにしたときにoptionalにしたいからです。optionalにするのは、a、txt、srvのいずれかだけマニフェストに書けば良くなるようにしたいからです。
type RecordSpec struct {
//+optional
A *A
//+optional
Txt *Txt
//+optional
Srv *Srv
}
この定義からCRDを生成しKubernetesに登録しておけば、以下のようなマニフェストを宣言できるようになります。
apiVersion: example.test/v1alpha1
kind: Record
metadata:
name: record-sample
spec:
a:
ipAddresses: [203.0.113.10]
apiVersion: example.test/v1alpha1
kind: Record
metadata:
name: record-sample-txt
spec:
txt:
value: [hello]
apiVersion: example.test/v1alpha1
kind: Record
metadata:
name: record-sample-srv
spec:
srv:
port: 443
priority: 10
target: "svc.example.com."
weight: 5
見てのとおり、構造が異なるspecをそれぞれ定義できるようになるわけです。
「いずれか一つ」の制約をどう保証するか
このパターンの堅牢性を確保するには、「いずれか一つだけが存在する」という制約をAPIサーバーに強制させる必要があります。
OpenAPI v3スキーマにはoneOfというキーワードがありますが、残念ながらcontroller-runtimeのコード生成ツール(controller-gen)は、現時点でoneOfをサポートしていません。
そこで、この制約を保証するためにAPIサーバーのCELバリデーションルールを活用します。RecordSpecのコメントに記述された+kubebuilder:validation:XValidationマーカーが、CRDにバリデーションルールを埋め込む役割を果たします。このルールによって、複数のフィールドが同時に設定された不正なリソースは、APIサーバーによって即座に拒否されるようになります。
// +kubebuilder:validation:XValidation:rule="[has(self.a), has(self.txt), has(self.srv)].exists_one(x, x)",message="Exactly one of spec.a, spec.txt, or spec.srv must be set"
type RecordSpec struct {
// ...
}
このCELルールは、「a, txt, srvフィールドいずれかひとつだけであること」という意味です。
[has(self.a), has(self.txt), has(self.srv)].exists_one(x, x)
Goの型安全性が最大のメリット
タグレスユニオンパターンの最大の恩恵は、Goで実装するコントローラーが型安全になる点です。APIサーバーが「いずれか一つ」の制約を保証してくれるため、Reconcileロジックに渡されるオブジェクトは、常に有効な状態であることが約束されています。
これにより、開発者は冗長な防御的プログラミングから解放され、Goの静的型システムのメリットを最大限に享受できます。
func (r *RecordReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// ... (Recordオブジェクトを取得)
// タグレスユニオンパターンとAPIサーバーのバリデーションのおかげで、
// このswitch文は極めて安全かつシンプルになる。
switch {
case record.Spec.A != nil:
// このブロック内では、record.Spec.A は有効なポインタであり、
// 他のフィールドは nil であることが保証されている。
log.Info("Reconciling A variant", "ipAddresses", record.Spec.A.IPAddresses)
case record.Spec.Txt != nil:
// 同様に、このブロックでは record.Spec.Txt のみが有効。
log.Info("Reconciling TXT variant", "value", record.Spec.Txt.Value)
case record.Spec.Srv != nil:
log.Info("Reconciling SRV variant", "target", record.Spec.Srv.Target)
default:
// このパスは理論上到達不能。
return ctrl.Result{}, fmt.Errorf("unreachable: record spec has no variant")
}
return ctrl.Result{}, nil
}
このコードでは、APIスキーマがロジックの正しさを担保しているため、switch文による型分岐が完全に安全に行えます。
まとめ
controller-runtimeを用いた開発において、タグレスユニオンは「いずれか一つの型」を実装するためのパターンです。JSONスキーマとGoの型定義どちらの観点からも、バランスのいい型安全性を持たせられるのが魅力です。
より詳しい実装はsuinplayground/controller-runtime/03-tagless-unionで確認できます。