Zum Inhalt springen
SONOYA.com
Raspberry Pi & Linux

Eigenen systemd-Dienst anlegen: Skripte dauerhaft starten

Python-Skript oder Bot als systemd-Dienst: startet beim Booten und nach Absturz neu, schreibt Logs. Mit getesteter Unit-Vorlage und Timer.

Von

Terminal-Symbol auf farbigem Verlauf für einen eigenen systemd-Dienst, der ein Skript unter Linux beim Booten startet

Dein Python-Skript läuft, solange das Terminal offen ist. Schließt Du die SSH-Sitzung, ist es weg. Startet der Raspberry Pi neu, musst Du es von Hand wieder anwerfen, und stürzt es nachts ab, merkst Du es erst am Morgen. Die Lösung für all das ist ein eigener systemd-Dienst. systemd ist der Dienstverwalter, der auf praktisch jedem aktuellen Linux läuft, auf Raspberry Pi OS genauso wie auf Debian und Ubuntu. Er startet Dein Skript beim Booten, startet es nach einem Absturz neu und sammelt seine Ausgaben in einem Log.

In dieser Anleitung legen wir Schritt für Schritt einen Dienst für ein Python-Skript an, zum Beispiel einen Bot, erklären jede Zeile der Unit-Datei und richten am Ende einen Timer als Ersatz für einen Cronjob ein. Getestet habe ich alle Dateien und Befehle unter Debian 13 „Trixie“ mit systemd 257 auf arm64, also auf derselben Basis wie das aktuelle Raspberry Pi OS. Dort habe ich auch den automatischen Neustart nach einem Absturz und die typischen Fehlermeldungen nachgestellt.

Das Skript an seinen Platz bringen

Ein Dienst sollte nicht aus Deinem Home-Verzeichnis laufen und nicht als root. Wir legen deshalb einen eigenen Systembenutzer an, der sich nicht anmelden kann und nur für dieses Skript da ist. Sein Home-Verzeichnis /opt/meinbot ist gleichzeitig der Ordner für das Skript:

Code
sudo useradd --system --create-home --home-dir /opt/meinbot --shell /usr/sbin/nologin meinbot

Ersetze meinbot hier und in allen folgenden Befehlen durch einen Namen, der zu Deinem Projekt passt. Kopiere dann Dein Skript in den Ordner und lege eine virtuelle Python-Umgebung an, damit die Pakete des Skripts nicht mit dem System kollidieren:

Code
sudo cp bot.py /opt/meinbot/
sudo python3 -m venv /opt/meinbot/venv
sudo /opt/meinbot/venv/bin/pip install python-telegram-bot

Die letzte Zeile ist ein Beispiel. Installiere dort die Pakete, die Dein Skript braucht. Geheime Werte wie ein Bot-Token gehören nicht ins Skript, sondern in eine eigene Datei, die nur der Dienstbenutzer lesen darf. Öffne sie mit sudo nano /opt/meinbot/.env und trag eine Zeile pro Wert ein, etwa BOT_TOKEN=123456:ABC. Zum Schluss gibst Du dem Benutzer den Ordner und schützt die Datei mit dem Token:

Code
sudo chown -R meinbot:meinbot /opt/meinbot
sudo chmod 600 /opt/meinbot/.env

Wie Du so einen Bot überhaupt baust, zeigt Telegram-Bot mit Python bauen: Anleitung für Einsteiger.

Die Unit-Datei schreiben

Eigene Dienste gehören nach /etc/systemd/system/. Der Dateiname ist gleichzeitig der Name des Dienstes und endet auf .service. Öffne die Datei mit:

Code
sudo nano /etc/systemd/system/meinbot.service

Füge diesen Inhalt ein:

Code
[Unit]
Description=Mein Python-Bot
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Type=exec
User=meinbot
Group=meinbot
WorkingDirectory=/opt/meinbot
EnvironmentFile=-/opt/meinbot/.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/opt/meinbot/venv/bin/python /opt/meinbot/bot.py
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target

Speichere mit Strg + O, bestätige mit Enter und schließe mit Strg + X.

Was jede Zeile bewirkt

Im Abschnitt [Unit] steht, was der Dienst ist und wann er starten darf:

  • Description ist der Name, den Du später in Statusmeldungen und Logs siehst.
  • Wants=network-online.target und After=network-online.target sorgen gemeinsam dafür, dass der Bot erst startet, wenn das Netzwerk wirklich steht. Ein Bot, der vor dem WLAN startet, scheitert sonst beim ersten Verbindungsversuch. After allein legt nur die Reihenfolge fest, erst Wants holt das Netzwerk-Ziel überhaupt dazu.
  • StartLimitIntervalSec=300 und StartLimitBurst=5 sind die Notbremse: Stürzt der Dienst innerhalb von fünf Minuten mehr als fünfmal ab, gibt systemd auf, statt ihn endlos neu zu starten.

Im Abschnitt [Service] steht, wie das Skript läuft:

  • Type=exec meldet den Dienst erst dann als gestartet, wenn das Programm wirklich ausgeführt werden konnte. Ein Tippfehler im Pfad fällt so sofort beim Start auf. Die systemd-Doku empfiehlt diesen Typ für dauerhaft laufende Dienste.
  • User und Group lassen das Skript als der eben angelegte Benutzer laufen statt als root.
  • WorkingDirectory ist der Ordner, in dem das Skript startet. Relative Pfade im Skript, etwa zu einer Datenbankdatei, beziehen sich darauf.
  • EnvironmentFile liest die Werte aus der .env-Datei als Umgebungsvariablen ein. Das Minus vor dem Pfad bedeutet: Fehlt die Datei, startet der Dienst trotzdem.
  • Environment=PYTHONUNBUFFERED=1 sorgt dafür, dass jede Ausgabe von print() sofort im Log landet. Ohne diese Zeile puffert Python die Ausgaben, und das Log bleibt scheinbar leer.
  • ExecStart ist der eigentliche Befehl, immer mit vollständigen Pfaden. Weil wir den Python-Interpreter aus der virtuellen Umgebung aufrufen, nutzt das Skript automatisch deren Pakete. Ein source venv/bin/activate brauchst Du nicht.
  • Restart=on-failure startet das Skript neu, wenn es mit einem Fehler endet, und RestartSec=10 wartet davor zehn Sekunden.

Der Abschnitt [Install] mit WantedBy=multi-user.target sagt schließlich, dass der Dienst beim normalen Systemstart mit hochfahren soll.

Prüfen, aktivieren, starten

Bevor Du den Dienst startest, lass systemd die Datei prüfen:

Code
sudo systemd-analyze verify /etc/systemd/system/meinbot.service

Kommt keine Ausgabe, ist alles in Ordnung. Dann liest systemd die neue Datei ein, und Du aktivierst und startest den Dienst in einem Schritt:

Code
sudo systemctl daemon-reload
sudo systemctl enable --now meinbot.service

enable sorgt für den Start bei jedem Booten, --now startet ihn zusätzlich sofort. Ob er läuft, zeigt:

Code
systemctl status meinbot.service

In der Zeile Active muss active (running) stehen, und darunter siehst Du die letzten Ausgaben Deines Skripts. Mit q verlässt Du die Anzeige.

Logs lesen mit journalctl

Alles, was Dein Skript ausgibt, landet im Journal von systemd. Die Ausgaben live mitlesen kannst Du mit:

Code
journalctl -u meinbot.service -f

Beenden mit Strg + C. Für die Meldungen seit dem letzten Systemstart nimmst Du journalctl -u meinbot.service -b, für einen Zeitraum etwa journalctl -u meinbot.service --since "1 hour ago".

Den automatischen Neustart habe ich im Test mit einem absichtlichen Absturz geprüft. Das Journal zeigt dann diese Abfolge, und nach zehn Sekunden läuft der Bot wieder:

Code
meinbot.service: Main process exited, code=exited, status=1/FAILURE
meinbot.service: Failed with result 'exit-code'.
meinbot.service: Scheduled restart job, restart counter is at 1.
Started meinbot.service - Mein Python-Bot.

Nach jeder Änderung an der Unit-Datei brauchst Du sudo systemctl daemon-reload und danach sudo systemctl restart meinbot.service. Änderst Du nur das Skript, reicht der Neustart.

Häufige Fehler

status=203/EXEC: systemd konnte das Programm aus ExecStart nicht ausführen. Fast immer ist der Pfad falsch, etwa ein Tippfehler im Interpreter oder eine virtuelle Umgebung, die an einem anderen Ort liegt. Prüf den Pfad mit ls -l /opt/meinbot/venv/bin/python. Dank Type=exec meldet schon systemctl start den Fehler, statt einen laufenden Dienst vorzutäuschen.

status=217/USER: Der Benutzer aus User= existiert nicht. Leg ihn an oder korrigiere den Namen.

Permission denied im Log: Der Dienstbenutzer darf eine Datei nicht lesen oder in einen Ordner nicht schreiben. Prüf mit ls -l, wem die Dateien gehören, und gib sie ihm mit chown.

Das Skript läuft von Hand, als Dienst aber nicht: Im Terminal hast Du Umgebungsvariablen, einen PATH und ein Arbeitsverzeichnis, die der Dienst nicht hat. Nutze im Skript vollständige Pfade oder Pfade relativ zu WorkingDirectory, und hinterlege fehlende Variablen in der .env.

Start request repeated too quickly: Der Dienst ist zu oft hintereinander abgestürzt, und die Notbremse hat gegriffen. Such im Journal nach der eigentlichen Ursache, behebe sie und setz den Zähler mit sudo systemctl reset-failed meinbot.service zurück.

Dienste ohne root: User-Services

Wenn Du keine Root-Rechte hast oder ein Skript bewusst unter Deinem eigenen Benutzer laufen soll, gibt es User-Services. Die Datei liegt dann in ~/.config/systemd/user/, die Zeilen User und Group entfallen, und als Ziel unter [Install] trägst Du WantedBy=default.target ein. Bedient wird alles mit systemctl --user statt sudo systemctl, also etwa systemctl --user enable --now meinbot.service.

Ein User-Service läuft normalerweise nur, solange Du angemeldet bist. Damit er beim Booten startet und nach dem Abmelden weiterläuft, erlaubst Du das einmalig mit:

Code
sudo loginctl enable-linger $USER

Timer statt Cronjob

Für Aufgaben, die nicht dauerhaft laufen, sondern zu festen Zeiten, bietet systemd Timer. Sie haben gegenüber cron zwei Vorteile: Die Ausgaben landen im Journal, und ein verpasster Lauf wird nachgeholt, wenn der Pi zur geplanten Zeit aus war. Ein Timer besteht aus zwei Dateien mit demselben Namen. Die erste beschreibt die Aufgabe, hier das Löschen alter Logdateien. Leg sie mit sudo nano /etc/systemd/system/aufraeumen.service an:

Code
[Unit]
Description=Alte Logdateien aufräumen

[Service]
Type=oneshot
ExecStart=/usr/bin/find /var/log/meinbot -name "*.log" -mtime +14 -delete

Der Befehl löscht alle .log-Dateien, die älter als 14 Tage sind. Ersetze /var/log/meinbot durch den Ordner, in den Dein Skript seine Logdateien schreibt, oder trag in ExecStart einen ganz anderen Befehl ein, etwa den Aufruf eines Backup-Skripts. Type=oneshot bedeutet, dass der Befehl einmal läuft und dann endet. Die zweite Datei legt fest, wann. Öffne sie mit sudo nano /etc/systemd/system/aufraeumen.timer:

Code
[Unit]
Description=Täglich alte Logdateien aufräumen

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=10min

[Install]
WantedBy=timers.target

OnCalendar=*-*-* 03:00:00 heißt jeden Tag um 3 Uhr. Persistent=true holt einen verpassten Lauf nach, und RandomizedDelaySec verteilt den Start zufällig auf bis zu zehn Minuten danach. Aktiviert wird der Timer, nicht der Dienst:

Code
sudo systemctl daemon-reload
sudo systemctl enable --now aufraeumen.timer
systemctl list-timers aufraeumen.timer

Die letzte Zeile zeigt in der Spalte NEXT, wann der Timer das nächste Mal auslöst. Ob ein Zeitausdruck das meint, was Du denkst, prüfst Du vorher mit systemd-analyze calendar "Mon..Fri *-*-* 07:30". Die Ausgabe nennt den nächsten Termin, in diesem Beispiel den nächsten Werktag um 7:30 Uhr.

Für einfache Aufgaben bleibt cron trotzdem eine gute Wahl, siehe Anleitung: Cronjobs unter Raspberry Pi OS einrichten. Am Mac übernimmt launchd die Rolle von systemd, das zeigt Hintergrund-Jobs am Mac mit launchd statt cron.

Weiterlesen.