Docker BuildKit 캐시 마운트, CI에서 이렇게 쓰면 빌드가 확 빨라진다

CI에서 이미지 빌드가 답답할 때가 있다. Dockerfile에 COPY . . 한 줄 아래에 npm ci나 pip install을 넣어놨는데, 소스 하나만 바뀌어도 매번 의존성을 처음부터 다시 받는다. 결국 5분씩 걸리던 빌드가 CI 러너 열댓 대에서 동시에 돌면서 팀 전체 개발 흐름을 갉아먹는다. 이 글은 BuildKit의 --mount=type=cache를 CI 파이프라인에 어떻게 붙이는지, 그리고 ephemeral 러너 환경에서 캐시를 어떻게 살려두는지 정리한 가이드다. 실제로 우리 팀에서 프론트/백엔드 이미지 30여 개에 이걸 굴려보고 정리한 내용이다.
왜 layer cache만으로는 부족한가
BuildKit 없이도 Docker는 layer cache를 쓴다. RUN npm ci 라인이 바뀌지 않으면 이전 layer를 재사용한다. 문제는 layer cache는 "라인 자체가 변경되면 무효화"된다는 것이다. package.json을 한 글자만 고쳐도 그 아래 모든 layer가 다시 계산된다. 이게 실무에서 자주 일어난다.
또 CI는 대부분 ephemeral 러너다. 매번 새 컨테이너에서 시작하면 로컬 layer cache 자체가 비어있다. registry에서 이전 이미지를 --cache-from으로 끌어와도, 파일 하나만 바뀐 아래 라인들은 다 무효다.
BuildKit 캐시 마운트는 이 문제를 다르게 접근한다. layer가 아니라 디렉터리를 마운트해서, 그 안에 담긴 npm/pip/apt/gradle 캐시를 다음 빌드에서도 그대로 쓴다. layer가 무효화돼도 마운트된 캐시 디렉터리는 살아있다. npm의 경우, package.json이 바뀌어도 이미 받아둔 tarball은 재사용된다.
Dockerfile 예시
npm 프로젝트:
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
npm ci --prefer-offline
COPY . .
RUN npm run build
pip 프로젝트:
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
pip install -r requirements.txt
COPY . .
Go 프로젝트 (module + build cache 두 개 마운트):
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=0 go build -o /out/app ./cmd/api
여기서 몇 가지 포인트가 있다.
첫째, # syntax=docker/dockerfile:1.7을 안 넣으면 캐시 마운트 문법이 파싱 안 된다. BuildKit이 활성화돼 있어도 이 헤더는 필요하다.
둘째, sharing=locked가 기본이다. 병렬 빌드에서 같은 캐시 디렉터리에 두 프로세스가 접근하면 락이 걸린다. 대부분 이게 안전한 기본값이다. 성능 위해 sharing=shared로 바꿀 수도 있지만, npm/pip은 동시 쓰기가 안전하지 않을 수 있으니 락 두는 게 낫다.
셋째, apt/yum도 마찬가지로 캐시할 수 있다. /var/cache/apt와 /var/lib/apt/lists를 마운트하고, Debian 이미지의 경우 rm -f /etc/apt/apt.conf.d/docker-clean을 먼저 해줘야 캐시가 살아남는다.
CI에서 캐시를 살려두는 문제
로컬에서 docker build를 반복 실행하면 캐시 마운트가 알아서 로컬에 쌓인다. CI에서는 얘기가 다르다. 러너가 매번 새로 뜨면 캐시가 사라진다.
해결책은 세 가지 정도가 있다.
옵션 1: Registry 캐시 백엔드
가장 흔한 방식이다. BuildKit이 캐시 자체를 이미지 형태로 registry에 푸시하고, 다음 빌드에서 이걸 끌어온다.
docker buildx build \
--cache-from type=registry,ref=ghcr.io/myorg/app:buildcache \
--cache-to type=registry,ref=ghcr.io/myorg/app:buildcache,mode=max \
-t ghcr.io/myorg/app:${GITHUB_SHA} \
--push .
mode=max가 중요한데, 이걸 안 붙이면 최종 이미지에 남는 layer만 캐시로 push된다. 중간 stage(builder 등)의 캐시가 유실되니 반드시 붙인다.
이 방식의 단점은 network overhead다. 캐시 이미지가 크면(수 GB) push/pull 시간이 오히려 빌드 시간을 잡아먹는다. 우리 팀은 이 방식으로 갔다가 monorepo 캐시가 3.2GB까지 커지면서 손해 보는 상황이 왔었다. 그래서 옵션 2로 넘어갔다.
옵션 2: GitHub Actions 캐시 백엔드 (GHA 전용)
GitHub Actions를 쓴다면 type=gha 백엔드가 있다. GitHub의 Actions cache를 그대로 쓴다.
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/myorg/app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
10GB까지 저장되고, 접근이 빠르다. 다만 GHA cache는 브랜치 격리가 있어서 feature 브랜치에서 만든 캐시를 다른 브랜치가 못 쓴다. main에서 warm-up 워크플로우를 주기적으로 돌려주면 브랜치별 콜드 스타트를 줄일 수 있다.
옵션 3: 자체 buildkitd + 영구 볼륨
Kubernetes에 buildkitd를 StatefulSet으로 띄우고, 캐시 디렉터리를 PVC로 붙이는 방식이다. 러너가 remote buildkitd에 접속해 빌드를 던진다. 캐시는 buildkitd 파드가 계속 들고 있는다.
docker buildx create \
--name k8s-builder \
--driver kubernetes \
--driver-opt namespace=buildkit,replicas=3
우리 팀은 결국 이 방식으로 정착했다. 셋업이 좀 성가시지만, 캐시 hit rate가 옵션 1/2 대비 확연히 좋다. 다만 buildkitd 파드 관리(OOM, 디스크 관리) 이슈가 새로 생긴다는 걸 감안해야 한다.
캐시 마운트가 잘 안 먹을 때 확인할 것
몇 번 삽질하면서 알게 된 것들이다.
.dockerignore에 캐시 디렉터리가 포함되어 있으면 마운트가 이상하게 동작할 수 있다. 예를 들어 node_modules를 dockerignore로 뺐다 하더라도, 마운트 target 경로가 이거랑 겹치면 안 된다.
alpine 이미지에서 apk 캐시는 기본적으로 비활성이다. apk add --no-cache 대신 --mount=type=cache,target=/var/cache/apk apk add ...로 바꾸고 이미지 마지막에 캐시를 지우지 말아야 한다.
멀티 아키텍처 빌드를 하면 아키텍처별로 캐시가 분리된다. linux/amd64와 linux/arm64가 같은 캐시 마운트를 공유하지 않는다. 이걸 모르면 arm64 빌드가 항상 콜드로 도는 걸 보고 당황할 수 있다.
BuildKit 버전이 낮으면 캐시 마운트가 무시된다. docker buildx version이 0.13 이상인지 확인하자. GitHub-hosted 러너는 최신을 쓰지만, self-hosted 러너 이미지가 오래됐으면 문제가 된다.
얼마나 빨라지나
우리 팀 백엔드 서비스(Go, 의존성 200개 정도) 기준으로 정리하면:
- 캐시 마운트 없이, layer cache만: 평균 3분 40초
- 캐시 마운트 + registry 백엔드: 평균 1분 20초
- 캐시 마운트 + K8s buildkitd(옵션 3): 평균 35초
프론트엔드(Next.js, 의존성 900개 이상)에서는 차이가 더 크다. 캐시 없이 7분 걸리던 게 옵션 3에서 50초까지 내려왔다. 특히 node_modules 재설치를 안 하는 게 크다.
수치는 프로젝트마다 다를 거다. 다만 캐시 마운트는 손이 거의 안 가는 최적화라서, 도입 안 할 이유가 잘 없다. Dockerfile에 두세 줄 추가하고 CI 설정 몇 줄 바꾸면 끝이다.
다음에 볼 것
캐시 마운트는 빌드 자체를 빠르게 하는 도구다. 이미지 사이즈나 취약점 관리는 별개 문제다. 다음에는 SBOM 자동 생성(syft/grype)을 CI에 붙이는 얘기를 정리해보려 한다. 혹시 다른 캐시 전략(예: nix binary cache로 Dockerfile 자체를 안 쓰는 접근) 쓰시는 분 있으면 어떻게 굴리시는지 궁금하다.