小鵬汽車技術中臺實踐 :微服務篇

“中臺”這個概念火了一年多了,年初的時候又“火”了一次。相信任何事物都有它的兩面性,正如我們做架構的時候其實也一直在做取捨。

小鵬汽車的技術中臺(Logan)已經快兩歲了,今天我們不討論該不該做技術中臺,只說說中臺給我們帶來了什麼。

不管黑貓白貓,捉到老鼠就是好貓。

一、背景

小鵬汽車的智能離不開複雜系統的支撐,其特有的互聯網基因要求業務能夠應對市場的迅速變化:快速響應、快速試錯、快速創新。同時,爲了給客戶提供優質服務,系統需要更高的可靠性,降本增效也是公司快速發展過程中特別關注的。

技術中臺正好契合了公司上述所有需求。在公司的引領和推動下,2018年4月召開了小鵬汽車技術中臺的啓動會,同年5月底團隊成立。技術中臺團隊始終堅持“兵不在多而在精”的原則,近兩年高峯時期團隊也未超過 10 人。

也許有人會懷疑,一支足球隊規模的團隊,到底能做出個什麼樣的中臺來?

簡單來說,小鵬汽車的技術中臺主要由以下及部分組成:

  • 基於SpringCloud、Kubernetes自研的微服務體系平臺
  • 遵循業界標準的自服務中間件平臺
  • 可監可控的高性能中間件SDK

接入中臺的應用不需要寫一行業務代碼,可以立即具備以下能力:

  • 生產級應用:健康檢查、節點部署反親和性、自動擴縮容、JVM GC參數調優、資源隔離、滾動部署
  • 自動接入網關:限流、熔斷、訪問控制
  • SpringBoot使用最佳實踐:配置調優、profile標準化、線程池和連接池調優
  • 自動化CICD:自動化鏡像生成、自動處理git的tag、代碼質量
  • 豐富的問題定位手段:日誌中心、註冊中心、微服務全方位監控(JVM/Trace/Metrics/告警)
  • 系統級無埋點監控、業務Metrics提供擴展機制
  • 調用鏈
  • 數據可視化:豐富的研發數據展示
  • 配置中心提供不停機,動態調整應用配置能力

對外保證:

  • DevOps提高效率:開發、部署、問題定位
  • 生產可用性:彈性、容錯

如上圖,小鵬汽車的技術中臺分爲微服務中臺和雲平臺兩大部分:

  • 雲平臺:爲技術中臺提供底層的基礎支撐,基於主流的容器技術Docker和K8s構建,提升資源的利用率、降低運維成本;自研了容器化的部署平臺,提供用戶友好的UI、日誌聚合、跨集羣部署、歷史修訂版;自定義 CRD 封裝應用部署單元,以效率和可用性爲目標,提供生產級應用最佳實踐,簡化應用在Kubernetes的生命週期管理;開發人員只需專注業務,通過 CICD 標準化構建部署到雲平臺;
  • 微服務中臺:基於 SpringCloud 打造,提供微服務的管理、治理、監控、統一標準化配置,助力系統微服務化,提升生產可用性;並通過研發中臺提升研發、測試、問題定位效率。

本文主要分享微服務中臺的實踐經驗。

二、微服務中臺

2.1 技術選型

歷史背景:

  • 微服務功能薄弱:原有部分服務統一使用 SpringBoot + Dubbo 架構,具備微服務的初步形態,歷史負擔小,但版本不統一;且當時的 Dubbo 生態,無法提供足夠的監控和治理功能;
  • 缺少有效的監控手段:運維監控平臺使用 Zabbix 和 Open-Falcon,缺乏對應用自身的業務監控和告警;
  • 問題排查效率低:目前有部分日誌通過 agent(FileBeat)進行採集,大數據根據需要做分析,而應用無法實施查看系統日誌;缺少調用鏈的支持,由於分佈式系統的複雜性,出現問題較難定位;
  • 無法運行時修改配置:需要重新打包或修改配置,重啓應用:用戶有感知、配置缺乏回滾、審批、灰度;
  • 公共基礎功能還沒有統一:比如連接池使用、第三方依賴版本、代碼文件編碼、日誌格式;
  • 缺少項目腳手架:創建新項目多從舊項目中複製黏黏基礎代碼,無法保證持續的更新;
  • 代碼質量難度量:缺乏代碼質量管理平臺,大數項目缺乏單元測試。

鑑於以上原因,開源軟件及產品被列入首選,同時結合現實情況及需求在開源的基礎上進行功能的擴展開發。只有實在沒有選擇的情況下,我們纔會考慮自造輪子。

  • Netflix OSS 經過了大規模的生產級驗證,功能強大,組件豐富
  • SpringCloud 基於 SpringBoot,社區支持強大,有着開發效率高、更新頻率快、等優勢,且集成了 Netflix 的衆多組件,降低了使用門檻,它能夠與同樣使用 SpringBoot 的原有服務完美兼容,降低接入的時間和人工成本。

部分正在使用的組件:

  • API 網關 Zuul
  • 服務註冊發現 Eureka:基於 AP 模型
  • 客戶端負載均衡 Ribbon
  • 服務熔斷 Hystrix
  • 聲明式的服務調用 Feign
  • 邊車 Sidecar:集成了服務註冊和發現,以及 Zuul 用於實現跨語言的微服務調用
  • 分佈式跟蹤 Sleuth & Zipkin:支持 OpenTracing 協議
  • 網關限流 spring-cloud-zuul-ratelimit:結合 Zuul一起使用,支持路徑、請求源、用戶等維讀的請求限流
  • 應用指標數據 Micrometer:支持 prometheus
  • 擴展包 Actuator:提供管理接口
  • 服務管理及監控 SpringBoot Admin
  • 自動 API 文檔 Auto Restdoc:減少代碼侵入
  • 配置中心 Apollo:攜程出品的配置中心實現,支持配置的熱更新(藉助 SpringCloud 的 Context Refresh 概念
  • 數據庫連接池 Hikari:支持輸出 metrics
  • 自研項目生成器、文檔中心等

此外,中臺通過自定義的 SDK 提供對上述功能開箱即用,包含組件的配置調優、Metrics輸出、統一日誌、框架 Bug 的緊急修復以及功能擴展。

2.2 系統架構

三、可用性提升

參考官方的文檔,跑個 DEMO 是很容易的,但是真正在生產級環境中使用又會踩不少坑。

可用性對於車企來說尤爲重要,甚至說高於一切也毫不誇張,公司對這一方面也格外重視。

經過兩年的不斷踩坑填坑,同時藉助雲平臺的能力(比如自我修復、滾動升級等功能)提升了系統的可用性;同時降低應用發佈時對業務的影響:從原本的低峯時間版本升級,提升到隨時部署升級,甚至部分服務已經可以實現自動擴縮容。

下面列出我們踩過的一部分坑,也是我們不斷修煉提升可用性過程中關注的問題點。

3.1 Eureka

a. 服務實例上下線的被發現延遲

使用默認配置的情況下,實例正常上、下線的被發現延遲最大爲 90s ,比如,服務B(提供方)的一個實例上/下線,服務 A(調用方)在最長 90s之後纔會發現。這與 Netflix 的設計有關:

  1. Eureka 服務端的三級緩存模型

    a. registry :存儲服務實例信息 (服務上下線實時更新)

    b. readWriteCacheMap :讀寫緩存 (實時從 registry 中更新,過期時間 180s )

    c. readOnlyCacheMap :只讀緩存,默認從這裏獲取服務信息 (每隔 30s 從 readWriteCacheMap 更新)

  2. Eureka 消費端每隔 30s 請求 Eureka 服務端獲取增量更新,然後更新本地服務列表

  3. Ribbon 客戶端每隔 30s 從 Eureka 客戶端的本地服務列表中

可通過修改配置縮短上下線的被發現延遲:

  1. 縮短 readOnlyCacheMap 的更新週期
  2. 縮短服務消費端獲取增量更新的週期
  3. 縮短 Ribbon 客戶端從 Eureka 客戶端的本地服務列表的更新週期;或者將拉的模式,改成推的模式 (需要代碼提供新的實現)

b. 非正常下線的被發現延遲

上面提到默認配置下被發現的延遲最大是 90s。運行過程中不可避免的會出現非正常下線的情況,比如進程被強殺(kill -9),實例來不及通知註冊中心進行註銷操作就退出了。這種情況下,此實例的信息會存在服務調用方的 Ribbon 、實例列表中最長達 240s。如果是 2 個實例的話,會有 50% 的請求受到影響。這同樣源於 Netflix 的設計:

  1. 實例上線完成註冊後,會每個 30s 向註冊中心(Eureka 服務端)發送心跳請求
  2. 實例續約超時時間:實例超過 90s 未發送發送心跳纔會被註冊中心清理
  3. 註冊中心的清理操作是個定時任務,間隔 60s

可通過修改配置縮短上下線的被發現延遲:

  1. 縮短心跳間隔
  2. 縮短實例約的超時時間
  3. 縮短清理任務的工作週期

c. 自我保護模式到底開不開?

Eureka是基於 CAP 理論的 AP 模型,用於保證分區容錯性,但這也導致註冊信息在網絡分區期間可能出現不一致,自我保護功能是爲了儘可能減少這種不一致。

自我保護(self preservation)是Eureka的一項功能,Eureka註冊表在未收到實例的心跳情況超過一定閾值(默認:85%)時停止驅逐過期的實例。

解決了 b 中的延遲問題,必然會導致自我保護模式不準確。

解釋自我保護模式的工作原理,需要另開一篇文章來說。

d. 一個深坑

解決了 b 中的延遲問題,還會帶來一個坑更深的坑:極低的概率下,實例啓動後本地處於 UP 狀態,而遠端(註冊中心)的狀態持續處於 STARTING 狀態,且無法更新。這會導致實例實際正常運行,而且對服務消費者不可見。

這種情況在 Kubernetes 環境下尤爲恐怖:實例本地狀態爲 UP ,健康狀態也爲 UP 。在 Kubernetes 的滾動升級模式下,舊的實例爲刪除,新的實例正常運行,卻對消費端不可見。

問題的分析、重現方式已經記錄在Netflix Eureka的 issue(又是一長篇,目前只有英文)中,Pull Request 也已經合併到了 1.7.x 分支。不過嘛,呵呵,一直沒有發佈新版本。

注:在技術中臺運行一年多,生產環境累計出現的次數不到 10 次,不是 2 個實例運行的情況下沒有對請求出現影響。

3.2 Ribbon的容錯

Ribbon 大家常聽到的就是負載均衡,但是除了負載均衡之外,它還提供容錯的能力:重試 。

  • 遇到異常時進行重試:默認是 ConnectException 和 SocketTimeoutException ;
  • 重試次數:單個實例的重試次數( ribbon.MaxAutoRetries 默認:0),重試幾個實例( ribbon.MaxAutoRetriesNextServer 默認:1)。怎麼理解?比如 A 有4個實例:a1、a2、a3、a4。路由請求到 a1 的時報 ConnectException 異常,重試的時候不會再對 a1 進行重試(單個實例重試次數 0)。假如重試的對象 a2 也報同樣的異常,則會放棄繼續重試(重試 1 個實例);
  • 是否對所有操作重試: ribbon.OkToRetryOnAllOperations (默認: false ,即只對滿足條件的 GET 請求進行重試),其實對應的也就是常說的 HTTP 動詞: GET 、 POST 、 DELTE 等等。

這裏有個隱藏的坑,就是加入開啓了對所有操作重試的情況下,且出現SocketTimeoutException時,可能會導致一致性的問題:

因爲 SocketTimeoutException 不只是連接超時,還有讀取超時 。假如一個 POST 請求會更新數據庫,出現客戶端的讀取超時 ,但是服務端可能在客戶端斷開後完成的更新的操作。如果客戶端進行重試,則會再次進行更新。

3.3 SpringBoot Tomcat

SpringBoot Tomcat 的優雅退出,不知爲何官方沒有實現,實在匪夷所思。

爲什麼需要優雅退出?當 Spring 上下文關閉時,假如有未處理完的請求,不等請求處理完畢就直接退出。從健壯性或者一致性方面考慮,並不是一個好的解決方案。

理想的方案:收到 Spring 上下文關閉事件,阻止 Connector 接受新的請求,然後對線程池執行 #shutdown() 的操作並等待隊列中的請求處理完成。當然這裏不能無限期的等待下去(滾動升級無法繼續),設置一個超時時間比如 30s 或者 60s。如果還沒執行完,那就是執行 #shutdownNow() ,讓未完成的操作自求多福吧。

3.4 HttpClient連接池的Keep-Alive

這也是我們生產中遇到的一個問題,會在凌晨的低峯時段偶發。異常如下:

org.apache.http.NoHttpResponseException: The target server failed to respond

Caused by: org.apache.http.NoHttpResponseException: 10.128.61.43:8080 failed to respond

HttpClient 連接池創建的連接初始默認的有效期(timeToLive)是 900s,後續收到響應後會嘗試從響應的頭信息中獲取 KeepAlive: timeout=xxx 服務端連接的保持時間,然後再更新連接的有效期。後續請求從連接池獲取連接後,會再次檢查有效性,避免使用過期的連接發送請求。

參考Tomcat 8.5 配置,其中也提供 Keep-Alive 相關的配置:

keepAliveTimeout: 默認爲60s. 見org.apache.coyote.http11.Constants#L28.

maxKeepAliveRequests: 默認爲100. 設置爲1, 禁用keep alive; 設置爲-1, 無限制.

SpringBoot中通過 server.connectionTimeout 來設置Tomcat的 keepAliveTimeout ,如果未設置,則使用Tomcat的默認配置。

大寫的但是 :Tomcat 並沒有在響應頭部帶上 Keep-Alive:timeout=60 。

可通過增加過濾器–在響應頭部增加 Keep-Alive 的 timeout 配置的方式來解決。

3.5 滾動升級時的可用性保障

微服務中臺運行於雲平臺之上,應用的升級模式爲滾動升級:對服務進行升級時會先創建新版本的實例,待相應的檢查(藉助 Actuator 提供的 /health 接口)通過後,再將舊版本的實例下線。

即使完成 3.1 中 a 和 b 兩項的優化,舊的實例還會存在於消費端的本地緩存中一段時間,當然,藉助 Ribbon 的容錯可以避開這個問題,但是重試會降低吞吐。

藉助 Kubernetes 的 Pod 的 preStop lifecycle hook,在 Pod 退出時會先調用 preStop 配置的腳本。在腳本中調用本地服務的 /service-registry/instance-status 接口,將實例的狀態修改爲 DOWN ,並等待一段時間(比如 30s)。之後纔會將退出信號放行到容器中,完成後續的退出動作。以此來降低 Eureka AP模型帶來的不一致隱患。

四、功能擴展

4.1 跨語言的微服務調用

小鵬汽車的業務比較複雜,除了主要的 Java 技術棧之外,還有基於 CPU/GPU 的 Python應用,以及 Node.js 應用和前端應用。

而中臺的出發點是功能的複用,如何讓非 Java 語言的應用使用到中臺的能力?

我們的做法是藉助 Sidecar 模式實現基礎功能下沉,與應用解耦,將 SDK 中的功能,下沉到獨立進程中。

對於我們運行在 Kubernetes 上的應用來說,實現這一點就更容易了:在 Pod 中增加一個 Sidecar 的容器。

同時 Sidecar 還給我們帶來了意想不到的效果:

  • 通過抽象出與功能相關的共同基礎設施到一個獨立進程,降低了微服務代碼的複雜度;
  • 因爲你不再需要編寫相同的第三方組件配置文件和代碼,所以能夠降低微服務架構中的代碼重複度;
  • 降低應用程序代碼和底層平臺的耦合度,使應用聚焦於業務功能,也方便基礎設施的迭代演進。

當然目前的實現也不是很完美,sidecar 的實現,我們用的是 spring-cloud-netflix-sidecar 。是的,Java 的實現很重,對資源還存在一定的浪費。但是戰略有了,戰術的選擇是多樣的,比如 C++ 實現的 Envoy 。

因此當前的方案 不只是一次嘗試,也是一個佈局 。

4.2 統一日誌

服務的容器化,允許我們在統一了日誌格式後,將日誌直接輸出到標準輸出/錯誤。通過 Docker 的 json-file driver 統一落盤到 Node(Kubernetes的工作節點)上。再經由以 DaemonSet 方式運行採集器掛在日誌目錄對日誌進行採集。

詳細的工作方式及調優,請期待雲平臺實踐篇(雲平臺的能力不僅限於此)。

五、CICD

技術中臺同樣提供了基於 Jenkins 流水線(Pipeline)的CICD 平臺,經歷過兩個階段:項目級的流水線和平臺級的流水線。

這兩個流水線有什麼不同?這個跟平臺的發展階段相關。平臺發展之初,由於團隊規模小,在 CICD 平臺上投入的成本相對較少。由於一開始接入的系統不多,流水線的腳本都放在各個項目的代碼倉庫中(項目級的流水線),每次腳本更新都要去各個項目中更新。隨着接入的系統越來越多,更新的成本變得越來越高。

因此我們在後續的演進中進一步將流水線提升到平臺級,即平臺上的系統使用統一的流水線腳本,腳本保存在 GitLab 倉庫中,並提供版本控制。

藉助 Job DSL 插件,我們將抽象的項目元數據轉化爲 Jenkins 作業。在項目註冊到平臺時,會自動爲其創建作業。

每個作業的結構基本一致,包含:

  • Folder Pipeline Library:與 Global Pipeline Library 一樣,都是 Shared Library 的實現。區別是前者包含的是項目相關的信息,比如所處的 Gitlab(存在多個 Gitlab)的訪問信息。後者是所有作業共享的,包含了構建腳本;
  • Project Metadata:抽象項目的元數據,比如名稱、倉庫地址、語言等;
  • Pipeline Script:流水線的入口,啓動之後使用 Global Pipeline Library 中的構建腳本進行構建。

外部對接雲平臺、Sonar、Nexus 以及自動化測試平臺。

六、總結

微服務中臺不僅僅是技術上的開發工作,還包括中臺的落地,以及打造各種配套的功能設施,比如流程的簡化、DevOps還有表面上無法體現的各種優化和修復工作。

後續除了雲平臺,我們還會分享一些其他方向的經驗。“生命不止,奮鬥不息”,小鵬汽車的技術中臺也會持續演進。

作者介紹:

張曉輝:資深碼農,12 年軟件開發經驗。曾在匯豐軟件、唯品會、數人云等公司任職。目前就職小鵬汽車,在基礎架構團隊從事技術中臺的研發。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章