这台 RK3588 盒子挂着一块 1.8T USB 外接机械盘,网盘、媒体库、录播和下载器都把数据放在上面。麻烦的不是「盘坏了怎么办」,而是盘不在时整机会怎么样:开机时 USB 若还没枚举出来,fstab 的挂载等待、服务的 RequiresMountsFor 依赖和 Docker 的自动重启策略会串成一条链,把引导堵在磁盘等待上;就算开机成功,容器也会绑定到 eMMC 上的空目录,之后盘挂上了,容器看到的还是旧视图。
2026-10-09 部署的 usb-storage-guard 就是为这两个问题写的,目标只有一句:缺盘绝不能阻塞开机,盘回来了再自动把业务拉起来。
环境与前提
接管范围:宿主机服务是 Cloudreve 与 Jellyfin;Docker 侧自动发现 Alist、Navidrome、bililive-go、cloudreve-aria2 的外盘 bind,系统盘容器不接管。
处理命令的耗时会让实际响应延长,这不是硬实时保护。
设计目标与实施步骤
一、拆掉开机等待链
「缺盘不阻塞开机」不能靠加长等待,只能靠移除依赖,共四件事:
挂载仍用 nofail:保留 fstab 的
nofail,x-systemd.automount,x-systemd.device-timeout=15s;automount 由 autofs 提供,挂载动作不需要硬盘在场。禁用服务的直接开机入口:
systemctl disable cloudreve.service,只禁用开机入口,不停止当前运行、不删单元;RequiresMountsFor、BindsTo与 UUID/ext4/权限检查保留,改由守护确认就绪后后台启动。容器 restart 改
no:外盘容器由守护启动并做失败限速重试;Alist/Navidrome 的源 Compose 同步为restart: "no",未重建容器。守护自身不带外盘依赖:
Type=simple,只拉起系统盘上的 Python 进程,不以 USB 设备、mount 或 Docker 作为启动依赖。
设备 UUID 链接不存在时不提交挂载作业;只有设备已存在但未挂载,才后台重试,不阻塞引导目标。
systemctl start --no-block <mount>不能走的三条路:把整个 docker.service 绑到 USB 盘、无限 sleep、靠循环 restart 或加大设备等待超时硬扛。
守护自己的 systemd 单元就是下面这份,关键在于它只有 After=local-fs.target,并且把常驻进程钉在 CPU0–3:
[Unit]
Description=USB storage readiness and automatic container recovery
After=local-fs.target
StartLimitIntervalSec=0
[Service]
Type=simple
ExecStart=/opt/usb-storage-guard/venv/bin/python /opt/usb-storage-guard/guard.py run
Restart=on-failure
RestartSec=10s
TimeoutStartSec=5s
TimeoutStopSec=45s
CPUAffinity=0 1 2 3
Environment=PYTHONDONTWRITEBYTECODE=1
NoNewPrivileges=true
UMask=0077
StateDirectory=usb-storage-guard
StateDirectoryMode=0700
LimitNOFILE=65535
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target二、按 UUID 识别,不信设备名
每轮读内核 mountinfo、udev 的 UUID 设备链接和 sysfs 存在性,确认真实块设备号、文件系统、可写挂载与文件系统根是否完整。只看目录存在会误判,/dev/sda 这种设备名不可信,autofs 也不算盘就绪。
失去设备、真实挂载消失、只读挂载、UUID 不符或维护暂停时,相关服务与容器停止,盘就绪且业务路径存在后才恢复;多盘容器必须全部依赖就绪。守护不创建缺失业务目录、不 fsck、不强制卸载。
稳态下不扫描盘上目录、不读文件内容,业务路径的 stat 只在启动或恢复时进行,检查只读内核表和 Docker 元数据。
三、容器内 mountinfo 复核
Docker 默认的 rprivate bind 视图是最值得说的坑:外盘未挂载时容器把 eMMC 空目录绑了进去,随后宿主机挂上盘,容器 mount namespace 里看到的仍是旧目录,写入落在系统盘的「冒名目录」里。
守护读每个容器 PID 的 mountinfo,核对绑定目标、设备号、文件系统和文件系统内根目录;不对就先 stop,再核对业务路径确属目标盘,最后 start 重建绑定。宿主机重挂载的「代次」记在状态里,守护重启后仍能识别设备号复用。
四、Docker bind 自动发现
不用维护容器名白名单:每轮检查全部容器的实际 Mounts,bind Source 落在登记盘挂载点内即自动纳入,按完整容器 ID 保存原 restart 策略并调为 no。新增自动发现,缺盘停止、就绪启动;修改或重建后清理旧 ID 记录、按新 ID 重新判断;删除时清理状态与 ID 级暂停标记,不删业务目录、数据或镜像;移除外盘绑定则停止接管,原 ID 仍存在且 restart 仍是 no 时恢复原策略,但不覆盖已改成其他策略的容器。
Compose one-off 不纳入;特殊容器用标签 usb-storage-guard.mode: ignore 排除。pause <容器名> 是持久维护意图、跨同名重建保留,ID 级暂停随 ID 清理删除。
五、自动发现的边界
它不是 Docker API 启动拦截器:新建容器并立即 start 时,守护可能下一轮才发现,挡不住第一次「抢跑」。要彻底避免,用 docker compose create 创建后交给守护启动,并在 Compose 里写 restart: "no"。首次接管后策略已持久化,下次开机不会 Docker 先行自启;若把 restart 改回 always,守护会再次纠正,但发现前存在窗口。示例中 create_host_path: false 是关键:禁止自动创建 host 目录,避免缺盘时在系统盘造出冒名目录。
services:
app:
image: your-arm64-image
restart: "no"
cpuset: "0-3"
volumes:
- type: bind
source: /media/ztl/LinuxDate/app-data
target: /data
bind:
create_host_path: false六、手动停止的持久化意图
2026-10-13 的补丁解决一个尴尬问题:用户手动停了容器或服务,守护下一轮又把它拉起来。
Docker 侧只读 DockerRootDir 下
config.v2.json的HasBeenManuallyStopped,按 ID 持久记录,不看退出码;手停后即使立刻重启守护、还没轮到轮询也不会启动,手动 start 才清除标志。守护自己的停止也区分归属:因缺盘、暂停或旧视图而 stop 时先落盘 guard_stop 归属,避免与用户停止混淆,守护重启后仍能恢复。
受管 systemd 服务装
40-usb-storage-guard-intent.conf,ExecStartPre 清意图、ExecStopPost 同步持久化;关机、挂载丢失、异常退出及守护自有 stop 不记为手停。新 ID 是新对象,手停不跨删除重建继承,跨同名重建用
pause;整盘resume不清除单项手停,单服务resume才是明确恢复意图。
自然正常退出无法与用户 stop 可靠区分,此时保守保持停止;非受管服务的
systemctl stop不等于 disable,跨开机关闭要用菜单或disable --now。
七、日常管理与移植
usb-storage-guard status
usb-storage-guard check
usb-storage-guard pause alist # 维护单个容器,跨重启保留
usb-storage-guard resume alist
usb-storage-guard pause linuxdate # 整块盘的依赖暂停
usb-storage-guard resume linuxdatestatus/check 只读可随时运行,不要在守护运行时执行 once。配置 /etc/usb-storage-guard.json 的 disks 可登记多块盘;新增盘先做 UUID 固定 nofail 挂载并确认权限,不自动格式化也不递归 chmod。
配置文件的内容如下,required_paths 是业务真正依赖的目录(用来确认路径落在目标盘上),services 是要协同的宿主机服务:
{
"poll_seconds": 10,
"retry_seconds": 30,
"state_dir": "/var/lib/usb-storage-guard",
"disks": [
{
"name": "linuxdate",
"uuid": "8b9cbec5-c4d7-466f-acea-d1c9531ccada",
"fstype": "ext4",
"mountpoint": "/media/ztl/LinuxDate",
"mount_unit": "media-ztl-LinuxDate.mount",
"required_paths": [
"/media/ztl/LinuxDate/cloudreve/uploads",
"/media/ztl/LinuxDate/files/cloudreve"
],
"services": ["cloudreve.service"]
}
]
}安装入口是一个默认预演的 shell 脚本,加 --apply 才落盘;它会先给已有目标文件做同目录日期备份,再建虚拟环境、装脚本与单元、同步停服钩子,最后禁掉 Cloudreve 的直接开机入口并重启守护:
#!/usr/bin/env bash
# 默认预演安装;只变更明确列出的文件,不重启Docker/整机。
set -euo pipefail
base="$(cd -- "$(dirname -- "$0")" && pwd)"
if [[ ${1:-} != --apply ]]; then
echo '预演:安装Python守护及虚拟环境、JSON配置、systemd单元与管理入口。'
echo '预演:Cloudreve禁用直接开机入口,保留UUID/权限/mount绑定。'
echo '预演:自动发现外盘容器并保存原策略;配置Compose应同步restart: "no"。'
echo '不修改fstab、不修改容器Compose、不重启Docker、不读写业务数据。'
exit 0
fi
[[ $# == 1 && $EUID == 0 ]] || { echo '请以root运行且仅提供--apply'; exit 1; }
# 防止重装时无本体却启动恢复逻辑。
[[ -f /etc/systemd/system/cloudreve.service ]] || { echo '先恢复Cloudreve本体或修改配置移除该服务'; exit 1; }
stamp="$(date +%Y%m%d-%H%M%S)"
backup_file() {
if [[ -e $1 ]]; then cp -a -- "$1" "$1.bak.$stamp"; fi
}
mkdir -p /opt/usb-storage-guard /etc/systemd/system
for target in /opt/usb-storage-guard/guard.py /etc/usb-storage-guard.json /etc/systemd/system/usb-storage-guard.service /usr/local/sbin/usb-storage-guard; do
backup_file "$target"
done
python3 -m venv --without-pip /opt/usb-storage-guard/venv
install -m 0644 "$base/guard.py" /opt/usb-storage-guard/guard.py
# 既有配置不自动覆盖,重装或更新后人工合并。
if [[ ! -e /etc/usb-storage-guard.json ]]; then install -m 0600 "$base/config.json" /etc/usb-storage-guard.json; fi
install -m 0644 "$base/usb-storage-guard.service" /etc/systemd/system/usb-storage-guard.service
install -m 0755 "$base/usb-storage-guard" /usr/local/sbin/usb-storage-guard
/opt/usb-storage-guard/venv/bin/python -c 'import sys; sys.path.insert(0,"/opt/usb-storage-guard"); import guard; guard.load_config("/etc/usb-storage-guard.json")'
# 同步安装停服钩子并迁移现有停止状态,不触碰业务运行状态。
/opt/usb-storage-guard/venv/bin/python "$base/install_service_hooks.py" --apply
systemctl disable cloudreve.service
systemctl daemon-reload
systemctl enable usb-storage-guard.service
systemctl restart usb-storage-guard.service
echo '安装完成,请查看状态与日志;无需重启Docker。'安装脚本倒数第几行会把上面这份源码目录里的 usb-storage-guard 用 install -m 0755 装到 /usr/local/sbin/usb-storage-guard,也就是第七节里那几条 usb-storage-guard status/pause/resume 命令实际执行的文件。它本身只是个 4 行的启动器,负责切到守护专用的标准库虚拟环境再把参数原样转给 guard.py,不包含任何业务逻辑:
#!/usr/bin/env bash
# 与运行服务使用同一个标准库虚拟环境。
set -euo pipefail
exec /opt/usb-storage-guard/venv/bin/python /opt/usb-storage-guard/guard.py "$@"实测验证
38 项单元测试通过:缺盘、错误 UUID、autofs、只读、路径边界、多盘、目录缺失拒绝、迟挂后台作业、旧容器视图 stop/start、自动新增/编辑/删除/改名、暂停、限速、重启后持久代次。
隔离集成测试 7 组通过:临时容器先绑 /run 空目录再以真实盘只读 bind,确认旧 eMMC 视图被识别、自动 stop/start 后指向正确盘;稳定时不重复重启;模拟缺 UUID 停止与恢复自动启动;移除外盘不再接管。已清理临时容器与挂载,未卸载真实盘。
正式状态:守护 enabled/active;Cloudreve disabled 但 active(由守护恢复);Alist/Navidrome running 且
restart=no;Cloudreve HTTP200、Alist HTTP200、Navidrome HTTP302,守护重启后全部线程 CPU0–3。恢复时延:Cloudreve 短暂停服后守护约 4.1 秒自动提交恢复;随后进程出现过一次 SIGKILL(来源未确定,内核无对应 OOM 记录),按原 15 秒重试恢复 HTTP200,原日志保留。不能以一瞬间的 active 代替 HTTP 就绪验证。
2026-10-13 补丁验证:71 项守护单测、21 项菜单回归、旧 7 组挂载隔离、新增 13 组停服隔离实机验证通过。Jellyfin inactive/disabled+manual=true,酷狗下载器保持停止;Cloudreve200、Alist200、Navidrome302、录播与 Aria2 healthy。Cloudreve 未重启,故未在生产服务做真实 stop/start。
测试事故也如实记录:首次集成脚本未按 ID 过滤容器列表,两轮模拟缺盘时误停了 Alist、Navidrome、bililive-go、cloudreve-aria2 并已恢复;日志与 StartedAt 真实保留,Cloudreve 未重启,录像日志显示重启后 29 个房间均不监听,因此没有依据承诺事故前所有任务未受影响。之后测试已限制为精确 ID 加命令白名单,重跑后既有容器 ID 与 StartedAt 未变。
注意事项、局限与未验证项
未实际拔插、断电或做缺盘整机重启,未做 Web 业务上传下载,未实测功耗。跨开机规则由持久文件加新实例入口验证,不能当成真实开机验收。
突然拔盘仍会损坏进行中的 I/O,轮询无法消除检测窗口或断电风险。本方案移除了所接管服务的开机等待链,不宣称第三方程序、厂商 USB 枚举或硬件故障永远没有延迟;访问 automount 目录仍可能触发一次有限的设备等待。
Docker 侧只支持本机 Unix socket,依赖 Docker 29.8.2 已验证的内部持久字段;守护对 Docker 配置只读,不把 Env 写进日志。元数据缺失或不兼容时保守拒绝自动 start,升级 Docker 必须回归;最后检查至 API 调用之间仍有并发窗口。
手停保护只作用于已登记并装钩子的服务。系统盘 Docker 不纳入本守护,
always仍会在 Docker 重启时启动,需在 1Panel 改成unless-stopped/no。自动发现仅限登记盘下的直接 bind 路径;named volume、local-driver 设备选项、经外盘之外的符号链接、Swarm/Kubernetes 都不自动推断。新增外盘要先登记 UUID 与挂载点,不能把所有
/media目录当成可信盘。
还原与回滚
归档在 /root/AI/backup/usb-storage-guard/,含 README、默认预演 restore、镜像文件与 MANIFEST,不含数据库、密钥、容器数据或旧 ID 状态。恢复顺序是先恢复 UUID 挂载、Docker/容器、Cloudreve 本体及 Blob 补丁、业务数据,最后装本守护;恢复旧本体归档并重新 enable Cloudreve 会破坏无盘启动入口,需重新 disable。撤销则先停用并禁用守护,执行 usb-storage-guard release 还原仍存在容器的原策略,手工改回源 Compose 并恢复 systemctl enable cloudreve.service。全程不要删盘上数据,不要 Docker prune;归档在 eMMC 上,重装前必须拷到设备外。
附:完整脚本
上面步骤里用到的完整脚本不再整段贴在正文,已作为附件上传到对象存储,点击文件名即可下载:
guard.py —— 562 行,USB 存储守护主程序
install_service_hooks.py —— 45 行,systemd 启停意图钩子安装器
对象存储策略会自动把文件名改写成 UUID(例如
95D96A8A-FB38-4CCB-AB42-F6E906E70760.py),这是存储策略的行为,不影响文件内容;下载后按正文里的路径改名放好即可。