irisuinwl’s diary

サークル不思議(略)入巣次元の、数学や技術的なことを書きます。

FirestoreのCRUD APIを作って、負荷試験をする

⛄⛄ この記事は Classi developers Advent Calendar 2021 12/21の記事です ⛄⛄

こんにちは、いりすです。

最近、バーチャルの肉体を得てYoutubeで配信したりVTuberっぽいことをしてます。 腰と足をトラッキングする用にkat loco sというデバイスを買ったりして、VRの感動を覚えてます。

f:id:irisuinwl:20211220045221p:plain
1か月前にやったVR筋トレ

さて、今回は、以前作成したGoogle CloudのFirestoreのCRUD APIに負荷テストをするアプリケーションについて解説し、負荷テスト結果をみていきます(VR関係ない笑)

source:

github.com

モチベーション

なぜこのアプリケーションを作ったか、です

最近Cloud SQLだけでなく、Documentを保存する場所として Firestore を利用する機会が多く、

使っていく上でFirestoreの性能をどの程度発揮できるか気になることが多く、いっそのこと様々なテストをできるようにアプリケーション一式実装して試してみようと思ったからです。

Firestoreってなに?

FirestoreGoogle CloudのNoSQL Documentデータベースです。

基本的にはjsonのような形式でデータを保存するDocument, Documentの集まりであるCollectionという構造があり、collection/document の階層構造にデータを保存していきます。

(documentにもサブコレクションが作れ collection/document/collection/document/... といった階層構造が可能です)

https://cloud.google.com/firestore/docs/data-model

事前準備

Google Cloudで新しいプロジェクトを作成し、firestoreを有効します。

Google Cloudの検索からfirestoreを検索し、firestoreのコンソールへ飛びます。

f:id:irisuinwl:20211221035409p:plain

最初firestoreを有効にしていない場合、以下の初期化画面が現れます。

f:id:irisuinwl:20211221035501p:plain

今回はネイティブモードを選択します。

firestoreのモードを選択すると下記のロケーション選択画面が出てくるのでasia-northeast1を選択します。

f:id:irisuinwl:20211221035609p:plain

以上で初期設定は終わりました。

アーキテクチャ

今回考えたアーキテクチャはFirestoreにCRUDを送るAPIをFlask+uWSGI+Nginxで作成し、負荷テストサービスとしてLocustを利用します。

f:id:irisuinwl:20211220050843p:plain

実装

下記にどのソースがどの実装に対応しているか記載します。

.
├── README.md
├── api # uWSGI+Flaskのソースやconfig
│   ├── Dockerfile
│   ├── requirements.txt
│   ├── src # Flaskのソース
│   │   ├── app.py
│   │   └── firestore_client.py
│   └── uwsgi.ini
├── docker-compose.yaml
├── locust # locustのソース
│   ├── Dockerfile
│   ├── requirements.txt
│   └── src
│       ├── __pycache__
│       │   └── locustfile.cpython-38.pyc
│       └── locustfile.py
└── nginx # nginxのコンフィグとdockerfile
     ├── Dockerfile
     └── nginx.conf

Flask

FlaskでFirestoreに単純なCRUDをするAPIを作ります。

github.com

APIのエンドポイントとしては、URIにFirestoreのルートコレクションとドキュメントIDを指定してCRUDを行うようにします。

pythonのfirestore clientとして公式の google-cloud-firestoreを用います。

下記に一例としてGet methodを例として上げます。

@app.route("/fs/<string:collection_id>/<string:doc_id>", methods=['GET'])
def fs_get(collection_id, doc_id):
    response = {'success': True}

    doc_ref = fs.collection(collection_id).document(doc_id)
    doc = doc_ref.get()
    if doc.exists:
        response = doc.to_dict()
    else:
        response = {}

    return json.dumps(response)

fs.collection(collection_id).document(doc_id)で参照したいドキュメントの場所を指定し、doc_ref.get()で指定参照からドキュメントを取得します。

Firestore Client

firestore clientはlocalの時はデバッグ用のmock, そうでなければ実際のfirestoreにつなぐようにします。 mockには mock-firestore を利用します。 これで、ローカル実行を容易にします。

def get_fs_client(config: str):
    if config == 'local':
        fs = MockFirestore()
    else:
        fs = firestore.Client()
    
    return fs


config = os.getenv('APP_CONFIG')
fs = get_fs_client(config)

Locust

locustで下記の負荷をかけるシナリオを書きます。

  • 指定のcollection_id/doc_idに document を Createする
  • 作成したdocumentをReadする
  • 作成したdocumentをUpdateする
  • 作成したdocumentをDeleteする

github.com

class QuickstartUser(FastHttpUser):
    wait_time = between(1, 2.5)

    def initialize_task(self):
        self.collection_id = uuid4()
        self.doc_id = uuid4()

    def get_fs(self):
        uri = f'/fs/{self.collection_id}/{self.doc_id}'
        self.client.get(uri, name='get_fs')
        time.sleep(0.01)

    def post_fs(self):
        uri = f'/fs/{self.collection_id}/{self.doc_id}'
        headers = {'content-type': 'application/json'}
        payload = {'key': 'value'}
        self.client.post(uri, json=payload, headers=headers, name='post_fs')
        time.sleep(0.01)

    def put_fs(self):
        uri = f'/fs/{self.collection_id}/{self.doc_id}'
        headers = {'content-type': 'application/json'}
        payload = {'key': 'updated'}
        self.client.put(uri, json=payload, headers=headers, name='put_fs')
        time.sleep(0.01)

    def delete_fs(self):
        uri = f'/fs/{self.collection_id}/{self.doc_id}'
        self.client.delete(uri, name='delete_fs')
        time.sleep(0.01)

    @task
    def normal_scenario(self):
        self.initialize_task()
        self.post_fs()
        self.get_fs()
        self.put_fs()
        self.delete_fs()

Userを継承することで負荷シナリオを実施するユーザーのクラスを定義できます。

そして、@taskでLocustで実行するタスクを定義できます。

今回はnormal_scenarioに負荷シナリオ実施前の初期化(collection_iddoc_idの初期化)してからCRUDを順に実行するシナリオを書きました。

このように、LocustではAPIにリクエストを送るシナリオをpythonで記載することができ、負荷テストを楽に実施できます。

実行

今回は問題設定を簡単にするためlocalのdocker-composeを使います

  • mockで実行
$ export DIR=${pwd}
$ docker-compose build
$ docker-compose up
  • mock以外で実行
    • GOOGLE_APPLICATION_CREDENTIALSgcloud auth loginした際に生成される認証情報を指定します。(この例だと ~/.config/gcloud/application_default_credentials.json)
    • PROJECT_ID はfirestoreのcrudしたいprojectを指定します(新しくプロジェクトを作成し、firestoreを有効にすることをお勧めします。)
$ export APP_CONFIG=develop
$ export PROJECT_ID=[Your_PROJECT_ID]
$ export GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/application_default_credentials.json 
$ export DIR=${pwd}
$ docker-compose build
$ docker-compose up

locustにアクセスしてテスト実行するにはhttp://localhost:9081にアクセスし、下記のように設定し、runすれば可能です。

f:id:irisuinwl:20211220060058p:plain

実行結果

1秒間に1user増えていき(spawn rate = 1)、100userが同時にAPIへリクエストする想定で、locustで負荷をかけます。

firestore mockの場合

まず、local上のfirestore mockでの実行結果です。

f:id:irisuinwl:20211220055512p:plain

f:id:irisuinwl:20211220055539p:plain

100user p99が1msという結果になりました

firestore-mockはlocal computer内のメモリ上にデータを入れており、外のネットワークにリクエストを送ってないので、この結果は妥当に思えます。

実firestoreにつないだ場合

続いてlocal上に立てたAPIで実際にfirestoreにつないだ場合の負荷です。

実際に、負荷をかける前にAPIが正しくfirestoreと接続されているか確かめるにはTotal users = 1, Spawn rate = 1で負荷をかけてfirestoreコンソールでデータが作成できていることを確認できれば良いです。

mockと同様に100userで同時にAPIへリクエストする想定で負荷をかけます。

f:id:irisuinwl:20211221040409p:plain

f:id:irisuinwl:20211221040434p:plain

結果としてCRUDのすべてのAPIにおいて100user p99がおよそ70msという結果になりました。

mockに比べるとネットワークを経由しているのでレイテンシは上がりますが、それでも70msと高いレイテンシとなってます。

ただ、今回は local API+firestoreという単純な構成なため、今回考えられなかった負荷テストで考慮する点を列挙します。

負荷テストする上で考慮する点

  • 負荷をかけてレイテンシが高い、レイテンシが低い、負荷テストのOK/NGを判断する基準をきめる
    • 無限にユーザーを増やせば無限にレイテンシを上がるので、どの程度の負荷に耐えうるかの基準やテスト設計が必要です。
    • 現在のユーザー数, RPSをログから見て通常時/ピーク時にどの程度か、今後ユーザーが増えた場合どの程度まで許容できるレイテンシとなるか
  • アプリケーションを構成するインフラストラクチャとネットワークを考慮する
    • 今回動かした環境はlocalですが、実際にアプリケーションを動かすのはCompute EngineやCloud Run, App Engine, Kubernetes Engineなど様々な選択肢を取れます
      • resourceのmachine spec, location
      • スケーリングしている場合のworker数
      • kubernetesを使っている場合サービス間通信のレイテンシ
    • 負荷を与える基盤もlocalなのか、computing resourceなのか、様々な選択を取れます。
    • Load Balancingを使う場合、ロードバランサーの設定は正しいか、負荷を分散できているかも考慮対象です
    • 実現したい機能に対して適切なStorageを選択、Storageの設計、Storageの接続なども性能に影響されます
  • 負荷をかけて問題個所を特定できるか
    • 負荷試験の結果、性能が良くないと分かった際に、どの部分を直せばいいかわかるか?
    • Google Cloudの場合、Operations を使うと容易
      • Logging : 適切にログが出せているか
      • Monitoring : 適切にシステムの指標を出せているか
      • Profiler : コードの各処理のCPU timeや経過時間を出力できているか
      • Trace : コード上の処理のトレーシングが出来ているか

ざっとあげると以上のようになります。

大体の観点は Google Cloudの資格である Professional Cloud Architect において、アプリケーションを設計する上で考慮する項目となっております。

自分は資格取得を通して上記観点を考えるきっかけとなりましたが、具体的な学習プロセスは以下の記事が参考になると思います:

tech.classi.jp

まとめ

  • firestore のCRUDをするAPIを作りました
  • Locustでmockの場合と実際にfirestoreにつなぐ場合に負荷をかけました
  • 今回は単純化した負荷テストなので、実際の負荷テスト観点を列挙しました

今回作ったAPIとLocustのシナリオは単純にCRUDするだけでしたが、今後は様々なコレクション階層、シナリオパターン、インフラまで含めて拡充していきたいです。

明日のClassi developers Advent Calendar 2021 はnakaearthさんです!


ばいばいー

f:id:irisuinwl:20211221050203j:plain

冬服ver, kawaii