本文轉載自昆侖楓的簡書https://www.jianshu.com/p/6d393cbb694a
Spring cloud+eureka是目前微服務主流解決方案之一,kubernetes則是廣泛應用的發布工具,兩者結合使用很常見。而兩者結合時如何優雅啟停從而實現無感發布很關鍵。
下面將從不做特殊處理時啟停存在的問題、業務代碼設計要求、spring cloud+eureka本身停機處理機制、k8s滾動發布如何關聯spring程序的啟停機制 幾點分析和提出解決方案。
1、不做特殊處理時啟停存在的問題
1.1、啟動服務時的問題
(1)spring boot只要對Application類做EnableDiscoveryClient注解,服務啟動后會自動向eureka注冊。如果服務需要預加載較多數據,預加載完成前的請求可能會失敗。要解決需要控制注冊發生在預加載后。
(2)spring程序啟動和預加載數據需要時間。但k8s會在啟動Pod后很快將外部請求導向服務,導致異常。解決這個問題需要k8s知道服務是否就緒。
1.2、停止服務時的問題
最原始的關閉服務方法是使用kill指令,常用的信號選項:
(1) kill -2 pid 向指定 pid 發送 SIGINT 中斷信號, 等同於 ctrl+c.
(2) kill -9 pid, 向指定 pid 發送 SIGKILL 立即終止信號.
(3) kill -15 pid, 向指定 pid 發送 SIGTERM 終止信號.
(4) kill pid 等同於 kill 15 pid
SIGINT/SIGKILL/SIGTERM 信號的區別:
(1) SIGINT (ctrl+c) 信號(信號編號為2),信號會被當前進程樹接收到,也就說, 不僅當前進程會收到該信號,而且它的子進程也會收到.
(2) SIGKILL信號(信號編號為 9),程序不能捕獲該信號,最粗暴最快速結束程序的方法.
(3) SIGTERM 信號 (信號編號為 15), 信號會被當前進程接收到, 但它的子進程不會收到, 如果當前進程被 kill 掉, 它的的子進程的父進程將變成 init 進程 (init 進程是那個 pid 為 1 的進程)
結束某個服務,應該盡量使用 kill pid,而不是 kill -9 pid。如果服務提供關閉通知接口,在完全退出之前,可以先做一些善后處理。
k8s關閉pod時,相當於執行kill -15指令,且在一定時間后如果仍未關閉就執行kill -9 強制殺死進程,這個時間可以配置,默認30s。
Java提供了注冊監聽器接收SIGTERM信號的機制 —— 示例 。spring服務在接收到SIGTERM信號后,框架會回收線程,終止定時任務,而spring cloud的服務發現模塊客戶端會主動向eureka請求下線。但仍會存在以下問題。
(1) 處理中的任務會被直接打斷,無法做妥善處理。比如sleep阻塞的線程會直接報異常 InterruptedException,執行中的定時任務會被直接終止退出。
(2) kill指令發出SIGTERM信號后,微服務會自動向eureka發送下線通知,但這個過程畢竟是網絡請求,有延時。其他微服務仍然會有持續發送一些請求到要關停的服務,而這時服務內部spring框架底層已經在做停止服務的處理,容易產生異常。
這兩個問題前者需要提前通知服務要被關停,並留足夠時間做善后處理;后者需要微服務得到服務關停預告后主動向eureka發送下線請求。
2、代碼設計要求
為了主動停止服務,業務代碼要做到以下要求
- 定時任務的業務處理如果時間太長,要拆分成多個短時子任務,執行每個子任務前檢查服務是否要終止,如果是則主動保存數據並不再執行后續任務。
- 未執行的任務在服務重新啟動后或者由其他實例自動檢測並執行,避免因停機產生的異常數據無法消除。
3、Spring cloud+eureka的優雅啟停方案
只基於spring cloud+eureka體系做開發也很常見,所以先了解這個體系本身解決優雅啟停的方案。
3.1微服務下線快速感知
要做到優雅啟停,微服務下線后快速被其他微服務節點感知至關重要。快速感知才能避免微服務下線后其他服務還長時間繼續請求。
spring cloud+eureka要做到下線快速感知有以下參數要注意配置。
eureka server端需要配置如下:
eureka:
server:
evictionIntervalTimerInMs: 5000 #啟用主動失效,並且每次主動失效檢測間隔為5000ms
responseCacheUpdateIntervalMs: 5000 #從ReadWriteMap刷新節點信息到ReadOnlyMap的時間,client讀取的是后者,默認30s
eureka server端對微服務節點信息的記錄有兩層,ReadWriteMap和ReadOnlyMap。最新的變化在ReadWriteMap,經過一段時間后才會更新到 ReadOnlyMap。而提供給client讀取的是 ReadOnlyMap,所以 responseCacheUpdateIntervalMs一般設置為3-5s較好。
另外提一下,很多文章解釋快速感知時,會提到一個配置——enableSelfPreservation,即是自我保護開關,這個配置大意是當eureka發現節點的心跳大面積異常時就持續一段時間(默認5分鍾,可配置)不更新節點信息。因為這時很可能是eureka server的網絡出問題了,保留錯誤的數據也要避免徹底癱瘓。此機制觸發時管理頁面會有紅字警告。但實測自我保護機制不會干擾服務主動下線時啟停的感知,所以自我保護開關可以自行按需設置。
eureka client端需要配置如下
eureka:
instance:
#服務刷新時間,每隔這個時間會主動心跳一次
leaseRenewalIntervalInSeconds: 3
#服務過期時間,超過這個時間沒有接收到心跳EurekaServer就會將這個實例剔除
leaseExpirationDurationInSeconds: 10
client:
fetchRegistry: true #定期更新eureka server拉去服務節點清單,快速感知服務下線
registryFetchIntervalSeconds: 5 #eureka client刷新本地緩存時間,默認30s
這四個配置前兩者保證服務上下線都快速被eureka server感知,后兩者保證快速感知其他微服務的上下線。
但leaseRenewalIntervalInSeconds 和 leaseExpirationDurationInSeconds兩個配置的時間對於主動下線的感知沒有影響。后面提到的優雅啟停方案都是屬於主動下線,所以這兩個配置的值可以自行調整。
3.2啟動問題處理方案
啟動問題中的第一個問題:微服務自動向eureka注注冊時數據預加載還未完成,是spring cloud本身就要處理的問題,第二個問題結合k8s時才會出現。
這里先說明第一個問題的解決方案。我們可以直接將預加載代碼放在static代碼塊或者PostConstruct標注的代碼塊,這樣可以使得預加載成為spring程序啟動的一部分,從而使得自動注冊必然發生在預加載后。同時為了方便對外提供狀態,我們在Application類main方法最后一行記錄加載完成狀態為true,並記錄時間。下面為示例代碼:
/** * 服務是否已經啟動 */ public volatile static boolean isStart = false; /** * 服務啟動的時間 */ public volatile static long startTime = 0; /** * 服務是否處於准備關閉的狀態,這個時候一些定時任務就應該及時退出了 */ public volatile static boolean isPreStop = false; public static void main(String[] args) { SpringApplication.run(xxxApplication.class, args); logger.info("服務啟動完畢"); startTime = System.currentTimeMillis(); isStart = true; } @PostConstruct public void preload(){ logger.info("預加載完畢"); }
之后再提供一個接口給查詢是否就緒,如果沒就緒就返回500的http code。由於服務啟動完畢后還要一定時間才能穩定注冊到eureka並被其他微服務感知到,所以需要睡眠一定時間。這個時間應該至少是 responseCacheUpdateIntervalMs + registryFetchIntervalSeconds,如果要更保險,可以再增加幾秒。如果要更嚴謹的話還可以增加判斷注冊是否成功。
示例代碼如下,其中isPreStop變量在后面停止方案部分會賦值,主要是為了避免服務已經處於停機准備階段了ifReady仍然返回true。
public boolean ifReady() { long timeCur = System.currentTimeMillis(); if (isStart && !isPreStop && timeCur - startTime > 16000) { return true;//success } else { //TODO:throw Exception在外層做異常處理並返回500的http code } }
3.3停止問題解決方案
停止服務兩個問題的關鍵在於
- 主動通知服務要停止,並預留時間做處理工作;
- 主動向eureka發送下線通知
對於上面兩個要求spring cloud+eureka體系有多種解決方案實現,下面只介紹最簡單易用的方案。
- 服務提供一個http接口專門用於下線通知並在接口里做服務終止處理,調用接口返回成功后預留一定時間,且在查看日志確定所有請求都結束后再調用kill -9殺死服務。這個http接口最好不對外暴露或者限制ip白名單才能調用。
- 在上一步提到的http接口中向eureka發送下線通知,比如下面的代碼就可以做到向eureka發送下線通知。由於其他微服務接收到該服務下線要一定時間,所以發送下線通知服務仍然要運行一段時間。這個時間和啟動時的等待時間類似,至少要是 responseCacheUpdateIntervalMs + registryFetchIntervalSeconds ,為了保險可以多幾秒。
@Autowired private EurekaAutoServiceRegistration autoServiceRegistration; public boolean preStop() { preStopStartTime = System.currentTimeMillis(); isPreStop = true; autoServiceRegistration.stop(); try { Thread.sleep(16000); } catch (InterruptedException e) { e.printStackTrace(); } //TODO:這里才能做回收http接口要用到的資源的操作,包括數據緩存等 return true; }
上面的方案http接口可以在服務器通過curl指令手工調用也可以結合shell腳本、jenkins腳本等使用做到自動化處理。
另外提一下,網上很多文章使用的是 EurekaClient對象的 shutDown方法,但是我實驗到這種方式無法真正下線服務,在eurekaServer端會報以下異常,其中xxx為實例的名稱,最后也沒有搞清楚原因。
Cancelled instance xxx (replication=false) DS: Registry: cancel failed because Lease is not registered for: xxxx Not Found (Cancel): xxx
3.4小結
3.1提到的eureka client端fetchRegistry 、registryFetchIntervalSeconds兩個配置和后面兩個章節提到的就緒檢測接口、prestop接口的預留時間也就是sleep的時間注意同一個系統的微服務要保持一致。
prestop接口的預留時間和eureka client的配置要一致很好理解,其他服務要感知到它已經下線不再向其發送請求,這個服務才能關閉。這些時間如果不統一可能出現在服務關停后其他微服務還沒拉取到新的節點列表的情況。
就緒檢測接口的時間和eureka client的配置要一致需要舉例說明。假設eureka server responseCacheUpdateIntervalMs的值為5000,有兩個微服務A和B,A有兩個實例,A1和A2。A在就緒接口的預留時間是15s,而B服務更新服務列表的時間registryFetchIntervalSeconds為20s。這種情況下發布A1服務:
- A1服務啟動完15s后就緒接口返回成功
- 發布管理腳本認為A1服務就緒完畢,別的節點也已經感知到A1的啟動
- 發布管理腳本開始調用A2服務的prestop接口,等返回后成功后重啟A2服務
- 但A1的發布實際上要20+5=25s才能被B感知到,剩下的時間B服務只能看到A2服務並繼續向其發送請求
為了避免上面這種不可控的情況,各個微服務的相關配置還是直接設置為一致較好。
4、k8s發布時關聯spring cloud啟停方案
使用k8s發布服務默認使用的滾動發布方案,這個方案本身已經有一定機制減少發布的影響。滾動發布時發布完一個新版本的pod后才會下線一個舊的pod,並把指向sevice的請求經負載均衡指向新pod,直到所有舊的pod下線,新的pod全部發布完畢。
所以只要k8s在pod的啟停時做到和微服務聯動,就可以做到無感發布。關鍵在於探知微服務是否准備好了、通知服務將要停止、配置啟停過程預留的時間。這幾個方面k8s都有相關的機制,所以我們先了解這些機制,再整合得出解決思路。
4.1 k8s相關機制或配置
4.1.1 啟動后就緒時間
這個機制相對簡單粗暴,通過一個屬性 minReadySeconds 設置pod啟動后多長時間才被認為就緒,默認為0s。
4.1.2 探針機制
k8s提供了是兩種探針的機制,分別為就緒探針 readinessProbe、存活探針 livenessProbe。
探針機制可以通過http接口、shell指令、tcp確認容器的狀態。探針還可以配置延遲探測時間、探測間隔、探測成功或失敗條件延后時間等參數。使用http接口探測時,可以配置header參數,如果響應的狀態碼大於等於200 且小於 400,則診斷被認為是成功的。
- 存活探針,主要用於檢測pod是否異常,如果k8s通過健康探針檢測到服務異常后會替換或重啟容器
- 就緒探針,這個探測通過時才會將其加入到service匹配的endpoint列表中,並向該容器發送http請求,否則會將pod從列表移除直到就緒探針再次通過
就緒探針和存活探針比較類似,都會持續執行檢測,只是檢測會導致的結果不一樣,一個會導致容器重啟或被替換,一個會導致http請求停止分發到容器。
探針機制詳細介紹可以查看 這里 。
詳細的配置可以查看 這里。但是要注意這個文檔里還提到啟動探針機制,但筆者嘗試配置無發生效,不確定是否和版本有關,所以對其不做介紹。
4.1.3 terminationGracePeriodSeconds 配置延遲關閉時間
該屬性默認30s,只配置terminationGracePeriodSeconds這個屬性而沒有配置prestop時,k8s會先發送SIGTERM信號給主進程,然后然后等待terminationGracePeriodSeconds 屬性的時間,會被使用SIGKILL殺死。這個機制相對簡單粗暴。
4.1.4 prestop 機制
prestop機制,為容器生命周期鈎子中的一種,也就是在准備關閉pod時會調用的。這個接口調用是至少一次,有可能調用多次,需要做好冪等處理。官方文檔有詳細介紹,可以參考查看 這里。這個機制和就緒探針類似,也可以通過http接口、shell指令兩種方式進行。
這個機制跟terminationGracePeriodSeconds會有關聯,k8s會根據prestop設置的方式通知服務將要關閉Pod,如果在GracePeriod時間后prestop鈎子仍然還在運行,就會直接發送SIGTERM信號,等待2s后使用SIGKILL殺死主進程。更詳細的步驟說明可以查看 這里
prestop對應的接口或者指令的指令執行時間不宜過長,且盡量少做資源消耗大的操作,30s以內為宜。因為k8s在pod關閉處理時會先陸續新建新的pod,如果prestop過長,會導致出現兩倍數量的pod,如果prestop嗲用的接口或指令處理不當,可能會導致資源消耗達到正常值的兩倍。
4.1.5 以上幾類機制的配置示例
提供上述幾個機制的deployment文件配置示例如下
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: review-demo
namespace: scm
labels:
app: review-demo
spec:
replicas: 3
# minReadySeconds: 60 #滾動升級時60s后認為該pod就緒
strategy:
rollingUpdate: ##由於replicas為3,則整個升級,pod個數在2-4個之間
maxSurge: 1 #滾動升級時會先啟動1個pod
maxUnavailable: 1 #滾動升級時允許的最大Unavailable的pod個數
template:
metadata:
labels:
app: review-demo
spec:
terminationGracePeriodSeconds: 60 ##k8s將會給應用發送SIGTERM信號,可以用來正確、優雅地關閉應用,默認為30秒
containers:
- name: review-demo
image: library/review-demo:0.0.1-SNAPSHOT
imagePullPolicy: IfNotPresent
lifecycle:
preStop:
httpGet:
path: /prestop
port: 8080
scheme: HTTP
livenessProbe: #kubernetes認為該pod是存活的,不存活則需要重啟
httpGet:
path: /health
port: 8080
scheme: HTTP
httpHeaders:
- name: Custom-Header
value: Awesome
initialDelaySeconds: 60 ## equals to the max startup time of the application + couple of seconds
timeoutSeconds: 10
successThreshold: 1
failureThreshold: 5
periodSeconds: 5 # 多少秒執行一次檢測
readinessProbe: #kubernetes認為該pod是准備好接收http請求了的
httpGet:
path: /ifready
port: 8080
scheme: HTTP
httpHeaders:
- name: Custom-Header
value: Awesome
initialDelaySeconds: 30 #equals to min startup time of the app
timeoutSeconds: 10
successThreshold: 1
failureThreshold: 5
periodSeconds: 5 # 多少秒執行一次檢測
resources:
# keep request = limit to keep this container in guaranteed class
requests:
cpu: 50m
memory: 200Mi
limits:
cpu: 500m
memory: 500Mi
env:
- name: PROFILE
value: "test"
ports:
- name: http
containerPort: 8080
4.2、基於以上機制解決啟動問題
使用k8s的就緒探針配合3.2提到的spring cloud+eureka程序本身的啟動和注冊機制就可以完美的解決1.1提到的微服務啟動未完成或預加載未完成就被注冊到eureka以及被k8s導入外部流量的問題。
4.3、基於機制解決停機問題
1.2提到的k8s發布spring cloud服務時停機過程的問題只需結合3.3提到的方案,使用k8s preStop機制調用微服務的服務終止處理接口即可解決。但是prestop存在一個超時時間,如果設置的超時時間內prestop調用的接口沒有完成,則服務存在強制退出的可能,從而產生異常數據。但絕大部分系統對結束處理的時長和嚴謹程度也沒那么嚴格,而且由於異常宕機難以避免,一般程序本身也必須要有異常數據處理的方案,所以這個方案基本夠用。
4.4、更嚴謹的方案
4.3提供的方案可以滿足絕大部分場景了,但對高要求的場景而然有一定的缺陷。比Pod在關閉過程中終止或暫停各種在途任務和向eureka下線服務時很可能遇到異常情況,導致超出預期時間后服務仍未終止,可能需要繼續保持pod運行,中斷pod重啟或升級方案等。
這個情況可以考慮使用k8s的ApiServer(可以通過接口管理pod等的增刪查改和啟停)結合WebHook機制訪問自定義接口確認服務是否可以關閉。這種使用方法比較高級了,如果微服務體系比較大時可以考慮這種方案。具體參考此文章 的 另辟蹊徑:解耦 Pod 刪除的控制流 部分。
5、試驗
為了試驗這個優雅啟停方案,基於k8s環境,使用兩個spring cloud+eureka服務,其中一個服務A為認證服務,提供token有效性的檢查接口,A服務使用兩個實例。另外一個服務B調用A的token校驗接口。
嘗試不使用以上的優雅啟停方案時,使用壓測工具wrk次序訪問B服務的接口,使其調用A服務的checkToken接口。然后發布新版本的A服務,結果在B服務的日志中發現大量訪問A服務接口不通的錯誤日志,時間和A服務的發布時間符合。說明光靠k8s本身的滾動發布機制,無法保證微服務體系對內部的高可用。
而是使用以上的優雅啟停方案后,服務B請求訪問A服務的token校驗接口失敗的情況不再存在。
6、總結
前面4.2、4.3提到的方案雖然在一些極端情況,可能還會產生異常數據,但對於非金融場景已經夠用。
作者:昆侖楓
鏈接:https://www.jianshu.com/p/6d393cbb694a
來源:簡書