Мультиплатформенные образы: что это такое и зачем они нужны

Привет! Меня зовут Павел Агалецкий, я ведущий инженер в Авито. В нашей компании многие специалисты используют ноутбуки MacBook для локальной разработки, а большинство сервисов запускаются внутри Docker контейнеров. Долгое время мы обходились версиями Docker образов для архитектуры процессоров amd64. 

С появлением новых процессоров Apple Silicon (M1, M2) мы стали запускать контейнеры в режиме эмуляции процессора, что приводило к низкой скорости их работы. Так возникла задача поддержать внутреннюю сборку и работу с образами для архитектуры arm64 внутри Apple Silicon.

В этой статье я расскажу:

  • Что такое платформа Docker контейнеров и на что она влияет;

  • Как запустить один и тот же образ на разных платформах;

  • Почему мультиплатформенные образы удобнее и лучше;

  • Как сделать образ мультиплатформенным;

  • Как собирать образы в режиме кросс-компиляции.

42d163db9fcb404b0208b94e9583561e.png

Что такое платформа Docker контейнеров и на что она влияет

Платформа — это комбинация двух понятий: названия ядра операционной системы и набора команд CPU. Они описывают, для какого вычислительного устройства предназначен образ. Чаще всего платформа обозначается строкой, в которой ОС и CPU разделены слешем, например:

linux/amd64

linux/arm64

windows/amd64

darwin/arm64

Если в описании контейнера стоит платформа linux/amd64, это значит, что запустить его возможно только с компьютера, на котором установлен дистрибутив Linux и процессор с набором команд amd64. Контейнер наследует ядро хостовой ОС, поэтому на других платформах он работать не будет. 

Платформа влияет на исполняемые файлы внутри контейнера, так как они скомпилированы под набор команд CPU и используют системные вызовы ОС. Например, если в контейнере лежат исполняемые файлы для linux/amd64, это значит, что они точно запустятся в рамках операционной системы на базе Linux и с процессором с набором команд amd64. 

В примере выше указана платформа darwin/arm64. Darwin — операционная система macOS, поэтому возникает справедливый вопрос:, а что, на ней можно запустить контейнер? К сожалению, нет, Docker не работает на macOS, но я включил эту платформу именно как пример.

Узнать платформу образа и контейнера можно в манифесте — это отдельный файл в формате json. В нём есть поле platform с информацией по ОС и CPU:

 {
   "mediaType": 
  "application/vnd.docker.distribution.manifest.v2+json",
   "digest": 
  "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270",
   "size": 7682,
   "platform": {
     "architecture": "amd64",
     "os": "linux",
     "features": [
         "sse4"
       ]
     }
 }

Как запустить один и тот же образ на разных платформах

Представим несколько компьютеров. Первый работает на базе Windows с процессором amd64, второй — linux с amd64, а третий — macOS с arm64. Фактически, третий — это виртуальная машина с Linux. Мы хотим запустить на всех компьютерах образ golang:1.20 и сделать это так, чтобы он работал более-менее одинаково. Есть три способа это сделать.

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

golang:1.20-amd64

golang:1.20-arm64

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

Запустить образ через эмуляцию. Например, у нас есть образ под архитектуру amd64, а у нас используется процессор с arm64, как в случае с Apple Silicon. Что можно сделать:

  • установить виртуальную машину с дистрибутивом Linux;

  • установить эмулятор QEMU, который будет транслировать все команды, предназначенные для amd64, в команды для arm64. На Linux такое возможно благодаря binfmt_misc — это система в ядре, которая перед запуском бинарного файла оборачивает его внутрь QEMU. Соответственно, неважно, какая платформа прописана в манифесте, — всё будет работать.

Так мы и работали: скачивали образы для amd64 и запускали их на локальных компьютерах на базе arm64 через эмуляцию. Всё это работало очень медленно и не всегда. Сложные низкоуровневые приложения, которые используют специфичные наборы инструкций процессора, могли и не запускаться. Например, образ с MongoDB 5.0 для amd64 не работал на arm64 в режиме эмуляции. Поэтому мы стали переходить на мультиплатформенные образы.

Сделать образ мультиплатформенным. В этом случае под одним общим именем находятся несколько образов, каждый из которых подходит для конкретной платформы. Они могут запускаться нативно, что намного быстрее, чем работать через эмулятор. Когда речь идёт о сложных приложениях, то разница в скорости работы может составлять минуты, а иногда и часы на различные операции. 

Наглядный пример: простейшее Go-приложение, которое при запуске просто печатает hello.

package main
func main() {
    print("hello")
}

Скомпилируем его с помощью образа arm64 нативно, а потом сделаем то же самое в режиме эмуляции под amd64. Нативный запуск скомпилировал наше приложение за 2 секунды, а запуск в эмуляции занял уже 22 секунды — в 11 раз дольше:

native: linux/arm64

real 0m1.190s

user 0m2.045s

sys 0m0.257s

emulation: linux/amd64

real 0m12.484s

user 0m22.814s

sys 0m1.040s

Чтобы Docker Engine понимал, какой образ ему нужен, существует Manifest List. Это метаманифест, который содержит ссылки на манифесты отдельных образов.

Manifest List ссылается на другие манифесты

Manifest List ссылается на другие манифесты

В коде ниже видно, что Manifest List не содержит информации о слоях, а ссылается на два других манифеста: один предназначен для архитектуры ppc64le, а другой — для amd64.

{
 "schemaVersion": 2,
 "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
 "manifests": [
 {
 "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
 "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f",
 "size": 7143,
 "platform": {
 "architecture": "ppc64le",
 "os": "linux"
 }
 },
 {
 "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
 "digest": "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270",
 "size": 7682,
 "platform": {
 "architecture": "amd64",
 "os": "linux",
 "features": [
 "sse4"
 ]
 }
 }
 ]
}

Когда Docker хочет скачать и запустить образ, runtime запрашивает Manifest List из registry — реестра образов. Он отправляет запрос с критериями, в том числе с информацией о необходимой платформе. Получив манифест, он выбирает образ, который соответствует запросу, после чего скачивает его и запускает. Таким образом компьютеры с разной ОС и архитектурой процессора могут работать с одним и тем же образом.

Как сделать образ мультиплатформенным

Существует два способа собрать мультиплатформенный образ:  

  • сложный: через ручное выполнение манипуляций над манифестами;

  • простой: через использование buildx плагина для Docker.

Суть действий остается одинаковой. Сначала собираются отдельные образы под каждую требуемую платформу, а затем для них формируется Manifest List. После этого образы и манифесты загружаются в Docker registry. Хорошее и подробное описание, как собрать подобный образ, есть в статье на сайте Docker.

В обоих случаях при сборке образов под другую архитектуру потребуется решить вопрос, где выполнять такую сборку. 

Лучше всего использовать сервера, способные собрать отдельные образы под свою архитектуру нативно: например, arm-сервер для сборки под arm64 и x64-сервер для сборки под amd64. Такой способ позволяет сделать сборку значительно быстрее.

Если использовать, например, arm-сервер нет возможности, то docker buildx позволяет выполнять и так называемую кросс-сборку — сборку образа в режиме эмуляции. Для этого в составе buildx есть специальный компонент, builder. 

Builder — это отдельный контейнер, в рамках которого выполняется сборка. Для кросс-сборок он запускает процесс внутри QEMU и позволяет собирать, например, arm-образы на сервер с x64 процессором. Время сборки и её стабильность в таком случае может быть намного больше.

Допустим, мы собрали мультиплатформенный образ golang:1.20, внутри которого лежат два образа для разных платформ. Теперь мы можем запустить его на компьютерах с разной архитектурой процессора, например, linux/arm64 и linux/amd64:

docker run --rm registy.tld/avito/golang:1.20 uname -a

Linux b3eeecba3afb 5.15.0-75-generic #82-Ubuntu SMP Mon Jun 12 
06:02:05 UTC 2023 x86_64 GNU/Linux

docker run --rm registy.tld/avito/golang:1.20 uname -a

Linux b3eeecba3afb 5.15.0-75-generic #82-Ubuntu SMP Mon Jun 12 
06:02:05 UTC 2023 aarch64 GNU/Linux

Имя образа одно и то же, но на каждом компьютере запускается образ с нужной архитектурой.

Сборка образов в режиме кросс-компиляции

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

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

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

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

ARG GOLANG_VERSION=1.20.4

# Специальный аргумент
# на которой выполняется сборка
# За счёт этого следующая инструкция FROM выкачает
# и запустит сборку на базе нативного образа,
# например, linux/amd64 для процессоров Intel

FROM --platform=" class="formula inline">BUILDPLATFORM golang:

# Аргументы указывают docker buildx, что нам нужны значения TARGETARCH,
# TARGETOS. Они содержат названия целевой архитектуры
# и операционной системы

ARG TARGETARCH

ARG TARGETOS

COPY . /mybinary-src

# Собираем наше приложение под требуемую нам архитектуру

RUN cd /mybinary-src && GOOS=" class="formula inline">TARGETOS GOARCH=

# Для следующей инструкции FROM будет использован образ с платформой
# совпадающей с целевой архитектурой и операционной системой

FROM –platform=" class="formula inline">TARGETPLATFORM registy.tld/golang:$GOLANG_VERSION

COPY --from=builder /avito-actions/bin /avito-actions/bin/

Используя Dockerfile выше, мы можем собрать наше приложение сразу под несколько платформ с помощью buildx. Например, собрать контейнеры под платформы linux/amd64 и linux/arm64 с общим именем myapp:

docker buildx build --platform=linux/amd64,linux/arm64 -t myapp

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

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

Предыдущая статья: Что будет с мобильными релизами, если улучшать и автоматизировать процессы

© Habrahabr.ru