Compiling Elixir+Mix projects that use Rustler with Nix
- published
This was a bit annoying to figure out, and I saw no resources talking about this on the Internet yet, so I’m hoping this will help some poor lost soul in the future (which might as well even be me, years from now).
For context: the way I like to work on my projects is to use the normal development tools (cargo, mix, pnpm, node, and so on) when I’m developing code, but have those tools provided to me through Nix. When I’m deploying code, I don’t use those tools directly, but build everything through Nix, which means I’ll eventually use
buildRustPackage
,crane
,buildNpmPackage
, and so on. During development, I just get the tools through a nix shell.
Some background
There’s this really cool project called Rustler which lets you easily run Rust code inside Erlang/Elixir. Erlang has support for something called Native Implemented Functions, which essentially means that it can load a shared library and make some calls to code from it, all within Erlang code. Elixir inherits this capability.
Rustler makes it easy to build and use a shared library following the structure that Erlang’s NIF expects. To do so, it provides some Rust code, which helps build the shared library. It also provides some Erlang/Elixir code, which helps load and use the shared library.
I’ll stop referring to Erlang/Elixir now, because what follows comes from me trying to do things within Elixir and Mix. It might be different for Erlang.
By default, the Elixir code provided by Rustler builds the Rust code. This means that as the Elixir code is being compiled, when it gets to the Rustler-specific bits, it will trigger a build of some Rust code. This is pretty cool behaviour for development, but is a bit of a nightmare scenario when doing things within Nix.
Nix sandboxes builds, which means that unless you do some magic with nativeBuildInputs
and other things, it will fail to compile your Elixir code because it can’t find cargo
.
Worse, even if you do make it find cargo
, the default cargo
behaviour will be to download dependencies of your code and do a lot of other things, and this will also fail within Nix, since the sandbox disables network access by default.
Making Rustler work in Nix
Luckily, Rustler’s Elixir code let you control its Rust-compiling behaviour by preventing any compilation from happening at all.
With Nix, it would be preferrable to build the Rust code separately, and then provide the shared library from the Rust code to the Elixir code. This becomes very relevant when you want to have more control over how the Rust code is built, by changing the target to musl or something like that.
To accomplish this, I’m assuming you already have the following set up:
- A derivation that builds your Elixir code without any Rustler/native bits at all;
- A derivation that builds your Rust code.
This is roughly how I had these things in my project (a few details omitted to focus on the important bits).
My Elixir derivation uses mixRelease
:
# This comes from mix2nix.
mixNixDeps = import ./mix_deps.nix { inherit beamPackages lib; };
elixir_project = beamPackages.mixRelease {
inherit src pname version mixNixDeps;
};
And my Rust derivation uses crane
, compiling for a musl target:
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
targets = [ "x86_64-unknown-linux-musl" ];
};
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
commonArgs = {
inherit src;
CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl";
CARGO_BUILD_RUSTFLAGS = "-C target-feature=-crt-static";
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
myCrate = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
});
For this post, I’ll assume the name of the crate is mycrate
, which means it will generate a shared library called libmycrate.so
.
To support the pattern of “use default Rustler behaviour during development” and “use Nix tooling during deployment”, I had to come up with different ways to use the Rustler-specific Elixir code based on whether I was developing or deploying with Nix.
There might be a better way to do this, but I used a macro like the following:
defmodule MyRustlerMacro do
defmacro rustler_use() do
use_precompiled = System.get_env("PRECOMPILED_NIF", "false")
cond do
use_precompiled == "true" or use_precompiled == "1" ->
quote do
use Rustler,
otp_app: :my_app,
skip_compilation?: true,
load_from: {:my_app, "priv/native/libmycrate"}
end
true ->
quote do
use Rustler,
otp_app: :my_app,
crate: "mycrate",
path: "../mycrate",
target: "x86_64-unknown-linux-musl"
end
end
end
end
And then in the module that has the functions to be replaced by Rustler:
defmodule MyRustler do
require MyRustlerMacro
MyRustlerMacro.rustler_use()
def function_from_rustler1, do: :erlang.nif_error(:nif_not_loaded)
def function_from_rustler2, do: :erlang.nif_error(:nif_not_loaded)
end
The macro changes its behaviour depending on whether the PRECOMPILED_NIF
environment variable is set.
When it’s not set, it will default to Rustler’s default behaviour, which is to perform the Rust compilation within the Elixir compilation.
When it’s set, it will use Rustler by telling it not to compile anything, and to load the shared library at priv/native/libmycrate
(Erlang’s NIF will load priv/native/libmycrate.so
, but you shouldn’t add the extension here).
The reason why I use
priv/native
is because I’m doing this from a Phoenix project, which already usespriv
for other things. The default Rustler behaviour will also put the shared library inpriv/native
, so I’m not deviating too much here.
With the Elixir code properly configured, some extra bits are necessary in the Elixir derivation, because we’re not telling it about the Rust code at all yet. Let’s fix that:
elixir_project = beamPackages.mixRelease {
inherit src pname version mixNixDeps;
PRECOMPILED_NIF = true;
preBuild = ''
mkdir -p priv/native
cp ${myCrate}/lib/libmycrate.so priv/native
'';
};
With this done, the Elixir code should properly build and run now.
Some bonuses
-
Switching compile-time behaviour based on an environment variable like that is a Bad Idea™ in most cases, because Mix won’t recompile your code if the only thing that changed was the value of the environment variable. This works here because it’s assumed we’ll never set that environment variable during development, and Nix will force the derivation to be rebuilt if the environment variable changes.
Since Nix always builds things in sandboxes, it won’t ever reuse previous artifacts from the Elixir compilation, so Elixir will always compile everything again should the environment variable change. We’re essentially relying on a Nix behaviour to make recompilation happen whenever the environment variable changes, but this shouldn’t be an issue either because within Nix we always want that environment variable to be set (otherwise the build won’t work at all!).
-
I believe it’s not really necessary to copy the shared library in the
preBuild
phase. I only do this because, as I mentioned, I’m using Phoenix, which will end up packing the entirepriv
directory in the outputs for me. From what I could gather, if the shared library doesn’t exist during the Elixir compilation, there’ll be a warning emitted, but it’ll still proceed. The shared library only needs to exist when the Elixir project runs. You probably can get away with copying the shared library at some later phase in the derivation if you want/need to. This is all a guess though, because I haven’t explored it.