Container-Based Deployment
Building optimized Docker containers for Rust MCP servers requires understanding the unique characteristics of Rust binaries and the Cloud Run execution environment. This lesson covers advanced Dockerfile patterns, image optimization, and container best practices.
Learning Objectives
By the end of this lesson, you will:
- Create highly optimized multi-stage Dockerfiles for Rust
- Minimize container image size for faster deployments
- Implement proper caching strategies for faster builds
- Configure containers for Cloud Run's execution model
- Handle cross-compilation for different architectures
Why Container Size Matters
┌─────────────────────────────────────────────────────────────────────┐
│ Container Size Impact │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Image Size Pull Time Cold Start Registry Cost │
│ ────────── ────────── ────────── ──────────── │
│ 10MB ~1s ~2s $0.10/GB │
│ 50MB ~3s ~4s $0.10/GB │
│ 100MB ~5s ~6s $0.10/GB │
│ 500MB ~15s ~17s $0.10/GB │
│ 1GB+ ~30s+ ~35s+ $0.10/GB │
│ │
│ Target for Rust MCP servers: <50MB │
│ │
└─────────────────────────────────────────────────────────────────────┘
Rust's Container Advantage
Rust produces statically-linked binaries that can run in minimal containers:
┌─────────────────────────────────────────────────────────────────────┐
│ Language Container Comparison │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Language Base Image Runtime Deps Typical Size │
│ ───────── ────────── ──────────── ──────────── │
│ Python python:3.11 pip packages 500MB-1GB │
│ Node.js node:20 npm packages 300MB-800MB │
│ Java eclipse-temurin JRE 400MB-600MB │
│ Go scratch/alpine none 10MB-50MB │
│ Rust scratch/alpine ca-certs only 5MB-30MB │
│ │
└─────────────────────────────────────────────────────────────────────┘
Multi-Stage Build Patterns
Basic Multi-Stage Dockerfile
# Stage 1: Build
FROM rust:1.75-slim-bookworm AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy and build
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/my-mcp-server /usr/local/bin/
CMD ["my-mcp-server"]
Optimized Multi-Stage with Dependency Caching
This pattern separates dependency compilation from source compilation for much faster rebuilds:
# Stage 1: Chef - prepare recipe
FROM rust:1.75-slim-bookworm AS chef
RUN cargo install cargo-chef
WORKDIR /app
# Stage 2: Planner - analyze dependencies
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
# Stage 3: Builder - build dependencies first, then source
FROM chef AS builder
# Install build dependencies
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Build dependencies (cached layer)
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
# Build application
COPY . .
RUN cargo build --release
# Stage 4: Runtime
FROM debian:bookworm-slim AS runtime
# Install only runtime dependencies
RUN apt-get update && apt-get install -y \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN useradd -m -u 1000 appuser
USER appuser
WORKDIR /app
COPY --from=builder /app/target/release/my-mcp-server .
ENV PORT=8080
EXPOSE 8080
CMD ["./my-mcp-server"]
Minimal Scratch-Based Container
For the smallest possible image when you don't need a shell:
# Stage 1: Build with musl for static linking
FROM rust:1.75-alpine AS builder
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static
WORKDIR /app
# Build with musl target
COPY . .
RUN RUSTFLAGS='-C target-feature=+crt-static' \
cargo build --release --target x86_64-unknown-linux-musl
# Stage 2: Scratch runtime (no OS, just binary)
FROM scratch
# Copy CA certificates for HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy binary
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/my-mcp-server /
# Set user (numeric, since scratch has no /etc/passwd)
USER 1000
ENV PORT=8080
EXPOSE 8080
ENTRYPOINT ["/my-mcp-server"]
Distroless Runtime
Google's distroless images provide a middle ground - minimal but with some debugging capabilities:
# Stage 1: Build
FROM rust:1.75-slim-bookworm AS builder
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
RUN cargo build --release
# Stage 2: Distroless runtime
FROM gcr.io/distroless/cc-debian12
COPY --from=builder /app/target/release/my-mcp-server /
ENV PORT=8080
EXPOSE 8080
USER nonroot
ENTRYPOINT ["/my-mcp-server"]
Build Optimization Strategies
Cargo Configuration for Smaller Binaries
# Cargo.toml
[profile.release]
opt-level = "z" # Optimize for size (smallest)
lto = true # Link-time optimization
codegen-units = 1 # Single codegen unit
panic = "abort" # No unwinding code
strip = true # Strip symbols
# For production with balance of size and speed
[profile.release-optimized]
inherits = "release"
opt-level = 3 # Optimize for speed
lto = "thin" # Faster LTO
Reducing Binary Size
# Check binary size before optimization
cargo build --release
ls -lh target/release/my-mcp-server
# Before: 15MB
# After Cargo.toml optimizations
cargo build --release
ls -lh target/release/my-mcp-server
# After: 5MB
# Additional stripping (if strip=true not in Cargo.toml)
strip target/release/my-mcp-server
# After strip: 3MB
# UPX compression (optional, trades startup time for size)
upx --best target/release/my-mcp-server
# After UPX: 1.5MB (but slower startup)
Dependency Audit
Remove unused dependencies to reduce compile time and binary size:
# Find unused dependencies
cargo install cargo-udeps
cargo +nightly udeps
# Analyze dependency tree
cargo tree --duplicates
# Check feature flags being used
cargo tree -e features
Conditional Compilation
Use feature flags to include only what you need:
# Cargo.toml
[features]
default = ["postgres"]
postgres = ["sqlx/postgres"]
mysql = ["sqlx/mysql"]
sqlite = ["sqlx/sqlite"]
full = ["postgres", "mysql", "sqlite"]
# Build with specific features
RUN cargo build --release --no-default-features --features postgres
Cross-Compilation
Building for Different Architectures
Cloud Run supports both AMD64 and ARM64. ARM64 can be cheaper and more efficient:
# Cross-compilation for ARM64
FROM --platform=$BUILDPLATFORM rust:1.75-slim-bookworm AS builder
ARG TARGETPLATFORM
ARG BUILDPLATFORM
# Install cross-compilation tools
RUN case "$TARGETPLATFORM" in \
"linux/arm64") \
apt-get update && apt-get install -y \
gcc-aarch64-linux-gnu \
libc6-dev-arm64-cross \
&& rustup target add aarch64-unknown-linux-gnu \
;; \
"linux/amd64") \
apt-get update && apt-get install -y \
gcc \
libc6-dev \
;; \
esac && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
# Build for target platform
RUN case "$TARGETPLATFORM" in \
"linux/arm64") \
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
cargo build --release --target aarch64-unknown-linux-gnu \
&& cp target/aarch64-unknown-linux-gnu/release/my-mcp-server target/release/ \
;; \
"linux/amd64") \
cargo build --release \
;; \
esac
# Runtime stage
FROM --platform=$TARGETPLATFORM 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/my-mcp-server /usr/local/bin/
CMD ["my-mcp-server"]
Building Multi-Architecture Images
# Enable buildx
docker buildx create --use
# Build for multiple architectures
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t us-central1-docker.pkg.dev/PROJECT/mcp-servers/my-mcp-server:v1 \
--push \
.
Deploying ARM64 to Cloud Run
# Deploy specifying ARM64
gcloud run deploy my-mcp-server \
--image us-central1-docker.pkg.dev/PROJECT/mcp-servers/my-mcp-server:v1 \
--platform managed \
--cpu-boost \
--execution-environment gen2 # Required for ARM
Container Security
Non-Root User
Always run as non-root:
# Create user in builder stage if needed
FROM debian:bookworm-slim AS runtime
# Create non-root user with specific UID
RUN groupadd -r -g 1000 appgroup && \
useradd -r -u 1000 -g appgroup -s /sbin/nologin appuser
# Set ownership of application files
COPY --from=builder --chown=appuser:appgroup /app/target/release/my-mcp-server /app/
# Switch to non-root user
USER appuser
WORKDIR /app
CMD ["./my-mcp-server"]
Read-Only Filesystem
Configure Cloud Run to use read-only container filesystem:
# service.yaml
spec:
template:
spec:
containers:
- image: my-image
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 100Mi
Vulnerability Scanning
# Scan with Trivy
trivy image us-central1-docker.pkg.dev/PROJECT/mcp-servers/my-mcp-server:v1
# Scan with Google's scanner
gcloud artifacts docker images scan \
us-central1-docker.pkg.dev/PROJECT/mcp-servers/my-mcp-server:v1
# Enable automatic scanning in Artifact Registry
gcloud artifacts repositories update mcp-servers \
--location=us-central1 \
--enable-vulnerability-scanning
Security Labels
# Add security-related labels
LABEL org.opencontainers.image.source="https://github.com/org/repo" \
org.opencontainers.image.revision="abc123" \
org.opencontainers.image.created="2024-01-15T10:00:00Z" \
org.opencontainers.image.licenses="MIT"
Health Checks and Probes
Dockerfile Health Check
# Install curl for health checks (debian-based)
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
Native Rust Health Check (No curl)
Build a tiny health check binary:
// src/bin/healthcheck.rs use std::net::TcpStream; use std::process::exit; use std::time::Duration; fn main() { let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string()); let addr = format!("127.0.0.1:{}", port); match TcpStream::connect_timeout( &addr.parse().unwrap(), Duration::from_secs(2), ) { Ok(_) => exit(0), Err(_) => exit(1), } }
# Copy both binaries
COPY --from=builder /app/target/release/my-mcp-server .
COPY --from=builder /app/target/release/healthcheck .
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["./healthcheck"]
Cloud Run Probes
# service.yaml
spec:
template:
spec:
containers:
- image: my-image
# Startup probe - gives time for initialization
startupProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 0
periodSeconds: 2
timeoutSeconds: 3
failureThreshold: 30 # 60 seconds max startup
# Liveness probe - restart if unhealthy
livenessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 30
timeoutSeconds: 3
failureThreshold: 3
Environment Configuration
Build-Time vs Runtime Configuration
# Build-time arguments (baked into image)
ARG RUST_VERSION=1.75
ARG BUILD_DATE
ARG GIT_COMMIT
FROM rust:${RUST_VERSION}-slim-bookworm AS builder
# Runtime environment variables (overridable at deploy)
ENV PORT=8080 \
RUST_LOG=info \
RUST_BACKTRACE=0
# Labels from build args
LABEL build.date="${BUILD_DATE}" \
build.commit="${GIT_COMMIT}"
Handling Secrets at Build Time
Never embed secrets in images. Use multi-stage builds to ensure secrets don't leak:
# BAD - secret in final image
FROM rust:1.75 AS builder
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
RUN cargo build --release
# GOOD - secret only in builder, not in final image
FROM rust:1.75 AS builder
# Secret used only during build (e.g., private registry)
RUN --mount=type=secret,id=cargo_token \
CARGO_REGISTRIES_MY_REGISTRY_TOKEN=$(cat /run/secrets/cargo_token) \
cargo build --release
# Final image has no secrets
FROM debian:bookworm-slim
COPY --from=builder /app/target/release/my-mcp-server /
CMD ["/my-mcp-server"]
Build with secrets:
docker build --secret id=cargo_token,src=.cargo_token -t my-image .
Local Development with Docker
Development Dockerfile
# Dockerfile.dev
FROM rust:1.75-slim-bookworm
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Install development tools
RUN cargo install cargo-watch
WORKDIR /app
# Mount source code, don't copy
VOLUME /app
ENV PORT=8080
EXPOSE 8080
# Auto-reload on changes
CMD ["cargo", "watch", "-x", "run"]
Docker Compose for Development
# docker-compose.yml
version: '3.8'
services:
mcp-server:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "8080:8080"
volumes:
- .:/app
- cargo-cache:/usr/local/cargo/registry
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/mcp
- RUST_LOG=debug
depends_on:
- db
db:
image: postgres:15-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: mcp
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
cargo-cache:
postgres-data:
# Start development environment
docker compose up
# Rebuild after dependency changes
docker compose up --build
Build Performance
Layer Caching Strategy
┌─────────────────────────────────────────────────────────────────────┐
│ Layer Caching Hierarchy │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Layer 1: Base image (cached across all builds) │
│ │ │
│ ▼ │
│ Layer 2: System packages (cached if unchanged) │
│ │ │
│ ▼ │
│ Layer 3: Cargo dependencies (cached if Cargo.toml unchanged) │
│ │ │
│ ▼ │
│ Layer 4: Source code (rebuilt on code changes) │
│ │ │
│ ▼ │
│ Layer 5: Final binary (rebuilt if any above changed) │
│ │
│ Key: Structure Dockerfile to maximize cache hits │
│ │
└─────────────────────────────────────────────────────────────────────┘
Build Cache with Cloud Build
# cloudbuild.yaml with caching
steps:
- name: 'gcr.io/cloud-builders/docker'
entrypoint: 'bash'
args:
- '-c'
- |
docker pull us-central1-docker.pkg.dev/$PROJECT_ID/mcp-servers/my-mcp-server:cache || true
docker build \
--cache-from us-central1-docker.pkg.dev/$PROJECT_ID/mcp-servers/my-mcp-server:cache \
--build-arg BUILDKIT_INLINE_CACHE=1 \
-t us-central1-docker.pkg.dev/$PROJECT_ID/mcp-servers/my-mcp-server:$COMMIT_SHA \
-t us-central1-docker.pkg.dev/$PROJECT_ID/mcp-servers/my-mcp-server:cache \
.
- name: 'gcr.io/cloud-builders/docker'
args: ['push', '--all-tags', 'us-central1-docker.pkg.dev/$PROJECT_ID/mcp-servers/my-mcp-server']
Summary
Optimizing containers for Rust MCP servers involves:
- Multi-stage builds - Separate build and runtime environments
- Dependency caching - Use cargo-chef or similar for faster rebuilds
- Minimal base images - scratch, distroless, or alpine
- Binary optimization - LTO, strip symbols, size optimization
- Security hardening - Non-root user, read-only filesystem, vulnerability scanning
- Cross-compilation - Support multiple architectures for cost optimization
Target image sizes:
- Scratch-based: 5-15MB
- Distroless: 15-30MB
- Debian-slim: 30-50MB
The smaller your container, the faster your cold starts and the lower your costs.
Practice Ideas
These informal exercises help reinforce the concepts.
Practice 1: Size Reduction Challenge
Take an existing Rust project and create a Dockerfile that produces an image under 20MB.
Practice 2: Build Time Optimization
Measure build times with and without cargo-chef caching. Document the improvement.
Practice 3: Multi-Architecture Build
Create a CI/CD pipeline that builds and pushes images for both AMD64 and ARM64.