Stage 4: Containers, and images six times smaller
The company is now a household name. Traffic spikes by orders of magnitude when the app goes viral, and the backend has grown into fourteen services, so shipping fourteen AMIs no longer scales. Static Rust binaries on empty base images, cross-compiled without emulation, run by Podman Compose. The backend image went from 137 MB to 23.1 MB.
137 → 23.1 MB- Role
- Team project; I built the container images and local stack
- Scale
- 14 service images
- Stack
- Podman, Compose, Rust
- Targets
- Small, rootless images that build on a laptop
Context
The product kept growing. The backend split into per-domain services and workers, fourteen binaries in all. Baking a golden image for each, as in stage 3, meant fourteen slow image builds for every release. Containers were the next step, and this stage is about making them small and safe before anything orchestrates them.
Constraints:
- Images must run without root
- Builds have to work on a laptop and in CI alike
- Fourteen services, so per-image size multiplies
Architecture and failover
Each service compiles to a static binary against musl and ships on a scratch base that holds only the binary and CA certificates. It runs as an unprivileged user. cargo-chef caches the dependency layer, so a code-only change rebuilds in a fraction of the time. Images cross-compile for each architecture without emulation.
- 1Cache dependencies
cargo-chef builds the dependency layer once.
- 2Cross-compile
A static musl binary for each target, without emulation.
If it fails: Builders under 8 GiB run out of memory; move the build.
- 3Assemble on scratch
Only the binary and CA certificates, with a non-root user.
- 4DeployRun with Podman Compose
Postgres, Redis, NATS, the backend and three workers.
Podman Compose runs the whole stack locally: Postgres, Redis, NATS, the backend and its three workers. The same images later run on Kubernetes, and the node preparation scripts for kubeadm on Ubuntu and Rocky Linux are part of this stage.
Key decisions
Static binaries on an empty base image
The first backend image carried a full OS layer for a program that needs almost none of it.
Trade-off: An empty image has no shell, so debugging needs an ephemeral sidecar, and a container HEALTHCHECK command cannot run inside it.
Cross-compilation instead of emulation
Building ARM and x86 images by emulation is slow, and the Rust compiler runs out of memory on small builders.
Trade-off: Cross-compiling needs musl toolchains and workarounds for upstream bugs in two C libraries.
Lifecycle
- cargo-chef builds and caches the dependency layer.
- The service compiles statically for the target architecture.
- The build copies the binary and CA certificates onto
scratchand sets a non-root user.
podman compose upstarts the data services and every backend process.- Each service reads its configuration from the environment.
- The runtime checks health from outside the container, since
scratchhas no shell.
Failure modes
| Failure | Blast radius | Detection | Recovery |
|---|---|---|---|
| Builder out of memory | Build fails | rustc killed | Builders with 8 to 10 GiB |
| Missing CA certificates | Every outbound TLS call fails | Errors at first request | CA bundle copied into every image |
| Debugging a running container | No shell inside | Engineer needs one | Ephemeral debug sidecar |
Results
What I'd change
I would remove the logging library that pulled in a Kafka client and its C dependencies before writing a single Containerfile. It caused most of the cross-compilation trouble, and nothing in the product used Kafka.
I would commit the container work as I built it. Mine stayed uncommitted while the team moved on, so the images and the measurements lived on one laptop.
I would add a health check that works without a shell, such as a small endpoint the orchestrator probes, before any of these images reach Kubernetes.
Next stage: the containers move onto Kubernetes, managed through GitOps.