[Перевод] Компиляция контейнеров — Dockerfiles, LLVM и BuildKit

Эта статья конкатенация двух статей Адама Гордона Белла (Adam Gordon Bell) из Earthly. Добавил в основную статью про компиляцию контейнеров BuildKit выдержки из его другой статьи про BuildKit для понимания как это работает и как использовать BuildKit напрямую.

Тынис Тийги (Tõnis Tiigi), сотрудник Docker и основной разработчик BuildKit, создал BuildKit, чтобы отделить логику построения образов от основного проекта moby и обеспечить возможность дальнейшего развития. BuildKit поддерживает подключаемые интерфейсы, которые позволяют создавать не только образы docker из Dockerfiles. С помощью BuildKit мы можем заменить синтаксис Dockerfile на hlb и заменить формат образа докера на вывод в виде чистого tar-файла. Это лишь одна из возможных комбинаций, которые открывает BuildKit с его подключаемыми бэкэндами и интерфейсами.

Введение

Как получаются контейнеры? Обычно из серии операторов, таких как RUN, FROM и COPY, которые помещаются в Dockerfile и собираются. Но как эти команды превращаются в образ, а затем в работающий контейнер? Понять это можно если пройти этапы создания образа контейнера самостоятельно. Мы создадим образ программно, а затем разработаем обычный синтаксический интерфейс и будем использовать его для создания образа.

docker build

Мы можем создавать образы контейнеров несколькими способами. Мы можем использовать пакеты сборки, мы можем использовать инструменты сборки, такие как Bazel или sbt, но, безусловно, наиболее распространенным способом создания образов является использование docker build с Dockerfile. Таким образом создаются знакомые базовые образы Alpine, Ubuntu и Debian.

Вот пример Dockerfile:

FROM alpine
COPY README.md README.md
RUN echo "standard docker build" > /built.txt"

В этом руководстве мы будем использовать вариации этого Dockerfile.

Мы можем собрать его так:

docker build . -t test

Но что происходит, когда вы вызываете docker build? Чтобы понять это, нам понадобится немного предыстории.

Background

Образ докера состоит из слоев. Эти слои образуют неизменную файловую систему. Образ контейнера также содержит некоторые описательные данные, такие как команда запуска, открываемые порты и монтируемые тома. Когда вы делаетеdocker run образа, он запускается внутри среды выполнения контейнера.

Мне нравится рассматривать образы и контейнеры по аналогии. Если образ похож на исполняемый файл, то контейнер похож на процесс. Вы можете запускать несколько контейнеров из одного образа, а работающий образ — это вовсе не образ, а контейнер.

1461fb3e119f8fa2cb1b86bbaa344709.png

Продолжая нашу аналогию, BuildKit — это компилятор, как например LLVM. Но в то время как компилятор берет исходный код и библиотеки и создает исполняемый файл, BuildKit берет Dockerfile и путь к файлу и создает образ контейнера.

8f6909ca8b6da2635ef6b12a088a5638.png

Docker build использует BuildKit, чтобы превратить Dockerfile в образ докера, образ OCI или другой формат образа. Здесь мы в основном будем использовать BuildKit напрямую.

0ddf0aef8570cd6311197c237e3c11c2.png

Как работают компиляторы?

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

Компиляция классического «Hello, World» на С в ассемблерный код x86 с использованием интерфейса Clang для LLVM выглядит так:

03ad26f648113424303f0089f5b2e372.png

Создание образа из файла докеров работает аналогичным образом:

063786481cccfd853c9de7a1b4d15c31.png

BuildKit передается Dockerfile и контексту сборки, который является текущим рабочим каталогом на приведенной выше схеме. Проще говоря, каждая строка в файле Dockerfile превращается в слой в итоговом изображении. Одним из существенных отличий построения образа от компиляции является контекст сборки. Входные данные компилятора ограничены исходным кодом, тогда как docker build принимает ссылку на файловую систему хоста в качестве входных данных и использует ее для выполнения таких действий, как COPY.

В чем подвох

На предыдущей диаграмме, где компиляции «Hello, World» отображена за один шаг, упущена важная деталь. Если бы каждый компилятор представлял собой вручную закодированный маппинг с языка высокого уровня на машинный код x86, то переход на процессор Apple M1 был бы довольно сложным, поскольку он имеет другой набор инструкций.

Авторы компилятора преодолели эту проблему, разбив компиляцию на фазы. Традиционные фазы — это frontend (интерфейс), backend и middle, которую иногда называют оптимизатором (optimizer), и она имеет дело в основном с внутренним представлением (IR).

30bc6bd3258f05be52bc8cca3c3f6e93.png

Такой поэтапный подход означает, что вам не нужен новый компилятор для каждой новой машинной архитектуры. Вместо этого вам просто нужен новый backend. Вот пример того, как это выглядит в LLVM:

7b362ec731f13ae99eded8888e4117c5.png

Промежуточные представления

Такой подход с использованием нескольких бэкэндов позволяет LLVM ориентироваться на ARM, X86 и многие другие компьютерные архитектуры, используя промежуточное представление LLVM (IR) в качестве стандартного протокола. LLVM IR — это удобочитаемый язык программирования, который серверная часть должна принимать в качестве входных данных. Чтобы создать новый бэкэнд, вам нужно написать переводчик из LLVM IR в целевой машинный код. Этот перевод — основная задача каждого бэкэнда.

Когда у вас есть этот IR, у вас есть протокол, который различные фазы компилятора могут использовать в качестве интерфейса, и вы можете создавать не только множество бэкэндов, но и множество интерфейсов (frontend). LLVM имеет интерфейсы для множества языков, включая C, Julia, Objective-C, Rust и Swift.

a4c21f5c8cafe68b0e53b8cde6281c28.png

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

На практике дело не только в этом. Интерфейсы должны токенизировать и анализировать входные файлы, и они должны возвращать нужные ошибки. Бэкенды часто требуют оптимизации для конкретных целей и эвристики. Но для нас сейчас критическим моментом является то, что стандартное представление превращается в мост, соединяющий многие интерфейсы со многими внутренними интерфейсами. Этот общий интерфейс устраняет необходимость в создании компилятора для каждой комбинации языка и машинной архитектуры. Это простой, но очень полезный трюк!

BuildKit

Образы, в отличие от исполняемых файлов, имеют собственную изолированную файловую систему. Тем не менее, задача создания образа очень похожа на компиляцию исполняемого файла. Они могут иметь различный синтаксис (dockerfile1.0, dockerfile1.2), и результат должен быть нацелен на несколько архитектур компьютеров (arm64 против x86_64).

«LLB для Dockerfile так же как LLVM IR для C»  BuildKit Readme

Это сходство не ускользнуло от внимания создателей BuildKit. BuildKit имеет собственное промежуточное представление LLB. И там, где LLVM IR имеет такие вещи, как вызовы функций и стратегии сборки мусора, LLB имеет монтируемые файловые системы и выполнение операторов.

59d87635cdf2491545c777c4f04eba48.png

LLB определяется как буфер протокола, а это означает, что внешние интерфейсы BuildKit могут делать запросы GRPC к buildkitd для непосредственного создания контейнера.

1b0e4aa76b1789208b6b517d5df3687d.png

Программное создание образа

Давайте программно сгенерируем LLB для образа, а затем соберем образ.

В этом примере мы будем использовать Go, который позволяет нам использовать существующие библиотеки BuildKit, но это можно сделать на любом языке с поддержкой Protocol Buffer.

Импортирeм LLB:

import (
	"github.com/moby/buildkit/client/llb"
)

Создадим LLB для образа Alpine:

func createLLBState() llb.State {   
 return llb.Image("docker.io/library/alpine").        
   File(llb.Copy(llb.Local("context"), "README.md", "README.md")).       
   Run(llb.Args([]string{"/bin/sh", "-c", "echo \"programmatically built\" > /built.txt"})).      
Root()  
}

Мы выполняем эквивалент FROM, используя lib.Image. Затем мы копируем файл из локальной файловой системы в образ, используя FILE и COPY. Наконец, мы запускаем RUN для вывода текста в файл. LLB имеет гораздо больше операций, но вы можете воссоздать множество стандартных образов с помощью этих трех блоков.

Последнее, что нам нужно сделать, это превратить это в протокол-буфер и выдать его в стандартный вывод:

func main() {

	dt, err := createLLBState().Marshal(context.TODO(), llb.LinuxAmd64)
	if err != nil {
		panic(err)
	}
	llb.WriteTo(dt, os.Stdout)
}

Давайте посмотрим что сгенерируется, используя параметр dump-llb в buildctl:

go run ./writellb/writellb.go | 
 buildctl debug dump-llb | 
 jq .

Мы получаем этот LLB в формате JSON:

{
  "Op": {
    "Op": {
      "source": {
        "identifier": "local://context",
        "attrs": {
          "local.unique": "s43w96rwjsm9tf1zlxvn6nezg"
        }
      }
    },
    "constraints": {}
  },
  "Digest": "sha256:c3ca71edeaa161bafed7f3dbdeeab9a5ab34587f569fd71c0a89b4d1e40d77f6",
  "OpMetadata": {
    "caps": {
      "source.local": true,
      "source.local.unique": true
    }
  }
}
{
  "Op": {
    "Op": {
      "source": {
        "identifier": "docker-image://docker.io/library/alpine:latest"
      }
    },
    "platform": {
      "Architecture": "amd64",
      "OS": "linux"
    },
    "constraints": {}
  },
  "Digest": "sha256:665ba8b2cdc0cb0200e2a42a6b3c0f8f684089f4cd1b81494fbb9805879120f7",
  "OpMetadata": {
    "caps": {
      "source.image": true
    }
  }
}
{
  "Op": {
    "inputs": [
      {
        "digest": "sha256:665ba8b2cdc0cb0200e2a42a6b3c0f8f684089f4cd1b81494fbb9805879120f7",
        "index": 0
      },
      {
        "digest": "sha256:c3ca71edeaa161bafed7f3dbdeeab9a5ab34587f569fd71c0a89b4d1e40d77f6",
        "index": 0
      }
    ],
    "Op": {
      "file": {
        "actions": [
          {
            "input": 0,
            "secondaryInput": 1,
            "output": 0,
            "Action": {
              "copy": {
                "src": "/README.md",
                "dest": "/README.md",
                "mode": -1,
                "timestamp": -1
              }
            }
          }
        ]
      }
    },
    "platform": {
      "Architecture": "amd64",
      "OS": "linux"
    },
    "constraints": {}
  },
  "Digest": "sha256:ba425dda86f06cf10ee66d85beda9d500adcce2336b047e072c1f0d403334cf6",
  "OpMetadata": {
    "caps": {
      "file.base": true
    }
  }
}
{
  "Op": {
    "inputs": [
      {
        "digest": "sha256:ba425dda86f06cf10ee66d85beda9d500adcce2336b047e072c1f0d403334cf6",
        "index": 0
      }
    ],
    "Op": {
      "exec": {
        "meta": {
          "args": [
            "/bin/sh",
            "-c",
            "echo "programmatically built" > /built.txt"
          ],
          "cwd": "/"
        },
        "mounts": [
          {
            "input": 0,
            "dest": "/",
            "output": 0
          }
        ]
      }
    },
    "platform": {
      "Architecture": "amd64",
      "OS": "linux"
    },
    "constraints": {}
  },
  "Digest": "sha256:d2d18486652288fdb3516460bd6d1c2a90103d93d507a9b63ddd4a846a0fca2b",
  "OpMetadata": {
    "caps": {
      "exec.meta.base": true,
      "exec.mount.bind": true
    }
  }
}
{
  "Op": {
    "inputs": [
      {
        "digest": "sha256:d2d18486652288fdb3516460bd6d1c2a90103d93d507a9b63ddd4a846a0fca2b",
        "index": 0
      }
    ],
    "Op": null
  },
  "Digest": "sha256:fda9d405d3c557e2bd79413628a435da0000e75b9305e52789dd71001a91c704",
  "OpMetadata": {
    "caps": {
      "constraints": true,
      "platform": true
    }
  }
}

Просматривая вывод, мы видим, как наш код отображается на LLB.

Вот наша COPY как часть FileOp:

 "Action": {
              "copy": {
                "src": "/README.md",
                "dest": "/README.md",
                "mode": -1,
                "timestamp": -1
              }

Вот отображение контекста нашей сборки для использования в нашей команде COPY:

"Op": {
      "source": {
        "identifier": "local://context",
        "attrs": {
          "local.unique": "s43w96rwjsm9tf1zlxvn6nezg"
        }
      }

Точно так же вывод содержит LLB, который соответствует нашим командам RUN и FROM.

Создание нашего LLB

BuildKit состоит из двух основных компонентов: buildctl и buildkitd. buildctl — это контроллер BuildKit, который взаимодействует с buildkitd.

Buildkitd отвечает за создание образа, но фактическое выполнение каждого шага выполняет runc. Runc выполняет каждую команду RUN в вашем файле докеров в отдельном процессе. Runc требует ядра Linux 5.2 или более поздней версии с поддержкой cgroups, поэтому buildkitd не может работать изначально в macOS или Windows.

Как устанавливать описано тут — не будем останавливаться. В примере ниже описан способ через docker как более универсальный, так как запустить buildcid в macOS или Windows.

docker run --rm --privileged -d --name buildkit moby/buildkit
export BUILDKIT_HOST=docker-container://buildkit

go run ./writellb/writellb.go | 
buildctl build 
--local context=. 
--output type=image,name=docker.io/agbell/test,push=true

Флаг вывода (output) позволяет нам указать, какой бэкэнд мы хотим использовать в BuildKit. Мы попросим его создать образ OCI и отправить его в docker.io. По умолчанию результат сборки остается внутренним для BuildKit поэтому мы указали тип вывода. BuildKit поддерживает несколько типов вывода. Мы можем вывести tar: --output type=tar,dest=out.tar (этот файл не является образом. Нет никаких слоев или манифестов, только полная файловая система, которую будет содержать созданный образ).

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

Мы можем запустить с тем же результатом:

docker run -it --pull always agbell/test:latest /bin/sh

Затем мы можем увидеть результаты наших программных команд COPY и RUN:

cat built.txt 
  programmatically built
ls README.md
  README.md

Пример полного кода может стать отличной отправной точкой для создания вашего собственного программного образа докера.

frontend для BuildKit

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

#syntax=r2d4/mocker
apiVersion: v1alpha1
images:- name: demo
  from: ubuntu:16.04
  package:
    install:
    - curl
    - git
    - gcc

А поскольку сборка Docker поддерживает команду #syntax, мы даже можем создать Mockerfiles напрямую с помощью docker build.

docker build -f mockerfile.yaml

Для поддержки команды #syntax все, что нужно, — это поместить интерфейс в образ докера, который принимает запрос gRPC в правильном формате, опубликовать это изображение где-нибудь. На этом этапе любой может использовать ваш интерфейс docker build, просто используя #syntax = yourimagename.

Создание собственного примера внешнего интерфейса для docker build

Создание токенизатора и парсера как службы gRPC выходит за рамки этой статьи. Но мы можем научится этому, извлекая и изменяя существующий интерфейс. Стандартный интерфейс Dockerfile легко отделить от проекта moby. Я вытащил соответствующие части в отдельный репозиторий. Давайте внесем в него несколько изменений и проверим.

До сих пор мы использовали только команды докера FROM, RUN и COPY. Поверхностно синтаксис Dockerfile с его командами, начинающимися с заглавной буквы, очень похож на язык программирования INTERCAL. Давайте изменим эти команды на их эквивалент INTERCAL и разработаем наш собственный формат Ickfile.

Dockerfile

Ickfile

FROM

COME FROM

RUN

PLEASE

COPY

STASH

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

fccdd91018c17572d2902c97b1d8d03b.png

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

package command

// Define constants for the command strings
const (
	Copy        = "stash"
	Run         = "please"
	From        = "come_from"
	...
)

Затем мы можем увидеть результаты наших команд STASH и PLEASE:

Я отправил этот образ в dockerhub. Любой может начать создавать образы, используя наш формат ickfile, добавив #syntax = agbell/ick к существующему Dockerfile. Никакой ручной установки не требуется!

Еще пару примеров использования BuildKit напрямую

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

FROM agbell/test:local
RUN echo "BuildKit built">  file
CMD ["/bin/sh"] 
> docker tag alpine agbell/test:local
> buildctl build \
    --frontend=dockerfile.v0 \
    --local context=. \
    --local dockerfile=. \
------
 > [internal] load metadata for docker.io/agbell/test:local:
------
error: failed to solve: rpc error: code = Unknown desc = failed to solve with frontend dockerfile.v0: failed to create LLB definition: docker.io/agbell/test:local: not found

Это не работает. Похоже, он пытается получить изображение из docker.io, реестра docker-хаба по умолчанию.

Мы можем проверить это, быстро перехватив запросы от buildkitd:

➜ cat ~\Dockerfile
FROM moby/buildkit 
RUN apk update && apk add curl
WORKDIR /usr/local/share/ca-certificates
COPY mitmproxy.crt mitmproxy.crt
RUN update-ca-certificates
➜ docker build . -t buildkit:mitm
 ...
➜ docker run --rm --privileged -d --name buildkit buildkit:mitm
6676dc0109eb3f5f09f7380d697005b6aae401bb72a4ee366f0bb279c0be137b
b0250ff1373bfd6800bb12e6012e8a15.png

Мы видим ошибку 404, и это подтверждает, что buildkitd ожидает доступа к реестру по сети с помощью docker registry v2 api.

Мы можем наблюдать за выполнением нашей сборки, используя pstree и watch. Откройте два терминала, запустите в одном:

docker exec -it buildkit "/bin/watch" "-n1" "pstree -p"

В другом:

buildctl build ...

Вы увидите, что buildkitd запускает процесс buildkit-runc, а затем отдельный процесс для каждой команды RUN.

47507e9b22b4df533ece27c0e4bc9ad0.png

Заключение

BuildKit используется внутри сборки докеров в современных версиях docker, но его использование напрямую открывает некоторые дополнительные возможности.

Мы рассмотрели одно использование — изменение типа вывода. Мы можем использовать BuildKit для экспорта tar и локальных файловых систем. Мы также используем pstree и mitmProxy, чтобы наблюдать, как buildkitd обрабатывает процессы и делает сетевые запросы.

Мы узнали, что трехэтапная структура, заимствованная у компиляторов, обеспечивает создание образов, что промежуточное представление, называемое LLB, является ключом к этой структуре. Опираясь на эти знания, мы создали два интерфейса для создания образов.

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

Анонсы других публикаций и возможность комментирования (если тут не можете) в телеге.

© Habrahabr.ru