Published on

GitHub Actions에서 Docker 레이어 캐시가 안 먹힐 때

Authors

서버리스 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-actiondocker/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/amd64linux/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-fromcache-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 --from=build /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 --mount=type=cache,target=/root/.cache/pip \
    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 레이어 캐시 문제의 대부분이 해결됩니다.

  1. docker/setup-buildx-action + docker/build-push-action으로 BuildKit 기반 빌드
  2. cache-fromcache-to를 반드시 함께 설정(type=gha 또는 type=registry)
  3. Dockerfile에서 의존성 설치 레이어를 소스 복사보다 앞에 두고, .dockerignore를 정리

캐시가 계속 안 먹힌다면, “러너가 바뀌어도 재사용 가능한 캐시 백엔드가 있는지”와 “캐시가 깨지는 첫 레이어가 어디인지”를 로그로 확인해 원인을 좁히면 됩니다. 이 두 가지가 잡히면, Docker 레이어 캐시는 더 이상 운이 아니라 설계의 문제가 됩니다.