掘金 后端 ( ) • 2024-05-25 15:30

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

问题发生

某一天收到报警,提示线上某一台机器 tcp.inuse metric 数量过多,通过监控查看机器有 10 多万个正在使用的 tcp scoket 数量。这个数量级明显是有问题了 赶紧排查。

image.png

问题排查

确认链接消耗

首先登录到机器 查看 scoket 状态,发现一共有 106735 个 socket,其中几乎都是 tcp 在使用,这也比较符合服务的情况,因为这个机器上的服务都是 http 服务,并且没有用 UDP 协议。

cat /proc/net/sockstat


sockets: used 106735
TCP: inuse 105772 orphan 0 tw 109 alloc 106576 mem 653
UDP: inuse 11 mem 12
UDPLITE: inuse 0
RAW: inuse 0
FRAG: inuse 0 memory 0

说明:

  • sockets:
    • used:已使用的所有协议套接字总量
  • TCP:
    • inuse:正在使用(正在侦听)的 TCP 套接字数量。其值等于 netstat –l n t | grep ^tcp | wc –l
    • orphan:无主(不属于任何进程)的 TCP 连接数(无用、待销毁的 TCP socket 数)
    • tw:等待关闭的 TCP 连接数。其值等于 netstat –ant | grep TIME_WAIT | wc –l
    • alloc(allocated):已分配(已建立、已申请到 sk_buff)的 TCP 套接字数量。其值等于 netstat –a n t | grep ^tcp | wc –l
    • mem:套接字缓冲区使用量
  • UDP:
    • inuse:正在使用的 UDP 套接字数量
    • mem: UDP 套接字使用的内存页面数。
  • RAW:
    • inuse: 当前使用中的 RAW 套接字数量(用于访问网络协议的底层)
  • FRAG:
    • inuse: 当前使用中的 IP 碎片重组缓存数。
    • mem: IP 碎片重组缓存使用的内存页面数。

执行 netstat -ant 查看全部 TCP 链接,发现大部分都是和本地的 1234 端口保持 ESTABLISHED 状态,例如

image.png

根据上面的 Local Address 中的端口,找到对应的进程,发现此进程是我们自身的服务。

lsof -iTCP:19585

由此可见是我们的服务链接端口 1234 进程的 tcp 连接数过多,并且链接状态大多数是 ESTABLISHED。定位到链接消耗的范围已经确定,本地进程调用端口 1234 的进程也符合我们的业务逻辑。下面就需要分析为什么会有这么多连接是 ESTABLISHED,为什么没有被释放。

确认服务的链接状态

TCP 连接的状态

首先我们先来了解下 TCP 的链接状态,在 TCP(传输控制协议)连接的建立和关闭过程中,会经历一系列特定的状态,这些状态定义了连接在不同阶段的状态和行为。了解这些状态有助于诊断和解决网络问题。

TCP 连接建立(Three-Way Handshake)

TCP 连接的建立过程称为“三次握手”,包括以下状态:

  1. CLOSED:初始状态,表示连接关闭或不存在。
  2. SYN-SENT:客户端在发送连接请求(SYN)报文后进入该状态,等待服务器的回应。
  3. SYN-RECEIVED:服务器在收到客户端的 SYN 报文并发送 SYN-ACK 报文后进入该状态,等待客户端的确认。
  4. ESTABLISHED:客户端和服务器在完成三次握手后都进入该状态,表示连接已经建立,可以进行数据传输。

三次握手详细步骤

  1. 客户端 → 服务器:SYN
    1. 客户端发送一个 SYN 报文,表示希望建立连接。
    2. 进入 SYN-SENT 状态。
  2. 服务器 → 客户端:SYN-ACK
    1. 服务器收到 SYN 报文后,发送一个 SYN-ACK 报文,表示同意建立连接,并要求确认。
    2. 进入 SYN-RECEIVED 状态。
  3. 客户端 → 服务器:ACK
    1. 客户端收到 SYN-ACK 报文后,发送一个 ACK 报文,确认连接建立。
    2. 客户端和服务器都进入 ESTABLISHED 状态,连接建立完成。

TCP 连接关闭(Four-Way Handshake)

TCP 连接的关闭过程称为“四次挥手”,包括以下状态:

  1. FIN-WAIT-1:主动关闭连接的一方(通常是客户端)发送 FIN 报文后进入该状态,等待对方的确认。
  2. FIN-WAIT-2:在收到对方的 ACK 报文后进入该状态,等待对方的 FIN 报文。
  3. CLOSE-WAIT:被动关闭连接的一方(通常是服务器)收到 FIN 报文后进入该状态,等待应用程序处理完成后关闭连接。
  4. LAST-ACK:被动关闭的一方在发送 FIN 报文后进入该状态,等待对方的确认。
  5. TIME-WAIT:主动关闭的一方在收到 FIN 报文后进入该状态,等待一段时间以确保对方收到 ACK 报文,防止旧数据包影响新连接。
  6. CLOSED:连接彻底关闭,释放所有资源。

四次挥手详细步骤

  1. 客户端 → 服务器:FIN
    1. 客户端发送一个 FIN 报文,表示希望关闭连接。
    2. 进入 FIN-WAIT-1 状态。
  2. 服务器 → 客户端:ACK
    1. 服务器收到 FIN 报文后,发送一个 ACK 报文,确认关闭请求。
    2. 服务器进入 CLOSE-WAIT 状态,客户端进入 FIN-WAIT-2 状态。
  3. 服务器 → 客户端:FIN
    1. 服务器处理完毕后,发送一个 FIN 报文,表示同意关闭连接。
    2. 进入 LAST-ACK 状态。
  4. 客户端 → 服务器:ACK
    1. 客户端收到 FIN 报文后,发送一个 ACK 报文,确认关闭。
    2. 客户端进入 TIME-WAIT 状态,等待一段时间后进入 CLOSED 状态。
    3. 服务器收到 ACK 报文后直接进入 CLOSED 状态。

连接状态示意图

image.png

TCP 连接的详细信息

通过上面的介绍,可以得知我们的链接都是 ESTABLISHED 状态,这说明我们的链接已经建立,可以进行数据传输。只不过一直没有进入到四次挥手(断开链接)的阶段。这相当符合 keepalive 的特性,下面我们来验证下。

利用 ss 工具查看 socket 详细信息

ss -aoen state established|grep 127.0.0.1:19585

image.png

可以看到确实是因为 keepalive 保持了链接,导致链接没有关闭。使用 keepalive 保持链接没有问题,但是不释放就有问题了,keepalive 释放可以从服务端释放也可以从客户端释放,通常是客户端或者服务端会设置超时。没有释放说明服务端和客户端都没有释放所以导致了大量 tcp 链接是 ESTABLISHED 状态。

问题代码定位

我们的服务 chaos-agent 是调用 1234 端口的服务保持了大量链接,1234 端口服务端 我们就不排查了,重点关注下自身服务的调用代码。

为什么会有这么多的 keepalive 链接在保持? 理论上应该是发起请求 复用 keepalive 链接就可以了,首先看下发送请求的代码,是一个常见的发送请求的函数,代码相对比较简单(越觉得简单的地方越容易出现问题)。代码逻辑:在这里构建了 http.Client 设置了 Transport,tranSport 中设置了 DialContext 超时时间为 10 秒钟,然后调用 client.Get()

func Curl(url string) (body string, statusCode int, err error) {
   var start = time.Now()


   // 记录每次请求的日志, 方便排查
   defer func() {
      logrus.Infof("client.Get||url=%s||code=%d||body=%s||err=%v||cost=%s", url, statusCode, body, err, time.Since(start))
   }()


   trans := http.Transport{
      DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
         return net.DialTimeout(network, addr, 10*time.Second)
      },
   }
   client := http.Client{
      Transport: &trans,
   }
   resp, err := client.Get(url)
   if err != nil {
      return "", 0, err
   }
   defer resp.Body.Close()
   bytes, err := ioutil.ReadAll(resp.Body)
   if err != nil {
      return "", resp.StatusCode, err
   }
   return string(bytes), resp.StatusCode, nil
}

其实问题就在 transport 的使用上,在 Go 的标准库中,net/http 包中的 Transport 结构体是用于控制 HTTP 客户端的传输行为的重要组件。Transport 结构体定义了 HTTP 客户端的传输参数、连接池、代理设置等,并提供了一些方法来定制 HTTP 客户端的行为。

Transport 结构体的一些主要作用:

  1. 连接池管理:Transport 结构体维护了一个连接池,用于管理与服务器的 TCP 连接。连接池可以复用已建立的连接,避免了频繁地创建和销毁连接,提高了性能和效率。
  2. 代理设置:Transport 结构体可以设置代理服务器的地址,用于通过代理服务器与目标服务器建立连接。这使得 HTTP 客户端能够通过代理服务器访问 Internet。
  3. TLS 配置:Transport 结构体可以配置 TLS(Transport Layer Security)参数,包括根证书、客户端证书、客户端密钥等,用于建立安全的 HTTPS 连接。
  4. 超时设置:Transport 结构体可以设置连接超时时间、读取超时时间、写入超时时间等,用于控制与服务器建立连接、发送请求和接收响应的超时行为。
  5. 重定向策略:Transport 结构体可以设置重定向策略,包括是否允许重定向、最大重定向次数等,用于控制 HTTP 客户端的重定向行为。
  6. 空闲连接管理:Transport 结构体可以设置空闲连接的超时时间,用于控制连接池中空闲连接的保持时间,超过该时间未被使用的连接会被关闭。
  7. 基础认证和代理认证:Transport 结构体可以设置基础认证和代理认证的用户名和密码,用于在发送请求时进行身份验证。
  8. HTTP/2 支持:Transport 结构体可以配置是否启用 HTTP/2 协议,以及 HTTP/2 相关的参数。

如果看过了解过或者阅读过 http 库代码的同学,一眼就能发现这个代码的问题。根本原因主要就是在每次调用时都新建了 transport,这导致了链接不能被复用。从而引发了每次调用都创建新的 tcp 连接,并且链接还是默认打开 keepalive 的。这样就会引发这个问题,链接累计的越来越多,且不能再使用完立刻关闭。

image.png

问题修复

修复这个问题比较简单,只需要将transport架构体定义为全局变量,为了更加优雅和安全又增加了最大空闲连接数和链接的空闲时间以及keealive的保持周期。参考如下:

image.png 上线运行后,观察了一段时间连接数不再持续增加,非常稳定,问题得到解决。

总结

通过本文我们可以了解到,在使用底层库时,必须小心谨慎,了解其实现机制,以避免因为不熟悉库的行为而引发问题。在处理网络连接问题时,了解 TCP 连接的状态、建立和关闭过程,以及熟悉相关工具的使用方法,能够更好帮助我们理解和解决问题。ps: 后续有精力会出一篇文章,详细介绍golang http client库的原理和设计。