Software Design 2024年10月号 連載「レガシーシステム攻略のプロセス」第6回 ZOZOTOWNにおけるBFFアーキテクチャ実装

Software Design 2024年10月号 連載「レガシーシステム攻略のプロセス」第6回 ZOZOTOWNにおけるBFFアーキテクチャ実装

はじめに

技術評論社様より発刊されているSoftware Designの2024年5月号より「レガシーシステム攻略のプロセス」と題した全8回の連載が始まりました。

3年前に行われたZOZOTOWNの大規模なリニューアルを行う際、リプレイスプロジェクトと関連する課題を解決するためにBFF(Backends For Frontends)の導入が行われました。今回は、その経緯と効果を紹介します。

目次

はじめに

こんにちは。株式会社ZOZO技術本部SRE部の三神と技術本部ECプラットフォーム部の藤本です。ZOZOTOWNでは約3年前に大規模なリニューアルを実施し、BFF(Backends For Frontends)を導入しました。第6回ではBFFの導入に至った経緯やそのしくみ、そして導入後にどのような変化があったのかについて紹介します。

ZOZOTOWNの課題とBFFによる解決

2021年3月に実施したZOZOTOWNの大規模リニューアルの一環として、BFFを導入するという判断をしました。BFFはアーキテクチャ設計パターンのひとつで、クライアントからのリクエストを一元管理し、フロントエンドとバックエンドの間で双方の複雑さを吸収して処理を効率化するためのものです。BFF導入の背景には、ZOZOTOWNリプレイスプロジェクトやリニューアル時にやりたかったことに関連して、いくつか解決したい課題がありました。その中でもとくに大きなものとして次の2つがありました。

通信量の増大

1つ目はクライアントからの通信量の増大です。連載第1回(本誌2024年5月号)でお伝えしているとおり、ZOZOTOWNリプレイスプロジェクトでは、VBScriptのモノリスなアプリケーションからGoやJavaを使ったマイクロサービスへ移行を進めていました。これまでのモノリスなアプリケーションとして動いていたところからマイクロサービスへ機能を切り出していくと、内部の機能の呼び出しだったところが各マイクロサービスに対しての通信へ置き換わることとなります。

このマイクロサービスの呼び出しに伴う通信を、そのままブラウザやスマートフォンアプリといったクライアントから直接行おうとすると、サーバとの通信回数が何倍にも増えてしまいます。もし1回のサーバとの通信で必要な情報が集められない場合は、複数回同じサーバに通信する必要が出てきてしまいます。とくにスマートフォンの場合は、通信回数が増えることで電池の消費も増えてしまうのでより大きな問題となります。

パーソナライズ機能の追加

課題の2つ目は、ユーザーの性別や年齢、お気に入り情報などからコンテンツの内容を変化させるパーソナライズ機能の追加です。ZOZOTOWNを訪れるユーザーの興味や関心はさまざまなので、それぞれのユーザーの好みに合わせたコンテンツを表示することで、より便利に使ってもらえることを目指しています。

リニューアル時のパーソナライズ機能の追加には、表示する条件の変更が柔軟に行えるようにしくみを整えることも含まれていました。日常的なサービスの運営として、パーソナライズ機能で表示するコンテンツの種類や数を、キャンペーンやセール、季節などに合わせて調整できるようにする計画だったため、処理をクライアント側に実装するわけにはいきませんでした。もしクライアント側に実装すると、変更のたびにリリースが必要になってしまい、柔軟な変更という部分が損なわれてしまいます。こちらも1つ目の課題と同様に、パーソナライズの条件の変更のたびにスマートフォンのアプリをアップデートするのは現実的ではないので、避けなければなりませんでした。

BFFによる解決

1つ目の課題は、クライアントと各マイクロサービスの間にZOZO Aggregation APIというBFFを配置して、通信量を抑えることで解決しました(図1)。クライアントへのレスポンスも、重複した内容を削りながら必要としている情報を整理してレスポンスできるようになっています。また今後さらに必要なマイクロサービスが増えたとしても、ZOZO Aggregation API内でレスポンスを1つにまとめられるので、クライアント側の通信に大きな影響を与えずに済みます。もちろん表示するコンテンツを増やした場合はレスポンスのサイズも増えますが、クライアントの通信回数を増やした場合よりも抑えられると考えています。

図1 クライアントとマイクロサービスの間にZOZO Aggregation APIを配置

そして2つ目の課題は、サービスを「表示するコンテンツを選択するサービス(推薦サービス)」と「コンテンツの中身を提供するサービス」に分けて、ZOZO Aggregation APIからそれぞれのサービスを順に呼び出すことで解決しました。推薦サービスを独立させることによってクライアントの中に処理を持たせないという仕様はクリアできました。しかし、各サービスをクライアントから直接呼び出してしまうと通信量の増大という課題が残ってしまうことになるので、リニューアルのタイミングでBFFを入れる判断をしました。追加したパーソナライズ機能は、ユーザーごとに表示するコンテンツの種類や数の調整を推薦サービスが行い、その結果をもとにZOZO Aggregation APIがコンテンツに必要な情報を各マイクロサービスから集めて、最後にクライアントが必要としている形に整形してレスポンスする流れになっています(図2)。表示するコンテンツを変更したい場合も、ZOZO Aggregation APIと推薦サービスの間で調整すればよく、クライアント側が意識する必要はほぼなくなっています。

図2 ZOZO Aggregation APIによるパーソナライズ機能の実現

アーキテクチャの説明

BFFをマイクロサービスとして構築

ZOZOTOWNではAPIゲートウェイパターンのアーキテクチャを採用しており、認証認可やカナリアリリース機能を備える高機能なZOZO API Gatewayを内製しています。このZOZO API Gatewayを軸にZOZOTOWNのシステムは構成されています。

そこで、ZOZOTOWNトップページの表示内容を生成するAPIとしてZOZO Aggregation APIをZOZO API Gateway配下の1マイクロサービスとして設置し、リクエストもZOZO API Gatewayを経由してルーティングする形で構築しました。

BFF構成にて見えてきた課題

ZOZO Aggregation API導入後のシステム要件を整理する中で、各マイクロサービスの最大負荷が設計当初の想定以上に高いことが判明しました。

リニューアル後のトップページでは、ZOZOTOWNを利用する各ユーザーに対して趣味、嗜好に合わせた魅力的な商品をリーチするために今まで以上に多くのデータを使ってパーソナライズを行っています。そのため各マイクロサービスへのリクエストがリニューアル前に比べて格段に増加していました。それに加えて「ZOZOWEEK」等の大規模セール時は通常時と比較して圧倒的にユニークユーザー数が多くなるので、スパイクを考慮すると各マイクロサービスへのリクエストがリニューアル前の数倍以上になる可能性が出てきました。

リニューアル後の大規模セール時に発生するスパイクをシミュレーションすると、既存の各マイクロサービス構成では負荷に耐えられないことがわかりました。各マイクロサービスが耐えきれずレスポンスが遅延して、ZOZO Aggregation APIのレスポンスも遅延すると、トップページ生成時間が長くなるのでZOZOTOWNでの体験を著しく損なってしまいます。

キャッシュの導入

この問題の対策として、各マイクロサービスの増設、もしくはキャッシュの導入を検討しました。前者の増設による対策の場合、すべてのマイクロサービスをイベントごとに増設する必要があり、そのための工数や維持費用が膨れ上がり現実的な解決策とは言えませんでした。そこで、後者のキャッシュによる解決策を中心に検討を進めていきました。

レスポンスの遅延はトップページにおけるアクセス増が原因なので、AkamaiやFastlyといったCDNを用いたキャッシュによる負荷軽減策を模索しました。しかし、パーソナライズを実現するにあたり、多種多様なデータの組み合わせを想定しているため、ユーザーごとに表示される内容に差異が多くなる仕様になっていました。したがって、ZOZO Aggregation APIにて集約した後のページをキャッシュするCDNのような方式は負荷対策として効果的ではありませんでした。

Redisをキャッシュに使う

そこで、トップページ生成に必要なデータを細かくキャッシュする方式を検討しました。ZOZO Aggregation APIではパーソナライズ条件に基づいて取得したデータをモジュール(部品)として扱っており、モジュールを組み合わせてトップページを生成しています。モジュール単位であれば、同一条件下でのレスポンスデータ生成においてキャッシュが利用できます。そのため、必要なリクエストをすべてマイクロサービスへ送るのではなく、マイクロサービスから取得したデータをモジュール単位でキャッシュするシステム構成に変更しました。

具体的には、マイクロサービスへ接続するときのURLとパラメーターをキーに、マイクロサービスから実際に取得できるレスポンスを値としてAmazon ElastiCache(Redis)に保存できるようにZOZO Aggregation APIを改修しました(図3)。マイクロサービスにリクエストを送る代わりにRedisからキャッシュを取得することで、直接リクエストする回数を減らして負荷の軽減を図る目的です。

図3 モジュール単位のキャッシュ

これにより、もう一度同じ条件のモジュールを取得する場合は先にRedisを参照することで、マイクロサービスに接続する回数を減らせました。キャッシュを導入した効果はすばらしく、インフラの増強を最小限に抑えることができました。

キャッシュを導入したことによる新たな問題

キャッシュを使うことでコストの問題は解決できましたが、ZOZOTOWNの商品情報は随時更新されていくので、いつまでも同じキャッシュを使い続けることはできません。ZOZOTOWNでは毎日午前0時にクーポンを切り替えているため、少なくとも1日に1回はキャッシュに保存した商品情報を更新する必要があります。実際はクーポンの切り替え以外でも、価格や説明など商品の情報は1日に複数回更新される場合があります。

一般的に、キャッシュを保存するときは有効期限を設けて、期限が来たら自然に消えていくように設計することが多いと思います。この場合、キャッシュの有効期限が切れたタイミングで、瞬間的にマイクロサービスへリクエストが殺到することになります(図4)。せっかく負荷を減らしたにもかかわらず、マイクロサービスへの負荷が一気に増大してしまうということです。この現象は一般的にCache Stampede(キャッシュスタンピード)、Dog piling(ドッグパイル)などの名称で呼ばれています。本記事では当時社内で利用していたキャッシュスタンピードの名称を使用します。

図4 キャッシュが参照できないときにマイクロサービスヘリクエストが殺到する

キャッシュスタンピードの対策

キャッシュスタンピードを防ぐ代表的な方法は3つあります。

  1. 次の有効期限に参照するキャッシュを事前に作る
  2. 有効期限が切れる前に延長する
  3. ロックを使って1プロセスだけオリジンから取得する

当時は未来の商品公開情報を生成するしくみが存在していなかったため、1の方法は採用できませんでした。また2の方法は、キャッシュ有効期限は延長されるものの保存している内容がそのまま残るため、商品情報を更新したいという要件には合いませんでした。結果として、残った3の方法を選択しました。

ZOZO Aggregation APIはKubernetes上で動作しているので、単純にアプリケーション内部でロックを取得しただけではほかのPodとはロックを共有できません。そのため、RedisのSETコマンドにNXオプションを付与して、Redis上でロックを取得することにしました。

NXオプションはキーが存在しない場合のみ値を設定して、キーが存在する場合は何もせず失敗します。ロックを取得できた場合はオリジンから商品情報を取得してキャッシュの更新処理を行い、ロックを取得できなかった場合はキャッシュが更新されるのを一定時間待つようにしています。これによりキャッシュスタンピードを防ぎつつ、キャッシュ更新時の遅延も抑えながら安定してレスポンスを返すことができています。

サービスの可用性

BFFは可用性が大事

BFFはフロントエンドからリクエストを受け付けるため、BFFに障害が発生するとサービス全体に直結しやすい傾向にあります。つまり、BFFはサービス可用性を考えるうえで重要なポイントです。

ZOZO Aggregation APIに関しても、BFFとして設計を進めていくうえで障害時のシナリオをシミュレーションしたところ、大きな課題を発見しました。ZOZO Aggregation APIは複数のマイクロサービスからモジュールとして商品のデータを取得する必要があるため、初期の設計では、いずれかのマイクロサービスに障害が発生した際に引きずられてカスケード障害が発生することが懸念されました。しかし、ZOZO Aggregation APIは初期の設計でも3つ以上のマイクロサービスと通信してトップページに必要なモジュールを生成していたので、ZOZO Aggregation APIとその3つのマイクロサービスがすべて正常に動作することが、正常にトップページを生成する条件となっています。そのため、可用性の低いシステムになっていました。

BFF導入後のアーキテクチャでZOZOTOWNの可用性を担保するにはこの課題の対策が必須となりました。

各マイクロサービスに依存しないしくみ

そこでZOZO Aggregation APIでは、いずれかのマイクロサービスにて障害が発生した場合は、取得できた情報とデフォルトとして定義された情報を組み合わせたモジュールを生成してレスポンスを行う仕様にしました。タイムアウトとリトライ制御を各マイクロサービスに設定しておき、マイクロサービスが規定の時間内に正常なレスポンスを返さない場合はほかのマイクロサービスから取得できたデータとデフォルト定義されたデータにてモジュールを生成します。

実際に運用が始まると、障害の際にマイクロサービスに障害が発生して一部のデータを取得できない状態になりました。しかし本仕様のおかげでZOZO Aggregation APIは障害にならず、ZOZOTOWNのトップページを表示し続けることができました。

運用時に見つけた課題

ZOZO Aggregation APIにはリリース後もさまざまな機能が追加されており、マイクロサービスの通信先もリリース時と比べて増えている状態でした。リリース初期は同じKubernetesクラスター内のマイクロサービスとの通信がほとんどでしたが、社内の別環境にあるAPIや社外のAPIからデータを取得して生成するモジュールも出てきました。通信先が増えてもZOZO Aggregation APIにて各マイクロサービスの障害に引きずられないしくみを導入しているので安心していましたが、障害発生時に挙動を確認した際に気になる点がありました。

先の仕様ではZOZO Aggregation APIから各マイクロサービスに対してタイムアウトとリトライ制御を使って障害判定をしていたため、障害発生時に200を返すことによりレスポンスタイムの悪化が発生していました。仮にマイクロサービスにて10分間障害が発生するとZOZO Aggregation APIは「レスポンスは遅延しているが200を返す」状態で10分間動作し続けていることになります。マイクロサービスの障害に引きずられないしくみを導入したのはZOZOTOWNのユーザー体験を損なわないことが目的ですが、この状態はユーザー体験が良いとは言えないので対策することになりました。

ZOZO Aggregation APIでは各マイクロサービス間との通信におけるタイムアウトとリトライ制御にIstioを利用しているため、Istioを活用して対応する方法がないかを検討しました。Istioを調査する中でサーキットブレーカー機能があるとわかり、ZOZO Aggregation APIと各マイクロサービスとの通信にサーキットブレーカーを導入することで障害発生時のレスポンスを改善できるのではと考えました。

サーキットブレーカーの導入

サーキットブレーカーとは、あるサービスの障害を検知した場合には通信を遮断、その後サービスの復旧を検知すると通信を復旧させるしくみです。サーキットブレーカーを導入することで、各マイクロサービスに障害が発生した際にサーキットブレーカーがそれを検知し、ZOZO Aggregation APIと該当マイクロサービスとの通信を即座に遮断します。遮断されている状態ではZOZO Aggregation APIが該当マイクロサービスに通信をすると即座にエラーレスポンスが返るので、先のしくみにより取得できたデータからのレスポンスデータをもとにモジュールを生成します。マイクロサービスの障害が収束するとサーキットブレーカーがそれを検知してZOZO Aggregation APIとの通信を復旧させます(図5)。

図5 サーキットプレーカー導入による変化

サーキットブレーカーを導入したことで、ZOZO Aggregation APIは障害発生時でも都度タイムアウトを待たずにレスポンスを返せるようになりました。また先のしくみと合わせることで、特定のマイクロサービスに障害が発生したとしてもユーザー体験を損なわないシステムになり、BFFとして信頼性が高い状態となりました。

意図しているエラー条件

このように可用性担保のためさまざまな対策が行われたZOZO Aggregation APIですが、1つだけ可用性を考慮せず、意図して500エラーをレスポンスする条件があります。それは「キャッシュから正常なデータを取得できない状態」です。

ZOZO Aggregation APIはキャッシュを導入することで各マイクロサービスの負荷軽減を行っています。キャッシュからデータが取得できない場合に、各マイクロサービスから直接データを取得する挙動だと、セールなどの高負荷時に対象マイクロサービスがダウンする可能性があります。ZOZO Aggregation APIのキャッシュに障害が発生したことで各マイクロサービスが高負荷になり、ZOZOTOWNの別機能に影響が出るという事態は防がなければいけません。そこで、キャッシュにて障害が発生した場合は、ZOZO Aggregation APIにて500エラーを返して各マイクロサービスと通信をしない仕様にしています。

なおキャッシュ障害を検知した場合は、予備で用意しているキャッシュに通信先を変更することで迅速な復旧ができるようにしています。

BFFにおける障害試験の重要性

前述のとおりBFFは可用性がとても重要なので、障害時の動作を把握するために、障害試験にはかなり注力しています。サーキットブレーカー導入時はもちろんのこと、新たな通信先が追加されるたびに、さまざまなエンドポイントにて障害発生時の動作を確認しています。

ZOZO Aggregation API自体の障害発生シミュレーションは当然行いますが、外部サービスも含めて多種多様なマイクロサービスと通信するため「各マイクロサービスがダウンした場合の挙動」を定義しておき、障害試験で想定通りのレスポンスが返答されるか確認することが大事です。Istioを使ってZOZO Aggregation APIと各マイクロサービス間の通信に遅延を発生させて、サーキットブレーカーの発動と発動後のレスポンス内容が想定どおりになっているかはリリース前にチェックしています。

これらのチェックを行っているため、ZOZO Aggregation APIはリリースから今年で3年が経過しているにもかかわらず、安定した運用を続けられています。

BFFのこれまでとこれから

ZOZOでは、BFFアーキテクチャの国内での実例がまだ少なかった2021年から、ZOZO Aggregation APIを構築して運用を続けてきました。運用していく中でキャッシュスタンピードをはじめとしたさまざまな課題が見つかりましたが、開発者とSREが一丸となって改善を続けてきました。結果を見れば、この3年間におけるZOZOTOWNの安定性にZOZO Aggregation APIは大きく貢献しており、当初想定していたアーキテクチャのメリットを享受できています。

リリース当初はZOZOTOWNトップページの表示内容を生成するAPIでしたが、現在はトップページだけではなくカート画面や検索画面等に表示するデータも扱う、ZOZOTOWNにおける中核を担うAPIとなりました。req/sやキャッシュの使用量も含めて右肩上がりになっており、用途はこれからも増えていく予定のため、今後の増強も予定しています。

また、3年の運用でZOZO Aggregation APIに機能が増えてきたことで、さまざまな課題が見えてきました。たとえば、さまざまな機能が追加されてロジックに複雑さが出てきたことや、関係者が増えたことによりコミュニケーションコストも増えてきたこと、マイクロサービスとBFFの責務があいまいになっている部分があることなどが挙げられます。これらの問題に対応するために、機能ごとにBFFとしての機能を分割する案や、デバイス別に分割する案といったさまざまな角度からこれからのZOZO Aggregation APIについて議論を進めています。今後のZOZOにおけるBFFの方針が決まった際にはテックブログ等で公開したいと思っています。

おわりに

連載第6回では2021年に導入したZOZOのBFFであるZOZO Aggregation APIについて、導入により発生したメリットや、運用上の課題、今後の展望について紹介しました。

BFFアーキテクチャのひとつの形としてBFFの導入を検討している方の参考になればうれしく思います。


本記事は、技術本部 SRE部フロントSREブロック ブロック長の三神 拓哉と同 ECプラットフォーム部マイグレーションブロックの藤本 拓也によって執筆されました。

本記事の初出は、Software Design 2024年10月号 連載「レガシーシステム攻略のプロセス」の第6回「ZOZOTOWNにおけるBFFアーキテクチャ実装」です。


ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com

カテゴリー