TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

938

はじめに こんにちは、SRE部の秋田と鈴木です。ZOZOTOWNのオンプレミスとクラウドの運用・保守・構築に携わっています。 現在、ZOZOTOWNはリプレイスプロジェクトの真っ只中です。そのため、いくつもの壁にぶつかりつつも、それらを1つずつ解決してプロジェクトを進めている状況です。 オンプレミス基盤上で動くWebサーバのリプレイスを行う際に、既存構成では十分なテストを行うことができませんでした。本記事では、その課題をAkamai Application Load Balancerを導入することで解決したアプローチを紹介します。これにより、既存のシステム構成を大きく変更することなく、より柔軟にテストやシステムの変更を加えられるようになりました。 はじめに 既存構成 キャッシュストアのリプレイス計画 生じた課題点  課題1:カナリアリリースできない  課題2:既存のiRuleが利用できない Akamai Application Load Balancerの導入 選定理由 導入後の構成 導入による効果 まとめ 最後に 既存構成 ZOZOTOWNのWebサイトは自社で保有しているオンプレミス基盤の上で稼働しています。そのため、ユーザはZOZOTOWNにアクセスする際、まずはCDNである Akamai Intelligent Edge にアクセスします。そして、Akamai Intelligent Edgeからオンプレミスで管理している F5社のネットワーク機器であるBIG-IP を通ることでWebサーバに到達します。 既存のZOZOTOWNではWebサーバのメモリ領域にセッション情報を保持しており、ユーザを常に同一のWebサーバに振り分ける必要がありました。なお、セッション情報にはログイン認証情報やカートの情報が含まれています。そのため、今までアクセスしていたWebサーバと異なるWebサーバにアクセスすると、ログアウトしてしまう、カートの中身がなくなる等の影響が発生します。 前述の理由のため、同一のWebサーバへユーザがアクセスするよう、BIG-IPのロードバランサでStickyセッションを用いてアクセスを制御していました。他にも各種Botからのトラフィック等をより詳細に制御するために、BIG-IPのiRuleを用いていました。しかし、既存のiRuleはZOZOTOWNの歴史と共に肥大化しており、運用コストが高くなっていました。 Webサーバがセッション情報を持つステートフルな状態ではスケーリングに手間がかかります。また、iRuleに依存したトラフィック制御は今後の運用で負債になる可能性がありました。Webサーバのクラウド移行や今後のリプレイスの障壁となる前に脱却することが望ましいです。 より詳細な構成は、下記資料で紹介しているので併せてご覧ください。 speakerdeck.com キャッシュストアのリプレイス計画 弊社では中期目標としてこのセッション情報のリプレイスを掲げ、Amazon ElastiCache(以下、ElastiCache)にリプレイスするプロジェクトを進めてきました。最初の段階ではアプリケーションレイヤーで使用しているキャッシュストアをリプレイスし、ElastiCacheに関するノウハウの蓄積、開発や運用の体制を整備してきました。 techblog.zozo.com 次の段階では、既存システムの課題であるWebサーバがメモリ領域に保持していたセッション情報をリプレイスします。しかし、新たに作成した「セッション情報がメモリ領域上にないWebサーバ」と「既存のWebサーバ」を入れ替える際に課題が生じました。 生じた課題点 Webサーバのキャッシュストアリプレイスを進める上で、下記の課題が出てきました。 カナリアリリースができない 既存のiRuleが利用できない  課題1:カナリアリリースできない 既存の構成においてキャッシュストアの設定をデプロイするためには、Webサーバをすべて新規Webサーバに入れ替える「ビッグバンリリース」をする必要がありました。ビッグバンリリースを行う場合、もしもの事故が発生した場合の影響は計り知れません。 ZOZOTOWNのアプリ向けに新規開発された機能は自社開発した「API Gateway」の加重ルーティング機能を用いてカナリアリリースをしています。ZOZOTOWNのWebサーバでも同様に安定したリリース手段が必要でした。 techblog.zozo.com  課題2:既存のiRuleが利用できない iRuleの設定は振り分け先のサーバがすべてStickyセッションを「有効化している」、または「すべて無効化している」の2択です。 今回のキャッシュストアのリプレイスでは、新規WebサーバはStickyセッションがないものの、旧WebサーバたちはStickyセッションがあるため、iRuleでは制御できません。そのため、通信するサーバを振り分けられるiRuleとは別の仕組みが必要でした。 Akamai Application Load Balancerの導入 前述の課題を、Akamai Application Load Balancer(以下、ALB)を導入することで解決しました。ALBの詳細は以下のドキュメントをご確認ください。 learn.akamai.com 選定理由 ALBを導入する際に必要な要件は以下の4点でした。 既存構成を変えることなく導入できる なるべく速く導入できる 加重ルーティングができる ヘッダーやパスによるルーティングが可能である ZOZOTOWNでは、もともとCDNとしてAkamaiを利用していました。そのため、最も簡単かつスピーディに導入できる、十分な機能を持っているALBとしてAkamai ALBを選択しました。 導入後の構成 既存のCDNとオンプレミス基盤の間にALBを配置する構成にしました。 オンプレミス基盤には旧Webサーバ専用のオリジンと新規Webサーバ専用のオリジンを作成します。そして、どちらへ通信を流すか、また流す割合をどうするかはすべてALBで制御します。 トラフィック制御の例を1つ紹介します。各オリジンに流すトラフィック量を制御できるので、下図では旧Webサーバ専用のオリジンには90%、新規Webサーバ専用のオリジンには10%を流す制御をしています。なお、旧Webサーバのオリジンを dc1 、 新規Webサーバのオリジンを dc2 としています。 ALBは加重ルーティング、パスルーティング、ヘッダー等による制御ができます。新規サービスをリリースする際には加重ルーティングとヘッダー制御等を用いて流れる通信量を制御し、カナリアリリースを実現します。 また、今までiRuleを用いて制御していたトラフィックをALBで制御することにしました。下図のように、新たにBot専用のオリジンを作成し、ALBでUserAgentを元にトラフィック制御しています。これにより、既存のiRuleに囚われない制御が可能となりました。下図では、Bot専用のオリジンは dc3 としています。 最終的には、Botからの通信は dc3 に流し、通常のトラフィックは dc1 と dc2 に設定した割合で流すことが可能になりました。 導入による効果 ALBの導入・活用により、直近の課題であったセッション情報管理のリプレイスにおいて、カナリアリリースを実現できました。現在は、少しずつ割合を変えながらリリースしている状態です。リリースによっては、エラーを検知して切り戻しが実際に発生し、導入した恩恵を受けられています。 また、ALBに今までトラフィック制御に利用していたiRuleの機能を移しました。ZOZOTOWNの歴史と共に肥大化したiRuleは運用コストが高く、場合によってはiRuleの設定の影響でZOZOTOWNにまったくアクセスできなくなる可能性もありました。そのようなシステムの制御をALBに移したことで以下の2つの効果を得ることができました。 AkamaiのStaging環境での事前確認が可能になった トラフィック制御の設定が容易になった 今まで負担となっていたiRuleの運用をなくし、安全かつ正確な運用ができるようになりました。 まとめ ALBを導入することで既存の構成をほぼ変えることなく、よりモダン、かつ安定感のある環境を整えることができました。 また、導入によって以下の効果を得ることができました。 リリース方式の改善 既存構成で運用負荷の高かったシステムからの脱却 リプレイスに柔軟に対応できるシステムの実現 今後はALBを用い、さらにリプレイスを加速させていきます。 最後に ZOZOのシステムは現在リプレイス真っ只中です。一緒にリプレイスへ取り組んでいただける方、興味がある方は以下のリンクから是非ご応募ください。 https://hrmos.co/pages/zozo/jobs/0000009 hrmos.co また、カジュアル面談も随時実施中です。「話を聞いてみたい」のような気軽な感じで大丈夫です。是非ご応募ください。 hrmos.co
アバター
はじめに こんにちは、ZOZO NEXTのApplied MLチームでMLエンジニアをしている柳です。機械学習を使ってビジネス上の課題解決をする仕事に取り組んでいます。今回は、BizDevメンバーのAutoML Tables活用をサポートする中で出会った課題やその解決方法について紹介します。 はじめに 概要 AutoML Tablesによるモデリング 基本的な使い方 配信施策における使い方 発生した課題 SQLの管理不足からバグが生じやすくなった課題 オフライン評価が未整備である課題 繰り返し作業が発生する課題 解決方法 SQL管理の厳格化 適切なオフライン評価の実装 Vertex Pipelinesによる自動化 AutoML Tablesのパイプラインコンポーネントに関するTips 特徴量の指定方法 バッチ推論と結果の取得 最後に 概要 ZOZOTOWNでは様々なプロモーション施策が日々打たれています。ZOZOTOWNをご利用の方は、メールやアプリ上でキャンペーンやクーポンの配信を受け取ることも多いのではないでしょうか。このような配信施策では、ターゲットを絞ることが重要です。無闇矢鱈に多数のユーザーに配信をしてしまうと配信コストがかかります。さらに、興味のないキャンペーンが大量に通知されるとユーザー体験も損なわれます。そのため、個々のユーザーの興味を抽出し、それに合わせた配信をするのが理想です。 弊社では、MLのビジネス活用を進めるBizDevメンバーを中心に、このような課題に取り組んでいます。そこでよく使われているのが、GCPの AutoML Tables です。以前から存在するサービスですが、Vertex AIの登場に伴ってその一機能としても提供されるようになりました。専門的なMLライブラリの扱い方を覚える必要がなく、ビジネス課題をMLを使って解決するのに集中できる便利なツールです。私たちのようなMLエンジニアは特徴量の作り方のディスカッションを時々するくらいで、基本的にはBizDevメンバーがモデリングから配信まで行っていました。 しかし、このようなAutoMLのビジネス活用が拡大していく中で、徐々に技術的負債が溜まっていることもわかってきました。それらは大別すると以下のように分類できます。 コードの管理やレビュー環境に関する課題 モデルの学習や評価方法に関する課題 特に後者はある程度MLを使った経験がないとなかなか気づきづらいようなものでした。本記事ではこれら課題の具体的な内容と、それを解決するための取り組みについて紹介します。 AutoML Tablesによるモデリング 本章では、AutoML Tablesの説明と、配信施策での使用例を紹介します。 基本的な使い方 AutoML Tablesの使い方は概ね以下の流れです。詳しくは 公式ドキュメント を参照してください。 BigQueryテーブルなどに、学習用の表形式データを用意する のデータをVertex AIのデータセットとしてインポートし、特徴量として利用するカラム、ラベルとして利用するカラムの選択、及び回帰や分類など課題の種類と最適化指標を指定し学習を開始する コンソールで精度や特徴量の重要度を確認し、モデルがうまくできていそうかをチェックする 用途に合わせて推論用データを作成し、推論する 配信施策における使い方 配信施策では「ユーザーがある対象に興味を持っているか」を予測するようなモデルを作ります。以下では、例として「ユーザーがカテゴリXに興味を持っているか」を予測するモデルの作り方を考えてみます。様々な方法が考えられますが、ここでは次のようにアプローチしてみましょう。 学習 二値分類を解く ある期間にカテゴリXの商品を購入したユーザーを正例とし、負例はカテゴリXの商品を購入していないユーザーから正例と同じ数だけサンプリングする 推論 AutoML Tablesの二値分類モデルでは、バッチ推論をすると各ユーザーに0から1の予測値が付与される この値の上位Kユーザーを最終的な推論結果とする 特徴量は各ユーザーの年齢などの属性情報や、ZOZOTOWNでの実際の行動履歴を用います。BigQueryを使って正例・負例ユーザーを抽出し、特徴量をjoinすればデータセットは完成です。 あとは、前述のようにAutoML Tablesを利用することで作業は完了します。弊社のBizDevメンバーは普段からBigQueryを使って分析しているので、データ抽出用のSQLを難なく書くことができます。そのため、BizDevメンバーだけでモデル作成から配信用のユーザー抽出まで行えます。 発生した課題 弊社では前述の通り、 BizDevメンバーがAutoML Tablesを活用してきました。しかし、利用の拡大に伴い、以下のような問題が見られるようになりました。 SQLの管理不足からバグが生じやすくなった課題 いくらAutoML Tablesがノーコードで学習・推論してくれると言っても、それに投入するデータを作るにはSQLを書く必要があります。施策が変われば抽出したいユーザーも変わり、予測に有効な特徴量も変わってきます。そのような場合、往々にして以前の施策で使っていたSQLを流用して新規施策用のデータを抽出することになります。上記の例で言えば、「カテゴリXに興味あるユーザーを当てるためのSQLを、カテゴリYを当てるためのものに変えよう」ということです。また、配信期間が変われば特徴量を計算する期間も変わってきます。このような際に、しばしばSQLをローカルで直接書き換え利用することが行われていました。修正や継ぎ足しが行われたSQLは可読性が低下し、バグが入りやすくなります。 実際に、学習したモデルの特徴量の重要度に違和感があり調べてみたところ、SQLにバグが混入していたということがありました。これは仕組みを作って防ぐべき問題です。 オフライン評価が未整備である課題 MLモデルの改善を正しい方向に進める上で、適切なオフライン評価を設定することは非常に重要です。上記の例で作成したいのは「予測上位K件に正例ユーザー(カテゴリX購入者)をできるだけ多く含めることができる」モデルです。そのため、本来であれば以下のようなPrecision@KやRecall@Kなどのメトリクスで評価をすべきです。 しかし、AutoML Tablesではこれらのメトリクスは自動で計算されません。その代わりに、二値分類のAUCなどが計算されコンソールに表示されます。通常の二値分類タスクであればこれで問題ありませんが、今回は負例をサンプリングしているため、サンプリング方法に敏感な指標となってしまいます。正例との識別が難しい負例をより多くサンプルするようにすれば、二値分類の精度は低くなります。逆に識別が簡単な負例を多くサンプルすれば二値分類の精度は上がります。私たちが本当に欲しいモデルは正例を精度良く抽出できるようなモデルであり、負例のサンプリング方法によってメトリクスが上下するのは好ましくありません。 モデルを正しく改善していくために、本来評価したいPrecision@KやRecall@Kなどのメトリクスが確認できるように環境を整理する必要がありました。 繰り返し作業が発生する課題 前述の通り、AutoMLで自動化できるのはあくまでも学習・推論作業のみであり、データの抽出は当然自分でやらなければなりません。そのため、BizDevメンバーが毎回決められた手順でSQLを逐次実行しており、繰り返し作業や計算の各ステップが終わるまでのソワソワして待つ時間が生じていました。 解決方法 本章では、上記課題を解決するために取り組んだ解決方法を紹介します。 SQL管理の厳格化 まずは、シンプルにデータ抽出用SQLのGit管理を厳格化することにしました。SQLごとに「正例抽出用」「特徴量の抽出用」など役割を明確にし、集計期間や集計対象カテゴリなど、パラメトライズできる部分をクエリパラメータにしました。そして、新たに特徴量や学習ターゲットを追加する際には、GitHub上でプルリクエストを作る運用方針にしています。 適切なオフライン評価の実装 適切なオフライン評価をするために、AutoMLの外部に評価機能を実装しました。例えば、上記の例では、カテゴリXの購入ユーザーを時系列に沿って学習用と評価用に分割します。この評価用に分けられたユーザーをground truthとして、モデルのprecision@Kやrecall@Kを計算します。こうすることで、負例サンプリングの方法に鈍感な評価ができるようになります。そして、これらのメトリクスの評価は後述のパイプラインに組み込み、コンソール上で確認できるようにしています。 Vertex Pipelinesによる自動化 繰り返し作業の自動化をするため、以下のワークフローを Vertex Pipelines 上に実装しました。なお、Vertex Pipelinesは 先日GA版になった機能 です。 データ抽出 AutoML Tablesによる学習 AutoML外での評価・バッチ推論 構築したワークフローは以下の通りです。 このワークフローにより、前述のメトリクスは以下のように可視化されます。 Vertex Pipelinesはパイプライン定義を記入したJSONファイルをアップロードすることで、GCPコンソールから実行できます。なお、このJSONファイルの管理・更新はMLエンジニアが担当します。BizDevメンバーにはJSONファイルを渡し、適宜パラメータを変更して施策に合ったモデリングをしてもらいます。これにより、ノーコードの環境を維持しつつ、BizDevメンバーの作業負荷の軽減を実現しました。 また、MLエンジニアがコード類を管理し、個々の現場でSQLを修正して利用することがなくなったため、バグが混入するリスクも減少しました。 AutoML Tablesのパイプラインコンポーネントに関するTips 最後にVertex PipelinesでAutoML Tablesを使う際のTipsを紹介します。なお、Vertex Pipelinesについては過去の記事でも紹介しているのでご参照ください。 techblog.zozo.com また、AutoML Tablesを使ったパイプラインについては、以下の公式ブログが参考になります。 cloud.google.com 特徴量の指定方法 学習コンポーネントに渡したテーブルの中から特定の特徴量のみ学習に使う方法を紹介します。上記の公式ブログから学習コンポーネントの部分を抜粋します。 from google_cloud_pipeline_components import aiplatform as gcc_aip @ kfp.dsl.pipeline (name= "automl-tab-beans-training-v2" , pipeline_root=PIPELINE_ROOT) def pipeline ( bq_source: str = "bq://aju-dev-demos.beans.beans1" , display_name: str = DISPLAY_NAME, project: str = PROJECT_ID, gcp_region: str = "us-central1" , api_endpoint: str = "us-central1-aiplatform.googleapis.com" , thresholds_dict_str: str = '{"auRoc": 0.95}' , ): dataset_create_op = gcc_aip.TabularDatasetCreateOp( project=project, display_name=display_name, bq_source=bq_source ) training_op = gcc_aip.AutoMLTabularTrainingJobRunOp( project=project, display_name=display_name, optimization_prediction_type= "classification" , optimization_objective= "minimize-log-loss" , budget_milli_node_hours= 1000 , column_transformations=[ { "numeric" : { "column_name" : "Area" }}, { "numeric" : { "column_name" : "Perimeter" }}, { "numeric" : { "column_name" : "MajorAxisLength" }}, ... other columns ... { "categorical" : { "column_name" : "Class" }}, ], dataset=dataset_create_op.outputs[ "dataset" ], target_column= "Class" , ) ... 学習コンポーネントは AutoMLTabularTrainingJobRunOp です。ここで使用する特徴量を指定する際に column_transformations という変数を設定しています。 しかし、 Pythonコンポーネントのドキュメント には、次のように書かれています。 Consider using column_specs as column_transformations will be deprecated eventually. つまり、現在は column_specs という変数の利用が推奨されています。 column_transformations は辞書のリストが入る仕様ですが、 column_specs では仕様が以下のように変更されています。 { "Area" : "numeric" , "Perimeter" : "numeric" ,...} バッチ推論と結果の取得 上記の AutoMLTabularTrainingJobRunOp で作成されたモデルを ModelBatchPredictOp に渡すことでバッチ推論が可能です。 batch_prediction_op = gcc_aip.ModelBatchPredictOp( project=<プロジェクト名>, location=<リージョン名>, job_display_name=<好きなディスプレイ名>, model=training_op.outputs[ "model" ], # AutoMLTabularTrainingJobRunOpの出力 bigquery_source_input_uri=<推論用BigQueryテーブル名>, bigquery_destination_output_uri=<推論結果の書き込み先>, instances_format= "bigquery" , predictions_format= "bigquery" , ) bigquery_source_input_uri は推論対象のBigQueryのテーブルです。詳しくは ドキュメント を参照してください。 bigquery_destination_output_uri には、 bq://<project> もしくは bq://<project>.<dataset> 形式で推論結果の出力先を指定します。テーブル名などはコンポーネントによって一意のものが自動で付与されます。 また、バッチ推論コンポーネントで作られた推論結果のテーブルは、コンポーネントによって一意の名前がつけられており、ユーザー側で指定することができません。このテーブル名を取得するには、バッチ推論リソースにアクセスする必要があります。例えば、以下のようなコンポーネントで直接 curl を使って取得します。 @ component ( base_image=base_image, output_component_file= None , ) def get_batch_predict_info_op_wrapper ( batch_predict_job: Input[Artifact], # バッチ推論の出力 data_path: OutputPath( str ), # テーブル名の書き込み先 ): import os import subprocess uri = batch_predict_job.uri url = uri.replace( "aiplatform://v1/" , "https://us-central1-aiplatform.googleapis.com/v1/" , ) os.makedirs(os.path.dirname(data_path), exist_ok= True ) cmd = 'curl -X GET -H "Content-Type: application/json" -H "Authorization: Bearer "$(gcloud auth application-default print-access-token) "{url}" > "{data_path}"' .format( url=url, data_path=data_path ) subprocess.call(cmd, shell= True ) 最後に 本記事ではZOZOTOWNにおけるMLのビジネス応用の一例と、それを改善するための取り組みを紹介しました。 ZOZO NEXTでは、機械学習を適切に使用して課題を解決できるMLエンジニアを募集しています。今回は配信施策について紹介しましたが、検索や推薦の領域でもML活用が進んでいます。 ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co hrmos.co hrmos.co
アバター
こんにちは、検索基盤部 検索基盤ブロックの渡です。私は検索基盤ブロックで、主にZOZOTOWNの検索周りのシステム開発に従事しています。 以前の記事 では、Elasticsearchのマッピング設定の最適化について取り上げました。そして、今回は日本語による形態素解析を実現するまでの手順をご紹介します。 techblog.zozo.com 目次 目次 はじめに Elasticsearchで全文検索を実現させる手順 全文検索のためのマッピング定義 Analyzerの構造 日本語対応のAnalyzer 日本語対応のためのプラグイン追加 kuromoji Analyzerを指定したマッピング定義の例 kuromojiプラグイン機能 カスタムしたAnalyzerのマッピング定義 Analyzerの動作確認 modeを選択した場合のマッピング定義の例 Analyzer適用の注意点 kuromoji以外の日本語形態素解析「Sudachi」 まとめ はじめに ZOZOTOWNの検索機能では、Elasticsearchを利用しています。現在では検索機能の全般でElasticsearchを利用していますが、リリース当初はキーワード検索を実現するために採用していました。そのため、全文検索を実現するためのマッピング定義やAnalyzerを理解する必要がありました。 Elasticsearchで全文検索を実現させる手順 Elasticsearchの環境準備 マッピングの定義 どのようにデータを格納するかを決める Analyzerの定義 どのように分割するか(検索でヒットさせるか)を決める データの投入 検索 本記事では、2. と 3. を取り扱います。 全文検索のためのマッピング定義 ドキュメント内の各フィールドのデータ構造やデータ型を記述した情報のことをマッピングと呼びます。 www.elastic.co 下記はマッピング定義の例です。 PUT /sample_index { " mappings ": { " properties ": { " age ": { " type ": " integer " } , " email ": { " type ": " keyword " } , " name ": { " type ": " text " } } } } また、文字列をフィールドに格納するためのデータ型には下記の2種類が存在します。全文検索では、文章から特定の文字列を検索することを指すため、前者のtext型のフィールドを使用します。 text型 Analyzerによる単語の分割が行われ、転置インデックスが形成される keyword型 Analyzerによる単語の分割が行われず、原形のまま転置インデックスが形成される Analyzerの構造 全文検索するために文章を単語の単位に分割する処理機能をAnalyzerと呼びます。 下記はマッピング定義の例です。 なお、Elasticsearchがデフォルトで提供するAnalyzerは 公式ドキュメント で参照可能です。 www.elastic.co PUT sample_index { " mappings ": { " properties ": { " goods_name ": { " type ": " text ", " analyzer ": " standard " } } } } そして、Analyzerは3つの処理ブロックから構成されています。 Character filters 1文字単位の変換処理 Tokenizer トークン(単語)に分割する処理 Token filters 各トークンに対する変換処理 上記の処理を用い、Analyzerは下記の流れで変換処理を行います。 Input Character Filters Tokenizer Token Filters Output また、Tokenizerは1つが必須であり、Character FiltersとToken Filtersは任意の数で構成できます。 www.elastic.co 例えば、Standard Analyzerは以下の構成です。 Character Filters なし Tokenizer Standard Tokenizer Token Filters Lower Case Token Filter Stop Token Filter 日本語対応のAnalyzer Elasticsearchがデフォルトで提供するAnalyzerは、日本語に対応していません。そのため、日本語を扱うAnalyzerを構成する必要があります。日本語の単語分割は英語と比較して複雑であるため、個別に用意しなければいけません。 英語の文は日本語とは異なり、予め単語と単語の区切りがほとんどの箇所で明確に示される。このため、単語分割の処理は日本語の場合ほど複雑である必要はなく、簡単なルールに基づく場合が多い。 (引用: 形態素解析 - Wikipedia ) 日本語対応のためのプラグイン追加 日本語を扱うAnalyzerを構成するために、以下のプラグインをインストールします。 ICU Analysis Plugin kuromoji Analysis Plugin kuromoji Analyzerを指定したマッピング定義の例 PUT sample_index { " mappings ": { " properties ": { " goods_name ": { " type ": " text ", " analyzer ": " kuromoji " } } } } kuromojiプラグイン機能 kuromoji Analyzerの詳細は 公式ドキュメント から確認できます。ここでは、Char Filter、Tokenizer、Token Filterを表にまとめます。 分類 プラグイン 機能 例 Character Filter kuromoji_iteration_mark 踊り字の正規化 時々 → 時時 Tokenizer kuromoji_tokenizer トークン化 関西国際空港 → 関西、関西国際空港、国際、空港 Token Filter kuromoji_baseform 原形化 飲み → 飲む Token Filter kuromoji_part_of_speech 不要な品詞の除去 寿司がおいしいね → "寿司""おいしい" Token Filter kuromoji_readingform 読み仮名付与 寿司 → "スシ"もしくは"sushi" Token Filter kuromoji_stemmer 長音の除去 サーバー → サーバ Token Filter ja_stop ストップワードの除去 これ欲しい → 欲しい Token Filter kuromoji_number 漢数字の半角数字化 一〇〇〇 → 1000 カスタムしたAnalyzerのマッピング定義 Token Filterは、主に kuromoji_analyzer に含まれるデフォルトのものを使用 ICU Normalization Character Filte を以下の変換のために使用 全角ASCII文字を、半角文字に変換 半角カタカナを、全角カタカナに変換 英字の大文字を、小文字に変換 PUT sample_index { " settings ": { " analysis ": { " analyzer ": { " my_ja_analyzer ": { " type ": " custom ", " char_filter ": [ " icu_normalizer " ] , " tokenizer ": " kuromoji_tokenizer ", " filter ": [ " kuromoji_baseform ", " kuromoji_part_of_speech ", " ja_stop ", " kuromoji_number ", " kuromoji_stemmer " ] } } } } , " mappings ": { " properties ": { " goods_name ": { " type ": " text ", " analyzer ": " my_ja_analyzer " } } } } Analyzerの動作確認 作成したAnalyzerで文章がどのように分割されるかを確認します。 GET sample_index/_analyze { " analyzer ": " my_ja_analyzer ", " text " : " ファッション通販サイト「ZOZOTOWN」、ファッションコーディネートアプリ「WEAR」などの各種サービスの企画・開発・運営や、「ZOZOSUIT 2」、「ZOZOMAT」、「ZOZOGLASS」などの計測テクノロジーの開発・活用をおこなっています。 " } Analyzerの結果は以下の通りです。日本語による形態素解析が行われていることを確認できます。 { " tokens " : [ { " token " : " ファッション ", " start_offset " : 0 , " end_offset " : 6 , " type " : " word ", " position " : 0 } , { " token " : " 通販 ", " start_offset " : 6 , " end_offset " : 8 , " type " : " word ", " position " : 1 } , { " token " : " サイト ", " start_offset " : 8 , " end_offset " : 11 , " type " : " word ", " position " : 2 } , { " token " : " zozotown ", " start_offset " : 12 , " end_offset " : 20 , " type " : " word ", " position " : 3 } , { " token " : " ファッション ", " start_offset " : 22 , " end_offset " : 28 , " type " : " word ", " position " : 4 } , { " token " : " ファッションコーディネートアプリ ", " start_offset " : 22 , " end_offset " : 38 , " type " : " word ", " position " : 4 , " positionLength " : 3 } , { " token " : " コーディネート ", " start_offset " : 28 , " end_offset " : 35 , " type " : " word ", " position " : 5 } , { " token " : " アプリ ", " start_offset " : 35 , " end_offset " : 38 , " type " : " word ", " position " : 6 } , { " token " : " wear ", " start_offset " : 39 , " end_offset " : 43 , " type " : " word ", " position " : 7 } , { " token " : " 各種 ", " start_offset " : 47 , " end_offset " : 49 , " type " : " word ", " position " : 10 } , { " token " : " サービス ", " start_offset " : 49 , " end_offset " : 53 , " type " : " word ", " position " : 11 } , { " token " : " 企画 ", " start_offset " : 54 , " end_offset " : 56 , " type " : " word ", " position " : 13 } , { " token " : " 開発 ", " start_offset " : 57 , " end_offset " : 59 , " type " : " word ", " position " : 14 } , { " token " : " 運営 ", " start_offset " : 60 , " end_offset " : 62 , " type " : " word ", " position " : 15 } , { " token " : " zozosuit ", " start_offset " : 65 , " end_offset " : 73 , " type " : " word ", " position " : 17 } , { " token " : " 2 ", " start_offset " : 74 , " end_offset " : 75 , " type " : " word ", " position " : 18 } , { " token " : " zozomat ", " start_offset " : 78 , " end_offset " : 85 , " type " : " word ", " position " : 19 } , { " token " : " zozoglass ", " start_offset " : 88 , " end_offset " : 97 , " type " : " word ", " position " : 20 } , { " token " : " 計測 ", " start_offset " : 101 , " end_offset " : 103 , " type " : " word ", " position " : 23 } , { " token " : " テクノロジ ", " start_offset " : 103 , " end_offset " : 109 , " type " : " word ", " position " : 24 } , { " token " : " 開発 ", " start_offset " : 110 , " end_offset " : 112 , " type " : " word ", " position " : 26 } , { " token " : " 活用 ", " start_offset " : 113 , " end_offset " : 115 , " type " : " word ", " position " : 27 } , { " token " : " おこなう ", " start_offset " : 116 , " end_offset " : 120 , " type " : " word ", " position " : 29 } ] } なお、「ファッションコーディネートアプリ」が、"ファッション"、"ファッションコーディネートアプリ"、"コーディネート"、"アプリ"の4つに重複して分割されているのは、 kuromoji_tokenizer の形態素解析のmodeがデフォルトで search になっているためです。 { " tokens " : [ { " token " : " ファッション ", " start_offset " : 0 , " end_offset " : 6 , " type " : " word ", " position " : 0 } , { " token " : " ファッションコーディネートアプリ ", " start_offset " : 0 , " end_offset " : 16 , " type " : " word ", " position " : 0 , " positionLength " : 3 } , { " token " : " コーディネート ", " start_offset " : 6 , " end_offset " : 13 , " type " : " word ", " position " : 1 } , { " token " : " アプリ ", " start_offset " : 13 , " end_offset " : 16 , " type " : " word ", " position " : 2 } ] } search 以外にも、形態素解析のmodeは以下の3つから選択が可能です。 mode 説明 例 normal 通常のセグメンテーションで単語分割しない "ファッションコーディネートアプリ" search 検索を対象としたセグメンテーションで単語分割する "ファッション"、"ファッションコーディネートアプリ"、"コーディネート"、"アプリ" extended 拡張モードは不明な単語を1文字に分割する "ファッション"、"ファッションコーディネートアプリ"、"コーディネート"、"ア"、"プ"、"リ" modeを選択した場合のマッピング定義の例 参考までにmodeにextendedを選択する場合のマッピング定義例を紹介します。 注意点は、extendedによって1文字に分割したトークンがある場合、"kuromoji_part_of_speech token filter" によって、不要な品詞の除去対象になる点です。 なお、今回は確認が目的のため、"kuromoji_part_of_speech token filter" は指定していません。 PUT sample_index { " settings ": { " analysis ": { " tokenizer ": { " my_custom_tokenizer ": { " mode ": " extended ", " type ": " kuromoji_tokenizer ", " discard_punctuation ": " true " } } , " analyzer ": { " my_ja_analyzer ": { " type ": " custom ", " char_filter ": [ " icu_normalizer " ] , " tokenizer ": " my_custom_tokenizer ", " filter ": [ " kuromoji_baseform ", " ja_stop ", " kuromoji_number ", " kuromoji_stemmer " ] } } } } , " mappings ": { " properties ": { " goods_name ": { " type ": " text ", " analyzer ": " my_ja_analyzer " } } } } 以下の文章を用いて、作成したextendedモードのAnalyzerの動作確認をします。 GET sample_index/_analyze { " analyzer ": " my_ja_analyzer ", " text " : " ファッションコーディネートアプリ " } 以下の結果から、extendedモードによる形態素解析が行われていることが確認できます。 { " tokens " : [ { " token " : " ファッション ", " start_offset " : 0 , " end_offset " : 6 , " type " : " word ", " position " : 0 } , { " token " : " ファッションコーディネートアプリ ", " start_offset " : 0 , " end_offset " : 16 , " type " : " word ", " position " : 0 , " positionLength " : 5 } , { " token " : " コーディネート ", " start_offset " : 6 , " end_offset " : 13 , " type " : " word ", " position " : 1 } , { " token " : " ア ", " start_offset " : 13 , " end_offset " : 14 , " type " : " word ", " position " : 2 } , { " token " : " プ ", " start_offset " : 14 , " end_offset " : 15 , " type " : " word ", " position " : 3 } , { " token " : " リ ", " start_offset " : 15 , " end_offset " : 16 , " type " : " word ", " position " : 4 } ] } Analyzer適用の注意点 実際に辞書 1 を更新していた際に、内容が反映されていないという問題が発生しました。正確には「辞書の内容が反映されていない」のではなく、以下の理由(辞書更新 = データも更新)が原因でした。 転置インデックスを利用している検索エンジンでは、単語の区切りが変更されるような辞書の更新があった場合、最低でも影響があるドキュメントについては再登録が必要となるわけです。 これが大原則(辞書更新=データも更新)となります。 基本的には辞書の更新を行った場合は、ドキュメントの再インデックス(再登録)が必要となります。 (引用: 辞書の更新についての注意点@johtaniの日記 3rd ) 上記の理由に該当していました。辞書更新後はドキュメントの再インデックスを行う必要があり、負荷の高い作業だったのです。現在は、定期的にインデックスを洗い替えしているため、辞書更新の運用負荷は軽減されております。 kuromoji以外の日本語形態素解析「Sudachi」 Elasticsearchで利用可能な日本語の形態素解析には、kuromoji以外に、 Sudachi があり、チーム内でも関心が高まっています。 Sudachiは、2017年8月に日本語形態素解析器として ワークスアプリケーションズ 徳島人工知能NLP研究所 からOSS公開されました。 特長として下記の点が挙げられます。 複数の分割単位の併用 必要に応じて切り替え 形態素解析と固有表現抽出の融合 多数の収録語彙 UniDicとNEologdをベースに調整 機能のプラグイン化 文字正規化や未知語処理に機能追加が可能 同義語辞書との連携 具体的な内容は本記事では省略しますが、ElasticsearchとSudachiの連携に興味のある方は以下の記事が参考になるのでご参照ください。 www.m3tech.blog まとめ 本記事では、日本語による形態素解析を実現するために、データの格納方法(マッピング定義)や、データの分割方法(Analyzer)の一部を紹介しました。 今回紹介した形態素解析による日本語の検索以外にも、n-gramを併用して検索漏れを少なくさせるアナライズ方法もあります。柔軟でやれることも豊富なため、ユースケースに応じた選択をしていく必要があります。 ZOZOでは、検索機能を開発・改善していきたいエンジニアを全国から募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co kuromojiのユーザー辞書や、 Synonym Graph Token FilterのSynonym辞書 を指す ↩
アバター
こんにちは、アーキテクト部の廣瀬です。 弊社ではサービスの一部にSQL Serverを使用しています。以前の記事でSQL Serverのスナップショット分離レベルを導入した事例を紹介しました。 techblog.zozo.com この段階で、スナップショット分離レベルの導入によってデータ基盤連携の課題は解決できていました。しかし、今度はスナップショット分離レベル特有の問題が発生しました。本記事では、そこで発生した問題と、どのように調査・対応していったのかを紹介します。 発生した問題 あるDBに対する全クエリの内、一部のクエリでタイムアウト多発が約20分間ほど継続した後、自然解消しました。スナップショット分離レベルの導入から約2週間経過していたため、最初は障害との関連性は低いと考えていました。 しかし、今まで経験したことがない種類のエラーだったので、スナップショット分離レベルを導入したことに留意しつつ、調査を進めていくことにしました。なお、調査は以前紹介した障害調査フローに従って実施したので、併せてご覧ください。 techblog.zozo.com 調査の流れ パフォーマンスモニタの主要メトリクス 最初に、パフォーマンスモニタの主要なメトリクスを確認していきました。特に着目したメトリックを紹介します。 上図、 Batch Resp Statistics の Elapsed Time:Total(ms) では、例えば「応答時間が0ミリ秒以上1ミリ秒未満の全クエリの実行時間を足し合わせると5秒になる」といったことが分かります。各値を積み上げた面グラフにすることで、クエリの総実行時間の推移を確認できますが、障害発生中は急激に増加しています。 次に、インスタンス全体のクエリパフォーマンスに影響があったかを確認するために、ワーカースレッド数の推移も確認しました。 上図はワーカースレッド数のグラフであり、ワーカースレッド数が上昇し、ワーカースレッドの確保待ちは発生しているものの、上限(赤色の点線)には達していないことが分かります。従って、ワーカースレッド枯渇によりインスタンス全体が著しくスローダウンしているわけではありませんでした。 上図はCPU使用率のグラフです。障害発生中はCPU使用率が100%にほぼ張り付いている状況でした。また、CPUキューの発生も確認できました。 一部のクエリが突然タイムアウトしてCPU負荷が急激に上昇する場合、典型的な原因は「クエリプランの後退」です。「クエリプランの後退」とは、通常時は高速なプランが採用されているのに、リコンパイル時のパラメータ等が原因で低速なプランが生成されることを指します。 この事象はSQL Server 2017以降では「自動プラン選択修正」という機能を有効にしておくと自動復旧されます。しかし、問題が発生したDBは2016以前のバージョンだったため、代わりに 自動リコンパイルによる簡易的な自動チューニング機能 を実装していました。これは、「クエリプランの後退」が疑われる際に該当クエリを自動でリコンパイルするというものです。 リコンパイルが多発していたストアドプロシージャ 今回の障害発生時にリコンパイルのログが出ていないか確認しました。すると、障害発生と同タイミングでリコンパイルの発生が確認できました。 しかし、リコンパイルが走ってもクエリのスローダウンは解消されず、エラーが多発し続けていました。そして、リコンパイルされていたクエリは特定のストアドプロシージャだけでした。このストアドプロシージャがどのステートメントでスローダウンしているのかを 収集しておいたログ を使って調査すると、以下のことが分かりました。 カーソルのオープン処理で非常に時間がかかっている 遅いステートメントの last_wait_type は SOS_SCHEDULER_YIELD このカーソルは以下のようなシンプルなクエリで宣言されていました。 DECLARE my_cursor CURSOR FAST_FORWARD FOR SELECT col1 ,col2 ,col3 ,... ,colN FROM tableA JOIN tableB ON tableA.col1 = tableB.col2 JOIN tableC ON tableB.col2 = tableC.col3 ... JOIN tableN ON ... WHERE col4 = @col4 ORDER BY col5 何度もリコンパイルはされているので、 非典型パラメータ がコンパイル時に使用されたことで「クエリプランの後退」が発生したわけでは無いと判断しました。 他に考えられる原因として「統計情報が何度リコンパイルしてもスロークエリになってしまう状態であった」可能性を疑いました。そのため、関連テーブルの障害前後の統計情報の更新日時をログで確認しましたが、明確な関連性は見られませんでした。 実行プランはロギングの対象外にしているため後追いできませんが、タイムアウトしたクエリが通常時にどのようなプランで実行されているのかを確認しました。 その結果、1点気になる箇所がありました。基本的にはIndex Seekでデータを読み取り、Nested Loopsで結合するという操作を繰り返すだけでしたが、1か所だけIndex Scanになっていました。なお、Index Scanになっていたテーブルをここでは「テーブルA」とします。 テーブルAの調査 テーブルAを詳しく調査したところ、以下のような特殊なデータ更新が毎日1回行われていました。 テーブルAと同じ構造のテーブルA'を全件DELETE テーブルA'に日次のデータをINSERT テーブルAとテーブルA'をリネームすることで入れ替え 障害発生中のテーブルAのインデックス容量の推移を確認してみたところ、不思議な事象が起きていました。 上図は、1分間ごとの容量推移です。1分ごとに少しずつ容量が少なくなっており、最終的に1.3GBから約1MBまでサイズが減少しています。 しかし、日次のデータ洗い替え処理は05分台には完了しておりこの日は0レコード(row_count=0)でした。レコード数が0なのに容量が1.3GBで、かつ徐々にサイズが減少していく不思議な事象です。理由は不明でしたが、障害発生時もテーブルAへのアクセスがIndex Scanなら最大約1.3GBのデータ読み取りが発生するため、スロークエリ化も納得がいきます。 Skipped Ghosted Records/sec この事象を説明できるメトリクスが無いか再度パフォーマンスモニタのデータを調査したところ、「Skipped Ghosted Records/sec」というメトリックを見つけました。 上図のように、障害発生中に顕著な上昇を示していたCPU負荷と同じタイミングで数値が上昇しています。 ドキュメント によると、「スキャン中にスキップされた1秒あたりの非実体レコードの数」という説明が書かれていました。そして、 他のドキュメント には、ゴーストレコードは以下の内容で説明されていました。 インデックス ページのリーフ レベルから削除されたレコードは、物理的にはページから削除されません。代わりに、レコードに "削除対象" (つまり、ゴースト) としてマークされます。 つまり、行はページで保持されますが、行ヘッダーのビットが変更され、行が実際にはゴーストであることが示されます。 これは、削除操作中のパフォーマンスを最適化するためのものです。 ゴーストは行レベルのロックに必要ですが、古いバージョンの行を維持する必要があるスナップショットの分離にも必要です。 (引用: ゴースト クリーンアップ プロセスのガイド - SQL Server | Microsoft Docs ) テーブルAの日次データ入れ替え処理で、テーブルA'を全件DELETEします。このとき大量のゴーストレコードが発生しますが、クリーンアップタスクによるゴーストレコードの削除に何故か時間がかかっているようでした。そのため、Index Scan時のデータ読み取りサイズが非常に大きくなっています。 日次データの入れ替え処理は以前から運用していたので、DBに加えた他の変更が挙動の変化をもたらしたのではと考えました。そのため、直近で設定を追加したスナップショット分離レベル有効化の影響を疑い、さらに調査することにしました。 スナップショット分離レベルとゴーストレコードの関連性を明らかにする実験 スナップショット分離レベルを有効化した状態と無効化した状態とで、「Skipped Ghosted Records/sec」の発生状況を比較しました。 比較手順を順に説明します。手順は ドキュメント の以下の記述を考慮して作成しています。 ゴーストは行レベルのロックに必要ですが、古いバージョンの行を維持する必要があるスナップショットの分離にも必要です。 (引用: ゴースト クリーンアップ プロセスのガイド - SQL Server | Microsoft Docs ) スナップショット分離レベルが有効な場合 1. スナップショット分離の有効化 ALTER DATABASE sample_db SET ALLOW_SNAPSHOT_ISOLATION ON 2. テストテーブルの作成とデータのINSERT ランダムな整数値を100レコード分INSERTします。 DROP TABLE IF EXISTS sample_table CREATE TABLE sample_table (pk INT IDENTITY( 1 , 1 ) PRIMARY KEY CLUSTERED, c1 INT, c2 INT) GO INSERT INTO sample_table (c1, c2) VALUES ( CAST (RAND() * 100 AS INT), CAST (RAND() * 100 AS INT)) GO 100 3. 別のテストテーブルの作成とデータのINSERT トランザクションを開いたまま1件だけINSERTします。 CREATE TABLE sample_table_2 (pk INT IDENTITY( 1 , 1 ) PRIMARY KEY CLUSTERED, c1 INT, c2 INT) BEGIN TRAN INSERT INTO sample_table_2 (c1, c2) VALUES ( CAST (RAND() * 100 AS INT), CAST (RAND() * 100 AS INT)) 4. 別のクエリウィンドウで全レコードの50%をDELETE DELETE FROM sample_table WHERE (pk% 2 ) = 0 5. さらに別のクエリウィンドウで、以下のクエリ(フルスキャン)を連続実行 SELECT COUNT (*) FROM sample_table WITH (NOLOCK) 100件のデータをINSERTし、50件のデータをDELETEしたため、実行プラン中の読み取り行数は50行となります。ただし、実際には「Skipped Ghosted Records/sec」が発生するので、100行分のデータ読み取りが発生し、削除済みの50行がスキップされています。 6. 「Skipped Ghosted Records/sec」の変化を確認 上図のように、「Skipped Ghosted Records/sec」が発生し続けていることが確認できます。そして、手順 3. で開いたトランザクションをコミットすると、数秒後に「Skipped Ghosted Records/sec」の値が0になり、クリーンアップタスクによるゴーストレコードの削除を確認できます。 スナップショット分離レベルが無効な場合 1. スナップショット分離の無効化 ALTER DATABASE sample_db SET ALLOW_SNAPSHOT_ISOLATION OFF 手順 2. から 5. は「スナップショット分離レベルが有効な場合」の手順と同じクエリを実行します。 6. 「Skipped Ghosted Records/sec」の変化を確認 上図のように、「Skipped Ghosted Records/sec」の値が一瞬上昇しますが、すぐに元に戻ることが確認できます。つまり、クリーンアップタスクによってゴーストレコードが即座に削除されたということです。 両者の挙動の考察 スナップショット分離レベルを有効化すると、各レコードが必ずバージョン管理されるようになります。 この環境でゴーストレコードが削除されるための条件として、sample_tableへのDELETEが実行されたタイミングより前にOPENされたトランザクションがCOMMITされる必要があるようです。この条件は、トランザクション内でアクセスしているテーブルがsample_tableでなくても当てはまります。 スナップショット分離レベルでのSELECTでも同様の挙動になる 手順 3. を以下の2パターンに置き換えて実施しても、挙動が異なります。 ゴーストレコードがクリーンアップされるためには、DELETE処理よりも前に実行された「スナップショット分離レベルでのSELECTクエリ」も完了する必要があります。 --パターン1 SET NOCOUNT ON CREATE TABLE sample_table_2 (pk INT IDENTITY( 1 , 1 ) PRIMARY KEY CLUSTERED, c1 INT, c2 INT) GO INSERT INTO sample_table_2 (c1, c2) VALUES ( CAST (RAND() * 100 AS INT), CAST (RAND() * 100 AS INT)) GO 100000 --長時間実行させるためのcross join SELECT COUNT_BIG(*) FROM sample_table_2 a CROSS JOIN sample_table_2 b CROSS JOIN sys.all_objects CROSS JOIN sys.all_columns --パターン2 SET NOCOUNT ON CREATE TABLE sample_table_2 (pk INT IDENTITY( 1 , 1 ) PRIMARY KEY CLUSTERED, c1 int, c2 int) GO INSERT INTO sample_table_2 (c1, c2) VALUES ( CAST (RAND() * 100 AS INT), CAST (RAND() * 100 AS INT)) GO 100000 --スナップショット分離レベルで実行 SET TRANSACTION ISOLATION LEVEL SNAPSHOT --長時間実行させるためのcross join SELECT COUNT_BIG(*) FROM sample_table_2 a CROSS JOIN sample_table_2 b CROSS JOIN sys.all_objects CROSS JOIN sys.all_columns TRUNCATEだと再現しない 手順 3. を以下の2パターンに置き換えると、挙動の差異は生まれなくなりました。どちらも「Skipped Ghosted Records/sec」が発生しません。 --パターン1 SET NOCOUNT ON CREATE TABLE sample_table_2 (pk INT IDENTITY( 1 , 1 ) PRIMARY KEY CLUSTERED, c1 INT, c2 INT) GO INSERT INTO sample_table_2 (c1, c2) VALUES ( CAST (RAND() * 100 AS INT), CAST (RAND() * 100 AS INT)) GO 100000 TRUNCATE TABLE sample_table_2 --パターン2 SET NOCOUNT ON CREATE TABLE sample_table_2 (pk INT IDENTITY( 1 , 1 ) PRIMARY KEY CLUSTERED, c1 INT, c2 INT) GO INSERT INTO sample_table_2 (c1, c2) VALUES ( CAST (RAND() * 100 AS INT), CAST (RAND() * 100 AS INT)) GO 100000 --スナップショット分離レベルで実行 SET TRANSACTION ISOLATION LEVEL SNAPSHOT TRUNCATE TABLE sample_table_2 調査結果のまとめ 前述の調査結果をまとめると、障害の原因は以下のように推測できます。 テーブルAの日次データの入れ替え処理で、テーブルA'が全件DELETEされる テーブルA'のDELETEより前に実行されていた更新処理またはスナップショット分離レベルでのSELECTによってゴーストレコードが削除されにくい状況になる テーブルA'をリネームしたテーブルAは、統計情報としては0レコードなのでIndex Scanするプランになるが、フルスキャンの際に「Skipped Ghosted Records/sec」が多発する データ読み取り量が多いため、普段より低速となりタイムアウト多発に繋がった そのため、以下の3つを満たす環境では注意が必要です。 様々なトランザクションが同時に実行されており、タイミングもバラバラである スナップショット分離レベルが有効になっている 大量のレコードを全件DELETEする処理がある 対応策の実施 実験結果から、テーブルAの日次データの入れ替え処理におけるテーブルA'の全件DELETE処理をTRUNCATEに変更しました。この変更により同様の障害が発生することは無くなりました。 まとめ 本記事では、スナップショット分離レベルを有効にした環境で発生した障害と、その原因調査から対応策の実施までの流れを紹介しました。同様の事象で困っている方の参考になれば幸いです。 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは、ZOZO CTOブロックの光野( @kotatsu360 )です。 ZOZOでは、10/28に After DroidKaigi 2021 を開催しました。 zozotech-inc.connpass.com 10月19日〜21日に開催されたDroidKaigi 2021の振り返りオンラインイベントを、DroidKaigi 2021に協賛している株式会社ZOZO、ヤフー株式会社、LINE株式会社の3社合同で開催いたしました。 登壇内容まとめ ZOZO、ヤフー、LINEよりそれぞれ1名ずつ、合計3名がLTで登壇し、その後パネルディスカッションも実施されました。 ZOZOTOWNアプリへのIn-app updatesの導入とその運用について (ZOZO 山田 祐介) 巨大なプロダクトにおける技術負債と戦った成功と失敗の軌跡(途中経過) (ヤフー 木内 啓輔) Glideをもっと深くまでカスタマイズしてもっと便利に (LINE 玉木 英嗣) パネルディスカッション (モデレーター兼パネリスト:ZOZO 堀江 亮介、パネリスト:ヤフー 森 洋之 / LINE 玉木 英嗣) 最後に ZOZOでは、プロダクト開発以外にも今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは、技術本部 データシステム部 MLOpsブロックの平田( @TrsNium )です。約2年半ぶりの執筆となる今回の記事では、MLOps向け基盤を「Kubeflow Pipelines」から「Vertex Pieplines」へ移行して運用コストを削減した取り組みを紹介します。 目次 目次 はじめに Vertex Pipelinesとは Vertex Pipelinesへの移行 Vertex Pipelinesへ移行するワークフロー 1. ワークフローのKubeflow Pipelines SDK V2への移行 コンパイラのデータ型の制約が厳しくなった ContainerOp APIが非推奨になった Kubeflow PipelinesのPlaceholderを使用できなくなった 2. スケジュール実行されているワークフローへ前回実行分が終わるまでの待機処理を追加 3. Vertex Pipelinesの監視 今後の展望 各プロジェクトで使える便利共通コンポーネント集の作成 Vertex Pipelines用のテンプレートリポジトリの作成 まとめ 参考 はじめに 弊社ではML(Machine Learning)のモデル生成や特徴量生成にGKE(Google Kubernetes Engine)上でセルフホストしたKubeflow Pipelinesを使用していました。しかし、構築・運用コストが大きすぎるという課題感がありました。具体的にはKubeflowの依存する Istio や Kubernetes Applications のバージョンが古く、Kubernetesクラスタのバージョンアップデートをできなかったり、Kubeflowの内部ステートを保持しているMySQLが実際のステートと一致しない状況が発生していました。 詳しくは、中山( @Civitaspo )が過去の記事「 KubeflowによるMLOps基盤構築から得られた知見と課題 」で、構築や運用に関する課題感を紹介しているので、併せてご覧ください。 techblog.zozo.com このような運用課題へアプローチしていたところ、 Google I/O 2021 で Vertex AI の発表がありました。その後、Vertex AIのコンポーネントの1つである Vertex Pipelines を調査し、Kubeflow Pipelinesの恩恵を享受しつつ運用コストを大幅に削減できる確信が得られたため、Kubeflow PipelinesからVertex Pipelinesへの移行を開始しました。 Vertex Pipelinesとは Vertex Pipelinesは、GCPが提供しているKubeflow Pipelinesのフルマネージドサービスです。似たサービスに Cloud AI Platform Pipelines がありますが、明確に違いがあります。 Cloud AI Platform PipelinesではKubeflow PipelinesをGKEやCloud SQLをプロビジョニングして構築するのに対し、Vertex Pipelinesでは構築が不要です 1 。これにより、GKEやCloud SQLを管理する必要がなくなります。また、ワークフローが動いてない間の待機時間はCloud AI Platform PipelinesではGKEやCloud SQLの料金が必要なのに対し、Vertex Pipelinesではそれらの料金が発生しません。 つまり、構築や運用コストの面でKubeflow PipelinesやCloud AI Platform Pipelinesと比べ、Vertex Pipelinesには大きなアドバンテージがあります。 また、2つ目の違いは、Kubeflow PipelinesのSDK( kfp )のバージョンが異なる点です。Cloud AI Platform Pipelinesや、私たちがこれまで利用していたKubeflow PipelinesではSDKのバージョンがV1だったのに対し、Vertex PipelinesではV2です。なお、Kubeflow Pipelines 1.6以上のバージョンであればSDK V1はSDK V2と互換性がありますが、それ以外はありません。 Vertex Pipelinesへの移行 本章では、Kubeflow PipelinesからVertex Pipelinesへの移行の流れを説明します。 移行前に運用していたKubeflow Pipelinesのバージョンが1.2であり、SDK V2との互換性がないため、SDK V2でワークフローを記述し直す必要がありました。また、Kubeflow Pipelinesは、AWS(Amazon Web Services)やGCP、オンプレミス等で動作するようにKubernetesの様々な機能を駆使して設計されています。 一方、Vertex Pipelinesでは、それらの機能をGCPのサービスに置き換えているため、ワークフロー実行時の挙動が異なることがあります。提供されて間もないサービスなこともあり、Cloud Monitoringで取得可能なメトリクスが多くなく、ワークフローを外部から監視できる仕組みがありません。 これらの課題に対し、移行時にどのように解決していったのか、説明します。 Vertex Pipelinesへ移行するワークフロー Vertex Pipelinesへ移行するワークフローは、 WEAR ユーザーのコーディネート画像からアイテム特徴量を抽出し、Firestoreへそれを保存するような処理を行っています。対象のコーディネート画像が3000万件以上と膨大にあるため、日次の差分で処理をしています。 下図がワークフローの全体像です。 このワークフローでは、日次の差分データを取得するためにデータ基盤チームが管理するBigQueryからコーディネート情報を全件取得し、前日の全件取得との差分から新規コーディネート情報を一覧化しています。このコーディネート情報には、ユーザーの情報とコーディネート画像のURLが含まれています。 そして、コーディネート画像はAmazon S3に保存されていますが、データ基盤にはCDN経由のURLを格納しているため、S3へ直接取得するためのパス情報がありません。また、S3から直接画像を取得する料金と、CDN経由で画像をダウンロードする料金にさほど差がないため、CDN経由で画像を取得するようにしています。ただし、CDNに大量のリクエストを送ることになるので、DDoSと誤判定されないように固定の外部アドレスを使用しアクセスします。 上記の要件を満たすようにVertex Pipelinesへ移行した結果、ワークフローは以下の構成になりました。 なお、移行にあたり取り組んだ主な内容は以下の通りです。 ワークフローのKubeflow Pipelines SDK V2への移行 スケジュール実行されているワークフローへ前回実行分が終わるまでの待機処理を追加 Vertex Pipelinesの監視 各取り組みの詳細を紹介します。 1. ワークフローのKubeflow Pipelines SDK V2への移行 Kubeflow Pipelines SDK V1からSDK V2への移行に際し、影響のある変更点として以下の点が挙げられます。 コンパイラのデータ型の制約が厳しくなった ContainerOp APIが非推奨になった Kubeflow PipelinesのPlaceholderを使用できなくなった コンパイラのデータ型の制約が厳しくなった Kubeflow Pipelinesでは、コンテナ化されたコマンドラインプログラムをコンポーネントとして記述できます。そして、そのコンポーネントはyamlで定義する方法の他に、Pythonで処理を定義しコンポーネントにできます。しかし、SDK V2では、Pythonで記述するコンポーネントの入出力に必ずデータ型を注釈する必要があるよう、仕様が変更されました。そこでサポートされる基本的なデータ型は str , int , float , bool , dict , list です。 他にもGCP関連のコンポーネントで使用される型や、大量のデータを アーティファクト としてやりとりするための型が用意されています。今回移行するパイプラインでは、中間データはBigQueryに保存しておりアーティファクトは使用していないため、基本的なデータ型とGCP関連の型に関する修正を行いました。 from typing import NamedTuple # SDK V2では動作しない # NamedTupleに型を指定しても動かない # ref. https://github.com/kubeflow/pipelines/issues/5912#issuecomment-872112664 @ component def example (a: float , b: float ) -> NamedTuple( 'output' , [ ( 'sum' , 'product' ), ]): sum_ = a + b product_value = a * b from collections import namedtuple output = namedtuple( 'output' , [ 'sum' , 'product' ]) return output(sum_value, product_value) # SDK V2で動作する @ component def example (a: float , b: float ) -> typing.Dict: sum_value = a + b product_value = a * b return dict [ 'sum' : sum_value, 'product' : product_value] 基本的なデータ型は、コンパイラに型を正しく伝えるために全てのPython関数に対して型アノテーションをつけるように変更しました。また、GCP関連の型に関しては、以前 str 型で値を受け渡しできていたものができなくなり、専用の GCPProjectID 型等を使用する必要があります。 しかし、 GCPProjectID 型等でデータの受け渡しをしてもコンパイラから型が間違っているとエラーが起きる状態になっています。この問題に関しては、メンテナーが改修したりドキュメントを整備しているようなので対応を待っている状態です 2 。なお、現状の回避策として、 GCPProjectID 型等で定義されている型を String 等にコンポーネントのyamlを書き換え運用をしています。 また、SDK V2移行に伴い、ExitHandler APIが正しく動作しなくなりました。 ExitHandlerは、ExitHandler内に記述したタスクが終了したら終了ハンドラーを呼ぶオペレーターです 3 。これは、ExitHandlerを使用した際にWITH句内に記述しているタスクへ正しくパラメータが伝わっていないことが原因でした。このコンパイラ起因の問題は、 Pull Request で修正を加え、既にmergeされています。 ContainerOp APIが非推奨になった SDK V2ではContainerOp APIが非推奨になり、代わりにコンポーネントを使用する必要があります。 ただし、Vertex PipelinesはVPCネイティブではないため、タスク毎に動的に外部アドレスが割り当てられます。これは、CDNへアクセスする際に固定の外部アドレスでアクセスしなければならない要件を満たすことができません。今回は、この問題を回避するためにGKE上でPodとしてタスクを実行するコンポーネントを作成しました。 下図がそのコンポーネントのイメージです。 上図のように、Vertex Pipelinesのワーカー内でGKEとの認証を通しPodを作成します。そして、Podが作成されたらPodが実行を正常または異常終了するまで待っています。 静的な外部アドレスをCloud NATにアタッチしたネットワーク環境下でGKEを構築することにより、外部へアクセスする際の外部アドレスを固定化できます。そして、そのGKEのPod上でタスクを実行することにより固定の外部アドレスでCDNへアクセスすることが可能となります。今回は既にセットアップされたGKEがあったためこの方法をとりましたが、Cloud RunのVPCコネクタを使用することで外部アドレスを固定しアクセスできます。 Kubeflow PipelinesのPlaceholderを使用できなくなった Vertex PipelinesではKubeflow Pipelinesで使用できていたPlaceholderが使用不可能になりました。例えば、Placeholderには次のようなものがあります。 {{workflow.uid}}, {{workflow.name}}, {{workflow.status}}, {{workflow.creationTimestamp.Y}}, {{workflow.creationTimestamp.m}}, {{workflow.creationTimestamp.d}} これらはワークフローの名前、終了ステータス、実行時間を取得するものです。しかし、このPlaceholderが使用できなくなったため、自前で代わりになるものを実装したり運用でカバーする必要が出てきました。 例えば、ワークフローの終了ステータスを取得するPlaceholderは、exit_handler内でSlackへ通知をする処理をしていました。しかし、ステータスの取得が不可能になったので、後述するCloud Scheduler + Cloud Functionsで代替機能を作りました。また、実行日時の取得もPythonのdatetimeモジュール等を使用して置き換えています。 2. スケジュール実行されているワークフローへ前回実行分が終わるまでの待機処理を追加 Kubeflow Pipelinesのスケジュールドワークフロー(Recurring Run)には、前回実行分が終わっていない場合に、後続のワークフローを待機させる機能がありました。 しかし、Vertex Pipelinesのスケジューリング機能はCloud Scheduler + Cloud Functionで構成されており、前回実行分を考慮せずに後続のワークフローをキックするようになっています。そこで、ワークフローのタスク内部から前回実行分のワークフローが終了しているかを確認し、終了していなければsleepして待つ実装をし、同等の機能を担保します。 def wait_previous_execution (pipeline_name: str , project: str , region: str ): from google.cloud.aiplatform_v1.services.pipeline_service import PipelineServiceClient from google.api_core.client_options import ClientOptions from google.cloud.aiplatform_v1.types.pipeline_service import ListPipelineJobsRequest from datetime import datetime import time import pytz CURRENT_TIME = pytz.UTC.localize(datetime.utcnow()) option = ClientOptions(api_endpoint=f "{region}-aiplatform.googleapis.com" ) client = PipelineServiceClient(client_options=option) REQUEST = ListPipelineJobsRequest(parent=f "projects/{project}/locations/{region}" , filter = 'state="PIPELINE_STATE_RUNNING"' ) def _get_running_pipelines (): result = client.list_pipeline_jobs(REQUEST) pipelines = [pipeline for pipeline in result if pipeline.pipeline_spec[ 'pipelineInfo' ][ 'name' ]==pipeline_name] sorted_pipelines = sorted ( pipelines, key= lambda pipeline: pipeline.create_time, reverse= True ) # Ignore pipelines created after this one. filtered_pipelines = [ pipeline for pipeline in sorted_pipelines if CURRENT_TIME > pipeline.create_time ] return filtered_pipelines running_pipelines = _get_running_pipelines() # Wait for the other pipelines to finish # The pipeline executing this function is also counted, so the condition is greater than 1 while len (running_pipelines) > 1 : time.sleep( 120 ) running_pipelines = _get_running_pipelines() return None 3. Vertex Pipelinesの監視 私たちのチームでは普段からサービスの監視等にはCloud Monitoringを使用しています。しかし、Cloud Monitoringで利用できるVertex Pipelinesのメトリクスに有用なものが少ないため、監視の仕組みを内製しています。監視はCloud Scheduler + Cloud Functionsで行っており、Cloud Schedulerから定期的にCloud Functionsを叩き、アラートの閾値に達していないかの確認しています。以下が監視の仕組みのイメージです。 Cloud Functions内では以下のようなコードを使用し、Vertex PipelinesのAPIを叩き監視対象のパイプラインの成功可否と実行時間SLOが満たされているかをチェックします。 import crontab from datetime import datetime, timedelta import json import requests import os import pytz import math import typing """ cron: "*/5 * * * *" project: something-dev pipelines: - name: something slo_execution_time: 4h slo_time_format: '%Hh' region: asia-east1 environment: dev slack_webhook: something ...省略 """ SLACK_MESSAGE_FORMAT = """ {{ "text": "{text}", "attachments": [ {{ "color": "{color}", "text": "{attachment_text}", "fields": {fields} }} ] }} """ CRON = os.environ.get( "cron" ) ENV = os.environ.get( "environment" ) PROJECT = os.environ.get( "project" ) PIPELINES = os.environ.get( "pipelines" ) SLACK_WEBHOOK = os.environ.get( "slack_webhook" ) from google.cloud.aiplatform_v1.services.pipeline_service import PipelineServiceClient from google.api_core.client_options import ClientOptions from google.cloud.aiplatform_v1.types.pipeline_service import ListPipelineJobsRequest class MonitorVertexPipelines : def __init__ ( self, project: str , monitor_schedule: crontab._crontab.CronTab, monitor_targets: dict , ): self.project = project self.monitor_schedule = monitor_schedule self.monitor_targets = monitor_targets def __n_times_previous_time (self, start, n): assert n > 0 , "n must be greater than or equal to 1" def _previous (p, cnt): if cnt == 0 : return p else : return _previous( p + timedelta( seconds=math.floor(self.monitor_schedule.previous(now=p)) ), cnt - 1 , ) return _previous(start, n) def __get_pipelines (self, region: str , name: str , filter : str ): option = ClientOptions(api_endpoint=f "{region}-aiplatform.googleapis.com" ) client = PipelineServiceClient(client_options=option) request = ListPipelineJobsRequest( parent=f "projects/{self.project}/locations/{region}" , filter = filter ) pipelines = client.list_pipeline_jobs(request) return [ pipeline for pipeline in pipelines if pipeline.pipeline_spec[ "pipelineInfo" ][ "name" ] == name ] def finished_pipelines (self, current_time: datetime = datetime.utcnow()): previous_schedule_time = pytz.UTC.localize( self.__n_times_previous_time(current_time, 2 ) ) result = [] for target in self.monitor_targets: if not all ( [ must_included_key in target.keys() for must_included_key in ( "region" , "name" ) ] ): continue pipeline_name = target.get( "name" ) pipelines = self.__get_pipelines( target.get( "region" ), pipeline_name, filter = 'state!="PIPELINE_STATE_RUNNING"' , ) pipelines = [ pipeline for pipeline in pipelines if pipeline.end_time is not None ] # Ignore pipelines ended before previous monitoring time. pipelines = [ pipeline for pipeline in pipelines if previous_schedule_time < pipeline.end_time ] result += pipelines return result def pipelines_not_satisfy_slo ( self, current_time: datetime = datetime.utcnow() ): # NOTE default strptime value is 1900-01-01T00:00:00.000 # ref. https://docs.python.org/3/library/datetime.html#technical-detail DEFAULT_STRPTIME = datetime.strptime( "" , "" ) previous_schedule_time = pytz.UTC.localize( self.__n_times_previous_time(current_time, 2 ) ) current_schedule_time = pytz.UTC.localize( self.__n_times_previous_time(current_time, 1 ) ) result = [] for target in self.monitor_targets: if not all ( [ must_included_key in target.keys() for must_included_key in ( "region" , "name" , "slo_execution_time" , "slo_time_format" , ) ] ): continue pipeline_name = target.get( "name" ) slo_execution_time = target.get( "slo_execution_time" ) slo_format = target.get( "slo_time_format" ) slo = datetime.strptime(slo_execution_time, slo_format) - DEFAULT_STRPTIME pipelines = self.__get_pipelines( target.get( "region" ), pipeline_name, filter = 'state="PIPELINE_STATE_RUNNING"' , ) pipelines = [ pipeline for pipeline in pipelines if previous_schedule_time < pipeline.create_time + slo and pipeline.create_time + slo < current_schedule_time ] result += pipelines return result def monitor_vertex_pipelines (request): """Responds to any HTTP request. Args: request (flask.Request): HTTP request object. Returns: The response text or any set of values that can be turned into a Response object using `make_response <https://flask.palletsprojects.com/en/1.1.x/api/#flask.Flask.make_response>`. """ now = datetime.utcnow() monitor_schedule = crontab.CronTab(CRON) monitor_targets = json.loads(PIPELINES) monitor = MonitorVertexPipelines(PROJECT, monitor_schedule, monitor_targets) finished_pipelines = monitor.finished_pipelines(now) pipelines_not_satisfy_slo = monitor.pipelines_not_satisfy_slo(now) for pipeline in finished_pipelines: notify_to_slack( ENV, pipeline, "monitor pipeline status" , lambda pipeline: pipeline.state.name != "PIPELINE_STATE_SUCCEEDED" , ) for pipeline in pipelines_not_satisfy_slo: notify_to_slack(ENV, pipeline, "monitor pipeline slo" , lambda _: True ) return None def notify_to_slack ( env: str , pipeline: dict , attachment_text: str , danger_condition: typing.Callable ): display_name = pipeline.display_name pipeline_name = pipeline.pipeline_spec[ "pipelineInfo" ][ "name" ] state = pipeline.state.name start_time = pipeline.start_time end_time = pipeline.end_time fields = [ { "title" : "Display Name" , "value" : display_name, "short" : False }, { "title" : "Pipeline Name" , "value" : pipeline_name, "short" : False }, { "tile" : "State" , "value" : state, "short" : False }, { "title" : "Start Time" , "value" : str (start_time), "short" : False }, { "title" : "End Time" , "value" : str (end_time), "short" : False }, ] if danger_condition(pipeline): text = "<!channel>" if env in ( "stg" , "prd" ) else "" color = "danger" else : text = "" color = "good" DATA = SLACK_MESSAGE_FORMAT.format( text=text, color=color, attachment_text=attachment_text, fields=json.dumps(fields), ) requests.post(SLACK_WEBHOOK, data=DATA) return None 今後の展望 以上のように、Kubeflow PipelinesからVertex Pipelinesへの移行を実施してきました。現在は、よりVertex Pipelinesを快適に使えるよう、以下のことに取り組んでいます。 各プロジェクトで使える便利共通コンポーネント集の作成 Vertex Pipelines用のテンプレートリポジトリの作成 各プロジェクトで使える便利共通コンポーネント集の作成 前述のGKE上でPodとしてjobを実行するコンポーネントであったり、GCPのSecret Managerから秘匿情報を取得するような便利コンポーネントをまとめたリポジトリです。このリポジトリをフェッチし、コンポーネントをロードするだけで、それらを利用できるような世界観を目指しています。リポジトリのCI/CD・テスト等の基本的な仕組みはできており、後はコンポーネントを追加するだけの状態まで到達しています。 Vertex Pipelines用のテンプレートリポジトリの作成 Vertex PipelinesのCI/CD、監視、スクリプト類がまとまったテンプレートリポジトリを用いて「開発の高速化/SREのキャッチアップコストの低下」を実現させるための取り組みです。前述の監視やSDK V2でパイプラインを記述する知見は社内に多くないので、先回りをし、より便利な環境を整えていくことで開発者/SREがストレスフリーにVertex Pipelinesを利用できる環境を目指しています。 まとめ 本記事ではKubeflow PipelinesからVertex Pipelinesへの移行により運用コストを削減させる取り組みを紹介をしました。Kubeflow PipelinesからVertex Pipelinesへ移行するコストは高いですが、Kubeflow Pipelinesをセルフホストした際の構築・運用コストからは解放されました。 現在、私たちのチームではバッチ処理の実行環境の整備以外にも、汎用的なML系サービスのサービング環境も構築中です。ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com hrmos.co 参考 Introducing Kubeflow Pipelines SDK v2 Building Python Function-based Components GCPのコンソールなどから確認する方法はありませんが、gVisor上に構築されるようです ↩ 同様のIssue( convert string to GCSPath object or create one #4710 )と、それに対するメンテナー動向( Update KFP samples to use types that are compatible in v2. #5801 ) ↩ ExitHandlerの例: exit_handler/exit_handler.py ↩
アバター
こんにちは。 株式会社ZOZO NEXT にある ZOZO Research のApplied MLチーム所属の後藤です。社内の様々な課題を機械学習を活用して解決する仕事に取り組んでいます。 弊社(当時は株式会社ZOZOテクノロジーズ)では2019年1月より、ZOZO Researchと同志社大学 桂井研究室の共同研究を開始しました。本記事では、共同研究を行う際のポイントと、その成果を紹介します。 目次 目次 はじめに なぜ大学との共同研究を行うのか 共同研究を行う際のポイント 社内の喫緊の課題を研究テーマにしない 学生からの発案を大切にする 先生と学生を社内セミナーに招待して講演会を開く 共同研究の実績 フィット感の定量化の研究 参考文献 スタイルタグの関係性の可視化の研究 参考文献 類似ブランドの関係可視化と検索インタフェースの研究 参考文献 アニメ画像からコスプレ衣装画像を生成する研究 参考文献 最後に はじめに ZOZO ResearchはZOZOグループが保有するファッションに関する多様な情報資産を活用し、「ファッションを数値化する」ことをミッションとしている研究組織です。 これまでに、プロダクトの運用を通じて得られたデータの公開や、ファッション特有の課題設定を機械学習を使って解く研究論文などを発表してきました。 オープンデータセット第1弾:Open Bandit DataおよびOpen Bandit pipelineを公開 オープンデータセット第2弾:Shift15Mを公開 「社会的意思決定アルゴリズムのオープンソース開発&実装基盤」が日本イノベーション大賞で内閣総理大臣賞を受賞 ZOZO研究所、コンピュータビジョン分野における世界三大国際会議の一つECCVにて論文が採択 〜 深層学習と集合マッチングの融合によるコーディネート推薦 〜 - ニュース - 株式会社ZOZOテクノロジーズ それに並行して、大学との共同研究も進めています。現在までに同志社大学、九州工業大学、九州大学、東京大学、早稲田大学、上智大学、イェール大学の研究室との共同研究を行ってきました。 本記事では、その中でも、同志社大学 桂井研究室との共同研究の取り組みにフォーカスします。 同志社大学 桂井研究室は、同志社大学 理工学部 インテリジェント情報工学科 桂井麻里衣准教授の研究グループです。ビッグデータを活用したデータマイニング、ソーシャルネットワーク解析、マルティメディア処理などをテーマに様々な情報技術を研究しています。 iml.doshisha.ac.jp そのような桂井研究室の強みとZOZO Researchの情報資産をかけ合わせることにより、ファッションコーディネートアプリ「 WEAR 」のデータから「ファッションを数値化」する方法の研究を進めることにしました。 corp.zozo.com なぜ大学との共同研究を行うのか なぜ大学との共同研究を行うのか、その理由は「これまでにない価値を持つ発明をし、会社の非線形成長を促進させるため」です。既存プロダクトの一部の最適化や運用コストの軽減など、研究開発の課題は社内に山程あります。プロダクトの品質を上げるために、時間と労力をかけてこれらの課題を解決し続けるべきですが、会社が大きく成長するためにはこれまでにない価値をもつ発明をする必要があります。ZOZO Researchは「ファッションの数値化」を行うことで、この課題に挑戦しています。ファッションの数値化には、高度な情報処理技術と独創的なアイデアが必要です。大学と共同研究を進めることで技術力と発想力を備えた人たちとのコミュニケーションを生み、ZOZOの情報資産を使ったイノベーションを創出できる環境を目指しています。 共同研究を行う際のポイント 本章では、共同研究の道のりを振り返った際に、実施して良かった点を紹介します。 社内の喫緊の課題を研究テーマにしない 桂井研究室と研究テーマを決める際は、社内の喫緊の課題や現場の声を押し付けないように気をつけました。会社のKPIに紐付いた研究課題は、自由度が低く作業色の強いものになってしまいがちだからです。 大学は高い専門性と発想力を有した人材の宝庫なので、まずは自由な発想で課題設定をしていただくのが良いでしょう。一方で、ZOZO Research側はその自由な発想を、ビジネスへの応用や社内活用の文脈に位置づけるといった役割に回ります。 学生からの発案を大切にする ほとんどのテーマは、学生の卒業研究からスタートし、国際会議や論文誌への投稿へと発展させていくというパターンで進めてきています。 学生と議論していると、私たちが考えもしなかったアイデアや観点が飛び出してくることがあります。例えば、次の章で述べるアニメ画像からコスプレ衣装の画像を得るといった課題は、一見すると奇をてらったものに見えます。しかし、テクスチャや形状の異なるものを対応付ける技術だと考えると、ファッションの領域では衣服画像とモデル着用画像の対応付けといった応用例が考えられます。一見価値がわかりにくいアイデアもありますが、なぜそのようなアイデアが出たのか深く考え、議論と実験を積み上げていくと、論文として成立する内容に磨かれていきます。 先生と学生を社内セミナーに招待して講演会を開く 共同研究の中で得られた知見や成果を、論文の著者本人に解説してもらう社内セミナーを三度開きました。学会や論文誌への対外的な発表だけに留めず、社内で議論することによって、プロダクトへの応用や他の分野での研究のインスピレーションとなっていきます。 共同研究の実績 本章では、前述の共同研究から生まれた、具体的な研究内容を紹介します。 フィット感の定量化の研究 衣服着用時のシルエットはファッションスタイルの印象を左右する要素の一つです。スキニーパンツとワイドパンツの2例を考えると、同じボトムでも身体に対する衣服のシルエットが占める領域が大きく異なります。このフィット感を定量化できれば、WEARのスタイリングを検索する際の軸として利用したり、ユーザーの嗜好や商品推薦にも利用できます。 具体的には、WEARの投稿画像に対して、3D人体モデルとセマンティックセグメンテーションのモデルを適用させます。身体と衣服の画像上を占める領域を抜き出した上で、Tightness Indexとして以下のような指標を定義します。すると、数値が大きいほど身体と衣服の領域が近いことを意味し、タイトな着こなしであると判断できます。 参考文献 池田宗也,桂井麻里衣,真木勇人,後藤亮介,“身体と被服のサイズ関係に基づく着用シルエットの印象推定,” 第11回データ工学と情報マネジメントに関するフォーラム (DEIM2019), A1-1, 長崎,2019年3月. スタイルタグの関係性の可視化の研究 WEARの投稿には投稿内容を表現するためのタグが投稿者自身によって付与されます。これにより、同じタグが付けられた投稿同士を結びつけることができ、情報を絞り込む際に有用な手段となります。このタグの中には、ファッションスタイルを表現するタグが含まれており、共起関係に注目すると、スタイル間の関係性が抽出できると考えられます。 この研究では、画像特徴量が近い投稿同士は、視覚的に近いファッションスタイルを有しているという仮説のもと、類似画像グループのタグの共起回数に基づいてタグネットワークを構築しました。その結果、視認しやすく、コミュニティ検出がしやすいタグネットワークが構築できることがわかりました。 参考文献 上村幸汰,桂井麻里衣,真木勇人,後藤亮介,“タグ付き画像を用いたファッションスタイルの関係性の可視化,” 第11回データ工学と情報マネジメントに関するフォーラム (DEIM2019), E8-2, 長崎,2019年3月. 類似ブランドの関係可視化と検索インタフェースの研究 「ファッションブランドAとBが似ている」と言うとき、似ているという尺度には様々な観点が考えられます。また、好みのブランドに似ているブランドを推薦した際に、そのブランドのコンセプトがきちんと伝わらなければ興味を持つことは難しいでしょう。そこでブランド類似度の可視化手法とユーザーインタフェースを提案する研究を行いました。 WEARの投稿に付与されたタグのうち、ooコーデ/ooスタイルといったスタイリングの意味を表すタグに限定し、着用されている商品のブランドを特徴づける実験をしました。その結果、得られたブランド類似度ネットワークは適度に疎なネットワークとなり、高い視認性を持つことがわかりました。同時にブランドのコンセプトが確認可能なユーザーインタフェースを実装しています。 参考文献 Natsuki Hashimoto, Marie Katsurai, and Ryosuke Goto, "A Visualization Interface for Exploring Similar Brands on a Fashion E-Commerce Platform," Proceedings of 2021 International Conference on Web Services (ICWS2021), pp. 642–644. アニメ画像からコスプレ衣装画像を生成する研究 衣服単体の画像からモデル着用画像に変換するなど、ファッションECの分野では画像ドメインを変換したものが有用なシチュエーションがあります。敵対的生成ネットワークはこのようなタスクを実施する際の有望な選択肢となります。しかし、学習をうまく進める方法として膨大な数の手法が提案されており、ファッションのドメインではどのようなものが有効か自明ではありません。 この研究ではアニメキャラクターの画像から、コスプレ衣装の画像を生成するというタスクの提案と、その際に有用なGANアルゴリズムの試行錯誤をしています。また、Webサイトからスクレイプしたノイジーなデータをどのように整形するとうまくいきやすいかを調べ、方法論としてまとめています。 この研究はGIGAZINEにも取り上げられ話題となりました。 gigazine.net 参考文献 Koya Tango, Marie Katsurai, Hayato Maki, Ryosuke Goto, "Anime-to-Real Clothing: Cosplay Costume Generation via Image-to-Image Translation", arXiv:2008.11479. 最後に 本記事では、共同研究を行う際のポイントと、ZOZO Researchと同志社大学 桂井研究室による共同研究の成果の概要をお伝えしました。 引き続き、桂井研究室とはWEARのデータを活用したこれまでにない研究を行っていきます。「ファッションを数値化する」をミッションに、斬新な着想を形にしてファッションテックの発展に貢献していきたいと考えています。 ZOZO Researchでは、機械学習の社会実装を推し進めることのできるMLエンジニアを募集しています。今回紹介した共同研究以外にも、検索/推薦/画像認識などプロダクトで活用する機械学習技術の開発を進めていけるメンバーを募集しています。 ご興味のある方は、以下のリンクからぜひご応募ください。 hrmos.co hrmos.co hrmos.co
アバター
はじめに こんにちは、Selenium 4の正式版がなかなかリリースされなく、ソワソワしている品質管理部・自動化推進ブロックの木村です。 私が所属する品質管理部は、ZOZOTOWNやWEARなどの開発プロジェクトに対してテスト・検証を行い、完成品がユーザーの手に届いても問題ないかを確認する部署です。その品質管理部では、先日、部署で開発運用しているSeleniumによる自動テストのシステムをオンプレからAWSに移行しました。自動テストの書き方や、個々のAWSサービスの使い方の記事は多く存在するので、本記事では自動テスト全体の概要を紹介します。単純な移行だけでなく、サーバレスやマネージドサービスを活用しているので、部分的にでも参考になる点があれば幸いです。 背景 品質管理部が行っていたリグレッションテストやシステムテストを部分的に自動化するために、Seleniumによる自動テストのシステムを開発し、複数台のオンプレサーバで運用してきました。しかし、それらのサーバを撤去する必要が出てきたため、運用のしやすさや将来的な拡張性を考えてクラウド移行することにしました。 クラウド環境の選定 最初に、クラウド環境を選定する必要があります。社内で主に利用されているAWSかGCPのどちらに寄せるのかを検討しました。 検討の結果、AWSを選定しました。その理由は、スマートフォンのアプリのテストの存在にあります。これまでもAppiumを使ったスマートフォンアプリのテストを行っています。クラウド移行に際し、AWSであればAWS Device Farmを利用すれば、ある程度既存のソースコードを流用したまま移行できる可能性があると考えました。一方、GCPの場合は、Firebase Test Labではソースコードを完全に切り分け、新しく用意する必要が出てくることが懸念点でした。 自動テストの仕組み 今回の移行対象となる自動テストを行うシステムは、テスト処理だけではなく運用面の機能も含め、主に下記5つの機能を有しています。 Seleniumによるテスト処理 実行管理とスケジュール 設定と結果の管理 結果の閲覧 ソースコード管理とビルド これらの全ての機能をそのままEC2に移行してしまえ、という話もありました。しかし、AWSに移行する良い機会なので、移行期間の短縮よりもAWSの特性を活かした移行を実現させることを優先させ、移行対応を機能ごとに分けて進めました。 Seleniumによるテスト処理 移行前後でテストの仕組みがどのように変わったのかを下図で示しています。 移行前はSeleniumのテストコードをマウントしたDockerコンテナを起動し、別コンテナで起動したSelenium Gridを利用してブラウザを操作していました。 その構成を、移行後はECS(Elastic Container Service)のFargateを利用することにしました。Dockerを利用していたので、移行自体は簡単に行えました。 ECSがタスクを起動し、ECR(Elastic Container Registry)からコンテナイメージを取得してコンテナを起動します。テストコードが実行されるとSelenium Gridコンテナに接続し、ブラウザを利用したテストが実施されます。 なお、テストコード用のコンテナには複数のテストが含まれており、実施するテストによって起動時に渡す環境変数で使い分けています。 初めてECSを触った時は、コンテナ同士の接続はどうなるのだろうと疑問でした。しかし、同一タスクで立ち上げられたコンテナは同じ環境内に起動されるようでした。そのため、同一タスク内でテストコード用のコンテナとSelenium Grid用のコンテナを立ち上げれば、テストコードからは定番の localhost:4444 でブラウザに接続できます。 その他にもいくつか検討・工夫した点があるので紹介します。 ブラウザの起動方法 前述の通り、検討初期段階ではコンテナ間の接続は困難だろうと思い、PythonとChromeブラウザをインストールした1つのコンテナにテストコードを入れる形式にしていました。しかし、この方法だとSeleniumでありがちなバージョン管理が煩雑になるという問題がありました。 そもそもこの問題を解決するためにSelenium Gridを利用しています。そして、調査を進めていくうちに接続が問題なく可能なことも分かったので、最終的にはコンテナを分けた構成にしています。 AWSのIPからの開発環境へのアクセス方法 当然のことながら、AWSに移行すると社内ネットワークから外れます。テスト対象が接続元IPで制限されている可能性を考慮し、NAT Gatewayを一時的に構築しました。結論としては、テスト対象が全てインターネット上に公開されているものだったため、NAT Gatewayは廃止しました。テストの要件次第で柔軟に対応する必要がある注意ポイントです。 実行管理とスケジュール 移行前後で実行管理の仕組みがどのように変わったのかを下図で示しています。 移行前はスケジュール管理のためのソースコードが入ったDockerコンテナを起動しておき、そのコンテナからSeleniumのテストを実行するコンテナを立ち上げる構成でした。 移行後は、EventBridgeを活用する構成に変更しています。EventBridgeから直接ECSを呼び出すことも可能ですが、臨時でテストを実行したい場合に、ECSのコンソールからタスクを起動することが手間だったのでLambdaを経由する構成にしました。 そして、EventBridgeは指定の時刻になったらLambdaを実行します。この際に実行するテスト情報をJSONで渡します。Lambdaは受け取ったテスト情報を環境変数に入れた上で、ECSのタスクを起動します。 また、臨時でテストを実行したい場合には、LambdaのコンソールからワンクリックでECSタスクを起動できます。さらに、API Gatewayなどを用意しておけば外部からの実行も可能です。 なお、EventBridgeを利用していて気になる点は、Webコンソールのフォームデザインです。EventBridgeの入力設定で 定数(JSONテキスト) を使用していますが、その入力が テキストエリア(複数行) ではなく テキストフィールド(一行) になっているため、多少の使いにくさがあります。 設定と結果の管理 移行前後で設定と結果の管理の仕組みがどのように変わったのかを下図で示しています。 移行後の仕組みを、順を追って説明します。 設定の保存先 結果の保存先 設定と結果の取得 設定の保存先 設定に関する情報は、「テスト設定」と「ケース設定」の2種類が存在します。 前者の「テスト設定」はテスト全体に影響する情報、例えばテスト対象URLなどが含まれるJSONファイルです。このJSONファイルはそのままS3に保存します。 後者の「ケース設定」は、テストの中に含まれる個々のケース(手順)が記載されるものです。Googleスプレッドシートで管理し、利用時にCSV形式でS3へ保存します。実際にケースを管理するチームが品質管理部とは別なため、誰でも気軽に触れられるという理由でスプレッドシートを採用しています。 なお、ケース情報のCSVをそのままS3に置いた場合、S3上ではファイルの編集が行えないため、毎回ダウンロードする必要があります。一方、DBで管理しようとするとDBやクエリに関する知識が必要になってしまい、運用メンバーへの負担となってしまいます。そのため、スプレッドシートを使って複数人で気軽に編集できる状態は維持しつつ、Lambda経由でCSVへ変換できるよう、その仕組みを実装しました。 結果の保存先 結果として出力されるものにも2種類あり、テストの実行時に取得されるスクリーンショット画像やログなどの「出力結果」と、最終的にテストが成功・失敗したのかの情報がまとまった「テスト結果」が存在します。 前者の「出力結果」は、前述の通りスクリーンショット画像やログなどが含まれます。テスト結果がNGの場合に調査に利用するファイル群なので、運用するチームなら誰でも見れるよう環境である必要があります。なお、このファイル群は編集する必要性がなく、閲覧のみで問題ないのでS3に保存しています。 後者の「テスト結果」は、テストケース毎に成功・失敗したのかの結果を含んだ情報であり、DBに保存しています。この情報を元に、後述の結果を表示するWebページの作成や通知に利用しています。なお、このDBはMySQLを利用していたので、そのままAurora MySQLに移行しています。 移行先のAurora MySQLはサーバレスを選択しています。そのため、プロビジョニングされたDBクラスタと比べ、制約も多くあります。しかし、アクセスが低頻度や断続的、または予測がつかない場合には有効な選択です。クラスタ自体の運用も不要になるメリットがあります。今回も、社内の自動テストのための環境なので、アクセス数には大きな波があります。少々接続に時間を要したとしても、許容可能です。これらを踏まえ、サーバレスを選択しました。 設定と結果の取得 テストの定期実行以外にも、WebコンソールからトリガーとなるLambdaを実行すれば、任意のタイミングでテストを開始できます。とはいえ、実際の運用では手元のPC上で実行したいものです。そこで、各種設定や結果はAWS内だけではなく、ローカルからのアクセスも考慮しています。 その考慮のために、下記の処理をLambdaで1つずつ作成し、API Gatewayから実行できるようにしました。 指定したスプレッドシートからCSVをダウンロードする処理 渡されたファイルをS3に保存する処理 テスト結果をAuroraにインサートする処理 なお、Googleスプレッドシートへのアクセスに必要な接続情報や、Auroraへの接続情報はAWS Secrets Managerに保存しています。 結果の閲覧 移行前後で結果の閲覧の仕組みがどのように変わったのかを下図で示しています。 移行前の構成では、DBに保存された結果を閲覧できるようにDjangoを使って簡易的なWebページを作成していました。このWebページは結果閲覧だけでなく、設定変更やスケジュール変更といった機能も有していました。しかし、この仕組みはクラウド移行前から廃止する方向で検討していたこともあり 1 、移行後は用途を結果閲覧だけに絞ることにしました。最終的に、移行後はS3で静的Webサイトを表示する仕組みになりました。 テスト実行時のSeleniumのテストコードを改修し、最終結果が記載されたHTMLファイルを出力する処理を追加しました。出力されたHTMLファイルは、テスト結果のファイルと同様にS3 Bucketに保存します。WAF、CloudFrontを経由して接続を制限しながら、外部からHTMLファイルにアクセスできる環境を構築しました。 現状の要件では、結果の閲覧のみで良いのでこの形式を採用しています。今後、他の機能を拡充する必要が出てきたら、改めて動的な処理が必要となることが見込まれます。 ソースコードの管理とビルド 移行後に新しく導入した、ソースコード管理とビルドの仕組みを下図で示しています。 移行前は特に仕組みを用意しておらず、GitHubから更新されたソースコードをサーバ内で git pull していました。しかし、移行後はECSを利用するため、コンテナイメージを作成してECRにpushする必要があります。そのため、GitHubと連携させ、ビルド作業を効率化させました。 手順の大きな流れは以下の通りです。 GitHub Actionsを利用してテストコードをZIP化 ZIPファイルをS3に保存 CodeBuildがZIPファイルからコンテナイメージをビルド ビルドしたコンテナイメージをECRにpush なお、CodeBuildはS3のイベント通知を受け取れないので、ビルドの自動開始のためにEventBridgeやCodePipeline、Lambdaなどを挟む必要があります。今回は扱いやすさを優先し、EventBridgeを選択しました。 また、EventBridgeでS3のputを検知するために、CloudTrailも利用しています。なお、その設定の際に必要な項目は、EventBridgeのイベントパターンや、S3バケットのバージョニング有効化など多岐に渡ります。詳細は下記のドキュメントを参照してください。 チュートリアル: EventBridge を使用した Amazon S3 オブジェクトレベルの操作のログを記録する - Amazon EventBridge Using dynamic Amazon S3 event handling with Amazon EventBridge | AWS Compute Blog また、本来ならGitHub Actionsでコンテナイメージをビルドし、そのままECRにプッシュする方がシンプルで綺麗です。しかし、将来利用を予定しているDevice Farmは実行時にテストコードのZIPファイルが必要です。そのため、上記のようにコンテナイメージとZIPファイルの両方を揃える形式にしています。記事の冒頭でも触れた、Appiumを使ったテストのDevice Farm対応は順次対応中です。 まとめ 本記事で紹介した、AWS上のSelenium自動テストシステムの構築図は以下の通りです。 まだ不足部分もあるため、現在も取り組みを続けています。設計段階で、各処理を小分けにしたことで部分的な改修や切り替えが行いやすくなったと実感しています。 最後に ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからご応募ください! tech.zozo.com チームメンバーの全員がDjangoを扱えるわけでもなく、筆者一人で開発運用していた状態でした ↩
アバター
こんにちは、データシステム部のAnirudh Gururaj Jamkhandiです。私はECにおけるユーザーの購買率向上を目指して、推薦アルゴリズムの研究開発に携わっています。 高機能な計算機の登場により、現在では様々な業界で機械学習が飛躍的に利用されています。特に、深層学習は特定のタスクにおいては、人間の能力をはるかに超える結果を出しています。しかし、人間にとっては初歩的な能力である、自ら問題を生成したり他のタスクに一般化する能力はまだありません。近年、そういった課題を解決するための学習アルゴリズムの開発が盛んに行われています。本記事では、そのようなアルゴリズムの1つである「Open Ended Learning」を紹介します。 目次 目次 はじめに Open Ended Learningの紹介 Open Ended Learningの研究の現状 インタラクティブな推薦システムの問題設定 インタラクティブな推薦をサポートするRecSimとそのコンポーネント RecSimフレームワークを用いたアルゴリズムの設計 ドキュメントモデル ユーザーモデル ユーザー選択モデル 報酬機能 エージェント アルゴリズム:POET アルゴリズム:Enhanced POET トレーニングプロセス ZOZOにおけるOpen Ended Learningの推薦システムへの応用 問題設定 実験結果と考察 結論と今後の課題 はじめに この10年間、機械学習の技術はこれまでになく発展し、実社会への導入が行われてきました。 Artificial Intelligence Index Report 2019 によると、グローバル企業の50%以上が、少なくとも1つの機能にAIを採用していると言われています。AIは楽観的な予測と大規模な投資が行われてきた一方で、特に自動運転・家事代行・音声アシスタント技術の開発においては、失望・信頼の喪失や投資減(「AIウィンター」)の時期も見られます。このような落ち込みの理由として考えられるのは、学習アルゴリズムが汎化されない、 あるいは不測の事態にうまく適応できないこと です。この問題は、アルゴリズムに収益を依存している企業、特にECにも大きく影響します。従って、不測の事態でもうまく機能する学習アルゴリズムの開発は実サービスにおいて重要な課題となっています。 ZOZOでは、機械学習アルゴリズムが様々な場面で利用されています。例えば、ユーザーへのアイテムのレコメンド、画像検索などがあります。これらのタスクでは、各領域の専門家が根本的な問題を特定し、指標やインプットを最適化することで問題解決する必要があります。特に推薦システムでは、データの少ない新規ユーザー、新規アイテム、多様性などに対応するモデル開発をすることになります。このような複雑な課題を認識し、かつ解決できるアルゴリズムはあるのでしょうか。本記事では、このような多様で複雑な問題を生成・特定し、同時に未知の状況にもうまく一般化して解決できる「Open Ended Learning(以下、OEL)」という手法を紹介します。 Open Ended Learningの紹介 state-of-the-artとされる既存手法からさらに改善する方法は、問題を選んだり時には作ったりして、それを解決しようとするアプローチでした。そうすることでアルゴリズムが改善され、それが課題解決に役立ちます。一方、人工生命の研究者が提唱する自然進化に基づくアプローチは、問題を解決するだけでなく、問題を自動生成するアルゴリズムを作ることです。OELとは、学習モデルが好奇心を絶やさず、自ら挑戦的な学習機会を生み出すような学習のことです。設定した問題のみを解決する機械学習アルゴリズムとは異なり、OELは私たちの想像を超える驚きを生み出してくれる可能性も秘めています。 Open Ended Learningの研究の現状 人工生命の研究者たちは、以前からOELの研究・調査をしてきました。しかし、取り組むべき課題の複雑さが増すにつれ、進化のために利用できるデータが足りないことに気付き、この研究が活発化し始めました。主要な例としては、Uber AIのWangらが「二足歩行ロボット」に適用した POET(Paired Open Ended Trailbrazer) やDeepMindが 「かくれんぼ」や「旗取りゲーム」などに応用した研究 があります。いずれの研究も、ある環境で学んだ経験を別の環境に応用させるOELによって汎化性能を改善しています。最近よく見られる Generative Adversarial Networks(GAN) も OELの一種 です。 ZOZOでも様々な状況でのレコメンデーションをシミュレートすることで、既存性能を超えるアルゴリズム開発を試みています。強化学習に基づき、逐次的なユーザー行動のモデル化を行い、長期的なエンゲージメントを最大化する手法です。 次に、このようなシステムとユーザー行動の相互作用を使って動的に変化させる、つまり インタラクティブ な推薦システムの設計を紹介します インタラクティブな推薦システムの問題設定 この推薦システムでは、セッションにおける報酬の最大化を目標とします。 セッション最適化では、状態 、行動 、報酬関数 、遷移確率 、割引係数 を持つMarkov Decision Process(MDP)としてモデル化できます。 状態 ユーザーの特徴(デモグラ、興味など)と過去の行動に関連した情報(過去の推薦結果、閲覧・クリック・カートに追加したアイテム、満足度など)の両方を表す 行動 選択されたアイテム、Iは推薦アイテム候補 選択し得るアイテムサイズkが固定されていると仮定すると、 は s.t. であり、 はアイテムサイズを表す 遷移確率 状態 で行動 をとったときに状態が になる確率を表す 報酬 行動 による期待報酬であり、行動 のアイテムに対するユーザーエンゲージメントの度合を表す 私たちはこのような推薦システムの様々な部分をモデル化するために RecSim フレームワークを使用し、ユーザー、アイテム、ユーザー×アイテム間の相互作用のモデル化にOELを利用しました。 インタラクティブな推薦をサポートするRecSimとそのコンポーネント RecSimは、推薦システムに強化学習を用いるためのシミュレーションプラットフォームです。推薦候補アイテム群(以下、ドキュメント)に対してユーザー行動のシミュレーションを実施する環境を作成できます。このフレームワークは、いくつかのコンポーネントで構成されています。 以下にコンポーネントの特徴を示します。 環境は、ドキュメントモデル、ユーザーモデル、ユーザー選択モデルで構成される エージェントは、推薦結果を作成するためのモデル(以下、ポリシー)を持ち、ドキュメントとユーザーの特徴を利用して推薦する ドキュメントモデルは、ドキュメントの特徴(品質などの潜在的な特徴と、評価や人気などの観測可能な特徴)の事前分布からドキュメントをサンプリングする ユーザーモデルは、ユーザー特徴(満足度、興味などの潜在的特徴、年齢などの観測可能な特徴、セッションの長さなどの行動的特徴)の事前分布から、ユーザーをサンプリングする ユーザー選択モデルは、エージェントのレコメンデーションに対するユーザーの反応をエミュレートする 具体的には、推薦されたドキュメントの特徴とユーザーの特徴を用いて、利用しそうなドキュメントを選択する ユーザー遷移モデルは、ユーザー選択モデルからドキュメントが選択された後に、このモデルを介してユーザー状態を更新する これらのコンポーネントによって強化学習を行います。エージェントが環境と相互作用し、その相互作用に対するフィードバックを受け取り、期待報酬を最大化することでアクションの選択を最適化します。 次に、どのように推薦システムをシミュレートするのかを説明します。 シミュレーションの各ステップは、4つのプロセスで構成されています。図1は各コンポーネントとステップの関連を示しています。 ユーザーモデルからユーザー状態を、ドキュメントモデルからドキュメントの特徴を要求し、それらをエージェントに送る エージェント(推薦アルゴリズム)は、現在のポリシーを使用して、ドキュメントセットを返す ユーザー選択モデルがドキュメントを選択する ユーザー遷移モデルを用いてユーザーモデルを更新し、報酬によってエージェントポリシーを更新する このステップ内のプロセスは、あらかじめ設定された終了条件が満たされるまで繰り返されます。そして、最初のステップから最終状態までのすべての状態を集めたものがエピソードです。 図1:RecSimコンポーネントの全体像(引用: https://arxiv.org/pdf/1909.04847.pdf ) RecSimフレームワークを用いたアルゴリズムの設計 課題設定として、 長期的なユーザー行動がモデル化された環境を目指します 。なぜなら、過去の研究でユーザーの潜在的な状態は、レコメンデーションやサービスの変化に伴ってゆっくりと変化することが確認されているためです。この環境では、CTRは高いが満足度が弱いドキュメントもあれば、CTRは低いが満足度が高いドキュメントもあります。そのため、課題はこの2つのバランスをとり、長期的に最適なトレードオフを実現することです。満足度は潜在的な変数ですが、このシステムダイナミクスは部分的に観測可能です。満足度は、エンゲージメントの増減から推測できます。 このような環境に関するシミュレータは、以下のように設計されています。 ドキュメントモデル モジュール特徴量の事前分布からモジュールをサンプリングします。モジュールの特徴量としては、CTR、CVR、価格などを使用します。そして、全モジュールのCTR、CVR、価格の平均と分散を求め、それぞれの特徴をガウス分布に当てはめてモジュール特徴量の事前分布とします。 ユーザーモデル ユーザー特徴量の事前分布からユーザーをサンプリングします。各ユーザーは、net positive exposure ( ) と呼ばれる特徴量と、satisfaction ( ) と呼ばれる特徴量を持ちます。満足度は増加の抑制のため、ロジスティック関数を用いて表します。 ここで は ユーザー固有の感度パラメータ、t はエピソード内の時間ステップです。ユーザーがドキュメントを選択すると、 は次のように進化します。 ここで、 はユーザー固有の記憶割引(忘却因子)、 はイノベーションの標準偏差です、 は CTR です。そして、これが「ユーザー遷移モデル」です。 は、長期的なエンゲージメントの反応( )とパルス消費の反応( )を線形に補うパラメータを持つ対数正規分布で下記の定義とします。 と はそれぞれの平均CTRと標準偏差です。 ~ このように、ユーザーの状態は( ) の組み合わせで定義され、ユーザー状態の唯一のダイナミクスは満足度として表されます。 ユーザー選択モデル ユーザー反応をシミュレーションするために、CatBoostをモジュールのクリック確率予測に用います。 報酬機能 ここでは目標を累積報酬で表します。また、報酬は目標に向けた中間的なフィードバック(正または負)を提供します。今回は推薦結果によってユーザーの総合的なエンゲージメントを向上させることを目標としています。全体的な報酬機能は、以下の2つの要素で構成されています。 ランキングベースの報酬 : エージェントはモジュールの順位を直接予測する代わりに、順位式 の係数を予測するように学習します。そして、予測された係数を用いて、各モジュールのスコアを計算します。モジュールiのスコアは次のように与えられます。 次に、上位k個のスコアを抽出し、ポジションバイアス を割り当てます。そして、最終的なランキング報酬は、モジュール報酬の加重和として計算します。 エンゲージメントベースの報酬:ユーザー選択モデルがエージェントの推薦に対するユーザーの反応を予測すると、ユーザーは選択したモジュールに 秒(先に定義した)エンゲージメントします。エンゲージメント時間は、エージェントの推薦に対するユーザーの満足度としてフィードバックします。つまり、 を報酬として使用します。 「ランキングベースの報酬」はモジュールを適切にランキングした場合の報酬で、「エンゲージメントベースの報酬」はモジュールをクリックした場合の報酬です。最終的な報酬は、ランキングベースの報酬とエンゲージメントベースの報酬の合計です。 エージェント POETの後継となるEnhanced POETというOELアルゴリズムを使用しています。POETの基本的な考え方は以下の通りです。 ノベルティサーチ : 従来、機械学習・深層学習・進化アルゴリズムを含む学習アルゴリズムは、特定の目的関数を解決するために使用されてきました。生物学的な進化は人間の知能を生み出す重要な要因の1つであり、自然界では全体的な目標がなく、ある機能のために進化した機能が他の機能に使われることもあります。従って、推論のルールをハードコーディングしたり、特定の性能指標で高得点を取るために学習するのではなく、新規性や興味深さを優先します。実際に、 ある目的を完全に無視することで、その目的を追求するよりも早く最適化している事例 もあります。 ゴールの切り替え : 1つのエージェントのみを使って新規性のある行動を生み出すのではなく、様々なニッチなタスクと各タスクのそれぞれで良い結果を出すエージェントを保持します。各エージェントは、自分のニッチなタスクで最適化された後、別のニッチな問題でも再度評価されます。もしそのエージェントが他のニッチなタスクで良い結果を出せば、そのエージェントは新しい目的のために最適化されます。従って、興味深い方向にアイデアを追いかけることでアルゴリズムは多様な結果を生み出し、問題を解決できます。 最小基準共進化(Minimal Criterion Coevolution)と品質の多様性 : 自然界では、 繁殖するために長く生き残るという基本的な原則 に従っています。このシンプルな原理により、私たちは多様で複雑な環境を作り出すことができます。MCCでは相互作用する2つの集団を進化させることで、他の母集団に対して閾値(最小基準)を満たすことで生存できるようになり、オープンエンド(制約のなさ)を促進します。 アルゴリズム: POET 図2:POETアルゴリズムの疑似コード(引用: https://arxiv.org/pdf/1901.01753.pdf ) このアルゴリズムは、図2のようにランダムに初期化された1つの「環境⇔エージェント」のペアから始まります。その後、POETはメインループの中で以下の3つのタスクを実行します。 ペアになったエージェントを各環境に最適化する この際の最適化アルゴリズムには、進化戦略を用います。 N(mutate)インターバルごとに対象となる環境パラメータを変異させる 残したい環境は、ペアとなったエージェントがある閾値を超えている環境です。ある環境が変異元の対象となった場合、まずその環境の子環境を作るために数回の変異(新規性の探索)を行います。その後、新しい環境が簡単すぎず、かつ難しすぎない(品質の多様性を確保する)ことを保証するために、元のペアエージェントとMCCで照合します。これらの操作後に残った環境を異なる環境とペアになったエージェントとテストし、最も良いパフォーマンスを示したものを新しいペアエージェントとして残します。 図3:平原の環境に切り株が発生した環境変異の例(引用: https://arxiv.org/pdf/2003.08536.pdf ) N(transfer)インターバルごとに対象となる環境パラメータを変異させる このステップではすべてのエージェントが各環境でテストされ、どれかが元のペアのエージェントよりも優れた性能を発揮した場合、より優れたエージェントに置き換えられます。図4の はエージェントのポリシーです。 図4:ゴールの切り替えを行いながら変異するステップ(引用: https://icml.cc/media/icml-2019/Slides/4336.pdf ) アルゴリズム: Enhanced POET POETを汎用的に利用するために、従来では環境の分布パラメータとして表現されていた部分が環境エンコーディング(EE)と環境キャラクタリゼーション(EC)に分離されました。 EEには座標を入力して幾何学的なパターンを生成するニューラルネットワークであるCPPN(Compositional Pattern Production Network)を提案し、ECにはPATA-EC(Performance of All Transferred Agents Environmental Characterization)という指標を提案しています。これは環境の新規性評価には相応の対処が必要であるという考えに基づき、新環境ではすべてのエージェントでその環境との報酬を算出します。そして、相対的なエージェントの順番がどれだけ違うかによって新規性を評価します。 このように新しい環境を生成することで、新たな挑戦を続けるアルゴリズムがPOETです。ペアエージェントは、ニューラルネットワークで表現され、期待報酬を最大化するために状態(ユーザー状態とRecSimでシミュレートされたモジュールの状態)と行動(ランキング生成のための係数)を対応させるポリシーを学習します。 トレーニングプロセス 図5:トレーニングプロセスの全体像(引用: https://arxiv.org/pdf/1902.00851.pdf ) 図5は、エージェントがユーザーと相互作用し、報酬(≒エンゲージメント)を最大化するトレーニングプロセスです。学習は、シミュレータがドキュメントモデルとユーザーモデルからそれぞれの特徴量を要求し、エージェントに送信することから始まります。エージェント(Enhanced POET)は、現在のポリシーを使ってランキング係数を予測し、推薦結果を生成します。ユーザー選択モデルは、その推薦結果に対してモジュール特徴量とユーザー状態を考慮し、ユーザーの選択を予測します。シミュレータは、ユーザー遷移モデルを用いてユーザーモデルを更新し、ユーザーの反応と報酬を用いてエージェントポリシーを更新します。 ZOZOにおけるOpen Ended Learningの推薦システムへの応用 問題設定 現在、ZOZOTOWNのトップページのデザインは「モジュール」構造になっています。「モジュール」とは、図6のようにセール対象商品や新作商品などの特集化されたコンテンツ集合を表しています。 図6:「チェックしたアイテム」モジュールの例 そして、このページではユーザー体験を向上させるために、主に2種類のパーソナライズド・レコメンデーションを提供しています。図7のようなモジュール内での商品推薦とモジュール順序の最適化です。本記事では、後者のモジュール順序の最適化に焦点を当てます。 図7:モジュール・ランキングによるZOZOTOWNのカスタマイズ また、ZOZOTOWNには、毎日多くの新規ユーザーが訪問し、新着アイテムも多く追加されます。このようなデータの少ないユーザー、アイテムでは コールドスタート問題 が発生します。 さらに、レコメンデーションに多様性を持たせることで、ユーザー体験の向上が期待できます。 ZOZOTOWNでは、RecSimフレームワークを使用した推薦環境でモジュールランキングのオフライン実験を行いました。 なお、学習プロセス全体は、大規模な実データに対応するため Fiber Framework を用いて並列化させます。本ケースでは、20の環境とそのペアエージェントをアクティブなものとして実験しています。学習の進捗状況の測定には、研究論文と同様に累積新規環境作成・解決数 ANNECS という指標を使用しています。ANNECSスコアは、新しい環境がMCCをクリアし、かつ設定した報酬の閾値を超えた環境数のカウンターです。そのため、本スコアはニッチであり、有意義な変異を実現した指標となります。 実験結果と考察 結果としては、図8のように学習が進むにつれANNECSスコアは増加し、アルゴリズムがますます有意義な課題を生み出していることを示しました。最終的に10,000イテレーションの学習後、ANNECSスコアは130以上となりました。 図8:イテレーション数とANNECSスコア この実験の主な目的は、OELを推薦タスクに応用して複雑な問題を解決すると同時に新しい問題を見つけ出し、またある環境での進化が別の環境でどのように適応するかを確認することでした。そして、シミュレータを使ったオンライントレーニングで20の環境とそのペアエージェントを作成した後、これらをオフライン評価で比較し、最も性能の良いモデルを1つ選定しました。 MODEL PRECISION@10 RECALL@10 NDCG@10 Collaborative Filtering 1.929 13.669 0.0599 NCF 2.522 17.878 0.0868 LamdaMART 2.321 17.683 0.0849 BPRMF 2.483 17.6 0.0837 Direct Curriculum using ES 2.462 17.532 0.0828 Enhanced POET 2.598 17.789 0.0891 このモデルを他のランキングモデルと比較したところ、OELを用いた推薦システムで他の手法よりも優れた結果を得ることができました。この結果とANNECSスコアの増加グラフにより、アルゴリズムが新しい問題を見つけ、それを解決できたことを示しています。 結論と今後の課題 OELが何を発見し、どのような未来をもたらすかはわかりません。そのため、この不確実性に懐疑的な人はPOETのようなシステムを「ランダム性をもたらすアルゴリズム」と解釈するかもしれません。しかし、進化の考え方にヒントを得て不確実性を取り入れたOELは、昨今興味深い研究や多くの コンテスト で盛り上がりを見せています。将来的には、レコメンデーションシステムをはじめとする様々なアプリケーションで、このようなおもしろいアルゴリズムが利用されるかもしれません。 ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
アバター
こんにちは、ZOZO CTOブロックの池田( @ikenyal )です。 ZOZOでは、10/6に After iOSDC Japan 2021 を開催しました。 zozotech-inc.connpass.com 本イベントでは、ZOZO、Mobility Technologies、Sansanの3社による合同イベントです。9/17-19に開催されたiOSDC Japan 2021のスポンサーである3社からエンジニアが集まり、各社の社員によるiOS関連技術のLTと、iOSDC Japan 2021イベントを振り返るパネルディスカッションを行いました。本イベントには、ZOZOの技術顧問でもある岸川氏も登壇しました。 登壇内容 まとめ ZOZO、Mobility Technologies、Sansanよりそれぞれ1名ずつ、合計3名がLTで登壇し、ZOZO 技術顧問の岸川 克己氏が特別講演を、その後パネルディスカッションも実施されました。 LT1「CompositionalLayoutは銀の弾丸となるのか!?実際に導入してみて得た知見、全て公開しちゃいます」 (ZOZO / 小松 悟) LT2「機械的なコーディングの自動化」 (Mobility Technologies / 今入 庸介) LT3「【TCA】書きやすくて分かりやすい!Reducerのテストの基本」 (Sansan / 池端 貴恵) 特別講演「GitHub Actionsでテストの結果をわかりやすく表示する」 (ZOZO 技術顧問 / 岸川 克己) パネルディスカッション (モデレーター: Mobility Technologies / 日浅 貴啓、パネラー: ZOZO / 坂倉 勉・Mobility Technologies /古屋 広二・Sansan / 相川 健太) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
はじめに こんにちは、ZOZOTOWN開発本部でZOZOTOWN iOSアプリを開発している松井です。 ZOZOでは、お客様がファッションを心から楽しむことができるよう、日々さまざまな新規案件に取り組んでいます。最近では、ZOZOTOWNの全体的な大幅リニューアルや、 ZOZOGLASS のリリースなどが記憶に新しいのではないでしょうか。まだ試していない方はぜひ使ってみてください! 新規の開発案件だけではなく、お客様が使いやすいアプリをリリースし続けていくために取り組むべきことがあります。それは既存のアプリを見直し、お客様の声に耳を傾けたり、数値を見たりしてより良くしていく活動です。 その活動の一環として、毎週行われるアプリ部の定例で誰でも改善提案をできる時間がとられています。提案書のフォーマットも用意されており、社員の意見を積極的に取り入れようとする仕組みが整っています。そのため、社員の意見をスピーディに上へあげることが可能です。多くの社員がZOZOTOWNの改善に取り組んでいる中で、iOSチームでも「改善できるところを見つけ、提案・開発・計測をしていくプロジェクト」を実践しています。 本記事では、そのプロジェクトにおいて限られたリソースの中でどのように課題を見極め改善提案を行っているか、その工夫点と成果をお伝えします。 ボトムアップで改善提案を行う理由 いきなりですが、エンジニアが売上貢献するにはどんな道があるでしょうか。まず最初に思いつくのは、会社の企画部署や上層部が意思決定した新規開発をガンガン進めるという道です。しかし、それ以外にもエンジニアが売上貢献できる道はありそうです。 大きなインパクトを与える新規案件の開発だけに集中してしまうと、お客様がアプリを使っていて感じる不満や改善点が取りこぼされていきます。しかし、iOSチームという小さなスコープで取り組むことによって、そうした小さな部分にも目を向けることができます。わたしたちは数値データだけでなく、お客様からの問い合わせの声にもひとつひとつ目を通しています。そこから課題を見つけ、改善提案をすることでお客様により良い体験を提供するよう努めています。そのような小さな改善の積み上げによってお客様の体験を向上させることは、ZOZOTOWNに愛着を持って長く使い続けてもらうことに繋がり、結果として売上にも貢献すると考えています。 小さな改善への取り組み方 実際に、上記の流れで取り組んでいます。 課題を明確にし、原因を分析、データを元に仮説を立てて、提案する。 特に目新しいことはしていないことに気付くでしょう。流れとしては一般的ですが、その中でも「課題の深掘り」と「調査結果を元に仮説を立てる」に特に注力しています。 課題の深掘り 課題が発見されたら、すぐに解決策のアイデアを出したくなります。本記事に興味のある読者の方は、特に改善というキーワードに感度が高く、賛同いただける方も多いでしょう。普段アプリを使っていて「ここ改善したいな」と思うところは容易にいくつも思いつくのではないでしょうか。わたしたちもそうでした。しかし、それを片っ端から改善していく訳にもいきません。わたしたちは「改善提案専門部隊」ではありません。他の案件も並行で進めながら、サービスに寄与する効果をあげる必要があります。課題を思いついたときに、それが本質的な課題なのか深掘ることで、限られたリソースで最大の効果を発揮できる選択が可能になります。 1つ具体例を交えてお話します。「お気に入り画面にカートボタンを置く」という改善提案についてです。なお、この施策は既にリリースされ、数値の改善も見られたため、詳細は事例紹介として後述します。 改善前は、お気に入り画面で、特定の商品の値段部分をタップするとその商品をカートに投入できました。しかし、「値段をタップしたら商品がカートに投入される」という挙動は、お客様が想定することは難しいでしょう。多くのお客様は、その挙動に気付くことができません。「お気に入り画面から直接カートに入れる機能が気付きにくい」という課題が出てきたことで、「じゃあ、気付かれるようにしよう!」というアプローチのアイデアを出したくなります。しかし、そのアプローチは本当に正しいのでしょうか。もしかしたら、そのアプローチが間違っていることもあるでしょう。実はこの機能自体に需要がなく、そもそも無くしても良いものかもしれません。その場合、気付かれるようにしたところでユーザー体験は向上しません。改善案のリリース後、確度高く結果を得るためにも、実装前に一度思いついた課題を深掘ることを仕組み化することが重要です。 そこで、「そもそもこの機能に気付いて使っているお客様はどれくらいいるのか」「機能自体、本当に必要なのか」「どの画面からのカート投入率が高いのか」を、Google Analyticsを使って集計しました。必要な数値が収集できていない場合は、A/Bテストを実施するのもひとつの手です。その結果、想定よりも使っているお客様が多く、必要な機能だということがわかりました。しかし、そもそも気付きにくいという課題は依然として残るので、「わかりやすくする」ことで必要なお客様にもっと使っていただけるであろうという仮説のもと、「カートに入れる」と明記する提案をしました。 なお、冒頭で「改善提案をすることで売上貢献する」という話をしました。「この改善は他の画面からのカート投入率を奪うものであって、本質的にはカート投入率が増える改善ではないのでは」と思った方もいるかもしれません。この点は、ユーザビリティを良くしていくことにより、「ZOZOTOWNは使いやすいからまた使おう」と思ってもらえる機会を増やしていき、結果として売上拡大に貢献できるような仕組みです。 調査結果を元に仮説を立てる 「仮説を立てる」ことにこだわることで、説得力が高まり、アイデアが案件として採用されやすくなります。また、仮説を立てられるかどうかによって、取り組むべき課題のフィルター効果が期待できます。仮説を立てようとすると、その仮説を担保するだけの根拠が必要になります。そのため、提案に説得力が生まれ、仮説を検証するための有効な課題解決策がおのずと湧いてきます。逆に仮説が立てられないのであれば、設定している課題が間違っている可能性があります。その場合は、課題の深掘りサイクルに戻り、本質的な課題は何であったかを見直します。改善提案だけに時間を割ける環境ではないことがほとんどだと思うので、少ない時間で最大限の改善をするためにも、「仮説を立てることができるか」という視点を大事にしています。 チーム内レビュー 提案書を作成したあとに、iOSチーム内でレビューの時間を必ず設けています。この段階で根拠が足りていなかった点や他に解決できそうなアイデアがないか、など意見をもらっています。上に提案を持っていく前に複数人の目を通すことで、新たな気付きを得られることも多く、より筋の通った改善提案にブラッシュアップできます。 取り組みの流れは以上です。エンジニアからも売上に貢献することは充分に可能ですし、エンジニアの視点だからこそ気付く部分や、出てくるアイデアもあります。改善提案を行う上で、少しでも参考になる部分があれば幸いです。 事例紹介 さいごに、上記の取り組みを全て改善提案のフローに適用し、リリースまで行った案件をご紹介します。他にもいくつもの提案がありますが、本記事では2つ具体例として挙げます。 お気に入り画面にカートボタンを置くことでカート投入率が向上 カートボタンの設置に伴い、値段部分のタップでカートに入れられる導線は削除しています。お気に入り画面からのカート投入率の計測によって、検証をしました。その結果、リリース直後から数値が向上したことから、この機能の存在に気付き、使っていただけるお客様が増えたと言えます。 ホーム画面のモジュールを2段表示にすることで商品詳細画面への遷移率が向上 ホーム画面から商品詳細画面への遷移率を向上させるための改善です。 商品詳細画面への遷移率を上げるということは、お客様に商品のお気に入りや、カートへ入れてもらう機会を増やすことに直結します。例えば実店舗だとしても、まず服を手にとって見てもらうことが大事になってきますよね。こちらはA/Bテストでの検証の結果、正式にリリースすることとなりました。Androidアプリでは先行して既にリリースされており、iOSアプリも現在開発中です。 # おわりに 本記事では、iOSエンジニアが主体的に行っている改善提案のフローをご紹介しました。こうした改善にも興味あるよという方は、ぜひ面談してみませんか。ZOZOでは、これからもこうした活動を通してお客様により良い体験を提供し、売上の最大化を目指すことでサービスにとってプラスとなるよう努めていきます。 ZOZOではiOSエンジニアを大募集中ですのでご興味のある方はこちらからご応募ください。 hrmos.co
アバター
はじめに こんにちは、SRE部 ECプラットフォーム基盤SREブロックの亀井です。 ZOZOTOWNのマイクロサービスプラットフォーム基盤(以下、プラットフォーム基盤)ではサービス間通信におけるトラフィック制御・カナリアリリース実装のため、 Istio によるサービスメッシュを導入しました。現在は初期段階としてBFF機能を司るZOZO Aggregation APIとその通信先サービス間へ部分的に導入しています。 ZOZO Aggregation APIについては、以前に三神が紹介しているので、そちらの記事をご参照ください。 techblog.zozo.com その後、Istioによる一貫したトラフィック制御・カナリアリリース実装を目的とし、プラットフォーム基盤全体へサービスメッシュを拡大しました。本記事ではその取り組みを紹介します。 なお、本記事はプロダクション運用中サービスのサービスメッシュ移行という運用目線の内容です。Istioの概要や選定理由などサービスメッシュ導入の背景にご興味がある方は、以前川崎が執筆した記事をご参照ください。 techblog.zozo.com はじめに サービスメッシュ導入後の課題 プロダクション運用中サービスのサービスメッシュ化方針 ZOZO API GatewayとIstioの責務整理と機能分担 段階的な移行 ZOZO API Gatewayサービスメッシュ化における考慮点 ZOZOTOWNへの導入効果 今後の課題 k8sクラスタを跨ぐIstioサービスメッシュの拡大 カナリアリリースの自動化 さいごに サービスメッシュ導入後の課題 ZOZO Aggregation APIと通信先サービスが部分的にサービスメッシュ化された状態を下図に示します。 「ZOZO Aggregation API → サービス」間はサービスメッシュ化され、Istioによるトラフィック制御・カナリアリリースが実装されました。しかし、プラットフォーム基盤全体ではサービスメッシュの導入は部分的であり、下図の様にサービスによってトラフィック制御・カナリアリリース手法に差異が生まれていました。サービスによって「設定が異なる」または「複数の設定を持つ」状態となっており、運用負荷が高く、二重にリトライが行われるなどの設定不備によるミスが起きやすい状況にありました。 この状況の解消に向け、プラットフォーム基盤全体へサービスメッシュを拡大し、Istioによる一貫したトラフィック制御・カナリアリリース実装の展開を進めました。 プロダクション運用中サービスのサービスメッシュ化方針 プロダクション運用中サービスのサービスメッシュ化では、大きく以下の2点を実施しました。 ZOZO API GatewayとIstioの責務整理と機能分担 段階的な移行 以降で、具体的な内容を順に説明していきます。 ZOZO API GatewayとIstioの責務整理と機能分担 ZOZOTOWNは ストラングラーパターン でレガシシステムの段階的なリプレイスを行っています。ZOZO API Gatewayは、この中でストラングラーファサードという役割を担っており、ルーティングや認証、トラフィック制御などの機能を持つリバースプロキシとして動作しています。なお、ZOZO API Gatewayは、独自要件に対し柔軟に対応出来るよう独自実装しています。 詳細は、旗野の記事をご参照ください。 techblog.zozo.com 一方、 Istio はトラフィック制御、セキュリティ、可観測性の機能を持ちます。つまり、ZOZO API GatewayとIstioでタイムアウト・リトライなどのトラフィック制御機能が重複しています。そこで、ZOZO API GatewayとIstioの責務を明確にし、重複する機能を分担する必要がありました。 まず、下図の様に責務を整理しました。 (画像が小さい場合は 拡大 してご覧ください) そして、下図の様に機能を分担しました。 このような責務整理と機能分担の結果、プラットフォーム基盤全体に対し、サービスメッシュの拡大を滞りなく進める事が出来ました。 段階的な移行 ZOZOTOWNを停止させずにサービスメッシュへ移行するため、下記の様に段階的な移行方針を取りました。 優先度の高いサービスから段階的にサービスメッシュ化 ZOZO API Gateway その他マイクロサービス 無停止を前提としたサービス単位でカナリアリリース 一斉にプラットフォーム基盤全体をサービスメッシュ化せず、優先度の高いサービスから下図の様に10%、100%とカナリアリリースし、無停止で移行しました。 ZOZO API Gatewayサービスメッシュ化における考慮点 ZOZO API Gatewayは責務整理と機能分担の他にも考慮した点があります。みなさまの参考になるであろう、大きな考慮点なので、その内容をご紹介します。 「ALB → ZOZO API Gateway」のトラフィックはサービスメッシュ外から中への通信(Ingress Traffic)です。Ingress Trafficにおいても、サービスメッシュ間のトラフィック同様にIstioによる一貫したトラフィック制御が求められていました。 そこで、 IngressGateway を使う事で上記の課題を解決しました。サービスメッシュの境界にIngressGateway(実態はistio-proxy)を追加する事で、Ingress TrafficもIstioによるトラフィック制御が可能となります。 なお、k8sマニフェストは下記の通りです。 IstioOperator にてIngressGatewayコンポーネントを作成し、ZOZO API Gateway用のIstioカスタムリソースを設定します。 apiVersion : install.istio.io/v1alpha1 kind : IstioOperator metadata : namespace : istio-system name : istio-control-plane spec : components : ingressGateways : # IngressGatewayコンポーネントを追加 - name : ingressgateway --- apiVersion : networking.istio.io/v1alpha3 kind : Gateway metadata : name : gateway spec : selector : istio : ingressgateway servers : - hosts : - zozo-api-gateway.example.com port : name : http number : 80 protocol : HTTP --- apiVersion : networking.istio.io/v1alpha3 kind : VirtualService metadata : name : virtualservice spec : gateways : - gateway hosts : - zozo-api-gateway.example.com http : - route : - destination : host : zozo-api-gateway.ns.svc.cluster.local subset : primary weight : 100 - destination : host : zozo-api-gateway.ns.svc.cluster.local subset : canary weight : 0 timeout : 10s --- apiVersion : networking.istio.io/v1alpha3 kind : DestinationRule metadata : name : destinationrule spec : host : zozo-api-gateway.ns.svc.cluster.local subsets : - name : primary labels : version : primary - name : canary labels : version : canary Amazon EKS上にIngressGatewayをデプロイすると、デフォルトではClassic Load Balancer(CLB)が作成され、サービスが外部に公開されます。しかし、ZOZOTOWNではセキュリティ要件により、AWS WAFのアタッチされたApplication Load Balancer(ALB)を使っています。そのため、サービスメッシュ化も同様のセキュリティレベルを保つため、下図の様にIngressGatewayはCLBで公開せず、既存のALB配下で公開する構成にしました。 そして、CLBはセキュリティホールとなり得るため、削除しています。下記の様にIstioOperatorのIngressGatewayコンポーネントを設定する事でCLBを作成しない事が可能です。 apiVersion : install.istio.io/v1alpha1 kind : IstioOperator metadata : namespace : istio-system name : istio-control-plane spec : components : ingressGateways : - name : ingressgateway k8s : service : type : NodePort # CLBを作成しない ZOZOTOWNへの導入効果 ZOZO API GatewayとIstioの責務整理と機能分担を行い、サービス単位での段階的な移行をしました。その結果、ZOZOTOWNを停止することなく、下図の様にプラットフォーム基盤全体をサービスメッシュ化することが出来ました。 そして、プラットフォーム基盤全体がサービスメッシュ化された事で下記の様な事が可能となっています。 一貫したトラフィック制御 カナリアリリース手法の統一 基盤全体でのIstio活用 2つ目に挙げた「カナリアリリース手法の統一」は、ZOZO API Gatewayの場合、下図の様に変更されIstioによる加重ルーティングを用いてカナリアリリースが可能になりました。 次に、Istio Virtual Service、Destination Ruleリソースのマニフェスト設定例を紹介します。 まず、Destination Ruleでsubsetにprimary、canaryを登録します。合わせて、Virtual Serviceのroute部分に先程のsubsetを指定し宛先を登録します。そして、 weight を更新してクラスタに適応すると、istiodにより自動的にistio-proxyのconfigが更新され、ZOZO API Gatewayへのトラフィック加重率が変更されます。 apiVersion : networking.istio.io/v1alpha3 kind : VirtualService metadata : name : virtualservice spec : hosts : - zozo-api-gateway.example.com gateways : - ingressgateway http : - route : - destination : host : zozo-api-gateway.ns.svc.cluster.local subset : primary weight : 90 - destination : host : zozo-api-gateway.ns.svc.cluster.local subset : canary weight : 10 retries : attempts : 1 perTryTimeout : 3s retryOn : 5xx timeout : 6s --- apiVersion : networking.istio.io/v1alpha3 kind : DestinationRule metadata : name : destinationrule spec : host : zozo-api-gateway.ns.svc.cluster.local subsets : - name : primary labels : version : zozo-api-gateway - name : canary labels : version : zozo-api-gateway-canary 以上の流れで、プラットフォーム基盤全体のカナリアリリース手法が上記に統一されました。 また、基盤全体でIstioの活用も行っており、直近ではサーキットブレーカーを導入しマイクロサービスの連鎖障害に備える取り組みを行いました。詳細は大澤の記事で解説しているので、併せてご参照ください。 techblog.zozo.com 今後の課題 さらなる改善のため、大きく下記2つの課題に取り組んでいく予定です。 k8sクラスタを跨ぐIstioサービスメッシュの拡大 カナリアリリースの自動化 k8sクラスタを跨ぐIstioサービスメッシュの拡大 ECプラットフォーム基盤SREブロックでは、認証サービス基盤というもう1つの基盤・k8sクラスタが存在します。個人情報などのセキュリティ要件の高い情報を取り扱うサービスが稼働する基盤です。プラットフォーム基盤から認証サービス基盤間の通信は現状サービスメッシュ化出来ておらず、下図の様にZOZO API Gatewayによるトラフィック制御が行われています。 k8sクラスタを跨ぐサービスメッシュの構築を今後の課題としています。 カナリアリリースの自動化 プラットフォーム基盤全体のサービスメッシュ化により、障害を軽減し無停止で進行するカナリアリリース手法が統一されました。しかし、カナリアリリースの進行における判断コストや加重ルーティングを進行、もしくは切り戻す設定変更コストは依然高い状況にあります。一方、カナリアリリース手法が統一されたことで、判断の自動化・設定変更の自動化がしやすくなりました。そこで、Progressive Deliveryの導入など更なるリリーススピードの向上、運用負荷の削減も今後の課題としています。 さいごに ZOZOでは、一緒にサービスを作り上げてくれるSREエンジニアを募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 https://hrmos.co/pages/zozo/jobs/0000010 hrmos.co
アバター
こんにちは、ZOZOTOWN開発本部の名取( @ahiru___z )と計測プラットフォーム開発本部の寺田( @tama_Ud )です。先日、9/17から9/19までの3日間に渡って iOSDC Japan 2021 が開催されました。例年通り素晴らしい発表が盛り沢山でしたね! iosdc.jp 昨年に引き続きオンライン開催となりましたが、Discordを使ったAsk the Speakerやニコニコ動画の弾幕などリアルタイム性のあるコミュニケーション手段が今年も充実しており非常に楽しく学びの多い3日間となりました。今年はアンカンファレンスという新しい取り組みもあり、例年以上に楽しいイベントだったと実感しています。 弊社は今年もスポンサーとして協賛し、7名のエンジニアがスピーカーとして登壇、2名のエンジニアが原稿を寄稿いたしました。本記事ではiOSDC Japan 2021で登壇・寄稿した弊社エンジニアの発表内容を、登壇者・寄稿者のコメントを添えてご紹介します。 登壇内容の紹介 「A Swift Stack Overflow」 @kapsy1312 のレギュラートークです。 スタックメモリの基本とスタックオーバーフロー現象に関する解説です。特に、 Swiftのスタックオーバーフローとその回避仕方を紹介しています。 スタックメモリは自動的に管理され、Swiftは主にヒープに割り当てられたオブジェクトを使用するため、ほとんどのプログラマはスタックメモリに関する深い知識が必要ではありません。しかし、C言語のコードと連携する場合には、スタックメモリの落とし穴がいくつか存在しているので、学ぶ価値があります。 登壇では、スタックとは何かを説明し、clangとswiftcが出力したスタックの簡単な例の紹介、Swiftでの悪いスタック使用例の紹介、スタックの問題をデバッグして回避する方法を解説しています。 fortee.jp 「iOSアプリ開発に入門して、いきなりUnity as a Libraryに挑戦してわかったこと。」 @i_kinopee のレギュラートークです。 Unity as a Libraryを活用し、3Dシミュレーションを組み込んだiOSアプリ開発に挑戦した際の内容です。 今回iOSアプリ開発自体も初挑戦であったため、学習方法や初心者でもUnity as a Libraryに挑戦可能であることをお伝えします。実際には、先人のおかげでUnity as a Libraryに関する日本語記事が充実していることもあり、大きな問題もなく実装を進められています。 ARを開発する際にも便利なUnity as a Library、ぜひ皆さんも試してみてはいかがでしょうか。 fortee.jp 「iOSではじめるWebAR 2021」 @ikkou のレギュラートークです。 昨年に引き続き、iOSにおけるWebARの最新動向を駆け足20分で紹介しました。 今年はARや関連するLiDARに触れるトークが去年よりも多かった印象ですが、WebでARというテーマは今年もまだまだニッチでした。だからこそ、日本国内のiOSエンジニアが多く集まるiOSDC Japanという場でそれを伝える意義があると思っていますし、実際に伝える場を持てて良かったです。 当日ご覧いただけなかった方は、是非スライドを覗いてWebにおけるARの現状を知ってもらえると幸いです。 fortee.jp 「未知のファイル形式をCodableで読み書きするのに役立つテクニック 『Apple Watchの文字盤ファイル』」 @banjun のレギュラートークです。 Apple Watchの文字盤ファイルを題材に、未知のファイルを解析してCodableとNSFileWrapperでMacアプリのビューアーを作っていき、そのなかで出てきた普通とは言えないCodableの対処テクニックを紹介しました。 Apple Watchユーザーではない人も聴きに来てくれたようです。Ask the Speakerでは普段使っている文字盤のタイプを教えてもらったりしましたが、やはり写真・インフォグラフ・Siriあたりが人気のようでした。Appleの言う多彩な文字盤の良さを見つけるためにも、他の文字盤を使っている人の話も聞いてみたいと思いました。 fortee.jp 「再現ができない?特定ができない?ZOZOTOWNアプリのトップクラッシュに立ち向かった話」 @chichilam86 のLTです。 メモリ不足によるクラッシュは直接の原因がログやスタックトレースに現れないので再現が容易でなく、特定困難です。そのため、リニューアル後のZOZOTOWNアプリでのトップクラッシュに対して、メモリ不足の仮説と原因の解析から検証までの流れを紹介しました。 同じ悩みをお持ちの方の参考になれば幸いです。 fortee.jp 「あなたの知らないSafariのExperimental Featuresの世界」 @ikkou のLTです。 わりとマニアックだと思っているSafariの設定、しかも普段使う分には触る必要のないExperimental Featuresについて5分でお伝えしました。 どちらかと言うとLT芸ではなく、ガチで時間いっぱいお伝えする内容でしたが、反応を伺っていると「知らなかった!」の声もあり、少なからず伝えたいことを届けられて良かったです。 fortee.jp 「作ってわかる!LiDARによるカメラの暗所オートフォーカス機能」 @tama_Ud のレギュラートークです。 2020年3月発売のiPadから搭載されたLiDARスキャナですが、AR領域で特に注目を浴びていますね。しかし、今回はARでなくLiDARのAF機能補助について焦点を当ててお話しています。 AF機能をLiDARを使って実装してみることで、LiDARを使ったアプリ開発への理解が深まれば幸いです。 fortee.jp 「SceneKitを使ってアプリのクオリティを劇的に上げる」 @ahiru___z のレギュラートークです。 UIKitだけでは実現が難しいリッチな表現を、SceneKitを使って実装する方法を紹介しました。 SceneKitは3Dコンテンツを扱うアプリ開発でのみ使用するFrameworkと思われがちですが、実際にはそんなことはありません。UIKitとの親和性は高く、使い方や概念を適切に理解することで非常に強力な武器となり得ます。私自身、個人開発でよく使用するFrameworkの1つでもあります。 興味を持った方、ぜひSceneKitを触ってみてください。 fortee.jp 「ほんの一瞬だけでもConcurrencyの計算理論に触れてみませんか?」 @banjun のLTです。 並行計算のモデルのひとつであるCCSについて、その入口を紹介しました。 おそらく事前知識のある人がほとんどいない分野だったのではないかと思いますが、その分、多くの方に概念の存在だけでも知ってもらえたら幸いです。今年のLTは、大学の講義よりも、さらに多くの人に並行計算を伝えられる最強の場だったのかもしれません。 誰も来ないことも覚悟していたAsk the Speakerですが、このLTのきっかけや参考文献の話をしたり、「コルーチンとインターリーブは似ているのでは?」「π計算との違いは?」など、ディープな話もでき、このネタでLTしておいて良かったと感じました。 fortee.jp 寄稿内容の紹介 「誰も知らないASO(App Store Optimization)の話」 @ahiru___z の寄稿です。 ASOに関する原稿を寄稿しました。 個人開発のアプリで累計200万DL以上を達成しました。その過程で行った自身の取り組みを中心に、まずは手軽に誰でも始めることができる基本的な手法をまとめています。ASOは一朝一夕に効果が現れるものではありませんが地道な取り組みによってある程度の効果を得ることは間違いなく可能です。 応用的なテクニックはまた別の機会にまとめたいと思います。 拡大 「CodableでJSONのNullを出力するためのTips」 hirotakanの寄稿です。 Codable + Property WrapperのTipsを2ページの原稿で紹介しました。 実務の参考や、Codableの理解に繋がれば幸いです。今回初めての寄稿でしたが、2ページは他の募集に比べるとハードルが低いので、チャレンジしてみたいけどなかなか踏み出せない方におすすめです。 拡大 CfPネタ出し会 & レビュー会 弊社では毎年自由参加でiOSDC JapanのCfPネタ出し会 & レビュー会をiOSエンジニア同士で行っています。 昨年はネタ表を利用したCfPネタの整理 を実施していましたが、今年はDiscord上でネタ出し会を行いました。既にネタがある人は発表してコメントをもらい、何を発表しようか迷っている人は参加者との会話の中でネタを引き出してもらうスタイルで進行しました。6月の段階でネタ出し会を行ったため、早い段階でネタを固めることができました。 技術顧問の岸川さんには例年CfP採択後のレビュー会に参加していただいていました。しかし、今年はCfP採択前に行うレビュー会の段階で参加していただき、CfPの書き方はもちろん、どうすれば自分の発表したい内容がより適切かつ魅力的に伝わるのかなどを教えていただきました。その結果、例年以上に実りの多いレビュー会となりました。 弊社は複数の事業ドメインを有しているため、一括りに「iOSエンジニア」と言ってもそれぞれ強い分野や興味のある分野が異なります。そのためレビュー会では様々な分野の話を聞くことができてとても楽しかったです。 少し余談となりますが、最近では岸川さんとの1on1も積極的に行っており、エンジニアとしてスキルアップしていく環境が十分に整備されています。 また、弊社ではカンファレンスへの参加は業務として扱われるため、iOSDC Japanには休日出勤という形で参加しました。今年は内定者アルバイトの方も複数名イベントへ参加しましたが、社員と同様にチケット代は経費となりイベントへの参加は業務時間として扱われています。 ZOZOではiOSエンジニアを大募集中ですのでご興味のある方はこちらからご応募ください。 hrmos.co After iOSDC Japan 2021を今年も開催 iOSDC Japanのアツい3日間が過ぎ、レポート記事を書いたので今年のiOSDC Japanは終了! ……ではありません。 今年もラップアップイベントにあたるAfter iOSDC Japan 2021をZOZO、Mobility Technologies、Sansanの3社の合同で開催します。 各社の社員によるLT、パネルディスカッションを行いますので、興味のある方はぜひご参加ください。 こんな方におすすめです。 iOSに関わるソフトウェアエンジニア iOSDCを一緒に振り返りたい方 iOSDCには参加しなかったけど、情報が知りたいという方 なお、本イベントにはZOZOの技術顧問でもある岸川さんも登壇します。 iOSDC Japan 2021に参加した方もそうでない方も、みんなで振り返りましょう。イベント申し込みは以下のページからお願いします。 zozotech-inc.connpass.com
アバター
こんにちは、ZOZO CTOブロックの池田( @ikenyal )です。 本日、10/1に株式会社ZOZOテクノロジーズは組織再編が行われ、株式会社ZOZO及び株式会社ZOZO NEXTとして再始動します。それに伴い、ご愛読いただいていた「ZOZO Technologies TECH BLOG」も「ZOZO TECH BLOG」に名称変更をします。 発信内容はこれまでと変わらず、ZOZOのエンジニアが有益な技術情報をお届けしますので、引き続きご愛読よろしくお願いします。
アバター
はじめに こんにちは。メディアプラットフォーム本部 WEAR部 WEAR-SREの笹沢( @sasamuku )です。 ZOZOが新しく展開する「FAANS」というショップスタッフ向けアプリをクローズドβ版としてテスト運用しています。本アプリは、 WEAR と連携したコーディネート投稿や、その成果を可視化する機能などをショップスタッフの皆さんに提供するtoBのソリューションです。現在、正式リリースに向け開発を進めています。 そして、FAANSのAPIはCloud Runと呼ばれるサーバレスなコンテナ実行基盤で稼働しています。本記事では、FAANSの実行基盤としてCloud Runを選定した理由や、構築・運用するためにSREとして取り組んだことをご紹介します。 Cloud Runを選んだ理由 まず、クラウドサービスはGCPを選択しています。FAANSでは開発速度の向上と運用負荷の軽減のため、認証やメッセージング、Webホスティングの機能にFirebaseを採用することにしました。そのため、クラウドサービスとしてもGCPを選択することが開発やコスト管理の面で最も妥当な判断でした。 FAANSの実行基盤に求められる要件には、大きく以下のものがあります。そのため、これらを満たすサービスをGCPの中から選定しました。 管理が容易なサーバレスプラットフォームであること Goのバージョン1.16をサポートしていること まず、「1.」を満たすサービスとして、Google App Engine, Cloud Functions, Cloud Run, GKEが挙げられました。さらに、2021年6月の選定時点で「2.」を満たせるものに絞ると、Cloud RunとGKEの2つが選択肢に残りました。 Cloud Runは 一部の制約 を満たせば、任意のプログラミング言語をサポートできます。また、オートスケールや従量課金、ミドルウェア管理が不要な点などのマネージドサービスとしての一般的な利点も備えています。 一方のGKEには、スケールやCI/CDの細やかな設定ができるという魅力がありました。しかし、リリースまでの期間やSREチームの規模を踏まえ、マニュフェストファイルなしで即座に利用開始できるフルマネージド版のCloud Runを選択することにしました。なお、以降で「Cloud Run」と呼称するものはフルマネージド版を指します。 実際にCloud Runを利用してみると、そのシンプルさに驚きました。サービスを作成しコンテナをデプロイするだけで、URLの発行と証明書取得が自動で行われ、ものの数分でHTTPS通信を開始できます。 しかし、Cloud Run単体ではWAFを導入できない、Datadog APMを設定できないなどの制約事項もありますので事前調査が大切です。 サービスを運用していくための取り組み 次に、Cloud Runでサービス運用していく上で行っている、SREとしての取り組みをいくつかご紹介します。Cloud Run特有の課題とその対応についても触れているので、Cloud Runでこれからサービスを公開したい方の参考になれば幸いです。 アーキテクチャ概観 アーキテクチャの概観は、一部検証フェーズの構成も含まれますが下図の通りです。 アプリケーションは全てCloud Runで稼働しています。処理時間の長い一部のデータベース更新は、レスポンス時間短縮のためにCloud Tasksへオフロードしています。また、社内の別システムからのイベントを取得するために、Cloud Pub/Subを用意して疎結合になるよう連携しています。 IaCへの取り組み FAANSではクラウドサービスにGCP、監視にDatadog、オンコール通知にPagerDutyを利用しており、それらのほぼ全てをTerraformで管理しています。これにより、共通の手続きで異なるサービスの構成を管理できるようにしています。その他にも、変更管理やコードレビューなどのIaCで一般的なメリットも享受しています。 Terraformの公式ドキュメント は簡潔で、すぐ実践できます。しかし、トラブルシューティング関連の記載が少なく、問題発生時にはこのドキュメントだけで対応することが難しいという一面もあります。その点に関しては、既に利用している第三者の情報を参考にして解決できることもあるので、本記事もそのような有益な情報になるよう、密かに期待しています。 Cloud Runにおいても、構築段階でうまくいかない場面が多々ありました。ここでは、抜粋したtfファイルを元に、特に注意しておくべき点をお伝えします。 resource "google_cloud_run_service" "default" { provider = google-beta # secret key を扱うため (2021/9/16時点) name = "cloudrun-srv-${var.env}" location = "asia-northeast1" template { spec { containers { image = var.docker_image env { name = "STAGE" value = var.env } env { name = "KEY" value_from { secret_key_ref { name = google_secret_manager_secret.key.secret_id key = "latest" } } } } service_account_name = google_service_account.default.email } metadata { annotations = { "run.googleapis.com/vpc-access-connector" = "${google_vpc_access_connector.serverless.name}" "run.googleapis.com/vpc-access-egress" = "all-traffic" "autoscaling.knative.dev/maxScale" = "100" } } } metadata { annotations = { generated-by = "magic-modules" "run.googleapis.com/launch-stage" = "BETA" "run.googleapis.com/ingress" = "all" } } autogenerate_revision_name = true traffic { percent = 100 latest_revision = true } lifecycle { ignore_changes = [ template [ 0 ] .metadata [ 0 ] .annotations [ "run.googleapis.com/client-version" ] , template [ 0 ] .metadata [ 0 ] .annotations [ "client.knative.dev/user-image" ] , template [ 0 ] .metadata [ 0 ] .annotations [ "run.googleapis.com/client-name" ] , template [ 0 ] .metadata [ 0 ] .annotations [ "run.googleapis.com/sandbox" ] , metadata [ 0 ] .annotations [ "client.knative.dev/user-image" ] , metadata [ 0 ] .annotations [ "run.googleapis.com/client-name" ] , metadata [ 0 ] .annotations [ "run.googleapis.com/client-version" ] ] } } まず、 autogenerate_revision_name フィールドを true に設定することは、ほぼ必須です。これは、リビジョン名をTerraformで管理せず、GCPで発行させるための設定です。 false あるいは設定されていない状態だと、同一のリビジョン名が発行されてコンフリクトが発生し、リビジョンが作成できません。詳細は こちら で詳しく説明されています。 また、 lifecycle ブロックを利用して特定のannotationに対する更新を無視するよう設定しています。Terraform管理外からリビジョンを作成した場合、例えばgcloudコマンドで作成した場合に、一部のannotationが自動的に変更または作成されます。すると、実際の状態とtfstateとの差分が生じるため、 terraform plan の出力が煩雑になるという問題が生じます。なるべく意味のある変更差分のみを表示させたいと考えたため、今回はこのような対策を施しました。しかし、重要なannotationに対して適用しないよう注意が必要です。例えば、Cloud Runサービスと後述するサーバレスVPCアクセスコネクタとの紐付けはannotationを使って指定しています。 その他の内容は、 公式ドキュメント を参照ください。 監視への取り組み 私達のチームでは主な監視ツールとしてDatadogを利用していますが、監視においてもCloud Run特有の課題がありましたので、対応策を含めご紹介します。 その課題とは、「Cloud RunはDatadog APMをサポートしていないこと」でした。なお、最新のサポート状況は こちら をご確認ください。 Datadog APMはライブラリを組み込んでAgentを構成することで、アプリケーションからインフラまでの一貫した監視を可能にするツールです。アプリケーションエラーやレイテンシはもちろん、リクエスト処理状況をクエリ単位で可視化できるなど豊富な機能を提供していたため、チームでは積極的に活用し障害対応や改善業務に役立てていました。 Datadog APMに非対応な点は残念でしたが、リクエスト数やエラー数などの基本的なメトリクスはDatadog Integrationsで取得できていました。さらに、アプリケーションエラーはSentryで取得できていたため、直近で大きな問題にはならないと判断し、他の方法でDatadog APMが提供する指標を補完できないか検討しました。 その結果、エンドポイント毎のレイテンシはCloud RunのリクエストログをDatadogに転送し、それをメトリクス化することで可視化できると分かりました。今後のサービス拡大に向けSLI/SLOを策定したい背景もあり、エンドポイント毎のレイテンシはぜひ取得したいというモチベーションがありました。なお、サービス全体のレイテンシだと特定のリクエストにおけるレスポンス遅延を検知できない可能性があるので注意が必要です。 以下では、Cloud Runのエンドポイント毎のレイテンシをDatadogでメトリクス化する手順をご説明します。 Cloud Runはリクエストログを自動的にCloud Loggingに転送しています。リクエストログには、エンドポイントのパスやレイテンシが格納されているため、Datadogでメトリクス化することによりダッシュボードでの閲覧が可能となります。 まず、下図のようにCloud Pub/Subを使ってDatadogにリクエストログをPushします。 Datadogでログからメトリクスを作成する流れは次の通りです。 リクエストログ内 latency フィールドをnumberとして パース 詳細は 拙稿 をご参照ください パースされた latency を 定量的ファセット に登録 新しい ログベースメトリクス を作成 最終的に完成したダッシュボードが下図です。作成したメトリクスを利用し、図内の赤枠で示している通り、エンドポイント毎のレイテンシを表示させています。 CI/CDへの取り組み CI/CDにはGitHub Actionsを使用しています。コード管理、レビュー、マージ、CI/CDといったコードのライフサイクル全てをGitHubで完結できる点が非常に便利です。また、クラウドベンダー公式のActionも公開されており、今後ますます充実していくことが期待されます。 FAANSのアプリケーションは、下図のような流れでデプロイをしています。 コンテナデプロイの前に、FirestoreのIndex作成と初期データ投入をします。Cloud Runのデプロイについては こちら よりワークフローの詳細をご確認ください。 そして、GCPやDatadogのプロビジョニングにもGitHub Actionsを利用しています。Pull Requestを作成すると terraform plan の出力結果がConversationタブに表示されます。これにより、コードレビュー時にplan結果を確認でき、より安全にapplyを実行できます。 GitHub Actionsにおける terraform plan の表示方法は こちら を参照ください。 外部向きIPアドレスの固定 最後に、Cloud Runにおける外部向きIPアドレスの固定方法をご紹介します。 FAANSはAWSに構築された社内システムと通信する必要がありましたが、クラウドが異なるためピアリング接続によるプライベート通信はできませんでした。専用線での通信はコストや障害点の多さから構成が難しかったため、IPアドレス制限を設けてインターネット経由で接続する構成を選択しました。 Cloud Runの外部向きIPアドレスは動的であるため、下図のようなサーバレスVPCアクセスコネクタを用いた構成を取り、予約済みの静的アドレスを使えるようにしました。 VPC内のリソースは以下のtfファイルで定義しています。 # vpc resource "google_compute_network" "vpc-network" { name = "vpc-${var.env}" mtu = 1460 auto_create_subnetworks = false } # subnet for serverless vpc access connector resource "google_compute_subnetwork" "serverless" { name = "subnetwork-serverless-${var.env}" ip_cidr_range = "10.124.0.0/28" # VPCコネクタの制限により/28を指定 region = "asia-northeast1" network = google_compute_network.vpc-network.id } # vpc connector resource "google_vpc_access_connector" "serverless" { provider = google-beta # subnet と紐付けるため (2021/9/16時点) name = "vpc-connector-${var.env}" subnet { name = google_compute_subnetwork.serverless.name } region = google_compute_subnetwork.serverless.region } # cloud router resource "google_compute_router" "serverless" { name = "router-serverless-${var.env}" network = google_compute_network.vpc-network.name region = google_compute_subnetwork.serverless.region } # ip address resource "google_compute_address" "serverless" { count = 1 name = "ip-serverless-${var.env}" address_type = "EXTERNAL" region = google_compute_subnetwork.serverless.region } # nat resource "google_compute_router_nat" "serverless" { name = "nat-serverless-${var.env}" router = google_compute_router.serverless.name region = google_compute_subnetwork.serverless.region nat_ip_allocate_option = "MANUAL_ONLY" nat_ips = google_compute_address.serverless.*.self_link source_subnetwork_ip_ranges_to_nat = "LIST_OF_SUBNETWORKS" subnetwork { name = google_compute_subnetwork.serverless.id source_ip_ranges_to_nat = [ "ALL_IP_RANGES" ] } } Cloud Runに対しては、外部向きトラフィックの全てをサーバレスVPCアクセスコネクタ経由でルーティングするように設定します。 gcloudコマンドを利用する場合と、Terraformを利用する場合のそれぞれの設定方法はこちらです。 gcloud run deploy SERVICE_NAME \ --image=IMAGE_URL \ --vpc-connector=CONNECTOR_NAME \ --vpc-egress=all-traffic resource "google_cloud_run_service" "run" { template { metadata { annotations = { "run.googleapis.com/vpc-access-connector" = "${google_vpc_access_connector.serverless.name}" "run.googleapis.com/vpc-access-egress" = "all-traffic" } ~中略~ } ~中略~ } ~中略~ } なお、コマンドラインから構成する手順は こちら をご参照ください。 まとめ Cloud Runでサービスを構築・運用する際のSREとしての取り組みをご紹介しました。FAANSはまだテスト運用を開始してから日も浅く、取り組めていない課題も存在します。今後は、パフォーマンスチューニング、WAF導入、SLO/SLI策定などを視野に入れつつ、ユーザが快適に利用できるサービス作りに貢献していきたいです。 さいごに ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 tech.zozo.com
アバター
はじめに こんにちは。SRE部 ECプラットフォームSREチームの大澤です。 先日、SREチームにてBFF機能を司る「ZOZO Aggregation API」の導入について紹介しました。 techblog.zozo.com BFFは複数のバックエンドと通信するアーキテクチャであるため、通信先のバックエンド障害に大きな影響を受けてしまいます。そのため、ZOZO Aggregation APIでは、各バックエンド間の通信障害をIstioによるタイムアウトとリトライ制御で可用性を担保していました。 今回は、新たにIstioサーキットブレーカーを導入することで、さらなる安定性・回復性の向上を果たした取り組みを紹介します。 サーキットブレーカーとは サーキットブレーカーとは、あるサービスの障害を検知した場合には通信を遮断、その後サービスの復旧を検知すると通信を復旧させる仕組みです。 複数のマイクロサービスが連動するサービスの場合、一部のマイクロサービスの障害が連鎖的な障害に繋がるカスケード障害を発生させる可能性があります。 以下はカスケード障害の例です。 Service C が応答不能となると、 Servie B は Service C からのレスポンスを待ち続けるため、不安定な状態となります。この状況が続くと Service B が応答不能となり、連動する Service A へと障害が連鎖します。 サーキットブレーカーは、このようなマイクロサービスアーキテクチャ特有の課題に対するデザインパターンの1つです。 以下の図は、先程の例にサーキットブレーカーを導入した場合の流れです。 Service C で発生した障害を検知すると Servie B はリクエストを遮断します。リクエストを遮断することでレスポンスを待ち続ける状況やスレッドプールの枯渇を防ぎ、 Service B と連動する Service A を保護します。 サーキットブレーカーパターンの詳細については こちら を参照ください。 docs.microsoft.com ここで紹介した例は非常にシンプルなカスケード障害の場合ですが、ZOZOTOWNのプラットフォーム基盤はマイクロサービスアーキテクチャを採用しており、より複雑なサービス間連携が発生しています。そのため、連鎖による大規模な障害に発展しないよう、カスケード障害への対策の必要性が増していました。 既存のタイムアウト・リトライ制御の問題点 ZOZO Aggregation APIでは、Istioによるタイムアウト・リトライ制御設定を通信先のバックエンド毎に入れています。設定したタイムアウト・リトライ試行内でバックエンドからレスポンスが得られない場合には、それ以外のバックエンドから取得できたモジュールのみでレスポンスし、サービスを継続しています。 以下の図は、リトライでサービスを救える場合の処理の流れの例です。商品詳細API呼出処理は、リトライを含めて130msで完了しています。 この様に、すぐに復旧が見込まれる様な一時的なネットワークの瞬断などの不具合であれば、リトライ機能により適切にサービスを救うことができます。 しかし、バックエンドとの通信のエラー状況によってはタイムアウト・リトライ制御が必ずしも適切に働くわけではありません。通信先のバックエンドが不安定になって直ちにエラーが返ってこない場合、Istioによるタイムアウトまでバックエンドからのレスポンスを待つことになります。 以下の例は、Istioで10sのタイムアウト、かつ1回のリトライを設定していた場合です。最終的に商品詳細API呼出処理は20s待つことになります。ZOZO Aggregation APIとしては、該当のAPI以外のバックエンドから取得したモジュールで正常ステータスを返却できます。ただし商品詳細APIの回復までレイテンシーは増加し続けてしまいます。 この様なレイテンシーの増加を防ぐために、異常なバックエンドをサービスアウトし、ZOZO Aggregation APIからリクエストしない状態にするのが理想的です。 以下の例はサーキットブレーカーを導入した場合に期待される動作例です。商品詳細APIに障害が発生している場合、API呼出を行わずに処理を完了できます。 この様に障害を検知し、リクエストを遮断するサーキットブレーカーは有効な手段です。 サーキットブレーカーの導入方法 サーキットブレーカーを導入するには、大きく分けて以下の2つのアプローチが考えられます。 各マイクロサービスにサーキットブレーカーが実装されたライブラリを組み込むアプローチ Istioやnginxのサービスメッシュなど、ネットワーク機能として導入するアプローチ 弊社は後者のアプローチを採用しました。なぜIstioサービスメッシュによる導入を選択したのか、どのようにZOZO Aggregation APIにサーキットブレーカーを導入していったのかを本章で紹介します。 Istioサーキットブレーカーを導入した理由 サーキットブレーカーが実装されたライブラリを各マイクロサービスに組み込んでいく場合、以下の課題がありました。 マイクロサービスへの組込やアップグレードの際に、アプリケーション開発者とSRE間でコミュニケーションが多く発生し、コミュニケーションコストが増加する マイクロサービス毎に異なるアーキテクチャー・言語を採用しているため、ライブラリ・組込方法も異なり一貫性の担保が困難になる こういった点を考慮し、SREチームでは以下の理由でIstioサービスメッシュによるアプローチを選択しました。 アプリケーションコードを変更する必要がなく、インフラコードの改修のみでサーキットブレーカーの機能追加が実現でき、かつサービスメッシュ全体で一貫した制御が可能 既にマイクロサービスプラットフォーム基盤にIstioサービスメッシュを活用していたので、サーキットブレーカー導入の敷居が低い Istioサーキットブレーカーは、外れ値検出(エラー検出)だけではなく、接続要求(接続数上限など)によるサーキットブレーカーも提供しており機能要件に適していた Istioサーキットブレーカーの組込 Istioサーキットブレーカーの設定項目の理解は、サーキットブレーカー自体の振る舞いを把握しているとより容易になります。 そのため、まずはサーキットブレーカーパターンの動作原理を説明します。 サーキットブレーカーは動作原理として以下の状態を持ちます。 Closed 遮断機がOFFの状態 リモートのサービスにリクエストを要求可能となる リクエストが失敗した場合、エラー数をカウントし、エラー数が閾値に達するとOpen状態へと移行する Open 遮断機がONの状態 リモートのサービスへのリクエストは直ちに失敗となる Open状態へ遷移した時間をカウントし、時間経過カウントが閾値に達するとHalf Open状態へ移行する Half Open 障害が解決したか確認する状態 リモートのサービスに少数の限られたリクエストを要求可能となる リクエストが成功した場合にはエラーカウントをリセットしClosed状態へ、リクエストが失敗した場合にはOpen状態へと移行する また、外れ値検出によるIstioサーキットブレーカーの組込は、カスタムリソースであるDestinationRuleへOutlierDetectionを設定することで実現できます。 以下のサンプルコードは、外れ値検出による基本的なサーキットブレーカーを組込む場合の例です。 apiVersion : networking.Istio.io/v1beta1 kind : DestinationRule metadata : name : test-api spec : host : test-api.test-api.svc.cluster.local trafficPolicy : outlierDetection : consecutive5xxErrors : 10 interval : 10s baseEjectionTime : 1m OutlierDetectionの設定項目は以下の通りです。 設定項目 説明 consecutive5xxErrors Open状態に遷移する5xxエラー閾値 interval 5xxエラー検出の間隔 baseEjectionTime Open状態からHalf Open状態に移行する時間 Istioサーキットブレーカーには、上記以外にも様々な設定値が存在します。詳細はDestinationRuleの 公式リファレンス をご参照ください。 istio.io 上記のサンプルの設定では「10秒間で10回の5xxエラーを検知すると、1分間Open状態とするサーキットブレーカー」として動作します。 よって、ZOZO Aggregation APIへのサーキットブレーカー組込は、既存のDestinationRuleにOutlierDetectionを設定するのみです。ただし、サーキットブレーカーを適切に稼働させるためにはバックエンドをOpen状態へ遷移させるための閾値を決定する必要があります。 閾値の決定 閾値を決めるには、以下の2つのアプローチがあります。 クライアント側のサービス要件で閾値を決める Service A は1sに、1回のエラー発生で Service D へのリクエスト前に遮断したい Service B は1sに、2回のエラー発生で Service D へのリクエスト前に遮断したい Service C は1sに、3回のエラー発生で Service D へのリクエスト前に遮断したい リモート側のサービス要件で閾値を決める Service D は1sに、4回のエラー発生で受付けるリクエストを遮断したい 以下に示すのは、クライアント側の要件で閾値を設定する場合の例です。DestinationRuleのサンプル同様に、どのサービスからのリクエストであるのかを個別に定義する必要があります。 apiVersion : networking.Istio.io/v1beta1 kind : DestinationRule metadata : name : service-d-api spec : host : service-d-api.service-d-api.svc.cluster.local # Service AからService Dへの設定 - name : service-a-api-to-service-d-api trafficPolicy : outlierDetection : consecutive5xxErrors : 1 interval : 1s baseEjectionTime : 1m # Service BからService Dへの設定 - name : service-b-api-to-service-d-api trafficPolicy : outlierDetection : consecutive5xxErrors : 2 interval : 1s baseEjectionTime : 1m # Service CからService Dへの設定 - name : service-c-api-to-service-d-api trafficPolicy : outlierDetection : consecutive5xxErrors : 3 interval : 1s baseEjectionTime : 1m SREチームではZOZOTOWNのセールなどのイベントに合わせて随時Pod数を調節しています。仮に Service D の管理者がPod数を2倍にした場合、 Service A〜C の管理者は個別にエラー閾値を調節しなければならず、運用が複雑になります。また、本記事では省略していますが、VirtualServiceにも同様に、どのサービスからのどのサービスへのルーティングであるか個別に定義する必要があり、複雑さがさらに増します。 そのため、SREチームではリモート側のサービス要件で閾値を決定する方法を採用しました。 サーキットブレーカー導入の効果 安定性・回復性向上のために導入したサーキットブレーカーですが、そのような機能が実際に使われることなく安定してサービスが運用されることが望ましいです。 幸いにもサーキットブレーカー導入後、実際の障害によってサーキットブレーカーが発動されたことはありません。そのため、今回は開発環境に用意したmockアプリで、擬似的に障害状態を再現した事例を紹介します。 以下の図は、サーキットブレーカー導入前のアプリケーショントレーシングの結果です。バックエンドサービスAPIのタイムアウトに影響を受け、ZOZO Aggregation APIのレイテンシーが増加していることが確認できます。 一方、以下の図は、サーキットブレーカー導入後のアプリケーショントレーシングの結果です。サーキットブレーカーによりバックエンドサービスAPIへのリクエストが直ちに遮断されていることが確認できます。また、サーキットブレーカー導入前にはバックエンドサービスAPIのパフォーマンス劣化の影響を受け、9.08sで返却していたレスポンスタイムが136msへ改善していることも確認できます。 サーキットブレーカー導入後の課題 サーキットブレーカーを導入したことにより回復性は高まりました。しかし、バックエンドが障害から復旧したと判断する時間設定によっては、サービス復旧までにタイムラグが生じてしまいます。ユーザ体験を損なわないためにも、「全てのモジュール情報が揃った正しいレスポンス」をタイムラグなく返却することが重要です。障害のパターンは様々なため、運用しながら最適値を見極めていく必要があります。 また、現状はサーキットブレーカーによって通信がOpen状態へ移行したことを検出しておらず、バックエンド自体のサービス稼働状況で通信状況が問題ないか判断しています。もし、通信がOpen状態に移行したことを検知できれば早期にサービス稼働状況が危険な状態であることを発見できるため、サーキットブレーカー検出も今後導入していく予定です。 まとめ 本記事では、Istioサーキットブレーカー導入の事例を紹介しました。本記事により、サーキットブレーカーの有効性、Istioサービスメッシュ環境下であれば簡単に導入可能であることをご理解いただけたら幸いです。また新たな知見が得られた際には、紹介したいと思います。 さいごに ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 tech.zozo.com
アバター
はじめまして、ECプラットフォーム部 API基盤チームに2021年新卒入社した山添です。普段はAPI GatewayやID基盤の開発に携わっています。 データベースを運用していると、ビジネスロジックの変更やクエリ最適化のためにデータベーススキーマを変更することがあります。その際にデータベースマイグレーションツールを使うことで、運用の過程で変更されるスキーマの管理を楽にできます。 しかし、データベースマイグレーションツールであるsqldefが便利なのですが、弊社で使われているSQL Serverには対応していませんでした。そのため、何かしらの対策が必要でした。 本記事では、それらに関連した以下の内容を紹介します。 データベースマイグレーションツールとしてsqldefを採用していること sqldefでSQL Serverサポートをするためにコントリビュートしていること sqldefの開発のために必要な基礎知識と具体的な実装について 目次 目次 前提知識 データベースマイグレーション データベースマイグレーションツール Flyway sqldef sqldefを採用した背景 sqldefと言語アプリケーション sqldefの処理の流れ 言語アプリケーションの基礎要素 字句解析器 構文解析器 抽象構文木 sqldefの実装 マイグレーション処理の流れ sqldefがサポートする構文の追加 テストの追加 抽象構文木の改修 yaccファイルの改修 adapterの改修 generatorの改修 おわりに 前提知識 はじめに、本記事で扱うデータベースマイグレーションとマイグレーションツールについて紹介します。ご存じの方は、 sqldefを採用した背景 の章からご覧ください。 データベースマイグレーション アプリケーションの変更に伴い、データベースのスキーマ情報の変更を必要とする場合があります。例えば以下のケースです。 新機能の実装に必要となるカラムを追加したい ビジネスロジックの変更に伴いカラムの制約を変更したい パフォーマンスチューニングのためにインデックスを追加したい データベースに保存されているデータを保持したままスキーマ情報を変更することをデータベースマイグレーションと呼びます。そのデータベースマイグレーションにより、仕様変更や機能追加でスキーマ情報の変更が発生しても柔軟に適応できます。しかし、環境ごとのスキーマ管理や更新のためのDDL文を実行する手間など、新たな課題が浮上します。 データベースマイグレーションツール 上記の課題を解決する手段の1つとして、データベースマイグレーションツールがあります。データベースマイグレーションツールは、スキーマ情報の変更に伴うDDL文の実行を管理するためのツールです。 多くのデータベースマイグレーションツールは、現時点と最新のスキーマ情報の差分から実行が必要なDDL文だけを実行してくれます。そのため、開発環境や本番環境ごとにスキーマ情報を管理し、更新に必要なDDL文を1つ1つ手動で実行するという手間を省くことができます。 また、データベースマイグレーションツールは、アプリケーションフレームワークに組み込まれているものから独立したツールとして実行可能なものなど、様々な選択肢が存在します。本記事では現在弊社で採用しているFlyway、sqldefの2つを紹介します。この2つはどちらも独立したツールとして実行可能です。 Flyway FlywayはJava製のデータベースマイグレーションツールです。Apache License 2.0で配布されているコミュニティ版と有償のチーム版が存在しており、サポートや機能に違いがあります。 flywaydb.org Flywayの特徴は、利用における選択肢の多さです。更新操作の記述はDDL文またはJavaコードが選択できます。実行方法はコマンドラインクライアント、Java APIやMaven plugin、Gradle pluginが用意されています。特にマイグレーション可能なデータベースの数は群を抜いています。詳しくは こちら をご覧ください。 また、Flywayはファイル名でスキーマ情報のバージョン管理を行い、必要な更新操作を検出して実行します。そのため、現在のデータベースを更新するために「どのDDL文を実行する必要があるのか」を考える必要がありません。データベースマイグレーションに必要な作業は、データベースを任意のバージョンから次のバージョンに移行するためのDDL文を定義するだけです。 例えば、以下のDDL文で定義したテーブルがあるとします。 -- ファイル名:V1_20210901_create_users.sql CREATE TABLE users ( id BIGINT PRIMARY KEY, name VARCHAR ( 20 ) ) このテーブルに email カラムを追加したい場合は、以下の様なDDL文を新たなマイグレーションファイルに定義してFlywayを実行します。そうすることで、Flywayが未実行のマイグレーションファイルを検出し実行してくれます。 -- ファイル名:V1_20210903_add_email.sql ALTER TABLE users ADD email VARCHAR ( 100 ) NOT NULL また、コミュニティ版とチーム版の一番大きな違いは、 各種データベースのサポート期間 です。コミュニティ版ではデータベースの特定のバージョンがリリースされてから5年がサポート対象期間ですが、チーム版ではサポート期間が10年になります。例えば、データベースにSQL Server 2017を利用している場合、コミュニティ版のサポート期間は2022年までです。 sqldef sqldefはGo言語製のデータベースマイグレーションツールで、MITライセンスで配布されています。 github.com Go言語製のツールなので、ビルドしたバイナリから実行できます。2021年9月時点でサポートしているデータベースはMySQL、PostgreSQL、SQLite3、SQL Serverです。各データベースごとにサポートしている機能が異なるので、詳しくは こちら をご覧ください。 sqldefの特徴は、更新操作の定義が必要ない点です。多くのデータベースマイグレーションツールでは、 ALTER TABLE ... などの更新操作を定義し、データベースのマイグレーションとバージョン管理を実現します。しかし、sqldefでは最新のスキーマ情報を定義したDDL文さえあれば、自動的に必要な更新操作を生成し実行してくれます。バージョン管理はGitなど他のツールに任せることで、更新操作を実行する度にファイルが増えていき管理が煩雑になるのを防ぐことができます。 Flywayの例と同様に、以下の定義をしたテーブルがあるとします。 CREATE TABLE users ( id BIGINT PRIMARY KEY, name VARCHAR ( 20 ) ) このテーブルに email カラムを追加したい場合は、上記のファイルを以下の様に編集します。 CREATE TABLE users ( id BIGINT PRIMARY KEY, name VARCHAR ( 20 ), email VARCHAR ( 100 ) NOT NULL ) このDDL文を使ってsqldefを実行することで、自動で ALTER TABLE users ADD email ... 文を生成し、実行してくれます。 sqldefを利用する際には、Flywayと比較してサポートしているデータベースが少ない点に注意が必要です。また、基本的なDDL構文はサポートされていますが、サポートされていない構文を使いたい場合は適宜修正が必要です。 sqldefを採用した背景 チーム内で採用するマイグレーションツールを検討した結果、以下の3点からsqldefを高く評価しました。 マイグレーションファイルの管理から解放される バイナリだけで動かせるため、Flywayと比較して実行が容易である 1 機能がシンプルなため、自分たちの手でメンテナンスが可能である ところが、弊社ではMySQLとSQL Serverをメインで使っていますが、マイグレーションツールを検討していた2021年6月時点でsqldefはSQL Serverに対応していませんでした。それでも、sqldefは魅力的であり、最終的に自分たちでsqldefにコントリビュートしてSQL Server対応をしていくという決断を下しました。 その後、コントリビュートの成果として、2021年9月時点でSQL Serverでも以下のDDL文がsqldefによって生成できるよう対応が進みました。 Table: CREATE TABLE, DROP TABLE Column: ADD COLUMN, DROP COLUMN, DROP CONSTRAINT Index: ADD INDEX, DROP INDEX Primary key: ADD PRIMARY KEY, DROP PRIMARY KEY VIEW: CREATE VIEW, DROP VIEW github.com 以降の章では、SQL Serverサポートを進める上で得られたsqldefの開発に必要な知識から実装までの流れを紹介します。 sqldefと言語アプリケーション 本章では、sqldefがどの様にマイグレーションを実現しているのか、全体像とそれを実現するための基礎知識を紹介します。sqldefの全体像から内部実装を知ることで、現在サポートされていないSQL構文を管理したくなった際に、自分でサポート構文の追加をしたりbugfixをするのに役立ちます。 sqldefの処理の流れ sqldefでデータベースマイグレーションを実行すると、以下の順序で処理が実行されます。 既存のデータベースからDDL文を書き出す 1.で書き出されたDDL文と新しいDDL文を構文解析し、それぞれの抽象構文木を生成する 2つの抽象構文木を比較し、実行すべき更新操作のDDL文を生成する 生成されたDDL文を実行する 上記の処理を図にまとめると以下の様になります。 この様に、文字列を構文解析して何らかの処理を実行するツールを「言語アプリケーション」と呼びます 2 。代表的な言語アプリケーションにはインタプリタやコンパイラがあげられます。 言語アプリケーションは汎用性が高いツールです。言語アプリケーションの開発手法を学ぶことで、デバッグツールや静的解析ツール、言語翻訳ツールなど開発・運用効率を向上するツールの開発ができる様になります。次の節では、sqldefの実装に必要な言語アプリケーションの基礎要素の知識を紹介します。 言語アプリケーションの基礎要素 この節では言語アプリケーションの開発に必要な基礎要素として字句解析器、構文解析器、抽象構文木を説明します。これらの基礎要素は数ある言語アプリケーションに共通して使われる要素であり、インタプリタやコンパイラの実装にも必要な要素です。sqldefでも中心に添えられている要素なので、これらの仕組みを知ることがsqldefの実装の理解に役立ちます。 字句解析器 字句解析器は、与えられた文字列を事前に定義したトークンの配列に変換する字句解析と呼ばれる処理をします。 そして、字句解析器はlexer、tokenizer、scannerなど様々な呼び方をされることがありますが、sqldefではtokenizerとして実装されています。トークンとは、解析する対象言語の文法中で1つの単位として扱うことができるものを指します。字句解析器は識別子(テーブル名など)や整数値の様に意味値を持つトークンに対しては、トークン型に加えて値の情報も出力します。DDL文の場合、トークンは文字列を空白によって分割した単語単位で表現されます。例えば、 CREATE や TABLE の他、 ( や識別子、整数値が1つのトークンとして定義され、トークン型が識別子や整数値の場合は、「 users 」や「 10 」の様な値も加えて出力します。 以下の様なDDL文を字句解析した場合を例にあげます。 CREATE TABLE users (name CHAR ( 10 )) この場合、字句解析器には以下の様なトークン列を出力されることが期待されます。 [ CREATE, TABLE, IDENTIFIER("users"), LPAREN, IDENTIFIER("name"), CHAR, LPAREN, INTEGER(10), RPAREN, RPAREN, SEMICOLON ] sqldefでは goyacc というパーサージェネレータが使われています。パーサージェネレータについては後述しますが、構文解析器を自動生成するためのツールです。パーサージェネレータにgoyaccを使う場合、goyaccに定められた仕様で字句解析器を実装しなければなりません。 pkg.go.dev goyaccで使える字句解析器は以下のインタフェースで定義されています。 type yyLexer interface { Lex(lval *yySymType) int Error(e string ) } Lex が字句解析の処理ですが、引数として受け取った lval にトークンの意味値を入れ、トークンの種類を int 型で返す様に実装します。このインタフェースを満たす様に字句解析器を実装した場合、上記のDDL文を入力した際の lval と戻り値は以下の様に遷移することが期待されます。 type tokenType int const ( CREATE tokenType = iota TABLE CHAR IDENT // 識別子 NUMBER LPAREN RPAREN SEMICOLON ) var tokens = [] struct { tokenType tokenType // Lex()の戻り値 lval string }{ {CREATE, "create" }, {TABLE, "table" }, {IDENT, "users" }, {LPAREN, "(" }, {IDENT, "name" }, {CHAR, "char" }, {LPAREN, "(" }, {NUMBER, "10" }, {RPAREN, ")" }, {RPAREN, ")" }, {SEMICOLON, ";" }, } iota はGo言語特有の記法で、定数に対して整数の連番を振ってくれます。goyaccを使う場合、goyaccがトークンタイプの整数値を定数として生成してくれます。上記の様に、事前に定義したトークンタイプを見つけて入力文字列を分割していくのが字句解析器の役割です。そして、字句解析器から出力されたトークン列が構文解析器に入力されます。 構文解析器 構文解析器は入力データを受け取り、何かしらのデータ構造を出力する処理をします。入力データは上述した字句解析器から出力されるトークン列です。出力するデータ構造には、構文解析木や抽象構文木など、後段の解析処理に適切なデータ構造を選択します。テキストを入力として受け取り、何かしらのデータ構造を出力するという意味では、多くの人が馴染み深いであろうJSONパーサーと考え方は同じです。ただし、言語アプリケーションで利用するのに適したデータ構造を出力するという点が異なります。 構文解析器は言語アプリケーションの中で重要な役割を担いますが、パーサージェネレータと呼ばれるツールを使って自動生成できます。そのため、言語アプリケーションを開発する場合は学習目的の場合を除いて、パーサージェネレータを使うのが良いでしょう。パーサージェネレータとして有名なツールにはyaccやbison、ANTLRがあります。sqldefはパーサージェネレータにyaccのGo実装であるgoyaccを採用しています。 goyaccは本家yaccと同じ様にバッカスナウア(BNF)記法に似た構文規則を与えることで、コンパイル可能なGo言語のコードを出力します。goyaccの入門には こちらの記事 が非常に参考になります。 抽象構文木 構文解析器の出力から得られるデータ構造を中間表現と呼びます。中間表現の中でも言語アプリケーションに良く用いられるデータ構造が抽象構文木です。 抽象構文木は入力列から不要な字句を省き、重要な字句の文法上の関連を記録したデータ構造です。抽象構文木を構築することで入力列の走査が容易になり、構文解析器の後段に置く処理を簡潔にできます。 なお、抽象構文木に含める情報は開発したいアプリケーションによって都度選択する必要がありますが、sqldefの場合はDDLの変更を検知するための情報が必要です。例えば、sqldefではchar型の文字列長の変更を検知して更新操作がされる様に実装されているので、 char(n) の文字列長を示す n も抽象構文木の情報に含める必要があります。 実際に以下の様なDDL文が与えられた場合を例にあげます。 CREATE TABLE users ( id INT, name CHAR ( 20 ) ) sqldefの構文解析器が構築する抽象構文木は以下の様になります。 テーブル名や型情報、文字列長など必要な情報だけが抽出され、DDL文を木構造で表現できていることが分かります。 この抽象構文木からインタプリタやコンパイラでは言語変換や評価をしたり、静的解析ツールでは木を走査して特定の文字列を見つけたりしています。 次章ではsqldefが抽象構文木をどの様に使ってデータベースマイグレーションを実現するのかを紹介します。 sqldefの実装 本章ではsqldefの実装について、マイグレーション処理の流れとサポートする構文の追加方法を例に紹介します。 マイグレーション処理の流れ DDL文を解析するために必要な抽象構文木は、実際の実装を見ると理解が容易になります。そのため、以下にGo言語のstructでDDL文のデータ構造の実装例を示します。 type DDL interface { Statement() } type CreateTable struct { table Table } func (c *CreateTable) Statement() {} type Table struct { name string columns []Column indexes []Index foreignKeys []ForeignKey } type Column struct { name string typeName string notNull * bool length int keyOption ColumnKeyOption } type ColumnKeyOption int const ( ColumnKeyNone ColumnKeyOption = iota ColumnKeyPrimary ColumnKeyUnique ) 上記は sqldefの実装 から一部を抜粋したものです。テーブルは、テーブル名の他にカラムやインデックス、外部キーの情報をリスト構造で保持しています。カラムは、カラム名や型情報の他に制約などカラムを表現するために必要な情報を保持します。カラムの中にデフォルト制約やチェック制約の構造体が埋め込まれており、DDLが木構造で表現されていることが分かります。 入力を抽象構文木にするまでの処理は、どの言語アプリケーションにも大方共通しますが、抽象構文木をどう使うかが肝になってきます。sqldefの場合、新DDL文の抽象構文木と既存データベースから出力される旧DDL文の抽象構文木の2つを比較し、更新用のDDL文を生成します。 例として、テーブル定義が変更された際に、どの様な処理が行われるかを見てみます。 旧テーブル、新テーブルとして以下のDDLが定義されているとします。旧テーブルと新テーブルの差分は、idカラムのデータ型の変更とnameカラムの追加です。 -- 旧テーブル CREATE TABLE users ( id INT PRIMARY KEY ) -- 新テーブル CREATE TABLE users ( id BIGINT PRIMARY KEY, name CHAR ( 20 ) ) この場合、sqldefの構文解析器から出力される抽象構文木は以下の様になります。 // 旧テーブルの抽象構文木 var currentTables = []Table{ { name: "users" , columns: []Column{ { name: "id" , typeName: "int" , keyOption: ColumnKeyPrimary, }, }, }, } // 新DDLの抽象構文木 var desiredDDLs = []DDL{ &CreateTable{ table: Table{ name: "users" , columns: []Column{ { name: "id" , typeName: "bigint" , keyOption: ColumnKeyPrimary, }, { name: "name" , typeName: "char" , length: 20 , }, }, }, }, } 上記の2つの抽象構文木を元にsqldefは schema/generator.go にある処理で更新DDL文を生成します。 sqldefがテーブルを比較し、更新DDL文を生成する処理を簡略化したコードで表すと以下の様に実装できます。 type Generator struct { mode GeneratorMode currentTables []*Table } func (g *Generator) generateDDLs(desiredDDLs []DDL) [] string { ddls := [] string {} for _, ddl := range desiredDDLs { // 旧テーブルの取得 currentTable := findTableByName(g.currentTables, desired.table.name) if currentTable != nil { tableDDLs := g.generateDDLsForCreateTable(*currentTable, *desired) ddls = append (ddls, tableDDLs...) } else { ddls = append (ddls, "テーブルの追加処理" ) } } return ddls } func (g *Generator) generateDDLsForCreateTable(currentTable Table, desired CreateTable) [] string { ddls := [] string {} for i, desiredColumn := range desired.table.columns { currentColumn := findColumnByName(currentTable.columns, desiredColumn.name) if currentColumn == nil { ddls = append (ddls, "カラムの追加処理" ) } else { // データ型のチェック if !g.haveSameDataType(*currentColumn, desiredColumn) { ddls = append (ddls, "データ型の変更処理" ) } // デフォルト制約のチェック if !areSameDefaultValue(currentColumn.defaultDef, desiredColumn.defaultDef) { if desiredColumn.defaultDef == nil { ddls = append (ddls, "デフォルト制約の削除処理" ) } else { ddls = append (ddls, "デフォルト制約の追加処理" ) } } // primary key, check制約, ...などのチェックとDDL生成 } } return ddls } DDLの生成処理は、テーブルやカラムの存在チェックや等価判定を駆使して愚直に実装されています。 上記のusersテーブルの定義の場合、以下の順に処理が実行されるでしょう。 findTableByName() が呼ばれ、 currentTable に既存のusersテーブルが入る currentTable != nil が真になり、 generateDDLsForCreateTable() が呼ばれる 既存テーブルからid列を探す id列のデータ型が変更されているので、 !haveSameDataType() が真になり、 ddls にデータ型の変更処理が追加される 既存テーブルからname列を探す currentColumn == nil が真になるので、 ddls にカラムの追加処理が追加される 実際には ddls に追加する処理は、Generator構造体のmodeにしたがって条件分岐し、各DB間の構文の差を吸収しています。例えばカラムの追加の場合、MySQLでは ALTER TABLE ... ADD ... ですが、SQL Serverでは ALTER TABLE ... ADD COLUMN ... の様な差分です。 また、テーブルやカラムの他にも外部キー制約やインデックスの比較、更新処理が定義されています。sqldefがどの様なDDL文を生成できるのか気になる方は、 schema/generator.go をご覧ください。新たにサポートしたい構文が出てきた時もここに処理を追加していきます。 sqldefがサポートする構文の追加 最後に、sqldefがサポートする構文を追加したい場合の追加手順を、SQL Serverで NOT FOR REPLICATION オプションを実際に追加対応した際の手順を例に紹介します。 SQL Serverでは制約に NOT FOR REPLICATION オプションを指定することで、レプリケーションエージェントによるテーブル操作時に制約を無視させることができます。sqldefで、その NOT FOR REPLICATION オプションをサポートするために取る手順は以下の通りです。 NOT FOR REPLICATION オプションを使えることが確認できるテストを追加する 抽象構文木に NOT FOR REPLICATION オプションの情報を追加する NOT FOR REPLICATION オプションを構文解析できる様にyaccファイルを改修する 既存データベースのDDL抽出部分で NOT FOR REPLICATION オプションも抽出できる様にadapterを改修する DDL生成部分であるgeneratorを改修する テストの追加 実装を開始する前に、まずは自分が追加しようとする処理の期待する動作を テストコードに書き起こします 。今回のプルリクエストでは、以下の2点を確認するテストコードを追加しています。 IDENTITYカラムとCHECK制約に NOT FOR REPLICATION オプションを使えること 新たに NOT FOR REPLICATION オプションを追加した際に適切な更新DDLが実行されること sqldefにはテストのためのヘルパーメソッドが用意されており、簡潔にテストコードを書くことができます。 assertApplyOutput() を使うことで、定義したDDL文をsqldefに与えた際の出力と期待する出力の比較テストができます。 抽象構文木の改修 次に抽象構文木で NOT FOR REPLICATION オプションの情報を保持できるよう改修をします。 sqldefは元々 Vitessの構文解析器 を拡張して開発されたという背景があります。そのため、sqldefにはVitess用の抽象構文木( sqlparser/ast.go )とsqldef用の抽象構文木( schema/ast.go )が存在します。goyaccで生成された構文解析器( sqlparser/parser.go )は、まず sqlparser/ast.go で定義されるデータ構造を出力します。その後、 schema/parser.go を使い、 schema/ast.go で定義されるデータ構造に変換します。以上のsqldef用の抽象構文木ができるまでの流れを以下の図にまとめました。 この流れがあるため、sqldefの抽象構文木に改修を加える際には、次の3ファイルの改修が必要です。 sqlparser/ast.go ( 今回の改修 ) schema/parser.go ( 今回の改修 ) schema/ast.go ( 今回の改修 ) 今回のプルリクエストでは抽象構文木の対象ノードのstructに NOT FOR REPLICATION の情報を保持するためのフィールドを追加しています。 schema/parser.go には抽象構文木の変換処理を追加しています。 yaccファイルの改修 テストコードに新しい構文を追加すると、テスト実行時に syntax error が発生するはずです。これは、構文解析器が新しく追加した構文(今回の場合は NOT FOR REPLICATION オプションの構文)を解析する手段を持たないため発生します。 構文解析器に新しい構文規則を追加するためにはyaccファイル( sqlparser/parser.y )を修正します。 今回の変更 では、新しいトークン( REPLICATION )の追加とカラムや制約を定義する構文内で NOT FOR REPLICATION オプションを読むための規則の追加をしています。 さらに、コールバックには上述した改修で追加した NOT FOR REPLICATION の情報を保持するためのフィールドに値を代入するための変更をしています。yaccファイルの修正が終わったらgoyaccコマンドを使って sqlparser/parser.go を生成します。 goyaccは構文解析器を生成する際に shift/reduce conflict や reduce/reduce conflict を発生させる場合があります。具体的には、新しく追加した規則が他の規則と重複してしまった際に発生します。conflictが発生してしまった場合は、既存の規則の中で流用できるものを探してみたり、省略記法を使えない様にするなどの対応で解決する場合があります。conflictに関して詳しくは「速習yacc 3 」をご覧ください。 adapterの改修 sqldefでは既存データベースから取得できる旧DDLと入力される新DDLを比較して更新DDLを生成します。 既存データベースから旧DDLを取得するための実装は adapter/ 配下にある各データベース用のパッケージに実装されています。例えば、MySQLでは SHOW CREATE TABLE 構文などを使って既存データベースのDDL文を取得できます。しかしSQL Serverにはその様な構文がないため、システムテーブルの情報を使ってDDL文を生成しています。 今回比較したいのは旧DDL文と新DDL文の NOT FOR REPLICATION の値です。そのため sys.check_constraints などのシステムテーブルから is_not_for_replication 列の値を読み込み、DDL文に追加する様に 改修 しています。 generatorの改修 抽象構文木とadapterの改修ができたら、最後に更新DDLの生成処理を改修します。 1つ目のステップで追加したテストの TestMssqldefCreateTableAddNotForReplication() を見れば、今回generatorに期待する動作が確認できます。期待する動作は、カラムと制約の NOT FOR REPLICATION オプションを確認し、旧テーブルと新テーブルに差分があれば更新DDL文として ALTER TABLE ... を生成することです。 IDENTITY要素の生成処理 を追うと分かりやすいですが、generatorは初めに areSameIdentityDefinition() で旧DDL文と新DDL文のIDENTITY要素を比較します。そして2つのDDL文に差分があった場合、カラムの削除とカラムの追加処理を更新DDL文のリストに追加しています。 この様に2つのDDL文の要素を簡単に比較できるのも、構文解析器を使ってDDL文を構造化したことの恩恵です。 以上がsqldefへのサポート構文追加の一例です。全ての変更がこのパターンに則しているわけではありませんが、各コンポーネントの役割を把握することが開発する際の手助けになるかと思います。 おわりに 本記事ではsqldefへの機能実装と言語アプリケーションの実装に必要な基礎知識をご紹介しました。 普段利用するツールの実装を理解することは、自分自身がそのツールをメンテナンスできる様になる点で有意義です。本記事が少しでもsqldefのユーザー増加に貢献し、開発がさらに活発になることを願っています。 ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com Flywayにも コマンドライツール がありますが、Java製のツールなので実行にはJVMが必要です。 ↩ Terence Parr, 「言語実装パターン コンパイラ技術によるテキスト処理から言語実装まで」, 2011。 ↩ 第9章 速習yacc ↩
アバター
はじめに ZOZOTOWN本部 ZOZOアプリ部 AndroidチームでZOZOTOWNのAndroidアプリを開発している鈴木です。 本投稿は、ZOZOTOWN AndroidアプリのHome画面に存在する「商品モジュール」実装中に発生したパフォーマンスの低下をPerfettoというツールを用いて特定・改善した事例を紹介します。 はじめに Home画面の「商品モジュール」について 発生した問題 調査方法について 調査ツールの選定 Perfettoとは? Perfetto UIを用いたAndroidアプリのトレースの流れ 1. 新しいトレースの設定・実行画面を表示 2. トレースに使用する端末を設定 3. トレースの設定 4. トレースコマンドの確認 5. トレースの実行 6. トレース結果の可視化 問題の調査 トレース内容 Perfettoのトレース設定 実行結果 問題の改善 改善方針の検討 1. 商品セルのinflate時間を短くする 2. 商品セルのinflate回数を減らす 改善方針の決定 改善方法 結果 パフォーマンスの比較方法 体感での比較 商品セルのinflateの平均時間の比較 まとめ おわりに Home画面の「商品モジュール」について Home画面は以下の画像のように、アプリを開いた際、最初に表示される画面のことを指します。商品情報やクーポン情報などを表示することで、ユーザに対して新しい発見の提供や、好みの商品への導線を提供しています。 Home画面の内、商品をある括りによって集めたものが「商品モジュール」です。例えば、マルチサイズアイテムと呼ばれるユーザの体型にあった商品を集めた商品モジュールや、最近チェックした商品を集めた商品モジュールなどがあります。 この商品モジュールは、2021/3/18のZOZOTOWNのリニューアルにおいて、以下の画像のようにデザインがリニューアルされ現在のデザインとなっています。 より多くの商品を閲覧できるように、横スクロールが新しく導入されるなど大幅にデザインがアップデートされました。 発生した問題 この新・商品モジュールを実装中に、Home画面の表示速度が遅いという問題が発生しました。 具体的には、商品の詳細画面からHome画面に戻る際、Home画面の表示に時間がかかるという問題です。 以下の動画が実際の例です。Home画面に戻る際、コンテンツ表示に時間がかかり、真っ白な画面が長く表示されています。 このパフォーマンスの低下は、ユーザ体験や売り上げに大きな影響が出ると考え、改善に向けて取り組むことにしました。 調査方法について 商品モジュールの実装において変更した点は基本的にレイアウトのみであったため、レイアウト周りにパフォーマンス低下の原因があると予測をたてて調査することにしました。 調査ツールの選定 リリース日との兼ね合いで、調査にはあまり時間をかけられません。短時間でボトルネックを特定する必要がありました。 商品モジュール内の商品1つ分のレイアウト(以下、商品セルと呼ぶ)はレイアウト構造が複雑です。変更箇所全てに対して計測用コードを実装するような方法は工数が大きくなるため、選択肢から除外して調査しました。 ツールを調査していると、システムトレースを用いることで、アプリのCPU使用率やスレッドの情報を記録できることがわかりました。 Android Developersの システムトレースの概要 によると、Androidのシステムトレースの方法には4種類オプションがあります。 Android StudioのCPU Profiler System Tracingアプリ Systraceコマンドラインツール Perfettoコマンドラインツール この4手法を、「短時間でトレース可能か」「トレース結果を短時間で確認可能か」という2つの軸で評価しました。 1の「Android StudioのCPU Profiler」は、トレースと結果の可視化がAndroid Studio内で完結するという方法です。実際に使用した結果、当時の筆者の実行環境では、トレース中やトレース結果の確認中にアプリやAndroid Studioの動作が重くなるという問題がありました 1 。この理由から、短時間でトレースが難しいと判断し、CPU Profilerを用いる方法は候補から除外しました。 2と3については、トレースを端末上で開始し、キャプチャしたデータをPCに移動させて可視化するという方法です。データをPCに移動して、前述のProfilerや後述のPerfetto UIなどで読み込まなければならないという点で、短時間での結果の確認が難しいと判断し候補から除外しました。 4の「Perfettoコマンドラインツール」は Perfetto UI からトレースの設定、Perfettoコマンドの実行、可視化が一貫してできる方法です。前述の3種類と比較して、最も短時間でボトルネックの特定が可能であったため、Perfettoを使用するという判断をしました。 Perfettoとは? Android 10で導入されたトレースツールであり、 Android Debug Bridge(ADB) を介して、パフォーマンス情報を収集できます。 パフォーマンス情報とは、CPU・メモリの使用率や各プロセスの情報等のことを指します。 さらに、 Perfetto UI と呼ばれるGUIツールを使用することで、トレースの設定、記録、可視化を一貫して行うことが可能です。 Perfetto UIを用いたAndroidアプリのトレースの流れ Perfetto UIを用いたトレースの基本的な流れ 2 は、以下の通りです。 1. 新しいトレースの設定・実行画面を表示 Perfetto UI にアクセスし、ページのサイドバーから「Record new trace」をクリックします。クリック後、以下のような画面が表示され、新しいトレースの設定・実行ができます。 2. トレースに使用する端末を設定 次に、トレースで使用するAndroid端末を設定します。「Add ADB Device」のボタンをクリックして、PCに接続されている端末を選択します。 3. トレースの設定 トレースの設定は、以下の画像のようにトレースしたい項目を選択していきます。 4. トレースコマンドの確認 設定完了後に「Recording command」の項目をクリックすると、設定した内容が adb コマンドとして表示されます。 5. トレースの実行 最後に「Start Recording」をクリックします。そうすることで、 4. トレースコマンドの確認 の項目で表示されたadbコマンドが端末上で実行されます。 6. トレース結果の可視化 トレースが終了すると、設定内容にもとづいてトレース結果が可視化されます。 問題の調査 実際にPerfettoを用いて調査した内容について述べます。 トレース内容 トレースは、実際に問題があった区間の内容としました。 具体的には、下図の通り、商品の詳細画面からHome画面のコンテンツが表示される区間としました。 Perfettoのトレース設定 Perfetto UI上で行ったトレース設定について説明します。 Perfeto UI 上で「Record new trace」を選択し、「Recording settings」において以下の画像のようにそれぞれ設定をしました。 ①の「Recording mode」では、バッファへ書き込まれるトレースデータの記録方式を設定します。今回は「Stop when full」を選択しました。これはトレースデータがバッファ上限を超えた際、書き込みをストップするという方式です。今回は後述のバッファサイズを十分確保できるためこの方式としました。 ②の「In-memory buffer size」では、トレースデータを書き込むためのバッファサイズを設定します。試しに計測をした結果、Perfetto UIが設定したデフォルト値「64MB」で問題なくトレース可能であったため「64MB」としました。 ③の「Max duration」では、トレースの時間を指定します。今回は、商品の詳細画面からHome画面のコンテンツが表示される間のトレースになるため、5(s)としました。 次に、「Android apps & svcs」を選択し、以下の画像のように設定しました。 ④の「Atrace userspace anntaions」を有効にし、⑤の「View System」を選択しました。これにより、Linuxカーネルに組み込まれている ftrace と呼ばれるトレース機能を使用して、レイアウト生成処理のイベントをキャプチャできるようになります。 設定が完了したら「Recording command」でトレース用のコマンドを取得します。 ⑥において、設定した①〜⑤の内容がadbコマンドとして出力されました。 下記が出力されたコマンドです 3 。 adb shell perfetto \ -c - --txt \ -o /data/misc/perfetto-traces/trace \ <<EOF buffers: { size_kb: 63488 fill_policy: DISCARD } buffers: { size_kb: 2048 fill_policy: DISCARD } data_sources: { config { name: "linux.ftrace" ftrace_config { ftrace_events: "ftrace/print" atrace_categories: "view" } } } duration_ms: 5000 EOF ⑦においてトレースする端末を選択し、⑧の「Start Recording」からトレースを開始します。 実行結果 トレース結果の内、①で示した区間が、商品セルをinflateしている箇所です。 inflateの内訳を見ると、②で示している区間において比較的時間がかかっていることがわかりました。 以下が②の部分を拡大した画像となっており、②はMaterialCardViewのinflate処理であることがわかりました。 さらに、以下のように、Home画面で表示される商品セルの数だけ(50以上)inflateが発生していることがわかりました。 問題の改善 トレース結果より、下記2つの改善方針の案を考えました。 商品セルのinflate時間を短くする 商品セルのinflate回数を減らす 改善方針の検討 どちらの方法も、一定の改善見込みがありました。 1. 商品セルのinflate時間を短くする Androidアプリでは、下記2つの計算後に、レイアウトの描画が行われます。 measureパス layoutパス measureパスでは、View, ViewGroupのサイズ計算が行われます。各View, ViewGroupが onMeasure() メソッドにより自身のサイズを申告することで、Viewツリーの全ノードの幅と高さを決定します。 layoutパスでは、View, ViewGroupの配置座標の計算が行われます。measureパスにより決定した各ノードのサイズ情報と親ノードの特徴(例えば、LinearLayoutでは子ノードを一列に並べるなど)をもとに、Viewツリーの全ノードの座標を決定します。 2つの計算が終了すると、各View, ViewGroupが draw() メソッドを呼び出し、レイアウトを描画していきます。 さて、以下のトレース結果からもわかるように、inflateはmeasureパスにおいて実行されています。 つまり、inflateの速度はmeasureパスの速度にも影響し、最終的にはレイアウトの描画にも影響します。 inflate時間の短縮に取り組むことで、パフォーマンスの改善が期待できます。 2. 商品セルのinflate回数を減らす 実行結果 の通り、Home画面で表示される商品セルの数だけinflateが実行されていました。 画面内に表示されていない商品セルについても、Home画面表示の際にinflateされていることから、この回数を減らすことでパフォーマンスの改善が期待できます。 改善方針の決定 今回はスケジュールの都合から、短時間で実装可能な方針のみ着手することとしました。 1.「商品セルのinflate時間を短くする」は、短時間で実装可能と判断しました。 商品セルのinflateにおいて一番時間がかかっているMaterialCardViewの使用を中止できる見込みが立ったためです。 2.「商品セルのinflate回数を減らす」は、短時間での実装は難しいと判断しました。 2のためには、以下のようにRecyclerView in RecyclerViewの構造にレイアウトを組み直す必要がありました。この構造にすることで、初回のレイアウト描画時はファーストビューに必要なコンテンツのみ描画処理され、inflate回数を減らすことができます。 レイアウト構造変更に伴う修正、RecyclerView in RecyclerViewを実現可能なライブラリの使用を検討・調査するなど、実装コストが高いと判断しました。 上記より、 1.「商品セルのinflate時間を短くする」 のみを着手することにしました。 改善方法 MaterialCardViewは、新・商品モジュールにおける商品セルの角丸を実装するために追加されており、セルのRootのViewとなっていました。 実装されていたコードのサンプルは下記の通りです。 <!-- レイアウトファイル --> <com . google . android . material . card . MaterialCardView android : id = "@+id/listItem" : > <!-- 商品情報(商品画像, 値段, ブランド名, etc.) --> : </com . google . android . material . card . MaterialCardView> // 角丸付与のロジック // 商品セル右側の上下 private fun setRoundCornersOnRightSides() { val CORNER_ROUND_DP = 10L val corner_round_px: Float = dpToPx(CORNER_ROUND_DP) binding.listItem.shapeAppearanceModel = ShapeAppearanceModel() .toBuilder() .setAllCornerSizes( 0F ) .setTopRightCorner(CornerFamily.ROUNDED, corner_round_px) .setBottomRightCorner(CornerFamily.ROUNDED, corner_round_px) .build() } : // 商品セル左側の上下 private fun setRoundCornersOnLeftSides() { : // 商品セルの上下左右 : MaterialCardViewを用いると、 ShapeAppearanceModel を用いて簡単に角丸を実装できます。 しかし、パフォーマンスを犠牲にしてまで利用するものではないため、FrameLayoutのbackgroundに角丸のdrawableを当てる方法で代替しました。 実装コードのサンプルは下記の通りです。 <!-- 商品セルのレイアウト --> <FrameLayout android : id = "@+id/listItem" : > <!-- 商品情報(商品画像, 値段, ブランド名, etc.) --> : </FrameLayout> // 角丸付与のロジック // 商品セル右側の上下 private fun setRoundCornersOnRightSides(context: Context) { binding.listItem.run { background = ContextCompat.getDrawable(context, R.drawable.bg_corner_right) foreground = ContextCompat.getDrawable(context, R.drawable.fg_corner_right) } } : // 商品セル左側の上下 private fun setRoundCornersOnLeftSides(context: Context) { : // 商品セルの上下左右 : 結果 パフォーマンスの比較方法 パフォーマンスが改善できているか確認するため、下記項目を改善方針の実装前後で比較しました。 端末を操作したときの体感での比較 商品セルのinflateの平均時間の比較 体感での比較は、実際にアプリを操作したときの動作比較です。 inflateの平均時間は、Perfettoを用いて3回トレースした際の、商品セルのinflate平均時間を比較しています。 体感での比較 チームで実際に操作して検証したところ、「微妙な差ではあるものの、対応後の方が速いように思う」という評価が集まりました。 商品セルのinflateの平均時間の比較 定量的にも評価します。 対応前 トレース(回目) 平均inflate時間(ms) 1 10.50 2 10.40 3 10.78 対応後 トレース(回目) 平均inflate時間(ms) 1 8.59 2 8.68 3 8.23 対応前はトレース3回の平均は10.56ms、対応後は8.5msでした。 1つ1つはたった2msと劇的ではないものの、商品セルの描画全体では 2ms × 50セル = 100ms ほど改善される結果となりました。 まとめ 本投稿ではPerfettoと呼ばれるツールを用いて、実装中に感じたパフォーマンスの低下を特定・改善した事例について紹介しました。 対応後の実装では、体感で劇的な改善とはなりませんでしたが、全体で100msほど表示速度が改善されました。 加えて、現在は 問題の改善 で述べた「商品セルのinflate回数を減らす」改善も実施し、RecyclerView in RecyclerViewのレイアウト構造となりました。これにより、更にパフォーマンスが改善されつつあります。 また、当時は開発環境の問題から諦めたAndroid StudioのCPU Profilerも、現在の筆者の開発環境では快適に動作するようになりました。これの使用も検討中です。 おわりに ZOZOテクノロジーズでは、Androidエンジニアを募集しています。 まずは、以下のリンクから、お気軽にカジュアル面談にご応募ください! hrmos.co 現在(2021/8/30時点)は問題なく確認できています。 ↩ Quickstart: Record traces on Android - Perfetto Tracing Docs ↩ コード内の各フィールドについては、 公式ドキュメント で詳しく説明されています。 ↩
アバター
はじめに こんにちは。マイグレーションチームの藤本です。 ZOZOTOWNはオンプレミスとクラウドのハイブリッドで動いており、その内、オンプレミス側のアプリケーションはClassic ASPとストアドプロシージャの組み合わせで実装されています。 私たちのチームでは、そのClassic ASPとストアドプロシージャの廃止を目標に、まずは参照系の処理をWeb APIで置き換える作業をしています。この記事では、 Karate を使って参照系の処理を置き換えるWeb API(以後、 参照系API )のE2Eテストを実現するための取り組みについてご紹介します。 全体に影響する修正とテストの必要性 フレームワークのバージョンアップ 冒頭の通り、参照系APIは商品やショップの情報を取得すると言ったZOZOTOWNでも最も古い部類に入る機能たちを、まずはAs−IsでそのままWeb API化することを目的とするものです。そのため、いわゆる「REST API」とは異なり、1つ1つのWeb APIが様々な機能を提供しています。同じエントリポイントであっても、その時のパラメーターによって挙動が全く異なるため、網羅的な動作確認が重要です。 この参照系APIは少し古いバージョンのSpring Bootで実装されており、ある時バージョンアップが必要となりました。 バージョンアップ時のテスト Spring Bootは各バージョンごとに マイグレーションガイド を作成してくれているので、手順通りに進めればそれほど躓くことなくバージョンアップができます。しかしながら、影響調査の過程で対象から漏れたり勘違いしたり、あるいはまだ修正されていないバグにぶつかってしまう可能性は十分にあります。Spring Bootのようなフレームワークのバージョンアップの場合には、できるだけ広い範囲をテストするべきです。 参照系Web APIで準備しているテストと課題 参照系APIは作成されたときからユニットテストが作られており、現在も機能の改修を続けながらControllerやService単位でテストもあわせて修正されています。 一方で、当時E2Eテストは不十分でした。主な役割の1つである商品やショップを探す機能は、エンドポイントやパラメーターの組み合わせのパターンが多く、この網羅が難しい状況でした。 このような状況でも、ある程度の網羅性を確保しつつE2Eテストを実施するために、Karateをテストツールとして採用しました。 Karateの準備 Karateを選択した理由 Karateはテスト自動化ツールのひとつで、MockサーバーやUIテストの機能も備えています。 github.com Karateを選択した大きな理由はJavaで作られていることです。参照系APIの開発はJavaで行っており、すでにチームメンバーのPCにも実行環境が整っています。 Karateの実行方法はいくつか用意されていますが、JARファイルを使ったスタンドアローンな実行方法を選択することで、簡単に実行できます。 JARファイルを取得する javaコマンドで実行する 今回はこの方法を採用しました。 テストを実行するための準備 本格的なパターンを作成する前に、まずは簡単なテストが実行できるのか試しました。 Spring Boot Actuatorを導入済みだったので、ヘルスチェックのエンドポイントにアクセスするテストシナリオを作成しました。ファイル名はtest.featureです。 Feature: Test Background: * def baseUrl = ' http://localhost:8080 ' Scenario: test Given url baseUrl + ' /actuator/health ' When method get Then status 200 /actuator/healthにGETでアクセスして、HTTPスタータスが200であればテストOKという簡単な内容です。 実行するときはKarateのJARファイルと先程のテストシナリオのファイルを指定します。 java -jar karate.jar test .feature 成功すると次のように結果が表示されます。 06:36:22. 721 [ main ] DEBUG com.intuit.karate - request: 1 > GET http://localhost:8080/actuator/health 1 > Host: localhost:8080 1 > Connection: Keep-Alive 1 > User-Agent: Apache-HttpClient/ 4 . 5 . 13 ( Java/ 11 . 0 . 12 ) 1 > Accept-Encoding: gzip,deflate 06:36:22. 875 [ main ] DEBUG com.intuit.karate - response time in milliseconds: 149 1 < 200 1 < Content-Type: application/vnd.spring-boot.actuator.v3+json 1 < Transfer-Encoding: chunked 1 < Date: XXX, XX Sep 2021 06:36:22 GMT 1 < Keep-Alive: timeout = 60 1 < Connection: keep-alive { " status " : " UP " , " groups " : [" liveness " , " readiness "] } --------------------------------------------------------- feature: test .feature scenarios: 1 | passed: 1 | failed: 0 | time: 0 . 6267 --------------------------------------------------------- 06:36:23. 834 [ main ] INFO com.intuit.karate.Suite - <<pass >> feature 1 of 1 (0 remaining) features/local/test.feature Karate version: 1.1.0 ====================================================== elapsed: 2.80 | threads: 1 | thread time: 0.63 features: 1 | skipped: 0 | efficiency: 0.22 scenarios: 1 | passed: 1 | failed: 0 ====================================================== HTML report: (paste into browser to view) | Karate version: 1.1.0 file:///target/karate-reports/karate-summary.html =================================================================== curlコマンドにverboseオプションをつけて実行したときと似た内容に加えて、実行にかかった時間とテスト結果のサマリが出力されます。出力の最終行にもある通り、テスト結果のサマリはレポートファイルとしても出力されます。これでKarateの準備はOKです。 テストパターンの準備 Karateの準備ができたら次はテストパターンの準備です。テストシナリオをできるだけ本番に近づけるため、Splunkのログを利用することにしました。ログからリクエスト内容を取得することで、実際の使われ方に近いテストを作成します。なお、ZOZOTOWNでのSplunk利用については、以下の記事をご覧ください。 techblog.zozo.com Splunkでテストパターン作成 抽出したサーチ文の例がこちらです。 uri_path = " /api/* " http_method = " GET " | strcat uri_path " ? " uri_query uri | strcat " Given url baseUrl + ' " uri " '| " given | strcat given + " When method get| " when | strcat when + " Then status 200|| " then | table then | rex field =then mode =sed " s/ \| / \n /g " URLから取得できるパスとクエリ、KarateのDSLを文字列として結合して、最後に改行を置換しています。このサーチ文で検索した結果をCSVとしてダウンロードするだけで、ほぼテストシナリオは完成です。具体的には以下のような結果が得られます。 Given url baseUrl + ' /actuator/health ' When method get Then status 200 この結果に、リクエスト先などの先頭行を加えると、前述のtest.featureと同じものが完成です。 Feature: Test Background: * def baseUrl = ' http://localhost:8080 ' Scenario: test Given url baseUrl + ' /actuator/health ' When method get Then status 200 Karateは非常にシンプルなDSLを持っており、このようにアクセスログからそのままテストシナリオを作ることが可能です。参照系APIのようにパラメーターの組み合わせや動作が複雑なWeb APIを扱う場合、とても便利な特徴です。 なお、この例で作成しているテストシナリオは、HTTPステータスが200であることしか確認していません。状況に応じてレスポンス内容のAssertionを追加ください。 なお、今回Karateを導入するにあたって、サーチ文による検索を1か月という期間で絞りました。そのため、数ヶ月に1回のアクセスやレアなパラメーターの組み合わせは含まれていない可能性があります。想定されるパターンを隈なく探し出すことにコストを掛けるよりも、まずは仕組みを作って実行できる環境を整えることを優先しました。これは参照系APIに、マスタデータを更新したり決済したりといった、クリティカルな機能が含まれていないからこその判断です。 クリティカルな機能が含まれるWeb APIの場合は、純粋に全パターンを網羅するテストを実施するほうが望ましいでしょう。 作成したテストパターンを使う 作成したテストパターンでテストを実行します。実行自体は前述と同様で、テストシナリオとして指定するファイルを変更するだけです。参照系APIは非公開APIのため、URLはダミーです。 Feature: Test api Background: * def baseUrl = ' http://localhost:8080/api ' Scenario: Pass Through Test Given url baseUrl + ' /v1/xxxxx/yyyyy ' When method get Then status 200 Given url baseUrl + ' /v1/xxxxx/yyyyy/?id=hoge ' When method get Then status 200 ...省略... java -jar karate.jar PassThroughTest.feature ...省略... 04:00:43. 703 [ main ] DEBUG com.intuit.karate - response time in milliseconds: 53 67 < 200 67 < Content-Type: application/json 67 < Transfer-Encoding: chunked 67 < XXX, XX Sep 2021 04:00:43 GMT 67 < Keep-Alive: timeout = 60 67 < Connection: keep-alive { " result " : [] } --------------------------------------------------------- feature: features/local/PassThroughTest.feature scenarios: 1 | passed: 1 | failed: 0 | time: 2 . 4704 --------------------------------------------------------- 04:00:46. 181 [ main ] INFO com.intuit.karate.Suite - <<pass >> feature 1 of 1 (0 remaining) features/local/PassThroughTest.feature Karate version: 1.1.0 ====================================================== elapsed: 6.30 | threads: 1 | thread time: 2.47 features: 1 | skipped: 0 | efficiency: 0.39 scenarios: 1 | passed: 1 | failed: 0 ====================================================== HTML report: (paste into browser to view) | Karate version: 1.1.0 file:///target/karate-reports/karate-summary.html =================================================================== 現時点で67件のテストを実行して、およそ7秒前後の実行時間になっています。これからパターンを増やしていくと実行時間も伸びていきますが、テストが苦になるほどの時間はかからない見込みです。 まとめ Karateを使ったWeb APIのテストを実現するための取り組みについてご紹介しました。ライブラリのバージョンアップといった全体に影響する変更も、テストを実行できる環境があることで、これまでよりも安心して行えるようになりました。ただ、今回作成したテストシナリオもまだまだ万全ではなく、今後はテストパターンや確認する内容を増やしていく必要があります。引き続き安定してサービスを提供できるよう、ZOZOTOWNの改善を進めていきます。 さいごに ZOZOTOWNのリプレイスはこれからも続きます。機能の追加とパフォーマンスの維持、安定稼働を両立しながら進めなければなりません。 ZOZOテクノロジーズでは、一緒にサービスを成長させていく仲間を募集中です。ご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co
アバター
はじめに こんにちは、EC基盤本部・MA部・MA基盤チームでマーケティングオートメーションのシステムを開発している長澤( @snagasawa_ )です。この記事では、CypressによるE2EテストをVue.jsプロジェクトへ導入した取り組みについて、実際の画面を交えてご紹介します。このE2Eテストによって、複雑な入力フォームを自動でテストできるようになり、修正後のバグを検知しやすくなりました。E2Eテストの導入を検討されている方の参考になれば幸いです。 Vue.jsプロジェクトの技術スタック 今回Cypressを導入したプロジェクトの主な技術スタックは以下の通りです。 Vue.js TypeScript Vuetify Open API 導入背景 E2Eテスト導入の理由は、複雑な入力フォームを動作保証するためです。 我々のチームでは、Line Friendship Manager(以下、LFM)という名前のLINEメッセージ配信ツールを開発・運用しています。詳細は割愛しますが、任意のユーザーセグメントに対してLINEメッセージを配信できる社内向けのマーケティングツールです。このプロダクトでは以下のような複雑な入力フォームが複数画面に渡って存在しています。 入力された値によって動的にフィールドの種類や数が変わる フィールドの中にフィールドが存在する入れ子構造になっている 使用するユーザーが限られた管理画面ということもあり、開発リソースは最小限でした。開発メンバーにはデザイナーもフロントエンドを専門とするエンジニアもおらず、1・2名のエンジニアのみでサーバーサイドとインフラを兼任してフロントエンドを開発していました。そのため、UIもCSSは最低限の実装のみで、基本的にはVuetifyのコンポーネントを組み合わせて開発していました。 一方で、上に挙げたような画面の複雑さがあり、修正の度に入力フォームの正しい挙動を担保する必要がありました。E2Eテスト導入までは手動で動作確認を行なっていましたが、検証パターンの抜け漏れが懸念でした。このため、コンポーネント単位のユニットテストよりも、コンポーネントの状態を組み合わせた複雑な画面変化のパターンを網羅しうるE2Eテストが必要でした。 Cypressを採用した理由 E2EテストのフレームワークはCypress以外にも複数存在しますが、Cypressを選択した理由は次の2つからです。 GUIテストランナー カスタムコマンド機能 1つ目のCypressのテストランナーは、ブラウザ上で画面のレンダリングとテストコードによるDOM操作をリアルタイムに再生する機能です。 docs.cypress.io docs.cypress.io 具体的には以下のような操作が可能で、デバッグがしやすくなります。 テスト実行中にDOM操作の再生を中断 テストが失敗した時の画面のレンダリングの確認 クリックした要素の取得やDeveloper ToolsのConsole Logへの出力 テスト実行後に指定のDOM操作まで巻き戻し このデバッグのしやすさが、今回のプロダクトのようにフォームが複雑であっても、意図しない画面状態を視認する一助になってくれるだろうという期待がありました。 2つ目はカスタムコマンド機能です。Cypressで事前定義されているコマンドを組み合わせて、独自のDOM操作を定義し再利用することで、効率的にテストを実装できます。後ほど実際に使用しているカスタムコマンドをご紹介します。 docs.cypress.io CypressのVue.js + TypeScript環境構築 ここからは実際の画面をもとに、導入からテスト実装の流れを説明します。 はじめにCypressをインストールし、 cypress open を実行します。そうするとデフォルトのディレクトリとファイルが作成され、テストランナーが起動します。TypeScriptの場合は、tsconfig.jsonを追加し、各種ファイルの拡張子を変更します。デフォルトのファイルを変更した場合は、cypress.jsonでもパスを修正します。 $ npm install cypress --save-dev $ npx cypress open $ tree ./cypress cypress ├── fixtures │ └── example.json ├── integration ├── plugins │ └── index.js ├── screenshots ├── support │ ├── commands.js │ └── index.js └── videos $ mv cypress/plugins/index.js cypress/plugins/index.ts $ mv cypress/support/index.js cypress/support/index.ts $ mv cypress/support/commands.js cypress/support/commands.ts cypress/tsconfig.json { " extends ": " ../tsconfig.json ", " compilerOptions ": { " types ": [ " cypress " ] } , " include ": [ " ../node_modules/cypress ", " ./**/*.ts " ] } cypress.json { " baseUrl ": " http://localhost:8082 ", " pluginsFile ": " cypress/plugins/index.ts ", " supportFile ": " cypress/support/index.ts ", " video ": false , " screenshotOnRunFailure ": false } 補足すると、Vue.jsプロジェクトではCypressをvue-cliのプラグインとしてもインストールできます。しかし、プラグインはバージョンが本家よりも古いためオススメしません。記事公開の時点ではCypressの最新バージョンは v8.3.0 ですが、プラグインでは v7.1.x となっています。Cypressはリリースが早く、次々と新しい機能が追加されるため、新しいものを使うほうが便利です。ただ、バグも多いので新規機能の追加直後の利用はご注意ください。 テスト実装の流れ 環境構築が終わったら実際にテストを実装していきます。 前述のLFMから、配信対象となるユーザーのセグメントを登録する「セグメント画面」をテストコードの題材にします。下の画像が画面のキャプチャです。 このセグメント画面で、条件を組み合わせてセグメントのユーザー数を確認・登録すると、その条件に基づきLINEメッセージが配信されます。例えば、「性別: 男性」「年齢: 20歳以上」「ZOZOカードを利用している」といった条件を指定できます。Google Analyticsをご存知の方であれば、セグメントビルダーを思い出していただければイメージしやすいかと思います。 このように複数の条件を組み合わせて登録する画面のため、フィールドの数が増減したり、条件の指標次第でフィールドが動的に切り替わる仕様となっています。また、これらのフィールドの状態をチェックしてボタンの活性・非活性を制御する必要もあります。 画面操作の主な流れは以下の通りです。 条件を指定する ターゲットを抽出してユーザー数を確認する 登録する これに沿ったテストの実装の流れは次の通りです。 画面にアクセスした時のリクエストのレスポンスをスタブにする テストデータを生成するFactoryを定義する カスタムコマンドを定義し、DOM操作のコマンドを書く 操作完了時の画面の遷移をチェックする interceptによるレスポンスのスタブ まずはじめに、画面からリクエストされるAPIのスタブを行います。 cy.visit() で画面へアクセスする際に発生するAPIリクエストのレスポンスを cy.intercept() でスタブにします。 cy.intercept() はリクエストのメソッド・URL・レスポンスを引数に渡すことで、それにマッチしたリクエストへ任意のレスポンスを返すことができます。 docs.cypress.io 今回は「ターゲット抽出」と「登録」の2つのリクエストで指定したレスポンスを返し、それ以外のリクエストは空のレスポンスを返すようにします。下はサンプルコードですが、第3引数に渡しているテストデータの segment 変数については後述します。 beforeEach (() => { // APIはデフォルトで空のレスポンスを返す cy.intercept ( /api/ , [] ); // ターゲット抽出APIと登録APIにアクセスするとsegmentを返す cy.intercept ( 'POST' , '/api/segments/count' , segment ) . as( 'countTarget' ); cy.intercept ( 'POST' , '/api/segments' , segment ) . as( 'saveSegment' ); // セグメント画面へアクセス cy.visit ( `${Cypress.config().baseUrl}/#/segments/new` ); } ); スタブしたリクエストは .as() でエイリアスを指定できます。エイリアスは cy.wait() でそのリクエストが完了するまで自動待機する時などに利用できます。 テストデータの生成 続いてテストで利用するテストデータを生成します。先ほどの segment 変数はスタブしたAPIのレスポンスであり、セグメントの条件やターゲットを抽出した結果のユーザー数を保持するオブジェクトが格納されます。今回はこのオブジェクトをテストデータのFactoryによって生成します。 cy.intercept() のレスポンスには、オブジェクトや cy.fixture() で生成したテストデータなどを渡すことができます。簡単な方法では、単に固定のJSONファイルを cy.intercept() の fixture オプションに渡すことも可能です。 しかし、LFMではOpen APIの定義からフロントエンドの型定義を生成しているため、テストデータもこの型定義と一致させるようにFactoryを定義しています。これによって、Open APIのスキーマ変更後にプロダクトコードのみを修正してFactoryの修正を忘れた場合は、テスト実行前のビルドが型定義のエラーによって異常終了するようにしています。 import * as faker from 'faker' ; // Open APIで生成した型定義 import { Segment , SegmentCondtion , SegmentTargetCount } from '@/services/api/types/segment' ; // Factoryの型定義 interface Factory < T > { create ( params?: Partial < T >) : T ; createList ( num: number ) : T [] ; } // Factoryの定義 const segmentFactory: Factory < Segment > = { create ( params ) { return { id: faker.random. number (), name: `セグメント${faker.random.number()}` , status : SegmentStatus.FIXED.value , segmentConditions: [ segmentConditionFactory.create () ] , segmentTargetCount: segmentTargetCountFactory.create (), createdTime: new Date (), updatedTime: new Date (), ...params , } ; } , createList ( num: number ) { return [ ... Array ( num ) ] .map ( n => this .create ()); } , } ; // segmentに含まれるオブジェクト用のFactory const segmentConditionFactory: Factory < SegmentCondition > = { /* 省略 */ } ; const segmentTargetCountFactory: Factory < SegmentTargetCount > = { /* 省略 */ } ; // テストデータの生成 const segment = segmentFactory.create (); カスタムコマンドによるDOM操作 最後にDOM操作とアサーションを追加していき、テストコードを完成させます。 テストコードを書いていると同じような実装が頻繁に登場します。特にVuetifyのようなUIコンポーネントライブラリを利用している場合は尚更です。そこでテストコードの実装をDRYにしたい場合、カスタムコマンドを定義して再利用するという方法があります。 公式ドキュメントでは、 要素のセレクターのベストプラクティス として data-* をコンポーネントの属性に追加し、CSSやJavaScriptの変更の影響を受けないように記述することが推奨されています。しかし、UIコンポーネントライブラリを利用している場合は、コンポーネントごとにカスタムコマンドを作ることでDOM操作をDRYに書けます。そのため、必ずしもこのプラクティスを厳守せずに、適宜カスタムコマンドを組み合わせて使っています。 実際に利用しているカスタムコマンドの一部です。このように小さなDOM操作を組み合わせてカスタムコマンドを定義しています。 cypress/support/commands.ts // 頻出する操作をカスタムコマンドとして定義 // 操作:ボタン要素の特定・テキストエリアの特定・ボタンを押す・テキストエリアを埋める Cypress.Commands.add ( 'getVBtn' , ( content: string ) => { cy.get ( '.v-btn' ) .contains ( 'span' , content ) . parent (); } ); Cypress.Commands.add ( 'getVTextField' , ( label: string ) => cy.contains ( 'label' , label ) .next ( 'input' ) ); Cypress.Commands.add ( 'clickVBtn' , ( content: string ) => cy.getVBtn ( content ) .click ( { force: true } ) ); Cypress.Commands.add ( 'fillInVTextField' , ( label: string , value: string | number ) => { return cy .getVTextField ( label ) .clear () . type( `${value}` ); } ); // カスタムコマンドの型定義 // これを行わないと、cyの後のメソッド呼び出しでエラーになる declare namespace Cypress { interface Chainable < Subject > { getVBtn: ( content: string ) => Chainable < Subject >; getVTextField: ( label: string ) => Chainable < Subject >; clickVBtn: ( content: string ) => Chainable < Subject >; fillInVTextField: ( label: string , value: string | number ) => Chainable < Subject >; } } カスタムコマンドを定義したら、実際にDOM操作のコードを書いていきます。下のコードは、セグメント画面の名前入力から条件指定までの操作をコード化したものです。 const fillOutForm = () => { // ① 名前の入力 cy.fillInVTextField ( '名前' , 'セグメント01' ); // ② 「条件を追加する」ボタンの押下 cy.clickVBtn ( '条件を追加する' ); // 条件のフィールドが1行追加されたことを確認 cy.get ( '[data-cy=segment-condition-field]' ) .should ( 'have.length' , 1 ); // ③ 条件の指標の選択 selectSegmentConditionDimension ( '年齢' ); // ④ 条件の値の入力 inputSegmentConditionValue ( 20 ); // ⑤ 条件の演算子の選択 selectSegmentConditionOperator ( '以上である' ); } ; const selectSegmentConditionDimension = ( text: string , index: number = 0 ) => { cy.get ( '[data-cy=segment-condition-dimension]' ) .eq ( index ) . parent () .click (); cy.contains ( text ) .click (); } ; const inputSegmentConditionValue = ( value: string | number , index: number = 0 ) => { cy.get ( '[data-cy=segment-condition-value]' ) .eq ( index ) . type( `${value} {enter}` ); } ; const selectSegmentConditionOperator = ( text: string , index: number = 0 ) => { cy.get ( '[data-cy=segment-condition-operator]' ) .eq ( index ) . parent () .click (); cy.contains ( text ) .click (); } ; このように要素の取得と、それに対する操作やアサーションの記述を繰り返していきます。 続いて、条件指定後の登録成功時の画面遷移までのコードです。 describe ( 'フォーム入力から登録ボタンを押すまで' , () => { context ( 'response status: 200' , () => { it ( 'スナックバーが表示され、セグメント一覧画面に遷移する' , () => { // ①〜⑤ フォーム入力 fillOutForm (); // ⑥ 「ターゲット数抽出」ボタンの押下 cy.getVBtn ( 'ターゲット数抽出' ) .should ( 'not.be.disabled' ) .click (); // ターゲット抽出APIのレスポンス待機 cy.wait ( '@countTarget' , { timeout: 5000 } ); // ⑦ 抽出結果の表示確認 cy.get ( '[data-cy=segment-target-count__target-count]' ) .contains ( segmentTargetCount.targetCount ); cy.get ( '[data-cy=segment-target-count__segmented-at]' ) .contains ( formatDate ( segmentTargetCount.segmentedAt ) ); // ⑧ 登録ボタンの押下 cy.getVBtn ( '登録' ) .should ( 'not.be.disabled' ) .click (); // 登録APIのレスポンス待機 cy.wait ( '@saveSegment' , { timeout: 5000 } ); // 登録成功メッセージの表示確認 cy.get ( '.v-snack__content' ) .should ( 'include.text' , '登録しました' ); // 登録成功時の一覧画面遷移の確認 cy.url ( { timeout: 5000 } ) .should ( location => expect ( location ) .to.include ( '/#/segments?tab=' ) ); } ); } ); } ); これまでのコードをまとめた最終的なテストコードは以下の通りです。 import { Operator } from '@/enum/segment' ; import { formatDate } from '@/services/formatter' ; import { segmentConditionFactory , segmentFactory , } from '@/factories' ; // テストデータの生成 const segmentCondition = segmentConditionFactory.create ( { dimension: '年齢' , value: 20 , operator: Operator.GREATER_THAN_OR_EQUAL_TO.value , } ); const segment = segmentFactory.create ( { name: 'セグメント01' , segmentConditions: [ segmentCondition ] , } ); const segmentTargetCount = segment.segmentTargetCount ; const fillOutForm = () => { // ① 名前の入力 cy.fillInVTextField ( '名前' , 'セグメント01' ); // ② 「条件を追加する」ボタンの押下 cy.clickVBtn ( '条件を追加する' ); // 条件のフィールドが1行追加されたことを確認 cy.get ( '[data-cy=segment-condition-field]' ) .should ( 'have.length' , 1 ); // ③ 条件の指標の選択 selectSegmentConditionDimension ( '年齢' ); // ④ 条件の値の入力 inputSegmentConditionValue ( 20 ); // ⑤ 条件の演算子の選択 selectSegmentConditionOperator ( '以上である' ); } ; const selectSegmentConditionDimension = ( text: string , index: number = 0 ) => { cy.get ( '[data-cy=segment-condition-dimension]' ) .eq ( index ) . parent () .click (); cy.contains ( text ) .click (); } ; const inputSegmentConditionValue = ( value: string | number , index: number = 0 ) => { cy.get ( '[data-cy=segment-condition-value]' ) .eq ( index ) . type( `${value} {enter}` ); } ; const selectSegmentConditionOperator = ( text: string , index: number = 0 ) => { cy.get ( '[data-cy=segment-condition-operator]' ) .eq ( index ) . parent () .click (); cy.contains ( text ) .click (); } ; // レスポンスのスタブと画面へのアクセス beforeEach (() => { cy.intercept ( /api/ , [] ); cy.intercept ( 'POST' , '/api/segments/count' , segment ) . as( 'countTarget' ); cy.intercept ( 'POST' , '/api/segments' , segment ) . as( 'saveSegment' ); cy.visit ( `${Cypress.config().baseUrl}/#/segments/new` ); } ); describe ( 'フォーム入力から登録ボタンを押すまで' , () => { context ( 'response status: 200' , () => { it ( 'スナックバーが表示され、セグメント一覧画面に遷移する' , () => { // ①〜⑤ フォーム入力 fillOutForm (); // ⑥ 「ターゲット数抽出」ボタンの押下 cy.getVBtn ( 'ターゲット数抽出' ) .should ( 'not.be.disabled' ) .click (); // ターゲット抽出APIのレスポンス待機 cy.wait ( '@countTarget' , { timeout: 5000 } ); // ⑦ 抽出結果の表示確認 cy.get ( '[data-cy=segment-target-count__target-count]' ) .contains ( segmentTargetCount.targetCount ); cy.get ( '[data-cy=segment-target-count__segmented-at]' ) .contains ( formatDate ( segmentTargetCount.segmentedAt ) ); // ⑧ 登録ボタンの押下 cy.getVBtn ( '登録' ) .should ( 'not.be.disabled' ) .click (); // 登録APIのレスポンス待機 cy.wait ( '@saveSegment' , { timeout: 5000 } ); // 登録成功メッセージの表示確認 cy.get ( '.v-snack__content' ) .should ( 'include.text' , '登録しました' ); // 登録成功時の一覧画面遷移の確認 cy.url ( { timeout: 5000 } ) .should ( location => expect ( location ) .to.include ( '/#/segments?tab=' ) ); } ); } ); } ); 上記のテストコードではカスタムコマンドがシンプルなボタン操作やフィールド入力のみのため、もしかするとその恩恵が分かりづらいかもしれません。しかし、テスト対象のコンポーネントの数が増え、同じ操作が繰り返される場合にはそのメリットを実感できます。もう少し複雑な例では、TimePickerやDatePicker用のカスタムコマンドも定義しました。このような複数のステップに渡る操作の場合はカスタムコマンド化によるメリットがより大きくなります。 Cypress.Commands.add ( 'clickOnVDatePicker' , ( label: string , date: string ) => { cy.getVTextField ( label ) .click ( { force: true } ); cy.get ( '.v-menu__content.menuable__content__active > .v-picker--date' ) .first () .within (() => { cy.contains ( '.v-btn' , date ) .click ( { force: true } ); cy.clickVBtn ( 'OK' ); } ); } ); Cypress.Commands.add ( 'clickOnVTimePicker' , ( label: string , hour: string , minute: string ) => { cy.getVTextField ( label ) .click ( { force: true } ); cy.get ( '.v-menu__content.menuable__content__active > .v-picker--time' ) .first () .within (() => { cy.contains ( '.v-time-picker-clock__item' , hour ) .click ( { force: true } ); cy.contains ( '.v-time-picker-clock__item' , minute ) .click ( { force: true , } ); cy.clickVBtn ( 'OK' ); } ); } ); E2Eテストによる効果 テスト実装後はCircleCIに設定を追加し、GitHubのPRにコミットがプッシュされるたびにテストが実行されるようにしました。これによって手動確認していた検証パターンの一部を代替し、当初の懸念であった抜け漏れの発生する可能性を低減できました。実際にテストを導入した画面では、その後バグが1件も発生していません。また、手動での確認時間を削減できた分、以前より開発サイクルが早くなりました。 まとめ Vuetifyを利用したVue.jsプロジェクトへ、CypressによるE2Eテストを導入する取り組みについてご紹介しました。また、Cypressのテストランナーやカスタムコマンドによる開発効率化についても紹介しました。効率的に画面開発の保守性を高める必要に迫られた時にはCypressの導入検討をオススメします。 さいごに ZOZOテクノロジーズでは一緒にプロダクトを開発してくれるエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください! tech.zozo.com
アバター