- Published on
Docker BuildKit 캐시 폭발로 CI 느림 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
CI에서 docker build가 어느 순간부터 급격히 느려지는 경우, 단순히 러너 성능 문제가 아니라 BuildKit 캐시가 폭발적으로 증가하면서 I/O와 메타데이터 처리 비용이 커진 상황일 때가 많습니다. 특히 모노레포, 멀티스테이지 빌드, 패키지 매니저 캐시를 잘못 다루는 Dockerfile이 결합되면 캐시 히트율은 낮아지고(=매번 빌드), 캐시 엔트리는 쓸데없이 늘어나는(=디스크 압박) 최악의 조합이 됩니다.
이 글은 다음을 목표로 합니다.
- BuildKit 캐시가 “왜” 폭발하는지 구조적으로 이해
- CI에서 흔히 보는 증상과 원인 매핑
- Dockerfile/빌드 옵션/캐시 저장소(레지스트리) 관점의 실전 처방
- 최종적으로 빌드 시간 변동폭을 줄이고 러너 디스크를 안정화
참고로 캐시는 애플리케이션 전반에서 성능/비용을 좌우하는 핵심 레이어입니다. 캐시 계층을 설계하고 튜닝하는 사고방식은 빌드 캐시에도 그대로 적용됩니다. 관련해서는 Production RAG 벡터 DB 캐시 계층 설계와 튜닝 글도 함께 보면 도움이 됩니다.
BuildKit 캐시가 폭발하는 대표 패턴
1) 컨텍스트가 매번 바뀌어 캐시 키가 계속 달라짐
BuildKit은 레이어 캐시를 “명령”뿐 아니라 입력 파일의 해시에도 강하게 묶습니다. 다음이 자주 문제를 만듭니다.
COPY . .를 너무 이른 단계에 수행.dockerignore가 부실해서node_modules,dist,.git, 테스트 아티팩트 등이 컨텍스트로 유입- 빌드마다 생성되는 파일(예: 버전 파일, 타임스탬프, 커밋 메타데이터)을 컨텍스트에 포함
컨텍스트가 커질수록 전송/해시 계산 자체가 느려지고, 무엇보다 캐시 재사용이 깨집니다.
2) 패키지 설치 단계가 불안정해서 매번 새 레이어가 생김
예를 들어 Node.js에서 다음이 캐시 폭발을 유발합니다.
package-lock.json이 자주 바뀌는 구조npm install처럼 비결정적(락파일 무시 가능)인 방식- 네트워크/레지스트리 지연으로 재시도, 미러 변경 등이 발생
결과적으로 “비슷하지만 다른” 레이어가 계속 생기고, CI는 점점 느려집니다.
3) 멀티스테이지에서 불필요한 산출물을 다음 단계로 넘김
빌더 단계에서 생성한 캐시/임시파일을 런타임 이미지로 넘기면 이미지가 커질 뿐 아니라, 빌드 캐시도 함께 비대해집니다.
4) 캐시 스코프가 너무 넓어 경쟁(경합)과 오염이 발생
여러 브랜치/PR이 같은 캐시를 공유하면 “캐시 히트율은 낮고, 엔트리는 늘어나는” 오염이 생깁니다.
- PR A가 만든 캐시가 PR B에 도움이 안 됨
- 하지만 디스크는 같이 잡아먹음
- 동시에 GC가 늦어져 러너가 터짐
먼저 해야 할 진단: 지금 무엇이 느린가
1) 빌드 로그에서 캐시 히트 확인
BuildKit 빌드에서는 단계별로 CACHED 여부가 힌트입니다. CI에서 다음이 보이면 캐시가 사실상 못 쓰고 있는 상태입니다.
- 매번 패키지 설치 단계가 재실행
COPY . .이후 단계가 거의 항상 재빌드
2) 로컬/러너에서 캐시 사용량 점검
러너에 쉘 접근이 가능하면 다음으로 대략적인 캐시 규모를 확인합니다.
docker buildx du --verbose
또는 단일 엔진 환경이면 다음도 유용합니다.
docker system df
3) 캐시 GC가 동작하는지 확인
BuildKit은 무한정 캐시를 쌓을 수 있습니다. “정리 정책”이 없다면 언젠가 디스크 100%로 CI가 멈춥니다. 이때부터는 빌드가 느려지다가, 결국 실패합니다.
해결 전략 1: Dockerfile 레이어 설계를 캐시 친화적으로 바꾸기
핵심 원칙
- 자주 바뀌는 파일은 최대한 뒤로
- 의존성 설치는 락파일 기반으로 분리
- 빌드 산출물만 다음 스테이지로 전달
- 컨텍스트를 최소화
Node.js 예시: 나쁜 Dockerfile
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]
문제점:
COPY . .때문에 소스 한 줄만 바뀌어도npm install캐시가 깨짐- 빌드 산출물과 개발용 파일이 섞임
Node.js 예시: 개선된 멀티스테이지 + 락파일 분리
# syntax=docker/dockerfile:1.7
FROM node:20-bookworm AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN \
npm ci
FROM node:20-bookworm AS build
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY /app/dist ./dist
COPY package.json ./
CMD ["node", "dist/index.js"]
포인트:
- 의존성 설치 단계는
package.json과package-lock.json에만 의존 --mount=type=cache로 npm 캐시를 레이어가 아니라 “빌드 캐시 마운트”로 분리- 런타임에는
dist만 복사해 이미지와 캐시 모두 슬림화
.dockerignore 는 사실상 필수
다음은 최소 예시입니다.
node_modules
.git
dist
coverage
.next
Dockerfile
*.log
컨텍스트가 작아지면 캐시 키 변동이 줄고, 전송/해시 비용도 확 떨어집니다.
해결 전략 2: 원격 캐시를 표준화해서 러너 디스크 의존 줄이기
CI 러너가 매번 새로 뜨는(에페메럴) 환경이면 로컬 캐시만으로는 한계가 있습니다. 이때는 레지스트리 기반 원격 캐시가 효과적입니다.
GitHub Actions 예시: buildx + registry cache
name: build
on:
push:
branches: [ main ]
jobs:
docker:
runs-on: ubuntu-latest
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/myorg/myapp:sha-${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/myorg/myapp:buildcache
cache-to: type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=max
설명:
cache-to로 원격 캐시를 축적하고, 다음 빌드는cache-from으로 가져옵니다.- 러너 디스크가 “캐시 저장소”가 아니라 “임시 실행 공간”이 되면서 캐시 폭발로 인한 변동이 크게 줄어듭니다.
주의:
mode=max는 캐시를 많이 저장해 히트율을 높이지만, 레지스트리 저장 비용이 증가할 수 있습니다.- 브랜치별로 캐시를 분리해야 오염이 줄어듭니다(아래 참고).
해결 전략 3: 캐시 스코프를 분리해 오염과 폭발을 동시에 막기
캐시를 하나로 공유하면 처음엔 빨라 보이지만, PR이 많아질수록 서로의 캐시가 쓸모 없는데도 계속 쌓입니다.
권장 스코프 전략
main은 고정 캐시(가장 가치가 큼)- PR은 PR 전용 캐시(짧은 TTL)
- 릴리스 태그는 별도(재현성 목적)
예: 캐시 ref를 브랜치로 분리
cache-from: type=registry,ref=ghcr.io/myorg/myapp:buildcache-${{ github.ref_name }}
cache-to: type=registry,ref=ghcr.io/myorg/myapp:buildcache-${{ github.ref_name }},mode=max
여기서 문제는 PR 브랜치가 많으면 캐시 ref도 많아진다는 점입니다. 따라서 레지스트리 정책(라이프사이클/정리)이나 주기적 삭제 작업을 같이 설계해야 합니다.
해결 전략 4: BuildKit GC(가비지 컬렉션) 정책을 “명시적으로” 운영하기
로컬 빌더(자체 러너, 장수 VM)에서 특히 중요한 부분입니다. 캐시가 폭발하는 팀은 대부분 정리 정책이 없습니다.
빌더 컨테이너/노드에서 주기적 prune
docker buildx prune --all --force --keep-storage 20GB
--keep-storage로 상한을 두면 “캐시가 쌓여서 느려지다가 터지는” 패턴을 막을 수 있습니다.- 장수 러너라면 크론으로 주 1회 또는 일 1회 실행을 권장합니다.
단, 무작정 --all 만 주면 캐시 히트율이 박살날 수 있으니, 팀의 빌드 빈도/디스크 크기에 맞춰 상한을 조정해야 합니다.
해결 전략 5: 캐시가 깨지는 흔한 실수 체크리스트
1) 빌드 인자/라벨에 매번 변하는 값 넣기
예를 들어 다음은 레이어 캐시를 광범위하게 무효화합니다.
ARG BUILD_TIME에 현재 시간을 넣음LABEL git_sha=...를 너무 이른 단계에서 적용
대응:
- 메타데이터는 가능한 마지막 단계에서만 적용
- 혹은 이미지 태그로 표현하고 Dockerfile 내부 변경을 최소화
2) 패키지 매니저 캐시를 레이어로 굳혀버리기
예: RUN npm ci 로 생성되는 캐시/임시파일이 레이어에 남으면 캐시 크기가 불필요하게 커집니다.
대응:
--mount=type=cache사용- 필요 시 설치 후 캐시 디렉터리 정리
3) 모노레포에서 불필요한 워크스페이스까지 복사
대응:
- 빌드 대상 서비스 디렉터리만 컨텍스트로 지정
- 또는
COPY범위를 명확히 제한
예:
docker build -f services/api/Dockerfile services/api
CI 관점에서의 운영 팁: “느려짐”을 조기에 감지하기
BuildKit 캐시는 DB처럼 관리해야 합니다. 초기에 감지하지 못하면 어느 날 갑자기 빌드가 2배, 3배로 늘어납니다. PostgreSQL에서 bloat가 쌓여 성능이 무너지는 것과 유사한 운영 이슈로 볼 수 있고, 관련 사고 대응 감각은 PostgreSQL VACUUM 안 끝남? bloat·락 7단계 진단 같은 글의 접근법과도 닮아 있습니다.
권장하는 최소 모니터링:
- 빌드 시간(p50/p95) 추적
- 러너 디스크 사용량 추적
- 캐시 hit ratio를 간접적으로라도(주요 단계
CACHED비율) 로그 기반으로 추정
정리: 가장 효과가 큰 처방 순서
.dockerignore정비 + 컨텍스트 최소화- Dockerfile 레이어 재배치(락파일 기반 의존성 단계 분리)
--mount=type=cache로 패키지 캐시를 레이어에서 분리- 원격 캐시(레지스트리) 도입으로 러너 디스크 의존 제거
- 캐시 스코프 분리(브랜치/PR) + 정리 정책(GC, prune, 라이프사이클)
위 순서대로 적용하면 “캐시 폭발로 CI가 점점 느려지는” 문제는 대부분 안정화됩니다. 핵심은 캐시를 단순한 보너스가 아니라, 수명주기와 스코프를 가진 운영 대상으로 다루는 것입니다.