Published on

Docker BuildKit 캐시 미적중, GitHub Actions 2배 빠르게

Authors

서버리스 CI 환경인 GitHub Actions에서 Docker 이미지를 빌드하다 보면, 분명 캐시를 켰는데도 매번 RUN npm ci 같은 무거운 단계가 다시 실행되는 경우가 많습니다. 로컬에서는 빠른데 CI만 느린 이유는 대부분 캐시 저장 위치가 휘발성이거나, 레이어가 조금만 바뀌어도 앞단부터 전부 무효화되거나, BuildKit 캐시를 내보내고(import/export)하는 설정이 불완전하기 때문입니다.

이 글에서는 BuildKit 캐시 미적중의 대표 패턴을 재현 가능한 형태로 분해하고, GitHub Actions에서 원격 캐시(GHA cache) + 레이어 안정화 + 멀티스테이지 최적화로 빌드 시간을 체감 2배 이상 줄이는 설정을 제공합니다.

참고: CI 빌드 최적화 대안으로 Kaniko를 고려 중이라면 Kaniko로 루트리스 멀티스테이지 빌드 60% 단축도 같이 비교해보면 선택이 쉬워집니다.

BuildKit 캐시가 CI에서 자주 깨지는 이유

BuildKit 캐시는 “이전 레이어의 결과물”을 재사용하는데, GitHub Actions에서는 다음 조건이 겹치면 캐시가 쉽게 미적중됩니다.

1) 러너가 매번 새로 떠서 로컬 캐시가 없다

GitHub-hosted runner는 작업이 끝나면 디스크가 사라집니다. 즉 docker build가 로컬에 쌓아둔 레이어 캐시는 다음 실행에서 존재하지 않습니다. 그래서 원격 캐시 export/import가 필수입니다.

2) COPY . .가 너무 빨라서 너무 많이 깨뜨린다

Dockerfile 초반에 COPY . .를 해버리면, 소스 코드의 아주 작은 변경(README, 테스트 파일, 버전 파일 등)도 이후 모든 레이어의 캐시 키를 바꿉니다. 그 결과 RUN npm ci 같은 단계가 매번 다시 실행됩니다.

3) .dockerignore가 부실해서 “불필요한 변경”이 캐시를 깨뜨린다

예를 들어 node_modules, dist, .git, 테스트 출력물 등이 빌드 컨텍스트에 포함되면, 매 커밋마다 컨텍스트 해시가 달라지고 캐시가 흔들립니다.

4) cache-to/cache-from가 이미지 레지스트리 캐시와 섞여 꼬인다

레지스트리 기반 캐시(type=registry)는 설정이 단순하지만, 권한/태그/만료 정책/멀티플랫폼 조합에 따라 캐시가 기대대로 안 붙는 경우가 있습니다. GitHub Actions에서는 type=gha가 보통 가장 간단하고 안정적입니다.

5) 멀티플랫폼(linux/amd64, linux/arm64) 빌드가 캐시를 분산시킨다

플랫폼이 다르면 레이어가 다릅니다. 단일 플랫폼으로 캐시를 먼저 안정화한 뒤 멀티플랫폼으로 확장하는 게 좋습니다.

“캐시가 붙었는지” 확인하는 로그 포인트

BuildKit이 캐시를 맞추면 보통 로그에 CACHED가 표시됩니다. 반대로 매번 실행되면 RUN ...가 실제로 수행됩니다.

GitHub Actions에서 docker/build-push-action을 쓰면, provenance, sbom 같은 옵션이 추가로 로그를 늘릴 수 있으니 우선은 캐시 여부만 보려면 빌드 출력이 너무 숨겨지지 않게 설정하는 편이 좋습니다.

가장 추천하는 해법: docker/build-push-action + type=gha

아래는 GitHub Actions 캐시 스토리지에 BuildKit 캐시를 저장하고, 다음 실행에서 다시 가져오는 가장 흔한 정답 구성입니다.

GitHub Actions 워크플로 예시

name: build

on:
  push:
    branches: [ main ]
  pull_request:

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

핵심은 cache-from: type=ghacache-to: type=gha,mode=max입니다.

  • type=gha: GitHub Actions 캐시 backend를 사용
  • mode=max: 가능한 많은 중간 레이어/메타데이터를 저장(캐시 적중률 상승)

이 설정만으로도 “러너가 휘발성이라 캐시가 없다” 문제는 거의 해결됩니다. 하지만 여전히 Dockerfile이 잘못 짜여 있으면 캐시가 계속 깨집니다.

Dockerfile에서 캐시를 “안 깨지게” 만드는 레이어 설계

캐시 최적화의 기본은 변경이 잦은 파일을 최대한 뒤로 미루는 것입니다.

아래는 Node.js 애플리케이션을 예로 든, 캐시 친화적인 멀티스테이지 Dockerfile입니다.

# syntax=docker/dockerfile:1.7

FROM node:22-alpine AS deps
WORKDIR /app

# 의존성 정의 파일만 먼저 복사 (캐시의 핵심)
COPY package.json package-lock.json ./

# npm 캐시를 BuildKit 캐시 마운트로 유지
RUN --mount=type=cache,target=/root/.npm \
    npm ci

FROM node:22-alpine AS build
WORKDIR /app

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

# 이제 소스 복사 (자주 바뀌는 부분은 뒤로)
COPY . .

RUN npm run build

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

# 런타임에 필요한 산출물만 복사
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json

EXPOSE 3000
CMD ["node", "dist/server.js"]

여기서 중요한 포인트는 다음과 같습니다.

  1. COPY package.json package-lock.json ./를 먼저 해서 의존성 레이어를 안정화
  2. RUN --mount=type=cache,target=...로 패키지 매니저 캐시를 BuildKit 캐시로 유지
  3. COPY . .는 최대한 뒤로 미룸
  4. 최종 이미지에는 dist 등 필요한 결과만 포함

만약 ESM 전환/Node 버전 차이로 빌드가 흔들린다면, CI 속도 이전에 빌드 재현성을 잡는 게 우선입니다. 관련해서는 Node.js ESM 전환 시 ERR_REQUIRE_ESM 해결 가이드도 참고할 만합니다.

.dockerignore로 “컨텍스트 해시”부터 안정화

캐시가 미적중되는 흔한 이유는 코드가 아니라 빌드 컨텍스트에 불필요한 파일이 섞이는 것입니다. 아래는 Node.js 기준의 예시입니다.

node_modules
npm-debug.log
.yarn
.pnpm-store

.git
.github

dist
coverage

.DS_Store
.env

.dockerignore가 없거나 부실하면, 예를 들어 coverage가 매번 바뀌는 것만으로도 COPY . . 단계의 해시가 바뀌고 이후 레이어가 전부 무효화됩니다.

RUN 단계에서 네트워크 의존을 줄이면 캐시 체감이 커진다

BuildKit 캐시가 붙어도, 다음 같은 요소가 있으면 체감 속도가 떨어집니다.

  • 빌드 중 외부에서 매번 다운로드(툴체인, OS 패키지)
  • 레지스트리 pull이 느림
  • 패키지 매니저가 매번 인덱스를 갱신

해결책은 다음 조합이 실전에서 효과가 좋습니다.

  • OS 패키지 설치는 최대한 상단에서 고정하고, 필요 최소만 설치
  • apk addapt-get은 한 레이어에 묶고 불필요한 update 반복 제거
  • 패키지 매니저 캐시는 --mount=type=cache 활용

예시(Alpine):

RUN apk add --no-cache libc6-compat

예시(Debian/Ubuntu 계열):

RUN apt-get update \
  && apt-get install -y --no-install-recommends ca-certificates \
  && rm -rf /var/lib/apt/lists/*

캐시가 여전히 미적중될 때 체크리스트

여기부터는 “설정은 했는데도 계속 느린” 상황에서 원인을 빠르게 좁히는 체크리스트입니다.

1) 빌드 플랫폼이 바뀌지 않았나

platforms: linux/amd64platforms: linux/arm64는 캐시를 공유하지 못합니다. 멀티플랫폼 빌드를 한다면, 먼저 단일 플랫폼으로 캐시 적중률을 확인하세요.

2) Dockerfile의 앞쪽 레이어가 자주 바뀌지 않나

  • ARG가 앞단에 있고 값이 매번 바뀜(예: ARG BUILD_DATE)
  • LABEL에 커밋 SHA를 너무 앞에서 박음
  • COPY . .가 너무 빨리 등장

특히 ARG는 캐시 키를 바꾸는 대표 원인입니다. 커밋 SHA 같은 값은 가능한 마지막에 넣거나, 정말 필요할 때만 사용하세요.

3) 빌드 컨텍스트가 커지지 않았나

actions/checkout 옵션이나 서브모듈, 생성 파일로 인해 컨텍스트가 커지면 업로드/해시 계산 비용도 증가합니다.

4) 캐시 스코프가 PR과 main에서 분리되어 있지 않나

type=gha는 기본적으로 워크플로/브랜치에 따라 캐시가 분리될 수 있습니다. PR에서만 느리다면 main에서 만들어진 캐시가 PR에 재사용되지 않는 구조일 수 있습니다. 이때는 캐시 키 전략을 점검하거나, 빌드 전략을 PR과 main에서 다르게 가져가는 것도 방법입니다.

5) “캐시가 붙었는데도 느린” 경우: 원인은 캐시가 아니라 병목이다

예를 들어 빌드는 CACHED인데도 전체 job이 느리다면, 병목은 다음일 수 있습니다.

  • 이미지 push가 느림(레이어 수/크기)
  • 취약점 스캔 단계가 느림
  • 테스트/린트가 느림

이런 경우는 빌드 캐시가 아니라 파이프라인 전반을 프로파일링해야 합니다. 캐시 문제를 추적하는 접근 자체는 인증 토큰 캐시나 JWK 캐시 트러블슈팅과 유사한데, 사고방식은 JWT invalid signature - JWK 회전·캐시 점검법에서 소개한 “캐시가 어디에 저장되고 언제 무효화되는가” 프레임이 그대로 적용됩니다.

실전 팁: “2배 빠르게” 만드는 가장 흔한 조합

팀에서 가장 자주 효과를 본 조합을 요약하면 아래 3가지입니다.

  1. GitHub Actions에서 cache-to/cache-fromtype=gha로 고정
  2. Dockerfile에서 의존성 설치 전에 package.json 계열만 먼저 COPY
  3. --mount=type=cache로 패키지 매니저 캐시를 BuildKit 캐시로 흡수

이 조합이면 보통 다음이 즉시 개선됩니다.

  • PR마다 npm ci/pip install 재실행 빈도 감소
  • 빌드 시간이 커밋 단위로 출렁이는 현상 완화
  • 러너 교체/병렬 실행에도 캐시가 유지되어 평균 시간이 안정화

마무리

GitHub Actions에서 Docker BuildKit 캐시가 미적중되는 문제는 “캐시를 켰는데 왜 느리지?”가 아니라, 대개 캐시를 저장하지 않았거나(휘발성 러너), 저장했어도 레이어 설계가 캐시를 계속 깨뜨리는 구조인 경우가 많습니다.

먼저 type=gha로 원격 캐시를 고정하고, 그 다음 Dockerfile을 COPY 순서 중심으로 재구성하세요. 마지막으로 .dockerignore와 패키지 매니저 캐시 마운트까지 적용하면, 대부분의 프로젝트에서 CI Docker 빌드가 체감 2배 이상 빨라지고, 무엇보다 빌드 시간이 안정적으로 예측 가능해집니다.