API実装に組み込みKVSを採用するまでの試行錯誤

AWSマイクロサービス

こんにちはGraat(グロース・アーキテクチャ&チームス)の鈴木雄介です。 本記事はグロースエクスパートナーズ アドベントカレンダー8日目の記事です。

エンタープライズ領域では、クラウドサービスを利用するような案件であっても、データはオンプレの基幹システムから連携されるものを利用するケースがあります。 こうした場合、基幹システム側の都合を吸収しながら、適切な設計を行う必要性があります。

本記事は、基幹システムとのデータ連携を前提としながら、どのようにシステム構成の検討を進めるのかについて、実際の案件を例に紹介します。なお、今回はアーキテクチャ設計の観点で整理していますので、個別製品の細かい情報は割愛します。

要件の整理

まず、システムの要件を整理します。 機能要件としては「リクエストされた商品コードに対して、その商品の価格や属性を返却する」というAPIです。つまり商品マスタをコードで検索して、その属性を返却する、というだけです。

商品件数は1億件。商品マスタの元データは基幹システムが送信する複数の商品情報ファイルであり、それらを合成する必要があります。基幹システムからの連携は夜間バッチに全件をファイルで受領する形になります。このためそれらのファイルを処理して合成しない限り、前日の差分はわかりません。

また、オンラインAPIへのリクエストに対して、返却する価格や属性情報は、ある特定の時間(例 8:00)で一斉に切り替える必要があります。夜間バッチで連携されるファイルは翌日付で有効になるデータのみが連携されてきます。つまり、当日8:00-翌8:00は前日夜間バッチで連携されたデータをもとに処理し、当日の夜に連携されたファイルの内容は裏でロードしておきながら、翌8:00にパチッと切り替えます。

次に非機能要件です。

  • オンラインAPIのターンアラウンドはネットワーク経路を除き10ms以下
  • オンラインAPIは24/365でSLOが99.95%(冗長化必須)
  • 立ち上げ初期は端末が非常に少ないためアクセスが1件/分程度だが、数年かけて数百/秒になっていく
  • なので、なるべく安くはじめたい
  • インフラはAWSのマネージドサービスとする。たとえばオンラインAPIはFargate(Docker)
  • 言語はJava

これらからシステムの特性を整理しておきます。これを読んで、どんな構成がいいか、ぜひ考えてみてください。

  • オンラインAPIはターンアラウンドへの要求が高いが、キーによるリードのみ
  • 元ネタは基幹システムから夜間に連携され、これらを合成し、大量(最大1億件)のデータを作る
  • 連携されるデータは「翌日分の全件」だけなので、現在利用している「本日分の全件」と一瞬で切り替える必要がある

RDBの検討

まず、考えるべきはオンラインAPIを意識したデータの配置です。特性で示したように「キーによる高速なリード」と「日中にはデータ更新が発生しない」ことを意識します。まずRDBは選択肢から外すべきだと考えました。RDBはSQLによる柔軟な検索機能と、データ整合性を維持するトランザクション機能が提供されています。並列でデータ更新があった場合でもデータ整合性を維持できますが、その分、処理速度に限界があります。そのため、今回のケースでは以下が課題となります。

  • ターンアラウンドで10-20msはかかってしまうため性能が達成できない
  • 夜間に1億件の更新処理でマシンパワーを使うが、そのリソースは日中には不要
  • 上記2点の解決はクラウド、リードレプリカ、RDBのメモリキャッシュなどで解決できるが、そこまでするコストメリットがない

つまり、RDBで実現できないことはないのですが、要件からすると過剰な機能になってしまい、結果的にコストに課題があるという結論です。

スクリーンショット 2020-12-07 14.14.27.png

メモリキャッシュの検討

RDBでないとすると、メモリキャッシュが候補となります。メモリキャッシュは高速なリード処理に向いており、ターンアラウンド10msも実現可能です。1つ目の候補は個別ノード内メモリキャッシュで、2つ目の候補はRedisなどの共有メモリキャッシュです。

今回は可用性が求められるため、オンラインAPIは複数ノードで構成されます。この場合、ノード間のデータの整合性を維持するためには共有メモリキャッシュが最優先の検討となります。そこでAWSのElastiCacheを利用してPoCを実施しました。実際にデータを生成し、それをキャッシュさせてみたところ40GBほどのメモリを使うことがわかりました。ファイルの状態ではそこまでのサイズではないのですが、キャッシュすることで思った以上のデータ量になりました。こうなると月額10万ぐらいはかかります。悩ましい。

そこで個別ノード内のメモリキャッシュも試すことにしました。データの整合性が課題ではありますが、商品情報をファイルの状態で共有し、それをノードの起動時にメモリキャッシュすればよいと考えました。データの切替えにはノードの再起動が必要ですが、2日分をキャッシュすることを前提に、切替え時間までにFargateのローリングアップデートを使えば無停止でデータの切替えることができます。まずは単純にJavaのHashMapを利用してみたところ、そもそも起動に時間がかかることと、Fargateのメモリ容量限界(30GB)を突破することがわかりました。工夫すれば30GB以下にできる可能性はあるものの、Fargateはメモリ容量をあげるためにCPU数を増やす必要性もあり、そのリソースが無駄になってしまいます。なにせ、最初は1vCPUでいいのですから。

結局、メモリキャッシュは高速ではありますが、相応にメモリを使いますし、そもそもメモリは高いのです。

スクリーンショット 2020-12-07 14.14.37.png

KeyValueストア(NoSQL)の検討

そもそも、キーによる検索なのであれば、メモリキャッシュと並行してKeyValueストア(NoSQL)も候補となります。AWSのマネージドサービスであればDynamoDBです。DynamoDBはスケーラビリティと性能には定評があるため、要件を満たすことができそうです。

ただ、こちらも問題はコストです。DynamoDBはデータのI/Oに対して課金されます。読み込みと書き込みそれぞれについて1秒あたりのデータサイズをユニットに換算して算出されます。そのため、今回のシステムのように毎日1億件のデータ更新があるような処理モデルだとコストが高くなってしまいます。また、DynamoDBは構造上、そのままではコネクションプーリングができないため、DAXというコネクションプーリング機能をオプションで用意する必要があります。

DynamoDBは、その技術に問題があるというよりも従量課金の考え方が今回のシステムには不一致でした。

組み込みKeyValueストアの検討

ここまで考えてきて、一番コストメリットがあるのは、やはり個別ノードでキャッシュする方式です。ただ、懸念はメモリサイズです。であれば、メモリを使わずにファイルシステム上で効率的にキャッシュすればいいと考えました。

たとえばElasticsearchのような検索エンジンをノード内に立てて、インデックスはファイルに持たせ、検索するのです。ただ、Elasticsearchも大袈裟なソリュションです。やりたいことはキーでの検索だけなので、柔軟な検索クエリは不要です。そこで、JavaVMで稼働し、組み込み用途で利用できるシンプルなKeyValueストアがないか、と考え始めました。また、Fargateのストレージ容量は最大20GBしかないため、ここに入るためにはデータファイルのサイズが小さいことも必要です。

というわけで「java embedded keyvalue」ググってみたところ、LevelDB(Java版)HaloDBMapDBなど、いくつかの候補があることがわかりました。LevelDBはGoogleがChromeブラウザのIndexedDB用に開発されたもので非常にシンプルかつ、データファイルの圧縮方式が優れています。Java版というのは、元々C++で組まれたものをはJavaにポーティングした製品です。LevelDB(Java版)を使ったPoCを実施したところ、ターンアラウンドの性能が問題ないこと、またデータファイルも20GB以下になることが確認できました。

組み込みKVSを個別ノードに搭載し処理することで性能とコストを両立できそうです。

最終構成

というわけで、最終的な構成です。

オンラインAPIはノード内のLevelDBへアクセスします。このLevelDBが読み込むデータファイルの作成には時間がかかるため、バッチ処理にオフロードしています。まず基幹システムから受信したファイルを合成して商品情報を作った上で、その商品情報をいったんLevelDBに投入し、データファイルを生成してS3に配置します。オンラインのAPIノードは、起動時にデータファイルをS3からダウンロードし、起動します。データファイルは、それなりの数になりますが並列ダウンロードすることで短時間での起動が可能になっています。また、簡易的なメモリキャッシュ機能もついているので、キャッシュヒットすれば検索処理を1ms以下で処理することができました。もちろん、Fargateのリソースも最適化できています。

ちなみにバッチ処理もFargateで動かしています。StepFunctionsからLambdaのタスクを起動し、そのタスクが処理終了後に落ちることでタスクが終了し、アドホックなバッチ処理を実現しています。また、データの合成処理はLambdaからAthenaを呼び出して処理しています。AthenaはS3をベースにした分散処理基盤で、SQLに似た構文を利用してデータの加工ができます。基幹システムからくる大容量ファイル(数GB程度)を操作するなら処理性能もコストも最高です。数GBのファイルのマージやソートであれば、多少複雑でも数分で完了し、1日1回夜間バッチで回すだけなら月間500円ぐらいです。

スクリーンショット 2020-12-07 14.14.59.png

いろいろと試行錯誤はあったのですがコストも含めて、目標としていた要件を満たすことができました。実は基幹システムにも同じような処理系はあるのですが圧倒的に安い構築コストと運用コストになっています。もちろん、基幹システムのほうが、より複雑な要件が実装されているため単純比較できることではありませんが、要件の整理さえできればクラウドシフトも実現できそうだ、という感覚まではつかむことができました。

まとめ

クラウドサービスは本当に多くの選択肢があり、ユースケースに応じて様々な可能性が考えられます。逆に言えば、今回の構成は、ある特定のユースケースを前提に設計されただけで、ちょっとでも違う要素があれば不適切かもしれません。「いつもの慣れた方式」「よくある方式」も重要ではありますが、機能要件/非機能要件を整理し、そこにあわせて可能性を探っていくことで、よりよいシステムが設計できるはずです。