本文介紹 GitHub 如何從單體架構遷移到微服務架構,并對其中一些最佳實踐做了詳細說明。
1旅程開啟
GitHub 建立于 2008 年,其宗旨是為開發人員托管和分享代碼提供便利。GitHub 的建立者也是開源貢獻者,他們在 Ruby 社群非常有影響力。正因為如此,GitHub 的架構深深地紮根于 Ruby on Rails。
在公司的整個發展曆程中,我們雇傭了世界上最好的 Ruby 開發人員,幫助我們擴充和優化代碼庫。如今,我們的平台上已經有超過 5000 萬名開發人員,每年有超過 8000 萬個 pull 請求合并,全球各大洲有超過 1 億個代碼存儲庫。
如你所見,這個單體架構已經帶我們走得很遠。一個演進了 12 年的代碼庫,每天要協調多次部署。我們有一個規模很大的平台,每天處理 10 億次 API 調用,我們還提供了一個高性能的使用者界面,專注于完成這項工作。
2内部快速增長
在過去 18 個月中,GitHub 内部經曆了快速增長。我們已經有超過 2000 名員工,為代碼庫做貢獻的工程師數量已經是以前的兩倍多。這種增長既包括自身的逐漸發展,也包括收購,如 Semmle、npm、Dependabot 和 Pull Panda。
此外,GitHub 是一個高度分散的團隊,在疫情發生前,我們就有超過 70% 的員工是在舊金山總部以外的地方辦公。GitHub 的員工和承包商要跨六大洲展開協作,他們工作的時區各不相同。我們有 1000 多名内部開發人員,他們有各種各樣的開發技能,涉及到許多不同的技術。
顯然,我們需要從根本上重新考慮下 GitHub 的軟體開發工作。讓每個人在參與開發之前都學習 Ruby,讓所有人都在同一個單體代碼庫上進行開發,不再是擴充 GitHub 最高效、最優化的方法。根據康威定律,任何組織設計的系統,其結構都是對組織溝通結構的複制。
反之亦然,單體架構會導緻更大規模的涉衆會議,更複雜的決策過程,因為交織的邏輯和共享的資料會影響所有團隊。
3單體 vs. 微服務
是以我們就想,是不是該從 Ruby on Rails 單體遷出,轉向一種微服務架構了?如果是這樣的話,我們該如何進行?單體架構和微服務架構各有所長。
在單體環境中,配置并運作應用程式更簡單,不用考慮複雜的依賴關系,拉取所有必要的依賴項。建立一個 Hubber,隻需幾個小時就可以在本機上配置好 GitHub 并運作起來。在單體架構中,代碼在有些情況下會更簡潔。例如,不用添加逾時處理邏輯,也不用考慮如何優雅地處理由網絡延遲和中斷所導緻的失敗。
此外,由于所有人都工作在同一個技術棧上,大家對代碼庫都很熟悉,是以可以友善地将開發人員和團隊調去開發單體的其他特性,有利于實作特性的全局最優。考慮到 GitHub 在過去 18 個月中的增長情況,微服務環境的一部分優點吸引了我們。
例如,建立具有系統級所有權的特性團隊,通過清晰定義的 API 契約确立職責邊界。在遵循 API 契約的前提下,團隊有充分的自由選擇最适合自己的技術棧。代碼庫更小意味着閱讀更容易、啟動速度更快、問題排查更簡單。開發人員不用為了提高生産力去了解一整個龐大的代碼庫的内部運作機制。最重要的是,服務現在可以根據各自的需求單獨擴充。
4務實——以賦能為出發點
在開始遷移 GitHub 之前,我們花了一些時間考慮為什麼要這樣做,以及這樣做的目标是什麼。對我們來說,這是文化上的巨大轉變,需要做大量的工作。我們得想好,到底要解決什麼問題和痛點。
在 GitHub,這樣做可以讓超過一半的開發人員(在過去的 18 個月中加入)在單體代碼庫之外富有成效地開展工作。我們的目标是賦能而非替代。
為此,我們得接受這樣一個現實,GitHub 未來的特性将基于一個單體 - 微服務混合的環境。也就是說,對于我們來說,維護和改進現有的單體代碼庫仍然很重要。有一個很好的例子是,我們最近更新到了 Ruby2.7。感興趣的話,可以從 GitHub 官方部落格上了解我們做了什麼,以及我們總體上如何改進系統。
5良好的架構始于子產品化
良好的架構始于子產品化。拆分單體的第一步是考慮基于特性功能分割代碼和資料。這個過程可以在真正在微服務環境中拆分之前在單體中完成。使代碼庫易于管理,通常都是一種良好的架構實踐。確定每個服務都有自己的資料,并且能夠控制對這些資料的通路,而且隻能通過明确定義的 API 契約通路。
我看到,在很多情況下,人們會首先抽出代碼邏輯,但仍然使用單體的共享資料庫。這往往會導緻分布式單體,這是最糟糕的單體,同時也是最糟糕的分布式。沒有獲得任何好處(比如,單獨快速地向生産環境中部署一組特性),卻還要應對微服務的複雜性。
6資料拆分
正确地拆分資料是從單體架構轉向微服務的基礎。這裡将稍微詳細地介紹下 GitHub 的做法。
首先,我們在現有的資料庫模式中識别功能邊界,并按照這些邊界将實際的資料庫表分組。例如,我們将所有存儲庫相關的表分到一起,所有使用者相關的分到一起,所有項目相關的分到一起。我們将生成的功能分組稱為模式域,并記錄在 YAML 定義檔案中。現在,這個檔案就成了事實來源。在資料庫模式中添加或删除表,都要更新這個檔案。我們通過一種靜态分析測試方法來提醒開發人員,在修改資料庫模式時,要更新這個檔案。
接下來,對于每個模式域,我們找了一個分區鍵。這是一個共享字段,将一個功能組中的所有資訊聯系在一起。例如,存儲庫模式域(其中包含所有與存儲庫相關的資料,如問題、pull 請求、評審意見)使用存儲庫 ID 作為分區鍵。最終,建立資料庫模式功能組幫助我們将資料拆分到微服務架構所需的不同伺服器和叢集上。
對于目前的跨域查詢,我們做了修複,以防資料拆分對産品造成破壞。在 GitHub,我們在單體中實作了一個查詢螢幕來幫助我們檢測,并在發現跨域查詢時發出告警資訊。我們會根據域邊界,把這些查詢拆分并重寫成多個,并在應用程式層實作必要的連接配接。在劃分完功能組後,我們開始通過一個類似的過程,進一步将資料分片到相應的租戶組。
GitHub 有超過 5000 萬使用者和 1 億個存儲庫,在這樣的規模下,功能組可能會變得非常大。這時,分區鍵就派上用場了。例如,一種簡單的方法是根據數值範圍将不同的使用者配置設定到不同的資料存儲。更常見的可能是根據每個資料集的特性(如區域和大小)所做的邏輯分組。Tenantizing 是一個很好的方法,可以将資料存儲故障的爆炸半徑限制在客戶的一個子集裡,而不是一下子影響到所有人。
7從核心服務和共享資源入手
我們已經花了很多時間讨論資料拆分的重要性。現在,我們換個話題,介紹下從單體中抽取服務的基礎工作。一定要記住,依賴方向隻能從單體内到單體外,不能反過來,否則,我們最終會得到一個分布式單體。也就是說,當從單體中抽取服務時,要從核心服務入手,然後逐漸到特性層面。
接下來,找出開發人員在單體環境中開發時所使用的助力工具。随着時間的推移建構一些共享工具以友善單體開發,這是很常見的。例如,我們的特性辨別,可以讓單體開發者安心地将新特性從測試環境轉到生産環境,因為在這個過程中,他們可以通過這個辨別控制誰能看到這些特性。将助力工具轉移出來,讓開發人員在單體之外也可以使用這些工具。
最後,在新服務上線運作後,務必要删除舊的代碼路徑。通過工具來識别誰在調用這個服務,并規劃好如何将流量全部導向新服務,這樣你就不用老是為兩套代碼提供支援了。在 GitHub,我們使用一個名為 Scientist 的工具幫我們處理這種上線,我們可以用它并排運作和比較新舊代碼路徑。
8AuthN/AuthZ 抽取
在 GitHub,我們決定首先抽取的核心服務是身份驗證和授權。身份驗證相當複雜,因為所有東西都依賴于它。網站和 Git 操作之間有一大堆的共享邏輯。也就是說,如果 github.com 宕掉了,那麼 Git 系統就無法通路了,即使是使用指令行視窗,也無法執行像 pull、push 這樣的 Git 操作。這就是為什麼把這些基礎部分抽取出來如此重要,那可以讓主要功能脫離單體而運作。
對于我們來說,身份驗證已經很簡單,因為我們已經在單體外部将它重寫為一個鏡像服務。目前的 Rails 應用程式(即我們的單體)使用 Twirp(這是一個 gRPC 風格的服務到服務通信架構)和它通信,依賴方向是由内到外。
9營運變化
監控、CI/CD、容器化都不是什麼新概念,但為了支援從單體到微服務的轉型,節省時間,加速向微服務的過渡,營運要做必要的改變。在修改這些工作流時,要時刻記着微服務的特性。與為一個大型單體運作單個高度定制化的管道相比,為衆多小型的、獨立運作的、基于不同技術棧的服務提供營運支援存在很大的差别。将監控從功能調用名額更新為網絡名額和契約接口。推動實作自動化程度更高、更可靠的 CI/CD 管道,并使其可以在服務之間共享。使用容器化技術支援各種語言和技術棧。建立工作流模闆以實作重用。
例如,在 GitHub,我們建立了一個自助服務運作時平台,可以用于微服務的打包傳遞。其目的是大幅減輕每個團隊建立微服務時的營運負擔。它提供了現成的 Kubernetes 模闆,可自由使用的 Ingress 負載均衡設定。它可以将日志自動提取到 Splunk,并內建了我們内部的部署流程。這樣,任何團隊想要試驗或上線一個新的微服務都會更容易。
10小處着手,考慮産品 / 業務價值
到目前為止,我們主要讨論的還是結構性變化,以及從單體成功過渡到微服務架構所需要的基礎工作。此後,任何新特性都應該建立成單體外的一個微服務。
下一步,找一些簡單的小特性從單體中遷移出來,例如,那些沒有複雜依賴和共享邏輯的特性。在 GitHub,我們是從 webhook 推送和文法高亮開始的。我們希望在遷移更多更大的單體功能之前,找出常見的模式和兩種架構之間的差别。我們是根據産品和業務價值來确定微服務的大小。
我們通過查找經常一起更改和部署的代碼和資料,來确定耦合度較高的特性或功能,并以此為基礎,自然地劃分成可以獨立于其他部分單獨疊代和部署的分組。此外,專注于産品和業務價值,還有助于組織内跨工程團隊、産品和設計開展緊密合作。請注意,拆分得太小往往會增加不必要的複雜度和開銷。例如,需要維護單獨的部署密鑰,更多的服務台職責,以及由于缺少知識共享而導緻的單點故障。
11實作異步性和彈性代碼
從單體轉向微服務是重大的模式轉變。在這個過程中,不管是軟體開發流程,還是實際的代碼庫,都會發生很大的變化。在最後一部分内容中,我們将快速了解下服務之間的通信以及失敗機制(designing for failure),這兩個都是微服務開發中非常重要的概念。
服務之間的通信方式有兩種:同步和異步。使用同步通信,用戶端在發送請求後會等待伺服器的響應。使用異步通信, 用戶端在發送請求後不會等待響應,每條消息都可以由多個接收者處理。在 GitHub,我們使用 Twirp 實作單體與單體外部核心服務(如授權)之間的同步通信。
然而,随着越來越多的服務移到單體之外,同步通信開始變得非常低效。而且,那還導緻了服務之間的緊耦合,背離了遷移到微服務架構的初衷。更好的做法是建立一個共享的事件管道,協調多個生産者和消費者之間的消息。在 SendGrid,我們使用的就是這種架構。
由于服務不再是運作在一台伺服器上,是以考慮網絡通信中的延遲和故障非常重要。對于大部分暫時的網絡問題,使用一種簡單的重試機制,定義好重試頻率和最大重試次數,就足夠了。可以考慮使用指數退避讓重試邏輯變得更加智能。例如,随着重試次數的增加延長等待時間,而不是間隔同樣的時間,進而緩解那些因為過載而無法響應的伺服器的壓力。作為一種自我保護和自愈機制,還可以在服務之間增加斷路器。例如,在多次嘗試失敗之後,斷路器會打開,在服務恢複之前,不再允許額外的請求進入。為服務設定逾時時間,這樣服務就不會一直等待外部服務的響應。設法實作優雅的失敗,可以向使用者展示友好的提示資訊,或者恢複到緩存中上一個已知的良好狀态。關注使用者體驗,做對企業有益的事。
12小結