IT/컨테이너

Docker BuildKit 캐시, 사실 내부적으로는 이렇게 돌아간다

gfrog 2026. 7. 4. 15:16
SMALL

BuildKit을 쓰기 시작한 지 꽤 됐다. DOCKER_BUILDKIT=1 넣는 건 이제 반사적으로 나온다. 그런데 얼마 전 팀 내부에서 "왜 RUN apt-get install이 캐시된 건 알겠는데, --mount=type=cache를 걸었을 때 도대체 뭐가 저장되고 뭐가 재사용되는 거냐"는 질문을 받고 답을 얼버무렸다. 그날 저녁에 소스와 문서를 뒤져가며 정리한 내용을 여기 남긴다.

이 글은 실행 가이드는 아니다. BuildKit이 캐시를 어떻게 표현하고, 어디에 저장하고, 어떤 기준으로 재사용을 판단하는지 흐름을 따라간다. docker buildx build --cache-to=type=registry 를 쳤을 때 그 뒤에서 일어나는 일이 궁금한 사람이 대상이다.

LLB — 캐시의 진짜 단위

먼저 알아둘 것: BuildKit은 Dockerfile을 직접 실행하지 않는다. 프론트엔드(예: dockerfile.v0)가 Dockerfile을 파싱해서 LLB(Low-Level Build definition) 라는 DAG로 변환한 다음, 그 그래프를 BuildKit 데몬(buildkitd)이 실행한다.

LLB는 노드들의 방향 그래프다. 각 노드는 하나의 오퍼레이션이다 — Exec, File(COPY/ADD), Source(FROM/git/http), Merge, Diff 같은 것들. 이 노드 하나하나가 캐시의 단위다. 여기서 자주 오해가 생기는데, Dockerfile 한 줄이 곧 LLB 노드 하나가 아니다. RUN --mount=type=cache,target=/root/.cache/go-build go build ./... 한 줄은 마운트 준비 노드와 실행 노드가 나뉘어 표현된다.

각 노드는 실행되기 전에 캐시 키로 해시된다. 이 키가 같으면 결과물을 재사용한다. 흐름을 단순화하면 이렇다:

LLB 노드
  ├─ 오퍼레이션 종류 (Exec/File/Source/...)
  ├─ 입력 노드들의 캐시 키 (부모 체인)
  ├─ 오퍼레이션 파라미터 (커맨드, mount 정의, env, user, ...)
  └─ 콘텐츠 기반 해시 (COPY 소스 파일들의 실제 바이트)
        ↓
    캐시 키 (SlowCache / FastCache 두 종류)

여기서 흥미로운 지점이 두 가지 있다. 하나는 콘텐츠 기반 해시다. 예전 Docker 빌더는 COPY . . 을 만나면 그냥 "이 스텝의 command string" 만으로 캐시 여부를 판단했다. BuildKit은 다르다. COPY . . 이든 COPY src src 든, 실제로 복사되는 파일들의 내용을 해시해서 키에 포함시킨다. 그래서 파일 내용이 그대로면 다른 경로로 복사해도 캐시가 히트한다.

다른 하나는 SlowCache vs FastCache 구분이다. FastCache는 "노드의 정의만 봐도 계산할 수 있는 키"고, SlowCache는 "이전 노드를 실제로 실행해봐야 계산할 수 있는 키" 다. 예를 들어 COPY 소스가 이전 스테이지의 출력이면, 그 스테이지를 돌려봐야 파일 해시를 뽑을 수 있다. BuildKit은 이걸 두 단계로 나눠서 처리한다. FastCache로 맞는 캐시가 있으면 아예 부모를 안 돌리고 끝낸다. 없으면 부모를 돌리고, 결과를 봐서 SlowCache를 계산한다. 이게 원격 캐시 히트할 때 빌드가 갑자기 몇 초 만에 끝나는 이유다.

Snapshot과 Content-Addressable Storage

캐시된 결과물은 어디에 저장되는가. /var/lib/buildkit 아래에 두 개의 큰 저장소가 있다.

첫째는 snapshotter. 파일시스템 레이어를 담는다. overlayfs, native, stargz 등 백엔드를 고를 수 있다. LLB 노드가 실행되면 그 결과 파일시스템 상태가 하나의 snapshot으로 저장된다. Snapshot은 부모 snapshot 위에 올린 오버레이 형태여서, 저장 공간이 레이어처럼 공유된다.

둘째는 content store. OCI 표준 스펙을 따르는 콘텐츠 주소 저장소다. 각 blob이 sha256 해시로 식별된다. Snapshot을 export할 때 여기 tar.gz(또는 zstd) 로 압축해서 저장한다. 원격 캐시 export/import도 이 content store 를 매개로 한다.

캐시 키는 별도의 cache metadata DB(boltdb) 가 관리한다. 키 → snapshot ID, 키 → 원격 매니페스트 참조 같은 매핑이 여기 들어있다.

이 세 개가 분리돼 있다는 게 실무에서 몇 가지 결론으로 이어진다. docker builder prune 을 돌려도 아직 참조되는 snapshot은 남는다. buildctl du 로 보면 Shared 컬럼이 있는데, 여러 캐시 키가 같은 snapshot을 가리키는 경우다. 그리고 --cache-to=type=registry,mode=max 를 걸면 중간 스테이지 snapshot들까지 전부 content store 형태로 뽑아 레지스트리에 올린다. mode=min 은 최종 이미지를 만드는 데 필요한 것만 올린다. 원격 캐시가 이상하게 크다 싶으면 mode 부터 확인한다.

Mount Cache는 다른 얘기다

RUN --mount=type=cache,target=/root/.m2 같은 캐시 마운트는 위에서 설명한 레이어 캐시와 완전히 다른 물건이다. 이건 레이어에 포함되지 않는다. 빌드 중에만 마운트되고, 빌드가 끝나면 사라진다. 다시 빌드할 때 buildkitd가 로컬에 유지 중인 별도 볼륨을 그 위치에 다시 마운트해준다.

즉 캐시 마운트의 내용물은:

  • 로컬 buildkitd 데몬 밖으로 안 나간다. --cache-to=type=registry 로도 export 안 된다
  • CI 러너가 매번 새로 뜨는 환경이면 캐시 마운트는 사실상 무의미하다. 처음엔 이걸 몰라서 GitHub Actions에서 --mount=type=cache 로 npm 캐시 걸어놓고 왜 안 빨라지는지 한참을 헤맸다
  • 대신 러너를 self-hosted로 유지하거나, actions/cache 같은 걸로 buildkitd 볼륨을 통째로 캐시하는 우회가 필요하다

id= 옵션이 중요한데, id가 같은 여러 마운트는 같은 볼륨을 공유한다. sharing=locked|shared|private 로 동시 접근 방식을 정한다. 병렬 빌드에서 npm/yarn/pip 캐시가 깨지는 경우가 있는데, sharing 모드를 잘못 잡아서 그런 경우가 많다. 기본값 shared 는 동시 쓰기를 허용하니 락 파일을 쓰는 매니저에는 위험하다.

v0.21 이후 바뀐 것들

최근 BuildKit v0.21부터 원격 캐시의 기본 매니페스트 포맷이 image-manifest 로 바뀌었다. 이전엔 image-index(멀티 아키텍처용) 를 기본으로 썼는데, ECR을 포함한 일부 레지스트리가 index를 캐시 매니페스트로 취급하는 걸 거부해서 문제가 자주 났다. 이제 image-manifest 가 기본이라 ECR/GCR에서 별 설정 없이도 잘 동작한다. 예전에 --cache-to=type=registry,image-manifest=true 를 명시적으로 붙였던 팀은 이제 옵션을 빼도 된다.

한 가지 조심할 것: 사내 하모니 레지스트리처럼 오래된 배포판을 쓰면 image-manifest 도 못 받는 경우가 있다. 원격 캐시 push가 조용히 실패하는 경우가 있으니 첫 배포 후엔 buildctl-daemonless.sh du 든 레지스트리 태그 목록이든 눈으로 한 번 확인하는 게 좋다.

정리하면

캐시 히트 여부는 세 가지가 결정한다. LLB 노드 정의(command, env, user, mount 스펙 등), 부모 노드들의 캐시 키, 그리고 COPY/ADD의 실제 파일 내용. 이 셋 중 하나라도 바뀌면 그 노드부터 아래 전부가 재실행된다. 그러니 캐시를 잘 살리려면 자주 바뀌는 스텝을 Dockerfile 뒤쪽으로 미루라는 옛날 조언이 여전히 유효하다 — 다만 이유는 "명령어가 같아서" 가 아니라 "부모 체인의 키가 안 변해서" 다.

내부 원리를 알아두면 이상한 캐시 미스가 났을 때 원인 후보를 훨씬 빠르게 좁힐 수 있다. 어제까지 잘 되던 게 오늘 안 되면 대개 셋 중 하나다: base 이미지의 digest 가 바뀌었거나, .dockerignore 밖에서 뭔가 새로 추가돼 파일 해시가 밀렸거나, buildx builder 인스턴스가 재생성돼서 로컬 캐시가 날아갔거나. 다음에는 buildctl debug 계열 커맨드로 캐시 키를 직접 뽑아 비교해보는 방법을 정리해볼까 한다.

BIG