- Published on
GitHub Actions로 Docker 멀티스테이지·캐시 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/백엔드든 프론트든, CI에서 Docker 이미지를 매 커밋마다 빌드하다 보면 가장 먼저 병목이 드러나는 지점이 의존성 설치와 레이어 캐시 미스입니다. 특히 GitHub Actions는 러너가 매번 새로 뜨기 때문에 로컬에서 잘 되던 Docker 캐시 전략이 그대로 통하지 않습니다.
이 글에서는 다음을 목표로 합니다.
- 멀티스테이지 Dockerfile로 이미지 크기와 공격면을 줄이기
- BuildKit 기반 캐시를 GitHub Actions에서 지속시키기(
cache-to,cache-from) - 레이어 캐시가 깨지는 흔한 원인을 제거해 “항상 빠른” 빌드를 만들기
캐시가 안 먹는 전형적인 실수들은 따로 정리해둔 글도 함께 참고하면 좋습니다: GitHub Actions 캐시가 안 먹을 때 터지는 7가지 함정
멀티스테이지 빌드가 CI에 특히 중요한 이유
멀티스테이지는 단순히 최종 이미지 크기만 줄이는 기법이 아닙니다. CI에서 멀티스테이지를 잘 쓰면 다음 이점이 큽니다.
- 캐시 경계가 명확해진다: 빌드 단계(컴파일/번들링)와 런타임 단계(실행 파일/정적 산출물) 분리
- 보안/운영 안정성: 런타임 이미지에 빌드 도구를 넣지 않음(예:
gcc,node-gyp,mvn) - 재빌드 비용 감소: 의존성 설치 레이어를 최대한 위로 올려 변경에 둔감하게 구성 가능
핵심은 “자주 바뀌는 것”을 Dockerfile 아래쪽으로, “덜 바뀌는 것”을 위쪽으로 배치하는 것입니다.
Dockerfile 튜닝: 캐시가 오래 살아남는 레이어 설계
아래는 Node.js 기반 서비스 예시입니다. 포인트는 package.json/package-lock.json만 먼저 복사해 의존성 설치 레이어를 고정시키고, BuildKit 캐시 마운트를 활용해 npm 캐시를 유지하는 것입니다.
# syntax=docker/dockerfile:1.6
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
# deps 레이어 재사용
COPY /app/node_modules ./node_modules
# 소스 복사 (자주 바뀌는 레이어)
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# 런타임에 필요한 산출물만
COPY /app/dist ./dist
COPY /app/package.json ./package.json
# 필요 시 런타임 의존성만 별도 설치하는 패턴도 가능
# (하지만 npm ci를 runner에서 다시 하면 캐시 설계가 달라짐)
EXPOSE 3000
CMD ["node", "dist/server.js"]
자주 캐시를 깨뜨리는 Dockerfile 패턴
COPY . .를 너무 일찍 수행- 빌드 단계에서
ARG를 많이 사용하고 값이 자주 바뀜(예: 커밋 SHA를ARG로 넣고 곧바로RUN에서 사용) RUN apk add ...같은 OS 패키지 설치를 여러 레이어로 쪼개거나, 업데이트/설치 순서가 들쑥날쑥함
OS 패키지는 가능하면 한 레이어로 묶고, 변경 가능성을 낮추는 편이 좋습니다.
RUN apk add --no-cache libc6-compat openssl
GitHub Actions에서 BuildKit 캐시를 “진짜로” 지속시키기
GitHub Actions에서 Docker 레이어 캐시는 기본적으로 지속되지 않습니다. 그래서 docker buildx + cache-to/cache-from 구성이 사실상 표준입니다.
여기서는 많이 쓰는 조합인 docker/setup-buildx-action과 docker/build-push-action을 사용합니다.
기본 워크플로 예시: GHCR로 푸시 + GitHub 캐시 사용
name: build-and-push
on:
push:
branches: ["main"]
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
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 }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
이 구성의 핵심은 다음 두 줄입니다.
cache-from: type=ghacache-to: type=gha,mode=max
type=gha는 GitHub Actions 캐시 스토리지를 BuildKit 캐시 백엔드로 쓰는 방식입니다. mode=max는 가능한 많은 중간 레이어 정보를 남겨 캐시 히트율을 올립니다.
캐시 키를 직접 관리해야 할까?
type=gha는 내부적으로 적절한 키를 관리해주지만, 다음 상황에서는 “캐시 분리”가 필요할 수 있습니다.
- 브랜치별로 캐시가 섞이면 안 되는 경우
main과release가 서로 다른 의존성 그래프를 갖는 경우- 모노레포에서 서비스별 캐시를 분리해야 하는 경우
그럴 때는 scope를 활용합니다.
cache-from: type=gha,scope=api
cache-to: type=gha,scope=api,mode=max
서비스 디렉터리별로 scope를 다르게 주면 캐시 오염을 크게 줄일 수 있습니다.
멀티아키텍처 빌드와 캐시: 느려지는 이유와 대응
linux/amd64와 linux/arm64를 동시에 빌드하면 캐시가 있어도 느려질 수 있습니다.
- QEMU 에뮬레이션 비용
- 아키텍처별로 레이어가 달라 캐시가 분리됨
그래도 멀티아키 빌드가 필요하다면 다음처럼 platforms를 지정합니다.
- name: Build and push (multi-arch)
uses: docker/build-push-action@v6
with:
push: true
platforms: linux/amd64,linux/arm64
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
운영이 EKS라면 노드 아키텍처(예: Graviton arm64)에 맞춰 단일 플랫폼만 빌드하는 게 비용/시간 측면에서 유리할 때가 많습니다. 이미지 최소화와 노드 운영 안정성 관점은 EKS에서 Bottlerocket로 노드 하드닝·롤백도 함께 보면 맥락이 이어집니다.
캐시 히트율을 올리는 “실전” 체크리스트
1) .dockerignore는 캐시 전략의 일부다
컨텍스트에 불필요한 파일이 들어오면 COPY . . 레이어 해시가 자주 바뀌어 캐시가 깨집니다.
예시:
# .dockerignore
node_modules
.git
.github
npm-debug.log
Dockerfile
README.md
coverage
dist
.next
- 빌드 산출물(
dist,.next)이 컨텍스트에 섞이면 캐시가 불안정해집니다. .git이 들어가면 변경이 너무 자주 발생합니다.
2) 커밋 SHA를 이미지 라벨로 넣되, 레이어 캐시를 깨지 않게
커밋 정보를 이미지에 남기고 싶다면 LABEL을 최종 스테이지에만 넣어 캐시 영향 범위를 최소화합니다.
ARG GIT_SHA
FROM node:20-alpine AS runner
LABEL org.opencontainers.image.revision=$GIT_SHA
워크플로에서는 다음처럼 전달합니다. 부등호가 포함된 표현은 없지만, 값이 자주 바뀌므로 “어느 스테이지에서 쓰는지”가 중요합니다.
with:
build-args: |
GIT_SHA=${{ github.sha }}
3) 의존성 설치는 “입력 파일”을 최소화
- Node:
package.json,package-lock.json만 - Python:
requirements.txt,poetry.lock만 - Java/Gradle:
build.gradle,settings.gradle,gradle.lockfile등만
의존성 설치 단계에서 소스 전체를 복사하면, 코드 한 줄 바뀔 때마다 의존성을 다시 받게 됩니다.
4) BuildKit 캐시 마운트는 “빌드 단계”에서만 의미가 큰 경우가 많다
예를 들어 npm 캐시는 빌드 단계에서 효과가 크지만, 최종 런타임 이미지는 캐시 디렉터리를 포함하지 않도록 해야 이미지가 깔끔해집니다.
5) 캐시가 안 먹을 때는 로그부터
docker/build-push-action은 BuildKit 로그를 제공합니다. 캐시 히트/미스가 의도대로인지 먼저 확인하세요.
캐시 미스가 반복된다면 원인 패턴은 상당히 정형화되어 있습니다. 대표 함정들은 위 내부 링크 글(GitHub Actions 캐시가 안 먹을 때 터지는 7가지 함정)에서 체크리스트로 빠르게 점검하는 편이 효율적입니다.
고급: 레지스트리 캐시(type=registry)로 팀/러너 간 공유
type=gha는 GitHub Actions 내에서 편하지만, 다음 니즈가 있으면 레지스트리 캐시가 더 적합합니다.
- 여러 CI 시스템(예: Jenkins + GitHub Actions)에서 캐시 공유
- 셀프호스티드 러너 풀에서 캐시를 중앙화
- 캐시를 장기간 보존
예시:
with:
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
주의할 점은 캐시 이미지도 결국 레지스트리 용량을 사용한다는 것입니다. 만료 정책(패키지 retention)과 함께 설계해야 합니다.
운영 관점에서의 멀티스테이지: 이미지가 작아지면 배포도 빨라진다
CI 빌드 시간이 줄어드는 것만큼, 최종 이미지가 작아져서 얻는 이득도 큽니다.
- 레지스트리 푸시/풀 시간 감소
- 노드 디스크/이미지 캐시 압박 감소
- 취약점 스캔 대상 축소
특히 쿠버네티스 환경에서는 이미지 풀 타임이 롤링 업데이트 속도에 직결됩니다. 노드 OS/런타임 계층까지 포함한 하드닝 관점은 EKS에서 Bottlerocket로 노드 하드닝·롤백과 같이 보면 “왜 런타임 이미지를 얇게 가져가야 하는지”가 더 선명해집니다.
결론: 빠른 CI는 Dockerfile과 캐시 백엔드의 합작품
정리하면, GitHub Actions에서 Docker 빌드를 빠르게 만드는 핵심은 두 축입니다.
- Dockerfile 레이어 설계: 멀티스테이지 + 의존성 레이어 고정 +
.dockerignore - BuildKit 캐시 지속화:
docker/build-push-action의cache-to/cache-from을 올바르게 구성
먼저 type=gha로 시작해 체감 성능을 얻고, 모노레포/멀티아키/팀 규모가 커지면 scope와 type=registry로 확장하는 흐름이 가장 시행착오가 적습니다.
캐시가 기대대로 동작하지 않을 때는 “왜 캐시 키가 바뀌었는지”를 추적하는 게 정답이며, 그 과정에서 가장 자주 만나는 원인들은 GitHub Actions 캐시가 안 먹을 때 터지는 7가지 함정에 체크리스트 형태로 정리되어 있습니다.