Cómo desplegar y escalar GitHub self-hosted runners en Kubernetes

Estos días estoy a tope con GitHub Actions y he estado preparando algunas demos para mostrar cómo funcionan los self-hosted runners en diferentes modalidades. Si quieres saber más sobre los runners de GitHub puedes echar un vistazo a este vídeo de mi serie sobre GitHub Actions:

Hoy quiero contarte cómo puedes utilizar Actions Runner Controller (ARC) para tus clústeres de Kubernetes y que puedas pasar de tener cero runners para tus flujos de GitHub Actions a todos los que necesites (o los que tu clúster te permita 😙).

Un Dev Container para el entorno de pruebas

Para poder mostrarte cómo funciona ARC me he preparado un entorno con Dev Containers que puedes clonar de este repo. En él he creado la siguiente configuración, en el archivo .devcontainer/devcontainer.json:

// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-in-docker
{
	"name": "Actions Runner Controller demo",
	// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
	"image": "mcr.microsoft.com/devcontainers/base:bullseye",
	"features": {
		"ghcr.io/devcontainers/features/docker-in-docker:2": {
			"moby": true,
			"azureDnsAutoDetection": true,
			"installDockerBuildx": true,
			"version": "latest",
			"dockerDashComposeVersion": "v2"
		},
		"ghcr.io/devcontainers-contrib/features/kind:1": {
			"version": "latest"
		},
		"ghcr.io/devcontainers/features/kubectl-helm-minikube:1": {
			"version": "latest",
			"helm": "latest",
			"minikube": "none"
		}
	},
	// Use 'forwardPorts' to make a list of ports inside the container available locally.
	// "forwardPorts": [],
	// Use 'postCreateCommand' to run commands after the container is created.
	"postCreateCommand": "bash .devcontainer/post-create.sh",
	"shutdownAction": "stopContainer"
	// Configure tool-specific properties.
	// "customizations": {},
	// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
	// "remoteUser": "root"
}

Como ves, utilizo Docker in Docker para poder montarme un cluster con Kind, además de instalar kubectl y Helm. Por último, como parte de la propiedad postCreateCommand le paso un script que lo primero que hace es comprobar que tenemos «en su sitio» un archivo .env:

#!/bin/bash
set -e
# if .env file does not exist throw error
if [ ! -f .devcontainer/.env ]; then
    echo "Please create a .env file in the .devcontainer folder"
    exit 1
fi

En este guardo tanto el ID de una GitHub App que me he creado para poder registrar los runners dentro de mi organización, además del ID de instalación dentro de la misma.

GITHUB_APP_ID=<YOUR_GITHUB_APP_ID>
GITHUB_APP_INSTALLATION_ID=<YOUR_GITHUB_INSTALLATION_ID>

Por lo tanto, necesitas crearte una GitHub App en tu organización/cuenta personal a través del apartado Settings > Developer settings > GitHub Apps con los siguientes permisos:

Repository permissions:

  • Actions: read-only
  • Metadata: read-only

Organization permissions:

  • Self-hosted runners: read and write

Una vez la tengas, necesitas además descargarte una private key desde el apartado General > Private keys y guardarla como parte del directorio .devcontainer con el nombre private-key.pem para que este script la utilice, que será lo siguiente que se compruebe:

# if private-key.pem file does not exist throw error
if [ ! -f .devcontainer/private-key.pem ]; then
    echo "Please download your private-key.pem file in the .devcontainer folder"
    exit 1
fi

Si cumples estos dos requisitos se cargarán los valores del .env:

source .devcontainer/.env

Y comenzará con la configuración del clúster de Kubernetes con Kind:

CLUSTER_NAME="arc-demo"
echo -e "Clear kubectl context"
kubectl config unset contexts.${CLUSTER_NAME}
if kind get clusters | grep -q "^${CLUSTER_NAME}$"; then
    echo "Cluster ${CLUSTER_NAME} already exists"
    kind delete cluster --name ${CLUSTER_NAME}
    echo -e "Create a kind cluster ⎈"
    kind create cluster --name ${CLUSTER_NAME} --config .devcontainer/kind-config.yaml
else
    echo -e "Create a kind cluster ⎈"
    kind create cluster --name ${CLUSTER_NAME} --config .devcontainer/kind-config.yaml
fi

Este tendrá tres nodos configurados en el archivo kind-config.yaml:

# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
- role: worker
- role: worker

Una vez que ya tengas un clúster con el que jugar se instala el controlador de Kubernetes para los runners:

echo -e "Installing Actions Runner Controller 🐈‍⬛"
NAMESPACE="arc-systems"
helm install arc \
    --namespace "${NAMESPACE}" \
    --create-namespace \
    oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller

Y por último se crea un secreto con la configuración para el gha-runner-scale-set que contiene la URL el nombre de la organización, el ID de la instalación de la GitHub App en esta organización y la private key, y después, a través de Helm, se instala el chart para desplegarlo en tu clúster:

INSTALLATION_NAME="returngis-arc-runner-set"
NAMESPACE="arc-runners"
GITHUB_CONFIG_URL="https://github.com/returngis"
# Load private-key.pem into GITHUB_PRIVATE_KEY
GITHUB_PRIVATE_KEY=$(cat .devcontainer/private-key.pem)
kubectl create ns "${NAMESPACE}"
kubectl create secret generic pre-defined-secret \
  --namespace=$NAMESPACE \
  --from-literal=github_app_id="${GITHUB_APP_ID}" \
  --from-literal=github_app_installation_id="${GITHUB_APP_INSTALLATION_ID}" \
  --from-literal=github_app_private_key="${GITHUB_PRIVATE_KEY}"
helm install "${INSTALLATION_NAME}" \
    --namespace "${NAMESPACE}" \
    --create-namespace \
    --set githubConfigUrl="${GITHUB_CONFIG_URL}" \
    --set githubConfigSecret=pre-defined-secret \
    oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set

Si todo ha ido bien, cuando termine de ejecutarse el script deberías tener un par de pods ejecutándose en el namespace arc-systems:

vscode ➜ /workspaces/actions-runner-controller (main) $ kubectl get pods -n arc-systems
NAME                                         READY   STATUS    RESTARTS   AGE
arc-gha-rs-controller-764f888fc7-rk77r       1/1     Running   0          2m46s
returngis-arc-runner-set-754b578d-listener   1/1     Running   0          2m39s

Y por otro lado en el namespace para este runner scale set inicialmente no deberías de ver nada, aunque si que deberías de ver que tienes un scale set registrado en tu organización:

Nota: en este pantallazo puedes ver que tengo uno online y el otro offline. Esto es porque le cambié el nombre 🤪 pero si se mantiene uno de los scale sets 24 horas desconectado se purgan de manera automática.

Ahora si ejecutas un workflow como el siguiente (que también forma parte del repo):

name: Actions Runner Controller Demo
on:
  workflow_dispatch:
jobs:
  job1:
    # You need to use the INSTALLATION_NAME
    runs-on: returngis-arc-runner-set
    steps:
    - run: echo "🎉 This job uses runner scale set runners!"
  job2:
    # You need to use the INSTALLATION_NAME
    runs-on: returngis-arc-runner-set
    steps:
    - run: echo "🎉 This job uses runner pods!"
  job3:
    # You need to use the INSTALLATION_NAME
    runs-on: returngis-arc-runner-set
    steps:
    - run: echo "🎉 This job uses runner pods!"

Deberías de ver en un momento dado que tienes hasta tres pod/github self hosted runners ejecutándose en tu clúster.

Y por supuesto tu flujo ejecutado correctamente 🥳

¡Saludos!