2

守护进程

守护进程对于Unix运维来说应该是不陌生的,所有的提供服务的进程基本上都是守护进程,通常也可以称为服务。它们由init进程启动,并且没有控制终端,是一种执行日常事务的进程。
在Unix系统下,有很多守护进程,在基于BSD的系统下运行下列命令

ps -axj

-a选项显示所有进程,包括其他用户的进程,-x显示没有控制终端的进程状态,-j显示与作业有关的信息:会话ID、进程组ID、控制终端以及终端进程组ID。在基于SystemV的系统中,对应命令是ps -efj,具体如何需要查看自己的ps命令说明,当然还有一些系统只允许超级用户查看到其他的用户进程,普通用户不能查看其他用户进程。
我们知道,除了用户进程以外,还有很多系统进程,比如守护进程,对于大部分Unix环境来说,使用的都是SystemV风格的init启动方式,首先是Grub引导内核启动,然后内核会查找/sbin/init程序并且启动它,init程序会根据一系列的配置文件启动不同的脚本,最终启动守护进程。当然,目前最新的操作系统基本上都是systemd,所以是不同的,但是基本原理还是相同的,比如同样都是root权限运行,所有守护进程没有控制终端,

编程规则

编写守护进程的时候需要遵循一些规则,以免出现各种问题

  1. 首先是调用umask函数设置文件屏蔽字,比如0,因为守护进程是fork产生的,继承来的文件模式创建屏蔽字可能会被设置为拒绝某些权限

  2. 调用fork,然后使父进程exit。首先,由于我们不知道守护进程是如何产生的,它有可能是用户shell调用后产生的,所以我们需要让其能被init托管。其次,子进程虽然继承了父进程的进程组ID,但是却有了新的进程ID,也就是说,不可能是组长进程了

  3. 调用setsid创建一个会话,使进程成为新会话的首进程,成为一个新进程组的组长进程,没有控制终端

  4. 将当前工作目录设置为/目录。因为从父进程继承过来的属性可能会导致文件系统无法卸载,所以我们需要使用chdir()函数。

  5. 关闭不需要的文件描述符

  6. 某些守护进程在0、1、2上打开/dev/null来保证不会有标准输入输出。

#include "include/apue.h"
#include <syslog.h>
#include <fcntl.h>
#include <sys/resource.h>

void daemonize(const char *cmd)
{
    int i, fd0, fd1, fd2;
    pid_t pid;
    struct rlimit rl;
    struct sigaction sa;
    
    umask(0);
    
    if (getrlimit(RLIMIT_NOFILE, &rl) < 0)
        err_quit("%s: can't get file limit", cmd);
        
    if ((pid = fork()) < 0)
        err_quit("%s: can't fork", cmd);
    else if (pid != 0)
        exit();
    setsid();
    
    sa.sa_handler = SIG_IGN;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
    if (sigaction(SIGHUP, &sa, NULL) < 0)
        err_quit("%s: can't ignore SIGHUP", cmd);
    if ((pid = fork()) < 0)
        err_quit("%s: can't fork", cmd);
    else if (pid != 0)
        exit();
        
    if (chdir("/") < 0)
        err_quit("%s: can't change directory to /", cmd);
    
    if (rl.rlim_max == RLIM_INFINITY)
        rl.rlim_max = 1024;
    for (i = 0; i < rl.rlim_max; ++i)
        close(i);
        
    fd0 = open("/dev/null", O_RDWR);
    fd1 = dup(0);
    fd2 = dup(0);
    
    openlog(cmd, LOG_CONS, LOG_DAEMON);
    if (fd0 != 0 || fd1 != 1 || fd2 != 2) {
        syslog(LOG_ERR, "unexpected file descriptors %d %d %d", fd0, fd1, fd2);
        exit(1);
    }
}

上面这个函数实际上就是之前所说必须遵循的规则的写法,只需要通过main函数调用这个函数就能使进程变为守护进程,当然,由于权限问题,实际上并不是实际的写法。

日志

守护进程的一个问题就是日志问题,我们知道,任何的程序必然需要有方式记录下自己的活动日志,对于大部分情况来说,都是通过标准输入标准输出标准错误的形式记录日志,但是守护进程没有控制终端,不能写到标准错误上,而且我们也不会希望它写到终端上,其中一个解决方法是写到一个单独的文件中,但是这样会让运维人员非常头痛,因为程序一多就会非常混乱,所以就需要一个集中式的守护进程来记录日志。
syslog是BSD伯克利开发的,广泛运用于BSD系列的系统中,后来成为了Unix标准之一。当然,目前由于systemd的实质性接管,所以syslog的作用正在被systemd蚕食,这里不是讨论的重点。
syslog的架构很简单,但是很有效,syslogd作为系统服务启动,然后侦听/dev/logsocket、/dev/klogsocket和UDP514端口。其中/dev/log用于接收本地用户进程的日志信息,UDP514端口接收网络上的日志信息,/dev/klog则是监听内核的日志信息。
由于这种架构,所以开发者可以使用三种方法产生日志信息

  1. 内核例程可以调用log函数,任何一个用户进程都可以打开读取/dev/klog读取信息

  2. 守护进程调用syslog函数来产生日志信息,最终这些信息将被发送到/dev/log

  3. 任何进程都可以向UDP514端口发送日志信息。

syslogd在启动的时候回读取配置文件,一般在/etc/syslog.conf,里面决定了消息应当被发送到何处,甚至有可能重要信息会被在管理员控制台上打印。

void openlog(const char *ident, int option, int facility);
void syslog(int priority, const char *format, ...);
void closelog(void);
int setlogmask(int maskpri);

openlog函数让开发者指定一个ident参数,也就是标识符,以后,这个ident将被加到每则日志消息中。option参数则是指定了各种选项位屏蔽。

option 说明
LOG_CONS 若日志信息不能通过Unix Domain数据报,则将该消息写入控制台
LONG_NDELAY 立即打开至syslogd守护进程的Unix Domain数据报套接字,不要等到第一条信息已经被记录时候再打开。通常,在记录第一条信息之前,不打开套接字
LOG_NOWAIT 不要等待在将消息记录日志过程中可能以创建的子进程,因为在syslog调用wait时,应用程序可能已获得了子进程的状态。这种处理阻止了与捕捉SIGCHLD信号的应用程序之前产生的冲突
LOG_ODELAY 在第一条消息被记录之前延迟打开链接
LOG_PERROR 除将日志消息发送给syslogd以外,还将其写入到标准错误
LOG_PID 记录每条信息都要包含进程ID

openlog的facility参数值则包含了很多可选值,但是非常遗憾的是,只有少部分是能被跨平台使用的。具体可以参见各平台的Unix手册。
syslog函数则会产生一条日志,其priority参数是facility和level的组合,format参数则是格式化字符串,基本和vsprintf函数一样。
setlogmask函数用于设置进程的记录优先级屏蔽字。它返回调用它之前的屏蔽字,也就是可以用来存储着或者了解之前的屏蔽字状态,各条消息除非已在记录优先级屏蔽字中进行了设置,否则不会被记录。

单实例守护进程

很多情况下,守护进程只是一个进程,因为不需要并发地进行操作,而且这样很有可能导致资源竞争,所以在很多情况下,守护进程只会实现在任意时刻只存在守护进程一个副本,所以为了保证只存在一个副本,就需要一种机制来保证。而文件和记录锁就是这样一种保证方式,实际上,不单单是单实例守护进程,几乎所有的守护进程都采用了这种方式。

#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <syslog.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>
#include <sys/stat.h>

#define LOCKFILE "/var/run/daemon.pid"
#define LOCKMODE (S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH)

extern int lockfile(int);

int already_running(void)
{
    int fd;
    char buf[16];
    
    fd = open(LOCKFILE, O_RDWR | O_CREATE, LOCKMODE);
    if (fd < 0) {
        syslog(LOG_ERR, "can't open %s: %s", LOCKFILE, strerror(error));
        exit(1);
    }
    if (lockfile(fd) < 0) {
        if (errno == EACCES || errno == EAGAIN) {
            close(fd);
            return(1);
        }
        syslog(LOG_ERR, "can't lock %s: %s", LOCKFILE, strerror(errno));
        exit(1);
    }
    ftruncate(fd, 0);
    sprintf(buf, "%ld", (long)getpid());
    write(fd, buf, strlen(buf) + 1);
    return(0);
}

实际上上面的行为非常常见,守护进程启动的时候试图创建一个文件并且将进程ID写入其中,如果该文件加锁,则lockfile函数将会失败,并且返回,表明已经有守护进程正在运行。否则将文件长度截断为0,将进程ID写入其中。

守护进程的惯例

  1. 如果守护进程使用锁文件,那么该文件通常存储在/var/run目录中。不过需要注意,守护进程需要超级用户权限才能创建文件。锁文件名字一般是name.pid

  2. 如果守护进程支持配置文件,则配置文件一般存储在/etc目录中。

  3. 守护进程可以通过命令行启动,但是通常是使用init脚本启动的。

  4. 如果一个守护进程有一个配置文件,在启动的时候会读取该文件,但是在此之后一般就不会再查看它。如果管理员更改了配置文件,那么该守护进程可能需要重新启动,后来在信号机制中加入了SIGHUP信号的捕捉,让守护进程接收到信号后重新读取配置文件。

客户进程-服务器进程模型

C/S进程模型在Unix环境中非常常见,守护进程通常就是服务器进程,然后等待客户进程语气联系,提出某种类型的请求,为了保证请求的高效处理,服务器进程中调用fork然后exec另一个程序来提供服务是非常常见的。这些服务器进程通常管理着多种资源。而为了保证文件描述符不被滥用,所以需要对所有被执行程序不需要的文件描述符设置成执行时关闭(close-on-exec)。


山河永寂
2.4k 声望159 粉丝