ビルドキャッシュはなぜその場所で壊れるのか
한국어 원문으로 표시합니다.
한 줄 요약
각 레이어의 캐시 키는 부모 레이어의 다이제스트 + 자기 명령으로 계산됩니다. 그래서 위에서 하나가 깨지면 그 아래는 내용과 무관하게 전부 다시 실행됩니다.
왜 이게 필요했나
FROM node:22-bookworm-slim # 1
WORKDIR /app # 2
COPY . . # 3 <- 소스 한 글자만 바뀌어도 여기서 깨진다
RUN npm ci # 4 <- 그래서 여기도
RUN npm run build # 5 <- 여기도
주석 한 줄을 고쳐도 의존성 설치가 처음부터 다시 돕니다. 도커는 명령의 의미를 모르고, 그 자리에 있는 레이어의 부모가 달라졌다는 사실만 압니다.
어떻게 동작하나
명령 종류에 따라 캐시 키 계산 방식이 다릅니다. 이 차이를 모르면 진단이 계속 빗나갑니다.
RUN의 캐시 키는 명령 문자열 그 자체입니다. 무엇을 하는지는 보지 않습니다. 그래서RUN apt-get update한 줄은 몇 달이 지나도 영원히 캐시에 적중합니다.apt-get update와install을 같은 RUN 에 붙여야 하는 진짜 이유는 크기가 아니라 캐시입니다.COPY/ADD의 캐시 키는 대상 파일들의 내용 해시입니다.
여기서 오래된 오해를 하나 정정해야 합니다. "파일을 열어 저장(touch)만
해도 캐시가 깨진다"는 말은 구 빌더 시절 이야기입니다. 클래식 빌더는
파일 메타데이터에 수정 시각을 포함했지만, BuildKit 은 내용 해시와 모드,
소유권만 봅니다. CI 에서 git clone 을 새로 해 모든 파일의 mtime 이
현재 시각이 되어도 캐시에 영향이 없습니다. 여전히 캐시가 깨진다면
원인은 mtime 이 아니라 다른 곳에 있습니다.
그래서 해법은 하나뿐입니다. 자주 바뀌는 것을 아래로 내리는 것.
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 가 같은지 확인하고, 앞 레이어를 일부러 깨뜨려 아래가 전부 다시 도는 것을 보고, 삭제로는 이미지가 줄지 않는다는 것을 두 이미지의 크기 차이로 증명합니다.