Published on

Docker 빌드 캐시 깨짐, BuildKit+GHA로 고치기

Authors

서버/백엔드 CI에서 Docker 이미지 빌드 시간이 갑자기 2~10배로 늘어나는 순간이 있습니다. 로컬에서는 잘 캐시되는데 GitHub Actions 같은 CI에서는 매번 apt-get부터 npm ci까지 전부 다시 도는 경우가 대표적입니다. 이 글은 **왜 캐시가 깨지는지(원인)**를 레이어 관점에서 정리하고, BuildKit과 GitHub Actions 캐시(GHA)를 결합해 “다음 빌드에서 진짜로 재사용되는” 구성을 만드는 실전 가이드를 제공합니다.

아래 내용은 특히 다음 상황에서 효과가 큽니다.

  • PR마다 이미지 빌드가 느려져 개발 피드백 루프가 깨진 경우
  • 멀티 스테이지 빌드에서 특정 단계만 계속 캐시 미스가 나는 경우
  • 동일 커밋인데도 CI가 매번 풀 빌드를 하는 경우

관련해서 배포 단계에서 이미지 풀 자체가 실패하는 케이스는 별도 원인(인증, TLS, DNS 등)이 많으니, 그 이슈가 섞여 있다면 먼저 Kubernetes ImagePullBackOff - 401·TLS·DNS 원인별 해결도 같이 확인하는 것을 권합니다.

캐시가 “깨지는” 대표 원인 7가지

Docker 캐시는 “파일 시스템 스냅샷”이 아니라 레이어 단위의 결정적 결과물입니다. 즉, 어떤 레이어의 입력이 조금이라도 바뀌면 그 레이어부터 아래가 전부 무효화됩니다.

1) COPY . .가 너무 이른 시점에 등장

가장 흔한 패턴입니다.

  • COPY . .는 리포지토리의 거의 모든 변경을 해당 레이어 입력으로 포함합니다.
  • 그러면 그 다음에 오는 RUN npm ci, RUN gradle build 같은 무거운 단계가 매번 재실행됩니다.

해결은 간단합니다. 의존성 파일만 먼저 복사해서 의존성 설치 레이어를 “고정”합니다.

2) 빌드 컨텍스트에 불필요한 파일이 포함됨

.git, node_modules, dist, build, 테스트 아티팩트 등이 컨텍스트에 포함되면 사소한 변경도 캐시를 흔듭니다.

  • .dockerignore가 캐시 성능의 절반을 결정합니다.

3) apt-get update 같은 비결정적 단계

apt-get update는 시간에 따라 인덱스가 바뀌므로 같은 Dockerfile이라도 결과가 달라질 수 있습니다. 또한 한 레이어에서 update만 하고 다음 레이어에서 install하면 캐시가 더 자주 깨집니다.

  • 같은 RUN에서 updateinstall을 결합하고, 필요하면 버전 핀ning을 고려합니다.

4) 빌드 ARG/ENV가 자주 바뀜

ARG BUILD_DATE, ARG GIT_SHA 같은 값이 레이어에 포함되면 매 빌드 캐시 미스로 이어집니다.

  • 메타데이터는 가능하면 이미지 라벨로 옮기고, 실제 빌드 산출물에 영향을 주지 않는 값은 레이어 앞단에 두지 않습니다.

5) CI가 이전 캐시를 저장/복원하지 않음

로컬 Docker 데몬은 빌드 캐시를 디스크에 유지하지만, GitHub Actions 러너는 대부분 매 실행이 “새 머신”입니다.

  • BuildKit의 원격 캐시(cache-to, cache-from)가 없으면 캐시는 사실상 매번 0부터 시작합니다.

6) 멀티 플랫폼 빌드에서 플랫폼별 캐시가 분리됨

linux/amd64linux/arm64를 동시에 빌드하면 캐시 키가 달라져 재사용률이 떨어질 수 있습니다.

  • 플랫폼별 캐시 스코프를 명시하거나, 빌드 전략을 분리합니다.

7) 베이스 이미지 태그가 가변적임

FROM node:20 같은 태그는 시간이 지나면 내용이 바뀝니다.

  • 가능한 경우 digest 고정(@sha256:...)이나 최소한 마이너/패치 고정이 캐시 안정성을 높입니다.

BuildKit이 캐시의 게임 룰을 바꾸는 지점

BuildKit은 단순히 “빠른 빌더”가 아니라, 캐시를 내보내고(import/export) 정교하게 제어할 수 있게 해줍니다.

핵심은 두 가지입니다.

  • --cache-to: 빌드 결과 캐시를 외부 저장소로 내보냄
  • --cache-from: 이전에 저장한 캐시를 가져와서 재사용

GitHub Actions에서는 type=gha를 쓰면 Actions 캐시 백엔드에 BuildKit 캐시를 저장합니다. 별도 S3나 레지스트리 캐시를 운영하지 않아도 되는 장점이 있습니다.

Dockerfile 레이어 설계: “의존성 캐시”부터 고정하기

아래는 Node.js 예시지만, Gradle/Maven/pip도 동일한 원리입니다.

나쁜 예: 변경에 취약한 레이어 순서

FROM node:20-bullseye
WORKDIR /app

COPY . .
RUN npm ci
RUN npm run build

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

이 구조는 src 파일 한 줄만 바뀌어도 npm ci가 다시 실행됩니다.

좋은 예: 의존성 파일을 먼저 복사

FROM node:20-bullseye AS deps
WORKDIR /app

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

FROM node:20-bullseye AS build
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-bullseye AS runtime
WORKDIR /app

ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY package.json ./

CMD ["node", "dist/index.js"]
  • npm ci 레이어는 package-lock.json이 바뀔 때만 다시 돕니다.
  • 소스 변경은 주로 npm run build부터 영향을 줍니다.

.dockerignore는 필수

# .dockerignore
.git
.github
node_modules
npm-debug.log
Dockerfile
README.md
coverage
.dist
build

컨텍스트가 작아질수록 캐시 일관성과 빌드 전송 속도가 같이 좋아집니다.

GitHub Actions: BuildKit + GHA 캐시로 “다음 빌드”를 빠르게

다음은 docker/build-push-action을 사용해 BuildKit 캐시를 GHA에 저장/복원하는 대표 구성입니다.

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

jobs:
  docker:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push (with GHA cache)
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          push: ${{ github.ref == 'refs/heads/main' }}
          tags: |
            ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

포인트는 다음입니다.

  • cache-from: type=gha로 이전 캐시를 가져오고
  • cache-to: type=gha,mode=max로 가능한 많은 캐시 메타데이터를 저장합니다.

이 구성이 제대로 작동하면 PR 빌드도 “완전 신규 러너”임에도 이전 빌드 캐시를 재사용합니다.

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

1) BuildKit이 실제로 켜져 있는지

docker/build-push-action을 쓰면 기본적으로 Buildx/BuildKit 경로를 타지만, 로컬이나 다른 CI에서는 DOCKER_BUILDKIT=1이 필요할 수 있습니다.

DOCKER_BUILDKIT=1 docker build -t myapp:dev .

2) 캐시 스코프가 충돌하거나 너무 넓지 않은지

레포가 모노레포라면 서비스별로 캐시가 섞여 효율이 떨어질 수 있습니다. 이때는 scope를 줘서 분리합니다.

cache-from: type=gha,scope=my-service
cache-to: type=gha,scope=my-service,mode=max

3) 레이어가 “결정적”인지

다음 패턴은 캐시를 쉽게 망가뜨립니다.

  • RUN curl https://... | bash 처럼 외부 리소스가 매번 바뀌는 경우
  • RUN npm install 처럼 lockfile 없이 설치하는 경우

가능하면 lockfile 기반 설치(npm ci, pnpm install --frozen-lockfile)로 고정하세요.

4) 타임스탬프/버전 주입 위치

ARG GIT_SHA를 아주 앞 레이어에 두면 그 아래가 전부 깨집니다. 메타데이터는 마지막에 라벨로 넣는 편이 낫습니다.

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

고급: 레지스트리 캐시(type=registry)로 팀 캐시 공유

GHA 캐시는 레포 단위로 편하고 빠르지만, 조직/프로젝트 성격에 따라 레지스트리 캐시가 더 나을 때가 있습니다.

  • 여러 CI 시스템에서 같은 캐시를 공유하고 싶다
  • 자체 러너와 클라우드 러너를 섞어 쓴다
  • 캐시 보존 정책을 더 강하게 가져가고 싶다

예시는 다음과 같습니다.

- name: Build and push (registry cache)
  uses: docker/build-push-action@v6
  with:
    push: true
    tags: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
    cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
    cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max

이 방식은 캐시 자체도 이미지처럼 레지스트리에 저장되므로 네트워크/권한 이슈가 있으면 영향을 받습니다. 만약 EKS 배포에서 이미지 풀 인증이 자주 흔들린다면 EKS Pod ImagePullBackOff - ECR 인증·IRSA 점검법처럼 런타임 풀 경로도 같이 점검하는 게 안전합니다.

실전 팁: 캐시 효율을 수치로 확인하기

캐시가 “된 것 같긴 한데” 애매하면 로그를 더 자세히 보는 게 좋습니다.

  • docker buildx build --progress=plain ...으로 레이어별 캐시 히트 여부를 확인
docker buildx build --progress=plain -t myapp:dev .

또한 빌드가 느려졌을 때는 캐시 문제와 별개로 CI 러너 리소스 부족(메모리 압박)도 겹칠 수 있습니다. 빌드 중 프로세스가 죽거나 OOM이 의심되면 리눅스 OOM Killer로 프로세스 죽음 원인 추적도 함께 참고하면 원인 분리가 빨라집니다.

정리: “Dockerfile 레이어”와 “CI 캐시 백엔드”를 같이 맞춰라

Docker 빌드 캐시 깨짐은 보통 한 가지 이유가 아니라,

  • Dockerfile 레이어 설계(무거운 단계가 어디에 있나)
  • 빌드 컨텍스트(무엇이 입력으로 들어가나)
  • CI 환경(이전 캐시를 가져올 수 있나)

이 세 가지가 맞물려 발생합니다.

가장 효과가 큰 처방은 다음 순서로 적용하는 것입니다.

  1. .dockerignore 정리로 컨텍스트를 안정화
  2. 의존성 파일을 먼저 COPY하도록 Dockerfile 재구성
  3. GitHub Actions에서 BuildKit 캐시를 type=gha로 export/import
  4. 모노레포면 scope로 캐시 경계를 명확히

여기까지 적용하면 “PR 빌드 10분”이 “1~3분”대로 내려오는 케이스가 흔합니다. 이후 최적화는 서비스 특성(언어, 패키지 매니저, 멀티 플랫폼, 레지스트리 정책)에 맞춰 type=registry나 베이스 이미지 고정까지 확장하면 됩니다.