Dockerized Github Actions Runner

Let's take a confusing recursive journey into the world of Docker and Github Actions, shall we?

Deployment Woes

In my time as a developer, I have frequently had the add-on task of figuring out deployment automation. I have made my fair share of pipeline processes and set up runners for Gitlab, Bitbucket, Jenkins, and Github Actions. Every system has its nuances and challenges to be overcome.

My Main Problem

Like most Engineers, I have many side projects. Dozens. And, like most Engineers, few have gone further than brainstorming stage. One annoying hurdle for each project is figuring out yet another deployment process. So I have tried to simplify some parts of that process.

The Annoying Repetitive Process

Each repo requires it’s own runner for pipeline processes to run on. You can use built in cloud runners, but it’s easy to run into the limits if you are focused on one project - then you have to manually deploy anyway, or fork over some cash to get the pipeline to run. I don’t want to have to pay Github for every dumb idea I have that I will only complete 25% of.

Solution? Use your own Runners.

Which leads to it’s own problem. You have to configure each runner, manually. Sure, there’s a simple script when adding a new runner. But that installs the runner and kicks it off. How do you start it every time you reboot? Startup script or service? Ok, fine, but what if you have more than one? 10? 50? That seems like a project all on it’s own.

Leverage Docker Compose

So let’s create a Docker image that installs the Github Actions runner, then runs the configuration script with a passed in token. And we can compose multiple Docker containers for all your projects in one docker-compose.yml file.

All of the following files can be found here:
https://github.com/coma-toast/gh-runner-docker

Here is an example:

docker-compose.yml

version: "3.7"
services:
    repo-runner:
        image: jasonsdocker2018/gh-runner-docker
        environment:
            - USERNAME=coma-toast
            - REPO=repo-name
            - TOKEN=the token
        volumes:
            - another-repo-runner:/actions-runner
            - /var/run/docker.sock:/var/run/docker.sock
volumes:
    another-repo-runner:

The token is only valid for 1 hour, but I removed it because it still makes me nervous.

USERNAME is your Github username
REPO is the Github Repo name
TOKEN you can get by going to the Repo -> Settings -> Actions -> Runners -> New Self Hosted Runner
The volumes are needed for persistence. The github runner script creates a config file and looks for it on startup.

Now you can have ALL of your runners as separate services in ONE Docker Compose file. Neat.

Here is the Dockerfile:

FROM --platform=linux/amd64 ubuntu:20.04 

# VERSION conflicts with the docker install script
ARG GHVERSION

# So the tzdata install doesn't stop to prompt
ENV DEBIAN_FRONTEND=noninteractive

# Copy EntryPoint
COPY ./EntryPoint.sh /EntryPoint.sh

# Install dependencies
RUN apt-get update
RUN apt-get install -y curl tar bash sudo apt-utils
RUN apt-get -y install tzdata

# Install docker so we can build docker images in the pipeline
RUN curl -sSL https://get.docker.com/ | sh

# Set up destination folder and user
RUN mkdir actions-runner
RUN useradd -r runner
RUN adduser runner sudo
RUN chmod 777 actions-runner
RUN mkdir /home/runner
RUN chown runner:docker /home/runner

# Add the runner user to sudoers
RUN mkdir -p /etc/sudoers.d \
        && echo "runner ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/runner \
        && chmod 0440 /etc/sudoers.d/runner

# Move to the destination folder
WORKDIR /actions-runner

# Get the files and 
RUN curl -o actions-runner-linux-x64-$GHVERSION.tar.gz -L https://github.com/actions/runner/releases/download/v${GHVERSION}/actions-runner-linux-x64-${GHVERSION}.tar.gz
RUN curl -o dotnet-install.sh -L https://dot.net/v1/dotnet-install.sh
RUN chmod +x dotnet-install.sh
RUN tar xzf ./actions-runner-linux-x64-$GHVERSION.tar.gz
RUN ./bin/installdependencies.sh

USER runner

ENTRYPOINT [ "/EntryPoint.sh" ]

Basically we setup the user, install all the necessary pre-reqs, get the Actions Runner script.

Here is the clever bit - EntryPoint.sh:

#!/bin/bash

if ! grep -q $TOKEN installed; then
    rm .runner
    echo | ./config.sh --url https://github.com/$USERNAME/$REPO --token $TOKEN
    echo $TOKEN > installed
fi

if ./run.sh; then
    rm installed
fi

We dump the TOKEN into an installed file that is checked on startup. On first run, installed won’t exist and we can configure the new runner. If a new token is generated for some reason, the $TOKEN and the contents of installed won’t match, so we wipe the whole thing and kick off the config script again. The run.sh script will listen forever once it’s configured, or exit if there’s a config issue - at which point, again, we wipe it all to reconfigure on the next run.

The Confusing Bit…

Here’s where we go a little crazy. All of this is great. But it’s a Docker image that needs to be built/updated and pushed. Is there any way we can have this automatically deploy, say using our own self-hosted runners, maybe in, oh, I don’t know… a docker-compose.yml file, perhaps?

I’m glad you asked.

version: "3.7"
services:
    gh-runner-docker:
        image: jasonsdocker2018/gh-runner-docker
        environment:
            - USERNAME=coma-toast
            - REPO=gh-runner-docker
            - TOKEN=the token
        volumes:
            - gh-runner-docker:/actions-runner
            - /var/run/docker.sock:/var/run/docker.sock
volumes:
    gh-runner-docker:

So… how exactly does the runner build the runner and deploy the runner from the runner?

A Github Actions pipeline:

.github/workflows/docker-image.yml

name: Docker Image CI

on:
    push:
        branches: ["main"]
    pull_request:
        branches: ["main"]

jobs:
    build:
        runs-on: self-hosted

        steps:
            - name: Checkout
              uses: actions/checkout@v3

            - name: Log in to Docker Hub
              uses: docker/login-action@v2
              with:
                  username: ${{ secrets.DOCKER_USERNAME }}
                  password: ${{ secrets.DOCKER_PASSWORD }}
            - name: Extract metadata (tags, labels) for Docker
              id: meta
              uses: docker/metadata-action@v4
              with:
                  images: jasonsdocker2018/gh-runner-docker
                  flavor: latest=true

            - name: Build and push Docker image
              uses: docker/build-push-action@v3
              with:
                  context: .
                  push: true
                  tags: ${{ steps.meta.outputs.tags }}
                  labels: ${{ steps.meta.outputs.labels }}
                  build-args: GHVERSION=2.299.1

Yet Another Problem

Developing and troubleshooting the runner Docker image can be difficult if you are trying to change the Dockerfile, so here is (yet another) docker-compose.yml using the build context of . to use the local Dockerfile:

version: "3"

services:
    runner:
        build:
            context: .
            args:
                GHVERSION: 2.299.1
        environment:
            - USERNAME=coma-toast
            - REPO=gh-runner-docker
            - TOKEN=the token
        volumes:
            - data:/actions-runner
            - /var/run/docker.sock:/var/run/docker.sock
volumes:
    data:

So far, I have 2 repo’s using this for deployment. I’d say it’s more than 25% done, at least.