ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux守护进程完全指南:从后台运行原理到systemd实践

Linux守护进程完全指南:从后台运行原理到systemd实践 不做开场白直接进入正题。今天我想好好聊聊 Linux 守护进程这件事。很多人在 Linux 上跑服务第一步就是nohup ./app 或者用把程序丢到后台然后关掉终端以为这就完事了。等第二天连上服务器一看进程没了日志也没了一脸懵。这其实就是没弄明白守护进程和普通后台进程的区别。我这几年在服务器上部署过不少服务从最简单的自研监控脚本到企业级的消息队列踩过的坑不少这篇文章把我对守护进程的理解、写法、坑点、以及和 systemd 的配合一次说清楚希望能帮刚接触 Linux 服务管理的朋友少走些弯路。文章内容会涵盖典型场景、代码示例、排查思路和几个真实的踩坑记录适合刚入门的新人也适合想把自己的脚本或服务写得更加规范的老手。1. 为什么需要守护进程从一次线上事故说起1.1 一个本该常驻却悄悄消失的服务先讲一个我印象很深的线上事故。当时公司有个内部的数据同步脚本是用 Python 写的大概每五分钟从第三方接口拉一次数据写入本地数据库。最初部署的人图省事直接在 SSH 终端里跑python sync_data.py 然后断开终端就走了。前两周一切正常直到有一天发版有人顺手重启了一下跳板机结果那个脚本就再也找不到了。数据同步一停就是三天等发现的时候积压的数据已经把接口限流打满了。这个事故的根源很简单只是把进程放到了后台它仍然是当前终端会话的子进程。只要终端会话结束内核就会向这个进程组发送 SIGHUP 信号默认行为直接终止进程。守护进程要解决的核心问题恰恰就是脱离终端会话的束缚让它真正变成系统级的常驻服务而不是某个终端下的附属品。1.2 守护进程的本质定义守护进程daemon在 Linux 里的定义其实很明确它是在后台运行、没有控制终端、独立于会话的进程。系统启动时就有一批这样的进程在跑比如 crond、sshd、rsyslogd它们共同的特点是父进程 PID 是 1init/systemd也就是说它已经被孤儿化由 init 进程收养。没有控制终端所以不会因为终端关闭而被 SIGHUP 杀掉。工作目录通常切换到一个持久存在的目录比如/或/var/run避免占用某个已被删除的挂载点目录。文件权限掩码umask会主动重置确保创建的文件权限符合预期不受启动者环境影响。标准输入、输出、错误默认重定向到/dev/null或日志文件不会向终端输出内容。这些特征不是凭空定义的每一条背后都有实际的意义。比如没有控制终端这一点就牵扯出另一个问题守护进程日志打到哪儿去如果还习惯用 printf 往 stdout 打日志一旦你把它 daemon 化这些输出基本就丢了。所以后面写守护进程的时候日志设计是要提前想清楚的。2. 手写一个符合规范的守护进程C 语言实现详解很多人以为守护进程是 systemd 发明的概念其实不是。systemd 只是把守护进程的启动和管理标准化了守护进程本身的写法早就有了一套经典范式。用 C 语言手写一遍是理解这套范式最好的方式。2.1 核心步骤拆解fork、setsid、重定向一个规范的守护进程创建流程可以拆成下面几步第一步fork()一次然后父进程退出。这一步的目的是让子进程成为孤儿进程由 init 收养确保它不会被当前 shell 的作业控制干扰。第二步在子进程中调用setsid()创建新会话。调用成功后当前进程会成为新会话的首进程同时摆脱控制终端。这一步是整个守护进程化最关键的一环。第三步再fork()一次父进程也就是第一次 fork 出来的子进程退出。这一步是为了确保新进程不是会话首进程从而无法重新申请控制终端。虽然现在很多场景下不做这一步也不会出问题但规范起见建议做。第四步chdir(/)切换工作目录避免占用某个动态挂载点。第五步umask(0)重置文件权限掩码。第六步把 stdin、stdout、stderr 全部重定向到/dev/null或日志文件。这是很多人容易忽略的因为一旦缺少这一步库函数里某些往 stderr 写的错误信息可能会出现意想不到的行为。2.2 一份可以直接编译运行的示例代码下面这份代码是我实际项目里简化出来的模板去掉了业务逻辑只保留守护进程化的骨架#include stdio.h #include stdlib.h #include unistd.h #include sys/types.h #include sys/stat.h #include fcntl.h #include signal.h #include string.h #include syslog.h void daemonize() { pid_t pid fork(); if (pid 0) { perror(fork失败); exit(1); } /* 父进程退出 */ if (pid 0) { exit(0); } /* 子进程创建新会话 */ if (setsid() 0) { perror(setsid失败); exit(1); } /* 第二次fork确保不是会话首进程 */ pid fork(); if (pid 0) { perror(第二次fork失败); exit(1); } if (pid 0) { exit(0); } /* 切换工作目录 */ chdir(/); /* 重置umask */ umask(0); /* 重定向标准输入输出 */ int fd open(/dev/null, O_RDWR); if (fd 0) { dup2(fd, STDIN_FILENO); dup2(fd, STDOUT_FILENO); dup2(fd, STDERR_FILENO); if (fd 2) { close(fd); } } } int main() { daemonize(); /* 使用syslog记录日志因为stdout已经没意义了 */ openlog(mydaemon, LOG_PID | LOG_CONS, LOG_DAEMON); syslog(LOG_INFO, 守护进程启动成功); /* 这里写你的业务逻辑比如一个简单的循环 */ while (1) { syslog(LOG_INFO, 守护进程运行中...); sleep(30); } closelog(); return 0; }编译方式gcc -o mydaemon mydaemon.c ./mydaemon ps -ef | grep mydaemon执行后你会看到进程的 PPID 是 1TTY 那一列是?说明它已经成功脱离了终端。这就是一个最基础的守护进程。2.3 为什么要有两次 fork有朋友问过我第二次 fork 到底是不是多余的说实话单从让进程在后台运行这个目标来看一次 fork 加 setsid 就够了这也是很多简化版本的做法。但第二次 fork 有一个非常实际的意义防止进程重新获得控制终端。具体来说当 setsid 之后当前进程已经是会话首进程了。如果这时候它再去打开一个终端设备文件内核有可能让它重新获得该终端作为控制终端这会给后续的行为带来不确定性。第二次 fork 之后子进程不再是会话首进程即使它打开终端设备也不会被设置为控制终端。等于从机制上彻底断开了和终端的可能的联系。另外第二次 fork 在 System V 系的系统上被认为更规范因为 System V 的守护进程规则里明确要求确保进程不是会话首进程。虽然在 Linux 上不一定会出问题但既然规范摆在那里多花一次 fork 的成本非常低换取更全面的行为保证这笔账是划算的。3. 更简单也更现代的方案systemd 接管你的守护进程手写 C 版本的守护进程能让人理解底层机制但如果你问我日常部署服务推荐用哪种方式我的答案是不要自己写守护进程逻辑让 systemd 来管。3.1 从nohup到 systemd 的演进逻辑以前没有 systemd 的年代写守护进程的活只能自己干。后来大家发现每个程序都抄一遍 fork/setsid 代码其实很蠢而且还有各种历史包袱。systemd 的出现把如何成为守护进程这件事从程序员手里收走了你不需要在代码里 fork不需要 setsid你只需要写一个 service 文件告诉 systemd帮我跑这个程序、崩溃了帮我重启、开机帮我启动剩下的全部由 systemd 完成。甚至systemd 还提供了一种反向的守护进程化机制。如果你写的程序已经有自己 daemonize 的逻辑systemd 可以通过Typeforking来适配如果你的程序是前台运行的那就用Typesimple反而更简单。3.2 写一个实用的 service 文件这里用一个我之前写过的 Node.js 服务举个例子。假设我有一个项目在/opt/myapp下启动命令是node server.js进程需要一个 PID 文件来做后续管理。那 service 文件可以这样写[Unit] DescriptionMy Node.js App Service Afternetwork.target mysql.service [Service] Typesimple Userdeploy Groupdeploy WorkingDirectory/opt/myapp ExecStart/usr/bin/node server.js Restartalways RestartSec5 EnvironmentNODE_ENVproduction StandardOutputjournal StandardErrorjournal SyslogIdentifiermyapp [Install] WantedBymulti-user.target关键配置我逐一说明Afternetwork.target mysql.service确保网络和数据库服务先就绪再启动本服务避免一启动就连不上数据库。Userdeploy指定运行用户绝对不要用 root 跑业务服务这是安全底线。Restartalways无论进程以什么方式退出都自动拉起来。配合RestartSec5崩溃后等 5 秒再启动避免疯狂重启打爆日志。StandardOutputjournal、StandardErrorjournal把服务的标准输出和错误都交给 journald 管理这样可以直接用journalctl -u myapp查看日志比手动写日志文件再轮转省心很多。SyslogIdentifiermyapp给日志打一个固定标识后面按服务名过滤日志就靠它。写好之后的操作流程sudo cp myapp.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable myapp sudo systemctl start myapp sudo systemctl status myappenable的作用是创建开机自启动的软链接start是立即启动。这两步分开执行的好处是不会出现开机自启还没配置好但服务已经在跑的情况。3.3 日志管理和查看技巧接了 systemd 之后日志查看变成了日常高频操作。比较实用的几个命令# 查看服务最近20行日志并实时跟踪 journalctl -u myapp -e -f # 查看今天所有日志 journalctl -u myapp --since today # 查看某个时间段的日志 journalctl -u myapp --since 2024-06-01 10:00:00 --until 2024-06-01 12:00:00有朋友会遇到一个问题服务器跑久了journald 日志文件越积越大把/var/lib/journal空间吃满了。默认策略下 journald 是根据文件大小上限轮转的没设上限的话确实可能膨胀。早期我就在一台低配机器上因为这个把根分区写满过。建议在/etc/systemd/journald.conf里加上SystemMaxUse500M限制整个 journal 最多占用 500MB 磁盘空间超出后最老的日志会被自动清理。改完配置后重启 journald 服务sudo systemctl restart systemd-journald这个限制对生产环境来说至关重要尤其是那些跑了几百个容器的主机日志量真的是以 GB 为单位增长的。4. 两类典型实战Python 脚本守护化与外部程序托管4.1 用 systemd 把 Python 脚本变成常驻服务Python 写服务不稀奇问题是什么方式去跑。我见过有人写while True: do_something(); sleep(60)的脚本然后用 nohup 丢后台结果一跑就是几个月不更新。后来运维同学接手想重启都不知道从哪下手——因为它根本不在 systemd 的管辖范围内。我的建议是所有需要长期运行的 Python 脚本哪怕只是个监控脚本都统一纳入 systemd 管理。下面以一个采集脚本为例[Unit] DescriptionData Collector Script Afternetwork-online.target [Service] Typesimple Usercollector WorkingDirectory/opt/collector ExecStart/usr/bin/python3 /opt/collector/collect.py Restarton-failure RestartSec10 [Install] WantedBymulti-user.target这里和 Node.js 示例的区别主要是Restarton-failure普通脚本如果正常退出比如某个跑批任务结束不应该被自动拉起只有异常退出非零退出码时才重启。选restart策略的时候建议先想清楚这个服务退出了是否应该被拉起来——后台常驻服务用always跑批类任务用on-failure这个区分很重要。另外要留意的是ExecStart里的解释器路径。很多人图省事写python3但 systemd 的环境 PATH 很精简经常找不到/usr/local/bin/python3。最稳妥的办法是用which python3查出绝对路径再填进去。还有一个不算 bug 但容易让新手困惑的事Python 脚本如果直接向 stdout 输出比如用print()在 systemd 下这些输出都进 journal。如果脚本本身有 logging 模块建议直接用StreamHandler打到 stdout这样就不需要额外写日志文件也不用担心日志轮转问题。journald 会帮你做日志的按大小轮转查询还方便。4.2 托管一个外部二进制程序时的坑比起管自己写的 Python 脚本更常见也更头疼的是托管别人编译好的二进制程序比如某个开源软件、某个商业 agent。这种程序你不能改它的源码只能通过 service 文件去适配它。遇到这类程序第一件事是搞清楚它属于哪种类型前台运行型启动后进程不退出一直占着终端。对应Typesimple。后台型启动后立即返回真正的工作进程 fork 到后台。对应Typeforking。一次性执行型执行完特定任务后自行退出。对应Typeoneshot。判断方法不复杂直接把程序放终端里跑一下如果终端被占住了是前台型如果终端立刻能敲命令而ps里能看到新进程是后台型。区分清楚类型是 service 文件正确工作的前提Type配错了systemd 对服务状态的判断就会出错可能出现明明进程还活着systemd 却说 failed的怪事。有一次我托管某个内部服务它启动时要求必须有一个 PID 文件参数。service 文件写成这样Typeforking PIDFile/var/run/myservice.pid ExecStart/usr/bin/myservice -d -p /var/run/myservice.pid-d让它后台运行-p指定 PID 文件路径。这里有个容易踩的坑PID 文件路径不能随意写在 /var/run 下很多程序要求这个目录存在且有写权限。系统重启那么多次/var/run 可能被清理如果程序没有自动创建目录就会启动失败。解决方法是加一行ExecStartPre/usr/bin/mkdir -p /var/run/myservice ExecStartPre/usr/bin/chown myservice:myservice /var/run/myserviceExecStartPre会在ExecStart之前执行正好可以用来准备运行环境。这种预执行机制在处理二进制程序的路径依赖问题时非常好用。4.3 踩过的坑PID 文件未更新导致 systemd 误判服务状态接上一个话题讲一个具体的事故。某次我托管一个 Java 写的服务端程序service 文件里有PIDFile字段。程序启动正常我用systemctl status查看显示 active (running)。过了几个小时我需要确认进程是否真的还活着执行ps -ef | grep java结果发现 PID 文件里的进程号早已不存在了但 systemd 还认为服务活着。原因排查下来是这样的这个 Java 程序启动后会在短时间内 fork 一个子进程然后自己退出但 PID 文件里记录的是子进程的 PID。由于 service 文件里写了PIDFilesystemd 完全信任 PID 文件的更新状态不再自己去检查进程是否存在。当 PID 文件里的进程真的死了但文件没被删除systemd 就处于盲目信任状态。这个问题最终是怎么解决的我检查了程序的官方文档发现它支持-Dpidfile.path参数来指定 PID 文件路径而且程序本身会在父进程退出前正确清理 PID 文件。我重新调整了启动参数确保 PID 文件始终指向那个常驻的子进程systemd 的状态判断才恢复正常。这个教训总结成一句话PIDFile只有在程序本身能正确维护该文件的情况下才可靠。如果你托管的程序 PID 文件管理不规范宁可把它当成Typesimple来写让 systemd 用 cgroup 追踪进程组内的所有进程反而更可靠。5. 守护进程的四大常见问题排查清单下面的内容来自我实际运维经验的沉淀。守护进程看起来简单真正出了问题排查链路却很容易让人绕弯。5.1 进程莫名其妙消失的原因守护进程挂掉的常见原因我按频率排个序第一OOM Killer。这是最容易被忽略的。Linux 内存不足时内核会挑选一个进程杀掉守护进程因为通常拥有较长的运行时长被选中的概率并不低。排查方式是看dmesg | grep -i kill或者journalctl -k --since today | grep -i oom。我经历过一次 MySQL 在凌晨 4 点被 OOM Killer 干掉的事故当时查了一个多小时才想到这个方向。第二段错误等程序自身的 bug。这类问题一般会留下 core dump但默认情况下很多系统的 core dump 是关闭的。建议在 service 文件的[Service]段里加上LimitCOREinfinity这样崩溃时能留下 core 文件配合 gdb 回溯调用栈定位效率高很多。第三外部依赖失效。比如数据库连接断开、磁盘空间满、网络不通程序没有做好错误处理异常退出。这类问题光看程序自身日志可能不够还要看系统日志和依赖服务的状态。第四被人为 kill。多用户服务器上别的管理员不知道这个进程的作用kill -9一把梭的情况太常见了。这个不是技术问题是管理问题。系统化管理的思路还是那一个把所有服务纳入 systemd那么在systemctl stop之前随意 kill 都会触发Restartalways把进程拉回来。5.2 服务启动失败时的标准排查链路每次遇到systemctl start之后服务起不来我有一套固定的排查顺序分享出来第一步看错误提示。执行systemctl start myservice后终端直接显示的报错信息往往是最直接的原因但很多新手会忽略直接去看日志。第二步查看服务状态详情systemctl status myservice -l-l参数非常重要不加的话有些日志行会被截断。第三步查 journal 日志journalctl -u myservice --no-pager -n 50重点看ExecStart打印出来的最后几行以及是否出现了Permission denied、No such file or directory这类关键错误。第四步验证配置。执行systemd-analyze verify /etc/systemd/system/myservice.service这个命令会检查 service 文件的语法和常见错误比如WantedBy拼写错误、ExecStart路径不存在都会报出来。第五步手工执行启动命令。在 shell 里直接跑一遍ExecStart的完整命令看能不能起来。这一步能区分程序本身有问题还是systemd 环境导致的问题。注意手工执行时环境变量和 systemd 下不同如果手工能起来而 systemd 起不来优先检查环境变量、PATH、工作目录。5.3 端口被占用和文件句柄不足守护进程最常见的端口类报错是Address already in use。这个问题的麻烦在于有时候进程死了但端口还被占着。原因是 TCP 连接处于 TIME_WAIT 状态端口还没释放。排查命令ss -tlnp | grep 端口号可以看到到底是哪个进程占用了端口以及连接处于什么状态。如果是 TIME_WAIT 状态稍等一会儿就自动释放了不用过于紧张。如果确实是僵尸进程占着端口那就找到对应的 PID 去处理。文件句柄不足是另一类容易在守护进程中暴露的问题。每个进程能打开的文件数是有限制的用ulimit -n查看。有的服务运行几天后开始报Too many open files多半是代码里没有正确关闭连接。临时调大可以这样ulimit -n 65535但要永久生效还是要在 service 文件里写LimitNOFILE65535重启服务后生效。顺手在代码层面排查文件描述符泄漏双管齐下才治本。5.4 一个典型排查案例rsyslog 卡死的完整链路这个案例是在一台日志量很大的机器上遇到的。rsyslog 服务运行一段时间后日志打不进去了systemctl status rsyslog显示 active但 journal 里全是超时报错。排查过程是这样的一开始我怀疑是磁盘满了但df -h显示才用了 40%。接着我看 rsyslog 主配置确认文件路径没错。后来我用lsof -p rsyslog的PID查看它打开的文件发现有一个日志文件被删除了但进程还拿着旧的 fd 在写。这个问题的完整链条是日志轮转工具 logrotate 在轮转日志时如果没有正确配置copytruncate或通知 rsyslog 重开日志文件rsyslog 会继续往已经被重命名的旧文件 fd 里写数据导致新日志文件一直是空的而旧文件虽然被删了但空间没有释放磁盘用量表现为文件不存在但仍占空间。最终解决方案在 logrotate 配置里加postrotate脚本告诉 rsyslog 重新打开日志文件/var/log/messages { rotate 5 weekly postrotate /usr/bin/systemctl restart rsyslog /dev/null 21 || true endscript }这个案例告诉我一个道理守护进程本身看着没问题不代表它工作正常。日志打到哪儿去了、文件描述符指向哪里这类看不见的细节才最考验排查功力。6. 用户体验优化对外部程序做环境约束和加固写完 service 文件之后很多人就直接丢服务器上线了。实际上状态是运行了安全性和健壮性还远远不够。这部分讲几个我常用的加固操作。6.1 禁止写入、只读根目录的沙箱思路systemd 提供了很多轻量级的沙箱选项在不改变程序代码的前提下大幅度缩小程序的权限范围。我习惯在 service 文件里加上这几行ProtectSystemstrict ReadWritePaths/var/lib/myapp /var/log/myapp PrivateTmptrue NoNewPrivilegestrueProtectSystemstrict意味着除了ReadWritePaths显式指定的路径外整个文件系统对服务都是只读的。如果服务程序有漏洞想往 /tmp 写点东西PrivateTmptrue会给它一个隔离的 tmp 目录。NoNewPrivilegestrue从内核层面禁止这个服务通过 setuid 等操作提权。这套配置对 Web 服务、消息队列这类对外暴露的服务特别有意义。它解决的不是功能能不能跑的问题而是万一程序被攻破攻击者能造成多大破坏的问题。这个思路和我上面提到的不要在业务里用 root 运行是叠加的能多一层保护就多一层。6.2 资源限制CPU、内存、句柄一个都不能漏生产环境最怕的就是某个服务失控把整个机器拖垮。systemd 可以直接给服务设置资源上限[Service] MemoryMax2G CPUQuota80% TasksMax200MemoryMax超过之后进程申请内存会直接失败而不是触发系统 OOM 去杀别人。CPUQuota80%限制 CPU 使用率为一个核心的 80%防止某个死循环把整个机器的 CPU 吃满。TasksMax200限制进程可以创建的线程/进程数量避免 fork 炸弹级别的问题。这些参数的价值在于不需要程序本身做任何配合系统层面就给它套上了紧箍咒。上线前压测的时候可以知道服务正常需要多少资源然后留 20% 的余量设置限制既不干扰正常功能又能兜底极端情况。6.3 用户与权限拒绝用 root 跑一切服务我在多个环境里见过用 root 直接跑 Nginx、跑 Python 脚本的问就是图省事反正内网没风险。这个习惯必须改。正确的做法是给每个服务建一个专用用户sudo useradd -r -s /usr/sbin/nologin myapp-r创建系统用户-s /usr/sbin/nologin禁止这个用户登录 shell。然后在 service 文件里Usermyapp Groupmyapp这样程序以最小权限运行即使被攻破攻击者拿到的也不是 root。这个改动几乎零成本却能挡住一大批利用权限配置不当的攻击手法。维护服务越多越会体会到最小权限这四个字的分量。7. 把守护进程管好的三个小习惯最后分享几个我在实践中沉淀下来的小习惯不一定写进教科书但非常实用。第一个习惯服务启动前先做一次可观测性检查。我会在 service 文件里配置好ExecStartPre比如检查配置文件语法ExecStartPre/usr/bin/nginx -t如果配置有错服务直接启动失败而不是启动成功但立刻退出后者排查起来麻烦得多。第二个习惯为每个服务写一个简单的健康检查接口或脚本。比如 HTTP 服务就提供一个/healthz端点脚本服务就以退出码 0 表示正常。这样后续接入 Prometheus 或者告警系统就很方便。没有健康检查的服务等于裸奔。第三个习惯每次修改 service 文件后第一时间systemctl daemon-reload。很多人改了配置直接systemctl restart可能重启的还是旧配置。先 reload 再 restart这个顺序已经刻进我的肌肉记忆了。这四个板块的内容看起来零散但它们指向同一个目标让守护进程真正守得住。从最底层的 fork/setsid 原理到中层的 systemd 配置再到上层的安全加固和排障每一层都是在解决进程能否长期稳定地跑下去这个问题。我在实际运维中最大的体会是守护进程这件事难的不是写出来而是让它在无人值守的情况下日复一日地稳定运行。希望这篇文章里的方法能让你在遇到进程又没了的时候多几条清晰的解决路径。
返回列表