無停機部署一個 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 進程。
-
保持整個安裝設置相對簡單。當項目流量增加后,我可能需要找到性能瓶頸,決定是否需要做水平擴展,不過至少在現在,保持簡單還是需要的。
強烈推薦:healthchecks.io,它是一個免費的開源的基于 cron 的監控服務。在 cron 里配置好監控只需要幾分鐘時間,卻能讓你晚上睡得更好!
本文地址:http://www.oschina.net/translate/deploying-a-django-app-with-no-downtime
原文地址:https://medium.com/@healthchecks/deploying-a-django-app-with-no-downtime-f4e02738ab06#.6ytmwskav