Published on

Docker BuildKit 캐시로 CI 빌드 70% 단축하기

Authors

CI에서 Docker 이미지 빌드가 느려지는 가장 큰 이유는 매번 같은 의존성 설치와 컴파일을 반복하기 때문입니다. 특히 Node.js/Next.js, Python, Go, Java처럼 의존성 다운로드가 큰 프로젝트는 네트워크·레지스트리 상태에 따라 빌드 시간이 쉽게 요동칩니다.

Docker BuildKit은 이 문제를 해결하기 위해 캐시를 더 똑똑하게 만들고, 그 캐시를 CI 간에도 공유할 수 있게 해줍니다. 이 글에서는 BuildKit 캐시를 활용해 CI 빌드를 체감상 70% 수준까지 단축하는 패턴을, Dockerfile 설계부터 GitHub Actions 예시까지 한 번에 정리합니다.

또한 Next.js 기반 프로젝트를 예로 들지만, 원리는 모든 스택에 동일합니다. (Next.js 관련 운영 이슈는 Next.js 이미지 최적화 실패? remotePatterns·403 해결 글도 함께 참고하면 좋습니다.)

BuildKit 캐시가 “진짜” 빠른 이유

전통적인 Docker 빌드는 레이어 캐시가 있더라도 다음 한계가 있었습니다.

  • CI는 매번 깨끗한 머신에서 시작하므로 로컬 레이어 캐시가 없다
  • RUN npm ci 같은 단계는 컨텍스트가 조금만 바뀌어도 캐시가 무효화된다
  • 캐시를 공유하려면 레지스트리에 이미지를 푸시/풀해야 하는데, 이건 캐시 목적치고 무겁다

BuildKit은 다음 기능으로 이 한계를 깨줍니다.

  • --cache-from, --cache-to원격 캐시를 내보내고 가져오기
  • RUN --mount=type=cache로 패키지 매니저 캐시 디렉터리를 레이어와 분리해서 재사용
  • 멀티스테이지 빌드에서 빌드 산출물과 런타임 이미지를 분리해 최종 이미지 슬림화

핵심은 “도커 레이어 캐시”만 믿지 말고, BuildKit 캐시 익스포트캐시 마운트를 함께 쓰는 것입니다.

사전 준비: BuildKit 활성화

로컬에서 테스트할 때는 다음처럼 BuildKit을 켭니다.

DOCKER_BUILDKIT=1 docker build -t myapp:local .

CI에서는 docker buildx를 쓰는 것이 사실상 표준입니다. Buildx는 BuildKit 기반이며, 캐시 익스포트/임포트가 자연스럽게 연결됩니다.

Dockerfile 설계가 70%를 좌우한다

캐시 최적화는 결국 Dockerfile의 “변경 빈도”를 기준으로 레이어를 나누는 작업입니다.

  • 자주 바뀌는 것: 애플리케이션 소스 코드
  • 덜 바뀌는 것: 의존성 정의 파일, 락 파일
  • 거의 안 바뀌는 것: OS 패키지 설치

Node.js(Next.js) 예시: 의존성 레이어를 고정하기

아래 Dockerfile은 Next.js를 예시로, 의존성 설치 단계가 최대한 캐시를 타도록 구성했습니다.

주의: 본문에 < > 문자가 노출되면 MDX에서 빌드 에러가 날 수 있으므로, 제네릭/화살표/플레이스홀더가 포함된 표현은 전부 인라인 코드로 감쌌습니다.

# syntax=docker/dockerfile:1.7

FROM node:20-alpine AS deps
WORKDIR /app

# 의존성 정의 파일만 먼저 복사 (소스 변경으로 캐시 깨지는 것 방지)
COPY package.json package-lock.json ./

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


FROM node:20-alpine AS build
WORKDIR /app

# deps 스테이지의 node_modules 재사용
COPY --from=deps /app/node_modules ./node_modules

# 이제 소스 전체를 복사
COPY . .

# Next.js 빌드 캐시도 재사용 가능
RUN --mount=type=cache,id=next-cache,target=/app/.next/cache \
    npm run build


FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# 런타임에 필요한 것만 복사
COPY --from=build /app/package.json ./
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public
COPY --from=build /app/node_modules ./node_modules

EXPOSE 3000
CMD ["npm", "run", "start"]

이 구조의 포인트는 다음과 같습니다.

  • COPY package.json package-lock.json 이후 npm ci가 실행되므로, 소스가 바뀌어도 의존성 설치 레이어는 유지될 확률이 높습니다.
  • --mount=type=cachenpm 다운로드 캐시를 유지하므로, 의존성 레이어가 무효화되더라도 재다운로드 비용이 크게 줄어듭니다.
  • Next.js는 .next/cache가 빌드 시간을 크게 좌우하므로, BuildKit 캐시 마운트로 누적 효과가 큽니다.

참고로 Node.js 모듈 시스템 이슈로 CI가 깨질 때가 많은데, ESM 관련 문제는 Node.js 20+ ESM에서 ERR_REQUIRE_ESM 완전정복도 같이 보면 디버깅 시간이 줄어듭니다.

CI에서 “원격 캐시”를 붙이는 방법

로컬 캐시는 CI에서 의미가 없습니다. CI는 매번 새 러너에서 시작하니까요. 그래서 BuildKit의 cache-tocache-from을 사용해 캐시를 원격에 저장해야 합니다.

원격 캐시는 보통 두 가지 선택지가 있습니다.

  • 레지스트리 캐시: type=registry로 컨테이너 레지스트리에 캐시 메타데이터를 푸시
  • GitHub Actions 캐시: type=gha로 GitHub 캐시 스토리지를 사용

GitHub Actions 예시: type=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: .
          push: true
          tags: ghcr.io/my-org/myapp:sha-${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
  • cache-to: type=gha,mode=max는 가능한 많은 캐시 메타데이터를 저장해 재사용률을 올립니다.
  • 브랜치 전략에 따라 main과 PR 빌드의 캐시 공유 범위를 조절하고 싶다면, 워크플로우 키를 분리하거나 레지스트리 캐시를 고려합니다.

레지스트리 캐시 예시: type=registry

레지스트리 캐시는 팀/브랜치 간 공유가 쉽고, GitHub Actions 외의 CI로 옮겨도 구성이 유사합니다.

docker buildx build \
  --tag ghcr.io/my-org/myapp:latest \
  --cache-from type=registry,ref=ghcr.io/my-org/myapp:buildcache \
  --cache-to type=registry,ref=ghcr.io/my-org/myapp:buildcache,mode=max \
  --push \
  .

이 방식은 캐시 전용 ref(myapp:buildcache)를 따로 두는 것이 일반적입니다.

캐시가 안 먹는 대표 원인 7가지

BuildKit을 붙였는데도 “왜 그대로 느리지?”가 자주 나옵니다. 아래 항목을 체크하면 대부분 해결됩니다.

1) 의존성 설치 전에 소스를 먼저 COPY

COPY . .npm ci보다 먼저 오면, 소스 변경이 있을 때마다 의존성 레이어가 깨집니다. 반드시 락 파일만 먼저 복사하세요.

2) 락 파일이 자주 바뀜

package-lock.json이나 pnpm-lock.yaml이 불필요하게 변경되면 캐시 적중률이 떨어집니다. CI에서 npm install로 락이 재생성되는지 확인하세요.

3) .dockerignore가 부실함

컨텍스트가 커지면 전송 시간 자체가 늘고, 불필요한 파일 변경으로 캐시가 깨집니다.

# .dockerignore 예시
node_modules
.next
.git
coverage
.DS_Store
*.log

4) RUN apk add 같은 OS 패키지 설치가 자주 바뀜

패키지 설치는 상단 레이어에 두고, 자주 변경되는 앱 로직과 분리하세요.

5) 멀티스테이지에서 불필요한 파일을 최종 이미지에 복사함

최종 이미지가 커지면 푸시/풀 시간이 늘고, 배포 파이프라인 전체가 느려집니다.

6) 캐시 스토리지가 브랜치마다 분리되어 있음

PR 빌드가 main 캐시를 못 가져오면 매번 처음부터 빌드합니다. 조직 정책에 맞게 공유 범위를 설계하세요.

7) 빌드가 외부 네트워크에 과도하게 의존함

패키지 레지스트리, apt 미러, 사내 프록시 상태에 따라 빌드가 흔들립니다. 캐시 마운트와 원격 캐시를 함께 써서 네트워크 의존도를 낮추는 게 핵심입니다. 운영 환경에서 네트워크/환경 차이로 작업이 실패하는 패턴은 리눅스 crontab 안 돈다? 로그·환경·쉘 차이에서 다루는 “환경 차이 디버깅” 관점도 도움이 됩니다.

“70% 단축”이 나오는 전형적인 시나리오

다음 조건이면 50%에서 80%까지 단축이 흔합니다.

  • CI가 매번 클린 러너에서 시작
  • Node.js/Next.js처럼 의존성 설치 + 번들링 비용이 큼
  • Dockerfile이 COPY . .npm ci 구조였음
  • BuildKit 캐시 익스포트가 없었음

개선 후에는 보통 다음처럼 바뀝니다.

  • 의존성 단계: 캐시 적중 시 수 초~수십 초
  • 소스 빌드 단계: Next.js 캐시 적중 시 크게 단축
  • 최종 이미지: 슬림해져 푸시/풀도 단축

즉, “빌드 단계만 빠른 것”이 아니라 **CI 파이프라인 전체(빌드, 푸시, 배포 준비)**가 같이 빨라집니다.

체크리스트: 오늘 바로 적용하기

  • docker buildx 기반으로 빌드하도록 CI를 변경했는가
  • cache-fromcache-to를 설정했는가 (type=gha 또는 type=registry)
  • Dockerfile에서 의존성 정의 파일을 먼저 복사하고 npm ci를 실행하는가
  • RUN --mount=type=cache로 패키지 매니저 캐시를 분리했는가
  • .dockerignore로 컨텍스트를 최소화했는가
  • 멀티스테이지로 런타임 이미지를 줄였는가

위 항목을 순서대로 적용하면, 대부분의 프로젝트에서 “체감 70%”에 가까운 개선을 얻을 수 있습니다. 특히 팀 규모가 커서 CI가 자주 돌수록, BuildKit 캐시는 단순한 최적화가 아니라 개발 생산성을 지키는 인프라가 됩니다.