無停機部署一個 Django 應用

nngrr 9年前發布 | 12K 次閱讀 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 進程。

  • 保持整個安裝設置相對簡單。當項目流量增加后,我可能需要找到性能瓶頸,決定是否需要做水平擴展,不過至少在現在,保持簡單還是需要的。

無停機部署一個 Django 應用

強烈推薦:healthchecks.io,它是一個免費的開源的基于 cron 的監控服務。在 cron 里配置好監控只需要幾分鐘時間,卻能讓你晚上睡得更好!

 本文由用戶 nngrr 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!