Look, I love Rust. Iâm always talking about Rust. So today, weâre talking about Rust.
Iâve been building Kubernetes operators for years, mostly in Go. Itâs the obvious choice: Kubernetes itself is written in Go, and controller-runtime and kubebuilder make scaffolding easy. But âpath of least resistanceâ and âbest tool for the jobâ arenât always the same thing.
Since CoreOS introduced the operator pattern in 2016, operators have proliferated across the ecosystem, managing databases, message queues, certificates, and increasingly complex stateful workloads. As they become more critical to production infrastructure, the language theyâre written in matters more than ever.
I recently joined CoreWeave, and Iâm thinking a lot about how operators scale when youâre managing GPU infrastructure across hundreds of clusters. Rustâs memory efficiency and safety guarantees arenât nice-to-haves at that scale. Theyâre operational necessities.
kube-rs is a mature, production-ready Rust client for Kubernetes. It gives you everything you need to build operators with Rustâs memory safety, zero-cost abstractions, and fearless concurrency. The type system catches so many bugs that would be runtime panics in Go. If youâre building controllers that need to be reliable, efficient, and correct, itâs time to look beyond Go.
Why Rust for Kubernetes Operators?
The obvious question: why choose Rust over Go for something so deeply embedded in the Go ecosystem?
Memory Safety Without a Garbage Collector
Goâs garbage collector is good, but it introduces latency spikes. For most applications, this is irrelevant. For a controller reconciling thousands of resources under pressure during a cluster incident, those pauses matter. Rust gives you deterministic memory management with zero garbage collection pauses. Your controllerâs performance is predictable under load.
More importantly, Rustâs ownership model eliminates entire classes of bugs at compile time. No null pointer dereferences. No data races. No use-after-free. When your operator is managing production databases or handling secrets rotation, these guarantees arenât academic. Theyâre operational necessities.
Type System as Documentation and Guardrail
Goâs type system is intentionally simple. Rustâs is intentionally expressive. When youâre defining Custom Resource Definitions (CRDs), Rustâs type system (with enums, pattern matching, and trait-based generics) lets you model your domain with precision that Goâs structs and interfaces canât match.
Consider modeling the state of a managed database:
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]pub enum DatabasePhase { Provisioning { started_at: String, progress: ProvisioningStep, }, Running { endpoint: String, version: String, last_backup: Option<String>, }, Upgrading { from_version: String, to_version: String, rollback_snapshot: String, }, Failed { reason: String, last_healthy_at: String, recovery_attempts: u32, },}In Go, youâd represent this as a string phase field with a collection of optional fields, losing the compilerâs ability to enforce that you handle every state transition correctly. In Rust, a match on DatabasePhase forces you to handle every variant. You canât forget an edge case.
Resource Efficiency
Rust binaries are compact and start fast. A typical kube-rs operator compiles to a binary often under 20 MB with release optimizations, starts in milliseconds, and uses a fraction of the memory of its Go equivalent. When youâre running operators across hundreds of clusters, this efficiency compounds into real cost savings.
Getting Started with kube-rs
Iâm going to walk you through building a real operator. Weâll create a controller that manages a custom WebApp resource: a simplified abstraction that creates and manages a Deployment, Service, and optionally an Ingress for web applications.
Project Setup
cargo init webapp-operatorcd webapp-operatorAdd the essential dependencies to your Cargo.toml:
[dependencies]kube = { version = "3.0", features = ["runtime", "derive"] }k8s-openapi = { version = "0.27", features = ["latest", "schemars"] }schemars = "1"serde = { version = "1", features = ["derive"] }serde_json = "1"tokio = { version = "1", features = ["full"] }tracing = "0.1"tracing-subscriber = { version = "0.3", features = ["env-filter"] }thiserror = "2"futures = "0.3"Defining Your Custom Resource
The CRD is where Rustâs type system really shines. kube-rs uses derive macros to generate the full CRD schema from your Rust types:
use kube::CustomResource;use schemars::JsonSchema;use serde::{Deserialize, Serialize};
#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)]#[kube( group = "apps.rawkode.academy", version = "v1", kind = "WebApp", plural = "webapps", status = "WebAppStatus", namespaced, printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#, printcolumn = r#"{"name":"Replicas","type":"integer","jsonPath":".spec.replicas"}"#)]pub struct WebAppSpec { /// Container image to deploy pub image: String,
/// Number of replicas #[serde(default = "default_replicas")] pub replicas: i32,
/// Port the application listens on #[serde(default = "default_port")] pub port: i32,
/// Optional hostname for Ingress creation pub hostname: Option<String>,
/// Environment variables to inject #[serde(default)] pub env: Vec<EnvVar>,
/// Resource requirements pub resources: Option<ResourceRequirements>,}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]pub struct EnvVar { pub name: String, pub value: String,}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]pub struct ResourceRequirements { pub cpu: String, pub memory: String,}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]pub struct WebAppStatus { pub phase: String, pub ready_replicas: i32, pub message: Option<String>, pub last_reconciled: Option<String>,}
fn default_replicas() -> i32 { 3 }fn default_port() -> i32 { 8080 }That #[derive(CustomResource)] macro generates a complete WebApp struct with metadata, spec, and status, plus methods to generate the CRD YAML. Call WebApp::crd() and you get a fully-typed CustomResourceDefinition ready to apply to your cluster.
The Reconciliation Loop
The heart of any operator is its reconciliation function. kube-rs provides a Controller runtime that handles watch events, requeueing, and error backoff. Your job is to implement the reconcile function:
use kube::{ api::{Api, Patch, PatchParams, ResourceExt}, runtime::controller::{Action, Controller}, Client, Resource,};use std::sync::Arc;use tokio::time::Duration;
// Shared state for the controllerstruct Context { client: Client,}
async fn reconcile( webapp: Arc<WebApp>, ctx: Arc<Context>,) -> Result<Action, Error> { let client = &ctx.client; let name = webapp.name_any(); let namespace = webapp.namespace().unwrap_or_default();
tracing::info!( name = %name, namespace = %namespace, "Reconciling WebApp" );
// Step 1: Ensure the Deployment exists and is up-to-date reconcile_deployment(client, &webapp).await?;
// Step 2: Ensure the Service exists reconcile_service(client, &webapp).await?;
// Step 3: If hostname is specified, ensure Ingress exists if webapp.spec.hostname.is_some() { reconcile_ingress(client, &webapp).await?; }
// Step 4: Update status update_status(client, &webapp).await?;
// Re-reconcile every 5 minutes to catch drift Ok(Action::requeue(Duration::from_secs(300)))}
fn error_policy( _webapp: Arc<WebApp>, error: &Error, _ctx: Arc<Context>,) -> Action { tracing::error!(%error, "Reconciliation failed"); // Fixed retry interval of 10 seconds Action::requeue(Duration::from_secs(10))}Building the Deployment
This is where Rustâs type safety pays off. Instead of constructing untyped JSON objects, you build fully-typed Kubernetes resources:
use k8s_openapi::api::apps::v1::{Deployment, DeploymentSpec};use k8s_openapi::api::core::v1::{ Container, ContainerPort, PodSpec, PodTemplateSpec,};use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;use std::collections::BTreeMap;
async fn reconcile_deployment( client: &Client, webapp: &WebApp,) -> Result<(), Error> { let namespace = webapp.namespace().unwrap_or_default(); let name = webapp.name_any(); let deployments: Api<Deployment> = Api::namespaced(client.clone(), &namespace);
let labels: BTreeMap<String, String> = BTreeMap::from([ ("app.kubernetes.io/name".into(), name.clone()), ("app.kubernetes.io/managed-by".into(), "webapp-operator".into()), ]);
let deployment = Deployment { metadata: kube::api::ObjectMeta { name: Some(name.clone()), namespace: Some(namespace.clone()), labels: Some(labels.clone()), owner_references: Some( webapp.controller_owner_ref(&()).into_iter().collect() ), ..Default::default() }, spec: Some(DeploymentSpec { replicas: Some(webapp.spec.replicas), selector: LabelSelector { match_labels: Some(labels.clone()), ..Default::default() }, template: PodTemplateSpec { metadata: Some(kube::api::ObjectMeta { labels: Some(labels), ..Default::default() }), spec: Some(PodSpec { containers: vec![Container { name: "app".into(), image: Some(webapp.spec.image.clone()), ports: Some(vec![ContainerPort { container_port: webapp.spec.port, ..Default::default() }]), ..Default::default() }], ..Default::default() }), }, ..Default::default() }), ..Default::default() };
deployments .patch( &name, &PatchParams::apply("webapp-operator"), &Patch::Apply(deployment), ) .await?;
Ok(())}Notice the owner_references line. This sets up Kubernetes garbage collection so that when a WebApp is deleted, its Deployment is automatically cleaned up. kube-rs provides controller_owner_ref as a convenience method that returns an Option<OwnerReference>. The .into_iter().collect() pattern cleanly converts it into the Vec that owner_references expects.
Architecture: How It All Fits Together
Hereâs how the operator interacts with the Kubernetes API and manages its resources:
The operator watches for changes to WebApp custom resources. When a change is detected, the watcher enqueues a reconciliation event. The reconciler then ensures the actual state (Deployments, Services, Ingresses) matches the desired state defined in the WebApp spec. This is the standard level-triggered reconciliation pattern, the same one that powers every built-in Kubernetes controller.
Testing Your Operator
One of kube-rsâs underrated strengths is its testing story. The library provides utilities for both unit testing with mocked API responses and integration testing against real clusters.
Unit Testing with Mocked Responses
#[cfg(test)]mod tests { use super::*; use kube::api::ObjectMeta;
fn test_webapp() -> WebApp { WebApp { metadata: ObjectMeta { name: Some("test-app".into()), namespace: Some("default".into()), ..Default::default() }, spec: WebAppSpec { image: "nginx:latest".into(), replicas: 2, port: 80, hostname: Some("test.example.com".into()), env: vec![], resources: None, }, status: None, } }
#[test] fn test_crd_generation() { // Verify CRD schema is valid let crd = WebApp::crd(); assert_eq!(crd.metadata.name, Some("webapps.apps.rawkode.academy".into())); }
#[test] fn test_default_values() { let spec: WebAppSpec = serde_json::from_str(r#"{ "image": "nginx:latest" }"#).unwrap(); assert_eq!(spec.replicas, 3); assert_eq!(spec.port, 8080); }}Integration Testing
For integration tests, kube-rs works well with tools like k3d or kind. Spin up an ephemeral cluster in CI:
#[tokio::test]#[ignore = "requires cluster"]async fn test_full_reconciliation() { let client = Client::try_default().await.unwrap(); let webapps: Api<WebApp> = Api::namespaced(client.clone(), "default");
// Create a WebApp let webapp = WebApp::new("integration-test", WebAppSpec { image: "nginx:latest".into(), replicas: 1, port: 80, hostname: None, env: vec![], resources: None, });
webapps.create(&Default::default(), &webapp).await.unwrap();
// Wait for reconciliation tokio::time::sleep(Duration::from_secs(5)).await;
// Verify Deployment was created let deployments: Api<Deployment> = Api::namespaced(client.clone(), "default"); let deploy = deployments.get("integration-test").await.unwrap(); assert_eq!( deploy.spec.unwrap().replicas, Some(1) );}The Main Function: Wiring It All Together
Hereâs the complete entrypoint that starts the controller:
use futures::StreamExt;
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { tracing_subscriber::fmt() .with_env_filter("webapp_operator=info,kube=info") .json() .init();
let client = Client::try_default().await?;
// Ensure CRD exists (or apply it separately) let webapps: Api<WebApp> = Api::all(client.clone()); let deployments: Api<Deployment> = Api::all(client.clone());
tracing::info!("Starting WebApp operator");
Controller::new(webapps, Default::default()) // Watch owned Deployments for changes .owns(deployments, Default::default()) .run(reconcile, error_policy, Arc::new(Context { client })) .for_each(|result| async move { match result { Ok(obj) => tracing::info!(resource = ?obj, "Reconciled"), Err(err) => tracing::error!(%err, "Reconciliation error"), } }) .await;
Ok(())}The .owns(deployments, Default::default()) call is crucial. It tells the controller to also watch Deployments and trigger reconciliation of the owning WebApp if a managed Deployment changes. This is how your operator detects drift: if someone manually edits a Deployment, the controller immediately reconciles it back to the desired state.
Containerizing and Deploying
Rustâs compilation model makes containerization straightforward. Hereâs a multi-stage Dockerfile:
FROM rust:1.93 AS builderWORKDIR /buildCOPY . .RUN cargo build --release
FROM gcr.io/distroless/cc-debian12:nonrootCOPY --from=builder /build/target/release/webapp-operator /ENTRYPOINT ["/webapp-operator"]The final image is typically under 30 MB. Compare this to Go operator images, which with similar distroless builds land around 30-50 MB, or larger when you start including debugging tools. Java-based operators (yes, they exist) can exceed 300 MB.
When to Choose Rust Over Go
I want to be straight with you. Rust isnât always the right choice for every operator.
Choose Rust when:
- Your operator manages security-sensitive resources (secrets, certificates, network policies)
- You need predictable, low-latency reconciliation under high load
- Your team already has Rust experience
- Youâre building a long-lived project where compile-time correctness pays dividends
- Resource efficiency matters (edge deployments, large-scale multi-cluster)
Stick with Go when:
- Youâre prototyping and need maximum iteration speed
- Your team is Go-native and switching would slow delivery
- You need deep integration with controller-runtimeâs ecosystem (e.g., Kubebuilder scaffolding)
- The operator is simple enough that Goâs simplicity is an advantage
The ecosystem around kube-rs is growing steadily. kopium generates Rust types from existing CRDs, and the broader kube-rs project (a CNCF Sandbox project) continues to add tooling that reduces boilerplate. The community is smaller than Goâs operator ecosystem, but itâs active, welcoming, and shipping real production software.
Production Readiness Checklist
Before shipping your Rust operator to production, make sure youâve covered these essentials:
- RBAC manifests: Generate ClusterRole/ClusterRoleBinding with minimum required permissions
- Leader election: Use a leader election crate such as
kube-leader-election,kube-coordinate, orkube-lease-managerto prevent split-brain in HA deployments - Metrics endpoint: Expose Prometheus metrics for reconciliation duration, queue depth, and errors
- Health probes: Implement
/healthzand/readyzendpoints for Kubernetes liveness and readiness checks - Graceful shutdown: Handle SIGTERM to complete in-flight reconciliations before exiting
- Finalizers: Add finalizers to handle custom cleanup logic when resources are deleted
- Status subresource: Always write meaningful status conditions so users can
kubectl get webappsand understand state - Owner references: Set owner references on all created resources for automatic garbage collection
- Event recording: Emit Kubernetes Events so
kubectl describeshows what the operator is doing - E2E tests in CI: Run integration tests against an ephemeral cluster (k3d/kind) on every PR
- CRD versioning strategy: Plan for how youâll handle CRD schema evolution (conversion webhooks if needed)
- Container image signing: Sign images with cosign and publish SBOMs for supply chain security
The Bigger Picture
Weâre past the point where âit worksâ is good enough for operators. They need to be secure, efficient, and maintainable over years of production use. Rustâs guarantees align perfectly with those requirements.
kube-rs has proven itself in production at scale. Itâs maintained by an active community, has excellent documentation, and the tooling ecosystem keeps growing. If youâve been curious about Rust but havenât found the right project to start with, a Kubernetes operator is a great place to begin. You get practical Rust experience while building something genuinely useful for your infrastructure.
The cloud native world doesnât have to be a Go monoculture. Diversity in implementation languages leads to diversity in ideas and approaches. Your next operator might just be your first Rust project.
Give it a try. Your future on-call self will thank you.