原文:https://ops.tips/blog/how-linux-tcp-introspection/

在本文中,我们为读者介绍套接字在准备接受连接之前,系统在幕后做了哪些工作,以及“准备好接受连接”倒底意味着什么 。由于篇幅较长,本文分为上下两篇进行翻译,这里为下篇。

检查listen函数的backlog参数是否受具体域名空间所限


如果该参数确实由具体网络命名空间决定的话,那么,我们可以设法进入某个命名空间,设置某个限值,然后,让外部看到的却是另一个限值:

# Check the somaxconn limit as set in the
# default network namespace
cat /proc/sys/net/core/somaxconn
128


# Create a new network namespace
ip netns add mynamespace


# Join the network namespace and then
# check the value set for `somaxconn`
# within it
ip netns mynamespace exec \
        cat /proc/sys/net/core/somaxconn
128


# Modify the limit set from within the 
# network namespace
ip netns mynamespace exec \
        /bin/sh -c "echo 1024 > /proc/sys/net/core/somaxconn"


# Check whether the limit is in place there
ip netns mynamespace exec \
        cat /proc/sys/net/core/somaxconn
1024


# Check that the host's limit is still the
# same as before (128), meaning that the change
# took effect only within the namespace
cat /proc/sys/net/core/somaxconn
128

所以,通过/proc,我们可以看到相关的sysctl参数的情况,但是,它们真的就位了吗?

要解决这个问题,我们首先需要了解如何收集为给定套接字设置的backlog参数的限值。

利用procfs收集TCP套接字的相关信息


通过/proc/net/tcp,我们可以看到当前名称空间中所有的套接字。

通常,我们可以利用这个文件找到所需的大部分信息。

该文件包含了一些非常有用的信息,比如:

例如,在我们让套接字进入监听状态之后,就可以通过它来查看相关信息了:

# Retrieve a list of all of the TCP sockets that
# are either listening of that have had or has a
# established connection.    

        hexadecimal representation <-.
        of the conn state.           |
                                     |
cat /proc/net/tcp                    |
   .---------------.               .----.
sl | local_address | rem_address   | st | tx_queue rx_queue 
0: | 00000000:0016 | 00000000:0000 | 0A | 00000000:00000000 
   *---------------*               *----*
    |                                  |
    *-> Local address in the format    *-.
        <ip>:<port>, where numbers are   |
        represented in the hexadecimal   |
        format.                          |
                    .--------------------*
                    |
        The states here correspond to the
        ones in include/net/tcp_states.h:

enum {
    TCP_ESTABLISHED = 1,
    TCP_SYN_SENT,
    TCP_SYN_RECV,
    TCP_FIN_WAIT1,
    TCP_FIN_WAIT2,
    TCP_TIME_WAIT,     .-> 0A = 10 --> LISTEN
    TCP_CLOSE,         |
    TCP_CLOSE_WAIT,    |
    TCP_LAST_ACK,      |
    TCP_LISTEN,  ------*
    TCP_CLOSING,
        TCP_NEW_SYN_RECV,
    TCP_MAX_STATES,
};

当然,这里并没有看到为侦听套接字配置的backlog参数,这是因为该信息与处于LISTEN状态的套接字密切相关,当然,目前来说,这只是一个猜测。

那么,我们该如何进行检测呢?

检查侦听套接字的backlog参数的大小


为了完成这项任务,最简便的方法是使用iproute2中的ss命令。

现在,请考虑以下用户空间代码:

int main (int argc, char** argv) {
        // Create a socket for the AF_INET
        // communication domain, of type SOCK_STREAM
        // without a protocol specified.
        int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
        if (sock_fd == -1) {
                perror("socket");
                return 1;
        }

        // Mark the socket as passive with a backlog
        // size of 128.
        int err = listen(sockfd, 128);
        if (err == -1) {
                perror("listen");
                return 1;
        }

        // Sleep
        sleep(3000);
}

运行上面的代码后,执行ss命令:

# Display a list of passive tcp sockets, showing
# as much info as possible.
ss \
  --info \     .------> Number of connections waiting
  --tcp \      |        to be accepted.
  --listen \   |    .-> Maximum size of the backlog.
  --extended   |    |
        .--------..--------.
State   | Recv-Q || Send-Q | ...
LISTEN  | 0      || 128    | ...
        *--------**--------*

在这里,我们之所以使用的是ss,而非/proc/net/TCP,主要是因为后者的最新版本没有提供套接字的backlog方面的信息,而ss却提供了。

实际上,ss之所以能够提供这方面的信息,是因为它使用了不同的API从内核中检索信息,即它没有从procfs中读取信息,而是使用了netlink

Netlink是一种面向数据报的服务[...]用于在内核和用户空间进程之间传输信息。

鉴于netlink可以与许多不同内核子系统的通信,因此,ss需要指定它打算与哪个子系统通信——就套接字来说,将选择sock_diag

sock_diag netlink子系统提供了一种机制,用于从内核获取有关各种地址族套接字的信息。

     该子系统可用于获取各个套接字的信息或请求套接字列表。

更具体地说,允许我们收集backlog信息的是UDIAG_SHOW_RQLEN标志:

UDIAG_SHOW_RQLEN
       ...
       udiag_rqueue
              For listening sockets: the number of pending
              connections. [ ... ] 

       udiag_wqueue
              For listening sockets: the backlog length which
              equals to the value passed as the second argu
              ment to listen(2). [...]

现在,再次运行上一节中的代码,我们可以看到,这里的限制确实视每个命名空间而定。

好了,我们已经介绍了这个backlog队列的大小问题,但是,它是如何初始化的呢?

ipv4协议族中listen函数的内部运行机制


利用sysctl值(SOMAXCONN)限制backlog大小之后,下一步是将侦听任务交给协议族的相关函数(inet_listen)来完成。

这一过程,具体如下图所示。

为了提高可读性,这里已经对TCP Fast Open的代码进行了相应的处理,下面是inet_listen函数的实现代码:

int
inet_listen(struct socket* sock, int backlog)
{
    struct sock*  sk = sock->sk;
    unsigned char old_state;
    int           err, tcp_fastopen;

    // Ensure that we have a fresh socket that has
    // not been put into `LISTEN` state before, and
    // is not connected.
    //
    // Also, ensure that it's of the TCP type (otherwise
    // the idea of a connection wouldn't make sense).
    err = -EINVAL;
    if (sock->state != SS_UNCONNECTED || sock->type != SOCK_STREAM)
        goto out;

    if (_some_tcp_fast_open_stuff_) {
        // ... do some TCP fast open stuff ...

        // Initialize the necessary data structures
        // for turning this socket into a listening socket
        // that is going to be able to receive connections.
        err = inet_csk_listen_start(sk, backlog);
        if (err)
            goto out;
    }

    // Annotate the protocol-specific socket structure
    // with the backlog configured by `sys_listen` (the
    // value from userspace after being capped by the
    // kernel).
    sk->sk_max_ack_backlog = backlog;
    err                    = 0;
    return err;
}

完成某些检查后,inet_csk_listen_start开始侦听套接字的变化情况,并对连接队列进行赋值:

int inet_csk_listen_start(struct sock *sk, int backlog)
{
    struct inet_connection_sock *icsk = inet_csk(sk);
    struct inet_sock *inet = inet_sk(sk);
    int err = -EADDRINUSE;

        // Initializes the internet connection accept
        // queue.
    reqsk_queue_alloc(&icsk->icsk_accept_queue);

        // Sets the maximum ACK backlog to the one that
        // was capped by the kernel.
    sk->sk_max_ack_backlog = backlog;

        // Sets the current size of the backlog to 0 (given
        // that it's not started yet.
    sk->sk_ack_backlog = 0;
    inet_csk_delack_init(sk);

        // Marks the socket as in the TCP_LISTEN state.
    sk_state_store(sk, TCP_LISTEN);

        // Tries to either reserve the port already
        // bound to the socket or pick a "random" one.
    if (!sk->sk_prot->get_port(sk, inet->inet_num)) {
        inet->inet_sport = htons(inet->inet_num);

        sk_dst_reset(sk);
        err = sk->sk_prot->hash(sk);

        if (likely(!err))
            return 0;
    }

        // If things went south, then return the error
        // but first set the state of the socket to
        // TCP_CLOSE.
    sk->sk_state = TCP_CLOSE;
    return err;
}

现在,我们已经为套接字设置了一个地址、正确的状态集和一个为传入的连接进行排序的队列,接下来,我们就可以接收连接了。

不过,在此之前,先让我们来了解一下可能会遇到的一些情况。

如果侦听之前没有执行绑定操作的话,会出现什么情况


如果“根本”没有执行绑定操作的话,listen(2)最终会为你选择一个随机的端口。

为什么会这样呢?如果我们仔细考察inet_csk_listen_start用来准备端口的方法(get_port),我们就会发现,如果底层套接字没有选择端口的话,它会随机选一个临时端口。

/* Obtain a reference to a local port for the given sock,
 * if snum is zero it means select any available local port.
 * We try to allocate an odd port (and leave even ports for connect())
 */
int inet_csk_get_port(struct sock *sk, unsigned short snum)
{
    bool reuse = sk->sk_reuse && sk->sk_state != TCP_LISTEN;
    struct inet_hashinfo *hinfo = sk->sk_prot->h.hashinfo;
    int ret = 1, port = snum;
    struct inet_bind_hashbucket *head;
    struct inet_bind_bucket *tb = NULL;

        // If we didn't specify a port (port == 0)
    if (!port) {
        head = inet_csk_find_open_port(sk, &tb, &port);
        if (!head)
            return ret;
        if (!tb)
            goto tb_not_found;
        goto success;
    }

        // ...
}

所以,如果您不想在侦听的时候选择端口的话,那就随您便吧!

当接收连接的速度不够快时,哪些指标会达到峰值


假设套接字进入被动状态时,我们总是有两个队列(一个队列用于那些尚未完成三次握手的连接,另一个用于那些已经完成但尚未被接收的队列),我们可以想象 ,一旦接收连接的速度跟不上的话,第二个队列将逐渐被塞满。

我们可以看到的第一个指标是我们之前已经介绍过的指标,即sock_diag为特定套接字报告的idiag_rqueue和idiag_wqueue的值。

idiag_rqueue
       对于侦听套接字:挂起连接的数量。

       对于其他套接字:传入队列中的数据量。

idiag_wqueue
       对于侦听套接字:积压长度。

       对于其他套接字:可用于发送操作的内存量。

虽然这些对于每个套接字的分析来说非常有用,但我们可以查看更高级别的信息,以便从整体上了解该机器的接收队列是否将出现溢出情况。

鉴于每当内核尝试将传入请求从syn队列转移到接收队列并失败时,它会在ListenOverflows上记录一个错误,所以,我们可以跟踪错误的数量(您可以从/proc/net/netstat中获取该数据):

# Retrieve the number of listen overflows
# (accept queue full, making transitioning a
# connection from `syn queue` to `accept queue`
# not possible at the moment).
cat /proc/net/netstat
cat /proc/net/netstat
TcpExt: SyncookiesSent SyncookiesRecv ...  ListenOverflows
TcpExt: 0 0 ... 105 ...

当然,我们可以看到,/proc/net/netstat提供的数据的格式不够人性化。这时,netstat(工具)就有了用武之地了:

netstat --statistics | \
        grep 'times the listen queue of a socket overflowed'
105 times the listen queue of a socket overflowed

想知道内核代码中发生了什么吗? C详见tcp_v4_syn_recv_sock

/*
 * The three way handshake has completed - we got a valid synack -
 * now create the new socket.
 */
struct sock *tcp_v4_syn_recv_sock(const struct sock *sk, struct sk_buff *skb,
                  struct request_sock *req,
                  struct dst_entry *dst,
                  struct request_sock *req_unhash,
                  bool *own_req)
{
        // ...
    if (sk_acceptq_is_full(sk))
        goto exit_overflow;
        // ...
exit_overflow:
    NET_INC_STATS(
                sock_net(sk), 
                LINUX_MIB_LISTENOVERFLOWS); // (ListenOverflows)
}

现在,如果syn队列接近满载,但是仍然没有出现三方握手已经完成的连接,所以不能将连接转移到接收队列,假设该队列眼看就要溢出了,那该怎么办呢?

这时,另一个指标就派上用场了,即TCPReqQFullDrop或TCPReqQFullDoCookies(取决于是否启用了SYN cookie),详情请参见tcp_conn_request

如果想知道某时刻第一个队列(syn队列)中的连接数是多少,我们可以列出仍处于syn-recv状态的所有套接字:

# List all sockets that are in
# the `SYN-RECV` state  towards
# the port 1337.
ss \
  --numeric \
  state syn-recv sport = :1337

关于该主题,在CloudFlare上有一篇很棒的文章:SYN packet handling in the wild

大家不妨去看看吧!

小结


如果能够理解为接收新连接而设置服务器TCP套接字所涉及的一些边缘情况的话,自然是极好的。所以,我计划对这个过程中涉及的其他一些内容做进一步的解释,以便帮助读者理解现代的TCP的一些怪癖行为,但那是另一篇文章的任务。最后,祝大家阅读愉快!

参考资料


源链接

Hacking more

...