Published on

GitHub Actions Docker 빌드 캐시로 70% 단축하기

Authors

CI에서 Docker 이미지를 매번 no cache에 가깝게 다시 빌드하면, 코드 변경이 작아도 빌드 단계가 전부 재실행되어 파이프라인이 금방 느려집니다. 특히 Node.js, Python, Go처럼 의존성 설치 단계가 무거운 스택은 npm ci/pip install/go mod download가 매번 반복되면서 체감 시간이 크게 늘어납니다.

이 글에서는 GitHub Actions에서 Docker 빌드를 빠르게 만드는 핵심인 BuildKit 기반 빌드 캐시를 제대로 쓰는 방법을 다룹니다. 목표는 단순합니다.

  • 변경이 없는 레이어는 재사용
  • 의존성 설치 레이어를 최대한 안정적으로 캐시
  • PR 빌드와 main 빌드 모두에서 캐시 히트율을 높임
  • 캐시 저장소를 GHA cache 또는 registry cache로 운영해 러너 재시작에도 캐시 유지

아래 설정을 적용하면 프로젝트 성격에 따라 다르지만 빌드 시간 40~70% 단축이 충분히 가능합니다.

왜 GitHub Actions에서 Docker 빌드가 느려질까

GitHub-hosted runner는 기본적으로 에페메랄입니다. 즉, 워크플로가 끝나면 러너가 사라지고, 로컬 Docker 레이어 캐시도 함께 사라집니다. 로컬 개발 머신에서 docker build가 빠른 이유는 이전 레이어가 남아 있기 때문인데, CI에서는 그 전제가 깨집니다.

또 하나의 원인은 Dockerfile 구조입니다.

  • COPY . .가 너무 빨리 나오면, 코드 한 줄 바뀌어도 이후 레이어가 전부 무효화
  • 의존성 설치 전에 lockfile만 복사하지 않으면, npm ci 레이어가 매번 재실행
  • 멀티스테이지 빌드에서 stage 간 캐시 공유가 되지 않도록 작성된 경우

결론적으로, CI에서 빠르게 만들려면 두 가지가 필요합니다.

  1. Dockerfile을 캐시 친화적으로 재구성
  2. Buildx 캐시를 외부에 저장해서 러너가 바뀌어도 재사용

핵심 개념: BuildKit/Buildx 캐시 타입 2가지

GitHub Actions에서 가장 많이 쓰는 캐시는 크게 두 가지입니다.

1) type=gha (GitHub Actions 캐시 백엔드)

  • 장점: 설정이 간단하고 GitHub Actions에 최적화
  • 단점: 조직/리포 정책에 따라 캐시 용량 제한 영향, 브랜치/키 전략이 중요

2) type=registry (레지스트리에 캐시를 이미지로 저장)

  • 장점: 캐시가 레지스트리에 남아 장기 보존/공유가 쉬움, 팀/브랜치 간 공유 유리
  • 단점: 레지스트리 권한/비용 고려, 캐시 이미지 관리 필요

실무에서는 type=gha로 시작해서, 모노레포/다중 서비스/대규모 팀이면 type=registry로 가는 경우가 많습니다.

Dockerfile을 캐시 친화적으로 바꾸는 패턴

여기서는 Node.js 예시로 설명하지만, 원리는 동일합니다.

  • 의존성 설치 레이어를 최대한 위로 올리고
  • 변경이 잦은 소스 복사는 뒤로 미루고
  • BuildKit 캐시 마운트로 패키지 캐시를 재사용

아래 Dockerfile은 빌드 캐시를 잘 타도록 구성한 예시입니다.

# syntax=docker/dockerfile:1.7

FROM node:20-alpine AS deps
WORKDIR /app

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

# 2) BuildKit 캐시 마운트로 npm 캐시 재사용
RUN --mount=type=cache,target=/root/.npm \
    npm ci

FROM node:20-alpine AS build
WORKDIR /app

# deps 레이어 재사용
COPY --from=deps /app/node_modules ./node_modules

# 3) 이제 소스 복사 (여기서 변경이 자주 발생)
COPY . .

RUN npm run build

FROM node:20-alpine AS runtime
WORKDIR /app

ENV NODE_ENV=production

COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules

EXPOSE 3000
CMD ["node", "dist/server.js"]

포인트는 다음과 같습니다.

  • COPY package.json package-lock.json을 먼저 해서 npm ci 레이어가 소스 변경에 영향을 덜 받게 함
  • RUN --mount=type=cache를 사용해 패키지 매니저의 다운로드 캐시를 재사용
  • 멀티스테이지로 런타임 이미지를 슬림하게 유지

이 Dockerfile만으로도 로컬에서는 빨라지지만, CI에서는 여전히 러너가 초기화되므로 외부 캐시 저장이 필요합니다.

GitHub Actions 설정: Buildx + GHA 캐시로 속도 올리기

가장 범용적인 조합은 docker/setup-buildx-actiondocker/build-push-action을 쓰는 방식입니다.

아래 워크플로는 다음을 포함합니다.

  • Buildx 활성화
  • cache-from/cache-totype=gha 사용
  • main 브랜치에서는 push까지 수행
name: build-and-push

on:
  push:
    branches: ["main"]
  pull_request:

jobs:
  docker:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GHCR
        if: github.event_name == 'push'
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build (and push on main)
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          push: ${{ github.event_name == 'push' }}
          tags: |
            ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

mode=max가 중요한 이유

cache-to: type=gha,mode=max는 가능한 많은 중간 레이어 캐시를 저장합니다. 특히 멀티스테이지 빌드에서 캐시 히트율이 올라가서 체감 성능 차이가 큽니다.

다만 캐시 용량이 커질 수 있으니, 리포 규모가 큰 경우에는 registry cache로 옮기거나 Dockerfile을 더 최적화하는 편이 안정적입니다.

레지스트리 캐시로 더 안정적인 캐시 공유하기

팀 규모가 커지면 PR 빌드가 많아지고, 브랜치가 다양해지면서 type=gha만으로는 캐시가 분산되거나 히트율이 떨어질 수 있습니다. 이때는 캐시를 레지스트리에 “캐시 이미지”로 저장하는 접근이 좋습니다.

예시는 GHCR에 캐시 이미지를 :buildcache 태그로 유지하는 방식입니다.

- name: Build and push with registry cache
  uses: docker/build-push-action@v6
  with:
    context: .
    push: ${{ github.event_name == 'push' }}
    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

이 방식의 장점은 다음과 같습니다.

  • 러너가 바뀌어도 캐시가 레지스트리에 남아있음
  • 여러 브랜치/여러 워크플로가 같은 캐시를 공유 가능
  • 장기적으로 캐시 히트율이 안정적

주의할 점도 있습니다.

  • 캐시 이미지도 레지스트리 저장 공간을 차지함
  • 권한이 부족하면 403이 발생할 수 있음

ECR을 사용한다면 권한 설정이 특히 중요합니다. EKS에서 IAM 최소권한을 설계하는 흐름과 비슷하게, CI에서 레지스트리 권한을 최소화하고 필요한 액션만 허용하는 편이 안전합니다. 관련 내용은 EKS IRSA로 Pod IAM 권한 최소화 실전 가이드도 함께 참고하면 좋습니다.

캐시가 안 먹을 때 가장 흔한 원인 7가지

빌드 시간이 줄지 않는다면 아래부터 의심하는 게 빠릅니다.

1) Dockerfile에서 COPY . .가 너무 앞에 있음

소스가 조금만 바뀌어도 이후 레이어가 전부 무효화됩니다. lockfile만 먼저 복사하도록 분리하세요.

2) lockfile이 커밋되어 있지 않음

package-lock.json/pnpm-lock.yaml/poetry.lock이 없으면 의존성 레이어가 매번 달라져 캐시가 깨집니다.

3) --mount=type=cache를 쓰지 않음

패키지 매니저 다운로드 캐시를 재사용하지 못하면, 레이어 캐시가 있어도 네트워크 다운로드가 병목이 됩니다.

4) 빌드 컨텍스트가 너무 큼

.dockerignore가 부실하면 전송/해시 계산 비용이 커지고, 작은 변경에도 컨텍스트 해시가 자주 바뀝니다.

권장 .dockerignore 예시는 아래와 같습니다.

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

5) 태그/캐시 키 전략이 매번 달라짐

type=gha는 내부적으로 캐시를 관리하지만, 워크플로가 분리되어 있거나 캐시 스코프가 달라지면 히트율이 떨어질 수 있습니다.

6) 멀티플랫폼 빌드에서 캐시가 분산됨

linux/amd64linux/arm64를 동시에 빌드하면 캐시가 아키텍처별로 나뉩니다. 필요할 때만 멀티플랫폼을 켜고, 기본은 단일 플랫폼으로 운영하면 비용이 줄어듭니다.

7) 레지스트리 인증/토큰 만료

레지스트리 캐시를 쓰는데 403 또는 pull 실패가 나면 권한/토큰 만료를 먼저 확인하세요. 쿠버네티스에서 이미지 pull이 실패하는 경우의 진단 흐름은 K8s ImagePullBackOff - ECR 403·토큰 만료 해결도 참고할 수 있습니다.

70% 단축을 만드는 운영 팁: 측정과 병목 분리

캐시 최적화는 “감”으로 하면 금방 한계가 옵니다. 아래처럼 측정 포인트를 나눠보면 개선이 쉬워집니다.

  • Docker 빌드 로그에서 어떤 step이 매번 실행되는지 확인
  • 의존성 설치 step이 재실행되면 Dockerfile 구조 문제
  • 레이어는 캐시되는데도 느리면 네트워크 다운로드 캐시 문제
  • 컨텍스트 전송이 느리면 .dockerignore 문제

BuildKit 로그를 더 보고 싶다면 빌드 단계에서 --progress=plain을 쓰는 것도 좋습니다. docker/build-push-action에서는 기본 출력이 요약이라, 문제 재현 시에만 plain 로그로 바꿔 분석하는 식으로 운영할 수 있습니다.

보너스: PR에서는 push 없이도 캐시를 남길 수 있을까

가능합니다. 다만 레지스트리 캐시를 쓰는 경우, PR에서도 캐시를 cache-to로 업로드하도록 하면 다음 PR 빌드가 빨라집니다. 대신 PR 빌드에서 레지스트리 쓰기 권한을 줄지(보안) 여부를 결정해야 합니다.

  • 보안 우선: PR에서는 cache-from만 사용
  • 속도 우선: PR에서도 cache-to 허용 (신뢰된 브랜치/내부 PR만)

조직 정책에 따라 선택하세요.

정리

GitHub Actions에서 Docker 빌드를 70%까지 단축하는 핵심은 “캐시를 믿을 수 있게 만드는 것”입니다.

  • Dockerfile을 캐시 친화적으로 재구성 (lockfile 분리, COPY . . 뒤로)
  • BuildKit 캐시 마운트로 패키지 다운로드 캐시 재사용
  • docker/build-push-action에서 cache-from/cache-totype=gha 또는 type=registry로 구성
  • .dockerignore로 빌드 컨텍스트를 줄여 캐시 무효화를 최소화

CI가 빨라지면 단순히 개발 경험이 좋아지는 수준을 넘어, 배포 빈도와 장애 대응 속도까지 직접적으로 개선됩니다. Argo CD 같은 GitOps 환경에서 잦은 배포를 안정적으로 유지하려면, 결국 빌드부터 빨라야 전체 파이프라인이 건강해집니다. 관련해서 배포 단계에서 문제가 생겼을 때의 진단 흐름은 Argo CD Sync 실패? RBAC·CRD·Drift 진단법도 함께 읽어두면 도움이 됩니다.