Implementing a Continuous Delivery Pipeline for my Discord Bot with GitHub Actions, podman, and systemd
I’ve been having a lot of fun lately refining a weekend project I started a few months ago. I basically threw this bot over the wall back in early April. About a month ago, I started getting serious about learning the Go programming language, so I thought I’d just revisit my Discord bot with a more “learned” eye and find ways to polish it up a bit.
Popple is a Discord bot that I made for myself and my friends, and it has been my playground for practicing everything I was learning in a project with an extremely small blast radius. Actually, the blast radius is both small and sympathetic, since most of my friends in that server are software developers too; so it was easy to laugh about whatever bugs that had made it into the running version of the bot.
In any case, I’ve been pushing commits to Popple consistently each week (actually, much to my pleasant surprise, a few developers have contributed to closing some of my “good first issues” on the repo too!) This rate of change started to become cumbersome, especially since early on, I didn’t have any automation in place for this.
My deployments were all manual, and looked like this:
- Push changes to the branch I wanted them on
- SSH into my VPS
- Run
go install github.com/connorkuehl/popple@latest
- Get annoyed that
go-install
seems to be caching the@latest
, and re-run the command pasting in the latest SHA instead of thelatest
keyword…
Yuck!
Lucky for me, I was learning about Ansible, so I thought it’d be fun to write a playbook. The playbook would fetch the latest changes for the Git ref I passed in as a variable, it would build & install the binary to my PATH, and restart a systemd unit so that the new binary could “go live.”
This playbook was actually an enormous improvement to the manual steps; but there was still a lot to be desired here.
- I didn’t want to keep the Go toolchain or git installed on my server
- I didn’t want the server to have to build the code from source
Containers to the rescue
Long story short, I wrote a Dockerfile, I made an account on hub.docker.com, and I built and pushed images of the tip of my master
branch as well as the commits I had already tagged as releases.
My Dockerfile looks like this:
FROM golang:1.15-alpine AS build
RUN apk add build-base # for gcc
RUN mkdir /popple
ADD . /popple
WORKDIR /popple
RUN go build .
FROM alpine:latest
WORKDIR /root/
COPY --from=build /popple/popple .
# docker image run --rm -v path/to/db:/root/popple.sqlite \
# -v path/to/token:/root/bot.token \
# image_name
ENTRYPOINT ["/root/popple", "-db", "popple.sqlite", "-token", "bot.token"]
This Dockerfile takes advantage of what are known as “multi-stage builds.” The first image can balloon up with all of your build dependencies, but can hand off the final binary to a much slimmer image for actual use.
You can see my first image is called “build” and the second image has a COPY --from=build ...
statement which copies the binary over. Go’s static linking is what buys us this convenient one-line copy. The binary is all we need.
Then, on my VPS, I could simply: podman run --name popple_bot -d ... docker.io/conkue/popple
It was great! Still rather manual, but this removed the need to keep a Go toolchain and git installed on my VPS. Furthermore, we haven’t exactly removed me from the equation here. I still have to build the new image, push it to Docker Hub, SSH in, pull the latest image and bounce the container.
Actually, this all sounds more involved than what the process was like before… but not for long! (And yes, I probably could have just updated the Ansible playbook but I knew we could automate even that step and that’s where I was moving towards… can’t stop now!)
GitHub Actions wasn’t far behind
Lucky for me, automatically pushing a container image from GitHub Actions is already a solved problem.
I found a tutorial on Docker’s website for configuring a GitHub Action that will build and push a container image from the Dockerfile in your git repo.
My action looks exactly like this (at least, at the time of this writing):
name: Deploy to Docker Hub
on:
push:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Set up caching
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Check out source code
uses: actions/checkout@v2
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Set up Docker BuildX
id: buildx
uses: docker/setup-buildx-action@v1
- name: Ship it
id: docker_build
uses: docker/build-push-action@v2
with:
context: ./
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/popple:latest
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
Okay, so far, this is fantastic, but I’ve only removed the need for me to build and push the container images from my local machine, but that’s a step in the right direction.
podman-auto-update would like to join the party
Actually, before I even knew about podman-auto-update(1)
, I had generated a systemd unit for my Podman container, but I am going to reorder the events so that I look like a capable and highly observant blog author.
Turns out, podman
will check to see if there’s a new version of your container image for you. All you have to do is ask. The podman
version shipped in the Enterprise Linux distribution that runs on my VPS doesn’t seem new enough to support the registry
value for the io.containers.autoupdate
label; which appears to be favored by the documentation. I would have used that if it did. It does support the image
value, so:
$ podman run --label="io.containers.autoupdate=image" -d --name popple_bot \
...snipped \
docker.io/conkue/popple:latest
podman-auto-update(1)
is aware of the fact that many might choose to have systemd
start or stop their containers, and so it will behave nicely, especially once you see the next step where we generate a systemd
unit for this bot.
But first, let’s enable and start the systemd
timer that podman
ships, so that auto-updates are indeed automatic:
$ systemctl enable podman-auto-update
$ systemctl start podman-auto-update
The timer will automatically check for updates once per day at midnight, but this can be tweaked according to the podman-auto-update(1)
man page. I’ll probably leave it at the default setting to see how it feels, especially since I’ve pretty much added everything I want to add to the bot.
You can also just SSH in and run podman auto-update
if you want to trigger this early.
Giving a daemon the reigns
I like setting up systemd
units so that when my VPS is restarted everything comes back up nicely. podman
makes this easy too:
$ podman generate systemd --new --name popple_bot > /etc/systemd/system/popple.service
$ systemctl daemon-reload
$ systemctl enable popple
$ systemctl start popple
Conclusion
Let’s take a look at my to do list:
I don’t want to manually SSH in to the VPS to deploy*podman-auto-update
will automatically pull the latest bot container imageI don’t want to manually build and push container images* GitHub Actions will automatically push a new image of the latest commit tomaster
when I pushI don’t want my VPS to have git or the Go toolchain installed* Just needspodman
!I want my bot to automatically come back up when the server restarts*podman
generated asystemd
unit for me!
Sweet! All in a morning’s work. Now I can just kick back, push some commits, and watch the changes roll out automatically.