Jack-Li's Blog

不要把 NFS 作为高 I/O 服务的 Data Root:一次踩坑与原理解析

2026.07.20
jack-li

在日常开发和运维中,高速共享网络存储(如 NFS)因其超大容量和便捷的跨节点共享特性,常常成为开发者眼中的“香饽饽”。然而,如果盲目将高并发、密集写操作的服务(如 Docker Registry、GitLab 数据库)的数据目录部署在 NFS 上,可能会引发意想不到的灾难。

本文记录了一次因为将 GitLab 的数据目录挂载在 NFS 机械硬盘上,导致服务器彻底卡死、SSH 拒绝连接的排错经历,并深入分析了背后的 Linux 内核与 NFS 机制。


1. 事情起因:20TB NFS 的诱惑

最近,课题组的服务器接入了一台 20TB 的机械硬盘存储服务器,通过光纤高速互联挂载在各节点主机的 /public 目录下。其实测速度非常惊人,移动一个 133GB 的文件夹只需不到 10 分钟。

这让我萌生了一个想法:如果把所有程序和数据都放在 /public 中,不就可以完美实现节点间的程序与数据共享了吗?

说干就干,我迅速完成了以下部署:

  1. Docker Registry:配置好 Dockerfile,打包镜像并上传至 Registry,各个节点拉取并运行,一切顺利。
  2. GitLab 部署:为了配合 CI/CD 自动运行任务,我决定把 GitLab 也部署起来。在 AI 的指导下,我使用 Docker Compose 快速搭建了 GitLab 服务,并直接将其数据目录映射到了 NFS 的 /public/var/srv/ 目录下。

20TB 空间随心用,当时觉得这套方案简直完美。


2. 异样与暴雷:从卡顿到服务器失联

然而好景不长,很快我就发现 GitLab 网页操作极其卡顿。照理说,开发机距离服务器仅 30 米,在千兆校园网内不应该有如此明显的延迟。

午饭时我琢磨了一下:虽然光纤交换机速度很快,但远端存储本质上还是机械硬盘,并发随机 I/O 性能较差,这很可能是瓶颈所在。为了解决卡顿,我决定将 GitLab 迁移回本地主机的 SSD 固态硬盘上。

回到工位,我执行了以下操作:

  1. 执行 docker compose down 停止服务。
  2. 修改 docker-compose.yml,将数据目录改回本地固态硬盘路径。
  3. 重新执行 docker compose up -d 启动。

就在这时,诡异的事情发生了:我的 SSH 终端突然毫无响应,紧接着服务器连 ping 都 ping 不通了


3. 原理解析:为什么 NFS 会让整机崩溃?

服务器失联后,我通过排查与分析,厘清了这是由于 NFS 写入阻塞Linux 内存回收机制 共同作用导致的全局冻结。

核心诱因 1:不可中断的内核休眠(D 状态 / Uninterruptible Sleep)

当 GitLab 内部的 PostgreSQL 数据库等服务试图在 NFS 挂载点上写数据时,会发起系统调用(如 writefsync)。为了保证数据一致性,Linux 内核会将发起调用的进程置于 D 状态(TASK_UNINTERRUPTIBLE)

  • 什么是 D 状态:内核向存储设备发起请求后,强制让该进程休眠,直到设备返回“写入成功”的响应。
  • 不响应信号:处于 D 状态的进程无法被任何信号中断,即使执行 kill -9 也无济于事。
  • 致命的延迟:本地固态硬盘的写入响应通常在微秒级别,而 NFS 在面临高并发随机读写或网络波动时,响应时间可能飙升至数秒甚至数分钟。这导致大量进程在内核中无限期死等。

核心诱因 2:内存页缓存(Page Cache)锁死引发全局卡死

GitLab 启动时会产生庞大的临时缓存和日志写入,触发了如下连锁反应:

  1. 写缓存积压:内核为了提高效率,会先将数据写入本地内存的 Page Cache(页缓存)中,再由后台线程异步刷盘(Flush)到 NFS 挂载点。
  2. 脏页(Dirty Pages)超限:由于 NFS 写入极慢,Page Cache 迅速被未刷盘的脏数据填满,达到了系统内核的 vm.dirty_ratio 限制阈值。
  3. 全局内存申请阻塞(Memory Pressure):一旦脏页占比超限,Linux 内核为了防止内存耗尽,会激活强制回收机制:要求系统内所有申请新内存的进程(包括 SSH 服务的 sshd 进程、网络协议栈等),必须先协助将内存中的脏页刷写到磁盘中。

最终结果:由于 NFS 写入阻塞,脏页根本刷不动。这导致整个系统的内存分配器彻底冻结,连 sshd 想要创建新线程处理 SSH 连接请求都无法申请到内存。服务器因此彻底失联。


4. 惊险解决与二次卡死

眼看 SSH 挂掉,我急忙联系楼管阿姨打开机房大门,对服务器进行了硬重启。

回到工位,SSH 重新连通,我松了一口气。在 AI 的建议下,我准备立刻清理掉挂载在 NFS 上的容器,以防它随 Docker 守护进程启动而再次卡死:

docker rm -f gitlab_server gitlab_runner

然而,因为刚才重启了服务器,Docker 服务默认并没有启动。我习惯性地输入:

systemctl start docker

回车按下的那一瞬间,命令行窗口再次失去响应——又卡死了

原因分析:Docker 守护进程启动时,会去扫描并初始化本地容器的状态。由于之前的 GitLab 容器配置依然关联着 NFS 挂载路径,Docker 在初始化时尝试去访问该路径,结果再次触发了 NFS I/O 阻塞,导致 Docker 守护进程进入 D 状态,并顺带锁死了内存分配,重演了先前的悲剧。

由于时间已晚,我只能等待第二天再次麻烦楼管阿姨重启服务器。


5. 总结与最佳实践

经历这次波折,我深入研究了 NFS 的挂载配置及应用场景:

NFS 的挂载模式:Hard vs Soft

NFS 挂载通常有两种模式:

  • hard 模式(默认):如果写操作未完成,客户端会无限期阻塞并重试,直至成功。在互联网后端中,为了防止数据损坏或丢失,通常默认采用 hard 模式。但缺点是,一旦远端存储响应慢,本地进程极易卡死在 D 状态。
  • soft 模式:在网络超时或写入失败后,直接向应用返回 I/O 错误。虽然不会卡死进程,但可能导致应用因数据不一致而直接崩溃。

读写特性的差异

  • 读操作:NFS 的读操作在网络拥堵时,虽然也会有延迟,但由于通常不涉及强制的内核级脏页刷盘逻辑,对系统全局内存分配器的冲击相对较小。
  • 写操作:特别是高频、随机、带有 fsync(强制刷盘)性质的写操作(如数据库、缓存、日志目录),是 NFS 的绝对禁区。

最终解决方案

第二天重启服务器后,我果断放弃了将 GitLab 部署在挂载 NFS 的节点上。

  1. 服务迁移:将 GitLab 完整迁移部署到没有挂载 NFS、且配备本地 SSD 固态硬盘的 Web 服务器上。
  2. 反向代理:在集群的入口网关(Manager 节点)上配置 Nginx 反向代理,将 GitLab 的 Web 流量代理至该 Web 服务器。

一句话总结千万不要把 NFS 作为服务的 Data Root,尤其是涉及到数据库或有密集写操作的路径。网络共享存储更适合存放只读的静态资源、冷备份数据,或者大文件的顺序读写。