Hi,欢迎来到 卡瓦邦噶! 我叫 laixintao,我有一种特别的幽默感。我的梦想是让网络变得更加开放、自由和快速。这个博客发布我的一些想法,编程相关的文章,以及书、电影、游戏和音乐。

声明:本博客内容仅代表本人观点,和我的雇主无关。本站所有内容未加说明均为原创,一经发布自动进入公有领域,本人放弃所有权利。

P99 是如何计算的

Latency (延迟)是我们在监控线上的组件运行情况的一个非常重要的指标,它可以告诉我们请求在多少时间内完成。监控 Latency 是一个很微妙的事情,比如,假如一分钟有 1亿次请求,你就有了 1亿个数字。如何从这些数字中反映出用户的真实体验呢?

之前的公司用平均值来反应所有有关延迟的数据,这样的好处是计算量小,实施简单。只需要记录所有请求的一个时间总和,以及请求次数,两个数字,就可以计算出平均耗时。但问题是,平均耗时非常容易掩盖真实的问题。比如现在有 1% 的请求非常慢,但是其余的请求很快,那么这 1% 的请求耗时会被其他的 99% 给拉平,将真正的问题掩盖。

所以更加科学的一种监控方式是观察 P99/P95/P90 等,叫做 Quantile。简单的理解,P99 就是第 99% 个请求所用的耗时。假如 P99 现在是 10ms, 那么我们可以说 “99% 的请求都在 10ms 内完成”。虽然在一些请求量较小的情况下,P99 可能受长尾请求的影响。但是由于 SRE 一般不会给在量小的业务上花费太多精力,所以这个问题并不是很大。

但是计算就成了一个问题。P99 是计算时间的分布,所以我们是否要保存下来 1亿个请求的时间,才能知道第 99% 的请求所用的时间呢?

这样耗费的资源太大了。考虑到监控所需要的数据对准确性的要求并不高。比如说 P99 实际上是 15.7ms 但是计算得到数据是 15.5ms,甚至是 14ms,我认为都是可以接受的。我们关注更多的是它的变化。“P99 耗时从 10.7ms 上涨到了 14ms” 和 “P99耗时从 11ms 上涨到了 15.5ms” 这个信息对于我们来说区别并不是很大。(当然了,如果是用于衡量服务是否达到了服务等级协议 SLO 的话,还是很大的。这样需要合理地规划 Bucket 来提高准确性)。

所以基于这个,Prometheus 采用了一种非常巧妙的数据结构来计算 Quantile: Histogram.

Histogram 本质上是一些桶。举例子说,我们为了计算 P99,可以将所有的请求分成 10 个桶,第一个存放 0-1ms 完成的请求的数量,后面 9 个桶存放的请求耗时上区间分别是 5ms 10ms 50ms 100ms 200ms 300ms 500ms 1s 2s. 这样只要保存 10 个数字就可以了。要计算 P99 的话,只需要知道第 99% 个数字落在了哪一个桶,比如说落在了 300ms-500ms 的桶,那我们就可以说现在的 99% 的请求都在 500ms 之内完成(这样说不太准确,如果准确的说,应该是第 99% 个请求在 300ms – 500ms 之间完成)。这些数据也可以用来计算 P90, P95 等等。

由于我们的监控一般是绘制一条曲线,而不是一个区间。所以 P99 在 300-500 之间是不行的,需要计算出一个数字来。

Prometheus 是假设每一个桶内的数据都是线性分布的,比如说现在 300-500 的桶里面一共有 100 个请求,小于300个桶里面一共有 9850 个请求。所有的桶一共有 1万个请求。那么我们要找的 P99 其实是第 10000 * 0.99 = 9900 个请求。第 9900 个请求在 300-500 的桶里面是第 9900 – 9850 = 50 个请求。根据桶里面都是线性分布的假设,第50个请求在这个桶里面的耗时是 (500 – 300) * (50/100) = 400ms, 即 P99 就是 400ms.

可以注意到因为是基于线性分布的假设,不是准确的数据。比如假设 300-500 的桶中耗时最高的请求也只有 310ms, 得到的计算结果也会是 400ms. 桶的区间越大,越不准确,桶的区间越小,越准确。


写这篇文章,是因为昨天同事跑来问我,“为啥我的日志显示最慢的请求也才 1s 多,但是这个 P999 latency 显示是 3s?”

我查了一下确实如他所说,但是这个结果确实预期的。因为我们设置的桶的分布是:10ms, 50ms, 100ms, 500ms, 1s, 5s, 10s, 60s.

如上所说,Promtheus 只能保证 P999 latency 落在了 1s – 5s 之间,但不能保证误差。

如果要计算准确的 Quantile, 可以使用 Summary 计算。简单来说,这个算法没有分桶,是直接在机器上计算准确的 P99 的值,然后保存 P99 这个数字。但问题一个是在机器本地计算,而不是在 Prometheus 机器上计算,会占用业务机器的资源;另一个是无法聚合,如果我们有很多实例,知道每一个实例的 P99 是没有什么意义的,我们更想知道所有请求的 P99. 显然,原始的信息已经丢失,这个 P99 per instance 是无法支持继续计算的。

另外一个设计巧妙的地方是,300-500 这个桶保存的并不是 300-500 耗时的请求数,而是 <500ms 的请求数。也就是说,后面的桶的请求数总是包含了它前面的所有的桶。这样的好处是,虽然我们保存的数据没有增加(还是10个数字),但是保存的信息增加了。假如说中间丢弃一个桶,依然能够计算出来 P99. 在某些情况下非常有用,比如监控资源不够了,我们可以临时不收集前5个桶,依然可以计算 P99.

 

使用 mtr 检查网络问题,以及注意事项

在检查两个 IP 之间的网络情况的时候,常用的工具有两个:ping 可以检查两个 IP 之间通不通,以及延迟有多少;traceroute 可以检查从一个 IP 到另一个 IP 需要经过哪些 hop。

mtr 将这两者结合了起来:使用 traceroute 将两个 IP 之间需要经过的 hop 找出来,然后依次去 ping 这些 hop,就可以看到当前的 IP 到所有的这些 hop 的延迟和丢包率,这样在某些情况下就可以诊断出来丢包和延迟发生在哪一个节点上。

mtr 的安装和使用非常简单,和 ping 类似,只要执行 mtr <ip> 命令,就可以得到如下的界面:

这里展示的是从一台 DigitalOcean 的机器上到 1.1.1.1 这个 IP 的每一个 hop 信息。这个界面非常简单易懂,列出了从当前 IP 到目标 IP 之间要经过的 hop,中间的丢包率和 ping 出来的延迟。这个界面和 htop 一样,可以调整 Display Mode, fields 的显示顺序等,按照界面提示操作即可。

mtr 在使用的时候有一些需要注意的地方。

中间节点探测结果不一致问题

经常会看到中间某些节点丢包率比后面的节点还要高,这可能是中间节点对 ICMP 协议限速,导致中间节点可能看到 packet loss, 但是后面的节点没有,或者后面节点 loss 的数量比前面少。这种情况下,永远相信后面的节点。原理很简单,mtr 和 traceroute 的原理类似,都是发送 TTL=1,2,3,4,5… 的包探测出 IP 包的路由节点,然后去 ping 这些节点。所以这里的丢包率是从本地依次 ping 这些节点的丢包率。假如中间某个节点发生了丢包,那么它后面的节点一定会丢包,因为后面节点要可达必须经过中间的节点。像上面图中那种情况,第 2 个节点有 10% 的丢包率,后面的反而没有,说明节点 2 并不是真正地丢包,只是对你的 ping 丢了包,实际的包没有丢。

如果发生真正的丢包,会是这样子:

从某一个节点开始,后面的节点都会发生丢包。

但是其实也可以看到,节点 3 和 4 5 比后面的节点的丢包率要高,说明真实的丢包率只是 40%. 即丢包率以后面的节点为准

Latency 也同理,可能看到中间节点的 latency 比后面的要高。很显然,如果它 latency 真的高,那么后面节点的 latency 不能比前面节点的 latency 还小。所以 latency 不一致的情况下,以后面节点的为准。

来回路径不一致的问题

发送过去的包的路由,和返回的包的路由,并不总是一致的。所以如果有条件的话,最好从两端都使用 mtr 进行诊断,或许会发现不一样的线索。

但是对于 Latency 来说,如果两边的路由一致,但实际只有在一边去向另一边的时候有延迟,那 mtr 是无法检测出来的。因为 mtr 本质上是用 ping 来检测延迟,ping 只能得到来回总共的时间,不能得到单边的时间。

中间出现 ??? 的情况

如上图,第一个节点。有时候 mtr 的报告中会出现 ??? 的标志,这是因为 traceroute 拿不到中间节点的信息,一般是因为这个节点被设置成不回应 ICMP 包,但是能够正常转发包。所以这种情况下即不能拿到 IP,也无法测试丢包率,延迟等。

使用 tcp

现在的 mtr 也支持通过 tcp ping 了,发送 SYN 包进行探测,但是很多设备不会回应 TCP 包,所以会看到大量的 packet loss, 带来很多误导。

不要去排查每一个网络问题!

不要去试图明白每一次丢包背后的原因。网络协议本身就是设计成有很多容错和降级的。任何时候都有可能发生路有错误,网络拥塞,设备维护等问题。如果 mtr 显示丢包率有 10%,一般不会有什么大问题,因为一般的上层应用协议都会处理好这部分丢包。如果去排查每一次网络丢包问题,只会徒增人力成本。

(真希望我亲爱的开发同学们能理解这一点)

 

参考资料:

  1. Diagnosing Network Issues with MTR
  2. Traceroute: Finding meaning among the stars
  3. Understanding the Ping and Traceroute Commands
 

在终端快速选中上一个命令的输出内容

在终端里面一个非常日常的操作,就是选中上一个命令的输出内容。比如使用文本处理程序处理一些服务器的 IP,最后得到一个结果,将这个结果复制给同事,或者粘贴到工单系统中;在终端上使用 date 程序转换日期的格式,最后要将这个日期复制到别的地方去使用;比如最常见的一个操作,使用 date +%s 命令得到当前的时间戳,然后复制这个时间戳。如果你使用终端的话,基本上每天都要重复这个操作几十次。

本文就来讨论这个最简单的操作:复制上一个命令的输出结果。

虽然是一个看似很简单的操作,但是我却为了如何能在这个操作上节省几秒钟苦苦思索了多年。也发现了很多人同样在寻找一个方法来高效地执行这个操作。这篇文章将会讨论几种方法来实现这个动作,虽然最后我使用的方法并不是我发明的。发明它的人也同样花了很长的时间(按作者原话说 “Look, it’s still quarantine, okay?”),所以背后的奇技淫巧和神奇的思路也同样精彩!希望这篇文章能给读者每天节省几秒钟,也能在阅读的过程中带来一些乐趣。

笨蛋的方法

这是最显而易见的一种方法。为了复制上一个命令得到的内容,我们要将右手拿开,放到鼠标上,选中文本,然后按下 Command + C (在其他的系统上是别的按键),将内容复制到剪切板里面。

也同样显而易见,这么做太浪费时间了。首先,所有需要将手从键盘上拿开的操作都是浪费时间的;其次,选中操作也不是那么简单,开头和结尾需要定位两次。使用键盘是一个 0 或 1 的操作。按下了就是按下了,没有按就是没有按,闭上眼睛也能操作。而鼠标需要精确地定位,闭上眼睛是绝对无法完成的。如果遇到要复制的命令有很长的输入(比如要复制一段 cat info.log | grep code=404 | tail 的日志输出),那么要同时使用鼠标进行定位和翻页,变成了一个超高难度的动作。

这样增加心智的东西,太反人类了。

朴素的方法

Unix 系统里面的管道真是一个伟大的发明!因为在终端里面程序的输出是一个 stdout, 所以理论上,我们就可以使用一个程序,将它的 stdout 导入到系统的剪切板里面去。比如在 Mac OS X 上面可以使用 pbcopy 将程序的输出内容导入到剪切板中,然后使用 pbpaste 粘贴出来,或者直接使用 Command + V 粘贴。

在其它的 Linux 系统中也可以做到类似的事情,比如 xselxclip 。其实原理非常简单,只需要调用系统提供的剪切板相关的 API,将 stdin 的内容写入到进去就好了。

类似这样使用管道的工具还有很多,比如 fpp 工具。可以自动地识别出来 stdout 中的 file path,然后提供一个 GUI 让你选择文件,按下 Enter 打开。比如 git stat | fpp 这样用。

这种方法的优点是可靠,不涉及鼠标操作。虽然也并不是特别高效,因为要敲很多字母(不过可以使用 alias)。

这类使用管道的最大的缺点就是不是所见即所得的。很多时候需要敲下命令,看到 stdout 确认没有问题,然后再敲一遍命令后面加上 | pbcopy 加到剪切板中,在遇到运行时间很长,或者需要消耗很大资源的时候,就有点不合适了。虽然可以一次性使用 tee 程序既输出到 stdout 又输出到 pipe 中,但是这样一来运行命令的心智负担又太大了。这么长的命令难以形成肌肉记忆,所以本质上来说,效率也算是特别高。

优雅的方法

另一个既简单又傻瓜的方法是使用 iTerm2 自带的功能,在 iTerm2 中选择 “Edit -> Select Output of Last Command” 即可选中上一条命令的输出,使用快捷键的话是 Command + Shift + A .

如果你看到这个选项是灰色的,说明你没有安装 shell 集成。在菜单栏选择 “Install Shell Integration” 即可,iTerm2 会帮你执行一个 Curl xxx | bash 来安装相关的依赖。

这种方法的优点是使用足够简单,一个快捷键就够了,而且这是选中+复制,并不需要再按下 Command + C。如果大部分时间使用的终端模拟器都是 iTerm2 的话,这个方法也足够了。

缺点也显而易见,这是 iTerm2 提供的功能,如果你要使用 Ubuntu,就不行了。另外,它的工作原理是,它知道你在 iTerm2 中运行的命令,所以可以捕获命令的输出信息。这样就带来一些很严重的问题,比如,如果你使用 Tmux 的,那么在 iTerm2 看来,无论你在 Tmux 里面开多少个 session 和 window, 对它来说都是一个程序,也就无法在 Tmux 里面成功捕获 stdout 了。

Tmux 可能有人不用,那还有一个场景应该无法避免,就是 ssh. 同理,你 ssh 到一台机器上去执行命令,对于 iTerm2 来说它都只看到一个 ssh 命令,所以如果这样复制的话,它会把你在 ssh 命令下看到的所有内容都复制下来。(其实上面提到的 pbcopy 同理,也无法在 ssh 远程机器上工作的。)

而要想在 ssh 下也工作,就必须不区别是在远程机器上执行的命令,还是本地执行的命令,从整个终端模拟器的 buffer 入手。使用正则匹配或许是个好的方法。

黑客的方法

由于没有一个方法能够省心省力地完成这个工作,我这几年来每天都过得郁郁寡欢。

某天在 hackernews 上看有人分享 Tmux 复制文本的操作方法,就点进去读了一下,稍微有些失望,因为这些东西我已经知道了。但是这时候网页突然载入完成了播客中的 gif,在 gif 中发现有一段竟然是在命令的 output 之间跳来挑去!这就是我苦苦寻找多年的东西!

在确认这并不是 Tmux 本身的功能之后,我发邮件问了作者是如何做到能在 Tmux 里面快速选择上一命令输出的。

没想到作者很快回复了我的邮件。

整个 idea 非常简单,使用一个脚本即可实现,只用到了 Tmux 自身的命令。核心思想是去复制当前 cursor 所在的 Shell Prompt 和上一个 Shell Prompt 之间的内容,使用 Tmux 的命令控制光标移动,选择文字。

脚本如下(现在作者有一篇博客,Quickly copy the output of the last shell command you ran ,很详细地介绍了这个脚本每一步都在干什么)。

bind -n 的意思是将这个操作绑定到 root key table,默认是绑定到 Prefix table,改成绑定到 root 的话,这个操作就不涉及按下 Tmux 的 Prefix key 了。S-M-Up 是 Shift + Option + Up 这三个键一起按下的意思,即将这三个键一起按下绑定成下面这个脚本。

然后这个脚本进入 copy-mode,先控制光标到行头。之后分成两个 block,首先看 if 不满足的下面的那个 block,基本上就是向前寻找之前的一个 Shell Prompt,如果找到了,就从这里开始复制,这样,两个 Shell Prompt 之间的内容就被选中了。再来看 if 里面的内容,意思是当前行如果有 Shell Prompt 的话,就直接复制整行。这样就可以做到,依次往上选中上一个 output,上一个命令, 再上一个 output,再上一个命令,…… 缺点就是只能支持向上选择,不支持向下选择。不过其实也够用了。if 里面的那个嵌套的 if 是处理 Tmux 在 vi 的 copy-mode 下的一个 Corner case, 详细的解释可以去看原文。

这里有一个很 triky 的地方,就是如果你的 Shell Prompt 的格式里面有空格的话,比如以 $␣ 来结尾,在 Tmux 的复制模式下,对于没有执行过命令的行,比如多按了几次回车,Tmux 会直接将这些行中 Shell Prompt 的空格删除,这样就造成我们的脚本无法匹配到空格。比如下面这个 Shell,复制模式下在 date 和 echo 命令中间的三行就没有空格了。

这里解决的方法是,将 Shell Prompt 最后的空格,改成 Non-breaking space, Unicode 码是 \u00A0 。(可以看到,上面的脚本匹配的其实就是这个 Unicode)。如果使用 Vim 的话,可以在输入模式下按下 Ctrl + V ,进入ins-special-keys mode, 然后依次输入 u 0 0 a 0,就可以输入这个 Unicode。

这样,对我来说几乎就是一个完美的方案了。如果去读作者的博客,就会发现这里面的坑实在太多了,Tmux 在 vi 的 copy mode 下的行为,去掉空格的行为,跳转行为(在行被 Wrap 的情况下必须执行两次 start-of-line 才能真正跳转到行头,等等。估计作者也是花了很多时间才写好这个脚本。

在 ssh 的情况下,理论上也可以做到,因为这个方法是针对 Tmux 显示的 buffer 进行操作。但是要改下这个脚本的匹配,因为远程的主机的 Prompt 可能和你本地电脑不一样。上面的脚本使用的 search-forward-text ,如果改成 search-forward 就可以按照正则搜索。

没有实现的方法

这个方法是我很久之前做的一个尝试,只不过到现在都还没有完成。

之前看到过这么一个项目:tmux-url-select。它能帮助你快速选择当前 Tmux 窗口中的 URL,复制或者打开。

我去看了一下代码,发现思路非常神奇。它是这么做的:

  1. 先 capture 下来当前窗口的全部内容;
  2. 打开一个新的窗口,覆盖掉了原来的窗口;
  3. 新的窗口其实是一个新的 GUI 程序,然后将老窗口的内容放上来,在这个程序内实现了选择、跳转、定义按键等工作;

由于用户实际上是进入了一个新的程序,但是这个新的程序通过修改 Tmux 窗口的名字,让用户还感觉自己在 Tmux 中一样。由于是一个新的程序,那它就可以不受限制的做到任何是事情了!

所以我看到这个东西,第一个想法就是将它 fork 过来,将选择 URL 改成选择上一个命令的 output. 实际上也应该是可行的。到现在没有完成的原因是…… 这个项目是 Perl 写的,Perl 看起来不像是人写的东西。

Ian (上文提到的作者)也说,思路很有趣,但是长期来看,不如花时间提升 Tmux 自身的 copy mode 收益更多。他准备提交 patch 给 Tmux.

其他的方法

在和作者 Ian 的交流中,他还告诉我其他一些他使用的工具,也非常实用。

tmux-thumbs 这个很有意思。这个就像是 vimium/vimperator 的操作模式一样,可以让你快速通过一些按键去选择当前 buffer 的文本块。

extrakto 和上面的工具类似,但是这个工具是用的 fzf 模式。可以通过模糊搜索,查找当前 buffer 出现过的文本,进行快速选中。但是好像不能复制出到剪切板。

 

 

在你家公司使用 Django Migrate

Django 自带一个成熟的 ORM,提供了数据库结构迁移的功能。通过两个命令可以很方便地执行表结构更新:

  1. python manage.py makemigrations 生成迁移的 Python 脚本;
  2. python manage.py migrate 将脚本转换成 SQL 来执行,并在数据库保存已经执行过的 migrations;

但是这种方便的方法在大公司基本上都没办法直接使用,因为一般是不允许直接让你从笔记本去使用具有 ALTER 权限的数据库用户连接数据库的。有些公司是有在线的平台,只能通过平台提交数据库变更,有些是只能 DBA 来执行数据库变更。

在这种情况下我们就无法使用 Django 自带的 migrations 的功能了。那么有哪些可选的替代方案呢?

第一种是自己手写 SQL 建表,来对应代码的结构声明。之前公司在 Java 中的方案就是这种,有很多弊端。ORM 很多时候都有 length 之类的定义,这种手动对应很容易让两边不同步,没有那边是 source of the truth. 不过好像大家的忍受能力比较高,比较喜欢这种手工工作。

第二种是直接使用声明式编程。意思就是你只要声明你需要什么 table 就可以了。其实 Django 的方案就可以认为是一种声明式的。不过“声明式数据迁移”的本质是语言无关的,你写两张 create table,这个工具就可以产生从 table 1 迁移到 table 2 的 alter SQL. 不过我只听说过,没有见过落地的这种工具。

退一步,有 1st1 的公司 EdgeDBPrisma 实现了一种通过 DSL 来完成的声明式,思想和 Django 是差不多的,声明式的数据库结构迁移。

第三种,就是只适用于 Django 的方案。Django 自带了一个命令,sqlmigrate, 可以从 migrations 的文件生成 SQL 语句。

但是,还有一个问题是怎么知道哪些 migrations 执行过了,哪些没有执行过。

在原生的 Django 方案中,这个问题是通过在数据库的一张表存储 migration 的文件名来解决的。

我们也可以通过命令查询。

在每次进行 python manage.py migrate 命令的时候,Django 就去查询数据哪些 migrations 是已经完成了的,然后只执行没有执行的。

由于无法直接连接生产环境的数据库,我们就需要其他的方法来找到没有执行的 migrations.

这里我使用的方法是通过代码来记录:

  1. 部署的时候,通过和上一次代码的 diff, 就可以找到新生成的 migrations, 执行这些 migrations;
  2. 每次部署代码,都执行所有没有执行过的 migrations;

这样,migrations 代码就可以作为 source of the truth.

步骤如下:

  1. 先通过 git 命令找出改动的 migrations 文件;
  2. 处理文件名,解析成 app migration 的格式;
  3. 通过 sqlmigrate 命令生成 SQL,以及回滚的 SQL;
  4. 得到 SQL 执行文件,去执行。

这个流程应该在多数的公司都可以行得通了。

使用 Makefile 可以写成以下的脚本:

执行的效果:

生成的数据库文件如下:

几点要注意的事情:

  1. 以前我习惯通过 migrations 来做数据迁移,但是现在这种形式显然是无法为数据迁移生成 SQL 的。所以数据迁移只能通过 SQL 来做了。不过问题也不大,我写 SQL 的功力已经提高了,大部分的逻辑都可以通过 SQL 写出来;
  2. 注意 database constrains 的问题。Django 要有办法为 contrains 命名,所以原则上要提供一个数据连接让 Django 知道现在有哪些 contrains 存在了。我们是禁用了外键约束的,如果你用的话,要注意名字的重复问题。

This requires an active database connection, which it will use to resolve constraint names; this means you must generate the SQL against a copy of the database you wish to later apply it on.

doc

 

好了,到此为止,就可以愉快地使用 Django 了。

 

Docker (容器) 的原理

第一次接触 docker 的人可能都会对它感到神奇,一行 docker run,就能创建出来一个类似虚拟机的隔离环境,里面的依赖都是 reproduceable 的!然而这里面并没有什么魔法,有人说 Docker 并没有发明什么新的技术。确实是,它只不过是将一些 Linux 已经有的功能集合在一起,提供了一个简单的 UI 来创建“容器”。

这篇文章用来介绍容器的原理。

什么是一个容器?我们从容器的标准开始说起。

一、OCI Specification

OCI 现在是容器的事实标准,它规定了两部分的标准:

  1. Image spec:容器如何打包
  2. Runtime spec:容器如何运行

Image Spec

容器的运行时是通过 Image 创建的,Image Spec 规定了这个 Image 里面要放什么文件。本质上,一个 Image 就是一个 tar 包。里面一般包含这些内容:

manifest 里面包含 config 和 layers,其中 config 包含以下内容的配置:

  1. 创建运行时(container)的时候需要的配置;
  2. layers的配置
  3. image 的 metadata

layers 就是组成 rootfs 的一些文件。base 层的 layer 有所有的文件,之后的 layer 只保存基于 base 层的 changes。在创建容器的时候需要打开这个 Image,先找到 base layer,然后将之后的 layer 一个一个地 apply changes,得到最后的 rootfs。

我们可以下载一个 Nginx 的 Docker Image 来看下里面都有什么。

首先 pull 下来 docker 的 image,然后将它保存为一个 tar 文件。

然后再把它解压开:

然后使用 tree 命令看下里面的结构:

打开 manifest.json 就会发现里面标注了 config 文件,以及 layers 的信息,config 里面有每一层 layer 的信息。

如果解压 layer.tar,就可以看到里面用于构建 rootfs 的一些文件了。

容器运行的时候,就依赖这些文件,而不依赖 host 系统上的依赖。这样就做到和 host 上面的依赖隔离。

Runtime Spec

从 Image 解包之后,我们就可以创建 container 了,大体的过程就是创建一个 container 然后在 container 中运行进程。因为有了 Image 里面的依赖,容器里面就可以不依赖系统的任何依赖。

容器的生命周期如下:

Image, Container 和 Process

  1. Containers 从 Image 创建,一个 Image 可以创建多个 contaners
  2. 但是在 Container 作出修改之后,也可以直接将里面的内容保存为新的 Image
  3. 进程运行在 Container 里面

实现和生态

runC 是 OCI 的标准实现。Docker 是在之上包装了 daemon 和 cli。

Kubernetes 为了实现可替换的容器运行时实现,定义了 CRI (Container Runtime Interface),现在的实现有 cri-containerd 和 cri-o 等,但是都是基于 oci/runc 的。

所以后文中使用 runc 来解释容器用到的一些技术。

2. 进程之间的隔离

如果没有 namepsace 的话,就不会有 docker 了。在容器里面,一个进程只能看到同一个容器下面的其他进程(pid),就是用 namespace 实现的。

namespace 有很多种,比如 pid namespace, mount namespace。先来通过例子说 pid namespace。

运行 runc

要运行一个 runc 的容器,首先需要一个符合 OCI Spec 的 bundle。我们可以直接通过 docker 创建这样的一个 bundle。

首先我们创建一个目录来运行我们的 runc,在里面需要创建一个 rootfs 目录。然后用 docker 下载一个 busybox 的 image 输出到 rootfs 中。

然后运行 runc spec ,这个命令会创建一个 config.json 作为默认的配置文件。

进入到 containers 文件夹,就可以运行 runc 了(需要 root 权限)。

查看 namespace

容器只是在 host 机器上的一个普通进程而已。我们可以通过 perf-tools 里面的 execsnoop 来查看容器进程在 host 上面的 pid。execsnoop 顾名思义,可以 snoop Linux 的 exec 调用。在虚拟机里面可能不工作,最好找一台物理机(或者笔记本)进行试验。

我们退出刚才的 runc 容器,先打开 execsnoop,然后在另一个窗口中在开启容器。会发现 host 上有了新的进程。

新的进程的 pid 是 92528.

可以使用 ps 程序查看这个 pid 的 pid namespace.

可以看到在宿主机这个进程的 pidns 是 4026534092。

这个命令只显示了 pid namespace, 我们可以通过 /proc 文件系统查看这个进程其他的 pidns.

使用 cinf 工具,可以查看这个 namespace 更详细的内容。

可以看到这个 ns 下面只有一个进程。

到这里可以得出结论,当我们启动一个新的容器的时候,一系列的 namespace 会自动创建,init 进程会被放到这个 namespace 下面:

  • 一个级才能拿只能看到同一个 namespace 下面的其他进程
  • 在容器里面 pid=1 的进程,在 host 上只是一个普通进程

docker/runc exec

那么当我们执行 exec 的时候发生了什么呢?

运行 runc exec xyxy /bin/top -b ,从 execsnoop 中可以看到 pid:

直接使用 runc 的 ps 命令也可以看到 pid,但是 pid 会和 execsnoop 显示的命令不一样:

在运行原来的 cinf 命令查看这个 namespace:

可以看到现在这个 namespace 下面有两个进程了。

在 runc 的容器里面我们去看 top,会发现有两个进程,它们的 pid 分别是 1 和 13,这就是 namespace 的作用。

3. cgroups

Namespaces 可以控制进程在 container 中可以看到什么(隔离),而 cgroups 可以控制进程可以使用的资源(资源)。

我们可以使用 lsgroup 查看现在系统上的 cgroup, 然后将它保存到一个文件中

然后使用 runc run xyxy 启动一个名字叫 xyxy 的容器,再次查看 cgroup:

可以看到容器创建之后系统上多了一些 cgroup,并且它们的 parent 目录是我们的 sh 所在的 cgroup.

cgroup 可以控制进程所能使用的内存,cpu 等资源。

在容器的 cgroup 中也可以加入更多的进程。

首先使用 runc 查看一下进程的 pid:

然后查看这个 cgroup 下面有哪些进程:

发现只有这一个。

下面通过容器的 exec 命令加入一个新的进程到这个 cgroup 中:

然后再次查看是否有新的 cgroup 生成:

输出为空,说明没有新的 cgroup 生成。

然后通过查看原来的 cgroup,可以确认新的进程 top 被加入到了原来的 cgroup 中。

总结:当一个新的 container 创建的时候,容器会为每种资源创建一个 cgroup 来限制容器可以使用的资源。

那么如何通过 cgroup 来对资源限制呢?

默认情况下的容器是不限制资源的,比如说内存,默认情况下是 9223372036854771712:

要限制一个容器使用的内存大小,只需要将限制写入到这个文件里面去就可以了:

内存是一个非弹性的资源,不像是 CPU 和 IO,如果资源压力很大,程序不会直接退出,可能会运行慢一些,然后再资源缓解的时候恢复。对于内存来说,如果程序无法申请出来需要的内存的话,就会直接退出(或者 pause,取决于 memory.oom_control 的设置)。

上面这种修改 cgroup 限制的方法,其实就是 runc 在做的事情。但是使用 runc 我们不应该直接去改 cgroup,而是应该修改 config.json ,然后 runc 帮我们去配置 cgroup。

修改方法是在 linux.resources 下面添加:

然后 runc 启动之后可以查看 cgroup 限制。

我们可以验证 runc 的资源限制是通过 cgroup 来实现的,通过修改内存限制到一个很小的值(比如10000)让容器无法启动而报错:

从错误日志可以看到,cgroup 的限制文件无法写入。可以确认底层就是 cgroup.

4. Linux Capabilities

Capabilities 也是 Linux 提供的功能,可以在用户有 root 权限的同时,限制 root 使用某些权限。

先准备好一个容器,带有 Libcap,这里我们还是直接使用 docker 安装好然后导出。

然后将这个 docker 容器导出到 runc 的 rootfs:

最后生成一个 spec:

然后进入到容器里面验证,会发现在容器里面无法修改 hostname,即使已经是 root 了也不行:

这是因为,修改 hostname 需要 CAP_SYS_ADMIN 权限,即使是 root 也需要。

我们可以将 CAP_SYS_ADMIN 加入到 init 进程的 capabilities 的 bounding permitted effective list 中。

修改 capabilities 为以下内容:

然后重新开启一个容器进去测试,发现就可以修改 hostname 了。

查看 Capability

要使用 pscap ,首先要安装 libcap-ng-utils,然后可以查看刚刚打开的那两个容器的 capabilities:

可以看到一个有 sys_admin ,一个没有。

除了修改 config.json 来添加 capabilities,也可以在 exec 的时候直接通过命令行参数 --cap 来要求 additional caps.

在容器中,可以通过 capsh 命令查看 capability:

可看到 Current 和 Bounding 里面有 cap_sys_admin+ep 的意思是它们也在 effective 和 permitted 中。

5. 文件系统的隔离

在容器中只能看到容器里面的文件,而不能看到 host 上面的文件(不map的情况下),做到了隔离。

Linux 使用 tree 的形式组织文件系统,最底层叫做 rootfs, 一般由发行版提供,mount 到 / 。然后其他的文件系统 mount 到 / 下面。比如,可以将一个外部的 USB 设备 mount 到  /data 下面。

mount(2)是用来 mount 文件的系统的 syscall。当系统启动的时候,init 进程就会做一些初始化的 mount。

所有的进程都有自己的 mount table,但是大多数情况下都指向了同一个地方,init process 的 mount table。

但是其实可以从 parent 进程继承过来之后,再做一些改变。这样只会影响到它自己。这就是 mount namespace。如果 mount namespace 下面有任何进程修改了 mount table,其他的进程也会受到影响。所以当你在shell mount 一个 usb 设备的时候,GUI 的 file explorer 也会看到这个设备。

Mount Namespace

一般来说应用在启动的时候不会修改 mount namespace. 比如现在在我的虚拟机中,就有一下的 mount namespace:

现在启动一个 container,可以看到有了新的 mount namespace:

在 host 进程上查看 mount info:

可以看到这个进程的 / mount 到了 /dev/mapper/vagrant-root 上。

在 host 机器上,查看 mount,会发现这个设备同样 mount 在了 / 上。

所以这里就有了问题:为什么 container 的 rootfs 会和 host 的 rootfs 是一样的呢?这是否意味着 contianer 能读写 host 的文件了呢?contianer 的 rootfs 不应该是 runc 的 pwd 里面的 rootfs 吗?

我们可以看下 container 里面的 / 到底是什么。

在 container 里面查看 / 的 inode number:

然后看下 Host 上运行 runc 所在的 pwd 下面的 rootfs:

可以看到,容器里面的 / 确实就是 host 上的 rootfs

但是他们是怎么做到都 mount 到 /dev/mapper/vagrant-root 的呢?

这里的 “jail” 其实是 privot_root 提供的。它可以改变 process 的运行时的 rootfs. 相关代码可以查看这里。这个 idea 其实来自于 lxc

chroot

要做到文件系统的隔离,其实并不一定需要创建一个新的 mount namespace 和 privot_root 来进行文件系统的隔离,可以直接使用 chroot(2) 来 jail 容器进程。chroot 并没有改变任何 mount table,它只是让进程的 / 看起来就是一个指定的目录。

关于 chroot 和 privot_root 的对比可以参考这里

简单来说,privot_root 更加彻底和安全。

如果在 runc 使用 chroot,只需要将 {“type”:”mount”} 删掉即可。

也可以删掉这部分,这是为 privot_root 准备的。

然后创建一个新的容器,发现依然不能读写 rootfs 之外的东西。

Bind Mount

Linux 支持 bind mount. 就是可以将一个文件目录同时 mount 到多个地方。这样,我们就可以实现在 host 和 container 之间共享文件了。

config.json 中作出一下修改:

这样, host 上面的 /home/vagrant/test_cap/workspace_host 就会和容器中的 /my_workspace 同步了。可以在 host 上面执行:

然后在 container 里面:

Bind 不仅可以用来 mount host 的目录,还可以用来 mount host 上面的 device file。比如可以将 host 的 UBS 设备 mount 到 container 中。

Docker  Volume

Volume 是 docker 中的概念,OCI 中并没有定义。

本质上它仍然是一个 mount,可以理解为是 docker 帮你管理好这个 mount,你只要通过命令行告诉 docker 要 mount 的东西就好了。

6. User and root

User 和 permission 是 Linux 上面几乎最古老的权限系统了。工作原理简要如下:

  1. 系统有很多 users 和 groups
  2. 每个文件术语一个 owner 和一个 group
  3. 每一个进程术语一个 user 和多个 groups
  4. 结合以上三点,每一个文件都有一个 mode,标志了针对三种不同类型的进程的权限控制: owner, group 和 other.

注意 kernel 只关心 uid 和 guid,user name 和 group name 只是给用户看的。

执行容器内进程的 uid

config.json 文件中的 User 字段可以指定容器的进程以什么 uid 来运行,默认是 0,即 root。这个字段不是必须的,如果删去,依然是以 uid=0 运行。

在 host 上,uid 也是 0:

不推荐使用 root 来跑容器。但是好在默认我们的容器进程还受 capability 的限制。不像 host 的 root 一样有很多权限。

但是仍然推荐使用一个非 root 用户来运行容器的进程。通过修改 config.json 的 uid/guid 可以控制。

然后在容器中可以看到 uid 已经变成 1000 了。

在 host 上可以看到进程的 uid 已经不是 root 了:

创建容器的时候默认不会创建 user namespace。

使用 User namespace 进行 UID/GID mapping

接下来我们创建一个单独的 user namespace.

在开始之前我们先看下 host 上现有的 user namespace:

然后通过修改 config.json 来启用 user namespace. 首先在 namespaces 下面添加 user 来启用,然后添加一个 uid/guid mapping:

然后重新运行容器,再次查看 user namespace:

在容器里面,我们看到 uid=1000:

但是在 host 上,这个进程的 pid=2000:

这就是 uid/gid mapping 的作用,通过 /proc 文件也可以查看 mapping 的设置:

通过设置容器内的进程的 uid,我们就可以控制他们对于文件的权限。比如如果文件的 owner 是 root,我们可以通过设置 uid 来让容器内的进程不可读这个文件。

一般不推荐使用 root 运行容器的进程,如果一定要用的话,使用 user namespace 将它隔离出去。

在同一个容器内运行多个进程的场景中,也可以通过 user namespace 来单独控制容器内的进程。

7. 网络

在网络方面,OCI Runtime Spec 只做了创建和假如 network namespace, 其他的工作需要通过 hooks 完成,需要用户在容器的运行时的不同的阶段来进行自定义。

使用默认的 config.json ,就只有一个 loop device ,没有 eth0 ,所以也就不能连接到容器外面的网络。但是我们可以通过 netns 作为 hook 来提供网络。

首先,在宿主机上,下载 netns 到 /usr/local/bin 中。因为 hooks 在 host 中执行,所以这些 Binary 要放在 host 中而不是容器中,容器的 rootfs 不需要任何东西。

使用 netns 设置 bridge network

config.json 中作出如下修改,除了 hooks,还需要 CAP_NET_RAW  capability, 这样我们才可以在容器中使用 ping。

然后再启动一个新的容器。

可以看到除了 loop 之外,有了一个 eth0 device.

也可以 ping 了:

Bridge, Veth, Route and iptable/NAT

当一个 hook 创建的时候,container runtime 会将 container 的 state 传给 hook,包括 container的 pid, namespace 等。然后 hook(在这里就是 netns )就会通过这个 pid 来找到 network namespace,然后 netns 会做以下几件事:

  1. 创建一个 linux bridge,默认的名字是 netns0 ,并且设置 MASQUERADE rule;
  2. 创建一个 veth pair,一端连接 netns0 ,另一端连接 container network namespace, 名字在 container 里面是 eth0;
  3. 给 container 里面的 eth0 分配一个 ip,然后设置 route table.

bridge and interfaces

netns0 穿件的时候又两个 interfaces,名字是 netnsv0-$(containerPid):(brctl 需要通过 apt install bridge-utils 安装)

netnsv0-8179 是 veth pair 其中的一个,连接 bridge,另一个 endpoint 是 container 中的。

vthe pair

在 host 中,netnsv0-8179 的 index 是7:

然后在 container 中,etch0 的 index 也是7.

所以可以确认容器里面的 eth0 和 host 的 netnsv0-8179 是一对 pair。

同理可以确认 netnsv0-10577 是和 container 10577 中的 eth0 是一对 pair。

到这里我们知道容器是如何和 host 通过 veth pair 搭建 bridge 的。有了 network interfaces,还需要 route table 和 iptables.

Route Table

container 里面的 routing table 如下:

可以看到所有的流量都从 eth0 到 gateway, 即 bridge netns0

在 host 上:

以及:

192.168.1.1 是 home route,一个真实的 bridge.

总结起来,ping 的时候,从 container 中,包会从 netns 的 virtual bridge netns ,发送到一个真正的 route gateway,然后到外网去。

iptable/nat

netns 做的另一个事情是设置 MASQUERADE,这样所有从 container 发出去的包(source是 172.19.0.0/16 )都会被 NAT,这样外面只会看到这个包是从 host 来的,而不知道是否来自于一个 container,只能看到 host 的 IP。


至此,容器用到的一些技术基本上就讲完了。所以说容器本质上是使用 Linux 提供的一些技术来实现进程的隔离,对于 host 来说,它仍然只是一个普通的进程而已。

参考资料:

主要是一些 Linux 手册,以及最主要的,Bin Chen 的博客:Understand Container. 本文基本上是我在学习他的博客的笔记。