- Published on
Docker 빌드 캐시 깨짐, BuildKit+GHA로 고치기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/백엔드 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에서update와install을 결합하고, 필요하면 버전 핀ning을 고려합니다.
4) 빌드 ARG/ENV가 자주 바뀜
ARG BUILD_DATE, ARG GIT_SHA 같은 값이 레이어에 포함되면 매 빌드 캐시 미스로 이어집니다.
- 메타데이터는 가능하면 이미지 라벨로 옮기고, 실제 빌드 산출물에 영향을 주지 않는 값은 레이어 앞단에 두지 않습니다.
5) CI가 이전 캐시를 저장/복원하지 않음
로컬 Docker 데몬은 빌드 캐시를 디스크에 유지하지만, GitHub Actions 러너는 대부분 매 실행이 “새 머신”입니다.
- BuildKit의 원격 캐시(
cache-to,cache-from)가 없으면 캐시는 사실상 매번 0부터 시작합니다.
6) 멀티 플랫폼 빌드에서 플랫폼별 캐시가 분리됨
linux/amd64와 linux/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 /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-bullseye AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY /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 환경(이전 캐시를 가져올 수 있나)
이 세 가지가 맞물려 발생합니다.
가장 효과가 큰 처방은 다음 순서로 적용하는 것입니다.
.dockerignore정리로 컨텍스트를 안정화- 의존성 파일을 먼저
COPY하도록 Dockerfile 재구성 - GitHub Actions에서 BuildKit 캐시를
type=gha로 export/import - 모노레포면
scope로 캐시 경계를 명확히
여기까지 적용하면 “PR 빌드 10분”이 “1~3분”대로 내려오는 케이스가 흔합니다. 이후 최적화는 서비스 특성(언어, 패키지 매니저, 멀티 플랫폼, 레지스트리 정책)에 맞춰 type=registry나 베이스 이미지 고정까지 확장하면 됩니다.