Представьте, что вы отдали заказчику образ. Сборка на вашем x86-сервере прошла без единой красной строки, в реестре лежит тег, в логе написано DONE. Заказчик запускает контейнер на ARM-машине и получает в ответ короткое и неприятное exec format error. Ядро открыло файл, посмотрело на заголовок исполняемого файла и честно сказало, что эти инструкции ему незнакомы.

Эта история хороша тем, что в ней никто не соврал. Команда buildx действительно отработала. Просто «успешная сборка» отвечает на вопрос «все шаги Dockerfile выполнились», а заказчика интересует другой: «исполняется ли мой код на моём процессоре». Между этими вопросами лежит вся тема статьи: как собрать образ linux/arm64 на сервере с linux/amd64, чем за это заплатить временем и как убедиться, что результат работает, а не просто красиво подписан.

Три разные архитектуры, которые легко принять за одну

Когда говорят «собираем под ARM на x86», обычно смешивают три разных понятия. Если развести их, половина загадочных ошибок перестанет быть загадочной.

Платформа сборщика. Это машина (или контейнер BuildKit), на которой выполняются инструкции Dockerfile. На обычном VPS на AMD EPYC она linux/amd64. Именно здесь исполняется всё, что написано после RUN, если вы не сказали сборщику ничего иного.

Платформа базового образа. Строка FROM тянет из реестра не «образ вообще», а вариант образа под конкретную платформу. Если вы просите linux/arm64, из многоплатформенного тега приедут слои для ARM. Если базовый образ опубликован только под amd64, вы либо получите ошибку, либо (при небрежной настройке) тихо возьмёте не тот вариант.

Архитектура итогового бинарника. Это то, что в конце концов окажется в файловой системе образа: ваша программа, интерпретатор, нативные расширения, загруженные утилиты. Именно она определяет, запустится ли контейнер у заказчика.

Эти три величины могут не совпадать, и именно поэтому возникают ловушки. Классический пример: базовый образ взят под arm64, а в Dockerfile есть строка, которая скачивает готовый бинарник по жёстко записанной ссылке с суффиксом amd64. Сборка зелёная, слои ARM, подпись платформы правильная, а внутри лежит файл для другого процессора. Метка на образе — это заявление, а не проверка содержимого.

Документация Docker по многоплатформенным сборкам различает целевые архитектуры вывода, например linux/amd64 и linux/arm64 (Docker: Multi-platform builds). Это важная мысль для практики: один тег может содержать несколько вариантов, и каждый из них собирается и проверяется отдельно. «Собрали под ARM» не значит «собрали вообще всё».

Три дороги к одному результату

Та же страница документации описывает три стратегии многоплатформенной сборки: эмуляцию QEMU, несколько нативных узлов сборки и кросс-компиляцию. Они отвечают на один и тот же вопрос, но платят разной монетой.

Три стратегии сборки linux/arm64 и их цена (авторское сопоставление)
СтратегияКак это работаетЧто вы платитеКогда подходит
Эмуляция QEMUШаги RUN для ARM исполняются на x86 через программную эмуляцию процессораВремя сборки и зависимость от настройки эмуляции на хостеРедкие сборки, небольшие проекты, быстрый старт без второй машины
Нативные узлыДля каждой платформы подключён свой builder на подходящем железеВторая машина с ARM и её сопровождениеЧастые сборки, тяжёлая компиляция, требование к скорости
Кросс-компиляцияКомпилятор на x86 выпускает код для ARM, ARM-код во время сборки не исполняетсяУсилия по настройке toolchain и зависимость от языка и проектаЯзыки с хорошей поддержкой кросс-сборки, где итоговый файл создаётся компилятором

Эмуляция привлекательна тем, что почти не требует изменений: тот же Dockerfile, добавляется флаг --platform. Цена скрыта в слове «почти». Каждая инструкция RUN для ARM превращается в программу, которую настоящий процессор x86 исполняет не напрямую, а через интерпретацию чужих инструкций. Вы не меняете Dockerfile, но меняете физику того, что происходит под ним.

Нативные узлы убирают эту цену, заменяя её другой: теперь у вас два сервера (или сервер и ARM-машина где-то ещё), которые надо подключить к одному buildx-инстансу и не забыть о них. Кросс-компиляция вообще обходит исполнение чужого кода, но работает только там, где язык и инструментарий это позволяют: Go, например, привычно собирается под другую архитектуру одной переменной окружения, а вот проект с цепочкой нативных зависимостей на Python потребует куда больше усилий.

Выбор зависит не от того, какая стратегия «правильнее», а от того, как часто вы собираете, что собираете и сколько времени готовы ждать. Это можно выяснить только измерением, к которому мы вернёмся чуть ниже.

Минимальная команда и то, чего она не доказывает

Исходный вариант выглядит так. Предполагается, что buildx доступен на сервере, а на хосте настроена эмуляция для ARM; без неё сборка для чужой платформы остановится на первом же RUN:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t registry.example.com/team/app:1.4.0 \
  --push .

Здесь registry.example.com/team/app — условное имя, подставьте своё. Команда собирает обе платформы и публикует их под одним тегом. Эмуляция на хосте не гарантирована заранее: на любом конкретном VPS наличие нужной поддержки и прав, достаточных для её настройки, проверяется отдельно, а не принимается на веру. Если у вас есть root-доступ, как на VPS MAATRIX, то обычно проще разобраться, что и как настроено; но «обычно проще» и «уже работает» — разные утверждения.

Что эта команда подтверждает: все шаги дошли до конца и результат отправлен в реестр. Чего она не подтверждает: что внутри ARM-варианта лежат ARM-бинарники, что программа в нём стартует и что она делает свою работу. Всё это придётся проверять руками, и об этом дальше.

Почему эмуляция по-разному бьёт по разным шагам

В документации Docker сказано, что эмуляция может быть существенно медленнее нативной сборки, особенно для компиляции и для сжатия и распаковки (Docker: Multi-platform builds). Число «во сколько раз» там не обещано, и честнее его не придумывать: оно зависит от проекта, компилятора, процессора хоста и от того, что ещё делает машина. Зато из причины можно вывести, какие шаги пострадают сильнее.

Посмотрим на типичный Dockerfile и разложим его по тому, что делает каждый шаг с процессором.

  • Компиляция. Компилятор — это огромная программа, которая сама выполняет миллионы операций над данными. Под эмуляцией каждая из них проходит через слой трансляции. Чем больше исходников и чем «тяжелее» оптимизация, тем заметнее накопится разница.
  • Установка зависимостей. Менеджер пакетов делает смесь: часть времени уходит на сеть и диск (которым эмуляция почти не мешает), часть на распаковку архивов и запуск скриптов установки. Если пакеты приходят уже готовыми бинарными, шаг переносится легче; если они собираются из исходников, он превращается в компиляцию со всеми её последствиями.
  • Упаковка. Сжатие и распаковка слоёв, архивация артефактов — это тоже вычисления, а не просто копирование байтов. Документация отдельно называет их среди проблемных мест.
  • Копирование файлов. Инструкция COPY не исполняет чужой код, так что эмуляция её почти не затрагивает. Пользы от ускорения здесь ждать не стоит, но и жаловаться не на что.

Отсюда практический вывод: проекты по-разному относятся к эмуляции. Образ, где в основном копируется готовый скомпилированный артефакт и ставится пара пакетов, может пройти ARM-сборку без особой боли. Образ, который тянет компилятор и собирает нативные расширения, почувствует её на каждом шаге. Если вы не знаете, к какому типу относитесь, это знание можно получить дешёвым способом: измерить, не угадывая.

Как разделить шаги, чтобы эмулировать меньше

Самый действенный приём не требует ни второй машины, ни нового железа: не заставлять ARM-вариант исполнять то, что можно сделать на родной платформе. Многоступенчатая сборка позволяет закрепить стадию компиляции за платформой сборщика и положить в итоговый образ уже готовый результат. Идея в том, чтобы компилятор работал на x86 без эмуляции, а в ARM-образ попадали только файлы под нужную архитектуру.

FROM --platform=$BUILDPLATFORM golang:1.22 AS build
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY . .
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/app ./cmd/app

FROM alpine:3.20
COPY --from=build /out/app /usr/local/bin/app
ENTRYPOINT ["/usr/local/bin/app"]

Это иллюстрация подхода на примере Go; версии образов здесь условные, подставляйте свои. Переменные BUILDPLATFORM, TARGETOS и TARGETARCH предоставляются сборщиком при многоплатформенной сборке. Стадия build привязана к платформе сборщика, поэтому компилятор запускается без эмуляции, а GOARCH заставляет его выпускать код под цель. Итоговая стадия берёт базовый образ под целевую платформу и копирует туда готовый файл. Эмулироваться остаётся минимум, а здесь вообще ничего тяжёлого.

Подход работает не для всех стеков. Если ваш проект тянет С-библиотеки через cgo или нативные расширения интерпретируемых языков, кросс-компиляция превращается в отдельную инженерную задачу. Тогда разумно либо принять цену эмуляции, либо искать нативный узел. Именно поэтому полезно уметь честно измерить, сколько эта цена составляет в вашем случае.

Замер времени: холодная и повторная сборка

Скорость сборки нельзя обсуждать одним числом. Холодная сборка (кеш пуст, всё делается с нуля) и повторная (часть слоёв берётся из кеша) отвечают на разные вопросы. Первая показывает худший случай: свежий CI-раннер, новый сервер, сброшенный кеш. Вторая показывает повседневность: вы поправили две строки кода, и сборщик должен пересобрать только последние слои.

Чтобы замер означал что-то, ему нужны правила.

  1. Фиксируйте условия. Один и тот же сервер, тот же коммит, та же версия buildx. Если между замерами что-то поменялось, сравнение теряет смысл.
  2. Измеряйте каждую платформу отдельно. Соберите сначала только linux/amd64, затем только linux/arm64. Время общей команды, где обе платформы собираются вместе, скрывает, кто из них отнимает время.
  3. Делайте и холодный, и повторный прогон. Для холодного прогона нужно сбросить кеш сборщика, для повторного изменить один файл исходников, а не весь проект.
  4. Повторяйте. Один запуск может зацепить занятую сеть или шумного соседа по диску. Два-три прогона с записью каждого дают представление о разбросе.
  5. Отдельно смотрите на шаги. В выводе сборки видно, сколько занимает каждый этап. Именно по нему видно, где эмуляция больнее всего, а где её почти не заметно.

Результаты удобно складывать в таблицу вроде этой. Цифры в ней вы вписываете сами по своим прогонам; заранее известных «типичных» значений у нас нет, и придумывать их было бы нечестно.

Шаблон протокола замера (заполняется по результатам ваших прогонов)
Сценарийlinux/amd64linux/arm64Самый долгий шаг для arm64
Холодная сборка………
Правка исходников, кеш прогрет………
Правка зависимостей, кеш прогрет………

Строку про зависимости стоит делать отдельно: именно изменение манифеста зависимостей сбрасывает кеш на самом дорогом участке. Если в вашем проекте именно она оказывается пугающей, это сигнал либо переупорядочить Dockerfile (сначала копировать только файлы зависимостей, потом код), либо перенести тяжёлые шаги на нативный исполнитель. Сжимать итоговый образ тоже полезно: меньше слоёв и размер означают меньше работы на упаковке и передаче; об этом есть отдельный материал, как уменьшить размер Docker-образа.

Кеш как отдельная сущность

Повторная сборка быстра ровно настолько, насколько жив кеш. По документации, buildx поддерживает кеш типов inline, registry и local, причём доступность конкретного backend зависит от драйвера и хранилища образов (Docker: Cache storage backends). Это означает, что рецепт «включите registry cache» не универсален: сначала стоит проверить, что выбранный backend работает с вашим builder.

Практически важны ещё два момента из той же документации. Registry cache хранится отдельно от итогового образа, а внешний кеш нужно явно импортировать и экспортировать. Из этого следует простая вещь: если вы однажды собрали и запушили образ, это ещё не значит, что следующая сборка на другой машине получит кеш. Кеш не едет вместе с образом сам по себе. Для CI, где раннеры одноразовые, это решающее обстоятельство: без явной настройки импорта каждая «повторная» сборка на новом раннере окажется холодной.

Для многоплатформенной сборки это тем более существенно. Слои для каждой платформы разные, а значит и кеш по каждой нужно беречь отдельно; потеря кеша бьёт сильнее всего по ARM-варианту, если он собирается под эмуляцией. Поэтому в замер стоит включать вариант «кеш подключён на новой машине», если ваш рабочий процесс предполагает одноразовые раннеры.

И отдельное предупреждение, не про скорость, а про осторожность. В документации по кешу отмечено, что передача секретов через COPY или ARG может их раскрыть, и для этого предусмотрен специальный механизм секретов сборки. Многоплатформенный образ с токеном, случайно запечённым в слой, — это уже не одна утечка, а несколько, по числу платформ, и кеш в реестре может хранить то же самое. Токены для доступа к приватным репозиториям при сборке передавайте через предназначенный для этого механизм, а не через аргументы.

Скорость не заменяет совместимость

Замер времени полезен, но у него есть коварное свойство: быстро — значит хорошо. Сборка, которая стала быстрее, потому что кеш подхватил слой с бинарником не той архитектуры, выглядит как победа. Вывод таблицы отвечает на вопрос «сколько ждать», а не «правильное ли получилось». Поэтому следующий шаг не про время, а про содержимое.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Манифест не равен результату

Многоплатформенный тег в реестре — это не один образ, а указатель: список вариантов, в котором каждая платформа ссылается на свой набор слоёв. Когда вы делаете docker pull или запускаете контейнер, клиент смотрит на платформу машины и выбирает подходящий вариант. Если подходящего нет, получите ошибку о недоступной платформе; если вариант есть, но внутри него лежит не то, получите уже exec format error или что-нибудь более причудливое.

Поэтому проверка делится на два уровня: что опубликовано и что оно делает. Первый уровень смотрится одной командой:

docker buildx imagetools inspect registry.example.com/team/app:1.4.0

Вывод перечисляет платформы, под которыми в манифесте зарегистрированы варианты. Вы должны увидеть там и linux/amd64, и linux/arm64. Если одного из них нет, публикация неполна, и дальше проверять нечего.

Обратите внимание на тонкость. Иногда в списке появляются дополнительные записи — вспомогательные вложения вроде метаданных о происхождении сборки, которые отображаются как платформа unknown/unknown. Они не являются запускаемыми вариантами, и считать их «третьей платформой» не нужно. Ваш критерий простой: для каждой заявленной платформы есть запись с настоящей архитектурой.

Но и это ещё не доказательство. Манифест говорит, что вариант зарегистрирован, а не что внутри него лежит. Чтобы это выяснить, нужно посмотреть на сам вариант.

docker buildx imagetools inspect registry.example.com/team/app:1.4.0 \
  --format '{{json .Image}}'

Такая форма выводит конфигурацию образа в формате JSON. Если нужен конкретный вариант, можно указать платформу в запросе, а при необходимости обратиться к нему по дайджесту. Что искать в конфигурации: архитектуру (architecture) и ОС (os) конкретного варианта, а также точку входа и команду запуска. Если в записи ARM стоит amd64, значит где-то в процессе подменили платформу, и дальше можно не запускать.

Формат вывода и точные поля зависят от версии клиента, поэтому, если шаблон у вас не сработает, смотрите справку вашей версии buildx. Принцип остаётся тем же: сверить заявленную архитектуру каждого варианта с тем, что вы ожидали.

Запуск: единственное, что действительно проверяет

Теперь самое важное. Манифест можно прочитать, архитектуру в метаданных можно сверить, но убедиться, что программа работает, получится только запуском. Для этого есть три ступени, от дешёвой к надёжной.

Ступень первая: что за файл лежит внутри

Можно посмотреть на сам исполняемый файл, не запуская его. Утилита file умеет назвать архитектуру ELF-файла, и в ARM-образе вы ожидаете увидеть ARM aarch64. Если в образе нет file, можно вытащить бинарник наружу и посмотреть на хосте. Это быстро отлавливает самую обидную ошибку: подмену архитектуры скачанным готовым файлом.

Смотреть стоит не только на главный бинарник. Нативные расширения интерпретируемых языков, динамические библиотеки и загружаемые в сборке утилиты — все они могут оказаться «не той» архитектуры. Хорошая привычка: проверять всё, что скачивается по ссылке внутри Dockerfile. Если ссылка содержит amd64, x86_64 или linux-64 в явном виде, это почти наверняка дефект. Вместо этого используйте TARGETARCH для выбора нужного варианта загрузки.

Ступень вторая: запуск под эмуляцией на вашем сервере

Если на хосте настроена эмуляция, ARM-вариант можно запустить прямо на x86-сервере, указав платформу явно:

docker run --rm --platform linux/arm64 registry.example.com/team/app:1.4.0 uname -m

Ожидаемый ответ — aarch64. Это подтверждает, что вы действительно запустили вариант для ARM, а не молча получили amd64. Затем пусть контейнер сделает то, ради чего существует: ответит на запрос, обработает тестовый файл, пройдёт короткий набор проверок. Запуск --version недостаточен: многие программы честно печатают версию и разваливаются при первой реальной работе, например при загрузке нативной библиотеки или обращении к системному вызову.

Эта ступень полезна, но у неё есть ограничение, о котором стоит помнить: вы проверяете ARM-вариант под той же эмуляцией, которая использовалась при сборке. Если эмуляция что-то прощает (поведение отдельных инструкций, особенности ядра хоста), настоящий процессор может оказаться строже. Поэтому эмуляционный запуск — хороший дымовой тест, но не окончательный вердикт.

Ступень третья: настоящий ARM

Финальная проверка — запустить образ на машине с ARM-процессором. Это может быть сервер, плата, облачная машина с процессором этой архитектуры или рабочая станция разработчика. Образ стягивается без указания платформы, как это сделает заказчик: docker pull и затем docker run. Тем самым вы проверяете сразу всё: что клиент выбрал правильный вариант из манифеста, что слои скачались, что программа стартовала и выполнила рабочий сценарий.

Обратите внимание: здесь важно именно не писать --platform. Явное указание платформы скрывает ошибку выбора варианта: клиент делает то, что вы сказали, даже если сам по умолчанию выбрал бы другое. Пользователь заказчика не напишет этот флаг, поэтому ваш тест не должен его требовать.

Рабочий сценарий лучше подготовить заранее и сделать одинаковым для обеих платформ. Допустим, ваш образ — HTTP-сервис. Тогда проверка выглядит так: запустить контейнер, дождаться готовности, отправить запрос к эндпоинту, который затрагивает реальную логику (а не только /health), сверить ответ с эталоном. Один и тот же тест, прогнанный на amd64 и arm64, ловит различия, которые не видны ни в манифесте, ни в версии.

Какие дефекты находятся на каждой ступени

Какую ошибку ловит каждая проверка (авторская классификация)
ПроверкаЧто ловитЧто может пропустить
Успешный buildx buildСинтаксические ошибки, недоступные пакеты, падения шаговБинарники не той архитектуры, неработающий запуск
imagetools inspectОтсутствие платформы в манифесте, неверная архитектура в метаданныхНеверное содержимое при правильной подписи
Проверка файла (file)Скачанные или скопированные бинарники под другую архитектуруОшибки времени выполнения, отсутствующие библиотеки
Запуск под эмуляциейПадение при старте, неверная точка входа, неработающая логика на базовом уровнеОтличия реального процессора и ядра от эмулятора
Запуск на настоящем ARM без --platformОшибки выбора варианта клиентом, большинство проблем исполненияТолько то, чего не покрывает ваш тестовый сценарий

Таблица показывает простую закономерность: каждая следующая проверка дороже, но и ловит больше. Не обязательно доходить до последней ступени при каждой правке. Разумный режим: первые три-четыре на каждой сборке автоматически, а запуск на настоящем ARM — перед релизом и при изменениях, затрагивающих зависимости или базовый образ.

Где здесь VPS и что от него нужно

Для описанного в статье рабочего процесса сервер выполняет сразу несколько ролей: он исполняет сборку, хранит кеш и (при желании) служит проверочным стендом для эмуляционного запуска. На VPS MAATRIX доступны NVMe-диски, процессоры AMD EPYC и root-доступ. Для сборки образов последнее практически важно: настройка эмуляции и управление buildx требуют прав, которых нет в ограниченных окружениях. При этом наличие поддержки эмуляции в конкретной ОС нужно проверить самому, а не считать данностью.

Какой тариф нужен — вопрос замера, а не угадывания. Ресурсы тарифов (от 1 vCPU и 2 ГБ RAM у Ba до 16 vCPU и 32 ГБ у Djed) описывают конфигурацию, а не обещание ёмкости. Что именно потянет ваша сборка, показывает пробный прогон по протоколу выше. Разумный порядок такой: проверить сборку на небольшой конфигурации, посмотреть, какой шаг упирается в процессор и какой в память или диск, и уже по этому решать, нужен ли вам более крупный сервер для эмуляции или отдельный нативный узел для ARM. Диск тоже учитывайте: кеш по двум платформам и промежуточные слои растут заметно быстрее, чем кажется по размеру итогового образа.

Если замер покажет, что холодные ARM-сборки под эмуляцией не укладываются в ваше терпение, у вас три законных выхода: перенести тяжёлые шаги на кросс-компиляцию, подключить нативный ARM-исполнитель как второй узел buildx или сократить количество холодных сборок за счёт аккуратного внешнего кеша. Какой из них выбрать, определяет не авторитет, а ваша таблица с замерами.

Сквозной пример: от зелёной сборки до работающего ответа

Соберём последовательность целиком, условно, для сервиса, который отвечает на запрос /convert.

  1. Собираем и публикуем обе платформы одной командой docker buildx build --platform linux/amd64,linux/arm64 ... --push.
  2. Читаем манифест через docker buildx imagetools inspect и убеждаемся, что есть и amd64, и arm64 (вспомогательные записи без архитектуры не считаем).
  3. Запускаем ARM-вариант с --platform linux/arm64 и командой uname -m; ожидаем aarch64.
  4. Проверяем главный бинарник и подгружаемые файлы утилитой file; все должны оказаться ARM-файлами.
  5. Поднимаем контейнер и отправляем реальный запрос к /convert с тестовым файлом; сверяем результат с эталоном, полученным на amd64.
  6. Перед релизом повторяем пункт 5 на настоящем ARM-хосте, где образ получен обычным docker pull без указания платформы.

Эта последовательность занимает меньше времени, чем разбор жалобы заказчика, и главное, она отвечает на нужный вопрос на каждом шаге. Если какой-то пункт ломается, сразу понятно, где искать: на этапе публикации, в содержимом, в запуске или в логике программы.

Когда образ считается готовым

Вернёмся к покупателю с ARM-машиной из начала. Ему не важно, какая платформа написана в манифесте, сколько минут шла сборка и чем вы ее собирали: эмуляцией, нативным узлом или кросс-компилятором. Ему важно, что контейнер стартует, принимает запрос и возвращает правильный ответ. Все остальные проверки (подпись платформы, архитектура в метаданных, зелёный статус сборки) — это ступени к этому результату, а не он сам.

Поэтому критерий готовности простой и немного суровый: образ готов, когда программа выполнила свою работу на ARM. Красивая подпись linux/arm64 без запуска остаётся только красивой подписью.