相比于其他旅行,邮轮之旅最好的地方就是几乎不用做攻略,不用做计划,反正一直都在船上,也不需要预定住宿,不需要交通。喜欢什么玩什么,喜欢什么吃什么。非常适合带父母旅行,或者带小朋友来玩。所有的项目都是现场排队,唯一要预定的是丝绸之路演出,但事实证明,不预定也 ok 的,无非入场晚一点而已,观看效果也不会太差。整个行程可以不像旅游一样有压力,当作放松之旅就可以啦。
我们这一次坐的船是海洋光谱号(Spectrum of the Seas)。皇家加勒比是一个比较大的邮轮公司,旗下的邮轮游很多,不同的城市有不同的船和航线,有的时候船也会调往其他地方。海洋光谱号去年和今年(2014年)三月前都是在东南亚的航线,但是3月份就会去上海了,所以官方上可以看到新加坡出发的,从3月开始就没有了。上海的朋友就可以订到海洋光谱号了。也会有一条(唯一一次)从新加坡到上海的单向航线。
以前也想可以营造某种形象,写出自己漫不经心地解决天大难题的故事,对行业案例、术语侃侃而谈。发现自己到底写不出来这样的文章(倒是读过不少)。有些博客我不喜欢读,是因为字里行间作者不是想分享什么,而是想说明自己有多厉害,知道这么多,或是想说明公司多厉害,这种文章通常知识也不多。比如他们会说:当然对于这个问题我们可以用 XX 解决,但是 XX 是什么?他要假设读者已经知道了,要解释的话就显得水平太低了。
比如,我一直以为这是一个它是一个完整的 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 代理。
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。扩容的时候,就将一部分流量分给新扩容出来的实例,缩容的时候,就提前不会再将流量调度到即将缩容的实例上去,非常完美。
负载均衡器
但是很显然,只是加了一层,并没有解决原先存在的问题:负载均衡器自身的扩容和缩容如何解决呢?
负载均衡器
对于负载均衡器,它要解决这几个问题:
能够发现后端的 Real Server,比如有新的实例上线之后,新实例要开始从负载均衡器那里收到流量;
需要解决自身的服务发现问题。因为负载均衡相当于在客户端和 Real Server 之间加了一层,要通过某种方式,告诉客户端负载均衡的地址。解决 Load Balancer 本身,扩容和缩容的问题(当然了,可以通过在负载均衡器前面再加负载均衡器来解决,后面会举这样的例子);
性能一般比 Real Server 要高,这样,才能用更少的机器去承担和后面 Real Server 一样的流量。当然了,因为负载均衡器的逻辑一般比 Real Server 要少,所以这一点现实里不算难;
具一例子,Nginx 是一个七层的反向代理,可以认为是一个七层的负载均衡器。它解决以上三个问题:
通过 Nginx 配置文件中的 upstream 可以配置后端的 Real Server 的地址,也可以通过 openresty 自制其他服务发现的机制;
自身服务发现可以通过在上述,在 Nginx 前面添加四层负载均衡器来解决。这样,对于四层负载均衡来说,Nginx 就相当于是一个 “Real Server” 了;也可以通过将 Nginx 的 IP 配置在 DNS 域名 A 记录上,这样,相当于让客户端通过 DNS 能够发现 7 层代理的地址;
这里补充一个三层网络的知识点:我们知道 TCP 是基于 IP 的,意味着 TCP 要靠 IP 协议,经过一系列的路由,将 TCP 数据从源 IP 送到正确的目标 IP 上。路由器之间,一般用动态路由协议来交换彼此之间的路由信息,而可达路径往往不止一条。这时,路由器只会选择一条最优路径来使用,而且是永远只用这一个路径,而不会用其他的路径,其他路径是空闲的。
比如下图:
存在多条可达路径的情况
数据从 A 发往 B(假设用的 BGP 路由协议),所有的数据都会通过 A -> C -> B 发过去,没有数据会走到 D 和 E 上。因为 A -> C -> B 跳数最少,是最优路径。
那 D 和 E 不是就浪费掉了吗?确实是的,有一些违反直觉。这样做一个很大的原因是保证包到达的顺序和发送出去的顺序一致,所以使用单一最优线路发送。
区别是,原来要将数据从 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 登陆。
现在由于新增了一个 LB,导致路由器将 Client IP 发给 VIP 的包转发到了 LB C 上(原来是在 LB B 上);
要求客户端 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 连接的数据不受添加节点的影响。
我们需要一个外部的 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)。
一个设备在收到数据进行回应的时候,回复给谁呢?它会将 IP 的来源作为回应的目的 IP,将 IP 包的目的 IP(就是自己)作为来源 IP,即发给我的 IP 包,我把它的来源 IP 和目的 IP 调换,然后填充响应发送回去。
一个设备收到一个包的时候,它看这个包的 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 回复给客户端。
Real Server 在回复的时候,虽然 DIP 还是客户端 IP,但是目的 MAC 写的确是 LB 的 MAC 地址。交换机一看,在同一个 LAN 下,直接将这个包交给了 LB 实例。这时候,LB 就可以修改来源 IP 为自己的 VIP,转发给客户端了。
这么做有一个架构依赖:Real Server 和 LB 必须在同一个子网下,不能走三层路由。因为 Real Server 想把这个包发送给 LB,包的 MAC 地址是对的,是 LB 的地址,但是三层的目的地址是错误的,不是 LB 的地址,而是 Real Server 的地址。一旦走三层路由,就露馅了,路由器会将这个包转发给客户端,不经过 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 直接将请求回复给客户端。
我们按照上面的知识来推理一下:
Client 要能正确处理连接,那么它收到的包必定是:SIP: LB VIP, DIP: Client IP
那代表 Server 发出来的 IP 包必定是:SIP: LB VIP, DIP: Client IP
根据原则5,那么 Server 收到的包必定是:SIP: Client IP, DIP: LB VIP
所以,完整的图如下:
这个架构看起来很奇怪,Is it even possible?
让我们来一个一个地这里面的问题,看看能否将这些问题都解决掉:
LB 到 Real Server 这里,怎么能把 DIP: LB VIP 这个包正确发给 Server 呢?显然 Server 上要配置这个 VIP,这样,Server 收到这个包才不会丢弃,有这个 VIP 才能处理 Dest IP 是 VIP 的包(原则6)。那现在 LB 上面有这个 VIP,Server 上也有这个 VIP,;
Server 有了 VIP,LB 也有这个 VIP,怎么确定 LB 把这个包发出去之后,收到包的是 Server,而不是另一个 LB 呢?经过上面 DNAT 的讨论,我们已经可以熟练地使用二层转发了,LB 转发给后端的时候,直接指定 Server 的 MAC 地址即可。即 LB 对 Server 的服务发现要使用 MAC 地址而不是 IP 地址;二层转发就带来一个缺点:LB 和 Sever 必须部署在同一个 LAN。
客户端请求进入到 IDC 的时候,LB 和 Server 都有相同的 VIP,怎么保证一定是 LB 先收到这个请求处理,而不是 Server 收到这个请求处理?(如果是 Server 收到的话,那 LB 相当于不存在了)。让 Server 完全忽略 ARP 请求即可,即其他的机器都不知道 Server 上这个 VIP 存在,甚至 LB 也不知道,只有 Server 自己知道它有这个 VIP。
核心问题是,它的网络处理都是用了 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,不需要处理滑动窗口。