网络问题排查笔记
{Back to Index}  

Table of Contents

1 TCP 流程

1.1 正常连接

tcp_conn.png

Figure 1: 三次握手

  1. 客户端向服务端发送 SYN 包请求建立 TCP 连接。客户端连接状态变为 SYN_SENT 状态。
  2. 服务端收到 SYN 包之后,服务端连接状态变为 SYN_RECV 状态。随后会创建轻量级 request_sock 结构来表示连接信息(里面能唯一确定某个客户端发来的 SYN 的信息),并将这个 request_sock 结构放入 TCP 的半连接队列 SYN_Queue 中,TCP 内核协议栈发送 SYN+ACK 包给客户端。
  3. 客户端的 TCP 内核协议栈收到服务端发送过来的 SYN+ACK 后,随即回复 ACK 包给服务端。此时客户端连接状态变为 ESTANLISHED 状态。
  4. 服务端收到客户端的 ACK 包之后,从半连接队列中查找是否有代表该客户端连接的轻量级 request_sock 结构,如果有,连接状态变为 ESTABLISHED 状态,随后会从半连接队列 SYN-Queue 中将 request_socket 结构取出移动到全连接队列 ACCEPT-Queue 中。
  5. 用户进程的 accpet 系统调用根据监听 Socket 克隆出一个真正的连接 Socket 然后返回。

1.2 半连接队列 SYN-Queue 已满(SYN flood)

sync_queue_full.png

假设现在有大量的客户端在向服务端发送 SYN 包请求建立连接,但是这些客户端比较坏,在收到服务端的 SYN+ACK 包之后就是不回复 ACK 包给服务端,而服务端一直收不到客户端的 ACK 包,所以就会重传 SYN+ACK 包给客户端,重传次数由内核参数 tcp_synack_retries 限制,默认为 5 次。

5 次的重传时间间隔为 1s , 2s , 4s , 8s , 16s ,总共 31s ,而第 5 次重传的 SYN+ACK 包发出后还要等 32s 才能知道第 5 次也超时了,所以,总共需要 1s + 2s + 4s+ 8s+ 16s + 32s = 63s ,TCP 才会把断开这个连接,并从半连接队列中移除对应的 request_sock 。

当半连接队列溢出之后,再有正常的客户端连接进来之后, 内核协议栈默认情况下就会直接丢弃 SYN 包 ,导致服务端无法处理正常客户端的请求,这就叫做 SYN Flood 攻击。

1.3 全连接队列 ACCEPT-Queue 已满

当服务端的负载比较大并且从全连接队列中 accept 连接处理的比较慢,同时又有大量新的客户端连接上来的时候,就会导致 TCP 全连接队列溢出。

内核参数 net.ipv4.tcp_abort_on_overflow 会影响内核协议栈处理全连接队列溢出的行为。

1.3.1 tcp_abort_on_overflow=0

tcp_abort_on_overflow = 0 时,服务端内核协议栈会将该连接标记为 acked 状态, 但仍保留在 SYN-Queue 中 ,并开启 SYN+ACK 重传机制(通过忽略ACK的方式)。当 SYN+ACK 包的重传次数超过 net.ipv4.tcp_synack_retries 设置的值时,再将该连接从 SYN queue 中删除。
但是此时在客户端的视角来说,连接已经建立成功了。 客户端并不知道此时 ACK 包已经被服务端所忽略,如果此时向服务端发送数据的话,服务端会回复 RST 给客户端。

accept_queue_full0.png

1.3.2 tcp_abort_on_overflow=1

当 tcp_abort_on_overflow = 1 时, 服务端TCP 协议栈直接回复 RST 包,并直接从 SYN-Queue 中删除该连接信息。

accept_queue_full1.png

1.4 正常关闭

tcp_close.png

Figure 5: TCP连接关闭

1.5 Linger

在默认情况下,当调用 Socket 的 close 方法后 , close 方法会立即返回 ,剩下的事情会交给内核协议栈处理,
如果此时 Socket 对应的发送缓冲区还有数据待发送,接下来内核协议栈会将 Socket 发送缓冲区的数据发送出去,随后会向对端发送 FIN 包关闭连接。
注意:此时应用程序是无法感知到这些数据是否已经发送到对端的,因为应用程序在调用 close 方法后就立马返回了, 剩下的这些都是内核在替我们完成。接着主动关闭方就进入了 TCP 四次挥手的关闭流程最后进入TIME_WAIT状态。

SO_LINGER 选项会控制调用 close 方法关闭 Socket 的行为。

struct linger {
      int l_onoff;   // linger active
      int l_linger;  // how many seconds to linger for
};
l_onoff
表示是否开启 SO_LINGER 选项。0 表示关闭。默认情况下是关闭的。
int l_linger
如果开启了 SO_LINGER 选项,则该参数表示应用程序调用 close 方法后需要阻塞等待多长时间。单位为秒。

这两个参数的不同组合会影响到 Socket 的关闭行为:

1.5.1 选项关闭

l_onoff = 0 时 l_linger 的值会被忽略,即默认关闭行为。

1.5.2 阻塞

l_onoff = 1,l_linger > 0 时,应用程序调用 close 方法后就不会立马返回, 无论 Socket 是阻塞模式还是非阻塞模式,应用程序都会阻塞在这里。
直到以下两个条件其中之一发生,才会解除阻塞返回。随后进行正常的四次挥手关闭流程。

  • 当 Socket 发送缓冲区的数据全部发送出去,并等到对端 ACK 后,close 方法返回。
  • 应用程序在 close 方法上的阻塞时间到达 l_linger 设置的值后,close 方法返回。

linger.png

Figure 6: linger

1.5.3 RST

l_onoff = 1,l_linger = 0 时,当应用程序调用 close 方法后会立即返回,随后内核直接 清空 Socket 的发送缓冲区,并向对端发送 RST 包
主动关闭方直接跳过四次挥手进入 CLOSE 状态, 注意这种情况下是不会有 TIME_WAIT 状态的。

linger_rst.png

Figure 7: linger rst

1.6 主动关闭方在关闭时 Socket 接收缓冲区还有未处理数据

close_socket_early.png

Figure 8: 客户端提前关闭Socket

  • 主动关闭方在调用 close() 系统调用关闭 Socket 时,内核会检查 Socket 接收缓冲区中是否还有数据未被读取处理,如果有,则直接清空 Socket 接收缓冲区中的未处理数据,并向对端发送 RST 。
  • 如果此时 Socket 接收缓冲区中没有未被处理的数据,内核才会走正常的关闭流程,尝试将 Socket 发送缓冲区中的数据发送出去,然后向对端发送 FIN ,走正常的四次挥手关闭流程。

1.7 连接半关闭

half_close.png

Figure 9: TCP连接半关闭

2 TCP 队列溢出

2.1 原理

tcp-queue-overflow.jpg

Figure 10: syns/accept 队列

上图中,有两个队列:

syns queue
半连接队列
accept queue
全连接队列

三次握手阶段,当 server 收到 client 的 syn 后,把消息放到 syns queue 中,并回复 syn+ack 给 client , 当 server 收到 client 的 ack 时,如果这时 accept queue 没满,那就从 syns queue 中 pop 出暂存的信息放入 accept queue 中, 否则按 net.ipv4.tcp_abort_on_overflow 设置的值执行:

  • net.ipv4.tcp_abort_on_overflow = 0
    表示如果三次握手第三步的时候 accept queue 满了,则 server 丢弃 client 发过来的 ack 。
  • net.ipv4.tcp_abort_on_overflow = 1
    表示第三步的时候如果全连接队列满了,server 发送一个 rst 包给 client ,表示拒绝这个握手过程和这个连接

2.2 查看队列溢出信息

# netstat -s | grep -i listen
    1 times the listen queue of a socket queue overflowed -> 全连接队列溢出的次数
    1 SYNs to LISTEN sockets dropped -> 半连接队列溢出的次数


# ss -lnt
State       Recv-Q Send-Q                              Local Address:Port                                             Peer Address:Port
LISTEN      0      128                                             *:80                                                          *:*
                   ---
                    |-> 全连接队列 (backlog) 最大为 128
LISTEN      0      5                                               *:48080                                                       *:*
            -
            |-> 全连接队列当前使用了多少
LISTEN      0      128                                             *:4369                                                        *:*
LISTEN      0      128

2.3 设置队列参数

全连接队列的大小取决于 min(backlog, net.core.somaxconn) 。 其中 backlog 是在 socket 创建的时候传入的,somaxconn 是一个 OS 级别的系统参数。

半连接队列的大小取决于 max(64, net.ipv4.tcp_max_syn_backlog)

# cat /proc/sys/net/core/somaxconn
128
cat /proc/sys/net/ipv4/tcp_max_syn_backlog
128

3 TIME_WAIT

TIME_WAIT 对系统是有帮助的,不是多余的。

tcp_state_machine3.jpg

Figure 11: TCP 状态机

3.1 统计 TCP 状态

# ss -ant | awk '{print $1}' | sort | uniq -c
     20 CLOSE-WAIT
    233 ESTAB
     26 LISTEN
      1 State -> 这行是 header ,可以忽略
     14 TIME-WAIT

3.2 作用

duplicate-segment.png

Figure 12: 上一个连接延迟的数据包被接收后,影响到新的连接

last-ack.png

Figure 13: 最后一个 ACK 包丢失,导致对端停留在 LAST-ACK 状态,影响新连接的建立

3.3 相关参数

3.3.1 net.ipv4.tcp_timestamps

tcp_tw_reuse 和 tcp_tw_recycle 默认都是关闭的,这两个参数必须在 timestamps 打开的前提下才能生效使用,timestamps 默认都是打开的。

3.3.1.1 查看因 timestamps 而被拒绝的数据包
~$ netstat -s | grep timestamp
        timestamp requests: 8
    282 packets rejects in established connections because of timestamp -> 282 个包被丢弃

3.3.2 net.ipv4.tcp_tw_reuse

允许将一个处于 TIME-WAIT 状态的端口重新用于新的 TCP 连接,默认为 0 ,表示关闭,其防止重复报文的原理是基于时间戳。

当复用连接后,连接的时间被更新为当前的时间,当延迟的数据达到,延迟数据的时间是小于新连接的时间, 所以,内核可以通过时间判断出,延迟的数据可以安全的丢弃掉了。

3.3.3 net.ipv4.tcp_tw_recycle 不要开启

这个配置,主要影响到了 inbound 的连接即 做为服务端角色 ,客户端连进来,服务端主动关闭了连接,TIME_WAIT 状态的 socket 处于服务端,服务端快速的回收该状态的连接。

当开启了这个配置后,内核会快速(3.5*RTO 内)回收处于 TIME_WAIT 状态的 socket 连接,

这里不再是复用了,而是之前处于 TIME_WAIT 状态的连接已经被 destroy 掉了, 新的连接,刚好是和某一个被 destroy 掉的连接使用了相同的五元组而已。

在启用该配置,当一个连接进入 TIME_WAIT 状态后,内核里会记录包括该连接对应的五元组中的对方 IP 等在内的一些统计数据, 包括从对方 IP 所接收到的最近的一次数据包时间。 当有新的数据包到达,只要时间晚于内核记录的这个时间,数据包都会被统统的丢掉。 因此,同一源 IP 主机的连接请求中的 timestamp 必须是递增的。

但是同一个源 IP 可能会是 NAT 后很多机器,这些机器 timestamp 递增性无可保证,服务器会拒绝非递增请求连接,直接导致不能三次握手。

3.3.4 net.ipv4.tcp_max_tw_buckets

调整 tcp_tw_reuse 和 tcp_tw_recycle 其实是违反 TCP 协议规定的,在服务器资源允许,负载不大的条件下,尽量不要打开,尽量选择调大该参数。

这个参数的意义是:同时保持 TIME_WAIT 套接字的最大个数。
超过这个数字那么该 TIME_WAIT 套接字将立刻被释放并在 /var/log/message 日志中打印警告信息(TCP: time wait bucket table overflow)。

4 抓取 HTTPS 数据

假设使用跳板机,且目标地址为 webexapis.com

  1. 跳板机上生成自签名证书

    openssl genrsa 2048 >ca.key.pem    ## 生成 2048 位私钥
    openssl req -new -x509 -key ca.key.pem -out ca.cert.pem -days 365
    
  2. 跳板机上监听加密端口

    LOCAL_PLAIN_PORT=48080
    socat openssl-listen:443,cert=ca.cert.pem,key=ca.key.pem,verify=0,reuseaddr,fork tcp:127.0.0.1:$LOCAL_PLAIN_PORT
    
  3. 跳板机上监听明文端口

    LOCAL_PLAIN_PORT=48080
    TARGET=webapis.com
    socat tcp-listen:$LOCAL_PLAIN_PORT,reuseaddr,fork openssl-connect:$TARGET:443,verify=0
    
  4. 修改本机 /etc/hosts

    <jumper_ip>       webexapis.com
    

Author: Hao Ruan (ruanhao1116@gmail.com)

Created: 2020-04-12 Sun 22:03

Updated: 2024-12-31 Tue 11:05

Emacs 27.2 (Org mode 9.4.4)