开机自启这件事,比想象的容易翻车
「我把脚本放到 crontab 里 @reboot 了」「我 systemctl enable 过了」「我写进 /etc/rc.local 了」——这三种回答背后是三拨人,他们都以为自己解决了开机自启,但其中至少一半人在真正重启之后发现服务没起来,或者起来了但状态不对。
开机自启之所以容易出问题,是因为它涉及一条很长的链路:内核启动 → init 系统接管 → 挂载与网络就绪 → 服务真正拉起。这条链路上每个环节都有各自的前置条件,而「执行我的脚本」这件事往往很早就被安排了,早到它需要的东西还没准备好。
这篇文章讲的是单机自建场景,系统以 systemd 为主(Debian 10+、Ubuntu 16.04+、CentOS 7+ 都是)。内容分三块:先讲清 systemd 里的启动顺序到底是怎么排的,再讲四种「起来了但不正常」的具体形态,最后给一套可以直接抄的自检流程。
systemd 的启动顺序:不是按字母,也不是按目录
很多人以为 /etc/systemd/system/ 下的 unit 是按文件名顺序启动的。不是。systemd 是一个依赖驱动的调度器:它先算出所有 WantedBy / RequiredBy 关系的整体图,然后并行启动所有「前置都满足」的 unit。所以启动顺序由两样东西决定:目标(target)和依赖(After / Before / Wants / Requires)。
target 决定「什么时候」
最常见的错误是随手写 WantedBy=multi-user.target。这是个合法值,但它的含义是「多用户模式达成时启动」,而多用户模式达成并不等于网络可用(如果你用的是 network.target)或者磁盘全挂好(如果用了网络存储)。
把 target 的层次记清楚,自启问题就解决一半:
sysinit.target ← 本地文件系统、内核参数、基本设备
└ basic.target ← 基础服务(日志、socket 激活)
└ network.target ← 网络栈初始化完成(≠ 有可用地址)
└ network-online.target ← 网络真的连上了、有 IP 了
└ multi-user.target ← 多用户、无图形界面
└ graphical.target ← 图形界面需要联网的服务——访问数据库、同步到对象存储、拉取远端配置、做 DNS 查询——一定要用 network-online.target,并且要 Wants=network-online.target 加 After=network-online.target 两条一起写。少了 Wants 只写 After 是没用的:After 只规定「如果它也在启动队列里,就排在它后面」,它不在队列里就毫无效力。这是最经典的一个坑。
After 与 Wants 的分工
四个关键指令,含义完全不同,经常被混用:
Wants=:软依赖。我想让它先起来,但它失败了我照样继续。启动时会把它拉进队列。
Requires=:硬依赖。它起不来,我就不启动;它中途挂了,我也跟着停。用在「没有它业务就完全没有意义」的场景,比如应用 unit 依赖自己的数据库 unit。
After=:纯顺序。不影响是否启动,只影响先后。如果对方根本不在启动队列里,这条等于没写。
Before=:反向的 After,通常在被依赖方写,或者在你想插队时写。
正确的组合是 After + Wants(软顺序)或 After + Requires(硬顺序)。只写 After 是无效顺序,只写 Wants 是无序依赖——两个都不完整。
一个可以直接抄的 unit
[Unit]
Description=My blog sync worker
Documentation=https://example.com/ops/sync
Wants=network-online.target
After=network-online.target mysql.service
ConditionPathExists=/opt/blog/sync.php
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/blog
ExecStart=/usr/bin/php /opt/blog/sync.php --daemon
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
StartLimitBurst=5
StartLimitIntervalSec=120
TimeoutStopSec=30
KillSignal=SIGTERM
StandardOutput=journal
StandardError=journal
SyslogIdentifier=blog-sync
# 轻量加固,按需删减
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
[Install]
WantedBy=multi-user.target几个细节值得单独说。WorkingDirectory 一定要写,否则相对路径全都失效——这是「手动跑正常、开机跑就挂」的第一大原因。User 一定要写,不写就是 root,既不合规也可能因为没有权限读配置文件而失败(奇怪的是有些失败是反过来的:手动用 root 跑正常,systemd 用 www-data 跑就报权限不足)。
ExecStart 里的路径必须都是绝对路径。systemd 不读你的 shell 环境,PATH 也不是你登录后的那个。所有 php、python3、node 都要写全路径。
StartLimitBurst 和 StartLimitIntervalSec 要一起写,它们的意思是「120 秒内最多重启 5 次,超过就放弃」。如果不写,一个反复崩溃的服务会被 systemd 无限重启,日志刷满磁盘。注意这两个参数在旧版本 systemd 里属于 [Service] 段,新版本(v230 之后)移到了 [Unit]。放错位置不报错,但也不生效——很难发现。
四种「起来了但不正常」
形态一:进程在,端口不通
systemctl status 显示 active (running),但 curl localhost:8080 连接被拒。
原因通常是进程还在初始化。systemd 默认 Type=simple,意思是「fork 完就认为启动成功」,此时进程可能还在加载数据、监听的 socket 还没 bind。依赖它的服务在这段时间里访问就会失败。
修法是改成 Type=notify(如果程序支持 sd_notify)或者 Type=exec 配合 ExecStartPost 做健康检查,再或者用 socket 激活(blog-sync.socket),让 systemd 先建好监听、进程起来后再接。最省事的替代方案是加一段等端口的 ExecStartPost:
ExecStartPost=/bin/sh -c 'for i in $(seq 1 30); do curl -sf http://127.0.0.1:8080/healthz && exit 0; sleep 1; done; exit 1'这样 systemd 会认为「启动完成」的时刻是端口真的通了,后续依赖方拿到的就是可用的服务。
形态二:环境变量丢了
手动 export DB_HOST=... 之后跑脚本一切正常,写成 unit 就报「连不上数据库」。因为 systemd 不继承你登录 shell 的任何环境变量。
两个解法。Environment= 直接写在 unit 里,适合一两个简单变量;EnvironmentFile= 指向一个文件,适合多个变量或者含特殊字符的场景:
EnvironmentFile=/etc/blog/sync.env
# 文件内容形如(等号两边不要空格,不要加引号,除非值里有空格)
# DB_HOST=127.0.0.1
# DB_PASS=p@ss=wordEnvironmentFile 里如果值包含 #,它会被当成注释截断。# 出现在密码里就得用引号包起来,或者干脆换一个不含 # 的密码。这种坑排查起来极其费时,报错只有「认证失败」。
另外,如果 EnvironmentFile 指向的文件不存在,服务直接启动失败,报错在 journal 里但并不显眼。EnvironmentFile=-/path(前面加短横线)表示文件可选,不存在也继续。
形态三:重启次数超限,进入 failed 状态
现象是「服务跑了几天突然停了,日志里看不出为什么」,systemctl status 显示 failed,journalctl 里有一行 Start request repeated too quickly。
这是 StartLimitBurst 生效了。服务在某段时间内反复崩溃(比如数据库短暂不可用导致 worker 退出),触发限流后 systemd 放弃重启,服务就永久停在 failed。这时候手工 systemctl restart 能恢复,但下次还会再发生。
两种处理思路。一是放宽限流并加长 RestartSec,让它在长时间不可用的情况下仍能不断重试(比如 RestartSec=30、StartLimitIntervalSec=0 表示不限制)。二是保留限流,但加一个监控告警,failed 时告诉你。我个人倾向前者用于「偶发外部依赖故障」,后者用于「代码有 bug 反复崩溃」——后者的正确做法是修 bug,而不是让它无限重启。
恢复 failed 状态要记得先 systemctl reset-failed blog-sync,否则限流计数器还在,next start 会立刻再触发限制。
形态四:启动顺序对,但磁盘或挂载还没好
如果你的应用依赖某个额外挂载的磁盘(数据盘、NFS、对象存储的 fuse 挂载),multi-user.target 时它可能是好的,但不保证。正确的依赖是 After=local-fs.target(本地文件系统全部挂载完成)或者针对具体挂载点的 RequiresMountsFor=/mnt/data。
[Unit]
RequiresMountsFor=/mnt/data
After=local-fs.targetRequiresMountsFor 是个很好用的指令,它会自动加上对挂载点和块设备的依赖,比手写 Requires= 更准确。如果挂载点配在 /etc/fstab 里,这一条就够了。
另外要提醒:/etc/fstab 里如果有任何一个挂载项配错(比如设备不存在),启动会卡在「等待设备超时」90 秒,甚至进入紧急模式。这是「重启后 SSH 连不上」的常见原因之一。改 fstab 之前一定要先 findmnt --verify 验证一遍。
crontab @reboot 为什么不推荐
@reboot 能用,但它有三个本质缺陷。
第一,它没有顺序概念。所有 @reboot 任务都在 cron 守护进程启动后几乎同时执行,此时网络、数据库、挂载都可能没准备好。你没法说「等 MySQL 起来之后再跑」。
第二,它不管进程存活。脚本退出了就退出了,崩溃了不会重启。cron 只负责「在那一刻跑一次」。
第三,它的日志很难找。大多数发行版的 cron 会往 /var/log/syslog 或 mail 里塞输出,而 mail 通常没配,于是输出直接丢了。排查时你会看到「任务没执行」,但找不到任何线索。
如果只是「开机跑一次性的清理脚本」,@reboot 完全够用,配一行重定向到日志文件即可:
@reboot /opt/scripts/cleanup.sh >> /var/log/cleanup.log 2>&1但只要是长期运行的服务,就应该用 systemd unit。Restart=on-failure 提供的自愈能力,是 cron 永远给不了的。
一套可以照做的自检流程
写完 unit 之后,不要直接 reboot 赌运气,按这七步走。
第一步,语法检查。systemd-analyze verify /etc/systemd/system/blog-sync.service。它会提示不存在的可执行文件、明显写错的指令名,能提前抓掉一半的低级错误。
第二步,systemctl daemon-reload。改完 unit 文件必须执行,否则 systemd 用的是内存里的旧版本。这是「改了没生效」的头号原因。
第三步,systemctl start 手动启动一次,看状态。systemctl status blog-sync 应该看到 active (running) 且 Main PID 有值。
第四步,读日志。journalctl -u blog-sync -n 50 --no-pager。这一步必须做,因为 status 里的日志往往被截断,完整的错误信息在 journal 里。
第五步,执行 systemctl enable,然后确认软链已建立:systemctl is-enabled blog-sync 应该返回 enabled。enable 只是创建了一个指向 unit 的软链接在 multi-user.target.wants 里,它不做任何其他事。
第六步,模拟加载完成后的启动。这一步很多人不知道:systemctl isolate multi-user.target 会让系统切换到多用户目标,效果接近「重新走一遍启动流程」但不用真重启。观察服务是否正常起来。这一步能抓出 90% 的启动顺序问题,而且耗时只有几秒。
第七步,才是真重启。重启之后第一件事不是看网站,而是跑这几条:
systemctl --failed # 有没有 failed 的 unit
systemd-analyze blame | head -20 # 启动耗时 top20,有没有异常慢的
systemd-analyze critical-chain blog-sync # 这台服务的启动关键路径
journalctl -b -p err --no-pager | head -30 # 本次启动的所有错误级日志systemd-analyze critical-chain 特别有用:它把服务的启动链路按时间排出来,标出每一环的耗时。如果服务实际启动时间是开机后 45 秒,而关键路径显示它在等 network-online.target 等了 40 秒,你就知道问题出在网络配置(可能是 DHCP 超时)而不是你的 unit。
一个容易忽略的替代方案:socket 激活
如果你的服务是「按需响应」而不是「一直跑循环」,可以考虑 socket 激活。systemd 先建好监听 socket,进程在第一个连接到达时被拉起,空闲一段时间后自动退出。它天然解决了启动顺序问题——socket 一建好,前端的连接就会被内核排队,不存在「端口还没起来」的窗口。
# blog-api.socket
[Socket]
ListenStream=127.0.0.1:9000
Accept=no
[Install]
WantedBy=sockets.target对应的 service 不需要 [Install] 段,也不需要 enable——enable socket 就够了。当进程被拉起,它从 sd_listen_fds() 或者 LISTEN_FDS 环境变量拿到已经建好的 fd。
这个方案的代价是应用要支持从继承的 fd 监听(PHP 的 php-fpm 就支持,listen = /dev/fd/3 配合 ListenStream)。如果程序不支持,就不值得为此改代码,老老实实用 network-online.target + 健康检查。
小结:把「自启」当成一个可验证的状态
开机自启最常见的失败不是「一条指令写错了」,而是「从来没真正验证过」。写完 unit 跑一次 start 就以为搞定了,等到三个月后机器被服务商强制重启,才发现服务没起来,站点已经挂了几小时。
所以务实的做法是把「自启可用」纳入例行检查:每月挑一次低峰时段做一次 systemctl isolate multi-user.target 演练,观察所有关键服务是否恢复。这个动作只需要两分钟,却能提前发现几乎所有自启问题。另外在外部加一个最低成本的存活检查(另一台机器每小时 curl 一次健康页),确保即使自己没发现,也能收到告警。
最后一条经验:unit 文件应该进版本管理。放进 git,写在部署文档里,重启机器前先看一眼 /etc/systemd/system 和仓库里的配置是否一致。配置漂移是自启问题最隐蔽的来源——你在某次紧急修复时手改了 unit、忘了提交,下次重建服务器时问题重现,而你完全不记得改过。