cover_image

HTTPDNS 从理论到实践

Copypeng 小米有品技术团队
2022年01月06日 09:18

 提供的服务再好,用户连接不上也白搭。本文从理论出发,逐步阐述了小米有品技术团队在移动端对网络DNS方面的改进与尝试


01

DNS的工作原理


DNS,全称Domain Name System,即域名解析系统。它负责将域名,例如:www.baidu.com,转换成它对应的IP地址:182.61.200.6。

你可以尝试在浏览器中输入http://www.baidu.com或者http://182.61.200.6,它们都能访问百度的服务,但应该没有人会日常使用 IP 地址来上网。DNS 的作用有点类似电话通讯录,没人能记得住冗长且可能会变化的电话号码,但记住一个名称相对简单。

DNS 是互联网的基石之一,无论网页还是 APP,每一个网络请求的第一步,就是通过 DNS 查询 IP 地址。无论你是通过 WiFi,还是 4G/5G 移动数据上网,你的互联网服务提供商或称为运营商(ISP),例如:中国联通、中国移动,都会下发几个 DNS 服务的地址给你的手机或电脑,每次进行网络请求时,都会先向 DNS 服务查询访问域名的 IP 地址,之后再继续向拿到的 IP 地址发送你真正的想发送的请求。

我们以访问网页https://www.xiaomiyoupin.com为例,描述一次 DNS 查询的大致过程:

图片

  1. 手机或电脑等客户端向运营商下发的 DNS 服务器地址发送www.xiaomiyoupin.com这个域名的DNS查询请求。

  2. 运营商 DNS 服务器会查询本地缓存记录,如果有有效IP地址则返回给请求客户端,没有则自己向上级服务器查询,如果没有会继续向再上级查询。这个过程被称为递归查询。

  3. 递归查询一直到权威服务器,返回IP地址。为了方便描述,此处可以简单地认为“权威服务器“保存了所有域名与IP地址的对应关系,所有的域名注册与更新都在此记录。实际上权威服务器与TLD服务器以及根服务器协同才能完成这个过程。

  4. 权威服务器返回有效IP地址,递归服务器一级一级缓存并将IP地址返回给请求客户端。缓存有效期内,当递归服务器再次收到相同域名请求时,可返回缓存结果,而无需再费力递归查询至权威服务器。

  5. 请求客户端拿到IP地址 220.181.52.160。

  6. 请求客户端向目标IP地址 220.181.52.160 发送 HTTPS 请求,获得结果。 


02

使用运营商 DNS服务的问题


尽管默认使用运营商提供的 DNS 服务是目前绝对主流的域名解析方式,每天支撑着几乎所有设备连接互联网,为海量信息导航到目标机器。但它也有不少问题:

  • 可用性取决于当地的运营商运维水平,可能出现 DNS 服务器故障,越偏远的地区,出现问题的可能性越大,维护不够及时。

  • 使用 UDP 明文通信,容易被人恶意劫持。

  • 部分运营商可能为节约成本,减少服务器查询次数,超长使用缓存,导致域名如果已经在权威域名服务器变更了 IP 地址,无法及时生效,最终造成用户无法访问。尤其是遇到业务需要及时调度流量时,由于运营商的缓存问题,等待解析结果在全网生效的时间将十分漫长。

  • 除了缓存 IP 地址,运营商还会缓存内容。比如域名A下的某段 json 数据,用户访问后,运营商为了加快访问速度,会将该数据保存至自己的服务器,后续有用户再次访问该数据时,返回的 IP 并不是域名A的,而是运营商自己的服务器。用户访问没有问题,但是域名A作为数据源头想及时更新这段数据,将变得非常困难。

  • 获得的 IP 地址并不是最优。为方便用户更快速的访问,一个域名往往在不同运营商网段、不同地区部署了多个 IP 地址,理想情况下就近访问同网段的 IP 地址速度最佳。但存在部分运营商偷懒自己不部署 DNS 递归服务器,而是将 DNS 解析请求转发到其他运营商,或者利用 NAT 转换,将用户的出口公网 IP 不定向转换至其他运营商,导致无法准确判断请求来源,最终从权威服务器返回的往往是跨网段、跨地区的 IP 地址,用户访问相对较慢。

图片

*图中用户 C 本应获得IP 3.3.3.3 才能访问速度最快


  • 最后是解析延时问题。由于各地运营商服务质量参差不齐,线路、路由、DNS 服务器性能差异等等,可能进行一次 DNS 查询的延时严重,最终导致用户网络请求耗时增加。

一方面是当下 DNS 服务的隐忧,另一方面,黑客针对 DNS 的攻击也越来越频繁。根据 IDC 发布的《2020年全球 DNS 威胁报告》,2020年全球有 79% 的企业或组织受到了 DNS 攻击,每个组织平均遭受攻击9.5次,每次攻击造成的损失(包括补救成本、时间成本、商业损失)平均高达 92.4w 美金。

有品电商2020年7月抽样监控 iOS 与 Android 平台小米有品 APP ,统计显示,有 3.044 % 的网络请求无法通过 DNS 获取IP地址,同时有 0.314% 网络请求获取的 IP 地址不正确(被劫持)。


03

HTTPDNS 的优势


随着 DNS 问题的突出,越来越多的互联网服务商开始直接向用户提供 DNS 服务。毕竟如果用户因为自己网络的问题无法打开你的 APP,他们大多会抱怨你 APP 服务不行,运营商也不用承担责任。而使用 HTTPDNS 是移动端APP、PC客户端等应用在现阶段提高用户 DNS 服务质量非常有效的方法。

HTTPDNS 使用 HTTPS 协议绕过运营商 DNS,直接将用户的域名解析请求发送到自己或购买的第三方服务的服务器上,然后返回 IP 地址。原理并不复杂,但带来的收益却十分明显:

  • HTTPS 协议相比于传统 DNS 依旧使用 UDP 53 端口明文发送域名查询,具有极高的安全性与隐私性。能有效避免被劫持的问题。

  • 直接绕过了运营商,服务可用性自己保障,并且直接解决了 DNS 缓存无法及时更新的问题。可根据自身业务需求快速更新 IP 地址,调度流量。

  • 客户端在本地构建缓存,IP 地址有效期内不进行 DNS 网络请求,直接使用本地 IP。用户能获得比以往更快、更稳定的网络请求速度。

  • 用户客户端发起 HTTPDNS 请求时,可直接获取客户端本地 IP 传输给服务器,服务器可结合 IP 地址库返回给客户端同网段、距离最近的 IP 地址。同时服务器可以返回多个 IP 地址,客户端在用户实际网络环境中测速后,选择最优 IP 地址。

  • 对用户来说,无感知,无额外操作。对互联网服务商来说,扩展性强,业务可根据自身需求实现更精细化的流量调度,甚至是不同版本客户端、不同网络类型、不同地理位置都可以做差异化处理。

  • 如果 HTTPDNS 出现任何故障,依然可以切换使用原运营商提供的 DNS 服务。


04

移动端 HTTPDNS 实践


想要实现一套完整的 HTTPDNS 方案并充分发挥上述优势,需要不少工作并注重一些细节。现结合行业资料以及小米有品 APP 实现经验,总结如下:

一,后端服务选型

一个高可用性、灵活的 HTTPDNS 后端服务是整个方案的基础。选型时必须考虑以下需求:

  • 提供 HTTPDNS API,使用域名与客户端 IP 作为参数,返回距离客户端最近的 IP 地址或地址列表,以及有效时间(TTL)。

  • 能及时同步权威域名服务器。

  • 多节点分布,支持 AnyCast IP(见文章末尾拓展阅读:什么是AnyCast)访问,避免 API 自身遭遇 DNS 故障,以及可以节点就近访问,加快获取速度。

除自建服务外,目前市面有阿里云与腾讯云提供的 HTTPDNS 服务相对主流。两者除上述基础需求能满足之外,还支持扩展,方便流量的精准调度。


图片

*阿里云 HTTPDNS 服务支持函数计算自定义域名解析


二,构建HTTPDNS SDK


图片

*HTTPDNS 交互流程

HTTPDNS SDK 涵盖交互图中的查询模块与更新模块。外部工程使用 SDK 接口,传入需要解析的域名,SDK内部根据域名查询缓存,如果当前缓存中有 TTL 未过期的 IP 地址,则直接返回。如果缓存中没有有效 IP,返回空,外部工程应立刻让此次请求降级走原生 DNS 解析,同时 SDK 内异步发起 HTTPDNS API 请求,获取结果,更新缓存,这样后续解析域名时就能命中缓存。

对于单域名对应多 IP 的情况,SDK 内实现 TCP Socket 连接测速功能,根据连接速度将 IP 地址排序,更新缓存。同时,可对外提供评价接口,外部工程通过接口反馈网络请求成功与否,SDK内根据测速与访问成功率综合评价排序。

考虑到目前普遍将IP TTL设定得相对较短:普通的600s,短至10s。而大多数情况下,业务不会频繁的更换 IP 地址,如果每次 TTL 过期则请求 API 刷新缓存会徒增服务器压力,SDK 可对外提供接口是否允许使用 TTL 过期的 IP 地址,让业务灵活使用。

提供预解析接口,让外部工程启动时既可传入域名,开始请求获取IP地址。以便在真实业务请求前,已获得 IP 地址,缓存直接命中。

提供缓存持久化功能,对于 IP 地址更换不频繁的业务,可以选择开启该功能,将缓存保存在本地,APP 启动时即可直接使用,可以加快首屏的加载速度。

监测用户网络类型,当用户切换网络,比如 Wi-Fi 与移动数据网络切换时,应立即异步请求 API,获取最新IP地址,更新缓存。

检测系统网络代理状态,当用户使用网络代理时,停止HTTP DNS解析,让外部工程使用原生网络请求。

如果使用第三方提供的HTTP DNS服务,他们大多会提供SDK,覆盖上述功能,可直接使用。而我们在参考阿里云、腾讯云等商用SDK的基础上,根据业务实际需求,完善加入了:IPv4、IPv6双栈优选支持 Webview用户常用WiFi缓存检测等功能,已在小米有品APP Android 与 iOS 两个平台上线。


三,平滑接入

完成 SDK,解决掉获取 IP 地址的问题后,客户端如何平滑地接入现有网络引擎,是个比构建 SDK 更值得研究、探讨的课题。我们分 Android 与 iOS 两个部分来叙述:

Android

相比于 iOS,Android 简直太幸运,有优秀的 OkHttp 项目,并且已经成为事实上的“标准”网络库,被绝大多数应用广泛使用,从Android 4.4起,系统自身也内置使用了 OkHttp。更加幸运的是,OkHttp支持自定义 DNS。参考文档:

https://square.github.io/okhttp/4.x/okhttp/okhttp3/-ok-http-client/-builder/dns/

我们实现 Interface Dns 即可:

public class myDns implements Dns {//复写lookup函数,这是OkHttp进行DNS查询的入口@Override  public List<InetAddress> lookup(String hostname) throws UnknownHostException {    //从SDK获取IP地址列表    final String[] ips = myHttpDnsSDK.getIPByHostname(hostname);    //获取成功(SDK内缓存命中),则使用    if(ips !=null && ips.length > 0) {      List<InetAddress> inetAddresses = new ArrayList<>();      for (String ip : ips) {        inetAddresses.add(InetAddress.getByName(ip));      }      return inetAddresses;     }    //获取失败,则使用系统默认DNS    return Dns.SYSTEM.lookup(hostname);   }}


然后将实现的类,在 OkHttpClient 对象初始化时传入:


OkHttpClient client = new OkHttpClient().newBuilder().dns(myDns).build();

如果工程中有多个 OkHttpClient 或分布在第三方库中,不方便介入初始化过程,可以考虑使用 Aspect 方式做切面拦截织入。例如:

@Before("call(* okhttp3.OkHttpClient.Builder.build(..))")public void beforeBuildOkHttpClient(JoinPoint joinPoint) {    ((OkHttpClient.Builder)joinPoint.getTarget()).dns(myDns);}


iOS
如果你的工程使用原生的 NSURLSeesion 或基于它的网络库,比如 AFNetworking ,由于缺乏DNS 扩展,那支持 HTTPDNS 将是相当繁琐的一件事。
一些较大规模的团队,比如微信,使用的是自建网络库 Mars,跨多个平台支持了 DNS 扩展,并在此基础上实现了自己的 HTTPDNS 方案,内部名为 newdns。而百度 APP 则是使用了开源的 Chromium cronet 网络引擎,修改其 DNS 模块实现。
不使用 NSURLSeesion,独立研发或使用开源网络引擎,对小团队来说,维护成本还是相当高的,而且面对升级 HTTP 版本、TLS 版本等等网络协议更新时,无法自动享受系统升级带来的新特性,需要额外的适配工作。此处可以参考一个非常有趣的开源项目:YMHTTP。为了接入 HTTPDNS,他们将开源网络引擎 libcurl 封装,在与 NSURLSession API 保持高度一致的同时拓展了 DNS 的能力,用 iOS 开发者熟悉的使用方式使用 libcurl。当然,依然无法避免不使用原生网络引擎带来的诸多不便:比如目前不支持 HTTP/2;不支持 NSURLSessionTaskMetrics,无法进行网络监控。
如果非要使用原生NSURLSession,似乎只能使用 IP 直连方案,在请求 URL 中直接用 IP地址替换域名,将https://www.domian.com/path/to/myfile?key=value替换为诸如https://www.1.2.3.4.com/path/to/myfile?key=value形式。
当然,这也会带来很多问题需要处理:
  • 需要将每个请求的 HTTP Header 中将原域名设置上 Host 字段,否则服务器可能不知道你访问的是哪个域名接口,它会默认使用你替换后的 IP 地址作为 Host。

  • 处理 Cookie,使用 IP 替换域名后,系统会认为你域名发生了改变,请求不会携带原域名下的Cookie,尤其是一个域名对应多个 IP 地址交替使用时,尤其需要留意。

  • 如果你的业务服务器,非常幸运是非 SNI 场景,即一个 IP 地址下只有一个域名,那么HTTPS请求还需要处理 TLS 握手时的证书问题。因为系统会认为你 URL 中的 IP 是你的域名名称,而握手时服务器返回的是原域名的证书,证书名称对不上,系统默认无法通过校验。此种情况可以如下处理:
//IP直连方案,非SNI证书处理-(void)URLSession:(NSURLSession *)session              task:(NSURLSessionTask *)task didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * __nullable credential))completionHandler{
   NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;   NSURLCredential credential = nil;   //获取原域名来验证   NSString host = [[self.request allHTTPHeaderFields] objectForKey:@"host"];   if (!host) {    host = self.request.URL.host;   }   if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {       if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:host]) {           disposition = NSURLSessionAuthChallengeUseCredential;           credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];        }        else {            disposition = NSURLSessionAuthChallengePerformDefaultHandling;        }
     }     else {         disposition = NSURLSessionAuthChallengePerformDefaultHandling;     }      // 对于其他的challenges直接使用默认的验证方案    completionHandler(disposition,credential);}

如果,非常不幸,业务服务器配置了SNI,一个 IP 地址下有多个域名,TLS 握手验证将根本无法通过:使用 IP 直连时,系统会默认使用 URL 中的 IP 地址作为域名, NSURLSeesion 也未提供任何接口设置,发起 TLS 握手请求时,由于服务器下有多个域名的证书,而握手请求中是 IP 地址,无一能匹配。服务器一般会返回默认证书,大概率是其他域名的,像非 SNI 情景一样仅仅修改名称的方法也失效了。
如果盲目信任所有证书,则突破了 HTTPS 的安全底线。

隐秘的角落
真的没有更好的方案,既能使用NSURLSeesion,还能处理 SNI 吗?如果不考虑 App Store 过审要求,答案是:有的,使用私有 API。
iOS 12 起,Apple 公开了 Network Framework 框架,这是一套 C 的API,用来替代 Socket 给开发者更底层的网络控制能力。而NSURLSeesion正是基于此框架实现。我们随意发起一次 HTTPS  请求,看看他们之间的调用关系:

图片

可以看到,NSURLSeesion每次建立连接,经过私有类 NWconcrete_nw_connection 桥接,对应的是 Network Framework 中的建连方法 nw_connection_create。该方法有两个关键参数:
  • endpoint 目标连接点,包括目标服务器的地址与端口,如果地址是域名,则系统会进行 DNS 解析获得 IP 地址,地址是 IP 地址,则直接发起连接;
  • parameters 连接参数,包括使用 TCP 还是 UDP 协议等详细参数;

经过在 iOS 13上实际测试,我们只要 Swizzle NWConcrete_nw_connection中的
initWithEndpoint:parameters:identifier:方法,替换endpoint参数,使我们HTTPDNS SDK 提供的IP地址,则相当于跳过了系统 DNS 过程,使用了我们自己的 DNS 服务,并且系统会自动使用原 endpoint 中的 hostname 进行TLS握手。
- (id)swizzled_nw_connection_initWithEndpoint:(id)arg1                                   parameters:(id)arg2                                   identifier:(unsigned int)arg3{    NSString *description = [arg1 description];
NSArray *components = [description componentsSeparatedByString:@":"]; //通过443端口判断是否是HTTPS请求    if (components.count == 2 && [components[1] isEqualToString:@"443"]) {        NSString *domain = components[0]; //通过HTTPDNS 获取IP地址 NSString *ip = [myHttpDnsSDK getIpByDomain:domain]; if (ip != nil) { //使用公开方法创建新的 endpoint,替换原 endpoint 即可            id newEndpoint = nw_endpoint_create_host([ip UTF8String], "443");           if (newEndpoint != nil) {               arg1 = nil;               return [self swizzled_nw_connection_initWithEndpoint:newEndpoint parameters:arg2 identifier:arg3]           }         }      }      return [self swizzled_nw_connection_initWithEndpoint:arg1 parameters:arg2 identifier:arg3];}


搞定了 iOS 13,遗憾的是,在 iOS 14 之后,上述方法失效了。原因是 Apple 在 LLVM 上推出的新特性:objc_direct,简单的说,就是将 OC 方法“转换”成了 C 方法,直接通过函数地址调用,跳过了我们熟悉的 objc_msgSend 消息发送的过程。
initWithEndpoint:parameters:identifier: 方法好被使用了该新特性,无法被Swizzle。
那么,我们又一次走到绝境了吗?
如果 dump iOS runtime 的头文件,你会发现,iOS 14 相比于之前,在 URLSeesion 相关类中,增加了相当多的私有方法与属性。光在 NSURLSessionTask中就增加了几十个,可以相当详细的定制每个网络 Task。令人怀疑,Apple 是不是在准备后续将他们真正开放给开发者。其中,我们看到了一个熟悉的身影:
NSObject * <OS_nw_endpoint> _hostOverride 属性以及它的对应设置方法:
- (void)set_hostOverride:(NSObject*<OS_nw_endpoint>)arg1 ;
是的,iOS 14 上出现了新的私有 API,可以直接指定网络 Task 的 endpoint
那么,我们只需在创建完 NSURLSessionTask后,通过HTTPDNS SDK获取 IP,创建新的 endpoint ,设置上即可,完美解决。
NSURLSessionDataTask *task = [NSURLSessionObject dataTaskWithRequest:request];NSString *domain = request.URL.host;NSString *scheme = request.URL.scheme;if ([scheme isEqualToString:@"https"]) {    NSString *ip = [myHttpDnsSDK getIpByDomain:domain];    if (ip != nil) {    //如果能通过HTTPDNS获取到IP,则指定为task的目标IP        id newEndpoint = nw_endpoint_create_host([ip UTF8String], "443");        [task set_hostOverride:newEndpoint];    }}[task resume];
注意,由于涉及私有 API,这里我们只能作为技术探讨,无法在生产环境上使用。


05

接入效果


小米有品 APP 接入 HTTPDNS 后,延续原网络请求抽样监控方式,对比接入前数据,上线一个月后即取得了相当明显的提升:
  1. DNS 解析错误率,即所有请求中 DNS 查询失败占比,由 3.044% 降至 1.579%;
  1. DNS 劫持率,即所有请求中 DNS 查询的结果为非法 IP 的占比,由 0.314% 降至0.007%;
  2. DNS 查询耗时方面,查询耗时 95th 百分位数由 130ms 降至 6ms,99th 百分位数由 1700ms 降56ms。

    图片
    *接入前DNS查询耗时分布(单位 ms)

    图片
    *接入后DNS查询耗时分布(单位 ms)


    06

    拓展阅读


    一,什么是AnyCast
    AnyCast,任播,相比于我们熟悉的单播(一对一),以及广播(一对多),任播同时拥有两者的优点。通常来说,一个 IP 地址对应一台物理主机,而一个 AnyCast IP 地址,基于网络寻址和路由的策略,对应的是一组主机,同时跟广播不一样,发送到这个 IP 地址的报文只会被网络路由到路由协议度量的最近的主机上。一对多发送,却只会发送到最近的一台主机上。
    AnyCast 在 DNS 以及 CDN 部署上有广泛应用。

    二,DNS over TLS,DNS over HTTPS
    HTTPDNS 毕竟是依赖客户端的方案,各互联网服务厂商用来保障自己的 APP 网络体验。那我们使用浏览器上网怎么办?也不是所有客户端都有能力接入HTTPDNS,我们的安全与隐私如何保障?
    答案是DNS over TLS(DoT)与 DNS over HTTPS(DoH)。
    自从1987年 DNS 诞生以来,它就处于明文未加密的状态,劫持与攻击相当容易,并且会泄漏隐私。2016年 IETF(互联网工程任务组)发布 DNS over TLS 标准,约定请求者与DNS服务器先进行TLS握手,然后在加密的 TLS通道中交换数据,可以有效的避免被劫持与偷听。2018年,DNS over HTTPS标准发布,在前者的基础上可以使用更通用的443端口。
    可惜国内运营商们还没有跟上脚步,部分互联网公司倒是提供公共的 DoT 与 DoH 服务。譬如阿里公共DNS与腾讯DNSPod。
    同时,Android 9 起,支持在系统内配置全局 DoT/DoH;iOS 14 起,支持开发者开发 APP 支持系统全局或应用内 DoT/DoH。
    继续滑动看下一个
    小米有品技术团队
    向上滑动看下一个