Skip to content
Hogin Hogin
Go back

KRO: composite Kubernetes APIs without writing your own operator

8 мин чтения

A developer who just wants “a web service with a database” usually gets seven YAML files and a “figure it out.” Kube Resource Orchestrator offers a different path: the platform team describes the resource bundle once, as a graph, and the developer applies a single CRD with three fields. The difference isn’t that someone finally wrote good docs — it’s that composition no longer needs to be copy-pasted by hand.

Table of contents

Open Table of contents

The problem: seven manifests for one service

A typical “just a web service” in a cluster is never one resource. It’s a Deployment with the right limits and probes, a Service, a ConfigMap with the app config, a separate CloudNativePG Cluster for the database, a Secret with the connection string, sometimes an Ingress and a HorizontalPodAutoscaler on top. A developer who needs a database and an HTTP endpoint gets, at best, a Helm chart with a dozen value fields — at worst, a pile of YAML copied from a neighboring service and hand-tuned.

There are two standard answers to this pain. The first is to write a full Kubernetes operator in Go: kubebuilder or operator-sdk, your own controller, your own reconcile loop, your own tests. That’s heavyweight for an internal platform where such a CRD is needed once or twice, not shipped as a product with a public API. The second is a Helm chart with a mountain of {{ if }} and {{ range }}, which quickly turns into an unreadable template on top of a template: the dependency logic between resources gets smeared across conditionals instead of being stated explicitly.

A developer applies either a stack of seven manifests or a single composite CRD

What ResourceGraphDefinition changes

KRO introduces one new top-level CRD — ResourceGraphDefinition (RGD). In it, the platform engineer describes, once: the new API the developer will get (kind: WebApp, with image, replicas, dbSize fields), which primitive resources it’s made of (Deployment, Service, a ConfigMap, an external CloudNativePG Cluster), and how those resources’ fields reference each other.

The key mechanic is references via CEL expressions written directly into the resource manifest — not a separate DAG. If Service.spec.selector points at ${deployment.spec.template.metadata.labels} and ConfigMap.data.DB_HOST points at ${cluster.status.host}, KRO builds the dependency graph from those expressions itself and applies resources in the right order: whatever the others depend on first, then the dependents — and it waits for cluster.status.host to actually appear in status before creating the ConfigMap. The controller logic you’d otherwise have to hand-write — “wait for Postgres to come up, then create the config” — here simply falls out of the reference graph.

Once the RGD is applied, KRO generates a real CRD (WebApp) and stands up its own controller for it dynamically — no recompiling or redeploying KRO itself. From then on the developer only sees WebApp in the cluster, not what it’s built from.

The RGD compiles into a CRD and a controller; the developer only applies WebApp

KRO, Crossplane Compositions, and Helm with conditionals aren’t the same thing

All three approaches solve “give the developer a simple API instead of a resource bundle,” but at different layers. Crossplane Compositions do the same job as RGDs, but for cloud infrastructure outside the cluster — RDS, VPCs, IAM roles — via providers that translate Kubernetes resources into cloud API calls. KRO only works inside the cluster: it can’t create an RDS instance on its own, but it can fold a Crossplane resource into a composition alongside a plain Deployment and Service, as long as Crossplane is already installed as a provider.

Helm with conditionals is the most accessible option, but it doesn’t solve the composition problem — it masks it with a text templating engine: dependencies between resources aren’t declared, they’re implied by hook ordering and wait flags. An RGD stores the dependency graph as data (CEL references), not as line order in a file — that’s exactly what lets KRO derive the apply order automatically, something Helm has no way to do.

KROCrossplane CompositionsHelm + conditionals
ScopeResources inside the clusterCloud infrastructure + clusterAnything, but in text
How dependencies are describedCEL references in the RGD, graph built automaticallyPatches between composition fieldsHook ordering, by hand
Needs a Go controllerNo — generated dynamicallyNo, but needs a cloud providerNo
What the developer getsA simple CRD (WebApp)A simple CRD (XDatabase etc.)A values.yaml with dozens of fields
Where it’s strongerSelf-service over in-cluster resourcesMulti-cloud infrastructure provisioningFast start with no new dependencies

Practice: a WebApp over a Deployment, Service, and CloudNativePG

Take that same typical case — a web service with a database on CloudNativePG (covered in a separate post on schema-as-code). The platform team describes the ResourceGraphDefinition once:

apiVersion: kro.run/v1alpha1
kind: ResourceGraphDefinition
metadata:
  name: webapp
spec:
  schema:
    apiVersion: v1alpha1
    kind: WebApp
    spec:
      image: string
      replicas: integer | default=2
      dbSize: string | default="1Gi"
    status:
      dbHost: ${cluster.status.host}
      ready: ${deployment.status.readyReplicas}
  resources:
    - id: cluster
      template:
        apiVersion: postgresql.cnpg.io/v1
        kind: Cluster
        metadata:
          name: ${schema.metadata.name}-db
        spec:
          instances: 1
          storage:
            size: ${schema.spec.dbSize}
    - id: deployment
      template:
        apiVersion: apps/v1
        kind: Deployment
        metadata:
          name: ${schema.metadata.name}
        spec:
          replicas: ${schema.spec.replicas}
          template:
            spec:
              containers:
                - name: app
                  image: ${schema.spec.image}
                  env:
                    - name: DB_HOST
                      value: ${cluster.status.host}
    - id: service
      template:
        apiVersion: v1
        kind: Service
        metadata:
          name: ${schema.metadata.name}
        spec:
          selector: ${deployment.spec.template.metadata.labels}
          ports:
            - port: 80

After this, the developer never sees Cluster, Deployment, or Service as separate objects — they apply one thing:

apiVersion: v1alpha1
kind: WebApp
metadata:
  name: checkout
spec:
  image: registry.internal/checkout:1.4.2
  replicas: 3
  dbSize: "5Gi"

KRO creates the Cluster itself, waits for CloudNativePG to publish status.host, and only then creates the Deployment with the database address already substituted in — because ${cluster.status.host} in the Deployment template is exactly the reference the controller uses to derive ordering.

Self-service via Backstage: one software template instead of three YAMLs

Pairing this with a service catalog makes the loop even shorter: if Backstage’s scaffolder is already set up (as covered in the post on building a catalog from scratch), a software template can generate not a pile of files but a single WebApp manifest and push it to the GitOps repo. The developer fills in a form with three fields — name, image, database size — and gets a working service with a database in the cluster, never seeing a ResourceGraphDefinition and never knowing that WebApp is built from three primitive resources underneath. That’s the actual point of pairing a catalog with KRO: a self-service API, not just a shorter YAML file.

Where KRO doesn’t fit

KRO is a good fit when everything you need to compose lives inside a single cluster or is reachable through an already-installed Crossplane provider. It’s a poor fit when the main job is complex multi-cloud provisioning: standing up VPCs across three AWS regions, peering them, issuing IAM roles, and only then deploying a cluster on top of that. That’s where Crossplane has something KRO lacks out of the box — a mature ecosystem of cloud-specific providers, composition functions for complex field transformation, and managed-resource lifecycle handling that accounts for cloud API state, not just Kubernetes objects. Modeling that kind of provisioning through an RGD is technically possible, but it means reinventing what Crossplane’s providers already handle and have battle-tested. The rule is simple: if you’re composing Kubernetes resources, reach for KRO; if you’re composing cloud infrastructure, reach for Crossplane — and KRO can sit on top of it for the last, in-cluster layer of composition if you want both.

How to verify it works

After kubectl apply -f resourcegraphdefinition.yaml, first check that KRO accepted the graph and generated the CRD: kubectl get resourcegraphdefinition webapp -o jsonpath='{.status.state}' should return Active, not Inactive with an error in .status.conditions. Next, kubectl get crd webapps.kro.run should exist — that’s the dynamically generated API. After applying an instance, kubectl get webapp checkout -o jsonpath='{.status.ready}' should eventually show a number of replicas matching spec.replicas, and kubectl get cluster checkout-db should confirm CloudNativePG actually brought the database up rather than hanging on a missing storage class.

Bottom line

KRO doesn’t replace Crossplane, nor does it replace writing operators where they’re genuinely warranted — it closes the gap between “copy-paste YAML by hand” and “write a Go controller for one internal API.” The cost of entry is learning how CEL references in a ResourceGraphDefinition turn into a dependency graph and an apply order. The payoff is developers getting a WebApp instead of three files, and the platform team getting a self-service API it can wrap in a Backstage software template without opening a Go module for a single entity. The choice between KRO and Crossplane isn’t about trends — it comes down to what you’re actually composing: Kubernetes resources, or cloud infrastructure beyond them.


Share this post:

Next Post
Scorecards for platform teams: measuring whether the service catalog actually works