- Published on
Docker BuildKit 캐시 깨짐? GitHub Actions 10분 진단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스 CI인 GitHub Actions에서 Docker 빌드가 갑자기 느려지면 대부분은 “BuildKit 캐시가 깨졌다”로 귀결됩니다. 문제는 원인이 한 가지가 아니라는 점입니다. cache-from이 제대로 안 붙었을 수도 있고, 컨텍스트가 미세하게 바뀌었을 수도 있고, RUN 레이어가 비결정적으로 흔들렸을 수도 있습니다.
이 글은 10분 안에 “캐시가 왜 안 맞는지”를 좁히기 위한 실전 진단 순서와, 바로 붙여 넣어 쓸 수 있는 GitHub Actions 및 Dockerfile 패턴을 제공합니다.
문맥상 GitHub Actions 워크플로우 구조 자체를 정리하고 싶다면 모노레포에서 GitHub Actions 재사용 워크플로우 설계·버전관리도 함께 보면 캐시 전략을 팀 단위로 표준화하기 쉽습니다.
0) 10분 진단 목표: “어느 레이어부터 무효화되는가”
BuildKit 캐시는 “전체가 된다/안 된다”가 아니라 레이어 단위로 맞거나 깨집니다. 따라서 첫 번째 목표는 아래 둘 중 하나를 빠르게 확인하는 것입니다.
- 캐시 소스 자체가 없음:
cache-from이 비어 있거나, 이전 캐시가 저장되지 않음 - 캐시 소스는 있는데 미스매치: Dockerfile/컨텍스트/빌드 인자 변화로 특정 레이어부터 무효화
10분 진단은 다음 순서로 진행합니다.
cache-to/cache-from이 실제로 동작하는지 확인- 빌드 로그에서 캐시 히트/미스가 어디서 시작되는지 확인
- 컨텍스트 변화(특히
.dockerignore)로 인한 무효화 여부 확인 - 비결정적
RUN(apt/pip/npm 등)로 인한 레이어 흔들림 확인 - 멀티스테이지/타겟/플랫폼 불일치 확인
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 /app/dist ./dist
COPY /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_SHA를 RUN 전에 두면, 커밋이 바뀔 때마다 해당 레이어 이후가 전부 무효화됩니다. 가능하면 메타데이터는 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 재사용 워크플로우 설계·버전관리처럼 캐시 옵션까지 포함해 버저닝하는 전략이 특히 효과적입니다.