🔥

スケール可能なKVSをCloudRunで自作して、Firestoreに勝負を挑んで計測した話

3秒まとめ

  • CloudRun + GCS製の自作KVSを作る方法を紹介
  • CloudRunのライフサイクルを理解することで、In-memoryのデータを永続化する
  • Firebaseより多少早いKVSができたが、Firestoreとあまり差が出なかった
    • 測定結果も合わせて公開

外部APIの結果をキャッシュして使いまわしたい

弊社プロダクトのPintを開発している際に、サーバーサイドで外部APIを叩いてその結果を利用したいケースが出てきました。

外部のAPIのため、 1回/1sec程度のレートリミットが存在しているほか、レスポンス自体も 1000ms以上かかるような状況でした。モバイル上で多用するケースが想定されたので、毎回ユーザーを 1sec も待たせるのはモバイルエンジニアとして許せる状況ではなく、結果をキャッシュしてせめて 100ms 程度で返したいと思い、改善することにしました。

今回利用する外部のAPIは、一般的なREST APIのため、返り値はネストされたJSONです。サーバー側の実装はGoで行っているので、構造を保ったまま保存できれば、 json.Marshall() で簡単に構造体へ変換できるため便利です。

したがって、SQLに複数のテーブルに分けて保存するのではなく、NoSQLを利用して保存しようと考えました。外部APIに対するリクエストをKey、レスポンスをValueとして捉えたKVSの機能を満たしていればOKです。

https://upstash.com/

個人プロジェクトであれば、サーバレスでフルマネージドなRedisを提供する Upstash を使いたいところですが、会社の依存するサービスを増やすことは必ずしも幸せなことではないため、今回は利用しません。また、GCPのRedisは結構値段が張るので、導入前の検証については他で行いたいと考えました。

KVSは、要するにGoで言えばmapを用意しておいて、KeyからValueを返せればOKなので、高分断性と高可用性を意識しつつ実際に自前のKVSを実装してみることにしました。とはいっても、CloudRunのメモリ上にデータを展開しておいて、それをただ返すだけの簡単なものです。ここにGCSを用いたおまけ程度の永続化処理をつけていきます。

現在プロジェクトで利用しているFirestoreも今回のケースに適したKVS的に使えるNoSQLなので、これを自作KVSと比較して、実際のパフォーマンスを測定し、プロダクトへの適応を考えていきます

CAP定理のAP型KVSを目指してKVSを自作する

https://amzn.to/3HkeGoU

システム設計の面接試験によると、CAP定理とは以下のように定義されています。

分散システムにおいて、一貫性(Consistency), 可用性(Availability), 高分断性(Partition Tolerance)を同時に保証するのは不可能とするもの

今回作るキャッシュ用KVSの場合、一貫性は犠牲になってもいいので、高速で、大量のリクエストが発生しても応答を返す可用性、システムの一部がダウンしても応答を返す分断耐性を持っていればOKです。

したがって、(カッコつけて)AP型のKVSを目指して実装を試みます。

ちなみに、これらを達成するためにGCPの各サービスをバリバリ使うので、APを実現するための実装はほぼ行いません。あくまで、「今後のスケーリングが可能」であることが重要で、今の段階で即時大スケールに対応できる必要はありません。

GCPを積極的に利用した自作KVSのアーキテクチャを考える

そもそもプロダクトにおいて、大量にKVSを叩くことになるかなんともいえない状況なので、アクセスに応じたオートスケーリングができるサービスが好ましいです。むしろアクセスがない時はSleepしていて欲しいところ。

ということで、CloudRunを使います。デプロイがとても簡単でGCPとの相性もバッチリのGoで実装を行います。

KVSなので、単純にmapを使って、In-memoryで特定のKeyからValueを返します。ただし、In-memoryだけではCloudRunのコンテナが終了するごとにデータが揮発してしまうため、永続化ももちろん必要です。永続化までをこの記事で扱います。

https://github.com/go-chi/chi

まずはデータの入り口と出口として、簡単なREST APIを実装します。今回は、Chiというフレームワークを利用しました。とてもシンプルに書けるのでお気に入りです。

ChiによるREST APIの実装
var m = map[string]interface{}{}

// readの例
r.Get("/read/{key}", func(w http.ResponseWriter, r *http.Request) {
		key := chi.URLParam(r, "key")

		if m[key] != nil {
			render.Status(r, http.StatusOK)
			render.JSON(w, r, map[string]interface{}{
				"status": "success",
				"value":  m[key],
			})
			return
		}

		render.Status(r, http.StatusNotFound)
		render.JSON(w, r, map[string]interface{}{
			"status": "not found",
			"value":  "null",
		})
	})

// writeの例
r.Post("/write", func(w http.ResponseWriter, r *http.Request) {
		params := map[string]interface{}{}

		decoder := json.NewDecoder(r.Body)
		if err := decoder.Decode(&params); err != nil {
			render.Status(r, http.StatusBadRequest)
			render.JSON(w, r, map[string]interface{}{
				"error": err.Error(),
			})
			return
		}
		defer r.Body.Close()

		key := params["key"].(string)
		value := params["value"]

		if key != "" && value != nil {
			m[key] = value
			render.Status(r, http.StatusOK)
			render.JSON(w, r, map[string]interface{}{
				"status": "success",
				"value":  value,
			})
			return
		}
		render.Status(r, http.StatusNotFound)
	})

これらをまとめると以下のような動作をするKVSができます

  • JSONをメモリ上にmapとして展開し、データベースとして扱う
  • RESTで受けたReadリクエストに入っているKeyに応じてValueを返却する
  • RESTで受けたWriteリクエストに入っているKeyとValueをmemoryに保存する

ここまで考えた上で、今度は永続化について検討していきます。

CloudRunコンテナの終了時にトリガされる永続化処理を実装するためには、CloudRunのライフサイクルを理解するのが手っ取り早いのでサクッと見ていきましょう。

CloudRunのライフサイクルを理解し、データを永続化

https://cloud.google.com/blog/ja/products/serverless/lifecycle-container-cloud-run

CloudRunのコンテナがアイドル状態 → シャットダウンされる際にはLinuxカーネル機能である SIGTERMシグナルをキャッチすることが可能です。このシグナルを受けたコンテナは、10秒間の猶予が与えられ、その後削除されます。

したがってSIGTERMシグナルをHandlingして、その間にGCSに保存するようなコードを書けばデートを永続化することができます。
ローカル実行時にも同様に終了シグナルSIGINTが送信されるため、どちらのシグナルを受けても永続化コードが実行されるように実装を試みます。

// シグナルをリッスンするチャンネルを作成
var signalChan chan (os.Signal) = make(chan os.Signal, 1)

// SIGINT シグナル: ローカルでCtrl+C を押した時に送信される
// SIGTERM シグナル: CloudRunのコンテナ終了時に送信される
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)

// シグナルを受け取る
sig := <-signalChan
fmt.Printf("%s signal caught", sig)

// 猶予は10秒
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

// その間にデータを永続化する
err := saveGcs(ctx)
if err != nil {
		fmt.Printf("failed to save GCS: %v\n", err)
}

上記のように実装すれば、GCSにデータを永続化することが可能です。

他方、データの読み込みは非常に簡単です。Goの init関数を使えば、脳死で読み込めます。

var m = map[string]interface{}{}

func init() {
	ctx := context.Background()
	d, err := readFromGcs(ctx, Bucket, Path)
	if err != nil {
		fmt.Printf("failed to load from GCS: %v\n", err)
	}
	m = d
}

これでGCSを利用してデータの永続化し、コンテナ起動時にデータを展開することができるシンプルなKVSができます。

このアーキテクチャの問題は、メモリに乗り切らないくらいデータが増えると、そもそもCloudRunコンテナが起動しないことや、CloudRunコンテナが複数個起動した際の一貫性が保証されないことです。

メモリ問題については、Keyによって対応するコンテナを分け、それらをルーティングするバランサを実装すれば解決できます。そのように実装すれば、一部Keyに対応するコンテナがクラッシュしていても、他のKeyに対するValueを返すことができます。CAP理論の通り、同時に一貫性を向上させることは難しそうです。

しかし、そこを作り込む前にまずはサクッと速度を計測してみましょう。

実用に足る速度であり、かつFirestoreに優越する理由がなければ、わざわざ自作KVSを使う意味はありません。

自作KVS(CloudRun)と Firestoreを比較したら、微妙にKVSの方が早かったが、Firestoreで十分かもしれない

結論から言うと、17.7 msほどKVSの方が速かったです。

https://www.thunderclient.com/

Avg 外部APIを100%としたとき
外部API 957 ms 100%
自作KVS(CloudRun) 94.3ms 9.8%
Firestore 112 ms 11.7%
  • リージョンはCloudRun、Firestoreともに asia-northeast1
  • CloudRunのコンテナのSpecはデフォルト
  • Firestoreは、 document id にKeyを指定して、Valueを各フィールドに格納、取得する場合は document id を直接指定するような方式として、CloudRunから叩く構成
  • ローカルから、KVSが載ったCloudRun, Firebaseを叩くCloudRunを叩いた時のレスポンスタイム
  • ローカルのクライアントはThunder Clientを利用
  • 外部APIは10回測定, それ以外は100回測定の平均のレスポンスタイム

CloudRun上のmemoryからデータを返せるので、KVSはめっちゃ早いと踏んでいたのですが、正直思ったよりは速度出ないな〜という印象。実際にプロダクトとして使うとしたら、バランサの実装も必要なので、その処理時間も加味するとFirestoreとあまり変わらなそうです。

もちろん、実装コストも高いです。

Firestoreは奮闘していました。100ms程度で返したいという要求を完全とは行かないものの、おおむねクリアしています。データの一貫性やスケーラビリティを保つ文脈でも、自作KVSよりFirestoreの方が優れていそうです。

この結果により、今回はFirestoreをキャッシュサーバーとして利用することにしました。

まとめ

CloudRunのライフサイクルを理解し、GCSと組み合わせることで簡単なKVSを実装することができました。

今後は、Keyに応じて担当するコンテナを切り替えるバランサを実装していくことで、スケールするKVSを作ることができるかもしれません。一方、実用に足るスペックにしていくためには、一貫性のなさをどの程度許容できるか決定し、それを保証する仕組みをちゃんと考える必要があります。

また、Firestoreは非常に優秀で、IDを指定した Read/Write はとても早いことが改めて認識できました。NoSQLな構造はAPIレスポンスを格納することにも向いています。

SQL的な使い方をしようとしてFirestoreが悪者になる現象があちこちで起こっている印象はありますが、向き不向きがあって当然。今回のようなケースでは実用に足ると感じました。

https://zenn.dev/kou_pg_0131/articles/thredot-introduction

最近では、@kokiさんが、N-gramという手法をつかってFirestoreで全文検索(爆速) を実装していました。技術の良いところをサクッと利用できるところに憧れます。僕もFirestoreのいいところは利用しつつ、苦手なところを別の技術でサクッとフォローできる技術者になりたいものです。

RedisをはじめとするManagedなKVSを利用できる場合は、Expireの設定など便利な機能の恩恵も受けられるためそちらを使う方がパフォーマンスも利便性も優れていることでしょう。

RedisやFirestoreのどちらが高価になるかはユースケースにもよりますが、アクセスが限定的である場合はFirestoreの無料枠でなんとかできる可能性も高く、検討の余地はあります。

おまけ

もう一月終わんのかよ!おじにはこの時の流れの速さがキツいぜ…

https://twitter.com/hagakun_yakuzai

株式会社マインディアでは、Flutterリードエンジニア、Goエンジニア、Railsエンジニアを募集しております。
カジュアル面談などの場を用意しておりますので、気軽にお声がけください。

引き続き、Flutter/Go/GCP周りの気になることを調査したり、記事にしていきますので、良かったらZennTwitterのフォローをお願いします!

株式会社マインディア テックブログ

Discussion