I’d like to preface this article by saying that it is not an authoritative guide, rather it is just me documenting my experience figuring various things out, in the hope that it’ll be useful or interesting to someone else. I assume some knowledge of Nix and containerization throughout this article.

Starting off – setting up a Go Nix flake

Let’s start off by creating a Go Nix flake. I found this article which was helpful.

(If you’re wondering what a Nix flake is, or why I want to use one rather than just using a regular old Nix derivation, this article, written by the original creator of Nix, has a good summary).

So, back to the point… Let’s run the commands recommended in the article linked above to set the flake up, using the gomod2nix template:

$ nix flake init -t github:nix-community/gomod2nix#app
$ git init
$ git add .

Okay, all set up. Let’s try and run the flake:

$ nix run
warning: Git tree '/home/jpw/go-container' is dirty
warning: creating lock file '/home/jpw/go-container/flake.lock'
warning: Git tree '/home/jpw/go-container' is dirty
error: unable to execute '/nix/store/0y6yhlqpa6qczqy6cy0kakqqcswm0pzc-myapp-0.1/bin/myapp': No such file or directory

Hm, it’s not working – nix run is trying to execute a binary that doesn’t in the derivation. Let’s build the derivation to see what’s going on under the hood:

$ nix build
$ tree result
result
└── bin
    └── gomod2nix-template

So the problem is that the binary is called gomod2nix-template, but nix run is trying to execute myapp.

The binary is called gomod2nix-template because the go.mod in the template declares the module as “example.com/gomod2nix-template”.

But why is nix run trying to execute the myapp binary? To figure that out, we need to take a look at flake.nix:

{
  description = "A basic gomod2nix flake";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  inputs.flake-utils.url = "github:numtide/flake-utils";
  inputs.gomod2nix.url = "github:nix-community/gomod2nix";

  outputs = { self, nixpkgs, flake-utils, gomod2nix }:
    (flake-utils.lib.eachDefaultSystem
      (system:
        let
          pkgs = import nixpkgs {
            inherit system;
            overlays = [ gomod2nix.overlays.default ];
          };

        in
        {
          packages.default = pkgs.callPackage ./. { };
          devShells.default = import ./shell.nix { inherit pkgs; };
        })
    );
}

A bit of knowledge about how nix run works under the hood is needed to understand what’s going on here. Per the Nix wiki:

When output apps..myapp is not defined, nix run myapp runs .myapp>/bin/

In our case, we’re running nix run, which is equivalent to nix run default, and we aren’t defining apps.default in our flake, so nix run is running ${packages.default}/bin/${packages.default.name}.

So, where does packages.default come from? It’s defined as pkgs.callPackage ./. {}, which comes from default.nix:

{ pkgs ? (
    let
      inherit (builtins) fetchTree fromJSON readFile;
      inherit ((fromJSON (readFile ./flake.lock)).nodes) nixpkgs gomod2nix;
    in
    import (fetchTree nixpkgs.locked) {
      overlays = [
        (import "${fetchTree gomod2nix.locked}/overlay.nix")
      ];
    }
  )
}:

pkgs.buildGoApplication {
  pname = "myapp";
  version = "0.1";
  pwd = ./.;
  src = ./.;
  modules = ./gomod2nix.toml;
}

Okay, so that’s where myapp is coming from. Let’s rename the app to example, and also rename the go module accordingly.

$ nix run
warning: Git tree '/home/jpw/go-container' is dirty
Hello flake

Yay.

Containerising the flake

I read this article to get some information about building Docker containers using Nix. That article is about Rust, but we can steal learn from the containerisation bits.

So the way they’re doing it is to stick another output into the flake called packages.container, which can be built using nix build #container. The relevant snippet is:

          packages.container = pkgs.dockerTools.buildImage {
            inherit name;
            tag = packages.${name}.version;
            created = "now";
            contents = packages.${name};
            config.Cmd = [ "${packages.${name}}/bin/flynix" ];
          };

We can stick similar into our flake, adapting it slightly to the following:

          packages.container = pkgs.dockerTools.buildImage {
            name = "example";
            tag = "0.1";
            created = "now";
            contents = packages.default;
            config.Cmd = [ "${packages.default}/bin/example" ];
          };

Our flake.nix now looks like:

{
  description = "A basic gomod2nix flake";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  inputs.flake-utils.url = "github:numtide/flake-utils";
  inputs.gomod2nix.url = "github:nix-community/gomod2nix";

  outputs = { self, nixpkgs, flake-utils, gomod2nix }:
    (flake-utils.lib.eachDefaultSystem
      (system:
        let
          pkgs = import nixpkgs {
            inherit system;
            overlays = [ gomod2nix.overlays.default ];
          };

        in
        rec {
          packages.default = pkgs.callPackage ./. { };
          packages.container = pkgs.dockerTools.buildImage {
            name = "example";
            tag = "0.1";
            created = "now";
            contents = packages.default;
            config.Cmd = [ "${packages.default}/bin/example" ];
          };
          devShells.default = import ./shell.nix { inherit pkgs; };
        })
    );
}

(note we’ve had to add a rec before the definition of the flake, so that we can refer to packages within the buildImage call).

Building and running the container

$ nix build .#container
warning: Git tree '/home/jpw/go-container' is dirty
trace: warning: in docker image example: The contents parameter is deprecated. Change to copyToRoot if the contents are designed to be copied to the root filesystem, such as when you use `buildEnv` or similar between contents and your packages. Use copyToRoot = buildEnv { ... }; or similar if you intend to add packages to /bin.

Let’s ignore that deprecation warning for now…

To run the generated image in Docker:

$ docker load < result && docker run --rm example:0.1
e33348d6a90c: Loading layer  2.662MB/2.662MB
Loaded image: example:0.1
Hello flake

It works!

Wait, why is it twice as large as scratch?

I was curious about how large the generated image would be.

$ docker image ls
REPOSITORY                  TAG       IMAGE ID       CREATED              SIZE
example                     0.1       4ad28c8532ac   About a minute ago   2.66MB

Cool, that’s pretty small!

Just to double-check, I decided to compare it against an image created using a Dockerfile based on scratch:

FROM scratch
COPY ./example /bin/example
CMD ["/bin/example"]
$  docker build . -t example-scratch:0.1
[ ... ]
$ docker image ls
REPOSITORY                  TAG       IMAGE ID       CREATED          SIZE
example                     0.1       4ad28c8532ac   2 minutes ago    2.66MB
example-scratch             0.1       75fbb3a82aa9   1 minute ago     1.33MB

Hm! The example-scratch image is precisely half as large!

Let’s have a look at what the example image actually contains:

$ tar -xvf ../result
./
4ad28c8532ac243b839c40a2bfd597aecb41eef3ac28f2612f787f31cc9b8dce.json
c896502ad29a9f8ec27c42deb1f25d53ade0813bd7497ce9b2d580c2f0c5e9c6/
c896502ad29a9f8ec27c42deb1f25d53ade0813bd7497ce9b2d580c2f0c5e9c6/VERSION
c896502ad29a9f8ec27c42deb1f25d53ade0813bd7497ce9b2d580c2f0c5e9c6/json
c896502ad29a9f8ec27c42deb1f25d53ade0813bd7497ce9b2d580c2f0c5e9c6/layer.tar
manifest.json
repositories

$ cd c896502ad29a9f8ec27c42deb1f25d53ade0813bd7497ce9b2d580c2f0c5e9c6
$ tar -xvf layer.tar
./
./bin/
./bin/example
./nix/
./nix/store/
nix/store/bnga9npn83frx32fqrijdbdqrdrr8mdh-example-0.1/
nix/store/bnga9npn83frx32fqrijdbdqrdrr8mdh-example-0.1/bin/
nix/store/bnga9npn83frx32fqrijdbdqrdrr8mdh-example-0.1/bin/example

Hm! The example binary exists in the image twice. In a normal Nix environment, /bin/example would be a symlink to /nix/store/bnga9npn83frx32fqrijdbdqrdrr8mdh-example-0.1/bin/example, but not here. I’m not sure why this is, exactly.

In order to fix this, we need to control which files get included in the built image. Conveniently, the solution to that also solves the deprecation warning we see when we build the container:

$ nix build .#container
warning: Git tree '/home/jpw/go-container' is dirty
trace: warning: in docker image example: The contents parameter is deprecated. Change to copyToRoot if the contents are designed to be copied to the root filesystem, such as when you use `buildEnv` or similar between contents and your packages. Use copyToRoot = buildEnv { ... }; or similar if you intend to add packages to /bin.

It recommends using copyToRoot, rather than contents. This article has notes on how to use copyToRoot. Notably, it has a pathsToLink which lets us do exactly what we want to do - we can make Nix only link files under /bin/, which will avoid the duplicate files in the image.

So, after switching to that approach, our flake.nix looks like:

{
  description = "A basic gomod2nix flake";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  inputs.flake-utils.url = "github:numtide/flake-utils";
  inputs.gomod2nix.url = "github:nix-community/gomod2nix";

  outputs = { self, nixpkgs, flake-utils, gomod2nix }:
    (flake-utils.lib.eachDefaultSystem
      (system:
        let
          pkgs = import nixpkgs {
            inherit system;
            overlays = [ gomod2nix.overlays.default ];
          };

        in
        rec {
          packages.default = pkgs.callPackage ./. { };
          packages.container = pkgs.dockerTools.buildImage {
            name = "example";
            tag = "0.1";
            created = "now";
            copyToRoot = pkgs.buildEnv {
              name = "image-root";
              paths = [ packages.default ];
              pathsToLink = [ "/bin" ];
            };
            config.Cmd = [ "${packages.default}/bin/example" ];
          };
          devShells.default = import ./shell.nix { inherit pkgs; };
        })
    );
}

We use pathsToLink to tell Nix to only include files under /bin in the environment that gets copied in to the image.

Let’s build it and see what the image size is:

$ docker load < result
$ docker image ls
REPOSITORY                  TAG       IMAGE ID       CREATED          SIZE
example                     0.1       93d153753fd9   36 seconds ago   1.33MB

It worked!

Spring cleaning

I’m not sure I really like that flake.nix contains application-specific code, like the path to the binary. For simplicity, let’s split out the container building into a separate file.

We can do this like so - here’s container.nix:

{ pkgs, package }:

pkgs.dockerTools.buildImage {
  name = "example";
  tag = "0.1";
  created = "now";
  copyToRoot = pkgs.buildEnv {
    name = "image-root";
    paths = [ package ];
    pathsToLink = [ "/bin" ];
  };
  config.Cmd = [ "${package}/bin/example" ];
}

Here’s flake.nix:

{
  description = "A basic gomod2nix flake";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  inputs.flake-utils.url = "github:numtide/flake-utils";
  inputs.gomod2nix.url = "github:nix-community/gomod2nix";

  outputs = { self, nixpkgs, flake-utils, gomod2nix }:
    (flake-utils.lib.eachDefaultSystem
      (system:
        let
          pkgs = import nixpkgs {
            inherit system;
            overlays = [ gomod2nix.overlays.default ];
          };

        in
        rec {
          packages.default = pkgs.callPackage ./. { };
          packages.container = pkgs.callPackage ./container.nix { package = packages.default; };
          devShells.default = import ./shell.nix { inherit pkgs; };
        })
    );
}

So, we’ve split the container building code into a separate Nix expression, in a separate file, that accepts the package from the flake through its packages argument.

Let’s check it works:

$ nix build .#container
$ docker load < result && docker run --rm example:0.1
ace5c9ecfd0b: Loading layer  1.341MB/1.341MB
The image example:0.1 already exists, renaming the old one with ID sha256:93d153753fd935d932be43c2673a5f1a163809f2bcb8ae0867d70426f9ddcf93 to empty string
Loaded image: example:0.1
Hello flake

Good to see it still works!

That concludes my evening’s exploration of building a Go container image with Nix.

Read More