The most surprising thing about building Kubernetes Operators is how much boilerplate they eliminate, effectively allowing you to treat your custom resources like first-class Kubernetes objects.
Let’s watch an Operator in action. Imagine we have a MyService custom resource defined like this:
apiVersion: example.com/v1
kind: MyService
metadata:
name: my-app
spec:
replicas: 3
image: nginx:latest
And our Operator, written in Rust using kube-rs, is watching for these MyService resources. When it sees my-app, it will ensure that a Deployment and a Service object exist in Kubernetes that match the spec:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app-deployment
spec:
replicas: 3
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: my-app-container
image: nginx:latest
---
apiVersion: v1
kind: Service
metadata:
name: my-app-service
spec:
selector:
app: my-app
ports:
- protocol: TCP
port: 80
targetPort: 80
The Operator reconciles the desired state (the MyService resource) with the actual state of the cluster (the Deployment and Service). If you change spec.replicas to 5 in MyService, the Operator will detect the change and update the Deployment’s replica count. If you delete the MyService resource, the Operator will clean up the associated Deployment and Service.
Here’s how kube-rs simplifies this. You define your Custom Resource Definition (CRD) as a Rust struct, often using serde for serialization and schemars for schema generation:
use kube::{CustomResource, ApiVersion, Kind, Resource};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(CustomResource, Serialize, Deserialize, Clone, Debug, JsonSchema)]
#[kube(
group = "example.com",
version = "v1",
kind = "MyService",
plural = "myservices"
)]
pub struct MyServiceSpec {
pub replicas: i32,
pub image: String,
}
This struct directly maps to your MyService YAML. The #[kube(...)] attribute tells kube-rs how to register this as a Kubernetes custom resource.
The core of the Operator is a controller loop. You’ll typically use the kube-rs controller module for this. It abstracts away the complexities of watching for events (creation, update, deletion) on your custom resource and provides a reconcile function that you implement.
use kube::{Api, Client, Error};
use kube_controller::{reconcile, Controller, Context};
use futures::StreamExt;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let client = Client::try_default().await?;
let myservices: Api<MyService> = Api::all(client.clone());
let deployments: Api<Deployment> = Api::all(client.clone());
let services: Api<Service> = Api::all(client.clone());
Controller::new(myservices.clone(), Context {
client: client.clone(),
custom_resources: myservices,
// ... other shared state
})
.run(reconcile_myservice)
.for_each(|_| async { /* handle reconciliation result */ })
.await;
Ok(())
}
async fn reconcile_myservice(myservice: MyService, ctx: Context<MyService>) -> Result<ReconcileResult, Error> {
let client = ctx.client.clone();
let desired_deployment = build_deployment(&myservice);
let desired_service = build_service(&myservice);
// Logic to create/update/delete Deployment and Service based on myservice
// ...
Ok(reconcile::Continue(myservice.metadata.name.unwrap()).into())
}
The reconcile_myservice function is where your business logic lives. It receives the MyService object that triggered the event and the Kubernetes client. Inside, you’ll compare the desired state defined by myservice.spec with the actual state of Deployments and Services in the cluster. If they don’t match, you’ll use the client to create, update, or delete Kubernetes objects.
The kube-rs controller module handles the event stream, ensuring your reconcile function is called for every relevant change. It also provides mechanisms for error handling, requeuing, and managing shared state.
The power of this approach lies in abstracting away the direct interaction with Kubernetes API for every single object. Instead, you declare the desired state of your custom resource, and the Operator, powered by kube-rs, ensures that the cluster’s actual state converges to that desired state. This is the core principle of declarative infrastructure.
One subtle but powerful aspect is how kube-rs leverages the Resource trait. This trait provides a consistent interface for interacting with any Kubernetes resource, whether it’s a built-in type like Deployment or your custom MyService. This means your reconciliation logic can be written generically to handle different resource types, making your Operator more adaptable and easier to extend. For example, you can use Api<impl Resource> or pattern matching on resource kinds to apply common logic.
The next step in building a robust Operator involves handling finalizers, which are crucial for graceful deletion of custom resources, ensuring that associated resources are cleaned up before the custom resource itself is removed.