Drei Go-Dienste, klare Grenzen: systemd statt Container-Stack

Drei Go-Dienste, klare Grenzen: systemd statt Container-Stack
By Matthias Petermann / on 29.08.2026

Ein statisch gelinktes Go-Binary ist selbst ausführbar. Sein Release muss weder einen Package-Manager noch einen separaten Interpreter oder einen eigenen Linux-Userspace mit /bin/sh mitbringen. Es braucht auf dem Zielsystem auch keine dynamisch gelinkten Bibliotheken in exakt passenden Versionen. Dennoch braucht ein dauerhaft laufender Dienst eine saubere Betriebsgrenze: begrenzten Speicher und CPU-Zeit, einen nicht privilegierten Benutzer, nur die tatsächlich benötigten Dateipfade, zuverlässige Neustarts und eine klare Log-Schnittstelle.

Ein Container-Stack kann das leisten, ist aber nicht immer die kleinste sichere Betriebsform. Auf einem einzelnen systemd-basierten Linux-Host bringen die vorhandenen Mechanismen vieles davon bereits mit: nicht als Ersatz für OCI-Container oder Kubernetes, sondern als bewusst kleiner Betriebsvertrag direkt auf dem Host.

Warum eigenständige Binaries wieder attraktiv sind

Diese Art von Deployment ist nicht neu, erlebt mit Go aber eine bemerkenswerte Renaissance. Ein Go-Programm kann mit deaktiviertem CGO statisch gelinkt werden, Assets mit embed direkt enthalten und per Cross-Compilation für eine bestimmte Kombination aus Betriebssystem und Architektur gebaut werden. Aus einem Quellstand entstehen so klar definierte Releases für etwa Linux auf x86_64 oder ARM, ohne dass auf dem Zielsystem eine Go-Installation oder ein separater Runtime-Dateibaum nötig ist.

Das ist keine magische Plattformunabhängigkeit: Ein Linux-ARM-Binary läuft nicht auf Windows oder x86_64. Go reduziert aber die Reibung, für jedes Ziel ein passendes, kleines Release zu erzeugen. Für Dienste mit eingebetteter Weboberfläche, Templates oder Standardkonfiguration bedeutet das eine angenehme Developer Experience: bauen, ausliefern, als Dienst starten.

🤔 Die Rückkehr eines vertrauten Gefühls
Früher lieferte etwa Turbo Pascal ein ähnliches Versprechen: Quelltext, Ressourcen und Compiler ergaben eine ausführbare Datei, die sich ohne separate Laufzeitumgebung verteilen ließ. Go ist technisch und architektonisch etwas ganz anderes, bringt aber für viele Infrastruktur- und Edge-Dienste dieses direkte Gefühl zurück. Der Unterschied heute: Die Binärdatei trifft auf moderne Linux-Grenzen wie cgroups, Namespaces und deklaratives Service-Management.

Ein Fallbeispiel mit drei unterschiedlichen Rechten

Auf einem einzelnen Home-Server laufen drei statisch gelinkte Go-Dienste: Governer führt Regeln und Automatisierungen aus, Console bietet Bedienung und Beobachtung, Meterbridge liest zwei optische Zählerports. Gerade diese unterschiedlichen Rechte machen die drei Dienste zu einem guten Beispiel: Zwei benötigen Netzwerkzugriff und getrennten State, einer zusätzlich zwei konkrete Geräte.

Verteilungsdiagramm eines Linux-Hosts mit systemd, Governer, Console, Meterbridge, MQTT-Broker und zwei Smart-Metern
Beispiel als Verteilungsdiagramm
🤔 Ein Beispiel aus einer entstehenden Plattform
Governer, Console und Meterbridge sind Komponenten meiner eigenen, noch entstehenden Smart-Home-Plattform. Sie dienen hier als konkretes Beispiel für Regeln, Bedienung und Hardware-Anbindung. Der Fokus liegt auf dem Betriebsmodell, nicht auf einem fertigen Produkt oder einer universellen Referenzarchitektur. Den fachlichen Hintergrund beschreibt Ein Smart Home, das sich wie ein Zuhause anfühlt.

Die Anwendung muss dafür mitspielen

systemd kann einen Prozess gut begrenzen, aber keine unklare Anwendung reparieren. Die drei Dienste sollten einige einfache Eigenschaften erfüllen:

Eigenschaft Warum sie den Betrieb vereinfacht
Ein Prozess pro Dienst systemd überwacht eindeutig den richtigen Hauptprozess.
Konfiguration außerhalb des Binaries Dieselbe Release-Datei läuft mit anderer Konfiguration in einer anderen Umgebung.
State in einem definierten Pfad Schreibrechte bleiben auf genau einen Ort begrenzt.
Logs nach stdout/stderr journalctl wird zur zentralen Schnittstelle für die von den Diensten ausgegebenen Logs.
SIGTERM sauber verarbeiten Updates und Neustarts beenden HTTP-Server, MQTT-Verbindungen und serielle Ports kontrolliert.
Statisch gelinkt, wenn möglich Das Deployment besteht tatsächlich nur aus dem Binary und nicht aus einem versteckten Bibliotheksbaum.

Das ergänzt die Prinzipien aus Die 12-Factor App in Go: Konfiguration bleibt vom Code getrennt, Logs sind ein Ereignisstrom und der Prozess ist disposabel. Der Unterschied liegt im Ziel: nicht eine beliebige Cloud-Plattform, sondern ein einzelner, bewusst einfach gehaltener Linux-Host.

Für Go ist ein portables Linux-Release beispielsweise so baubar:

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
  go build -trimpath -ldflags='-s -w' -o dist/governer ./cmd/governer

CGO_ENABLED=0 ist kein Dogma. Braucht ein Dienst bewusst CGO, etwa wegen eines Hardware- oder Datenbanktreibers, ist das legitim. Dann müssen Loader und Bibliotheken jedoch Teil des Deployment-Vertrags sein. ldd dist/governer macht sichtbar, ob das Ergebnis wirklich keine dynamischen Abhängigkeiten hat.

Was systemd bereits als Grenze liefert

Die Einstellungen stammen überwiegend aus systemd.exec(5). Sie sind kein zusätzlicher Daemon und keine besondere systemd-Edition, sondern Optionen im [Service]-Abschnitt einer normalen Unit.

ℹ️ Der Ausgangspunkt
Ein systemd-Service besitzt immer eine eigene cgroup. Ressourcenlimits, Restart-Verhalten, Logs und viele Linux-Sandbox-Mechanismen lassen sich deshalb deklarativ in einer Unit beschreiben. Für drei lokale, eigenständige Go-Dienste ist das oft einfacher und besser nachvollziehbar als ein vollständiger Container-Stack.
Einstellung Wirkung
DynamicUser=yes systemd vergibt zur Laufzeit eine nicht privilegierte UID; ein dauerhaftes Benutzerkonto ist nicht nötig.
StateDirectory= erzeugt einen persistenten, passend berechtigten State-Pfad unter /var/lib/.
ProtectSystem=strict macht das Dateisystem für den Dienst weitgehend read-only.
ProtectHome=yes blendet Home-Verzeichnisse aus.
PrivateTmp=yes schafft ein eigenes /tmp und /var/tmp.
PrivateDevices=yes stellt nur einen sehr kleinen Gerätebestand bereit. Zusätzliche Geräte müssen explizit zugelassen und eingebunden werden.
NoNewPrivileges=yes verhindert, dass der Prozess durch setuid-Dateien oder File-Capabilities neue Privilegien gewinnt.
CapabilityBoundingSet= entfernt Linux-Capabilities, die der Dienst nicht benötigt.
RestrictAddressFamilies= beschränkt, welche Socket-Arten ein Prozess öffnen darf.
MemoryMax=, CPUQuota=, TasksMax= setzen Grenzen für Speicher, CPU-Zeit und Anzahl von Threads beziehungsweise Prozessen.

Das sind echte technische Grenzen, keine Konventionen für Entwickler. Sie ersetzen dennoch kein Threat Model: Ein Dienst mit Zugriff auf ein sensibles MQTT-Konto, einen beschreibbaren Host-Mount oder physische Aktoren kann trotz Sandbox Schaden anrichten. Die Grenze muss immer zu den vergebenen Rechten passen.

ℹ️ Nicht jede Direktive schützt dasselbe
MemoryMax= wirkt über die cgroup, ProtectSystem= über einen Mount-Namespace, DynamicUser= über Unix-Dateirechte und NoNewPrivileges= gegen spätere Privileggewinne. Erst die Kombination begrenzt einen Dienst sinnvoll. Keine einzelne Einstellung macht aus einem Prozess einen Container oder eine VM.

Ein einfacher Release-Vertrag

Die Auslieferung lässt sich ohne Image und ohne eigenen Deployment-Daemon klar halten. Binaries liegen unter /usr/local/libexec, Konfiguration unter /etc und nur systemd verwaltet Laufzeit-State unter /var/lib.

/usr/local/libexec/governer
/usr/local/libexec/console
/usr/local/libexec/meterbridge
/etc/governer/governer.env
/etc/console/console.env
/etc/meterbridge/meterbridge.env
/etc/systemd/system/governer.service
/etc/systemd/system/console.service
/etc/systemd/system/meterbridge.service
/var/lib/governer/       # von systemd angelegt
/var/lib/console/        # von systemd angelegt

Ein Release ist dann kein Container-Layer, sondern der Austausch einer eindeutigen Datei. Die Konfiguration bleibt erhalten und wird nicht mit dem Binary vermischt:

sudo install -m 0755 dist/governer /usr/local/libexec/governer
sudo install -m 0755 dist/console /usr/local/libexec/console
sudo install -m 0755 dist/meterbridge /usr/local/libexec/meterbridge
sudo systemctl restart governer console meterbridge

In einem automatisierten Deployment gehören Prüfsumme, Signatur oder ein versionsiertes Release-Verzeichnis dazu. Entscheidend ist die Trennung: Build erzeugt Binaries, Konfigurationsmanagement verwaltet /etc, systemd führt aus. Damit bleibt der gleiche Vorteil erhalten, der auch hinter deklarativen Quadlets steckt: Der gewünschte Betriebszustand ist als Text nachvollziehbar und mit den üblichen Systemwerkzeugen bedienbar.

Gemeinsame Basis für die Services

Die folgenden Units verwenden bewusst nur Härtungen, die zu diesen drei Diensten passen. Sie benötigen Netzwerkzugriff für MQTT, HTTP oder Prometheus; PrivateNetwork=yes wäre deshalb ohne zusätzliches Routing falsch. Das Host-Netz wird bewusst geteilt, während Dateisystem, Benutzer, temporäre Dateien und Gerätezugriff begrenzt werden.

Wants=network-online.target und After=network-online.target ordnen nur den Startvorgang. Sie garantieren weder funktionierendes DNS noch einen erreichbaren MQTT-Broker. Jeder Dienst muss Verbindungen deshalb selbst robust wieder aufbauen können.

Alle Dienste loggen nach stdout und stderr. Der Alltag bleibt damit klein:

sudo systemctl enable --now governer console meterbridge
sudo systemctl status governer
sudo journalctl -u meterbridge -f
systemd-cgtop

systemd-cgtop zeigt die tatsächliche Ressourcennutzung nach cgroups. Die Limits selbst stehen in systemd.resource-control(5). Sie sollen aus Messwerten entstehen, nicht aus Wunschdenken: zunächst beobachten, eine sinnvolle Obergrenze setzen und Lastspitzen gezielt testen.

💡 Härtung sichtbar prüfen
systemd-analyze security governer.service bewertet die gesetzten Sandbox-Optionen und zeigt fehlende Schutzmaßnahmen. Das ist ein hilfreicher Konfigurationscheck, aber kein Ersatz für ein Threat Model oder einen Test der tatsächlich benötigten Rechte.

Governer: Regeln und Automatisierung

Governer wertet MQTT-Zustände aus und publiziert fachliche Befehle. Er ist damit ein wichtiger Dienst, aber nicht der Ort für die unmittelbare Grundfunktion eines Aktors. Die lokale Aktorlogik und das klare Modell aus state/, config/ und cmd/ bleiben bei den Komponenten, wie im Beitrag Ein Smart Home, das sich wie ein Zuhause anfühlt beschrieben. Fällt Governer aus, endet also Komfortautomatisierung, nicht die Bedienbarkeit des Hauses.

# /etc/systemd/system/governer.service
[Unit]
Description=Governer Smart-Home Rules and Automation Engine
Wants=network-online.target
After=network-online.target

[Service]
Type=exec
ExecStart=/usr/local/libexec/governer
EnvironmentFile=/etc/governer/governer.env
DynamicUser=yes
StateDirectory=governer
StateDirectoryMode=0750

Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s

MemoryMax=256M
CPUQuota=50%
TasksMax=64

NoNewPrivileges=yes
CapabilityBoundingSet=
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6

[Install]
WantedBy=multi-user.target

StateDirectory=governer ist der einzig vorgesehene persistente Schreibpfad. Das Binary muss deshalb seinen optionalen State unter /var/lib/governer ablegen. Regeldefinitionen können im geschützten /etc/governer liegen. Zugangsdaten sollten nicht unbedacht in EnvironmentFile= landen: systemd liest die Datei zwar vor dem Prozessstart, übergebene Umgebungsvariablen sind aber leichter versehentlich sichtbar als gezielt bereitgestellte Dateien. LoadCredential= ist für MQTT-Passwörter oder Zertifikate meist die bessere Schnittstelle.

Console: Bedienung und Beobachtung

Console stellt die lokale Weboberfläche bereit, beobachtet MQTT und exportiert gegebenenfalls Metriken. Sie darf fachlich erlaubte Wünsche auslösen, besitzt aber keine versteckte zweite Aktorlogik. Das ist dieselbe bewusste Trennung, die die Smart-Home-Architektur bei einem Neustart der Oberfläche handlungsfähig hält.

# /etc/systemd/system/console.service
[Unit]
Description=Console Smart-Home UI and Monitoring
Wants=network-online.target
After=network-online.target

[Service]
Type=exec
ExecStart=/usr/local/libexec/console
EnvironmentFile=/etc/console/console.env
DynamicUser=yes
StateDirectory=console
StateDirectoryMode=0750

Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s

MemoryMax=256M
CPUQuota=50%
TasksMax=96

NoNewPrivileges=yes
CapabilityBoundingSet=
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6

[Install]
WantedBy=multi-user.target

Die Unit veröffentlicht den HTTP-Port bewusst direkt im Host-Netz. Auf einem einzelnen Home-Server ist das oft der einfachste und transparenteste Weg. Firewall, Bind-Adresse, Authentisierung und TLS bestimmen gemeinsam, aus welchen Netzen und unter welchen Bedingungen Console erreichbar ist. Soll die Oberfläche ausschließlich lokal erreichbar sein, bindet die Anwendung auf 127.0.0.1 oder ::1; ein Reverse Proxy kann den externen Zugriff übernehmen.

Meterbridge: zwei serielle Ports, sonst nichts

Meterbridge braucht zusätzlich Zugriff auf zwei USB-IEC-Leseköpfe. Hier ist es besonders wichtig, nicht einfach den gesamten Host-/dev-Baum freizugeben. PrivateDevices=yes erzeugt eine reduzierte Gerätelandschaft. Die zwei benötigten Geräte werden anschließend explizit eingebunden und per cgroup-Gerätepolicy freigegeben.

# /etc/systemd/system/meterbridge.service
[Unit]
Description=Meterbridge Smart-Meter to MQTT Gateway
Wants=network-online.target
After=network-online.target
ConditionPathExists=/dev/ttyUSB0
ConditionPathExists=/dev/ttyUSB1

[Service]
Type=exec
ExecStart=/usr/local/libexec/meterbridge
EnvironmentFile=/etc/meterbridge/meterbridge.env
DynamicUser=yes
SupplementaryGroups=dialout

Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s

MemoryMax=128M
CPUQuota=25%
TasksMax=32

NoNewPrivileges=yes
CapabilityBoundingSet=
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
BindPaths=/dev/ttyUSB0
BindPaths=/dev/ttyUSB1
DeviceAllow=/dev/ttyUSB0 rw
DeviceAllow=/dev/ttyUSB1 rw
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6

[Install]
WantedBy=multi-user.target

Die Kombination ist wichtig: BindPaths= macht die Geräte im privaten Geräte-Namespace sichtbar, DeviceAllow= erlaubt ihren Zugriff in der cgroup und SupplementaryGroups=dialout erfüllt die normalen Unix-Dateirechte vieler Distributionen. Fehlt eine dieser drei Ebenen, kann der Dienst den Port trotz scheinbar korrekter Konfiguration nicht öffnen.

/dev/ttyUSB0 und /dev/ttyUSB1 sind nur zur Lesbarkeit im Beispiel gewählt. Ihre Nummerierung kann sich nach einem Neustart ändern. Im produktiven Setup sollte eine udev-Regel für jeden Lesekopf einen stabilen Namen erzeugen, etwa /dev/smartmeter/main und /dev/smartmeter/solar. Diese stabilen Pfade werden dann durchgängig in ConditionPathExists=, BindPaths=, DeviceAllow= und der Meterbridge-Konfiguration verwendet. Die Regel muss dazu auf stabile Eigenschaften wie Seriennummer oder USB-Portpfad matchen, nicht auf die wechselnde ttyUSB-Nummer. Soll Meterbridge auch nach einem späteren Einstecken automatisch starten, braucht es zusätzlich eine udev-getriggerte Startlogik oder eine geeignete Geräteabhängigkeit; ConditionPathExists= allein überspringt den Start beim Boot und reagiert nicht auf Hotplug.

⚠️ Hardwarezugriff ist eine echte Berechtigung
Ein serieller Port ist keine harmlose Datei. Wer Meterbridge verändern kann, kann damit das Protokoll am physisch angeschlossenen Gerät beeinflussen. Die Unit begrenzt den Zugriff auf zwei konkrete Pfade, ersetzt aber weder den Schutz des Deployment-Wegs noch die Prüfung der udev-Regeln.

Die passende Grenze wählen

Isolation ist keine eindimensionale Treppe. Jede Stufe schützt andere Ressourcen und bringt andere Betriebsarbeit mit. Ein OCI-Container teilt mit dem Host weiterhin den Kernel; eine VM trennt den Kernel, macht aber Netzwerk, Images und Patchen nicht automatisch sicher. Umgekehrt kann eine konsequent gehärtete systemd-Unit für einen einzelnen Dienst wirkungsvoller sein als ein Container mit großzügigen Mounts und Root-Rechten.

Die Ampeln bewerten die jeweilige Spalte: 🟢 steht für eine starke Eigenschaft, 🟡 für einen bewussten Kompromiss und 🔴 für eine schwache Eigenschaft. Die rechte Spalte beschreibt, wann dieser Kompromiss sinnvoll ist.

Mechanismus Einfacher Betrieb Technische Trennung Wann er passt
Host-Binary 🟢 🔴 Einmalige, vollständig vertrauenswürdige Admin-Werkzeuge oder lokale Entwicklung. Für dauerhafte Dienste fehlen Ressourcen-, Benutzer-, Dateisystem- und Prozessgrenzen.
chroot 🟢 🔴 Ein älterer Softwarebestand oder ein Build-Schritt benötigt einen abweichenden Wurzelpfad. Es ist kein Sicherheitscontainer: Prozesse, Netzwerk und viele Host-Eigenschaften bleiben sichtbar.
Gehärtete systemd-Unit 🟢 🟡 Eigenständige, statisch gelinkte Go-Dienste auf einem Host. cgroups, dynamische UID, Mount- und Gerätebeschränkungen sind deklarativ in einer Unit sichtbar.
systemd-nspawn 🟡 🟡 Eine Appliance oder Legacy-Workload braucht eigenen Root-Dateibaum, PID- und Mount-Namespaces oder ein optional getrenntes Netzwerk. Der Host-Kernel bleibt geteilt.
OCI-Container mit Quadlet 🟡 🟡 Images sind bereits das Lieferformat oder die Anwendung bringt mehrere Laufzeitabhängigkeiten mit. Image, Registry und Runtime gehören dann zur Lieferkette.
Virtuelle Maschine 🔴 🟢 Unterschiedliche Vertrauenszonen, fremder Code, ein eigener Kernel oder harte Mandantentrennung rechtfertigen den höheren Ressourcen- und Patch-Aufwand. Hypervisor-, Netzwerk- und Passthrough-Konfiguration bleiben Teil der Sicherheitsgrenze.

Die Tabelle ist keine Rangliste. Die Frage lautet nicht “Was ist am modernsten?”, sondern: Welche Ressource muss vor welchem Fehler oder Angreifer geschützt werden?

Für Governer, Console und Meterbridge ist die gehärtete systemd-Unit die kleinste passende Stufe. Die Dienste sind eigene Binaries, ihre Zuständigkeiten sind bereits getrennt und nur Meterbridge benötigt zwei explizite Hardware-Ressourcen. Ein zusätzlicher Root-Dateibaum, eine Registry oder eine Container-Runtime würde im aktuellen Modell mehr Betriebsfläche schaffen, ohne eine relevante Anforderung besser abzudecken.

💡 Sicherheit ohne unnötige Schichten
Keine Kompromisse bei Sicherheit bedeutet nicht automatisch die schwerste Isolationsstufe. Es bedeutet, Rechte standardmäßig zu verweigern und nur gezielt zu vergeben: eigene Laufzeit-UID, klarer State-Pfad, minimale Capabilities, konkrete Geräte, begrenzte Ressourcen, gepflegte Releases und nachvollziehbare Konfiguration. Eine VM ist notwendig, wenn das Threat Model eine Kernelgrenze fordert; davor ist sie kein Ersatz für diese Grundlagen.

Der Wechselpunkt zu nspawn, Quadlet oder VM

Die drei systemd-Units sind kein Sackgassen-Design. Wenn sich die Anforderungen ändern, ist der nächste Schritt klar statt reflexhaft:

  • Ein Dienst benötigt einen fremden oder historisch gewachsenen Linux-Userspace: systemd-nspawn gibt ihm einen eigenen Root-Dateibaum und zusätzliche Namespaces, ohne daraus gleich eine Container-Plattform zu machen.
  • Die Anwendung wird mit Bibliotheken, Hilfsprogrammen und reproduzierbaren Images ausgeliefert: Ein OCI-Image mit einem Quadlet macht diesen Liefervertrag deklarativ und weiterhin über systemctl bedienbar.
  • Der Dienst verarbeitet nicht vertrauenswürdigen Code, braucht einen eigenen Kernel oder trennt Mandanten: Eine VM schafft die angemessene Virtualisierungsgrenze.

systemd-nspawn und Quadlets sind damit keine konkurrierenden Antworten auf dieses Setup. Sie sind weitere, klar abgegrenzte Werkzeuge, wenn der Schutz- oder Lieferbedarf über eine gehärtete Prozessgrenze hinauswächst.

Der Maßstab bleibt: die kleinste Grenze wählen, die das konkrete Risiko zuverlässig behandelt. Für Governer, Console und Meterbridge besteht der sichtbare Betriebsvertrag deshalb aus drei Unit-Dateien, drei Konfigurationsverzeichnissen und drei Binaries. Start, Stop, Logs, Limits und die erlaubten Host-Ressourcen bleiben überall an einer Stelle nachvollziehbar: systemctl, journalctl und cgroups.

Referenzen


Bildnachweis: Tux, das Linux-Maskottchen, wurde von Larry Ewing erstellt (unter Verwendung von GIMP, 1996); Verwendung im Rahmen redaktioneller Berichterstattung.