用 whitenoise 提供静态文件服务(Python)

Whitenoise 这个项目是一个符合 WSGI 标准的静态文件服务器,因为 WSGI 是可以嵌套的,所以 Whitenoise 可以和你原来的 WSGI 应用配合的很好,迁移成本很小,或者像 Django 这种项目有某种 middleware 机制,迁移就更方便了。本文介绍这个库的使用、为什么要用这个库(尽量说服你 ),以及买二赠一的源代码导读。

从文档掏出来的一个 QuickStart 如下:

可以看到,其实就是将你的 WSGI app 外面再包一层 WSGI app,即 WhiteNoise。

Django 可以不通过这种方式,因为 Whitenoise 对 Django 做了一些额外的适配,可以使用 Django 原生的中间件机制。

静态文件服务其实就是对于 HTTP 请求,发送对应的文件给用户。这件事情为什么要用 Whitenoise 来做呢?这个项目存在的意义究竟是什么呢?当你没听说过这个项目之前,一般的做法是用 Python 写的 web 应用来处理动态内容,用 Nginx/Apache2 这种专业的 HTTP 服务器处理静态文件;或者将静态文件都放在 S3 这种对象存储上。

为什么用 Whitenoise 比这两种做法要好,官方的 FAQ 写的很好,我这里捧哏转述一下。

首先使用 S3 这种方案,只是可以 work 的方式但是不是最佳的。第一,对压缩的支持不好。HTTP 的大多数时间都花在了网络上,而现在大多数个人电脑的 CPU 都是闲置的,所以假如服务器要传给客户端文件,那么服务器这边压缩一遍,通过网络传给客户端,客户端解压,虽然多了一次压缩/解压,但是总体上时间还是快的。S3 目前不支持压缩。支持压缩的话就要读 Accept-Encoding 这个 Header 看客户端支持哪种压缩方式(古老的 Gzip 压缩,或者现代的 brotli)。还要设置好 Vary 这些 Header 告诉 CDN 怎么处理缓存(什么是 Vary?)。而 Whitenoise 都帮你处理好了。另一个不便是配置 S3 比较麻烦,要用客户端上传,要有 Key,Secret 这些东西。CDN 的话,CDN 知道你的地址,你知道 CDN 的地址(互相确认过眼神)就好了。

其次,为什么不用 Nginx 呢?用 Nginx 是可以的,Whitenoise 使用的场景是 Heroku 这种 PaaS 平台,这种场景下 Nginx 不太好搞,所以用了 Whitenoise 你可以脱离 Nginx 了,直接将 WSGI app 平滑地部署到 PaaS 上。而且用 Nginx 你得仔细的设置很多东西,比如 CORS Header,cache Header,Nginx 原生不支持 brotli ,你还得去装 module 。

最后的一个问题是,Python 的性能问题是否意味着 Whitenoise 是一个效率很低的静态文件服务器?

如果你关心性能的话一定要使用 CDN,而对于 CDN 后面的真实服务器来说,最重要的事情是正确设置 HTTP 的 Header,最大限度的、正确的使用 CDN。所以这个问题更是一个逻辑是否正确的问题,而不是效率的问题。一个 静态文件 Request 的处理过程,其实主要是根据 PATH 来找到对应的文件并返回而已。对大多输的 WSGI 服务器(比如 Gunicorn)来说,发送文件的部分是对内核的 sendfile 的系统调用,效率是很高的,用 Python 还是 C,区别并不大。

下面是源码导读。

通过本文开头的那个例子可以看出,Whitenoise 实例化出来的对象其实是一个 WSGI app,它的 __call__() 方法如下:

HTTP 请求到了 WSGI 应用的时候会首先进入这个方法,然后尝试根据 PATH 寻找对应的静态文件,如果找到的话,通过 self.serve() 处理返回内容;如果找不到的话,就调用进入 self.application ,就是后端 Python 应用。Whitenoise 只提供了这一种配置方法,不像 uWSGI,有 4 种 serve 静态文件的配置方法。这里我有一个担忧是每一个进来的请求都要经过查找对应静态文件的逻辑,不知道对性能有没有影响。

serve 方法如下:

通过 static_file.get_response 方法拿到 Response,start_response 这一行是 WSGI 标准,返回 200 OK 这样的状态信息以及 Headers,最后以 list 形式返回 body。后面的 FileWrapper 是从 wsgiref.util 导入的,这个 FileWrapper 在 CPython 库里面的代码其实就是实现了迭代器,每次读 8k 的内容返回。如果用了其他的 WSGI 服务器,会拿到对应的 file_wrapper。从这里可以看出,Whitenoise 本身不处理 socket 发送文件的部分,真正负责这个的是 WSGI 服务器,比如 uWSGI 这种。

通过上面的介绍,我们知道这个库最重要的就是正确返回 Header,所以核心的逻辑都在 static_file.get_response 里面。

首先检查 method 必须是 GET 或 HEAD,否则就返回 403.

然后根据 etag 和 HTTP_IF_MODIFIED_SINCE 这个 Header 检查文件是否有修改。

接着解析 path 和 headers,注意这时候还没有打开文件,如果是 HEAD 方法,是不需要打开文件的,直接将 file 变量设置为 None 即可,后面发送的时候会自动不发送 body。

最后看是否是 HTTP_RANGE 请求,如果是,只读相应部分的文件。

主要的逻辑就到这里了,这个库的代码只有几千行,主要的逻辑就是上面介绍的这些。其他的一些包括 compress.py 有压缩相关的代码,middleware.py 和 storage.py 这些处理 Django 相关的一些 migration,scantree.py 处理查找文件的部分等等。

压缩的部分有一个值得注意的,这个变量将不需要压缩的文件的后缀列出来了。因为这些文件格式本身是经过高质量压缩的,如果启用压缩,会徒增计算成本,但是实际并不会减少很多文件体积。

总之,这是一个很小的库,但是对 HTTP 文件服务是一个比较值得参考的实现。Worth to read.

 

5年博客路

写博客 5 年了。

博客已经成为了我的习惯,自己有什么想法的时候,都喜欢将这些思绪理清楚,记录下来。想法只是想想很可能就那么过去了,但是想要整理成一片得体的博客的话,就该考虑周全一些了。第一篇博客写在高中毕业的时候,写下的东西读起来总觉得不完全表达出自己所想的,只能算是表达出六七分吧。写一篇要花我很长时间,最后还总不满意。现在不一样了,打开 WordPress 的编辑器,很快就能把心里想的记下来。我甚至已经对这个编辑器有感情了,一打开它就能找到很多灵感。

最初,总是担心没什么可写的,博客荒废在那里,首页停留着一篇几年前更新的文章,那可太丢人了(对自己的要求,没有觉得那些不更新的博客就不好)。也见过很多优秀的博客许久不更新了,很可惜。

但是从某一个时刻起,这件事默默的发生了质变。我很放心:我的博客永远不会停更。具体的原因我也说不上来,但是总感觉无论再忙,有一些想法不记下来就心里不舒服。那些担心也没有了,不会在意浏览量了,不会在意有人从这些文字中挑刺,虽然它们有时候前后矛盾,也不会在意自己有多久没写了,是不是要找个话题水上一篇来。

我把它当做自己的一个角落,虽然这个角落是完全公开的。

这里记录了我经历的一些事,有些很痛苦,只想写一写,写下来之后我从来没有去看过第二遍。也记录了一些我学到的东西,这是我最开心写的,我比较好为人师,学到一点点东西,就想讲给别人听,希望能帮到一些人吧。有时候自己回来看看,发现自己对这个方面还有过兴趣,也许能重新拾起来研究一下也说不定呢。也记录了一些杂七杂八的想法,比如看完一本书,看完电影,有时候想到了一些自己以前不曾想到的事,也会记下来。这些东西其实不成章法啦,写下来,最希望的是能有人来一起讨论一下,可惜的是,这部分内容实际上收到的有价值的讨论还非常少。还有有对自己做的事情的一些思考,比如做爬虫的时候发现这个工作其实挺有趣的,写了一篇介绍爬虫工作的文章。那时候一直想做一个定时执行任务的平台,只要你给我一个脚本,我可以帮你按照 cron 的方式执行它。在平台上可以清楚地看到执行的日志,像 Github 的 commit 日历一样做一个执行日历,一定很酷。但是一直没做下去(域名都买好了……),只是记录了一些想法

技术方面的文章无关乎深浅,再深奥的东西,其实只是抽象的太高,说不定你最后搞明白了发现是他设计的不好呢!所以并不是你看不懂的博客就是深奥的、写得好的(虽然大多数人都这样想)。有些自己觉得不值得一提的事情,写下来之后发现竟然帮到了很多人(如果你有访客记录的话,浏览量最多的文章一定让你大吃一惊)。所以我基本不会因为一个话题太浅而不写,专门挑显示我的技术高的东西去写。也不会去抄官方文档到博客上来赚取访问量。一个东西写与不写,对我来说就一个标准:我想不想写。毕竟这是我自己的博客,也没有人逼我,更没有人给我发钱。

我发现只有自己感兴趣的东西,写下来才是好的,字里行间都能流露出激情。不感兴趣的东西,肯定也写不好,要么像流水账一样,要么条例不清晰。王垠的这篇《解谜计算机科学1》挺好,但是只有第一篇,后面的太监了。没有了总比勉强写出来要好,我觉得。另外  agentzh 的 Nginx变量漫谈,也非常好,全都带着示例,讲的很明白,如果自己没兴趣,肯定写不出这么一大坨,还带着例子。好的博客是将复杂的问题说明白的,而不是写的别人看不懂的(故作高深)。这里有一篇《Programming for passion — not profit — produce high quality software》,讲的也是这个道理。

出自于兴趣做的事情,一定是比钱、虚荣、利益驱动的做的要好的。这也是为什么那么多人来参与开源,将项目做的那么好的原因。

这篇文章主要想表达一下我对博客的感情,因为我最近发现很多(不写博客的)人对博客有种误解,仿佛这件事情是用浏览量、评论量什么的来量化的。不是这样的,这只是茫茫互联网上的一个个人的角落。我把自己的故事留在这里,把自己读过的、听过的、看过的、想过的留在这里。搜索引擎就像小蜜蜂一样,有时候会将这些东西分享给全世界的人看,有时候这些东西就静静的待在这里。没有人能强迫你写下什么,也没有人能强迫你删除什么。

希望你也有这样一个角落。

 

开始用 Ansible 管理机器了

说起来很可笑,总说自己是 SRE,但是 Salt,ansible 这种东西还没玩溜呢。平时的运维和发布一般都是工单,要走一些审批流程,所以也没啥场景去用这些。今天部署一个东西,才发现自己已经重复在机器上编译 Python 这种事情干了三次了,好,是时候将这些操作自动化了。

之所以选择 ansible,是因为它是通过 ssh 协议的,不必再客户端上面安装任何东西,不依赖其他 broker 数据库等,ansible 项目本身就是 Python 脚本,所以安装和使用就非常方便。

体验下来,发现 ansible 被人吐槽“慢”真是一点都不委屈,实在太慢了。

我主要用的是 role 的功能来将平时要做的事情在服务器上组织起来。

一个 Compile Python 的 Role

ansible 提供了 role 来组织你的脚本,role 其实是 playbook 通过文件结构将不同的部分组织,用的时候在 playbook 中 include 就可以了。

下面以一个编译 Python 的 role 为例。

首先它的目录结构如下:

在 roles 文件夹里面建了一个 compile_python37 的文件夹,一个文件夹就是一个 role,然后按照 role 规定的文件夹结构在这个 role 中新建相关的比如 tasks

tools.yaml 中的内容如下,就是调用一下 role:

compile_python37/tasks/main.yml 中的内容如下:

可以看到用到了很多 "{{ xxx }}" 这样的东西,这相当于一些变量,我们将固定的操作写在 tasks 里面,将变量 vars 里面,这样之后如果要修改一些诸如解压地址的时候,就可以直接编辑 vars 了。

unarchive 可以直接写远程的 url,解压到本地的一个位置。

with_items 其实是循环,会循环 value 中所有的值,将它们当做 item 来执行一次命令。比如第一个 yum 命令会对 "{{ python_yum }}" 中的每一个值都执行一次。

compile_python37/vars/main.yml 的内容如下:

可以看到就是上面 tasks 需要的变量。

搞定,另外 galaxy 这里有一些大家写的 role,类似像一个 docker hub。不过我试了几个,直接用都有问题……

最后一点 Tips,单字母的命令放着不用太浪费了,推荐一下几个 alias:

再来一点 Tips,ansible 默认的输出实在是太反人类了,是一个压缩后的 json,换行符都没了,变成了一坨,编译命令、yum 安装命令的输出根本没法看了。找了一圈之后从一个 issue 里面才发现解决方案:设置 stdout_callback = debug 就好了,会以格式化的 json 来显示。也可以设置成 minimal 或 yaml,我觉得 yaml 比较好看。可以通过环境变量设置或者设置文件来设置。看这点赞数,颇有点“如何退出 Vim”的意味啊,为啥不弄成默认设置呢?

 

评价 GC 算法好坏的几个标准

大学时候读《松本行弘的程序世界》的时候,粗略的了解了一下垃圾回收算法,写过一篇介绍三种最基本的垃圾回收算法的文章。最近在看《垃圾回收的算法与实现》,接触到了这些垃圾回收算法的实现细节。粗略了解的话,基本的原理就是之前这篇博客介绍的内容,这样说出来很简单,也容易理解。但是实现起来就有很多细节了,首先要用到很多指针,所有的对象和引用的基础都是指针。然后会用到链表来保存一些可用内存,用堆来分配对象等。

其实在学校的时候学了很多数据结构,工作了发现一只在 CURD,但是研究 GC 这个话题会用上那些复杂的数据结构和算法,优化的空间也很大,非常有意思。

从这些算法细节也可以看出这些不同的算法其实都有一些强大的地方和弱点。好在评价一个垃圾回收算法的好坏有比较明确的标准,这篇文章就参考《垃圾回收的算法与实现》这本书谈一下评价标准。

吞吐量

垃圾回收算法(6 个字太长了,以下简称 GC)算是对程序完成它想做的事情的一种辅助,并不是程序的主要目的(废话)。所以 GC 占用的时间越少越好,程序花在正事上面的时间越多越好。

这个标准严格定义应该是 需要处理的堆大小 ÷ GC 占用时间。书中称为“吞吐量”。

这个指标其实很好理解。举个例子吧,标记清除算法要遍历两次,第一次遍历所有活跃的对象,将它们标记为“不是垃圾”。第二次遍历所有的对象,将没有被标记的垃圾回收掉。复制收集算法只需要遍历一次,将活跃的对象从内存的一般复制到另一半。所以从这个指标的角度讲,复制收集算法完爆标记清除。

但实际上,这个指标是不能“静态衡量”的。依然是上面两种算法,标记清除遍历的时候速度很快,只要写一个标记就可以了。而复制收集算法要有 copy 操作。虽然堆中活动对象的增加,甚至会出现复制收集吞吐量小于标记清除的情况。

内存使用率

这个指标也比较好理解,GC 算法需要一些标记,但是如果算法本身所使用的内存占得很多,就得不偿失了。这个算法本身的目的就是回收内存,本身却占用了很多内存,听起来就不合理。

其实,从算法的角度讲,内存是空间,吞吐量是时间。算法上有“用空间换时间”的策略,自然 GC 也会有。这是一种 Trade off,很多情况不可兼得。

拿引用计数来说,用几个位来表示引用计数是门学问。简单的话,占用 8 个位表示,那每个对象 1byte 就没有了。假设是占用 2 个字节的对象,那么内存占用就扩大了整整 1.5 倍。 而大多数对象仅仅会被引用 1 次而已。所以引用计数方法就发展出一些优化措施,减少引用计数占用的内存,配合其他算法来处理计数器溢出的问题。有一种极端的方式是只使用 1 位来计数(倒不如说这是一种标记了)。叫做 1位引用计数。具体的原理,这篇文章就不展开说了。

复制清除算法每次只能使用一半的空间,所以这个算法的内存使用率也是很低的。

最大暂停时间

这个指标和“吞吐量”看起来有些像,GC 算法速度越快,时间就越小。但是吞吐量指的是总体的速度,最大暂停时间指的是 GC 算法执行的时候,程序在等待 GC 完成的最大时间。这段时间由于 GC 的运行程序无法做其他的事情。

为什么这个指标会重要呢?有些程序可能可以忍受吞吐量不高,但是实时性要求很强的。比如说,A 算法每分钟暂停 1 次,一次暂停 1 秒,一小时一共暂停 60s。另一个 B 算法每小时暂停 1 次,一次暂停 30s。可想而知,如果是用户程序暂停时间长是体验很糟糕的,另外比如机器人控制程序,迈开一只腿这时候到了暂停时间,机器人就摔倒了。

引用计数的最大暂停时间是最好的,因为对象的引用到 0 的时候立即会回收,这个过程不需要暂停程序。而复制算法和标记清除算法需要在无法申请出更多内存的时候,暂停程序,开始清除阶段/复制阶段。

连续性(缓存友好程度)

我们知道,越快的存储价格就越高。CPU 寄存器速度最快,但是只有几个寄存器。高速缓存非常快,但是极其昂贵。然后是内存、硬盘等。

如果程序在执行的时候缓存命中率高,那么运行效率就会高。

在这篇文章中,我们可以将程序粗略的分成两部分:GC 运行的部分和程序运行的部分。如果 GC 运行的时候需要频繁寻找对象,然后对象的引用又在很远的地方,那么缓存命中率就会很低;另一方面说,如果 GC 算法将程序的对象变得很离散,那么程序在运行的时候,互相引用的对象离得很远,效率就会很低。

标记清除算法会造成内存的碎片,对缓存不友好。复制收集算法由于是拷贝不是垃圾的对象,所以在一次拷贝操作之后,垃圾对象被释放,非垃圾对象都在一起了,所以命中率会高。另外上面提到的 1 位引用计数算法由于只拷贝指针,而不需要去找到对象,所以缓存命令率也会高。

是否写入操作(copy-on-write 友好程度)

Copy-on-write 在现代计算机技术中是比较常用的。简单来说,如果你 fork 出 4 个进程,而这 4 个进程都只是在读同一片内存,系统将会将这一片内存共享给 4 个进程,如果有其中一个进程想要写入的时候,系统才会复制出来这部分内存给这个进程写入。

这里特别要“批评”的是引用计数,因为引用计数的原理是每一个对象记住有多少个对象指向了自己。这就意味着,即使我们没有更改这个对象,只是读(应用)了这个对象,相关的内存就有了写入操作,这部分内存就会被复制。

2017 年 Instagram 发表的这篇关闭 Python 的 GC 来提高性能的文章,原理就是这样。

以上内容现学现卖,如有错误欢迎指出。