На текущем месте работы столкнулся с необходимости собирать Docker образы для сервисов написанных на Rust. Обычно в таком случае пишется Dockerfile, который внутри докера просто собирает контейнер и все. Но все оказалось не так однозначно: такая схема довольно неплохо работает, когда у тебя есть x86_64 Linux машина, но любой шаг в сторону и начинаются большие проблемы.
Все довольно неприятно уже на Intel MacBook машинах, докер поедает довольно много ресурсов с хоста, а еще возникают всякие странные приколы с монтированием файловой системы и правами доступа. Но настоящий ужас начинается на Макбуках с Apple Silicon процессорами, где обычной виртуализацией уже не обойдешься и можно часами ждать сборки простого сервиса через qemu. Можно решать эту проблему через сборку контейнеров в CI, но когда разработчиков много, а им надо часто что-то пересобирать, то там образовывалась очередь.
Поэтому я начал искать пути, а каким же образом можно избежать в общем-то не нужной виртуализации и в результате нашел довольно успешный и универсальный подход, которым и хочу теперь поделиться.
Nix Package Manager
В проекте мы уже довольно давно для контроля зависимостей пользовались Nix Package Manager, кто не знает, это такой вот чисто функциональный пакетный менеджер и среда разработки ПО, которая позволяет изолировать зависимости проекта, обеспечивая воспроизводимость и предсказуемость в разработке.
Если более простыми словами, то это такой вот аналог virtualenv, или nvm, но который умеет создавать виртуальные окружения, состоящие из вообще любых мыслимых и немыслимых программ и зависимостей.
Устанавливается он довольно просто (хотя на маке и требует создание APFS тома и root прав), и позволяет обходиться единым описанием окружения как на Linux (в том числе WSL), так и на MacOS. А само окружение описываются в специальном shell.nix файле, и выглядят следующим образом:
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
# Всякие бинарные программы, типа компиляторов
nativeBuildInputs = with pkgs; [
rustc
cargo
protobuf
cmake
pkg-config
];
# Всякие библиотечные зависимости
buildInputs = with pkgs; [
openssl
zlib
];
}
Ну а дальше просто в директории, в которой описан этот shell.nix файл просто достаточно написать команду
# Просто войти в шелл
nix-shell shell.nix
# А можно еще и сразу выполнить команду из под шелла
nix-shell --run "cargo build"
И дальше Nix Package Manager соберет окружение, содержащее все описанное в shell.nix файле. Такого файла вполне достаточно, чтобы собирать простые Rust проекты. Причем, все зависимости будут установлены в /nix/store директорию и тем самым не будут загрязнять систему, и могут быть позже удалены командой nix store gc.
Думаю, что наступило самое время попробовать этот файлик в деле и убедиться, что все установилось и работает, перед тем, как идти дальше.
Использование его в качестве кросс-компилятора
Дальше оказалось, что Nix довольно неплохо поддерживает кросс-компиляцию, но мои попытки просто так взять и в лоб собрать с Мака под Линукс что-то, воспользовавшись шеллом, описанным выше, с треском провалилась. Оказалось, что и Rust из Nix пакетов не очень умеет в кросс-компиляцию и еще куча библиотек по факту просто не собираются.
Но в интернете было довольно много подсказок и советов как заставить эту всю схему работать, а дальше я уже, руководствуясь накопленным опытом и документацией по Nix, начал совершенствовать все эти наработки и в конце концов это все выросло в полноценный оверлей для кросс-компиляции, в котором я смог существенно упростить настройку кросс-компиляции, и не только это.
Существует еще и кеш двоичных пакетов, в котором уже есть большое количество собранных библиотек, что заметно убыстряет первую сборку шелла, чтобы включить его, выполните команду. Очень рекомендую это сделать до того, как вы будете пробовать запускать примеры.
nix-shell -p cachix --run "cachix use nixpkgs-cross-overlay"
В итоге, чтобы получить работающий шелл, который может собирать статически слинкованные musl бинарники под x86_86 достаточно воспользоваться примерно таким файлом:
{ localSystem ? builtins.currentSystem
# Default cross-compilation configuration, you may override it by passing the
# `--arg crossSystem '<our-own-config>'` to `nix-shell`.
, crossSystem ? { config = "x86_64-unknown-linux-musl"; isStatic = true; useLLVM = true; }
# Override nixpkgs-cross-overlay branch.
}:
let
# Fetch the nixpkgs-cross-overlay sources.
src = builtins.fetchTarball "https://github.com/alekseysidorov/nixpkgs-cross-overlay/tarball/main";
# Use the nixpkgs revision provided by the overlay.
# This is the best way, as they are the most proven and compatible.
nixpkgs = "${src}/utils/nixpkgs.nix";
# Make cross system packages.
pkgs = import nixpkgs {
inherit localSystem crossSystem;
overlays = [
# <- You may add your extra overlays here.
];
};
in
# And now, with the resulting packages, we can describe the cross-compilation shell.
pkgs.mkShell {
# Native project dependencies like build utilities and additional routines
# like container building, linters, etc.
nativeBuildInputs = [
# This overlay also provides the `rust-overlay`, so it is easy to override the default Rust toolchain setup.
# Uncomment this line if you want to use the Rust toolchain provided by this shell.
pkgs.pkgsBuildHost.rust-bin.stable.latest.default
# Will add some dependencies like libiconv.
pkgs.pkgsBuildHost.rustBuildHostDependencies
# Crates dependencies
pkgs.cargoDeps.openssl-sys
pkgs.cargoDeps.prost
];
# Libraries essential to build the service binaries.
buildInputs = with pkgs; [
# Enable Rust cross-compilation support.
rustCrossHook
# Some native libraries.
icu
];
# Prettify shell prompt.
shellHook = "${pkgs.crossBashPrompt}";
}
Запускать привычным образом:
nix-shell shell.nix
Такие бинарники не зависят от libc и их можно просто напрямую распространять в любом Linux дистрибутиве, ну или паковать в докер образ. Одним словом, быстро, просто и удобно. В принципе, уже на этом месте можно расслабиться и получать удовольствие от жизни, получившиеся бинарники вполне себе можно паковать в Alpine Linux и дальше распространять, но Nix сам умеет собирать докер образы вообще без самого Докера и написания докерфайлов.
Nix dockerTools
При помощи dockerTools можно собирать докер образы напрямую при помощи Nix, причем содержать они будут только лишь самый минимальный набор файлов, сравнимый с distroless образами, а все зависимости можно разруливать все так-же при помощи Nix Package Manager.
У меня есть пример скрипта, который может собрать минимальный докер образ из выданного ему Rust бинарника:
{ localSystem ? builtins.currentSystem
, crossSystem ? { config = "x86_64-unknown-linux-musl"; isStatic = true; useLLVM = true; }
}:
let
# Fetch the nixpkgs-cross-overlay sources.
src = builtins.fetchTarball "https://github.com/alekseysidorov/nixpkgs-cross-overlay/tarball/main";
# Use the nixpkgs revision provided by the overlay.
# This is the best way, as they are the most proven and compatible.
nixpkgs = "${src}/utils/nixpkgs.nix";
# Make cross system packages.
pkgs = import nixpkgs {
inherit localSystem crossSystem;
overlays = [
# <- You may add your extra overlays here.
];
};
in
# And now, with the resulting packages, we can describe the cross-compilation shell.
pkgs.mkShell {
# Native project dependencies like build utilities and additional routines
# like container building, linters, etc.
nativeBuildInputs = [
pkgs.pkgsBuildHost.rust-bin.stable.latest.default
# Will add some dependencies like libiconv.
pkgs.pkgsBuildHost.rustBuildHostDependencies
# Crates dependencies.
pkgs.cargoDeps.openssl-sys
# A simple script to create a docker image from the Cargo workspace member.
(pkgs.pkgsBuildHost.writeShellApplication {
name = "cargo-nix-docker-image";
runtimeInputs = with pkgs.pkgsBuildHost; [
nix
docker
];
text = let shellFile = ./shell.nix; in ''
binary_name=$1
# Compile cargo binary
cargo build --release
# Copy this shell to the target dir
cp ${shellFile} ./target/shell.nix
# Build docker image from the compiled service
image_archive=$(nix-build ./target/shell.nix -A dockerImage --argstr name "$binary_name")
docker load <"$image_archive"
'';
})
];
# Libraries essential to build the service binaries.
buildInputs = with pkgs; [
# Enable Rust cross-compilation support.
rustCrossHook
];
# Prettify shell prompt.
shellHook = ''
${pkgs.crossBashPrompt}
echo "Welcome to the Cargo docker images builder demo shell!"
echo ""
echo "Usage:"
echo ""
echo "$ cargo-nix-docker-image <executable-name>"
echo ""
echo "Have a nice day!"
'';
/* Service docker image definition
Usage:
```shell
cargo-nix-docker-image executable-name>
```
*/
passthru.dockerImage = (
{
# Cargo workspace member name
name
, tag ? "latest"
}:
pkgs.pkgsBuildHost.dockerTools.buildLayeredImage {
inherit tag name;
contents = with pkgs; [
coreutils
bashInteractive
dockerTools.caCertificates
# Actual service binary compiled by Cargo
(copyBinaryFromCargoBuild {
inherit name;
targetDir = ./.;
buildInputs = [
openssl.dev
];
})
# Utilites like ldd to help image debugging
stdenv.cc.libc_bin
];
config = {
Cmd = [ name ];
WorkingDir = "/";
Expose = 8080;
};
}
);
}
Он уже чуть более хитрый, тут shell.nix используется одновременно в двух ипостасях: как описание среды сборки и как аналог Dockerfile. Чтобы собрать докер образ вы должны сделать два шага:
# Зайти в шелл и выполнить команду сборки самого проекты
nix-shell shell.nix
cargo build --release
# Использовать команду nix-build с атрибутом dockerImage для упаковки
# бинарника в Докер образ
export IMAGE=$(nix-build ./target/shell.nix -A dockerImage --argstr name <имя бинарника>)
# Причем в результате у вас в переменной $IMAGE будет ссылка на докер архив,
# который надо будет загрузить командой
docker load <"$IMAGE"
То есть, на первом шаге вы собираете бинарник, а на втором уже пакуете его в докер образ. А чтобы описать оба этих этапа используется passthru, идея ее в том, что на самом деле pkgs.mkShell это функция, а шелл это результат вычисления этой функции, или другими словами output derivation, ну а buildLayeredImage тоже является функцией, результатом вычисления которой является архив с докер образом и если эту функцию передать через атрибут passthru, то можно будет вызывать ее при помощи ключа -A у команды nix-build.
nix-build ./target/shell.nix -A dockerImage --argstr name <имя бинарника>
Думаю, что на этом можно завершить технические детали, о них можно рассуждать бесконечно и главное вовремя остановиться, поэтому, предлагаю перейти уже к заключению.
Полезные ссылки
Nix - официальный сайт
nixpkgs-cross-overlay - сам оверлей
nixpkgs-rust-service-example - полноценный пример использования оверлея, включающий в себя CI и обеспечение воспроизводимости сборки (об этом будет следующая статья).
Заключение
Это лишь наглядная демонстрация возможностей Nix с моим подключенным оверлеем, в принципе, этого уже достаточно, чтобы значительно облегчить себе жизнь, особенно, если вы владелец Apple Silicon машины. Я прямо очень сильно ощутил разницу между тем, что было раньше и как оно стало теперь, а еще с помощью Nix можно делать свои внутренние оверлеи, которые могут содержать любые необходимые для сопровождения проектов утилиты и очень просто их переиспользовать между проектами - это действительно просто археудобно.
И я надеюсь, что это все реально упрощает жизнь, но есть еще много интересного, о чем бы я хотел рассказать, в особенности, на тему воспризводимости сборки, но все это тянет на большую отдельную статью, в общем, ждите новостей.
Кстати говоря, этот оверлей прекрасно будет работать и с C++ или C проектами.
qrdl
А чем не устраивает
?
Мне казалось, что как раз кросс-компиляция в Rust делается просто, я на Linux спокойно собираю под другие платформы
Gorthauer87 Автор
А на маке уже не так просто, да и попробуйте так собрать проект, который зависит от Rocksdb, например, или ему нужен openssl.
lrrr11
а в чем проблема собрать openssl под нужную архитектуру?
./Configure VC-WIN64A
и вот это всё. Rocksdb собирается cmake - тоже нет никаких проблем подсунуть ему нужный тулчейн. Или можно даже не собирать, а скачать уже готовые бинарники.Gorthauer87 Автор
Ну конечно то можно руками, можно руками и binutils собирать и gcc и libc, и потом cmake, а потом еще snappy, zstd, bzip2, заодно еще убедиться, что Perl нужной версии. Ну в потом это все в Шелл скрипт запихнуть.
Только нифига все это надо, если можно воспользоваться уже готовым инструментом, который плюс ко всему еще обеспечивает повторяемость сборки. Можно при помощи fake.lock файла зафиксировать версии всех пакетов и тогда скрипт всегда будет работать и выдавать одинаковый результат.
qrdl
Понял, спасибо. Не знал, что Rust на macOS не так работает, как на Linux. Для меня еще один аргумент против перехода на Мак.
Gorthauer87 Автор
Это и на Линуксе так просто не работает, попробуйте без дополнительных пакетов собирать что-то под aarch64, или riscv. Так как минимум, линкер нужен.
А вот чтобы взять его, нужны внешние зависимости.
Кстати, такую проблему решили в Zig, у них есть универсальный линкер, который почти под любую libc работает. Так что в простых случаях проще всего воспользоваться вот такой штукой.
https://github.com/rust-cross/cargo-zigbuild
Но Nix это нечто большее, чем простые случаи и это универсальное решение, которые к тому же единообразно работает на Маке и Линуксе.
slonopotamus
Учитывая что ваша конструкция и так уже подразумевает наличие докера, просто запускаем линукс в нём.
Gorthauer87 Автор
Неа, не подразумевает, моя конструкция в итоге создает архив, который потом через docker load загружается, а может, например, копироваться с помощью skopeo.
Окей, ну да запустили мы Линукс в докере, молодцы, а дальше если нам нужна другая процессорная архитектура, ну например, хост у нас x86_64, а нам нужен aarch64 таргет, то уупс. Или запускать докер внутри qemu транслятора а это минус 90 процентов производительности, или внутри Докера опять же настраивать кросс-компиляцию.
Так а если нам все равно ее настраивать, нафига нам лишний слой?