TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

988

はじめに こんにちは。計測プラットフォーム開発本部バックエンドチームの岡山です。普段はZOZOMATやZOZOGLASSなどの計測技術に関わるシステムの開発、運用に携わっています。去年の夏にZOZOFITというサービスを北米向けにローンチし、そのシステムも同様に開発、運用に携わっています。 本記事では、ZOZOFITの認証フローで実行されるScala実装のAWS Lambda関数が抱えていたパフォーマンス課題と、その課題の解決に至るまでの取り組みについてご紹介します。 目次 はじめに 目次 ZOZOFITとは ZOZOFITが利用する認証サービス カスタム認証フローとは パフォーマンスに関する課題 カスタム認証フローにおけるボトルネックの特定 Lambda関数のボトルネック調査 Lambda関数のメモリ設定最適化 パフォーマンス改善結果 終わりに ZOZOFITとは ZOZOFITは2022年に発表した体型管理を目的としたフィットネスアプリです。ZOZOSUITの計測技術を利用したサービスであり、2023年3月時点では、体型計測および身体3Dモデルのデータ・体脂肪率の表示機能を提供しています。 ZOZOFITが利用する認証サービス ZOZOFITでは以下の理由により、認証処理の実装にAmazon Cognitoを利用しています。 認証・認可の機構を時間をかけて実装するよりもコアとなる機能の実装に時間を使いたかった 普段から利用しているAWSの他サービスとの親和性が高かった また、サービス要件に対応するためAmazon Cognitoが提供しているカスタム認証フローを利用しました。 カスタム認証フローとは Amazon Cognitoではユーザーを認証するフローが複数用意されています。カスタム認証フローはその中の1つのフローです。カスタム認証フローを利用することで、AWS Lambdaトリガーを利用し認証フローをカスタマイズ可能です。 カスタム認証フローの詳細についてはAmazon Cognitoのドキュメントをご覧ください。 docs.aws.amazon.com パフォーマンスに関する課題 私たちのチームではDatadogを利用して定期的にレイテンシを振り返っており、その活動の中でサインアップに関連するエンドポイントのパフォーマンスが悪いことを知りました。原因の深掘りのためにAPMを利用して調査しました。結果として、サインアップ時に利用される処理のエンドポイントの大半が特定の処理に偏っていることが分かりました。下記は実際のトレースの一例です。Amazon CognitoのInitiateAuth APIの処理がdurationの大半を占めています。 問題となっているエンドポイントの詳細について説明します。下図は簡略化したZOZOFITの認証フローの図で、赤色で示す箇所がパフォーマンスに課題のあるエンドポイントの処理です。まず初めにユーザーはZOZOFITを利用するためにEメールアドレスを入力し認証を開始します。そして、APIサーバーはクライアントからの呼び出しを受けて、Amazon CognitoのInitiateAuth APIを呼び出します。Amazon CognitoはInitiateAuth APIへのリクエストを受けて、2つのAWS Lambda関数を同期的に呼び出します。APIサーバーはLambdaの処理が完了したのを受けてクライアントにレスポンスを返します。 このフローの特徴として、Create Auth Challenge Lambdaの実行時に、AWS SDKを使用してAmazon SESで確認コードを含んだEメールをユーザーへ同期的に送信しています。 上記のエンドポイントは90パーセンタイルでも 4640.8ms のレスポンスタイムでした。私たちのチームでは、ZOZOFITのローンチ前にEメールを同期的に送信する意思決定をしていました。しかし、そのことを加味しても想定外の数値であり、改善する必要があると考えました。 認証フローの図を見ると分かるようにAmazon CognitoのInitiate Auth APIをリクエストすると、2つのAWS Lambda関数が実行されます。この時Amazon CognitoはLambda関数を同期的に呼び出します。よって、これらLambda関数の実行時間がエンドポイントのパフォーマンスに直接影響を与えていると仮説を立てて調査を進めました。 カスタム認証フローにおけるボトルネックの特定 次に、問題となっていたAWS Lambda関数のパフォーマンス調査をしました。 問題となっていたエンドポイントで実行されるAWS Lambda関数は2つあったため、問題の切り分けのためにDatadogを利用して関数の実行時間の確認をしました。 2つのAWS Lambda関数の実行時間の平均を比較すると、Create Auth Challenge Lambdaはもう1つのLambda関数より 2092ms 長いことが判明しました。 Lambda関数のボトルネック調査 次に、Create Auth Challenge Lambda関数をトレースし、処理のボトルネックを明らかにすることを考えました。このLambda関数はScalaで実装しており、GraalVM Native Imageで実行ファイルにコンパイルし、Lambdaのカスタムランタイム上で関数を実行しています。今回はそのような状況に適したトレーシングツールを発見できず、処理時間をログ出力する方法でボトルネックを探りました。 Lambda関数をScalaで実装した背景は、チームで2つ以上の言語を維持運用するほどのチーム規模もないため、普段使い慣れているScalaを選択したことにあります。 背景の詳細については、以下記事の計測システム部児島が記載した内容を参考にしてください。 AWS re:Invent 2022 参加レポート(ラスベガスの写真と厳選したセッション情報をお届けします!) 調査した結果、ボトルネックとなっていた処理はAWS SDKを使いAmazon SESで確認コードを含んだEメールをユーザーへ送信する処理でした。この処理に必要な時間はAWS Lambda関数全体の実行時間の 約91.5% を占めていました。このLambda関数のメイン処理は上述のAWS SDKを使ったEメール送信であったため、この調査結果は想定範囲内でした。 次に、cold startとwarm startでの実行時間の差分を調査しました。一般的にcold start無し(いわゆるwarm start)の場合、Lambdaの実行環境やAWS SDKクライアントなどが再利用されるため実行時間の短縮を期待できます。下記の図は調査結果を表した図となります。 warm startの場合に処理時間が平均1921ms短縮されることを確認しました。 上記の調査結果から、実際のボトルネックはAWS SDKクライアントの初期化処理などのEメールを送信するための準備処理であると仮説を立てることが出来ました。 ここまでの調査結果を踏まえ、以下の解決策が考えられました。 Lambda関数がwarm startで実行される状態を増やす Lambda関数がcold startで実行される場合でも高速に動作するよう修正 (1)の具体的な解決策としては、 Provisioned Concurrency の設定があります。ZOZOFITはローンチされたばかりのサービスでユーザー数がまだ多くないため事前にLambda関数の実行予約をするのはコストに釣り合わないと判断しました。また、 Lambda SnapStart 有効化による効果についても実際にパフォーマンスを調査・検討しました。下図はSnapStartの有無とGraalVM Native Imageを利用した場合における関数の処理時間の比較です。今回の調査においては、既存実装であるGraalVM Native Imageを利用した方法の方が良いパフォーマンスであったため、SnapStartの有効化を選択しませんでした。 最終的に、私たちは(2)を選択し、Lambda関数に設定されているメモリの最適化を実施しました。設定メモリの最適化はLambda関数のパフォーマンス改善策として知られており、コードの修正も発生しないことから小さく始めることができます。 Lambda関数のメモリ設定最適化 AWS Lambdaでは、設定されるメモリの量によって、Lambda関数で使用できる仮想CPUの量が決まります。メモリを追加することで、それに比例して使用可能な全体的な計算能力が向上します。 詳細は、 AWSの公式ブログ記事 を参考にしてください。 このLambda関数に設定されていたメモリは128MBでした。この値が設定されていた理由は、ZOZOFITローンチ時、このLambda関数のレスポンス性能はミッションクリティカルでなく、実際にパフォーマンス課題等が見られた際に調整する想定があったためです。 当初、どれほどのメモリを設定すれば効率よくLambda関数の実行時間を減らすことができるかが不明でした。ですので、Lambda関数の設定メモリを変更し、実行時間やコストの変化を調査しました。元々設定されていた128MBからスタートし、256, 512, 1024MBと順に設定メモリを変更しLambda関数を実行しました。 下図が設定メモリ別の課金時間と月額の利用料金です。この図を参考にしながらチームで議論し、Lambda関数の設定メモリに1024MBを指定する決定をしました。理由は2点あり、1024MBで課金時間の減少幅が落ち着いていること、月額の利用料金も約$0.02と許容できる額だったためです。 パフォーマンス改善結果 最後に、Create Auth Challenge Lambdaと認証開始エンドポイントのパフォーマンスの変化についてまとめました。どちらも90パーセンタイルのレスポンスタイムにて比較をしています。また、Create Auth Challenge Lambdaはcold start有の場合の実行時間を示しています。 上図からわかる通り、最終的にCreate Auth Challenge Lambdaの実行時間は 約76.2% 削減され、認証開始エンドポイントのレスポンスタイムは 約44.5% 削減されました。 メモリ設定の最適化後、再度Create Auth Challenge Lambda関数のボトルネック処理を調査しました。最適化前と同様に、AWS SDKを使い、Amazon SESでユーザーにEメールを送信する処理がボトルネックであることを確認しました。これはある意味想定通りで、メモリ設定の最適化によってLambda関数の処理全体のパフォーマンスが向上した結果と考えることができます。 今回はメモリ設定の見直しにより、Lambda関数がcold start時でも高速に動作するよう修正しました。しかし、今後のチーム状況によってはGraalVM Native Imageを使わず、Javaランタイム上でLambda関数を実行したくなる可能性もあると思います。そのような時は、再度Lambda SnapStartの有効化を検討したり、パフォーマンスを保ちながら、チームで運用しやすくなる工夫を取り入れていきたいです。 終わりに ZOZOFITの認証フローで実行されるScala実装のAWS Lambda関数のパフォーマンス改善についてご紹介しました。ZOZOFITは ZOZO New Zealand との協業のプロジェクトであり言語の壁もあるので簡単なプロジェクトではないですが、 チームでADRを残す取り組み やプロジェクト全体の改善も進めています。 計測プラットフォーム部バックエンドチームでは、 ZOZOFIT のように、日本国内に限らず新しいサービスを開発していくバックエンドエンジニアを募集しています。 ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
はじめに こんにちは。ML・データ部MLOpsブロックの築山( @2kyym )です。 MLOpsブロックでは2022年の上期から Argo CD の導入に着手しました。本記事ではArgo CDの導入を検討した背景から導入のメリット、また導入における公式マニフェストへの変更点や、運用において必須である認証や権限管理など、具体的な手順についてご紹介します。少しでもArgo CDの導入を検討している方の助けになれば幸いです。 またArgo CDを導入するきっかけとなった、複数運用していたKubernetesクラスタを1つに集約するマルチテナントクラスタへの移行についても触れます。マルチテナントクラスタの設計や具体的な移行作業については述べると長くなってしまうため、詳細については改めて別の記事にてご紹介できればと思います。 Argo CDについては、昨年の計測SREブロックの記事でも触れられていますので是非こちらもご参照ください。 techblog.zozo.com 本記事ではArgo CD自体や導入のメリットについては簡単な紹介にとどめ、導入時の細かい作業や、導入後チーム運用に乗せるため必要な作業を中心として説明します。 また、本記事ではSSO(Single Sign-On)ログインと権限管理に弊社で利用している Azure Active Directory (Azure AD)を使用することを前提とします。しかしArgo CDではAzure AD以外にもOktaなど別の認証基盤も数多くサポートしており、大まかな手順は変わらないはずです。また、出来る限りAzure ADに特化した記述を避けて説明します。 目次 はじめに 目次 背景 従来の課題 MLOpsマルチテナントクラスタについて Argo CDについて Argo CD導入によるデプロイフローの変化 Argo CDの導入メリット 導入手順 公式マニフェストにパッチを当てる IAP認証とSSOログインについて IAP認証の導入 SSOログインの導入 きめ細かい権限管理について 補足: Secret管理のためのExternal Secrets Operatorの導入 おわりに 背景 まずArgo CDについて述べる前に、背景としてMLOpsブロックにおけるインフラの運用課題と、それを解決するためのマルチテナントクラスタについて説明します。 なお前提として、MLOpsブロックではパブリッククラウドとしてGoogle Cloudを使用しており、インフラには Google Kubernetes Engine (GKE)を使用しています。 従来の課題 MLOpsブロックでは、 ZOZOTOWN や WEAR に機械学習系の機能(推薦、検索、etc...)を提供するサービスを幅広く開発・運用しています。これらサービスのAPI群や一部のバッチは先述の通りGKEクラスタ上にデプロイされています。 また、これらのワークロードは従来、全て1つのクラスタにデプロイされていたわけではありません。推薦や検索、類似画像検索などといった大まかなくくりでGoogle Cloudプロジェクト自体が分かれており、そのため各プロジェクトごとのGKEクラスタへ別々にデプロイされていました。 特に類似画像検索とWEAR向けの機能はサービスあたり1つのGoogle Cloudプロジェクト、すなわちGKEクラスタで運用していました。そのため、サービス数が増えるにしたがってMLOpsブロックで管理するクラスタ数がどんどん増えていきました。 管理するGKEクラスタの増加に伴い、開発運用において次の課題が生まれました。 定期的に実施する必要があるKubernetesのバージョンアップ業務の工数が増加する 新規サービスのインフラを構築する際に、新規のGKEクラスタを構築しなければならない手間が発生する TerraformでGKE関連の冗長な記述が増加する、また共通の変更を加える際にはそれら全てに対して変更を加える手間が発生する 1点目については、GKE Autopilotなどのマネージドサービスを活用すればバージョンアップの自動化が可能ですが、MLOpsブロックでは安定性の観点からバージョンアップを手動で実施しています。そのため、クラスタの数が増加するとこちらの工数も単調増加してしまいます。 2点目については、上記の運用ですとサービスごとにGKEクラスタを分けているため、軽量なAPIが1つのみ必要な場合であっても新規のクラスタを構築する必要があります。 3点目については、我々はGoogle Cloud上のリソースを管理するために Terraform を利用していますが、サービス別にクラスタを構築するとほぼ同じ内容のTerraformリソースが様々なリポジトリに記述されてしまっていました。 MLOpsマルチテナントクラスタについて ここまで述べた開発運用における課題を解決するために、MLOpsブロックで管理するサービスのKubernetesワークロードを1つのGKEクラスタに集約する方針となりました。 マルチテナントクラスタの設計や移行の具体的な手順の検討については、詳細に説明するとそれだけで別の記事が書けてしまうボリュームとなるため、またの機会にぜひお伝えできればと思います。 ここでは簡単に触れるだけに留めますが、サービスごとにNamespaceとノードプールを分けて全てのサービスを1つのクラスタにデプロイするという点以外は、基本的に移行前と同じ構成で動作するように移行を進めました。 もちろん、開発運用・Staging環境・QA環境・本番環境はGoogle Cloudプロジェクト自体を分けて別のクラスタを用意しています。 このマルチテナントクラスタへの移行によって、先程述べた運用課題についてはある程度解決する見通しが立ちました。一方で、以前より感じていた次の運用課題は未だに残っていました。 運用しているサービスの数が非常に多く、監視は適切に行っているものの、それらのデプロイ状況やHealthyかどうかの状況を一覧する術がない 特定のサービスに紐づくKubernetesリソース(Deployment, Ingress, Service Account, Secretなど)の状況を確認する術がない GitHub Actions (GHA)のJob内でkubectl applyを実行することでデプロイを行っており、デプロイ状況がリポジトリのDesired Stateからズレた際に検知・修復できない マルチテナントクラスタへの移行に着手するタイミングで、こういった課題を解決するためにMLOpsブロックではArgo CDの導入を検討することにしました。 以下の章では、Argo CDについてごく簡単な紹介をした後に、導入して感じたメリットと具体的な導入手順について述べます。また、Argo CDでのデプロイを運用に乗せるにあたって必須になるであろう認証と権限管理についても出来る限り具体的に説明します。 Argo CDについて まずはArgo CDについて簡単にご紹介します。Argo CDはKubernetes向けのCD(Continuous Delivery)ツールです。 仕組みとしては、Kubernetesクラスタ上のArgo CDが管理対象のサービス(Argo CDではApplicationという単位で管理)のマニフェストが存在するリポジトリを監視します。そしてリポジトリにおいてマニフェストの変更を検知すると、その状態に合わせて自動で同期するようデプロイが行われます。 また、管理対象のApplicationごとに同期の対象(Desired State)とするリポジトリとブランチを設定できます。そのため、例えばStaging環境のArgo CDではmainブランチを、本番環境のArgo CDではreleaseブランチをターゲットとすることで環境ごとのデプロイが実施できます。 Argo CDの公式ドキュメントでは宣言的(declarative)であることがアピールされています。具体的には、Argo CD自体の設定はもちろん、Argo CDによって管理するアプリケーションの設定まで全てをKubernetesのカスタムリソースとしてマニフェストに記載できます。 以下に、Argo CDによるデプロイフローの簡単な概念図を Cloud Native Computing Foundation(CNCF)のブログ記事 より引用します。 Argo CD導入によるデプロイフローの変化 従来のGHAによるデプロイと比較して、Argo CDの導入によってデプロイフローがどう変化したか、ここで簡単に図を交えて説明します。図の左側は従来のGHAによるデプロイを、右側はArgo CDによるデプロイを示しています。 これまではGHAのJobから、つまりクラスタの外部から kubectl apply コマンドによってデプロイを行っていました。デプロイのタイミングはPull Requestがmain/qa/releaseブランチにマージされた際であり、それ以外のタイミングでは実施されません。 Argo CDの導入後は、クラスタ内部のArgo CD Controllerが同期の対象(Desired State)とするブランチを監視し、差分を検知したタイミングでデプロイが実施されます。例えばPull Requestがマージされた際はDesired Stateに差分が発生するのでデプロイが実施されます。後述する設定によって、何らかの問題や手動変更によってDesired Stateからズレた状態になってしまった際も自動デプロイを行うことが可能です。 Argo CDの導入メリット 導入のきっかけは前章で述べた通り、マルチテナントクラスタへ移行するのに合わせて、運用課題を解決するツールを導入できればタイミングが良いという部分が大きいです。それに加えて、動作検証にあたって感じたメリットを、前章で述べた運用課題の裏返しになりますが以下に列挙します。 GUIが使いやすく、一覧性も高いため、数多の運用サービスのデプロイ状況やHealthyかどうかが簡単に確認できる 特定のサービス、すなわちApplicationに紐づくすべてのKubernetesリソースとその状況が簡単に確認できる 通知(Argo CD Notifications)や自動修復(Self Healing)機能によって、デプロイ状況がDesired Stateから乖離してしまった場合のアクションが取れる 自動カナリアリリースのためにArgo Rolloutsの導入を検討していたが、同じArgo FamilyであるArgo CDを導入することでスマートに管理できる まず1点目と2点目についてですが、マルチテナントクラスタへ集約することで、GKEのコンソールやkubectlコマンドによるサービス状況の確認がそもそも以前よりも行いやすくなっていました。Argo CDを導入したことで、それに加えて運用する全てのサービス、すなわちApplicationのデプロイ状況と、それぞれに紐づくDeployment以外のKubernetesリソース状況も簡単に確認できるようになりました。 3点目については、GHAによるデプロイではカバーできなかったDesired Stateから乖離した場合の対応が可能となりました。開発過程での動作確認を頻繁に行わない本番環境においては、自動修復(Self Healing)機能を有効にしています。こうすることで定期的にデプロイ状況とDesired Stateを照合し、差分が生じた場合は自動で再同期を行えます。 最後に4点目について、今回の記事では深く触れませんが、自動カナリアリリース実現のために導入を検討していたArgo Rolloutsのカスタムリソースをスマートに取り扱えるという利点もありました。どちらもArgo Family内のツールであるため互換性が高く、例えば自動カナリアリリースにおける切り戻し(Abort)といったアクションがArgo CDのGUIやCLIを通して簡単に実施が可能です。 このような利点を踏まえて動作検証を進め、マルチテナントクラスタへの移行と同時にArgo CDの導入を実施することになりました。 導入手順 ここからは具体的なArgo CDの導入手順について説明します。 導入にあたって全ての事柄について説明すると記事が長くなってしまうため、公式ドキュメントを一見して分かりやすい部分に関しては説明を省きます。具体的にはApplicationやAppProjectといった基本的なカスタムリソースの概念やそのマニフェストの書き方については、特筆すべき点がないため本記事では述べません。本章では次の内容について述べます。 マルチテナントクラスタに導入し、運用に乗せるにあたって公式マニフェストにパッチを当てた(カスタマイズした)内容 セキュアかつ使いやすいArgo CD環境を用意するための認証とSSO(Single Sign-On)の導入について MLOpsブロック以外のチームのメンバーにArgo CDを使ってもらう際のきめ細かい権限管理について 次の節ではそれぞれの内容について、背景から導入手順までを実際のコードに則って説明します。 公式マニフェストにパッチを当てる 導入チームのインフラ運用次第ではありますが、公式マニフェストを導入しただけでは動作せず、一部マニフェストにパッチを当てる必要が出てくるケースが多いと考えます。今回の導入にあたっては公式マニフェストに幾つかパッチを当てる必要があったため、出来る限り実際のコードに則って説明を進めます。 我々の運用では、公式のマニフェストに含まれる次の3つのマニフェストにパッチを当てています。なお、IAP認証やSSOログイン、権限管理に関連したパッチについては後述します。 Deployment, StatefulSet群が定義されている argocd-repo-server-deploy.yaml Argo CDのKubernetesワークロードが、マルチテナントクラスタのArgo CD専用のノードプールで起動するようにするためのパッチ 各ワークロードごとに適切なResource Limitsを設定するためのパッチ ConfigMap群が定義されている argocd-cm.yaml デプロイ(同期)の結果をSlackに通知するためのパッチ 本番環境のみ、事故防止のためにWeb UIの見た目をカスタマイズするためのパッチ デフォルトで有効になっている、管理者向けのIDとパスワードによるArgo CDへのログインを無効化するためのパッチ (後述)IAP認証、SSOログイン、権限管理に関連するパッチ Service群が定義されている argocd-server-service.yaml argocd-server をGoogle CloudのBackend Serviceと紐付けるためのパッチ ここでは argocd-repo-server-deploy.yaml と argocd-cm.yaml にフォーカスし、実際のパッチの例を示しつつ説明します。 まず、 argocd-repo-server-deploy.yaml に対して適用するパッチを以下に示します。ここではマニフェストの例として argocd-applicationset-controller を挙げています。 apiVersion : apps/v1 kind : Deployment metadata : name : argocd-applicationset-controller spec : template : spec : containers : - name : argocd-applicationset-controller resources : requests : cpu : 100m memory : 150Mi limits : cpu : 500m memory : 300Mi affinity : nodeAffinity : requiredDuringSchedulingIgnoredDuringExecution : nodeSelectorTerms : - matchExpressions : - key : cloud.google.com/gke-nodepool operator : In values : - "argocd-xxxx-v1" tolerations : - key : "dedicated" operator : "Equal" value : "argocd-xxxx-v1" effect : "NoSchedule" リソースの割り当てに関しては特筆することはありませんが、ワークロードごとに適切な値を設定しています。 所望のノードへのスケジューリングに関しては、 nodeAffinity と tolerations の両方を設定しています。MLOpsブロックではサービスごとにノードプールを分けて運用しており、Argo CDに関しても専用のノードプールを設けています。また、各ノードプールには不要なワークロードがスケジューリングされないよう、 NoSchedule taintを付与しています。そのため、Argo CD向けのノードで起動させるために nodeAffinity だけでなくノードプール名をvalueに指定した tolerations が必要です。 次に、 argocd-cm.yaml に対して適用するパッチを以下に示します。こちらには argocd-cm , argocd-rbac-cm , argocd-notifications-cm といった複数のConfigMapマニフェストが定義されています。本来同じファイルに記載しますが、ここでは分かりやすさのため分けて記載します。 先述の通りIAP認証、SSOログイン、権限管理に関しては後述するため、ここでは該当する部分の記述を省いています。 apiVersion : v1 kind : ConfigMap metadata : name : argocd-cm data : admin.enabled : "false" ui.cssurl : "./custom/my-styles.css" my-styles.css : | .nav-bar { background : red; } まずは argocd-cm です。 admin.enabled をfalseに指定することで、デフォルトで発行される管理者向けのIDとパスワードによるログインが無効化されます。この設定によって、Argo CDのWeb UIにおけるログイン画面でもSSOによるログインしか表示されなくなります。 同じくカスタムCSSを使用するための設定も記述しています。本番環境のArgo CDを操作する際、本番環境だと認識せずに意図しない操作をすることを防ぐため、UI上のサイドバーを警告色にするようカスタマイズしています。 なお my-styles.css に関しては argocd-repo-server-deploy.yaml で別途 /shared/app/custom にVolumeをマウントしています。 次に示す画像は1枚目が開発環境のUIであり、2枚目が本番環境のUIです。 apiVersion : v1 kind : ConfigMap metadata : name : argocd-rbac-cm data : policy.default : role:readonly argocd-rbac-cm では、ロールが割り当てられていないユーザーがログインした際に、読み取り権限のみを持つロールをデフォルトで割り当てる設定をしています。 apiVersion : v1 kind : ConfigMap metadata : name : argocd-notifications-cm data : service.slack : | token : $slack_token config.yaml : | context : argocdUrl : <REPLACE_THIS_FIELD> template.app-sync-failed : | message : | <!channel> {{ if eq .serviceType "slack" }} :exclamation:{{end}} The sync operation of application {{ .app.metadata.name }} has failed at {{ .app.status.operationState.finishedAt }} with the following error: {{ .app.status.operationState.message }} Sync operation details are available at : {{ .context.argocdUrl }} /applications/{{.app.metadata.name}}?operation= true . slack : attachments : | [{ "title" : "{{ .app.metadata.name}}" , "title_link" : "{{.context.argocdUrl}}/applications/{{.app.metadata.name}}" , "color" : "#E96D76" , "fields" : [ { "title" : "Sync Status" , "value" : "{{.app.status.sync.status}}" , "short" : true } , { "title" : "Repository" , "value" : "{{.app.spec.source.repoURL}}" , "short" : true } {{ range $index, $c : = .app.status.conditions }} {{ if not $index }} , {{ end }} {{ if $index }} , {{ end }} { "title" : "{{$c.type}}" , "value" : "{{$c.message}}" , "short" : true } {{ end }} ] }] deliveryPolicy : Post groupingKey : "" notifyBroadcast : false trigger.on-sync-failed : | - description : Application syncing has failed send : - app-sync-failed when : app.status.operationState.phase in [ 'Error' , 'Failed' ] argocd-notifications-cm は、Argo CDの公式プラグインである Argo CD Notifications に関するConfigMapです、この例では失敗時にSlack通知を行う記述について示します。 まず token はArgo CDの通知をするSlackアプリケーションのOAuthトークンであり、秘匿情報のためSecretを介して値を渡します。 argocdUrl はSlack通知のメッセージに表示されるArgo CDのURLであり、環境ごとに値が違うためパッチによって上書きしています。 template.app-sync-failed と trigger.on-sync-failed は、同期の失敗時に通知するメッセージのテンプレートと、その発火条件の設定です。 app-sync-failed を含む基本的な状態の通知に関しては、 Argo CD Notificationsのマニフェストにはじめから定義 されています。そのため本来であればパッチを定義する必要はないのですが、失敗時はSlackメンションを付けて通知をしたいため、少々冗長さを許容してこのような方法を取っています。 IAP認証とSSOログインについて Argo CDのWeb UIにアクセス出来るように、今回導入したArgo CDは外部公開されており、認証は必須です。 これまでは、外部公開するサービスにはCloud ArmorによるIP制限を設け、社内ネットワークやVPNを通してのみアクセスできるようにしていたケースが多くありました。しかしこの運用はVPNによる通信速度の低下といった課題もあり開発体験が悪かったため、今回はIP制限を設けず、Google CloudのCloud IAPによってIAP認証を設ける方針としました。 また、IAP認証を通過したのちにArgo CDへログインできるユーザーをMLOpsブロックのメンバーを始めとする関係メンバーのみに絞るため、SSOログインを導入しました。 Argo CDはOktaやMicrosoft Azure Active Directory(Azure AD)を始めとした様々な認証基盤によるSSOログインをサポートしています。今回は弊社で社内向けアプリのログイン全般に利用しているAzure ADを利用して、関係メンバーのみがログインできる方針としました。 なお、Argo CDではIDとパスワードによるログイン運用も可能ですが、上記の方針だとよりセキュアかつ利用が簡単なため、管理者であってもIDとパスワードによるログインはできないようにしています。 この章ではIAP認証とSSOログインの導入について説明しますが、Azure ADに特化した内容については流れを把握するための簡単な説明だけに済ませます。 IAP認証の導入 この節では、IAP認証とSSOログインの導入について実際のマニフェストを交えつつ簡単に説明します。 まずIAP認証によって、MLOpsブロックの関係者に限らず社内アカウントを所持するメンバーを認証します。続くArgo CDのログインで関係者のみ権限を分けて認証します。 次はTerraformでArgo CDのBackend Service(Kubernetes Ingressをデプロイすると作成される)に対して、社内アカウントのアクセス権を付与している例です。 resource "google_iap_web_backend_service_iam_binding" "argo-cd-iap-iam-binding" { project = local.project web_backend_service = data.google_compute_backend_service.argo-cd-backend-service.name role = "roles/iap.httpsResourceAccessor" members = [ "domain:zozo.com" , ] } また、Cloud IAPによる認証をするため、前もってGoogle Cloud側でOAuth 2.0クライアントを作成する必要があります。次はTerraformによるOAuthクライアントのリソース定義の例です。 # Since the iap brand already existed, refer the brand name to create iap client: `gcloud alpha iap oauth-brands list` resource "google_iap_client" "argo-cd-iap-client" { display_name = "argo-cd-iap-client" brand = "projects/$ { local.project_number } /brands/$ { local.project_number } " } OAuthクライアントを作成後、IAP認証に利用するクライアントIDとクライアントSecretを取得してGoogle Secret Managerに別途保管しておきます。 そしてArgo CDのWeb UIを司る argocd-server のServiceに紐づく、GKEのカスタムリソースであるBackendConfigに次の設定を適用します。 apiVersion : cloud.google.com/v1 kind : BackendConfig metadata : name : argocd-backend-config spec : ... iap : enabled : true oauthclientCredentials : secretName : argocd-secret ここで oauthclientCredentials として渡している argocd-secret という名前のSecretリソースに、先程作成したOAuthクライアントのIDとSecretの値を含めています。 上記の設定によりIAP認証を導入し、社内アカウントを持つメンバーの認証を行えるようになりました。 SSOログインの導入 続いて、SSOログインに関する設定について説明します。先述の通り、弊社では社内向けアプリの認証基盤としてAzure ADを利用しているため、今回のArgo CDへのSSOログインにおいてもこちらを用いる方針としました。 SSOログインを行うため、まずはAzure AD側でArgo CDと紐づくSAMLアプリケーションを用意する必要があります。アプリケーションの追加は、Argo CDの導入を済ませた上でコーポレートエンジニアリングチームにエンドポイントを伝えて依頼しました。Azure ADでなくOktaなどの別の認証基盤を利用しているケースでも、大体同じ流れになるかと思います。 またSAMLアプリケーションの作成を依頼する際、後述するきめ細かい権限管理のため、適切な粒度でのユーザーグループの作成も同時にお願いしました。ユーザーグループごとの権限はArgo CD側で細かく調整できるため、一旦は権限を付与する可能性のあるチームの単位(検索系、推薦系など)でユーザーグループを分けておきます。 結果として、環境ごとのSAMLアプリケーションと、それに紐づく7つのユーザーグループが用意できました。 認証基盤側の準備が完了した後は、 argocd-cm に次のパッチを当てることでSSOログインを有効化します。 apiVersion : v1 kind : ConfigMap metadata : name : argocd-cm data : url : https://${endpoint} dex.config : | logger : level : debug format : json connectors : - type : saml id : saml name : saml config : entityIssuer : https://${endpoint}api/dex/callback ssoURL : https://${ssoUrl} caData : $argocd_dex_saml_ca redirectURI : https://${endpoint}/api/dex/callback usernameAttr : email emailAttr : email groupsAttr : Group こちらの設定も利用する認証基盤によって多少変化しますので、ここでは参考程度にお伝えします。 endpoint は構築したArgo CDのエンドポイントであり、 ssoUrl はAzure ADアプリケーション側で発行されたログインURLです。 caData はSAML証明書であり、秘匿情報のためOAuthクライアントの情報と同様に argocd-secret というSecretに含めて渡しています。 これでIAP認証とSSOログインの両方を導入し、利用者にとってログインが簡単でかつセキュアな環境を整えることができました。 きめ細かい権限管理について ここまでの作業によって、管理者であるMLOpsブロックのメンバーを含む利用者が、簡単かつセキュアにArgo CDを利用できるようになりました。この節では利用者ごとにきめ細かい権限管理をする方法について説明します。 Argo CDでは、全てのリソースに対する全権限を持つ admin ロールと、全てのリソースに対する読み取り権限を持つ readonly ロールが組み込まれています。また、デフォルトでは readonly ロールが割り当てられます。 しかし先述の通りMLOpsブロックが管理するサービスの領域は広いため、利用するメンバーによってどのArgo CDリソースの権限を付与するかを切り分けたいケースがあります。例えば管理者であるMLOpsブロックのメンバーには全リソースに対する権限を付与し、検索系のメンバーには検索に関連したApplicationに対する権限のみを付与したい、といったケースです。 スムーズな開発のため開発環境では編集権限を付与し、本番環境では読み取り権限のみを付与できると嬉しいですし、管理者でも先述の admin ロールよりも権限を絞る、といった柔軟な権限設定が望まれます。 Argo CDでは管理者が定義したカスタムユーザーまたはSSO構成ごとに、Argo CDリソースの権限レベルを細かく設定したカスタムロールを割り当てる、ロールベースアクセス制御(RBAC)が可能です。 Azure ADをはじめとした認証基盤側で、先述の通り割り当てたいロールごとに検索系メンバー、推薦系メンバーといったユーザーグループを用意しました。これらは後ほど解説するArgo CDカスタムロールにそれぞれ紐付けることができ、きめ細かい権限管理が行えます。 RBACはArgo CDに含まれるConfigMapのマニフェストである argocd-rbac-cm にパッチを当てることによって設定できます。参考までに、Staging環境における検索系チーム向けのロール割り当ての例を示します。 apiVersion : v1 kind : ConfigMap metadata : name : argocd-rbac-cm data : # Azure AD groups ref. https://portal.azure.com/... policy.csv : | p, role:search-member, applications, get, search/*, allow p, role:search-member, clusters, get, *, allow p, role:search-member, repositories, get, *, allow p, role:search-member, projects, get, search, allow g, "${azure_ad_search_group_object_id_stg}" , role:search-member search-member がロール名にあたるもので、最終行でこれをユーザーグループの識別子と紐付けています。 ここで azure_ad_search_group_object_id_stg はAzure ADユーザーグループのオブジェクトID(一意なIDであるため伏せています)です。なお、SSO構成によって紐付けに用いる識別子は変わります。開発環境ではなくStaging環境のため、メンバーには検索系のApplicationのみに対する読み取り権限と、その他リソースの読み取り権限を付与しています。 また、以下に同じくStaging環境におけるMLOpsブロックメンバー向けのロール割り当ての例を示します。本来上記と同じパッチに記述するものですが、ここでは分かりやすさのために分けて記述します。 apiVersion : v1 kind : ConfigMap metadata : name : argocd-rbac-cm data : policy.csv : | p, role:org-admin, applications, *, */* , allow p, role:org-admin, clusters, get, *, allow p, role:org-admin, repositories, get, *, allow p, role:org-admin, projects, get, *, allow g, "${azure_ad_admin_group_object_id_stg}" , role:org-admin 検索系チーム向けのロールと比較すると分かるように、全てのApplicationに対する全ての操作権限を付与しています。一方で、Application以外のリソースに変更を加える際は基本的にマニフェストの修正をしてPRレビューを通すといったフローで行うため、その他リソースに関しては読み取り権限のみを付与しています。 このように、SSO構成と紐付いたRBACを設定することで、利用チーム別かつデプロイ環境別にきめ細かく安全な権限管理ができます。 補足: Secret管理のためのExternal Secrets Operatorの導入 本題からはずれますが、マルチテナントクラスタへの移行とArgo CDの導入において避けては通れなかった External Secrets Operator の導入について、ここで参考までに述べます。 MLOpsブロックでの従来の運用では、Kubernetes Secret管理とデプロイに Berglas というGoogle製のオープンソースCLIツールを利用していました。このCLIツールは用途がKubernetesに限られないもので、コマンドを実行することで対象のSecret値の実体化が行えます。バックエンド(Secretの保管先)としてGCS、Google Secret Managerが利用できます。 従来の運用におけるSecretのデプロイフローを下の図に示します。 Argo CD導入前のため、GHAからSecretリソースのデプロイを行う前提です。まずBerglasコマンドを実行するスクリプトを介して、Google Secret Managerを参照してSecretの実体化を行い、その後クラスタへのデプロイを実施しています。 GHAのみならず、動作確認などローカル環境からの手動デプロイにおいても同じフローで実施しており、各メンバーのローカル環境でBerglasによるSecretの実体化を行う手間がありました。また開発メンバーにはGoogle Secret Managerに対するAccessorロールを付与する必要があり、詳細は省きますがマニフェストの構成が直感的でなく分かりづらいという問題もありました。 上記のような課題感があったため、Secret保管先としてはGoogle Secret Managerを引き続き利用しつつ、Berglasの利用を撤廃できる方針を検討していました。 Argo CDは先述の通りDesired Stateとするブランチに対して同期を行いますが、リポジトリ上にはもちろん秘匿情報は保管できません。また、Argo CD側からBerglasによってSecret値を実体化させる術もありません。そのため通常の同期ではSecretの値を実体化させてデプロイすることが出来ず、Berglasの利用を継続したままArgo CDを導入するのは難しいという点もありました。 そこで検討した結果候補に挙がったのが、Argo CDの公式プラグインである Argo CD Vault Plugin (AVP)と、External Secrets Operatorの2つです。 AVPを用いた場合、Argo CDがバックエンドであるSecretの保管先を参照し、Secretリソース中のプレースホルダを上書きすることでSecretの実体化を行います。 バックエンドとしてGoogle Secret Managerを利用でき、かつArgo CDプラグインとして動作するため、運用するKubernetesワークロードが増えないという利点もあります。使い方もKubernetes Secretリソースにアノテーションを追加するだけでよくシンプルで、初めはBerglasからこちらに移行することを検討しました。 しかしあくまでもArgo CDのプラグインであるため、動作確認などを目的とする手動デプロイといった、Argo CDを介さないデプロイではSecretの値を実体化させることができず、運用が難しいという結論に至りました。 続いて検討したのがExternal Secrets Operatorです。こちらの運用ではマルチテナントクラスタ上にデプロイされたExternal Secrets Operatorが、Google Secret Managerの参照とSecretの実体化の役割を担います。ユーザーがExternalSecretというカスタムリソースを作成すると、External Secrets OperatorがExternalSecretに紐付くKubernetes Secretリソースを作成します。 次にExternal Secrets Operatorの概念図を 公式ドキュメント より引用します。 デプロイ時にクラスタ内部からバックエンドを参照してSecretの実体化を行うという点で、使い勝手の面ではAVPと大きく変わりません。 メリットとしてはArgo CDとは独立であるため、ExternalSecretリソースをArgo CDを介さずデプロイした場合であってもSecretの実体化を行える点が挙げられます。運用するワークロードが増えるというデメリットはありますが、使い勝手を考えこちらを導入することにしました。 Berglasの利用が撤廃できたため、デプロイ前にSecretを実体化させる手間がなくなりました。また、Google Secret Managerの権限をメンバーに付与する必要がなくなり、マニフェストの構成もシンプルになるなど、多くのメリットが得られました。 おわりに 最後まで読んでいただきありがとうございました。 MLOpsブロックでは多数のサービスを開発運用する上での課題を解決するために、マルチテナントクラスタへの移行とArgo CDの導入を実施し、運用において多くのメリットを得ることができました。 また本記事では、Argo CDを導入し運用していくにあたってほぼ必須となる、認証機構の導入やメンバーごとの権限管理をきめ細かく行う方法についても説明しました。本記事が皆様のお役に立てば幸いです。 最後になりますが、ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co corp.zozo.com
はじめに こんにちは、技術本部SRE部ZOZOSREチームの堀口です。普段はZOZOTOWNのオンプレミスとクラウドの構築・運用に携わっています。またDBREとしてZOZOTOWNのデータベース全般の運用・保守も兼務しております。 ZOZOTOWNではSQL Serverを中心とした各種DBMSが稼働しています。その中でZOZOTOWNサービスの根幹となるいわゆる基幹データベース(以下、基幹DBと呼ぶ)を5年ぶりにリプレースしました。 基幹DB群は、商品情報、在庫情報、注文情報、会員様情報、ブランド様情報、配送管理、キャンペーン情報、分析系情報などZOZOTOWNサービスにおけるほぼ全ての情報を管理しているものとなります。 リプレースのモチベーションは5年のハードウェア(以下、HWと呼ぶ)保守期限終了およびSQL ServerのEnd Of Life(以下、EOLと呼ぶ)を迎えるため、HWの更改/SQL Server、OSのバージョンアップが必要となったことです。 本記事では、どのようにこれら基幹DBリプレースを推進したか、ZOZOTOWNサービスを引き続き快適に利用して頂けるよう案件を推進したかという内容を紹介させて頂きます。 目次 はじめに 目次 基幹DBのリプレース方針 機器選定 A社 or B社? 機器スペック選定 OS/SQL Server/各種ミドルウェアのバージョン選定 切替計画 SQL Server2012と2019のクエリ互換性 基幹DBの理解 設計・構築 テスト アプリケーションテスト 性能テスト 対象クエリの抽出方針 負荷ツールの選定 性能テスト結果 障害テスト 運用テスト 品質管理テスト 切替リハーサル 切替リハーサル結果 本番切替 終わりに 基幹DBのリプレース方針 基幹DB群はZOZOTOWN創業時から存在すること、サービスにおける絶大な影響を持つことから、いかに安全かつ革新的にこのリプレース案件を成功させるかをずっと考えていました。 その結果、以下の方針でリプレースすることにしました。 改善したいところはあるものの、手を出さずにオンプレミスサーバ⇒オンプレミスサーバへの単純リプレースとする ベンダー依存の体制から脱却する 各種連携システム(主に人)と完璧に情報を共有し「漏れ」をなくす 1つ目の単純リプレースとした理由は「安全にリプレースする」ことを最優先としたためです。改善したかった所としては以下のものがありましたが、本プロジェクトのスコープからは除外しました。 不要オブジェクトやデータ連携ルートの削除、既存オブジェクトの適切なDBへの移動 オンプレミスサーバに存在する理由のないテーブルや処理は「Amazon RDS」,「Amazon Aurora」,「Google Cloud BigQuery」などに部分移行 2つ目のベンダー依存に関しては、前例と実績を重視するあまり機器選定からリリースへ至るまで(言い方は少々悪いですが)ベンダー任せにしてしまっているところがありました。機器選定時、適切な検証と選択を行なわず、また運用に関しても自社エンジニアでできることはほとんどなくベンダーに頼り切りとなっている体質があったため、今回はこれも是正したいと強く思っていました。 3つ目に、基幹DBと連携するサーバやシステムが多数あるため、リリース時には複数システムで切替作業が必要となります。この方針決めの時点では全容は把握できていませんでしたが、日々のDBREとしての活動の中で容易に想像できました。 以降、上記方針に基づいてあらゆる事を判断していくこととなります。 機器選定 対象となるHW機器は大きく分けて以下の3つです。 サーバ製品 ストレージ製品 スイッチ製品 また選定した機器と委託するベンダーとは関連があり、選定した機器に対してそれを得意とするベンダーを構築担当としてお願いすることになります。そのため、 機器選定=ベンダー選定である という事を念頭に置いて選定を行なっていくことになります。 機器選定においては、A社とB社の製品を比較・検討していくことにしました。まず実施したことはストレージ製品の性能比較です。性能比較にあたっては、以下の方針を立てました。 同一条件で同一処理を実行し、ストレージ単体の秒間スループット、IOPS、レイテンシを比較する 同様にSQL Server経由での性能を比較する 読み込み/書き込み、ブロックサイズ(8K、64K、256K)、スレッド数(1、32)、sequential/randomの全ての組み合わせパターンを比較する SQL Serverを経由したselect/insert/update/deleteのパターンを比較する 製品間の性能差を計測するものであり本番環境の性能を保証するレベルのテストではない(≒限界値テストは行わない) この検証をするための検証環境は各ベンダーに用意して頂きました。 ディスク単体の性能計測はMicrosoft社(以下、MS社と呼ぶ)製のベンチマークツールである「DiskSpd」を使用しました。これは弊社内でノウハウがあったことと、オプションが多数あり細かい動作を指定できること、outputが見やすいことから採用しました。 DISKSPD を使用してワークロード ストレージのパフォーマンスをテストする を参考に実行したDISKSPDコマンド例は次のとおりです。 Diskspd.exe -c1G -b8K -t1 -o1 -L -h -si -d120 T:\test.dat c1G:テスト用ファイルサイズ1GB b8K:ブロックサイズ8KB t1:同時スレッド数1 o1:キューデプス1 L:レイテンシー統計出力 h:ソフトウェア/ハードウェアキャッシュを無効化する si:シーケンシャルアクセス d120:120秒間実行 T:\test.dat:テストファイル名 上記のコマンドを実行するとこのように細かく性能値を出力してくれます(少々長いです)。 Diskspd結果 Command Line: Diskspd.exe -c1G -b8K -t1 -o1 -L -h -si -d120 T:\test.dat Input parameters: timespan: 1 ------------- duration: 120s warm up time: 5s cool down time: 0s measuring latency random seed: 0 path: 'T:\test.dat' think time: 0ms burst size: 0 software cache disabled hardware write cache disabled, writethrough on performing read test block size: 8KiB using interlocked sequential I/O (stride: 8KiB) number of outstanding I/O operations per thread: 1 threads per file: 1 IO priority: normal System information: computer name: PE640-SP01 start time: 2021/12/13 12:26:03 UTC Results for timespan 1: ******************************************************************************* actual test time: 120.01s thread count: 1 proc count: 32 CPU | Usage | User | Kernel | Idle ------------------------------------------- 0| 20.71%| 0.65%| 20.06%| 79.29% 1| 0.03%| 0.01%| 0.01%| 99.97% 2| 0.01%| 0.00%| 0.01%| 99.99% 3| 0.03%| 0.00%| 0.03%| 99.97% 4| 0.01%| 0.01%| 0.00%| 99.99% 5| 0.74%| 0.34%| 0.40%| 99.26% 6| 0.14%| 0.05%| 0.09%| 99.86% 7| 0.18%| 0.08%| 0.10%| 99.82% 8| 0.13%| 0.07%| 0.07%| 99.87% 9| 0.04%| 0.03%| 0.01%| 99.96% 10| 0.12%| 0.09%| 0.03%| 99.88% 11| 0.08%| 0.04%| 0.04%| 99.92% 12| 0.13%| 0.12%| 0.01%| 99.87% 13| 0.00%| 0.00%| 0.00%| 100.00% 14| 0.33%| 0.10%| 0.22%| 99.67% 15| 0.03%| 0.00%| 0.03%| 99.97% 16| 1.76%| 0.00%| 1.76%| 98.24% 17| 0.04%| 0.03%| 0.01%| 99.96% 18| 0.03%| 0.00%| 0.03%| 99.97% 19| 0.00%| 0.00%| 0.00%| 100.00% 20| 0.04%| 0.03%| 0.01%| 99.96% 21| 0.04%| 0.03%| 0.01%| 99.96% 22| 0.03%| 0.01%| 0.01%| 99.97% 23| 0.00%| 0.00%| 0.00%| 100.00% 24| 1.05%| 0.00%| 1.05%| 98.95% 25| 0.00%| 0.00%| 0.00%| 100.00% 26| 0.01%| 0.00%| 0.01%| 99.99% 27| 0.00%| 0.00%| 0.00%| 100.00% 28| 0.07%| 0.05%| 0.01%| 99.93% 29| 0.00%| 0.00%| 0.00%| 100.00% 30| 0.00%| 0.00%| 0.00%| 100.00% 31| 0.05%| 0.03%| 0.03%| 99.95% ------------------------------------------- avg.| 0.81%| 0.05%| 0.75%| 99.19% Total IO thread | bytes | I/Os | MiB/s | I/O per s | AvgLat | LatStdDev | file ----------------------------------------------------------------------------------------------------- 0 | 6798966784 | 829952 | 54.03 | 6915.95 | 0.144 | 0.115 | T:\test.dat (1GiB) ----------------------------------------------------------------------------------------------------- total: 6798966784 | 829952 | 54.03 | 6915.95 | 0.144 | 0.115 Read IO thread | bytes | I/Os | MiB/s | I/O per s | AvgLat | LatStdDev | file ----------------------------------------------------------------------------------------------------- 0 | 6798966784 | 829952 | 54.03 | 6915.95 | 0.144 | 0.115 | T:\test.dat (1GiB) ----------------------------------------------------------------------------------------------------- total: 6798966784 | 829952 | 54.03 | 6915.95 | 0.144 | 0.115 Write IO thread | bytes | I/Os | MiB/s | I/O per s | AvgLat | LatStdDev | file ----------------------------------------------------------------------------------------------------- 0 | 0 | 0 | 0.00 | 0.00 | 0.000 | N/A | T:\test.dat (1GiB) ----------------------------------------------------------------------------------------------------- total: 0 | 0 | 0.00 | 0.00 | 0.000 | N/A total: %-ile | Read (ms) | Write (ms) | Total (ms) ---------------------------------------------- min | 0.111 | N/A | 0.111 25th | 0.131 | N/A | 0.131 50th | 0.137 | N/A | 0.137 75th | 0.147 | N/A | 0.147 90th | 0.161 | N/A | 0.161 95th | 0.185 | N/A | 0.185 99th | 0.226 | N/A | 0.226 3-nines | 0.478 | N/A | 0.478 4-nines | 0.960 | N/A | 0.960 5-nines | 2.050 | N/A | 2.050 6-nines | 100.823 | N/A | 100.823 7-nines | 100.823 | N/A | 100.823 8-nines | 100.823 | N/A | 100.823 9-nines | 100.823 | N/A | 100.823 max | 100.823 | N/A | 100.823 これらの結果から重要なメトリクスを抽出し、一覧化し比較しました。 結果より、 ストレージへの純粋なアクセス性能においては、ほぼ全てのパターンでA社の方がB社より優秀 ということがわかりました。 次にSQL Serverを経由したアクセス性能を検証しました。 対象クエリについてはOLTP/バッチ処理を想定した本番環境に実際に発行されている「select/update/insert/delete」文の中から重いと判断したものを抽出しました。それをテスト用クエリとしシングル/並列スレッドでSSMSから実行しました。 また検証用サーバ機は調達の問題により両社でCPUのスペック差が発生していたため、以下の設定をSQL Serverに行うことで条件を統一しました。 SQL Serverが使用するCPUについて、A社のCPUのコア数をB社の32コアに合わせ32コアへ縮小する 関係マスクを自動→固定に変更(両社とも) 観察すべきメトリクスは色々ありますが、レスポンス時間、リソース使用状況を取得するため、以下の取得設定を事前に仕込み、性能測定に使用しました。 クエリストア パフォーマンスモニタ DMV情報 またオプティマイザによって作成される実行プランに違いが出てしまうと純粋な比較とならないため、実行プランが同じであることは項目毎に確認していきました。テスト結果はDiskSpdと同様に一覧化し比較しました。結果的としては、DiskSpdの結果と同様にA社製品の方が性能的に”上”という結果となりました。 レスポンス リソース状況 A社 or B社? 上で書いた通り機器選定=ベンダー選定でもあり、システム構築や5年後までを見据えた保守サポートなど長いお付き合いとなります。また当然ですがコストについても比較しなければ決定するには不十分です。基幹DBのワークロードにおける性能比較はA社の方に分がありましたが、果たしてその結果だけで評価してよいものか考えました。 全体の評価には、以下の評価項目について両社を比較し合計点の大きい方を採択するという方針を決めました。 評価項目 分類 信頼性 企業信用力 資質・能力 取り纏めSE能力 提案力 適合性1 製品 性能 適合性2 提案内容 業務の理解度 業界知識 業務知識 実現性のある提案か 実現性 日程・体制 将来性 拡張/保守 経済性 見積金額 なるべく公平な評価を目指した結果このような点数制になりました。これでも主観が入ってしまうことは否めませんが、最終的にはこの点数を根拠として、本リプレース案件はA社製品を採択することになりました。 5年前のリプレース以前からZOZOTOWNが採用してきた基幹DBのHWベンダーを今回の基幹DBリプレースから一新することになりました。 機器スペック選定 基幹DBはオンプレミスなサーバ群のため当然ですがクラウド環境のように容易なスペック変更はできません。そのためこの時点でスペック不足とならないよう慎重にCPU/メモリ/ディスクサイズを決定する必要があります。選定の方針としては以下の2つです。 現行で稼働する基幹DBのスペックとリソース使用率を基準に将来的な成長率を考慮し決定する 多少のオーバースペックは許容する ここについては、HWベンダーのアセスメントサービスを利用して最終的なスペックを提案して頂き決定しました。 OS/SQL Server/各種ミドルウェアのバージョン選定 基本方針は以下の通りです。 原則最新バージョンで構成する SQL Serverについては連携するシステムとの互換性に注意する SQL Serverのバージョンは最新である2019を採用することを軸に検討しました。また互換性レベルも150として現行の100から大幅に引き上げることにしました。 基幹DBは各種システム(サーバ)とのデータ連携にSQL Serverのトランザクションレプリケーション機能を利用してます。パブリッシャー側とサブスクライバー側のSQL Serverバージョンは仕様上、互換性の制約があり3世代以上のバージョンの隔たりは許容されていません。このため最新のSQL Server2019にバージョンアップするDBとSQL Server2016を採用せざるを得ないDBが混在することになりました。このバージョン互換性問題は後述するシステム切替方式にも影響することになります。 OS/ミドルウェアについては特段問題なかったので最新バージョンを選択しました。 切替計画 切替計画を立てる際、一番に考えたのはZOZOTOWNサービスの無停止での切替ができないかでした。現行DBサーバの横に新DBサーバ群を構築しテストし、切替当日クライアント側で向き先を一気に切り替える方式はほぼ決定していましたが、以下の理由で無停止での切替は断念しました。 新旧サーバ間のデータ同期にDBリストアを行う必要があるが、その後レプリケーションを再作成する必要があり、一時的にテーブルのDROP/CREATEが走ること クライアント側が点で切り変わらない限り一部のクライアントは新DB、一部のクライアントは旧DBを更新することになりデータの不整合が発生すること そこで以下の全体方針を立てました。 切り替え当日は、レプリケーションの張り直し作業が必要となるため、数時間のZOZOTOWNのサービス停止が必要となる 1日のサービス停止時間の削減および切り戻しの容易さを考慮し、切り替えは2段階で行う(以降、フェーズ1、フェーズ2と呼ぶ) フェーズ1およびフェーズ2の間の過渡期ではSQL Server2019(FrontDB/BackDB)とSQL Server2012(ReportDB/BatchDB)が両立しサービスすることになる SQL Server2019と2012はレプリケーションの互換性がないため、互換性を保つため一時的に2016のSQL Serverを中継用に挟むことにより対応する ZOZOTOWNを停止することは避けられないとしても、停止時間が9時間になるのと18時間になるのとではZOZOTOWNユーザーへの影響度が変わってきます。また一度に変更する範囲を狭めることで問題が発生した際の切り分けや対応範囲が限定され解決しやすくなるという理由から、FrontDB/BackDBの切替とReporDB/BatchDBの切替を別日になるよう日程を2つに分けて切替を行うことにしました。 本番環境の状態遷移を図にしてみます。上段が現行DB、下段が新DBとなります。矢印はレプリケーションの線になります。 FrontDB、BackDBだけ切り替えた状態がフェーズ1完了時となります。この時、SQL Serverのバージョン互換性の問題で新FrontDB/新BackDBと旧ReportDB/旧BatchDBとの間で直接レプリケーションを張ることができません。このため新ReportDB上に中間となるバージョンを持った中継用SQL Serverインスタンスを暫定的に挟むことでバージョン互換性の問題をクリアしました。 フェーズ2完了時点では中間インスタンスを削除し、新DBのみで稼働することになります。 レプリケーションの互換性の詳細はSQL Serverマニュアル( レプリケーションの下位互換性 ) をご参照ください。 フェーズ1完了時は、「SQL Server 2019」→「SQL Server 2016」→「SQL Server 2012」という構成でデータ連携することにしました。 SQL Server2012と2019のクエリ互換性 バージョンアップにおける既存クエリの互換性のチェックについてはMS社が提供する「Data Migration Assistant(DMA)」を利用しました。このサービスの詳細は弊社テックブログ( Data Migration Assistant )を参照頂くとして、簡単に言うとクエリを渡すと異なるSQL Serverのバージョン間で互換性があるか、つまり動くかをチェックしてくれるものです。 本案件では、下記の両方についてDMAを利用してチェックをかけました。 ストアドプロシージャなどの静的クエリ アプリケーション側で動的に発行されるアドホックなクエリ 影響のあるクエリは以下の3つのタイプという結果になりました。 内容 説明 Unqualified Join(s) detected JOIN句の書き方が正しくない LEFT OUTER JOINなどのようにJOIN句を省略せずに書く 悪い例) select * from table1, table2 where table1.col1 = table2.col1 SET ROWCOUNT used in the context of DML statements such as INSERT,UPDATE,or DELETE INSERT,UPDATE,DELETE文においてROWCOUNTで行を絞る機能が無効となる。代わりにTOP XXXを使う必要がある 悪い例) SET ROWCOUNT 100 UPDATE~ ORDER BY specifies integer ordinal ORDER BY句に指定するカラムを列番号で指定しない。代わりにカラム名で指定する 悪い例) order by 1 こちらについては、クライアント処理を担当する各チーム(以下、業務チームと呼ぶ)に確認/修正を依頼しました。 基幹DBの理解 私がこの案件のPMを任された時、基幹DB群に対する理解度は半人前(勉強中)な状態でした。それまではクラウドベースの商品系APIのインフラ(とデータベース)の運用/保守担当だったので基幹DB群については知見があまりない中で、いかにリプレースを成功させるかをずっと考えていました。 DBに限らず1つのシステムの運用を理解するには、「いつ」「だれが」「どこで」「なんのために」「どのように」「何する」といういわゆる5W1Hを全てのオブジェクトに対して整理することだと考えています。データベースに関していえば上記を全テーブルに対して整理することです。 それが理想ではあるものの1つのDBで1000テーブル近くのテーブルを所有する基幹DBにおいて全てを整理するには時間が足りません。まずはデータベース視点で、基幹DBへ接続して来ているクライアント達についてDB単位で整理することから始めました。 基幹DBを用途別に整理すると、以下の6種類のデータベースに分かれます。 DB名 用途 FrontDB セール情報 ショップニュース 会員情報 クーポン情報 ファッションまとめ 注文情報 メンバー情報 ポイント履歴 在庫情報 BackDB 商品系情報 ショップ、ブランド系情報 拠点情報 メルマガ ReportDB 分析系情報 BatchDB 人気順情報 検索系 コーディネイト系 ReadonlyDB 商品詳細情報 DmsDB データ連携中継用 これらに対してアクセスしているサーバやユーザをSQL ServerのDMVから情報を取得しました。 --クエリ1 select host_name,login_name, count (*) AS [num of sessions] from dm_exec_requests_dump_per_several_seconds with (nolock) group by host_name,login_name order by host_name,login_name 上記の「dm_exec_requests_dump_per_several_seconds」というテーブルは我々が後からワークロードを都合よく確認できるよう定期的に(5秒間隔)DMV情報をサマリしたものを貯めておくワークテーブルです。 このクエリを実行すると、10日分のクライアント別/ユーザ別のアクセス状況、おおよそのセッション数が分かります。 この結果を元に、下記項目を一覧化し整理していくことから基幹DBの使われ方や運用を理解していきました。 アクセス元のユーザ名毎のクライアント処理 アクセス元サーバ群を機能別にカテゴリ化 アクセス元の担当部署 設計・構築 設計については冒頭で書いた通り、本案件は単純移行を方針としているため原則、現行踏襲で設計する、HWのスペックアップなど差分に関わる箇所においては設計変更するという方針ですすめました。構築においても同様です。こちらは外部ベンダー主導で作業して頂き、弊社でレビューする形式にしてすすめました。 テスト 本案件で実施した各種テストとテスト観点について以下に整理します。 テスト名 観点 アプリケーションテスト ・SQL Serverバージョンの違いによるクエリの正常性確認 ・DB接続切替箇所の特定・手順・網羅性の確認 性能テスト ・SQL Serverバージョンアップに伴うオプティマイザのバージョンアップにより現行よりも劣化した実行プランが生成されないこと ・クエリ処理時間が現行と同等、または改善すること ・リソース使用量に異常性がないか 障害テスト ・基幹DBを構成する一連のシステム内で障害が発生した場合にきちんとリカバリができサービス再開ができること ・期待したアラートが発報され障害時に運用担当者が気づける仕組みができていること 運用テスト ・各種運用手順書の妥当性 品質管理テスト ・各種URLを発行し想定通りの結果となること(ノンリグレッションテスト) アプリケーションテスト アプリケーションテストは、言うまでもなくバージョン差異に影響することなくクエリが正常に動作することです。クライアント処理をテストするための環境として動作させるため、本番サーバからコピーしたほぼ全てのを各種テスト用クライアントをVMゲストとして起動させました。 もう1つDBへの接続箇所の洗い出しという観点がありました。ここではテストの段階で間違えて本番DBにアクセスした場合のリスクと切替漏れに気付ける仕組みが必要です。テスト用クライアントは現行DB群にアクセスできないようWindowsFirewallの機能で本番DB宛てのアウトバウンド通信を全て拒否するような設定を入れました。 性能テスト 性能テストについては、あるべき論と限られた環境の中でどこまでやるのかという点において非常に悩みました。リプレースする基幹DBに求められる最大のミッションは、 ZOZOTOWNで最も負荷のかかる大規模セール時のリクエストを捌けること となります。 さらにブレークダウンしていくと、性能テストとして担保したいことは概ね以下となります。 項番 指標 目標値 (1) 最大秒間バッチリクエスト数を捌けること XXXXXバッチ/sec (2) 同時実行性が担保できること ワーカースレッド数XXXX程度 (3) 応答時間が現行と同等もしくは向上すること 95パーセンタイルでX ms以下 (4) HWリソース不足が発生しないこと CPU使用率XX%以下 さて、上記をテストするには各DBサーバに対するクエリの種類やリクエスト数を本番サービス並みに発生させる必要がありますが、テスト用クライアントの台数が圧倒的に少ないため断念しました。 そこで性能テストとして実施可能な範囲で方針として以下に決定しました。 項番 方針 (1) DB単体での性能を計測する (2) 本番環境のクエリを一定のルールで抽出し性能を測定する (3) HWリソースの消費量が異常値とならないことを確認する (1)については、クライアントの性能に依存するURL経由での性能ではなくDBサーバ単体の性能を測定するということです。(2)については”問題となりうるクエリ”を抽出しその性能を測定することとしました。現状の環境とスケジュール感、工数を鑑みるとできることは少なく最低限の確認とならざるを得ないという感想です。 対象クエリの抽出方針 テストで使用するクエリは以下の条件で選定、抽出しました。 項番 方針 (1) OLTP/バッチの2パターン (2) 対象コマンドは、select/insert/update/delete (3) bulk insert(bcp処理) (4) リンクサーバ経由のクエリも含ませる(分散トランザクションの性能確認) (3)、(4)については基幹DBのワークロード特性から必要なものとなります。(1)のOLTP/バッチの判断基準は以下としました。 OLTP : 実行回数が1時間に1000回以上 バッチ : 実行回数が1時間に360回以下※最短実行間隔が5秒として5秒×12回×60分=360回 かつ負荷が高いものの抽出基準として以下を設定しました。 処理 抽出基準 OLTP 実行回数の多いもの(execution countが多いもの) 1時間のトータルの経過時間が多いもの(total elapsed time、total worker time) workspacememoryが大きいもの バッチ 処理時間、CPU時間が多いもの workspacememoryが大きいもの 負荷ツールの選定 負荷をかけるツールはMS社の「OStress」を使用しました。 設定がシンプルであること、並列度、回数を指定するのに細かい設定が不要なことから選定しました。対抗馬はJMeterでしたがこちらは設定が複雑であるとの理由から却下しました。 ostress.exeコマンド例 ostress.exe -Sホスト名 -Uユーザ名 -Pパスワード -dDB名 -q -M6200 -r8000 -n100 -oD:\work\seino\2-1-1 -iD:\work\seino\2-1-1.sql 気を付けたのは、目標の負荷を掛けるためにオプションn(実行時のコネクション数)、r(1コネクションあたりの実行回数)を調整しつつ実行する必要があるということでした。 また複数のコネクションを並列で実行する場合、1つのクエリファイルが同時に実行されるためinsert文でキー重複が発生するのを避ける必要があります。このような場合はキー項目の値にsessionidと紐づいた値を設定するようにクエリを書き換えることで重複を排除しました。これは複数セッションから同一行へアクセスするのと別々の行へアクセスするのとでは性能が大きく変わってきてしまうことからも大事なことです。 サンプルとしては以下のようなクエリとなります。 declare @変数A int = @@SPID insert into テーブルA(キーカラムA,カラムB)values(@変数A,0); insert into テーブルA(キーカラムA,カラムB)values(@変数A + 1,0); insert into テーブルA(キーカラムA,カラムB)values(@変数A + 2,0); ・ ・ update テーブルA set カラムB = 0 where キーカラムA = @変数A; update テーブルA set カラムB = 0 where キーカラムA = @変数A + 1; update テーブルA set カラムB = 0 where キーカラムA = @変数A + 2; ・ ・ ・ あと細かいですが、OStressの仕様でオプションと値の間にスペースを入れてはいけません。 性能テスト結果 それぞれの観点と結果をまとめると以下の通りで、結論としては問題なしでした。ただしクエリパターンの全網羅はできていないため本番サービス後に非効率な実行プランが生成された結果、応答時間の長くなるクエリが出てくる懸念は残りますが、それらは個別でチューニングしていく判断をしました。 観点 結果 SQL Serverバージョンアップに伴うオプティマイザのバージョンアップにより、現行よりも劣化した実行プランが生成されないこと 現行と異なるプランが作成されたものがあったが、劣化したプランとは言えないため問題なしとする。 クエリ処理時間が現行と同等、または改善すること 1項目だけ想定よりも遅くなる事象が発生したが現行側のサンプルがパラメータの値により速度の幅があり、今回のテストではその範囲内で収まっているため問題なし HWリソースの使用量が現行と同等、または改善すること CPU、メモリ、ストレージについて余裕あり(無風なので当然だが異常値がなければ良いという判断) 障害テスト 障害テストは、可用性が担保されているかだけでなく監視を通じて期待したアラートが発報されるかを確認するテストです。こちらは今回のリプレース対象サーバだけでなくWebサーバの挙動も影響してきますので、Webサーバに対しテストURLを常時発行している状態で障害を発生させる形で実施しました。確認ポイントとしては、サービス断の有無とサービス断が発生した場合の復旧時間の計測、監視設定の妥当性となります。 なお設計としては多重障害は救済対象外とし、あくまでSingle Point Of Failure(SPOF)を排除するのが方針となります。障害パターンとしてはHWの物理故障からミドルウェア障害までの範囲を対象としました。 カテゴリ 障害パターン HW障害 サーバ機(CPU) サーバ機(メモリ) サーバ機(内蔵ディスク) サーバ機(NIC) サーバ機(電源) ストレージ(コントローラ部分障害) ストレージ(コントローラノード障害) ストレージ(ディスク) SANスイッチ OS障害 OS停止 リソース不足 DB障害 アクセス不可 クエリ遅延 レプリケーション遅延 データ損失 NW障害 スイッチ(筐体) スイッチ(LAG) スイッチ(ポート) スイッチ(電源) DRサイト障害 ログ配布失敗 サーバ機(CPU) サーバ機(メモリ) サーバ機(内蔵ディスク) サーバ機(NIC) サーバ機(電源) ミドルウェア障害 WSFCプロセス障害 VSRプロセス障害 ALogプロセス障害 SCOMプロセス障害 AD障害 認証不可 運用テスト 運用テストは、システム運用中に発生しうる運用作業を手順化し弊社エンジニアが実施できる状態となっているか確認することを目的としてます。このため弊社エンジニアがテストを実施し、手順書をブラッシュアップしていく作業となります。範囲としては従来ベンダーに依頼していた作業を弊社エンジニアが実施できるようになるというリプレースの大方針の1つ「ベンダー依存の体制から脱却する」を実現するためのアクションになります。 品質管理テスト これはブラックボックスなノンリグレッションテストであり、ZOZOTOWNの品質を一定のレベルで保つために行うものとなります。データベースから見るとクエリバリエーションの網羅性が高いので、 ”全く返ってこないクエリ” を発見するのに非常に有用なものとなりました。 切替リハーサル リハーサルは2フェーズでの切替方式に合わせ、フェーズ1、フェーズ2でそれぞれ行いました。 リハーサルの目的としては以下を策定しました。 目的 詳細 手順検証 計画・準備した移行手順が実用に耐えるか 時間計測 時間内に移行処理、手順を完了できるか 作業の慣らし 事前演習することでの作業効率化 当たり前ですが、リハーサルでやったことをブラッシュアップしたものが本番切替になるのでこのタイミングで本番切替の詳細を詰め計画しなければなりません。本番切替については以下を決定しました。 項番 方針 (1) フェーズ1とフェーズ2の二段階で移行作業を行う※前述 (2) 移行作業中はZOZOTOWNのサービスを停止する(フェーズ1/フェーズ2それぞれ9時間を想定) (3) 移行当日の作業をできる限り最小化するため事前に移行可能なものは移行前日までに実施しておく (4) コンテンジェンシープランを準備する (5) 本番移行を実施する人がリハーサルを実施する (5)についてはリハーサルの方針として追加しました。上で書いた「作業の慣らし」が必要なためです。 切替方針として死守しなければならなかったのは、(2)の移行当日のZOZOTOWNの停止時間を守るということでした。これは売り上げに直結するというのはもちろんの事、切替方式の工夫次第で長くも短くもできるものという理由からです。 データ移行の方式は、まず移行当日までの作業SQL Serverの機能で現行DB→新DBへの完全リストアを行います。その後発生した更新は定期的なログ適用にて随時追いつきをかけていきます。そして移行当日はDBアクセスを完全に停止することで静止点を作り、最終ログ適用から静止点までの更新分のみを適用することで完全に同期させる方式で行いました。この作業後に、トランザクションレプリケーションの再同期およびインデックス作成という時間のかかる作業が必要になります。 作業手順のステップとしては487ステップ以上、関連チーム10チーム以上といった膨大な作業内容を9時間以内で収めるといったミッションとなりました。そして初回のリハーサルの結果は、”全然ダメ”でした。 切替リハーサル結果 ”全然ダメ”だった主な理由は、作業時間が完全にオーバーしてしまったためです。机上計算および検証により見積もっていたものの、リハーサルを実施してみると時間が全然足りませんでした。 対策案としては3つ。 案 内容 案1 ZOZOサイト停止時間を延ばす →例えばサイト停止9時間を12時間に 案2 切り替えフェーズをDB毎に複数回に分ける →2段階切替を4段階切替に 案3 今の手順を工夫して効率化、当初どおり9時間 案1/案2は共に売上げへの悪影響が避けられません。案3しか選択肢はありませんでした。各担当者に時間短縮のための仕組み改善を依頼し、並行作業できるところは極限まで並列化し、スクリプト化や半自動化で作業効率を上げるなど時間短縮をとにかく優先するよう作業内容を改善しました。 極限までの並列化により切替全体のタスクが複雑化してしまったものの、各作業チームの努力の甲斐あって再リハーサルでは予定時間に切替を終えることが可能だと分かりました。これはリハーサルの成果です。なおフェーズ2のリハーサルはフェーズ1のフィードバックを取り込むことで問題なく完了しました。 本番切替 さて本番リリースではサイト告知等、リハーサルではやれなかったタスクが入ることになります。作業時間帯もリハーサルは日中でしたが、本番切替は夜間のアクセスが少ない時間帯で行いました。 この辺の(一見影響なさそうな)違いに足元を救われて失敗するということを過去に何度も見てきているのでリハーサルやそれまでのテストで実施できなかった箇所が不安材料でした。結果的には、フェーズ1は予定時間より15分程度の遅れ、フェーズ2は予定時間内に完了しました。 トラブルについては、切替作業中に発生したものとサービス再開した後に発生したものを合わせると39件発生しました。内容的には一部のクエリの処理遅延、リハーサルでテストできなかったいう”出るべくして出た”トラブルと、リハーサルでの確認漏れやテストすべきだったのにできなかったものも多数ありこれは反省すべきことでした。 終わりに 現在、ZOZOTOWNはリプレースした基幹DB群で安定したサービスを提供しております。しかし本番リリース後、安定稼働するまでにはおよそ2~3ヵ月の日数がかかりました。リリース後発生したトラブル群とそれへの対処内容や新旧DB間での性能比較については、ここでは長くなってしまうのでまた別の機会にお話することとします。 何はともあれ、この案件に関わってくれた多くのチーム/担当者、そして機器選定からリリース、運用まで携わってくれたベンダーの方達の助けによってこの大型案件を終えることができたことに感謝します。 ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
はじめに こんにちは。ZOZO DevRelブロックの @wiroha です。3/23にJavaのオンラインイベント「 ZOZO Tech Meetup〜Java活用事例紹介〜 」を開催しました。ZOZOの開発において「Java」にフォーカスした技術選定や設計手法、設計時の考え方などを紹介するイベントです。 登壇内容まとめ 弊社から次の4名が登壇しました。 ZOZOTOWNの商品の閲覧を支えるJava (技術本部 ECプラットフォーム部 / 藤本 拓也) ZOZOTOWNのカート決済システムのリプレイス〜歩みとこれから〜 (技術本部 カート決済部 / 高橋 和太郎) Spring Boot+Redis Cache 〜検索APIにキャッシュを導入、実装時の工夫や効果〜 (技術本部 検索基盤部 / 佐藤 由弥) ZOZOTOWNの裏側に迫る!Javaで作られたBFFの開発事例を紹介 (ZOZOTOWN開発本部 ZOZOTOWNWEB部 / 小川 雄太郎) 当日の発表はYouTubeのアーカイブで視聴可能です。 www.youtube.com ZOZOTOWNの商品の閲覧を支えるJava 藤本よりこれまでと今後の商品基盤を紹介 speakerdeck.com YouTube 2:51〜 ZOZOではたくさんのリプレイスが進んでおり、Javaで動いているシステムが多数あります。VBScriptからのマイクロサービス化はダイナミックな変化です。課題を随時Issueにしていき週に1回解消する会を行っているそうです。積んだままにしない工夫をきちんと続けるのは当たり前のようで大変なことだと思います。 今後構築していく商品基盤では複数の機能を持っていたAPIを責務ごとに分割していくとのことで、メンテナンスが容易になりそうですね。ArchUnitというライブラリの紹介があり、依存関係をチェックできるのが便利そうでした。今後どうなったかもまたお伝えしたいとのことで楽しみです。 ZOZOTOWNのカート決済システムのリプレイス〜歩みとこれから〜 高橋よりカートという高負荷になりやすいシステムの話 speakerdeck.com YouTube 19:27〜 カートはセールや販売開始時といった高負荷イベントへの対応が必要です。Cart Queuing Systemを入れることでキャパシティコントロールができるようになりました。現在はAPIからSQL Serverのストアドプロシージャを呼び出すための技術選定をしています。4つの検証対象ライブラリで平均レイテンシやコード量、DB接続回数を比較していました。 事業案件を進めるチームでは業務でJavaに触れる機会が少ないため、外部講師によるJava講習会をしたり、チーム内での勉強会も週に2回程度実施したりと、学習が活発であるのはとても良いと思いました。 Spring Boot+Redis Cache 〜検索APIにキャッシュを導入、実装時の工夫や効果〜 佐藤よりキャッシュ導入の効果を紹介 speakerdeck.com YouTube 36:54〜 検索システムは検索と聞いて思い浮かべる検索フォームだけではなくランキングやサジェスト、類似画像検索や商品の並び順なども担っているそうです。Amazon ElastiCache for Redisを追加することでElasticsearchを経由しなくなり高速に検索結果を返却できるようになりました。さまざまな工夫も含めると90%以上のレイテンシが改善されたそうです。 gzip圧縮でキャッシュデータを圧縮する工夫をしたところメモリ使用率が1/3に、ネットワーク通信量が1/7に減少したのは大きな効果ですね。実践した効果を具体的な数字で知れるのは参考にしやすくて助かります。実装時の工夫の詳細として紹介していたブログ記事はこちらですので合わせてご覧いただくとさらに理解が進みます。 techblog.zozo.com ZOZOTOWNの裏側に迫る!Javaで作られたBFFの開発事例を紹介 小川よりリプレイスの説明 speakerdeck.com YouTube 55:50〜 私は以前までAndroidエンジニアだったのでBFF(Backends For Frontends)の話は興味深く聞いてしまいました。BFFはクライアントとバックエンドの中間に位置し、UIやユースケースに合わせてデータの加工をおこなう中間層のことです。 リプレイスについて3段階で説明され、徐々に保守性や開発効率が上がっているのがわかります。BFFの導入によりクライアント側の実装が簡素化され、通信量も削減、マイクロサービス側の変更に影響を受けずにBFF側で修正を完結でき開発効率の向上も見込めるそうです。モバイルアプリはリリースするのに審査が必要であったり過去のバージョンが残ったりとコントロールしづらい面があるので、BFFで吸収してもらえるのはありがたいと感じました。 カジュアル面談もできるそうなので、お話してみたい方はぜひこちらからご応募ください! hrmos.co 最後に 質疑応答の時間にはメリットだけでなくデメリットはあるのかなど、さらに深く知ることができました。それぞれ書き切れなかった内容がたくさんあるので、ぜひ資料やYouTubeのアーカイブをご覧ください! ZOZOではJavaを活用し、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co
こんにちは。WEAR Webフロントエンドチームの冨川 ( @ssssota ) です。 私たちのチームでは普段 WEAR のWebフロントエンド全般の開発から運用までを行なっています。また、あと半年ほどで10年になるVBScript+jQuery環境からNext.js/React環境へのリプレイスを進めています。 リプレイスの詳細は弊チームの藤井が書いた記事をご覧ください。 techblog.zozo.com 本記事では、WEARのWebリプレイス環境における自動テストの構成について紹介します。自動テストの構成を悩んでいる方の決断の一助になれば幸いです。 はじめに 前提 構成の決定と判断 QAチームによるE2Eテスト Playwrightによるビジュアルリグレッションテスト Vitestによる小さなテスト その他検討したテスト おわりに はじめに 先に結論を述べますが、WEARのWebフロントエンドリプレイス環境における自動テストは以下の構成としました。 「ビジュアルリグレッションテストを主軸とし、小さなテスト 1 を適宜拡充する」 自動テストは、いかに開発速度を向上させられるかに着目して考えました。 前提 この決定をするにあたり判断材料となったいくつかの前提条件を述べます。 割ける工数 現在のWEAR Webフロントエンドチームは4名で機能開発やリプレイス、運用改善などが行われています。リプレイスに注力できるのは、時によって変動はあるもののおよそ2名前後です。いくらでも時間をかけられるわけではないのでスピード感も鍵となります。 QAチームによるテスト 本記事では、自動テストの構成について紹介していますが、その構成の根底にはQAチームのエンドツーエンドな自動化されたシナリオテストと手動による探索的テストがあります。最低限の品質はここで担保されるということ、また、テストの重複などによるロスを減らすことも鍵になります。 インタラクションの量 WEARのWeb版は、コーディネートの投稿機能などは持っていません。ページにもよりますが、テストするべきインタラクションはかなり限定されています。 構成の決定と判断 今回は、以上のような条件から「規模(カバー領域)」と「コスト」にフォーカスし構成を決定しました。 要素を簡易的な図にしたものがこちらです。 それぞれの要素について解説します。 QAチームによるE2Eテスト 前提の紹介でも述べていますが、WEARにはQAチームが存在し、機能リリース時などに開発チームと連携しながらテストを行なっています。 機能リリース時とはいえ探索的なテストも行われるため、高い確度で品質が保証されます。QAチームの存在により「リグレッションをいかに防ぎ、 開発速度を上げられるか 」という観点で構成を考えるに至りました。 Playwrightによるビジュアルリグレッションテスト 低いコストで広い範囲をカバーできるという点からPlaywrightを用いたページ単位のビジュアルリグレッションテストを採用しました。 QAチームによるテストが行われるタイミング以外でも細かなリリースは行われるためにデザイン崩れが発生していないことを保証するために行われます。前提でも述べた通り、WEARのWeb版はユーザーによるインタラクションが限定されているためにページのスクリーンショットに差分がなければ挙動としてもある程度の保証がされるという判断をしています。 Playwrightによるビジュアルリグレッションテストのコードの一例を示します。 import { test , expect } from '@playwright/test' ; test ( 'Visual Regression Test sample' , async ( { page } ) => { await page. goto( '/some/target/page' ); await expect ( page ) .toHaveScreenshot ( { fullPage: true } ); } ); Playwrightの設定を済ませた上で上記の様なコードだけで1つのページのスクリーンショットが撮影、差分チェックできます。 Playwrightのスクリーンショット撮影時は自動的にアニメーションも無効になる(opt-inで有効にできます)ので不安定さを回避できますし、必要に応じてボタンクリックなども利用できます。 実際の運用としては、このテストではAPIはMSWを用いたモックデータが利用されネットワークの不安定さを極力減らしつつ行っています。また、Pull Requestではデフォルトでこのテストは実行されず、特定のラベルを付与するか、マージしたタイミングで実行されるようにしています。 Vitestによる小さなテスト 簡単なロジック、UIなどの小さなテストにはVitestを採用しています。 こちらは、Playwrightとは対照的にすべてのPull Requestで実行されます。 JestではなくVitestが採用されているのは、多くの場合で設定量が少なく済む点、トランスパイルが高速な点です。Jestでもtransformerを設定することでトランスパイルの速度を改善できますが、設定にさまざまな検討の余地が生まれてしまいます。 Pull Requestで実行されるテストは静的解析の他には、この小さなテストが主になります。ビジュアルリグレッションテストがPull Requestでは実行されないこともあり、Pull RequestでのCIの実行時間は平均で2分に収まっています。 その他検討したテスト 直近では採用しなかったものの利用していた、もしくは利用を検討したテストを紹介します。 Cypress 以前はComponent Testを利用していたがCypressの書き方が独特な点、他テストと比較してFlakyさをハンドルしづらい点など開発コストが高いためフェードアウト Playwright Component Testing Cypress Component Testの移行先として検証していたがExperimentalということもありここにコストを掛けるのは時期尚早と判断 Chromatic (Visual Regression Test) 導入はしているものの毎PRテストするかなど運用に課題 StorybookのStoryをテストでき開発コストは低く恩恵を受けやすいため、今後、運用体制を整える予定 おわりに WEARのWebリプレイス環境における自動テストの構成を紹介しました。開発速度の向上を主目的に「ビジュアルリグレッションテストと小さなテスト」を利用する構成です。 自動テストは一朝一夕で設計・構築できるものではありません。今回紹介した様に、 プロダクトの性質 、 開発体制 、 開発フェーズ 、 自動テストの目的および、その時点でのゴール などを明確にした上で中・長期的に向き合うことが大事だと思います。 WEARではこのようなサービスの品質改善に興味があり、一緒に作り上げて行ける方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com 小さなテストという名称を用いているのは、ユニットテストという単語の曖昧性を避けるためです。和田卓人氏が執筆された記事「 テストサイズ ~自動テストとCIにフィットする明確なテスト分類基準~ 」よりSmallテストを本記事では小さなテストとしています。 ↩
はじめに こんにちは、計測プラットフォーム開発本部アプリ部の中岡、永井、東原です。私たちのチームではZOZOMAT、ZOZOGLASSといった既存の計測機能の改善と、新規計測アプリの研究開発を担当しています。 その新規計測アプリとして、ZOZOFITというボディーマネジメントサービスを2022年の夏に米国でローンチしました。この記事では、ZOZOFITのiOSアプリを新規開発するにあたって、どのような技術要素を取り入れたかについてご紹介します。 目次 はじめに 目次 ZOZOFITとは 計測機能とその実装・統合 計測機能について 計測機能の実装・統合について iOSアプリの技術要素 使用技術 対応OS UIフレームワーク CI/CD パッケージ管理 その他ツール アーキテクチャ プロジェクト構成 今後の課題 おわりに ZOZOFITとは ZOZOFITは、ZOZOグループのZOZO Apparel USA, Inc.が提供するボディーマネジメントサービスであり米国でサービス提供をしています。 初代ZOZOSUITよりも大幅に計測精度を向上させたZOZOSUITを着用し、専用のスマートフォンアプリを利用することで手軽に3Dボディースキャンを行い、計測データをトラッキングできます。 計測可能な箇所は、肩幅、胸囲、腕周囲、ウエスト周囲、ヒップ周囲、太もも周囲、ふくらはぎ周囲の7箇所であり、体脂肪率も測定されます。計測データは下の画像のようにアプリ上で確認でき、過去のデータとの比較やグラフ表示が可能です。また、目標の管理機能によりそれぞれの計測部位について目標を設定でき、達成状況を確認できます。 図1:ZOZOFIT iOSアプリ 開発についてはグループ会社である ZOZO New Zealand と協業して進めています。プロジェクト全体の話は下記の記事がありますので、ぜひご覧ください。 technote.zozo.com 計測機能とその実装・統合 計測プラットフォーム開発本部アプリ部の永井です。ここでは、ZOZOFITの主要機能である計測機能の詳細と、その計測機能がiOSアプリへどのように実装・統合されているかについて説明します。 計測機能について まずは、計測機能がどのようなものかをユーザー視点で説明していきます。 計測は、スーツを着用したユーザーの全身をスマートフォンの背面カメラで360度撮影することによって行われます。以下の図は、アプリ内のチュートリアルで使われているものです。 図2:計測方法 このように計測時、ユーザーはスマートフォンをスタンドに立てかけ、そこから2mほど離れた場所に立つ必要があります。そして、その場で体を0時から12時まで回転させながら、合計12枚の写真を撮影します。 計測の最中、ユーザーには背面カメラが向けられていて画面を見ることができないため、計測を進めるための案内はすべて音声により行われます。 そうして案内に沿って計測を完了させると、アプリ上で全身の3Dモデルと計測データを見ることできます。以下の図は実際のアプリ画面です。 図3:計測結果 このように自身の身体の気になる部位について、いろいろな角度から3Dモデルを見たり、計測データの変化をグラフで追ったりできます。 計測機能の実装・統合について 計測機能の実装・統合の説明にあたって、先に計測機能のアルゴリズムを紹介します。詳細は伏せますが、簡略化すると以下のようになります。 ZOZOSUITを着用したユーザーを360度、12枚の写真として撮影 ユーザーとスマートフォンとの距離やユーザーの身体の回転具合などに問題がないかをチェックする 撮影した写真を画像処理 スーツ全体に施されたドットマーカーのパターン認識 ユーザーの身体のシルエット検出 画像処理の結果から3Dモデルを生成 生成された3Dモデルから各部位の計測データが得られる このアルゴリズムは ZOZO New Zealand が開発したC++ライブラリによって提供されており、その中で、OpenCVやMediaPipeのような画像処理・機械学習のライブラリが使われています。MediaPipeはソースコードが公開されており、利用したい機能をZOZOFIT向けにカスタマイズできることから採用に至りました。 また、計測結果の3Dモデル描画は、WebGL(Three.js)で実装されたものをWeb Viewで表示する仕組みとなっており、iOSアプリでは WKWebView が使われています。 計測機能がこのような実装となっている大きな理由は、クロスプラットフォームのためです。ZOZOFITはAndroid・iOSの2つのプラットフォームでアプリを展開しており、主要機能である計測機能については両プラットフォームで共通のものを提供することが重要でした。そのためネイティブとは切り離された、両プラットフォームに対応する技術を用いて実装されています。 ZOZOFITに限らず、これまでのZOZOMATやZOZOGLASSといった計測プロダクトでもクロスプラットフォームは大きな関心事でした。これまでの計測プロダクトについては下記の記事がありますので、興味があればぜひご覧ください。 techblog.zozo.com techblog.zozo.com さて、以下の図は、計測機能がどのように統合されているかを示したものです。 図4:計測機能の統合 iOSアプリのリポジトリ内で、サブモジュールとして計測ライブラリと3D Model Viewerのリポジトリを参照しています。計測ライブラリについては、CMakeコマンドによりXcodeプロジェクトを生成することで、ワークスペース内で利用できるようにしています。 iOSアプリの技術要素 計測プラットフォーム開発本部アプリ部の 中岡 です。ここではZOZOFIT iOSの技術要素について説明します。 使用技術 2023年3月時点では以下のような技術構成となっています。 開発言語:Swift 5.7 対応OS:iOS 15~ UIフレームワーク:SwiftUI(一部UIKit) CI/CD:Bitrise パッケージ管理:Swift Package Manager ライブラリ: Factory 、 Charts 、 SwiftGen 、 SwiftLint 、 swift-snapshot-testing など。 その他ツール:Figma、TestFlight、Firebase 対応OS 基本的に最新バージョンから1つ前のメジャーバージョンまでをサポートする方針となっています。開発当初はiOS 16がリリースされていなかったのですが、ZOZOFITがリリースされる2022年8月にはiOS 16がリリースされているということもあり開発当初からiOS 15~で開発していました。 また、開発体験に関してはiOS 14をサポートするより向上はしましたが、本アプリ開発においてそこまで大きく変わったという印象はありませんでした。 UIフレームワーク 基本的にSwiftUIをベースに開発していますが、一部実装が困難な部分はUIKitを使用しています。具体的には以下の図のように最前面にローディング画面を表示することがSwiftUIだけでは困難でした。そのためUIKitの UIWindow を使用して最前面に表示しています。 図5:ローディング表示時のView階層 CI/CD CI/CDにはBitriseを使用しており、PRが作られた際に自動でテストを実行するワークフローとApp Store Connectにアップロードするワークフローがあります。また、これらの設定の bitrise.yml は同リポジトリで管理しています。 パッケージ管理 パッケージの管理は全てSwift Package Managerで行なっています。また、SwiftLintやSwiftGenといったツールもプラグイン機能を活用しバージョン管理をしています。 その他ツール デザイン、テスト用アプリの配布はそれぞれFigma、TestFlightを使用しています。また、FirebaseはCrashlytics、Analytics、Dynamic Linksを使用するために導入しています。Analyticsの導入については下記の記事を公開しているので興味がある方はこちらをご覧ください。 techblog.zozo.com アーキテクチャ 基本的に以下のようなView、Config、Managerという構成をとっています。 View いわゆる見た目の部分です。 SwiftUI.View で書かれており、ユーザからのイベントをConfigに渡します。 Config MVVMでいうViewModelのような役割です。 ObservableObject プロトコルに準拠したオブジェクトでViewの状態管理や受け取った値をManagerに渡します。 Manager MVVMでいうModelのような役割です。アプリのビジネスロジック部分で計測アルゴリズムの実行や、計測データの管理などを行なっています。 プロジェクト構成 ZOZOFIT iOSのプロジェクト構成は以下の図のようになっています。コアロジックや共通コンポーネント、カスタムModifier等をパッケージ化しそれらをXcodeプロジェクトから呼び出しています。 図6:ZOZOFIT iOSの依存関係 今後の課題 ローンチからまだ1年足らずということもあり、現状のZOZOFIT iOSにはいくつかの課題が残っています。 まずは、テストが行いづらいという点が挙げられます。現在一部のManagerが @Published プロパティを持っており ObservableObject としてViewから参照されています。Mock化するためにこれらのProtocolを定義したいのですが、SwiftのProtocolでは @Published プロパティを定義できずコンパイルエラーとなってしまいます。そのためこれらのManagerに依存しているViewやConfigのテストが行いづらくなってしまっています。加えて、ZOZOFITはプロダクトの特性上、カメラやセンサデータを使用するため計測機能をデバッグするには実機で動かす必要があり時間と手間がかかるといった課題もあります。これについては、カメラを使わず事前に用意した画像を読み込むようにすることで改善できます。 ZOZO New Zealand の開発チームがSDKを作成する際にそのような機能を持つアプリを用意していたので、その機能をZOZOFITアプリにも取り込めると良いなと思っています。 また、計測機能の統合方法についても改善の余地があります。現在はCMakeによって、C++で書かれた計測ライブラリのXcodeプロジェクトを生成し、ワークスペース内で統合するというアプローチをとっています。しかし、理想的には計測ライブラリをXCFramework化して、統合することがよりシンプルで望ましいと考えています。 効率的に開発をするためにも今後チームで話し合いこれらの課題は解決していきたいです。 おわりに ZOZOFITのiOSアプリについてその全体像をご紹介しました。アプリは昨年リリースされたばかりであり、より多くのユーザーに使っていただくために改善や新機能の追加を行なっています。例えば現在は機能開発に加えて、データをより活用できるようにGoogle Analyticsの測定箇所を見直したり分析レポートの作成に取り組んでいます。ZOZOFITは ZOZO New Zealand との協業のプロジェクトであり関係者が多く言語の壁もあるので簡単なプロジェクトではありませんが、プロジェクト全体の改善も行いながら進めているところです。 これからのグロースを目指し、海外チームと協業しながらSwiftUIを利用してiOSアプリ開発をしていく、ということに少しでも興味のある方は以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに 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にオフロードする取り組みなどが必要でした。セッションをオフロードすることで、サーバーとセッションが分離でき、ストラングラーパターンによる置き換えが可能になりました。 セッションオフロードの詳細は、杉山の記事をご参照ください。 techblog.zozo.com カナリアリリース ZOZOTOWNはUIや機能改修によってビジネス指標に大きく影響が生じるサイトです。これを考慮し、UIや機能要件はリプレイス前と可能な限り互換性を維持し、挙動は変えないという方針を定めています。リリース前に念入りにQAテストを実施しますが、リリースでの不具合の発生や他システムに影響を及ぼすリスクは存在します。このリスクを軽減するために、一部のユーザーだけに絞り新システムを提供し、段階的にリリースするカナリアリリースを実施しています。このカナリアリリースについてはAkamai Application Load Balancerの加重ルーティングという機能を利用して実現しています。 加重ルーティングの詳細は、秋田の記事をご参照ください。 techblog.zozo.com これらの例以外にも、リプレイスサービスを構築するために CI/CD戦略 、 BFF API 、 サービスメッシュ 、 プログレッシブデリバリー などの施策を実施しベースが整ってきました。こうした背景から満を持してフロントエンドのリプレイスが始動しました。 リプレイスは、全く別のものに一気に刷新するのではなく、このようにサービスを構築するためのベースを構築し、それぞれの機能ごとにマイルストーンを設定し段階的に置き換えていくことが有効です。 フロントエンドリプレイスPhase1 リプレイスをどのページから着手するか検討した結果、ホーム画面 1 を選択しました。理由は下記の通りです。 ホーム画面で利用しているAPIは、大部分がBFF(Backend For Frontend)から提供されており、レガシーシステムへの依存が比較的少ない アクセス数や機能が多く、開発や運用のナレッジを蓄積しやすい サービスの象徴的ページであり、開発のモチベーションが湧きやすい さらにリプレイスの付加価値としてSPA(Single Page Application)化することでページ遷移を高速にし、UXの向上を考えました。具体的にはホーム画面では、下記の図のような商品のカテゴリーや性別を切り替えるタブUIがあります。 このタブUIでのページ遷移時にページ全体を読み込まず、商品データだけを動的に切り替えるSPAを実装しました。 次にフロントエンドフレームワークについてです。フレームワークはNext.jsを採用しました。選定理由は下記の通りです。 Reactベースのフレームワーク 2 ゼロコンフィグで開発を始められる ページごとのレンダリング手法を柔軟に切り替えるができる 数々のパフォーマンス最適化など新機能が毎年リリースされており、とてもアクティブに開発されている 利用している開発者が多く 3 、コミュニティーが盛んでWebに情報が多い HeadlessCMSと相性が良い 4 Next.js以外の新たに導入したライブラリを紹介します。 ライブラリ名 説明 Emotion CSSinJSライブラリ SWR データ取得とそれに関連する操作を提供する React Hooksライブラリ MSW APIモッキングライブラリ Recoil 状態管理ライブラリ openapi-typescript-code-generator OpenAPI定義からクライアントコードを生成するライブラリ それぞれの選定意図については記事の本題ではないため紹介のみとします。Emotionの選定意図は、菊地の記事をご覧ください。 techblog.zozo.com これらの新フレームワークや新技術の導入とインフラを構築することで、フロントエンドリプレイスの礎を作るマイルストーンを社内ではフロントエンドリプレイス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 を実行しているためです。 結果は下記のとおりです。 項目 レスポンスタイム (95percentile) スループット (req/sec)  スペック 高負荷 ネストが深く子要素が多いコンポーネント 364ms 5 req/sec CPU: 3core メモリ: 5GiB 中負荷 ネストの深さ子要素数がホームと同程度のコンポーネント 169ms 30 req/sec CPU: 2core メモリ: 5GiB 低負荷 Next.js の初期設定のまま 61ms 60req/sec CPU: 2core メモリ: 5GiB 高負荷の場合はパフォーマンス基準を安定的に満たし、スループットは 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カスタムサーバーは下記のように書けます。 import Next from 'next' import Fastify from 'fastify' import type { FastifyPluginCallback } from 'fastify' type Option = { isDev : boolean } const isDev = NODE_ENV !== 'production' const app = Fastify( { /** * Dev の際は Next.js のサーバーの起動までのタイムアウトを設定します。 * */ pluginTimeout : isDev ? 120_000 : 0 , } ) export const nextJsCustomServerPlugin: FastifyPluginCallback < Option > = async ( serve , option , done ) => { const app = Next( { dev : option.isDev } ) const handle = app.getRequestHandler() await app.prepare(). catch (( err ) => { serve. log . error ( 'error' , err) done(err) } ) serve.all( '/*' , async ( req , reply ) => { await handle(req.raw, reply.raw) reply.sent = true } ) serve.setNotFoundHandler( async ( req , reply ) => { await app.render404(req.raw, reply.raw) reply.sent = true } ) done() } app. register (nextJsCustomServerPlugin, { isDev } ) app.listen(PORT, HOST, () => { app. log . info ( `started server` ) } ) Fastifyには Hooks というAPIがあり、リクエストからレスポンスまでのライフサイクルイベントをフックにして処理を実行できます。前述したリダイレクトの実装などはリクエストをフックにして下記のように書けます。 app.addHook( 'onRequest' , ( req , reply , done ) => { const isNextAssetsPath = req. url . startsWith ( '/_next/' ) const isSpPath = req. url . startsWith ( `/sp/` ) const isMobileDevice = req. headers [ 'user-agent' ]?.includes( 'Mobile' ) && !req. headers [ 'user-agent' ]?.includes( 'iPad' ) if (isNextAssetsPath || isPublicPath) { done() } else if (isMobileDevice && !isSpPath) { reply.redirect( 302 , path. join ( '/sp' , req. url )) } else if (!isMobileDevice && isSpPath) { reply.redirect( 302 , req. url . slice ( '/sp' . length )) } else { done() } } ) 次にロギングについて紹介します。弊社はサーバーアクセスログを JSON Lines という形式で標準出力しています。JSON形式であれば jq などのツールを用いてデータ加工や集計を簡単に扱うことができるためです。このJSON形式のログの標準出力には pino というライブラリを用いています。pinoはFastifyとの相性は抜群で、使い方はloggerにpinoを指定するだけです。下記はリクエストの latency などの情報をアクセスログに出力する例です。 import Fastify from 'fastify' import pino, { LoggerOptions } from 'pino' // ログ出力も下記のようにオプションを使って柔軟にカスタマイズ可能 const pinoOptions: LoggerOptions = { timestamp : () => `,"timestamp":" ${ new Date ( Date . now ()). toISOString () } "` , formatters : { level ( label ) { const severity = label. toUpperCase () return { severity } } , } , } const app = Fastify( { logger : pino(pinoOptions) } ) app.addHook( 'onResponse' , ( req , reply , done ) => { const latency = reply.getResponseTime() / 1000 req. log . info ( { latency } ) done() } ) 下記のようにJSONが出力されます。 { " severity ":" INFO "," timestamp ": " 2023-03-09T09:27:48.963Z "," latency ":" 0.2675443229675293 " } Sentryでのエラーログ集積とソースマップのアップロード アプリケーションのエラートラッキングツールにはSentryを利用しています。Sentryはnextjs向けのSDKとして @sentry/nextjs を提供しており、このSDKを利用して実装できます。カスタムサーバーを使っているため、カスタムサーバー向けに @sentry/node も併用して利用します。また、どの環境で起きたエラーか特定をしやすくするために、 enviroment 変数に カスタムサーバー 、 Next.jsのサーバーサイド 、 Next.jsのクライアントサイド の3つの環境の値を設定しています。 const dsn = process .env.NEXT_PUBLIC_SENTRY_DSN if (dsn) { SentryNode.init( { dsn , environment : '[CUSTOM SERVER]' // next.js の SSR のエラーの場合は [NEXTJS SERVER] CSR の場合は[NEXTJS BROWSER]と指定する } ) } 次に、ソースマップについてです。ソースマップファイルをSentryにアップロードすることで、Next.jsでビルドしたJavaScriptコードのエラーではなく、ビルド前のソースコードでエラーの該当箇所を示してくれる機能があります。詳しくは Source Maps をご覧ください。この機能を活用しないと、エラーの原因を突き止めるのは困難です。活用するためには、next.jsのコンフィグファイルを編集し、 next build の際にソースマップファイルをアップロードする必要があります。下記が next.config.js 設定例です。 const { withSentryConfig } = require( '@sentry/nextjs' ) const buildConfig = { sentry : { hideSourceMaps : !!isProdOrStg, // 環境によってはソースマップの参照をソースコードに含めない。 widenClientFileUpload : true , // next build の際に Sentry にクライアントのソースマップファイルをアップロードするフラグ } , } const sentryWebpackPluginOptions = { silent : false , dryRun : (isLocal || isGitHubAction) ? true : false , // ソースマップのアップロードの有無のフラグ、環境によっては、アップロードを実行しない。例えば、ローカル環境や、GitHub Action などでは実行しないように設定ができる release : process .env.BUILD_ID ?? undefined , org : '<ORG NAME>' , authToken : process .env.SENTRY_AUTH_TOKEN, project : '<PROJECT NAME>' , debug : false , } module .exports = withSentryConfig(buildConfig, sentryWebpackPluginOptions) 注意点としては、ソースマップをユーザーに公開したくない場合は hideSourceMaps を有効にすることです。ソースマップから開発コードの復元が可能でセキュリティーの観点からこれを避けたかったため、本番環境ではこの設定を有効にしています。逆に開発環境ではデバッグを効率的に行うため、 hideSourceMaps を無効にしてソースマップ機能を有効にしています。 ソースマップアップロードを可能にするDockerイメージ作成 前述の通りNext.jsのSSRサーバーはKubernetes上で稼働します。そのためには、Next.jsのDockerコンテナアプリケーションをビルドする必要があります。Next.jsのドキュメントに Dockerfileのサンプル が提示されていますので、これをベースに作成します。加えて、 こちらのベストプラクティス集 も参考にしました。Next.jsのドキュメントだと node-alpine のイメージが使われていますが、 node-slim 6 をベースに構築しました。下記がDockerfileの一部です。 # <version>は任意のversionを当てはめてください。 FROM node:<version>-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN apt-get update RUN npm ci FROM node:<version>-slim AS builder RUN apt-get update WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . # ビルド際に環境変数をアプリケーションが参照できるようにする ARG NEXT_PUBLIC_SENTRY_DSN ENV NEXT_PUBLIC_SENTRY_DSN $NEXT_PUBLIC_SENTRY_DSN ARG SENTRY_AUTH_TOKEN ENV SENTRY_AUTH_TOKEN $SENTRY_AUTH_TOKEN RUN npm run build # ソースマップをSentryにアップロードすれば不要なため.map拡張子のファイルを削除するnode.jsスクリプトを実行 RUN node script/removeSourceMap.js # devDependencies のファイルを削除して軽量化 RUN npm prune --production FROM node:<version>-slim AS runner # 略... Next.jsのDockerfileの特徴的な点は、イメージ作成時にCSS、JavaScriptなどのアセットファイルのビルドを実行することです。そのため、アプリケーションのサーバーの起動時ではなく、イメージ作成時に必要な環境変数を設定する必要があります。これを実現するために、ARG、ENV命令を使用します。例では NEXT_PUBLIC_SENTRY_DSN 、 SENTRY_AUTH_TOKEN を記述してます。注意点としては、環境変数をブラウザに公開する場合の名称です。 Next.jsでは NEXT_PUBLIC_ というプレフィックス をつければ公開される環境変数になります。例では、 SENTRY_AUTH_TOKEN はSentryにソースマップをアップロードするためにSDKで必要な値のため、公開は不要のためプレフィックスはつけません。 また、ソースマップについても注意が必要です。前述の通り、Next.jsの設定ファイルに hideSourceMaps:true と設定することで、ソースマップファイルへの参照は消されますが、ソースマップファイル自体は残り続けます。そのため、ファイル自体も削除する必要があります。以下は、Node.jsスクリプトを使用して .map 拡張子のファイルを削除する例です。 const fs = require ( 'fs' ) const path = require ( 'path' ) const FileType = { file : 'file' , directory : 'directory' , unknown : 'unknown' , sourceMap : '.map' , } const getFileType = ( path ) => { try { const stat = fs . statSync ( path ) if ( stat . isFile ()) { return FileType . file } if ( stat . isDirectory ()) { return FileType . directory } return FileType . unknown } catch ( e ) { return FileType . unknown } } const getFileList = ( dirPath ) => { const ret = [] const paths = fs . readdirSync ( dirPath ) paths . forEach (( p ) => { const filePath = path . resolve ( dirPath , p ) if ( getFileType ( filePath ) === FileType . file ) { ret . push ( filePath ) } if ( getFileType ( filePath ) === FileType . directory ) { ret . push ( ... getFileList ( filePath )) } return }) return ret } const sourceMapFileList = getFileList ( './.next/static' ) . filter ( ( p ) => path . extname ( p ) === FileType . sourceMap ) sourceMapFileList . forEach (( filePath ) => { fs . unlink ( filePath , ( err ) => { if ( err ) throw err }) }) 最後に npm prune を実行して、不要なファイルを削除しDockerイメージの軽量化を図ります。これによって node_module の中にある TypeScript やライブラリの型定義など、ビルド以降は利用しないライブラリのファイルを削除します。 ナレッジの紹介は以上です。 効果と今後の課題 新システムのリリースは、リクエストの1%、20%、50%、100%と徐々にルーティングさせるカナリアリリースによって、各段階で検証し、不具合がないことを確認できました。これにより、インフラやフロントエンドの技術的なベースの構築、Next.jsの開発ナレッジの獲得が達成されました。また、ホーム画面ではSPA化によって、タブの切り替えなどのユーザー体験が向上しました。しかし、課題として残る点もあります。1点目はパフォーマンスです。CDNでキャッシュされることで、 TTFB の値は 200ms(95percentile) 以内でレスポンスできています。この数値は、600msという遅いと判断される基準よりもかなり余裕がある数値です。一方で、 Web Vitals の他の基準である、FCP、LCP、CLSなどの数値はまだまだ改善の余地があります。現在は、レンダリングの最適化や画像サイズの最適化などについて検討中です。2点目は、コスト最適化です。ホーム画面はSSRのキャッシュが要件的に可能でしたが、不可能なページも今後発生する可能性があります。この場合は、CDNでのSSRを実現できないか検討しています。 まとめ 本記事では、ZOZOTOWNのホーム画面のリプレイスをどのようなシステム構成で段階的に実現したのかの事例を紹介しました。加えて、最適なレンダリング選択やCDNでのキャッシュ、カスタムサーバー、Sentryへのソースマップのアップロードなどについて説明しました。いずれもNext.jsのアプリケーションをプロダクションレディにするナレッジです。 歴史が長く、アクセス数の多いサービスを段階的にリプレイスするためには、事前にマイルストーンを設定し、インフラ的なベースの構築を進行していくことが重要です。さらに、こうしたインフラのベースの仕組みや意図についてアプリケーション開発者が理解し、Next.jsなどのフレームワーク固有の設定ナレッジを蓄積していくことで運用が可能になります。これまでフロントエンド開発では活用していなかったNext.jsはもちろんCDNやNode.js、Kubernetesなどを用いる術を今回得ました。これらのツールを活用し、より高い品質のサービスを提供していきたい考えです。 ZOZOでは、そんなサービスを一緒に作り上げてくれる方を募集中です。ご興味のある方は、下記のリンクからぜひご応募ください。 corp.zozo.com ホーム画面とは具体的には https://zozo.jp/ 、 https://zozo.jp/shoes/ 、 https://zozo.jp/cosme/ の画面をさします。 ↩ 既にZOZOTOWNはReactを用いて開発しており、開発者のノウハウも蓄積されていたため。 ↩ state of js 2022 のデータを見ても近年利用率1位を維持しています。 ↩ ZOZOではmicroCMSを活用 していますが、技術スタックの相性の良さから、これまで以上にmicroCMSを使って効率的に開発できるようになりました。 ↩ Server Rendering vs Static Rendering ↩ node-slimは、Debian Linuxをベースとした軽量なDockerイメージです。これは、Alpine Linuxをベースとしたnode-alpineよりもわずかにイメージサイズは大きいです。しかし、Debianベースの方が広範なツールやライブラリを利用でき、汎用性が高いためnode-slimを採用しています。 ↩
はじめに こんにちは、計測プラットフォーム開発本部SREブロックの近藤です。普段はZOZOMATやZOZOGLASS、ZOZOFITなどの計測技術に関わるシステムの開発、運用に携わっています。 計測プラットフォーム開発本部では、複数のプロダクトを開発運用していますが、リリース作業はプロダクト単位で行っています。プロダクトによってローンチから数年経過し安定傾向のものもあれば、ローンチしたばかりで機能開発が盛んなものもある状態です。 複数のプロダクトを管理する上では当然の状況ですが、プロダクト単位でリリース作業手順が異なり、手順そのものにも課題がある状態でした。 本記事では、リリース作業で課題となっていた部分の紹介と、それぞれの課題に対する対応策についてご紹介します。 目次 はじめに 目次 現状 課題と対応方針 リリース作業の自動化 リリース作業の自動化をする上での必須条件の確認 自動化が必要な箇所の洗い出し 自動化対応 リリースアナウンス 負荷試験の結果確認 CIが通過した場合、PRのマージ BotUserが作成するPRの自動マージを有効化 ImageUpdaterが作成するPRの自動マージを有効化 負荷試験が成功した際に自動マージを有効化する リリース粒度のミニマム化 リリース手順、ブランチ戦略の統一 振り返り 導入効果 導入後に顕在化した課題 終わりに 現状 これまであった課題をお話しする前に、現状のプロダクト毎のデプロイ方法、ブランチ戦略、リリース頻度をご紹介します。 プロダクト名 デプロイ方法 ブランチ戦略 リリース頻度 ZOZOMAT(2020/02ローンチ) ArgoCD 1 Git Flow 隔週に一度の定期リリース ZOZOGLASS(2021/03ローンチ) ArgoCD Git Flow 隔週に一度の定期リリース ZOZOFIT(2022/08ローンチ) ArgoCD 2 GitHub Flow mainが更新されたらリリース 上記の3つのプロダクトで、デプロイ方法は統一されていますが、ブランチ戦略が異なるため、リリース手順が微妙に異なる状態でした。 ブランチ戦略は昨年から新しいプロダクトはGitHub Flow、それ以前はGit Flowを採用していました。 Git Flowを採用していた背景として、昔は動作確認を手動で行っており、都度のPR単位でリリースすると動作確認の工数がとても高くなる状況でした。このため、リリースを定期作業とすることで作業工数を抑えていた経緯があります。 昨年からデプロイパイプラインのリアーキテクトやArgo Rolloutsの導入なども進み、現状は動作確認やロールバックが自動化されている状態でした。このため、新しいプロダクトではGitHub Flowを採用しています。リリース手順に関しては、GitHubのIssue Templateで管理しており、リリースのタイミングで担当者がIssueを起票していました。 Git FlowとGitHub Flowのブランチ戦略で大きな違いは、GitHub Flowはmainが常にリリース可能な状態であることです。 Git Flow GitHub Flow 弊チームで採用していたGit Flowですが、アプリケーションリポジトリではいくつかオリジナルのものに変更を加えてあります。特徴はmainブランチとreleaseブランチという2つのプライマリブランチを保持している点です。developブランチはありません。通常の開発は、releaseブランチからfeatureブランチを作成して行います。リリース作業時はreleaseブランチをmainブランチにmergeし、mainブランチをリリースします。 課題と対応方針 このような状況の中で、課題は大きく3つありました。なるべくシンプルに、なるべく楽にしたい、という理想ベースでそれぞれの課題への対応方針を定めました。 No. 課題 方針 1 手動によるリリースのため、人の工数が取られる リリース作業の自動化 2 複数の変更が一度にリリースされるため、パフォーマンスの変更要因の特定が困難 リリース粒度のミニマム化 3 リリース手順やブランチ戦略がプロダクト毎に異なり、認知負荷が高い リリース手順、ブランチ戦略の統一 リリース作業の自動化 リリース作業の自動化については、導入する上での必須条件を整理し、対応が必要な箇所の洗い出しを行いました。 リリース作業の自動化をする上での必須条件の確認 条件 対応済み PRのマージが自動化されていること 動作確認が自動化されていること ✅ 問題が発生した場合、自動でロールバックが行われること ✅ 補足すると、動作確認はスクリプトで自動化されており、ロールバックはArgo Rolloutsによって自動化されていました。Argo Rolloutsに関しては、カナリアリリースについてのブログ記事が公開されているので、気になる方はこちらの記事をご参照ください。 techblog.zozo.com 自動化が必要な箇所の洗い出し リリース作業の流れを簡略化して図にすると以下のようになります。自動化前の状態で人が行っていた作業は、図のオレンジ色の部分になります。 自動化前 自動化後 人が行っていた作業は、PRのマージと、Slackへのアナウンス、負荷試験の結果確認のみでした。なお、ここでいうPRのマージは、releaseブランチをmainブランチにマージするという作業です。個別の修正はreleaseブランチにマージする時点でレビューを受けており、実質CIが通過していることを確認だけしていた状態です。 自動化対応 自動化が必要な作業の洗い出しが終わったので、次は各作業の自動化を行いました。 リリースアナウンス Argo Rolloutsでは、ロールアウトの開始終了を通知できます。各Subscribe可能なイベントについては 公式サイト を参照ください。この仕組みを利用することで、Kubernetesマニフェストを数行変更するだけで、通知の自動化ができました。 もともと開発チーム向けに通知はしていたのですが、ロールアウトの開始と終了をこれまでリリースアナウンスを行っていたSlackチャンネルにも通知するようにしました。通知は複数のチャンネルに飛ばすことができ、その場合は ; で繋ぎます。 # ZOZOGLASSのAPIサーバー用のRolloutの設定 apiVersion : argoproj.io/v1alpha1 kind : Rollout metadata : name : api-server-rollout annotations : notifications.argoproj.io/subscribe.on-rollout-aborted.slack : rollout_notification notifications.argoproj.io/subscribe.on-rollout-completed.slack : rollout_notification;zozoglass_release notifications.argoproj.io/subscribe.on-rollout-step-completed.slack : rollout_notification notifications.argoproj.io/subscribe.on-rollout-updated.slack : zozoglass_release notifications.argoproj.io/subscribe.on-analysis-run-failed.slack : rollout_notification 負荷試験の結果確認 リリース時にパフォーマンス上問題がないか確認するため、負荷試験を実行し、Gatlingが生成したレポートを人が目視で確認していました。 Gatlingでは、負荷試験の結果を評価するAssertionsが提供されています。Assertionsについての詳しい設定方法については 公式サイト を参照ください。Assertionsが提供する responseTime.percentile(99) を利用することで、レスポンスタイムの99パーセンタイルの期待値を設定できます。同様に failedRequests.percent を利用することで、エラー率の期待値を設定できます。期待値を満たさなかった場合、Gatlingのテストは失敗となります。 導入は既に目標値が定まっていたので、既存のGatlingのコードを数行変更することで、結果確認の自動化ができました。 setUp( scenarioMeasure .inject(rampUsers(LoadTestMeasureUsers.toInt).during(LoadTestSeconds.toInt)), scenarioBrowseCosmeticsRecommendations .inject(constantUsersPerSec(GetFaceColorRps.toInt).during(LoadTestSeconds.toInt).randomized) ).protocols(httpProtocol) // Then // 今回追加したAssertions部分 .assertions( forAll.responseTime.percentile( 99 ).lt(ResponceTimeThreshold.toInt), forAll.failedRequests.percent.lte(FailedRateThreshold.toDouble) ) CIが通過した場合、PRのマージ PRの自動マージに関しては、自動でマージすること自体は簡単でしたが、いくつか例外を考慮する必要がありました。今回PRの自動マージを行いたいリポジトリは、アプリケーションのコードを管理するリポジトリとKubernetesマニフェストを管理するリポジトリの2つがありました。 計測プラットフォームでは、ArgoCDを利用することでGitOpsに準拠した形でのデプロイパイプラインを採用しています。ArgoCDを利用したデプロイパイプラインについては、ArgoCDの導入についてのブログ記事が公開されているので、気になる方はこちらの記事をご参照ください。 techblog.zozo.com それぞれのリポジトリで作成されるPR、自動マージをブロックしたい条件を整理した結果を以下の表にまとめます。 リポジトリ PRの種別 自動マージをブロックしたい条件 アプリケーションリポジトリ ・BotUserが作るライブラリ更新のPR ・人が作る機能追加/BugFixのPR ・releaseブランチからmainブランチへのPR ・CIが失敗したPR ・BotUser以外が作ったPR Kubernetesマニフェストリポジトリ ・ImageUpdaterが作るアプリケーションImage更新のPR ・人が作るKubernetesマニフェストに対する更新PR ・mainブランチからreleaseブランチへのPR ・CIが失敗したPR ・人のコミットが入ったPR ・負荷試験が失敗した場合のreleaseブランチへのPR BotUserが作成するPRとImageUpdaterが作成するPRに関しては、自動マージするために以下のようなGitHub Actionsを追加し、CIが通過した場合は自動マージするようにしました。なお、自動マージは GitHubから提供されている自動マージ機能 を利用しています。この機能により、ブランチプロテクションルールを守りつつ自動マージを容易に実装できました。自動マージの利用にはリポジトリ側で設定を有効化する必要があるので、設定時には公式の設定手順を参照ください。 BotUserが作成するPRの自動マージを有効化 name : BotUser Auto Merge on : pull_request : branches : - 'main' permissions : pull-requests : write contents : write jobs : auto-merge : runs-on : ubuntu-latest if : ${{ github.actor == 'bot-user' }} environment : name : ${{ inputs.env }} steps : - name : Checkout uses : actions/checkout@v3 - name : Approve PR shell : bash run : gh pr review "$PR_URL" --approve env : PR_URL : ${{ github.event.pull_request.html_url }} GH_TOKEN : ${{ secrets.GITHUB_TOKEN }} # BotUserが作るPRにapproveするので、GitHubActionのTOKENを指定している - name : Auto Merge PR shell : bash run : gh pr merge --auto --merge "$PR_URL" env : PR_URL : ${{ github.event.pull_request.html_url }} GH_TOKEN : ${{ secrets.GITHUB_TOKEN }} ImageUpdaterが作成するPRの自動マージを有効化 name : Create Image Updater PR and Auto Merge inputs : argocd-application : description : ArgoCD Application Name required : true application-repo : description : Application Repository of GitHub required : true source-image : description : image watched by Image Updater. e.x. api-server required : true target-images-to-duplicate-image-tag : description : images apart from api-server. The format of item is `imageA,imageB,imageC` required : true bot-user-pat : required : true runs : using : composite steps : - name : Update production file id : create-commit shell : bash run : | git config --global user.email "action@github.com" git config --global user.name "GitHub Action" STG_FILE="kubernetes/overlays/staging/.argocd-source-${{ inputs.argocd-application }}.yaml" PRD_FILE="kubernetes/overlays/production/.argocd-source-${{ inputs.argocd-application }}.yaml" # 次のステップで変数を利用する COMMIT_HASH=$(grep -oE "${{ inputs.source-image }}:[0-9a-z\-]+$" $STG_FILE | sed "s/${{ inputs.source-image }}://g" ) echo "COMMIT_HASH=${COMMIT_HASH}" >> $GITHUB_OUTPUT # 更新されたapi-serverのタグを全イメージに反映する。 IMAGES=${{ inputs.target-images-to-duplicate-image-tag }} for image in ${IMAGES//,/ }; do grep -oE ".+${{ inputs.source-image }}:[0-9a-z\-]+$" $STG_FILE | sed "s/${{ inputs.source-image }}/$image/g" >> $STG_FILE done # ステージング用のイメージタグの変更を本番用のファイルにも反映させる sed -e 's/${STG_AWS_ACCOUNT_ID}/${PRD_AWS_ACCOUNT_ID}/g' $STG_FILE > $PRD_FILE git add $STG_FILE git commit -m 'update image tags on staging' git add $PRD_FILE git commit -m 'update image tags on production' git status git push origin HEAD - name : Create PR to main branch id : create-pr uses : actions/github-script@v6 with : script : | const { COMMIT_HASH } = process.env const { repo, owner } = context.repo; const result = await github.rest.pulls.create({ title : '[Image Updater] イメージタグの更新' , owner, repo, head : '${{ github.ref_name }}' , base : 'main' , body : `## アプリケーションの変更内容\nhttps://github.com/st-tech/${{ inputs.application-repo }}/commit/${COMMIT_HASH}\n## 反映方法\n - staging リリース: main ブランチにマージすると自動で staging 環境の Pod が入れ替わる。\n - production リリース: main から release ブランチの PR をマージすると自動で production 環境の Pod が入れ替わる。` }); process.env.PR_URL = result.data.html_url env : COMMIT_HASH : ${{ steps.create-commit.outputs.COMMIT_HASH }} #[ NOTE ] GH_TOKENでBotUserのpatを指定している理由はsecrets.GITHUB_TOKENだとPR作成者とapprove者が同一になってしまい、Approveできないため - name : Approve PR shell : bash run : gh pr review "$PR_URL" --approve env : PR_URL : ${{ steps.create-pr.outputs.PR_URL }} GH_TOKEN : ${{ inputs.bot-user-pat }} - name : Auto Merge PR shell : bash run : gh pr merge --auto --merge "$PR_URL" env : PR_URL : ${{ steps.create-pr.outputs.PR_URL }} GH_TOKEN : ${{ inputs.bot-user-pat }} 例外として、Kubernetesマニフェストのreleaseブランチに対する自動マージは人のコミットが含まれない、かつ、負荷試験が成功した場合のみ有効化する必要がありました。このケースだけGitHub Actionsではなく、負荷試験の成功後にスクリプトで自動マージするようにしました。 負荷試験が成功した際に自動マージを有効化する #!/usr/bin/env bash # GH_TOKENはJobの環境変数で設定、BASE(release)とHEAD(main)は動作確認を手軽に行うために環境変数から取得する pr_url = `gh search prs --state open --repo st-tech/ ${GIT_REPOSITORY} --base ${GIT_BASE_BRANCH} --head ${GIT_HEAD_BRANCH} --sort created --limit 1 --json url | jq -r . [] .url` # check only argocd-image-updater or GitHub Action and BotUser gh pr view --repo st-tech/ ${GIT_REPOSITORY} $pr_url --json commits --jq ' .commits[].authors[] |select(.name|test("(argocd-image-updater|GitHub Action|bot-user)")|not) ' | grep email contain_human_commit = $? if [ $contain_human_commit == 0 ]; then message = " <!here>[ " ${PRODUCT_NAME} " ] Stopped Auto Merge ${pr_url} " echo $message | ./slack.sh exit 0 fi # approved & auto-merge enabled gh pr review $pr_url --approve --repo st-tech/ ${GIT_REPOSITORY} approved = $? gh pr merge --auto --merge $pr_url --repo st-tech/ ${GIT_REPOSITORY} auto_merge = $? if [ $approved == 0 ] && [ $auto_merge == 0 ]; then message = " <!here>[ " ${PRODUCT_NAME} " ] Merged Release PR ${pr_url} " else message = " <!here>[ " ${PRODUCT_NAME} " ] Failed Auto Merge ${pr_url} " fi リリース粒度のミニマム化 リリース作業の自動化対応が完了した後、アプリケーションリポジトリのブランチ戦略をGitHub Flowに統一しました。リリース作業の自動化とブランチ戦略の変更により、1PR毎にリリースが行われる様になりました。 リリース手順、ブランチ戦略の統一 リリース作業を自動化したことで手順は統一され、ブランチ戦略もGitHub Flowに統一されました。 振り返り リリース作業の自動化対応とブランチ戦略を変更することで、最終的には以下のような状態になりました。 プロダクト名 デプロイ方法 ブランチ戦略 リリース頻度 ZOZOMAT ArgoCD GitHub Flow 随時 ZOZOGLASS ArgoCD GitHub Flow 随時 ZOZOFIT ArgoCD GitHub Flow 随時 結果、GitHub Flow with GitOpsとタイトルで挙げた状態に辿り着きました。 導入効果 導入後に元々の課題が解決されたか確認してみたところ、以下のような結果になりました。 リリース粒度 1リリースに要する作業時間 リリース頻度 変更前 5-10のPRをまとめてリリース 2時間 1-2/月 変更後 PR単位でリリース 5分 15-20/月 まず、リリース粒度に関しては、PR単位でのリリースとなりました。課題だった複数の変更が同時にリリースされ、パフォーマンスの変更要因の特定が困難な状況は解消されました。 1リリースに要する作業時間は、これまで2時間かかっていたものがPRのマージだけになったので5分に短縮されました(なお、PRのレビュー時間はリリース作業とは別として扱っています)。リリース頻度は課題ではなかったですが、こちらも大きな変化があったので記載しておきます。これまで1-2/月だったものが15-20/月になっており、7倍以上に増えました。 導入後に顕在化した課題 負荷試験の失敗 導入直後にストックされていたPRが短時間に連続でマージされ、負荷試験を立て続けて行う状態が発生しました。これにより想定以上の負荷がかかる状態となり、負荷試験の結果が目標値を下回りました。結果として、自動リリースが失敗しました。 この課題に関しては、切り替え直後の一時的な問題だと判断し、恒久対応は行っていない状態です。開発ペースが上がると顕在化する問題のため、負荷試験の排他制御などの対応を将来的には入れる可能性があります。 祝祭日にも自動リリースされてしまう ライブラリ更新系のPRは自動作成の曜日は平日のみとすることで、極力人がいる時間帯にリリースが発生するようにしていました。 ただ、祝祭日は考慮していなかったので、どうしようかという議論が導入後の祝日を迎えてされました。 結果として、問題があった場合は検知されリリースが止まる、万が一リリースされてしまっても自動でロールバックされるため、祝祭日は考慮しない形となりました。 終わりに 今回のご紹介させていただいたGitHub Flow with GitOpsの導入は、これまでの改善があってこそできた形です。日々の改善に助けられた形で、リリース作業の自動化に踏み切れたので、継続して改善を続けてくれたチームメンバーへの感謝が凄まじかったです。 GitHub Flowはより早く価値を届ける事が強く求められる、更新が活発なプロダクトこそ恩恵を得られる印象が強いです。 今回の改善では、小さい粒度でリリースすることやリリース速度の高速化は、プロダクトのフェーズに関わらず得られる恩恵があると再確認できました。 計測プラットフォーム開発本部では、今回紹介させていただいたように、新規サービスの開発だけでなく既存サービスの改善も日々行っています。このような環境を楽しみ、サービスを一緒に盛り上げていける方を募集しています。少しでもご興味のある方は以下のリンクからぜひご応募ください。 corp.zozo.com Git Flowは、Gitのブランチ戦略の1つです。mainブランチとは別にプライマリブランチを保持することで、リリースタイミングを柔軟にコントロール可能とします。Git Flowに関しての詳細は、原著である A successful Git branching model を参照ください。 ↩ GitHub Flowは、GitHub社が採用しているGitのブランチ戦略です。GitHub Flowに関しての詳細は、 公式のガイド を参照ください。 ↩
はじめに こんにちは。計測プラットフォーム開発本部バックエンドチームの佐次田です。普段はZOZOMATやZOZOGLASSなどの計測技術に関わるシステムの開発、運用に携わっています。去年の夏に、ZOZOFITというサービスを北米向けにローンチしました。 本記事では、ZOZOFITのローンチまでに遭遇した意思決定における課題と、ADRというドキュメンテーション手法を用いた解決までの取り組みについて紹介します。 目次 はじめに 目次 計測プラットフォーム開発本部 バックエンドチームとは ZOZOFITとは 開発中に直面した課題 過去の背景が分からず決断しにくい 意思決定の結論が追いにくい 意思決定の認識合わせに時間がかかる ADRの導入 ADRとは 展開 ADRのフォーマット 使用ツール チームへの展開 ADRの一例 振り返り 課題はどう解決されたのか メリット デメリット 最後に 計測プラットフォーム開発本部 バックエンドチームとは 計測プラットフォームバックエンドチームは、ZOZOGLASS/ZOZOMAT/ZOZOSUITによって採集される計測データにまつわるバックエンド開発を担うチームです。アプリやブラウザに対するクライアントAPIや、ZOZOTOWN内部のマイクロサービスのAPIにおいて、徹底的に低レイテンシにこだわりを持つことと、高可用性を保つことを目指しています。 ZOZOFITとは ZOZOFITは2022年に発表した体型管理を目的としたフィットネスアプリです。ZOZOSUITの計測技術を利用したサービスであり、2023年3月時点では、体型計測および身体3Dモデルのデータ・体脂肪率の表示機能を提供しています。 開発中に直面した課題 ZOZOFITの開発を進めるにあたり先んじてアーキテクチャを選定する必要がありました。選定基準として、過去に運用実績があるZOZOSUITやZOZOMATなどの知見は重要なものでした。実際に私たちのチームではスピードと品質を担保するために過去実績があるアーキテクチャを採用することに決め改善する必要があるものは新しいチャレンジを行うことに決めました。その後、開発を進めるにあたり以下の課題に直面しました。 過去の背景が分からず決断しにくい 私たちのチームはメンバーの半数が新しく参画したメンバーであり、過去の決定はまばらに文書化されている状態でした。メンバー内でも過去の意思決定を把握できているメンバーが偏っている状態であり、新規メンバーは決定の背後にある動機を理解できていない状態でした。新規事業においてはスピード感も重要となるため、ローンチまでに過去の決定をキャッチアップするための同期的なコミュニケーションも取りにくい状態でした。そのため、新規メンバーは新しい意思決定に踏み込みにくい状態となっていました。 意思決定の結論が追いにくい ZOZOFITにおける意思決定はアジャイルに何度も行われ、意思決定の機会は数多くありました。しかし、過去の決定はSlackでの会話やGitHubでのコミュニケーションなどに点在しており、最終的な結論を理解するためには多くの議論を遡って見ていく必要がありました。議論が追いきれなかった場合は再度同じ提案をする必要があり、何度も同じテーマについて会話する状況となっていました。 意思決定の認識合わせに時間がかかる 私たちのチームはフルリモートでの業務を主としており、海外チームと共に開発を進めています。そのため非同期でのコミュニケーションを重要視しており、SlackやGitHub等のツールを使って積極的に会話をしています。 ZOZOFITにおける海外チームとの協業についてはブログ記事が公開されていますので、気になる方はこちらの記事をご参照ください。 technote.zozo.com より良い決定のためには、背後にある動機や判断材料が重要となります。しかし、背景などの情報が伝わっていない状態で会話を進めていたため、情報を集めるために各チームと都度コミュニケーションが発生していました。また、非同期でのコミュニケーションはレスポンスがすぐに返ってくるとは限らないため、別作業とコミュニケーションでのスイッチングコストも課題となっていました。 ADRの導入 私たちは上記の課題を解決するためにADRという手法を導入することにしました。 ADRとは ADRとはArchitecture Decision Recordの略称であり重要なアーキテクチャの意志決定を、背景、結果と共に記録したドキュメントです。 Micheal Nygard氏は「 DOCUMENTING ARCHITECTURE DECISIONS 」においてADRの思想について言及しており、意思決定の理由と背景を追跡することは難しいため記録しておくべきだと述べています。 One of the hardest things to track during the life of a project is the motivation behind certain decisions. A new person coming on to a project may be perplexed, baffled, delighted, or infuriated by some past decision. Without understanding the rationale or consequences, this person has only two choices: Blindly accept the decision. Blindly change it. プロジェクトの中で最も追跡が困難なことの1つは、ある決定の背後にある動機である。 プロジェクトに新しく参加した人は、過去の決定に戸惑い、困惑し、喜び、あるいは激怒するかもしれません。 このような場合、その理由や結果を理解できないまま、以下のどちらかを選択することになります。 決定を盲目的に受け入れる やみくもに変更する 実際に私たちのチームにおいても決定の背景や動機を読み取れない問題が起きており、導入することで課題解決に繋がると考えました。 しかし、ADRは決定のドキュメントを記録していく手法であり、 Agile Manifesto においては包括的なドキュメントより動くソフトウェアを重視すると記載されています。その問題については、以下のように述べています。 Agile methods are not opposed to documentation, only to valueless documentation. Documents that assist the team itself can have value, but only if they are kept up to date. Large documents are never kept up to date. Small, modular documents have at least a chance at being updated. アジャイルな手法は文書化に反対しているわけではなく、価値のない文書に反対しているだけである。 チーム自身を支援する文書は価値を持つことができますが、それは最新の状態に保たれている場合に限られます。 大きなドキュメントは決して最新の状態に保たれません。小さく、モジュール化された文書であれば、少なくとも更新される可能性があります。 ドキュメントを小さい単位で記録していくことで、より扱いやすく色々な人にとって価値のあるものとなります。ADRは仕様書のように最新の状態に更新し続ける必要はなく意思決定のログとして扱えるため、後追いで見た時に読み手が解釈しやすく、価値があるドキュメントとなります。 上記を踏まえて、以下の項目を意識してADRの導入を行うこととしました。 意思決定の背景・動機が残っている状態となること ドキュメントは小さな粒度で残していくこと ADRは仕様書ではなく、決定のログとして残していくこと 展開 ADRのフォーマットは展開のしやすさを考慮してシンプルなものを採用することにしました。テンプレートは数多く公開されていますが、私たちのチームでは下記のテンプレートを参考にすることにしました。 https://github.com/joelparkerhenderson/architecture-decision-record/blob/main/templates/decision-record-template-by-michael-nygard/index.md ADRのフォーマット 実際の運用では上記テンプレートの一部解釈を変更して運用しています。使用しているテンプレートは以下となります。 YYYY/MM/DD ADR Title # Status # Situation # Context # Material for decision # Decision # Consequences セクションごとの詳細は以下となります。 Status 意思決定のステータス proposed/accepted/rejected/deprecated/ この項目があることで、ドキュメントの状態が一目で分かるようになります ステータスの更新は忘れがちなので注意が必要です Situation 決定時のビジネスにおける制約や会社、チームの状況 重要な決定ほど外的要因に左右されるため、決定に作用した状況を記載します 意思決定の状況があることでテクノロジー以外の部分で決定に作用した内容を把握できるようになります Context 決定が必要となった背後にある動機や理由 決定内容は記録が残っていなかったとしても最新のプロダクトからある程度把握が可能ですが、決定の背後にある動機や理由は記録がなければ読み取ることは非常に困難です 読み手に対して何故この意思決定が必要となったのかを伝える上でとても重要な項目です Material for decision 決定時に参照した材料 決定の材料を記載します。意思決定をスムーズに行うために必要な項目です Decision 最終的な決定内容と理由 最終的な決定を簡潔に記述します 決定に至った背景・理由も記述することで、後追いで見た時により価値が高いドキュメントとなります Consequences 最終的な結果 意思決定が及ぼす結果を記載します 決定時に結果を記入できるものは記録し、振り返りが必要なものに対しては後追いで記述します 使用ツール 私たちのチームではドキュメントツールとしてConfluenceを採用しており、ADRの記録先としてもConfluenceを採用することに決めました。対抗馬としてGitHubを採用するかどうか迷いましたが、以下の理由から見送ることとしました。 Confluenceの方がよりカジュアルにドキュメント作成が可能 開発者以外の方からも気軽にアクセス可能な状態としたかった 一方でGitHubを使用することで、ソースコードと同列にADRを管理できるようになります。この決定についてはどちらが最適か結論が出ていないため後ほど振り返る予定です。 チームへの展開 チーム展開時にはADRを残していきたい旨をADR形式でチームに展開し相談しました。私たちのチームではADRを記録する対象をアーキテクチャのみに絞らず、チームで残すべきと判断した意思決定は全て記録するようにしました。これにより、アーキテクチャに関する内容やチーム内のルールなど、多くのカテゴリのADRが記録されることになり煩雑になることも懸念しましたが、運用がスムーズにいくまでは考えることを少なくするため全ての決定を記録する体制を取っています。また、過去の意思決定も思い出しを含めて可能な限りADRとして起票するようにしました。ZOZOFITは新規事業なため、開発に携わっている私たちが記録しなければ後で参加したメンバーが困ると判断したためです。起票に時間はかかりましたが、これにより過去の殆どの意思決定がADRとして残っている状況を作りました。 ADRの一例 ADRの一例を紹介します。ZOZOFITの開発を進める中で私たちはIDaaSとしてCognitoを採用することに決めました。しかし、メールアドレスの変更処理において意図していない挙動となることが判明したため以下のADRを記録しています。 # 2022/05/16 Cognitoのメールアドレス変更の処理を自作する # Status - proposed/ **accepted**/ rejected/ deprecated/ superseded # Situation - ZOZOFITの開発初期のタイミング - IDaaSとしてCognitoを採用しており、Cognitoを使ったPoC実装が完了している - チームメンバーにCognitoについて詳しいメンバーはいない - ZOZOFITのリリースタイミングの目処は決まっている # Context - 2022年5月の時点においてCognitoを使用する場合に「変更後のメールアドレスが未検証の状態でも、Cognitoのユーザーのメールアドレスが更新されてしまう」問題が起きることが判明した - これによって以下の問題が発生する - ユーザーはリフレッシュトークンの有効期限が切れた場合、変更前のメールアドレスが使えない状態となり、ログインできなくなる - ユーザーは他人のメールアドレスを自身のメールアドレスとしてZOZOFITに登録できてしまう - 上記は許容できないため、対応策を決定する必要がある # Material for decision - 問題が発生するまでの操作フロー - ユーザーはZOZOFITのアプリ上でメールアドレスの変更を行う - サーバーはCognitoのUpdateUserAttributesのAPIを呼び出しメールアドレスを更新する - Cognitoはメール未検証状態でユーザーのメールアドレスを更新する - このタイミングで過去のメールアドレスと書き変わる - 以下の記事において、この問題について言及されています - https://kohei1116.hateblo.jp/entry/2020/02/16/aws-cognito#1-UpdateUserAttributes - https://zenn.dev/dove/articles/78ecf08b51ee0c - 対応策としては2つ考えられそうです - Cognitoのメールアドレス変更の処理を自作する - Cognitoにおけるユーザーのメールアドレスの検証処理を自作し、ZOZOFITサーバー側の責務とする - 別のIDaaSを利用する - 別のサービスの調査を行い、同様の事象が発生するかどうか確認する - 起きないのであれば、メールアドレス変更処理を自作した場合と比較し、どちらを選択するか決めた方が良さそう # Decision - Cognitoのメールアドレス変更の処理を自作する - 新規でAPIを書く必要があるが、既にCognitoを用いての開発が進んでおり別のIDaaSを選択するのに比べて工数が低いと判断 # Consequence - 結果として - メールアドレス変更処理を行うAPIを自作する - 認証周りの実装をCognitoの外に持つことになるが許容する - 2023/03 - ADRに対して振り返りを行いました - 背景にある事象はCognitoにおいて解決されており、メールアドレス変更処理を自作する必要はない状況となりました - メールアドレス変更処理フローを変更するかどうかについては後続のADRで起票します - 参考 - https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-settings-email-phone-verification.html 上記のADRについては後日振り返りを行い、2023年3月時点で問題が起きないことが分かっています。 過去の意思決定を記録しておくことで振り返りの実施をスムーズに行えるようになり、次の意思決定に繋げることができました。 振り返り 結果としては、ZOZOFITにおいて約40件のADRが記録されています。 課題はどう解決されたのか 過去の背景が分からず決断しにくい ADRを残すことで、新規の意思決定が行いやすい状態となりました 最も効果があった部分は、意思決定に対して振り返りを行うことが容易となった点です 当時のタイミングでは最適だったとしてもタイミングによっては選択肢が変わることもあると思います。ADRを残すことで過去の知見を再利用しながら新しい意思決定に繋げることが可能となりました 実際にZOZOFITローンチ前の意思決定に対して振り返りを行い、新しいアクションが生まれました 何度も同じテーマを議論する あれ、これ何でしたっけ?となった時に記録が残っていることで同じことを議論する回数が減りました 意思決定の認識合わせに時間がかかる ADRを運用していく中で認識合わせのスピードが上がりました 意思決定のフローを事前にADRをproposedな状態で記入した後に議論する運用とすることで、必要な背景・材料が揃った状態で会話を開始できるようになり、認識合わせに必要な時間が削減されました メリット 導入してみて感じたメリットをまとめると以下となります。 透明性の向上 チーム内における情報量がフラットとなった チーム外からも気軽にドキュメントを参照できるようになり、APIやアーキテクチャ選定の思想や理由を伝えやすくなった 集約性の向上 過去の情報を遡るときはADRを参照すればOKとなった 可読性の向上 議論が提案状態なのか、結論が出ているのか一目で判断できるようになった ADRはフォーマットに沿っており結果の把握が行いやすくなった デメリット デメリットではないですが、ADRを記録する対象には向き不向きがあると感じました。例えば仕様書のようなものは最新の状態に合わせるために何度も更新が必要なためADRとして扱うには不向きだと感じました。また、運用面においても文書化は意識しないと行われないため、導入当初は旗振り役が意識して啓蒙活動をしなければ、浸透までは行き着きにくいと感じました。 最後に ADRを導入することで過去にローンチした計測技術・アーキテクチャの知見を活かし、新しい価値を素早く生み出すように努めています。ADRを導入することで過去の意思決定を気軽に参照できるようになり、会話が発生し、学びを得る機会が増えたと感じています。 計測プラットフォーム部バックエンドチームでは、 ZOZOFIT のように、日本国内に限らず新しいサービスを開発していくバックエンドエンジニアを募集しています。 ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
はじめに こんにちは。SRE部ECプラットフォーム基盤SREブロックの石田です。 本記事では、Aurora Serverless v2を本番導入するにあたってどのような検討をし、どのように導入していったか、また導入後に得られた効果について紹介します。 はじめに Aurora Serverless とは 背景 比較検討 比較内容 方針の決定 アーキテクチャ 導入 1. Aurora Serverless v2を手動で構築 2. AWS CloudFormationでProvisioned型Aurora MySQLバージョン3を再構築 3. AWS CloudFormationでAurora Serverless v2に移行 4. 負荷試験・障害試験 負荷試験 障害試験 導入により得られた効果 柔軟なスケーリング インフラコスト 最後に Aurora Serverless とは Aurora Serverless とは、Amazon AuroraにおいてオンデマンドのAuto Scaling設定が行えるデータベースです。アプリケーションニーズに応じて自動的に起動・停止が可能で、データベース容量を管理することなく、スケールアップ・スケールダウンが可能です。 Aurora Serverlessのデータベースの容量は、ACU(Aurora Capacity Unit)という単位が用いられています。1ACUあたり約2GiBのメモリと対応するCPU、ネットワークが組み合わされています。 また、Aurora Serverlessには、Aurora Serverless v1とAurora Serverless v2が存在します。v1とv2の比較は 公式ドキュメント をご確認ください。 背景 ZOZOTOWNではシステムリプレイスを進めており、2022年の春に会員基盤のクラウド化・マイクロサービス化をはじめました。これまでデータベースにはSQL Serverを使用していましたが、リプレイス後はMySQLを使うことが決まっていました。 私たちは2022年6月頃にAWS側で用意するMySQLデータベースの選定を始め、下記のような背景がありAurora Serverless v2の採用を検討しました。 コミュニティMySQL 5.7のEOL は2023年10月であり、EOL対応が必要になるため新規構築のタイミングでMySQL 8.0を採用したい セールなどの高負荷時に備えて手動でスケールアップするといった運用負荷をできる限りなくしたい 比較検討 検討を進める上では、Provisioned型のAurora MySQLバージョン3とProvisioned型のAurora MySQLバージョン2を比較して検討しました。 比較内容 選定ポイントとなる項目に対する比較表が下記の通りです。 Aurora Serverless v1は、マルチAZに対応していないことやスケーリング速度がv2より遅いことが懸念としてあったため、比較対象からは除外しています。 本検討は2022年6月27日時点の内容になります。 Aurora Serverless v2 Provisioned型Aurora MySQLバージョン3 Provisioned型Aurora MySQLバージョン2 MySQL 8.0互換である ○ ○ × LTSバージョンに対応している × × ○ AWS CloudFormationによるIaC化ができる ×(検討時) ○ ○ アプリケーションのレイテンシー目標を達成できる 要検証 ○ ○ スケールアップ作業が不要 ○ × × インフラコストが抑えられる ユースケースによる ユースケースによる ユースケースによる それぞれの比較内容について説明します。 MySQL 8.0互換である MySQL 8.0互換はEOLまで期間があり直近でのバージョンアップ作業は不要です。Provisioned型Aurora MySQLバージョン2はMySQL 5.7互換のため、EOLを迎える前にバージョンアップ作業が必要になります。 LTSバージョンに対応している LTSバージョンに対応していない場合、強制アップデートの回数は多くなり、アップデート通知からの猶予期間も短い可能性があります。 2022年2月27日現在でもAurora Serverless v2及びProvisioned型Aurora MySQLバージョン3はLTSバージョンに対応していません。 AWS CloudFormationによるIaC化ができる 弊チームではIaCによる環境構築を徹底しており、AWSリソースであれば、可能な限りAWS CloudFormationにて管理できるものを選択しています。 検討時点ではAurora Serverless v2はAWS CloudFormationには対応していませんでしたが、2022年10月5日に サポートを開始 したことを発表しました。 アプリケーションのレイテンシー目標を達成できる Provisioned型Aurora MySQLは運用実績があったため、レイテンシー目標を達成できる見込みがありました。Aurora Serverless v2は運用実績がなく、レイテンシー目標を達成できるかどうかは検証が必要です。 スケールアップ作業が不要 Aurora Serverless v2はオートスケールするため、セールなどの高負荷時にスケールアップ作業は不要です。Provisioned型Aurora MySQLはスケールアップする作業が発生します。 インフラコストが抑えられる Provisioned型Aurora MySQLはある程度の商用負荷を想定して大きめのスペックを用意する必要があります。Aurora Serverless v2は検討時点では通常時やピーク時のリクエスト数に対するスペックは不確定なため、比較してインフラコストが抑えられるかはまだ不明の状況でした。 方針の決定 ZOZOTOWNの会員基盤がAurora Serverless v2を採用する上での懸念は下記のようなものでした。 LTSバージョンに対応していないこと 検討時点ではAWS CloudFormationによるIaC化ができないこと レイテンシー目標を達成できるか インフラコストを抑えられるか これらを踏まえ、懸念について判断ポイントを設け、方針を決定しました。 1つ目のLTSバージョンに対応していないことについては、深夜帯にメンテナンスを設け、数十秒のサービス断であれば問題ないと判断しました。 2つ目のIaC化については、パフォーマンスを評価する負荷試験の実施前までにAWS CloudFormationがサポートされれば良いと考えました。そのため、開発期間中は手動で構築したAurora Serverless v2を活用することとしました。 3つ目のレイテンシーについては、負荷試験にてデータベースの観点でレイテンシー目標を達成できなければ、Provisioned型Aurora MySQLバージョン3に切り替えることとしました。 4つ目のコストについては、負荷試験後に概算し許容できるかを判断することとしました。 アーキテクチャ ZOZOTOWN会員基盤の大まかなアーキテクチャは下図の通りです。 アプリケーションはEKSで稼働し、Aurora Serverless v2はWriter Instance 1台、Reader Instance 1台のマルチAZ構成となります。 導入 方針で決めたことをもとに、導入に向けて対応したことや考慮した点を順に説明します。 Aurora Serverless v2を手動で構築 AWS CloudFormationでProvisioned型Aurora MySQLバージョン3を再構築 AWS CloudFormationでAurora Serverless v2に移行 負荷試験・障害試験 1. Aurora Serverless v2を手動で構築 方針決定後の時点でもAurora Serverless v2はAWS CloudFormationに対応していなかったため、 公式ドキュメント を参考に手動で構築しました。 2. AWS CloudFormationでProvisioned型Aurora MySQLバージョン3を再構築 負荷試験が始まる前、Aurora Serverless v2はAWS CloudFormationにまだ対応していませんでした。そのため、方針決定時の判断ポイントに従ってAWS CloudFormationでProvisioned型Aurora MySQLバージョン3を再構築しました。 この時、アプリケーション開発中でありデータを保持する必要があったため、新規で構築して切り替えるといったことができませんでした。そのため、mysqldumpでバックアップを取得し、AWS CloudFormationで構築したProvisioned型Aurora MySQLバージョン3にリストアを実施しました。 3. AWS CloudFormationでAurora Serverless v2に移行 Provisioned型Aurora MySQLバージョン3を再構築した同じタイミングで、 Aurora Serverless v2がAWS CloudFormationのサポートを開始 しました。負荷試験が始まる直前でしたが、このタイミングを逃したくないと考え、AWS CloudFormationでAurora Serverless v2へ移行することにしました。 基本的な移行の流れは、 公式ドキュメント に従ってAWS CloudFormationで作業していきます。Aurora Serverless v2への移行はフェイルオーバーで切り替えが必要なため、一時的に接続できなくなりますが、再構築は不要でアプリケーション開発には影響なく移行できました。 Aurora Serverless v2をAWS CloudFormationで定義する際の注意点としては、Aurora Serverless v1とは異なるパラメータがあることです。例えば、 ServerlessV2ScalingConfiguration などです。 AWS::RDS::DBCluster の「Amazon Aurora Serverless v2 DBクラスターの作成」をよく確認する必要があります。 4. 負荷試験・障害試験 移行したAurora Serverless v2について、負荷試験・障害試験の実施内容と結果を説明します。 負荷試験 データベース観点で負荷試験を実施することで、アプリケーションのレイテンシー目標を達成できるか確認しました。 具体的には、Aurora Serverless v2のMax ACUは64に固定し、Min ACUを2から16まで変動させ、Min ACUによってレイテンシーがどのように変化するのか確認します。商用を想定したリクエストを5分間流し続けることで負荷をかけます。 負荷をかけるツールは弊チームで開発したOSSツールであるGatling Operatorを使用します。詳細は以前TECH BLOGに公開された下記記事をご参照ください。 techblog.zozo.com 負荷試験の結果は下記グラフの通りです。 これより、Min ACUが小さいとレイテンシーは高くなり、徐々に大きくしていくとレイテンシーは低くなりますが、大きくしすぎてもレイテンシーはそれほど変わらないことがわかります。レイテンシー目標としてはMin ACUが3で達成できたため、この値を採用しています。 障害試験 障害試験では、アプリケーションにリクエストを流しながらフェイルオーバーした際のサービス断時間や、ACU変更時にサービス断が発生するか確認しました。 試験結果は以下の通りです。 確認項目 サービス断 フェイルオーバー 最大40秒 Min ACU変更 なし Max ACU変更(再起動時) 最大10秒 Max ACUの変更について補足すると、ACUの反映は即時ですが変更に伴うデータベースへの同時接続数などのパラメータを反映させるためには別途再起動が必要です。Reader Instanceのエンドポイントは現状使用していないため、Writer Instanceの再起動時のみ最大10秒のサービス断となりました。 運用としては1つずつInstanceを再起動可能ですが、再起動が含まれるフェイルオーバーで実施することにしています。これは、既存のProvisioned型Aurora MySQLではフェイルオーバーで実施しており、手順を統一させたいことが背景としてあります。緊急を要するようであればその時に応じて各Instanceの再起動を検討することとしています。 データベースへの同時接続数について触れましたが、Max ACUにより同時接続数が異なるため、考慮が必要です。その他含めACUのチューニングの詳細については公式ドキュメントの Aurora Serverless v2でのパフォーマンスとスケーリング をご確認ください。 導入により得られた効果 Aurora Serverless v2を導入することにより得られた効果について説明します。 柔軟なスケーリング 負荷試験・障害試験の完了後、オンプレミス環境のデータベースからAurora Serverless v2へのデータ移行を実施しました。 データ移行時のACUの推移は下記グラフの通りです。 青色の線がWriter Instance、紫色の線がReader Instanceです。 Aurora Serverless v2は約14ACUまでスケーリングし、エラーなくデータ移行を完了させることができました。データ移行完了後は即座に縮退されていました。 このように柔軟なオートスケーリングにより、データ移行時のスケールアップやスケールダウンを実施する工数を削減できました。 インフラコスト Aurora Serverless v2と、Provisioned型Aurora MySQLのインフラコストを比較し、どのような効果が得られたかを説明します。 まず、Aurora Serverless v2における3か月間のACUの大まかな推移は下記グラフの通りです。 商用環境ではスケールアップはほぼしない状況でした。一方で開発環境は必要な時にはスケールアップし、使用していない時には最小0.5ACUとなっており、柔軟にスケーリングしていることがわかります。 次に、3か月間使用したインスタンス利用料金の1か月平均をAurora Serverless v2とProvisioned型Aurora MySQLで比較した表は下記の通りです。 Aurora Serverless v2 Provisioned型Aurora MySQL 商用環境 USD 893 USD 781 開発環境 USD 1,400 USD 1,265 合計 USD 2,293 USD 2,046 Provisioned型Aurora MySQLはAurora Serverless v2でMaxにスケールしていたACUをインスタンスタイプに換算して試算しています。インスタンス利用料金の詳細は 公式ドキュメント をご確認ください。 以上を単純に比較すると、現在の会員基盤の利用状況ではAurora Serverless v2よりProvisioned型Aurora MySQLの方がインフラコストは抑えられたという結果でした。 ただ、Provisioned型Aurora MySQLの運用時は、スケールアップなどの運用コストを抑えるために余裕をもったインスタンスタイプを用意しています。このことを考慮すると、インフラコストにあまり有意な差はなかったと判断しています。 今後、会員基盤では追加機能の開発や既存機能のリプレイスが計画されています。その際に、都度スケールアップを行うような運用コストが抑えられているという点ではポジティブに捉えています。 最後に 本記事では、Aurora Serverless v2を本番導入するにあたってどのような検討をし、どのように導入していったか、また導入後に得られた効果について紹介しました。 Aurora Serverless v2導入時には手動構築からAWS CloudFormation管理に移行したことやACUのチューニングには苦労しましたが、無事に本番導入できました。 Aurora Serverless v2を導入することにより、データ移行の高負荷時には柔軟にスケーリングし、手動でスケールアップするといった運用負荷をなくすことができました。またインフラコストについては、総括すると会員基盤の現状のユースケースでは、Provisioned型Aurora MySQLと比較してあまり有意な差はありませんでした。しかし、運用コストが抑えられているという点ではポジティブに捉えました。 今後の会員基盤においては、追加機能の開発や既存機能のリプレイスに伴い、最適な基盤を模索していきたいと考えています。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
はじめに こんにちは! WEARバックエンドブロックの高久です。 WEAR ではOpenAPI(Swagger)を使って、アプリやWebのクライアントが利用するAPIを定義しています。そして先日、開発効率化のために OpenAPI Generator でOpenAPIからAPIクライアントコードを自動生成、活用できるように整備をしました。その中でOpenAPI Generatorに適したOpenAPIの書き方のポイントがいくつかあったので、内容を紹介していきます。 想定読者 OpenAPIを現在利用している、またはこれから利用する予定の方 OpenAPI Generatorを利用したコード自動生成を検討している方 背景 当初WEARではAPIクライアントコードはOpenAPIでのAPI定義を基に各クライアントが手動で実装していました。しかし手動で実装すると初期の実装コストや変更時の追従コストがかかるため、開発効率化のためにOpenAPI Generatorを利用してAPIクライアントコードを自動生成することにしました。 ただ、そのままのOpenAPIでコードを自動生成しようとすると、エラーが発生したり、自動で名付けられたクラス名が不相応だったりと実用できるコードではありませんでした。そのためTry&Errorで実用可能なコードになるまで改善を繰り返しました。今回はその改善から得たOpenAPI GeneratorフレンドリーなOpenAPIの書き方を紹介します。 前提 OpenAPIのバージョンは3.0.0です。 言語によってOpenAPI Generatorの挙動が変わるため、本記事に記載する事象が全ての言語に当てはまる訳ではありません。WEARではクライアントの言語としてSwift, Kotlin, Go, TypeScriptを利用しているため、今回はそのいずれかの言語で発生した内容となります。 書き方のポイント tags、operationIdを1エンドポイントにつき1つ設定する レスポンススキーマでenumを使わない anyOf、oneOfを使わない type:object単位で/components/schemas配下にスキーマ化する enumに各言語の予約語を使用しない tags、operationIdを1エンドポイントにつき1つ設定する paths: /pet/findByStatus: get: tags: - pet operationId: findPetsByStatus 理由 tags 、 operationId は自動生成されたコードではそれぞれクラス名、メソッド名になります。 それぞれ設定しないと自動で名前が付与されてしまいます。意図しない名前が付与されてしまうことを防ぐため、1エンドポイントにつき1つ設定して適切なクラス名、メソッド名を付与するようにしています。 (以降Rubyでの生成結果) 以下、tags、operationIdを設定した例。 class PetApi ・・・ def find_pets_by_status(opts = {}) ・・・ end end 以下、tags、operationIdを設定しなかった例。このように自動で名付けられます。 class DefaultApi ・・・ def pet_find_by_status_get(opts = {}) ・・・ end end また tags はOpenAPIでは配列形式で設定が可能ですが、タグを2つ以上設定すると設定したタグのクラスに同じメソッドが重複して定義されてしまいます。そのため1エンドポイントにつきタグは1つだけ付与することを推奨します。 レスポンススキーマでenumを使わない いい例。 components: schemas: status: type: string 悪い例。 components: schemas: status: type: string enum: - placed - approved - delivered 理由 enumに値を追加したい場合に考慮することが増えるためです。 APIレスポンスのenumに値を追加したい場合、クライアントコード側でもenumが追加された状態でないと、自動生成コードでパースエラーになることがわかりました 1 。 APIとクライアントでenumを追加するタイミングを合わせる必要があるのですが、そのためにはアプリの強制アップデート等の対応が必要になってきます。その考慮が必要など対応工数が大きくかかるため、WEARではレスポンススキーマからはenumを削除し、どの値でも受け付けられるようにしました。 なお、リクエストパラメータのenumに関しては上記の課題は影響しないため、使用しても特に問題ありません。 anyOf、oneOfを使わない 理由 anyOf、oneOf はまだ完全にサポートされておらず、動作が不安定なためです。2023年3月現在、こちらの issue でまだ議論が行われております。 幸いWEARではanyOf, oneOfの使用箇所が少なかったため、使用箇所は排除し今後使わない方針としました。 type:object 単位で /components/schemas 配下にスキーマを作成し、ref参照する いい例。 components: schemas: Pet: type: object properties: id: type: integer category: $ref: '#/components/schemas/Category' Category: type: object properties: id: type: integer name: type: string 悪い例(Petオブジェクトのなかに、categoryオブジェクトを定義している) components: schemas: Pet: type: object properties: id: type: integer category: type: object properties: id: type: integer name: type: string 理由 type:object の中に更にobjectを定義すると、そのobjectのモデル名が自動で付与されてしまうためです。 以下は「悪い例」の定義で自動生成した際のソースコードです。 module OpenapiClient class PetCategory ・・・ end end 自動的に PetCategory というモデル名が名付けされています。言語によっては InlineObject{連番} というような名付けがされることもあり、可読性、保守性の面で実用的ではありません。 type:object の中にobjectが必要になったら、 /components/schemas 配下にスキーマを1つ作りref参照することで、自動で名付けされることを防げます。 また以下のようなレスポンスボディの定義も同様です。 悪い例。 paths: /pet/{petId}: get: parameters: - name: petId in: path schema: type: integer responses: '200': content: application/json: schema: type: object properties: id: type: integer name: type: string category: $ref: '#/components/schemas/Category' いい例。 paths: /pet/{petId}: get: parameters: - name: petId in: path schema: type: integer responses: '200': content: application/json: schema: $ref: '#/components/schemas/GetPetResponse' enumに各言語の予約語を使用しない 理由 自動生成したコードでシンタックスエラーになるためです。 WEARで発生した例を紹介します。あるパラメータでenumに open を使っていました。しかしKotlinでは open が修飾子に当たるため、自動生成したコードでシンタックスエラーになることがありました。各言語で予約語が異なるので、全てを考慮して命名することは難しいのですが、意識しておく必要があります。 まとめ OpenAPI GeneratorフレンドリーなOpenAPIの書き方をいくつかご紹介しました。 WEARでは修正箇所のボリュームが多かったため、問題度合いやエンドポイントから優先度を立てて修正していきました。修正コストはかかったものの、それを上回るメリットがありました。 OpenAPIを使っていてOpenAPI Generatorでのコード自動生成を検討している方がいれば、是非参考にしてみてください。 最後に WEARでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com ただしこの挙動は自動生成する言語によって変わる可能性があります。WEARではGoでパースエラーになったことを確認しています。 ↩
はじめに こんにちは。ブランドソリューション開発本部FAANSバックエンドブロックの田村です。普段はサーバサイドエンジニアとしてFAANSのバックエンドシステムの開発をしています。 FAANSとは、弊社が2022年8月に正式ローンチした、アパレル店舗のショップスタッフの販売サポートツールです。FAANSでは、データベースとしてGCPのサーバレスでドキュメント指向のNoSQLデータベースである Cloud Firestore を当初採用していました。Cloud Firestoreはサーバレスなので運用負荷が掛からず、また安価でスケーラビリティにも優れたハイパフォーマンスなデータベースです。 しかし、Cloud Firestoreを使用して開発・運用していく中で直面した様々な課題からGCPのフルマネージドのリレーショナルデータベースである Cloud SQL のPostgreSQLにデータベースのリプレイスを実施しました。 本記事では、Cloud FirestoreからPostgreSQLへのリプレイス過程を紹介します。 Cloud Firestoreを採用していた理由 FAANSのバックエンドのシステムで利用するデータベースとしてCloud Firestoreを当初採用していました。FAANSのシステムは、アパレル企業毎にその企業と紐付いたスタッフ情報やショップ情報をデータベースで管理します。ユーザーが自分の所属企業とは別の企業のデータにアクセスできてしまわないように、データベース内で企業をテナントの単位としたマルチテナンシーを実現できるデータベースが必要でした。 Cloud Firestoreにはサブコレクションという概念があり、階層構造でデータを保持できます。トップレベルのコレクションとして企業データを管理し、その配下のサブコレクションとしてその企業と紐づくデータを保持することによって、企業単位でデータを完全に分離しマルチテナンシーを安全に運用できます。 Cloud Firestore時代の開発と直面した課題 Cloud Firestoreはドキュメント指向のNoSQLデータベースであるにも関わらず、普段使い慣れているリレーショナルデータベースで求められるようなリレーションモデルの正規化思考が抜けずにデータモデリングしてしまっていて反省すべき点でもあるのですが、データを取得するために仕方なくN+1問題が発生するようなクエリを発行していました。 その結果、Web APIサーバの一部のエンドポイントではリクエストあたりのCloud Firestoreへのクエリ発行回数が極めて多い実装となり、Web APIサーバへのリクエストのレスポンスタイムが大変遅くなってしまうという問題が発生しました。 この問題を解決するために、画面仕様に合わせて適切に非正規化されたデータモデルを設計する必要があると考えました。具体的にどういうことかを、スタッフ一覧の画面を例に説明していきます。 画面仕様を「スタッフの名前とスタッフが所属している店舗の名前をリストで表示する」とします。Cloud Firestoreのデータベース設計を下記のように仮定します。 StaffMemberは、とある企業に所属しているスタッフを表現しています。Shopは、店舗ショップ情報を表現しており、スタッフはいずれかのショップに所属しているとします。必要な情報を取得する場合、StaffMemberコレクションから取得した後に、ShopIDを基にShopコレクションからデータを取得する必要があります。パフォーマンス最適化のことを考えると、1コレクションのみから取得できるのが理想です。 そのため、画面仕様に合わせてスタッフ名や所属ショップ名といったデータを適切に非正規化されたデータモデルで保持するために、そのための新しいコレクションを用意するのが良いと考えられます。以下の例では、StaffMemberInfoという新規コレクションを作成しています。 そうすることでN+1問題は回避できますが、新たな問題が生まれてしまいます。画面仕様が「スタッフ名、所属ショップ名、所属事業名のリストを表示する」に変更された場合、新たなフィールドの追加が必要となります。仕様変更の度に変更する必要があり、仕様変更に弱い作りとなります。 さらに、ショップ名等更新があるたびに非正規化している値も更新する必要があります。更新処理が複雑になったり、実装漏れによる更新忘れが発生してしまうことも考えられます。また、非正規化された設計がゆえに、依然として1リクエストあたりのクエリの発行回数が多いためWeb APIサーバーのレスポンスタイムが遅いという問題も残りました。 今後FAANSは事業や組織としても大きくなり、仕様変更の頻度も増えていくと考えられます。そのため、データはできる限り正規化して正しく保持して仕様変更に強くしておくべきだと考えていました。 PostgreSQL採用理由 マルチテナンシーの要件に対応できるデータベースを改めて技術調査したところ、PostgreSQLでRLS(Row Level Security)の機能を利用して実現できることが分かりました。Row Level Securityとは、行単位へのアクセス制御を可能にする仕組みで、 PostgreSQL Version 9.5から利用できます 。 例えば、企業情報を保持しているcompaniesというテーブルがあるとします。下記のように設定することで、特定のcompany_idに一致するデータのみ取得可能となります。 ALTER TABLE companies ENABLE ROW LEVEL SECURITY; CREATE POLICY multi_tenant_policy ON companies AS PERMISSIVE FOR ALL TO public USING ((id)::text = current_setting( ' app.company_id ' ::text)); データ取得の際は、下記のようにクエリを発行することで、company_idが 123 である企業情報を取得することが可能となります。 BEGIN ; SET LOCAL app.company_id = ' 123 ' ; SELECT * FROM companies; COMMIT ; SET LOCALにすることにより、このトランザクション内で有効なcompany_idを指定できます。ここでapp.company_idを指定せずにcompaniesを取得しようとするとエラーが出てクエリの実行に失敗します。これにより、誤って別企業のデータを閲覧してしまうことは無くなります。また、適切に正規化できていれば、画面仕様を変更する際は取得するクエリを変更するだけで済みます。 Row Level Securityで安全にマルチテナンシーを運用できる点と、 リレーショナルデータベースで採用されているリレーショナルモデルの方が画面仕様の変更に強い作りとしやすい点からPostgreSQLを採用しました。 Row Level Securityの適用方法 Row Level Securityを利用することになり、安全に運用するために必ずトランザクション内でCRUD処理を行うようにしています。指定された企業ID(companyID)で企業情報を取得する処理のGo言語による実装例を以下に示します。RunTransaction内では、前述の SET LOCAL app.company_id が実行され、必ず企業IDが設定されている状態にしています。もし、RunTransactionを忘れて取得処理を記述してしまっても、クエリの実行時にエラーで失敗するように実装していますのでそのようなミスは発生しません。 err = txm.RunTransaction(ctx, companyID, func (ctx context.Context) error { // 企業情報取得処理... }) if err != nil { return err } PostgreSQL移行実装 PostgreSQL移行の実装をするにあたり、下記の項目を決定しつつ行いました。 スキーマ管理方法 開発環境、本番環境へのDDL(Data Definition Language)適用方法 データベースアクセスライブラリの選定 既存開発と並行して移行を実装する方針 データベースマイグレーション リリース作業 スキーマ管理方法 開発時のDDLの定義方法は、自分でSQLを実行するかデータベース管理ツールでテーブル定義をするかは、個人の選択に任されています。ただし、最終的なテーブル定義は、チーム全体で揃える必要があります。 そこで利用したのが、 sqldef というツールです。sqldefは、適用先のデータベーススキーマとの差分を検出して、必要なDDLを自動で生成や実行してくれる、データベース変更管理ツールです。その中の機能で、指定のデータベースに存在するテーブルのDDLをSQLファイルとして出力できます。生成されたSQLファイルをコミットして管理することで、開発時の定義方法は異なっていたとしても、最終的な成果物は同じ形式で出力されます。 開発環境、本番環境へのDDL適用方法 適用時もsqldefを用いて、コミットされているsqlファイルと適用先のデータベースのスキーマとの間で差分を検出し、その差分のDDLを適用する形で運用しています。テーブル定義に変更が入るような改修をする場合は、差分DDLの結果をPull Requestのコメントとして自動出力されるようにしました。そうすることで、意図しない変更をしようとしていないかを確認できます。レビューで問題なくマージされた場合、CI/CDにより自動でDDL適用がされます。 まとめると、下記の流れとなります。 ローカル環境のデータベースに新規テーブル定義を追加する sqldefで生成されるSQLファイルを git commit する Pull Requestを作成して、開発環境で実行されるDDLがPull Requestのコメントに自動で出力される 問題なければ、mainブランチにマージして開発環境に自動的に適用される データベースアクセスライブラリの選定 Go言語でPostgreSQLにクエリを発行する際に使用するライブラリとして、データベースのテーブル定義を元にコードの自動生成が可能で、型安全で記述ができる SQLBoiler を採用しました。マイグレーション機能はありませんが、前述の通りマイグレーションに関してはsqldefを利用しています。 下記にデータ投入時と取得時のコード例を記載します。ここでは、企業データのモデルをCompanyとします。 データ投入例 company := &model.Company{ Name: company.Name, } if err := company.Insert(ctx, db, boil.Infer()); err != nil { return nil , err } データ取得例 企業名が、「株式会社ZOZO」のデータを取得する際を例にすると、次のように型安全で記述できます。 companies, err := model.Companies( model.CompanyWhere.Name.EQ( "株式会社ZOZO" ), ).All(ctx, db) if err != nil { return nil , err } 既存開発と並行して移行を実装する方針 FAANSのバックエンドで動作しているWeb APIサーバーのアーキテクチャとして、クリーンアーキテクチャとRepositoryパターンを採用しています。省略している箇所もありますが、アーキテクチャ図は下記のようになります。 PostgreSQLを実装するに当たり、変更箇所は下記の赤い部分となります。 PostgreSQLの実装には、Use Case層とRepository層の箇所のみ変更が必要でした。Use Case層は、RLSを適用するために若干の変更が必要でしたが、ほとんどは既存コードのままで実装できました。Repository層の部分をPostgreSQL接続に変更することで、実装が完了する流れでした。 データベースの移行を決断した当時、FAANSは既にCloud Firestoreで本番運用されている状態にあり、また既存機能の改修や新機能の開発が日々進行していました。そのため、既存機能に影響がないようにデータベース移行の開発をする必要があったので、PostgreSQL用のUse Case層とRepository層の実装は、別ファイルで作成することにしました。つまり、Cloud Firestore用のUse Case, RepositoryとPostgreSQL用のUse Case2, Repository2の、どちらのコードも混在している状態です。 ただし、Use Case層とRepository層をinterfaceで抽象化し、 wire というGo言語の依存性注入ライブラリでレイヤー間の依存性を管理するようにしていました。そうすることで、本番稼働しているCloud Firestore版の既存機能に影響を出さずに並行してPostgreSQL版の開発を進められるようにしました。 もちろん、Cloud Firestore版の実装の仕様に修正が入った場合は、PostgreSQL版の実装も同様の修正が必要になります。こちらに関しては、定期的に差分を確認して取り込むということを行っておりました。しかし、定期的に差分を取り込むといっても、手作業となるため漏れが発生していました。そこで、移行コード開発終盤時に最終確認のため、全差分チェックを行うことで漏れを防ぐように気をつけました。 PostgreSQL版の開発の途中からは、Cloud Firestore版のコードを改修する際にPostgreSQL版の実装との間に差分が発生しないようにPostgreSQL版の改修も併せて行う決まりにしたので、それ以降の実装漏れは最小限に抑えられたかと思います。反省点としては、開発速度は少し落ちてしまいますが、もう少し早い段階からこのような方針で進めておくべきだったと思います。 データベースマイグレーション Cloud FirestoreからPostgreSQLへのデータ移行では、データをマイグレーションする必要があります。そのため、Cloud Firestoreからデータを取得してPostgreSQLにデータを投入する処理を作成しました。 この処理の流れは、下記のシーケンス図で示します。 ただし、データ投入だけでは正しいデータで登録されているか不安になります。そこで、Cloud Firestoreから取得したデータとPostgreSQLから取得したデータを比較して検証し、移行の安全性を高めました。 30個ほどのテーブルを、依存関係に応じてグループ分けし、マイグレーションを実施するように実装しました。例えば、事業データは企業データに依存しているので、企業データが作成された後に事業データを作成する必要があります。このような依存関係を元に、企業はAグループ、事業はBグループのように割り振っていきました。その結果、4つのグループに絞られました。 SQLBoilerにはBulk Insertの処理が実装されておらず、また1件ずつ書き込む場合は許容できないほどの時間が掛かってしまうことが想定されました。そこで、Bulk Insertを行うSQLBoilerのテンプレートファイルを作成することで、効率的にデータ投入しました。Bulk Insertテンプレートファイルは、 こちらの記事 を参考にさせていただきました。 リリース作業 リリース作業は、以下のような流れでした。 メンテナンス開始 データベースマイグレーション実施 Web APIサーバのサービス切り替え QA(品質保証)テスト メンテナンス解除 Web APIサーバのサービスの切り替えは、下記の図のようにドメイン先のサービスをPostgreSQL版に切り替えるようなイメージです。 まずは、開発環境で実際にリプレイス実施してみて、手順等に問題がないかリハーサルを行いました。リハーサル時には、表示言語の差によって付与したい権限を探すのに手間取ったり、意図したサービスの方にトラフィックが流れているかの確認方法が少し手間だったり、細かいところに気づけました。本番作業までに、それらの手間を無くすようにして、万全の状態で本番リリース作業を行ったため、スムーズに切り替えを完了できました。 リプレイスの結果 Cloud Firestore利用時は、パフォーマンス最適化のためではありましたが、別コレクションに同じ情報を保持するように非正規化を行っていました。PostgreSQLにリプレイスすることにより、正規化されたデータとして全てのデータを持つことができるようになりました。これにより、非正規化されていたデータが故の複雑な処理を実装する必要もなくなり、処理がシンプルで理解しやすくなったように思います。今後の新機能開発は、特別な場合を除いて非正規化のことを考える必要がなくなりましたので、よりスムーズに開発できそうです。 さらに、パフォーマンス最適化が出来ていなかったAPIのレスポンスタイムが、劇的に改善されました。下図から、リプレイスを実施した2022年12月15日以降でWeb APIのレイテンシーが全体的に改善されていることが分かります。 また、リリース後から一ヶ月ほどシステムの様子をみましたが、リプレイスによる目立ったシステム障害は発生しませんでした。データの表示が速くなり快適になったとのお声もいただき、リプレイスは大成功に終わりました。 さいごに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、ブランドソリューション開発本部フロントエンド部WEAR Androidブロックの安土琢朗です。普段はファッションコーディネートアプリWEARのAndroidアプリを開発しています。 WEARではすでにXMLで書かれたレイアウトをJetpack Composeにリファクタリングする作業を進めています。作業を進める中で、Jetpack ComposeのLazyColumn利用箇所でスクロールが以前よりスムーズに動かない、初回起動時にスクロールが遅いなどのパフォーマンス問題に直面しました。 本投稿では、最適なパフォーマンスを実現する方法の1つであるベースラインプロファイルの導入の仕方について説明します。 ベースラインプロファイルとは ベースラインプロファイルはAndroid Runtime(ART)がプリコンパイルする時に使うクラスやメソッドをリスト化してあるものです。ベースラインプロファイルを使うことで起動時間とジャンクの削減、全体的なランタイムパフォーマンスの向上ができます。 それでは実際に導入をみていきましょう。 導入方法 benchmarkモジュールをアプリに追加する まずAndroid Studioの"Create New Module" でベンチマーク用のモジュールを追加するテンプレートが用意されているのでモジュールを追加します。 ベースライン プロファイルの難読化を無効にする ベンチマークに対して難読化を無効にする必要があります。appモジュール内にbenchmark-rules.proというファイルを作成します。 benchmark-rules.pro # Disables obfuscation for benchmark builds. -dontobfuscate 次に、appモジュールのbuild.gradle.ktsでbenchmark buildTypeを変更し、作成したファイルを追加します。 app/build.gradle.kts buildTypes { create("benchmark") { signingConfig = signingConfigs.getByName("debug") matchingFallbacks += listOf("release") proguardFiles("benchmark-rules.pro") } } ベースラインプロファイルのジェネレータを作成する ベースラインプロファイルを生成するために、benchmarkモジュールにテストクラスBaselineProfileGeneratorを作成します。 BaselineProfileGenerator.kt @ExperimentalBaselineProfilesApi class BaselineProfileGenerator { companion object { const val PACKAGE_NAME = "com.example.myapplication" } @get:Rule val baselineProfileRule = BaselineProfileRule() @Test fun generate() = baselineProfileRule.collectBaselineProfile(PACKAGE_NAME) { startActivityAndWait() startApplication() scrollScreen() } // アプリを起動する関数 private fun MacrobenchmarkScope.startApplication() { pressHome() startActivityAndWait() device.wait(Until.hasObject(By.pkg(device.launcherPackageName).depth( 0 )), 5_000 ) val suggestions = device.findObject(By.res( "suggestions" )) val searchCondition = Until.hasObject(By.res( "coordinateTop" )) suggestions.wait(searchCondition, 5_000 ) } // リストをスクロールする関数 private fun MacrobenchmarkScope.scrollScreen() { val suggestions = device.findObject(By.res( "suggestions" )) suggestions.setGestureMargin(device.displayWidth / 5 ) suggestions.fling(Direction.DOWN) device.waitForIdle() } } startApplication() 関数では次の3点を行います。 アプリの状態が再起動になったことを確認。 デフォルトのアクティビティを開始し、最初のフレームがレンダリングされるのを待つ。 コンテンツが読み込まれてレンダリングされ、ユーザー操作が可能になるまで待つ。 scrollScreen()関数では次の2点を行います。 LazyColumnのmodifierにtestTagを追加してtagを元にスクロールできるUI要素を見つける。 リストをスクロールする。 ベースラインプロファイルのジェネレータを実行する ベースラインプロファイルを生成するには、root権限のあるAndroid9(API 28)以上のデバイスを使用する必要があります。benchmarkモジュールのbuild.gradle.ktsファイルで、Gradleで管理されているデバイスを定義します。 benchmark/build.gradle.kts testOptions { managedDevices { devices { create<ManagedVirtualDevice>("pixel2Api31") { device = "Pixel 2" apiLevel = 31 systemImageSource = "aosp" } } } } 生成されたベースラインプロファイルをアプリに適用する テストが正常に終了したら、アプリにベースラインプロファイルを適用します。生成されたファイルは /benchmark/build/outputs/ の中の managed_device_android_test_additional_output/ フォルダ内にあります。そのファイルをappモジュールにbaseline-prof.txtでコピーします。 続いて、appモジュールにprofileinstallerの依存関係を追加します。 app/build.gradle.kts dependencies { implementation("androidx.profileinstaller:profileinstaller:1.2.0") } ここまでがベースラインプロファイルを生成してアプリに適用するまでの手順です。 次に実際にアプリのパフォーマンスについて測定してみます。使うライブラリはMacrobenchmarkです。 Macrobenchmarkとは 「Jetpack Macrobenchmark」は、パフォーマンスを測定するために導入されます。起動やUIの操作、アニメーションなどのパフォーマンスを測定できます。このライブラリを使用すると、以下のことができます。 アプリを複数回測定し、起動パターンやスクロール速度で測定できます。 複数のテスト実行結果を平均化し、パフォーマンスのばらつきを抑えることができます。 アプリのコンパイル状態を制御することで、パフォーマンスの安定性に影響を与える要因を制御できます。 Google Playストアで行われるインストール時の最適化をローカルで再現して、実際のパフォーマンスを確認できます。 Macrobenchmark導入方法 Macrobenchmarkのライブラリを追加 まずbenchmarkモジュールにMacrobenchmarkのライブラリを追加します。 benchmark/build.gradle.kts dependencies { implementation "androidx.benchmark:benchmark-macro-junit4:1.1.1" } アプリのパフォーマンスを測定する アプリのパフォーマンスを測定するために以下のテストクラスを作成します。 StartupBenchmark.kt class StartupBenchmark { companion object { const val PACKAGE_NAME = "com.example.myapplication" } @get:Rule val benchmarkRule = MacrobenchmarkRule() @Test fun startupCompilationNone() = startup(CompilationMode.None()) @Test fun startupCompilationPartial() = startup(CompilationMode.Partial()) @Test fun startupCompilationFull() = startup(CompilationMode.Full()) private fun startup(compilationMode: CompilationMode) = benchmarkRule.measureRepeated( packageName = PACKAGE_NAME, metrics = listOf(StartupTimingMetric()), iterations = 5 , compilationMode = compilationMode, startupMode = StartupMode.COLD ) { pressHome() startActivityAndWait() device.wait(Until.hasObject(By.pkg(device.launcherPackageName).depth( 0 )), 5_000 ) val suggestions = device.findObject(By.res( "suggestions" )) val searchCondition = Until.hasObject(By.res( "coordinateTop" )) suggestions.wait(searchCondition, 5_000 ) suggestions.setGestureMargin(device.displayWidth / 5 ) suggestions.fling(Direction.DOWN) device.waitForIdle() } } benchmarkRule.measureRepeated関数に以下パラメータを指定します。 packageNameは測定するアプリのパッケージ名を指定します。 metricsは測定する情報の種類を指定します。 iterationsはベンチマークを繰り返す回数を指定します。回数が多いほど結果は正確になります。 startupModeはアプリの起動方法を指定します。指定可能な値はCOLD、WARM、HOTです。 measureBlockは測定するアクション(例えば、アクティビティの開始、ボタンのクリック、スクロール、スワイプなど)を定義します。 CompilationModeを使って異なる3つのテスト関数を追加します。それぞれのテストの役割は以下です。 CompolationMode.Noneはアプリのコードをプリコンパイルしません。 CompolationMode.Partialはベンチマークを実行する前にベースラインプロファイルを読み込みプロファイルで指定されたクラス、関数をプリコンパイルします。 CompolationMode.Fullはアプリ全体をプリコンパイルします。 測定結果 次の表に中央値を示します。 timeToInitialDisplay(ms) None 394.5 Full 454.1 Partial 348.7 Noneモードの場合は全くプリコンパイルしないのでパフォーマンスが悪くなりPartialより数値は大きくなります。 Fullモードの場合はコード全体をプリコンパイルします。なので起動時にディスクの読み込みにかかるコストと命令キャッシュの負荷が増加するため、パフォーマンスは1番低くなります。 Partialモードの場合はベースラインプロファイルを使用していてユーザーがよく使うコードを優先的にプリコンパイルし、あまり重要でないコードは一時的に読み込まないようにしています。結果としてベースラインプロファイルを使用しているPartialはパフォーマンスが高い結果となりました。 まとめ 今回ベースラインプロファイルを導入することによってパフォーマンスを改善できました。ただライブラリ更新や実装が変わった時にベースラインプロファイルを都度作成する必要があるので、ワークフローを作成するようにした方が良いです。またMacrobenchmarkを使って実際にパフォーマンスを測定でき、数値で比較できました。今後その数値を元にパフォーマンスをより向上させたいと思います。 最後に ZOZOではAndroidエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください。 corp.zozo.com
はじめまして。ZOZO DevRelブロックの @wiroha です。2月1日に入社し技術広報などを担当していくことなりました。皆さまどうぞよろしくお願いいたします。 はじめに 2/22に AWSコスト削減事例祭り をAutifyさん、dipさんと共催しました。AWSを活用する3社が集まりAWSコスト削減についての事例を発表するオンラインイベントです。皆さまの関心が高いテーマのようで、約400名ものお申込をいただきました! zozotech-inc.connpass.com 素敵な配信会場はAutifyさんにご提供いただきました。ありがとうございました。 モデレータはZOZOの笹沢とdipの石川さん ※登壇者及び運営者は感染症予防をした上で、登壇時のみマスクを着用せずお話ししています 登壇内容まとめ 各社から合わせて次の4名が登壇しました。 塵も積もれば山となるコスト削減の話 (Autify 松浦) WEARのEKSコストを救いたい (ZOZO 小林) AWSコスト分析を利用したコスト最適化 (dip ジョンフンモ) プロダクト間のデータ連携をイベント駆動で作り直した話 (dip 藤中) 当日の発表はYouTubeのアーカイブでご覧下さい。 www.youtube.com 塵も積もれば山となるコスト削減の話 トップバッターのAutify 松浦さん speakerdeck.com YouTube 2:54〜 Autifyの松浦さんからは、AWSコストが急増した中で行った見直しの話がされました。スプレッドシートを作ってサービスとリージョンごとに1つ1つ優先順位をつけて改善策を検討していったそうで、地道な努力を感じました。トラフィックの削減、S3 Intelligent-Tieringの有効化、CloudWatchに送るログの精査などによってコストが劇的に減ったとのことでした。着手中の削減施策については質問も活発に行われました。 WEARのEKSコストを救いたい 2人目は弊社ZOZOの小林 speakerdeck.com YouTube 24:18〜 ZOZOの小林からは、EKSコストが10倍になったという驚きの話がありました。Fargate specのoptimize、Fargate実行時間の削減を実施し、またEKS on FargateからEKS on EC2への移行は検証中とのことです。EC2(EKS)への移行はAutify松浦さんも着手中として同じ話をされており、結果が気になりますね。 AWSコスト分析を利用したコスト最適化 3人目はdip ジョンフンモさん speakerdeck.com YouTube 49:26〜 dipのジョンフンモさんからはAWS Compute Optimizerを用いた最適化の発表がされました。AWS Compute Optimizerはリソースの最適なタイプを推奨するだけでなく、リスクも合わせて見積もってくれるのが便利だと感じました。バイトル、バイトルPROなど複数のサービスで段階的に最適化を行い、年間料金の約10%の削減に成功したそうです。 プロダクト間のデータ連携をイベント駆動で作り直した話 最後はdip 藤中さん speakerdeck.com YouTube 59:45〜 dipの藤中さんからは、コボットとバイトルという2つのプロダクトのデータ連携の発表がされました。データベースからAPIによってデータを取得するシステムから、AWS Lambdaを使ったイベント駆動のシステムに作り直すことでインフラコストを10分の1にでき、タイムラグがなくなったそうです。LambdaのCI/CD環境設計に手間取るなど、大変だったことも聞けるのは実例のありがたい点でした。 最後に 登壇者の皆さまはとても緊張されていましたが、無事に終わると笑顔で「次回も開催したい」と盛り上がっていました。それぞれ検証中の内容や今後の展望も発表いただいたので、結果が出る頃にまた開催できればと思います! ご登壇いただいた皆さま、ありがとうございました ZOZOではAWSを活用し、一緒にサービスを作り上げてくれる仲間を募集中です。今回発表のあったWEARだけでなくZOZOTOWN、オープンポジションでもSREエンジニアを募集しておりますので、ご興味のある方は以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、技術本部ML・データ部MLOpsブロックの鹿山( @Ash_Kayamin )です。MLOpsブロックではバッチ実行環境として Vertex AI Pipelines を用いています。Vertex AI PipelinesはGCPマネージドなKubeflow Pipelinesを提供するサービスで、コンテナ化した処理に依存関係をもたせたパイプラインを定義し実行できます。この記事ではVertex AI Pipelinesで起動するノードからIPアドレス制限があるエンドポイントへ通信するために、NATを利用して通信元IPアドレスを固定した方法と実装のはまりどころについてご紹介します。 Vertex AI Pipelinesの利用例については過去の記事で紹介していますので、併せてご覧ください。 techblog.zozo.com 目次 はじめに 目次 課題:Vertex AI Pipelinesで起動するノードからIPアドレス制限があるエンドポイントと通信したい 解決策:ピアリングしたVPCにNATインスタンスを作成し、NATインスタンス経由で外部通信させる 通信元IPアドレスを固定するための最小構成 手順1. プライベートサービスアクセスを用いてユーザー管理のVPCとGoogleの共有VPCをピアリングする 手順2. 外部通信用VPC、サブネットおよびNATインスタンスを作成する 手順3. カスタムルートを作成してGoogleの共有VPCにエクスポートする NATインスタンスを冗長化した構成 差分1. Managed Instance Groupを用いてNATインスタンスを2台作成する 差分2. ロードバランサーのバックエンドにManaged Instance Groupを設定し、ロードバランサー経由でNATインスタンスに接続する 差分3. Cloud NATを用いて、NATインスタンスからの外部通信の通信元IPアドレスを固定する 実際に冗長構成で通信する例 終わりに 課題:Vertex AI Pipelinesで起動するノードからIPアドレス制限があるエンドポイントと通信したい 先日、Vertex AI Pipelinesで実行するバッチの中で、IPアドレス制限を課しているエンドポイントと通信することが必要になりました。しかしながら、Vertex AI Pipelinesで起動するノードからの通信の通信元IPアドレスは固定されず、またパイプラインの実行パラメータ等で指定もできません。そのため、デフォルトではIPアドレス制限を課しているエンドポイントと通信できません。通信先のIPアドレス制限に対応するため、通信元IPアドレスを固定する方法を検討する必要がありました。 解決策:ピアリングしたVPCにNATインスタンスを作成し、NATインスタンス経由で外部通信させる GCP公式ブログ でVertex AI Pipelinesを様々なネットワーク構成で利用する方法が紹介されています。今回作成した構成はこちらを参考にさせていただきました。公式ブログで紹介されている構成のおおまかな流れは以下になります。 ユーザーが管理するVPCとGoogleの共有VPCをピアリングする。 ユーザーが管理するVPCにNATインスタンスを作成する。 Googleの共有VPCで起動するノードからの特定の宛先への外部通信がNATインスタンスを経由するようにカスタムルートを作成する。 Vertex AI pipelinesのパイプラインパラメータ VPC network に、ピアリングしたユーザー管理のVPCを指定してパイプラインを実行する。 パイプラインパラメータ VPC network にピアリングしたユーザー管理のVPCを指定することで、ピアリング先のGoogleの共有VPCでノードを起動できます。このGoogleの共有VPC内で起動したノードから特定の宛先へ外部通信する際にNATインスタンスを経由させることで、通信元IPアドレスをNATインスタンスに付与したIPアドレスに固定します。 ネットワーク構成の概略図は以下になります。こちらの詳細については後ほどご説明します。 公式ブログの構成例ではNATインスタンスの耐障害性が考慮されていません。そこで、 Managed Insntance Group(MIG) と Cloud NAT を利用することでNATインスタンスが担う機能に冗長性を持たせました。MIGはインスタンスの設定を記載したテンプレートからインスタンスを起動した上で、インスタンスのヘルスチェック・再起動・負荷に応じたスケーリング等を自動的に行ってくれます。 リージョンMIG を使用すると、インスタンスを複数のゾーンに配置できます。また、Cloud NATはGCPマネージドなNATを提供するサービスになります。まず、Vertex AI Pipelinesで起動したノードからの外部通信は、MIGで起動したNATインスタンスを経由するようにしました。加えて、NATインスタンスからの外部通信はCloud NATに付与したIPアドレスを通信元IPアドレスとするようにCloud NATを設定しました。MIGとCloud NATを合わせて利用することで、NATインスタンスに冗長性を持たせた通信元IPアドレスの固定を実現しました。 ネットワーク構成の概略図は以下になります。こちらの詳細についても後ほどご説明します。 通信元IPアドレスを固定するための最小構成 この章では、NATインスタンスを用いて通信元IPアドレス固定を実現する最小構成の構築手順と利用方法についてご説明します。 図の例では、Vertex AI Pipelinesで起動したノードから、カスタムルートを設定しているIPアドレス x.x.x.x へ通信した場合、最終的にNATインスタンスから宛先アドレス x.x.x.x へ通信元アドレス y.y.y.y で通信が行われます。ルーティングの例を以下に示します。 手順1. プライベートサービスアクセスを用いてユーザー管理のVPCとGoogleの共有VPCをピアリングする まずVPC Aを作成し、 公式ドキュメント の手順に従ってGoogleの共有VPCとピアリングします。ピアリングをすることで、VPC間で内部通信できるようになります。ピアリングにあたっては、CIDRを指定する必要があります。このCIDRはユーザー管理のVPC内の他のサブネットで利用しているCIDRと重複してはいけません。Vertex AI Pipelinesでパイプラインを実行する際のパラメータ VPC network にピアリング済みのVPC Aを指定すると、パイプラインで利用するノードのIPアドレスはここで指定するCIDRから割り当てられるようになります。 Googleの共有VPCとピアリングした際のGCPマネジメントコンソールでのVPC Peeringの表示は以下のようになります。 ここで指定するCIDRのレンジが小さいと、同じネットワークを指定して複数のパイプラインを実行した際に、IPアドレスが枯渇してパイプラインを実行できなくなる可能性があります。そのため十分な大きさのレンジを割り当てるように注意します。Vertex AI Pipelinesで実行するパイプラインに含まれる各コンポーネントの処理はVertex AI TrainingのCustom Jobとして実行されます。割り当てるレンジと、Custom Jobで起動できるノードの数の関係は 公式ドキュメント に記載があります。例えば/16を割り当てると最大63ジョブ(1ジョブ当たり8ノードと仮定)を並列実行できます。つまり、並列で63個のコンポーネントを同時に実行できますが、これ以上のコンポーネントを実行しようとするとIPアドレス不足でエラーが発生します。また、 公式ドキュメント の通り、同じGCPプロジェクト内で特定のネットワークを指定して実行しているパイプラインがある場合、そのパイプラインを実行中は別のネットワークを指定したパイプラインは実行できないことにも注意が必要です。 加えて、Vertex AI Pipelinesで起動するノードから、VPC A内のサブネットに作成するNATインスタンスへの通信を許可するため、ここで指定するCIDRからの通信を許可するファイアウォールルールをVPC Aに作成します。 手順2. 外部通信用VPC、サブネットおよびNATインスタンスを作成する 次にVPC AのサブネットA、VPC BならびにVPC BのサブネットBを作成します。そして、サブネットA、Bそれぞれに接続した2つのネットワークインタフェースを持つCompute Engineインスタンスを作成します。サブネットBに接続するネットワークインタフェースにはパブリックIPを割り当てます。Compute EngineのインスタンスをNATとして機能させるため、以下コマンドをインスタンスの起動時に実行されるスクリプトとして設定します。 # サブネットAに10.95.0.0/16, サブネットBに10.97.0.0/16を割り当てている前提で # それぞれのサブネットに接続しているネットワークインタフェース名を取得する private_interface = $( ifconfig | grep 10 . 95 -B 1 | head -n 1 | awk -F: { ' print $1 ' } ) public_interface = $( ifconfig | grep 10 . 97 -B 1 | head -n 1 | awk -F: { ' print $1 ' } ) # フォワーディングとマスカレードを有効化 sysctl -w net.ipv4.ip_forward = 1 sudo iptables -t nat -A POSTROUTING -o " $public_interface " -j MASQUERADE # サブネットBのデフォルトゲートウェイをネクストホップとするデフォルトルートを作成 sudo ip route add default via 10 . 97 . 0 . 1 dev " $public_interface " # Googleの共有VPCとのピアリングで10.98.0.0/21を割り当てている前提で # Vertex AI Pipelinesで起動したノードへの戻り通信(=宛先が10.98.0.0/21)の場合はサブネットAのデフォルトゲートウェイをネクストホップとする sudo ip route add 10 . 98 . 0 . 0 / 21 via 10 . 95 . 0 . 1 dev " $private_interface " # Cloud IAPの戻り通信(=宛先が35.235.240.0/20)の場合はサブネットAのデフォルトゲートウェイをネクストホップとする. インスタンスへIAPを用いてSSH接続したい場合に必要 sudo ip route add 35 . 235 . 240 . 0 / 20 via 10 . 95 . 0 . 1 dev " $private_interface " このスクリプトではサブネットAに属するネットワークインタフェースで受けた通信を、サブネットBに属するネットワークインタフェースからVPC Bのデフォルトゲートウェイへ送るデフォルトルートを作成します。IPマスカレードの設定とこのデフォルトルートにより、NATインスタンスのサブネットAのネットワークインタフェースで受けた通信は、サブネットBのネットワークインタフェースに割り当てたパブリックIPアドレスを通信元IPアドレスとして、サブネットBのデフォルトゲートウェイ経由で外部と通信します。 また、Vertex AI Pipelinesで起動したノードへの戻り通信を考慮したルートを追加していることに注意してください。戻り通信に対しては、行きの通信を受けたサブネットAのデフォルトゲートウェイをネクストホップとしています。このルートがないと、デフォルトルートによって戻り通信がサブネットBのデフォルトゲートウェイにルーティングされてしまい、Vertex AI Pipelinesで起動したノードと通信ができません。 手順3. カスタムルートを作成してGoogleの共有VPCにエクスポートする 最後にNATインスタンス経由で通信させたい外部IPアドレスを定め、このIPアドレスへの通信を手順2で作成したNATインタンスへルーティングするカスタムルートをVPC Aに作成します。そしてVPC Peeringの設定でカスタムルートのエクスポートを有効にし、このルートをVertex AI Pipelinesのノードが起動するGoogleの共有VPCへエクスポートします。 カスタムルートのエクスポートを有効にした際の、GCPマネジメントコンソールでのVPC Peeringの表示は以下のようになります。 ここで注意して欲しいのが、VPC Peeringではデフォルトルートならびに、通信元のIPアドレスをベースにしたルーティングを行うルートのエクスポートはサポートされていないことです。デフォルトルート(宛先IPアドレス 0.0.0.0/0 に対するカスタムルート)のエクスポートはサポートされていないので、Googleの共有VPCからの全ての通信をNATインスタンス経由にするようなルートを作成しても反映されません。デフォルトルートを作成するとGCPマネジメントコンソール上はピアリング先へのルートのエクスポートが成功した表示になります。しかしながら、実際にはこのルートは適用されないので注意してください。また、2023年1月には通信元のIPアドレスをベースにしたルーティング( Policy-based routes )がプレビュー機能として提供され始めましたが、こちらはピアリング先にはエクスポートされません。したがって、NATインスタンス経由にしたい宛先ごとにカスタムルートを作成してエクスポートする必要があります。 今回の構成において2つのVPCを作成しているのはこの理由からです。VPC Aに、通信したい特定の宛先への通信をNATインスタンスにルーティングするルートを作成するため、VPC A内からは通信したい特定の宛先への外部通信はできません。NATインスタンスから出た通信が再びNATインスタンスにルーティングされて戻ってきてしまうためです。この問題を解消するためにVPC Bを作成しVPC Aで受けた通信をVPC Bから外部通信するようにしています。 NATインスタンスを冗長化した構成 ここまで説明した最小構成ではNATインスタンスの部分が冗長化されていません。そのためNATインスタンスに問題が発生した場合、Vertex AI Pipelinesで起動したノードからカスタムルートを設定している特定の宛先への外部通信が一切行えなくなってしまいます。そこでNATインスタンスを冗長化した構成を作成しました。最小構成との差分をこの章で解説します。 再掲ですが、構築した冗長構成を以下の図に示します。 図の例では、Vertex AI Pipelinesで起動したノードから、カスタムルートを設定しているIPアドレス x.x.x.x へ通信をした場合、最終的にCloud NATから宛先アドレス x.x.x.x へ通信元アドレス y.y.y.y で通信が行われます。ルーティングの例を以下に示します。 差分1. Managed Instance Groupを用いてNATインスタンスを2台作成する まず、リージョンMIGを用いて2つの異なるゾーンでインスタンスを起動するように変更しました。MIGはインスタンスの設定を記載したテンプレートからインスタンスを起動した上で、インスタンスのヘルスチェック、再起動ならびに負荷に応じたスケーリング等を自動で行います。MIGからのヘルスチェックに対応するため、起動時に実行するスクリプトの末尾に以下のコマンドを追加しました。 # MIGからのヘルスチェックの戻り通信(=宛先が35.191.0.0/16、 130.211.0.0/22) の場合はサブネットAのデフォルトゲートウェイをネクストホップとする. MIGのヘルスチェックでの通信に必要 sudo ip route add 35 . 191 . 0 . 0 / 16 via 10 . 95 . 0 . 1 dev " $private_interface " sudo ip route add 130 . 211 . 0 . 0 / 22 via 10 . 95 . 0 . 1 dev " $private_interface " # 外部への接続が可能なことを確認するヘルスチェックエンドポイントをPythonで作成 cat <<EOF > /usr/local/sbin/health-check-server.py #!/usr/bin/env python from http.server import BaseHTTPRequestHandler、 HTTPServer import subprocess PORT_NUMBER = 8080 PING_HOST = "example.com" def connectivityCheck(): try: subprocess.check_call(["ping", "-c", "1", PING_HOST]) return True except subprocess.CalledProcessError as e: return False class MyHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path == '/health-check': if connectivityCheck(): self.send_response(200) self.end_headers() self.wfile.write(b"OK") else: self.send_response(503) else: self.send_response(404) try: server = HTTPServer(("", PORT_NUMBER), MyHandler) print(f"Started httpserver on port {PORT_NUMBER}") #Wait forever for incoming http requests server.serve_forever() except KeyboardInterrupt: print("^C received, shutting down the web server") server.socket.close() EOF # ヘルスチェックエンドポイントを起動 nohup sudo python3 /usr/local/sbin/health-check-server.py > /dev/null 2 >&1 & 加えて、MIGからのヘルスチェックの通信を許可するファイアウォールルールをVPC Aに追加しました。ヘルスチェックエンドポイントではリクエストを受けると外部通信し、通信に成功した場合には200 OK、失敗した場合には503 Service Unavailableを返します。こうすることで、何らかの問題が発生してインスタンスから外部通信が行えなくなりNATインスタンスとしての役割を果たせなくなった際に、MIGで障害を検出して自動的にインスタンスを再起動できます。 差分2. ロードバランサーのバックエンドにManaged Instance Groupを設定し、ロードバランサー経由でNATインスタンスに接続する 次に、内部ロードバランサーを作成し、ロードバランサーのバックエンドに差分1で作成したMIGを設定します。そして、この内部ロードバランサーをカスタムルートでのルーティング先に変更します。ロードバランサーのヘルスチェック機能で、MIGのいずれかのインスタンスで障害が発生した場合、障害が発生したインスタンスはロードバランサーのルーティング対象から自動的に除外されます。NAT経由にしたい通信をLBを介してNATインスタンスにルーティングすることで、特定のNATインスタンスに障害が発生した場合でも、他の正常なNATインスタンスを利用して外部通信を継続できます。 差分3. Cloud NATを用いて、NATインスタンスからの外部通信の通信元IPアドレスを固定する MIG管理のインスタンスに、事前に作成した固定のパブリックIPアドレスを付与する場合、インスタンステンプレートのネットワークインタフェース設定に固定のパブリックIPアドレスを記載することになります。この場合、同じパブリックIPアドレスは1つのインスタンスにしか付与できないので、同じテンプレートを利用するMIGでは1台しかインスタンスを起動できないという問題が発生します。この問題を解決するため、サブネットBからの外部通信は全てCloud NAT経由で通信をするように変更し、NATインスタンスのネットワークインタフェースに付与していたパブリックIPアドレスは削除します。こうすることで、MIGで作成する個別のインスタンスに固定のパブリックIPアドレスを付与する必要がなくなり、MIGで複数のインスタンスを作成できるようになります。 実際に冗長構成で通信する例 冗長構成を実際に構築して通信をする例を示します。Vertex AI Pipelinesでパイプラインを実行する際のパラメータ VPC network に手順1で作成したVPC Aを指定します。手順2で予めカスタムルートを作成した宛先へ外部通信する処理をパイプライン内で行うと、Clound NAT経由での外部通信になります。 実際に通信した際のカスタムルートの設定、Cloud NATの設定、Vertex AI Pipelinesのコンテナログを以下に示します。 Vertex AI Pipelinesのコンテナログに出力されている通り、Vertex AI Pipelinesが起動したノードで実行したコンテナからifconfig.ioへcurlしています。curlの出力をみると、カスタムルートを設定しているIPアドレス 172.64.110.32 への通信をしています。そしてcurlのレスポンスを見ると、通信の通信元IPアドレスがCloudNATへ割り当てたパブリックIPアドレス 34.37.88.80 になっていることがわかります。 終わりに 今回はNATを利用し、Vertex AI Pipelinesで起動するノードから通信する際の通信元IPアドレスの固定を実現しました。これにより、Vertex AI Pipelinesで起動したノードからIPアドレス制限があるエンドポイントへ通信できるようになりました。 今後の活用方針としては例えば、複数の異なるGCPプロジェクトで実行するパイプラインから利用する共通機能を作成する際の活用が考えられます。1つのGCPプロジェクトに作成した共通機能のエンドポイントにIPアドレス制限を設けることで、異なるGCPプロジェクトで実行する通信元IPアドレスを固定したパイプラインからの外部通信は許可しつつ、他の管理されていない環境からの通信を弾くことができます。 なお複数のGCPプロジェクト間のネットワークをつなぐサービスとしてShared VPCがありますが、Vertex AI Pipelinesではその恩恵にあずかれないので注意してください。Shared VPCを用いてGCPプロジェクト間で通信可能なネットワークを構築していても、Vertex AI Pipelinesで起動するノードから他のGCPプロジェクトのリソースへは内部通信はできません。 公式ドキュメント に記載の通り、VPC Peeringでは推移的なルーティングはサポートされていません。そのため、Googleの共有VPCとShared VPCの1つのVPCをピアリングしても、直接ピアリングしたVPC間でしか通信できず、Shared VPCに含まれる他のVPCとGoogleの共有VPCは通信できません。またVertex AI Pipelinesで必要とするCIDRは比較的大きいので、Shared VPCに属するVPCをピアリング対象とするとShared VPC全体のCIDRを逼迫してしまう可能性もありおすすめできません。 今後は今回作成した仕組みを用いて、より運用負荷とセキュリティリスクを低減したMLバッチの実行環境を構築していく予定です! ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com hrmos.co
こんにちは。WEAR部iOSチームの小野寺です。 先日CollectionViewで実装しているトップページを改修しました。 改修はトップページに並べていたコンテンツを1つにまとめて、横スクロール(手動 / 自動)によってコンテンツを切り替え可能にしました。 横スクロールによってコンテンツを切り替える仕様なので、CompositionalLayoutで実装しました。 上記の方針で進めていく中で、困難な実装に直面したので紹介します。 セクション全体への装飾 最初に直面した問題が、セクション全体に対するViewの装飾です。今回画像の赤枠部分について、次のように改修が必要になりました。 トップページ改修の要件 横スクロールで、コンテンツを切り替えられるレイアウトに変更 その上に固定で表示され続けるViewを被せる(画像の赤枠) 固定で表示するViewの高さはタグ名の長さによって変わる 改修前 改修後 レイアウト調整にコストがかかる 固定で表示され続けるViewは、UICollectionReusableView, NSCollectionLayoutBoundarySupplementaryItemを使用しました。 func createLayout() -> UICollectionViewCompositionalLayout { ~~ 省略:横スクロール用のレイアウト設定 ~~ let decorationViewHeight = 150 let decorationViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(decorationViewHeight)) let decorationView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: tagContainerViewSize, elementKind: “tag-container-element-kind”, alignment: .bottom, absoluteOffset: .init(x: 0, y: -decorationViewHeight)) section.boundarySupplementaryItems = [decorationView] ~~ 省略 ~~ } 追加したViewのy座標を、セクションの乗せたい位置(decorationViewHeight)までずらすことで、セクション全体へかかるようにしています。 ここではまだ、高さが変わることを考慮していないので、内容によってはレイアウトが崩れてしまいます。 Viewの高さを計算する対応を追加し、コンテンツの表示に必要な高さを確保します。 class DecorationView: UICollectionReusableView { ~~ 省略 ~~ static func calcViewHeight(title: String, containerSizeOfWidth: CGFloat) -> CGFloat { let decorationView = calculationBaseView decorationView.label.text = title decorationView.setNeedsDisplay() decorationView.layoutIfNeeded() let layoutViewSize = layoutView.systemLayoutSizeFitting(CGSize(width: containerSizeOfWidth, height: 0), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) return layoutViewSize.height } ~~ 省略 ~~ } 追加したcalcViewHeight()をdecorationViewHeightへ反映させます。 func createLayout() -> UICollectionViewCompositionalLayout { ~~ 省略:横スクロール用のレイアウト設定 ~~ // let decorationViewHeight = 150 let title = "冬がはじまるよ" let layout = collectionView.layoutAttributesForItem(at: IndexPath(item: 0, section: 0))! let decorationViewHeight = DecorationView.calcViewHeight(title: title, containerSizeOfWidth: layout.frame.width) let decorationViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(decorationViewHeight)) let decorationView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: tagContainerViewSize, elementKind: “tag-container-element-kind”, alignment: .bottom, absoluteOffset: .init(x: 0, y: -decorationViewHeight)) section.boundarySupplementaryItems = [decorationView] ~~ 省略 ~~ } コンテンツの内容によって高さを取得する必要があるのでレイアウト調整にコストがかかっています。 OSによってはフッターが非表示になる さらに、iOS14.5未満のOSバージョンで、フッターがセルの裏に隠れてしまう問題もありました。 この事象に対しては、UICollectionReusableViewを使った対応が取れませんでした。 今回は後にターゲットOSを上げる予定があった為、暫定対応としてUICollectionReusableViewに直接実装したコンテンツをカスタムUIViewとして切り出しました。 事象が発生するバージョンではUICollectionReusableViewは使用せず、このカスタムUIViewをCollectionViewのSubViewとして扱う改修で対応しました。 コンテンツの自動スクロール レイアウトの改修の次は、自動でコンテンツをスクロールさせる機能の追加です。 自動スクロール機能の要件 一定時間に画面の操作がない場合に、次のセルを表示 最後のセルまで表示させたら、先頭に戻る 自動スクロール中に意図しないスクロールの発生 自動スクロールの対応は、はじめに以下の方針で検討しました。 一定時間でスクロールできるように、Timerを追加して定周期でスクロール処理を呼び出す。 スクロール処理は、scrollToItem(at:at:animated:)をデフォルトのアニメーションを有効にして、コンテンツをスクロールさせる。 Timer側からの呼び出し Timer.scheduledTimer( withTimeInterval: 3.0, repeats: true, block: { [weak self] _ in guard let self = self else { return } let toItem = displayContentIndexPathItem + 1 self.scrollToItemForCarousel(at: .init(item: toItem, section: 0)) } ) 定周期で呼び出すスクロール処理 func scrollToItemForCarousel(at indexPath: IndexPath) { collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true) } 実際動作させてみたところcollectionView.contentOffset.yが、アニメーションの度に改修したセクションのデフォルトの位置へと、引き戻される問題が発生しました。 原因は、scrollToItem(at:at:animated:) へ第1引数で渡しているindexPathにありました。スクロール先の指定にindexPathが使用されることで、x座標、y座標それぞれに対してscrollToItemが作用してしまいます。 これによりCollectionViewの垂直方向が、少しでもスクロールされた状況下の場合に、事象が発生してしまいました。 自作の自動スクロールのアニメーションを追加 問題を解消するために、今回はscrollToItem(at:at:animated:)のデフォルトのアニメーションは使わず、次のような自作アニメーションを実装しました。 自動スクロール前のcontentOffsetの位置を保持する scrollToItem(at:at:animated:)でスクロール位置を更新 y座標を調整前の値で更新する 2と3を1つのアニメーションとして扱う func scrollToItemForCarousel(at indexPath: IndexPath) { let currentOffset = collectionView.contentOffset UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) { [weak self] in guard let collectionView = self?.collectionView else { return } collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false) collectionView.contentOffset.y = currentOffset.y } } scrollToItemでのindexPathの更新とy座標をもとに戻す対応を1つのアニメーションとして実装することで、引き戻される問題を回避しています。 先頭へ戻るアニメーションにコストがかかる ここまでの対応で、自動で次のコンテンツへ切り替える対応ができました。次に最後のセルまで表示させたら、先頭に戻るアニメーションが必要です。 戻る処理についてもscrollToItem(at:at:animated:)のアニメーションをそのまま使用できれば、簡潔に対応可能です。 (第1引数へ先頭のIndexPathを指定することで、コンテンツが最終位置にいる状態から、アニメーション付きで一気に先頭へ戻す挙動を実現できます) しかし自作アニメーションを使用する場合は、次のような変更が必要になりました。 UIView.animateのcompletion内で再度自作アニメーションを呼ぶ処理を追加 引数needsRepeateを追加し、先頭のセルに戻ってくるまでこの値を有効にする needsRepeateの値が有効の間、自作アニメーションを繰り返す アニメーション処理を繰り返すことで、最後尾から先頭までのコンテンツの移動を、1つのアニメーションのように見せます。 この際durationの値を調整して、scrollToItem(at:at:animated:)のアニメーションを使った場合の挙動に近づけています。 func scrollToItemForCarousel(at indexPath: IndexPath, needsRepeate: Bool) { let currentOffset = collectionView.contentOffse let duration: TimeInterval = needsRepeate ? 0.05 : 0.3 UIView.animate(withDuration: duration, delay: 0, options: .curveEaseInOut) { [weak self] in guard let collectionView = self?.collectionView else { return } collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false) collectionView.contentOffset.y = currentOffset.y } completion: { [weak self] _ in let previousIndex = indexPath.item - 1 if needsRepeate { self?.scrollToItemForCarousel(at: .init(item: previousIndex, section: indexPath.section), needsRepeate: previousIndex >= 0) } } } CompostionalLayoutを使った実装は、レイアウトを組むまでは容易でしたが「セクション全体への装飾」と「コンテンツの自動スクロール」の実装が複雑になり大変でした。 CompositionalLayoutを使わない解決策 これらの実装をCompostionalLayoutを使わず、CollectionViewを中に入れたセル(ContentCollectionViewInCell)を使う方法で考えてみます。 ContentCollectionViewInCellは、横スクロールで切り替え可能なコンテンツの表示に使用するCollectionViewと装飾に使用するViewを配置したもので考えてみます。 ContentCollectionViewInCell.xib ContentCollectionViewInCell.swift class ContentCollectionViewInCell: UICollectionViewCell, UICollectionViewDataSource { @IBOutlet var collectionView: UICollectionView! @IBOutlet var decorationView: DecorationView! var contentImages: [UIImage] = [] override func awakeFromNib() { super.awakeFromNib() configureCollectionView() } func configure(contentImages: [UIImage], labelText: String) { self.contentImages = contentImages collectionView.reloadData() } private func configureCollectionView() { collectionView.register(ContentImageCell.self, forCellWithReuseIdentifier: ContentImageCell.identifier) collectionView.dataSource = self collectionView.collectionViewLayout = createPagingContentLayout() } private func createPagingContentLayout() -> UICollectionViewLayout { UICollectionViewCompositionalLayout { _, _ -> NSCollectionLayoutSection? in ~~ 省略:横スクロール用のレイアウト設定 ~~ return section } } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return contentImages.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ContentImageCell.identifier, for: indexPath) ~~ 省略:cellの中身の更新 ~~ return cell } } セクション全体を装飾するViewの調整 ContentCollectionViewInCellを使用した場合、CompostionalLayoutで苦労したセクション全体を装飾するViewの高さの調整は、オートレイアウトで解決できます。 実際に「ComposionalLayoutを使用した場合」と「ContentCollectionViewInCellを使用した場合」で実装を比較してみます。 ComposionalLayoutを使用した場合 class ViewController: UIViewController { ~~ 省略 ~~ func createLayout() -> UICollectionViewCompositionalLayout { ~~ 省略:横スクロール用のレイアウト設定 ~~ let title = "冬がはじまるよ\nアウターはMAST\n見れたらいいねスターダスト" let layout = collectionView.layoutAttributesForItem(at: IndexPath(item: 0, section: 0))! // 最前面に配置したViewの高さを計算 let decorationViewHeight = DecorationView.calcViewHeight(title: title, containerSizeOfWidth: layout.frame.width) // 最前面に配置したViewの高さの反映と位置を調整する let decorationViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(decorationViewHeight)) let decorationView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: tagContainerViewSize, elementKind: “tag-container-element-kind”, alignment: .bottom, absoluteOffset: .init(x: 0, y: -decorationViewHeight)) section.boundarySupplementaryItems = [decorationView] return section } ~~ 省略 ~~ } ContentCollectionViewInCellを使用した場合 class ContentCollectionViewInCell: UICollectionViewCell, UICollectionViewDataSource, UICollectionViewDelegate { ~~ 省略 ~~ func updateDecorationViewLabel() { decorationView.contentLabel.text = "冬がはじまるよ\nアウターはMAST\n見れたらいいねスターダスト" collectionView.reloadData() } } ContentCollectionViewInCellを使用した場合、decorationViewが持つラベルの設定とcollectionViewの更新のみで、期待した高さが反映されます。 OS差分の問題 ComposionalLayoutを使用せずにContentCollectionViewInCellを使用した場合は、iOS14.5未満とそうでないOSの差分による影響も無くなります。 コンテンツの自動スクロール機能 コンテンツの自動スクロール機能もContentCollectionViewInCellを使用した場合は、実装が容易になります。 y座標は常に固定で扱うことが可能なことから、scrollToItem(at:at:animated:)の呼び出しのみで対応することが可能になります。 実際に「ComposionalLayoutを使用した場合」と「ContentCollectionViewInCellを使用した場合」で比較すると次のようになります。 ComposionalLayoutを使用した場合 class ViewController: UIViewController { ~~ 省略 ~~ func scrollToItemForCarousel(at indexPath: IndexPath, needsRepeate: Bool) { let currentOffset = collectionView.contentOffse let duration: TimeInterval = needsRepeate ? 0.05 : 0.3 UIView.animate(withDuration: duration, delay: 0, options: .curveEaseInOut) { [weak self] in guard let collectionView = self?.collectionView else { return } // 次のセルに進めるアニメーション collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false) collectionView.contentOffset.y = currentOffset.y } completion: { [weak self] _ in let previousIndex = indexPath.item - 1 if needsRepeate { // 先頭のセルに戻すアニメーション self?.scrollToItemForCarousel(at: .init(item: previousIndex, section: indexPath.section), needsRepeate: previousIndex >= 0) } } } ~~ 省略 ~~ } ContentCollectionViewInCellを使用した場合 class ContentCollectionViewInCell: UICollectionViewCell, UICollectionViewDataSource { ~~ 省略 ~~ func autoScroll(toIndex: Int) { let totalContents = 5 if indexPath.item < totalContents { // 次のセルに進めるアニメーション collectionView.scrollToItem(at: .init(item: toIndex, section: 0), at: .centeredHorizontally, animated: true) } else { // 先頭のセルに戻すアニメーション collectionView.scrollToItem(at: .init(item: 0, section: 0), at: .centeredHorizontally, animated: true) } } ~~ 省略 ~~ } このように今回紹介したケース単体で見ると、CollectionViewを中に入れたセルを使う対応の方がより簡潔に済みます。 CollectionViewInCellの問題点 CollectionViewInCellにも仕様によっては、かえってコード量の増加や実装の複雑度が上がってしまう問題が考えられます。 考えられる問題 セル毎にCollectionViewの設定が必要になる 親のセルが表示するセルの状態の管理をするようになる セルの階層に比例してタップアクションのハンドリングが複雑になる 実現したいUIに対する実装が容易になる一方で、アクションに対する実装やその後の運用が困難になるといったトレードオフがあることを理解しておく必要があります。 おわりに 今回は複数のレイアウトが混在する画面の改修だったため、CompositionalLayoutを使う方針を選択しました。 これにより、横スクロールのような複雑なレイアウトに対しても簡潔に取り入れることができました。 その一方で、凝ったUIの仕様では実装の複雑度が上がってしまいました。 今回は最初に検討していた、CompositionalLayoutで実現する方法を選択しましたが、メリット・デメリットを把握して現状取れる最適な選択をすることが大事だと感じました。 WEARでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は次のリンクからぜひご応募ください。 hrmos.co
はじめに こんにちは。ML・データ部/推薦基盤ブロックの佐藤( @rayuron )です。私たちは、ZOZOTOWNのパーソナライズを実現する機械学習を用いた推薦システムを開発・運用しています。また、推薦システムの実績を定常的に確認するためのシステムも開発しています。本記事では、Lookerを用いて推薦システムの実績をモニタリングするシステムの改善に取り組んだ件についてご紹介します。 はじめに 改善の背景と課題 背景 課題 課題解決のために 要件1. 指標異常時の自動アラート 要件2. サマリの定期配信 要件3. 上記2つをSlack通知できること ダッシュボードの候補の比較 要件を満たすための設計 要件の実現方法 開発環境と本番環境 実装 ディレクトリ構成 ダッシュボード ダッシュボード構築の流れ 配信実績に関して 推薦結果に関して GitHub Actions 1. 指標異常時の自動アラート 2. サマリの定期配信 工夫した点と苦労した点 工夫した点 サマリの定期配信のフォーマット 苦労した点 アラートの閾値の決め方 改善の効果 今後の展望 おわりに 改善の背景と課題 背景 運用しているシステムの1つにメール配信を利用してシューズアイテムを訴求するシステムがあり、私たちのチームではユーザーが興味を惹くアイテムを推薦するための機械学習システムを開発・運用しています。この推薦システムの実績を定常的にモニタリングするために、 Looker Studio (旧 Data Portal)を用いてダッシュボードを構築していました。さらに、このダッシュボードに連携するためのデータを集計するシステム「モニタリングシステム」を運用しており、以下の図で構成されます。 Vertex AI PipelinesはCloud SchedulerとCloud Functionsによって1日1回定期実行されます。Vertex AI PipelinesではBigQueryのジョブを実行し、Looker Studioのダッシュボードに表示しやすい形式でデータを整形してその結果を連携用のテーブルとして保存します。Looker Studioではこの中間テーブルからデータを取得してダッシュボードを表示しています。また、週に1度、指標の変化率を以下の様にSlackで通知していました。 メール配信数: N 件(前週比:N %) 週間売上: N 円(前週比:N %) 1配信あたり流入数: N 件/配信(前週差:N pt) 1配信あたり注文数: N 件/配信(前週差:N pt) 1配信あたり売上: N 円/配信(前週差:N pt) Vertex AI Pipelinesは一般的に機械学習システムのワークフロー管理ツールとして使用されますが、私たちのチームでは推薦システムの実績をモニタリングする用途でも使用しています。Vertex AI Pipelinesの導入事例については過去のテックブログでも紹介していますのでご参照ください。 techblog.zozo.com 課題 モニタリングシステムを運用してみて課題となったのは、 指標の異常に気付くのが遅れたり、そもそも気付かないことでした。 これまでは人が能動的にダッシュボードを見に行かなければ指標の異常に気づけないという状況でした。 課題解決のために 結論を申し上げると、上記課題を解決するためにVertex AI PipelinesとLooker Studioで構築していたモニタリングシステムをLookerを使用したシステムに置換しました。ここからは代替となるシステムを検討する際の3つの要件をご紹介します。 要件1. 指標異常時の自動アラート 課題の通り、指標の異常に気づくためには人がダッシュボードを見る工程が必要でした。この工程を自動化するため、指標異常時にはアラートを自動的に通知する仕組みを実現する必要があります。異常値の定義の方法には統計学や機械学習を用いる方法がありますが、今回は簡単な閾値の判定で異常値の検知をします。 要件2. サマリの定期配信 要件1を満たすことで明らかな異常値に気づくことはできるものの、こうした閾値判定だけでは中長期的な変化を捉えることができないため、少なからず人の目での定期的なチェックが必要です。また、プロダクトを管理するという観点で指標のトレンドを把握する必要があります。 要件3. 上記2つをSlack通知できること また、私たちのチームでは通知をSlackで受け取るため上記2つをSlackに通知することが要件となります。 ダッシュボードの候補の比較 部署内では、ダッシュボードとしてLooker、Looker Studio、スプレッドシートを使用しています。そこで、活用事例があるこれらのサービスを使用して要件を満たすシステムが実現できないかを考えました。 要件 Looker Looker Studio スプレッドシート 1. 指標異常時の自動アラート ○ x x 2. サマリの定期配信 ○ ○ x 3. 上記2つをSlack通知できること ○ x x スプレッドシートは標準機能で要件を満たさず、Looker Studioは要件2のサマリをメールで送ることができましたが、Slackには通知できませんでした。そのため、全ての要件を満たした Lookerを採用することに決めました 。 要件を満たすための設計 上記の要件を実現するためにシステム設計をしました。Lookerのダッシュボードやアラートの細かな設定をすべてGitHubでバージョン管理できるようにすることと設定追加の拡張性を重視しています。 要件の実現方法 Lookerの標準機能を用いて指標異常時の自動アラートとサマリの定期配信のSlack通知を実現します。以下の図のシステム構成を考えました。 アラートと定期配信に関するyamlの設定ファイルを作成し、GitHub Actions上でLooker APIを使ってそれらの情報を設定します。アラートとサマリの定期配信の設定はLookerのUIからできますが、設定数の増大を想定してLooker APIを用います。 開発環境と本番環境 動作確認のために開発環境と本番環境に分け実装します。Lookerのインスタンスを2つ使用し、以下の図のような構成にしました。 Looker IDEからアラートと定期配信の対象となるダッシュボードのために必要なファイルを実装します。アラートと定期配信に関するyamlの設定ファイルに関しては、Looker IDE上でyamlが編集できないため、ローカルPCからPull Requestを作成します。GitHubとLookerのWebhookの設定とブランチの設定をすることで、それぞれのLookerインスタンスがGitHubの変更を反映するようにします。環境毎のブランチとGitHub Actionsの設定についてまとめると以下の様になります。 環境 ブランチ トリガー 実行内容 開発環境 main feature branchを main branchにmerge 設定ファイル記載のアラートの登録 設定ファイル記載の定期配信の登録 本番環境 release main branchを release branchにmerge 設定ファイル記載のアラートの登録 設定ファイル記載の定期配信の登録 実装 このセクションでは上記で説明した設計の具体的な実装について説明します。 ディレクトリ構成 ダッシュボード GitHub Actions ディレクトリ構成 ディレクトリ構成は以下です。異なるダッシュボードで同じLookMLファイルを参照する場合を考え、LookMLファイルを再利用しやすくするようにディレクトリを分けています。 . ├── .github │ └── workflows │ ├── main-push.yaml │ ├── main-merge.yaml │ └── release.yaml ├── README.md ├── config │   └── project_name │   ├── alert.yaml │   └── scheduled_plan.yaml ├── dashboards │   ├── *.dashboard.lookml ├── explores │   ├── *.explore.lkml ├── models │   └── *.model.lkml ├── scripts │   ├── compare_dashboards.py │   ├── set_alert.py │   └── set_scheduled_plan.py ├── tests │ └── *.test.lkml └── views └── *.view.lkml ディレクトリ名と詳細は以下です。 ディレクトリ名 詳細 .github GitHub Actionsの設定ファイル README.md READMEファイル config アラートと定期配信の設定ファイル dashboards LookML Dashboardファイル explores Lookerのexploreファイル models Lookerのmodelファイル scripts CIのスクリプト tests Lookerのtestファイル views Lookerのviewファイル ダッシュボード 指標異常時の自動アラートとサマリの定期配信は指定されたダッシュボードに対して行われるため、ダッシュボード構築が必要となります。 ダッシュボード構築の流れ Looker内で定義できるダッシュボードには ユーザー定義ダッシュボード と LookML Dashboard の2種類があります。以下の表でそれぞれを簡単に比較します。2つのダッシュボードについてより詳細な比較は 公式ドキュメント をご参照ください。 種類 ダッシュボードの作成方法 編集可能なユーザー ユーザー定義ダッシュボード UIから作成 ビジネスユーザーとLookerデベロッパー LookML Dashboard ファイルで定義 Lookerデベロッパーの選択したグループ ユーザー定義ダッシュボードはビジネスユーザーにも編集権限があります。ビジネスユーザーが意図せずダッシュボードに変更を加えてしまうことを回避するために最終的にLookML Dashboardを使用します。 以下の流れでLookML Dashboardを構築します。 Looker IDEからview、model、exploreファイルを定義 Explore UIからユーザー定義ダッシュボードを作成 ユーザー定義ダッシュボードをLookML Dashboardに変換 詳細については以下の公式ドキュメントをご参照ください。 Lookerの用語と概念 ユーザー定義ダッシュボードの作成 ユーザー定義ダッシュボードからLookML Dashboardに変換 配信実績に関して メール経由の注文率などの配信実績に関しては実績値と変化率を折れ線グラフで表示します。さらにそれらの指標を性別と年代別に分けてプロットしました。以下はダッシュボードの一例です。 メール流入率 世代別のメール流入率 推薦結果に関して 今回の改善に伴い、推薦を作成したユーザーの世代別割合やユーザーが推薦される商品画像の一部など推薦結果に関わる指標もモニタリング対象としました。 推薦を作成したユーザーの世代別割合 推薦される商品画像の一部 GitHub Actions 指標異常時の自動アラートとサマリの定期配信の設定についてGitHub Actionsを使用して実装します。 1. 指標異常時の自動アラート アラートの設定用に以下のようなyamlファイルを作成し、CI実行時に内容をLookerへ登録します。 - lookml_dashboard_id : kpi_monitoring_shoes::shoes_recommendation alerts : - lookml_link_id : visit/open cron : "0 10 * * *" name : mail_result.visit_per_open lower : 0.1 upper : 0.5 - lookml_link_id : recommend_generation cron : "0 10 * * *" name : generation lower : 0.01 upper : 0.1 field_name : member.age_tier field_value : 19 to 22 ... それぞれのパラメーターで指定できる内容は以下です。 パラメーター 内容 lookml_dashboard_id ダッシュボードのID alerts -- (alerts配下に情報を記載) lookml_link_id ダッシュボードの要素のID cron アラートのスケジュール name モニタリングする指標の列 lower 下限閾値 upper 上限閾値 field_name ピボットテーブルを使用した場合のモニタリングする指標の列 field_value ピボットテーブルを使用した場合のモニタリングする指標の値 上記のパラメーターにモニタリングしたい指標の情報を記述すると、CI実行時に設定が登録され同じタイミングですでに登録されていた設定を削除します。アラートのSlack通知のイメージは以下です。 2. サマリの定期配信 サマリの定期配信の設定用に以下のようなyamlファイルを作成し、CI実行時に内容をLookerへ登録します。 - lookml_dashboard_id : kpi_monitoring_shoes::shoes_recommendation_weekly cron : 0 10 * * thu title : shoes_recommendation_weekly slack_message : shoes_recommendation_weekly format : wysiwyg_png - lookml_dashboard_id : kpi_monitoring_shoes::shoes_recommendation_monthly cron : 0 10 4 * * title : shoes_recommendation_monthly slack_message : shoes_recommendation_monthly format : wysiwyg_png ... それぞれのパラメーターで指定できる内容は以下です。 パラメーター 内容 lookml_dashboard_id ダッシュボードのID cron 定期配信のスケジュール title 定期配信のタイトル slack_message 定期配信時のメッセージ format 通知時のファイル形式 上記のパラメーターに定期配信したいダッシュボードの情報を記述すると、CI実行時に設定が登録され同じタイミングですでに登録されていた設定を削除します。定期配信のSlack通知のイメージは以下です。 工夫した点と苦労した点 全体を通して工夫した点と苦労した点を以下で説明します。 工夫した点 サマリの定期配信のフォーマット 当初は時系列のグラフをSlackに通知することを考えていましたが、視認性の向上のため最低限の指標を載せたシンプルなダッシュボードを定期配信するようにしました。PDFよりもロードが早いPNG形式で表示し、Slackの画像をワンクリックすることで指標を確認できるようにしました。具体的には変更前と変更後でSlack通知の様子は以下の様に異なります。 変更前 変更後 苦労した点 アラートの閾値の決め方 アラートの閾値の決め方に現状も苦労しています。配信実績の閾値はプロジェクトメンバーと議論することにより共通認識を持つことができます。一方、機械学習タスクは問題設定が様々であったり、モデルが時として人間の直感に反した結果を返したりするため、閾値決めは難しいものと考えています。この点は今後の検討事項としています。 改善の効果 これまで説明してきたシステムを構築することで、以下の2つの効果を得られました。 1つ目は、指標の異常時に自動で気づく体制ができたことです。以前は、指標の異常に気付くためには人による指標のモニタリングというオペレーションが必要でした。アラート機能を実装したことによって、指標の異常時に自動的に気付く体制ができました。 2つ目は推薦を以前より高い解像度で説明できる様になったことです。思わぬ効果でしたが、モニタリングする指標が増えたことでどのようなユーザーにどのようなアイテムが推薦されているかを以前より高い解像度で認識できました。これによりユーザーやアイテムの簡単な傾向をアドホック分析なしに定常的に確認できる様になりました。 今後の展望 実装を終えてみて、今後の展望は大きく以下の4つです。 1つ目は異常値のアラートに関してです。季節の影響を強く受けるファッションドメインで、常に同じ閾値で異常値を判定することは正しい状態かを考える余地があります。また、アラートが通知された後の意思決定フローについても考える必要があります。 2つ目はサマリの定期配信に関してです。定期配信は形骸化してしまう可能性が大いにあるので、今後形骸化しない方法を考える必要があります。 3つ目はダッシュボードに載せる指標に関してです。今回は配信実績と推薦結果に関わる指標をメインでダッシュボードに載せました。一方で、今回の改善を通してLookerで機械学習モデルに関わる指標をモニタリングできると感じたので、今後検証していきたいと考えています。 4つ目はOSSとしての公開です。本記事で紹介したモニタリングシステムは将来的にOSSとして公開することを考えています。 おわりに 本記事では機械学習を用いた推薦に関するモニタリングシステムの改善の話をしてきました。現在ZOZOではパーソナライズを強化している最中で、今回の話で挙がった推薦をモニタリングするシステムの構築もその一環です。推薦に関わるエンジニアを募集しているので、ご興味がある方は是非以下のリンクからご応募ください! hrmos.co hrmos.co
こんにちは。ML・データ部データサイエンス1ブロックの尾崎です。データサイエンス1ブロックでは機械学習モデルや、データ分析によって得られたルールベースのモデルの開発をしています。特に、ZOZOTOWNやWEARの画像データを扱っています。 本記事では、教師データがないPoC特有の「モデルの評価をどうするか」という課題への対策を商品画像の色抽出の事例とともに紹介します。教師データが無いという同じ境遇に置かれた方々の一助となれば幸いです。 目次 目次 事業上の課題 どのようなモデルを作ったか モデルの評価をどうしたか 何を正解ラベルとするか アノテーションを外注するか、内製するか 評価指標の設計をどうしたか まとめ 参考 事業上の課題 アパレル商品の検索において、カラーは重要な要素の1つです。ZOZOTOWNでは15色のカラー(図1)を指定して検索できますが、より細かな粒度で商品を検索したいユースケースもあります。最近ではメイクやファッションにパーソナルカラーを取り入れることが流行しています。自分のパーソナルカラーに合う色で検索したいユーザもいることでしょう。ただ、現在のZOZOTOWNの検索だと「黄緑色」の商品に細かく絞り込みたくても、検索結果に「濃い緑色」の商品などが含まれてしまいます(図2)。 図1 ZOZOTOWNのカラー検索で使える色 図2 「緑色」の条件で絞り込んだ検索結果 詳しいカラーで検索できる機能があるとUX向上にも寄与します。そのためには、これまでのカラーデータよりも詳細なカラーデータを得る必要があります。そこで、データサイエンス1ブロックでは 教師データがない状況 から、商品画像のカラーを抽出するモデルを開発しました。 どのようなモデルを作ったか 図3が、作ったモデルによる抽出・検索結果です。カラーコードの色で検索した結果を列ごとに表示しています。教師データがない状況でここまでの精度を出すことができました。 図3 モデルによる出力結果 モデルの概要図は図4です。商品画像からカラーと比率を抽出するモデルです。 図4 カラー抽出モデル概要図 以下のステップでカラー抽出を実現しました。 商品画像からセマンティック・セグメンテーションによってファッションアイテムのマスクを抽出する 商品のカテゴリ情報から最適なマスクを選択する マスク内のピクセルのカラーをクラスタリングし、代表色にまとめる カラーはL*a*b*(以下、簡単のためLabと表記する)色空間上の3次元のベクトルで表現しています。Lab色空間は人間の視覚に近くなるよう設計されており、人間が知覚する色差をユークリッド距離で表せます。 モデルの評価をどうしたか 教師データがないので、工夫して定量評価できるようにしました。定量評価をわざわざ行えるようにした理由は実験サイクルを早く、正確に回すためです。定性評価では実験のたびに関係者へ評価をお願いするので時間がかかりますし、評価に主観が入り込んでしまいます。 次の節からその工夫を解説します。主に正解ラベル、アノテーションを外注/内製するか、評価指標の決め方を紹介します。 何を正解ラベルとするか 教師データがなくても、定量評価には正解ラベルが必要です。正解ラベルは、商品画像に含まれる「カラーのみ」としました。他にも以下の表1の案がありました。 表1 正解ラベル案の比較 「カラーのみ」を選んだ理由はアノテーションコストが最も低く、かつ検索というユースケースにおいては「カラー」と「カラー数」が評価できれば十分だったからです。 アノテーションを外注するか、内製するか 今回は内製にしました。理由は「1件あたりのアノテーション時間」を計測し、最終的にかかる時間を見積もったところ、コア業務に支障がでない時間で終わりそうだったからです。この判断により、外注費用の節約ができましたし、アノテーションがまったく終わらないという事態も回避できました。 評価指標の設計をどうしたか IoU (Intersection over Union) など、セマンティック・セグメンテーションの指標ではなく、評価指標を独自に定義する必要がありました。なぜなら、正解ラベルとして「ピクセル単位のラベル」ではなく、商品に含まれる「カラーのみ」を採用したからです。 まず、評価したい観点として以下の2つを洗い出しました。 カラーが近いか? カラー数が同じか? カラー数も評価する理由は今回のユースケースである検索において、どんなに色が近くても色数が異なればユーザ体験が損なわれると判断したからです。 この2つの観点を測るため、以下の図5のように色の近いペアごとに類似度を計算し、その和を正解と予測のうち多い方の色数で割った値を評価指標としました。 図5 独自に定義した評価指標の概要 これを数式で表現すると以下になります。 :正解カラーの集合( :Lab色空間上のベクトル) :予測カラーの集合( :Lab色空間上のベクトル) :予測カラーと正解カラーの類似度(詳しい定義は後述) なぜ、この式で2つの観点を測れるのかを解説していきます。まず、1つ目の観点である「カラーが近いか?」は、分子(類似度 を足し合わせること)で測れます。ただし、 近い色のペアのみ に絞ります。なぜなら、すべてのペアだと以下の問題があるからです。 同系色間の類似度が強く反映されてしまう 近くない色のペアによって平均化されてしまう 例えば、以下の図6の左の類似度行列の予測には緑の同系色 が含まれています。影の色を異なる同系色として抽出してしまうことがよくあります。こうなると白系のペア と比べて、緑系のペア の占める割合が高くなり、緑系が強く評価指標に反映されてしまいます。また、図6の左の方が右よりも上手く抽出できていますが、類似度の和は同じ3.3になってしまいます。これは、近くない色のペア も含めているからです。 図6 2つの類似度行列の例(説明の簡単のため、正確な値ではありません) 近い色のペアのみ に絞ることで、緑は のみが使われ、緑と白が対等に評価されます。また、左右の類似度の和は、それぞれ1.9、1.5となり「左の方が右よりも上手く抽出できている」ということを表せます。 の漸化式(以下に再掲)は、正解・予測カラーの集合から「近い色のペア=類似度が最大のペア」を順に取り出していくことを表しています。 :正解カラーの集合( :Lab色空間上のベクトル) :予測カラーの集合( :Lab色空間上のベクトル) この漸化式を図解したものが以下の図7になります。赤字が最も近いペアとして取り出されていきます。この赤字の類似度の和が分子になります。 図7 漸化式を類似度行列で図解 ところで、類似度 の定義は以下になります。 :予測・正解カラー間のユークリッド距離。Lab空間上の2点間のユークリッド距離は人間が知覚する色差に近しい 1 :関係者間で定性的に決めた「これ以上離れたら、全く異なる色に感じる距離」。データセット内の距離の最大値を使ってしまうと、ほとんどの色のペアの類似度が高くなってしまうため。 わざわざ距離を類似度に変換した理由は、最終的な指標を「何色中、何色あってるか」と解釈しやすくするためです。例えば、2色のうち1色は完全一致(類似度1)、もう1色は半分くらい似ている色(類似度0.5)のとき「2色のうち1.5色あっている」と解釈できるようにするためです。 2つ目の観点である「カラー数が同じか?」は分母(正解と予測のうち、多い方の色数 で割ること)によって測れます。他の案として類似度の平均にしてしまう、つまり、少ない方の色数 で割ってしまうと「カラー数が同じか?」を評価できません。例えば、正解が2色のとき、予測が2色でも100色でも分母は2となり同じ評価値になってしまいます。多い方の色数で割ることによって、100色より2色の方が良いことを表現できます。 以上より、今回の評価指標(以下に再掲)は、2つの観点「カラーが近いか?」と「カラー数が同じか?」を測れていると言えます。 最後に「予測の良し悪しと、評価値の良し悪しが連動するか」を具体的なデータで確認します。 図8 評価値が高い/低い例 図8の評価値が高い例では、カラーの近さとカラー数が両方とも合っています。一方、評価値が低い例では、全然違うカラーだったり、カラー数が間違っています。つまり、予測の良し悪しが評価値の良し悪しに連動していることを確認できました。このように評価値が高い/低い例を確認することで、評価指標に欠陥がないかを確認できます。この確認のおかげで「近い色のペアのみに絞る」や「多い方の色数で割る」という改善を思いつくことができました。 まとめ 本記事では、教師データがないPoC特有の「モデルの評価をどうするか」という課題に対して、商品画像の色抽出の事例とともに以下の解決策を紹介しました。 教師データが無くても、正解ラベルを用意して定量評価の方法を確立することで、実験サイクルを早く・正確に回せるようにしました。 アノテーションの外注か内製かを選ぶにあたり「1件あたりのアノテーション時間」を計測し、最終的にかかる時間を見積もりました。この判断により、外注費用の節約ができましたし、アノテーションがまったく終わらないという事態も回避できました。 独自の評価指標や正解ラベルを定義する際は「ユースケースに必要十分な評価観点を明らかにすること」と「予測の良し悪しと、指標の良し悪しが連動するかを具体的なデータで確認すること」で適切な定量評価方法を設計できました。 ZOZOではデータサイエンティスト・MLエンジニアのメンバーを募集しています。今回紹介した画像タスクに興味ある方はもちろん、幅広い分野で一緒に研究や開発を進めていけるメンバーも募集しています。ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co 参考 Jain, Anil K. (1989). Fundamentals of Digital Image Processing. New Jersey, United States of America: Prentice Hall. pp. 68, 71, 73. ISBN 0-13-336165-9. ↩
はじめに こんにちは。計測プラットフォーム開発本部 計測プロデュース部の井上です。 私たちは ZOZOFIT 、 ZOZOMAT 、 ZOZOMAT for Hands や ZOZOGLASS などの計測技術に関わるプロダクトのサービス開発をしています。先日ローンチしたZOZOFITではGoogle Analytics 4(以下、GA4)を導入しました。本記事ではGA4を導入する際に工夫した点と注意点について紹介します。 目次 はじめに 目次 計測プラットフォーム開発本部 計測プロデュース部とは 計測プロダクトとデータ分析 ZOZOFITとは GA4の導入 自動計測screen_viewイベントの無効化 手動計測screen_viewイベントの実装 GA4のDebugView設定 GA4とBigQueryの連携 Looker Studio Looker Studioのフィルタ機能 Looker Studioの関数 Looker Studioの正規表現 まとめ おわりに 計測プラットフォーム開発本部 計測プロデュース部とは 計測プラットフォーム開発本部は、計測技術の活用を通して、「世界中に計測技術を通じて、新しい価値をプラスする」ことをミッションとしています。その中で計測プロデュース部は、計測サービスを企画、開発するチームです。 計測プロダクトとデータ分析 私たちが提供している計測プロダクトではGoogle Analytics(以下、GA)を活用してUI/UX向上に役立てています。例えば、GAから取得する指標に離脱数があります。計測フローの中でユーザーの離脱数が多い箇所に関して、チュートリアル動画の内容の見直すなどサービスをより使いやすくする施策を日々行っています。その他にも地域、年齢、性別、端末の種類など様々な角度から取得したデータを切り取ることで属性の違いによる傾向を分析し、サービスの強みと弱みを見出す努力をしています。 これらの分析の積み重ねが最終的にサービス全体の今後の方向性や優先順位を決める意思決定につながっています。また、データの全社的な使用性と加工性を考えて、GAで取得したデータは会社アカウントのBigQueryに連携しています。その結果、他部署のスタッフであってもデータにアクセスできる上、好きなツールでシームレスに連携が可能なので、部を跨いだ活用がなされています。最終的に収集したデータはLooker Studio(旧GoogleDataStudio)を用いて可視化し、データから得られた気付きを分かりやすい形でチームに共有しています。 ZOZOFITとは ZOZOFIT は2022年に発表した体型管理を目的としたフィットネスアプリです。現在は北米のみのローンチ展開となっています。 これまで計測プロダクトではUniversal Analytics(以下、UA)を用いて計測していましたが、Google社から2023年7月にUA クローズのアナウンス がされていることから、GA4でZOZOFITを設計する必要がありました。 GA4はGoogle社が提供するアクセス解析ツールで第4世代のものになります。特徴としてはウェブとアプリを統合したイベントを計測単位とし、プライバシーに配慮した分析(Cookieレス)ができます。以下では私たちがGA4を導入する際に工夫した点と注意点について紹介します。GA4は 公式ドキュメント を参考に導入しました。 GA4の導入 ここからはGA4の導入に際して、躓いた点や最初から知っていれば作業が捗った点などを紹介します。 自動計測screen_viewイベントの無効化 GA4では初めから 自動収集イベント が設定されています。自動収集イベントのscreen_viewイベントはデフォルトでパラメータ値(スクリーン名)が送られないため、後で分析する際にスクリーンを特定することが難しくなってしまいます。そのため、screen_viewイベントの自動計測を無効化し、任意のスクリーン名が送られるように各スクリーンにコードを実装しました。以下は自動計測screen_viewイベントの無効化の設定方法になります。 iOSの場合は、 Info.plist で FirebaseAutomaticScreenReportingEnabled を false に設定します。 <key>FirebaseAutomaticScreenReportingEnabled</key> <false/> Androidの場合は、 AndroidManifest.xml で <application> タグ内の FirebaseAutomaticScreenReportingEnabled を false にします。 <meta-data android : name = "google_analytics_automatic_screen_reporting_enabled" android : value = "false" /> 手動計測screen_viewイベントの実装 UAではページビュー、イベント(ユーザーのアクション)という概念が存在しましたが、GA4からは全てイベントを指標とする計測に変わりました。そのためUA時代のスクリーンビューは スクリーンビューイベント 、イベントは アクションイベント として設計する対応としました。例えばZOZOFITの計測結果では、計測結果の画面が表示された時は ページビューイベント 、ユーザーによるトレンドチャート 1 のタッチ時は アクションイベント が発火されるよう実装しました。 ZOZOFITの肩の計測結果の画面。画面下のトレンドチャートをタッチするとアクションイベントが発火される。 以下はコードの実装例となります。 iOS(Swift) ページビューイベント Analytics.logEvent("ScreenView", parameters: [ AnalyticsParameterScreenName: "ShoulderScreen", "ScreenCategory": "Results"]) アクションイベント Analytics.logEvent("Action", parameters: [ "ActionName": "ShoulderTrendChartTouchAction"]) Android(Kotlin) ページビューイベント firebaseAnalytics.logEvent("ScreenView") { param(FirebaseAnalytics.Param.SCREEN_NAME, "ShoulderScreen") param("ScreenCategory", "Results")} アクションイベント firebaseAnalytics.logEvent("Action") { param("ActionName", "ShoulderTrendChartTouchAction")} これらをiOSの場合は onAppear (SwiftUI)もしくは viewDidAppear (UIKit)メソッド、Androidの場合は onResume メソッドで呼び出せばイベントが発火されます。また、パラメータはGA4のデフォルトパラメータと ScreenCategory のようなカスタムパラメータを設定できます。 GA4のDebugView設定 DebugViewはGA4から新しく追加された機能で、端末を絞ってリアルタイムにトラッキング情報を確認できます。私たちは設定した内容が正しくトラッキングできるかテストするためにDebugViewを使っています。 これまでUAではリアルタイムレポートの項目からページやイベントの確認はできましたが、端末(ユーザー)を絞って確認することができませんでした。そのため、テストの際は確認用の環境を作って、確認中はなるべくテスターだけがアクセスするなどの配慮が必要でした。 また、GA4にはリアルタイムレポートの機能が引き続き存在し、その中にユーザースナップショットというDebugViewと似た機能があります。 ユーザースナップショットは候補となるユーザーがランダムに選ばれる仕様で確認したい端末を選択できない場合がありました。 以上の2つの理由からトラッキング確認にはDebugViewを採用しました。 以下はDebugViewの設定方法になります。iOSの場合は、以下のコードでデバッグモードを有効にしてDebugViewを見れるようにします。 var args = ProcessInfo.processInfo.arguments args.append("-FIRDebugEnabled") ProcessInfo.processInfo.setValue(args, forKey: "arguments") Androidの場合は、以下の設定をします。 Android検証端末の 開発者向けオプションとUSBデバッグを有効化 します。 Android検証端末を繋ぐPCで Android Debug Bridge (以下、adb)コマンドのパスを通します。 Android検証端末とPCを接続し、PCで以下のコマンドを実行します。 $ adb shell setprop debug.firebase.analytics.app PACKAGE_NAME 設定ができたら、GA4プロパティからDebugViewを確認します。 DebugViewから自身が設定したイベント名、パラメータが確認できればOK。 GA4とBigQueryの連携 先に述べた全社的な使用性に加え、GA4の データ保持期間 は最長で14カ月(年齢、性別などは2カ月)であり、14カ月以上データを保持することが考えられるためBigQueryに連携しました。BigQueryは他にも生データのクエリ分析や中間テーブルの作成、他サービスとデータの統合ができるなどのメリットがあります。デメリットとしては、1日上限の100万イベントを超えると超過料金が発生します。ちなみにUA時代にも同じ理由からBigQueryと連携していましたが、UAのデータ保持期間は最長で50カ月であったので、GA4とBigQuery連携のメリットがより高まったと言えます。 GA4の連携したいプロパティの管理画面から BigQueryのリンク を選択して連携できます。初回データの反映は連携完了後 24時間以内 でBigQueryプロジェクトにエクスポートされます。 event_params(とuser_proparties)はネストされた状態で入る。データをクエリで取り出す際はアンネストする必要があるので注意。 Looker Studio BigQueryからクエリを用いてデータ抽出できますが、クエリに不慣れな場合、データ抽出できなかったり、時間がかかってしまうことがあります。その点、Looker Studioを用いれば比較的、手軽にデータ分析できます。データの流れは下図のようになります。 Looker Studioのフィルタ機能 ここからは私たちが使っているLooker Studioの有効な機能を紹介します。Looker Studioにはデータのフィルタ機能があります。フィルタを用いれば欲しいデータに絞った分析ができます。例えば、 screen_name が StartingScreen のイベント数を集計したい場合はフィルタを以下のように設定します。 条件 パラメータ 不等号 値 一致条件 Event Param Value(String) 次に等しい(=) StartingScreen フィルタをかけないと他のパラメータが混在してしまう。 フィルタをかけることにより、StartingScreenだけに絞ったイベント数を集計できる。 Looker Studioの関数 Looker Studioでは 関数 を用いて様々な計算を行うことができます。 例えば、CASE関数を用いれば、パラメータの表示名を他の名称に置き換えることができます。この方法は報告する相手に合わせて英語名のパラメータを日本語名に変更したい場合などに有効です。ディメンションのフィールド作成から新規フィールドを開いて、以下の式を入力します。 case when Event Param Value (String)="StartingScreen" then "開始画面" else null end StartingScreen を 開始画面 に置き換える。 when以降の式を足していけば、画面が増えた時にも対応できる。 Looker Studioの正規表現 Looker Studioでは計算フィールド内で正規表現を使うことができます。そのため例えば、年代別の分析がしたい場合、以下の式を計算フィールドに入力します。 case when REGEXP_MATCH(DATETIME_DIFF(TODAY(),Birth Date , YEAR),‘^1[0-9]’) then ‘10代’ when REGEXP_MATCH(DATETIME_DIFF(TODAY(),Birth Date , YEAR),‘^2[0-9]’) then ‘20代’ when REGEXP_MATCH(DATETIME_DIFF(TODAY(),Birth Date , YEAR),‘^3[0-9]’) then ‘30代’ when REGEXP_MATCH(DATETIME_DIFF(TODAY(),Birth Date , YEAR),‘^4[0-9]’) then ‘40代’ when REGEXP_MATCH(DATETIME_DIFF(TODAY(),Birth Date , YEAR),‘^5[0-9]’) then ‘50代’ when REGEXP_MATCH(DATETIME_DIFF(TODAY(),Birth Date , YEAR),‘^6[0-9]’) then ‘60代’ when REGEXP_MATCH(DATETIME_DIFF(TODAY(),Birth Date , YEAR),‘^7[0-9]’) then ‘70代’ when REGEXP_MATCH(DATETIME_DIFF(TODAY(),Birth Date , YEAR),‘^8[0-9]’) then ‘80代’ when REGEXP_MATCH(DATETIME_DIFF(TODAY(),Birth Date , YEAR),‘^9[0-9]’) then ‘90代’ when REGEXP_MATCH(DATETIME_DIFF(TODAY(),Birth Date , YEAR),‘^1[0-9][0-9]’) then ‘100代’ else null end Birth Date(YYYYMMDD) を変数として算出する。 まとめ ここまで書いた内容はGA4の導入を始めた初期の私が知りたかった、もしくは知っていれば幾分か作業が捗ったと思う内容です。例えば、GA4から変わったトラッキングの概念や、DebugViewの新しい機能の使い方、他にもLooker Studioのフィルタや関数を用いた分析などがあります。まだまだアップデートの多いGA4ですが、今後もキャッチアップを続けていきます。今回書いた内容の他にも方法はあると思いますが、本記事が少しでも導入の手助けになれば幸いです。 おわりに 計測プラットフォーム開発本部では、今後も新しいサービスのローンチを予定しています。更にスピード感を持った開発が求められますが、このような課題に対して楽しんで取り組み、サービスを一緒に盛り上げていける方を募集しています。少しでもご興味のある方は以下のリンクからぜひご応募ください。また、カジュアル面談も随時実施中ですのでお気軽にご応募ください。 テクニカルプロダクトマネージャー(ZOZOMAT/ZOZOFIT等) | ZOZOグループ ZOZOFITの計測結果の推移を時系列に確認できるチャート。 ↩
こんにちは。検索基盤部の倉澤です。 ZOZOTOWNには、ユーザーが検索クエリを入力した際に、入力の続きを補完したキーワードを提示するサジェスト機能があります。この機能は一般に「Query Auto Completion」と呼ばれ、素早くユーザーの検索を完了させることを目的としています。 検索基盤部では、ZOZOTOWNの商品検索だけではなくサジェスト機能の改善にも取り組んでいます。今回は近年実施したサジェスト機能の改善事例を紹介します。2年程前にまとめたサジェスト改善事例の記事も併せてご覧ください。 techblog.zozo.com 目次 目次 システム概要 インデキシングフェーズ 検索フェーズ 改善事例 ユーザーインタフェース サジェスト候補のハイライト サジェスト機能の視覚的な奥行き サジェスト候補のキーワード ブランド名やショップ名の表記揺れ 検索クエリの読み仮名と異なるサジェスト候補のキーワード 今後の展望 スペル修正 ドキュメントの生成ロジック おわりに 改善事例を紹介する前にサジェスト機能のシステム概要を紹介します。 システム概要 システム概要をインデキシングフェーズと検索フェーズに分けて簡単に説明します。 インデキシングフェーズ インデキシングフェーズでは、サジェスト候補のキーワード生成、Elasticsearchへのインデックス生成、エイリアスの更新をするバッチジョブを定期的に実行しています。実行基盤にはGoogle Kubernetes Engineを採用し、CronJobによりバッチジョブをスケジューリングしています。 サジェスト候補となるキーワードは、ユーザーの検索クエリのログから生成しています。検索クエリのログはGoogle BigQueryに日々格納しており、インデキシングフェーズではGoogle BigQueryから過去数日分の検索ログを取得しサジェスト候補のキーワードを生成しています。 また、Google BigQueryから取得する検索クエリのログには、1つも商品が表示されなかった検索クエリ(0件ヒットクエリ)も含まれています。ユーザーに0件ヒットクエリを提示することは再検索の手間を発生させ、ユーザーの離脱に繋がってしまう可能性があります。そのため、サジェスト候補を生成する際には0件ヒットクエリを除外しています。 Elasticsearchでのクエリとドキュメントのマッチングに用いられるスコアは デフォルト でBM25をベースに計算されます。私たちはデフォルトのスコアを利用せず、過去のサジェスト候補へのクリック率やクリック後の商品購入率などを利用しスコア計算しています。 検索フェーズ 検索フェーズでは、ユーザーから入力されたクエリを受け取り、ZOZOTOWNの検索機能を提供するAPIにてElasticsearchのクエリを生成します。Elasticsearchの検索クエリでは、入力されたクエリと表示されるサジェスト候補の対応をわかりやすくするため prefix match を用いています。Elasticsearchにリクエストを送信するとサジェスト候補のキーワードが返されます。 また、サジェスト候補クリック後の商品検索ロジックについてはZOZOTOWN検索の精度改善の取り組み紹介の記事で説明しています。 techblog.zozo.com 改善事例 ユーザーインタフェースやサジェスト候補のキーワードに対する改善例をいくつか紹介します。各事例に対する課題やアプローチ方法を簡単にまとめた表が以下になります。 カテゴリー 事例 概要 課題 アプローチ方法 ユーザーインタフェース サジェスト候補のハイライト 検索クエリに対するサジェスト候補のハイライト箇所について 各サジェスト候補を比較する際の視覚的負荷 検索クエリとサジェスト候補の差分箇所をハイライト ユーザーインタフェース サジェスト機能の視覚的な奥行き 検索クエリ入力時のサジェスト機能の見せ方について サジェスト機能以外のコンテンツによる検索行動への影響 ページ背景を暗くし奥行きをつける事によるサジェスト機能の強調 サジェスト候補のキーワード生成 ブランド名やショップ名の表記揺れ 検索クエリとサジェスト候補のキーワード間に発生する表記揺れについて 表記揺れしたブランド名やショップ名がサジェスト候補に表示される サジェスト候補のキーワードに対する読み仮名の取得やElasticsearchのクエリ改修 サジェスト候補のキーワード生成 検索クエリの読み仮名と異なるサジェスト候補のキーワード 日本語検索クエリの読みに対して表示すべきサジェスト候補について 日本語検索クエリの読み仮名と異なるサジェスト候補のキーワードが表示される インデキシング時や検索時に適用されるnormalizerの修正 ユーザーインタフェース 検索基盤部では、 Baymard Institute社 が提供する検索体験のガイドラインレポートを改善方針として活用していました。 Baymard Institute社のガイドラインレポートは有償ですが、オートコンプリート機能にまつわるレポート「 9 UX Best Practice Design Patterns for Autocomplete Suggestions(Only 19% Get Everything Right) 」は無償で公開されているので、こちらを交えて改善例を紹介します。 サジェスト候補のハイライト ガイドラインレポートによると、ユーザーが入力したクエリとサジェスト候補のキーワードの差分を強調することで各候補との比較が容易になり、ユーザーの視覚的な負担を低減できるとされています。 良い例と悪い例のイメージ図は以下の通りです。 サジェスト機能の視覚的な奥行き ガイドラインレポートによると、ウェブサイト・アプリ内に配置されている数多くのコンテンツはユーザーがサジェスト機能へ集中する妨げになる可能性があるとされています。 そのため私たちは、他のコンテンツとの間に境界線を設けるだけではなく、ユーザーが検索窓に入力している間はページの背景を暗くし視覚的な奥行きを付けることでサジェスト機能を強調しました。 良い例と悪い例のイメージ図は以下の通りです。 ZOZOTOWNのサジェスト機能では、以下のようにサジェスト候補のハイライトや視覚的な奥行きを持たせたユーザーインタフェースが実現されています。 サジェスト候補のキーワード インデキシングするドキュメントやElasticsearchの検索クエリに対する改善例を紹介します。 ブランド名やショップ名の表記揺れ ユーザーが入力したクエリに含まれる語とサジェスト候補のキーワードに含まれる語との間に発生する表記揺れにより、適切なサジェスト候補が取得できないという問題がありました。例えば、ユーザーが検索窓に「ゾゾタウン」と入力した場合、「ZOZOTOWN」から始まるサジェスト候補がヒットしませんでした。 この事象は特にブランドやショップに関するキーワードにおいて多く発生していたため、私たちはブランドやショップの読み仮名のデータを利用して対処しました。 サジェスト候補のキーワードの読み仮名をドキュメントの"furigana"フィールドとして追加しました。検索クエリにブランド名やショップ名の読み仮名が入力された場合、"furigana"フィールドによりドキュメントをヒットさせます。 インデキシング時にブランド名やショップ名に対する読み仮名を管理している辞書データを参照し、"furigana"フィールドへブランド名やショップ名の読み仮名を追加しています。 以下が改善後のドキュメントの一例です。"suggest_candidate"フィールドがサジェスト候補のキーワードです。 { " suggest_candidate ": " ZOZOTOWN限定 ", " furigana ": " ゾゾタウンゲンテイ " , ... } 以下が"furigana"フィールドを用いたElasticsearchの検索クエリの一部です。検索クエリ「ゾゾタウン」が上記のドキュメントへヒットするようになりました。 { " query ": { " bool ": { " should ": [ { " prefix ": { " suggest_candidate ": " ゾゾタウン " } } , { " prefix ": { " furigana ": " ゾゾタウン " } } ] } } } 検索クエリ「ゾゾタウン」を入力し「ZOZOTOWN」を含むサジェスト候補が表示されていることがわかります。 冒頭で述べたようにユーザーの検索クエリのログを用いてサジェスト候補を生成しています。そのため、サジェスト候補のキーワード生成時にブランド名やショップ名の表記が揺れている問題がありました。 例えば、正式なブランド表記は「ZOZOTOWN」だがサジェスト候補のキーワードとして「zozotown」が定義されてしまうことです。 この事象はブランド名やショップ名に対する別称や読み仮名を管理している辞書によって解決しました。サジェスト候補のキーワード生成時に行っている大まかな処理内容は以下の通りです。 検索クエリをターム分割 ターム毎に辞書を参照 参照したタームを正式なブランド名やショップ名に変換 検索クエリの読み仮名と異なるサジェスト候補のキーワード ユーザーから日本語の検索クエリ、例えば「あい」が入力された場合に「Airplane」(エアプレーン)など日本語の読み仮名と異なるサジェスト候補が表示されてしまう課題がありました。日本語の場合、読み仮名にマッチするドキュメントをヒットさせるのが自然と考えたため、この課題に取り組みました。 一方、ローマ字の検索クエリ、例えば「ai」が入力された場合に「アイシャドウ」など入力されたローマ字表記と異なるサジェスト候補が表示されていました。しかし、PCからキーボードを操作する際に意図せずローマ字表記となってしまうケースが考えられたため、日本語入力のみ対応する方針を取りました。 なぜ検索クエリ「あい」に対してドキュメント「Airplane」がヒットしていたのかを説明します。 ドキュメント「Airplane」のインデキシング時に独自定義している normalizer により最終的にローマ字変換され「ai」として登録される 入力されたクエリ「あい」も最終的にローマ字変換され「ai」となる 「ai」に対して「airplane」がprefix matchする normalizerによる変換処理を模式的に表しました。 入力 正規化 ローマ字変換 検索クエリ あい あい ai ドキュメント Airplane airplane airplane ローマ字変換を削除することで検索クエリ「あい」はドキュメント「Airplane」にヒットしなくなります。 一方、ローマ字変換を削除すると検索クエリ「ai」に対してドキュメント「アイシャドウ」はヒットしなくなります。 そのため、サジェスト候補のキーワードの振り仮名をローマ字変換した値をドキュメントの"furigana_romaji"フィールドとして追加しました。こうすることでローマ字の検索クエリに対して、"furigana_romaji"フィールドを用いてマッチさせることができ、今まで通りドキュメントをヒットさせています。 以下が"furigana_romaji"フィールドを追加したドキュメントの一例です。 { " suggest_candidate ": " アイシャドウ ", " furigana ": " あいしゃどう " , " furigana_romaji ": " aishadou ", ... } 以下が"furigana_romaji"フィールドを用いたElasticsearchの検索クエリの一部です。 { " query ": { " bool ": { " should ": [ { " prefix ": { " suggest_candidate ": " ai " } } , { " prefix ": { " furigana ": " ai " } } , { " prefix ": { " furigana_romaji ": " ai " } } ] } } } 以下の通り、検索クエリ「ai」に対して今まで通り「アイシャドウ」が表示されています。具体的なブランド名や商品名には加工処理をしています。 今後の展望 今回は主にユーザーインタフェースやサジェスト候補のキーワードを精度高く表示する改善例を中心に紹介しました。今後については以下のような視点でもサジェスト機能をさらに改善していきたいと考えています。 スペル修正 ユーザーから入力されたクエリにスペルミスがあった場合、ユーザーが意図したサジェスト候補は返されずユーザー側でクエリを修正する必要があります。ユーザーにより早く探している商品に辿り着いてもらうため、ユーザーの検索意図を理解し入力されたクエリにスペルミスがあった場合でも意図した結果を返すように改善していきたいと考えています。 ドキュメントの生成ロジック 冒頭で述べたように現状サジェスト候補のドキュメントは過去のユーザー検索ログから生成しておりますが、検索頻度が低いクエリにヒットするようなドキュメントは生成しておりません。より多くの検索クエリに対応できるようにサジェスト候補の生成ロジックの改善に取り組んでいきたいと考えています。 おわりに ZOZOでは検索エンジニア・MLエンジニアを募集しています。今回紹介した検索技術に興味ある方はもちろん、幅広い分野で一緒に研究や開発を進めていけるメンバーも募集しています。 ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co hrmos.co