Skip to content
ioob.dev
Go back

Docker 11편 — BuildKit과 고급 빌드

· 7분 읽기
Docker 시리즈 (11/13)
  1. Docker 입문 1편 — Docker란
  2. Docker 입문 2편 — 이미지와 레이어
  3. Docker 입문 3편 — Dockerfile 작성법
  4. Docker 입문 4편 — 컨테이너 생명주기
  5. Docker 입문 5편 — 볼륨과 데이터 영속성
  6. Docker 입문 6편 — 네트워크
  7. Docker 7편 — Docker Compose로 다중 컨테이너 오케스트레이션
  8. Docker 8편 — 멀티스테이지 빌드로 이미지 다이어트
  9. Docker 9편 — 레지스트리, 이미지는 어디에 두는가
  10. Docker 10편 — 컨테이너 보안, 터지기 전에 막는 것들
  11. Docker 11편 — BuildKit과 고급 빌드
  12. Docker 12편 — 프로덕션 모범 사례
  13. Docker 13편 — 트러블슈팅과 대안
Table of contents

Table of contents

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 .

--secretid와 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에서 돌리면 네이티브 대비 몇 배 걸린다. 대안은 두 가지다.

  1. 네이티브 빌더 풀링 — 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
  2. 크로스 컴파일 — 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) 같은 것들 — 운영이 생존하느냐 마느냐를 가르는 설정들이다.

12편: 프로덕션 모범 사례


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Docker 10편 — 컨테이너 보안, 터지기 전에 막는 것들
Next Post
Docker 12편 — 프로덕션 모범 사례