Clojure 部署無需停機
在uSwitch,我們迷上了微服務架構,這些微服務大部分采用了Clojure Ring庫,我們的基礎設施托管在Amazon AWS(亞馬遜網頁服務)。
微服務的一個優點是支持橫向擴展,尤其是把它們部署在 EC2(亞馬遜彈性計算網云)上,實現這一點很簡單: 增加更多的機器! 不幸使用了 Clojure,或者對 java虛擬器有特殊的要求,應用服務啟動時性能很低,這些問題說明部署會占用很多不合理的時間。 為了解決這些問題,我們使用熱插拔式的部署方式。 從響應的 ELB(彈性負載均衡)中移除一個主機;更新主機上的服務;將主機加入到 ELB中。
我們升級一個服務的過程如下圖:
操作步驟是:
1.初始化運行nginx反向代理到服務v1;
2.從ELB中移除第一個主機;
3.停止主機上的服務v1;
4.將主機上的服務升級為v2;
5.把主機重新加入到ELB中;
6.從ELB中移除下一個主機;
7.停止主機上的服務v1;
8.將主機上的服務升級為v2;
9.把主機重新加入到ELB中;
盡管大部分情況這種操作方式是可行的,但是作為一種升級方案我們對此并不滿意,原因如下:
1. 如果一個服務只發布在某一個主機上,那么部署的時候,這個服務有一段時間會不可用。
2. 熱插拔式的部署意味著全部的部署時間與主機的數量呈線性關系。
3. 如果新發布的服務在主機上不能正常啟動,有可能導致失去ELB中整個服務的基礎設施。
4. 從 ELB中移除一個主機時,可能移除了多個服務,這樣會降低我們系統的響應性能。
作為我們最近黑客生活的一部分,我們決定研究一個解決方案,方案基于一個簡單的決定: 如果一個服務監聽的是一個隨機的端口,那么我們可以運行兩個實例,此時一個服務會有兩個不同的版本。 復雜之處在于:服務監聽的端口是隨機的,nginx反向代理端口時,我們要如何處理這個服務?如何整理之前運行的其它版本的這個服務? 使用一個服務注冊表可以解決這些問題,例如etcd, 注冊表里存儲服務的端口號和PID(進程ID),同時我們使用一個類似 confd的進程監視服務的變化。
升級服務的過程更像是這樣:
其中的步驟是:
1.初始化運行nginx反向代理到服務v1;
2.啟動服務v2,把服務v1的端口和服務v2的端口保存到etcd中;
3.confd監視端口的變化,重新生成nginx配置,重啟nginx,斷開服務v1,連接服務v2;
4.服務v2關聯之前的服務v1的PID和自身的PID,將關聯信息保存到etcd中;
5.confd檢測到PID變化,生成并運行停止服務的腳本,停止服務v1;
nginx重啟時,主進程啟動新的線程并終止原來的線程,這種方式意味著服務的不可用時間基本為0。
解決方法
服務
目前,我們準備采用Stuart Sierra優秀的組件項目來管理服務的生命周期,初始化的時候它會存儲一個隨機的數字,然后它會把數字返回給任意一個請求。 啟動Jetty(一個開源的Servlet容器),操作指定的系統,端口就是數據傳輸所需的端口號。 如果我們以某種方式使用這個端口號進行通信,nginx能夠接收到數據,那么我們就可以一次運行服務的多個實例,并轉換反向代理。
我們的etcd會運行在某一個主機上,而不是集群上: 在主機之外,我們不需要傳輸這個服務的信息。 為了將端口號由服務傳遞給etcd,我們計劃使用一個已知的密鑰 uswitch/experiment/port/current部署etcd-clojure。
(ns etcd-experiment.system (:require [com.stuartsierra.component :refer [Lifecycle]] [etcd-experiment.util :refer [etcd-swap-value]] [ring.adapter.jetty :refer [run-jetty]]))(defrecord JettyComponent [root-key routes] Lifecycle (start [component] (let [server (run-jetty routes {:join? false :port 0}) server-port (.getLocalPort (first (.getConnectors server)))] (etcd-swap-value (str root-key "/port") server-port) (clojure.pprint/pprint {:service-port server-port}) (-> component (assoc :server server) (assoc :server-port server-port))))
(stop [component] (let [server (:server component)] (.stop server) component)))
(defn jetty-component [root-key routes] (map->JettyComponent {:root-key root-key :routes routes}))</pre>
我們需要構建一個組件,用來將服務的PID存儲到uswitch/experiment/pid/current路徑下,并確保它只依賴于服務組件。
(ns etcd-experiment.pid-manager (:require [com.stuartsierra.component :refer [Lifecycle]] [clj-pid.core :as pid] [etcd-experiment.util :refer [etcd-swap-value]]))(defrecord PidManager [root-key service] Lifecycle (start [component] (let [pid (pid/current)] (etcd-swap-value (str root-key "/pid") pid) (clojure.pprint/pprint {:service-pid pid}) (assoc component :pid pid)))
(stop [component] component))
(defn pid-manager-component [root-key] (map->PidManager {:root-key root-key}))</pre>
我們也需要將之前的鍵值保存到路徑 uswitch/experiment/port/previous 和uswitch/experiment/pid/previous下,這個功能,我們在代碼 etcd-experiment.util/etcd-swap-value 中實現。
隨機分配端口號的優點:不僅支持一次可以運行多個相同的服務,而且只有在服務啟動之后端口號才可以訪問。 由于組件的依賴性,當服務成功部署并啟動后,我們才將端口號和 PID信息寫入 etcd。
基礎架構
除了允許將一個新發布服務的響應與服務分離之外,etcd看起來被使用過度了:我們可以監視etcd鍵變化,并以希望的方式對其進行修改,而不用將etcd與服務緊緊地耦合在一起。 etcd鍵變化后,confd使用配置生成文件并執行命令,這點我們將在后面用到。服務有一個相關的nginx配置文件,路徑為/etc/nginx/sites-enabled/experiment.conf,它實現在一臺獨立主機上運行多個服務。為了實現基于etcd信息變化的處理機制,我們在系統中添加一個配置文件/etc/confd/conf.d/experiment-nginx.toml,實現監視uswitch/experiment/port/current路徑下數據、生成nginx配置文件、當配置變化時重啟nginx。nginx配置文件的模板比較簡單,我們只需要在輸出文件中分配一個隨機的端口號。
[template] src = "nginx.conf.tmpl" dest = "/etc/nginx/sites-enabled/experiment-nginx.conf" owner = "root" group = "root" mode = "0644" keys = ["/uswitch/experiment/port/current"] check_cmd = "/usr/sbin/nginx -t -c /etc/nginx/nginx.conf" reload_cmd = "/usr/sbin/service nginx reload"upstream experiment_app { server 127.0.0.1:{{.uswitch_experiment_port_current}}; }server { listen 80;
location / { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $http_host; proxy_redirect off; proxy_pass http://experiment_app; } }</pre>
nginx重啟導致主進程開了一個新的工作進程并殺死老的那個,從客戶端應用的角度來看,這意味著我們擁有(幾乎為)零的停機時間。正因如此我們不需要為了更新我們的服務,而從ELB中移除我們的主機,因此,我們可以終止這種移除-更新-添加部署(方式),使用并行(方式)部署到所有的機器。
我們可以通過查看uswitch/experiment/pid/previous,配置和產生一個可以被執行的腳本去殺死與其相關聯的進程PID,清掉之前的服務。
[template] src = "restart-service.sh.tmpl" dest = "/tmp/restart-service.sh" mode = "0700" keys = ["/uswitch/experiment/pid/previous"] reload_cmd = "/bin/sh /tmp/restart-service.sh"#!/bin/sh kill -9 {{.uswitch_experiment_pid_previous}}與其他地方一樣,都需要對配置定期檢查,我們在首次開啟我們服務的時候,查看nginx生成的配置,并且重啟nginx。如果服務第二次被啟動,nginx配置再次被生成并且nginx重啟;之前的服務就會被殺掉;更重要的是,返回的服務數量也改變了!
如果你對于在我們的機器上發生的事情感興趣,這里有介紹,還包含了hack day工程。
總結
希望這篇頗具深度的服務部署攻略使你相信我們的解決方案:
部署系統時實際宕機時間為0,這點比以前減少很多;
能夠并行部署在多臺機器上,這意味著部署時間接近一個常量,以前部署時間為一個線性值;
可靠性提高,只有當新的服務啟動成功后,舊的服務才會被替換,如果啟動失敗,以前我們不得不進行回滾;
服務間相互隔離,在同一臺機器上部署多個服務時各自不受影響,以前我們降級服務的次數比部署服務的次數還多;
下一步是微服務架構的核心:集群部署etcd,完全移除nginx:如果客戶端應用使用注冊表定位服務的方式,那么下面這些步驟都是不必要的。實際上,我們也在尋找不使用etcd而建立一個完整的服務注冊表的方案,比如consul 或者 zookeeper, 后者在我們的其它項目中已有部署。 然而,這種方法要求客戶端應用承擔更多的功能,這嚴重偏離了預期目標!
目前這段代碼仍然是我們黑客生活的一部分:它可以使用,但還需要在真實環境中進行驗證。假設在多臺主機上有很多服務在同時運行,我們定期地部署服務,上面的這個方案可以節省我們相當大一部分時間,它可以應用到我們的生產系統中。
如果你用其它的方案解決了上面提到的問題,請務必留言。