GitHub leitet fremde PRs in isolierte Umgebungen. Vertrauenswürdige Deployments und CI laufen auf getrennten Runnern desselben VPS.

Diagramm nach bestehender Runner-Struktur, historischen erfolgreichen Jobs und geprüften systemd-Konfigurationen. Die Animation zeigt zuerst die Aufgabenverteilung und Ressourcengrenzen und behält danach nur ein niederfrequentes Verbindungsglühen; bei aktivierter Systemeinstellung „Bewegung reduzieren” wird ein statisches Architekturdiagramm angezeigt.

Wenn CI auf einem privaten Repository intensiv läuft, werden die kostenlosen Minuten aus dem GitHub-Actions-Plan schnell zu einer Zahl, die man im Auge behalten muss. Wenn bereits ein VPS vorhanden ist, der rund um die Uhr läuft, kann GitHub dann weiter die Workflows verwalten und die eigentlichen Builds und Tests diesem Rechner überlassen?

Ja. Zwei Workflows, die ich pflege, haben bereits einen Teil ihrer deterministischen Aufgaben auf denselben VPS ausgelagert: Ein Runner kümmert sich um Builds und Deployments des Blogs, der andere führt Prüfungen für Engineering-Tools aus. GitHub übernimmt Auslösung und Protokollierung; die Berechnung findet auf meinem eigenen Server statt. GitHub berechnet derzeit keine Rechenminuten für Self-hosted-Runner-Jobs; das heißt aber nicht, dass der VPS kostenlos ist – und schon gar nicht, dass man jeden PR auf die Produktionsmaschine werfen sollte.

Ob man das tun sollte, würde ich mit drei Fragen beginnen:

  • Sind die kostenlosen Minuten wirklich fast aufgebraucht? Wenn noch reichlich übrig ist, ist die Bequemlichkeit von GitHub-hosted vielleicht wertvoller als der Eigenbau.
  • Muss der VPS ohnehin rund um die Uhr online sein? Vorhandene Rechenkapazität weiterzuverwenden, ist eine andere Rechnung, als für CI einen neuen Rechner zu mieten.
  • Wie vertrauenswürdig ist der auszuführende Code? Feststehende Aufgaben auf einem geschützten main-Branch kommen für den Eigenbau infrage; nicht vertrauenswürdige PRs gehören zuerst in eine isolierte Ausführungsumgebung.

Die ersten beiden Fragen entscheiden, ob sich der Aufwand lohnt; die dritte entscheidet, ob man zum Sparen einiger Minuten die Produktionsumgebung gleich mit opfert.

Self-hosted ist bei Rechenminuten kostenlos, nicht bei der ganzen Rechnung

Die GitHub-Actions-Abrechnungsdokumentation besagt derzeit: Self-hosted-Runner-Jobs sind kostenlos; Standard-GitHub-hosted-Runner auf privaten Repositories verbrauchen zuerst die im Plan enthaltenen Minuten, Übermengen werden abgerechnet. Nach den aktuellen offiziellen Kontingenten enthält GitHub Free 2000 Minuten pro Monat, Pro und Team 3000 Minuten – die genaue Zahl hängt vom Konto des Repository-Besitzers ab. Bei einem Standard-Linux-x64-Runner mit 2 vCPUs als Beispiel liegt der offizielle Listenpreis bei $0.006 pro Minute, und gehostete Jobs runden Teilminuten auf.

Das muss man in drei Posten zerlegen:

KostenWas nach dem Umzug auf einen VPS passiert
GitHub-hosted-RechenminutenDie ausgelagerten Self-hosted-Jobs verbrauchen diese gehosteten Minuten nicht mehr
VPS-Rechenleistung und -WartungCPU, Speicher, Platte, Bandbreite und Betriebskosten tragen Sie selbst
Andere GitHub-SKUsÜbermengen an Artifact-/Cache-Speicher und andere gehostete Jobs werden weiterhin separat abgerechnet

Es gibt auch einen Zeitpunkt, bei dem alte Nachrichten leicht in die Irre führen. GitHub hatte ursprünglich angekündigt, für Self-hosted Runner eine Plattformgebühr von $0.002 pro Minute zu erheben, und die Ankündigung später aktualisiert, um die Einführung zu verschieben. “Abrechnung ab März 2026” aus einem alten Beitrag gilt nicht als aktuelle Regel; umgekehrt gibt es keine Garantie, dass es für immer so bleibt.

Meine drei historischen Jobs eignen sich gut für eine Annahme: Wenn sie auf Standard-2-vCPU-Linux-gehosteten-Runnern exakt gleich lang liefen, würden sie auf 9, 13 und 2 Minuten aufgerundet, also insgesamt 24 Minuten. Nur wenn all diese Minuten abgerechnete Übermengen wären, läge der Listenpreis-Referenzwert bei $0.144. Das ist eine Rechnung gegen Listenpreise, keine tatsächliche Ersparnis; Rechnung und im Plan enthaltene Nutzung für denselben Zeitraum müssen im Usage des Kontos geprüft werden.

Ein VPS, zwei Runner: Das ist nicht mit einem runs-on-Wechsel getan

Ein Self-hosted Runner ist ein auf dem eigenen Rechner laufender Prozess, der sich aktiv mit GitHub verbindet, um Aufgaben abzuholen – nicht GitHub, das sich per SSH in den VPS einloggt. Die offizielle Runner-Dokumentation beschreibt den Mechanismus vollständig.

Aus meinen bestehenden GitHub-Actions-Job-Protokollen habe ich drei erfolgreich ausgeführte Aufgaben herausgezogen:

Tatsächliche AufgabeRunner-RolleErgebnisZeitspanne zwischen erstem und letztem Log
Blog-Build und Produktions-DeploymentProduktions-RunnerSuccess8 Min. 32 Sek.
Blog-PR-Inhaltsprüfung (3. Versuch)Derselbe Produktions-RunnerSuccess12 Min. 12 Sek.
Engineering-Tooling-Sicherheits- und VertragsprüfungenEigenständiger CI-RunnerSuccess1 Min. 27 Sek.

Die beiden Runner haben auf GitHub unterschiedliche Identitäten, und die Job-Logs zeigen auf denselben VPS. Das belegt zumindest, dass zwei Repositories tatsächlich eigene Rechenressourcen nutzen und nicht nur eine schön geschriebene YAML-Datei. Die Zeiten in der Tabelle sind die Log-Spannen historischer Jobs – gut zum Abgleich der Ausführungsprotokolle; Kapazitätsfragen erfordern weiter Ressourcenlimits und betriebliche Disziplin.

Auf einem nicht allzu starken VPS ist das häufigste Problem eher Ressourcenkonkurrenz: CI startet einen großen Build, und der ständig laufende Produktionsdienst wird plötzlich träge. Deshalb behandle ich Runner-Identitäten, Host-Limits und Workflow-Zulassung getrennt.

Runner-Labels allein genügen nicht: systemd übernimmt die eigentliche Ressourcenbegrenzung

Die beiden Repositories können jeweils unter Settings → Actions → Runners einen eigenen Repo-level Runner anlegen und von der aktuellen GitHub-Seite Download-URL, Prüfsumme und ein kurzlebiges Registrierungs-Token holen. Produktion und CI laufen unter verschiedenen Nicht-Root-Linux-Konten mit separaten Arbeitsverzeichnissen und Minimalberechtigungen und erhalten jeweils eigene Labels, zum Beispiel:

# 已审查的生产部署
runs-on: [self-hosted, linux, x64, deploy-prod]

# 受信任的工程检查
runs-on: [self-hosted, linux, x64, ci-lab]

Labels wählen nur aus, wer läuft; sie isolieren nicht. Wenn beide Runner Produktionsgeheimnisse lesen können oder beide uneingeschränkte sudo- oder Docker-Socket-Berechtigungen haben, schützen unterschiedliche Namen nichts auf dem VPS.

Das Installationsskript, das ich für den CI-Runner mit niedriger Priorität geschrieben habe, enthält tatsächlich diesen systemd-Service-Drop-in. Das ist der anfängliche Limitsatz auf einem gemeinsamen 2-vCPU-VPS mit ca. 2.4 GiB Speicher:

[Service]
CPUQuota=75%
MemoryHigh=700M
MemoryMax=1050M
OOMScoreAdjust=400
Nice=10

Hier gibt es ein leicht misszuverstehendes Detail. Nach der systemd-Dokumentation zur Ressourcensteuerung bedeutet CPUQuota=75% 75 % der CPU-Zeit eines einzelnen CPU-Kerns, nicht 75 % des gesamten Dual-Core-VPS. MemoryHigh=700M ist eine weiche Schwelle, ab der der Speicherrückforderungsdruck steigt; MemoryMax=1050M ist eine harte Grenze – bei Überschreitung kann es zu OOM kommen und der CI-Job schlägt fehl. Nice=10 und OOMScoreAdjust=400 sorgen dafür, dass dieses CI eher kritischen Diensten weicht, garantieren aber nicht, dass die Produktion nie beeinträchtigt wird.

Voraussetzung ist ein Host mit cgroup v2 mit Speicher-Controllern und einer passenden systemd-Version. Führen Sie zuerst test -f /sys/fs/cgroup/cgroup.controllers && grep -qw memory /sys/fs/cgroup/cgroup.controllers && systemctl --version aus, um die einheitliche Hierarchie, den Speicher-Controller und die systemd-Version zu bestätigen; kopieren Sie das nicht in eine alte Umgebung und gehen Sie davon aus, dass die Limits greifen.

Die ./config.sh des Runners registriert nur; sie erstellt keinen systemd-Service automatisch. Folgen Sie den offiziellen Linux-Service-Schritten von GitHub, gehen Sie jeweils in die registrierten Produktions- und CI-Runner-Installationsverzeichnisse und führen Sie mit Admin-Rechten install <dedizierter Benutzer> auf der jeweiligen svc.sh aus, dann start. Führen Sie zum Beispiel im eigenen Installationsverzeichnis des CI-Runners (registriert, svc.sh vorhanden) aus:

sudo ./svc.sh install ci-runner
sudo ./svc.sh start
sudo ./svc.sh status

ci-runner ist ein Beispielkonto – verwenden Sie nicht das Produktionskonto; wenn bereits ein Dienst läuft, kein zweites install. Suchen Sie danach die echte CI-Unit und setzen Sie die Limits in einem Wartungsfenster:

systemctl list-unit-files 'actions.runner.*.service'

# 替换为上一行显示的真实 CI unit,切勿照抄占位名称
UNIT='actions.runner.EXAMPLE-REPO.ci-01.service'
sudo systemctl edit "$UNIT"
# 在编辑器里保存上面的 [Service] 配置
sudo systemctl daemon-reload
sudo systemctl restart "$UNIT"
systemctl cat "$UNIT"
systemctl show "$UNIT" -p CPUQuotaPerSecUSec -p MemoryHigh -p MemoryMax

restart unterbricht den gerade von diesem Runner ausgeführten Job – stellen Sie zuerst sicher, dass er leer läuft, und starten Sie nicht versehentlich den Produktions-Runner neu. Bestätigen Sie nach dem Neustart auf der Runners-Seite des Repositorys, dass er wieder Online ist, und gleichen Sie die wirksamen Werte mit systemctl show ab. Entfernen Sie beim Zurückrollen nur den diesmal neu hinzugefügten CI-Drop-in – räumen Sie nicht beiläufig vorhandene Service-Anpassungen weg. Registrierungscodes, Hostnamen, Servicekonten und Produktionsverzeichnisse müssen nie in Workflows stehen und sollten in keinem öffentlichen Artikel erscheinen.

Vor Workflow-Start noch einmal die Ressourcen prüfen

systemd kann der Prozessgruppe eines Dienstes Grenzen setzen, aber keinen freien Plattenspeicher herbeizaubern. Mein Engineering-CI hat zusätzlich eine Ressourcenzulassung: Auslesen des verfügbaren Speichers des Hosts und des freien Platzes auf dem Dateisystem des Arbeitsverzeichnisses. Unten eine schlanke Node.js-Variante für einen vertrauenswürdigen main-Branch:

name: Trusted main validation
on:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: trusted-main-validation
  cancel-in-progress: false

jobs:
  test:
    runs-on: [self-hosted, linux, x64, ci-lab]
    timeout-minutes: 12
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      - name: Check available resources
        shell: bash
        run: |
          set -euo pipefail
          mem_kib="$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo)"
          disk_kib="$(df -Pk "$GITHUB_WORKSPACE" | awk 'NR==2 {print $4}')"
          echo "available memory=${mem_kib} KiB, disk=${disk_kib} KiB"
          if [ "$mem_kib" -lt 600000 ]; then
            echo "::error::Memory below 600000 KiB: ${mem_kib} KiB"
            exit 1
          fi
          if [ "$disk_kib" -lt 5242880 ]; then
            echo "::error::Disk below 5242880 KiB: ${disk_kib} KiB"
            exit 1
          fi
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - run: npm ci
      - run: npm test

Dieses Beispiel verwendet actions/checkout@v7 und actions/setup-node@v7; sie nutzen Node.js 24 als Action-Laufzeit und erfordern mindestens Actions-Runner-Version v2.327.1. Prüfen Sie vor der Migration bestehender Rechner die Runner-Version – aktualisieren Sie nicht nur die YAML. checkout v7 und setup-node v7 haben entsprechende Hinweise.

Etwa 586 MiB verfügbarer Speicher und 5 GiB freie Platte sind die Zulassungsschwellen dieses kleinen VPS. Das Skript gibt Restmengen und Fehlergrund aus, sodass Speicher- von Plattenmangel leicht zu unterscheiden ist.

Dieser Schritt kann nur prüfen, nicht reservieren. Die concurrency-Gruppen zweier Repositories sind keine repoübergreifende globale Sperre. Meine Wahl ist, das Engineering-CI unter die CPU-/Speicher-Obergrenzen seines eigenen systemd-cgroups zu stellen – bei Ressourcenknappheit schlägt es fehl, statt unbegrenzt zu wachsen; der Produktionsdienst behält seine eigenen Berechtigungen und Ressourcengrenzen. Für große Builds oder Fälle, in denen beide Repositories strikt nie gleichzeitig laufen dürfen, ist die zuverlässigste Lösung der Wechsel zu einer einzigen kontrollierten Aufgabenwarteschlange oder einem separaten VPS, mit einem einzelnen Scheduler, der Arbeit seriell verteilt; verwechseln Sie nicht gleichnamige repoübergreifende concurrency mit einem globalen Mutex. Wer echte Host-Serialisierung will, kann einen separat geprüften flock-Wrapper-Executor entwerfen – der muss aber den Einstieg aller ressourcenintensiven Aufgaben sperren; ein Schloss nur vor einem Job bringt nichts; dieser Ansatz ist bei mir nicht im Einsatz. Der Preis dafür: Aufgaben müssen warten; der gleichzeitige Durchsatz beider Repositories bleibt nicht erhalten.

Wer auf demselben Host die schweren Schritte beider Repositories wirklich serialisieren will, kann eine separat geprüfte gemeinsame Dateisperren-Variante wählen. Lassen Sie zuerst einen Admin eine nur für Sperren gedachte Systemgruppe anlegen, beide vertrauenswürdigen Runner-Benutzer in die Gruppe aufnehmen und die gemeinsame Sperrdatei konfigurieren (nur zur Build-Koordination; der Gruppe keine Produktionsverzeichnis-Rechte geben):

sudo groupadd --system ci-queue
sudo usermod -aG ci-queue runner-a
sudo usermod -aG ci-queue runner-b
sudo install -d -o root -g ci-queue -m 3770 /var/lock/ci-queue
sudo install -o root -g ci-queue -m 0660 /dev/null /var/lock/ci-queue/build.lock

Die Kontonamen hier sind Platzhalter; nach dem Hinzufügen neuer Gruppen sollten die betreffenden Runner-Dienste in einem Wartungsfenster neu gestartet werden, damit sie die neuen Zusatzgruppen erhalten; laufende Jobs dürfen nicht leichtfertig unterbrochen werden. Dann müssen beide Repositories ihre ressourcenintensiven Befehle in den Geltungsbereich desselben Schlosses legen:

flock -w 900 /var/lock/ci-queue/build.lock \
  bash -e -c 'npm ci && npm test'

So wartet der zweite Teilnehmer höchstens 900 Sekunden und schlägt dann fehl, statt endlos zu hängen. Es schützt nur die vom selben Schloss umfassten Befehle – es serialisiert nicht automatisch alle Schritte eines GitHub-Jobs und hält einen nicht vertrauenswürdigen Workflow nicht davon ab, das Schloss bewusst zu umgehen. Auch deshalb setzen meine bestehenden Dual-Runner dieses Schloss-Schema nicht ein; es ist eine nachbaubare Variante, die eine eigenständige Bewertung braucht, kein gemessenes Ergebnis aus einem laufenden Aufbau.

Erhöhen Sie MemoryMax nicht grenzenlos, nur um einen CI-Lauf grün zu bekommen; Aufgaben, die das Spielraum dauerhaft überschreiten, gehören auf einen eigenen Rechner.

PRs und Produktion auf demselben Runner: Eine andere Risikorechnung

Beim Blick auf die bestehenden Workflows des Blogs stellte ich fest, dass Inhalts-PR-Prüfung und Produktions-Deployment weiterhin dieselben Produktions-Runner-Labels verwenden. In einem privaten Repository mit nur vertrauenswürdigen Betreuern mag das vorerst tragbar sein – als Sicherheitstutorial für offene externe Zusammenarbeit taugt es aber nicht.

Der Sicherheitsleitfaden von GitHub warnt: Persistente Self-hosted Runner, die nicht vertrauenswürdigen Code ausführen, können jobübergreifende persistente Verunreinigungen hinterlassen oder Skripte an Host-Dateien und -Berechtigungen heranlassen. Wenn künftig externe Mitarbeitende, Fork-PRs oder anderer nicht vertrauenswürdiger Code in die Validierungspipeline gelangt, sollten sie von der Produktionsmaschine getrennt werden:

不受信任 PR -> GitHub-hosted / 全新隔离 Runner -> 审查
受保护 main -> 专用生产 Self-hosted Runner -> 部署

Das bedeutet, dass ein Teil der PR-Validierung weiterhin GitHub-hosted-Minuten kosten kann. Aber fremde Skripte in die Nähe von Deployment-Anmeldedaten zu lassen, um ein paar Minuten zu sparen, ist ein schlechter Tausch. Selbst bei einem privaten Repository muss man die tatsächlichen Mitarbeitenden-Berechtigungen, den Branch-Schutz und wer Workflows ändern darf betrachten.

Runner ausgefallen oder VPS defekt: So stellt man das Deployment wieder her

Die Kosten selbstgehosteter Runner sind nicht nur die Monatsmiete – dazu kommen Ausfälle, Updates und Wiederherstellungszeit. Ein besonders praktischer Unterschied: Wenn ein Self-hosted Runner offline ist, reihen sich Jobs mit seinen Labels ein; sie fallen nicht automatisch auf ubuntu-latest zurück. Laut GitHub-Dokumentation wartet ein Job ohne verfügbaren Runner bis zu etwa 24 Stunden, bevor er fehlschlägt.

Die Wiederherstellungsreihenfolge kann einfach sein: Zuerst auf der Runners-Seite des GitHub-Repositorys prüfen, ob Online; dann CI-seitig ./svc.sh status, journalctl -u <echte-unit> -n 100 --no-pager und freien Plattenplatz ansehen; bei cgroup-OOM zuerst die Arbeitslast verkleinern oder auf Leerlauf warten, statt das Limit einfach aufzuheben. Die Runner-Anwendung aktualisiert sich normalerweise automatisch, aber nach Deaktivierung der Auto-Updates muss man Versionen selbst verfolgen; nach den offiziellen Regeln empfängt ein Runner jenseits des Update-Fensters oder bei fehlendem kritischem Sicherheitsupdate möglicherweise keine Aufgaben mehr.

Das Produktions-Deployment sollte einen klar definierten separaten Wiederherstellungskanal haben: etwa einen separat genehmigten GitHub-hosted-Build plus einen kontrollierten Deployment-Schritt hinter einem geschützten Release-Einstiegspunkt, oder nach Wiederherstellung des VPS über den bestehenden Betriebskanal erneut versuchen. GitHub-hosted Runner können standardmäßig weder auf lokale VPS-Verzeichnisse zugreifen noch die ursprünglichen Produktions-Deployment-Anmeldedaten halten, also ist ein bloßer Wechsel von runs-on auf ubuntu-latest kein funktionierender Rückfall. Bevor das nicht separat geübt wurde, beschreiben Sie diese Reserve nicht als verifizierte automatische Notfallwiederherstellung.

Ob wirklich gespart wurde, entscheidet zuletzt das Usage

Grüne Actions beweisen nur, dass Jobs fertig wurden; sie erstellen keine Monatsabrechnung. Unter GitHubs Billing & licensing → Usage die Actions-Nutzung pro Abrechnungszeitraum ansehen; für Einzelposten einen Bericht nach der offiziellen Methode exportieren. Über-quota-gehostete Minuten, Artifact-/Cache-Speicher, zusätzliche VPS-Miete und Wartungskosten getrennt berechnen.

Bei solchen Migrationen lautet meine Reihenfolge immer: Zuerst prüfen, ob die Planminuten wirklich knapp sind, dann prüfen, ob der VPS ohnehin dauerhaft läuft, und zuletzt die Vertrauensgrenze des ausgeführten Codes prüfen. Stabile, häufige, vertrauenswürdige Builds auf einem bestehenden VPS eignen sich gut für Self-hosted; Projekte mit wenig CI und reichlich ungenutzten Freiminuten fahren mit GitHub-hosted vielleicht günstiger.

Die Kostenrechnung lässt sich direkt mit eigenen Zahlen füttern: Aus dem Plan herausfallende, migrierbare gehostete Minuten pro Monat × passender SKU-Einzelpreis − neue VPS-/Betriebskosten. Zum Beispiel 1000 Minuten ausschließlich als Standard-Linux-2-Core-Übermenge: Listenpreis $6; lagen sie ohnehin im Freikontingent, kann die Grenzrechnung $0 ergeben. Nur vertrauenswürdige, wartbare, wiederherstellbare Aufgaben auf die Maschine holen, dann rechnet es sich.

Der frühere ChatGPT-Web-+-GitHub-+-Self-hosted-Runner-Praxisbericht handelte vom Entwicklungseinstieg und vom Deployment-Ausgang; hier geht es um eine andere Frage: Wie spart man GitHub-hosted-Minuten, und wie verhindert man, dass sich mehrere Runner auf einem VPS gegenseitig ausbremsen.

Zeitangabe: Abrechnung und offizielle Ankündigungen Stand 19. Oktober 2026 geprüft; Job-Zeiten aus bestehenden Ausführungsprotokollen, systemd-Werte aus bestehenden Installationsskripten – keine neuen Host-Stresstests.