生产环境下如何优雅地重启 Tornado

标签:Python

之前我在《Tornado 使用经验》一文中,提到了调用 tornado.process.fork_processes() 来提高性能的方法。
在最近的实践中,我发现这样会有些弊端,所以便有了本文。
当然,这些仍然只是我个人的探索而已,并不保证是最佳实践。

首先说下为什么我不再使用 tornado.process.fork_processes() 方法。
网站在上线后,难免会遇到需求更改或修复 bug 的时候,这就免不了要重启 Tornado 进程了。如果使用上述方法的话,会有一个主进程和多个子进程需要 kill,然后再重新运行。简单来说,代码如下:
killall python
nohup python myapp.py >> log/myapp.log &
如果除了 Tornado,还有其他 Python 进程的话,就更麻烦了,需要精确地找到这些进程的 pid,再分别干掉。于是总觉得不太优雅。

后来和知乎的李申申聊天时,问了他这个问题,他推荐我使用 Supervisor
这玩意折腾了我半天,大致配出这样一个玩意:
[unix_http_server]
file = /tmp/supervisor.sock

[supervisord]
logfile = %(here)s/log/supervisord.log
pidfile = /tmp/supervisord.pid
directory = %(here)s

[supervisorctl]
serverurl = unix:///tmp/supervisor.sock

[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

[program:myapp]
command = python myapp.py
directory = %(here)s
stdout_logfile = %(here)s/log/myapp.log
stderr_logfile = %(here)s/log/myapp_err.log
然后运行这条命令,就能启动服务了:
supervisord -c supervisord.conf
接着坑爹的事发生了,本以为这样能重启的:
supervisorctl -c supervisord.conf restart myapp
结果却出错了,Supervisor 认为没有启动成功,但其实已经重启好了。

研究了一番才知道,tornado.process.fork_processes() 方法产生的子进程并不会随主进程的退出而退出,而 Supervisor 当然是不知道这些子进程的。(顺带一提,Supervisor 也不能管理守护进程。)
如此一来,如果主进程有什么异常,或者被 kill 掉了,子进程就变成僵尸进程了,确实很有问题。

思索了一番,还是决定自己创建多个进程,分布在多个端口,然后由 nginx 来反向代理到 80 端口。
其中 nginx 的部分配置如下:
http {
    upstream myapps {
        server 127.0.0.1:6666;
        server 127.0.0.1:6667;
        server 127.0.0.1:6668;
        server 127.0.0.1:6669;
    }

    server {
        listen       80;
        server_name  localhost;

        location / {
            proxy_pass_header Server;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For  $remote_addr;
            proxy_set_header X-Scheme $scheme;
            proxy_pass http://myapps;
        }
    }
}
而 supervisord.conf 则改成这样:
[program:myapp]
command = python myapp.py 666%(process_num)d
process_name = %(program_name)s%(process_num)d
numprocs = 4
numprocs_start = 6
directory = %(here)s
stdout_logfile = %(here)s/log/myapp.log
stderr_logfile = %(here)s/log/myapp_err.log
现在 myapp.py 需要接收一个端口参数了,且不再 fork 子进程,代码我就略过了。
此外,如果端口号末位从 0 开始增长的话,是不需要设置 numprocs_start 的,这里只是个人喜好而已。

重启的命令则改成了这样:
supervisorctl -c supervisord.conf restart myapp:*

可是在使用过程中发现,每次重启都会导致网站有大约 10 秒无法访问,这显然不够好。
于是又写了个脚本,依次重启各个进程:
for i in {6..9}
	do supervisorctl -c supervisord.conf restart myapp:myapp$i
done
nginx -s reload

这样基本任何时候网站都是可访问的,不过在重启的过程中,有些没处理完的请求可能会被直接中断掉。
搜索了一番后,找到了这两篇文章可供参考:《Proper way to stop a Tornado》《Tornado server graceful stop》
简单来说,就是捕捉 TERM 和 INT 信号,使 Tornado 在退出前先停止接收新请求(由 nginx 分发到其他端口),再尝试处理未完成的回调,最后才退出:
def sig_handler(sig, frame):
    logging.warning('Caught signal: %s', sig)
    tornado.ioloop.IOLoop.instance().add_callback(shutdown)

def shutdown():
    logging.info('Stopping http server')
    server.stop() # 不接收新的 HTTP 请求

    logging.info('Will shutdown in %s seconds ...', settings.MAX_WAIT_SECONDS_BEFORE_SHUTDOWN)
    io_loop = tornado.ioloop.IOLoop.instance()

    deadline = time.time() + settings.MAX_WAIT_SECONDS_BEFORE_SHUTDOWN

    def stop_loop():
        now = time.time()
        if now < deadline and (io_loop._callbacks or io_loop._timeouts):
            io_loop.add_timeout(now + 1, stop_loop)
        else:
            io_loop.stop() # 处理完现有的 callback 和 timeout 后,可以跳出 io_loop.start() 里的循环
            logging.info('Shutdown')
    stop_loop()

if __name__ == '__main__':
    port = int(sys.argv[1])
    if settings.IPV4_ONLY:
        import socket
        sockets = bind_sockets(port, family=socket.AF_INET)
    else:
        sockets = bind_sockets(port)
    server = HTTPServer(application, xheaders=True)
    server.add_sockets(sockets)

    signal.signal(signal.SIGTERM, sig_handler)
    signal.signal(signal.SIGINT, sig_handler)

    tornado.ioloop.IOLoop.instance().start()
    logging.info('Exit')
试了下以这种方式重启,耗时几秒的请求也不会被强制中断;100 个并发的压力测试下,重启过程中也没有任何失败请求出现。
不过如果你的 settings.MAX_WAIT_SECONDS_BEFORE_SHUTDOWN 设得超过 10 秒,就要相应地增加 supervisord.conf 中 stopwaitsecs 的时间,否则会被强行杀掉的。

最后顺带一提,修改了 Supervisor 的配置,也可以用 supervisorctl reread 来重新载入,或用 supervisorctl reload 来载入新配置并重启所有子进程。直接运行 supervisorctl 的话,可以进入命令行模式操作。

2015年10月31日更新:
文中有两点需要修正:
  1. 为保证线程安全,add_callback 方法应该改成 add_callback_from_signal。
  2. Tornado 的后续版本增加了一系列内置的 timeouts,所以只检查 callbacks 即可。

28条评论 你不来一发么↓ 顺序排列 倒序排列

    向下滚动可载入更多评论,或者点这里禁止自动加载

    想说点什么呢?