☰
Ceph RBD镜像锁机制深度解析:从原理到生产故障排查
2026/10/3 9:10:55 网站建设 项目流程

我在生产环境里跟这玩意儿死磕过不少次。rbd镜像的锁,说白了就是Ceph给块设备加的一道“写入互斥闸门”,防止两个客户端同一时刻对同一块镜像下手写坏。锁一旦出问题,表现往往很典型:虚拟机起不来、迁移卡死、rbd map报busy,更恶心的是明明没有马在车上,锁却赖着不走。

这篇文章适合谁看?只要你的Ceph集群在用RBD提供存储——不管背后是OpenStack、KVM/QEMU、Proxmox,还是普通物理机直接mount——我都建议把全文读完再上岗。镜像锁的很多细节不在常规文档里,而在真实故障现场,尤其在你经历过高可用切换、集群重启、宿主机宕机之后,你一定会回来翻这篇文章。我会顺着“锁是什么 → 锁怎么工作 → 命令行怎么操作 → 故障怎么排查 → 参数怎么调优”这条线索,把整个锁机制从头到尾过一遍。

1. rbd镜像锁到底是什么

1.1 一句话定位

RBD(RADOS Block Device)镜像锁是librbd和krbd在镜像header对象上实现的一种分布式协作锁。你可以把它理解成文件锁,只不过锁的粒度不是某个文件,而是一整块虚拟磁盘。客户端打开镜像以后,如果接下来要写数据,就必须先拿到锁;拿到锁才能写,其他人要么只读等待,要么直接报Device or resource busy。

锁主要服务于两类场景。第一类是同一块镜像同时被多个客户端打开,必须串行化写操作,不然磁盘文件系统会在几秒钟之内被写成一锅粥。第二类是快照、克隆、在线扩容这类元数据操作,它们要重新读写镜像的元数据,如果这个时候有人在乱写数据,同样会出大问题,所以这些操作同样要走锁流程。

默认配置下,新建RBD镜像会开启exclusive-lock特性,标准客户端只要挂载就会自动加锁。这也解释了为什么很多事故本质上是同一个原因:挂载进程异常退出,锁没有立刻释放,下一个客户端在傻等。

1.2 没有锁会发生什么

以前有个测试环境,为了图省事把两个计算节点的虚拟机放在同一块裸盘上做共享存储,结果两块文件系统同时挂载,什么都没干,几分钟后磁盘出现了大量目录项错乱。这种事故不是Ceph的锅,是典型的没有锁的锅。RBD本质是一块带网络访问的磁盘,如果你把裸设备同时交给两台物理机去mount,后果和一块SCSI盘同时挂到两台机器上没有任何区别。除非底层是集群文件系统,否则就是在赌命。

有锁也不代表万能。锁实现不当,客户端A持锁写入,客户端B不遵守锁协议强行写入,一样损坏数据。所以RBD锁准确说是“协作锁”:它希望所有客户端都讲武德。值得庆幸的是,标准客户端(内核krbd模块、librbd库)确实都遵循这套协议,问题通常出在人为配置或异常进程上。

1.3 两种锁形态:独占锁和共享锁

RBD锁分两种类型。独占锁就是互斥锁,同一时刻只能有一个持锁者,符号是X锁,用途是写镜像。共享锁叫做S锁,允许多个客户端同时持锁,主要用于只读场景,比如多节点同时读同一个镜像做备份。

共享锁带一个tag,同tag的S锁互相兼容,不同tag的S锁互相冲突。这个设计看起来多余,实际非常实用。比如两套备份系统,如果它们用同一个tag的锁,说明约定在做同一种读操作,可以并行;如果tag不同,说明彼此业务逻辑可能对镜像做不同事情,就不能并发。这和分布式锁的经典套路对比,tag机制相当于给不同业务方划了一条看不见的道。

1.4 与文件锁、数据库锁的类比

很多人第一次接触RBD锁会问:这和Linux的flock、mysql的表锁有什么区别?本质都是“在一个公共位置记录谁占用了什么资源”。flock在内存里、mysql在数据字典里、RBD在RADOS对象的omap里。区别在于RBD锁要面对网络故障、节点宕机、时钟漂移,所以它的核心设计是:锁记录本身要持久化,同时要有超时机制,不能因为一个客户端死掉就永久锁死资源。理解了这一点,再看后面的watch机制就不会觉得奇怪了。

2. 锁是怎么落地的:从加锁到解锁

2.1 锁存在哪里

锁不是存在某个独立锁服务里,而是存在镜像的header对象上。每个RBD镜像都有一个元数据对象,锁信息写在对象的omap中,记录了锁的ID、cookie、客户端ID(形如client.1234)、网络地址这些字段。也就是说,锁可以持久化,也能被其他节点看见,关键是它必须依赖一个“活”的持锁客户端做心跳。

客户端对header对象注册一个watch。OSD根据watch判断客户端是否还活着,如果客户端一直不续约,超过watch超时时间,OSD就判断它死了,锁就变成陈旧锁。默认watch超时是30秒,所以客户端崩溃后,锁不会马上消失,最坏情况要等30秒甚至更久,才能被下一个客户端从逻辑上抢占。

2.2 加锁到解锁的完整链路

我习惯把整个流程拆成五步来记:

  1. 打开镜像,读取header元数据,确认这条镜像开了哪些features。
  2. 如果有写意图,尝试获取镜像锁,本质是往header对象的omap写一条锁记录。
  3. 如果锁已经被别人持有且冲突,获取锁失败。librbd会按配置重试,内核krbd模块通常直接报busy。
  4. 持锁期间,客户端要周期性续约watch,证明自己还活着。
  5. 正常退出时主动删除锁记录并撤销watch;异常退出只能等watch超时过期。

这个流程和分布式锁没有本质不同:一个共享存储点、一个持锁标记、一个心跳机制。只不过RBD的存储点是RADOS对象,心跳走的是watch/notify协议。

2.3 陈旧锁是怎么产生的

明白锁的机制后,再解释陈旧锁:持锁客户端崩溃、网络中断、被强杀,watch没有正常撤销,OSD过了watch超时后判定客户端失联,锁就在逻辑上“悬空”了。此时rbd lock ls看到的锁依然存在,但属于过期锁。

在实现上,陈旧锁不会被动消失,而是等下一个客户端尝试获取锁时,发现锁已过期并主动抢占。所以你会发现,有时候故障发生后什么都不做,过一两分钟业务自己又恢复了,就是其他客户端在后台把陈旧锁抢走了。反过来,如果没有任何客户端尝试抢锁,这把锁记录会一直躺在header对象上,直到你手动清或进行重启。所以定期检查锁列表是件很划算的事。

2.4 锁与blocklist的关系

这里要提醒一句,watch过期和OSD对客户端生死状态的判断是两套时间线。OSD通过心跳机制判断客户端节点是否整体不可达,如果它判定节点失联,可能触发blocklist操作,把节点踢出黑名单。节点一旦被blocklist,它持有的所有镜像锁都失去意义。

看锁问题的时候,不能只盯锁本身,还要看一眼mon日志里有没有blocklist事件。很多场景里,blocklist和陈旧锁是同时出现的,把被blocklist的节点清理掉之后,锁自然就好处理了。不要以为锁问题只是锁的问题,它常常是节点健康问题的投影。

3. 实际操作:用rbd命令管理镜像锁

3.1 查看锁状态

遇到锁相关的报错,第一步永远是看锁列表:

rbd lock ls pool-name/image-name

老版本写 rbd lock list 也行,两者等价。输出示例:

Pool Image ID Locker Address pool img auto client.1234 172.16.1.2:0/123456

如果锁已经变成陈旧锁,较新版本会在状态里标出stale。看到stale,基本可以放心清理。这里有个经验:用rbd lock ls之前,先确认命令背后连接的是正确的pool和namespace,曾经有人写错pool,查了半天查不到锁,实际上锁在另一个pool里。自动化脚本里我还习惯加 --format=json,方便后续解析和判断。

3.2 手动加锁与解锁

除了客户端自动锁,RBD也支持手动锁,常用在外部备份软件、容灾切换工具做业务协调。加锁:

rbd lock add pool-name/image-name backup-lock --shared backuptag

释放:

rbd lock remove pool-name/image-name backup-lock client.1234

手动锁和自动锁的区别在于,手动锁不会随进程崩溃自动过期,它完全由发锁方自己管理。写脚本时一定要在异常分支里清锁,否则下次备份会被自己当年留下的锁卡住。这是我在自动化脚本里踩过的最蠢的坑,没有之一。光备份脚本本身测通了不算数,测试故障注入路径时,手动锁残留问题一定会暴露。

3.3 强制清理残留锁

确认持锁客户端已经不存在后,清理标准动作:

rbd lock remove pool-name/image-name auto client.1234.xxx

auto是锁ID,rbd lock ls输出里ID列就是。

必须强调:强制清锁前,要确认原持锁者真的没有在写镜像。如果对方还在写,你删锁后它后续写入会失败,更麻烦的是别的客户端抢到锁开始写,两边同时写同一块数据。生产环境我宁可多等一下watch超时,也不轻易强制清锁,除非业务侧已经明确这台虚拟机被强杀了。

3.4 锁和镜像特性的组合使用

RBD锁不是孤立存在的,它和一组镜像特性绑在一起。exclusive-lock是最核心的特性,object-map、fast-diff、journaling这些特性都依赖它。比如要开journaling做灾备,必须先开exclusive-lock。所以你在创建镜像时如果手动指定了features,一定要留意是否把exclusive-lock关了,否则后面要么不能开journaling,要么多写者场景锁保护失效。

我见过有人为了省性能,把exclusive-lock关掉,结果做在线迁移时数据写乱。这种为了性能牺牲一致性的操作,在存储领域从来都不划算。真觉得锁开销大,可以先排查业务是否真的需要多写者,而不是一关了之。镜像创建时留一条规范化的features模板,比每次手工敲参数要可靠得多。

4. 排查实录:镜像锁引发的经典故障

4.1 内核挂载失败:Device or resource busy

这个报错出现频率最高。rbd map时报:

rbd: map failed: write to /sys/bus/rbd/devices: Device or resource busy

意思是锁被占。常见场景:虚拟机被强杀或宿主机崩溃,旧的krbd锁还在。处理顺序:先rbd lock ls确认锁,再判断持锁客户端是否真的不在了,确认残留后执行rbd lock remove,清理完重新map就恢复。如果不想每次强杀后都手动清,就把watch超时调小,同时把挂载流程做成有规范启停脚本的,让锁走正常释放路径。

4.2 在线迁移卡住:锁易主

OpenStack或Proxmox的虚拟机在线迁移时,源和目标两端qemu进程同时存在,都通过librbd访问同一块镜像。迁移后半段目标qemu要抢exclusive lock,源qemu要交回锁。这个锁易主瞬间往往伴随短暂IO阻塞,如果磁盘负载高,迁移会卡几十秒甚至超时。

我在实际环境踩过一个坑:调整了迁移并发数后,大量迁移同时抢占锁,锁冲突频繁,整个集群IO延迟一度飙到上百毫秒。后来把迁移并发控制住,又把librbd侧锁超时参数调短,让抢锁失败尽早报错而不是无限等,迁移才稳定。对live migration场景,建议把librbd侧锁等待时间设为20到30秒,不要用默认的60秒。

4.3 客户端崩溃后的锁残留与自动恢复

qemu进程被kill -9,或宿主机断电,锁不会走正常释放路径,于是残留下来。经过watch超时后,在rbd lock ls里变成stale。此时新客户端尝试拿锁时,部分librbd会自动抢占陈旧锁,不需要人工干预;但有的版本会等待一个锁重试周期,表现就是虚拟机启动特别慢。

我的习惯是:运维平台的故障转移脚本里,先扫一遍rbd lock ls,发现stale锁直接remove,能显著缩短故障转移时间。前提是锁确实stale,否则不要动手。操作前可以用时间戳对比一下,锁记录时间是几分钟前的,而进程列表里根本没有对应客户端,那才符合清锁条件。

4.4 快照、克隆、扩容操作被锁卡住

锁不只影响挂载和写入,有些镜像元数据操作被锁卡住时,表现是任务pending,前台IO越来越慢。比如正在运行的虚拟机做快照时,ceph底层会短暂冻结IO去处理元数据保护,如果锁状态异常,快照就卡住。

遇到这类问题,不要第一时间怀疑存储底层硬件,先查锁。有一次告警说克隆任务一直pending,查了半天,结果是一个备份脚本的手动锁没释放,把整个克隆流程压住了。手动锁是隐形问题,不在自动锁路径里,但拦起路来一视同仁。排查时注意rbd lock ls里ID列不是auto的锁,基本都是手动锁,优先级要更高。

4.5 容器和rbd-nbd场景的锁边缘情况

用rbd-nbd而不是内核krbd挂载RBD时,锁行为也值得注意。rbd-nbd走的是用户态librbd,进程异常退出时同样会产生陈旧锁。但更隐蔽的问题是,如果在容器里映射RBD,容器重建后旧的rbd-nbd进程还可能留在宿主机上,锁一直有效,新的容器实例永远拿不到锁。

这种场景下,容器编排脚本里除了清理容器,还要kill对应rbd-nbd进程并清锁。我把这条写进过checklist,因为只要漏掉一次,故障转移就会卡在启动阶段。排查现场如果看到rbd lock ls的持锁地址指向某个已经不存在的容器IP,别怀疑,就是残留。

5. 避坑总结与参数调优

5.1 关键参数速查表

生产环境真正影响锁行为的参数,整理成表:

位置参数默认值影响建议
OSDosd watch timeout30秒控制watch过期、陈旧锁自动抢占速度故障转移频繁可调10~15秒,注意心跳流量
librbdrbd_lock_timeout60秒librbd抢锁重试上限迁移/故障转移建议20~30秒
镜像特性exclusive-lock开启决定是否启用自动锁多写者场景必须开启
镜像特性journaling关闭依赖exclusive-lock,增加日志开销按灾备需求单独开

调参不是越激进越好。osd watch timeout调太短,客户端续约watch的频率要提高,OSD的watch流量明显增加,高负载集群可能带来额外CPU和网络压力。我的思路是先明确故障转移的最坏可接受时延,再倒推参数,不要为了“快”而调。配套监控里加上锁等待延时的指标,比盲目调参会更有依据。

5.2 krbd和librbd对锁的不同态度

对同一把锁,内核krbd和用户态librbd表现很不同。krbd在rbd map时尝试拿锁,拿不到立刻报错,不会无限等待,这是内核模块干净的脾气。大量使用内核挂载的环境,要特别重视故障转移时的锁清理。librbd则更有耐心,支持配置锁超时并重试,逻辑更灵活,代价是有时候你会觉得“它卡住了”,其实人家在等锁。

两者看的是同一把锁,因为锁数据都在header对象的omap上。所以不用担心krbd和librbd各搞一套,它们遵守的是同一套协议。真正需要区分的是业务场景:内核挂载多见于物理机或容器宿主机直连,librbd多见于qemu虚拟机,两者的故障处理节奏完全不同。

5.3 我压箱底的经验

最后分享几条反复验证过的判断规则。第一,看到busy报错别急着清锁,先判断持锁客户端是否活着:能ping通、能看进程、rbd status还有输出,那锁大概率是健康的,别动。第二,异常退出后的锁,watch超时后基本可以安全抢占,但强制清理前看一眼mon日志里有没有blocklist事件,免得清完锁节点又被踢。第三,部署层面把挂载、卸载都做成明确启停脚本,让锁走正常释放路径,减少非正常残留。

这篇文章写到这里,刚好把我这些年和rbd镜像锁搏斗的体会讲完了。最后再分享一个小技巧:容灾切换脚本里,把rbd lock ls输出解析成机器可读格式,加一步自动判断stale并清理的逻辑,能省掉不少半夜被叫起来清锁的麻烦。锁这东西,平时看着不起眼,真出了事,它就是那根压垮业务的最后一根稻草。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询