[Перевод] Организуем окружение Rust и сборку Docker с применением Nix Flakes
❯ Чем интересен Nix
В Rust новое окружение для разработки обычно настраивается без труда — просто воспользуйтесь rustup и в добрый путь. Но при использовании такого сборочного инструмента как язык Nix, вы можете приобрести гораздо больше, лишь немного потрудившись. Ведь Nix позволяет:
- Указывать в коде зависимости от проектов, не написанных на rust;
- Автоматически добавлять в путь все инструменты/зависимости ваших проектов при помощи direnv;
- С лёгкостью собирать тонкие контейнеры Docker.
Стоит начать применять Nix при работе в репозитории — и «как раньше» уже не захочется. Никаких больше README со списком команд Homebrew, apt, pacman и др., которые было бы необходимо выполнять. Сборка тонких контейнеров Docker делается в два счёта, без необходимости вручную вручную обрабатывать множество слоёв, из которых требуется копировать сборочные артефакты.
Этот пост — спартанское руководство, которое поможет быстро вкатиться в работу с nix, поэтому я не буду вдаваться в подробности о том, как nix работает под капотом, и каков синтаксис nix. Если вам нужна дешёвая и сердитая справка по синтаксису nix — рекомендую пост learn X in Y’s. Если у вас есть некоторый опыт функционального программирования, то большую часть базовой информации сразу подхватите.
❯ Окружение для разработки
Воспользуемся nix flakes, чтобы настроить nix для нашего проекта. Flakes (далее — «снежинки») — это новаторский инструмент для улучшения воспроизводимости сборок; для этого в проект добавляется так называемый «файл блокировок» (lock file). Каждая снежинка может иметь вводы inputs
, в качестве которых служат другие снежинки/файлы nix и много выводов. Здесь стоит отметить, что все файлы, на которые стоят ссылки из ваших снежинок (в том числе, сама снежинка) должны добавляться в git. Если вы столкнётесь с ошибками «file not found» (файл не найден), убедитесь, что добавили всё необходимое командой git add
.
Чтобы приступить к работе в корне вашего проекта, выполните:
$ nix flake init
В результате у вас сформируется снежинка flake.nix
— файл, который будет иметь вид:
flake.nix
{
description = "A very basic flake";
outputs = { self, nixpkgs }: {
packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;
defaultPackage.x86_64-linux = self.packages.x86_64-linux.hello;
};
}
Эта стартовая снежинка соберёт для вас двоичный файл hello world при помощи nix build .#hello
, вызывающей первую строку, или просто при помощи nix build
, вызывающей строку defaultPackage
. Недостаток такой сборки в том, что получающийся пакет применим только в x86/64 Linux. Поэтому давайте расширим ввод, чтобы круг пригодных систем также увеличился.
flake.nix
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let pkgs = import nixpkgs { inherit system; };
in {
packages.hello = pkgs.hello;
defaultPackage = pkgs.hello;
});
}
Мы добавили ещё два ввода. Первый — nixpkgs
, позволяющий указать, какую версию nixpkgs следует использовать. В репозитории nixpkg насчитывается много тысяч пакетов, и обновляются они настолько часто, что здесь мы воспользуемся нестабильной веткой. Также добавим flake-utils, помогающий обеспечить поддержку снежинки сразу во многих системах, а не только в Linux.
Теперь под Linux и mac соберётся пакет hello. Запустив nix build
, вы должны увидеть каталог result
, в котором содержится пакет hello
, и его можно выполнить при помощи ./result/bin/hello
. Каталог result — это символьная ссылка на вывод сборки в хранилище nix (там nix хранит все файлы вывода). Это не обязательно будет каталог, результат просто зависит от сборки.
❯ Rust в Nix
Чтобы продвинуться от «hello world» к rust, давайте добавим ещё один ввод:
flake.nix
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay";
};
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; };
rustVersion = pkgs.rust-bin.stable.latest.default;
in {
devShell = pkgs.mkShell {
buildInputs =
[ (rustVersion.override { extensions = [ "rust-src" ]; }) ];
};
});
Мы добавили rust-overlay, что позволяет нам без труда указывать различные версии rust — и не полагаться на nixpkgs
для справки о том, какая версия rust используется в конкретном случае.
Мы также переключили outputs
на работу только c devShell
, и этот вывод привязан к nix develop
. Запустив его, вы получите в песочнице новую оболочку со стабильной версией rust.
Если вы хотите воспользоваться конкретной версией/ночной сборкой, то попробуйте rustVersion = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml)
; для считывания файла инструментария rust. Работайте с указанной там версией.
Также вы могли заметить, что мы добавили .override { extensions = [ "rust-src" ]; })
. Этот код нужен анализатору rust, чтобы получить исходный код rust.
❯ Автоматически загружаем окружение Nix
Теперь, когда мы обзавелись интересующей нас версией rust, давайте автоматизируем шаг nix develop
.
Установите direnv и nix-direnv. Второй компонент опционален, но помогает с кэшированием, так что рекомендую взять и его.
Direnv добавит в вашу оболочку перехватчики, поэтому, когда вы зайдёте в ваш проект через cd
, то проект автоматически загрузит окружение nix за вас, и вам не потребуется выполнять nix develop
.
В корне вашего проекта выполните:
$ echo "use flake" >> .envrc
$ direnv allow
Файл .envrc
будет загружен direnv и для обустройства вашего окружения будет использоваться полученный от снежинки вывод devShell
. При внесении изменений в direnv вашей снежинки загружайте только то, что изменилось.
Если вы работаете с VS Code, пользуйтесь nix env selector, так, чтобы VS Code «знала» о снежинке. Без этого можно и обойтись, если вы открываете VS Code из командной строки, но настроить такую возможность не составляет труда.
❯ Сборка проекта Rust
Теперь, когда мы добавили rust в наше рабочее окружение, и новое приложение на rust мы можем сделать с помощью:
$ cargo init
Затем можно запустить/собрать проект так, как это обычно делается при помощи cargo run/cargo build
. В процессе разработки всё функционирует нормально, но давайте попытаемся собрать проект при помощи nix. Это пригодится нам позже, на этапе сборки образа docker.
Давайте и выводы обновим.
flake.nix
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; };
rustVersion = pkgs.rust-bin.stable.latest.default;
rustPlatform = pkgs.makeRustPlatform {
cargo = rustVersion;
rustc = rustVersion;
};
myRustBuild = rustPlatform.buildRustPackage {
pname =
"rust_nix_blog"; # make this what ever your cargo.toml package.name is
version = "0.1.0";
src = ./.; # the folder with the cargo.toml
cargoLock.lockFile = ./Cargo.lock;
};
in {
defaultPackage = myRustBuild;
devShell = pkgs.mkShell {
buildInputs =
[ (rustVersion.override { extensions = [ "rust-src" ]; }) ];
};
});
Сначала нам нужно сделать rustPlatform
с нашей версией rust. На этой платформе позволяется собрать пакет rust при помощи rustPlatform.buildRustPackage
. В nix это равноценно cargo build
. Нам понадобится cargoLock.lockFile
, чтобы nix мог кэшировать все зависимости вашего проекта, основываясь на имеющемся у вас файле блокировок.
Теперь можно выполнить nix build
, тогда ваш проект снова окажется в каталоге result
. В моём случае я могу выполнить ./result/bin/rust_nix_blog
.
❯ Делаем образ Docker
Теперь, когда мы научили nix собирать проект rust, сделать контейнер docker не составляет труда.
flake.nix
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; };
rustVersion = pkgs.rust-bin.stable.latest.default;
rustPlatform = pkgs.makeRustPlatform {
cargo = rustVersion;
rustc = rustVersion;
};
myRustBuild = rustPlatform.buildRustPackage {
pname =
"rust_nix_blog"; # make this what ever your cargo.toml package.name is
version = "0.1.0";
src = ./.; # the folder with the cargo.toml
cargoLock.lockFile = ./Cargo.lock;
};
dockerImage = pkgs.dockerTools.buildImage {
name = "rust-nix-blog";
config = { Cmd = [ "${myRustBuild}/bin/rust_nix_blog" ]; };
};
in {
packages = {
rustPackage = myRustBuild;
docker = dockerImage;
};
defaultPackage = dockerImage;
devShell = pkgs.mkShell {
buildInputs =
[ (rustVersion.override { extensions = [ "rust-src" ]; }) ];
};
});
Теперь nix build
или nix build .#docker
соберёт образ docker. После сборки result
станет просто символьной ссылкой на tar-файл с образом; в архиве, как и раньше, будет лежать каталог. Поскольку nix декларативен, он не загружает этот файл в docker за вас. Вы можете загрузить его сами при помощи:
$ docker load < result
В качестве вывода вы должны получить rust-nix-blog:yyc9gd4nkydrikzpsvlp3gmwnpxhh1ik
— это загруженный образ с тегом.
Теперь запустим образ с помощью:
$ docker run rust-nix-blog:yyc9gd4nkydrikzpsvlp3gmwnpxhh1ik
Этот процесс можно немного автоматизировать при помощи скрипта. (Слегка модифицированный пример предлагается здесь).
#!/usr/bin/env bash
set -e; set -o pipefail;
nix build '.#docker'
image=$((docker load < result) | sed -n '$s/^Loaded image: //p')
docker image tag "$image" rust-nix-blog:latest
Воспользуемся dive, чтобы просмотреть образ. Для временного использования подойдёт nix shell nixpkgs#dive
.
33 MB └── nix
33 MB └── store
374 kB ├─⊕ h4isr1pyv1fjdrxj2g80vsa4hp2hp76s-rust_nix_blog-0.1.0
1.8 MB ├─⊕ ik4qlj53grwmg7avzrfrn34bjf6a30ch-libunistring-1.0
246 kB ├─⊕ w3zngkrag7vnm7v1q8vnqb71q6a1w8gn-libidn2-2.3.2
30 MB └─⊕ ybkkrhdwdj227kr20vk8qnzqnmj7a06x-glibc-2.34-115
Просмотрев вывод, видим, что это однослойный образ, в котором содержится только необходимый минимум для запуска двоичного файла (в нашем случае — только libc и код rust).
❯ Распространённые проблемы при устранении неполадок
Сборочные зависимости, не относящиеся к Rust
Когда сборка при помощи nix настроена — это отличная штука, она уже никогда не откажет. Но доведение её до рабочего состояния — процесс порой болезненный. Если ваш код rust полагается на системные пакеты (например, на OpenSSL), не забудьте включить их, например, в buildInputs
.
rustPlatform.buildRustPackage {
pname =
"rust_nix_blog"; # make this what ever your cargo.toml package.name is
version = "0.1.0";
src = ./.; # the folder with the cargo.toml
nativeBuildInputs = [pkgs.pkg-config ]; # just for the host building the package
buildInputs = [pkgs.openssl]; # packages needed by the consumer
cargoLock.lockFile = ./Cargo.lock;
};
Конкретно для OpenSSL я рекомендовал бы по возможности использовать rustls. Сборка с ней протекает легче, и написана она на rust.
❯ Документация Nix
Документация Nix не самая качественная. Да, некоторая информация отыскивается в вики и мануале, мне кажется, что наилучшие материалы есть в разнообразных блогах по nix. Вот несколько хороших ссылок:
- Xe пишет в основном о nixos — дистрибутиве Linux на основе nix. Но по её статьям удобно учить язык и его распространённые паттерны.
- Блог Иэна Генри по изучению nix — серия статей о том, как автор сам учил nix. Это не столько документация, сколько избранные заметки о ходе работы при использовании nix.
- Знакомство со снежинками nix. Отличная трёхчастная подборка о том, что представляют собой снежинки, и как с ними работать.
- Сборка контейнеров docker при помощи nix. Ссылался на этот источник ранее, но упомяну ещё раз, так как это хорошая справка по дополнительным опциям, используемым при сборке контейнеров.
Также исключительно рекомендую самостоятельно покопаться в nix, освоив Nixos в качестве рабочего дистрибутива Linux. Так вам будет проще освоиться с языком и быстро перейти к его использованию, когда у вас сложится полная картинка.