11장. 레이어 캐시와 빌드 순서 — 한 줄 고쳤는데 왜 다 다시 만드나
코드 한 줄을 고치고 다시 빌드했더니 한쪽은 17.6초, 다른 쪽은 3.7초였습니다. 같은 앱, 같은 명령입니다. Dockerfile에서 COPY를 어디에 놓느냐만 달랐어요.
{/* 삽화 자리: 같은 재료로 탑을 쌓는 두 사람. 왼쪽은 맨 아래 블록을 갈아 끼우려고 탑 전체를 허물고 있고, 오른쪽은 맨 위 블록 하나만 바꿔 끼운다. 아래 블록들은 그대로 서 있다. 프롬프트: docs/book/illustration-prompts-container.md */}
배경
4장에서 이미지가 레이어로 쌓이고 같은 층은 한 번만 저장된다고 했죠. 8장에서는 위층이 아래층을 덮고, 지워도 아래층엔 남는다고 했고요.
Dockerfile을 쓰는 일은 그 층을 어떤 순서로 쌓을지 정하는 일입니다. 순서가 두 가지를 바꿉니다. 빌드가 얼마나 걸리는지, 그리고 이미지가 얼마나 큰지요.
여기서부터 3부입니다. 2부는 alpine 셸로 원리를 팠어요. 3부는 여는 글의 그 Spring Boot 앱 하나만 붙들고 갑니다. 담고, 띄우고, 재우고, 재는 데까지요. docker stop에 3.2초를 쓰고 exit 137로 죽던 그 앱이고, 사고의 원인은 12장에서 풉니다.
스토리
같은 앱을 두 가지 순서로 빌드해봤습니다.
먼저 순서를 생각 안 한 판입니다. naive라고 부를게요.
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
COPY app/ .
RUN mvn -q -B package -DskipTests소스와 pom.xml을 한 번에 복사하고 빌드합니다. 자연스러운 순서예요.
다음은 순서를 나눈 판입니다.
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
COPY app/pom.xml .
RUN mvn -q -B dependency:go-offline
COPY app/src ./src
RUN mvn -q -B package -DskipTestspom.xml을 먼저 복사해 의존성을 미리 받아두고, 그다음에 소스를 복사합니다. 두 줄이 늘었어요. 이쪽이 ordered입니다.
둘 다 이렇게 빌드합니다.
cd docs/book/code/container
docker build -f ch11/Dockerfile.naive -t ch11-naive .
docker build -f ch11/Dockerfile.ordered -t ch11-ordered .두 판 모두 캐시를 데워두고, 소스 한 줄만 고친 뒤 다시 빌드했습니다.
=== 소스 한 줄만 고치고 재빌드 ===
naive 17.6초
ordered 3.7초✅ 실측 (Docker Engine 29.7.2 / Docker Desktop 4.87.0 / Apple M1 / macOS 26.6, 2026-09. Spring Boot 3.3.5 / Maven 3.9 / Temurin 21. 3회 반복 시 naive 17.118.5초, ordered 3.23.7초. 전체 소스: docs/book/code/container/ch11/rebuild.sh)
네 배 안팎입니다. naive의 17초는 거의 전부 의존성을 다시 내려받는 시간이라 네트워크에 따라 흔들려요.
띄워보면 이렇게 답합니다.
docker run --rm -p 8080:8080 ch11-ordered &
curl localhost:8080hello from pid 11번입니다. 이 숫자가 12장 전체의 주제예요.
핵심
캐시는 줄 단위로 끊깁니다
왜 이런 차이가 나는지는 Docker가 캐시를 판단하는 방식을 보면 됩니다.
RUN·COPY·ADD가 레이어를 만듭니다. ENV나 CMD, WORKDIR은 메타데이터만 바꿔서 docker history에 0B로 찍혀요.
Docker는 위에서부터 한 줄씩 내려오면서 이 줄의 입력이 전과 같은지 봅니다. 결과가 아니라 입력이에요. COPY는 복사할 파일의 내용, RUN은 명령 문자열이요. 명령을 실제로 돌려서 결과를 비교하는 게 아닙니다. 그러면 캐시를 쓰는 의미가 없으니까요.
이 구분이 중요합니다. 확인해보면 바로 보여요.
printf 'FROM alpine:3.20\nRUN date +%%s%%N > /stamp\n' > Dockerfile
docker build -q -t ck1 . && docker run --rm ck1 cat /stamp
docker build -q -t ck2 . && docker run --rm ck2 cat /stamp 1차: 1789952112
2차: 1789952112✅ 실측 (같은 환경)
date의 결과는 분명히 달라졌는데 다시 안 돌았습니다. 명령 문자열이 같으니 캐시를 쓴 거예요.
RUN apt-get update가 몇 달 된 패키지 목록을 계속 돌려주는 이유가 이겁니다. RUN git clone이 어제 커밋을 물어오는 것도요.
그러다 한 줄이 달라지면 그 줄부터 아래는 전부 다시 만듭니다. 아래층이 바뀌었으니 그 위에 쌓인 것도 믿을 수 없거든요.
이제 두 판을 다시 보세요.
| naive | ordered | |
|---|---|---|
COPY app/ 또는 COPY pom.xml | 소스가 바뀌었으니 캐시 깨짐 | pom.xml은 안 바뀜, 캐시 유지 |
| 의존성 받기 | (같은 줄에 묶여 있음) | 캐시 유지 |
COPY src | — | 소스가 바뀌었으니 캐시 깨짐 |
| 빌드 | 다시 | 다시 |
naive는 소스 한 글자만 바뀌어도 COPY app/에서 캐시가 깨집니다. 그 아래가 RUN mvn package 하나뿐이라, 의존성 내려받기부터 전부 다시 합니다.
ordered는 pom.xml이 안 바뀌었으니 의존성 레이어가 살아남아요. 다시 하는 건 소스 복사와 컴파일뿐입니다.
규칙 하나로 줄이면 이렇습니다. 자주 바뀌는 것을 아래에 두세요. 의존성 목록은 한 달에 한 번 바뀌고 소스는 하루에 스무 번 바뀌니까요.
잘못 짜놓고도 빠르다고 느낍니다
한 가지 더 재봤습니다. 아무것도 안 고치고 그냥 다시 빌드하면요.
=== 아무것도 안 고치고 재빌드 (완전 캐시 히트) ===
naive 0.6초
ordered 0.6초✅ 실측 (같은 환경, 같은 소스)
똑같습니다. 둘 다 모든 줄이 캐시에 맞으니까요.
이게 중요한 이유가 있습니다. Dockerfile 순서를 잘못 짜놓고도 “빌드 빠른데?”라고 느낄 수 있거든요. 차이는 코드를 고친 뒤에만 나타납니다. 하루에 스무 번 겪는 그 상황에서요.
그러니 Dockerfile 순서를 잴 때는 반드시 소스를 한 줄 고친 뒤에 재세요.
멀티스테이지: 빌드 도구를 안 남기기
순서 말고 크기도 봅시다.
지금까지 본 Dockerfile은 FROM이 두 번 나옵니다. 첫 번째는 Maven 이미지에서 빌드하고, 두 번째는 JRE 이미지에 결과물만 옮겨요. 이걸 멀티스테이지 빌드라고 합니다.
안 쓰면 어떻게 되는지 재봤습니다.
=== 이미지 크기: 멀티스테이지를 쓰면 ===
ch11-singlestage 642 MB
ch11-ordered 346 MB✅ 실측 (같은 환경, 같은 소스)
296MB 차이입니다. 무엇이 그 차이인지 docker history로 열어봤습니다.
docker history ch11-singlestage 22.7MB RUN mvn -q -B package -DskipTests
1.37kB COPY app/src ./src
87.2MB RUN mvn -q -B dependency:go-offline
1.34kB COPY app/pom.xml .
0B WORKDIR /build
… (그 아래로 JDK·Maven 베이스 레이어들)✅ 실측 (같은 환경, 같은 소스)
의존성 캐시 ~/.m2가 87MB, 빌드 산출물이 22.7MB입니다. 그리고 그 아래에 JDK 전체와 Maven 이미지가 깔아둔 것들이 있어요. git이나 ssh 클라이언트 같은 것도 그 안에 있습니다.
앱을 돌리는 데는 하나도 필요 없는 것들입니다. 털렸을 때 공격자가 쓸 것들이기도 하고요.
멀티스테이지는 마지막 FROM부터가 최종 이미지입니다. 앞 단계는 빌드하는 동안만 존재하고, COPY --from=build로 가져온 것만 넘어옵니다.
FROM maven:3.9-eclipse-temurin-21 AS build
# ... 빌드 ...
FROM eclipse-temurin:21-jre ← 여기부터가 최종 이미지
COPY --from=build /build/target/app.jar app.jar디스크만의 이야기는 아닙니다. 다만 4장에서 배운 걸 여기 적용해야 해요. 642MB와 346MB는 풀어놓은 크기고, 레지스트리로 오가는 건 압축된 값입니다. 그래서 전송량이 296MB 그대로 줄지는 않아요. 자릿수는 그대로 줄지만요.
그리고 안 들어간 것은 취약점도 안 가져옵니다. 컨테이너 안에 컴파일러가 있을 이유가 없잖아요.
지워도 안 줄어듭니다
8장에서 whiteout을 봤습니다. 지우는 게 진짜 지우는 게 아니라 “없는 걸로 쳐라” 표시를 위층에 올리는 거라고요.
그 성질이 Dockerfile에서 어떻게 드러나는지 재봤습니다. 50MB 파일을 만들고 지우는 이미지 둘인데, 하나는 두 줄로 하고 하나는 한 줄로 했어요.
# 두 줄
RUN dd if=/dev/zero of=/big.bin bs=1M count=50
RUN rm /big.bin
# 한 줄
RUN dd if=/dev/zero of=/big.bin bs=1M count=50 && rm /big.bin=== 지웠는데 왜 안 줄어드나 ===
만들기 RUN / 지우기 RUN (두 줄) 58 MB
만들고 && 지우기 (한 줄) 8 MB
베이스 alpine 8 MB✅ 실측 (같은 환경, 같은 소스)
두 줄로 하면 50MB가 그대로 남습니다. 한 줄로 하면 베이스와 같은 크기예요.
이유는 8장 그대로입니다. RUN마다 레이어가 하나씩 생기는데, 첫 줄이 만든 레이어에는 50MB 파일이 들어 있어요. 둘째 줄은 그 위에 whiteout만 올립니다. 아래층은 못 건드리니까요. 완성된 이미지에서 파일은 안 보이지만 층에는 그대로 있습니다.
한 줄로 묶으면 레이어가 하나만 생기고, 그 레이어가 완성될 때 파일은 이미 없습니다.
apt-get update && apt-get install && rm -rf /var/lib/apt/lists/*를 한 줄로 쓰는 관행이 여기서 나옵니다. 세 줄로 나눠 쓰면 패키지 목록이 이미지에 영원히 남아요.
그래서 Dockerfile을 쓰는 규칙
세 가지로 정리됩니다.
| 규칙 | 왜 | 실측 |
|---|---|---|
| 자주 바뀌는 것을 아래로 | 바뀐 줄부터 아래가 다시 만들어진다 | 17.6초 → 3.7초 |
| 빌드 도구는 앞 단계에 두고 결과물만 옮기기 | 마지막 FROM부터가 최종 이미지 | 642MB → 346MB |
만들고 지우는 건 한 RUN 안에서 | 레이어가 완성된 뒤에는 못 지운다 | 58MB → 8MB |
세 규칙 모두 8장의 구조에서 바로 나옵니다. 레이어는 순서대로 쌓이고, 위층이 아래층을 덮고, 아래층은 안 지워집니다. 이미지가 그렇게 생겼다는 걸 알면 Dockerfile 규칙은 외울 게 아니라 따라 나오는 겁니다.
언제 이게 안 통하나
세 가지를 짚어두겠습니다.
첫째, CI에는 캐시가 없습니다. 새 러너는 매번 빈 상태로 시작하니 순서를 아무리 잘 짜도 17초 쪽으로 돌아와요. CI에서도 효과를 보려면 캐시를 따로 실어 날라야 합니다. --cache-from·--cache-to나, 의존성 디렉터리를 캐시 마운트로 잡는 RUN --mount=type=cache,target=/root/.m2요. 후자는 pom.xml이 바뀌어도 이미 받아둔 걸 재사용하니 이 장의 규칙보다 한 수 위입니다.
둘째, 멀티스테이지에는 대가가 있습니다. JRE 이미지에는 jcmd·jmap 같은 진단 도구가 없어요. 운영 중에 힙 덤프를 못 뜹니다. 13장에서 메모리를 들여다볼 때 이 빚이 돌아옵니다.
셋째, &&로 한 줄에 묶으면 크기는 줄지만 캐시 단위가 굵어집니다. 서른 줄짜리 체인은 그 안의 한 글자만 바뀌어도 전부 다시 돌아요. 크기와 캐시 세밀도를 맞바꾸는 겁니다.
그리고 .dockerignore가 없으면 순서를 아무리 잘 짜도 소용없습니다. COPY . .이 target/이나 .git/을 같이 집어 가면 빌드마다 캐시가 깨지거든요.
정리
- Dockerfile의 각 줄이 레이어 하나를 만들고, 한 줄이 바뀌면 그 줄부터 아래가 전부 다시 만들어집니다.
- 그래서 자주 바뀌는 것을 아래에 둬야 합니다. 실측에서
pom.xml을 먼저 복사하는 것만으로 코드 수정 후 재빌드가 17.6초에서 3.7초로 줄었습니다. - 아무것도 안 고치면 두 판 다 0.6초입니다. 순서를 잘못 짜놓고도 모를 수 있어요. 차이는 코드를 고친 뒤에만 보입니다.
- 멀티스테이지는 마지막
FROM부터가 최종 이미지입니다. 빌드 도구를 안 남기니 실측에서 642MB가 346MB가 됐습니다. - 만든 것을 다음 줄에서 지우면 이미지는 안 줄어듭니다. 8장의 whiteout 그대로예요. 실측에서 58MB와 8MB로 갈렸습니다.
- 캐시가 보는 건 결과가 아니라 입력입니다.
RUN은 명령 문자열만 봐요. 그래서RUN date를 두 번 빌드하면 같은 시각이 나옵니다. - CI에는 캐시가 없고, JRE 이미지에는 진단 도구가 없고,
&&한 줄은 캐시 단위를 굵게 만듭니다..dockerignore가 없으면 순서도 소용없고요.
생각해볼 질문
앞에서 띄워봤을 때 앱이 hello from pid 1이라고 답했습니다.
그런데 여는 글에서 본 그 사고가 아직 남아 있습니다. 배포할 때마다 docker stop이 3.2초를 기다렸다가 exit 137로 죽던 것이요.
그때 범인이 Dockerfile 마지막 줄이라고 했죠. 대괄호 두 개가 있고 없고의 차이라고요.
이 장에서 Dockerfile을 한참 들여다봤으니 이제 그 줄로 돌아갈 차례입니다. CMD java -jar app.jar와 CMD ["java", "-jar", "app.jar"]는 무엇이 다르길래, 종료 신호가 앱에 닿고 안 닿고가 갈릴까요.
그리고 PID 1이라는 자리는 왜 그렇게 특별할까요.