Series · Linux · Chapter 3

Linux 磁盘管理

Linux 磁盘从识别到上线的完整链路:用 lsblk 看清块设备、用 GPT 分区、用 ext4 / xfs 格式化、用 /etc/fstab 持久化挂载,用 LVM 在线扩容,并讲清 df 与 du 不一致、删了文件空间不回收等典型故障的真正成因。

线上的磁盘问题,几乎从来都不是“敲一两条命令”就能搞定的。你面对的是一摞分层的栈:底下是块设备(一块物理盘或一块云盘),上面是分区表(MBR 或 GPT),可选地夹一层 LVM 把文件系统从具体磁盘解耦出来,然后是文件系统驱动(ext4、xfs、btrfs)赋予原始字节“文件”的语义,最后是挂载点——应用真正打开文件的那个目录路径。我见过的大多数线上故障,只要你能说出“现在卡在哪一层”,就已经赢了一半。

本文沿着一条端到端的工作流走:识别一块新盘 → 分区 → 格式化 → 持久化挂载 → 用 LVM 在线扩容 → 排查典型故障。每一步都会讲清底层机制,目的是让你之后能“推理”系统在做什么,而不是死记一堆命令。

文件系统层级:东西到底放在哪里

Linux 文件系统层级

Linux 遵循 Filesystem Hierarchy Standard(FHS)/ 下每一个一级目录都有明确的职责,理解了这套约定,“哪些目录该独立成一块卷”这种决策就会变得很自然。

目录用途
/整棵目录树的根;操作系统本身。
/bin/sbin系统启动所必需的可执行文件。
/etc系统级配置文件(纯文本)。
/home各用户的家目录。
/usr安装的软件和库(/usr/bin/usr/lib)。
/var经常变化的数据:日志、邮件队列、包缓存、部分数据库。
/tmp临时文件;多数发行版上是 RAM 中的 tmpfs
/dev设备节点(/dev/sda/dev/null 等)。
/proc/sys内核暴露出来的虚拟文件系统。
/mnt/media习惯性的挂载点,用来挂可移动盘或额外存储。

生产环境里非常常见的布局是把 //var/home/data 放在不同的块设备上。这么做的理由完全是运维上的考虑:日志失控写满 /var 时,根分区还有空间登录和救援;/data 需要扩容时可以只动那一块卷,不影响系统盘。

块设备与命名:磁盘在 Linux 里长什么样

每一块挂到内核上的磁盘都会变成 /dev 下的一个块设备节点。命名规则编码了它走的是什么总线:

路径含义
/dev/sda/dev/sdbSATA / SAS / USB 盘(走 SCSI 子系统)
/dev/nvme0n1NVMe 固态盘,namespace 1
/dev/vdavirtio 盘(KVM / 云主机)
/dev/xvdaXen 虚拟盘(早期 AWS 实例)
/dev/sr0光驱

分区在后面接数字;NVMe 还会多一个 p 隔开:/dev/sda1/dev/nvme0n1p1

动手做任何破坏性操作之前,先跑这三条命令:

1
2
3
lsblk -f                    # 块设备树,含文件系统类型和 UUID
sudo fdisk -l               # 详细的分区表
sudo blkid                  # 每个已格式化分区的 UUID

lsblk -f 是这篇文章里最有用的一条命令——它一次列出块设备树、每个分区上的文件系统、当前挂载点和 UUID。如果别的都忘了,就记住它。

命名陷阱:/dev/sdb 为什么会“变”

设备名是内核启动时按枚举顺序分配的。多插一块盘、HBA 探测顺序不同、或者云主机做了一次实时迁移,原来的 /dev/sdb 就可能突然变成了 /dev/sdc永远不要把 /dev/sdX 写进 /etc/fstab 用稳定的标识符挂载:

  • UUID——格式化时分配,跟随文件系统的整个生命周期不变(UUID=8f1c-...)。
  • /dev/disk/by-id/...——厂商 + 序列号;当你需要知道“这是哪块物理盘”时很有用。
  • /dev/disk/by-label/...——格式化时设置的人类可读标签。

分区表:MBR vs GPT

MBR 与 GPT 分区布局对比

分区表写在磁盘开头,告诉操作系统这块盘是怎么切分的。Linux 支持两种格式。

MBR(Master Boot Record) 是从 IBM PC 时代继承下来的老格式。整张分区表就塞在磁盘最开头那一个 512 字节扇区里。它的所有限制都是从这个事实直接推导出来的:

  • 32 位 LBA 寻址,最大可寻址容量 2 TiB
  • 最多 4 个主分区。再多就要建一个“扩展分区”,里面再开“逻辑分区”——一种历来都嫌别扭的折中方案。
  • 整张表只有一份。第一个扇区一旦损坏,分区信息就没了。

GPT(GUID Partition Table) 是 UEFI 规范定义的现代替代方案,完整解决了 MBR 的所有痛点:

  • 64 位 LBA——容量上限基本没有边界(ZB 量级)。
  • 默认最多 128 个分区,每个分区都用 GUID 命名。
  • 在磁盘开头放一份主表头,结尾放一份备份表头;两者都有 CRC32 校验和——损坏可被检测,也能恢复。
  • 第一个扇区放一个“保护性 MBR”,让那些不认 GPT 的老工具看到的是“一整块未知分区”,不会以为是空闲空间被覆盖。

默认就用 GPT,除非有非常具体的理由(很老的 BIOS 只能从 MBR 引导,或者要双系统装一个 32 位的 Windows)。所有现代发行版和云上系统都默认用 GPT。

工具:fdisk、gdisk、parted

  • fdisk——历史上只支持 MBR;现代版本也能处理 GPT。
  • gdisk——专门面向 GPT,操作上更明确。
  • parted——MBR / GPT 都支持,还有非交互的批处理模式,方便写脚本。

fdisk 的典型交互流程:

1
2
3
4
5
6
7
sudo fdisk /dev/sdb
# p   打印当前分区表
# g   建一张全新的空 GPT 表
# n   新建分区(一路回车用整盘默认值即可)
# w   把分区表写盘并退出
sudo partprobe /dev/sdb     # 让内核重新读取分区表
lsblk -f /dev/sdb           # 确认新分区已经出现

parted 写成非交互的等价脚本:

1
2
sudo parted -s /dev/sdb mklabel gpt
sudo parted -s /dev/sdb mkpart primary ext4 1MiB 100%

这里的起点 1MiB 不是为了好看——它保证分区对齐到 1 MiB 边界,与现代盘的 4 KiB 物理扇区匹配。分区不对齐时,文件系统的一次 4 KiB 逻辑写入会变成两次物理读 + 两次物理写,是一种沉默而恼人的性能杀手。现代工具默认就这么做,但你应该知道这条规则的存在。

文件系统:把分区变成“能用的东西”

分区只是磁盘上一段连续的字节区间。要在里面存有名字的文件,得先格式化——往里写入文件系统的盘上数据结构(superblock、inode 表、空闲块位图、日志等等),让内核里的文件系统驱动能解释这些字节。

ext4 / xfs / btrfs 对比

在 Linux 服务器上你真正会遇到的就这三种文件系统:

  • ext4——Debian / Ubuntu 的默认,也是通用工作负载的稳妥之选。工具链成熟(fscktune2fse2label),失败模式经过充分研究,性能可预测。
  • xfs——RHEL 7+ 及衍生发行版的默认。为大文件、高并行、超大文件系统设计。不能缩小——只能扩或者迁。
  • btrfs——写时复制(CoW),原生支持快照、对数据元数据都做校验和、内置 RAID 0/1/10。openSUSE 和 Fedora Workstation 默认。复杂度更高,某些配置历史上口碑一般;生产里用它,多半是冲着快照能力去的。

格式化

1
2
sudo mkfs.ext4 -L data /dev/sdb1     # ext4,标签为 "data"
sudo mkfs.xfs  -L data /dev/sdb1     # xfs 等价命令

格式化完,lsblk -f 就会显示文件系统类型、标签和 UUID。把 UUID 记下来——后面写 /etc/fstab 要用。

临时挂一下

1
2
3
sudo mkdir -p /mnt/data
sudo mount /dev/sdb1 /mnt/data
df -h /mnt/data

挂载是纯运行时操作。它不会改盘上任何东西,只是告诉内核:“从现在起,/mnt/data 下的路径请由 /dev/sdb1 上的文件系统提供服务。”重启之后,挂载就没了。

持久化:/etc/fstab

挂载点与挂载表

/etc/fstab 是持久化的挂载表——开机时 systemd(或者 init 脚本里的 mount -a)会把里面列出的全部挂上。

挂载永远用 UUID,绝不要用 /dev/sdX

1
2
sudo blkid /dev/sdb1
# /dev/sdb1: LABEL="data" UUID="8f1c-...-3a" TYPE="ext4"

/etc/fstab 加一行:

# <device>             <mount>   <fs>   <options>           <dump> <pass>
UUID=8f1c-...-3a       /mnt/data ext4   defaults,noatime    0      2

这六列分别是:

  1. 来源——UUID、label,或设备路径。
  2. 挂载点——必须是已经存在的空目录。
  3. 文件系统类型——ext4xfstmpfsnfs 等。
  4. 挂载选项——defaults,再叠上像 noatime(不更新访问时间,对读多场景收益很大)、ronosuidnodevdiscard_netdev 之类。
  5. dump——固定写 0(老的 dump 备份工具早就退场了)。
  6. fsck pass——/1,其他文件系统写 2,跳过检查写 0

改完一定要先验证再重启。 /etc/fstab 写错会让系统启动到救援模式起不来。安全的做法是:

1
2
sudo mount -a       # 立刻按 fstab 全部挂一遍
mount | grep data   # 确认新挂载已经生效

mount -a 报错就先修好,再去重启。

卸载与 “target is busy”

1
sudo umount /mnt/data

最常见的失败是 umount: target is busy:还有某个进程持有挂载点下的文件描述符,或者把工作目录设在了挂载点下。定位这个进程:

1
2
sudo lsof +D /mnt/data       # 挂载点下被打开的文件
sudo fuser -vm /mnt/data     # 占用挂载点的进程及 PID

把对应进程 kill 掉或者重启。实在没办法时,umount -l /mnt/data 做“懒卸载”——挂载点立刻从命名空间消失,等到最后一个文件描述符关闭才真正释放。慎用,它会掩盖真正的问题。

LVM:把文件系统从物理盘解耦出来

LVM 三层栈:PV → VG → LV

不用 LVM 时,文件系统直接坐在分区之上,扩文件系统就要扩分区,扩分区又要求紧挨着分区后面有连续的空闲空间。在生产环境里这条件几乎不成立。

LVM(Logical Volume Manager) 在物理盘和文件系统之间塞了一层间接层。它有三个核心概念:

  • PV — Physical Volume(物理卷):被 LVM “认领”的整盘或某个分区。(pvcreate
  • VG — Volume Group(卷组):由一个或多个 PV 组成的容量池。VG 内部按固定大小的**物理扩展(PE)**切片管理(默认 4 MiB)。(vgcreatevgextend
  • LV — Logical Volume(逻辑卷):从 VG 里切出来的虚拟块设备。在内核眼里,LV 长得就跟一个分区一样,你在它上面建文件系统。(lvcreate

关键洞见:LV 在底层 PV 上不必连续。 给 LV 扩容,本质就是从 VG 池子里多分配一些 PE,这些 PE 可以来自任何 PV——所以不需要重新分区也能扩容

搭起这套栈

1
2
3
4
5
sudo pvcreate /dev/sdb /dev/sdc                    # 把两块盘交给 LVM
sudo vgcreate vg_data /dev/sdb /dev/sdc            # 合成一个卷组
sudo lvcreate -n lv_app -L 100G vg_data            # 切一个 100G 的 LV
sudo mkfs.ext4 /dev/vg_data/lv_app
sudo mount /dev/vg_data/lv_app /data

任何时候都能查看当前栈的状态:

1
2
3
4
sudo pvs        # 物理卷一览
sudo vgs        # 卷组一览
sudo lvs        # 逻辑卷一览
sudo pvdisplay  # 详细信息,排查问题时用

在线扩容:最小停机的标准动作

/data 用到 80%,告警越来越频繁。LVM 把扩容做成了在线操作,整个过程只要几秒:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 1. 挂一块新盘上来,把它注册成 PV。
sudo pvcreate /dev/sdd

# 2. 加进已有的 VG。
sudo vgextend vg_data /dev/sdd

# 3. 扩 LV。+SIZE 是绝对增量;
#    +100%FREE 表示把 VG 剩下的空间全吃掉。
sudo lvextend -L +200G /dev/vg_data/lv_app
# 或:sudo lvextend -l +100%FREE /dev/vg_data/lv_app

# 4. 把上面的文件系统也撑起来。
sudo resize2fs /dev/vg_data/lv_app   # ext4,在线
# 或:sudo xfs_growfs /data          # xfs,必须在挂载状态下

整个过程没有重启服务,也没有数据搬迁。文件系统只是看到下面忽然多了块。

缩容是另一回事,难得多

  • ext4 可以缩,但必须离线:先 umount,跑 e2fsck -f,再 resize2fs <新尺寸>,最后 lvreduce。顺序错了文件系统就被截断了。
  • xfs 完全不能缩容。官方推荐的做法是“建一个更小的 LV,用 rsync -aHAX 把数据拷过去,然后切挂载点”。

经验法则:计划着扩,别计划着缩。 起手量给小一点,按需扩展。

快照(Snapshot)

LVM 可以给一个 LV 建写时复制的快照。快照本身也是一个 LV,最初和原 LV 共享所有 PE;原 LV 上的某个块发生变化时,变化前的内容会被复制到快照预留的空间里。

1
2
3
sudo lvcreate -L 10G -s -n lv_app_snap /dev/vg_data/lv_app
# 然后做点高风险的事,比如版本升级
sudo lvremove /dev/vg_data/lv_app_snap   # 不要了就删

快照适合做短时间一致性点(在快照上跑备份,让线上文件系统继续写入),也适合做快速回滚的窗口。但它不是备份——快照预留空间满了,快照就失效;底层 VG 整个挂了,原卷和快照一起没。

看用量:df vs du,以及它们为什么对不上

按理说应该给出同一个答案的两条命令,经常对不上号。

df文件系统:“你现在占了多少?”

1
2
df -h          # 每个挂载点的容量,人类可读
df -i          # inode 用量(不是看字节,而是看 inode)

du 是去遍历目录,把每个文件的大小相加:

1
2
sudo du -h -d 1 /var | sort -h    # /var 下一层的汇总,按大小排序
sudo du -sh /var/log              # 单个路径的总大小

df 报“满了”但 du 找不到罪魁祸首,常见原因有三个。

1. 进程还持有已删除文件的句柄

某进程把 /var/log/app.log 打开后写了好几周,有人把这个文件 rm 掉了。目录项消失——du 看不见、ls 看不见——但只要还有任何文件描述符引用这个 inode,inode 和数据块就不会被释放。进程继续写。磁盘继续满。

1
sudo lsof | grep '(deleted)'

修复方法是让那个进程关闭这个文件:重启服务、给它发 SIGHUP(如果它支持重新打开日志)、或者用 logrotatecopytruncate 模式(适用于不支持自我重开日志的进程)。

2. 挂载错了位置

你以为自己在看数据卷,实际上根本没挂上去,你正在把根分区写满。

1
2
findmnt /data       # 显示来源设备和文件系统类型
mount | grep data

findmnt 什么都没返回,说明 /data 只是 / 上的一个普通目录。

3. ext4 的保留块

ext4 默认会给 root5% 空间。设计意图是磁盘被普通用户写满时关键服务还能写。但在大数据卷上 5% 就是不小的一块,会让 df 在普通用户还能写之前就显示“满”。

1
2
sudo tune2fs -l /dev/sdb1 | grep -i 'reserved'
sudo tune2fs -m 1 /dev/sdb1     # 把保留比例降到 1%(谨慎)

只在不承载操作系统本身的卷上这么做。

inode 耗尽

如果工作负载狂建小文件(缓存、邮件队列、构建产物),文件系统的字节是空的、inode 却用满了。df -i 会显示 IUse% 100%,而 df -h 显示一堆空闲。唯一的修复方法是删文件,或者用更高的 inode 密度重新格式化(mkfs.ext4 -N <数量>)。xfs 的 inode 是动态分配的,基本不会撞这个问题。

inode、硬链接、软链接:理解文件系统行为的钥匙

每个 Unix 文件系统底下都有一个核心数据结构——inode。inode 存一个文件的全部元数据:类型、属主、权限、大小、时间戳、链接计数、指向数据块的指针。它不存文件名。文件名住在目录里,目录本身也是文件,内容是一张 (名字 → inode 号) 的映射表。

这个解耦能解释很多看上去奇怪的行为:

  • 硬链接——指向同一个 inode 的另一个目录项(ln src dst)。两个名字地位完全平等——没有“原文件”和“复制文件”的区别。删一个,inode 的链接计数减一;只有当计数归零时,数据才被回收。硬链接不能跨文件系统,按惯例也不能给目录建。
  • 软链接(符号链接)——一个内容是路径字符串的小文件(ln -s src dst)。有自己的 inode,能跨文件系统。目标被删后会变成“悬空链接”。
  • 同一文件系统内的重命名只是改写一个目录项,inode 和数据块完全不动——这就是它为什么是原子的、瞬间完成的。
  • “我把文件删了,盘却没释放空间”——目录项那边链接计数降到零了,但还有进程持着这个 inode 的文件描述符(这也算一份引用)。数据要等到那个描述符关闭才被释放。这跟前面 df/du 对不上是同一个机制。
1
2
3
stat /etc/passwd       # inode 号、链接数、各种大小和时间戳
ls -li                 # 第一列就是 inode 号
df -i                  # 每个文件系统的 inode 用量

/dev 下的特殊文件

不是每个 /dev 下的节点都对应硬件。有几个是纯内核抽象,但你会反复用到:

  • /dev/null——丢弃一切写入它的内容;读它直接返回 EOF。常用于静默输出:command > /dev/null 2>&1
  • /dev/zero——无限输出零字节流。常用于预分配文件或写零擦除:dd if=/dev/zero of=test.bin bs=1M count=1024
  • /dev/random/dev/urandom——熵源。几乎所有场景都该用 urandom;老 /dev/random 那种“熵不够会阻塞”的行为是早期内核的历史包袱,现代 Linux 上做密码学并不需要它。
1
2
3
# 一次粗略的 1 GiB 写入基准(忽略缓存,但够用)
dd if=/dev/zero of=/data/test.bin bs=1M count=1024 oflag=direct
rm /data/test.bin

端到端清单:从一块新盘到可用空间

把一块刚挂上的盘变成可用空间的标准路径:

  1. 识别新设备。lsblk -f 应该看到一块空的 /dev/sdb(或 /dev/nvme1n1),下面没分区,也没文件系统。
  2. 决定要不要走 LVM。 这块卷未来会扩容,就一开始就把它放到 LVM 下面。事后再加 LVM 要停机。
  3. 分区——用 GPT(fdiskparted)。如果要走 LVM,可以完全跳过分区步骤,直接对整盘 pvcreate,更简洁,少一层。
  4. 格式化——通用场景用 ext4,大文件 / 高并行用 xfs。
  5. 挂载验证mount /dev/sdb1 /mnt/data && df -h /mnt/data
  6. 持久化——往 /etc/fstab 写一条以 UUID 为来源的记录,跑 sudo mount -a 测一遍。
  7. 重启验证一次——选个维护窗口,在你真正依赖这块卷之前,跑一次完整的重启流程。

把这套清单跑顺了,多数磁盘故障就从“惊慌”变成了“按部就班”——下面的排障章节也就成了一份参考手册,而不是抢救脚本。

排障手册

“盘满了”但我刚删了好几个 G 的日志

几乎一定是某个进程持有已删除文件的句柄。

1
sudo lsof | grep '(deleted)' | sort -k7 -h

把那个进程重启,或者给它发信号让它重新打开日志文件。

“重启之后挂载失败”

按出现频率排:

  1. /etc/fstab 里的 UUID 写错了。用 blkid 核对。
  2. initramfs 里缺文件系统驱动(少见,但 btrfs / zfs / xfs 在最小化安装上偶尔会撞到)。
  3. 启动顺序问题:你尝试在 LVM / RAID / 网络起来之前挂一个依赖它们的路径。网络文件系统加 _netdev 选项;LVM 和软 RAID 一般 initramfs 会自动处理。

sudo mount -a 能复现开机时的挂载序列,journalctl -b | grep -i mount 看的是真正失败的细节。

“性能突然变差”

按层往下查,不要往上猜:

1
2
3
iostat -x 1        # 磁盘级利用率、await、IOPS
vmstat 1           # %wa 列就是 CPU 等 I/O 的时间占比
dmesg | tail -50   # 内核级 I/O 错误、SMART 警告

await 高、%util 低,通常是底层存储慢(云盘、网络存储拥塞)。%util 高、队列深度低,常常是单线程 fsync 类的负载。vmstat%wa 高、磁盘看起来又没问题,多半是在 swap——查 free -hswapon --show

文件系统忽然变成只读

内核检测到无法安全写入的损坏时,会把文件系统重新挂为只读。先看内核日志:

1
2
dmesg | tail -200
journalctl -k --since "1 hour ago"

如果底盘在挂(smartctl -a /dev/sda 显示有重映射扇区或介质错误),换盘。如果是文件系统本身坏了,先 umount,再跑离线修复——ext4 用 e2fsck -fy /dev/sdb1,xfs 用 xfs_repair /dev/sdb1数据重要的话,先做快照或 dd 镜像。

命令速查

事故现场可以直接抄的一份速查。

探查与查看

1
2
3
4
5
6
lsblk -f                   # 块设备 + 文件系统 + 挂载点 + UUID 一棵树
findmnt                    # 系统里每一个挂载点的树
mount | column -t          # 当前所有活动挂载
df -h                      # 文件系统用量(按字节)
df -i                      # 文件系统用量(按 inode)
sudo blkid                 # 每个已格式化分区的 UUID 与 TYPE

分区

1
2
3
4
5
sudo fdisk -l              # 列出所有盘的分区表
sudo fdisk /dev/sdb        # 交互式编辑(也能处理 GPT)
sudo parted -s /dev/sdb mklabel gpt
sudo parted -s /dev/sdb mkpart primary ext4 1MiB 100%
sudo partprobe /dev/sdb    # 让内核重新读分区表

格式化与查看文件系统

1
2
3
4
sudo mkfs.ext4 -L data /dev/sdb1
sudo mkfs.xfs  -L data /dev/sdb1
sudo tune2fs -l /dev/sdb1  | head        # ext4 的 superblock 信息
sudo xfs_info /data                      # xfs 的几何与挂载选项

挂载与持久化

1
2
3
4
sudo mount /dev/sdb1 /mnt/data
sudo mount -o remount,noatime /mnt/data  # 在线改挂载选项
sudo umount /mnt/data
sudo mount -a                            # 立即按 /etc/fstab 全部挂一遍

LVM

1
2
3
4
5
6
7
8
sudo pvs ; sudo vgs ; sudo lvs           # 三层一行式概览
sudo pvcreate /dev/sdb
sudo vgcreate vg_data /dev/sdb
sudo vgextend vg_data /dev/sdc           # 往池子里再加一块盘
sudo lvcreate -L 100G -n lv_app vg_data
sudo lvextend -L +50G /dev/vg_data/lv_app
sudo resize2fs /dev/vg_data/lv_app       # ext4 扩容
sudo xfs_growfs /data                    # xfs 扩容(注意是路径,不是设备)

排障

1
2
3
4
5
6
sudo lsof | grep '(deleted)'             # 删掉但仍被进程持有的文件
sudo fuser -vm /mnt/data                 # 谁在占用挂载点
iostat -x 1                              # 单盘 I/O 指标
vmstat 1                                 # I/O wait、swap、上下文切换
dmesg | tail -200                        # 最近的内核消息
sudo smartctl -a /dev/sda                # 磁盘健康(SMART)

两条凌晨三点会救你命的提醒

  • 任何破坏性命令之前,先跑一遍 lsblk -f 确认目标。 一秒钟的事,能避免“我格式化错了盘”这种灾难。
  • 每改完一层,就先确认上一层能看到这层的变化,再往下走。 块 → 分区 → LVM → 文件系统 → 挂载。某一层“消失”了就停下来查清楚,不要硬冲过去。

Liked this piece?

Follow on GitHub for the next one — usually one a week.

GitHub