- Published on
Docker BuildKit 캐시 깨짐·느림, 원인별 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 팀/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 \
npm ci
FROM node:20-alpine AS build
WORKDIR /app
COPY /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/amd64 와 linux/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 clone 을 RUN 에서 수행
네트워크 상태, 브랜치 최신 커밋 등으로 결과가 흔들립니다.
해결: 가능한 한 빌드 컨텍스트로 고정하거나, 커밋 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 \
apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
sharing=locked는 병렬 빌드 시 캐시 디렉터리 충돌을 줄입니다.
npm:
RUN \
npm ci
pip:
RUN \
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 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 \
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
ENV NODE_ENV=production
COPY /app/.next ./.next
COPY /app/public ./public
COPY /app/package.json ./package.json
COPY /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "node_modules/next/dist/bin/next", "start"]
효과:
- 의존성 설치 레이어의 입력이 lockfile로 고정되어 캐시 히트율 상승
- 네트워크 다운로드는
type=cache로 재사용되어 캐시 미스 시에도 빠름 - 런타임 이미지는 빌드 도구가 빠져 작고 안전
9) 체크리스트: 캐시 깨짐·느림을 가장 빨리 줄이는 순서
.dockerignore정리로 컨텍스트 변동 제거COPY순서 재배치: lockfile 먼저, 소스는 나중- 패키지 매니저에
--mount=type=cache적용 - CI에서는
cache-to/cache-from로 외부 캐시 저장 ARG/ENV로 변동값 주입 시 영향 범위를 마지막 단계로 격리- 모노레포는 컨텍스트/복사 범위를 최소화
마무리: “캐시가 왜 깨졌는지”를 입력 관점으로 재구성하라
BuildKit 캐시는 마법이 아니라 “입력 동일성”에 매우 민감한 시스템입니다. 캐시가 깨질 때는 보통 Dockerfile의 특정 단계가 아니라, 그 단계가 바라보는 입력(컨텍스트/ARG/네트워크/플랫폼)이 흔들리고 있습니다.
--progress=plain 으로 캐시 미스가 시작되는 지점을 특정한 뒤, 이 글의 패턴대로 입력을 고정하거나(컨텍스트 축소, lockfile 우선) 비용을 낮추면(캐시 마운트, CI 캐시 export) BuildKit은 다시 빠르고 예측 가능해집니다.