- C# 98.8%
- Dockerfile 1.2%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| .vscode | ||
| ci | ||
| DwdWetter | ||
| DwdWetter.Tests | ||
| .gitignore | ||
| DwdWetter.slnx | ||
| README.md | ||
CI-Demo_DWD-Wetter
Eine kleine ASP.NET-Anwendung holt aktuelle Messwerte des Deutschen Wetterdienstes für Berlin und zeichnet sie im ASCII-Art-Stil, angelehnt an wttr.in.
Die App ist Mittel zum Zweck. Der eigentliche Gegenstand dieses Repos ist die Pipeline: ein selbst betriebener Forgejo-Runner, der bei jedem Push in einem eigens gebauten Container baut.
Stand: Die Pipeline klont, baut, testet und publiziert den Native-AOT-Build im Container. Offen sind die beiden anderen Publish-Profile und die Auslieferung der Artefakte als Release.
Funktionsprinzip
git.duske.it (Forgejo, rootless Podman auf anderem Host)
│
│ Runner fragt per Poll: "Arbeit für Label 'docker'?"
▼
┌─────────── Debian (Build-Host) ───────────────────────────┐
│ │
│ forgejo-runner.service (systemd, User: runner) │
│ │ startet pro Job einen Container │
│ ▼ │
│ Docker-Daemon ──► Container aus ci-trixie-dotnet-10 │
│ └─ dotnet-sdk-10.0, git, clang │
│ hier laufen die Steps │
└────────────────────────────────────────────────────────────┘
Kernidee: Der Runner selbst arbeitet nicht. Er holt den Job ab, startet dafür einen Container und lässt die Steps darin laufen. Das Debian ist nur Wirt für den Runner-Prozess und den Docker-Daemon, nicht die Ausführungsumgebung. Nach dem Job wird der Container samt Netzwerk gelöscht.
Wer stattdessen direkt auf dem Host bauen will, nimmt das Backend host
(siehe Host-Modus als Alternative).
Die Datenquelle
Der DWD betreibt keine REST-API. Auf die Frage nach einer Schnittstelle antwortet er in den eigenen FAQ, dass zurzeit keine dedizierte API angeboten wird. Was es gibt, ist ein Open-Data-Server mit Rohdaten.
Diese Demo benutzt die SYNOP-Bodenwettermeldungen im JSON-Format:
https://opendata.dwd.de/weather/weather_reports/synoptic/germany/json/
Das sind echte Messwerte der DWD-Stationen, seit 2018 zusätzlich zum BUFR-Format auch als JSON verfügbar.
Warum nicht Brightsky: Brightsky liefert bequemes JSON auf denselben DWD-Daten, ist aber ein von einer Privatperson freiwillig betriebenes Projekt. Für dieses Repo war eine offizielle Stelle Bedingung. Wer die Bequemlichkeit will, findet Brightsky unter brightsky.dev; die Nutzungsbedingungen des DWD gelten für die Daten weiterhin.
Was beim Hinsehen herauskam
Alle folgenden Angaben stammen aus dem echten Datenbestand, nicht aus Beispielen im Netz.
Zwei Sorten Dateien, nicht eine. Der Server legt alle 5–8 Minuten eine Datei ab, aber es sind zwei verschiedene Dinge:
| Beispiel | gepackt | entpackt | Inhalt |
|---|---|---|---|
| MW_370 | 8 KB | 0,9 MB | Teillieferung, kein Berlin |
| MW_378 | 13 KB | 1,1 MB | Teillieferung, nur Dahlem |
| MW_372 | 424 KB | 87 MB | Volllieferung, alle Stationen |
| MW_376 | 639 KB | 133 MB | Volllieferung, alle Stationen |
Der latest-Alias ist unbrauchbar. Er zeigt auf die zuletzt abgelegte
Datei, und das ist meistens eine Teillieferung ohne Berlin. Die ursprüngliche
Annahme, latest genüge und der Berlin-fehlt-Fall sei die Ausnahme, war
falsch — er wäre der Normalfall gewesen. Die App liest deshalb das
Verzeichnis-Listing und nimmt daraus die neueste Datei oberhalb einer
Größenschwelle von 150 KB. Das sind genau die Volllieferungen.
Die Berliner Stationen. Verifiziert, nicht geraten:
| Station | WMO | meldet |
|---|---|---|
| Berlin-Dahlem(FU) | 10381 | vollen Satz |
| Berlin-Tempelhof | 10384 | vollen Satz |
| Berlin Brandenburg | 10385 | vollen Satz |
| Berlin-Buch | — | nur Temperatur und Feuchte |
| Berlin-Marzahn | — | nur Temperatur und Feuchte |
Die letzten beiden sind automatische Stationen ohne WMO-Nummer. Wer nur über die Kennung sucht, findet sie nicht — und es gibt Volllieferungen, in denen Berlin ausschließlich durch diese beiden vertreten ist. Die App sucht deshalb über den Stationsnamen und arbeitet die Liste in dieser Reihenfolge ab: die vorderen Stationen zuerst, die beiden hinteren als Rückfall.
Die Struktur. Das Format entspricht der Ausgabe von bufr_dump -js, einem
maschinellen Dump einer meteorologischen Binärstruktur:
{ "messages": [ meldung, meldung, ... ] }
meldung = [ {key,value}, {key,value}, ..., [ subset, subset, ... ] ]
└─ Kopfdaten └─ je ein Subset pro Station
subset = [ {key,value}, [ {key,value}, [ {key,value}, [ ... ] ] ] ]
└─ tief rechts-verschachtelt, bis zu 150 Ebenen
Die Verschachtelung trägt keine Information. Es zählt allein die Reihenfolge
der Key/Value-Paare, und ein neues subsetNumber beginnt eine neue Station.
Damit lässt sich die Datei als flacher Token-Strom lesen, statt einen Baum zu
bauen. Die Feldnamen sind die der WMO-Tabellen: airTemperature in Kelvin,
pressureReducedToMeanSeaLevel in Pascal, presentWeather als Code, dazu
stationOrSiteName, blockNumber, stationNumber.
bz2 ohne externes Werkzeug. System.IO.Compression kann Gzip und Deflate,
aber kein Bzip2. Die naheliegende Lösung — bzip2 im Container aufrufen —
scheitert am Windows-Build: dort gibt es das Werkzeug nicht, und ein
Self-Contained-Binary, das ein externes Programm braucht, ist keines mehr.
Die App benutzt deshalb SharpZipLib und entpackt im Prozess. Ins
Dockerfile muss dafür nichts.
Die App
Ein Endpunkt, ein Zweck:
GET /
Antwortet mit HTML. Kein Auth, keine Datenbank, keine Konfiguration, kein Swagger.
Wetterbericht: Berlin-Tempelhof (WMO 10384)
Bewoelkt
.--. 21.1 °C Taupunkt 12.1 °C
.-( ). 5.4 m/s aus W (270°)
(___.__)__) 56 % rel. Feuchte
1017.9 hPa
Beobachtet: 01.09.2026 18:30 UTC
Ablauf pro Request: Listing holen → neueste Volllieferung wählen → herunterladen → im Prozess entpacken → streamend parsen → beste Berliner Station → HTML. Dauert rund 4 Sekunden, davon etwa 1 Sekunde für das Parsen der 69–133 MB.
Zustandslos. Jeder Aufruf geht denselben Weg von vorn. Kein Cache, kein Hintergrund-Refresh, kein zuletzt bekannter Wert. Zwei Requests kurz nacheinander lesen beide das Listing neu.
Streamend geparst. Eine entpackte Volllieferung ist bis zu 133 MB groß.
Als Objektbaum im Speicher wäre das ein Vielfaches davon, und im
AOT-Container erst recht unschön. Der Parser läuft mit Utf8JsonReader über
einen 64-KB-Puffer und hält immer nur die eine Station, die er gerade liest.
Der Speicherbedarf hängt nicht von der Dateigröße ab.
Minimal API statt Controller. Native AOT verträgt keine MVC-Controller,
weil die per Reflection gebunden werden. Für einen einzigen Endpunkt ist die
Minimal API ohnehin die passendere Bauform. CreateSlimBuilder lässt alles
weg, was hier nicht gebraucht wird, und EnableRequestDelegateGenerator im
csproj erzeugt die Endpunkt-Anbindung zur Compilezeit.
Aufbau
DwdWetter/
Program.cs Endpunkt und DI, sieben Zeilen Substanz
DwdClient.cs Listing lesen, Datei wählen, holen, entpacken
SynopParser.cs Token-Strom lesen, Station heraussuchen
AsciiArt.cs Wettercode zu Bild
HtmlPage.cs Bild und Messwerte nebeneinander setzen
Observation.cs eine Beobachtung, alle Werte optional
DwdWetter.Tests/
Fixtures/ gekürzte echte DWD-Meldungen
Der Netzzugriff steckt hinter IWeatherSource. Nur deshalb laufen die Tests
ohne Internet.
Bauen und Ausliefern
Drei Wege, je ein Publish Profile unter
DwdWetter/Properties/PublishProfiles/. Alle drei erzeugen ein einzelnes
Binary; auf dem Zielsystem muss kein .NET installiert sein.
| # | Profil | Ergebnis | Größe | Start |
|---|---|---|---|---|
| 1 | Linux-NativeAot |
natives ELF, kein .NET | 11,8 MB | 72–95 ms |
| 2 | Linux-SelfContained |
SingleFile + ReadyToRun | 122 MB | ~305 ms |
| 3 | Windows-SelfContained |
DwdWetter.exe |
123 MB | nicht gemessen |
dotnet publish DwdWetter/DwdWetter.csproj -p:PublishProfile=Linux-NativeAot
dotnet publish DwdWetter/DwdWetter.csproj -p:PublishProfile=Linux-SelfContained
dotnet publish DwdWetter/DwdWetter.csproj -p:PublishProfile=Windows-SelfContained
Die Ausgabe landet unter DwdWetter/bin/Release/<Profilname>/.
Weg 1, Native AOT, ist der interessante. Kein JIT, keine Runtime im
Gepäck: der Compiler erzeugt direkt Maschinencode. Ein Zehntel der Größe und
ein Viertel der Startzeit gegenüber Weg 2. Der Preis ist, dass Reflection
weitgehend wegfällt — was hier nichts kostet, weil die App keine braucht.
IsAotCompatible steht im csproj und gilt für alle Builds, damit ein
AOT-Problem nicht erst im AOT-Profil auffällt.
Weg 3 lässt sich auf Linux bauen (Cross-Compilation), aber dort nicht
ausführen. Der Build ist durchgelaufen und erzeugt die .exe samt
web.config; dass sie auf Windows startet, ist ungetestet.
Laufnummer aus der CI
Die csproj nimmt eine Build-Nummer entgegen:
<BuildNumber Condition="'$(BuildNumber)' == ''">dev</BuildNumber>
<InformationalVersion>1.0.$(BuildNumber)</InformationalVersion>
dotnet publish ... -p:BuildNumber=${{ github.run_number }}
Ohne Angabe steht 1.0.dev im Binary.
Tests
dotnet test
16 Tests, alle grün, keiner braucht Netz. Sie prüfen die drei Stellen, an denen tatsächlich etwas schiefgehen kann:
- Der Parser gegen gekürzte, echte DWD-Meldungen: Station finden,
Kelvin und Pascal umrechnen, und — die eigentliche Fehlerquelle — Stationen
sauber an
subsetNumbertrennen, statt Werte der Vorgängerstation durchsickern zu lassen. Dazu der Fall "keine Berliner Station gemeldet". - Die Wetterlage aus dem BUFR-Code 020003, inklusive der Verschiebung um 100 bei automatischen Stationen und des Rückfalls auf den Bedeckungsgrad. Außerdem, dass alle ASCII-Bilder gleich viele Zeilen gleicher Breite haben — sonst franst die rechte Spalte aus.
- Der Endpunkt über
WebApplicationFactorymit vorgegebener Beobachtung, einmal mit Daten und einmal ohne.
Die Fixtures unter DwdWetter.Tests/Fixtures/ sind echte Meldungen vom
Open-Data-Server, auf zwei Meldungen gekürzt.
Entwickeln
Die Projektmappe ist DwdWetter.slnx — das neue, schlanke XML-Format, das
dotnet seit .NET 9 versteht. dotnet build und dotnet test im Repo-Root
finden sie von allein.
Unter .vscode/ liegt eine fertige Konfiguration für VSCodium mit der
Erweiterung muhammad-sammy.csharp (dem lizenzfreien Fork von
ms-dotnettools.csharp). Der Fork liefert statt des proprietären vsdbg den
offenen netcoredbg mit; der Anmeldetyp in launch.json heißt trotzdem
weiterhin coreclr.
Startkonfigurationen (F5):
| Name | Zweck |
|---|---|
DwdWetter starten |
Debug-Build, öffnet den Browser auf Port 5199, sobald der Server meldet |
Native-AOT-Binary starten |
publiziert per Task und startet das native Binary auf Port 5200 |
An Prozess anhaengen |
für einen bereits laufenden Prozess |
Tests: anhaengen |
siehe unten |
Tasks (Strg+Shift+B für den Build):
build, test, test (wartet auf Debugger) sowie je eine Task pro
Publish-Profil.
Tests debuggen braucht zwei Schritte, weil die Tests in einem
Kindprozess laufen und ein Debugger am dotnet test-Aufruf dort nichts
sieht:
- Task
test (wartet auf Debugger)starten. Sie setztVSTEST_HOST_DEBUG=1; der Testhost hält an und nennt seine PID im Terminal. - Startkonfiguration
Tests: anhaengenausführen und diese PID auswählen.
Das Projekt hat bewusst keine launchSettings.json. Die Adresse steht
direkt in der jeweiligen Startkonfiguration.
Bausteine
| Ort | Rolle |
|---|---|
/usr/local/bin/forgejo-runner |
Binary, v13.1.0 |
/etc/systemd/system/forgejo-runner.service |
Dienst-Definition, läuft als User runner |
/home/runner/runner-config.yml |
Konfiguration, vor allem die Labels |
.runner (Pfad aus der Config) |
Registrierungs-Identität: UUID + dauerhaftes Token. Secret. |
ci/Dockerfile → ci-trixie-dotnet-10 |
Build-Umgebung, lokal gebaut, in keiner Registry |
.forgejo/workflows/*.yml im Repo |
Was gebaut wird. Erzeugt die Jobs. |
DwdWetter/ |
die App |
DwdWetter/Properties/PublishProfiles/ |
die drei Auslieferungswege |
DwdWetter.slnx |
Projektmappe, von dotnet build und dotnet test genutzt |
.vscode/ |
Debugger und Tasks für VSCodium |
Das Label-System
Das ist der Angelpunkt des ganzen Aufbaus. Ohne Label bekommt der Runner niemals einen Job, und Forgejo meldet das nicht.
docker : docker:// ci-trixie-dotnet-10:latest
│ │ │
│ │ └─ Image
│ └─ Backend: "docker" = Container, "host" = direkt auf der Maschine
└─ Name. Frei wählbar. Darauf zeigt "runs-on:" im Workflow.
Der Name links und das Backend rechts heißen beide zufällig docker, haben
aber nichts miteinander zu tun. kantine:docker://ci-trixie-dotnet-10 mit
runs-on: kantine wäre identisch und lesbarer.
In /home/runner/runner-config.yml:
runner:
labels: ["docker:docker://ci-trixie-dotnet-10:latest"]
Die ausführliche Mapping-Form aus den Config-Kommentaren ist dieselbe Sache in
anderer Schreibweise. Sie lohnt erst, wenn mehrere Optionen dazukommen
(?platform=linux/amd64 und ähnliches).
Forgejo bekommt nur den Namen gemeldet, nicht das Image. Im Log steht
deshalb with labels: [docker], und in der Weboberfläche steht als Label
docker. Wo der Job tatsächlich landet, entscheidet allein der Runner.
Erstinstallation
1) Docker
sudo apt install docker.io
sudo usermod -aG docker $USER
sudo usermod -aG docker runner
Danach neu anmelden und den Dienst neu starten:
sudo systemctl restart forgejo-runner
Ein Neustart der Shell allein reicht nicht: Die Gruppenzuordnung wird beim Prozessstart gelesen, der laufende Daemon kennt sie also noch nicht.
Prüfen, und zwar für beide User:
docker ps
sudo -u runner docker ps
Eine leere Tabelle mit Kopfzeile ist das gewünschte Ergebnis: Der Client hat den Daemon erreicht und eine Antwort bekommen.
Warum
docker.ioaus Debian und nicht das offizielle Repo: Ein Befehl, keine GPG-Schlüssel, Sicherheitsupdates über den normalen Kanal. Die Docker-Version ist für den Runner egal, er benutzt seit Jahren stabile API-Aufrufe. Werdocker composeals Subkommando braucht, landet später doch beim offiziellen Repo.
Warum nicht Podman: Der Runner spricht die Docker-API über den Socket an. Podman kann die emulieren, aber für Actions-Workloads ist diese Kombination kaum erprobt: Man hängt an einer Kompatibilitätsschicht, zu der es im Fehlerfall wenig Erfahrungsberichte gibt. Auf einem RHEL-artigen Zielsystem, wo rootless Podman der Standard ist, sieht die Rechnung anders aus.
2) Build-Image bauen
ci/Dockerfile:
FROM debian:trixie
RUN apt update \
&& apt install wget -y \
&& wget https://packages.microsoft.com/config/debian/13/packages-microsoft-prod.deb -O packages-microsoft-prod.deb \
&& dpkg -i packages-microsoft-prod.deb \
&& rm packages-microsoft-prod.deb \
&& apt update \
&& apt install dotnet-sdk-10.0 -y \
&& apt install git -y \
&& apt install clang -y
docker build -t ci-trixie-dotnet-10:latest ci/
docker run --rm ci-trixie-dotnet-10:latest dotnet --version
Es liegt unter ci/, nicht im Repo-Root. Ein Dockerfile im Root liest sich
wie "damit wird die App gebaut", und das stimmt nicht: Es baut die
Build-Umgebung, nicht die Anwendung.
Der Punkt am Ende ist das Kontext-Verzeichnis, nicht Dekoration. Alles, was
per COPY ins Image soll, muss darunter liegen.
Warum ein eigenes Image statt eines fertigen dotnet-Images: bewusste Entscheidung. Der Inhalt ist damit vollständig bekannt und im Dockerfile nachlesbar.
Das Image liegt nur lokal. Damit der Runner es nicht in einer Registry sucht,
muss force_pull aus sein. Im Job-Log ist das an forcePull=false zu sehen.
clang ist für den Native-AOT-Build nötig; ohne ihn bricht der Link-Schritt
ab. zlib1g-dev nicht — nachgemessen, siehe Learnings. bzip2 ebenfalls
nicht, die App entpackt im Prozess. nodejs erst, sobald uses:-Actions zum
Einsatz kommen, siehe
actions/checkout und Node.
3) systemd-Unit
[Unit]
Description=Forgejo Runner
Documentation=https://forgejo.org/docs/latest/admin/actions/
After=docker.service
[Service]
ExecStart=/usr/local/bin/forgejo-runner daemon -c /home/runner/runner-config.yml
ExecReload=/bin/kill -s HUP $MAINPID
User=runner
WorkingDirectory=/home/runner
Restart=on-failure
TimeoutStopSec=infinity
RestartSec=10
[Install]
WantedBy=multi-user.target
Der User runner und /home/runner müssen vorher existieren. Der Runner legt
unter $HOME/.cache Dateien an, ein Home mit /nonexistent bricht ab.
4) Labels setzen und starten
sudo systemctl restart forgejo-runner
sudo journalctl -u forgejo-runner -n 5
Erwartete Zeile:
runner: CzEwo Runner, with version: v13.1.0, with labels: [docker], ephemeral: false, declared successfully
Steht dort with labels: [], ist die Config nicht angekommen. Dann liegt das
labels: entweder nicht unter runner:, oder die .runner-Datei gewinnt.
5) Workflow im Repository
Pfad exakt so, ohne führende oder eingestreute Leerzeichen, und workflows
im Plural:
.forgejo/workflows/build.yml
name: Build
on: [push]
jobs:
build:
runs-on: docker
steps:
- run: |
git clone https://token:${{ secrets.GITHUB_TOKEN }}@git.duske.it/Christian/CI-Demo_DWD-Wetter.git \
&& cd CI-Demo_DWD-Wetter \
&& dotnet build \
&& dotnet test \
&& dotnet publish DwdWetter/DwdWetter.csproj -p:PublishProfile=Linux-NativeAot
runs-on muss dem Namen des Labels entsprechen, nicht dem Image.
Der Dateiname erscheint in der Weboberfläche als Filter (build.yml), der
name: darin als Überschrift des Laufs. Beide sollten dasselbe meinen.
Betrieb / Alltag
sudo systemctl restart forgejo-runner
sudo systemctl status forgejo-runner
sudo journalctl -u forgejo-runner -f
Nach jeder Änderung an runner-config.yml ist ein Restart nötig. Nach jedem
docker build mit gleichem Tag genügt es, den nächsten Job zu starten, der
Runner nimmt das neue Image.
Übersicht über alle Läufe der Instanz, inklusive der Zuordnung zum Runner:
https://git.duske.it/admin/actions/runners/1
Unter "Letzte Aufgaben auf diesem Runner" steht, welche Repos tatsächlich Jobs erzeugt haben. Das ist die verlässlichste Stelle, um "läuft überhaupt etwas?" zu beantworten.
Privates Repository klonen
Forgejo erzeugt pro Lauf ein Token, das nirgends angelegt werden muss. Es ist nur während des Jobs gültig, gilt nur für dieses Repo und wird im Log maskiert. Es liegt nicht unter Einstellungen → Actions → Geheimnisse, dort stehen nur selbst eingetragene Secrets.
- run: |
git clone --branch feature/runner-test \
https://token:${{ secrets.GITHUB_TOKEN }}@git.duske.it/Christian/CI-Demo_DWD-Wetter.git
cd CI-Demo_DWD-Wetter
dotnet build
FORGEJO_TOKEN ist ein Alias mit identischem Wert. Der Name GITHUB_TOKEN
existiert, damit übernommene Workflows ohne Anpassung laufen.
Der senkrechte Strich macht aus dem Block ein mehrzeiliges Shell-Skript in
einer gemeinsamen Shell, deshalb wirkt cd über die Zeile hinaus.
Noch nicht verifiziert. Dieser Schritt ist bislang nicht durchgelaufen.
Repo-URL vs. Git-URL
https://git.duske.it/Christian/CzEwo /src/branch/ feature/runner-test
└─ Server └─ Owner/Repo └─ nur Web └─ Branch
Der mittlere Teil ist Browser-Navigation. Git will die Repo-Adresse plus
--branch als eigenes Argument.
actions/checkout und Node
Fast alle uses:-Actions sind JavaScript-Programme. Der Runner braucht dafür
eine Node-Runtime im Container. Ein Image ohne Node lässt eigene
run:-Zeilen problemlos laufen, bricht aber beim ersten uses: ab.
Zwei Wege:
nodejsmit ins Dockerfile aufnehmen undactions/checkout@v4benutzen- ohne Actions arbeiten und selbst
git cloneaufrufen
Was checkout abnimmt: flacher Klon statt voller Historie, automatische
Nutzung des Job-Tokens, und vor allem das Auschecken genau des Commits,
der den Lauf ausgelöst hat. Ein eigenes git clone holt immer den aktuellen
Branch-Stand. Wird zwischen Push und Build nachgepusht, baut man etwas
anderes als erwartet.
Host-Modus als Alternative
Für ein Setup, in dem der Server ohnehin nur diesen einen Build macht, ist der Container-Weg unnötiger Aufwand:
runner:
labels: ["debian:host"]
Kein Backend-Ziel, kein Image. Die Steps laufen direkt im Debian, alle Werkzeuge werden einmal per apt installiert und bleiben liegen.
| Container | Host | |
|---|---|---|
| Abhängigkeiten | im Dockerfile, versionierbar | einmal per apt auf dem Host |
| Aufwand pro Änderung | Image neu bauen | apt install |
| Sauberkeit | jeder Job startet frisch | Jobs hinterlassen Spuren |
| Reproduzierbarkeit | gegeben | unklar, ob es woanders auch baut |
| Docker nötig | ja | nein |
Troubleshooting
| Symptom | Ursache / Fix |
|---|---|
with labels: [] im Log |
Labels fehlen oder stehen nicht unter runner:. Ohne Label wird nie ein Job zugeteilt. |
| Runner steht auf "Inaktiv", grüner Punkt | Normalzustand. Er meldet sich, hat gerade nichts zu tun. |
| Repo zeigt "Es existieren noch keine Workflows" | Pfad prüfen. Byteweise, nicht im Browser (siehe Learnings). |
| Job bleibt in "wartend" hängen | runs-on passt zu keinem Label eines erreichbaren Runners. |
Job bricht bei uses: ab |
Kein Node im Image. |
docker pull schlägt fehl bei lokalem Image |
force_pull in der Runner-Config abschalten. |
| Runner plötzlich offline | Dienst prüfen: sudo systemctl status forgejo-runner. |
| Erster Job dauert 30s, danach 2s | Normal. Der erste Lauf zieht das Image, danach liegt es im Cache. |
| Seite zeigt "Keine aktuellen Daten für Berlin" | Kommt vor. Nicht jede Volllieferung führt eine Berliner Station. |
Platform linker ('clang' or 'gcc') not found |
C-Toolchain fehlt im Image. clang nachinstallieren, Image neu bauen. |
Learnings
Ein Tabulator im Pfad, und Forgejo schweigt
Das kostete den größten Teil der Zeit. Beim Anlegen der Datei über die Weboberfläche waren aus einem Copy-Paste unsichtbare Zeichen in den Verzeichnisnamen gerutscht. Der tatsächliche Pfad lautete:
" \t.forgejo/workflows/build.yml"
Sichtbar wurde das ausschließlich in der Adresszeile des Browsers:
/src/branch/master/%20%09.forgejo/workflows
└─ %20 = Leerzeichen, %09 = Tabulator
Die Breadcrumb zeigte sauber .forgejo / workflows, weil der Browser die
Zeichen nicht darstellt. Forgejo vergleicht den Pfad zeichengenau,
" \t.forgejo" ist für den Server ein normales Verzeichnis mit seltsamem
Namen. Es gab keinen Workflow, also keinen Lauf, also keine
Fehlermeldung.
Erst der Vergleich zweier Commits nebeneinander machte es sichtbar:
.forgejo/workflows ← korrekt
.forgejo / workflows ← kaputt
Konsequenz: Bei "Datei ist da, Forgejo sieht sie nicht" zuerst den Pfad byteweise prüfen, nicht die Konfiguration:
git ls-tree -r master --name-only | grep -i forgejo | cat -A
Sackgassen, die geprüft und ausgeschlossen wurden
Der Reihe nach durchprobiert, alle ergebnislos. Steht hier, damit sie nicht nochmal drankommen:
| Vermutung | Befund |
|---|---|
| Actions-Modul im Repo deaktiviert | War aktiv. Einstellungen → Einheiten → Übersicht. Wäre es aus, fehlte der Actions-Reiter komplett. |
Falscher Standardbranch (main statt master) |
master war korrekt eingetragen. |
| Repo ist ein Spiegel, Pushes lösen nichts aus | Kein Spiegel. Steht sonst als Markierung neben dem Repo-Namen. |
Actions global in app.ini aus |
Seit Forgejo v1.21 standardmäßig an. Der erste Testlauf hatte das ohnehin bewiesen. |
| Workflow muss erst im Standardbranch existieren | Nie bestätigt. Die Datei lag zu dem Zeitpunkt am falschen Pfad, der Test war wertlos. Offene Frage. |
API-Abfrage /api/v1/repos/.../contents/... als Gegenprobe |
Untauglich. Liefert im Browser auch für das funktionierende Repo "Ziel konnte nicht gefunden werden". |
Was tatsächlich weitergeholfen hätte: sofort
https://git.duske.it/admin/actions/runners/1 aufrufen. Dort steht, welche
Repos je einen Job erzeugt haben. Stand dort nichts vom fraglichen Repo, war
klar, dass das Problem vor der Job-Zuteilung liegt und der Runner unschuldig
ist.
Der Container ist nicht der Host
Naheliegender Denkfehler beim Image-Tag: Auf dem Host läuft Trixie, also
müsste auch das Image Trixie sein. Falsch. Der Container bringt eine eigene
Distribution mit, eigenes apt, eigenes /usr. Geteilt wird allein der Kernel
des Hosts.
Ebenso: apt install im Job funktioniert, ist aber sinnlos. Der Container
wird nach dem Job gelöscht, die Installation stirbt mit ihm und läuft beim
nächsten Push von vorn. Deshalb gehört sie ins Dockerfile.
apt-get update gehört in dieselbe RUN-Zeile
Jedes RUN ist eine eigene Schicht mit eigener Shell. Der Build-Cache greift
auf Zeilenebene. Steht update in einer eigenen Zeile, nimmt Docker beim
nächsten Build die alte Paketliste aus dem Cache und installiert gegen
veraltete Indizes. Aus demselben Grund wirkt cd nicht über Zeilengrenzen,
dafür gibt es WORKDIR.
Gruppenmitgliedschaft in docker ist Root
Wer den Socket ansprechen kann, startet einen Container mit gemountetem Host-Dateisystem. Auf einer Wegwerf-Testmaschine egal, auf einem echten Server nicht.
Die .runner-Datei
JSON, entsteht bei register, enthält UUID und ein dauerhaftes Token, das aus
dem einmaligen Registrierungstoken abgeleitet wurde. Damit meldet sich der
Daemon bei jedem Start neu an, deshalb registriert man nur einmal.
Sie ist ein Secret und gehört nicht ins Repository. Wird sie gelöscht, muss neu registriert werden, und der alte Eintrag bleibt als Karteileiche in der Weboberfläche stehen.
Die Labels stehen ebenfalls darin. Bleiben sie nach einer Config-Änderung leer, ist das der zweite Ort zum Nachschauen.
"Offizielle Quelle" heißt nicht "bequeme Quelle"
Die Erwartung, eine Bundesbehörde biete eine JSON-REST-API, ist falsch. Der DWD stellt Rohdaten bereit, in Formaten, die aus der Meteorologie kommen und nicht aus dem Web. Wer eine handliche API will, landet zwangsläufig bei einem Drittanbieter, und der ist dann nicht mehr die offizielle Stelle.
Genau diese Reibung ist für eine Build-Demo brauchbar: Es gibt echte Arbeit zu tun, statt nur einen HTTP-Aufruf abzusetzen.
Erst in die Daten schauen, dann entscheiden
Drei Annahmen über das DWD-Format haben sich am echten Datenbestand als falsch herausgestellt, und jede hätte eine Fehlentscheidung nach sich gezogen:
- "
latestgenügt, Berlin fehlt nur selten" — falsch.latestzeigt fast immer auf eine Teillieferung ohne Berlin. Die Seite hätte meistens nichts angezeigt. - "Die Dateien sind ein paar hundert Kilobyte groß" — das ist die gepackte Größe. Entpackt sind es bis zu 133 MB. Als Objektbaum im Speicher wäre das im AOT-Container unangenehm geworden.
- "Berliner Stationen findet man über die WMO-Nummer" — Berlin-Buch und Berlin-Marzahn haben keine. Genau das hat beim ersten Live-Lauf zu einem leeren Ergebnis geführt, obwohl Berlin in der Datei stand.
Der Aufwand dafür war eine halbe Stunde Herumstochern in einer echten Datei, vor der ersten Zeile Code.
Fehler einzeln kommen lassen
Der AOT-Publish wurde bewusst ohne die fehlenden Pakete gestartet, statt sie
vorher zu installieren. Der Grund: Die Vermutung, es brauche gcc und
zlib1g-dev, stand zwar im README, war aber von einer anderen Maschine
übernommen. Der echte Fehler nannte dann clang oder gcc und sagte
nichts über zlib.
Das Muster gilt allgemein für diese Art Fehler: Sie kommen gestaffelt. Der Linker meldet sich erst, wenn kompiliert ist, die Header erst, wenn der Linker läuft. Wer alles auf Verdacht vorinstalliert, erfährt nie, was tatsächlich nötig war, und schleppt Pakete mit, die niemand braucht.
Nachgemessen wurde auch der zweite Teil: ein Image mit clang allein, ohne
zlib1g-dev, und derselbe Publish-Lauf. Lief fehlerfrei durch. Die
zlib-Header waren nie nötig, sie standen nur in einer von einer anderen
Maschine übernommenen Vermutung. Eine Variable geändert, eine Frage
beantwortet.
Das gilt für dieses Projekt. Käme eine Abhängigkeit dazu, die gegen zlib linkt, wäre der Befund hinfällig.
Derselbe Gedanke wie bei den DWD-Daten: erst hinschauen, dann entscheiden.
Verschachtelung, die keine Information trägt
Das bufr_dump -js-Format verschachtelt jedes Key/Value-Paar eine Ebene
tiefer als das vorige, bis zu 150 Ebenen weit. Das sieht nach einem Baum aus,
den man nachbauen müsste. Ist es aber nicht: die Schachtelung ist ein Artefakt
der BUFR-Struktur, und für das Auslesen zählt allein die Reihenfolge der
Paare. Wer das erkennt, liest die Datei als flachen Token-Strom mit
konstantem Speicher, statt 133 MB in einen Objektbaum zu heben.
Offene Punkte
App
- Bedeckungsgrad und Wettercode gemeinsam auswerten. Aktuell entscheidet der Code, und nur wenn er "nichts Nennenswertes" meldet, der Bedeckungsgrad.
- Kein Rückwärtslaufen durchs Verzeichnis: enthält die neueste Volllieferung kein Berlin, meldet die Seite das und gut. Bewusste Entscheidung gegen mehrere 133-MB-Durchläufe pro Request.
Auslieferung
- Der Windows-Build ist cross-compiliert, aber nie auf Windows gestartet.
- Ob die App als systemd-Unit oder Windows-Service läuft, ist ungetestet. Es gibt bislang keine Installationsanleitung.
Pipeline
- Nur das AOT-Profil wird gebaut. Self-Contained Linux und Windows fehlen im Workflow.
- Build-Artefakte als Release veröffentlichen. Bislang liegen sie im
Container und werden mit ihm gelöscht. Der Weg dahin:
release.ymlmiton: push: tags:, das Release über die Forgejo-API percurlanlegen und das Binary als Asset anhängen. Keine Action nötig, also auch keinnodejsim Image. Eine Schleife entsteht dabei nicht: Ein Release verändert den Git-Baum nicht, es gibt also keinen Push, auf denon: [push]reagieren könnte. -p:BuildNumber=${{ github.run_number }}ist vorgesehen, aber noch nicht im Workflow.on: [push]löst bei jeder Änderung aus, auch bei einer README-Zeile. Spätestens wenn alle drei Profile gebaut werden, lohnt eine Trennung inci.yml(jeder Push) undrelease.yml(nur Tags).nodejsins Dockerfile, fallsactions/checkout@v4benutzt werden soll.- Klären, ob ein Workflow im Standardbranch existieren muss, damit Feature-Branches Läufe erzeugen.