从Shell守护脚本到systemd:Ubuntu常驻进程管理实践

📅 发布时间:2026/10/10 6:53:37
从Shell守护脚本到systemd:Ubuntu常驻进程管理实践
1. 从守护脚本搬进 systemd不是炫技是脚本真的扛不住了我最早在 Ubuntu 上“守护”一个常驻进程用的也是几乎所有新手都会写的 Shell 守护脚本while true加sleep加pgrep判断循环里补一句“如果进程没了就拉起来”。这套写法在 Ubuntu 24.04 的小项目里能跑但扛不了多少风浪。后来连续几次遇到“进程反复假死、日志爆炸、开机起不来”的问题我才下决心把脚本全部换成 systemd 管理也就是用 systemd 替代 Shell 守护脚本而不是在 Shell 这一层继续打补丁。先说一个很典型的场景我有个 Python 写的抓取服务跑了大概两个月某天接口超时导致进程挂住守护脚本里pgrep -f myserver.py检查命令仍然返回正常因为进程没消失只是阻塞了脚本自然也不会重启它。最后是下游调用方把问题反馈到我这边我才发现服务已经“脑死亡”半天。守护脚本只管“进程存不存在”根本不管“进程活得好不好”这是它先天就有的短板。1.1 Shell 守护脚本在 Ubuntu 24.04 上遇到的真实场景很多人写守护脚本的套路都差不多#!/bin/bash while true; do if ! pgrep -f /opt/app/myserver.py /dev/null; then nohup /usr/bin/python3 /opt/app/myserver.py /opt/app/app.log 21 fi sleep 5 done这个脚本看起来似乎“够用”但拆开看就有不少问题。pgrep -f匹配的是完整命令行一旦你的进程名和别的命令撞了它可能误判如果进程变成僵尸进程pgrep也能查到但服务实际已经不再干活。守护脚本为了让进程常驻还会用nohup和把进程丢到后台这等于把进程“脱管”了脚本自己都拿不到子进程的退出状态。我遇到最无语的一次是因为改了/opt/app目录下的依赖包进程启动时报错退出守护脚本确实检测到了进程消失也重新拉起来了但新进程又因为同样的错误退出这就在sleep 5的节奏里反复空转。最后进程像钟摆一样不停重启日志被刷出几十 GB把磁盘填满整个 Ubuntu 24.04 桌面都开始卡顿。回头查的时候你要从几万条重复日志里找第一次报错的原因极其痛苦。1.2 守护脚本的四个老毛病重启逻辑、环境继承、日志与自启这套老毛病不是我不能接受而是它天生难解。第一是彻底的“重启逻辑脆弱”。Shell 守护脚本区分不了进程是被 kill 了、自己崩溃了、还是正常退出。它只能通过“进程还在不在”来做判断。真正要处理“退出码 0 不重启退出码非 0 才重启”就得自己手动把退出码捞出来但nohup后台进程的退出码根本传不回主循环于是只能一刀切。第二是环境继承问题。脚本里手工启动的进程继承了 Shell 的环境变量却没有继承 systemd 在开机时建立的完整运行环境。特别是当你把脚本丢进crontab reboot或rc.local里自启时PATH 往往被缩得很小JAVA_HOME、PYTHONPATH 之类可能根本没设置。我见过最典型的报错就是/usr/bin/env: ‘python3’: No such file or directory明明你手动登录时能跑开机自启却起不来。第三是日志管理全靠自己。nohup.out或者 app.log这种写法只做了追加没有大小控制、没有轮转。Ubuntu 24.04 位于/var/log下的系统日志有logrotate管但你手工指定的/opt/app/app.log不在 logrotate 默认配置里几个月下来非常痛苦。第四是开机自启顺序无法保证。Shell 脚本通过crontab reboot启动但系统网络、挂载点、数据库可能还没就绪脚本不会等待这些依赖。而 systemd 作为服务管理器核心处理的就是“进程生命周期 依赖顺序 自启编排”这三件事恰好是守护脚本最薄弱的部分。1.3 systemd 不是玄学而是把“守护逻辑”从代码里抽出来很多人一听到 systemd 就觉得复杂其实在 Ubuntu 24.04 上它服务管理的一套逻辑完全可以理解为“把原本用 Shell 写死的守护行为改成一段声明式配置”。你不再需要自己写while true因为Restartalways就是循环不再需要自己sleep因为RestartSec5就是间隔不再需要自己管理 PID 文件因为 systemd 本身就知道 Main PID不再需要自己重定向日志因为journald会统一接管。这不是说 Shell 没用而是说“进程守护”这个需求本来就是操作系统的活儿不该靠应用层脚本硬扛。明白这一点之后迁移就不是复杂工程而是把老脚本里的每一条逻辑翻译成 systemd 的配置项而已。2. 迁移前必看的“翻译表”Shell 守护脚本怎么写systemd 就怎么配把 Shell 守护脚本迁到 systemd最关键的思维转换是脚本里的每一条语句都将对应 systemd 单元文件里的某个指令。你不是在重写功能而是在“翻译配置”而且很多时候翻译的结果会比脚本更精确。2.1 守护循环、启动命令与路径的映射关系先讲最基本的映射可以用下面这张表快速对齐Shell 守护脚本写法意图systemd 配置while true常驻循环Restartalwayssleep 5重启间隔RestartSec5nohup python3 /opt/app/app.py app.log 21 后台启动并记录日志ExecStart/usr/bin/python3 /opt/app/app.pycd /opt/app切换工作目录WorkingDirectory/opt/appexport FOObar注入环境变量EnvironmentFOObarexport $(cat /etc/app.conf)读取环境文件EnvironmentFile/etc/app.confrunuser -u www -- command指定运行用户UserwwwGroupwwwreboot /opt/start.sh开机自启WantedBymulti-user.targetpgrep -f app.py kill进程存在性判断不再需要由 systemd 跟踪 Main PID这张表建议打印出来贴在工位上第一次迁移时拿着老脚本逐行对照比对着文档翻要直观得多。2.2Restart系列配置的语义差异别把重启参数搞错Restart是迁移时最容易配错的选项它和 Shell 里“死了就拉起来”的简单语义差异很大。Restartalways表示无论进程以什么方式退出都会重启包括你主动systemctl stop之后systemd 也不会“拉起来”这点可以放心手动停止不会触发重启但进程自身正常退出也会被拉起来。而Restarton-failure比较接近脚本的直觉退出码非 0 或收到异常信号时才重启退出码 0 则保持停止。还有一个很容易踩坑的设计如果某个进程只在“非正常退出”时才需要被拉起但它在完成工作后主动以 0 退出你用了Restartalways就会看到 systemd 一遍遍把它唤醒跑一下又退出反复空转。这种情况其实适合一次性任务的oneshot类型或者用Restarton-successUbuntu 24.04 的 systemd 版本支持。另外RestartPreventExitStatus可以指定某些退出码永不重启比如RestartPreventExitStatus0配合Restarton-failure会让“正常退出”真正变成结束而不是拉起。重启间隔用RestartSec控制建议至少 3 秒。以前.net服务崩了秒级重启还没等静态文件写好又启动反而一直失败。设置 5 到 10 秒相当于给服务一个“冷静期”。2.3 环境变量、工作目录与权限和 Shell 的天然继承说再见Shell 守护脚本里环境变量是“沿袭”下来的登录 Shell 的.bashrc、.profile里所有export都会被后台进程继承。systemd 单元默认不读取这些 Shell 资源配置反而更干净。老脚本里的每一处export迁移到 systemd 时有两条路少量配置直接用Environment写在单元文件里适合不会变的固定值。数量多或需要在外部修改的配置用EnvironmentFile指向一个独立的键值对文件比如/etc/app.conf。这个文件里每一行写成KEYvalue即可systemd 会自动读入。工作目录用WorkingDirectory指定很多 Python 项目依赖相对路径读写文件不写这个配置就会找不到文件。权限方面如果服务只需要普通用户权限绝不要用 root 跑。单元文件里写Userwww、Groupwww作用等同runuser -u www但更受控还能顺便覆盖CAP_权限。Ubuntu 24.04 上很多 Web 类项目都是这样配置的。2.4 日志从 nohup.out 换成 journald省下的不只是磁盘日志处理是迁移后体感最明显的部分。老脚本的nohup.out不轮转、不压缩几个月就是几个 GB。systemd 下只要不在单元文件里单独重定向服务的标准输出和标准错误默认全部进journald查看日志用一条命令journalctl -u my-daemon -f-f是跟踪模式类似tail -f。配合journalctl -u my-daemon --since 10 min ago可以快速看最近一段时间的日志。这比手动grep格式化程度不知道高多少。更重要的是日志大小有了统一的治理入口/etc/systemd/journald.conf里设置SystemMaxUse500M让整个 journal 最多占用 500 MB老日志自动滚动清理不再有“磁盘被日志塞爆”的丑事。3. 实战写一个可复用的.service单元文件从能跑到跑得稳理论讲完直接进入实操。这一节我会带着你从零写一个能被 systemd 完整管理的守护服务并解释每一步为什么这样写。3.1 最小配置ExecStart、Restart、Install三段缺一不可先在/etc/systemd/system/my-daemon.service创建单元文件“my-daemon”换成你实际的服务名。一个最小可用的守护配置通常长这样[Unit] DescriptionMy Python Daemon Service Afternetwork-online.target Wantsnetwork-online.target [Service] ExecStart/usr/bin/python3 /opt/app/my_daemon.py Restarton-failure RestartSec5 Userwww Groupwww WorkingDirectory/opt/app EnvironmentPYTHONUNBUFFERED1 [Install] WantedBymulti-user.target逐段解释。[Unit]里的Description是给人看的After和Wants告诉 systemd 这个服务在网络联机后才启动但“联机”不是硬依赖服务启动失败不会因为网络目标没有完成而阻塞Requires才是硬依赖后面细说。[Service]是核心ExecStart使用绝对路径不要写python3 /opt/app/my_daemon.py因为 systemd 不保证继承你的PATH写清楚/usr/bin/python3最稳妥。Restarton-failure是保守且合理的默认能让正常退出不被拉起来异常退出自动重启。User和WorkingDirectory前文说过了PYTHONUNBUFFERED1保证 Python 输出不被缓冲日志实时进 journald。[Install]里只写一行WantedBymulti-user.target这是 Ubuntu 24.04 默认的多用户运行目标相当于“开机进入桌面或字符界面后启动该服务”。写完后需要执行三步sudo systemctl daemon-reload sudo systemctl enable my-daemon sudo systemctl start my-daemondaemon-reload让 systemd 重新读取新增的单元文件enable创建开机自启软链start立即启动服务。enable和start也可以合并成enable --now my-daemon。3.2 环境文件的使用把配置从代码里剥离开如果你的服务需要维护者经常改参数比如数据库地址、开关状态不建议每次systemctl edit改单元文件更好的做法是用EnvironmentFile。创建一个/etc/my-daemon.envDB_HOST127.0.0.1 DB_PORT5432 LOG_LEVELinfo然后在[Service]段里加一行EnvironmentFile/etc/my-daemon.env改配置时只需要编辑这个文件然后systemctl restart my-daemon。这个文件的权限要注意如果配置里有密码或密钥chmod 600并归属 root不然普通用户能直接读到敏感内容。实测下来这个方式比在代码里写os.environ默认值要清爽很多。3.3 用户级服务与系统级服务什么时候用systemctl --userUbuntu 24.04 支持“用户服务”即~/.config/systemd/user/下的单元文件通过systemctl --user start xxx管理。用户服务最大的优势是随用户登录而启动不需要 root 权限。它适合跑桌面环境相关的辅助进程比如输入法守护、剪贴板工具、个人网盘同步等。但如果你要守护的是一个系统层面的服务比如监听端口给局域网设备提供接口建议还是用系统级服务。系统级服务的优势是开机最早启动、不依赖用户登录、崩溃后即使没有用户会话也能被拉起。判断标准很简单这个服务在没有任何人登录时还应不应该运行应该就放系统级。3.4 启动验证和状态解读别只会看active (running)服务启动后用systemctl status my-daemon查看状态。输出里几行关键信息要会读Loaded: loaded (/etc/systemd/system/my-daemon.service; enabled)说明配置已加载且已设置开机自启。Active: active (running)说明主进程存活。如果看到activating (auto-restart)说明 systemd 正在等待重启间隔通常是因为启动失败次数太多。Main PID: 1234 (my_daemon.py)当前主进程号。记住这个 PID 对排查僵尸进程、假死问题很有帮助。CGroup: /system.slice/my-daemon.servicesystemd 把服务放入独立控制组代表它对这个组内的所有进程有完整生命周期管理子进程也不会因为父进程退出而变成孤儿。如果状态是failed立即用journalctl -u my-daemon -b看本次开机以来的完整日志。这一步比任何猜测都直接。4. 依赖、热插拔与看门狗用 udev 和 Watchdog 补齐脚本做不到的事很多守护脚本还有一个高频场景等待某个设备插入后再启动任务。老做法是脚本里写一个死循环不停检查设备是否存在。这种轮询写法又蠢又费 CPU而且容易漏事件。systemd 配合 udev 可以做到“事件驱动”这在 Ubuntu 24.04 上已经是标准做法。4.1 为什么守护脚本处理热插拔容易漏事件我在一个小主机上接过一个需求USB 手机插入时自动启动 adb 相关的同步任务拔出时关停服务。用 Shell 守护脚本做只能每隔几秒lsusb一次再比对 IDUSB 设备插入后到被脚本发现最长延迟一整个轮询周期。拔插很快时脚本甚至可能完全漏掉事件。更麻烦的是这个轮询脚本本身还得被另一个守护脚本保护层层套娃复杂度爆炸。systemd 的解法是让内核在设备事件发生时直接通知服务管理器udev 规则匹配到 USB 设备插入就把对应的.service拉起来。不需要轮询没有延迟也不需要常驻一个监控进程。4.2 udev 规则加 systemd 服务插入设备自动启动的标准组合先在/etc/udev/rules.d/99-usb-autorun.rules写规则ACTIONadd, SUBSYSTEMusb, ATTR{idVendor}18d1, ATTR{idProduct}4ee1, TAGsystemd, ENV{SYSTEMD_WANTS}usb-autorun.service18d1和4ee1是某类安卓手机的 vendor/product ID实际使用时用lsusb查你的设备 ID 替换。TAGsystemd和ENV{SYSTEMD_WANTS}usb-autorun.service是关键它们告诉 systemd在设备匹配事件发生时启动指定的服务。ENV{SYSTEMD_WANTS}会自动把usb-autorun.service作为该设备单元的一个依赖拉起来。建议保留usb-autorun.service而不是单独把服务命令粘到规则里。理由一是 udev 规则里写长命令很难调试理由二是服务单元还可以定义Restart、User、Environment等完整语义规则只需要一行触发点。设备移除时可以在规则中补充ACTIONremove分支停止服务或者写一个成对的服务单元处理停止逻辑。实际项目中我通常用如下规则来处理移除事件ACTIONremove, SUBSYSTEMusb, ATTR{idVendor}18d1, ATTR{idProduct}4ee1, RUN/usr/bin/systemctl stop usb-autorun.service注意插入时用SYSTEMD_WANTS移除时用RUN/usr/bin/systemctl stop ...。前者是声明式依赖后者是命令式操作。二者搭配基本能满足 USB 热插拔启停服务的需求。4.3After、Wants、Requires依赖配置里的分寸感单元文件里的依赖关系最常见的三种选项要分辨清楚Requires硬依赖主服务没起来这个服务也不会启动主服务退出这个服务也会被停止。Wants软依赖主服务如果能启动就一起启动但启动失败不阻止当前服务运行。After只影响顺序不影响是否启动。意思是我在它后面启动。三者可以组合。比如我的业务服务依赖数据库我会写Afterpostgresql.service和Wantspostgresql.service数据库没启动时我服务等它数据库启动失败时我服务照样尝试启动并在日志里报错。如果要强制“数据库起不来就别启动业务”就用Requirespostgresql.service。刚迁移的人容易一股脑写Requires结果数据库临时维护业务也跟着全挂这种牵连经常是没必要的。4.4 Watchdog 和健康检查让 systemd 帮你看进程是不是“活着”前文提到老脚本判断不了进程“假死”。systemd 提供了WatchdogSec配置来解决这一类问题。原理是服务启动时如果设置了WatchdogSec30systemd 会要求服务每隔一定时间向它“报到”一次。如果超过 30 秒没有收到报到的信号systemd 会认为服务被卡死按配置执行重启。这个行为需要程序本身调用sd_notify接口或systemd-notify命令完成“喂狗”。对 Python 脚本来说有独立的systemd-python库代码里大致这样import systemd.daemon systemd.daemon.notify(WATCHDOG1)对普通 Shell 守护脚本来说可以直接在脚本主循环里调用/usr/bin/systemd-notify WATCHDOG1。这相当于告诉 systemd“我还活着”。为了让这套机制可用单元文件里需要配置Typenotify而不是默认的simple[Service] Typenotify ExecStart/usr/bin/python3 /opt/app/my_daemon.py WatchdogSec30Typenotify的含义是systemd 等到进程发送READY1信号后才认为服务启动完成。配合WatchdogSec30系统才能准确区分“启动中”“运行中”“假死”三个状态。如果程序没有实现通知机制那就不要配这两个参数老老实实依赖Restart和退出码判断就好。5. 故障排查实录这三个“服务没起来”让我把 systemd 摸透了迁移过程中你一定会遇到服务起不来的情况。不要慌我把踩过的三次典型故障还原出来每个都附上完整的定位链路你可以照着这套方法排查自己的单元文件。5.1status203/EXEC脚本路径或解释器路径写错第一次用 systemd 托管一个 Node 项目我写完单元文件后执行systemctl start myservice结果状态立刻变成failed。使用systemctl status myservice看到关键信息Process: 1234 ExecStart/opt/app/server.js (codeexited, status203/EXEC)203/EXEC是一个常见错误码直译就是“systemd 尝试执行 ExecStart 指定的程序但没成功”。我最初写的ExecStart/opt/app/server.js虽然文件有可执行权限但第一行如果是#!/usr/bin/env nodesystemd 在早期路径下可能找不到env或node。后来改成ExecStart/usr/bin/node /opt/app/server.js才算通过。排查这类问题先确认目标解释器的绝对路径which node which python3然后检查脚本头部shebang。在 Ubuntu 24.04 上/bin/sh已指向dash如果你的脚本第一行写的是#!/bin/bash但 systemd 执行环境中没有/bin/bash同样会报203/EXEC。尽量写成绝对路径。5.2StartLimitIntervalSec触发的“重启保护”服务为什么突然不再重启第二类故障更隐蔽。一个跑 ESPHome 的守护服务代码里偶发段错误机制上配置了Restarton-failure刚迁移时确实会自动重启。但过了一天我发现服务停在那里不拉了状态显示failed日志里有这么一条Start request repeated too quickly. Failed to start my-daemon.service.这是 systemd 的“启动速率限制”在起作用默认在 10 秒内如果服务启动失败超过 5 次systemd 就停止继续尝试防止系统资源被反复空转耗尽。这本身是保护机制不是 bug。遇到这种情况你不需要改启动频率因为程序持续崩溃才是根源。先看日志定位崩溃点修完代码再执行sudo systemctl reset-failed my-daemon sudo systemctl start my-daemon如果确实需要短时间内多次重启来应对某个顽疾可以在[Service]下显式设置StartLimitIntervalSec30 StartLimitBurst10意思是 30 秒内最多尝试重启 10 次。不建议把次数调太高等于放弃保护机制一般业务场景默认值够用。5.3 日志占满/varjournald 的清理与收敛第三个问题是很多 Ubuntu 24.04 用户迟早会遇到的journal 日志太大。因为 systemd 接管日志后默认在/var/log/journal/里存持久化日志没有限制的话几个月就能积累几十 GB尤其服务在短时间内循环重启时日志增速恐怖。一条命令看当前占用journalctl --disk-usage清理到指定大小sudo journalctl --vacuum-size200M只保留最近 7 天sudo journalctl --vacuum-time7d如果你希望长期限制 journal 增长编辑/etc/systemd/journald.confSystemMaxUse500M SystemMaxFileSize100M改完重启 journaldsudo systemctl restart systemd-journald这样服务日志无论如何都不会把根分区塞满。曾经写过几天日志就爆盘现在迁移后配合这个限制磁盘占用非常稳定。5.4 用systemd-analyze检查启动顺序排查“服务起来但功能不对”的怪问题还有一种情况服务虽然active (running)但业务请求一直报错。这类问题不怪进程崩溃而是启动顺序不对。比如服务连数据库但数据库还没监听端口服务就起来建连接池了必然失败。虽然连接池会自动重连但初始化阶段容易留下脏状态。用下面几条命令可以快速分析启动链路systemd-analyze blame systemd-analyze critical-chainblame列出每个单元启动耗时critical-chain显示系统启动的关键链路。重点检查你的服务是不是被安排在了数据库、网络等基础服务之前。如果顺序错了修正After即可不需要改业务代码。6. 最后迁移建议先把脚本留下一条条对照着搬如果你要把现有 Shell 守护脚本迁移到 systemd我的建议是“分批次搬别一把梭”。先选一个非关键的服务写成单元文件跑一周确认稳定再逐步迁移其他服务。这个过程中老脚本不需要删可以在迁移阶段停用等 systemd 版本跑满一个月后再清理。迁移时随身带上第二小节的对照表从ExecStart开始核对再检查环境变量、工作目录、用户权限、重启策略、日志去向这五个点覆盖了 90% 的迁移需求。每一个服务迁移后至少观察两个完整周期一次手动重启一次模拟进程崩溃kill -9 pid后看它是否自动拉起。我个人最后的一点体会是不要把 systemd 单元文件当成一次性配置写完就忘。事务里给别人交接服务时单元文件本身就是最好的文档它比任何运维笔记都精确。接受这个思路之后你再看那些while true; do ... done的旧脚本会发现自己回不去了。