Published on

Docker BuildKit 캐시 깨짐·느림, 원인별 최적화

Authors

서로 다른 팀/CI 환경에서 Docker BuildKit을 쓰다 보면 “어제는 캐시가 잘 먹었는데 오늘은 매번 풀빌드” 또는 “캐시는 유지되는데 빌드가 이상하게 느림” 같은 문제가 반복됩니다. 대부분은 BuildKit 자체의 문제가 아니라 캐시 키가 바뀌는 입력(컨텍스트, Dockerfile, ARG/ENV, 네트워크 의존성) 이 매 빌드마다 흔들리기 때문입니다.

이 글은 BuildKit 캐시가 깨지는 대표 원인을 증상 기반으로 분류하고, 각 원인별로 Dockerfile/CI 설정을 어떻게 바꿔야 하는지를 코드와 함께 정리합니다.

관련해서 “캐시가 꼬여 재검증이 반복되는” 유형의 사고는 앱 레이어에서도 자주 보이는데, 원인 추적 관점은 유사합니다. 예: Next.js App Router 캐시 꼬임·재검증 버그 해결, Next.js 14 RSC에서 fetch 캐시 꼬임 해결법


BuildKit 캐시가 “깨지는” 원리: 무엇이 캐시 키를 바꾸나

BuildKit은 레이어별로 “이 단계의 입력이 동일하면 결과도 동일하다”는 가정 하에 캐시를 재사용합니다. 여기서 입력은 대략 다음을 포함합니다.

  • Dockerfile의 해당 단계 명령(예: RUN, COPY)
  • 해당 단계로 들어오는 파일 스냅샷(빌드 컨텍스트)
  • ARG/ENV
  • 베이스 이미지 digest(태그가 아니라 실제 digest)
  • RUN 단계에서 참조하는 네트워크 리소스(패키지 저장소 등)는 명시적으로는 입력에 고정되지 않지만, 결과물이 달라지면 다음 단계에 영향을 줌

즉, 캐시가 깨지는지 여부는 “내가 바꿨다고 생각하는 것”이 아니라 BuildKit이 입력으로 간주하는 것이 바뀌었는지로 결정됩니다.


1) 증상: 코드 안 바꿨는데 COPY . . 이후가 매번 풀빌드

가장 흔한 원인은 빌드 컨텍스트가 불필요하게 자주 바뀌는 것입니다.

원인 A: .dockerignore 미흡(로그, 빌드 산출물, 테스트 리포트 포함)

COPY . . 는 컨텍스트 전체 스냅샷을 입력으로 잡습니다. 여기서 하나라도 바뀌면 이후 레이어가 줄줄이 무효화됩니다.

해결: .dockerignore 정리

node_modules
.next
.dist
build
coverage
*.log
.DS_Store
.git
.github
.env
.env.*

특히 CI에서 생성되는 coverage, test-results, reports 디렉터리가 컨텍스트에 포함되면 “코드 변경 없음”인데도 캐시가 깨지는 일이 잦습니다.

원인 B: COPY . . 위치가 너무 이르다

의존성 설치 전에 전체 소스를 복사하면, 소스 변경이 의존성 레이어까지 무효화합니다.

해결: 의존성 파일만 먼저 복사하고 설치

Node.js 예시:

# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS deps
WORKDIR /app

# 의존성 관련 파일만 먼저
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules

# 그 다음에 소스 전체 복사
COPY . .
RUN npm run build

핵심은 npm ci 레이어의 입력을 package-lock.json 등으로 제한해 캐시 히트율을 올리는 것입니다.


2) 증상: 같은 커밋인데 로컬은 캐시, CI는 매번 처음부터

CI에서 캐시가 안 먹는 이유는 대개 빌더/캐시 저장소가 매번 새로 생기기 때문입니다.

원인 A: GitHub Actions 기본 Docker 빌더는 매 런마다 상태가 휘발

호스트가 새 VM이면 로컬 캐시가 없습니다. BuildKit 캐시를 외부로 export/import 해야 합니다.

해결: cache-to/cache-from로 레지스트리 또는 GHA 캐시 사용

docker/build-push-action 예시:

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

- name: Build and push
  uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/org/app:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

레지스트리 캐시를 쓰는 예시:

with:
  cache-from: type=registry,ref=ghcr.io/org/app:buildcache
  cache-to: type=registry,ref=ghcr.io/org/app:buildcache,mode=max
  • mode=max 는 더 많은 중간 레이어를 저장해 캐시 히트율을 올립니다(저장공간은 증가).

원인 B: 멀티 플랫폼 빌드에서 캐시가 분리됨

linux/amd64linux/arm64 는 레이어가 다르므로 캐시도 분리됩니다.

해결: 플랫폼별 캐시 키를 분리하거나, 멀티 플랫폼 캐시를 레지스트리에 축적

with:
  platforms: linux/amd64,linux/arm64
  cache-to: type=registry,ref=ghcr.io/org/app:buildcache,mode=max
  cache-from: type=registry,ref=ghcr.io/org/app:buildcache

3) 증상: 캐시가 “가끔” 깨지고, 원인을 못 찾겠음

이 경우는 ARG/ENV, 타임스탬프, git 메타데이터 같은 “비결정적 입력”이 섞였을 가능성이 큽니다.

원인 A: ARG 가 매 빌드마다 바뀜(예: 빌드 시간, 커밋 SHA)

아래처럼 ARG BUILD_DATE 를 쓰면, 그 이후 레이어는 매번 무효화됩니다.

나쁜 예:

ARG BUILD_DATE
RUN echo "$BUILD_DATE" > /build-date.txt

해결 1: 메타데이터는 이미지 라벨로 이동

ARG VCS_REF
ARG BUILD_DATE
LABEL org.opencontainers.image.revision=$VCS_REF \
      org.opencontainers.image.created=$BUILD_DATE

라벨은 레이어를 만들긴 하지만, 최소한 “빌드 산출물 생성 단계”와 분리해 영향 범위를 줄일 수 있습니다.

해결 2: 빌드 산출물과 무관한 단계는 마지막으로

# ... build steps

# 마지막에만 메타데이터 기록
ARG VCS_REF
LABEL org.opencontainers.image.revision=$VCS_REF

원인 B: git cloneRUN 에서 수행

네트워크 상태, 브랜치 최신 커밋 등으로 결과가 흔들립니다.

해결: 가능한 한 빌드 컨텍스트로 고정하거나, 커밋 digest를 고정

ARG LIB_COMMIT
RUN git clone https://example.com/lib.git \
 && cd lib \
 && git checkout $LIB_COMMIT

단, 이 경우에도 네트워크 의존이 있으니 CI 안정성이 떨어질 수 있습니다. 가능하면 소스를 서브모듈/아카이브로 컨텍스트에 포함시키는 편이 낫습니다.


4) 증상: 캐시는 유지되는데 빌드가 느림(특히 RUN apt-get/npm install)

캐시가 “레이어 단위”로만 동작한다고 생각하면 함정이 있습니다. 레이어는 캐시가 되지만, 캐시가 깨지는 순간 비용이 너무 큽니다. 그래서 BuildKit의 캐시 마운트로 패키지 매니저 다운로드를 가속하는 것이 중요합니다.

원인 A: 패키지 매니저 다운로드가 매번 다시 발생

해결: --mount=type=cache 사용

Debian/Ubuntu apt:

# syntax=docker/dockerfile:1.7
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update \
 && apt-get install -y --no-install-recommends curl ca-certificates \
 && rm -rf /var/lib/apt/lists/*
  • sharing=locked 는 병렬 빌드 시 캐시 디렉터리 충돌을 줄입니다.

npm:

RUN --mount=type=cache,target=/root/.npm \
    npm ci

pip:

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

이 패턴은 레이어 캐시가 깨져도 “다운로드/압축 해제” 비용을 크게 줄여 체감 속도를 개선합니다.


5) 증상: COPY 때문에 캐시가 깨지는 범위가 너무 큼(모노레포)

모노레포에서 COPY . . 는 작은 변경도 전체를 흔들어 캐시 효율이 급락합니다.

해결: 빌드 컨텍스트 자체를 분리하거나, 필요한 서브디렉터리만 복사

예: apps/web 만 빌드한다면

WORKDIR /repo
COPY package.json package-lock.json ./
COPY apps/web/package.json apps/web/package-lock.json ./apps/web/
RUN --mount=type=cache,target=/root/.npm npm ci

COPY apps/web ./apps/web
WORKDIR /repo/apps/web
RUN npm run build

또는 Docker 빌드 컨텍스트를 apps/web 로 제한:

docker build -f apps/web/Dockerfile apps/web

컨텍스트를 줄이는 것만으로 캐시 안정성이 크게 올라갑니다.


6) 증상: 같은 Dockerfile인데 팀원마다 캐시 히트율이 다름

로컬 개발 환경에서는 다음이 흔한 원인입니다.

  • Docker Desktop 설정/리소스(디스크 I/O)
  • 빌드 시 --no-cache 습관
  • 서로 다른 빌더 인스턴스 사용

해결: buildx 빌더를 고정하고, BuildKit 사용을 명시

docker buildx create --name devbuilder --use
docker buildx inspect --bootstrap

# BuildKit 활성화(환경에 따라 기본값이 다를 수 있음)
DOCKER_BUILDKIT=1 docker buildx build -t app:dev .

캐시 진단 시에는 “왜 이 단계가 캐시 미스인지”를 로그로 확인하는 게 빠릅니다.


7) 원인 추적: 어떤 단계가 왜 캐시 미스인지 확인하기

방법 A: plain progress로 단계별 로그 확인

docker buildx build --progress=plain -t app:debug .

여기서 CACHED 표시가 사라지는 첫 지점을 찾으면, 그 단계의 입력이 흔들리는 것입니다.

방법 B: 빌드 정의를 더 쪼개서 “변동 범위”를 줄이기

  • 의존성 설치 단계 분리
  • 빌드/테스트/런타임 분리(멀티 스테이지)
  • 변경이 잦은 파일을 가능한 뒤로

이 과정은 시스템 문제를 “원인별로 격리”하는 전형적인 디버깅 방식과 같습니다. 예를 들어 캐시/재시작 루프의 원인을 분해해 추적하는 접근은 systemd 서비스가 계속 재시작될 때 원인 추적법 같은 글의 트러블슈팅 흐름과도 닮아 있습니다.


8) 실전 레시피: 느린/불안정한 Dockerfile을 BuildKit 친화적으로 리팩터링

아래는 자주 보이는 “캐시가 잘 안 먹는” Dockerfile을 개선한 예시입니다.

개선 전(전형적인 문제)

FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["npm", "start"]

문제점:

  • COPY . . 로 인해 작은 변경도 npm install 캐시를 깨뜨림
  • npm install 은 lockfile 기반 재현성이 떨어질 수 있음
  • 다운로드 캐시가 없어 네트워크에 취약

개선 후(캐시 안정성 + 속도)

# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public
COPY --from=build /app/package.json ./package.json
COPY --from=deps /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "node_modules/next/dist/bin/next", "start"]

효과:

  • 의존성 설치 레이어의 입력이 lockfile로 고정되어 캐시 히트율 상승
  • 네트워크 다운로드는 type=cache 로 재사용되어 캐시 미스 시에도 빠름
  • 런타임 이미지는 빌드 도구가 빠져 작고 안전

9) 체크리스트: 캐시 깨짐·느림을 가장 빨리 줄이는 순서

  1. .dockerignore 정리로 컨텍스트 변동 제거
  2. COPY 순서 재배치: lockfile 먼저, 소스는 나중
  3. 패키지 매니저에 --mount=type=cache 적용
  4. CI에서는 cache-to/cache-from 로 외부 캐시 저장
  5. ARG/ENV 로 변동값 주입 시 영향 범위를 마지막 단계로 격리
  6. 모노레포는 컨텍스트/복사 범위를 최소화

마무리: “캐시가 왜 깨졌는지”를 입력 관점으로 재구성하라

BuildKit 캐시는 마법이 아니라 “입력 동일성”에 매우 민감한 시스템입니다. 캐시가 깨질 때는 보통 Dockerfile의 특정 단계가 아니라, 그 단계가 바라보는 입력(컨텍스트/ARG/네트워크/플랫폼)이 흔들리고 있습니다.

--progress=plain 으로 캐시 미스가 시작되는 지점을 특정한 뒤, 이 글의 패턴대로 입력을 고정하거나(컨텍스트 축소, lockfile 우선) 비용을 낮추면(캐시 마운트, CI 캐시 export) BuildKit은 다시 빠르고 예측 가능해집니다.