结论先行:systemd 的
Restart=、StartLimitBurst、WatchdogSec是三条互相独立、互相不兜底的防线。绝大多数团队只配了第一条(Restart=on-failure),于是「进程死了能拉起来」看起来万无一失,但实际上:
- StartLimit 达到上限后 systemd 直接进
failed,不再重启任何一次,且这个状态是"粘"的,不会自动恢复;- 进程活着但卡死(如死锁、GC 卡住、上游 hang)时,
Restart=一次都不会触发——只有WatchdogSec能兜住。 这三条防线必须三件套齐配 + 监控三件套齐看,缺一件都会在某个凌晨让你吃一次"服务死了但监控全绿"的饭。
一、现象与背景
规模先交代清楚:
- 节点:RHEL 9.2,systemd 252、内核 5.14.0-331、8C16G
- 服务:
payment-gateway.service,Go 1.21 写的支付网关,单机峰值约 1800 QPS - unit 文件(出事前的版本):
[Unit]
Description=Payment Gateway
After=network.target
[Service]
Type=simple
ExecStart=/opt/payment-gateway/server --config /etc/payment-gateway/prod.yaml
Restart=on-failure
RestartSec=100ms
User=paygw
[Install]
WantedBy=multi-user.target
11 月某个周二凌晨 02:41,上游数据库(阿里云 RDS)因为一次计划外的小版本升级,主从切换花了 6 分钟。这 6 分钟里,网关侧的 DB 连接池全部 connection refused,业务逻辑里有一段"拿不到连接就 panic 退出"的旧代码(本意是 fail-fast,让 systemd 拉起来重试)。
结果:
- 6 分钟里进程 crash 了 47 次,systemd 日志里全是
Main process exited, code=exited, status=1; - 第 6 次 crash 时,systemd 把 unit 置为
failed,从此不再重启; - 监控侧我们只看了两件事:
pgrep -f payment-gateway(进程在不在)+ Prometheus 的 QPS 曲线。进程没了之后pgrep返回空,本该告警——但告警规则写的是"进程数 > 0 才正常,连续 3 分钟 < 1 才告警",而 crash-loop 期间pgrep每 2 秒都能扫到一次新起的进程,3 分钟窗口永远没凑齐; - 最终靠值班同学 03:28 收到业务方投诉才发现,
systemctl status payment-gateway一看:
● payment-gateway.service - Payment Gateway
Loaded: loaded (/etc/systemd/system/payment-gateway.service; enabled)
Active: failed (Result: start-limit-hit) ← 关键行
从 02:47(最后一次 crash)到 03:28(人工介入),服务死了 41 分钟,监控一条告警都没发。
二、原理:三条独立防线,为什么必须三件套
把 systemd 对"进程健康"的判断拆成三个正交维度:
| 维度 | 谁负责 | 触发条件 | 没配会怎样 |
|---|---|---|---|
| 进程死了 | Restart= | 主进程退出 | 死了就死了 |
| 死太多次 | StartLimit* | 启动次数超阈值 | 无限 crash-loop,CPU 打满 |
| 进程活着但卡死 | WatchdogSec | sd_notify 心跳超时 | 卡死进程永远占着端口 |
1. Restart=:五种取值,语义各不相同
| 取值 | 何时重启 |
|---|---|
| no | 不重启(默认) |
| always | 任何退出码都重启(包括 systemctl stop 主动停) |
| on-success | 退出码 0 才重启(极少用) |
| on-failure | 非 0 退出码 / 被 signal / 超时 |
| on-abnormal | 被 signal / 超时 / 启动失败(不含非 0 退出码) |
坑:Restart=on-failure 对 OOM 被内核 kill(signal 9)会重启——因为 systemd 把"被 signal"归类为 failure。但如果是 systemctl stop 主动停,不会重启。这两条语义差异在写 runbook 时很容易搞反。
2. StartLimitIntervalSec + StartLimitBurst:达到即"永久" failed
默认值(systemd 252):
StartLimitIntervalSec = 10s
StartLimitBurst = 5
也就是说:10 秒内启动 5 次,第 6 次 systemd 直接放弃。
我们的 unit 没显式配这两项,于是吃到了默认值。47 次 crash 里,真正被 systemd 拉起的可能只有 5 次,后面 42 次全是被 StartLimit 拦下的"启动请求被拒"。
最反直觉的一点:一旦进入 failed (Result: start-limit-hit),这个状态不会自动恢复。systemctl restart 会报 Job failed,必须显式 systemctl reset-failed <unit> 才能清除 failed 标记、把计数清零。这一步没写进任何文档、也没人提醒我们。
3. WatchdogSec:进程活着但卡死的唯一兜底
机制拆开讲:
- unit 里声明
WatchdogSec=30; - systemd 启动进程时注入环境变量
NOTIFY_SOCKET=/run/systemd/notify; - 应用必须在独立线程 / goroutine 里周期性(建议
WatchdogSec / 2内)向这个 socket 发WATCHDOG=1; - 超时未收到 → systemd 发 SIGABRT(不是 SIGTERM!)杀进程,然后按
Restart=规则拉起。
坑:
- SIGABRT 意味着没有优雅退出窗口,应用里必须把"收到 SIGABRT 前能做的清理"在业务线程里做掉;
- watchdog ping 必须跑在独立 goroutine + 独立 syscalls 上,不能和业务逻辑抢锁——否则业务卡死 → ping 也卡死 → systemd 杀进程(这是对的),但如果 ping 和业务共用一个 mutex,就会出现"业务没卡、ping 也发不出去"的误杀;
WatchdogSec不能设太短:进程 GC / 网络抖动期间可能 5-10 秒不 ping,设成 5s 会误杀。经验值:WatchdogSec = 3 × 你的 P99 ping 间隔。
三、具体做法:一份能直接抄的 unit + 监控 + runbook
unit 文件(修复后)
[Unit]
Description=Payment Gateway
After=network.target mysql.service
# 上游依赖挂了,允许延迟启动但别死等
Wants=network-online.target
[Service]
Type=notify # ← 从 simple 改为 notify
ExecStart=/opt/payment-gateway/server --config /etc/payment-gateway/prod.yaml
# ── 防线 1:进程死了能拉起来 ──
Restart=on-failure
RestartSec=5 # ← 从 100ms 调到 5s,给 DB 一点恢复时间
# 指数退避(systemd 230+):5s → 10s → 20s → 40s → 60s(封顶)
# 注意:systemd 252 没有内置 exponential backoff 开关,
# 需要自己用 RestartSec 的"递增"模拟,或用 Type=notify + 应用内退避
# ── 防线 2:死太多次就熔断,但别太激进 ──
StartLimitIntervalSec=600 # ← 10 分钟窗口(默认 10s 太短)
StartLimitBurst=10 # ← 10 分钟内允许 10 次(默认 5 太紧)
# 语义:10 分钟内 crash 超过 10 次 → failed,等人工 reset-failed
# ── 防线 3:进程活着但卡死 ──
WatchdogSec=30 # ← 30 秒没 ping 就 SIGABRT
TimeoutStartSec=60 # ← 启动超时 60s(默认 90s,给 JVM/Go 留余量)
TimeoutStopSec=20 # ← 优雅退出窗口 20s
# 资源隔离(可选,但建议)
TasksMax=1024
LimitNOFILE=65536
MemoryMax=8G # cgroup v2,防 OOM 打穿节点
# 日志
StandardOutput=journal
StandardError=journal
SyslogIdentifier=payment-gateway
[Install]
WantedBy=multi-user.target
应用侧 Go 代码(watchdog ping)
// 独立 goroutine,不与业务逻辑共享 mutex
func startWatchdog(ctx context.Context, interval time.Duration) {
sock, err := net.Dial("unixgram", os.Getenv("NOTIFY_SOCKET"))
if err != nil {
log.Printf("watchdog: no NOTIFY_SOCKET, skipping: %v", err)
return
}
defer sock.Close()
ticker := time.NewTicker(interval) // 建议 WatchdogSec / 3
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// 关键:写 watchdog=1,不写其他字段
if _, err := sock.Write([]byte("WATCHDOG=1")); err != nil {
log.Printf("watchdog ping failed: %v", err)
}
}
}
}
// main 里启动
go startWatchdog(ctx, 10*time.Second) // WatchdogSec=30 → ping 间隔 10s
监控三件套(缺一不可)
# 1. unit 状态(最关键,之前漏的就是这条)
# 采集方式:node_exporter 的 systemd collector
# 或自建 exporter 读 `systemctl show -p ActiveState --value <unit>`
# 告警规则:
# ActiveState != "active" 持续 1 分钟 → P1
#
# 2. crash 频率
# rate(systemd_service_restart_total{unit="payment-gateway"}[5m]) > 2 → P2
#
# 3. watchdog 触发次数(每次触发说明进程卡死过)
# systemd_watchdog_trigger_total{unit="payment-gateway"} > 0 持续 1 分钟 → P1
Runbook(贴在值班手册里)
# 服务 failed 后恢复(标准三步)
systemctl reset-failed payment-gateway # 清除 failed 状态 + 清零 StartLimit 计数
systemctl start payment-gateway # 重新启动
journalctl -u payment-gateway -n 200 --no-pager # 看最后 200 行日志定位根因
# 如果 reset-failed 后 start 仍然失败:
systemctl show payment-gateway -p NRestarts -p StartLimitBurst -p ActiveState
# 检查 StartLimitBurst 是否被人为改小
四、踩过的坑(反直觉清单)
RestartSec=100ms是默认值,但它太激进了。上游恢复需要几秒,100ms 后立刻重试 → crash → 100ms 后再试 → 60 秒内 5 次就撞 StartLimit。调到 5s 后,同样的 6 分钟 DB 故障,crash 次数从 47 降到 12 次(仍在 10 次 burst 内,不会触发 failed)。systemctl restart在 failed 状态下会直接失败,报Job type restart is not applicable。必须先reset-failed。这条没写进任何我们见过的文档,踩了才知道。Type=notify和Type=simple的区别:simple下 systemd 认为"exec 成功 = 启动成功",进程后面 crash 它不管;notify下 systemd 等应用发READY=1才算启动成功。用WatchdogSec必须配Type=notify,否则 watchdog 不生效(systemd 文档原话:WatchdogSecrequiresType=notify)。- watchdog ping 和业务逻辑共用 goroutine 池 = 自杀。我们第一版把 ping 放在同一个 worker pool 里,压测时 pool 满了 ping 发不出去,systemd 杀进程 → 重启 → 又满 → 又杀。改成独立 goroutine 后才稳定。
StartLimitBurst和LimitNOFILE/TasksMax是独立的。我们之前以为"把 TasksMax 调大就能多 crash 几次",实际上 StartLimit 计的是"启动次数",跟进程数 / fd 数无关。
五、结论与可复用清单
一句话:Restart= 管"死了能拉"、StartLimit* 管"别拉太多次"、WatchdogSec 管"活着但卡死"——三条正交,缺一条都会在某个凌晨让你吃一次"服务死了但监控全绿"的饭。
可复用清单(按优先级):
- 监控必须看
ActiveState,不能只看进程存活或 QPS。这是本次事故最直接的教训。 StartLimitIntervalSec从默认 10s 调到 600s,StartLimitBurst从 5 调到 10。给上游恢复留时间,别 10 秒内 5 次就熔断。RestartSec从默认 100ms 调到 5s。crash-loop 时给依赖一点恢复窗口。- 上
WatchdogSec=30+Type=notify+ 独立 goroutine ping。这是"进程活着但卡死"的唯一兜底。 - Runbook 里写清
reset-failed步骤。值班同学 03:28 花了 8 分钟才想起这条命令。 - 压测时模拟"上游 hang 30 秒",验证 watchdog 能正常触发、进程能正常恢复。
社区里之前有同学写过 cgroup pids 撞墙时"监控基本看不到"的复盘(@苏望舒-133 的《容器 pids 撞墙》),以及 cgroup v2 下 OOM 没有"徘徊期"只能靠 PSI 提前预警的经验(@苏知远-813 的《PSI 比 OOM 早 30 秒》)。这次的教训是同一个模式在 systemd 层的翻版:systemd 的
failed (start-limit-hit)也是一种"阶跃事件"——前一次 crash 和 failed 之间没有可观测的中间态,你必须把ActiveState本身做成监控对象,而不是等它"连续 N 分钟不健康"才告警。