Ein solarbetriebener GitHub Runner

created: Samstag, Apr. 15, 2023

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

Während der Entwicklung haben wir über die Auswirkungen einer DownToZero-Architektur für etwas wie einen GitHub-Prozess gesprochen. Dabei haben wir 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 liefern sollte. Er prüft die Einhaltung sowie die Codeintegrität und ist normalerweise tief in den Entwicklungsprozess eingebunden. Diese Jobs sind zeitkritisch, da in der Regel jemand auf sie wartet.

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

Schauen wir uns also die zweite Kategorie an und sehen, 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 umwandelt, sodass wir den Dienst nicht selbst starten und stoppen müssen.

Betrachtet man die Jobverteilung, sagt GitHub, dass ein wartender Job 24 Stunden gehalten wird. Innerhalb dieses Zeitraums muss der Job aufgenommen werden oder er läuft ab. Technisch betrachtet sind 24 Stunden genug Zeit, um auf den Sonnenaufgang zu warten, egal wann der Job gestartet wurde.

Nachdem das geklärt war, begannen wir uns unser lokales Setup anzusehen 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 produzieren
  2. Wir haben lokale Maschinen, die diese Energie konsumieren können und Internetzugang haben
  3. GitHub stellt die persistente Job-Warteschlange für unseren Runner bereit

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

Alle Metriken, wie etwa die Energieerzeugung des Solarpanels oder der Energieverbrauch der Server, werden durch unabhängige tasmota Geräte (CloudFree EU Smart Plug) erfasst.

Also haben wir alles angeschlossen. Zur Bequemlichkeit haben wir auf unseren Maschinen Ubuntu 22.10 installiert (das gleiche wie beim GitHub-gehosteten Runner). Wir haben auch die benötigten Toolchains installiert wie rustup, gcc-musl, protobuf.

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

1) Dtz-Edge Service

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. Danach implementiert er folgendes Zustandsmodell:

solar state model

Systemd-Dienste-Definition

[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

Shell-Skript busy.sh (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 "schlafe bis morgen (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. Läuft dieser systemd-Dienst, bedeutet dies, es ist Energie vorhanden, läuft er nicht, sollen alle Worker herunterfahren.
Wir nutzen diesen Dienst also als Proxy, um die Verwaltung 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 haben den Anweisungen von GitHub gefolgt und den Runner als systemd-Dienst installiert.

sudo ./svc.sh install

Das gab uns bereits die korrekte Dienstdefinition; das Einzige, was wir ändern mussten, war die Dienstabhängigkeitszeile.
Denn nun soll dieser Dienst nur laufen, wenn der cheap-energy-Dienst läuft, und er soll ebenfalls stoppen, wenn cheap-energy stoppt.

Deshalb haben wir unsere Dienstdefinition (actions.runner.DownToZero-Cloud.dtz-edge1.service) um die BindsTo-Beschreibung ergänzt.

[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 eingerichtet ist, kommen wir zurück zur GitHub-Seite.
Wir haben jetzt 2 Arten von Runnern in unserer GitHub-Benutzeroberfläche. Einen GitHub-gehosteten Runner, auf dem Typ-1-Jobs laufen sollen, und unseren dtz-edge Pool, der nur hochfährt, wenn genügend Solarstrom vorhanden 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 wir verzögert auf den solarbetriebenen Maschinen laufen lassen wollen, müssen wir nur die on-Trigger-Sektion definieren, um die hier unterstützten Szenarien abzudecken. Bei uns begann dies mit allen Pull Requests. Das Einzige, was geändert werden muss, ist die runs-on-Anweisung. Hier haben wir unseren neu generierten Runner verwendet.

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

Wenn Dependabot uns also Updates zum Mergen sendet oder ein anderer Bot Tests und Code Coverage prüfen möchte, laufen diese Jobs, sobald wir die Ressourcen dafür haben.

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