Releasing a static Go binary on CentOS

3 minute read Published:

Baby's first steps for releasing Go software on CentOS with an RPM

A recap of some things I learned recently while packaging a Go binary for deployment on CentOS hosts.

RPM spec file

Until now I had never released any of my software as an RPM. I just dropped minimal instructions into README.md and called it a day:

$ sudo yum install <list of dependencies>
$ wget <link_to_my_project_binary_release>
$ mv binary /usr/bin/
$ chmod +x binary

Even worse is software where I also had systemd unit files:

$ cp file.service /etc/systemd/system/
$ sudo systemctl daemon-reload
$ sudo systemctl enable my.service
$ sudo systmectl start my.service

Unfortunately, regardless of how well-intentioned my users were (including public users for my open source projects, and coworkers for my company/private projects), it’s not the best UX to require a user to execute a handful of shell commands.

I pieced together an RPM spec file for my project goat, and installation (on RPM and systemd-enabled distros) became a breeze.

The first part is simple, some naming and descriptions:

%define pkgname goat

Name: %{pkgname}
Version: %{_version}
Release: 1%{?dist}
Summary: Attach and mount EBS volumes

License: BSD 3-clause
URL: https://github.com/sevagh/goat

As we see here, pkgname is defined as goat and used throughout the rest of the spec file, but we never %define version - this is because I pass the version from the outside (so I could have one source of version truth): @rpmbuild [...] --define "_version $(VERSION)".

Then we declare the source files:

Source0: %{pkgname}
Source1: %{pkgname}.service

This says that the files my RPM will include in it are goat (the Go binary, built with go build), and goat.service, the systemd unit file.

Here we define the requirements:

Requires: systemd mdadm

Some unimportant steps (in goat’s case at least):

%description
Automatically attach and mount EBS volumes to a running EC2 instance.


#%prep
#%setup
#%build

Now for the meat of the install phase:

%install
%{__mkdir} -p %{buildroot}/%{_bindir}
%{__mkdir} -p %{buildroot}/%{_unitdir}
%{__install} -m0775 %{SOURCE0} %{buildroot}/%{_bindir}/%{pkgname}
%{__install} -m0777 %{SOURCE1} %{buildroot}/%{_unitdir}/%{pkgname}.service


%files
%{_bindir}/%{pkgname}
%{_unitdir}/%{pkgname}.service

These are all spec file macros that I learned from the excellent documentation. Read the lines and familiarize yourself with them. It’s basically saying to install the binary and systemd file into their respective locations (/usr/bin/ and /usr/lib/systemd/).

Finally, some pre/post shell script sections to run some systemd commands.

Post install; systemctl daemon-reload if it’s the first time goat is being installed:

%post
if [ $1 -eq 1 ]; then
        /bin/systemctl daemon-reload >/dev/null 2>&1 || :
fi
#/bin/systemctl enable goat.service >/dev/null 2>&1 || :

You can choose to enable your service (and even start it) here but I chose to allow my users to have more control over what goat does, and have my RPM just install it.

The pre-uninstall phase; disable and stop goat:

%preun
if [ $1 -eq 0 ] ; then
        # Package removal, not upgrade
        /bin/systemctl disable goat.service >/dev/null 2>&1 || :
        /bin/systemctl stop goat.service >/dev/null 2>&1 || :
fi

The post-uninstall phase; another daemon-reload:

%postun
/bin/systemctl daemon-reload >/dev/null 2>&1 || :

rpmlint and rpmbuild

This is the Make rule I wrote for goat to generate RPMs:

@rpmlint specfile.spec
@rpmbuild -ba specfile.spec --define "_sourcedir $$PWD" --define "_version $(VERSION)"

rpmlint does what the name suggests: lints your specfile. Useful.

The rpmbuild command has two defines: one mentioned above, where the _version is sourced from the same place in the Makefile ($(VERSION)) so I don’t have to run around and change a string in 5 different places when making a release.

The other define is _sourcedir $$PWD, which localizes the build phase to $PWD. This way I can run the rpmbuild command from the goat repo.

Go version from Makefile

Tangentially, how I pass the same $(VERSION) to the Go code itself is with:

@go build -ldflags "-X main.VERSION=$(VERSION)" .

In the Go code this is how it’s used:

# in main.go
var VERSION string

func main