Giteas act_runner rootless ausführen

5 Minuten Lesezeit

In diesem Artikel zeige ich, wie ich den act_runner meiner Gitea-Instanz von gitea/act_runner auf gitea/act_runner:latest-dind-rootless umstelle.

Warum dieser Aufwand? Weil act_runner in Docker läuft und Zugriff auf den Daemon per /var/run/docker.sock als sogenanntes Volume (gemeint im Sinne von Datenträger) bzw. Bind-Mount benötigt, um zu funktionieren. Der Besitzer dieses Sockets ist root, wodurch der Container praktisch Vollzugriff auf das Hostsystem erhält.

“RULE #1 - Do not expose the Docker daemon socket (even to the containers)” - OWASP / Docker Security Cheat Sheet

Dies ist hoch riskant, denn es liegt im Prinzip des Runners, von Dritten erzeugten Job-Code auszuführen. Ein offenes Einfallstor. In Verbindung mit direktem Durchgriff auf das Hostsystem kann das im Falle eines erfolgreichen Angriffes einen Totalausfall bzw. die “feindliche Übernahme” meiner Infrastruktur bedeuten.

Gefahr durch docker.sock

Ich richte mich in dieser Anleitung nach der Empfehlung von OWASP zum Thema Docker-Sicherheit und wende deren Regel(n) in der Praxis an. OWASP steht für “Open Web Application Security Project”. Es ist eine Organisation, die sich für höhere Sicherheit von Webanwendungen einsetzt und dabei Anwender wie mich mit kostenfreien Artikeln, Dokumentation und Technologie unterstützt.

Die unter Regel 1 beschriebenen Maßnahmen lauten:

Dockers tcp Socket abgeschaltet lassen

Sollte der Zugang zum Docker Daemon per tcp eingeschaltet sein, kann sich mit diesem - sofern keine weiteren Vorkehrungen getroffen wurden - über eine ungesicherte Verbindung und ohne Authentisierung verbunden werden. Der Daemon ist dann durch praktisch jeden Internetnutzer zugänglich und damit angreifbar.

Wie stelle ich also sicher, dass der tcp Socket abgeschaltet ist? Die Anleitung hierzu findet sich in den Docker Docs und muss quasi verkehrt herum angewandt werden. Auch dort wird ausdrücklich vor ungeschützter Verfügbarmachung des tcp-Sockets gewarnt.

Nach Ausführen aller erforderlichen Schritte lasse ich netstat einmal durchlaufen. Es listet mir offene Sockets, Netzwerkschnittstellen und Routing-Tabellen auf:

# check if Docker Daemon's "dockerd" tcp socket is exposed
schallbert@machine:~# netstat -lntp | grep dockerd
# test is a pass if this command does not return anything.

Bei mir findet sich kein Eintrag mit dockerd. Mein Server ist an dieser Stelle schon einmal nicht verwundbar.

Den Docker Socket /var/run/docker.sock nicht in andere Container einbinden

Hier wird es schon etwas schwieriger: act_runner benötigt den Socket, um Job-Container zu erzeugen, zu verwalten und schließlich wieder zu entsorgen. Ohne Zugriff auf den Docker Daemon über den Socket funktioniert die Build-Pipeline schlicht und einfach nicht - es sei denn, man möchte komplett auf Docker verzichten und sowohl den Runner als auch die Build-Jobs direkt auf der Host-Maschine laufen lassen. Damit gingen aber viele Nachteile einher: Verlust der Kapselung, keine Portabilität, schlechte Skalierbarkeit, geringere Sicherheit…

Aber auch für die Option mit Docker gibt es eine Lösung: act_runner Docker-in-Docker aufsetzen. Mit diesem Setup erhält act_runner einen eigenen Docker Daemon, jedoch mit eingeschränkten Rechten und ohne Zugriff auf den des Host-Systems. Dieser übernimmt dann den Lebenszyklus von Job-Containern, sodass sie komplett unabhängig vom Host-System laufen.

Docker in Docker

Folgendes Schaubild verdeutlicht den Unterschied:

Image: act_runner with standard configuration versus DinD-rootless. The latter has an isolated Docker Daemon running within the act_runner container.

Act_runner “rootless” starten

Also folge ich der Anleitung und kopiere mir eine passende docker-compose.yml zusammen. Wichtig hier:

  1. Es muss privileged: true gesetzt werden. Ansonsten kann der Docker Daemon im act_runner-Container nicht richtig starten, da ihm Kernel-Funktionen fehlen. Somit schmiert der ganze Container wieder und wieder ab, ohne dabei hilfreiche Fehlermeldungen zu erzeugen.
  2. Die Umgebungsvariable DOCKER_HOST=unix:///var/run/user/1000/docker.sock muss gesetzt sein. Hier wird der Docker Socket über einen nicht-privilegierten User gesteuert und steht dem Runner für die Verwaltung von Job Containern zur Verfügung. Der Daemon läuft gekapselt im Container und steht nicht in Verbindung mit der Host-Maschine.

Stand Jul-2025: Problem mit 0.2.12-dind-rootless

Hier hatte ich direkt mein erstes Problem. Der Runner stürzt kurz nach dem Start mit folgender Fehlermeldung ab:

schallbert@machine:~# docker logs gitea-runner
# [...]
[rootlesskit:parent] error: failed to start the child: fork/exec /proc/self/exe: operation not permitted

Hilfe kam aus der Gitea-Community. Mit der Vorgängerversion 0.2.11 läuft act_runner sauber hoch. Trotzdem wird er aus mir unbekannten Gründen auf Gitea als 0.2.12 angezeigt.

Image: DinD-rootless runner is now working fine

Volume-Verwirrung

Prima! Da der Runner nun läuft, lasse ich ihn direkt mal auf einen Job los. Leider schlägt das Bauen bereits nach Sekundenbruchteilen mit dieser Meldung fehl:

# Runner step: Set up job
failed to start container: Error response from daemon: error while creating mount source path '<volumeSourceFullPath>': mkdir <volumeSourcePath>: permission denied

Hier habe ich stundenlang recherchieren müssen und war lange der falschen Überzeugung, dass es an fehlenden Berechtigungen bei Ordnern auf der Host-Maschine liegt. Später erst verstand ich wirklich, dass Docker-in-Docker genau so gemeint ist, wie es heißt: Nicht nur werden Container durch Container erzeugt, sondern innerhalb des Containers läuft ein separater Docker Daemon!

Damit funktioniert nämlich der klassische Weg nicht mehr, Volumes in Job Containern direkt vom Host-System per -v /a/b:/x/y verfügbar zu machen. Stattdessen müssen Volumes nun durchgereicht werden. Beispiel:

host directory /opt/server/www/blog-artifacts -> act_runner volume /tmp/blog-artifacts:z –> job container volume /tmp/blog-artifacts

Das :z im Volume vom act_runner wird jetzt wichtig. Es zeigt Docker gegenüber an, dass dieses Volume zwischen Containern geteilt wird. Diese Volumes müssen nicht nur in der docker-compose.yml von act_runner angegeben werden, sondern auch die Job-Skripte im Ordner .gitea/workflows/ sind jetzt entsprechend anzupassen. Hier wird das :z auf der “rechten Seite” hingegen nicht benötigt.

“is not a valid volume”

Allem Aufwand zum Trotz laufen meine Jobs noch immer nicht durch. Dieses Mal wegen einer Fehlermeldung, die mir bereits bekannt vorkam:

# Runner step: Set up job
[/tmp/blog-artifacts] is not a valid volume, will be ignored

Also rein in die config.yml von act_runner und die Volume-Namen hinzufügen:

# /gitea/runner/config.yml
# [...]
  valid_volumes: ["/tmp/blog-artifacts", "/tmp/lectures-artifacts"]
# [...]

In dieser Datei kann ich privileged: false stehen lassen, denn der Job Container selbst benötigt anders als act_runner keine Kernel-Features.

Berechtigungen korrekt setzen

Nun erhalte ich erneut Permission Denied Fehlermeldungen beim Ausführen meiner Jobs, wenn auch nicht direkt im ersten Schritt der Aktionen. Da ich die Volume-Pfade jetzt bis ins Kleinste geprüft habe, kann es eigentlich nur noch an den Ordnerberechtigungen auf der Host-Maschine liegen.

Damit die vom Job-Container erzeugten Artefakte durch die Volumes auf meinem Host abgelegt werden können, muss ich das zu schreibende Verzeichnis und alle Unterordner -R an den vorher definierten, nicht-privilegierten User ID=1000 übergeben:

schallbert@machine:~# chown -R 1000:1000 /target/path/to/artifact/

Endlich funkioniert alles reibungslos und ich habe der (unwahrscheinlichen, aber möglichen) Übernahme meines Host-Systems durch bösartige Job-Container einen Riegel vorgeschoben.