外观
systemd 单元
把一个 jar 或脚本变成"开机自启、崩溃自动重启、日志可查"的服务,只需要一个 unit 文件。
一、最小单元
ini
# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=app
Group=app
WorkingDirectory=/opt/app
Environment="JAVA_HOME=/usr/local/jdk-17"
ExecStart=/opt/app/bin/start.sh
Restart=on-failure
RestartSec=5
LimitNOFILE=65535
[Install]
WantedBy=multi-user.targetbash
systemctl daemon-reload
systemctl enable --now myapp
systemctl status myapp --no-pagertext
● myapp.service - My Application
Loaded: loaded (/etc/systemd/system/myapp.service; enabled; vendor preset: disabled)
Active: active (running) since Tue 2026-10-27 22:01:12 CST; 5s ago
Main PID: 32811 (java)
Tasks: 42 (limit: 19485)
Memory: 512.4M
CPU: 1.204s
CGroup: /system.slice/myapp.service
└─32811 java -jar /opt/app/app.jar三段结构:
| 段 | 放什么 |
|---|---|
[Unit] | 描述、依赖关系、启动顺序 |
[Service] | 怎么启动、以谁运行、怎么重启 |
[Install] | systemctl enable 时装到哪个 target |
二、Type 怎么选
| Type | 行为 | 适用 |
|---|---|---|
simple(默认) | ExecStart 一执行就算启动成功 | 前台常驻进程(Java、Go、Node) |
forking | 父进程 fork 后退出,子进程继续 | 传统守护进程(nginx 默认) |
notify | 进程调用 sd_notify(READY=1) 才算就绪 | Redis、部分现代服务 |
oneshot | 执行完就退出,不算"运行中" | 初始化脚本、一次性任务 |
idle | 等其他任务都启动完再跑 | 非紧急任务 |
选错 Type 的典型症状:
- 用
simple跑forking的程序 → systemd 认为主进程已退出,反复重启。 - 用
forking跑前台程序 → systemd 一直等到超时(90 秒)才报失败。 - 用
notify但程序不发通知 → 一直activating,超时后失败。
bash
# 看程序到底是不是前台
/opt/app/bin/start.sh & # 如果它立即返回但进程还在,就是 forking三、依赖与顺序
ini
[Unit]
After=network-online.target mysql.service redis.service
Wants=network-online.target
Requires=mysql.service| 指令 | 作用 |
|---|---|
After=B | 在 B 之后启动(只管顺序) |
Before=B | 在 B 之前启动 |
Wants=B | 想要 B,但 B 失败也继续 |
Requires=B | 必须有 B,B 停止则自己也停 |
BindsTo=B | 更严格,B 消失就立即停 |
只有 After 不会"等数据库就绪"
After=mysql.service 只保证"mysql 的启动脚本已经执行过",不保证 mysqld 能接受连接。应用起来时数据库可能还在初始化。
两个办法:
- 应用自己加重试(推荐)。
- 在 unit 里加
ExecStartPre轮询探测:
ini
ExecStartPre=/bin/sh -c 'until nc -z 127.0.0.1 3306; do sleep 2; done'network-online.target 需要 systemd-networkd-wait-online 或 NetworkManager-wait-online 支持。只写 network.target 时,网卡可能还没拿到 IP。
四、常用指令
ini
[Service]
User=app
Group=app
WorkingDirectory=/opt/app
Environment="PROFILE=prod" "LOG_LEVEL=info"
EnvironmentFile=-/etc/app/app.env # 开头的 - 表示文件不存在也不报错
ExecStartPre=/bin/mkdir -p /data/app/logs
ExecStart=/opt/app/bin/start.sh
ExecReload=/bin/kill -HUP $MAINPID
ExecStop=/bin/kill -TERM $MAINPID
ExecStartPost=/bin/sh -c 'echo started >> /var/log/app-boot.log'
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=5
TimeoutStartSec=120
TimeoutStopSec=30
KillSignal=SIGTERM
SendSIGKILL=yes
LimitNOFILE=65535
LimitNPROC=4096
OOMScoreAdjust=-500
SuccessExitStatus=143逐项:
| 指令 | 说明 |
|---|---|
EnvironmentFile=-path | - 前缀表示文件缺失不报错 |
ExecStart | 只能有一个(oneshot 可以有多个) |
Restart=on-failure | 非 0 退出或被杀时重启 |
StartLimitBurst | 60 秒内超过 5 次就不再重启(防止无限重启) |
TimeoutStopSec | SIGTERM 后等多久发 SIGKILL |
LimitNOFILE | 文件句柄上限,覆盖 limits.conf |
OOMScoreAdjust | 内存紧张时让内核优先杀别人 |
SuccessExitStatus=143 | 把 143(SIGTERM)当正常退出 |
ExecStart 的写法细节:
ini
# 不经过 shell,参数按引号拆分(推荐)
ExecStart=/usr/local/jdk-17/bin/java -Xms1g -Xmx1g -jar /opt/app/app.jar
# 需要 shell 特性(管道、重定向、变量展开)时显式调用
ExecStart=/bin/sh -c 'exec /opt/app/bin/start.sh >> /var/log/app.log 2>&1'ExecStart 不要用 shell 形式启动常驻进程
ExecStart=/bin/sh -c '/opt/app/start.sh &' 会让 systemd 跟踪 sh,而不是真正的 Java 进程。systemctl stop 只杀掉 sh,Java 变成孤儿进程继续跑。
要么让脚本 exec 替换自己(exec java -jar ...),要么直接 ExecStart 指向 java。
五、日志
bash
journalctl -u myapp -f
journalctl -u myapp --since '10 min ago'
journalctl -u myapp -p err -n 50
journalctl -u myapp -o json-pretty | tailtext
Oct 27 22:01:12 kafka-1 systemd[1]: Started My Application.
Oct 27 22:01:13 kafka-1 java[32811]: 2026-10-27 22:01:13 INFO Started in 8.4s| 参数 | 作用 |
|---|---|
-f | 跟随 |
--since today / '2 hours ago' | 时间范围 |
-p err | 优先级(emerg/alert/crit/err/warning/notice/info/debug) |
-b | 本次启动 |
-b -1 | 上次启动 |
-o cat | 只输出消息体(去掉时间戳前缀) |
--no-pager | 不分页(脚本里必加) |
限制日志占用:
ini
# /etc/systemd/journald.conf
SystemMaxUse=2G
SystemMaxFileSize=200M
MaxRetentionSec=30daybash
systemctl restart systemd-journald
journalctl --disk-usage
journalctl --vacuum-size=1Gtext
Archived and active journals take up 1.8G in the file system.应用自己写日志文件时 journalctl 可能是空的
journalctl -u 只收 stdout/stderr。应用写到 /opt/app/logs/app.log 的日志要看文件,或者用 StandardOutput=file:/path 重定向(但这样一来 journald 又收不到了)。
六、常用操作
bash
systemctl daemon-reload # 改了 unit 文件后必须执行
systemctl enable --now myapp # 开机自启 + 立即启动
systemctl disable myapp
systemctl restart myapp
systemctl reload myapp # 发 SIGHUP / 执行 ExecReload
systemctl stop myapp
systemctl status myapp
systemctl cat myapp # 看最终生效的 unit(含 drop-in)
systemctl edit myapp # 创建 override,不改原文件
systemctl list-units --failed
systemctl list-unit-files --state=enabledsystemctl edit myapp 会创建 /etc/systemd/system/myapp.service.d/override.conf,内容只需写要覆盖的部分:
ini
[Service]
Environment="PROFILE=staging"改完自动 daemon-reload。
七、排错
启动失败
bash
systemctl status myapp --no-pager
journalctl -u myapp -n 50 --no-pager
systemctl cat myapptext
● myapp.service - My Application
Active: failed (Result: exit-code) since Tue 2026-10-27 22:05:02 CST
Process: 33120 ExecStart=/opt/app/bin/start.sh (code=exited, status=203/EXEC)常见状态码:
| 状态 | 含义 | 处置 |
|---|---|---|
203/EXEC | ExecStart 路径不存在或无执行权限 | 检查绝对路径和 chmod +x |
200/CHDIR | WorkingDirectory 不存在 | 先建目录 |
1/FAILURE | 程序自己退出 1 | 看应用日志 |
timeout | 启动超过 TimeoutStartSec | 调大,或改对 Type |
signal=KILL / 9 | 被 SIGKILL(OOM 或超时) | 查内存与 dmesg |
status=143 | 收到 SIGTERM | 若属正常停止可加 SuccessExitStatus=143 |
命令路径问题
ExecStart 不会读 /etc/profile、.bashrc,也不会展开 $PATH 里的别名。
ini
# 错
ExecStart=java -jar /opt/app/app.jar
# 对
ExecStart=/usr/local/jdk-17/bin/java -jar /opt/app/app.jar或者:
ini
Environment="PATH=/usr/local/jdk-17/bin:/usr/local/bin:/usr/bin:/bin"
ExecStart=/usr/local/jdk-17/bin/java -jar /opt/app/app.jar改了配置没生效
bash
systemctl daemon-reload && systemctl restart myapp忘了 daemon-reload 是最常见的"改了没反应"。
八、完整示例:Spring Boot
ini
# /etc/systemd/system/order-api.service
[Unit]
Description=Order API
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=app
Group=app
WorkingDirectory=/opt/order-api
EnvironmentFile=-/etc/order-api/env
ExecStart=/usr/local/jdk-17/bin/java \
-Xms1g -Xmx1g \
-XX:+UseG1GC -XX:MaxGCPauseMillis=200 \
-Xlog:gc*:file=/var/log/order-api/gc.log:time,uptime:filecount=5,filesize=50m \
-jar /opt/order-api/app.jar \
--spring.profiles.active=prod
SuccessExitStatus=143
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
LimitNOFILE=65535
OOMScoreAdjust=-500
[Install]
WantedBy=multi-user.target配套的优雅停机(application.yml):
yaml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 25sTimeoutStopSec=30 > timeout-per-shutdown-phase=25s,保证应用有机会自己收尾。反过来的话 systemd 会先 SIGKILL。
九、踩过的坑
| 现象 | 根因 | 处置 |
|---|---|---|
203/EXEC | 路径错/无执行权限 | 绝对路径 + chmod +x |
| 改了 unit 不生效 | 没 daemon-reload | 跑一次 |
| 服务显示 active 但端口没监听 | Type 选错(该 notify 用了 simple) | 改 Type |
| stop 后进程还在 | ExecStart 用了 sh -c 带 & | 用 exec 或直接指向程序 |
| 启动超时 | Type=forking 用在后台程序上 | 改 simple |
| 频繁重启 | 应用启动就崩 | 看 journalctl,加 StartLimitBurst 防止刷屏 |
| 日志找不到 | 应用写文件而非 stdout | 看应用日志路径 |
| 重启机器后服务没起 | 没 enable | systemctl enable |
| 端口被占导致启动失败 | 有残留进程 | ss -lntp,必要时 pkill |