一個(gè)可供小團(tuán)隊(duì)參考的微服務(wù)落地實(shí)踐
微服務(wù)是否適合小團(tuán)隊(duì)是個(gè)見(jiàn)仁見(jiàn)智的問(wèn)題。但小團(tuán)隊(duì)并不代表出品的一定是小產(chǎn)品,當(dāng)業(yè)務(wù)變得越來(lái)越復(fù)雜,如何使用微服務(wù)分而治之就成為一個(gè)不得不面對(duì)的問(wèn)題。
因?yàn)槲⒎?wù)是對(duì)整個(gè)團(tuán)隊(duì)的考驗(yàn),從開(kāi)發(fā)到交付,每一步都充滿(mǎn)了挑戰(zhàn)。經(jīng)過(guò) 1 年多的探索和實(shí)踐,本著將 DevOps 落實(shí)到產(chǎn)品中的愿景,一步步建設(shè)出適合我們的微服務(wù)平臺(tái)。
要不要微服務(wù)
我們的產(chǎn)品是 Linkflow,企業(yè)運(yùn)營(yíng)人員使用的客戶(hù)數(shù)據(jù)平臺(tái)(CDP)。產(chǎn)品的一個(gè)重要部分類(lèi)似企業(yè)版的“捷徑",讓運(yùn)營(yíng)人員可以像搭樂(lè)高積木一樣創(chuàng)建企業(yè)的自動(dòng)化流程,無(wú)需編程即可讓數(shù)據(jù)流動(dòng)起來(lái)。
從這一點(diǎn)上,我們的業(yè)務(wù)特點(diǎn)就是聚少成多,把一個(gè)個(gè)服務(wù)連接起來(lái)就成了數(shù)據(jù)的海洋。
理念上跟微服務(wù)一致,一個(gè)個(gè)獨(dú)立的小服務(wù)最終實(shí)現(xiàn)大功能。當(dāng)然我們一開(kāi)始也沒(méi)有使用微服務(wù),當(dāng)業(yè)務(wù)還未成型就開(kāi)始考慮架構(gòu),那么就是“過(guò)度設(shè)計(jì)"。
另一方面需要考慮的因素就是“人",有沒(méi)有經(jīng)歷過(guò)微服務(wù)項(xiàng)目的人,團(tuán)隊(duì)是否有 DevOps 文化等等,綜合考量是否需要微服務(wù)化。
微服務(wù)的好處是什么?
- 相比于單體應(yīng)用,每個(gè)服務(wù)的復(fù)雜度會(huì)下降,特別是數(shù)據(jù)層面(數(shù)據(jù)表關(guān)系)更清晰,不會(huì)一個(gè)應(yīng)用上百?gòu)埍恚聠T工上手快。
- 對(duì)于穩(wěn)定的核心業(yè)務(wù)可以單獨(dú)成為一個(gè)服務(wù),降低該服務(wù)的發(fā)布頻率,也減少測(cè)試人員壓力。
- 可以將不同密集型的服務(wù)搭配著放到物理機(jī)上,或者單獨(dú)對(duì)某個(gè)服務(wù)進(jìn)行擴(kuò)容,實(shí)現(xiàn)硬件資源的充分利用。
- 部署靈活,在私有化項(xiàng)目中,如果客戶(hù)有不需要的業(yè)務(wù),那么對(duì)應(yīng)的微服務(wù)就不需要部署,節(jié)省硬件成本,就像上文提到的樂(lè)高積木理念。
微服務(wù)有什么挑戰(zhàn)?
- 一旦設(shè)計(jì)不合理,交叉調(diào)用,相互依賴(lài)頻繁,就會(huì)出現(xiàn)牽一發(fā)動(dòng)全身的局面。想象單個(gè)應(yīng)用內(nèi) Service 層依賴(lài)復(fù)雜的場(chǎng)面就明白了。
- 項(xiàng)目多了,輪子需求也會(huì)變多,需要有人專(zhuān)注公共代碼的開(kāi)發(fā)。
- 開(kāi)發(fā)過(guò)程的質(zhì)量需要通過(guò)持續(xù)集成(CI)嚴(yán)格把控,提高自動(dòng)化測(cè)試的比例,因?yàn)橥粋€(gè)接口改動(dòng)會(huì)涉及多個(gè)項(xiàng)目,光靠人工測(cè)試很難覆蓋所有情況。
- 發(fā)布過(guò)程會(huì)變得復(fù)雜,因?yàn)槲⒎?wù)要發(fā)揮全部能力需要容器化的加持,容器編排就是***的挑戰(zhàn)。
- 線上運(yùn)維,當(dāng)系統(tǒng)出現(xiàn)問(wèn)題需要快速定位到某個(gè)機(jī)器節(jié)點(diǎn)或具體服務(wù),監(jiān)控和鏈路日志分析都必不可少。
下面詳細(xì)說(shuō)說(shuō)我們是怎么應(yīng)對(duì)這些挑戰(zhàn)的。
開(kāi)發(fā)過(guò)程的挑戰(zhàn)
持續(xù)集成
通過(guò) CI 將開(kāi)發(fā)過(guò)程規(guī)范化,串聯(lián)自動(dòng)化測(cè)試和人工 Review。
我們使用 Gerrit 作為代碼&分支管理工具,在流程管理上遵循 GitLab 的工作流模型:
- 開(kāi)發(fā)人員提交代碼至 Gerrit 的 Magic 分支。
- 代碼 Review 人員 Review 代碼并給出評(píng)分。
- 對(duì)應(yīng) Repo 的 Jenkins job 監(jiān)聽(tīng)分支上的變動(dòng),觸發(fā) Build job。經(jīng)過(guò) IT 和 Sonar 的靜態(tài)代碼檢查給出評(píng)分。
- Review 和 Verify 皆通過(guò)之后,相應(yīng) Repo 的負(fù)責(zé)人將代碼 Merge 到真實(shí)分支上。
- 若有一項(xiàng)不通過(guò),代碼修改后重復(fù)過(guò)程。
- Gerrit 將代碼實(shí)時(shí)同步備份至兩個(gè)遠(yuǎn)程倉(cāng)庫(kù)中。
集成測(cè)試
一般來(lái)說(shuō)代碼自動(dòng)執(zhí)行的都是單元測(cè)試(Unit Test),即不依賴(lài)任何資源(數(shù)據(jù)庫(kù),消息隊(duì)列)和其他服務(wù),只測(cè)試本系統(tǒng)的代碼邏輯。
但這種測(cè)試需要 Mock 的部分非常多,一是寫(xiě)起來(lái)復(fù)雜,二是代碼重構(gòu)起來(lái)跟著改的測(cè)試用例也非常多,顯得不夠敏捷。而且一旦要求開(kāi)發(fā)團(tuán)隊(duì)要達(dá)到某個(gè)覆蓋率,就會(huì)出現(xiàn)很多造假的情況。
所以我們選擇主要針對(duì) API 進(jìn)行測(cè)試,即針對(duì) Controller 層的測(cè)試。另外對(duì)于一些公共組件如分布式鎖,Json 序列化模塊也會(huì)有對(duì)應(yīng)的測(cè)試代碼覆蓋。
測(cè)試代碼在運(yùn)行時(shí)會(huì)采用一個(gè)隨機(jī)端口拉起項(xiàng)目,并通過(guò) HTTP Client 對(duì)本地 API 發(fā)起請(qǐng)求,測(cè)試只會(huì)對(duì)外部服務(wù)做 Mock,數(shù)據(jù)庫(kù)的讀寫(xiě),消息隊(duì)列的消費(fèi)等都是真實(shí)操作,相當(dāng)于把 Jmeter 的事情在 Java 層面完成一部分。
Spring Boot 項(xiàng)目可以很容易的啟動(dòng)這樣一個(gè)測(cè)試環(huán)境,代碼如下:
測(cè)試過(guò)程的 HTTP Client 推薦使用 io.rest-assured:rest-assured 支持 JsonPath,十分好用。
測(cè)試時(shí)需要注意的一個(gè)點(diǎn)是測(cè)試數(shù)據(jù)的構(gòu)造和清理。構(gòu)造又分為 Schema 的創(chuàng)建和測(cè)試數(shù)據(jù)的創(chuàng)建:
- Schema 由 Flyway 處理,在啟用測(cè)試環(huán)境前先刪除所有表,再進(jìn)行表的創(chuàng)建。
- 測(cè)試數(shù)據(jù)可以通過(guò) @Sql 讀取一個(gè) SQL 文件進(jìn)行創(chuàng)建,在一個(gè)用例結(jié)束后再清除這些數(shù)據(jù)。
順帶說(shuō)一下,基于 Flyway 的 Schema Upgrade 功能我們封成了獨(dú)立的項(xiàng)目,每個(gè)微服務(wù)都有自己的 Upgrade 項(xiàng)目。
好處:一是支持 command-line 模式,可以細(xì)粒度的控制升級(jí)版本;二是也可以支持分庫(kù)分表以后的 Schema 操作。Upgrade項(xiàng)目也會(huì)被制作成 Docker image 提交到 Docker hub。
測(cè)試在每次提交代碼后都會(huì)執(zhí)行,Jenkins 監(jiān)聽(tīng) Gerrit 的提交,通過(guò) docker run -rm {upgrade 項(xiàng)目的 image}先執(zhí)行一次 Schema Upgrade,然后 Gradle test 執(zhí)行測(cè)試。
最終會(huì)生成測(cè)試報(bào)告和覆蓋率報(bào)告,覆蓋率報(bào)告采用 JaCoCo 的 Gradle 插件生成,如下圖:
這里多提一點(diǎn),除了集成測(cè)試,服務(wù)之間的接口要保證兼容,實(shí)際上還需要一種 consumer-driven testing tool。
就是說(shuō)接口消費(fèi)端先寫(xiě)接口測(cè)試用例,然后發(fā)布到一個(gè)公共區(qū)域,接口提供方發(fā)布接口時(shí)也會(huì)執(zhí)行這個(gè)公共區(qū)域的用例,一旦測(cè)試失敗,表示接口出現(xiàn)了不兼容的情況。
比較推薦大家使用 Pact 或是 Spring Cloud Contact。我們目前的契約基于“人的信任”,畢竟服務(wù)端開(kāi)發(fā)者還不多,所以沒(méi)有必要使用這樣一套工具。
集成測(cè)試的同時(shí)還會(huì)進(jìn)行靜態(tài)代碼檢查,我們用的是 Sonar,當(dāng)所有檢查通過(guò)后 Jenkins 會(huì) +1 分,再由 Reviewer 進(jìn)行代碼 Review。
自動(dòng)化測(cè)試
單獨(dú)拿自動(dòng)化測(cè)試出來(lái)說(shuō),就是因?yàn)樗琴|(zhì)量保證的非常重要的一環(huán),上文能在 CI 中執(zhí)行的測(cè)試都是針對(duì)單個(gè)微服務(wù)的。
那么當(dāng)所有服務(wù)(包括前端頁(yè)面)都在一起工作的時(shí)候是否會(huì)出現(xiàn)問(wèn)題,就需要一個(gè)更接近線上的環(huán)境來(lái)進(jìn)行測(cè)試了。
在自動(dòng)化測(cè)試環(huán)節(jié),我們結(jié)合 Docker 提高一定的工作效率并提高測(cè)試運(yùn)行時(shí)環(huán)境的一致性以及可移植性。
在準(zhǔn)備好基礎(chǔ)的 Pyhton 鏡像以及 Webdriver(Selenium)之后,我們的自動(dòng)化測(cè)試工作主要由以下主要步驟組成:
- 測(cè)試人員在本地調(diào)試測(cè)試代碼并提交至 Gerrit。
- Jenkins 進(jìn)行測(cè)試運(yùn)行時(shí)環(huán)境的鏡像制作,主要將引用的各種組件和庫(kù)打包進(jìn)一個(gè) Python 的基礎(chǔ)鏡像。
- 通過(guò) Jenkins 定時(shí)或手動(dòng)觸發(fā),調(diào)用環(huán)境部署的 Job 將專(zhuān)用的自動(dòng)化測(cè)試環(huán)境更新,然后拉取自動(dòng)化測(cè)試代碼啟動(dòng)一次性的自動(dòng)化測(cè)試運(yùn)行時(shí)環(huán)境的 Docker 容器,將代碼和測(cè)試報(bào)告的路徑鏡像至容器內(nèi)。
- 自動(dòng)化測(cè)試過(guò)程將在容器內(nèi)進(jìn)行。
- 測(cè)試完成之后,不必手動(dòng)清理產(chǎn)生的各種多余內(nèi)容,直接在 Jenkins 上查看發(fā)布出來(lái)的測(cè)試結(jié)果與趨勢(shì)。
關(guān)于部分性能測(cè)試的執(zhí)行,我們同樣也將其集成到 Jenkins 中,在可以直觀的通過(guò)一些結(jié)果數(shù)值來(lái)觀察版本性能變化情況的回歸測(cè)試和基礎(chǔ)場(chǎng)景,將會(huì)很大程度的提高效率,便捷的觀察趨勢(shì):
- 測(cè)試人員在本地調(diào)試測(cè)試代碼并提交至 Gerrit。
- 通過(guò) Jenkins 定時(shí)或手動(dòng)觸發(fā),調(diào)用環(huán)境部署的 Job 將專(zhuān)用的性能測(cè)試環(huán)境更新以及可能的 Mock Server 更新。
- 拉取***的性能測(cè)試代碼,通過(guò) Jenkins 的性能測(cè)試插件來(lái)調(diào)用測(cè)試腳本。
- 測(cè)試完成之后,直接在 Jenkins 上查看通過(guò)插件發(fā)布出來(lái)的測(cè)試結(jié)果與趨勢(shì)。
發(fā)布過(guò)程的挑戰(zhàn)
上面提到微服務(wù)一定需要結(jié)合容器化才能發(fā)揮全部?jī)?yōu)勢(shì),容器化就意味著線上有一套容器編排平臺(tái)。我們目前采用是 Redhat 的 OpenShift。
所以發(fā)布過(guò)程較原來(lái)只是啟動(dòng) Jar 包相比要復(fù)雜的多,需要結(jié)合容器編排平臺(tái)的特點(diǎn)找到合適的方法。
鏡像準(zhǔn)備
公司開(kāi)發(fā)基于 GitLab 的工作流程,Git 分支為 Master,Pre-production和 Prodution 三個(gè)分支,同時(shí)生產(chǎn)版本發(fā)布都打上對(duì)應(yīng)的 Tag。
每個(gè)項(xiàng)目代碼里面都包含 Dockerfile 與 Jenkinsfile,通過(guò) Jenkins 的多分支 Pipeline 來(lái)打包 Docker 鏡像并推送到 Harbor 私庫(kù)上。
Docker 鏡像的命令方式為:項(xiàng)目名/分支名:git_commit_id,如 funnel/production:4ee0b052fd8bd3c4f253b5c2777657424fccfbc9。
Tag 版本的 Docker 鏡像命名為:項(xiàng)目名 /release:tag 名,如 funnel/release:18.10.R1。
在 Jenkins 中執(zhí)行 build docker image job 時(shí)會(huì)在每次 Pull 代碼之后調(diào)用 Harbor 的 API 來(lái)判斷此版本的 Docker image 是否已經(jīng)存在,如果存在就不執(zhí)行后續(xù)編譯打包的 Stage。
在 Jenkins 的發(fā)布任務(wù)中會(huì)調(diào)用打包 Job,避免了重復(fù)打包鏡像,這樣就大大的加快了發(fā)布速度。
數(shù)據(jù)庫(kù) Schema 升級(jí)
數(shù)據(jù)庫(kù)的升級(jí)用的是 Flyway,打包成 Docker 鏡像后,在 OpenShift 中創(chuàng)建 Job 去執(zhí)行數(shù)據(jù)庫(kù)升級(jí)。
Job 可以用最簡(jiǎn)單的命令行的方式去創(chuàng)建:
腳本升級(jí)任務(wù)也集成在 Jenkins 中。
容器發(fā)布
OpenShift 有個(gè)特別概念叫 DeploymentConfig,原生 Kubernetes Deployment 與之相似,但 OpenShift 的 DeploymentConfig 功能更多。
DeploymentConfig 關(guān)聯(lián)了一個(gè)叫做 ImageStreamTag 的東西,而這個(gè) ImagesStreamTag 和實(shí)際的鏡像地址做關(guān)聯(lián),當(dāng) ImageStreamTag 關(guān)聯(lián)的鏡像地址發(fā)生了變更,就會(huì)觸發(fā)相應(yīng)的 DeploymentConfig 重新部署。
我們發(fā)布是使用了 Jenkins+OpenShift 插件,只需要將項(xiàng)目對(duì)應(yīng)的 ImageStreamTag 指向到新生成的鏡像上,就觸發(fā)了部署。
如果是服務(wù)升級(jí),已經(jīng)有容器在運(yùn)行怎么實(shí)現(xiàn)平滑替換而不影響業(yè)務(wù)呢?
配置 Pod 的健康檢查,Health Check 只配置了 ReadinessProbe,沒(méi)有用 LivenessProbe。
因?yàn)?LivenessProbe 在健康檢查失敗之后,會(huì)將故障的 Pod 直接干掉,故障現(xiàn)場(chǎng)沒(méi)有保留,不利于問(wèn)題的排查定位。而 ReadinessProbe 只會(huì)將故障的 Pod 從 Service 中踢除,不接受流量。
使用了 ReadinessProbe 后,可以實(shí)現(xiàn)滾動(dòng)升級(jí)不中斷業(yè)務(wù),只有當(dāng) Pod 健康檢查成功之后,關(guān)聯(lián)的 Service 才會(huì)轉(zhuǎn)發(fā)流量請(qǐng)求給新升級(jí)的 Pod,并銷(xiāo)毀舊的 Pod。
線上運(yùn)維的挑戰(zhàn)
服務(wù)間調(diào)用
Spring Cloud 使用 Eruka 接受服務(wù)注冊(cè)請(qǐng)求,并在內(nèi)存中維護(hù)服務(wù)列表。
當(dāng)一個(gè)服務(wù)作為客戶(hù)端發(fā)起跨服務(wù)調(diào)用時(shí),會(huì)先獲取服務(wù)提供者列表,再通過(guò)某種負(fù)載均衡算法取得具體的服務(wù)提供者地址(IP + Port),即所謂的客戶(hù)端服務(wù)發(fā)現(xiàn)。在本地開(kāi)發(fā)環(huán)境中我們使用這種方式。
由于 OpenShift 天然就提供服務(wù)端服務(wù)發(fā)現(xiàn),即 Service 模塊,客戶(hù)端無(wú)需關(guān)注服務(wù)發(fā)現(xiàn)具體細(xì)節(jié),只需知道服務(wù)的域名就可以發(fā)起調(diào)用。
由于我們有 Node.js 應(yīng)用,在實(shí)現(xiàn) Eureka 的注冊(cè)和去注冊(cè)的過(guò)程中都遇到過(guò)一些問(wèn)題,不能達(dá)到生產(chǎn)級(jí)別。
所以決定直接使用 Service 方式替換掉 Eureka,也為以后采用 Service Mesh 做好鋪墊。
具體的做法是,配置環(huán)境變量:
- EUREKA_CLIENT_ENABLED=false,RIBBON_EUREKA_ENABLED=false
并將服務(wù)列表如:
- FOO_RIBBON_LISTOFSERVERS: '[http://foo:8080](http://foo:8080/)'
寫(xiě)進(jìn) ConfigMap 中,以 envFrom: configMapRef 方式獲取環(huán)境變量列表。
如果一個(gè)服務(wù)需要暴露到外部怎么辦,比如暴露前端的 HTML 文件或者服務(wù)端的 Gateway。
OpenShift 內(nèi)置的 HAProxy Router,相當(dāng)于 Kubernetes 的 Ingress,直接在 OpenShift 的 Web 界面里面就可以很方便的配置。
我們將前端的資源也作為一個(gè) Pod 并有對(duì)應(yīng)的 Service,當(dāng)請(qǐng)求進(jìn)入 HAProxy 符合規(guī)則就會(huì)轉(zhuǎn)發(fā)到 UI 所在的 Service。
Router 支持 A/B test 等功能,唯一的遺憾是還不支持 URL Rewrite。
對(duì)于需要 URL Rewrite 的場(chǎng)景怎么辦?那么就直接將 Nginx 也作為一個(gè)服務(wù),再做一層轉(zhuǎn)發(fā)。流程變成 Router → Nginx Pod → 具體提供服務(wù)的 Pod。
鏈路跟蹤
開(kāi)源的全鏈路跟蹤很多,比如 Spring Cloud Sleuth + Zipkin,國(guó)內(nèi)有美團(tuán)的 CAT 等等。
其目的就是當(dāng)一個(gè)請(qǐng)求經(jīng)過(guò)多個(gè)服務(wù)時(shí),可以通過(guò)一個(gè)固定值獲取整條請(qǐng)求鏈路的行為日志,基于此可以再進(jìn)行耗時(shí)分析等,衍生出一些性能診斷的功能。
不過(guò)對(duì)于我們而言,首要目的就是 Trouble Shooting,出了問(wèn)題需要快速定位異常出現(xiàn)在什么服務(wù),整個(gè)請(qǐng)求的鏈路是怎樣的。
為了讓解決方案輕量,我們?cè)谌罩局写蛴?RequestId 以及 TraceId 來(lái)標(biāo)記鏈路。
RequestId 在 Gateway 生成表示唯一一次請(qǐng)求,TraceId 相當(dāng)于二級(jí)路徑,一開(kāi)始與 RequestId 一樣,但進(jìn)入線程池或者消息隊(duì)列后,TraceId 會(huì)增加標(biāo)記來(lái)標(biāo)識(shí)唯一條路徑。
舉個(gè)例子,當(dāng)一次請(qǐng)求向 MQ 發(fā)送一個(gè)消息,那么這個(gè)消息可能會(huì)被多個(gè)消費(fèi)者消費(fèi),此時(shí)每個(gè)消費(fèi)線程都會(huì)自己生成一個(gè) TraceId 來(lái)標(biāo)記消費(fèi)鏈路。加入 TraceId 的目的就是為了避免只用 RequestId 過(guò)濾出太多日志。
實(shí)現(xiàn)上,通過(guò) ThreadLocal 存放 APIRequestContext 串聯(lián)單服務(wù)內(nèi)的所有調(diào)用。
當(dāng)跨服務(wù)調(diào)用時(shí),將 APIRequestContext 信息轉(zhuǎn)化為 HTTP Header,被調(diào)用方獲取到 HTTP Header 后再次構(gòu)建 APIRequestContext 放入 ThreadLocal,重復(fù)循環(huán)保證 RequestId 和 TraceId 不丟失即可。
如果進(jìn)入 MQ,那么 APIRequestContext 信息轉(zhuǎn)化為 Message Header 即可(基于 RabbitMQ 實(shí)現(xiàn))。
當(dāng)日志匯總到日志系統(tǒng)后,如果出現(xiàn)問(wèn)題,只需要捕獲發(fā)生異常的 RequestId 或是 TraceId 即可進(jìn)行問(wèn)題定位。
經(jīng)過(guò)一年來(lái)的使用,基本可以滿(mǎn)足絕大多數(shù) Trouble Shooting 的場(chǎng)景,一般半小時(shí)內(nèi)即可定位到具體業(yè)務(wù)。
容器監(jiān)控
容器化前監(jiān)控用的是 Telegraf 探針,容器化后用的是 Prometheus,直接安裝了 OpenShift 自帶的 cluster-monitoring-operator。
自帶的監(jiān)控項(xiàng)目已經(jīng)比較全面,包括 Node,Pod 資源的監(jiān)控,在新增 Node 后也會(huì)自動(dòng)添加進(jìn)來(lái)。
Java 項(xiàng)目也添加了 Prometheus 的監(jiān)控端點(diǎn),只是可惜 cluster-monitoring-operator 提供的配置是只讀的,后期將研究怎么將 Java 的 JVM 監(jiān)控這些整合進(jìn)來(lái)。
總結(jié)
開(kāi)源軟件是對(duì)中小團(tuán)隊(duì)的一種福音,無(wú)論是 Spring Cloud 還是 Kubernetes 都大大降低了團(tuán)隊(duì)在基礎(chǔ)設(shè)施建設(shè)上的時(shí)間成本。
當(dāng)然其中有更多的話題,比如服務(wù)升降級(jí),限流熔斷,分布式任務(wù)調(diào)度,灰度發(fā)布,功能開(kāi)關(guān)等等都需要更多時(shí)間來(lái)探討。
對(duì)于小團(tuán)隊(duì),要根據(jù)自身情況選擇微服務(wù)的技術(shù)方案,不可一味追新,適合自己的才是***的。