infra

Docker 멀티스테이지 빌드로 Node.js 이미지 크기를 줄이는 방법

Node.js Docker 이미지를 그냥 빌드하면 1GB가 넘기도 한다. 멀티스테이지 빌드를 쓰면 빌드 도구를 최종 이미지에서 제외해서 수백 MB로 줄일 수 있다. Dockerfile 작성부터 .dockerignore까지 정리했다.

Docker멀티스테이지빌드Node.js이미지최적화infra
멀티스테이지 빌드가 적용된 Node.js Dockerfile — builder 스테이지와 runner 스테이지로 나뉘고 최종 이미지 크기가 줄어든 docker images 출력 화면
  • ·멀티스테이지 빌드: 하나의 Dockerfile에 여러 FROM 지시어, 최종 이미지에는 마지막 스테이지만 포함
  • ·node:alpine: Debian 기반 node 이미지 대비 약 3배 작은 Alpine Linux 기반 경량 이미지
  • ·COPY --from=builder: 이전 스테이지에서 빌드된 파일만 복사해 최종 이미지 구성
  • ·.dockerignore: node_modules, .git 등 불필요한 파일을 빌드 컨텍스트에서 제외
Next.js 앱을 Docker로 처음 컨테이너화했을 때 이미지 크기가 1.5GB였다. 개발 의존성과 빌드 도구가 모두 포함됐기 때문이었다. 멀티스테이지 빌드로 builder 스테이지에서 npm ci와 npm run build를 하고 runner 스테이지에서는 빌드 산출물과 프로덕션 의존성만 복사하니 이미지가 200MB 아래로 줄었다. .dockerignore에 node_modules를 추가하는 것만으로도 빌드 컨텍스트 전송 시간이 크게 단축됐다.

Docker 멀티스테이지 빌드란 무엇인가

Docker 멀티스테이지 빌드가 Node.js 이미지 최적화에 필요한 이유

Node.js 앱을 Dockerfile 한 단계로 빌드하면 최종 이미지에 빌드 도구와 개발 의존성이 모두 포함된다. TypeScript 프로젝트라면 tsc 컴파일러, Next.js라면 빌드에 필요한 대량의 패키지가 들어간다. 이 모든 것이 포함된 이미지는 1GB가 넘기도 한다. 컨테이너 레지스트리에 push하고 pull하는 시간이 길어지고, 서버에서 차지하는 디스크 공간도 크다. 이미지에 불필요한 도구가 많을수록 보안 취약점이 생길 수 있는 표면도 늘어난다. 멀티스테이지 빌드는 이 문제를 해결한다. 하나의 Dockerfile 안에 빌드 스테이지와 실행 스테이지를 분리한다. 빌드 스테이지에서 모든 의존성을 설치하고 앱을 빌드한다. 실행 스테이지는 빌드 스테이지에서 만들어진 산출물만 복사해서 가져온다. 최종 이미지에는 빌드 도구나 개발 의존성이 없고 앱 실행에 필요한 파일만 들어간다. COPY --from=빌드스테이지 명령으로 이전 스테이지의 파일을 선택적으로 가져올 수 있다. 단순한 Dockerfile 분리만으로 이미지 크기를 수 배 줄이는 효과를 볼 수 있다.

Node.js 멀티스테이지 Dockerfile 작성하기

Node.js 앱을 위한 멀티스테이지 Dockerfile을 작성하는 방법

멀티스테이지 Dockerfile은 FROM 지시어가 여러 번 나온다. 각 FROM이 새 스테이지의 시작이다. AS 키워드로 스테이지에 이름을 붙이면 나중에 참조하기 편하다. 첫 번째 스테이지인 builder는 빌드 환경이다. node:20 이미지를 쓰고 WORKDIR을 설정한다. package.json과 package-lock.json을 먼저 COPY하고 npm ci로 모든 의존성을 설치한다. 그 다음 소스 코드를 COPY하고 npm run build를 실행한다. 두 번째 스테이지인 runner는 실제 실행 환경이다. 경량 이미지인 node:20-alpine을 쓰면 첫 스테이지보다 훨씬 작은 기반 이미지로 시작한다. npm ci --omit=dev로 프로덕션 의존성만 설치한다. COPY --from=builder로 빌드 스테이지에서 만들어진 빌드 결과물을 가져온다. 최종 이미지에 들어가는 것은 두 번째 스테이지의 내용뿐이다. 첫 번째 스테이지는 빌드 과정에서만 쓰이고 최종 이미지에 포함되지 않는다.

# Dockerfile
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]

Next.js 앱을 위한 Docker 멀티스테이지 빌드와 standalone 출력 설정

Next.js 앱을 Docker로 빌드할 때는 next.config.ts에 output: 'standalone' 설정을 추가하면 멀티스테이지 빌드와 잘 맞는다. standalone 모드로 빌드하면 .next/standalone 폴더에 앱 실행에 필요한 최소한의 파일만 모아진다. node_modules 전체를 복사하지 않아도 되어서 최종 이미지 크기가 크게 줄어든다. builder 스테이지에서 npm ci와 next build를 실행하고, runner 스테이지에서 .next/standalone 폴더와 .next/static, public 폴더를 복사한다. standalone 폴더 안에 server.js 파일이 있고 이 파일로 앱을 시작한다. Next.js 공식 문서에서 Docker 배포 예시로 이 패턴을 권장하고 있다. 실제로 적용해보면 Next.js 앱 이미지가 1GB 이상에서 150~200MB 수준으로 줄어드는 효과를 볼 수 있다. .dockerignore에 node_modules, .next, .git을 추가하면 빌드 컨텍스트 크기도 줄어서 docker build 속도도 빨라진다.

# next.config.ts
const config = { output: 'standalone' }

# Next.js 전용 멀티스테이지 Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

Docker 이미지 최적화 추가 설정

.dockerignore로 Docker 빌드 컨텍스트를 최적화하는 방법

.dockerignore 파일은 docker build 명령을 실행할 때 Docker 데몬에 전송되는 빌드 컨텍스트에서 제외할 파일과 디렉토리를 지정한다. node_modules 폴더가 수백 MB에 달하는 경우 .dockerignore에 추가하지 않으면 빌드할 때마다 이 용량 전체를 전송해서 시간이 오래 걸린다. .git 디렉토리도 제외해야 한다. 커밋 이력이 많으면 .git 폴더가 수십 MB가 되는데 빌드에는 전혀 필요 없다. .next, dist, out 같은 이전 빌드 산출물도 제외한다. Dockerfile 자체와 .dockerignore 파일은 포함해도 되지만 최종 이미지에 들어가지는 않는다. .env 파일은 반드시 제외해야 한다. 빌드 컨텍스트에 포함되면 이미지에 민감한 환경변수가 들어갈 수 있다. README.md, 테스트 파일, CI 설정 파일도 빌드에 불필요하므로 제외하면 컨텍스트 크기를 더 줄일 수 있다. .dockerignore 파일을 추가한 후 docker build 명령을 실행하면 Sending build context to Docker daemon 메시지에서 컨텍스트 크기가 줄어든 것을 확인할 수 있다.

Docker 멀티스테이지 빌드에서 캐시를 활용해 빌드 속도를 높이는 방법

Docker는 레이어 캐시를 활용해서 변경되지 않은 레이어는 다시 빌드하지 않는다. Dockerfile의 레이어 순서를 최적화하면 캐시 히트율을 높여 빌드 시간을 단축할 수 있다. 의존성 설치와 소스 코드 복사를 분리하는 패턴이 핵심이다. COPY package*.json ./과 RUN npm ci를 소스 코드 COPY보다 먼저 배치하면 package.json이 바뀌지 않는 한 npm ci 레이어가 캐시된다. 소스 코드만 바뀐 경우 npm ci는 캐시를 그대로 쓰고 이후 단계만 다시 실행된다. 반대로 COPY . . 한 줄로 모든 파일을 복사하면 소스 코드가 변경될 때마다 npm ci부터 다시 실행된다. 프로덕션 환경에서 자주 빌드하는 CI/CD 파이프라인에서는 이 차이가 빌드 시간에 크게 영향을 준다. Docker BuildKit을 사용하면 캐시 마운트를 활용해 npm 캐시를 레이어 밖에 저장할 수 있다. RUN --mount=type=cache,target=/root/.npm npm ci 형태로 쓰면 의존성이 바뀌어도 npm 패키지 캐시가 유지되어 재설치 시간이 줄어든다.

자주 묻는 질문

멀티스테이지 빌드를 쓰면 이미지 크기가 얼마나 줄어드나요?+

프로젝트마다 다르지만 일반적으로 50~80% 감소합니다. 개발 의존성이 많거나 빌드 도구가 무거운 TypeScript, Next.js 프로젝트에서 효과가 특히 큽니다. node-alpine 기반 이미지를 쓰면 추가로 줄일 수 있습니다.

node:alpine 이미지를 쓸 때 주의할 점이 있나요?+

Alpine Linux는 glibc 대신 musl libc를 씁니다. 일부 네이티브 모듈(sharp, bcrypt 등)이 Alpine에서 빌드 오류를 낼 수 있습니다. 이 경우 node:slim이나 일반 node 이미지를 써야 합니다. 또한 Alpine에는 bash가 없고 sh만 있습니다.

.dockerignore에 node_modules를 추가하면 빌드가 느려지지 않나요?+

오히려 빨라집니다. Dockerfile의 npm ci 명령이 컨테이너 안에서 의존성을 새로 설치하기 때문에 호스트의 node_modules는 필요 없습니다. node_modules를 빌드 컨텍스트에서 제외하면 전송 시간이 크게 줄어들고 캐시도 더 정확하게 동작합니다.

멀티스테이지 빌드에서 특정 스테이지만 빌드하고 싶으면 어떻게 하나요?+

docker build --target builder . 형태로 --target 옵션에 스테이지 이름을 지정하면 됩니다. 개발 환경에서 builder 스테이지만 쓰거나, CI에서 빌드 결과를 테스트하고 싶을 때 유용합니다.

관련 글