2416 字
约 8 分钟
16
S15-X07 · systemd 服务托管:从 nohup 到 unit 文件

nohup java -jar app.jar & 能让进程活过一次 SSH 断开,但活不过一次重启:机器起来之后没人把它拉回来,崩了也没人重启它,日志堆在 nohup.out 里没人轮转,出了问题连“它属于哪个服务”都说不清。托管这件事,Ubuntu 24.04 上只有一个正确答案:写 unit 文件交给 systemd。

这篇按“为什么换 → 文件怎么写 → 怎么排错”的顺序,把 unit 的每个常用字段和它对应的坑讲清楚。


1. nohup & 到底缺什么

能力 nohup ... & systemd unit
忽略 SIGHUP 有(这就是 nohup 的全部作用) 不需要,进程不在终端会话里
开机自启 没有 enable
崩溃自动重启 没有 Restart=
进程归属与 cgroup 没有,孤儿进程 整个 cgroup 由 systemd 追踪
日志统一收集 nohup.out,无轮转 journald,按服务名过滤
资源限制 继承当前 shell 的 ulimit LimitNOFILE= 等显式声明
依赖顺序 手写 sleep 30 After= / Wants=

还有一个隐蔽差异:SSH 断开后进程是否被杀,取决于 logind.conf 的 KillUserProcesses。nohup 只挡信号,挡不住 cgroup 级的清理。


2. unit 文件的三段结构

# /etc/systemd/system/app.service
[Unit]
Description=Clarity API
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=30
StartLimitBurst=5

[Service]
Type=simple
User=deploy
Group=www-data
WorkingDirectory=/srv/app
EnvironmentFile=-/etc/default/app
Environment="JAVA_OPTS=-Xms512m -Xmx512m"
ExecStart=/usr/bin/java $JAVA_OPTS -jar /srv/app/app.jar
Restart=on-failure
RestartSec=5
LimitNOFILE=65535
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
段 回答的问题 常用键
[Unit] 这是什么、和谁有依赖、失败几次放弃 Description After Requires Wants StartLimit*
[Service] 怎么启动、用什么身份、怎么重启、日志去哪 Type ExecStart User Restart EnvironmentFile
[Install] enable 时挂到哪个 target 下 WantedBy RequiredBy Alias

放哪里也有讲究:/usr/lib/systemd/system/ 归包管理器,/etc/systemd/system/ 归管理员。自己写的服务一律放后者;想改包自带的 unit,用 systemctl edit app 生成 drop-in(/etc/systemd/system/app.service.d/override.conf),不要直接改原文件,否则升级会被覆盖。


3. Type= 决定 systemd 怎么判断“起来了”

Type 就绪判定 适用 坑
simple(默认) ExecStart 一 fork 就算启动成功 前台不退出的进程 程序自己 daemon 化会立刻被判失败
exec 同 simple,但等 execve 成功 同上,更严谨 与 simple 差别只在启动失败检测
forking 父进程退出后才算就绪 老式自 daemon 化的程序 必须配 PIDFile=,否则追不到主进程
notify 程序调 sd_notify(READY=1) 才算就绪 支持 notify 的程序(如 nginx 的部分模式) 程序不支持就会一直卡到 TimeoutStartSec
oneshot ExecStart 跑完即成功 一次性任务 不能配 Restart=always
idle 等其它任务跑完再启动 控制台输出相关 很少用

最常见的错误组合:程序内部 fork() 后父进程退出,unit 却写 Type=simple,systemd 看到主进程退出就认为服务挂了,于是一路 Restart 循环。要么改成 Type=forking + PIDFile=,要么让程序前台运行——后者更好。


4. 环境变量:Environment 与 EnvironmentFile

Environment=LANG=C.UTF-8
Environment="JAVA_OPTS=-Xms512m -Xmx512m -Dfile.encoding=UTF-8"
EnvironmentFile=/etc/default/app
EnvironmentFile=-/etc/default/app-extra
写法 解析规则 注意
Environment=A=1 B=2 空格分隔多个变量 值里有空格必须整体加引号
Environment="A=foo bar" 引号内的空格保留 引号不是 shell 引号,规则更简单
EnvironmentFile= 每行 KEY=VALUE,支持注释 不加引号,行内 # 之后是值的一部分
EnvironmentFile=-/path 前缀 - 表示文件不存在也不报错 可选配置的标准写法
$VAR 展开 只展开同一 unit 里已定义的变量 不会读到 shell 的 $PATH

最后一条是重灾区。写 Environment=PATH=/usr/local/bin:$PATH 不会得到你期望的 PATH,因为 systemd 不认识 shell 的 $PATH,展开结果可能是空。要固定 PATH 就整条写全,或者用 EnvironmentFile 加一行完整的 PATH=...。

ExecStart 里的 $JAVA_OPTS 会被 systemd 做变量替换(不是 shell 展开),所以 -Xmx512m 这类参数能传进去;但管道、重定向、通配符都不支持,需要 shell 语法时必须显式走 shell:

ExecStart=/bin/sh -c 'exec /usr/bin/java -jar /srv/app/app.jar >> /var/log/app/out.log 2>&1'

5. 重启策略与失败上限

Restart=on-failure
RestartSec=5
StartLimitIntervalSec=30
StartLimitBurst=5
值 什么时候重启 适用
no(默认) 从不 一次性任务
on-failure 退出码非 0、被信号杀、超时 服务默认选它
always 任何退出,包括 0 必须常驻的守护进程
on-abnormal 仅信号/超时/看门狗 不想因配置错误反复重启

StartLimitBurst 与 StartLimitIntervalSec 一起构成熔断:30 秒内失败 5 次就停止重启,服务进入 failed 状态。没有这层保护,一个配置写错的服务会无限重启并刷满日志。

RestartSec 别设成 0:程序端口还没释放就重启,会撞 Address already in use,看起来像“重启也没用”。


6. daemon-reload 什么时候必须执行

操作 需要 daemon-reload 需要重启服务
新建 unit 文件 是 是(首次 start)
修改 ExecStart / Environment / Restart 是 是
只想让新配置生效但不想中断 是 是,reload 只重读应用自身配置
systemctl edit 之后 自动执行,不用手动 是
只改 [Install] 段 是 否,重跑 enable 即可
改 /etc/default/app(EnvironmentFile) 否 是

daemon-reload 只重读 unit 定义,不碰进程。所以“改了 unit 但行为没变”几乎总是只做了 restart 没做 daemon-reload——systemd 用的还是内存里那份旧定义。


7. 日志:journalctl -u 与输出重定向

$ journalctl -u app -f
$ journalctl -u app --since '10 min ago' -o cat
$ journalctl -u app -b -1 --no-pager | tail -100
$ journalctl -u app -p err
StandardOutput= 行为 适用
journal(默认) 进 journald,按 unit 过滤 绝大多数服务
journal+console 同时打到控制台 调试
file:/path 追加到文件(systemd 240+ 用 append:) 应用自己按行切分时
null 丢弃 高频无意义输出
inherit 继承 systemd 的 stdout Type=oneshot 串联

journalctl 默认按时间正序、分页器输出,脚本里记得加 --no-pager。-o cat 只留消息体,去掉时间戳和主机名,适合管道给 grep。

持久化日志要先开 /var/log/journal:mkdir -p /var/log/journal && systemctl restart systemd-journald。默认配置只存在内存(/run/log/journal),重启即丢。


8. enable 与 start 是两件事

$ sudo systemctl daemon-reload
$ sudo systemctl enable --now app.service
$ systemctl is-enabled app && systemctl is-active app
命令 作用 不改 unit 时是否需要
start 现在启动 —
stop 现在停止 —
enable 按 [Install] 建符号链接,开机自启 只做一次
disable 删符号链接,不停止进程 只做一次
enable --now 两者合一 部署脚本常用
restart 停再起 改配置后
reload 发 SIGHUP 或执行 ExecReload 只改应用配置时

“服务跑着但重启后没了”就是漏了 enable;“enable 了但当前没跑”就是漏了 start。systemctl is-enabled 与 is-active 分别回答这两个问题。


9. 常见坑:六种启动失败

现象 原因 处理
status=203/EXEC ExecStart 不是绝对路径,或脚本没 shebang、无执行权限 写全路径;chmod +x;首行加 #!/usr/bin/env bash
status=200/CHDIR WorkingDirectory 不存在 先建目录,或删掉该行
status=217/USER User= 指定的用户不存在 id deploy 确认
一直 activating 然后超时 Type=notify 但程序不发 notify 改 Type=simple,或设 NotifyAccess=all
重启风暴 崩溃循环且无 StartLimit* 加 StartLimitBurst / RestartSec
Environment 里的值被截断 含空格未加引号,或 $VAR 未展开 整体加双引号;改走 EnvironmentFile

排错固定动作:

$ systemd-analyze verify /etc/systemd/system/app.service
$ systemctl status app -l --no-pager
$ journalctl -u app -b --no-pager | tail -50

systemd-analyze verify 能在启动前抓出大多数语法与路径问题,比反复 start 看日志快得多。


10. 边界:什么不该塞进 systemd

需求 更合适的方案 理由
定间隔的短任务 systemd timer 或 cron 常驻进程只为 sleep 循环是浪费
多副本与滚动发布 容器编排 systemd 不做调度与健康检查
需要精细资源配额 cgroup v2 + CPUQuota=/MemoryMax= 简单场景 unit 就够,复杂场景要编排
需要跨机高可用 keepalived / 负载均衡 单机 unit 只管本机
交互式调试 systemd-run --scope 临时跑 不要为调试写 unit

一句话收口:nohup 解决“别被信号杀”,systemd 解决“谁负责让它活着”。 unit 文件写对了,重启、崩溃、日志、开机自启这四件事就一次性都有了。


11. 自测

  1. nohup 只挡住了哪个信号?它没解决的四件事是什么?
  2. Type=simple 与 Type=forking 的就绪判定差在哪?
  3. 改了 unit 里的 ExecStart,为什么只 restart 不生效?
  4. Environment=PATH=/usr/local/bin:$PATH 会得到什么结果?
  5. enable 和 start 分别改了什么状态?
  6. 服务报 status=203/EXEC,最先查哪三样?
S15-X07 · systemd 服务托管:从 nohup 到 unit 文件
https://www.clxhxhhr.top/posts/5709/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。