- Published on
Docker BuildKit 캐시로 CI 빌드 10배 줄이기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
CI에서 Docker 이미지 빌드가 느려지는 이유는 대개 단순합니다. 매 커밋마다 docker build가 “처음부터” 다시 돌아가고, 패키지 다운로드와 의존성 설치가 매번 반복되기 때문입니다. 로컬 개발 환경에서는 레이어 캐시가 남아 있어 빠른데, CI 러너는 매번 새 머신이거나 워크스페이스가 정리되어 캐시가 증발합니다.
BuildKit은 이 문제를 해결하기 위한 현대적인 빌드 엔진입니다. 핵심은 다음 3가지를 제대로 쓰는 것입니다.
- 레이어 캐시를 최대한 재사용하도록 Dockerfile을 재구성
- CI 밖에 캐시를 “내보내고(export)” 다음 빌드에서 “가져오기(import)”
- 패키지 매니저/빌드 도구의 다운로드 캐시를
--mount=type=cache로 지속
이 글은 위 3가지를 조합해 실제로 CI 빌드 시간을 체감상 10배까지 줄이는 방법을 다룹니다.
BuildKit이 CI에서 빠른 이유
전통적인 Docker 빌드는 레이어 캐시만 바라봅니다. 하지만 CI는 다음 조건 때문에 레이어 캐시가 잘 안 맞습니다.
- 매 빌드마다 깨끗한 VM/컨테이너에서 시작
docker build가 로컬 디스크 캐시에만 의존- 의존성 설치 단계가 자주 무효화되도록 Dockerfile이 작성됨
BuildKit은 여기서 한 단계 더 나아가:
- 빌드 캐시를 레지스트리(또는 파일)로 내보내고 다시 가져올 수 있고
- 특정 디렉터리를 “캐시 볼륨”처럼 재사용할 수 있으며
- 병렬 빌드, 더 정교한 캐시 키 계산 등을 수행합니다.
1) BuildKit 활성화: 가장 먼저 할 일
로컬에서는 Docker Desktop이 이미 BuildKit을 쓰는 경우가 많지만, CI는 명시적으로 켜는 편이 안전합니다.
옵션 A: 환경변수로 활성화
DOCKER_BUILDKIT=1 docker build -t myapp:ci .
옵션 B: buildx 사용(권장)
buildx는 BuildKit을 전제로 캐시 export/import, 멀티플랫폼 등을 깔끔하게 제공합니다.
docker buildx create --use --name ci-builder
docker buildx build \
--progress=plain \
-t myapp:ci \
.
CI에서는 --progress=plain을 켜면 어떤 단계가 캐시 히트/미스인지 로그로 확인하기 좋습니다.
2) Dockerfile 재구성: 캐시가 “깨지지 않게” 만들기
캐시 전략의 70%는 Dockerfile에서 결정됩니다. 가장 흔한 실수는 “소스 전체를 먼저 복사”해서 의존성 설치 레이어가 매번 무효화되는 것입니다.
나쁜 예: 의존성 설치가 매번 다시 도는 Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "server.js"]
위 구조는 코드 한 줄만 바뀌어도 COPY . . 레이어가 바뀌고, 그 아래의 npm ci가 전부 다시 실행됩니다.
좋은 예: lockfile 기반으로 의존성 레이어를 분리
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS deps
WORKDIR /app
# 의존성 캐시를 최대화하려면 package.json/lock만 먼저 복사
COPY package.json package-lock.json ./
# npm 캐시 디렉터리를 BuildKit cache mount로 고정
RUN \
npm ci
FROM node:20-alpine AS build
WORKDIR /app
# deps 스테이지의 node_modules 재사용
COPY /app/node_modules ./node_modules
# 이제 소스 복사(자주 바뀌는 영역)
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY /app/dist ./dist
COPY /app/package.json ./package.json
CMD ["node", "dist/server.js"]
포인트는 2가지입니다.
package-lock.json이 바뀌지 않으면npm ci레이어는 그대로 재사용--mount=type=cache로 npm 다운로드 캐시까지 재사용
이 구조만으로도 CI에서 “의존성 설치” 시간이 크게 줄어듭니다.
3) CI에서 캐시를 살리는 핵심: cache export/import
CI가 매번 새 환경이라면 로컬 레이어 캐시는 의미가 없습니다. 그래서 BuildKit의 “외부 캐시”가 필요합니다.
대표적인 방식은 2가지입니다.
type=registry: 컨테이너 레지스트리에 캐시를 저장type=gha: GitHub Actions 캐시 백엔드 사용
여기서는 범용성이 높은 type=registry를 먼저 설명합니다.
레지스트리 캐시: 한 번만 세팅하면 모든 러너에서 재사용
docker buildx build \
--progress=plain \
--cache-from=type=registry,ref=ghcr.io/acme/myapp:buildcache \
--cache-to=type=registry,ref=ghcr.io/acme/myapp:buildcache,mode=max \
-t ghcr.io/acme/myapp:sha-${GIT_SHA} \
--push \
.
--cache-from: 이전 빌드 캐시를 가져옴--cache-to: 이번 빌드 결과를 캐시로 내보냄mode=max: 가능한 많은 캐시 메타데이터를 저장(대체로 CI에 유리)
주의할 점:
- 레지스트리 권한이 필요합니다(푸시 권한)
- 캐시 이미지 태그(
buildcache)는 고정 태그로 두는 편이 운영이 쉽습니다
GitHub Actions라면 type=gha도 매우 편함
docker buildx build \
--cache-from=type=gha \
--cache-to=type=gha,mode=max \
-t myapp:ci \
.
레지스트리 없이도 캐시를 남길 수 있지만, 다른 CI로 옮길 때 이식성이 떨어질 수 있습니다.
4) --mount=type=cache로 패키지 다운로드를 “진짜로” 줄이기
레이어 캐시는 “명령 결과가 동일할 때”만 재사용됩니다. 하지만 패키지 매니저는 네트워크 다운로드가 크고, 종종 레이어 재사용이 깨질 수 있습니다. 이때 cache mount가 강력합니다.
Python(pip) 예시
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN \
pip install -r requirements.txt
COPY . .
CMD ["python", "main.py"]
Debian/Ubuntu apt 예시
# syntax=docker/dockerfile:1.7
FROM ubuntu:24.04
RUN \
apt-get update && apt-get install -y curl ca-certificates
이 방식은 “동일 러너”에서만 의미가 있는 것처럼 보이지만, 앞서 말한 외부 캐시(export/import)와 결합하면 CI에서도 효과가 납니다. BuildKit이 캐시 마운트의 내용까지 캐시로 추적해 재사용 가능하게 만들기 때문입니다(상황에 따라 차이는 있으나, 체감 효과가 큰 편입니다).
5) 멀티스테이지 빌드로 캐시 적중률과 이미지 크기를 동시에
멀티스테이지는 단순히 이미지 크기만 줄이는 기술이 아닙니다. “변경이 잦은 레이어”와 “변경이 드문 레이어”를 분리해 캐시 적중률을 올리는 구조적 장치입니다.
- deps 스테이지: lockfile 기반, 변경 빈도 낮음
- build 스테이지: 소스 기반, 변경 빈도 높음
- runtime 스테이지: 실행 파일만 포함
특히 프론트엔드/백엔드 모노레포에서는 COPY 범위를 최소화해 “관련 없는 변경”이 캐시를 깨지 않도록 만드는 것이 중요합니다.
6) CI에서 흔히 캐시가 깨지는 원인 체크리스트
캐시가 안 먹는다고 느껴지면 아래를 먼저 의심하세요.
1) COPY . .가 너무 이르다
의존성 설치 전에 소스 전체를 복사하면 lockfile이 안 바뀌어도 캐시가 무효화됩니다.
2) .dockerignore가 부실하다
불필요한 파일이 빌드 컨텍스트에 섞이면, 사소한 변경이 캐시를 깨뜨립니다.
예시 .dockerignore:
node_modules
.git
dist
.next
coverage
*.log
Dockerfile*
README.md
3) 빌드마다 달라지는 값이 레이어에 섞인다
예를 들어 빌드 타임스탬프, git rev-parse 결과를 RUN에서 파일로 쓰면 캐시가 계속 깨집니다. 메타데이터는 가급적 라벨로 처리하거나, 정말 필요할 때만 마지막 레이어에서 하세요.
4) apt-get update 단독 실행
RUN apt-get update와 RUN apt-get install을 분리하면 캐시/재현성 모두 악화됩니다. 한 레이어에서 묶는 편이 낫습니다.
7) GitHub Actions 예시: BuildKit 캐시로 체감 10배 만들기
아래는 레지스트리 캐시(type=registry)를 쓰는 전형적인 패턴입니다.
name: ci
on:
push:
branches: ["main"]
jobs:
build:
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 with cache
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/acme/myapp:${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/acme/myapp:buildcache
cache-to: type=registry,ref=ghcr.io/acme/myapp:buildcache,mode=max
provenance: false
docker/build-push-action을 쓰면 buildx 플래그를 YAML로 옮겨 관리할 수 있어 유지보수가 쉽습니다.
8) 운영 팁: 캐시도 “데이터”라서 관리가 필요하다
캐시가 쌓이면 저장소 비용이 늘고, 오래된 캐시가 오히려 혼란을 줄 수 있습니다. 다음 원칙을 추천합니다.
- 캐시 태그는 고정(
buildcache)으로 두되, 주기적으로 정리 정책을 둠 - 의존성 대규모 업데이트(예: Node 메이저 업그레이드) 시 캐시를 한 번 리셋
- 캐시 히트율을 로그로 확인하고 Dockerfile 변경 전후로 비교
또한 캐시 전략은 “어디까지나 성능 최적화”이므로, 캐시 때문에 데이터가 꼬이거나 오래된 결과물이 섞이는 경험을 했다면 캐시 무효화 경로를 명확히 설계해야 합니다. 애플리케이션 레벨에서도 캐시 무효화가 어려운 문제인 것처럼, 빌드 캐시도 동일합니다. 관련해서는 Next.js 14 RSC 캐시 무효화로 데이터 꼬임 해결 글의 접근(무효화 기준을 명시적으로 설계)을 참고할 만합니다.
9) “10배”가 나오는 구간: 어디서 시간이 줄어드나
대부분의 프로젝트에서 빌드 시간이 크게 줄어드는 구간은 다음 순서로 나타납니다.
- 패키지 다운로드(네트워크) 감소:
--mount=type=cache효과 - 의존성 설치 레이어 재사용: lockfile 분리
COPY - 빌드 산출물 재사용: 빌드 단계 캐시 적중
- CI 외부 캐시로 “깨끗한 러너” 문제 해결:
cache-to/cache-from
특히 1번과 4번이 결합되면, “매번 3~8분 걸리던 의존성 설치”가 “수십 초”로 줄어드는 경우가 흔합니다. 여기에 빌드 단계까지 캐시가 맞으면 전체 파이프라인이 체감상 10배 가까이 빨라집니다.
10) 마무리: 적용 순서 요약
한 번에 다 바꾸기보다 아래 순서대로 적용하면 실패 확률이 낮습니다.
- BuildKit/Buildx를 CI에서 확실히 사용
- Dockerfile에서 lockfile 분리
COPY로 의존성 레이어 고정 .dockerignore정리로 빌드 컨텍스트 안정화--mount=type=cache로 패키지 매니저 캐시 적용--cache-to/--cache-from로 외부 캐시 연결(레지스트리 또는 CI 캐시)
CI 빌드가 빨라지면 단순히 비용이 줄어드는 것뿐 아니라, 배포 리드타임이 줄고 롤백/핫픽스 대응도 빨라집니다. 결국 “빌드 캐시 최적화”는 개발 생산성 최적화의 가장 확실한 레버 중 하나입니다.
추가로, 빌드/배포 이후 런타임에서 문제가 생겼을 때 빠르게 원인을 좁히는 운영 역량도 중요합니다. 배포 후 파드가 반복 재시작된다면 Kubernetes CrashLoopBackOff 원인별 10분 진단 같은 체크리스트를 함께 갖춰두면 전체 사이클이 더 단단해집니다.