はじめに ZOZOTOWN開発本部の武井と申します。ZOZOTOWNのフロントエンドリプレイスプロジェクトを主に担当しております。 ZOZO DEVELOPERS BLOG でも「 ZOZOのリプレイスプロジェクトで得られる唯一無二の経験。大規模サービスを進化させるやりがいとは 」というインタビュー記事を掲載しておりますので、もしよろしければこちらも併せてご覧ください。
さて、本題です。現在ZOZOTOWNではオンプレミスかつ、モノリスだった既存システムをマイクロサービスAPIに責務を分割したり、インフラをクラウドに移行したりしています。しかし、いわゆるWebのUIを構築するためのシステムは現在も既存システムに新機能開発や機能改修を行なっており、リプレイスに着手できていませんでした。
そこで、まずホーム画面から段階的にリプレイスすべく設計・開発を昨年から行ない、無事リリースできました。 ZOZOTOWN のソースファイルを見ると Next.js で提供されていることがフロントエンドエンジニアの方にはお分かりいただけると思います。
本記事では、ホーム画面のリプレイスをどのようなシステム構成で実現したのかの事例と、Next.jsのアプリケーションをプロダクションレディにするナレッジや設定内容の一部などをご紹介します。
目次 はじめに 目次 背景 セッションオフロード カナリアリリース フロントエンドリプレイスPhase1 システム構成 Next.jsのアプリケーションをプロダクションレディにするナレッジ 要件を実現するためのレンダリング選定 Next.jsの性能試験でレンダリングとスループットの関係性を調査 CDNキャッシュを用いた最適化 URLに対して複数のキャッシュを作成する カスタムサーバーでルーティングのカスタマイズとロギングの実現 Sentryでのエラーログ集積とソースマップのアップロード ソースマップアップロードを可能にするDockerイメージ作成 効果と今後の課題 まとめ 背景 ZOZOTOWNのリプレイスは開発効率を上げる、運用コストを下げる、人材獲得の強化を目的として掲げています。その手段としてAPIのマイクロサービス化をしています。これと同時並行でフロントエンドに新フレームワークを導入しリプレイスする計画がありました。 過去の弊社瀬尾の発表資料 では下記の図で示され、数年前から検討はされていました。
歴史が長く、アクセス数の多いサービスを、稼働させたままリプレイスするのは一筋縄ではいきません。さらに、我々は既存のサービスの成長を止めずに、リプレイスする方針で取り組んでいます。こうしたリプレイスを実現するベースを構築する必要がありました。ベースの構築にどのような困難があったのか、セッションオフロードとカナリアリリースを例にあげて紹介します。
セッションオフロード サービスを止めないリプレイスを実現するために、 ストラングラーパターン というレガシーシステムを徐々にモダナイズするためのアーキテクチャパターンを採用しています。具体的にはリプレイスしたパスへのリクエストはモダンシステムに、置き換え前のパスはレガシーシステムにパスルーティングします。最終的には、リプレイス前のシステムへのパスルーティングはなくなり、リプレイス完了となります。
フロントエンドのHTMLの配信においても前述したパスルーティングを用いて、パスごとに段階的なリリースを計画していました。しかし、このパスルーティングを実現できない事情が既存システムにはありました。既存システムはIISのユーザーセッション機能を利用しており、セッションがWebサーバーに紐づいています。つまり、ユーザーはセッションが続いている限り以前接続したサーバーに接続されます。いわゆるスティッキーセッションです。これでは、パスルーティングを機能させることができません。この問題を解消するために、セッション情報をAmazon ElastiCache for Redisにオフロードする取り組みなどが必要でした。セッションをオフロードすることで、サーバーとセッションが分離でき、ストラングラーパターンによる置き換えが可能になりました。
セッションオフロードの詳細は、杉山の記事をご参照ください。
カナリアリリース ZOZOTOWNはUIや機能改修によってビジネス指標に大きく影響が生じるサイトです。これを考慮し、UIや機能要件はリプレイス前と可能な限り互換性を維持し、挙動は変えないという方針を定めています。リリース前に念入りにQAテストを実施しますが、リリースでの不具合の発生や他システムに影響を及ぼすリスクは存在します。このリスクを軽減するために、一部のユーザーだけに絞り新システムを提供し、段階的にリリースするカナリアリリースを実施しています。このカナリアリリースについてはAkamai Application Load Balancerの加重ルーティングという機能を利用して実現しています。
加重ルーティングの詳細は、秋田の記事をご参照ください。
これらの例以外にも、リプレイスサービスを構築するために CI/CD戦略 、 BFF API 、 サービスメッシュ 、 プログレッシブデリバリー などの施策を実施しベースが整ってきました。こうした背景から満を持してフロントエンドのリプレイスが始動しました。
リプレイスは、全く別のものに一気に刷新するのではなく、このようにサービスを構築するためのベースを構築し、それぞれの機能ごとにマイルストーンを設定し段階的に置き換えていくことが有効です。
フロントエンドリプレイスPhase1 リプレイスをどのページから着手するか検討した結果、ホーム画面を選択しました。理由は下記の通りです。
ホーム画面で利用しているAPIは、大部分がBFF(Backend For Frontend)から提供されており、レガシーシステムへの依存が比較的少ない アクセス数や機能が多く、開発や運用のナレッジを蓄積しやすい サービスの象徴的ページであり、開発のモチベーションが湧きやすい さらにリプレイスの付加価値としてSPA(Single Page Application)化することでページ遷移を高速にし、UXの向上を考えました。具体的にはホーム画面では、下記の図のような商品のカテゴリーや性別を切り替えるタブUIがあります。
このタブUIでのページ遷移時にページ全体を読み込まず、商品データだけを動的に切り替えるSPAを実装しました。
次にフロントエンドフレームワークについてです。フレームワークはNext.jsを採用しました。選定理由は下記の通りです。
Reactベースのフレームワーク ゼロコンフィグで開発を始められる ページごとのレンダリング手法を柔軟に切り替えるができる 数々のパフォーマンス最適化など新機能が毎年リリースされており、とてもアクティブに開発されている 利用している開発者が多く、コミュニティーが盛んでWebに情報が多い HeadlessCMSと相性が良い Next.js以外の新たに導入したライブラリを紹介します。
それぞれの選定意図については記事の本題ではないため紹介のみとします。Emotionの選定意図は、菊地の記事をご覧ください。
これらの新フレームワークや新技術の導入とインフラを構築することで、フロントエンドリプレイスの礎を作るマイルストーンを社内ではフロントエンドリプレイスPhase1と呼んでいます。以降も複数のマイルストーンをおき、2024年を目処にフロントエンドのリプレイスをすべて完了させる計画です。
システム構成 リプレイスに際して構築したシステムは下記のような構成です。
まずCDNを経由してユーザーのブラウザにコンテンツが配信されます。弊社ではCDNにAkamaiを採用しております。このAkamaiでは、(1)キャッシュ、パスルーティングとあるように、コンテンツのキャッシュや、ユーザーのリクエストパスから適切なサービスにルーティングをするパスルーティングなどを行なっています。具体的にはホーム画面のパスをリプレイス後のシステムへ、ホーム画面以外のパスは既存システムにリクエストをルーティングしています。
次にリプレイス後のパブリッククラウドのシステムですが、AWS上に構築しており、コンテナアプリ基盤にマネージドKubernetesサービスであるEKSを採用しています。また、複数サービスを単一Kubernetesクラスタで稼働させる、いわゆるマルチテナントクラスタ方式です。このクラスタにマイクロサービス群と、BFF API、そして今回新設したNext.jsのSSRを実行するサーバーが稼働しています。
最後に(2)データ取得、セッション共有とあるように、リプレイス後のシステムと既存オンプレミスのIISサーバーとセッションデータ共有や、まだリプレイスが完了していないデータストアからデータ取得を可能にしています。これによりあらゆる機能要件を満たすことができます。
なお、実際には認証サービスや、APIルーティングを行うAPI Gatewayなどのサービスとも通信していますが、ここでは省略しております。
以上がシステムの全体像です。
Next.jsのアプリケーションをプロダクションレディにするナレッジ Next.jsの機能はシンプルなため、Reactを使ったプログラミングに習熟していれば、スムーズに開発を進めることができました。Web上に開発に関するナレッジが多く集積されている点が大きな要因と思われます。一方で、Next.jsのアプリケーションのサーバー負荷への考慮、ロギングやエラーハンドリングなどのプロダクションレディにするための情報がWeb上に少ないように感じました。なのでここからはそれらのナレッジについて紹介したいと思います。
要件を実現するためのレンダリング選定 Next.jsのアプリケーションにおいて、SSR(Server Side Rendering)するか否かというのはとても重要な決断です。 アプリケーションの性質や要件によれば、SSRせずSG(Static Generation)やCSR(Client Side Rendering)も可能です。その場合は静的ファイルを配信するのみとなりインフラの管理コストは低くなります。
一方、SSRする場合はNode.jsの実行環境を必要とするため、アプリケーションを監視するエンジニアのオンコール体制の構築、サーバーコスト、パフォーマンス的な懸念等々の管理コストが発生してしまいます。可能であればSSRしたくはありませんが、下記のような機能要件やSEOを考慮してSSRすることは不可避でした。
メタタグにブランド数、商品名、OGP画像などの動的データを含めたい ファーストビューに表示されるUIはローディングなど挟まず表示したい セールやキャンペーンの開始や終了のタイミングに合わせて時限式に切り替わるUIを提供したい 3の要件についてクライアントサイドのJavaScriptで、時限式に切り替わる実装をする選択もあります。しかし、クライアントのJavaScriptはソースファイルが公開されます。そのため将来のキャンペーンやセール情報が露見してしまう可能性があります。したがって、クライアントではなくSSRするという結論になりました。
Next.jsの性能試験でレンダリングとスループットの関係性を調査 GoやJavaのAPIサーバーは運用実績があり、性質や運用についてのノウハウがあります。一方でNode.jsを運用するのは初めての試みでした。加えてNode.jsはシングルスレッドのランタイム環境という特性があります。そのため、CPUバウンドなタスクを実行する場合、サーバー処理をブロックしてしまいパフォーマンス低下の可能性があります。具体的には、SSRの処理がCPUバウンドな処理で知られており 5 、この事象が起きてしまえばインフラコストが高くついてしまうことや、パフォーマンス要件を満たせない懸念があると考えました。本番にリリースしてからパフォーマンス要件が満たせないことになれば問題ですので、Next.jsアプリケーションの性能試験を実施しました。
を実行しているためです。性能試験は、 Gatling Operator というツールを用いて、本番に近いインフラやサーバーをセットアップし、リクエストを送りその結果をモニタリングして計測します。パフォーマンス要件の基準は Lighthouse の TTFB の基準値 を参考に600ms 以内とし、この状態で秒間どの程度のリクエストを捌けるかスループットも計測します。SSRするコンポーネントの規模によって、パフォーマンスやスループットの目処をつけておきたかったため3パターン実装しました。スペックのcore数が2core以上なのはNext.jsアプリケーション以外にもサービスメッシュとしてistio proxy
結果は下記のとおりです。
高負荷の場合はパフォーマンス基準を安定的に満たし、スループットは 5req/sec と効率が悪い結果となりました。やはり前述した通り、CPUバウンドなSSRになってしまうとインフラのコストパフォーマンスは悪くなりそうです。しかし、中程度の負荷であれば、スループットも性能はまずまずという結果も得られました。この結果から、負荷を考慮したSSR実装をすることに加え、負荷増加が考えられるリリースをする際には負荷試験を行ない事前に検知するなど対策すればスケーラブルに運用できるという判断をしました。以上の性能試験から、SSRという選択肢ありきで安心して開発に着手できました。
CDNキャッシュを用いた最適化 性能試験の結果から、Node.jsをスケーラブルに運用できることが分かりました。しかしながら、性能は常に最適な状態に保つことが望ましいため、CDNでキャッシュを使用することでパフォーマンスを向上し、コスト削減を実現できます。HTMLをSSRした結果を一定期間CDNでキャッシュすることにより、オリジンサーバー上でNode.jsサーバーの負荷を大幅に軽減できます。ただし、パフォーマンス要件を満たすためにキャッシュを有効にできない場合もあります。ホーム画面の要件に関してはキャッシュが可能であるため、キャッシュを有効にしています。具体的には、下記のようにレスポンスヘッダーの Cache-Control を使用して、キャッシュの保持期間を制御できます。
Cache-Control: s-maxage=seconds
検証の際には、時間の文字列をHTMLに埋め込んでおくと、キャッシュできているか検証しやすいので、実装しておくのがおすすめです。Next.jsでは下記のように書けます。
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
export default function Index({ time }: InferGetServerSidePropsType<typeof getServerSideProps>) {
return (
<script
type="application/json"
data-type="cacheTimeDisplay"
data-time={time}
/>
)
}
export const getServerSideProps: GetServerSideProps<{ time: string }> = async ({
res,
}) => {
const second = '10'
res.setHeader(
'Cache-Control',
`s-maxage=${second}`
)
return {
props: {
time: new Date().toISOString(),
}
}
}
このようにしてレスポンスヘッダーをページごとに異なる時間を設定することで、キャッシュを最適化していくことができます。ただし、CDNキャッシュを利用する際には注意が必要です。特に、SSRするHTMLには個人を特定できるようなパーソナルな情報を含めないようにすることが重要です。ユーザーごとに異なるパーソナル情報はAPIから取得し、クライアントサイドでレンダリングするようにする必要があります。パーソナル情報をSSRしてしまうと、CDNキャッシュを利用できなくなってしまうためです。もし誤ってパーソナル情報をキャッシュさせてしまった場合、重大な情報漏えいが起こる可能性もあるため、キャッシュを用いる際には慎重に実装する必要があります。
URLに対して複数のキャッシュを作成する 通常、1つのURLに対して1つのキャッシュが作成されますが、 Cache ID Modification という機能を使うことで、複数のキャッシュを1つのURLに対して作成できます。例えば、ホーム画面にはカルーセルバナーがあり、このバナーは指定なし、レディース、メンズ、キッズの4つのパターンがあります。
これをSSRするには、4つのキャッシュを作成する必要があります。これを実現するために、Cache ID Modification機能を使用しています。Cache IDは、CDNの管理画面で設定でき、Cookieやリクエストヘッダーなどを指定できます。この場合、Cookieに性別を保存し、このCookieをCache IDに設定しました。これにより、4つの異なるキャッシュが作成され、適切なカルーセルバナーがSSRされます。
カスタムサーバーでルーティングのカスタマイズとロギングの実現 zozo.jpのホーム画面はデスクトップ向けには https://zozo.jp/ 、モバイルでサイト https://zozo.jp/sp/ とURLが異なります。そのため、モバイルデバイスで https://zozo.jp/ にアクセスした場合は、 https://zozo.jp/sp/ にリダイレクトされるような実装が入っています。例えばこのようなルーティングをカスタマイズしたい場合に利用できるのが カスタムサーバー という機能です。この機能を使えばNode.jsサーバーのモジュールとしてNext.jsを利用できます。Node.jsの組み込みモジュールでも実装は可能ですが、Webフレームワークの Fastify を利用しました。理由はパフォーマンスの良さ、TypeScriptとの相性、ロギングのしやすさなどです。
Fastifyを利用する場合Next.jsカスタムサーバーは下記のように書けます。
続きは こちら