Multi-platform Go images with Nix
Yak shaving can lead one to produce solutions to problems that are perhaps pleasing to only oneself.
But pleasing it is.
For good reasons, Docker—and OCI—is seemingly forever pertinent in the world of software packaging and deployment. Containers provide largely reproducible (FROM ubuntu:latest
...!) and layer-cached builds for complex, multi-language full-stack applications to single-binary, statically-linked web servers.
This second type of application is most relevant to the day job and the weekend one-off.
At work—because said static-linked binaries is how we roll—we've almost entirely pivoted to using ko to build and publish our OCI images. ko can perform multi-platform builds via Go's native cross-compilation (GOOS
+ GOARCH
) and simple OCI packaged, all without Docker.
ko is excellent. It uses the go build
cache for dependency caching and is incredibly fast.
But I'm in the bad, bad habit of throwing a flake.nix
in the root of all my projects.
And conveniently enough, Nix's dockerTools
family of packages supports Docker-daemon-free image builds.
So here's a little formula I cooked up for ko-like multi-platform image builds, with a little example of how to orchestrate things via SourceHut Builds and crane.
flake.nix
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
...
}:
flake-utils.lib.eachSystem
[
"aarch64-linux"
"x86_64-linux"
"aarch64-darwin"
]
(
system:
let
pkgs = import nixpkgs { inherit system; };
name = "mapenv";
registry = "docker.io/raylas";
version = builtins.substring 0 8 self.lastModifiedDate;
image = "${registry}/${name}:${version}";
mkBin =
os: arch:
pkgs.buildGoModule rec {
pname = name;
inherit version;
src = ./.;
subPackages = [ "cmd/${pname}" ];
ldflags = [
"-s"
"-w"
];
vendorHash = "<vendor_hash>";
preBuild = ''
export GOOS=${os}
export GOARCH=${arch}
export CGO_ENABLED=0
'';
installPhase = ''
runHook preInstall
if [ -d $GOPATH/bin/${os}_${arch} ]; then
BINDIR=$GOPATH/bin/${os}_${arch}
else
BINDIR=$GOPATH/bin
fi
install -D $BINDIR/${pname} $out/bin/${pname}
runHook postInstall
'';
};
mkImage =
arch: bin:
pkgs.dockerTools.buildLayeredImage {
name = "${registry}/${name}";
tag = "${version}-${arch}";
architecture = arch;
compressor = "none";
contents = with pkgs; [
cacert
bin
];
config.Entrypoint = [ "/bin/mapenv" ];
};
in
{
packages = {
arm64 = mkImage "arm64" (mkBin "linux" "arm64");
amd64 = mkImage "amd64" (mkBin "linux" "amd64");
inherit image;
};
devShell = pkgs.mkShell {
buildInputs = with pkgs; [
go
gofumpt
golangci-lint
gopls
go-tools
crane
];
};
}
);
}
.build.yaml
image: nixos/unstable
packages:
- nixos.attic-client
- nixos.crane
secrets:
- [...]
environment:
NIX_CONFIG: "experimental-features = nix-command flakes"
tasks:
- login: |
mkdir -p ~/.docker
crane auth login docker.io --username raylas --password-stdin < ~/.docker-token
- publish: |
cd mapenv
IMAGE=$(nix eval .#image --raw)
crane push "$(nix build .#arm64 --print-out-paths)" "$IMAGE-arm64"
crane push "$(nix build .#amd64 --print-out-paths)" "$IMAGE-amd64"
crane index append \
-m "$IMAGE-arm64" \
-m "$IMAGE-amd64" \
-t "$IMAGE"
crane tag "$IMAGE" latest
crane is a game changer for OCI manipulation and registry operations. Feels immensely lightweight compared to skopeo.
Then, throw pair of Cachix or Attic tasks in that build definition and take advantage of Nix's incredibly thorough caching model.
Here's the project where I implemented this nonsense. Maybe I'll make a reusable Flake after sitting on this pattern for a bit.