몇 년 전만 해도 도커 이미지는 사실상 amd64(x86_64) 하나면 충분했다. 하지만 이제 개발자 노트북은 arm64로 바뀌었고 클라우드에서도 arm64 인스턴스가 같은 성능을 더 싼 값에 제공하면서, 로컬에서 잘 돌던 이미지가 arm64 서버에서 exec format error로 죽는 일이 흔하다.

해결책은 멀티 아키텍처 이미지(multi-arch image)다. 하나의 태그 아래 amd64와 arm64 이미지를 함께 넣어두면 각 노드는 자기 아키텍처에 맞는 이미지를 알아서 골라 받는다. 이 글에서는 docker buildx와 매니페스트 리스트의 동작 원리, QEMU 에뮬레이션과 네이티브 빌드의 트레이드오프, CI 파이프라인, 멀티 아키텍처 빌드가 조용히 실패하는 함정까지 실제 설정과 함께 정리한다.

매니페스트 리스트: 하나의 태그, 여러 아키텍처

멀티 아키텍처 이미지의 핵심은 매니페스트 리스트(manifest list), 정식 명칭 OCI image index다. myapp:1.0 태그를 pull 할 때 레지스트리는 곧바로 레이어를 주는 게 아니라 먼저 매니페스트를 반환하는데, 멀티 아키텍처 이미지에서는 이 매니페스트가 “아키텍처별 실제 이미지의 목록”을 담은 인덱스다.

도커 데몬은 이 인덱스를 받아 자신의 os/arch에 맞는 항목을 골라 그것이 가리키는 실제 이미지와 레이어를 내려받는다. 즉 선택은 클라이언트가 pull 시점에 한다. 태그가 하나뿐이므로 배포 매니페스트나 docker run을 아키텍처별로 나눌 필요가 없다.

# 특정 태그가 어떤 아키텍처를 담고 있는지 확인
docker buildx imagetools inspect myregistry.io/myapp:1.0
# Manifests:
#   Platform:  linux/amd64   (sha256:aaa...)
#   Platform:  linux/arm64   (sha256:bbb...)

여기서 기억할 점은 매니페스트 리스트 자체는 레이어를 담지 않는 포인터 모음이라는 것이다. 실제 amd64·arm64 이미지는 각각 독립적으로 레지스트리에 저장되고 인덱스는 그 둘을 다이제스트로 참조할 뿐이다. 아키텍처가 다르면 레이어도 다르므로 둘은 공유되지 않지만, 태그 관리는 단일화된다.

buildx와 BuildKit: 무엇이 달라졌나

전통적인 docker build는 실행 호스트의 아키텍처로만 이미지를 만든다. 멀티 아키텍처를 만들려면 buildx가 필요하다. buildx는 BuildKit 엔진을 감싼 도구로, 여러 플랫폼 대상 병렬 빌드와 매니페스트 리스트 생성을 한 번에 처리한다.

buildx는 빌더 인스턴스(builder instance) 개념을 쓴다. 기본 docker 드라이버 빌더는 멀티 플랫폼 출력을 지원하지 않으므로, 컨테이너 안에서 BuildKit을 돌리는 docker-container 드라이버로 별도 빌더를 만들고 여기에 QEMU 에뮬레이터를 붙인다.

# docker-container 드라이버로 멀티 플랫폼 빌더 생성 후 기본으로 사용
docker buildx create --name multiarch --driver docker-container --use

# 부트스트랩 및 지원 플랫폼 확인
docker buildx inspect --bootstrap
# Platforms: linux/amd64, linux/arm64, linux/arm/v7, ...
# → 여기에 arm64 가 없다면 QEMU 등록이 안 된 것

빌더가 어떤 플랫폼을 지원하는지는 QEMU 등록 여부에 달려 있다. 리눅스 호스트에서는 binfmt_misc로 다른 아키텍처 바이너리를 QEMU로 자동 실행하도록 커널에 등록해야 한다. Docker Desktop은 이걸 기본으로 처리하지만, 순수 리눅스 CI 러너에서는 docker run --privileged --rm tonistiigi/binfmt --install all로 직접 등록해야 한다.

단일 명령으로 멀티 아키텍처 빌드하기

빌더가 준비되면 --platform에 쉼표로 여러 아키텍처를 나열해 한 번에 빌드·푸시할 수 있다. 중요한 제약이 하나 있다. 로컬 도커 저장소는 단일 아키텍처만 담을 수 있으므로 멀티 플랫폼 빌드 결과는 로컬에 로드할 수 없고, 반드시 --push로 레지스트리에 올리거나 --output type=oci로 파일에 뽑아야 한다.

# amd64 + arm64 동시 빌드 후 곧바로 레지스트리로 푸시
docker buildx build 
  --platform linux/amd64,linux/arm64 
  --tag myregistry.io/myapp:1.0 
  --push 
  .

# 참고: --load 는 단일 플랫폼에서만 동작한다
docker buildx build --platform linux/arm64 --load -t myapp:test .

이 명령 하나가 두 아키텍처 빌드를 병렬로 수행해 각각 푸시한 뒤, 두 다이제스트를 묶은 매니페스트 리스트를 만들어 myapp:1.0 태그에 붙인다.

Dockerfile을 크로스 컴파일 친화적으로 작성하기

성능을 가르는 핵심은 에뮬레이션을 피하는 것이다. QEMU로 arm64 안에서 컴파일러를 돌리면 네이티브 대비 수 배에서 수십 배까지 느려진다. Go나 Rust처럼 크로스 컴파일이 쉬운 언어라면 빌드는 호스트에서 네이티브로 돌리고 타깃 아키텍처만 바꿔 바이너리를 뽑는 편이 훨씬 빠르다.

BuildKit은 이를 위해 빌드 인자로 BUILDPLATFORM(빌드가 실제 도는 곳)과 TARGETPLATFORM(결과물의 대상)을 자동 주입한다. FROM --platform=$BUILDPLATFORM으로 빌드 스테이지를 호스트에 고정하면 컴파일러는 네이티브로 실행되고, 타깃만 인자로 넘겨 크로스 컴파일한다.

# 빌드 스테이지는 호스트 네이티브로 실행 → 에뮬레이션 회피
FROM --platform=$BUILDPLATFORM golang:1.22 AS build
ARG TARGETOS TARGETARCH   # BuildKit 이 자동 주입 (linux, amd64/arm64)
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Go 크로스 컴파일: 컴파일러는 네이티브, 산출물만 타깃 아키텍처
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH 
    go build -o /out/app ./cmd/app

# 런타임 스테이지는 타깃(TARGETPLATFORM) 이미지 사용
FROM alpine:3.19
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]

이 방식이면 amd64 러너 하나에서 두 아키텍처 바이너리를 모두 네이티브 속도로 뽑고 런타임 베이스 이미지만 각 아키텍처용으로 pull 한다. 다만 인터프리터 언어에서 C 확장을 빌드하는 pip install이나 npm rebuild 단계는 여전히 에뮬레이션 비용을 그대로 받는다.

에뮬레이션이냐 네이티브 러너냐

크로스 컴파일이 어려운 워크로드(C 확장이 많은 파이썬 이미지 등)에서는 두 가지 선택지가 있다. QEMU 에뮬레이션은 설정이 단순하고 러너 한 대면 되지만 무거운 컴파일이 돌면 빌드가 몇 배 느려지고 드물게 에뮬레이션 버그로 세그폴트가 나기도 한다. 네이티브 멀티 러너는 각 아키텍처 러너가 자기 것을 네이티브로 빌드한 뒤 매니페스트 리스트로 합치므로 빠르고 안정적이지만 러너 인프라가 늘어난다.

네이티브 멀티 러너 방식에서는 각 러너가 자기 아키텍처 이미지를 태그 없이 다이제스트로만 푸시하고, 별도의 병합 단계에서 imagetools create로 두 다이제스트를 묶는다. 태그 충돌 없이 안전하게 합치는 표준 패턴이다.

# [각 러너] 태그 대신 다이제스트로만 푸시 (push-by-digest)
docker buildx build --platform linux/arm64 
  --output "type=image,name=myregistry.io/myapp,push-by-digest=true,name-canonical=true" 
  --iidfile arm64.digest .

# [병합 단계] 각 러너가 남긴 다이제스트를 하나의 태그로 묶기
docker buildx imagetools create 
  --tag myregistry.io/myapp:1.0 
  myregistry.io/myapp@$(cat amd64.digest) 
  myregistry.io/myapp@$(cat arm64.digest)

CI 파이프라인에 통합하기

CI에서는 QEMU 등록 → buildx 빌더 생성 → 멀티 플랫폼 빌드·푸시 순서를 밟는다. 빌드 캐시를 레지스트리에 저장하면 다음 실행에서 변하지 않은 레이어를 재사용해 시간을 크게 줄인다. 아래 GitLab CI 예시에서 캐시를 type=registry로 저장하는 부분이 핵심이다.

build-multiarch:
  stage: build
  image: docker:26
  services:
    - docker:26-dind
  variables:
    DOCKER_BUILDKIT: "1"
  before_script:
    # QEMU 등록 (arm64 크로스 실행 활성화)
    - docker run --privileged --rm tonistiigi/binfmt --install all
    - docker buildx create --name ci --driver docker-container --use
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
  script:
    - docker buildx build
        --platform linux/amd64,linux/arm64
        --cache-from type=registry,ref=$CI_REGISTRY_IMAGE:buildcache
        --cache-to   type=registry,ref=$CI_REGISTRY_IMAGE:buildcache,mode=max
        --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
        --tag $CI_REGISTRY_IMAGE:latest
        --push
        .

여기서 mode=max는 중간 빌드 스테이지 레이어까지 모두 캐시에 저장한다는 뜻이다. 멀티 스테이지 Dockerfile에서 빌드 스테이지 캐시가 재사용되어야 효과가 크므로 대부분 유리하지만, 캐시 용량이 커지므로 스토리지 비용과 저울질해야 한다.

흔한 함정과 검증

멀티 아키텍처 빌드는 “성공”이 떠도 실제로는 반쪽만 만들어진 경우가 잦다. 대표적인 함정은 다음과 같다.

  • 베이스 이미지가 arm64를 지원하지 않음: FROM 이미지가 arm64 매니페스트를 갖고 있지 않으면 arm64 빌드가 실패한다. 오래된 사내·벤더 이미지에서 자주 발생하므로 docker buildx imagetools inspect <base>로 먼저 확인해야 한다.
  • 아키텍처 종속 바이너리 다운로드: curl로 amd64 전용 바이너리를 받으면 arm64 이미지에 amd64 바이너리가 들어가 실행 시 죽는다. Docker 아키텍처명(amd64/arm64)과 도구 명칭(x86_64/aarch64)이 다른 경우가 많으므로 case "$TARGETARCH" 분기로 URL을 매핑해야 한다.
  • QEMU 미등록: 빌더가 arm64를 지원하지 않는데 --platform에 넣으면 실패한다.

빌드가 끝나면 반드시 매니페스트를 검증한다. 가장 확실한 방법은 arm64 이미지를 실제 arm64 환경에서 돌려보는 것이지만, 최소한 인덱스에 두 플랫폼이 모두 존재하는지는 CI에서 자동으로 점검할 수 있다.

# 매니페스트에 두 아키텍처가 모두 있는지 CI에서 자동 검증
for p in linux/amd64 linux/arm64; do
  docker buildx imagetools inspect "$IMAGE:$TAG" 
    --format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}}{{"n"}}{{end}}' 
    | grep -qx "$p" || { echo "누락된 플랫폼: $p"; exit 1; }
done

마무리

멀티 아키텍처 이미지는 이제 특수한 요구가 아니라 기본 소양에 가깝다. 핵심은 세 가지다. 하나의 태그가 매니페스트 리스트로 여러 아키텍처를 가리키며 선택은 pull 시점에 일어난다는 구조, 가능하면 --platform=$BUILDPLATFORM과 TARGETARCH로 크로스 컴파일해 에뮬레이션을 피하는 것, 크로스 컴파일이 어려우면 네이티브 멀티 러너로 각자 빌드한 뒤 imagetools create로 합치는 것이다.

모든 선택에는 비용이 있다. QEMU는 설정이 간단한 대신 느리고, 네이티브 멀티 러너는 빠른 대신 인프라가 늘어난다. 워크로드가 컴파일 중심인지 인터프리터 중심인지 먼저 재보고 결정하되, 무엇을 고르든 빌드 후 매니페스트에 두 아키텍처가 모두 담겼는지 검증하는 단계만큼은 파이프라인에서 빼지 말아야 한다.

자주 묻는 질문

Q. 멀티 아키텍처 이미지를 --load로 로컬에서 실행해볼 수는 없나요?
A. 로컬 저장소는 단일 아키텍처만 담을 수 있어 전체를 --load 할 수 없습니다. 대신 테스트하려는 아키텍처 하나만 --platform linux/arm64 --load로 지정하면 그 이미지만 로컬에 로드해 실행해볼 수 있습니다.

Q. arm64 맥에서 amd64 이미지도 함께 빌드하면 느리지 않나요?
A. Go/Rust처럼 크로스 컴파일이 되는 언어라면 --platform=$BUILDPLATFORM으로 빌드 스테이지를 네이티브(arm64)에 고정하고 amd64 바이너리만 크로스 컴파일하므로 거의 느려지지 않습니다. 반면 C 확장을 소스에서 컴파일하는 이미지라면 amd64 부분이 QEMU로 에뮬레이션되어 느려지므로 CI의 amd64 네이티브 러너를 함께 쓰는 편이 낫습니다.