- Published on
Docker 빌드 느림? BuildKit 캐시·레이어 최적화 12
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/CI에서 Docker 빌드가 갑자기 느려지면 대부분은 “컴퓨터가 느려졌다”가 아니라 캐시가 깨졌거나(캐시 미스), 레이어가 잘못 쪼개졌거나, 빌드 컨텍스트가 비대해졌거나, 네트워크/패키지 설치가 매번 반복되는 구조 때문입니다. 특히 CI에서는 매 빌드가 깨끗한 환경에서 시작되기 쉬워서, 로컬에서는 빠른데 Actions/Runner에서는 항상 느린 일이 흔합니다.
이 글은 BuildKit을 전제로 캐시를 ‘먹게’ 만들고, Dockerfile 레이어를 재설계해 변경이 잦은 부분과 안정적인 부분을 분리하며, 의존성 설치/테스트/빌드 산출물을 효율적으로 다루는 12가지 최적화 포인트를 실전 관점에서 정리합니다.
> CI 캐시가 자꾸 빗나가면(키/경로 충돌 등) Docker 캐시도 함께 무력화되는 경우가 많습니다. CI 캐시 디버깅은 GitHub Actions 캐시가 안 먹을 때 - key·dir 충돌 디버깅도 같이 참고하면 좋습니다.
0) 먼저 확인: BuildKit이 켜져 있는가
BuildKit은 캐시/병렬화/마운트 캐시 등 현대적인 최적화의 기반입니다.
- 로컬(일회성)
env DOCKER_BUILDKIT=1 docker build -t myapp:dev .
- docker buildx 사용(권장)
docker buildx create --use --name mybuilder
docker buildx inspect --bootstrap
BuildKit 로그를 더 자세히 보고 싶다면:
docker buildx build --progress=plain -t myapp:dev .
이제부터의 12가지는 BuildKit을 켠 상태에서 효과가 극대화됩니다.
1) .dockerignore로 빌드 컨텍스트 다이어트
빌드 컨텍스트가 커지면 docker build 시작부터 느려지고, 사소한 파일 변경도 캐시를 깨뜨립니다.
예시 .dockerignore:
.git
node_modules
__pycache__
*.log
*.tmp
.env
.dist
build
coverage
.DS_Store
핵심은 이미지에 필요 없는 것(VCS, 로컬 의존성 디렉터리, 테스트 산출물)을 컨텍스트에서 배제하는 것입니다.
2) “변경 잦은 파일”은 최대한 뒤로: 레이어 캐시의 기본
Dockerfile에서 앞쪽 레이어가 깨지면 뒤 레이어는 전부 재실행됩니다. 따라서 변경이 잦은 소스 복사는 뒤로, 변경이 적은 의존성 설치는 앞으로 배치합니다.
Node.js 예시:
# syntax=docker/dockerfile:1.7
FROM node:20-bookworm AS deps
WORKDIR /app
# 의존성 정의만 먼저 복사
COPY package.json package-lock.json ./
# 캐시 마운트로 npm 캐시 재사용
RUN \
npm ci
FROM node:20-bookworm AS runner
WORKDIR /app
# node_modules는 deps 스테이지에서 가져옴
COPY /app/node_modules ./node_modules
# 소스는 마지막에 복사(변경 잦음)
COPY . .
CMD ["node", "server.js"]
이 구조의 목표는 “코드 한 줄 바꿨다고 의존성 설치를 다시 하지 않게” 만드는 것입니다.
3) 멀티스테이지로 빌드/런타임 분리(캐시 + 이미지 경량화)
빌드 도구(컴파일러, devDependencies)를 런타임 이미지에 남기면 이미지가 커지고, 레이어 변경 범위도 넓어집니다.
Go 예시:
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN \
go mod download
COPY . .
RUN \
CGO_ENABLED=0 GOOS=linux go build -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12 AS runtime
COPY /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]
**의존성 다운로드 캐시(go/pkg/mod)**와 **빌드 캐시(go-build)**를 분리하면 CI에서도 체감이 큽니다.
4) BuildKit --mount=type=cache로 패키지 설치 가속
BuildKit의 캐시 마운트는 “레이어 캐시”와 다르게 빌드 단계 내부에서 반복되는 다운로드/컴파일 캐시를 보존합니다.
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"]
주의: 캐시 마운트는 빌드 머신/빌더 인스턴스에 종속됩니다. CI에서 러너가 매번 바뀌면 **원격 캐시(export/import)**를 함께 써야 효과가 지속됩니다(아래 8번).
5) apt-get은 한 레이어에 묶고, 인덱스/캐시 정리
apt-get update와 apt-get install을 분리하면 캐시가 깨졌을 때 업데이트가 재실행되며, 레이어도 불필요하게 늘어납니다.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
--no-install-recommends로 불필요 패키지 설치 방지/var/lib/apt/lists삭제로 이미지 크기 축소(네트워크 재다운로드 비용은 있지만 런타임에 이득)
6) COPY . .를 쪼개서 캐시 히트율을 올리기
모노레포/대형 프로젝트에서 COPY . .는 너무 많은 변경을 한 번에 끌어옵니다. 변경이 적은 디렉터리부터 복사하고, 변경이 잦은 부분은 뒤로 미룹니다.
COPY package.json package-lock.json ./
RUN npm ci
COPY src ./src
COPY public ./public
또한 테스트/문서/툴링 파일이 소스와 섞여 있으면 캐시가 자주 깨집니다. 빌드에 필요한 최소만 COPY하는 습관이 중요합니다.
7) 재현 가능한 빌드를 위해 “버전 고정”과 락파일을 사용
캐시 최적화의 역설은 “매번 다른 결과가 나오면 캐시가 의미 없다”는 점입니다.
- Node:
npm ci+package-lock.json - Python:
requirements.txt에 핀(pin) 버전, 가능하면pip-tools/poetry.lock - OS 패키지: 가능하면 베이스 이미지 태그를 고정(
ubuntu:22.04등)
버전이 떠다니면 같은 Dockerfile이라도 매번 다운로드/빌드가 달라져 캐시 히트율이 떨어집니다.
8) CI에서 진짜 핵심: 원격 캐시(export/import) 사용
로컬에선 빠른데 CI에서 느린 가장 흔한 이유는 러너가 매번 새로워서 캐시가 없다는 것입니다. Buildx의 --cache-to/--cache-from로 해결합니다.
GitHub Actions 예시(레지스트리 캐시):
docker buildx build \
--cache-from=type=registry,ref=ghcr.io/myorg/myapp:buildcache \
--cache-to=type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=max \
-t ghcr.io/myorg/myapp:${GITHUB_SHA} \
--push .
mode=max: 가능한 많은 캐시 메타데이터를 올려 재사용성 증가- 레지스트리 캐시는 러너가 바뀌어도 유지
CI 파이프라인 전체 최적화가 필요하면, 배포 흐름 관점에서 GitHub Actions로 EKS 무중단 배포 - Blue-Green CI/CD도 함께 보면 “빌드-푸시-배포” 병목을 같이 잡기 좋습니다.
9) --mount=type=secret로 비밀정보 때문에 캐시 깨지는 문제 방지
.env나 토큰 파일을 COPY로 넣으면 레이어가 캐시 불가가 되거나(보안상), 작은 변경에도 캐시가 깨집니다. BuildKit secret을 쓰면 이미지에 남기지 않고 빌드 중에만 사용합니다.
# syntax=docker/dockerfile:1.7
RUN \
npm ci
빌드 실행:
docker buildx build \
--secret id=npmrc,src=$HOME/.npmrc \
-t myapp:dev .
10) --mount=type=bind로 불필요한 COPY를 줄이는 고급 패턴
특정 단계에서만 소스가 필요하다면, COPY 대신 바인드 마운트로 읽기 전용 접근을 할 수 있습니다(특히 빌드 툴 실행 단계).
# syntax=docker/dockerfile:1.7
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# 소스를 레이어로 굳히지 않고 빌드 단계에서만 마운트
RUN \
cp -r /src/. . \
&& npm run build
이 패턴은 상황에 따라 호불호가 있지만, “빌드 산출물만 필요하고 소스 전체를 이미지 레이어로 남기고 싶지 않은” 경우에 유용합니다.
11) 플랫폼/아키텍처 멀티빌드 시 QEMU 에뮬레이션 비용 줄이기
linux/amd64에서 linux/arm64를 빌드하면 QEMU로 느려질 수 있습니다.
- 가능하면 네이티브 러너(arm64 머신)에서 arm64 빌드
- 멀티아치가 필요하면 캐시를 플랫폼별로 분리
docker buildx build \
--platform linux/amd64,linux/arm64 \
--cache-to=type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=max \
--push -t ghcr.io/myorg/myapp:latest .
멀티아치 빌드는 “한 번에 다”보다, 플랫폼별 파이프라인 분리가 더 빠른 경우도 많습니다.
12) 캐시가 왜 안 먹는지 ‘관측’하는 방법(원인 찾기 체크리스트)
최적화는 감으로 하면 실패합니다. 다음을 점검해 캐시 미스 원인을 좁히세요.
--progress=plain으로 어떤 단계가 재실행되는지 확인COPY단계에서 캐시가 깨진다면:.dockerignore누락- 빌드 컨텍스트에 타임스탬프가 자주 바뀌는 파일 포함
COPY . .로 너무 넓게 복사
- 패키지 설치 단계가 매번 돈다면:
- 락파일 변경/미사용
apt-get update분리- BuildKit cache mount 미사용
- CI에서 원격 캐시 미사용
추가로, CI 캐시(예: Actions cache)와 Docker 캐시를 함께 쓰는 경우 키 설계/경로 충돌 때문에 캐시가 무효화될 수 있습니다. 이때는 GitHub Actions 캐시가 안 먹을 때 - key·dir 충돌 디버깅이 직접적인 도움이 됩니다.
실전 템플릿: “빠른 Dockerfile”의 전형
아래는 다양한 언어에 공통으로 적용되는 뼈대입니다.
# syntax=docker/dockerfile:1.7
FROM debian:bookworm-slim AS base
WORKDIR /app
# 1) OS 의존성은 한 레이어로
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# 2) 의존성 정의 파일만 먼저 복사
# (예: package.json/lock, requirements.txt, go.mod/go.sum 등)
COPY requirements.txt ./
# 3) 패키지 다운로드 캐시 마운트
RUN \
true
# 4) 소스는 마지막에
COPY . .
CMD ["bash", "-lc", "echo run"]
여기에 언어별 설치/빌드 단계를 끼워 넣되, 원칙은 같습니다.
- 컨텍스트 최소화
- “변경 적은 것”을 앞으로
- 의존성 설치는 캐시 마운트
- CI는 원격 캐시
마무리: 빌드 속도는 ‘설계’로 결정된다
Docker 빌드가 느린 문제는 대부분 BuildKit 캐시 전략 + Dockerfile 레이어 설계 + CI에서의 캐시 지속성으로 해결됩니다. 특히 8번(원격 캐시)까지 적용하면 로컬/CI 간 체감 격차가 크게 줄어듭니다.
다음 액션을 추천합니다.
.dockerignore정리 후 컨텍스트 크기 확인- Dockerfile에서
COPY순서 재배치(락파일 우선) - 패키지 매니저에
--mount=type=cache적용 - CI에
--cache-to/--cache-from(registry) 추가
이 4가지만 해도 “빌드가 매번 5~10분”에서 “변경 없으면 수십 초”로 내려오는 케이스가 흔합니다.