How often do you find yourself in a situation where something builds and works on your machine, but doesn’t build on CI or fails catastrophically in production?
In many cases, those problems are a symptom of something most developers face: lack of reproducibility. Your code depends on an environment variable at compile time, or significantly changes behavior with different versions of a dependency.
Such problems are usually hard to diagnose and even harder to fix. However, there is a tool that can help you solve those issues – Nix.
Nix consists of a package manager and a language to describe packages for the said manager. It features reproducible builds, cross-distro and -platform compatibility, binary caching, as well as a large collection of packages maintained by thousands of contributors.
Nix: how it works
Nix consists of two parts: a package manager and a language. Nix programming language is a rather simple lazy (almost) pure functional with dynamic typing that specializes in building packages. The package manager, on the other hand, is interesting and pretty unique. It all starts with one idea.
FHS is not suitable for reproducible builds
Nix stems from the idea that FHS is fundamentally incompatible with reproducibility. Let me explain.
Every time you see a path like /bin/python
or /lib/libudev.so
, there are a lot of things that you don’t know about the file that’s located there.
- What’s the version of the package it came from?
- What are the libraries it uses?
- What configure flags were enabled during the build?
Answers to these questions can (and most likely will) change the behaviour of an application that uses those files. There are ways to get around this in FHS – for example, link directly to /lib/libudev.so.1.6.3
or use /bin/python3.7
in your shebang. However, there are still a lot of unknowns.
This means that if we want to get any reproducibility and consistency, FHS does not work since there is no way to infer a lot of properties of a given file.
One solution is tools like Docker, Snap, and Flatpak that create isolated FHS environments containing fixed versions of all the dependencies of a given application, and distribute those environments. However, this solution has a host of problems.
What if we want to apply different configure flags to our application or change one of the dependencies? There is no guarantee that you would be able to get the build artifact from build instructions, since putting all the build artifacts in an isolated container guarantees consistency, not reproducibility, because during build-time, tools from host’s FHS are often used, and besides the dependencies that come from other isolated environments might change.
For example, two people using the same docker image will always get the same results, but two people building the same Dockerfile can (and often do) end up with two different images.
This makes one wonder: why not isolate the build itself, similarly to build artifacts?
What does Nix actually do (as a package manager)?
For every package that Nix builds, it first computes its derivation (this is usually done by evaluating expressions written in Nix language), a file that contains:
- mentions of all the files and other packages that will be required during the build
- build instructions for actually building the package,
- some metainformation about the package,
- most crucially, a store path (prefix) under which the package will be installed, which is of the form
/nix/store/<hash>-<name>-<version>
(hence the name store path), wherehash
is a hash of all the other data in the derivation.
For example, here’s a really simple derivation for GNU hello
in JSON format with some (empty) fields omitted for brevity:
{
"/nix/store/sg3sw1zdddfkl3hk639asml56xsxw8pf-hello-2.10.drv": {
"outputs": {
"out": {
"path": "/nix/store/dvv4irwgdm8lpbhdkqghvmjmjknrikh4-hello-2.10"
}
},
"inputSrcs": [
"/nix/store/9krlzvny65gdc8s7kpb6lkx8cd02c25b-default-builder.sh"
],
"inputDrvs": {
"/nix/store/8pq31sp946581sbh2m18pb8iwp0bwxj6-stdenv-linux.drv": [
"out"
],
"/nix/store/cni8m2cjshnc8fbanwrxagan6f8lxjf6-hello-2.10.tar.gz.drv": [
"out"
],
"/nix/store/md39vwk6mmi64f6z6z9cnnjksvv6xkf3-bash-4.4-p23.drv": [
"out"
]
},
"platform": "x86_64-linux",
"builder": "/nix/store/kgp3vq8l9yb8mzghbw83kyr3f26yqvsz-bash-4.4-p23/bin/bash",
"args": [
"-e",
"/nix/store/9krlzvny65gdc8s7kpb6lkx8cd02c25b-default-builder.sh"
],
"env": {
"buildInputs": "",
"builder": "/nix/store/kgp3vq8l9yb8mzghbw83kyr3f26yqvsz-bash-4.4-p23/bin/bash",
"doCheck": "1",
"name": "hello-2.10",
"nativeBuildInputs": "",
"out": "/nix/store/dvv4irwgdm8lpbhdkqghvmjmjknrikh4-hello-2.10",
"outputs": "out",
"pname": "hello",
"src": "/nix/store/3x7dwzq014bblazs7kq20p9hyzz0qh8g-hello-2.10.tar.gz",
"stdenv": "/nix/store/hn7xq448b49d40zq0xs6lq538qvldls1-stdenv-linux",
"system": "x86_64-linux",
"version": "2.10"
}
}
}
Then, it realises that derivation by running build instructions specified in it inside an isolated environment containing the dependencies and only the dependencies of the package.
This way, Nix can guarantee some really important properties:
- Realising the same derivation will always get almost the same output since the build commands only have access to the explicitly specified dependencies. (The output can differ if the build instruction uses some hardware-dependent information, such as current time,
/dev/urandom
, or CPU performance) - The same store path (
/nix/store/<hash>-<name>-<version>
) will always contain the result of exactly the same commands, since<hash>
depends on all the variables that are at play while building said package.
But what do these guarantees give us?
Benefits of Nix
Reproducibility
This is the most obvious benefit, as it is the whole motivation behind Nix. Two people building the same package will always get the same output (see the note above for situations where the output can differ slightly) if you’re careful enough with pinning the versions of inputs in place. And even if some input is different, it will be very clear since the store path will change.
Binary caching
Another benefit we get from Nix is binary caching. Since the store path is known before building it and we are sure that the same store path will contain the same output, we can substitute (in other words, fetch) that store path from some other location as long as that location has that path and we trust the person building it (the outputs can be signed after building so that one can store them in an untrusted place).
Multiple versions of any package can be installed simultaneously
Every package is installed under its own prefix, so there are no collisions (unless a given package depends on multiple versions of another package, which is a rare occasion and is usually easy enough to handle). This can be really handy during development – think Python’s virtualenvs but for any language!
Distributed building
Since we know exactly what’s needed to realise a given derivation, it can be easily done remotely as long as the remote server has all the dependencies already (or can build them faster than the local machine).
Non-privileged builds
The build is isolated and it can’t alter anything on the host system other than its output. This means that it’s safe to allow non-privileged users to realise derivations.
Less state = smaller backups
Since the application and its dependencies can now be easily rebuilt from just the derivations, we can safely omit them from backups.
Ecosystem
These benefits are cool, however, the concepts of Nix can be extended further. So far, we only described how to perform reproducible builds and have said nothing about another property that is a very important prerequisite for the healthy sleep of all the DevOps team – runtime consistency. How can we be sure that the application will have exactly the same configuration files and Linux kernel version every time we deploy it on the server?
NixOS
NixOS is a GNU/Linux distribution that uses Nix as both a package manager and a configuration manager. Every single configuration file is described as a derivation, and your system is a derivation that depends on all the applications and config files you’ve specified. Hence, we get all the benefits of Nix applied to our runtime environment. If two people install a NixOS system that has the same store path, they will always get exactly the same system on their computers!
Additionally, because nothing is ever installed the way it usually is on other distros, all the updates and rollbacks are atomic. You have specified an incorrect configuration file for your init system and your PC doesn’t boot anymore? Worry not, every single derivation that was installed on your PC (and wasn’t garbage collected) is listed in the boot loader’s listing, and you can boot directly into it.
Another benefit of NixOS is that it’s really easy to spin up a new server since the only thing you need for that is the original Nix expressions you’ve used to build your old server.
Nixpkgs
nixpkgs
a giant collection of package descriptions in the Nix language that can be easily altered and combined together. It contains a lot of language-specific package sets, including a complete and up-to-date mirror of Hackage. It also features powerful cross-compilation tools. For example, cross-compiling bash to Windows is as simple as nix build nixpkgs.pkgsCross.mingwW64.bash
.
nixpkgs
makes Nix a very powerful tool to build your application, especially if you need to target multiple platforms and use many languages.
Deep integration with many existing tools and services
nixpkgs
and NixOS also contain a lot of integrations with existing tools.
Need to build a Docker image with your application? Easy:
dockerTools.buildImage {
name = "helloworld";
contents = [ hello ];
config.Entrypoint = "hello";
}
Need to quickly spin up nginx
without writing a ton of configs? Literally two lines:
services.nginx.enable = true;
services.nginx.virtualHosts."example.com".webRoot = "/var/lib/example.com";
Want to declaratively configure vim
? That’s the spirit:
vim_configurable.customize {
name = "vim-with-plugins";
# add custom .vimrc lines like this:
vimrcConfig.customRC = ''
set hidden
set colorcolumn=80
'';
vimrcConfig.vam.pluginDictionaries = [
{ name = "youcompleteme"; }
{ name = "phpCompletion"; ft_regex = "^php\$"; }
{ name = "phpCompletion"; filename_regex = "^.php\$"; }
{ name = "phpCompletion"; tag = "lazy"; }
];
}
Path forward
If you want to try Nix out, there are multiple ways to approach it. You can download NixOS and play with it on a virtual machine; alternatively, you can download Nix and use it in the OS of your choice. To do so, visit https://nixos.org/download.html.
There are many materials for learning, including the famous Nix Pills, Nix Cookbook, and various manuals.