使用Python编写systemd服务

在几乎所有Linux发行版都使用systemd的现在,使用Python开发一个守护进程惊人的简单。

创建一个服务

第一步:编写systemd单元

systemd兼并了服务管理工具与监视器工具,systemd既支持系统服务,也支持用户服务。

首先需要创建一个适配服务的systemd单元文件,这个单元文件可以保存在:

  • 系统级:/etc/systemd/system目录下。
  • 全局用户级:/etc/systemd/user目录下。
  • 用户级:~/.config/systemd/user/目录下。

这个单元文件名应当为:

1
服务名.service

内容骨干为:

1
2
3
4
5
[Unit]
Description=描述

[Service]
ExecStart=/usr/bin/python3 -u /脚本路径

就这么简单,接下来我们讲一些详细内容。

标准输出与标准错误

在过去,用户需要将服务的所有输出保存或者重定向到日志文件中,但是对于systemd来说,这一切都不再需要了,因为systemd的journald组件可以收集并记录systemd服务的输出,换句话说,这变成了systemd的任务

这一行为也可以通过添加这两个选项修改:

1
2
3
4
5
6
7
8
[Unit]
StandardOutput=journal|null|syslog|file:路径
StandardError=journal|null|syslog|file:路径

# journal即为默认选项,收集到journald
# null表示直接抛弃所有输出
# syslog表示收集到syslog缓冲区,注意,所有syslog缓冲区输出也会收集到journald一份
# file表示收集到文件中

值得一提的是,在默认情况下,Python的输出是带缓冲的,这也就意味着,输出会被阻塞到填满缓冲区后才通过管道传输给systemd,这是不合适的,因此,我们需要关闭Python的输出缓冲,要么给Python解释器加上-u选项,要么在服务单元中添加以下内容:

1
2
[Service]
Environment=PYTHONUNBUFFERED=1

使用用户运行

出于安全因素考虑,对于系统级服务,我们也会希望使用独立用户而不是root运行,对于systemd来说,这很容易实现:

1
2
3
[Service]
User=用户名
Group=组名

还有一种更方便的方法,也就是使用systemd动态生成的临时用户,这种方法是为了取代过去的nobody公用用户:

1
2
[Service]
DynamicUser=yes

自动拉起

systemd包含着过去的Supervisor的功能,也就是服务的自动拉起,要实现也相当简单,只需要添加这个选项:

1
2
3
4
5
6
[Service]
Restart=no|always|on-failure

# no即为默认选项,不启用自动拉起功能
# always表示总是自动拉起
# on-failure表示仅在服务异常退出时自动拉起

简单服务与fork服务

在传统的情况下,我们创建一个服务需要从主进程分叉出守护进程,将守护进程的PID保存在PID文件中,然后杀死主进程(参考:PEP 3143)。

但是如之前所说,对于systemd来说,我们不再需要这么麻烦,systemd本身就有能力进行守护进程的生命周期管理和自动拉起,因此我们只需要实现一个简单的前台服务,然后将服务类型设为:

1
2
[Service]
Type=simple

即可。

但是,systemd仍然是可以兼容传统的守护进程逻辑的,只需要给systemd指出服务类型和PID文件路径即可:

1
2
3
[Service]
Type=forking
PIDFile=/run/xxx.pid

此外,如果用户在服务实现中使用了systemd.daemon库自行给systemd发送信号的话,那么就不需要让systemd对服务的状态进行推断,此时可以将服务类型设为:

1
2
[Service]
Type=notify

我们在之后会再次提到。

停止与垃圾处理

systemd会对服务的生命周期进行管理,这其中当然包括对服务的停止。

我们知道,systemd使用cgroup对服务的所有进程进行追踪,默认情况下,systemd会对服务cgroup内的所有进程发送SIGTERM信号,如果90秒后仍然存在未停止的进程,那么就再对它们发送SIGKILL信号,不过,这一行为可以进行修改:

1
2
3
4
5
6
7
[Service]
KillMode=control-group|process|mixed|none

# control-group即为默认值
# process表示仅对主进程进行杀死,而无视其他进程
# mixed表示对主进程发送SIGTERM信号,但是对其他进程一律发送SIGKILL信号
# none表示不进行任何杀死操作,这一选项必须搭配ExecStop=使用

这些选项给用户实现守护进程的垃圾处理提供了多种方式,比如,用户可以使用KillMode=process,然后让主进程捕获信号后,再自行处理垃圾和杀死子进程。

除此之外,我们还可以直接让systemd帮忙进行垃圾处理:

1
2
3
4
5
6
7
8
[Service]
ExecStop=/命令
ExecStopPost=/命令

# ExecStop=指定的命令会在进行杀死操作之前先执行
# ExecStopPost=指定的命令会在进行杀死操作之后才执行
# 简单来说,它们两者的区别仅仅在于,ExecStopPost=无论在服务启动是否启动成功的情况下都会执行,而ExecStop=则反之
# 因此,如果用户需要特别的手动杀死部分进程,那么使用ExecStop=,如果要进行进程完全停止后的垃圾处理,使用ExecStopPost

服务启用

“启用”一个服务在systemd意味着将服务设置开机启动,这是通过将服务设置为某些target的依赖(一般为多用户模式)实现的,因此只需要添加依赖关系即可:

1
2
[Install]
WantedBy=default.target

值得一提的是,如果在主机上不存在用户的任何会话,那么用户的systemd服务也会随之被销毁,要使得用户的服务拥有和系统级服务一样的生命周期,需要给用户启用Linger(参考systemd文档)。

资源限制

systemd可以轻易地对服务进行资源限制,就像这样:

1
2
[Service]
LimitCPU=

沙箱

systemd也可以对服务进行沙箱化,限制服务对某些服务或网络的使用,从而有效提高安全性,详情请看systemd文档:

1
2
[Service]
ProtectSystem=no|yes|strict

编写服务实现

是时候使用Python实现守护进程的主要内容了,一个简单的前台服务只需要实现一个永久的循环即可:

1
2
3
if __name__ == '__main__':
while True:
......

既然要编写适配systemd的守护进程,那么自然离不开systemd模块:

1
import systemd.daemon as sd

检测系统类型

使用这一函数检测系统是否使用systemd:

1
systemd.daemon.booted() -> bool

如果是,那么返回True,否则返回False

发送通知

我们之前提到过notify,它的功能就是给systemd发送一些服务状态信号,函数签名如下:

1
systemd.daemon.notify('状态')

而状态有以下几种:

  • READY=1:告诉systemd服务已经启动完成。
  • RELOADING=1:告诉systemd服务正在重载。
  • STOPPING=1:告诉systemd服务正在停止。

举个例子,我们可以在启动前进行一些准备工作:

1
2
3
4
5
6
7
8
9
import systemd.daemon

if __name__ == '__main__':
print('Starting......')
systemd.daemon.notify('READY=1')

# 然后开始实现主逻辑
while True:
......

或者,我们可以在退出前进行一些处理:

1
2
3
4
5
6
def exit():
print('Exiting......')
systemd.daemon.notify('STOPPING=1')
sys.exit(0)

signal.signal(signal.SIGTERM, exit())

第三步:测试

可以使用systemd-run工具进行测试,这是一个临时封装systemd单元并运行的命令,它的常见用法为:

1
systemd-run --unit=任务名 [--user] [--service-type=oneshot|forking] [-G] [-p "PIDFile=/run/xxx.pid"] [-p "SuccessExitStatus=0 1 2"] -d|--working-directory=工作目录 [-E "Key=value"] [-r] 执行的命令

选项含义为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
--unit=
明确指定单元的名称(而不是自动生成)

--user
与当前调用用户的用户服务管理器(systemd 用户实例)通信,而不是默认的系统级服务管理器(systemd 系统实例)

-G, --collect
即使临时单元执行失败(failed),也在结束后从内存中卸载它

--property=, -p
为临时单元设置一个属性

--same-dir, -d
使用用户当前的工作目录运行服务进程

-E NAME=VALUE, --setenv=NAME=VALUE
给服务进程传递一个环境变量。可以多次使用此选项以传递多个环境变量

-r, --remain-after-exit
在服务进程结束之后,继续保持服务的存在,直到被明确的停止(stop)后才卸载