infra
Platform

모듈 맵

[Docker] 실무에 필수적인 멀티 스테이지 빌드와 최적화 기법

0 / 27 완료

펼치기
0 / 27 완료0%

Docker · 17 / 27

[Docker] 실무에 필수적인 멀티 스테이지 빌드와 최적화 기법

빌드 환경과 실행 환경을 분리해 프로덕션 이미지 크기를 90% 이상 줄입니다

멀티 스테이지 빌드와 이미지 경량화

🚨INCIDENT ALERT
HIGH

CI는 통과했지만 배포 시간이 길고, 이미지 스캔 결과에는 불필요한 패키지 취약점이 쏟아집니다. 원인을 보면 빌드 도구와 런타임이 한 이미지에 섞여 있어 크기와 공격면이 함께 커진 상태입니다. 멀티 스테이지 빌드는 "작게 만드는 기술"을 넘어 운영 안정성과 보안 품질을 동시에 올리는 패턴입니다. 이 모듈에서 스테이지 분리, 베이스 선택, 캐시 최적화를 실무 기준으로 익힙니다.

Go 애플리케이션을 단순하게 빌드하면 이미지 크기가 1 GB를 넘기도 합니다. 하지만 멀티 스테이지 빌드를 적용하면 동일한 애플리케이션을 10~20 MB 이미지로 만들 수 있습니다. 이 챕터에서는 빌드 환경과 실행 환경을 완전히 분리하는 방법, 그리고 그 과정에서 활용하는 다양한 최적화 기법을 다룹니다.


이번 챕터에서 배울 것

단일 스테이지 빌드의 한계를 이해하고, 멀티 스테이지 빌드로 프로덕션급 경량 이미지를 만드는 전 과정을 학습합니다.

  • 1멀티 스테이지 빌드 원리 — 빌드 레이어와 런타임 레이어를 분리하는 이유
  • 2FROM ... AS 문법과 COPY --from으로 스테이지 간 파일 전달
  • 3distroless / alpine 베이스 이미지 선택 기준과 트레이드오프
  • 4.dockerignore로 빌드 컨텍스트 최적화 — 빌드 속도와 보안 동시 개선
  • 5BuildKit 병렬 빌드와 --target 플래그로 CI 파이프라인 효율화
실습 환경 준비

Go와 Node.js 두 가지 언어로 멀티 스테이지 빌드를 실습합니다. Go는 단일 바이너리 특성 덕분에 경량화 효과가 극적으로 나타납니다.

BuildKit 활성화 확인
docker buildx version
실습 디렉토리 생성
mkdir -p ~/multistage-lab && cd ~/multistage-lab
Go 설치 여부 확인 (선택사항 — 없어도 Docker 내에서 빌드)
go version || echo 'Go not installed locally — will build inside Docker'
샘플 앱 소스는 실습 단계에서 직접 작성

별도 레포 클론 불필요

이미지 크기 비교를 위한 기준 이미지 pull
docker pull golang:1.22-alpine && docker pull gcr.io/distroless/static-debian12
💡개념

왜 빌드 환경과 실행 환경을 분리해야 하는가

왜 빌드 환경과 실행 환경을 분리해야 하는가

단일 스테이지 빌드의 문제점

전통적인 방식으로 Go 앱을 패키징하면 Dockerfile은 다음과 같습니다.

Dockerfile
# 단일 스테이지 — 빌드 도구가 최종 이미지에 그대로 남음
FROM golang:1.22

WORKDIR /app
COPY . .
RUN go build -o server .

CMD ["./server"]

이 이미지를 빌드하면 약 800 MB~1 GB 크기가 됩니다. golang:1.22 베이스 이미지 자체가 Go 컴파일러, 표준 라이브러리, 빌드 캐시, OS 유틸리티를 모두 포함하기 때문입니다. 실제 프로덕션에서 실행되는 바이너리는 단 수 MB에 불과한데도 말이죠.

이 문제는 보안 측면에서도 심각합니다. 불필요한 도구가 이미지에 남아 있으면 침해 사고 시 공격자에게 더 많은 수단을 제공합니다.

멀티 스테이지 빌드 원리

멀티 스테이지 빌드는 하나의 Dockerfile에 여러 개의 FROM 을 사용해 각 단계를 독립적으로 정의합니다. 이전 스테이지에서 필요한 파일만 COPY --from 으로 골라 다음 스테이지로 가져옵니다.

[빌드 스테이지]                     [런타임 스테이지]
golang:1.22 (1 GB)                 distroless/static (5 MB)
  ├── Go 컴파일러                     ├── CA 인증서
  ├── 표준 라이브러리                  ├── /etc/passwd (기본)
  ├── 소스 코드                        └── ./server  ← 여기서만 복사
  └── ./server (빌드 결과물) ──COPY──▶

최종 이미지에는 런타임 스테이지만 포함됩니다. 빌드 스테이지는 중간 단계로만 사용되고 최종 이미지에는 흔적을 남기지 않습니다.

💡개념

FROM ... AS와 COPY --from 문법 완전 정복

개념은 이해했는데 막상 Dockerfile을 쓰다 보면 "builder 스테이지에서 만든 바이너리를 다음 스테이지로 어떻게 가져오지?"가 막힙니다. COPY --from=builder를 써봤는데 "file not found" 에러가 나면, 빌드 스테이지의 WORKDIR과 파일 경로를 정확히 지정하지 않은 것입니다. 같은 Dockerfile 안에 FROM이 두 개 있는 구조가 처음에는 낯설지만, AS로 이름을 붙이고 --from으로 참조하는 패턴만 익히면 어떤 언어에도 적용할 수 있습니다. 이 ConceptBlock에서는 스테이지 이름 지정과 COPY --from 문법의 동작 방식을 다룹니다.

FROM ... AS와 COPY --from 문법 완전 정복

스테이지 이름 지정과 참조

FROM ... AS 이름 으로 각 스테이지에 이름을 붙이고, COPY --from=이름 으로 해당 스테이지의 결과물만 골라 다음 스테이지로 전달합니다. 아래 예시에서 builder 스테이지의 컴파일러와 소스코드는 최종 이미지에 전혀 들어가지 않습니다.

Dockerfile
# AS 키워드로 스테이지에 이름을 붙인다
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="-s -w" -o server .

# ──────────────────────────────────────────────────────
# 런타임 스테이지 — 최소한의 베이스 이미지에서 시작
FROM gcr.io/distroless/static-debian12

WORKDIR /

# builder 스테이지에서 바이너리만 복사
COPY --from=builder /app/server /server

# non-root 사용자로 실행 (distroless에서는 65532가 nonroot)
USER 65532:65532

EXPOSE 8080
ENTRYPOINT ["/server"]

COPY --from의 다양한 활용

Dockerfile
# 이전 스테이지에서 복사 (스테이지 이름 사용)
COPY --from=builder /app/server .

# 이전 스테이지 인덱스로 참조 (0부터 시작)
COPY --from=0 /app/server .

# 완전히 다른 이미지에서 파일을 가져올 수도 있음
# (예: 특정 버전의 바이너리를 레지스트리에서 직접 추출)
COPY --from=alpine:3.19 /usr/bin/wget /usr/bin/wget

빌드 최적화 옵션

Dockerfile
# -ldflags="-s -w": 심볼 테이블(-s)과 DWARF 디버그 정보(-w) 제거
# CGO_ENABLED=0: C 라이브러리 링크 비활성화 → 완전 정적 바이너리
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w" -trimpath -o server .

-ldflags="-s -w" 옵션만으로도 바이너리 크기가 30~40% 줄어듭니다. upx 도구로 추가 압축하면 더 작아지지만 시작 시간이 늘어나는 트레이드오프가 있습니다.

💡개념

베이스 이미지 선택 — distroless vs Alpine vs scratch

멀티 스테이지 빌드로 바이너리를 분리했는데 런타임 스테이지에 FROM ubuntu:22.04를 쓰면 최종 이미지가 여전히 80MB입니다. 실제 바이너리는 10MB인데 말이죠. 반대로 FROM scratch를 쓰면 glibc가 없어서 "exec format error"가 납니다. 보안 감사에서는 "셸이 있는 이미지는 침해 시 공격 표면이 넓다"고 지적합니다. 베이스 이미지 선택은 크기, 호환성, 보안 세 축의 트레이드오프이고, 어떤 언어와 링킹 방식을 쓰느냐에 따라 선택지가 달라집니다. 이 ConceptBlock에서는 주요 런타임 베이스 이미지의 특성과 언어별 선택 기준을 다룹니다.

베이스 이미지 선택 — distroless vs Alpine vs scratch

런타임 베이스 이미지 비교

이미지크기셸 포함패키지 관리자glibc권장 용도
ubuntu:22.04~77 MBbashaptO개발/디버깅
debian:bookworm-slim~74 MBbashaptO범용 앱
alpine:3.19~7 MBshapkX (musl)경량화 필요 앱
distroless/static~2 MBXXXGo 정적 바이너리
distroless/base~20 MBXXOglibc 필요 앱
scratch0 MBXXX완전 정적 바이너리

Alpine 사용 시 주의사항

Alpine은 glibc 대신 musl libc를 사용합니다. 대부분의 Go/Python 앱에서는 문제없지만, glibc에 의존하는 C 확장 모듈(예: 일부 numpy 의존성)은 Alpine에서 실행 시 오류가 날 수 있습니다.

Dockerfile
# Python + Alpine: 컴파일이 필요한 패키지는 빌드 의존성 설치 필요
FROM python:3.11-alpine AS builder
RUN apk add --no-cache gcc musl-dev libffi-dev
RUN pip install --prefix=/install -r requirements.txt

FROM python:3.11-alpine
COPY --from=builder /install /usr/local
COPY app/ /app
CMD ["python", "/app/main.py"]

Node.js 앱에 적합한 멀티 스테이지 패턴

Dockerfile
# ── 1단계: 의존성 설치 ──────────────────────────────
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# ci는 lock 파일을 엄격하게 따름 (install보다 빠르고 재현 가능)
RUN npm ci --only=production

# ── 2단계: 빌드 (TypeScript 컴파일 등) ──────────────
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ── 3단계: 최종 런타임 이미지 ─────────────────────────
FROM node:20-alpine AS runtime
WORKDIR /app

# 프로덕션 의존성만 복사
COPY --from=deps /app/node_modules ./node_modules
# 빌드 결과물만 복사
COPY --from=build /app/dist ./dist
COPY package.json ./

# non-root 사용자로 실행
USER node

EXPOSE 3000
CMD ["node", "dist/index.js"]

이 패턴에서 최종 이미지는 devDependencies(TypeScript 컴파일러, ESLint 등)를 포함하지 않습니다.

💡개념

.dockerignore로 빌드 컨텍스트 최적화

docker build .를 실행했는데 "Sending build context to Docker daemon 450MB"가 뜨고 빌드가 시작도 하기 전에 30초가 흐릅니다. node_modules 디렉토리가 빌드 컨텍스트에 포함됐기 때문입니다. Dockerfile 안에서 npm ci로 의존성을 재설치하는데도 호스트의 node_modules까지 전송하는 것은 순수한 낭비입니다. .env 파일이 빌드 컨텍스트에 포함되면 docker history로 읽힐 수 있어 보안 감사에서 문제가 됩니다. .dockerignore는 .gitignore와 비슷한 역할을 하지만 Docker 빌드 성능과 보안 두 가지를 동시에 잡습니다. 이 ConceptBlock에서는 효과적인 .dockerignore 작성 기준과 빌드 컨텍스트 크기를 측정하는 방법을 다룹니다.

.dockerignore로 빌드 컨텍스트 최적화

빌드 컨텍스트란?

docker build . 를 실행하면 . 경로의 모든 파일이 Docker 데몬으로 전송됩니다. 이것을 빌드 컨텍스트라고 합니다. 로컬 Docker는 빠르지만, CI 서버나 원격 BuildKit 데몬을 사용하는 경우 수백 MB의 컨텍스트 전송이 병목이 됩니다.

효과적인 .dockerignore 작성

DOCKERIGNORE
# ── 버전 관리 ─────────────────────────────────────
.git
.gitignore
.gitattributes

# ── Node.js ──────────────────────────────────────
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# ── 빌드 아티팩트 ─────────────────────────────────
dist
build
*.o
*.a
*.so

# ── 환경 설정 (보안 중요) ──────────────────────────
.env
.env.*
*.pem
*.key
secrets/

# ── 테스트/개발 파일 ──────────────────────────────
**/*_test.go
**/*.test.js
**/*.spec.ts
coverage/
.nyc_output

# ── IDE/도구 ────────────────────────────────────
.idea
.vscode
*.swp
.DS_Store

# ── Docker 파일 자체 (자기 참조 방지) ─────────────
Dockerfile*
docker-compose*.yml
.dockerignore

.dockerignore 효과 측정

로컬 터미널
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part4/exam_17 && cd /tmp/docker/part4/exam_17

# 빌드 전 컨텍스트 크기 확인 (실제 전송 크기 확인)
du -sh . --exclude=.git

# .dockerignore 적용 후 실제 전송 크기는 빌드 로그에서 확인
docker build . 2>&1 | grep "Sending build context"
# 예시 출력: Sending build context to Docker daemon  2.048kB
💡개념

BuildKit 병렬 빌드와 --target 플래그

프론트엔드와 백엔드를 함께 빌드하는 Dockerfile이 있는데 전체 빌드가 8분 걸립니다. 두 빌드는 서로 완전히 독립적인데도 순서대로 실행되기 때문입니다. BuildKit을 사용하면 독립적인 스테이지를 자동으로 병렬 실행해 전체 빌드 시간을 절반으로 줄일 수 있습니다. CI에서 "테스트 통과한 것만 프로덕션 이미지로 빌드한다"는 파이프라인을 만들고 싶을 때는 --target 플래그로 원하는 스테이지까지만 빌드하는 것이 핵심입니다. 이 ConceptBlock에서는 BuildKit 병렬 빌드의 동작 원리와 --target을 활용한 CI 파이프라인 설계를 다룹니다.

BuildKit 병렬 빌드와 --target 플래그

BuildKit 활성화

BuildKit은 Docker 23.0부터 기본 활성화되어 있습니다. 구버전 환경이라면 환경변수로 활성화합니다.

로컬 터미널
# 일회성 활성화
DOCKER_BUILDKIT=1 docker build .

# 영구 활성화 (Docker 데몬 설정)
# /etc/docker/daemon.json
{
  "features": { "buildkit": true }
}

# docker buildx는 항상 BuildKit 사용
docker buildx build .

병렬 빌드 활용 예시

BuildKit은 서로 의존하지 않는 스테이지를 자동으로 병렬 실행합니다.

Dockerfile
# frontend와 backend는 서로 독립 — BuildKit이 동시에 빌드
FROM node:20-alpine AS frontend-builder
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build

FROM golang:1.22-alpine AS backend-builder
WORKDIR /backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ .
RUN CGO_ENABLED=0 go build -o api-server .

# 최종 이미지 — 두 스테이지의 결과를 조합
FROM nginx:alpine AS runtime
COPY --from=frontend-builder /frontend/dist /usr/share/nginx/html
COPY --from=backend-builder /backend/api-server /usr/local/bin/
COPY nginx.conf /etc/nginx/nginx.conf

--target으로 CI 단계별 빌드

Docker
# 테스트 스테이지까지만 빌드 (테스트 실행용)
docker build --target test -t myapp:test .

# 최종 프로덕션 이미지 빌드
docker build --target runtime -t myapp:prod .

# CI 파이프라인 예시 (GitHub Actions)
# - name: Run tests
#   run: docker build --target test --tag app:test .
#          && docker run --rm app:test npm test

기본 실습

1Go 앱 단일 스테이지로 빌드 — 크기 확인

실습 디렉토리를 만들고 간단한 Go HTTP 서버를 작성합니다.

실습 전 디렉토리와 예제 파일을 먼저 준비합니다.

로컬 터미널
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part3/exam_1 && cd /tmp/docker/part3/exam_1

# 멀티 스테이지 빌드 비교용 기본 Dockerfile 생성
cat > Dockerfile.single << 'EOF'
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]
EOF

cat > Dockerfile.multi << 'EOF'
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .

FROM scratch
COPY --from=builder /app/server /server
CMD ["/server"]
EOF

이제 실습을 진행합니다.

로컬 터미널
mkdir -p ~/multistage-lab/go-app
cd ~/multistage-lab/go-app

# main.go 작성
cat > main.go << 'EOF'
package main

import (
    "fmt"
    "net/http"
    "os"
)

func main() {
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello from Go! Running as UID: %d\n", os.Getuid())
    })
    http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        fmt.Fprint(w, "ok")
    })
    fmt.Printf("Server listening on :%s\n", port)
    http.ListenAndServe(":"+port, nil)
}
EOF

# go.mod 초기화
cat > go.mod << 'EOF'
module example.com/goapp

go 1.22
EOF

# 단일 스테이지 Dockerfile
cat > Dockerfile.single << 'EOF'
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]
EOF

# 빌드
docker build -f Dockerfile.single -t goapp:single .

# 이미지 크기 확인
docker images goapp:single

golang:1.22 베이스 이미지 때문에 약 850 MB~1 GB 크기의 이미지가 만들어지는 것을 확인합니다.

mkdir -p ~/multistage-lab/go-app && cd ~/multistage-lab/go-app
2멀티 스테이지 빌드로 이미지 크기 90% 감소

이제 멀티 스테이지 Dockerfile을 작성하고 크기를 비교합니다.

로컬 터미널
cd ~/multistage-lab/go-app

cat > Dockerfile.multi << 'EOF'
# ── 빌드 스테이지 ────────────────────────────────────────
FROM golang:1.22-alpine AS builder

WORKDIR /app

# 의존성 먼저 복사 (go.sum이 없으면 go.mod만 복사)
COPY go.mod ./
RUN go mod download

COPY . .

# 정적 바이너리 빌드: CGO 비활성화, 디버그 정보 제거
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w" -trimpath -o server .

# ── 런타임 스테이지 ──────────────────────────────────────
FROM gcr.io/distroless/static-debian12

WORKDIR /

# builder 스테이지의 바이너리만 복사
COPY --from=builder /app/server /server

# non-root 실행 (distroless nonroot UID)
USER 65532:65532

EXPOSE 8080
ENTRYPOINT ["/server"]
EOF

# 멀티 스테이지 이미지 빌드
docker build -f Dockerfile.multi -t goapp:multi .

# 두 이미지 크기 비교
echo "=== 이미지 크기 비교 ==="
docker images goapp:single goapp:multi

# 상세 레이어 정보
echo "=== 멀티 스테이지 이미지 레이어 ==="
docker history goapp:multi
3Node.js 앱 3단계 멀티 스테이지 빌드

실제 프로젝트에서 더 자주 쓰는 Node.js + TypeScript 패턴을 실습합니다.

로컬 터미널
mkdir -p ~/multistage-lab/node-app
cd ~/multistage-lab/node-app

# package.json
cat > package.json << 'EOF'
{
  "name": "node-multistage",
  "version": "1.0.0",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "typescript": "^5.3.0",
    "@types/express": "^4.17.21",
    "@types/node": "^20.0.0"
  }
}
EOF

# tsconfig.json
cat > tsconfig.json << 'EOF'
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true
  }
}
EOF

mkdir -p src
cat > src/index.ts << 'EOF'
import express from 'express';
const app = express();
const port = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.json({ message: 'Hello from TypeScript!', uid: process.getuid?.() });
});

app.get('/health', (req, res) => res.status(200).send('ok'));

app.listen(port, () => {
  console.log(`Server running on port ${port}`);
});
EOF

# .dockerignore
cat > .dockerignore << 'EOF'
node_modules
dist
.git
*.md
.env
EOF

cat > Dockerfile << 'EOF'
# ── 1단계: 프로덕션 의존성만 설치 ───────────────────────
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev

# ── 2단계: 빌드 (devDependencies 포함) ─────────────────
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY tsconfig.json .
COPY src/ ./src/
RUN npm run build

# ── 3단계: 최종 런타임 이미지 ─────────────────────────────
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production

COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json .

USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
EOF

# 빌드 및 크기 확인
docker build -t nodeapp:multi .
docker images nodeapp:multi

# TypeScript 컴파일러가 최종 이미지에 없음을 확인
docker run --rm nodeapp:multi sh -c "ls node_modules/.bin/tsc 2>/dev/null || echo 'tsc not found — devDeps excluded'"
4--target으로 특정 스테이지만 빌드

CI 파이프라인에서 테스트와 프로덕션 이미지를 분리하는 방법입니다.

로컬 터미널
cd ~/multistage-lab/node-app

# 빌드 스테이지까지만 빌드 (개발/디버그용)
docker build --target build -t nodeapp:build-stage .

# 빌드 스테이지 이미지에서 TypeScript 파일 확인 (개발 의존성 포함)
docker run --rm nodeapp:build-stage ls node_modules/.bin/tsc

echo "=== 전체 스테이지 이미지 크기 비교 ==="
docker images nodeapp

# BuildKit 캐시 활용 — 두 번째 빌드는 캐시 히트로 빠름
time docker build -t nodeapp:multi .
🔍실행 후 확인할 것
  • 단일 스테이지 대비 멀티 스테이지 결과에서 이미지 크기 차이를 수치로 확인했는가?
  • `COPY --from` 경로와 스테이지 이름을 정확히 맞춰 재현 가능한 빌드를 만들었는가?
  • `--target`으로 중간 스테이지를 분리 실행해 디버깅/CI 시간을 줄였는가?

트러블슈팅

빌드 스테이지에서 생성한 파일 경로가 잘못 지정되었을 때 발생합니다.

원인 진단: 빌드 스테이지에서 실제로 생성된 파일 경로를 확인합니다.

Docker
# 빌드 스테이지에서 실제 파일 위치 확인
docker build --target builder -t debug-builder .
docker run --rm debug-builder find /app -name "server" -o -name "*.js" 2>/dev/null | head -20

해결 방법: COPY --from에서 실제 바이너리 경로를 맞춥니다.

Dockerfile
# 잘못된 예시 — 바이너리 이름이 실제와 다름
COPY --from=builder /app/myserver /server  # 파일이 없음

# 올바른 예시 — go build의 -o 옵션과 일치시킴
RUN go build -o server .
COPY --from=builder /app/server /server

빌드 스테이지의 WORKDIR과 빌드 출력 경로를 정확히 일치시켜야 합니다. -o 플래그로 출력 파일명을 명시적으로 지정하면 혼란을 줄일 수 있습니다.

node_modules나 .git 디렉토리가 빌드 컨텍스트에 포함되면 발생합니다.

원인 진단: 빌드 컨텍스트에 포함된 파일 목록을 먼저 확인합니다.

로컬 터미널
# 빌드 컨텍스트에 무엇이 포함되는지 확인
# .dockerignore 없이 빌드 컨텍스트 크기 측정
du -sh node_modules .git dist 2>/dev/null

# docker build의 첫 번째 출력 줄 확인
docker build . 2>&1 | head -3

해결 방법: .dockerignore로 불필요한 파일을 빌드 컨텍스트에서 제외합니다.

로컬 터미널
# .dockerignore 생성
cat >> .dockerignore << 'EOF'
node_modules
.git
dist
.next
.nuxt
coverage
*.log
EOF

# 컨텍스트 크기 재확인
docker build . 2>&1 | head -3
# 예상 출력: Sending build context to Docker daemon  12.29kB

.dockerignore를 추가하는 것만으로도 빌드 시간이 30초에서 2초로 단축되는 경우도 있습니다.

distroless/static 이미지에서 CGO가 활성화된 바이너리를 실행할 때 발생합니다.

원인 진단: 바이너리의 링킹 방식을 확인합니다. 동적 링킹이면 필요한 라이브러리가 없어 실패합니다.

Docker
# 바이너리가 동적 링킹인지 정적 링킹인지 확인
docker run --rm --entrypoint sh golang:1.22-alpine -c \
  "cd /tmp && go build -o test main.go && ldd test"
# 정적이면: not a dynamic executable
# 동적이면: libpthread.so.0, libc.so.6 등 표시됨

해결 방법: CGO를 비활성화해서 정적 바이너리를 만들거나 필요한 라이브러리를 포함합니다.

Dockerfile
# CGO 비활성화로 완전 정적 바이너리 생성
RUN CGO_ENABLED=0 GOOS=linux go build -o server .

# CGO가 필요하다면 distroless/static 대신 distroless/base 사용
# (glibc 포함)
FROM gcr.io/distroless/base-debian12

C 라이브러리에 의존하는 Go 패키지(SQLite 드라이버 등)는 CGO_ENABLED=0으로 빌드할 수 없습니다. 이 경우 pure Go 대안 라이브러리를 사용하거나 distroless/base를 선택합니다.

실무 맥락

💼
실무 맥락배포 파이프라인에서 CI 빌드 이미지 크기를 줄여 배포 시간 단축
현업 패턴

실제 스타트업 현장에서 흔히 발생하는 상황입니다. CI/CD 파이프라인에서 이미지 빌드 → ECR push → ECS 배포까지 전체 사이클이 10분을 넘어가면 개발자 생산성이 크게 떨어집니다.

대표적인 개선 포인트:

  1. 이미지 크기 감소 → pull 시간 단축: AWS ECS는 배포 시 새 태스크 인스턴스에서 이미지를 pull합니다. 1 GB 이미지가 50 MB로 줄면 pull 시간이 약 60초에서 3초로 줄어듭니다.

  2. 레이어 캐시 최적화: 의존성 레이어(go.mod, package.json)가 소스코드 레이어보다 앞에 오면, 소스만 변경된 경우 의존성 설치 레이어를 재사용합니다. 자주 변경되지 않는 것 → 자주 변경되는 순서로 Dockerfile을 배치합니다.

  3. 빌드 캐시 마운트 활용: GitHub Actions나 GitLab CI에서 BuildKit 인라인 캐시를 ECR에 저장하면 CI 서버가 바뀌어도 캐시를 재사용할 수 있습니다.

Docker
# CI에서 캐시를 레지스트리에 저장/로드
docker buildx build \
  --cache-from type=registry,ref=123456789.dkr.ecr.ap-northeast-2.amazonaws.com/myapp:cache \
  --cache-to   type=registry,ref=123456789.dkr.ecr.ap-northeast-2.amazonaws.com/myapp:cache,mode=max \
  -t 123456789.dkr.ecr.ap-northeast-2.amazonaws.com/myapp:$GIT_SHA \
  --push .

한 팀에서 이 최적화를 적용한 후 전체 배포 사이클을 12분에서 3분 30초로 단축한 사례가 있습니다. 이미지 크기 감소, 레이어 캐시 활용, CI 캐시 저장 세 가지를 모두 적용한 결과입니다.

핵심 요약

개념명령/설정설명
스테이지 정의FROM image AS nameDockerfile에 여러 FROM 사용, 이름으로 참조
스테이지 간 복사COPY --from=name /src /dst이전 스테이지 결과물만 선택적으로 가져옴
정적 바이너리CGO_ENABLED=0 go build -ldflags="-s -w"distroless/static과 함께 사용 가능한 Go 바이너리
특정 스테이지 빌드docker build --target name .CI에서 테스트/프로덕션 이미지 분리
빌드 컨텍스트 최적화.dockerignore 파일node_modules, .git 등 제외 → 빌드 속도 향상
병렬 빌드BuildKit 자동 처리독립적인 스테이지는 동시 실행
의존성 캐시패키지 파일 먼저 COPY소스 변경 시에도 의존성 설치 레이어 캐시 재사용
distrolessgcr.io/distroless/static-debian12셸 없는 최소 이미지, 보안 강화

지식 확인

퀴즈 — 5문제

Q1

멀티 스테이지 빌드에서 `COPY --from=builder /app/dist ./dist` 구문의 의미는?

Q2

`docker build --target builder .` 명령어의 동작은?

Q3

distroless 이미지(예: gcr.io/distroless/base)의 특징으로 올바른 것은?

Q4

.dockerignore 파일에서 `node_modules`를 제외해야 하는 가장 중요한 이유는?

Q5

BuildKit을 활성화했을 때 멀티 스테이지 빌드에서 얻을 수 있는 추가 이점은?

0 / 5 답변

🧪 실습으로 확인하기

Docker Compose 멀티 서비스 구성

초급

docker-compose.yml로 nginx + 앱 컨테이너를 함께 정의하고, 서비스 간 통신과 볼륨 마운트를 구성한다.

35📋 4단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요

docker중급 · 50
[Docker] 컨테이너 상태 진단을 위한 헬스체크와 Restart Policy 설정
Docker 트랙 계속
networking입문 · 45
[Network] OSI 7계층과 TCP/IP 4계층 모델 실무적 관점 분석
Networking 트랙 시작점