- Published on
Docker BuildKit 캐시로 GitHub Actions 10배 가속
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스 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가지가 겹칩니다.
- 러너가 매번 새 환경
- 로컬 Docker 레이어 캐시가 남지 않습니다.
- Dockerfile 레이어 설계가 캐시 친화적이지 않음
COPY . .가 너무 빨리 나오면 작은 소스 변경에도 의존성 레이어가 무효화됩니다.
- 의존성 다운로드/빌드 산출물 캐시 부재
- 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-from과 cache-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 \
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 \
gradle --no-daemon dependencies
COPY . .
RUN \
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 npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY /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 캐시는 만능이 아니고, 잘못 쓰면 “캐시 관리 비용” 때문에 역효과가 납니다.
- 컨텍스트가 너무 큼
.dockerignore가 부실하면 매번 수백 MB를 전송/해시 계산합니다.
- 자주 바뀌는 파일이 앞 레이어에 있음
- 예:
COPY . .가 너무 위에 있거나, 버전/빌드번호 파일이 초반에 섞임
- 예:
- 캐시 키가 과도하게 분기됨
- 브랜치별, 서비스별로 캐시를 너무 쪼개면 히트율이 떨어짐
mode=max가 항상 정답은 아님- 저장량이 커져 업로드/다운로드가 병목이 될 수 있음
- 베이스 이미지 태그가 흔들림
node:latest같은 태그는 시점에 따라 레이어가 달라져 캐시가 깨짐
- 빌드 산출물을 레이어에 과도하게 포함
- 빌드가 끝난 뒤 필요 없는 파일까지 포함하면 캐시도 비대해짐
캐시가 비대해져 CI가 느려지는 문제는 별도로 정리한 글에서 더 깊게 다룹니다. 빌드가 빨라졌다가 다시 느려지는 패턴이라면 아래 글을 함께 보세요.
성능 측정: “10배”를 재현 가능하게 만드는 체크리스트
가속을 체감이 아니라 데이터로 확인하려면 아래를 점검합니다.
1) 빌드 로그에서 캐시 히트 확인
BuildKit은 캐시 히트 시 CACHED 같은 표시가 나옵니다. GitHub Actions 로그에서 npm ci나 gradle 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에서 반복 빌드가 많은 프로젝트일수록, 빌드 시간이 눈에 띄게 줄어드는 것을 확인할 수 있을 것입니다.