Published on

Docker buildx QEMU 느림·오류 해결 7단계

Authors

서버에서 linux/amd64 이미지를 만들던 파이프라인을 linux/arm64까지 확장하는 순간, Docker buildx + QEMU 조합이 갑자기 느려지거나(수십 분 단위), 특정 단계에서만 빌드가 터지는 경험을 자주 합니다. 특히 CI에서만 간헐적으로 실패하는 경우가 많아 “재시도하면 되긴 하는데” 상태로 방치되기 쉽습니다.

이 글은 멀티아키텍처 빌드에서 QEMU가 느려지는 구조적 이유를 짚고, 흔한 오류 패턴을 7단계로 나눠 빠르게 수습하는 방법을 정리합니다. 목표는 두 가지입니다.

  • QEMU가 꼭 필요한 구간과 아닌 구간을 분리해 속도를 정상화
  • 에뮬레이션 특유의 불안정/호환성 오류를 재현 가능하게 만들고 제거

캐시가 꼬여 “어제는 됐는데 오늘은 안 됨” 같은 상황은 빌드에서도 매우 흔합니다. 원인이 캐시/재검증에 있을 때의 접근법은 Next.js App Router 캐시 꼬임·재검증 버그 해결 글의 사고방식(재현, 무효화, 관측 포인트 분리)도 도움이 됩니다.

0. 전제: QEMU가 느린 이유를 한 문장으로

QEMU는 다른 CPU 아키텍처의 명령을 소프트웨어로 번역합니다. 번역 비용은 CPU 바운드이고, 특히 다음이 겹치면 급격히 느려집니다.

  • 패키지 설치(컴파일/링크)처럼 CPU를 많이 쓰는 단계
  • I/O가 많은 단계(압축 해제, 파일 수십만 개 생성)
  • 레이어 캐시가 잘 안 먹는 Dockerfile 구조

따라서 “QEMU를 빠르게” 만들기보다, 현실적으로는 QEMU를 덜 타게 만들고, 타야 한다면 관측/캐시/빌더 설정으로 손실을 줄이는 게 핵심입니다.


1단계: 먼저 “정말 QEMU를 타고 있는지” 확인

빌드가 느린데 원인이 QEMU가 아닐 수도 있습니다(네트워크, 레지스트리 제한, 캐시 미스 등). 아래처럼 빌드 로그와 환경을 확인합니다.

체크 포인트

  • 빌드 플랫폼과 타깃 플랫폼이 다른가
  • binfmt_misc 등록이 되어 있는가
  • buildx 빌더가 어떤 드라이버/노드를 쓰는가

명령어

docker buildx version

docker buildx ls

docker buildx inspect --bootstrap

# QEMU 등록 상태(리눅스)
ls -al /proc/sys/fs/binfmt_misc/
cat /proc/sys/fs/binfmt_misc/qemu-aarch64 2>/dev/null || true

흔한 징후

  • docker buildx inspect에서 Platformslinux/arm64가 보이는데도 빌드가 실패한다면, QEMU는 등록됐지만 버전/호환성 문제가 있을 수 있습니다.
  • 로컬에서는 빠른데 CI에서만 느리면, CI 머신의 CPU 제한/동시 실행/스토리지 성능 문제가 QEMU 비용을 증폭시키는 경우가 많습니다.

2단계: QEMU/binfmt를 “최신으로 재등록”해 이상 증상을 제거

가장 흔한 원인 중 하나가 오래된 QEMU 사용자 공간 에뮬레이터입니다. 특히 exec format error, illegal instruction, 특정 바이너리 실행 시 즉사 같은 증상이 나타납니다.

해결: tonistiigi/binfmt로 재설치

# 권장: 필요한 아키텍처만
docker run --privileged --rm tonistiigi/binfmt --install arm64,amd64

# 모두 설치(디버깅 단계에서만)
docker run --privileged --rm tonistiigi/binfmt --install all

확인

docker run --rm --platform linux/arm64 alpine uname -m
# 기대값: aarch64

자주 보는 오류와 의미

  • exec format error: 해당 플랫폼 실행을 커널이 처리 못함(대개 binfmt 미등록/깨짐)
  • illegal instruction: QEMU 버그/호환성 또는 이미지 내부 바이너리가 특정 CPU 기능을 가정

3단계: 빌더를 docker-container로 고정하고 격리한다

buildx는 빌더 드라이버에 따라 캐시/동작이 달라집니다. 멀티아키 빌드는 docker-container 드라이버가 가장 예측 가능하고, CI에서도 재현이 쉽습니다.

새 빌더 생성(권장)

docker buildx create --name multiarch --driver docker-container --use
docker buildx inspect --bootstrap

왜 필요한가

  • 호스트 Docker 데몬과 빌더가 분리되어 캐시/의존성이 덜 꼬입니다.
  • 빌더 컨테이너에 리소스 제한/네트워크 설정을 부여하기 쉽습니다.

4단계: Dockerfile을 “QEMU를 덜 타는 구조”로 재배치

속도 개선의 80%는 여기서 나옵니다. 핵심은 빌드 타임에 실행되는 바이너리를 가능하면 빌드 머신 아키텍처에서 실행시키는 것입니다.

원칙 1: BUILDPLATFORMTARGETPLATFORM을 분리

멀티스테이지 빌드에서 빌드 도구(예: Go/Rust/Node 빌드)는 BUILDPLATFORM에서 실행하고, 최종 결과물만 TARGETPLATFORM으로 옮깁니다.

예시: Go

# syntax=docker/dockerfile:1.7

ARG TARGETPLATFORM
ARG BUILDPLATFORM

FROM --platform=$BUILDPLATFORM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
# TARGETPLATFORM에 맞춰 크로스 컴파일(에뮬레이션 실행 최소화)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12 AS runtime
COPY --from=builder /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]

여기서 중요한 점은, RUN go build--platform=$BUILDPLATFORM에서 실행되므로 QEMU를 타지 않는다는 것입니다(크로스 컴파일로 해결).

원칙 2: 패키지 설치 레이어를 캐시 친화적으로

apt-get/apk add는 레이어 캐시가 깨지면 QEMU에서 재실행되어 매우 느려집니다.

  • COPY . . 전에 의존성 파일만 먼저 복사
  • BuildKit 캐시 마운트 활용

예시: Debian/Ubuntu

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

원칙 3: RUN에서 CPU 먹는 컴파일을 피할 수 없으면 “네이티브 빌더”를 고려

C/C++/Rust처럼 크로스 컴파일 난이도가 높고 빌드 시간이 긴 경우, QEMU는 거의 답이 없습니다. 이때는 6단계(네이티브 노드 추가)를 바로 검토하세요.


5단계: 캐시 전략을 명시해 CI에서 속도/안정성 확보

QEMU가 느린 상황에서 캐시가 안 먹으면, 매번 “처음부터 에뮬레이션”이라 빌드 시간이 폭발합니다. BuildKit의 캐시를 레지스트리에 저장해 CI 간 재사용하는 것이 효과적입니다.

cache-to/cache-from 사용

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t ghcr.io/acme/myapp:sha-${GITHUB_SHA} \
  -t ghcr.io/acme/myapp:latest \
  --cache-from type=registry,ref=ghcr.io/acme/myapp:buildcache \
  --cache-to type=registry,ref=ghcr.io/acme/myapp:buildcache,mode=max \
  --push .

실전 팁

  • mode=max는 캐시를 많이 저장해 재사용률을 올리지만 레지스트리 비용이 늘 수 있습니다.
  • 캐시가 “오염”되어 이상 동작이 의심되면 캐시 키(ref)를 바꿔 격리합니다. (예: 브랜치별 캐시)

6단계: QEMU를 버리고 “네이티브 arm64 빌더 노드”를 추가

가장 확실한 해결책입니다. 특히 아래 조건이면 QEMU 최적화보다 네이티브가 정답인 경우가 많습니다.

  • 이미지 빌드에 컴파일 단계가 포함(Rust, C++, Python wheels 등)
  • 빌드 시간이 10분을 넘어가며 CI 비용이 민감
  • QEMU에서만 간헐적으로 세그폴트/타임아웃 발생

구성: buildx builder에 노드 추가

예를 들어 amd64 머신(기존 CI 러너) + arm64 머신(예: Graviton, Apple Silicon VM, arm64 베어메탈)을 함께 묶습니다.

# amd64 노드에서 builder 생성
docker buildx create --name multi --driver docker-container --use

# arm64 노드를 원격 Docker 컨텍스트로 추가(예: SSH)
docker context create arm64ctx --docker "host=ssh://ubuntu@arm64-builder"

docker buildx create --append --name multi arm64ctx

docker buildx inspect --bootstrap

이제 --platform linux/arm64 빌드는 arm64 노드에서 네이티브로 수행되어 속도와 안정성이 크게 개선됩니다.

운영에서도 “노드는 살아 있는데 특정 마운트/네트워크에서만 지연” 같은 케이스가 있습니다. 빌더 노드가 원격 스토리지(EFS 등)에 의존한다면, 지연 원인 분석 방식은 EKS에서 Pod는 뜨는데 EFS Mount 타임아웃 해결 글의 체크리스트가 유사하게 적용됩니다.


7단계: 자주 터지는 오류 패턴별 처방(재현 중심)

마지막은 “느림”이 아니라 “오류”를 빠르게 끝내는 단계입니다. QEMU는 환경에 민감해서, 로그를 남기고 재현성을 확보하는 게 중요합니다.

패턴 A: exec format error

  • 원인: binfmt 미설치/깨짐, --platform 불일치
  • 처방:
    • 2단계 재등록
    • docker buildx build --platform ... 값 확인
    • 베이스 이미지가 해당 아키텍처를 지원하는지 확인
docker buildx imagetools inspect alpine:3.19

패턴 B: qemu: uncaught target signal 11 (Segmentation fault)

  • 원인: QEMU 버그/리소스 부족/특정 바이너리 조합
  • 처방:
    • QEMU 재등록 + 빌더 재생성(3단계)
    • 병렬 빌드/테스트를 줄여 메모리 여유 확보
    • 가능하면 6단계(네이티브 arm64)로 전환

패턴 C: illegal instruction

  • 원인: 이미지 내부 바이너리가 특정 CPU feature를 가정하거나, QEMU 호환 문제
  • 처방:
    • 해당 바이너리를 제공하는 패키지/베이스 이미지를 교체
    • 컴파일 플래그에서 지나친 CPU 최적화 제거(예: -march=native 금지)

패턴 D: TLS/네트워크 관련으로만 느리거나 실패

  • 원인: CI 네트워크 제한, DNS/프록시, 레지스트리 rate limit
  • 처방:
    • --progress=plain으로 로그를 평문으로 보고 어디서 멈추는지 확인
    • 의존성 다운로드를 캐시(5단계)
docker buildx build --progress=plain --no-cache \
  --platform linux/arm64 \
  -t test:debug .

패턴 E: 캐시 때문에 “가끔만” 깨짐

  • 원인: 비결정적 빌드(시간/네트워크/미고정 버전), 캐시 레이어 오염
  • 처방:
    • 패키지 버전 고정, 체크섬 검증
    • 캐시 ref를 브랜치/주기별로 분리

권장 체크리스트(요약)

  • binfmt를 최신으로 재등록했는가(2단계)
  • 빌더를 docker-container로 고정했는가(3단계)
  • Dockerfile에서 빌드 도구 실행을 BUILDPLATFORM으로 옮겼는가(4단계)
  • cache-to/cache-from으로 CI 캐시를 강제했는가(5단계)
  • 컴파일이 무겁다면 네이티브 arm64 노드로 전환했는가(6단계)
  • 오류는 --progress=plain으로 재현 로그를 확보했는가(7단계)

멀티아키 빌드는 “한 번 설정하면 끝”이 아니라, 프로젝트 특성(컴파일 비중, 의존성 다운로드, 캐시 히트율)에 따라 최적점이 달라집니다. 위 7단계를 순서대로 적용하면, QEMU를 억지로 튜닝하기보다 QEMU 의존도를 낮추고, 필요한 곳에는 캐시/노드 전략으로 비용을 줄이는 방향으로 안정적으로 수렴합니다.

추가로 원하시면, 사용 중인 Dockerfile과 CI 환경(GitHub Actions/GitLab CI/Jenkins 등), 타깃 플랫폼 조합(linux/amd64 + linux/arm64 등)을 기준으로 “어느 단계가 QEMU를 타는지”를 로그에서 짚어 최적화 포인트를 구체적으로 잡아드릴 수 있습니다.