Docker 이미지를 아무 생각 없이 작성하면 빌드할 때마다 수백 MB를 다시 내려받고, CI 파이프라인이 5분씩 막힌다. 핵심은 Layer 캐시를 최대한 재사용하도록 Dockerfile 명령어 순서를 설계하는 것이다. 변경 빈도가 낮은 레이어를 위에, 자주 바뀌는 소스 코드를 아래에 배치하면 대부분의 빌드에서 캐시가 그대로 살아남는다.
Docker는 각 명령어(RUN, COPY, ADD 등)를 독립 레이어로 저장한다. 어떤 레이어가 바뀌면 그 아래의 모든 레이어 캐시가 무효화된다.
Dockerfile 실행 순서 (캐시 관점)
[FROM node:20-alpine] ← 거의 변경 없음 (캐시 유지)
│
[COPY package*.json ./] ← 의존성 변경 시에만 무효화
│
[RUN npm ci] ← 위가 캐시 hit → 이것도 캐시 hit
│
[COPY . .] ← 소스 변경마다 무효화
│
[RUN npm run build] ← 항상 위와 함께 재실행
소스 코드(COPY . .)를 npm ci 위에 두면 소스가 한 글자만 바뀌어도 npm ci가 매번 다시 실행된다. 순서만 바꿔도 빌드 시간이 절반 이하로 줄어드는 경우가 흔하다.
COPY . . 명령어는 빌드 컨텍스트 전체를 Docker 데몬에 전송한다. .dockerignore가 없으면 node_modules, .git, 로컬 빌드 산출물까지 모두 전송되어 캐시 무효화가 빈번하게 발생한다.
# .dockerignore 예시
node_modules
.git
dist
.env*
*.log
빌드 컨텍스트(Build Context) — docker build 명령을 실행한 디렉터리가 데몬으로 전송되는 파일 묶음. 크기가 클수록 전송 시간과 캐시 무효화 확률이 높아진다.
node:20(약 1 GB) 대신 node:20-alpine(약 170 MB)을 쓰면 이미지 크기와 취약점 노출 면적이 모두 줄어든다.
| 베이스 이미지 | 압축 크기 | 패키지 매니저 |
|---|---|---|
| node:20 | ~360 MB | apt |
| node:20-slim | ~90 MB | apt (최소) |
| node:20-alpine | ~60 MB | apk |
Alpine에서 네이티브 모듈이 필요하다면 apk add --no-cache python3 make g++를 먼저 설치한다. 이 줄은 의존성 설치보다 앞에, FROM 바로 다음에 넣어야 캐시 효율이 좋다.
Multi-stage Build는 빌드 환경과 실행 환경을 분리해 최종 이미지에 컴파일러, 소스 코드, 테스트 파일이 남지 않도록 한다.
# Stage 1: 빌드
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: 실행 (builder 산출물만 복사)
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
EXPOSE 3000
CMD ["node", "dist/main.js"]
runner 스테이지에는 소스 코드와 devDependencies가 없다. 일반적으로 최종 이미지 크기가 빌드 스테이지 대비 40~60% 줄어든다.
COPY package*.json → RUN npm ci → COPY . . 순서를 지킨다..dockerignore에 node_modules, .git, 빌드 산출물을 추가한다.alpine 변형으로 교체한다.