Ramblings of an aging IT geek
← Ramblings of an aging IT geek
golang

building go binaries on my laptop and shipping them to an arm box

A practical account of cross-compiling Go for a small ARM device, the GOARM and CGO traps that bite you, and why a static Go binary is one of the nicest things to deploy onto a constrained box.

A laptop terminal building a binary destined for a small single-board computer

I have a small ARM box at the edge of the network doing one job, and I wanted to run a little Go service on it. Compiling on the device itself was the obvious first idea and the wrong one. The box has the CPU of a determined calculator; a build that takes four seconds on my laptop took the better part of a tea break on the device, when it didn't fall over on memory. So: cross-compile on the laptop, copy the binary across, done. This is one of the genuinely brilliant things about Go, and it's worth writing down the bits that trip people up.

the happy path

For a pure-Go program with no C dependencies, cross-compiling is two environment variables and a build:

GOOS=linux GOARCH=arm GOARM=7 go build -o myservice .

That's it. No cross-toolchain to install, no sysroot to assemble, no afternoon lost to a C library that won't configure. GOOS and GOARCH tell the compiler what to target, the Go toolchain ships with everything it needs to emit code for that target, and out comes a binary. scp it over, chmod +x, run it. The first time you do this it feels like cheating.

The one that catches people is GOARM. ARM is not one thing. GOARM=7 targets ARMv7 with hardware floating point, which is what most modern small boards want. Older or cheaper chips may be ARMv6 (GOARM=6), and the original Raspberry Pi is the classic example that needs GOARM=6 and will simply refuse to run a GOARM=7 binary with an illegal-instruction crash that tells you nothing useful. If your binary builds cleanly and then dies instantly on the device, suspect GOARM before anything else. Check what you're actually targeting:

# on the device
cat /proc/cpuinfo | grep -i 'model\|features\|CPU architecture'

For 64-bit ARM boards the story is even simpler, GOARCH=arm64, and GOARM doesn't apply at all because there's no soft-float question to answer.

A successful cross-compile and an scp transfer to the target board

where it stops being magic: CGO

The magic holds right up until you import something that needs C. The moment CGO_ENABLED is on (and it's on by default), the Go toolchain needs a C cross-compiler for the target, and now you are back in the world of toolchains and sysroots that Go usually saves you from. Common culprits are the net package under certain build conditions, anything using os/user, and obviously any library wrapping a C dependency like SQLite.

My preference, wherever I can get away with it, is to turn CGO off entirely and get a fully static binary:

CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o myservice .

With CGO_ENABLED=0 the resulting binary is static, depends on no shared libraries on the target, and does not care what's installed on the box. You can drop it onto a minimal image, a busybox userland, a scratch container, and it just runs. For a service heading to a constrained device this is exactly what you want: no "works on my machine, missing libfoo on yours", no glibc version mismatch, just one file that is the whole program.

A static binary running on a bare minimal ARM userland with nothing else installed

The cost is that some of the standard library's behaviour changes subtly when CGO is off. DNS resolution, for instance, switches from the C resolver to Go's pure-Go resolver, which usually behaves the same but can differ on systems with unusual nsswitch.conf setups. For most edge boxes this is a non-issue, but it's the sort of thing worth knowing exists before it surprises you.

If you genuinely need a C dependency, the pragmatic route in 2019 is to build inside a container that has the cross-toolchain set up properly, rather than wrestling your laptop's environment into shape. Let the messy bit live in a Dockerfile you can throw away.

the bit I actually appreciate

What I keep coming back to is how undramatic the whole thing is. A static Go binary for ARM is one file, it has no runtime to install, no interpreter version to match, no dependency tree to reconcile against whatever ancient packages the device's distro shipped with. Deployment is scp and a systemd unit. Rollback is keeping the previous binary next to it and pointing the unit back. There is no virtualenv, no node_modules, no shared-library archaeology.

[Unit]
Description=my little edge service
After=network.target

[Service]
ExecStart=/opt/myservice/myservice
Restart=on-failure

[Install]
WantedBy=multi-user.target

For software that has to live on a small, awkward, hard-to-reach box and just keep working, this combination (cross-compile on a fast machine, ship a static binary, run it under systemd) is one of the most pleasant deployment stories I know. It removes an entire category of problems by simply not having them. I knew the theory of why Go did this; living with it on a device I can barely SSH into is what actually sold me.