4Cs Cloud-Native & Linux

4Cs – Cloud, Cluster, Container, and Code & Linux Tips and Tricks

Tag: Devops

  • Multi Stage Docker Builds – Reduce Image Size and DevOps Risk

    Docker has become the method of modern software delivery, but naïve Dockerfiles often produce bloated images that contain build tools, source code, and temporary files. Large images increase attack surface, slow down CI pipelines, and waste bandwidth. Multi‑stage builds solve these problems by allowing you to compile or assemble artifacts in one stage and copy only the final binaries into a lean runtime stage.

    This article explains how multi‑stage Docker builds work, walks through a real‑world example for a Go microservice, shows how to apply the technique to Python, Node.js, and compiled C applications, and provides best‑practice tips for security and DevOps risk reduction.

    What Is a Multi‑Stage Build?

    A multi‑stage build is simply a Dockerfile that defines multiple FROM statements, each creating its own intermediate image. You can reference any earlier stage by name using the --from= flag in a COPY command. The final image is whatever you CMD or ENTRYPOINT at the end of the file, and everything else is discarded.

    Example skeleton:

    # Stage 1 – Build environment
    FROM golang:1.22-alpine AS builder
    WORKDIR /src
    COPY . .
    RUN go build -o app .
    
    # Stage 2 – Runtime environment
    FROM alpine:3.19
    COPY --from=builder /src/app /usr/local/bin/app
    EXPOSE 8080
    CMD ["app"]
    

    Only the second stage’s layers are present in the final image, resulting in a tiny Alpine base plus your compiled binary.

    Why Multi‑Stage Improves DevOps

    IssueTraditional DockerfileMulti‑stage Solution
    Large size (hundreds of MB)Build tools stay in imageOnly runtime deps remain
    Secret leakage (e.g., API keys used during build)Keys may be left in layersSecrets never copied to final stage
    Slow CI/CD pipelinesLong docker push/pull timesFaster transfers, less storage
    Vulnerability surfaceBuild‑time packages stay installedMinimal base reduces CVE count

    Step 1 – Write a Simple Service

    Create main.go:

    package main
    
    import (
        "fmt"
        "log"
        "net/http"
    )
    
    func handler(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello from multi‑stage Go!")
    }
    
    func main() {
        http.HandleFunc("/", handler)
        log.Println("Listening on :8080")
        log.Fatal(http.ListenAndServe(":8080", nil))
    }
    

    Step 2 – Dockerfile with Multi‑Stage

    # ---------- Builder ----------
    FROM golang:1.22-alpine AS builder
    WORKDIR /app
    COPY go.mod go.sum ./
    RUN go mod download
    COPY . .
    RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server .
    
    # ---------- Runtime ----------
    FROM alpine:3.19
    LABEL maintainer="devops@example.com"
    RUN addgroup -S app && adduser -S -G app app
    WORKDIR /home/app
    COPY --from=builder /app/server .
    USER app
    EXPOSE 8080
    ENTRYPOINT ["./server"]
    

    Why This Is Secure

    • CGO_ENABLED=0 builds a static binary, eliminating the need for glibc in the runtime stage.
    • The runtime image runs as an unprivileged user (app).
    • No source files or Go toolchain are present after copy.

    Step 3 – Build and Verify Size

    docker build -t go‑microservice:latest .
    docker images | grep go‑microservice
    

    Typical output:

    go-microservice   latest   a1b2c3d4e5f6   12MB   2 minutes ago
    

    Contrast with a monolithic Dockerfile that might be >150 MB.

    Example 2 – Python Application with Dependencies

    Python projects often rely on pip to install many libraries, which can bloat images. Multi‑stage builds let you compile wheels in a builder and copy only the needed packages.

    Project Layout

    app/
    ├── requirements.txt
    └── main.py
    

    requirements.txt

    flask==3.0.2
    requests==2.31.0
    

    main.py

    from flask import Flask, jsonify
    import requests
    
    app = Flask(__name__)
    
    @app.route("/")
    def hello():
        return jsonify(message="Hello from multi‑stage Python!")
    
    if __name__ == "__main__":
        app.run(host="0.0.0.0", port=5000)
    

    Dockerfile

    # ---------- Builder ----------
    FROM python:3.12-slim AS builder
    WORKDIR /src
    COPY requirements.txt .
    RUN pip install --no-cache-dir -r requirements.txt --target=/install
    
    # ---------- Runtime ----------
    FROM python:3.12-alpine
    LABEL maintainer="devops@example.com"
    ENV PYTHONUNBUFFERED=1
    WORKDIR /app
    COPY --from=builder /install /usr/local/lib/python3.12/site-packages
    COPY app/ .
    EXPOSE 5000
    CMD ["python", "main.py"]
    

    Key Points

    • The builder uses python:3.12-slim which includes build tools like gcc.
    • After installing dependencies to /install, we copy that directory into the Alpine runtime, which does not have any compilers.
    • This results in a final image of ~70 MB versus >200 MB for a single‑stage approach.

    Build and Test

    docker build -t py‑multi:latest .
    docker run --rm -p 5000:5000 py‑multi:latest
    

    Visit http://localhost:5000 to see the JSON response.

    Example 3 – Node.js with Native Addons

    Node projects that compile native addons (e.g., bcrypt) need a full build environment. Use multi‑stage to keep only compiled binaries.

    Dockerfile

    # ---------- Builder ----------
    FROM node:20-alpine AS builder
    WORKDIR /app
    COPY package*.json ./
    RUN npm ci --omit=dev   # install production deps (includes native compile)
    COPY . .
    RUN npm run build       # if you have a build step (e.g., TypeScript)
    
    # ---------- Runtime ----------
    FROM node:20-alpine
    LABEL maintainer="devops@example.com"
    WORKDIR /app
    ENV NODE_ENV=production
    COPY --from=builder /app/node_modules ./node_modules
    COPY --from=builder /app/dist ./dist   # if you built a dist folder
    EXPOSE 3000
    CMD ["node", "dist/index.js"]
    

    The builder stage compiles native addons using the Alpine toolchain, then discards npm itself and any development dependencies.

    Example 4 – C Application with Static Linking

    When building a low‑level service in C, you may need glibc. The trick is to compile statically in the builder and copy only the binary.

    Dockerfile

    # ---------- Builder ----------
    FROM gcc:13 AS builder
    WORKDIR /src
    COPY . .
    RUN gcc -static -O2 -o hello_world main.c
    
    # ---------- Runtime ----------
    FROM scratch
    COPY --from=builder /src/hello_world /hello_world
    EXPOSE 8080
    ENTRYPOINT ["/hello_world"]
    

    scratch is an empty image, so the final container contains only the binary and nothing else. This yields a ~1 MB image.

    Best Practices for Multi‑Stage Builds

    1. Name Stages Explicitly – Use AS builderAS runtime etc., to improve readability.
    2. Minimize Layers – Combine related commands with && to reduce intermediate layers, especially in the final stage.
    3. Leverage .dockerignore – Exclude source files, test data, and local caches from being sent to the daemon. Example:*.log node_modules .git __pycache__
    4. Use Trusted Base Images – Prefer official minimal images (alpinescratch) for runtime stages. Verify image digests if security is critical.
    5. Scan Final Image – Run tools like trivy or docker scan on the final stage to ensure no known CVEs are present.trivy image my‑app:latest
    6. Avoid Secrets in Build Args – Never pass API keys via ARG. If you need them for a build step, inject them at runtime instead of copying into the final stage.
    7. Set Non‑Root User – Always create and switch to an unprivileged user in the runtime stage (USER app).

    Automating Multi‑Stage Builds in CI/CD

    Most CI systems (GitHub Actions, GitLab CI, Jenkins) already support docker build. To enforce multi‑stage builds:

    # .github/workflows/docker.yml
    name: Build and Push Docker Image
    on:
      push:
        branches: [ main ]
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - name: Set up QEMU
            uses: docker/setup-qemu-action@v3
          - name: Set up Docker Buildx
            uses: docker/setup-buildx-action@v2
          - name: Login to Docker Hub
            uses: docker/login-action@v3
            with:
              username: ${{ secrets.DOCKER_USER }}
              password: ${{ secrets.DOCKER_PASS }}
          - name: Build and push multi‑stage image
            run: |
              docker buildx build \
                --platform linux/amd64,linux/arm64 \
                -t myrepo/myapp:${{ github.sha }} \
                --push .
    

    The docker buildx command automatically uses the Dockerfile’s stages; you do not need extra flags.

    Reducing DevOps Risk with Multi‑Stage

    • Predictable Deployments – Smaller images mean fewer unexpected runtime dependencies.
    • Faster Rollbacks – Pulling a 10 MB image is almost instantaneous compared to a 200 MB one, enabling quick recovery.
    • Lower Cost – Reduced storage on container registries and less bandwidth usage in CI pipelines translate to cost savings.

    Conclusion

    Multi‑stage Docker builds are a simple yet powerful technique that transforms bloated images into lean, secure artifacts. By separating build-time tooling from runtime dependencies, you shrink image size, eliminate secret leakage, improve pipeline speed, and reduce the attack surface. The examples above cover Go, Python, Node.js, and native C workloads, showing how universal this approach is across languages. Adopt the best‑practice checklist, integrate scanning tools, and enforce multi‑stage builds in your CI pipelines to dramatically lower DevOps risk while delivering faster, more reliable containers.