도커 고급 강좌 #2 멀티 아키텍처 이미지 — amd64와 arm64 한 묶음

7 분 소요

Apple Silicon 머신이 일반화되고, AWS Graviton(ARM) 같은 ARM 운영 환경이 널리 쓰이면서 — 한 이미지가 두 아키텍처 모두에서 도는 일이 표준이 됐습니다. 이번 글은 그 흐름을 본격적으로 다룹니다.

도커 고급 강좌 시리즈에서 이번 글의 위치:

  • #1 BuildKit과 buildx
  • #2 멀티 아키텍처 이미지 — linux/amd64 + linux/arm64 ← 이번 글
  • #3 이미지 보안 — non-root, distroless, scan(Trivy)
  • #4 SBOM과 서명(cosign)
  • #5 리소스 제한과 cgroups
  • #6 프로덕션 운영 — restart 정책, healthcheck, graceful shutdown

가장 흔한 사고 — Apple Silicon ↔ amd64 #

이런 경험을 한 번쯤 합니다.

로컬 (M1/M2/M3)
docker build -t myapp:1.0 .
docker push ghcr.io/me/myapp:1.0

# 운영 서버 (amd64)
docker pull ghcr.io/me/myapp:1.0
docker run myapp:1.0
# exec /myapp: exec format error

exec format error. 운영 서버가 받은 바이너리가 arm64 용 이라 amd64 CPU가 실행을 못 한 거입니다. Apple Silicon의 도커는 기본이 arm64 이미지를 만듭니다. 푸시할 땐 그걸 그대로 올렸고, 운영에서 받았을 땐 호환 가능한 변형이 없어서 arm64가 amd64 호스트에서 실행을 시도한 결과입니다.

해결은 두 갈래:

  1. 단일 amd64 이미지를 강제로 만든다--platform linux/amd64
  2. 두 아키텍처를 모두 묶은 멀티 아키 이미지를 만든다--platform linux/amd64,linux/arm64

운영 환경이 한 종류라면 1, 두 종류 이상이거나 미래에 어디로 갈지 모르면 2가 정석입니다. 요즘은 거의 항상 2.

Manifest list — 한 태그가 여러 이미지를 가리키는 구조 #

멀티 아키 이미지의 정체부터 봅시다. ghcr.io/me/myapp:1.0이라는 한 태그가, 사실은 여러 이미지를 묶은 manifest list (OCI 명세에선 image index) 일 수 있습니다.

manifest list의 구조
ghcr.io/me/myapp:1.0   ← manifest list (image index)
   ├── linux/amd64    → image manifest @sha256:aaa...
   ├── linux/arm64    → image manifest @sha256:bbb...
   └── linux/arm/v7   → image manifest @sha256:ccc...

도커 클라이언트가 pull 할 때, 자기 호스트의 OS / 아키에 맞는 매니페스트를 자동으로 골라 다운받습니다. 사용자는 단일 태그 한 줄만 알면 됩니다.

docker buildx imagetools로 들여다보면 분명해집니다.

manifest list 인스펙트
docker buildx imagetools inspect python:3.14
# Name:      docker.io/library/python:3.14
# MediaType: application/vnd.oci.image.index.v1+json
# Digest:    sha256:abc...
#
# Manifests:
#   Name:      docker.io/library/python:3.14@sha256:111...
#   MediaType: application/vnd.oci.image.manifest.v1+json
#   Platform:  linux/amd64
#
#   Name:      docker.io/library/python:3.14@sha256:222...
#   MediaType: application/vnd.oci.image.manifest.v1+json
#   Platform:  linux/arm64
#
#   ... (linux/arm/v7, linux/386, linux/ppc64le, linux/s390x ...)

공식 이미지들은 보통 6~8 개 아키텍처를 동시에 지원합니다.

빌드 — 두 아키 동시에 #

buildx + docker-container 드라이버 위에서 한 명령으로 만듭니다.

멀티 아키 빌드
# 첫 번째: docker-container 드라이버 빌더 만들기 ([#1] 참고)
docker buildx create --name multi --driver docker-container --use --bootstrap

# 빌드 + 푸시
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t ghcr.io/me/myapp:1.0 \
  --push .

여기서 짚어둘 두 사실:

  1. --load는 단일 플랫폼만 가능. 멀티 플랫폼은 --push 또는 --output type=oci,...
  2. 단일 호스트에서 두 아키를 만드는 건 한쪽이 emulation으로 돈다 — 그래서 느릴 수 있음

어떤 플랫폼이 가능한가 #

현재 빌더의 지원 플랫폼
docker buildx inspect multi
# ...
# Platforms: linux/amd64, linux/arm64, linux/arm/v7,
#            linux/arm/v6, linux/386, linux/ppc64le, linux/s390x

기본 docker-container 드라이버는 BuildKit 컨테이너 안에 QEMU emulation이 같이 들어 있어 위 모든 플랫폼을 빌드할 수 있습니다. 호스트 CPU와 다른 아키는 emulation으로 도는 것입니다.

QEMU emulation — 가능한가, 빠른가 #

QEMU는 한 아키 위에서 다른 아키 코드를 돌리는 에뮬레이터입니다. 도커는 리눅스의 binfmt_misc와 QEMU를 묶어서, amd64 호스트에서 arm64 컨테이너를 돌릴 수 있게 해줍니다.

binfmt 등록 (한 번만)
docker run --privileged --rm tonistiigi/binfmt --install all

Docker Desktop은 이 등록을 자동으로 해 둡니다. 리눅스 CI 환경에선 명시적으로 해야 할 때가 있습니다.

비용 #

작업같은 아키QEMU emulation
apt-get install빠름2~5배 느림
컴파일 (gcc, tsc)빠름3~10배 느림
작은 스크립트 / COPY빠름거의 동일

체감 차이는 빌드의 무게에 따라 큽니다. 가벼운 Python/Node 앱은 emulation만으로 충분하고, 무거운 컴파일이 들어가는 Rust/Go/C 빌드는 emulation이 매우 느립니다. 후자는 다음 절의 네이티브 빌더가 답입니다.

네이티브 멀티 아키 빌더 — --append #

진지하게 멀티 아키 빌드를 굴린다면, 각 아키마다 네이티브 머신을 두는 게 가장 빠릅니다. buildx는 한 builder에 여러 노드(머신)를 등록하는 기능이 있습니다.

네이티브 빌더 — amd64 + arm64
# 첫 노드 (amd64 머신에서)
docker buildx create --name native --node native-amd64 --platform linux/amd64

# 두 번째 노드 추가 (arm64 머신을 SSH로 등록)
docker buildx create --append \
  --name native \
  --node native-arm64 \
  --platform linux/arm64 \
  ssh://ubuntu@arm64-host

docker buildx use native
docker buildx inspect --bootstrap

이 빌더로 --platform linux/amd64,linux/arm64 빌드를 돌리면, 각 플랫폼을 그 아키 머신이 네이티브로 빌드 합니다. 결과는 자동으로 manifest list로 묶입니다.

GitHub Actions에서는 ARM 러너가 무료로 제공되기 시작해서 (ubuntu-24.04-arm), 두 종류의 러너에서 빌드한 결과를 마지막에 합치는 패턴도 흔해졌습니다.

.github/workflows/multi-arch.yml (요약)
jobs:
  build-amd64:
    runs-on: ubuntu-24.04
    # ... amd64 빌드, digest 출력

  build-arm64:
    runs-on: ubuntu-24.04-arm
    # ... arm64 빌드, digest 출력

  manifest:
    needs: [build-amd64, build-arm64]
    # docker buildx imagetools create로 두 digest를 한 manifest list로

각 매트릭스 잡이 자기 아키만 네이티브로 빌드하고, 마지막에 묶기만 — 가장 빠른 멀티 아키 CI 패턴입니다.

docker buildx imagetools — 합치고 검증하는 도구 #

imagetools 명령은 이미지 / manifest list를 이미 빌드된 상태에서 다루는 도구입니다.

자주 쓰는 형태
# 1) manifest list 검증 (위에서 본 명령)
docker buildx imagetools inspect ghcr.io/me/myapp:1.0

# 2) 두 이미지를 한 manifest list로 묶기
docker buildx imagetools create \
  -t ghcr.io/me/myapp:1.0 \
  ghcr.io/me/myapp:1.0-amd64 \
  ghcr.io/me/myapp:1.0-arm64

# 3) 태그 옮기기 (가벼운 변경)
docker buildx imagetools create \
  -t ghcr.io/me/myapp:latest \
  ghcr.io/me/myapp:1.0

2 번이 위에서 본 GHA 패턴의 마지막 단계입니다. 각 잡이 별도 이미지로 푸시한 뒤, 마지막 잡이 imagetools create로 하나의 manifest list로 묶는 흐름을 정리합니다.

흔한 함정들 #

1. --platform 안 줘서 호스트 아키만 푸시됨 #

실수
docker buildx build --push -t myapp .   # 호스트 아키만 푸시됨

docker-container 드라이버라도 --platform 안 주면 호스트 아키 한 개만 빌드/푸시합니다. 멀티 아키를 노린다면 항상 --platform을 명시해야 합니다.

2. 베이스 이미지가 한쪽만 지원함 #

문제 있을 수 있는 베이스
FROM somebase:1.0

somebase:1.0이 amd64만 있으면 arm64 빌드 시점에 manifest를 못 찾고 실패합니다. 빌드 전에 베이스의 플랫폼 지원을 확인:

베이스 점검
docker buildx imagetools inspect somebase:1.0

널리 쓰이는 공식 이미지들 (python, node, golang, nginx, postgres)은 보통 멀티 아키를 다 지원합니다.

3. 한쪽 아키에서만 통과하는 테스트 #

빌드 도중 RUN go test ... 같은 단계가 있다면, 두 아키에서 모두 도는지 확인해야 합니다. 어떤 라이브러리는 amd64-only 어셈블리를 쓰는 식의 경우가 있어, 한쪽에서만 깨질 수 있습니다.

4. 결과 검증 안 함 #

CI가 --push 후에 결과를 검증하지 않으면 버그를 못 잡습니다. 한 줄을 더하는 게 안전:

CI의 마지막 검증
docker buildx imagetools inspect ghcr.io/me/myapp:${TAG} \
  --format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}}{{"\n"}}{{end}}'
# linux/amd64
# linux/arm64

기대한 플랫폼이 모두 들어 있는지 그냥 눈으로 확인하는 단계입니다.

한 컨테이너의 아키 강제하기 — --platform on run #

런타임에 특정 아키를 강제로 받을 수도 있습니다.

amd64 강제
docker run --platform linux/amd64 myapp

Apple Silicon에서 amd64 이미지가 필요한 상황(예: amd64-only 인 어떤 데이터베이스 클라이언트)에 자주 씁니다. 이 경우 호스트가 emulation으로 돌리니 성능이 떨어집니다.

컴파일된 언어 — 정적 vs 동적 #

멀티 아키에서 한 번씩 만나는 지점입니다. 글리브-libc(glibc) 동적 링크 바이너리는 OS의 libc와 호환되어야 합니다. 정적 바이너리는 이 의존이 없어 한 번 빌드하면 어디서든 도는데, alpine의 musl libc와 glibc가 이런 경우에 충돌합니다.

플랫폼 호환성
+ Go (CGO_ENABLED=0)         정적 — glibc/musl 무관, 어디서든
+ Rust (musl target)         정적 — alpine 기반에서 자연스러움
+ Python wheel               glibc/musl 별로 따로 만들어짐
+ Node native modules        같은 아키 + 같은 libc 여야
- glibc 컴파일된 C 확장 →    musl(alpine)에서 안 돔

멀티 아키 빌드 + slim 베이스(=glibc)가 일반 앱의 기본 조합입니다. alpine은 매력적이지만, 지금까지 본 요소들과 곱해지면 함정이 늘어납니다.

정리 #

이번 글에서 잡은 그림:

  • 멀티 아키 이미지는 한 태그가 manifest list (OCI image index)로 여러 단일 매니페스트를 묶은 것
  • **docker-container 드라이버 빌더 + --platform linux/amd64,linux/arm64**가 표준 패턴
  • 멀티 플랫폼은 --load 안 됨 — --push 또는 OCI 출력
  • QEMU emulation은 가능하지만 무거운 컴파일에서 느려짐. 진지하면 네이티브 멀티 아키 빌더(또는 GHA의 amd64+arm64 매트릭스 + imagetools 합치기)
  • **docker buildx imagetools**로 결과를 검증 / 합치기 / 태그 이동
  • 베이스 이미지의 플랫폼 지원 / 한 아키에서만 도는 코드 / CI 검증 누락이 흔한 함정

다음 글(#3 이미지 보안 — non-root, distroless, scan(Trivy))에서는 이 빌드 인프라 위에 보안 감각을 얹습니다. 비특권 사용자, 읽기 전용 루트, capabilities drop, 그리고 Trivy 같은 도구로 이미지의 알려진 취약점을 스캔하는 방법입니다.

X