A Better Way to Build Reproducible Docker Images with Nix
Tags: docker, kubernetes, devops, cloud
Published: 2024-03-19 21:56:36

## The issue with `docker build`

- Redundant package downloads on every build
- Failed builds due to network flakiness
- Bloated images from duplicate files
- Inability to recreate historical images/environments

Docker has become the de-facto standard for packaging and deploying applications, solving many real-world environmental consistency and dependency management problems.

Even though most Dockerfiles build successfully 99.99% of the time, the remaining 0.01% can cause major issues, often rearing their head at the most inopportune times (like 4am!). This is because Docker builds have full access to the internet to download dependencies, making it impossible to recreate the exact state of repositories and packages at a future date.

Additionally, the naive way of adding packages results in wasted image space from duplicate files due to docker build layers. As cloud providers charge for image storage and data transfer, this inefficiency can quickly become costly at scale.

## The Solution: Using Nix to Build Docker Images

Nix is a powerful package manager that emphasizes reproducible builds and reliable deployments by specifying dependencies upfront. When using Nix to build Docker images, you get two key advantages:

1. Deterministic Builds Without Internet Access
With Nix, you declare all the dependencies needed in advance, so image builds don't require an internet connection. This ensures bit-for-bit reproducibility, letting you recreate images years down the line.

2. Layered Docker Images Avoiding Duplication  
Nix can create layered Docker images where each dependency is stored in its own layer. So when pushing updates, only the modified layers are transferred - eliminating package duplication and shrinking image size.

## An Example: Building a Go Service

Let's look at an example of building a Go service named "Douglas Adams Quotes" as a Docker image using Nix:

First, we define the Go package in a Nix flake:

```nix
bin = pkgs.buildGoModule {
  pname = "douglas-adams-quotes"; 
  inherit version;
  src = ./.;
  vendorHash = null;
};
```

This tells Nix to fetch the Go toolchain, any external dependencies, and build the code at the current directory.

To create the layered Docker image:

```nix 
docker = pkgs.dockerTools.buildLayeredImage {
  name = "registry.fly.io/douglas-adams-quotes";
  tag = "latest"; 
  contents = with pkgs; [ cacert ];
  config.Cmd = "${bin}/bin/douglas-adams-quotes";
};
```

Simply specify the image name/tag, any root contents like SSL certificates, and the command to run the built binary. Nix handles creating the minimal layers for dependencies like glibc.

Run `nix build .#docker` to build the image, then `docker load` it into your local Docker daemon - ready for deployment!

## The Power of Layers and Caching

One of Nix's biggest strengths is its leveraging layered Docker images and caching. If you have a monorepo with multiple services, their Docker images automatically share re-used layers without extra work.

Even better, Nix can avoid rebuilding unchanged dependencies across projects by relying on binary caches. Build results are uploaded to a cache, so future runs can re-use those components instead of rebuilding everything from scratch.

This cache also enables an unbelievable version of time travel. Need to recreate a Docker image from 14 months ago? Just `nix build` the repo hash you want - Nix will reassemble the image deterministically, pulling components from the cache wherever possible.

> Give Nix a try for your next Docker-based deployment.