Python守护进程
守护进程不是服务
守护进程不是服务,或者说,守护进程不完全是服务,服务意味着一个主进程,和能够通过某种方式与该主进程进行交流与控制的另一个进程;而守护进程仅仅意味着服务的主进程部分。
创建一个systemd服务
在几乎所有Linux发行版都使用systemd的现在,使用Python开发一个服务惊人的简单。
第一步:编写systemd单元
systemd兼并了服务管理工具与监视器工具,systemd既支持系统服务,也支持用户服务。
首先需要创建一个适配服务的systemd服务单元文件,这个服务单元文件可以保存在:
- 系统级:
/etc/systemd/system目录下。 - 全局用户级:
/etc/systemd/user目录下。 - 用户级:
~/.config/systemd/user/目录下。 
这个服务单元文件名应当为:
1  | 服务名.service  | 
内容骨干为:
1  | [Unit]  | 
就这么简单,接下来我们讲一些详细内容。
标准输出与标准错误
在过去,用户需要将服务的所有输出保存或者重定向到日志文件中,但是对于systemd来说,这一切都不再需要了,因为systemd的journald组件可以收集并记录systemd服务的输出,换句话说,这变成了systemd的任务。
这一行为也可以通过添加这两个选项修改:
1  | [Unit]  | 
值得一提的是,在默认情况下,Python的输出是带缓冲的,这也就意味着,输出会被阻塞到填满缓冲区后才通过管道传输给systemd,这是不合适的,因此,我们需要关闭Python的输出缓冲,要么给Python解释器加上-u选项,要么在服务单元中添加以下内容:
1  | [Service]  | 
标准输入
和标准输出与标准错误的选项类似,标准输入也有一个选项:
1  | StandardInput=null|data|tty|tty-force|tty-fail|file:路径|socket  | 
我们来分别讲讲:
使用固定输入
使用StandardInput=data选项可以使得服务在启动时接收一个固定的标准输入,这个固定的标准输入通过StandardInputText=或StandardInputData=(如果是二进制)给出:
1  | StandardInput=data  | 
如果直接给出了StandardInputText=或StandardInputData=,那么实际上StandardInput=的默认值就会变成data。
使用终端
使用StandardInput=tty|tty-force|tty-fail可以将某个终端(Ctrl + Fn)分配给服务使用,不过这三种选项存在细微差别:
StandardInput=tty在终端已经被占用时会阻塞服务启动并等待释放。StandardInput=tty-force在终端已经被占用时会直接抢占终端。StandardInput=tty-fail在终端已经被占用时会直接启动失败。
在设置这一选项后,还需要使用TTYPath=指定使用的终端:
1  | StandardInput=tty|tty-force|tty-fail  | 
这样就可以使用Ctrl + Alt + F5切换到TTY5进行控制了。
使用文件
使用StandardInput=file:路径可以使用任意文件作为标准输入的数据来源,“任意文件”意味着可以是普通的文本文件,也可以是FIFO,这就带来了很多种可能性。
首先我们可以使用普通的文本文件,这就和StandardInput=data差不多,会直接读取文件内容作为固定的数据源。
其次我们还可以使用FIFO,不过需要注意的是,此时服务的启动会被阻塞,直到从FIFO中接收到第一个标准输入,FIFO在输入源程序退出后会发送一个EOF,这会关闭标准输入。
那么假如我们要实现FIFO在服务启动时自动创建,在服务停止时自动删除该怎么办呢?其实很简单,一种思路是,创建另一个服务:
1  | [Unit]  | 
然后,我们再在原来的服务中添加依赖即可:
1  | [Unit]  | 
使用套接字
这个“套接字”指的不是具体意义上的套接字,而是systemd的套接字单元,而且仅可用于套接字激活的服务单元。
使用StandardInput=socket选项,我们可以很容易的实现通过某些接口激活的任务,例如,对于前面的FIFO需求,更优雅的思路是,使用systemd原生的.socket单元,创建一个监听FIFO的.socket单元:
1  | [Socket]  | 
然后,在配套的服务单元中使用StandardInput=socket读取其内容即可:
1  | [Service]  | 
需要注意的是,由于FIFO的读取/发送目标是内核缓冲区,读取时内核缓冲区只会忠诚地返回它内部保存的数据(而网络套接字读取/发送目标是网络对端,对端会进行消费动作),如果StandardOutput=socket(默认值),那么会导致严重的死锁(进程从FIFO中接收到数据后,会再次发送到FIFO,接着立刻从FIFO读取到自己刚刚发送的数据,从而导致死循环,直到打满内核缓冲区),因此StandardOutput=journal是必要的。
但是,对于网络套接字,就没有上述那些问题,我们可以直接大大方方地使用它进行双工通信:
1  | [Unit]  | 
然后,我们的应用程序必须改写为模板服务XXX@.service:
1  | [Unit]  | 
然后,每当我们向套接字传入某些数据时,便会自动激活该服务并将通信套接字的文件描述符连接到标准输入和标准输出,应用程序可以直接通过标准输入和标准输出和远端进行通信。
需要注意的是,Accept=yes是必要的,否则systemd传递给服务的套接字将不会是通信套接字,而是监听套接字,应用程序需要通过sd_listen_fds接口、或标准输入、或标准输出的文件描述符获取监听套接字,然后自行调用accept()创建通信套接字。
使用用户运行
出于安全因素考虑,对于系统级服务,我们也会希望使用独立用户而不是root运行,对于systemd来说,这很容易实现:
1  | [Service]  | 
还有一种更方便的方法,也就是使用systemd动态生成的临时用户,这种方法是为了取代过去的nobody公用用户:
1  | [Service]  | 
自动拉起
systemd包含着Supervisor的功能,可以进行服务的健康监测(准确地说,是活动状态监测)与自动拉起,要实现也相当简单,只需要添加这个选项:
1  | [Service]  | 
简单服务与forking服务
在传统的情况下,我们创建一个服务需要从主进程分叉出守护进程,将守护进程的PID保存在PID文件中,然后杀死主进程(参考:PEP 3143)。
但是如之前所说,对于systemd来说,我们不再需要这么麻烦,systemd本身就有能力进行进程的生命周期管理和自动拉起,因此我们只需要实现一个简单的主程序,然后将服务类型设为:
1  | [Service]  | 
即可。
但是,systemd仍然是可以兼容传统的守护进程逻辑的,只需要给systemd指出forking服务类型和PID文件路径即可:
1  | [Service]  | 
此外,如果开发者在服务实现中使用了systemd.daemon库自行给systemd发送信号的话,那么就不需要让systemd对服务的状态进行推断,此时可以将服务类型设为:
1  | [Service]  | 
我们在之后会再次提到。
停止与垃圾处理
systemd会对服务的生命周期进行管理,这其中当然包括对服务的停止。
我们知道,systemd使用CGroup对服务的所有进程进行追踪,默认情况下,在停止服务时,systemd会对服务CGroup内的所有进程发送SIGTERM信号,如果90秒后仍然存在未停止的进程,那么就再对它们发送SIGKILL信号,不过,这一行为可以进行修改:
1  | [Service]  | 
这些选项给开发者实现守护进程的垃圾处理提供了多种方式,比如,开发者可以使用KillMode=process,然后让主进程捕获信号后,再自行处理垃圾和杀死子进程。或者,开发者可以使用KillMode=none,完全使用在ExecStop=中指定的方法进行服务停止。
除此之外,我们还可以直接让systemd帮忙进行垃圾处理:
1  | [Service]  | 
服务启用
“启用”一个服务对systemd来说意味着执行服务的[Install]段,要实现服务的开机启动,我们是通过将服务设置为某些target的依赖(一般为多用户模式)实现的,因此只需要添加依赖关系即可:
1  | [Install]  | 
值得一提的是,如果在主机上不存在用户的任何会话,那么用户的systemd服务也会随之被销毁,要使得用户的服务拥有和系统级服务一样的生命周期,需要给用户启用Linger(参考systemd文档)。
资源限制
基于CGroup,systemd可以轻易地对服务进行资源限制,就像这样:
1  | [Service]  | 
详情请看systemd文档。
沙箱
systemd也可以对服务进行沙箱化,限制服务对某些系统目录或网络的使用,从而有效提高安全性,详情请看systemd文档:
1  | [Service]  | 
编写服务实现
是时候使用Python实现守护进程的主要内容了,一个简单的前台服务只需要实现一个永久的循环即可:
1  | if __name__ == '__main__':  | 
既然要编写适配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服务正在停止。STATUS=...:告诉systemd服务的状态文本。ERRNO=X:服务出错了,以X状态码退出。MAINPID=X:告诉systemd主进程的PID。BUSERROR=...:告诉systemd D-Bus问题,等号右侧必须是D-Bus风格的错误。WATCHDOG=1:告诉systemd服务看门狗该服务仍在运行。WATCHDOG_USEC=...:重设看门狗的时间。FDSTORE=1:保留文件描述符,这些文件描述符将在下次启动服务时通过sd_listen_fds的方式传递给进程。FDSTOREMOVE=1:移除文件描述符。FDNAME=...:保留的文件描述符。
举个例子,我们可以在启动前进行一些准备工作:
1  | import systemd.daemon  | 
或者,我们可以在退出前进行一些处理:
1  | def 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  | --unit=  | 
更多优化
编写单元文件的建议
- 尽量不要使用依赖。如果一定要使用依赖,尽量使用
Wants=,这可以避免一个服务被用户停止时导致另一个服务被连带停止。 - 尽量不要修改
StandardOutput=和StandardError=,交给journald处理就好。 - 尽量带上
ProtectSystem=yes|strict,一般的服务都用不到会被这个配置项屏蔽的目录。 - 尽量带上
PrivateDevices=yes,一般的服务很少需要直接访问硬件。 - 尽量带上
PrivateNetwork=yes,如果是非网络服务的话。 
套接字激活
systemd官方认为,对于任何需要使用套接字通信的服务来说,套接字都应当交给systemd创建、配置和管理,而不是程序内部。这样,我们就把套接字的创建提前到了服务启动之前,有利于服务的并行启动。
systemd接收到数据后,会对数据进行排队,同时启动服务进程,服务启动后,systemd会将套接字的文件描述符传递给进程,因此,使用套接字激活机制的服务,必须有能力从systemd接手套接字。要实现这一功能,我们有很多种办法:
- systemd传递的第一个文件描述符总是
3,如果服务只监听一个端口,我们可以直接使用文件描述符3获取套接字。 - 如果服务只监听一个端口,那么可以设置
StandardInput=socket和StandardOutput=socket,通过文件描述符0或文件描述符1获取套接字。 - 如果服务监听多个端口,我们可以使用
systemd.daemon.listen_fds()函数获取文件描述符列表,然后逐一获取。 - 我们可以使用
socket.socket(fileno=文件描述符)这个函数实现获取文件描述符对应的套接字对象。 - 我们可以使用
socket.fromfd(fd, family, type)这个函数克隆文件描述符,并创建一个新的套接字对象(不常用)。 
自行创建套接字兼容模式
很多应用程序本身没有实现systemd的套接字激活协议,systemd组为此开发了一个专门用于实现套接字激活,而不必修改现存应用程序的代理工具systemd-socket-proxyd。
首先我们需要编写一个套接字单元proxy.socket:
1  | [Socket]  | 
然后我们编写一个用于代理的服务proxy.service:
1  | [Unit]  | 
接着我们只需要为我们的程序再编写一个服务XXX.service:
1  | [Unit]  | 
Inetd兼容模式
参考:End Road。
为传统inetd设计的程序无需修改即可在 systemd 的套接字激活协议下运行,这一行为是通过Accept=yes选项配置的。
首先我们需要编写一个套接字单元服务名.socket:
1  | [Socket]  | 
接着,需要修改我们的服务单元为模板服务单元:服务名@.service,并且必须进行如下设置:
1  | [Service]  | 
接着,我们在程序中不需要关心任何套接字内容,只需要从标准输入读取字节流,将响应字节流写入标准输出:
1  | import sys  | 
- 程序应当在处理完成请求后直接以非0退出码退出。
 
直接适配
首先我们需要创建一个套接字单元文件,它的文件名应为服务名.socket,内容如下:
1  | [Unit]  | 
通常,我们会希望在启用按需启动的服务时仅仅启用对应的套接字,所以要对服务单元进行一些修改:
1  | # 以上内容省略  | 
接着,我们需要在程序代码中实现套接字:
1  | import systemd.daemon  | 
- 程序应当在
accept()后持续接收请求,在明确要求停止后才退出。 
我们在socketserver定义的服务器类中是不是也可以实现呢?答案是可以的:
1  | import systemd.daemon  | 
为了提高复用性,我们可以创建一个类:
1  | import systemd.daemon  | 
再往上一层,我们对于HTTP服务器也可以这么做:
1  | import systemd.daemon  | 
然后,我们只需要启用套接字,那么之后有客户端对套接字发送请求时,对应的服务就会被systemd自动激活了。
D-Bus激活
要使用D-Bus激活机制,我们首先必须确保D-Bus激活请求会被转发给systemd,以确保D-Bus服务不会被重复激活。要实现这一点,我们需要在/usr/share/dbus-1/system-services/目录下创建一个D-Bus服务文件,内容如下:
1  | [D-Bus Service]  | 
然后我们还需要在服务单元中指出BusName:
1  | [Unit]  | 
在过去要禁用D-Bus服务,除了删除以外别无它法,不过现在在systemd的帮助下,通过给服务单元设置标准化的别名,然后将服务别名设为和D-Bus绑定的服务,这样,我们就可以使用systemctl命令对其进行启用与禁用了。
然后,我们就需要编写服务的主程序及其接口配置了,对于Python来说,我们使用pydbus模块实现与D-Bus的绑定与Bus的申请。
首先我们需要编写服务的Policy,保存在/etc/dbus-1/system.d/目录下:
1  | <?xml version="1.0" encoding="UTF-8"?>  | 
首先,我们编写服务端:
1  | #!/bin/env python3  | 
接着我们编写D-Bus服务文件和systemd单元文件:
1  | [D-BUS Service]  | 
1  | [Unit]  | 
最后启动服务,我们测试连通性:
1  | busctl call org.example.test /org/example/test org.example.test log s 'Message'  | 
应该能得到:
1  | i 1  | 
标准守护进程模块
python-daemon是符合 PEP 3143 标准的守护进程实现模块。
实现逻辑
根据《UNIX环境高级编程》的记录,详细来说,要实现一个守护进程,那么需要依次执行以下几步:
- 关闭所有已经打开的文件描述符(Close)(注:这并不代表之后守护进程不能再打开文件)。
 - 切换当前的工作目录(Chdir)。
 - 重设自身的权限掩码(Umask)。
 - 进入后台(第一次Fork)。
 - 脱离进程组(Setsid)。
 - 无视终端IO发送的信号(Setsid)。
 - 脱离控制终端,并且确保不会重新获取终端(Setsid和第二次Fork)。
 - 确保正确地处理以下行为:
- 被System V init启动的情况。
 - 接收到SIGTERM信号的退出。
 - 子进程在退出时要向父进程发送SIGCHLD信号。
 
 
一些守护进程工具,例如 Slack-daemon,不同于 PEP 3143 仅仅实现一个单独的守护进程的目标,它们专注于实现完整的UNIX守护进程功能。参考它们的功能,对于一个守护进程来说,这些额外行为是可取的:
- 设置当前的进程上下文。
 - 检测
initd与inetd,在被其启动时做出正确的反应。 - 设置SUID与SGID权限,以提高安全性。
 - 阻止生成内核转储文件,以防止信息泄露。
 - 创建以守护进程命名的PID文件,并保存在合适的位置,以确保守护进程的唯一性(可选)。
 - 重设守护进程所属的用户和组(可选,仅用于root守护进程)。
 - 重设CHROOT目录(可选,仅可用于root守护进程)。
 - 捕获守护进程的标准输出和标准错误,重定向到Syslog缓冲区或者文件(可选)。
 
具体内容
python-daemon是围绕一个守护进程上下文类daemon.DaemonContext(),与它的参数实现的:
1  | 对象名 = daemon.DaemonContext(  | 
这些参数都会被转换为实例属性,可以在之后通过实例进行修改,只需要通过对象.属性 = 值即可。
而这个对象有以下这些方法:
打开守护进程上下文,它会将当前的程序进程Fork出一个子进程,然后杀死父进程,并在子进程中继续运行当前程序,换句话说,当这个方法返回时,运行中的程序就会变成守护进程,is_open被设为True(换句话说,所有进程分叉过程都交给守护进程上下文解决了,如果你想了解具体是怎么实现的,请看 python-daemon):
1  | 对象.open() -> None  | 
守护进程接收到指定信号后(默认是signal.SIGTERM,见之前提到的默认signal_map)进行退出的方法,它会简单地退出程序,然后抛出SystemExit异常以解释接收到的信号:
1  | 对象.terminate([信号])  | 
关闭守护进程上下文,**这意味着清除PID文件,以及将is_open设为False**:
1  | 对象.close() -> None  | 
返回守护进程上下文是否处于开启状态:
1  | 对象.is_open -> bool  | 
此外,既然是上下文,它就当然实现了with语句:
执行with语句时自动执行的方法,调用.open()方法并返回实例:
1  | 对象.__enter__() -> DaemonContext  | 
退出with语句时自动执行的方法,调用.close()方法,如果关闭成功则返回True:
1  | 对象.__exit__() -> bool  | 
这些方法几乎都不需要用户关心,它们是在with语句中被隐式调用的。
使用
要使用daemon模块,你至少还需要lockfile和signal模块:
1  | import daemon  | 
简单来说,你只需要把守护进程的主逻辑放到上下文中:
1  | with daemon.DaemonContext(pidfile=lockfile.LockFile('/run/xxx.pid')):  | 
更规范的用法是:
1  | # 定义一个在Fork守护进程之前的准备函数  | 
效果
你应该能在ps xf的输出中看到一个脱离终端,孤立的守护进程,进程显示的命令就是执行脚本时执行的命令:
1  | PID TTY STAT TIME COMMAND  | 
缺陷与注意事项
因为在守护进程上下文内的程序已经与终端脱离连接了,所以如果出错,那么不会在终端上显示任何错误,因此需要提前做好Log登记。如果发现执行脚本后什么也没有发生,也没有守护进程产生,那么就说明守护进程上下文内的代码有问题。
一种可行的测试方法是在实际使用前先不要把主程序放到守护进程上下文中,单次执行后检查无误再放进守护进程上下文中测试。
- 在守护进程上下文中读取文件时,千万不要忘了
working_directory=参数的默认值是/而不是os.getcwd()。 - 当已经有一个守护进程在运行时,再次运行一次守护进程,它会在守护进程上下文前阻塞并等待锁,而不会进入主程序,直到锁解除。
 
示例程序
1  | #!/bin/python3  | 
简化模块
python-daemon有一个简化的service模块,非常易于使用。
服务类
service模块定义了一个Service类,用户只需要继承这个类即可:
1  | class MyService(service.Service):  | 
实例化时,需要提供以下参数:
1  | 对象名 = MyService('服务名', pid_dir='PID文件保存目录'[, signals=['信号列表']])  | 
控制服务
实例化服务对象后,就可以轻易的控制服务:
1  | if __name__ == '__main__':  | 
当然,还是建议使用位置参数实现子命令进行操作。
登记日志
service模块封装了syslog日志处理器查找函数find_syslog(),可以直接使用syslog登记日志:
1  | class MyService(service.Service):  | 
等待信号
当.start()方法被调用时,服务会使用一个独立进程运行run()函数;当.stop()方法被调用时,会对run()进程发送SIGTERM信号,此时,run()进程的self.got_sigterm()方法会返回True。此外,也可以使用.kill()方法直接发送SIGKILL信号。
另外,用户可以通过.send_signal()方法给守护进程发送信号,run()函数中,也可以使用self.wait_for_signal()方法等待接收其他信号,不过,这些信号必须在初始化时通过signals参数声明:
1  | class MyService(service.Service):  |