去远航

之前觉得像泰坦尼克号这样的游轮旅行是距离普通人很遥远的一件事,甚至觉得这种东西不存在了,因为飞机快太多了,水上交通只有轮渡和货轮才有存在的必要。后来听说还有游轮这种东西,但是目的已经不是交通了,而是在船上游玩。

相比于其他旅行,邮轮之旅最好的地方就是几乎不用做攻略,不用做计划,反正一直都在船上,也不需要预定住宿,不需要交通。喜欢什么玩什么,喜欢什么吃什么。非常适合带父母旅行,或者带小朋友来玩。所有的项目都是现场排队,唯一要预定的是丝绸之路演出,但事实证明,不预定也 ok 的,无非入场晚一点而已,观看效果也不会太差。整个行程可以不像旅游一样有压力,当作放松之旅就可以啦。

邮轮的模式

就是你只需要支付住宿的钱,其他的都是免费的,包括:游玩项目,吃饭,观看演出,参加 party。简单来说,预定的时候支付了一笔住宿费,后面都不用花钱了。(我在船上就一分钱没花)。当然,上船之后都有收费项目,比如免费的饮料都不含酒精,酒水是要收费的。游玩项目免费的只是浅尝辄止,想要继续精进(比如冲浪,风洞跳伞)都是要额外付费的。餐厅也有付费餐厅,有的时候付费餐厅的人会去免费餐厅拉客。比较赞的一点是,所有的收费项目都是明码标价,绝对不对在不知不觉中被隐藏小费,体验非常好。

上船之后护照会被收走,发一张房卡,叫做 seapass。航程结束之后不回收,可以留作纪念。在船上很多项目都要刷这张卡,所有的消费也都是刷这张卡消费。有一点神奇的是,在滨城和普吉岛下船的话,也是刷这张卡?! 不需要护照,刷卡绑定照片核对,所以下船的话卡片千万不能丢了。

船上也可以用现金消费,不过用的是美金。(另外船上的紧急电话也是 911,这船就像是美国的飞地)

可以提前下载一个官方的 app,上船之后可以连接船上 wifi。如果购买上网套餐的话,就能上网,如果不购买上网套餐,相当于在一个局域网,只能使用 app:能够看每天的项目,可以在 app 内和船上的朋友聊天,可以看 shiptime (航程如果跨越时区,手机的自动调整在海上是不准确的),可以 reserve 一些项目,也可以看自己的消费账单。

预定方式和价格

官网直接预定即可,即使有其他做旅游的平台和公司,其实也都是在这里订的,然后收你一些手续费。有意向要去玩的话可以多刷一刷他们的网站,不定时会有折扣。房间有不同类型:无窗,有遮挡窗,阳台房,有遮挡阳台房等。我们定的阳台房,阳台房价格一晚1000人民币(每人)算是不错了的,刷到的话可以下手。

正常一间房间就是住两个人,如果是1人去玩的话,价格几乎跟2人一样。带小朋友的话,官网也经常能刷到免费带小朋友的活动。

我们这一次坐的船是海洋光谱号(Spectrum of the Seas)。皇家加勒比是一个比较大的邮轮公司,旗下的邮轮游很多,不同的城市有不同的船和航线,有的时候船也会调往其他地方。海洋光谱号去年和今年(2014年)三月前都是在东南亚的航线,但是3月份就会去上海了,所以官方上可以看到新加坡出发的,从3月开始就没有了。上海的朋友就可以订到海洋光谱号了。也会有一条(唯一一次)从新加坡到上海的单向航线。

选择房间

船头和船尾会晃动的厉害一些,怕晕船尽量选低楼层,船中间的房间。但其实船两头正常人也能接受,船很大,很难晕船的。99% 的时间都是跟陆地一样。

6层和13层会比较吵,因为餐厅,剧院,Party 在4、5层,13层以上是活动区。

房间会有一张沙发,沙发拉出来下面一层也可以是一张床,所以一个房间可以住下四个人。有需要的话可以让客房服务铺好这张“床”。4个人的话也可以订两个房间,然后阳台可以打通(都是组合的钢板,可以拆),也比较舒适。

我感觉船晚上会加速,晃动的厉害一些。有一个小插曲,有一天晚上我总觉得有一些噪音随机出现,找了半天没找到是在哪里发出来的。最后实在受不了,就去前台问了一下。前台说是衣橱里面的衣架,刚刚来了一个人问一样的问题,最后发现是衣架晃动的声音。我回去看了一下,果然是。

吃饭

船上餐厅有收费餐厅和付费。付费的我都没去过,所以不写了。

免费餐厅主要有两种:Main Dining Room 和帆船餐厅。

  • Main Dinning Room 是西餐,仪式感非常强,整套的西餐刀叉,每次进去服务员都会帮女士拉出座椅,菜单也是西式的前菜+主菜+甜品的风格,会吃到一些自助餐厅吃不到的东西。缺点是上菜速度有些慢,如果赶时间看演出的话最好不要去。
  • 帆船餐厅就是自助了,品质相当于景区 Resort 酒店的自助水平,还是不错的,虽然是算不上惊艳,但是很干净,进去之前每个人都要洗手,餐厅很注意卫生,比如,禁止用使用过的杯子二次接水。缺点是有些游客素质差,不排队,没礼貌。

其他还有一些小餐厅,泳池附近有戏水餐厅,有冰激凌站,热狗车,都是免费的。早餐也可以叫 Room Service,在阳台上看着大海吃早餐,也是免费的。

所有的服务员都非常友好,简直是海底捞级别的服务。最后一天每一个餐厅的服务员和厨师都出来跳舞,气氛很赞。

演出

每晚都有演出,这些演出超出我的预期了,非常精彩,有歌舞,杂技,魔术,二人滑稽戏等等。建议观看!一般每个演出在同一天有两场,可以安排下吃饭时间,不要错过。

Party

晚上有各种 party,青少年 party,单身 party,虚拟烟花 Party 等等。

我觉得最有意思的是一个 Silence Party。现场有dj,但是没音响,大家都戴耳机,耳机有一圈 LED 颜色,表示当前的频道的颜色:有蓝绿两个音乐频道,可以自己切换,耳机颜色不同的人可能跳不到一块去,而且自己看不到自己耳机的颜色。有的时候会觉得自己格格不入。关掉耳机,感觉大家都和僵尸一样。

除了 Party,可以留意 app 上提示的当晚服装建议,有正装夜之类的。但是我们的船每天都是 casual.

上网

前面提到过如果买了上网套餐就可以上网了。使用的网络是 starlink,速度测试大约 2MiB/s,但是非常稳定,体验很好,视频通话,看 youtube,Netflix 都是足够的。

上网认证的步骤做的体验很好,首次登陆需要创建一个 wifi 账号,然后后续用这个账号登陆。我们买的是每人1个设备套餐,登陆第二个设备,前一个设备会提示你要不要 logout,wifi 认证成功率 100%,一次都不掉链子。

有一种作弊手段是,用一个路由器连接 wifi,然后将其共享给所有的人用。安卓手机就可以做到这种效果。不过我没用过,看网上这样说可以。

The Key

类似于特权卡吧,买了的话,Seapass 会带有一个 key 的标志。包括的权益主要有:

  • 上网套餐(1设备);
  • 快速登船和下船,包括在目的地下船,比较实用;
  • 上船当天额外一顿午餐;
  • 餐厅固定座位;
  • 演出专属位置:演出前 key 会员先进入,演出前 15min 所有人都可以进入。不顾实际体验是都不管,所以这条几乎没用。

核心卖点就是上网套餐了。在约定游轮之后,邮箱会经常收到折扣信息,我是刷到 35% 入手的,只比购买上网套餐贵了一点点。需要注意的是如果购买的话,必须整个房间一起购买,所以有人认为单纯需要上网的话,The key 比作弊共享的方式贵太多了。

在目的地下船

前面提到过下船不需要护照,只需要 seapass 卡就可以了。不在高峰时间下船,速度非常快,几乎10-20分钟就能到目的地。

下船方式分成两种:

  • Dock: 船会停在码头上,直接出船的 Gateway 就可以从码头出去,没有海关检查,速度很快;
  • Tender: 船不会靠近码头,会有小船从游轮将游客接送到码头。

我们的行程是包括滨城和普吉岛,滨城是 Dock,靠近 George Town,普吉岛是 Tiner,靠近巴东海滩。都是在景区,所以出去转一圈非常方便。

下船的话要注意船上的规定,回来要过安检的,比如酒水就可能不让带上船,买了的话会收走,行程结束归还。

另外,如果去过了目的地,不下船也是一个很好的选择,一个是时间短,这么短也玩不了多少内容。另一个是在下船时间,船上人比较少,可以少排队体验一些项目。

游玩项目

  • 模拟跳伞:就是在风洞里面飞行,第一次体验,挺好玩的。免费体验是 1分钟的课程。
  • 甲板冲浪:分成两种,趴着到跪起来的,和站的的,因为用的不一样的板子,很刺激。但是排队的地方太晒了,注意防晒。
  • 碰碰车:很好玩,有时候很少人排队,可以经常去看看,人少就上。
  • 射箭:个人也比较喜欢。后面人少,也可以尽情玩。
  • 乒乓球:公司就有,所以没啥兴趣。
  • 篮球:也没啥兴趣;
  • 体感游戏机:小朋友很喜欢,我玩的时候有个小孩在指导我,我每次路过都看到这小孩在玩这个,感觉可能是一个 NPC。
  • 儿童游戏厅:不是投币的,是直接刷房卡。
  • 露天泳池
  • 室内日光浴场
  • 泡泡浴
  • 北极星(可以看作是只有一个舱的魔天伦):我们想定的时候系统坏了,可惜了,没体验过。
  • 南极球(我们去的时候关闭了)
  • 高层透明玻璃的走道:惊险又刺激。
  • 以及到处都有的躺椅:躺一躺也是不错的,中间还有露天电影,晚上放电影,白天放可爱猫咪的视频。
  • 赌场:建议出海不要涉赌,我们去剧院的路上要穿过赌场,拍了一些照片。有很多中年人大叔大妈在里面玩。机器的玩法居然也是刷 seapass 卡就可以下注,我觉得这可太恐怖了,没有花钱的感觉,说不定不知不觉就输了一大笔。

~~贴图时间~~

碰碰车欢乐多
甲板冲浪
整整齐齐的餐厅
学习的叠餐巾,跌的是蜡烛
学习叠毛巾,叠的是北极熊
主餐厅的牛排
船头的日光浴场
晚上的船上泳池
演出
从滨城码头看海洋光谱号
滨城,著名的自行车
房间墙壁都是钢铁的,门也是,所以可以用各种磁铁。
演出
Silence party,每个人都戴着耳机
从船上看滨城码头
模拟跳伞
帆船自助餐厅
在阳台上吃早餐,这天到了普吉岛
Costa Serena 号,这艘游轮和我们一起停在了滨城和普吉岛。看起来比海洋光谱号小很多。这是他们的官网
普吉岛的海滩,上次来普吉岛正好是一年前了,可惜这篇游记躺在草稿箱一年还没有写完
从普吉岛的 tender 看海洋光谱号
船的顶楼景色
船上的日落
晚上的船
船上顶层的跑道
回到了新加坡

其他网友写的:

 

站立提醒软件推荐:stretchly

之前在这篇博客《沉浸式工作》推荐过一个站立办公的软件,定时提醒。它好的地方是可以在锁定屏幕的时候认为你正在休息,但是有一个很让我苦恼的问题:就是它的提醒依赖通知,容易被忽略。

尝试了其他几款软件之后,我现在觉得用的 stretchly 非常好,推荐一下。从名字可以看出来,它的本意不是提醒站立,而是定时提醒你休息,伸伸懒腰,眼睛离开屏幕。但是意思差不多。

它工作的方式是用一个页面盖住当前的屏幕,告诉你该休息了。一些细节处理的非常好。

  • 如果有外接屏幕,两个屏幕都会盖住。
  • 当前在使用的 app 也不会失去焦点,降低了误操作的概率,也不会造成意外的错误。

弹出框也是可以暂时跳过的,也可以设置强制模式让它永远不能跳过。

Stretchly 提醒弹出的界面

Mac 安装方法:

也支持 Windows 和 Linux,更多安装方式见这里官方网站

 

2023 年总结

其实也没有什么好总结的。我若是说说自己今年的流水账,那肯定很无聊。要说技术问题的话,就更无聊的,值得说的事情在博客里应该已经说过了。

那就随便写一写吧,主要记录一下想法,将来回头看看自己每一年的想法,应该也挺有趣的。

感觉最近心境上有了一些变化,比如以前写博客,就是想收获一些名气,运营上会花不少功夫:交换友情链接,要挑选好时间发布,写完了发到很多地方做宣传之类的。现在几乎不会这样做了。现在写博客的目的是什么?我自己都已经想不明白了,可能是单纯想记录一下自己的想法,写完之后也会有一些满足感。

以前也想可以营造某种形象,写出自己漫不经心地解决天大难题的故事,对行业案例、术语侃侃而谈。发现自己到底写不出来这样的文章(倒是读过不少)。有些博客我不喜欢读,是因为字里行间作者不是想分享什么,而是想说明自己有多厉害,知道这么多,或是想说明公司多厉害,这种文章通常知识也不多。比如他们会说:当然对于这个问题我们可以用 XX 解决,但是 XX 是什么?他要假设读者已经知道了,要解释的话就显得水平太低了。

以前会焦虑,现在不会了。以前喜欢强迫自己去学会某一些自己不喜欢的东西,研究一些不感兴趣的技术,细想一下原因,应该是想得到别人的认可,比较在乎别人的想法。现在倒不是很在意了,不懂什么就研究什么。有些对工作和仕途没有什么用的技术,今年也花了很多时间在这上面,比如终端应用啥的。

去年写过:

假如喜欢编程这件事情并且想长久地坚持下去的话,比如 30 年,就会发现有些事情是不重要的,有些事情是重要的。

眼光放长远的话,也就无需焦虑了。慢慢积累,坚持 30 年,很难不成为一个厉害的人吧?

生活上

也是平凡的一年,好像没有什么特别值得说的。

旅行倒是去了不少地方:普吉岛,巴厘岛,去爬了一座火山,去了澳大利亚。算是比较精彩的一年。最后假期居然还剩个 8 天。对了,还完成了新加坡环岛骑行,这可太牛逼了。

工作上

今年的工作大部分是去年工作的延续,每一个季度的 OKR 都没有完成(好像在大公司定制的 OKR 从来没有完成过),感觉 OKR 挺扯淡的,定制 OKR 和实际工作严重脱节:临时插进来的工作又不能不做,SRE 的一部分工作时间也不能自己安排,得看项目的紧急程度。

今年大部分工作都是对已有的项目优化,支持一些公司决定做的事情。

这里面值得一提的是监控系统存储 metrics 随着时间越来越长,索引越来越大了。TSDB 存储时序数据很擅长,但是 metrics 横向的数量过多,就有问题了。集群的索引已经到了 20TiB 级别,查询也还好,可以限制查询范围,没想到出问题的居然是写入链路。写入新 metrics 的时候要在存储节点上先查询要写入的 metrics 是否存在,这些写入前的查询在一些情况下会造成很大的索引查询压力,导致集群雪崩:比如一个存储节点坏掉,所有向这个存储节点在写入的来源都要重新路由到其他的节点进行写入,这时候大家一起进行写入前查询,就足以将其他所有的存储节点都查的干不了任何其他事情,loadavg 升到 500多。写入被停止了,写入来源这时候会将写不下去的监控数据暂时存到本地,等知道 metrics 应该往哪里写了,再将存量 metrics 写回去。可惜的是,一旦存储节点哼哧哼哧把查询完成了,又被大量写入弄崩溃了。这个问题之前没有遇到,跑的好好的,(其他集群和其他公司一般的用法应该遇不到),是因为索引不大问题就不大,20TiB 的索引怎么出来的呢?我们存了1年多的时序数据,历史上的 metrics 都在这个索引里面,就膨胀了。而一般的需求,时序顺序存储1个月就够了。

解决思路略微奇葩,写入前的查询会造成压力,我就拆分了两个存储集群,一个 hot 一个 cold。实时数据写入 hot,只保存 7天数据,每天晚上在低峰期将当前数据移动到 cold,查询的时候两个集群一起查。这样就完美了:

  • hot 集群的索引只有 10G,怎么查都不会出问题,快的很;
  • hot 集群的查询能力也很强了,因为大部分时序数据查询的都是当前数据,hot 集群配置高,实例少,缓存命中率高;
  • cold 集群磁盘大,HDD 盘,成本低,性能低,但是应对 QPS 不到 1 完全够了;
  • 每天的迁移只有在迁移开始的时候会大查询 cold 集群,而且限制并发,不是一起查的,查询结束之后顺序写入性能也贼高;

感觉解决的很完美。

新项目倒是也有一个,做了一个Alert 自动诊断系统,可惜利用率不是很高。

历史长河

经常会忘了哪一年是哪一年,哪一年发生了什么。我印象最深的是 2002 年附近,好像小时候的时间过的很慢,2002 年之后是 2003 年,然后是 2004 年,一直到 2008 年,但是好像 2008 年之后时间变快了,还没熟悉过来新的年份就马上进入到了下一年。有一种 2023 年和 2003 年之间只隔了10年的错觉。

不过我之前发现维基百科有页面记录每一年的大事年表,2023年的简中版本见此

2024,新的一年

先看看去年我写的今年计划:

  • 锻炼身体:上半年还可以,下半年就懒回去了;2024年要继续锻炼身体;
  • 打字训练:算是颇有成果,至少正确的指法了,一年都在刻意用正确的指法打字了,可以盲打所有按键包括符号和数字,甚至连右 Shift 键都能用起来了;
  • 学习:没按照计划学,但是也学了不少东西;

去年的时候感觉自己太活跃了,希望能不写新的项目,下半年还是写了一些,主要是用 textual 这个库做了很多终端工具:

  • flameshow: 一个终端展示火焰图的工具,已经完成;
  • vcron:一个终端计算 crontab 的工具,已经完成;
  • mactop:一个 mac 的类似 htop 的工具,基本完成,但是对 M1 的 macbook 兼容性还有一些问题,没有 M1 的电脑,不方便测试。

新的一年的话,我要认真再学习一下编译原理,最好能实现一个简单直观的、可以定义规则的 DSL。

我的梦想

自从小时候有一次全班同学都要写一下自己的梦想,我就开始思考我的梦想应该是什么。有的看起来就不可能完成,甚至没有努力一下的必要,因为实现的几率太小;有的又太微不足道感觉不值得作为一生的梦想。于是提及这个问题的时候,总是草草找一个比较合适作为答案的来交差。

今年看了很多电影,于是想,看电影真是一件令人快乐的电影,这一辈子要是能看很多电影就好了。那一生能够看多少电影呢?如果一年看 100 部,已经算是比较多的了。那么 50 年也才能看 5000 部!如果真能看 5000 部电影,也算是一件了不起的事情吧。

这样一想,一生看 5000 部电影也是一件不简单的事情:要活 50 年,要身体健康,至少眼睛和耳朵要没问题,智力也要没问题,还得经常有足够的空闲时间……

这也不是一件痛苦的事情。我们谈起梦想,仿佛默认要像苦行僧一样努力很久才能实现。但是何必要这样呢?梦想不能就像一部一部看电影一样呢?看完一部看下一部,每一部都有每一部的乐趣。

实现梦想之后呢?可能有些梦想被实现了之后会让人感到空虚。但是看电影也不会,看完 5000 部电影之后,也不会想:梦想终于实现了,我再也不用看电影了吧。而是找时间去看 5001 部电影。

所以“一生看完 5000 部电影”这个事情作为一个梦想还真是不错。说起来也不至于让人家笑话。

不知道为什么说这个,胡思乱想就写了这么多。其实我是有一个梦想的,只是写出来读者会笑话,就不写了。

说到这里了,就继续说说人生的意义吧。

今年看了一本书叫做 Ask a Philosopher, 有一个问题就是问 The meaning of life.

作者说,他也不知道,但是这不重要。

大意是,假设有一天你意外地发现地球是一个外星人的农场,外星人在地球上放养了很多人,人类的意义就是快速繁殖,吃的白白胖胖,将来有一天给外星人吃掉。这就是人生的意义。

但是知道人生的意义会让我们的生活变好吗?会让我们的精神变好吗?我们应该为了人生的意义——努力地吃和繁殖,然后被外星人吃掉——而去努力吗?显然不是。即使我们的命运就是被外星人吃掉,也不意味着我们应该朝这个方向努力。

我们思考人生的意义,大部分都是低潮的时候。无论人生的意义是什么,它应该指导我们更积极地生活。

既然这样的话,我们应该勇敢地追寻自己热爱的东西吧!而不是寻求人生的意义的真相。

其他的年终总结列表:

  1. 2013年
  2. 2014年
  3. 2015年
  4. 2016年
  5. 2017年
  6. 2018年
  7. 2019年
  8. 2020年
  9. 2021年
  10. 2022年
 

程序的 Metrics 优化——Prometheus 文档缺失的一章

Prometheus 设计的 Pull 模式监控非常优雅:程序开发者只需要做一件事情,暴露出来一个 HTTP 服务,/metrics 返回当前程序的 metrics,就可以了。然后 Prometheus 会定时过来请求 metrics 数据,存储到 TSDB 中。程序只需要关注一件事:暴露 (export) metrics。

如果一开始接触的监控就是 Prometheus,可能觉得这种模式非常自然,符合直觉(就像第一次接触 SCM 就是 Git 会觉得 Git 也很符合直觉一样?)。但是 Prometheus 出现之前,很多公司的做法,是运维人员清洗日志,按照日志特定位置的关键字聚合成 metrics,然后画图,非常麻烦。最大的问题是运维人员会看不懂日志——为了节省空间,很多日志都没有 key,只有分隔符和 value。依稀记得,我的上一家公司为了解决这个问题,还启动了一个项目,叫做“监控代码化”。这和 Prometheus 的想法如出一辙:让开发者来暴露 metrics,因为开发者是最懂程序的。

但问题是,开发者却不是最懂监控的。

我们的监控系统经历过很多次 overload,一般都是伴随着应用发布。开发者不清楚修改 metrics 会对监控系统本身带来多大的压力。我发现就连 Prometheus 的文档也没有专门说明这部分内容。所以这篇博客就尝试介绍一下 metrics 采集量计算的逻辑和优化的方法。

首先,在代码中暴露 metrics 的时候,每一个新的 label value 意味着新的 metrics,比如,下面这三个是三个不同的 metrics。

即使它们的 metric name 相同,也并不意味着什么,这只是给人类看的语法糖。它实际存储起来像下面这样:

代码中(SDK, 比如 golang SDK)一个 metric实例 可能产生多于一条 metrics,比如 Histogram 会产生 10 个 _bucket,加上一个 _sum 和 一个 _count

添加 Label 的情况

如果添加一个新的 label,新的 label 会和原来的 metrics 做笛卡尔积。比如,新添加的 label 是 HTTP 的 status code,添加之后可能会变成下面这样:

这里要格外注意,一个新的 label 不意味着成倍的 metrics,而是笛卡尔积。或者这么想,添加的 每一个 label 都可能将原来的 metrics 拆成多个 metrics。

例子1: 假设原来有 10 条 metrics,新添加了一个 label 叫做机器的 IP,那么新的 metrics 还是 10 条,因为每一个 metrics 在采集的时候就会带有 instance label;

例子2: 假设原来有 10 条 metrics,metrics 原来有 pathquery_params (HTTP协议的),然后新添加一个 label 叫做 full_url 含有 path 和 query_params, 那么新的 metrics 还是 10 条,因为每一个原有的 metric 和新的 metric 都是一一对应的;

例子3: 假设原来有 10 条 metrics,新添加的一个 label 叫做 client port,那么新的 metrics 可能会是 10 * 65535 条(先不考虑 reserve port). 因为 port 有 65535 个不同的的值,并且于原来的 label 都没有关系,所以每一个 label 都被拆成了 65535 个。

这就告诉我们:那些无法枚举,程序不能控制的 lebels,千万不能加入到 metrics 中。

Label 必须是可以枚举的

不可枚举的 metrics 比如,url,IP,等等。或者客户端传递过来的参数。即使参数可能是一个有限集,但是 client 如果有办法不按照这个集合来传,换句话说,你控制不了,那就不能放到 label 中去。否则,会导致 TSDB 的索引快速膨胀,让查询速度急剧下降。

TSDB 是专门存储时序数据的数据库,一条 metrics 在历史时间的数据,它能很快找出来。但是如果要找 100万条 metrics,查询就比较慢了。我们要尽量减少一次查询涉及的 metrics 数量。

优化 Metrics 输出

有了上面的背景, 我们在添加 metrics 的时候,就可以考虑怎么即能够满足监控需求,又可以添加最少量的 metrics 完成需求。

比如我们在统计延迟的时候,Histogram 一下子就是 12 条 metrics。假设我们想在像看到 cluster 维度(有10个)的延迟,也想监控 API 维度(有20个)的延迟,但是我们不需要监控每一个 API 在每一个 cluster 的延迟。

很多人会这么写:request_duration_bucket{cluster='A', API='user', le='10'},这样的话,总数就是 cluster * API * 10 (bucket) = 10 * 20 * 10 = 2000 个 metrics。

但是如果我们暴露两个 metrics:

  • request_duration_cluster_bucket{cluster='A', le='10'}
  • request_duration_api_bucket{api='user', le='10'}

那么总数就是 cluster * 10 bucket + user * 10 bucket = 200 + 100 = 300 个 metrics,少了一个数量级。

另一种情况是,比如在七层网关上的 metrics,我们需要:nginx_instance, status_code, upstream_ip, path, 数据可能达到千万级别,所有的这些 label 都是需要的,用来定位问题。

我们有两个面板:

  1. 一个是汇总查询,只查询所有的实例上面的 status_code,用于发现问题;
  2. 一个是详情查询,需要查询 instance, upstream, path 等等,用于在发现问题之后定位问题;

将这些 label 全都暴露出来,就可以满足两个需求了。但是现在出来一个问题,就是在做需求 1 的时候,由于 metrics 的总量太大了,监控上将他们聚合起来的成本很高,查询速度很慢。

这时候,添加一个新的 metrics 就可以解决问题:requests_by_code_total{status_code="200"}。它只有一个 label,所以 metrics 的量很少,查询速度很快。

这样也可以节省成本,虽然有些反直觉:添加 metrics 也可以减少成本。因为成本不仅仅是存储成本,还有查询计算的成本。查询成本也很可观,因为这个 metrics 通常可能要每 10s 就查询一次,用来做 alerting,判断系统是否正常。

我觉得如果 metrics 经过精心的设计,可以解决大部分的规模问题。但是有时候程序的代码不是我们能控制的,下面还有一些方法可以对付一下。如果代码改不了,可以考虑下下面的方法。

Recording rules 和多级 Recording rules

对于 metrics 太多导致查询起来太慢,可以用 Prometheus 的 recording rules 的方案。核心思想是,既然用户查询的太慢了,那么我就让一个聚合程序每 10s 查询一次,然后将查询结果写入到 TSDB 中,这样,用户在使用这个 metrics 的时候,查询结果已经存在了,必然很快。

Prometheus recording rules 工作原理

有时候 metrics 太多以至于查询根本就查不出来(recording rules 本质上也是一个查询)。还有一种方法是多级聚合。比如第一级分成 5份,每一份保存好聚合结果之后,第二级再跑一次 L2 聚合。

Prometheus recording rules 多级工作原理

这样做的缺点,一个是时效性低,数据有延迟。另一个就是查询造成的压力不减反增——每次查询成本都很高,以前是查询慢,现在查询快了,但是每分钟即使没有人用也要跑查询。

这个方案直觉上就有一个缺点:数据的流向,是首先抓回来存到存储,然后从存储查询出来,计算,然后再写到存储。数据多了一次出来再进去。那我们能够抓到 metrics,计算聚合,然后存储吗?

Streaming Aggregation

我觉得这个想法是合理的,这样数据只进入到了存储一次。

但是 Prometheus 没有这样的功能。我发现 Uber 的 m3db 有,他在聚合 metrics 方面就是在采集的时候计算的。这样开发者暴露再垃圾的 metrics 我们都可以得到想要的聚合了。

m3db 的问题是,它的文档是在太乱了,文档组织毫无逻辑,光运行起来就废了好大的功夫。跑起来之后发现性能也很差,原来的一个 vmagent 可以抓取的 targets,用上 m3db 之后连 1/6 都处理不了。这文档也没有让人想要贡献的欲望,索性作罢。

VictoraMetrics 去年支持了 stream aggregation。支持让 vmagent (VictoriaMetrics 系统中负责采集 metrics 的组件)在采集 metrics 的时候进行聚合。我们在一些场景下用了这个功能,资源使用很少,效果不错。

VictoriaMetrics 的 stream aggregation

它有一些缺点:只支持有限的聚合函数,不可以叠加。比如,用了 increase 之后就不可以再用 sum 了。但是 increase 又不算是聚合,原来有多少用了 increase 就还有多少。这样的话,这个功能就很鸡肋了。我发 issue 咨询了下,意外地得知,VictoriaMetrics 里面的 sum 居然是处理了处理了 counter reset 的,所以说,可以直接用 sum 聚合,sum then rate 也是没有问题!他们提的另一个方案是部署多个 vmagent,每一个都跑一个 stream aggregation:vmagent1 -> vmagent2 -> … -> vmagentN

另一个缺点是,每一个 vmagent 都会得到一份聚合之后的数据,但是问题不大,已经将 metrics 减少很多倍了。在配置的时候,要给每一个 vmagent 的 stream aggregation 规则都加上一个 vmagent 编号的 label,否则的话,多个 vmagent 可能得到完全相同的 metrics 名字,这样数据就不对了。

 

四层负载均衡漫谈

对于四层负载均衡,我一直只是作为一个使用者,把它当作一个简单 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 技术的文章:

四层负载均衡系列文章