- Published on
Docker BuildKit 캐시로 CI 빌드 80% 단축 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
CI에서 컨테이너 이미지를 매번 docker build로 처음부터 다시 만들면, 네트워크 다운로드와 의존성 설치가 병목이 되어 빌드 시간이 쉽게 수 분에서 수십 분으로 늘어납니다. 특히 Node.js, Python, JVM 계열은 패키지 설치 단계가 길고, 사소한 파일 변경 하나로 캐시가 무효화되면 체감이 큽니다.
BuildKit 캐시는 이 문제를 “레이어 캐시를 CI 간에도 재사용”하도록 확장합니다. 로컬 Docker 데몬의 캐시만 믿는 것이 아니라, 레지스트리나 CI 전용 캐시 백엔드에 빌드 결과(레이어/메타데이터)를 저장하고 다음 빌드에서 당겨오는 방식입니다. 제대로 설계하면 CI 빌드 시간을 80% 이상 줄이는 경우가 흔합니다.
이 글은 다음을 목표로 합니다.
- BuildKit 캐시의 동작 원리를 이해하고
- Dockerfile을 캐시 친화적으로 구조화하고
- GitHub Actions에서
buildx와 원격 캐시를 연결하며 - 캐시 미스 원인을 빠르게 추적하는 체크리스트를 갖추기
캐시가 기대만큼 안 먹는 상황은 대부분 “키 설계”와 “Dockerfile 레이어 구성”에서 발생합니다. GitHub Actions 캐시 전반의 미스 원인도 함께 참고하면 디버깅이 빨라집니다: GitHub Actions 캐시 미스 원인 7가지와 해결
BuildKit 캐시가 CI를 빠르게 만드는 구조
BuildKit은 Dockerfile을 실행하면서 각 단계(레이어)에 대해 다음을 기반으로 캐시 키를 계산합니다.
- 해당 단계의 명령(
RUN,COPY,ARG등) - 입력 파일의 해시(특히
COPY로 들어오는 파일) - 베이스 이미지 digest
- 빌드 인자와 환경변수(캐시 키에 포함되는 범위)
핵심은 “캐시가 유효하면 해당 단계는 실행하지 않고 결과를 재사용”한다는 점입니다. CI에서 빌드를 매번 새로 띄우면 로컬 캐시가 없으니 의미가 없는데, BuildKit은 캐시를 외부로 내보내고(--cache-to) 다음 빌드에서 가져올 수(--cache-from) 있게 해줍니다.
캐시 백엔드는 크게 두 가지가 실전에서 많이 쓰입니다.
- 레지스트리 기반 캐시: 컨테이너 레지스트리에 캐시 메타데이터/레이어를 저장
- GitHub Actions 캐시 backend: GitHub가 제공하는 캐시 저장소를 BuildKit이 직접 사용
둘 다 장단점이 있어, 조직의 보안/네트워크/레지스트리 정책에 맞춰 선택합니다.
빌드 80% 단축을 만드는 Dockerfile 레이어 설계
캐시 성능은 CI 설정만으로는 한계가 있고, Dockerfile이 절반 이상을 결정합니다. 원칙은 간단합니다.
- “자주 바뀌는 파일”은 가능한 뒤로 미루기
- “의존성 설치”는 가능한 앞에 두고, 입력 파일을 최소화하기
- 불필요한 컨텍스트 전송을 막기 위해
.dockerignore를 촘촘히 구성하기
Node.js 예시: 의존성 캐시를 살리는 Dockerfile
아래는 흔히 사용하는 패턴입니다.
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS deps
WORKDIR /app
# 의존성 정의 파일만 먼저 복사
COPY package.json package-lock.json ./
# npm 캐시를 BuildKit cache mount로 유지
RUN \
npm ci
FROM node:20-alpine AS build
WORKDIR /app
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
CMD ["node", "dist/server.js"]
포인트는 다음입니다.
COPY . .를 최대한 늦게 수행해 애플리케이션 코드 변경이 의존성 설치 캐시를 깨지 않게 함RUN --mount=type=cache로 패키지 매니저 캐시 디렉터리를 유지해 네트워크 다운로드를 최소화- 멀티 스테이지로 런타임 이미지 크기를 줄여 배포 속도까지 개선
여기서 # syntax=docker/dockerfile:1.7 같은 상단 지시자는 BuildKit 전용 기능(캐시 mount 등)을 안정적으로 쓰기 위한 것입니다. 이 줄이 없으면 기능이 제한되거나 동작이 달라질 수 있습니다.
Python 예시: pip 다운로드 병목 제거
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS base
WORKDIR /app
COPY requirements.txt ./
RUN \
pip install -r requirements.txt
COPY . .
CMD ["python", "main.py"]
Python도 마찬가지로 requirements.txt만 먼저 복사해서 의존성 레이어를 안정적으로 캐시합니다.
.dockerignore는 캐시의 시작점
컨텍스트에 불필요한 파일이 섞이면 COPY . . 단계의 입력 해시가 자주 바뀌어 캐시가 깨집니다.
# .dockerignore
.git
node_modules
.dist
build
coverage
.next
.DS_Store
.env
*.log
특히 CI에서 생성되는 아티팩트(예: 테스트 리포트)가 컨텍스트에 섞이면, 코드가 안 바뀌어도 캐시가 무효화될 수 있습니다.
GitHub Actions에서 BuildKit 원격 캐시 적용 (실전)
BuildKit을 CI에서 쓰는 가장 보편적인 방법은 docker/build-push-action과 buildx를 사용하는 것입니다.
방식 1: GitHub Actions 캐시 backend 사용
레지스트리 권한/정책을 건드리기 어려운 팀에서 빠르게 적용하기 좋습니다.
name: build
on:
push:
branches: ["main"]
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
# BuildKit 캐시
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to의mode=max는 더 많은 캐시 메타데이터를 남겨 재사용률을 올립니다(대신 캐시 크기가 커질 수 있음).- 이 방식은 레지스트리 캐시보다 “프로젝트/워크플로우” 경계가 명확해 운영이 단순합니다.
방식 2: 레지스트리 기반 캐시 사용 (팀/브랜치 공유에 강함)
여러 워크플로우, 여러 러너에서 캐시를 넓게 공유하고 싶다면 레지스트리 캐시가 유리합니다.
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
이때 레지스트리 권한 문제가 자주 발생합니다. GITHUB_TOKEN 권한과 OIDC 설정 이슈가 있으면 아래 글이 빠른 해결에 도움이 됩니다.
캐시가 안 먹을 때: “캐시 미스” 원인 체크리스트
BuildKit 캐시는 “한 번 설정하면 끝”이 아니라, 캐시 키가 깨지는 지점을 제거하는 작업입니다. 아래는 현장에서 가장 자주 보는 원인입니다.
1) COPY . .가 너무 이르고, 변경이 잦은 파일이 포함됨
- 해결: 의존성 정의 파일만 먼저 복사 후 설치, 앱 코드는 마지막에 복사
- 해결:
.dockerignore강화
2) ARG/ENV가 의존성 설치 단계에 섞여 캐시 키를 변경
예를 들어 ARG BUILD_TIME 같은 값을 의존성 설치 이전에 넣으면 매 빌드마다 캐시가 깨집니다.
- 해결: 변동 값은 최대한 뒤 단계로 이동
- 해결: 정말 필요한 경우에만
ARG사용
3) 베이스 이미지가 latest로 흔들림
FROM node:20-alpine도 내부적으로 업데이트가 있을 수 있습니다.
- 해결: 가능하면 digest로 고정(예:
node@sha256:...)
4) 테스트/빌드 산출물이 컨텍스트에 포함됨
- 해결:
.dockerignore에 CI 산출물 경로를 추가
5) 패키지 매니저가 네트워크를 과도하게 사용
- 해결:
--mount=type=cache로 npm/pip/gradle 캐시 디렉터리 마운트
6) 멀티 플랫폼 빌드에서 캐시 기대치가 달라짐
linux/amd64와 linux/arm64는 레이어가 달라 캐시 공유가 제한됩니다.
- 해결: 플랫폼별 캐시를 분리하거나, 자주 쓰는 플랫폼을 우선 최적화
7) 캐시 저장소가 너무 자주 정리되거나, 키가 분기별로 분산됨
- 해결: GHA 캐시 정책/키 전략 점검
- 해결: 레지스트리 캐시로 전환 고려
캐시 미스 디버깅은 결국 “어떤 단계가 다시 실행되는지”를 보는 게 핵심입니다. 워크플로우 로그에서 각 단계가 CACHED로 처리되는지 확인하고, 캐시가 깨지는 단계의 입력을 최소화하세요.
고급: BuildKit 캐시 mount로 의존성 설치를 더 줄이기
BuildKit의 cache mount는 레이어 캐시와 별개로 “빌드 중 임시 캐시 디렉터리”를 유지합니다. 즉, 레이어 캐시가 깨져 RUN npm ci가 다시 실행되더라도, 다운로드 자체는 캐시 디렉터리에서 재사용되어 속도가 크게 개선됩니다.
apt 캐시도 가능
# syntax=docker/dockerfile:1.7
FROM ubuntu:22.04
RUN \
apt-get update && apt-get install -y curl ca-certificates
다만 apt는 이미지 재현성/보안 업데이트 정책과 맞물리므로, “항상 최신 패키지”가 필요한지 “재현성”이 중요한지 팀 기준을 먼저 정하세요.
측정과 검증: 80% 단축을 숫자로 만드는 방법
체감이 아니라 수치로 증명하려면, 최소 아래 3가지를 분리해 측정하는 것이 좋습니다.
- 캐시 없는 첫 빌드 시간(콜드)
- 코드만 변경했을 때 빌드 시간(핫)
- 의존성 파일 변경했을 때 빌드 시간(부분 콜드)
보통 80% 단축이 나오는 구간은 2번(핫 빌드)입니다. 예를 들어 Node.js 프로젝트에서 npm ci가 3분, 빌드가 1분이라면, 캐시가 잘 먹으면 4분이 1분 이하로 떨어지는 구조가 됩니다.
운영 팁: 캐시 전략을 팀 규모에 맞추기
- 소규모/단일 리포지토리:
type=gha가 가장 도입이 쉽고 유지보수가 단순 - 모노레포/여러 워크플로우/여러 러너: 레지스트리 캐시가 공유 효율이 높음
- 보안이 엄격하고 외부 캐시가 부담: 자체 레지스트리(사내 Harbor 등)에 캐시 저장
또한 캐시는 “무조건 많이 저장”이 답이 아닙니다. 저장 비용과 캐시 오염(불필요한 변형)이 생길 수 있으니, 다음 기준으로 균형을 잡습니다.
- 브랜치 전략:
main기준 캐시를 두고 feature 브랜치는cache-from로만 당겨쓰기 - 의존성 업데이트 주기: 주 1회 정도 의존성 레이어를 의도적으로 갱신하는 파이프라인을 별도로 둠
정리
Docker BuildKit 캐시로 CI 빌드를 80% 단축하려면, “원격 캐시 연결”과 “캐시 친화적 Dockerfile”이 함께 가야 합니다.
--cache-to/--cache-from로 CI 간 캐시를 공유하고- 의존성 설치 단계의 입력 파일을 최소화하며
--mount=type=cache로 다운로드 병목을 줄이고.dockerignore로 컨텍스트 변동을 통제하면
대부분의 프로젝트에서 핫 빌드 시간이 눈에 띄게 줄어듭니다. 캐시가 기대만큼 동작하지 않으면, 어떤 단계가 재실행되는지부터 확인하고(로그의 CACHED 여부), 그 단계의 입력을 줄이는 방향으로 Dockerfile을 재구성하세요.