멀티 스테이지 빌드와 이미지 경량화
CI는 통과했지만 배포 시간이 길고, 이미지 스캔 결과에는 불필요한 패키지 취약점이 쏟아집니다. 원인을 보면 빌드 도구와 런타임이 한 이미지에 섞여 있어 크기와 공격면이 함께 커진 상태입니다. 멀티 스테이지 빌드는 "작게 만드는 기술"을 넘어 운영 안정성과 보안 품질을 동시에 올리는 패턴입니다. 이 모듈에서 스테이지 분리, 베이스 선택, 캐시 최적화를 실무 기준으로 익힙니다.
Go 애플리케이션을 단순하게 빌드하면 이미지 크기가 1 GB를 넘기도 합니다. 하지만 멀티 스테이지 빌드를 적용하면 동일한 애플리케이션을 10~20 MB 이미지로 만들 수 있습니다. 이 챕터에서는 빌드 환경과 실행 환경을 완전히 분리하는 방법, 그리고 그 과정에서 활용하는 다양한 최적화 기법을 다룹니다.
단일 스테이지 빌드의 한계를 이해하고, 멀티 스테이지 빌드로 프로덕션급 경량 이미지를 만드는 전 과정을 학습합니다.
- 1멀티 스테이지 빌드 원리 — 빌드 레이어와 런타임 레이어를 분리하는 이유
- 2FROM ... AS 문법과 COPY --from으로 스테이지 간 파일 전달
- 3distroless / alpine 베이스 이미지 선택 기준과 트레이드오프
- 4.dockerignore로 빌드 컨텍스트 최적화 — 빌드 속도와 보안 동시 개선
- 5BuildKit 병렬 빌드와 --target 플래그로 CI 파이프라인 효율화
Go와 Node.js 두 가지 언어로 멀티 스테이지 빌드를 실습합니다. Go는 단일 바이너리 특성 덕분에 경량화 효과가 극적으로 나타납니다.
docker buildx versionmkdir -p ~/multistage-lab && cd ~/multistage-labgo version || echo 'Go not installed locally — will build inside Docker'별도 레포 클론 불필요
docker pull golang:1.22-alpine && docker pull gcr.io/distroless/static-debian12왜 빌드 환경과 실행 환경을 분리해야 하는가

단일 스테이지 빌드의 문제점
전통적인 방식으로 Go 앱을 패키징하면 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=이름 으로 해당 스테이지의 결과물만 골라 다음 스테이지로 전달합니다. 아래 예시에서 builder 스테이지의 컴파일러와 소스코드는 최종 이미지에 전혀 들어가지 않습니다.
# 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의 다양한 활용
# 이전 스테이지에서 복사 (스테이지 이름 사용)
COPY --from=builder /app/server .
# 이전 스테이지 인덱스로 참조 (0부터 시작)
COPY --from=0 /app/server .
# 완전히 다른 이미지에서 파일을 가져올 수도 있음
# (예: 특정 버전의 바이너리를 레지스트리에서 직접 추출)
COPY --from=alpine:3.19 /usr/bin/wget /usr/bin/wget
빌드 최적화 옵션
# -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에서는 주요 런타임 베이스 이미지의 특성과 언어별 선택 기준을 다룹니다.

런타임 베이스 이미지 비교
| 이미지 | 크기 | 셸 포함 | 패키지 관리자 | glibc | 권장 용도 |
|---|---|---|---|---|---|
ubuntu:22.04 | ~77 MB | bash | apt | O | 개발/디버깅 |
debian:bookworm-slim | ~74 MB | bash | apt | O | 범용 앱 |
alpine:3.19 | ~7 MB | sh | apk | X (musl) | 경량화 필요 앱 |
distroless/static | ~2 MB | X | X | X | Go 정적 바이너리 |
distroless/base | ~20 MB | X | X | O | glibc 필요 앱 |
scratch | 0 MB | X | X | X | 완전 정적 바이너리 |
Alpine 사용 시 주의사항
Alpine은 glibc 대신 musl libc를 사용합니다. 대부분의 Go/Python 앱에서는 문제없지만, glibc에 의존하는 C 확장 모듈(예: 일부 numpy 의존성)은 Alpine에서 실행 시 오류가 날 수 있습니다.
# 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 앱에 적합한 멀티 스테이지 패턴
# ── 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 작성 기준과 빌드 컨텍스트 크기를 측정하는 방법을 다룹니다.

빌드 컨텍스트란?
docker build . 를 실행하면 . 경로의 모든 파일이 Docker 데몬으로 전송됩니다. 이것을 빌드 컨텍스트라고 합니다. 로컬 Docker는 빠르지만, CI 서버나 원격 BuildKit 데몬을 사용하는 경우 수백 MB의 컨텍스트 전송이 병목이 됩니다.
효과적인 .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 활성화
BuildKit은 Docker 23.0부터 기본 활성화되어 있습니다. 구버전 환경이라면 환경변수로 활성화합니다.
# 일회성 활성화
DOCKER_BUILDKIT=1 docker build .
# 영구 활성화 (Docker 데몬 설정)
# /etc/docker/daemon.json
{
"features": { "buildkit": true }
}
# docker buildx는 항상 BuildKit 사용
docker buildx build .
병렬 빌드 활용 예시
BuildKit은 서로 의존하지 않는 스테이지를 자동으로 병렬 실행합니다.
# 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 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
기본 실습
실습 디렉토리를 만들고 간단한 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이제 멀티 스테이지 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
실제 프로젝트에서 더 자주 쓰는 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'"
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 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에서 실제 바이너리 경로를 맞춥니다.
# 잘못된 예시 — 바이너리 이름이 실제와 다름
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 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를 비활성화해서 정적 바이너리를 만들거나 필요한 라이브러리를 포함합니다.
# 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/CD 파이프라인에서 이미지 빌드 → ECR push → ECS 배포까지 전체 사이클이 10분을 넘어가면 개발자 생산성이 크게 떨어집니다.
대표적인 개선 포인트:
-
이미지 크기 감소 → pull 시간 단축: AWS ECS는 배포 시 새 태스크 인스턴스에서 이미지를 pull합니다. 1 GB 이미지가 50 MB로 줄면 pull 시간이 약 60초에서 3초로 줄어듭니다.
-
레이어 캐시 최적화: 의존성 레이어(go.mod, package.json)가 소스코드 레이어보다 앞에 오면, 소스만 변경된 경우 의존성 설치 레이어를 재사용합니다. 자주 변경되지 않는 것 → 자주 변경되는 순서로 Dockerfile을 배치합니다.
-
빌드 캐시 마운트 활용: GitHub Actions나 GitLab CI에서 BuildKit 인라인 캐시를 ECR에 저장하면 CI 서버가 바뀌어도 캐시를 재사용할 수 있습니다.
# 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 name | Dockerfile에 여러 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 | 소스 변경 시에도 의존성 설치 레이어 캐시 재사용 |
| distroless | gcr.io/distroless/static-debian12 | 셸 없는 최소 이미지, 보안 강화 |