开源的世界如此之小?!

今天想基于前两天有用户提交的 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 的功能。

 

程序 Hot reload config 的实现方式

有时候一些进程必可避免的有重启代价太大的问题。比如有一些长连接的进程,重启会断开连接,然后所有的客户端都需要重连,或者进程的内存中已经缓存了很多内容,如果启动的话缓存就凉了,要重新预热。

但是又有一些配置,我们希望修改进程的配置,而不需要重启进程。

在 像设计 UI 一样去设计配置项 中聊过有一种方式是做一个配置中心,然后如果有配置修改的时候,就推送一个请求给应用程序,这样程序那边收到配置的更新的时候做一个 callback 来执行配置的变更。

这个方案很好,但是依赖一些其他的组件,中心化的配置中心,以及程序内需要有 SDK 依赖去接收和解析配置更新的内容。

本来讨论不依赖其他组件的实现方式,即更新文件,不需要重启进程来让文件生效。

File Watch

这是一种比较自然想到的实现方式,也是最糟糕的方式。

它的原理是,进程在启动之后,就开一个线程负责 watch 这个文件所有的变化,一旦发现变化,就执行配置更新的 callback。

这种实现糟糕的地方在于:

  1. 不同系统的文件变更的 API 是不同的,比如 Linux 的 inotify,Mac/BSD 的 kqueue,和 Windows 的 ReadDirectoryChangesW. 为了兼容这些不同系统的 API,一般需要引入一个专业的 SDK 来封装,比如 watchdog
  2. 占用系统的 thread,虽然这个也有可能做成异步的
  3. 占用系统的 fd,这个看起来不严重,但是有的时候在系统的 fd 用完了的时候,我们想要用 hotreload 去修改一个参数来控制程序的行为,就麻烦了;

好处在于 import 一个 SDK 之后,不需要写很多代码就可以实现。

每一次都重新 load

这种方法就是,每次在使用配置项的时候,不使用全局变量,而每次都去解析配置文件。这样也就不存在说要 hot reload 的问题了,因为每一次使用都是一次 reload,所有用到配置的情况,都是最新的。

很多人乍一看觉得这种实现非常蠢,但是你仔细想一下,很多需要 hot reload 的配置的都不是读写很高的。

比如 lobbyboy 作为一个个人用的 ssh server,每次在有连接进来的时候,花几 ms 去 load 一下最新的 ssh key 配置,是完全可以接受的。

最近在写的 prometheus-http-sd 里面的加载 script 和文件的时候,也是对于每一个请求都是即时 load 的,即时这样,也能在 5ms 内完成请求。

这样节省了非常大的工作量:

  1. 不需要写更新配置的 callback 了,直接复用 load 的代码
  2. 不需要引入额外的依赖了
  3. 非常简单,每个人都能理解

这两种方式有一个问题,就是一旦文件发生更改,就立即生效了,没有时间让你去验证文件对不对,而且这是一个单向的 fire and forget 的操作,操作的时候不知道自己的动作对不对,必须要通过旁路去检查,比如看一下日志或者监控。虽然可以有其他 work around,比如先放到其他 location 验证。

SIGHUP

这是最好的方案。程序注册一个 signal handler,在收到 SIGHUP 信号的时候,reload config。

这样的好处是:

  1. reload 是明确的操作人的意图,而不是单纯文件修改了就 reload,语义没有歧义
  2. 实现成本很小

而且,我们一般可以控制 reload 的行为。比如说使用 systemctl reload service 的时候,我们可以自定义 reload 命令:

  1. 先检查配置文件是否合法,如果不合法,放弃 reload;
  2. 给程序发送 reload 信号;

以下是一个例子,在 systemd 的 unit 文件里面可以定义 ExecReload,即定义执行 systemctl reload serivce 的时候所做的事情。

这里有两个技巧:

  1. Systemd 里面会给你一个 $MAINPID 让你用,即主进程的 PID,直接往这里发送信号即可;
  2. 使用 ; 可以定义多个命令,一个启动成功才会执行下一个。要注意这不是 shell 的 ;

这样,我们在 reload 的时候,如果第一个检查不通过,systemctl reload 的 return code 就不是0,我们就知道 reload 失败了。

在操作的时候,我们可以直接在 Ansible 的操作结果中看到动作出错,而不是需要去 ssh 到机器上,查看执行结果是否正确。