Side Project 成本最小化运行

有时候,人们会忘了今天的计算机资源已经如此强大,一台 $5/月 的机器可以干多少事情。

之前有人在讨论 redis.io 这个官网上,访问量一定巨大,而且可以实时运行 Redis 命令,一定用了很多机器,是一个分布式的系统。但其实就是跑在一个 $5/月的 VPS 单机上。

原文,后来替换成 4G 内存的机器了。

我在工作之余会做一些出于兴趣的 Hobby Project,这些 Project 可以分成两种:

  1. 工具和库之类的,只要下载就可以使用,用户量再大也不会增加我的成本;
  2. 提供服务的网站类型,需要花钱购买域名和服务器,并且要付出维护成本;

对于第二种类型,只有减少成本,项目才可能持续:

  1. 每一个项目刚开始的时候可能都没有什么人,如果不节省成本,很可能在一开始就没什么用户,以及高昂的运行成本而做不下去
  2. 成本低可以长时间积累用户

这篇博客写一些如何降低运行成本的方法。

运行平台

如果是静态网站,选择就很多了,cloudflare, Vercel, netlify, 都可以。只需要把前端的文件上传上去就可以了。

如果是动态网站,近几年也有很多不错的 SaaS 平台可以选择:Sass 部署可以选择 fly.io, heroku, serverless 可以选择 cloudflare, aws 等等。这些平台的免费额度基本都可以覆盖很多场景了。

但是我做的小东西基本上都需要运行用户提交的代码,所以我一般用 DigitalOcean 的虚拟机。好处是便宜,自己完全可以用控制 VM,而且账单透明。不会有 vendor lock, 随时可以换另一家的 VM 用。

部署

如果选择 SaaS 的话,就需要根据使用的 SaaS 平台写部署描述文件。

如果使用虚拟机来部署的话,我一般使用 ansible 来管理部署:

  • 将代码放在一个 repository, 可以公开,也可以私有
  • 使用一个 私有的 repository, 存放 ansible 配置文件,包括一些 secret 文件,host inventory 都放在这里面
  • 每次发布版本的时候,在代码 repository 提交 tag,build binary
  • 然后在 ansible repository 修改部署的 tag,提交一次部署(这一步其实可以集成到 CI 里面去,每次改动都去自动运行一下 Ansible)
  • 保持一个原则:就是给你一组新的 IP,能够在几分钟之内搭建好一模一样的集群(这样可以不用担心原来的集群坏掉,没有运维负担)

Logs 和 Metrics

Logs 也有一些 Saas 平台可以使用,比如 Datadog, sematext, 但是我都没有用过,一个是会提高管理成本,也会提高运行成本,即使 Saas 是免费的,你也需要流量把 log 发出去,而且我比较习惯使用命令行的工具看日志。

关于 Metrics, 其实可以不必关心,小型的服务一开始用 jvns 介绍的方法完全足够了:Monitoring tiny web services,即使用黑盒监控探测,以及定时 curl 网站的方式,从小时级别确保网站在正常运行。这样已经可以检测足够多的东西了,比如 SSL 证书过期,数据库挂了等情况。

如果要更精确的监控,一个比较好的方法是部署 Prometheus + Grafana, 两个组件一个是收集 metrics 的一个是用来展示 metrics 的,都可以用 docker 来启动,然后用 Ansible 管理配置文件,做监控代码化,也能做到随时迁移。

如果要运行用户代码的话……

之前做的一个项目叫 clock.sh, 是一个定时执行用户代码的服务,可以理解成 serverless crontab. 最近还在跑的一个项目叫 xbin.io, 提供一些命令行工具,不用安装就可以运行。这两个项目都需要运行用户代码。

Jvns 在她的博客写过很多解决类似需求的工具分析,基本上和我想要的一样,非常推荐阅读一下:

我现在使用的方案是:Docker + runsc, 使用最新版的 docker,避免 0day 攻击。然后用 runsc 只能允许有限的 syscall,就可以满足大部分的安全需要了(暂时还没出过问题)。

开发和测试

前面说了这么多有关运维部署的事情,其实写代码也有一些技巧。就是尽量不要自己从头自己实现一个功能或者模块,你的代码应该只去实现核心模块。(除非你要写的东西很好玩)。因为业余的时间有限,要把一件事情做成,就要把时间用在关键的地方。几个原则:

  1. 如果一个功能需要很复杂的实现,就用现成的库。比如登录功能,涉及反垃圾,验证邮箱,管理 session,那么直接用 Oauth 库 + Github 账号登录,几行配置就解决了;
  2. 如果一个功能不算复杂,但是现有的库的实现都比较复杂,那么自己写。比如你的一个服务去调用另一个服务,只有一个 API,用 protobuf + gRPC 太复杂了,不如自己设计一个 TCP 协议,或者直接用 HTTP;
  3. 实现的时候写的代码越少越好。代码越少,bug 越少,1年之后你在看自己的代码也看的懂。

有关测试:

  1. 可以只写关键地方的测试,以及复杂的地方的单元测试,核心目的是避免自己以后修改代码的时候不注意改错了复杂的地方;
  2. 可以不写集成测试,因为集成测试都很复杂,即使通过了集成测试,你也不会很有信心一定没问题。所以集成测试即使通过了,一般你也会手动再去验证一下,所以意义不大;
  3. 不是一定需要 CI 自动运行测试,你可以在 merge 之前手动在本地跑一下测试。因为 CI 的调试和维护也很花时间,只有自己一个人的话,makefile 反而更好用。

最后一点

对于业余的项目,应该让自己的精力放在比较重要的(快乐)事情上。自己维护的东西要越少越好。

比如要实现一个功能的话,现成的 SaaS > 基于库实现 > 自己从软件方面实现 > 使用新的开源项目额外部署系统来实现。问题尽量从软件自身解决。举个例子,比如每小时要清理一下不在运行的容器,一个方法是写一个脚本做成 crontab 运行,但是一个更好的方法是在软件本身开一个后台线程,每隔一个小时就检查一遍。这样,就不需要维护 crontab 了(实际上是由 crond 运行的)。

 

长连接负载均衡的问题

在分布式的系统中,如果服务 A 要调用服务 B,并且两个服务都部署了多个实例的话,就要解决负载均衡的问题。即,我们希望到达 B 的 QPS 在 B 的所有实例中都是均衡的。

以前的类似 HTTP/1.1 的实现中,服务 A 每发起一次请求,都需要跟 B 建立一个 TCP 连接。所以负载均衡的实现方式一般都是基于连接数的。但是每次都建立一个新的连接,性能会很低。所以就有了后来长连接的实现方式:建立一个 TCP 连接,在这上面发送很多请求,复用这个 TCP 连接。gRPC 就是基于 HTTP/2 实现的,用了这种长连接的方式。

用长连接会提高性能,因为不用每次都去重新建立一个 TCP 连接。但是也有一些问题。

第一个问题就是负载均衡。Kubernetes 这篇博客讲了为什么 gRPC 需要特殊的负载均衡。很显然,HTTP/1.1 的方式,每次随机选择一个实例去调用,负载是均衡的。但是 HTTP/2 这种一直用一个连接的方式,一旦连接上了就会一直用,使用哪一个实例就取决于最开始选择的谁。

即使是一开始有办法让它连接均衡,但是有一些情况会打破这种均衡。比如说一台一台重启 service instance。

在每重启了一个 instance 之后,原本连接这个 instance 的 client 就会与其断开连接,转而去连接其他的可用 instance。所以,第一台被重启的 instance 重启完成之后是不会有连接的。其他的 instance 会增加:(1/n)/(n-1) * total connections 的连接。n 是总实例数。

因为每一个 instance 重启之后都会增加其他 instance 的连接数,就有两个问题:

  1. 第一个重启的 instance 到头来会有最多的连接数,最后一个重启的,不会有连接,非常不均衡
  2. 最后一个重启的 instance 在重启的时候会造成大量的 client 去重连

第二个问题就是增加服务端 instance 的时候,不会有 client 去连接它。即服务端迁移/上线下线的问题。因为所有的 client 都使用原来已经建立好的连接,不会知道有新的 instance 可用了。其实说到底和第一个问题差不多。

解决的方法,想到 3 个。

第一个就是如同上文博客中提到的那样,在 client 和 server 之间加一个 load balancer,来维护到后端的连接。可以完美解决上面两个问题。缺点是资源会比较高,架构增加复杂性。

第二个方法是从服务端解决:服务端可以不定时给客户端发送 GOAWAY 指令,示意客户端去连接别的 server instance。api-server 有一个选项是可以指定用多少的概率去给 client 发送这个指令:–goaway-chance float.

To prevent HTTP/2 clients from getting stuck on a single apiserver, randomly close a connection (GOAWAY). The client’s other in-flight requests won’t be affected, and the client will reconnect, likely landing on a different apiserver after going through the load balancer again. This argument sets the fraction of requests that will be sent a GOAWAY. Clusters with single apiservers, or which don’t use a load balancer, should NOT enable this. Min is 0 (off), Max is .02 (1/50 requests); .001 (1/1000) is a recommended starting point.

这样还有一个好处,就是下线的时候,不是粗暴地退出,而是可以对自己当前所有的连接都发送 GOAWAY 指令。然后无损地退出。

第三个方法就是从客户端解决:客户端不使用单一连接去连接服务端,而是使用一个连接池:

  1. 客户端每次要发送请求的时候,需要先向自己的连接池请求一个可用连接:
    1. 这时候,如果有,就返回一个连接
    2. 如果没有,就发起建立连接
  2. 使用完成之后,将连接放回连接池
  3. 连接池支持设置一些参数,比如:
    1. 如果 idle 一定的时间,就关闭连接
    2. 一个连接 serve 了多少个 request 之后,或者被使用了多少次之后,就关闭它,不再使用。

这样,一来可以解决一个连接被无限使用的问题,而来关闭连接也是无损的,因为连接池里面的连接没有给任何人使用,由连接池自己管理。其实,像数据库客户端,比如 jdbc,以及 Redis 客户端,都是这么实现的。

 

无法 Patch

最近在试图实现 pdir2 彻底 disable color 的 feature, 让它和其他 cli 的做法一样:在 stdout 是 TTY 的时候默认开启带有颜色的输出,在 stdout 不是 TTY 的时候不输出颜色,即没有颜色相关的 escape code. 这个 pull request 在这里

实现比较简单,在非 TTY 的情况下,新建了一个 Fake 的 Color Render,没有做任何渲染,直接输出。

但是在测试的时候遇到了难题:之前的测试都是按照默认输出颜色来写的,而 pytest 运行的时候,显然是没有 TTY 的,我的代码改变了这个行为,导致 pytest 运行测试的时候,颜色都消失了。

我试图用 patch 来解决这个问题,设定一个全局的 fixture,让 pytest 运行的时候,sys.stdout.isatty() 返回的是 True, 这样所有之前的 test case 都可以依然 pass. 然后就发现了一个非常难解决的问题。

使用 sys.stdout.isatty() 的地方,是在一个 module 的全局的地方,判断之后设置了一个全局变量,类似如下:

而这个 module 在 pytest collecting tests 的时候就已经 import 了,所以这个 use_color 缓存在了 module 的全局中,即使我去 patch isatty() , 实际上也不会调用到了。

 

于是我想到删除这个缓存。在 Python3 里面做这件事很简单,用 importlib.reload("pdir") 就可以了。

然而又出现了一个棘手的问题:pdir 这个 module,其实在 import 之后就已经不存在了。作者为了想让用户这么使用:import pdir; pdir(foo) 而不需要 from pdir import pdir; pdir(foo),用了一个 trick:即,在 pdir.__init__ 中,直接将 sys.module['pdir'] 替换掉了: sys.modules[__name__] = PrettyDir,这样的好处是:import 进来的不再是一个 module,而是一个 class,直接可以调用了。但是坏处是,我们再也找不到 pdir 这个 module 了,也就无法使用 importlib 进行 reload.

为了能够让它在 patch 之后进行 reload,我尝试了很多 hack,比如直接 patch 它的全局变量,发现会失败,因为 patch 也找不到 target 了,因为 patch 也找不到 pdir 这个 module,会提示 class 没有你要 patch 的这个属性;另外尝试过,在源代码中,sys.modules[__name__] = PrettyDir替换之前先保存原来的 module 到一个新的名字,发现也不行,这个 import 机制貌似必须让 module 的名字和 module 对应。

睡了一觉之后,我又在思考为什么 pytest 在收集测试的时候就运行了 module 的 init 呢?为什么不让我先 patch 好,它再去 import?

又仔细看了代码,发现,有一些 test.py 文件,在文件的开头就 import pdir 了。这时候,无论我怎么 patch,后面运行测试的时候都会使用已经 init 好的。

所以要解决这个问题,其实很简单:

  1. test 文件不能再任何地方 import pdir,必须要在 test case 里面进行 import,这样,收集测试的时候不会初始化 module
  2. 然后我设置一个全局的 fixture 去 patch isatty()。这样,执行的逻辑就变成了:收集测试(没有 pdir init)-> 全局 fixture 执行, isatty() patch 为 True -> 执行测试 -> 测试内部 import pdir -> pdir 认为 stdout 是一个 TTY

 

这样还有一个不好的地方,就是全局初始化好了,测试中就无法再测试不是 TTY 的逻辑了。

最后在代码中看到一段 sys.modules 的删除逻辑,好像作者也遇到过类似全局变量在测试中需要重新初始化的需求。把这段代码放到 fixture,发现居然神奇的工作了。原理很简单,就是我 patch 了之后,需要删除所有 pdir 的缓存,这样 import 的时候,就会重新 init 一遍。需要注意的是,不能只删除 pdir, pdir.* 都需要删除。

这样,只需要在测试 TTY 的时候,使用 tty 这个 fixture,不使用的话就默认不是一个 TTY。

 

要解决这个问题, 还有其他一些可能的思路有:

  1. 让 pytest 为每一个测试(或者测试文件)重新开启一个 python 解释器,这样就完全干净了。但是没看到 pytest 有这样的 feature
  2. 减少全局变量的使用,每一次调用都判断是否是 TTY 的逻辑,这样就是为了测试去修改原来的逻辑了,不太喜欢
 

像设计 UI 一样去设计配置项

在蚂蚁金服工作的时候,见到和使用了很多设计糟糕的系统,其中涉及最糟糕的叫做一个  AntX 的东西,现在想起来还会吓得发抖。

具体的实现已经记不太清了,因为自己从来也没有真正掌握过。只是记得这个其实是一个动态配置的系统,要使用它,在你的应用中要写入3个模板文件,其中一个模板会先进行 render,再去用这些变量 render 另外一个模板;render 的过程用到的变量是在一个 web 界面上配置的,并不存在于代码中;实际 render 出来的结果,有一些是程序编译的时候注入实际变量,貌似有一部分还是在运行时进行注入的。

复杂的模板文件、每一个模板还是使用不同的语法、需要在 web 配置、配置区分编译时和运行时变量、区分不同的环境变量。这听起来就是一个灾难了,我相信整个公司也找不到几个人能把这个最终配置生成的过程说的明明白白。最终大家也承认这套系统无法继续维护了,程序中已经存在有很多变量其实是没有用到的,但是程序跑的好好的,谁也没有动力去梳理一遍。最后,大家决定写一个新的配置系统来替代它。

Config 是面向用户的东西,应该像 UI 一样追求简洁,易懂,避免歧义。因为配置错误而导致的事故数不胜数,其实,很多都是由配置的作者以及使用者的理解有代沟造成的。这篇博客就来讲一讲我觉得不错的配置实践。

 

There should be one– and preferably only one –obvious way to do it.

软件的输入有很多种,命令行参数、环境变量、stdin、配置文件等都可以作为配置项去控制软件的行为。很多软件对同一个配置提供多种配置方式,增加了复杂度。

比如 Etcd 的这个 BUG:grpc gateway 在使用 –config-file 的时候默认是 false,不使用 –config-file 的时候是 true。即使 –config-file=/dev/null 也会变成 false。当时花了很长时间去排查。

我觉得对于服务端的软件,其实大部分配置都要求从配置文件中配置就可以。在 lobbyboy 这个项目中验证了一下自己的想法。lobbyboy 作为一个 server 端软件,所有的配置只能从配置文件输入,除了 -c 可以改变配置文件的 Path 之外没有别的命令行参数。

Nginx 的命令行也没有多少参数,大部分配置都是通过配置文件来控制的。

当然对于 Client 端软件来说,就比较复杂了,很多参数同时支持环境变量、args、xxxrc 文件配置,会比较用户友好一些。比如 iredis 的配置文件读取顺序是:

  • Options from command line
  • $PWD/.iredisrc
  • ~/.iredisrc (this path can be changed with iredis --iredisrc $YOUR_PATH)
  • /etc/iredisrc
  • default config in IRedis package.

客户端软件的配置最好遵守 XDG Base Directory Specification

 

Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.

让配置更加简单一些,在设计的时候,可以考虑这几个问题:

  1. 这个配置是否有必要?用户在大部分场景下是否用默认值就足够了?
  2. 多个配置是否可能用一个配置没有歧义地讲清楚?
  3. 可以这样考虑:添加一个新的配置的时候,如何使用最少的语言将这个配置解释清楚?

在 UI 的设计中,每一个像素都是重要的,每一个空间都要想办法争取节省,添加太多没必要的东西,将会把UI变得不直观。配置也是,配置不是一个无限大的文件,配置的添加是会带来成本的,不是程序运行的成本,而是人的成本,这比运行的性能更加重要。

说到动态配置,很多时候我都在怀疑这到底是不是一个伪需求。现在的应用都被设计成无状态的,可以随时重启的。那么我修改配置的时候,是否可以改一下配置文件然后重新部署?当然,如果有上千个实例的话,修改速度可能会成为一个问题。

有动态配置功能的时候,一个误区是开发者会将所有“感觉将来可能会修改”的配置都放到动态配置中。其实我觉得所有的配置都可以先作为 hardcode,如果发现需要修改的多了,再移动到配置文件或者动态配置中。

Json 绝对是一个糟糕的配置语言。它的优点在于机器读取和解析没有歧义,不像 Yaml 那样。

Why JSON isn’t a Good Configuration Language

但是它对于人类编辑来说,实在太不友好了:

  • json 只是易于解析但是太难编辑,非常容易出错。比如 [] 不允许在末位添加 ,,否则会出现格式错误。这样,你每次编辑的时候,添加一行会出现两行 diff.
  • json in json 更是噩梦。我发现一个规律,就是在有 json 的地方,就会有 json in json(指的是 json 里面有一些 value,是字符串,字符串只是添加了转义的 json)。甚至有可能有 json in json in json.
  • 比如很多list都设计成 items:[{key: name, value:jack}],一个list 这已经就已经有两层的嵌套了。
  • Json 对于 line editor,比如 sed,awk 也不够友好。
  • 不支持注释,导致无法在文档里面给出一个 self-explain 的 example.

 

If the implementation is hard to explain, it’s a bad idea.
If the implementation is easy to explain, it may be a good idea.

很多配置的名字都和实现绑定,可能是一个思维定式。为一个配置想出来一个好的名字,可以尝试忘记实现的细节,想一想如何能够直白、没有歧义地、容易被记住地解释这个配置。

一个典型的例子是,配置尽量不要使用双重否定。比如 disallow_publish,最好使用 allow_publish.

uWSGI 里面有一个配置叫做 harakiri,是日语的“切腹自尽”的意思,表示一个进程在规定的时间内如果没有完成请求的处理,就会退出。比较贴切。

Redis 的 save 触发时间的配置我觉得设计的很好:

可惜的是,这种设计好像没有什么模式可以遵循。

后记:其实这篇文章放在草稿箱里面很久了,只是有一些想法,但是不知道怎么描述比较合适。配置如同给变量起名字一样,是一个难以说清的话题。本文也只是潦草的表达了一些凌乱的想法,看了设计服务端软件配置的 4 条建议 这篇文章,决定将草稿箱里面沉睡已久的文章发出来。欢迎读者交流沟通。

 

心动网络宣传片的翻译

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

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

 

做完需要多久?

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

没有 deadline 吗?

我的原则,高于 deadline.

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

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

什么时候才能做完?

当我们接近完美的时候。

他们说完美是不存在的。

所以我们要更加努力。

你为什么选择游戏?

单纯是为了它的纯粹。

如今,什么都是 Pay to win。

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

为什么不遵守规则?

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

你不应该更现实一点吗?

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