BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

「もうさばき切れない」アクセスが激増したECプラットフォームにおける負荷対策

はじめに

CTOの川口 (id:dmnlk) です。
5月にオンラインmeetupをさせて頂きその中で「具体的な負荷対策に関しては開発ブログで!」と言っていた件ですが気づいたらもう9月になりかけていました。

コロナ禍においてネットショップ作成サービス「BASE」の利用者様が急増しました。

www.nikkei.com

5 月には 100 万ショップを超えるショップオーナー様にご利用していただいております。
今まで EC 事業を行っていなかった飲食店様や様々な業種の方が利用をはじめていただき、ショップオーナー様も購入者様共に短期の見通しでは想定をしていないアクセスが発生しました。
その途中でシステムとして対応しきれない面もあり、アクセス負荷によるサービスの不安定を招き皆様にはご不便や販売時間を変更していただくお願いなどをしてしまい大変申し訳ありませんでした。
現在では安定しておりますが、その中で一体 BASE にはどのような問題が発生していたのか、それをいかにして解決したのかを公開させていただこうと思います。

BASEのシステム構成について

BASE は構成としてシンプルな Web アプリケーションになっており、オーナー様向け管理画面及び PC やスマートフォンでアクセスされるショップ画面を提供するアプリケーション、ショッピングアプリ「BASE」向け API アプリケーション、サードパーティ向け API アプリケーションなどがあり、データベースは単一のものをそれぞれが参照及び書き込みを行っています。ログ系データのみデータベースを別にしてあります。
アクセスが増えるとこのデータベースの負荷が高くなります。WebAP サーバーに関してはある程度スケールアウトやスケールアップが柔軟に行えるようになっておりますが、データベースに関してはそうはいきません。
データベースは Amazon Aurora MySQL を利用しています。 読み込みに関しては reader インスタンスを増やすことでスケールアウトが可能ですが書き込みに関しては容易には実現できません。

DBへの新規接続タイムアウト問題

コロナ禍において利用者が増えている最中、1 日の中でもアクセスが多い夜間帯でアプリケーションでエラーが多く発生する状態が観測され始めました。 そのエラーは DB への接続時に SQLSTATE[HY000] [2002] Connection timed out というものでした。
このとき、Aurora のメトリクスとして障害になりうるようなものは見当たりませんでした。 Connection 数が Max Connection の上限に達してしまっているならば、 Too many connections のエラーが出ているはずですが発生していません。
CPU や memory に関しても余裕がありました。 このエラーが発生しているとき、ブラウザ上でのアクセスも非常に待たされる状態です。
さらに、社内管理画面のようなアクセスが非常に少ないアプリケーションでも同様のエラーが発生していたので Apache などの WebAP が原因でないと推察していました。

原因が分からない状態ではありましたが、このまま放置することも出来ず利用者がまだまだ増えていく最中でしたので何かしらの対策を打つ必要がありました。 New Relic などのプロファイリングツールを用いて状態を見ていたところ、MySQL への接続に非常に時間がかかっていることはわかりました。
これはクエリの重さに依らず、非常にシンプルな SELECT でも起きていました。 設定でタイムアウトは 30 秒に設定していたので MySQL への接続それ自体に 30 秒かかってしまいアプリケーションとしてエラーになっているということです。
何が起きているかは分かるが、何故起きているが分からない状態で打てる手は多くありません。 アプリケーションで時間のかかっている処理などを少しでも改修するということは行っていましたが目に見える効果は大きくありませんでした。

インスタンスのスケールアップ

あまり褒められるものではありませんが、とりあえず primary インスタンスのスケールアップを行うことにしました。
深夜に緊急メンテナンスを行い、primary インスタンスのスケールアップを行いました。
1段階上位のインスタンスへのスケールアップを行ったあと、状態は改善しました。
上記のエラーは夜間帯でも発生しなくなり、理由はわからないが何かしらの対策にはなったと胸を撫で下ろしました。

が、これから 2 週間ほど経ったあと同様のエラーが再発するようになってしまいました。
アクセスは更に増えていたので、また何かしらの性能限界に達してしまったのだということがわかりました。 インスタンスのスケールアップはまだ可能ではありましたが、延々と上げ続けるわけにもいきません。
コストパフォーマンスの面でも無駄が大きすぎますし、原因が分からない以上同様の問題にぶつかることは自明です。
事実、エラーが発生している状況でも Aurora の CPU 使用率は 10%程度しか上がっていません。
弊社では AWS のエンタープライズサポートに加入していますので、AWS の方にも調査を依頼しました。 各種メトリクスの提示や、エラー発生時のアプリケーションの状態などを AWS 側に提出しました。

back_logパラメータの変更

数日後 AWS の方から「back_log パラメータの変更を検討してはどうか」という回答を得ました。 AWS の観測によると当該時間帯、DB への新規接続が数万を超えており SYN の再送が起きていたということがわかりました。
これは Aurora 側で新規接続が受け付けられていない状態です。 新規接続が多くなりすぎて、Aurora の back_log が溢れ SYN/ACK が返却できないため SYN の再送が行われてしまっています。
back_log キューは「サーバアプリケーションが listen しているソケットが、accept していない確立済 TCP コネクションを保持するキュー」であり、これが溢れてしまうと Aurora(サーバー)がパケットを drop してしまうため SYN の再送が発生してしまうということになります。
https://blog.cloudflare.com/syn-packet-handling-in-the-wild/
http://u-kipedia.hateblo.jp/entry/2015/01/01/001135

Aurora にも back_log が設定されており、そのデフォルト値はインスタンスタイプによって自動的に引き上げられます。
スケールアップによって一時的に改善されたのは、この back_log パラメータが引き上げられたことによって一時的に受け入れられる新規接続が増えたことによるものでした。 度重なるアクセス増によって更に新規接続が増えたため、引き上げられた上限を超えたため再度 back_log から溢れてしまい接続が drop してしまったようです。
このパラメータの変更はデータベースの restart を伴うため、オンラインで行うことはできません。failover が行われるとはいえサービスの停止が必要です。
直近に深夜メンテナンスを行ったばかりで心苦しいところではありましたが、システムの安定化のために必要な措置として再度深夜メンテナンスを行い back_log パラメータを引き上げることにしました。
これはパラメータグループでいう back_log というパラメータになるので、ここを max_connections と同値に変更しました。
これによりシステムの安定度は大きく向上しました。

その他のデータストアへの対応

BASE では MySQL だけでなく Memcached や Redis を利用しています。 これらも自社で運用しているのではなくマネージドサービスの Amazon ElastiCache を利用しています。
データベース接続に関して問題がなくなったことで上記のデータストアへのアクセスに同様のエラーが発生していそうなことが分かってきました。
Amazon ElastiCache では Aurora のように back_log パラメータの変更が出来ません。
そこでアプリケーション側で対応することにしました。

PHPでの検索結果Persistent Connectionについて

新規接続を都度行うのではなくコネクションプーリングのように再利用することで新規接続を減らすことができます。
Memcached、Redis のコネクションオブジェクト作成時にそれぞれにオプションを指定することで可能です。
PHP ではこれを Persistent Connection、持続的接続と呼びます。
下記のドキュメントを参照し持続的接続を行うことで確立した接続を維持したまま、新たな接続を受け付けるようにしました。
https://www.php.net/manual/ja/memcached.construct.php
https://github.com/phpredis/phpredis#pconnect-popen
BASE のアプリケーションのデプロイでは Blue-Green deployment を利用しているため、デプロイ毎に新規インスタンス群が増えます。
待機系になったインスタンス群も、デプロイ後でも接続を保持してしまうので httpd の restart を行う必要がありました。

なお、MySQL への接続にも Persistent Connection を行うことは可能です。
PDO::ATTR_PERSISTENT によって管理されます。 有効にすることはフラグをオンにするだけなので実装コストがほぼ掛かりません。
https://www.php.net/manual/ja/features.persistent-connections.php
ですが大きなデメリットがあります。
ドキュメントを参照すると

警告 持続的接続を使用する際にはまだいくつか心に留めておく必要がある注意 点があります。一つは持続的接続でテーブルをロックする場合にスクリプト が何らかの理由でロックを外し損ねると、それ以降に実行されるスクリプト がその接続を使用すると永久にブロックしつづけてしまい、ウェブサーバーか データベースサーバーを再起動しなければならなくなるということです。もう 一つはトランザクションを使用している場合に、トランザクションブロック が終了する前にスクリプトが終了してしまうと、そのブロックは次に同じ接続を使用して実行されるスクリプトに引き継がれる、ということです。 どちらの場合でも register_shutdown_function()を使用してテーブルの ロックを解除したりトランザクションをロールバックする簡単なクリーン アップ関数を登録することができます。しかしそれよりも良い方法は、テーブルロックやトランザクションを使用するスクリプトでは持続的接続を使用 せず、問題を完全に避けて通ることです(他の箇所で使用する分には問題あ りません)。

とあります。
これは接続が状態を持ってしまい、それぞれのケアをアプリケーション実装しなければいけないということです。 トランザクション処理のためにロックを確保している接続がエラーになってしまった場合などにその接続はロックを確保したままになってしまいます。
別のリクエストがその接続を利用とするとケアされていない場合ブロックしてしまうのは更なる障害を生んでしまいます。 トランザクションも同様です。
register_shutdown_function にロックやトランザクションを解除するような仕組みを作らなければなりません。 この処理が正しく動くことを検証することは相当量の検証が必要になるでしょう。 そのため、BASE では DB 接続へは Persistent Connection を利用することを採用しませんでした。

新規接続の限界

back_log の調整、他データストアの Persistent Connection によってしばらくは安定稼働させることができました。
しかし、この時の BASE のアクセス量の伸びは凄まじくこの構成でも接続エラーが発生するようになってしまいました。
ピーク時に秒間 2 万もの新規接続が primary インスタンスへ行われているといった状態です。
残された手段は primary のインスタンスに対しての接続数を如何にして減らすか、ということのみです。 前述のように PHP の Persistent Connection を使うことは新たなリスクを増やすことになります。 一般的に PHP+MySQL という構成だと ConnectionPool を用いることは稀です。なので利用できるミドルウェアもそこまで多くはありません。
ProxySQLMaxScaleというミドルウェアもありますが、これらの検証を行う時間的余裕がありません。これらを利用する場合、これ自体の可用性なども検証し運用する必要があります。
AWS からマネージドのコネクションプーリングとも呼べるRDS Proxyもありますがこの時点ではまだ GA になっておらずプロダクション環境に投入できません。

ここで出来ることアプリケーション側の接続をいかに reader インスタンスに行うか、ということです。
これはクエリを reader に向けるというよくある負荷対策ではなく、新規接続自体を reader に最初から向けるということが求められます。
弊社ではデータベースへのクエリ実行を primary や reader へ振り分けるのに弊社メンバーが作ったライブラリを利用しています。
どのようなライブラリなのか、というのは下記スライドを参照してください。

このライブラリの設定値の中で default_readというパラメータを true にすることで新規接続の時点で reader インスタンスへ接続を向けることが可能です。
Aurora はそのアーキテクチャ上、一般的に言われる primary と reader インスタンス間のデータ反映ラグがとても小さくなるようになっています。最大でも 100ms を超えることはありません。
ですが、全くのゼロでないのでそのラグが致命的な不具合になることがあります。ショップオーナーが変更した価格が反映されずに決済されてしまう、商品の在庫を超過して決済が行われるといった不具合だけは絶対に避けねばなりません。 なので default_read パラメータを有効にせず運用していました。

改めて、BASE というアプリケーションを見直すと主に 3 つの画面に分割されます。

  1. ショップオーナー管理画面
  2. ショップ情報や商品情報が表示され購入者が閲覧するショップ画面
  3. 決済処理を行うカート画面

これらはサブドメインで切り分けられていますが、コード及び稼働サーバーは同一です。 この中で 1 と 3 に関しては更新処理が多く行われており、この更新処理に不整合が起きると致命的な不具合となります。 ですが、2 のショップ画面に関しては更新処理はほとんどありません。あってもショップへのお問い合わせをする画面やチャット用の処理、くらいが主となります。
ここだけに絞るならば、default_read を true にするという選択肢は取れるのではないかと考えました。
最もリクエストが多いのもこの画面になるので効果は大きくなることも想定できます。 当初は 1 と 3 のインスタンス群と 2 のインスタンス群を別のサーバーとして振り分け、その場合のみ設定値を変更することも検討しましたが、新規接続をする前にリクエスト時のサブドメインによってこの設定値を可変にできることが判明したのでアプリケーションコードの変更を行いました。
結果として効果は覿面でした。

上記スライドにも上げられていますが、下記のように primary インスタンスへの接続は大幅に減少し reader への接続が増やすことができました。
f:id:dmnlk:20200824192101p:plain

connection count
上記の変更により圧倒的に BASE の安定度は向上し、ピーク帯においてもページが重かったり決済ができない、などといったことから解放されました。

余談とはなりますが CakePHP2 はデータソース毎に新規接続を作ります。
データベースインスタンスが別であればもちろんのこと、論理データベースが分割されていればその分データソースが分けられるので接続が増えることになります。インスタンスは同一でも、アカウント情報と注文情報などを論理データベースで分けるといったことはよくある構成ですが、これが全て別個の新規接続を生んでしまいます。
1 画面を表示する 1 リクエストにも関わらず複数の新規接続を生んでしまうのでここをうまく 1 つの接続を取り回せるような仕組みを開発することも新規接続を減らすために有効となります。

まとめ

今回 BASE において想定外であり未曾有の負荷であったことは言うまでもありません。
スケールアップだけでは対応できない、アプリケーションの単純なチューニングだけではどうにもならないという事象に初めて遭遇しました。
この中で、例えクラウドインフラを利用していても基礎的なミドルウェアや Linux の知識などが非常に重要であること、アプリケーションの構成を正しく理解しどのレイヤであれば思い切った対策を打てるかといったことを知っておくことの重要性を再認識しました。 合わせて、単純な DB の cpu/memory 使用率だけでなく新規接続の数など様々なメトリクスを監視することも重要になります。
なお、Aurora 1.19 にバージョンを上げると ConnectionAttempts というメトリクスが取得可能になるため、新規接続数をモニタリングすることが可能です(問題発生当時はこれより低いバージョンでした)。
AWS によりますと、Auroraのv2系にすることで更なるパフォーマンスの向上も見込めるということでしたのでアップデート計画を進めています。

今回は AWS の方のサポートが手厚かったことも大変助けになりました。 サポートケースの詳細を記すことはできませんが、弊社側でどのようなメトリクスを取得すれば調査に役立つかや具体的な検証用のソースコードの提供などもしていただきどれくらいの新規接続を行うと Aurora 側の限界値に達するかといった詳細情報まで頂きました。
このおかげで BASE としてどれくらいコネクションを減らすことが必要なのかの目算ができるようになりました。

もちろんこれでどんな時でも問題ないというわけではなく、新規接続をこれからも減らしたり負荷のかかる処理の分散、外部決済サービスへのリクエストの非同期化などまだまだやっていかなければなりません。 「BASE」というプラットフォームをご利用していただくショップオーナー様や購入者様の皆様がどれだけ増えても快適にご利用いただけるようこれからも改善を続けていきます。

弊社ではこのような大規模アプリケーションのインフラ運用やアプリケーション改善などを共に進めていってくれるメンバーを募集しています。
下記からご応募ください。

binc.jp

いきなり応募ではなく、ちょっと話を聞いてみたいといった方でもお気軽にどうぞ。
TwitterへのDMも開放していますので是非。

twitter.com