Published on

Docker BuildKit 캐시로 GitHub Actions 10배 가속

Authors

서버리스 CI 환경인 GitHub Actions에서 Docker 이미지를 매번 clean build로 만들면, 빌드 시간이 수 분에서 수십 분까지 늘어납니다. 특히 Node.js나 Java(Gradle/Maven)처럼 의존성 다운로드가 큰 프로젝트는 네트워크와 압축 해제 비용이 누적되어 병목이 더 심해집니다.

해결의 핵심은 BuildKit 캐시를 “지속” 시키는 것입니다. BuildKit은 레이어 캐시뿐 아니라 RUN --mount=type=cache 같은 빌드 중간 캐시도 지원합니다. 하지만 GitHub Actions는 러너가 매번 새로 뜨기 때문에, 캐시를 외부로 내보내(cache-to) 다음 실행에서 다시 가져와(cache-from)야 효과가 납니다.

이 글에서는 BuildKit 캐시로 GitHub Actions를 체감 10배까지 가속하는 패턴을, 실패 포인트까지 포함해 정리합니다.

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

대부분 아래 3가지가 겹칩니다.

  1. 러너가 매번 새 환경
    • 로컬 Docker 레이어 캐시가 남지 않습니다.
  2. Dockerfile 레이어 설계가 캐시 친화적이지 않음
    • COPY . .가 너무 빨리 나오면 작은 소스 변경에도 의존성 레이어가 무효화됩니다.
  3. 의존성 다운로드/빌드 산출물 캐시 부재
    • npm/pnpm/yarn 캐시, Gradle 캐시, Maven 로컬 저장소가 매번 새로 채워집니다.

BuildKit 캐시를 제대로 쓰면 1번을 우회하고, Dockerfile을 정리하면 2번을, --mount=type=cache로 3번을 해결할 수 있습니다.

BuildKit 캐시의 3가지 백엔드 선택

BuildKit 캐시는 크게 세 가지 방식으로 “외부화”할 수 있습니다.

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

  • 장점: 설정이 간단하고 GitHub가 관리
  • 단점: 캐시 크기/정책 영향, 레포/브랜치 정책에 따라 히트율이 흔들릴 수 있음

2) 레지스트리 캐시(type=registry)

  • 장점: 팀/브랜치/러너에 상관없이 재현성 좋음, 대규모 조직에서 안정적
  • 단점: 레지스트리 저장 비용, 접근 권한/토큰 관리 필요

3) 로컬 디렉터리 캐시(type=local)

  • 장점: 디버깅에 좋고 동작이 직관적
  • 단점: GitHub Actions에서는 결국 actions/cache로 감싸야 해서 번거로움

실무에서는 type=gha로 빠르게 도입하고, 빌드가 매우 무겁거나 멀티 브랜치/멀티 서비스로 커지면 type=registry로 승격하는 흐름이 무난합니다.

GitHub Actions에서 BuildKit 캐시 적용(가장 쉬운 구성)

아래 구성은 docker/build-push-action을 사용해 BuildKit 캐시를 GitHub Actions 캐시로 저장/복원합니다.

예시: type=gha 캐시로 빌드 가속

name: ci

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
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

핵심은 cache-fromcache-to입니다.

  • cache-from: type=gha
    • 이전 실행에서 저장된 캐시를 가져옵니다.
  • cache-to: type=gha,mode=max
    • 가능한 많은 캐시 메타데이터를 저장해 히트율을 올립니다.

이 설정만으로도 “매번 의존성 다시 받기”가 크게 줄어들며, 프로젝트에 따라 빌드 시간이 수 분에서 수십 초대로 떨어집니다.

Dockerfile을 캐시 친화적으로 바꾸면 효과가 폭발한다

캐시는 “있기만” 해서는 안 되고, 깨지지 않게 Dockerfile을 설계해야 합니다.

안 좋은 예: 소스 전체 복사 후 의존성 설치

FROM node:20-alpine
WORKDIR /app

COPY . .
RUN npm ci
RUN npm run build

소스 파일 하나만 바뀌어도 COPY . . 레이어가 바뀌면서 npm ci가 매번 다시 실행됩니다.

좋은 예: 의존성 파일을 먼저 복사

# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS build
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build
  • package-lock.json이 바뀌지 않는 한 npm ci 레이어는 캐시가 살아남습니다.
  • BuildKit 캐시까지 붙으면 GitHub Actions에서도 이 레이어를 재사용합니다.

RUN --mount=type=cache로 의존성 다운로드를 더 줄이기

BuildKit의 진짜 강점은 “레이어 캐시” 외에도, 빌드 단계에서 쓰는 캐시 디렉터리를 별도로 유지하는 기능입니다.

Node.js: npm 캐시 디렉터리 마운트

# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS build
WORKDIR /app

COPY package.json package-lock.json ./

RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .
RUN npm run build

이렇게 하면 npm ci가 재실행되더라도, 다운로드한 패키지 아카이브가 캐시되어 네트워크 비용이 크게 줄어듭니다.

Gradle: Gradle 캐시 마운트

# syntax=docker/dockerfile:1.7
FROM gradle:8.5-jdk17 AS build
WORKDIR /home/gradle/project

COPY build.gradle settings.gradle gradle.properties ./
COPY gradle ./gradle

RUN --mount=type=cache,target=/home/gradle/.gradle \
    gradle --no-daemon dependencies

COPY . .
RUN --mount=type=cache,target=/home/gradle/.gradle \
    gradle --no-daemon clean build
  • dependencies를 먼저 한 번 해두면(또는 build 전에) 의존성 캐시가 더 안정적으로 쌓입니다.
  • 단, 멀티모듈이면 settings.gradle과 모듈별 build.gradle 복사 전략을 더 정교하게 잡아야 합니다.

멀티스테이지 빌드에서 캐시를 잃지 않는 법

멀티스테이지는 런타임 이미지를 작게 만들지만, 캐시 설계가 어긋나면 빌드가 느려질 수 있습니다.

예시: Node 빌드 산출물만 런타임으로 복사

# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS build
WORKDIR /app

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html

포인트는 빌드 단계에서 캐시가 잘 먹도록 레이어를 분리하고, 런타임 단계는 최대한 단순하게 유지하는 것입니다.

레지스트리 캐시로 더 안정적으로 운영하기(type=registry)

type=gha가 간편하지만, 조직/레포 정책이나 캐시 만료로 히트율이 흔들릴 때가 있습니다. 이때는 레지스트리에 캐시 이미지를 따로 저장하는 방식이 효과적입니다.

예시: GHCR에 캐시 저장

- name: Build and push (with registry cache)
  uses: docker/build-push-action@v6
  with:
    context: .
    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
  • buildcache 태그는 실제 배포용 이미지가 아니라 “캐시 저장소”로 쓰입니다.
  • 브랜치별로 캐시를 분리하고 싶다면 ref에 브랜치명을 섞되, 너무 잘게 쪼개면 캐시가 분산되어 오히려 느려질 수 있습니다.

캐시가 오히려 느려지는 대표 함정 6가지

BuildKit 캐시는 만능이 아니고, 잘못 쓰면 “캐시 관리 비용” 때문에 역효과가 납니다.

  1. 컨텍스트가 너무 큼
    • .dockerignore가 부실하면 매번 수백 MB를 전송/해시 계산합니다.
  2. 자주 바뀌는 파일이 앞 레이어에 있음
    • 예: COPY . .가 너무 위에 있거나, 버전/빌드번호 파일이 초반에 섞임
  3. 캐시 키가 과도하게 분기됨
    • 브랜치별, 서비스별로 캐시를 너무 쪼개면 히트율이 떨어짐
  4. mode=max가 항상 정답은 아님
    • 저장량이 커져 업로드/다운로드가 병목이 될 수 있음
  5. 베이스 이미지 태그가 흔들림
    • node:latest 같은 태그는 시점에 따라 레이어가 달라져 캐시가 깨짐
  6. 빌드 산출물을 레이어에 과도하게 포함
    • 빌드가 끝난 뒤 필요 없는 파일까지 포함하면 캐시도 비대해짐

캐시가 비대해져 CI가 느려지는 문제는 별도로 정리한 글에서 더 깊게 다룹니다. 빌드가 빨라졌다가 다시 느려지는 패턴이라면 아래 글을 함께 보세요.

성능 측정: “10배”를 재현 가능하게 만드는 체크리스트

가속을 체감이 아니라 데이터로 확인하려면 아래를 점검합니다.

1) 빌드 로그에서 캐시 히트 확인

BuildKit은 캐시 히트 시 CACHED 같은 표시가 나옵니다. GitHub Actions 로그에서 npm cigradle build 구간이 캐시로 처리되는지 확인하세요.

2) 빌드 시간 분해(네트워크 vs 컴파일)

  • 의존성 다운로드가 대부분이면 --mount=type=cache가 즉효
  • 컴파일이 대부분이면 병렬성, 빌드 옵션, 러너 스펙을 검토

3) Docker 컨텍스트 최적화

.dockerignore는 캐시 못지않게 중요합니다.

.git
node_modules
.gradle
build
.dist
coverage
.DS_Store

컨텍스트가 작아지면 해시 계산과 업로드가 줄어, 캐시 조회 자체도 빨라집니다.

보안/권한 이슈: 레지스트리 로그인과 토큰

레지스트리 캐시(type=registry)를 쓰면 로그인/권한 문제가 성능 이슈로 위장해 나타날 수 있습니다. 예를 들어 캐시 pull이 실패하면 매번 풀 빌드가 되어 “갑자기 느려진 CI”가 됩니다.

특히 OIDC나 토큰 권한 설정이 꼬이면 403으로 막히는데, 이 경우는 성능 튜닝보다 먼저 권한을 해결해야 합니다.

실전 권장 조합(요약)

  • 빠른 도입: docker/build-push-action + cache-to/cache-from type=gha
  • 장기 운영/안정성: type=registry로 캐시를 레지스트리에 고정
  • Dockerfile 최적화:
    • 의존성 관련 파일을 먼저 COPY
    • RUN --mount=type=cache로 패키지 매니저/빌드 툴 캐시 유지
    • .dockerignore로 컨텍스트 최소화

BuildKit 캐시는 “한 번 켜면 끝”이 아니라, Dockerfile 레이어 설계와 캐시 백엔드 선택까지 맞물릴 때 진짜 효과가 납니다. 위 구성대로 적용하면 GitHub Actions에서 반복 빌드가 많은 프로젝트일수록, 빌드 시간이 눈에 띄게 줄어드는 것을 확인할 수 있을 것입니다.