无停机部署一个 Django 应用
无停机部署一个 Django 应用
当 healthchecks.io 的流量超过每秒一次访问之后,我就意识到不能随意在部署代码后重启服务了。作为一个监控服务,即使丢掉几个 HTTP 请求也是不应该的。而且,如果服务器变得更加繁忙的话,这个问题只会更加严重。
先简单介绍一下我们所做的工作,这是一个相对简单的 Django 实现的 app,由 gunicorn 来运行,前端是 nginx。数据保存在 PostgreSQL 数据库里。gunicorn和另一个额外的后台进程由 supervisor 负责管理。整个服务在单个$20级别的DigitalOcean实例上运行。
此外,我的技术选型的指导方针是整个架构尽可能的简单,能够使用尽可能长的时间。需要添加的东西,例如负载均衡、数据库容灾、k-v存储、消息队列等等,都要是必须的。另一方面,还需要考虑更多的事情,包括监控、备份等等。同时,对于刚接触这个项目的人来说,需要花更多时间来了解整个系统的“输入和输出”,并且从头开始搭建系统。既需要保持简单、实用,还要保证性能和功能符合预期,这是个不错的挑战。
目前的部署方式是使用 Fabric 脚本,以及用于 supervisor 和 nginx 的配置模板。在我的工作机上运行“fab deploy”,Fabric 本就会在远端机上完成下面的事情:
-
为新的部署准备新的目录,假设这个目录为 $TARGET。
-
在 $TARGET/venv 下设置 Python 3 的 virtualenv。
-
从 GitHub 上把最新代码拉下来放到 $TARGET。使用 GitHub 的 svn 接口会方便一些,可以运行“svn export”命令。这只会拉下来源码,不包含版本控制相关的元数据,这是我们想要的。
-
安装 requirements 文件列出了的依赖。这些依赖会安装到新的 virtualenv 环境,不会影响到线上的应用。下载和安装这些依赖会花费一些时间。
-
运行 Django 管理命令来收集静态文件,执行数据库迁移等等。
-
更新 superviso r配置,运行新的虚拟环境下的 gunicorn。
-
如果 nginx 配置模板有改动的话,需要更新 nginx 配置。
-
运行“supervisorctl reload”和“/etc/init.d/nginx restart”。此时服务会不可用,直到 supervisor 启动备份服务和 gunicorn 进程,以及 Django 的代码初始化完成。这通常需要 5 到10 秒钟的时间,这段时间 nginx 会返回“502 Bad Gateway”给客户端。
-
全部完成。
Fabric 脚本的相关部分参考下面的代码,其中的 virtualenv 上下文管理部分源自优秀的 fabtools 库。
def deploy(): """ Checks out code, prepares venv, runs management commands, updates supervisor and nginx configuration. """ now = datetime.datetime.today() now_string = now.strftime("%Y%m%d-%H%M%S") project_dir = "/home/hc/webapps/hc-%s" % now_string venv_dir = os.path.join(project_dir, "venv") svn_url = "https://github.com/healthchecks/healthchecks/trunk" run("svn export %s %s" % (svn_url, project_dir)) with cd(project_dir): run("virtualenv --python=python3 --system-site-packages venv") # local_settings.py is where things like access keys go put("local_settings.py", ".") put("newrelic.ini", ".") with virtualenv(venv_dir): run("pip install -U gunicorn raven newrelic") run("pip install -r requirements.txt") run("python manage.py collectstatic --noinput") run("python manage.py compress") with settings(user="hc"): run("python manage.py migrate") run("python manage.py ensuretriggers") run("python manage.py clearsessions") switch(project_dir) def switch(project_dir): # Supervisor upload_template("supervisor/hc.conf.tmpl", "/etc/supervisor/conf.d/hc.conf", context=locals(), backup=False, use_sudo=True) upload_template("supervisor/hc_sendalerts.conf.tmpl", "/etc/supervisor/conf.d/hc_sendalerts.conf", context=locals(), backup=False, use_sudo=True) # Nginx upload_template("nginx/hc.conf.tmpl", "/etc/nginx/sites-enabled/hc.conf", context=locals(), backup=False, use_sudo=True) sudo("supervisorctl reload") sudo("/etc/init.d/nginx reload")
现在,如何消除掉部署的最后一步停止服务的时间呢?我们来加一些前提条件:没有负载均衡(目前)。所有的功能都需要集中在一台机器,而且不能有非 200 的响应码。不过我们可以有一些小小的让步:可以考虑一个稍微简单(一般)的情形,不需要做数据库合并,或者数据库合并是向后兼容的,应用的老版本在数据库合并之后也能工作。
经过观察,我发现应用的某些部分的可用性比其他部分的更重要。特别是被监控的客户端系统需要访问的 API,其重要程度要高于用户需要访问的前端页面。虽然向用户显示错误页面肯定是很糟糕的,但是不丢掉客户端的请求更加重要。丢失的请求可能会导致后续发送不该发送的报警,这显然更加糟糕。
我考虑过使用 Amazon API Gateway 来处理客户端的 ping 请求,也实现了原型。这需要把 ping 消息放到 Amazon SQS 队列里,Django 在空闲的时候去消费。这是相对简单的增强可用性和扩展性的方式,不过代价比较大,也带来新的外部依赖。将来需要再考虑一下有没有更好的办法。
另一种方式:把监听客户端的 ping 请求这个功能与 Django 应用的其他部分分离开。Ping 的监听逻辑非常简单,最终只涉及到两个 SQL 操作:一个更新操作和一个插入操作。重写这部分代码应该比较简单,也许可以使用 Python的microframeworks,或者也可以不用 Python 去实现,甚至还可以在 nginx 里去实现(使用 ngx_postgres 模块)。有意思的是,这里有一段 nginx 的配置,做的就是类似的事情(忽略其中可笑的正则表达式):
location ~ ^/(\w\w\w\w\w\w\w\w-\w\w\w\w-\w\w\w\w-\w\w\w\w-\w\w\w\w\w\w\w\w\w\w\w\w)/?$ { add_header Content-Type text/plain; postgres_pass database; postgres_output value; postgres_escape $ip $remote_addr; postgres_escape $agent =$http_user_agent; postgres_escape $body =$request_body; postgres_query " WITH t AS ( UPDATE api_check SET last_ping=now() WHERE code='$1' RETURNING id, last_ping ) INSERT INTO api_ping (created, remote_addr, method, ua, body, owner_id, scheme) SELECT last_ping, $ip, '$request_method', $agent, $body, id, '$scheme' FROM t RETURNING 'OK' "; postgres_rewrite no_changes 400; }
简单的说明一下这段配置:当客户端请求并且 URL 满足一定规则的时候,服务端会执行 PostgreSQL 查询,返回 HTTP 的 200 或者4 00。这样做性能上也占优,因为请求没有走到 gunicorn、Django 和 psycopg2。只要数据库可用,nginx 就可以处理 ping 请求,即使是 Django 由于某种原因挂掉了。
不过,这种方式用了一点小伎俩,而且还引入了一些细节,开发者和系统管理员需要了解这些细节。例如,当数据库的 schema 更改时,前面提到的 SQL 查询语句也需要更新并测试。另外,ngx_postgres扩展也不是简单的通过“apt-get install”就能安装成功的。
让我们再想一下,也许通过仔细规划进程的重加载,就能实现零宕机时间的目标。
我的脚本里之前使用的是“/etc/init.d/nginx restart”,这是因为我不知道更好的办法。不过现在我知道可以改成 “/etc/init.d/nginx reload”,这样会更优雅一些:
执行 service nginx reload 或 /etc/init.d/nginx reload
可以再不停止服务的前提下重新加载配置。如果有未完成的请求,那么处理这些请求的 nginx 进程会保留到处理完才退出,所以这确实是重载配置的非常优雅的方式 – “Nginx config reload without downtime” on ServerFault
类似的,我的脚本使用“supervisorctl reload”来停止服务、重新加载配置、然后再启动所有的服务。实际上,应该使用“supervisorctl update”来在配置有更新的时候启动、停止和重启服务。
现在,“fab deploy”的工作流程如下:
-
和以前一样,设置好新的虚拟环境
-
用唯一的名字(“hc_timestamp”)创建 supervisor 任务
-
在正在运行的进程之外启动一个新的 gunicorn 进程。nginx 通过 UNIX 套接字与 gunicorn进程通信,每个进程使用独立的基于时间戳的套接字文件。
-
稍等一会,保证新的 gunicorn 进程已经启动并提供服务。
-
更新 nginx 配置,指向新的套接字文件,然后重启 nginx
-
停止旧的 gunicorn 进程
下面是 Fabric 脚本的改进部分,与 supervisor 任务处理相关:
def switch(tag, project_dir): # Supervisor supervisor_conf_path = "/etc/supervisor/conf.d/hc_%s.conf" % tag upload_template("supervisor/hc.conf.tmpl", supervisor_conf_path, context=locals(), backup=False, use_sudo=True) upload_template("supervisor/hc_sendalerts.conf.tmpl", "/etc/supervisor/conf.d/hc_sendalerts.conf", context=locals(), backup=False, use_sudo=True) # Starts up gunicorn from the new virtualenv sudo("supervisorctl update") # Give it some time to start up time.sleep(5) # Let's check the new server is nominally working # gunicorn listens on UNIX socket so this is a bit contrived: l = ("GET /about/ HTTP/1.0\\r\\n" "Host: healthchecks.io\\r\\n" "\\r\\n") cmd = 'echo -e "%s" | nc -U /tmp/hc-%s.sock' % (l, tag) # Look for known string in response. If it's not found, something # is wrong with the new deployment and we abort assert "Monkey See Monkey Do" in run(cmd, quiet=True) # nginx upload_template("nginx/hc.conf.tmpl", "/etc/nginx/sites-enabled/hc.conf", context=locals(), backup=False, use_sudo=True) sudo("/etc/init.d/nginx reload") # should be live now - remove supervisor conf for previous versions s = sudo("for i in /etc/supervisor/conf.d/*.conf; do echo $i; done") for line in s.split("\n"): line = line.strip() if line == supervisor_conf_path: continue if line.startswith("/etc/supervisor/conf.d/hc_2"): sudo("rm %s" % line) # This stops gunicorn processes sudo("supervisorctl update")
通过这种方式,nginx 可以一直提供服务,总可以与在线的 gunicorn 进程交互。为了验证这点,我写了一个脚本无限循环的请求特定的 URL。当遇到非 200 的响应结果时,会打印出相应的错误信息。用这个脚本对测试虚拟机进行压测,期间部署了多次,没有发现有请求被丢掉。成功!
总结
代码部署时保证零宕机有很多种方式,每一种都有其优缺点。例如,把关键部分从一个大的系统里区分出来,这是一个合理的策略。这样每个部分就可以独立进行更新。之后每个部分也可以独立的进行扩展。这种方式的不足之处是需要维护更多的代码和配置。
最终的结果是:
-
热加载 supervisor 和 nginx 配置,而不是简单的重启它们。回顾一下,这是显而易见要做的。
-
确认新的 gunicorn 进程运行正常并且被 nginx 使用,然后才能停止老的 gunicorn 进程。
-
保持整个安装设置相对简单。当项目流量增加后,我可能需要找到性能瓶颈,决定是否需要做水平扩展,不过至少在现在,保持简单还是需要的。
强烈推荐:healthchecks.io,它是一个免费的开源的基于 cron 的监控服务。在 cron 里配置好监控只需要几分钟时间,却能让你晚上睡得更好!