Docker 镜像构建的一些技巧

最近做了一个好玩的工具,叫 xbin.io 。其中有一项工作是为不同的工具来构建 Docker 镜像,让他们都运行在 Docker 中(实际上,是兼容 Docker image 的其他 sandbox 系统,没有直接用 Docker)。支持的工具越来越多,为了节省资源,Build 的 Docker image 就越小越好,文件越少,其实启动速度也会略微快一些,也会更安全一些。

这篇文章来介绍一下做 Docker Image 的一些技巧。

在之前的博客 Docker (容器) 的原理 中介绍过 Docker image 是如何工作的。简单来说,就是使用 Linux 的 overlayfs, overlay file system 可以做到,将两个 file system merge 在一起,下层的文件系统只读,上层的文件系统可写。如果你读,找到上层就读上层的,否则的话就找到下层的给你读。然后写的话会写入到上层。这样,其实对于最终用户来说,可以认为只有一个 merge 之后的文件系统,用起来和普通文件系统没有什么区别。

有了这个功能,Docker 运行的时候,从最下层的文件系统开始,merge 两层,得到新的 fs 然后再 merge 上一层,然后再 merge 最上一层,最后得到最终的 directory,然后用 chroot 改变进程的 root 目录,启动 container。

了解了原理之后,你会发现,这种设计对于 Docker 来说非常合适:

  1. 如果 2 个 image 都是基于 Ubuntu,那么两个 Image 可以共用 Ubuntu 的 base image,只需要存储一份;
  2. 如果 pull 新的 image,某一层如果已经存在,那么这一层之前的内容其实就不需要 pull 了;

后面 build image 的技巧其实都是基于这两点。

另外稍微提一下,Docker image 其实就是一个 tar 包。一般来说我们通过 Dockerfiledocker built 命令来构建,但是其实也可以用其他工具构建,只要构建出来的 image 符合 Docker 的规范,就可以运行。比如,之前的博文 Build 一个最小的 Redis Docker Image 就是用 Nix 构建出来的。

技巧1:删除缓存

一般的包管理器,比如 apt, pip 等,下载包的时候,都会下载缓存,下次安装同一个包的时候不必从网络上下载,直接使用缓存即可。

但是在 Docker Image 中,我们是不需要这些缓存的。所以我们在 Dockerfile 中下载东西一般会使用这种命令:

在包安装好之后,去删除缓存。

一个常见的错误是,有人会这么写:

Dockerfile 里面的每一个 RUN 都会创建一层新的 layer,如上所说,这样其实是创建了 3 层 layer,前 2 层带来了缓存,第三层删除了缓存。如同 git 一样,你在一个新的 commit 里面删除了之前的文件,其实文件还是在 git 历史中的,最终的 docker image 其实没有减少。

但是 Docker 有了一个新的功能,docker build --squash。squash 功能会在 Docker 完成构建之后,将所有的 layers 压缩成一个 layer,也就是说,最终构建出来的 Docker image 只有一层。所以,如上在多个 RUN 中写 clean 命令,其实也可以。我不太喜欢这种方式,因为前文提到的,多个 image 共享 base image 以及加速 pull 的 feature 其实就用不到了。

一些常见的包管理器删除缓存的方法:

yum

yum clean all

dnf

dnf clean all

rvm

rvm cleanup all

gem

gem cleanup

cpan

rm -rf ~/.cpan/{build,sources}/*

pip

rm -rf ~/.cache/pip/*

apt-get

apt-get clean

另外,上面这个命令其实还有一个缺点。因为我们在同一个 RUN 中写多行,不容易看出这个 dnf 到底安装了什么。而且,第一行和最后一行不一样,如果修改,diff 看到的会是两行内容,很不友好,容易出错。

可以写成这种形式,比较清晰。

技巧2:改动不频繁的内容往前放

通过前文介绍过的原理,可以知道,对于一个 Docker image 有 ABCD 四层,B 修改了,那么 BCD 会改变。

根据这个原理,我们在构建的时候可以将系统依赖往前写,因为像 apt, dnf 这些安装的东西,是很少修改的。然后写应用的库依赖,比如 pip install,最后 copy 应用。

比如下面这个 Dockerfile,就会在每次代码改变的时候都重新 Build 大部分 layers,即使只改了一个网页的标题。

我们可以改成,先安装 Nginx,再单独 copy requirements.txt,然后安装 pip 依赖,最后 copy 应用代码。

技巧3:构建和运行 Image 分离

我们在编译应用的时候需要很多构建工具,比如 gcc, golang 等。但是在运行的时候不需要。在构建完成之后,去删除那些构建工具是很麻烦的。

我们可以这样:使用一个 Docker 作为 builder,安装所有的构建依赖,进行构建,构建完成后,重新选择一个 Base image,然后将构建的产物复制到新的 base image,这样,最终的 image 只含有运行需要的东西。

比如,这是安装一个 golang 应用 pup 的代码:

我们使用 golang 这个 1G 多大的 image 来安装,安装完成之后将 binary 复制到 alpine, 最终的产物只有 10M 左右。这种方法特别适合一些静态编译的编程语言,比如 golang 和 rust.

技巧4:检查构建产物

这是最有用的一个技巧了。

dive 是一个 TUI,命令行的交互式 App,它可以让你看到 docker 每一层里面都有什么。

dive ubuntu:latest 命令可以看到 ubuntu image 里面都有什么文件。内容会显示为两侧,左边显示每一层的信息,右边显示当前层(会包含之前的所有层)的文件内容,本层新添加的文件会用黄色来显示。通过 tab 键可以切换左右的操作。

一个非常有用的功能是,按下 ctrl + U 可以只显示当前层相比于前一层增加的内容,这样,就可以看到增加的文件是否是预期的了。

ctrl + Space 可以折叠起来所有的目录,然后交互式地打开他们查看,就像是 Docker 中的 ncdu

 

参考资料:

  1. https://jvns.ca/blog/2019/11/18/how-containers-work–overlayfs/
  2. https://www.kernel.org/doc/html/latest/filesystems/overlayfs.html
  3. http://docs.projectatomic.io/container-best-practices/
 

分享一下“稍后阅读”的 random 功能

最近用了 @yiran 转载的一个 pocket 的隐藏功能,发现太好用了。在博客上单独再分享一下。

TL;DR Pocket(一个稍后阅读App) 有一个 url,是 https://getpocket.com/random,每次打开它就会自动定向到你在 pocket 里面的一篇文章。

这样就可以解决一个问题:pocket 里面的文章越积累越多,可能最新的文章是一些比较难的话题,你每次打开都头疼,没有很长时间来研究这些内容,那么这几篇文章就成了 Head-of-Line Blocking, 永远把后面的内容挡住了。

Random 就可以解决这个问题。每次给打开一篇你保存的任意的文章来读。这个打开的不是 pocket 的界面,而是直接到了原网站。

有一个小小的缺点是读完之后没有办法很方便的标记成已读。Pocket 不支持重复的记录,所以读完之后可以再次保存到 Pocket 中,这个地址就会出现在 Pocket 里最前面,下次打开就可以将他们 Archive 了。

在桌面浏览器上我直接将这个 URL 放到了最显眼的 Bookmarks Bar 上,闲的时候就打开一篇看看。如果 Random 到不想看的就可以直接再点击一次。

IPhone 上面可以使用“将书签添加到主屏幕这个功能”。不过有一个小技巧,就是因为 https://getpocket.com/random 这个 URL 会重定向到其他的页面,不太好添加这个 URL 本身。可以先开飞行模式,用 Safari 打开就不会重定向了。然后再添加到主屏幕。这样就好像多了一个 App,每次点击都会随机到一个页面上去。

 

用 Nginx 在公网上搭建加密数据通道

最近在跨机房做一个部署,因为机房之间暂时没有专线,所以流量需要经过公网。对于经过公网的流量,我们一般需要做以下的安全措施:

  1. 只能允许已知的 IP 来访问;
  2. 流量需要加密;

第一项很简单,一般的防火墙,或者 Iptables 都可以做到。

对于加密的部分,最近做了一些实验和学习,这篇文章总结加密的实现方案,假设读者没有 TLS 方面的背景知识,会简单介绍原理和所有的代码解释。

TLS/SSL 的原理

TLS 是加密传输数据,保证数据在传输的过程中中间的人无法解密,无法修改。(本文中将 TLS 与 SSL 作为同义词。所以提到 SSL 的时候,您可以认为和 TLS 没有区别。)

传输的加密并不是很困难,比如双方用密码加密就可以。但是这样一来,问题就到了该怎么协商这个密码。显然使用固定的密码是不行的,比如每个人都要访问一个网站,如果网站使用固定的密码,那么和没有密码也没有什么区别了,每个人都可以使用这个密码去伪造网站。

TLS 要解决的问题就是,能证明你,是你。现在使用的是非对称加密的技术。非对称加密会有两个秘钥,一个是公钥,一个是私钥。公钥会放在互联网上公开,私钥不公开,只有自己知道。只有你有私钥,我才相信你是你。非对称加密的两个秘钥提供了一下功能(本文不会详细介绍这部分原理,只简单提到理解后续内容需要的知识):

  1. 公钥加密的数据,只有用私钥可以解密;
  2. 私钥可以对数据进行签名,公钥拿到数据之后可以验证数据是否由私钥的所有者签名的。

有了这两点,网站就可以和访问者构建一个加密的数据通道。首选,网站将公钥公开(即我们经常说的“证书”),访客连接到网站的服务器第一件事就是下载网站的证书。因为证书是公开的,每个人都能下载到此网站的证书,那么怎么确定对方就是此证书的所有者呢?客户端会生成一个随机数,并使用公钥进行加密,发送给服务器:请解密这段密文。这就是上文提到的 功能1,即公钥加密的数据,只有私钥才能解密。服务器解密之后发回来(当然,并不是明文发回来的,详细的 TLS 握手过程,见这里,客户端就相信对方的确是这个证书的所有者。后续就可以通过非对称加密协商一个密码,然后使用此密码进行对称加密传输(性能快)。

但是这样就足够验证对方身份了吗?假设这样一种情况,我并不是 google.com 这个域名的所有者,但是我生成了一对证书,然后自己部署,将用户访问 google.com 的流量劫持到自己这里来,是不是也能使用自己的证书和用户进行加密传输呢?

所以就有了另一个问题:访客不仅要验证对方是证书的真实所有者,还要验证对方的证书的合法性。即 google.com 的证书只有 Google 公司可以拥有,我的博客的证书只有我的博客可以拥有。私自签发的证书不合法。

为了解决这个问题,就需要有一个权威的机构,做如下的保证:只有网站的所有者,才能拥有网站的证书。然后访客只要信任这个“权威的机构”就可以了。

CA 扮演的角色

CA 的全称是 Certification Authority, 是一个第三方机构,在上述加密的流程中,扮演的角色同时被访客和网站所信任。

网站需要去 CA 申请证书,而 CA 要对自己颁发(签名)的证书负责,即确保证书颁发给了对方,颁发证书之前要验证你是你。申请证书的时候,CA 一般会要求你完成一个 Challenge 来证明身份,比如,要求你将某个 URL 返回特定内容,或者要求你将 DNS 的某个 text record 返回特定内容来证明你的确拥有此域名(详见 validation standards)。只有你证明了你是你,CA 才会签证书给你。

访客是怎么验证证书的呢?这就用到了上文提到的 功能2:“私钥可以对数据进行签名,公钥拿到数据之后可以验证数据是否由私钥的所有者签名的。” CA 也有自己的一套私钥公钥,CA 使用私钥对网站的证书进行签名(担保),访客拿到网站的证书之后,使用 CA 的公钥校验签名即可验证这个“担保”的有效性。

那么 CA 的公钥是怎么来的呢?答案是直接存储在客户端的。Linux 一般存储在 /etc/ssl/certs。由此可见,CA 列表更新通常意味着要升级系统,一个新的 CA 被广泛接受是一个漫长的过程。新 CA 签发的证书可能有一些老旧的系统依然不信任。比如 letsencrypt 的 CA,之前就是使用交叉签名的方式工作,即已有的 CA 为我做担保,我可以给其他的网站签发证书。这也是中级证书的工作方式。每天有这么多网站要申请证书,CA 怎么签发的过来呢?于是 CA 就给很多中级证书签名,中级证书给网站签名。这就是“信任链”。访客既然信任 CA,也就信任 CA 签发的中级,也就信任中级签发的证书。

被信任很漫长,被不信任很简单。

CA (以及中级证书机构)有着非常大的权利。举例,CA 假如给图谋不轨的人签发了 Google 的证书,那么攻击者就可以冒充 Google。即使 Google 和这个 CA 并没有任何业务往来,但是自己的用户还是被这个 CA 伤害了。所以 CA 必须做好自己的义务:

  1. 保护自己的私钥不被泄漏;
  2. 做好验证证书申请者身份的义务;
  3. 如果 (2) 有了疏忽,对于错误签发的证书要及时吊销

案例:赛门铁克证书占了活跃证书的 30% – 45%(当时),但是被 Google 发现其错误颁发了 3万个证书,发现后却不作为。因此逐步在后续的 Chrome 版本中吊销了赛门铁克的证书

案例2:let’sencrypt 今年1月份发现自己的 TLS-ALPN-01 chanllege 有问题,于是按照规定,在5天后吊销了这期间通过 TLS-ALPN-01 颁发的所有证书

说道这里我想继续跑一个题。我以前给博客部署证书的时候(2017年)就想:CA 给我发一个证书居然要收我的钱?这个不是零成本的东西吗?他们想发多少就发多少。看到现在读者应该明白了,这并不是一个零成本的事情:签发证书的验证服务需要花钱,而 CA Root key 的保护要花更多的钱。整个 CA 公司(组织)的核心资产就是一个 key,如果这个 key 暴露了,后果不堪设想。所以,一个无比重要却要一直使用的 key 在一个上千万人的组织里怎么被使用而不暴露给任何一个人呢?这是要花很多钱的。Root key 的生成会有一个仪式(Key ceremony),全程录像,有 20 多个不同组织的代表会现场参加并监督,会有 3000 多个人观看实时录像,确保 key 的生成是标准流程。在 Root key 的保存和使用上,Root key 只会签中级 CA,以减少使用次数以及 Root key 需要被 revoke(代价太大)的风险。Root Key 保存在一个特殊的硬件中(HSM, Hardware security module),完全离线保存,HSM 也放在特殊的机房中,7×24 有人看守,并离线录像,机房有 Class 5 Alarm System,有多把锁,没有一个人可以单独进入。使用这个 Root Key 必须物理上进入这个机房,使用过程全程录像,并且记录使用过程,如果有问题可以很快地将 Root Key 签的内容 revoke。这里有一个视频介绍 Key Signing Ceremony,非常有趣。所以说 CA 机构并不是一个摇钱树,Let’s Encrypt 这种组织简直就是慈善机构。

以上就是 TLS,证书,CA 大致的工作原理,稍稍有些跑题,有了这些知识我们就可以利用 TLS 来建立一个加密的数据通道了。后续几乎都是实际的操作。笔者对这部分也不是精通,如果有错误,欢迎指出。

对应用透明的加密通道的方案

背景

上文是通过网站部署 HTTPS 来讲的 TLS 的工作原理。其实网站部署 HTTPS 还算是比较简单:你只需要找一个 CA,申请证书,完成 CA 的验证,部署证书,就可以了。

现在要解决的问题更加复杂一些:我们的两个组件之间是通过自己研发的协议通讯(基于 TCP),现在要分别部署在两个机房,通过公网进行通讯。

我们的方案要对通讯的两边做好安全防护:

  1. 数据要进行加密传输;
  2. 要对两边做身份验证,比如 A 向 B 发起连接,A 要验证 B 的身份,B 也要验证 A 的身份;
  3. 最好对于应用来说透明,即应用完全不修改代码,依然按照原来的方式工作,但是我们将中间的流量进行加密;

mTLS

mTLS 的全称是 Mutual TLS. 即双向的 TLS 验证。HTTPS 只是访客验证了网站的身份,网站并没有验证访客的身份。其实要验证也是可以的,网站发送证书之后可以跟访客说:“现在该轮到你出示你的证书了”。如果访客不能提供有效的证书,网站可以拒绝服务。

其实,ssh 方式就是一个双向验证的过程。我们都知道通过 ssh key 登录 server 的时候,需要让 server 信任你的 key(即将你的 pubkey 放到 server 上去)。但是还有一个过程容易被忽略掉,在第一次通过 ssh 连接服务器的时候,ssh 客户端会给你展示 server 的 pubkey,问你是否信任。如果之后这个 key 变了,说明有可能你连接到的并不是目的服务器。

第一次连接到服务器的提示

如果之后这个 key 变了,ssh 客户端就会拒绝连接。

Git 也是通过走 ssh 协议的,所以也是一个双向认证。你在使用 Github 的时候要互相信任对方:

  • Github 信任你的方式是:你将自己的 pubkey 上传到 Github (设置,profile,keys)
  • 你信任 Github 的方式是:Github 将自己的 pubkey 公布在网上

解决方案

为了实现对应用透明的加密通讯,我们在两个机房各搭建一个 Nginx,这里两个 Nginx 之间通过 mTLS 相互认证对方。应用将请求明文发给同机房的 Nginx,然后 Nginx 负责加密发给对方。对于应用来说,对方机房的组件就如同和自己工作在相同机房一样。最终搭建起来如下图所示。

搭建过程

因为用 HTTP 流量来搭建,相关的工具和日志会更友好一些。所以我们会先用 HTTP 将这个通道搭建起来,然后换成 tcp steam。

准备证书

我们一共需要两套证书,一套给 Client,一套给 Server. 因为我们这里主要要解决的问题内部互相信任的问题,不需要开给外面的用户,所以这里我们采用 self signed certificate. 即,我们自己做 CA,给自己签发证书。自签发证书的好处是很灵活,方便,坏处是有一些安全隐患(毕竟不像权威机构那样专业)。所以我把这个过程写在博客上,请大家帮忙看看流程有没有问题。

首先我们创建一个 CA 的 key,即私钥。CA 的 key 最好给一个密码保护,每次使用这个 CA 签发证书的时候,都需要输入密码。

生成 key 的命令:

输出(其中按照提示输入密码):

命令的解释:

  • openssl: cert 和 key 相关的操作我们都用 openssl 来完成;
  • genrsa: 生成 RSA 私钥;
  • -des3: 生成的 key,使用 des3 进行加密,如果不加这个参数,就不会提示让你输入密码;
  • 4096: 生成 key 的长度;

这里我们假设所使用的密码是 hello.

然后我们来生成 CA 的公钥部分,即证书。

这时会询问你一些信息,比如地区,组织名字之类的。其中,Organization Name 和 Common Name 需要留意。CA 的这一步填什么都可以。Common Name 又简称 CN,就是证书签发给哪一个域名(也可以是 IP)的意思。

输出会是如下所示:

命令的解释:

  • req: 创建证书请求;
  • -new: 产生新的证书;
  • -x509: 直接使用 x509 产生新的自签名证书,如果不加这个参数,会产生一个“证书签名请求”而不是一个证书。
  • -days 365: 证书1年之后过期,也可以省略这个参数,设置为永不过期;
  • key: 创建公共证书的私钥,会被提示输入私钥的密码;
  • -out: 生成的证书。

到这里,我们有了一对 CA 证书,ca.keyca.crt 两个文件。接下来申请 server 端的证书。

Server 端证书依然是先生成一个 key,这里就不需要密码保护了:

然后这里下一步不是直接生成证书,而是生成一个证书请求。但是那些问题依然是要回答一遍的。

回答问题的时候要注意两个地方:

  • Organization Name: 不能和 CA 的一样;
  • Common Name: 必须要写一个,可以写一个不存在的域名,比如 proxy.example.com。否则,会有错误:“* SSL: unable to obtain common name from peer certificate”。

否则证书无法使用。

到这里其实也可以看出,CA 的证书和其他的证书没有什么不同,也是一个普通的证书而已。

这个 .csr 文件是 Ceritifcate Signing Request,即请求签名。接下来我们使用我们的 CA 给这个 Server 证书签名(作担保!)。

这个命令需要输入 CA key 的密码,就是刚刚说的 hello

命令的解释:

  • x509: 公有证书的标准格式;
  • -CA: 使用 CA 对其签名;
  • -CAkey: CA key(没有这个岂不是人人可以用 CA 证书签名了?);
  • -set_serial 01: 签发的序列号,如果证书有过期时间的话,过期之后,可以直接用这个 .csr 修改序列号重新签一个,不需要重新生成 .csr 文件;

如此,就得到了 server.crt 文件。

我们可以使用这条命令验证生成的证书是 ok 的:

重复此流程再签发一个 client 端的证书。

结束后,我们有以下内容:

  • ca.key
  • ca.crt
  • CA 的密码,需要保存
  • server.key
  • server.crt
  • server.csr: 部署不需要用到,可以只保存在安全的地方即可;
  • Server 证书签发序列:只保存即可;
  • client.key
  • client.crt
  • client.csr: 部署不需要用到,可以只保存在安全的地方即可;
  • Client 证书签发序列:只保存即可;

然后接下来就可以部署起来了。

搭建远程 Server 端的 Nginx

为了模拟转发到后端应用的场景,这里的 Nginx 不使用静态文件,而是用一个 fastapi 写的样例程序来做后端:

启动的命令是:

程序默认会运行在 8000 端口。

然后修改 Nginx 的配置,nginx.conf 不变,我们只修改 default 的配置,将 default rename 成 remote_server,然后修改成成如下配置:

这就是一个很简单的 Nginx HTTPS 配置,证书配置上了我们刚刚自己签发的证书:

  • ssl_certificate: 告诉 Nginx 使用哪一个公有证书;
  • ssl_certificate_key: 此证书对用的私钥是什么,服务器需要有私钥才能工作。

证书已经配置好了。这时候我们去 cURL 443 端口会出现错误:“curl: (60) SSL: unable to obtain common name from peer certificate”,cURL 不信任这个服务器的证书。这是当然了,因为这个证书是我们自己作为 CA 签的。

要正常访问,必须使用 cURL --ca ./ca.cert 来告诉 cURL 我们信任这个 CA (所签发的所有证书)。

另外还要注意的是,记得我们之前的 Server 证书是签发给 proxy.example.com 的吗?我们这里必须要访问这个域名才行。需要这样使用:

--connect-to 的意思是,所有发往这个域名的请求,都直接发给这个 IP。

Client 对 Server 的验证就配置好了,接下来再配置 Server 对 Client 的验证。

我们只需要将上面的配置文件改成如下即可:

添加的内容的含义:

  • ssl_verify_client: 需要验证客户端的证书;
  • ssl_client_certificate: 我们信任这个 CA 所签发的所有证书。

这里有一个小插曲:Nginx 的文档上说,ssl_trusted_certificate 和 ssl_client_certificate 这两个配置效果都是一样的,唯一的区别是 ssl_client_certificate 会将信任的 CA 列表发送给客户端,但是 ssl_trusted_certificate 不会发。发送是合理的,因为客户端如果有很多证书,让客户端一个一个去尝试哪一个能建连是没有意义并且很浪费的。ssl_trusted_certificate 的作用是验证 OCSP Response。但是我尝试了 ssl_trusted_certificate,Nginx 会直接 fail 掉语法检查:

这里发现一个 ticket 询问和我一样的问题:https://trac.nginx.org/nginx/ticket/1902,不过至今没有回复。我以为是 Nginx 版本的 Bug,然后尝试了最新的版本依然是一样的结果。如果读者知道可以指点一下,谢谢。

这样配置之后 reload Nginx,就开启了对客户端的证书验证了。这时候我们继续使用上面那个 cURL,就无法得到响应。

Nginx 会要求你提供证书。

如下的 cURL,带上证书,就可以正常拿到响应。

这样,远端的 Nginx 就配置好了,它会提供证书证明自己的身份,也会要求客户端提供证书进行验证。

接下来搭建本地的 Nginx,将明文请求加密对接到远端的 Nginx。

搭建本地 Client 端的 Nginx

本地机房开启一个 Nginx,监听 80 端口,转发到远程的 443 端口。

配置如下:

这个配置可以分成两部分看,第一部分,是要验证对方的证书:

  • proxy_ssl_verify: 需要对方提供证书;
  • proxy_ssl_trusted_certificate: 我们只信任这个 CA 签发的所有证书;
  • proxy_ssl_server_name: 不像 cURL 的 --connect-to 选项,这里我们直接指定目标 IP 转发,但是我们使用 SNI 功能来告诉对方我们要连接哪一个 domain,来验证相关 domain 的证书;
  • proxy_ssl_name: 我们需要哪一个 domain 的证书。

然后第二部分是提供自己的证书:

  • proxy_ssl_certificate: 我的证书;
  • proxy_ssl_certificate_key: 我的私钥,不会发送给对方,只是本地 Nginx 自己使用。

然后就可以 cURL 本地的 80 端口了:

可以看到我们从客户端(cURL)发出明文 HTTP 请求,到服务端(fastapi)收到明文 HTTP 请求,两边都不知道中间流量加密过程,但是走公网的部分已经被加密了。就实现了本文开头的需求。

代理 TCP steam

以上是 HTTP 的配置,将其换成 TCP Steam 的代理也很简单,相应的配置修改一下就可以。这里我们以 Redis 服务为例来展示一下配置。

/etc/nginx/nginx.conf

Remote Server 的配置:/etc/nginx/sites-enabled/remote_server

local_client 的配置:/etc/nginx/sites-enabled/client_server

基本上就是把 HTTP 代理换成了 TCP 代理指令。

这样配置好之后,我们就可以用 redis-cli 去连接本地的 80 端口了。

 

一些参考资料:

  1. Nginx 反向代理相关的文档。http://nginx.org/en/docs/http/ngx_http_proxy_module.html
  2. CA 如何保存 key?https://security.stackexchange.com/questions/24896/how-do-certification-authorities-store-their-private-root-keys
  3. 自签名证书的风险:https://www.preveil.com/blog/public-and-private-key/
  4. TLS 建联的展示,每一个字段都有详细的展示,非常好看:https://tls.ulfheim.net/
  5. 什么是 mTLS? https://www.cloudflare.com/zh-cn/learning/access-management/what-is-mutual-tls/
 

2021 年年鉴

今年没有在跨年的时候及时写完年终总结,是因为那天还在西安隔离。2021 年一整年依然是疫情肆虐,下半年在新加坡尤其严重。所以在这一年的最后回想起来,还是比较无聊的。

所幸这一年经过的所有的事情都还算顺利,没有什么大起大落。

就先说说工作吧。其实今年的大部分时间都花在了工作上。从 6 月以来,新加坡的防疫政策从清零变成了与病毒共存,病例数量一直居高不下,一直到年末的时候才开始下降。所以下半年我们几乎都是在家办公的。在家办公就导致了很多问题,比如有同事在 town hall 上提出的 endless working hours,就成了我最大的问题。

以前想,居家办公可以让我们有更多的时间去安排自己的工作。但实际上实行起来,却发现工作时长不可避免的延长了。一方面,大家一起没有了通勤这个工作和生活的分割,所以有时候会在 IM 上在工作时间之外去沟通一些问题。很多时候虽然说不急,可以回头再回复。但是我看到了不回复就会难受,只好去帮人家解决;另一方面,下半年的大促一个接着一个,每个月至少一次。大促之前有事情要忙,大促之后又有因为大促而 block 的事情要忙,也导致工作时间延长。居家办公也导致工作设备的质量下降,没有一把合适的椅子,没有像样的办公的桌子(当初租房子的时候也没想到自己会在家工作这么长时间),导致开始腰疼了。

抱怨就说这么多吧,希望可以尽快恢复办公室办公,也希望这波疫情赶紧过去吧(这篇文章在 2月底捡起来继续写,发现第三波疫情又开始了,真的是烦)。

好在,今年做的工作都比较有意思,所以也没有感觉到多累。

今年工作上做的事情主要有两个,一个是公司的 service mesh 系统的维护。另一个是从零开始一个的 SRE Chaos Engineering 项目,我起的名字的叫做 Chaolab.

Service mesh 方面,其实没有什么特别大的项目或者改动,只不过是在各种部署结构和部署流程上修修补补,没有什么值得特别骄傲的,但是不知不觉,这个项目也越来越稳定了。比如:目前在升级的时候是无法保持住长链接的,通过发布的算法可以分批发布,每次不影响某一个服务 20% 的 instance,降低发布的影响;删除了大量不再使用的资源,归还了很多机器;下线了很多私有部署的 Nginx,迁移到公司的网关上去;将服务部署到了越来越多的机房,等等。

还有一个很有意思的事情,就是完全重构了部署使用的脚本,这个项目以及所有的依赖可以在一台笔记本上跑起来,一共涉及十多个组件,以及 20 多个 docker instance。算是去年一整年的工作成果的总结吧,之前下线了一些不必要的依赖,整理很多次部署结构,才能使它成为可能。

关于 Chaoslab 项目。这个算是比较有成就感的项目。一个人从头写了前端,后端,部署,agent 等组件,算是一个比较完整的项目了。主要实现的功能就是,你可以在页面上去创建一个实验,然后给物理机,或者容器等去实现注入 chaos,然后写 probe 去验证软件是否在注入故障之后依然工作正常。支持 crontab,权限控制,自动 rollback 等功能,大部分会使用到的注入选项都使用到了。

除此之外,就是一些杂七杂八的工作了。比如日常值班,回答别人的问题啥的。今年的感受就是,之前不被承认的工作在 Shopee 都是被认可的。还做了一个平台组件的 status page,这个是之前就想尝试的,一直依赖都有一个观点,就是应该通过公开的信息和用户信任感,用户需要知道你的组件的状态,在什么时间遇到过什么问题。虽然只是一些 CURD 的工作,没有什么技术含量。但是最后做出来非常满足。我一开始的设计有很多问题,但是很多人都给我提了建议,修改之后,就好很多了。

工作方面想到的就这么多吧。在开源方面,其实今年做的不够多,iredis 基本上是修修补补。目前的补全实现是基于每一个命令的,也就是说,redis-server 的改动,我都必须做出相应的适配,否则,一些新的命令或者老的命令修改了语法,都不会有很好的自动补全。然而 redis 6.0 新加入的改动都还没有支持完全。主要是没时间去做。

今年开了一个新坑,lobbyboy. 是一个可以自动帮你开新的虚拟机的 ssh server。一开始是满足自己的需求,后来看到其他人也有需求,也有很多贡献者来提交代码。甚至在这个项目上写的代码比我都多了。后来把这个项目迁移到了一个 org 下面,而不是只有我自己有权限。虽然后面在实现了基本的功能之后也没有花很多时间在上面了。

今年学会了什么?

新学的东西不算多,感觉自己在啃老本。倒是接触了 Prometheus,读了 Up&Running 那本书,感觉对于使用算是比较了解了。

另外看了一些 golang 的教程,目前算是能看懂 golang 的代码,但是写的话还是差点火候。今天还是继续学习一下 golang 这门语言,多看一些代码和书。

另外感觉自己的沟通能力有了很明显的提升。能够在别人描述不清楚自己的问题的时候帮别人解答问题。:)

明年计划学习什么?

除了 Golang 之外,Kubernetes 打算多花点时间学习一下,因为工作中要用的到。

工作和学习的事情就说这么多吧。去年也发生了很多有意义的事情,很多也是一时半会说不完的。年的时候从新加坡回国过年,疫情之下的回国程序真的是复杂呀,交了数不清的文件,终于拿到大使馆发的绿码了。但是偏偏不巧选择的落地地点是西安,落地的第二天西安宣布封城。终于在隔离结束的时候,回到了上海,上海又说你是从西安回来的,作为重点人群需要隔离14天。虽然我极力解释我是在隔离结束之后完全闭环转移到上海的,没有接触过西安的人,也是无济于事。最后还是被拉倒隔离酒店隔离了14天。这28天应该是我在这一年里面最郁闷的。

今年年初的时候和老婆一起去拍了婚纱照,寒冷的冬天在外滩懂的瑟瑟发抖,辛苦老婆了。

今天的首要任务之一就是将婚礼举办好,嘻嘻。

年后经过香港转机返回新加坡,正巧第三波 Omicron 疫情又开始,香港和新加坡又是疫情比较严重的地方(怎么我这段时间去哪哪严重的?!)。经过了道道审核,最后还算是顺利地回到了新加坡。

路上还见了多年未见的老朋友,一起吃了快乐小羊。他吃羊间隙还得拿出来电脑工作,我感叹大家都成社畜了。

回到新加坡之后,很快安顿了下来,换了一个房子,也很快进入了工作状态。

好像也没什么好说的了。2021 年是过的比较平静的一年,2022 年也没有什么特殊的计划。老婆的计划倒是比较重要,今年除了婚礼要办,老婆也在努力朝着一些会改变人生轨迹的大事上面付出努力,我只有默默地支持,提供帮助,希望一切顺利。总之,希望2022年能够多付出一些,过的也更多姿多彩一些吧。


往年的总结:

  1. 2013年
  2. 2014年
  3. 2015年
  4. 2016年
  5. 2017年
  6. 2018年
  7. 2019年
  8. 2020年
 

寻找丢失的信号

记录一个今天遇到的小问题。这是继 Debug 一个在 uWSGI 下使用 subprocess 卡住的问题 之后又一次遇到信号问题。

我写的 chaos engineering 平台支持一个功能:立即中断正在进行的实验,并且执行 rollback 操作复原注入的操作。每一个实验都是由一个进程负责的,终止的方法是向进程发送一个 SIGINT 信号,让进程停止注入并且切换到 rollback 开始清理。

最近的一个改动从 asyncio.create_subprocess_exec 切换到了 asyncio.create_subprocess_shell 导致了一个 bug,现象是线上的执行器根本收不到停止的信号了,刹车失灵,险些酿成悲剧。

经过警方调查发现,asyncio.create_subprocess_shell 其实会开一个新的 shell 来执行命令,默认使用的是 sh,而 sh 默认是不转发它收到的信号的。(这里我是用了 killsnoop 来发现 sh 确实收到了信号,但是执行 chaos 的进程没有收到,然后查阅文档并通过实验复现确认 sh 不会转发信号。)

但是这个问题我在开发环境(Mac)并没有测试出来,因为开发环境工作的好好的。

我写了一个最小的 case 可以复现这个场景:

在 Mac 上的表现是,python 进程的子进程就直接是 sleep 进程,并没有一个中间的 sh 进程。

而在 Linux 上的表现是:python 进程的子进程是 sh 进程,然后 sh 的子进程才是 sleep 进程。

经过 ./grey 指点,发现在 Mac 上 sh -c "sleep 99" 之后,sh 自己也不见了,只有 sleep 99 这个进程,父进程是我自己的 zsh shell.

这里就真相大白了。中间进程的这个 sh 并不会转发 signal,所以在线上的 Linux 系统上收不到信号;在开发电脑上由于没有中间的 sh ,所以直接将 signal 发给了子进程。

那么 sh 在两个系统上到底有怎么样的不同呢?

在我的 Mac 上,man sh 说:

sh is a POSIX-compliant command interpreter (shell). It is implemented by re-execing as either bash(1), dash(1), or zsh(1) as determined by the symbolic link located at /private/var/select/sh. If /private/var/select/sh does not exist or does not point to a valid shell, sh will use one of the supported shells.

经过查看,可以发现其实 sh 在 Mac 上是 bash:

对于 bash -c "sleep 99" 这个命令,bash 有一些优化,为了节省资源,bash 会直接通过 execve() 去执行 sleep,这样在系统上就可以少存在一个 bash 进程。详细解释

而在 ubuntu 上,sh 其实是 dash:

dash (至少我们使用的版本)还没有这个优化,所以在 Python 的 subprocess shell (经过 linw1995 指点)中就会有两层进程,一个是 dash,dash 的子进程才是运行的命令。

在 ubuntu 上 bash -c "sleep 99" 可以看到 bash 本身也是会消失的。说明这个确实是 bash 的行为。

bash 进程消失不太准确,它其实是换了一个形式存在而已。strace可以证明它存在过:

 

反思一下这个问题,有以下几点可以做的更好:

  • 换成 Linux 开发;
  • 写测试用例,CI 完全可以发现这个问题;
  • 还是尽量使用 asyncio.create_subprocess_exec 来执行命令吧!