IT/컨테이너

BuildKit cache mount + registry cache, CI 이미지 빌드 캐싱 제대로 하기

gfrog 2026. 9. 8. 00:13

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/cachecache-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, 컨테이너, 캐싱