当我们上线新的服务应用时,经常不得不重启Web服务器以完成部署。但这会对用户造成一定影响,特别是服务器处于繁忙状态时,问题更严重。本文中,作者将针对这一问题,讲述其如何在不停机条件下部署Django应用。

当我的网站 healthchecks.io 每秒接收的请求次数超过一次之后,我就非常清楚地认识到我不能再像以前那样部署代码之后随意重启 web 服务器了。作为一个监控服务,哪怕是漏掉几条 HTTP 的请求也是不能接受的。并且随着时间推移,服务器越来越忙碌,这个问题只会更严重。

先让我简单介绍一下我在做什么吧:我在做的 app 是一个相对简单的 Django app,搭建在 gunicorn 的 WSGI 服务上,使用 nginx 作为代理服务器,而数据存储在 PostgreSQL 数据库上。gunicorn 进程和一个附加的后台作业都由 supervisor 管理。app 主机是一台区区 20 美元的 DigitalOcean 服务器。

另外,鉴于当前技术选择的日新月异,我遵循的指导原则就是保证技术栈尽可能长时间地保持简单和可扩展。添加一些诸如负载均衡、数据库复制、键值对存储、消息队列等等的功能,这些功能的确都能带来某些益处。然而从另一方面来讲,这也意味着有更多东西需要管理、监控和备份。同时对于参与项目的新人来说,他们不得不花更多时间弄清楚系统的数据流向,从头开始建立起所有的东西。我认为在不牺牲性能和特性的前提下,能够保持简单不冗余的架构是一种有趣的挑战。

我使用的部署机制是 Fabric 脚本加上 supervisor 和 nginx 的配置模板。每次我在工作站上运行 “fab deploy”命令时,Fabric 脚本会在远程服务器上完成如下事项:

  • 为新的部署创建一个目录,姑且将之称为 

$TARGET

  • 在 

$TARGET/venv

  • 从 GitHub 上抓取最新的代码快照到你的

$TARGET

  • 目录。你可以使用 GitHub 的 SVN 接口运行“svn export”命令完成这项操作,非常简便。这项操作如你所愿地只是生成了源文件,但是没有任何版本控制的元数据。
  • 根据 requirements 文件安装依赖包。这些依赖包会安装在虚拟环境下而不会影响现有应用。下载和安装依赖包大概需要一分钟(这里的时间估算应当针对国外环境,国内的网络状况,你懂的 :) 译者注。)
  • 运行 Django 管理命令收集静态文件,执行数据库迁移等等准备工作。
  • 重写 supervisor 的配置文件,在新的虚拟环境下运行 gunicorn。
  • 更新 nginx 配置文件,以防改动过 nginx 配置模板中的某些配置没有生效。
  • 运行 “supervisorctl reload” 和“/etc/init.d/nginx restart”。当前 web 应用还是不能访问的,直到 supervisor 开始运行,启动 gunicorn 进程并且初始 Django 代码完成之后才能访问。这个过程通常需要 5 至 10秒,在这期间 nginx 一般会返回 “502 Bad Gateway”。
  • 大功告成!

下面是相关的 Fabric 脚本代码示例。脚本中使用的虚拟环境上下文管理器(virtualenv context manager)来源于非常棒的 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" )

 


现在问题来了:在每次发布的最后一步怎样尽量避免停机?首先我们先设置如下约束条件:没有负载均衡(至少现在没有)。所有服务运行在一个环境下,尽可能避免一切错误返回。现在我们从最简单的步骤开始。我们先来考虑一种简单的(并且是常见的)场景:不会发生数据库迁移,并且部署的改动是向后兼容的:旧版本的 app 可以在迁移之后正常运行。

我想到的第一个想法是基于这样一个观点:可用性对于 app 的某些部分更为重要,而对于其他部分可能就没那么重要了。具体到我的 app 来说,比如 app 监听客户机发来的 ping 包的 API 接口部分就需要更高的可用性,而给普通访问者提供的前端服务页面部分就没有那么重要了。尽管给访问者返回了错误页面十分尴尬,但是不遗漏任何一个 ping 包才是最重要的。毕竟我们提供的是监控服务,一个丢失的 ping 包可能导致在之后某个时间发出一个假警报——这才是最尴尬的!

我考虑使用亚马逊 API 监听这些 ping 包,并且设计了个原型。它可以把 ping 包信息放在 亚马逊 SQS 队列中,当 Django app 有空闲时再处理。这是一种相对简单的处理方法,大幅提高了可用性和扩展性,但是也是以增加复杂度和添加新的外部依赖为代价的。我可能在之后还需要重新审视一下这个方案。

下一个想法是这样的:把监听 ping 包的功能从原来的 app 中剥离出来。ping 监听的逻辑其实非常简单,最终只相当于两个 SQL 操作:update 和 insert。这一部分代码重构起来非常容易,比如使用一个 python 的微型框架,或者使用其他的语言实现,甚至可以用 nginx 本身的 ngx_postgres 模型来实现。下面提供了基本实现了这个功能的 nginx 配置文件,仅供消遣(忽略掉那个写得很滑稽的正则表达式 :))。



 

location ~ ^ / ( wwwwwwww - wwww - wwww - wwww - wwwwwwwwwwww ) / ? $ {
     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 数据库上查询并且返回,返回码是 200 或者 400。这对性能也是一种提升,因为请求不需要在跑一遍 gunicorn,Django 和 psycopg2 然后才返回了。只要数据库可用,nginx 就可以处理这些 ping 包,即便是 Django 应用意外宕机了也没关系。

然而这种实现方式在某些情况下并非最好选择,这种方式有一定技巧性,并且要求开发者和系统管理员的知识储备要足够丰富才行。举个例子,当数据库模式发生改变,上面的 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” 可以完成以下任务:

  • 建立一个新的虚拟环境,和之前一样。
  • 创建一个唯一名称的 supervisor 任务(比如“hc_timestamp”)。
  • 与已经运行的 gunicorn 进程并行启动一个新的 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.0rn"
         "Host: healthchecks.iorn"
         "rn" )
 
     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。如果得到了一个非正确的返回,就会打印出来一个显眼的错误消息。使用这个脚本不断冲击我的测试虚拟机的同时,我做了一些部署操作,并没有发现丢失的请求。大功告成!

总结

要实现在代码部署时不停机,有很多种方式,每种方式都要权衡利弊。比如说一个合理的策略是把关键部分从原来的应用中剥离出来,每个部分都可以独立更新。之后每个部分也可以独立扩展。但是这种策略的缺点就是有更多的代码和配置需要维护。

最终我达成的效果是:

  • 热重载 supervisor 和 nginx 配置,而不是直接重启它们。根据之前的经验,这种做法的好处是显而易见的。
  • 在停止旧的 gunicorn 进程之前,确认新的 gunicorn 进程已经启动并且与 nginx 正常通信
  • 保持整个架构相对简单。当这个项目有更多人在用时,我需要找出性能短板并且想办法水平扩展。但是现在就要考虑到这一点。

打个广告:healthchecks.io 是一个免费的开源 cron 监控服务。你只需要花几分钟的时间就可以启动对你的 cron 任务的监控。妈妈再也不用担心我晚上睡不好啦!