🧪

コロナ予約サイトチャレンジ。1万TPSを体験しよう

2021/05/22に公開

はじめに

いろんな話題が出ているコロナ予約サイトですが、横浜市の予約サイトが公開すぐに落ちたことでまず話題になりました。
https://it.srad.jp/story/21/05/05/0354240/

ただ、最大34万人の予約者なので 1分あたり最大100万件のアクセスを想定していたが、開始直後に200万件のアクセスがあったということで33,000TPSというかなりのトラフィックが来た事が予想されます。

対応策がサーバを増やして目標値を当初設計の6倍に引き上げるとの事だったのですが、空席照会のついた予約システムってDBにある程度同期的に書き込む必要があるので、そんな簡単にスケールアウト出来ないはずです。
JSとかCSSとかも含めてるならさておき、メインのページなどのHTMLなどを含めたPVだと仮定してもDBに数千アクセスが行きますし参照だけではなく更新も入ります。どうやったのか本当に謎なんですが、特に工夫のないアプリ実装でどのくらいスケールするのか少し気になったので試してみました。

TL;DR

  • 500 TPSまではいけた。ただしDBが96 cores
  • チートしたら5000 TPSくらいまでいった。同時ユーザ数は3000.
  • 工夫すればそれなりに性能は出る感触だけど、工夫しないとやっぱりでなかった
  • あと、このレベルだと負荷を作るの自体が大変

検証構成

そもそもなのですが秒間1万TPSってローカルで出すのはかなり大変な認識です。ローカルマシンにアプリもロードジェネレータも両方置いたらダメなのはもちろんですが、そもそも8コアとかしか積んでないマシンではスレッド生成に限度があります。

というわけで、下記を参考にGKE Autopilotで分散ロードジェネレータをLocustを使って作ります。
https://cloud.google.com/architecture/distributed-load-testing-using-gke?hl=ja

テストシナリオは「予約表示 -> 予約 -> 予約表示」くらいでしょうか。名前と日付はランダム生成です。

class UserBehavior(TaskSet):
    @task(3)
    def show(self):
        res = self.client.get("/book")

    @task(1)
    def book(self):
        print(self.get_date())
        res = self.client.post("/book", json={"name": self.get_user(), "date":self.get_date()})

実際のスクリプト及びk8sの構成ファイルは下記となります。
https://github.com/koduki/vaccine-booking-simulator/tree/main/distributed-load-testing

そして予約サイト相当のアプリケーションはRDBを使うシンプルなもので行きます。機能は以下。UIは面倒なのでAPIのみです。

  • 6月1日から30日の予約システム
  • 空席照会ができる
  • 予約が空いていれば予約に成功。各日付とも閾値(10,000)を超えた場合は予約失敗

利用したアプリのコードは下記。特別工夫はしてないとても素朴な実装ですが、とりあえず分かりやすくUpdate文で件数を更新してロックで死ぬ、とかにはさすがになってないつもりです。
https://github.com/koduki/vaccine-booking-simulator/tree/main/app

環境構築

予約シミュレーションのデプロイ

まず、アプリケーションをCloud Runにデプロイします。
最初に今回はRDBを使うのでCloud SQLでPostgreSQLを立ち上げます。

とりあえず必要なパラメータを環境変数として宣言します。別に値を直接入れても構いません。また、コードもチェックアウトしておきます。

PROJECT=$(gcloud config get-value project)
REGION=us-central1
ZONE=${REGION}-b
CLUSTER=load-test-cluster
DB_PASS=abcd12345

git clone https://github.com/koduki/vaccine-booking-simulator.git

つづいてCloud SQLの起動。とりあえず2 CPU/7.5 GBあたり作ります。動作確認だけならティアはdb-g1-smallでも良いと思います。

gcloud sql instances create pginstance --database-version=POSTGRES_12 --tier=db-custom-2-7680 --region=$REGION
gcloud sql users set-password postgres --host=% --instance pginstance --password $DB_PASS

つぎにアプリケーションのビルドとデプロイをします。

cd app
./mvnw package && ./mvnw package -Dquarkus.package.type=uber-jar
docker build -t gcr.io/${PROJECT}/vaccine-booking-simulator -f src/main/docker/Dockerfile.jvm . 
docker push gcr.io/${PROJECT}/vaccine-booking-simulator
gcloud run deploy vaccine-booking-simulator \
  --image gcr.io/${PROJECT}/vaccine-booking-simulator \
  --region ${REGION} \
  --platform managed \
  --allow-unauthenticated \
  --add-cloudsql-instances ${PROJECT}:${REGION}:pginstance \
  --update-env-vars INSTANCE_CONNECTION_NAME="${PROJECT}:${REGION}:pginstance"
gcloud run services update vaccine-booking-simulator --region us-central1 --platform managed --update-env-vars QUARKUS_DATASOURCE_JDBC_URL="jdbc:postgresql:///exampledb?ipTypes=PUBLIC&cloudSqlInstance=${PROJRCT}:${REGION}:pginstance&socketFactory=com.google.cloud.sql.postgres.SocketFactory
",QUARKUS_DATASOURCE_USERNAME=postgres,QUARKUS_DATASOURCE_PASSWORD=${DB_PASSWORD}

Cloud RunでCloud SQLを使う場合の接続方法は下記を参考にしてください。また、Javaの場合はpostgres-socket-factoryが必要なのでこちらもMavenリポジトリから落とす必要があります。
https://cloud.google.com/sql/docs/mysql/connect-run
https://mvnrepository.com/artifact/com.google.cloud.sql/postgres-socket-factory

ロードジェネレータのクラスタの作成

ロージェネレータの基盤にはGKE Autopilotを使います。こちらはk8sを使いながらも自分でノードを管理しなくても良いという優れもの。Pod数をしていするとGCP側で管理するノードに適切にデプロイしてくれます。Podを増やせばノードも増えるし、減らせば減ります。課金はPod数のみに対して行われるのでノードのマシンスペック等を気にする必要はありません! なお、VPCは自分のものを利用するのでNW的には通常のk8sのように扱えます。

この環境を使う事でお金の許す限りフレキシブルに負荷を増やすことが出来ます。

まずk8sのAPIを有効にし、ディレクトリを移動します。

gcloud services enable container.googleapis.com
cd distributed-load-testing

クラスタを作成します。特にノードの作成とかは要らないので簡単ですね。

gcloud container clusters create-auto $CLUSTER --create-subnetwork name=gke --region $REGION 
gcloud container clusters get-credentials $CLUSTER --region $REGION --project $PROJECT

次に負荷を生成するLocustイメージを作成します。これは公式のlocustio/locustをベースに実行するテストタスクなどを組込んだものです。

docker build -t gcr.io/${PROJECT}/locust-tasks load-tasks
docker push gcr.io/${PROJECT}/locust-tasks

さてロードジェネレータのデプロイですがまずYAMLファイルを変更します。kubernetes-config/*.yamlimageTARGET_HOSTです。TARGET_HOSTに入れるURLは先ほどデプロイしたCloud Runのものを入れてください。

それでは以下のコマンドでデプロイします。

kubectl apply -f kubernetes-config

Podがデプロイされる様子やNodeの増減を以下のコマンドで眺めましょう

watch -n 1 kubectl get po
kubectl get nodes

デプロイが完了してrunningになったら以下のコマンドでLocustの管理画面のURLを取得します。ちなみに今回試した手順だと認証が掛かってないので、FirewallなりIAPなり何かしらのアクセス制限を実際には入れた方が良いかと思います。

EXTERNAL_IP=$(kubectl get svc locust-master -o jsonpath="{.status.loadBalancer.ingress[0].ip}")
echo "http://${EXTERNAL_IP}:8089"

負荷テスト開始

ユーザ数: 100 くらいで様子見

まずはユーザ数100くらいで負荷をかけてみます。DBのCPUは2コア、Cloud RunのCPUは1つでインスタンス数も1つに限定しています。

Locustを見てみるとユーザ数が増えるごとにパフォーマンスが悪くなっているのが分かります。一回、謎のハネがありますが概ね50TPSで落ち着いています。

RDBの該当時間のパフォーマンスは以下の通り。SQL Insightを見るとCPU使い過ぎですがクエリの平均は7.3msと十分速そうです。

リソースを調整。96コアの最強マシンを投入

いったんCloud RunのCPUコア数を増やしてみます。

gcloud run services update vaccine-booking-simulator --region us-central1 --platform managed --max-instances 1 --cpu 2

先ほどと大して変わらないというか平均では下がってますね。

コア数ではなく台数も増やしてみます。

gcloud run services update vaccine-booking-simulator --region us-central1 --platform managed --max-instances 2 --cpu 2

変わりませんね。

アプリサーバのリソース調整はいったん止めて、やはりRDBのCPU使用率が高すぎるのでスペックを上げます。SQL自体は速くてもRDB側でそれ以外のリソースが足りない可能性も多いにあるので。

RDBを16コア/64GBのマシンにアップグレードします。DBサーバなら最低このくらいはあるはず。

gcloud sql instances patch pginstance --tier db-custom-16-61440

Locustのチャートを見てみます。大幅に性能が改善しましたね! 330TPSまで増えています。やはりRDBが2コアではあまりにもしょぼ過ぎたようです。

RDB側を見てみましょう。CPUが張り付いては無いですが結構天井にすでに近いですね。SQLレスポンスは相変わらず良さそう。

次はCloud Run側のリソースも増やしたいですが、その前にDBに余力を作る意味でさらに増強しましょう。

96 コア/ 360 GBにします。これは中々のつよつよマシン。お財布がドキドキします><

gcloud sql instances patch pginstance3 --tier db-custom-96-368640

RDB側の負荷はこんな感じさっきより余力が出来てますね。

では、サーバ側のリソースも増やします。4コアでインスタンス数を300にします。

gcloud run services update vaccine-booking-simulator --region us-central1 --platform managed --max-instances 300 --cpu 4

この状態でもRPSは伸びないので、ここで100ユーザをさばき切れている状態と言えます。

負荷を徐々に上げていくも。。。

ここから負荷をふやします。まずは250。

TPSが500まで伸びました。

しかしながらDBの限界も近そうです。。。

とりあえずユーザ数を400くらいに増やしてみます。

なんとTPSは変わらず500前後。

やはりRDBのCPU限界のようです。

CPUを増やせばまだ性能は伸びますがこいつは通常の単独インスタンスではGCP最強の96コア。これ以上スケールアップで対応するのは困難です。スケールアウトさせればとなるのですが、RDBの書き込みを含むスケールアウトはシャーディング等が必要なのでそんなに簡単には対応できないので、インフラ増強してフリーランチが食べれるのはここまでですね。秒間1万とかほど遠い結果です。

チート。限界突破。

と、ここで気づいたのですがいつのまにかINSERTが走らなくなっています。どうも日当たり1万件の上限を突破していたため発行されなかったようです。
というわけで滅びの呪文を唱えます。

exampledb=> delete from book;
DELETE 290001

1500 TPS前後まで一気に性能が上がりました!

おお、戦士たちが蘇ったぞ! RDBのCPU負荷にも余裕が出来ました。

まあ、データ量が増えると遅くなる実装みたいですね。countしまくってるからそのせいだろうなぁ。ただ、30日で1日1万だとおよそ想定人数なのでこのキャパシティをさばけないと性能要件を満たしていません。満たしていませんが目をつむって、適度に滅びの呪文を唱えながら負荷を上げていきたいと思います。

ユーザ数を600にして1800前後になりました。そこから800までユーザ数を上げてみましたが特に変わらず。

DELETEした直後であればDBにも余力はありそうなので、負荷側の限界の可能性もあります。

というわけでちょっと負荷を掛けているPod数を増やします。

kubectl scale deployment/locust-worker --replicas=8

ユーザも1500ユーザまで上げました。2500TPSを超え。

まだまだいくよー♪

kubectl scale deployment/locust-worker --replicas=20

最終的にユーザを3000ユーザまで上げました。5300TPSを超え。ユーザ数が2500 ~ 3000でほぼTPSの増加が無かったこととこのあたりからコネクションエラーが多発したので真面目にチューニングしないとこの辺が限度の気がします。

ちなみにCloud Runのインスタンスは130個まで行きました。((((;゚Д゚))))ガクガクブルブル

後片付け

負荷テストが終わったらコストの無駄なので環境は削除してしまいましょう。

kubectl delete -f kubernetes-config
gcloud container clusters delete $CLUSTER --region $REGION

gcloud sql instances delete pginstance
gcloud run services delete vaccine-booking-simulator --region us-central1 --platform managed

おまけ

実行計画が実行時間が短いせいがSQL Insightsでほとんど出してくれなかったのですが、こんな感じでドリルダウンしながらスロークエリの分析が出来ます。

せっかくOpenTelemetryと対応してるのだから商用APMみたいにCloud Traceのアプリ向けダッシュボードから取れたらと思いますが、それが出来るのはもう少し先かな? どっちかというとサードパーティでもそれをやろうと思えばできるのがOpenTelemetryの強みなので「自分で作れ」なのかもですが。

まとめ

超素朴な実装、という前提で予約サイトの負荷テストをしてみましたが96コアのマシンを使っても500TPSくらいで頭打ちしてしまいました。これはDB負荷なのでスケールアウトでの対応も少し難しい気がします。まあ、参照ヘビーだったのでリードレプリカで改善する部分も大きいかもしれませんけど。

最後にチートでデータを削除しまくった感じだとロードジェネレータ20 Pod(12ノード) 使って3000ユーザ分の負荷を生成したところ5000 TPSくらいまで行きましたが、この辺で頭打ちです。1万TPSの半分にも満たないですね。てか、それでもこんなDB負荷初めて生成したよ。。。

まあチートした感じからすると、テーブル構造やDBアクセスが適切ならもっと性能は稼げそうです。コメントでも指摘ありましたが少なくとも参照時の空席照会で毎回countする必要はなくKVS置くかマテビュー作るかして非同期更新にしてしまえばお幅に負荷は減る気がしますね。それでも登録時に失敗すれば特に要件的にも問題ない。

このあたり実際に作るときには工夫のしがいがありそうですが逆に言えばなんも考えずに作るとこの程度で性能頭打ちになりがちという事でもあるのかな、と。
コネクションエラーも出てたのでNW的な同時接続数とかファイルディスクリプタ周りの調整も必要かも? アプリの作りを工夫してDBアクセス量自体を減らすのが正道かな? あとシャーディング。日付でパーティションしたらcountとかは速くなりそうだけどどうだろ。とか、推測はいっぱいできますが試してみないと何ともですね。楽しいけど大変な奴です。

あと、今回初めてGKE Autopilotを使いましたが結構手軽で良いのと、Loucusも便利なのでフレキシブルな負荷テスト環境が作れたのが個人的には良かったです。JMeterと余ってるサーバでやりくりしてたあの頃ではこれだけの負荷を作れたかどうか。。。

ただ負荷の生成や96コアの最強マシンのためにいつもより課金したので今月の請求が心配です。。。誰かバッジくださいw ><

それではHappy Hacking!

追記

結局いくらかかったの? というコメントがチラチラあったので追記しておくと環境づくりの試行錯誤諸々合わせて4000円くらい。このテストをもう一度再現するだけなら1000円前後で行ける気がします。

あと、バッジありがとうございます!

Discussion