- Published on
Docker BuildKit 캐시로 CI 빌드 70% 단축
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
CI에서 Docker 이미지 빌드가 느린 가장 큰 이유는 간단합니다. 매 실행마다 빌드가 ‘깨끗한 머신’에서 시작되기 때문에, 로컬에서 잘 되던 레이어 캐시가 CI에서는 거의 재사용되지 않습니다.
BuildKit은 이 문제를 정면으로 해결합니다. 핵심은 cache-to로 캐시를 외부(레지스트리 또는 GitHub Actions 캐시)에 내보내고, 다음 빌드에서 cache-from으로 다시 가져오는 것입니다. 이 한 가지가 제대로만 동작해도, 의존성 설치 레이어(예: npm ci, pip install, gradle dependencies)가 대부분 재사용되면서 체감상 50~80%까지 빌드 시간이 줄어듭니다.
이 글에서는 BuildKit 캐시가 왜 CI에서 특히 강력한지, 어떤 캐시 백엔드를 선택해야 하는지, 그리고 Dockerfile을 어떻게 작성해야 캐시 적중률이 올라가는지까지 실전 관점으로 정리합니다. 캐시가 갑자기 안 먹는 상황은 별도 글인 Docker 빌드가 느릴 때 - BuildKit 캐시 깨짐 해결도 함께 참고하면 좋습니다.
BuildKit 캐시가 CI에서 빛을 발하는 이유
전통적인 Docker 레이어 캐시의 한계
- 로컬 개발 머신에서는 이전 빌드의 레이어가 디스크에 남아 있어 재사용됩니다.
- CI 러너는 매번 새로 뜨거나(에페메럴) 워크스페이스가 초기화되어 레이어 캐시가 사라집니다.
- 그래서
RUN npm ci같은 “시간 많이 먹는 레이어”가 매번 다시 실행됩니다.
BuildKit의 핵심: 캐시를 ‘외부로’ 내보내고 다시 가져오기
BuildKit은 빌드 결과물(이미지)뿐 아니라 “중간 빌드 상태”를 캐시로 내보낼 수 있습니다.
--cache-to: 이번 빌드에서 생성된 캐시를 어딘가에 저장--cache-from: 이전에 저장된 캐시를 다시 로드해서 레이어 재사용
CI에서 중요한 건 이 캐시 저장소를 신뢰성 있게 유지하는 것입니다.
캐시 백엔드 선택: registry vs gha
BuildKit 캐시는 크게 두 가지 방식이 많이 쓰입니다.
1) 레지스트리 캐시(type=registry)
- 장점: 어디서든(다른 CI, 로컬) 동일하게 가져올 수 있고, 장기 보관이 쉬움
- 단점: 레지스트리 용량/트래픽 비용, 권한/토큰 관리 필요
- 추천 상황: 멀티 CI 환경, 셀프호스팅 러너, 장기적으로 안정적인 캐시가 필요할 때
2) GitHub Actions 캐시(type=gha)
- 장점: 설정이 단순하고 GitHub Actions에서 매우 편리
- 단점: 캐시 만료/용량 정책에 영향, 다른 CI로 이식성 낮음
- 추천 상황: GitHub Actions 중심의 파이프라인, 빠른 도입이 목표일 때
GitHub Actions에서 캐시가 기대대로 동작하지 않는 경우가 꽤 많습니다. 그때는 GitHub Actions 캐시가 안 먹힐 때 원인 9가지 체크리스트가 바로 도움이 됩니다.
GitHub Actions 예제: BuildKit 캐시로 빌드 가속
아래 예제는 docker/build-push-action을 사용해 BuildKit 캐시를 type=gha로 연결합니다. 핵심은 cache-from과 cache-to를 같이 설정하는 것입니다.
name: build
on:
push:
branches: ["main"]
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
mode=max는 왜 중요한가
cache-to에서 mode=max를 주면 더 많은 캐시 메타데이터를 저장해 재사용 범위가 넓어집니다. CI에서 “조금만 바뀌어도 캐시 미스”가 잦다면 mode=max가 체감 개선을 주는 경우가 많습니다.
레지스트리 캐시 예제: 팀/환경 간 캐시 공유
레지스트리 캐시는 “캐시 이미지”를 별도 태그로 저장하는 패턴이 흔합니다.
docker buildx build \
--platform linux/amd64 \
--tag ghcr.io/my-org/my-app:latest \
--cache-from type=registry,ref=ghcr.io/my-org/my-app:buildcache \
--cache-to type=registry,ref=ghcr.io/my-org/my-app:buildcache,mode=max \
--push \
.
이 방식은 로컬 개발에서도 같은 캐시를 당겨 쓸 수 있어 “CI만 빠른” 상태가 아니라 팀 전체 빌드 경험을 개선하기 좋습니다.
Dockerfile이 캐시 친화적이어야 70% 단축이 나온다
BuildKit 캐시를 붙였는데도 빨라지지 않는다면, 대부분 Dockerfile 레이어 설계가 캐시를 깨고 있을 확률이 높습니다.
1) 의존성 설치 레이어를 소스 코드보다 위로 올리기
Node.js를 예로 들면, package.json과 package-lock.json만 먼저 복사해 npm ci를 수행해야 합니다.
# syntax=docker/dockerfile:1
FROM node:20-slim AS build
WORKDIR /app
# 1) 의존성 메타 파일만 먼저 복사
COPY package.json package-lock.json ./
# 2) 의존성 설치 레이어를 먼저 고정
RUN npm ci
# 3) 그 다음 소스 복사 (여기서 자주 변경됨)
COPY . .
RUN npm run build
반대로 아래처럼 작성하면 소스가 조금만 바뀌어도 npm ci 레이어까지 같이 깨집니다.
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
2) BuildKit 전용 캐시 마운트로 패키지 다운로드 가속
BuildKit은 RUN 단계에 캐시 디렉터리를 마운트할 수 있습니다. 같은 빌드 컨텍스트에서 반복 다운로드를 줄여 체감 속도를 크게 올립니다.
Node 예시:
# syntax=docker/dockerfile:1.7
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN \
npm ci
COPY . .
RUN npm run build
Python 예시:
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt ./
RUN \
pip install -r requirements.txt
COPY . .
이 캐시 마운트는 “레이어 캐시”와는 결이 다릅니다. 레이어가 재사용되지 않더라도 다운로드 캐시가 남아 설치 시간이 줄어드는 형태라, CI에서 특히 효과가 좋습니다.
3) .dockerignore로 컨텍스트를 줄여 캐시 무효화를 방지
빌드 컨텍스트에 불필요한 파일이 섞이면 다음 문제가 생깁니다.
- 컨텍스트 업로드/압축 시간 증가
- 의미 없는 파일 변경이
COPY . .레이어의 해시를 바꿔 캐시 미스 유발
예:
# .dockerignore
node_modules
.git
.github
dist
coverage
*.log
Dockerfile*
README.md
리포지토리 상황에 따라 다르지만, CI에서 컨텍스트 최적화만으로도 수십 초에서 수 분이 줄어드는 경우가 있습니다.
CI에서 캐시가 깨지는 대표 원인과 진단법
1) 빌드 아규먼트가 매번 바뀐다
ARG BUILD_DATE처럼 매 빌드마다 값이 바뀌는 ARG를 상단에 두면 이후 레이어가 전부 무효화됩니다.
- 해결: 변동 값은 가능한 한 아래 레이어로 내리거나, 이미지 라벨링은 최종 단계에서만 수행
2) COPY . .가 너무 이르다
앞에서 설명한 것처럼 의존성 설치 이전에 전체 소스를 복사하면 캐시 적중률이 급락합니다.
3) 멀티 스테이지에서 불필요하게 파일이 오염된다
빌드 스테이지에 테스트 산출물, 린트 캐시, 임시 파일이 생성되면 다음 빌드에서 해시 변화로 캐시가 흔들릴 수 있습니다.
- 해결: 산출물 경로를 고정하고, 임시 파일은 생성 위치를 통제
4) 캐시 키가 브랜치/PR마다 분리된다
특히 type=gha는 워크플로/브랜치 정책에 따라 캐시가 의도치 않게 분리될 수 있습니다.
- 해결: 메인 브랜치 캐시를 기준으로 PR에서
cache-from을 공유하도록 구성(조직 정책에 따라 제한될 수 있음)
더 깊은 원인(캐시 메타데이터 꼬임, Buildx 드라이버, 레이어 재사용 조건 등)은 Docker 빌드가 느릴 때 - BuildKit 캐시 깨짐 해결에 진단 플로우로 정리해 두었습니다.
“70% 단축”이 현실적으로 나오는 워크로드
다음 레이어가 병목인 프로젝트일수록 효과가 큽니다.
- Node:
npm ci또는pnpm install이 1~3분 이상 - Python:
pip install에서 네이티브 빌드 휠 다운로드/컴파일이 잦음 - Java/Gradle: 의존성 다운로드와 테스트/빌드 캐시가 비싼 편
대략적인 경험칙은 이렇습니다.
- 빌드 시간의 대부분이 “의존성 설치”라면 BuildKit 캐시로 50~80% 단축 가능
- 빌드 시간의 대부분이 “컴파일/테스트”라면 Docker 레이어 캐시만으로는 한계가 있고, 언어별 빌드 캐시(Gradle build cache 등)와 병행해야 함
운영 관점 체크리스트
cache-to와cache-from을 반드시 한 쌍으로 설정했는가- 캐시 백엔드를 프로젝트 성격에 맞게 골랐는가(레지스트리 vs
type=gha) - Dockerfile에서 의존성 설치 레이어가 소스 변경에 영향받지 않도록 분리했는가
--mount=type=cache를 사용해 다운로드 캐시를 활용했는가.dockerignore로 컨텍스트를 최소화했는가- 변동
ARG나 불필요한 파일 생성으로 레이어 해시가 흔들리지 않는가
마무리
BuildKit 캐시는 “한 번만 잘 붙이면 계속 빨라지는” 유형의 최적화입니다. 특히 CI처럼 매번 초기 상태에서 빌드하는 환경에서는 효과가 극대화됩니다.
정리하면, CI 빌드 70% 단축의 핵심은 두 가지입니다.
- BuildKit 캐시를 외부로 내보내고(
cache-to) 다시 가져오기(cache-from) - Dockerfile을 캐시가 잘 맞도록 재구성(의존성 레이어 분리, 캐시 마운트, 컨텍스트 최소화)
캐시가 기대만큼 안 먹거나 어느 순간부터 갑자기 느려졌다면, 위 체크리스트로 원인을 좁힌 뒤 Docker 빌드가 느릴 때 - BuildKit 캐시 깨짐 해결의 진단 항목까지 함께 확인해보면 대부분 해결됩니다.