我的删库经历

看了一篇文章讲自己删库经历,感同身受的一点是:

But, as far as I know, no one ever asked why a junior developer had access to a production database. I probably did need read access, but why write access? And if I did need write access, did I really need permission for a destructive command like TRUNCATE?

Blameless 的事故 review 如此重要,以至于如果没有它,我们会更加注重在人犯的错误上,而不会关注到系统上的漏洞:为什么初级工程师会有 write 权限?为什么 TRUNCATE 没有禁止?为什么无法从一张表恢复备份?

我唯一的一次删库经历是删除了一个线下的测试库。事情是这样的。

我刚入职上一家公司不久,pull 下来项目的代码,发现是一篇狼藉,几乎无法维护(不是我个人品味上认为的无法维护,是客观上的无法维护的代码,如果你读完,就可以想象这些代码的质量了)。

于是我的计划是:

  1. 先修复好单元测试,让每一次提交都通过单元测试;
  2. 逐步和团队重构代码;

那是一个基于 Django 的项目,之前的单元测试因为配置问题,都无法启动。在修复完这些问题的时候,我运行了测试命令:python manage.py test.

几分钟之后,发现测试库被删除了。这才发现,之前的代码中数据库的配置也有一个问题,测试环境连接的数据库,和单元测试定义(本来不需要定义,django 自己会生成)的名字是同一个数据库的名字。Django 运行测试的时候,如果不指定 --keepdb,Django 运行完测试之后会删除掉测试用的库。

虽然是一个线下的测试库,但是导致了很多的问题:

  1. 大家把很多配置数据直接通过 shell 写到了数据库里面,代码中没有备份;
  2. DBA 不负责维护线下库,无法恢复备份;

所以最后只能通过凭借记忆恢复数据。

这个也暴露出来很多问题:

  1. 为了图快,这个项目的开发有很多没有遵守规范的地方,比如直接将重要的数据保存在了一个没有 SLA 保证的数据库里面;(后来我发现很多这个公司内内部项目都是这么做的,以线下环境为主,因为线上环境每次发布和修改审批流程太复杂了,只改动一行代码发布到线上需要至少 40分钟。在很多人眼里,内部项目的质量是不重要的,只需要晋升答辩的时候能够有一些系统的截图就可以。)
  2. 数据修改应该使用 Django 的 Data migration,这样非业务写入的数据都会在代码库中持久化,还有历史记录;
  3. 项目有危险的配置错误;
  4. 单元测试从来没有运行成功过;

这个项目最后的命运也以无法维护而告终,我们最后重新写了一个项目来取代它。

 

系统的过载(Overload)以及处理思路

本文所谈到的过载,容易和雪崩混淆。雪崩指的是在分布式系统中,某一个依赖无法正常提供服务,但是系统无法屏蔽掉这个依赖,最终导致整个系统都无法提供服务了。

过载指的是某一个服务所收到的请求量原大于它能处理的请求。

所以后续的讨论使用过载这个词,虽然有的时候我们在讨论相同的问题,但是用的是“雪崩”这个词。

对于过载,似乎有一个很简单的解决方法:现在很多系统的设计都已经考虑好限流了。即,假如某一个系统能处理的 QPS 是 1000,那么它只会同时允许 1000 个请求进入处理,当第 1001 个请求来的时候,会迅速得到错误,直接返回。这样保护系统总是可以成功处理 1000 个请求的。

这就是 Rate limit, 一个典型的 overload protector. Rate limit 有很多实现的方法:Token bucket, Leaky bucket, 等等。

然而这样就足够了吗?

想象这样一种情况:有一个网站(比如您当前正在阅读的这个博客),所能够处理的 HTTP 请求是 1k/s. 但是目前有 1万 个访客正在访问,然后触发了网关(比如说是 Nginx)的限流机制,对于每秒钟第 1000 个之后的请求都直接访问 429 Too many requests.

这样的话,网站本身每秒都在处理 1000 个请求,看起来是在正常工作的,但是对于终端的用户来说呢?他们能正常浏览网页吗?答案是不能的。因为,拿博客的首页来说,一共要发送 61 个 HTTP 请求才能正常显示。也就是说,每一个用户都需要成功拿到 61 个 HTTP 的响应才能看到一个完整的网页,否则,可能缺失一些 css 文件导致网站的格式显示不正确;或者缺失了某些图片导致不知道内容在说的什么;或者无法请求某些 js 导致部分功能缺失。为了看到完整的网页,他们可能重新刷新网页,进一步加剧了当前网站承受的总流量。

再考虑这样一种情况。在一个微服务的系统中,为了展示一个商品的详细信息,“商品微服务” 需要调用3次“用户微服务”,目的分别是:

  1. 拿到用户所在的地区,用来计算运费;
  2. 拿到用户的会员等级,渲染优惠;
  3. 拿到用户当前账户中的积分信息,渲染出来如果使用积分的话可以优惠多少钱;

假如“用户微服务”过载的话,用户微服务对于所有的请求来源进行限流,那么现在这三个请求每一个都可能失败,每一个失败了都无法成功渲染出来商品详细信息。

还有一种情况。假设某一个服务发现系统使用 Etcd 这种带 lease 的数据库作为后端依赖。一个服务注册上之后,需要不断 renew lease, 才能保持在线上。如果它挂了,不再 renew lease,那么当 lease 过期之后,Etcd 就会将其从数据库中删除掉。

某一天,这个系统重启了,所有的服务都开始重新注册自己,请求量太大导致 Etcd 过载了。为了保护自己,Etcd 也有 Rate limit 机制,它只允许 3000 个请求同时处理,对于第 3001 个请求也会直接返回错误。这时候,有一些服务能够注册成功, 但是后续的 renew lease (keepalive) 请求可能失败。这样,即使 Etcd 能够成功处理 3000 个注册请求,但是这些注册功能的服务因为 keepalive 请求无法被接受,所以不得不重新注册自己。就会导致 Etcd 一直处于一种 overload 的状态,永远无法恢复。

以上几种情况,都是在有 Rate limiter 的情况下,系统依然会被 overload。

Overload 的时候,总请求量是比容量要大的,那么我们的解决思路就是,要保证总请求量随时间不断减少,最终,总请求量在能够处理的容量之内。虽然有些请求可能失败,但是他们最终的重试是会成功的。换句话说,要保证我们处理的请求都是“有效的”请求。

举一个反例:网站使用队列来缓存住超过容量限制的 HTTP 请求,然后不断从队列中取出来请求处理。这之所以是一个糟糕的方案,是因为在 HTTP 的场景中,如果一个用户打不开网页,那么他会刷新而不是等在这里。所以网站服务器从队列中拿出来的请求总是用户已经放弃的请求,那么一直在处理“无效”的请求了。

一些可以考虑的思路如下。

按照上下文进行限流

当过载的时候,我们要保证依然在容量限制内的数量的用户是可以正常服务的。但是不能单纯的从请求的角度来进行限流。而是应该加入业务上下的维度。比如:

  • 假如一个用户打开了某一个页面,要保证后续的操作都是成功的,至少可以完成一次交易;假如某一个用户已经被 rate limiter 失败了,那么这个用户的请求我们都让它失败掉,不要再处理他的请求浪费资源了;
  • 或者按照用户 id 进行 sharding,只保护某一部分用户;过一段时间保护另一部分用户;

原子化调用

在上文中第二个例子中,可以考虑将三次请求变成一次请求。这样按照请求维度的限流依然是有效的。

切换成异步链路

对于某些情况,可以考虑使用上面的“反例”。比如说用户的付款请求,如果遇到了失败,那么用户很可能放弃这笔交易。我们可以考虑使用队列来让付款请求排队,尽可能让付款请求有更大的能够成功处理的机会:

  • 显示明确的处理中的字样,尽量给与用户安抚;
  • 如果用户尝试取消,弹出确认提示可以取消付款,交易尚未发生,但是鼓励继续等待;
  • 对于信用好的用户,或者单价低的订单,考虑先推进交易,异步从账户中扣款;

临时的降级(断路)

在雪崩的情况下能够有应急的方式临时屏蔽掉一些消耗资源的特性。

比如在上述 Etcd 的场景中,我们可以考虑,当服务首次注册成功的时候,将 lease 的时间设置为 20min,在第15min 的时候开始尝试 renew,如果失败,以指数级的时间退避重试。如果重试的次数比较少就成功 renew 了,就将 lease 的时间设置为 10min;下一次再缩短 lease,直到 lease 是一个足够短,满足服务健康检查的时间。这样可以在短期内给与 Etcd 足够的时间去恢复,保证它当前正在处理的请求都是最终的要求(指的是,把服务注册上去是第一要务,健康检查踢掉异常服务是次要任务,可以暂时降级)。

 

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/