- Published on
GitHub Actions로 Docker 멀티스테이지·캐시 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 배포 파이프라인에서 Docker 이미지를 매번 docker build 로 처음부터 만들면, CI 시간과 비용이 눈에 띄게 증가합니다. 특히 Node.js, Python, Java처럼 의존성 설치가 무거운 프로젝트는 캐시 전략이 없으면 PR 하나당 수 분이 쉽게 날아갑니다.
이 글에서는 GitHub Actions에서 Docker 멀티스테이지 빌드와 BuildKit 기반 캐시를 결합해 다음을 달성하는 방법을 다룹니다.
- 멀티스테이지로 최종 이미지 크기 감소
- 의존성 레이어 캐시로 빌드 시간 단축
cache-to/cache-from를 이용한 레지스트리 캐시 공유- PR에서는 빌드만, main에서는 빌드 + 푸시
- 태깅(커밋 SHA, 브랜치, semver) 전략
병렬화 자체로 CI를 줄이는 접근도 유효하므로, 워크플로를 여러 이미지/서비스로 확장한다면 GitHub Actions 매트릭스 빌드로 CI 50% 줄이기도 함께 참고하면 좋습니다.
멀티스테이지 빌드가 캐시 최적화의 시작인 이유
멀티스테이지는 단순히 “이미지 크기 줄이기” 용도가 아닙니다. CI 관점에서는 다음이 핵심입니다.
- 의존성 설치 단계와 소스 빌드 단계를 분리하면, 소스 코드가 자주 바뀌어도 의존성 레이어는 재사용됩니다.
- 최종 런타임 이미지에는 빌드 툴체인이 포함되지 않아 푸시/풀 비용도 내려갑니다.
- 테스트/빌드/런타임 단계를 나누면, 특정 단계만 캐시가 깨져도 전체를 다시 만들지 않습니다.
아래는 Node.js 예시이지만, Python(예: pip wheel), Java(예: mvn dependency:go-offline)도 동일한 패턴으로 적용 가능합니다.
Dockerfile: 캐시가 잘 먹는 멀티스테이지 패턴
핵심은 “캐시가 잘 먹는 파일 복사 순서”입니다. 의존성 정의 파일을 먼저 복사하고 설치한 뒤, 마지막에 소스를 복사해야 변경 범위가 최소화됩니다.
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS deps
WORKDIR /app
# 의존성 파일만 먼저 복사 (캐시 핵심)
COPY package.json package-lock.json ./
# BuildKit 캐시 마운트로 npm 캐시 재사용
RUN \
npm ci
FROM node:20-alpine AS build
WORKDIR /app
COPY /app/node_modules ./node_modules
# 소스는 나중에 복사 (소스 변경이 deps 캐시를 깨지 않게)
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# 런타임에 필요한 것만 복사
COPY /app/dist ./dist
COPY package.json ./
# 런타임 의존성만 별도로 구성하고 싶다면 여기서 npm prune를 고려
# RUN npm prune --omit=dev
EXPOSE 3000
CMD ["node", "dist/server.js"]
자주 놓치는 포인트
- 상단의
# syntax=...는 BuildKit 기능(캐시 마운트 등)을 쓰기 위해 중요합니다. 이 줄이 없으면--mount=type=cache가 동작하지 않거나 제한될 수 있습니다. COPY . .를 너무 일찍 하면, README 한 줄 바뀌어도 의존성 설치 레이어가 깨집니다..dockerignore를 반드시 설정해 불필요한 파일이 빌드 컨텍스트에 들어오지 않게 해야 합니다.
예시 .dockerignore:
node_modules
.git
.github
Dockerfile
README.md
*.log
dist
GitHub Actions: buildx + 레지스트리 캐시로 CI 가속
GitHub Actions에서 Docker 캐시를 제대로 쓰려면 docker buildx 와 docker/build-push-action 조합이 사실상 표준입니다. 이때 캐시는 크게 두 가지가 있습니다.
type=gha: GitHub Actions 캐시 저장소 사용(간편, 빠름)type=registry: 레지스트리에 캐시를 푸시(러너/워크플로가 달라도 공유 가능)
프로젝트/조직 규모가 커질수록 type=registry 가 안정적으로 체감됩니다. 다만 레지스트리 권한과 비용을 고려해야 합니다.
아래는 GitHub Container Registry(ghcr.io) 기준으로, PR에서는 빌드만 하고 main에서는 푸시까지 수행하며, 캐시를 레지스트리에 저장하는 예시입니다.
name: docker-build
on:
pull_request:
push:
branches: ["main"]
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
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: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository }}
tags: |
type=sha
type=ref,event=branch
- name: Build (and push on main)
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
왜 mode=max 인가
cache-to 의 mode=max 는 가능한 많은 레이어를 캐시에 저장합니다. 멀티스테이지 빌드에서 특히 효과가 좋습니다. 반대로 캐시 크기가 부담이면 mode=min 으로 줄일 수 있습니다.
PR에서도 캐시를 쓰고 싶다면
PR은 보통 레지스트리 로그인/푸시를 막아두는데, 그러면 type=registry 캐시를 읽기 어렵습니다. 이 경우 선택지는 다음입니다.
cache-from만 허용(읽기 권한만 부여)- PR에서는
type=gha를 쓰고, main에서만type=registry로 승격 - 사내 레지스트리(ECR 등)에서 읽기 전용 토큰을 발급
type=gha 캐시를 섞는 하이브리드 구성
레지스트리 캐시가 가장 범용적이지만, 설정이 번거롭거나 권한 이슈가 있으면 type=gha 만으로도 큰 개선이 가능합니다.
- name: Build with GHA cache
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: local/test:sha
cache-from: type=gha
cache-to: type=gha,mode=max
다만 type=gha 는 워크플로/브랜치/키에 따라 캐시 적중률이 달라질 수 있어, 여러 저장소/여러 서비스로 확장할수록 레지스트리 캐시가 더 예측 가능합니다.
캐시가 깨지는 대표 원인과 해결 체크리스트
1) 빌드 컨텍스트에 불필요한 파일이 포함됨
- 해결:
.dockerignore강화 - 특히
.git이 들어가면 커밋마다 컨텍스트가 바뀌어 캐시가 흔들립니다.
2) 의존성 설치 전에 소스를 복사함
- 해결: 의존성 파일만 먼저
COPY하고 설치
3) 타임스탬프/환경값이 레이어를 오염
- 예:
RUN echo $(date)같은 명령은 매번 캐시를 깨뜨립니다. - 해결: 빌드에 결정적이지 않은 값은 레이어에 포함하지 않기
4) 패키지 매니저가 네트워크 상태에 민감
- 해결: BuildKit 캐시 마운트 사용(
--mount=type=cache) - Python이라면
pip다운로드 캐시, Java라면 Maven 로컬 저장소를 캐시 마운트로 분리하는 방식이 유사합니다.
태깅 전략: 운영에서 중요한 것은 “재현 가능성”
이미지 태그는 보통 다음 3종을 같이 씁니다.
sha태그:type=sha로 커밋 단위 재현- 브랜치 태그:
main,develop등 최신 포인터 - 릴리스 태그:
v1.2.3같은 semver
docker/metadata-action 을 쓰면 태깅/라벨링을 표준화할 수 있고, 나중에 어떤 커밋이 어떤 이미지로 배포됐는지 추적이 쉬워집니다.
멀티서비스로 확장: 매트릭스 + 캐시 키 설계
서비스가 api, worker, web 처럼 여러 개라면 워크플로를 매트릭스로 돌리는 것이 일반적입니다. 이때 캐시 레퍼런스를 서비스별로 분리해야 캐시 오염을 막습니다.
예를 들어 ref=...:buildcache-api 처럼 서비스별 캐시 태그를 두는 방식입니다. 병렬화 자체로 시간을 더 줄이는 방법은 GitHub Actions 매트릭스 빌드로 CI 50% 줄이기에서 자세히 다룬 패턴과 결합할 수 있습니다.
간단 예시:
strategy:
matrix:
service: ["api", "web"]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: ./services/${{ matrix.service }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-${{ matrix.service }}
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-${{ matrix.service }},mode=max
운영 배포에서 자주 만나는 후속 이슈
CI에서 이미지를 잘 만들어도, 쿠버네티스에서 풀 단계에서 막히는 경우가 많습니다. 특히 레지스트리 권한/인증서/네트워크 문제가 대표적입니다.
- ECR 권한 문제로
ImagePullBackOff와403이 뜨는 케이스는 Kubernetes ImagePullBackOff - ECR 403 해결 가이드에서 체크리스트 형태로 정리되어 있습니다. - 클러스터에서 인증서 신뢰 문제로
x509 unknown authority가 발생하면 EKS Pod에서 x509 unknown authority 오류 해결 흐름대로 원인을 좁히는 것이 빠릅니다.
정리: “멀티스테이지 + BuildKit 캐시”를 기본값으로
- 멀티스테이지는 이미지 크기뿐 아니라 캐시 효율을 올리는 구조적 장치입니다.
- Dockerfile은
COPY순서가 성능을 좌우합니다. 의존성 정의 파일을 먼저 복사하고 설치 단계를 분리하세요. - GitHub Actions에서는
docker/build-push-action과cache-to/cache-from를 사용해 캐시를 CI 밖으로 끌어내는 것이 핵심입니다. - 팀/서비스가 커질수록
type=registry캐시가 재현성과 적중률 측면에서 유리합니다.
위 설정을 적용하면, “PR마다 5분 빌드”가 “대부분 1분 내외”로 줄어드는 구간이 분명히 나옵니다. 이후에는 매트릭스 병렬화, 테스트 단계 분리, 취약점 스캔 등으로 파이프라인을 확장해도 CI 시간이 폭증하지 않게 관리할 수 있습니다.