Docker 멀티스테이지 빌드: 레이어 캐시 순서, BuildKit 캐시 마운트, distroless로 이미지 줄이기
이 글의 핵심
컴파일러와 빌드 도구가 그대로 남은 단일 스테이지 이미지는 크고, 그만큼 공격 표면도 넓습니다. 이 글은 일반 빌드와 멀티스테이지 빌드의 구조 차이에서 출발해 소스 한 줄만 고쳐도 의존성이 다시 설치되는 캐시 무효화 원인, 셸과 패키지 매니저가 없는 distroless 이미지를 고를 때의 득실을 설명합니다. Next.js, Go 마이크로서비스, Rust 웹 서버 사례로 바로 옮겨 쓸 수 있는 Dockerfile 구성을 확인할 수 있습니다.
들어가며
Docker 멀티스테이지 빌드는 빌드 환경과 런타임 환경을 분리하여 이미지 크기를 대폭 줄이는 기법입니다. 컴파일러, 빌드 도구, 개발 의존성은 빌드 단계에만 필요하며, 최종 이미지에는 실행 파일과 런타임 의존성만 포함합니다. 비유로 말씀드리면, 일반 빌드는 주방 도구와 재료를 모두 식탁에 올리는 것이고, 멀티스테이지 빌드는 주방에서 요리하고 완성된 음식만 식탁에 올리는 것입니다.
이미지 크기가 중요한 이유는 디스크 공간보다 배포 속도와 보안에 있습니다. 오토스케일링으로 새 노드가 뜰 때마다 이미지를 내려받아야 하므로 크기가 곧 기동 시간이 되고, 이미지 안에 컴파일러·패키지 매니저·셸이 들어 있으면 취약점 스캐너가 보고하는 CVE 목록도 그만큼 길어집니다. 멀티스테이지 빌드는 이 두 문제를 Dockerfile 하나 안에서 해결합니다. 예전에는 빌드용 Dockerfile과 실행용 Dockerfile을 따로 두고 셸 스크립트로 산출물을 옮기는 “빌더 패턴”을 썼는데, Docker 17.05에서 멀티스테이지가 도입되며 이 과정이 COPY --from 한 줄로 바뀌었습니다.
아래에 나오는 이미지 크기는 공식 베이스 이미지 크기를 기준으로 한 대략적인 규모입니다. 실제 크기는 의존성 수와 애플리케이션에 따라 크게 달라지므로 docker images로 직접 확인하는 것이 정확합니다.
개념 설명
일반 빌드 vs 멀티스테이지 빌드
일반 빌드 (단일 스테이지):
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]
문제점:
node_modules전체 포함 (수백 MB)- 빌드 도구 포함 (TypeScript 컴파일러 등)
- 소스 코드 포함 (불필요)
- 이미지 크기: ~1GB 멀티스테이지 빌드:
# 빌드 스테이지
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 런타임 스테이지
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
장점:
- 빌드 도구 제외
- 소스 코드 제외
- 베이스 이미지가
node:20(약 1GB 규모)에서node:20-slim(수백 MB 미만)으로 바뀌어 크기가 크게 줄어듦
단, 이 버전은 빌드 스테이지의 node_modules를 그대로 복사하므로 TypeScript 컴파일러 같은 devDependencies까지 런타임 이미지에 들어갑니다. 아래 Node.js 예제에서 이 부분을 정리합니다.
멀티스테이지 빌드 구조
# 스테이지 1: 빌드
FROM <빌드 이미지> AS <스테이지 이름>
# 빌드 작업
# 스테이지 2: 런타임
FROM <런타임 이미지>
COPY --from=<스테이지 이름> <빌드 결과물> <목적지>
# 실행 명령
실전 구현
Node.js 애플리케이션
기본 멀티스테이지:
# 빌드 스테이지
FROM node:20 AS builder
WORKDIR /app
# 의존성 설치 (캐싱 최적화) - 빌드에 devDependencies가 필요하므로 전체 설치
COPY package*.json ./
RUN npm ci
# 소스 복사 및 빌드 후, 런타임에 불필요한 devDependencies 제거
COPY . .
RUN npm run build && npm prune --omit=dev
# 런타임 스테이지
FROM node:20-slim
WORKDIR /app
# 빌드 결과물만 복사
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
# 비root 사용자
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nodejs
USER nodejs
EXPOSE 3000
CMD ["node", "dist/index.js"]
처음 멀티스테이지로 바꿀 때 가장 흔히 하는 실수가 빌드 스테이지에서 npm ci --only=production(또는 --omit=dev)을 먼저 하는 것입니다. 그러면 typescript나 번들러 같은 devDependencies가 설치되지 않아 npm run build에서 tsc: not found 같은 에러로 실패합니다. 빌드에는 전체 의존성을 설치하고, 빌드가 끝난 뒤 npm prune --omit=dev로 개발용 패키지를 지운 node_modules를 런타임으로 넘기는 순서가 맞습니다. --only=production은 npm 7 이후 폐기 예정 옵션이라 --omit=dev를 쓰는 것이 좋습니다.
USER nodejs로 root가 아닌 사용자로 실행하는 것도 중요합니다. 컨테이너 안의 root는 설정에 따라 호스트 커널의 root와 같은 UID라서, 애플리케이션 취약점으로 컨테이너를 탈출하면 피해가 커집니다. 다만 사용자를 바꾸면 3000번 같은 1024 미만이 아닌 포트는 문제없지만, 80번 포트에 바인딩하려 하면 EACCES: permission denied가 나므로 컨테이너 안에서는 높은 포트를 쓰고 포트 매핑으로 연결합니다.
이미지 크기 (대략):
- 일반 빌드: 1GB 이상
- 멀티스테이지: 수백 MB 이하 (의존성 크기에 따라 다름)
Go 애플리케이션
최적화된 멀티스테이지:
# 빌드 스테이지
FROM golang:1.22 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 main .
# 런타임 스테이지 (scratch 사용)
FROM scratch
WORKDIR /app
# 빌드된 바이너리만 복사
COPY --from=builder /app/main .
# CA 인증서 복사 (HTTPS 통신용)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
CMD ["./main"]
이미지 크기 (대략):
- 일반 빌드:
golang이미지 크기만 수백 MB - 멀티스테이지: 바이너리 크기에 가까운 수 MB~수십 MB
Go가 scratch(완전히 빈 이미지)를 쓸 수 있는 것은 CGO_ENABLED=0으로 C 라이브러리에 의존하지 않는 정적 바이너리를 만들기 때문입니다. cgo를 켠 채로 빌드하면 바이너리가 glibc에 동적 링크되어, scratch에서 실행할 때 exec /app/main: no such file or directory라는 헷갈리는 에러가 납니다. 파일은 분명히 있는데 “없다”고 하는 이유는 바이너리가 요구하는 동적 로더(/lib64/ld-linux-x86-64.so.2)가 없기 때문입니다. -a -installsuffix cgo는 오래된 Go 버전에서 쓰던 관용구로, 최근 버전에서는 CGO_ENABLED=0만으로 충분합니다.
scratch에는 인증서, 시간대 정보, /etc/passwd도 없습니다. HTTPS 요청을 하려면 예제처럼 CA 인증서를 복사해야 하고(없으면 x509: certificate signed by unknown authority), time.LoadLocation("Asia/Seoul")을 쓰려면 /usr/share/zoneinfo를 복사하거나 Go 코드에 import _ "time/tzdata"를 추가해야 합니다.
Rust 애플리케이션
최적화된 멀티스테이지:
# 빌드 스테이지
FROM rust:1.77 AS builder
WORKDIR /app
# 의존성 캐싱 (더미 프로젝트)
RUN cargo new --bin dummy
WORKDIR /app/dummy
COPY Cargo.toml Cargo.lock ./
RUN cargo build --release
RUN rm src/*.rs
# 실제 소스 빌드
COPY src ./src
RUN touch src/main.rs
RUN cargo build --release
# 런타임 스테이지
FROM debian:bookworm-slim
WORKDIR /app
# 런타임 의존성
RUN apt-get update && \
apt-get install -y ca-certificates && \
rm -rf /var/lib/apt/lists/*
# 빌드된 바이너리 복사
COPY --from=builder /app/dummy/target/release/myapp .
# 비root 사용자
RUN useradd -m -u 1001 appuser
USER appuser
EXPOSE 8080
CMD ["./myapp"]
이미지 크기 (대략):
- 일반 빌드:
rust이미지와 빌드 산출물(target/)을 합쳐 GB 단위 - 멀티스테이지:
debian:bookworm-slim+ 바이너리 수준
Rust의 더미 프로젝트 트릭은 Docker 레이어 캐시를 활용하기 위한 우회책입니다. Cargo에는 “의존성만 빌드”하는 명령이 없으므로, 빈 main.rs로 한 번 빌드해 의존성 크레이트를 컴파일해 두고, 그 다음에 실제 소스를 복사해 다시 빌드합니다. touch src/main.rs는 Cargo가 더미 빌드 결과를 최신으로 착각해 실제 소스를 컴파일하지 않는 문제를 막기 위한 것입니다. 이 과정을 자동화한 cargo-chef 도구도 있어, 워크스페이스가 여러 크레이트로 나뉜 프로젝트라면 그쪽이 더 안정적입니다. 런타임을 debian:bookworm-slim으로 둔 것은 Rust 바이너리가 기본적으로 glibc에 동적 링크되기 때문이며, 빌드 이미지의 Debian 버전과 런타임 이미지의 버전이 다르면 GLIBC_2.xx not found 에러가 날 수 있으니 같은 배포판 버전을 맞춰야 합니다.
Python 애플리케이션
최적화된 멀티스테이지:
# 빌드 스테이지
FROM python:3.12 AS builder
WORKDIR /app
# 의존성 설치 (가상환경에 설치해 경로째 복사)
RUN python -m venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# 런타임 스테이지
FROM python:3.12-slim
WORKDIR /app
# 설치된 패키지 복사 (빌드 스테이지와 같은 경로·같은 Python 버전이어야 함)
COPY --from=builder /opt/venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
# 소스 복사
COPY . .
# 비root 사용자
RUN useradd -m -u 1001 appuser && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["python", "main.py"]
예전 버전의 이 예제는 pip install --user로 /root/.local에 설치한 뒤 USER appuser로 바꿨는데, 이렇게 하면 일반 사용자는 권한이 없는 /root 아래의 패키지를 읽지 못해 실행 시 ModuleNotFoundError가 납니다. 가상환경을 /opt/venv 같은 공용 경로에 만들어 통째로 복사하면 이 문제가 없고, PATH만 바꾸면 되므로 코드도 단순합니다. Python의 멀티스테이지 이점은 주로 빌드 도구를 빼는 데서 나옵니다. psycopg2처럼 C 확장을 소스에서 빌드해야 하는 패키지는 gcc와 헤더가 필요한데, 이것을 빌드 스테이지에만 두고 런타임은 slim으로 유지할 수 있습니다. 대신 빌드한 확장이 링크하는 런타임 라이브러리(libpq5 등)는 런타임 스테이지에 설치해야 합니다.
이미지 크기 (대략):
- 일반 빌드:
python:3.12이미지만 약 1GB 규모 - 멀티스테이지:
python:3.12-slim기반으로 수백 MB 이하
고급 최적화
레이어 캐싱 최적화
나쁜 예:
FROM node:20 AS builder
WORKDIR /app
# 소스 먼저 복사 (매번 캐시 무효화)
COPY . .
RUN npm install
RUN npm run build
좋은 예:
FROM node:20 AS builder
WORKDIR /app
# 의존성 파일만 먼저 복사
COPY package*.json ./
RUN npm ci
# 소스는 나중에 복사
COPY . .
RUN npm run build
이유:
package.json변경 시에만npm ci재실행- 소스 코드 변경 시 빌드만 재실행
- CI/CD 빌드 시간 대폭 단축
Docker는 각 명령의 결과를 레이어로 캐시하고, COPY 명령은 복사하는 파일들의 내용 해시가 이전과 같을 때만 캐시를 씁니다. 한 레이어의 캐시가 깨지면 그 아래 모든 레이어도 다시 실행됩니다. 그래서 COPY . .를 먼저 하면 README 한 글자만 바꿔도 해시가 달라져 npm install부터 다시 돕니다. 이 규칙 때문에 .dockerignore도 캐시에 영향을 줍니다. .git 폴더나 로컬 node_modules가 빌드 컨텍스트에 들어가면, 커밋할 때마다 .git 내용이 바뀌어 COPY . . 이후 캐시가 계속 무효화됩니다.
distroless 이미지
Google distroless 이미지 사용:
# 빌드 스테이지
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o main .
# 런타임 스테이지 (distroless)
FROM gcr.io/distroless/static-debian12
WORKDIR /app
COPY --from=builder /app/main .
CMD ["./main"]
장점:
- 셸, 패키지 매니저 없음 (보안 강화)
- 최소한의 파일만 포함
- 이미지 크기 최소화
distroless는 scratch와 일반 배포판 이미지의 중간입니다. 셸과 패키지 매니저는 없지만 CA 인증서, 시간대 데이터, /etc/passwd의 nonroot 사용자 같은 최소 파일은 들어 있어서, 위 Go 예제에서 인증서를 직접 복사할 필요가 없습니다. 언어별로 static, base(glibc 포함), cc, python3, nodejs 변형이 있습니다. 트레이드오프는 디버깅입니다. 셸이 없으니 문제가 생겼을 때 docker exec -it <컨테이너> sh로 들어가 볼 수 없습니다. 운영 중 디버깅이 필요하면 :debug 태그 변형(busybox 셸 포함)을 임시로 쓰거나, Kubernetes의 kubectl debug로 임시 디버그 컨테이너를 붙이는 방식을 씁니다.
Alpine Linux
Alpine 기반 이미지:
# 빌드 스테이지
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 런타임 스테이지
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
주의사항:
- Alpine은
musl libc사용 (glibc와 호환성 문제 가능) - 네이티브 모듈 빌드 시 추가 패키지 필요
Alpine은 작지만 공짜는 아닙니다. glibc용으로 미리 빌드된 바이너리(prebuilt)를 내려받는 npm 패키지나 Python 휠은 musl에서 동작하지 않아 소스 빌드로 넘어가고, 그러면 빌드 스테이지에 python3 make g++를 설치해야 하며 빌드 시간도 길어집니다. sharp, bcrypt, canvas 같은 네이티브 모듈에서 이런 일이 자주 생깁니다. 또 빌드 스테이지는 Debian 기반, 런타임은 Alpine으로 섞으면 빌드된 네이티브 모듈이 런타임에서 로드되지 않는 Error relocating ...: symbol not found 같은 에러가 납니다. 두 스테이지의 libc를 맞추는 것이 원칙이고, 크기 차이가 생각보다 크지 않다면 -slim(Debian) 이미지가 더 무난한 선택입니다.
BuildKit 캐시 마운트
BuildKit 활성화:
export DOCKER_BUILDKIT=1
캐시 마운트 사용:
# syntax=docker/dockerfile:1.4
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
장점:
- npm 캐시를 빌드 간 공유
- 의존성 다운로드 시간 단축
레이어 캐시는 package-lock.json이 한 줄만 바뀌어도 통째로 무효화되어 모든 패키지를 다시 내려받습니다. 캐시 마운트는 레이어와 별개로 BuildKit이 관리하는 디렉터리를 빌드 간에 유지해, 레이어가 무효화되더라도 이미 받아 둔 패키지는 로컬 캐시에서 가져오게 합니다. 캐시 마운트의 내용은 최종 이미지에 들어가지 않으므로 --no-cache-dir 같은 옵션으로 캐시를 지우는 수고도 필요 없습니다. Docker 23 이후에는 BuildKit이 기본값이라 DOCKER_BUILDKIT=1을 따로 설정하지 않아도 됩니다. 다만 CI 러너가 매번 새로 만들어지는 환경에서는 캐시 마운트도 사라지므로, GitHub Actions라면 docker/build-push-action의 cache-from/cache-to: type=gha로 레이어 캐시를 외부에 저장해야 효과가 있습니다.
성능 비교
이미지 크기 비교
| 언어 | 일반 빌드 | 멀티스테이지 | 감소율 |
|---|---|---|---|
| Node.js | 1.2GB | 200MB | 83% |
| Go | 800MB | 10MB | 99% |
| Rust | 2GB | 80MB | 96% |
| Python | 1GB | 200MB | 80% |
위 표의 숫자는 각 언어의 공식 빌드 이미지와 경량 런타임 이미지 크기에서 나오는 대략적인 규모입니다. 실제 결과는 애플리케이션 의존성에 따라 크게 달라지므로, 바꾸기 전후로 docker images와 docker history <이미지>로 직접 비교해 보는 것이 가장 정확합니다. docker history는 레이어별 크기를 보여 주므로 어떤 COPY나 RUN이 크기를 차지하는지 바로 드러납니다. 레이어 단위로 더 자세히 보고 싶다면 dive 도구가 유용합니다.
빌드 시간
빌드 시간에서 멀티스테이지 자체보다 큰 영향을 주는 것은 캐시입니다. 의존성 설치 레이어가 캐시되면 소스만 바뀐 재빌드는 컴파일 단계만 다시 실행되고, 의존성 설치 시간이 긴 프로젝트일수록 차이가 커집니다. 멀티스테이지에서는 BuildKit이 최종 이미지에 필요하지 않은 스테이지를 건너뛰고, 서로 의존하지 않는 스테이지는 병렬로 빌드하므로 스테이지를 잘게 나누는 것도 도움이 됩니다. 반대로 CI에서 매번 캐시 없이 빌드한다면 이런 최적화 효과가 거의 없으므로, 레이어 캐시를 레지스트리나 CI 캐시에 저장하는 설정이 함께 필요합니다.
실무 사례
사례 1: Next.js 애플리케이션
# 의존성 설치 스테이지
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
# 빌드 스테이지
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# 런타임 스테이지
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
이 Dockerfile은 next.config.js에 output: 'standalone'이 설정되어 있어야 동작합니다. 이 옵션을 켜면 Next.js가 빌드 시 실제로 필요한 파일만 추적해 .next/standalone에 최소한의 node_modules와 server.js를 모아 줍니다. 설정이 빠지면 .next/standalone 디렉터리가 생성되지 않아 COPY --from=builder /app/.next/standalone 단계에서 not found 에러가 납니다. public과 .next/static은 standalone 출력에 포함되지 않으므로 예제처럼 따로 복사해야 하고, 이것을 빠뜨리면 페이지는 뜨는데 CSS와 이미지가 404가 되는 증상이 나타납니다. deps 스테이지를 따로 둔 이유는 의존성 설치 레이어를 소스 변경과 분리해 캐시하기 위해서입니다.
결과 (대략): 전체 node_modules를 담은 이미지보다 standalone 출력 이미지가 훨씬 작아집니다.
사례 2: Go 마이크로서비스
# 빌드 스테이지
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 -ldflags="-w -s" -o main .
# 런타임 스테이지 (scratch)
FROM scratch
COPY --from=builder /app/main /main
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/main"]
-ldflags="-w -s"는 디버그 정보와 심볼 테이블을 빼서 바이너리를 줄입니다. 대신 패닉 시 스택 트레이스의 함수 이름은 남지만 디버거로 분석하기는 어려워지므로, 운영 이미지에서만 쓰고 디버깅용 빌드는 따로 두는 것이 일반적입니다. ENTRYPOINT ["/main"]처럼 대괄호 안 경로를 반드시 따옴표로 감싸야 합니다. JSON 배열로 해석되지 않으면 Docker는 셸 형식으로 간주해 /bin/sh -c로 실행하려 하는데, scratch에는 셸이 없어 컨테이너가 exec: "/bin/sh": stat /bin/sh: no such file or directory로 바로 종료됩니다.
결과 (대략): 바이너리 크기 수준의 이미지
사례 3: Rust 웹 서버
# 빌드 스테이지
FROM rust:1.77 AS builder
WORKDIR /app
# 의존성 캐싱
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
# 실제 빌드
COPY src ./src
RUN touch src/main.rs
RUN cargo build --release
# 런타임 스테이지
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update && \
apt-get install -y ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/myapp .
RUN useradd -m -u 1001 appuser
USER appuser
EXPOSE 8080
CMD ["./myapp"]
결과 (대략): debian:bookworm-slim 크기 + 바이너리 크기
트러블슈팅
COPY —from이 실패함
문제:
COPY --from=builder /app/dist ./dist
# Error: COPY failed: file not found
BuildKit에서는 이 에러가 failed to compute cache key: failed to calculate checksum of ref ...: "/app/dist": not found 형태로 나옵니다.
원인:
- 빌드 스테이지에서 파일이 생성되지 않음
- 경로가 잘못됨
.dockerignore가 필요한 파일을 빌드 컨텍스트에서 제외함- 빌드 도구의 출력 디렉터리가 예상과 다름(
dist대신build,out등) 해결:
# 빌드 스테이지에서 확인
FROM node:20 AS builder
WORKDIR /app
COPY . .
RUN npm run build && ls -la dist/
# 런타임 스테이지
FROM node:20-slim
COPY --from=builder /app/dist ./dist
캐시가 무효화됨
문제:
- 소스 코드 한 줄 수정해도 의존성 재설치 원인:
- COPY 순서가 잘못됨 해결:
# 나쁜 예
COPY . .
RUN npm install
# 좋은 예
COPY package*.json ./
RUN npm ci
COPY . .
이미지 크기가 여전히 큼
문제:
- 멀티스테이지인데도 이미지가 큼 원인:
- 불필요한 파일 복사
- 베이스 이미지가 큼 해결:
# .dockerignore 파일 생성
node_modules
.git
.env
*.md
tests
# slim 또는 alpine 이미지 사용 (Dockerfile 주석은 줄 끝이 아니라 줄 맨 앞에만 쓸 수 있음)
FROM node:20-slim
멀티스테이지인데도 이미지가 큰 경우는 대개 COPY --from으로 너무 많은 것을 가져오는 경우입니다. 빌드 스테이지의 /app 전체를 복사하거나, devDependencies가 포함된 node_modules를 그대로 넘기면 멀티스테이지의 이점이 대부분 사라집니다. 또 RUN apt-get install 뒤에 같은 RUN에서 rm -rf /var/lib/apt/lists/*를 하지 않고 다음 RUN에서 지우면, 이전 레이어에 파일이 이미 기록되어 있어 이미지 크기는 줄지 않습니다. 레이어는 추가만 되고 이전 레이어의 내용을 줄이지는 못하기 때문입니다.
런타임 의존성 누락
문제:
- 애플리케이션 실행 시 라이브러리 누락 에러 원인:
- 런타임 의존성이 빌드 스테이지에만 있음 해결:
# 런타임 스테이지에 의존성 설치
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y \
ca-certificates \
libssl3 \
&& rm -rf /var/lib/apt/lists/*
에러 메시지는 보통 error while loading shared libraries: libssl.so.3: cannot open shared object file: No such file or directory처럼 누락된 라이브러리 이름을 알려 줍니다. 어떤 라이브러리가 필요한지 미리 확인하려면 빌드 스테이지에서 ldd target/release/myapp을 실행해 동적 의존성 목록을 보면 됩니다. 이름이 비슷해도 배포판 버전마다 패키지 이름이 다르므로(libssl1.1 → libssl3), 런타임 이미지의 배포판 버전을 올릴 때 이 부분이 자주 깨집니다. Rust라면 rustls처럼 순수 Rust TLS 구현을 쓰거나 musl 타깃으로 정적 빌드해 OpenSSL 의존성을 아예 없애는 방법도 있습니다.
마무리
Docker 멀티스테이지 빌드는 이미지 크기를 대폭 줄이고 보안을 강화하는 필수 기법입니다. 핵심 원칙:
- 빌드 환경과 런타임 환경 분리
- 레이어 캐싱 최적화 (의존성 먼저, 소스 나중)
- 최소한의 베이스 이미지 (slim, alpine, distroless, scratch)
- 불필요한 파일 제외 (.dockerignore)
- 비root 사용자 사용 (보안) 효과:
- 이미지 크기: 빌드 도구와 불필요한 파일이 빠진 만큼 감소 (언어·런타임 이미지에 따라 수 배~수십 배)
- 빌드 시간: 캐시가 유지되는 환경에서 재빌드 시간 단축
- 보안: 공격 표면 감소
- 비용: 저장소 및 전송 비용 절감 Kubernetes 배포나 CI/CD 파이프라인과 함께 사용하면 더욱 효과적입니다.
자주 묻는 질문 (FAQ)
Q. 멀티스테이지로 바꿨는데 소스 한 줄만 고쳐도 의존성이 다시 설치되는 이유는 무엇인가요?
A. 대부분 COPY 순서 문제입니다. COPY . . 뒤에 npm install을 두면 소스가 바뀔 때마다 그 레이어의 캐시가 무효화되어 의존성 설치부터 다시 실행됩니다. package*.json만 먼저 복사해 npm ci를 실행하고 나머지 소스는 그다음에 복사하면, 의존성 목록이 바뀌지 않는 한 설치 레이어는 캐시에서 재사용됩니다.