发布于 

Ceph MDS InodeTable 分配流程和故障分析

本文对 Ceph 中 inode 分配流程进行了总结,并解释了 MDS 重启过程中可能遇到的一个故障

inode 分配流程

在 Ceph 中我们创建目录、文件、软链接时都需要为其分配一个 inode 作为唯一标识,这一分配过程在 Server::prepare_new_inode 中完成,而分配的依据,也就是哪些 inode 还没有被分配,哪些
被分配了还没有生效(落盘)则是记录在 InoTable 中:

1
2
3
4
5
6
class InoTable : public MDSTable {
...
private:
interval_set<inodeno_t> free; // unused ids
interval_set<inodeno_t> projected_free;
...

其中 free 记录的就是没有被分配的 inode 集合,而 projected_free 则是从 free 中剔除了已经被分配但是还没有落盘的部分,因此 projected_freefree 的子集,同时显然分配也首先要在 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
2
id = projected_free.range_start();
projected_free.erase(id);

这里首先将 inodeprojected_free 中移除并将其返回给客户端,这个 inode 会在落盘后响应客户端时 applyfree 中移除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void Server::reply_client_request(MDRequestRef& mdr, const ref_t<MClientReply> &reply)
{
...
apply_allocated_inos(mdr, session);
...

void Server::apply_allocated_inos(MDRequestRef& mdr, Session *session)
{
...
mds->inotable->apply_alloc_id(mdr->alloc_ino);
...

void InoTable::apply_alloc_id(inodeno_t id)
{
...
free.erase(id);
...

到这里为止 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 逻辑,首先在 sessioncompleted_request 中如果找到 req->get_reqid().tid,那么就对于这类请求就直接构建一个 MClientReply 并返回给客户端,而不必再执行一遍了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void Server::handle_client_request(const cref_t<MClientRequest> &req)
{
...
bool has_completed = false;
if (req->is_replay() || req->get_retry_attempt()) {
inodeno_t created;
if (session->have_completed_request(req->get_reqid().tid, &created)) {
has_completed = true;
if (req->is_replay() || ...) {
dout(5) << "already completed " << req->get_reqid() << dendl;
auto reply = make_message<MClientReply>(*req, 0);
if (created != inodeno_t()) {
bufferlist extra;
encode(created, extra);
reply->set_extra_bl(extra);
}
mds->send_message_client(reply, session);

if (req->is_queued_for_replay())
mds->queue_one_replay();
return;
}
...
...

那对于没有落盘的 unsafe_request 请求,在 MDS 就没有办法直接完成,而是必须需要 MDS 重新执行一遍请求,那这里就涉及到一个问题,之前我们在 prepare_new_inode 中分配 inode 号只需要拿 projected_free 中的第一个没有被分配的 inode 号即可,那在 MDS 重新执行此请求时我们还是拿第一个 inode 号来分配吗?这样会造成和之前分配的 inode 号不相等的问题吗?

而实际上为了避免这个问题,客户端在发送 unsafe_request 的时候就会携带之前 MDS 的处理结果,并要求 MDS 再次分配同样的 inode,当然这里能这样做还是基于集群中不存在恶意节点,因此我们相信客户端告知的 inode 确实是 MDS 之前分配出去的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void Server::handle_client_openc(MDRequestRef& mdr)
{
...
CInode *newi = prepare_new_inode(mdr, dn->get_dir(), inodeno_t(req->head.ino),
req->head.args.open.mode | S_IFREG, &layout);
...

CInode* Server::prepare_new_inode(MDRequestRef& mdr, CDir *dir, inodeno_t useino, unsigned mode, const file_layout_t *layout)
{
...
bool allow_prealloc_inos = mdr->session->is_open();
if (allow_prealloc_inos && (mdr->used_prealloc_ino = _inode->ino = mdr->session->take_ino(useino))) {
mds->sessionmap.mark_projected(mdr->session);
} else {
mdr->alloc_ino = _inode->ino = mds->inotable->project_alloc_id(useino);
}
...

注意这里 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_requestsold_requests,那实际上客户端还会在所有消息的最后发送一条 client_reconnect 消息标志全部消息都发完了,MDS 在收到这条消息后将客户端从 MDS 的 client_reconnect_gather 中删除,MDS 会在 reconnect 阶段结束后遍历并通过 kill_session 关闭未完成 client_reconnect 的客户端 session

Server::journal_close_session 中 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Server::journal_close_session(Session *session, int state, Context *on_safe)
{
...
interval_set<inodeno_t> inos_to_free; // (1)
inos_to_free.insert(session->pending_prealloc_inos);
inos_to_free.insert(session->free_prealloc_inos);
...
auto fin = new C_MDS_session_finish(this, session, sseq, false, pv, inos_to_free, piv,
session->delegated_inos, mdlog->get_current_segment(), on_safe); // (2)
...
while(!session->requests.empty()) { // (3)
auto mdr = MDRequestRef(*session->requests.begin());
mdcache->request_kill(mdr);
}

这里 MDS 在 (1) 处回收了之前分配给 sessioninode, 并在 (3) 处移除了客户端 session 对应的请求,然后等到 (2) 完成后将 sessionsession_map 中移除。

那么一旦客户端没有完成 reconnect, MDS 会在 Server::kill_session 中会一路调到 AsyncConnectionshutdown_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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void Client::kick_requests_closed(MetaSession *session)
{
for (map<ceph_tid_t, MetaRequest*>::iterator p = mds_requests.begin(); p != mds_requests.end(); ++p) {
...
if (req->mds == session->mds_num) {
if (req->caller_cond) { // (1)
req->kick = true;
req->caller_cond->notify_all();
}
req->item.remove_myself(); // (2)

if (req->got_unsafe) { // (3)
req->unsafe_item.remove_myself();
if (is_dir_operation(req)) {
Inode *dir = req->inode();
dir->set_async_err(-CEPHFS_EIO);
req->unsafe_dir_item.remove_myself();
}
if (req->target) {
InodeRef &in = req->target;
in->set_async_err(-CEPHFS_EIO);
req->unsafe_target_item.remove_myself();
}
signal_cond_list(req->waitfor_safe);
unregister_request(req);
}
}
}
...

可以看到客户端在 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 中移除。

那么这里我们做一个简单的对上面部分的总结:

  1. 如果客户端 reconnect 失败, MDS 会将客户端 session kill 掉并回收之前分配给客户端的 inode
  2. 客户端得知 session 被对端 MDS kill 掉,会将 session 对应的 unsafe_requests 移除,并唤醒所有正在等待回复的请求
  3. 被唤醒的请求会在重建 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 会回收分配给 sessioninode)的话就有可能造成 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 被丢弃所引发的问题的分析,希望对大家有所帮助。