프로그래밍/DevOps

[DevOps] Docker 이미지 최적화 — 멀티스테이지 빌드로 용량 확 줄이기

이슬먹는 개발자 2026. 7. 11. 14:23
728x90
반응형

Docker를 좀 쓰다 보면 이런 걸 발견한다.

docker images

REPOSITORY   TAG      SIZE
my-app       latest   1.2GB   ← 엥? 내 앱이 이렇게 크다고?

간단한 앱 하나 만들었을 뿐인데 이미지가 1GB를 훌쩍 넘는다. 이게 왜 문제냐면, 이미지가 클수록 빌드도 느리고, 레지스트리에 올리고 내리는 것도 느리고, 배포도 느리다. 컨테이너의 장점인 "가볍고 빠르게"가 무색해진다.

오늘은 이 뚱뚱한 이미지를 날씬하게 만드는 법을 다룬다. 핵심은 멀티스테이지 빌드(multi-stage build)다.


왜 이미지가 이렇게 커질까?

이유는 크게 두 가지다.

첫째, 베이스 이미지가 무겁다. FROM node:20처럼 그냥 쓰면, 이 이미지 안에는 앱 실행에 꼭 필요하지 않은 것까지 잔뜩 들어있다. 컴파일러, 빌드 도구, 각종 시스템 라이브러리... 개발할 땐 편하지만 실제 실행엔 필요 없는 것들이다.

둘째, 빌드 과정의 찌꺼기가 남는다. 앱을 빌드하려면 개발 의존성, 소스코드, 빌드 도구가 필요하다. 근데 빌드가 끝나고 실제로 실행할 때는 그 결과물(빌드된 파일)만 있으면 되지, 빌드에 쓴 도구들은 필요 없다. 그런데 그것들이 이미지에 그대로 남아서 용량을 잡아먹는다.

정리하면, "빌드에 필요한 것"과 "실행에 필요한 것"이 다른데, 다 이미지에 넣어버려서 뚱뚱해지는 거다.


해결책 1: 가벼운 베이스 이미지 쓰기 (alpine, slim)

가장 쉬운 첫걸음이다. 같은 Node라도 훨씬 가벼운 버전이 있다.

FROM node:20          # 약 1GB
FROM node:20-slim     # 약 200MB
FROM node:20-alpine   # 약 130MB

alpine은 Alpine Linux라는 초경량 리눅스를 기반으로 한 이미지다. 꼭 필요한 것만 들어있어서 아주 작다. slim은 그 중간쯤이다.

-alpine으로 바꾸는 것만으로도 용량이 확 준다. 다만 alpine은 일부 라이브러리가 빠져 있어서, 특정 패키지가 안 깔리는 경우가 가끔 있다. 그럴 땐 slim으로 타협하면 된다.


해결책 2: 멀티스테이지 빌드 (핵심)

진짜 핵심은 이거다. 아이디어는 단순하다. "빌드하는 곳"과 "실행하는 곳"을 분리하자.

  • 1단계(빌드 스테이지): 무거운 도구를 다 써서 앱을 빌드한다.
  • 2단계(실행 스테이지): 깨끗한 가벼운 이미지에, 빌드 결과물만 쏙 가져와서 실행한다.

빌드에 쓴 도구들은 1단계에 두고 버리고, 최종 이미지에는 실행에 필요한 것만 남기는 거다.

프론트엔드(예: React) 앱을 예로 보자.

# ===== 1단계: 빌드 =====
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build          # 결과물이 /app/dist 에 생김

# ===== 2단계: 실행 =====
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80

포인트는 두 군데다.

FROM node:20 AS builder — 첫 스테이지에 builder라는 이름을 붙였다. 여기선 무거운 node 이미지로 마음껏 빌드한다.

COPY --from=builder /app/dist ... — 두 번째 스테이지에서 --from=builder1단계의 결과물(dist 폴더)만 가져온다. node나 npm, 소스코드, 개발 의존성은 하나도 안 가져온다. 오직 빌드된 정적 파일만.

최종 이미지는 가벼운 nginx:alpine에 정적 파일만 얹은 거라, 원래 1GB가 넘던 게 수십 MB 수준으로 줄어든다.


백엔드도 마찬가지 (예: Go, Java)

멀티스테이지는 컴파일 언어에서 특히 효과가 극적이다. Go를 예로 들면,

# ===== 1단계: 빌드 =====
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN go build -o server        # 실행 파일 하나 생성

# ===== 2단계: 실행 =====
FROM alpine:latest
COPY --from=builder /app/server /server
CMD ["/server"]

Go는 컴파일하면 실행 파일 하나가 딱 나온다. 그러니 최종 이미지엔 Go 컴파일러도, 소스코드도 필요 없다. 그냥 그 실행 파일 하나만 가벼운 alpine에 얹으면 끝이다. 이러면 이미지가 수백 MB에서 10~20MB 수준까지도 내려간다.


보너스: .dockerignore로 불필요한 것 제외

COPY . .을 할 때 node_modules.git 같은 무겁고 불필요한 폴더까지 복사되면 빌드도 느려지고 이미지도 커진다. 이걸 막는 게 .dockerignore 파일이다. .gitignore랑 사용법이 똑같다.

node_modules
.git
.env
*.log
dist

프로젝트 루트에 이 파일을 두면, 여기 적힌 것들은 이미지에 안 들어간다. 사소해 보여도 빌드 속도랑 용량에 꽤 효과가 있다.


정리

방법 효과 난이도
가벼운 베이스 (alpine/slim) 용량 확 감소 쉬움
멀티스테이지 빌드 빌드 찌꺼기 완전 제거 보통
.dockerignore 불필요한 파일 제외 쉬움

핵심을 다시 정리하면 이렇다.

  • 이미지가 뚱뚱한 건 빌드용 도구까지 최종 이미지에 남기 때문이다.
  • 멀티스테이지 빌드로 "빌드하는 곳"과 "실행하는 곳"을 나누고, COPY --from으로 결과물만 가져오면 용량이 극적으로 준다.
  • 여기에 가벼운 베이스 이미지 + .dockerignore를 곁들이면 완성이다.

작은 이미지는 그냥 보기 좋은 게 아니라, 배포가 빨라지고 저장 비용이 줄고 보안 표면도 작아지는 실질적인 이득이 있다. 실무 Dockerfile은 거의 다 멀티스테이지로 짜여 있으니, 이 패턴은 꼭 손에 익혀두자.

이걸로 컨테이너/Docker 계열을 마무리한다. 이제 컨테이너를 만들고, 여러 개 묶고, 최적화까지 할 수 있게 됐다. 다음부터는 이 컨테이너를 자동으로 테스트하고 배포하는 CI/CD 세계로 들어가보자.

728x90
반응형