You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

29 KiB

03 | 握手TCP连接都是用TCP协议沟通的吗

你好,我是胜辉。

在前面预习篇的两节课里我们一起回顾和学习了网络分层模型与排查工具也初步学习了一下抓包分析技术。相信现在的你已经比刚开始的时候多了不少底气了。那么从今天开始我们就要正式进入TCP这本大部头而首先要攻破的就是握手和挥手。

TCP的三次握手非常有名我们工作中也时常能用到所以这块知识的实用性是很强的。更不用说技术面试里面无论是什么岗位似乎只要是技术岗都可能会问到TCP握手。可见它跟操作系统基础、编程基础等类似同属于计算机技术的底座之一。

握手,说简单也简单,不就是三次握手嘛。说复杂也复杂,别看只是三次握手,中间还是有不少学问的,有些看似复杂的问题,也能用握手的技术来解决。不信你就跟我看这几个案例。

TCP连接都是用TCP协议沟通的吗

看到这个小标题可能你都觉得奇怪了TCP连接不用TCP协议沟通还用什么呢

确实一般来说TCP连接是标准的TCP三次握手完成的

  1. 客户端发送SYN
  2. 服务端收到SYN后回复SYN+ACK
  3. 客户端收到SYN+ACK后回复ACK。

这里面SYN会在两端各发送一次表示“我准备好了可以开始连接了”。ACK也是两端各发送了一次表示“我知道你准备好了我们开始通信吧”。

那既然是4个报文为什么是三次发送呢显然服务端的SYN和ACK是合并在一起发送的就节省了一次发送。这个在英文里叫Piggybacking就是背着走搭顺风车的意思。

如果服务端不想接受这次握手,它会怎么做呢?可能会出现这么几种情况:

  1. 不搭理这次连接,就当什么都没收到,什么都没发生。这种行为,也可以说是“装聋作哑”。
  2. 给予回复,明确拒绝。相当于有人伸手过来想握手,你一巴掌拍掉,真的是非常刚了。

第一种情况,因为服务端做了“静默丢包也就是虽然收到了SYN但是它直接丢弃了也不给客户端回复任何消息。这也导致了一个问题就是客户端无法分清楚这个SYN到底是下面哪种情况

  1. 在网络上丢失了,服务端收不到,自然不会有回复;
  2. 对端收到了但没回,就是刚才说的“静默丢包”;
  3. 对端收到了也回了,但这个回包在网络中丢了。

你看就这么简单的一个SYN还能引申出三种状况出来。感觉什么东西一沾上网络就要变成麻烦事啊。所以跟我们在第1讲里学过的一样:设计网络协议真的不简单。

那么从客户端的角度对于SYN包发出去之后迟迟没有回应的情况它的策略是做重试而且不止一次。那会重试几次呢重试多久呢这个问题一下子还不太好回答。不过有tcpdump帮忙我们可以搞清楚重试的问题也可以搞清楚“TCP连接是否都用TCP协议沟通”的问题。

动手实验

你可以借助Iptables和tcpdump做个实验来验证这件事。你需要一台测试用的服务端安装Ubuntu等Linux类系统然后用你的笔记本作为客户端发起测试。这里我也放了一个视频展示了这个实验过程你可以结合着对照来看。

注意在这个视频中我是直接在tcpdump窗口里解读抓包结果的而在下面我们是用Wireshark来解读思路其实是一样的只是操作方式略有不同正好你可以都学习一下。

第一步在服务端执行下面的这条命令让Iptables静默丢弃掉发往自己80端口的数据包

Iptables -I INPUT -p tcp --dport 80 -j DROP

第二步在客户端启动tcpdump抓包

sudo tcpdump -i any -w telnet-80.pcap port 80

第三步从客户端发起一次telnet

telnet 服务端IP 80

这个时候这个telnet会挂起

大约一两分钟后才会失败退出,你随后就会明白背后发生了什么。

这时你可以把客户端的tcpdump停掉了按下Ctrl+C。然后用Wireshark打开这个抓包文件看看里面是什么

telnet挂起的原因就在这里握手请求一直没成功。客户端一共有7个SYN包发出或者说除了第一次SYN后续还有6次重试。客户端当然也不是“傻子”这么多次都失败就放弃了连接尝试把失败的消息传递给了用户空间程序然后就是telnet退出。

这里有个信息很值得我们关注。第二列是数据包之间的时间间隔也就是1秒2秒4.2秒8.2秒16.1秒33秒每个间隔是上一个的两倍左右。到第6次重试失败后客户端就彻底放弃了。

显然,这里的翻倍时间,就是“指数退避”(Exponential backoff)原则的体现。这里的时间不是精确的整秒,因为指数退避原则本身就不建议在精确的整秒做重试,最好是有所浮动,这样可以让重试成功的机会变得更大一些。

这里实际上也是一个知识点了:TCP握手没响应的话操作系统会做重试。在Linux中这个设置是由内核参数net.ipv4.tcp_syn_retries控制的默认值为6也就是我们前面刚观察到的现象。以下就是我的Ubuntu 20.04测试机的配置:

$ sudo sysctl net.ipv4.tcp_syn_retries
net.ipv4.tcp_syn_retries = 6

还有另外好几个有关TCP重试的设置值也都可以调整。更全面的内容呢你可以直接man tcp查看tcp的内核手册的信息。比如下面就是对于tcp_syn_retries的解释

tcp_syn_retries (integer; default: 5; since Linux 2.2)
       The  maximum  number of times initial SYNs for an active TCP connection attempt will be retransmitted.  This value should not be higher than 255.  The default value is 5, which corresponds to approximately 180 seconds.

既然静默丢包会引起客户端空等待的问题,那我们直接拒绝,应该就能解决这个问题了吧?

正好Iptables的规则动作有好几种前面我们用DROP那这次我们用REJECT这应该能让客户端立刻退出了。执行下面的这条命令让Iptables拒绝发到80端口的数据包

Iptables -I INPUT -p tcp --dport 80 -j REJECT

跟前面的实验一样我们在客户端发起telnet 服务端IP 80。果然telnet立刻退出显示

$ telnet 47.94.129.219 80
Trying 47.94.129.219...
telnet: connect to address 47.94.129.219: Connection refused
telnet: Unable to connect to remote host

可见连接请求确实被拒绝了。我在telnet同时也抓了包我们来看一下抓包文件

奇怪抓包文件里并没有期望的TCP RST是我们抓包命令没写对吗下面是这条命令你已经初步学过tcpdump抓包命令了看看有没有什么问题

sudo tcpdump -i any -w telnet-80-reject.pcap host 47.94.129.219 and port 80

命令语法没问题要不然命令都无法执行。那过滤条件呢指定了远端IP和端口这是很常见的用法应该也没什么问题。

但是,这里隐藏了一个假设的前提也就是我们认为这次握手的所有过程都是通过这个80端口进行的。但事实上呢我们稍微改一下抓包条件只保留远端IP去掉端口的限制

sudo tcpdump -i any -w telnet-80-reject.pcap host 47.94.129.219

然后再来看看,我们抓到的报文是怎样的:

很意外,居然对端回复了一个ICMP消息Destination unreachable (Port unreachable)。这还不是最意外的,我们选中这个报文,进一步看它的详情,可能会更惊讶:

原来这个ICMP消息不仅通过type=3表示这是一个“端口不可达”的错误消息而且在它的payload里面还携带了完整的TCP握手包的信息而这个握手包可是客户端发过来的。

补充一下如果我们回头再检查一下前面生成的Iptables规则它是这样的

-A INPUT -p tcp -m tcp --dport 80 -j REJECT --reject-with icmp-port-unreachable

原来它自动补上了reject-with icmp-port-unreachable也就是说确实用ICMP消息做了回复。当然你还可以把这个动作定义为reject-with tcp-reset那样的话就符合我们一开始的期望了。
 
事实上无论是收到TCP RST还是ICMP port unreachable消息客户端的connect()调用都是返回ECONNREFUSED这就是telnet都报“connection refused”的深层次原因。

所以,这个握手失败的情况终于搞清楚了,它是这么发生的:

TCP握手拒绝这个事竟然可以是ICMP报文来达成的。“握手过程用TCP协议做沟通”看起来这么理所当然的事情居然也会反转你是不是也有点自我怀疑了是不是其他网络知识也未必是我自己认为的那样呢

这个知识点其实是几年前我在处理一个客户的TCP连接问题时遇到的。剧情么前面已经给你“演”过一遍了。当时我也深感TCP的水太深快没过脖子了甚至有点喘不过气来……从此以后我再也不敢小看任何知识点同时也领教了tcpdump和Wireshark在网络分析方面的威力。有了这两个大杀器的帮助我的网络水平提高很快。这个经验我也分享给你相信你也一定能从中受益。

Windows服务器加域报RPC service unavailable?

虽然tcpdump + Wireshark的组合威力强大但用起来总是会稍微花点时间。**有没有不用抓包分析也能做排查TCP连接问题的方法呢**这样也好快一点啊。接下来这个例子,就是这样的。

我们eBay也有不少Windows服务器这些机器都由Active Directory简称AD管理。有一次我们有一台Windows服务器加入AD失败相关同事已经排查了好久一直没找到原因。操作过程就是最普通的加域动作

然后一开始显示加域成功但是过一两分钟后又会来个“回马枪”冒出来一个The RPC server is unavailable的报错

在Windows的体系里面这个报错大体意思是连不上RPC服务器。同事检查过RPC服务端并没有问题然后其他Windows客户端加域呢也都正常唯独这台就不行。

单独一台机器加不了域,本身也不是特别大的麻烦,但是同事还是想找一下根因,于是就让我帮忙。很幸运,当时我只用了大概十分钟就找到了原因(这里我有点不谦虚了,我对你扔过来的鸡蛋和番茄表示接受)。

这倒不是我对Windows多么精通主要是正确的排查思路帮助了我。给你分享一下我当时的思路:

  1. 既然报错是RPC unavailable那可能意味着有一个RPC服务没有得到响应。
  2. 没有得到服务端的响应,那多半是跟网络有关系,特别是跟端口的连通性有关系。
  3. 要知道RPC使用的是动态端口每次连接都可能连接到不同的服务端口。所以我也没办法预先知道是具体哪几个端口如果我知道的话直接找防火墙团队去把那几个服务端口打开就好了但这个做不到。这一点也是同事卡了许久的原因之一他也不知道如何找到这些“动态会变的RPC端口”。
  4. 要找到实时在用的动态RPC端口最方便的方法就是运行netstat命令。无论连接是处在什么状态比如是在传输数据的ESTABLISHED状态、新近关闭端口的TIME_WAIT状态都可以用netstat命令看到。
  5. 我运行了netstat在当时的命令输出中我注意到有一个 SYN_SENT状态的连接,它要连的就是服务端的一个高端口。

那么这个SYN_SENT状态究竟说明了什么呢

SYN_SENT是TCP的11个状态之一。要理解SYN_SENT的含义我们首先要把整个TCP状态机的机制搞清楚。关于TCP状态机目前流传比较广的是下面这张图。我没有考证过这张图的出处不过在Stevens的《UNIX网络编程套接字联网API》里就有这张图很有可能最早就是来自于Stevens

这张图浓缩了TCP状态转换的所有知识点确实值得反复研读。不过我鸡蛋里挑个骨头这张图也有个小小的问题就是对于初学者来说它并不容易理解。

比如多年前我自己在学习TCP的时候就一直没有彻底看懂这张图。好笑的是我经常假装自己看懂了还拿这张图跟别人侃侃而谈而对方还被我唬住了呢。所以你也要学会了当大家都不是很懂的时候你对自己的话越相信你就越有说服力哦。

好了当然是跟你开个玩笑做学问还是要严谨。那么这张图的难点在哪呢我觉得主要是视角不固定一会是发送方一会是接收方对初学者来说很容易混淆。实际上在Stevens的这本书中还有另外一张图我认为更加清晰明了也是我想推荐给你的

在上面这张图里无论是客户端或者服务端我们从上往下看它要经历的各个TCP状态都展示得十分清楚。我把这个过程解读如下

后续的过程,不用我继续解读,你也会看得很清楚了:分别沿着左边和右边的垂直线从上往下看就经历了客户端和服务端的TCP生命周期里的各种状态,这个过程中,视角保持一致。你觉得是否比前面那张转换图,更加容易理解呢?

看懂了这张图你应该就明白了SYN_SENT这个状态意味着当时这个连接请求SYN包已经从这台Windows服务器发出试图跟远端的AD域控制器进行连接。但由于对端迟迟没有回应SYN+ACK报文那么客户端这个连接的状态就只能“停留”在SYN_SENT状态无法转化为ESTABLISHED状态。

等到达了SYN timeout时间后Windows操作系统会放弃这次连接而这个SYN_SENT状态的连接也会消失不见。所以前面提到的“实时”两字也是很关键的。如果不是在问题发生时运行netstat哪怕是过了几分钟再去运行netstat错过了这个SYN_SENT我也不能发现这个失败的TCP连接企图也就无法定位到真正的原因了。

然后我们拿着这个端口去找防火墙团队,对方检查了配置,发现这个端口确实是禁止的。在开通后,问题就解决了。

所以说,真的不要小看任何知识点和小工具,你掌握以后,完全可以起到关键性的作用对了排查防火墙也时常是我们工作的痛点我在第5和第6讲会专门讲解这方面的排查技巧敬请期待

这里还有一个技术点我想给你展开一下。我们在前面已经讨论过了SYN重试的问题显然这次Windows的SYN_SENT的背后我们相信应该也是有数次的SYN重试的情况。同时因为我观察到这个SYN_SENT停留了大约有十几二十秒所以我判断应该也有指数退避的存在所以这个状态才保留了那么长时间。

也就是说无论是Linux还是Windows都实现了类似的TCP握手方面的容错手段。还是那句话设计网络不容易。理解了设计者的初心,很多问题就不会那么模糊了,可能你一下子就能看清。

发送的数据还能超过接收窗口?

最后一个案例表面上并不直接跟握手相关,但背地里就……不剧透了,看剧情。

前段时间有个朋友找到我咨询一个问题。他们最近处理了一个Redis相关的技术问题让他们既开心又“闹心”。开心的是整体分析是正确的问题也得以解决“闹心”的是唯独有个技术点好像无法自圆其说所以想让我看看到底是怎么回事。

这个问题是Redis服务告诉客户端它的接收窗口是190字节但是客户端居然会发送308字节大大超出了接收窗口。下图是他们用Wireshark打开抓包文件后的界面

我一开始也懵了难道TCP的深水又到我脖子这儿了在我多年的抓包分析经历中数据超过接收窗口的情况好像还没有遇到过这次算是TCP准备再次让我“开开眼”吗

不过我很快又稳定了下来因为我想到了一个朋友他们没有注意到的细节。在说到TCP窗口的时候一般都会提到一个很重要的概念Window Scale。这是因为TCP最初是七八十年代的产物1981年9月定稿的RFC793才第一次正式确定了TCP的标准。当时的网络带宽还处于“石器时代”机器的带宽只有现在的百分之一那么TCP接收窗口自然也没必要很大2个字节长度代表的65535字节的窗口足矣。

但是后来网络带宽越来越大65535字节的窗口慢慢就不够用了于是设计者们又想出了一个巧妙的办法。原先的Window字段还是保持不变在TCP扩展部分也就是TCP Options里面增加一个Window Scale的字段它表示原始Window值的左移位数最高可以左移14位。

如果你还没有完全忘记计算机课的基本知识那么应该明白这是一个非常大的提升了扩大了2的14次方即16384倍。16384乘以65535这个数字就是1G字节也就是说一个启用了Window Scale特性的TCP连接最大的接收窗口可以达到1GB。可以说这个数字至今都是够用的。

说了这么多我们用Wireshark来看看它究竟长啥样。选中一个SYN报文在Wireshark窗口中部找到TCP的部分展开Options就能看到了

我们逐一理解下。

  • Kind这个值是3每个TCP Option都有自己的编号3代表这是Window Scale类型。
  • Length3字节含Kind、Length自己、Shift count。
  • Shift count6也就是我们最为关心的窗口将要被右移的位数2的6次方就是64。

小小提醒SYN包里的Window是不会被Scale放大的只有握手后的报文才会。

当然TCP的窗口也是TCP知识体系里一块挺大的分支领域我会在当前这个“实战一”模块的传输效率部分也就是第9~11讲里详细讲解这方面的知识帮你把这块的东西真正搞透。

回到握手。既然Window Scale这么有用那每个TCP报文应该都是带上这个信息的吧因为它在TCP头部里面嘛而每个TCP报文都有头部的不是吗

你要这样想就错了。事实上Window Scale只出现在TCP握手里面。你再想想就明白了这个是“控制面”的信息说一次让双方明白就够了每次都说不光显得“话痨”也很浪费带宽啊。一般传输过程中的报文完全不需要再浪费这3个字节来传送一个已经同步过的信息。所以握手之后的TCP报文里面是不带Window Scale的。

比如我们来看一个传输中的报文比如客户端发送的TLS Client Hello报文

可见原始窗口502字节放大128倍后就是64256字节了。

说到这里,想必你已经明白了:我朋友这次的疑惑,其实就是缺少TCP握手包造成的。要知道Wireshark也一样要依赖握手包才能了解到这次连接用的Window Scale值然后才好在原始Window值的基础上对Window值进行右移放大得出真正的窗口值。于是因为这次他们的抓包没有抓取到握手报文所以Wireshark里看到的窗口就是190字节而不是190字节的某个倍数了

当时通信的另一端当然知道这个信息所以它发送308字节一点都不意外因为这个值根本就没超出接收窗口。

那么,**是不是没有抓取到握手包的话Wireshark里读取到的Window就一定不对呢**大部分时候是这样的。不过还有一部分老系统的TCP栈并没有启用Window Scale那么抓包文件中有没有握手包都没关系只要看基本Window就好了。

说到这里你对TCP握手的印象是不是又有改变呢它简单也丰富它靠谱也调皮。你只有真的读懂它才不会被它牵着鼻子走。而读懂它的方法是什么呢

就是多读些TCP理论就是多做些抓包分析就是多处理些案例更是多走走多看看。只要有心你总有机会可以学会可以成长。

小结

作为这个模块的第一课这次我们围绕TCP握手展开了几个有趣的案例并从中梳理了以下知识点

  1. 客户端发起的连接请求可能因为各种原因没有回复,这时客户端会做重试。一般在Linux里重试次数默认是6次内核参数是net.ipv4.tcp_syn_retries。重试间隔遵循了指数退避原则
  2. 服务端拒绝TCP握手除了用TCP RST另外一种方式是通过ICMP Destination unreachablePort unreachable消息。从客户端应用程序看这两种回复都属于“对端拒绝”所以应用表面看不出区别但我们在抓包的时候要注意如果单纯抓取服务端口的报文就会漏过这个ICMP消息可能对排查不利。
  3. 对于连通性相关的问题除了用tcpdump+Wireshark这个黄金组合我们还可以在理解TCP握手原理的基础上使用小工具比如netstat来排查。特别是对于RPC服务场景在问题发生时及时执行netstat -ant找到SYN_SENT状态的连接,这个很可能是突破口。
  4. 我们也学习了如何在Wireshark中查看Window Scale。握手包中的Window Scale信息十分重要这会帮助我们知道正确的接收窗口。在分析抓包文件时要注意是否连接的握手包被抓取到没有握手包这个Window值一般就不准。

可以说,**应用都靠连接,连接都靠握手。**掌握好了握手你的TCP就算入门了。学完这节课之后你有没有觉得今天的你比昨天的你要强一些了呢加油后面更多的知识在等你来发现。

思考题

最后,还是按照惯例,还是给你留几道思考题:

  1. 在Linux中还有一个内核参数也是关于握手的net.ipv4.tcp_synack_retries。你知道这个参数是用来做什么的吗
  2. 如果握手双方一方支持Window Scale一方不支持那么在这个连接里Window Scale最终会被启用吗你可以参考RFC1323,给出你的解答。

欢迎在留言区分享你的答案,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。

扩展知识:聊聊几个常见误区

很多时候,我们的成长不仅是由于学到了正确的知识,更是由于纠正了“错误的认知”。下面列几个常见误区,你看看自己有没有“中招”。

UDP也有握手?

有些同学会有这个误解可能是跟nc这个命令有关。我们来看一个TCP端口22的测试

victor@victorebpf:~$ nc -v -w 2 47.94.129.219 22
Connection to 47.94.129.219 22 port [tcp/ssh] succeeded!

同一时间的tcpdump抓包显示这个TCP经历了成功的握手和挥手

$ sudo tcpdump -i any host 47.94.129.219
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on any, link-type LINUX_SLL (Linux cooked v1), capture size 262144 bytes
11:58:10.749372 IP victorebpf.51072 > 47.94.129.219.ssh: Flags [S], seq 966857509, win 64240, options [mss 1460,sackOK,TS val 1565897461 ecr 0,nop,wscale 7], length 0
11:58:10.781734 IP 47.94.129.219.ssh > victorebpf.51072: Flags [S.], seq 3170176001, ack 966857510, win 65535, options [mss 1460], length 0
11:58:10.781880 IP victorebpf.51072 > 47.94.129.219.ssh: Flags [.], ack 1, win 64240, length 0
11:58:10.782344 IP victorebpf.51072 > 47.94.129.219.ssh: Flags [F.], seq 1, ack 1, win 64240, length 0
11:58:10.782586 IP 47.94.129.219.ssh > victorebpf.51072: Flags [.], ack 2, win 65535, length 0
11:58:10.821202 IP 47.94.129.219.ssh > victorebpf.51072: Flags [P.], seq 1:42, ack 2, win 65535, length 41
11:58:10.821292 IP victorebpf.51072 > 47.94.129.219.ssh: Flags [R], seq 966857511, win 0, length 0

如果我们用nc测试 UDP 22端口看看会发生什么。注意UDP 22是没有服务在监听的。但是nc一样告诉我们succeeded这似乎在告诉我们这个UDP 22端口确实是在监听的

$ nc -v -w 2 47.94.129.219 22
Connection to 47.94.129.219 22 port [tcp/ssh] succeeded!
victor@victorebpf:~$ nc -v -w 2 47.94.129.219 -u 22
Connection to 47.94.129.219 22 port [udp/*] succeeded!

同一时间的抓包显示客户端发送了4个UDP报文但服务端没有任何回复

11:59:05.605556 IP victorebpf.54145 > 47.94.129.219.22: UDP, length 1
11:59:05.605995 IP victorebpf.54145 > 47.94.129.219.22: UDP, length 1
11:59:06.606436 IP victorebpf.54145 > 47.94.129.219.22: UDP, length 1
11:59:07.607134 IP victorebpf.54145 > 47.94.129.219.22: UDP, length 1

从表象上看nc告诉我们这个跟UDP 22端口的“连接”是成功的这是nc的Bug吗可能并不算是。原因就在于UDP本身不是面向连接的所以没有一个确定的UDP协议层面的“答复”。这种答复需要由调用UDP的应用程序自己去实现。

那为什么在这里nc还是要告诉我们成功呢可能只是因为对端没有回复ICMP port unreachable。nc的逻辑是

  • 对于UDP来说除非明确拒绝否则可视为“连通”
  • 对TCP来说除非明确接受否则视为“不连通”。

所以当你下次用nc探测UDP端口不通的结果是可信的而能通succeeded的结果并不准确只能作为参考。

一台机器最多65535个TCP连接?

这也是很常见的误区了。我还是小白的时候也曾经深信不疑。当时读到一篇讨论服务器可以承受多少TCP连接就是C10k问题的文章时还觉得奇怪不是端口范围只有0~65535吗为什么还会有几十万上百万连接呢

这就是没有意识到,连接是四元组(咱们在第一节课讲到过并不是单纯的源端口或者目的端口。那么多个数相乘这个乘积当然可以远远超过65535了。先不谈论海量级网站的场景就算我们维护一台Web服务器假如当前有10万台客户端连着你平均每个客户端跟你有6个连接这很常见那么就是60万个连接了是不是也早就超过6万了

当然在限定场景下一个客户端假设只有一个出口IP和一个服务端假设也只有一个IP和一个服务端口那么确实只能最多发起6万多个连接。但你自己也已经明白这跟前面的误解已经是两回事了。

不能同时发起握手?

如果两端同时发送了SYN给对方也就是双方都收到了一个SYN那么接下来它们会进入什么状态呢你可能觉得这应该不行。

其实通信双方还真的可以同时向对方发送SYN也能建立起连接。你可以参考这节课里我提到的TCP状态转换图。在Richard Stevens的《TCP/IP详解第一卷里,也提到了这个知识点,参考下图:

当然这种情况是很罕见的你可以参考一下也丰富一下你对TCP握手的理解。