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
Multi-stage build concepts
Builder vs runtime
- Multi-stage: Multiple
FROMlines; name stages withAS. Build in the first stage; in the second, use a light base (alpine, distroless, debian-slim) andCOPY --from=build_stageto bring only the binary and needed.sofiles. - 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-recommendsand remove/var/lib/apt/lists/after installs to shrink layers. - Fewer layers: Combine
RUNsteps when practical.
Base image comparison
| Base | Size | glibc | Typical C++ | Notes |
|---|---|---|---|---|
| ubuntu:22.04 | ~77MB | Yes | Yes | Easy default |
| debian:bookworm-slim | ~80MB | Yes | Yes | Often recommended |
| alpine:3.19 | ~7MB | No (musl) | Caution | glibc binaries won’t run |
| gcr.io/distroless/cc-debian12 | ~50MB | Yes | Yes | No 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.