AI Infra 训练营
总览
  • Day 1 · 集群起步 + CNI
  • Day 2 · 控制面 + etcd
  • Day 3 · CRD + Operator + Webhook
  • Day 4 · 存储深度
  • Day 5 · 卷扩容 + 安全
  • Day 6 · 调度 + 可观测
  • Day 7 · Harbor + ArgoCD + Mesh
  • Day 8 · AI Infra
  • Day 9 · Triton + GPU
  • Day 10 · MIG + HPA + 量化
  • Day 11 · AI Agent 端到端
  • Day 12 · 灾备
  • Day 13 · Operator + 联邦 + Mesh + RAG
  • Day 14 · CKA / CKS + 总结
  • LLM 训练手册
  • RAG + Agent 手册
  • 推理优化手册
  • 上下文工程手册
  • Agent 开发手册
  • 面试深度复盘
  • 训练 v2 深度手册
  • 心智模型
  • 看懂命令输出
  • 容器网络底层
  • K8s 网络深入
  • DNS 全套
  • 故障排查方法论
  • 心智模型
  • 容器挂载完整指南
  • K8s Volumes 大全
  • PV/PVC/CSI 深入
  • NFS 深入
  • 分布式存储概览
  • 故障排查 runbook
HiHuo 主站
GitHub
总览
  • Day 1 · 集群起步 + CNI
  • Day 2 · 控制面 + etcd
  • Day 3 · CRD + Operator + Webhook
  • Day 4 · 存储深度
  • Day 5 · 卷扩容 + 安全
  • Day 6 · 调度 + 可观测
  • Day 7 · Harbor + ArgoCD + Mesh
  • Day 8 · AI Infra
  • Day 9 · Triton + GPU
  • Day 10 · MIG + HPA + 量化
  • Day 11 · AI Agent 端到端
  • Day 12 · 灾备
  • Day 13 · Operator + 联邦 + Mesh + RAG
  • Day 14 · CKA / CKS + 总结
  • LLM 训练手册
  • RAG + Agent 手册
  • 推理优化手册
  • 上下文工程手册
  • Agent 开发手册
  • 面试深度复盘
  • 训练 v2 深度手册
  • 心智模型
  • 看懂命令输出
  • 容器网络底层
  • K8s 网络深入
  • DNS 全套
  • 故障排查方法论
  • 心智模型
  • 容器挂载完整指南
  • K8s Volumes 大全
  • PV/PVC/CSI 深入
  • NFS 深入
  • 分布式存储概览
  • 故障排查 runbook
HiHuo 主站
GitHub
  • 存储专题

    • 存储心智模型:从一次 write() 到磁盘
    • 容器挂载完整指南:bind mount / volume / mount propagation / 多视角
    • K8s Volume 类型大全 + subPath / 多视角
    • PV / PVC / StorageClass / CSI 深入
    • NFS 深入:server / client / K8s NFS / 经典坑
    • 分布式存储概览:Ceph / Longhorn / Rook
    • 存储故障排查 runbook

存储心智模型:从一次 write() 到磁盘

容器和 K8s 存储的所有"魔幻"都是建立在 Linux 存储栈 之上。一次应用调用 write() 到数据真正落到盘上、要穿过 6-7 层抽象。

这一篇把这些层讲清楚——后面所有存储话题(PV / NFS / CSI / mount propagation)都是基于这套心智模型。

这篇要回答什么

基础
  1. 块存储 / 文件存储 / 对象存储 —— 区别在哪?
  2. "VFS"、"page cache"、"block device" 各是干啥的?
  3. 我应用调 write(),到底什么时候真落到盘上?
  4. 为啥 rm 文件磁盘空间不释放?
K8s
  1. K8s PVC 背后到底是块设备还是文件系统?
  2. inode 满了 是什么概念?怎么发生的?
  3. fsync / O_DIRECT / O_SYNC 这些应用层标志影响 K8s 存储吗?
  4. ext4 / xfs / btrfs 在 K8s 节点上选哪个?
生产
  1. 磁盘 IOPS / 吞吐 / 延迟三者的关系 + 不同负载该看哪个?
  2. K8s 节点磁盘满了的几种"满"——怎么分辨?

学完之后看 iostat / df / 各种"磁盘"问题、心里有谱。


1. 三类存储:块 / 文件 / 对象

graph TB
    subgraph Block[块存储 Block Storage]
        B1["接口: read/write 块<br>例如 sector 12345"]
        B2["产品: 云盘 / SAN / iSCSI / Ceph RBD<br>本地 SSD/NVMe"]
        B3["特征: 速度快、原始裸数据<br>需要在上面建文件系统才能用"]
    end

    subgraph File[文件存储 File Storage]
        F1["接口: open/read/write/close 文件"]
        F2["产品: NFS / CIFS / CephFS / EFS / 本地磁盘+ext4"]
        F3["特征: 有目录结构、有权限<br>多客户端可共享"]
    end

    subgraph Object[对象存储 Object Storage]
        O1["接口: HTTP PUT/GET object"]
        O2["产品: S3 / OSS / MinIO / Ceph RGW"]
        O3["特征: 海量、便宜、无文件系统语义<br>不能直接 mount 跑应用"]
    end

    style Block fill:#e1f5ff
    style File fill:#ffe1f5
    style Object fill:#fff4e1

三者关系(关键)

对象存储  (S3)              ←  最上层抽象、HTTP 接口
   ↓ (可以建在)
文件存储  (NFS, ext4 文件)    ←  中间抽象、文件系统语义
   ↓ (建在)
块存储   (云盘, /dev/sda)    ←  最底层、裸字节
   ↓ (建在)
物理介质  (SSD, HDD)

K8s PV 的 accessModes 直接对应这三类:

accessMode类型例子
ReadWriteOnce (RWO)块云盘(同时只一个节点挂)
ReadWriteMany (RWX)文件NFS / CephFS
ReadOnlyMany (ROX)文件(只读)共享只读卷
ReadWriteOncePod (RWOP)块 / 单 podK8s 1.27+

反面:用对象存储当文件系统

mount -t s3fs ...                       # 用 FUSE 把 S3 mount 成文件系统

能跑、不能用:

  • 没有文件锁
  • 重命名 = 复制+删除(极慢)
  • 修改文件中间 = 重写整个 object
  • 没有 inode 概念

适合"只读 + 大对象",不适合"频繁修改 + 多客户端写"——例如不要把数据库放 S3FS 上。


2. Linux 存储栈分层

应用调 write(fd, buf, len),数据穿过这些层才到盘:

flowchart TB
    App["应用代码: write(fd, buf, len)"]

    subgraph Userspace[用户态]
        Libc["glibc / Go runtime<br>(可能有自己 buffer)"]
    end

    subgraph Kernel[内核态]
        VFS["VFS (虚拟文件系统层)<br>统一接口: open/read/write"]
        FS["具体文件系统<br>(ext4 / xfs / btrfs)<br>负责 inode/目录/journal"]
        PC["Page Cache<br>(内存中缓存的文件页)"]
        BIO["Block I/O Layer<br>合并/排序/调度"]
        Driver["设备驱动<br>(nvme / scsi / virtio_blk)"]
    end

    Hardware["物理盘<br>(NVMe / SATA SSD / HDD)"]

    App --> Libc --> VFS
    VFS --> FS
    FS <--> PC
    FS --> BIO
    BIO --> Driver
    Driver --> Hardware

    style Userspace fill:#e1f5ff
    style Kernel fill:#ffe1f5
    style Hardware fill:#e1ffe1

各层职责

层职责调试命令
应用 buffer应用自己的缓冲(如 BufferedWriter)看代码
VFS统一接口、把 write() 路由到具体文件系统strace
文件系统inode 管理、目录、权限、journaltune2fs -l / xfs_info
page cache内存里缓存最近读写的文件页free -h(buff/cache)
block I/O合并相邻请求、排序、IO scheduleriostat -x / /proc/diskstats
驱动翻译成具体设备指令lspci / lsblk -d

一次 write 的实际旅程(默认情况)

1. write(fd, "hello", 5)
2. → VFS write() syscall
3. → ext4 实现 → 找到文件对应的 inode
4. → 把数据复制到 page cache(**内存中**)
5. → 标记页 dirty
6. → 立即返回应用(**这时盘上还没写!**)

数据还在内存里。直到下面之一发生才真落盘:

  • fsync(fd) 主动刷
  • O_SYNC / O_DIRECT 打开文件时强制同步
  • 脏页超 vm.dirty_background_ratio (默认 10%) → 后台 kworker 慢慢刷
  • 脏页超 vm.dirty_ratio (默认 20%) → 应用 write 阻塞 直到刷完
  • 脏页超时(30 秒默认)→ flusher 刷
  • sync 命令 / umount

反直觉的"write 已成功 != 数据安全"

f = open("important.dat", "w")
f.write(b"critical data")
f.close()                                  # write 返回成功

# 这时候机器突然断电
# → 数据可能丢

close() 不保证 fsync。要"真落盘":

import os
f = open("important.dat", "wb")
f.write(b"critical data")
f.flush()                                  # 1. flush 应用 buffer 到内核
os.fsync(f.fileno())                       # 2. fsync 强制落盘
f.close()

数据库 / 消息队列默认会 fsync 关键写——所以慢。开 fsync=false 的数据库断电就丢数据。


3. Page Cache —— 理解后排错神器

$ free -h
              total        used        free      shared  buff/cache   available
Mem:          16Gi        4.0Gi       100Mi        50Mi        12Gi        12Gi
                                                              ^^^^^^^^
                                                              文件 cache

buff/cache 高 不是问题——Linux 主动用空闲内存做文件缓存。被需要时立即让出。

Page Cache 的好处

$ dd if=large.bin of=/dev/null bs=1M count=1000
# 第一次:从盘读、慢

$ dd if=large.bin of=/dev/null bs=1M count=1000
# 第二次:从 page cache 读、几乎瞬间

K8s 节点上同一镜像被多个 pod 用 → 实际只读盘一次。

看哪些文件在 cache 里

# 装 vmtouch
$ vmtouch /var/lib/containerd
Files: 12345
Resident pages: 234567/345678  68.0%      ← 68% 文件在内存里
Resident size: ...

手动清 page cache(调试用、生产慎用)

sync                                       # 先把脏页落盘
echo 3 > /proc/sys/vm/drop_caches          # 1=clean pages, 2=dentries+inodes, 3=both

清完之后下次访问要重新读盘——节点 IO 瞬间飙。

反直觉:内存"满了"不一定是问题

$ free -h
              total        used        free      shared  buff/cache   available
Mem:          16Gi        14Gi       100Mi        50Mi        500Mi       1.5Gi
                                                                          ^^^^^
                                                                          可用 1.5G

free 列只是"完全没人用的"。available 才是"应用真能拿的"(含可回收 cache)。

K8s pod 看 OOMKilled、节点 free 看起来还有空 —— 看 cgroup memory.limit,不要看节点 free。


4. 文件系统层:ext4 / xfs / btrfs

graph TB
    subgraph Files[一个文件的组成]
        InodeT["inode (元数据)<br>权限/owner/时间戳/大小<br>data block 指针"]
        Data["data blocks (实际数据)"]
        InodeT -.指向.-> Data
    end

    subgraph Dir[目录的组成]
        DirInode["目录 inode"]
        DirEntries["dir entries<br>{filename -> inode}"]
        DirInode --> DirEntries
        DirEntries -."entry: foo.txt".-> InodeT
    end

    style Files fill:#e1f5ff
    style Dir fill:#ffe1f5

inode 是什么

每个文件 / 目录都有唯一 inode——存所有元数据(除文件名)。

$ ls -li /etc/passwd
123456 -rw-r--r-- 1 root root 2400 May 27 14:00 /etc/passwd
^^^^^^
inode 号

$ stat /etc/passwd
  File: /etc/passwd
  Size: 2400         Blocks: 8          IO Block: 4096   regular file
Device: 803h/2051d   Inode: 123456      Links: 1
Access: (0644/-rw-r--r--)  Uid: (0/root)   Gid: (0/root)
Access: 2026-05-27 14:30:00.123456789 +0000
Modify: 2026-05-27 14:00:00.000000000 +0000
Change: 2026-05-27 14:00:00.000000000 +0000
 Birth: 2026-05-25 10:00:00.000000000 +0000

文件名不在 inode 里——文件名在目录里、目录项指向 inode。

硬链接 / 软链接

$ touch original.txt
$ ln original.txt hardlink           # 硬链接 = 新的目录项指向**同一个 inode**
$ ln -s original.txt symlink         # 软链接 = 一个**新文件**、内容是路径字符串

$ ls -li *.txt symlink hardlink
123456 -rw-r--r-- 2 ... original.txt    ← 2 个链接
123456 -rw-r--r-- 2 ... hardlink        ← 同 inode、同内容
123457 lrwxrwxrwx 1 ... symlink -> original.txt  ← 不同 inode

rm original.txt 之后 hardlink 还在(inode 引用计数还 > 0);symlink 失效。

inode 满了是什么意思

文件系统创建时预分配了固定数量的 inode(基于盘大小):

$ df -i                                # -i = inode usage
Filesystem      Inodes  IUsed   IFree IUse% Mounted on
/dev/sda3      6291456 6280000   11456  100% /     ← 用满了
/dev/sdb1      6291456  100000 6191456    2% /data

字节空间没满、但 inode 满 → 创建文件失败:

$ touch /new.txt
touch: cannot touch '/new.txt': No space left on device
                                        ← 即使 df -h 显示有空间

K8s 节点常见原因:

  • containerd 没 GC、镜像 layer 堆积(每个层几千 inode)
  • 大量小文件(每个文件 1 inode)
  • emptyDir 卷里写了几百万小文件没清

修:

  • 删大量小文件
  • 重建文件系统(-N <count> 指定更多 inode)
  • 用 xfs(动态分配 inode,不会满)

ext4 vs xfs(K8s 节点常见问)

特性ext4xfs
成熟度极成熟成熟
inode 数创建时固定动态分配
大文件一般优秀
小文件良良
在线扩容✅✅
缩容✅❌
K8s 默认多数发行版RHEL/CentOS 7+ 默认

生产推荐:

  • 通用 / 不确定 → ext4
  • 大文件 / 数据库 / 日志服务 → xfs
  • inode 容易满(大量小文件)→ xfs

详见 mkfs.md。


5. block I/O 层 + scheduler

关键参数:IOPS / 吞吐 / 延迟

指标意义影响场景
IOPS (I/O per second)每秒能完成多少 I/O 请求小文件 / 数据库(事务多)
吞吐(MB/s)每秒传多少字节大文件 / 视频 / 备份
延迟(ms)单个 I/O 多久完成数据库事务 / 应用响应

三者互相制约:

  • 高 IOPS 配置可能吞吐不高(SSD 4K 随机读)
  • 高吞吐配置 IOPS 可能不高(HDD 大文件顺序读)
  • 低延迟 = 没排队等

云上"高 IOPS SSD"价格 ≠ "高吞吐 HDD"价格。选盘要看负载。

iostat 解读

$ iostat -x 1
Device       r/s   w/s    rkB/s    wkB/s   rrqm/s wrqm/s  r_await w_await aqu-sz  %util
sda          50.0  100.0  3000.0   8000.0   2.0   5.0     5.20    15.40   2.50    85.0
              ↑    ↑       ↑        ↑                      ↑       ↑      ↑       ↑
            读IOPS 写IOPS  读吞吐    写吞吐                读延迟   写延迟  队列   利用率
指标含义异常
r/s w/s读 / 写 IOPS-
rkB/s wkB/s读 / 写 吞吐-
r_await w_await读 / 写 平均延迟 (ms)> 10ms 慢
aqu-sz平均队列深度> 1 表示排队
%util设备繁忙占比持续 > 80% = 瓶颈

详见 iostat.md。

IO scheduler

$ cat /sys/block/sda/queue/scheduler
[mq-deadline] bfq none kyber
                          ^^^^^^
                          中括号 = 当前用的
Scheduler适合
noneNVMe SSD(硬件自己调度)
mq-deadline通用 / SSD / 默认
bfq桌面 / 公平性
kyber低延迟

NVMe 用 none、SATA SSD 用 mq-deadline。通常不需要改。


6. 容器和存储栈的关系

flowchart TB
    subgraph PodA[Pod A 视角]
        AppA["应用<br>看到 /data/file.txt"]
    end
    subgraph Container[容器 mount namespace]
        Mnt["容器内 /data<br>= bind mount<br>/var/lib/kubelet/pods/abc.../volumes/.../foo"]
    end
    subgraph Host[节点 root mount namespace]
        Host1["/var/lib/kubelet/pods/abc.../volumes/.../foo<br>是 bind mount"]
        Host2["实际数据: /mnt/ssd/pvc-xxx/foo<br>(被 CSI driver 挂的盘)"]
        Host3["底层: /dev/sda1 (块设备)"]
    end

    AppA --> Mnt
    Mnt -.bind mount.-> Host1
    Host1 -.bind mount.-> Host2
    Host2 -.mount.-> Host3

    style PodA fill:#e1f5ff
    style Container fill:#ffe1c4
    style Host fill:#e1ffe1

同一份数据、4 个视角:

  • 应用代码里:/data/file.txt
  • 容器 mount ns 里:/data/file.txt
  • 节点 host 视角:/var/lib/kubelet/pods/abc.../volumes/.../foo/file.txt
  • 底层块设备:/dev/sda1 上的某些 sector

排错时经常被这种"层层 mount" 转晕。下一篇 01-container-volumes.md 专门讲这个。


7. 反面教材合集

反面 1:以为 rm 立刻释放空间

$ df -h /
/dev/sda3   100G    95G  5.0G  95% /

$ rm /var/log/huge.log                # 10G
$ df -h /
/dev/sda3   100G    95G  5.0G  95% /  ← 没变!

某进程还持有这个文件的 fd:

$ lsof | grep deleted
rsyslogd  ...  /var/log/huge.log (deleted)   ← 还在用

Linux 语义:文件被打开时不会真删,直到所有 fd 关闭。

修:重启那个进程(systemctl restart rsyslog)或者 > /proc/<PID>/fd/<N> 截断。

反面 2:以为 ext4 文件系统的 5% 保留是浪费

$ df -h
/dev/sda3   100G    95G  0K  100% /     ← 怎么是 100%、不是 95%?

ext4 默认保留 5% 给 root 用、防止填满后系统无法操作。用户视角看到的"满"是 95% 实际盘空间。

调整:

tune2fs -m 1 /dev/sda3          # 留 1%

K8s 数据盘可以设 0(tune2fs -m 0)。根分区保留 5% 是有道理的、不要全清。

反面 3:写 page cache 之后突然断电以为数据保住

应用 write() 返回成功 → 数据在 page cache(内存)→ 断电就丢。

关键应用(数据库、消息队列)应主动 fsync。 K8s 的 PV 突然 detach(节点挂)也会丢未 fsync 的数据。

Reliability checklist

如果你的应用要"数据不丢"——

  1. 关键写之后显式 fsync() / f.flush() + os.fsync()
  2. 数据库 fsync=on(PostgreSQL)/ innodb_flush_log_at_trx_commit=1(MySQL)
  3. 用 SSD/NVMe(fsync 快)+ 大电池 RAID 卡(battery-backed write cache)
  4. K8s PV 节点故障切换前 detach → 节点要稳

:::

反面 4:用 du 和 df 看到的大小不一致

$ du -sh /var
50G

$ df -h /var
60G used                             ← 多 10G?

du 看文件总和、df 看 fs 元数据:

  • 文件被 rm 但有进程持有 fd → df 计、du 不计
  • 文件含 hole(sparse file)→ du 看物理用量、df 一样
  • 文件系统的 journal / 备份超级块 → df 计、du 不计

修:"找进程持文件" 用 lsof | grep deleted。

反面 5:以为 K8s emptyDir 在节点重启后保留

volumes:
  - name: cache
    emptyDir: {}

emptyDir 是pod 生命周期——pod 删除(不是 restart container)= 卷消失。 节点重启 pod 重新调度 = 视为新 pod = 空 emptyDir。

要持久 → PVC。

详见 02-k8s-volumes.md。


8. 排查 cheatsheet

"磁盘满了" 4 种"满"

flowchart TD
    Full[节点报磁盘满] --> Q1{df -h<br>哪个 mount 满?}
    Q1 -->|root /| R1[查 du -sh /* / journalctl 清]
    Q1 -->|/var/lib/containerd| R2[crictl rmi --prune]
    Q1 -->|/var/log| R3[journalctl --vacuum-size]
    Q1 -->|没看出哪满| R4[df -i 看 inode]
    R4 --> Q2{inode 满?}
    Q2 -->|是| R5[找大量小文件清]
    Q2 -->|否| Q3{lsof 看 deleted}
    Q3 -->|有| R6[重启占用进程释放]
    Q3 -->|没| R7[overlay 层 dangling]

8 个救命命令

# 1. 哪个 mount 满
df -hT
df -h --total                                # 全部

# 2. inode 满吗
df -i

# 3. 哪个目录占空间
du -sh /var/* 2>/dev/null | sort -hr | head

# 4. 进程持着已删的文件
lsof | grep deleted | sort -k7 -hr | head

# 5. 大文件 top 10
find / -xdev -type f -size +100M 2>/dev/null -exec ls -lh {} \; | sort -k5 -hr | head

# 6. 容器 / 镜像清理
crictl rmi --prune                           # K8s 节点用 crictl
docker system prune -af --volumes            # docker 本地
journalctl --vacuum-size=500M

# 7. ext4 保留率调
tune2fs -l /dev/sda3 | grep Reserved         # 看当前
tune2fs -m 1 /dev/sda3                       # 调 1%

# 8. K8s pod / volume 占用
du -sh /var/lib/kubelet/pods/*/volumes/* | sort -hr | head

9. 全系列回顾

篇内容
本篇存储心智模型
01-container-volumes.md容器挂载完整指南 + 多视角
02-k8s-volumes.mdK8s Volume 类型大全
03-pv-pvc-storageclass.mdPV / PVC / CSI 深入
04-nfs-deep.mdNFS 深入
05-distributed-storage.mdCeph / Longhorn 概览
06-storage-troubleshooting.md故障排查 runbook

命令文档

存储相关命令独立文档:

  • lsblk / df / du —— 看盘 / 看空间
  • mount / umount —— 挂载基础
  • mkfs —— 格式化
  • fstab —— 持久挂载
  • parted —— 分区
  • lvm —— LVM 套件
  • findmnt —— 现代挂载查看
  • iostat —— I/O 性能
  • iotop —— 进程级 I/O
  • smartctl —— 盘健康
  • nfsstat —— NFS 状态
在 GitHub 上编辑此页
Next
容器挂载完整指南:bind mount / volume / mount propagation / 多视角