티스토리 뷰
건설 장비는 입주 전에 치워야 한다
집을 짓는다고 상상해보자. 공사할 때는 크레인, 시멘트 트럭, 인부들의 작업복이 필요하다.
하지만 집이 다 지어지고 입주할 때, 거실 한복판에 크레인을 놔두고 사는 사람은 없다.
소프트웨어 빌드도 똑같다. 애플리케이션을 '만들 때(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 이렇게 맞춰야 바이너리 호환성 문제가 안 생긴다.
멀티 스테이지 빌드는 선택이 아니라 필수다. 특히 클라우드 비용을 아끼고 싶다면 더더욱 그렇다. 이미지 용량이 작으면 저장소 비용도 줄고, 오토스케일링 할 때 서버 뜨는 속도도 빨라진다.
오늘의 핵심 요약:
- 분리: 빌드 도구와 실행 환경을 분리해라.
- 선택적 복사: COPY --from을 사용해 결과물만 쏙 빼와라.
- 통일: 두 스테이지의 OS 버전을 맞춰라.
이제 이미지 하나는 기가 막히게 만들 수 있게 되었다. 하지만 요즘 서비스가 어디 컨테이너 하나로 돌아가나? 프론트엔드, 백엔드, DB, Redis까지 최소 4개는 띄워야 "아, 개발 좀 하겠네" 소리 나온다.
이걸 하나하나 docker run 명령어로 띄우다간 손가락 관절염 온다. 다음 글에서는 이 모든 컨테이너를 한 방에 관리하는 지휘자,
Docker Compose를 다루겠다.
끝!
'Docker' 카테고리의 다른 글
| Docker] 6. 도커 레지스트리와 배포 (0) | 2025.12.05 |
|---|---|
| [Docker] 5. Docker Compose: 컨테이너 오케스트라의 지휘자 (0) | 2025.12.05 |
| [Docker] 3. Dockerfile 작성법: 한 줄 한 줄의 의미 (0) | 2025.12.05 |
| [Docker] 2. 설치 (0) | 2025.12.05 |
| [Docker] 1. Docker, 제대로 좀 알고 쓰자 (0) | 2025.12.05 |
- 인천 구월동 이탈리안 맛집
- react
- redux
- 파니노구스토
- 맛집
- 이탈리안 레스토랑
- 정보보안기사 #실기 #정리
- react-native
- javascript
- Async
- redux-thunk
- Promise
- AsyncStorage
- await
- 인천 구월동 맛집
- Total
- Today
- Yesterday
