免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

Linux进程关系与守护进程:网络服务故障排查的底层逻辑

Linux进程关系与守护进程:网络服务故障排查的底层逻辑 做Linux运维这些年我有个习惯服务器一出现网络服务异常先不看TCP状态也不急着抓包而是先打开终端问自己三个问题当前服务到底有几个进程在跑它们之间的父子关系是什么哪个进程是真正监听端口、接收连接的很多线上问题比如Nginx起不来、端口被占用、PHP-FPM出现一堆defunct进程、systemd报服务启动失败追到根上基本都是进程间关系没搞清楚。再往后走一步几乎所有需要长期对外提供服务的程序本质上都是一个守护进程。这篇博客就围绕这两块展开把进程组、会话、父子进程、孤儿和僵尸进程、守护进程的实现机制讲透再把systemd管理网络守护进程的常用姿势和排查命令整理出来希望对做运维或服务端开发的朋友有帮助。1. 进程间关系是网络服务的底层骨架1.1 为什么网络服务绕不开进程关系网络服务几乎没有单进程跑到底的。Nginx是master加workerPHP-FPM是master加workerSSHD每来一个客户端会话就fork一个sshd子进程。就连你通过systemd启动的一个Java服务如果内部开了线程池虽然操作系统视角只是单进程但进程内部同样在并发处理网络连接。多进程模型之所以普遍是因为隔离性好某个worker进程崩了master还能拉起新的worker不至于整个服务断掉。这个模式带来一个必然结果进程之间不是孤立的而是按父子、同组、同会话形成一棵树甚至一张网。你用systemctl stop nginx最终是向master进程发信号master再通知每个worker退出并回收退出状态。如果你不理解这条链路直接kill -9杀掉一个不认识的workernginx主进程马上会再拉起新worker表面看起来没变化但连接数、日志、状态全被打乱了。所以聊Linux网络绕不开进程关系。1.2 进程、进程组、会话三层关系怎么分层要理清关系先从几个ID入手PID、PPID、PGID、SID。PID是进程身份证PPID是父进程的PIDPGID是进程组ID一个进程组里所有进程可以一起被信号控制SID是会话ID会话是一组进程组的集合通常对应一个登录终端。我习惯用一个类比PID像工号PPID是汇报给谁进程组像一个项目组会话是整个部门大家都挂在同一个终端上打卡。查询的时候直接一行命令就能看到完整关系ps -eo pid,ppid,pgid,sid,comm | head -20shell执行一条管道命令时会把管道里的所有进程放进同一个进程组这就是为什么你按CtrlC管道里的多个进程会一起收到SIGINT信号。前台后台作业本质是会话里的前台进程组和后台进程组。这里要特别强调会话控制终端属于会话终端关闭时内核会给会话首进程和前台进程组发SIGHUP。如果你只是把程序放到后台运行也就是加个它仍然在这个会话里终端一关就可能没命。守护进程的第一要务就是脱离会话也就是后面要说的setsid。2. 网络场景下的进程生命周期从父子到孤儿、僵尸2.1 网络服务里的父子进程怎么建立看nginx进程树用pstree看一台装了Nginx的服务器典型输出类似这样systemd─┬─nginx───2*[nginx] ├─sshd───sshd───bash───pstree └─php-fpm───5*[php-fpm]这里的systemd是1号进程nginx master的PPID就是systemd。master进程持有一批worker子进程具体数量由worker_processes决定。master的工作是读取配置、绑定端口、fork worker、处理信号worker才真正accept连接、解析HTTP、发响应。我的实操经验是排查网络服务时先跑pstree -ap master_pid几秒钟就能判断主进程是否正常。如果worker进程数量和配置对不上说明fork流程出过问题如果某个worker变成Z状态说明master没有及时回收子进程退出状态。父子关系是所有问题的起点先把这棵树看明白再谈抓包和调参。2.2 孤儿和僵尸两种容易混淆的进程状态进程fork之后父子进程各自独立运行。最常见的两种异常状态是孤儿进程和僵尸进程。孤儿进程是父进程先退出子进程还在运行这时子进程会被1号进程systemd收养由systemd负责后续回收。孤儿本身不可怕很多守护进程在双fork过程中就是主动制造孤儿。僵尸进程是子进程先退出父进程却一直没有调用wait或waitpid读走退出状态内核里只留下一个task_struct和pid进程表中状态是Z命令行里还会带defunct标识。僵尸进程的危害不在于占CPU或内存因为资源已经释放了真正的问题是PID被占着。Linux的PID默认上限通常是32768如果php-fpm的worker不断退出而master没有wait过一段时间所有worker都会变成Z新worker fork不出来线上就会出现请求堆积。而且僵尸进程不能用kill -9杀因为它已经死了你只能处理它的父进程要么让父进程去wait要么重启父进程让僵尸变成孤儿后由systemd统一回收。排查命令很简单ps -eo pid,ppid,stat,cmd | awk $3 ~ /^Z/ {print}如果看到PPID是1而状态还是Z一般是systemd还没来得及收割的瞬时现象正常很快消失如果PPID指向应用进程且数量持续增长基本就是应用忘了处理SIGCHLD需要看代码或者重启服务。3. 守护进程把Linux网络服务常驻后台的机制3.1 网络服务为什么必须成为守护进程网络服务的本质是长期对外提供服务。服务启动后如果它跟你的shell绑在一起你退出终端SIGHUP就会把它带走。要让它不受终端影响就需要把它变成守护进程。一个真正的daemon通常具备几个特征PPID为1systemd没有控制终端工作目录是/或服务自己的目录umask被重置标准输入输出错误重定向到/dev/null或日志文件。这里要特别注意区分后台作业和守护进程。你在命令行敲nohup ./server 只是让进程忽略SIGHUP并且放到后台它仍然属于当前shell的会话。守护进程需要调用setsid创建一个全新会话彻底脱离原来的终端。systemd流行之前写网络服务的人几乎人手一个daemonize函数现在虽然systemd帮我们做了大部分事情但理解底层机制对排查问题帮助极大。3.2 经典双fork实现每一步都有原因下面这个简化的C语言daemonize函数是很多老一代网络服务程序的雏形值得逐行看懂#include stdio.h #include stdlib.h #include unistd.h #include sys/types.h #include sys/stat.h #include fcntl.h void daemonize(void) { pid_t pid; pid fork(); if (pid 0) exit(EXIT_FAILURE); if (pid 0) exit(EXIT_SUCCESS); // 父进程退出让子进程变成孤儿 if (setsid() 0) exit(EXIT_FAILURE); // 创建新会话脱离控制终端 pid fork(); if (pid 0) exit(EXIT_FAILURE); if (pid 0) exit(EXIT_SUCCESS); // 第二次fork避免重新获得控制终端 umask(0); // 重置文件权限掩码 chdir(/); // 不占用挂载点 int fd open(/dev/null, O_RDWR); if (fd 0) exit(EXIT_FAILURE); dup2(fd, STDIN_FILENO); dup2(fd, STDOUT_FILENO); dup2(fd, STDERR_FILENO); if (fd STDERR_FILENO) close(fd); }为什么第一次fork之前要先fork因为setsid有一个限制只有非进程组组长的进程才能调用成功。你直接在一个进程组组长里调setsid会失败先fork出来的子进程PID和PGID不相等不是组长调用setsid也就顺利了。第二次fork同样有讲究第一次fork后的子进程执行setsid后已经成为新会话的会话首进程后续如果它打开一个终端设备可能会自动获得控制终端。再fork一次让最终运行的孙进程不是会话首进程就彻底断了这条路。chdir到根目录是为了避免服务进程占用某个挂载点导致系统想卸载文件系统时“设备忙”。umask(0)是为了不让父进程继承的权限掩码影响服务创建日志文件。重定向标准输入输出错误则是因为网络守护进程不需要终端交互日志应该写到文件或syslog。现在生产环境很少自己手写这套流程了但如果你接手的是一个老项目或者二进制程序本身就是按双fork方式daemonize的后面用systemd配置时就会遇到Typeforking的选择这是后话。3.3 不写代码也能把普通进程变成守护进程有些场景你不改代码也想把一个普通网络服务进程放后台常驻。命令层面有几种常见方案nohup ./server server.log 21 setsid ./servernohup加只是忽略SIGHUP进程仍然在当前会话setsid直接让进程创建新会话更接近守护进程。还有一种disown只是把作业从shell作业表里去掉避免shell退出时提醒你但进程还是挂在这个会话下。三者对比如下方式是否新会话是否能脱离SIGHUP适用场景nohup 否是临时跑命令setsid是是临时把某个服务脱离终端disown否不一定当前会话内的作业管理systemd是是生产环境正式服务建议能交给systemd管理的就不要用命令硬搞毕竟systemd还负责开机自启、崩溃重启、日志采集和资源限制。上述命令行方式更适合做实验或临时调试。4. 网络守护进程与systemd从super server到Socket激活4.1 传统inetd/xinetd的思路很早之前Linux系统对外提供的一些基础网络服务比如时间同步、远程登录等不会让每个服务都常驻一个进程而是用一个超级服务进程统管所有端口这个超级服务进程就是inetd后来是xinetd。xinetd同时监听一批端口有客户端连接到达时它才根据端口号fork并exec对应的服务程序服务处理完连接后退出。这种按需启动的方式对低频访问的服务很省内存但问题也很明显每次连接都要fork加exec开销大高并发场景下扛不住而且服务之间的安全隔离也不够精细。不过这个思路并没有过时反而被systemd发扬光大了。systemd的Socket激活本质上就是升级版的inetd思想先创建监听Socket但不等服务进程常驻内存真正有连接来了再拉起服务进程并把已经建立好的监听socket传给服务。区别在于systemd不是给每个连接fork一个进程而是默认由服务进程自己accept连接性能好得多。4.2 systemd的Socket激活配置监听端口而不启动服务以自研的demo服务为例。先创建一个socket unit让systemd负责监听TCP 9000端口# /etc/systemd/system/demo.socket [Unit] DescriptionDemo TCP socket [Socket] ListenStream127.0.0.1:9000 Acceptno [Install] WantedBysockets.target再写一个service unit定义真正处理连接的守护进程# /etc/systemd/system/demo.service [Unit] DescriptionDemo Daemon Service Requiresdemo.socket Afterdemo.socket [Service] ExecStart/opt/demo/server Typesimple启用后观察端口systemctl daemon-reload systemctl enable --now demo.socket ss -lntp | grep :9000此时你会发现监听9000端口的进程是systemd而不是demo服务进程。只有当第一个连接到达时systemd才会把demo.service拉起来并把监听socket通过文件描述符传给服务进程。如果服务支持这种机制它可以通过systemd设置的环境变量LISTEN_PID、LISTEN_FDS判断自己是不是被socket激活的再决定怎么获取socket。这个方案有个很大的好处服务重启时监听socket仍然在systemd手里不会出现“Address already in use”也不会因为重启进程导致短时间连接全部被拒。如果你的服务程序本身支持socket激活非常推荐尝试。如果你的服务是自己双fork的daemonize老程序在systemd里配置时要注意[Service] Typeforking PIDFile/run/demo.pid ExecStart/opt/demo/server --daemonTypeforking告诉systemdExecStart启动的那个进程会fork一个子进程父进程很快退出真正服务是子进程。PIDFile则让systemd知道该去哪个文件读主进程PID否则它可能误判服务已经退出。这里踩坑的人很多检查报错时别忘了看Type和PIDFile。5. 进程关系引发的网络故障排查与实战5.1 Address already in use不是杀掉端口进程就完事最经典的报错是nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)。第一反应是看端口被谁占了ss -lntp | grep :80 lsof -i :80 -P -n如果看到监听进程是之前的nginx master不要急着kill -9。nginx这类网络守护进程有完整的信号机制应该systemctl reload nginx让它重新加载配置或发TERM信号让它优雅退出由master自己去关掉worker。如果一上来就kill -9 masterworker可能变成孤儿或残留状态旧连接直接断开pid文件、unix socket也可能清理不干净。还有一种情况就是上面说的socket激活systemd已经通过socket unit监听了端口服务进程内部又自己bind同一个端口两边抢监听必然起不来。所以配置服务前先想清楚谁负责监听避免双绑。5.2 僵尸进程拖垮网络服务重启父进程而不是杀僵尸我曾经碰到过一个线上PHP-FPM故障ps aux里能看到一堆defunct状态的进程请求全部卡住。先别慌用命令定位ps -eo pid,ppid,stat,cmd | awk $3 ~ /^Z/ {print}把输出里每个Z进程的PPID找出来发现父进程都是php-fpm master。这时候最合理的操作是systemctl restart php-fpmmaster进程退出后所有僵尸子进程会被systemd接管并回收新master起来后正常处理SIGCHLD僵尸自然清零。直接kill -9 僵尸PID是没有用的僵尸进程已经死了内核只等父进程来读退出状态你杀不动它。如果重启父进程后僵尸仍然持续出现就要怀疑程序在信号处理上有问题是不是没捕获SIGCHLD或者事件循环阻塞导致waitpid一直没有机会执行。这种问题只能从代码层面解决重启只是治标。5.3 信号误伤为什么我很少用pkill重启服务有一次图快我直接用pkill -9 php-fpm把master和worker一起杀了结果进程虽然没了但PID文件、unix socket、共享内存文件全部残留重启时各种诡异报错。后来我学到一个原则网络服务是一棵进程树操作一定要从树根下手。systemctl stop/restart是最标准的做法它会向主进程发信号由主进程协调子进程退出和回收。如果必须手动操作先看进程组关系。kill -- -PGID可以一次把信号发给整个进程组但使用前务必要确认PGID不会误伤其他程序。比如终端里有个前台作业和后台作业它们可能属于不同进程组但同一会话中还有其他无关进程。宁可多敲一个pstree -ap也不要贸然对整个PGID开火。5.4 网络连接与进程的对应关系处理连接调度问题时我常用ss -tnp看每个TCP连接由哪个进程持有ss -tnp state established ( sport :443 or dport :443 )如果某个worker持有的连接数异常多多半是Nginx的worker_connections配置不合理或者某个上游连接没有及时释放。对于单进程多线程服务ss -tnp显示的PID只有一个想看线程信息可以用ps -eLf | grep pid或者top -H -p pid。网络守护进程还有一类隐藏问题就是文件描述符耗尽当进程打开的fd达到上限accept会返回ENFILE或EMFILE。检查ls /proc/pid/fd | wc -l和ulimit -n这看起来是系统资源问题但本质上也和进程生命周期管理密切相关。6. 我的经验工具箱和几个小技巧6.1 几组命令快速梳理进程关系最常用的组合我整理成了清单排查网络服务时按顺序跑pstree -ap pid # 看进程树和PID快速定位master和worker ps -eo pid,ppid,pgid,sid,stat,comm # 看进程归属关系 cat /proc/pid/status # 看单个进程的PPid、NSpid、信号信息 ss -tnp # 看端口和进程占用关系 ls -l /proc/pid/fd | grep socket # 看进程打开的socket FD数量我个人的习惯是先用pstree因为它呈现的是树状结构一眼就能看出来父子关系对不对。再用ps过滤僵尸状态检查有没有异常。最后才看端口和连接数。这个顺序能避免很多“盲目重启”的操作。6.2 写守护进程时的三个容易踩的坑第一个坑是忘了处理SIGCHLD。网络服务的worker进程是会被外部条件或超时机制主动终止的父进程如果不调用waitpid回收僵尸进程会越来越多。如果你根本不关心子进程退出状态可以直接忽略SIGCHLD信号内核会自动完成回收signal(SIGCHLD, SIG_IGN);或者用sigaction设置SA_NOCLDWAIT效果类似。但如果你需要记录子进程退出码就必须老老实实写SIGCHLD处理函数配合waitpid(-1, status, WNOHANG)循环收割。第二个坑是systemd配置与程序启动方式不匹配。前面提过Typesimple和Typeforking的区别自研程序如果明明会fork却配置成simplesystemd就会因为主进程很快退出而判定服务失败反过来一个不该fork的程序配成forkingsystemd又会一直等PIDFile超时报错。写service unit前先弄清楚程序行为再决定Type。第三个坑是socket激活与程序bind逻辑冲突。程序如果自己监听一个端口就不要再用systemd的ListenStream同端口监听同一个端口否则必然报错。选了socket激活方式程序内部又没处理LISTEN_PID和LISTEN_FDS连接进来也没人accept。新东西要配套用别混搭。6.3 快速验证一个新守护进程能不能跑给新写的网络守护进程做上线前验证我一般不走完整systemd流程而是先在前台跑一遍直接执行./server观察启动日志确认端口能监听、日志能输出。用ss -lntp确认监听PID就是当前进程再拿一个客户端连一下确保协议正常。如果没问题再改用setsid ./server或systemd unit方式转后台。最后打开一个全新终端模拟会话退出确认服务还在再用systemctl status检查状态。先前台跑日志清楚排障时间最少。一上来就systemctl enable一旦起不来journal里的报错信息往往比前台输出含糊得多。我个人在实际排障中的一个体会是进程关系树的优先级高于一切表象。无论报错是端口冲突、僵尸进程还是服务启动失败先问自己树根在哪、父进程是谁、子进程状态如何问题基本就解了一半。很多看似网络层的故障最后其实都落在进程管理和守护进程机制上。希望这篇东西能帮你在下次踩坑时更快定位。
返回列表