- Published on
Docker BuildKit 캐시로 CI 빌드 70% 줄이기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/클라이언트 코드가 조금만 바뀌어도 CI에서 docker build 가 매번 처음부터 돌아가면, 빌드 시간이 10분에서 3분으로 줄어들 여지가 그대로 낭비됩니다. 핵심은 “Docker 레이어 캐시”가 아니라, BuildKit 캐시를 CI 환경에서 재사용 가능하게 만드는 것입니다.
이 글에서는 BuildKit이 캐시를 어떻게 만들고 재사용하는지, 그리고 GitHub Actions 같은 휘발성 러너에서도 캐시를 살리는 실전 구성을 통해 체감 70% 수준의 빌드 시간 단축을 만드는 방법을 정리합니다.
왜 CI에서는 캐시가 잘 안 먹힐까
로컬에서는 한 번 빌드하면 다음부터 빠른데, CI에서는 느린 이유는 대부분 아래 중 하나입니다.
- 러너가 매번 새 머신이라 로컬 레이어 캐시가 없다
--cache-from을 쓰지 않거나, 써도 inline cache 메타데이터가 이미지에 없어서 참조가 불가능하다- Dockerfile이 캐시 친화적으로 구성되지 않아 변경이 상위 레이어까지 전파된다
- 의존성 설치 단계가 매번 invalidation 된다(예:
COPY . .가 너무 빨리 등장)
BuildKit은 이 문제를 “캐시를 외부로 내보내고(import/export) 다시 가져오는” 방식으로 해결합니다.
BuildKit 캐시의 3가지 축: 레이어, inline, 원격 캐시
BuildKit 캐시 전략은 크게 세 가지를 조합합니다.
1) 레이어 캐시(로컬)
가장 기본. 하지만 CI의 휘발성 환경에서는 의미가 거의 없습니다.
2) Inline cache
이미지 자체에 캐시 메타데이터를 포함시키는 방식입니다. 다음 빌드에서 해당 이미지를 --cache-from 으로 참조하면, 레이어를 다시 빌드하지 않고 재사용할 수 있습니다.
- 장점: 레지스트리에 이미지를 푸시/풀할 수 있으면 동작
- 단점: 메타데이터만 포함되므로, 상황에 따라 원격 캐시보다 효율이 떨어질 수 있음
3) 원격 캐시(Registry/GHA/S3 등)
BuildKit 캐시를 별도 저장소에 export/import 합니다.
- 장점: CI에서 가장 강력. 이미지와 별개로 캐시를 저장해 재사용률이 높음
- 단점: 설정이 약간 더 필요
Dockerfile을 캐시 친화적으로 바꾸는 규칙
캐시가 먹는지의 70%는 Dockerfile에서 결정됩니다. 핵심 규칙은 “변경이 잦은 파일은 최대한 뒤로”입니다.
Node.js 예시: 의존성 레이어를 고정하기
아래는 자주 실패하는 패턴입니다.
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
COPY . . 때문에 소스가 조금만 바뀌어도 npm ci 레이어가 매번 깨집니다. 캐시 친화적으로 바꾸면 다음과 같습니다.
FROM node:20-alpine AS build
WORKDIR /app
# 1) 의존성 정의만 먼저 복사
COPY package.json package-lock.json ./
# 2) BuildKit 캐시 마운트로 npm 캐시 재사용
RUN \
npm ci
# 3) 그 다음에 소스 복사
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY /app/dist ./dist
CMD ["node", "dist/index.js"]
포인트는 두 가지입니다.
package-lock.json이 바뀌지 않으면npm ci레이어가 재사용--mount=type=cache로 패키지 매니저 캐시를 유지해 네트워크/압축해제 비용 감소
Python 예시: requirements 레이어 고정
FROM python:3.12-slim AS base
WORKDIR /app
COPY requirements.txt ./
RUN \
pip install -r requirements.txt
COPY . .
CMD ["python", "main.py"]
CI에서 BuildKit을 켜는 표준: buildx
CI에서는 docker build 대신 docker buildx build 를 쓰는 것이 사실상 표준입니다. buildx는 BuildKit의 캐시 import/export 옵션을 제대로 노출합니다.
GitHub Actions: GHA 캐시로 빌드 시간 확 줄이기
가장 재현이 쉬운 구성입니다.
name: build
on:
push:
branches: ["main"]
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: ghcr.io/my-org/my-app:${{ github.sha }}
# 핵심: BuildKit 캐시 import/export
cache-from: type=gha
cache-to: type=gha,mode=max
설명:
type=gha는 GitHub Actions 캐시 스토리지를 사용합니다.mode=max는 가능한 많은 캐시 메타데이터를 저장해 재사용률을 올립니다.- 이 설정만으로도 의존성 설치가 큰 프로젝트는 빌드 시간이 눈에 띄게 줄어듭니다.
Registry 캐시: 러너가 바뀌어도 캐시 유지
사내 CI나 Jenkins처럼 GHA 캐시가 없거나, 레지스트리 중심으로 운영하고 싶다면 type=registry 가 실용적입니다.
docker buildx build \
--tag my-registry.local/my-app:sha-123 \
--cache-from type=registry,ref=my-registry.local/my-app:buildcache \
--cache-to type=registry,ref=my-registry.local/my-app:buildcache,mode=max \
--push \
.
이 방식은 “이미지 태그”와 “캐시 태그(ref)”를 분리해 운영하는 것이 포인트입니다.
Inline cache도 같이 쓰면 좋은 경우
원격 캐시를 쓰더라도, inline cache는 보조적으로 유용합니다. 특히 “캐시 저장소가 비었을 때도, 직전 이미지가 있으면 어느 정도 빨라지는” 효과가 있습니다.
buildx에서는 보통 아래처럼 씁니다.
docker buildx build \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from type=registry,ref=ghcr.io/my-org/my-app:latest \
--tag ghcr.io/my-org/my-app:latest \
--push \
.
주의: inline cache는 이미지에 메타데이터를 넣는 것이지, 항상 원격 캐시만큼 강력하진 않습니다. 하지만 “최소한의 캐시 힌트”로는 꽤 도움이 됩니다.
캐시가 깨지는 대표 원인 7가지(체크리스트)
CI에서 캐시가 안 먹는다고 느낄 때는 아래를 순서대로 확인하면 대부분 해결됩니다.
- Dockerfile 상단에 변경 잦은
COPY . .가 있지 않은가 - 패키지 락 파일이 매 빌드마다 변하지 않는가(예: lockfile 생성 정책)
- 빌드 인자(
--build-arg)가 매번 달라지지 않는가(예: 타임스탬프 주입) RUN apt-get update같은 네트워크 의존 단계가 상위에 있어 매번 흔들리지 않는가cache-to는 있는데cache-from이 없는 “일방향 저장”이 아닌가- 멀티 플랫폼 빌드에서
--platform이 바뀌며 캐시가 분리되지 않는가 - 빌드 컨텍스트에 불필요한 파일이 포함되어 해시가 계속 바뀌지 않는가(예:
.git, 로그, 테스트 산출물)
특히 7번은 .dockerignore 로 즉시 개선됩니다.
.git
node_modules
.dist
coverage
*.log
실제로 70% 줄이는 접근: 병목부터 쪼개기
BuildKit 캐시는 “모든 걸 빠르게”가 아니라, 비싼 단계(의존성 설치, 컴파일, 테스트 도구 설치)를 캐시로 고정해서 시간을 줄입니다.
실무에서 효과가 큰 순서는 보통 다음과 같습니다.
- 의존성 설치 레이어 고정(
package-lock.json,requirements.txt,go.sum분리) - BuildKit cache mount로 패키지 매니저 캐시 재사용
- 원격 캐시(export/import)로 휘발성 러너 문제 해결
- 멀티스테이지로 런타임 이미지를 슬림하게 만들어 푸시/풀 시간도 감소
네트워크 병목이 겹치면 “빌드가 느리다”가 아니라 “레지스트리 pull이 느리다”가 될 수도 있습니다. ECR이나 사설 레지스트리에서 pull 제한/429가 발생한다면, 빌드 최적화와 함께 배포 파이프라인도 점검해야 합니다. 관련해서는 EKS에서 ECR ImagePullBackOff 429 해결법도 같이 참고하면 좋습니다.
Jenkins/자체 CI에서 적용할 때 주의점
Jenkins에서는 워커가 재사용될 수 있어 로컬 캐시가 남아있는 경우도 있지만, 오토스케일 환경에서는 결국 휘발성 문제가 다시 나타납니다. 따라서 buildx + 원격 캐시를 표준으로 두는 편이 안전합니다.
Jenkinsfile에서 셸로 호출할 때는 다음을 기본 골격으로 삼을 수 있습니다.
# buildx builder 준비(이미 있으면 생략)
docker buildx create --name ci-builder --use || docker buildx use ci-builder
docker buildx build \
--cache-from type=registry,ref=my-registry.local/my-app:buildcache \
--cache-to type=registry,ref=my-registry.local/my-app:buildcache,mode=max \
--tag my-registry.local/my-app:${GIT_COMMIT} \
--push \
.
Jenkins Declarative Pipeline에서 문법/따옴표 문제로 시간을 날리기 쉬운데, 이런 류의 실수 패턴은 Jenkins Declarative Pipeline Groovy 에러 10선에 정리해 두었습니다.
관측: 캐시가 진짜 먹는지 확인하는 방법
“빨라진 것 같다”가 아니라, 로그로 확인해야 합니다.
- buildx는 단계별로
CACHED표시가 뜹니다. --progress=plain옵션을 켜면 어떤 스텝이 캐시 히트인지 더 명확히 보입니다.
docker buildx build --progress=plain .
또한 캐시가 깨지는 원인을 찾는 과정은 분산 시스템 장애 진단과 비슷하게 “단계별 가설-검증”이 중요합니다. 진단 프레임을 좋아한다면 Kafka Exactly-Once 깨질 때 진단 7단계처럼, 원인 후보를 체크리스트로 줄여가는 방식이 CI 캐시에도 그대로 통합니다.
결론: BuildKit 캐시는 ‘설정’이 아니라 ‘설계’다
BuildKit을 켠다고 자동으로 70%가 줄지는 않습니다. 하지만 아래 3가지를 같이 맞추면, 많은 팀에서 그 수준의 개선이 현실적으로 가능합니다.
- Dockerfile을 캐시 친화적으로 재구성(의존성 레이어 분리)
- buildx로 원격 캐시를 import/export
.dockerignore와 빌드 인자 정책으로 캐시 invalidation을 통제
CI 빌드가 느리다는 문제는 보통 “개발 생산성” 문제로만 보이지만, 실제로는 레지스트리 트래픽, 배포 속도, 장애 대응 속도까지 줄줄이 연결됩니다. BuildKit 캐시를 한 번 제대로 잡아두면, 파이프라인 전체가 눈에 띄게 가벼워집니다.