现象
- 边缘存储两个节点fileserver,glance挂载物理机上的挂载点均出现挂住无法访问
- 从客户端抓包看,客户端内核间隔5s向服务端发送renew租约请求,服务端返回NFS4ERR_EXPIRED,即租约过期错误,从抓包现象看客户端一直向服务端发送相同的clientid renew请求,服务端一直返回租约过期错误,导致挂载点无法恢复
初步分析
- ganesha服务端返回NFS4ERR_EXPIRED错误的场景
a. 客户端的clientid是服务端生成的,是一个64位值,其值中的高位4字节包括服务端的serverepoch值(服务端每次启动时通过到1970-0-0的相等时间生成,这样一般来说,不同的ganesha网关,或同一ganesha网关重启,serverepoch的值都会变化)
b. 从客户端发送过来的clientid中的epoch值与服务端的epcoh相等(若不相同,说明不是同一个ganesha网关,或者原网关已重启,会直接返回NFS4ERR_STALE_STATEID错误),但是在clientid_confimed表中找不到对应的clientid hash记录,则会返回NFS4ERR_EXPIRED
c. 客户端与服务端通过setclientid和setclientidconfirm两个接口确定好clientid后,服务端会将clientid插入到clientid_confimed表中,clientid有一个租约有效期,默认是1分钟,然后从客户端行为来说会间隔5s向服务端更新租约,有几种情况会导致服务端将clientid从表中remove掉,例如客户端与服务端存在网络问题,导致renew请求超时或者丢失,或者服务端线程忙,导致update_lease没有及时处理等 - nfs客户端收到NFS4ERR_EXPIRED后,按逻辑应该会重新进行clientid协商,从上图现象中可以看出没有重新协商clientid,因此导致卡死,从内核debug日志也能看出
- 通过将ganesha 调用_valid_lease线程gdb挂住,尝试复现返回NFS4ERR_EXPIRED场景,发现客户端可以进行clientid重新协商,因此客户端恢复了
kernel 3.10.0-693.5.2源码分析
static const struct rpc_call_ops nfs4_renew_ops = {
.rpc_call_done = nfs4_renew_done,
.rpc_release = nfs4_renew_release,
};
__rpc_execute
{
for(;;) {
do_action = task->tk_callback;
task->tk_callback = NULL;
if (do_action == NULL) {
do_action = task->tk_action;
if (do_action == NULL)
break;
}
trace_rpc_task_run_action(task->tk_client, task, task->tk_action);
do_action(task);
}
rpc_release_task(task);
}
call_decode中处理服务响应信息:
{
task->tk_action = rpc_exit_task;
if (decode) {
task->tk_status = rpcauth_unwrap_resp(task, decode, req, p,
task->tk_msg.rpc_resp);
}
// RPC: 21627 call_decode result -10011
dprintk("RPC: %5u call_decode result %d\n", task->tk_pid, task->tk_status);
}
因此在__rpc_execute中的for循环中,下一步调用rpc_exit_task
rpc_exit_task中回调并结束状态机:
{
task->tk_action = NULL;
task->tk_ops->rpc_call_done -> nfs4_renew_done
}
nfs4_renew_done对于NFS4ERR_EXPIRED错误会调用nfs4_schedule_lease_recovery
{
switch (task->tk_status) {
case 0:
break;
case -NFS4ERR_LEASE_MOVED:
nfs4_schedule_lease_moved_recovery(clp);
break;
default:
/* Unless we're shutting down, schedule state recovery! */
if (test_bit(NFS_CS_RENEWD, &clp->cl_res_state) == 0)
return;
if (task->tk_status != NFS4ERR_CB_PATH_DOWN) {
nfs4_schedule_lease_recovery(clp);
return;
}
nfs4_schedule_path_down_recovery(clp);
}
do_renew_lease(clp, timestamp);
}
nfs4_schedule_lease_recovery
{
if (!test_bit(NFS4CLNT_LEASE_EXPIRED, &clp->cl_state))
set_bit(NFS4CLNT_CHECK_LEASE, &clp->cl_state);
// nfs4_schedule_lease_recovery: scheduling lease recovery for server 100.125.255.100
nfs4_schedule_state_manager(clp);
}
从调用流程看不会设置NFS4CLNT_LEASE_EXPIRED,因此会设置NFS4CLNT_CHECK_LEASE
nfs4_schedule_state_manager中会起线程并执行状态机
{
if (test_and_set_bit(NFS4CLNT_MANAGER_RUNNING, &clp->cl_state) != 0)
return;
task = kthread_run(nfs4_run_state_manager, clp, buf);
}
nfs4_schedule_state_manager中检查clp->cl_state,若已经置为NFS4CLNT_MANAGER_RUNNING,则返回,通过设置此标志,确保只会起一个线程并执行后续的流程:
nfs4_run_state_manager -> nfs4_state_manager
nfs4_state_manager(struct nfs_client *clp)
{
int status = 0;
const char *section = "", *section_sep = "";
/* Ensure exclusive access to NFSv4 state */
do {
......
if (test_bit(NFS4CLNT_LEASE_EXPIRED, &clp->cl_state)) {
section = "lease expired";
/* We're going to have to re-establish a clientid */
status = nfs4_reclaim_lease(clp);
if (status < 0)
goto out_error;
continue;
}
......
if (test_and_clear_bit(NFS4CLNT_CHECK_LEASE, &clp->cl_state)) {
section = "check lease";
status = nfs4_check_lease(clp);
if (status < 0)
goto out_error;
continue;
}
......
nfs4_end_drain_session(clp);
if (test_and_clear_bit(NFS4CLNT_DELEGRETURN, &clp->cl_state)) {
nfs_client_return_marked_delegations(clp);
continue;
}
nfs4_clear_state_manager_bit(clp);
/* Did we race with an attempt to give us more work? */
if (clp->cl_state == 0)
break;
if (test_and_set_bit(NFS4CLNT_MANAGER_RUNNING, &clp->cl_state) != 0)
break;
} while (atomic_read(&clp->cl_count) > 1);
}
因为一开始clp->cl_state置为NFS4CLNT_CHECK_LEASE,则状态机会先调用nfs4_check_lease,nfs4_check_lease会再次执行nfs4_proc_renew,即同步向服务端发起组约更新,那么服务端会再次返回NFS4ERR_EXPIRED错误
nfs4_check_lease
-> status = ops->renew_lease(clp, cred) -> nfs4_proc_renew
-> nfs4_recovery_handle_error(clp, status)
在nfs4_recovery_handle_error中对于NFS4ERR_EXPIRED错误,会将clp->cl_state置位:set_bit(NFS4CLNT_LEASE_EXPIRED, &clp->cl_state),再次进入状态机,并会调用nfs4_reclaim_lease
nfs4_reclaim_lease
-> nfs4_establish_lease
-> status = ops->establish_clid(clp, cred); -> nfs4_init_clientid
也就是nfs4_init_clientid中进行clientid的重新协商,所以从正常流程看,客户端应该能恢复,但是与日志和抓包现象不符,从内核debug日志和抓包情况看,客户端一直在进行同一个clientid的renew请求流程,并且一直是周期循环的状态,也就是重新clientid协商没有成功
周期进行租约更新的流程:
rpc_release_task
{
rpc_final_put_task
INIT_WORK(&task->u.tk_work, rpc_async_release)
}
rpc_async_release
-> rpc_release_calldata(task->tk_ops, task->tk_calldata)
-> ops->rpc_release(calldata) -> nfs4_renew_release
-> nfs4_schedule_state_renewal
nfs4_schedule_state_renewal再起一个延迟任务,
{
timeout = (2 * clp->cl_lease_time) / 3 + (long)clp->cl_last_renewal
- (long)jiffies;
if (timeout < 5 * HZ)
timeout = 5 * HZ;
mod_delayed_work(system_wq, &clp->cl_renewd, timeout);
set_bit(NFS_CS_RENEWD, &clp->cl_res_state);
}
在nfs4_alloc_client中会初始化clp->cl_renewd:
INIT_DELAYED_WORK(&clp->cl_renewd, nfs4_renew_state);
delayed_work中实际调用的是nfs4_renew_state
nfs4_renew_state
-> ret = ops->sched_state_renewal(clp, cred, renew_flags) -> nfs4_proc_async_renew
显然周期性的租约更新每次收到服务端的响应还是NFS4ERR_EXPIRED错误,则客户端每次都会再次进入恢复状态机,但是我们没有看到nfs4_recovery_handle_error中打印的debug日志,从上流程中分析,若客户端已经处于恢复状态机时,则新的恢复流程直接退出(clp->cl_state处于NFS4CLNT_MANAGER_RUNNING状态),说明可能有之前状态机流程还存在
通过cat /proc/*stack |grep nfs4_run_state_manager找到可疑的内核线程栈,可以确认此状态机线程一直卡在这里,最终导致其他客户端操作都在等待此恢复流程:
[<ffffffffc097aa70>] nfs4_drain_slot_tbl+0x60/0x70 [nfsv4]
[<ffffffffc097aa97>] nfs4_begin_drain_session.isra.11+0x17/0x40 [nfsv4]
[<ffffffffc097b37f>] nfs4_establish_lease+0x2f/0x80 [nfsv4]
[<ffffffffc097c818>] nfs4_state_manager+0x1d8/0x8c0 [nfsv4]
[<ffffffffc097cf1f>] nfs4_run_state_manager+0x1f/0x40 [nfsv4]
[<ffffffff810b099f>] kthread+0xcf/0xe0
[<ffffffff816b4fd8>] ret_from_fork+0x58/0x90
static int nfs4_drain_slot_tbl(struct nfs4_slot_table *tbl)
{
set_bit(NFS4_SLOT_TBL_DRAINING, &tbl->slot_tbl_state);
spin_lock(&tbl->slot_tbl_lock);
if (tbl->highest_used_slotid != NFS4_NO_SLOT) {
INIT_COMPLETION(tbl->complete);
spin_unlock(&tbl->slot_tbl_lock);
return wait_for_completion_interruptible(&tbl->complete);
}
spin_unlock(&tbl->slot_tbl_lock);
return 0;
}
highest_used_slotid指的是nfs客户端已使用的最大的slotid,当调用nfs4_alloc_slot时,可能会增加highest_used_slotid值,当一个nfs请求结束,调用nfs4_free_slot释放slot时,可能会降低highest_used_slotid值,只有所有请求都完成时,所有的slot都释放,highest_used_slotid值会置为NFS4_NO_SLOT,当前状态机线程卡在这里,等待所有的slot释放
问题总结
- nfs 4.0中客户端与服务端通信,首先需要协商clientid(setclientid,setclientid_confirm),clientid作为客户端唯一标示区别各个客户端,clientid由服务端的serverepoch + count构成。
- 服务端因网络,负载等原因可能导致客户端的renew租约更新请求出现超时(默认阈值60s),服务端会定期将租约超过阈值的客户端的clientid清除掉,之后客户端的后续请求到达服务端会查不到clientid导致直接返回租约过期(-10011,NFS4ERR_EXPIRED)错误
- 客户端收到NFS4ERR_EXPIRED错误后,会起一个内核线程,在该线程中发起一个恢复状态机流程,先立即同步发送一个新的renew检查请求,若再次收到NFS4ERR_EXPIRED错误,则会发起重新协商clientid流程(setclientid,setclientid_confirm),但是在执行流程流程之前,必须确保之前的nfs客户端请求已经都结束,判断标准是通过slot_table中所有的slot已经都释放(highest_used_slotid == NFS4_NO_SLOT),如果存在还没有释放的slotid,则需要等待
- 当前出问题的客户端一直在恢复流程中等待所有slotid的释放,说明客户端在某些异常流程处理时没有将使用的slotid释放,或者实际slot都释放了,但是没有成功将等待的状态机线程唤醒,导致恢复流程卡住
解决办法
- 临时解决办法:从代码中看到状态机线程注册了kill信号,状态机挂住的内核线程可以通过kill命令杀掉
cd /proc
for pid in *
do
rc=`cat $pid/stack 2>&1|grep nfs4_drain_slot_tbl >/dev/null`
if [[ $? -eq 0 ]]; then
echo $pid
kill -9 $pid
fi
done
- 长期解决办法:暂时没有看到内核针对此问题的针对性bugfix和patch,有一个关于此问题的报告但是没有解答(https://www.spinics.net/lists/linux-nfs/msg78299.html),建议使用更新版本的内核看问题是否再现,也希望有遇到类似问题的同学给予一些提示和帮助