How to Use Rust with Kubernetes

You use Rust with Kubernetes primarily by building high-performance custom controllers or CRDs using the `kube-rs` client library, or by compiling your Rust applications into static binaries to run as lightweight, dependency-free containers.

How to Use Rust with Kubernetes

You wrote a Rust service that handles requests in microseconds. You push it to Kubernetes and the cluster screams. The image is 800 megabytes. It pulls glibc, libssl, and half the internet just to run your binary. Or you need a custom controller to manage a fleet of databases. You look at the Kubernetes SDK and see Go. You wonder if you have to rewrite your logic in a language you don't know.

Rust fits Kubernetes in two distinct ways. You can shrink your workloads until they vanish, or you can write type-safe controllers that catch API mistakes at compile time.

Shrink the image. Trust the types.

Static binaries and the scratch container

Rust compiles everything into a single executable. No shared libraries. No dynamic linking drama. You can drop this file onto a bare metal server or inside an empty container, and it runs. The container ecosystem loves this. A standard Linux container includes a shell, package manager, and C libraries. You don't need any of that for a Rust binary. You can use the scratch image, which is literally empty.

Convention aside: The community calls this the "zero-dependency" approach. If your binary runs in scratch, you have eliminated the entire OS attack surface. No CVEs in libc. No rootkits in the shell. Just your code.

// src/main.rs
/// A minimal service that prints a message and exits.
fn main() {
    // Print to stdout. Kubernetes captures this for logs.
    println!("Rust service running in a scratch container");
}
# Cargo.toml
[profile.release]
# Abort on panic to skip unwinding. Shrinks binary size significantly.
panic = "abort"
# Strip symbols to remove debug info. Reduces size further.
strip = "symbols"
# Dockerfile
# Use scratch. It has zero libraries. Your binary must be static.
FROM scratch
# Copy the release binary. Path depends on your build target.
COPY target/x86_64-unknown-linux-musl/release/my-app /app
# Run the binary. No shell needed.
CMD ["/app"]

When you build for Kubernetes, you target x86_64-unknown-linux-musl. The musl library is a lightweight C standard library implementation. Rust links against it statically. The result is a binary that carries its own dependencies. You don't need glibc on the host. You don't need apt or yum. You can use cross to compile this from macOS or Windows without a Linux VM. cross spins up a Docker container with the right toolchain, builds the binary, and hands it back. You copy that binary into scratch. The final image is often under 5 megabytes. Compare that to a Node.js image at 200 megabytes or a Go image at 50 megabytes. The attack surface shrinks. Startup time drops to milliseconds.

Don't fight the linker. Build for musl and run on scratch.

Controllers with kube-rs

Kubernetes exposes a REST API. Most clients wrap that API in loose structures. Rust's kube-rs library wraps it in types. If you ask for a Pod and get a Service, the compiler stops you. If you miss a required field in a manifest, the code won't build. This is how you write operators and custom controllers in Rust.

The kube-rs library turns the Kubernetes API into Rust types. You define a struct that matches your Custom Resource Definition. The library derives the serialization logic. The Controller struct handles the watch loop, leader election, and retry logic. You only write the reconciliation function. When the resource changes, the function runs. You compare the desired state in the spec with the actual state in the cluster. You patch the cluster to match.

Convention aside: The community standard is the CustomResource derive macro. Manual struct definitions work, but the macro generates the boilerplate for you. It creates the type, the status type, and the API group version kind metadata. If you see manual structs in kube-rs code, it's likely legacy or a very specific edge case. Use the macro.

// src/main.rs
use kube::CustomResource;
use serde::{Deserialize, Serialize};

/// A custom resource definition representing a database cluster.
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug)]
#[kube(
    group = "db.example.com",
    version = "v1",
    kind = "DatabaseCluster",
    namespaced
)]
pub struct DatabaseClusterSpec {
    /// The number of replicas to maintain.
    pub replicas: i32,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Create a client. Uses in-cluster config if available, else kubeconfig.
    let client = kube::Client::try_default().await?;
    // Target the custom resource in the default namespace.
    let clusters: kube::Api<DatabaseCluster> = kube::Api::default_namespaced(client);

    // Build a controller that watches for changes.
    let controller = kube::runtime::Controller::new(clusters, kube::api::ListParams::default())
        .run(
            // Reconcile function. Runs when the resource changes.
            |cluster, _ctx| async move {
                // Log the name. In real code, you'd create pods here.
                println!("Reconciling cluster: {}", cluster.name_any());
                Ok(())
            },
            Default::default(),
        )
        .await;

    // Block until the controller stops.
    controller.await?;
    Ok(())
}

The macro generates DatabaseCluster as a full Kubernetes type. You get a type-safe client for free. The controller watches the API server for events. When a DatabaseCluster changes, the reconcile function fires. You read the spec.replicas. You check how many pods exist. If the count is wrong, you create or delete pods. The loop runs until the cluster matches the spec.

Treat the reconciliation loop as the source of truth. If the cluster drifts, the loop fixes it.

Pitfalls and compiler errors

If you try to run a dynamically linked binary in scratch, the container crashes immediately. The error message is cryptic: exec format error or no such file or directory. This happens because the binary expects libc.so.6 and the container has nothing. The fix is to build for musl. If you use cross, ensure your target matches.

If you see E0277 (trait bound not satisfied) when using kube-rs, check your struct derives. Kubernetes types need Deserialize, Serialize, and Clone. Missing one breaks the type system. If you get E0308 (mismatched types) in the reconcile function, you likely returned the wrong type from the async block. The controller expects Result<(), Error>.

Convention aside: Run cargo clippy on your operators. The linter catches async anti-patterns that cause deadlocks in long-running controllers. It also flags unused imports that bloat your build.

Check your derives. The compiler is your safety net, not your enemy.

When to use what

Use scratch containers when your binary is fully static and you want the smallest possible image. Use alpine containers when you need a shell for debugging or a specific library that Rust cannot link statically. Use kube-rs when you are writing a custom controller or operator and want type safety against the Kubernetes API. Use cross when you develop on macOS or Windows and need to build Linux musl binaries without a VM. Use panic = "abort" in your release profile to reduce binary size and improve startup time for containerized workloads. Reach for glibc targets only when you have a dependency that refuses to compile with musl; this is rare in the Rust ecosystem.

Pick the tool that matches the constraint. Small image? Scratch. Type safety? kube-rs. Cross-platform? Cross.

Where to go next