Docker 빌드 캐시 CI 절반 줄이는 Buildx 설정 — TIL

목차

Docker 빌드 캐시 CI 설정을 손봤더니 GitHub Actions 빌드 시간이 12분에서 5분 30초로 줄었다. 한 일은 두 가지뿐이다 — type=gha로 캐시 백엔드를 등록하고, cache-to/cache-from의 scope를 브랜치별로 분리했다.

원래는 캐시가 아예 없는 상태였다. docker/build-push-action을 호출만 하고 끝. 코드를 작성 중 빌드 로그를 들여다봤는데 npm ci에 3분 40초, pip install에 2분 10초가 찍히는 게 보였다. 의존성 파일은 거의 안 바뀌는데도 매 푸시마다 같은 시간을 똑같이 쓴다. 이거 왜 아무도 안 알려주나 싶었던 부분이다 — 캐시 설정 한 줄로 끝나는 문제였다.

오늘 한 것 — gha 캐시 적용과 scope 분리

따라서, 처음엔 cache-from: type=gha만 추가하면 끝나는 줄 알았다. 빌드를 한 번 더 돌렸는데 시간이 거의 안 줄었다. 로그를 다시 보니 CACHED 표시가 마지막 스테이지에만 붙어 있다. 중간 스테이지는 전부 풀빌드.

12분 빌드의 구성을 먼저 본다

또한, 빌드 시간을 줄이려면 어느 단계가 오래 걸리는지부터 보는 게 순서다. 멀티 스테이지 Dockerfile은 대략 이런 구조였다.

FROM node:20-slim AS deps         # 의존성 설치 (3분 40초)
FROM python:3.12-slim AS pydeps   # 파이썬 의존성 (2분 10초)
FROM node:20-slim AS build        # 프론트 빌드 (2분)
FROM python:3.12-slim AS runtime  # 런타임 (1분)

여기서 가장 무거운 건 deps와 pydeps다. 두 스테이지를 캐시에 넣을 수 있으면 절반은 자동으로 줄어든다는 계산이 나왔다. 문제는 cache-from만 잡았을 때 이 두 스테이지가 캐시에 안 들어가는 점이었다. 빌드 컨텍스트가 같아도 BuildKit이 중간 결과를 저장하지 않으면 다음 빌드에서 못 꺼내 쓴다.

type=gha mode=max로 첫 단추 끼우기

mode=max를 붙이고 나서 비로소 중간 스테이지가 캐시로 들어갔다. mode=min이 기본인데, 이건 최종 이미지에 실제로 포함되는 레이어만 캐싱한다. 멀티 스테이지 빌드에서는 build 스테이지의 산출물이 runtime 스테이지로 COPY되기 때문에, 그 build 스테이지 자체의 중간 레이어는 캐시에 안 들어간다.

mode=max는 모든 스테이지의 레이어를 전부 저장한다. 캐시 용량은 더 먹는다. 우리 프로젝트는 멀티 스테이지로 약 1.2GB 정도 캐시가 쌓였다. GitHub Actions Cache의 repo당 10GB 한도를 고려하면 8~9 푸시 정도는 캐시가 유지된다는 계산이다.

그러나, 여기서 한 가지 의외였던 점 — mode=max로 잡았는데도 두 번째 빌드에서 캐시가 안 먹었다. 로그를 다시 봤더니 cache-to만 잡고 cache-from은 안 잡혀 있었다. 캐시는 만들었는데 읽기를 안 했던 거다. 두 옵션은 반드시 같이 명시해야 한다.

scope를 브랜치별로 쪼개기

여기까지 했을 때 main 브랜치 빌드는 잘 줄었다. 그런데 PR 빌드에서 이상한 일이 생겼다. PR이 main 캐시를 덮어쓰고, 그 다음 main 푸시가 또 풀빌드를 도는 패턴이다. PR마다 의존성이 살짝 다르면 캐시 키가 같은데 내용이 안 맞아서 무효화되는 식이다.

해결책은 scope 분리다. cache-to는 현재 브랜치 이름으로 scope를 지정하고, cache-from은 현재 브랜치 scope를 먼저 읽고 없으면 main scope를 fallback으로 읽도록 두 줄로 적었다. 이렇게 하면 PR은 main 캐시를 읽되 자기 캐시에만 쓰기 때문에 main을 오염시키지 않는다.

이처럼, 이 두 가지 변경으로 12분 → 5분 30초가 됐다. 손이 간 시간은 30분 정도. 가장 ROI가 좋았던 작업이다.

새로 알게 된 것 — 캐시 백엔드와 mode의 차이

설정을 마치고 BuildKit 문서를 다시 읽어보다가, GitHub Actions에서 쓸 수 있는 Docker 빌드 캐시 CI 백엔드가 생각보다 많다는 걸 알게 됐다. 처음엔 type=gha만 봤는데, 상황에 따라 다른 걸 골라야 하는 케이스가 있다.

다섯 가지 캐시 백엔드 비교

백엔드 저장 위치 장점 한계
type=gha GitHub Actions Cache 설정 간단, 무료 repo당 10GB, LRU 삭제
type=registry 컨테이너 레지스트리 용량 제한 적음, 팀 공유 가능 푸시 시간 추가, 권한 설정 필요
type=inline 이미지 메타데이터 별도 저장소 불필요 mode=max 불가, 멀티 스테이지 캐시 누락
type=local 러너 디스크 가장 빠름 self-hosted runner에서만 의미 있음
type=s3 AWS S3 대용량, 리전 제어 비용·권한·네트워크 변수 다수

대부분의 GitHub Actions 워크플로는 type=gha로 충분하다. 캐시가 10GB를 넘어가거나, 여러 워크플로/팀에서 캐시를 공유하고 싶을 때 type=registry를 검토하는 식이다.

type=inline은 이름이 매력적이지만 함정이 있다. 캐시 메타데이터를 이미지 안에 넣는 방식이라 별도 저장소가 필요 없는데, 대신 멀티 스테이지 빌드의 중간 스테이지는 캐시에 못 들어간다. 단일 스테이지 빌드라면 괜찮지만, 요즘 Dockerfile은 거의 다 멀티 스테이지라 실용성이 낮다.

mode=min과 mode=max는 다른 종류의 캐시다

문서를 보면 mode를 한두 줄로 짧게 설명하고 넘어간다. 그래서 처음엔 단순히 캐시 양의 차이라고 생각했는데, 실제 동작은 더 다르다.

mode=min은 결과 이미지로 흘러 들어가는 레이어만 저장한다. 즉 FROM ... AS runtime에 들어가는 레이어들과 그 의존 체인. 그 외의 빌드 전용 스테이지(deps, build 등)는 캐시 대상이 아니다. 다음 빌드에서 deps 스테이지를 다시 처음부터 도는 이유다.

mode=max는 모든 스테이지의 모든 레이어를 저장한다. 멀티 스테이지가 깊으면 캐시가 빠르게 커진다. 우리는 4 스테이지짜리 Dockerfile이라 1.2GB 정도였지만, 6~8 스테이지에 베이스 이미지가 크면 캐시가 3~4GB까지 가는 케이스도 봤다는 보고가 GitHub Discussion에 있었다.

결국, 체감상 mode=max가 거의 항상 옳다. 단점이 캐시 용량인데, GitHub Actions Cache는 LRU로 알아서 비워주니까 신경 안 써도 되는 부분이다.

프론트 빌드 캐시와 다른 점

이처럼, 프론트엔드에서 일하던 시절에는 webpack persistent cache나 Vite의 의존성 캐시처럼 도구가 알아서 캐시를 만들고 무효화했다. cache: { type: 'filesystem' } 한 줄 켜면 끝나는 식이다. 캐시 키는 도구가 파일 해시로 자동 산출했다.

즉, Docker는 사고방식이 다르다. 빌드는 매번 깨끗한 컨테이너에서 도는 게 전제다. 그래서 캐시를 외부에 명시적으로 두고, 그 위치를 옵션으로 알려줘야 한다. cache-from은 어디서 읽을지, cache-to는 어디에 쓸지, mode는 얼마나 깊이 쓸지, scope는 어느 범위로 격리할지를 전부 손으로 잡는다.

즉, 처음엔 이게 번거롭게 느껴졌다. 한 달쯤 만져보니 오히려 명시적이라 좋더라. 캐시가 안 맞을 때 디버깅이 쉽다. 어디서 읽고 어디에 쓰는지 옵션만 보면 알 수 있으니까. 프론트 도구는 캐시 디렉터리가 어디인지 매번 찾아 헤맸던 기억이 있다.

코드 — 실전 워크플로 설정

설명만 길어졌으니 실제로 적용한 워크플로를 짧게 보자. 핵심은 두 줄이다.

- uses: docker/build-push-action@v5
  with:
    context: .
    push: ${{ github.event_name == 'push' }}
    tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
    cache-from: |
      type=gha,scope=${{ github.ref_name }}
      type=gha,scope=main
    cache-to: type=gha,mode=max,scope=${{ github.ref_name }}

cache-from을 두 줄로 적은 부분이 중요하다. 현재 브랜치 scope를 먼저 시도하고, 없으면 main scope로 fallback한다. 새 브랜치를 만들었을 때 main에서 빌드한 캐시를 그대로 쓸 수 있어서 첫 PR 빌드도 빠르다.

cache-to는 현재 브랜치 scope에만 쓴다. PR 빌드가 main의 캐시를 덮어쓰는 사고를 막는다. 이 분리가 안 되어 있으면 PR이 망친 캐시 때문에 main 빌드가 풀빌드를 도는 시나리오가 벌어진다.

그래서, :::tip 캐시가 안 먹는 가장 흔한 원인은 Dockerfile에서 자주 바뀌는 파일을 너무 위쪽에서 COPY 하는 거다. COPY package*.json ./을 먼저, RUN npm ci를 그 다음, 소스 코드 전체 COPY . .은 가장 마지막에. 의존성 파일이 안 바뀌면 npm ci 레이어가 통째로 캐시된다. 이 순서를 잘못 잡으면 위에서 본 옵션을 아무리 잘 잡아도 캐시가 의미 없어진다. :::

그래서, 또 하나의 함정은 .dockerignore다. node_modules, .git, 빌드 산출물 디렉터리를 제외하지 않으면 매번 빌드 컨텍스트 해시가 달라져서 캐시가 무효화된다. 처음에 node_modules를 빼먹고 헤맨 시간이 있었다.

메모 — 막힌 부분과 다음 계획

다 좋았던 건 아니다. 몇 가지 풀리지 않은 부분과 의문점이 남았다.

첫째, 멀티 플랫폼 빌드(linux/amd64 + linux/arm64)에서는 캐시 동작이 살짝 다르다는 이야기를 GitHub Issue에서 봤다. 플랫폼별로 scope가 어떻게 나뉘는지, 한 캐시를 공유할 수 있는지에 대한 부분이 모호하다. 우리 프로젝트는 amd64만 쓰고 있어서 직접 검증은 못 했다. 멀티 아키 빌드를 도입할 때 다시 봐야 할 숙제다.

둘째, GitHub Actions Cache의 10GB 한도가 LRU로 동작한다고 문서에 적혀 있는데, 실제로 어떤 단위(레이어? scope?)로 삭제되는지가 명확하지 않다. 캐시 사용량이 한도에 가까워질 때 어떤 캐시가 먼저 날아가는지 예측이 어렵다. type=registry로 옮기면 이 문제는 사라지지만, 푸시 시간이 늘어나는 트레이드오프가 있어 망설여진다.

또한, 셋째, BuildKit 자체의 캐시 인덱싱 알고리즘은 거의 블랙박스다. 어떤 변경이 캐시 무효화를 일으키는지, 같은 명령이 다른 해시로 잡히는 케이스가 있는지에 대한 디버깅 도구가 부족하다. --progress=plain으로 로그를 자세히 볼 수는 있지만, 캐시 키 자체를 직접 들여다보긴 어렵다.

마지막으로 작성 시점(2026년 5월 기준) docker/build-push-action은 v5가 최신 안정 버전이고, setup-buildx-action은 v3이다. 더 최신을 쓰고 싶다면 공식 GitHub 저장소의 릴리스 노트로 버전 호환성을 확인하는 게 안전하다. BuildKit 자체 캐시 동작은 Docker 공식 문서의 cache backends 페이지에 정리되어 있다.

그러나, 당장 실행할 수 있는 액션 세 가지를 정리하면 — (1) 워크플로의 cache-from/cache-totype=gha,mode=max를 추가한다. (2) scope를 ${{ github.ref_name }}으로 잡고 fallback으로 main을 둔다. (3) Dockerfile에서 의존성 파일 COPY를 소스 코드 COPY 위로 옮기고 .dockerignorenode_modules와 빌드 산출물을 넣는다.

다음엔 type=registry로 옮겨서 10GB 한도를 풀고, 워크플로 간 캐시 공유가 실제로 얼마나 빨라지는지 실험해볼 생각이다.

관련 글