本文首发于 vivo互联网技术 微信公众号
链接:https://mp.weixin.qq.com/s/AkbAN4UZLDf841g1ZLFPBQ
作者:Wu Yue
本文系统性的讲述了 DNS 协议与 WebSocket 协议的重要细节。
一、DNS
1、Linux dig命令
我们首先通过 Linux 下的dig命令来了解一下 DNS 是怎么做域名解析的。我们首先输入命令:
dig www.baidu.com
看下标注的红框,从左到右依次代表:
- 域名的名称 也就是服务器名称
- 网络类型,DNS协议在设计的时候考虑到了其他网络类型,但是目前位置这个值还是写死的IN 你就理解成是互联网就可以了。这个值一般不变
- 标识域名对应何种类型的地址,A 就代表ip的地址。
这里可能有人会问了,这个域名的后面为啥还有个“.”?我们输入的明明是www.baidu.com不是www.baidu.com.啊 。
这里要提一下:
末尾的.代表的就是根域名,每个域名都有根域名,所以通常我们会省略它。
根域名的下一级叫顶级域名,比如我们熟知的.com与.net。
再下一级就是次级域名了,比如例子中的.baidu。这个次级域名只要你有钱是可以随便注册的。
最后这个www,这个代表三级域名。一般是用户在自己的域里面为服务器分配的名称。用户可以随便分他。
所以可以看出来这里的域名是分级别的。能弄明白这点就能搞清楚为什么DNS的查询过程是分级查询了。
我们可以利用dig+trace命令来完整的还原一次分级查询的过程:
你看通过命令的方式就能一目了然的理解DNS查询的过程了。这远比你在网上搜一些什么DNS是递归查询啊之类的要来的直观。这里有眼尖的小伙伴可能会问,这个CNAME是用来干嘛的?大家只要理解CNAME主要用来做CDN加速的即可。详细的可以去维基百科查询,那里说的很清楚,本文受限于篇幅就不在这个知识点上展开了。
2、WireShark学习理解DNS报文
这里注意因为 Wireshark的捕获过滤器无法设置DNS协议,又因为DNS是基于 UDP协议的,所以这里捕获过滤器我们就设置为 UDP就好。
然后就可以在一堆 UDP报文中找到我们想看的DNS报文了,我们在浏览器中输入www.airbnb.com:
这里要注意左边有两个箭头,向右的箭头代表“请求”,向左的箭头就代表该“请求”的回复了。
这些DNS报文经过 Wireshark的解析以后,格式已经帮我们分析好了,所以看起来很清晰。也很简单。这里我们不再详细分析DNS的二进制报文格式,有兴趣的可以自行查找相关资料。在我们上述展示的DNS报文抓包截图的时候,细心的同学已经发现了,我们DNS报文的查询地址是172.22.3.102。一般而言,大部分公司内部网络都会提供一个统一的DNS服务器,这个地址就是内部的DNS服务器地址了,有图为证:
我们当然也可以使用其他DNS查询,比如使用著名的谷歌DNS
3、传统DNS服务查询的缺点
经过上述的分析看起来DNS的查询过程好像比较简单,但实际上DNS带来的性能或者安全问题很多很多。我们首先来还原一下完整的DNS查询过程(假设我们想访问csdn的网站):
- 浏览器输入一个域名地址,如果操作系统的DNS缓存中有这个域名的Ip地址 那么直接返回,没有的话 就去第二步。
- 操作系统会向 本系统设置的tcp/ip 参数中的DNS服务器地址 发出DNS查询报文。注意这个服务器我们通常叫他 本地DNS服务器。也就是上述我们截图中的172.22.3.102
- 如果本地DNS服务器的缓存中有这个域名对应的ip地址,那就直接返回,如果没有,继续下一步。
-
首先看DNS服务器的架构图:
- 也就是说当我们的本地DNS服务器缓存中没有该域名的ip地址的时候,本地DNS服务器就会直接向 根DNS(全世界只有13台)服务器去查询,然后根DNS服务器就会分析这个域名,告诉我们的本地DNS服务器 你应该去.net 这个DNS服务器去查询。然后.net这个DNS服务器又告诉本地DNS服务器 你应该去csdn.net 这个DNS服务器 去查询DNS地址。然后最终csdn的 DNS服务器就将正确的ip地址返回给我们的本地DNS,本地DNS再将这个值返回给我们的浏览器(这个过程其实你用前面的dig+trace命令可以更直观的体会到)。
通过上述的一次完整的DNS交互过程,我们可以至少得出三个结论:
- DNS服务器是可以做负载均衡的。当然前提条件是你这个域名得自己建一个DNS服务器。一般大厂都会自建。
- DNS的查询是一个递归的过程,弱网的情况,这个时间会变的很漫长。且DNS使用的是 UDP传输协议,弱网有直接查询失败的可能。
- DNS的查询过程不可控,比如说本地DNS服务器完全可以返回一个错误的ip地址。比如你访问了一个京东的链接,然后返回给你的ip地址是拼多多的。
这还只是表面上看出来的传统DNS查询的缺点,实际上现在我们每天大部分的流量都来自于移动网络。移动网络中,传统DNS服务暴露出来的问题更多:
- 前文我们说过本地DNS服务器会缓存域名的ip,但这个缓存时效我们控制不了,全靠运营商的操守。有可能发生我们ip地址已经变化,但是本地DNS服务器返回的还是老ip的情况。
- 有些运营商为了节省运营商和运营商之间的流量计算成本,会偷偷的将一些静态页面缓存。当用户访问这些页面的时候 往往访问的是这些静态页面的缓存服务器的地址。此时不管我们的页面更新了多少内容,对于用户来说都是老的页面。
- 运营商在某些场景,例如人口集中的地铁站,演唱会,足球场附近等等,一旦发现自己的用户太多,本地DNS服务器压力巨大的时候,就会手动设置将本地DNS服务器向根域名服务器
- 查询 然后递归查询 DNS的过程 修改成:直接向另外一个运营商(假设这个运营商名字为B)的DNS服务器地址进行查询,B的DNS服务器就会返回一个B的地址,此时运营商A的用户访问的ip地址就是运营商B的ip了,这种跨运营商访问的场景速度会非常慢。
- 某些宽带提供方的NAT服务非常不稳定,大家都知道我们在家上网的时候 本机地址其实就是一个内网地址,我们之所以能访问外部的网络是因为这些宽带提供方提供了一层网关来负责NAT,这个NAT会将我们的内网地址转换成一个外网的地址,NAT之后的ip,某些权威DNS服务器就无法判断这个ip到底属于哪个运营商。也会带来跨运营商访问的问题。
- 除了自己的DNS服务器,其他公共DNS服务器的缓存时效都不可控,这对双机房部署,异地多活,多域名等策略都会有影响。
- 弱网环境下,因为DNS使用的传输协议是不可靠的 UDP,又因为DNS查询的过程是一个递归的过程,所以DNS查询在弱网环境下是有概率失败的。
4、HTTPDNS
基于上述缺点,越来越多的大厂使用了HTTPDNS的这种技术(据腾讯的公开资料显示,15年腾讯每天的localDNS失败次数就达到了80w次,接入HTTPDNS以后,用户平均访问延迟下降超过10%,访问失败率下降了超过五分之一,用户访问体验的效果提升非常显著):
这种技术的原理其实挺简单的,无非就是让我们的手机App 发起一个HTTP请求(这个请求地址多数使用ip直连,如果使用域名那么依然针对此请求依然有传统DNS的问题),这个请求可以携带用户所在的运营商,地理位置,精确到省市,然后服务器根据这些信息 返回一个最佳的ip地址给App,然后App将这个域名-ip的映射关系设置到我们的okhttp中。这样手机中的大部分请求就会直接使用我们HTTP服务器返回的ip地址而不是运营商的地址了。
注意这里我说的是大部分请求而不是全部请求的原因是,对于Android系统来说,webview的DNS查询过程代码全部在c层,且版本和版本之间有一定差异,这部分的hook过程极为艰难,截止到这篇文章编写的时候,笔者依旧没有查询到公开的能够hook webview DNS的源码,而iOS这点做的显然就比Android好一些,对于iOS来说webview的HTTP就是一个正常的HTTPrequest 与原生的代码并没有任何区别。对于Android客户端来说,接入HTTPDNS也不是一件特别容易的事。即使现在拥有了okhttp。
方案一:
通过okhttp的拦截器,在发出请求之前将我们的url中的域名直接替换成ip,再手动往header中添加host头部信息。缺点:如果url是https的,ip直连会出现证书校验的问题。另外因为请求的时候 我们直接用的ip 但是 服务端返回的set cookie头部信息却带上的域名,这里也要额外处理。优点:因为是拦截器的实现机制,所以很容易做开关进行降级处理。
方案二:
通过okhttp的DNS直接接管。
public class HttpDNS implements DNS {
private static final DNS SYSTEM = DNS.SYSTEM;
@Override
public List<InetAddress> lookup(String hostname) throws UnknownHostException {
//假设这个DNShelper可以返回我们httpDNS查询的结果
String ip = DNSHelper.getIpByHost(hostname);
if (ip != null && !ip.equals("")) {
List<InetAddress> inetAddresses = Arrays.asList(InetAddress.getAllByName(ip));
return inetAddresses;
}
return SYSTEM.lookup(hostname);
}
}
//然后让okhttp使用我们的DNS实现就好
OkHttpClient client = new OkHttpClient.Builder()
.DNS(new HttpDNS())
.build();
这种方案就不存在拦截器哪种缺点,因为本质上这种方案和系统的DNS查询方案并无二致,无非系统的是 UDP去localDNS找,我们的是用HTTP去 HTTP服务器上找。这种方案可以解决方案一的所有缺点,但是有一个问题就是一旦这个HTTPDNS返回的结果有问题,那么很难降级。且okhttp的DNS查询也是有一层缓存的,一旦我们的HTTP DNS服务器返回的地址有误,那么在一定时间范围内后续针对这个域名的访问都会有问题。
前面我们说过Android自身webview的机制导致HTTPDNS很难在webview中起到作用,但是仍旧有一些方法可以尽量规避掉webview中loacalDNS速度慢的缺点。例如我们可以在html中设置预加载静态资源的DNS请求,而不用等到真正请求这些资源的时候才会查找DNS。
<!--域名预解析-->
<meta http-equiv="x-DNS-prefetch-control" content="on" >
<link rel="DNS-prefetch" href="//vivo.com.cn" >
考虑到实际上webview和App自身代码使用的DNS缓存都是操作系统中的同一块存储区域,我们也可以统计出我们常用web页面中频繁请求的url的域名,在App一启动的时机,就提前访问这些域名,这样等到热点web页面在加载的时候,如果操作系统DNS缓存已经有了对应的ip,则可以省略一次DNS的查询。
5、DNS真的是基于UDP协议的吗?
其实DNS协议真的不是完全基于UDP协议的,DNS的协议里面其实有主DNS服务器和辅DNS服务器的概念,辅DNS服务器在启动的时候会主动去主DNS服务器上拉取最新的该区域DNS信息。这个拉取的过程采用的就是TCP协议,而不是UDP协议。也就是协议文档中说的zone transfer。
这里有人可能会想到,为什么不用UDP协议来完成这个过程,因为UDP协议最大只能传送512个byte的数据,而辅DNS要拉取该区域的DNS信息很容易就超过这个最大报文数量的限制,所以这里采用的就是TCP协议来完成拉取数据的操作。
二、WebSocket
1、有HTTP轮询为什么还需要 WebSocket 技术?
很多人不明白为什么一定要用 WebSocket,明明我轮询HTTP请求一样可以完成需求。这句话本身并不错,可以用 WebSocket 的地方确实全部都可以用轮询HTTP请求来替代。但是其背后的效率却天差地别。
我们可以把 WebSocket 看成是 HTTP 协议为了支持长连接所打的一个大补丁,它和 HTTP 有一些共性,是为了解决 HTTP 本身无法解决的某些问题而做出的一个改良设计。在以前 HTTP 协议中所谓的 keep-alive 长连接是指在一次 TCP 连接中完成多个 HTTP 请求,但是对每个请求仍然要单独发 header;所谓的轮询是指从客户端不断主动的向服务器发 HTTP 请求查询是否有新数据。这种模式有三个缺点:
- 除了真正的数据部分外,服务器和客户端还要大量交换 HTTP header,信息交换效率很低。
- 因为HTTP是无状态的,每次请求服务端都要通过客户端传递来的参数来查询这个请求到底是谁的,例如每次都要查询一下这个userId下面有多少存款,买过几部手机等等,对服务器的宝贵的计算资源是一种浪费。
- 轮询的时间间隔不好设置,设置高了,用户的界面响应不及时,设置的太低,又怕流量消耗大,服务器扛不住。
当然轮询也有优点就是实现成本极低,几乎不需要客户端和服务端有额外的开发成本。WebSocket在首次使用的时候还是需要做一些基础设施改造的(例如nginx相应的配置)。WebSocket的实现成本:虽然说现代服务器编程中默认都提供了WebSocket的实现,但是我们知道考虑到扩展性等因素,我们通常都不会直接和源服务器打交道,而是和代理服务器打交道。对WebSocket来说同样如此,所以这里对于首次实现WebSocket的团队是有一定技术成本。
上图是一个简单的服务器架构图,客户端发出去的请求经过一台专门做负载均衡的代理服务器以后将这些请求逐一转发到对应的源服务器上。而对于WebSocket来说 情况则变的稍微有点复杂:
相比纯 HTTP来说,WebSocket通常会增加一层专门的消息分发系统提高消息的处理效率。通常是Kafka或者是RabbitMQ。
2、Wireshark解析WebSocket报文
首先 来看一下WebSocket的帧格式。我们首先设置一下 Wireshark的捕获器:
设置一下我们想要捕获的域名和端口号。注意WebSocket是可以复用HTTP端口号的。http://demos.kaazing.com这个网址是一个专门用来体验WebSocket技术的网址。我们以这网站为例。
可以看出来这里我们操作步骤一共是 connect,然后发消息,服务器返回我们发送的消息,最后我们主动断开连接。
WebSocket是一个基于帧的协议,所以这里我们着重分析一下WebSocket的帧格式,每个帧头部的 第4-第7个 bit位,这4个bit 代表的就是Opcode,比较重要的就是几个值:
- 2:代表这是二进制帧,
- 1:代表这是一个文本帧,
- 8:代表关闭帧。
3、WebSocket连接的建立过程
这里有人就要问了,既然WebSocket是能保证长连接(tcp)的,那么这条长连接是由谁发起的?看下图:
这个抓包结果显示的说明了WebSocket下面使用的tcp连接是交给HTTP1.1来发起的。来详细看一下,这里我用箭头标注的都是必须要设置的HTTP头部信息,否则是无法完成WebSocket连接的建立的。
此外我们还需要注意Sec-WebSocket-Accept,和Sec-WebSocket-Key 这2个头部信息。
客户端生成一个随机数以后用base64加密以后放到Sec-WebSocket-Key头部信息中,然后服务器接受到这个信息,用这个值与rfc中规定的一个魔法字符串:“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”拼起来,然后再使用sha-1加密 再经过base64 以后计算出来的值 放到Sec-WebSocket-Accept头部中返回给客户端。
这么做的原因是带来一些基础的保障,前面我们说过WebSocket连接的建立是依托HTTP消息的,为了防止这个WebSocket连接的建立是调用者无心误触发或者其他异常情况,所以这里有一次额外的数据校验的过程。
4、WebSocket连接的断开过程
看完连接,我们再看一下断开连接,与WebSocket的连接不同,WebSocket的断开连接是有明确步骤的,需要先断开WebSocket的连接,然后才是tcp的断开连接。
可以看出来,断开连接的步骤是客户端先发了一个断开连接的帧,然后服务端再给客户端发一个确认断开WebSocket连接的帧。最后就是tcp的四次挥手了,保证了tcp连接的彻底断开。
另外HTTP1.1中保持长连接的方法其实就是一个定时器,定时器大概时间为60s,如果60s都没有HTTP消息,那么这个tcp连接就断掉了。WebSocket虽然也是利用了HTTP1.1的消息来保证tcp的连接,但是保证这条tcp连接不被断开的方法却又不是定时器了,与mqtt xmpp等协议类似,WebSocket保持长连接的方法也是利用了心跳包。
在RFC协议中,规定了opcode 为0x9 0xA的帧为心跳帧,但是往往 这个关于心跳包的协议却很少有人遵守,很多时候人们选择间隔一段时间发送一个任意帧(当然这个任意帧的内容需要客户端和服务端提前约定好)来保证心跳包的建立。比如前文中我们拿来做例子的http://demos.kaazing.com网站,他的心跳包就没有遵守协议 而是:
图中可以看出来这个心跳包大概是30s发送一次,而且并没有使用rfc中约定好的0x9或者0xA的所谓ping pong的心跳帧,而是就用了最简单的文本帧来表示。
上图所示,左边的就是WebSocket 服务端发起的心跳包,opcode的值还是text文本帧的意思,只不过文本的内容很特殊。右边就是WebSocket客户端回复的心跳包。
5、WebSocket的代理缓存污染
这里要注意的是 Wireshark抓包的时候,最右边有一个masked的标识,这通常代表这一个WebSocket的帧是由客户端发送给服务端的,这是一个掩码的标识。在WebSocket协议中只要是客户端发起的消息,都必须经过这个随机的masking-key的掩码计算之后才能传输。这是为了解决代理缓存污染的问题。
注意这里问题的核心是实现不当的代理服务器,所谓实现不当的代理服务器就是指没有完整实现好WebSocket协议的代理服务器。而不是真正意义上恶意的代理服务器,恶意的代理服务器,用mask帧的技术是无法避免的。
所谓mask掩码技术就是指浏览器在发送WebSocket帧的时候必须生成一个随机的mask-key,在帧的二进制中将传输的内容与这个mask-key做异或操作。得出来的值才可以在网络中传输。
当我们的服务器接收到这个WebSocket帧以后就可以用这个mask-key来反异或,从而就可以得出真正的内容了,这是最低成本实现检测WebSocket帧是否遭到篡改的方案。例如:我们用WebSocket 传输一个 文本帧,内容为字符串vivo,vivo的ascii码的16进制为:76 69 76 6f。而这个消息,这次浏览器生成的mask-key 为 23 68 c0 a3。
我们将这2个值进行异或操作:
可以得到值为55 01 b6 cc。然后看一下抓包的帧内容里面是不是这个值: