PromQL 使用多个 label 组合过滤

继《最近的工作感悟》中提到的大部分问题都解决了之后,有一些错误还是无法避免的,就试图想办法从监控系统中忽略掉。尝试了很长时间,发现在 PromQL 中写 “exclude 特定 label 的 metrics” 这样的查询不是很方便,目前没有找到比较合适的方法,这里记录一下一些可行的,但不是特别优雅的方法。

问题可以简化成这样:有一个 metric 叫做 request_count, 有两个 label:

  • client: 客户端的名字,比如有: curl, chrome, safari, firefox, python
  • error_code: 400, 200, 403, 302 等

因为有一些错误无法避免,比如由爬虫(假设 clientpython)引起的 404 问题,在 chrome 上发生的 403 问题,我们想从监控中忽略掉。

首先 request_count{client!="python", error_code="404"} 这样的查询是不行的,因为这样会忽略来自 python 的所有的请求,以及所有的 error_code=404。这样写实际上是一个  and 的关系,metric 的 label 满足所有的条件才会展示,否则不展示。

 

其实通过 Grafana 的 Transform 设置,我们可以取消展示一些单独的 Metric。选择 Transform  tab,然后选择 “Filter by name”, 就可以勾选单独的 metric 取消展示。

这样可以解决展示的问题,但是查询结果实际还是包含这些 metric 的。如果基于这个查询来设置 alerting rules 的话,那么这些 metric 还是无法被忽略。我还是想从查询上来忽略这些 metric,这样无论展示和告警,都可以使用同一个 aggr rule.

 

通过查询来忽略的方法有些 tricky,因为涉及两个 lebel 的 and 条件查询,总体的思路是:

  1. 忽略 label A 的所有 metrics;
  2. 使用 or 添加满足 label A 和 B 两个 label 的 metrics;

以上面的例子,查询的 PromQL 就是:

因为 label 的写法只支持 and,但是我们可以使用 or 组合 metric 来实现查询。

or 也支持连续的写法,以及再需要提一下需要永远先 rate 再 sum,所以回到刚开始的例子,就需要写成:

 

参考:How to filter by two labels in prometheus?

 

《Prometheus Up & Running》阅读

最近读完了这本讲监控的书:Prometheus Up & Running,学到很多东西,在博客上推荐一下。

可以将这本书的读者分成三种角色:

  1. 应用程序的开发者,需要使用 Prometheus 来监控自己的应用;
  2. SRE,需要监控应用以及服务器的运行状态;
  3. 监控系统的维护者,可能也是 SRE,需要维护和部署 Prometheus。

全书分成了 6 个部分:

  1. 介绍配 Prometheus 的一些概念,工作的模式,核心的思想。比如数据不是“完全准确的”,“拉取的模型”,存储,监控面板等等;
  2. 介绍了应用的 Metrics 如何暴露,Metrics 的类型,一些 Prometheus 的概念(更详细)等等;
  3. Prometheus 现在的周边生态已经比较完善了,这一部分介绍如何使用已有的 Exporter 以及如何自己写 Exporter;
  4. 介绍如何使用 PromQL 做查询,PromQL 是一个完整并且强大(图灵完备的)查询语言;
  5. 介绍如何配置告警,一些核心思想,工作原理,需要避免的误区等等;
  6. Prometheus 的部署,如何扩大存储的规模,如何解决性能问题,如何提高查询速度等;

那么回到上面的三种角色,对于工作于监控领域的 SRE 来说,推荐阅读 1-6 章,每一章都会有所启发。对于普通的 SRE 来说,推荐阅读 1-6 章,因为监控可以说是 SRE 工作的中心,如果不会用监控就和瞎子一样,如果精通监控可以让很多事情事半功倍。对于应用开发者来说,推荐阅读 1-6 章。开发者需要熟悉 Prometheus 里面的一些概念,才能正确的 Expose metrics,所以 1-3 章是开发者必读的;同时开发者又是最了解自己的系统的人,所以监控面板的主要编辑者应该是开发者,写 PromQL 就变得重要了,所以第 4-5 章也是必读的。第 6 章就有一些微妙了,Prometheus 其实本质上是一个很简单的架构,但是要在大规模下运行,就需要其他的一些方案。比如我们公司用的是 Thanos,其实它的背后是一个个独立的 Prometheus,Grafana 的查询直接去查 Thanos,Thanos 就表现的是一个无限大的 Prometheus 一样。我刚接触这个架构经常犯的一个错误是在 alerting rule 里面写聚合的查询,导致很多 alerts 没发出来。因为 alerts 在本质上还是在每一个 Prometheus 上做聚合(evaluation)的,那个时候每个 Prometheus 计算自己本身的数据,认为都没有达到 firing 的状态,但是实际将每一个 Prometheus 聚合起来已经达到了。所以,要正确地使用这一套系统,其实还是要了解背后的部署状态比较好。

要是说读完这本书我学到最重要的一点的话,就是:SRE 的效率和正确性同等重要。

去监控现象而不是监控原因,花很多努力让 labels 变得有意义,在工作被 page 的次数和问题发现时间做平衡而不是一味追求快速发现问题,等等。表面上看这些都是在提高 SRE 的幸福感,但是本质上也是提高软件质量和用户体验的正确道路。

要说这本书的缺点的话,就是感觉很多例子都举得不是很好,好的例子应该从现实中找,但是书中的有些例子太刻意了。大部分的例子也有点难运行(和监控这个话题也有关,这本书没有处处将例子的完整配置写出来,可能也太占篇幅了,就导致不是所有的例子都可以复现的),以至于有些话题有些抽象,不太好理解。但是以后遇到问题可以找到相关的章节,再找一下灵感。

现在的监控系统感觉正确性已经做得很好了,需要提高的是体验上的问题,比如部署上的扩展性,UI 上的配置,如何可维护等等。虽然从 Grafana 到 Prometheus 到 Alert 都可以用过 yaml 来配置,做到 gitops,但是在一个很大的团队下,配合起来太痛苦了。最后都会走向 UI 的配置吧,UI 如果没有专业的人来设计,又会做的很难用。难用的话就不会做到 Easy to change, 最后又会做的难以维护。听起来很像是一个悖论呢……

最后推荐一下 My Philosophy on Alerting 这篇文章,Google 的 SRE 写的。

 

PromQL 简明教程

这篇文章介绍如何使用 PromQL 查询 Prometheus 里面的数据。包括如何使用函数,理解这些函数,Metrics 的逻辑等等,因为看了很多教程试图学习 PromQL,发现这些教程都直说有哪些函数、语法是什么,看完之后还是很难理解。比如 [1m] 是什么意思?为什么有的函数需要有的函数不需要?它对 Grafana 上面展示的数据有什么影响?rateirate 的区别是什么?sumrate 要先用哪个后用哪个?经过照葫芦画瓢地写了很多 PromQL 来设置监控和告警规则,我渐渐对 PromQL 的逻辑有了一些理解。这篇文章从头开始,通过介绍 PromQL 里面的逻辑,来理解这些函数的作用。本文不会一一回答上面这些问题,但是我的这些问题都是由于之前对 PromQL 里面的逻辑和概念不了解,相信读完本文之后,这些问题的答案就显得不言而喻了。

本文不会深入讲解 Prometheus 的数据存储原理,Prometheus 对 metrics 的抓取原理等问题;也不会深入介绍 PromQL 中每一个 API 的实现。只会着重于介绍如何写 PromQL 的原理,和它的设计逻辑。但是相信如果理解了本文这些概念,可以更透彻地理解和阅读 Prometheus 官方的文档。

Metric 类型

Prometheus 里面其实只有两种数据类型。Gauge 和 Counter。

Gauge

Gauge 是比较符合直觉的。它就是表示一个当前的“状态”,比如内存当前是多少,CPU 当前的使用率是多少。

Counter

Counter 有一些不符合直觉。我想了很久才理解(可能我有点钻牛角尖了)。Counter 是一个永远只递增的 Metric 类型。

使用 Counter 计算得到的,每秒收到的 packet 数量

典型的 Counter 举例:服务器服务的请求数,服务器收到了多少包(上图)。这个数字是只增不减的,用 Counter 最合适了。因为每一个时间点的总请求数都会包含之前时间点的请求数,所以可以理解成它是一个“有状态的”(非官方说法,我这么说只是为了方便读者理解)。使用 Counter 记录每一个时间点的“总数”,然后除以时间,就可以得到 QPS,packets/s 等数据。

为什么需要 Counter 呢?先来回顾一下 Gauge,你可以将 Gauge 理解为“无状态的”,即类型是 Gauge 的 metric 不需要关心历史的值,只需要记录当前的值是多少就可以了。比如当前的内存值,当前的 CPU 使用率。当然,如果你想要查询历史的值,依然是可以查到的。只不过对于每一个时间点的“内存使用量”这个 Gauge,不包含历史的数据。那么可否用 Gauge 来代替 Counter 呢?

Prometheus 是一个抓取的模型:服务器暴露一个 HTTP 服务,Prometheus 来访问这个 HTTP 接口来获取 metrics 的数据。如果使用 Gauge 来表示上面的 pk/s 数据的话,只能使用这种方案:使用这个 Metric 记录自从上次抓取过后收到的 Packet 总数(或者直接记录 Packet/s ,原理是一样的)。每次 Prometheus 来抓取数据之后,就将这个值重置为 0. 这样的实现就类似 Gauge 了。

Prometheus 的抓取模型,去访问服务的 HTTP 来抓取 metrics

这种实现的缺点有:

  1. 抓取数据本质是 GET 操作,但是这个 GET 操作却会修改数据(将 metric 重置为0),所以会带来很多隐患,比如一个服务每次只能由一个 Prometheus 来抓取,不能扩展;不能 cURL 这个 /metrics 来进行 debug,因为会影响真实的数据,等等。
  2. 如果服务器发生了重启,数据将会清零,会丢失数据(虽然 Counter 也没有从本质上解决这个问题)。

Counter 因为是一个只递增的值,所以它可以判断数字下降的问题,比如现在请求的 Count 数是 1000,然后下次 Prometheus 来抓取发现变成了 20,那么 Prometheus 就知道,真实的数据不可能是 20,因为请求数是不可能下降的。所以它会将这个点认为是 1020。

然后用 Counter 也可以解决多次读的问题,服务器上的 /metrics,可以使用 cURL 和 grep 等工具实时查看,不会改变数据。Counter 有关的细节可以参考下 How does a Prometheus Counter work?

其实 Prometheus 里面还有两种数据类型,一种是 Histogram,另一种是 Summary.

但是这两种类型本质上都是 Counter。比如,如果你要统计一个服务处理请求的平均耗时,就可以用 Summary。在代码中只用一种 Summary 类型,就可以暴露出收到的总请求数,处理这些请求花费的总时间数,两个 Counter 类型的 metric。算是一个“语法糖”。

Histogram 是由多个 Counter 组成的一组(bucket)metrics,比如你要统计 P99 的信息,使用 Histogram 可以暴露出 10 个 bucket 分别存放不同耗时区间的请求数,使用 histogram_quantile 函数就可以方便地计算出 P99(《P99是如何计算的?》). 本质上也是一个“糖”。假如 Prometheus 没有 Histogram 和 Summary 这两种 Metric 类型,也是完全可以的,只不过我们在使用上就需要多做很多事情,麻烦一些。

讲了这么说,希望读者已经明白 Counter 和 Gauge 了。因为我们接下来的查询会一直跟这两种 Metric 类型打交道。

Selectors

下面这张图简单地表示了 Metric 在 Prometheus 中的样子,以给读者一个概念。

如果我们直接在 Grafana 中使用 node_network_receive_packets_total 来画图的话,就会得到 5 条线。

Counter 的值很大,并且此图基本上看不到变化趋势。因为它们只增加,可以认为是这个服务器自存在以来收到的所有的包的数量。

Metric 可以通过 label 来进行选择,比如 node_network_receive_packets_total{device=”bond0″} 就会只查询到 bond0 的数据,绘制 bond0 这个 device 的曲线。也支持正则表达式,可以通过 node_network_receive_packets_total{device=~”en.*”} 绘制 en0 和 en2 的曲线。

其实,metric name 也是一个 “label”, 所以 node_network_receive_packets_total{device="bond0"} 本质上是 {__name__="node_network_receive_packets_total", device="bond0"} 。但是因为 metric name 基本上是必用的 label,所以我们一般用第一种写法, 这样看起来更易懂。

PromQL 支持很复杂的 Selector,详细的用法可以参考文档。 值得一提的是,Prometheus 是图灵完备 (Turing Complete)的(Surprise!)。

实际上,如果你使用下面的查询语句,将会仅仅得到一个数字,而不是整个 metric 的历史数据(node_network_receive_packets_total{device=~"en.*"} 得到的是下图中黄色的部分。

这个就是 Instant Vector:只查询到 metric 的在某个时间点(默认是当前时间)的值。

PromQL 语言的数据类型

为了避免读者混淆,这里说明一下 Metric Type 和 PromQL 查询语言中的数据类型的区别。很简单,在写 PromQL 的时候,无论是 Counter 还是 Gauge,对于函数来说都是一串数字,他们数据结构上没有区别。我们说的 Instant Vector 还是 Range Vector, 指的是 PromQL 函数的入参和返回值的类型。

Instant Vector

Instant 是立即的意思,Instant Vector 顾名思义,就是当前的值。假如查询的时间点是 t,那么查询会返回距离 t 时间点最近的一个值。

常用的另一种数据类型是 Range Vector。

Range Vector

Range Vector 顾名思义,返回的是一个 range 的数据。

Range 的表示方法是 [1m],表示 1 分钟的数据。也可以使用 [1h] 表示 1 小时,[1d] 表示 1 天。支持的所有的 duration 表示方法可以参考文档

假如我们对 Prometheus 的采集配置是每 10s 采集一次,那么 1 分钟内就会有采集 6 次,就会有 6 个数据点。我们使用node_network_receive_packets_total{device=~“.*”}[1m] 查询的话,就可以得到以下的数据:两个 metric,最后的 6 个数据点。

Prometheus 大部分的函数要么接受的是 Instant Vector,要么接受的是 Range Vector。所以要看懂这些函数的文档,就要理解这两种类型。

在详细解释之前,请读者思考一个问题:在 Grafana 中画出来一个 Metric 的图标,需要查询结果是一个 Instant Vector,还是 Range Vector 呢?

答案是 Instant Vector (Surprise!)。

为什么呢?要画出一段时间的 Chart,不应该需要一个 Range 的数据吗?为什么是 Instant Vector?

答案是:Range Vector 基本上只是为了给函数用的,Grafana 绘图只能接受 Instant Vector。Prometheus 的查询 API 是以 HTTP 的形式提供的,Grafana 在渲染一个图标的时候会向 Prometheus 去查询数据。而这个查询 API 主要有两种:

第一种是 /query:查询一个时间点的数据,返回一个数据值,通过 ?time=1627111334 可以查询指定时间的数据。

假如要绘制 1 个小时内的 Chart 的话,Grafana 首先需要你在创建 Chart 的时候传入一个 step 值,表示多久查一个数据,这里假设 step=1min 的话,我们对每分钟需要查询一次数据。那么 Grafana 会向 Prometheus 发送 60 次请求,查询 60 个数据点,即 60 个 Instant Vector,然后绘制出来一张图表。

Grafana 的 step 设置

当然,60 次请求太多了。所以就有了第二种 API query_range,接收的参数有 ?start=<start timestamp>&end=<end timestamp>&step=60。但是这个 API 本质上,是一个语法糖,在 Prometheus 内部还是对 60 个点进行了分别计算,然后返回。当然了,会有一些优化。

然后就有了下一个问题:为什么 Grafana 偏偏要绘制 Instant Vector,而不是 Range Vector 呢?

Grafana 只接受 Instant Vector, 如果查询的结果是 Range Vector, 会报错

因为这里的 Range Vector 并不是一个“绘制的时间”,而是函数计算所需要的时间区间。看下面的例子就容易理解了。

来解释一下这个查询:

rate(node_network_receive_packets_total{device=~”en.*”}[1m])

查询每秒收到的 packet 数量

node_network_receive_packets_total 是一个 Counter,为了计算每秒的 packet 数量,我们要计算每秒的数量,就要用到 rate 函数。

先来看一个时间点的计算,假如我们计算 t 时间点的每秒 packet 数量,rate 函数可以帮我们用这段时间([1m])的总 packet 数量,除以时间 [1m] ,就得到了一个“平均值”,以此作为曲线来绘制。

以这种方法就得到了一个点的数据。

然后我们对之前的每一个点,都以此法进行计算,就得到了一个 pk/s 的曲线(最长的那条是原始的数据,黄色的表示 rate 对于每一个点的计算过程,蓝色的框为最终的绘制的点)。

所以这个 PromQL 查询最终得到的数据点是:… 2.2, 1.96, 2.31, 2, 1.71 (即蓝色的点)。

这里有两个选中的 metric,分别是 en0en2,所以 rate 会分别计算两条曲线,就得到了上面的 Chart,有两条线。

rate, irate 和 increase

很多人都会纠结 iraterate 有什么区别。看到这里,其实就很好解释了。

以下来自官方的文档:

irate()
irate(v range-vector) calculates the per-second instant rate of increase of the time series in the range vector. This is based on the last two data points.

即,irate 是计算的最后两个点之间的差值。可以用下图来表示:

irate 的计算方式

自然,因为只用最后两个点的差值来计算,会比 rate 平均值的方法得到的结果,变化更加剧烈,更能反映当时的情况。那既然是使用最后两个点计算,这里又为什么需要 [1m] 呢?这个 [1m] 不是用来计算的,是用来限制找 t-2 个点的时间的,比如,如果中间丢了很多数据,那么显然这个点的计算会很不准确,irate 在计算的时候会最多向前在 [1m] 找点,如果超过 [1m] 没有找到数据点,这个点的计算就放弃了。

在现实中的例子,可以将上面查询的 rate 改成 irate

irate(node_network_receive_packets_total{device=~”en.*”}[1m])

对比与之前的图,可以看到变化更加剧烈了。

那么,是不是我们总是使用 irate 比较好呢?也不是,比如 requests/s 这种,如果变化太剧烈,从面板上你只能看到一条剧烈抖动导致看不清数值的曲线,而具体值我们是不太关心的,我们可能更关心一天中的 QPS 变化情况;但是像是 CPU,network 这种资源的变化,使用 irate 更加有意义一些。

还有一个函数叫做 increase,它的计算方式是 end - start,没有除。计算的是每分钟的增量。比较好理解,这里就不画图了。

这三个函数接受的都是 Range Vector,返回的是 Instant Vector,比较常用。

另外需要注意的是,increaserate 的 range 内必须要有至少 4 个数据点。详细的解释可以见这里:What range should I use with rate()?

介绍了这两种类型,那么其他的 Prometheus 函数应该都可以看文档理解了。Prometheus 的文档中会将函数这样标注:

changes()
For each input time series, changes(v range-vector) returns the number of times its value has changed within the provided time range as an instant vector.

我们就知道,changes() 这个函数接受的是一个 range-vector, 所以要带上类似于 [1m] 。不能传入不带类似 [1m] 的 metrics,类似于这样的使用是不合法的:change(requests_count{server="server_a"},这样就相当于传入了一个 Instant Vector。

看到这里,你应该已经成为一只在 Prometheus 里面自由翱翔的鸟儿了。接下来可以抱着文档去写查询了,但是在这之前,让我再介绍一点非常重要的误区。

使用函数的顺序问题

在计算 P99 的时候,我们会使用下面的查询:

首先,Histogram 是一个 Counter,所以我们要使用 rate 先处理,然后根据 le 将 labels 使用 sum 合起来,最后使用 histogram_quantile 来计算。这三个函数的顺序是不能调换的,必须是先 ratesum,最后 histogram_quantile

为什么呢?这个问题可以分成两步来看:

rate 必须在 sum 之前。前面提到过 Prometheus 支持在 Counter 的数据有下降之后自动处理的,比如服务器重启了,metric 重新从 0 开始。这个其实不是在存储的时候做的,比如应用暴露的 metric 就是从 2033 变成 0 了,那么 Prometheus 就会忠实地存储 0. 但是在计算 rate 的时候,就会识别出来这个下降。但是 sum 不会,所以如果先 sumrate,曲线就会出现非常大的波动。详细见这里

histogram_quantile 必须在最后。在《P99是如何计算的?》这篇文章中介绍了 P99 的原理。也就是说 histogram_quantile 计算的结果是近似值,去聚合(无论是 sum 还是 max 还是 avg)这个值都是没有意义的。

 

以上都是个人理解,可能存在错误,请在评论批评指正。

最后推荐一个入门的视频,讲得非常好:https://www.youtube.com/watch?v=hTjHuoWxsks

 

Build 一个最小的 Redis Docker Image

好吧,我承认这么说可能违反了广告法,但是……它确实挺小的。

可以对比一组数据:

  • 官方的 Redis 镜像:105MB
  • 官方的基于 alpine 的 Redis 镜像:32.3MB
  • 在 ubuntu 下面用默认配置 Build 出来的 redis-server binary:13M
  • 一个什么都没有的 alpine:latest docker 镜像:5.6MB
  • 上图中的 Redis 镜像:1.69MB

所以……可以说,确实挺小了。

当然生产环境肯定不差 100M 这点空间,还是带上一些常用的工具在生产环境跑比较好。本文只是在玩 Nix 时候的自娱自乐,没有什么实际意义。

这个镜像是用 Nix build 的,实际上,就是玩了一下 Nix 网站上的 Cover Demo。用到的手段有:

  1. 使用 Nix 来 build 这个 image;
  2. 编译的时候关闭了 systemd 的支持,Docker 里面不需要这种东西;
  3. 使用 musl 静态链接编译;
  4. 把除了 redis-server 之外的东西删除掉;
  5. 编译完成使用 strip -s 对最终的 binary 再次做删减。

具体的编译方法

首先在 Nix 创建一个文件,来编译 Redis,这里实际上使用的 Nix 打包好的 Redis,我只是对其通过 preBuildpostInstall 做了一些操作,替换 musl 和 strip 之类的。

然后再需要一个文件描述如何 build docker image:

非常简单,以至于不需要解释。

然后运行下面这个命令,build 就可以了。因为我已经 build 好了,所以 Nix 不会再出现 build 的日志。

Docker (容器) 的原理 曾经解释过,Docker image 本质上就是一个 tar 包。我们使用 docker load -i ./result 可以 load 这个 image。然后就可以运行了:

可能你发现了这个 image 有一些奇怪:Created 51 years ago. 其实这是对的。因为 Nix 号称是完全 reproducible 的,但是 image 如果有一个创建时间的话,那么每次 build 出来的产物都会因为这个创建时间,而导致每次的产物 hash 都不一样。所以 Nix 将 Docker image 产物的 Created 时间设置成 0 了。即 timestamp = 0.

看看这个镜像里面都有什么?

读者可以在 Docker hub 上下载这个镜像,然后使用 docker save 将它保存成 tar 再解压,看看里面都有什么。我这里直接去解压 Nix build 好的 image,每一层 layer 下面的 layer.tar 也都解压到对应的 layer 下面了。

可以看到,里面一层只有一个 redis-server 的 binary,上面一层是一个 bianry 的符号链接。符号链接是 Nix 的逻辑,符号链接很小,就懒得去删除了。

体验这个镜像

我把这个镜像放到了 Docker hub 上。可以直接运行 docker run laixintao/redis-minimal:v1 redis-server 来体验一下。

 

Docker 在 NixOS 里面的安装可以参考这里

 

警惕复用的陷阱

最近经历了人生的低谷。原因是在重构一些 Ansible 的部署脚本,Ansible 是本着声明式的理念世界的,但是这些脚本让我看的怀疑人生了。我开始思考这些脚本为什么会这么写。

脚本的作用非常简单,就是部署一些用 Go 写的程序。因为 Go 的程序依赖非常简单,只有一个 Binary,扔到机器上就能跑。

将问题简化一下,比如有三个服务,它们部署的过程分别如下。

服务A:

  1. 上传 binary;
  2. 上传配置文件;
  3. 上传 systemd 文件;
  4. 启动服务

服务B:

  1. 上传 binary;
  2. 上传配置文件;
  3. 新建一些目录;
  4. 上传 systemd 文件;
  5. 启动服务;

服务C:

  1. 上传 binary;
  2. 安装 Nginx;
  3. 上传 Nginx 配置文件;
  4. 启动 Nginx;

当前的部署脚本,总流程(Ansible 中的 Playbook)只有一个。这三个服务都将通过一个过程部署,传入一个 service_name 来区分要部署的是哪个服务。其简化过程如下:

  1. 上传 binary,binary 的名字就是 service_name
  2. 上传配置文件,对应的 {{service_name}}.yaml
  3. 通过变量检查是否要创建目录,如果是,就执行创建,否则跳过;
  4. 检查本地的配置文件中,对应 service_name 下面是否有 xx_extra,如果有,则需要上传;
  5. 检查 systemd 文件,是否需要特殊处理,如果是,执行特殊处理;
  6. 上传 systemd 文件;
  7. 检查是否有安装额外软件,如果有(比如 Nginx),就安装;
  8. 额外的软件;
  9. 启动服务。

本来部署的代码是可以写成:

现在却变成了:

看起来,三个服务能够共用一个代码了,但实际上,在每一个 common 里面,都是一些:

披着一层 “复用” 的外衣,徒增了复杂度。

为什么要重构呢?因为这些脚本完全可以一个服务写一套,这样的复用没有任何意义。假如由于业务的需要,我们在安装服务C的时候需要特殊设置一个 log rotate 进程的话,需要这么做:

  1. 先在总的流程里面加一个 common config log rotate;
  2. 然后在这个 common config log rotate 里面添加一个判断:
    1. 如果是在安装服务C,就执行对应的操作;
    2. 否则跳过;

这样的 if 越来越多,这套脚本就变成今天这样难以维护了。

为什么说这种复用是虚假的复用呢?Ansible 的逻辑里面,是可以支持复用的,但它的复用一般是将公共的部分封装,比如使用一个 role 来安装 Docker. 里面也有各种 if 来判断是什么 Linux 发行版,但是这种 if 是符合语义的:使用这个 role 可以帮你安装好 Docker,我在里面判断如果是 CentOS 就要这么安装,如果是 Ubuntu 就需要执行这些,等等。这是在“将复杂留给自己,将方便给用户”。当我使用他这个 role 的时候,我只要 include 进来,然后设置几个变量就好了。但是回到我们现在这个情况,此项目的复用没有带来任何简单。如果来了一个新的服务需要部署,我不可能直接使用原来的代码,必须要修改主流程,并且在原来的代码中添加 if

这几个服务本身就是在干不同的事情,完全不应该共用一套逻辑。虽然项目刚开始的时候看起来部署的流程差不多,但是看起来差不多并不意味着就有关系。随着业务发展,不同的项目必定或早或晚出现特殊的配置。

我感觉这种味道的代码非常常见,在业务的代码中经常见到不同的函数复用了一个公共的函数,这个公共函数里面又充满了各种 if 去判断调用者是谁,然后根据不同的调用者去执行不同的逻辑(等等,这不会就是 Java 常说的控制反转吧!)。怎么能知道什么是合理的复用,什么是不合理的呢?其实你看到就知道了,从代码中你能看到作者在“绞尽脑汁”想着怎么搞点抽象出来。

写这篇文章是今天在听《捕蛇者说 EP 29. 架构设计与 12fallacy(上)》的时候听大家谈到了应该警惕 code reuse, 表示深有同感。

那么,怎么能知道什么时候要 reuse,什么时候不要呢?我也不知道,但是感觉有几个思路是可以参考的。

  1. 多读文档,多学习。如果知道 Ansible 是声明式的写法的话,就不会使用这么多变量控制状态。使用任何一种工具,都需要阅读文档,理解这个工具的想法;
  2. 代码应该以人能读懂为首要目标。逻辑上没有关系的事情就不应该放在一起。人能读懂的代码应该可以经常被修改,能够经常被修改的代码必须足够简单。

 

另外,这一期嘉宾提到了德雷福斯模型,非常有趣:

德雷福斯模型(Dreyfus model of skill acquisition),将一个技能的学习程度类比成阶梯式的模型。由上而下分成:专家、精通者、胜任者、高级新手、新手五个等级。

各等级含意如下:

  • 专家:凭直觉做事。
  • 精通者:技能上:能认知自己的技能与他人差异,能透过观察别人去认知自己的错误,形成比新手更快的学习速度。职位上:能明确知道自己的职位在整体系统上的位置。
  • 胜任者:能解决问题。
  • 高级新手:不愿全盘思考。统计资料显示,多数人落在这个层级;当管理阶层分配工作给高级新手,他们认为每项工作一样重要,不明了优先层度,意味着他们无法认知每件工作的相关性。因此管理者认清,工作需给高级新手时,必须排列优先级。
  • 新手:需要指令才能工作。

所以,努力让自己成为一个专家吧,拥有什么时候应该 Reuse 的“直觉”。


2021年07月09日更新一些内容:

Goodbye, Clean Code 描述了和本文很类似的一种“过度的”抽象。

The Wrong Abstraction 描述了一种在显示工作中更常见的一种情况:

  1. Programmer A sees duplication.
  2. Programmer A extracts duplication and gives it a name.This creates a new abstraction. It could be a new method, or perhaps even a new class.
  3. Programmer A replaces the duplication with the new abstraction.Ah, the code is perfect. Programmer A trots happily away.
  4. Time passes.
  5. A new requirement appears for which the current abstraction is almost perfect.
  6. Programmer B gets tasked to implement this requirement.

    Programmer B feels honor-bound to retain the existing abstraction, but since isn’t exactly the same for every case, they alter the code to take a parameter, and then add logic to conditionally do the right thing based on the value of that parameter.

    What was once a universal abstraction now behaves differently for different cases.

  7. Another new requirement arrives.
    Programmer X.
    Another additional parameter.
    Another new conditional.
    Loop until code becomes incomprehensible.
  8. You appear in the story about here, and your life takes a dramatic turn for the worse.