Ein solarbetriebener GitHub Runner

created: Samstag, Apr. 15, 2023

Je weiter unser kleines Projekt voranschreitet, desto mehr Code produzieren wir. Und wie die meisten in unserer Community hosten wir unseren Code heutzutage auf GitHub.

Während der Entwicklung haben wir also die Implikationen einer DownToZero-Architektur für etwas wie einen GitHub-Prozess diskutiert. Wir haben schnell zwei Arten von Aktionen identifiziert, die auf der Infrastruktur ausgeführt werden müssen.

Die erste ist der CI-Build, der dem Entwickler immer sofortiges Feedback geben soll. Er prüft die Einhaltung sowie die Code-Integrität und ist normalerweise eng in den Entwicklungsprozess eingebunden. Diese Jobs sind zeitkritisch, da meistens jemand auf sie wartet.

Die zweite Kategorie, die wir identifiziert haben, ist etwas anders. Mit dem Aufstieg von Dependabot und anderen Sicherheitsscannern sahen wir immer mehr Pipelines, die von diesen Bots ausgelöst werden. Das Besondere daran ist: Wir wollen die Pipeline ausführen, um unsere Abhängigkeiten zu überprüfen und unseren Code aktuell zu halten, aber gleichzeitig wartet niemand auf diese Pipelines. Es würde also keinen Unterschied machen, wenn diese Pipelines verzögert würden.

Schauen wir uns also die zweite Kategorie an und prüfen, ob wir etwas innerhalb von GitHub bauen können. GitHub erlaubt es jedem, selbstgehostete Runner an jedes Projekt anzuhängen (eigene Runner hosten). Wenn man sich den Prozess ansieht, ist er relativ einfach: Den Runner herunterladen, an die Organisation oder das Repository anhängen und dann das Shell-Skript ausführen. Es gibt auch einen kleinen Helfer, der diesen Runner in einen systemd-Dienst verwandelt, so dass wir den Dienst nicht selbst starten und stoppen müssen.

Bezüglich der Job-Verteilung sagt GitHub, dass der wartende Job 24 Stunden gehalten wird. Innerhalb dieses Zeitfensters muss der Job übernommen werden, sonst läuft er aus. 24 Stunden sind also technisch genug Zeit, um auf den Sonnenaufgang zu warten, unabhängig davon, wann der Job erzeugt wurde.

Nachdem das geklärt war, haben wir uns unser lokales Setup angesehen und wie wir eine solche Kapazitätsplanung erreichen können. Unser aktuelles Setup sieht so aus.

solar-powered-runner

  1. Wir haben Solarpanels, die Energie erzeugen
  2. Wir haben lokale Maschinen, die diese Energie verbrauchen können und Internetzugang haben
  3. GitHub stellt die persistente Job-Warteschlange für unseren Runner bereit

Wir haben keinen Batteriespeicher angeschlossen, weil das das gesamte System teurer und komplexer machen würde.

Alle Metriken, wie die Energieausbeute des Solarpanels oder der Energieverbrauch der Server, werden von unabhängigen tasmota-Geräten (CloudFree EU Smart Plug) erfasst.

Also haben wir alles angeschlossen. Für die Bequemlichkeit haben wir Ubuntu 22.10 (das gleiche wie für den gehosteten GitHub-Runner) auf unseren Maschinen installiert. Wir haben auch die benötigte Toolchain installiert, wie rustup, gcc-musl, protobuf.

Jetzt haben wir 3 unabhängige systemd-Dienste geschrieben.

1) Dtz-Edge Service

Der erste Dienst läuft immer und liest die Energieausbeute von HomeAssistant (hier werden unsere Energiedaten aggregiert). Er berücksichtigt auch, welche anderen Geräte gerade laufen und wie viel Energie diese bereits verbrauchen. Dann implementiert er folgendes Zustandsmodell:

solar state model

Systemd-Dienstdefinition

[Unit]
Description=dtz edge Service

[Service]
Type=simple
WorkingDirectory=/root/dtz-edge
ExecStart=!/root/dtz-edge/busy.sh
Restart=always

[Install]
Alias=dtz-edge
WantedBy=multi-user.target

Busy.sh Shell-Skript (verkürzte Version)

#!/bin/bash

for (( ; ; ))
do

POWER=`curl -H 'Authorization: Bearer token1' -H "Content-Type: application/json" http://192.168.178.76:8123/api/states/sensor.solar_panel_energy_power 2> /dev/null | jq -r .state`

METER=`curl -H 'Authorization: Bearer token1' -H "Content-Type: application/json" http://192.168.178.76:8123/api/states/sensor.tasmota_energy_power_4 2> /dev/null | jq -r .state`

SALDO=$((POWER - METER))
echo "Saldo: $SALDO (solar: $POWER)"

CURRENT_HOUR=`date +%H`

if [ $CURRENT_HOUR -gt 17 ]; then
  service cheap-energy stop
  service actions.runner.DownToZero-Cloud.dtz-edge1 stop
  echo "sleep till tomorrow (10h)"
  rtcwake -m disk -s 36000
fi
if [ $SALDO -gt 70 ]; then
    echo "mehr als 70: $SALDO"
    service cheap-energy start
    service actions.runner.DownToZero-Cloud.dtz-edge1 start
    sleep 300;
else
    service cheap-energy stop
    rtcwake -m mem -s 660
fi
done

2) Cheap-Energy Service

Dieser Dienst hält nur den Zustand, dass günstige Energie verfügbar ist. Wenn dieser systemd-Dienst läuft, bedeutet das, es ist Energie verfügbar; wenn er gestoppt wird, sollen alle Worker herunterfahren.
Wir verwenden diesen Dienst also als Proxy, um das Management zu erleichtern.

[Unit]
Description=günstige Energie

[Service]
Type=simple
WorkingDirectory=/root/dtz-edge
ExecStart=!/root/dtz-edge/cheap-energy.sh
Restart=always

[Install]
Alias=cheap-energy
WantedBy=multi-user.target

Das Skript, das wir hier ausführen, ist nur ein sleep-Befehl.

#!/bin/bash

sleep infinity

3) GitHub Runner Service

Wir folgten den Anweisungen von GitHub und installierten den Runner als systemd-Dienst.

sudo ./svc.sh install

Das gab uns bereits die korrekte Dienstdefinition und das Einzige, was wir anpassen mussten, war die Abhängigkeitszeile des Dienstes. Denn nun wollen wir, dass dieser Dienst läuft, sobald der cheap-energy-Dienst läuft, und dass dieser Dienst stoppt, wenn cheap-energy gestoppt wird.

Also änderten wir unsere Dienstdefinition (actions.runner.DownToZero-Cloud.dtz-edge1.service), um die BindsTo-Beschreibung einzufügen.

[Unit]
Description=GitHub Actions Runner (DownToZero-Cloud.dtz-edge1)
After=network.target
BindsTo=cheap-energy.service

[Service]
ExecStart=/home/user1/gh-dtz-org/runsvc.sh
User=user1
WorkingDirectory=/home/user1/gh-dtz-org
KillMode=process
KillSignal=SIGTERM
TimeoutStopSec=5min

[Install]
WantedBy=multi-user.target

Nachdem der Hardware-Teil der Lösung steht, wenden wir uns wieder GitHub zu.
Wir haben nun 2 Arten von Runnern in unserer GitHub-Oberfläche. Einen GitHub-gehosteten Runner, auf dem wir unsere Typ-1-Tasks laufen lassen wollen, und unseren dtz-edge-Pool, der nur startet, wenn genügend Solarstrom verfügbar ist.

solar pool in GitHub

Lassen Sie uns unsere Pipeline-Definitionen aufteilen.

Für die Typ-1-Jobs kann alles wie bei einer normalen GitHub-Pipeline bleiben.

name: build
on:
  workflow_dispatch:
  push:
    branches:
    - main
jobs:
  build:
    permissions: write-all
    runs-on: ubuntu-latest

Für die Typ-2-Jobs, also Jobs, die verzögert auf den solarbetriebenen Maschinen laufen sollen, muss im on-Trigger-Abschnitt nur definiert werden, welche Szenarien hier unterstützt werden sollen. In unserem Fall haben wir damit begonnen, dies für alle Pull Requests zu tun. Dann muss nur noch die runs-on-Anweisung geändert werden. Hier setzen wir unseren neu erzeugten Runner ein.

name: pr
on:
  workflow_dispatch:
  pull_request:
jobs:
  test:
    name: coverage
    runs-on: self-hosted

Jetzt laufen alle Jobs, die Dependabot uns zum Mergen sendet, oder andere Bots, die Tests und Code Coverage prüfen wollen, immer dann, wenn wir die Ressourcen dafür haben.

Als zusätzlicher Bonus müssen wir für diese zusätzlichen Runner auch nicht mehr zahlen. On-Premise-Runner sind (im Sinne der GitHub-Preise) kostenlos.