Docker Multi-Stage Builds | Smaller Images, Separate Build and Runtime

Docker Multi-Stage Builds | Smaller Images, Separate Build and Runtime

이 글의 핵심

Use multiple FROM stages, copy only binaries and runtime libs into the final image, and drop compilers from production—smaller pulls, smaller attack surface.

Introduction

Multi-stage builds put several FROM lines in one Dockerfile, name stages with AS, compile in the builder stage, then COPY --from only the binary and required .so files into a small runtime image. The final image does not ship compilers, headers, or source—smaller pulls and smaller attack surface.

This article focuses on patterns illustrated with C++ toolchains; the same ideas apply to Node, Go, and other compiled or bundled apps.

After reading this post

  • You understand builder vs runtime separation
  • You can write a minimal multi-stage Dockerfile
  • You can troubleshoot missing shared libraries and Alpine vs glibc issues

Table of contents

  1. Multi-stage build concepts
  2. Optimize the deployment image
  3. Common errors
  4. Conclusion

Multi-stage build concepts

Builder vs runtime

  • Multi-stage: Multiple FROM lines; name stages with AS. Build in the first stage; in the second, use a light base (alpine, distroless, debian-slim) and COPY --from=build_stage to bring only the binary and needed .so files.
  • The final image excludes compilers, headers, and build tools → smaller size and attack surface.
flowchart TB
  subgraph stage1["Stage 1: builder"]
    S1[ubuntu:22.04] --> S2[Install g++, cmake]
    S2 --> S3[Copy source]
    S3 --> S4[Build]
    S4 --> S5[myapp binary]
  end
  subgraph stage2["Stage 2: runtime"]
    R1[debian:bookworm-slim] --> R2[Install libstdc++6 only]
    R2 --> R3[COPY --from builder]
    R3 --> R4["Final image ~50MB"]
  end
  S5 -.->|COPY --from| R3

Basic multi-stage Dockerfile

The builder stage compiles with g++/cmake; the debian:bookworm-slim stage copies only myapp and installs libstdc++6 for dynamic linking.

# Stage 1: build
FROM ubuntu:22.04 AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
    g++ cmake \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . .
RUN cmake -B build && cmake --build build

# Stage 2: runtime
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
    libstdc++6 \
    && rm -rf /var/lib/apt/lists/*
COPY --from=builder /src/build/myapp /usr/local/bin/
CMD ["myapp"]

Dynamic vs static linking

  • Dynamic: Install libstdc++6 (and friends) in the runtime stage—usually easier to maintain.
  • Static: Options like -static-libstdc++ can reduce runtime packages—verify licensing and ABI policies.

vcpkg + multi-stage example

# ========== Stage 1: build ==========
FROM ubuntu:22.04 AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
    g++ cmake git ca-certificates \
    && rm -rf /var/lib/apt/lists/*

ENV VCPKG_ROOT=/opt/vcpkg
RUN git clone https://github.com/Microsoft/vcpkg.git ${VCPKG_ROOT} \
    && ${VCPKG_ROOT}/bootstrap-vcpkg.sh -disableMetrics

WORKDIR /src
COPY vcpkg.json .
RUN ${VCPKG_ROOT}/vcpkg install

COPY CMakeLists.txt .
COPY src/ src/
RUN cmake -B build \
    -DCMAKE_TOOLCHAIN_FILE=${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake \
    && cmake --build build --target myapp

# ========== Stage 2: runtime ==========
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
    libstdc++6 libgcc-s1 \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /src/build/myapp /usr/local/bin/
USER nobody
CMD ["myapp"]

Optimize the deployment image

Slim bases and clean layers

  • Base images: Alpine is tiny but uses musl—a glibc binary may not run. debian-slim, ubuntu minimal, or distroless are safer defaults for many glibc binaries.
  • Cleanup: Use apt-get install --no-install-recommends and remove /var/lib/apt/lists/ after installs to shrink layers.
  • Fewer layers: Combine RUN steps when practical.

Base image comparison

BaseSizeglibcTypical C++Notes
ubuntu:22.04~77MBYesYesEasy default
debian:bookworm-slim~80MBYesYesOften recommended
alpine:3.19~7MBNo (musl)Cautionglibc binaries won’t run
gcr.io/distroless/cc-debian12~50MBYesYesNo shell—strong isolation

distroless example

FROM debian:bookworm-slim AS builder
# ... build ...

FROM gcr.io/distroless/cc-debian12
COPY --from=builder /usr/local/bin/myapp /app/myapp
ENTRYPOINT ["/app/myapp"]

distroless has no shell or package manager—minimal attack surface. For debugging, override entrypoint at docker run time.


Common errors

“cannot find -lstdc++” / “undefined reference”

Cause: Runtime stage missing libstdc++.

Fix:

RUN apt-get update && apt-get install -y --no-install-recommends \
    libstdc++6 libgcc-s1 \
    && rm -rf /var/lib/apt/lists/*

“error while loading shared libraries: libxxx.so.X”

Cause: Dynamic dependency missing in the runtime image.

Fix: On the builder, run:

ldd build/myapp

Install missing .so in the runtime stage or COPY --from=builder the needed libraries.

Alpine: “FATAL: kernel too old” or glibc mismatch

Cause: Alpine uses musl; binaries built for glibc won’t run.

Fix: Use debian-slim or distroless for glibc binaries, or build inside Alpine if you standardize on musl.

”no such file or directory” when running the binary

Cause: Missing dynamic linker or wrong architecture.

Fix: file build/myapp and ldd build/myapp.


Conclusion

Multi-stage Docker builds keep heavy toolchains out of production and make runtime images easier to scan and deploy. Pair with layer caching, .dockerignore, and a registry workflow—see Docker Compose production patterns and the Node.js deployment guide for full-stack examples.