モダンBFFを活用した既存APIサーバーの再構築

技術部の青木峰郎です。 去年までは主にデータ分析システムの構築を担当していましたが、 最近はなぜかレシピサービスのサービス開発をやっています。 今日は、そのサービス開発をする過程で導入したBFF(Backends for Frontends)であるOrchaについて、 導入の動機と実装の詳細をお話しします。

Orcha導入にいたる経緯

まずはOrcha導入までの経緯、動機からお話ししましょう。

最初のきっかけは、わたしが去年から参加しているブックマークのようなサービスの開発プロジェクトでした。 このプロジェクトの実装のために新しいmicroserviceを追加することになったのですが、 そのときにいくつかの要望(制約)がありました。

1つめは、撤退するとなったときに、すぐに、きれいに撤退できること。

2つめが、スマホアプリからのAPI呼び出し回数はできるだけ増やしたくない、という要望です。

図1を見てください。 既存APIサーバーとは別に新しいmicroservice(API)を追加してスマホアプリから呼べば、 今回追加する部分はきれいに分かれていて実装も簡単です。 しかし、それではスマホアプリからのAPI呼び出し回数が増えてしまいます。

f:id:mineroaoki:20190621215345j:plain
図1: 単純にサービスを増やすとAPI呼び出し回数が増えてしまう

例えばクックパッドアプリのトップページは現在でもすでに10以上のAPIを呼んでいるので、 もうできるかぎりAPI呼び出し回数を増やしたくありません。

かと言って、既存APIサーバー(Pantry)の改修もしたくありません。 図2のように、Pantryから新サービスを叩くように変更すればAPI呼び出しを1つにまとめることはできます。 しかしこのPantryというサーバーは以前の記事で説明した「世界最大のモノリシックなRailsアプリケーション」であり、 理由はよくわからないがとにかくこれをさわるだけで開発期間が3倍になる優れモノです。 できることならいっさいPantryにさわることなく開発を終えたいわけです。

f:id:mineroaoki:20190621215427j:plain
図2: Pantryをいじれば目的は達成できるが対価が必要

つまり、API呼び出し回数は増やしたくないのでできれば既存のAPIに値を追加する形で実装したい。 しかしそのためにPantryはいじりたくない。

API呼び出し回数を増やしたくない……既存のAPIに手を加えたい……でもPantryはいじりたくない……。

この3つの思いが謎の悪魔合体を遂げて生まれたのがOrcha(オルカ)なのです。

Orcha 〜クックパッドのためのBFF〜

Orchaを導入した後のアーキテクチャを図3に示しました。 見てのようにOrchaはリバースプロキシと既存のAPIサーバーであるPantryの間にはさまって、 スマホアプリに特化したAPIを提供します。

f:id:mineroaoki:20190621215234j:plain
図3: Orchaのアーキテクチャ

今回は既存APIに新規サービスの情報を追加したいというのがそもそもの目的だったので、 まずOrchaがPantryのAPIを呼んで、レスポンスで得たJSONに新規サービスからの情報を差し込むことで目的を達成しています。 この場合のOrchaは「高機能なJSON用sed」のような働きをします。

OrchaはクックパッドのiOS/Androidアプリに特化したAPIを提供することを主眼としたシステムなので、 いわゆるBFF(Backends for Frontends)だとも言えます。 BFFとは、スマホアプリやウェブフロントエンドのような特定のクライアントに特化したAPIサーバーのことです。 汎用のAPIではなく、あるクライアントに密着した固有のAPIを提供することを目的にしています。 BFFについての詳細はこのあたりの記事をお読みください。

ちなみに、当初はBFFというよりオーケストレーション層を作るぞ! という気持ちのほうが強かったので、 "Orchestration Layer" の先頭を適当に切ってOrchaと命名しました。

すべてのAPIはカバーしない

Orchaはこのような経緯で導入したため、現在のところ、 スマホアプリが必要とするすべてのAPIを提供しているわけではありません。 スマホアプリのトップページのすべてのデータを返すトップページAPIなどの、スマホアプリに特化したAPIの一部のみを提供しています。 残りのAPIについては、現在もリバースプロキシからPantryへ直接リクエストを投げています。

そのような中途半端な入れかたをした1つめの理由は、 もし今回のプロジェクトがうまくいかなかったときは新規サービスをOrchaごと捨てて撤退する予定だったからです。 まるごと捨てるなら、必要最小限のAPIだけをOrchaにサーブさせておいたほうが、当然捨てるのも簡単です。

2つめは消極的な理由で、Orcha経由にするメリットが特にないからです。 既存のAPIをOrcha経由で呼ぶようにしたところで、単にレイテンシが数ミリ秒増えるだけで、たいしていいことがありません。 強いて言うとこの記事を書くときに「一部しか経由しませんよ」という説明をしなくて済むくらいでしょう。 それにもし将来メリットが発生してOrchaを経由するように変えようと思ったら、その時に変えればいいだけです。 したがって、当初は実装量がより少ないほうを選ぶことにしました。

Orchaの実装設計

Orchaの実装言語はしばし悩んだのちJavaに決めました。 Spring WebFluxとSpring Reactorを使って、非同期のリクエスト処理を実装しています。

JavaとSpringを選択した第一の理由はパフォーマンスです。 Pantryはやたらとリソース食いなので、 1 ECS task(サーバー台数とおおむね同じ意味)が3 CPUコア、メモリ4GBで、 毎日のピークタイムには150以上のECS taskが必要になっています。 これと同じ調子でリソースをバカ食いするサーバーをもう1つ立てるのはさすがに避けたいところです。

またレイテンシについても気を遣う必要があります。OrchaをPantryの前に立てるということは、 Orchaでかかったレイテンシーがそのまま既存のレイテンシーに追加されるということです。 Orchaのレイテンシーはできるだけ小さくしておかなければ、 スマホアプリの使い勝手を大きく悪化させてしまうことになるでしょう。 それを避けるには例えば、複数システムへのAPI呼び出しを並列化するなどの工夫をすべきです。

さらに、どのようなAPIを呼ぶことになるかは予測できないので、非常に遅いAPIもあるかもしれません。 そのような場合にもワーカーを使い果たして停止するようなことのないアーキテクチャを選択する必要があります。 ここまで来ると選択肢は非同期I/Oしかないでしょう。

非同期リクエストのフレームワークがあり、実行効率が高いとなると、定番はJVM系かGoです。 そこで結局、何度もJavaを利用した実績があったこと、 Java 8とJava 9での改善およびLombokの登場により言語仕様に目立った不満がなくなったこと、 さらに品質の高いAWS SDKやDBドライバがあること、の3点からJavaとSpringに落ち着きました。

なお正確に言うと、限定公開を始めた当初はリクエスト数も非常に少なかったため、 Spring WebMVCを使って同期リクエスト処理を実装しました。その後、全体公開することが決まった時点で、 API単位でSpring WebFluxに切り替えて非同期化していきました。 ここは同期・非同期のフレームワークが両方あり、しかも同居が可能なSpring Frameworkの利点が最大限に活きたところです。

認証処理の共通化

パフォーマンス向上という点ではOrcha導入にあたってもう1つ配慮したポイントがあります。 それは認証処理の共通化です。

図4はOrcha導入前のクックパッドアプリの認証経路です。 一言で言うとAuthCenterというシステムがすべての認証を請け負っており、 マイクロサービス各位はそれぞれ独立に認証を行うという仕組みです。

f:id:mineroaoki:20190621215506j:plain
図4: これまでの認証処理

これまではそれでも大きな問題はありませんでした。 なぜなら、基本的に1 APIは1システムによってハンドルされていたため、 APIリクエスト数と認証回数が等しかったからです。

しかしOrcha導入後は、1 APIリクエストにつき2回以上の認証処理が発生します。 つまり、最悪の場合はAuthCenterへのリクエスト数が突如として2倍以上になる可能性があるわけです(図5)。

f:id:mineroaoki:20190621215527j:plain
図5: 何も考えずにOrchaを導入したときの認証処理

AuthCenterは現時点でもすでに社内随一のリクエスト数を誇る人気サービスで、 ぶっちゃけた話DBがけっこうパツパツだったりするので、 いきなりリクエスト数が倍になれば陥落する可能性もあります。 それはいくらなんでもまずかろうということで、 Orchaの全体公開に合わせてID tokenを使った認証処理の共通化を実装しました(図6)。

f:id:mineroaoki:20190621215614j:plain
図6: ID tokenを使った認証の共通化

仕組みはこうです。 まず最初にリクエストを受けたOrchaはAuthCenterにアクセストークンを渡して検証してもらい、 認可などのためのメタデータを含むID tokenを受け取ります。 Orchaはアクセストークンの代わりに、AuthCenterから受け取ったID tokenを各サーバーに付与してリクエストします。

ID tokenは、JWTという形式のJSONを秘密鍵で署名したものです。 秘密鍵に対応する公開鍵は社内の全サービスに共有されているため、 そのトークンは間違いなくAuthCenterが発行したものであることが検証できます。 つまり各サービス内でその検証だけ行えば、 いちいちAuthCenterに問い合わせなくとも認証を完了することができるのです。

このへんはめんどくさかったのでわたしにはあまり知見がなかったので、 弊社の無敵万能エンジニア id:koba789 に仕様決めから全部ぶんなげて実装してもらいました。 そのうち id:koba789 が詳しいことを書いてくれると思います。

サービスメッシュを使った他システムとの連携

Orchaと他の上流システムとの通信は、 すべてクックパッドの標準的なサービスメッシュシステムを介して行いました。 サービスメッシュは特にBFFだから使うというものではありませんが、 個人的に今回いくつか利点を実感できたので述べたいと思います。

まずサービスメッシュでの自動リトライ機能について。 エンドポイントごとにタイムアウトを設定でき、タイムアウトした場合は自動的にリトライする、 それでもだめならしばらく通信を止める(サーキットブレーカー)という機能があり、これが非常に便利です。 最初はリトライなんていつ起きるんだよと疑っていたのですが、実際に試してみたら毎分起きていて認識を改めました。 また障害などで大量にエラーが発生したときにはサーキットブレーカーが働いて輻輳を防止してくれるので、 高いレベルで可用性を高めてくれます。

第二にクライアントサイドロードバランシングが容易に実装できる点。 Orchaには上流システムにgRPCのシステムがいくつかあるのですが、 普通のHTTP通信でも、クライアントサイドロードバランシングのgRPCでも、 こちら側の設定はほぼ同じ設定で通信できるようになるのでとても楽でした。

最後に、他システムとの通信のメトリクスが自動的に取得されて視覚化される点です(図7)。 これは正確に言えばサービスメッシュ自体の機能ではなく「サービスメッシュがあると容易に実装できる機能の1つ」です。 自システムで発生したエラーの数はもちろん、どの上流システムとの通信で500がいくつ出ているのか、 どのシステムとの通信が遅くなっているかも一目でわかるため、性能調査や障害調査に役立ちまくりでした。 他社のエンジニアにこの画面を見せると異常にうらやましがられる画面です。 この画面のためだけにでもサービスメッシュを実装する価値があると思います。

f:id:mineroaoki:20190621215640p:plain
図7: 他システムとの通信のモニター画面

Orcha導入後の評価

以上が、Orchaを入れた経緯とその設計などの詳細です。 これを踏まえて、現時点までの結果と評価を述べます。

まず、当初の目的であった 「API呼び出し回数を増やさずに、撤退しやすい仕組みで、新規サービスを高速に追加すること」は問題なく達成できたと思います。 Orchaと新規サービスを合わせて、インフラ構築からとりあえず動き出すまでをわたし1人だけで、1週間で完了できました。 これはPantryで開発をしていてはとても達成できない目標でした。 また、現在は新卒で入ったばかりのエンジニアにOrchaの開発をしてもらっているのですが、こちらもスムーズに開発できています。 これもPantryではありえないことです。

第二に、かなり真剣に考えたパフォーマンスについても、全体公開後の数値を見るかぎり問題なさそうです。 現在、ピークタイムでも全プロセスの合計リソースがCPU 1コア、メモリ8GBで余裕をもって全リクエストをさばけています。 もちろんECS(Docker)で動いていますし、オートスケールを設定してあるので、必要なときは勝手にECS task数が増減されます。 非同期処理に特有のつらい点として、「ものすごい勢いでメモリリークする」などの問題が全体公開直後に発生したりしましたが、 これも早期に解決できました(タイムアウト設定の問題でした)。

第三にJavaとSpringの選択についても満足しています。 Springについてはいろいろいい点はありましたが、 まずデフォルトでアプリケーション設定がファイル(application.yml)と環境変数で透過的に設定できる点が便利です。 開発環境ではいろいろと便利なデフォルトや設定例を提供したいのでapplication.ymlをレポジトリにコミットしておき、 本番環境ではDocker前提なのですべての設定を環境変数で設定する、ということが簡単にできるので大変便利でした。 また当然ながら設定項目はアノテーション一発でオブジェクトに自動マッピングしたうえDIで注入できます。

ちょっとした追加の機能実装をしたいときにほぼ間違いなくライブラリがある点も有利です。 例えば開発環境でだけ動く単純なリバースプロキシ機能を追加したくなったのですが、 Spring Cloud Gatewayを導入し、application.ymlを少し書くだけで簡単に実装できました。 このへんのライブラリの充実っぷりはさすがです。

総じて、アーキテクチャ・実装設計ともに現時点では満足しています。 次のチェックポイントは、スマホアプリのエンジニアがさわるようになったときでしょう。

これからのOrcha開発ロードマップ

最後に、今後のOrchaの開発ロードマップについて今考えていることを述べます。

直近の目標は、Orchaをより完全な集約層にすることです。 具体的には、既存のAPIサーバー(Pantry)に存在する、 実質的に集約層として機能しているAPIのコードをすべて剥がしてOrchaに移動することです。

集約層的なAPIは全体からすれば数は少ないですが、実装が複雑なので分量はけっこうあります。 このコード移動を完遂して、スマホアプリの開発者がOrchaをいじれるようになることが当面のゴールです。

また、集約層的なAPIの移動が完了すれば、 残るAPIはすべてリソースを処理するAPIになるはずなので、 そちらは小さいシステムに分割してgRPCにしてしまいたいところですが…… これが終わるにはあと何年かかるやら、という感じです。終わりが見えない。

まとめ

本稿では、クックパッドのレシピサービスに新たに追加したBFF "Orcha" に関して、 その動機と実装、評価をお話ししました。 今回、個人的に一番うまくやれたと思う点は、既存システムの改善と新機能の追加を両立できたことです。 通常、この2つは利益相反の関係にあることが多く、どちらを取るかジレンマに悩まされがちです。 しかしOrchaについては珍しいことに両者を同時に満たす一石二鳥の手を打てたので大変満足しています。

では最後にいつものやつです。

弊社は世界最大のモノリスを共に崩していく仲間を募集中です。 三度のメシよりRailsが大好きなかたも、 RailsアプリをJavaに書き換えてこの世から消滅させたいかたも、 あとついでに今回の話とは関係ないですがデータエンジニアも S3とSQSとLambdaでAWSピタゴラスイッチしたい人も、 ともに大募集しております。 興味を持たれたかたはぜひ以下のサイトよりご応募ください。