Published on

Docker BuildKit 캐시·멀티스테이지로 CI 빌드 70% 단축

Authors

CI에서 Docker 이미지 빌드가 느린 이유는 대부분 단순합니다. 매번 같은 의존성을 다시 받거나, 소스 변경과 무관한 레이어까지 캐시가 깨지거나, 빌드 산출물과 런타임 이미지를 한 덩어리로 만들어 전송량이 커지기 때문입니다.

이 글에서는 Docker BuildKit의 캐시 메커니즘을 CI에 맞게 설계하고, 멀티스테이지로 빌드와 런타임을 분리해 빌드 시간과 푸시 시간을 함께 줄이는 방법을 다룹니다. 목표는 “캐시가 안정적으로 먹고, 변경된 부분만 다시 빌드하며, 최종 이미지는 작게 유지”입니다.

문제 상황별로 더 깊게 파고들고 싶다면 BuildKit 캐시가 예상대로 동작하지 않을 때의 체크리스트도 함께 참고하세요.

CI 빌드가 느려지는 3가지 패턴

1) 캐시가 “있는데도” 매번 다시 받는 의존성

언어별 패키지 매니저는 다운로드와 압축 해제 비용이 큽니다. CI 러너가 매번 새로 떠서 로컬 캐시가 없으면, npm ci, pip install, go mod download, mvn -DskipTests package 같은 단계가 계속 반복됩니다.

BuildKit은 이 문제를 --mount=type=cache로 해결합니다. 핵심은 의존성 캐시 디렉터리를 레이어에 굽지 않고, 빌드 시점에 재사용 가능한 캐시 볼륨처럼 붙여서 다음 빌드에 재사용하는 것입니다.

2) Dockerfile 레이어 순서가 바뀌어 캐시 키가 쉽게 깨짐

COPY . .를 너무 일찍 하면, 소스 코드의 작은 변경도 의존성 설치 레이어를 무효화합니다. 결과적으로 “매번 처음부터”가 됩니다.

해결은 간단합니다.

  • 의존성 정의 파일만 먼저 복사
  • 의존성 설치
  • 그 다음에 소스 전체 복사

3) 단일 스테이지로 런타임 이미지가 비대해짐

빌드 도구(컴파일러, 패키지 매니저, 테스트 도구)가 모두 포함된 이미지는 크기가 커서 푸시/풀 시간이 늘고, 보안 스캐닝 시간도 길어집니다.

멀티스테이지로 “빌드 환경”과 “실행 환경”을 분리하면 최종 이미지는 산출물만 포함해 가벼워지고, CI에서 푸시/배포까지 빨라집니다.

BuildKit 활성화와 CI 기본 세팅

BuildKit을 쓰려면 최소한 아래 중 하나가 필요합니다.

  • 환경 변수 DOCKER_BUILDKIT=1
  • Docker CLI에서 docker buildx build 사용

GitHub Actions 기준으로는 보통 buildx를 사용하며, 캐시를 레지스트리나 GHA 캐시 백엔드에 저장합니다.

다음 예시는 레지스트리 캐시를 사용해 러너가 바뀌어도 캐시를 재사용하는 패턴입니다.

# 로컬/CI 공통: buildx 빌더 준비
docker buildx create --use --name ci-builder || docker buildx use ci-builder

docker buildx build \
  --file Dockerfile \
  --tag my-registry.example.com/myapp:sha-${GITHUB_SHA} \
  --cache-from type=registry,ref=my-registry.example.com/myapp:buildcache \
  --cache-to type=registry,ref=my-registry.example.com/myapp:buildcache,mode=max \
  --push \
  .
  • cache-tomode=max는 가능한 많은 캐시 메타데이터를 보존해 재사용률을 높입니다.
  • cache-from은 이전 캐시를 읽어오는 역할입니다.

--mount=type=cache로 의존성 설치를 “증분”으로 만들기

BuildKit 캐시는 레이어 캐시만으로는 부족합니다. 의존성 설치는 네트워크와 디스크 작업이 많아, 레이어 캐시가 깨지는 순간 비용이 폭발합니다. 이때 --mount=type=cache가 효과적입니다.

아래는 Node.js 예시입니다.

# syntax=docker/dockerfile:1.7

FROM node:20-bookworm AS deps
WORKDIR /app

# 의존성 정의 파일만 먼저 복사
COPY package.json package-lock.json ./

# npm 캐시를 BuildKit 캐시로 연결
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
    npm ci

FROM node:20-bookworm AS build
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN npm run build

FROM node:20-bookworm-slim AS runtime
WORKDIR /app

ENV NODE_ENV=production

COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json

CMD ["node", "dist/server.js"]

포인트는 다음과 같습니다.

  • deps 스테이지에서 package.json 계열만 복사해 의존성 레이어 캐시가 잘 유지되게 함
  • npm 다운로드 캐시를 --mount=type=cache로 붙여 레이어 캐시가 깨져도 다운로드 비용 최소화
  • runtimeslim 베이스를 사용하고 산출물만 복사해 이미지 크기를 줄임

Python, Java, Go도 같은 패턴으로 적용됩니다.

멀티스테이지 설계: 빌드/테스트/런타임을 분리하라

멀티스테이지를 단순히 “빌드하고 복사” 수준에서 끝내면 아쉬운 경우가 많습니다. CI에서는 보통 다음 요구가 동시에 존재합니다.

  • 테스트는 반드시 돌려야 함
  • 최종 런타임 이미지는 작아야 함
  • 테스트 도구는 런타임에 필요 없음

이때 스테이지를 3개로 나누면 깔끔합니다.

  1. deps: 의존성 설치(캐시 최적화)
  2. test: 테스트 실행(실패 시 여기서 중단)
  3. runtime: 산출물만 포함

예시(Gradle 기반 Spring Boot)입니다.

# syntax=docker/dockerfile:1.7

FROM gradle:8.6-jdk17 AS deps
WORKDIR /home/gradle/project

COPY build.gradle settings.gradle gradle.properties ./
COPY gradle ./gradle

# Gradle 캐시를 BuildKit 캐시로 연결
RUN --mount=type=cache,id=gradle-cache,target=/home/gradle/.gradle \
    gradle --no-daemon dependencies

FROM gradle:8.6-jdk17 AS test
WORKDIR /home/gradle/project

COPY --from=deps /home/gradle/.gradle /home/gradle/.gradle
COPY . .

RUN --mount=type=cache,id=gradle-cache,target=/home/gradle/.gradle \
    gradle --no-daemon test

FROM gradle:8.6-jdk17 AS build
WORKDIR /home/gradle/project

COPY --from=test /home/gradle/project /home/gradle/project

RUN --mount=type=cache,id=gradle-cache,target=/home/gradle/.gradle \
    gradle --no-daemon bootJar

FROM eclipse-temurin:17-jre-jammy AS runtime
WORKDIR /app

COPY --from=build /home/gradle/project/build/libs/*.jar app.jar

EXPOSE 8080
CMD ["java", "-jar", "/app/app.jar"]

이 구조의 장점은 다음과 같습니다.

  • 테스트 실패 시 runtime 이미지 생성까지 가지 않으므로 CI 리소스 낭비 감소
  • Gradle 캐시를 --mount=type=cache로 재사용해 다운로드 비용을 크게 절감
  • 런타임은 JRE만 포함해 이미지가 작고 배포가 빠름

캐시가 잘 먹도록 만드는 Dockerfile 레이어 전략

BuildKit을 켜도 캐시가 기대만큼 안 먹는 경우가 많습니다. 대부분 Dockerfile 레이어 설계 문제입니다.

1) “변경이 잦은 파일”은 최대한 뒤로

  • 소스 전체 COPY는 뒤로
  • 의존성 정의 파일은 앞으로

2) .dockerignore로 캐시 교란 요소 제거

CI에서 자주 바뀌는 파일이 빌드 컨텍스트에 포함되면 캐시 키가 불필요하게 변합니다.

예시 .dockerignore입니다.

.git
node_modules
build
.dist
coverage
*.log
.DS_Store

# CI 메타 파일이 컨텍스트에 섞이지 않게
.github

# 로컬 개발용
.env

3) ARG는 정말 필요한 곳에만

ARG BUILD_SHA 같은 값을 초반 레이어에서 사용하면, 커밋이 바뀔 때마다 앞 레이어 캐시가 전부 무효화됩니다.

권장 패턴은 “라벨링은 마지막에”입니다.

ARG BUILD_SHA
LABEL org.opencontainers.image.revision=$BUILD_SHA

이 라벨을 runtime 스테이지 마지막 부분에 두면 캐시 손실을 최소화할 수 있습니다.

CI에서 체감 70% 단축이 나오는 조합

실전에서 가장 큰 효과가 나는 조합은 보통 아래 3종 세트입니다.

  1. 멀티스테이지로 런타임 이미지 경량화
  2. --mount=type=cache로 패키지 매니저 캐시 재사용
  3. cache-tocache-from을 레지스트리로 연결해 러너가 바뀌어도 캐시 유지

빌드 시간이 줄어드는 구간을 나눠보면 대개 다음과 같습니다.

  • 의존성 다운로드/압축 해제: --mount=cache로 대폭 절감
  • 컴파일/번들링: 의존성 레이어 캐시 유지로 재빌드 빈도 감소
  • 이미지 푸시: 멀티스테이지로 최종 이미지 크기 감소

특히 PR 단위로 자주 빌드하는 팀에서는 “빌드 시간 단축”이 곧 “리뷰 리드타임 단축”으로 이어집니다.

GitHub Actions 예시: 레지스트리 캐시 + 빌드 푸시

다음은 GitHub Actions에서 BuildKit 캐시를 레지스트리에 저장하는 전형적인 구성입니다.

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-qemu-action@v3

      - uses: docker/setup-buildx-action@v3

      - 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/my-org/myapp:${{ github.sha }}
          cache-from: type=registry,ref=ghcr.io/my-org/myapp:buildcache
          cache-to: type=registry,ref=ghcr.io/my-org/myapp:buildcache,mode=max
          build-args: |
            BUILD_SHA=${{ github.sha }}

이 구성에서 중요한 점은 buildcache 태그를 별도로 유지하는 것입니다. 애플리케이션 태그와 캐시 태그를 분리하면, 배포 정책과 캐시 정책이 충돌하지 않습니다.

자주 터지는 함정과 디버깅 포인트

1) 캐시가 전혀 안 먹는 것처럼 보일 때

  • 빌드 컨텍스트가 매번 달라지는지 확인(불필요한 파일 포함)
  • Dockerfile 상단에 # syntax=docker/dockerfile:1.7 같은 문법 버전을 지정했는지 확인
  • --mount=type=cacheid가 빌드마다 바뀌지 않는지 확인

관련 실전 디버깅은 아래 글이 도움이 됩니다.

2) 멀티스테이지인데도 최종 이미지가 큰 경우

  • runtime에 소스 전체를 복사하고 있지 않은지 확인
  • 패키지 매니저 캐시 디렉터리를 실수로 COPY하고 있지 않은지 확인
  • 베이스 이미지를 slim 또는 distroless 계열로 바꿀 수 있는지 검토

3) 캐시가 오히려 오염되는 경우

캐시가 “빠르긴 한데 이상하게 동작”하는 경우도 있습니다. 예를 들어 lock 파일이 바뀌었는데도 이전 의존성이 남아 테스트가 통과하는 것처럼 보이면 위험합니다.

  • 의존성 설치 단계는 lock 파일을 포함해 캐시 키가 정확히 바뀌도록 구성
  • npm ci, pip install --requirement, Gradle의 lock 기능 등 “재현 가능한 설치” 사용

마무리: 빌드 시간을 줄이는 것은 설계 문제다

CI 빌드 70% 단축은 특별한 트릭이라기보다, 캐시가 잘 먹도록 레이어를 설계하고, 의존성 캐시를 --mount=type=cache로 분리하고, 러너가 바뀌어도 캐시를 유지하도록 cache-tocache-from을 레지스트리에 연결하면 현실적으로 달성 가능한 수치입니다.

정리하면 체크리스트는 다음 5개로 귀결됩니다.

  • BuildKit 사용(buildx) 고정
  • 의존성 정의 파일 먼저 COPY
  • 패키지 매니저 캐시는 --mount=type=cache
  • 멀티스테이지로 deps/test/runtime 분리
  • 캐시 백엔드는 레지스트리 또는 CI 캐시로 외부화

이 5가지만 지켜도, “CI에서 Docker 빌드가 늘 병목”인 상황은 대부분 해소됩니다.