Stripe's monorepo developer environment

Nelson Elhage

Stripe 的 Monorepo 開發環境

原文由 Nelson Elhage 發布,訂閱此部落格

我在 Stripe 工作了約七年,從 2012 年到 2019 年。這段期間,我使用並參與打造了好幾代 Stripe 的開發環境——也就是工程師每天用來撰寫與測試程式碼的工具。我認為 Stripe 在設計與建構這套開發體驗上做得相當不錯,離開之後,我也發現自己一再向朋友與同事描述那套環境的各種功能。

這篇文章試著就我記憶所及,記錄下那套環境的重要特色。我也會試著反思這些選擇背後的脈絡、限制與動機;雖然我認為以當時的情境而言這些都是不錯的選擇,但它們深受業務與技術脈絡所影響,其他團隊很可能需要不同的做法。

先說明幾點但書:已經過了將近五年,我肯定記錯了一些細節,儘管我對整體樣貌仍有把握。我也確信 Stripe 在那之後仍持續演進,這份文件並不代表 Stripe 當今的開發體驗。

此外,雖然我參與了這裡描述的許多元件,但我無意、也不會宣稱擁有整體的功勞。這裡的一切都是多年來由眾多優秀的工程師共同演進而來,無論是在願景的形塑還是實際的落地實作上。

Stripe 的背景脈絡

在我所描述的時期,Stripe 的程式碼庫大多以 Ruby 撰寫,並集中在一個龐大的 monorepo 中。這個程式碼庫支撐著多個服務,彼此之間大量共用程式碼。Stripe 在 monorepo 之外、以及使用其他語言的服務數量也很多且持續成長,但概括來說,它們對核心業務的重要性較低——特別是 Stripe API 幾乎完全就是 monorepo 中的單一 Ruby 服務。我在此討論的工具與體驗,主要是為了支援在 monorepo 中的開發而設計與打造。其他服務與語言就算有支援,也只是順帶而已。

Stripe 的工具是由歷代不同的團隊與個人所建構與維護,但我會統稱為「開發者生產力」團隊(簡稱「devprod」),這是我任職最後幾年該團隊的名稱。

對工具的投入

Stripe 在我任職相當早期的時候,就設立了專門負責內部工具與生產力的編制——也就是後來打造這些工具的團隊——但在最後三、四年,團隊規模大幅成長並真正發展成熟。這個團隊長期由優秀的工程師組成,其中包含幾位非常資深的 IC。比起接下來要概述的任何技術選擇,我認為這個團隊的存在,以及它對開發工具穩定性可靠性的投入(稍晚一點,當團隊成長到足以先解決燃眉之急、並有餘裕進行規劃與投資之後),才是 Stripe 開發體驗得以成功的關鍵驅動力。技術選擇與工程實力絕對重要,但必須有足夠的人力以及充分的持續維護作為後盾,才能真正發揮作用。

這種環境的可靠性與穩定性,尤其是在團隊與程式碼庫持續成長與演變的過程中,會是一個反覆出現的主題。世界上再好的工具,也難以彌補「動不動就得花上一整天來除錯自己的開發環境」所帶來的傷害,因此把這件事做好,往往比其他任何決策都更重要。許多設計選擇的目的,就是為了讓 devprod 團隊能更輕鬆、更一致地支援與監控這些工具,並讓問題得以集中除錯與解決,而不是把負擔丟給個別工程師。

開發環境的架構

開發中的程式碼在雲端執行

關於開發環境,有一個決定性的問題:開發中的程式碼是在開發者的筆電本機上執行,還是必須在集中管理的環境中,於遠端的執行個體或容器上執行?

多年來,Stripe 工程師兩種方式都曾使用,取決於個人偏好,以及在不同時間點、不同環境下什麼方式可行、各種零碎的決策與細節。當開發者生產力團隊決定投入打造單一官方推薦的環境時,我們最終選擇支援每位開發者在 Stripe 雲端環境(位於正式環境之外)中擁有各自的執行個體(「devboxes」)。在開發過程中,程式碼都在 devbox 上執行,無論是執行 minitest 測試,或是進行互動式測試與實驗皆然。

這些開發用的執行個體由 Stripe 標準的組態管理工具來佈建,而且是暫時性的——只要一個指令就能銷毀你的執行個體並重新建立一個新的(系統會保留一些已預熱的備用執行個體,讓這個操作通常非常快速)。一個登錄服務會追蹤哪個執行個體正對應到哪位工程師,供工具取用。所有 devbox 都能透過 ssh 讓所有工程師存取,這讓協作與環境問題的除錯變得更容易。

這樣的設計意味著,大多數環境設定問題都能由工具團隊或服務擁有者集中解決,開發者大多不必擔心要自行更新開發環境才能跟上變化。

支援新的相依套件

Stripe 不斷為 API 或其他服務新增與調整相依套件;舉例來說,在 API 中實作速率限制就需要一個 Redis 叢集。在開發程式碼於筆電上執行的世界裡,這意味著每台筆電現在都需要安裝並可能設定好 Redis。在開發者的筆電上更新設定是一大挑戰,往往只能靠工程師之間口耳相傳:「嘿,我剛拉了 master,現在卻出現奇怪的錯誤——有人知道怎麼修嗎?」接著同事就會傳來適當的設定指令。另一種做法是把安裝 Redis 的 brew 指令偷偷塞進開發者會定期執行的某個隨機腳本裡,期望能解決問題,但這種做法也很脆弱,還會不時拖慢其他工具的執行。

採用 devbox 模式後,負責新增 Redis 的團隊本來就要負責在正式環境中設定它,他們也可以加入適當的 Puppet 設定,確保 Redis 同樣會在 devbox 上安裝並執行。萬一有使用者在這個新路徑上遇到問題,該團隊或 devprod 的成員就能直接 ssh 進入他的 devbox、除錯問題,然後直接更新 Puppet 或 Ruby 原始碼,避免問題影響到其他使用者。

在我任職於 Stripe 的大部分時間裡,新功能與基礎架構的變更持續不斷地帶來各種形式的新相依套件,或對既有相依套件的設定變更;統一採用 devbox 既減輕了這些團隊的工作負擔,也大幅降低了基礎架構變更破壞他人開發體驗的頻率,這兩點都極具價值。

編輯器與原始碼控管

要執行開發中的新程式碼之前,你得先透過原始碼控管取得程式碼並進行編輯。

在 Stripe,雖然程式碼執行於雲端,但 git 的工作目錄與編輯器仍保留在本機、也就是開發者的筆電上。Stripe 基於多項原因選擇了這種做法,包括:

  • 支援多樣化的編輯器與 IDE 環境

    有些編輯器(如 emacsvim)透過 SSH 連線也能跑得相當不錯,有些(如 VS Code 或搭配 tramp 的 emacs)支援以本機介面操作遠端檔案系統,但許多編輯器並不支援。把程式碼留在筆電上,Stripe 就能讓開發者繼續使用任何他們想用的編輯器。

  • 延遲

    Stripe 在全球各地都有開發者,但在當時尚未在美國以外維運大規模的基礎設施。在超過 200 毫秒網路延遲的另一端編輯程式碼是非常痛苦的。在全球建立 devbox 是一個選項,但基於諸多原因會相當複雜。

  • 原始碼的耐久性與檔案系統效能

    把原始碼的真實來源保留在筆電上、而非放在 devbox 上,讓執行環境更容易被視為暫時且可拋棄的。這個特性反過來對於保持環境為最新狀態、避免隨時間產生漂移非常有幫助。

    原始碼也可以存放在與 devbox 分離、可透過網路存取的儲存空間上(例如 EFS 或 EBS 磁碟區),但除了複雜度成本之外,這些選項的延遲相對較高,而原始碼的使用又相當依賴快速的檔案詮釋資料查詢;若沒有下足苦功進行調校與最佳化,在 NFS 或 EBS 上執行 git 操作往往會慢到令人痛苦。

自動同步

把程式碼留在筆電上、卻在雲端執行,帶來了一個新的挑戰:編輯過的程式碼要如何筆電傳到 devbox 上?

甚至在我加入之前,Stripe 就有一個「sync」腳本,把檔案監看器與 rsync 黏合在一起:它會監控你本機的工作目錄是否有變更,並複製到 devbox 上。過去,開發者會以臨時的方式,在另一個終端機或 tmux 視窗中手動啟動並監看這個腳本。

後來,開發者生產力團隊接手了這個工具並投入大量心力,主要目標是增加「精緻度」,讓它對其他開發者而言無縫且幾乎無感。

特別的是,他們讓同步在不需要任何設定或介入的情況下自動發生:他們與 IT 團隊合作,將同步腳本以 launchd 服務的形式安裝到每一台開發者筆電上,並利用前面提到的登錄服務自動找到正確的 devbox。

他們也在錯誤與網路問題的可靠性與自我修復上投入了大量心力。其中一項「內部」但重大的改進,是遷移到 watchman 來進行檔案監看:在我們的測試中,它是我們找到最穩健的檔案監看器,大幅降低了遺漏更新或檔案監看器「卡住」的頻率。我們也得以運用它的一些更進階功能,稍後會再提到。

整體而言,這些投入大致上是成功的:大多數開發者都能把程式碼同步視為理所當然的「日常」,很少需要去思考或除錯它。

同步的一個缺點是,它讓「用程式碼來處理程式碼」變得更困難,例如自動化遷移工具,或甚至只是 linter、程式碼產生工具(Stripe 最終依賴了少數因各種原因必須提交的程式碼產生元件)。這在我任職期間始終是一個小痛點;我們靠著混合策略來應對:

  • 在開發者筆電上執行,並處理隨之而來的環境挑戰
  • 在 devbox 上執行,然後再以某種方式「同步回來」產生的檔案。我們有一個小型的協定,可以在「同步回來」的包裝下執行腳本,讓它們能要求把檔案複製回筆電,但整體來說仍有些笨拙、不符合人體工學,且偶爾不可靠。

devbox 上的 HTTP 服務

Stripe 的許多程式碼是在 HTTP 服務中執行的,其中最具代表性的就是 Stripe API。開發者生產力團隊打造了相關工具來支援這些服務的開發,包括:

  • DNS 會將形如 *.$username.stripe-dev.com 的主機名稱解析到該使用者目前的 devbox
  • 每個 devbox 上都會執行一個前端服務,負責終止 SSL、根據用戶端憑證處理驗證與授權,並將服務名稱對應到適當的本機執行個體
    • Stripe 的每個服務都被靜態指派了一個本機通訊埠,由前端用來轉送流量。
    • 舉例來說,API 服務可能會被指派到通訊埠 3000,因此前端會將 api.nelhage.stripe-dev.com 轉送到 localhost:3000
  • 另一個代理服務會在這些通訊埠上監聽,並按需求啟動後端的 Ruby 服務。
    • 因此,當首次請求 localhost:3000 時,API 服務便會啟動,並持續執行以直接處理後續請求。
  • 這些服務在 devbox 上使用了 Stripe 的自動載入器,因此只會在需要時才載入 Ruby 程式碼,帶來快速的啟動時間。
  • 自動載入器也會追蹤哪些原始碼檔案被載入到哪個服務中,並監看檔案系統的變更;如果某個已載入的檔案在磁碟上發生變更,使用該檔案的任何服務都會自動重新啟動以套用變更。

這些基礎設施的整體效果是,開發者修改程式碼後,幾乎可以立即——無需任何手動重新啟動——透過一個固定、可在 Stripe 內部共享的 URL 存取執行其新程式碼的服務副本。即使他們重新建立了一個新的 devbox,這個 URL 依然保持穩定。此外,雖然這項功能適用於幾乎所有內部服務,每個 devbox 卻只會執行該開發者實際有在使用的服務,從而降低了 CPU 與記憶體的負載。

pay 指令

Devprod 也打造並維護了一個 pay 命令列工具,提供對各種 devbox 功能與工作流程的統一入口。

Devbox 本就可以透過 ssh 存取(封裝為 pay ssh),但 pay 也封裝了最常見的使用情境:

  • pay test ... 會在 devbox 上執行 minitest 測試(底層透過 ssh),並將輸出與結束狀態回傳到本機。
  • pay curl 封裝了 curl,提供一個幫手來對你 devbox 上的服務執行手動的 curl 指令,這在對 API 端點進行臨時的徒手測試時特別有用。
  • pay typecheck 封裝了 Sorbet,在 devbox 上對程式碼進行型別檢查

同步屏障

除了這些工具帶來的便利性之外,它們還提供了一項關鍵功能:與原始碼同步流程的整合。

pay 指令中會對原始碼進行操作的那些,會與同步流程溝通,並確保在遠端執行程式碼之前,同步已適當地「追上」進度。這項功能是同步透明化的一大關鍵改進:過去,常見的陷阱是在編輯檔案後、同步尚未完成前就開始執行測試,結果卻是在舊版本上跑測試,卻以為自己正在測試新程式碼。這往往會讓人誤以為修改沒有生效,進而導致白忙一場與極大的挫折感。

透過從筆電發起工作流程,pay 子指令能確保它們看到的檔案系統狀態與編輯器一致,並透過與同步機制的協作,確保 devbox 看到的狀態「至少等於或新於」該狀態。

這種「同步屏障」的直觀做法需要(例如)pay test 去觸發一次新的同步,這會為工作流程增加令人沮喪的延遲,即使你根本沒有改動任何檔案。透過運用 watchman 的 clock 功能,我們得以做得更好並避免不必要的往返。在工程師編輯檔案並執行測試的常見情境下,事件的時間軸會如下所示:

  • 使用者編輯並儲存檔案
  • pay syncwatchman 喚醒。它記錄下檔案系統的 clock,並啟動一次 rsync
  • 使用者啟動 pay test 指令
  • pay test 要求 pay sync 等待同步追上進度
  • 作為回應,pay sync 會再次檢查檔案系統的 clock
  • 由於檔案系統自先前儲存後並未改變,這個時間戳記與目前 rsync 相關聯的時間戳記相符
  • pay sync 現在知道它只需要等待已經在進行中的 rsync 指令完成,再通知 pay test 即可

使用 watchman 的 clock 讓這套機制在事件重新排序時仍能保持穩健;無論 pay test 的呼叫發生在同步之前、期間或之後,我們仍能得到正確的行為。

這種 pay sync 的協作也為同步流程的健康狀態提供了有用的可視性。開發者的筆電畢竟是筆電,本來就會不時離線又重新上線。因此,同步失敗,甚至長時間落後的狀況都可能是正常的,這讓我們難以從同步腳本本身有效地收集錯誤統計資料。然而,如果使用者執行了一個會在 devbox 上運作的 pay 指令,就代表有充分證據顯示使用者預期同步是健康的。因此,如果對同步流程的同步屏障呼叫失敗或逾時,那就是向 Stripe 集中式例外追蹤系統回報錯誤的適當時機。該回報反過來讓 devprod 得以監控同步系統與使用者體驗的整體健康狀況。

LSP 與編輯器工具

如上所述,Stripe 工程師可以使用任何他們選擇的編輯器,但到了 2019 年,開發者生產力團隊已決定投入資源支援 VS Code。他們將其定為首選編輯器,並特別為該環境投入工具開發。

其中一部分工作是一些雖小但能實質提升生活品質的內部外掛,例如支援對游標所在位置的特定測試執行 pay test

然而,更實質的改進發生在 Sorbet——Stripe 的 Ruby 型別檢查器——逐漸成熟並擁有 LSP 伺服器實作之後(LSP 即 Language Server Protocol,定義了一套讓伺服器以可被多種不同編輯器取用的方式,提供如「尋找定義」或自動完成等語言感知功能的介面)。

Stripe 將 VS Code 設定為透過 ssh 在 devbox 上執行 Sorbet LSP 伺服器,並像本機 LSP 伺服器一樣透過 stdin 與 stdout 進行通訊。這麼做讓 Sorbet 得以利用 devbox 龐大的記憶體(LSP 伺服器在大型程式碼庫上普遍都很耗記憶體,Sorbet 也不例外),並在一個對 Sorbet 團隊而言更容易監控、除錯與測試的 Linux 環境中執行。

整體而言,這個做法運作得相當不錯;LSP 伺服器本來就必須容忍一定程度的延遲,因此額外的網路跳轉與檔案同步帶來的延遲大多不成問題。舉例來說,LSP 協定被設計來處理(極為常見的)使用者已在編輯器中修改、但尚未儲存到磁碟的情況,因此會讓編輯器直接將相關編輯內容傳送給伺服器。這個過程實際上也自動對檔案同步的一些延遲提供了韌性。

反思

依我之見,這裡描述的工具相當出色、運作得相當順暢,並為 Stripe 的許多工程師創造了有效且高生產力的開發體驗。我想在此反思一下我認為形塑了這些工具的幾個 Stripe 組織與技術堆疊細節;如果你在其他組織從事開發者工具工作,這些都是值得考量的面向。

組織規模

這裡描述的工具,是在 Stripe 工程團隊從數百人成長到超過一千人的這段期間內開發出來的。在這段期間,devprod 從不到一人全職,成長到約十幾位工程師的團隊。這裡描述的許多工具,都是在該團隊達到三人以上規模的後期所打造的。

這個規模——devprod 的規模,以及整體組織大到足以負擔十個全職人力投入在工具上——是我們做出這些選擇的一大因素。我已經描述了許多相當複雜的客製化工具;我們需要足夠的工程師來建構並維護它們,也需要足夠多的「客戶」工程師來讓這項投資獲得回報。我要再次強調維護與可靠性:把檔案監看器和 rsync 兜在一起以從筆電同步檔案到遠端是很容易的——第一版在 2012 年就已存在,代表不到一天的初始投入。真正的神奇之處不在於這樣的工具存在與否,而在於它足夠可靠,並在成長與需求變化的過程中持續保持可靠,讓你身為使用者根本不必去想它

此外,Stripe 當時也正快速成長中。在一個快速成長的組織裡,擁有一個開箱即用、對新工程師幾乎沒有設定痛點的開發環境更為重要。在較穩定的組織中,工程師可以學會工具的怪癖並懂得如何繞過它們,而那大多是一次性的成本。但在快速成長的組織裡,這些成本會不斷由每一位新進人員以及負責培訓他們的工程師來承擔,因此穩定性與無縫體驗所帶來的回報就更高。

這個特性是「devbox」模式的一大動機。在筆電本機進行開發有其優勢,但要集中管理環境或協助使用者除錯要困難得多,因此幾乎無可避免地需要個別開發者投入更多專業與心力。改為採用集中佈建、可拋棄的 devbox,讓我們得以將大部分維護工作集中處理,並能把「拉取 master 並重建你的 devbox」作為第一線除錯建議,這個做法解決了大多數問題。

程式碼庫

我已提到,這套基礎設施支援的是一個架構與模式相對一致的大型 monorepo,無論是在時間跨度上還是程式碼庫的各個部分皆然。擁有這樣一個穩定的目標創造了槓桿點,讓開發者生產力團隊得以打造共享工具(如自動載入器與 devbox 服務管理器),這些工具與環境及程式碼庫深度整合,並提供使用者友善的功能。在一個包含更多樣化語言、執行環境與模式的環境中,為其中任何一個進行如此深入的特化就沒有意義,基礎設施團隊需要建構更通用的工具,把使用者程式碼更視為一個不透明的黑盒子。

此外,基於各種歷史、業務與技術原因,Stripe 的程式碼庫在許多方面耦合度相當高;對我們這些開發者生產力團隊的人來說,很明顯它不會輕易或迅速地被拆解(例如拆成更多的微服務)。我們確實持續投入工具與模式來提升 monorepo 內的模組化與抽象化,但對於 Ruby monorepo 整體結構的「黏性」所抱持的信心,也證明了我們在專用工具上的龐大投資是合理的。

我要指出的是,即使在 Stripe 內部,這個選擇在某種程度上也具爭議性,並非總是一致認同。儘管大部分程式碼與商業價值都位於 Ruby monorepo 中,Stripe 仍有大量其他程式碼庫在運行基礎設施元件或特定管線的某些部分;這些通常較少獲得工具與基礎設施團隊的支援,這也是一個反覆出現的緊張來源。

Monorepo 是以 Ruby 撰寫這一點,本身也具體形塑了我們的許多決策。舉例來說,一個編譯式語言、且原始碼與產物更為分離的環境,可能會把我們推向不同的方向。此外,Stripe 的 monorepo(就我們所知)是當時存在的最大 Ruby 程式碼庫,這意味著我們往往得靠自己,現有工具在一或多個方面都難以支撐我們的規模。

結語

隨著工程組織的成長,維持開發者生產力是困難的。即使幾乎無法量化這種效應,隨著組織與程式碼庫的成長,每人平均生產力在某種程度上下降幾乎是無可避免的。

挑戰出現在組織的各個層面,而且往往是社交與組織層面的,而非僅僅是技術層面。我絕不會聲稱自己擁有所有答案,或認為 Stripe 已為這項挑戰的各個面向找到了最佳、甚至「持續足夠好」的解決方案。然而,我確實認為到 2019 年左右,Stripe 已在開發者工具上投入足夠多,使得我們在許多方面改善了相較於許多較小成長階段時的中位數開發體驗,而我已試著在此傳達支撐這份體驗的基本選擇與基礎設施。

最後:開發體驗當然只是故事的一部分:程式碼與功能的完整生命週期會繼續延伸到 CI、程式碼審查,最終透過部署進入正式環境,在那裡它將被進一步觀察、除錯與演進。光是撰寫那些系統,就需要至少再寫幾篇同等篇幅的文章。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言