跳到正文
systemd 守护服务:不只是写一行 Restart=always
systemd 守护服务:不只是写一行 Restart=always

systemd 守护服务:不只是写一行 Restart=always

从 nohup 迁移到 systemd 后,我重新理解了 restart 策略、enable、启动限速、日志、健康检查和 sandbox;进程回来不等于服务已经恢复。

速读#

nohup 只能让进程在终端断开后继续运行,systemd 则把启动身份、工作目录、环境、重启策略、日志、资源边界和开机依赖写成可审查配置。但“systemctl 显示 active”只说明主进程仍在,不代表端口可用、数据库已连接或外部请求能成功;守护必须和健康检查、告警、回滚与独立管理入口一起使用。

我最初把 systemd 简化成 Restart=alwaysWantedBy=multi-user.target 两行,后来发现两者都容易被误读。Restart=always 连正常退出也会重启,只有 systemd 发起的 stop 等情况被排除;WantedBy 只描述 enable 时应把 unit 挂到哪个 target,真正创建开机启动链接的是 systemctl enable。理解这些状态转换,比背一份 unit 模板更重要。

nohup 与服务管理解决的不是同一层问题#

nohup python3 main.py & 适合临时实验:shell 退出后进程不会收到同样的挂断信号,输出可以重定向到文件。它不会声明应该用哪个系统用户、何时随机器启动、退出后是否重试、停止时给多少收尾时间,也不会自动限制重启风暴。脚本找不到、环境变量丢失和日志写满磁盘都要自己处理。

systemd 的价值是把这些隐含状态变成 unit,并用统一命令管理。它不是唯一的 supervisor,容器编排、s6、runit 和其他平台也能承担相近职责;在普通 Debian VPS 上,systemd 已经是 PID 1,直接使用现有能力比再引入一层进程管理器更简单。一个要长期提供服务的进程若只能靠我记得登录重启,部署仍未完成。

一份能解释的最小 unit#

下面示例适合一个需要持续运行、状态写入 /var/lib/myapp 的 Python 服务:

[Unit]
Description=My application
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/usr/bin/python3 /opt/myapp/main.py
Restart=on-failure
RestartSec=3
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/myapp
UMask=0027
[Install]
WantedBy=multi-user.target

ExecStart 使用绝对路径并写出完整脚本位置,因为 systemd 不会读取交互式 shell 的 .bashrc、虚拟环境激活和别名,也不会默认把命令交给 shell 解析。现代 systemd 对简单可执行文件名有固定搜索路径,但绝对路径更容易跨环境审查;若确实需要管道、重定向或复杂 shell 语义,应显式调用脚本,而不是把一长串命令塞进 /bin/sh -c

EnvironmentFile 应在仓库外,由 root 管理并限制权限,应用日志不得打印全部环境。ProtectSystem=strict 把大部分文件系统改为只读,ReadWritePaths 只放行业务状态目录;这些 sandbox 选项要根据实际写入和系统调用逐项测试,不能为了评分更高一次全开,最后再用 root 运行绕过所有限制。

restart 策略要匹配退出语义#

常驻 Web 服务通常可以从 Restart=on-failure 开始:非零退出、信号或超时会重启,正常 exit 0 则保留停止状态。如果程序无论因何退出都应继续存在,例如当前站点的 OAuth 与部署 webhook,Restart=always 也合理;它会在正常退出后再次启动,但 systemctl stop myapp 代表管理员明确意图,不会立即被 restart 拉回。

短任务、迁移脚本和定时 job 不适合无条件重启,成功后退出本来就是完成。反过来,一个服务把致命配置错误错误地以状态 0 退出,on-failure 也不会救它;退出码契约需要由应用和 unit 共同定义,必要时再使用 SuccessExitStatusRestartPreventExitStatus,而不是盲目改成 always。

StartLimitIntervalSecStartLimitBurst 控制在时间窗口内允许多少次启动,避免三秒一崩的进程无限刷日志和消耗资源。触发限制后先看根因,修复配置,再执行 systemctl reset-failed myapp;把限制调到无限只会掩盖持续故障。可以直接查看重启计数和状态:

Terminal window
systemctl show myapp -p ActiveState -p SubState -p NRestarts

network-online 也不是“互联网一定可用”#

After=network.target 主要表达启动顺序,不保证地址、路由和 DNS 已经可用。Wants=network-online.targetAfter=network-online.target 会等待系统所配置的 online target,但只有相应的 wait-online 服务正确启用时才有意义,也不能保证某个外部 API 当时健康。

长期服务仍应对 DNS、数据库和上游连接实现有限重试与退避。把一次开机网络抖动完全交给 systemd restart,可以作为最后兜底,却会让应用不断完整重启;更精确的做法是应用区分临时依赖失败和不可恢复配置错误,并在健康状态中暴露结果。

修改、启用和启动是三件事#

复制或修改 unit 后,先验证,再让 manager 重新读取:

Terminal window
sudo systemd-analyze verify /etc/systemd/system/myapp.service
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp

daemon-reload 只重新加载 unit 定义,不会自动重启已经运行的服务;修改 ExecStart 或环境后,还要显式 restart。enable 创建开机目标依赖,--now 额外立即启动;只写 WantedBy 不执行 enable,重启后不会凭空自启。反过来,静态 unit 或由 timer/socket 拉起的 unit 可能根本不需要 enable 服务本身,仍要按启动模型判断。

部署期间用 systemctl cat myapp 查看 systemd 合并后的完整 unit,包括 drop-in,避免只读主文件却漏掉覆盖项。修改后也要确认实际进程命令:

Terminal window
systemctl show myapp -p FragmentPath -p DropInPaths -p ExecStart

这比“我刚刚明明改过文件”更能回答线上到底加载了什么。

active 只说明进程,不说明业务健康#

systemd 默认观察主进程生命周期。一个进程可以仍在运行,却卡死事件循环、耗尽连接池或始终返回 500;此时 unit 仍是 active,Restart= 不会触发。服务至少要有本机健康端点,部署后实际请求一次,外部监控再从用户路径验证 Cloudflare、Tunnel、Nginx 和应用整条链。

Terminal window
curl -fsS http://127.0.0.1:4180/healthz
journalctl -u myapp -b --no-pager

WatchdogSec= 也不是写上就能检测卡死。应用必须支持 sd_notify 并按约定发送 WATCHDOG=1,否则 systemd 只会认定它超时。RuntimeWatchdogSec= 则是 system manager 与硬件 watchdog 的另一层,用于 PID 1 或内核失去响应时尝试重启主机;云主机断电、网络隔离和供应商故障仍需要外部监控与重建方案。三个 watchdog 名字相近,保护的故障域不同。

journal 统一了入口,但日志仍要管理#

systemd 会收集服务 stdout/stderr,journalctl -u myapp --since yesterday 可以按 unit 和时间查看退出码与重启记录。部分发行版默认 journal 是易失的,重启后旧日志可能消失;持久化、磁盘上限和轮转要检查 /etc/systemd/journald.conf 与实际 /var/log/journal,不能把“能用 journalctl”误认为日志永久保存。

应用还要避免把 Token、Cookie 和完整请求体写进 journal。统一日志让查询更方便,也让具有 journal 读取权限的用户能集中看到更多敏感信息。告警应关注服务反复重启、健康失败和日志异常,而不只是“当前 active”;一个进程夜里崩了十次又自动回来,用户也许没发现,维护者仍然需要知道。

当前站点怎样使用 sandbox#

当前 OAuth 与部署 webhook unit 使用独立低权限用户,执行 root 拥有的 /opt 代码副本,环境文件放在 /etc,并开启 ProtectSystem=strictPrivateTmp、空 capability 集合与系统调用过滤;只有部署服务通过 ReadWritePaths 获得发布目录写权限。这样即使部署 checkout 被错误修改,长期运行的服务代码也不会自动跟着变。

加固不是无代价。我曾尝试更严格的系统调用与内存执行限制,Node JIT 和构建工具因此失败;TypeScript 的原生工具探测被禁止 syscall 时甚至收到 SIGSYS 退出。最后没有强开 MemoryDenyWriteExecute,并让被过滤的 syscall 返回 EPERM,使工具能够正常降级。systemd-analyze security myapp 适合发现候选项,不是追求满分的考试;每项限制都要用真实启动、构建和故障路径验证。

systemd 把“关掉终端还能跑”提升成可声明、可重启和可审计的服务生命周期,但它只管理自己看得到的进程与资源。可靠上线仍需要应用健康、外部监控、数据恢复和主机救援。守护的分水岭不是 unit 文件存在,而是进程退出、卡死、重启和整机故障时,我分别知道谁会处理、谁会通知,以及哪一步仍然必须由人完成。

版权许可

CC BY-NC-SA 4.0 本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

相关文章

s1oopX

登录 s1oopX