- Published on
Docker BuildKit 캐시가 안 먹을 때 진단·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 빌드 시간을 줄이려고 BuildKit을 켰는데도 RUN npm ci 같은 무거운 단계가 매번 다시 실행되면, 체감상 캐시는 "아예 없는 것"처럼 보입니다. 하지만 대부분은 BuildKit 자체 문제가 아니라 캐시 키가 매번 달라지거나, 캐시를 저장·복원하지 못하거나, Dockerfile 작성 방식이 캐시 친화적이지 않아서 발생합니다.
이 글은 "왜 캐시가 안 먹는지"를 레이어 단위로 추적하는 방법과, 로컬·CI 모두에서 재현 가능한 해결책을 정리한 진단 가이드입니다.
먼저 확인: BuildKit이 정말 켜져 있나
BuildKit이 꺼져 있으면 캐시 동작이 다르게 보이거나, --mount=type=cache 같은 문법이 무시됩니다.
# 권장: buildx 사용
docker buildx version
# 빌드 시 BuildKit 사용 확인
docker buildx build --progress=plain -t myapp:dev .
--progress=plain 출력에서 CACHED 표기가 보이는지, 그리고 각 스텝이 어떤 입력으로 캐시 키를 만드는지(특히 COPY/RUN)를 확인합니다.
증상별로 분류하기: "캐시 미스"는 3가지 유형이 있다
1) 로컬에서는 캐시가 되는데 CI에서는 매번 풀 빌드
대개 CI 러너가 매번 새 머신이거나, 원격 캐시를 푸시하지 않아 캐시를 재사용하지 못하는 케이스입니다.
2) 특정 단계만 반복적으로 다시 돈다
보통 COPY . .가 너무 이르게 등장해, 소스의 작은 변화가 의존성 설치 단계까지 전파됩니다. 또는 RUN에서 네트워크/시간/랜덤 값이 섞여 캐시가 깨집니다.
3) 아무 단계도 캐시가 안 되는 것처럼 보인다
빌드 컨텍스트가 매번 달라지거나(예: .dockerignore 미흡), 빌드 인자 ARG/--build-arg가 매번 바뀌는 경우가 많습니다.
진단 1단계: 어떤 스텝에서 캐시가 깨지는지 로그로 고정
가장 먼저 해야 할 일은 "캐시가 안 먹는다"를 감정이 아니라 스텝 번호로 고정하는 것입니다.
docker buildx build --progress=plain --no-cache=false -t myapp:debug .
출력에서 다음을 체크합니다.
CACHED가 뜨는 스텝과 안 뜨는 스텝- 캐시가 깨지는 최초 지점이
COPY인지RUN인지 #x [stage y/z]형태로 멀티스테이지에서 어느 스테이지인지
캐시가 깨지는 최초 지점 바로 위에 있는 입력(파일, 인자, 환경변수)을 의심하면 됩니다.
진단 2단계: 빌드 컨텍스트가 매번 변하는지 확인
BuildKit 캐시는 본질적으로 "입력 해시" 기반입니다. 빌드 컨텍스트에 불필요한 파일이 섞이면, 소스가 안 바뀌어도 해시가 변합니다.
.dockerignore가 없거나 빈약한 경우
아래 항목이 컨텍스트에 들어가면 캐시를 자주 깨뜨립니다.
node_modules,dist,.next,build.git- 로컬 로그, 임시 파일
- 테스트 산출물, 커버리지
예시:
.git
node_modules
.next
dist
build
coverage
*.log
.DS_Store
CI에서 생성되는 파일이 컨텍스트에 포함되는 경우
예: 빌드 직전에 버전 파일을 생성하거나, 린트 결과를 워크스페이스에 기록하는 스크립트가 있으면 COPY . . 이후 모든 레이어가 무효화됩니다.
해결은 둘 중 하나입니다.
- 생성 파일을
.dockerignore로 제외 - 생성 시점을 Dockerfile 내부로 옮기고, 필요한 단계에서만
COPY하기
진단 3단계: Dockerfile 구조가 캐시를 "못 쓰게" 만들고 있나
가장 흔한 안티패턴은 의존성 설치 전에 COPY . .를 해버리는 것입니다.
Node.js 예시: 의존성 캐시가 안 먹는 Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "server.js"]
소스 파일 하나만 바뀌어도 COPY . . 레이어가 바뀌고, 그 아래 npm ci 캐시가 전부 깨집니다.
해결: lockfile 기반으로 의존성 레이어 분리
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS build
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
COPY /app/dist ./dist
CMD ["node", "dist/server.js"]
핵심은 다음입니다.
package-lock.json이 안 바뀌면npm ci레이어는 그대로 재사용--mount=type=cache로 패키지 다운로드 캐시까지 재사용(특히 CI에서 효과 큼)
진단 4단계: ARG/ENV가 캐시를 매번 바꾸고 있나
BuildKit은 ARG 값이 달라지면 해당 ARG를 참조하는 이후 레이어 캐시를 무효화합니다. 흔한 예:
ARG GIT_SHA를 매 빌드마다 넣고LABEL에 박아버림ARG BUILD_DATE를 넣고 어딘가에서 사용
나쁜 예
ARG GIT_SHA
LABEL org.opencontainers.image.revision=$GIT_SHA
RUN npm ci
LABEL은 괜찮아 보이지만, ARG가 앞에서 선언되고 이후 단계에서 참조되면 캐시 키에 영향을 줍니다.
개선 패턴
- 빌드 결과물에 꼭 필요하지 않은 메타데이터는 마지막 스테이지에서만 적용
- 혹은
LABEL만 바꾸고 싶다면, 의존성 설치와 빌드가 끝난 뒤에 배치
# build 단계들...
FROM node:20-alpine AS runtime
ARG GIT_SHA
LABEL org.opencontainers.image.revision=$GIT_SHA
# 여기서부터는 런타임에 필요한 COPY만
진단 5단계: 시간/네트워크 의존 RUN이 캐시를 깨는지
아래 작업은 캐시 관점에서 위험합니다.
RUN apk add --no-cache는 패키지 인덱스 변화에 민감RUN curl https://... | sh는 원격 내용이 바뀌면 재현 불가RUN date같은 시간 의존 명령
해결 원칙
- 가능한 한 버전을 고정하고 체크섬 검증
- 다운로드 파일은 별도 레이어로 분리
- 패키지 매니저 캐시는
--mount=type=cache로 분리
예:
# syntax=docker/dockerfile:1.6
RUN \
apk add --update-cache bash ca-certificates
CI에서 캐시가 안 먹는 "진짜" 이유: 캐시를 저장하지 않는다
로컬은 도커 데몬이 레이어를 들고 있으니 캐시가 됩니다. 하지만 CI는 러너가 매번 새로 뜨면 로컬 캐시가 없습니다. 이때는 원격 캐시를 써야 합니다.
GitHub Actions에서 BuildKit 원격 캐시(Registry) 사용
아래는 buildx로 이미지를 푸시하면서 캐시도 함께 푸시/풀하는 전형적인 구성입니다.
name: build
on: [push]
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/OWNER/REPO/myapp:${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/OWNER/REPO/myapp:buildcache
cache-to: type=registry,ref=ghcr.io/OWNER/REPO/myapp:buildcache,mode=max
provenance: false
포인트:
cache-from이 없으면 매번 "새 캐시"로 시작cache-to를mode=max로 두면 더 많은 중간 레이어를 보관provenance는 조직 정책에 따라 켜도 되지만, 문제 진단 단계에서는 단순화가 도움이 됩니다
CI에서 AWS ECR을 쓴다면 OIDC로 자격증명을 발급하는 패턴이 함께 자주 등장합니다. 이 부분은 GitHub Actions OIDC로 AWS 자격증명 0초 발급 글의 구성을 그대로 가져오면 깔끔합니다.
멀티스테이지에서 캐시가 새는 지점
멀티스테이지 자체는 캐시에 유리하지만, 다음 실수로 캐시가 새기도 합니다.
deps스테이지에서 만든node_modules를build로 복사하지 않고 다시 설치COPY --from=deps전에COPY . .를 해버려 의존성 레이어가 다시 계산되게 만듦target을 바꿔가며 빌드하는데 캐시 백엔드가 공유되지 않음
권장 체크:
- 의존성 스테이지는 lockfile만 입력으로 받게 만들기
- 빌드 스테이지는 소스 입력을 받되, 의존성은 이전 스테이지에서 복사
캐시가 "먹은 것 같은데" 이미지 풀에서 터질 때
빌드는 빨라졌는데 배포 환경에서 ImagePullBackOff가 발생하면 캐시 문제가 아니라 레지스트리/태그/권한/아키텍처 문제일 수 있습니다. 쿠버네티스에서 이미지 풀 실패 원인을 빠르게 훑으려면 K8s ImagePullBackOff·ErrImagePull 원인 12가지 체크리스트가 도움이 됩니다.
실전 체크리스트: BuildKit 캐시 미스 10분 컷
1) 로그로 최초 캐시 미스 스텝을 고정
docker buildx build --progress=plain로CACHED유무 확인
2) .dockerignore를 먼저 정리
.git, 산출물 디렉터리, 로컬 캐시 제거
3) Dockerfile을 "변경 빈도" 순으로 재배치
- lockfile
COPY후 의존성 설치 - 소스
COPY는 빌드 직전에
4) ARG/ENV로 캐시를 깨고 있지 않은지 확인
GIT_SHA,BUILD_DATE는 마지막 스테이지로 이동
5) CI는 원격 캐시를 반드시 구성
cache-from과cache-to를 같은 ref로 연결- 레지스트리 로그인/권한 확인
마무리
BuildKit 캐시는 "켜면 알아서 빨라지는" 기능이라기보다, 입력(컨텍스트, Dockerfile, 빌드 인자)과 저장소(로컬, 레지스트리, CI)의 합으로 성능이 결정됩니다. 캐시가 안 먹을 때는 감으로 Dockerfile을 뜯기보다, --progress=plain로 최초 미스 지점을 고정하고, 컨텍스트와 Dockerfile의 입력 경계를 줄이는 쪽으로 접근하면 대부분 해결됩니다.
다음 단계로는 레포 구조가 복잡한 모놀리포에서 캐시를 더 공격적으로 최적화하는 방법도 고려할 수 있습니다. Nx를 쓰는 환경이라면 GitHub Actions로 Nx 모놀리포 CI/CD 최적화처럼 "변경된 것만 빌드" 전략과 BuildKit 캐시를 함께 쓰면 빌드 시간이 크게 줄어듭니다.