一、从一次线上事故说起
我有个朋友,他们的团队用容器跑一套数据密集型的应用,存储后端用的是RBD(Ceph的块设备)。一开始一切正常,后来为了做测试,他们给一个数据卷打了个快照,然后又从这个快照克隆了个新卷挂到测试容器里。结果呢?测试容器跑了没几分钟,整个集群的写入性能像坐滑梯一样往下掉,监控上看到IO延迟从几毫秒飙到了几百毫秒。最后排查了半天,问题出在快照和克隆的“写时复制”(COW)机制上。今天咱们就用大白话聊聊这个坑是怎么来的,以及怎么避免。
二、先理解快照和克隆到底是啥
2.1 快照:记录那一刻的“照片”
RBD快照就是一个时间点的副本,但它不是把数据全复制一份,而是记录“从这一刻起,哪些数据块发生了变化”。打个比方,你有一本笔记本,快照就像拍了一张当前页面的照片。之后你在本子上改内容,照片里还是原来的样子。RBD通过指针管理这些数据块,快照创建后,原本的数据块变成只读的,新的写入会分配到新的位置。
2.2 克隆:从快照长出“新枝”
克隆是基于快照创建的新的可写块设备。你可以把一个快照当作模板,克隆出的卷和快照共享那些未改变的数据块。Ceph的“快照克隆”(也叫COW克隆)就是这种机制:克隆卷里的数据块,如果没被修改过,就指向快照的底层数据块;一旦写入,就先把原数据块复制一份出来,再修改副本,并把指针指向新副本。
2.3 名词别怕,看个图(虽然没图但能想象)
想象有一个大数组,每个元素对应一个数据块。快照和克隆共享这个数组的指针。当你往克隆里写数据时,系统发现该数据块被共享,就把旧的数据内容复制到一个新位置,然后在新位置上改。这就是“写时复制”。
三、在容器环境里,坑在哪里
容器环境下,很多场景会频繁使用快照和克隆:比如CI/CD流水线里每个构建环境都从同一个基础镜像克隆;比如测试环境要快速创建多个数据库实例;还有数据分析团队经常克隆数据卷做实验。这些场景都会触发COW机制,但很多架构师没意识到它带来的性能隐患。
3.1 大块写入会触发大量复制
COW的“复制”不是复制你写入的那部分,而是整个对象块(通常4MB)。比如你往文件里追加了1KB数据,如果这个文件所在的块是共享的,RBD会先读出来整整4MB的旧数据,写到另一个地方,再把新数据合并进去。这一读一写,就是8MB的IO搬运。如果负载是大量小随机写入,那系统会陷入“复制—写”的循环,性能自然雪崩。
# 观察COW引起的额外IO,可以使用Ceph的admin套件命令
# 假设pool名为mypool,镜像名为test-clone
rbd du mypool/test-clone
# 你会看到类似输出,其中used是真实消耗空间,snapshot表示快照占用的空间
# 注意:这里的used不代表IO次数,要监控IO可以用ceph osd perf
3.2 对象没有打散,热点集中
容器编排系统(比如Kubernetes)一般会为每个Pod分配独立的存储卷。如果你用同一个快照克隆出几十个卷,这些克隆卷的数据块一开始都指向同一个底层OSD组。所有写入都集中在这几个OSD上,产生热点。而且这些OSD同时要处理来自所有克隆的COW复制操作,CPU和磁盘都会飙高。
四、一个完整的示例:Kubernetes里踩坑全过程
为了让你看得更明白,我们用 Kubernetes + Ceph RBD 的CSI插件模拟这个场景。技术栈:Kubernetes,Ceph CSI,rbd命令。
4.1 准备工作
先部署一个Ceph集群,创建pool和初始镜像。我们创建一个2GB的块设备,写满数据,代表“干净的基础镜像”。
# 创建pool(如果还没有)
ceph osd pool create mypool 64 64
# 创建初始镜像
rbd create --size 2048 mypool/base-image
# 映射并格式化(这里演示,实际容器里不会直接映射)
sudo rbd map mypool/base-image
sudo mkfs.ext4 /dev/rbd0
sudo mount /dev/rbd0 /mnt/base
# 写入一些数据,模拟“有内容的镜像”
sudo dd if=/dev/urandom of=/mnt/base/data.bin bs=1M count=500
# 卸载并取消映射
sudo umount /mnt/base
sudo rbd unmap /dev/rbd0
# 创建快照
rbd snap create mypool/base-image@snap-v1
4.2 创建克隆并挂载到容器
现在用这个快照克隆出5个卷,然后部署一个Deployment,每个副本挂载一个克隆卷。
apiVersion: apps/v1
kind: Deployment
metadata:
name: cow-writer
spec:
replicas: 5
selector:
matchLabels:
app: cow-writer
template:
metadata:
labels:
app: cow-writer
spec:
containers:
- name: writer
image: busybox
command: ["/bin/sh","-c"]
args:
- |
# 持续写入随机数据,模拟高频写入
while true; do
echo "writing..." >> /mnt/test/random.txt
dd if=/dev/urandom of=/mnt/test/random.bin bs=1M count=1 conv=fdatasync
sleep 0.1
done
volumeMounts:
- name: data
mountPath: /mnt/test
volumes:
- name: data
persistentVolumeClaim:
claimName: cow-pvc
先创建PVC(这里需要先创建对应的StorageClass)。
# 创建StorageClass,使用RBD
cat <<EOF | kubectl apply -f -
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: rbd-sc
provisioner: rbd.csi.ceph.com
parameters:
pool: mypool
clusterID: ceph
csi.storage.k8s.io/controller-expand-secret-name: csi-rbd-secret
csi.storage.k8s.io/controller-expand-secret-namespace: default
csi.storage.k8s.io/node-stage-secret-name: csi-rbd-secret
csi.storage.k8s.io/node-stage-secret-namespace: default
reclaimPolicy: Delete
EOF
再用脚本创建5个PVC,每个PVC通过快照克隆创建。注意,每个PVC的dataSource引用同一个快照。
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: cow-pvc-1
spec:
storageClassName: rbd-sc
dataSource:
name: base-image@snap-v1
kind: VolumeSnapshot
apiGroup: snapshot.storage.k8s.io
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
如果你把这样一个PVC的副本数增加到50个,每个Pod都在写,COW机制的放大效应就会显现。我在一个模拟环境里测试过,当并发写克隆卷的Pod数量超过10个,每个Pod的写入延迟就会从0.5ms增加到30ms以上,而且Ceph的OSD CPU使用率接近100%。
4.3 为什么延迟会飙升
核心原因有三个:
- 读旧块:每个写入请求都要先读取对应的原始块(从快照中)。
- 写新块:把读出来的旧块写入到一个新对象,之后再把修改后的数据写进去。也就是说,一次逻辑写,在RBD层变成了“一读一写”甚至“一读两写”。
- 锁竞争:同一个底层对象可能被多个克隆卷同时写,RBD内部需要加锁保证一致性,导致等待。
五、COW机制的详细剖析
5.1 什么时候才复制?
不是所有写都触发复制。只有当你往一个“被共享的数据块”中写入内容时才会触发。如果某个数据块已经被之前的写操作“私有化”了(即复制过了),后续再写就不用再复制。RBD内部通过父子关系维护这个状态。但问题是,容器环境里的写入模式往往都是“先写几个小文件”,每个文件落在不同的数据块上,所以几乎所有块都会被触发一次复制,代价极高。
5.2 空间放大
快照克隆后,克隆卷本身一开始几乎不占额外空间,但随着写入增多,每个修改过的块都会占一份新拷贝。如果修改频繁,空间会迅速膨胀。我们曾见过一个2GB的克隆卷,在写入600MB数据后,实际占用的空间达到了1.5GB,因为很多4MB的块只修改了几KB,却被整个复制。这种空间放大还会导致缓存命中率下降,因为数据块散落在不同对象里。
5.3 和普通卷对比
普通RBD卷没有父快照,写入时直接覆盖原块,没有复制这一步。而克隆卷的写入路径多了一道“检查父块—复制—写新块”的工序。指令路径更长,延迟自然更高。你可以把COW理解为“每次写都要先抄一遍旧数据”,就像你在图书馆借了一本参考书,每次你想在上面做笔记,都得先复印一整页,再在复印件上写,你说慢不慢。
六、避开陷阱的实用方法
6.1 用“全链克隆”而不是“快照克隆”
Ceph RBD支持两种克隆:
- COW克隆(快照克隆):空间节省,但写放大严重。
- 全量克隆(非COW):完全复制快照的数据,形成独立的镜像。虽然创建时间更长,需要拷贝所有数据,但之后就完全独立,没有COW开销。
如果你的场景是“创建后长期运行,并且有大量写操作”,强烈建议用全量克隆。
# 创建一个全量克隆(不使用快照的COW克隆)
rbd clone --no-provision mypool/base-image@snap-v1 mypool/full-clone-1
# 不是,这个命令不对。正确的全量克隆是:先创建快照,然后复制
# 假设已有快照,复制成一个独立镜像
rbd snap protect mypool/base-image@snap-v1
rbd clone mypool/base-image@snap-v1 mypool/full-clone-temp
# 为了让它完全独立,需要执行“flatten”操作,把间接块全部展平
rbd flatten mypool/full-clone-temp
看,flatten命令就是把所有共享块都拷贝一遍,让镜像完全独立。虽然这样做之后,镜像会消耗快照所在的所有数据大小,但写入性能就不再受COW影响了。
6.2 优化写入模式
如果不得不用COW克隆,可以调整应用层的行为:
- 大块顺序写:把随机小写入合并成顺序的大写入,减少触发复制次数。
- 预分配并写入整个块:比如把文件大小设为4MB的整数倍,一次写满整个块,让COW更容易“整块替换”。
- 禁用部分文件系统特性:比如ext4的延迟分配,可能会让小块写入跨块边界,增加COW次数。
6.3 利用Ceph的“写归并”机制
Ceph的写路径本身会把多个小IO合并到同一个对象中。在容器环境里,如果你用文件系统,尽量保证每个容器写的数据落在同一个对象内。可以通过调整对象大小(rbd_default_object_size)来匹配你的IO模式。默认是4MB,如果你的写入是1MB,可以设成1MB,降低“写一个块复制整个4MB”的浪费。
# 创建镜像时指定对象大小2MB
rbd create --size 1024 --object-size 2M mypool/test2
6.4 设置合理的快照保留策略
快照太多了会拖累父镜像的IO,因为每次写克隆卷都要在快照链中查找父块。建议只保留必要的快照,并及时删除老快照。
七、注意事项与坑中坑
7.1 快照保护
如果克隆还在使用中,快照无法删除。如果你删除了“被保护”的快照,Ceph会报错。很多同学不懂,就强行删快照,结果导致克隆卷的数据丢失。一定要注意,rbd snap protect和rbd snap unprotect的时机。
# 查看快照保护状态
rbd snap ls --format=json mypool/base-image
# 如果克隆还在用,先unprotect会报错
# 正确做法是:先删除所有克隆,再unprotect,再删快照
7.2 OSD的缓存和内存
COW的复制操作会把旧数据从OSD读出来,这个操作会占用OSD的缓存。如果多个克隆卷同时工作,OSD内存会被大量“复制缓冲”占掉,导致缓存命中率下降,进一步拖慢所有IO。我们可以监控OSD内存使用:
ceph daemon osd.0 perf dump | grep -E "cow|copy"
7.3 容器编排层面的问题
Kubernetes CSI插件在创建克隆卷时,默认使用COW克隆。有些存储类允许指定参数flattenOnClone为true,这样CSI会在克隆完成后自动执行flatten操作,让卷变回独立卷,但创建时间会变长。如果你追求快速启动,又不希望后期写性能受损,可以考虑这个参数。
# StorageClass示例:自动展平
parameters:
pool: mypool
flattenOnClone: "true"
7.4 测试你的真实场景
建议上线前做一次“老化的克隆卷测试”:从快照克隆出10个卷,连续写4小时,看延迟变化趋势。很多问题不是一开始就出现的,而是当COW复制累积到一定程度后突然爆发。
# 模拟连续写,记录延迟
fio --name=cowtest --ioengine=libaio --direct=1 --rw=randwrite \
--bs=4k --size=1G --runtime=300 --time_based --group_reporting \
--directory=/mnt/test
如果这个fio测出来的p99延迟超过你容灾标准的3倍,你就得认真考虑是否使用COW克隆了。
八、总结:什么时候该用什么
COW快照克隆在容器环境里适合这些场景:
- 只读工作负载(比如构建完的镜像,只用来启动容器读数据)。
- 数据量小且写入频率低(比如配置文件)。
- 临时环境,用完即删(比如测试会话)。
而它不适合:
- 持续高速写入的数据库。
- 大量随机小IO的文件服务。
- 所有Pod同时启动并写入的重负载场景。
如果你需要高性能、低延迟,就多花点时间做全量克隆(flatten),或者干脆直接用RBD镜像创建PVC,不要节约那点存储空间。存储空间和性能之间总要取舍,但性能一旦崩起来,业务损失远大于你节省的磁盘。
另外,随着Ceph版本更新,COW机制也在不断优化,比如新的“rbd_discard”和“对象映射”特性,但底层那套“复制旧块”的逻辑还在。所以,理解并规避这个坑,是每一个容器存储工程师的必修课。希望这篇博客能帮你少踩一次雷。
Comments