이미지 크기를 줄이는 건 미관 문제가 아닙니다. 빌드 시간, 배포 속도, 레지스트리 비용, 그리고 보안 취약점 수에 전부 영향을 줍니다. 특히 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 ci와npm install의 차이 —ci는package-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 | — |
| + .dockerignore | node:20 | 약 1.0 GB | 약 9% |
| 멀티스테이지 + Alpine | node:20-alpine | 약 180 MB | 약 84% |
| + distroless | distroless/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"]
--chown을 COPY에 붙이는 게 요령입니다. 복사 후 RUN chown을 따로 하면 레이어가 하나 더 생기면서 용량이 두 배가 됩니다. 파일 소유권만 바뀐 복사본이 통째로 쌓이기 때문입니다.
쿠버네티스 쪽에서도 강제할 수 있습니다. 이미지가 root로 실행되려 하면 파드 자체가 뜨지 않습니다.
securityContext:
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
배포 속도가 빨라진다
AKS에서 이미지 크기는 스케일 아웃 반응 속도와 직결됩니다. 새 노드가 뜰 때 이미지를 당겨와야 하는데, 1.1GB와 130MB는 체감 차이가 큽니다. 트래픽이 급증해 노드가 늘어나는 상황에서 이 시간이 곧 지연으로 나타납니다.
ACR과 AKS를 같은 리전에 두는 것도 함께 챙기세요. 다른 리전이면 이미지를 당길 때마다 리전 간 트래픽 요금이 붙고 속도도 느립니다.
정리
.dockerignore부터 만든다 (node_modules,.git필수)- 의존성 파일을 먼저 복사해 레이어 캐시를 살린다
- 멀티스테이지 + Alpine으로 대부분의 감소를 얻는다 (여기가 핵심)
- 보안이 중요하면 distroless까지, 단 디버깅 방법을 먼저 익힌다
- 비루트 사용자로 실행하고
--chown은COPY에 붙인다 docker history로 어느 레이어가 큰지 확인하며 조정한다
Alpine 호환성 문제가 생기면 무리하지 말고 -slim으로 물러서는 게 낫습니다. 크기를 100MB 더 줄이려다 원인 모를 런타임 오류를 디버깅하는 건 손해입니다.
배포 파이프라인 쪽은 GitHub Actions vs Azure Pipelines를, 컨테이너 배포 대상 선택은 App Service·Container Apps·AKS 비교를 참고하세요.