存储心智模型:从一次 write() 到磁盘
容器和 K8s 存储的所有"魔幻"都是建立在 Linux 存储栈 之上。一次应用调用
write()到数据真正落到盘上、要穿过 6-7 层抽象。这一篇把这些层讲清楚——后面所有存储话题(PV / NFS / CSI / mount propagation)都是基于这套心智模型。
这篇要回答什么
- 块存储 / 文件存储 / 对象存储 —— 区别在哪?
- "VFS"、"page cache"、"block device" 各是干啥的?
- 我应用调
write(),到底什么时候真落到盘上? - 为啥
rm文件磁盘空间不释放?
- K8s PVC 背后到底是块设备还是文件系统?
- inode 满了 是什么概念?怎么发生的?
- fsync / O_DIRECT / O_SYNC 这些应用层标志影响 K8s 存储吗?
- ext4 / xfs / btrfs 在 K8s 节点上选哪个?
- 磁盘 IOPS / 吞吐 / 延迟三者的关系 + 不同负载该看哪个?
- 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) | 块 / 单 pod | K8s 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 管理、目录、权限、journal | tune2fs -l / xfs_info |
| page cache | 内存里缓存最近读写的文件页 | free -h(buff/cache) |
| block I/O | 合并相邻请求、排序、IO scheduler | iostat -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 节点常见问)
| 特性 | ext4 | xfs |
|---|---|---|
| 成熟度 | 极成熟 | 成熟 |
| 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 | 适合 |
|---|---|
none | NVMe 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
如果你的应用要"数据不丢"——
- 关键写之后显式 fsync() /
f.flush()+os.fsync() - 数据库
fsync=on(PostgreSQL)/innodb_flush_log_at_trx_commit=1(MySQL) - 用 SSD/NVMe(fsync 快)+ 大电池 RAID 卡(battery-backed write cache)
- 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。
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.md | K8s Volume 类型大全 |
| 03-pv-pvc-storageclass.md | PV / PVC / CSI 深入 |
| 04-nfs-deep.md | NFS 深入 |
| 05-distributed-storage.md | Ceph / Longhorn 概览 |
| 06-storage-troubleshooting.md | 故障排查 runbook |