Home Stopping a Python systemd service cleanly
Post
Cancel

Stopping a Python systemd service cleanly

Suppose you are running a Python systemd service that opens some file descriptors (these can be regular files, named pipes, sockets, and so on). When the service is stopped, it needs to finish cleanly by closing and/or removing the associated resources. This post presents two manners in which the service may be stopped gracefully.

First, we will look at how a Python script my_service.py may be ran as a systemd service. Next, we will discuss the general form of that Python script. Finally, we will see how to stop the service gracefully.

Running a Python script as a systemd service

An executable can be made into a systemd service by creating a unit (configuration) file, also known as a .service file (see the systemd.service man page). The .service files are stored in a specific location and have a certain format.

Unit file location

There are actually three places where unit files may be stored:

  • /etc/systemd/system/: system-specific; take precedence over run-time unit files
  • /run/systemd/system/: run-time; take precedence over default unit files
  • /usr/lib/systemd/system/: default; may be overwritten when the system updates

If you create your own systemd service, place the .service unit file in /etc/systemd/system/.

Unit file format

A unit file (for a service) has three sections: [Unit], [Service] and [Install]. You can read more on unit file anatomy. For this example, let us assume a very simple my-service.service file:

1
2
3
4
5
6
7
8
9
10
11
[Unit]
Description=My test service
After=network.target

[Service]
User=alex
Type=simple
ExecStart=/usr/bin/python /home/alex/services/my_service.py

[Install]
WantedBy=multi-user.target

Using the service

First, we need to add the new service and to reload systemd:

1
2
sudo ln -sf ~/services/my-service.service /etc/systemd/system/my-service.service
sudo systemctl daemon-reload

Then we can enable the service (this way it will be ran each time the system boots) and start it:

1
2
sudo systemctl enable my-service.service
sudo systemctl start my-service.service

If my_service.py is configured to log information (and it should!), we can check the system logs with journalctl:

1
journalctl -u my-service.service

Add an -f to the previous command if you want to follow the journal as it updates, just like with tail -f.

The danger zone

Recall the original assumption: my_service.py opens file descriptors and uses the underlying resources. Whenever the service stops running (through sudo systemctl stop my-service.service, for example), it must do so gracefully by closing the open resources first.

Before diving into the possible solutions, let us first take a look at the general structure of the my_service.py script.

The structure of the Python service

The Python service (my_service.py) may be a TCP server, client, or any other process that needs to run as a daemon. For the purpose of this example, let us suppose that the service uses a standard event loop and opens a named pipe in /tmp to read from it. Instead of doing something useful, our example service will just sleep and then log something when it wakes up. The general structure is as follows (possible exceptions from the os module are not caught for brevity purposes):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import logging
import os
import sys
import time


class MyService:
    FIFO = '/tmp/myservice_pipe'

    def __init__(self, delay=5):
        self.logger = self._init_logger()
        self.delay = delay
        if not os.path.exists(MyService.FIFO):
            os.mkfifo(MyService.FIFO)
        self.fifo = os.open(MyService.FIFO, os.O_RDWR | os.O_NONBLOCK)
        self.logger.info('MyService instance created')
        
    def _init_logger(self):
        logger = logging.getLogger(__name__)
        logger.setLevel(logging.DEBUG)
        stdout_handler = logging.StreamHandler()
        stdout_handler.setLevel(logging.DEBUG)
        stdout_handler.setFormatter(logging.Formatter('%(levelname)8s | %(message)s'))
        logger.addHandler(stdout_handler)
        return logger

    def start(self):
        try:
            while True:
                time.sleep(self.delay)
                self.logger.info('Tick')
        except KeyboardInterrupt:
            self.logger.warning('Keyboard interrupt (SIGINT) received...')
            self.stop()

    def stop(self):
        self.logger.info('Cleaning up...')
        if os.path.exists(MyService.FIFO):
            os.close(self.fifo)
            os.remove(MyService.FIFO)
            self.logger.info('Named pipe removed')
        else:
            self.logger.error('Named pipe not found, nothing to clean up')
        sys.exit(0)


if __name__ == '__main__':
    service = MyService()
    service.start()

If my_service.py is ran in a terminal, we can stop it by hitting Ctrl+C (registered as SIGINT):

1
2
3
4
5
6
7
python test_service.py 
    INFO | MyService instance created
    INFO | Tick
    INFO | Tick
^C WARNING | Keyboard interrupt (SIGINT) received...
    INFO | Cleaning up...
    INFO | Named pipe removed

How to stop the Python service gracefully

In the previous section we’ve seen that the named pipe is removed as expected if my_service.py is executed in a console. However, if we run it as a systemd service, stopping it via systemctl will result in an improper shutdown of our service: the named pipe is not removed :scream:

This happens because, by default, the kill signal sent by systemd when using systemctl stop is SIGTERM, not SIGINT, therefore we cannot catch it as a KeyboardInterrupt.

Here are two solutions for this problem.

Use SIGINT instead of SIGTERM

The most immediate solution is to instruct systemd to send a SIGINT (registered as a keyboard interrupt) instead of the default SIGTERM. This is done by adding the following line to the [Service] section of the my-service.service unit file:

1
KillSignal=SIGINT

This way, whenever we stop or restart the service with systemctl, the signal sent to kill my_service.py is SIGINT and it is caught by the except block in lines 32-34.

Use a SIGTERM handler

The other solution is to register a signal handler for SIGTERM. For this, we will need to import signal and instruct Python to do something useful when receiving a SIGTERM. We add the following method to the MyService class:

1
2
3
    def _handle_sigterm(self, sig, frame):
        self.logger.warning('SIGTERM received...')
        self.stop()

Note the two arguments sig and frame: although not used explicitly, they must be present because this is the method that we will register for handling SIGTERM. If they are absent we get a TypeError when a SIGTERM is received. We add the following line to the __init__() method:

1
        signal.signal(signal.SIGTERM, self._handle_sigterm)

If we check the system log with journalctl -u my-service.service we see the following:

1
2
3
4
5
6
7
8
9
10
juin 26 18:35:17 phantom systemd[1]: Started My test service.
juin 26 18:35:17 phantom python[558725]:     INFO | MyService instance created
juin 26 18:35:22 phantom python[558725]:     INFO | Tick
juin 26 18:35:27 phantom python[558725]:     INFO | Tick
juin 26 18:35:32 phantom python[558725]:  WARNING | SIGTERM received...
juin 26 18:35:32 phantom python[558725]:     INFO | Cleaning up...
juin 26 18:35:32 phantom python[558725]:     INFO | Named pipe removed
juin 26 18:35:32 phantom systemd[1]: Stopping My test service...
juin 26 18:35:32 phantom systemd[1]: test-service.service: Succeeded.
juin 26 18:35:32 phantom systemd[1]: Stopped My test service.

Conclusion

That’s it! You can now use either of the two methods to stop your Python systemd service gracefully: either tell systemd to kill it with a SIGINT or, better yet, install a custom SIGTERM handler.

There’s a caveat with both approaches if the service launches more than one process. The systemd.kill man page explains that by default (when KillMode=control-group) all the processes launched by the unit file will receive SIGTERM (or SIGINT if we use KillSignal=SIGINT as explained here). However, if they fail to stop, they will be knocked-out with SIGKILL. In such situations, one should be extra-careful with proper shutdown and cleanup.

Further reading

This post is licensed under CC BY 4.0 by the author.

How to fix Python logger printing the same entry multiple times

Kill a Python subprocess and its children when a timeout is reached

Comments powered by Disqus.