Published on

GitHub Actions로 Docker 멀티스테이지·캐시 최적화

Authors

서버 배포 파이프라인에서 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 관점에서는 다음이 핵심입니다.

  1. 의존성 설치 단계와 소스 빌드 단계를 분리하면, 소스 코드가 자주 바뀌어도 의존성 레이어는 재사용됩니다.
  2. 최종 런타임 이미지에는 빌드 툴체인이 포함되지 않아 푸시/풀 비용도 내려갑니다.
  3. 테스트/빌드/런타임 단계를 나누면, 특정 단계만 캐시가 깨져도 전체를 다시 만들지 않습니다.

아래는 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 --mount=type=cache,target=/root/.npm \
    npm ci

FROM node:20-alpine AS build
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules

# 소스는 나중에 복사 (소스 변경이 deps 캐시를 깨지 않게)
COPY . .

RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# 런타임에 필요한 것만 복사
COPY --from=build /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 buildxdocker/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-tomode=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에서 이미지를 잘 만들어도, 쿠버네티스에서 풀 단계에서 막히는 경우가 많습니다. 특히 레지스트리 권한/인증서/네트워크 문제가 대표적입니다.

정리: “멀티스테이지 + BuildKit 캐시”를 기본값으로

  • 멀티스테이지는 이미지 크기뿐 아니라 캐시 효율을 올리는 구조적 장치입니다.
  • Dockerfile은 COPY 순서가 성능을 좌우합니다. 의존성 정의 파일을 먼저 복사하고 설치 단계를 분리하세요.
  • GitHub Actions에서는 docker/build-push-actioncache-to / cache-from 를 사용해 캐시를 CI 밖으로 끌어내는 것이 핵심입니다.
  • 팀/서비스가 커질수록 type=registry 캐시가 재현성과 적중률 측면에서 유리합니다.

위 설정을 적용하면, “PR마다 5분 빌드”가 “대부분 1분 내외”로 줄어드는 구간이 분명히 나옵니다. 이후에는 매트릭스 병렬화, 테스트 단계 분리, 취약점 스캔 등으로 파이프라인을 확장해도 CI 시간이 폭증하지 않게 관리할 수 있습니다.