当 healthchecks.io 的流量超过每秒一次访问之后,我就意识到不能随意在部署代码后重启服务了。作为一个监控服务,即使丢掉几个 HTTP 请求也是不应该的。而且,如果服务器变得更加繁忙的话,这个问题只会更加严重。 先简单介绍一下我们所做的工作,这是一个相对简单的 Django 实现的 app,由 gunicorn 来运行,前端是 nginx。数据保存在 PostgreSQL 数据库里。gunicorn和另一个额外的后台进程由 supervisor 负责管理。整个服务在单个$20级别的DigitalOcean实例上运行。 此外,我的技术选型的指导方针是整个架构尽可能的简单,能够使用尽可能长的时间。需要添加的东西,例如负载均衡、数据库容灾、k-v存储、消息队列等等,都要是必须的。另一方面,还需要考虑更多的事情,包括监控、备份等等。同时,对于刚接触这个项目的人来说,需要花更多时间来了解整个系统的“输入和输出”,并且从头开始搭建系统。既需要保持简单、实用,还要保证性能和功能符合预期,这是个不错的挑战。 |
目前的部署方式是使用 Fabric 脚本,以及用于 supervisor 和 nginx 的配置模板。在我的工作机上运行“fab deploy”,Fabric 本就会在远端机上完成下面的事情:
|
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”的工作流程如下:
下面是 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 的响应结果时,会打印出相应的错误信息。用这个脚本对测试虚拟机进行压测,期间部署了多次,没有发现有请求被丢掉。成功! |
总结
|
本文转自:开源中国社区 [http://www.oschina.net]
本文标题:无停机部署一个 Django 应用
本文地址:http://www.oschina.net/translate/deploying-a-django-app-with-no-downtime
参与翻译:coding12
英文原文:Deploying a Django App with No Downtime