无停机部署一个 Django 应用

nngrr 8年前

无停机部署一个 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 进程。

  • 保持整个安装设置相对简单。当项目流量增加后,我可能需要找到性能瓶颈,决定是否需要做水平扩展,不过至少在现在,保持简单还是需要的。

无停机部署一个 Django 应用

强烈推荐:healthchecks.io,它是一个免费的开源的基于 cron 的监控服务。在 cron 里配置好监控只需要几分钟时间,却能让你晚上睡得更好!