使用 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 不一致的情况下,以后面节点的为准。

网络设备转发包的时候,是直接在接口的 ASIC 芯片1上完成的。traceroute 的原理是发送 TTL=1 的包到各级的网络设备上,来迫使这些设备收到 TTL=1 的包之后,回复 ICMP 来暴露自己的 IP 地址,用于 debug。但是 TTL=1 的包的处理无法简单的在 ASIC 芯片上处理,ASIC 芯片能做的事情很简单,但是速度很快。TTL=1 的包通常要送到 CPU 来处理(所谓的 slow path),所以网络设备在回复 ICMP 的包时候,可能比转发正常的数据面流量速度要慢很多。

来回路径不一致的问题

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

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

中间出现 ??? 的情况

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

使用 tcp

现在的 mtr 也支持通过 tcp ping 了,发送 SYN 包进行探测,原理和 ICMP 包的 TTL 是一样的。

但是使用 --tcp 的时候,会经常看到中间的链路丢包问题,TCP ping 看起来并不是很文档,不知道是什么原因造成的。可以借助上文提到的惊讶,如果只是中间链路丢包而后面的链路不丢包,可能是误报。

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

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

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

参考资料:

  1. Diagnosing Network Issues with MTR
  2. Traceroute: Finding meaning among the stars
  3. Understanding the Ping and Traceroute Commands
  1. https://en.wikipedia.org/wiki/Application-specific_integrated_circuit ↩︎
 

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

在终端里面一个非常日常的操作,就是选中上一个命令的输出内容。比如使用文本处理程序处理一些服务器的 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 出现过的文本,进行快速选中。但是好像不能复制出到剪切板。


2021年07月06日更新:

我发现在服务器上复制命令的时候,连同命令本身以及 prompt 一起复制更加实用,因为我们的 prompt 带有机器的 hostname,包括机器标签,ip 等。复制命令可以让同事知道这个 output 是怎么来的,打开的文件路径?awk?grep?都可以一眼就能看出来。所以我将这个脚本改了一下,← 是只复制 output,↑ 是向上复制 prompt、命令以及 output,↓ 也一样,只不过是向下选择。

详情见:https://twitter.com/laixintao/status/1412081667498332161

实现代码如下( commit ,可以关注下 myrc 这个仓库,我将我的所有配置文件都放在这里,从这里可以看到最新的版本):

 

在你家公司使用 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 可以控制进程可以使用的资源(资源)。

我们可以使用 lscgroup 查看现在系统上的 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 中,eth0 的 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,eth0 的另一端是 netnsv0-8179,连接在 bridge netns0 上面:

在 host 上:

以及:

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

总结起来,ping 的时候,需要一出一进两条路有:

  1. 从 container 出去:包会从容器内,发到 virtual bridge netns ,发送到一个真正的 route gateway,然后到外网去。
  2. ping 包的回复,进入到 Host 中,会走第二条路由,发送到 netns0 ,然后根据 IP 进入到容器中。

这样,我们就可以从容器中 ping 通外面的地址了。但是还有一个问题:我们是用 172.19.0.0 这个地址段去 ping 的,假设同一个局域网内有两个电脑,分别运行 docker,那都使用默认的这个地址段,不就乱套了嘛?

iptable/nat

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

 


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

参考资料:

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

 

软件的分层

在关于软件的复杂度上, David J. Wheeler 

“We can solve any problem by introducing an extra level of indirection.”

在使用了一段时间的 React Hook 之后,对于分层有一些感触。可能在维护和管理规模较大的软件上,添加更多的抽象和分层是必不可少的。但是分层不一定会带来更多的复杂度,巧妙的设计可以让软件依然容易维护。

我发现设计好、接受度高的软件,代码倾向于让用户按照业务逻辑来组织,而不是按照框架的实现来组织。

比如 React Hooks,在没有它之前,在一个组件中,你要将所有的所有组件的 ComponentDidMount 放在一起,将 ComponentDidUpdate 放在一起。如果一个页面有 5 个组件构成,那么每一个组件都要分别写到两组里面去,如果涉及更多的状态管理,涉及同一个组件的状态管理将分散在更多的地方。

但是 Hooks,让你可以把通一个组件的状态、控制逻辑、渲染逻辑都放在通一个地方。

这就使得代码的阅读性和可维护性变得很好。

另外一个例子是 Django 框架组织代码的形式。Django 使用 app 来组织用户的代码,在每一个 app 里面都有 view model 等,控制这个 app 的内容。这样的好处有:这个 app 只管理这一部分的逻辑,与其他 app 的耦合性很低,“高内聚,低耦合”。

一开始接触这样的框架的时候比较不适应, 比如怎么划分 app,是一个经验问题。新手很容易将所有的内容都写到同一个 app 中,或者直接按照团队的分工来划分 app。但如果正确掌握了这种组织代码的形式的话,代码就的可维护性就会提高很多。

一个反例是蚂蚁的 SOFA 框架。以前的同事跟我说,“来蚂蚁就要学习 SOFA 的分层,学会了这个就掌握了精华了。” 使用这个框架写了一年多的代码,我还是无法理解其中的智慧。撇开启动速度长达三分钟、配置混乱并且难以理解这些问题不说。就说你要把代码写在哪一层这个问题,就会难倒很多新手。下面是一个新项目默认的分层结构,实际上随着项目的开发,层数会增加很多:

这样的设计默认了用户必须理解框架对每一层的设定。将项目变得难以管理,并且增加了很多工作量。比如对某一个 model 添加一个评论功能, 在 Django 中几乎是一小时就可以完成并上线的工作量, 在 SOFA 中可能需要几天的时间,在不同的层上添加逻辑。实际上大多数时候这些“层”什么都没有干,只不过是直接去调用下一层。

好的设计应该是 “make the easy things easy, and the hard things possible.” 显然,这种设计是让所有的事情都变得一样复杂。即使写一个 Hello World 出来,你用这个框架也需要创建出来一个庞然大物。

实话说,我在蚂蚁的这段工作经历,从开发体验上说,是非常痛苦的。包括框架启动慢、复杂并且混乱的配置,对 Java 语言的强绑定,缺少文档,代码难以测试(因为即使是本地开发也连接了很多其他服务)等等。

那么为什么会造成这种情况呢?我认为和组织形式有关。康威定律说“设计系统的架构受制于产生这些设计的组织的沟通结构。” 我认为可以再扩展一下,不光受制于沟通结构,和整个组织的政策都有很大关系。做一个事情的方案有很多种,可以使用一层抽象,也可以使用三层抽象,甚至可能有某种优雅的方法不添加额外的实体概念去实现。这不是一件简单地事情,需要极具经验的工程师才能做得很完美。然而假如 KPI 的压力太大,以及 KPI 只看结果贯彻地太好,那么怎么做就不会变的不重要,毕竟都可以达到一样的效果(但从某一个量化指标上来说),虽然可能会带来更大的理解成本,以及潜在的维护成本、沟通成本,甚至带来的稳定性隐患等。但是这不重要,KPI 怎么完成无所谓,只要完成了都一样。

另一个表现是晋升,一些公司像是封建社会一样有着森严的等级,某一等级的工程师只能做那一等级的事情,大家都想着向上晋升。但是很多晋升过去的人已经不写代码了,很多高等级的 SRE 工程师甚至都已经很久不使用终端了。这就导致在晋升的时候,这些高等级的工程师组成的评委团不会太过于注重技术方案。本来评审一个候选人的时候应该问 “为什么选择使用 A 而不使用方案 B?” “你这样做会有某某问题是如何解决的” “XX是怎么处理的”,由于无法理解技术所以只能问出这种问题:

你发这个的底层逻辑是什么?顶层设计在哪里?最终交付价值是什么?过程的抓手在哪里?如何保证结果的闭环?能否赋能产品生态?你比别人发的亮点在哪?优势在哪?我没有看到你的沉淀和思考,你有形成自己的方法论吗?你得让别人清楚,凭什么发这个的人是你,换别人来发不一样吗?

或许觉得这是网友的调侃,但是在当你确确实实要在晋升的时候去想破头思考这些问题该怎么回答的时候,就不那么好笑了。

在这种环境下,像是压测、限流、熔断、容灾等等方案,只要去做肯定是可以完成的,但是你可以因为这件事带来非常大的改造成本,造成严重的开发效率问题,搞出来很多让开发人员难以理解的概念,和蹩脚的设计,也可以做的很漂亮。虽然对于将来评价你的评委来说,这并没有很大的不同。甚至你因为设计的拙劣带来了很大的改造成本,却又给你带来了更多的工作量,加班卖力的完成,于是又成了一个可以被人称道的点,可以凸显你的推动能力、领导能力,这是评委们非常喜欢的能力。虽然本质上只是给大家带来了一堆麻烦而已。

另外,这种晋升机制又会让大家去强行给自己加活。通常的套路表现为:找出开源软件中的一两个毛病,然后以此为借口声称这无法满足我们公司的需要,所以需要“自研”一套,然后“自研”的软件解决了自己当初找到的那几个毛病,成功获得晋升。而实际上,自研的东西可能又带来了成百个其他的问题,但除了给使用者造成了痛苦之外,倒没有什么问题,因为还有其他人虎视眈眈地想再重新研发一次,替换掉你这个项目呢。

如此对技术的不够重视,加上繁杂的会议积压开发时间,工期紧,导致大部分的工程师不会有时间去思考设计、分层的问题了。虽然有时候停下来思考可能带来更多的收益,但是弥漫在焦虑中很难停得下来。我在这种环境中也写出了不少垃圾。在这种情况下,人们就越倾向于使用自己非常熟悉的技术,不愿意学习新的东西,因为这会减少自己工作中的麻烦。懒惰地使用分层和抽象解决问题,导致软件越来越复杂。声称 Java 才是适合“企业级”应用的最佳解决方案,实际上只是懒得思考和设计。毕竟,现在的事情已经够多了,我们应该把更多的时间放在“顶层设计”,思考“业务价值”上,技术方面,只要实现了就好,怎么实现的,没有人关心。我觉得这也就是为什么蚂蚁的很多人将很多东西做成了自己熟悉的系统的样子,比如很多中间件经过内部的修改变得只支持 Java,如果你在蚂蚁使用除了 Java 之外的语言几乎是难上加难(2020年);比如很多写过交易系统人去写一个逻辑非常简单的东西都会分成7层来写,甚至类的名字会使用交易系统的概念来明令,XXOrder,XXTransaction;比如听说 Python 实现东西很快,但是会把 Python 去写成 Java 的样子。

说了这么多,需要提醒一下读者的是,这并不能代表蚂蚁所有的技术,甚至有人会觉得 SOFA 非常成功,给无数小微企业带去了收益。总之,只是我自己的想法而已,如果我很喜欢蚂蚁的研发环境,我也不会离开蚂蚁。也可能是我天生愚钝,无法理解里面的大智慧吧。

复杂是很简单的,简单是很困难的。好的软件需要很多年的持续耕耘才行,一边做一遍思考,从自己现在做的事情开始,一点一滴,随着时间慢慢积累才行。

最后给读者推荐我比较喜欢的一个视频吧,John 讲的 A Philosophy of Software Design | John Ousterhout | Talks at Google 。以及他的书:A Philosophy of Software Design .


2021年06月15日更新:

一点想法。有关 web 框架,好的框架都是从简单的开始,随着项目的发展,逐渐变得越来越复杂,比如说 Flask,项目开始的时候可能就是一个文件,用户可以根据需要,引入依赖,拆分模块。虽然有些框架采用了不一样的哲学,比如 Django, React, 但是一开始脚手架生成的框架总是简单的。不好的框架,一开始就会给你生成大量的代码,即使你要完成一个很简单的功能,也要给你分个七八层,引入几十个依赖。记得以前有一次公司让领导亲手用我们自己的框架写一个 Hello World 类似的东西,领导们写了一整天,搞的满头大汗。