1. 问题重现:当Docker抢跑了你的硬盘

不知道你有没有遇到过这种让人抓狂的情况:在Ubuntu 20.04上,你明明插好了一块移动硬盘或者外置硬盘盒,系统启动后,却发现硬盘死活挂载不上。打开终端,用 sudo mount 或者查看 dmesg 日志,经常会看到一个经典的错误提示:ntfs-3g-mount: mount failed: Device or resource busy

字面意思是“设备或资源忙”,但你的硬盘明明刚通电,怎么就“忙”起来了呢?这个问题我遇到过不止一次,尤其是在搭建家庭服务器或者开发环境,把Ubuntu当作主力系统,并且同时运行Docker容器的时候。一开始我也很懵,以为是硬盘坏了,或者NTFS驱动有问题,反复折腾 ntfs-3g 的版本和参数,结果都是徒劳。

后来经过一番排查,我才发现问题的根源,其实是一个典型的“启动顺序”打架事件。想象一下,你的Ubuntu系统开机就像一场百米赛跑。发令枪一响(内核启动完成),几个“运动员”同时冲了出去:

  1. 系统服务管理器(systemd):它是裁判兼教练,负责调度。
  2. 硬盘自动挂载服务:比如 udisks2 或者你配置的 fstabrc.local,它的任务就是去把 /dev/sdb1 这样的硬盘分区,挂载到 /mnt/data 这样的目录。
  3. Docker服务:这位“运动员”比较特殊,它一启动,就迫不及待地把自己管理的所有容器(特别是那些设置了 --restart always 的容器)都拉起来。

冲突就发生在这里。如果你的某个Docker容器(比如我遇到过的OpenWRT软路由容器、或者一些需要访问USB设备的监控容器)配置了自动重启,并且它声明要使用某些设备资源(可能是整个USB控制器,也可能是特定的端口号),那么Docker服务启动这个容器的瞬间,系统就会认为相关的设备(即使是你想挂载的那块硬盘所在的USB总线)已经被“占用”了。

此时,轮到“硬盘自动挂载服务”这位运动员上场,它伸手去抓那个设备(/dev/sdb1),系统却告诉它:“别动,这东西已经有人在用了!”于是,挂载失败,报出“Device or resource busy”的错误。本质上,这不是 ntfs-3g 这个NTFS文件系统驱动程序的错,它只是个“背锅侠”。真正的矛盾在于,Docker容器的启动,抢在了物理硬盘挂载完成之前

所以,解决这个问题的核心思路就非常明确了:我们必须修改赛跑规则,让“硬盘挂载”这位运动员先跑,确保它稳稳地拿到设备资源后,“Docker服务”再起跑。接下来,我就带你一步步实施这个“启动顺序优化”方案。

2. 诊断与确认:找到冲突的元凶

在动手修改系统配置之前,我们不能盲目下药。先要准确诊断,确认问题是否真的是由Docker启动顺序引起的,并找到具体是哪个设备被占用了。

第一步,复现问题并查看错误日志。 开机后,手动尝试挂载硬盘,这是最直接的测试。假设你的NTFS硬盘分区是 /dev/sdb1,想要挂载到 /mnt/mydisk

sudo mount -t ntfs-3g /dev/sdb1 /mnt/mydisk

如果看到 ntfs-3g-mount: mount failed: Device or resource busy 这个错误,就进入了我们的问题场景。

第二步,使用 lsoffuser 命令侦探。 这两个命令是Linux下查看“谁在占用文件或设备”的神器。当挂载失败时,立即在终端执行:

sudo lsof /dev/sdb1

或者

sudo fuser -v /dev/sdb1

lsof 会列出所有打开了 /dev/sdb1 这个设备文件的进程。如果输出是空的,或者只显示一些像 bashlsof 本身这样的进程,那可能不是直接的进程占用。但有时候,你可能会发现某个奇怪的进程ID关联着它。

更常见的情况是,设备被内核的某个子系统(比如Docker使用的容器运行时)以更底层的方式“锁定”了。这时,fuser 命令可能也看不出什么。我们需要换个思路,查看系统日志。

第三步,查阅系统启动日志(journalctl)。 systemd的日志工具 journalctl 能提供开机过程中的详细记录,这对我们定位启动时序问题至关重要。运行以下命令,查看本次启动以来的内核及服务日志:

sudo journalctl -b

这条命令的输出会很多。我们可以用更精准的过滤,只看与Docker、mount、USB或你的设备(sdb)相关的信息:

sudo journalctl -b | grep -E "(docker|mount|sdb|ntfs)"

仔细翻阅这些日志,你可能会发现一些线索,比如在挂载失败的时间点前后,有Docker容器启动的日志记录。例如,看到 “Started Docker Application Container Engine.” 紧接着就是挂载失败的信息,这就是一个强烈的间接证据。

第四步,模拟验证:重启Docker服务。 一个非常实用的验证方法是:如果我们先确保硬盘已经成功挂载了,然后重启Docker服务,看看是否一切正常。操作如下:

  1. 先想办法挂上硬盘(比如在系统启动后,手动停止相关Docker容器再挂载)。
  2. 硬盘挂载成功后,重启Docker服务:
    sudo systemctl restart docker
    
  3. 观察你的容器是否都正常启动,并且硬盘挂载点是否依然可访问。

如果经过这个流程后,一切正常,那就几乎可以100%断定是启动顺序的问题了。因为手动操作保证了“先挂载,后启动Docker”的顺序。诊断清楚后,我们就可以进入解决方案的核心部分:调整系统服务的启动依赖关系。

3. 核心解决:调整Docker服务启动时机

既然确定了是Docker跑得太快,那我们就给它加个“延时启动”或者明确告诉系统“等硬盘挂载好了再启动Docker”。在Ubuntu 20.04使用systemd的系统里,有几种优雅的方式来实现。

3.1 方法一:为Docker服务添加启动前延时(简单直接)

这是最直观、改动最小的方法。我们直接修改Docker服务的systemd单元文件,让它在执行真正的启动命令前,先“睡”一会儿。

  1. 找到并编辑Docker的service文件。 Ubuntu 20.04上,Docker的systemd服务文件通常在这里:/lib/systemd/system/docker.service(有时也可能是 /usr/lib/systemd/system/docker.service)。我们先做个备份是个好习惯:

    sudo cp /lib/systemd/system/docker.service /lib/systemd/system/docker.service.backup
    

    然后使用你喜欢的编辑器(如nano或vim)打开它:

    sudo nano /lib/systemd/system/docker.service
    
  2. 添加延时指令。 在文件中找到 [Service] 这个部分。在这个区块里,添加一行 ExecStartPre 指令。这个指令用于定义在主要服务(ExecStart)启动之前执行的命令。

    [Service]
    Type=notify
    # 下面这行是新增的,表示等待30秒
    ExecStartPre=/bin/sleep 30
    ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
    ...
    

    关键点解释

    • ExecStartPre=/bin/sleep 30:这行命令告诉systemd,在启动Docker守护进程之前,先执行 /bin/sleep 30 命令,也就是等待30秒。
    • 30秒是否足够? 这取决于你的系统启动速度和硬盘挂载所需的时间。对于大多数SATA或USB硬盘,30秒通常绰绰有余。如果你使用的是速度较慢的硬盘或者在树莓派等设备上,可以适当延长,比如 sleep 45sleep 60。这个时间给了系统足够的窗口去完成所有自动挂载任务。
  3. 重新加载systemd配置并重启Docker。 修改完服务文件后,需要让systemd识别我们的更改:

    sudo systemctl daemon-reload
    

    然后,重启Docker服务以使更改生效(或者直接重启系统进行整体测试):

    sudo systemctl restart docker
    

这个方法的优缺点:

  • 优点:简单,无需精确知道硬盘挂载服务的具体名称,一个简单的延时就能解决大部分时序冲突。
  • 缺点:有点“笨”,属于固定等待。无论硬盘挂载是否已经在30秒内完成,它都会等够30秒,略微增加了系统启动的总时间。如果硬盘挂载因其他问题失败,Docker依然会启动,可能引发其他错误。

3.2 方法二:使用systemd依赖关系(更精准)

更优雅的方式是利用systemd强大的依赖管理系统。我们可以修改Docker服务的配置,让它明确地在某个“挂载点”(mount point)就绪之后再启动。

  1. 确保你的硬盘通过 /etc/fstab 自动挂载。 这是使用此方法的前提。你需要将硬盘的挂载信息写入 /etc/fstab 文件。例如,添加一行类似下面的配置(请根据你的实际情况修改UUID、路径和文件系统类型):

    UUID=1234-ABCD /mnt/mydisk ntfs-3g defaults,nofail,uid=1000,gid=1000,dmask=022,fmask=133 0 0
    

    使用 blkid 命令可以查看硬盘分区的UUID。nofail 选项很重要,它表示即使挂载失败,系统也会继续启动,防止因硬盘未插入导致系统无法启动。

  2. 为挂载点创建独立的systemd mount单元。 当你使用 /etc/fstab 时,systemd会自动为每个条目生成对应的 .mount 单元。这个单元的名字就是挂载路径的转换,例如 /mnt/mydisk 会对应 mnt-mydisk.mount

  3. 修改Docker服务,添加挂载点依赖。 再次编辑 docker.service 文件,这次我们在 [Unit] 部分添加依赖:

    sudo nano /lib/systemd/system/docker.service
    

    在文件开头的 [Unit] 区块里,添加 AfterRequires 指令:

    [Unit]
    Description=Docker Application Container Engine
    Documentation=https://docs.docker.com
    # 新增以下两行
    After=mnt-mydisk.mount
    Requires=mnt-mydisk.mount
    BindsTo=containerd.service
    ...
    

    关键点解释

    • After=mnt-mydisk.mount:这表示Docker服务 mnt-mydisk.mount 单元启动之后才启动。
    • Requires=mnt-mydisk.mount:这表示Docker服务要求 mnt-mydisk.mount 单元必须成功启动。如果硬盘挂载失败,Docker服务将不会启动。
    • 你可以指定多个挂载点,用空格隔开,例如 After=mnt-data.mount mnt-backup.mount
  4. 重新加载配置并测试。 同样执行 sudo systemctl daemon-reload,然后重启系统进行测试。你可以使用 systemctl list-dependencies docker.service 来查看Docker的依赖关系是否已更新。

这个方法的优缺点:

  • 优点:精准、高效。Docker只在所需的硬盘挂载成功后立即启动,不浪费任何等待时间。逻辑清晰,符合systemd的设计哲学。
  • 缺点:配置稍复杂,需要先配置好 /etc/fstab,并且要知道准确的 .mount 单元名。如果挂载失败 (Requires),会阻止Docker启动,这可能不是你想要的(此时可以考虑只用 After 而不用 Requires,或者结合 Wants 指令)。

对于大多数用户,我推荐先尝试方法一(添加Sleep延时),因为它简单粗暴且有效。如果你追求启动流程的精确控制,或者有多个依赖不同硬盘的容器,那么花点时间配置方法二(依赖关系) 是值得的。

4. 确保硬盘自动挂载:配置fstab与替代方案

解决了Docker的启动顺序,我们还要确保硬盘本身能被系统自动、可靠地挂载。虽然很多桌面环境会自动挂载可移动磁盘,但对于服务器或需要固定挂载点的情况,手动配置是更稳妥的选择。

4.1 首选方案:配置 /etc/fstab

/etc/fstab 是Linux系统定义静态文件系统信息的传统且核心的文件。通过它来配置自动挂载是最标准的方式。

  1. 获取硬盘分区的唯一标识符(推荐使用UUID)。 使用设备名(如 /dev/sdb1)可能会变,比如你插拔了其他USB设备后,sdb 可能变成 sdc。UUID是分区的唯一标识,更稳定。运行以下命令查看:

    sudo blkid
    

    在输出中找到你的NTFS分区,记录下它的 UUID 值,例如 UUID="2ABC4567D890123E"

  2. 创建挂载点目录。 选择一个目录作为挂载点,通常放在 /mnt//media/ 下:

    sudo mkdir -p /mnt/mydata
    
  3. 编辑 /etc/fstab 文件。

    sudo nano /etc/fstab
    

    在文件末尾添加一行,格式如下:

    UUID=2ABC4567D890123E /mnt/mydata ntfs-3g defaults,nofail,uid=1000,gid=1000,dmask=022,fmask=133 0 0
    

    参数详解

    • UUID=...:替换成你的实际UUID。
    • /mnt/mydata:替换成你的挂载点路径。
    • ntfs-3g:文件系统类型。
    • defaults:使用默认挂载选项(包含rw, suid, dev, exec, auto, nouser, async)。
    • nofail非常重要! 系统启动时,如果设备不存在,忽略错误继续启动。防止硬盘没插时系统卡住。
    • uid=1000, gid=1000:将挂载后的文件所有者设置为你的普通用户(通常uid=1000),这样你就有读写权限了。
    • dmask=022, fmask=133:设置目录和文件的权限掩码。dmask=022 使得目录权限为755(所有者全权,组和其他可读可执行),fmask=133 使得文件权限为644(所有者可读写,组和其他只读)。这是一个兼顾安全和方便的常用设置。
    • 0 0:最后两个数字分别代表 dump(备份工具)和 fsck(文件系统检查)的选项,对于NTFS硬盘通常都设为0。
  4. 测试fstab配置。 在重启前,先测试一下配置是否正确,避免配置错误导致无法进入系统:

    sudo mount -a
    

    这条命令会尝试挂载所有在 /etc/fstab 中定义但未挂载的设备。如果没有报错,并且使用 df -hls /mnt/mydata 能看到你的文件和目录,说明配置成功。

4.2 传统方案:使用 rc.local(适用于非systemd或特定场景)

在一些老教程或者特定场景(比如原文中提到的树莓派),你可能会看到使用 /etc/rc.local 脚本的方法。在Ubuntu 20.04中,rc.local 默认没有启用,需要手动设置。

  1. 启用 rc.local 服务。 创建或编辑服务文件:

    sudo nano /etc/systemd/system/rc-local.service
    

    写入以下内容:

    [Unit]
    Description=/etc/rc.local Compatibility
    ConditionPathExists=/etc/rc.local
    After=network.target
    
    [Service]
    Type=forking
    ExecStart=/etc/rc.local start
    TimeoutSec=0
    RemainAfterExit=yes
    GuessMainPID=no
    
    [Install]
    WantedBy=multi-user.target
    
  2. 创建并编辑 /etc/rc.local 脚本。

    sudo nano /etc/rc.local
    

    写入以下内容,注意 #!/bin/sh 是必须的:

    #!/bin/sh -e
    # 在这里添加你的挂载命令
    mount -t ntfs-3g /dev/sdb1 /mnt/mydata
    exit 0
    

    然后赋予可执行权限:

    sudo chmod +x /etc/rc.local
    
  3. 启用并启动服务。

    sudo systemctl enable rc-local
    sudo systemctl start rc-local
    

对比与建议:

  • /etc/fstab首选。它是系统级别的标准配置,与文件系统管理工具集成更好,systemd 也能基于它生成精确的 .mount 单元,便于我们做服务依赖管理(如3.2节所述)。
  • rc.local 更像一个“万能启动脚本”,适合执行一些零散的、非标准的启动任务。但在现代systemd系统中,它的执行时机可能不如 .mount 单元明确。除非你有历史遗留脚本或特殊需求,否则建议优先使用 fstab

无论选择哪种方式,目标都是一致的:确保在Docker服务尝试启动之前,你的NTFS硬盘已经稳稳地挂载在了指定的位置。

5. 进阶优化与故障排查

完成了基本的启动顺序调整和挂载配置后,我们的系统应该已经可以稳定工作了。但为了应对更复杂的情况或进一步优化,这里还有一些进阶技巧和排查思路。

5.1 处理多个硬盘或复杂依赖

如果你的系统有多个需要挂载的硬盘,并且Docker容器依赖其中不止一个,可以在Docker服务的 [Unit] 部分指定多个 AfterRequires 依赖。例如:

[Unit]
After=mnt-data.mount mnt-backup.mount media-archive.mount
Requires=mnt-data.mount mnt-backup.mount
Wants=media-archive.mount

这里使用了 Wants 而不是 Requires 对于 media-archive.mount,表示Docker希望这个挂载点存在,但即使它挂载失败,Docker仍然可以启动。

5.2 检查服务启动状态与顺序

学会使用 systemctl 命令来分析和调试服务启动顺序是非常有用的。

  • 查看服务的依赖关系:

    systemctl list-dependencies docker.service
    

    这会以树状图显示Docker服务依赖哪些单元,以及哪些单元依赖它。确认你的挂载点(如 mnt-mydisk.mount)是否出现在依赖列表中。

  • 分析启动时间线:

    systemd-analyze critical-chain docker.service
    

    这个命令会显示Docker服务启动过程中的关键路径,以及每个步骤花费的时间。你可以看到它是否在等待某个挂载单元。

  • 查看挂载单元的状态:

    systemctl status mnt-mydisk.mount
    

    这会显示该挂载点的当前状态、是否激活、以及相关的日志片段。

5.3 当问题依然存在:其他排查方向

如果按照上述步骤操作后,问题仍然偶尔出现,可以考虑以下方向:

  1. USB电源管理或休眠问题: 有些USB控制器或硬盘盒在系统休眠或电源管理策略下可能会被重置。可以尝试在BIOS/UEFI中禁用USB的休眠节能选项,或者在Linux内核参数中添加 usbcore.autosuspend=-1 来禁用USB自动挂起。编辑 /etc/default/grub 文件中的 GRUB_CMDLINE_LINUX_DEFAULT 行,添加该参数,然后运行 sudo update-grub 并重启。

  2. 文件系统损坏: 虽然不常见,但NTFS文件系统本身可能存在问题。可以在Windows系统下对硬盘进行一次完整的磁盘检查(chkdsk /f),修复潜在错误。

  3. 内核模块加载顺序: 极少数情况下,可能需要确保 usb-storageuas(USB Attached SCSI)等内核模块在相关服务之前加载。可以通过创建 modprobe 配置或 systemd 模块加载服务来调整,但这属于比较深入的调试。

  4. 查看更详细的启动日志: 使用 sudo journalctl -b -u docker -u systemd-udevd 可以同时查看Docker服务和设备管理服务(udev)的日志,有助于理解设备发现和初始化的全过程。

5.4 一个完整的配置示例

为了让思路更清晰,这里给出一个我本人在类似环境(Ubuntu 20.04服务器,Docker运行若干容器,外接NTFS硬盘用于数据存储)中最终采用的配置组合,供你参考:

  1. 硬盘挂载 (/etc/fstab):

    UUID=ABCDEF0123456789 /mnt/storage ntfs-3g defaults,nofail,uid=998,gid=998,dmask=027,fmask=137 0 0
    
    • 我创建了一个专门用于服务的用户和组(uid=998, gid=998),而不是直接用我的个人账户,这样更安全。
    • dmask=027fmask=137 设置了更严格的权限(目录755,文件640),因为这是一个内部服务使用的数据盘。
  2. Docker服务延迟 (/lib/systemd/system/docker.service):

    [Unit]
    # 添加了明确的挂载点依赖,但只用 After,不用 Requires
    After=mnt-storage.mount network-online.target containerd.service
    Wants=network-online.target mnt-storage.mount
    ...
    [Service]
    # 同时保留了5秒的短暂延时,作为双保险
    ExecStartPre=/bin/sleep 5
    ...
    
    • 我结合了两种方法:使用 AfterWants 声明对挂载点的依赖,同时保留一个很短的 sleep 5。这个5秒不是为了等硬盘,而是为了确保在挂载完成后,系统其他部分(如网络)有更稳定的准备时间。

这套配置在我这里运行了超过一年,经历了无数次重启和断电,再也没有出现过硬盘挂载失败导致容器启动异常的问题。启动顺序的优化就像给系统流程加上了一个可靠的交通信号灯,让各个服务井然有序地工作,避免了争抢资源导致的“交通事故”。

更多推荐