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.

 

开源软件源码编译指南

之前在博客介绍了 Linux From Scratch, 最近发现 LFS 已经有新的版本 10.1 了,周末打算重新编译一下。

LFS 在首页推荐了两个教程,可以学习编译软件的一些基础常识,一个是 TLDP 的 Building and Installing Software Packages for Linux另一个是 Beginner’s Guide to Installing from Source,相当于是 TLDP 的简略版。边学习边记了一些笔记和总结。那这篇博客,就相当于是简略版的简略版吧。

软件的开发和发行

一个操作系统除了内核,还需要成千上万的软件才能真正去完成一些工作。专有系统一般会帮你管理这些软件,开源系统的软件则是由世界各地的开发者贡献的。这些软件通常不仅可以在 Linux 上使用,只要版权允许,也可以在 BSD 等系统使用,如果版权允许,有些还可以在专有系统运行。比如 Mac, Solaris 系统等。

开源软件发行中有两个常见的术语:

  • 上游(Upstream): 通常指的是软件的原作者;
  • 下游(Downstream): 通常指的是发行版,和用户;

大部分的开源软件都会开放一个版本控制系统管理的代码,任何人可以匿名下载代码,比如从 Github clone. 使用版本管理软件下载代码包含软件的开发历史(commit),可能会比较慢,软件在发布(release)的时候通常会将代码打包提供下载,这样下载就不包含代码提交历史,所以下载会更快一些,也能给世界节省一些带宽,减缓全球气候变暖。也有一些软件没有开放版本控制,只有定期打包开放的代码,这种就只能下载不带历史的源代码了。

发行版,Binary,和编译

Linux (以及其他的操作系统)有很多的发行版,这些发行版所帮你做的一个重要的事情就是给流行的软件打包。让你可以直接从 apt/dnf 等 package manager 下载安装软件。这些 package manager 有些是直接分发 binary 的,这意味着你在安装的时候直接下载下来放到 $PATH 下面就好了,并不需要自己编译。

Package manager 带来的好处是:

  • 方便:要找什么软件直接从它们提供的 repository 下载就好了;
  • 安全:package manager 在下载的时候会自动校验 md5,发行版的开发者也会时刻关注安全问题,所维护的打包软件会及时地做安全更新;

但是另一方面,发行版要维持一定的稳定性。比如你在特定版本的 Ubuntu 上,从官方的 Repository 就只能安装到特定版本的 Python,而不能安装最新的。(当然也有发行版的策略是永远提供最新版的软件。)

在这种情况下如果你想使用最新版的软件,就需要自己编译了。

另一种可能需要自己编译软件的情况是定制。一般开源软件会提供一些参数,比如删掉不必要的功能。这些定制都是要在编译阶段配置的,发行版要兼顾大部分的用户,所以一般都会尽量打开所有的 Feature,如果你需要特定的配置,就需要自己编译了。

自己编译软件的时候也要注意不要搞乱了你的 package manager, 后面可能出现意想不到的错误。最好的方法是阅读对应的 package specification 然后使用 package manager 来编译和安装软件。

源码下载和安全

一般的软件会提供 http 服务或者 ftp 服务让大家下载。大部分人应该都会从网上下载东西,在浏览器里面使用右键,另存为,就可以了。在终端上可以使用 wget 程序或者 curl 来下载。

除了从官网下载之外,一般还会有很多 mirror,其实就是散步在世界各地的备份。这样一来可以备份软件,而来可以让用户就近下载,节省原服务器的带宽,也节省整个世界的带宽。

tarball

之前介绍过文件系统。可以知道,不同的文件系统对于目录(文件夹)和文件的组织方式并不相同。所以在互联网上传输的一般是打包成一个的文件而不是文件夹。

文件打包说白了就是将所有的文件 append 到一个文件中,然后创建一个 “index”,里面包含了所有的文件信息,比如每个文件的名字,offset,length,index 等等,方便不必解包就可以知道里面都有什么,甚至可以从中提取出来某一个文件而不必完全解包。

tar 程序是最常见的打包程序(参考之前的这篇博客)。名字的意思是 “tape archive”,虽然现在文件已经都不存储在 tape 上,而是 disk 上了…… 用 tar 打包的文件通常叫做 tarball. 用 tar 打包可以保留操作系统的权限信息,以及文件的 owner id 等信息。

因为源代码都是文本,压缩比率非常高,很适合压缩之后传输。tar 仅仅是打包,一般打包之后还会进行压缩。常见的压缩有:

  • gzip: 文件后缀是 .tar.gz 表示是一个使用 gzip 压缩之后的 tarball
  • bzip2: 后缀一般是 .tar.bz2
  • xz: 后缀是 .tar.xz

tar 现在的版本已经非常智能了,可以自动识别文件然后调用对应的解压工具进行解压,比如这么使用:

但是 tar 是一个非常古老的程序了,是否支持自动解压取决于版本,参数的使用也取决于版本。本着 Unix 的哲学:一个工具只做一件事情。我比较喜欢这么用,解压交给解压工具,解包交给解包工具:

tar 在解压的时候,如果在当前目录存在重名文件,会直接覆盖掉。一般在软件打包的时候,会将所有的内容放到一个目录内,这样你解包的时候所有的内容也会都在一个新的目录内。但不排除有些软件在打包的时候没有这么做,所以盲目地解压网上下载回来的软件可能搞乱你的当前目录。有两种方法可以避免:

一个在解压之前先看看里面都有些啥:

另一个方法是创建一个新的目录,在里面解压:

解压的时候最好不要盲目地使用这些参数,这些参数可以改变解压的目标目录,很可能覆盖你的系统上的文件:

Windows 和 DOS 上经常用的一种压缩格式是 zip. 可以理解为 zip 是 tar + gzip. zip 不会保留文件系统的权限信息。对应的解压工具是 unzip.

即使使用 tar 解压,如果要保留文件的 owner 信息的话,需要使用 root 用户解压才行。但是有这种需求的情况非常少。非常不推荐使用 root 解包文件!这样可能覆盖重要的系统文件。

需要注意的是,http 服务和 ftp 服务都可能被劫持(即使使用 https 也有被劫持的可能),mirror 上存放的软件可能被篡改。所以下载回来的软件在安装之前一定要验证!

使用 md5sum 校验文件

大部分的文件下载都会提供一个 md5 校验码,我们将文件下载回来之后,只要将文件计算一个 md5sum,然后和官方提供的 md5 对比就可以了。注意这个 md5sum 必须从官方渠道获得,不能从 mirror 获得,否则就没有意义了。

以 Python 为例,在这个下载地址( https://www.python.org/downloads/release/python-395/ ) 有提供 md5 码:

下载这个 XZ compressed source tarball 然后校验:

md5 一样,就可以认为文件是和官方完全一致的。

有些软件要下载多个文件,会提供一个 checksum-file, 可以使用这个命令,会自动根据 checksum-file 里面的文件逐个检查 md5:

使用 GPG key 校验文件

有些软件是使用 GPG key 签名来证明软件来源。作者用自己的 key 对文件签名,然后将文件与签名文件一同发布,用户需要下载四个东西:

  1. 作者的 public key
  2. 作者的 gpg key 指纹,证明 public 的来源真实
  3. 软件包
  4. 软件包的签名

校验的步骤是:

  1. 导入作者的 public key
  2. 检查 public key,确认指纹,然后信任此 key
  3. 通过软件包的签名来验证软件包

这里依然以上面的 Python 源码包为例,来通过 gpg key 进行校验。

第一步,找到作者的 public key。在 https://www.python.org/downloads/ 可以看到,Python 3.8 和 3.9 的 Release Manager 是 Łukasz,我们根据这个 key 的地址将这个 key 下载下来。

Python 官方开发者的 gpg key



然后确认这个 key 的指纹,与作者公布的指纹做对比。发现一致,即可信任。

Łukasz 公布在 keybase 上的 GPG public key



或者可以直接通过指纹 sign 这个 key,比上面的步骤要简单一些,是等效的。

最后,就可以下载代码以及 .asc 后缀的签名文件了。

阅读文档!

源码文件在解压之后,一般在最上层的目录下会有 README 或者 INSTALL 类似的文件,这是安装的文档说明。解压之后需要做的第一件事就是阅读这些文件。

文档中会有两个地方比较重要:

  1. 编译和安装这个软件需要的依赖,如果没有依赖就安装这个软件,要么编译有问题,要么会运行有问题;
  2. 软件编译的时候接收的一些参数;

Patching

某些软件可能在一些环境下无法正常工作,如果软件流行的话,在网上搜一下解决办法,一般可以找到一些人发布的解决办法。通过修改一些参数或者代码就可以了。叫做 Patch。

Patch 有两种:

  1. 修改脚本:比如 sed/awk 脚本,运行这些脚本可以修改源代码。例子
  2. Patch 文件:diff 的形式,人类可读。需要使用 Patch 程序去“应用”这个 diff,即“让这个 diff 生效” 例子

常见的编译系统

简单点说,编译就是将下载回来的代码变成可以在操作系统执行的 binary 文件。所以分成两步:

  1. 编译(make)
  2. 将编译好的文件放到系统的 $PATH 下面,这样可以在任意路径执行软件 (make install)

作者会将编译所用到的一些文件都放在 tarball 里面。同时,作者会希望有更多的人使用他的软件(谁不想呢?),所以会尽量让编译过程变简单一些。大部分的软件会使用 make 软件作为编译的工具。

这里不展开讲 make 了,简单点说,你提供给 make 一个 makefile,里面按照 make 的格式告诉 make:“如果目标文件不是基于最新的源码,就执行这个动作”,其中,目标文件可以是最终的软件,也可以是中间的产物。make 会自动帮你解决好先后顺序问题。

然而,make 的描述能力有限。但是想要处理起来所有的配置项,也捉襟见肘。所以这里又出现了另外一个工具:configure. 通常,软件包不会直接包含一个 makefile,而是会包含一个 configure 脚本以及一个 makefile.in 模板文件。执行 ./configure 就可以生成一个 makefile 文件,然后再执行 make.

所以,一般的编译流程如下:

另外,configure 也不是手写的,是由另一叫做 autotools 的 GNU 软件生成的。但是这个不需要用户使用,而是由软件作者来生成好。

最后一步 make install 的作用是将编译好的 binary 复制到系统的 $PATH 下。除了这一步以外,其他的步骤最好都使用普通用户来执行。

总体的流程如下:

有些软件没有使用 configure 脚本。因为它们足够简单,将 configure 的逻辑直接写到 makefile 里面了。比如 Redis,就没有。安装的时候只要 make 就可以了。

configuremake 生成的产物可能会和源代码文件混合在一起,弄得比较混乱,可以使用 make clean 清理编译产物。

但是对于每次编译,都在一个临时文件夹里面进行,或许是一个更好的办法。

其他的一些编译方式

cmake 现在也比较流行了,cmakeconfigure 类似,会基于传入的参数生成一个 makefile。除了使用下面这个命令替换 ./configure 之外,其他的步骤基本一样。

再次提醒一下,编译软件之前先看文档是一个好习惯。

有一些软件使用基于 Python/Perl 的编译工具,这些具体就参考文档好了。

还有一些工具直接是脚本语言编写的,这样的软件不需要编译,只要将软件拷贝到解释器读取的目录即可。不过一般这些语言都会自带一些包管理工具。

环境变量

configure 接受的配置项可以通过两种方式指定。

一种是环境变量。

或者直接 export 到当前的 shell 中然后执行。

另一种是通过执行 configure 时候的命令行参数。具体支持的参数,可以通过 ./configure --help 来查看。

编译安装文档

软件附带的文档没有统一的形式,常见的一些有:

  • 提供在线的 HTML 形式的文档;
  • 提供标准的 archive file, 可以直接下载;
  • 单独提供文档下载,可以通过 man/info 等查看;
  • make installdoc 安装
  • ……

总之还是参考文档来安装文档吧!

错误排查

编译是会很容易出错的一个过程。发生错误的时候一般根据编译错误一步一步排查就好了。要点是不要乱猜,要根据事实去推测。

举个最近遇到的问题。在一台机器上编译 Python3 失败了。./configure 的错误提示如下:

根据提示去查看 config.log 日志文件,在文件中发现这么一段:

可以知道是 gcc 尝试测试编译一段简单的程序失败了。失败原因是 ld 不支持 -p 参数。

然后查看机器上的 ld 为什么不支持。

通过这几个命令可以看到这个机器上有很多个 ld ,而使用的 ldelfutils 里面的 ld,并不是 GNU 的 ld。编译的时候将 /usr/bin 这个路径提前就好了。

大部分错误的情况是缺失了一些依赖,喜欢走弯路的话可以去网上搜索缺失的文件的名字,运气好找到缺失的依赖安装了就好了,不喜欢走弯路的话可以参考安装文档。也有时候缺失的依赖是可选的,可以通过 ./configure 的参数忽略掉。

编译器一般有一些高级的参数可以优化性能,但是如果不是很了解自己在做啥的话,最好还是不要碰。(既然你还在读这篇文章,还是不要碰了吧。)

编译出来的文件一般会带有一些 debug 参考的信息,一般用户用不着,可以通过 strip 命令来剪掉。

这是在 Mac 系统上编译出来的 Python3.9,strip 之后少了0.8M.

不过今天的硬盘已经这么不值钱了,也没什么必要其实。

最后有些软件可能在安装之后需要一些特定的配置才能运行。还是那句话,读文档。

练习

看到这里,编译下来 LFS 基本就没什么问题了,可以按照 LFS 从头开始编译一些 Linux 的工具试试,这里列出了一个能工作的 Linux 需要的基本软件。

 

为什么 Cloudflare 提供免费的服务?

在我的博客切换到 Cloudflare 的 CDN 上之后,我发现速度快的惊人,原来 18s 才能加载完首页的全部内容,现在只需要 0.5s 左右就全部加载完了。在 Cloudlflare 的后台看到,我这个小博客每天消耗的 Cloudflare 的流量在 3G 左右。高的时候可能有 10G 了。

CDN 的流量费用一般是 $0.10/GB, 那么 Cloudflare 为什么能够给用户提供免费服务呢?在网上找到了一个 2016 年的回答(有趣的是,这个回答里面说 CDN 的流量是 $0.10/GB, 5 年之后的今天这个价格似乎没怎么变化),列出了 Cloudflare 能这样做的 5 个原因:

  1. 数据。有了广泛的免费用户,Cloudflare 就能获得更多的数据,从而为付费用户提供更好的保护服务;
  2. 客户推荐。很多 Cloudflare 的免费用户后来将 Cloudflare 带到了工作中。很多头部客户都来自于客户雇员使用了 Cloudflare 的免费服务,而向公司推荐;
  3. 人才推荐。Cloudflare 希望雇佣世界上最聪明的人。很多企业 SaaS 服务提供者都在招人方面花费了很多钱。Cloudflare 没有这么多的投入,但是也有源源不断的人才推荐。在 2015 年,Cloudflare 的面试雇佣率是 1.6%, 与一些最大的 ToC 互联网公司相当;
  4. QA. Cloudflare 面临的一个问题是,如何在产品级别的规模下测试软件的质量。现在,当有新的功能的时候,他们一般先推向免费用户(实际在我使用的过程中,发现 Cloudflare 有一些 Feature, 作为免费用户,是无法关闭的)。很多志愿者会测试这些 Feature 并提交 bug. 这让 Cloudflare 产品的迭代速度比一般的 SaaS 公司都要快;
  5. 带宽鸡与蛋的问题。通常,为了得到更便宜的带宽,需要购买更大的规模,但是要从付费用户那里获得大规模使用,你首先要给他们极具竞争力的价格。免费用户解决了这个先有鸡还是先有蛋的问题。Cloudflare 用户的多样性让他们有了很大的筹码,可以让他们去和各地的电信服务商谈判,做流量对等交换协议。极大降低了带宽成本。

今天(2016年),Cloudflare 的毛利率超过了 70%, 即使大部分客户没有付钱,他们依然可以实现收支平衡。

Cloudflare 创办于 2009 年,已经在去年(2020年)上市,现在市值 327 亿美金。上面这个回答,来自于5年前,Cloudflare 的 CEO Matthew Prince.