除了网络通信外,服务器程序通常还需考虑许多其他细节问题,这些细节问题涉及广而零碎,且基本上是模板式的,我们称之为服务器程序规范,如:
1.Linux服务器程序一般以后台进程形式运行,后台进程又称守护进程(daemon),它没有控制终端,因此不会意外接收到用户输入。守护进程的父进程通常是init进程(PID为1的进程)。
2.Linux服务器程序通常有一套日志系统,它至少能输出日志到文件,有的高级服务器还能输出日志到专门的UDP服务器。大部分后台进程都在/var/log目录下拥有自己的日志目录。
3.Linux服务器程序一般以某个专门的非root身份运行,如mysqld、httpd、syslogd等后台进程分别拥有运行账户mysql、apache、syslog。
4.Linux服务器程序通常是可配置的,服务器程序通常能处理很多选项,如果选项太多,除命令行外可用配置文件来管理。绝大多数服务器程序都有配置文件,并存放在/etc目录下,如squid服务器的配置文件是/etc/squid3/squid.conf。
5.Linux服务器进程通常会在启动时生成一个PID文件并存入/var/run目录中,以记录该后台进程的PID,如syslogd的PID文件是/var/run/syslogd.pid。
6.Linux服务器通常需要考虑系统资源和限制,以预测自身能承受多大负荷,如进程可用文件描述符总数和内存总量等。
服务器的调试和维护都需要一个专业的日志系统,Linux提供一个守护进程来处理系统日志,即syslogd,但现在Linux上使用的都是它的升级版rsyslogd。
rsyslogd守护进程既能接收用户进程输出的日志,又能接收内核日志。用户进程是通过调用syslog函数生成日志的,该函数将日志输出到一个UNIX本地域socket类型(AF_UNIX)的文件/dev/log中,rsyslogd则监听该文件以获取用户进程的输出。内核日志在老系统上是通过rklogd守护进程管理的,rsyslogd利用以下技术实现了同样的功能:内核日志有printk等函数打印至内核的环状缓存(ring buffer)中,环状缓存的内容直接映射到/proc/kmsg文件中,rsyslogd则通过读取该文件获得内核日志。
rsyslogd守护进程在接收到用户进程或内核输入的日志后,会把它们输出至特定的日志文件,默认,调试信息会保存至/var/log/debug文件,普通信息保存至/var/log/messages文件,内核消息保存至/var/log/kern.log文件,但日志信息具体如何分发,可在rsyslogd的配置文件中设置。rsyslogd的主配置文件是/etc/rsyslog.conf,其中主要可以设置的内容包括:内核日志输入路径(接收来自操作系统内核的日志消息的路径),是否接收UDP日志及其监听端口(默认为514,见/etc/services文件),是否接收TCP日志及其监听端口,日志文件的权限,包含哪些子配置文件(如/etc/rsyslog.d/*.conf)。rsyslogd的子配置文件则指定各类日志的目标存储文件。
上图中的dmesg是一个Linux和Unix系统中的命令行工具,用于显示系统的内核消息。
应用进程使用syslog函数与rsyslogd守护进程通信:
syslog函数使用可变参数(第二个参数message和后面的可变参数列表)来结构化输出。priority参数是设施值和日志级别的按位或,设施值的默认值是LOG_USER,日志级别有以下几个:
openlog函数可改变syslog函数的默认输出方式,进一步结构化日志内容:
ident参数指定的字符串被添加到日志消息的日期和时间之后,它通常被设置为程序的名字。logopt参数对后续syslog函数的行为进行配置,它可取以下值的按位或:
facility参数修改syslog函数中的默认设施值。
程序在开发阶段可能需要输出很多调试信息,而发布后又需要将这些调试信息关闭,解决这个问题的方法不是在程序发布后删除调试代码(因为日后我们可能还需要用到),而是简单地设置日志掩码,使日志级别大于日志掩码的日志被系统忽略。setlogmask函数可用于设置syslog的日志掩码:
maskpri参数指定日志掩码值,该函数始终会成功,它返回调用进程先前的日志掩码值。最后使用closelog函数关闭日志功能:
大部分服务器需要以root身份启动,但不能以root身份运行,以下函数可获取和设置当前进程的真实用户ID(UID)、有效用户ID(EUID)、真实组(GID)、有效组(EGID):
一个进程拥有两个用户ID:UID和EUID,EUID存在的目的是方便资源访问,它使得运行程序的用户拥有该程序的有效用户的权限,如su程序,任何用户都可使用它修改自己的账户信息,但修改账户时su程序需要访问/etc/passwd文件,而访问该文件需要root权限,用ls命令可以看到,su程序的所用者是root,且它被设置了set-user-id标志,这个标志表示,任何用户运行su程序时,其有效用户id就是该程序的所有者(即root),因此根据有效用户的含义,任何运行su程序的用户都能访问/etc/passwd文件。有效用户为root的进程称为特权进程(privileged processes)。EGID的含义与EUID类似,能给运行目标程序的用户提供有效组的权限。
可用以下程序测试进程的UID和EUID的区别:
#include <unistd.h>
#include <stdio.h>
int main() {
uid_t uid = getuid();
uid_t euid = geteuid();
printf("userid is %d, effective userid is: %d\n", uid, euid);
return 0;
}
将以上程序(名为test_uid)的所有者设置为root,并设置该文件的set-user-id标志,然后运行该程序以查看UID和EUID,具体操作如下:
由上图,进程UID是启动程序的用户的ID,而EUID是root账户(文件所有者)的ID。
以下代码将以root身份启动的进程切换为真实用户ID运行:
static bool switch_to_user(uid_t user_id, gid_t gp_id) {
// 确保目标用户不是root,root用户的用户ID和组ID都是0
if ((user_id == 0) && (gp_id == 0)) {
return false;
}
gid_t gid = getgid();
uid_t uid = getuid();
// 如果当前用户不是root也不是目标用户
if (((gid != 0) || (uid != 0)) && ((gid != gp_id) || (uid != user_id))) {
return false;
}
// 如果不是root,则已经是目标用户了
if (uid != 0) {
return true;
}
// 切换到目标用户
if ((setgid(gp_id) < 0) || (setuid(user_id) < 0)) {
return false;
}
return true;
}
Linux下每个进程都属于一个进程组,因此进程除了有PID信息外,还有进程组ID(PGID),可用getpgid函数获取指定进程的PGID:
getpgid函数成功时返回pid参数表示进程所属的进程组PGID,失败则返回-1并设置errno。
每个进程组都有一个首领进程,其PGID和PID相同,进程组将一直存在,直到其中所有进程都离开进程组中(终止或者加入到其他进程组)。
以下函数用于设置PGID:
setpgid函数将参数pid表示进程的PGID设置为参数pgid。如果pid和pgid参数相同,则由pid参数指定的进程将被设置为进程组首领,如果pid参数为0,则表示把当前进程的PGID设置为pgid参数,如果pgid参数为0,则表示将pid参数指定的进程组ID设置为本进程的PID。setpgid函数成功时返回0,失败时返回-1并设置errno。
一个进程只能设置自己或其子进程的PGID,且子进程调用exec系列函数后,我们就不能在父进程中对它设置PGID。
setsid函数用于创建一个会话:
setsid进程不能由进程组的首领进程调用,否则将产生一个错误。对于非组首领的进程,调用该函数会创建新会话,且还有如下额外效果:
1.调用进程成为会话的首领,此时该进程是新会话的唯一成员。
2.新建一个进程组,其PGID就是调用进程的PID,调用进程成为该组的首领。
3.调用进程将失去终端(如果有的话)。
setsid函数成功时返回新进程组的PGID,失败则返回-1并设置errno。
Linux进程并未提供所谓会话ID(SID)的概念,但Linux系统认为它等于会话首领所在的进程组的PGID,并提供了函数getsid来读取SID:
可用ps命令查看进程、进程组、会话之间的关系:
我们是在bash shell下执行ps和less命令的,所以ps和less命令的父进程是bash命令,这可从PPID(父进程PID)一列看出。这3条命令创建了一个会话(SID是1943)和2个进程组(PGID分别是1942和2298)。bash命令的PID、PGID、SID都相同,说明它是会话的首领,也是组1943的首领。ps命令则是组2298的首领,因为其PID也是2298。下图描述了上图中3个进程的关系:
Linux上运行的程序会受到资源限制的影响,如物理设备限制(CPU、内存等)、系统策略限制(CPU时间等)、具体实现的限制(文件名的最大长度等)。Linux系统资源限制可通过以下函数来读取或设置:
rlim参数是rlimit类型的指针:
rlim_t是一个整数类型,它描述资源量。rlim_cur成员指定资源的软限制,rlim_max成员指定资源的硬限制。软限制是一个建议性的,最好不要超越的限制,如果超越,系统可能向进程发送信号以终止其运行,例如,当进程CPU时间超过其软限制时,系统将向进程发送SIGXCPU信号,当文件尺寸超过其软限制时,系统将向进程发送SIGXFSZ信号。硬限制一般是软限制的上限,普通程序可以减小硬限制,而只有以root身份运行的程序才能增加硬限制。我们可用ulimit命令修改当前shell环境下的资源限制(软限制和硬限制),这种修改将对该shell启动的所有后续程序有效,我们也可以通过修改配置文件来改变系统软限制和硬限制,且这种修改是永久的。
resource参数指定资源限制类型,下标列出了部分比较重要的资源限制类型:
setrlimit和getrlimit函数成功时返回0,失败则返回-1并设置errno。
有些服务器程序还需改变工作目录和根目录,一般,Web服务器的逻辑根目录并非文件系统的根目录,而是站点的根目录(对于Linux的Web服务来说,该目录一般是/var/www)。
获取进程当前工作目录和改变进程工作目录的函数:
buf参数指向的内存用于存储进程当前工作目录的绝对路径名,其大小由size参数指定。如果当前工作目录的绝对路径长度加上结束字符\0超过了size参数,则getcwd函数将返回NULL,并将errno设为ERANGE。如果buf参数为NULL且size参数非0,则getcwd函数可能在内部使用malloc函数动态分配内存,并将进程的当前工作目录存储在其中,此时我们必须自己释放getcwd函数在内部创建的这块内存。getcwd函数成功时返回一个指向目标存储区的指针(指向buf参数或getcwd函数在内部动态创建的缓存区),失败则返回NULL并设置errno。
chdir函数的path参数指定要切换到的目标目录,它成功时返回0,失败时返回-1并设置errno。
chroot函数改变进程根目录:
path参数指定要切换到的目标根目录,它成功时返回0,失败时返回-1并设置errno。chroot函数不改变进程的当前工作目录,所以调用chroot后,我们仍需使用chdir("/")
将工作目录切换到新的根目录。改变进程的根目录后,我们可能无法访问类似/dev的文件或目录,因为它们并非处于新的根目录之下,但调用chroot后,进程原先打开的文件描述符依然生效,所以我们可以利用这些早先打开的文件描述符来访问调用chroot后不能直接访问的文件或目录,尤其是一些日志文件。只有特权进程才能改变根目录。
以下函数可以让一个进程以守护进程的方式运行:
bool daemonize() {
// 创建子进程,关闭父进程,这样子进程就不是进程组首进程,就可以调用setsid了
pid_t pid = fork();
if (pid < 0) {
return false;
} else if (pid > 0) {
exit(0);
}
// 设置文件权限掩码,这样当进程创建新文件时,文件的额权限将是0777
umask(0);
// 创建新会话,本进程将成为进程组的首领
pid_t sid = setsid();
if (sid < 0) {
return false;
}
// 切换工作目录,防止当前工作目录所在文件系统不能卸载
if ((chdir("/")) < 0) {
return false;
}
// 关闭所有文件描述符
close(STDIN_FILENO);
close(STDOUT_FILENO);
close(STDERR_FILENO);
// 此处省略了关闭其他已打开的文件描述符的代码
// 将标准输入、标准输出、标准错误重定向到/dev/null文件
open("/dev/null", O_RDONLY);
open("/dev/null", O_RDWR);
open("/dev/null", O_RDWR);
return true;
}
Linux提供了完成以上功能的库函数:
nochdir参数用于指定是否改变工作目录,如果传0,则工作目录将被设为根目录,否则继续使用当前工作目录。noclose参数传0表示将标准输入、标准输出、标准错误重定向到/dev/null文件,否则不改变这些文件描述符。daemon函数成功时返回0,失败则返回-1并设置errno。