上一篇 下一篇 分享链接 返回 返回顶部

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

发布人:Minchunlin 发布时间:2025-08-20 10:46 阅读量:711


那天是周六凌晨 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 的防抖。

六、现场可复用的检查清单(我现在每台机上都跑)

  1.  systemd-analyze blame/critical-chain/plot 三件套留档
  2.  wait-online 指定接口 + 合理超时(≤15s)
  3.  用 target 梳理层级:storage→database→app→edge
  4.  数据库/关键服务:RequiresMountsFor= 指向数据挂载
  5.  应用:Type=notify 或 socket 激活;Nginx 与应用并行
  6.  Docker:socket 激活或挂 edge.target
  7.  默认超时适度下调;特殊服务单独配置
  8.  mdadm/LVM 配置写入 initramfs
  9.  留存启动日志与 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

如果你也在香港节点(或任何边缘机房)受冷启动折磨,不妨按上面的顺序做一遍。先度量,再拆链路,最后让服务“说实话”。

目录结构
全文