Introdução
Compilar Rust dentro de um container Docker pode ser frustrante: builds de 15 minutos, imagens de 2 GB e cache que nunca funciona direito. Se você já passou por isso, este guia é para você. Vamos construir um pipeline Docker que gera imagens de menos de 10 MB, com builds incrementais rápidos e seguros para produção.
O segredo está em combinar multi-stage builds, cache inteligente de dependências e targets estáticos com musl. Essas técnicas são usadas por empresas como Cloudflare e Discord nos seus serviços Rust em produção.
O Problema: Build Ingênuo
O Dockerfile mais básico para Rust compila tudo em uma única imagem:
FROM rust:1.88
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/meu-app"]
Esse approach tem três problemas graves:
- Imagem gigante (~1.4 GB): inclui o compilador Rust, todas as ferramentas de build e código-fonte
- Sem cache de dependências: qualquer mudança no código recompila todas as crates
- Superfície de ataque: ferramentas de desenvolvimento disponíveis em produção
Multi-Stage Build: A Base
A primeira otimização é separar o build da imagem final:
# Stage 1: Build
FROM rust:1.88-slim AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
# Stage 2: Runtime
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/meu-app /usr/local/bin/
USER 1000
CMD ["meu-app"]
A imagem final cai de 1.4 GB para ~80 MB. Mas ainda recompilamos tudo a cada mudança no código. Vamos resolver isso.
Cache de Dependências com cargo-chef
O cargo-chef é a ferramenta padrão da comunidade para cache de dependências em Docker. Ele funciona em três etapas:
- Planner: analisa seu projeto e gera um “recipe” — um plano de build das dependências
- Cacher: compila apenas as dependências a partir do recipe
- Builder: compila seu código-fonte usando o cache das dependências
# Stage 1: Planner
FROM rust:1.88-slim AS planner
WORKDIR /app
RUN cargo install cargo-chef
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
# Stage 2: Cache de dependências
FROM rust:1.88-slim AS cacher
WORKDIR /app
RUN cargo install cargo-chef
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
# Stage 3: Build
FROM rust:1.88-slim AS builder
WORKDIR /app
COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo /usr/local/cargo
COPY . .
RUN cargo build --release
# Stage 4: Runtime
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/meu-app /usr/local/bin/
USER 1000
CMD ["meu-app"]
Agora, quando você altera apenas código-fonte, o Docker reutiliza o cache do stage 2. O tempo de rebuild cai de 10+ minutos para segundos em muitos casos.
Usando com Cargo Workspaces
Se seu projeto usa Cargo workspaces, o cargo-chef funciona sem alterações. Ele detecta automaticamente a estrutura do workspace e gera o recipe correto para todos os crates.
Binários Estáticos com musl: Imagens Mínimas
Para imagens realmente pequenas, compile com o target x86_64-unknown-linux-musl e use scratch ou alpine como base:
# Stage 1: Build estático
FROM rust:1.88-slim AS builder
RUN apt-get update && apt-get install -y musl-tools && rm -rf /var/lib/apt/lists/*
RUN rustup target add x86_64-unknown-linux-musl
WORKDIR /app
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl
# Stage 2: Imagem mínima
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/meu-app /
USER 1000
ENTRYPOINT ["/meu-app"]
O resultado? Uma imagem Docker de 5-10 MB contendo apenas seu binário e certificados SSL. Sem shell, sem package manager, sem nada que um atacante possa explorar.
Quando Não Usar musl
Nem toda crate compila limpo com musl. Projetos que dependem de linkagem dinâmica com bibliotecas C (como OpenSSL direto) podem ter problemas. Alternativas:
- Use
rustlsem vez deopensslpara TLS — funciona perfeitamente com musl - Para crates que usam SQLx com PostgreSQL, considere
debian:bookworm-slimem vez descratch - Se usar Diesel, compile com as features corretas para linkagem estática
Otimizações Avançadas de Build
Perfil de Release Otimizado
Configure seu Cargo.toml para gerar binários menores:
[profile.release]
opt-level = "z" # Otimizar para tamanho
lto = true # Link-Time Optimization
codegen-units = 1 # Compilação mais lenta, binário menor
panic = "abort" # Remove overhead de unwinding
strip = true # Remove símbolos de debug
Essas opções podem reduzir o binário de 20 MB para 3-5 MB. O trade-off é um tempo de compilação maior — perfeito para builds de CI/CD onde o tamanho da imagem importa.
Cache com BuildKit
O Docker BuildKit oferece cache de montagem que persiste entre builds:
FROM rust:1.88-slim AS builder
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release && \
cp target/release/meu-app /usr/local/bin/meu-app
O diretório target/ e o registry do Cargo são preservados entre builds, proporcionando compilação verdadeiramente incremental.
Segurança em Produção
Usuário Não-Root
Nunca rode seu aplicativo como root. Use um usuário sem privilégios:
FROM debian:bookworm-slim
RUN groupadd -r appuser && useradd -r -g appuser appuser
COPY --from=builder --chown=appuser:appuser /app/target/release/meu-app /usr/local/bin/
USER appuser
CMD ["meu-app"]
Scanning de Vulnerabilidades
Integre scanning de vulnerabilidades no pipeline:
# Scan da imagem Docker
docker scout cve meu-app:latest
# Scan das dependências Rust
cargo audit
cargo deny check
O cargo audit verifica suas dependências contra o banco de dados RustSec. Combinado com tratamento de erros robusto e testes abrangentes, você tem uma aplicação sólida para produção.
Health Checks
Adicione health checks ao seu Dockerfile:
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["/usr/local/bin/meu-app", "--health-check"]
Se sua aplicação usa Axum, implemente um endpoint /health:
use axum::{Router, routing::get};
async fn health_check() -> &'static str {
"OK"
}
let app = Router::new()
.route("/health", get(health_check));
Exemplo Completo: API Web com Axum
Juntando tudo — cargo-chef, musl, segurança — em um Dockerfile de produção:
FROM rust:1.88-slim AS planner
WORKDIR /app
RUN cargo install cargo-chef
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM rust:1.88-slim AS cacher
WORKDIR /app
RUN apt-get update && apt-get install -y musl-tools && rm -rf /var/lib/apt/lists/*
RUN rustup target add x86_64-unknown-linux-musl
RUN cargo install cargo-chef
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --target x86_64-unknown-linux-musl --recipe-path recipe.json
FROM rust:1.88-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y musl-tools && rm -rf /var/lib/apt/lists/*
RUN rustup target add x86_64-unknown-linux-musl
COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo /usr/local/cargo
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/minha-api /
USER 1000
EXPOSE 3000
ENTRYPOINT ["/minha-api"]
Resultado: imagem de ~8 MB com build incremental rápido e superfície de ataque mínima.
Comparativo de Tamanho de Imagens
| Estratégia | Tamanho da Imagem | Tempo de Rebuild |
|---|---|---|
Build simples (rust:1.88) | ~1.4 GB | 10-15 min |
| Multi-stage + Debian slim | ~80 MB | 10-15 min |
| Multi-stage + cargo-chef | ~80 MB | ~30 seg |
| musl + scratch | ~5-10 MB | 10-15 min |
| cargo-chef + musl + scratch | ~5-10 MB | ~30 seg |
Perguntas Frequentes
Qual a melhor imagem base para Rust em produção?
Para tamanho mínimo e segurança máxima, use scratch com binários compilados para musl. Se sua aplicação precisa de ferramentas do sistema ou bibliotecas C dinâmicas, use debian:bookworm-slim.
O que é cargo-chef e por que usar?
cargo-chef é uma ferramenta que separa o build de dependências do build do seu código. Ele gera um “recipe” que permite ao Docker cachear a camada de dependências, evitando recompilações desnecessárias.
Posso usar Alpine Linux em vez de scratch?
Sim. Alpine usa musl por padrão, então binários compilados com o target musl rodam nativamente. A imagem final fica em torno de 10-15 MB — um pouco maior que scratch, mas com shell e package manager disponíveis para debugging.
Como debugar uma imagem scratch em produção?
Use docker debug (Docker Desktop) ou crie uma imagem de debug separada baseada em debian:bookworm-slim com ferramentas de diagnóstico. Mantenha a imagem de produção limpa e use logging com tracing para observabilidade.
Conclusão
Docker e Rust são uma combinação poderosa quando feitos direito. Com multi-stage builds, cargo-chef e targets estáticos, você consegue imagens de produção minúsculas e seguras, sem sacrificar a velocidade de desenvolvimento.
As técnicas deste artigo se aplicam igualmente a projetos simples e a monorepos com Cargo workspaces. Se você está montando um pipeline de CI/CD para Rust, combine essas práticas com testes automatizados para um fluxo de deploy confiável.
Para quem está explorando outras linguagens com Docker, compare as estratégias de containerização com Go (que também gera binários estáticos nativamente) e Python (onde o tamanho de imagem é um desafio maior).