Parse在籍中に多くのブログ記事を書きましたが、Facebookが製品を終了させた後、すべてが消えてしまいました。そのほとんどは気にしていません(「サービス信頼性アップデート」が、ええ、たくさんありました)が、特定の1つの記事を失ったことには腹を立てていました。それは、Ruby on RailsからGolangへのAPI全体を2年にわたって苦闘しながら書き換えた、残忍で殺伐とした回顧録でした。

以前にも探したことがあると思ったのですが、今朝、移行に関する質問を受けたことで、Wayback Machineをもう一度開いてさらに深く掘り下げたところ、… ✨見つかりました!!✨

正直に言うと、この書き換えを行わなかったら、Honeycombは存在しなかった可能性があります。書き換えの初期段階では、Goで何かをリリースすると、世界が何度も何度も壊れました。私が述べたように、

RailsのHTTP処理は「受け入れるものは寛大に」という哲学に基づいて構築されています。そのため、開発者は文書化されていない、またはRFCに準拠していないAPIリクエストを誤って送信してしまうことがあります…しかし、Railsミドルウェアがそれをきれいに処理してくれます。

Railsはどんなゴミでも受け入れていましたが、Goはそうではありませんでした。破綻が続きました。テストでは、探すことを知らなかったものをキャッチできませんでした。最終的に、私たちは本番トラフィックを分割し、各リクエストをそれぞれ独自のMongoDBレプリカセットをバックエンドとするGo APIサーバーとRuby APIサーバーで実行し、レスポンスを差分するというワークフローにたどり着きました。この時、私たちはScubaが、個々のレスポンスをフィールドごとに、行ごとに比較する能力が非常に強力であることに初めて気づきました。

一度そのようなツールを使うと、虜になってしまいます…メトリクスや集計に戻ることは不可能です。残りは、ご存知の通り、歴史です。

全体として、10年経った今でも血と内臓の匂いがするような内容ですが、それでもかなり楽しい読み物です。どうぞ。

元々は2015年6月10日にblog.parse.comに投稿されました。

Parseの最初のコード行が書かれてから、ほぼ4年が経ちました。2011年、Parseはモバイルアプリ構築の問題を解決するための、ちょっとしたクレイジーなアイデアでした。

最初の数行はRuby on Railsで書かれました。

Rubyのおかげで、Parseの最初のバージョンを素早くリリースできました。小さなエンジニアチームが素早く反復処理を行い、機能を追加できました。YAMLパース、oauth、JSONパース、MongoDB、MySQLなどの処理を行うための、奥深いライブラリサポート、gem、デプロイツール、ベストプラクティスが利用可能だったため、多くの車輪の再発明をする必要がありませんでした。

UnicornをHTTPサーバーとして、Capistranoをコードデプロイに、RVMを環境管理に、そしてYAMLパース、oauth、JSONパース、MongoDB、MySQLなどの処理を行うために無数のオープンソースgemを使用しました。また、インフラストラクチャ管理のためにRubyベースのChefも使用したため、すべてがうまく連携していました。しばらくの間は。

最初のトラブル兆候は、デプロイプロセスで表面化しました。コードベースが大きくなるにつれて、デプロイに時間がかかるようになり、「グレースフル」なunicorn再起動は実際にはあまりグレースフルではありませんでした。そこで、Capistranoにローリングデプロイグループをモンキーパッチしました。

「モンキーパッチ」はすぐに、Rubyコードベースに関連付けることを学んだ主要な技術用語になりました。

1年半後の2012年末には、200台のAPIサーバーがm1.xlargeインスタンスタイプで動作し、1インスタンスあたり24のunicornワーカーが稼働していました。これは60,000のモバイルアプリに対して1秒あたり3000リクエストを処理するためでした。フルデプロイまたはロールバックには20分かかり、デプロイ中にAPIが影響を受けないようにするため、複雑なロードバランサのシャッフルと事前ウォーミングを行う必要がありました。

その後、Parseは本当に離陸し、ホッケースティック成長を経験し始めました。

APIトラフィックとアプリ数がより速く成長し始めると、新しいリクエストトラフィックを処理するために、より多くのデータベースマシンを急速にスピンアップしなければならなくなりました。その時、「リクエストごとに1プロセス」というRailsモデルの部分が崩れ始めました。

典型的なRuby on Railsセットアップでは、固定のワーカープロセスプールがあり、各ワーカーは一度に1つのリクエストしか処理できません。そのため、特に遅いタイプのリクエストがあると、ワーカープールがそのタイプのリクエストで急速に埋まってしまう可能性があります。これは、オートスケーリンググループが反応するよりも速く起こります。また、これらのワーカーの大半は他のサービスを待機しているだけなので、無駄でもあります。最初は、これはかなり稀にしか起こらず、人間を呼び出してAPIを稼働し続けるために必要なことを行うことで問題を管理できました。しかし、より速く成長し、より多くのデータベースとワーカーを追加し始めると、障害点とパフォーマンスが低下する原因が増えました。

Parseが10倍の規模になることを見据えて、「リクエストごとに1プロセス」モデルではスケールできないことに気づきました。Railsの方法とは根本的に異なる非同期モデルに移行する必要がありました。ええ、書き換えは難しく、予想よりも常に時間がかかることは承知していましたが、Railsコードベースを「リクエストごとに1プロセス」に縛ったままスケールさせる方法は見つかりませんでした。

非同期操作が必要であることはわかっていました。いくつかのオプションを検討しました:

プッシュ通知サービスの一部で既にEventMachineを使用していましたが、スケーリングも同様に問題がありました。意図せず同期動作や並列性バグを導入してしまうというトラブルが絶えませんでした。Ruby gemの大部分は非同期ではなく、多くのものがスレッドセーフではないため、一般的なタスクを非同期で行うライブラリを見つけるのは困難でした。

これは明白な解決策のように思えるかもしれません – 結局、Javaにはスレッドがあり、大量の並行処理を扱うことができます。さらに、すでにRubyなので、TwitterがScalaに落ち着く前に調査した解決策です。しかし、JRubyは基本的にRubyなので、非同期ライブラリサポートの問題は依然として残ります。後でJRubyからJavaへの2回目の書き換えが必要になることを懸念しました。そして、バックエンドチームやopsチームの誰も、JVMのデプロイとチューニングを扱いたがりませんでした。外宇宙からでもうめき声が聞こえるほどでした。

チームには経験豊富なC++開発者が多くいました。また、Cloud CodeサーバーでV8を埋め込んで実行するなど、スタックに既にC++をいくつか持っていました。しかし、C++は良い選択とは思えませんでした。私たちのC++コードはデバッグとメンテナンスがより困難でした。C++開発は一般的に、よりモダンな代替案よりも生産性が低いことが明らかでした。HTTPリクエスト処理など、私たちにとって重要であることがわかっているもののライブラリサポートが不足していました。非同期操作は可能でしたが、しばしば扱いにくいものでした。そして、誰も大量のC++コードを書きたがりませんでした。

C#は有力な候補でした。AsyncとAwaitで最高の並行処理モデルを持っていたと言えます。実際の問題は、Linux上でのC#開発が常に二流市民のように感じられたことでした。一般的なオープンソースツールと相互運用するライブラリはC#では利用できないことが多く、ツールチェーンを大幅に変更する必要がありました。

GoとC#はどちらも言語レベルで低レベルに非同期操作が組み込まれており、大勢の人々が非同期コードを簡単に書くことができます。MongoDB Goドライバーは、おそらく存在する中で最高のMongoDBドライバーであり、MongoDBとの複雑なやり取りはParseの核心です。Goroutineはスレッドよりもはるかに軽量でした。そして、正直に言うと、Goコードを書くことに最も興奮していました。Goコードを書くために優秀なエンジニアを採用するのは、他のどの堅実な非同期言語よりもはるかに簡単だと思いました。

最終的に、選択はC#とGoに絞られ、私たちはGoを選びました。

最初に、EventMachineプッシュバックエンドをRubyからGoに書き換えることから始めました。Goの並行処理で予備的なベンチマークを行い、各ネットワーク接続がわずか4kbのRAMしか消費しないことを発見しました。EventMachineプッシュバックエンドをGoに書き換えた後、カーネルチューニングなどの作業に手を加えることなく、ノードあたりの接続数を25万から150万に増やすことができました。さらに、とても楽しそうでした。そこで、Goに決めました。

他のマイナーなサービスもいくつか書き換え、新しいサービスをGoで構築し始めました。しかし、主な課題は、api.parse.comへのリクエストを処理するコアAPIサーバーを書き換えながら、シームレスに後方互換性を維持することでした。このエンドポイントをエンドポイントごとに書き換え、本番環境に影響を与えないようにライブシャドウイングシステムを使用し、動作が一致していることを確認するために差分メトリクスを監視しました。

この間、Parseはバックエンドのアプリ数を10倍にし、リクエストトラフィックを10倍以上にしました。また、Rubyがバックエンドを担うストレージシステムの数も10倍にしました。急速に動くターゲットを追いかけていました。

書き換えで最も困難だったのは、Railsミドルウェアで得られるすべての文書化されていない動作や魔法のような謎の部分に対処することでした。ParseはREST APIを公開しており、RailsのHTTP処理は「受け入れるものは寛大に」という哲学に基づいて構築されています。そのため、開発者は文書化されていない、またはRFCに準拠していないAPIリクエストを誤って送信してしまうことがあります…しかし、Railsミドルウェアがそれをきれいに処理してくれます。

そこで、Ruby APIからGo APIに多くの楽しい動作を移植し、Railsが処理していた奇妙なリクエストを引き続き処理できるようにする必要がありました。二重にエンコードされたURL、奇妙なcontent-length要件、本体を持つべきでないHTTPリクエストの本体、ひどいoauthの誤用、ひどく誤ってエンコードされたUnicodeなどです。

私たちのGoコードは今、以下のような楽しくて気難しいコメントでいっぱいです:

// Note: an unset cache version is treated by ruby as “”.
// Because of this, dirtying this isn’t as simple as deleting it – we need to
// actually set a new value.

// This byte sequence is what ruby expects.
// yes that’s a paren after the second 180, per ruby.

// Inserting and having an op is kinda weird: We already know
// state zero. But ruby supports it, so go does too.

// single geo query, don’t do anything. stupid and does not make sense
// but ruby does it. Changing this will break a lot of client tests.
// just be nice and fix it here.

// Ruby sets various defaults directly in the structure and expects them to appear in cache.
// For consistency, we’ll do the same thing.

書き換えは価値があったでしょうか? はい、非常に価値がありました。信頼性が1桁向上しました。さらに重要なのは、データベースやバックエンドサービスを増やしても、APIがますます脆弱になっていないことです。コードベースは整理され、魔法のようなgemや暗黙の前提の多くが取り除かれました。顧客全体でコテナンシー問題が改善されました。opsチームは、週に何度も呼び出されてRuby APIの障害を追跡し、手動で修復しようとするストレスから解放されました。そして言うまでもなく、顧客もより満足していました。

APIレイヤーに起因する信頼性に影響を与えるイベントは、今ではほとんど発生しません – これは1年前からの大きな変化です。今、タイムアウトやエラーが発生した場合、通常は単一のアプリに限定されます – 1つのアプリが非常に非効率なクエリを発行してタイムアウトやフルテーブルスキャンを引き起こしたり、自動的にリバランスしたり、問題のあるアクターをフィルタリングすることで解決できるデータベース関連のコテナンシー問題が発生したりするためです。

非同期モデルには他にも多くの利点がありました。APIが行っているすべての操作をカウンターやメトリクスで計測できるようになりました。これらは他のサービスとの通信を妨げるブロッキング操作ではなくなったためです。プロビジョニングされたAPIサーバープールを約90%削減できました。また、スタックから分離されたRails APIサーバーのサイロを取り除き、アーキテクチャを大幅に簡素化できました。

それだけではなく、完全な統合テストスイートの実行時間が25分から2分に短縮され、ローリング再起動を伴う完全なAPIサーバーデプロイの所要時間が30分から3分に短縮されました。go APIサーバーはグレースフルに再起動するため、ロードバランサのジャグリングや事前ウォーミングは必要ありません。

私たちはGoが大好きです。デプロイが本当に速く、計測が本当に簡単で、リソースの面で本当に軽量で安価であることがわかりました。ここに到達するまでには時間がかかりましたが、その旅はそれだけの価値がありました。

クレジット/非難は、Goの使用を最初に決定したShyam Jayaraman、APIサーバーの書き換えの大部分を最初から最後まで監督したIttai Golde、Goコードベースを支えるライブラリとインフラストラクチャを多数作成してオープンソース化したNaitik Shah、そして書き換えを実行した素晴らしいParseバックエンドSWEチームの残りのメンバーに帰せられます。

この投稿についてのディスカッション

もっと読む準備はできましたか?