用 RestartSec 与 StartLimitBurst 抑制崩溃服务反复拉起

线上常遇到一类故障:某个服务因为配置写错、依赖模块缺失或端口被占用而一启动就退出,而它的 unit 文件里写着 Restart=always。systemd 会立刻重新拉起它,进程又秒退,于是形成每秒数次、甚至每秒十几次的重启风暴。日志被刷满、监控被打爆、CPU 被 fork 和 exec 吃掉,真正的根因被淹没在 Started ... 与 Stopped

线上常遇到一类故障:某个服务因为配置写错、依赖模块缺失或端口被占用而一启动就退出,而它的 unit 文件里写着 Restart=always。systemd 会立刻重新拉起它,进程又秒退,于是形成每秒数次、甚至每秒十几次的重启风暴。日志被刷满、监控被打爆、CPU 被 fork 和 exec 吃掉,真正的根因被淹没在 Started ... 与 Stopped ... 的循环里。

这篇内容只解决一件事:如何用 RestartSec 和 StartLimitBurst / StartLimitIntervalSec 这一组参数,把「无限快速重试」改成「有节奏地重试,失败到阈值就彻底停下」,并在停机之后留下一条可被监控捕获的状态。文中给出的配置都可以直接粘进 unit 文件验证。

先看清重启风暴的形成机制

一个典型的错误 unit 长这样,很多人从网上抄来后就不再改动:

[Unit]
Description=Demo API
After=network.target

[Service]
ExecStart=/usr/local/bin/demo-api --config /etc/demo/config.yaml
Restart=always

[Install]
WantedBy=multi-user.target

Restart=always 的语义是:只要服务停下来,除了 systemctl stop 这类显式停止之外,一律重启。而 RestartSec 未设置时默认值是 100ms。也就是说进程退出后约 0.1 秒就再次拉起。若 ExecStart 里的程序因为 config.yaml 第 12 行 listen: 0.0.0.0:99999 端口越界而 exit 1,你得到的是稳定复现的每秒 5~8 次重启。

可以这样直观地观察:

systemctl status demo-api
# ● demo-api.service - Demo API
#      Loaded: loaded (/etc/systemd/system/demo-api.service; enabled; vendor preset: disabled)
#      Active: activating (auto-restart) (Result: exit-code) since Tue 2024-05-14 10:21:07 CST; 231ms ago
#     Process: 28471 ExecStart=/usr/local/bin/demo-api --config /etc/demo/config.yaml (code=exited, status=1/FAILURE)
#    Main PID: 28471 (code=exited, status=1/FAILURE)
# 统计一分钟内的启动次数,数字能说明一切
journalctl -u demo-api --since "1 minute ago" --no-pager | grep -c "Starting Demo API"

关键点在于 activating (auto-restart) 这个状态:它在 systemd 里属于「正在激活」,不是 failed,所以默认不会触发 OnFailure=,也不进入 failed 状态,监控脚本若只看 ActiveState=failed 就完全看不到问题。

RestartSec:给重试加上节奏

RestartSec= 控制两次重启之间的间隔,接受 systemd 的时间单位后缀,例如 ms、s、min、h,也接受 1min 30s 这样的组合写法。它同时影响 Restart= 与 Restart=on-failure 触发的重启。

[Service]
Restart=on-failure
RestartSec=5s

参数选择有实际依据,不是随便写个数字:

  • 起步 1~5 秒:适合数据库连接池、下游 HTTP 依赖这类「对端可能正在重启」的场景。能挡住瞬时抖动,又不至于让恢复慢到影响可用性。
  • 10~30 秒:适合启动成本高的服务(JVM 预热、加载大词典、重建缓存),也适合崩溃会大量 dump 日志或产生 core 文件的进程,避免磁盘被瞬间写满。
  • 不要小于等于 100ms:那等于没配。也别用 RestartSec=0,在 Type=simple 下等于回到默认行为。

注意 RestartSec 不是退避算法,它是固定间隔。systemd 没有内置的指数退避,想要「1s、2s、4s、8s」这类递增节奏,只能靠外部脚本或 ExecStartPre 读状态文件实现,多数场景没必要。固定间隔配合下文的数量限流已经够用。

另一个容易忽略的点:对于 Type=notify 或 Type=forking 的服务,如果进程在启动阶段卡住而非退出,RestartSec 不会生效,需要 TimeoutStartSec= 或 TimeoutStopSec= 配合。二者解决的是不同问题,别混淆。

StartLimitBurst 与 StartLimitIntervalSec:给重试设置上限

光有间隔还不够。如果服务是确定性崩溃,每 5 秒重启一次,一天下来 17000 多次,日志依然可观,而且问题永远不会「失败」,只会一直徘徊。这时候要用启动频率限流。

这里要强调一个版本差异,是很多人配置后不生效的原因:

systemd 版本 参数所在段落 参数名
≥ 229(CentOS 7 后期、Ubuntu 18.04+、Debian 10+) [Unit] StartLimitIntervalSec= / StartLimitBurst=
< 229 [Service] StartLimitInterval= / StartLimitBurst=

写在错误的段落里,systemd 会直接报 Unknown key name 'StartLimitIntervalSec' in section 'Service', ignoring.,然后用默认值继续跑,看起来「配了没用」。默认值是 StartLimitIntervalSec=10s、StartLimitBurst=5,含义是 10 秒内允许启动 5 次,超过即拒绝。

[Unit]
Description=Demo API
After=network-online.target
Wants=network-online.target

# 600 秒内最多允许启动 6 次
StartLimitIntervalSec=600
StartLimitBurst=6

[Service]
Type=simple
ExecStart=/usr/local/bin/demo-api --config /etc/demo/config.yaml
Restart=on-failure
RestartSec=10s
TimeoutStopSec=20

[Install]
WantedBy=multi-user.target

这套组合的实际效果:崩溃后等 10 秒重试,最多连试 6 次(也就是 6 次启动机会,约 1 分钟窗口的总尝试量),第 7 次被拒绝,服务进入 failed 状态。此后再 systemctl start 也会被立刻拦下,需先 systemctl reset-failed demo-api 清零计数器。

被限流时的日志长这样,记住这几行,排障时一眼能认出:

demo-api.service: Start request repeated too quickly.
demo-api.service: Failed with result 'start-limit-hit'.
Failed to start Demo API.

状态里则是:

systemctl show demo-api -p ActiveState -p Result -p NRestarts
# ActiveState=failed
# Result=start-limit-hit
# NRestarts=5

NRestarts 这个属性是 systemd 230 之后引入的,直接反映自上次 reset-failed 或成功启动以来重启了多少次,非常适合丢给 node_exporter 之类的采集器做趋势报警。

按场景选参数:三套可直接用的组合

场景一:无状态 API,期望快速恢复

[Unit]
StartLimitIntervalSec=120
StartLimitBurst=5

[Service]
Restart=on-failure
RestartSec=2s

两分钟内允许 5 次启动,间隔 2 秒。适合单实例部署、能在数秒内起来的 Go / Node 服务。5 次都起不来,说明不是抖动而是真实故障,交给人处理是对的。

场景二:依赖外部组件(数据库、消息队列)

[Unit]
StartLimitIntervalSec=1800
StartLimitBurst=30

[Service]
Restart=on-failure
RestartSec=20s
TimeoutStartSec=90

半小时内允许 30 次,平均每分钟一次,能容忍外部依赖较长时间的不可用而不会错判为 definitively failed。配合 After=postgresql.service 与 Requires= 使用更稳,但注意,被 Requires= 连带停止的情况不会消耗 StartLimitBurst 的次数。

场景三:调试期或明确「起不来就别起来」的服务

[Unit]
StartLimitIntervalSec=0

[Service]
Restart=no

StartLimitIntervalSec=0 表示关闭限流(任何次数都允许)。反过来,如果你希望只启动一次,直接 Restart=no 最干净,不必依赖限流去「间接」实现。

验证与恢复:别配完就走

改完 unit 必须 daemon-reload,否则旧配置继续生效:

sudo systemctl daemon-reload
systemctl show demo-api -p StartLimitIntervalUSec -p StartLimitBurst
# StartLimitIntervalUSec=10min
# StartLimitBurst=6

用属性的微秒形式能确认参数确实被解析,比肉眼读配置文件可靠。接下来造一个确定性崩溃的测试单元:

# /etc/systemd/system/crash-test.service
[Unit]
Description=Crash test
StartLimitIntervalSec=60
StartLimitBurst=4

[Service]
Type=simple
ExecStart=/bin/sh -c 'echo "attempt at $(date -Is)"; exit 1'
Restart=always
RestartSec=3s

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl start crash-test
sleep 40
journalctl -u crash-test --no-pager | tail -n 20

预期是 4 次尝试后停下,日志结尾出现 start-limit-hit。若你看到日志以 status=1/FAILURE 反复出现却没有停下,先确认 StartLimitIntervalSec 是写在 [Unit] 而不是 [Service],再用 systemctl --version 核对版本。

恢复流程要写进 runbook,因为限流状态会「粘住」:

sudo systemctl reset-failed crash-test
sudo systemctl start crash-test

不执行 reset-failed 直接 start 会立即收到 Failed to start crash-test.service: Unit crash-test.service has a bad unit file setting. 之外的另一种报错——Job for crash-test.service failed because start of the service was attempted too often.,很多人这时会误改配置,其实只是计数器没清。

监控侧建议采集 ActiveState、Result、NRestarts 三个字段。当 Result=start-limit-hit 时直接告警,能把「一直重启」从沉默的 activating 状态里揪出来。同理,Restart=always 加良好限流的组合,比单纯 Restart=on-failure 更容易暴露异常退出与信号杀死的区别。

小结

抑制崩溃服务拉起风暴,靠的是两条独立机制的组合:RestartSec 控制重试的时间间隔,把「秒退秒起」变成有节奏的尝试;StartLimitIntervalSec 与 StartLimitBurst 控制重试的总量,让确定性故障在一定次数后进入 failed 而不是无限循环。配置时注意 StartLimit* 随 systemd 版本在 [Unit] 与 [Service] 之间迁移,改完必须 daemon-reload,恢复时必须 reset-failed。把 Result=start-limit-hit 接进监控,就等于给重启风暴装了报警器。

FAQ

配了 StartLimitBurst 但服务还是无限重启,为什么?

按顺序排查三点:一是 StartLimitIntervalSec 是否写在 [Unit](systemd ≥ 229);二是执行过 systemctl daemon-reload 没有;三是用 systemctl show 服务名 -p StartLimitBurst -p StartLimitIntervalUSec 确认值是否真的被读入。还有一个隐蔽原因:服务配置了 Restart=always 且被别的 unit PartOf= 或定时器反复拉起,那样计数会被重置。

RestartSec 和 StartLimitIntervalSec 有什么区别,是不是重复了?

不重复,解决的是两个维度。RestartSec 是单次重启前的等待时间;StartLimitIntervalSec 是统计窗口长度,配合 StartLimitBurst 决定「窗口内允许几次启动」。极端例子:RestartSec=60s 而 StartLimitIntervalSec=10s、StartLimitBurst=5,则限流几乎永远不会触发,因为两次启动相隔远超窗口。

start-limit-hit 之后,机器重启会自动开始吗?

不会。限流状态属于运行时状态,但 enabled 的 unit 在系统启动时会被重新尝试启动,此时计数器是干净的。注意如果崩溃原因仍在,它会在开机后继续触发新一轮限流。

能不能只靠 RestartSec 设大一点,不用限流参数?

可以用 RestartSec=60s 把频率压到每分钟一次,但这样等于对确定性故障永远保持「每分钟试一次」,状态始终在 activating 与 failed 之间摆动,监控难以判断服务是否真的挂死。限流的价值在于给出明确终态。

为什么日志里提示 Unknown key name ‘StartLimitIntervalSec’ in section ‘Service’?

因为该版本 systemd 只认 [Service] 里的旧名 StartLimitInterval=。把参数移到 [Unit] 并改用带 Sec 后缀的名字即可;若要同时兼容老版本,可写两处,systemd 会忽略不认识的键并在日志中给出提示,不影响运行。

Restart=on-failure 时,进程被 OOM killer 杀死会重启吗?

会。被信号杀死(包括 SIGKILL)在 systemd 里视作 failure,Restart=on-failure 会触发重启并消耗限流计数。如果这类重启频率高,优先修内存上限(MemoryMax=)而不是放大 StartLimitBurst。