PHPでSpannerを使ったときにハマったことを思い出す

こんにちは。カヤックボンドの駒田です。

この記事は 面白法人グループ Advent Calendar 2022 の2日目を予定していた記事でした。

今年ももう終わりですね。

さて、何について書こうか悩みましたが…
過去にPHP Laravel x Spanner の組み合わせで苦労したことを書いてみようと思います。

1.Spannerとは

Cloud Spanner は、フルマネージドのミッション クリティカルなリレーショナル データベース サービスです。グローバルなトランザクション整合性、高可用性のための自動の同期レプリケーション、2 つの SQL 言語(Google 標準 SQL(拡張機能を含む ANSI 2011)と PostgreSQL)が含まれています。

Cloud Spanner ドキュメント  |  Google Cloudより

Google Cloud の Spanner というフルマネジメントRDBのサービスですね。
最近は結構あちこちで採用されている印象です。

2.Spannerの特徴

NewSQLなどと呼ばれているものの一種で

なのが特徴です。

概ねフルマネージメントかつオートシャーディングを目当てに導入される印象ですね。

その分コストが高いのですが、シャーディングが必要になる規模ですと
ユーザーデータの格納先用途等で、候補に挙がってくるサービスじゃないかな、と思います。

ちなみに、昔よりもこまかーいユニットという単位で立てれるようになったので
最小構成で立てる場合はそこまで高くないようです。
1ノードだと、2週間たたずに無料枠クレジットが吹っ飛んでいたのでこれはありがたい!
https://cloud.google.com/blog/ja/products/databases/use-spanner-at-low-cost-with-granular-instance-sizing

3.PHP(Laravel)とSpanner 導入

そんなSpannerのためのクライアントは各種言語が用意されていますがGoで触る人がやはり多いようで
phpで扱おう!というケースはあまり無いんじゃないかな、と思います。

私が扱ったタイミングは少し前になってしまうのですが、
その当時もやはりphpで扱おうという人は同様にまだあまり多くは無く…。
どうしたもんかな、と思っていた中、コロプラさんがLaravelでSpannerを扱うためのOSSを提供しておりました。

github.com

有難くこちらを利用させて頂き、比較的スムーズに導入することができました。

その後、MySQLに存在する機能が使えずにMigration(afterが使えなかったりした)や
その他クエリ実行でコケたりと細かい苦労はありつつ、とはいえ比較的スムーズに導入、実装は進みました。
(今はエミュレータがありますし、機能の対応範囲も増え、このあたりの苦労は激減しています。)

で、わーいやったぜ。これでRDBは安心だーと思っていた、のですが…

4.導入して困った事

負荷試験をしたところ、Spannerの接続周りが明らかにボトルネックになってしまっていました。
各種Spanner導入記事にも「sessionの構築にはコストがかかる、session poolはしっかり管理せえよ」
と記載があることが多いですが、見事にそこにハマった形です。

では、いったい何が原因だったのか。
いくつかあるので順番に説明していきます。

4-1. flockによる排他制御

Laravel-Spannerでは、Spannerへの接続にCloudSpannerのセッションをプールしておくSessionPoolのキャッシュ、
そしてGCPのアクセストークンのキャッシュであるAuthCacheを利用しているようで
デフォルトですと、このキャッシュはファイルキャッシュとしての実装がされていました。

このプロジェクトにおけるデフォルトの排他制御はflockが使われており
これによるロック解除待ちが発生し、1つずつ順番にしかアクセスできないような状態になっていました。

そのためまずこの形式をセマフォへ変更してみたところ
全体的な性能の改善は見られませんでしたが、ロック周りの時間は短くなり、負荷の傾向に変化がみられました。

4-2. ファイルキャッシュを別のキャッシュ形式へ

負荷の傾向が変わった、ということはボトルネックの箇所が変わった、ということで
一旦ロックはこのままで、別のアプローチをとることにしました。

Laravel-SpannerのReadMeには下記の記載があり、そもそもキャッシュを何で持つか、を変更できるようになっていました。

By default, laravel-spanner uses Filesystem Cache Adapter as the caching pool. If you want to use your own caching pool, you can extend ServiceProvider and inject it into the constructor of Colopl\Spanner\Connection.

そのためファイルキャッシュから共有メモリ内へのキャッシュへと変更を行うことで改善を図りました。

これを行ったところある程度の性能改善がみられました…が、
セッションプールの異常な増加、一定の負荷を超えるとメモリ不足になる、といったような事象が発生し始めてしまいました。

4-3. 共有メモリキーの分離

共有メモリの格納はSysVCacheItemPoolを利用して実装していましたが パラメータにprojというものがありました。

ftok() - プロセス間通信 (IPC) キーの生成

ftokによる共有メモリキーの指定に使われる値であり、デフォルトだとAが指定されます。
そのため未指定の場合だと、SpannerのセッションもAuthCacheも常にAの指定になってしまっていました。
これに加え、AuthCacheについてはロック処理は入っていないことが分かったことで

おそらく
同一の領域が参照された場合に共有メモリに格納されたキャッシュデータが破壊される
→ 破壊された部分が使われないゴミキャッシュとして中途半端に残る
→ 新規で作られたセッションやAuthCacheが書き込まれ、
→ また破壊され…のループでメモリが溢れる。
というような状態になってしまっていたものと思われました。

この対応として、明示的にprojを分けて共有メモリに書き込むようにすることでボトルネックは解消し
おおむね予想していた性能がでる結果となりました。

もちろんこの後も細かい諸々だったり付随する対応はあったのですが、大きなハマりはこのあたりだったかなと思います。

5. 他、quotaについてなど

これは言語に依存する部分ではないですが、spannerについてその他での注意点といえばquota周りですよね。

https://cloud.google.com/spanner/quotas?hl=ja

何かと困るのはミューテーション制限!
バッチ処理とかでもあっさり引っかかる値なので、十分に注意が必要ですね。 ちなみに22年10月に倍に増えたらしいです、やったね。

上限
commit サイズ(インデックスを含む) 100 MB
セッションあたりの同時読み取り数 100
commit あたりのミューテーション(インデックスを含む) 20,00040,000に増えた!
データベースあたりのパーティション化 DML の同時実行ステートメント数 20,000

Cloud Spanner のトランザクションあたりの更新回数が 2 倍に増加 | Google Cloud 公式ブログ ※ミューテーションの推定なども詳しく書いてあるので是非。


というわけでPHP Laravel x spanner の組み合わせの話でした。
この組み合わせ、あまり情報も多く無いので、もし今後同じ組み合わせを試す人が居たときに参考になるかなあと思い記事にしてみました。
いやあ、いいサービスですよね。Spanner。

それでは皆様、良いお年を。

Webサービスの急激なアクセス数増加を予測して対処する方法と実践

どうも、ゲームコミュニティ事業部Tonamelのサーバサイド担当の谷脇です。

今回はTonamelのサービス特性上、どうしても発生する急激なアクセス数の増加(以下スパイクアクセス)をどのように対処しているかをお話します。

Tonamelのサービス内容については以前の記事に書いています。一言でいうと「誰でもeスポーツ大会の運営ができるサービス」です。

あっという間に大会作成, 大会運営のベストツール, リアルタイムにコミュニケーション, リアルタイムに進行チェック
Tonamelの特徴

大会が開始したときに発生するスパイクアクセス

10:40頃に鋭くスパイクが発生している
スパイクアクセスの様子

上記は、あるゲーム大会が大会を開始した10:40ごろのリクエスト数の遷移です。Tonamelは大会の開始と同時にトーナメント表が公開されます。大会に参加している人はもちろん、観戦を行っている人もトーナメント表を見に来ます。観戦する人の同期としては、知人や推しの選手のトーナメント表上での位置や、相手を確認しに来ているようです。

Tonamelに限らず、Webサービス内で多数のユーザーが一斉に使うような「イベント」のようなものがあるサービスの特性として、このようなスパイクアクセスはどうしても発生します。

採用しなかった案: スパイクをなだらかにするアプローチ

私が以前関わっていたソーシャルゲームでは、プッシュ通知を用いてイベントを通知すると、一斉に負荷がスパイクしてしまう現象がありました。このときの対処として、プッシュ通知をある程度、ゲームの体験を損なわない程度に分散させることを行っていました。

しかし、ゲームのルール上、同時に通知しないと不公平感があったり、そもそも今回のケースのような通知のタイミングを契機としたスパイクではないなどでは「スパイクをなだらかにする」アプローチは使用できません。あるとすれば、APIとしてリクエスト過多のような状態を表現し、リトライさせて分散させるようなやり方もありそうです。しかしトーナメント表がなかなか見れないなどのサービス体験の劣化が予想されます。

というわけで、「来たリクエストは全部ちゃんと返す」「とにかくスパイクに耐える」方針をTonamelでは取っています。

職人が週末の負荷を予測してスケールアウト予約する

Tonamelの場合、スパイクアクセス時に問題になるのは、WebアプリケーションのCPU負荷でした。それ以外のDBの負荷などはまだ問題にはなっていませんでした。ですので、Webアプリケーションサーバーをスケールアウトさせれば、スパイクアクセスに耐えられます。

TonamelはインフラにAWSを採用し、このスパイクアクセス問題に取り組んでいたときはEC2上でPerlのWebアプリケーションを動かしていました。EC2のCPU負荷やALBのコネクション数をもとにしたオートスケールも動いていましたが、スパイクアクセスの場合、オートスケールは間に合いません。上記のメトリックが示すように数分以内に2倍から3倍のリクエスト数増加が見られます。

一方で、Tonamelのサービス特性上、スパイクアクセスが発生する時間を予測することが可能です。Tonamelの大会は、

  1. 主催者によって大会が作成される この時点で大会の開催日時と規模(エントリー人数)の上限が確定する
  2. 主催者によって大会の参加者募集が行われる
  3. 大会参加者の募集が締め切られる この時点で規模(エントリー人数)が確定する
  4. トーナメント表が確定し、大会が開始する ここでスパイクアクセスが発生する

という経緯をたどります。1や3は大会開始よりも1週間以上前のことが多いです。またeスポーツ大会の参加者の特性上、週末に大会が行われることが多いです。スパイクアクセスが発生するのも週末になることが多いので、「この先1週間のスケールアウトの予定を金曜日に立てる」ことを取りました。つまりサーバ台数の需要予測ですね。

また、この過程で監視サービスとして用いているMackerelを見ながら、「この大会のときはリクエストが詰まった」などの履歴を、大会の規模やゲーム、主催者などの記録と一緒に取っていきます。これにより、ある程度の精度の「任意の大会に必要なサーバ台数」というのを割り出せるようになりました。

職人が検討するissueの様子

チーム内では「職人芸」と言っていまいたが、上記のナレッジから、特定の時間帯に必要なサーバ台数を割り出すPull Requestがこちらです。スケールアウトのスケジュールはEC2 Auto Scalingに対してterraformで記述する形をとっています。このPull Requestにコメントで「この時間にこの台数が必要な理由」を書いて、レビューをした上で適用する運用をしました。レビューの形で職人の暗黙知がチーム内に広がることで、この作業もローテーションして行えるようになりました。

時間帯別の大会参加人数ヒートマップを元に検討している様子

また、需要予測を手助けするための、redashのダッシュボードも作成しました。このissueでは、時間帯別の参加人数のヒートマップをもとに、台数を割り出しています。

「職人芸」を自動化する

定量的な基準を持ってスケールアウトを行い、スパイクアクセスへの対処が出来てきたものの、毎週このような定形作業を行うのは苦痛でした。また、定量的に出来ているわけですから、自動化が可能なはずです。というわけで、上記の職人芸を定期バッチ化し、crontabに載せることで自動化してみました。

負荷の予測を元に事前スケールアウトを行うPull Request

上記issueは自動化を試みたときのものです。issueの目的に、

金曜日の調査時に引っかからなかった大会が開催時までに膨れて予想外の負荷スパイクを生む場合でも、サーバが重くならないようにする

とあるように、実はこのとき特定の主催団体さんの使い方で、需要予測調査時に引っかからないにもかかわらず、予期しないスパイクアクセスが発生するようになっていました。具体的にいうと、Tonamel上でエントリー募集を行わずに、Google Formなど他のサービスで行い、大会の開催直前に他サービスでエントリーした人にTonamelのエントリーフォームを案内する運用です。この当時はTonamelのエントリーフォームの機能が弱く、例えば選択式の質問であったり、画像の投稿がエントリー時出来ませんでした。2022年12月現在ではこのあたりの機能もTonamel単体で行えますが、当時にこのような大会運用をするのは致し方ありません。

しかし、自動化すればこのような悩みも解決できます。職人が調査をするのは、他のタスクとの兼ね合いもあり1週間に1度が限度ですが、調査と判断およびスケジュールの適用まで自動化してしまえば、1時間に1度の頻度などで走らせられます。また、スケールアウトの粒度も人力のときと比べて細かいですから、インフラコストも削減できます。自動化によって完全上位互換の恩恵を受けられるわけです。

Perlのバッチに係数をいっぱい書き連ねて職人の知識を移植

需要予測職人芸バッチは以下の流れで動作します。なお、上記の職人芸をやったときから、この自動化を行うまでにEC2からECSに移行しているため、AWS Application Autoscalingに対して適用しています。

  1. バッチの起動時刻の30分前から90分後までに開催する大会を探索
  2. エントリー人数をもとに必要な台数を割り出す
  3. スケールアウトもしくはスケールインを適用する

以下、なぜこの様になっているかを見ていきます。

1. バッチの起動時刻の30分前から90分後までに開催する大会を探索

30分前から探索しているのは、すでに開始予定時刻を過ぎた大会であってもまだトーナメント表を公開していないケースがあるからです。トーナメント表作成時に位置を調整したり、チェックインに遅れた人をDMを契機に手動で追加するなどの救済措置を取っている主催団体もあります。その時間を鑑みて30分前から見ています。

2. エントリー人数をもとに必要な台数を割り出す

このバッチには、MAX_PARTICIPANT_BY_TASKという定数が設定されています。これは今までの大会から、1タスクあたりどれぐらいの人数がさばけるかを算出した値です。各大会のエントリー人数をこの値で割って、必要な台数を計算します。

しかし、ゲームや主催団体、また大会形式によって係数をかけています。ゲームによってはチームで参加する大会もあります。この場合、エントリーしている人以外に、チームメンバーもトーナメント表を見ることが予想されます。そこで「このゲームはこの人数で対戦するルール」とわかっている場合には、エントリー人数に人数分の係数をかけています。

また、注目度が高い主催団体が大会を開催される場合、多くの観戦者がトーナメント表を見に来る場合があります。これも係数で特定の主催団体に対して係数をかけています。

そして大会形式ですが、Tonamelの場合、シングルエリミネーション, ダブルエリミネーション, スイスドロー, フリーフォーオール形式に対応しています。この中で、シングルエリミネーションはキャッシュを効かせてある程度チューニングしていたのですが、スイスドローに関してはそれがなかったため、多くのCPUをトーナメント表の構築に使うため、多めに係数を設定していました。フリーフォーオールは、他の形式では1vs1のフォーマットであるところ、最大99人が1つの大戦カードに入れるため、多くのCPUをチャットのポーリングで消費するケースがあります。そこで係数で多くの負荷を消費するように設定しています。

参考まで、コード内で主催団体と大会形式で係数を設定しているところを貼ります。

コード中に設定している係数

3. スケールアウトもしくはスケールインを適用する

こちらは、AWS::CLIWrapperを用いて、ECSの台数を調整しています。

1時間から30分程度の定期実行でやれるので、スケジュールを登録するのではなく、バッチ実行時にスケールアウトを行うようにしています。

以上のバッチを適用し、ECS化で唯一残っていたEC2のデプロイ用のサーバのcrontabに30分毎に適用するようにしました。こちらも後日、完全ECS化を達成するために、ecscheduleを用いて、30分毎にバッチコンテナを起動して上記の挙動を実現しています。

実際の挙動を見てみる

台数の増加の様子

これは先日行われた大会での台数の遷移の様子です。10:30に開始予定で、エントリーは216チーム x 5〜7人のゲームです。この大会は多くの人がTwitterでのリンク拡散からトーナメント表を見に来るので、主催団体の係数が適用されて、ドバっと台数をあげてスパイクに備えています。

冒頭のスパイクアクセスのグラフを再掲

一方でリクエスト数はこちらです。冒頭に上げたスパイクの例と同じものです。10:10から10:40の30分間に分間リクエスト数が10倍程度まで増加しています。

CPU使用率の様子

WebアプリケーションのCPU負荷とレスポンスタイムを見てみましょう。09:30ごろからスケールアウトバッチが反応して台数をあげています。はじめのほうは無駄ですが、スパイクのときに余裕にある程度を余裕を持った程度のCPU負荷でさばけているので、このスケールアウトはバッチリだったと言えます。

また、スパイクに耐えたあとは、最低台数を引き下げて、あとはオートスケールの負荷追従に任せます。これで勝手に今の負荷に最適な台数までスケールインされます。

レスポンスタイムの様子。該当時間帯に悪化は見られない

レスポンスタイムを見ても、スパイクの発生時にp99のメトリックでも詰まった様子はありません。ちなみに他の時刻では跳ねているのは、ユーザーのリクエストを返しているのとは別のターゲットグループのメトリックです。

ここからのさらに改善としてTonamelのデータ基盤のデータからより良い精度の需要予測を与える話もあるんですが、まだ自動化された職人芸でも回っております。

まとめ

以下に実践したことをまとめます。

  • スパイクアクセスのために事前に需要予測するのと、ナレッジを蓄積
  • 需要予測のナレッジをプログラムに移植し自動化
  • サービス運用の人的負荷の削減とともに、インフラコストの最適化と「大規模大会にいつでも耐える」というサービス自体の価値向上

SREingとしての定量的な観察からの可用性の向上とトイルの削減を行うと、サービスの価値が向上したよという話でした。最後に、職人芸の自動化に取り組んでくれた同僚の @koluku に感謝します。