소스 코드 한 줄만 바꿨는데 빌드할 때마다 npm install이 처음부터 다시 도는 경우가 있다. 이미지는 레이어(layer)의 스택이고 Docker는 레이어 단위로 빌드 결과를 캐시하기 때문이다. 어떤 레이어가 깨지면 그 아래 레이어가 줄줄이 다시 빌드된다. 그래서 Dockerfile의 명령어 순서가 빌드 속도를 좌우한다.
아래에서는 이미지를 빌드하면서 캐시가 깨지는 순간을 docker build 로그로 확인하고, 명령어 순서가 빌드 시간을 어떻게 바꾸는지 측정한다. 모든 출력은 Docker 29.3.1에서 돌린 결과다.
이미지는 읽기 전용 레이어의 스택이다
Dockerfile의 각 명령어(RUN, COPY, ADD 등)는 직전 상태로부터의 변경분만 담은 읽기 전용 레이어를 하나씩 만든다. 컨테이너를 실행하면 이 레이어들이 OverlayFS(현재 기본 스토리지 드라이버 overlay2) 위에서 하나의 파일 시스템 뷰로 합쳐진다.
docker history로 실제 레이어를 들여다보면 이 구조가 그대로 보인다. 아래는 nginx:1.27-alpine 이미지다.
docker history nginx:1.27-alpine --format "table {{.CreatedBy}}\t{{.Size}}"
CREATED BY SIZE
RUN /bin/sh -c set -x && apkArch="$(cat … 38.7MB
ENV NJS_RELEASE=1 0B
CMD ["nginx" "-g" "daemon off;"] 0B
STOPSIGNAL SIGQUIT 0B
EXPOSE map[80/tcp:{}] 0B
ENTRYPOINT ["/docker-entrypoint.sh"] 0B
COPY 30-tune-worker-processes.sh /docker-ent… 16.4kB
COPY docker-entrypoint.sh / # buildkit 8.19kB
RUN /bin/sh -c set -x && addgroup -g 101… 5.36MB
ENV NGINX_VERSION=1.27.5 0B
CMD ["/bin/sh"] 0B
ADD alpine-minirootfs-3.21.3-x86_64.tar.gz /… 8.5MB
ENV, CMD, EXPOSE 같은 메타데이터 명령어는 0B다. 레이어는 만들지만 파일 시스템을 바꾸지 않는다. 실제 용량을 차지하는 것은 베이스 이미지(ADD alpine-minirootfs, 8.5MB)와 패키지를 설치하는 RUN 레이어(38.7MB)다. 이미지 크기를 줄이려면 0B 레이어가 아니라 이 RUN 레이어를 봐야 한다.
레이어 캐시가 깨지는 순간
Docker는 레이어를 만들 때 명령어와 입력이 이전 빌드와 같은지 확인하고, 같으면 캐시된 레이어를 그대로 쓴다. 캐시는 다음 중 하나라도 달라지면 깨지고, 한 번 깨진 지점부터 그 아래 레이어는 전부 다시 빌드된다.
| 조건 | 예시 |
|---|---|
| 명령어 텍스트 변경 | RUN apt-get install nginx → RUN apt-get install -y nginx |
COPY/ADD 대상 파일 내용 변경 | 소스 코드 수정 |
ARG 값 변경 | --build-arg VERSION=2.0 |
| 선행 레이어가 깨짐 | 위쪽 레이어가 재빌드되면 아래는 무조건 재빌드 |
마지막 줄이 핵심이다. 자주 바뀌는 명령어를 Dockerfile 위쪽에 두면 그 아래 무거운 명령어들이 매번 같이 무효화된다.
순서만 바꿔도 18초에서 8초로
같은 Node.js 앱을 두 가지 Dockerfile로 빌드해 비교했다. 차이는 COPY와 RUN npm install의 순서뿐이다.
나쁜 순서 (Dockerfile.bad):
FROM node:20-alpine
WORKDIR /app
COPY . . # 소스 전체를 먼저 복사
RUN npm install --omit=dev # 그 뒤에 설치
좋은 순서 (Dockerfile.good):
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # 의존성 정의만 먼저
RUN npm install --omit=dev # 설치
COPY . . # 소스는 맨 마지막
두 이미지를 한 번 빌드해 캐시를 채운 뒤, 소스 파일(server.js) 한 줄만 바꾸고 다시 빌드했다. 좋은 순서 쪽의 빌드 로그다.
#7 [3/5] COPY package.json package-lock.json ./
#7 CACHED
#8 [4/5] RUN npm install --omit=dev
#8 CACHED
#9 [5/5] COPY . .
#9 DONE 0.8s
package.json이 그대로이므로 COPY package.json 레이어가 캐시에 적중하고, 그 결과 무거운 RUN npm install도 CACHED로 건너뛴다. 바뀐 소스는 맨 아래 COPY . .에서만 다시 복사된다.
나쁜 순서 쪽은 같은 변경에 이렇게 반응한다.
#7 [3/4] COPY . .
#7 DONE 0.8s
#8 [4/4] RUN npm install --omit=dev
#8 7.413 added 70 packages, and audited 71 packages in 6s
#8 DONE 8.1s
소스 한 줄이 바뀌면서 COPY . . 레이어가 깨지고, 바로 아래 npm install까지 무효화돼 패키지 70개를 다시 받는다. /usr/bin/time으로 잰 두 빌드의 전체 시간 차이다.
good rebuild: 8.21 s
bad rebuild: 18.61 s
명령어는 똑같고 순서만 다른데 같은 변경에 대한 빌드 시간이 두 배 이상 벌어진다. 자주 바뀌는 것(소스)일수록 Dockerfile 아래에, 거의 안 바뀌는 것(의존성 설치)일수록 위에 둔다.
레이어를 합쳐서 이미지 줄이기
레이어는 추가만 기록한다. 한 레이어에서 만든 파일을 다음 레이어에서 지워도, 이전 레이어에는 그 파일이 그대로 남아 최종 이미지 용량에 포함된다. 임시 파일은 만든 그 레이어 안에서 지워야 한다.
apt로 패키지를 설치하는 두 방식을 비교했다.
분리된 방식 (Dockerfile.split):
FROM debian:bookworm-slim
RUN apt-get update
RUN apt-get install -y --no-install-recommends curl ca-certificates
통합한 방식 (Dockerfile.merged):
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
빌드한 두 이미지의 크기다.
REPOSITORY:TAG SIZE
aptdemo:merged 135MB
aptdemo:split 170MB
docker history로 들여다보면 차이의 정체가 드러난다. 분리된 쪽에는 apt-get update가 만든 패키지 인덱스가 별도 레이어(19.8MB)로 남아 있다.
# split
RUN /bin/sh -c apt-get install -y --no-insta… 15.9MB
RUN /bin/sh -c apt-get update # buildkit 19.8MB ← 그대로 남음
# debian.sh ... 85.3MB
# merged
RUN /bin/sh -c apt-get update && apt-get… 15.9MB ← 인덱스는 같은 레이어에서 삭제됨
# debian.sh ... 85.3MB
통합한 쪽은 rm -rf /var/lib/apt/lists/*가 같은 RUN 안에서 실행되므로 인덱스가 애초에 레이어에 남지 않는다. 35MB 차이는 여기서 나온다.
.dockerignore도 같은 맥락이다. node_modules, .git 같은 디렉터리를 빌드 컨텍스트에서 제외하면 불필요한 파일이 COPY . .로 딸려 들어가지 않아 이미지가 가벼워지고 컨텍스트 전송도 빨라진다.
# .dockerignore
node_modules
.git
*.test.js
coverage/
멀티 스테이지 빌드
컴파일러나 빌드 도구는 빌드할 때만 필요하고 실행할 때는 필요 없다. 멀티 스테이지 빌드는 하나의 Dockerfile에 여러 FROM을 두어 빌드 환경과 실행 환경을 분리하고, 최종 이미지에는 결과물만 복사한다.
# 빌드 스테이지
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod ./
COPY . .
RUN CGO_ENABLED=0 go build -o main .
# 실행 스테이지
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /app/main . # 빌드 결과 바이너리만 가져옴
EXPOSE 8080
CMD ["./main"]
같은 Go 앱을 단일 스테이지(golang 이미지 그대로 사용)와 멀티 스테이지로 빌드해 크기를 비교했다.
REPOSITORY:TAG SIZE
gostage:multi 22.1MB
gostage:single 429MB
단일 스테이지는 Go 툴체인 전체(429MB)를 이미지에 그대로 안고 가지만, 멀티 스테이지는 컴파일된 바이너리만 alpine 위에 올려 22.1MB로 끝난다. 실행에 필요 없는 컴파일러가 통째로 빠진 결과로 95% 감소다.
분석 도구
레이어별 크기를 빠르게 보려면 앞에서 쓴 docker history가 가장 손쉽다. 크기순으로 보고 싶으면 형식을 지정한다.
docker history --format "table {{.Size}}\t{{.CreatedBy}}" <이미지>
레이어 안에서 어떤 파일이 추가·삭제됐는지까지 보려면 dive가 유용하다. 레이어를 하나씩 넘기며 변경된 파일 트리를 보여주고, 지워야 할 파일이 남아 있는지를 효율성 점수로 알려준다. CI에 물려 이미지 효율을 자동 검사할 수도 있다.
dive <이미지>
CI=true dive <이미지> --ci-config .dive-ci.yml