四层负载均衡漫谈

对于四层负载均衡,我一直只是作为一个使用者,把它当作一个简单 TCP 层的反向代理来使用。但是随着在项目中使用的越来越多,我发现我对这个技术存在很多误解!

比如,我一直以为这是一个它是一个完整的 TCP 实现,和外部的客户端建立一个 TCP 连接,然后和后面的 Real Server 建立一个 TCP 连接,在两个 TCP 连接之间复制数据。实际上发现不是的!有一天我的同事告诉我,它只看 TCP header 中的端口,flags,找到应该去往的转发地址,然后就丢给后端,并不维护完整的 TCP 实现,比如滑动窗口等等。

客户端发送给它一个 IP Packet,它看一下 IP port 五元组和 TCP flags,就直接转发给后端了,像是 cwnd, rwnd, 这些它自己都不计算,转发给后端让后端去自己计算。Real Server 发送回来的包也是,基本上就是看下 TCP 的 flags 然后改下 IP 直接转发给 Client。这种 LB 更像是在转发 IP 包,而不是 TCP 代理。

仔细想一下,这样很合理,因为作为一个四层负载均衡,它本身并不处理 TCP 的内容,只是转发,所以只看需要的字段就好了,即使维护了 TCP 的滑动窗口也没有意义,因为计算的瓶颈不会在 LB 这里,反而降低了转发的速度。

所以虽说是四层负载均衡,但其实它并不完全是一个四层实现。正因为这样,它的转发效率非常高。

除此之外,四层负载均衡还有很多有意思的做法。这篇文章就来写一下四层负载均衡用到的一些技术。文中尽量不假设读者有很强的网络二层(本文指的都是 OSI 七层模型),三层的知识,我们平时接触的都是四层以及七层(应用层)的网络接口,所以文中用到的时候,我会稍作解释,这样,大家都可以理解这些设计的妙处。

我隐约感觉这篇文章会写的很长,所以建议读者去冲一杯咖啡,找一个温暖的午后,慢慢在 TCP 协议中畅游。

我们先从“负载均衡”开始说起。

什么是负载均衡?

大部分面向用户提供的应用都是客户端-服务器模式。如果客户端变得很多(即负载变大了),那么提供服务的一台服务器不够用了,需要扩容到多台上去,怎么将“负载”,“均衡”到所有服务器上去呢?

我觉得有两种思路。

依赖服务发现的客户端侧做负载均衡

第一种是客户端需要知道有多个服务器存在,然后访问的时候,不同的客户端尝试访问不同的服务器。如果有 3 台服务器,1/3 的客户端请求到 A,1/3 到 B,1/3 到 C。这样,所有的服务器都派上用场了!

使用这种办法做负载均衡的有很多例子,比如,在 DNS 中回复多个 IP 地址。

用我的笔记本查询一下 google.com 的 IP 地址,会发现有很多个 IP。

dig google IP address

这样,当不同的用户在访问 google.com 的时候,拿到不同的 IP 地址,负载就被“均衡”了。

但是这样也有一些问题:

  1. DNS Answer 是有限制的,这意味着,我们不能无限地向 DNS 中添加 IP 地址;
  2. 有一些 DNS 查询的实现,不是从结果里面随机选择一个,而是总是用第一个;
  3. DNS 经过了层层缓存,当我们扩容或者缩容服务器 IP 的时候,很可能客户端要过好久才会意识到更新。

对于 (1),解决办法是,假设 IP 超过了 DNS 响应最大的大小,就每次返回不同的 IP。对于 (2) 的解决办法是,DNS 每次返回的 IP 列表都乱序一下。

所以,我们在多次 dig google.com 这个域名的时候,会发现每次拿到的 IP 列表和顺序都是不一样的。

多次 dig google.com 的结果

但是 (3) 的问题,就不是很好解决了。浏览器可能会缓存,操作系统会缓存,路由器,ISP 都会缓存 DNS 结果,这很多是作为服务提供方的我们是不能控制的。

在现代的很多商业 App 中,为了能够让自己开发的 App 完全控制访问的目标地址,而不被系统等其他服务缓存,很多 App 会使用 HTTP-DNS(正确的称呼应该是 DNS over HTTPS)。即发送 HTTPS 请求到自己的 DNS 服务器,来获取服务的地址。这样,在操作系统等看来,这不是一个 DNS 请求,而是一个 HTTPS 请求,就不会对其内容进行缓存了。如此就可以绕过操作系统、ISP 等的 DNS 服务器,每一次 DNS 请求都是真正从自己的 DNS 服务器获取的结果,就可以实时、精准控制客户端访问的目标地址了。

DNS 的本质,是告诉客户端,去找服务器的时候,应该去哪里找,是一种服务发现。类似的,微服务中,服务之间互相调用,会有一个注册中心的组件,在调用的时候,告诉调用方有哪一些服务可以被调用,调用方从列表中选择其中一个来使用。

这种在服务发现的时候就将目标进行均衡的方式,好处是不需要一个中心架构,所谓的注册中心本质上是一个控制面,不承载数据,所以流量比较小。所以扩展性很好,没有瓶颈。

而这种方式的问题就是——其实可以从缺点 (3) 看出来——客户端的行为是不受我们控制的。所以另一种负载均衡就是不依赖客户端的行为:DNS 只返回固定的 IP。其他的诸如 HA 等内容,都基于这一个 IP 来实现。

这就有了第一个问题,一个服务器有一个 IP,如果固定了 IP,还怎么做扩容呢?

反向代理模式的负载均衡

答案是通过 ECMP(下文会详细介绍)技术,可以让多个服务器都来通过同一个 IP 提供服务 (Virutal IP, VIP)。

在介绍 ECMP 之前,我们先讨论一下“反向代理模式”的负载均衡技术。

这也是一种负载均衡的模式,简单来说,为了让我的服务实例(比如业务的 App server)能够随时扩容,缩容,就是在 Real Server 前面放一些负载均衡器,将流量”均衡“地转发给后端的 Real Server。扩容的时候,就将一部分流量分给新扩容出来的实例,缩容的时候,就提前不会再将流量调度到即将缩容的实例上去,非常完美。

负载均衡器

但是很显然,只是加了一层,并没有解决原先存在的问题:负载均衡器自身的扩容和缩容如何解决呢?

负载均衡器

对于负载均衡器,它要解决这几个问题:

  1. 能够发现后端的 Real Server,比如有新的实例上线之后,新实例要开始从负载均衡器那里收到流量;
  2. 需要解决自身的服务发现问题。因为负载均衡相当于在客户端和 Real Server 之间加了一层,要通过某种方式,告诉客户端负载均衡的地址。解决 Load Balancer 本身,扩容和缩容的问题(当然了,可以通过在负载均衡器前面再加负载均衡器来解决,后面会举这样的例子);
  3. 性能一般比 Real Server 要高,这样,才能用更少的机器去承担和后面 Real Server 一样的流量。当然了,因为负载均衡器的逻辑一般比 Real Server 要少,所以这一点现实里不算难;

具一例子,Nginx 是一个七层的反向代理,可以认为是一个七层的负载均衡器。它解决以上三个问题:

  1. 通过 Nginx 配置文件中的 upstream 可以配置后端的 Real Server 的地址,也可以通过 openresty 自制其他服务发现的机制;
  2. 自身服务发现可以通过在上述,在 Nginx 前面添加四层负载均衡器来解决。这样,对于四层负载均衡来说,Nginx 就相当于是一个 “Real Server” 了;也可以通过将 Nginx 的 IP 配置在 DNS 域名 A 记录上,这样,相当于让客户端通过 DNS 能够发现 7 层代理的地址;
  3. Nginx 的性能比后端的真实处理 HTTP 请求的服务肯定是要高的,因为 Nginx 只解析 HTTP Header 就可以处理请求进行转发,而后端服务需要处理业务逻辑,比如用户登陆,生成网页,等等。所以 Nginx 一般比后端的性能要高的多。

对于本文主要讲的四层负载均衡,问题 (1) 可以轻松实现,用 Etcd 实现一种服务发现就可以,也可以监听后端 Real Server 的上线和下线事件,每次当事件发生的时候,更新自己的转发表。这个问题不算难,所以本文不会过多深入了。下文会注重 (2) 和 (3).

七层负载均衡和四层负载均衡技术

大型的网站服务一般会用这样的架构,在业务应用前面加七层负载均衡,然后在七层负载均衡前面加四层负载均衡。当用户发送 HTTP 请求的时候,请求会(经过机房内的路由器,交换机等设备)首先到达四层负载均衡,四层转发给七层,七层转发给应用。

四层和七层的区别是什么?为什么要四层、七层两套呢?只有一套行不行?

根本区别的话,其实就一句话:

  • 四层负载均衡只解析网络包到第四层,根据四层的内容(比如 TCP port,IP 等)就能确定转发给谁;
  • 七层负载均衡解析网络包到第七层,要根据七层的内容(比如 HTTP URL path,HTTP header 等)才能确定转发给谁;

考试:

  1. redis-cluster-proxy 可以根据请求 Redis 的 key 转发到正确的 Redis 实例上去,那么这是一个几层负载均衡?
  2. Nginx stream 模式可以给数据库,比如 Mysql 的 3306 端口做一个反向代理,在这个场景下,所有发给 Nginx 的流量都会透明地转发给 Mysql,此场景下,Nginx 是一个几层负载均衡?
  3. Coredns 可以按照如下方式工作:如果我知道一个 DNS 的结果,我直接返回,如果不知道,我发给 1.1.1.1 来查询。如果我们将其看作一个代理的话,它是几层?

答案:1. 七层; 2. 四层; 3. 七层;

当然了,网络包一层一层的就是套娃,要解析七层首先要解析四层,要解析四层首先要解析三层。

经典四层和七层架构和解析包的关系 (图是我画的,牛逼吧?)

四层负载均衡只解析包到四层就可以处理了,所以比七层要快很多。因为七层即应用层,我要解析 HTTP 内容(不仅仅是 HTTP,其他应用层协议的负载均衡,比如一些数据库 proxy,gRPC 代理,等等,都是类似的,都需要解析完成应用层才能确定转发目标),首先要将 Header 全部读完,读完之后要看下 Content Length 是有多长,然后知道 Body 要读到哪里。根据不同的 URL Path,还要确定路由到哪一个 upstream,听起来就很头疼。

回到第三个问题:只有一个七层负载均衡行不行?

答案是可以。四层的优势在于,它的工作更少(此外,下文还会介绍一些性能更高的方案),所以速度更快。

一般一些小型网站,就直接一个 Nginx listen 一个公网 IP,对外提供服务。四层负载均衡不就是快么,小网站要那么快干嘛?用户没那么多,能跑就行。

那不如再退一步,七层负载也不要了,行不行?我直接把应用 listen 的端口放在公网上。

额……也不是不行。

能跑是能跑,但是一般像 uWSGI, gunicorn 这些软件,都会建议你在前面挡一个专业的反向代理(负载均衡器),即使你只有一个后端实例,一对一转发(可以参考之前写的这篇:部署 Django 项目背后的原理:为什么需要 Nginx 和 Gunicron这些东西?)。因为 Nginx 这样的专业七层代理,可以以专业的角度做很多事情,比如 buffer,连接保持,压缩,负载均衡,缓存等等功能。简单来说,它的性能更高,没有它的话,你的应用更可能被客户端拖垮。举例,假设一个客户端(浏览器)给你发送的 HTTP 请求很慢,需要一分钟才会发完。如果有 Nginx 的话,Nginx 会帮后端应用将整个 HTTP 请求收完整(缓存),然后一次性发给后端应用。这里有一个测试,如果没有 buffer 的话,HTTP 服务的连接会被慢的 client 都占满。

可以换一个角度想:四层,七层,应用,这里面:

  1. 应用是最慢的,因为它要处理业务逻辑,比如磁盘读写,数据库读写等;
  2. 七层要快一些,因为七层只负责组装 HTTP 请求就可以了;
  3. 四层就更快了,因为它不处理七层包,处理到四层就结束了,任务非常单一;

那么只有四层负载均衡行不行?

可以是可以,但是从没见过有人这么干。

原因很简单,四层负载均衡能做的事情,七层负载均衡都能做(因为七层的包它都解开了,四层的信息当然都可以拿到)。七层技术一般来讲更加上层,比四层得到的信息要多一些。所以如果只选择一种负载均衡——四层或者七层的话,一定是先尝试用七层负载均衡去解决,解决不了(一般是性能原因)的问题,才考虑加一个四层,将一些东西放到四层上去做。反过来,七层能做的功能很多四层都做不了,比如根据 HTTP header 跳转。所以,很少简单不上七层负载均衡,直接上四层的。除非是非 HTTP 的流量,比如 Redis 和 MySQL,直接在前面加一个四层代理,还算合理一些。

实际上,很多公司起步的时候,就是一个 Nginx 放到公网上就上线了。后面业务做大了,遇到性能瓶颈,或者对高可用要求高了,再考虑加上四层均衡的。

除了在前面加,在后面也可以加。业务做大了,Nginx 和后端应用之间,可能再加一个 应用 API Gateway,可以看作是“应用层的负载均衡”,可以用 Golang,Java,Python 等来实现,可以读写数据库等,做一些特殊的逻辑。因为可以编程了,所以就支持了比 Nginx 更加复杂的特性。比如根据 user id,用户特征等进行 AB 测试。其实 Nginx 也可以做这些事情,Nginx 可以用 Lua 编程扩展。说开去就跑题更远了。

下来就进入主题,我们来讨论下四层负载均衡的高可用和高性能是怎么实现的。

对于四层负载均衡的要求

我们先从最简单的架构讲起,不如就用 Nginx 来作为一个四层负载均衡的转发器。哦对了,既然下文我们主要开始讨论四层负载均衡了,那么经常放在四层负载均衡后面的七层负载均衡,我们就认为它是 Real Server 了。

Nginx 作为四层负载均衡使用

这个简单的架构有一些问题:比如,每一个 LB 都有一个独立的 WAN(即公网的意思)IP,那就回到上文部分提到的 DNS 做负载均衡的问题了。

四层负载均衡的后端对象不一定是七层负载均衡,甚至不一定是 HTTP 服务,像数据库服务(需要内网四层负载均衡),Git 服务器,缓存,都可能需要 TCP 层面的负载均衡。有一些服务的入口只能用一个 IP,但是,我们又不希望这个 IP 是一个单点。另外,有很多业务场景需要 TCP 长连接,不能短时间就断开了。

总结一下,我们对四层负载均衡的期望是什么:

  1. IP 是高可用的,而不是单点的,理想情况下,我们想暴露仅一个高可用 IP 在 WAN 上面;
  2. 另外,当 LB 本身有操作的时候,希望不要影响已有的连接,即可以让长连接不中断地运行较长的时间,比如 24 小时;

第二点连接迁移是一个很复杂的问题,有多种实现,也取决于架构的设计,我们放到后面再说。但是“一个高可用的 IP”这个技术几乎有一个事实标准,就是 ECMP.

ECMP 技术

路由选路原理

这里补充一个三层网络的知识点:我们知道 TCP 是基于 IP 的,意味着 TCP 要靠 IP 协议,经过一系列的路由,将 TCP 数据从源 IP 送到正确的目标 IP 上。路由器之间,一般用动态路由协议来交换彼此之间的路由信息,而可达路径往往不止一条。这时,路由器只会选择一条最优路径来使用,而且是永远只用这一个路径,而不会用其他的路径,其他路径是空闲的。

比如下图:

存在多条可达路径的情况

数据从 A 发往 B(假设用的 BGP 路由协议),所有的数据都会通过 A -> C -> B 发过去,没有数据会走到 D 和 E 上。因为 A -> C -> B 跳数最少,是最优路径。

那 D 和 E 不是就浪费掉了吗?确实是的,有一些违反直觉。这样做一个很大的原因是保证包到达的顺序和发送出去的顺序一致,所以使用单一最优线路发送。

等下,TCP 不是可以给我们保证顺序吗?为什么三层要关心顺序?

是的,TCP 可以将乱序的包重新排好顺序,可以保证应用程序从 TCP socket 读到的内容,一定是按照发送顺序的。但是乱序会导致 TCP 性能严重下降:

  1. 一个原因是,TCP 接收端收到包之后,要重新在 buffer 中排序,会浪费内存和计算;
  2. 另一个原因是,接收端发送了 3 个 SACK 触发快速重传,发送端可能会认为发生了拥塞,从而降低 cwnd,影响长肥管道的性能;

所以,三层是会尽力保证发送的顺序的。

补充一点,不是所有的协议都用条数来选择最优路径的,比如 OSPF 主要参考的是带宽,EIGRP 主要参考的是 5 个 K 值:

  1. K1(bandwidth)
  2. K2(load)
  3. K3(delay)
  4. K4(reliability)
  5. K5(MTU)

但是,他们都是选出一条最优路径来用的。跳数也好,带宽也好,最终,路由协议会对所有的可用线路计算出一个 metric 值,然后选择一个 metric 最低(越低越好)的来用。

有一种方法可以干扰路由表,叫做 Policy-based Routing (PBR),通过添加策略来在选路之前就做出决定。比如一家企业有两条线路,一条 1G 一条 200M,可以配置视频会议走 1G,其余的走 200M,这样视频拥挤不会造成办公网异常。

现在考虑一种特殊情况,假设下图:

存在两条 metric 相同的路径

A(1.1.1.1) 要发往 B(2.2.2.2) 数据包,可达路径有两条,C 和 D,使用的路由交换协议并不重要,重要的是,他们的 metric 都是 100,如此的话,A 的路由会如何决定呢?

在这种情况下,流量会均匀地发给两条路径。

但是前面强调的“顺序”怎么保证呢?

在 A 使用两条路径发送给 B 的时候,对于 hash(src_ip, src_port, dst_ip, dst_port) (hash可以配置),A 会始终选择同一条路径,这样就保证了 order,也用上了两条路径。

ECMP

这就是 ECMP(Equal-cost multipath) 了。由上面可以见,ECMP 其实并不是一个实体,不需要一个叫做“ECMP”的软件来运行。它只是路由协议——或者说是路由器的实现——所带来的一个 feature(一个副作用?)。

现在我们将上面这个拓扑图扩展一下,如下图:

区别是,原来要将数据从 1.1.1.1 发给 2.2.2.2 有两条路,可以走 C 也可以走 D。但是现在,C 上面直接就有 2.2.2.2,D 上面也有 2.2.2.2. 对于发送者 A 来说,前后两种拓扑图有什么区别吗?没有区别。因为在 A 看来,将数据发给 2.2.2.2,还是:要么走 C,要么走 D,有两条 Path, 它们是 Equal-cost. 所以,在这种情况下,A 依然会将流量经过 hash,然后在两条线路上发送。

如果目标不是一个路由器,而是一个 Server 的话呢?

对于 A 来说,假设我们的 Server 运行了路由协议(Linux 是可以运行路由协议的,比如 ospfd),那么 A,并不知道线路的另一端是一个路由器,还是一个 Server,还是其他的硬件,还是一只狗(假设这只狗可以发送数字信号并且通晓路由协议的话)。那么,对于 A 来说,这个图和上面图是一模一样的。

到这里,我们意识到:我们有两个服务器,这两个服务器的 IP 都是 2.2.2.2,都可以提供服务,对于客户端来说,它不知道这个 IP 背后是一个服务器还是多个服务器,它只知道自己访问的是这个 IP。这个 IP 就叫做 VIP(虚拟IP,Virtual IP),不是具体的某一个实例的 IP。所以,我们的需求(1)已经实现了:IP 是高可用的,而不是单点的,理想情况下,我们想暴露仅一个高可用 IP 在 WAN 上面。

可能有的读者会有疑问:两台机器有相同的 IP 地址,我如果要 ssh 到某一个机器上做操作,怎么确保 ssh 到的机器就是我要的那台呢?

用 2.2.2.2 肯定是不行了,但是我们的机器不是只能有一个 IP:一个机器可以有多个物理网卡,每一个卡都可以配置一个 IP;即使只有一个物理网卡,在操作系统上也可以配置多个 interface,然后每一个 interface 上配置一个 IP。总之,这种架构下,我们登陆的 IP 一般不是 VIP,而是用其他的 IP 登陆。

ECMP 也有一些问题:首先它给架构带来一些复杂性,我们不能随便找几台服务器做出一个 VIP 来,需要三层设备的配合。其次,ECMP 是逐跳判断的,只发生在路由器选择下一跳的时候。

考虑下面这个拓扑图:

ECMP metric 相同,但是实际处理能力不同

A 会均等地将流量发送给 B1 和 B2,因为 B1 和 B2 metric 值是相等的,A 不可能知道 B1 和 B2 后面是什么样子,对它来说,只参考 metric。然后会将流量 1:1 发送给 B1 和 B2,B1 和 B2 收到流量转发给自己的后端,互相也不可能知道其他路由器收到了多少流量。如此,流量到服务器的时候,收到的请求量 E:C:D 实际上是 2:1:1.

现实里,这些也不难,难的是二三层设备的维护和软件 四层LB 的维护往往是不同的团队,团队之间的协作可能造成困难甚至事故。

总结一下:现在作为四层负载均衡,大问题已经解决了——我们已经可以完成负载均衡本身的服务发现了——通过三层设备 ECMP 来让所有的四层负载均衡实例均等收到流量。负载均衡转发流量到后端 Real Server 也比较好解决,可以和任意的服务发现技术组合实现,最简单的,比如直接写在配置文件里面。

使用 VIP 的方式部署 Nginx

如果永远不做其他变更了,似乎需求就完成了。但是如果需要变更,比如机器硬件坏了,或需要升级软件,或需要扩容,就会发现问题了。

如上所说,四层负载均衡一个重要的职责是,要保持住长连接。我们做运维操作是不能破坏这些已经存在的连接的。我们先拿扩容场景来说:假设我们现在新增一个四层负载均衡实例,会发现原来路由器的 hash 结果全都变了,可能一个 TCP 连接经过原来的 hash 走到了服务器 A 上面,加了一台实例,就 hash 到了服务器 C 上面。客户端的 TCP 连接还能正常工作吗?这里存在两个 TCP 连接,我们可以分别分析。

添加了一个 LB 实例

我们先理一下问题:

  1. 客户端角度,连接了 VIP,创建 TCP 连接之后在这个 TCP 连接上传输数据,但是它并不知道这是一个 VIP;
  2. LB (下文会用 LB 代表 第四层 Load Balancer)角度,B server 将这个 TCP 连接代理到 RS (Real Server) 192.168.1.5;
  3. 现在由于新增了一个 LB,导致路由器将 Client IP 发给 VIP 的包转发到了 LB C 上(原来是在 LB B 上);
  4. 要求客户端 TCP 连接继续工作,服务不能中断;

长连接保持技术

这里,加机器导致长连接会断开的核心原因,我认为是:TCP 是有状态的。B 知道有这么一个 TCP 的连接存在,因为 B 经过了和客户端之间的 TCP 握手,所以 B 可以正确处理这个 TCP 连接的数据。标志一个 TCP 连接的是四元组(有些地方会说五元组,多出来一个是“协议类型”,但是我们讨论 TCP,就不多说协议类型了):来源 IP,来源端口,目的 IP,目的端口。这个四元组在 B 的内存中,B 就“认识”这个 TCP 连接。如果数据经过路由器发给了新机器 C,C 不认识这个 TCP 连接,收到数据的时候只能回应 RST:“你是谁呀,没经过 TCP 握手就给我发送数据,我跟你很熟吗?”

要让原来的连接不断,我们这里有两种思路,第一种是,因为只有 B 认识这个 TCP 连接,所以我们想办法,让在添加新机器的情况下,数据始终是发送给 B 的,而不是发送给 C,这样,数据一直发给 B,也就没有问题了;另一个思路是,让 C 也认识这个 TCP 连接,这样,数据无论是发送给 B 还是发送给 C,都可以被正确处理。

我们先看最简单直白的解决方案:添加机器不改变原有连接的方法。

Sticky ECMP

将问题简化一下:由于添加机器,路由器将本来发给 B 的数据发给了 C,那么解决方法自然就是:希望路由器将原来发给 B 的数据一直发给 B,只有新建连接的时候,才使用 C 新建连接。即,新机器上线,只有新 TCP 连接才发过来,已经存在的 TCP 连接的数据不受添加节点的影响。

一致性 Hash?

有的读者可能想到了一致性 Hash (Consistent Hash),需要说明的是,一致性 Hash 并不能解决这个问题。

一致性 Hash 能够解决的问题是:假设现在有 3 个机器,A,B,C,每一个机器上有 100 个连接,当新添加一个机器 D 的时候,由于 Hash 算法改变,情况变成了:

  • A 上面的 50 个连接现在 Hash 到了 B 身上,10 个到了 C, 20 个到了 D
  • B 的 60 个 到了 A 身上,20 个到了 C 身上,20 个到了 D 上
  • C 的 10 个到了 A 上……

最终结果虽然还是平衡的,但是所有的连接都经过了洗牌,可能所有的连接都必须断开,重建一次。

如果有了一致性 Hash,上面的情况就会变成:

  • A B 不会变
  • C 的 50 个连接会 Hash 到 D 上

这样虽然最终结果虽然有一些不均衡,但是可以让需要变化的连接最少。

一致性 Hash 一般用在数据库存储分片上比较多。在我们的场景下,我们希望解决的问题是,原本在 A 处理的连接,即使新添加了机器,也必须继续交给 A 处理。新的连接可以给新机器处理。

因为 LB 无法控制前面的设备发给 LB 的哪一个实例,所以这个功能需要前面的设备(有可能是二层设备,也有可能是三层设备)来实现。网络设备上有个类似的功能就叫 Sticky ECMP。意思是网络设备在 ECMP 情况下选择转发下一跳的时候,不仅仅会使用 hash 算法,还会记住 hash 过的值,比如 src IP 和 dest IP,一旦经过 hash 了,后续节点数变多了,也是会得到一样的 hash 值。

Sticky ECMP 效果就是,(取决于实际的 hash 方法,这里假设就用 IP 来 hash),即使增加 LB 实例,旧的客户端(即使是新建连接)流量只会到旧的 LB 实例。这里要注意的是,这个效果和我们期望的效果还是有一点小小的不同:

  • 我们希望的是:旧的 TCP 连接去到旧的 LB 实例上去,新的 TCP 连接有一部分去到新实例上去;
  • 而 Sticky ECMP 的效果是:旧的客户端一直去旧的 LB 实例,即使新建连接,只要是 IP 不变,取得 LB 实例就不会变。新的客户端(新的 IP)进来,根据 hash 算法,才有可能到新的实例上去;

这就会导致,新添加的实例可能接收的 QPS 比其他实例要少,并且这种不均衡会存在相当一段时间。

除了这个问题,另一个问题是,我们很多情况下,并不能控制二层和三层设备,有可能这些设备本身就不支持 ECMP 的 feature。

Extra Proxy

另一种思路是,我们可以添加一层额外的 Proxy,这一层 Proxy 可以跟踪并记录连接的状态,知道旧的 TCP 连接应该发送到哪一个正确的 LB 实例上去。

添加的新的一层 Proxy 我们叫它 Director,Director 之间同步连接的状态,所有的 Director 都有两个信息:

  1. TCP 连接的状态;
  2. 如果是已经存在的 TCP 连接,知道这个连接应该被哪一个 LB 来处理;

这时候,当我们添加一个新的 Director + LB 实例的时候(其实可以只添加 LB 实例不添加 Director,效果一样)。上面长连接的情况,无论哪一个 Director 收到了旧的 TCP 连接上发来的数据,都会发送给正确的 LB 实例 B。如下图所示。

这种方式依然有两个缺点:

  1. State 的同步可能延迟。比如一个 Director 知道了一个新的 TCP 状态,它还没来得及告诉其他的 Director,这时候 LB 扩容了,那么这个 TCP 数据流就可能发送一个不知道它的状态的 Director 上;
  2. 所有的 Director 都存储了重复的数据(如上介绍的),这个数据量也是不小的。

Github 的 GLB 有一种很创新的方法来解决这个问题:

之前每一个连接 hash 到一个 LB 实例上去,这个实例可能是错误的。现在这么做:

  1. 每一个连接经过 hash 得到两个 LB 实例,一个叫做 Primary,一个叫做 Secondary。所有的 LB 实例,无论是旧的,还是新添加的,都使用一样的 Hash 方法,对每一个连接 Hash 得到两个 LB 实例;
  2. 连接会转发到 Primary 上去,但是这个实例可能是错误的。
  3. Primary 如果发现自己并不能处理这个连接,就转发到 Secondary 上去。
Hash 表会得到两个结果,图来自 Github
如果 Primary Proxy 无法处理连接,会尝试二次转发,图来自 Github

这样做有几个好处:

  1. LB 之间并没有数据的共享,只要在代码层面让所有的实例使用一致的 hash 算法就可以了;
  2. 不需要同步数据,也就没有延迟了。

缺点嘛,就是多转发了一次。但是考虑到 LB 的性能,以及这种情况应该只发生在有运维操作的时候,添加节点在一段时间之后,所有的连接理论上应该都能在第一次转发完成。所以是可以接受的。

另一个缺点是,因为 Hash 值只有两次,如果第二次转发到的机器依然是错误的,那么就只能 drop 掉连接了。为了避免在第二次 hash 还是找不到正确实例的情况,我们要保证连接的 rehash 迁移不能同时发生超过一次,即,我们添加机器的时候,只能添加一台,等所有的旧连接都结束掉,再添加第二台。

为什么不能一次添加多台?比如我们一下子加上三台机器,XYZ,有一个连接原来在 A,现在 rehash 的结果是 Primary 是 X,Secondary 是 Y,那么 X 转发给了 Y,依然是错误的。同理,也不能在连接没 drain 干净的情况下就继续添加机器。

以上方法还有一个相同的缺点:只能处理添加机器的情况,无法处理减少机器的情况。

减少机器分成两种情况,计划下线和故障下线(比如机器挂了)。

计划下线的情况很简单,和扩容差不多:我们只要让要下线的 LB 实例标记为不再接收新的连接,但是已有的连接继续处理,等所有的连接都正常结束了,机器就可以正常下线了。

但是故障下线的情况,上面的方案几乎都处理不了。因为只有这一个 LB 实例能够处理这个 TCP 连接,如果实例没了,那么也就没人能处理了。所以这种连接只能 Drop 掉。

如果要处理这种情况,就必须让多个 LB 实例能够处理相同的 TCP 连接。这就要在 LB 之间同步连接的状态。

LB 之间的状态同步

在上图中,数据转发的任务,由于扩容,从 B 转移到了 C 上。要让 C 能处理这个转发,我们要先看原来的 B 上面有什么:

  1. 有 Client 到 VIP 的 TCP 连接;
  2. 有 LB 到 Real Server 的 TCP 连接;

假设所有的 LB 如果能同步这些状态,那么所有的实例,其实可以看作是一个巨大的虚拟实例,因为所有的实例知道的信息都是一样的。这样,我们就可以随时添加节点了,甚至也可以随时减少节点。因为无论数据发送到哪一个 LB 实例上,都可以被正确处理。

如下图所示:

我们需要一个外部的 State Service (也可以通过 IP Multicast 来实现)来存放连接的状态,当 LB 每次跟 client 以及 Real Server 建立起连接的时候,就将 State 写入这个 Service。这个 Service 要负责以某种机制将连接的状态同步给其他的 LB,虽然有一些延迟,但是这样,当新连接建立之后不久(ms级别的时间),所有的 LB 实例都知道这个新连接的存在了。

这样,当发生 rehash 的时候,也没有关系,因为所有的 LB 都可以处理所有的连接。只有一点小问题,就是连接刚建立,还没来得及同步状态,马上就发生了 rehash,这种情况连接还是会被 drop 掉。

这里有一个小细节,就是(重申一次)TCP 连接是按照四元组的形势存储的,LB 连接 Real Server 也是四元组。所以,LB 现在连接 Real Server 也必须用 VIP 才行,换句话说,LB 实例连接 Real Server 的 IP 必须是一样的 IP。如果不一样的话,Real Server 对于陌生的 IP 就又会有那样的疑惑,我没跟你握手呀,于是 Drop 连接。(前文中二次转发就不会有这个问题,因为始终是同一个 LB 连接了 Real Server)。

当然了,这种方式依然是有状态延迟问题,以及状态同步带来的 Overhead。一个可能的解决方案是,假设大部分连接都很快结束了(对 HTTP 来说假设是成立的),所以,只有连接存在了 3s 以上我才同步他们的状态,低于 3s 不同步,在发生 rehash 的时候舍弃部分连接好了。

话说回来,其实很多方案都是允许长连接断开的,然后客户端负责处理好异常:如果出现了连接重制,就重新建立连接,然后恢复之前的通讯。这种方式显然更鲁棒!

这就是连接同步的一些技术,下面是更精彩的部分,我们开始讨论如何设计网络架构。

转发架构

在开始讨论之前,我们要复习一下几个(看起来是废话但是很重要的)原则:

  1. LB 的一个重要作用就是保护 Real Server,客户端始终只能看到 LB 的 VIP 地址,不能看到 Real Server 的地址;
  2. 一个设备发送给另一个设备 TCP 数据的时候,实际发送的是 IP 包,因为 TCP 是基于 IP 的。IP 包又是基于二层以太网的。所以,可以理解为,每次发送数据都是发送的二层以太网包。
  3. 发送二层包的过程可以简化如下:
    • 查看目的 IP 和我是否是在同一个子网,如果是,那么将目的 MAC 地址直接设置为目的 IP 所在的 MAC 地址(如果不知道,就发送 ARP 询问);
    • 如果不在同一个子网,那么将目的 MAC 地址设置为网关 IP 的 MAC 地址;
  4. 可以看到,网络数据发送的过程,其实就是网络设备两两之间发送的过程,交互的是以太网二层的包,每次都需要解开再重新封装。
  5. 一个设备在收到数据进行回应的时候,回复给谁呢?它会将 IP 的来源作为回应的目的 IP,将 IP 包的目的 IP(就是自己)作为来源 IP,即发给我的 IP 包,我把它的来源 IP 和目的 IP 调换,然后填充响应发送回去。
  6. 一个设备收到一个包的时候,它看这个包的 IP 自己有没有(是不是发给我的),如果没有(只谈 Unicast,不谈 Multicast 等),就丢弃,如果我有这个 IP,我才去处理这个包。

在以下的讨论中,可能会回来引用这些原则。

Full NAT 模式

目前我们讨论的转发模式,可以叫做是 Full NAT 模式。NAT 是地址转换的意思,Full NAT 可以之于 SNAT 来理解,我们家庭上网都经过路由器给我们做 SNAT,Source Network Address Translation, 路由器将内网的地址转换成 ISP 分配的公网地址,Source IP 被转换了,但是 Destination IP 没有变,所以叫 SNAT。那么 Full NAT 就是都变了。

Full NAT 模式,IP 的变化

如上图,Real Server 看到的 IP 包,来源 IP 和 目的 IP 都变了,所以这个就叫 Full NAT。

一个小细节是 LB 为什么用一个新的 LB Local IP 来连接 Real Server 而不是使用 VIP 来连接?假设所有的 LB 实例有实现上问所说的连接状态同步,那么是没问题的,因为每一个 LB 都可以处理连接。但是如果是不共享状态的模式,只有特定的 LB 才能处理特定的连接,使用同一个 VIP 就会有问题了:Client -> LB VIP 和回包路径 Real Server -> LB VIP 不一定会经过 ECMP 去往同一个 LB 实例上去,这样就处理不了了。下文将 One-Arm 和 Two-Arm 会再讨论。但是无论用什么 IP,都是 Full NAT 模式。

Full NAT 有一个显而易见的问题:现在 Server 看不到 Client IP 了,它只能看到 LB 的 IP。很多场景下我们都是需要 Client IP 做一些事情的,怎么办呢?

有一种方案是叫做 TCP Option Address (TOA),相关 RFC 7974。原理是:在 LB 和 Real Server 握手阶段,LB 将客户端真实的 IP 写到 TCP Option 字段中。Real Server 读取这个字段来获取客户端的真实地址。

这样做很巧妙:只有在握手阶段有额外的数据,数据传输的时候没有带来额外的开销;握手阶段(没有用 TCP Fast Open)不会携带数据,加入额外的 Option 也不会超过 MTU

缺点是 Real Server 侧需要加载一个内核模块(因为 TCP 的处理在内核),从 TCP Option 里面拿到 Client IP 而不是使用 TCP 连接的 IP 地址。然后将这个 IP 放到 Socket Option 里面,最后 Real Server 上的 App 再从 Socket 的 API 中获取地址。

另外一种解决方案,是不隐藏客户端地址,即只转换目的地址。就是 DNAT 模式。

DNAT 模式

DNAT 顾名思义,就是 Destination 地址转换。DNAT 完美地解决了原则1:隐藏 Real Server 的地址。但是有没有隐藏客户端的地址,很巧妙。

它的原理如下图

DNAT 转发模式

LB 发给 Real Server 的时候,只修改了目的地址,为 Real Server 的地址。这样 Real Server 就可以认为来源 IP 就是客户端的真实 IP 了。

但是这里显然有两个问题,Real Server 在回复的时候,根据原则5,将目的 IP (自己的 IP)作为来源 IP,一旦发出去这样的 IP 包,网络设备就会直接路由给 Client,这样,一来暴露了 Real Server 真实的地址,二来 Client 根本就不认识这个 IP:我发给了 LB VIP 请求,怎么轮到你来回复给我东西?于是直接会 Reset 掉这个连接。

问题就出在,Real Server 是不能直接发送给客户端的,而是必须回复给 LB,让 LB 回复给客户端。

那么目的 IP 都已经是客户端 IP 了,怎么将这个包发送给 LB 呢?

答案就是只走二层。根据原则2,如果我们的包经过二层可以到达目的地,那么三层写的什么都无所谓,交换机不会去看三层内容。如下图所示:

DNAT 要经过 LB

Real Server 在回复的时候,虽然 DIP 还是客户端 IP,但是目的 MAC 写的确是 LB 的 MAC 地址。交换机一看,在同一个 LAN 下,直接将这个包交给了 LB 实例。这时候,LB 就可以修改来源 IP 为自己的 VIP,转发给客户端了。

这么做有一个架构依赖:Real Server 和 LB 必须在同一个子网下,不能走三层路由。因为 Real Server 想把这个包发送给 LB,包的 MAC 地址是对的,是 LB 的地址,但是三层的目的地址是错误的,不是 LB 的地址,而是 Real Server 的地址。一旦走三层路由,就露馅了,路由器会将这个包转发给客户端,不经过 LB。

因为这种架构依赖,以及实际部署比较复杂(需要修改路由,走 LB 回包)我见过用 DNAT 的场景很少。

一个解决办法是,回包的时候走 SNAT,就像我们在家里用路由器上网一样,RS 回包不经过 LB,但是经过一个 SNAT(类似家里的路由器),SNAT 会把来源的地址 RS IP 改成 VIP,然后再发给客户端。来回都进行地址转换了,看起来像是 FullNAT,但其实不是。FullNAT 是在同一个机器上保持两端的连接,SNAT+DNAT 是用两种组件,SNAT 和 DNAT 可以分开部署。

Chrysan 补充:DNAT 模式在公有云 LB 底层使用很多,因为可以让 Real Server(公有云的用户) 透明的获取 client IP。实现上不需要放在一个二层,而是依赖了公有云底层 VPC overlay 网络。简单的说,宿主机上的 vswitch 是自己写的,可以强行让 RS->client 的报文转发到对应的 LB 设备。(和后面要讲到的能够跨越二层的 DSR 有些异曲同工之妙!)

无论是 Full NAT 和 DNAT,其实都有一个潜在的瓶颈存在:LB 本身。

互联网的现实是:用户上行流量很小,下行流量很大。比如浏览网页,看视频,看直播,下载软件,等等,场景都是用户发送很小的请求出去,服务器发送很多内容回来。ISP 也知道这个事实,所以在办宽带的时候,ISP 很鸡贼得给你搞上下行不对等的带宽,上行只有5M,下行有100M,把它当成 100M 宽带卖给你,体验也没有特别糟糕,因为大部分场景是在下载。

话说回到 LB,对于 LB 来说,那就是入带宽很少,出带宽很大,消耗资源的地方几乎都在 Real Server 回复包给 LB,LB 转发给客户端上。但是 LB 本身是用来干嘛的来着?高可用,安全,防 DDOS,这些都是针对入流量做的。所以现在出现了一个很奇怪的现象:我一个本身是主要针对入流量做的软件,现在大部分时间都花在处理出流量上,这不是本末倒置嘛?其实,我们就遇到过很多 LB 被出流量打挂的情况。

出流量是我们自己的 Real Server 回复的,基本不存在安全问题,也不需要过滤。那能不能让 Real Server 直接回复给 Client,完全绕过 LB 呢?当然了,之前的条件还是要满足,即,不能暴露 Real Server 的 IP,还得让 Client 正确处理 TCP 请求。

如何做到这两点呢?只能让 Real Server 通过:SIP: LB VIP, DIP: Client IP 来回包了。

DSR 模式

这种模式就叫做 DSR 模式,Direct Server Return。Real Server 直接将请求回复给客户端。

我们按照上面的知识来推理一下:

  1. Client 要能正确处理连接,那么它收到的包必定是:SIP: LB VIP, DIP: Client IP
  2. 那代表 Server 发出来的 IP 包必定是:SIP: LB VIP, DIP: Client IP
  3. 根据原则5,那么 Server 收到的包必定是:SIP: Client IP, DIP: LB VIP

所以,完整的图如下:

这个架构看起来很奇怪,Is it even possible?

让我们来一个一个地这里面的问题,看看能否将这些问题都解决掉:

  1. LB 到 Real Server 这里,怎么能把 DIP: LB VIP 这个包正确发给 Server 呢?显然 Server 上要配置这个 VIP,这样,Server 收到这个包才不会丢弃,有这个 VIP 才能处理 Dest IP 是 VIP 的包(原则6)。那现在 LB 上面有这个 VIP,Server 上也有这个 VIP,;
  2. Server 有了 VIP,LB 也有这个 VIP,怎么确定 LB 把这个包发出去之后,收到包的是 Server,而不是另一个 LB 呢?经过上面 DNAT 的讨论,我们已经可以熟练地使用二层转发了,LB 转发给后端的时候,直接指定 Server 的 MAC 地址即可。即 LB 对 Server 的服务发现要使用 MAC 地址而不是 IP 地址;二层转发就带来一个缺点:LB 和 Sever 必须部署在同一个 LAN。
  3. 客户端请求进入到 IDC 的时候,LB 和 Server 都有相同的 VIP,怎么保证一定是 LB 先收到这个请求处理,而不是 Server 收到这个请求处理?(如果是 Server 收到的话,那 LB 相当于不存在了)。让 Server 完全忽略 ARP 请求即可,即其他的机器都不知道 Server 上这个 VIP 存在,甚至 LB 也不知道,只有 Server 自己知道它有这个 VIP。

这样,看起来就能实现这种路由方式了:客户端请求发送给 VIP,实际上是 LB 收到处理,LB 经过二层同 LAN 路由发送给 Server,Server 处理之后将响应以 VIP 的身份发送给客户端。客户端收到相应很开心,它并不知道是谁处理的,也不需要知道。

这个转发模式最大的限制,就是 LB 和 Server 必须部署在同一个二层下。这个限制的原因是因为 LB 要通过二层 MAC 寻址来发送给 Real Server 数据。我们有没有其他的方法来实现 LB 和 Server 之间的转发,并且能够支持走三层路由呢?

支持跨二层的 DSR 模式

现在我们要满足的需求是:

  • SIP 和 DIP 都不能变,因为 Server 直接回复给 Client IP 需要这些信息;
  • LB 要发送数据给 Real Server,如果要跨越三层,那么就必须使用 Real Server 的 IP;

看起来我们这里需要两层 IP。于是就有了一种方案:把原来的 IP 包封装一下,放到一个 UDP 包里面。LB 发送一个 UDP 包给 Real Server,Real Server 收到之后打开这个 UDP 包,看到的是一个 IP 包,后面就当作普通的 IP 来处理。

这种技术有很多,比如 GRE 就是其中的一种。由于涉及到将原来的 packet 封装在一个 UDP 包里面,所以会增加 UDP header 和 IP header,会涉及到 MTU 超过 1500 bytes 的问题,请参考 有关 MTU 和 MSS 的一切 一文。

这样,LB 和 Server 之间的通讯也能跨越三层了,因为对于路由设备来说,LB 在给 Server 发送一个 UDP 包。(有一些地方说 DSR 模式必须在同 LAN 下部署,实际上是不对的,此方案就可以跨 LAN)

DSR 模式有一个很大的弊端(感谢 Chrysan 补充): 因为回包流量不经过 LB,LB 只有单边流量,导致无法获得 TCP 完整状态机。举个例子,Real Server 突然发送 RST 给 Client,发送到 client 的 RST 不经过 LB,LB 无法感知这个状态变化,会继续保持这个连接为 established(如果是正常结束的话,Client 发回来的 FIN 倒是会被 LB 感知到)。期间如果 client 复用了 src port(短链接场景复用 src port 概率很高),会无法建连接。

主要的转发架构到这里就说完了,下面我们聚焦于单个 LB 内部发生的事情,看一下如何才能让 LB 达到最大吞吐。

转发实现

应用程序 syscall 调用转发,Nginx Stream

上述应 Nginx 来做转发的方式,无疑是最慢的。因为它经过了整个 Kernel 网络栈的处理。

Nginx stream 经过了完整的 kernel network stack

我们说 Nginx 是一个高性能的反向代理,是说作为应用层的程序,它有多路复用,它的模型已经很快了。作为 7 层负载均衡来说,性能足够了。但是作为四层负载均衡来说,就差很多。

核心问题是,它的网络处理都是用了 Linux 的 syscall,意味着对于每一个 TCP 连接的转发,它都要对 Client 维护一个完整的 TCP 连接,包括滑动窗口,buffer,等等,对于 Real Server 一侧,也要维护一个 TCP 连接;依赖syscall 的另一个问题是,从 Kernel Space 向 User Space 传递数据要 copy 内存,也是一个瓶颈。

那么有没有办法不使用 Kernel 的 TCP 实现呢?

Kernel Module 转发,LVS

LVS 的思路是,将转发逻辑直接做到 Kernel 中,用 Kernel Module 的形式。

这样,就不需要向 User Space 来拷贝数据了。另外 LB 也不需要对客户端,和 Real server 维护两个完整的 TCP 实现,它只要将所有客户端发给 LB 的包,一模一样地转发给 Real Server;再将所有 Real Server 发给 LB 的包,一模一样地转发给客户端,就可以了。不需要处理 buffer,也不需要 ACK,不需要处理滑动窗口。

LVS 虽说是在 Kernel 里面,但是依然经过了网络栈,会损失部分性能。那有没有办法在更前面处理包的转发呢?

Kernel eBPF 转发,XDP

XDP 全称是 Express Data Path, 光听名字就很快。它比 LVS 先进的地方在于,包在进入到 NIC 的时候,就可以执行 eBPF 程序进行转发了。也就是说,执行的位置更靠前,bypass Kernel nework stack 的更多,所以也就更快。当然,也有一些弊端,Kernel network stack 提供的一些功能就不能用了。(废话,为了速度让你给跳过了)。

XDP 相比于 LVS 的另外一个好处,是更加安全和方便测试一些。Kernel Module 无论是开发还是测试,门槛都比较高。XDP 是基于 eBPF 的,eBPF 虚拟机自带验证,帮助你避免写出一些危险的代码。

上面都是一些直接在 Kernel 处理的技术,那么反正都是减少 kernel space 和 user space 的拷贝,那能不能 bypass kernel,直接在 User Space 实现呢?听起来很疯狂,但其实是可以的,效果也很好。

User Space 转发,DPVS

DPDK 全称是 Data Plane Development Kit。是英特尔的一个技术,能够让应用程序通过这个库来直接从 User Space 读取网卡数据的程序。DPVS 是爱奇艺使用 DPDK 改写了 LVS,是一个 LB 软件。

本来的路径是,NIC 收到网络包,发出中断,CPU 再来处理,kernel 将内容复制到 user space,程序就可以处理网络内容了。

现在是应用程序直接去 NIC 读写内容,没 Kernel 什么事了,甚至连中断也没了。

啥?中断都没了,怎么知道有内容需要处理?实际上是通过轮询实现的,即 CPU 一直在 NIC 读内容,如果没有内容要处理就再读一次,一直读 (Busy Polling)。所以……使用 DPDK 可以发现即使没有数据,CPU 使用率也是 100%,有数据也是 100%。

跳过了 Kernel Network Stack,甚至跳过了中断,DPDK 的性能自然很高。

一个副作用是,因为我们完全用网卡驱动去读内容,这意味着,使用 syscall 的读写网络的程序无法正常工作了,因为 NIC 已经被应用程序接管而不是 Kernel,Kernel 甚至都不知道这个 NIC 的存在。所以,像 curl,nginx,dnsloopup 等这种软件都无法工作了,甚至 sshd 都无法工作了。那不是给我们的维护带来很大负担嘛?难道要重写这些所有的软件?

使用 dpdk 重写是一种思路,另一种思路是,可以用 dpdk 虚拟出一个网卡,应用程序如果对流量不感兴趣,比如是 ssh 流量,就交给这个虚拟网卡,传统程序都跑在虚拟网卡上。

另一个思路是……再插一张网卡,用于管理程序。

Nginx stream 这种模式目前基本不用了,这篇文章放在这里只是和后面的方式作比较方便读者理解。Grey 告诉我一个 Kernel 角度来看的,这些转发方式的区别:Nginx 转发的模式是对客户端建立 TCP 连接,对 Real Server 建立 TCP 连接,收到一个 SKB (可以简单理解成 IP 包),处理一下,然后创建一个新的 SKB,发给 Real Server。但是 LVS, DPVS, XDP 这些不会创建新的 SKB,它直接修改客户端收到的 SKB 然后转发给后端 Real Server。另外,Nginx Strem 依赖 syscall,原生的情况只能通过 syscall 提供的能力处理 TCP 请求,像是 DNAT,DSR 这些“特殊的”转发模式,涉及到三层包,需要更多的魔法(比如 iptables)配合才行。

下图是 Linux, DPDK, XDP 三种方式的性能比较:

图片来自 Andree Toonk 的一个视频

One Arm 和 Two Arm

到这里,其实大部分的概念都已经讲完了。最后再来说一下 one-arm 和 two-arm。

这个概念是有一些混乱的,混乱的原因是,大家在讨论的时候,经常忽略声明讨论的前提,是在说物理网卡还是逻辑网卡。

我最先听说这个词是在跨 vLAN 通讯上:假设不同子网的两个设备要通讯,应该怎么做?不同 LAN 那就是二层不通了,显然只能走三层。假设不同 vlan 的两个设备要通讯,这么做?答案还是走三层。

但是这里有一个奇怪的地方出现了,因为是 vlan,所以其实所有的设备都连在一个交换机上,即两个 vlan 在同一个交换机上。路由器如果要连接两个 vlan,其实只用一根线,连接一个交换机,然后在路由器的接口上虚拟出来两个子接口,每一个子接口连接一个 vlan,就可以了。

架构图如下:

图片来自维基百科

这个路由器设备上只有一根线,给它起个名字就叫做 Router-on-a-stick. 也叫做 one-arm。

在这个场景下,one-arm 指的是物理线路只有一条。

换到 LB 场景上来说,如果你看到一个地方提到 one-arm 或者 two-arm,只能根据上下文推测一下它说的物理网卡还是逻辑网卡。其实我发现,大多数情况下,人们都是想说逻辑网卡。

比如,跑在 WAN 上的 LB,经典配置就是有两个 interface,一个配置 WAN VIP,一个配置 LAN IP 用于连接 Real Server。我们一般叫这种配置是 two-arm。

但是这两个 interface 其实可以在同一张物理网卡上。

对于 LAN 上的 Lb,可以使用同一个内网 VIP 同时用于对 Client 的连接和对 Server 的连接。逻辑上是一个 Interface,可以称作是 one-arm。但是物理网卡可以有两个甚至两个以上做 bonding。

总结

这个比较大的话题总算是讲完了,LB 笔者在工作中使用过比较多,但是实际设计的经验不多,难免存在错误。如果读者发现,欢迎不吝赐教。

实际在技术选型的时候,一般先从软件开始,软件决定了,再看软件支持的转发架构。然后再实现长连接保持技术。不是所有的软件都支持长连接保持,有一些可能要定制化,二次开发才能实现。

L4LB 技术需要考虑的一些内容

后面有时间了,打算根据这个框架在博客中分析一下一些经典的四层 LB 实现。

一些 L4LB 软件:

一些其他有关 kernel bypass 技术的文章:

四层负载均衡系列文章

 

sed 原地替换和符号连接的一个小坑

今天遇到的一个小问题:foo.txt 是一个常规文件,link-1 是一个符号连接(有些地方又叫软连接,symbol link),指向的是 foo.txt,我们使用 sed -i"" 's/foo/bar/g'link-1 的内容进行替换,替换完成之后,发现 link-1 从一个符号连接变成了一个常规的文件。

实验流程如下,我们对 link-1 进行原地替换,-i"",执行之后,foo.txt 的内容没有改变,但是 link-1 从符号连接变成了常规文件:

乍一看,不合理,sed 只是用来替换一个文件,怎么能改变文件的属性呢?

但是仔细一想,其实挺合理:

  • sed 不可能同时打开一个文件,一边读一边写,因为一写的话,文件会被 truncate。比如执行一下 cat foo.txt > foo.txt,文件会被直接清空;
  • sed 也不可能将文件读入内存,处理,然后写入原文件。因为 sed 最基本的设计就是一个“行处理器”,要一行一行 streaming 处理,读入一部分,处理,然后写入一部分,用很少的内存就够了;
  • 所以要实现原地替换的话,就需要有一个临时文件,sed 先把结果写入到这个文件,最后将文件 rename 到原来的地方;

可以用 strace 来验证我们的推测:

可以看到,sed 就是打开了一个临时文件,然后读-处理-写,最后进行 rename(2)

使用 strace 跟踪 sed 的结果

那有没有不改变文件性质的方法呢?如果解析出来符号连接指向的目标就好了。

很多 Unix 命令,比如 cp, ls 都有设置 follow 符号连接还是不 follow 的选项,sed 应该也有。看了下 man sed,发现果然有。

再用 strace 追踪了一下过程,发现 rename 的时候,就会指向符号连接的 target 而不是符号连接本身了。

strace 追踪 sed 执行过程
 

SRE 线上操作指南

我们每天要进行大量的线上变更操作。怎么保证这些操作安全,不会导致故障,是我每天都在思考的问题。

这篇文章从工作经历总结一些原则和想法,希望能有帮助。

线上操作有几点基本的要求:

  • 操作需要是可以灰度的 (Canary):即能够在一小部分范围内生效,如果没有问题,可以继续操作更多的部分;
  • 操作必须是可以验证和监控的:要知道自己操作的结果,是否符合预期;
  • 操作必须是可以回滚的:如果发现自己的操作不符合预期,那么有办法能够回到之前的状态;

逻辑很简单:假设我一开始做操作范围很小,可以灰度,做完之后我可以监控是否符合预期,如果不符合预期就回滚,那么,操作就是安全的。

这三步中的每一步看似很简单,但是实际做起来很难。

灰度

发布过程是最简单的一种灰度场景,现在有蓝绿发布模式:

  1. 分成两组,将所有的流量切换到绿组;
  2. 先发布蓝组,此时是没有流量的,发布完成之后,将流量逐渐切换到蓝组;清空绿组;
  3. 然后发布绿组,发布完成之后将流量切换到平均到两组

还有滚动发布,对于每一个实例:让 Load Balancer 不再发给它新的流量,然后升级,然后开始接收流量,如果没有问题,继续以此处理其他的实例。

几乎每个人都可以理解灰度的必要性,但是不是每一种操作都是可以灰度的。

比如说数据库 DDL 的变更,很难灰度,提交到数据库,数据库就开始应用了;还有一些动态配置系统,一些全局配置,如果修改,就对所有的应用同时生效的;一般都是这样一些数据源类型的变更,很容易出现不支持灰度的情况。不可以灰度的情况也是最容易导致问题的。

一个替代方案是,搭建一套一模一样的环境,在这个环境先应用变更,测试一下是否符合预期。但是在今天分布式环境下,很难模拟出来一模一样的环境,可能规模小了,可能测试环境没有一些用户的使用场景,等等。总之,模拟的环境没有问题,不能代表生产的环境就没有问题。

最好的解决办法,还是在软件和架构,从设计上就能支持灰度。

验证与监控

所有的操作,一定要知道自己在做什么,效果是什么。做完之后进行验证。听起来很简单,但是实际上,很多人做事像是闭着眼睛,不知道自己在做什么,做完之后有什么效果也不管。

验证操作的结果

举一个例子,比如目前网关遇到了什么问题,经过查询,发现和 Nginx 的一个参数有关,然后根据网上的内容修改了这个参数,回头去看问题解决了没有。如果没有,继续在网上查资料,看和什么参数有关。

上述操作,一个潜在的问题是,当问题真正修复了之后,我们不知道自己做了啥才修复问题的。也有一些时候,相同的配置变了名字,实际上这个修改这个参数是可以解决问题的,只不过我们用了从网上得到的过时的参数名字,所以不生效。

所以,对于每一个操作,推荐直接去验证目前的操作结果。比如改了一个 log 参数,那么直接去看这个参数是否生效,是否符合预期,然后再去看其他的问题是否得到解决。

做操作要一步一步来,做一步验证一步。

另外,最好去验证操作的副作用,而不是验证操作本身。比如,修改了一个配置,不是去 cat 一下配置文件确认就可以了,而是要去看自己修改的配置是否真的生效了。比如路由器设备,我们执行了一些命令 ip route ... ,验证的方法并不是 show running-config 去看配置是否有这一条,而是要去看 show ip route 确定配置是否生效。比如修改了 postgres 数据库的一个配置,重启数据库,并不一定意味着配置生效了,你可能修改了一个错误位置的配置文件,验证的方法应该是进入到 postgresl 数据库中,然后执行 SHOW ALL 命令,校验配置是否是预期的。

验证核心的业务指标

除了验证操作结果之外,也要关注业务指标是否还正常。

如果业务指标不正常了,而恰好和自己的操作时间吻合,那么就应该立即回滚。

听起来很合理?但是实际上,很多人(我也是)第一反应都会是,我的操作不可能引起这个问题,让我先看看日志,到底发生什么了。

当发生问题的时候,时间很宝贵,正确的做法是第一时间在群组里面宣布自己的操作(事实上,操作之前就宣布了,但是消息太多,没有问题的时候没有人会认真看操作历史),然后开始进行回滚。可惜的是,我发现这么做的人很少,大部分都是想去排查,直到确定是自己的操作导致的,才开始回滚。

回滚

回滚方案至关重要。有了能够工作的回滚方案,在出现未预期的问题的时候,我们可以不需要调查根因就直接触发回滚操作,最大限度减少损失。

但是同上,不是所有的操作都可以回滚的。一些可以补偿的方案有,操作上尽量设计成可以回滚的(有些废话)。比如,DDIA 这本书就介绍了数据上如何做向前兼容和向后兼容的方法。

举个例子,比如软件新版本的一个配置要从名字 A 改成 B,不要直接改,而是添加一个配置 B,代码里面可以读 B,如果没有的话,尝试读 A。等升级完成之后,在下一个新版本中,去掉 A 的逻辑。这样,每两个版本之间都是兼容的。

一次只做一个操作,不要将多个操作合在一起

如果将多个操作合在一起,上面的灰度、监控和回滚都不好做了。不知道问题是哪一个变更造成的。

原则上一次操作只做一个修改。

操作计划和操作记录

一些复杂的操作,比如修改 DNS,配置网关,配置其他东西,可能是联动的。而且现实中也不是所有的东西都适合自动化的。这些复杂的操作,推荐在操作之前就写好操作计划,然后对着一步一步操作,贴上必要的验证结果和操作时间。万一出现什么异常,就可以将异常出现的时间和自己的操作记录对照,很有用的。操作计划也可以相互 review,如果是 gitops 的话,就更好了。

将参数尽量写在操作流程,而不是操作的时候

比如在操作某一个节点的时候,一种方案是,直接在操作的 pipeline 中输入 IP,然后执行操作。但是 IP 很容易输入错误,看到这个 IP,也不会反应过来这到底是哪一台机器。所以,更好地一种方法是,给这个 IP 一个名字,我们使用名字操作,而不是指定 IP。

比如在 Ansible 的 inventory 中,我们可以这样写:

这样,我们在操作的时候,输入的参数就是 webserver_us_1 了,输入一个 IP。(我已经见过很多个因为搞错测试和生产的 IP 而导致的线上事故了)。

另外如果要操作多个机器,可以将 Host 按照区域,灰度步骤等,进行编组。这样在操作的时候,直接指定编组,而不是临时将机器进行分组。

使用像 Jenkins 这种平台的时候,执行 Job 的时候,最好将参数变成选择项,而不是 text 输入,减少错误的可能性。

总结来说,就是尽量将操作的时候需要填写的选项降低到最小。将操作固化下来,review 这些操作,进行测试,测试没问题之后,merge 到主干。这样,对操作步骤就可以更有信心。

操作的每一步都设计成可以失败的

为了进一步防止在操作的时候选错参数的情况,需要在设计操作流程的时候多花点心思才可以。

操作的每一个都应该设计成可以失败的,在大多数的实例是可用的情况下,少数实例的重启甚至宕机应该不引发问题才对。这样,即使在操作的入口选择错了参数,本想执行测试环境却执行了线上环境,那么也不会有什么大问题。

举一个例子,服务升级的步骤,不好的写法是,直接将服务的 binary 下载到执行目录,然后 reload;比较好的写法是,将服务的 binary 下载到一个临时目录,下载完成,校验 checksum,然后设定权限,最后通过 symbolic link 链接到可执行的位置。后者的好处是,无论在哪一步失败,都不会造成问题。

理想的情况下,所有的步骤无论执行或者失败都不应该造成影响。比如,保证所有 merge 到主干的代码都是可以部署的,即使不小心触发部署,也不应该造成很严重的事故。这样,即使「手抖」也不应该造成问题,SRE 的生活将会充满阳光,而不是每天提心吊胆。

Ad-hoc 命令

我们经常需要 ssh 登录机器来运行一个命令做检查。对于敲下的命令,一定要知道这些命令的原理

举几个不当操作的例子:

  • 有同事使用过 Vim 查看日志。这是不正确的,用 Vim 打开日志文件(通常很大)会将文件读入内存,导致机器的内存不足,OOM-killer 开始杀掉业务进程。在机器上处理文本,需要使用 grep, awk, sed 这种「流式风格」的软件,或者使用 less 这种使用文件 offset 风格的软件;
  • tcpdump 运行的繁忙的机器上,对性能是有影响的;
  • 有些命令行看起来是读操作,但是也是会对性能造成影响的,比如 RAID 卡控制器;
  • mount 主机的 /etc/hosts 文件进 docker container,如果使用 Vim 修改 Host 的文件,docker 中的文件不会改变(要验证操作结果而不是验证操作本身,不然,就不会发现问题)。因为 Vim 编辑文件默认会修改 inode(:h backupcopy), 应该用 echo 来修改;

一个小技巧之避免整点操作

如果公司比较大的话,那么不可避免的,很多操作都是在同时进行的。可能有人在进行发布操作,有人在修改配置。如果造成了故障,就很难知道故障是谁导致的。

所以我倾向于避开和其他人同时进行操作——选择一些看起来不是整齐的时间。比如 15:13, 16:27 这样的时间。当发生故障的时候,可以快速判断和自己的操作的相关性。

另外一个原因是,公司的业务可能在整点触发一些发劵,通知,促销等,所以这样做也可以避开业务高峰。

不过,还是最好要选择在工作时间进行线上操作。如果是非工作时间,出了问题,就会加大发现问题的时间(可能造成的问题你发现不了,但是其他的业务方会发现),也会加大找到相关负责人的时间。

效率即是安全

这是 Last but not least! 操作的效率至关重要。

我认为运维平台要设计成简洁,没有歧义,流程清晰的,非必要不审批。这可能跟直觉相反,尤其是领导的直觉。

领导(不知为何)觉得审批流程越多越好,出了事故就开始思考在哪一个阶段可以加上一个审批流程,来避免类似的问题发生。但其实,我觉得流程越多,出问题的概率不减反增。

程序员天生就不喜欢繁重的流程,如果流程太重,就会出现其他的问题,比如,人们会想办法绕过不必要的流程;会想办法“搭车发布”(意思就是将多个操作合并成一个,这也是违反原则的,一次应该只做一个操作);对于明显出现异常苗头的时候,因为不想重新走审批而铤而走险。

但是出现这种情况,领导不会觉得流程有问题,领导会觉得你小子不按照流程办事,开除。

最后导致 SRE 的幸福感很低,事情还是要那么多,完成工作不得不铤而走险,还得责任自负。

事实上,真正能保证安全的是架构设计简单,做事的人知道自己在做什么,操作按照如上灰度、验证,出问题回滚,而不是靠流程。SRE 之间 Review 是有价值的,审批是没有价值的,大部分的审批仅仅是请示一下领导而已,领导可能看不懂操作的后果是什么。

所以,流程是有代价的。

 

Flameshow 性能优化小记

由于工作中经常遇到需要性能分析,所以自己写了一个能在终端看火焰图(后面打算再写篇博客介绍下火焰图以及如何阅读)的小工具:https://github.com/laixintao/flameshow。这篇文章先记录一下,在开发过程中遇到的一些性能问题,以及优化方法。

之所以记录这篇博客,是因为这个问题思考了很久,尝试过很多方法,最后解决的时候,感觉太爽了,跟当前解决了 iredis 那个巨大正则表达式编译速度太慢的问题一样。所以迫不及待地想分享一下。

先介绍一下这个项目的背景。这个程序做的事情,本质上就是按照程序在 stack 上花费的时间,渲染出如下一张火焰图,方便快速定位程序都把时间花在了什么地方。

flameshow 展示火焰图

本质上就是将 frame 按照一层一层地渲染出来,如果程序在这个 frame 花费的时间长,就渲染的长度多一些,如果花的时间短,就渲染的长度短一些。

我是用 textual 这个库(很赞的一个库,准备单独写一篇博客来赞美这个库!)来做渲染的,只花了两个小时就写出来了原形。但是速度非常慢,效果如下:

优化之前的载入效果

优化之后的效果如下:

优化之后的载入效果

上面的 GIF 都没有经过裁剪,可以看到效果非常明显,原来需要 5s 左右渲染出来,优化之后只需要 100ms 左右。下面就介绍一些优化的方法。

从 Golang 到 Python

目前火焰图渲染支持的格式主要是 Golang 定义的 pprof 的格式,是 golang 定义主要使用的一种格式(其实很多其他语言的工具,导出的格式也是 pprof)。那么解析这种格式,就是需要用 golang。

所以最开始的解析就是通过 Python 代码,再通过 cffi,去调用 golang 生成的 .so

Python 调用 golang 的库

这就有一个问题,使用 golang 解析完成 pprof 文件,通过什么数据格式来传给 Python 呢?这里面还要经过 C,所以得用很基本的数据类型才行。我就选择了 json。

但是这个 Json 非常大,一个原本 64KiB 的 pprof 文件,解析成 json 之后,就变成了 13MiB! 所以在 Python 中反序列化就耗时很久。

跨语言调用还带来一个问题就是安装麻烦,要么我去给每一个平台 build wheel 分发,要么用户安装环境需要有 go 才能编译这个扩展。这个工具定位就是能够直接在服务器上进行性能分析的,所以如果不依赖 go 编译器就好啦!

求助 messense 编译 wheel 的问题是,他建议用 Python 实现一下 pprof 文件的解析(这个主意真好!)。

于是我看了下这部分的 golang 代码,发现也不复杂,,核心逻辑是:

  1. 如果是 gzip 压缩的文件,就先 uncompress
  2. 然后通过 protobuf 来做解析
  3. 做数据恢复

(3)数据恢复步骤很有趣,我发现,pprof 里面有很多 id 引用,比如一个 Function 里有 binary map,line,等各种信息,表达多个 function 的 stack 关系的时候,并不是直接将每一个 function 像 json 那样列出来,而是将所有的 function id 列出,一个是 int 类型的 array。然后有一个 function table,记录了每一个 function 的内容。其中,最精彩的部分是,所有的数据结构都不是 string,然后有一个 string_table 字段记录了所有的 string,这样,整个 pprof 文件中,一个重复的 string 都没有出现。这样,压缩效率非常高。代价就是,我们在解析的时候,读完 protobuf 还要将这些 id 还原回去。

压缩效率有多高呢?

如果直接 pprof 转换成 json,是 13M,用 gzip 压缩之后是479K,但是原本的 pprof 只有 64K。

pprof compression rate

这部分工作做完之后,读取 pprof 的环节加快了 70% 左右。下面的日志是前后版本分别运行,计算的解析 pprof 解析的时间。

之前需要一共 0.9s,优化后不到 0.3s

渲染部分优化

从上面的 Gif 可以看到,即使数据读取完了,渲染,看到最后的火焰图也经过了不少的时间,看起来就比较 “卡”。

这是因为最开始写的时候,直接用了 textual 的 widget,每一个颜色的 frame 都是一个单独的 widget。

渲染逻辑,每一个框都是一个单独的 widget,widget 用来限制 content 的最大宽度,也帮我们计算了偏移量。但是 wieget 嵌套非常多,上面只出现了 5 个 span,但是需要 12 个 widget

一开始也没有想到为什么这么做会慢。于是用 py-spy perf 了一下(咦,是谁在用火焰图去分析火焰图分析工具?是我!)

上面这种渲染方式得到的性能分析火焰图

可以看到,主要的时间都在 compositor.py, 并且这个函数也在不断嵌套。

一个想到的办法,就是先通过减少渲染的层级,来节省时间。因为我们看火焰图的时候,注意点在哪一条最长,短的其实不太关心。所以,我做了以下优化:

  1. 如果 sample 数量超过 N,sample 越多,横向渲染的 span 越少;比如,在 sample 有 3000 多的时候,只渲染最长的 5 条 span 的 children,其他的,只显示一个 +more
  2. 同理,向下的层级也是越少;
  3. 如果当前的 Frame 只有一个 child,那么不使用嵌套 widget 的方法,直接在当前的 widget 加一个 Span 堆下去;
  4. loading 的时候,显示 loading... 字样,稍微提高一下用户体验。

渲染一个巨大的 sample 的时候,如下图:

可以看到总体的信息很少,有很多 +more 但是还是能看的

但是这些都是治标不治本。有一天朋友跟我说:你这个工具都是终端渲染,全都是字符而已,渲染速度应该很快吧?我直接破防了。

通过 Line API 来渲染

最根本的解决办法,就是改变渲染的逻辑,不应该用嵌套(递归好爽,这个工具的 0.1 版本我记得写过五六次递归……),而是直接一行一行 print 出来,这样直觉上应该做到秒开。

只用一个 widget,改变渲染的逻辑,按照行直接 print

textual 正好有这么个 API,可以让你自定义渲染的方式:line API. (要么这么说这个库做的好呢,你需要的人家都想到了。)

API 的格式就是 def render_line(self, line_no: int)

textual 渲染的时候,就来调用你的这个函数,你返回在 line_no 的内容。

其余的就是大量的重写工作了,因为不是 widget 直接嵌套了,所以很多宽度和偏移的计算要自己做。做完之后,果然可以做到秒开了!


这之间遇到一些奇奇怪怪的问题,比如有一个很有意思的:下面有 3 行,括号内,表示一个 Span 的 offset 和 value。

第一个行的内容是: Root(0, 100)

第二行:Frame1(0, 5.4), Frame2(5.4, 2.4), Frame3(7.8, 92.2)

如果渲染这两行,我们按照他们的 value 比例进行宽度渲染,假设当前屏幕宽度为 100 字符(终端最小单位是字符,无法打印出来 1.4 个字哈,要么是1,要么是2,所以必须将上面的 float 比例转换成 int 的宽度和偏移)。

第一行,直接 100 个字符,很简单。

第二行:

  • Frame1 按照 5.4 value,四舍五入,打印 5 个字符;
  • Frame2 偏移 5.4,宽度 2.4 四舍五入之后是 offset 5,占用宽度2;
  • 那么第三个 frame 就麻烦了,还是按照四舍五入吗?那么就是偏移等于 8,宽度 92;这就出问题了,前面一共宽度是 5+2,Frame3 从偏移 8 开始,就空出来一个。

核心问题是,如果简单的四舍五入,那么原来的 value 总和,并不等于对所有 value 四舍五入之后的总和。

这样就导致…… 渲染出来的纵向不是直的,而是歪歪扭扭的。

round 导致纵向无法对齐

这个问题可太有意思了,让我简化一下需求:请实现一个功能:给一个数组,float 类型,把每一个元素都变成 int,要求:

  1. 前后相同 index 的差值不能超过1
  2. 并且前后相加总和要想等(float精度问题造成的差值可以忽略,可以假设前后的 sum 都一定是 int)

读者可以想想,你会怎么实现呢?

答案在这里

 

有关 TLS/SSL 证书的一切

TLS 握手其中关键的一步,就是 Server 端要向 Client 端证明自己的身份。感觉有关 TLS 的内容,介绍握手的原理的有很多,但是介绍证书的并不多,证书是 TLS/SSL 非常关键的一环。本文就尝试说明,证书是用来干什么的,Google 是如何防止别人冒充 Google 的,证书为什么会频繁出问题,等等。

(后记:写了整整 10 个小时,这篇文章难度很低,不涉及数学内容,最多就是几个 openssl 命令,篇幅较长,读者可以打开一瓶啤酒,慢慢阅读)

证书是来解决什么问题的?

假设有一个银行叫做 super-bank.com,保存了客户一百亿的现金,客户可以通过登陆他们的网站转账。

这时候一个黑客走进了一家星巴克,连上了星巴克的 WiFi,通过伪造 DHCP 服务,告诉其他使用星巴克 WiFi 的用户:我就是网关,你们要上网的话,让我帮你们转发就可以了!这时候他就可以看到所有用户的用户名密码。

有读者说了,可以用 TLS 加密呀!

这时黑客不知道从哪里搞来了一张签发给 super-bank.com 的证书,然后伪造了一个服务器,告诉用户,相信我,我就是这家银行!

于是又把用户的密码偷走了……

TLS 是如何保证安全的?

那么这里问题出在哪里呢?

就是这张证书。证书的作用是:有且仅有证书的持有者,才是真正的 super-bank.com.

即,证明我是我。

有一个普遍的误解,就是只有证书机构才能签发证书。其实不是的,每个人都可以签发证书,我可以自己创建一个 Root 证书,也可以给任何域名签发证书。证书可以有很多,客户端必须只信任那些“有效”的证书。

那么哪一些证书是可以信任的证书呢?就是权威机构签发的证书。

这个问题有三个参与者:客户端,CA(Certificate Authority, 签发证书的机构), 和网站。

CA,客户端,和网站

这个话题之所以讨论起来比较复杂,我觉得根本原因是因为这三者之间互相联系,又各司其职,在讨论的时候容易弄混了这是谁的指责而搞不清楚问题。所以,这篇文章我使用三个主要部分,对这三者要解决的问题和做的事情分别讨论,希望能够说清楚。

他们的关系是:

  • 客户端信任 CA 机构;
  • CA 机构给网站签发证书;
  • 客户端在访问网站的时候,网站出示自己的证书,由于客户端信任 CA 机构,也就信任 CA 机构签发的证书;

有点像我们去吃饭,怎么知道是不是黑店呢?我们看饭店有没有工商局签发的营业执照,我们信任工商局,如果这家饭店持有工商局签发的营业执照,我们就信任这家饭店。

现在问题看起来非常简单,但是如果我们往坏处想一想,有没有破坏这个信任链的方法?就会发现这个简单的“信任”问题,解决起来并不简单。

比如,我向 CA 机构去申请一个签发给 super-bank.com 的证书行不行?CA 机构为什么不会签发给我这张证书呢?

证书机构

证书机构要做的事情有 3 件:

  1. 对网站:有人向我申请证书,我要验证申请人的身份,如果他不是 super-bank.com,那我就不能签发证书给他;
  2. 对自己:要保护好自己的根证书的 private key;
  3. 对客户端:要被客户端信任;

验证网站身份

我们不能签发证书给错误的人,这样的话,证书持有者不就可以冒充别人了吗?所以对于所有的申请者,我们都要确保他确实是有这个域名的控制权,才能给他签发证书。即要验证申请人的身份。

行业的标准叫做 ACME Challenge,大致的原理是:向我(CA)证明你是 super-bank.com, 你让 super-bank.com/.well-known/acme-challenge/foo 这个 URL 返回 bar 这个 text,来证明你有控制权,我就给你发证书。(其实还支持 DNS TXT 等其他的方式)

保管好自己的 Private Key

机构要保管好自己的 private key,因为 key 一旦泄漏了,意味着 (2) 也做不到了,key 的持有者可以随便签发证书了。所以,如果 Private Key 泄漏了,那会是很恐怖的,对于 CA 来说将是灭顶之灾。因为所有的客户端必须吊销这个 CA 签发出来的所有证书,网站必须重新部署证书,给 SRE 们带来的麻烦不说,假如有客户端没有及时吊销这个 CA,那么这个客户端访问的服务器,天知道还是不是真的服务器了,免不了被盗号骗钱。

对此,CA 机构对于使用 CA Root 证书的 private key 非常谨慎。用 Nginx 在公网上搭建加密数据通道 这篇文章中介绍过 private key 的管理制度之严格。

世界上有这么多网站需要申请(过期了也要重新申请)证书,如果每次签发证书 CA 都要从笼子里把 key 拿出来用,未免效率也太低了!

所以 CA Root 一般不会直接给网站签发证书,而是会签发一个中级证书,用这个中级证书给网站签发证书。

这就是 x509: 如果客户端信任 CA,那么客户端也应该信任 CA 签发出来的证书,那么客户端也应该信任 CA 签发出来的证书签发出来的证书…… 诶?!等等,既然这样,那是不是说,CA 给我签发的证书,我也可以拿来签发其他的证书?那岂不是人人都可以拿 CA 的信用签发证书了?

当然不行,CA 签发出来的证书,都有一个 X509v3 Basic Constraints: critical,值是 CA:FALSE.我把这个博客的证书下载下来,用 openssl 工具解析,可以看到这个证书链条中,Root 证书和中级证书都是 CA:TRUE, 只有我的证书是 CA:FALSE.

CA 的值,签发给 Entity 的证书 CA:FALSE 意味着即使签发出来证书,也不被信任。

那我们可以修改一下这个字段,去签发证书吗?

答案是可以,但是如果我修改了我自己的证书,修改之后的证书就不再会被信任。

Client –trust–> Root CA –trust–> Intermediate CA –NOT trust –> kawabangga.com — NOT trust –> 我 sign 的 super-bank.com

为什么我改了这个证书,别人就不信了呢?

证书签发过程

证书的签发其实很简单,就是用了 private key 和 public key 的特点:

  • private key 签名的数据,public key 可以验证;
  • public key 加密的数据,private key 可以解密;

让我来跑个题,其实我觉得这么说有些复杂,我将其简化为:

  • 一个 key 加密的数据,只有用另一个 key 可以解密;只不过我们将一个 key 发布出去作为 public key,一个自己藏起来作为 private key。

咦?为什么少了一条?

因为签名的原理,本质上也是加密:

  1. 我先把整个证书进行 hash,然后对 hash 的值,使用 private key 加密;
  2. 验证者将整个证书 hash,得到 hash 的值,然后使用我发布的 public key 对 (1) 拿到的秘文进行解密,对比 hash 值,如果一样,说明确实 private key 的持有者加密的;

客户端验证证书的过程如下,其实就是对比一下公钥解密之后的 Hash 值,和自己计算的 Hash 值是否一样。

图片来自这里

这样,签名就保证了以下几点:

  1. 签名者无法抵赖,因为只有 private key 的持有者才能进行签名;
  2. 签名之后的内容无法篡改,因为一旦篡改,验证的步骤就会失败;

如此,我们就会发现,CA 对证书签名之后,证书的任何部分都不能修改,包括 CA:FALSE,也包括有效期等。如果过期,必须重签证书,自己想修改一下日期继续用也是不行的(废话)。

这里我想重申一个误解:很多人认为证书签发是 CA 拿自己的证书签名,但是其实不是,CA 只是拿自己的 private key 给原证书 append 了一个加密的 hash 值而已。(后面会考的!)

证书机构要被客户端信任

啊,这一切的一切,都要有一个核心的基础:客户端要信任 CA,客户端对其他所有证书的信任都来源于对 CA Root 证书的信任。

而建立信任又是一个漫长的过程。(我去,这句话好有哲理,让我把它做成名言)

建立信任是一个漫长的过程。

–laixintao

那么客户端如何信任一个 CA 呢?答案是客户端会将 CA 存储在本地。具体本地的什么地方,取决于客户端是什么,比如 Linux,位置在 /etc/ssl/certs, Mac 可以用 keychain 来查看。Chrome 信任的证书在这里,Mozilla 的在这里

哇,世界上有这么多客户端,增加一个新的 CA 得多困难呀!

其实也不是,10 年前(2013年),有几个人雄心勃勃得创建了一个组织 Let’s Encrypt:我们要给世界上所有需要 TLS/SSL 的网站免费签发证书!用友好的方式!因为我们想有一个更加安全和隐私的互联网。

一个新的 CA?信任从哪里来呢?

事实上,这个 CA 并不需要花很多年慢慢取得所有客户端的信任。如上面所说,它只要有一个 Root CA 信任即可——客户端信任一个老的 CA,老的 CA 给新 CA 做签名,客户端就会信任新 CA。这个老的 CA 在这里就是 DST Root CA X3。按照如上逻辑,客户端信任 DST Root CA X3,也就信任了 DST Root CA X3 签给 Let’s Encrypt 的证书,也就信任了 DST Root CA X3 签给 Let’s Encrypt 的证书签发的中级证书,也就信任了 DST Root CA X3 签给 Let’s Encrypt 的证书签发的中级证书签发的其他网站证书,也就信任了 DST Root CA X3 签给 Let’s Encrypt 的证书签发的中级证书签发的其他网站证书签发……等等,网站证书不能再签发证书了,CA: FALSE 还记得吗?

哇,有这么多证书,客户端应该怎么去验证呢?听起来很麻烦,我到底应该信任谁呀?

客户端

终于把 CA 机构做的事情说的差不多了,可见,作为 CA 并不是一件简单的事情,管理成本可不小,怪不得发证书要收钱呢!可见,Let’s Encrypt 可真是伟大的一个组织!

作为客户端,验证证书也不是一个简单的事情,介绍 TLS 握手的文章往往会提到 “服务器发回证书,客户端进行验证”,但实际上,通过上面的介绍,我们可以发现,服务器发回来的并不是一个证书,而是多个证书!

我们可以通过抓包确认这一点:

访问 zoom.us,在 TLS 握手的时候抓到的包

可以看到 Server 一股脑发送了三个证书回来(所以 TLS 建立的成本不小呀,这一下就是接近 4K 的数据了)。

那么客户端怎么去验证这些证书呢?

构建 Chain of Trust

客户端要相信这个证书,最终的目的一定是验证到一个自己信任的 CA Root 上去。一旦验证了 CA Root,客户端就信任了 Root 签发的证书,也就信任了……Sorry,忍住了,不会再说一遍了。

所以,首先客户端要构建出来一个签发的链条,通过这个链条去验证到 Root 上, 并且每向上找一步,都要确认这个验证过的证书是没问题的。

构建这个链条的依据,是从证书本身携带的 issuer 字段,每一个证书都标明了是谁签发给我的。

可以看到 zoom.us 证书的签发者是 DigiCert TLS RSA SHA256 2020 CA1,而这个证书的签发者是 DigiCert Global Root CA

怎么才算是“没问题”呢?

  • 证书时间不能过期;
  • 使用 issuer 的 public key 验证签名没问题;
  • issuer 必须是 CA:TRUE (如上文所说);
  • ……

验证步骤就是从 zoom.us 的证书开始,如果没问题就验证它的 issuer。直到遇到一个证书,issuer 是自己,这就表示到了 Root 了。Root 证书不会相信 Server 发回来的,就算发回来也没用,Root 证书,客户端只会信任自己本地存储的。如果本地存储的 Root 证书能验证通过中级证书,说明就没问题了。

验证的过程可以用下面的伪代码标识:

(代码来自这里

用俺这个脚本可以打印出来网站的证书链:

以上面的 zoom.us 为例,验证过程是:

  • 验证 *.zoom.us 证书,通过 DigiCert TLS RSA SHA256 2020 CA1 进行签名验证(签名过程上文介绍过);
  • 验证 DigiCert TLS RSA SHA256 2020 CA1 ,通过它的 issuer DigiCert Global Root CA 进行签名验证,DigiCert Global Root CA 读取本地的文件,而不是使用 Zoom Server 发回来的这个。

我们可以用 openssl 来手动验证这个 cert,来更加了解证书验证的过程。

首先,我写了一个脚本,可以下载网站的 TLS 证书到本地:

使用方法是 download_site_cert_chain zoom.us

下载完成之后将在本地得到这两个证书:

使用下面这个命令验证 zoom_us.pem

验证失败。因为 zoom.us 的 issuer 并不是一个 openssl 认识的 CA,而是一个中级 CA。openssl 并不信任这个中级 CA,所以我们要告诉 openssl,链条中还有一个证书——中级 CA 的证书,通过这个中级 CA 的证书,openssl 可以发现,哦,原来你这个 digicert_tls_rsa_sha256_2020_ca1.pemDigiCert 签发的呀!你早说不就得了:

这下子就 OK 了。

我们还可以验证一下,openssl 有没有在本地去读 CA 文件。

我们使用 strace 看下 openssl 都打开了哪些文件:strace openssl verify -untrusted digicert_tls_rsa_sha256_2020_ca1.pem zoom_us.pem 2>&1 | grep open

openssl 打开的文件

可以看到,openssl 打开过我们给的两个 .pem 证书文件。但是看不出来哪一个是 CA Root 证书。

其实是最后一个:/usr/lib/ssl/certs/3513523f.0 就是。

不知道读者有没有发现,上面的过程貌似缺少了一步:客户端是怎么根据服务器发回来的证书,对应到本地 CA Root 文件的?

答案是通过中级证书的 issuer,openssl 给所有的 Root 证书 subject 制作了一个重命名的 Hash,用这个 hash 做了符号链接(不确定为啥,是为了查找起来更快吗?),查找的时候,根据中级证书的 issuer (即 Root 证书的 subject)进行 hash,就可以找到本地证书文件。

我们查看这个证书的 subject,跟中级证书的 issuer 显示一样:

然后可以手动计算一下 hash:

/usr/lib/ssl/certs/3513523f.0 恰好匹配。咦,还有个 .0 呢?这里让读者自己想一下吧。答案在这里

Root CA 交叉签名

前文提到了,一个新的 CA 在打开市场之前,需要找一个大哥罩着自己。那么具体是怎么工作的呢?

我们用上文的脚本去拿到维基百科的证书(使用的是 Let’s Encrypt 签发的证书,Let’s Encrypt 可真是伟大的一个组织!):

可以看到有三个证书,这时候,客户端从验证 wikipedia.com 开始:

  1. 验证 R3 签发的 wikipedia.com,如果成功:
  2. 验证 ISRG Root X1 签发的 R3,但是如果客户端在本地存储了(即信任) ISRG Root X1 ,就会用本地的验证,然后验证结束,验证成功。否则:
  3. 验证 DST Root CA X3,客户端信任 DST Root CA X3,验证结束。

这就是交叉签名的验证原理。

2021 年发生过一个印象深刻的小插曲,2021年9月30日,是当时 DST Root CA X3 的过期时间。这意味着,2021年9月30日过后,如果客户端不信任 ISRG Root X1,那么客户端就无法信任 Let’s Encrypt 签发的任何证书。此时,Let’s Encrypt 的接受程度已经很大,主流浏览器和操作系统都已经直接信任了(Let’s Encrypt 可真是伟大的一个组织!)。这意味着几乎不会发生任何问题。

但是,有一些 Ubuntu 16.04 服务器由于很久不更新(只有更新才能获取到新的 CA,很合理吧)(2021年了还在用不更新的 Ubuntu 16.04?这是什么草台班子?!),所以一直没有信任 ISRG Root X1。这导致,在2021年9月30日那一天,很多服务器访问其他网站和 API 出错。这就是客户端部分没有做好 CA Root 维护的一个例子。

中级 CA 交叉签名

现在来到真正麻烦的话题了。上文的 Root CA 交叉签名是一种方式,也是最通用的方式。只有一个链条:Cert > R3 > X1 > DST,客户端这样验证下去就可以了。

但是中级 CA 也可以被交叉签名。

这里重申几个(我之前困惑)要点:

  1. 一个证书只能有一个 issuer,因为 issuer 是证书的固定字段,不是一个 List;
  2. 签名的本质,只是 append 一个 private key 加密的 hash 值;
  3. 中级证书不被客户端直接信任,客户端信任的只有 Root CA;

有了这些,我们来看之前 Let’s Encrypt 的 R3 中级证书交叉签名的方式(这是之前签名的一种方式,现在已经不用了,请读者不要跟上面实际抓包的例子混淆,现在基本上不使用中级证书交叉签名的方式了,都是 X1 Root 证书直接被 DST Root 来签名,下面只是历史的一个简化例子,而且忽略了其他证书链的存在):

R3 中级证书被两个机构签名

咦?一个证书不是只能有一个 issuer 吗?为什么这个图的 R3 有两个 issuer?

没错,很多图会这么表示中级 CA 被多个 Root CA 交叉签名,但是实际并不是这样的,实际上是两个证书!R3 证书拿去找 DST Root CA X3 签名,得到一个签名后的证书;然后 R3 再去找 ISRG Root CA X1 签名,又得到一个证书,现实中,证书链应该是如下这样:

ICA Cross Sign

R3 得到了两个签名后的证书!这时候 example.com 来找 R3 进行签名了,R3 应该用哪一个签名呢?

答案是用任意一个都行!

请回忆我们上文讨论过的签名的原理,其实就是 append 一个加密的 Hash 值,那么 R3 使用相同的证书去找不同 Root 签名两次,本质上,得到的两个证书,证书自己的 public key 和 private key 都是一样的。所以 R3 在给其他网站签发证书的时候,使用任意一个证书的 private key,给网站证书计算出来的 hash 值,是一模一样的。

这里的核心是需要理解,证书签发本质上是用 private key 对 hash 值进行加密。

这就意味着,我们的中级 CA 其实可以被无数个 ICA 签名,只不过多个签名就多一个证书,而这些所有的中级 CA 证书,都需要让网站返回给客户端的。因为客户端需要这些中级证书来知道签发的 Root CA,来构建出来 Chain of Trust.

如上例子,网站拿到 R3 签名的证书,在和访客建立 TLS 连接的时候,需要返回:

  1. example.com 网站的证书;
  2. DST Root CA X3 签发的 R3 证书;
  3. ISRG Root X1 签发的 R3 证书;

这样,客户端无论是信任 DST Root CA X3 还是 ISRG Root X1,最后都可以达成信任。

这里有一个事故的例子,就是因为他们的网站找 Let’s Encrypt 签发证书之后,只返回了:

  1. example.com 网站的证书;
  2. DST Root CA X3 签发的 R3 证书;

缺少了一个 R3,而 DST Root CA X3 在 2021 年9月过期了,导致网站不被信任。

话说回来,这部分内容其实没有实际验证,因为我用脚本下载了很多网站的证书链,发现没有一个是使用中级证书来交叉验证的,都是使用 Root CA 来交叉验证。这里提到说,即使缺少了一个中级证书,客户端也能自己找到信任链,然后成功验证。这点我对此表示怀疑,可能真的取决于客户端的行为吧。其实通过验证对客户端来说也是没问题的,因为这些证书本质上是完全合法的,只是因为无法完成 Chain of Trust 而无法完成验证而已。

为什么找了这么多案例,没有一个是用中级证书来做交叉验证的?我认为可能是,如果用 Root CA 去找老 CA 验证,那么客户端的验证过程是没有分叉的一条链;如果是中级证书交叉验证的话,那验证过程就会复杂一些吧。但是这部分没有资料证实,如果读者知道,欢迎赐教。

客户端修改本地 CA 的行为

一般来说,客户端的证书是由软件或者操作系统维护的,但是毕竟证书只是文件而已,客户端的用户可以自己维护这些文件。但是这有什么问题呢?

拿上文的中间人攻击的例子来说,由于只有拥有网站的证书才可以被客户端信任,中间人没有此证书,所以,即不能看到通讯内容,也无法篡改通讯内容,如果伪装自己就是目标网站,也会因为没有合法证书而被识破。

假设有一个比较坏的 CA Root 给某个黑客签发了 google.com 的证书,而客户端信任这个 CA Root,然后黑客伪造了网站,那么客户端的浏览器就会信任此网站。

这在历史上是真实存在的。VeriSign Class 3 Public Primary Certification Authority – G5 就因为错误签发过 google.com 的证书,而被很多操作系统吊销 Root CA(即不在信任)。上周的一个新闻

我之前在阿里巴巴公司工作的时候,公司有一个办公软件叫“阿里郎”,如果不让它在个人手机上安装它自己的证书的话,很多功能无法使用。现在读者可以理解安装的后果了吗?如果安装了它的证书,那么也就信任了它签发的任何证书,这就意味着它作为中间人,可以看到用户手机上的所有流量,包括聊天记录,HTTPS 登陆的用户名密码等等。(当然了,公司声称绝不会收集用户隐私)

MITMProxy 可以用来对 HTTPS 抓包,它的原理也是在你电脑上安装一个证书,然后模拟中间人攻击。你访问 exmaple.com 的时候,MITM 会收到你的请求,然后给你返回 MITM 的证书而不是 exmaple.com 的证书,因为你信任了 MITM 的证书,所以对于客户端来说,就认为自己在访问真正的 example.com,然后在这个连接上发送内容,这个代理可以看到你的明文请求,再转发给真正的目标。这样就实现了对 HTTPS 抓包的效果,经过 MITM 的内容都可以被明文看到。

那有一些应用不想被抓包呢(可能偷偷收集了什么东西而不想被用户看到?),有一种技术,就是在客户端的代码中拒绝信任所有 CA,只信任自己的证书。

Client Certificate Pinning

其实道理很简单,比如有些程序就不使用系统维护的证书,而是自己维护一份信任的 CA 列表,比如 Chrome。

那客户端将信任的列表进一步缩小,缩小到只有信任自己签发的证书(或者自己签发的 CA),就是 Client Certificate Pinning。其实抖音 App 就是这样,我们即使用 MITMProxy 也无法对其抓包,因为无法让它信任 MITM 签发的证书,它只信任自己硬编码的证书。(也许你找到这部分证书存放在哪里然后进行修改,就可以绕过去了)。

网站

最后说到网站了。我们前面说了很多 CA 和客户端在证书方面可能出的问题,但是最经常出问题的部分,还是网站——很多网站(包括本博客,嘻嘻)都经历过的问题:就是证书过期。

网站要确保两个事情:

  1. 确保自己的证书不会过期;
  2. 保护自己的 Private key 不泄漏,因为如果其他人有了 Private key,证书就失去“只有我才能证明是我”的意义了。找 CA 签发证书原则上只提交 public key 让其签名即可,所以,只有网站自己才持有 private key;

记得更新你的证书哦!

第一点看似简单,但是很多网站都出现过类似问题(说明大部分团队……都是草台班子)。隐藏的一点是,所有的证书都有过期时间,即使是 CA Root 和中级 CA,网站要确保证书链的每一环都没有过期。

可能这一点难做的原因,就是证书签出来两年,两年之后……可能大部分人都会记得换新的(如果你在一家大公司工作过,你可能就会理解保证“2年后记得做某件事”实际上有多么困难)。

所以,Let’s Encrypt 就出了一个绝妙的点子:我们签发的所有证书只有 90 天有效期,没有例外!(可见,Let’s Encrypt 可真是伟大的一个组织!)

这样做有两个好处:

  1. Private key 相当于密码一样,每 90 天换一次,更加安全;
  2. 谁会喜欢每 3 个月走遍流程呀?这就鼓励网站使用自动化 renew 的流程,每 60 天换一次,这样也更不容易出问题了。

Private key 保护的问题

Private key 如此重要,因为拿到它的人就可以欺骗所有的客户端——我就是这个网站了。所以保证 key 不泄漏就至关重要了。

但是,万一泄漏了呢?Private key 要部署在直接让客户端访问的服务器上,如果服务器遭到攻击,不就会泄漏 Private key 吗?常在河边走,哪有不湿鞋!

对已签发的证书吊销

读到这里,不知读者是否发现另一个问题:假设网站的证书还剩下 6 个月,然后 private key 泄漏了,开始部署假网站,那么目前为止我们介绍过的方法都无法阻止假网站,因为假网站拿到了证书(证书链可以直接从原网站下载到,然后部署)有了 private key 就可以跟客户端建立 TLS 连接。这个证书是权威 CA 签发的,完全合法。

客户端只能吊销 CA,但是因此吊销 CA 是不现实的,其他没有漏泄 key 的网站也要跟着遭殃吗?

所以我们需要一种机制,对于已签发的证书进行吊销。

现在有两种主流的方式,一种是 CRL,一种是 OCSP

原理上,就是 CA 证书自身带有这个信息,告诉客户端在校验证书的时候,应该去访问这个 URL 列表,查看自己要验证的证书是否在吊销列表中,如果在,就不要信任。

CRL

x509v3 extensions 中的信息,可以看到 CRL 和 OCSP 的地址

以 CRL 为例,我们可以使用下面这个命令,从上文下载到的 digicert 证书中拿到 CRL 的地址:

然后去这个 CRL 地址下载下来内容(是 DER 格式),用 openssl 来解开 DER 格式,就可以看到这个 CA 吊销过的一些证书:

通过 CA 携带的 URL 可以看到 digicert revoke 的证书

OCSP 也是类似的原理。

那么这样做会不会有什么问题呢?客户端要验证这个证书,还要去请求一次这个 URL 验证证书的合法性,显然,这会带来几个问题(用 OSCP 来举例):

  1. 网站本身的性能会下降,因为多出来一次请求 CA 的时间;CA 的 OCSP 服务器会成为访问的热点,可能被客户端过载;
  2. 隐私问题,CA 就会知道客户端访问了哪一些域名。因为这个问题,Let’s Encrypt 就在 2024年7月决定终止支持 OCSP 了
  3. 潜在的安全问题:假设现在 CA OCSP 服务挂掉了,客户端有两个选择:
    • 选择忽略验证,继续信任目标网站——这样的话就失去 OCSP 的意义了,已被吊销证书的持有者,只要想办法打挂 CA 的 OCSP 服务,或者(如果有权限的话)block 掉客户端对 OCSP 的访问,就可以让自己的证书信任;
    • 选择不相信目标网站——这会因为 CA 的问题造成对目标网站的不可用,对目标网站来说是无法接受的;

OCSP Stapling

OCSP Stapling 可以解决以上问题。它的核心原理是:

  1. 网站定期去访问 CA 的 OCSP 服务,确认自己的证书是没有被吊销的,拿到 OCSP Response;
  2. 客户端访问网站的时候,网站连同证书一起出示 OCSP Response,证明自己的证书是没有被吊销的;

这样就没有了客户端和 CA 之间的依赖,就解决了以上问题。

等等,那如果网站伪造 OCSP Response 呢?

这是不可以伪造的,因为 OCSP Response 是经过 CA 签名的,客户端要验证这个签名,证明 OCSP Response 确实是 CA 确认的。

可以使用如下命令查看到 OCSP Response:

以下是一个 OCSP Response 内容的示例:

如此,如果客户端收到了网站发送的 OCSP Response,就直接进行验证即可;如果没有收到,就自己查询 CA,如果收到了但是验证没有通过,就直接停止连接,不信任网站。唯一的问题就是证书吊销之后会有一段延迟,OCSP Response 的时间过期才行。但是是可以接受的。

HTTP Public Key Pinning

网站也能做点什么,有一个叫做 HPKP 的技术,就是客户端在访问网站的时候,网站返回的 HTTP 响应中,包含一个叫做 Public-Key-Pins 的 Header,这个 key 就是证书的 Public key hash。哪一个证书呢?可以直接 pin 此网站的证书,亦可以是中级证书,或者某一个 CA 的 Root 证书。一旦返回如此的 HTTP 响应了,就是告诉客户端,你应该只信任此证书,(如果 pin 的是 CA 的 Root 证书的话,就是告诉客户端只信任此 CA 签发给我的网站的证书),这样,就可以避免其他人签发出来假冒伪劣的证书了。

比如我们的 super-bank.com 和某 CA 建立了强烈的信任关系,但是 super-bank.com 作为这么重要的一个网站,又担心其他 CA 乱签发出来 super-bank.com 的证书,super-bank.com 就可以在客户端访问他的时候,pin 这个 CA 的 Root。意在告诉客户端,我这个网站呀,只会用 甲CA 签发证书,一旦有别的 CA 签发出来我这个网站的证书呀,即使你信任这个 CA,也一定是假冒伪劣的!

那假如网站真的要更换证书呢?岂不是客户端也不会信任了?所以如果要用 HPKP,必须要有一个 backup key,如果没有 backup key,HPKP 不会生效。

Certificate Transparency

Certificate Transparency, 翻译过来叫做“证书透明化”,这部分需要客户端、CA、网站一起才能支持,所以放到最后来讨论。

它要解决的问题是什么?

就是 CA 错误签发其他网站证书的问题。有一些 CA 内部管理其实是及其混乱的。比如,这里有篇文章,讲的是这位作者如何拿到了 github.io 等域名的证书的,这个证书就是从沃通拿到的,可见沃通存在的问题很多,后来被 Mozilla 和 Google 移除信任。沃通还提出过申诉(还有脸申诉,你干过啥自己没数吗?),称只服务于中国区的用户(好嘛,这不就是说“中国人只坑中国人”?),最后当然是被驳回了,因为我们知道,一个不被信任的 CA 是无法控制作用范围的,管理不当会对全世界的网站和用户造成安全问题。(所以沃通你到底知道不知道自己在做什么?)

为了防止类似的事情发生,Google 牵头发起了 Certificate Transparency. 要解决的就是 CA 乱签证书的问题。如果要详细了解 CT 的话,他们的官方网站解释的太清楚了。

官网上的原理图

这我按照自己的理解解释一下,三方分别要做的事情:

  1. CA 在签发证书的时候,必须将签发的证书放到 CT 数据库中,CT 会给证书加 SCT;CA 将签名的证书发回给网站,这个证书是带有 SCT 的;
  2. 客户端访问网站时候,只有证书带有 SCT 才会信任;这样,就保证了所有客户端信任的证书,都在 SC 数据库里面有记录;
  3. 网站可以监控 SC 数据库,关注是否有 CA 签发了自己不知情的证书;

如此,就没有 CA 可以偷偷签发某一个网站的证书,被客户端信任而不被网站知道了。


啊,终于写完了。我的酒也喝完了。本文的内容我都尽量自己验证尝试过了,但是不能保证完全正确,如果读者中有专家发现其中错误,欢迎不吝赐教。本来标题想起一个类似英文的 All I Know About Certificates, 但是发现好像没有对应的比较顺口的中文标题。就写了个《有关 TLS/SSL 证书的一切》,其实只是我知道的一切而已,请见谅。

2023年9月2日更新:补充了 OCSP Stapling 和 Certificate Transparency 的内容。

2023年9月2日更新:文章发出来之后,网友贴了一些他们写的博客,也非常好: