现代守护进程的谬误

译自:https://jdebp.uk/FGA/unix-daemon-design-mistakes-to-avoid.html

对于Unix上的守护进程,有一些传统概念带来的谬误,这些谬误已经严重影响了现代进程监视器程序(Daemontools家族、systemd)的正常运作,因此必须予以避免和修正。

fork()调用不是为了让守护进程进入后台

1
2
3
When the daemon program is started by init, the SRC, or inetd, the originating process calls the fork routine to create a child process. The code in the child process calls an exec routine to run the daemon program. The parent process considers the child process that it created to be the daemon. When the child process terminates, the parent process considers the daemon to have terminated. Therefore, it would be a mistake for the daemon program to execute the code in Figure 13 on page 27 if the daemon was started by init, the SRC, or inetd.

-- Eric M. Agar. Writing Reliable AIX Daemons, IBM Redbook SG 24-4946-00, IBM Corporation

这个想法从根本上就是有问题的。“前台”和“后台”这些概念并不是面向进程的,而是面向用户的。它们只能用于描述拥有控制终端的进程,譬如对于控制终端来说,存在着“前台进程组”的概念。

换句话说,“前台”和“后台”这些概念只能用于描述需要呈现用户交互界面的程序,例如在GUI中,高亮的窗口会被视为“处于前台”。但是守护进程本来就没有控制终端,也不会呈现任何用户交互界面,自然也就不存在“前台”和“后台”的概念。

当过去的人们说“使用fork()调用让守护进程进入后台”的时候,他们其实根本指的就不是“后台”,而是让程序被Shell异步启动,换句话说,让Shell不必等待程序终止就可以接受下一条命令。然而,Unix的Shell本身就完美支持这些功能(作业控制),完全没有必要在程序内部这么做。

正确的做法是,让守护进程的启动程序决定到底是异步启动还是同步启动。不要假定这些程序总是期望守护进程异步启动。尽管,在现代操作系统上,系统管理员的确总是希望守护进程异步启动。

守护进程的监视器程序往往会假定如果它的子进程退出了,那么就意味着服务停止,需要进行重新启动(相似地,它们也会通过给子进程发送信号来停止服务)。实际上,传统的SysVinit和BSDinit也会这么做(通过respawn),过去30年的其他守护进程监视工具组自然也不必多说。

但是,调用fork()来“让守护进程进入后台”完全打破了这一工作模式。相当具有讽刺意味的是,这么做完全是多此一举,因为,即使不使用fork()调用,被监视器程序唤醒的守护进程也本来就“在后台”。监视器程序本身就在异步运行,而且也没有控制终端,亦不需要任何Shell程序作为它们的Session Leader。

如果有菜鸟问,怎么通过/etc/rc.local,或者类似的脚本异步启动守护进程的话,直接告诉他们去看Shell的作业控制功能文档就好了。

“前台”模式不是“Debug”模式

同步地运行一个守护进程并不一定意味着要获取大量的Debug信息。如果你的程序里面还存在着一个用于切换“前台”或“后台”模式的选项,不要让它有多个功能。不使用fork()调用和是否启用程序的Debug输出之间完全连半毛钱关系也没有。

别用syslog()

Syslog,说白了,是一个相当差劲的日志机制。一些最典型的缺点包括:

  • 所有的,来自不同守护进程的日志信息流都会被混在一起,而目的居然是重新分成多种不同类型的日志文本文件。这种多输入-多输出的模式完全就是浪费时间和精力。
  • 它需要大量的额外文件(包括一个守护进程),而且名字在不同的类Unix系统上还不一样。
  • 它使用UDP进行远程日志通信,这很不可靠。在网络负载极端的情况下,日志信息的记录顺序可能出现偏差,甚至有的日志可能会完全丢失。
  • 它使用AF_LOCAL套接字进行本地通信,日志消息很容易被入侵或伪造(比如用logger命令就可以)。

你应该把日志直接写到标准输出或者标准错误,就像所有其他的Unix程序一样,因为:

  • 你的程序的输出天然地、自动地和其他程序的输出相隔离。
  • 不需要任何额外的文件或程序。
  • 日志数据不会丢失,也不会被打乱顺序。
  • 其他人没办法伪造你的程序的输出。

你会发现这样做,守护进程反而更容易编写。使用fprintf(stderr, ...)(或者std::clog)打印日志总是比syslog()调用要简单。

在大部分的进程监视器套件中,启动守护进程时往往会打开一个管道,然后把守护进程的输出连接到管道的读取端,再把管道的写出端连接到别的日志守护进程上。监视器程序还会确保管道的打开状态,这样就算日志守护进程意外崩溃,在重新启动后,遗留的日志也会被安全地保存在管道里,不会丢失。

如果系统上没有这样的进程监视器套件,我们仍然可以使用loggersploggersissylog这样的程序来把守护进程的输出重定向到日志守护进程。但是反过来就不行syslog()调用和别的日志机制完全不兼容,除非存在一个监听Syslog端口的守护进程。此外,用户还可以方便地把标准输出和标准错误分开管理,但是用syslog()调用就很难实现。

systemd的做法就是把所有的守护进程的标准输出和标准错误通过管道连接到systemd-journald守护进程。尽管在设计理念上这还是和Syslog有点相似——比如把所有的日志流都集中到单个巨大的流中。虽然并不清除对守护进程的运行有什么影响,但是这么做是有一个巨大的问题的——假设有一个信息量超大的日志源(可能是恶意的哦)短时间内涌入大量的信息,那么在日志轮转时,来自其他守护进程的重要日志就可能会丢失。这也是Syslog使用多输入-多输出机制的原因。在大部分系统的初始状态下,利用这个机制冲掉重要日志还是很简单的。

更好的解决方案是,从一开始就避免汇聚输入。Daemontools家族的套件允许任意一个守护进程都通过独立的管道连接到独立的日志守护进程,换句话说,允许日志流彻底分离,每个守护进程的日志分别采用不同的轮转和大小控制措施,甚至还可以让每个日志守护进程都使用独立的用户账户运行,这样就可以最大限度地确保日志守护进程以及它们创建的日志文件的安全性。

不要直接处理TCP/IP连接

让系统套接字管理守护进程,比如inetdtcpservertcpsvd管理套接字的创建和监听。你的程序要做的仅仅只是读取标准输入、写入标准输出而已。这样,就算别人希望通过别的方式连接到你的程序,也可以轻松实现。

确保你的程序可以通过UCSPI服务器程序启动。如果你真的不得不获取一些TCP信息,比如套接字地址,不要直接调用getpeername()。TCP/IP的本地和远程信息应该通过TCP环境变量由UCSPI服务器提供

这样设计也更接近最小权限原则。因为如果你的程序需要自己处理小于1023的端口,那它就必须要通过超级用户权限运行。这样的话,你的程序里的所有代码都必须再三审计,以确保它不会有漏洞。然而,如果你的程序通过前面提到过的这些守护进程进行套接字管理,它就可以通过setuidgid工具以非超级用户的权限运行。程序中的漏洞带来的风险也可以被规避,攻击者就算获得了用户权限,影响也会被限制在有限的范围内。

如果你的程序必须要手动创建和监听套接字,那么必须允许用户手动控制使用的IP地址和端口号。

不要创建PID文件

/run/var/run目录下创建PID文件有很多缺点:

  • 容易发生竞态条件。
  • 不可靠。如果一个守护进程在没有清理PID文件的情况下退出,用户就可能会在清理PID文件时意外杀死其他无辜的进程。
  • 它让创建多个守护进程实例变得格外麻烦。
  • 很难实现在系统启动时自动清理旧的PID文件。

让用户自行选择的进程监视器控制进程的启动和停止。监视器程序不需要PID文件也能清楚地知道进程ID,因为它们是调用fork()创建守护进程的直接父进程。

Daemontools被认为是/var/run的正确处理方法。Daemontools家族的其他套件也使用了相同的方案。使用这些工具就完全不需要PID文件。守护进程完全通过svc命令进行控制,而不需要执行killpkill等命令。