Docker 이미지 1.1GB → 130MB: 네 가지 방식으로 빌드해 단계별로 비교하기

Docker 이미지 멀티스테이지 빌드로 크기 줄이기 - 컨테이너 최적화

이미지 크기를 줄이는 건 미관 문제가 아닙니다. 빌드 시간, 배포 속도, 레지스트리 비용, 그리고 보안 취약점 수에 전부 영향을 줍니다. 특히 AKS에서 노드가 스케일 아웃될 때 이미지를 당겨오는 시간이 곧 서비스 대응 속도가 됩니다.

이 글은 같은 애플리케이션을 네 가지 방식으로 빌드해서 크기를 비교하고, 각 단계에서 무엇이 줄어드는지 확인합니다. 마지막에는 크기 외에 함께 얻는 것들도 정리합니다.

출발점: 흔히 쓰는 단일 스테이지

Node.js 애플리케이션을 예로 들겠습니다. 처음에는 대개 이렇게 씁니다.

# Dockerfile.v1 - 단일 스테이지
FROM node:20

WORKDIR /app
COPY . .
RUN npm install
RUN npm run build

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

동작은 합니다. 문제는 최종 이미지에 필요 없는 게 전부 들어 있다는 것입니다. 구체적으로는 이렇습니다.

  • 빌드 도구 — TypeScript 컴파일러, webpack 등. 실행 시엔 불필요
  • devDependencies — 테스트 프레임워크, 린터, 타입 정의
  • 소스 코드 — 빌드 결과물만 있으면 되는데 원본까지
  • 전체 OS 배포판node:20은 Debian 전체를 포함합니다. 컴파일러, 패키지 매니저, 셸 도구 일체
  • .git 디렉터리COPY . .로 통째로 들어갑니다

마지막 항목은 크기 문제를 넘어 보안 문제이기도 합니다. 커밋 이력에 있던 시크릿이 이미지 안으로 따라 들어갑니다.

1단계: .dockerignore 추가

Dockerfile을 고치기 전에 이것부터입니다. 비용 대비 효과가 가장 큽니다.

# .dockerignore
.git
.github
node_modules
dist
build
coverage
.env
.env.*
*.log
.vscode
.idea
README.md
docs/
**/*.test.ts
**/*.spec.ts
Dockerfile*
docker-compose*

node_modules를 넣는 이유가 중요합니다. 로컬의 node_modules가 컨텍스트로 전송되면 빌드가 느려질 뿐 아니라, 로컬 OS용으로 컴파일된 네이티브 모듈이 컨테이너에 들어가 런타임 오류를 냅니다. 맥에서 빌드한 bcrypt가 리눅스 컨테이너에서 안 도는 식입니다.

2단계: 멀티스테이지 빌드

핵심 아이디어는 단순합니다. 빌드하는 컨테이너와 실행하는 컨테이너를 분리하고, 빌드 결과물만 옮깁니다.

# Dockerfile.v2 - 멀티스테이지
# ---------- 빌드 스테이지 ----------
FROM node:20-alpine AS builder

WORKDIR /app

# 의존성 파일만 먼저 복사 (레이어 캐시 활용)
COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# 프로덕션 의존성만 다시 설치
RUN npm ci --omit=dev

# ---------- 실행 스테이지 ----------
FROM node:20-alpine AS runner

WORKDIR /app
ENV NODE_ENV=production

# 빌드 결과물과 프로덕션 의존성만 가져온다
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

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

여기서 두 가지가 중요합니다.

  • package*.json을 먼저 복사하는 이유 — Docker는 레이어 단위로 캐시합니다. 소스만 바뀌고 의존성이 그대로면 npm ci 레이어가 재사용되어 빌드가 훨씬 빨라집니다. COPY . .를 먼저 하면 파일 하나만 고쳐도 의존성을 다시 설치합니다
  • npm cinpm install의 차이cipackage-lock.json을 정확히 따르고 node_modules를 지우고 새로 설치합니다. 빌드 재현성이 보장됩니다

3단계: distroless로 OS까지 걷어내기

Alpine까지 왔으면 대부분 만족스럽지만, 한 단계 더 갈 수 있습니다. distroless는 셸도, 패키지 매니저도, 심지어 ls도 없는 이미지입니다. 런타임과 애플리케이션만 들어 있습니다.

# Dockerfile.v3 - distroless
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm ci --omit=dev

# ---------- distroless 실행 스테이지 ----------
FROM gcr.io/distroless/nodejs20-debian12 AS runner

WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

# distroless에는 셸이 없으므로 exec 형식만 가능
CMD ["dist/main.js"]

distroless의 진짜 가치는 크기보다 공격 표면입니다. 컨테이너에 침투해도 셸이 없어서 명령을 실행할 수 없고, 패키지 매니저가 없어서 도구를 설치할 수도 없습니다.

대가도 분명합니다. 디버깅이 어렵습니다. kubectl exec로 들어가도 셸이 없어서 아무것도 못 합니다. 이때는 kubectl debug로 디버그 컨테이너를 붙이는 방식을 씁니다.

# distroless 컨테이너 디버깅 - 임시 컨테이너를 같은 프로세스 네임스페이스에 붙임
kubectl debug -it <POD> -n myapp \
  --image=busybox:1.36 \
  --target=<CONTAINER_NAME>

# 붙은 뒤 대상 컨테이너의 파일시스템 접근
ls /proc/1/root/app

이 방법을 모르면 distroless 도입 후 첫 장애에서 곤란해집니다. AKS CrashLoopBackOff 원인 추적에 관련 내용을 더 정리해두었습니다.

단계별 크기 비교

같은 애플리케이션을 네 방식으로 빌드했을 때의 대략적인 크기입니다. 애플리케이션에 따라 달라지지만 비율의 경향은 일관됩니다.

방식베이스 이미지대략적 크기감소율
단일 스테이지node:20약 1.1 GB
+ .dockerignorenode:20약 1.0 GB약 9%
멀티스테이지 + Alpinenode:20-alpine약 180 MB약 84%
+ distrolessdistroless/nodejs20약 130 MB약 88%

표에서 읽어야 할 것은 대부분의 감소가 3번 행에서 일어난다는 점입니다. 멀티스테이지 + Alpine 조합이 핵심이고, distroless는 보안을 위한 추가 선택입니다.

직접 재보시려면 이렇게 확인합니다.

# 각 방식으로 빌드해서 크기 비교
for V in v1 v2 v3; do
  docker build -f "Dockerfile.$V" -t "myapp:$V" . > /dev/null 2>&1
done

docker images myapp --format "table {{.Tag}}\t{{.Size}}"

# 레이어별로 무엇이 용량을 먹는지 확인
docker history myapp:v1 --human --format "table {{.Size}}\t{{.CreatedBy}}" | head -20

docker history가 유용합니다. 어느 RUN 명령이 용량을 만들었는지 바로 보입니다. 예상 밖의 레이어가 크다면 거기부터 손보면 됩니다.

Alpine을 쓸 때 주의할 점

Alpine이 작은 이유는 glibc 대신 musl libc를 쓰기 때문입니다. 여기서 문제가 생길 수 있습니다.

  • 네이티브 모듈 호환성 — glibc를 전제로 컴파일된 바이너리가 안 돕니다. Python의 일부 휠(wheel), Node의 일부 네이티브 애드온이 해당됩니다
  • Python 성능 이슈 — musl의 메모리 할당자 특성상 일부 Python 워크로드가 눈에 띄게 느려질 수 있습니다
  • DNS 해석 차이 — musl의 리졸버 동작이 glibc와 달라, 특정 환경에서 이름 해석 문제가 보고됩니다

Alpine에서 문제가 생기면 -slim 태그가 좋은 절충안입니다. Debian 기반이라 호환성은 유지하면서 전체 이미지보다 훨씬 작습니다. node:20-slim, python:3.12-slim 같은 식입니다.

크기 외에 함께 얻는 것

취약점 수가 줄어든다

이미지에 든 패키지가 적을수록 알려진 취약점(CVE)도 적습니다. 전체 Debian 이미지에는 애플리케이션이 쓰지도 않는 패키지의 취약점이 수백 개씩 잡힙니다. 보안 스캔을 CI에 넣으면 이 차이가 바로 드러납니다.

# ACR 내장 스캔 결과 확인 (Defender for Containers 필요)
az acr repository show-manifests \
  --name acrprodkrc01 --repository myapp \
  --detail --query "[].{tag:tags[0], size:imageSize, created:createdTime}" -o table

# 또는 Trivy로 로컬 스캔
trivy image --severity HIGH,CRITICAL myapp:v1
trivy image --severity HIGH,CRITICAL myapp:v3

두 결과를 비교해보면 멀티스테이지 전환의 명분이 크기보다 여기에 있다는 걸 알게 됩니다.

비루트 사용자로 실행하기

이미지를 정리하는 김에 함께 해두면 좋은 것입니다. 기본적으로 컨테이너는 root로 실행됩니다. 침해 시 피해가 커집니다.

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# 전용 사용자 생성
RUN addgroup -g 1001 -S nodejs \
 && adduser  -u 1001 -S nodejs -G nodejs

COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules

USER nodejs
EXPOSE 3000
CMD ["node", "dist/main.js"]

--chownCOPY에 붙이는 게 요령입니다. 복사 후 RUN chown을 따로 하면 레이어가 하나 더 생기면서 용량이 두 배가 됩니다. 파일 소유권만 바뀐 복사본이 통째로 쌓이기 때문입니다.

쿠버네티스 쪽에서도 강제할 수 있습니다. 이미지가 root로 실행되려 하면 파드 자체가 뜨지 않습니다.

securityContext:
  runAsNonRoot: true
  runAsUser: 1001
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]

배포 속도가 빨라진다

AKS에서 이미지 크기는 스케일 아웃 반응 속도와 직결됩니다. 새 노드가 뜰 때 이미지를 당겨와야 하는데, 1.1GB와 130MB는 체감 차이가 큽니다. 트래픽이 급증해 노드가 늘어나는 상황에서 이 시간이 곧 지연으로 나타납니다.

ACR과 AKS를 같은 리전에 두는 것도 함께 챙기세요. 다른 리전이면 이미지를 당길 때마다 리전 간 트래픽 요금이 붙고 속도도 느립니다.

정리

  1. .dockerignore부터 만든다 (node_modules, .git 필수)
  2. 의존성 파일을 먼저 복사해 레이어 캐시를 살린다
  3. 멀티스테이지 + Alpine으로 대부분의 감소를 얻는다 (여기가 핵심)
  4. 보안이 중요하면 distroless까지, 단 디버깅 방법을 먼저 익힌다
  5. 비루트 사용자로 실행하고 --chownCOPY에 붙인다
  6. docker history어느 레이어가 큰지 확인하며 조정한다

Alpine 호환성 문제가 생기면 무리하지 말고 -slim으로 물러서는 게 낫습니다. 크기를 100MB 더 줄이려다 원인 모를 런타임 오류를 디버깅하는 건 손해입니다.

배포 파이프라인 쪽은 GitHub Actions vs Azure Pipelines를, 컨테이너 배포 대상 선택은 App Service·Container Apps·AKS 비교를 참고하세요.

댓글 남기기