BuildKit 고급 빌드 캐시와 빌드 최적화
PR마다 빌드가 10분 이상 걸려 리뷰 피드백이 늦어지고, 팀 전체 배포 리듬이 느려집니다. 원인을 보면 의존성 다운로드를 매번 처음부터 반복하고, CI는 캐시를 거의 재사용하지 못합니다. BuildKit은 단순 속도 개선이 아니라 빌드 파이프라인의 병목을 구조적으로 제거하는 도구입니다. 이 모듈은 캐시 마운트·시크릿 마운트·레지스트리 캐시 전략을 실전 형태로 정리합니다.
Docker 17.05에서 멀티스테이지 빌드가 도입되었다면, BuildKit은 빌드 엔진 자체를 재설계한 변화입니다. 기존 레이어 캐시의 한계를 극복하는 마운트 타입 캐시, 빌드 시크릿 안전 주입, DAG 기반 병렬 실행까지 — BuildKit을 제대로 활용하면 10분짜리 빌드를 2분 안에 끝낼 수 있습니다.
BuildKit의 고급 기능을 단계적으로 익혀 실제 프로젝트의 빌드 시간을 극적으로 단축하는 방법을 실습합니다. 특히 CI/CD 파이프라인에서 캐시를 활용하는 전략을 중점적으로 다룹니다.
- 1BuildKit을 활성화(DOCKER_BUILDKIT=1)하고 docker buildx와 레거시 빌더의 차이를 설명할 수 있다
- 2RUN --mount=type=cache로 npm/pip/apt 패키지 캐시를 빌드 간 재사용할 수 있다
- 3RUN --mount=type=secret으로 이미지 레이어에 흔적을 남기지 않고 빌드 시 비밀값을 주입할 수 있다
- 4DAG 기반 병렬 스테이지 빌드로 멀티스테이지 빌드 시간을 단축할 수 있다
- 5--cache-from과 레지스트리 캐시 패턴으로 CI 환경 빌드 캐시 전략을 구성할 수 있다
- 6docker build --progress=plain으로 빌드 단계별 시간을 프로파일링할 수 있다
Docker 23.0 이상 환경에서는 BuildKit이 기본 빌더로 사용됩니다. 구버전 환경에서는 DOCKER_BUILDKIT=1 환경 변수를 설정해야 합니다. docker buildx는 BuildKit 기반의 확장 빌드 CLI입니다.
docker version --format '{{.Server.Version}}'docker buildx versiondocker buildx create --use --name mybuilder --driver docker-containermkdir -p ~/buildkit-lab && cd ~/buildkit-labexport DOCKER_BUILDKIT=1 — Docker 23.0 이상에서는 기본 활성화됩니다
BuildKit이란 무엇인가 — 레거시 빌더와의 근본 차이
PR 하나 올릴 때마다 GitHub Actions 빌드가 15분 걸립니다. 팀원 5명이 오전에 동시에 PR을 올리면 빌드 큐가 쌓여 한 시간이 넘어갑니다. 개발자들이 피드백을 기다리다 다른 작업으로 넘어가고, 결국 컨텍스트 스위칭 비용이 생깁니다. 느린 빌드의 주범은 대부분 패키지 설치 — npm install, pip install이 매번 수백 MB를 새로 내려받기 때문입니다. BuildKit은 Docker 빌드 엔진 자체를 재설계한 것으로, 기존 레이어 캐시의 구조적 한계를 해결합니다.
확대
레거시 빌더(Classic Builder)의 한계
Docker 23.0 이전까지 기본으로 사용하던 레거시 빌더는 다음과 같은 구조적 한계를 가졌습니다.
레거시 빌더 동작 방식:
Dockerfile의 각 줄을 위에서 아래로 순서대로 실행
→ 의존관계 없는 스테이지도 직렬 실행
→ RUN 명령의 부산물(패키지 캐시 등)이 레이어에 포함
→ 빌드 시크릿을 안전하게 주입할 방법 없음
→ 캐시 무효화 시 해당 줄 이후 모든 레이어 재빌드
BuildKit의 핵심 개선점
BuildKit 동작 방식:
Dockerfile 전체를 파싱 → 의존 그래프(DAG) 생성
→ 독립 스테이지 병렬 실행 (CPU 코어 활용)
→ --mount=type=cache: 패키지 캐시를 레이어 외부에 유지
→ --mount=type=secret: 빌드 시크릿을 임시 마운트
→ --mount=type=ssh: SSH 에이전트 포워딩
→ 최종 이미지에 불필요한 레이어 포함 없음
BuildKit 활성화 방법
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part5/exam_23 && cd /tmp/docker/part5/exam_23
# 방법 1: 환경 변수로 활성화 (구버전 Docker)
export DOCKER_BUILDKIT=1
docker build -t myapp:latest .
# 방법 2: docker buildx 사용 (권장)
docker buildx build -t myapp:latest .
# 방법 3: /etc/docker/daemon.json 으로 전역 설정
# { "features": { "buildkit": true } }
# BuildKit 활성화 확인
docker buildx ls
# NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS
# mybuilder * docker-container running v0.12.5 linux/amd64
# default docker running v0.11.7 linux/amd64
Dockerfile 첫 줄 — syntax 지시어
BuildKit은 # syntax= 지시어로 Dockerfile 파서 버전을 지정할 수 있습니다.
# syntax=docker/dockerfile:1.6
FROM node:20-alpine
# 이 지시어로 최신 BuildKit 기능을 안정적으로 사용할 수 있습니다
# 1.6 버전부터 --mount=type=cache의 sharing 옵션 개선
BuildKit이 캐시 히트를 판정하는 법 — 그래프부터 무효화 전파까지
같은 코드로 다시 빌드하면 어떤 줄엔 CACHED가 붙고 어떤 줄엔 안 붙습니다. 어떤 날은 한 줄만 고쳤는데 그 아래가 전부 다시 돕니다. BuildKit이 "이 스텝을 다시 실행할지, 캐시를 쓸지"를 어떻게 판정하는지 알면 캐시가 왜 깨졌고 어디서부터 다시 도는지를 로그에서 짚어낼 수 있습니다. 캐시는 빌드 그래프 구성 → 스텝별 캐시 키 계산 → 캐시 조회 → HIT/MISS 처리 → 무효화 전파의 순서로 판정됩니다.
docker buildx build .
│
① 빌드 그래프 구성 Dockerfile 전체 파싱 → 스텝 간 의존을 DAG로
│
② 스텝별 캐시 키 계산 (명령 문자열 + 입력 해시 + 부모 스텝 키) → 해시
│ → COPY·ADD: 복사 대상 파일 내용의 체크섬이 입력
│ → RUN: 명령 문자열 자체가 입력 (실행 결과·바깥세상은 안 봄)
│
③ 캐시 조회 계산한 키를 로컬 캐시 → --cache-from 레지스트리 캐시 순으로 검색
│
④ 판정
│ → HIT (키 일치): 스텝 실행 생략, 저장된 레이어 재사용 "CACHED"
│ → MISS (키 없음): 스텝 실제 실행 후 결과를 그 키로 저장
│
⑤ 무효화 전파 한 스텝이 MISS면 그 스텝을 부모로 하는 아래 스텝들의
▼ 부모 키가 바뀌어, 그 지점부터 사슬 끝까지 연쇄 재실행
[결과] 독립 스텝은 병렬로, 캐시 히트 스텝은 즉시 통과
각 단계에서 무슨 일이 일어나고, 어긋나면 어떤 증상인가:
| 단계 | 하는 일 | 여기서 어긋나면 |
|---|---|---|
| ① 그래프 구성 | Dockerfile을 파싱해 스텝 간 의존을 DAG로 만든다. 서로 의존 없는 스테이지는 병렬 실행 후보가 됨 | # syntax 지시어 없이 구형 문법 → 일부 --mount·캐시 기능이 무시됨 |
| ② 캐시 키 계산 | 스텝마다 (명령 · 입력 · 부모 스텝 키)를 합쳐 해시. COPY는 파일 내용 체크섬, RUN은 명령 문자열이 입력 | RUN apt-get update는 문자열만 같으면 키도 같아, 바깥 인덱스가 바뀌어도 캐시 히트 → 낡은 인덱스 재사용 |
| ③ 캐시 조회 | 계산한 키를 로컬 캐시 스토어에서, 없으면 --cache-from 레지스트리 캐시에서 찾음 | CI는 새 러너라 로컬 캐시가 비어 매번 MISS → --cache-from으로 외부 캐시를 붙여야 히트 |
| ④ HIT·MISS 처리 | HIT면 실행을 건너뛰고 저장된 레이어를 재사용(CACHED), MISS면 실제 실행 후 결과를 그 키로 저장 | --no-cache면 조회를 건너뛰고 전부 MISS / type=cache 마운트 캐시는 이 레이어 캐시와 별개 스토어 |
| ⑤ 무효화 전파 | 한 스텝이 MISS(키 변경)면 그 스텝에 의존하는 아래 스텝의 키도 달라져 사슬로 다시 실행 | 의존성 파일보다 소스를 먼저 COPY하면 코드 한 줄 변경이 install 스텝까지 무효화 |
--progress=plain 로그에서 CACHED [n/m]가 붙은 줄이 ④의 HIT, 안 붙은 줄이 MISS입니다. "어디서부터 CACHED가 사라지는가"가 곧 ②의 캐시 키가 처음 바뀐 지점이자 ⑤ 전파의 출발점입니다. 캐시가 안 먹는 흔한 원인은 셋으로 갈립니다 — 레이어 순서(의존성을 소스보다 먼저 COPY, ⑤ 문제), CI의 빈 로컬 캐시(--cache-from 필요, ③ 문제), 그리고 RUN이 명령 문자열만 본다는 한계(apt-get update와 install을 한 RUN으로 합치기, ② 문제)입니다. 참고로 --mount=type=cache 마운트 캐시는 이 판정과 별개로 스텝 실행 중 디렉터리를 빌려주는 것이라, 캐시 키가 MISS여도 그 안에 받아둔 패키지는 그대로 재사용됩니다.
RUN --mount=type=cache — 빌드 간 패키지 캐시 영구 재사용
의존성 라이브러리 하나를 추가했습니다. package.json에 한 줄이 바뀌었고, 덕분에 npm install 레이어 캐시가 통째로 무효화됩니다. 1,234개 패키지를 다시 내려받는 데 7분이 넘게 걸립니다. 정작 바뀐 것은 패키지 하나인데 말입니다. --mount=type=cache는 패키지 캐시를 이미지 레이어 바깥 별도 공간에 보관합니다. package.json이 바뀌어도 이미 내려받은 패키지는 그대로 남아 있어서 변경된 것만 추가로 받습니다.
확대
기존 레이어 캐시의 문제
기존 Dockerfile에서 패키지 설치 레이어 캐시는 package.json이 변경되는 순간 완전히 무효화됩니다. 의존성이 1개만 추가되어도 npm install 전체를 처음부터 다시 실행합니다.
# 기존 방식 — package.json 변경 시 전체 재설치
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci # 캐시 무효화 시 수백 MB 재다운로드
COPY . .
RUN npm run build
--mount=type=cache 원리
--mount=type=cache는 BuildKit 호스트의 별도 캐시 스토리지에 디렉토리를 마운트합니다. 이 캐시는:
- 이미지 레이어에 포함되지 않음 → 최종 이미지 크기 영향 없음
- 빌드 간에 유지됨 → 패키지 다운로드를 건너뜀
- 여러 빌드가 동시에 접근해도 안전 →
sharing옵션으로 제어
# syntax=docker/dockerfile:1.6
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
# npm 캐시 디렉토리를 BuildKit 캐시에 마운트
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
언어별 캐시 마운트 설정
Node.js (npm/yarn/pnpm)
# npm — 캐시 디렉토리: /root/.npm
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline
# yarn — 캐시 디렉토리: /usr/local/share/.cache/yarn
RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
yarn install --frozen-lockfile
# pnpm — 캐시 디렉토리: /root/.local/share/pnpm/store
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
Python (pip)
# pip — 캐시 디렉토리: /root/.cache/pip
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
# 주의: --no-cache-dir은 pip의 내장 캐시를 끄는 것이 아님
# BuildKit 마운트 캐시와 별개 개념
apt (Debian/Ubuntu 계열)
# apt — 캐시 디렉토리: /var/cache/apt, /var/lib/apt
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
Go (모듈 캐시)
# Go — 모듈 캐시와 빌드 캐시 모두 마운트
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /app/server ./cmd/server
sharing 옵션
여러 빌드가 동시에 같은 캐시에 접근할 때의 동작을 제어합니다.
# shared (기본값): 여러 빌드가 동시에 읽기/쓰기 가능
RUN --mount=type=cache,target=/root/.npm,sharing=shared \
npm ci
# locked: 한 번에 하나의 빌드만 접근 (apt 같은 잠금 파일이 있는 경우)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update
# private: 각 빌드가 독립적인 캐시 사본 사용 (격리 필요 시)
RUN --mount=type=cache,target=/tmp/build,sharing=private \
make build
RUN --mount=type=secret — 빌드 시 비밀값 안전 주입
확대
왜 ARG로 시크릿을 전달하면 위험한가
# ❌ 절대 하지 말 것 — ARG로 시크릿 전달
ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN
# docker history로 토큰 노출 확인 가능:
# docker history myimage --no-trunc
# IMAGE CREATED CREATED BY
# ... ... /bin/sh -c npm config set //registry.npmjs.org/:_authToken=npm_xxxxxxxxxxxx
docker history, docker inspect, 레이어 추출(docker save)로 ARG로 전달한 시크릿이 완전히 노출됩니다.
--mount=type=secret 동작 방식
빌드 호스트의 ~/.npmrc(토큰 포함)가 BuildKit 빌드 컨테이너의 /run/secrets/npmrc로 임시 마운트됩니다.
- RUN 명령 실행 중에만 존재
- 빌드 완료 후 파일이 사라짐
- 최종 이미지 레이어에 포함되지 않음
사용법
private npm registry 인증
# syntax=docker/dockerfile:1.6
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
# --mount=type=secret으로 .npmrc를 빌드 시에만 마운트
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
--mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
빌드 명령:
# 방법 1: 파일로 시크릿 전달
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
docker buildx build \
--secret id=npmrc,src=.npmrc \
-t myapp:latest .
# 방법 2: 환경 변수에서 직접 전달
docker buildx build \
--secret id=npmrc,env=NPM_TOKEN \
-t myapp:latest .
pip private index 인증
# syntax=docker/dockerfile:1.6
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=secret,id=pip_conf,target=/root/.config/pip/pip.conf \
--mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# pip.conf 파일 예시
# [global]
# index-url = https://user:token@private.pypi.company.com/simple/
docker buildx build \
--secret id=pip_conf,src=./pip.conf \
-t myapp:latest .
SSH 에이전트 포워딩 (private Git 리포지토리 클론)
# syntax=docker/dockerfile:1.6
FROM golang:1.21-alpine
# SSH known_hosts 설정
RUN mkdir -p /root/.ssh && \
ssh-keyscan github.com >> /root/.ssh/known_hosts
WORKDIR /app
# SSH 에이전트를 마운트하여 private 리포지토리에서 모듈 다운로드
RUN --mount=type=ssh \
--mount=type=cache,target=/go/pkg/mod \
go mod download
# SSH 에이전트가 키를 로드하고 있는지 확인
ssh-add -l
docker buildx build \
--ssh default \
-t myapp:latest .
시크릿 노출 여부 검증
# 빌드 후 이미지 히스토리 확인 — 시크릿이 보이지 않아야 함
docker history myapp:latest --no-trunc | grep -i token
# (아무것도 출력되지 않아야 정상)
# 레이어 직접 검사
docker save myapp:latest | tar -tvf - | grep npmrc
# (아무것도 출력되지 않아야 정상)
DAG 기반 병렬 빌드와 --cache-from 레지스트리 캐시
멀티스테이지 Dockerfile에서 프론트엔드 빌드(45초)와 백엔드 빌드(40초)가 순서대로 실행됩니다. 둘은 서로 아무런 관련이 없는데도 백엔드가 끝날 때까지 프론트엔드가 기다립니다. 총 시간이 95초입니다. 두 스테이지가 동시에 돌았다면 55초면 충분합니다. BuildKit은 Dockerfile 전체를 분석해서 의존 관계가 없는 스테이지를 자동으로 병렬 실행합니다. 코드 변경 없이 Dockerfile 구조만 그대로 두면 됩니다.
확대
BuildKit의 병렬 빌드 원리
BuildKit은 Dockerfile을 파싱할 때 스테이지 간 의존 관계를 분석하여 DAG(방향성 비순환 그래프)를 구성합니다. 서로 의존하지 않는 스테이지는 자동으로 병렬 실행됩니다.
# syntax=docker/dockerfile:1.6
# -- 스테이지 1: 프론트엔드 빌드 (독립적)
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY frontend/ .
RUN npm run build
# -- 스테이지 2: 백엔드 빌드 (독립적, 스테이지 1과 병렬 실행)
FROM golang:1.21-alpine AS backend-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
go build -o /app/server ./cmd/server
# -- 스테이지 3: 최종 이미지 (스테이지 1, 2 완료 후 실행)
FROM gcr.io/distroless/base-debian12
WORKDIR /app
COPY --from=backend-builder /app/server .
COPY --from=frontend-builder /app/frontend/dist ./static
EXPOSE 8080
ENTRYPOINT ["/app/server"]
확대
--cache-from: CI 환경의 레지스트리 캐시
CI 파이프라인은 매 실행마다 새 환경(컨테이너/VM)에서 시작하므로 로컬 레이어 캐시가 없습니다. --cache-from으로 레지스트리의 이미지를 캐시 소스로 지정합니다.
# CI 파이프라인 빌드 스크립트 (GitHub Actions 예시)
# 1단계: 레지스트리에서 기존 이미지를 캐시로 불러오면서 빌드
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 \
--tag ghcr.io/myorg/myapp:latest \
--push \
.
cache-to 모드 비교
# mode=min (기본값): 최종 이미지의 레이어만 캐시에 저장
# → 캐시 크기 작음, 중간 스테이지 캐시 없음
docker buildx build \
--cache-to type=registry,ref=myregistry/myapp:cache,mode=min .
# mode=max: 모든 중간 스테이지 레이어도 캐시에 저장
# → 캐시 크기 크지만 멀티스테이지 빌드에서 최대 효과
docker buildx build \
--cache-to type=registry,ref=myregistry/myapp:cache,mode=max .
--progress=plain으로 빌드 단계 분석
# 빌드 단계별 소요 시간 전체 출력
docker buildx build --progress=plain -t myapp:latest . 2>&1 | tee build.log
# 출력 예시:
# #1 [internal] load build definition from Dockerfile 0.1s
# #2 [internal] load .dockerignore 0.0s
# #3 [frontend-builder 1/5] FROM node:20-alpine 0.3s
# #4 [backend-builder 1/4] FROM golang:1.21-alpine 0.2s
# #5 [frontend-builder 2/5] COPY frontend/package*.json 0.1s
# #6 [backend-builder 2/4] COPY go.mod go.sum 0.1s
# #7 [frontend-builder 3/5] RUN npm ci 38.4s ← 오래 걸리는 단계
# #8 [backend-builder 3/4] RUN go mod download 22.1s
# ...
# 가장 오래 걸리는 단계 찾기
grep -E '#[0-9]+ .* [0-9]+\.[0-9]+s$' build.log | sort -t' ' -k3 -rn | head -10
docker buildx bake — 복잡한 빌드 선언적 관리
여러 이미지를 빌드하는 복잡한 파이프라인을 docker-bake.hcl 파일로 선언적으로 관리할 수 있습니다.
# docker-bake.hcl
group "default" {
targets = ["api", "frontend", "worker"]
}
variable "REGISTRY" {
default = "ghcr.io/myorg"
}
variable "TAG" {
default = "latest"
}
target "api" {
context = "./services/api"
dockerfile = "Dockerfile"
tags = ["${REGISTRY}/api:${TAG}"]
cache-from = ["type=registry,ref=${REGISTRY}/api:buildcache"]
cache-to = ["type=registry,ref=${REGISTRY}/api:buildcache,mode=max"]
}
target "frontend" {
context = "./services/frontend"
dockerfile = "Dockerfile"
tags = ["${REGISTRY}/frontend:${TAG}"]
cache-from = ["type=registry,ref=${REGISTRY}/frontend:buildcache"]
cache-to = ["type=registry,ref=${REGISTRY}/frontend:buildcache,mode=max"]
}
target "worker" {
context = "./services/worker"
tags = ["${REGISTRY}/worker:${TAG}"]
}
# 모든 타겟을 병렬로 빌드
docker buildx bake --push
# 특정 타겟만 빌드
docker buildx bake api --push
# 드라이 런으로 설정 확인
docker buildx bake --print
캐시 마운트 없는 기존 방식과 캐시 마운트 방식의 빌드 시간을 직접 비교합니다.
# Dockerfile.nocache — 기존 방식
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Dockerfile.cache — BuildKit 캐시 마운트 방식
# syntax=docker/dockerfile:1.6
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
# 첫 번째 빌드 (캐시 없음, 시간 측정)
time docker buildx build --progress=plain -t node-nocache:test -f Dockerfile.nocache .
# 두 번째 빌드 (캐시 마운트, 첫 실행)
time docker buildx build --progress=plain -t node-cache:test -f Dockerfile.cache .
# package.json 내용 일부 변경 후 세 번째 빌드 (캐시 재사용 효과 확인)
time docker buildx build --progress=plain -t node-cache:test2 -f Dockerfile.cache .
docker buildx build --progress=plain -t node-nocache:test -f Dockerfile.nocache .- --progress=plain 출력에서 먼저 각 단계 번호(#N)와 소요 시간을 확인 — 그 다음 CACHED 레이블 유무를 확인한다. "CACHED"가 붙은 단계는 0.0~0.1s, 붙지 않은 단계는 수 초~수십 초가 표시된다
- 캐시 효율 판단: 세 번째 빌드에서 npm ci 단계 소요 시간이 첫 번째 빌드 대비 90% 이상 단축(예: 38초→2초 미만)되어야 정상 캐시 히트 — 단축이 없으면 캐시 키(cache id)가 충돌하거나 sharing=locked 설정 문제
- docker buildx du 로 캐시 스토리지 총량 확인 — 첫 빌드 후 수십~수백 MB가 기록되어야 하며, 두 번째 빌드 후에도 크게 늘어나지 않으면 캐시 재사용이 일어난 것. 0B이면 buildx 빌더 드라이버가 docker-container가 아닌 것
- docker images로 node-nocache:test와 node-cache:test 크기 비교 — 두 이미지 크기가 동일해야 정상. 캐시 마운트 방식이 더 크면 캐시가 레이어에 포함된 것(설정 오류)
트러블슈팅
증상
docker buildx build 명령을 실행하면 Dockerfile을 찾지 못한다는 에러가 납니다.
docker buildx build -t myapp:latest .
# ERROR: failed to solve: failed to read dockerfile:
# open /tmp/buildkit123/Dockerfile: no such file or directory
원인 진단
# 현재 디렉토리 확인
ls -la Dockerfile*
# (아무것도 없거나 대소문자가 다를 수 있음)
# 파일명 확인 — 'dockerfile' (소문자)인 경우
ls dockerfile # 소문자로 작성된 경우
# buildx는 기본적으로 'Dockerfile' (대문자 D)를 찾음
해결
# 방법 1: 파일명 수정
mv dockerfile Dockerfile
# 방법 2: -f 옵션으로 파일 명시
docker buildx build -f dockerfile -t myapp:latest .
# 방법 3: --file 전체 경로 지정
docker buildx build --file ./infra/Dockerfile -t myapp:latest .
증상
로컬에서는 두 번째 빌드가 확실히 빠른데, CI(GitHub Actions)에서는 매번 처음부터 다운로드합니다.
# CI 로그 (GitHub Actions):
# #7 [3/5] RUN --mount=type=cache,target=/root/.npm npm ci
# #7 CACHED? NO — 38.4s ← 매번 풀 설치
원인
--mount=type=cache는 BuildKit 빌드 호스트의 로컬 캐시를 사용합니다. GitHub Actions는 Job마다 새 가상 머신을 프로비저닝하므로 이전 빌드의 로컬 캐시가 존재하지 않습니다.
로컬 빌드: 항상 같은 머신 → BuildKit 캐시 유지됨 ✓
GitHub Actions: 새 VM마다 시작 → BuildKit 캐시 없음 ✗
해결
CI 환경에서는 --cache-from / --cache-to 레지스트리 캐시 전략을 병행해야 합니다.
# .github/workflows/build.yml
- name: Build and push with cache
run: |
docker buildx build \
--cache-from type=registry,ref=ghcr.io/${{ github.repository }}:buildcache \
--cache-to type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max \
--tag ghcr.io/${{ github.repository }}:latest \
--push \
.
# 캐시 효과 확인 — CI 두 번째 실행 이후 로그에서 CACHED 확인
# #7 [3/5] RUN --mount=type=cache,target=/root/.npm npm ci
# #7 CACHED 0.1s ← 레지스트리 캐시 히트!
증상
빌드 캐시 오염을 의심해서 docker builder prune --all을 실행했더니, 다음 빌드에서 모든 패키지를 처음부터 다시 내려받습니다.
# 증상 확인 후 진단
docker system df # 먼저 캐시 크기 파악
docker buildx build -t myapp:test .
# #7 [3/5] RUN --mount=type=cache,target=/root/.npm npm ci
# #7 38.2s ← 다시 전체 다운로드
원인 및 대처
docker builder prune은 BuildKit의 레이어 캐시와 마운트 캐시 모두를 삭제합니다. 정상 동작이지만, 캐시 오염 의심 시에는 전체 정화보다 타겟 정화를 먼저 시도하세요.
# 권장: 전체 정화 전에 오염 범위 파악
docker system df # 캐시 크기 확인
# 단계적 정화
docker builder prune # 24시간 이상 된 캐시만 삭제 (더 안전)
BuildKit 캐시 전체 삭제 — 다음 빌드가 처음부터 재실행
안전한 실행 조건: 개인 로컬 머신에서만 실행. 공유 빌드 서버나 CI 환경에서는 금지. 먼저 docker builder prune(--all 없이)으로 오래된 캐시만 제거하는 것을 우선 시도하세요.
실행 전 반드시 확인
- docker system df 로 삭제될 캐시 크기 먼저 확인
- 공유 빌드 서버(Jenkins, GitLab Runner 등)가 아닌 로컬 머신인지 확인
- docker builder prune (--all 없이)으로 부분 정화 먼저 시도
docker builder prune --all --force위 항목을 모두 확인한 후 복사할 수 있습니다
# 정화 후 캐시 재워밍 (warm-up) — 첫 빌드는 느리지만 두 번째부터 정상
docker buildx build -t myapp:warmup . # 캐시 재구축
docker buildx build -t myapp:latest . # 이후 정상 속도
심화 — 캐시 마운트의 저장소·수명·소유권
심화: cache mount는 어디에 저장되고 언제 사라지는가
--mount=type=cache를 "빌드 간 재사용되는 캐시"로만 기억하면, 정작 캐시가 예고 없이 사라지거나 다른 프로젝트와 섞이는 날 당황하게 됩니다. 이 캐시가 물리적으로 어디에 살고 무엇에 의해 지워지는지를 알아야 캐시 전략을 안정적으로 세울 수 있습니다.
- 레이어도 레지스트리 캐시도 아닌 별도 스토어에 산다: cache mount 디렉터리는 이미지 레이어에도,
--cache-to type=registry로 내보내는 레지스트리 캐시에도 포함되지 않고 오직 빌더(buildkitd)의 로컬 캐시 스토어에만 보관됩니다. 레지스트리 캐시가 저장하는 것은 "레이어 실행 결과"이지 mount 디렉터리가 아닙니다. 그래서 새 CI 러너에서 레이어 캐시는--cache-from으로 복원돼도, mount 캐시(부분 다운로드된 패키지들)는 늘 빈 상태에서 시작합니다. - 캐시를 구분하는 키는
id다:id를 지정하지 않으면target경로가 곧 캐시 id가 됩니다. 서로 다른 두 프로젝트가 모두target=/root/.npm을 쓰면 같은 캐시를 공유합니다 — 보통은 무해하지만, 락 경합이나 예상 밖의 캐시 재사용을 만들 수 있어 프로젝트를 격리하려면id=proj-a-npm처럼 명시합니다. - 빌더의 자동 GC 대상이다: 이 스토어는 buildkitd의 가비지 컬렉션 정책에 따라 디스크가 상한에 닿으면 오래된 캐시부터 evict됩니다. 공유 빌더에서 누군가 대형 빌드를 한 번 돌려 디스크를 밀면 내 npm 캐시가 조용히 밀려나, "어제는 빠르던 빌드가 오늘 느린" 간헐 현상이 생깁니다 — 수동
builder prune과 달리 아무 조작 없이 일어납니다.
정리하면 mount 캐시는 "그 빌더에 남아 있는 동안만" 유효한 최적화입니다. 지속성이 중요하면 영속 빌더(자체 호스트 러너에 docker-container 드라이버를 유지)나, mount 캐시를 명시적으로 저장·복원하는 gha 같은 캐시 백엔드를 씁니다.
상황: 베이스 이미지 보안을 위해 USER appuser(비root)로 전환했습니다. 그 전까지 잘 붙던 cache mount가 이제 동작을 멈춥니다. 어떤 빌드에서는 EACCES: permission denied로 죽고, 어떤 빌드에서는 에러도 없이 그냥 매번 패키지를 처음부터 내려받습니다.
원인: cache mount 디렉터리는 기본적으로 uid 0(root) 소유로 생성·마운트됩니다. RUN이 비root 사용자로 실행되면 그 디렉터리에 쓸 권한이 없습니다. 패키지 매니저에 따라 권한 오류로 실패하거나, 캐시 쓰기를 조용히 포기하고 매번 새로 받습니다. USER 지시어와 cache mount 소유권이 어긋난 것이지, 캐시 기능 자체의 문제가 아닙니다.
진단: --progress=plain으로 해당 RUN의 전체 출력을 열어 EACCES/Permission denied 메시지가 나는지 확인합니다. 또는 mount 대상 경로를 잠깐 ls -ld로 찍어 소유자가 root(uid 0)인데 실행 사용자는 다른 uid인지 대조합니다. root 홈의 캐시 경로(/root/.cache)를 비root가 쓰려 하고 있지는 않은지도 함께 봅니다.
해결: mount에 실행 사용자에 맞춘 uid=/gid=(필요하면 mode=)를 지정합니다. 캐시 경로도 그 사용자의 홈 아래 실제 경로로 잡습니다.
# syntax=docker/dockerfile:1.6
FROM python:3.11-slim
RUN useradd -u 1000 -m appuser
USER appuser
WORKDIR /home/appuser/app
COPY requirements.txt .
# 실행 사용자(uid 1000)에 맞춰 소유권을 지정 — 캐시 경로도 그 사용자 홈 아래로
RUN --mount=type=cache,target=/home/appuser/.cache/pip,uid=1000,gid=1000 \
pip install --user -r requirements.txt
BuildKit 캐시가 실제로 팀 생산성에 미치는 영향
중견 규모 서비스 팀에서 배포 파이프라인을 담당하게 됐을 때, CI 빌드 시간이 평균 18분이었습니다. 팀원 8명이 하루 34회 PR을 올리면 하루에 빌드 큐가 수십 건 쌓입니다. 리뷰어가 "CI 결과 기다리는 동안" 다른 PR로 넘어가고, 피드백 루프가 12시간으로 늘어납니다.
BuildKit 캐시 전략 적용 후 결과:
Before:
- npm install: 평균 9분 (의존성 340개)
- 전체 빌드: 18분
- 하루 빌드 큐 대기: 누적 2시간 이상
After (캐시 마운트 + 레지스트리 캐시):
- npm install (캐시 히트 시): 12초
- 전체 빌드: 4분 30초
- 빌드 큐 대기: 거의 없음
실무에서 자주 쓰는 패턴:
# 1. 멀티 서비스 Bake — 마이크로서비스 여러 개를 한 번에 병렬 빌드
docker buildx bake --push
# 2. 아키텍처별 멀티 플랫폼 빌드 (ARM + AMD64)
docker buildx build \
--platform linux/amd64,linux/arm64 \
--cache-from type=registry,ref=myregistry/app:cache \
--cache-to type=registry,ref=myregistry/app:cache,mode=max \
-t myregistry/app:latest \
--push .
# 3. 개발 빌드와 프로덕션 빌드 캐시 분리
# 같은 base 레이어 캐시를 공유하면서 최종 스테이지만 분리
docker buildx build --target=dev --cache-to type=registry,ref=myregistry/app:dev-cache --push .
docker buildx build --target=prod --cache-from type=registry,ref=myregistry/app:dev-cache --push .
비용 관점 — 캐시가 CI 비용을 줄이는 이유: GitHub Actions, CircleCI 등은 빌드 시간을 기준으로 과금합니다. npm install 9분을 12초로 줄이면 월 수백 달러의 CI 비용 절감이 실제로 발생합니다.
명령어·단축키 빠른 참조
이 모듈에서 다룬 BuildKit 캐시·시크릿 빌드 명령을 실전 옵션과 함께 모았습니다. Dockerfile의 RUN --mount과 CLI 옵션을 함께 정리했습니다.
| 명령어/단축키 | 용도 | 자주 쓰는 예 |
|---|---|---|
docker buildx build | BuildKit 빌더로 이미지 빌드 | docker buildx build -t myapp:latest . |
RUN --mount=type=cache | 패키지 캐시를 빌드 간 재사용(Dockerfile) | RUN --mount=type=cache,target=/root/.npm npm ci |
--mount=type=cache,sharing=locked | 동시 빌드 시 캐시 쓰기 직렬화 | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked apt-get update |
RUN --mount=type=secret | 시크릿을 레이어에 남기지 않고 주입 | docker buildx build --secret id=npmrc,src=$HOME/.npmrc . |
RUN --mount=type=ssh | SSH 에이전트로 private repo 접근 | docker buildx build --ssh default . |
docker buildx build --progress=plain | 캐시 히트/미스 상세 로그 | docker buildx build --progress=plain -t myapp . 2>&1 | tee build.log |
docker buildx build --no-cache | 캐시 무시하고 처음부터 빌드 | docker buildx build --no-cache -t myapp . |
--cache-to / --cache-from type=registry | 레지스트리에 빌드 캐시 저장·재사용(CI) | docker buildx build --cache-to type=registry,ref=reg/app:cache --push . |
docker buildx bake | 여러 타깃을 파일로 선언해 일괄 빌드 | docker buildx bake --push / docker buildx bake --print |
docker history | 레이어별 명령·크기 확인(시크릿 유출 점검) | docker history myapp:latest --no-trunc | grep -i token |
docker system df | 빌드 캐시·이미지 디스크 사용량 확인 | docker system df (-v로 상세) |
docker builder prune | 쌓인 빌드 캐시 정리 | docker builder prune --filter until=24h (24h 이상만) |
DOCKER_BUILDKIT=1 | 구버전에서 BuildKit 활성화 | DOCKER_BUILDKIT=1 docker build -t myapp . |
관련 모듈로 더 깊이:
- 실무에 필수적인 멀티 스테이지 빌드와 최적화 기법 — 빌드 캐시와 함께 최종 이미지 크기를 줄이는 멀티 스테이지 빌드의 근본 원리
- 빌드 자동화와 이미지 태그 배포 파이프라인 구축 — registry 캐시를 CI 파이프라인에 연결해 빌드 시간과 비용을 줄이는 법
- 사용하지 않는 이미지 정리와 최적의 태그 아카이빙 전략 — 누적되는 빌드 캐시와 이미지를 안전하게 정리하는 운영 전략
다음 모듈에서는 Docker Swarm의 한계와 Kubernetes 전환 로드맵을 다루며, 단일 서버를 넘어 멀티 노드 오케스트레이션으로 이동하는 방법을 살펴봅니다.