LabHub
배우기 러닝패스 코스

Building Images

Why the Build Cache Breaks Exactly There

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

각 레이어의 캐시 키는 부모 레이어의 다이제스트 + 자기 명령으로 계산됩니다. 그래서 위에서 하나가 깨지면 그 아래는 내용과 무관하게 전부 다시 실행됩니다.

Layer map: 부모 레이어의 다이제스트 + 자기 명령 · 의미를 모르고 · RUN 의 캐시 키는 명령 문자열 그 자체 · 캐시

왜 이게 필요했나

FROM node:22-bookworm-slim   # 1
WORKDIR /app                 # 2
COPY . .                     # 3  <- 소스 한 글자만 바뀌어도 여기서 깨진다
RUN npm ci                   # 4  <- 그래서 여기도
RUN npm run build            # 5  <- 여기도

주석 한 줄을 고쳐도 의존성 설치가 처음부터 다시 돕니다. 도커는 명령의 의미를 모르고, 그 자리에 있는 레이어의 부모가 달라졌다는 사실만 압니다.

어떻게 동작하나

명령 종류에 따라 캐시 키 계산 방식이 다릅니다. 이 차이를 모르면 진단이 계속 빗나갑니다.

여기서 오래된 오해를 하나 정정해야 합니다. "파일을 열어 저장(touch)만 해도 캐시가 깨진다"는 말은 구 빌더 시절 이야기입니다. 클래식 빌더는 파일 메타데이터에 수정 시각을 포함했지만, BuildKit 은 내용 해시와 모드, 소유권만 봅니다. CI 에서 git clone 을 새로 해 모든 파일의 mtime 이 현재 시각이 되어도 캐시에 영향이 없습니다. 여전히 캐시가 깨진다면 원인은 mtime 이 아니라 다른 곳에 있습니다.

빌드 캐시가 깨지는 자리 비교 — COPY . . 를 먼저 쓰면 3번에서 깨져 npm ci 와 build 까지 다시 돌고, package.json 만 먼저 COPY 하면 소스가 바뀌어도 npm ci 는 캐시에 적중한다

그래서 해법은 하나뿐입니다. 자주 바뀌는 것을 아래로 내리는 것.

COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

현장에서 만나는 모습

CI 에서만 유독 느린 경우가 있습니다. 로컬에서 두 번째 빌드가 4초인데 CI 는 90초에서 8분으로 늘었고, 로그를 보면 RUN npm ci 하나가 287.4초를 씁니다. 이유는 단순합니다 — 캐시는 빌더의 로컬 스토리지에 있는데 CI 러너는 매 실행마다 새 머신입니다. 그래서 레지스트리에 캐시를 내보내는 설정이 따로 필요하고, 이때 mode=max 를 빼면 최종 스테이지만 캐시되어 정작 비싼 빌더 스테이지가 매번 다시 돕니다.

.dockerignore 도 오해가 많습니다. 이것은 이미지 크기 최적화 도구가 아니라 전송량 최적화 도구입니다. 실측을 보면 컨텍스트 전송이 612.44MB / 18.3초에서 3.71MB / 0.4초로 줄었습니다. 다만 COPY . . 을 쓴다면 이미지 크기에도 영향이 가고, .env 를 제외하는 이유는 크기가 아니라 보안입니다.

캐시가 깨지는 규칙

레이어 캐시는 바로 앞 레이어가 같고 명령도 같을 때 재사용됩니다. 한 줄이 깨지면 그 아래는 전부 다시 만듭니다. 그래서 덜 바뀌는 것을 위로 올립니다.

# ❌ 소스가 한 글자만 바뀌어도 의존성을 다시 받는다
COPY . /app
RUN npm ci

# ✅ package.json 이 안 바뀌면 npm ci 는 캐시에서
COPY package*.json /app/
RUN npm ci
COPY . /app

COPY 는 파일 내용의 체크섬으로 판단하고, RUN명령 문자열만 봅니다. 그래서 RUN apt-get update && apt-get install -y curl 은 명령이 같으면 캐시를 쓰고, 그동안 저장소가 갱신됐어도 옛 패키지 목록을 씁니다. 이것이 "빌드는 됐는데 옛 버전이 들어갔다" 의 원인입니다.

.dockerignore 가 캐시를 지킨다

COPY . /app 의 체크섬에는 빌드 컨텍스트의 모든 파일 이 들어갑니다. .git, node_modules, 로그 파일이 바뀔 때마다 캐시가 깨집니다.

.git
node_modules
*.log
.env*
__pycache__

부수 효과가 하나 더 있습니다. 컨텍스트가 작아지면 데몬으로 전송하는 양 이 줄어 빌드 시작이 빨라집니다. .git 하나만 빼도 수백 MB 가 줄기도 합니다. 그리고 .env 를 빼는 것은 보안 문제이기도 합니다.

BuildKit 의 마운트 캐시

패키지 캐시를 레이어에 넣지 않고 빌드 사이에 공유 할 수 있습니다. 이미지 크기를 늘리지 않으면서 재빌드가 빨라집니다.

# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm     npm ci

RUN --mount=type=cache,target=/var/cache/apt     --mount=type=cache,target=/var/lib/apt/lists     apt-get update && apt-get install -y --no-install-recommends curl

비밀도 같은 방식으로 넣습니다. --mount=type=secret 으로 넘기면 레이어에 남지 않습니다.

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci

docker build --secret id=npmrc,src=$HOME/.npmrc . 로 넘깁니다. 파일을 COPY 했다가 지우는 방식과 달리 레이어에 흔적이 없습니다.

멀티스테이지로 최종 이미지를 줄이기

빌드 도구는 실행에 필요 없습니다. 별도 스테이지에서 만들고 결과물만 옮깁니다.

FROM golang:1.23 AS build
WORKDIR /src
COPY go.* ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app

FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/app /app
USER nonroot
ENTRYPOINT ["/app"]

최종 이미지에 컴파일러도 소스도 셸도 없습니다. 크기가 줄고 공격 표면도 줄어듭니다. 대신 디버깅할 때 셸이 없으니 네임스페이스를 공유하는 도구 컨테이너를 붙입니다.

다음 실습에서 할 것

같은 소스로 두 번 빌드해 이미지 ID 가 같은지 확인하고, 앞 레이어를 일부러 깨뜨려 아래가 전부 다시 도는 것을 보고, 삭제로는 이미지가 줄지 않는다는 것을 두 이미지의 크기 차이로 증명합니다.