- Published on
Docker 멀티아키 빌드 느림? Buildx+QEMU 최적화 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Docker 멀티아키 이미지(linux/amd64, linux/arm64)를 Buildx로 빌드하면, 로컬에서는 그럭저럭인데 CI에서 갑자기 수십 분까지 늘어지는 경우가 흔합니다. 대부분의 병목은 QEMU 에뮬레이션 자체의 느림, 캐시가 깨지는 Dockerfile 구조, 그리고 원격 캐시/레지스트리 설정 부재가 겹쳐서 발생합니다.
이 글은 "왜 느린지"를 원인 단위로 쪼개고, Buildx와 QEMU 조합에서 바로 적용 가능한 최적화 7가지를 실전 관점으로 정리합니다. (예시는 GitHub Actions 기준이지만 원리는 동일합니다.)
참고로 CI 캐시가 기대처럼 동작하지 않아 속도가 급락하는 문제는 GitHub Actions 캐시가 안 먹을 때 - 키 전략·디버깅, 캐시 무효화로 빌드가 느려지는 케이스는 GitHub Actions 캐시 무효화로 빌드가 느릴 때도 함께 보면 원인 추적에 도움이 됩니다.
멀티아키 빌드가 느려지는 3가지 대표 병목
1) arm64 를 amd64 머신에서 QEMU로 빌드
에뮬레이션은 CPU 명령을 번역하므로 컴파일/압축/링킹 작업이 특히 느려집니다. go build, npm ci, pip install 같은 단계가 대표적인 폭탄입니다.
2) 캐시가 레이어 단위로 재사용되지 않음
COPY . . 가 너무 이른 단계에 있거나, lockfile이 바뀌지 않았는데도 의존성 설치 레이어가 매번 깨지면 멀티아키 빌드 시간은 기하급수로 늘어납니다.
3) BuildKit 캐시를 원격으로 공유하지 못함
로컬에서는 빠른데 CI에서 느린 이유는 대부분 "캐시가 매번 새로"이기 때문입니다. cache-to/cache-from 또는 레지스트리 기반 캐시를 쓰지 않으면 매 빌드가 콜드 스타트가 됩니다.
최적화 1) QEMU는 "최후의 수단"으로: 가능하면 네이티브 빌더를 섞기
가장 큰 개선은 에뮬레이션을 줄이는 것입니다.
- 방법 A:
amd64는 기존 CI 머신에서 빌드,arm64는 ARM 러너(예: AWS Graviton, self-hosted arm64)에서 빌드 - 방법 B: Buildx의
driver=kubernetes로 아키텍처별 노드 풀을 사용
QEMU 최적화는 분명 효과가 있지만, "컴파일이 많은 이미지"는 네이티브로 옮기는 순간 5배 이상 빨라지는 경우도 흔합니다.
최적화 2) Buildx 빌더를 제대로 만들고, BuildKit 설정을 고정
CI에서 매번 새 빌더를 만들면 캐시가 사라지거나, 설정이 흔들리면서 성능이 출렁일 수 있습니다. 아래처럼 빌더를 명시적으로 만들고 사용하세요.
docker buildx create --name ci-builder --use
docker buildx inspect --bootstrap
GitHub Actions 예시:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
install: true
driver-opts: |
image=moby/buildkit:latest
핵심은 "어떤 BuildKit을 쓰는지"를 고정하는 것입니다. 런너 업데이트로 BuildKit 버전이 바뀌면 캐시 호환성이나 성능이 미묘하게 달라질 수 있습니다.
최적화 3) 레지스트리 기반 원격 캐시를 강제 적용
멀티아키 빌드는 플랫폼이 2배인 것보다, "캐시가 없어서 2번 빌드"가 더 치명적입니다. Buildx는 캐시를 레지스트리에 저장할 수 있습니다.
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag ghcr.io/acme/app:sha-123 \
--cache-to type=registry,ref=ghcr.io/acme/app:buildcache,mode=max \
--cache-from type=registry,ref=ghcr.io/acme/app:buildcache \
--push .
mode=max: 중간 레이어까지 최대한 저장(캐시 히트율 상승)- 레지스트리 캐시는 CI 워크스페이스가 날아가도 유지
주의: 사설 레지스트리나 GHCR 권한 설정이 부실하면 캐시 푸시가 실패하면서 "캐시 없는 빌드"로 회귀합니다. 빌드 로그에서 cache-to 가 실제로 업로드되는지 확인하세요.
최적화 4) Dockerfile 레이어를 "캐시 친화적으로" 재구성
멀티아키에서 가장 흔한 실수는 의존성 설치 전에 소스 전체를 복사하는 것입니다. 예를 들어 Node는 package-lock.json 이 바뀌지 않으면 npm ci 레이어를 재사용할 수 있어야 합니다.
나쁜 예:
FROM node:20
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
좋은 예:
FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
Python도 동일합니다.
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN pip install -U pip && pip install poetry && poetry install --no-root
COPY . .
CMD ["python", "-m", "app"]
이 구조만으로도 QEMU 환경에서 "의존성 설치"가 매번 돌지 않게 되어 체감이 크게 좋아집니다.
최적화 5) BuildKit 캐시 마운트로 패키지 다운로드를 재사용
레지스트리 캐시가 "레이어" 단위라면, 캐시 마운트는 빌드 중 다운로드 디렉터리를 재사용하게 해줍니다. 특히 QEMU에서 네트워크+압축이 겹치면 설치가 매우 느려지므로 효과가 큽니다.
Node 예시:
# syntax=docker/dockerfile:1.7
FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
RUN \
npm ci
COPY . .
RUN npm run build
APT 예시:
# syntax=docker/dockerfile:1.7
FROM debian:bookworm-slim
RUN \
apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*
- 캐시 마운트는 레이어가 아니라 "빌드 캐시"에 저장됩니다.
- 따라서
cache-to/cache-from를 함께 구성해야 CI에서도 재사용됩니다.
최적화 6) QEMU 세팅을 최신으로, 그리고 필요한 아키만 등록
CI에서 QEMU를 설치할 때는 binfmt 등록이 핵심입니다. 다음 액션을 쓰면 편합니다.
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
포인트는 두 가지입니다.
platforms를 필요한 것만 등록해 불필요한 핸들러를 줄입니다.- QEMU/
binfmt는 버전 차이로 성능 편차가 생길 수 있어, 액션 버전과 런너 이미지를 고정하는 것이 좋습니다.
추가로, "에뮬레이션에서 특히 느린 단계"(예: 대형 네이티브 모듈 컴파일)가 있다면 해당 단계만 플랫폼별로 분기해 네이티브 빌더에서 수행하도록 설계를 바꾸는 것도 고려하세요.
최적화 7) 멀티스테이지와 아티팩트 분리로 QEMU 작업량 자체를 줄이기
QEMU 최적화의 본질은 "에뮬레이션에서 CPU 많이 쓰는 일을 안 하게" 만드는 것입니다. 멀티스테이지로 빌드/런타임을 분리하고, 런타임에는 결과물만 복사하세요.
Go 예시(정적 바이너리):
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN \
go mod download
COPY . .
ARG TARGETOS
ARG TARGETARCH
RUN \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12
COPY /out/app /app
ENTRYPOINT ["/app"]
여기서 중요한 점은 --platform=$BUILDPLATFORM 입니다.
- 빌드 스테이지는 "빌드 머신" 아키에서 동작
- Go는 크로스 컴파일이 가능하므로, QEMU 없이도
arm64바이너리를 만들 수 있습니다
반대로, 크로스 컴파일이 어려운 생태계(예: 일부 Node 네이티브 모듈, Python C-extension)는 네이티브 빌더로 분리하는 편이 낫습니다.
CI에서 바로 쓰는 권장 조합(GitHub Actions)
아래는 "QEMU + 원격 캐시 + 멀티아키 푸시"까지 한 번에 묶은 예시입니다.
name: build-and-push
on:
push:
branches: [ main ]
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ghcr.io/acme/app:${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/acme/app:buildcache
cache-to: type=registry,ref=ghcr.io/acme/app:buildcache,mode=max
이 구성에서 빌드가 여전히 느리다면, 다음 순서로 확인하면 원인 파악이 빠릅니다.
- 로그에서
CACHED가 충분히 뜨는가(레이어 캐시 히트율) cache-to업로드가 실제로 성공하는가(권한/레지스트리)- 느린 단계가 의존성 설치인지, 컴파일인지, 압축/링킹인지(병목 위치)
- 병목이
arm64에만 집중되는가(QEMU 문제)
마무리: "캐시"와 "에뮬레이션 회피"가 80%를 해결
Buildx+QEMU 멀티아키 빌드는 구조적으로 느릴 수밖에 있지만, 실무에서 느려지는 이유는 대개 두 가지로 수렴합니다.
- 캐시가 깨지는 Dockerfile/CI 구성
- QEMU에서 무거운 작업을 그대로 수행
이 글의 7가지 중에서 우선순위를 꼽으면 원격 캐시(cache-to/cache-from) 와 Dockerfile 레이어 재구성, 그리고 가능하면 네이티브 빌더로 분리가 가장 효과가 큽니다. 캐시가 안정적으로 먹기 시작하면 멀티아키 빌드는 "처음만 느리고 그 다음부터 빨라지는" 형태로 바뀌고, 배포 파이프라인 전체 리드타임도 눈에 띄게 줄어듭니다.