こんにちは!今回のインターンシッププログラムに参加し、AbemaTV Platform DivisionのPlatform Backendチームで勤務したコ・ウノです。
これまで比較的小さな開発組織で主にJava/Springを使用してきた私にとって、Go言語と大規模なトラフィック環境は新たな挑戦でした。本記事を通じて、私が技術的な課題をどのように解決し、その過程で一人のエンジニアとしてどう成長したかを共有したいと思います。
ABEMAの数千万トラフィックを支えるWebSocket認証設計
背景
私が所属するPlatform Backendチームは、ABEMAサービスのバックエンド基盤を構築する役割を担っており、現在は様々なリアルタイムサービスの導入のため、リアルタイム基盤を構築するプロジェクトを進行中です。
リアルタイム基盤は、多様なサービスが発行するイベントやメッセージを低遅延で伝達し、ワールドカップ期間中のような数千万規模のトラフィックを安定的に処理することを目標としています。
そのために、Edge computingを積極的に導入・活用しています。
Edge computingとは、ユーザーに最も近い場所でデータを処理する分散コンピューティング方式を指し、物理的に近い場所でデータを処理するため遅延が少なく、トラフィックを分散させられるというメリットがあります。
Edge Computingの利点を最大限に活用するため、デバイスとのWebSocket接続をEdge Computingで処理する構造を採用しました。中央サーバー(Origin)がメッセージを発行し、エッジサーバーがそれを受け取って各ユーザーに伝達する方式です。
しかし、ここで最初の問題が発生します。「この膨大な接続リクエストが本当に有効なユーザーからのものか、どうやって迅速かつ効率的に認証するのか?」もし、すべての認証リクエストが中央サーバーに向かうことになれば、Edge Computingを使う意味が薄れ、中央サーバーはあっという間にボトルネックになってしまうでしょう。この問題点をインターン期間中にどのように解決したか、皆さんにご紹介します。
JWT(JSON Web Token)とJWKS(JSON Web Key Set)標準を活用した鍵配布システム
結論から言うと、JWTを利用して中央サーバー(Origin)への追加リクエストなしにエッジでユーザー認証とセッション設定を可能にし、JWKSを利用してエッジに公開鍵を配布する方法を採用しました。
上の図のようなフローでJWTの発行と検証が行われます。なぜJWTとJWKSを選択したのかを説明します。
「認証はエッジで!」 – JWTの導入
中央サーバーの負荷を軽減するには、認証プロセスにおける依存性を最小限に抑える必要がありました。この問題の解決策として、JWT(JSON Web Token) を選択しました。
JWTは、トークン自体にユーザーIDや購読チャンネル情報など、認証に必要なデータを含めることができるため、エッジサーバーが中央サーバーに毎回問い合わせることなく、独自にトークンの有効性を検証し、セッションを設定できます。これにより、トークン発行以外のすべての作業をエッジサーバーで処理することが可能になります。
もう一つの悩み:「JWTをどうやって伝達するか?」
JWTを使うと決めたものの、「このトークンをクライアントからエッジサーバーへどう安全かつ安定的に伝達するか?」という、もう一つの現実的な問題に直面しました。WebSocketプロトコルのハンドシェイク過程で認証情報を伝達する方法には、大きく分けて4つの選択肢がありました。
- カスタムヘッダー: HTTP認証で最も一般的な方法ですが、ブラウザの標準WebSocket APIではカスタムヘッダーをサポートしていないため、Web環境では使用できないという致命的な欠点がありました。
- URLパラメータ: wss://…/?token=… のようにURLにトークンを付与して送信する方法で、Slackのような大規模サービスでも採用されている最も一般的な方式です。実装が簡単で、ほとんどの環境で互換性があるという大きな利点がありますが、URL自体がサーバーログなどに記録され、トークンが漏洩する可能性があるというセキュリティ上の懸念が存在しました。この問題は、トークンの有効期間を60秒と非常に短く設定することで、万が一トークンが漏洩しても悪用される可能性を最小限に抑えるという方法で緩和しました。
- クッキー: Web環境では自然な方法ですが、ABEMAはネイティブアプリもサポートする必要がありました。ネイティブ環境でクッキーを直接管理し、伝達することは開発負担を増大させる要因でした。
- Sec-WebSocket-Protocolヘッダー: WebSocket標準で規定されているサブプロトコル交渉用のヘッダーにトークン情報を乗せて送る方法です。明示的な方法ではありますが、私たちが検討していたEdge Computing環境で一部誤作動の事例が発見され、安定性を保証できませんでした。
すべての選択肢には明確な長所と短所が存在しました。私たちは、Webとネイティブアプリの両方をサポートする必要があるという汎用性と開発の容易さを優先し、短いトークン有効期間でセキュリティ懸念を緩和することで、最終的にURLパラメータを通じてトークンを伝達する方式を採用しました。
しかし、JWTを導入したことで新たな疑問が生じました。「トークンの改ざんを防ぐための署名(Signature)に必要な鍵は、どのように管理すべきか?」特に、トークンを発行する主体(中央サーバー)と検証する主体(エッジサーバー)が異なるリアルタイム基盤では、鍵管理戦略が非常に重要でした。
「安全な鍵管理」 – 非対称鍵
署名方式には、大きく分けて対称鍵と非対称鍵の2つの選択肢がありました。
- 対称鍵: 1つの鍵で署名と検証の両方を行います。実装は簡単ですが、検証のためにすべてのエッジサーバーに署名鍵を配布する必要があります。これは鍵漏洩のリスクを高め、管理を複雑にします。
- 非対称鍵: 秘密鍵(Private Key)で署名し、公開鍵(Public Key)で検証します。トークン発行が必要な中央サーバーにのみ秘密鍵を安全に保管し、エッジサーバーには検証用の公開鍵のみを配布すればよいため、はるかに安全です。
そのため、セキュリティと管理効率を考慮し、非対称鍵方式を採用しました。
追加で、どの非対称鍵を使用するかを決定する必要がありましたが、Goのベンチマーキングを活用して性能を比較し、処理速度とメモリ使用量を総合的に考慮してEd25519を使用することにしました。
goos: darwin goarch: arm64 pkg: test-project cpu: Apple M4 Max Benchmark_Sign_ES256-16 72435 15817 ns/op 9063 B/op 106 allocs/op Benchmark_Sign_Ed25519-16 65714 17764 ns/op 2433 B/op 36 allocs/op Benchmark_Sign_RS256-16 1712 698202 ns/op 3763 B/op 36 allocs/op Benchmark_Sign_PS256-16 1711 698291 ns/op 3971 B/op 41 allocs/op Benchmark_Verify_ES256-16 34021 34817 ns/op 3784 B/op 83 allocs/op Benchmark_Verify_Ed25519-16 31119 38665 ns/op 2432 B/op 58 allocs/op Benchmark_Verify_RS256-16 53991 22249 ns/op 4136 B/op 68 allocs/op Benchmark_Verify_PS256-16 52816 22779 ns/op 4072 B/op 72 allocs/op
「サービスを停止せずに鍵を交換せよ!」 – JWKS
セキュリティのため、暗号鍵は定期的に交換する必要があります。しかし、何の対策もなしに署名鍵を変更してしまうとどうなるでしょうか?
既存の鍵で発行された有効なトークンがすべて認証に失敗し、数千万のユーザーのWebSocket接続が一斉に切断されるという大惨事が発生します。そのため、無停止(Zero-Downtime)での鍵交換という最後の関門を通過する必要がありました。
最初はGCP KMSのようなクラウドサービスを検討しました。鍵を直接所有しなくてもよいという大きな利点がありましたが、2つの懸念から採用しませんでした。
- リクエスト制限(Rate Limit): ピークタイムに多数のデバイスが同時に接続すると、KMSの1分あたりのリクエスト制限に引っかかり、サービス遅延が発生する可能性があります。
- ネットワークコストと遅延: 他のベンダーのEdge Computingを使用する場合、外部ネットワークを介してKMSと通信する必要があるため、コストと遅延の問題が発生する可能性があります。
そこで、鍵を直接管理しながら配布する方式を選択し、その答えをJWKS(JSON Web Key Set) 標準に見出しました。
JWKSは、複数の公開鍵を /.well-known/jwks.json のような特定のURLを通じてリスト形式で提供する標準です。この「リスト」という点が、無停止での鍵交換の核心的な鍵となります。
鍵を識別する方法:JWK Thumbprintとkid
JWKSは複数の公開鍵をリストで管理しますが、その際に各鍵を一意に識別するために kid (Key ID) を使用します。JWTヘッダーには、トークンの署名に使用された鍵のkidが含まれており、エッジサーバーはこのkidを見てJWKSリストから正しい公開鍵を見つけ、署名を検証します。
では、このkidはどのように生成するのでしょうか?任意の文字列を使用することもできますが、リアルタイム基盤ではRFC 7638標準で定義されているJWK Thumbprint方式を使用することにしました。公開鍵の主要な構成要素(kty, crv, xなど)を定められた規則に従ってソートしてJSONを作成し、これをSHA-256でハッシュ化してkidを生成します。これにより、鍵自体の内容から一意のIDが生成されるため、鍵が変わればkidも常に変わり、管理が容易になります。
Goを使用してEd25519のJWK Thumbprintを以下のコードで生成できます。
これで、kidを利用した無停止での鍵交換プロセスは次のようになります。
- (準備) JWKSに新しい公開鍵(kid-new)を追加します。署名にはまだ古い鍵(kid-old)を使用します。エッジサーバーはこの時点でJWKSを更新し、kid-newの存在を事前に知ることになります。
- (切り替え) これから中央サーバーは新しい秘密鍵(kid-new)でトークンを署名し、JWTヘッダーにkid-newを明記します。エッジサーバーはkid-newで署名されたトークンとkid-oldで署名されたトークンを両方とも検証できます。
- (猶予) kid-oldで発行された最後のトークンの有効期間が切れるまで待ちます。
- (整理) kid-oldが不要になったら、JWKSリストからkid-oldを削除し、すべての交換プロセスを完了します。
このフローを表にまとめると、以下のようになります。
この方式により、ユーザーは鍵が交換される過程すら認識することなく、安定してサービスを利用し続けることができます。
まとめ
まとめると、リアルタイム基盤の設計と開発を進める中で多くの悩みがあり、それらを次のように解決しました。
- 問題: 中央サーバーにおける認証のボトルネック → 解決: JWTの導入
- 問題: JWTの伝達方式 → 解決: URLパラメータ方式と60秒という短いトークン有効期間
(汎用性とセキュリティのトレードオフ) - 問題: 分散環境における安全な鍵管理 → 解決: 非対称鍵(Ed25519)の採用
- 問題: サービス無停止での鍵交換 → 解決: JWKS標準の導入
結果
感想
Goとクラウドサービスを利用した開発は初めてだったので、インターン開始前は漠然とした不安がありました。
しかし、最初の週に簡単な作業をこなしながらABEMAの開発環境にすぐに慣れ、次第に自信を得ることができました。
2週目からは、主要な課題に取り組む前に設計作業を通じて、大規模トラフィック環境で考慮すべき点を深く学ぶことができました。単純な処理でさえもサーバーに大きな負荷をかける可能性があることを悟り、なぜクラウドサービスを積極的に活用すべきなのか、そして大規模トラフィック環境における設計原則とは何かを幅広く理解するようになりました。
3週目に本格的な開発を始めた際は、ABEMAのコードスタイルにできるだけ従うため、他の部分のコードを参考にしながら作業しました。この過程で、可読性の高いコードの書き方や開発時に留意すべき点を自然に習得できました。特に、クリーンで読みやすいABEMAのコードのおかげで、良いコードの重要性を体感し、多くのことを学ぶことができました。
4週目は、ブログ執筆と成果発表の準備をしながら、これまでの取り組みを振り返る時間でした。また、チームの懇親会に参加して最後に話しきれなかったことを話すなど、インターン生活を締めくくる一週間でした。
トレーナーのユシンさんのおかげで、昼食時間に多くの方々と交流することができました。サイバーエージェントへの志望動機についてすべてのエンジニアの方に質問したところ、皆さんが口を揃えて、会社の優れた技術力を通じて個人の技術的成長を遂げられる点を挙げられました。その答えから、会社の技術力に対する深い誇りを感じることができ、実際に1ヶ月間勤務してみて、私も会社の優れた技術力に深い感銘を受けました。また、社員の皆さんはとても仲が良く、オフィスにはいつも笑いが絶えず、和やかな雰囲気の中で働くには本当に良い環境だと思いました。
1ヶ月という短い期間でしたが、短期間で大規模トラフィック環境における設計・開発を直接経験し、本当に多くのことを学ぶことができました。大規模トラフィック環境での開発における留意点や、技術選定において何を考慮すべきかを幅広く理解できたことは、本当に貴重な経験でした。今回の経験を通じて、これからも大規模トラフィックを扱う環境で成長していきたいという熱意を感じ、今回の設計過程で学んだ多様な観点から技術を分析し選択する能力をさらに発展させなければならないと心に誓いました。
最後に
1ヶ月間、惜しみない支援をしてくださったトレーナーのユシンさん、メンターの辻さん、そして担当人事の宝田さんに心から感謝申し上げます。また、多くのアドバイスをくださったPlatform Backendチームの皆さんにも深く感謝いたします。
本当に楽しいインターン生活でした!