Ceph MDS InodeTable 分配流程和故障分析
本文对 Ceph 中 inode
分配流程进行了总结,并解释了 MDS 重启过程中可能遇到的一个故障
inode 分配流程
在 Ceph 中我们创建目录、文件、软链接时都需要为其分配一个 inode
作为唯一标识,这一分配过程在 Server::prepare_new_inode
中完成,而分配的依据,也就是哪些 inode
还没有被分配,哪些
被分配了还没有生效(落盘)则是记录在 InoTable
中:
1 | class InoTable : public MDSTable { |
其中 free
记录的就是没有被分配的 inode
集合,而 projected_free
则是从 free
中剔除了已经被分配但是还没有落盘的部分,因此 projected_free
是 free
的子集,同时显然分配也首先要在 projected_free
中来做。
实际上 interval_set
就是一堆 set
的集合,这里代表可用的 inode
范围的集合,给一个可能的 projected_free
的内容,非常好理解:
1 | projected_recycle: ..., {begin: 0x100068f03be, len: 49}, {beegin: 0x100068f045b, len: 2}, ... |
那么回到 Server::prepare_new_inode
, MDS 对于客户端的请求需要返回一个 CInode
结构,首先就需要从 InodeTable
中取出一个可用的 inode
,并以此构建 CInode
结构后返回,通常情况下这个过程很简单,我们只需要从 projected_free
中取出可用的第一个 inode
就可以了:
1 | id = projected_free.range_start(); |
这里首先将 inode
从 projected_free
中移除并将其返回给客户端,这个 inode
会在落盘后响应客户端时 apply
并 free
中移除:
1 | void Server::reply_client_request(MDRequestRef& mdr, const ref_t<MClientReply> &reply) |
到这里为止 MDS 只有在落盘之后才向客户端返回分配好的 inode,直到一天我们认为在 MDS 刷盘写 Journal
的时候让客户端死等的效率太低了,由此就引入了 early_reply
以使得客户端能够快速接收到 MDS 的处理结果。
首先当我们开启 mds_early_reply
时(实际上默认就是开启的),MDS 会在刷盘之前预先把处理的结果通过 Server::early_reply
返回一个 unsafe reply
给客户端,那么客户端在接收到 MDS 的返回结果后就可以继续进行下一个请求而不必等待 MDS 刷盘,而当 MDS 刷盘完成后则会再给客户端返回一个 safe reply
,这时客户端只需要结束该 request
即可。
这时如果在任意处理过程中 MDS 因为一些原因重启,则在客户端可能就会有一部分未接收到 safe_reply
确认的 unsafe_requests
需要 MDS 处理,那么这时客户端就会在 MDS 的 reconnect
阶段重发这些 unsafe_requests
消息以及之前已经发送但还没有收到任何回复的 old_requests
, MDS 接收到这些请求之后则会将 unsafe_requests
消息加入 replay_queue
中等到 client_replay
阶段处理,而 old_requests
则会放到 active
阶段再处理。
当 MDS 到达 client_replay
阶段后开始处理客户端 unsafe_requests
请求,这里由于客户端之前收到过 MDS 的 unsafe_reply
,因此在发送 unsafe_request
中会携带 MDS 之前的处理结果(也就是之前预分配的 inode
号),而在 MDS 这边对于客户端的 unsafe_request
则的处理会按照重启前已经落盘或还没有落盘分别处理。
如果说一条请求在 MDS 重启前已经落盘,那么在 Server::handle_client_request
中将进入 completed_request
逻辑,首先在 session
的 completed_request
中如果找到 req->get_reqid().tid
,那么就对于这类请求就直接构建一个 MClientReply
并返回给客户端,而不必再执行一遍了:
1 | void Server::handle_client_request(const cref_t<MClientRequest> &req) |
那对于没有落盘的 unsafe_request
请求,在 MDS 就没有办法直接完成,而是必须需要 MDS 重新执行一遍请求,那这里就涉及到一个问题,之前我们在 prepare_new_inode
中分配 inode
号只需要拿 projected_free
中的第一个没有被分配的 inode
号即可,那在 MDS 重新执行此请求时我们还是拿第一个 inode
号来分配吗?这样会造成和之前分配的 inode
号不相等的问题吗?
而实际上为了避免这个问题,客户端在发送 unsafe_request
的时候就会携带之前 MDS 的处理结果,并要求 MDS 再次分配同样的 inode
,当然这里能这样做还是基于集群中不存在恶意节点,因此我们相信客户端告知的 inode
确实是 MDS 之前分配出去的:
1 | void Server::handle_client_openc(MDRequestRef& mdr) |
注意这里 inode
可能是从 session
中取,也有可能从 InodeTable
取,取决于之前 MDS 是否通过 project_alloc_ids
分配给 session
一定数量的 inode
,正常情况下对于一个 session 的第一个创建请求, MDS 会在分配 inode
的同时,从 InodeTable
取出一截 inode
号放到 session 中,接着之后的分配请求就都从 session
中分配了,如果 session
中的 inode
快分配完了那么再从 InodeTable
中拿一截再放到 session
中。
MDS 重启故障
在上面的过程中无论 MDS 在重启前是否落盘都能 cover 住重启以后客户端发来的 unsafe_requests
消息,这样一来就能够保证 MDS 请求处理流程的幂等性。
但是上面的一切都是建立在 reconnect
以及 client_replay
正常进行的情况下,如果 reconnect
失败, MDS 会如何处理客户端?客户端又会如何处理 unsafe_requests
?
首先我们这里所讨论的 reconnect
失败是由于 MDS 没有在 reconnect
阶段收到客户端发来的 client_reconnect
消息,之前提到客户端会在 MDS 的 reconnect
阶段向 MDS 发送 unsafe_requests
和 old_requests
,那实际上客户端还会在所有消息的最后发送一条 client_reconnect
消息标志全部消息都发完了,MDS 在收到这条消息后将客户端从 MDS 的 client_reconnect_gather
中删除,MDS 会在 reconnect
阶段结束后遍历并通过 kill_session
关闭未完成 client_reconnect
的客户端 session
。
在 Server::journal_close_session
中 :
1 | void Server::journal_close_session(Session *session, int state, Context *on_safe) |
这里 MDS 在 (1)
处回收了之前分配给 session
的 inode
, 并在 (3)
处移除了客户端 session
对应的请求,然后等到 (2)
完成后将 session
从 session_map
中移除。
那么一旦客户端没有完成 reconnect
, MDS 会在 Server::kill_session
中会一路调到 AsyncConnection
的 shutdown_socket
以关闭 socket 连接,这样一来客户端在 TCP 层面就会感知到对端的 socket 已经被关闭,继而在 Client::_closed_mds_session
中关闭这个 session
对应的请求并关闭 MDS 对应的 session
, 这样一来下次 client 再要向 MDS 去发消息的时候就需要通过 Client::_get_or_open_mds_session
重新建立 session
。
在 Client::kick_requests_closed
中:
1 | void Client::kick_requests_closed(MetaSession *session) |
可以看到客户端在 kick_requests_closed
中主要做了三个事情, (1)
处通过 caller_cond->notify_all()
唤醒所有 wait 在 make_request
处的请求并将其标记为 kick
,这些请求都还没有接收到 MDS 的回复,因此会在下一个 while 循环中尝试和 MDS 建立新的连接并重传,接着 (2)
处将请求从 session->requests
中移除, (3)
中则是针对 unsafe_request
做了额外的处理,首先除了从 session->requests
中移除外 unsafe_request
还需要从 session->unsafe_requests
中移除,另外对于目录相关的操作客户端给目录的 inode
设置一个 CEPHFS_EIO
(给所有 inode
对应的 fh
设置 async_err
),以及有 MDS 返回 inode
的也给对应的 inode
设置一个 CEPHFS_EIO
, 最后将这些不需要重传(因为上层应用已经拿到了返回结果,实际上也无法重传, make_request
已经结束了)的请求通过 unregister_request
将其从 mds_requests
中移除。
那么这里我们做一个简单的对上面部分的总结:
- 如果客户端
reconnect
失败, MDS 会将客户端session
kill 掉并回收之前分配给客户端的inode
- 客户端得知
session
被对端 MDS kill 掉,会将session
对应的unsafe_requests
移除,并唤醒所有正在等待回复的请求 - 被唤醒的请求会在重建
session
后重试
那么我们假设有两个请求 req1 (mkdir dir1 - 创建一个目录),req2 (touch dir1/file1 - 创建该目录下的一个文件),其中在 MDS 重启前 req1 已经收到了来自 MDS 的 unsafe_reply
(但是在 MDS 上对于 req1 的处理还没有落盘)因此客户端继续发送 req2,这时 MDS 重启,客户端 reconnect
超时失败,MDS 于是通过 kill_session
关闭客户端 session
,而客户端如上文所说通过 kick_requests_closed
注销 req1 (req1 是 unsafe_requset
) 并唤醒 req2 (req2 正在等待 MDS 回复),那么客户端就会尝试和 MDS 重新建立 session
并重试 req2
这里对于一个 CREATE
请求,客户端需要携带父目录 inode
和要创建的文件的 dentry
,因此当请求到达 MDS 端时 MDS 在 MDCache::path_traverse
中就会尝试通过 get_inode
获取父目录,注意这里直接是从 Cache 中获取,因为 MDS 这里认为既然客户端能够发来这个请求那么一定是之前打开过这个目录,那么目录对应的 CInode
就应该在 LRU 中被 pin
住了,当然这里正常流程也有可能出现 Cache 不对等的情况,这涉及到的另一个已经被 fix 的 bug (对于两边 Cache 不对等的情况,MDS 应该尝试从 rados 重建 backtrace)我们这里不做展开,但是无论如何,对于 req1 创建的 inode
对于 MDS 来说确实是不可见的,因此 MDS 将会向客户端返回 ESTALE
错误,而当客户端接收到这个错误之后会继续尝试重传(commit aabd5e9c by Xiubo Li
移除了重传的逻辑),由此 MDS 和 Client 陷入了无限循环。
除此以外由于客户端在之前收到 unsafe_reply
时已经做了 insert_trace
, 目录 dir1 的 inode
已经通过 add_update_inode
加入了 inode_map
中,后续如果有请求分配到了这个 inode
(之前提到 MDS 会回收分配给 session
的 inode
)的话就有可能造成 inode
结构混乱,例如后续分到这个 inode
的请求是一个 CREATE
请求的话在客户端就会产生混淆(甚至 crash),在客户端重启之前都无法解决。
要解决以上这些问题,最根本的思路就是要解决客户端 _closed_mds_session
时处理一半的问题,要么所有的请求都 ban 掉,要么就让所有请求安全的重传,注意这里的所有也包括 unsafe_requests
,因为之后的请求可能和 unsafe_requests
形成依赖关系。这里我选择的思路是后者,因为一来前者已经有人做了(如果不关闭 mds_session_blocklist_on_evict
选项那所有的请求在下一次循环时都会因为 blocklist
取消),再一个后者也更符合 client_reconnect_stale
的语义(毕竟我们开启这个选项就是为了客户端无感知恢复连接)
以上就是对 MDS InoTable
分配流程的总结以及重启过程中 reconnect
失败导致的 unsafe_requests
被丢弃所引发的问题的分析,希望对大家有所帮助。