SRE&Devops 每周分享 Issue #6 Closing

Hi,这是这一系列的最后一篇内容,之后不会再每周提供定时发布。遇到有意思的文章我会分享在 Twitter 上,这种方式更加实时,也比较有互动性。这是我的 Twitter: laixintao 。以下是本期内容。

 

Ubuntu 发布的 《上云白皮书》

现在可选的云服务多种多样,公有云、私有云、混合云,还有不同的厂商提供了不同的服务,从 Bare mental Server 到 VPS 到容器服务。提供 Infrastructure 的经常还伴随着提供上层的服务。Ubuntu 发布的这个 PDF 介绍了各种云的概念,推荐一下,了解之后可以针对自己的需求和规模选择最合适的场景。

Envoy Proxy at Reddit

Reddit 的用户越来越多,服务规模也越来越大。Reddit 使用 Envoy 作为四层/七层负载均衡服务器,本文介绍了他们的方案。

Build a serverless Twitter reader using AWS Fargate

AWS 的一篇 serverless 手把手教程。

The Definitive PHP 5.6, 7.0, 7.1, 7.2 & 7.3 Benchmarks (2019)

最新版本的 PHP 的一些性能测试。

Using Golang to Build Microservices at The Economist: A Retrospective

经济学人使用 Go 语言来构建微服务的历史。

Why on earth did we choose Jenkins for 2019?

介绍了 Jenkins 的一些优点。

新闻:Red Hat 将 etcd 捐赠给了 CNAB

HOW DASHBOARDS ARE CHANGING HUMAN BEHAVIOR IN DEVOPS

Dashboard 怎么用,怎么设计,这是一个哲学。

Our learnings from adopting GraphQL

Netflix 介绍的使用 GraphQL 的经验。

rendora/rendora

给爬虫渲染出页面的一个项目。(使用无界面 Chrome 浏览器)

bloomberg/goldpinger

Debug k8s 的一个工具。

 

SRE&Devops 每周分享 Issue #5

这个周工作比较忙,分享的东西不多。

 

The headers we don’t want

介绍了几类被误用和滥用的 Header。有些不错的干货的,让我惊讶的是很明显用错 Header 的网站竟然有这么多,还长达 20 多年。不过有些观点我不同意本文,比如 Response 放上 Server,我觉得是有用的,比如对互联网上的统计,测试收集各种服务器的性能等。

Getting started with Jenkins X

Jenkins Kubernetes plugin 已经可以让 Jenkins 跑在 k8s 上了,Jenkins X 是持续集成 k8s 应用的一个方案,并且 Jenkins 本身要跑在 k8s 上。

​GitOps – Operations by Pull Request

Git 可以追踪所有的变更历史,可以轻松的回滚,使用 PullRequest 机制可以互相 Review。所以我一直想,如果用 Git 来做配置中心,或者将所有的线上操作通过 Git 来追踪(我上一家公司使用 Salt 就是这么做的),可以省很多事。

原来已经有人将这个想法实践了。这篇文章介绍了 Waveworks 基于 Git 的运维。我觉得本文能这么做最重要的一点是:运维工具必须是声明式的,表达一个最终状态,像 Ansible 那样。

这样可以使所有的操作都透明化,最终达到的一个效果是,文中提到他们有一次不小心将 AWS 上所有的节点都删除了,只用了仅仅 45 分钟,就恢复了回来。

deislabs/cnab-spec

CNAB:一份开源的、独立于云平台的规范,包括如何打包、运行分布式的应用。

DOCKER APP AND CNAB

Docker App 是遵循 CNAB 标注标准的工具,可以 build 符合 CNAB 标准的 bundle,也可以用来运行、升级 Bundle。

Announcing GitLab Serverless

Gitlab 宣布将在 12月22日上线 Serverless 服务。

 

Nginx(ngx_lua) 过滤 10w 个 User ID

今天的工作太刺激了,一天下来正好解决了一个有意思的问题。晚上来记录一下。

上次解决了当有很大的 HTTP body,在 ngx_lua 里面读不到的情况后,还留下一个解决性能问题。上次提到,我们对于用户的每一个请求,都要根据一个 json 形式的规则,来判断怎么样路由这个用户。为了让读者更明白这个问题,举几个例子:

  1. 给出一个 10万元素的用户 ID 列表,如果用户的 ID 在这里面,并且请求 URL 是 xx,Cookie 含有 xx,就转发到 Server A
  2. 用户 ID 在列表转发出 10% 的用户到 Server B

此模块我是用 ngx_lua 写的,现在有问题的实现是这样的,将这个规则保存在 ngx.shared_dict 里面。每一个请求过来,我就解析成 lua 的 table,然后判断规则。我的测试环境是一台内网的服务器,单进程开 Nginx,wrk 测试是 1800~1900 request/sec。开启这个模块之后,只有 88 requests/sec,由于每一个请求都要经过这个模块,这样的延迟是无法接受的。

规则是发到每台机器的 Nginx 上,要在 Nginx 所有的 work process 共享一个变量,不知道除了 shared_dict 还有啥方法。其实我想过自己基于 shared_dict 实现保存 table,就是我把 table 打扁平,按照 key-value 放到 shared_dict,但是这项工作想想就挺大的,而且要踩坑才能保证正确性。

今天发了一个邮件到 openresty 社区(这个社区非常活跃和友好!),问了这个问题。mrluanma 和 tokers 回复说可以用 mlcache 。其实我之前也看了一下这个项目,但是没有看完文档,不知道靠不靠谱,既然大家可以说这么用,就去试一试了。

这其实是一个缓存,首先 L1 缓存是每一个 Nginx 进程里面的 Lua vm 会有缓存,如果没有命中,那么第二层缓存就是 ngx.shared_dict ,如果再没有命中,就会调用用户的 callback,也就是所谓的 L3. 由于是一个缓存项目,所以有一些缓存方面的问题,比如 dog pile,此模块都处理好了。我的用法比较特殊,只是拿它来做多个 worker/多个 HTTP requests 的共享数据,所以很多地方没有细看。

新建一个 cache 的 Nginx 代码如下,需要写在 http 里面。

这里要注意 3 个地方:

  1. 因为要调用 set() 方法,我们是主动更新规则的,而不是等他过期。调用 set() 和 update() 要提供 worker 之间通讯的方式。mlcache 实现了通过 shared_dict 来通讯,所以我只要另外申请一个 shared_dict ,然后将这个 shared_dict 设置给 ipc_shm 就好了。
  2. ttl 和 neg_ttl 设置成 0 ,理由同上。
  3. 通过 _G 可以执行全局的变量,这样 lua 就可以直接使用 cache 这个名字了。

然后在设置规则的时候,直接通过 cache 来调用即可:

读取规则也是一样:

今天栽在这里很长时间,文档说第二个参数是 optional 的,我以为就可以不填。然后 set() 就填了两个变量。结果调试半天(Lua 奇葩的变量不够 nil 来补)。后来才明白这个 optional 的意思是你可以填一个 nil 进去,因为未定义的变量就是 nil 啊!难怪呢,我还想 lua 怎么实现的,难不成判断函数调用的参数个数?

另外一个点是 get() 方法要提供一个 callback 函数,L2 没有命中的时候提供就执行这个函数。在我这里,如果 L2 是 nil,那么 L3 也返回 nil 好了。

显示调用 set() 的一个非常重要的点是:一定要通知其他 worker 删除 L1 缓存。不然我们调用 set() 只是更新了一个 worker 的 L1 和 L2。在本文的场景下,worker 的数据不一致导致转发规则不一致是有问题的。这里只要在 set() 的之后调用一下 purge() 之后,通知其他 L1 去删除自己的缓存就可以了。 这里之前写的 purge() 函数的使用是有错误的,purge() 是清除缓存,包括用于 worker 交流的 shm 和 lua-resty-lrucache 的缓存,导致所有 L1 和 L2 miss 然后去 L3 更新,所以开销是比较大的(虽然在我这里,整个缓存=我的一个 table)。

正确的用法是这样的,使用 set() 的话,要多加一步,在 get() 前面调用一下 update() 。从源代码和文档得知,它的工作原理是这样的:set() 内置会广播一条消息,然后更新 L1 缓存(仅自己的 worker)和 L2 缓存。get() 之前 update() 这个调用会队列里面的广播事件,如果有事件的话,就先消费掉事件,没有事件的话,就什么也不做。注意所谓的 update 并不是更新 L2 缓存,而是消费所有的事件的意思。这样就做到一个 worker 更新某个值之后保持和其他 worker 一致了。

下图的第一个 worker 先 set 了一个值,然后通过 shared_dict 广播出去这个值的 name,回调函数是从 L1 删除这个值。其他 worker 蓝色的箭头表示 get() 之前 update() 去检查是否有事件需要处理。

修改之后性能从 88 requests/sec 上升到 300+ requests/sec,所有提升,但还是很慢。平均下来一个请求的延迟增加 3ms 多。

然后我又顺着这 10w userid 进行优化,规则里面这个 uid 是一个很大的 List,所以逻辑上是遍历查找一个用户是不是在列表里面的,O(n) 的效率。主要的耗时点就在这里。我想改成用 Set 结构来存,这样只要 O(1) 复杂度就够了。

问题是,Json 只有 Dict 和 List 两种数据结构,这个回答说的很好:

  1. 编程实现 List 和 Set 互相转换是很简单的
  2. Json 用于数据交换,你不能信任一个数据输入是 Uniq 的

但是我觉得 Json 增加 {"foo", "bar", "banana"} 这种形式好像没有什么不妥。

Anyway,我只能自己实现了,写了一个函数,在 Json 转换成 table 之后,找到我想转换的 Key,将它的 value 从 array 形式的 table(key 是 1 2 3 4 5 …)改成 Set 形式的 table (key 是各个元素,value 为 true),代码如下:

这样,在找的时候,只要看 value 是不是 true 就可以了:

这样改了之后,性能上升到 1800 requests/sec,跟不开启这个模块相比,基本上没有性能损耗了。

 

话说回来,这个问题跟我上一家公司的面试我的时候出的题目很像:给你一个 IP 列表,内存可以随便用,但是查找速度要快,如何看一个 IP 是否在表中?

我当时的方法是,一个 IP 用一个 bit ,bit 位要么是 0 要么是 1,表示此 IP 在或不在。表示世界上所有的 IP(IPv4)需要 2^32 个bit = 536M,将 IP 列表中的 bit 都置为 1,其余为0. 这里的关键是 IP 如何映射成 bit 表,IP 其实是 4 个字节而已,直接用 4 个字节所表示的数字作为 index 就好了。

巧合的是,这个周我正好认识了布隆过滤器。哈,我的想法真先进。

 

使用 ngx_lua (openresty)正确读取 HTTP 请求 body

之前用 ngx_lua(openresty) 写了一个处理 HTTP 请求的程序,今天发现当发送的 HTTP 请求 body 很大的时候,发现老是报错,最后定位到 ngx.req.get_body_data() 这个函数返回 nil ,而不是真正的 body。

于是我去 ngx_lua 的文档看这个函数的文档,发现文档中说有三种情况下,这个函数的返回值会是 nil:

This function returns nil if

  1. the request body has not been read,
  2. the request body has been read into disk temporary files,
  3. or the request body has zero size.

第一条,我是读了文档的,知道要 Nginx 默认是不会读 body 的,要么打开读 body 的开关,要么显示调用一下 read。所以代码中已经调用了 ngx.req.read_body() 了,不会是这个原因。

第三条,也不可能,我通过 curl 发送的,body 肯定是发出去了(可以抓包验证)。

那么基本上就确定是这个 request body 被读到硬盘的临时文件里了。看到这里我猜应该是 Nginx 是将大的 HTTP 请求放到磁盘中而不是放到内存中。搜了一下文档,发现有这个参数:

当请求体的大小大禹 client_body_buffer_size 的时候,Nginx 将会把它存到一个临时文件中,而不是放到内存中。这个值的大小默认是内存页的两倍:32 位系统上是 8k,64位系统上是16k。

OK,问题找到了,现在解决方案有两个:1)调大这个值,我觉得是不合理的,这样会浪费内存。2)可以想办法读到临时文件中的大 body。ngx_lua 提供了配套的方法 ngx_req.get_body_file,注意这只是获得文件,还要在 lua 代码中打开读取文件。

所以最后,处理一个请求且还能正确处理很大 body 的请求的代码是这样的,其中高亮的部分,是核心的逻辑,先尝试从内存中读 body,如果读不到,就去临时文件中读。

我读这个实际的应用场景是,读出 body,按照 json 解析出来当做路由规则(需求是需要动态设置 HTTP 的路由规则,所以我用 ngx_lua 在 nginx 上新开了个端口监控这样的规则)。现在用很大的规则一测试,发现性能下降很厉害。存到文件、再读出文件是一方面。另一方面是 ngx_lua 是为每一个 HTTP 请求开一个 lua 协程处理,不能共享变量,只能通过 lua_shared_dict 来保存持久的变量。但是 lua_shared_dict 的问题是,这个 dict 只支持 “Lua booleans, numbers, strings, or nil”,解析出来的 json 是一个 lua 的 table,不能保存到 lua_shared_dict 里面,我只好保存一个字符串,然后对每一个 HTTP 请求 json decode 这个字符串了。不知道有没有更好的方法。

 

HTTP 长连接

本文介绍 HTTP 长连接的协议、历史,TCP 长连接,和一些客户端、服务器实现。

Disclaimer: 本文所引用的资料都会给出链接,如有疑问应该去原资料验证,如果存在错误请不吝指出。

HTTP/1.0 会为每一个 HTTP 请求都建立 TCP 连接,这样显然是很低效的,所以就有人想在同一个 TCP 连接上发送多个 HTTP 请求,这样就省掉后面 TCP 连接建立的三次握手了。这就是“长连接”,英文里面也有其他的名字:HTTP Persistent Connection,HTTP Keep-Alive,或 HTTP Connection Reuse。

可以用 nc 来测试一下,这里我用 nc 连接到 httpbin.org 的服务器,然后用 tcpdump 抓包看其中的 TCP 连接情况。

用 nc 打开连接后,输入和输出如下。其中高亮的部分是我的输入,其余是服务器的输出:

可以看到我连续发送了两次 HTTP 请求,nc 都没有结束,并且还在等到我的输入,输入 ^c 之后才关闭,断开了连接。

抓包如下:

可以看到两次 HTTP 请求始终在同一个 TCP 连接上,只不过中间有很多保持心跳的 TCP 包。

HTTP/1.0 到 HTTP/1.1

在 HTTP/1.0 中,有人就尝试实现这样的长连接。实现的方式是客户端和服务器协商 Connection: Keep-Alive Header。加入浏览器支持长连接,那么就在 HTTP 请求的 Header 上添加 Connection: Keep-Alive ,如果服务器收到请求,就在 Response 中也加入这个 Header: Connection: Keep-Alive 。这样客户端再发送另一个请求的时候就会使用这个 TCP 连接,而不会新建一个。

然而,HTTP/1.0 的实现是错误的,主要的问题在代理。HTTP/1.0 的 Proxy Server 不懂这个字段,把它原样发给了后面的 Server,导致 Proxy 和 Server 之间建立了长连接,造成了一个 “Hung Connection”。有人提出一种处理的手段(RFC 7230 A.1.2),是想换一个字段来表示 Client 和 Proxy Server 之间的长连接 Proxy-Connection ,但是通常代理并不仅仅是一层,而是层层 HTTP 代理,这样依然就存在前面的问题,所以 Proxy-Connection 这个 Header 在任何情况下都用不到。

所以在 HTTP/1.0 使用长连接要小心,客户端要感知 hung 住的连接,要显示地关闭。因为 HTTP/1.0 的 Proxy 不支持长连接,所以在有 HTTP/1.0 Proxy 的情况下,不能使用长连接

然而,和 Proxy Server 的对话是长连接一个重要的使用场景,所以粗暴地规定不能对 Proxy 使用长连接是不能接受的。所以就需要一种新的机制,要满足以下的需求:1)对于旧版本的 Proxy Server ,忽略了长连接请求不会造成 Hung Connnection. 2)新版本的机制能够让 Real Server 和 Proxy Server 都能正确处理长连接。

HTTP/1.1 提出的方案(RFC2068 19.7.1)是:所有的 HTTP 连接默认都是长连接。当 Header 中添加 Connection: close 的时候,表示不保持长连接。这样可以满足上面说的需求,兼容旧版本的 Proxy Server(不会携带表示长连接的 Header)。

HTTP/1.1 的 Server 也可以跟 HTTP/1.0 的 Client 建立长连接,如果 Client 显式地携带了 Keep-Alive 的话。但是 HTTP/1.0 的客户端不支持 Chunked transfer-coding,所以必须每条信息都带上 Content-Length 表示边界。

另外除了 Connection 这个 Header,还有一个可选的 Header Keep-Alive 可以控制一些有关长连接的参数:

这个字段是可选的,只有带有参数的时候才生效。显而易见,这个 Header 必须和 Connection: Keep-Alive 一起发,毕竟它是控制长连接的,在没有长连接的情况下,这个 Header 就毫无意义。

最重要的一点,通过上面的描述你可能已经意识到了,就是 Connection: Keep-Alive 和下面要将的 Keep-Alive 这两个 Header 都是 hop-by-hop 的,就是说只对通讯的二者起作用,加入第二个人是 Proxy,那么 Proxy 和 Real Server 之间的连接是怎样的,就需要它们二者再自己商量了。

HTTP Keep-Alive Header

Hypertext Transfer Protocol (HTTP) Keep-Alive Header 这个 RFC 定义了 Keep-Alive Header 的一种形式。

为什么需要这些参数呢?

为了节省资源,Host 会选择关闭 idle 的长连接。比如一个连接很长时间没有使用,Host 会选择关闭它(显而易见)。

基于这个原因,很多客户端发送非幂等的请求的时候,会选择不复用现有的 idle 连接。理由如下,假设现在有一个非幂等请求进来,而恰好 Server 认为这个连接空闲了很长时间了,决定要关闭此连接。那么客户端收不到 HTTP 响应(也可能收不到 TCP 的 ACK),客户端就不知道这个非幂等请求到底被处理了没有。可能 Server 收到了这个请求之后又关闭的,也可能请求到达之前 Server 就关闭连接了。

所以很多客户端选择对所有非幂等请求都建立新的连接。但是每次都建立 TCP 连接,是很浪费资源的,也增加了请求的延迟。

假如 Client 知道 Server 的 timeout,客户端就可以在快要 timeout 的时候选择不使用这个已有的连接,或者发送请求来保证不超过 timeout。另外,如果客户端知道 timeout,那么在没有后续请求发送,而 timeout 时间又比较长的时候,客户端可以显示地要求 Server 关闭连接,释放资源。

Keep-Alive Header 的形式定义如下:

‘timeout’ 参数

timeout 参数代表当一个连接至少 idle 多长时间才会被关闭。它的 value 是一个代表秒数的 int 值。通常,连接上没有数据传输就被认为是 idle 的。但是不同的客户端和服务器实现对 idle 的理解不同,还受网络延迟的影响。所以建议客户端评估 idle 的时候算上网络延迟。

‘max’ 参数

max 参数表示在一个连接上客户端可以发送请求数的最大值。客户端在一个连接上请求的次数到了这个值,Server 就会关闭这个连接。

max 的 value 是一个 int 值,表示请求数。

对于客户端来说,收到了带有这个参数的 Response,就可以限制在同一个连接中发送的请求。举例说,假如客户端用一个队列来存放即将发送的请求,那么根据 max 就可以对队列分段。对于服务器来说,当服务器接收的请求达到了计数值之后就可以关闭连接。

Keep-Alive Extensions

除了这两个参数之外,也可以在 keep-alive-extension 字段中添加自定义的字段,如果服务器不理解这些字段,将忽略。

存在中间人的情况

在本文开头已经看到,HTTP 的长连接是基于 TCP 的长连接的。所以情况要复杂的多,实际情况有可能 TCP 保持长连接的时间要比 HTTP 短,HTTP 长连接 timeout 还没达到的时候,像 Net Address Translation (NAT) 这样的设备就将此 TCP 连接断开了,导致此连接不再可用。

HTTP/1.1 的中间人在转发请求的时候会直接丢掉这个 Header,因为它不需要认识这个 Header,它可以在任何时候断开连接

HTTP/1.0 的中间人,我们前面说过了,会导致错误。

此外,如果客户端(或中间人)感知到后面中间人的中间人(或中间 TCP 转发设备)的 timeout 更短的话,可能会修改这个值。Again,这个 Header 是 hop-by-hop 的。

从以下这个例子可以看出,每一端的连接都是独立协商的。

这里 Client 想要建立一个 timeout 为 10 分钟的长连接,但是 Proxy Server 只支持 120 秒,所以 Client 和 Proxy 之间的长连接最终是 120 秒的 timeout。Proxy 想和 Real Server 建立 1200 timeout 的长连接,但是 Real Server 将其降低到 300s。(PS:上图中 120 这个数字在 RFC 中写的是 5000,这样的话跟 RFC 的解释就冲突了,我不是很理解,我觉得 RFC 这里这个数字可能写错了。如果我理解错了,请指出)

如果 Upgraded HTTP Connections 的情况,就更复杂一些。如果 upgraded 的协议没有指明 timeout,那么会继承长连接初始化时候的 timeout,max 这个参数没有意义,因为升级后的 request 和 subrequest 被视为一个 HTTP 请求。客户端、中间人、服务器对 Upgraded 的策略可能不同,但是这个长连接的各个参数不再像上面一样可以分别独立协商,而是从 Client 到 Proxy 到 Real Server 的连接属性是一样的。

由于 keepalive 这个词被用在很多地方,而意义不尽相同,下面介绍一下 TCP 的 keepalive。

TCP Keepalive

对于 TCP 来说,Keepalive 并不是标准 TCP 协议规定的,所以 TCP 本身并不知道这个东西的存在,这只是在 TCP 之上的一个实现。

简单说,TCP Keepalive 就是设置一个 timer,时间一到就发送一个 probe packet,并设置 ACK。如果对方发送回来一个 ACK,那么那就知道这个 TCP 连接依然是可用的。如果对方没有发送 ACK 回来,那么就知道这个连接已经被断开了(实际的实现,一般会在收不到请求 ACK 的情况下重复发送多次 probe packet)。可以这么做是因为 1) TCP 是面向流的连接,而不是面向包的,所以在这个“流”中插入一个长度为 0 的包不会对这个流的内容造成任何影响。2)TCP 对每一个 packet 都会发送一个 ACK 确认。所以就可以用长度为 0 的一段“stream”来当做 probe。

那这个 TCP 的 Keepalive 有什么用呢?

主要有两个,第一是检查连接是否可用。假设 TCP 的另一端断电了,或者中间的某一个转发的设备断电了,那么通过检查连接的可用性,就可以确保不会在一个已经不能用的连接上发消息,不会有 false-positive。

第二是可以起到类似心跳的作用,防火墙或 NAT 设备的内存只能保存有限的连接数,它们普遍采用的策略是保留最近用到的连接,丢弃最旧没有有消息的连接。通过 Keepalive 的机制,我们可以让 NAT 设备保持我们的连接在可用列表中。

对比一下:HTTP 的 Keep-Alive Header 做的是设置长连接的 idle 时间,超过了这个时间就关闭连接,TCP Keepalive 设置的 timer 到了就发送空的 probe packet。

最后再提一个 Nginx 里面的 keepalive 指令,这个指令就更加迷惑了,跟上面介绍的都不搭边。它表示的是:Nginx 与 upstream 之间保持最多多少个 idle 的长连接。这个 idle 很关键,比如 keepalive 100 ,那么收到 300 个请求的时候,Nginx 和 upstream 建立 300 个长连接,这 300 个请求结束后,又来了 50 个请求,那么只有 50 个长连接是工作的,idle 的连接就有 250 个,Nginx 会关闭 150 个。更具体的例子可以看这里

 

参考资料: