- Published on
도커 빌드 캐시가 자꾸 깨지는 이유 — 레이어 캐시 키, ARG, BuildKit 캐시 마운트
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 주석 한 줄 고쳤는데 왜 npm ci가 다시 도는가
- 캐시 무효화에는 규칙이 하나뿐이다
- COPY의 캐시 키는 경로가 아니라 파일 내용이다
- 의존성 레이어 분리 — 정석 패턴과 그 함정
- ARG, 그리고 캐시를 매번 깨는 것들
- CI 러너에는 캐시가 없다 — 캐시 마운트와 레지스트리 캐시
- 재현성과 캐시는 서로를 밀어낸다
- 마치며 — 캐시가 안 먹으면 먼저 어디서 깨졌는지부터 본다
들어가며 — 주석 한 줄 고쳤는데 왜 npm ci가 다시 도는가
CI 파이프라인이 처음에는 90초였습니다. 지금은 8분입니다. 로그를 보면 매번 같은 줄에서 시간을 씁니다.
=> [builder 4/7] RUN npm ci 287.4s
=> [builder 5/7] RUN npm run build 41.2s
의존성은 3주째 그대로인데도 매번 5분 가까이 다시 설치합니다. 로컬에서는 두 번째 빌드가 4초에 끝나므로 "캐시가 되긴 되는 것 같은데" 라는 애매한 상태로 방치됩니다.
원인은 거의 항상 둘 중 하나입니다. Dockerfile의 명령 순서가 캐시 키를 매번 바꾸고 있거나, CI 러너에 애초에 캐시가 존재하지 않는 것입니다. 두 문제는 해결책이 완전히 다르므로 먼저 어느 쪽인지 구분해야 합니다.
캐시 무효화에는 규칙이 하나뿐이다
도커 빌드 캐시는 체인입니다. 각 레이어의 캐시 키는 부모 레이어의 다이제스트와 자기 명령으로 계산됩니다. 그래서 부모가 바뀌면 자식은 내용과 무관하게 전부 다시 실행됩니다.
FROM node:22-bookworm-slim # 1
WORKDIR /app # 2
COPY . . # 3 <- 소스 한 글자만 바뀌어도 여기서 깨짐
RUN npm ci # 4 <- 그래서 여기도 깨짐
RUN npm run build # 5 <- 여기도
COPY . .이 세 번째에 있으므로 소스 파일 하나만 수정해도 3번 이후가 전부 무효화됩니다. npm ci는 package-lock.json이 그대로인지 아닌지와 무관하게 다시 실행됩니다. 도커는 명령의 의미를 모르고, 그 자리에 있는 레이어의 부모가 달라졌다는 사실만 압니다.
이 규칙의 따름정리가 최적화의 전부입니다. 자주 바뀌는 것을 아래로 내리고, 드물게 바뀌는 것을 위로 올립니다.
COPY의 캐시 키는 경로가 아니라 파일 내용이다
명령별로 캐시 키 계산 방식이 다릅니다. 이 차이를 모르면 엉뚱한 곳을 고치게 됩니다.
RUN의 캐시 키는 명령 문자열 그 자체입니다. 명령이 무엇을 하는지는 보지 않습니다. 그래서 아래 두 줄은 몇 달이 지나도 영원히 캐시에 적중합니다.
RUN apt-get update
RUN curl -fsSL https://get.example.com/install.sh | sh
원격 저장소의 패키지 인덱스가 갱신되어도, 설치 스크립트가 바뀌어도 도커는 알지 못합니다. 이것이 apt-get update와 apt-get install을 반드시 같은 RUN에 붙여야 하는 진짜 이유입니다. 크기 때문이 아니라 캐시 때문입니다.
COPY와 ADD의 캐시 키는 대상 파일들의 내용 해시입니다. 여기서 흔한 오해가 하나 있습니다. "파일을 열어 저장만 해도 캐시가 깨진다"는 말은 구 빌더 시절의 이야기입니다. 클래식 빌더는 파일 메타데이터에 수정 시각을 포함했기 때문에 touch 한 번으로 캐시가 날아갔습니다. BuildKit은 내용 해시와 모드, 소유권만 봅니다.
DOCKER_BUILDKIT=1 docker build -t t1 . >/dev/null
touch src/index.ts
DOCKER_BUILDKIT=1 docker build -t t1 . 2>&1 | grep -E 'CACHED|RUN'
=> CACHED [2/5] WORKDIR /app
=> CACHED [3/5] COPY package.json package-lock.json ./
=> CACHED [4/5] RUN npm ci
=> CACHED [5/5] COPY . .
touch만으로는 깨지지 않습니다. 반대로 CI에서 git clone을 새로 하면 모든 파일의 mtime이 현재 시각이 되는데, BuildKit에서는 이것이 캐시에 영향을 주지 않습니다. 여전히 캐시가 깨진다면 원인은 mtime이 아니라 다른 곳에 있습니다.
의존성 레이어 분리 — 정석 패턴과 그 함정
정석은 의존성을 선언하는 파일만 먼저 복사하는 것입니다.
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
이제 소스만 바뀌면 COPY . .부터 무효화되고 npm ci는 캐시에서 나옵니다.
언어별로 대응되는 파일 쌍은 이렇습니다.
# Python
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Go
COPY go.mod go.sum ./
RUN go mod download
# Rust
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release
# Java (Maven)
COPY pom.xml ./
RUN mvn -B dependency:go-offline
여기에 두 가지 함정이 있습니다.
첫째, 모노레포에서 COPY package.json ./만 복사하면 워크스페이스 하위 패키지의 매니페스트가 빠져 npm ci가 실패합니다. 와일드카드로 디렉터리 구조를 유지하며 복사해야 합니다.
COPY package.json package-lock.json ./
COPY packages/api/package.json ./packages/api/
COPY packages/web/package.json ./packages/web/
COPY packages/shared/package.json ./packages/shared/
RUN npm ci
패키지가 늘어날 때마다 이 목록을 갱신해야 하는 것이 번거롭다면, BuildKit 1.7 이상에서 COPY --parents를 쓸 수 있습니다.
# syntax=docker/dockerfile:1.7-labs
COPY --parents package.json package-lock.json packages/*/package.json ./
RUN npm ci
둘째, 버전을 고정하지 않은 의존성 선언은 캐시를 재현성 없는 상태로 만듭니다. requirements.txt에 requests라고만 적혀 있으면 캐시가 살아 있는 동안에는 6개월 전 버전이 계속 쓰이고, 캐시가 한 번 깨지는 순간 최신 버전으로 점프합니다. 락 파일을 쓰는 이유가 여기에도 있습니다.
ARG, 그리고 캐시를 매번 깨는 것들
ARG는 캐시 동작이 빌더에 따라 달라 혼란을 일으킵니다. 클래식 빌더에서는 ARG 선언 자체가 이후 모든 레이어의 캐시 키에 들어갔습니다. BuildKit은 다릅니다. 실제로 그 값을 참조하는 명령만 무효화됩니다.
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
WORKDIR /app
ARG GIT_SHA
ARG BUILD_TIME
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
LABEL org.opencontainers.image.revision=$GIT_SHA
LABEL org.opencontainers.image.created=$BUILD_TIME
GIT_SHA가 매 커밋 달라져도 npm ci는 그 값을 참조하지 않으므로 캐시에 적중합니다. 참조하는 LABEL 두 줄만 다시 계산됩니다. 이 순서를 뒤집어 ARG를 참조하는 ENV를 위쪽에 두면 전부 무너집니다.
# 안티패턴
ARG GIT_SHA
ENV APP_REVISION=$GIT_SHA # 여기서부터 매 커밋 캐시 전멸
COPY package.json ./
RUN npm ci
캐시를 매번 깨는 전형적인 원인은 다음과 같습니다. 빌드 시각이나 커밋 해시를 상단 ENV에 넣는 것, COPY . .을 의존성 설치보다 위에 두는 것, .dockerignore가 없어 .git 디렉터리 변경이 컨텍스트 해시를 바꾸는 것, 그리고 베이스 이미지를 latest로 두어 원격 다이제스트가 바뀔 때마다 최상단이 무효화되는 것입니다.
CI 러너에는 캐시가 없다 — 캐시 마운트와 레지스트리 캐시
Dockerfile을 완벽하게 정리해도 CI에서는 여전히 매번 npm ci가 돕니다. 당연합니다. 캐시는 빌더의 로컬 스토리지에 있는데, GitHub Actions의 러너는 매 실행마다 새 VM입니다. 로컬에 캐시가 없으면 캐시 히트도 없습니다.
해결 수단은 두 가지이고, 둘은 서로를 대체하지 않습니다.
첫 번째는 캐시 마운트입니다. 패키지 매니저의 다운로드 캐시를 레이어에 굽지 않고 별도 볼륨에 두는 방식입니다.
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN \
npm ci
FROM python:3.12-slim AS py
RUN \
pip install -r requirements.txt
FROM golang:1.23 AS go
RUN \
go build ./...
여기서 가장 많이 밟는 함정을 명확히 해야 합니다. 캐시 마운트는 레지스트리 캐시로 내보내지지 않습니다. --cache-to type=registry나 type=gha를 아무리 잘 설정해도 RUN --mount=type=cache의 내용은 포함되지 않습니다. 캐시 마운트는 빌더 인스턴스에 종속된 로컬 상태이기 때문입니다. 매번 새로 뜨는 러너에서는 아무 효과가 없습니다.
캐시 마운트가 실제로 이득이 되는 곳은 개발자 로컬 머신, 그리고 BuildKit 데몬이 계속 살아 있는 셀프 호스티드 러너나 원격 빌더입니다.
# 팀이 공유하는 원격 빌더에 연결
docker buildx create --name shared --driver remote \
tcp://buildkit.internal:1234 --use
docker buildx build -t api:dev --load .
두 번째는 레지스트리 캐시입니다. 레이어 캐시를 OCI 아티팩트로 레지스트리에 올리고, 다음 빌드에서 내려받습니다. 이쪽은 러너가 매번 새로 떠도 동작합니다.
docker buildx build \
--cache-from type=registry,ref=registry.example.com/api:buildcache \
--cache-to type=registry,ref=registry.example.com/api:buildcache,mode=max \
--tag registry.example.com/api:v312 \
--push .
=> importing cache manifest from registry.example.com/api:buildcache 1.4s
=> CACHED [deps 3/3] RUN npm ci 0.0s
=> [builder 5/5] RUN npm run build 38.7s
=> exporting cache to registry.example.com/api:buildcache 6.2s
mode=max가 핵심입니다. 기본값인 mode=min은 최종 스테이지의 레이어만 내보내므로, 멀티스테이지 빌드에서 정작 비싼 빌더 스테이지가 캐시되지 않습니다. 멀티스테이지를 쓰면서 캐시가 안 먹는다는 신고의 대부분이 이것입니다.
GitHub Actions라면 type=gha가 편합니다.
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/org/api:${{ github.sha }}
cache-from: type=gha,scope=api-${{ github.ref_name }}
cache-to: type=gha,scope=api-${{ github.ref_name }},mode=max
scope를 브랜치별로 나누되, GitHub 캐시는 리포지토리당 10GB 한도이고 오래된 항목부터 축출됩니다. 스코프를 커밋 단위로 잘게 쪼개면 서로를 밀어내 히트율이 오히려 떨어집니다.
| 방식 | 저장 위치 | mode=max | 새 러너에서 유효 | 주 용도 | 함정 |
|---|---|---|---|---|---|
| 로컬 레이어 캐시 | 빌더 디스크 | 해당 없음 | 아니오 | 개발자 머신 | CI에서 무용 |
type=inline | 이미지 자체 | 미지원 | 예 | 단일 스테이지 | 멀티스테이지 캐시 누락 |
type=registry | 레지스트리 | 지원 | 예 | 범용 CI | 스토리지 비용, 정리 필요 |
type=gha | Actions 캐시 | 지원 | 예 | GitHub Actions | 리포당 10GB 한도 |
type=local | 디스크 경로 | 지원 | 조건부 | 셀프 호스티드 | 무한 증가, 수동 정리 |
RUN --mount=cache | 빌더 로컬 | 내보내기 불가 | 아니오 | 영속 빌더 | 러너가 새로 뜨면 무효 |
멀티 아키텍처 빌드에서는 캐시가 플랫폼별로 분리된다는 점을 알아야 합니다. linux/amd64와 linux/arm64는 서로 다른 캐시 체인을 가지므로, 하나의 캐시 ref에 mode=max로 함께 내보내는 것이 관리 면에서 낫습니다.
docker buildx build \
--platform linux/amd64,linux/arm64 \
--cache-from type=registry,ref=registry.example.com/api:buildcache \
--cache-to type=registry,ref=registry.example.com/api:buildcache,mode=max \
--tag registry.example.com/api:v312 --push .
QEMU 에뮬레이션으로 arm64를 빌드하면 네이티브 대비 5배에서 20배까지 느려집니다. 캐시를 조율하는 것보다 아키텍처별 네이티브 러너로 나눠 빌드하고 마지막에 매니페스트만 합치는 편이 대부분 더 빠릅니다.
docker buildx imagetools create \
--tag registry.example.com/api:v312 \
registry.example.com/api:v312-amd64 \
registry.example.com/api:v312-arm64
재현성과 캐시는 서로를 밀어낸다
캐시를 극대화하려는 압력과 재현 가능한 빌드를 원하는 압력은 방향이 반대입니다. 이 긴장을 인정하고 경계를 정해야 합니다.
FROM node:22는 편하지만 원격에서 다이제스트가 갱신되는 순간 모든 레이어가 무효화됩니다. 그리고 어제 빌드한 이미지와 오늘 빌드한 이미지가 다른 베이스를 쓰게 됩니다. 다이제스트로 고정하면 두 문제가 함께 사라집니다.
FROM node:22-bookworm-slim@sha256:9f3c1a5a4d1b7e2c8f0a6b9d3e5c7a1b4d8f2e6c0a9b3d5e7f1c4a8b2d6e0f31
갱신은 사람이 아니라 도구가 하게 둡니다.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
apt-get update도 같은 문제의 다른 얼굴입니다. 같은 RUN에 붙이면 재현성은 좋아지지만 install 목록이 바뀔 때마다 인덱스를 다시 받습니다. 인덱스 다운로드를 캐시 마운트로 옮기면 둘 다 얻을 수 있습니다.
# syntax=docker/dockerfile:1.7
RUN \
rm -f /etc/apt/apt.conf.d/docker-clean \
&& apt-get update \
&& apt-get install -y --no-install-recommends libpq5 ca-certificates
rm -f /etc/apt/apt.conf.d/docker-clean이 필요한 이유는 공식 데비안 이미지가 설치 직후 캐시를 자동 삭제하도록 설정되어 있기 때문입니다. 그 설정을 지우지 않으면 캐시 마운트가 매번 비어 있습니다.
빌드 결과를 비트 단위로 재현해야 한다면 BuildKit 0.13 이상에서 타임스탬프를 고정할 수 있습니다.
SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) \
docker buildx build \
--output type=registry,name=registry.example.com/api:v312,rewrite-timestamp=true .
마치며 — 캐시가 안 먹으면 먼저 어디서 깨졌는지부터 본다
캐시 문제는 추측으로 고쳐지지 않습니다. --progress=plain으로 빌드 로그를 뽑아 CACHED가 사라지는 첫 지점을 찾고, 그 한 줄의 캐시 키가 무엇인지부터 확인해야 합니다.
docker buildx build --progress=plain . 2>&1 | grep -E '^#[0-9]+ (CACHED|\[)'
#7 [deps 2/3] COPY package.json package-lock.json ./
#7 CACHED
#8 [deps 3/3] RUN npm ci
#8 CACHED
#9 [builder 4/6] COPY . .
#10 [builder 5/6] RUN npm run build
CACHED가 붙지 않은 첫 단계가 #9이므로, 그 위는 건드릴 필요가 없고 그 아래는 손댈 방법이 없습니다. 고칠 지점은 항상 그 한 줄입니다.
기억할 것은 하나입니다. 캐시는 체인이므로 위쪽에서 한 번만 깨져도 아래는 전부 무의미해집니다. 그래서 Dockerfile은 변경 빈도의 오름차순으로 쓰고, CI에서는 레지스트리 캐시를 mode=max로 붙이고, 캐시 마운트는 빌더가 살아 있는 환경에서만 기대해야 합니다.