- Published on
Docker 빌드가 느릴 때 BuildKit 캐시 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 배포 파이프라인에서 Docker 빌드는 생각보다 자주 병목이 됩니다. 특히 CI에서 매 빌드마다 의존성을 다시 받거나, 작은 코드 변경에도 RUN npm ci 같은 무거운 단계가 반복되면 빌드 시간이 눈덩이처럼 불어납니다. 이때 가장 먼저 점검할 대상이 BuildKit 캐시입니다. BuildKit은 단순히 “이전 레이어 재사용”을 넘어, 의존성 다운로드 캐시와 원격 캐시 내보내기까지 지원해 빌드 성능을 크게 끌어올릴 수 있습니다.
이 글에서는 Docker 빌드가 느려지는 대표 원인을 짚고, BuildKit 캐시를 레이어 설계, 의존성 캐시, 원격 캐시, CI 적용 순으로 최적화하는 실전 패턴을 정리합니다.
느린 Docker 빌드의 흔한 원인 5가지
1) 빌드 컨텍스트가 너무 큼
docker build 는 지정한 컨텍스트(보통 현재 디렉터리)를 전송합니다. 로그에 Sending build context 가 오래 걸리면 .git, node_modules, 빌드 산출물 등이 같이 들어가고 있을 가능성이 큽니다.
해결은 .dockerignore 입니다.
.git
node_modules
dist
build
coverage
*.log
.env
컨텍스트가 줄면 네트워크 전송뿐 아니라 캐시 키 계산, 파일 해시 계산도 빨라집니다.
2) 레이어 캐시가 자주 무효화됨
Dockerfile에서 COPY . . 를 너무 앞에 두면, 소스 코드가 조금만 바뀌어도 이후 레이어가 전부 무효화됩니다. 특히 의존성 설치가 COPY . . 뒤에 있으면 매번 의존성을 재설치하게 됩니다.
3) 의존성 설치 단계가 네트워크에 종속됨
apt-get, pip, npm, pnpm, yarn 등은 네트워크, 레지스트리 상태에 따라 편차가 큽니다. 캐시가 없으면 “운 좋으면 2분, 운 나쁘면 10분” 같은 변동이 발생합니다.
4) 멀티 스테이지 빌드인데 캐시 공유가 안 됨
빌드 스테이지에서 받은 의존성이나 다운로드 캐시가 다음 빌드에서 재사용되지 않으면, 멀티 스테이지를 써도 속도가 기대만큼 나오지 않습니다.
5) CI에서 매번 깨끗한 머신에서 빌드함
GitHub Actions 같은 환경은 기본적으로 매번 새 러너에서 시작합니다. 로컬에서 잘 되던 레이어 캐시가 CI에서는 전혀 재사용되지 않는 이유입니다.
이 문제는 “원격 캐시 내보내기”로 해결하는 경우가 많습니다. 이는 캐시를 이미지 레지스트리나 GitHub Actions 캐시에 저장해 다음 빌드에서 가져오는 방식입니다.
BuildKit 활성화 확인과 기본 설정
요즘 Docker는 기본적으로 BuildKit이 활성화된 경우가 많지만, 환경에 따라 다릅니다. 명시적으로 켜두면 재현성이 좋아집니다.
로컬에서 1회 실행으로 확인하려면 다음처럼 실행합니다.
DOCKER_BUILDKIT=1 docker build -t myapp:dev .
docker buildx 를 쓰면 BuildKit 기능을 더 적극적으로 활용할 수 있습니다.
docker buildx version
Dockerfile 레이어 설계로 캐시 적중률 올리기
핵심은 “변경이 잦은 것”을 뒤로 보내는 것입니다. 대표적으로 Node.js 프로젝트는 package.json 과 락 파일은 상대적으로 덜 바뀌고, 애플리케이션 소스는 자주 바뀝니다.
나쁜 예: 소스 복사 후 의존성 설치
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/index.js"]
소스가 바뀔 때마다 npm ci 가 다시 실행될 확률이 높습니다.
좋은 예: 의존성 레이어 분리
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
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 runtime
WORKDIR /app
ENV NODE_ENV=production
COPY /app/dist ./dist
COPY /app/package.json ./package.json
CMD ["node", "dist/index.js"]
이렇게 하면 소스만 바뀌는 경우 deps 스테이지가 캐시로 적중되어 의존성 재설치가 크게 줄어듭니다.
BuildKit의 --mount=type=cache 로 다운로드 캐시 붙이기
레이어 캐시는 “명령이 같고 입력이 같을 때”만 재사용됩니다. 하지만 패키지 매니저는 네트워크 다운로드가 크고, 레이어가 무효화되면 다시 다운로드가 발생합니다.
BuildKit의 캐시 마운트는 “다운로드 디렉터리”를 별도의 캐시로 유지해, 레이어가 다시 실행되더라도 다운로드를 재사용할 수 있게 합니다.
APT 캐시
# syntax=docker/dockerfile:1.7
FROM ubuntu:22.04
RUN \
apt-get update && apt-get install -y curl ca-certificates
npm 캐시
# syntax=docker/dockerfile:1.7
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN \
npm ci
pip 캐시
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN \
pip install -r requirements.txt
이 패턴은 “CI에서 캐시가 유지되는가”가 관건입니다. 로컬에서는 효과가 즉시 체감되지만, CI는 원격 캐시 설정을 같이 해야 합니다.
원격 캐시: CI에서 속도를 결정하는 한 방
CI가 매번 새 환경이라면 로컬 레이어 캐시는 의미가 없습니다. BuildKit은 캐시를 외부로 내보냈다가(import) 다시 가져오는(export) 기능을 제공합니다.
대표적인 방식은 2가지입니다.
- 레지스트리 기반 캐시: 컨테이너 레지스트리에 캐시를 푸시하고 다음 빌드에서 pull
- GitHub Actions 캐시 기반: Actions 캐시 스토리지에 저장
레지스트리 기반 캐시 예시
아래는 buildx 로 캐시를 레지스트리에 저장하는 예시입니다.
docker buildx build \
--platform linux/amd64 \
-t ghcr.io/myorg/myapp:sha-123 \
--cache-from type=registry,ref=ghcr.io/myorg/myapp:buildcache \
--cache-to type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=max \
--push \
.
--cache-to ... mode=max는 가능한 많은 캐시 메타데이터를 보존해 적중률을 높입니다.- 캐시 레퍼런스는 보통
:buildcache같은 별도 태그로 관리합니다.
GitHub Actions에서의 실전 구성 포인트
GitHub Actions를 쓴다면 OIDC나 권한 설정 이슈로 레지스트리 푸시가 실패할 수 있습니다. 이때는 인증 체인부터 점검해야 합니다. 관련해서는 GitHub Actions OIDC STS 실패 - InvalidIdentityToken 글도 함께 참고하면, “캐시가 아니라 인증 때문에 느려 보이는 상황”을 줄일 수 있습니다.
캐시를 깨뜨리는 실수들
1) 매 빌드마다 바뀌는 ARG를 너무 앞에 둠
예를 들어 커밋 SHA를 ARG 로 받고 이를 초반 레이어에서 사용하면, 그 뒤 레이어가 전부 무효화됩니다.
ARG GIT_SHA
RUN echo "$GIT_SHA" > /build-info.txt
이런 메타데이터는 가능한 뒤로 보내거나, 런타임 레이어에서만 추가하는 편이 캐시 효율이 좋습니다.
2) apt-get update 를 단독으로 실행
apt-get update 와 apt-get install 을 분리하면 캐시가 애매해지고, 레포 상태 변화로 재현성도 떨어집니다. 가능하면 한 레이어에서 묶고, 필요하면 버전 고정도 고려하세요.
3) .dockerignore 누락
컨텍스트가 커지면 캐시 키 계산이 느려지고, 변경 감지가 과도하게 발생합니다.
4) 멀티 스테이지에서 의존성 복사 전략이 비효율적
COPY . . 를 빌드 스테이지에서 너무 빨리 하면 deps 캐시가 깨집니다. deps 스테이지를 분리하고, 락 파일만 먼저 복사하는 패턴을 유지하세요.
성능 측정: “빨라진 것 같은데”를 숫자로 만들기
BuildKit 빌드는 단계별로 캐시 적중 여부가 로그에 나타납니다. CACHED 여부를 확인하고, 병목 단계가 어디인지 먼저 파악하세요.
또한 CI 전체가 느린 경우, Docker 빌드 외에 다른 병목(예: DB 커넥션 대기, 테스트 환경 준비)이 섞여 있을 수 있습니다. 예를 들어 통합 테스트가 RDS 연결 대기 때문에 길어지는 경우도 흔합니다. 이런 경우는 RDS PostgreSQL too many connections 원인·해결 같은 글에서 다루는 관점으로 원인을 분리해보면, “빌드 최적화”와 “테스트 인프라 최적화”를 구분하는 데 도움이 됩니다.
실전 추천 조합: Node.js 서비스 기준 템플릿
아래는 자주 쓰는 조합을 한 번에 넣은 예시입니다.
- deps 스테이지 분리
- npm 캐시 마운트
- 런타임 이미지는 최소 복사
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
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 runtime
WORKDIR /app
ENV NODE_ENV=production
# 필요한 것만 복사
COPY /app/dist ./dist
COPY /app/package.json ./package.json
CMD ["node", "dist/index.js"]
여기에 CI에서는 buildx 와 --cache-from, --cache-to 를 붙여 원격 캐시까지 연결하면, “로컬에서만 빠른 Dockerfile”이 아니라 “CI까지 빠른 빌드”가 됩니다.
체크리스트: 빌드가 느릴 때 우선순위대로 점검
.dockerignore로 컨텍스트 크기 줄이기- Dockerfile에서 의존성 설치 레이어를 소스 복사보다 앞에 두기
- BuildKit 캐시 마운트로 패키지 다운로드 캐시 붙이기
- CI에서는 원격 캐시를 반드시 구성하기(
--cache-to,--cache-from) - 캐시를 깨뜨리는
ARG위치, 불필요한 파일 복사, 단계 분리 실패 점검
BuildKit 캐시 최적화는 “한 번 설정하면 계속 이득을 보는” 영역입니다. 특히 팀 규모가 커질수록, 그리고 PR 빌드가 많아질수록 효과가 기하급수적으로 커집니다. 위 패턴대로 레이어 설계와 원격 캐시까지 연결해두면, Docker 빌드 시간을 안정적으로 줄이고 CI 변동성도 크게 낮출 수 있습니다.