Drei Go-Dienste, klare Grenzen: systemd statt Container-Stack
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.
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.
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.
| 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.
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.
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.
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.
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-nspawngibt 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
systemctlbedienbar. - 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
- systemd.exec(5)
- systemd.resource-control(5)
- systemd.service(5)
- systemd-nspawn(1)
- Arch Wiki: systemd-nspawn
Bildnachweis: Tux, das Linux-Maskottchen, wurde von Larry Ewing erstellt (unter Verwendung von GIMP, 1996); Verwendung im Rahmen redaktioneller Berichterstattung.