[Go] 如何完善处理 TCP 代理中连接的关闭

如何完善处理 TCP 代理中连接的关闭

TCP 单工连接(只关闭连接的读或写)在日常使用场景较少,但一个通用的 TCP 代理也需要考虑这个场景。

背景

今天在看老代码的时候,发现一个 TCP 代理的核心函数实现的比较粗糙,收到 EOF 后直接粗暴关闭两条 TCP 连接。

func ConnCat(uConn, rConn net.Conn) {
	wg := sync.WaitGroup{}
	wg.Add(2)

	go func() {
		defer wg.Done()
		io.Copy(uConn, rConn)
		uConn.Close()
		rConn.Close()
	}()

	go func() {
		defer wg.Done()
		io.Copy(rConn, uConn)
		uConn.Close()
		rConn.Close()
	}()

	wg.Wait()
}

一般场景下是感知不到问题的,但是做为一个代理,应该 只透传客户端/服务端的行为,不出现多余的动作。

比如只有客户端和服务端的情况,客户端在发送数据后直接关闭写,服务端在读取到 EOF 后仍然可以继续向客户端写入数据而不受影响。
如果写入的过程需要持续 3s 的时间,在代理的场景下上面 ConnCat 的处理就有问题,io.Copy 内部读取到 EOF 直接关闭两条连接,后续服务端向连接写入数据会失败。

连接关闭

TCP 连接断开发包顺序如下,一般的关闭场景操作系统会将第第二三个包进行合并发送,减少一次网络传输。

Client                                          Server
  |                                                  |
  \ -----------------------------------------------> |  FIN (seq=x)
  |                                                  |
  | <----------------------------------------------- /  ACK (ack=x+1, seq=y)
  |                                                  |
  | <----------------------------------------------- /  FIN (seq=y)
  |                                                  |
  \ -----------------------------------------------> |  ACK (ack=y+1, seq=x+1)
  |                                                  |

连接关闭相关的系统调用

  • close 关闭连接读写,并且释放 fd 资源
  • shutdown 关闭连接读、写或读写,相比 close 对连接的控制粒度更小,并且不会释放资源(意味着调用 shutdown 后还是需要调用 close

通知对端连接关闭为发送一个 FIN 包,shutdown(SHUT_WR) 为最小影响的触发 FIN 发送函数,closeshutdown(SHUT_RDWR) 都包括了其语义。

shutdown(SHUT_RD) 不会发送 FIN 包,只关闭连接的读,数据只会存在内核缓冲区中(控制不了对端是否往连接中写数据)

示例程序和抓包

以下是一个测试连接的 Go 代码架子,生成了两个 TCP 连接,放在两个 goroutine 中进行处理

func TestTCPClose(t *testing.T) {
	lis, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 12345})
	if err != nil {
		t.Fatal(err)
	}

	var (
		conn0     *net.TCPConn
		conn1     *net.TCPConn
		acceptErr error
	)

	acceptDoneCh := make(chan struct{})
	go func() {
		conn0, acceptErr = lis.AcceptTCP()
		close(acceptDoneCh)
	}()

	conn1, err = net.DialTCP("tcp", nil, lis.Addr().(*net.TCPAddr))
	if err != nil {
		t.Fatal(err)
	}
	<-acceptDoneCh
	if acceptErr != nil {
		t.Fatal(acceptErr)
	}

	wg := sync.WaitGroup{}
	wg.Add(2)

	// 两个 Conn 的处理
	go func() {
		wg.Done()
	}()
	go func() {
		wg.Done()
	}()

	wg.Wait()
	conn0.Close()
	conn1.Close()
}

正常关闭

连接不操作即可

	go func() {
		wg.Done()
	}()
	go func() {
		wg.Done()
	}()

非常常见的建立连接,关闭连接的过程

可以看到关闭合并了第二三个包

$ tcpdump -s0 -i lo port 12345 -nn
11:47:21.004953 IP 127.0.0.1.35404 > 127.0.0.1.12345: Flags [S], seq 3601102031, win 65495, options [mss 65495,sackOK,TS val 3232122047 ecr 0,nop,wscale 7], length 0
11:47:21.004964 IP 127.0.0.1.12345 > 127.0.0.1.35404: Flags [S.], seq 2679739517, ack 3601102032, win 65483, options [mss 65495,sackOK,TS val 3232122047 ecr 3232122047,nop,wscale 7], length 0
11:47:21.004973 IP 127.0.0.1.35404 > 127.0.0.1.12345: Flags [.], ack 1, win 512, options [nop,nop,TS val 3232122047 ecr 3232122047], length 0
11:47:21.005196 IP 127.0.0.1.12345 > 127.0.0.1.35404: Flags [F.], seq 1, ack 1, win 512, options [nop,nop,TS val 3232122048 ecr 3232122047], length 0
11:47:21.005206 IP 127.0.0.1.35404 > 127.0.0.1.12345: Flags [F.], seq 1, ack 2, win 512, options [nop,nop,TS val 3232122048 ecr 3232122048], length 0
11:47:21.005212 IP 127.0.0.1.12345 > 127.0.0.1.35404: Flags [.], ack 2, win 512, options [nop,nop,TS val 3232122048 ecr 3232122048], length 0

一个连接关闭读一个连接写入数据

连接处理如下,一个连接关闭读,另外一个连接分三次写入 10 个字节

	go func() {
		conn1.CloseRead()
		wg.Done()
	}()

	go func() {
		time.Sleep(time.Second)
		conn0.Write([]byte("hello"))
		conn0.Write([]byte("w"))
		conn0.Write([]byte("orld"))
		wg.Done()
	}()

	time.Sleep(time.Second)

抓包情况如下,可以看到是可以继续写入的,但是断开的时候直接发了一个 RST

$ tcpdump -s0 -i lo port 12345 -nn
11:25:05.480317 IP 127.0.0.1.40686 > 127.0.0.1.12345: Flags [S], seq 2951407472, win 65495, options [mss 65495,sackOK,TS val 3230786523 ecr 0,nop,wscale 7], length 0
11:25:05.480324 IP 127.0.0.1.12345 > 127.0.0.1.40686: Flags [S.], seq 1109826823, ack 2951407473, win 65483, options [mss 65495,sackOK,TS val 3230786523 ecr 3230786523,nop,wscale 7], length 0
11:25:05.480330 IP 127.0.0.1.40686 > 127.0.0.1.12345: Flags [.], ack 1, win 512, options [nop,nop,TS val 3230786523 ecr 3230786523], length 0
11:25:06.480602 IP 127.0.0.1.12345 > 127.0.0.1.40686: Flags [P.], seq 1:6, ack 1, win 512, options [nop,nop,TS val 3230787523 ecr 3230786523], length 5
11:25:06.480633 IP 127.0.0.1.40686 > 127.0.0.1.12345: Flags [.], ack 6, win 512, options [nop,nop,TS val 3230787523 ecr 3230787523], length 0
11:25:06.480662 IP 127.0.0.1.12345 > 127.0.0.1.40686: Flags [P.], seq 6:7, ack 1, win 512, options [nop,nop,TS val 3230787523 ecr 3230787523], length 1
11:25:06.480671 IP 127.0.0.1.40686 > 127.0.0.1.12345: Flags [.], ack 7, win 512, options [nop,nop,TS val 3230787523 ecr 3230787523], length 0
11:25:06.480682 IP 127.0.0.1.12345 > 127.0.0.1.40686: Flags [P.], seq 7:11, ack 1, win 512, options [nop,nop,TS val 3230787523 ecr 3230787523], length 4
11:25:06.480688 IP 127.0.0.1.40686 > 127.0.0.1.12345: Flags [.], ack 11, win 512, options [nop,nop,TS val 3230787523 ecr 3230787523], length 0
11:25:35.480940 IP 127.0.0.1.12345 > 127.0.0.1.40686: Flags [F.], seq 11, ack 1, win 512, options [nop,nop,TS val 3230816523 ecr 3230802547], length 0
11:25:35.481028 IP 127.0.0.1.40686 > 127.0.0.1.12345: Flags [R.], seq 1, ack 12, win 512, options [nop,nop,TS val 3230816523 ecr 3230816523], length 0

netstat 看到内核缓冲区的数据大小为 10

$ netstat -ntp
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp       10      0 127.0.0.1:40686         127.0.0.1:12345         ESTABLISHED 2810712/internal.te

两个连接分别关闭读(写)再进行写(读)

测试代码展示两个 TCP 连接分别关闭读(写)再进行写(读)

	go func() {
		conn1.Write([]byte("hello"))
		time.Sleep(time.Second * 1)
		conn1.CloseWrite()
		b := make([]byte, 1024)
		conn1.Read(b)
		wg.Done()
	}()

	go func() {
		b := make([]byte, 1024)
		conn0.Read(b)
		conn0.CloseRead()
		time.Sleep(time.Second * 2)
		conn0.Write([]byte("test"))
		wg.Done()
	}()

通过 tcpdump 抓包也可以看到 CloseWrite 会发送一个 FIN

$ tcpdump -s0 -i lo port 12345 -nn
17:21:09.877056 IP 127.0.0.1.44158 > 127.0.0.1.12345: Flags [S], seq 4257116181, win 65495, options [mss 65495,sackOK,TS val 3165750919 ecr 0,nop,wscale 7], length 0
17:21:09.877069 IP 127.0.0.1.12345 > 127.0.0.1.44158: Flags [S.], seq 188514168, ack 4257116182, win 65483, options [mss 65495,sackOK,TS val 3165750919 ecr 3165750919,nop,wscale 7], length 0
17:21:09.877081 IP 127.0.0.1.44158 > 127.0.0.1.12345: Flags [.], ack 1, win 512, options [nop,nop,TS val 3165750919 ecr 3165750919], length 0
17:21:09.877211 IP 127.0.0.1.44158 > 127.0.0.1.12345: Flags [P.], seq 1:6, ack 1, win 512, options [nop,nop,TS val 3165750920 ecr 3165750919], length 5
17:21:09.877219 IP 127.0.0.1.12345 > 127.0.0.1.44158: Flags [.], ack 6, win 512, options [nop,nop,TS val 3165750920 ecr 3165750920], length 0
17:21:10.878149 IP 127.0.0.1.44158 > 127.0.0.1.12345: Flags [F.], seq 6, ack 1, win 512, options [nop,nop,TS val 3165751920 ecr 3165750920], length 0
17:21:10.920263 IP 127.0.0.1.12345 > 127.0.0.1.44158: Flags [.], ack 7, win 512, options [nop,nop,TS val 3165751963 ecr 3165751920], length 0
17:21:11.877430 IP 127.0.0.1.12345 > 127.0.0.1.44158: Flags [P.], seq 1:5, ack 7, win 512, options [nop,nop,TS val 3165752920 ecr 3165751920], length 4
17:21:11.877460 IP 127.0.0.1.44158 > 127.0.0.1.12345: Flags [.], ack 5, win 512, options [nop,nop,TS val 3165752920 ecr 3165752920], length 0
17:21:11.882928 IP 127.0.0.1.12345 > 127.0.0.1.44158: Flags [F.], seq 5, ack 7, win 512, options [nop,nop,TS val 3165752925 ecr 3165752920], length 0
17:21:11.882957 IP 127.0.0.1.44158 > 127.0.0.1.12345: Flags [.], ack 6, win 512, options [nop,nop,TS val 3165752925 ecr 3165752925], length 0

分析

分析 TCP 的挥手过程,无论是 close 或者是 shutdown(SHUT_WR) 还是 shutdown(SHUT_RDWR) 都是只有一个 FIN 包发出。
对端只有通过读连接到出现 EOF 才能判断对端关闭了连接,至于是关闭写还是关闭读写这个并不清楚,但是收到 EOF 后可以等待数据全部写完再进行关闭。

两个 FIN 包发出的是可以有一个时间差的,也就是说调用 read 读取到 EOF 后可以隔一会再 close,这样确保所有后续的数据都写入到连接。
对端关闭连接读的情况下,那么写入如果报错(回包 RST)直接进入连接关闭逻辑处理即可,没有报错则认为对面是可以正常读的(内核影响,数据量小即使对端关闭读可能也不发送 RST,而是把数据放在缓冲区里面)。

一个代理服务完整建立的 TCP 连接图如下,每条线代表一条单工连接。

┌────────┐  R              W  ┌────────┐  R              W  ┌────────┐
│        │  ◄───────────────  │        │  ◄───────────────  │        │
│ Client │       UConn        │ Proxy  │        RConn       │ Server │
│        │  ───────────────►  │        │  ───────────────►  │        │
└────────┘  W              R  └────────┘  W              R  └────────┘

对于 Proxy 而言,需要将一条连接的包传递至另外一条连接,收到数据包则进行转发,读取到 EOF 则关闭另一条连接的写(也可以关闭本连接的读,多调用一次系统调用)

整个关闭的流程由 Client(Server 同样适用) 发起,是一个击鼓传花的过程:

  1. Client 关闭 UConn 连接的写端(或读端,后续数据写入报错则进入错误处理)
  2. Proxy 收到 UConn 的 EOF,关闭 RConn 连接的写端
  3. Server 收到 RConn 的 EOF,(可选,继续向连接中写入数据)关闭 RConn 连接的写端
  4. Proxy 收到 RConn 的 EOF,关闭 UConn 连接的写端
  5. 所有单工连接被关闭,连接代理再释放 fd 资源

核心实现

直接拿 docker-proxy 的实现修改一下,额外支持了主动退出的逻辑。

  • from.CloseRead() 这行代码可以不需要,已经 EOF,这条连接不会再出现数据了。
  • 读取或者写入失败的场景全部包含在 io.Copy 中,并且忽略了错误处理,尽可能减小两个代理过程的相互影响。
func ConnCat(ctx context.Context, client *net.TCPConn, backend *net.TCPConn) {
	var wg sync.WaitGroup

	broker := func(to, from *net.TCPConn) {
		io.Copy(to, from)
		from.CloseRead()
		to.CloseWrite()
		wg.Done()
	}

	wg.Add(2)
	go broker(client, backend)
	go broker(backend, client)

	finish := make(chan struct{})
	go func() {
		wg.Wait()
		close(finish)
	}()

	select {
	case <-ctx.Done():
	case <-finish:
	}
	client.Close()
	backend.Close()
	<-finish
}

参考

  1. https://github.com/moby/moby/blob/master/cmd/docker-proxy/tcp_proxy_linux.go#L27, docker-proxy 的 tcp 代理实现

posted on 2024-10-24 19:19  文一路挖坑侠  阅读(214)  评论(5编辑  收藏  举报

导航