BuildKit cache mount 제대로 쓰는 법 — Rust/Node CI 빌드 시간을 절반으로
CI에서 컨테이너 빌드가 매번 5분 넘게 걸리면 슬슬 화가 난다. 우리 팀에서도 Rust 서비스 하나 빌드하는 데 평균 5분 40초가 찍히길래, 며칠 시간 내서 BuildKit cache mount를 제대로 정리했다. 결과는 36초. 그래서 오늘은 이 과정을 정리해두려고 한다.
이 글은 BuildKit cache mount 자체의 동작 원리부터, CI 환경(특히 GitHub Actions처럼 runner가 ephemeral한 환경)에서 캐시를 살려두는 패턴까지 다룬다. Dockerfile 잘 쓰는 법은 인터넷에 널려 있지만, “CI에서 cache mount가 왜 안 살아남는지”를 한 번에 정리한 글이 의외로 잘 없어서 한번 써본다.
cache mount는 layer cache와 뭐가 다른가
일반적인 Docker layer cache는 RUN 명령 자체가 동일해야 살아남는다. requirements.txt를 한 줄만 바꿔도 그 아래 RUN은 다 무효화된다. 반면 cache mount는 레이어가 무효화돼도 캐시 디렉터리는 유지된다. pip이나 cargo가 받아둔 패키지 캐시를 그대로 쓸 수 있다는 뜻이다.
예를 들어 cargo build 라인은:
# syntax=docker/dockerfile:1.7
FROM rust:1.85 AS builder
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release
/usr/local/cargo/registry(다운로드한 크레이트)와 /app/target(컴파일 산출물) 두 디렉터리가 빌드 사이에 살아남는다. 소스 한 줄만 바꿔도 target 디렉터리가 그대로 있으면 incremental compilation이 동작한다.
Node.js는 보통 이렇게 쓴다:
RUN --mount=type=cache,target=/root/.npm \
npm ci
/root/.npm이 살아있으니 package-lock.json이 바뀌어도 변경분만 받아온다.
여기까지는 로컬에서는 잘 된다. 진짜 문제는 CI다.
GitHub Actions에서 cache mount가 사라지는 이유
GitHub Actions runner는 매 잡마다 새로 뜬다. BuildKit cache mount는 builder daemon이 들고 있는 캐시라서, runner가 죽으면 같이 날아간다. 그래서 별도 조치 없이 docker buildx build만 돌리면 매번 0부터 다시 받는다.
해결책은 두 가지다.
(1) Registry에 캐시를 export/import
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/my-org/my-app:${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/my-org/my-app:buildcache
cache-to: type=registry,ref=ghcr.io/my-org/my-app:buildcache,mode=max
mode=max가 중요하다. 기본값인 mode=min은 최종 stage 레이어만 캐시한다. multi-stage 빌드를 쓰면 builder stage 캐시(우리가 진짜 원하는 cargo target 같은 것)가 다 버려진다. 사실 이거 모르고 한 달 가까이 “왜 캐시가 안 먹지” 했다.
(2) GitHub Actions cache(gha) 사용
cache-from: type=gha
cache-to: type=gha,mode=max,scope=${{ github.ref_name }}
scope로 브랜치별 캐시를 분리할 수 있다. main 브랜치 캐시를 PR 빌드도 읽어가게 하려면 cache-from에는 scope를 빼거나 fallback을 추가하면 된다. gha 캐시는 리포지토리당 10GB 제한이 있으니 큰 이미지를 여럿 굴리면 금방 차서, 우리는 registry 방식이 더 안정적이라 그쪽으로 갔다.
cache mount의 sharing 옵션 — 동시 빌드에서 망하지 않으려면
여러 빌드가 같은 cache mount를 동시에 만지면 어떻게 될까? 기본은 sharing=shared로 동시 접근 허용인데, cargo나 npm처럼 lockfile 기반 도구는 보통 괜찮다. 그런데 Go 모듈 캐시는 동시 쓰기 중에 가끔 깨진다. 빌드가 “checksum mismatch”로 죽으면 의심해볼 부분.
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
go mod download
sharing=locked로 바꾸면 직렬화된다. 빌드는 약간 느려지지만 신뢰성은 올라간다. 우리 팀 Go 모노레포에서는 이걸로 잡았다.
sharing=private은 빌드마다 새 캐시를 받는 옵션인데, 그러면 사실상 캐시 의미가 없어진다. 디버깅용 정도.
id로 캐시 격리하기
Rust 서비스를 여러 개 빌드하는데 cargo registry 캐시를 공유하고 싶다면 id를 명시해서 의도적으로 공유한다.
RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \
--mount=type=cache,id=app-target-${TARGETARCH},target=/app/target \
cargo build --release
registry 캐시는 공유, target 캐시는 아키텍처별로 분리. amd64와 arm64를 같은 target에 섞어 쓰면 incremental rebuild가 박살 난다. 이거 한 번 당하면 안 잊는다.
최근 동향 한두 가지
Docker docs의 cache 최적화 가이드가 올해 초에 cache mount 관련 챕터를 크게 보강했다. 특히 cache-to 방식별 trade-off가 표로 정리돼서 처음 셋업할 때 참고하기 좋다.
그리고 BuildKit 0.13 이후로는 --mount=type=cache,mode=0755 같은 권한 옵션이 안정화돼서, non-root user로 빌드할 때 chown 안 해도 되는 경우가 늘었다. distroless 빌드 자주 하는 팀이면 이 부분 한번 확인해볼 만하다.
그래서 우리 팀에서는
Rust 서비스 6개를 monorepo에서 빌드하는데, 원래 평균 5분 40초가 걸렸다. 적용한 건 세 가지였다:
- cache mount로 cargo registry + target 분리 캐싱
- registry export(
mode=max)로 builder stage까지 모두 push - sccache를 추가로 붙여서 컴파일 단위까지 캐시
이 셋을 다 끼우니 변경 적은 PR은 36초, 의존성까지 새로 받는 빌드도 1분 50초 안쪽으로 떨어졌다. sccache 부분은 정리되면 다음 글에서 따로 다뤄보려고 한다.
혹시 BuildKit cache 관련해서 다른 패턴 쓰시는 분 있으면 댓글로 공유 부탁드린다. 특히 self-hosted runner에서 로컬 디렉터리 cache를 쓰는 케이스는 우리도 검토 중이라 경험담이 궁금하다.