- Published on
Docker 빌드 cache miss? BuildKit 캐시 진단법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 CI에서 docker build 가 매번 처음부터 다시 돌고, CACHED 가 거의 뜨지 않는다면 대부분은 “BuildKit 캐시 키가 깨졌다”는 뜻입니다. 문제는 단순히 --no-cache 여부가 아니라, Dockerfile의 레이어 설계, 빌드 컨텍스트(전송되는 파일), 네트워크/시간/패키지 인덱스처럼 비결정적 입력이 캐시 키에 섞이면서 발생합니다.
이 글은 BuildKit 기준으로 cache miss를 어디서부터 진단해야 하는지, 그리고 어떻게 구조적으로 캐시가 잘 먹는 Dockerfile을 만들지를 실전 체크리스트 형태로 정리합니다.
BuildKit 캐시가 깨지는 원리(핵심만)
BuildKit은 각 스텝(예: RUN, COPY)을 실행할 때 “이 스텝의 결과가 이전과 동일한가”를 판단하기 위해 캐시 키를 만듭니다. 대략적으로는 다음 입력들이 섞입니다.
- 해당 스텝의 명령 문자열(예:
RUN npm ci) - 이전 레이어의 해시(부모 상태)
- 스텝이 참조하는 파일들(
COPY,ADD, 일부RUN의 마운트 입력) - 빌드 인자/환경변수(
ARG,ENV) 값 - 일부 프론트엔드 옵션(플랫폼, BuildKit 버전, 마운트 설정)
즉, 바뀌지 않아야 할 입력이 자주 바뀌면 캐시는 매번 무효화됩니다.
1단계: 정말 BuildKit으로 빌드되고 있는지 확인
요즘 Docker는 기본적으로 BuildKit을 쓰는 경우가 많지만, 환경에 따라 다릅니다. 먼저 아래로 확인합니다.
docker buildx version
docker buildx ls
buildx가 없거나, CI에서 legacy builder를 쓰면 캐시 전략이 달라집니다.- 가능하면
docker buildx build를 기준으로 진단하세요.
2단계: cache miss가 “어느 스텝부터” 시작되는지 로그로 잡기
가장 먼저 할 일은 빌드 로그를 더 자세히 보는 것입니다.
docker buildx build \
--progress=plain \
-t myapp:debug \
.
--progress=plain 을 켜면 각 스텝이 CACHED 인지, 어떤 파일 전송이 일어났는지, 어떤 단계에서 다시 실행되는지 추적이 쉬워집니다.
자주 보이는 패턴
COPY . .직후부터 캐시가 깨진다RUN apt-get update이후부터 항상 다시 돈다RUN npm ci가 매번 다시 돈다- 멀티스테이지에서 builder는 캐시되는데 runtime 스테이지가 매번 다시 돈다
이제부터는 패턴별로 원인을 좁힙니다.
3단계: 빌드 컨텍스트가 커지거나 자주 바뀌는지 확인(.dockerignore)
cache miss의 1순위는 COPY . . 입니다. 이 스텝은 “컨텍스트에 포함된 파일 중 하나라도 바뀌면” 레이어가 무효화됩니다.
증상
- 소스 변경이 없는데도
COPY . .가 캐시되지 않음 - CI에서 매번 다른 파일(로그, 테스트 산출물,
.git)이 들어감
해결: .dockerignore 를 강하게
다음은 Node 예시입니다.
.git
.gitignore
node_modules
npm-debug.log
.yarn
.pnpm-store
.next
.dist
build
coverage
*.log
.env
.env.*
그리고 Dockerfile에서 COPY 를 쪼개서 캐시 친화적으로 만듭니다.
# 나쁜 예: 의존성 설치 전에 전체 복사
# COPY . .
# RUN npm ci
# 좋은 예: lockfile 먼저 복사해 의존성 레이어를 고정
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
이렇게 하면 소스 코드가 바뀌어도 npm ci 레이어는 lockfile이 바뀌지 않는 한 캐시됩니다.
4단계: ARG 와 ENV 가 캐시를 깨고 있는지 점검
ARG 와 ENV 는 생각보다 자주 캐시를 무효화합니다. 특히 CI에서 빌드 번호, 커밋 SHA를 ARG 로 주입하고 그 값을 RUN 에서 쓰면, 그 스텝 이후가 전부 무효화됩니다.
흔한 실수
ARG GIT_SHA
RUN echo "$GIT_SHA" > /app/version.txt
RUN npm ci
이 경우 GIT_SHA 가 매번 바뀌므로 npm ci 까지 같이 깨질 수 있습니다(부모 레이어가 달라지기 때문).
개선 패턴
- 메타데이터는 가능한 한 마지막 레이어에서만 기록
- 혹은 이미지 라벨로 처리
ARG GIT_SHA
LABEL org.opencontainers.image.revision=$GIT_SHA
5단계: 패키지 매니저 단계의 비결정성 제거(apt, apk, pip)
RUN apt-get update 는 대표적인 캐시 킬러입니다. 이유는 간단합니다. 같은 명령이어도 레포 인덱스가 시간에 따라 변하기 때문입니다.
기본 원칙
apt-get update와apt-get install을 같은RUN에 묶기- 필요 패키지를 고정(가능하면 버전 핀)
rm -rf /var/lib/apt/lists/*로 레이어 크기 감소
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
하지만 이것만으로는 “캐시가 잘 먹는다”가 아니라 “레이어가 깔끔하다”에 가깝습니다. CI에서 매번 새 빌드 머신이라면, 로컬 레이어 캐시 자체가 없어서 항상 다시 설치됩니다.
이때는 다음 섹션의 외부 캐시(export/import) 가 중요합니다.
6단계: BuildKit 캐시를 외부로 내보내고 다시 가져오기
로컬에서는 캐시가 잘 되는데 CI에서는 매번 깨진다면, 대부분은 빌드 머신이 매번 새로 뜨기 때문입니다. 해결은 BuildKit 캐시를 레지스트리나 CI 캐시 스토리지로 내보내는 것입니다.
레지스트리 캐시(가장 흔함)
docker buildx build \
--progress=plain \
--cache-to=type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
--cache-from=type=registry,ref=registry.example.com/myapp:buildcache \
-t registry.example.com/myapp:latest \
--push \
.
mode=max는 더 많은 중간 레이어를 캐시에 담아 재사용률을 올립니다.--cache-from을 반드시 같이 써야 다음 빌드에서 가져옵니다.
GitHub Actions라면 type=gha도 선택지
환경에 따라 type=gha 를 쓸 수 있지만, 조직/보안/크기 제한 때문에 레지스트리 캐시가 더 단순한 경우가 많습니다.
7단계: Dockerfile에서 “캐시가 먹는 순서”로 재배치
캐시 최적화는 결국 “변경이 잦은 것”을 뒤로 보내는 게임입니다.
Node 예시: 멀티스테이지 + 의존성 캐시
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN \
npm ci
FROM node:22-bookworm AS build
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY /app/dist ./dist
CMD ["node", "dist/server.js"]
포인트는 다음입니다.
deps스테이지에서 lockfile 기반으로npm ci레이어를 최대한 안정화--mount=type=cache로 npm 다운로드 캐시를 유지(같은 빌드 머신 내에서 특히 효과적)- 소스 전체 복사는
build스테이지에서 뒤로
참고로 Node 런타임/빌드 이슈는 캐시 문제와 함께 자주 엮입니다. 빌드 파이프라인이 흔들릴 때는 Node 22 TypeScript 실행 - strip-types 트러블슈팅도 같이 점검하면 원인 분리가 빨라집니다.
8단계: “매번 바뀌는 파일”을 빌드 단계에서 생성하지 않기
다음 같은 파일은 빌드마다 바뀌어 캐시를 깨기 쉽습니다.
- 빌드 시각을 박아 넣은 파일
git describe결과를 파일로 쓰는 스텝- 테스트 리포트, 커버리지 산출물
해결은 단순합니다.
- 산출물은 컨테이너 밖(CI 아티팩트)으로 빼기
- 꼭 이미지에 넣어야 한다면 마지막 단계에서만 생성
9단계: 캐시 진단에 유용한 명령들
빌드 기록/캐시 객체 확인
docker buildx du
빌드 캐시가 어디에 얼마나 쌓였는지 확인할 수 있습니다.
캐시 삭제(진단용)
docker buildx prune -f
# 더 공격적으로
docker buildx prune -a -f
로컬에서 “캐시가 되긴 하는데 뭔가 꼬였다”를 의심할 때만 진단용으로 쓰고, CI에서는 무작정 prune을 넣으면 성능만 악화됩니다.
10단계: 체크리스트로 빠르게 결론 내리기
아래 질문에 예/아니오로 답하면 대부분 결론이 납니다.
--progress=plain에서 cache miss가 시작되는 첫 스텝은COPY . .인가?- 예:
.dockerignore강화,COPY분리, lockfile 우선 복사
- 예:
ARG로 커밋 SHA, 빌드 번호를 주입하고 그 값이 중간 스텝에 영향을 주는가?- 예: 라벨로 옮기거나 마지막 레이어로 이동
- CI는 매번 새 머신에서 빌드하는가?
- 예:
--cache-to와--cache-from으로 외부 캐시 필수
- 예:
apt-get update,pip install,npm install같은 네트워크 의존 스텝이 매번 다시 도는가?- 예: 스텝 순서 재배치 + BuildKit cache mount + 외부 캐시
- 빌드 컨텍스트에 로그/산출물/
.git이 포함되는가?- 예:
.dockerignore로 차단
- 예:
CI에서 특히 많이 하는 실수: 캐시를 “이미지 레이어”로만 생각하기
이미지 레이어 캐시는 로컬 머신에서는 강력하지만, CI처럼 실행 환경이 휘발성인 곳에서는 “가져올 캐시가 없다”가 더 흔한 문제입니다. 이때는 BuildKit의 캐시 export/import를 파이프라인 표준으로 넣는 게 가장 효과가 큽니다.
CI 디버깅 관점에서 보면, 캐시 문제는 종종 다른 캐시 이슈(예: 애플리케이션 레벨 캐시, 프레임워크 캐시)와 같이 나타납니다. 예를 들어 Next.js 계열에서 캐시가 꼬여 증상이 비슷하게 보일 때는 Next.js 14 서버액션 500·CSRF·캐시 꼬임 해결처럼 “캐시 무효화 지점”을 먼저 특정하는 접근이 동일하게 통합니다.
마무리: cache miss는 현상이 아니라 ‘입력 변화’의 결과
BuildKit 캐시 진단은 어렵지 않습니다. --progress=plain 으로 cache miss가 시작되는 첫 스텝을 찾고, 그 스텝의 캐시 키에 섞이는 입력(파일, ARG/ENV, 네트워크 의존성, 빌드 컨텍스트)을 하나씩 제거하면 됩니다.
정리하면 우선순위는 다음 순서가 가장 효율적입니다.
.dockerignore와COPY분리로 컨텍스트 변동을 줄이기- lockfile 기반으로 의존성 레이어를 고정하기
- CI에서는
--cache-to와--cache-from으로 외부 캐시를 표준화하기 ARG와 시간/커밋 주입을 마지막으로 미루기
이 4가지만 적용해도 “매번 풀 리빌드”에서 “변경된 부분만 빌드”로 체감 성능이 크게 바뀝니다.