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. 自测
nohup只挡住了哪个信号?它没解决的四件事是什么?Type=simple与Type=forking的就绪判定差在哪?- 改了 unit 里的
ExecStart,为什么只restart不生效? Environment=PATH=/usr/local/bin:$PATH会得到什么结果?enable和start分别改了什么状态?- 服务报
status=203/EXEC,最先查哪三样?