スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

WebアプリケーションにGoの並行処理アーキテクチャを導入してSLOを改善し、WebAPIを100倍速くした話

こんにちは。スタディサプリの小中高プロダクト基盤開発グループでProduct Platform Engineer兼テックリードをやっている@tooooooooomyです。 今回は、WebアプリケーションにGoの並行処理機構を導入してSLOを改善し、WebAPIを100倍速くした話をしたいと思います。

前提条件

システムを0から作らない場合、アーキテクチャの改善の際には前提条件が付きものです。そこでまずは今回のシステムの前提条件をお話します。

対象となるシステムと、アーキテクチャ

今回対象とするシステムは、ここでは security-tracker と呼び、Webアプリケーション本体はGoで書かれています。 スタディサプリの各アプリケーションにおけるユーザーのログ1を、Amazon Kinesis Firehoseを通して、リクルート全体のセキュリティチームが管理するS3バケット(スタディサプリから見て、外部システム)に送信するシステムです。 セキュリティチームでは、このログから不正利用の兆候を検知するという業務を行っています。

全体のアーキテクチャ

SLOの運用開始

security-trackerのサービス立ち上げ当初、スタディサプリではService Level Objective(SLO)をサービスのオーナーシップを持つチームが管理する取り組みがまだはじまっていませんでした。
security-trackerがリリースされ、運用後しばらくして各アプリケーションのSLOが設定されていきました。security-tracker の可用性のSLOとしても、99.9% という目標が設定されました。

アーキテクチャの問題点

SLOを導入したことでアーキテクチャの問題点が浮かび上がってきました。Amazon Kinesis Firehoseの公式で発表されている可用性のSLAは99.9%です。
security-trackerのSLOは、Webアプリケーション単体のSLOとミドルウェアであるAmazon Kinesis FirehoseのSLAの掛け算によって決まります。つまり、Amazon Kinesis FirehoseのSLAが99.9%である以上、security-trackerのSLOとして99.9%を設定するのは、現実的に無理がありました。

例) Webアプリケーション単体のSLOを99.9%とすると、security-trackerサービス全体のSLOは 99.9 * 99.9 = 99.8%となる2。Webアプリケーションの可用性を100%と仮定すれば達成可能だが、それは不可能。

しかし、security-trackerの使われ方を考えると、可用性のSLOを下げる(≒ ユーザー体験を損なう)ことはチームとして許容できず、対策を考えることとなりました。

security-trackerの外部要件

アーキテクチャを考えるに当たって重要な、そもそものシステムの外部要件として、security-trackerが各アプリケーションから受け取ったログは、異常時には必ずしも100%保存できなくてもよい。というものがありました。
これは、security-trackerが扱うログが、ユーザーの行動を記録することが目的ではなく、不正アクセスが行われていないかを検知することが目的のためです。
初期段階でミドルウェアに可用性がAurora RDSやDynamo DBより劣るAmazon Kinesis Firehoseが採用されたのも、この要件があったためでした。

どのようにアーキテクチャを改善したか

さて、上記のような前提条件の中で問題解決のためにどんなアプローチを取ったかをご紹介します。
システムとして目標とするSLOを達成する方法としては、目標とするSLOよりも高い可用性が保証されるミドルウェアを採用するのが一般的です。
しかし今回のようにすでにシステムが稼働していて、さらに外部システムと連携していれば、新たなミドルウェアの導入は大きなコストとなります。
そこで今回は外部要件の、

security-trackerが各アプリケーションから受け取ったログは、異常時には必ずしも100%保存できなくてもよい

という部分に着目し、アプリケーションレイヤー、より厳密にはGoのレイヤーで問題の解決を試みました。
具体的には

  • httpリクエストを捌くゴルーチン(http context goroutine)

のみを起動し、httpリクエストを受け、ミドルウェアにリクエストをした後クライアントにレスポンスを返すという同期的な処理から、

  • httpリクエストを捌くゴルーチン(http context goroutine)
  • channelを監視し、ミドルウェアにリクエストをするゴルーチンを起動するゴルーチン(observer context goroutine)

という2つの役割を持つゴルーチンを起動し、2つのcontext間でchannelを通してデータをやりとりするというGoによる一般的な並行処理を採用しました。3
http context goroutineは、リクエストを受信したらログデータをchannelに渡し、即座にクライアントにレスポンスを返します。
observer context goroutineは、常にchannelを監視し、ログデータを受信したらミドルウェアにデータを送信する子ゴルーチン(worker context goroutine)を起動します。(実際のミドルウェアへのリクエストはこのworker context goroutineで行われます)
この変更によって、ミドルウェアであるAmazon Kinesis Firehoseの可用性とsecurity-trackerとしての可用性を切り分けて考えることが可能となり、security-trackerとしての可用性を99.9%以上に保つことが理論上可能になりました。
そう、お気づきの方もいらっしゃるかもしれませんが、この設計パターンはGo言語による並行処理の「5.3 ハートビート」や「5.6 不健全なゴルーチンを渡す」で紹介されている並行処理パターンを応用したものです。

Goの並行処理を実装した内部アーキテクチャ

一方、このアーキテクチャの問題点として、データの揮発性が挙げられます4が、外部要件としてそれが許容できることがわかっていたので、この問題点を許容するという意思決定をしています。

アーキテクチャ変更のステップ

理論上問題ないであろうことが確認され、開発環境で動くことが確認されても、実際に本番環境でアーキテクチャを変更するのは勇気のいる作業です。5
この作業を行うのに、チームで開発・運用しているDarklaunch v26 が大活躍しました。
Darklaunch v2について簡単に説明すると、機能のリリースをソースコードのデプロイと切り分けることができるWebAPIです。
このDarklaunch v2を利用することで、今回の本番環境のアーキテクチャ変更に置いては、

  1. 同期処理のみを稼働させる
  2. 同期処理と並行処理を動かすことのできる最新のコードベースをデプロイする
  3. 同期処理と並行処理を同時に動かす
  4. 同期処理を止めて並行処理単体で動かす
  5. しばらく様子を見る
  6. 最後に、問題がなければ同期処理のコードを削除し、並行処理のコードだけを残した最新のコードベースをデプロイする

といった感じで、新しいアーキテクチャのリリースを1回だけのコードベースデプロイで「お試し」することができました。
さらに、問題がないと判断できるまでは、いつでもコードの変更なしに元の同期処理に切り替える状態を維持しながら、監視を続けることができました。

アーキテクチャ変更後、システムはどうなったか

2023-09-28現在、アーキテクチャを同期処理からGoによる並行処理に切り替えてから2ヶ月が経過し、新しいアーキテクチャに問題ないことが確認できましたので、同期処理のコードは削除し、並行処理のみの新アーキテクチャでシステムは稼働しています。
この間にもAmazon Kinesis Firehoseへのリクエストは数回エラーとなりましたが、security-trackerのSLOはそのエラーに影響されず、切り替え以降100 %のSLOを保っています。
さらにこのアーキテクチャ変更を行ったことで、嬉しい副作用がありました。security-trackerのレイテンシーに同期処理のときの100倍以上(約50ms -> 約0.5 ms)の向上が見られたのです。
みなさんもご存知のように、Webアプリケーションにおける処理時間は、Webアプリケーション外との通信時間が大部分を占めています。並行処理によって非同期化したことにより改善することができたのですが、改めて通信時間の重さを実感する出来事となりました。
このエントリのタイトルでは「WebAPIを100倍速くした」と書きましたが、内部処理そのものを高速にしたのではなく、外部システムへの通信という時間のかかる処理を非同期に逃したことで、WebAPIのレイテンシ だけ見れば 、同期処理の100倍以上の向上が見られた...というのが実際のところです。7 ただし、このWebAPIのレイテンシの短縮は、ユーザーのログイン時間の短縮にダイレクトに効いてくるので、UX改善という意味でもとても有意義なものとなりました。
一方、並行処理を導入したことで、

  • http contextだけでなく、すべてのcontextでのerror handling
  • http contextだけでなく、すべてのcontextでのgraceful shutdown

のようなことを考慮する必要性が生まれています。デメリット、とまでは言いませんがシステムの複雑度が増したことは確かでしょう。

終わりに

いかがでしょうか。今回はGoの並行処理を利用したアーキテクチャ変更のお話をご紹介しました。
前提条件や制約次第ですが、一般的なWebアプリケーション開発においても、Go言語の並行処理の扱いやすさという優位性を示すことのできる事例だったのではないかと思います。
小中高プロダクト基盤開発グループではこのように、技術的なアプローチによってよりよいシステムを作っていく仲間を募集しています。
もしそんな小中高プロダクト基盤開発グループで開発することにもしご興味がありましたら、是非こちらのリンクをご活用いただければと思います。よろしくお願いします!


  1. ユーザーの個人情報は含まない、不正検知のみに使用する行動ログです
  2. 0.999*0.999 = 0.998001
  3. アプリケーションレイヤーでの変更のほうが、新しいミドルウェアの導入よりも低コストだと判断したということです
  4. channelを通したデータの受け渡しは、メモリ上で行われるので、処理途中でシステムがダウンした場合は、メモリ上のデータは失われます。これを本記事ではデータの揮発性と呼んでいます
  5. 当然ですが、アーキテクチャ変更時にシステムエラーを出してしまった場合、システムのSLOは下がります。ロールバックに時間がかかると、それだけでリスクです。
  6. https://blog.studysapuri.jp/entry/2023/07/05/090000
  7. Go言語による並行処理 「4.11 キュー」でも言及があるように、処理全体の処理時間が短縮されるものではありません