web.py session 的坑

用 web.py 开发的一个后台 API,但被请求了几十万次后服务器出现了磁盘空间不够:

# df
Filesystem  512-blocks      Used     Avail Capacity  Mounted on
/dev/sd0a      2057756    477776   1477096    24%    /
/dev/sd0k    624161968    335328 592618544     0%    /home
/dev/sd0d      8250780        28   7838216     0%    /tmp
/dev/sd0f      4122108    753660   3162344    19%    /usr
/dev/sd0g      2057756    409804   1545068    21%    /usr/X11R6
/dev/sd0h     20636924   2010480  17594600    10%    /usr/local
/dev/sd0j      4122108         4   3916000     0%    /usr/obj
/dev/sd0i      4122108         4   3916000     0%    /usr/src
/dev/sd0e     48964444  17933392  28582832    39%    /var

如上可见,磁盘空间是足够的,这种情况肯定是 inode 用完了,用 df -i 就可以看到。

导致 inode 耗尽的原因是有大量的文件创建,用下面列出每个文件夹文件数:

for i in /*; do echo $i; find $i |wc -l; done

最后发现是 Web 程序下 sessions 目录有几十万个小文件,因为代码中我将 Session 指定保存在文件中的:

if web.config.get('_session') is None:
    session = web.session.Session(app,
                                  web.session.DiskStore('sessions'),
                                  initializer={'loginin': False})
    web.config._session = session

先将 sessions 目录里的文件删除后,系统恢复了正常。

最先怀疑是我代码逻辑有问题,review 一次确定代码本身逻辑正确。为了 bug 复现,我在测试环境中循环请求网站:

for i in {1..100};do curl localhost:8080;done

和预期一致,sessions 目录出现大量小文件,怀疑对象马上转移到 web.py 上。看了下 web.py 源码,web.py 的 Session 功能实现在 session.py 的 Session 类中。在 Session 类的构造函数有这样一句代码:

if app:
    app.add_processor(self._processor)

我在网站后台初始化 Session 时提供了 app 参数,所以这条 if 语句成立:

web.session.Session(app,
                    web.session.DiskStore('sessions'),
                    initializer={'loginin': False})

app 参数是 application 类实例,定义在 web.py 的 application.py 中。

application 类中定义了一个 processors 列表,每接受到一个 HTTP 请求时就递归调用列表里的函数对象:

class application:
    def __init__(self, mapping=(), fvars={}, autoreload=None):
        ...
        self.processors = []
        ...

add_processor 方法就是负责添加处理函数,所以 Session 类中一开始就把内部的 _processor 函数放在其中,对每个 HTTP 请求都调用它。

_processor 实现如下:

def _processor(self, handler):
    """Application processor to setup session for every request"""
    self._cleanup()
    self._load()

    try:
        return handler()
    finally:
        self._save()

重点就在 _load 的实现的这段代码:

def _load(self):
    ...

    self.session_id = web.cookies().get(cookie_name)

    if self.session_id and not self._valid_session_id(self.session_id):
        self.session_id = None

    self._check_expiry()

    if self.session_id:
        d = self.store[self.session_id]
        self.update(d)
        self._validate_ip()

    if not self.session_id:
        self.session_id = self._generate_session_id()

    ...

如果一次 HTTP 请求的 Cookie 字段中没有 Session 信息,就产生新的 session id。

导致 session 文件爆增的原因就是:如果开启了 Session 功能,对每次 HTTP 请求,web.py 都会验证 Cookie 里是否有 session id,如果没有,web.py 就生成一个 session id 并把信息保存在 sessions 目录中,然后返回 Set-Cookie 让客户端设置一个 session 信息。但是调用 API 的程序是不会理会 Set-Cookie,这就导致在大量请求的情况下服务器消耗 inode 非常快。

用官方文档里提供的 Session 样例代码也可以复现这个问题:http://webpy.org/cookbook/sessions

根本原因就是 web.py 对 Session 的实现不合理,这种情况很容易产生拒绝服务攻击,所以目前来看,我不建议在生产环境中用 web.py 做开发框架:)