Published on

GitHub Actions Docker 레이어 캐시로 배포 3배 빠르게

Authors

서버 배포 파이프라인에서 시간이 가장 많이 새는 구간은 대개 docker build 입니다. 특히 Node.js·Python처럼 의존성 설치가 무거운 프로젝트는 코드 한 줄 바뀌어도 매번 npm ci 또는 pip install 이 재실행되며 5~15분이 쉽게 날아갑니다.

GitHub Actions에서는 Docker 레이어 캐시를 제대로 구성하면, 변경이 없는 레이어를 재사용해 빌드 시간을 크게 줄일 수 있습니다. 이 글에서는 BuildKit 기반의 docker/build-push-action 을 사용해 레이어 캐시로 빌드·푸시·배포를 3배 빠르게 만드는 실전 구성을 다룹니다.

또한 배포 대상이 Kubernetes라면 빌드가 빨라져도 ImagePullBackOff 같은 이슈로 배포가 지연될 수 있으니, 운영 중 자주 만나는 문제는 K8s ImagePullBackOff - ErrImagePull·401 빠른 해결도 함께 참고하면 좋습니다. AWS에 OIDC로 로그인하는 패턴을 쓴다면 GitHub Actions OIDC로 AWS STS AssumeRole 실패 해결도 연결해서 보면 파이프라인 안정성이 올라갑니다.

Docker 레이어 캐시가 “먹는” 조건

Docker 빌드는 Dockerfile의 각 명령이 레이어가 되고, 레이어 단위로 캐시가 잡힙니다. 캐시가 재사용되려면 다음이 동일해야 합니다.

  • Dockerfile의 해당 단계 명령 문자열
  • 해당 단계로 들어가는 입력(이전 레이어 결과, COPY 된 파일 내용)
  • Build arg, target stage 등 빌드 컨텍스트

즉, 의존성 설치 레이어를 살리려면 아래가 핵심입니다.

  • COPY . . 를 너무 일찍 하지 말 것
  • lockfile(예: package-lock.json, pnpm-lock.yaml)만 먼저 복사해서 npm ci 레이어를 분리할 것
  • 멀티 스테이지를 사용해 런타임 이미지를 작게 만들 것

캐시 전략 2가지: gha vs 레지스트리 캐시

GitHub Actions에서 흔히 쓰는 캐시는 크게 두 갈래입니다.

1) type=gha 캐시

  • 장점: 설정이 간단하고 빠름
  • 단점: 기본적으로 GitHub Actions 캐시 스토리지에 의존(조직 정책/용량/보존기간 영향)
  • 적합: 단일 리포지토리, 단일 워크플로우 중심

2) 레지스트리 캐시(type=registry)

  • 장점: 캐시를 이미지 레지스트리에 저장하므로 러너가 바뀌어도 잘 유지됨, 여러 워크플로우/브랜치에서도 활용 가능
  • 단점: 레지스트리 트래픽/스토리지 비용 증가 가능, 권한 설정 필요
  • 적합: 팀 규모가 있고 빌드가 잦은 서비스, 멀티 리포/멀티 워크플로우

실무에서는 gha 캐시로 1차 가속 + 레지스트리 캐시로 2차 보험 조합이 가장 무난합니다.

Dockerfile부터 캐시 친화적으로 바꾸기(Node.js 예시)

아래는 Next.js 또는 일반 Node.js 앱을 가정한 멀티 스테이지 Dockerfile 예시입니다. 핵심은 lockfile 기반으로 npm ci 레이어를 분리하는 것입니다.

# syntax=docker/dockerfile:1.7

FROM node:20-alpine AS deps
WORKDIR /app

# 1) 의존성 메타만 먼저 복사 (캐시 핵심)
COPY package.json package-lock.json ./

# 2) 네트워크가 무거운 단계는 최대한 앞에서 고정
RUN npm ci

FROM node:20-alpine AS builder
WORKDIR /app

# deps 레이어 재사용
COPY --from=deps /app/node_modules ./node_modules

# 3) 애플리케이션 소스는 그 다음 복사
COPY . .

# 4) 빌드
RUN npm run build

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

# 런타임에 필요한 결과물만 복사
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules

EXPOSE 3000
CMD ["npm", "run", "start"]

캐시가 깨지는 대표 패턴

  • COPY . .npm ci 보다 먼저 오면, 소스 파일 변경마다 의존성 설치 레이어가 깨집니다.
  • lockfile이 자주 바뀌는 구조(예: 의존성을 매 커밋마다 손대는 상황)도 캐시 효율이 떨어집니다.
  • 빌드 컨텍스트에 불필요한 파일이 많으면(로그, 테스트 산출물) COPY 단계 입력이 바뀌어 캐시가 흔들립니다.

.dockerignore 는 캐시 안정성과 업로드 시간을 동시에 줄입니다.

node_modules
.next
.git
.github
coverage
*.log

GitHub Actions: Buildx + 레이어 캐시 구성

가장 표준적인 방식은 docker/setup-buildx-actiondocker/build-push-action 조합입니다. 아래 워크플로우는 다음을 수행합니다.

  • Buildx 활성화
  • type=gha 캐시 사용
  • 레지스트리에 이미지 푸시
  • 태그를 shalatest 로 운영
name: build-and-push

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 QEMU
        uses: docker/setup-qemu-action@v3

      - 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 }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

mode=max 를 쓰는 이유

cache-to 에서 mode=max 는 더 많은 중간 레이어를 캐시에 남겨 재사용 폭을 넓힙니다. 용량은 늘 수 있지만, 대부분의 CI에서는 빌드 시간 절감 효과가 더 큽니다.

레지스트리 캐시까지 붙여 “러너가 바뀌어도” 빠르게

gha 캐시는 워크플로우/브랜치/보존 정책에 영향을 받을 수 있습니다. 팀에서 빌드가 잦고 러너 환경이 자주 바뀌면 레지스트리 캐시도 같이 두는 편이 안정적입니다.

아래 예시는 GHCR에 캐시 이미지를 별도 태그로 저장합니다.

      - name: Build and push (with registry cache)
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: |
            type=gha
            type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
          cache-to: |
            type=gha,mode=max
            type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max

이 구성을 쓰면 다음 상황에서 특히 효과가 큽니다.

  • 캐시가 만료되었거나, 다른 워크플로우에서 빌드했거나
  • self-hosted runner를 여러 대 두고 라운드로빈으로 돌아가거나
  • PR 빌드와 main 빌드가 서로 캐시를 공유해야 하거나

빌드 시간을 3배 줄이는 “체감 포인트”

레이어 캐시는 단순히 docker build 시간을 줄이는 것 이상으로, 배포 리드타임을 줄이는 연쇄 효과가 있습니다.

  • 의존성 설치 레이어 재사용으로 빌드가 수분 단위로 단축
  • 이미지 크기가 작아지면 레지스트리 푸시/풀도 단축
  • 배포 시스템(K8s, ECS 등)에서 노드가 이미지를 더 빨리 가져오므로 롤링 업데이트가 빨라짐

특히 Next.js 같은 앱은 빌드 산출물이 크고 의존성 트리가 커서, 캐시가 없을 때와 있을 때 편차가 큽니다. Next.js 성능 최적화는 런타임뿐 아니라 빌드 파이프라인에도 영향을 주니, 프론트 쪽 병목은 Next.js 14 RSC fetch waterfall 끊는 캐시·prefetch 최적화처럼 애플리케이션 레벨 캐시 전략도 같이 보면 전체 릴리즈 시간이 더 줄어듭니다.

자주 터지는 함정과 해결책

1) 캐시가 전혀 안 잡히는 경우

  • Dockerfile 상단에 # syntax=docker/dockerfile:1.7 같은 BuildKit 문법 선언이 없는 경우
  • 액션은 Buildx인데 로컬에서만 재현되는 Dockerfile 기능을 쓴 경우
  • 빌드 컨텍스트가 매번 달라지는 경우(예: 빌드 전에 버전 파일을 생성하고 COPY 입력이 흔들림)

해결은 “변하지 않는 입력을 앞 단계로” 옮기는 것입니다. lockfile, 툴체인 설치, OS 패키지 설치 같은 무거운 단계는 최대한 고정하세요.

2) 멀티 아키텍처 빌드가 느려진 경우

linux/amd64linux/arm64 를 동시에 만들면 QEMU 에뮬레이션으로 시간이 늘 수 있습니다. 캐시가 있어도 첫 빌드는 오래 걸립니다.

          platforms: linux/amd64,linux/arm64

대응:

  • main 브랜치만 멀티 아키텍처, PR은 단일 아키텍처로 제한
  • 베이스 이미지/의존성 레이어는 최대한 캐시에 남도록 구성

3) 빌드는 빨라졌는데 배포가 느린 경우

Kubernetes라면 이미지 풀 단계가 병목일 수 있습니다.

  • 이미지 태그를 매번 latest 만 쓰면 노드 캐시 활용이 꼬일 수 있음
  • 프라이빗 레지스트리 인증 문제로 ErrImagePull 이 반복될 수 있음

이 경우는 태그 전략을 sha 기반으로 바꾸고, 풀 정책과 레지스트리 인증을 점검하세요. 빠른 체크리스트는 위에서 언급한 K8s ImagePullBackOff - ErrImagePull·401 빠른 해결이 도움이 됩니다.

운영에 바로 쓰는 권장 체크리스트

  • Dockerfile
    • lockfile만 먼저 COPY 하고 의존성 설치 레이어 분리
    • .dockerignore 로 컨텍스트 최소화
    • 멀티 스테이지로 런타임 이미지 축소
  • GitHub Actions
    • docker/setup-buildx-action + docker/build-push-action 사용
    • cache-fromcache-to 를 반드시 설정
    • 가능하면 gha + registry 캐시를 조합
  • 배포
    • 태그는 sha 고정, latest 는 보조
    • K8s라면 풀/인증 문제를 별도로 모니터링

마무리

GitHub Actions에서 Docker 레이어 캐시는 “옵션”이 아니라, CI 비용과 배포 리드타임을 줄이는 기본기입니다. 핵심은 복잡하지 않습니다.

  • Dockerfile을 캐시 친화적으로 재구성하고
  • Buildx 기반 액션에서 cache-from / cache-to 를 켜고
  • 필요하면 레지스트리 캐시까지 붙여 러너 변화에도 흔들리지 않게 만든다

이 3가지만 지켜도, 의존성 설치가 무거운 서비스는 빌드 시간이 체감 3배 이상 줄어드는 경우가 흔합니다.