心动网络宣传片的翻译

心动公司的网站有一段很好看的宣传片,可以在 https://2400.hk/ 观看。这段影片有中文版本,在 https://www.xd.com/ 可以观看。相比于英文,我觉得中文在刻意避免翻译痕迹,反而看起来表达没有英文有力一些。

如果减少一些意译的力度,听起来可能更加自然一些。如下:

 

做完需要多久?

等做完的时候,我会告诉你。

没有 deadline 吗?

我的原则,高于 deadline.

你的产品不是为了赚钱吗?

先做好产品,钱自然会赚到。

什么时候才能做完?

当我们接近完美的时候。

他们说完美是不存在的。

所以我们要更加努力。

你为什么选择游戏?

单纯是为了它的纯粹。

如今,什么都是 Pay to win。

所以有人必须站出来,倡导 Play to win。

为什么不遵守规则?

规则是给玩家的,我们需要原则。

你不应该更现实一点吗?

满足于现实,我们就不会选择这个行业。

 

我的删库经历

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

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,每次点击都会随机到一个页面上去。