Современная бэкенд‑разработка не обходится без средств контейнеризации. Самому простому приложению, скорее всего, будет нужна хотя бы база данных или пучок дополнительных зависимостей из веб‑серверов, балансировщиков, сборщиков логов и метрик. Для быстрого их развёртывания и настройки используются кастомные или готовые образы и контейнеры. И когда разговор заходит о контейнерах, первое, что приходит на ум, — это Docker и Dockerfile.
Для многих это стандарт, отклонения от которого вызывают недоумение и вопросы. Но даже у всего хорошего есть альтернативы. Одна из них — Nix. Насколько она сопоставима по удобству и скорости с Docker?
Меня зовут Борис Табачников, я разработчик отдела RnD в СберТехе. Кратко расскажу, что такое Nix в целом, зачем вам его использовать и подробно сравню скорость работы Nix и Docker.
Статья будет полезна DevOps‑инженерам и разработчикам, интересующимся контейнеризацией. И особенно — тем, кто ищет альтернативы для Docker и кого заинтересовал Nix, но при этом сферы его использования и применимость для сборки образов недостаточно понятна.
Что такое Nix
Не стоит пугаться, если до этого вы не слышали про Nix. Это популярный в узких кругах инструмент, предоставляющий уникальный подход к управлению пакетами и конфигурированию системы. Именно так написано на официальном сайте продукта. Что же под этим подразумевается?
В основе всего лежит функциональный язык под тем же именем Nix, который обеспечивает декларативность и воспроизводимость результатов. Именно с его помощью конфигурируется образ для сборки контейнера. На этом моменте уже возникают большие вопросы к удобству использования. Функциональные языки в целом достаточно специфичные нишевые инструменты с довольно высоким порогом входа, которые не каждому придутся по вкусу.
Nix в этом плане не исключение. Несмотря на то, что у него более‑менее простой и понятный синтаксис, некоторые действия, элементарные в других языках и системах, в Nix превращаются в весьма неочевидные и требующие танцев с бубном. Так, например, если вам понадобится скачать файлик из интернета по ссылке, то, не зная его хеш, вряд ли у вас вообще что‑то получится.
Помимо языка, важная в нашем случае вещь — это пакетный менеджер. У Nix он свой, c большим количеством всевозможных пакетов. Найти можно практически что угодно, так же, как и с apt или aur. Добавлять пакеты в собираемый образ можно только через этот пакетный менеджер. Иначе снова упираемся в проблему доступности ресурсов в интернете без знания дополнительной информации о них.
Зачем использовать Nix
Прежде чем сравнивать Docker и Nix, давайте поймём, что вообще нас может сподвигнуть на использование Nix вместо Docker.
Главная особенность Nix — это воспроизводимость. Все объекты в системе Nix валидируются хешем содержимого для подтверждения его неизменности. Если какой‑то объект (например, собираемый образ) зависит от других объектов, то в случае их изменения поменяется их собственный хеш и, как следствие, хеш образа, что позволяет отслеживать неизменность образа и всех его компонентов.
Есть и нюанс. Из‑за подобной валидации возникают проблемы с получением данных из сети. Как правило, чтобы скачать информацию из интернета, достаточно иметь прямую ссылку и данные авторизации при необходимости. Но этого недостаточно для гарантии неизменности содержимого, которое мы скачаем. Данные под ссылкой можно подменить. Поэтому, чтобы что‑то скачать, помимо ссылки необходимо ещё знать хеш содержимого, которое за ней скрывается. Это приводит к проблеме: динамически что‑то скачать из интернета, не узнав хеш, не получится. Только статические ссылки с известным содержимым.
Второе преимущество — это возможность в качестве зависимости использовать один и тот же пакет, но в разных версиях. Если у объекта Y зависимость от пакета X версии v1, а объект Z — от пакета X версии v2, то у разных версий будет разный хеш. То есть это будут две разные сущности. А следовательно, у Y и Z по сути не будет общей зависимости. И если Y перейдет на X версии v3, а Z останется на своей, то всё успешно продолжит работать.

Теперь, когда мы ответили на вопрос, зачем нам использовать Nix, хочется понять, а стоит ли вообще его использовать для сборки. Насколько он хорош в сравнении с имеющимися инструментами?
Время сборки
Сборка образов — сложный и долгий процесс. При этом часто один и тот же образ приходится пересобирать по несколько раз в день, в час. Долгая сборка может негативно влиять на скорость разработки, ухудшать продуктивность разработчиков, замедлять конвейеры и тестирование. В некоторых случаях сборка образа может быть непосредственно функциональным требованием. В таком случае скорость особенно важна. Поэтому немалозначимым фактором при выборе инструмента для сборки контейнеров является производительность. А когда к нам в руки попадает новый для нас инструмент, то мы плохо понимаем, на что он способен (или не способен). Так и с Nix: есть свои плюсы, есть свои минусы, которые, возможно, кого‑то уже оттолкнули.
Но остаётся как минимум один вопрос, который в документации, к сожалению, не освещается: как быстро Nix работает? Попробуем разобраться, как он функционирует в сравнении с Docker.
Тестирование
Тестировать будем через командные интерфейсы, которые предоставляют
Docker и Nix: это основной способ взаимодействия большинства
пользователей с подобными системами. Соответственно, будем замерять
скорость работы консольных команд. В качестве бенчмарка для сравнения и
проведения серии тестов будем использовать утилиту hyperfine.
Её функциональности достаточно для нашей задачи: она может выполнять
несколько целевых команд и посчитать усреднённые значения времени
выполнения. Для каждой тестируемой команды можно задать команду prepare
, которая выполнится перед запуском.
Тестировать будем на следующих «атомарных» задачах:
- конфигурирование команды запуска;
- установка пакетов;
- копирование небольшого числа больших файлов;
- копирование большого числа маленьких файлов;
- выполнение произвольной команды.
По опыту, такие задачи включают множество базовых возможностей, различные комбинации которых перекрывают большинство кейсов использования dockerfile-ов и подобных конфигураций. В рамках одного теста будем производить сборку 10 раз и усреднять результаты.
Первый этап
Тестировать будем в несколько этапов. В начале выполним каждый тест на «чистой системе». То есть из расчёта, что до начала сборки у нас нет никаких артефактов для выполнения сборки: кешей, базового образа, других необходимых зависимостей. Имитируем ситуацию недавней установки сборщика или запуска новой ноды для задачи CI/CD.
Нюанс здесь в том, что при каждом запуске теста будет скачиваться базовый образ (использовать будем Ubuntu 20.04). Это будет учитываться во времени сборки и может давать нестабильное время работы, так как производительность сети может гулять. Но это некритично: во‑первых, у нас будет второй этап тестирования, а во‑вторых, мы проводим каждый тест несколько раз, чтобы нивелировать зависимость от скорости сети, которая на тестовой машине и так достаточно стабильная.
Для очистки данных Docker перед каждым запуском сборки будем использовать команду, которая удаляет все неиспользуемые контейнеры, сети, хранилища, образы и все кеши сборок:
sudo docker system prune ‑fa ‑volumes
Для очистки данных Nix будем использовать команду, которая удаляет ссылку nix‑image.tar.gz на собранный образ, а затем каскадом удаляет все неиспользуемые зависимости. Как только мы удалим ссылку на образ, он будет считаться неиспользуемым:
rm nix‑image.tar.gz && nix‑collect‑garbage ‑d
Таким образом, будем запускать hyperfine для тестирования следующим образом:
hyperfine -n docker -n nix -p 'sudo docker system prune -fa --volumes' -p 'rm nix-image.tar.gz && nix-collect-garbage -d' 'sudo docker build --no-cache .' 'nix-build nix.nix -o nix-image.tar.gz' --warmup 1
С параметром ‑warmup 1
один дополнительный запуск тестовых скриптов будет прогревающим: его время не будет учитываться в результатах теста.
Приведу сырые результаты первого этапа тестов:


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


Результаты
Если давать краткие результаты тестов, то можно сказать, что Docker почти во всём лучше. Местами даже значительно. Но давайте разберём результаты подробнее.

В первом тесте первого этапа наблюдается преимущество Docker в скорости более чем в 5 раз. Это можно объяснить тем, что Nix при первичном запуске сборки образов (не одном конкретном, а первой сборки на этой системе в целом) скачивает необходимые для запуска процесса сборки зависимости. Это отнимает дополнительное время.
Команда cmd
в Dockerfile, как видно из второго этапа,
сама по себе слабо влияет на скорость сборки. По сути, эта информация
просто попадает в OCI‑конфиг образа и дальше используется только
в момент запуска. Поэтому при наличии базового образа Docker собирает
новый меньше чем за секунду. Nix же таким ускорением похвастаться
не может. И несмотря на то, что пуллить образ и скачивать зависимости
ему больше не нужно, он всё равно тратит значительно больше времени
на распаковку базового образа, добавление метаданных и упаковку нового
образа.
Второй тест даёт интересные результаты. На чистой системе время работы Docker и Nix приблизительно одинаковое, а на рабочей Nix вообще оказался быстрее на 10–15 %. Как так? Ведь кажется, что основное время должно уходить на скачивание пакетов, и в обоих случаях оно должно быть плюс-минус одинаковым. А у Nix при этом должны сохраниться проблемы из предыдущего пункта.
Но это не совсем так. В случае с Docker установка пакетов производится через вызов пакетного менеджера командой run
в Dockerfile. А это далеко не самая простая операция для сборщика, так как все команды run
выполняются во временном контейнере, что добавляет накладные расходы.
Плюс к этому сам пакетный менеджер (в нашем случае apt) вносит свои
расходы.
У Nix же в этом плане всё проще: свой пакетный менеджер, который скачивает архив с запрашиваемым пакетом на хостовую систему (без временного контейнера) и создает слой с запрашиваемым пакетом в образе. Такая операция получается сильно дешевле. Как следствие, накладные расходы Docker в этой задаче компенсирует расходы Nix. А в случае рабочей системы Nix даже может дать некоторое преимущество. Но если необходимо установить много пакетов, и их скачивание будет занимать много времени, то преимущество Nix уже не будет столь заметным.
Третий и четвёртый тесты проводились для выяснения, как хорошо сборщики справляются с копированием файлов с хоста в контейнер. Третий тест был нацелен на небольшое количество больших файлов, четвертый — на множество маленьких, чтобы убедиться, что никакие краевые случаи не вызывают проблем у какого‑то из сборщиков.
Из результатов видно, что Docker во всех случаях справился лучше, чем Nix. Задержки во времени у Nix, вероятнее всего, связаны с тем, что первоначально он переносит все данные из указанной папки в своё отдельное хранилище (/nix/store). Все компоненты для сборки берутся только оттуда, чтобы изменения после запуска не повлияли на результат. Поэтому перед началом любых действий Nix всегда всё переносит в хранилище. Клиент Docker тоже передаёт контекст с файлами сборщику, а полученные данные сразу переносятся в слой образа.
Самые интересные результаты даёт последний, пятый тест. Его цель — сравнить скорость работы команды run
в Dockerfile и аналогичной в Nix. То есть выяснить, как быстро сборщик
выполнит пользовательскую инструкцию во время сборки. Тест разбит на две
части, так как у Nix есть два параметра, которые позволяют выполнить
скрипт. Это runAsRoot
и extraCommands
.
Различие их (помимо того, что один из них позволяет выполнить действие
из‑под root) в том, что первый позволяет модифицировать данные, которые
уже есть в образе: от базового образа или предыдущих слоёв. А параметр extraCommands
позволяет либо
выполнить действия, не затрагивающие файлы образа, либо создать новые и
в них что‑то записать. Например, если нужно добавить конфигурационный
файл для запуска приложения, то с такой задачей справится extraCommands
. Если же нужно запустить сборку из файлов, которые копируются в образ с хоста, то здесь придётся использовать runAsRoot
,
так как только таким образом у скрипта будет доступ к этим файлам.
Для сравнения Docker с обоими способами в Nix тест разбит на две части:
5.1 — сравнение с runAsRoot
, 5.2 — с extraCommands
.
Получаем ошеломляющий результат: runAsRoot
проигрывает
Docker в целых 40 раз на чистой системе и более чем в 140 — на рабочей.
То есть даже без учёта скачивания базового образа Nix работает
колоссально долго. Если сравнивать с предыдущими тестами, где Nix
проигрывал максимум пару десятков секунд, тут счёт уже идёт на минуты.
Почему так получается? В результатах второго теста кратко описывалось, как работает команда run
Docker, которая используется и здесь. В Docker она выполняется
во временном контейнере, но для этого надо уметь запускать контейнеры,
а Nix этого не умеет. Зато он умеет запускать виртуальные машины. Именно
это он и делает во время сборки: создаёт VM, переносит в неё весь
контекст сборки, выполняет запрошенную команду и переносит результат
в образ контейнера. Все, кто хоть раз запускали VM, знают, сколько
времени и ресурсов процессора это отнимает. Именно поэтому runAsRoot
так долго выполняется.
В свою очередь, extraCommands
работает достаточно быстро. Всё ещё значительно медленнее, чем Docker, но не так критично, как runAsRoot
. Такой прирост в скорости связан с тем, что extraCommands
выполняет все действия на хосте и переносит результат в образ. Это
позволяет выполнить команду достаточно быстро, но ограничивает доступ,
чтобы пользовательская команда не могла повредить систему хоста.
Выводы
Тесты показывают, что в настоящий момент Nix вряд ли может выступать в качестве полноценной замены Docker по части сборки образов. У него есть свои преимущества в виде надёжности, воспроизводимости и декларативности. Но есть и недостатки, основные из которых — неполноценный сетевой доступ и, к сожалению, время работы, которое в большинстве случаев оставляет желать лучшего.
Вероятнее всего, сейчас для большинства читателей это будут критические недостатки, которые заставят отказаться от Nix. Но, возможно, для каких‑то узких и специфичных задач Nix вам подойдёт. Например, если вам нужно только устанавливать пакеты в образ, то с этим Nix может справиться даже лучше. Но при выполнении кастомных команд, модифицирующих содержимое во время сборки, от Nix лучше держаться подальше.
Во всем остальном, касающемся сборки образов, Nix — это скорее компромисс, который далеко не всегда будет удовлетворять вас своей производительностью. Но у него есть свои фанаты и достаточно большое сообщество, которое явно будет улучшать его — в том числе и в области сборки образов, хотя это далеко не единственная и не главная его задача. В любом случае это довольно интересный инструмент, за развитием которого стоит последить.
Исходники тестов (Docker‑ и Nix‑файлы) можно посмотреть в репозитории.