Published on

Docker 빌드 느릴 때 - BuildKit 캐시 10배 튜닝

Authors

서버/CI에서 Docker 빌드가 갑자기 느려질 때, 흔히 캐시가 안 먹는다 정도로만 진단하고 끝내는 경우가 많습니다. 하지만 실제로는 다음 중 하나(혹은 복합)입니다.

  • Dockerfile 레이어가 자주 무효화되어 매번 npm ci 같은 무거운 작업이 재실행됨
  • CI 러너가 매번 새로 떠서 로컬 캐시가 남지 않음
  • 멀티 스테이지 빌드에서 의도치 않게 컨텍스트가 커져 COPY . . 한 방에 캐시가 깨짐
  • BuildKit을 쓰고 있어도 캐시를 저장/공유(export/import) 하지 못함

이 글은 BuildKit 기반으로 캐시를 정확히 설계해서, 로컬 개발 환경뿐 아니라 GitHub Actions, Jenkins, EKS 같은 환경에서도 빌드 시간을 안정적으로 줄이는 방법을 다룹니다.

참고로 컨테이너 빌드가 느려져서 배포가 꼬이면 운영 이슈로 이어지는 경우가 많습니다. 예를 들어 이미지 풀 실패나 권한 문제까지 겹치면 원인 파악이 어려워지는데, ECR에서 인증이 꼬일 때는 EKS Pod에서 AWS ECR 403 AccessDenied 해결도 함께 참고하면 좋습니다.

BuildKit 캐시가 빨라지는 구조 이해

BuildKit에서 성능을 좌우하는 포인트는 크게 3가지입니다.

  1. 레이어 캐시: RUN, COPY, ADD 단위로 결과를 저장
  2. 캐시 마운트: RUN --mount=type=cache 로 패키지 매니저 캐시 등을 별도 보존
  3. 캐시 내보내기/가져오기: CI처럼 매번 깨끗한 머신에서 빌드할 때 필수

여기서 많은 팀이 1번만 기대하다가 실패합니다. CI는 보통 워커가 매번 새로 생기므로 2, 3이 없으면 속도가 안 나옵니다.

0단계: BuildKit 활성화 및 빌드 드라이버 정리

로컬에서 BuildKit이 꺼져 있으면 캐시 마운트 같은 기능이 동작하지 않습니다.

# 1회 실행: 현재 셸에서만
export DOCKER_BUILDKIT=1

# buildx 사용 권장
docker buildx version

빌더를 만들고 docker-container 드라이버를 쓰면 캐시 export/import가 안정적입니다.

docker buildx create --name mybuilder --use
docker buildx inspect --bootstrap

1단계: Dockerfile에서 캐시가 깨지는 지점부터 제거

가장 흔한 실수: COPY . . 가 너무 이른 타이밍에 있음

Node.js 예시로, 아래처럼 작성하면 소스 한 줄 바뀔 때마다 npm ci 레이어가 매번 무효화됩니다.

# 나쁜 예시
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build

다음처럼 의존성 파일만 먼저 복사npm ci 레이어를 안정화해야 합니다.

# 좋은 예시
FROM node:20-alpine AS build
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

이것만으로도 로컬 개발에서 체감이 크게 바뀝니다.

.dockerignore 로 컨텍스트를 줄이면 캐시 적중률이 올라감

컨텍스트가 커지면 전송 시간도 늘고, 사소한 파일 변경이 COPY 해시를 바꿔 캐시가 깨집니다.

# .dockerignore 예시
node_modules
.git
.next
dist
coverage
.env
.DS_Store

2단계: RUN --mount=type=cache 로 패키지 캐시 재사용

BuildKit의 핵심은 레이어 캐시와 별개로 캐시 디렉터리를 마운트할 수 있다는 점입니다.

npm 예시

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

COPY package.json package-lock.json ./

RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
    npm ci

COPY . .
RUN npm run build
  • id 는 캐시 키(프로젝트별로 분리 권장)
  • target 은 패키지 매니저가 캐시를 저장하는 경로

이 방식은 레이어 캐시가 일부 깨져도(예: lockfile 변경) 다운로드 비용을 줄여줍니다.

pnpm 예시

pnpm은 store 경로가 중요합니다.

# 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,id=pnpm-store,target=/pnpm/store \
    pnpm config set store-dir /pnpm/store && \
    pnpm install --frozen-lockfile

COPY . .
RUN pnpm build

apt 예시(자주 쓰는 베이스 이미지 튜닝)

# syntax=docker/dockerfile:1.7
FROM ubuntu:22.04

RUN --mount=type=cache,target=/var/cache/apt \
    --mount=type=cache,target=/var/lib/apt \
    apt-get update && apt-get install -y curl ca-certificates

3단계: 멀티 스테이지에서 deps 레이어를 분리해 재사용 극대화

빌드/런타임을 나누는 것만으로는 부족하고, 의존성 설치 단계 자체를 분리하면 캐시가 더 안정적입니다.

# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
    npm ci

FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]

포인트는 deps 단계가 잘 캐시되면, 소스 변경이 잦아도 npm ci가 거의 재실행되지 않습니다.

4단계: CI에서 10배 차이를 만드는 캐시 export/import

로컬에서는 캐시가 남지만, CI는 워커가 매번 새로 떠서 캐시가 없습니다. 따라서 BuildKit 캐시를 외부로 내보내고 다음 빌드에서 가져와야 합니다.

캐시 백엔드는 대표적으로 3가지입니다.

  • inline: 이미지 내부에 캐시 메타데이터 포함(간단하지만 한계 있음)
  • registry: 레지스트리에 캐시를 별도 태그로 저장(가장 실전적)
  • local: CI 워크스페이스에 파일로 저장(러너가 유지될 때만 유효)

레지스트리 캐시(권장): --cache-to / --cache-from

docker buildx build \
  --platform linux/amd64 \
  -t registry.example.com/myapp:sha-123 \
  --push \
  --cache-to type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
  --cache-from type=registry,ref=registry.example.com/myapp:buildcache \
  .
  • mode=max 는 가능한 많은 캐시를 저장(대신 캐시 크기 증가)
  • CI에서 가장 큰 차이를 만드는 옵션이 이 조합입니다

인라인 캐시: 설정은 쉽지만 단독으론 부족할 수 있음

docker buildx build \
  -t registry.example.com/myapp:sha-123 \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  --push \
  .

인라인 캐시는 cache-from 을 이미지로 걸어 재사용하는 패턴에서 유용하지만, 레지스트리 캐시가 더 강력합니다.

5단계: 캐시 키 설계(브랜치/락파일/플랫폼)로 낭비 줄이기

캐시를 공유할수록 좋지만, 무작정 공유하면 오염되거나 적중률이 떨어집니다.

권장 기준:

  • 플랫폼별 분리: linux/amd64linux/arm64 캐시는 분리
  • 락파일 변경 시 큰 폭으로 무효화되므로, 락파일 기반으로 deps 캐시가 자연스럽게 갱신되게 구성
  • 브랜치 캐시: main 은 안정 캐시, PR은 main 캐시를 cache-from 으로 당겨오되 cache-to 는 PR 전용으로 저장

예시(개념):

# PR 빌드
--cache-from type=registry,ref=registry.example.com/myapp:buildcache-main \
--cache-to   type=registry,ref=registry.example.com/myapp:buildcache-pr-456,mode=max

6단계: 빌드 병목을 수치로 확인하는 방법

느리다 를 해결하려면 레이어별 시간을 봐야 합니다.

docker buildx build --progress=plain -t myapp:dev .

여기서 확인할 것:

  • 어떤 RUN 단계가 매번 다시 실행되는지
  • 어떤 COPY 단계에서 캐시가 깨지는지
  • transferring context 시간이 비정상적으로 긴지

빌드 시간이 CI 전체 TTFB/배포 리드타임에 영향을 주는 구조라면, 프론트 성능 이슈처럼 원인-지표-개선 루프로 접근하는 게 좋습니다. 웹 성능 지표 관점은 Next.js 14 RSC 느림? TTFB 급증 7가지 해결도 같이 읽어보면 문제를 구조적으로 보는 데 도움이 됩니다.

7단계: 자주 터지는 실전 함정 체크리스트

1) 컨텍스트에 비밀파일이 섞여 캐시가 불안정

.env, 인증서, 로컬 설정 파일이 자주 바뀌면 COPY 해시가 계속 변합니다. .dockerignore 로 제거하고, 런타임 주입(환경변수/시크릿)로 바꾸세요.

2) ARG 위치 때문에 모든 레이어가 무효화

ARG 는 해당 값이 바뀌면 이후 레이어 캐시가 깨집니다. 자주 바뀌는 ARG 는 가능한 뒤로 미루세요.

# 자주 바뀌는 커밋 SHA 같은 ARG는 뒤쪽으로
ARG GIT_SHA
LABEL org.opencontainers.image.revision=$GIT_SHA

3) 이미지 풀/푸시가 느려서 빌드가 느린 것처럼 보임

빌드 자체가 아니라 네트워크/레지스트리 병목일 수 있습니다. 특히 ECR 권한/토큰 이슈가 있으면 재시도 때문에 시간만 늘어납니다. 증상이 pull 단계에서 길어지는 형태라면 위에서 언급한 ECR 403 글도 같이 점검하세요.

4) 캐시가 커져서 오히려 느려짐

mode=max 는 강력하지만 캐시가 너무 커지면 레지스트리 저장/다운로드 시간이 늘 수 있습니다. 팀 규모/변경 빈도에 따라 mode=min 으로 타협하거나, 캐시 태그를 주기적으로 로테이션하세요.

예시: GitHub Actions에서 레지스트리 캐시로 고정 가속

아래는 레지스트리에 캐시를 저장하고, 다음 빌드에서 가져오는 전형적인 패턴입니다.

name: build
on:
  push:
    branches: [main]

jobs:
  docker:
    runs-on: ubuntu-latest
    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 \
            --platform linux/amd64 \
            -t ghcr.io/myorg/myapp:${{ github.sha }} \
            --cache-from type=registry,ref=ghcr.io/myorg/myapp:buildcache \
            --cache-to type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=max \
            .

이 구성에서 중요한 건 cache-tocache-from 이 동일한 ref 를 바라보게 해서, 빌드 캐시가 지속적으로 축적되도록 만드는 것입니다.

마무리: 10배 튜닝의 핵심 조합

체감 10배까지 노릴 때 가장 효과가 큰 조합은 다음 순서로 정리됩니다.

  1. Dockerfile 레이어 구조 개선: 의존성 파일 선복사, .dockerignore
  2. RUN --mount=type=cache 로 패키지 다운로드 비용 제거
  3. CI에서는 반드시 --cache-to / --cache-from 으로 캐시를 외부(레지스트리)로 공유
  4. 캐시 키를 플랫폼/브랜치 기준으로 설계해 적중률을 유지

여기까지 적용하면 빌드가 느리다 가 아니라, 어떤 단계가 느리고 왜 캐시가 깨지는지까지 설명 가능한 상태가 됩니다. 그 다음부터는 애플리케이션 특성(언어, 빌드 시스템, 모노레포 여부)에 맞춰 캐시를 더 세분화하면 됩니다.