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

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

image

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

文章转载自 开源中国社区[https://www.oschina.net]

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值