3.10.0-693.5.2内核nfs客户端租约过期挂死问题分析

现象

  1. 边缘存储两个节点fileserver,glance挂载物理机上的挂载点均出现挂住无法访问
  2. 从客户端抓包看,客户端内核间隔5s向服务端发送renew租约请求,服务端返回NFS4ERR_EXPIRED,即租约过期错误,从抓包现象看客户端一直向服务端发送相同的clientid renew请求,服务端一直返回租约过期错误,导致挂载点无法恢复

3.10.0-693.5.2内核nfs客户端租约过期挂死问题分析

初步分析

  1. 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没有及时处理等
  2. nfs客户端收到NFS4ERR_EXPIRED后,按逻辑应该会重新进行clientid协商,从上图现象中可以看出没有重新协商clientid,因此导致卡死,从内核debug日志也能看出
  3. 通过将ganesha 调用_valid_lease线程gdb挂住,尝试复现返回NFS4ERR_EXPIRED场景,发现客户端可以进行clientid重新协商,因此客户端恢复了

3.10.0-693.5.2内核nfs客户端租约过期挂死问题分析

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释放

问题总结

  1. nfs 4.0中客户端与服务端通信,首先需要协商clientid(setclientid,setclientid_confirm),clientid作为客户端唯一标示区别各个客户端,clientid由服务端的serverepoch + count构成。
  2. 服务端因网络,负载等原因可能导致客户端的renew租约更新请求出现超时(默认阈值60s),服务端会定期将租约超过阈值的客户端的clientid清除掉,之后客户端的后续请求到达服务端会查不到clientid导致直接返回租约过期(-10011,NFS4ERR_EXPIRED)错误
  3. 客户端收到NFS4ERR_EXPIRED错误后,会起一个内核线程,在该线程中发起一个恢复状态机流程,先立即同步发送一个新的renew检查请求,若再次收到NFS4ERR_EXPIRED错误,则会发起重新协商clientid流程(setclientid,setclientid_confirm),但是在执行流程流程之前,必须确保之前的nfs客户端请求已经都结束,判断标准是通过slot_table中所有的slot已经都释放(highest_used_slotid == NFS4_NO_SLOT),如果存在还没有释放的slotid,则需要等待
  4. 当前出问题的客户端一直在恢复流程中等待所有slotid的释放,说明客户端在某些异常流程处理时没有将使用的slotid释放,或者实际slot都释放了,但是没有成功将等待的状态机线程唤醒,导致恢复流程卡住

解决办法

  1. 临时解决办法:从代码中看到状态机线程注册了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
  1. 长期解决办法:暂时没有看到内核针对此问题的针对性bugfix和patch,有一个关于此问题的报告但是没有解答(https://www.spinics.net/lists/linux-nfs/msg78299.html),建议使用更新版本的内核看问题是否再现,也希望有遇到类似问题的同学给予一些提示和帮助
上一篇:区块链教程Fabric1.0源代码分析流言算法Gossip服务端一兄弟连区块链教程


下一篇:一著名软件公司的java笔试算法题的答案