Stripeのモノレポ開発環境
原文は Nelson Elhage により に公開されました。 このブログを購読する
私は2012年から2019年まで、約7年間Stripeで働いていた。その間、Stripeの開発環境――エンジニアが日々コードを書き、テストするために使うツール群――の何世代にもわたって利用し、また貢献してきた。Stripeはその開発者体験の設計と構築をかなりうまくやっていたと思うし、退職してからも、その環境の機能について友人や同僚に何度も語ってきた。
この記事では、記憶している限りのその環境の主要な特徴を記録してみたい。また、そうした選択の背景にある文脈や制約、動機についても振り返ってみたい。いずれも文脈の中では良い選択だったと思うが、それらはビジネス面・技術面の状況に深く根ざしたものであり、他のチームではまた違った形が必要になるだろう。
断っておきたいことがいくつかある。すでに5年近くが経っており、全体像については確信を持っているものの、細部の一部は記憶違いをしているに違いない。また、Stripeはその後も進化を続けているはずで、この文書が今日のStripeにおける開発者体験を表していると主張するつもりはない。
加えて、ここで述べるコンポーネントの多くに私自身も貢献したが、全体の功績を主張するつもりはない。ここにあるものすべてが、何年もの歳月をかけて、ビジョンの策定から実装に至るまで、多くの優秀なエンジニアたちの貢献によって進化してきたものだ。
Stripeを取り巻く状況
私が語る当時、Stripeのコードベースの大部分はRubyで書かれ、単一の巨大なモノレポに置かれていた。このコードベースは多数のサービスを支えており、コードは広範に共有されていた。Stripeにはモノレポの外や他言語で書かれたサービスも数多く、しかも増え続けていたが、概括していえばそれらはビジネスの中核からはやや外れていた――とりわけStripe APIは、ほぼ完全にモノレポ内の単一のRubyサービスだった。ここで論じるツールや開発体験は、主にこのモノレポでの開発を支援するために設計・構築されたものだ。その他のサービスや言語への対応は、あっても付随的なものに過ぎなかった。
Stripeのツール群は、入れ替わりながら様々なチームや個人によって構築・保守されてきたが、本稿ではその作者や所有者をまとめて「developer productivity」チーム(略して「devprod」)と呼ぶことにする。これは私の在籍最後の数年間におけるそのチームの名称だ。
ツールへの投資
Stripeは、私が在籍して比較的早い時期に、社内ツールと生産性に専念するチームのヘッドカウントを設けた――それが後にこれらのツールを構築するチームになるのだが――そして在籍最後の3、4年でそのチームは大幅に拡大し、本格的に力を発揮するようになった。このチームには一貫して優秀なエンジニアが配置され、中には非常にシニアなICも含まれていた。これから述べる技術的な選択のいずれよりも、Stripeの開発者体験が成功した最大の要因は、このチームの存在そのものと、開発ツールの安定性と信頼性への投資だったと思う(チームが十分に大きくなり、目先の火消しに追われるだけでなく、計画し投資する余裕ができてからは特にそうだ)。技術的な選択やエンジニアリングは確かに重要だが、機能するためには十分な人員と継続的なメンテナンスによる支えが不可欠だ。
環境のこの信頼性と安定性は、特にチームとコードベースが成長し進化する中で、繰り返し出てくるテーマになる。世界最高のツールをもってしても、「環境のデバッグで定期的に1日を潰す」ような状況を埋め合わせるのは難しい。だからこそ、この点をきちんと押さえることは、他のどんな決定よりも容易に重要度で上回りうる。設計上の選択の多くは、devprodチームがツールをより容易かつ一貫してサポート・監視できるようにし、問題を個々のエンジニアに押し付けるのではなく、集中的にデバッグして解決できるようにすることを目的としてなされた。
開発環境のアーキテクチャ
開発中のコードはクラウドで動く
開発環境を特徴づける決定的な問いがある。開発中のコードは、開発者のラップトップ上でローカルに実行されるのか、それとも中央でプロビジョニングされた環境内のインスタンスやコンテナ上でリモート実行される必要があるのか、という問いだ。
長年にわたり、Stripeのエンジニアは開発者の好みや、その時々でどの環境で何が動くかという個別の事情に応じて、両方の選択肢を使い分けていた。developer productivityチームが単一の推奨環境に投資することを決めたとき、Stripeのクラウド環境(本番環境の外側)で開発者ごとに用意されるインスタンス(「devbox」)をサポートすることに落ち着いた。開発中、コードはdevbox上で実行された。minitestの実行においても、対話的なテストや実験においても同様だ。
これらの開発用インスタンスはStripeの標準的な構成管理ツールによってプロビジョニングされ、一時的なものだった――単一のコマンドで自分のインスタンスを破棄し、新しいものをプロビジョニングできた(ウォームスペアが多数用意されていたため、この操作は通常きわめて高速だった)。どのエンジニアがどのインスタンスをアクティブに使っているかはレジストリが追跡し、各種ツールがそれを参照した。すべてのdevboxはすべてのエンジニアからsshでアクセス可能で、これがコラボレーションや環境問題のデバッグを容易にした。
この設計により、環境設定に関する問題のほとんどは、ツールチームやサービスオーナーが集中的に解決できるようになり、開発者は自身の開発環境を追従させるためにアップデートすることに大半の場合悩まされずに済んだ。
新しい依存関係の追加
Stripeでは、APIや他のサービスのために依存関係が絶えず追加・作り替えられていた。例えば、APIにレート制限を実装するにはRedisクラスタが必要だった。開発コードがラップトップ上で動く世界では、これはすべてのラップトップにRedisをインストールし、場合によっては設定する必要があることを意味した。開発者用ラップトップの設定を更新するのは困難で、しばしばエンジニア間の口コミ頼みになっていた。「masterをpullしたら変なエラーが出るようになったんだけど、直し方わかる?」と誰かが言い、同僚が適切な設定コマンドを送る、といった具合だ。あるいは、問題を解決しようと、Redisをインストールするbrewコマンドが、開発者が定期的に実行する何らかのスクリプトにこっそり忍び込ませられることもあったが、これも脆く、時折ランダムなツールの動作を遅くする原因になった。
devboxモデルでは、Redisを追加するチームはもともと本番環境での設定に責任を持っており、devboxにも同様にインストールされ実行されるように、適切なPuppet設定を追加すればよかった。もしユーザーがこの新しいパスで問題に遭遇しても、そのチームやdevprodのメンバーが直接そのdevboxにsshして問題をデバッグし、PuppetやRubyのソースを直接更新して、他のユーザーに影響が及ばないようにすることができた。
私がStripeに在籍していた期間の大半を通じて、新機能やインフラ変更によって、何らかの形で常に新しい依存関係や既存の依存関係の設定変更が生じていた。devboxに標準化したことで、そうしたチームの仕事は楽になり、インフラ変更によって誰かの開発者体験が壊れる頻度も大幅に減った。どちらも計り知れない価値があった。
エディタとソース管理
開発中の新しいコードを実行する前に、そもそもソース管理を通じてコードにアクセスし、編集する必要がある。
Stripeでは、コード自体はクラウドで実行される一方で、gitのチェックアウトやエディタは開発者のラップトップ上にローカルに置かれていた。Stripeがこのアプローチに落ち着いた理由はいくつかある。
幅広いエディタやIDE環境のサポート
一部のエディタ(
emacsやvimのようなもの)はSSHセッション越しでも比較的快適に動作するし、VS Codeやtrampを使ったemacsのようにリモートファイルシステムに対してローカルUIで動作するものもあるが、そうでないものも多い。コードをラップトップに置くことで、Stripeは開発者が使いたいエディタをそのまま使い続けられるようにした。レイテンシ
Stripeには世界中に開発者がいたが、当時はまだ米国外に大規模なインフラを持っていなかった。200ms以上のネットワークレイテンシの向こう側でコードを編集するのは苦痛だ。devboxをグローバルに立ち上げるという選択肢もあったが、様々な理由で複雑になっただろう。
ソースコードの耐久性とファイルシステムのパフォーマンス
信頼できる情報源(source-of-truth)であるソースコードをラップトップ側に置き、devboxの外に保つことで、実行環境を一時的で使い捨て可能なものとして扱うのがはるかに容易になった。この性質は、環境を最新の状態に保ち、時間の経過によるドリフトを防ぐ上でも非常に役立った。
ソースコードをdevboxとは別のネットワーク経由でアクセス可能なストレージ(例えばEFSやEBSボリューム)に置くこともできたが、複雑さのコストを別にしても、それらの選択肢は比較的レイテンシが高く、ソースコードは少なくとも高速なファイルメタデータのルックアップに依存する傾向がある。
git操作をNFS越しやEBS上で行うと、並外れたチューニングと最適化なしには耐え難いほど遅くなりがちだ。
自動同期
とはいえ、コードをラップトップに置きつつクラウドで実行するとなると、新たな課題が生じる。編集されたコードを、いかにしてラップトップからdevboxへ届けるか、という問題だ。
私が入社する前から、Stripeにはファイルウォッチャーとrsyncを組み合わせた「sync」スクリプトがあった。ローカルのチェックアウトを監視し、変更をdevboxにコピーするものだ。以前は、開発者がこのスクリプトを別のターミナルやtmuxウィンドウで手動で起動し、アドホックに監視していた。
やがてdeveloper productivityチームがこのツールの所有権を引き継ぎ、多大な投資を行った。主たる目的は「磨き込み」を加え、他の開発者にとってシームレスでほぼ意識させないものにすることだった。
特筆すべきは、同期が暗黙のうちに、設定や介入なしで起きるようにしたことだ。ITチームと連携して、すべての開発者用ラップトップにsyncスクリプトをlaunchdサービスとしてインストールし、前述のレジストリを使って正しいdevboxを自動的に見つけ出せるようにした。
エラーやネットワーク問題に対する信頼性と自己修復にも多大な投資がなされた。一つの「内部的」だが重要な改善は、ファイル監視をwatchmanへ移行したことだ。私たちのテストでは、それは群を抜いて堅牢なファイルウォッチャーであり、更新の取りこぼしやウォッチャーが「スタック」する頻度を大幅に減らした。さらに、その高度な機能の一部も活用できるようになったが、それについては後で触れる。
全体として、これらの投資は概ね成功した。ほとんどの開発者はコード同期を単なる「あって当然のもの」として扱えるようになり、それについて考えたりデバッグしたりする必要はめったになくなった。
同期の一つの欠点は、「コードに対して作用するコード」、例えば自動マイグレーションツールや、リンター、コード生成ツールなどを書きにくくすることだ(Stripeは最終的に、様々な理由でチェックインする必要のある、コード生成されたコンポーネントをいくつかに依存するようになった)。これは私の在籍中、やや悩みの種であり続けた。私たちは次のような戦略を組み合わせてなんとかやりくりした。
- 開発者のラップトップ上で実行し、環境面の課題に対処する
- devbox上で実行し、生成されたファイルを何らかの方法で「sync back(同期して戻す)」する。スクリプトを「sync-back」ラッパーで実行し、ファイルをラップトップにコピーして戻すよう要求できる小さなプロトコルを用意していたが、ややぎこちなく使い勝手が悪く、時折信頼性にも欠けた。
devbox上のHTTPサービス
Stripeのコードの多くはHTTPサービスの中で実行されており、とりわけStripe APIがそうだった。developer productivityチームは、これらのサービスの開発を支援するためのツールを構築した。具体的には以下のようなものだ。
- DNSで
*.$username.stripe-dev.comのような形式のホスト名をそのユーザーの現在のdevboxに解決する - 各devbox上でフロントエンドサービスが動作し、SSLを終端し、クライアント証明書に基づく認証・認可(auth[nz])を処理し、サービス名を適切なローカルインスタンスにマッピングした
- Stripeの各サービスにはローカルポートが静的に割り当てられており、フロントエンドはそれを使ってトラフィックをルーティングした。
- 例えば、APIサービスにはポート3000が割り当てられ、フロントエンドは
api.nelhage.stripe-dev.comへのリクエストをlocalhost:3000に転送した。
- それらの各ポートでは別のプロキシサービスがリッスンしており、背後にあるRubyサービスをオンデマンドで起動した。
- したがって、
localhost:3000への最初のリクエストでAPIサービスが起動し、その後は後続のリクエストを直接処理するために起動したままになった。
- したがって、
- それらのサービスはdevbox上でStripeのautoloaderを使用しており、必要なときだけRubyコードをロードしたため、起動時間は高速だった。
- autoloaderはまた、どのソースファイルがどのサービスでロードされたかを追跡し、ファイルシステムの変更を監視した。ロード済みのファイルがディスク上で変更されると、それを使用しているサービスは自動的に再起動して変更を取り込んだ。
このインフラの正味の効果として、開発者はコードを変更した後、手動で再起動することなくほぼ即座に、固定された社内(Stripe内)で共有可能なURLで、自身の新しいコードを実行するサービスの複製にアクセスできた。このURLは、新しいdevboxをプロビジョニングした場合でも安定していた。さらに、この機能はほぼすべての内部サービスで動作したにもかかわらず、各devboxではその開発者が実際に使用しているサービスだけが実行されたため、CPUやメモリの負荷が抑えられた。
payコマンド
devprodはまた、devboxの様々な機能やワークフローへの統一的なアクセスを提供するpayというコマンドラインツールも構築・保守していた。
devboxへはssh経由でアクセスできたし、実際にそうされていた(pay sshとしてカプセル化されていた)が、payは最も一般的な使用パターンもラップしていた。
pay test ...はdevbox上でminitestのテストを実行し(内部ではsshを使用)、出力と終了ステータスをローカルにパイプして返した。pay curlはcurlをラップし、devbox上のサービスに対して手動でcurlコマンドを実行するヘルパーを提供した。これはAPIエンドポイントのアドホックな手動テストを行う際によく役立った。pay typecheckはSorbetをラップしてdevbox上でコードの型チェックを行った。
同期バリア
これらのツールが提供する利便性に加えて、もう一つの重要な機能があった。ソース同期プロセスとの統合だ。
ソースコードを扱うpayコマンドはsyncプロセスと通信し、リモートでコードを実行する前に同期が適切に「追いついた」ことを保証した。この機能は、同期の透過性にとって極めて重要な改善だった。以前は、ファイルを編集してから同期が完了する前にテストを開始し、結果として新しいコードをテストしているつもりで古いバージョンに対してテストを実行してしまう、というのがよくある落とし穴だった。こうなると変更が効かなかったと誤って信じ込み、無駄な調査に奔走して大きなフラストレーションを招くことがしばしばあった。
ワークフローをラップトップ側から開始することで、payのサブコマンドはエディタと同じファイルシステムの状態を見ていることが保証され、同期と協調することで、devbox側がその状態「以降」の状態を見ていることを保証できた。
こうした「同期バリア」に対する素直なアプローチでは、例えばpay testが新たな同期をトリガーする必要があり、何も変更していない場合でもワークフローに苛立たしいレイテンシが加わってしまう。watchmanのclock機能を活用することで、私たちはより良い方法を実現し、不要なラウンドトリップを避けることができた。エンジニアがファイルを編集してテストを実行するという一般的なケースでは、イベントの時系列は次のようになる。
- ユーザーがファイルを編集して保存する
pay syncがwatchmanによって起こされる。ファイルシステムのクロックを記録し、rsyncを開始する- ユーザーが
pay testコマンドを開始する pay testがpay syncに対して、同期が追いつくまで待つよう要求する- それに応答して、
pay syncはまずファイルシステムのクロックを再度確認する - 先ほどの保存以降ファイルシステムは変化していないため、このタイムスタンプは現在進行中の
rsyncに関連付けられたものと一致する pay syncは、pay testに通知する前に、すでに進行中のrsyncコマンドを待てばよいだけだとわかる
watchmanのclockを使うことで、これはイベントの順序入れ替えに対して堅牢になる。pay testの呼び出しが同期の前、最中、後のいずれに発生しても、正しい動作が得られる。
このpay syncの連携は、同期プロセスの健全性を可視化するための有用なフックにもなった。開発者のラップトップはあくまでラップトップであり、日常的にオフラインになったりオンラインに復帰したりする。したがって、同期の失敗や、同期が長時間遅延すること自体は正常なことかもしれず、syncスクリプトから有用なエラー統計を収集することは困難だ。しかし、ユーザーがdevbox上で動作するpayコマンドを実行した場合、それはユーザーが同期が健全であることを期待しているという良い証拠になる。したがって、同期プロセスへの同期バリア呼び出しが失敗したりタイムアウトしたりした場合、それはStripeの中央の例外トラッカーにエラーを報告するのに適切なタイミングとなる。その報告によって、devprodは同期システム全体の健全性とユーザー体験を監視できるようになった。
LSPとエディタツール
前述の通り、Stripeのエンジニアは好きなエディタを使うことができたが、2019年までにdeveloper productivityチームはVS Codeに投資するという選択をした。VS Codeを推奨エディタとして宣言し、特にその環境向けのツールに投資したのだ。
この取り組みの一部は、小さいながらも意味のあるQOL向上をもたらす内部プラグインで構成されており、例えばカーソル位置の特定のテストに対してpay testを実行するサポートなどが含まれた。
しかし、より大きな改善は、StripeのRuby用型チェッカーであるSorbetが成熟し、LSPサーバーの実装を備えるようになってからもたらされた(LSPはLanguage Server Protocolの略で、サーバーが「定義へ移動」やオートコンプリートといった言語対応機能を、様々なエディタから利用可能な形で提供するためのインターフェースを定義するものだ)。
Stripeでは、VS CodeがSorbet LSPサーバーをssh経由でdevbox上で実行し、ローカルのLSPサーバーと同様にstdinとstdoutで通信するように設定した。これにより、Sorbetはdevboxの潤沢なRAMを活用でき(LSPサーバーは一般に大規模なコードベースではメモリを大量に消費する傾向があり、Sorbetも例外ではなかった)、またSorbetチームが監視・デバッグ・テストしやすいLinux環境で動作させることができた。
全般的に、このアプローチはかなりうまく機能した。LSPサーバーは一般に多少のレイテンシを許容しなければならないため、追加のネットワークホップやファイル同期による遅延はほとんど問題にならなかった。例えば、LSPプロトコルは、ユーザーがエディタで変更を加えたがまだディスクに保存していないという(きわめて一般的な)ケースを扱えるように設計されており、エディタが関連する編集内容を直接サーバーに送信する。このプロセスは、結果としてファイル同期における多少のレイテンシに対する堅牢性も自動的にもたらした。
振り返り
私見では、ここで述べたツール群はなかなか優れており、よく機能し、Stripeの多くのエンジニアにとって効果的で生産的な開発体験を生み出していた。ここでは、ツール群を形作ったと思うStripeの組織や技術スタックの詳細についていくつか振り返りたい。これらは、別の組織で開発者向けツールに取り組む際に検討する価値のある領域だと思う。
組織の規模
ここで述べたツール群は、Stripeのエンジニアリングチームが数百人から千人以上へと成長した期間にわたって開発された。その間にdevprodは1FTE未満から十数名のチームへと成長した。ここで述べたツールの多くは、devprodが3名以上になった後者の規模に近い時期に構築されたものだ。
この規模――devprodの規模、そしてひいてはツールに10FTEを割けるだけの組織全体の規模――は、私たちの選択における大きな要因だった。私はかなり作り込まれたカスタムツールを数多く紹介してきたが、それらを構築し維持するのに十分なエンジニアと、その投資に見合うだけの十分な数の「顧客」となるエンジニアが必要だった。ここで改めてメンテナンスと信頼性を強調しておきたい。ラップトップからファイルを同期するためにファイルウォッチャーとrsyncを組み合わせるのは簡単だ――最初のバージョンは2012年までに存在し、初期投資は1日にも満たなかった。最終的な魔法は、そのようなツールが存在したこと自体ではなく、それが十分に信頼でき、成長やニーズの変化の中でも信頼性を保ち続けたため、ユーザーとして意識する必要がなかったことにある。
加えて、Stripeは急速に成長してもいた。急速に成長する組織では、「そのまま動く」開発環境を最初から用意し、新しいエンジニアのセットアップの手間を最小限に抑えることがはるかに重要になる。より静的な組織であれば、エンジニアはツールの癖や回避策を学べばよく、それはほぼ一度きりのコストで済む。急成長する組織では、そのコストは新しい採用者一人ひとりと、彼らを教育するエンジニアによって常に負担され続けるため、安定性とシームレスさへの投資対効果は高まる。
この性質は、「devbox」モデルを採用した動機の大きな部分を占めていた。ラップトップでのローカル開発にも利点はあるが、環境を集中的に管理したりユーザーのデバッグを支援したりするのがはるかに難しく、ほぼ必然的に個々の開発者により多くの専門知識と関与が求められる。中央でプロビジョニングされる一時的(ephemeral)なdevboxへ移行したことで、私たちはそのメンテナンスの大部分を集中化でき、「masterをpullしてdevboxを作り直してください」という包括的なアドバイスを一次デバッグ対応として推奨でき、それで大半の問題は解決した。
コードベース
前述の通り、このインフラは比較的一貫したアーキテクチャとパターンを持つ大規模なモノレポを支えていた。時間的にもコードベース全体にわたっても安定したターゲットがあったことで、developer productivityチームは環境やコードベースとかなり深く統合され、ユーザーフレンドリーな機能を提供する共有ツール(autoloaderやdevboxのサービスマネージャーなど)を作るというテコの効くポイントを得られた。言語やランタイム、パターンの種類がはるかに多様な環境では、どれか一つに特化することは理にかなわず、インフラチームはユーザーのコードをより不透明な箱として扱う、より汎用的なツールを構築する必要がある。
さらに、様々な歴史的、ビジネス的、技術的な理由から、Stripeのコードベースは多くの面でかなり密結合だった。developer productivityチームの私たちには、それが容易にも迅速にも分解されない(例えば多数のマイクロサービスへ分割されるような)ことが明らかだった。私たちはモノレポ内でのモジュール性や抽象化のためのツールやパターンにも継続的に投資したが、Rubyモノレポの構造全体が持つ「粘着性」への確信が、特化したツールへの大規模な投資を正当化してもいた。
付け加えておくと、Stripe社内でもこの選択はやや議論を呼び、全員一致というわけではなかった。コードやビジネス価値の大部分はRubyモノレポにあったものの、Stripeにはインフラコンポーネントや特殊なパイプラインの一部を担う他のコードベースも多数存在し、それらは一般にツールやインフラチームからのサポートが手薄で、繰り返し緊張の種になっていた。
モノレポがとりわけRubyで書かれていたという事実も、私たちの多くの決定の詳細に影響を与えた。例えば、ソースと成果物がより疎結合なコンパイル言語であれば、私たちは別の方向に進んでいたかもしれない。加えて、Stripeのモノレポは(私たちの知る限り)世界最大のRubyコードベースであり、それは私たちがしばしば自力でやらざるを得ず、既存のツールが何らかの形で私たちの規模を支えるのに苦労したことを意味する。
締めくくりに
エンジニアリング組織が成長する中で開発者の生産性を維持するのは難しい。組織やコードベースが成長するにつれて、エンジニア一人あたりの生産性がある程度低下することはほぼ避けられないが、その影響を定量化することはほぼ不可能でもある。
課題は組織のあらゆるレベルで生じ、技術的なものと同じくらい社会的・組織的なものでもある。私がすべての答えを持っているとか、Stripeがこの課題のあらゆる側面で最適な、あるいは「一貫して十分良い」解決策を見つけたと主張するつもりは毛頭ない。ただ、2019年頃までには、Stripeは開発者ツールに十分な投資を行い、多くの面で成長過程のより小規模な段階と比べて中央値的な開発者体験を改善できていたと思うし、本稿ではその体験を支えた基本的な選択とインフラを伝えようと試みた。
最後に、開発体験はもちろん物語の一部に過ぎない。コードや機能の完全なライフサイクルは、その先もCIやコードレビューへと続き、最終的には本番環境へのデプロイへと至り、そこでさらに観測され、デバッグされ、進化していく。それらのシステムについて書くには、少なくとも本稿と同じ長さの別記事がさらに必要になるだろう。
記事をランダムに読む
コメント
ログインしてコメントする