DEV Community

lykins
lykins

Posted on • Updated on

Containerizing GitHub Runner

So containerizing a custom runner image actually came out a bit simpler than I had expected, this is mostly since others did the heavy lifting for me :).

Credit

All credit goes to the following posts:

How to containerize a GitHub Actions self-hosted runner

Deploying Self-Hosted GitHub Actions Runners with Docker

Changes

So I did make a few changes when I was building my runners.

  • Built with Packer

This gave me an opportunity to play with the Packer Docker Plugin.

  • Customized

Since I will be using Ansible Playbooks during my Packer builds, I added it into the image versus setting it up every time the pipeline runs.

In addition to Ansible Playbooks, I add/update the following:

Runners are more customizable and are ephemeral.

  • Labels: allow custom labels to be added when registering the runner.
  • Ephemeral: runner will exit after it fails or completes a run.
    • Nomad will bring a runner back up to replace.
    • Runner would deregister itself from GitHub.

Configuration

./config.sh --url https://github.com/${REPOSITORY} \
    --name ${RUNNER_NAME} \
    --labels ${RUNNER_LABELS} \
    --token ${REG_TOKEN} \
    --unattended \
    --ephemeral
Enter fullscreen mode Exit fullscreen mode

Cleanup

cleanup() {
    echo "Removing runner..."
    ./config.sh remove \
    --unattended \
    --token ${REG_TOKEN}
}

trap 'cleanup' EXIT
Enter fullscreen mode Exit fullscreen mode

Building the Runner

If you are not familiar with Dockerfiles, then you should be fine. I found this to be straightforward whether you have experience or if you are a beginner. I recommend checking out the Packer Docker plugin documentation.

All the code and configuration can be found here:

Runner Image

Really the only major thing you'll need outside your GitHub account is a registry to drop it in. I used GitHub as well in this case. You can also build locally.

Step 1

Add your plugins. I only used Docker, so that's it there.

packer {
  required_plugins {
    docker = {
      source  = "github.com/hashicorp/docker"
      version = "~> 1"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2

Like other Packer builds, configure your source block.
Keeping it like GitHub Actions and sticking with Ubuntu. Most other distributions I found to work.

I also specify the USER and script to run when it starts. The script will register the runner once the container is up.

source "docker" "actions-runner-builder" {
  image  = "ubuntu:latest"
  commit = true
  changes = [
    "USER runner",
    "ENTRYPOINT [\"/start.sh\"]",
    "LABEL runner_version=${var.runner_version}",
  ]
}
Enter fullscreen mode Exit fullscreen mode

Step 3

This all goes pretty quickly. The next step is the build block.

First provisioner is to drop the start.sh file onto the image.

  provisioner "file" {
    source      = "packer/builds/github-actions-images/builder/scripts/start.sh"
    destination = "/start.sh"
  }
Enter fullscreen mode Exit fullscreen mode

The next provisioner will configure the runner.

  provisioner "shell" {
    environment_vars = [
      "RUNNER_VERSION=${var.runner_version}",
      "DEBIAN_FRONTEND=noninteractive",
    ]
    script = "packer/builds/github-actions-images/builder/scripts/install.sh"
  }
Enter fullscreen mode Exit fullscreen mode

For my custom image, I wanted to point out three packages.

  • xorriso - for creating iso images (vSphere intended).
  • unzip - used when setting up Packer in the workflows.
  • git - used in packer builds to tag images and metadata.
  • ansible - most of the builds I ran I used ansible playbooks.

The script itself...

#!/bin/bash -e

# This script sets up the environment for installing GitHub Actions runner.
#
# Environment Variables:
#   DEBIAN_FRONTEND - Set to 'noninteractive' to avoid interactive prompts during package installation.
#   RUNNER_ARCH - Determines the architecture of the system using dpkg.
#   RUNNER_VERSION - Specifies the version of the GitHub Actions runner to install. Defaults to 2.319.1 if not set.
export DEBIAN_FRONTEND=noninteractive
RUNNER_ARCH=$(dpkg --print-architecture)
RUNNER_VERSION=${RUNNER_VERSION:-2.319.1}

# This script checks if the environment variable RUNNER_ARCH is set to "amd64".
# If it is, the script changes the value of RUNNER_ARCH to "x64".
# Finally, it prints the value of RUNNER_ARCH to the console.
if [ "$RUNNER_ARCH" = "amd64" ]; then
  RUNNER_ARCH="x64"
fi
echo "RUNNER_ARCH: $RUNNER_ARCH"

# This script determines the operating system of the runner by using the `uname -s` command,
# converts the output to lowercase, and stores it in the RUNNER_OS variable.
# It then prints the determined operating system.
RUNNER_OS=$(uname -s | tr '[:upper:]' '[:lower:]')
echo "RUNNER_OS: $RUNNER_OS"

# This script creates a new user named 'runner' with a home directory.
useradd -m runner

# This script updates the package lists, upgrades installed packages,
# and installs a set of essential development tools and libraries.
apt-get update -y && apt-get upgrade -y &&
  apt-get install -y --no-install-recommends \
    curl \
    jq \
    build-essential \
    libssl-dev \
    libffi-dev \
    python3 \
    python3-venv \
    python3-dev \
    python3-pip \
    unzip \
    libicu-dev \
    nodejs \
    xorriso \
    git

# This installs Ansible.
apt-get update -y
apt-get install software-properties-common -y
add-apt-repository --yes --update ppa:ansible/ansible
apt-get install ansible -y

# This script installs the GitHub Actions runner.
# It performs the following steps:
# 1. Changes the directory to /home/runner.
# 2. Creates a new directory named actions-runner.
# 3. Changes the directory to actions-runner.
# 4. Downloads the specified version of the GitHub Actions runner tarball from GitHub.
# 5. Extracts the contents of the downloaded tarball.
# 6. Removes the downloaded tarball to clean up.
#
# Environment variables required:
# - RUNNER_VERSION: The version of the GitHub Actions runner to install.
# - RUNNER_OS: The operating system of the runner (e.g., linux, osx).
# - RUNNER_ARCH: The architecture of the runner (e.g., x64, arm64).
cd /home/runner && mkdir actions-runner && cd actions-runner &&
  curl -O -L https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-${RUNNER_OS}-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz &&
  tar xzf ./actions-runner-${RUNNER_OS}-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz &&
  rm -rf actions-runner-${RUNNER_OS}-${RUNNER_ARCH}-${RUNNER_VERSION}.tar.gz

# This script changes the ownership of the home directory of the 'runner' user,
# installs dependencies required for GitHub Actions runner using the provided script,
# and then cleans up the APT cache to free up space.
chown -R runner ~runner &&
  /home/runner/actions-runner/bin/installdependencies.sh &&
  rm -rf /var/lib/apt/lists/*

# This script changes the current directory to the root directory
# and then grants execute permissions to the start.sh script located in the root directory.
cd / &&
  chmod +x ./start.sh
Enter fullscreen mode Exit fullscreen mode

Last step in the build is the tag and push the image.

  post-processors {
    post-processor "docker-tag" {
      repository = "${var.registry_server}/${var.registry_username}/actions-runner-builder"
      tags       = concat(["latest"], var.additional_tags)
    }
    post-processor "docker-push" {
      login          = true
      login_username = var.registry_username
      login_password = var.registry_token
      login_server   = var.registry_server
    }
  }
Enter fullscreen mode Exit fullscreen mode

That is it :).

runner-image

Running the Runner

So a couple of variables need passed when running the container. Only two are required - REPO and TOKEN.

REPO will specify the repository to add the runner to.
TOKEN is your GitHub Personal Access Token.

Additionally, NAME and LABELS can be added to better identify your runner or use in your Actions Workflows later on.

Running the Runner

Once you run it you should see it register and connect...

Registered

If you check in your repository you will now see the registered runner.

gha

Stopping the Runner

Since the runners are ephemeral, once a workflow run is done in GitHub Actions, it would automatically exit and the process would deregister it.

If running on Nomad or with Docker Compose, a new runner will spin up. In my case, I will stop it manually and since it is not orchestrated a new one should not spin up.

Well... Guess I am using an unnecessary argument. Nevertheless, it triggers the runner to clean up after itself. Something I am trying to hope my three-year-old would do.

stopping

If you check back your repository, the runner should now be removed cleanly. Not garbage or hung registrations left.

clean

That is it. Simple.

Top comments (0)