- Published on
GitHub Actions에서 Docker 레이어 캐시가 안 먹힐 때
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스 CI인 GitHub Actions에서 Docker 이미지를 빌드하면, 로컬 개발 환경에서는 잘 먹던 레이어 캐시가 갑자기 전혀 안 먹히는 경우가 많습니다. 특히 RUN apt-get update 같은 레이어가 매번 다시 실행되거나, npm ci/pip install이 항상 처음부터 도는 상황이 반복되면 빌드 시간이 눈덩이처럼 불어납니다.
이 글은 “캐시가 안 먹는다”를 감각적으로 추측하는 대신, 어떤 캐시(로컬 레이어 캐시, 레지스트리 캐시, GHA 캐시)를 쓰고 있는지를 분리해서 진단하고, BuildKit 기반의 docker buildx로 재현 가능한 캐시 전략을 만드는 것을 목표로 합니다.
1) 먼저 확인할 것: 지금 정말 캐시를 쓰고 있나
GitHub Actions 러너는 대부분 매 실행마다 깨끗한 VM에서 시작합니다. 즉 로컬 Docker 데몬의 레이어 캐시를 기대하면 거의 항상 실패합니다. 그래서 “캐시가 안 먹히는” 원인의 절반은 단순히 캐시 저장소가 없거나, 다음 실행에서 재사용되지 않는 구조 때문입니다.
다음 중 무엇을 기대하고 있는지부터 명확히 하세요.
- 로컬 레이어 캐시: 같은 머신에서 연속 빌드할 때만 의미 있음(자체 호스티드 러너면 가능)
- 레지스트리 기반 캐시: 이전 빌드 결과를 레지스트리(예: GHCR, ECR)에 캐시로 푸시하고 다음 빌드가 당겨씀
- GitHub Actions Cache 백엔드: BuildKit이 GHA 캐시 스토리지를 사용
BuildKit 로그가 나오고 있는지도 확인합니다.
# 빌드 로그에서 이런 문구가 보이면 BuildKit 기반일 가능성이 큼
# "CACHED" / "importing cache" / "exporting cache"
만약 docker build만 쓰고 있고 BuildKit이 꺼져 있으면 캐시 전략이 제한됩니다. Actions에서는 보통 docker/setup-buildx-action과 docker/build-push-action 조합을 권장합니다.
2) 가장 흔한 원인 7가지 (체크리스트)
2-1. 러너가 매번 새 머신이라 로컬 캐시가 증발
GitHub-hosted 러너는 기본적으로 상태가 유지되지 않습니다. 따라서 다음 조합 중 하나를 선택해야 합니다.
- 레지스트리 캐시 사용(
cache-to: type=registry) - GHA 캐시 사용(
cache-to: type=gha) - 자체 호스티드 러너로 로컬 캐시 유지
2-2. cache-from만 있고 cache-to가 없음
많이 하는 실수입니다. 캐시를 “가져오기”만 하고 “저장하기”를 안 하면 다음 실행에 남는 게 없습니다.
cache-from: 이전 캐시를 가져옴cache-to: 이번 빌드 캐시를 내보냄
둘 다 있어야 선순환이 됩니다.
2-3. 태그 전략이 매번 바뀌어 캐시 참조가 끊김
예를 들어 매번 :${GITHUB_SHA}만 쓰면, 캐시 소스가 안정적으로 존재하지 않을 수 있습니다. 캐시용 태그를 별도로 두는 게 안정적입니다.
- 배포용 태그: 커밋 SHA
- 캐시용 태그:
:buildcache또는 브랜치 기반
2-4. Dockerfile 레이어가 자주 invalidation 됨
캐시는 “레이어 입력이 동일”해야 재사용됩니다. 다음 패턴은 캐시를 쉽게 깨뜨립니다.
COPY . .를 너무 앞에 둬서, 소스 변경이 의존성 설치 레이어까지 전부 무효화RUN apt-get update를 단독 레이어로 둠(패키지 인덱스가 자주 바뀌어 캐시 효율 저하)- lockfile 없이
npm install같은 비결정적 설치
2-5. 빌드 컨텍스트가 커서 불필요한 변경이 자주 포함됨
.dockerignore가 부실하면, 테스트 결과물/로그/빌드 산출물 등이 컨텍스트에 포함되어 자주 변경되고, 그 결과 COPY 단계가 매번 달라져 캐시가 깨집니다.
2-6. 멀티플랫폼 빌드에서 캐시가 분리됨
linux/amd64와 linux/arm64는 캐시가 완전히 다르게 관리됩니다. 멀티플랫폼을 켜면 캐시 적중률이 떨어진 것처럼 보일 수 있습니다.
2-7. --no-cache 또는 pull: true로 인해 체감상 캐시가 무력화
--no-cache는 말 그대로 캐시를 쓰지 않습니다.pull: true는 베이스 이미지 갱신을 강제해 빌드가 달라질 수 있습니다(항상 나쁜 건 아니지만, 캐시 적중률은 떨어질 수 있음).
3) 정석 구성: buildx + GHA 캐시로 레이어 캐시 고정하기
가장 간단하게 “GitHub Actions에서만” 잘 동작하는 캐시는 type=gha입니다.
3-1. GitHub Actions 워크플로 예시
아래 예시는 빌드 캐시를 GHA에 저장하고, 다음 실행에서 재사용합니다.
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 }}:${{ github.sha }}
ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
핵심은 cache-from과 cache-to를 함께 두는 것, 그리고 mode=max로 메타데이터를 충분히 남기는 것입니다.
4) 레지스트리 캐시(Registry cache)로 팀/프로젝트 간 공유하기
GHA 캐시는 리포지토리 단위로 잘 동작하지만, 조직 내 여러 파이프라인에서 넓게 공유하거나, 자체 CI로 옮길 가능성이 있으면 레지스트리 캐시가 더 이식성이 좋습니다.
4-1. Registry cache 예시
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
여기서 :buildcache는 “실행마다 갱신되는 캐시 전용 태그”입니다. 배포 이미지 태그 정책과 분리하면 캐시 참조가 안정화됩니다.
5) Dockerfile 자체를 캐시 친화적으로 바꾸는 법
워크플로만 고쳐서는 한계가 있습니다. Dockerfile 레이어 구조가 캐시를 잘 타도록 설계돼야 합니다.
5-1. Node.js 예시: 의존성 레이어 분리
# syntax=docker/dockerfile:1
FROM node:20-bookworm AS build
WORKDIR /app
# 1) lockfile 먼저 복사해서 의존성 캐시를 최대화
COPY package.json package-lock.json ./
RUN npm ci
# 2) 그 다음 소스 복사
COPY . .
RUN npm run build
FROM node:20-bookworm-slim
WORKDIR /app
COPY /app/dist ./dist
CMD ["node", "dist/server.js"]
이 구조는 소스가 바뀌어도 npm ci 레이어가 유지될 확률이 높습니다.
5-2. Python 예시: requirements 고정 + 빌드 캐시
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
추가로 BuildKit의 캐시 마운트를 쓰면 다운로드 캐시를 더 공격적으로 재사용할 수 있습니다.
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN \
pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
주의: 이 방식은 BuildKit이 활성화되어야 하며, CI에서는 docker buildx 사용이 사실상 전제입니다.
6) 캐시가 “먹는 척만” 하는 경우: 로그로 판별하기
캐시 문제는 체감으로만 보면 헷갈립니다. 다음을 로그에서 확인하세요.
CACHED가 찍히는지importing cache manifest또는loaded cache가 있는지- 특정 단계만 계속 재실행되는지(예:
COPY . .이후 전부)
그리고 캐시가 깨지는 단계가 COPY . .라면 .dockerignore를 우선 점검하세요.
6-1. .dockerignore 예시
.git
node_modules
.dist
build
coverage
*.log
.DS_Store
컨텍스트 크기가 줄면 업로드 시간도 줄고, 불필요한 변경으로 인한 캐시 미스도 줄어듭니다.
7) 운영 관점 팁: 캐시도 “상태”라서 관측/정리가 필요
캐시는 성능을 올리지만, 상태가 쌓이면서 다른 문제를 만들기도 합니다. 예를 들어 캐시 태그가 계속 갱신되며 레지스트리 저장소 용량이 증가하거나, 오래된 캐시가 오히려 혼선을 줄 수 있습니다.
- 레지스트리 캐시를 쓴다면 보관 정책(예: GHCR retention, ECR lifecycle)을 설정
- 베이스 이미지 업데이트 정책을 정하고(
pull: true를 언제 켤지), 보안 패치와 빌드 속도 사이 균형을 맞춤
이런 “진단-정리-복구” 관점은 다른 운영 이슈에서도 유사하게 적용됩니다. 예를 들어 장애 원인을 빠르게 좁혀가는 방식은 리눅스 journald 로그 폭주로 디스크 꽉 찰 때 해결 같은 케이스와도 결이 같습니다.
또한 컨테이너 이미지 배포 이후 쿠버네티스에서 당겨오지 못하는 문제가 생기면 캐시보다 먼저 인증/토큰을 의심해야 합니다. 관련해서는 K8s ImagePullBackOff - ECR 인증·토큰 만료 해결도 함께 참고하면 흐름이 이어집니다.
8) 최종 점검: “이 조합이면 대부분 해결된다” 템플릿
다음 3가지를 동시에 적용하면, GitHub Actions에서 Docker 레이어 캐시 문제의 대부분이 해결됩니다.
docker/setup-buildx-action+docker/build-push-action으로 BuildKit 기반 빌드cache-from과cache-to를 반드시 함께 설정(type=gha또는type=registry)- Dockerfile에서 의존성 설치 레이어를 소스 복사보다 앞에 두고,
.dockerignore를 정리
캐시가 계속 안 먹힌다면, “러너가 바뀌어도 재사용 가능한 캐시 백엔드가 있는지”와 “캐시가 깨지는 첫 레이어가 어디인지”를 로그로 확인해 원인을 좁히면 됩니다. 이 두 가지가 잡히면, Docker 레이어 캐시는 더 이상 운이 아니라 설계의 문제가 됩니다.