Linux前后台任务、守护进程与systemd托管排查指南
凌晨两点被一条告警叫起来监控显示服务进程在、端口在、但请求全超时登上机器一看ps里那个熟悉的进程名确实还在状态位却是T。那一刻我才意识到很多干了三五年的同行对Linux 系统中的前后台任务与守护进程的理解其实卡在加个就是后台、nohup就是守护进程这个层面真出问题时连该看哪个字段都不知道。这篇东西就想把这件事从头捋一遍从CtrlZ按下的一瞬间内核做了什么到nohup、disown、setsid三者的边界在哪再到自己搓 daemon 化到底搓的是什么最后给出现在更推荐的托管方式。不管你是刚接触命令行、天天被关掉终端程序就没了困扰的新手还是写过几个常驻服务、但对SIGHUP、会话、进程组只停留在名词层面的老手看完至少能做到进程出问题时三分钟内判断出它是被冻住了、被信号干掉了还是自己卡在某个系统调用上。1. 先把后台这个词拆开、CtrlZ、nohup从来不是一回事1.1 一个把我叫起来的现场那天的情况很典型一个用./collector.sh 拉起来的采集脚本跑在跳板机的一个ssh会话里。白天一切正常晚上运维做了次网络策略调整连接被重置脚本就消失了——ps里找不到日志停在断开的那一秒。运维同学的第一反应是脚本自己崩了可我翻/var/log/messages里没有 OOM脚本自身也没有异常退出分支。真正的死因是SIGHUP。只是让 shell 不去wait这个子进程把它放进后台作业表进程和终端的绑定关系一点没动。终端一断内核给会话首进程发SIGHUPshell 收到后再转发给自己的作业脚本没有对这个信号做任何处理默认动作就是终止。所以准确说法是管的是shell 要不要等你不管终端断不断。这两件事被中文都翻译成后台误会就是从这儿开始的。同理CtrlZ也不是放到后台它是暂停nohup也不是变成守护进程它只是让进程忽略SIGHUP。三个词、三件不同的事混着用迟早要还债。1.2 会话、进程组、控制终端作业控制的三个底座不把下面这三个层级理清楚后面所有现象都只能靠背。它们的关系是嵌套的一个会话session里可以有多个进程组process group一个进程组里可以有多个进程会话最多绑定一个控制终端controlling terminal。打开一个终端登录时内核创建会话第一个进程通常是登录 shell成为会话首进程session leader会话 ID 就等于它的 PID。shell 自己在一个进程组里每执行一条命令尤其是管道又 fork 出一个新进程组这个新进程组的 PGID 等于组内第一个进程的 PID。终端同一时刻只认一个前台进程组这个值可以用ps -o tpgid看到。$ ps -eo pid,ppid,pgid,sid,tpgid,stat,comm | head -5 PID PPID PGID SID TPGID STAT COMMAND 1201 1198 1201 1201 1842 Ss bash 1842 1201 1842 1201 1842 R ps看这一行就够理解了bash的 PID/PGID/SID 全是 1201说明它既是会话首进程也是进程组组长ps命令的 PGID 是 1842TPGID也是 1842说明它此刻就是前台进程组STAT里的就是处于前台进程组的标记s表示会话首进程。这几个字段是排查一切作业控制问题的起点建议现在就敲一遍记住长什么样。1.3 终端驱动是怎么把按键变成信号的CtrlC能杀掉程序不是因为程序读了键盘而是终端驱动tty driver在行规程line discipline这一层就把这个字节拦截了翻译成SIGINT发给前台进程组里的所有进程。CtrlZ对应SIGTSTPT 是 tty stop 的意思Ctrl\对应SIGQUIT附带 core dumpCtrlS/CtrlQ是老式的流控不走信号。这里有个特别容易被忽略的点这些快捷键发给的是整个进程组不是单个进程。所以一条a | b | c的管道按一次CtrlC三个进程会同时收到SIGINT。反过来如果你只kill了管道里的某一个另外两个可能还在这就是我明明杀了进程怎么还在跑的常见来源。# 看某个终端上谁在前台、谁在后台 $ ps -eo pid,pgid,tpgid,stat,comm | awk $3 ! -1 # 给整个进程组发信号负号是组 ID $ kill -TERM -1842提示kill后面跟负数时解释的是进程组 ID 而不是 PID。写错一个符号可能把整个 shell 会话里的作业全带走执行前先用ps -g PGID确认范围。2. 作业控制全流程从CtrlZ到fg的每一步都发了什么2.1SIGTSTP与T状态进程没死只是被冻住了按下CtrlZ内核给前台进程组发SIGTSTP可停止信号进程进入T状态。这不是结束而是被调度器挂起它的内存、文件描述符、打开的网络连接全都保留着。SIGTSTP和SIGSTOP的区别只有一点前者可以被捕获或忽略后者不能。这也是为什么有些程序比如交互式编辑器能自己接管CtrlZ做别的用途。被停止的进程会向父进程shell发SIGCHLDshell 把它标记为Stopped并打印作业号。此时它在作业表里是已停止而不是已退出jobs能看见ps里状态是T。$ ./long_task ^Z [1] Stopped ./long_task $ jobs -l [1] 2345 Stopped (signal) ./long_task $ ps -o pid,stat,wchan:20,cmd -p 2345 PID STAT WCHAN CMD 2345 T do_signal_stop ./long_taskwchan显示do_signal_stop这就直接证明了它卡在等待继续信号上不是卡在 I/O、不是死锁。这一招在排查时非常好用T状态 do_signal_stop基本可以确定是被作业控制冻住的发个SIGCONT就能救回来。至于CtrlZ之后进程为什么看起来没占用 CPU道理也很直白T状态的进程不在就绪队列里调度器根本不会选它。它占的内存还在所以内存告警不会消失但 CPU 曲线会掉到底——这个组合内存不掉、CPU 归零本身就是个很强的诊断信号比翻日志快多了。2.2jobs/bg/fg的参数陷阱%1和1是两个世界作业号job number是 shell 自己维护的只对当前这个 shell 有效换个终端窗口就没了。它和 PID 完全是两套编号混用是新手最容易交的学费。$ jobs -l [1]- 2345 Running ./a.sh [2] 2400 Stopped ./b.sh # 正确%1 指作业号 1 $ fg %1 # 错误1 会被当成 PID 1 $ fg 1 # bash: fg: 1: no such jobjobs输出里表示当前作业fg/bg不带参数时操作它-表示上一个作业-l会附上 PID。bg %1做的事情是给该进程组发SIGCONTfg %1除了发SIGCONT还会调用tcsetpgrp把这个进程组设为终端的前台进程组把 shell 自己挪到后台。所以fg之后ps的TPGID会变这是验证它真的到前台了的方式。bg只能作用于已停止的作业。对一个正在运行的Running作业敲bg什么都不会发生——不是命令错了是它的设计语义就是让停下的继续跑。还有一个冷知识这个前后台切换机制要求 shell 启用了作业控制set -m。交互式 shell 默认开启但非交互式脚本里默认是关的那时启动的进程根本不在可控制的作业表里jobs输出为空bg/fg也用不了。写自动化脚本时踩这个坑的人不少现解释了半天为什么我的bg提示没有这个作业。2.3 后台任务读终端会被冻死SIGTTIN这个隐形杀手这是我认为最值得单独拎出来讲的一个现象。把任务放到后台后如果它尝试从终端读输入终端驱动会直接给它发SIGTTIN默认动作同样是停止。于是你看到一个明明在后台跑的进程状态却是TCPU 为 0日志一行不写。$ ./script_that_reads_stdin [1] 2600 $ sleep 2; ps -o pid,stat,wchan:16,cmd -p 2600 PID STAT WCHAN CMD 2600 T wait_for_partner /bin/bash ./script_that_reads_stdin # 打开另一个终端 $ kill -CONT 2600 # 它跑两步又会因为再次读 stdin 被停住 —— 死循环解决办法只有两类要么让它别读终端 /dev/null或从文件读要么给它一个不产生SIGTTIN的环境就是后面要讲的脱离控制终端。前一种是改程序行为后一种是改运行环境前者更省事优先考虑。写终端也有类似机制对应SIGTTOU不过终端默认把tostop标志关掉所以后台任务打印日志一般不会被停。这个行为可以用stty tostop打开打开后后台写终端也会被停。这个开关平时没人动但一旦被某个脚本打开又没关会给后来人留下一个极其难查的后台任务莫名停止的坑。3. 让任务活过终端断开nohup、disown、setsid的差别在哪3.1nohup只做了一件小事但很多人以为它做了全部nohup的实现极简把SIGHUP的处置设为忽略如果标准输出是终端就重定向到nohup.out。就这样没有 fork 两次没有setsid进程的会话和进程组关系完全不变。$ nohup ./worker.sh /var/log/worker.log 21 $ ps -eo pid,sid,tpgid,stat,cmd | grep worker 2700 1201 -1 S /bin/bash ./worker.sh注意SID还是 1201shell 的会话 ID说明它压根没离开会话。它能扛过终端断开靠的是忽略SIGHUP这一条而不是脱离终端。这带来两个后果一是它仍在原会话里某些极端情况下终端关闭的其他处理仍可能波及它二是如果它忘了重定向stdout继续往已关闭的终端写会收到EIO错误而不是被信号干掉进程就卡在一个莫名其妙的写失败循环里。所以nohup的正确用法是必须配重定向$ nohup ./worker.sh /var/log/worker.log 21 /dev/null 三个方向都重定向了才算完整stdout和stderr给文件stdin给/dev/null防止它读终端被SIGTTIN停住。只写nohup cmd 的话nohup.out会堆在启动目录里久了两三个 G 都有可能/分区被写满的事故我见过不止一次。3.2disown与disown -h处理已经跑起来了的进程nohup只能用在启动时任务已经跑起来了怎么办disown就是干这个的。它把作业从 shell 的作业表里摘掉shell 退出时就不会再给它发SIGHUP。这两个用法差别很大必须分清命令作用范围作业表shell 退出时的SIGHUP适用场景disown %1单个作业移除不发后台任务已经跑稳不想让它跟着 shell 死disown -a所有作业全部移除不发一口气收拾干净disown -h %1单个作业保留不发还想用jobs看着它但不想被连带杀死disown -r仅运行中的移除不发跳过已停止的作业-h这个选项名字来自SIGHUP字面意思就是处理 HUP 信号的部分。它保留了作业表记录所以jobs还能看见但 shell 收到SIGHUP时不会再转发给这个作业。注意disown只在支持作业控制的 shell 里有效而且必须在同一个 shell 会话里执行。已经ssh断开了再想disown来不及了——那时进程早就收到了SIGHUP要么已经死了要么已经被别的机制救了。3.3setsid是真正换房间附带一个会话首进程不能再开终端的设定nohup和disown都是在原会话里硬扛setsid是搬到新会话去住。它调用同名系统调用setsid()创建一个新会话调用者成为新会话的首进程和新进程组的组长且不带控制终端。由于setsid()要求调用者不能是当前进程组的组长setsid命令在内部会先 fork 一次让子进程去调用子进程的 PGID 不等于自己的 PID满足条件。$ setsid ./worker.sh /var/log/worker.log 21 /dev/null $ ps -eo pid,ppid,sid,tpgid,stat,cmd | grep worker 2800 1 2800 -1 S /bin/bash ./worker.sh看SID变成了 2800等于自己的 PID说明它已经是新会话首进程PPID是 1说明父进程退出后被 init或现代系统的 PID 1收养TPGID是 -1表示这个会话没有控制终端。这三个字段凑齐就说明它完成了一次干净的脱离。新会话没有控制终端也就没有终端断开这个事件可触发SIGHUP自然不会来。代价是它也没法再打开/dev/tty任何依赖终端的交互比如sudo要密码、ssh要确认指纹都会直接失败。这是特性不是 bug守护进程本来就不该跟人交互。三者放一起对比选型逻辑就清楚了手段是否换会话是否忽略SIGHUP需要提前规划典型场景否否—短任务、马上要fg回来nohup cmd 否是是临时跑个几分钟到几小时的批处理disown -h %1否是不转发否可事后补已经跑起来的长任务、不想中断setsid cmd 是天然收不到是需要彻底脱离终端的常驻进程真要长期跑我更推荐用tmux这类终端复用工具它自己维护一个脱离终端的服务端进程你在里面开的窗口不受ssh断开影响还能随时回去看现场。它解决的不是信号问题而是把进程挂在一个假终端上这个思路和后面要讲的守护进程其实是一脉相承的。4. 守护进程的实质脱离控制终端而不是看不见它4.1 教科书上的六步 daemon 化逐条讲清动机很多资料给的是fork 两次、setsid、chdir(/)、umask(0)、关闭文件描述符这个清单但很少讲清每一步在防什么。我按自己的理解重新排一遍配上代码#include stdio.h #include stdlib.h #include unistd.h #include fcntl.h #include sys/stat.h #include signal.h static void daemonize(void) { pid_t pid fork(); if (pid 0) exit(1); if (pid 0) exit(0); /* 第一步父进程退出shell 立刻返回 */ if (setsid() 0) exit(1); /* 第二步新会话脱离控制终端 */ signal(SIGHUP, SIG_IGN); /* 第三步兜底忽略挂断信号 */ pid fork(); if (pid 0) exit(1); if (pid 0) exit(0); /* 第四步再 fork放弃会话首进程身份 */ umask(0); /* 第五步文件权限自己说了算 */ if (chdir(/) 0) exit(1); /* 第六步不占用可卸载的挂载点 */ int fd open(/dev/null, O_RDWR); if (fd 0) { dup2(fd, STDIN_FILENO); dup2(fd, STDOUT_FILENO); dup2(fd, STDERR_FILENO); if (fd STDERR_FILENO) close(fd); } }第一步的动机是让启动它的 shell 认为命令已经结束回到提示符而不是挂在wait上。第二步setsid是核心它让进程脱离控制终端从此没有终端断开这个事件。第三步忽略SIGHUP是历史遗留的保险虽然新会话没有终端但某些场景下比如进程后来又开了终端仍可能收到。第五步umask(0)的理由是守护进程经常要创建日志、PID 文件、临时文件权限应该由代码里的mode参数显式决定而不是被启动环境的umask悄悄削一刀。这一步争议最大很多现代实现倾向设成022或027因为umask(0)配上一个偷懒的open(..., 0666)就等于给了所有人写权限。我个人的习惯是umask(027)需要宽权限的地方单独chmod。第六步chdir(/)是为了不钉住任何挂载点。如果守护进程的工作目录在某个 U 盘或网络挂载点上而它还开着这个目录umount就会一直报device is busy——这个坑我在维护老机器时踩过最后是靠lsof D找到是某个后台进程的cwd。4.2 双 fork 到底挡住了什么为什么要 fork 两次这个问题面试里被问烂了但能讲准确的不到一半。答案的核心是只有会话首进程才可能获得控制终端。内核规则是一个进程只有在自己是会话首进程、且当前没有控制终端的情况下打开一个终端设备时这个终端才会成为它的控制终端。第一次forksetsid之后子进程成了会话首进程虽然此刻没有控制终端但它随时可以打开一个/dev/tty*把控制终端要回来那样就前功尽弃了。第二次fork之后孙子进程不再是会话首进程会话首进程是它的父进程永久失去了获得控制终端的资格。这就把门焊死了。顺带的好处是父进程退出后孙子进程被 PID 1 收养避免成为僵尸。# 验证daemon 进程的 PPID 应是 1或系统的进程管理器SID 不等于 PID $ ps -eo pid,ppid,sid,tpgid,stat,cmd | grep mydaemon 3001 1 2999 -1 S /usr/local/bin/mydaemon注意这里SID是 2999 而不是 3001正好印证了会话首进程是已经退出的中间那层。如果哪天你看到某个号称 daemon 的进程SID等于自己的PID说明它只setsid了一次严格来说还有被重新绑定终端的可能。4.3 PID 文件、umask、工作目录、日志那些上线后才炸的细节自己写 daemon代码能跑只是起点下面这些才是决定它能不能稳定跑三个月的。PID 文件要用原子写。常见错误是pidfile open(...); write(pid)中间如果再启动一个实例两边都判断文件不存在然后同时写就会起两个。正确做法是先open(O_CREAT|O_EXCL)抢锁抢不到就直接退出或者干脆用flock在 PID 文件上加排他锁进程活着锁就在进程一死锁自动释放这个方案更省心不用处理上次异常退出留下脏 PID 文件的问题。日志不要自己往/var/log里写。自己管日志就得自己管轮转不然三个月后磁盘满。要么交给syslog系列接口要么输出到stdout让上层托管后面讲的journald就是这个思路。我见过最狠的一次某进程stdout没重定向、也没关父进程的pty又一直没人读管道缓冲区满了之后进程直接阻塞在write上整个服务假死——ps里状态是Swchan是pipe_write查了整整一天。信号处理要克制。守护进程里能安全处理的信号不多SIGHUP重载配置、SIGTERM优雅退出是标配其他尽量别碰。信号处理函数里只能用可重入函数write、_exit、sig_atomic_t赋值在里面printf或malloc是经典的自杀行为会偶发死锁而且极难复现。别忘了SIGPIPE。守护进程经常写 socket对端一关默认动作就是进程直接死日志里一个字都没有。这类问题查起来非常痛苦因为进程是干净地消失没有退出码。惯例是在启动时signal(SIGPIPE, SIG_IGN)然后在write返回-1且errno EPIPE时自己处理。5. 现在更推荐的做法别自己 daemon 化交给systemd5.1 一个能直接抄的unit模板与逐字段解读在自己代码里做双 fork 那一套现在其实已经不流行了。现代系统普遍有服务管理器systemd是主流它天生就是进程管理器让它来负责后台化、重启、日志、开机自启你的程序只需要老老实实待在前台跑。这才是正确的关注点分离。[Unit] Descriptioncollector worker Afternetwork-online.target Wantsnetwork-online.target [Service] Typesimple Userworker Groupworker WorkingDirectory/opt/collector EnvironmentCOLLECTOR_ENVprod EnvironmentFile-/etc/collector/env ExecStart/opt/collector/bin/collector --config /etc/collector/conf.yaml ExecReload/bin/kill -HUP $MAINPID Restarton-failure RestartSec3 TimeoutStopSec30 KillSignalSIGTERM LimitNOFILE65535 StandardOutputjournal StandardErrorjournal [Install] WantedBymulti-user.target逐条说几个真会影响行为的Typesimple表示ExecStart启动的进程就是主进程服务管理器启动后立刻认为服务已就绪。这是推荐类型前提是你的程序不 fork、前台运行。如果你还在代码里做 daemon 化就得改成Typeforking否则服务管理器会盯着那个早就退出的父进程报启动后立刻退出。Restarton-failure只在退出码非 0 或被信号杀掉时重启。如果程序会因为配置错误这种注定失败的原因退出配上这个就会无限重启日志被刷爆。这种情况用StartLimitBurst/StartLimitIntervalSec限流或者干脆先手动跑通再加Restart。ExecReload里$MAINPID是服务管理器替你替换的主进程 PID。自己用pkill或者killall在这里是危险的——机器上多半跑着不止一个同名进程。LimitNOFILE65535是很多人漏掉的。默认的软限制常常只有 1024一个持有一堆长连接的采集服务跑到一千出头就开始报Too many open files日志里全是accept失败。这类问题上线后几小时才炸很难联想到是限制问题建议写unit时就显式声明。5.2Typesimple/forking/notify/oneshot的选型与踩坑Type就绪判定适合什么程序常见坑simple启动即就绪前台运行的常驻服务推荐程序自己 daemon 化会启动就退出forking父进程退出即就绪老代码做了双 fork父进程退出太快/太慢都会误判notify程序调用sd_notify启动耗时长、需真正就绪后再接流量忘了NotifyAccessall会一直等oneshot进程退出即完成一次性任务、初始化脚本配Restartalways会无限循环notify这个类型值得多说一句。simple有个天然缺陷服务管理器在ExecStart一执行就认为就绪了可你的程序可能还在建连接池、加载索引这期间上游流量打进来就是一堆 5xx。notify让程序在真正准备好之后主动发个通知就绪信号才成立。代价是程序里要加几行代码调用sd_notify(0, READY1)还得确保NotifyAccess配置正确否则服务管理器收不到消息会一直卡在activating状态直到超时被杀。从forking迁到simple的常见动作是把代码里那段 daemon 化直接删掉改成前台跑。删的时候注意几件事chdir删了但工作目录得靠WorkingDirectory补上umask删了得靠UMask补上/dev/null重定向删了得靠StandardOutput/dev/null补上。少补一个行为就会变而且往往是那种跑了一周才发现的变。5.3 重启策略、日志归属与reload的正确姿势日志这件事StandardOutputjournal会让程序的所有输出进journald用journalctl -u collector -f看journalctl -u collector --since 1 hour ago翻历史。好处是不用管轮转、不用管权限、日志和时间戳、PID 自动关联代价是不能直接grep文件得用journalctl的过滤功能。# 只看某个服务的错误级别日志 $ journalctl -u collector -p err --since today # 看服务重启记录排查为什么半夜重启了 $ journalctl -u collector | grep -i Started\|Stopped # 查看服务当前状态与最后一次退出码 $ systemctl status collector -l --no-pagerreload的正确姿势是让程序自己处理SIGHUP而不是让服务管理器重启进程。重启进程会断开所有连接对长连接服务来说等于一次小型故障。systemctl reload collector会执行ExecReload里的命令也就是发SIGHUP程序收到后重新读配置、替换监听端口、不中断已有连接——这才是优雅重载。前提是程序真的实现了这个逻辑很多人ExecReload里写着kill -HUP $MAINPID程序根本没收这个信号等于发了个寂寞配置压根没生效。另外记住一条systemd默认KillModecontrol-group停止服务时会把该cgroup内的所有进程都杀掉包括你自己 fork 出来的那些子进程。这是好事能清理干净但如果你的程序依赖启动时 fork 的后台子进程在服务重启后继续存活那它会被一并干掉得改用别的方式管理。6. 现场排查链路进程在但服务像死了一样6.1 第一轮确认它到底处于什么状态发现进程在但没响应第一步不要去看代码先看状态位。一条命令能把关键信息拿全$ ps -o pid,ppid,pgid,sid,tpgid,stat,wchan:22,etime,cmd -p PID PID PPID PGID SID TPGID STAT WCHAN ELAPSED CMD 4102 1 4102 4102 -1 S ep_poll 3-12:04:11 ./collector按状态位分三类走T停止——回到第 2 章先查wchan是不是do_signal_stop。是的话就是被作业控制冻住了kill -CONT救回来然后追问是谁发的停止信号通常是CtrlZ或后台读终端触发的SIGTTIN。Z僵尸——进程已退出父进程没回收。僵尸不占 CPU 也不占内存只占一个 PID 表项。危害在于父进程如果一直不wait僵尸会越积越多最后耗尽 PID。这时候kill僵尸是没用的它已经死了得修父进程的wait逻辑。D不可中断睡眠——卡在内核态通常是磁盘 I/O 或网络文件系统。D状态的进程连kill -9都杀不掉只能等 I/O 超时。看到D基本可以断定是存储层的问题往下查存储。Sep_poll——典型的epoll_wait等待说明事件循环是健康的只是没事件来。这时候问题可能在网关上不在进程里。6.2 第二轮确认它在等什么状态位只能告诉你大类具体在等什么要看wchan和系统调用。wchan反映的是内核里阻塞的函数名strace能看到最后一两个系统调用。$ cat /proc/PID/wchan; echo futex_wait_queue_me $ sudo strace -f -p PID -e tracenetwork,read,write -s 128futex_wait_queue_me说明卡在用户态锁上很可能是死锁或者某个线程拿锁不放。这时候抓一下线程栈更直接$ ps -L -o pid,lwp,stat,wchan:20,comm -p PID | head -20 # 或者把调用栈整体 dump 出来看 $ sudo gdb -p PID -batch -ex thread apply all bt 2/dev/null | head -60文件描述符也是高频线索。进程只吃连接不回包先看它的fd有多少、都指向哪$ ls /proc/PID/fd | wc -l $ sudo lsof -p PID | awk {print $5} | sort | uniq -c | sort -rn | head如果fd数量接近ulimit -n的值就是第 5 章说的限制问题。如果socket数量异常多而CLOSE_WAIT占大头说明对端关了而本地没close是代码里的资源泄漏。6.3 几个反复出现的经典坑坑一nohup没重定向stdin。结果进程跑着跑着试图读终端被SIGTTIN停住状态变T。排查时看到的就是摸鱼进程CPU 0日志停在某个点ps显示T。修法是启动时加 /dev/null。坑二disown用错位置。在 A 窗口disown了B 窗口以为也算数。实际上每个 shell 的作业表是独立的B 窗口根本没记录过这个作业。判断某个进程有没有脱离原 shell看ps的PPID是不是 1 更靠谱。坑三systemd服务和手动启动并存。手动./collector 跑一份systemctl start collector又起一份两个抢同一个端口其中一个静默失败日志里只有一行bind: address already in use。这种服务明明启动了但行为不对的怪事先用ss -lntp | grep 端口看有几个 PID 占着一目了然。坑四KillMode与自建子进程。前面提过服务里 fork 出来的子进程在服务重启时会被一起清掉。如果你的架构是主进程负责拉起 worker那就别依赖重启主进程、worker 继续跑这种假设让 worker 也由服务管理器托管成独立服务或者把KillMode显式设成process并自己管理子进程生命周期。坑五ulimit在unit里不生效。在 shell 里ulimit -n 65535再systemctl start那个限制不会传递给服务因为服务是由服务管理器另起的进程环境不继承当前 shell。要在unit里写LimitNOFILE才管用。这个误会导致我明明调过了怎么还是报错的争论非常常见。坑六终端复用工具的窗口被意外关闭。用tmux挂着长任务但某个窗口里跑的是前台命令窗口被kill-window后进程照样收到SIGHUP。终端复用保护的是客户端断开不是窗口被关。真要长期跑还是在tmux里再套一层setsid或者干脆交给服务管理器。写到最后分享一个我自己一直在用的判断口诀看状态位定大类看wchan定原因看fd定资源看SID/TPGID定它和终端有没有关系。这四步走完九成以上的进程在但不干活都能落到具体方向上剩下的才是翻代码的事。至于自己动手写 daemon 这件事我的建议是除非你要写的是被别人托管的底层设施否则别写——把双 fork 那几十行删掉换成十行unit配置三个月后回来改需求的时候你会感谢现在的自己。