ARTICLE DETAIL

资讯详情

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

systemd替代Shell守护脚本:Ubuntu 24.04进程保活与自愈实战指南

systemd替代Shell守护脚本:Ubuntu 24.04进程保活与自愈实战指南 刚接触Linux服务器那阵子保活进程全靠Shell守护脚本一个while循环配sleep轮询发现进程不在就把人家拉起来。这套土办法到今天在Ubuntu 24.04 LTS上依然很多人都在用但它其实已经不是最优解。systemd作为系统的一号进程天生就是干进程管理这件事的而且干得更稳、更细。这篇文章我按自己这些年迁移脚本的真实经验把“用systemd替代Shell守护脚本”这件事从原理、配置到踩坑一次讲透适合正在维护家用服务器、开发板、或者生产环境的读者看完就能照着给自己服务改一套。1. 先看清楚Shell守护脚本到底哪里不行1.1 我最早写的守护脚本长这样我之前维护一个小业务一个自研进程负责从消息队列拉数据做落盘一旦崩了要能自动拉起。最初的方案就是最常见的while循环加轮询#!/bin/bash # /opt/bin/guard_worker.sh while true; do if ! pgrep -f worker_server /dev/null; then /opt/bin/worker_server echo $(date %F %T) worker_server restarted /var/log/worker_guard.log fi sleep 3 done这段脚本在当时确实解决了“进程挂了没人管”的问题但它身上的毛病也是一抓一大把我后来挨个踩过。首先是轮询空窗期。sleep 3意味着进程在循环间隙死掉最长要等3秒才能被发现这3秒对在线业务来说可能影响一大批请求。把sleep改小CPU占用又上来了对崩溃频繁的服务无异于火上浇油。其次是判断逻辑很脆弱。pgrep -f是按命令行匹配同一台机器上如果存在同名命令或者命令行带了相似参数很容易误判“进程还活着”。反过来如果工作进程已经变成僵尸进程但还没被父进程回收pgrep照样能看到它守护脚本就会以为一切正常——而实际服务早就死了。第三点是守护脚本自己也会死。脚本本身是一个独立进程没有上级机制照顾它。日志目录满了、脚本被误杀、机器重启后没人把它拉起来业务就裸奔。我后来遇到过正好是磁盘写满的情况守护脚本一直写日志结果自己先挂了业务进程随后也崩了等我上机器发现时业务已经没人管了好几个小时。这个场景到现在我都记得。再往后还牵扯到开机自启、日志轮转、多实例去重、环境变量初始化……这些靠Shell一层层补只会越补越厚。哪怕你熟练掌握了export、shift、for循环这些写法架构上的硬伤依然在最后就变成一座没人敢碰的“守护金字塔”。1.2 systemd为什么把这件事干得更好systemd是Ubuntu 24.04的一号进程它负责拉起系统的所有服务也负责监控它们的生命周期。让systemd来管你的常驻服务相当于给服务直接配了一个专职保姆而且这个保姆本身就是由系统init机制保护的不会再出现“守护进程自己没人管”的尴尬。对比起来就很直观对比项Shell守护脚本systemd服务谁在守护守护者没有人系统init本身自动重启自己写轮询Restart策略加重启间隔开机自启rc.local或cronrebootenable一条命令日志重定向到文件要自己轮转journald统一管理进程状态感知pgrep猜cgroup精确统计资源限制基本靠自觉MemoryLimit、CPUQuota、TasksMax依赖顺序启动脚本里手写sleepAfter、Wants、Requires声明式环境变量继承当前Shell白名单式显式注入除此之外systemd还给了很多Shell很难搞出来的能力比如用systemd timer做定时任务用udev设备事件触发服务用WatchdogSec做进程卡死检测。这些都是后面要展开的硬货。1.3 别把它当银弹当然systemd不是万能的我平时也不主张把所有东西都往服务里塞。容器、K8s集群里跑业务进程有更合适的编排和探针体系不太需要跟宿主机的systemd打交道。本地开发或者一次性的测试脚本直接放终端跑也没问题。真正适合用systemd替代Shell守护脚本的场景是“单机、长驻、需要自愈、需要随系统启动”这一类传统服务。明确了边界后面动手才不跑偏。2. 读懂systemd服务单元的关键配置2.1 服务单元文件的三段式迁移之前得会读服务单元文件。一个典型的service文件长这样后面完整用到先看结构[Unit] DescriptionDemo Worker Service Afternetwork.target [Service] Typesimple Userdemo ExecStart/opt/demoapp/worker.sh Restarton-failure [Install] WantedBymulti-user.target三个段的职责很清晰[Unit]描述这个单元是什么依赖什么排在谁后面。这里的After、Requires、Wants决定了服务的启动顺序和依赖关系。[Service]真正定义“怎么运行这个进程”。用什么用户、执行什么命令、什么类型、要不要重启全在这一段。[Install]定义怎么开机自启通常是WantedBymulti-user.target或者桌面环境的default.target。在Ubuntu 24.04上管理员自建的服务文件推荐放在/etc/systemd/system/目录优先级高于发行版自带的/usr/lib/systemd/system/。你后来想覆盖系统默认行为也通常在这个目录里写override。systemd会通过D-Bus接口把所有这些单元的状态暴露给systemctl等工具所以一条systemctl status能拿到的信息非常全。2.2 Type选错会出大事Type是所有参数里最容易写错的因为它直接描述了“服务进程是怎么起来的”。我见过最典型的错误把一个fork到后台的传统daemon按simple来配结果systemd只跟住了短暂存活的父进程父进程fork完子进程后自己一退出systemd以为服务已经结束立刻按Restart策略重启于是陷入“启动、退出、再启动”的循环。常见的几种Type一定要分清Type适用场景一句话解释simple绝大多数现代前台服务ExecStart进程一直在前台跑不退出exec与simple类似但校验execve成功二进制不存在或无法执行时立刻报错forking传统daemon程序父进程会fork子进程后自己退出oneshot一次性任务执行完就退出配合RemainAfterExityesnotify会主动通知systemd的服务启动完成后发READY1systemd才认为就绪迁移Shell脚本时绝大多数情况下选simple就是最稳的。因为你是用一个脚本把工作进程放在前台跑本质跟simple完全吻合。只有那种故意用把子进程拉到后台、然后主脚本快速退出的写法才需要考虑forking——但我建议干脆别这么写。为了配合systemd让脚本老老实实在前台跑完整个生命周期就好后面会发现好处很多。2.3 重启策略不要瞎写Restart是systemd替代Shell轮询的核心但它有讲究。瞎写Restartalways的大有人在用了之后发现服务没起来还一直刷重启日志或者服务自己以0码退出时还被反复拉起搞得运维一头雾水。常用取值我整理一下no退出后不重启默认值。on-success只有退出码为0或正常信号退出时才重启。on-failure非0退出码、被信号杀死、超时、watchdog触发时才重启这是最常用的。on-abnormal被信号杀死、超时、watchdog等异常情况才重启。always无论如何都重启但注意管理员用systemctl stop主动停掉的除外systemd会认为这是预期操作不会跟你较劲。on-watchdog只看门狗超时触发重启。配套的还有RestartSec设置重启间隔我一般写3到5秒避免崩溃循环时把系统资源打满。另外StartLimitIntervalSec和StartLimitBurst是另一道保险在指定时间窗口内连续启动次数超过阈值systemd会判定服务彻底失败不再继续尝试。比如StartLimitIntervalSec60、StartLimitBurst5意思是60秒内崩溃超过5次就放弃治疗进入failed状态。调试阶段不想被这个限制误伤可以设StartLimitIntervalSec0禁用窗口限制。这里有个我踩过的坑StartLimitIntervalSec和StartLimitBurst属于[Unit]段不是[Service]段。早期我习惯把所有跟“启动/重启”相关的参数一股脑塞到[Service]里结果systemd只是把这个未知键忽略掉了速率限制根本没生效服务疯狂重启也没人拦。后来一看系统日志才发现配置写错了位置。这个设计比Shell脚本高在“有状态”systemd知道进程是因为什么退出的再决定要不要重启而不是无脑拉起来。2.4 环境变量、用户和路径一个都不能少Shell里养成的习惯在systemd下第一条就得改systemd执行ExecStart时不经过Shell解析它直接调用execve所以Shell特有的管道、重定向、变量展开全都不可用。比如ExecStart/opt/bin/server --port 8080 /tmp/server.log 21这种写法会把、/tmp/server.log这些直接当作传给程序的参数。正确做法是把重定向写进脚本内部或者干脆不要重定向交给journald统一收日志。这种Shell习惯搬到systemd是一个不小的认知门槛。环境变量方面默认情况下systemd服务继承的PATH非常窄而且用户在Shell里export的变量不会带进来。需要什么变量就显式写进服务文件EnvironmentLOG_LEVELinfo EnvironmentFile/etc/demoapp/env.confEnvironmentFile特别适合放带密钥、带不同主机差异化配置的场合文件格式跟Shell的KEYVALUE一致但不支持export、source这些操作。还有两个容易被忽略的字段。User和Group决定服务以什么身份跑生产环境我强烈建议给每个服务建独立用户而不是直接用root。WorkingDirectory指定工作目录脚本里如果用了相对路径这个必须配好否则脚本大概率因为找不到文件而挂掉。3. 动手迁移把一个Shell守护的服务交给systemd3.1 先准备一个示例服务工作进程光讲理论没意思下面带一个最小但完整的例子走一遍。我造一个简单的Worker它的职责是每5秒往日志里写一行当前时间用来模拟真实的常驻业务进程#!/bin/bash # /opt/demoapp/worker.sh while true; do echo $(date %F %T) worker alive /var/log/demoapp/worker.log sleep 5 done这个过程如果不用systemd传统Shell守护脚本就是前面那种pgrep -f worker.sh加拉起。现在直接抛弃守护脚本让systemd来做。先把环境和用户准备好sudo useradd --system --home /opt/demoapp --shell /usr/sbin/nologin demo sudo mkdir -p /opt/demoapp /var/log/demoapp sudo tee /opt/demoapp/worker.sh /dev/null EOF #!/bin/bash while true; do echo $(date %F %T) worker alive /var/log/demoapp/worker.log sleep 5 done EOF sudo chmod x /opt/demoapp/worker.sh sudo chown -R demo:demo /opt/demoapp /var/log/demoapp这里建一个系统用户demo没有登录shell最小权限原则。目录都交给demo所有脚本本身和日志文件都有写入权限避免后面启动服务时报Permission denied。3.2 写一份生产可用的service文件接下来写服务单元文件/etc/systemd/system/demoapp.service[Unit] DescriptionDemo App Worker Service Afternetwork.target StartLimitIntervalSec60 StartLimitBurst5 [Service] Typesimple Userdemo Groupdemo WorkingDirectory/opt/demoapp ExecStart/opt/demoapp/worker.sh Restarton-failure RestartSec5 [Install] WantedBymulti-user.target逐个看Typesimple是因为worker.sh是一个前台不退出的脚本User和Group指定为demoWorkingDirectory保证脚本内部相对路径不跑偏ExecStart直接指向脚本不用bash -c包一层因为脚本自己带了解释器Restarton-failure意味着异常退出才重启正常退出比如手动systemctl stop不打扰RestartSec5给重启缓冲后面两个StartLimit字段是保险丝注意它们放在[Unit]段。写好之后校验一下单元文件语法这一步很多人会跳过其实很有用sudo systemd-analyze verify /etc/systemd/system/demoapp.service有语法问题会直接提示没有任何输出就是通过。3.3 加载、启动、开机自启三件套接下来是标准的操作流程sudo systemctl daemon-reload sudo systemctl enable --now demoapp sudo systemctl status demoappdaemon-reload是让新写或改过的unit文件被systemd重新读入这一步忘掉的话后面怎么启动都是旧的配置。enable --now是一并完成“加入开机自启”和“立即启动”。注意之前在[Install]写的WantedBymulti-user.target就在这里生效systemd会在/etc/systemd/system/multi-user.target.wants/下创建一个软链接相当于旧时代的“开机注册”。启动后看看状态正常会显示active (running)进程列表里能看到worker.sh。然后模拟一下崩溃kill -9 $(pgrep -f /opt/demoapp/worker.sh) sleep 6 systemctl status demoapp你会看到systemd几秒钟后自动把服务拉起来了状态里的Active字段会变成active (running)日志里也会多一条重启记录。这个体验比Shell守护脚本踏实太多——它知道进程是被kill掉的于是按on-failure规则拉起。而一旦你systemctl stop demoapp它又会非常顺从地停在那不会跟你较劲。3.4 配合journald把日志用起来既然不用守护脚本的重定向日志去哪了答案是journald。查看服务日志很简单journalctl -u demoapp -f-f是实时滚动。想看今天的journalctl -u demoapp --since today想看最后100条并带精确时间戳journalctl -u demoapp -n 100 -o short-isojournald日志默认存在/run/log/journal重启后会丢。想要持久化手动建一个/var/log/journal目录再重启systemd-journald即可sudo mkdir -p /var/log/journal sudo systemctl restart systemd-journald之后就再也不用操心“进程输出没有日志文件”了所有标准输出和标准错误都会自动落进journald还带时间戳和元数据。比如那个worker.sh如果打印内容也会被journald捕获随时可查。这是一步到位的日志基础设施。3.5 顺手把crontab也换成systemd timer迁移守护脚本时很多人会顺便把机器上的crontab一起迁了。systemd timer确实比cron多了不少好处日志统一、可以精确指定执行时机、还能用Persistenttrue处理错过的任务。我举一个每天凌晨3点清理一次日志的例子。先写关联的服务注意一次性任务要用oneshot# /etc/systemd/system/cleanup.service [Unit] DescriptionClean up old log files [Service] Typeoneshot Userdemo ExecStart/opt/demoapp/cleanup.sh再写配套的timer# /etc/systemd/system/cleanup.timer [Unit] DescriptionRun cleanup every day at 3am [Timer] OnCalendar*-*-* 03:00:00 Persistenttrue [Install] WantedBytimers.target然后sudo systemctl daemon-reload sudo systemctl enable --now cleanup.timer systemctl list-timersPersistenttrue的意思是如果计划时间点机器正好关机或休眠开机后补跑一次。这在cron里通常要额外脚本才能做到。日常维护里我已经把机器上九成cron都迁到了timer唯一保留cron的场景是中转机或者一时半会懒得迁移的旧机器。4. 进阶玩法udev热插拔、看门狗和定时任务4.1 设备热插拔触发服务插入手机自动启动这是很贴合实际的一个场景你把Android手机插到Ubuntu主机上希望系统自动做点事情比如执行备份脚本或者自动跑adb命令。传统做法是写udev规则然后用RUN去调用脚本很容易把udev worker卡住。更推荐的做法是打上systemd标签让udev把设备事件转交systemd由systemd拉起一个服务。我在/etc/udev/rules.d/99-android-phone.rules里写过类似规则ACTIONadd, SUBSYSTEMusb, ATTRS{idVendor}18d1, ATTRS{idProduct}4ee7, TAGsystemd, ENV{SYSTEMD_WANTS}phone-auto-backup.service字段含义拆开看ATTRS{idVendor}18d1匹配设备厂商ID18d1是Google的USB VID很多Android设备会用它。不同厂商要换成自己的VID和PID可以用lsusb查到。TAGsystemd把设备标记为systemd管理。ENV{SYSTEMD_WANTS}phone-auto-backup.service设备插入时systemd尝试启动这个服务。对应服务文件是一个oneshot任务# /etc/systemd/system/phone-auto-backup.service [Unit] DescriptionAuto backup phone photos Afterlocal-fs.target [Service] Typeoneshot ExecStart/usr/local/bin/phone-backup.sh这种由设备事件拉起的服务不需要systemctl enable服务文件存在并且能被systemd读到就行。规则写好后重载udev并跑一次trigger验证sudo udevadm control --reload-rules sudo udevadm trigger以后每次插入符合规则的手机systemd就会执行一次备份服务。和RUN直接调脚本相比SYSTEMD_WANTS的方式不会阻塞udev子系统的事件处理而且服务状态、日志都能用systemctl和journalctl统一查看非常适合做“设备插入自动执行”这类按需任务。4.2 服务卡死怎么办systemd看门狗前面说的Restart策略解决的是“进程退出”的问题。但还有一种更难查的故障进程既没退出也没死就是卡住了。比如死锁、网络阻塞、IO无限等待。进程还挂在cgroup里Shell守护脚本的pgrep根本察觉不到。这时候就需要看门狗机制。systemd的WatchdogSec做的是软件看门狗给服务设置一个超时时间服务进程必须在时间窗口内周期性地给systemd发心跳否则systemd就把进程杀掉并按策略重启。注意这和硬件的/dev/watchdog不一样它更灵活也不会因为某次偶然毛刺就把整机重启。一个典型配置[Service] Typenotify ExecStart/opt/demoapp/worker.sh WatchdogSec30 Restarton-watchdog RestartSec5这里Typenotify是关键服务必须通过systemd-notify或sd_notify接口发通知。在脚本里喂狗可以这样# /opt/demoapp/worker.sh systemd-notify --ready while true; do # 业务逻辑 systemd-notify WATCHDOG1 sleep 10 done--ready通知systemd服务已经就绪WATCHDOG1就是心跳。如果脚本因为某种原因卡住超过30秒没发心跳systemd会判定服务异常按Restarton-watchdog杀掉重启。需要特别提醒的是Typenotify模式下服务如果没有发送READY1systemd会一直等到TimeoutStartSec超时再杀掉所以脚本启动时第一件事就要把--ready发出去。这个特性对“服务还挂着但业务已经死了”的场景非常好用。我在香橙派Zero2这样的小板子上跑一些偶发死锁的程序时靠它多了一道最后的保险。4.3 启动顺序和依赖别乱来迁移过程中最容易翻车的还有依赖顺序。比如你的工作服务依赖数据库如果数据库还没起来它就先启动脚本在里面连库失败直接退出然后就开始崩溃循环。正确写法是声明依赖[Unit] DescriptionMy Service Requirespostgresql.service Afterpostgresql.serviceRequires强依赖目标服务挂了这个服务也会被停止。Wants弱依赖目标服务即使挂掉也不影响本服务。After只决定先后顺序不建立依赖关系。三者可以混用。比如既要保证顺序又不想被对方拖垮就只写Afternetwork-online.target而不写Requires。遇到“明明设置了After为什么还是提前启动”的问题多半是忘了配Wants或Requires因为After本身不触发启动它只是排队——如果没人把数据库拉起来After写得再多也没用。排查启动链可以用systemd-analyze critical-chain systemd-analyze time前者列出当前启动的依赖链和耗时后者给出系统总启动时间。调整开机启动顺序时这个工具比瞎猜强一百倍。5. 常见问题与排错实录5.1 环境变量和路径莫名消失迁移后最常见的问题是“脚本在终端跑得好好的变成服务就找不到命令”。多半是systemd执行服务时不会从你的Shell继承PATH。解决办法是在服务文件里显式补全[Service] EnvironmentPATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/demoapp/bin也可以用EnvironmentFile加载独立配置文件这样换机器、换环境时不用反复改服务文件。记住一个排查套路把服务文件里的ExecStart放到目标用户下手动执行一遍如果手动能跑、服务起不来优先检查环境变量和路径。5.2 服务启动失败状态一直是failedsystemctl status里如果显示failed先看两样东西退出码和最后几条日志。systemctl status demoapp journalctl -u demoapp -n 50常见原因按概率排脚本没有执行权限ExecStart/opt/demoapp/worker.sh要求脚本有x权限。解释器路径不对脚本首行#!/bin/bash如果写成了#!/bin/bash_xxx系统根本找不到解释器。用户没有目录权限Userdemo但目录属于root且目录权限是700脚本连读都读不了。WorkingDirectory不存在脚本里用了相对路径但工作目录没创建。ExecStart里带了Shell语法管道、重定向、变量展开没有包脚本就直写了。另外还有一个特征很明显的退出码203。这个码基本指向权限或解释器问题比如脚本没有执行权限或者指定的解释器不存在。这类问题基本都能从journalctl -u xxx -e的输出里找到根因-e会自动跳到日志末尾。养成先看日志的习惯比对着配置文件瞎改高效太多。5.3 进程还在但不干活Restart不生效这是比较坑的问题。如果程序hang住但不退出Restarton-failure根本不会触发因为systemd只关心“进程结束状态”不关心“是否卡住”。解决方案就是4.2里的WatchdogSec配合Typenotify让服务定期心跳。这是从“进程级保活”升级到“业务级保活”的关键一步。如果你服务的卡死表现为不响应端口也可以考虑配合健康检查脚本用Timer定期去探测发现异常就systemctl restart。不过这个方案没有Watchdog优雅我建议优先用Watchdog。5.4 开机自启没生效如果reboot之后服务没有起来先检查systemctl is-enabled demoapp输出enabled才是自启生效。如果没有执行systemctl enable demoapp。注意确认unit文件里的[Install]段写了WantedBymulti-user.target而且没有被注释掉。其次如果服务依赖网络开机阶段网络可能还没准备好这时要加上Afternetwork-online.target必要时配Wantsnetwork-online.target。很多“开机自启失败”其实是顺序问题不是自启配置问题。另外提醒一点系统服务用的是system实例桌面登录后的用户级服务用的是systemctl --user管理另一个实例两者别混。如果要在桌面环境托管输入法一类的应用操作的是用户级服务。5.5 常用排查命令速查把日常用得最多的几条命令整理成速查表贴在终端旁边很实用场景命令查看服务状态systemctl status xxx实时跟踪日志journalctl -u xxx -f查最近日志journalctl -u xxx --since today列出所有自启服务systemctl list-unit-files --stateenabled查看启动依赖链systemd-analyze critical-chain校验unit文件语法systemd-analyze verify xxx.service重载单元文件systemctl daemon-reload编辑覆盖配置systemctl edit xxx其中systemctl edit xxx是个容易被忽略的利器它会自动在/etc/systemd/system/xxx.service.d/下建一个override.conf你只管写增量配置systemd会把这个override和原unit合并。这样升级软件包时官方unit更新不会覆盖你的定制。我在生产环境改服务配置基本都走这条路尽量避免直接动发行版自带的unit文件。我个人在实际操作中最大的体会是迁移不要一上来就把所有脚本全换掉先挑一个最折磨人的常驻服务试水把service文件、日志、重启策略摸顺再逐步扩大范围。Shell守护脚本不是不能写只是systemd把那些运维动作沉淀成了标准化的声明式配置长期维护下来省心程度完全不是一个量级。你手上如果有正在用while循环保活的脚本今天就把它挪到/etc/systemd/system/下面试试重启一次机器对比一下体验应该就能直观感受到差别。
返回列表