Guide contents

Lesson 18 of 30

Docker, Kubernetes, and CI/CD for Axum Services

A service ships as a container image and runs under an orchestrator. This lesson packages a small Axum service, deploys it with Kubernetes manifests, and builds a CI pipeline that produces the image.

Term Meaning
Image A read-only file system snapshot plus a start command, built from a Dockerfile
Pod The smallest Kubernetes unit: one or more containers sharing a network
Deployment An object that keeps a set of identical pods running and manages updates
Service A stable name and virtual address that balances traffic across ready pods
HPA Horizontal Pod Autoscaler: changes the pod count based on a metric

Project setup

The service has the health and shutdown behavior from health, shutdown, and configuration, without the database, so the image runs on its own.

Cargo.toml

TOML
[package]
name = "storefront"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.8.4"
tokio = { version = "1.47", features = ["macros", "rt-multi-thread", "signal", "net", "time"] }

src/main.rs

Rust
use std::{
    env,
    net::SocketAddr,
    sync::{atomic::{AtomicBool, Ordering}, Arc},
    time::Duration,
};

use axum::{extract::State, http::StatusCode, routing::get, Router};
use tokio::{net::TcpListener, signal};

#[derive(Clone)]
struct AppState {
    ready: Arc<AtomicBool>,
    app_env: String,
    admin_token_set: bool,
}

async fn livez() -> &'static str {
    "alive"
}

async fn readyz(State(state): State<AppState>) -> (StatusCode, &'static str) {
    if state.ready.load(Ordering::Relaxed) {
        (StatusCode::OK, "ready")
    } else {
        (StatusCode::SERVICE_UNAVAILABLE, "not ready")
    }
}

async fn info(State(state): State<AppState>) -> String {
    format!("env={} admin_token_set={}", state.app_env, state.admin_token_set)
}

async fn shutdown_with_drain(ready: Arc<AtomicBool>, drain: Duration) {
    let mut terminate = signal::unix::signal(signal::unix::SignalKind::terminate())
        .expect("install SIGTERM handler");
    tokio::select! {
        _ = signal::ctrl_c() => {}
        _ = terminate.recv() => {}
    }
    ready.store(false, Ordering::Relaxed);
    tokio::time::sleep(drain).await;
}

#[tokio::main]
async fn main() {
    let addr: SocketAddr = env::var("LISTEN_ADDR")
        .unwrap_or_else(|_| "0.0.0.0:8080".to_owned())
        .parse()
        .expect("LISTEN_ADDR must be host:port");
    let drain_ms: u64 = env::var("DRAIN_DELAY_MS")
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(5_000);

    let ready = Arc::new(AtomicBool::new(false));
    let state = AppState {
        ready: ready.clone(),
        app_env: env::var("APP_ENV").unwrap_or_else(|_| "local".to_owned()),
        admin_token_set: env::var("ADMIN_TOKEN").is_ok(),
    };

    let app = Router::new()
        .route("/livez", get(livez))
        .route("/readyz", get(readyz))
        .route("/info", get(info))
        .with_state(state);

    let listener = TcpListener::bind(addr).await.expect("bind listener");
    ready.store(true, Ordering::Relaxed);

    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_with_drain(ready, Duration::from_millis(drain_ms)))
        .await
        .expect("serve");
}

Run cargo generate-lockfile once and commit Cargo.lock. The Dockerfiles use --locked, which fails the build when the lockfile needs changes, so the image uses the tested dependency versions.

Example 1: A multi-stage Dockerfile

Compiling Rust needs the toolchain, which is hundreds of megabytes. Running the result needs only the binary. A multi-stage build compiles in one stage and copies the binary into a small final stage.

Dockerfile

DOCKERFILE
# syntax=docker/dockerfile:1
FROM rust:1.85-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release --locked

FROM gcr.io/distroless/cc-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/target/release/storefront /app/storefront
EXPOSE 8080
USER nonroot
ENTRYPOINT ["/app/storefront"]

.dockerignore

Text
target/
.git/
.env
k8s/
  • The builder stage uses the full Rust image.
  • The final stage is distroless: C runtime libraries only, no shell and no package manager, so the image is small and has a small attack surface. The cc variant provides the glibc that a default Rust binary links dynamically. Build and runtime stages both use Debian 12.
  • USER nonroot runs the process without root privileges.
  • .dockerignore keeps the build context small and keeps local environment files out of the image.

Build and run:

Terminal
docker build -t storefront:local .
docker run --rm -p 8080:8080 -e APP_ENV=local storefront:local
curl -s localhost:8080/info
Output
env=local admin_token_set=false

The alternatives for the final stage trade size against debuggability. debian:bookworm-slim has a shell. scratch with a musl static binary (x86_64-unknown-linux-musl target) holds only the binary, but DNS and TLS behavior differ from glibc.

Example 2: Cache the dependency build

Docker reuses a layer when its inputs are unchanged. In Example 1 any source change invalidates the layer that compiles all dependencies. The fix is to build dependencies from the manifests alone first.

Changed from Example 1: the builder compiles a placeholder main.rs before the real source is copied.

Dockerfile

DOCKERFILE
# syntax=docker/dockerfile:1
FROM rust:1.85-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src \
    && echo "fn main() {}" > src/main.rs \
    && cargo build --release --locked \
    && rm -rf src
COPY src ./src
RUN touch src/main.rs && cargo build --release --locked

FROM gcr.io/distroless/cc-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/target/release/storefront /app/storefront
EXPOSE 8080
USER nonroot
ENTRYPOINT ["/app/storefront"]
  • The dependency layer is reused until Cargo.toml or Cargo.lock changes.
  • touch updates the timestamp so Cargo rebuilds the real crate instead of reusing the placeholder build.
  • cargo-chef automates the same idea, and BuildKit cache mounts (RUN --mount=type=cache,target=/usr/local/cargo/registry) keep the registry between builds on one builder.
  • Tag images with the Git commit hash. A latest tag moves, so two pods can run different code under one name and a rollback has no target.

Example 3: ConfigMap, Secret, Deployment, and Service

A ConfigMap holds non-secret settings and a Secret holds sensitive values. Both are injected as environment variables, so one image runs in every environment.

k8s/config.yaml

YAML
apiVersion: v1
kind: ConfigMap
metadata:
  name: storefront-config
data:
  LISTEN_ADDR: "0.0.0.0:8080"
  APP_ENV: "production"
  DRAIN_DELAY_MS: "5000"
---
apiVersion: v1
kind: Secret
metadata:
  name: storefront-secrets
type: Opaque
stringData:
  ADMIN_TOKEN: "CHANGE_ME"

k8s/deployment.yaml

YAML
apiVersion: apps/v1
kind: Deployment
metadata:
  name: storefront
spec:
  replicas: 3
  selector:
    matchLabels:
      app: storefront
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: storefront
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: storefront
          image: storefront:local
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8080
          envFrom:
            - configMapRef:
                name: storefront-config
            - secretRef:
                name: storefront-secrets
          resources:
            requests:
              cpu: 100m
              memory: 32Mi
            limits:
              memory: 64Mi
          startupProbe:
            httpGet:
              path: /livez
              port: 8080
            periodSeconds: 2
            failureThreshold: 30
          livenessProbe:
            httpGet:
              path: /livez
              port: 8080
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8080
            periodSeconds: 5

k8s/service.yaml

YAML
apiVersion: v1
kind: Service
metadata:
  name: storefront
spec:
  selector:
    app: storefront
  ports:
    - port: 80
      targetPort: 8080

What each part does:

  • replicas: 3 spreads pods over nodes, so one can fail or restart with no outage.
  • maxSurge: 1 with maxUnavailable: 0 starts one new pod, waits for readiness, then stops one old pod, so capacity never drops.
  • requests are what the scheduler reserves and what the HPA measures against. limits.memory is where the kernel kills the container. A CPU limit throttles the process and raises latency, so it is omitted.
  • The startup probe allows 60 seconds (30 tries every 2 seconds) before liveness applies. A failing liveness probe restarts the container. A failing readiness probe removes the pod from the Service endpoints.
  • terminationGracePeriodSeconds: 30 is the time between SIGTERM and SIGKILL. It exceeds the 5 second drain plus request drain time.
  • Other pods reach the service at http://storefront. A Service balances per connection, so one long-lived HTTP/2 connection (gRPC) sticks to one pod. See production gRPC and gateway and discovery.
  • Do not commit a Secret with real values. Create it from a secret manager or a sealed-secrets tool. Secret values are base64 encoded, not encrypted, so enable encryption at rest and restrict access with RBAC.
  • Environment variables are read at startup, so a config change needs kubectl rollout restart deployment/storefront.

Example 4: Autoscaling and disruption budget

The HPA keeps a metric near a target between a minimum and a maximum. Utilization is measured against the CPU request: with a 100m request and a 70% target, pods scale out when average use passes 70 millicores. The HPA needs a metrics server in the cluster.

k8s/hpa.yaml

YAML
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: storefront
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: storefront
  minReplicas: 3
  maxReplicas: 12
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

k8s/pdb.yaml

YAML
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: storefront
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: storefront
  • CPU is a weak signal for services that wait on databases. Latency, queue depth, or request rate from Prometheus metrics need a metrics adapter such as KEDA.
  • More pods mean more database connections. Set maxReplicas so that replicas times pool size fits under the database limit.
  • The PodDisruptionBudget keeps at least two pods running during node maintenance.

Rollout strategies:

Strategy Behavior Cost
Rolling update Replace pods gradually Old and new versions run together
Recreate Stop all old pods, then start new ones Downtime
Blue-green Run a full new environment, switch traffic at once Double capacity during the switch
Canary Send a small share of traffic to the new version, then increase Needs traffic splitting and metrics

Because old and new pods overlap, database changes follow expand and contract: add the new column (nullable), deploy code that writes both, backfill, deploy code that reads the new column, and remove the old one in a later release. Message and API formats follow the same rule.

Example 5: A CI pipeline

The pipeline runs on every push and produces one verified image, tagged with the commit.

.github/workflows/ci.yml

YAML
name: ci
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy, rustfmt
      - uses: Swatinem/rust-cache@v2
      - run: cargo fmt --all -- --check
      - run: cargo clippy --all-targets --locked -- -D warnings
      - run: cargo test --locked
      - run: cargo install cargo-audit --locked && cargo audit

  image:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/example/storefront:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
Step Catches
cargo fmt --check Style drift
cargo clippy -D warnings Common bugs
cargo test Logic errors. Services with a database add a Postgres service container, as in service testing
cargo audit Dependencies with known advisories
Image build and push Dockerfile errors, and the artifact
  • The image is built once and promoted through environments unchanged.
  • Deployment updates the image tag with kubectl set image deployment/storefront storefront=ghcr.io/example/storefront:<sha> or through a GitOps tool (Argo CD, Flux) that applies a manifest repository.

Complete example

Prerequisites: Rust 1.85 or newer, Docker, kind, kubectl. Run cargo generate-lockfile once so Cargo.lock exists. File tree:

Text
storefront/
├── Cargo.toml
├── Cargo.lock
├── Dockerfile
├── .dockerignore
├── src/
│   └── main.rs
├── k8s/
│   ├── config.yaml
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── hpa.yaml
│   └── pdb.yaml
└── .github/
    └── workflows/
        └── ci.yml

Cargo.toml

TOML
[package]
name = "storefront"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.8.4"
tokio = { version = "1.47", features = ["macros", "rt-multi-thread", "signal", "net", "time"] }

src/main.rs

Rust
use std::{
    env,
    net::SocketAddr,
    sync::{atomic::{AtomicBool, Ordering}, Arc},
    time::Duration,
};

use axum::{extract::State, http::StatusCode, routing::get, Router};
use tokio::{net::TcpListener, signal};

#[derive(Clone)]
struct AppState {
    ready: Arc<AtomicBool>,
    app_env: String,
    admin_token_set: bool,
}

async fn livez() -> &'static str {
    "alive"
}

async fn readyz(State(state): State<AppState>) -> (StatusCode, &'static str) {
    if state.ready.load(Ordering::Relaxed) {
        (StatusCode::OK, "ready")
    } else {
        (StatusCode::SERVICE_UNAVAILABLE, "not ready")
    }
}

async fn info(State(state): State<AppState>) -> String {
    format!("env={} admin_token_set={}", state.app_env, state.admin_token_set)
}

async fn shutdown_with_drain(ready: Arc<AtomicBool>, drain: Duration) {
    let mut terminate = signal::unix::signal(signal::unix::SignalKind::terminate())
        .expect("install SIGTERM handler");
    tokio::select! {
        _ = signal::ctrl_c() => {}
        _ = terminate.recv() => {}
    }
    ready.store(false, Ordering::Relaxed);
    tokio::time::sleep(drain).await;
}

#[tokio::main]
async fn main() {
    let addr: SocketAddr = env::var("LISTEN_ADDR")
        .unwrap_or_else(|_| "0.0.0.0:8080".to_owned())
        .parse()
        .expect("LISTEN_ADDR must be host:port");
    let drain_ms: u64 = env::var("DRAIN_DELAY_MS")
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(5_000);

    let ready = Arc::new(AtomicBool::new(false));
    let state = AppState {
        ready: ready.clone(),
        app_env: env::var("APP_ENV").unwrap_or_else(|_| "local".to_owned()),
        admin_token_set: env::var("ADMIN_TOKEN").is_ok(),
    };

    let app = Router::new()
        .route("/livez", get(livez))
        .route("/readyz", get(readyz))
        .route("/info", get(info))
        .with_state(state);

    let listener = TcpListener::bind(addr).await.expect("bind listener");
    ready.store(true, Ordering::Relaxed);

    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_with_drain(ready, Duration::from_millis(drain_ms)))
        .await
        .expect("serve");
}

Dockerfile

DOCKERFILE
# syntax=docker/dockerfile:1
FROM rust:1.85-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src \
    && echo "fn main() {}" > src/main.rs \
    && cargo build --release --locked \
    && rm -rf src
COPY src ./src
RUN touch src/main.rs && cargo build --release --locked

FROM gcr.io/distroless/cc-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/target/release/storefront /app/storefront
EXPOSE 8080
USER nonroot
ENTRYPOINT ["/app/storefront"]

.dockerignore

Text
target/
.git/
.env
k8s/

k8s/config.yaml

YAML
apiVersion: v1
kind: ConfigMap
metadata:
  name: storefront-config
data:
  LISTEN_ADDR: "0.0.0.0:8080"
  APP_ENV: "production"
  DRAIN_DELAY_MS: "5000"
---
apiVersion: v1
kind: Secret
metadata:
  name: storefront-secrets
type: Opaque
stringData:
  ADMIN_TOKEN: "CHANGE_ME"

k8s/deployment.yaml

YAML
apiVersion: apps/v1
kind: Deployment
metadata:
  name: storefront
spec:
  replicas: 3
  selector:
    matchLabels:
      app: storefront
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: storefront
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: storefront
          image: storefront:local
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8080
          envFrom:
            - configMapRef:
                name: storefront-config
            - secretRef:
                name: storefront-secrets
          resources:
            requests:
              cpu: 100m
              memory: 32Mi
            limits:
              memory: 64Mi
          startupProbe:
            httpGet:
              path: /livez
              port: 8080
            periodSeconds: 2
            failureThreshold: 30
          livenessProbe:
            httpGet:
              path: /livez
              port: 8080
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8080
            periodSeconds: 5

k8s/service.yaml

YAML
apiVersion: v1
kind: Service
metadata:
  name: storefront
spec:
  selector:
    app: storefront
  ports:
    - port: 80
      targetPort: 8080

k8s/hpa.yaml

YAML
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: storefront
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: storefront
  minReplicas: 3
  maxReplicas: 12
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

k8s/pdb.yaml

YAML
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: storefront
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: storefront

.github/workflows/ci.yml

YAML
name: ci
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy, rustfmt
      - uses: Swatinem/rust-cache@v2
      - run: cargo fmt --all -- --check
      - run: cargo clippy --all-targets --locked -- -D warnings
      - run: cargo test --locked
      - run: cargo install cargo-audit --locked && cargo audit

  image:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/example/storefront:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Build, deploy, and try

This uses kind (Kubernetes in Docker). The HPA in k8s/hpa.yaml is applied separately with kubectl apply -f k8s/hpa.yaml on clusters that run a metrics server.

Terminal
docker build -t storefront:local .
kind create cluster --name storefront
kind load docker-image storefront:local --name storefront
kubectl apply -f k8s/config.yaml -f k8s/deployment.yaml -f k8s/service.yaml -f k8s/pdb.yaml
kubectl rollout status deployment/storefront
kubectl get pods -l app=storefront
Output
deployment "storefront" successfully rolled out
NAME                          READY   STATUS    RESTARTS   AGE
storefront-<hash>-<id>        1/1     Running   0          20s
storefront-<hash>-<id>        1/1     Running   0          20s
storefront-<hash>-<id>        1/1     Running   0          20s

Reach the Service and check that ConfigMap and Secret values arrived:

Terminal
kubectl port-forward svc/storefront 8080:80 &
curl -s localhost:8080/readyz
curl -s localhost:8080/info
Output
ready
env=production admin_token_set=true

Restart the pods with a rolling update and undo it:

Terminal
kubectl rollout restart deployment/storefront
kubectl rollout undo deployment/storefront

Summary

  • A Rust service image has two stages: build on rust, run on distroless or slim as a non-root user.
  • Copy manifests first and build dependencies in their own layer so source changes do not rebuild them.
  • A Deployment sets replicas, resources, probes, and the rolling update policy.
  • Liveness restarts, readiness gates traffic, and the startup probe protects slow starts.
  • The grace period must exceed the drain delay plus request drain time.
  • ConfigMap and Secret feed environment variables, and changes need a rollout.
  • The HPA scales between a minimum and a maximum, bounded by database capacity.
  • CI runs format, lint, tests, and audit, then builds one image tagged with the commit.