BuildKit cache mount + registry cache, CI 이미지 빌드 캐싱 제대로 하기
CI에서 Docker 이미지 빌드 시간이 5분을 넘기기 시작하면 사람들이 슬슬 짜증을 낸다. 우리 팀도 그랬다. GitHub Actions 러너에서 Node.js 서비스 이미지 빌드가 평균 6분 30초. npm ci 한 번 도는 데만 3분 넘게 걸렸다. 근데 소스는 몇 줄 안 바뀌었는데 왜 매번 처음부터 다 하지? 라는 질문에서 시작한 이야기다.
이 글은 BuildKit cache mount와 registry cache 두 가지를 조합해서 CI 이미지 빌드 캐싱을 실제로 동작하게 만드는 가이드다. 로컬에서는 잘 되던 캐싱이 CI에서는 안 되는 이유부터, 러너가 매번 새로 뜨는 환경에서도 캐시를 살리는 방법까지 다룬다.
왜 layer cache 만으로는 부족한가
Docker의 기본 layer cache는 잘 알려져 있다. Dockerfile을 잘 쪼개서 COPY package.json ./ 다음에 RUN npm ci를 넣으면 package.json이 안 바뀔 때 npm ci 레이어가 재사용된다. 여기까지는 다들 안다.
문제는 이게 "이 RUN이 이전과 동일한가"만 본다는 점이다. 즉, package.json에 라이브러리 하나만 추가해도 npm ci 레이어는 무효화되고, 처음부터 전체 의존성을 다시 받는다. ~/.npm 캐시가 남아있다면 그중 상당수는 다시 안 받아도 되는데, layer cache 관점에서는 그 캐시 자체가 이미지 안에 없다.
여기서 등장하는 게 cache mount다. RUN --mount=type=cache,target=/root/.npm 이렇게 쓰면 그 RUN 명령이 도는 동안만 /root/.npm 경로에 BuildKit이 관리하는 영구 디렉토리가 마운트된다. 이미지에는 안 들어가지만, 다음 빌드 때 같은 경로에 다시 마운트되므로 npm 자체 캐시가 살아있다.
# syntax=docker/dockerfile:1.7
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline
COPY . .
RUN npm run build
첫 줄의 # syntax=docker/dockerfile:1.7 주석은 꼭 있어야 한다. 이게 없으면 프론트엔드가 구버전으로 폴백돼서 cache mount 문법을 못 알아먹는다. 이거 빼먹고 왜 안 되지 하는 사람 꽤 봤다.
로컬에서는 되는데 CI에서는 왜 안 되나
여기까지 하고 로컬에서 두 번째 빌드를 돌리면 npm ci가 몇 초 만에 끝난다. 캐시 mount가 잘 살아있다. 그런데 GitHub Actions에 올리면 매번 처음부터 다시 받는다. 왜?
간단하다. GitHub Actions 러너는 매번 새 VM이다. BuildKit이 로컬 디스크에 저장한 cache mount 데이터가 러너 종료와 함께 날아간다. 즉, cache mount는 "빌드 데몬이 계속 살아있는" 환경에서만 그대로는 의미가 있다.
CI에서 이걸 살리려면 두 가지 접근이 있다. 하나는 러너의 파일 시스템에 캐시를 저장했다가 다음 잡에서 restore하는 방식. GitHub Actions의 actions/cache나 cache-from=type=gha가 여기 해당한다. 다른 하나는 캐시를 registry에 밀어넣고 다음 빌드가 registry에서 당겨오는 방식. 이게 cache-to=type=registry다.
우리 팀은 사내 Harbor를 이미 쓰고 있어서 registry cache 방식을 골랐다. gha 캐시는 저장소당 10GB 제한이 있어서 이미지 몇 개 같이 캐싱하기 시작하면 금방 찬다. 반면 registry는 그런 제약이 없다.
Registry cache 설정하기
docker buildx build 명령으로 registry cache를 지정한다:
docker buildx build \
--cache-from type=registry,ref=harbor.internal/myteam/myapp:buildcache \
--cache-to type=registry,ref=harbor.internal/myteam/myapp:buildcache,mode=max \
--tag harbor.internal/myteam/myapp:${GITHUB_SHA} \
--push \
.
핵심은 mode=max다. 기본값은 mode=min인데, 이건 최종 이미지에 들어가는 레이어만 캐시로 push한다. 즉, 멀티스테이지 빌드에서 build 스테이지(빌드 도구, 소스, node_modules 다 있는 무거운 스테이지)의 캐시가 안 올라간다. mode=max로 바꾸면 모든 중간 스테이지의 레이어까지 다 push한다. 캐시 이미지 용량은 커지지만, 그게 우리가 원하는 거다.
한 가지 주의점. cache mount로 저장한 데이터는 registry cache에 같이 안 올라간다. 그러니까 npm의 ~/.npm 캐시는 여전히 러너별로 sandbox 안에서만 유효하다. GHA에서 cache mount 자체를 살리고 싶으면 reproducible-containers/buildkit-cache-dance 같은 액션을 붙여서 별도로 stash/restore를 걸어줘야 한다. 우리는 여기까진 안 갔다. Layer cache가 registry에서 오면 npm ci 스텝 자체가 스킵되기 때문에, cache mount는 layer cache가 miss 났을 때의 안전망 정도로만 쓴다.
GitHub Actions에 넣기
실제로 우리가 쓰는 워크플로우 스텝은 이렇다:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Harbor
uses: docker/login-action@v3
with:
registry: harbor.internal
username: ${{ secrets.HARBOR_USER }}
password: ${{ secrets.HARBOR_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: harbor.internal/myteam/myapp:${{ github.sha }}
cache-from: type=registry,ref=harbor.internal/myteam/myapp:buildcache
cache-to: type=registry,ref=harbor.internal/myteam/myapp:buildcache,mode=max
docker/build-push-action이 내부적으로 buildx를 써서 위의 CLI 옵션을 다 대신 처리해준다. Buildx 셋업 단계가 없으면 기본 docker 빌더로 폴백돼서 cache mount 문법도 못 쓴다.
브랜치별로 캐시를 분리하고 싶으면 태그에 브랜치명을 붙인다. 예: buildcache-${{ github.ref_name }}. main 브랜치용 캐시가 실험 브랜치의 이상한 의존성 조합에 오염되는 걸 방지할 수 있다. 대신 캐시 재사용률은 좀 떨어진다. 트레이드오프다.
실제로 얼마나 빨라졌나
우리 케이스 기준:
- 캐시 완전 miss (첫 빌드, 또는 lock 파일 크게 바뀜): 6분 30초 → 6분 40초. Registry에 캐시 push하는 시간이 추가돼서 조금 더 느리다.
- 소스만 바뀐 경우 (package-lock.json 그대로): 6분 30초 → 1분 10초. npm ci 스텝 통째로 스킵됨.
- 의존성 하나 추가된 경우: 6분 30초 → 2분 40초. Layer는 miss 나지만 npm 자체가 tarball 캐시를 재사용.
평균적으로 4분 정도 줄었다. 하루 40~50번 도는 파이프라인이라 러너 시간으로 환산하면 꽤 큰 절감이다.
잘 안 될 때 확인할 것들
캐시가 안 붙는 것 같으면 BUILDKIT_PROGRESS=plain 을 붙여서 빌드 로그를 자세히 본다. 각 스텝 옆에 CACHED 표시가 붙는지 확인한다. 안 붙었다면:
syntax지시자를 빼먹었는지. 위에서 얘기한 그 한 줄.cache-from의 태그에 실제 이미지가 있는지. 첫 빌드가 실패했거나 push 권한이 없어서 캐시가 안 올라가면 계속 miss만 난다. Harbor에서myapp:buildcache태그로 뭔가 올라와 있는지 직접 확인해 본다.- 베이스 이미지 해시가 바뀌었는지.
FROM node:20-alpine은 태그가 같아도 latest가 갱신되면 다른 해시로 pull된다. 이 순간 그 뒤 모든 레이어의 캐시가 다 무효화된다. 안정성을 원하면 digest 고정 (FROM node:20-alpine@sha256:...)을 검토한다. RUN명령이 미묘하게 다른지. 공백 하나, 개행 하나 차이로 다른 명령으로 인식된다. 특히 shell 확장이 들어간 경우.
마무리
registry cache는 세팅하기 좀 귀찮지만 한번 붙여두면 파이프라인 전체 속도감이 확 바뀐다. 특히 여러 마이크로서비스가 같은 베이스 이미지를 공유하는 조직이라면, 베이스 이미지 레이어까지 registry cache에서 재사용되면서 효과가 배가된다.
다만 우리도 아직 정리 중인 게 있다. Registry의 buildcache 태그가 계속 커진다. GC 정책을 어떻게 잡을지, 브랜치별 캐시를 얼마나 오래 유지할지는 팀마다 다를 텐데 우리는 아직 완전히 답을 못 냈다. 이건 다음 글에서 다뤄보려고 한다.
혹시 gha cache나 s3 cache backend 써서 다른 방식으로 잘 굴리시는 분 계시면 어떻게 하시는지 궁금하다.
태그: Docker, BuildKit, CI/CD, GitHub Actions, 컨테이너, 캐싱