モノリシックなRubyからGoによるマイクロサービスへ

過去9年わたりWebアプリケーションを開発してきたNiket氏(@nexneo)は、2013年からGoを使って作業をするようになりました。この講演では、彼がどのようにRubyのモノリシックアプリケーションを分解しつつ、Goで記述されたマイクロサービスへと至ったかについて説明しています。講演のスライドは、speakerdeck.com/nexneo/joy-of-single-purpose-services-in-goで閲覧可能です。

Single purpose servicesというのは、単一の問題を解決するサービスのことです。一般的にマイクロサービスとしても知られています。

Niket氏は、学校側が親御さんたちと連絡したり成績表や出席を管理したりするための人気オンラインプラットフォーム、Beehivelyの開発者です。BeehivelyはRubyベースのアプリケーションで、その機能は年々拡充(例えば成績表やレポートの生成、そして通知など)されています。一方で機能セットが増えるに従ってコードも巨大化してきたため、開発チームは2013年にその対応策としてRubyだけで作られていたモノリシックコードベースからRubyとGoによるマイクロサービスアーキテクチャへと移行することを決めました。

Niket

Rubyは複雑な言語です。Matz氏(Rubyの開発者、まつもとゆきひろ氏)自身の言葉がそれを表しています。”Ruby is simple in appearance, but is very complex inside, just like our human body.(Rubyは一見シンプルですが、その実、非常に複雑です。私たち人間の体のようにね)”

Beehively立ち上げ当初においては、Rubyのパワフルさと複雑さは非常に有効でしたが、時間が経つにつれて言語固有の複雑さがBeehivelyのコードベースに悪影響を及ぼすようになってきました。アプリケーションが小規模の時点では、確かにRubyはすばらしい言語と言えるでしょう。しかし、ついにはコードベースの複雑化を抑制できる言語やアーキテクチャに切り替えようと決意する日がやってきたわけです。

ここで講演の内容をご紹介しておきます。

  • なぜマイクロサービスアーキテクチャへと移行したか
  • マイクロサービスにとってGoが最善と思われた理由は何か

Rubyには苦労がつきまとう

開発チームがRubyのモノリシックアーキテクチャからの移行を検討するようになった原因(苦労)は主に3つあります。

メモリ消費と速度:アプリケーションの多くの部分では、速度改善のためにデータがメモリにキャッシュされる仕組みでしたが、Rubyでは割り当てやGC(Garbage Collection:ゴミ集め)に多くの時間が掛かるため、結局キャッシュによるパフォーマンスの改善自体が否定される形となります。これを回避するため、開発チームは遅さを感じさせないようなUX(ユーザエクスペリエンス)の次善策を講じると共に処理能力の高い設備を導入しました。これには一定の効果がありましたが、最終的には限界に達することになります。

並行処理:Rubyでの並行処理を最も無難に行う方法は、マルチプロセスを用いることです。彼らのアプリケーションでは、それぞれが個別のプロセスで動くワーカが数多く必要なため、メモリ消費量が大幅に増加してしまいます。

下位互換性:Rubyの世界には、往々にして物を破壊へと導くような展開の速さがあるということを彼らは経験を通じて感じました。重要だと思われていたライブラリが明日には役に立たなくなるかもしれないし、フレームワークが他のフレームワークと統合されることだってあり得るのです。このような言語文化は、製品がまだ新しく、言語への依存度の低い場合には有効かと思います。解放的ですばらしいですからね。しかし、ある時点からは安定性が望ましくなってくるはずです。その観点においては、Goコミュニティの文化はより安定性を指向しており、互換性を反故にするような変化はないと言えるでしょう。

アーキテクチャ再設計の試み1:墓穴を掘る

開発チームが最初に検討したのが、より現代的なテクノロジスタックが利用でき、かつよりよいUXが提供できるようプロジェクト全体を書き直すことです。作業を始めて数カ月後、書き直したシステムの動作は申し分ありませんでした。しかし、機能に関しては以前のアプリケーションと比べると見劣りがしてしまいます。新しいコードベースの出来は上々だったものの、機能的に劣ったシステムへの移行を利用者に勧めるわけにもいきません。そんなわけで、せっかく優れた新システムがありながら、機能が従来のレベルに追いつくまでは使用できないというジレンマに陥ることになります。

マイクロサービス

マイクロサービスアーキテクチャへの移行が、この問題のソリューションでした。

マイクロサービスの基本理念自体は別に目新しいものではありません。1つのことに専念し、それをしっかりと実行する、というUnixの信念がその発端と言えるでしょう。Unixにおける単一目的のツールと同じように、シンプルなマイクロサービスを組み合わせ、より大きく完全なソリューションを形成していきます。

マイクロサービスアーキテクチャへの移行が意味するところはつまり、アプリケーション全体を一から書き直す代わりに、アプリケーションのコンポーネントをスタンドアローンのピースとして取り出せるようにするということです。開発チームはこのおかげで、以前のシステムと同等の機能を維持しつつ、コード全体の設計を改善することに成功しました。

3つのサービス、ケーススタディ

彼らがマイクロサービスへ移行するために取り出した3つのコアコンポーネントが、PDFジェネレータ、警告システム、そして成績算出機能です。

PDFサービス

彼らのアプリケーションのPDFコンポーネントは、グループレポーティング機能によりHTMLからPDFを生成します。その実装は本質的にシーケンシャル(連続的)なものであり、その原理自体はアプリケーションの他の部分と絡み合っているため、改善するのは容易ではありません。しかし、マイクロサービスとして取り出せば話は違ってきます。

彼らのPDFマイクロサービスは、PDFをパーツに分割し、それらを並行処理することでPDFの生成プロセスを並行化するThin Proxyです。GoのシンプルなWebサービスであり、PDF全体を処理する単一要求を受けた場合、複数のRubyボックスで処理するためにPDFを分割して転送します。処理された分割パーツは最終的に1つのPDFへと統合され、要求を出したクライアントの元へと提供される仕組みです。

PDF Service

マイクロサービスではAPIの設計が非常に重要です。たとえこれらのサービス対象が内部クライアントのみであったとしても、APIの設計を無視していいということにはなりません。上述した彼らのPDFサービスは明確に定義されており、一般的なAPIをも有していました。そのおかげで、現在では他のクライアントアプリケーションも同様にサポートしています。

PDF Service API

PDFサービスAPI

PDFサービスを取り出してGoで書き直した成果は以下の通りです。

  • レポート生成速度が12倍に増加
  • メモリ消費量が30%減少

警告サービス

アラート機能に求められるのは、できる限り迅速にメールを送り、電話をかけ、テキストメッセージを送ることです。また、予期せぬ時にロードのスパイクに対処できることが必要なので、こうしたスパイクを処理するために多くのワーカを並行で実行しなければなりません。Rubyで書かれていた以前のワーカは、それぞれが400メガバイト使用していました。彼らは30ほどのワーカを実行させていたので、約12ギガバイトのメモリが必要でした。

Alert Service API

警告サービスAPI

警告機能をGoのマイクロサービスにリファクタリングしたことにより、次の点が改善されました。

  • 同じ数のワーカ(Rubyでは12ギガバイト必要でした)に必要なメモリが、わずか5メガバイトになりました。
  • WebSocketにおけるリアルタイムの進捗管理をGoで実装する場合は、充実したライブラリのサポートがあるので実に簡単です。websocket packageをチェックしてみてください。
  • 警告機能は、もはや彼らのアプリケーションの重大なボトルネックではありません。かえって、インターネットサービスプロバイダの帯域制限が警告機能のボトルネックとなっています(それは良いことですね)。そのおかげで、彼らとしてはいくつのアラートが同時に動いているか、アプリケーションがロードを処理できるかといった心配をする必要はなくなりました。

警告サービスを構築するにはeasyreqの構築が必要でした。これはTwilioやMailgunを利用するための小さなクライアントライブラリで、警告の送信に使用われるものです。

以下にライブラリの使用例を紹介します。

成績表サービス

彼らのシステムの成績表機能を再構築する主な目的は、この機能をもっとインタラクティブなものにすることでした。クライアントサイドのJavaScriptフレームワークをいくつか試したものの、セキュリティとデータの保全性に懸念があるため、重要なロジックはサーバに置かなければなりません。他にも、既存のバックエンドモジュールをレポート生成のために活用したいという考えがありました。

彼らはこうした計算処理とWebSocket経由でクライアントに結果をストリームするといった要件を満たすため、Goを採用することに決めました。現在、これを扱っているバックエンドのマイクロサービスは成績表サービスです。成績表サービスの対象範囲は非常に限定的で、クラスの成績表とキャッシュに関係する計算だけを扱います。キャッシュは、メモリ消費を完全に管理できるLRU方式で、実際、キャッシュの無効化はほとんど見られません。成績表の計算はGoでは非常に高速で処理されます。マイクロサービスの中で最も処理が遅いのは、Rubyのアプリケーションからソースデータを取得する部分です。そのため、成績表の内部キャッシュは、今ではソースデータをキャッシュするだけで、もう計算をキャッシュする必要はありません(高速で処理されるので)。

成績表をGoのマイクロサービスに切り替えてから、ほとんどの計算処理は10倍にスピードアップし、場合によっては50倍にもなりました。

成績表サービスを記述する際によく利用したパッケージは、golang.org/x/net/contextです。与えられたHTTPリクエストのコンテキスト変数の状況を把握したり、現在のリクエスト/レスポンスサイクルに関連するgoroutineの実行処理をコントロールしたりする場合に、大変便利です。特に、新しいコンテキストを構築する場合はWithValue関数をしっかり確認してください。

すべてを統合する

リクエストをマイクロサービスまたはメインのRubyアプリケーションに送信するにはNginxを使っています。Redisは、メインのアプリケーションとマイクロサービスを統合するには有効なツールでした。Goのマイクロサービスは、RubyアプリケーションをAPIクライアント、またはアップストリームプロキシとして扱います。これによって認証が簡単になります。マイクロサービスとメインアプリケーションは互いに独立してスケールできます。

デプロイメント

デプロイメントが容易であるという点が、Goでマイクロサービスを記述することを選択したもう1つの重要な理由でした。マイクロサービスアーキテクチャに切り替えることを決めた際、当初は全てのサービスをRubyで記述したいと考えていました。しかしRailsのアプリケーションはデプロイメントが困難でした。さらに、システム管理の観点から見ると、1つのアプリケーションを複数のサービスに分割することは、単に問題を大きくしているだけのように思われたのです。Goのデプロイメントは非常にシンプルです。構築が用意で、rsyncで同期し、upstart経由で再起動します。これは、彼らの全てのマイクロサービスで問題なく動作しました。

彼らは最近、Amazonの新しいデプロイメントサービスであるCodeDeployに切り替えました。CodeDeployはわずかなドキュメンテーションしか提供していませんが、それだけにGoサービス用のデプロイメントのセットアップには、ほんの2時間程度しかかかりませんでした。何度も失敗しながら4日間もかかったRubyのアプリケーションの時とは対照的です。

重要ポイント

2013年に切り替えに着手して以来、Beehivelyは効果的に機能する新しいアーキテクチャを発見してきました。その経験から得た重要なポイントを以下に挙げます。

  • GoはWebサービスを記述する上で威力を発揮するシンプルな言語です。とりわけ、今回紹介したようなジョブに対しては優れたツールです。Go言語そのものだけではなく、周辺ツール、およびコミュニティの文化も含めてすばらしいと言えます。
  • 最小の労力で最大の利益を実現するために、全てをGoに切り替えて最初からアプリケーションを書き直すのではなく、対象のコンポーネントだけを取り出してGoのマイクロサービスに切り替えるとよいでしょう。これまでのところ、利用者が使っていたどの機能についても、以前の状態に戻さなければならない事態にはなっていません。
  • 新しいアーキテクチャにはマルチプルサービスのデプロイメントが必要ですが、Goであればデプロイメントは容易です。これはシステム管理の観点からすると、重要なことです。

もしあなたが現在、モノリシックアプリケーションに関わっていて、その複雑さが開発速度に悪影響を及ぼしかねない状況であるなら、Goを使ったマイクロサービスアーキテクチャへの切り替えを検討してみてはいかがでしょうか。