Published on

Docker BuildKit 캐시 깨짐? GitHub Actions 10분 진단

Authors

서버리스 CI인 GitHub Actions에서 Docker 빌드가 갑자기 느려지면 대부분은 “BuildKit 캐시가 깨졌다”로 귀결됩니다. 문제는 원인이 한 가지가 아니라는 점입니다. cache-from이 제대로 안 붙었을 수도 있고, 컨텍스트가 미세하게 바뀌었을 수도 있고, RUN 레이어가 비결정적으로 흔들렸을 수도 있습니다.

이 글은 10분 안에 “캐시가 왜 안 맞는지”를 좁히기 위한 실전 진단 순서와, 바로 붙여 넣어 쓸 수 있는 GitHub Actions 및 Dockerfile 패턴을 제공합니다.

문맥상 GitHub Actions 워크플로우 구조 자체를 정리하고 싶다면 모노레포에서 GitHub Actions 재사용 워크플로우 설계·버전관리도 함께 보면 캐시 전략을 팀 단위로 표준화하기 쉽습니다.

0) 10분 진단 목표: “어느 레이어부터 무효화되는가”

BuildKit 캐시는 “전체가 된다/안 된다”가 아니라 레이어 단위로 맞거나 깨집니다. 따라서 첫 번째 목표는 아래 둘 중 하나를 빠르게 확인하는 것입니다.

  • 캐시 소스 자체가 없음: cache-from이 비어 있거나, 이전 캐시가 저장되지 않음
  • 캐시 소스는 있는데 미스매치: Dockerfile/컨텍스트/빌드 인자 변화로 특정 레이어부터 무효화

10분 진단은 다음 순서로 진행합니다.

  1. cache-to/cache-from이 실제로 동작하는지 확인
  2. 빌드 로그에서 캐시 히트/미스가 어디서 시작되는지 확인
  3. 컨텍스트 변화(특히 .dockerignore)로 인한 무효화 여부 확인
  4. 비결정적 RUN(apt/pip/npm 등)로 인한 레이어 흔들림 확인
  5. 멀티스테이지/타겟/플랫폼 불일치 확인

1) 1분: GitHub Actions에서 BuildKit 캐시가 “저장”되고 있나

가장 흔한 실수는 “캐시는 쓰고 있다고 생각했는데 사실 저장이 안 됨”입니다. GitHub Actions에서 docker/build-push-action을 쓴다면, 최소한 아래 형태가 있어야 합니다.

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: .
          file: ./Dockerfile
          push: true
          tags: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
          # 핵심: 캐시 저장/복원
          cache-from: type=gha
          cache-to: type=gha,mode=max

체크 포인트

  • cache-to가 없으면 다음 실행에서 가져올 캐시가 없습니다.
  • cache-from만 있고 cache-to가 없으면 “읽기 전용 캐시”가 됩니다.
  • type=gha는 GitHub Actions 캐시 백엔드를 쓰는 방식입니다. 레지스트리 캐시(type=registry)를 쓰는 팀도 있지만, 먼저 type=gha로 정상 동작을 확보하는 게 빠릅니다.

2) 2분: 로그에서 캐시 히트가 “어디까지” 되는지 확인

BuildKit은 캐시가 맞으면 보통 CACHED 또는 cache hit류의 표시가 나옵니다(출력 포맷에 따라 다름). GitHub Actions에서 더 명확히 보려면 plain 로그를 켜서 레이어별로 관찰합니다.

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: false
          tags: local/test:ci
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: false
          sbom: false
          # 로그 가시성
          outputs: type=docker
          build-args: |
            BUILDKIT_PROGRESS=plain

주의: BUILDKIT_PROGRESS는 보통 환경 변수로도 설정합니다. 액션에서 확실히 하려면 다음처럼 job 환경 변수로 주는 편이 안전합니다.

jobs:
  docker:
    runs-on: ubuntu-latest
    env:
      BUILDKIT_PROGRESS: plain

해석 방법

  • 초반 COPY package.json부터 바로 다시 실행된다면: 컨텍스트/파일 변경 또는 .dockerignore 누락 가능성이 큼
  • RUN apt-get update부터 매번 다시 돈다면: 비결정적 패키지 인덱스/시간 의존 가능성이 큼
  • 마지막 COPY . .에서부터 깨진다면: 소스 변경이 정상 반영되는 것이므로 오히려 정상일 수 있음(단, 너무 앞에서 깨지면 구조 개선 필요)

3) 2분: .dockerignore가 없거나 약하면 캐시는 “계속” 깨진다

캐시는 Dockerfile 명령뿐 아니라 빌드 컨텍스트 해시에 영향을 받습니다. 즉, COPY . .가 있는 순간 .git이나 node_modules 같은 거대한 디렉터리가 컨텍스트에 섞이면, 아주 작은 변화로도 해시가 달라져 레이어가 무효화됩니다.

최소한 아래는 넣어두는 것을 권합니다.

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

자주 놓치는 포인트

  • .github를 컨텍스트에 포함하면 워크플로우 파일 변경만으로도 캐시가 무효화될 수 있습니다.
  • 모노레포에서 루트 컨텍스트로 빌드하면, 다른 패키지 변경이 내 서비스 이미지 캐시를 깨뜨립니다.
    • 해결: context: ./services/api처럼 컨텍스트를 좁히거나, COPY 범위를 최소화합니다.

4) 2분: Dockerfile 레이어 순서가 캐시 효율을 결정한다

캐시가 “깨졌다”가 아니라 “원래부터 캐시가 잘 안 먹는 구조”인 경우가 많습니다. 핵심은 변경 빈도가 낮은 것부터 먼저 레이어로 만들고, 변경 빈도가 높은 소스 코드는 뒤로 미루는 것입니다.

나쁜 예

FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/index.js"]

소스가 한 줄만 바뀌어도 npm ci부터 다시 실행됩니다.

개선 예

FROM node:20-alpine AS build
WORKDIR /app

# 1) 의존성 관련 파일만 먼저 복사
COPY package.json package-lock.json ./
RUN npm ci

# 2) 그 다음 소스 복사
COPY . .
RUN npm run build

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

이렇게 하면 소스 변경이 있어도 의존성이 바뀌지 않는 한 npm ci 레이어는 캐시 히트가 됩니다.

5) 2분: 비결정적 RUN 때문에 “매번” 깨지는 패턴

다음 패턴은 캐시가 있어도 레이어 결과가 달라지기 쉬워, 사실상 계속 다시 빌드되는 것처럼 보일 수 있습니다.

  • apt-get update가 매번 다른 인덱스를 받아옴
  • pip install이 최신 버전을 당겨옴(버전 핀 미흡)
  • npm install을 사용(락파일 기반이 아닌 설치)
  • curl https://... | sh 같이 원격 스크립트를 즉시 실행

apt 예시: 한 레이어에서 끝내고, 필요하면 버전 핀

RUN apt-get update \
  && apt-get install -y --no-install-recommends ca-certificates curl \
  && rm -rf /var/lib/apt/lists/*

Python 예시: requirements 고정

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

requirements.txt에 버전 범위만 넓게 잡혀 있으면(예: requests>=2) 빌드 시점에 따라 결과가 달라질 수 있습니다.

6) 추가로 많이 터지는 원인 5가지(체크리스트)

6.1 플랫폼 불일치: linux/amd64 vs linux/arm64

로컬에서 arm64로 빌드한 캐시를 CI amd64에서 기대하면 캐시가 안 맞습니다. GitHub Actions에서 플랫폼을 명시해 혼선을 줄입니다.

with:
  platforms: linux/amd64
  cache-from: type=gha
  cache-to: type=gha,mode=max

6.2 멀티스테이지 타겟 불일치

--target을 바꾸면 캐시 체인이 바뀝니다.

with:
  target: runtime

6.3 빌드 인자 변화: ARG는 레이어 무효화 트리거

예를 들어 ARG GIT_SHARUN 전에 두면, 커밋이 바뀔 때마다 해당 레이어 이후가 전부 무효화됩니다. 가능하면 메타데이터는 LABEL로 뒤쪽에 두거나, 정말 필요한 곳에만 사용합니다.

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

LABEL 자체도 레이어를 만들지만, 보통 맨 마지막에 두면 앞 레이어 캐시는 살릴 수 있습니다.

6.4 타임스탬프/정렬 비결정성

압축 파일 생성, 빌드 산출물에 시간 포함, 파일 정렬 순서 등이 달라지면 레이어 결과가 매번 달라질 수 있습니다. 가능하면 빌드 도구 옵션으로 재현성을 확보합니다.

6.5 컨텍스트가 너무 큼

컨텍스트 전송 자체가 느려서 캐시가 안 먹는 것처럼 보이기도 합니다. .dockerignore로 줄이고, 서비스별로 컨텍스트를 분리하세요.

7) “캐시가 깨졌는지”를 재현 가능하게 만드는 최소 워크플로우

아래는 캐시 진단용으로 자주 쓰는 최소 구성입니다. 핵심은 cache-to/cache-from을 고정하고, 로그를 plain으로 보고, 이미지 push는 일단 끄는 것입니다.

name: docker-cache-diagnose
on:
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      BUILDKIT_PROGRESS: plain
    steps:
      - uses: actions/checkout@v4

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

      - name: Build (no push)
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          push: false
          tags: local/diagnose:ci
          cache-from: type=gha
          cache-to: type=gha,mode=max

이 워크플로우를 같은 커밋에서 두 번 연속 실행했을 때도 캐시가 전혀 안 맞는다면, 설정 문제가 아니라 Dockerfile/컨텍스트/비결정성 쪽일 확률이 높습니다.

8) 레지스트리 캐시(type=registry)로 전환해야 할 때

type=gha는 편하지만, 조직 정책이나 캐시 공유 범위(브랜치/리포 단위) 때문에 한계가 있을 수 있습니다. 특히 여러 워크플로우/리포에서 캐시를 공유하고 싶다면 레지스트리 캐시가 유리합니다.

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

이때는 레지스트리 인증이 필수입니다. AWS로 푸시하면서 권한 문제를 겪는다면 GitHub Actions OIDC로 AWS AssumeRoleAccessDenied 해결이 도움이 됩니다.

9) 결론: 10분 안에 원인을 좁히는 요약

  • 1분: cache-to/cache-from이 모두 있는지 확인 (type=gha로 먼저 고정)
  • 2분: plain 로그로 캐시가 어느 레이어부터 깨지는지 확인
  • 2분: .dockerignore 점검, 컨텍스트 범위 축소
  • 2분: Dockerfile 레이어 재배치(COPY lockfile 선행)
  • 2분: 비결정적 RUN 제거(버전 핀, 한 레이어로 정리)
  • 1분: 플랫폼/타겟/빌드 인자 변화 여부 확인

캐시 문제는 결국 “빌드 입력(컨텍스트, Dockerfile, 인자)이 바뀌었는가”와 “레이어가 재현 가능한가”로 환원됩니다. 위 순서대로 보면 감으로 추측하지 않고, 로그 기반으로 빠르게 원인을 특정할 수 있습니다.

CI 안정화 과정에서 워크플로우를 재사용 컴포넌트로 묶어 팀 표준으로 만들고 싶다면 모노레포에서 GitHub Actions 재사용 워크플로우 설계·버전관리처럼 캐시 옵션까지 포함해 버저닝하는 전략이 특히 효과적입니다.