Published on

Docker BuildKit로 멀티아키 빌드 5배 단축

Authors

서버/로컬 개발 환경이 amd64arm64 로 갈라지면서 멀티아키 이미지는 사실상 필수가 됐습니다. 문제는 빌드 시간이 급격히 늘어난다는 점입니다. 특히 arm64amd64 머신에서 QEMU로 에뮬레이션하면 체감상 “빌드가 멈춘 것 같은” 구간이 자주 생깁니다.

이 글은 Docker BuildKit과 buildx 를 기반으로 멀티아키 빌드를 현실적으로 3~5배 단축하는 방법을 다룹니다. 핵심은 단순히 BuildKit을 켜는 게 아니라, 캐시를 원격으로 공유하고, Dockerfile 레이어를 재구성하며, 에뮬레이션이 필요한 구간을 최소화하는 것입니다.

참고로 CI에서 Docker 권한/마운트 문제로 빌드가 흔들린다면, 빌드 성능 이전에 안정성부터 정리하는 게 좋습니다. 관련 케이스는 Jenkins Docker 에이전트 Permission denied 7가지 해결도 함께 보세요.

왜 멀티아키 빌드가 느린가

멀티아키 빌드가 느려지는 원인은 크게 4가지입니다.

  1. 에뮬레이션(QEMU) 비용: amd64 호스트에서 arm64 바이너리를 실행/컴파일하면 CPU 명령어 변환 비용이 큽니다.
  2. 캐시 미스: CI가 매번 새 머신에서 시작되면 레이어 캐시가 거의 없고, 멀티아키는 아키별로 캐시가 분리되어 더 불리합니다.
  3. 컨텍스트 전송 비용: 빌드 컨텍스트에 불필요한 파일이 많으면(예: node_modules, .git, 빌드 산출물) 매번 업로드/해시 계산으로 시간이 낭비됩니다.
  4. Dockerfile 레이어 설계 문제: 자주 변하는 파일이 앞 레이어에 있으면 캐시가 계속 깨져서 매번 “풀빌드”가 됩니다.

BuildKit은 2~4번을 강하게 개선할 수 있고, 1번은 “에뮬레이션 구간을 줄이는 설계”로 완화할 수 있습니다.

BuildKit과 buildx 기본 세팅

BuildKit 활성화 확인

로컬에서 먼저 확인합니다.

docker buildx version

docker buildx ls

buildx 가 없다면 Docker Desktop 최신 버전 또는 리눅스는 패키지 업데이트가 필요합니다.

멀티아키 빌더 생성(컨테이너 드라이버 권장)

docker 드라이버(기본)는 제약이 많아 멀티아키 캐시/내보내기에서 불리할 때가 있습니다. 보통은 docker-container 드라이버를 추천합니다.

docker buildx create \
  --name multiarch-builder \
  --driver docker-container \
  --use

docker buildx inspect --bootstrap

QEMU 등록(필요 시)

amd64 머신에서 arm64 를 빌드하려면 QEMU가 필요합니다.

docker run --privileged --rm tonistiigi/binfmt --install all

이 단계가 빠지면 exec format error 류의 문제가 나거나, 특정 단계에서 실행이 실패할 수 있습니다.

5배 단축의 핵심 1: 원격 캐시(Registry cache) 붙이기

멀티아키 빌드에서 가장 큰 체감 차이는 원격 캐시입니다. 로컬 캐시만 쓰면 CI에서는 매번 초기화되고, 아키별 캐시가 분리되어 더 느립니다.

BuildKit은 레이어 캐시를 레지스트리에 푸시/풀 할 수 있습니다.

buildx 명령 예시(캐시 포함)

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t ghcr.io/my-org/my-app:sha-1234 \
  -t ghcr.io/my-org/my-app:latest \
  --push \
  --cache-from type=registry,ref=ghcr.io/my-org/my-app:buildcache \
  --cache-to type=registry,ref=ghcr.io/my-org/my-app:buildcache,mode=max \
  .
  • --cache-to ... mode=max 는 가능한 많은 캐시 메타데이터를 올려 재사용률을 높입니다.
  • --cache-from 은 이전 빌드 캐시를 가져옵니다.

이것만으로도 “매번 풀빌드”에서 “변경된 레이어만 빌드”로 바뀌며, CI 기준으로 2~5배까지 줄어드는 케이스가 흔합니다.

캐시가 잘 안 먹을 때 체크

  • 태그/레퍼런스가 매번 달라 캐시를 못 찾는지 확인
  • 빌드 컨텍스트가 매번 달라지는 파일(예: 타임스탬프 포함)을 앞 레이어에서 복사하는지 확인
  • RUN 단계에서 네트워크 다운로드가 매번 달라지는지 확인(예: apt-get update 만 단독으로 실행)

5배 단축의 핵심 2: Dockerfile 레이어 재구성(변하는 것과 안 변하는 것 분리)

BuildKit 캐시를 제대로 쓰려면 Dockerfile은 “캐시 친화적”이어야 합니다. 원칙은 간단합니다.

  • 자주 변하는 소스 코드는 최대한 뒤로
  • 의존성 설치는 앞에서, 입력 파일을 최소화

Node.js 예시(의존성 레이어 캐시 극대화)

# syntax=docker/dockerfile:1.7
FROM node:20-bookworm AS deps
WORKDIR /app

# 의존성 정의 파일만 먼저 복사
COPY package.json package-lock.json ./

# BuildKit 캐시 마운트로 npm 캐시 재사용
RUN --mount=type=cache,target=/root/.npm \
    npm ci

FROM node:20-bookworm AS build
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN npm run build

FROM node:20-bookworm-slim AS runner
WORKDIR /app

ENV NODE_ENV=production

COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json

CMD ["node", "dist/server.js"]

포인트:

  • COPY . . 를 최대한 뒤로 미룹니다.
  • RUN --mount=type=cache 는 빌드 컨테이너 내부 캐시를 유지해 반복 빌드를 빠르게 합니다.
  • # syntax=... 선언으로 최신 BuildKit 문법(캐시 마운트 등)을 안정적으로 씁니다.

5배 단축의 핵심 3: 컨텍스트 다이어트(.dockerignore)

빌드 컨텍스트가 커지면 해시 계산, 전송, 캐시 키가 모두 불리해집니다. 멀티아키는 아키별로 빌드가 돌아가므로 비용이 곱절로 커집니다.

.dockerignore 예시

.git
node_modules
npm-debug.log
.DS_Store
dist
build
coverage
.env
*.pem
*.key

특히 node_modules 를 컨텍스트에 넣는 실수는 멀티아키에서 치명적입니다. 의존성은 이미지 안에서 설치하고, 컨텍스트에는 “소스와 락파일”만 두는 편이 낫습니다.

5배 단축의 핵심 4: 에뮬레이션 구간 최소화(가능하면 크로스 컴파일)

arm64amd64 에서 빌드할 때 제일 느린 구간은 “타겟 아키 바이너리를 실행하는 단계”입니다. 예를 들어 go test, npm postinstall 의 네이티브 모듈 빌드, pip 의 소스 빌드 등이 여기에 해당합니다.

전략은 2가지입니다.

  1. 빌드 단계에서 타겟 아키 실행을 피한다
  2. 가능하면 크로스 컴파일로 산출물만 만든다

Go 멀티아키: 크로스 컴파일로 QEMU 회피

# syntax=docker/dockerfile:1.7
FROM --platform=$BUILDPLATFORM golang:1.22 AS build
WORKDIR /src

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

COPY . .

ARG TARGETOS
ARG TARGETARCH

RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
  • --platform=$BUILDPLATFORM 으로 빌드 스테이지는 호스트 아키에서 실행되게 합니다.
  • TARGETOS, TARGETARCH 로 결과물만 타겟 아키로 뽑습니다.
  • CGO_ENABLED=0 이 가능하다면 QEMU 의존도를 크게 줄입니다.

이 패턴은 멀티아키에서 가장 강력한 가속입니다.

GitHub Actions에서 재현 가능한 멀티아키 파이프라인

CI에서 “항상 빠르게” 만들려면 캐시와 빌더를 워크플로우에 고정해야 합니다.

name: build-multiarch
on:
  push:
    branches: ["main"]

jobs:
  docker:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-qemu-action@v3

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push (multi-arch)
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          platforms: linux/amd64,linux/arm64
          tags: |
            ghcr.io/my-org/my-app:latest
            ghcr.io/my-org/my-app:${{ github.sha }}
          cache-from: type=registry,ref=ghcr.io/my-org/my-app:buildcache
          cache-to: type=registry,ref=ghcr.io/my-org/my-app:buildcache,mode=max

이 구성의 장점:

  • 러너가 바뀌어도 레지스트리 캐시로 속도가 유지됩니다.
  • build-push-action 이 buildx를 표준 방식으로 구성해 실수 여지가 줄어듭니다.

빌드가 여전히 느릴 때: 병목 체크리스트

1) 캐시가 깨지는 레이어 찾기

BuildKit 로그를 자세히 보고 싶다면 --progress=plain 을 사용합니다.

docker buildx build --progress=plain \
  --platform linux/amd64,linux/arm64 \
  --cache-from type=registry,ref=ghcr.io/my-org/my-app:buildcache \
  --cache-to type=registry,ref=ghcr.io/my-org/my-app:buildcache,mode=max \
  --push -t ghcr.io/my-org/my-app:test .

자주 깨지는 구간이 보통 다음 중 하나입니다.

  • COPY . . 가 너무 앞에 있음
  • 의존성 설치 단계에 불필요한 파일이 섞임
  • 패키지 매니저가 매번 인덱스를 새로 받아 캐시가 무력화됨

2) 네트워크 다운로드를 캐시/미러로 줄이기

apt-get 은 다음처럼 한 레이어에 묶고, 가능하면 빌드 캐시 마운트를 고려합니다.

RUN --mount=type=cache,target=/var/cache/apt \
    apt-get update && \
    apt-get install -y --no-install-recommends curl ca-certificates && \
    rm -rf /var/lib/apt/lists/*

3) 멀티스테이지로 런타임을 가볍게

빌드가 빨라지는 것과 별개로, 런타임 이미지가 작아지면 푸시/풀 시간도 줄어 전체 파이프라인이 빨라집니다. 멀티아키는 푸시해야 할 레이어가 늘어나므로 이 효과가 더 큽니다.

운영 관점: 멀티아키 배포 후 장애 포인트

멀티아키 자체는 빌드 문제처럼 보이지만, 실제 운영에서는 “특정 아키에서만 죽는 컨테이너”가 더 위험합니다. 예를 들어 arm64 에서만 네이티브 라이브러리가 누락되거나, 엔트리포인트가 다른 바이너리를 가리키는 경우가 있습니다.

배포 후 컨테이너가 기동 실패/헬스체크 실패로 루프를 돈다면, 원인 분석은 인프라 레벨 체크리스트가 더 빠를 수 있습니다. 케이스별 접근은 K8s CrashLoopBackOff 원인별 로그·Probe 해결 가이드도 도움이 됩니다.

결론: “BuildKit 켜기”가 아니라 “캐시 설계”가 성능을 만든다

멀티아키 빌드를 5배 빠르게 만드는 실전 우선순위는 다음과 같습니다.

  1. 레지스트리 기반 원격 캐시를 붙인다: --cache-to--cache-from
  2. Dockerfile 레이어를 재구성한다: 의존성 먼저, 소스는 나중
  3. .dockerignore 로 컨텍스트를 줄인다
  4. 가능하면 크로스 컴파일로 QEMU 실행 구간을 제거한다

이 4가지만 제대로 적용해도, 멀티아키 빌드는 “느리지만 어쩔 수 없는 것”에서 “충분히 빠르게 자동화 가능한 것”으로 바뀝니다.