Table of contents
- BuildKit이란 뭔가
- 활성화 확인
- 캐시 마운트 — 의존성 설치 속도 혁신
- 빌드 시크릿 — 이미지에 안 남기기
- 인라인 캐시와 레지스트리 캐시
- buildx — 멀티 아키텍처 빌드
- 빌드 인자와 환경 분기
- 빌드 프로비넌스(provenance)
- 실무 템플릿 — 한 번에 끼얹기
- 여기까지의 상태
BuildKit이란 뭔가
BuildKit은 Docker에서 분리된 독립 빌드 엔진이다. moby/buildkit 프로젝트로 개발되며, Dockerfile을 DAG(방향성 비순환 그래프)로 분석해 병렬 실행 가능한 단계를 동시에 빌드한다. 레거시 빌더는 RUN을 순차적으로만 돌렸는데, BuildKit은 의존이 없는 스테이지는 동시에 처리한다.
flowchart LR
subgraph LEGACY["레거시 빌더"]
L1["RUN apt install"] --> L2["RUN npm install"] --> L3["RUN build"]
end
subgraph BUILDKIT["BuildKit"]
B0["분석: DAG"]
B0 --> BA["stage: deps"]
B0 --> BB["stage: assets"]
BA --> BC["stage: compile"]
BB --> BC
BC --> BD["stage: final"]
end
멀티스테이지에서 서로 의존하지 않는 단계가 여러 개 있으면 BuildKit이 동시에 실행한다. 빌드 시간이 체감되게 줄어드는 이유다.
활성화 확인
최신 Docker면 아무 것도 안 해도 BuildKit이 기본이다. 구버전이라면 환경 변수로 켠다.
export DOCKER_BUILDKIT=1
docker build -t myapp:1.4.2 .
활성화됐는지 간단히 확인하려면 빌드 출력의 포맷이 [+] Building ...으로 시작하는지 본다. 레거시 빌더는 Sending build context ...로 시작한다.
Dockerfile 상단에 syntax 지시어를 넣으면 최신 Dockerfile 기능을 안전하게 쓸 수 있다.
# syntax=docker/dockerfile:1.7
# syntax=...는 BuildKit이 해당 버전의 Dockerfile 프론트엔드를 pull 받아 쓰게 한다. --mount 같은 기능이 안전하게 동작한다.
캐시 마운트 — 의존성 설치 속도 혁신
가장 체감이 큰 기능이다. apt, npm, go mod, pip 같은 툴은 전부 자기 캐시 디렉토리를 가진다. 레이어 캐시가 깨지면 이 캐시도 같이 사라져서 매번 처음부터 받는 일이 생긴다. --mount=type=cache는 이 캐시 디렉토리를 빌드 간에 유지한다.
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
.npm 디렉토리를 캐시로 마운트했기 때문에, package.json이 조금 바뀌어 레이어 캐시가 깨져도 대부분의 패키지 tarball은 로컬 캐시에서 재사용된다.
Go라면 이렇게 쓴다.
# syntax=docker/dockerfile:1.7
FROM golang:1.22-alpine AS 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=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /out/server ./cmd/server
모듈 캐시와 빌드 캐시를 둘 다 유지한다. 체감상 클린 빌드가 두 번째부터는 몇 배 빨라진다.
apt는 조금 다르다. Debian/Ubuntu 베이스는 패키지 캐시를 기본적으로 비우게 돼있는데, 그 설정을 잠시 끄고 캐시 마운트를 붙인다.
# syntax=docker/dockerfile:1.7
FROM ubuntu:22.04
RUN rm -f /etc/apt/apt.conf.d/docker-clean && \
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \
> /etc/apt/apt.conf.d/keep-cache
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 \
build-essential libpq-dev
빌드 시크릿 — 이미지에 안 남기기
10편에서 잠깐 다뤘던 내용을 다시 정리한다. --mount=type=secret은 빌드 중에만 파일을 노출하고 이미지에는 남기지 않는다.
# syntax=docker/dockerfile:1.7
FROM alpine:3.20
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
--mount=type=cache,target=/root/.npm \
npm install
docker build \
--secret id=npmrc,src=$HOME/.npmrc \
-t myapp:1.4.2 .
--secret의 id와 Dockerfile의 id가 일치해야 연결된다. 빌드가 끝나면 해당 마운트는 사라지고, 최종 이미지에도 .npmrc는 남지 않는다. docker history로 봐도 흔적이 없다.
SSH 키가 필요한 경우(프라이빗 Git 리포에서 의존성을 당길 때)는 type=ssh를 쓴다.
# syntax=docker/dockerfile:1.7
FROM alpine:3.20
RUN --mount=type=ssh \
apk add --no-cache git openssh && \
git clone git@github.com:private/repo.git /src
docker build --ssh default=$SSH_AUTH_SOCK -t myapp:1.4.2 .
호스트의 SSH agent에 연결해서 키 없이도 인증이 통한다.
인라인 캐시와 레지스트리 캐시
CI 머신은 매번 바뀌기 때문에 로컬 캐시가 유지되지 않는다. BuildKit은 원격 레지스트리를 캐시 저장소로 쓰는 기능을 제공한다.
인라인 캐시 — 이미지에 캐시 포함
docker buildx build \
--cache-to=type=inline \
-t harbor.example.com/team/myapp:1.4.2 \
--push .
type=inline은 최종 이미지에 캐시 메타데이터를 포함시킨다. 다음 빌드는 이걸 참조한다.
docker buildx build \
--cache-from=harbor.example.com/team/myapp:1.4.2 \
-t harbor.example.com/team/myapp:1.4.3 \
--push .
registry 캐시 — 별도 캐시 이미지
실무에서 더 많이 쓰는 건 type=registry 캐시다. 메인 이미지와 별개의 태그로 캐시를 push/pull한다.
docker buildx build \
--cache-to=type=registry,ref=harbor.example.com/team/myapp:buildcache,mode=max \
--cache-from=type=registry,ref=harbor.example.com/team/myapp:buildcache \
-t harbor.example.com/team/myapp:1.4.2 \
--push .
mode=max는 중간 레이어까지 다 캐시로 밀어올린다. mode=min은 최종 레이어만 저장해서 크기는 작지만 캐시 재사용률도 낮다.
GitHub Actions라면 type=gha(GitHub Actions 내장 캐시)도 유용하다.
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/myorg/myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
buildx — 멀티 아키텍처 빌드
개발자 노트북은 Apple Silicon(ARM64), 프로덕션 서버는 x86(AMD64)인 게 흔한 상황이다. 같은 이미지를 두 아키텍처로 다 올려놓으려면 buildx가 필요하다.
빌더 생성
docker buildx create --name multiarch --use
docker buildx inspect --bootstrap
--use로 기본 빌더로 지정한다. inspect --bootstrap은 필요한 Docker-in-Docker 컨테이너를 띄워 빌더를 준비시킨다.
QEMU — 다른 아키텍처 에뮬레이션
x86 호스트에서 ARM64를 빌드하려면 QEMU가 필요하다. 최신 Docker Desktop은 기본 포함이고, Linux 호스트에서는 한 번 등록해줘야 한다.
docker run --privileged --rm tonistiigi/binfmt --install all
이후 --platform으로 아키텍처 목록을 넘긴다.
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t harbor.example.com/team/myapp:1.4.2 \
--push .
빌드 흐름을 그림으로 보면 이렇다.
flowchart TB
CMD["docker buildx build --platform linux/amd64,linux/arm64"] --> BK["BuildKit (in Docker-in-Docker 빌더)"]
BK --> QEMU["QEMU 에뮬레이터"]
QEMU --> AMD["linux/amd64 빌드"]
QEMU --> ARM["linux/arm64 빌드"]
AMD --> MAN["manifest list 구성"]
ARM --> MAN
MAN --> REG["레지스트리 push"]
에뮬레이션의 느림을 해결하는 법
QEMU 에뮬레이션은 느리다. ARM64 빌드를 x86에서 돌리면 네이티브 대비 몇 배 걸린다. 대안은 두 가지다.
- 네이티브 빌더 풀링 — AMD64와 ARM64 호스트를 각각 준비해서
buildx가 네이티브로 분산 빌드하게 한다docker buildx create --name multiarch \ --node arm-node --platform linux/arm64 \ ssh://user@arm-host docker buildx create --append --name multiarch \ --node amd-node --platform linux/amd64 - 크로스 컴파일 — Go처럼 크로스 컴파일이 쉬운 언어는 컴파일은 네이티브로, 베이스 이미지만 플랫폼별로 다르게 가져간다
# syntax=docker/dockerfile:1.7 FROM --platform=$BUILDPLATFORM golang:1.22 AS builder ARG TARGETOS TARGETARCH WORKDIR /app COPY . . RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ go build -o /out/server ./cmd/server FROM alpine:3.20 COPY --from=builder /out/server /server ENTRYPOINT ["/server"]
BUILDPLATFORM은 빌더의 네이티브 플랫폼, TARGETPLATFORM/TARGETOS/TARGETARCH는 목적 플랫폼이다. 이 조합이 크로스 컴파일의 표준 패턴이다.
빌드 인자와 환경 분기
ARG는 빌드 시점 변수다. 이미지 내부의 ENV와 다르다.
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS builder
ARG NODE_ENV=production
RUN npm ci --omit=dev && npm run build:${NODE_ENV}
docker build --build-arg NODE_ENV=staging -t myapp:staging .
주의할 점은 ARG에 시크릿을 넣지 않는 것이다. --build-arg로 전달된 값은 이미지 메타데이터에 남을 수 있다. 시크릿은 반드시 --mount=type=secret으로 전달한다.
빌드 프로비넌스(provenance)
BuildKit은 “이 이미지가 어떤 Dockerfile, 어떤 빌드 명령으로 만들어졌는지”를 기록한 프로비넌스 어테스테이션을 붙일 수 있다.
docker buildx build \
--provenance=true \
--sbom=true \
-t harbor.example.com/team/myapp:1.4.2 \
--push .
docker buildx imagetools inspect harbor.example.com/team/myapp:1.4.2
SLSA(Supply-chain Levels for Software Artifacts) 요건을 맞춰야 하는 조직이라면 이 기능이 필수다. 이미지 하나마다 공급망 증빙이 따라붙는다.
실무 템플릿 — 한 번에 끼얹기
CI에서 쓸 만한 buildx 한 줄 명령 예시다.
docker buildx build \
--platform linux/amd64,linux/arm64 \
--cache-from=type=registry,ref=harbor.example.com/team/myapp:buildcache \
--cache-to=type=registry,ref=harbor.example.com/team/myapp:buildcache,mode=max \
--provenance=true --sbom=true \
--secret id=gh_token,env=GH_TOKEN \
--tag harbor.example.com/team/myapp:${VERSION} \
--tag harbor.example.com/team/myapp:${VERSION}-${SHA} \
--push .
이 한 줄에 멀티 아키텍처, 레지스트리 캐시, SBOM, 프로비넌스, 시크릿, 다중 태깅이 다 들어있다. 프로젝트 초기에 이 템플릿을 셋업해두면 이후 확장이 편하다.
여기까지의 상태
BuildKit의 핵심 기능을 끌어올려 쓰면 빌드 시간, 보안, 재현성이 동시에 개선된다. 이제 이렇게 잘 구운 이미지를 프로덕션에서 오래오래 잘 돌리는 법으로 옮겨간다.
다음 편은 프로덕션 모범 사례다. HEALTHCHECK, graceful shutdown, 로그 드라이버, 리소스 제한, init 프로세스(tini/dumb-init) 같은 것들 — 운영이 생존하느냐 마느냐를 가르는 설정들이다.

Loading comments...