自己写的应用部署到服务器上,重启后不会自动运行,这是新手必然遇到的第一个问题。早年的做法是往 /etc/rc.local 里加一行,或者用 crontab 的 @reboot,但这两者都没有依赖控制、没有自动重启、没有统一的日志,服务挂了也没人管。
现代 Linux 发行版(RHEL 7 及以上、CentOS 7 及以上、Rocky、AlmaLinux、Debian 8 及以上、Ubuntu 16.04 及以上)都使用 systemd 作为 init 系统。用 systemd 管理自启动,你能获得开机自动拉起、崩溃自动重启、依赖顺序控制、资源限制、日志统一收集这一整套能力。这篇文章讲清楚 unit 文件的三个段落、Type 与 Restart 的取值、enable 的原理,并给出 Node.js、Java、Gunicorn 三个可直接改用的实例,最后对比 rc.local 与 crontab @reboot 的适用边界。
systemd 与 unit 的基本概念
systemd 是 PID 1 的 init 进程,负责启动和管理系统中的所有服务。它管理的对象统称 unit,用 unit 文件描述,常见类型:
| 类型 | 后缀 | 用途 |
|---|---|---|
| Service | .service |
服务进程,本文主角 |
| Target | .target |
一组 unit 的逻辑分组,类似旧的运行级别 |
| Timer | .timer |
定时任务,可替代 cron |
| Socket | .socket |
socket 激活,按需启动服务 |
| Mount | .mount |
挂载点 |
| Path | .path |
路径变化时触发任务 |
| Slice | .slice |
资源分组,如 cgroup 层级 |
unit 文件有三个存放位置,优先级从高到低:
| 目录 | 用途 | 优先级 |
|---|---|---|
/etc/systemd/system/ |
管理员自定义,推荐使用 | 最高 |
/run/systemd/system/ |
运行时生成,临时 | 中 |
/usr/lib/systemd/system/(RHEL 系)/lib/systemd/system/(Debian 系) |
软件包安装 | 最低 |
自定义服务一律放 /etc/systemd/system/,不要修改软件包安装的文件,因为软件升级会覆盖它。想修改已有服务的参数,用 drop-in 目录:
sudo systemctl edit nginx.service
# 会创建 /etc/systemd/system/nginx.service.d/override.conf
unit 文件结构
一个典型的 service unit 分三段,每段有明确的职责:
[Unit]
Description=我的应用服务
Documentation=https://example.com/docs
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=appuser
Group=appgroup
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/node /opt/myapp/server.js
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
[Unit] 段:描述与依赖关系
| 指令 | 含义 |
|---|---|
Description |
服务描述,systemctl status 里会显示 |
Documentation |
文档地址 |
Before= / After= |
定义启动顺序,不表示依赖 |
Requires= |
强依赖,依赖失败则本服务失败 |
Requisite= |
依赖必须已启动,否则直接失败 |
Wants= |
弱依赖,依赖失败不影响本服务 |
BindsTo= |
强关联,依赖停止则本服务也停止 |
Conflicts= |
冲突,两者不能同时运行 |
ConditionPathExists= |
条件成立才启动 |
After 和 Requires 的区别必须搞清楚。 After=mysql.service 只表示"在 mysql 之后启动",如果 mysql 没装,本服务照常启动。Requires=mysql.service 才表示"mysql 必须成功启动,否则本服务也不启动",但它不保证顺序,所以通常两者一起写:
Requires=mysql.service
After=mysql.service
网络依赖是个高频坑。network.target 只表示网络配置已开始,不代表真的能上网;network-online.target 才表示网络真正就绪。需要联网的服务(比如启动后要调外部接口、要注册到注册中心)必须这样写:
After=network-online.target
Wants=network-online.target
同时要确保系统的网络等待服务已启用:systemctl enable --now NetworkManager-wait-online.service(使用 NetworkManager 时)或 systemd-networkd-wait-online.service。
[Service] 段:执行细节
| 指令 | 含义 |
|---|---|
Type |
进程类型,见下节 |
ExecStart |
启动命令,必须有绝对路径 |
ExecStartPre / ExecStartPost |
启动前后执行 |
ExecStop |
停止命令 |
ExecReload |
重载命令 |
User / Group |
运行身份 |
WorkingDirectory |
工作目录 |
Environment |
环境变量,可多行 |
EnvironmentFile |
从文件读取环境变量 |
Restart |
重启策略 |
RestartSec |
重启前等待秒数 |
TimeoutStartSec / TimeoutStopSec |
启动停止超时 |
KillSignal |
停止时发送的信号,默认 SIGTERM |
StandardOutput / StandardError |
输出去向,默认 journal |
LimitNOFILE |
文件描述符上限 |
PrivateTmp |
私有 /tmp 目录 |
ProtectSystem |
保护系统目录只读 |
几个易错点:ExecStart 必须是绝对路径,不能写 node server.js;命令中不支持 shell 的管道和重定向,需要的话要显式调用 shell:
ExecStart=/bin/bash -c '/usr/bin/node /opt/app/server.js >> /var/log/app.log 2>&1'
User 不写默认是 root,生产环境应创建专用用户,遵循最小权限原则。Environment 可以写多行,也可以用 EnvironmentFile 引入一个 KEY=VALUE 格式的文件,后者更适合管理密码等配置(文件权限设 600)。
[Install] 段:启用时的安装信息
这段决定 systemctl enable 会把服务的软链接放到哪里:
[Install]
WantedBy=multi-user.target
WantedBy 表示"当这个 target 被启动时,需要本服务"。最常用的 target:
| target | 含义 |
|---|---|
multi-user.target |
多用户命令行模式,服务器默认 |
graphical.target |
图形界面模式 |
network-online.target |
网络就绪 |
default.target |
默认 target 的软链接 |
也可以用 RequiredBy 表示强依赖。日常用 WantedBy=multi-user.target 就够了。
查看当前默认 target:
systemctl get-default
sudo systemctl set-default multi-user.target
Type 类型的选择
Type 决定了 systemd 如何判断服务"启动完成",选错会导致启动超时或者误判失败。
| Type | 适用场景 | systemd 的判定方式 |
|---|---|---|
simple |
默认,前台运行的程序 | 认为 ExecStart 进程就是主进程,启动即完成 |
exec |
类似 simple,但等 exec 完成 | 等 execve 成功后才算启动 |
forking |
传统守护进程,会 fork 到后台 | 等父进程退出,用 PIDFile 确定主进程 |
oneshot |
一次性任务,执行完就退出 | 等进程退出才算完成 |
notify |
支持 sd_notify 的程序 | 等程序主动发 READY=1 |
dbus |
通过 D-Bus 注册的服务 | 等 BusName 出现在 D-Bus |
idle |
等其他任务空闲后执行 | 延迟执行,极少用 |
判断方法很简单:程序启动后一直占着前台不退出(Node、Python、Go、Java 主类),用 simple;程序自己 fork 到后台并写 pid 文件(Nginx、传统 PHP-FPM、Redis 的旧配置),用 forking;跑完就退出的脚本(初始化数据、清理任务),用 oneshot。
forking 必须配合 PIDFile 使用,否则 systemd 找不到主进程:
Type=forking
PIDFile=/run/myapp/myapp.pid
oneshot 常用于初始化任务,通常需要加 RemainAfterExit=yes 让 systemd 把服务标记为 active,否则执行完状态变成 inactive:
Type=oneshot
RemainAfterExit=yes
Restart 策略
Restart 决定进程退出后 systemd 是否自动重启,这是 systemd 相比 rc.local 的核心优势之一。
| 取值 | 行为 |
|---|---|
no |
不重启,默认 |
on-success |
仅在正常退出(码 0)时重启 |
on-failure |
异常退出、被信号终止、超时才重启,最常用 |
on-abnormal |
被信号终止或超时才重启 |
on-watchdog |
看门狗超时才重启 |
on-abort |
因未捕获信号终止才重启 |
always |
无论如何都重启,包括正常退出 |
推荐配置:
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=300
StartLimitBurst=5
StartLimitBurst 与 StartLimitIntervalSec 组合,表示在指定时间窗口内最多重启多少次,超过后 systemd 会放弃并把服务置为 failed,避免"崩溃—重启—再崩溃"的无限循环打满日志和 CPU。这是必须配置的安全阀。
如果程序本身是跑批任务(跑完就该退出),不要用 Restart=always,否则它会一遍遍重复执行。
完整操作流程
写完 unit 文件后,按这个流程操作:
sudo vi /etc/systemd/system/myapp.service
sudo systemctl daemon-reload # 修改了 unit 文件必须执行
sudo systemctl enable --now myapp # 设开机自启并立即启动
systemctl status myapp --no-pager
journalctl -u myapp -f # 实时看日志
daemon-reload 是新手最容易忘记的一步。它的作用是让 systemd 重新读取 unit 文件,不做的话你的修改不会生效,表现是"明明改了配置,重启服务还是老样子"。
其他常用命令:
systemctl start myapp
systemctl stop myapp
systemctl restart myapp
systemctl reload myapp # 需要服务支持 ExecReload
systemctl enable myapp # 只设自启,不立即启动
systemctl disable myapp # 取消自启
systemctl is-enabled myapp
systemctl is-active myapp
systemctl daemon-reload # 重载 unit 配置
systemctl list-units --type=service --state=running
systemctl list-unit-files --type=service | grep enabled
systemctl --failed # 只看失败的
systemctl cat myapp # 查看最终生效的完整配置
systemctl show myapp # 查看所有属性
systemctl cat 非常有用,它会显示 unit 文件本体加上所有 drop-in 覆盖,能确认最终生效的配置是什么。
enable 到底做了什么? 它只是在 /etc/systemd/system/multi-user.target.wants/ 目录下创建了一个指向 unit 文件的软链接。disable 就是删掉这个链接。所以手工创建软链接也能达到同样效果,但不推荐,用标准命令更可靠。
验证自启配置是否正确:
systemctl is-enabled myapp
ls -l /etc/systemd/system/multi-user.target.wants/ | grep myapp
三个可直接改用的实例
Node.js 应用
[Unit]
Description=Node.js 应用服务
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=nodeapp
Group=nodeapp
WorkingDirectory=/opt/nodeapp
Environment=NODE_ENV=production
Environment=PORT=3000
ExecStart=/usr/bin/node /opt/nodeapp/server.js
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=nodeapp
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
用 nvm 安装的 Node 路径可能是 /home/用户名/.nvm/versions/node/v20.x/bin/node,要用 which node 或 readlink -f $(which node) 确认真实路径。不要写 ExecStart=npm start,npm 会额外包一层进程,信号传递和退出码都会出问题。
Java 应用
[Unit]
Description=Java 应用服务
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=javaapp
WorkingDirectory=/opt/javaapp
Environment=JAVA_HOME=/usr/lib/jvm/java-17-openjdk
EnvironmentFile=/opt/javaapp/app.env
ExecStart=/usr/lib/jvm/java-17-openjdk/bin/java \
-Xms512m -Xmx1024m \
-jar /opt/javaapp/app.jar \
--spring.profiles.active=prod
SuccessExitStatus=143 # SIGTERM,让优雅停机不被判为失败
Restart=on-failure
RestartSec=10
TimeoutStopSec=60 # 给 JVM 足够时间优雅关闭
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
SuccessExitStatus=143 是个实用技巧:systemd 停止服务时发 SIGTERM,Java 收到后退出码是 143,如果不声明,systemd 会认为服务异常退出。TimeoutStopSec 给 JVM 足够时间执行 shutdown hook,避免强行 kill 导致事务中断。
Python Gunicorn
[Unit]
Description=Gunicorn 服务
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
User=webapp
Group=webapp
WorkingDirectory=/opt/webapp
Environment=PATH=/opt/webapp/venv/bin:/usr/bin:/bin
EnvironmentFile=/opt/webapp/.env
ExecStart=/opt/webapp/venv/bin/gunicorn \
--workers 4 \
--bind 127.0.0.1:8000 \
--access-logfile - \
--error-logfile - \
app:application
ExecReload=/bin/kill -HUP $MAINPID
KillSignal=SIGTERM
Restart=on-failure
RestartSec=5
RuntimeDirectory=gunicorn
[Install]
WantedBy=multi-user.target
Type=notify 需要 Gunicorn 安装 gunicorn[gevent] 之外的 sd_notify 支持,实际使用中如果程序不发 READY 信号会导致启动超时,稳妥起见可以改用 Type=simple。RuntimeDirectory=gunicorn 会让 systemd 自动创建 /run/gunicorn 并设置权限,比手工 mkdir 干净。
启动失败怎么排查
systemctl status myapp --no-pager
journalctl -u myapp -n 50 --no-pager
journalctl -u myapp --since '-10 min' --no-pager
journalctl -xe --no-pager
systemd-analyze verify /etc/systemd/system/myapp.service
systemd-analyze verify 能检查 unit 文件的语法和常见错误,写完配置后先跑一遍很有必要。
常见原因按出现频率排序:
一是路径问题。 ExecStart 用了相对路径或者程序不在 PATH 里。用 command -v 确认绝对路径。
二是权限问题。 User 指定的用户对 WorkingDirectory、日志目录、配置文件没有读写权限。检查:
sudo -u appuser ls -l /opt/myapp
sudo -u appuser test -r /opt/myapp/config.yaml && echo "可读"
三是端口冲突或依赖未就绪。 看日志里有没有 Address already in use,用 ss -lntp 确认。
四是环境变量缺失。 systemd 不会加载用户的 .bashrc。用 Environment 或 EnvironmentFile 显式声明,这一点和 crontab 的坑一样。
五是 SELinux 限制(RHEL 系)。 自定义路径和端口可能被 SELinux 拒绝,看 /var/log/audit/audit.log 有没有 denied,或临时用 setenforce 0 验证是不是这个原因(验证后记得恢复):
sudo ausearch -m avc -ts recent
sudo journalctl -t setroubleshoot --no-pager
六是 Type 选错导致超时。 前台程序写成 forking 会一直等待父进程退出,最终超时失败。
rc.local 与 crontab @reboot 的对比
这两者仍然可用,但适用场景已经很窄。
| 维度 | systemd service | rc.local | crontab @reboot |
|---|---|---|---|
| 依赖顺序 | 精确控制 After、Requires |
无,几乎最后执行 | 无,cron 启动时执行 |
| 崩溃自动重启 | 支持 Restart |
不支持 | 不支持 |
| 日志 | 统一进 journald | 需自行重定向 | 需自行重定向 |
| 资源限制 | 支持 CPU、内存、句柄限制 | 不支持 | 不支持 |
| 停止与重载 | 标准命令 | 需自己写脚本 | 无停止概念 |
| 状态查看 | systemctl status |
无 | 无 |
| 适用场景 | 所有常驻服务 | 一次性开机脚本 | 简单的启动钩子 |
rc.local 在新系统上默认不存在,需要手工创建并赋予执行权限,还要有一个 systemd 单元去调用它:
sudo chmod +x /etc/rc.local
sudo systemctl enable --now rc-local
Debian、Ubuntu 上对应的是 /etc/rc.local,RHEL 系是 /etc/rc.d/rc.local,且都需要 chmod +x。多数 systemd 系统自带 rc-local.service 单元,但默认未启用。
结论:常驻服务一律用 systemd service;只有那种"开机时跑一次、跑完就结束、不需要管理"的初始化脚本才考虑 rc.local。 比如开机时设置某个内核参数、初始化目录权限、写入一个标记文件。@reboot 的限制更多,它连执行顺序都保证不了,只有最简单的场景才用。
常见问题
改了 unit 文件,重启服务没生效?
需要 systemctl daemon-reload。这不是重启服务,而是让 systemd 重新读取配置文件。修改任何 unit 文件后都必须执行它,然后再 systemctl restart。
enable 之后重启机器服务还是没起来?
先确认 systemctl is-enabled 返回 enabled,再看 journalctl -b -u 服务名 查上次启动时的日志。常见原因是依赖的 target 没达到(比如 After=network-online.target 但 wait-online 服务没启用),或者服务在启动阶段就失败了。
Type 该选 simple 还是 forking?
看程序行为。启动后一直占着终端不退出的是 simple;自己 fork 到后台、父进程立刻退出并留下 pid 文件的是 forking。不确定时先试 simple,如果 systemd 一直卡在 activating 状态直到超时,就改成 forking 并配 PIDFile。
服务一直重启,刷爆日志怎么办?
配置 StartLimitBurst 和 StartLimitIntervalSec 限制重启次数。同时用 systemctl reset-failed 服务名 清除失败计数。根本解决还是要修掉崩溃原因,看 journalctl -u 服务名 里的退出原因。
想让服务只运行一次,不要自动重启?
用 Type=oneshot 配合 RemainAfterExit=yes,Restart 设为 no。这种配置适合开机初始化任务,比如导入初始数据、预热缓存。
多个服务有先后顺序怎么配?
在被依赖的服务里写 Requires=依赖服务 加上 After=依赖服务,不要在依赖方写 Before。如果只需要顺序不需要强依赖,用 Wants 和 After 组合。target 可以用来聚合一组服务,先启动 target 再启动业务服务。
总结
- 自定义服务放在
/etc/systemd/system/,修改已有服务用systemctl edit创建 drop-in 覆盖,不要改软件包自带的文件。 - unit 三段各司其职:
[Unit]管描述与依赖(After管顺序、Requires管成败,两者常一起写),[Service]管执行细节,[Install]的WantedBy=multi-user.target决定 enable 时的挂载点。 - Type 按程序行为选择:前台常驻用
simple,自己 fork 到后台用forking并配PIDFile,一次性任务用oneshot加RemainAfterExit=yes。 - 必配
Restart=on-failure与StartLimitBurst限流,前者保证崩溃自动拉起,后者防止无限重启打满资源。 - 改完 unit 文件必须
systemctl daemon-reload,排查用systemctl status加journalctl -u,rc.local 和@reboot只适合不需要管理的开机一次性脚本。
企业QQ咨询




