香港服务器上的 Debian 11 如何优化 systemd 服务启动顺序,解决冷启动过慢的问题?

那天是周六凌晨 2:40。我在香港葵涌机房的冷风里打了个寒颤,远端 NOC 刚做完 UPS 旁路维护,整排服务器被执行了计划重启。业务不算峰值,但健康检查像多米诺骨牌一样从 L4 负载均衡器上掉线——我们那台承载核心 API 的香港节点,冷启动整整拖了 3 分 17 秒 才完全恢复可用。
我蹲在机柜前,手里捏着 IPMI 的小键盘,心里只有一个问题:到底是谁在拖慢启动?
接下来是我在这台机器上排查、度量、优化、回归的完整过程。文章写得很细,不管你是刚接触 systemd 的同学,还是做了多年 SRE 的同行,都能直接照着落地。
一、现场环境与问题画像
硬件与网络(现场真实参数)
| 项 | 参数 |
|---|---|
| 机房/地区 | 香港葵涌(单可用区) |
| 服务器 | 1U 自研,主板 Supermicro X12 系列 |
| CPU | Intel Xeon Silver 4310(12C/24T) |
| 内存 | 64GB DDR4-3200(4×16GB) |
| 系统盘 | 2× Intel P4510 NVMe 1TB(mdraid1) |
| 数据盘 | 2× SATA SSD 2TB(mdraid1,挂载到 /srv) |
| 网卡 | 2× 1GbE(bond0,mode=active-backup,VLAN 300) |
| 带外 | IPMI 2.0(独立管理口) |
软件栈
- OS:Debian 11 (bullseye),内核 5.10 LTS
- systemd:247(Debian 11 自带大版本)
- 网络:systemd-networkd + bonding + vlan
- 组件:PostgreSQL 13、Redis 6、Nginx 1.18、Docker 20.10(仅承载部分边缘任务)
- 自研 API:Python + gunicorn(Unix Socket),前置 Nginx 反代
启动耗时的第一手度量
# 1) 总体启动时间
systemd-analyze
# 2) Top N 耗时服务
systemd-analyze blame | head -n 20
# 3) 关键链路
systemd-analyze critical-chain
# 4) 生成 SVG 启动时序
systemd-analyze plot > /root/boot.svg
原始数据(节选)
| 指标 | 结果(优化前) |
|---|---|
systemd-analyze |
Firmware 4.375s, Loader 1.235s, Kernel 6.842s, Userspace 177.893s, Total 190.345s |
| Top 耗时 | systemd-networkd-wait-online.service 120.003s;docker.service 41.217s;mdadm-raid-assemble.service 12.941s;postfix.service 6.321s |
critical-chain 片段 |
network-online.target(→120s)→ docker.service(→41s)→ app.target(Nginx 等) |
初步结论
- network-online 在硬等 120s(默认超时),把整条链路都堵住了。
- Docker 冷启慢(overlay2 + 若干较大的镜像校验),又被串到 network-online 之后。
- 存储与挂载对 PostgreSQL 的依赖表达不清,导致数据库没必要地等待。
- 我们的应用服务在 systemd 里 没有使用 Type=notify 或 socket 激活,Nginx 必须“等人”,于是进一步被拉长。
二、优化思路(先原则后操作)
把“必须要网络上线”的服务放到 network-online.target 后;其它仅需本地回环/Unix Socket 的服务,从这条慢链上摘下来。
用 target 把业务层分组:storage.target、database.target、app.target……清晰表达 who 依赖 who。
用 Type=notify / socket 激活让“真正准备好”作为可感知事件,而不是“假装启动了”。
把挂载依赖写明白(x-systemd.requires=、RequiresMountsFor=),避免隐式等待。
缩短/防御性设置超时:systemd-networkd-wait-online、应用服务启动超时、默认超时。
Docker 延迟或按需激活,绝不阻塞关键路径。
三、实操步骤(逐条落地)
Step 0:保存基线 & 开启持久日志
mkdir -p /var/log/boot-bench && date > /var/log/boot-bench/before.txt
mkdir -p /var/log/journal && sed -i 's/^#Storage=.*/Storage=persistent/' /etc/systemd/journald.conf
systemctl restart systemd-journald
Step 1:让 network-online 只服务“必须要它的人”
我们先不取消 network-online,而是缩短等待并只让需要它的服务依赖它。
1.1 缩短 wait-online 的超时
mkdir -p /etc/systemd/system/systemd-networkd-wait-online.service.d
cat >/etc/systemd/system/systemd-networkd-wait-online.service.d/override.conf <<'EOF'
[Service]
ExecStart=
# 等待 bond0.300(VLAN 接口)上线,最多 15 秒
ExecStart=/lib/systemd/systemd-networkd-wait-online --interface=bond0.300 --timeout=15
EOF
systemctl daemon-reload
说明:多数情况下,真正需要的是“主业务口上线”,不是“所有接口都 ready”。精准指定接口 + 合理超时,能立刻省掉 1-2 分钟。
1.2 仅让“对公网/同城互联必需”的服务依赖 network-online
例:Nginx 需要对外口;Postfix 也需要;但 PostgreSQL/Redis/应用后端只用本地 Unix Socket,不需要等公网 IP。
# Nginx 明确依赖 network-online
mkdir -p /etc/systemd/system/nginx.service.d
cat >/etc/systemd/system/nginx.service.d/override.conf <<'EOF'
[Unit]
Wants=network-online.target
After=network-online.target
EOF
# PostgreSQL/Redis 去掉网络在线的隐式依赖(若有)
# 一般默认无,如有 wait-online 相关依赖,移除之
Step 2:把业务拆成清晰的 target(强迫自己说清依赖)
我们定义四个 target:存储、数据库、应用、边缘任务,并且明确它们的先后。
# storage.target
cat >/etc/systemd/system/storage.target <<'EOF'
[Unit]
Description=Storage stack is ready (mdraid/LVM/mounts)
Requires=local-fs.target
After=local-fs.target
# 可继续 Wants=mdadm-raid.service, lvm2-activation.service 等按需扩展
[Install]
WantedBy=multi-user.target
EOF
# database.target
cat >/etc/systemd/system/database.target <<'EOF'
[Unit]
Description=Databases are ready
Requires=storage.target
After=storage.target
[Install]
WantedBy=multi-user.target
EOF
# app.target
cat >/etc/systemd/system/app.target <<'EOF'
[Unit]
Description=Application stack is ready
Requires=database.target
After=database.target
[Install]
WantedBy=multi-user.target
EOF
# edge.target(非关键任务)
cat >/etc/systemd/system/edge.target <<'EOF'
[Unit]
Description=Edge/background tasks
After=app.target network-online.target
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable storage.target database.target app.target edge.target
目标是把 “谁等谁” 变得肉眼可见——这比在几十个 service 里到处写 After= 更稳、更好维护。
Step 3:把挂载与服务的关系写到位
数据库的数据在 /srv/data,我们既可以在 /etc/fstab 里用 x-systemd.* 声明,也可以在服务里用 RequiresMountsFor=。
3.1 /etc/fstab(示例)
/dev/md1 /srv ext4 noatime,defaults,x-systemd.requires=local-fs.target,x-systemd.after=local-fs.target 0 2
这样 systemd-fstab-generator 会把依赖织入图里,避免数据库抢跑。
3.2 PostgreSQL 服务添加挂载依赖(更直观)
mkdir -p /etc/systemd/system/postgresql.service.d
cat >/etc/systemd/system/postgresql.service.d/override.conf <<'EOF'
[Unit]
RequiresMountsFor=/srv/data
After=storage.target
Wants=storage.target
EOF
systemctl daemon-reload
Step 4:让应用“说实话”——Type=notify 与 socket 激活
我们的 API(gunicorn)默认 Type=simple,systemd 只知道进程起来了,不知道“准备好了没有”。把它改为 Type=notify + sd_notify,让 Nginx 在它 ready 后再起(或并行起,但不做流量切换)。
4.1 安装 sdnotify(Python)
pip install sdnotify
4.2 gunicorn 启动脚本(最小示例)
/opt/app/start.py:
from sdnotify import SystemdNotifier
import time
from app import create_app
if __name__ == "__main__":
n = SystemdNotifier()
# 这里可以做迁移/预热,例如等 Postgres/Redis 可用
# ... health check ...
n.notify("READY=1")
# 交给 gunicorn 主进程
# (也可用 gunicorn --preload 并在 post_fork 里做 READY 通知)
import os
os.execvp("gunicorn", [
"gunicorn", "app:wsgi",
"--bind", "unix:/run/app.sock",
"--workers", "4",
"--max-requests", "2000",
"--access-logfile", "-"
])
4.3 systemd 单元(应用服务)
/etc/systemd/system/app-api.service:
[Unit]
Description=My API (gunicorn)
Requires=database.target
After=database.target
PartOf=app.target
[Service]
Type=notify
NotifyAccess=all
User=www-data
Group=www-data
RuntimeDirectory=app
RuntimeDirectoryMode=0755
ExecStart=/usr/bin/python3 /opt/app/start.py
Restart=always
RestartSec=2s
TimeoutStartSec=30s
# 等待数据库就绪的小型防抖:失败快速重试,不把链路拖死
StartLimitIntervalSec=30
StartLimitBurst=10
[Install]
WantedBy=app.target
4.4 Nginx 只在应用 ready 后接管流量(两个做法)
做法 A:依赖 app.target,但不强制串行
# /etc/systemd/system/nginx.service.d/override.conf
[Unit]
Wants=app.target network-online.target
After=network-online.target
做法 B:用 socket 激活(更优雅)
把应用改为 app-api.socket + app-api.service:Socket 先行,Nginx 连上才拉起服务。示例略(思路:[Socket] ListenStream=/run/app.sock;[Install] WantedBy=sockets.target)。
Step 5:Docker 不进关键路径(能晚就晚)
如果 Docker 只跑边缘任务,就别阻塞主路径。
方案一:延后到 edge.target
# /etc/systemd/system/docker.service.d/override.conf
[Unit]
After=network-online.target app.target
Wants=network-online.target
PartOf=edge.target
[Service]
TimeoutStartSec=20s
方案二:socket 激活 dockerd
# /etc/systemd/system/docker.socket (通常已有)
[Socket]
ListenStream=/run/docker.sock
SocketMode=0660
SocketUser=root
SocketGroup=docker
# /etc/systemd/system/docker.service.d/override.conf
[Service]
ExecStart=
ExecStart=/usr/bin/dockerd --host=fd://
启用:
systemctl daemon-reload
systemctl enable --now docker.socket
systemctl disable docker.service # 让 socket 拉起
结果:只有当有进程访问 /run/docker.sock 时,dockerd 才会被激活。对我们的冷启动链路零影响。
Step 6:把邮局、无关服务移出主路径
例如 postfix.service,不影响 API 的冷启动可用性:
systemctl disable postfix
# 或者:
mkdir -p /etc/systemd/system/postfix.service.d
cat >/etc/systemd/system/postfix.service.d/override.conf <<'EOF'
[Unit]
PartOf=edge.target
After=edge.target
EOF
systemctl daemon-reload
Step 7:合理下调默认超时(谨慎)
# /etc/systemd/system.conf.d/timeout.conf
[Manager]
DefaultTimeoutStartSec=20s
DefaultTimeoutStopSec=15s
不要一刀切太激进。对“真的可能慢”的服务(比如数据库)保留特定的 TimeoutStartSec。
Step 8:存储/阵列的小坑一并填平(加速 early boot)
我们发现 mdadm-raid-assemble.service 有 12s 左右抖动。补一份明确的 md 配置可减轻组阵列扫描。
mdadm --examine --scan >> /etc/mdadm/mdadm.conf
update-initramfs -u
这能让 initramfs 在更少尝试下组建好 md 设备,减少等待。
四、优化后复测与对比
重启两次取中位数,分别记录 systemd-analyze 和关键链。
| 指标 | 优化前 | 优化后 |
|---|---|---|
| Userspace 时间 | 177.893s | 23.412s |
| 总启动时间(含固件/内核) | 190.345s | 36.028s |
networkd-wait-online |
120.003s | 4.102s(VLAN 指定 + 15s 超时,实际 4s) |
docker.service |
41.217s | 0s(socket 激活,冷启动不阻塞) |
mdadm-raid-assemble |
12.941s | 4.921s(有配置指导) |
| API 对外 200 首包 | 196s | ~28s(Nginx 在 28s 内切流) |
critical-chain(节选)变化
- 之前:network-online.target(120s) → docker.service(41s) → nginx.service → app-api.service
- 之后:local-fs.target → storage.target → database.target → app.target(并行启动)
- nginx.service 只等 network-online(~4s),与 app 并行,由 Type=notify 确保健康切换。
五、我踩过的坑与避坑建议
盲目禁用 networkd-wait-online
一刀切关掉,确实更快,但你可能在启动后前 2-3 秒收到 Nginx 报后端不可达。正确做法是精准到接口,或挂在只需要网络的服务上。
数据库忘记写挂载依赖
Postgres 抢跑偶尔不会挂,但可能以空目录启动生成数据目录,之后迁移会多出幽灵实例。用 RequiresMountsFor= 或 x-systemd.requires=,一劳永逸。
Type=simple 伪就绪
应用启动脚本把迁移/预热塞进里头,systemd 早就以为“好了”。Nginx 一接流量就 502。用 Type=notify 或至少 ExecStartPost 做关键健康检查。
Docker 放在关键路径
overlay2 冷校验 + 旧镜像 GC,你根本控制不了多久。要么 socket 激活,要么扔到 edge.target,别让它挡路。
统一下调超时导致假阳性失败
某些场景(比如文件系统自检、RAID 重组)确实会慢。建议只对特定服务调低或加 StartLimitBurst 的防抖。
六、现场可复用的检查清单(我现在每台机上都跑)
- systemd-analyze blame/critical-chain/plot 三件套留档
- wait-online 指定接口 + 合理超时(≤15s)
- 用 target 梳理层级:storage→database→app→edge
- 数据库/关键服务:RequiresMountsFor= 指向数据挂载
- 应用:Type=notify 或 socket 激活;Nginx 与应用并行
- Docker:socket 激活或挂 edge.target
- 默认超时适度下调;特殊服务单独配置
- mdadm/LVM 配置写入 initramfs
- 留存启动日志与 SVG(便于回归对比)
七、回滚与应急(不怕改变,就怕回不去)
任意一个 override 都可用 systemctl revert xxx.service 快速回滚。
target 删除:systemctl disable xxx.target && rm /etc/systemd/system/xxx.target,然后 daemon-reload。
出现网络未就绪就切流的情况:临时 systemctl start systemd-networkd-wait-online.service 并把超时拉回 120s,先止血后根因分析。
八、运维优化的思考与实践启示
第二天清晨 5:10,我们把冷启动回归的最后一次重启做完。systemd-analyze 打出 36 秒 总时长的那刻,我同事在机柜门上敲了两下:“这才像生产的样子。”
我走到走廊尽头的窗前,看见对面的山还在起雾。想起前几个月我们一遍遍嘀咕“systemd 太复杂了”,现在回头看,是我们没把依赖讲清楚。
运维不是和时间赛跑,而是为时间排好顺序。下次 NOC 说要重启的时候,我知道我们可以淡定地去喝杯咖啡了。
附:关键文件/配置汇总(可直接落地)
1)wait-online override
# /etc/systemd/system/systemd-networkd-wait-online.service.d/override.conf
[Service]
ExecStart=
ExecStart=/lib/systemd/systemd-networkd-wait-online --interface=bond0.300 --timeout=15
2)各层 target
# /etc/systemd/system/storage.target
[Unit]
Description=Storage stack is ready (mdraid/LVM/mounts)
Requires=local-fs.target
After=local-fs.target
[Install]
WantedBy=multi-user.target
# /etc/systemd/system/database.target
[Unit]
Description=Databases are ready
Requires=storage.target
After=storage.target
[Install]
WantedBy=multi-user.target
# /etc/systemd/system/app.target
[Unit]
Description=Application stack is ready
Requires=database.target
After=database.target
[Install]
WantedBy=multi-user.target
# /etc/systemd/system/edge.target
[Unit]
Description=Edge/background tasks
After=app.target network-online.target
[Install]
WantedBy=multi-user.target
3)PostgreSQL 依赖挂载
# /etc/systemd/system/postgresql.service.d/override.conf
[Unit]
RequiresMountsFor=/srv/data
After=storage.target
Wants=storage.target
4)应用服务(notify)
# /etc/systemd/system/app-api.service
[Unit]
Description=My API (gunicorn)
Requires=database.target
After=database.target
PartOf=app.target
[Service]
Type=notify
NotifyAccess=all
User=www-data
Group=www-data
RuntimeDirectory=app
RuntimeDirectoryMode=0755
ExecStart=/usr/bin/python3 /opt/app/start.py
Restart=always
RestartSec=2s
TimeoutStartSec=30s
StartLimitIntervalSec=30
StartLimitBurst=10
[Install]
WantedBy=app.target
5)Nginx 与网络/应用关系
# /etc/systemd/system/nginx.service.d/override.conf
[Unit]
Wants=app.target network-online.target
After=network-online.target
6)Docker 延迟到边缘目标或 socket 激活
# /etc/systemd/system/docker.service.d/override.conf
[Unit]
After=network-online.target app.target
Wants=network-online.target
PartOf=edge.target
[Service]
TimeoutStartSec=20s
7)默认超时(谨慎)
# /etc/systemd/system.conf.d/timeout.conf
[Manager]
DefaultTimeoutStartSec=20s
DefaultTimeoutStopSec=15s
如果你也在香港节点(或任何边缘机房)受冷启动折磨,不妨按上面的顺序做一遍。先度量,再拆链路,最后让服务“说实话”。