hacomono TECH BLOG

フィットネスクラブやスクールなどの顧客管理・予約・決済を行う、業界特化型SaaS「hacomono」を提供する会社のテックブログです!

コードでみる Crossplane と kro

この記事は hacomono Advent Calendar 2025 の11日目の記事です

こんにちは! 基盤本部プラットフォームチーム所属の kaikai です。
hacomono に入社して 1 年半ほど経ちましたが、ついにジムに通い始めました!
誰にも言ってないですがチーム内のマッスル枠を密かに狙っています。

さて、今回は Crossplanekro についてコードを交えながら解説していきたいと思います。
これらはどちらも Kubernetes 上で動作する OSS で、Kubernetes の抽象を通じて独自リソースを管理できるようにするものです。
Crossplane に関しては以前私が登壇した YAPC の資料も参考にしていただけますと幸いです。

TL;DR

  • Crossplane と kro は Kubernetes の抽象を通じて独自リソースを管理可能にする OSS
  • Crossplane には AWS や GCP などの Kubernetes 標準外リソースを管理する Provider が存在し、外部リソースの管理まで Crossplane で完結可能
  • kro は外部リソースの管理には関与せず、必要に応じて外部リソースを管理する仕組みを別途導入する必要がある
  • Crossplane は Function という gRPC サーバーとのやりとりで独自リソースのリソース構成を解決する
  • kro は CEL でリソース構成を定義し、独自リソースのリソース構成を解決する
  • Crossplane は責務が大きく、柔軟性も高いがその分複雑性も高い
  • kro は責務が小さくシンプルでGoのコード行数も Crossplane の 1/3 以下


そもそも Crossplane とは?kro とは?

Crossplane とは簡単にいうと、Kubernetes の抽象からあらゆるリソースを管理可能にする OSS の Kubernetes エコシステムです。
最近 CNCF の Graduated stage に昇格しました。
Crossplane は Kubernetes リソースに加え、AWS や GCP,Azure などのクラウドリソースを Kubernetes の抽象を通じて管理することができるようになります。

また、独自リソースを定義し、管理することが可能で、組織のポリシーに則ったクラウドリソースや、Ingress+ Service + Deployment + Monitoring Solution などの Kubernetes 複合リソースなどを独自リソースとして管理することができるようになります。

Crossplane によって定義された独自リソースを XR(Composite Resource) と呼びます。
プラットフォームエンジニアリングの一環として XR を開発者に提供することで、開発者は数行のmanifestで必要なリソースを作成できるようになります。

kro は Kube Resource Operator の略で kubernetes-sigs で管理されている OSS の Kubernetes エコシステムです。
最近までは開発途中との明記があったのですが、最新のドキュメントでは後方互換性に関する注意書きがあるのみで、ある程度安定してきたのかなと思います。
kro も Crossplane の XR のように独自リソースを定義可能で、独自の抽象から様々な Kubernetes リソースを管理可能にします。

独自リソースを定義して利用可能になるまで

ここからは、SuperApp という架空の独自リソースを定義し、利用可能になるまでの手順や内部動作を Crossplane と kro で比較しながら説明します。
SuperApp は Kubernetes の Service + Deployment + PodIdentity を組み合わせたリソースと仮定し、以下のように Kubernetes のマニフェストで SuperApp を作成すると、Service,Deployment,PodIdentity が自動的に作成されるようなイメージです。

apiVersion: platform.example.io/v1alpha1
kind: SuperApp
metadata:
  name: my-superapp
  namespace: default
spec:
  image: my-superapp-image:latest
  replicas: 3
  policyDocuments: |
  {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Effect": "Allow",
        "Action": [
          "s3:ListBucket"
        ],
        "Resource": [
          "arn:aws:s3:::example-bucket"
        ]
      }
    ]
  }
  ...

Crossplane でも kro でも独自リソースの抽象は同じものを提供できます。
NOTE:
これから説明する内容で、yamlのコードはCrossplane,kroの利用者が定義するKubernetes manifestで、GoのコードはCrossplane,kro自体のコードとなっております。

Crossplane の場合

Crossplane で独自リソースを定義する場合、まず XRD を作成します。
これは Kubernetes の CRD と同じようなもので、XR のスキーマ定義などを以下のように行います。

apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
  name: superapps.platform.example.io
spec:
  scope: Namespaced
  group: platform.example.io
  names:
    kind: SuperApp
    plural: superapps
  versions:
    - name: v1alpha1
      schema:
      # OpenAPI形式によるスキーマ定義
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                image:
                  type: string
                  description: "Container image with tag (e.g., 'my-app:1.0.0')"
                ...

XRDを Kubernetes に適用すると、Crossplane の XRD 用のコントローラーが XRD を検知し、CRD に変換して適用します。
また、XR 用のコントローラーも自動生成され、稼働が開始します。

// internal/controller/apiextensions/definition/reconciler.go
func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
    ...
    d := &v1.CompositeResourceDefinition{}
    if err := r.client.Get(ctx, req.NamespacedName, d); err != nil {
        ...
    }
    // CompositeResourceDefinition からCRDをレンダリング
    crd, err := r.composite.Render(d)
    ...
  // CRDを適用
    if err := r.client.Applicator.Apply(ctx, crd,...
  ...
    gvk := d.GetCompositeGroupVersionKind()
  ...
    ro := []composite.ReconcilerOption{
        composite.WithCompositeSchema(schema),
        ...
    }
  ...
  // XR用のControllerを生成
    cr := composite.NewReconciler(r.engine.GetCached(), gvk, ro...)
  ...
  // XR用のControllerを新たに稼働
    if err := r.engine.Start(controllerName, co...); err != nil {
    ...


XRD だけではスキーマしか定義されておらず、「どのように作成するのか」の情報が不明なため、「どのように作成するのか」を Composition で定義します。CompositionはCrossplane のカスタムリソースです。
Composition には「どの Function をどの input で呼び出すか」などの情報を記述します。
Function は Crossplane のカスタムリソースで、Crossplane のコントローラーとやり取りをする gRPC サーバーです。
Function には様々な種類があり、以下は function-go-templating という Go のテンプレートエンジンを利用したケースを記述しています。

apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: superapp.platform.example.io
spec:
  compositeTypeRef:
    apiVersion: platform.example.io/v1alpha1
    kind: SuperApp
  mode: Pipeline
  pipeline:
    - step: create-resources
      functionRef:
        name: function-go-templating
      input:
        apiVersion: gotemplating.fn.crossplane.io/v1beta1
        kind: GoTemplate
        source: Inline
        inline:
          template: |
            {{ $appName := $composite.metadata.name }}
            {{ $namespace := $composite.metadata.namespace }}
            ...
            ---
            # PodIdentityのリソースを作成
            # このリソースが展開されると、provider-aws-eksがPodIdentityを管理するようになる
            apiVersion: eks.aws.m.upbound.io/v1beta1
            kind: PodIdentityAssociation
            metadata:
              name: {{ $clusterName }}-{{ $namespace }}
              namespace: {{ $namespace }}
              ...
            ---
            # Deploymentのリソースを作成
            apiVersion: apps/v1
            kind: Deployment
            metadata:
              name: {{ $appName }}
              namespace: {{ $namespace }}
              ...
            ---
            # Serviceのリソースを作成
            apiVersion: v1
            kind: Service
            metadata:
              name: {{ $appName }}
              namespace: {{ $namespace }}
              ...


Compositionfunction-go-templating を利用しているため、function-go-templating 用の Function リソースも作成し、Kubernetes に適用します。

apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
  name: function-go-templating
spec:
  package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.11.0


Kubernetes 標準リソースに関しては Kubernetes 標準のコントローラーが管理してくれますが、PodIdentity のような Kubernetes 外のリソースに関しては管理してくれる別の何かが必要です。
Crossplane では Provider というカスタムリソースがあり、これを利用して外部リソースの管理を行います。
AWS の場合はリソース毎に Provider が存在するため、今回はPodIdentityを管理してくれる provider-aws-eks を Kubernetes に適用します。

apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-eks
spec:
  package: xpkg.crossplane.io/crossplane-contrib/provider-aws-eks:v2.0.0


これで XR を作成する準備が整いました。
XR をデプロイすると、以下のように内部のコントローラーが稼働して Compose メソッドを呼び出します。

// internal/controller/apiextensions/composite/reconciler.go
// XR用のController
func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
    xr := composite.New(composite.WithGroupVersionKind(r.gvk), composite.WithSchema(r.schema))
    if err := r.client.Get(ctx, req.NamespacedName, xr); err != nil {
        ...
    }
  // Functionを呼び出すComposeメソッド
    res, err := r.resource.Compose(ctx, xr, CompositionRequest{Revision: rev})
  ...
  // 構成リソースを利用してEvent発行やXRの状態更新などを実施
    for i, cd := range res.Composed {
        ...
  }


上記コードのr.resourceの実装であるFunctionComposerCompose メソッドでは以下のように Function にリクエストを送信し、XR の構成リソースを解決、その後解決したリソース群を順次 Apply しています。
今回であれば、 Service,Deployment,PodIdentity の情報が Function から返却され、それらを Apply することになるはずです。

// internal/controller/apiextensions/composite/composition_functions.go
func (c *FunctionComposer) Compose(ctx context.Context, xr *composite.Unstructured, req CompositionRequest) (CompositionResult, error) {
  // 現在の状態を取得
    observed, err := c.composite.ObserveComposedResources(ctx, xr)
    ...
  // dはあるべき状態を表す
    d := &fnv1.State{}
  // Compositionに定義されているPipelineを順次処理してあるべき状態dを更新し続ける
    for _, fn := range req.Revision.Spec.Pipeline {
    // Functionへ渡すRequestを構築
        req := &fnv1.RunFunctionRequest{Observed: o, Desired: d, Context: fctx}
        ...
    // Functionを呼び出し
        rsp, err := c.pipeline.RunFunction(ctx, fn.FunctionRef.Name, req)
        ...
    }
  // 構成リソースのあるべき状態を構築
    desired := ComposedResourceStates{}
  ...
  // Functionから取得できたあるべき状態をもとにdesiredを構築
    for name, dr := range d.GetResources() {
        cd := composed.New()
        if err := xfn.FromStruct(cd, dr.GetResource()); err != nil {
            ...
        }
        ...
        desired[ResourceName(name)] = ComposedResourceState{
            Resource:          cd,
            ...
        }
    }
    ...
    // 構成リソースを順次Apply
    for name, cd := range desired {
        if err := c.client.Patch(ctx, cd.Resource, client.Apply, client.ForceOwnership, client.FieldOwner(ComposedFieldOwnerName(xr))); err != nil {
            ...
        }
    }
    ...
  // XR自体のStatusを更新
    if err := c.client.Status().Patch(ctx, xr, client.Apply, client.ForceOwnership, client.FieldOwner(FieldOwnerXR)); err != nil {
    ...
    }
    ...
}

Apply されたリソースは各々のコントローラーが管理を行ってくれます。
Service,Deployment であれば Kubernetes 標準のコントローラー、PodIdentity であれば provider-aws-eks と言った感じです。
他にも細かい処理が実際は行われていますが、大まかな流れはこのようになっております。

kro の場合

kro で独自リソースを定義する場合、まず RGD を作成します。
RGD では管理したいリソースのスキーマと、「どのようにリソースを生成するのか」、を以下のように定義します。
「どのようにリソースを生成するのか」、に関しては CEL で定義します。

apiVersion: kro.run/v1alpha1
kind: ResourceGraphDefinition
metadata:
  name: superapp
spec:
  schema:
    apiVersion: apps.kro.run/v1alpha1
    kind: SuperApp
    spec:
      type: object
      properties:
        appName:
          type: string
          description: Name of the application
        image:
          type: string
          description: Container image for the deployment
        ...
  resources:
    # Deployment resource
    - id: deployment
      definition:
        apiVersion: apps/v1
        kind: Deployment
        metadata:
          name: ${schema.spec.appName}-deployment
          namespace: ${schema.metadata.namespace}
        ...
    # Service resource
    - id: service
      definition:
        apiVersion: v1
        kind: Service
        metadata:
          name: ${schema.spec.appName}-service
          namespace: ${schema.metadata.namespace}
        ...


RGD を適用すると kro のコントローラーが RGD を検知し、以下コードのように CRD に変換して適用します。
また、独自リソース用のコントローラも自動で生成され、稼働を開始します。
ここまでの動きは Crossplane の XRD を作成したときと似ているかと思います。

// pkg/controller/resourcegraphdefinition/controller.go
func (r *ResourceGraphDefinitionReconciler) Reconcile(
    ctx context.Context,
    o *v1alpha1.ResourceGraphDefinition,
) (ctrl.Result, error) {
    ...
  // RGDの内容をもとにリソースグラフを構築し、CRDの取得および適用、RGD用コントローラーの生成および稼働を実施
    topologicalOrder, resourcesInformation, reconcileErr := r.reconcileResourceGraphDefinition(ctx, o)
    ...
}
// pkg/controller/resourcegraphdefinition/controller_reconcile.go
func (r *ResourceGraphDefinitionReconciler) reconcileResourceGraphDefinition(
    ctx context.Context,
    rgd *v1alpha1.ResourceGraphDefinition,
) ([]string, []v1alpha1.ResourceInformation, error) {
    // RGDからGraphの構築
    processedRGD, resourcesInfo, err := r.reconcileResourceGraphDefinitionGraph(ctx, rgd)
  // GraphからCRDの取得
    crd := processedRGD.Instance.GetCRD()
    ...
  // CRDの適用
    if err := r.reconcileResourceGraphDefinitionCRD(ctx, crd); err != nil {
    ...
  // 独自リソース用のControllerを生成および稼働
    if err := r.reconcileResourceGraphDefinitionMicroController(ctx, processedRGD, graphExecLabeler); err != nil {
    ...
}
// pkg/controller/resourcegraphdefinition/controller_reconcile.go
func (r *ResourceGraphDefinitionReconciler) reconcileResourceGraphDefinitionMicroController(
    ctx context.Context,
    processedRGD *graph.Graph,
    graphExecLabeler metadata.Labeler,
) error {
    // RGDで定義されたリソース群から監視対象のGVRを取得
    resourceGVRsToWatch := r.getResourceGVRsToWatchForRGD(processedRGD)
  // RGD用のControllerを生成
    controller := r.setupMicroController(processedRGD, graphExecLabeler)
    ...
    // RGD用のControllerを新たに稼働
    err := r.dynamicController.Register(ctx, gvr, controller.Reconcile, resourceGVRsToWatch...)
    ...
}


kro の場合あとは独自リソースを作成するだけです。Crossplane と比べて kro はめちゃめちゃシンプルです。
それでは RGD で定義した SuperApp リソースを作成してみましょう。
すると、先ほど内部で生成されたコントローラーが RGD を参照し、依存関係などからグラフ構造を構築、その内容に沿ってリソースを Apply します。
結果Service,Deployment,PodIdentity などのリソースが Apply されることになるはずです。

// pkg/controller/instance/controller.go
func (c *Controller) Reconcile(ctx context.Context, req ctrl.Request) error {
   // 独自リソースを取得
    instance, err := c.clientSet.Dynamic().Resource(c.gvr).Namespace(req.Namespace).Get(ctx, req.Name, metav1.GetOptions{})
    ...
  // Reconcile処理を実施する構造体の生成
    instanceGraphReconciler := &instanceGraphReconciler{
        ...
    }
    ...
    return instanceGraphReconciler.reconcile(ctx)
}
// pkg/controller/instance/controller_reconcile.go
func (igr *instanceGraphReconciler) reconcile(ctx context.Context) error {
    ...
  // igr.reconcileInstance を呼び出してリソースの Apply を実施
    return igr.handleReconciliation(ctx, igr.reconcileInstance)
}
// pkg/controller/instance/controller_reconcile.go
func (igr *instanceGraphReconciler) reconcileInstance(ctx context.Context) error {
    ...
  // graph の順序に従ってリソースの状態や依存関係の解決を行い、構成リソースを整備
    for _, resourceID := range igr.runtime.TopologicalOrder() {
        // graphからリソースを取得
        resource, state := igr.runtime.GetResource(resourceID)
        ...
    // 適用するリソース構成の登録
        applyable := applyset.ApplyableObject{
            Unstructured: resource,
            ID:           resourceID,
        }
        clusterObj, err := aset.Add(ctx, applyable)
        ...
    }
  // 構成リソースのApply
    result, err := aset.Apply(ctx, prune)
    ...
  // エラー処理やステータス更新など
}

ただし、PodIdentity というリソースを Apply されても、Kubernetes 標準のコントローラーは管理してくれません。
つまり、このままでは PodIdentity などの外部リソースを管理するコントローラーが存在しないため、うまくいきません。
どうすれば良いかというと、Crossplane の Provider のような外部リソースを管理するコントローラーを導入する必要があります。
この外部リソースの管理については kro は関与しません。ここが Crossplane との大きな違いです。
実際に kro で AWS リソースを管理する場合は、ACK(AWS Controllers for Kubernetes) という AWS の各種リソースを管理するコントローラーか、 Crossplane の Provider などが選択肢として考えられます。

Crossplane と kro の違い

独自リソースを定義し、利用可能にするまでの流れで気付いたかも知れませんが、Crossplane と kro の違いとして

  • 責務の違い
  • 柔軟性
  • 複雑性

があると思います。

責務の違い

上述の通り、Crossplane は外部リソースを管理する Provider というカスタムリソースを持っており、Crossplane だけで外部リソースの管理まで完結できます。
そのため、Provider リソースを適用するだけで、外部リソースを含む独自リソースを定義できるようになります。

一方で kro は外部リソースの管理については関与せず、RGD の定義に基づいてリソースを Apply するという責務のみを持っています。
そのため、外部リソースを管理するコントローラーは別途導入・管理する必要があります。

柔軟性の違い

Crossplane は Function という gRPC サーバーとのやりとりでリソース構成を解決しています。
この Function は様々な種類がすでに存在していることとFunction 自体を自作することが可能なため、柔軟にリソース構成の解決処理を拡張できます。

対して kro は CEL でリソース構成を定義しています。
CEL はシンプルで学習コストも低いため、比較的簡単にリソース構成を定義できますが、凝ったリソース構成を定義するには限界があるかも知れません。

複雑性の違い

責務の違い、柔軟性の違いだけ見ると Crossplane の方が優れているように見えますが、その分複雑性も高くなっています。
まず、Crossplane は責務が大きく、Crossplane 自体のカスタムリソースに関しても20 種類ほど提供されています。
逆にいうと、それらを適切に選択し設定し、運用する必要があります。
もちろんカスタムリソース毎にそれらを管理するコントローラーも存在するため、多くのコントローラーが稼働しておりそれらの理解や管理も必要です。(インフラ費用もかかる。。。)
また、リソース構成を解決するためには Function とのやり取りによって達成する必要があり、Function との通信が何かしらの理由で失敗した場合はリソースの作成が失敗するため、フレーキーなエラーが発生する可能性も高まります。
トラブルシュートも Crossplane と Function が分散して動作するため複雑になります。

kro に関しては責務が小さくカスタムリソースも RGD のみで大変シンプルです。
Kubernetes 標準外リソースを管理する場合は外部リソースを管理するコントローラーを別途導入する必要がありますが、kro 自体の責務(リソース構成を解決して Apply)を達成するためには kro のみでよいです。
つまり、フレーキーなエラーも起きにくいですし、運用管理などの点でも優秀です。
主要ロジックが記述されているGoのコード行数に関しても Crossplane は 9 万行以上、kro は 3 万行ほどと大きな差があります。

結局どっちが良いの?

個人的にはどちらも良いソリューションであり、ユースケースによって使い分けるのが良いと思います。
Kubernetes リソースのみに対して独自リソースを構築したい場合は kro が良いと思います。
外部リソースを管理するコントローラーを導入する必要もなく、kro 単体で完結するからです。
外部リソースも含める場合やマルチクラウドを利用する場合などは Crossplane を利用するのも良いと思います。
Crossplane という単一のインターフェイスで外部リソースの管理から独自リソースの定義/管理まで完結できるからです。
また、構成リソースの解決には kro を使い、外部リソースの管理には Crossplane Provider を使うというハイブリッドな利用方法も考えられます。
これにより、構成リソースの解決には kro のシンプルさを享受しつつ、外部リソースの管理には Crossplane Provider を利用することが可能になります。
我々は現在 Crossplane のみを利用していますが、今後は kro の利用や動向も注視していきたいと思います。

最後に

ここまで読んでいただきありがとうございます!
大変長い記事になってしまいましたが、なんとなくでも Crossplane や kro の雰囲気をわかってもらえれば幸いです。
Crossplane を使ったマルチプロダクト基盤の話冒頭で紹介したYAPCの登壇資料などもございますので、是非是非ご覧ください!
それでは〜!