- Published on
GitHub Actions로 Docker 빌드캐시 5배 가속
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스 CI 환경에서 Docker 이미지를 매번 0부터 빌드하면, 작은 변경에도 의존성 설치 레이어가 반복 실행되어 시간이 급격히 늘어납니다. 로컬에서는 Docker 데몬이 레이어 캐시를 들고 있으니 빠른데, GitHub Actions는 러너가 매번 새로 뜨기 때문에 캐시가 기본적으로 휘발성입니다. 이 글은 GitHub Actions에서 BuildKit 기반 캐시를 “지속”시키고, 레이어가 다시 쓰이도록 설계해 빌드 시간을 체감 5배 수준까지 줄이는 방법을 실전 관점에서 정리합니다.
또한 캐시를 붙였는데도 빨라지지 않는 흔한 함정(컨텍스트가 커서 캐시가 무효화되는 경우, COPY . . 위치가 잘못된 경우, 패키지 매니저 캐시가 누락된 경우)도 함께 다룹니다.
관련해서 CI 장애나 운영 이슈를 함께 보려면 Jenkins 에이전트 오프라인 원인·복구 10분이나, 배포 후 이미지 풀 실패까지 이어질 때는 K8s ImagePullBackOff·ErrImagePull 원인 9가지도 같이 참고하면 흐름이 이어집니다.
왜 GitHub Actions에서는 Docker 빌드가 느려질까
GitHub Actions의 ubuntu-latest 러너는 작업이 끝나면 폐기됩니다. 즉 다음 실행에서 레이어 캐시가 남아있지 않습니다. 그래서 아래와 같은 고비용 레이어가 매번 재실행됩니다.
apt-get update및 OS 패키지 설치npm ci,pnpm install,pip install,go mod download같은 의존성 설치- 프론트엔드 번들링, 네이티브 빌드 등 CPU 집약 작업
BuildKit 캐시는 “레이어 캐시”뿐 아니라, 빌드 중간 산출물과 다운로드 캐시까지 저장할 수 있어 CI에서 효과가 큽니다. 핵심은 캐시를 외부 저장소에 export 하고 다음 빌드에서 import 하는 것입니다.
목표: 캐시를 지속시키는 2가지 전략
GitHub Actions에서 실전적으로 많이 쓰는 캐시 전략은 다음 2가지입니다.
- GHA 캐시 백엔드 사용:
type=gha로 캐시를 GitHub Actions 캐시에 저장 - 레지스트리 캐시 사용:
type=registry로 캐시를 Docker Registry에 저장(예: GHCR)
정리하면:
- 리포지토리 내부 CI만 고려하고, 가장 간단하게 가려면
type=gha - 여러 워크플로/브랜치/리포지토리에서 캐시를 공유하거나, 더 강한 재현성을 원하면
type=registry
둘 다 장단점이 있으니 뒤에서 각각 예제를 제공합니다.
사전 준비: Buildx와 BuildKit을 표준으로 깔기
Docker의 차세대 빌드 엔진은 BuildKit이고, Actions에서는 docker/build-push-action이 사실상 표준입니다.
필수 액션 구성은 보통 다음 3개입니다.
docker/setup-qemu-action(멀티아키텍처가 필요할 때)docker/setup-buildx-actiondocker/build-push-action
기본 워크플로 뼈대
아래 예시는 main 브랜치 푸시 시 이미지를 빌드하고, 필요하면 푸시까지 할 수 있는 형태입니다.
name: build-image
on:
push:
branches: [ main ]
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- 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
이 상태는 “캐시가 없다”에 가깝습니다. 이제부터 캐시를 추가합니다.
방법 1: type=gha로 캐시 저장하기(가장 간단)
docker/build-push-action의 cache-from, cache-to에 type=gha를 넣으면, GitHub Actions 캐시 스토리지를 백엔드로 사용합니다.
GHA 캐시 적용 예시
- name: Build and push (with GHA cache)
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
포인트:
cache-from: type=gha로 이전 캐시를 가져옵니다.cache-to: type=gha,mode=max로 캐시를 최대한 풍부하게 저장합니다.mode=max는 캐시 크기가 커질 수 있지만, CI 가속 효과가 큽니다.
이 방식만으로도 “의존성 설치 레이어”가 잘 고정되어 있으면 빌드 시간이 크게 줄어듭니다.
방법 2: 레지스트리 캐시로 더 강한 공유 캐시 만들기
GHA 캐시는 편하지만, 캐시 공유 범위/제어 측면에서 레지스트리 캐시가 유리한 경우가 있습니다. 특히 다음 상황에서 유용합니다.
- 브랜치가 많고, 브랜치 간 캐시 재사용을 적극적으로 하고 싶다
- 워크플로가 여러 개라 캐시를 공통으로 쓰고 싶다
- 캐시를 이미지와 함께 “레지스트리”에 붙여 운영 흐름을 단순화하고 싶다
GHCR 레지스트리 캐시 예시
- name: Build and push (with registry cache)
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
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
여기서 ref는 캐시를 저장할 “태그”입니다. 보통 buildcache 같은 전용 태그를 만들어 운영 이미지 태그와 분리합니다.
주의:
- 레지스트리 로그인 권한이 필요합니다.
- 퍼블릭 리포라도 패키지 권한 설정에 따라 푸시가 막힐 수 있습니다.
Dockerfile이 캐시 친화적이어야 진짜 5배가 나온다
캐시를 붙였는데도 빨라지지 않는 이유는 대부분 Dockerfile 레이어 설계가 잘못되어 캐시가 자주 깨지기 때문입니다.
가장 흔한 실수: COPY . .가 너무 빠르다
예를 들어 Node.js 프로젝트에서 아래처럼 작성하면 작은 코드 변경에도 npm ci 레이어가 매번 다시 돕니다.
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
COPY . .가 바뀌면 그 뒤 레이어는 전부 무효화되기 때문입니다.
개선: 의존성 파일만 먼저 복사
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
이렇게 하면 앱 소스가 바뀌어도 package-lock.json이 그대로인 한 npm ci 레이어는 캐시 히트가 납니다.
BuildKit 캐시 마운트로 의존성 다운로드까지 가속하기
레이어 캐시만으로도 효과가 있지만, 패키지 매니저의 다운로드 캐시까지 살리면 더 빨라집니다. BuildKit의 RUN --mount=type=cache가 핵심입니다.
아래 기능을 쓰려면 Dockerfile 상단에 syntax 지시자를 두는 것이 안전합니다.
# syntax=docker/dockerfile:1.7
Node.js 예시: npm 캐시 마운트
# syntax=docker/dockerfile:1.7
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN \
npm ci
COPY . .
RUN npm run build
Python 예시: pip 캐시 마운트
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN \
pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
Go 예시: 모듈/빌드 캐시 마운트
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN \
go mod download
COPY . .
RUN \
CGO_ENABLED=0 go build -o app ./cmd/server
이 방식은 “의존성 다운로드”가 네트워크 상황에 좌우되는 문제를 크게 줄여 CI 시간을 안정화합니다.
멀티스테이지로 최종 이미지 레이어 변동을 줄이기
빌드 결과물과 런타임을 분리하면 최종 이미지가 더 작아지고, 런타임 레이어가 안정적으로 유지되어 캐시 효율이 더 좋아집니다.
Node.js 멀티스테이지 예시
# 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 node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY /app/dist ./dist
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
CMD ["node", "dist/index.js"]
포인트:
- 빌드 스테이지의 변경이 런타임 이미지 전체를 흔들지 않도록 분리
- 런타임에서
--omit=dev로 프로덕션 의존성만 설치
캐시가 깨지는 숨은 원인 7가지
캐시를 구성했는데도 매번 풀빌드가 난다면 아래를 의심합니다.
.dockerignore가 없어 컨텍스트에node_modules,dist,.git등이 포함됨- 의존성 파일(
package-lock.json,poetry.lock,go.sum)이 자주 바뀜 apt-get update를 매번 실행하고, 패키지 버전 고정이 없음ARG나ENV가 자주 바뀌고 그 뒤 레이어가 큼- 빌드 시각을 박는 스크립트가 레이어를 계속 변경(예:
version.txt생성) COPY . .가 너무 앞에 있어 모든 레이어가 연쇄 무효화- 멀티플랫폼 빌드에서 플랫폼별 캐시가 분리되는데 이를 고려하지 않음
.dockerignore 최소 예시
.git
node_modules
dist
.next
coverage
Dockerfile
.dockerignore
컨텍스트가 작아지면 업로드/해시 계산 시간이 줄고, “의도치 않은 파일 변경”으로 캐시가 깨지는 문제도 함께 줄어듭니다.
실제로 5배를 만들려면: 측정과 로그 확인
체감만으로는 개선이 맞는지 판단하기 어렵습니다. 다음을 권장합니다.
- 워크플로 실행 시간(전체)과
docker build단계 시간(부분)을 분리해 비교 - BuildKit 로그에서
CACHED가 붙는 레이어가 늘어나는지 확인 - 변경이 잦은 파일이 어느 레이어를 깨는지 역추적
docker/build-push-action은 기본적으로 BuildKit을 쓰고, 로그에 캐시 히트 여부가 드러납니다. 캐시가 잘 먹으면 의존성 설치 레이어가 CACHED로 바뀌고, 소스 변경분만 다시 실행됩니다.
운영 관점 체크리스트
마지막으로, CI 가속이 실제 배포 안정성으로 이어지려면 아래를 같이 챙기는 편이 좋습니다.
- 이미지 태그 전략:
latest만 쓰지 말고 커밋 SHA 태그 병행 - 캐시 태그 분리:
buildcache는 전용으로 두고 운영 태그와 분리 - 풀 실패 대비: 쿠버네티스에서
ImagePullBackOff가 나면 레지스트리 권한/태그/아키텍처를 우선 확인
이미지 풀 이슈까지 이어질 때의 점검 항목은 K8s ImagePullBackOff·ErrImagePull 원인 9가지에 정리해 두었습니다.
결론
GitHub Actions에서 Docker 빌드가 느린 이유는 “캐시가 휘발성”이기 때문이고, 해결책은 BuildKit 캐시를 외부로 내보내고 다시 가져오는 구조를 만드는 것입니다. 가장 빠른 적용은 cache-to: type=gha,mode=max와 cache-from: type=gha이며, 더 강한 공유/통제가 필요하면 레지스트리 캐시를 사용합니다.
여기에 캐시 친화적인 Dockerfile(의존성 파일 선복사, RUN --mount=type=cache, 멀티스테이지, .dockerignore)까지 결합하면, CI 빌드가 “매번 수분”에서 “수십 초”로 줄어드는 구간을 만들 수 있고, 그 결과가 흔히 말하는 5배 가속으로 체감됩니다.