Multi-arch builds: arm64 + amd64
В 2026 году у DE-команды два мира:
- Локальные машины: Mac M1/M2/M3 (Apple Silicon, arm64), Linux ARM laptops.
- Production: AWS EC2 / GCP VM / k8s — обычно amd64 (x86_64), но AWS Graviton (arm64) стремительно растёт.
Если ты собираешь образ только на arm64 (потому что собираешь на Mac), он не запустится на amd64-сервере: exec format error. И наоборот. Решение — multi-arch build: один образ в registry, который содержит варианты для обеих архитектур, и Docker сам выбирает нужный при pull.
В этом уроке: зачем multi-arch, как собрать через buildx --platform, разница QEMU emulation vs native runners, как проверить, что pulled правильная arch.
Зачем multi-arch
Сценарий 1: ты на Mac M1 собрал образ:
docker build -t my-etl:v1 .
docker push my-etl:v1
На сервере AWS amd64:
docker run my-etl:v1
# exec /usr/local/bin/python: exec format error
Сценарий 2: твой DAG на Airflow в k8s (amd64 nodes) использует образ, который ты тестировал локально на arm64. В тестах всё работало — в проде не запустилось.
Multi-arch решает обе проблемы. Один tag :v1 в registry содержит оба варианта. При docker pull Docker смотрит на uname -m хоста и скачивает нужный.
Buildx: основной инструмент
docker build (legacy builder) не умеет multi-arch. Нужен Buildx — современный builder из BuildKit.
# Проверь, что Buildx есть (в современном Docker -- включён по умолчанию)
docker buildx version
# github.com/docker/buildx v0.26.1 ...
# Список builder'ов
docker buildx ls
# Создай новый builder (если по дефолту не "container")
docker buildx create --name multiarch --use --bootstrap
# Запусти multi-arch build
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/user/my-etl:v1 \
--push \
.
Что произошло:
buildx create --name multiarchсоздал builder типаdocker-container— это контейнер с BuildKit внутри (отличается от legacy builder).--platform linux/amd64,linux/arm64сказал Buildx собрать ДВА варианта.--pushсразу запушил в registry. Multi-arch builds НЕЛЬЗЯ load в локальный daemon (legacy daemon хранит images per-arch). Только push или save в OCI archive.
QEMU emulation vs native runners
Почему exec format error возникает при запуске образа не той архитектурыНа Mac M1 (arm64) ты можешь собрать amd64-вариант через QEMU emulation. Buildx запустит QEMU, который эмулирует x86_64 инструкции. Это работает, но медленно (3-10x медленнее native).
QEMU emulation (одна машина, две arch)
# Установить binfmt-handlers через qemu-user-static
docker run --privileged --rm tonistiigi/binfmt --install all
# Теперь Buildx может собирать любые platform на amd64 (или arm64) машине
docker buildx build --platform linux/amd64,linux/arm64 -t my-etl:v1 .
На Mac M1 amd64-сборка через QEMU занимает минут 5-15 для image с pip install — потому что каждая команда RUN эмулируется. Для DE-образов с pandas/pyspark это бывает мучительно.
Native multi-arch runners (быстро)
На CI вместо QEMU используют отдельные runner’ы для каждой arch и собирают параллельно. Этот подход:
- amd64-job собирает amd64-вариант на ubuntu-latest (amd64 runner).
- arm64-job собирает arm64-вариант на ubuntu-latest-arm (arm64 runner — GitHub предоставляет с 2024).
- После — merge manifest list через
docker manifest.
# .github/workflows/multi-arch.yml
name: multi-arch-build
on:
push:
branches: [main]
jobs:
build:
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
outputs: |
type=image,name=ghcr.io/${{ github.repository_owner }}/my-etl,push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
- name: Export digest
run: |
mkdir -p /tmp/digests
echo "${{ steps.build.outputs.digest }}" > /tmp/digests/$(echo ${{ matrix.platform }} | tr / -)
- uses: actions/upload-artifact@v4
with:
name: digests-${{ strategy.job-index }}
path: /tmp/digests/*
merge:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- name: Create manifest list
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
-t ghcr.io/${{ github.repository_owner }}/my-etl:latest \
$(printf 'ghcr.io/${{ github.repository_owner }}/my-etl@sha256:%s ' $(cat *))
Native runners собирают параллельно, без QEMU. amd64 + arm64 — параллельные jobs (~ 2-3 минуты каждый), потом merge (~ 30 секунд).
GitHub предоставляет бесплатные ubuntu-24.04-arm runners с 2024 года для public repos. Для private — через GitHub-hosted larger runners (платная подписка) или self-hosted ARM-машины.
Простой вариант: QEMU в одном job
Если у тебя нет доступа к arm-runner’ам, или образ маленький — QEMU достаточен:
# .github/workflows/multi-arch-qemu.yml
name: build-multiarch-qemu
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ghcr.io/${{ github.repository_owner }}/my-etl:latest
cache-from: type=gha
cache-to: type=gha,mode=max
setup-qemu-action устанавливает QEMU. После этого один job собирает оба варианта через emulation. Время сборки: 5-15 минут на medium-image, против 2-3 минут на native.
Как проверить, что pulled правильная arch
После push’а — проверь manifest list:
docker manifest inspect ghcr.io/user/my-etl:v1
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
"manifests": [
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"platform": { "architecture": "amd64", "os": "linux" },
"digest": "sha256:abc..."
},
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"platform": { "architecture": "arm64", "os": "linux" },
"digest": "sha256:def..."
}
]
}
Видишь две архитектуры — multi-arch собран корректно.
После docker pull:
docker image inspect ghcr.io/user/my-etl:v1 --format '{{.Architecture}}'
# arm64 (на Mac M1)
# amd64 (на EC2)
Или внутри контейнера:
docker run --rm ghcr.io/user/my-etl:v1 uname -m
# aarch64 (на Mac)
# x86_64 (на EC2)
ВНИМАНИЕ: если ты на Mac M1 пишешь docker run —platform linux/amd64 my-etl:v1, ты ЗАСТАВИШЬ запуск amd64-варианта через QEMU. Это полезно для тестирования “как будет в prod”, но медленно. Без —platform Docker автоматически выбирает arm64.
Когда multi-arch не нужен
Не каждому DE-образу нужны обе arch. Сценарии:
- Только prod на amd64, dev в k8s на amd64: тебе не нужен arm64. Собирай только linux/amd64.
- Локальный compose на Mac, но Spark выполняется в EMR (amd64): для compose-стенда собирай arm64, для spark-submit-кода в EMR — amd64. Это два разных image.
- Embedded / IoT: только arm64 (или armv7).
Multi-arch — это удобство, не обязательство. Не плати за неё временем CI, если у тебя одна целевая платформа.
Альтернатива: cross-compile
Для Go / Rust / Java можно cross-compile без QEMU:
# Multi-stage с cross-compile
FROM --platform=$BUILDPLATFORM golang:1.23 AS build
ARG TARGETOS TARGETARCH
WORKDIR /app
COPY . .
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/app
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
$BUILDPLATFORM — platform, на которой запущен Buildx (например, amd64 runner). $TARGETPLATFORM / $TARGETOS / $TARGETARCH — целевая платформа.
Сборка:
docker buildx build --platform linux/amd64,linux/arm64 .
Buildx запустит один контейнер на amd64 (нет QEMU), но go build дважды — для каждой целевой arch. Это быстро, потому что Go сам компилирует кросс-arch.
Для Python такое не работает — Python runtime per-arch, не cross-compile. Но если у тебя DE-инструмент на Go (например, vector, telegraf) — это идеальный паттерн.
Попробуй сам
# 1. Подготовь QEMU (один раз)
docker run --privileged --rm tonistiigi/binfmt --install all
# 2. Создай простой Dockerfile
cat > Dockerfile <<EOF
FROM python:3.11-slim
RUN echo "Architecture: \$(uname -m)" > /arch.txt
CMD cat /arch.txt
EOF
# 3. Собери multi-arch локально (не пушим)
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t my-etl:test \
--output type=oci,dest=image.tar \
.
# Multi-arch нельзя load в обычный daemon -- сохраняем в OCI archive
# 4. Или: собери и сразу push в registry (тогда увидишь manifest list)
docker login ghcr.io
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/USERNAME/test-multiarch:v1 \
--push \
.
# 5. Проверь manifest
docker manifest inspect ghcr.io/USERNAME/test-multiarch:v1
# 6. На Mac M1 запусти и убедись, что arm64
docker pull ghcr.io/USERNAME/test-multiarch:v1
docker run --rm ghcr.io/USERNAME/test-multiarch:v1
# Architecture: aarch64
# 7. Force amd64 через QEMU
docker run --rm --platform linux/amd64 ghcr.io/USERNAME/test-multiarch:v1
# Architecture: x86_64