Ein solarbetriebener GitHub Runner

created: Samstag, Apr. 15, 2023

Je weiter unser kleines Projekt voranschreitet, desto mehr Code produzieren wir. Und wie der Großteil unserer Community hosten wir heutzutage unseren Code auf GitHub.

Während der Entwicklung haben wir also die Auswirkungen einer DownToZero-Architektur für etwas wie einen GitHub-Prozess diskutiert. Schnell haben wir 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 sollte. Er prüft auf Compliance sowie die Integrität des Codes und ist normalerweise stark in den Entwicklungsprozess eingebunden. Diese Jobs sind zeitkritisch, weil in der Regel jemand auf sie wartet.

Die zweite Kategorie, die wir identifiziert haben, ist etwas anders. Mit dem Aufkommen von dependabot und anderen Sicherheitsscannern sahen wir immer mehr Pipelines, die von diesen Bots ausgelöst wurden. Das Besondere daran ist, dass wir die Pipeline ausführen wollen, um unsere Abhängigkeiten zu überprüfen und unseren Code auf dem neuesten Stand 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 jedem, selbstgehostete Runner an ein Projekt anzuhängen (hosting your own runners). Wenn man sich den Prozess anschaut, ist er relativ unkompliziert: den Runner herunterladen, an deine Organisation oder dein Repo anhängen und dann das Shell-Skript ausführen. Es gibt auch ein kleines Hilfsprogramm, das diesen Runner in einen systemd-Dienst verwandelt, sodass wir den Dienst nicht selbst starten und stoppen müssen.

Bezüglich der Jobverteilung gibt GitHub an, dass ein in der Warteschlange befindlicher Job 24 Stunden gehalten wird. Innerhalb dieses Zeitrahmens muss der Job abgeholt werden, sonst läuft er ab. Technisch sind 24 Stunden also genug Zeit, um auf Sonnenaufgang zu warten, unabhängig davon, wann der Job gestartet wurde.

Damit geklärt, begannen wir, uns unser lokales Setup anzusehen und wie wir eine solche Kapazitätsplanung erreichen können. Unser aktuelles Setup sieht so aus.

solarbetriebener-runner

  1. wir haben Solarpaneele, 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, da das Gesamtsystem dadurch teurer und komplexer würde.

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

Also haben wir alles angeschlossen. Der Bequemlichkeit halber haben wir auf unseren Maschinen Ubuntu 22.10 installiert (das gleiche wie beim GitHub-hosted Runner). Wir haben auch die benötigte Toolchain installiert, wie rustup, gcc-musl, protobuf.

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

1) Dtz-Edge-Dienst

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

Solar-Zustandsmodell

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 (gekü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 "more then 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-Dienst

Dieser Dienst hält nur den Zustand, dass günstige Energie verfügbar ist. Wenn dieser systemd-Dienst läuft, bedeutet das, dass Energie verfügbar ist; wenn er gestoppt wird, sollten alle Worker heruntergefahren werden. Wir verwenden diesen Dienst also als Proxy, um die Verwaltung zu vereinfachen.

[Unit]
Description=cheap energy

[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 einfach nur ein Sleep-Befehl.

#!/bin/bash

sleep infinity

3) GitHub Runner-Dienst

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

sudo ./svc.sh install

Das lieferte uns bereits die korrekte Dienstdefinition, und das Einzige, was wir ändern mussten, war die Service-Abhängigkeitszeile. Denn jetzt soll dieser Dienst laufen, wann immer der cheap-energy-Dienst läuft, und außerdem gestoppt werden, wenn der cheap-energy-Dienst gestoppt wird.

Also haben wir unsere Dienstdefinition (actions.runner.DownToZero-Cloud.dtz-edge1.service) geändert, um die BindsTo-Beschreibung einzuschließen.

[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 wir den Hardware-Teil der Lösung eingerichtet haben, wenden wir uns wieder der GitHub-Seite zu. Wir haben jetzt 2 Arten von Runnern in unserer GitHub-Oberfläche. Der eine ist der GitHub-gehostete Runner, auf dem wir unsere Typ-1-Tasks ausführen wollen, und der andere ist unser dtz-edge-Pool, der nur hochfährt, wenn genügend Solarenergie vorhanden ist.

Solar-Pool in GitHub

Teilen wir unsere Pipeline-Definitionen auf.

Bei den Typ-1-Jobs kann alles wie in einer normalen GitHub-Pipeline bleiben.

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

Bei den Typ-2-Jobs, also Jobs, die verzögert auf den solarbetriebenen Maschinen ausgeführt werden sollen, müssen wir lediglich den on-Trigger-Abschnitt so definieren, dass die hier unterstützten Szenarien abgedeckt sind. In unserem Fall haben wir damit begonnen, dies für alle Pull-Requests zu tun. Dann muss nur noch die runs-on-Angabe geändert werden. Hier haben wir unseren neu erstellten Runner platziert.

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

Nun werden also, wann immer dependabot uns Updates zum Mergen schickt oder ein anderer Bot Tests und Code-Coverage prüfen möchte, diese Jobs ausgeführt, wann immer wir die Ressourcen dafür haben.

Als zusätzlicher Bonus müssen wir für diese zusätzlichen Runner nicht mehr bezahlen. On-Prem-Runner sind kostenlos (im Sinne der GitHub-Preisgestaltung).