Hi,欢迎来到 卡瓦邦噶!这里是一个 SRE 的博客,讨论的内容有:Linux, Vim, Tmux, NixOS, Python, Java, TCP/IP, Container, Docker, Ansible, Git, Graphviz, Redis, Prometheus, SSH, Shell, Bash, GNU, Github, C, C++, Unix, Nginx, Lua, Django, React 以及运维的方法论,编程的思考,工作的感悟。除了技术上的内容之外,这个博客还会分享一些读书感悟,电影和音乐等。欢迎留下你的评论。

声明:本博客内容仅代表本人观点,和我的雇主无关。本站所有内容未加说明均为原创,一经发布自动进入公有领域,本人放弃所有权利。转载无需本人同意。但是依然建议在转载的时候留下本博客的链接,因为这里的很多内容在发布之后会还会不断地继续更新和追加内容。

用 Wireshark 分析 TCP 吞吐瓶颈

Debug 网络质量的时候,我们一般会关注两个因素:延迟和吞吐量(带宽)。延迟比较好验证,Ping 一下或者 mtr 一下就能看出来。这篇文章分享一个 debug 吞吐量的办法。

看重吞吐量的场景一般是所谓的长肥管道(Long Fat Networks, LFN, rfc7323). 比如下载大文件。吞吐量没有达到网络的上限,主要可能受 3 个方面的影响:

  1. 发送端出现了瓶颈
  2. 接收端出现了瓶颈
  3. 中间的网络层出现了瓶颈

发送端出现瓶颈一般的情况是 buffer 不够大,因为发送的过程是,应用调用 syscall,将要发送的数据放到 buffer 里面,然后由系统负责发送出去。如果 buffer 满了,那么应用会阻塞住(如果使用 block 的 API 的话),直到 buffer 可用了再继续 write,生产者和消费者模式。

图片来自 cisco

发送端出现瓶颈一般都比较好排查,甚至通过应用的日志看何时阻塞住了即可。大部分情况都是第 2,3 种情况,比较难以排查。这种情况发生在,发送端的应用已经将内容写入到了系统的 buffer 中,但是系统并没有很快的发送出去。

TCP 为了优化传输效率(注意这里的传输效率,并不是单纯某一个 TCP 连接的传输效率,而是整体网络的效率),会:

  1. 保护接收端,发送的数据不会超过接收端的 buffer 大小 (Flow control)。数据发送到接受端,也是和上面介绍的过程类似,kernel 先负责收好包放到 buffer 中,然后上层应用程序处理这个 buffer 中的内容,如果接收端的 buffer 过小,那么很容易出现瓶颈,即应用程序还没来得及处理就被填满了。那么如果数据继续发过来,buffer 存不下,接收端只能丢弃。
  2. 保护网络,发送的数据不会 overwhelming 网络 (Congestion Control, 拥塞控制), 如果中间的网络出现瓶颈,会导致长肥管道的吞吐不理想;

对于接收端的保护,在两边连接建立的时候,会协商好接收端的 buffer 大小 (receiver window size, rwnd), 并且在后续的发送中,接收端也会在每一个 ack 回包中报告自己剩余和接受的 window 大小。这样,发送端在发送的时候会保证不会发送超过接收端 buffer 大小的数据。(意思是,发送端需要负责,receiver 没有 ack 的总数,不会超过 receiver 的 buffer.)

图片来自 cisco

对于网络的保护,原理也是维护一个 Window,叫做 Congestion window,拥塞窗口,cwnd, 这个窗口就是当前网络的限制,发送端不会发送超过这个窗口的容量(没有 ack 的总数不会超过 cwnd)。

怎么找到这个 cwnd 的值呢?

这个就是关键了,默认的算法是 cubic, 也有其他算法可以使用,比如 Google 的 BBR.

主要的逻辑是,慢启动(Slow start), 发送数据来测试,如果能正确收到 receiver 那边的 ack,说明当前网络能容纳这个吞吐,将 cwnd x 2,然后继续测试。直到下面一种情况发生:

  1. 发送的包没有收到 ACK
  2. cwnd 已经等于 rwnd 了

第 2 点很好理解,说明网络吞吐并不是一个瓶颈,瓶颈是在接收端的 buffer 不够大。cwnd 不能超过 rwnd,不然会 overload 接收端。

对于第 1 点,本质上,发送端是用丢包来检测网络状况的,如果没有发生丢包,表示一切正常,如果发生丢包,说明网络处理不了这个发送速度,这时候发送端会直接将 cwnd 减半。

但实际造成第 1 点的情况并不一定是网络吞吐瓶颈,而可能是以下几种情况:

  1. 网络达到了瓶颈
  2. 网络质量问题丢包
  3. 中间网络设备延迟了包的送达,导致发送端没有在预期时间内收到 ACK

2 和 3 原因都会造成 cwnd 下降,无法充分利用网络吞吐。

以上就是基本的原理,下面介绍如何定位这种问题。

rwnd 查看方式

这个 window size 直接就在 TCP header 里面,抓下来就能看这个字段。

但是真正的 window size 需要乘以 factor, factor 是在 TCP 握手节点通过 TCP Options 协商的。所以如果分析一条 TCP 连接的 window size,必须抓到握手阶段的包,不然就不可以知道协商的 factor 是多少。

cwnd 查看方式

Congestion control 是发送端通过算法得到的一个动态变量,会实时调整,并不会体现在协议的传输数据中。所以要看这个,必须在发送端的机器上看。

在 Linux 中可以使用 ss -i 选项将 TCP 连接的参数都打印出来。

这里展示的单位是 TCP MSS. 即实际大小是 1460bytes * 10.

Wireshark 分析

Wireshark 提供了非常实用的统计功能,可以让你一眼就能看出当前的瓶颈是发生在了哪里。但是第一次打开这个图我不会看,一脸懵逼,也没查到资料要怎么看。好在我同事会,他把我教会了,我在这里记录一下,把你也教会。

首先,打开的方式如下:

然后你会看到如下的图。

首先需要明确,tcptrace 的图表示的是单方向的数据发送,因为 tcp 是双工协议,两边都能发送数据。其中最上面写了你当前在看的图数据是从 10.0.0.1 发送到 192.168.0.1 的,然后按右下角的按钮可以切换看的方向。

X轴表示的是时间,很好理解。

然后理解一下 Y 轴表示的 Sequence Number, 就是 TCP 包中的 Sequence Number,这个很关键。图中所有的数据,都是以 Sequence Number 为准的。

所以,你如果看到如上图所示,那么说明你看反了,因为数据的 Sequence Number 并没有增加过,说明几乎没有发送过数据,需要点击 Switch Direction。

这就对了,可以看到我们传输的 Sequence Number 在随着时间增加而增加。

这里面有 3 条线,含义如下:

除此之外,另外还有两种线:

需要始终记住的是 Y 轴是 Sequence Number,红色的线表示 SACK 的线表示这一段 Sequence Number 我已经收到了,然后配合黄色线表示 ACK 过的 Sequence Number,那么发送端就会知道,在中间这段空挡,包丢了,红色线和黄色线纵向的空白,是没有被 ACK 的包。所以,需要重新传输。而蓝色的线就是表示又重新传输了一遍。

学会了看这些图,我们可以认识几种常见的 pattern:

丢包

很多红色 SACK,说明接收端那边重复在说:中间有一个包我没有收到,中间有一个包我没有收到。

吞吐受到接收 window size 限制

从这个图可以看出,黄色的线(接收端一 ACK)一上升,蓝色就跟着上升(发送端就开始发),直到填满绿色的线(window size)。说明网络并不是瓶颈,可以调大接收端的 buffer size.

吞吐受到网络质量限制

从这张图中可以看出,接收端的 window size 远远不是瓶颈,还有很多空闲。

放大可以看出,中间有很多丢包和重传,并且每次只发送一点点数据,这说明很有可能是 cwnd 太小了,受到了拥塞控制算法的限制。

 

本文中用到的抓包文件可以从这里下载(credit: https://www.youtube.com/watch?v=yUmACeSmT7o):

  1. https://www.cloudshark.org/captures/f5eb7c033728
  2. https://www.cloudshark.org/captures/c967765aef38

其他的一些参考资料:

  1. https://www.stackpath.com/edge-academy/what-is-cwnd-and-rwnd/
  2. https://www.baeldung.com/cs/tcp-flow-control-vs-congestion-control
  3. https://www.cs.cornell.edu/courses/cs4450/2020sp/lecture21-congestion-control.pdf
  4. https://www.mi.fu-berlin.de/inf/groups/ag-tech/teaching/2011-12_WS/L_19531_Telematics/08_Transport_Layer.pdf
  5. https://wiki.aalto.fi/download/attachments/69901948/TCP-CongestionControlFinal.pdf
  6. https://paulgrevink.wordpress.com/2017/09/08/about-long-fat-networks-and-tcp-tuning/

帮这位老师的项目打一个广告:https://github.com/royzhr/spate 一个大流量网络性能测试工具。

 

开源的世界如此之小?!

今天想基于前两天有用户提交的 PR 做一下 iredis 的 TLS feature,然后突然发现 iredis 的 master 分支不能 build 了。fail 的 action

为了防止 action 日志失效,这里放一个截图

错误也非常莫名其妙,我什么时候就拿 None 和 String 比较了呢?

主要是前两天用户提交的 PR 完全 pass 了我才 merge 的呀,怎么 merge 到 master 之后就不行了呢?

隐约感觉是有什么依赖 broke 了,如果直接 diff 之前安装的依赖可能定位会快一些(这就是为啥我每次会在 CI 里面先打一行 pip list 的原因!)但是这次我想直接去找找是什么问题,闲着也是闲着。

那就直接先从出错误的 poetry 入手,我直接在它的代码里面加入了很多 print(Python 的优势,直接改安装的包就行)。然后不断缩小范围,发现 poetry-core 的代码中,有一个 copy.copy,copy 之后的内容和之前的不一样:

于是开始研究这个 copy 的对象有啥特殊的,发现是 tomlkit 里面的一个对象。不是一个简单的 list。

然后跳到 tomlkit 里面去看这个对象,发现是一个自定义的 list,代码有一些长,花了一些时间读明白之后发现,它解析之后为了保持格式问题,会加一个 Null 的 dummy 对象占位。本来这个 dummy 不会在 value 里面的,但是一经过 copy 的时候,__init__ 并没有过滤掉 Null,就导致这个 None 被加进去了。

提交了一个 PR 修复:https://github.com/sdispater/tomlkit/pull/221

这都不是重点,重点是,这个 bug 不是很好触发:

  1. 需要使用 copy.copy,除了 copy 我想不到其他的场景会出现这个问题,而 poetry 正好用到了,并且 copy 的是 classifier;
  2. 需要在 toml 的被 copy 的 list 里面有一些 Comment,而我的 iredis 里面,我之前为了给自己备忘去哪里找 classifier 列表正好写了一行 comment

这些还不是重点,重点是我去看了一下这个仓库,发现代码的问题是在两天前引入的,而两天内正好让我给触发了。

这些依然不是重点,重点是,这段代码的作者,居然正是我的明哥(@frostming)!!!

 

Golang 的 Channel 是一种免费的无锁实现吗?

今天看到一段代码,是用 Prometheus 的 client_go 暴露 metrics 的代码。里面不是简单的将对应的一个 metric counter inc()的操作,而是自己实现了一个非常奇怪的逻辑:

  1. 当程序需要将 counter +1 的时候,没有直接操作对应的 metrics,而是将要增加的 metrics 用自己的格式打包出一个对象,然后将对象发送到一个 channel,每一个 metric 都对应一个 channel
  2. 程序启动之初就会启动全局唯一的 worker goroutine, 这个 goroutine 负责操作所有的 metrics:它从不同的 channel 中拿到消息,然后解包出来,找到对应的应该增加的 metrics,然后执行最终的增加操作。

实际的操作还要复杂的多,先创建一个 MetricsBuilder,然后 MetricsBuilder 有 Add() 函数,实际上是往 Channel 里面发送了一条信息,Channel 里面读出来,又通过一些系列的层层调用执行到 metrics + 1 上。

事先声明, 本人不会写 golang,本文可能写的不对,请读者指正。

感觉本身就是一行 metrics.Add() 的事情,为什么要搞这么复杂呢?思来想去,我觉得唯一可能的解释就是:这是一个负载极高的系统,希望将 metrics 的操作变成异步的操作,不占用业务处理的时间。但是用 channel 还涉及到打包和解包,真的能快吗?

一开始我以为 channel 可能是一种高性能的无锁的操作,但是看了 golang 的 runtime 部分,发现也是有锁的,如果多个线程同时往一个 channel 里面写,也是存在竞争条件的。

而 Prometheus 的 client_golang 只是执行了一个 Add 操作:atomic.AddUint64(&c.valInt, ival)

虽然 atomic 也是一个 CAS 操作,但直觉上我觉得用 channel 是不会比 atomic 快的。

写了两段代码来比较这两种情况(测试代码和运行方式可以在 atomic_or_channel 这里找到):

直接用 atomic 代码:

模拟开一个 channel 负责增加的情况:

参数如下,意在模拟 100 个并行连接,需要增加1百万次:

实际运行的结果也和我想的一样,atomic 要比 channel 的方式快了 15 倍。

atomic 2s 就可以完成模拟 100 个客户端并行增加1百万次,即可以支持5千万的 QPS (还只是在我的笔记本上),而相同的操作用上文描述的 channel 的方式需要 30-40s。慢了15倍。

虽然我在有些地方说 atomic 很慢,但是这个速度对于 metrics 统计的这个场景来说,是完全足够了。


Twitter 上的 @Kontinuation 提醒有一种优化的方式,我觉得这个很好:

每一个 thread 维护自己的 threadlocal 变量,这样完全不需要锁。只是在 collect metrics 的时候采取收集每一个 thread 的 counter 等。TiKV 中就是使用这个方法实现的 Local 指标(@_yeya24),即每一个线程保存自己的指标在 Thread Local Storage,然后每 1s 刷新到全局变量(其实我觉得可以只有在 metrics 被收集的时候才刷新?),这样可以减少锁的次数。

但是在 golang 里面,从这篇文章发现 go 语言官方是不想让你用 thread local 的东西的,而且为此还专门让 go id 不容易被获取到。那我就像不到什么比较好的实现方法了。

 

 

Prometheus alert rules 拆分成多个查询表达式

Alertmanager 发送出来的告警是一条消息,一般我们会用 annotation 来说明发生什么事了。

但是 Grafana 发出来的,就会直接带上你的查询表达式当前的状况:

这个图是非常有用的,如果有图的话,基本上一看你就知道发生什么事情了,因为它可以告诉你一个 time series 图,是一个 Range 消息,如果没有图的话,就相当于你只能知道一个数据点的信息(Instant)。后面需要连接 VPN,打开对应的监控,去查看到底发生了什么。

但是我们依然没有选择用 Grafana 来做 Alert 系统,而是选择了 Alertmanager 来做。事实上,上面这个优点几乎是 Grafana 唯一的优点,在其他方面它都不如 Alertmanager,这个后面有时间再说吧。本文是想谈谈有什么可能让 Alertmanager 发出来消息的时候带上一个曲线图。

其实这件事情没有什么难度,只要在发出告警的带出一条渲染好的 URL 就可以了。唯一比较复杂的是,prometheus 对 alert 的逻辑是:如果一条查询的结果不是 null,那么就 fire,否则,说明一切正常。

比如 up == 0,正常情况下 up 值是 1,所以这条查询不会有结果。如果目标挂了,这么就成立了,这条查询就会有结果。

比如 rss_memory > 1G,正常情况下小于 1G,也不会有结果。但是如果超过了,查询就有结果了,就会 fire alerts。

所以如果你把 alert rules 的这条 expression 画出来的,就会长这样:

可以看到,触发 alerts 的时候就不是 null,threshold 以下的就都是 null 了。

一个解决方法是,我们可以将这个查询表达式拆开,分成作值和右值。分开画图。就可以看到像下面这样的效果:

本以为会有现成的工具可以拆开 alert rules,将其拆成两个表达式。然后调查了一番,发现并没有这种东西。

那么 Grafana 是怎么做到的呢?很简单……Grafana 设置的 alert rules 的右值只能是一个固定的数字,必须按照固定的格式填入。

Prometheus 用户组发现一个哥们也有相同的问题,一个哥们回答说 Prometheus 的 go 库就支持这个功能,有一个 ParseExpr 函数,传入一个 string 的 PromQL 代码,传回一个 Expr 解析好的表达式。这哥们是 Promlens 的,这是一个付费的服务,可以解析和可视化 PromQL。

然后又找到了一些可以解析 PromQL 相关的库:

最后决定用 metricsql 来实现。唯一的缺点是这个库是 golang 写的(VictoriaMetrics 所有的产品都是用 golang 写的)。我的项目是基于 Python 的。

我决定用 golang 实现我需要的功能:传入一个 expression,然后传回 split 好的语法树。然后用 CGO 编译成一个 shared library,最后在 Python 中用 cffi 来调用。

核心的逻辑很简单,即使将 expression 用 metricsql.Parse 函数去处理,得到一个语法树,然后递归遍历一遍:

  • 如果遇到逻辑操作符,比如 and, or, unless, 那么说明左边和右边依然是一个 expression,递归分析;
  • 如果是比较操作符:==, >, < 等,那么它的左值和右值就直接拆开,用于画图,到此递归结束。有的时候会遇到表达式依然是比较的情况:(a > 10) < 100,这种情况可以将 a > 10 看成是一条普通的线,直接返回即可,没有必要继续拆分;

为了方便测试和维护代码结构,go 写的核心的逻辑和 CGO 处理的部分分开。下面再写一个 CGO 函数,它的入口参数必须都是 import "C" 这个库提供的,将会用于 ABI。返回的参数也是。

编译方法如下:

go build -buildmode=c-shared -o libpypromql.so

这样就得到了一个 .so 文件和 header 文件。

下面就是用 Python 去调用这个 so 文件了。直接用 cffi 的接口就可以,这个很简单,基本上就是描述一下这个 so 的 ABI,然后用 cffi 去调用,解析结构。需要特别注意的是,golang 的 gc 不会释放它 return 的 C.CString,这部分必须手动释放:

最后一步,因为是用 Poetry 打包的 Python package,所以还需要写一个 build.py 告诉 poetry 怎么 build whl

还写了一个命令行的工具,可以从命令行拆分 expression,效果如下:

为了测试解析结果的正确性,我把 awesome-prometheus-alerts 项目所有的 alerts 抓下来了,用来生成了 400 多个单元测试。

项目的地址是:https://github.com/laixintao/promqlpy

 

Prometheus HTTP SD 框架

Prometheus 是现在比较流行的监控系统,它的工作模式是拉的模式:要监控的目标要负责把 metris 数据暴露出来,格式是普通的文本格式,协议是 HTTP,就像开放了一个普通的 HTTP 服务一样,然后 Prometheus 定时来这些 HTTP 接口收集 metrics,保存起来。

那么如何告诉 Prometheus 应该去哪里抓取这些 metrics 呢?

你可以直接把要抓取的目标写在 Prometheus 的配置文件里面去。但是这样一些经常变动的目标就不是很友好。

Prometheus 本身支持很多服务发现,比如 consul_sd, 配置一个 consul 的地址,Prometheus 会通过 Consul 发现抓取的目标。还有其他的一些支持云上的发现方式,比如 aws 的 ec2_sd 等。

但是也有很多是不支持的,比如 Etcd,Redis 等就不支持。

更重要的是,很多公司都会有自己内部的一些 cmdb 管理系统,机器资源管理系统,服务治理等,如果要让 Prometheus 自动根据这些信息去抓取 metrics,一种方法是定期向 Prometheus 支持的一种 sd 去同步数据。另一种方法是使用 Prometheus 的 file_sd 或者 http_sd, 即将自己的信息转换成某种 Prometheus 能够直接使用的格式。

prometheus-http-sd 就是这样一个框架,它支持用户自定义编程,通过实现一个 generate_targets() 函数,来返回 Prometheus 的 targets 格式。

然后,启动 prometheus-http-sd,它会 listen 一个端口,通过 HTTP 的服务暴露出来抓取的目标,给 Prometheus 做服务发现。每次 Prometheus 发送来 HTTP 请求的时候,会向后端去调用用户定义的 generate_targets() 函数,然后将函数的返回结果通过 HTTP Response 返回给 Prometheus。

这个项目的想法和 prometheus 的本职一样,所有的东西都通过 http 暴露出来 (metrics),如果你有一个接口,那么你就可以将其转换成 http 格式的 metrics;如果你有一个数据源,那么你就可以将其转换成 HTTP 格式的 targets。

https://github.com/laixintao/prometheus-http-sd

它以树状的目录组织 targets,即你可以将同一级不同目录的 targets 分配给不同的 Prometheus 实例来抓取。这样,也支持同一组目标用两个 job 来抓不同的 metrics 路径。

支持热加载,如果修改了 target generator 不需要重启应用。推荐用户使用 git 维护 target generator,然后只要更新这个文件夹就可以了。

支持 Dry run 模式,这样就可以将 prometheus-http-sd 集成到 CI 里面,每次修改 target generator 就可以在 CI 里面自动检查目标生成是否符合 Prometheus 的规范,Python 代码是否有问题。

除了支持用 Python 文件定义抓取目标,还支持静态的文件定义目标,支持 json,和 Yaml(Prometheus 原生的 file_sd 不支持 Yaml,不知道为啥)。

另外 prometheus-http-sd 本身也支持暴露 metrics,可以监控自身,比如 latency,generator 被调用的次数,生成的目标数量等等。

支持 admin 页面,可以用来 debug。

……

其他的一些功能大家可以直接看 Github 的项目主页,一些新加的 feature 都会写在 readme 上面。

目前使用下来,就是我们内部的一些系统比较慢,Prometheus 每 60s 一次来做服务发现的话,会导致有的时候会超时,虽然这大部分时候不是因为一个问题,因为 Prometheus 在 http_sd 失败的时候,会继续使用上一次的结果,但是为了保护后端的系统,我打算给 http_sd 加一个 cache 的功能。