参与 CBDB online query app 项目的总结与分享

    大家好,我的花名是冰码牛,目前在北京大学本科就读。因为研究组的合作,有幸负责 CBDB online query app 项目容器的打包、发布、部署工作。在这篇博文里,我将介绍整个服务系统的架构。

​    在参与项目的过程中 CBDB 资深项目经理宏苏给了我很大的权限自由:有必要的话我可以以 root 权限在服务器上执行指令。但是因为这台服务器上面还运行着其他关键服务,所以我自己必须严格「律己」,避免做出非常不专业的行为给 CBDB 造成麻烦。

​    因此我虽然是戴上了运维的帽子,但心里总是想着如何最少地执行需要高权限的操作,手上的活也就几乎完全倾向 DevOps 里部署的那一端。实事求是地讲,我觉得我在这一项目中的角色有点像架构师,不过是亲自下地干活的那种 ;-)

​    工作之初,我曾困惑于工作的内容,于是和宏苏老师进行了沟通。原来项目希望使没有技术背景的老师,也能在自己的电脑上无痛安装(部署)并使用 CBDB 开源查询系统。理解这一点以后,沟通就更顺畅了。想到之前籍着参与项目的机会对宏苏卖安利的那些五花八门的技术栈,就觉得有点好笑和害臊了(关于这一方面的趣事,也许可以留在以后另外写文章分享)。

架构的选择——平台篇(Dev)

​    在笔者还懵懵懂懂的时候,项目已经度过了初生阶段。一个自然的思考是如何设计机制避免未来修修补补导致的 regression。笔者把这视作自己的工作范围,向宏苏提出了建立 CI/CD 流程的建议。正因如此,笔者需要面临容器镜像编译平台的选择。

​    因为代码开源在 GitHub 平台上,所以与 GitHub 之间良好集成是必须的。但这里没考虑 GitHub Actions 的原因是,笔者之前使用 GitHub Actions 编译容器镜像时体验并不好。虽然在撰写本文时已经有非常棒的第三方 workflow 可以实现 Dockerfile 执行过程的缓存,但在做架构选择时,笔者因为这个(有些过时的)经验的缘故从一开始就排除了 GitHub Actions.

​    那么要不要考虑下 Docker Hub?笔者花了一天的时间做过调研,了解到 Docker.com 声明要从 2020 年 11 月起实行 pull rate limit。仔细研究规则发现,生产中就算本地镜像已经是最新,然而仅仅因为 Dockerfile 上的一句 `FROM` 就需要向 Docker Hub Registry 发起了一次查询(实际并没有拉取哪怕一层的镜像)——也会被算成是一次 pull。大致估算过场景后,笔者认为选择 Docker Hub 可能触发匿名用户的 pull rate limit(每 6 小时 100 次),因此在心里默默对它举了一次黄牌。

​    此外,笔者在之前的沟通中了解到 CBDB 的项目实践中所普遍遵守的原则:能使用公开平台就使用公开平台,尽一切可能地避免自建服务,以期提供长期且稳定的服务。基于这点考虑,笔者担心之后说不定出现不方便通过登录 Docker Hub 来缓解 rate limit 的情况(公开平台就意味着编译过程的日志可能可以被公开存取,此外还得考虑到团队其他人意外泄露相关登录 token 的情况等等……)——最后综合考虑下来笔者的结论是:能避开 Docker Hub 就避开,避免未来成为麻烦的温床。

​    悄悄说一句,参与项目时正赶上笔者在自己的服务器集群上全面推进从 `docker` 到 `podman` 的迁移过程。所以笔者是带着对红帽家的这一整个生态系统的好感参与了工作——你们自然可以猜到笔者最终的选择了——正是红帽家崭露头角的 quay.io.

架构的选择——流程篇(Ops)

​    引入 CI/CD 流程后的几天笔者都过得很忙。有古诗云「忽如一夜春风来」,而镜像编译出错的邮件就正如诗里形容的梨花那样趁夜间开遍了笔者的邮箱。错误原因按频次来排序大致如下:contributors 短时间内推送了多次提交、涉及到 Dockerfile 的更动出现了语法错误、单一镜像编译流程设计得过长,且执行命令中有太多依赖网络连接的操作……等等。

​    这里正好分享一下 quay.io 对不同错误的反应,因为截至笔者撰文时,红帽家显示的错误信息很多时候还是不具备参考价值——拿一个模拟的案例来举例好了,首先请看如下所示的 Dockerfile,其中存在至少一处错误,你能找出来吗?


FROM ubuntu:latest AS base
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ARG DEBIAN_FRONTEND=noninteractive
ARG image_build_date='2020-12-04'
# http://bugs.python.org/issue19846
# > At the moment, setting "LANG=C" on a Linux system *fundamentally breaks Python 3*, and that's not OK.
ENV LANG=C.UTF-8 \
    LC_ALL=C.UTF-8 \
    PKG_CONFIG=/usr/bin/pkgconf
RUN apt-get update && apt-get -y --no-install-recommends install \
    apt-transport-https apt-utils autoconf automake binutils build-essential ca-certificates checkinstall cmake coreutils curl dos2unix file gettext git gpg gpg-agent libarchive-tools libedit-dev libltdl-dev libncurses-dev libsystemd-dev libtool-bin libz-dev locales netbase ninja-build parallel pkgconf python3-pip util-linux \
    # && sed -i '/en_US.UTF-8/s/^# //' /etc/locale.gen \
    # && dpkg-reconfigure --frontend=noninteractive locales \
    && update-locale --reset LANG=C.UTF-8 LC_ALL=C.UTF-8
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

 

​    如果把这个 Dockerfile 提交给 quay.io 尝试编译,它不会有任何抱怨——只是会在资源调度完毕可以开始编译后不断「自责」而已,就像这样:

how_quay_react_to_a_syntax_error_in_dockerfile

​    笔者最初见到这个错误信息时可谓是一头雾水,后来总算通过小黄鸭大法找出了原因。吸取这样的经验,笔者草拟了后来的提交需要遵循的 checklist——这里有请检查清单上的第一项:hadolint

​    话题回到招致编译错误的因素 Top 1 上。虽然宏苏和笔者很快沟通好提交规范(我甚至有请宏苏把规范写在 README 的最顶部),但是一个 robust 的系统架构一定应该考虑到 human factors 的影响,笔者因此修改了 CI/CD 的触发条件。比如笔者新建了一个 git 分支,让 quay 仅监视这个分支的事件;又比如在通过 GitHub Action 自动更新该分支时设下 rate limit……总之都是为了避免如下所示的错误:

git tree missing

 

​    除了上面那些「师出有名」的错误以外,笔者托管在 quay 上的 CI 过程还好多次遇见下面这样莫名奇妙的错误(尽管莫名其妙但是伤害真的很大啊……不知不觉中 CI 就挂掉了好几天,偏偏笔者自己一点迹象都没察觉到):

 

WTF!?

​    关于笔者在 Docker 镜像编译上所尝试过的改进还有很多,但是这方面已经有很多大牛写过文章了,笔者也只是把方便拿来的部分统统拿来而已,为了不要偏题太多就不赘述了。总之反思当初的技术架构选型,会觉得还是有不少不成熟的地方——不该提前担心的地方担心过头了,干扰了判断。反而是调研后应该做的小规模试水被我抛在了脑后,其结果就是后来在 quay.io 平台上体验到的阵痛。好在最终问题都得到了解决,甚至还反过来促进了 Contribution Guide 的完善和规范,也不至于后悔啦~

架构的选择——应用篇(Client)

​    这篇文章几乎是和内部文档同时生出来的。因此在最后的这一部分,笔者想放上一些没能写在文档里的内容,作为对整个架构分享的总结——

​    在 CBDB 这边提供的公开服务上,笔者正着手于用 ACME.sh+HAProxy+Caddy 替代 NGINX 的计划。这一变化的目标是提供更好的性能、更细的工作划分,其中尤其以后半部分最为重要。

​    前面已经提到,项目的首要目标是让没有技术背景的老师也能很方便地在本地部署供即席检索的系统。因此在 CBDB 这边,部署的环境最好是能贴近用户自搭建的环境。上述的架构变化意在使工作层级足够细分,这样从 CBDB 公开的服务端出发,简化不必要的组件后就能得到适合自搭建用户本机启动系统的环境。

​    当然,这其中也有简化发布的意思。还记得笔者在最初接手工作时,第一反应就是联想到 nextcloud/docker 这个仓库。这实在是很好的样板,但笔者不希望搞出像这个仓库里那么多的组合搭配,其中每种搭配下都有对应的 NGINX 配置文件。与其让 NGINX 完成所有的工作,不如把 TLS 加密的任务和 web server 的任务分给不同的组件,这样就不需要针对开启加密和不开启加密的两种情况分别发布 NGINX 配置;若是环境中已经部署了其他的服务,用户也可以轻松地将某个组件用环境中现成的组件替换(比如这里笔者推荐的 Caddy,v2 版本起支持直接使用 NGINX 配置启动服务器,用户一行代码也不用改就能轻松实现替换);以上就是笔者的考量,希望未来 CBDB 发布的配置能为更多用户所参考,提供更广大范围的开箱即用体验。

​    若说到性能调优,话题就更为广泛了。考虑到这篇文章是笔者第一次通过 CBDB 和读者打交道,还是先只宽泛地简单谈谈吧:

​    我们不妨参考下网络调优方面的权威 cloudflare 在博客中是如何安排内容的,由此可以对不同调优方向的优先级有一个切实的体会。在 2017 年的一篇评估 ARM 平台的文章《ARM Takes Wing: Qualcomm vs. Intel CPU comparison》中,作者首先针对 OpenSSL 做了 benchmark。

​    因为我们和 cloudflare 面对的场景不同,所以博客中图片的结论不适合直接拿来套用。实际上在 CBDB(以及绝大多数和 Alexa Top 1000 排名无缘的网站)这边,TLS 握手阶段几乎不太可能遇到 CPU 上的性能瓶颈。所以调优的思路大概只有两点:其一,要充分让 CPU 「忙碌」;其二,缩短和客户端之间需要交流的数据内容。

​    笔者的选择是利用 HAProxy 在面对客户端连接的最前线上实现 TLS termination,因为它开箱就支持充分利用 CPU 的多个核心,高效完成握手和流量解密的任务。同时它也支持 RSA/ECC 双栈站点的配置,不仅能提升网站 TLS 握手的性能,也不至于损失兼容性(笔者曾经试着在 NGINX 上实现同样的架构,但却发现开启 OCSP Stapling 的情况下如果希望给两张证书都定期更新并缓存 OCSP Stapling 响应的话,是没有办法在配置里写得清楚明白的)。

​    搭配 ACME.sh 作为获取和管理 TLS 证书的工具,可以特别地指定证书签名的算法为椭圆曲线算法,大大缩短证书链的大小(ECDSA 算法要求 Windows Vista 或 Apple OS X 10.6 以上的系统支持,详细的兼容信息可以参阅《ECC Compatibility :: ECC Compatibility :: GlobalSign Support》。可以说在 2020 年的现在,对于新上线的服务来讲,即使是将网站部署为 ECC 单证书也不会有什么问题)。

​    唯一一点需要注意的是,上述的软件特性里有近几年才发布的功能,因此在追求稳定的服务器操作系统上很可能直接从软件仓库安装 HAProxy 是不够的。笔者利用 Dockerfile 实现了可以方便重现的编译环境,每天定时从最新的依赖项编译安装长期支持分支的 HAProxy 源代码。有需要的话可以参考:cbdb-project/haproxy_static

​    《ARM Takes Wing: Qualcomm vs. Intel CPU comparison》文中作者作为第二项 benchmark 执行的是压缩算法和实现方面的调优,我们也将进入这个话题做些探讨。不过在这之前笔者有一点强调想要放在开头:先压缩再加密是很多安全漏洞的温床。从 CRIME attack 到 BREACH attack,HTTP compression 招致的麻烦真是既多到爆又很难以修复。

​    笔者对于这个问题的态度是,可以接受用压缩效率换安全的 HTTP compression 实现,而且这一实现必须能被主流浏览器支持。毕竟若是为了安全不得不关闭 HTTP compression,那这篇文章可以到此结束了,不管后面的优化如何,都很难弥补关闭 HTTP compression 带来的性能损失。

​    那么,现实中究竟有没有这么美好的解决方案,可以满足上面笔者所提到的全部要求呢?事实上还真的有,而且几乎只有 HAProxy 实现了对这一解决方案的支持。这一解决方案就是:Stateless ZIP library (libslz)——只看名字就知道这是 zlib 的兼容实现了。实践上 libslz 针对压缩率不是非常重要的场景,以非常轻量的资源使用输出 zlib/gzip 可解码的压缩流,因此几乎可以被 100% 的客户端兼容。而它的设计又确保了将这一实现用在 HTTP servers 上时对 CRIME 类攻击有抵御性,incredible work!(需要注意的是在 HAProxy 上开启 gzip 压缩算法支持后,有必要在所有后端上关闭 gzip 压缩,否则最终的压缩不会交给 LIBSLZ 来完成)

​    有了上面的「负优化」过后,其他方面就要更加一把劲了,如此才能弥补上 zlib 库实现替换为 LIBSLZ 造成的压缩率损失。一些相对上述的调优来说有些不是那么「放之四海而皆准」的操作包括:利用 HAProxy 的 HTX(Native HTTP Representation) 特性、开启 Kernel splicing 选项等等。对于软件仓库发行的版本早于 1.9 的操作系统,HAProxy 作为 L7 proxy 的吞吐性能还有很大的空间可以优化(可以参考 cloudflare 博客《SOCKMAP - TCP splicing of the future》)。

​    当然啦,不用如上面那般「铤而走险」的调优思路也有一大把(但是这和笔者在文章里大剂量安利 HAProxy 有什么关系呢?),如果一一细讲的话这篇文章就不知道什么时候才能结束了。笔者只在文末附上个人非常常用到的网站,以供参考。

* TLS 配置调优:Mozilla SSL Configuration Generator
* 根据具体应用场景调优 TLS 证书链:cloudflare/cfssl: CFSSL: Cloudflare's PKI and TLS toolkit
* 站点 TLS 配置总体评估:SSL Server Test (Powered by Qualys SSL Labs)

* 某一 feature 的浏览器支持程度参考:Can I use... Support tables for HTML5, CSS3, etc
* Google 网站开发者手册(以图像优化为重点推荐):Optimize Images  | PageSpeed Insights  | Google Developers

 

【作者:冰码牛(北京大学数字人文中心) 编辑:王宏甦】