Published on

Docker BuildKit 캐시로 CI 10배 단축하는 법

Authors

서버리스나 쿠버네티스 배포 파이프라인에서 병목은 종종 docker build 입니다. 테스트는 몇 분인데 이미지 빌드가 10분, 20분씩 걸리면 CI 전체가 느려지고, 작은 수정도 배포까지 오래 기다리게 됩니다.

BuildKit 캐시는 이 문제를 정면으로 해결합니다. 핵심은 단순합니다.

  • BuildKit이 계산한 레이어 결과물을 재사용할 수 있게 만들고
  • CI 러너가 매번 새로 떠도 캐시를 원격 저장소에 보관했다가 다시 가져오며
  • 의존성 설치 레이어가 최대한 안 깨지도록 Dockerfile을 재구성하는 것

이 글에서는 BuildKit 캐시를 “제대로” 써서 CI 빌드를 체감 10배까지 줄이는 방법을 단계별로 정리합니다. 더 기본적인 개념과 70퍼센트 단축 사례는 Docker BuildKit 캐시로 CI 빌드 70% 단축하기도 함께 참고하면 좋습니다.

BuildKit 캐시가 기존 Docker 캐시와 다른 점

전통적인 Docker 빌드 캐시는 보통 로컬 데몬에 종속됩니다. 즉, 같은 머신에서 같은 빌드를 반복할 때만 이득이 큽니다. 하지만 CI는 다음 특징 때문에 로컬 캐시가 잘 안 먹습니다.

  • 러너가 매 빌드마다 새로 생성되는 경우가 많음
  • 워크스페이스가 매번 클린 상태
  • 빌드 노드가 여러 대로 분산

BuildKit은 캐시를 “내보내기”와 “가져오기” 할 수 있습니다.

  • --cache-to 로 캐시를 레지스트리나 파일 등에 저장
  • --cache-from 으로 다음 빌드에서 그 캐시를 가져와 재사용

그리고 단순한 최종 이미지 레이어뿐 아니라, 중간 단계 결과물까지 캐시로 저장할 수 있어 재사용성이 훨씬 좋아집니다.

목표 아키텍처: CI에서 캐시가 살아남게 만들기

CI 빌드 시간을 10배 줄이려면 아래 구성을 목표로 잡는 게 현실적입니다.

  1. 빌드 실행 시 BuildKit 사용
  2. 원격 캐시 저장소를 정함
    • Docker Registry 캐시가 가장 범용적
  3. Dockerfile을 캐시 친화적으로 재작성
  4. 의존성 설치에 캐시 마운트 적용
    • 예: npm, yarn, pnpm, pip, cargo 등

이 중에서 2번과 3번을 대충 하면 “BuildKit 켰는데 별로 안 빨라요”가 됩니다.

Dockerfile을 캐시 친화적으로 만드는 핵심 규칙

캐시가 깨지는 가장 흔한 이유는 “자주 바뀌는 파일을 너무 일찍 복사”하기 때문입니다.

규칙 1: 의존성 메타 파일을 먼저 복사

Node.js를 예로 들면 다음 순서가 정석입니다.

  • package.json 과 lockfile만 먼저 복사
  • 의존성 설치
  • 그 다음에 소스 전체 복사
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS build
WORKDIR /app

# 1) 의존성 메타만 복사
COPY package.json package-lock.json ./

# 2) 의존성 설치는 캐시 마운트로 가속
RUN --mount=type=cache,target=/root/.npm \
    npm ci

# 3) 소스 복사
COPY . .

# 4) 빌드
RUN npm run build

FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]

여기서 포인트는 두 가지입니다.

  • COPY . . 이전 단계에서 의존성이 설치되도록 분리
  • RUN--mount=type=cache 를 붙여 패키지 매니저 다운로드 캐시를 재사용

규칙 2: 불필요한 파일을 컨텍스트에서 제거

docker build 는 빌드 컨텍스트를 먼저 전송합니다. 컨텍스트가 커지면 그 자체로 느려지고, 변경 감지도 커져 캐시가 자주 깨집니다.

.dockerignore 를 반드시 설정하세요.

node_modules
.git
dist
.next
coverage
*.log
.env

규칙 3: 레이어 수보다 “레이어 안정성”이 더 중요

예전에는 RUN 을 합쳐 레이어 수를 줄이는 게 미덕이었지만, BuildKit 시대에는 “자주 바뀌는 것”과 “안 바뀌는 것”을 분리하는 게 더 중요합니다.

  • OS 패키지 설치는 초반에
  • 앱 소스 복사는 후반에
  • 빌드 산출물만 런타임 이미지로 복사

CI에서 BuildKit 활성화하기

대부분의 CI는 기본적으로 BuildKit을 켤 수 있습니다.

  • Docker CLI 기반이면 DOCKER_BUILDKIT=1
  • 또는 docker buildx build 사용

실전에서는 buildx 사용을 권장합니다. 이유는 원격 캐시와 멀티플랫폼, 드라이버 설정이 더 명확하기 때문입니다.

레지스트리 캐시로 “러너가 바뀌어도” 캐시 재사용

CI에서 10배 단축이 나오는 지점은 여기입니다. 로컬 캐시가 아니라 레지스트리에 캐시를 저장해 다음 빌드가 가져오게 만듭니다.

아래는 레지스트리 캐시의 전형적인 패턴입니다.

  • 캐시 저장: --cache-to type=registry,ref=...
  • 캐시 로드: --cache-from type=registry,ref=...

주의할 점은 ref 를 “이미지 태그”와 분리해 캐시 전용 태그를 쓰는 것입니다.

GitHub Actions 예시

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 }}

      - name: Build and push with cache
        run: |
          docker buildx build \
            --push \
            --tag ghcr.io/my-org/my-app:sha-${{ github.sha }} \
            --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 \
            .

여기서 중요한 옵션은 다음입니다.

  • mode=max 는 가능한 많은 중간 레이어를 캐시에 담습니다. CI 가속 효과가 가장 큽니다.
  • 캐시 refbuildcache 같은 고정 태그를 사용합니다.
  • --push 를 사용하면 CI 러너 로컬에 이미지를 남기지 않아도 됩니다.

GitLab CI 예시

build-image:
  image: docker:27
  services:
    - docker:27-dind
  variables:
    DOCKER_TLS_CERTDIR: ""
  script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
    - docker buildx create --use
    - docker buildx build \
        --push \
        --tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" \
        --cache-from type=registry,ref="$CI_REGISTRY_IMAGE:buildcache" \
        --cache-to type=registry,ref="$CI_REGISTRY_IMAGE:buildcache",mode=max \
        .

Inline 캐시와 레지스트리 캐시의 차이

BuildKit 캐시는 크게 두 방식으로 공유됩니다.

  • 레지스트리 캐시: 캐시만 별도 ref 로 저장
  • inline 캐시: 최종 이미지 메타데이터에 캐시 정보를 포함

inline 캐시는 설정이 간단하지만, 다음 제약이 있습니다.

  • 최종 이미지를 반드시 pull 해야 캐시를 활용 가능
  • 중간 단계 캐시를 충분히 담지 못하는 경우가 있음

반면 레지스트리 캐시는 캐시 전용 저장소 역할을 해서 CI에서 가장 예측 가능한 성능을 냅니다.

의존성 설치를 더 빠르게: cache mount 적극 활용

레지스트리 캐시는 “레이어 결과”를 재사용합니다. 하지만 의존성 설치 단계는 네트워크 다운로드가 포함돼 있고, 락파일이 조금만 바뀌어도 레이어가 통째로 다시 실행됩니다.

이때 --mount=type=cache 가 진짜 효자입니다. 레이어 캐시가 깨져도 다운로드 캐시는 남아 재실행 비용을 크게 낮춥니다.

pnpm 예시

# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS build
WORKDIR /app

RUN corepack enable

COPY package.json pnpm-lock.yaml ./

RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile

COPY . .
RUN pnpm build

Python pip 예시

# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS build
WORKDIR /app

COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

COPY . .
RUN python -m compileall .

캐시가 안 먹을 때 체크리스트

BuildKit 캐시는 “설정했는데도 느린” 경우가 흔합니다. 아래를 우선 확인하세요.

1) 빌드 컨텍스트가 매번 달라지지 않는가

  • 타임스탬프가 바뀌는 파일을 빌드 컨텍스트에 포함
  • 빌드 산출물 dist 를 컨텍스트에 포함
  • .git 포함으로 커밋마다 대규모 변경 인식

해결은 .dockerignore 정리입니다.

2) 의존성 설치 레이어가 소스 복사 뒤에 있지 않은가

COPY . . 다음에 npm ci 를 하면 소스 한 줄 바뀔 때마다 의존성을 다시 설치합니다. Dockerfile 순서를 바꾸는 것만으로도 빌드 시간이 극적으로 줄어듭니다.

3) cache ref 가 매번 바뀌지 않는가

캐시 ref 를 커밋 SHA로 만들면 매번 새로운 캐시가 쌓이고 재사용이 안 됩니다. 캐시는 고정 태그로 유지하고, 이미지 태그만 SHA로 분리하세요.

4) 멀티플랫폼 빌드에서 캐시가 분리되는가

linux/amd64linux/arm64 는 캐시가 별개입니다. CI에서 플랫폼이 섞이면 캐시 히트율이 떨어집니다. 플랫폼을 고정하거나 플랫폼별 캐시 ref 를 분리하세요.

5) 메모리 부족으로 빌드가 스로틀링 되는가

BuildKit은 병렬 처리도 하므로, 러너 스펙이 낮으면 오히려 느려질 수 있습니다. 빌드 중 OOM이나 강제 종료가 의심되면 리눅스 OOM Killer로 프로세스 죽음 원인 추적처럼 시스템 로그 기반으로 확인하는 게 빠릅니다.

“10배 단축”이 실제로 나오는 패턴

현장에서 10배 가까운 개선은 보통 아래 조합에서 나옵니다.

  • 베이스 이미지가 무겁고 의존성 설치가 오래 걸림
  • CI 러너가 매번 새로 떠서 로컬 캐시가 0
  • Dockerfile이 캐시 친화적이지 않아 작은 변경에도 의존성을 재설치

여기에 다음을 적용하면 체감이 크게 바뀝니다.

  • 레지스트리 캐시 도입 cache-tocache-from
  • 의존성 설치 단계 분리
  • 패키지 매니저 다운로드 캐시 마운트
  • .dockerignore 정리

결과적으로 “코드 1줄 수정” 같은 커밋은 빌드 단계 대부분이 캐시 히트가 나고, 실제로 실행되는 건 소스 복사 이후의 짧은 빌드나 테스트 정도만 남습니다.

운영 팁: 캐시 저장소 관리와 보안

레지스트리 캐시는 편하지만, 관리 포인트도 있습니다.

  • 캐시가 계속 커질 수 있으니 레지스트리의 보관 정책을 확인
  • 퍼블릭 레포에 캐시를 두면 빌드 메타 정보가 노출될 수 있으니 프라이빗 권장
  • 빌드 시크릿은 --secret 과 같은 메커니즘을 쓰고 레이어에 남기지 않기

예를 들어 npm 토큰을 환경변수로 RUN 에 직접 쓰면 레이어 히스토리에 남을 수 있습니다. BuildKit 시크릿 마운트를 고려하세요.

# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    --mount=type=cache,target=/root/.npm \
    npm ci

CI에서는 보통 아래처럼 전달합니다.

docker buildx build \
  --secret id=npmrc,src=.npmrc \
  --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 \
  .

마무리: BuildKit 캐시는 “옵션”이 아니라 CI 기본기

BuildKit 캐시는 단순히 빌드가 조금 빨라지는 수준이 아니라, CI의 구조를 바꿉니다.

  • 러너가 휘발성이어도 캐시가 유지되고
  • 동일한 작업을 반복하지 않으며
  • 배포 리드타임이 짧아져 작은 변경을 더 자주 안전하게 릴리스할 수 있습니다.

정리하면, CI 10배 단축을 위한 우선순위는 다음 순서가 가장 효율적입니다.

  1. Dockerfile을 캐시 친화적으로 재구성
  2. .dockerignore 로 컨텍스트 최소화
  3. buildx 와 레지스트리 캐시로 캐시 영속화
  4. 의존성 설치에 cache mount 적용

이미 BuildKit을 쓰고 있는데도 기대만큼 안 빨라졌다면, 위 체크리스트대로 캐시가 깨지는 지점을 먼저 제거해 보세요.