Buildah, Podman and SystemD

2019/07/10

A short bit of container history

Containers have become a crucial tool in the lives of system administrators and software developers. Many people never use the word container without saying the word docker first. Because docker has effectively brought containers into the mainstream, it has made “docker container” a term that is actually very misunderstood. Containers actually existed long before docker.

While docker’s tooling filled a gap that made containers approachable to a wider audience, it’s design remains very questionable. It starts with the blueprint of how a container is built: the Dockerfile. Reading these files as a newcomer would make you think that either the writers have an unhealthy obsession with one liners or they’re simply trying to win a code golf competition. This may seem like a minor issue in comparison to the arguably biggest design flaw that is the “docker daemon” which imposes a single point of failure for every running container on a system.

Luckily, due to the open container initiative, it has become a lot easier for container tooling to work together with one another. Podman and Buildah are two of those tools that not only solve design issues mentioned earlier, but provide a ton of new functionality too.

Containerizing a sample slack status changer script

For this example, we’ll be using a simple script that changes your slack status based on your SSID and public IP. This can be used to indicate you’re working from home, the office or elsewhere. You can find the source code here: https://github.com/JeffreyVdb/slack-working-from-home

Let’s say that these are the requirements for this container:

  1. non root user within container
  2. wireless-tools package to use iwgetid command for reading the SSID
  3. python requests package, because urllib is a pain to use

To start building your container using buildah, you simply start by creating a bash script:

#!/bin/bash
set -euo pipefail

This on it’s own has so many advantages. Using the set options above, you’ll be sure that the script aborts when a command finished with a non zero exit code, undefined variables are defined or if any command in a pipe fails. Being able to use bash (or any other language) gives you the power to express more complex logic in a clean manner.

The next step is defining the python version, the container base image and final image name. It will be useful to provide the ability to override the python version for testing with other versions. We’ll also make the image name configurable which will give us the -t option in docker build -t <tag_name> We can use the : ${variable_name:=default_value} syntax to define these variables.

: ${PYTHON_VERSION:=3.7.4}
: ${CONTAINER_NAME:=slack-wifi-updater}
CONTAINER_IMAGE=docker://docker.io/library/python:${PYTHON_VERSION}-alpine

Because buildah follows the UNIX philosophy, you will have to take care of cleaning your container after committing it. Luckily you can use the trap functionality to call a cleanup procedure when your script aborted or finished running. To make sure our script always cleans up the container, we can do something like this:

function cleanup() {
    [[ -n "${container+x}" ]] && buildah rm $container
}

trap 'exit 1' INT HUP QUIT TERM ALRM USR1
trap cleanup 0

container=$(buildah from $CONTAINER_IMAGE)

The cleanup function will clean the buildah container when the container variable is set. To call it whenever the script either aborts or finishes, we can use trap with a value of 0. The other trap signal will ensure that the exit status is always set correctly when the script is aborted. The command buildah from <container_image> will run a container based on the provided container image and it will output the name of the running container.

Now we’re ready to create the container user and install all the required software and python dependencies. To run commands in the container we created in the previous step, we can use buildah run <container_name> -- <command>. The thing that will relieve everyone who has ever used Dockerfiles is that buildah run will not automatically commit the container. To commit the container we’ll have to call buildah commit manually which we’ll do at the end of this script. With this in mind, we can stop worrying about layers and we can stop chaining every action with && to put as much as we can into one layer

: ${USER_UID:=1000}
buildah run $container -- sh -c "addgroup -S slack -g $USER_UID && adduser -S slack -G slack -u $USER_UID"
buildah run $container -- apk --no-cache add wireless-tools
buildah run $container -- pip3 install requests

If you’re nostalgic, you can still chain multiple commands together by executing them within a shell as is done above. After this step, we need to copy the python script to our container. This can be done using the buildah copy command:

buildah copy $container ./check_wifi.py /usr/bin/check_wifi

This will copy a script from our local filesystem to the container. If this script would have been written in Go and compiled in a build container, we can use the buildah mount command to mount the build container filesystem to our local filesystem. That will make it possible to use buildah copy to transfer file between different containers. This gives you the same functionality that multi stage Dockerfiles provide.

The last step is to configure the container to use the slack user we created earlier and set the container command to the script we’ve copied. Lastly we call buildah commit to save our container using the provided or default image name.

buildah config --user=slack --cmd=/usr/bin/check_wifi $container
buildah commit $container $CONTAINER_NAME

That’s it! You can now run this script as a non root user to build the container. You can then run this container as a non root user using podman. You can use the following snippet to try this out:

CONTAINER_NAME=slack-wifi USER_UID=$(id -u) ./build_container.sh

SLACK_LEGACY_TOKEN="<your slack legacy token>"
podman run --rm --net=host \
    -v $HOME/.slack-status.json:/etc/slack.json:Z \
    -e SLACK_TOKEN=$SLACK_LEGACY_TOKEN \
    -e SLACK_STATUS_FILE=/etc/slack.json \
    localhost/slack-wifi

A major bonus point of podman is that it doesn’t need a daemon to communicate to. This means that all of your containers will not start crashing when this daemon is being updated and restarted. It also means that your container processes are not managed by this daemon and that they can be monitored by something like SystemD.

You could also utilize systemd to start the container periodically using systemd timers. For this, you define define a service file like the following one:

[Unit]
Description=Slack status changer
After=network.target

[Service]
Type=oneshot
EnvironmentFile=-/home/user/.slack-token
ExecStart=/usr/bin/podman run \
    -v /home/user/.slack-status.json:/etc/slack.json:Z \
    -e SLACK_TOKEN=$SLACK_LEGACY_TOKEN \
    -e SLACK_STATUS_FILE=/etc/slack.json \
    localhost/slack-wifi

To run this service every 15 minutes, you can use the following timer file:

[Unit]
Description=Change slack status every 15 minutes

[Timer]
OnCalendar=*:0/15
Persistent=true
RandomizedDelaySec=60

[Install]
WantedBy=timers.target

Having the ability to run podman containers from systemd services makes it much easier to run services in containers on your personal VPS without having to consider advanced container orchestration like Kubernetes. If you’re very concerned about security, podman is a no brainer. A very good explanation of this can be found on this page: https://opensource.com/article/18/10/podman-more-secure-way-run-containers

As a final note I can very much recommend you to install podman and putting the following in your shell’s rc file:

alias docker=podman

You’ll be surprised to see how good this works.