도커 중급 강좌 #2 빌드 캐시 — BuildKit과 레이어 순서 최적화
기초 #6에서 잡은 “의존성 → 코드” 순서가 캐시의 첫 단추였다면, 이번 글은 그 위에 BuildKit 시대의 빌드 캐시 도구들을 본격적으로 얹습니다.
도커 중급 강좌 시리즈에서 이번 글의 위치:
- #1 멀티스테이지 빌드와 이미지 슬리밍
- #2 빌드 캐시 — 레이어 순서 최적화 ← 이번 글
- #3 docker-compose 기초 — web + db
- #4 compose 심화 — depends_on, healthcheck, profiles
- #5 환경변수와 secrets 관리
- #6 로깅과 디버깅
BuildKit이 기본이다 #
도커 빌드 엔진이 두 세대로 나뉘어 있다는 사실부터. 옛 빌더(legacy builder)와 BuildKit. 기능 차이가 큽니다.
| Legacy | BuildKit | |
|---|---|---|
| 병렬 빌드 | 안 함 | 함 |
--mount=type=cache | ✗ | ✓ |
--mount=type=secret | ✗ | ✓ |
COPY --link | ✗ | ✓ |
| 멀티 플랫폼 | 어려움 | 자연스러움 |
| 캐시 임포트 / 익스포트 | ✗ | ✓ |
최근 도커 버전(20.10+)에선 BuildKit이 기본이라 별도 설정 없이도 위 기능들을 쓸 수 있습니다. 강제로 켜고 싶다면:
DOCKER_BUILDKIT=1 docker build -t myapp .또는 buildx 명령으로:
docker buildx build -t myapp .buildx는 BuildKit 위에서 도는 향상된 CLI입니다. 단일 빌드는 docker build와 동일하고, 멀티 플랫폼 / 캐시 익스포트 같은 고급 기능은 buildx가 자연스럽습니다.
이번 글의 모든 예제는 BuildKit / buildx가 켜진 상태를 가정합니다.
레이어 캐시 — 한 번 더 깊이 #
기초에서 의존성 복사를 코드 복사보다 위에 두라는 규칙을 봤습니다. 이걸 한 단계 풀어보면:
이 레이어의 캐시 키 = 이전 레이어 다이제스트
+ 명령 자체(변수 포함)
+ (COPY/ADD인 경우) 복사할 파일들의 내용 해시세 가지 중 하나라도 바뀌면 캐시 무효화 입니다. 이걸 머리에 두면 캐시가 왜 자주 깨지는지 추적이 쉬워집니다.
자주 못 보고 지나치는 캐시 무효화 지점 #
FROM python:3.14-slim
ARG GIT_SHA # 이 위치의 ARG는 이후 모든 레이어 캐시 키에 포함
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
ARG BUILD_TIME # 이 위치의 ARG는 아래 레이어부터만 영향
LABEL build-time=$BUILD_TIMEARG는 선언된 위치 다음 레이어부터 캐시 키에 들어갑니다. 매 빌드마다 값이 바뀌는 BUILD_TIME 같은 인자를 위에 두면, 모든 레이어가 매번 다시 빌드됩니다. 자주 바뀌는 ARG는 마지막에 두세요.
ENV APP_VERSION=1.2.3 # 이 위치 이후 모든 레이어가 이 값을 캐시 키에 포함ENV도 마찬가지입니다. 자주 바뀌는 환경변수는 위쪽에 두지 마세요.
--mount=type=cache — 빌드 간 캐시 공유
#
여기부터가 BuildKit의 가장 큰 선물입니다. 레이어 캐시가 깨졌을 때도 패키지 매니저의 다운로드 캐시는 살려둘 수 있습니다.
npm #
# syntax=docker/dockerfile:1.7
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .--mount=type=cache,target=/root/.npm의 의미:
- 이 RUN 명령이 실행되는 동안만
/root/.npm위치에 빌드 간에 영구적인 캐시 디렉터리를 마운트 - 같은 호스트에서 다음 빌드를 돌릴 때 같은 캐시를 다시 마운트
- 캐시 자체는 이미지에 포함되지 않음 (이미지 크기 안 늘어남)
package-lock.json만 바뀌면 RUN 자체는 다시 실행되지만, npm이 보는 다운로드 캐시(~/.npm/_cacache)가 살아 있으므로 네트워크 안 타고 빠르게 끝납니다.
첫 줄의
# syntax=docker/dockerfile:1.7는 BuildKit에게 이 Dockerfile의 문법 버전을 명시합니다. 새 기능(특히--mount)을 안전하게 쓰기 위한 관용 한 줄입니다.
pip #
# syntax=docker/dockerfile:1.7
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY . .pip도 같은 패턴입니다. 단, 이전에 --no-cache-dir을 붙여 캐시를 안 만들게 하던 관용과는 정반대 흐름입니다. mount cache를 쓸 거면 --no-cache-dir은 제거합니다.
apt #
# syntax=docker/dockerfile:1.7
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update && \
apt-get install -y --no-install-recommends curlapt의 두 캐시 위치를 함께 마운트합니다. sharing=locked는 동시에 빌드되는 다른 스테이지가 같은 캐시를 손대지 못하게 락을 겁니다. apt처럼 동시 접근에 약한 도구에 필요합니다.
apt 캐시를 mount cache로 두면, 기초에서 본
rm -rf /var/lib/apt/lists/*가 더 이상 필요하지 않습니다. 캐시가 이미지 바깥에 있기 때문입니다.
Go module 캐시 #
# syntax=docker/dockerfile:1.7
FROM golang:1.23 AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o myappGo는 두 캐시(/go/pkg/mod — 모듈 캐시, /root/.cache/go-build — 컴파일 캐시)를 둘 다 마운트하면 재빌드가 매우 빨라집니다.
COPY --link — 빌드 병렬화
#
기본 COPY는 이전 레이어 위에 새 파일을 얹는 식이라, 이전 레이어가 다 만들어진 다음에야 실행됩니다. --link를 붙이면 부모 레이어와 독립적으로 만들 수 있어 BuildKit이 병렬화할 여지가 생깁니다.
FROM node:20-slim AS deps
WORKDIR /app
COPY --link package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:20-slim
WORKDIR /app
COPY --link --from=deps /app/node_modules ./node_modules
COPY --link . .체감 차이가 가장 큰 경우는 멀티스테이지 + 큰 --from=other 복사입니다. 안 쓸 이유가 거의 없으니 새로 짤 땐 --link를 기본으로 두는 편이 좋습니다.
--mount=type=secret — 비밀을 이미지에 박지 않기
#
빌드 시점에만 필요한 비밀(예: 사설 패키지 레지스트리 토큰)을 이미지 안에 굳히지 않고 쓰는 도구.
# syntax=docker/dockerfile:1.7
FROM python:3.14-slim
WORKDIR /app
RUN --mount=type=secret,id=pypi,target=/root/.pypirc \
pip install --extra-index-url https://... my-private-pkg빌드 명령에서 시크릿 파일을 넘김:
docker build --secret id=pypi,src=$HOME/.pypirc -t myapp ./root/.pypirc는 이 RUN 동안에만 존재하고, 이미지의 어떤 레이어에도 남지 않습니다. ARG나 ENV로 비밀을 넘기는 옛 패턴은 docker history로 노출되니, 비밀은 항상 이 방식으로.
RUN --mount=type=ssh — 사설 Git 저장소
#
go get / pip install git+ssh://...같이 빌드 중 SSH로 사설 저장소에 닿아야 할 때.
# syntax=docker/dockerfile:1.7
FROM python:3.14
RUN --mount=type=ssh \
pip install git+ssh://git@github.com/myorg/private-repo.gitdocker build --ssh default -t myapp .이미지에 SSH 키가 포함되지 않고, 호스트의 ssh-agent만 빌드 시간에 잠깐 빌려쓰는 흐름입니다.
외부 캐시 — --cache-from / --cache-to
#
지금까지의 캐시는 빌드를 도는 같은 호스트 위에서만 동작합니다. CI 머신은 매번 새로 뜨거나, 여러 러너가 분산돼 있으니 도움이 안 됩니다. 외부 캐시가 이를 보완합니다.
레지스트리 캐시 #
빌드 캐시 자체를 이미지처럼 레지스트리에 올려두는 방식입니다.
docker buildx build \
--cache-to=type=registry,ref=ghcr.io/curtis/myapp:cache,mode=max \
--cache-from=type=registry,ref=ghcr.io/curtis/myapp:cache \
-t ghcr.io/curtis/myapp:1.0 \
--push .--cache-to— 이 빌드의 캐시를 어디로 내보낼지--cache-from— 이 빌드 시작 전에 어디서 캐시를 받을지mode=max— 모든 레이어 캐시 (기본min은 결과 레이어만)
CI가 매번 새 머신에서 도는데도, 캐시가 레지스트리에 살아있어 빠른 빌드를 유지합니다.
GitHub Actions 캐시 #
GHA 위에서 도는 빌드는 GHA의 캐시 백엔드를 직접 쓸 수 있습니다.
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/curtis/myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxGHA 캐시는 GitHub이 무료로 제공하고(저장소당 10GB 정도), 연관된 워크플로우 안에서만 빠르게 접근됩니다. 외부 레지스트리 푸시 없이 캐시 공유가 되므로 가장 손쉬운 옵션입니다.
inline 캐시 #
이미지 자체에 캐시 메타데이터를 임베드하는 가장 가벼운 방식.
docker buildx build \
--cache-to=type=inline \
--cache-from=ghcr.io/curtis/myapp:latest \
-t ghcr.io/curtis/myapp:latest \
--push .별도 캐시 저장소가 필요 없습니다. --cache-from으로 가져오는 이미지가 그 자체로 캐시 정보를 들고 있습니다. 단, mode=min만 가능해서 멀티스테이지의 모든 중간 레이어까지 캐시 하지는 못합니다. 단순한 단일 스테이지 / 작은 프로젝트에 적합.
캐시 정책 한 표 #
| 상황 | 권장 캐시 |
|---|---|
| 로컬 개발 | mount cache (type=cache) |
| 단일 CI 머신, 자주 빌드 | mount cache + 레이어 캐시 |
| 분산 CI (GitHub Actions) | type=gha |
| 분산 CI (자체 호스팅) | type=registry |
| 단순 프로젝트, 외부 저장소 추가 안 하고 싶음 | type=inline |
--no-cache와 캐시 강제 갱신
#
캐시가 옛 결과를 쥐고 있을 때:
docker build --no-cache -t myapp .
docker build --no-cache-filter=builder -t myapp . # 특정 스테이지만--no-cache-filter는 BuildKit의 기능으로, 어떤 스테이지의 캐시만 무시할지 고를 수 있습니다. 의존성은 캐시를 쓰되 빌드는 처음부터 돌고 싶을 때 유용합니다.
캐시가 안 도는 흔한 이유 #
syntax=라인이 없음 — 옛 문법으로 해석돼 mount가 무시됨ARG/ENV가 위쪽에 있음 — 매 빌드 키가 바뀜COPY . .이 위쪽에 있음 — 코드 한 줄만 바뀌어도 모든 레이어 무효화- CI 머신이 매번 새로 뜸 — 외부 캐시(
type=gha,type=registry) 없이는 살릴 수 없음 - BuildKit이 안 켜짐 — Docker Desktop은 기본이지만 옛 CI 환경에선 명시 필요
--no-cache가 어딘가에 들어가 있음 — Makefile / CI 스크립트 검토
정리 #
이번 글에서 잡은 그림:
- BuildKit이 기본 빌더.
# syntax=docker/dockerfile:1.7한 줄로 새 기능을 켠다 - 레이어 캐시 키 = 이전 레이어 + 명령 + 복사 파일 내용. 자주 바뀌는
ARG/ENV/COPY는 아래로 - **
--mount=type=cache**로 npm/pip/apt/Go 캐시를 빌드 간 공유 — 레이어 캐시가 깨져도 패키지 다운로드는 빠름 - **
COPY --link**로 BuildKit의 병렬화 활용 — 새로 짤 땐 기본으로 - **
--mount=type=secret/ssh**로 비밀과 SSH 키를 이미지에 박지 않고 빌드 - 분산 CI에선 외부 캐시 —
type=gha,type=registry,type=inline중 환경에 맞춰 선택
다음 글(#3 docker-compose 기초 — web + db)에서는 한 컨테이너 중심의 흐름에서 한 발 더 나아가 여러 컨테이너를 한 파일로 정의 하는 도구로 들어가겠습니다. 이번 시리즈의 절반은 Compose가 차지합니다.