Rust #
Rust sering diposisikan sebagai “bahasa paling ideal” untuk container karena mampu menghasilkan single static binary. Single binary berarti tidak ada runtime terpisah, tidak ada dependency tree, tidak ada native module yang harus di-compile. Image Rust secara teori bisa sekecil 5-15 MB.
Tapi realitanya, Rust di production tetap punya jebakan:
- Image membengkak karena base image yang salah (glibc, musl).
- Build time lama (terutama untuk dependency yang banyak).
- TLS dan libc dependency sering tidak disadari.
- Debug dan observability sering diabaikan demi ukuran kecil.
- Static binary tidak selalu default — perlu konfigurasi disengaja.
Artikel ini membahas strategi Docker image Rust yang kecil, aman, dan benar-benar production-grade, bukan sekadar demo FROM scratch.
1. Realita Ukuran Image Rust #
| Setup | Ukuran Image |
|---|---|
rust:latest (single stage, no optimization) |
1.2-1.6 GB |
rust:latest + slim runtime |
150-250 MB |
rust:alpine + multi-stage + alpine |
40-80 MB |
| Multi-stage + distroless cc | 15-40 MB |
| Static binary (musl) + scratch | 5-15 MB |
Insight: Selisih antara 1.6 GB dan 5 MB adalah ratusan kali lipat. Image Rust tanpa strategi yang benar adalah pemborosan luar biasa, karena Rust sebenarnya sangat capable menghasilkan image yang sangat kecil.
2. Kenapa Image Rust Tetap Bisa Besar? #
Build Toolchain Sangat Besar #
Rust toolchain (rustc, cargo, LLVM, linker) itu sendiri ratusan MB dan tidak pernah dibutuhkan di runtime. Tanpa multi-stage, semua tool ini masuk image runtime.
Default Binary Dinamis #
Secara default, Rust link binary ke glibc di Linux. Binary yang bergantung glibc:
- Butuh image base yang punya glibc (
debian,ubuntu,alpinedengan gcompat,distroless/cc). - Tidak bisa pakai
scratch(kosong, tanpa libc). - Ukuran image minimum ~50 MB hanya untuk runtime.
TLS dan libc adalah Silent Dependency #
Crate seperti reqwest, openssl, native-tls menarik dependency runtime yang tidak langsung terlihat dari kode. Misalnya reqwest dengan native-tls butuh OpenSSL di runtime image.
Crate yang menarik dependency native:
reqwest(default) +native-tls→ butuh OpenSSL.reqwest+rustls→ TLS pure Rust, tanpa dependency native.openssl→ butuh OpenSSL di runtime.ring→ biasanya pure Rust, tapi cek.libsqlite3-sys→ butuh SQLite.
Cara cek dependency native:
# Build binary, lalu cek dynamic library
ldd target/release/myapp
Output yang perlu diperhatikan:
libssl.so→ butuh OpenSSL.libcrypto.so→ butuh OpenSSL.libc.so.6→ butuh glibc.libm.so.6→ butuh glibc.not a dynamic executable→ static binary, tidak butuh libc apapun.
3. Prinsip Utama: Binary + Certs, Titik #
Runtime image Rust idealnya hanya berisi binary aplikasi + CA certificates (untuk HTTPS).
Titik. Tidak ada compiler, tidak ada linker, tidak ada source code, tidak ada build cache, tidak ada cargo, tidak ada rustc.
4. Strategi Multi-Stage Build #
4.1 Pola Dasar: Debian Slim Runtime #
# syntax=docker/dockerfile:1.7
# ==== Stage 1: Build ====
FROM rust:1.79-bookworm AS builder
WORKDIR /app
# Install build dependency (kalau ada)
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Cache dependency
COPY Cargo.toml Cargo.lock ./
RUN mkdir src \
&& echo "fn main() { println!(\"placeholder\"); }" > src/main.rs \
&& cargo build --release \
&& rm -rf src target/release/deps/myapp*
# Build aplikasi
COPY src ./src
COPY tests ./tests
RUN cargo build --release --locked
# Strip binary
RUN strip target/release/myapp
# ==== Stage 2: Runtime ====
FROM debian:bookworm-slim
WORKDIR /app
# Install CA certificates untuk HTTPS
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copy binary
COPY --from=builder /app/target/release/myapp /usr/local/bin/myapp
# Non-root user
RUN groupadd -r app && useradd -r -g app -d /app -s /bin/bash app
USER app
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/myapp"]
Ukuran tipikal: 40-80 MB.
Penjelasan penting:
Stage 1: Build
rust:1.79-bookworm— image Rust official dengan toolchain lengkap.pkg-config,libssl-dev— untuk dependency native (OpenSSL).- Cache trick: buat dummy
main.rs, build untuk cache dependency, hapus, lalu build sebenarnya. Ini memaksimalkan Docker layer cache — perubahan kode tidak invalidate download dependency. cargo build --release --locked— build mode release (optimized) dengan lock file.strip target/release/myapp— hapus debug symbol, kurangi ukuran 20-30%.
Stage 2: Runtime
debian:bookworm-slim— Debian minimal.glibcsudah built-in.ca-certificates— untuk HTTPS. Tanpa ini,reqwestke HTTPS akan gagal.- Non-root user untuk security.
Kapan pakai: Default untuk production service Rust. Trade-off ukuran vs debugging能力.
4.2 Distroless Runtime — Production Mature #
Distroless membawa glibc, CA certs, dan tzdata, tanpa shell:
# ==== Stage 1: Build ====
FROM rust:1.79-bookworm AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
RUN mkdir src \
&& echo "fn main() { println!(\"placeholder\"); }" > src/main.rs \
&& cargo build --release \
&& rm -rf src target/release/deps/myapp*
COPY src ./src
RUN cargo build --release --locked \
&& strip target/release/myapp
# ==== Stage 2: Runtime Distroless ====
FROM gcr.io/distroless/cc-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/target/release/myapp /app/myapp
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/myapp"]
Ukuran tipikal: 15-40 MB.
Catatan penting:
gcr.io/distroless/cc-debian12:nonroot— distroless dengan glibc + libgcc + ca-certificates + tzdata. Cocok untuk binary yang link ke glibc.- Tag
:nonrootsudah include usernonroot. - Tidak ada shell — debugging interaktif tidak memungkinkan.
- HEALTHCHECK di Dockerfile sulit — pindahkan ke orchestrator.
4.3 Static Binary dengan musl + scratch — Paling Kecil #
Untuk image paling kecil, build binary yang fully static dan gunakan scratch (kosong).
Build dengan musl target:
# ==== Stage 1: Build ====
FROM rust:1.79-bookworm AS builder
WORKDIR /app
# Install musl target
RUN rustup target add x86_64-unknown-linux-musl
# Install musl-tools untuk linker
RUN apt-get update && apt-get install -y --no-install-recommends \
musl-tools \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# Cache dependency
COPY Cargo.toml Cargo.lock ./
RUN mkdir src \
&& echo "fn main() { println!(\"placeholder\"); }" > src/main.rs \
&& cargo build --release --target x86_64-unknown-linux-musl \
&& rm -rf src target/x86_64-unknown-linux-musl/release/deps/myapp*
# Build
COPY src ./src
RUN cargo build --release --target x86_64-unknown-linux-musl --locked \
&& strip target/x86_64-unknown-linux-musl/release/myapp
# ==== Stage 2: Runtime Scratch ====
FROM scratch
# Copy binary
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /app
# Untuk HTTPS, copy CA certificates
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Tidak ada user, karena scratch tidak punya /etc/passwd
# Binary harus jalan sebagai default (root) atau kita setup user nanti
EXPOSE 8080
ENTRYPOINT ["/app"]
Ukuran tipikal: 5-15 MB.
Catatan penting untuk scratch:
- Tidak ada
/etc/passwd—USERdirective tidak bekerja langsung. Perlu tambahkan file passwd manual atau terima default. - Tidak ada DNS resolver —
getaddrinfoakan gagal kecuali binary di-link dengangetaddrinfobuiltin atau pakai static DNS crate. - Tidak ada timezone data —
chronodenganLocal::now()akan error. - Tidak ada CA certs — sudah di-copy manual.
Solusi untuk non-root di scratch:
# Setup minimal passwd
FROM scratch
# Import user dari builder
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group
# Copy binary
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 1001:1001
ENTRYPOINT ["/app"]
Solusi untuk DNS di scratch:
Pakai crate dns-lookup atau hickory-resolver yang static, atau link ke getaddrinfo dari musl.
4.4 TLS dengan rustls (Pure Rust) #
Cara menghindari dependency OpenSSL native: pakai rustls di reqwest:
# Cargo.toml
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
rustls adalah TLS implementation pure Rust. Tidak butuh OpenSSL di runtime. Cocok untuk scratch dan image paling minimal.
Catatan:
rustls-tlslebih kecil dan lebih portable.- Tidak kompatibel dengan semua fitur OpenSSL (misal FIPS).
- Untuk enterprise yang butuh OpenSSL/FIPS, tetap pakai
native-tls.
5. Optimasi Binary Size #
5.1 Strip Binary #
RUN strip target/release/myapp
Menghapus debug symbol dan DWARF info. Menurunkan ukuran 20-30%.
5.2 strip = "symbols" di Cargo.toml
#
# Cargo.toml
[profile.release]
strip = "symbols"
Sama efeknya dengan strip command, tapi otomatis saat cargo build --release.
5.3 LTO (Link-Time Optimization) #
# Cargo.toml
[profile.release]
lto = true
LTO memungkinkan compiler mengoptimasi seluruh program saat linking. Hasilnya binary lebih kecil (5-15%) dan bisa lebih cepat. Trade-off: build time naik signifikan (bisa 2-5x lebih lama).
5.4 panic = "abort"
#
# Cargo.toml
[profile.release]
panic = "abort"
Menghilangkan unwinding code. Binary lebih kecil dan tidak perlu libunwind. Trade-off: stack trace saat panic jadi minimal.
5.5 Optimasi Code (codegen-units) #
# Cargo.toml
[profile.release]
codegen-units = 1
Satu code generation unit memungkinkan optimasi lebih agresif. Binary lebih kecil dan lebih cepat, tapi build time naik.
5.6 opt-level = "z" atau "s"
#
# Cargo.toml
[profile.release]
opt-level = "z" # optimize for size
opt-level = "z" (size) atau "s" (small) mengorbankan sedikit performa untuk ukuran lebih kecil. Untuk banyak workload service, ini trade-off yang bagus.
5.7 Kombinasi Optimal #
# Cargo.toml
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = "symbols"
Kombinasi ini menghasilkan binary paling kecil yang masih cukup cepat untuk kebanyakan service.
6. Caching Strategy #
Rust dependency cache (cargo build untuk dependency) sangat efektif untuk Docker layer cache:
# Step 1: Copy manifest
COPY Cargo.toml Cargo.lock ./
# Step 2: Buat dummy main untuk trigger compile dependency
RUN mkdir src \
&& echo "fn main() {}" > src/main.rs \
&& cargo build --release
# Step 3: Hapus dummy
RUN rm -rf src
# Step 4: Copy source sebenarnya
COPY src ./src
# Step 5: Build sebenarnya (dependency sudah di-cache)
RUN cargo build --release
Pola ini memastikan:
Cargo.toml/Cargo.locktidak berubah = layer dependency di-reuse.- Source code berubah = hanya rebuild source, dependency di-reuse.
Alternatif: cargo-chef
Tool cargo-chef mengotomasi pola di atas:
FROM lukemathwalker/cargo-chef:latest-rust-1.79-bookworm AS chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --locked
cargo-chef lebih reliable dan idiomatis untuk project besar.
7. Static vs Dynamic Linking #
Kapan Pakai Static (musl) #
Pro:
- Image paling kecil (5-15 MB).
- Bisa pakai
scratch. - Tidak ada dependency runtime.
Kontra:
- Beberapa crate native tidak support musl.
- Performance TLS sedikit lebih lambat (rustls vs OpenSSL).
- Debugging lebih sulit (no standard libc).
Kapan Pakai Dynamic (glibc) #
Pro:
- Semua crate native biasanya support.
- Performa TLS lebih baik (kalau pakai OpenSSL).
- Debugging lebih mudah (ada libc, ada tools).
Kontra:
- Image lebih besar (15-40 MB dengan distroless).
- Butuh base image dengan glibc.
Rekomendasi:
- Static (musl) untuk CLI tools, agent, sidecar, microservice sederhana.
- Dynamic (glibc) untuk service yang pakai banyak native dep, atau yang perlu performa TLS tinggi.
8. Healthcheck, Logging, dan Signal #
Healthcheck #
Buat endpoint HTTP di Rust service (axum, actix-web, warp, dll):
async fn health() -> &'static str {
"ok"
}
Di Dockerfile (untuk image dengan shell):
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1
Untuk distroless/scratch — pindahkan ke orchestrator probe.
Logging #
Gunakan crate structured logger (tracing, slog, log + env_logger):
use tracing::{info, Level};
use tracing_subscriber::FmtSubscriber;
let subscriber = FmtSubscriber::builder()
.with_max_level(Level::INFO)
.json()
.with_writer(std::io::stdout)
.finish();
tracing::subscriber::set_global_default(subscriber).unwrap();
info!(path = "/health", status = 200, "request completed");
Prinsip:
- Log ke STDOUT (atau STDERR untuk error).
- Format JSON untuk log aggregator.
- Include trace ID untuk distributed tracing.
Signal Handling #
Tokio, hyper, axum, actix-web sudah handle SIGTERM dengan benar secara default, tapi pastikan:
- Exec form di
ENTRYPOINT(ENTRYPOINT ["/app/myapp"]). - Graceful shutdown untuk in-flight request:
use tokio::signal;
let shutdown = async {
signal::ctrl_c().await.expect("failed to install CTRL+C handler");
};
axum::Server::bind(&addr)
.serve(app.into_make_service())
.with_graceful_shutdown(shutdown)
.await
.unwrap();
9. Security Hardening #
Non-Root User #
# Untuk distroless
USER nonroot:nonroot
# Untuk debian-slim
RUN groupadd -r app && useradd -r -g app -d /app -s /bin/bash app
USER app
# Untuk scratch (lihat strategi non-root di atas)
Vulnerability Scanning #
- name: Build
run: docker build -t myapp:${{ github.sha }} .
- name: Scan
run: trivy image --exit-code 1 --severity CRITICAL myapp:${{ github.sha }}
Image Rust relatif kecil dan sedikit dependency, tapi base image (debian, distroless) tetap perlu di-patch. Rebuild berkala.
Read-Only Filesystem #
Service Rust well-written biasanya bekerja dengan read-only root:
docker run --read-only --tmpfs /tmp myapp
10. Anti-Pattern yang Harus Dihindari #
✗ Single Stage Build #
// ✗ Compiler, cargo, source code semua di image final
FROM rust:1.79
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/myapp"]
Solusi: Multi-stage, runtime stage ramping.
✗ Pakai rust:latest
#
// ✗ Build tidak deterministik
FROM rust:latest
Solusi: Pin tag: rust:1.79.0-bookworm.
✗ Build dengan Default Target (glibc) #
// ✗ Binary tidak static, harus pakai base image dengan glibc
FROM rust:1.79-bookworm AS builder
RUN cargo build --release
// binary sekarang butuh glibc
Solusi: Build dengan musl target (--target x86_64-unknown-linux-musl) untuk static binary.
✗ Pakai OpenSSL Native #
# Cargo.toml
reqwest = "0.12" # default features include native-tls
Solusi: Pakai rustls-tls untuk pure Rust TLS.
✗ Copy Source ke Runtime #
// ✗ Source code .rs ada di runtime image
FROM debian:bookworm-slim
WORKDIR /app
COPY . .
COPY --from=builder /app/target/release/myapp /app
Solusi: Hanya copy binary dari build stage.
✗ Tidak Pakai Lock File #
# ✗ Build tidak reproducible
cargo build --release
Solusi: cargo build --release --locked dengan Cargo.lock di-commit.
11. Contoh Dockerfile Rust Production-Grade #
11.1 Versi Distroless (Recommended) #
# syntax=docker/dockerfile:1.7
# ==== Stage 1: Build ====
FROM rust:1.79.0-bookworm AS chef
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/* \
&& cargo install --locked cargo-chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --locked \
&& strip target/release/myapp
# ==== Stage 2: Runtime ====
FROM gcr.io/distroless/cc-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/target/release/myapp /app/myapp
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/myapp"]
11.2 Versi Static Binary + Scratch (Paling Kecil) #
# syntax=docker/dockerfile:1.7
FROM rust:1.79.0-bookworm AS chef
RUN apt-get update && apt-get install -y --no-install-recommends \
musl-tools \
pkg-config \
&& rm -rf /var/lib/apt/lists/* \
&& rustup target add x86_64-unknown-linux-musl \
&& cargo install --locked cargo-chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json --target x86_64-unknown-linux-musl
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json --target x86_64-unknown-linux-musl
COPY . .
ENV CC_x86_64_unknown_linux_musl=musl-gcc
RUN cargo build --release --target x86_64-unknown-linux-musl --locked \
&& strip target/x86_64-unknown-linux-musl/release/myapp
# ==== Runtime: Scratch ====
FROM scratch
# Setup minimal passwd
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group
# Copy binary dan CA certs
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /app/myapp
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 1001:1001
EXPOSE 8080
ENTRYPOINT ["/app/myapp"]
Karakteristik:
- Ukuran: 5-15 MB.
- Fully static binary.
- CA certs di-copy manual.
- Non-root user dengan UID eksplisit.
12. Kapan Pakai Strategi Mana #
| Kondisi | Pilihan | Alasan |
|---|---|---|
| Service production standar | distroless/cc | Ukuran kecil, observability solid |
| Microservice ringan / CLI | scratch + static | Ukuran paling kecil |
| Aplikasi dengan banyak native dep | distroless/cc | Butuh glibc |
| Sidecar / agent | scratch + static | Cold start time, ukuran |
| Serverless (Lambda container) | scratch + static | Cold start time kritis |
| Aplikasi tanpa HTTPS | scratch + static | Tidak perlu certs |
13. Checklist Review Dockerfile Rust #
BASE IMAGE:
□ Tag eksplisit (rust:1.79.0-bookworm, bukan latest)
□ Runtime stage ramping (debian-slim, distroless, scratch)
□ Bukan rust:latest (terlalu besar)
BUILD:
□ Multi-stage build
□ cargo build --release --locked
□ strip binary
□ Cache dependency (cargo-chef atau dummy main)
□ Build tool hanya di build stage
OPTIMASI:
□ lto = true (jika build time OK)
□ codegen-units = 1
□ panic = "abort"
□ strip = "symbols" atau strip command
□ opt-level = "z" (jika optimasi size prioritas)
LINKING:
□ musl target untuk static binary
□ rustls-tls bukan native-tls (untuk pure Rust TLS)
□ Cek ldd output — tidak ada dependency glibc (untuk scratch)
RUNTIME:
□ USER nonroot
□ CA certificates di-copy
□ Log ke STDOUT
□ Healthcheck (jika shell ada)
□ Signal handling (graceful shutdown)
□ ENTRYPOINT dalam exec form
UKURAN:
□ < 80 MB untuk debian-slim runtime
□ < 40 MB untuk distroless runtime
□ < 15 MB untuk scratch + static
□ docker history tidak ada layer aneh
KEAMANAN:
□ Tidak ada secret di image
□ .dockerignore tegas
□ Image di-scan trivy/grype
□ Non-root user
□ Base image up-to-date
TLS:
□ reqwest dengan rustls-tls (default features off)
□ CA certs di-copy ke runtime image
□ Custom TLS config di-test
Ringkasan #
- Image Rust ramping sangat mungkin — Rust adalah bahasa paling capable untuk container, dengan potensi 5-15 MB. Tapi butuh Dockerfile yang disiplin.
- Realita ukuran: 5-15 MB (scratch + static), 15-40 MB (distroless/cc), 40-80 MB (debian-slim), 150-250 MB (rust:slim runtime), 1.2-1.6 GB (rust:latest — anti-pattern).
- Multi-stage build wajib — compiler, cargo, dan LLVM harus berhenti di build stage. Runtime image hanya berisi binary + ca-certificates.
- Static binary dengan musl untuk image paling kecil —
--target x86_64-unknown-linux-muslmemungkinkanscratchruntime.rustls-tlsbukannative-tls— pure Rust TLS, tanpa dependency OpenSSL. Cocok untuk static binary dan image paling ramping.- Strip binary —
strip = "symbols"di Cargo.toml ataustripcommand. Menurunkan ukuran 20-30%.- LTO +
panic = "abort"+opt-level = "z"untuk binary paling kecil. Trade-off: build time naik.- Caching dengan cargo-chef —
cargo chef prepare+cargo chef cookadalah pola modern untuk cache dependency Rust. Lebih reliable dari dummy main trick.- Distroless untuk default production —
gcr.io/distroless/cc-debian12:nonrootmembawa glibc + ca-certificates tanpa shell. Ukuran 15-40 MB.- CA certificates wajib untuk HTTPS —
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/agar HTTPS call tidak gagal.- Tag eksplisit, bukan
latest—rust:1.79.0-bookworm. Build reproducibility penting.- Cek
lddoutput — pastikan binary yang kamu build tidak menarik dependency native yang tidak diharapkan.not a dynamic executableartinya static.- Image ramping butuh observability solid — log JSON ke STDOUT, metrics endpoint, healthcheck, graceful shutdown. Rust bisa 5-15 MB tanpa mengorbankan operasional.
- Scratch untuk CLI/agent/microservice kecil — service production yang lebih besar biasanya lebih cocok dengan distroless untuk stabilitas.