7. Linux服务器程序规范

第7章 Linux服务器程序规范

  • Linux服务器程序一般以后台进程形式运行。后台进程又称守护进程(daemon)。它没有控制终端,因而也不会意外接收到用户输入。守护进程的父进程通常是init进程(PID为1的进程)。

  • Linux服务器程序通常有一套日志系统,它至少能输出日志到文件,有的高级服务器还能输出日志到专门的UDP服务器。大部分后台进程都在/var/log目录下拥有自己的日志目录。

  • Linux服务器程序一般以某个专门的非root身份运行。比如mysqld、httpd、syslogd等后台进程,分别拥有自己的运行账户mysql、apache和syslog。

  • Linux服务器程序通常是可配置的。服务器程序通常能处理很多命令行选项,如果一次运行的选项太多,则可以用配置文件来管理。绝大多数服务器程序都有配置文件,并存放在/etc目录下。比如第4章讨论的squid服务器的配置文件是/etc/squid3/squid.conf。

  • Linux服务器进程通常会在启动的时候生成一个PID文件并存入/var/run目录中,以记录该后台进程的PID。比如syslogd的PID文件是/var/run/syslogd.pid。

  • Linux服务器程序通常需要考虑系统资源和限制,以预测自身能承受多大负荷,比如进程可用文件描述符总数和内存总量等。 在开始系统地学习网络编程之前,我们将用一章的篇幅来探讨服务器程序的一些主要的规范。

1. 日志

1.1 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的子配置文件则指定各类日志的目标存储文件。

1.2 syslog函数

应用程序使用syslog函数与rsyslogd守护进程通信。syslog函数的定义如下:

1
2
#include<syslog.h> 
void syslog(int priority,const char*message,...);

该函数采用可变参数(第二个参数message和第三个参数…)来结构化输出。

  • priority参数是所谓的设施值与日志级别的按位或。设施值的默认值是LOG_USER,我们下面的讨论也只限于这一种设施值。日志级别有如下几个:
1
2
3
4
5
6
7
8
9
#include<syslog.h> 
#define LOG_EMERG 0/*系统不可用*/
#define LOG_ALERT 1/*报警,需要立即采取动作*/
#define LOG_CRIT 2/*非常严重的情况*/
#define LOG_ERR 3/*错误*/
#define LOG_WARNING 4/*警告*/
#define LOG_NOTICE 5/*通知*/
#define LOG_INFO 6/*信息*/
#define LOG_DEBUG 7/*调试*/

下面这个函数可以改变syslog的默认输出方式,进一步结构化日志内容:

1
2
#include<syslog.h> 
void openlog(const char*ident,int logopt,int facility);

  • ident参数指定的字符串将被添加到日志消息的日期和时间之后,它通常被设置为程序的名字。
  • logopt参数对后续syslog调用的行为进行配置,它可取下列值的按位或:
1
2
3
4
#define LOG_PID 0x01/*在日志消息中包含程序PID*/ 
#define LOG_CONS 0x02/*如果消息不能记录到日志文件,则打印至终端*/
#define LOG_ODELAY 0x04/*延迟打开日志功能直到第一次调用syslog*/
#define LOG_NDELAY 0x08/*不延迟打开日志功能*/
  • facility参数可用来修改syslog函数中的默认设施值。

此外,日志的过滤也很重要。程序在开发阶段可能需要输出很多调试信息,而发布之后我们又需要将这些调试信息关闭。解决这个问题的方法并不是在程序发布之后删除调试代码(因为日后可能还需要用到),而是简单地设置日志掩码,使日志级别大于日志掩码的日志信息被系统忽略。下面这个函数用于设置syslog的日志掩码:

1
2
#include<syslog.h> 
int setlogmask(int maskpri);

maskpri参数指定日志掩码值。该函数始终会成功,它返回调用进程先前的日志掩码值。最后,不要忘了使用如下函数关闭日志功能:

1
2
#include<syslog.h> 
void closelog();

2. 用户信息

2.1 UID、EUID、GID和EGID

用户信息对于服务器程序的安全性来说是很重要的,比如大部分服务器都必须以root身份启动,但不能以root身份运行。下面这一组函数可以获取和设置当前进程的真实用户ID(UID)、有效用户ID(EUID)、真实组ID(GID)和有效组ID(EGID):

1
2
3
4
5
6
7
8
9
10
#include<sys/types.h> 
#include<unistd.h>
uid_t getuid();/*获取真实用户ID*/
uid_t geteuid();/*获取有效用户ID*/
gid_t getgid();/*获取真实组ID*/
gid_t getegid();/*获取有效组ID*/
int setuid(uid_t uid);/*设置真实用户ID*/
int seteuid(uid_t uid);/*设置有效用户ID*/
int setgid(gid_t gid);/*设置真实组ID*/
int setegid(gid_t gid);/*设置有效组ID*/

需要指出的是,一个进程拥有两个用户ID:UID和EUID。

  • EUID存在的目的是方便资源访问:它使得运行程序的用户拥有该程序的有效用户的权限。比如su程序,任何用户都可以使用它来修改自己的账户信息,但修改账户时su程序不得不访问/etc/passwd文件,而访问该文件是需要root权限的。那么以普通用户身份启动的su程序如何能访问/etc/passwd文件呢?窍门就在EUID。
  • 用ls命令可以查看到,su程序的所有者是root,并且它被设置了set-user-id标志。这个标志表示,任何普通用户运行su程序时,其有效用户就是该程序的所有者root。那么,根据有效用户的含义,任何运行su程序的普通用户都能够访问/etc/passwd文件。有效用户为root的进程称为特权进程(privileged processes)。EGID的含义与EUID类似:给运行目标程序的组用户提供有效组的权限。
  • 下面的代码清单7-1可以用来测试进程的UID和EUID的区别。 代码清单7-1 测试进程的UID和EUID的区别
1
2
3
4
5
6
7
8
#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。具体操作如下:

1
2
3
4
sudo chown root:root test_uid#修改目标文件的所有者为root 
sudo chmod+s test_uid#设置目标文件的set-user-id标志
./test_uid#运行程序
userid is 1000,effective userid is:0
从测试程序的输出来看,进程的UID是启动程序的用户的ID,而EUID则是root账户(文件所有者)的ID

2.2 切换用户

下面的代码清单7-2展示了如何将以root身份启动的进程切换为以一个普通用户身份运行。 代码清单7-2 切换用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static bool switch_to_user(uid_t user_id,gid_t gp_id) { 
/*先确保目标用户不是root*/
if((user_id==0)&&(gp_id==0)) {
return false;
}
/*确保当前用户是合法用户:root或者目标用户*/
gid_t gid=getgid();
uid_t uid=getuid();
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;
}

3. 进程间关系

3.1 进程组

Linux下每个进程都隶属于一个进程组,因此它们除了PID信息外,还有进程组ID(PGID)。我们可以用如下函数来获取指定进程的PGID:

1
2
#include<unistd.h> 
pid_t getpgid(pid_t pid);
  • 该函数成功时返回进程pid所属进程组的PGID,失败则返回-1并设置errno。
  • 每个进程组都有一个首领进程,其PGID和PID相同。进程组将一直存在,直到其中所有进程都退出,或者加入到其他进程组。

下面的函数用于设置PGID:

1
2
#include<unistd.h> 
int setpgid(pid_t pid,pid_t pgid);
  • 该函数将PID为pid的进程的PGID设置为pgid。如果pid和pgid相同,则由pid指定的进程将被设置为进程组首领;
  • 如果pid为0,则表示设置当前进程的PGID为pgid;
  • 如果pgid为0,则使用pid作为目标PGID。
  • setpgid函数成功时返回0,失败则返回-1并设置errno。
  • 一个进程只能设置自己或者其子进程的PGID。
  • 并且,当子进程调用exec系列函数后,我们也不能再在父进程中对它设置PGID。

3.2 会话

一些有关联的进程组将形成一个会话(session)。下面的函数用于创建一个会话:

1
2
#include<unistd.h> 
pid_t setsid(void);

该函数不能由进程组的首领进程调用,否则将产生一个错误。对于非组首领的进程,调用该函数不仅创建新会话,而且有如下额外效果: - 调用进程成为会话的首领,此时该进程是新会话的唯一成员。 - 新建一个进程组,其PGID就是调用进程的PID,调用进程成为该组的首领。 - 调用进程将甩开终端(如果有的话)。 - 该函数成功时返回新的进程组的PGID,失败则返回-1并设置errno。 - Linux进程并未提供所谓会话ID(SID)的概念,但Linux系统认为它等于会话首领所在的进程组的PGID,并提供了如下函数来读取SID:

1
2
#include<unistd.h> 
pid_t getsid(pid_t pid);

3.3 用ps命令查看进程关系

执行ps命令可查看进程、进程组和会话之间的关系:

1
2
3
4
5
ps-o pid,ppid,pgid,sid,comm|less
PID PPID PGID SID COMMAND
1943 1942 1943 1943 bash
2298 1943 2298 1943 ps
2299 1943 2298 1943 less
  • 我们是在bash shell下执行ps和less命令的,所以ps和less命令的父进程是bash命令,这可以从PPID(父进程PID)一列看出。
  • 这3条命令创建了1个会话(SID是1943)和2个进程组(PGID分别是1943和2298)。
  • bash命令的PID、PGID和SID都相同,很明显它既是会话的首领,也是组1943的首领。
  • ps命令则是组2298的首领,因为其PID也是2298。

4. 系统资源限制

Linux上运行的程序都会受到资源限制的影响,比如物理设备限制(CPU数量、内存数量等)、系统策略限制(CPU时间等),以及具体实现的限制(比如文件名的最大长度)。Linux系统资源限制可以通过如下一对函数来读取和设置:

1
2
3
#include<sys/resource.h> 
int getrlimit(int resource,struct rlimit*rlim);
int setrlimit(int resource,const struct rlimit*rlim);

rlim参数是rlimit结构体类型的指针,rlimit结构体的定义如下:

1
2
3
4
struct rlimit { 
rlim_t rlim_cur;
rlim_t rlim_max;
};

  • rlim_t是一个整数类型,它描述资源级别。
  • rlim_cur成员指定资源的软限制,rlim_max成员指定资源的硬限制。
  • 软限制是一个建议性的、最好不要超越的限制,如果超越的话,系统可能向进程发送信号以终止其运行。
    • 例如,当进程CPU时间超过其软限制时,系统将向进程发送SIGXCPU信号;
    • 当文件尺寸超过其软限制时,系统将向进程发送SIGXFSZ信号(见第10章)。
  • 硬限制一般是软限制的上限。
    • 普通程序可以减小硬限制,而只有以root身份运行的程序才能增加硬限制。
  • 此外,我们可以使用ulimit命令修改当前shell环境下的资源限制(软限制或/和硬限制),这种修改将对该shell启动的所有后续程序有效。
  • 我们也可以通过修改配置文件来改变系统软限制和硬限制,而且这种修改是永久的,详情见第16章。
  • resource参数指定资源限制类型。表7-1列举了部分比较重要的资源限制类型。
  • setrlimit和getrlimit成功时返回0,失败则返回-1并设置errno。

5. 改变工作目录和根目录

有些服务器程序还需要改变工作目录和根目录,比如我们第4章讨论的Web服务器。一般来说,Web服务器的逻辑根目录并非文件系统的根目录“/”,而是站点的根目录(对于Linux的Web服务来说,该目录一般是/var/www/)。

获取进程当前工作目录和改变进程工作目录的函数分别是:

1
2
3
#include<unistd.h> 
char*getcwd(char*buf,size_t size);
int chdir(const char*path);
  • 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,其定义如下:

1
2
#include<unistd.h> 
int chroot(const char*path);
  • path参数指定要切换到的目标根目录。它成功时返回0,失败时返回-1并设置errno。
  • chroot并不改变进程的当前工作目录,所以调用chroot之后,我们仍然需要使用chdir(“/”)来将工作目录切换至新的根目录。
  • 改变进程的根目录之后,程序可能无法访问类似/dev的文件(和目录),因为这些文件(和目录)并非处于新的根目录之下。不过好在调用chroot之后,进程原先打开的文件描述符依然生效,所以我们可以利用这些早先打开的文件描述符来访问调用chroot之后不能直接访问的文件(和目录),尤其是一些日志文件。
  • 此外,只有特权进程才能改变根目录。

6. 服务器程序后台化

最后,我们讨论如何在代码中让一个进程以守护进程的方式运行。守护进程的编写遵循一定的步骤[2],下面我们通过一个具体实现来探讨,如代码清单7-3所示。 代码清单7-3 将服务器程序以守护进程的方式运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
bool daemonize() { 
/*创建子进程,关闭父进程,这样可以使程序在后台运行*/
pid_t pid=fork();
if(pid<0) {
return false;
} else if(pid>0) {
exit(0);
}
/*设置文件权限掩码。当进程创建新文件(使用open(const char*pathname,int
flags,mode_t mode)系统调用)时,文件的权限将是mode&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提供了完成同样功能的库函数:

1
2
#include<unistd.h> 
int daemon(int nochdir,int noclose);

  • nochdir参数用于指定是否改变工作目录,如果给它传递0,则工作目录将被设置为“/”(根目录),否则继续使用当前工作目录。
  • noclose参数为0时,标准输入、标准输出和标准错误输出都被重定向到/dev/null文件,否则依然使用原来的设备。
  • 该函数成功时返回0,失败则返回-1并设置errno。

7. Linux服务器程序规范
http://binbo-zappy.github.io/2024/12/16/Linux高性能服务器编程-游双/7-Linux服务器程序规范/
作者
Binbo
发布于
2024年12月16日
许可协议