Systemd Service and stdio

After yesterday’s article, Arne Blankerts pointed me at a note showing how to install a program using stdio with systemd.
Code and Unit files
The code:
#! /usr/bin/env python3
import sys
if __name__ == "__main__":
while True:
line = input().strip()
print(f"ECHO: {line}")
if line == "QUIT":
sys.exit(0)
The Socket Unit:
$ systemctl --user cat kris2.socket
# /home/kris/.config/systemd/user/kris2.socket
[Unit]
Description=My second service
PartOf=kris2.service
[Socket]
ListenStream=127.0.0.1:12346
Accept=Yes
[Install]
WantedBy=sockets.target
And the Service Unit, which has to be a template:
$ systemctl --user cat kris2@.service
# /home/kris/.config/systemd/user/kris2@.service
[Unit]
Description=My second Service
After=network.target kris2.socket
Requires=kris2.socket
[Service]
Type=simple
ExecStart=/usr/bin/python3 %h/Python/systemd-socketserver/echoserver.py
TimeoutStopSec=5
StandardInput=socket
StandardOutput=socket
StandardError=journal
[Install]
WantedBy=default.target
A template Unit
A systemd template starts a new instance of the service for each incoming connection, because nothing in the code of the service is aware of being a network service.
Instead stdin
and stdout
are connected to the network by systemd, and stderr
is being directed to journald
.
The actual service just performs normal stdio.
Since connections are being accepted by systemd, and then fed into the service using normal stdio, a new instance of the service has to be started for each connection.
This is done with a service template, “kris2.@service
”.
Running stuff
We can enable and start the socket:
$ systemctl --user enable kris2.socket
Created symlink /home/kris/.config/systemd/user/sockets.target.wants/kris2.socket → /home/kris/.config/systemd/user/kris2.socket.
$ systemctl --user start kris2.socket
$ lsof -i -n -P
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
systemd 327983 kris 28u IPv4 2520788109 0t0 TCP 127.0.0.1:12346 (LISTEN)
Since the service is a template, it cannot be started.
$ systemctl --user enable kris2@.service
Failed to enable unit: File default.target: Identifier removed
That is also not necessary, because systemd will do that for us on connect:
$ telnet localhost 12346
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
I am a test.
ECHO: I am a test.
QUIT
ECHO: QUIT
Connection closed by foreign host.
While connected, we can see the user systemd instance hanging off the PID 1 systemd, and the family of user-services hanging off the user systemd instance.
systemd(1)─┬─ModemManager(2480)─┬─{ModemManager}(2565)
…
├─systemd(327983)─┬─(sd-pam)(327984)
│ ├─pipewire(327990)───{pipewire}(328047)
│ ├─pipewire-media-(327991)───{pipewire-media-}(3280+
│ └─python3(3315905)
…
Multiple ports
We can now modify the socket Unit to provide more than one port.
$ systemctl --user cat kris2.socket
# /home/kris/.config/systemd/user/kris2.socket
[Unit]
Description=My second service
PartOf=kris2.service
[Socket]
ListenStream=127.0.0.1:12346
ListenStream=127.0.0.1:12347
Accept=Yes
[Install]
WantedBy=sockets.target
And after this, the service will be available on both ports. Since the service’s code does not know anything about networking at all, it won’t even notice.
Summary
Using template systemd services, and redirecting stdin
and stdout
, we can create systemd Units that work with programs that are not aware of the fact that they are running connected to the network.
This simplifies the code for a service considerably, and also makes it much easier to test the service.
Template Units themselves cannot be enabled or started, which is initially unexpected, but makes a lot of sense once you start to think about it.