티스토리 뷰

건설 장비는 입주 전에 치워야 한다

 집을 짓는다고 상상해보자. 공사할 때는 크레인, 시멘트 트럭, 인부들의 작업복이 필요하다.

하지만 집이 다 지어지고 입주할 때, 거실 한복판에 크레인을 놔두고 사는 사람은 없다.

 

소프트웨어 빌드도 똑같다. 애플리케이션을 '만들 때(Build)' 필요한 도구와, '실행할 때(Run)' 필요한 도구는 다르다.

  • 빌드: 타입스크립트 컴파일러(tsc), C++ 컴파일러(gcc), 전체 라이브러리, 테스트 도구
  • 실행: 컴파일된 결과물(.js, binary), 최소한의 런타임

초보 시절엔 이 두 가지를 하나의 이미지에 다 때려 넣는다.

 

그러니 이미지가 뚱뚱해질 수밖에 없다.

 

Multi-stage Build는 "공사장에서 완성품만 쏙 빼서, 깨끗한 새집으로 옮기는 기술"이다.


원리와 구현 (Core Knowledge)

핵심은 FROM 명령어를 여러 번 쓰는 것이다. 하나의 Dockerfile 안에서 이미지를 여러 개 만들고, 앞 단계에서 만든 결과물만 뒤 단계로 복사해오는 방식이다.

가장 극적인 효과를 볼 수 있는 NestJS(혹은 TypeScript) 프로젝트를 예시로 들어보겠다.

1. 나쁜 예: 단일 스테이지 빌드

# 1. 베이스 이미지
FROM node:18

WORKDIR /app

# 2. 의존성 설치 (모든 패키지 다 설치함)
COPY package*.json ./
RUN npm install

# 3. 소스 코드 복사 및 빌드
COPY . .
RUN npm run build

# 4. 실행
CMD ["npm", "run", "start:prod"]

문제점:

  • node_modules 안에 개발용 패키지(devDependencies)가 잔뜩 들어있다.
  • 타입스크립트 원본 소스(.ts)가 이미지에 남아있다.
  • 빌드하느라 생긴 캐시 파일들이 공간을 차지한다.
  • 결과: 이미지 크기 약 900MB ~ 1.2GB.

2. 좋은 예: 멀티 스테이지 빌드

이 코드는 크게 **Builder(공사장)**와 Runner(입주할 집) 두 단계로 나뉜다.

# ==========================================
# [Stage 1] Builder: 빌드만을 위한 임시 컨테이너
# ==========================================
FROM node:18-alpine AS builder

WORKDIR /app

# 빌드에 필요한 모든 패키지 설치
COPY package*.json ./
RUN npm ci

# 소스 코드 복사 후 빌드 (dist 폴더 생성)
COPY . .
RUN npm run build

# ==========================================
# [Stage 2] Runner: 실제 배포될 경량 컨테이너
# ==========================================
FROM node:18-alpine

WORKDIR /app

# [중요] 운영 환경(Production) 설정
ENV NODE_ENV=production

# 1. 실행에 필요한 패키지 파일만 복사
COPY package*.json ./

# 2. 실행용 의존성만 설치 (devDependencies 제외)
# 이렇게 하면 TS 컴파일러 같은 건 설치되지 않음
RUN npm ci --only=production && npm cache clean --force

# 3. [핵심] Builder 스테이지에서 '빌드된 결과물'만 가져옴
# --from=builder 옵션이 마법의 키워드다.
COPY --from=builder /app/dist ./dist

# 4. 권한 설정 및 실행
USER node
CMD ["node", "dist/main.js"]

개선점:

  • 최종 이미지에는 ts 파일도, webpack도, typescript 컴파일러도 없다. 오직 실행 가능한 js 파일과 최소한의 라이브러리만 있다.
  • 결과: 이미지 크기 약 100MB ~ 200MB. (1/10로 줄었다.)

[Deep Dive] 왜 보안이 좋아지는가?

용량이 줄어드는 건 눈에 보이는 이득이고, 보이지 않는 더 큰 이득은 **보안(Security)**이다.

단일 스테이지로 빌드하면 소스 코드 전체가 이미지에 들어간다. 실수로 .env 파일을 복사했거나, 코드에 하드코딩된 API 키가 있다면? 해커가 이미지를 뜯어보는 순간 다 털린다.

멀티 스테이지를 쓰면, Stage 1에서 소스 코드를 지지고 볶든 상관없다. Stage 2(최종 이미지)로 안 가져오면 그만이다. 최종 결과물에는 컴파일된 난독화 코드만 남으므로 원본 유출 위험이 현저히 줄어든다.


삽질 로그 (Troubleshooting)

멀티 스테이지를 처음 적용할 때 자주 겪는 멘붕 상황들이다.

[Case 1] 경로 실수 (File Not Found)

  • 상황/에러메시지: 빌드는 성공했는데 실행하자마자 죽는다. Error: Cannot find module '/app/dist/main.js'
  • 원인 분석: COPY --from=builder 할 때 경로를 잘못 지정한 거다. Builder 스테이지의 폴더 구조와 Runner 스테이지의 폴더 구조가 머릿속에서 섞인 탓이다.
  • 해결책: Builder 스테이지에서 RUN ls -al /app/dist 같은 명령어를 넣어 실제로 파일이 어디 생성되는지 로그로 확인해라. 대부분 /app을 빼먹거나, dist 폴더 안의 구조가 예상과 달라서 생긴다.

[Case 2] 네이티브 모듈 충돌 (bcrypt, sharp)

  • 상황: Stage 1(Builder)에서는 잘 설치됐는데, Stage 2(Runner)로 넘기니까 실행할 때 에러가 난다. Error: The module '...' was compiled against a different Node.js version using NODE_MODULE_VERSION ...
  • 원인 분석: 만약 Stage 1은 일반 리눅스(Debian)를 쓰고, Stage 2는 알파인(Alpine)을 썼다면? C++로 작성된 라이브러리(bcrypt 등)는 OS 환경에 맞춰 컴파일된다. Debian에서 빌드한 바이너리를 Alpine으로 가져오면 호환이 안 돼서 터진다.
  • 해결책: Builder와 Runner의 베이스 이미지 OS를 통일해라. Builder: node:18-alpine Runner: node:18-alpine 이렇게 맞춰야 바이너리 호환성 문제가 안 생긴다.

멀티 스테이지 빌드는 선택이 아니라 필수다. 특히 클라우드 비용을 아끼고 싶다면 더더욱 그렇다. 이미지 용량이 작으면 저장소 비용도 줄고, 오토스케일링 할 때 서버 뜨는 속도도 빨라진다.

오늘의 핵심 요약:

  1. 분리: 빌드 도구와 실행 환경을 분리해라.
  2. 선택적 복사: COPY --from을 사용해 결과물만 쏙 빼와라.
  3. 통일: 두 스테이지의 OS 버전을 맞춰라.

이제 이미지 하나는 기가 막히게 만들 수 있게 되었다. 하지만 요즘 서비스가 어디 컨테이너 하나로 돌아가나? 프론트엔드, 백엔드, DB, Redis까지 최소 4개는 띄워야 "아, 개발 좀 하겠네" 소리 나온다.

 

이걸 하나하나 docker run 명령어로 띄우다간 손가락 관절염 온다. 다음 글에서는 이 모든 컨테이너를 한 방에 관리하는 지휘자,

Docker Compose를 다루겠다.

 

 

끝!

반응형
Comments