runsisi's

technical notes

ceph osd add/rm-noout 导致 rbd 内核客户端 IO 卡住

2020-03-17 runsisi#ceph

最近在 Ceph 集群升级时发现一个奇怪的现象,服务端升级正常完成之后,所有的内核态 rbd 客户端的 IO 都卡住了,简单分析原因如下。

测试环境

CentOS 7.4

Luminous 12.2.12 ceph 服务端

3.10.0-693 内核态 ceph 客户端

复现手段

OSD 节点升级方式如下:

  • 给待升级节点 OSD 设置 noout 标签(注意不是通过 ceph osd set noout 这种全局标签手段);
  • 重启待升级节点所有 OSD;
  • 清除 noout 标签;

使用如下的命令行进行模拟,最后一个 OSD 放在最后启动是为了模拟 OSD up 稍晚于 rm-noout 操作的情况:

# ceph osd add-noout 0 1 2; sleep 3; for i in 0 1 2; do systemctl stop ceph-osd@$i; done; sleep 3; for i in 0 1; do systemctl start ceph-osd@$i; done; sleep 3; ceph osd rm-noout 0 1 2; sleep 3; for i in 2; do systemctl start ceph-osd@$i; done;

信息采集

[Mar17 08:57] libceph: osd0 down
[  +0.000007] libceph: osd1 down
[  +0.000003] libceph: osd2 down
[  +5.678292] libceph: osd0 192.168.34.11:6800 socket closed (con state OPEN)
[  +0.000145] libceph: osd0 192.168.34.11:6800 socket error on write
[  +0.367702] libceph: osd0 192.168.34.11:6800 socket error on write
[  +1.005818] libceph: osd0 192.168.34.11:6800 socket error on write
[  +2.003405] libceph: osd0 192.168.34.11:6800 socket error on write
[  +4.000741] libceph: osd0 192.168.34.11:6800 socket error on write
[  +6.520864] libceph: osd0 up
[  +0.000007] libceph: osd1 up
[Mar17 08:58] libceph: osd0 down
[  +0.000005] libceph: osd1 down
[  +0.000003] libceph: osd2 down
[  +6.131281] libceph: osd2 up

从内核 dmesg 打印来看,最后一次的 OSD 全部 down 非常值得怀疑,从升级过程来看,并不存在 OSD 重复 stop 的情况。

# cat /sys/kernel/debug/ceph/<fsid>/osdmap
epoch 74 flags 0x1c8000
pool 1 'rbd' type 1 size 3 min_size 1 pg_num 64 pg_num_mask 63 flags 0x1 lfor 0 read_tier -1 write_tier -1
osd0    192.168.34.11:6800      100%    (exists)        100%
osd1    192.168.34.11:6802      100%    (exists)        100%
osd2    192.168.34.11:6804      100%    (exists, up)    100%

升级之后,内核客户端拿到的 osdmap 中 OSD.0 和 OSD.1 是 down 的,显然与服务端真实状态对不上,不过和 dmesg 的日志信息完全吻合。

故障分析

由于该问题属于必现故障,因此可以排除机器、网络等外部因素,只需要从代码着手即可。

在 L 版本中,osdmap 中 OSD 的状态位从 8 bit 切换成了 32 bit,但是 3.10.0-693 内核态客户端由于年代久远,仍然只能处理 8 bit 状态位,显然在 OSD 状态的处理上,客户端与 L 版本服务端的配合非常值得怀疑。

通过分析服务端代码,发现 add/rm-noout 操作都会设置 OSD 状态,而且是设置在非低 8 位,与老版本客户端(不支持 osdmap OSD 状态位 32 bit)交互时,会截取低 8 位的状态发送给客户端,显然,这种情况下,客户端会收到全 0 的 8 位状态位。此外,需要注意的是,OSD 状态位的设置或取消是异或操作,而不是简单的赋值操作。

下面结合内核态客户端代码与服务端的升级操作,来分析一下客户端的行为。

// linux-3.10.0-693/net/ceph/osdmap.c

static int decode_new_up_state_weight(void **p, void *end,
				      struct ceph_osdmap *map)
{
	/* new_state (up/down) */
	*p = new_state;
	len = ceph_decode_32(p);
	while (len--) {
		s32 osd;
		u8 xorstate;
		int ret;

		osd = ceph_decode_32(p);
		xorstate = ceph_decode_8(p);
		if (xorstate == 0)
			xorstate = CEPH_OSD_UP;
		BUG_ON(osd >= map->max_osd);
		if ((map->osd_state[osd] & CEPH_OSD_UP) &&
		    (xorstate & CEPH_OSD_UP))
			pr_info("osd%d down\n", osd);
		if ((map->osd_state[osd] & CEPH_OSD_EXISTS) &&
		    (xorstate & CEPH_OSD_EXISTS)) {
			pr_info("osd%d does not exist\n", osd);
			map->osd_weight[osd] = CEPH_OSD_IN;
			ret = set_primary_affinity(map, osd,
						   CEPH_OSD_DEFAULT_PRIMARY_AFFINITY);
			if (ret)
				return ret;
			memset(map->osd_addr + osd, 0, sizeof(*map->osd_addr));
			map->osd_state[osd] = 0;
		} else {
			map->osd_state[osd] ^= xorstate;
		}
	}

	/* new_up_client */
	*p = new_up_client;
	len = ceph_decode_32(p);
	while (len--) {
		s32 osd;
		struct ceph_entity_addr addr;

		osd = ceph_decode_32(p);
		ceph_decode_copy(p, &addr, sizeof(addr));
		ceph_decode_addr(&addr);
		BUG_ON(osd >= map->max_osd);
		pr_info("osd%d up\n", osd);
		map->osd_state[osd] |= CEPH_OSD_EXISTS | CEPH_OSD_UP;
		map->osd_addr[osd] = addr;
	}
}
  1. 初始时刻,服务端和客户端都处于稳态,所有 OSD 都是 up 状态;
  2. 服务端升级开始,通过 ceph osd add-noout 命令为该节点 OSD 设置 nouout 标签,生成新 osdmap,并发送给客户端仅截取了低 8 bit 的版本;
  3. 客户端收到新 osdmap,8 bit OSD 状态位全 0;
  4. 客户端多此一举将全 0 解释成 CEPH_OSD_UP,并与原来的状态进行异或操作,在客户端侧 OSD 被置成 down,即错误的置成了 down;
  5. 服务端重启 OSD 进程,OSD down,生成新 osdmap,且低 8 位 CEPH_OSD_UP 置位;
  6. 客户端收到新 osdmap,由于在第 4 步中,客户端 OSD 状态已置为 down,异或操作导致 OSD 置成 up,即错误的置成了 up;
  7. 服务端继续步骤 5 的操作,OSD 进程启动,OSD up,生成新 osdmap;
  8. 客户端收到新 osdmap,需要特别注意的是新 up 的 OSD 并不是通过上面描述的异或操作置上的 up 状态,而是走的上面代码中的后一个 while 循环,也就是说在客户端看来,OSD 状态仍然保持 up;
  9. 服务端升级结束,通过 ceph osd rm-noout 命令为该节点 OSD 取消 nouout 标签,生成新 osdmap 并发送给客户端仅截取了低 8 bit 的版本;
  10. 客户端收到新 osdmap,8 bit OSD 状态位全 0;
  11. 客户端多此一举将全 0 解释成 CEPH_OSD_UP,并与原来的状态进行异或操作,OSD 状态从第 6 步的 up 状态被置成 down,即错误的置成了 down;
  12. 最终,客户端得到的 OSD 状态就是 (exists) 而不是 (exists, up)

当然,这里忽略了 rm-noout 操作早于某些 OSD 进程启动完成这种情况,即我们从客户端 osdmap 看到的部分 OSD up,部分 OSD down 的情况,但分析过程类似,结果都是可解释的。

解决方案

本质上,这是代码的 bug,规避也很容易,一种就是服务端升级完成之后再将全部 OSD 进行重启,还一种就是服务端升级时不进行 add/rm-noout 操作。

从代码上来说,服务端和客户端都可以进行修改,一是服务端不编码低 8 位为 0 的 OSD 状态,二是客户端停止将低 8 为 0 的 OSD 状态解析成 CEPH_OSD_UP,当然,服务端的修改会更简单一些。

参考资料

libceph: osd_state is 32 bits wide in luminous

https://github.com/torvalds/linux/commit/0bb05da2ec57163b7a25efef001ed8f52b18b070

osd/OSDMap: make osd_state 32 bits wide

https://github.com/ceph/ceph/pull/15390

mon/OSDMonitor: remove zeroed new_state updates

https://github.com/ceph/ceph/pull/16518

osd/OSDMap: stop encoding osd_state with >8 bits wide states only for old client

https://github.com/ceph/ceph/pull/33814