- Published on
GitHub Actions+BuildKit로 Docker 빌드 10배 빠르게
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스 CI 환경에서 Docker 이미지를 빌드하다 보면, 로컬에서는 1분인데 GitHub Actions에서는 10분이 걸리는 일이 흔합니다. 대부분의 원인은 코드가 느린 게 아니라 캐시가 매번 깨지기 때문입니다.
이 글에서는 GitHub Actions에서 BuildKit 기반 빌드로 레이어 캐시를 안정적으로 재사용하고, cache-to/cache-from를 이용해 원격 캐시까지 붙여 빌드 시간을 체감상 10배 가까이 줄이는 패턴을 정리합니다. Node.js나 Python, Go 등 런타임은 달라도 원리는 동일합니다.
왜 GitHub Actions의 Docker 빌드는 유독 느릴까
GitHub Actions 러너는 매 실행마다 새 VM에 가깝습니다. 로컬 Docker 데몬처럼 디스크에 레이어가 계속 남아 있지 않으니, 아래가 반복됩니다.
- 베이스 이미지 pull
- 의존성 설치 레이어 재생성
- 애플리케이션 빌드 재실행
- 이미지 푸시
특히 RUN npm ci나 RUN pip install -r requirements.txt 같은 레이어는 시간이 크고, 소스 코드 변경과 무관한데도 Dockerfile 작성 순서가 나쁘면 매번 다시 실행됩니다.
BuildKit은 여기서 두 가지를 제공합니다.
- 정교한 캐시 키 관리: 빌드 컨텍스트, 명령, 파일 변경을 더 잘 추적
- 외부 캐시 저장소: 레이어 캐시를 레지스트리나 GitHub Actions 캐시에 저장했다가 다음 실행에서 복원
BuildKit을 GitHub Actions에서 쓰는 가장 안전한 방법
GitHub Actions에서는 직접 docker build를 치기보다, Docker 공식 액션 조합이 가장 시행착오가 적습니다.
docker/setup-buildx-action: BuildKit 빌더 생성docker/build-push-action: build, cache, push를 한 번에
이 조합은 멀티 플랫폼, 레지스트리 캐시, provenance 옵션 등도 함께 다루기 쉽습니다.
Dockerfile부터 캐시 친화적으로 바꾸기
BuildKit 캐시가 있어도 Dockerfile이 캐시를 못 타면 소용이 없습니다. 핵심은 변경이 잦은 파일을 최대한 뒤로 미루는 것입니다.
Node.js 예시: 의존성 레이어를 고정
아래처럼 package.json과 package-lock.json만 먼저 복사하고 설치하면, 소스 코드가 바뀌어도 의존성 설치 레이어는 재사용됩니다.
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS build
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY /app/dist ./dist
COPY /app/package.json ./package.json
CMD ["node", "dist/server.js"]
# syntax=...는 BuildKit Dockerfile 프론트엔드 버전 지정입니다.- 멀티 스테이지로 런타임 이미지를 작게 만들어 pull, push 시간도 줄입니다.
BuildKit 캐시 마운트로 의존성 설치를 더 빠르게
BuildKit의 RUN --mount=type=cache는 패키지 매니저 캐시를 레이어와 별개로 유지해 다운로드를 크게 줄입니다.
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN \
npm ci
이 패턴은 Python의 pip 캐시, Go 모듈 캐시에도 그대로 적용됩니다.
GitHub Actions: 레지스트리 캐시로 레이어 재사용하기
가장 재현성이 좋고 팀 단위로 공유하기 쉬운 방법은 레지스트리 캐시입니다. 즉, 이미지 레이어 캐시를 ghcr.io 같은 곳에 따로 보관합니다.
아래 워크플로는 다음을 수행합니다.
- Buildx 셋업
ghcr.io로그인cache-to로 캐시를 레지스트리에 저장cache-from으로 다음 빌드에서 캐시를 당겨옴
name: build-and-push
on:
push:
branches: ["main"]
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up 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 }}:latest
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
여기서 중요한 포인트는 두 가지입니다.
mode=max: 가능한 많은 중간 레이어까지 캐시에 포함해 재사용률을 높입니다.- 캐시 ref는 실제 서비스 태그와 분리(
:buildcache)하는 게 관리가 쉽습니다.
GitHub Actions 캐시 타입을 고를 때의 기준
BuildKit 캐시는 크게 세 가지 선택지가 있습니다.
type=registry: 가장 추천. 팀 공유, 재현성 좋음. 단, 레지스트리 권한과 저장 공간 고려 필요type=gha: GitHub Actions 캐시 저장소 사용. 설정이 간단하지만, 캐시 정책과 크기 제한을 의식해야 함- 로컬 캐시: 러너가 매번 초기화되므로 효과가 제한적
type=gha를 쓰고 싶다면 아래처럼 바꿀 수 있습니다.
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
팀 규모가 커지고 워크플로가 많아질수록, 캐시 히트율과 일관성은 type=registry가 더 안정적인 경우가 많습니다.
빌드 시간을 진짜로 10배 줄이는 체크리스트
BuildKit을 켰는데도 크게 빨라지지 않는다면, 아래에서 병목이 발생하는 경우가 많습니다.
1) .dockerignore가 없거나 부실함
빌드 컨텍스트가 커지면 캐시 키가 자주 바뀌고 업로드도 느려집니다. 최소한 아래는 제외하는 게 좋습니다.
node_modules
.git
.gitignore
Dockerfile
README.md
dist
coverage
.env
.dockerignore는 Dockerfile과 같은 디렉터리에 두고, 실제 프로젝트 구조에 맞게 조정하세요.
2) 의존성 설치 전에 소스 전체를 COPY함
COPY . .가 너무 앞에 있으면, 소스 변경이 의존성 레이어까지 전파됩니다. 의존성 파일만 먼저 복사하는 패턴으로 바꾸세요.
3) 멀티 플랫폼 빌드가 필요 없는데 QEMU를 켬
linux/amd64만 필요하다면 멀티 플랫폼은 꺼서 시간을 줄이세요.
with:
platforms: linux/amd64
4) 베이스 이미지가 너무 무거움
빌드가 빨라도 pull과 push가 느리면 전체 파이프라인이 느립니다. 가능하면 alpine, distroless, slim 계열을 검토하세요.
5) 빌드 산출물이 런타임 이미지에 과하게 포함됨
멀티 스테이지로 빌드 도구체인, 캐시, 테스트 산출물을 런타임에서 제거하면 이미지 크기가 줄고 배포도 빨라집니다.
관측과 디버깅: 캐시가 정말 히트하는지 확인하기
최적화는 측정이 없으면 금방 퇴행합니다. BuildKit 로그에서 CACHED가 늘어나는지 확인하세요.
추가로, docker/build-push-action에는 빌드 요약과 provenance 관련 옵션이 있으니, 조직 정책에 따라 활성화 여부를 결정하면 됩니다.
빌드가 빨라졌는데 런타임에서 문제가 생긴다면, 배포 이후 장애 진단 루틴도 함께 갖추는 게 좋습니다. 예를 들어 컨테이너가 반복 재시작되는 상황은 빌드 문제가 아니라 설정/환경 변수/프로브 문제인 경우가 많습니다. 이런 케이스는 Kubernetes CrashLoopBackOff 원인 12가지와 진단처럼 런타임 관점에서 분리해 보는 게 효과적입니다.
또한 CI가 빨라지면 배포 빈도가 올라가고, 엣지에서의 장애 탐지와 로그 상관분석이 더 중요해집니다. Cloudflare를 앞단에 두고 있다면 Cloudflare 520·521, Nginx·ALB 로그로 30분 진단 같은 플레이북을 함께 준비해두면 운영 품질이 올라갑니다.
실전 권장 조합 정리
최소 변경으로 효과를 보려면 아래 조합부터 적용하는 것이 좋습니다.
- Dockerfile을 캐시 친화적으로 재구성: 의존성 파일 먼저
COPY - BuildKit 활성화:
docker/setup-buildx-action+docker/build-push-action - 캐시는 레지스트리 기반:
cache-to/cache-from를type=registry로 - 패키지 매니저 캐시는
RUN --mount=type=cache로 다운로드 비용 절감 .dockerignore로 컨텍스트 슬림화
이 구성을 적용하면, 변경이 잦은 애플리케이션 소스만 다시 빌드하고 의존성/베이스 레이어는 재사용하게 되어, GitHub Actions에서 가장 비싼 구간이 크게 줄어듭니다. 결과적으로 빌드 시간이 수 분에서 수십 초대로 내려가면서, PR 피드백 루프와 배포 사이클이 체감할 정도로 빨라집니다.