BuildKit cache mount으로 CI 빌드 시간 7분 -> 40초 만든 가이드

우리 팀은 Python/Node 기반 마이크로서비스를 20개 가까이 굴리고 있다. GitHub Actions에서 평일 기준 하루 PR 빌드가 200건 정도 도는데, 빌드 한 번에 평균 6~7분이 걸렸다. PR 하나 머지하려면 빌드 큐에서 한참 기다리는 게 일상이었고, 러너 비용은 매달 슬금슬금 올라갔다.
근데 솔직히 빌드 로그를 들여다보면 절반 이상이 pip install, npm ci, apt-get install 단계였다. 매 빌드마다 pypi 미러에서 똑같은 패키지를 다시 받고 있던 거다. 결국 BuildKit cache mount을 제대로 깔고 나서 빌드 시간이 7분에서 평균 40초로 줄었다. 이 글은 그 과정에서 정리한 패턴이다.
왜 일반 레이어 캐시로는 부족했나
원래 Dockerfile에서 pip install -r requirements.txt 같은 줄은 BuildKit이 알아서 레이어 캐시를 잡아준다. 문제는 requirements.txt가 한 글자라도 바뀌면 전체 레이어가 무효화된다는 점이다. 패키지 하나 추가했을 뿐인데 70개 패키지를 다시 받는다. 우리 monorepo에서는 PR마다 dependency가 한두 개씩 바뀌는 게 일반적이라 이 레이어 캐시는 거의 일을 안 했다.
cache mount은 다르다. 패키지 매니저의 캐시 디렉토리(~/.cache/pip 같은 곳)를 빌드 간에 별도로 들고 다니는 개념이라, 레이어가 무효화돼도 캐시는 살아남는다. 새로 추가된 패키지만 받고 나머지는 캐시에서 꺼내 쓴다.
기본 패턴
상단에 # syntax=docker/dockerfile:1을 박는 게 시작이다. 이게 없으면 cache mount 문법 자체를 못 알아듣는다.
Python 예시:
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
pip install --no-cache-dir -r requirements.txt
여기서 헷갈리기 쉬운 게 --no-cache-dir이다. pip한테는 캐시 디렉토리에 안 쓴다고 말해놓고 BuildKit한테는 캐시 디렉토리를 mount한다? 모순처럼 보이지만 의도된 거다. pip의 --no-cache-dir은 최종 이미지에 캐시를 안 남기겠다는 뜻이고, BuildKit cache mount은 빌드 과정에서만 캐시를 쓴다. 둘은 충돌하지 않는다.
Node 예시:
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
npm ci --cache /root/.npm --prefer-offline
apt도 비슷하지만 한 가지 함정이 있다. Debian 계열 이미지는 기본적으로 apt 캐시를 빌드 끝에 지우는 hook이 있어서 cache mount이 무효가 된다. /etc/apt/apt.conf.d/docker-clean을 비활성화해야 한다.
RUN rm -f /etc/apt/apt.conf.d/docker-clean
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends curl ca-certificates
이 한 줄 빼먹어서 한참 헤맸다. 빌드는 정상으로 끝날는데 cache mount 효과가 0이었다.
sharing 옵션을 잘 쓰면 동시 빌드가 빨라진다
sharing 옵션은 디폴트가 shared인데, 우리 케이스에선 locked가 안전했다. monorepo에서 같은 base image를 쓰는 서비스 여러 개가 동시에 빌드되면 같은 cache mount을 동시에 건드린다. pip은 동시 쓰기에 의외로 약해서 가끔 캐시가 손상된다. locked로 잡으면 한 빌드가 끝날 때까지 다른 빌드는 기다린다.
근데 locked는 직렬화되니까 너무 자주 동시 빌드가 일어나면 오히려 느려질 수도 있다. 우리는 일단 locked로 가다가, 큐 대기가 심해지면 캐시 mount을 서비스별로 id 옵션으로 분리해서 격리하는 방향으로 갈 계획이다.
RUN --mount=type=cache,id=pip-service-a,target=/root/.cache/pip \
pip install -r requirements.txt
id를 다르게 주면 동일 target 디렉토리라도 BuildKit이 별도 캐시로 관리한다.
GitHub Actions에서 cache를 어떻게 들고 다니나
여기가 정작 제일 까다로웠다. cache mount은 빌드 머신의 BuildKit daemon에 저장된다. GitHub Actions의 ephemeral runner는 매번 새로 뜨니까 daemon 캐시가 있을 리 없다. cache mount이 빛을 보려면 외부 저장소에 캐시를 export/import 해야 한다.
docker/build-push-action 기준:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: my-registry/service:${{ github.sha }}
cache-from: type=registry,ref=my-registry/service:buildcache
cache-to: type=registry,ref=my-registry/service:buildcache,mode=max
mode=max가 중요하다. 디폴트(min)는 최종 레이어만 캐시하는데, max는 cache mount까지 전부 export한다. 처음에 mode=min으로 깔았다가 "cache mount이 왜 안 먹지" 하면서 30분 날렸다.
레지스트리에 캐시를 쌓으니까 ECR 비용이 좀 오를까 걱정했는데, 우리 케이스에서는 한 서비스당 캐시가 300~500MB 수준이라 큰 부담은 아니었다. 대신 캐시 이미지가 무한정 커지지 않도록 retention policy를 잡는 건 필요하다. ECR lifecycle 룰에 untagged > 7 days 같은 걸 걸어두는 정도.
결과: 7분에서 40초로
대표적인 Python 서비스 하나 기준이다:
| 단계 | Before | After |
|---|---|---|
| apt-get install | 90초 | 5초 |
| pip install | 220초 | 12초 |
| npm ci (프론트 묶음) | 70초 | 8초 |
| 나머지 (빌드, push) | 40초 | 35초 |
| 합계 | 약 420초 | 약 60초 |
PR 머지 사이클이 눈에 띄게 빨라졌고, 무엇보다 러너 사용 시간이 줄어들면서 월간 GitHub Actions 비용이 약 30% 떨어졌다. 사실 비용보다 더 중요한 건 PR 피드백 루프가 짧아졌다는 점이다. "빌드 5분 기다리느니 다른 거 하자" 같은 컨텍스트 스위치가 줄었다.
함정 몇 가지
cache mount이 만능은 아니다. 우리도 한참 쓰다 보니 몇 가지 알게 된 게 있다.
첫 번째, 레이어 캐시와 cache mount은 다르다는 걸 계속 헷갈리는 사람이 있다. 새로 합류한 동료가 "왜 빌드 캐시 무효화돼도 빠르냐"고 물어본 적이 있는데, BuildKit 캐시는 두 종류라는 걸 한번 설명해주면 다들 금방 이해한다.
두 번째, 로컬 개발 환경에서는 cache mount 효과를 잘 못 본다. Docker Desktop에서는 BuildKit daemon이 로컬에 있어서 첫 빌드 이후엔 빠른데, 어차피 IDE에서 hot reload 돌리는 게 일반적이라 도커 빌드 자체를 자주 안 한다. cache mount은 CI에서 진가가 나온다.
세 번째, multi-stage 빌드에서 stage 간 캐시 공유가 안 된다. builder stage에서 cache mount 쓴 캐시를 runtime stage에서 못 본다. 그래서 우리는 dependency install은 전부 builder에서만 하고, runtime stage는 COPY --from=builder로 결과물만 가져온다. 이게 cache mount과 multi-stage의 자연스러운 조합이다.
다음에 해보려는 것
레지스트리 캐시 대신 S3 backend로 옮기는 걸 검토 중이다. ECR보다 read/write가 빠르다는 보고가 좀 있어서. 또 monorepo에서 변경된 서비스만 빌드하도록 Bazel이나 Nx 같은 도구로 한 단계 더 자르는 것도 봐야 한다. 이건 다음 글에서 다뤄볼 생각이다.
혹시 cache mount 관련해서 다른 노하우 쓰시는 분 있으면 댓글로 공유해주세요. 특히 monorepo에서 cache id를 어떻게 관리하는지가 궁금합니다.