Crosscompiling Go applications with Make

by on .
This article is over two years old and may be outdated.

When a new version of one of my Go apps is ready, I usually want to build and package the binary for multiple platforms. This can easily be done in a shell script, but Make brings a bonus: it can run multiple builds at the same time.

Makefile 101

In case you've worked with make before, skip this section! If not: let's see what a basic makefile looks like.

Make defines targets, these are things that need to be done. Usually a target represents a file that has to be created. Every target contains a list of commands that have to be executed, we call that the recipe. Make will only execute the recipe if a file with the same name as the target does not exist. If your target does not represent a file, it's a phony target.

mytarget:
	echo Hello world.

space: mytarget foo
	echo "I'm in space."

foo:
	echo I am run only once
	touch foo

.PHONY: mytarget space

The above makefile contains 3 targets: mytarget, foo and space. The second target tells us that it needs both other targets, so when you call make space, make will also execute mytarget and foo.

At the bottom of the file we define a special .PHONY target: Here we simply tell make which targets are phony targets. Those do not represent a file on disk. As we usually don't compile separate Go files, most of our targets will be phony targets.

Let's see what this does: save the file as Makefile and run make space in the same directory:

$ make space  
echo Hello world.
Hello world.
echo I am run only once
I am run only once
echo "I'm in space."
I'm in space.

Notice that make always tells us what command it is executing. You can hide a command by prefixing it with @. As we'll be executing multiple targets concurrently, it may be useful to see which commands are running.

Run make space again. You'll see that the foo target is not run anymore. That's because we created a file named foo and foo is not a phony target.

Variables

GNU Make has two types of variables. When we assign to a variable with := the contents is evaluated at the time the variable is declared. If we use just = the contents is evaluated every time we use the variable.

MYVAR := Hello there! I am currently in $(shell pwd)
PWD = $(shell pwd)

The pwd shell command will be executed every time we use variable $(PWD) in the makefile, while $(MYVAR) will always contain the same value.

Cross compiling

Make supports running multiple targets at the same time. This means that we need a separate target for every GOOS and GOARCH in order to take advantage of the concurrency.

A simple way to do this would be:

linux:
	GOOS=linux GOARCH=amd64 go build ...

windows:
	GOOS=windows GOARCH=amd64 go build ...

release: windows linux

.PHONY release windows linux

This builds both linux and windows binaries when we call make release. We can improve this Makefile by looping over the platforms and architectures we want to build:

PLATFORMS := linux/amd64 windows/amd64

release: $(PLATFORMS)

$(PLATFORMS):
	echo Hello, I am $@

.PHONY release $(PLATFORMS)

What we do here is create a target that contains all possible platforms. Every platform is a separate target, just like it was in the previous Makefile. That also means that we have to add every platform to the phony list. Luckily, we can do that using the same variable. Inside the recipe we can use the special $@ that contains the name of the current target.

The next part is a bit harder, we need to separate OS and ARCH. Remember that variables can be evaluated at runtime? We'll use that feature to create an $(os) and $(arch) var.

temp = $(subst /, ,$@)
os = $(word 1, $(temp))
arch = $(word 2, $(temp))

String manipulation functions in make are limited, so we have to be a bit creative here. We'll use these variables inside the $(PLATFORM) target. Remember that $@ contains the name of the current target? When we replace the slash in this string we can use the word function to get the first and last part.

Combined with our previous Makefile, this becomes:

PLATFORMS := linux/amd64 windows/amd64

temp = $(subst /, ,$@)
os = $(word 1, $(temp))
arch = $(word 2, $(temp))

release: $(PLATFORMS)

$(PLATFORMS):
	GOOS=$(os) GOARCH=$(arch) go build -o '$(os)-$(arch)' mypackage

.PHONY release $(PLATFORMS)

Note that variables can only be set outside the recipe. As the contents is only evaluated when we use them, it still contains the correct value.

Concurrency

We now have a Makefile that creates binaries for the platforms we listed in the $(PLATFORMS) variable. When we call make release it builds everything we asked, one by one. The main reason to use make is the easy concurrency: simply call make with the -j n argument, where n is the number of simultaneous jobs.