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
[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
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
# 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
target/
.git/
.env
k8s/- The
builderstage 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
ccvariant provides the glibc that a default Rust binary links dynamically. Build and runtime stages both use Debian 12. USER nonrootruns the process without root privileges..dockerignorekeeps the build context small and keeps local environment files out of the image.
Build and run:
docker build -t storefront:local .
docker run --rm -p 8080:8080 -e APP_ENV=local storefront:local
curl -s localhost:8080/infoenv=local admin_token_set=falseThe 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
# 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.tomlorCargo.lockchanges. touchupdates the timestamp so Cargo rebuilds the real crate instead of reusing the placeholder build.cargo-chefautomates 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
latesttag 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
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
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: 5k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: storefront
spec:
selector:
app: storefront
ports:
- port: 80
targetPort: 8080What each part does:
replicas: 3spreads pods over nodes, so one can fail or restart with no outage.maxSurge: 1withmaxUnavailable: 0starts one new pod, waits for readiness, then stops one old pod, so capacity never drops.requestsare what the scheduler reserves and what the HPA measures against.limits.memoryis 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: 30is the time betweenSIGTERMandSIGKILL. 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
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: 70k8s/pdb.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
maxReplicasso 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
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:
storefront/
├── Cargo.toml
├── Cargo.lock
├── Dockerfile
├── .dockerignore
├── src/
│ └── main.rs
├── k8s/
│ ├── config.yaml
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── hpa.yaml
│ └── pdb.yaml
└── .github/
└── workflows/
└── ci.ymlCargo.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
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
# 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
target/
.git/
.env
k8s/k8s/config.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
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: 5k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: storefront
spec:
selector:
app: storefront
ports:
- port: 80
targetPort: 8080k8s/hpa.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: 70k8s/pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: storefront
spec:
minAvailable: 2
selector:
matchLabels:
app: storefront.github/workflows/ci.yml
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=maxBuild, 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.
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=storefrontdeployment "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 20sReach the Service and check that ConfigMap and Secret values arrived:
kubectl port-forward svc/storefront 8080:80 &
curl -s localhost:8080/readyz
curl -s localhost:8080/infoready
env=production admin_token_set=trueRestart the pods with a rolling update and undo it:
kubectl rollout restart deployment/storefront
kubectl rollout undo deployment/storefrontSummary
- 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.
