Sam Hooke

Run Poetry command as systemd service

These notes cover how to run a Python Poetry command as a systemd service. I’m using Ubuntu 22.04, but the steps should be the same for systemd on other OSes.

Create an example Poetry Python package §

Run poetry new example_service to create a new Poetry Python package named example_service.

Add systemd-python so that we can write to journald (see these notes for more details on installing systemd-python):

poetry add systemd-python

Add a basic main loop §

Create an example_service/service.py file with a basic main loop, which just writes to journald every second:

service.py §
from systemd import journal
import time

def main():
    journal.send("starting up")
    try:
        while True:
            t = time.time()
            journal.send("example message", MY_COUNTER=t)
            time.sleep(1)
    except SystemExit:
        # Allow Ctrl-C to gracefully exit
        pass

A real service would be more complex, but this is just a basic example.

Add an entry point in pyproject.toml §

Create an entry point, which starts the above main loop:

pyproject.toml §
[tool.poetry.scripts]
service-run = "example_service.service:main"

Find path to entry point §

On the command line we would normally run the service-run command by doing poetry run service-run. However, we cannot do that within a systemd service file because it requires the absolute path to the command.

Fortunately, the service-run command is just a “binary” within the virtual environment created by Poetry. If we enter the virtual environment created by Poetry, we can use which to get the path to the service-run binary:

$ poetry shell
$ which service-run
/home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py3.10/bin/service-run

This is the command we will give to systemd to launch our service.

Create a systemd service file §

To add the service to systemd, we need to create a service file. A good starting point is the man pages:

man systemd.unit

Within here, we can find this extremely basic example foo.service file:

[Unit]
Description=Foo

[Service]
ExecStart=/usr/sbin/foo-daemon

[Install]
WantedBy=multi-user.target

Taking this example file, we can update the Description, and change ExecStart to use the path to the entry point:

[Unit]
Description=ExampleService

[Service]
ExecStart=/home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py3.10/bin/service-run

[Install]
WantedBy=multi-user.target

Save the service file to /etc/systemd/system/example-service.service.

In summary, this service:

  • Has a description of ExampleService.
  • Is named example-service (based upon the filename). This is the name we will use in systemctl commands later.
  • Calls the service-run command when it starts, using the absolute path.

Test the service §

With the service installed, we can test starting the service:

sudo systemctl start example-service

Then check status to verify it is running:

$ systemctl status example-service
example-service.service - ExampleService
     Loaded: loaded (/etc/systemd/system/example-service.service; disabled; vendor preset: enabled)
     Active: active (running) since Wed 2023-11-29 13:00:51 GMT; 22s ago
   Main PID: 23746 (service-run)
      Tasks: 1 (limit: 4556)
     Memory: 5.3M
        CPU: 41ms
     CGroup: /system.slice/example-service.service
             └─23746 /home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py3.10/bin/python /home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py>

Nov 29 13:01:04 sam-virtual-machine python[23746]: example message
Nov 29 13:01:05 sam-virtual-machine python[23746]: example message
Nov 29 13:01:06 sam-virtual-machine python[23746]: example message
# ...
Nov 29 13:01:11 sam-virtual-machine python[23746]: example message
Nov 29 13:01:12 sam-virtual-machine python[23746]: example message
Nov 29 13:01:13 sam-virtual-machine python[23746]: example message

All looks good.

Stop the service:

sudo systemctl stop example-service

View the logs §

Our (short lived) service has written some logs to journald.

We can use journalctl to view the log entries for our service:

$ journalctl -u example-service.service
Nov 29 13:00:51 sam-virtual-machine systemd[1]: Started ExampleService.
Nov 29 13:00:51 sam-virtual-machine python[23746]: starting up
Nov 29 13:00:51 sam-virtual-machine python[23746]: example message
Nov 29 13:00:52 sam-virtual-machine python[23746]: example message
Nov 29 13:00:53 sam-virtual-machine python[23746]: example message
# ...
Nov 29 13:01:26 sam-virtual-machine python[23746]: example message
Nov 29 13:01:27 sam-virtual-machine python[23746]: example message
Nov 29 13:01:28 sam-virtual-machine python[23746]: example message
Nov 29 13:01:29 sam-virtual-machine systemd[1]: Stopping ExampleService...
Nov 29 13:01:29 sam-virtual-machine systemd[1]: example-service.service: Deactivated successfully.
Nov 29 13:01:29 sam-virtual-machine systemd[1]: Stopped ExampleService.

That looks good. We can see the example message lines, but our custom MY_COUNTER field is not shown. We can verify that looking at the output in JSON.

View the logs (in JSON) §

Now we’ll use journalctl to view the logs in JSON format. This will enable us to see the MY_COUNTER field:

journalctl -u example-service.service -o json-pretty

Here are a few observations about the following output:

  • Using JSON (via -o json-pretty or -o json) shows more fields. The following example contains only six of the “example message” entries for brevity.
  • The ordering of the fields is not consistent, with no apparent pattern. For example, in the following six entries, the MESSAGE field is at index 7, 4, 16, 6, 2, 20.
  • The MY_COUNTER field can be seen.
  • There is no comma between each JSON object in the list. It appears each entry is considered a separate object.
  • See here for more details about the JSON format.

Highlighted below are the MESSAGE and MY_COUNTER fields:

{
        "_UID" : "0",
        "_BOOT_ID" : "37e336ad066846788762df9dd3434383",
        "_TRANSPORT" : "journal",
        "_SYSTEMD_UNIT" : "example-service.service",
        "_CAP_EFFECTIVE" : "1ffffffffff",
        "_EXE" : "/home/sam/.pyenv/versions/3.10.12/bin/python3.10",
        "__MONOTONIC_TIMESTAMP" : "10244496630",
        "MESSAGE" : "example message",
        "_MACHINE_ID" : "33bc1116507e48e485dd1c2349f34250",
        "MY_COUNTER" : "1701262883.4878783",
        "_SELINUX_CONTEXT" : "unconfined\n",
        "_SYSTEMD_CGROUP" : "/system.slice/example-service.service",
        "CODE_LINE" : "9",
        "_PID" : "23746",
        "_SYSTEMD_INVOCATION_ID" : "b5ed8b3604604ba7b670b17fac332352",
        "_CMDLINE" : "/home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py3.10/bin/python /home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py3.10/bin/service-run",
        "__REALTIME_TIMESTAMP" : "1701262883488336",
        "SYSLOG_IDENTIFIER" : "python",
        "CODE_FUNC" : "main",
        "_SYSTEMD_SLICE" : "system.slice",
        "_SOURCE_REALTIME_TIMESTAMP" : "1701262883488229",
        "__CURSOR" : "s=6f5532dee89c46d49412e3998509ba23;i=d2b;b=37e336ad066846788762df9dd3434383;m=2629e9cf6;t=60b4a21d70250;x=fbb109a998998c23",
        "CODE_FILE" : "/home/sam/projects/example_service/example_service/service.py",
        "_COMM" : "service-run",
        "_HOSTNAME" : "sam-virtual-machine",
        "_GID" : "0"
}
{
        "_PID" : "23746",
        "__MONOTONIC_TIMESTAMP" : "10245497960",
        "_CAP_EFFECTIVE" : "1ffffffffff",
        "_SYSTEMD_CGROUP" : "/system.slice/example-service.service",
        "MESSAGE" : "example message",
        "_MACHINE_ID" : "33bc1116507e48e485dd1c2349f34250",
        "_BOOT_ID" : "37e336ad066846788762df9dd3434383",
        "_HOSTNAME" : "sam-virtual-machine",
        "_SYSTEMD_UNIT" : "example-service.service",
        "_SYSTEMD_INVOCATION_ID" : "b5ed8b3604604ba7b670b17fac332352",
        "CODE_FUNC" : "main",
        "SYSLOG_IDENTIFIER" : "python",
        "MY_COUNTER" : "1701262884.489381",
        "_TRANSPORT" : "journal",
        "_CMDLINE" : "/home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py3.10/bin/python /home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py3.10/bin/service-run",
        "__CURSOR" : "s=6f5532dee89c46d49412e3998509ba23;i=d2c;b=37e336ad066846788762df9dd3434383;m=262ade468;t=60b4a21e649c1;x=add90525c73f308",
        "_GID" : "0",
        "_COMM" : "service-run",
        "__REALTIME_TIMESTAMP" : "1701262884489665",
        "_UID" : "0",
        "_SOURCE_REALTIME_TIMESTAMP" : "1701262884489567",
        "_SELINUX_CONTEXT" : "unconfined\n",
        "CODE_LINE" : "9",
        "CODE_FILE" : "/home/sam/projects/example_service/example_service/service.py",
        "_SYSTEMD_SLICE" : "system.slice",
        "_EXE" : "/home/sam/.pyenv/versions/3.10.12/bin/python3.10"
}
{
        "_BOOT_ID" : "37e336ad066846788762df9dd3434383",
        "_MACHINE_ID" : "33bc1116507e48e485dd1c2349f34250",
        "_SYSTEMD_UNIT" : "example-service.service",
        "_CAP_EFFECTIVE" : "1ffffffffff",
        "_SELINUX_CONTEXT" : "unconfined\n",
        "CODE_LINE" : "9",
        "__MONOTONIC_TIMESTAMP" : "10246499375",
        "CODE_FILE" : "/home/sam/projects/example_service/example_service/service.py",
        "_HOSTNAME" : "sam-virtual-machine",
        "_EXE" : "/home/sam/.pyenv/versions/3.10.12/bin/python3.10",
        "_GID" : "0",
        "_SOURCE_REALTIME_TIMESTAMP" : "1701262885491058",
        "CODE_FUNC" : "main",
        "_SYSTEMD_CGROUP" : "/system.slice/example-service.service",
        "__CURSOR" : "s=6f5532dee89c46d49412e3998509ba23;i=d2d;b=37e336ad066846788762df9dd3434383;m=262bd2c2f;t=60b4a21f59189;x=32adc06622f7ea03",
        "_COMM" : "service-run",
        "MESSAGE" : "example message",
        "SYSLOG_IDENTIFIER" : "python",
        "_UID" : "0",
        "_TRANSPORT" : "journal",
        "_SYSTEMD_SLICE" : "system.slice",
        "_CMDLINE" : "/home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py3.10/bin/python /home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py3.10/bin/service-run",
        "_PID" : "23746",
        "MY_COUNTER" : "1701262885.4909284",
        "__REALTIME_TIMESTAMP" : "1701262885491081",
        "_SYSTEMD_INVOCATION_ID" : "b5ed8b3604604ba7b670b17fac332352"
}
{
        "__REALTIME_TIMESTAMP" : "1701262886492949",
        "_COMM" : "service-run",
        "CODE_FUNC" : "main",
        "_SYSTEMD_UNIT" : "example-service.service",
        "_SYSTEMD_CGROUP" : "/system.slice/example-service.service",
        "_MACHINE_ID" : "33bc1116507e48e485dd1c2349f34250",
        "MESSAGE" : "example message",
        "_EXE" : "/home/sam/.pyenv/versions/3.10.12/bin/python3.10",
        "_PID" : "23746",
        "CODE_FILE" : "/home/sam/projects/example_service/example_service/service.py",
        "_SYSTEMD_SLICE" : "system.slice",
        "_GID" : "0",
        "CODE_LINE" : "9",
        "SYSLOG_IDENTIFIER" : "python",
        "_SOURCE_REALTIME_TIMESTAMP" : "1701262886492926",
        "_HOSTNAME" : "sam-virtual-machine",
        "_CAP_EFFECTIVE" : "1ffffffffff",
        "_UID" : "0",
        "_TRANSPORT" : "journal",
        "_CMDLINE" : "/home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py3.10/bin/python /home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py3.10/bin/service-run",
        "_SYSTEMD_INVOCATION_ID" : "b5ed8b3604604ba7b670b17fac332352",
        "MY_COUNTER" : "1701262886.4927928",
        "__MONOTONIC_TIMESTAMP" : "10247501243",
        "_BOOT_ID" : "37e336ad066846788762df9dd3434383",
        "_SELINUX_CONTEXT" : "unconfined\n",
        "__CURSOR" : "s=6f5532dee89c46d49412e3998509ba23;i=d2e;b=37e336ad066846788762df9dd3434383;m=262cc75bb;t=60b4a2204db15;x=85f29ec0a6eb34e5"
}
{
        "_SYSTEMD_INVOCATION_ID" : "b5ed8b3604604ba7b670b17fac332352",
        "__REALTIME_TIMESTAMP" : "1701262887494453",
        "MESSAGE" : "example message",
        "_GID" : "0",
        "__MONOTONIC_TIMESTAMP" : "10248502748",
        "__CURSOR" : "s=6f5532dee89c46d49412e3998509ba23;i=d2f;b=37e336ad066846788762df9dd3434383;m=262dbbddc;t=60b4a22142335;x=9c0646fb4437aa08",
        "CODE_FILE" : "/home/sam/projects/example_service/example_service/service.py",
        "_COMM" : "service-run",
        "_HOSTNAME" : "sam-virtual-machine",
        "_MACHINE_ID" : "33bc1116507e48e485dd1c2349f34250",
        "_SYSTEMD_UNIT" : "example-service.service",
        "SYSLOG_IDENTIFIER" : "python",
        "_CAP_EFFECTIVE" : "1ffffffffff",
        "MY_COUNTER" : "1701262887.4942982",
        "_SYSTEMD_SLICE" : "system.slice",
        "_UID" : "0",
        "CODE_FUNC" : "main",
        "_TRANSPORT" : "journal",
        "_SYSTEMD_CGROUP" : "/system.slice/example-service.service",
        "CODE_LINE" : "9",
        "_BOOT_ID" : "37e336ad066846788762df9dd3434383",
        "_EXE" : "/home/sam/.pyenv/versions/3.10.12/bin/python3.10",
        "_SELINUX_CONTEXT" : "unconfined\n",
        "_SOURCE_REALTIME_TIMESTAMP" : "1701262887494431",
        "_CMDLINE" : "/home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py3.10/bin/python /home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py3.10/bin/service-run",
        "_PID" : "23746"
}
{
        "__MONOTONIC_TIMESTAMP" : "10249504543",
        "_BOOT_ID" : "37e336ad066846788762df9dd3434383",
        "_HOSTNAME" : "sam-virtual-machine",
        "CODE_FUNC" : "main",
        "_GID" : "0",
        "__CURSOR" : "s=6f5532dee89c46d49412e3998509ba23;i=d30;b=37e336ad066846788762df9dd3434383;m=262eb071f;t=60b4a22236c78;x=ef183be68aa4176b",
        "MY_COUNTER" : "1701262888.495936",
        "_PID" : "23746",
        "_COMM" : "service-run",
        "SYSLOG_IDENTIFIER" : "python",
        "_TRANSPORT" : "journal",
        "_SYSTEMD_SLICE" : "system.slice",
        "_SYSTEMD_UNIT" : "example-service.service",
        "_SELINUX_CONTEXT" : "unconfined\n",
        "_EXE" : "/home/sam/.pyenv/versions/3.10.12/bin/python3.10",
        "__REALTIME_TIMESTAMP" : "1701262888496248",
        "CODE_FILE" : "/home/sam/projects/example_service/example_service/service.py",
        "_CMDLINE" : "/home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py3.10/bin/python /home/sam/.cache/pypoetry/virtualenvs/example-service-0J3MkRb1-py3.10/bin/service-run",
        "_MACHINE_ID" : "33bc1116507e48e485dd1c2349f34250",
        "CODE_LINE" : "9",
        "MESSAGE" : "example message",
        "_UID" : "0",
        "_SOURCE_REALTIME_TIMESTAMP" : "1701262888496206",
        "_SYSTEMD_INVOCATION_ID" : "b5ed8b3604604ba7b670b17fac332352",
        "_SYSTEMD_CGROUP" : "/system.slice/example-service.service",
        "_CAP_EFFECTIVE" : "1ffffffffff"
}

Updating the service file §

If you make changes to the example-service.service file, you’ll need to run this command for them to take effect:

sudo systemctl daemon-reload

Conclusion §

We now have a very basic Python service, installed in a virtual environment create by Poetry, but run as a systemd service. It is logging to journald, including using custom fields.

As a next step, see these notes on how to read and filter the journald log entries in Python.

These are rough notes that vary greatly in quality and length, but prove useful to me, and hopefully to you too!

← Previous: Reading from and writing to journald in Python
Next: Using structlog and journald →