Nix IFD: A Ticking Time Bomb in Your Build Pipeline?

Nix IFD: A Ticking Time Bomb in Your Build Pipeline?
📆 Mon Dec 30 2024 von Jacek Galowicz
(10 Min. Lesezeit)

In the world of Nix, there’s a controversial technique that can either turbocharge your development or slam the brakes on your build times: Import From Derivation (IFD). IFD makes managing dependencies easier, but it can also make things complicated.

This article explains how IFD works and why it can be good or bad. It helps you understand when to use IFD and when to avoid it. Read on to make your Nix builds run smoothly!

What is IFD?

IFD, Import From Derivation, is a technique that is used for building packages with e.g. automatic dependency resolution. To understand how IFD works, let’s first have a look at how non-IFD builds work:

The Phases of Nix Builds

When we build a package, several things happen, but this is not immediately visible to us. This command builds the GNU hello app with a single command from the latest nixpkgs checkout:

$ nix build --print-out-paths nixpkgs#hello
/nix/store/9l3rahv16sipckaksiw988lxyfcihql9-hello-2.12.1
$ /nix/store/9l3rahv16sipckaksiw988lxyfcihql9-hello-2.12.1/bin/hello
Hello, world!

(The --print-out-paths flag is generally not needed but for this article, it is useful because it prints the output path that contains the package.)

When we run this command, the package build happens in multiple phases:

  1. Fetch the latest nixpkgs checkout that contains the nix expression behind nixpkgs#hello
  2. Evaluate the nix expression and create a derivation
  3. Realize the derivation and create an output path

Eelco’s original PhD thesis about Nix illustrates these phases like this:

The older, pre-flakes commands nix-instantiate and nix-store allow us to perform a nix build in the individual steps:

$ nix-instantiate ~/src/nixpkgs -A pkgs.hello
/nix/store/dwkzl7flwsi6rjginyhv9driwvyqf1s4-hello-2.12.1.drv
$ nix-store --realise /nix/store/dwkzl7flwsi6rjginyhv9driwvyqf1s4-hello-2.12.1.drv
/nix/store/99hmg51vhxrapv3n0d7afr10mlsyyq68-hello-2.12.1
$ /nix/store/99hmg51vhxrapv3n0d7afr10mlsyyq68-hello-2.12.1/bin/hello      
Hello, world!

With this sequence of commands, it’s obvious that the derivation step creates a .drv file in the nix store. When you open this file with a text editor, you will recognize that it contains structured data that refers to other nix store paths, but it essentially does not contain any nix expressions. The realization step creates the package from the structured information in the .drv file. .drv files can also easily be transferred to other hosts for parallel distributed compilation.

The following diagram illustrates what happens in a time sequence and what we are waiting for in each step:

Of course, the evaluation is typically rather quick, and what we’re waiting for most of the time is the package build itself.

What is interesting, though, is that the evaluation results in just one .drv path that is printed on the terminal. However, this .drv file refers to potentially many other .drv files. These are its dependencies, as most packages are not standalone but need libraries to run etc.

What the nix daemon sees after evaluation is a graph of derivations that depend on each other. With this information, the nix daemon regards each .drv file as a work package and parallelizes them as much as possible:

In this example, package D might be our hello package, and the other derivations are its dependencies, like the C library, etc. The daemon will try to download as much as possible from binary caches and only rebuild what is not available in the cache.

What is Import From Derivation (IFD)?

The output of a realized derivation is mostly executable binaries, libraries, etc. but they don’t have to. A realized derivation can also result in a folder with PDFs, images, XML/JSON documents, etc. - but also nix expressions that might have been generated from some other input. This happens quite often (see also the Practical Examples of IFD section later) and the consequence is that the Nix evaluator has to wait for the Nix builder to finish generating nix expressions before it can evaluate them:

In this diagram, Nix expression 1 is the one that imports Nix expression 2. But Nix expression 2 first has to be generated from some input. This leads to two Nix realisation steps.

This time sequence illustrates the user’s experience:

The nix tooling switches hence and forth between evaluation and realisation (building). The current implementation of the logging of these phases is lacking a bit, so for the user, it looks like a long-running evaluation.

The biggest problem potential comes with the fact that evaluation is inherently sequential (at least in the current implementation), while only realization is parallel. This means that builds with multiple cases of IFD do not scale well because they happen strictly one after the other.

Enabling or Disabling IFD

To check if IFD is enabled on your system, run this command:

$ nix config show | grep allow-import-from-derivation
allow-import-from-derivation = true

This setting can be set permanently in the nix configuration file in /etc/nix/nix.conf/ or ~/.config/nix/nix.conf.

How do I see if a project uses IFD?

The nix documentation page on IFD is very clear on that:

Passing an expression expr that evaluates to a store path to any built-in function that reads from the filesystem constitutes Import From Derivation (IFD)

Built-in functions that read from the filesystem are for example builtins.import or builtins.readFile, etc.

This explanation is short and clear but it does not help identify if a project uses IFD just by looking at the code, because such built-in functions are used everywhere. However, it might help to look at a concrete example to thoroughly understand what’s going on.

An IFD Example

Let’s get a feeling for how IFD works and what it looks like in action by building a small example that simulates aspects from real life:

# file: ifd.nix
let
  pkgs = import <nixpkgs> {};

  ifd = pkgs.runCommand "generate-nix-expr" {} ''
    mkdir $out
    echo "Generating Nix expression..."
    sleep 5

    cat <<EOF > $out/generated-nix-expr.nix

    { stdenv, lib, hello }:
    stdenv.mkDerivation {
      name = "hello-ifd";
      src = hello.src;
      env = lib.optionalAttrs stdenv.hostPlatform.isDarwin {
        NIX_LDFLAGS = "-liconv";
      };
    }
    EOF
  '';
in
pkgs.callPackage (ifd + "/generated-nix-expr.nix") {}
#                |<----------------------------->|
#                   This expression depends on a 
#                   store path that needs to be 
#                   realized

The generated variable holds a Nix derivation that creates a little hello-ifd package expression. This package expression is stored under $out/generated-nix-expr.nix with the following content:

{ stdenv, lib, hello }:
stdenv.mkDerivation {
  name = "hello-ifd";
  src = hello.src;
  env = lib.optionalAttrs stdenv.hostPlatform.isDarwin {
    NIX_LDFLAGS = "-liconv";
  };
}

This example package consumes the source code of the official hello package and rebuilds it as a new package. The env part is only necessary on macOS systems and can be left out on Linux systems.

In the last line of ifd.nix, we use callPackage on the generated package expression, which is where the import from derivation happens. The sleep 5 line simulates that generating the Nix package function expression consumes some compute time, as real-life IFD does, although we exagerrate it a bit for demonstration purposes.

(We also could have generated a nix expression like pkgs: pkgs.hello for example, but for this article, I wanted something that has to rebuild to make the full effect visible.)

Let’s run it:

$ nix-build ifd.nix
building '/nix/store/y17ycv7mwm42i0v1j7pjk82l6zjrk2va-generate-nix-expr.drv'...
Generating Nix expression...
this derivation will be built:
  /nix/store/4gdds17zwy27mykfqmzq1ad55y6lh3wc-hello-ifd.drv
building '/nix/store/4gdds17zwy27mykfqmzq1ad55y6lh3wc-hello-ifd.drv'...
# ... many lines of build output ...
/nix/store/04hjlmnz5z36c9nlzarhkkwc68hi8syk-hello-ifd
$ /nix/store/04hjlmnz5z36c9nlzarhkkwc68hi8syk-hello-ifd/bin/hello 
Hello, world!

We can see that generating the first Nix expression takes 5 seconds. After that, the actual package build runs and provides us with a working Hello-World package.

Let’s change the sleep 5 line to sleep 6 and build it again:

$ nix-build ifd.nix                                              
building '/nix/store/0qg68h7a6wlymmmdlbdvg3q78l1q164l-generate-nix-expr.drv'...
Generating Nix expression...
/nix/store/04hjlmnz5z36c9nlzarhkkwc68hi8syk-hello-ifd

What happens is that the hello-ifd package is not rebuilt because its derivation is the same as before - but we had to wait 6 seconds to find that out. If we run the same command again, the next invocation will be very quick because neither the generating derivation nor the generated derivation changed.

(This seems totally fine as long as the inputs to the IFD don’t change - but imagine a project where your colleagues don’t really know how to do proper source filtering…)

Practical Examples of IFD

Typically for some language stacks like Python (poetry2nix), Ruby (bundix), Rust (Crane), Javascript/Typescript (node2nix, yarn2nix), Haskell (cabal2nix, haskell.nix), OCaml (opam2nix), etc., projects often come with complex lock files that specify whole lists of package versions that need to be used in the exact combination together to build the working application. There is even dream2nix which aims to be an “automagic” IFD tool for multiple programming language stacks.

One popular example is the crane Nix library for Cargo Rust projects. Let’s look at an example from the Crane documentation:

# ...

myPackage = craneLib.buildPackage {
  src = craneLib.cleanCargoSource ./.;
};

With this single call, Crane automatically figures out all the Rust dependencies by their exact version, download hashes, and even the package name from the Rust project. To do this, it parses the Cargo and lock files to create Nix expressions in another sandboxed Nix build, which are then evaluated from IFD context.

Trade-offs and Considerations

In summary, while Import From Derivation offers increased flexibility and dynamic capabilities in Nix builds, it also introduces performance overhead, complexity, and potential pitfalls that should be carefully weighed when considering its use.

Advantages of IFD - Simplified Dependency Management

Without IFD, developers have to generate Nix expressions from e.g. lock files and commit them together with the rest of the code. The disadvantage of this manual approach is that it is easy to forget to keep the lock file and Nix files in sync whenever dependencies are updated. It might also be necessary to exclude the generated Nix files from linter and formatting tools as they are used for example in pre-commit hooks. Also, the generated Nix files might produce huge diffs in the version control system and become annoying noise in pull requests.

With IFD, the lock file becomes the single source of truth because all the Nix expressions are generated on the fly! This way there is no need for manual updates to Nix expressions when dependencies in the lock file change, hence developers can’t forget it.

Not all Nix CI systems support IFD, but Hercules CI for example does.

Disadvantages of IFD

IFD can introduce significant delays during the evaluation phase. Since Nix evaluation is typically single-threaded, incorporating IFD may cause evaluation to stall, leading to longer build times. However, often developers end up with IFD across multiple projects. What they then often don’t realize (yet) is that it can become a problem for downstream consumers: Other developers in the company for example might end up waiting for many IFD evaluations when building system images that consist of many IFD-using packages. Wait times that may be negligible in an upstream project do then pile up badly in downstream projects - consider this when designing guidelines for your organization! This is exactly the reason why IFD is not allowed inside the nixpkgs project.

Increased Complexity: The interleaving of evaluation and realization phases inherent in IFD can complicate the build process, making it harder to understand and debug.

Potential for Non-Determinism: If not carefully managed, IFD can introduce non-deterministic behavior into builds, undermining one of Nix’s core strengths.

Increased Complexity of Cross-Platform Builds: In cross-platform builds, IFD can introduce complexity when the derivation being imported needs to execute on a platform different from the one hosting the evaluation. This might impose a burden on the configuration requirements of the developer and build infrastructure.

Conclusion

IFD is an awesome mechanism that can make developer life easier but it is not a silver bullet: Parallel evaluation might become a thing in the future, but until then IFD can make builds really slow when many projects use it at the same time.

At Nixcademy, we help teams use IFD the right way. We can study your existing projects, requirements, and infrastructure and help your teams use IFD without making your builds slow. With a proper set of guides everyone in your organization knows when to use IFD and when not to.

Nixcademy on Twitter/X

Follow us on X

Nixcademy on LinkedIn

Follow us on LinkedIn

Get Online Nix(OS) Classes

Nixcademy online classes

Are you looking for...

  • a guided learning experience?
  • motivating group exercises?
  • a personal certificate?
Nixcademy Certificate

The Nixcademy Newsletter: Receive Monthly Nix(OS) Blog Digests

Nixcademy Newsletter

Receive a monthly digest of blog articles and podcasts with useful summaries to stay connected with the world of Nix and NixOS!

Stay updated on our latest classes, services, and exclusive discounts for newsletter subscribers!

Subscribe Now