도커 중급 강좌 #2 빌드 캐시 — BuildKit과 레이어 순서 최적화

7 분 소요

기초 #6에서 잡은 “의존성 → 코드” 순서가 캐시의 첫 단추였다면, 이번 글은 그 위에 BuildKit 시대의 빌드 캐시 도구들을 본격적으로 얹습니다.

도커 중급 강좌 시리즈에서 이번 글의 위치:

  • #1 멀티스테이지 빌드와 이미지 슬리밍
  • #2 빌드 캐시 — 레이어 순서 최적화 ← 이번 글
  • #3 docker-compose 기초 — web + db
  • #4 compose 심화 — depends_on, healthcheck, profiles
  • #5 환경변수와 secrets 관리
  • #6 로깅과 디버깅

BuildKit이 기본이다 #

도커 빌드 엔진이 두 세대로 나뉘어 있다는 사실부터. 옛 빌더(legacy builder)와 BuildKit. 기능 차이가 큽니다.

LegacyBuildKit
병렬 빌드안 함
--mount=type=cache
--mount=type=secret
COPY --link
멀티 플랫폼어려움자연스러움
캐시 임포트 / 익스포트

최근 도커 버전(20.10+)에선 BuildKit이 기본이라 별도 설정 없이도 위 기능들을 쓸 수 있습니다. 강제로 켜고 싶다면:

BuildKit 명시
DOCKER_BUILDKIT=1 docker build -t myapp .

또는 buildx 명령으로:

buildx 빌더
docker buildx build -t myapp .

buildx는 BuildKit 위에서 도는 향상된 CLI입니다. 단일 빌드는 docker build와 동일하고, 멀티 플랫폼 / 캐시 익스포트 같은 고급 기능은 buildx가 자연스럽습니다.

이번 글의 모든 예제는 BuildKit / buildx가 켜진 상태를 가정합니다.

레이어 캐시 — 한 번 더 깊이 #

기초에서 의존성 복사를 코드 복사보다 위에 두라는 규칙을 봤습니다. 이걸 한 단계 풀어보면:

캐시 키 결정
이 레이어의 캐시 키 = 이전 레이어 다이제스트
                    + 명령 자체(변수 포함)
                    + (COPY/ADD인 경우) 복사할 파일들의 내용 해시

세 가지 중 하나라도 바뀌면 캐시 무효화 입니다. 이걸 머리에 두면 캐시가 왜 자주 깨지는지 추적이 쉬워집니다.

자주 못 보고 지나치는 캐시 무효화 지점 #

ARG 위치 — 자주 만나는 함정
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_TIME

ARG는 선언된 위치 다음 레이어부터 캐시 키에 들어갑니다. 매 빌드마다 값이 바뀌는 BUILD_TIME 같은 인자를 위에 두면, 모든 레이어가 매번 다시 빌드됩니다. 자주 바뀌는 ARG는 마지막에 두세요.

ENV도 같음
ENV APP_VERSION=1.2.3   # 이 위치 이후 모든 레이어가 이 값을 캐시 키에 포함

ENV도 마찬가지입니다. 자주 바뀌는 환경변수는 위쪽에 두지 마세요.

--mount=type=cache — 빌드 간 캐시 공유 #

여기부터가 BuildKit의 가장 큰 선물입니다. 레이어 캐시가 깨졌을 때도 패키지 매니저의 다운로드 캐시는 살려둘 수 있습니다.

npm #

npm cache 공유
# 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 #

pip cache 공유
# 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 #

apt cache 공유
# 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 curl

apt의 두 캐시 위치를 함께 마운트합니다. sharing=locked는 동시에 빌드되는 다른 스테이지가 같은 캐시를 손대지 못하게 락을 겁니다. apt처럼 동시 접근에 약한 도구에 필요합니다.

apt 캐시를 mount cache로 두면, 기초에서 본 rm -rf /var/lib/apt/lists/*가 더 이상 필요하지 않습니다. 캐시가 이미지 바깥에 있기 때문입니다.

Go module 캐시 #

Go module + build 캐시
# 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 myapp

Go는 두 캐시(/go/pkg/mod — 모듈 캐시, /root/.cache/go-build — 컴파일 캐시)를 둘 다 마운트하면 재빌드가 매우 빨라집니다.

COPY --link — 빌드 병렬화 #

기본 COPY는 이전 레이어 위에 새 파일을 얹는 식이라, 이전 레이어가 다 만들어진 다음에야 실행됩니다. --link를 붙이면 부모 레이어와 독립적으로 만들 수 있어 BuildKit이 병렬화할 여지가 생깁니다.

COPY --link
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 — 비밀을 이미지에 박지 않기 #

빌드 시점에만 필요한 비밀(예: 사설 패키지 레지스트리 토큰)을 이미지 안에 굳히지 않고 쓰는 도구.

secret mount
# 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 동안에만 존재하고, 이미지의 어떤 레이어에도 남지 않습니다. ARGENV로 비밀을 넘기는 옛 패턴은 docker history로 노출되니, 비밀은 항상 이 방식으로.

RUN --mount=type=ssh — 사설 Git 저장소 #

go get / pip install git+ssh://...같이 빌드 중 SSH로 사설 저장소에 닿아야 할 때.

SSH agent 포워딩
# syntax=docker/dockerfile:1.7
FROM python:3.14
RUN --mount=type=ssh \
    pip install git+ssh://git@github.com/myorg/private-repo.git
ssh agent와 함께 빌드
docker 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의 캐시 백엔드를 직접 쓸 수 있습니다.

.github/workflows/build.yml (요약)
- 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=max

GHA 캐시는 GitHub이 무료로 제공하고(저장소당 10GB 정도), 연관된 워크플로우 안에서만 빠르게 접근됩니다. 외부 레지스트리 푸시 없이 캐시 공유가 되므로 가장 손쉬운 옵션입니다.

inline 캐시 #

이미지 자체에 캐시 메타데이터를 임베드하는 가장 가벼운 방식.

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가 차지합니다.

X