A Solar Powered GitHub Runner

created: sabato, apr 15, 2023

Più il nostro piccolo progetto avanza, più codice produciamo. E come la maggior parte della nostra community al giorno d’oggi, ospitiamo il nostro codice su GitHub.

Quindi durante lo sviluppo abbiamo discusso le implicazioni di un’architettura DownToZero per qualcosa come un processo GitHub. Abbiamo rapidamente identificato due tipi di azioni che devono essere eseguite sull’infrastruttura.

La prima è la build CI, che dovrebbe fornire sempre un riscontro immediato allo sviluppatore. Controlla la conformità così come l’integrità del codice ed è solitamente profondamente coinvolta nel processo di sviluppo. Questi job sono sensibili al tempo perché di solito qualcuno li sta aspettando.

La seconda categoria che abbiamo identificato è un po’ diversa. Con l’aumento di dependabot e altri scanner di sicurezza, abbiamo visto sempre più pipeline attivate da questi bot. La cosa è che vogliamo eseguire la pipeline per controllare le dipendenze e mantenere il nostro codice aggiornato, ma allo stesso tempo nessuno sta aspettando quelle pipeline. Quindi non farebbe alcuna differenza se quelle pipeline fossero rimandate.

Quindi diamo un’occhiata alla seconda categoria e vediamo se possiamo costruire qualcosa all’interno di GitHub. Beh, GitHub permette a chiunque di collegare runner self-hosted a qualsiasi progetto (hosting your own runners). Se guardi il processo, è relativamente semplice: scaricare il runner, collegarlo alla tua organizzazione o repo e poi eseguire lo script shell. C’è anche un piccolo helper che trasforma questo runner in un servizio systemd, così non dobbiamo avviare e fermare il servizio manualmente.

Guardando la distribuzione dei job, GitHub dice che il job in coda viene mantenuto per 24 ore. Entro quel lasso di tempo il job deve essere preso in carico o andrà in timeout. Quindi 24 ore sono tecnicamente abbastanza per aspettare che il sole sorga, indipendentemente da quando il job è stato creato.

Con questo chiarito, abbiamo iniziato a guardare la nostra configurazione locale e come possiamo raggiungere tale pianificazione della capacità. La nostra configurazione attuale è così.

solar-powered-runner

  1. abbiamo pannelli solari che producono energia
  2. abbiamo macchine locali che possono consumare quell’energia e hanno accesso a internet
  3. GitHub fornisce la coda di job persistente per il nostro runner

Non abbiamo alcun accumulo a batteria collegato, perché questo renderebbe l’intero sistema più costoso e complesso.

Tutte le metriche, come la produzione di energia del pannello solare o il consumo energetico dei server, sono tracciate da dispositivi indipendenti tasmota (CloudFree EU Smart Plug).

Quindi abbiamo collegato tutto. Per comodità, abbiamo installato Ubuntu 22.10 (lo stesso usato per il runner ospitato da GitHub) sulle nostre macchine. Abbiamo anche installato la toolchain di cui avevamo bisogno, come rustup, gcc-musl, protobuf.

Ora, abbiamo scritto 3 servizi systemd indipendenti.

1) Servizio Dtz-Edge

Il primo servizio è sempre attivo e legge l’output energetico da HomeAssistant (qui è dove vengono aggregate le nostre informazioni energetiche). Tiene anche in considerazione quali altri dispositivi sono attualmente in funzione e quanta energia stanno già consumando. Implementa quindi il seguente modello di stato:

solar state model

Definizione del servizio Systemd

[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

Script busy.sh (versione accorciata)

#!/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) Servizio Cheap-Energy

Questo servizio tiene solo lo stato che l’energia economica è disponibile. Quindi quando questo servizio systemd è in esecuzione, significa che c’è energia disponibile; quando è fermo, tutti i worker dovrebbero spegnersi. Quindi usiamo questo servizio come proxy per rendere più semplice la gestione.

[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

Lo script che eseguiamo qui è solo un comando sleep.

#!/bin/bash

sleep infinity

3) Servizio GitHub Runner

Abbiamo seguito le istruzioni fornite da GitHub e installato il runner come servizio systemd.

sudo ./svc.sh install

Questo ci ha già fornito la definizione del servizio corretta e l’unica cosa che abbiamo dovuto cambiare è stata la riga di dipendenza del servizio. Perché ora vogliamo che questo servizio venga eseguito ogni volta che il servizio cheap-energy è attivo, e che si fermi quando cheap-energy viene fermato.

Quindi abbiamo modificato la definizione del nostro servizio (actions.runner.DownToZero-Cloud.dtz-edge1.service) per includere la descrizione BindsTo.

[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

Ora che abbiamo configurato la parte hardware della soluzione, torniamo alla parte GitHub. Ora abbiamo 2 tipi di runner nella nostra UI di GitHub. Uno è il runner ospitato da GitHub, sul quale vogliamo eseguire il nostro task di tipo 1, e uno è il nostro pool dtz-edge che si attiva solo quando c’è abbastanza energia solare.

solar pool in GitHub

Dividiamo le nostre definizioni di pipeline.

Per i job di tipo 1, tutto può rimanere come una normale pipeline GitHub.

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

Per i job di tipo 2, cioè i job che vogliamo eseguire in ritardo sulle macchine alimentate a energia solare, dobbiamo solo definire la sezione on-trigger per includere gli scenari che vogliamo supportare qui. Nel nostro caso abbiamo iniziato facendo questo per tutte le pull request. Poi l’unica cosa che deve essere cambiata è l’istruzione runs-on. Qui abbiamo inserito il nostro runner appena creato.

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

Quindi ora ogni volta che dependabot ci invia degli aggiornamenti da unire, o qualche altro bot vuole controllare i test e la copertura del codice, quei job verranno eseguiti quando avremo le risorse per farlo.

Come vantaggio aggiuntivo, non dobbiamo più pagare per questi runner extra. I runner on-prem sono gratuiti (nel senso della tariffazione di GitHub).