- Published on
Docker buildx QEMU 느림·오류 해결 7단계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 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에서Platforms에linux/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: BUILDPLATFORM과 TARGETPLATFORM을 분리
멀티스테이지 빌드에서 빌드 도구(예: Go/Rust/Node 빌드)는 BUILDPLATFORM에서 실행하고, 최종 결과물만 TARGETPLATFORM으로 옮깁니다.
예시: Go
# syntax=docker/dockerfile:1.7
ARG TARGETPLATFORM
ARG BUILDPLATFORM
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN 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 /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 \
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를 타는지”를 로그에서 짚어 최적화 포인트를 구체적으로 잡아드릴 수 있습니다.