Blog Development

New-style daemons in Python

By Jorgen Schäfer 03 May 2017

Systemd introduced a new way of writing daemons, service programs in Unix. Previously, writing a Unix service required an elaborate setup process to prevent the system from accidentally killing the process. With systemd, a service can now be written as if it was running in the foreground, just like any other program. systemd takes care of running the program as a service.

Writing services tremendously simplified

A simple service program in Python might look like this:

#!/usr/bin/env python3
import time


while True:
    time.sleep(60)

The corresponding systemd service file to start this service, /etc/systemd/system/pythonservice.service, would look like this:

[Unit]
Description=Python Service


[Service]
ExecStart=/usr/local/bin/sample-service.py

Logging is just writing to standard output

As systemd intercepts output by the service and treats each line as output, logging can be as simple as using the print function. But Python includes a standard logging framework, and as libraries use this, it’s best to use it as well. Luckily, configuring the logging library to write to standard output is trivial:

#!/usr/bin/env python3

import logging
import time

logging.basicConfig(level="INFO")

while True:
    logging.info("Hi")
    time.sleep(60)

After starting the service using systemctl start, log output will start to appear in the journal:

$ sudo journalctl -u pythonservice
Jan 28 12:49:11 forcix systemd[1]: Started Python Service.
Jan 28 12:49:11 forcix sample-service.py[4095]: INFO:root:Hi
Jan 28 12:50:11 forcix sample-service.py[4095]: INFO:root:Hi

Log levels are supported as well

While standard output is a simple stream of lines, systemd supports prefixing lines with angle brackets and a number to define the log level they use. If such a prefix is not used, all lines are by default in the INFO level (which can be configured in the service file).

The logging module allows us to write our own handler to prefix the output with the appropriate marker:

#!/usr/bin/env python3

import logging
import sys
import time

def main():
    root_logger = logging.getLogger()
    root_logger.setLevel("INFO")
    root_logger.addHandler(SystemdHandler())

    while True:
        logging.debug("Debug")
        logging.info("Info")
        logging.warning("Warning")
        time.sleep(60)

class SystemdHandler(logging.Handler):
    # http://0pointer.de/public/systemd-man/sd-daemon.html
    PREFIX = {
        # EMERG <0>
        # ALERT <1>
        logging.CRITICAL: "<2>",
        logging.ERROR: "<3>",
        logging.WARNING: "<4>",
        # NOTICE <5>
        logging.INFO: "<6>",
        logging.DEBUG: "<7>",
        logging.NOTSET: "<7>"
    }

    def __init__(self, stream=sys.stdout):
        self.stream = stream
        logging.Handler.__init__(self)

    def emit(self, record):
        try:
            msg = self.PREFIX[record.levelno] + self.format(record) + "\n"
            self.stream.write(msg)
            self.stream.flush()
        except Exception:
            self.handleError(record)

main()

The messages are now interpreted correctly using the right log level in the journal.

Backtraces pose a problem

The only remaining problem now are backtraces. As systemd treats each line as a single log entry, and Python logs backtraces as multiple lines, further processing of the log messages can cause backtraces to be mangled and parts of them to be lost.

Jan 28 13:37:52 forcix systemd[1]: Started Python Service.
Jan 28 13:37:53 forcix sample-service.py[5312]: It broke
Jan 28 13:37:53 forcix sample-service.py[5312]: Traceback (most recent call last):
Jan 28 13:37:53 forcix sample-service.py[5312]:   File "/usr/local/bin/sample-service.py", line 14, in main
Jan 28 13:37:53 forcix sample-service.py[5312]:     0/0
Jan 28 13:37:53 forcix sample-service.py[5312]: ZeroDivisionError: division by zero

The solution is to simply replace newlines in the message with some escape character. There is no standard for this, so we have to pick something. Using the standard \n sequence seems as good a pick as any:

    def emit(self, record):
        try:
            msg = self.PREFIX[record.levelno] + self.format(record)
            msg = msg.replace("\n", "\\n")
            self.stream.write(msg + "\n")
            self.stream.flush()
        except Exception:
            self.handleError(record)

The backtrace now appears in a single log message:

Jan 28 13:39:06 forcix systemd[1]: Started Python Service.
Jan 28 13:39:06 forcix sample-service.py[5339]: It broke\nTraceback (most recent call last):\n  File "/usr/local/bin/sample-service.py", line 14, in main\n    0/0\nZeroDivisionError: division by zero

A service in 50 lines

What we did in this article was to write a new-style daemon in Python, with full support of log levels and single-line backtraces, in under 50 lines. The largest complexity came from the need to encode log levels. If log levels or single-line backtraces aren’t of any concern, the service can be condensed to fewer than 12 lines.

Starting with this setup, it’s easy to use more features provided by systemd for new-style daemons, like automated restart on failure, the security settings, or even socket activation.

Jorgen Schäfer

Jorgen Schäfer Jorgen is a software developer with more than ten years of experience in both application development and system administration. As a part-time freelancer, he writes technical articles, gives public talks, and provides training services.

Share Your Thoughts