TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

988

はじめに こんにちは、データシステム部データ基盤ブロックの纐纈です。9月から22卒内定者として、チームにジョインしました。 本記事では、弊社のデータ基盤チームが抱えていた課題と、その解決のために公開したOSSツール「Coppe」を紹介します。Coppeは、以下のような方にお勧めできるツールです。 BigQueryを使用したデータ基盤の監視に興味がある BigQueryの監視ツールとしてRedashを採用しているが、運用が面倒に感じている インフラの設定なしにBigQueryの監視を行えるツールが欲しい なお、本OSSはMonotaRO Tech Blogの記事「 SQLを使った監視でデータ基盤の品質を向上させる 」で紹介されていた仕組みを参考にし、より柔軟に監視項目を設定できるように新規開発しています。 OSSとして公開しているため、本記事と併せてご覧ください。 github.com 開発の経緯 現在、ZOZOはデータ基盤としてBigQueryを採用しています。そこには、オンプレやAWS、アプリケーションのログなど、あらゆるデータを集めており、タイミングも日次収集のものや、リアルタイム収集のものが存在します。その収集時に、遅延やオペレーションミス、意図しないデータの肥大化により、データ品質が下がってしまうことがあります。 その結果、データ基盤を利用した関連サービスに最新の正しい情報を反映できなくなってしまいます。そうなってしまうと、ZOZOが提供するサービスを利用するユーザーに、直接的な影響を与えてしまう可能性もあります。そのため、データの品質劣化には、いち早く気づき、対応する必要があります。 その対応策として、現在はRedashを使用しています。Redashは、SQLの分析結果をダッシュボードに可視化するOSSのBIツールです。これを利用し、BigQueryに定期的な監視クエリを実行し、その結果が期待値から外れる場合には、Slack通知で検知できるようにしています。一見すると、Redashで事足りているように見えますが、監視ツールとしては物足りない部分もあります。 redash.io 1点目の課題は、Redash自体をホスティングするためにWebサーバーやデータベース、Redisなどを自前で用意する必要がある点です。これは導入時に手間がかかるだけでなく、用意した環境の1つに障害が起きた際には、データ品質の監視ができなくなるという欠点があります。加えて、障害が発生したサーバーやサービスを立ち上げ直すのに手間と時間を要する点も懸念点です。 techblog.zozo.com また、いつ誰によってどんな目的でその監視項目を追加したのかといったことが不明瞭になったり、他チームからの監視項目の追加の要請をRedashを管理する弊チーム以外ができなかったりという課題点もあります。 そこで、Redashよりも気軽に運用が可能で、監視項目の管理をGitHub上で行える監視ツールを開発することにしました。 Coppeの機能 監視ツールCoppe(以下、Coppe) は、BigQueryへの定期的な監視を実施します。また、非機能要件として、以下の点を目的にしています。 障害発生時に、可能な限り自動再生できるインフラ構成 導入時のセットアップや監視項目の追加を気軽に行える仕様 なお、「Coppe」という名前は蜘蛛から着想を得ています。監視項目を「蜘蛛の巣」と見立て、エラーを検知したらすぐに検知して動き出すイメージで名付けました。「Coppe」は英語で昔使われていた蜘蛛を意味する単語です。私は虫が苦手なため、「Spider」のような蜘蛛を直接連想しやすい名前ではないので、この名前を気に入っています。 Coppeは、監視項目をYAMLとSQLで指定することで、指定されたスケジュールに沿ってBigQueryへの定期的なチェックを実行し、データ品質の監視を行います。監視項目が検知された場合には、Slackにアラートメッセージを通知します。アラートメッセージは、監視項目ごとにクエリの実行結果などを設定可能です。また、監視項目の追加は、YAMLとSQLで記述してGitHubにプッシュすると、GitHub ActionsによってGCPに自動デプロイされます。インフラのセットアップも、GitHub ActionsからTerraformを利用して、必要な環境を自動的にセットアップします。詳しいインフラ構成は後述します。 次に、Coppeの監視項目をサンプルを用いて説明します。 監視項目の追加は、以下のようなフォーマットでYAMLファイルに記述します。 - schedule : "*/5 * * * *" sql : SELECT COUNT(*) AS error_log_cnt FROM `project.schema.table` WHERE ... expect : row_count : 0 description : 直近5分の間にエラーログを検知しました。 上記の例で示したパターン以外にも、様々なオプションを用意しています。基本となる設定項目は以下の4つです。 監視スケジュール crontab形式による指定 BigQueryで実行するクエリ 期待するクエリ結果 アラートメッセージの内容 さらに複雑な監視項目を設定したい場合、以下のようなフォーマットにも対応しています。 - schedule : "0 * * * SUN,TUE,WED,THU,FRI" sql_file : streaming-datatransfer-delay-alert.sql matrix : env : [ stg, prd ] params : interval_minute : 5 expect : row_count : 0 description : | 次のテーブルで5分以上の遅延が発生しています {{ range . }} {{ .table_name }} : {{ .cnt }} (cnt) : {{ .delay_avg }} (delay_avg) : {{ .delay_max }} (delay_max) {{ end }} # streaming-datatransfer-delay-alert.sql SELECT label, table_name, COUNT (*) AS cnt, AVG (diff) AS delay_avg, MAX (diff) AS delay_max FROM ( SELECT table_name, changetrack_start_time, bigquery_insert_time, TIMESTAMP_DIFF(bigquery_insert_time, changetrack_start_time, SECOND) AS diff, CASE WHEN TIMESTAMP_DIFF(bigquery_insert_time, changetrack_start_time, SECOND) > 600 THEN 1 ELSE 0 END AS label FROM `streaming-datatransfer-{{.env}}.streaming_datatransfer.streaming_changetracktransfer_T*` WHERE bigquery_insert_time >= TIMESTAMP_SUB( CURRENT_TIMESTAMP (), INTERVAL {{.interval_minute}} MINUTE)) WHERE label = 1 GROUP BY label, table_name 上記の例は、以下の設定をしています。 日曜日と火〜金曜日に、指定のSQLファイルに記載されたクエリを毎時実行する streaming-datatransfer-stg と streaming-datatransfer-prd のプロジェクトで実行する クエリ結果を評価し、期待されている結果と照らし合わせる アラートメッセージにクエリ結果を展開し、パースした状態で通知する 監視項目のフィールドや書式について、詳しく説明します。 スケジュール schedule : "* * * * *" 監視のスケジュールは、crontab形式を採用しています。そのため、crontab同様に以下の順で指定します。 分 時 日 月 曜日 書式に従い、以下のように使用します。 毎分: "* * * * *" 10分おき: "*/10 * * * *" 毎時0分: "0 * * * *" 毎日0時: "0 0 * * *" 毎週月〜金の18時: "* 18 * * MON,TUE,WED,THU,FRI" クエリ BigQueryで実行するクエリは、 直接SQLを記載する方法と、SQLを記載した別ファイルへの相対パスを指定する方法があります。 sql: に直接SQLを記載する場合は、以下のようにYAML内にSQLを埋め込みます。 sql : SELECT * FROM ... また、SQLを記載した別ファイルの相対パスを指定する場合は、 sql_file: に記載します。 sql_file : some_dir/file_name.sql 他にも、テキストテンプレートの書式を使用し、以下のように params: を使用してSQL内にパラメータを入れることもできます。さらに、1つの監視項目を複数の組み合わせのパラメータに対して実行したい場合は、GitHub Actionsのマトリックスの仕様と同様に、 matrix: に配列を指定することも可能です。 sql : SELECT * FROM `sample-{{ .env }}-svc.some_schema.some_table_{{ .platform }}` WHERE timestamp > {{ .since }} params : since : TIMESTAMP_SUB(CURRENT_TIMESTAMP, INTERVAL 1 HOUR) matrix : env : [ dev, stg, prd ] platform : [ android, ios ] 期待するクエリ結果 監視対象となるクエリの期待値は、以下のように指定します。クエリ結果の列数を row_count: に指定したり、クエリ結果を使用した式 expression: を指定可能です。なお、式を利用することで、より複雑な監視条件の設定ができます。 expectation : row_count : 0 expectation : expression : column_name_1 == "foo" && column_name_2 == 0 アラートメッセージ 監視対象に指定したクエリの期待値から外れた結果を得た場合、Slackチャンネルにアラートを通知します。そのアラートの通知内容は description: にて指定可能です。 なお、アラートメッセージには、以下のように、クエリ結果やSQLで使用した params: や matrix: の値を展開して利用可能です。 # サンプルの監視項目 - schedule : "0 * * * *" sql : SELECT column_1, column_2 FROM `sample-{{ .env }}-svc.some_schema.some_table_{{ .platform }}` WHERE timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP, INTERVAL {{ .since_hour }} HOUR) params : since_hour : 1 matrix : env : [ prd, stg, dev ] platform : [ android, browser, ios, server ] expect : row_count : 0 description : | `sample-{{ .matrix.env }}-svc.some_schema.some_table_{{ .matrix.platform }}`にて、{{ .params.since_hour }}時間前から現在までの間に、以下のレコードが検出されました クエリ結果 {{ range .query_result }} - column_1 : {{ .column_1 }} , column_2: {{ .column_2 }} {{ end }} 上記の設定をした場合、実際には以下のようなアラートメッセージが生成されます。 # 実際のアラートメッセージ `sample-prd-svc.some_schema.some_table_ios`にて、1時間前から現在までの間に、以下のレコードが検出されました クエリ結果 - column_1 : aa, column_2: bb - column_1 : ab, column_2: bc Coppeのインフラ構成 本章では、Coppeのインフラ構成と、その選定理由を説明します。 Coppeのインフラ構成は、上図に示す通りです。Cloud Functionsをデプロイ先として、Pub/SubやCloud Schedulerを使用します。また、上図では省略していますが、Cloud Functions自体を監視するために、Cloud Monitoringも使用しています。 具体的な仕組みを説明します。Cloud Schedulerにより、毎分の間隔でPub/Subを介して上図左側のCloud Functionsを起動します。ここでは、YAMLファイルを元にスケジュールを実行するか判断し、SQLファイルのパースを行った上で、上図右側のPub/Subに監視項目のデータを渡します。そして、上図右側のCloud Functionsは、Pub/Subから監視項目を受け取り、BigQueryに問い合わせ、その結果が期待される値と等しいかどうかを確認します。期待される値と異なった場合は、指定したSlackチャンネルに通知します。 上記のインフラ構成は、前述のRedash運用の課題も考慮し、以下の選定基準で策定しました。 基準1:運用の手間が可能な限り不要である 基準2:費用が可能な限り抑えられる 実際に、デプロイ環境の候補に挙がったのは、以下の5つでした。なお、クエリの実行先がBigQueryということもあり、今回のインフラ選定ではGCP環境のみを検証対象にしています。 Cloud Run App Engine Cloud Functions Compute Engine Kubernetes Engine この5つの候補から、上記の2つの基準から選定していきます。アプリケーションだけでなく、インフラ面の運用が必要となるCompute Engine、クラスター自体に固定費がかかるKubernetesは基準から外れるため、候補から除外されました。アプリケーションであれば、BigQueryから取得した内容の計算が必要となる場合が多いです。しかし、Coppeでは、ほとんど計算力や拡張性を必要としておらず、2つの基準にある運用面と金銭面で、費用に見合わないため、この判断をしました。 また、Cloud Runも別途コンテナを用意する必要があるため、運用面の基準により候補から除外しました。Cloud Runはコンテナを使うため、Googleがサポートする言語以外も利用可能になるメリットがあります。しかし、今回のアプリケーションの要件では、特定の言語に依存する必要性もなく、Googleがサポートしている言語で十分に開発可能でした。そのため、このメリットの恩恵は受けられないと判断しました。 ここまでの検討の結果で、App EngineとCloud Functionsの2択に絞られました。どちらも、機能面ではCoppeで実現させたいことが可能であり、費用も少額、そして運用の手間も少なくて済む特徴を持っています。 しかし、App Engineは、プロジェクト毎に1つのアプリケーションしかデプロイできない条件があります。これは、「既にApp Engineを使っているプロジェクトでは、Coppeを使うことができない」という制約を発生させます。そのため、最終的にCloud Functionsを選定しました。Cloud Functionsは、ファンアウトパターンを容易に実現でき、コードがシンプルに書けるといった利点も持っています。 また、Coppeに必要なインフラ構成はTerraformを使って管理しています。初回に限り、以下の処理が必要ですが、それ以降はGitHub Actionsによって自動デプロイされる仕組みです。 SlackのWebhook URLとGCPのプロジェクト名を環境変数用のファイルに書き込む Slackへの通知をCloud Console上で許可する Terraformによるインフラ構築の下準備に必要なスクリプトを実行する まとめ 本記事では、データ品質担保のためのBigQuery基盤のデータ監視ツールである「Coppe」を紹介しました。Coppeの開発により、YAMLやSQLを使った監視項目の追加が可能になり、複数の環境を横断する設定も容易に実現可能になりました。その結果、BigQueryのデータに異常がないか、容易に定期的なチェックができるようになりました。 運用を開始してから、まだ日は浅いですが、Redashを活用していた時と同様の監視をCoppeで実現できています。本記事を読んで、使ってみたいと思った方は、是非使ってみてください。IssueやPull Requestもお待ちしております。 ZOZOではデータ基盤のガバナンスを強化し、利用者にとって安全安心なデータ基盤を整備していく仲間を募集しています。ご興味のある方は、以下のリンクからご応募ください。 hrmos.co
こんにちは、ZOZO CTOブロックの池田( @ikenyal )です。 エンジニアが12月に思い浮かべるキーワードは何でしょう。「アドベントカレンダー」ですね。 弊社も毎年アドベントカレンダーに参加しており、今年は合計125本の記事公開を完走しましたので、概要をお伝えします。 ZOZO Advent Calendar 2021 今年は合計5個のカレンダーを実施したため、12/1-25の期間に合計125本の記事を公開しました。 qiita.com 実施概要 アドベントカレンダーは任意参加で実施しています。 アドベントカレンダーはエンジニアのアウトプットの練習に適したイベントです。弊社ではテックブログをアウトプットの主軸に置いていますが、「まだテックブログを書く自信が無い」「テックブログに書くにはネタが小粒」のような場合に、アドベントカレンダーは良い機会です。 人気があった記事 はてなブックマークのブックマーク数が上位だったものを紹介します。 今年の数値を確認したところ、上位2記事の執筆者は新卒の2名でした。この2本の記事のブックマーク数は僅差であり、3位とも差を付けており、新卒エンジニアの力を改めて実感しました。 12/19 カレンダー1 の記事、 y_takaya による「 Goを学ぶときに参照した学習リソースまとめ 」 zenn.dev 皆さん、新しいプログラミング言語を学ぶ時、どのように学習しているでしょうか?私は4月に新卒でエンジニアになり、業務でGoを使うことになりました。その際、とりあえず公式チュートリアルであるTour of Goをやりましたが、その後にどうやって学習を進めれば良いか迷ってしまいました。 考えてみると、新しい言語を学ぶ際、毎回学習方法に困っている気がします。ネットでサンプルを探す、動画を見る、書籍を読む、などさまざまな学習方法があると思いますが、私は手を動かしながらいろいろなパターンを学んでいくのが好きです。 そこで今回Goを学ぶ際も、手を動かしてさまざまなコーディングのパターンを学習するために、ネットや書籍でサンプルを探して実践しました。この学習方法は私にとっては楽しみながら続けることができて、他の言語を学ぶ際も今回実装したサンプルを使って学習しようと考えています!そこで自分と同じ様な悩みを持っている人や未来の自分のために、手を動かしながら新しい言語(今回はGo)を学ぶ際に、自分が取り組んだ方法をまとめてみます! 12/10 カレンダー5 の記事、 tmrekk による「 Kubernetesを使う上で知っておきたいツールやプラグイン 」 zenn.dev 本記事では、普段Kubernetesを触っている中で便利に感じたツールやコマンドをざっくばらんに紹介します。 Kubernetes初心者からベテランまで幅広く楽しんでいただければ幸いです。 過去のアドベントカレンダー ZOZOでは、2018〜2020年もアドベントカレンダーに参加しています。なお、10/1に株式会社ZOZOテクノロジーズは組織再編が行われ、株式会社ZOZO及び株式会社ZOZO NEXTとして再始動しています。 ZOZOテクノロジーズ Advent Calendar 2020 qiita.com qiita.com qiita.com qiita.com ZOZOテクノロジーズ Advent Calendar 2019 qiita.com qiita.com qiita.com qiita.com qiita.com ZOZOテクノロジーズ Advent Calendar 2018 qiita.com qiita.com qiita.com 最後に ZOZOでは、プロダクト開発以外にも、今回のような外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
こんにちは、ZOZO CTOブロックの池田( @ikenyal )です。 ZOZOでは、12/7に ZOZO Tech Talk #2 - iOS を開催しました。 zozotech-inc.connpass.com 本イベントは、これまで夕刻に開催してきたMeetupとは異なり、ランチタイムに開催する「ZOZO Tech Talk」シリーズです。ZOZO Tech Talkでは、ZOZOがこれまで取り組んできた事例を紹介していきます。 そして、第2回はネイティブアプリ開発の中で、特にiOSにフォーカスし、弊社エンジニアがお話ししました。 登壇内容 まとめ 弊社の社員2名が登壇しました。 歩みを振り返ると見えてくる 今、新たな「仲間」がWEARアプリ開発に必要な理由 (メディア開発本部 WEAR部 / 小野寺 賢) 大公開!ZOZOTOWN iOSのコードレビューを覗きながらレビューの必要性を再確認しよう! (ZOZOTOWN開発本部 ZOZOTOWNアプリ部 / 松井 彩) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
こんにちは、ZOZO CTOブロックの池田( @ikenyal )です。 ZOZOでは、12/6に ZOZO Tech Talk #1 - Android を開催しました。 zozotech-inc.connpass.com 本イベントは、これまで夕刻に開催してきたMeetupとは異なり、ランチタイムに開催する「ZOZO Tech Talk」シリーズです。ZOZO Tech Talkでは、ZOZOがこれまで取り組んできた事例を紹介していきます。 そして、第1回はネイティブアプリ開発の中で、特にAndroidにフォーカスし、弊社エンジニアがお話ししました。 登壇内容 まとめ 弊社の社員2名が登壇しました。 ZOZOの新規サービス「FAANS」の開発 Android編 (メディア開発本部 FAANS部 / 山田 尚吾) Android Lintでコードの宣言順をチェックする (ZOZOTOWN開発本部 ZOZOTOWNアプリ部 / 鈴木 優佑) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
こんにちは。ECプラットフォーム部 カート決済ブロックの高橋です。 ZOZOTOWNでは、数年前よりClassic ASPからJavaへのリプレイスが実施されています。そのリプレイスの一環として、2021年4月からカート決済機能のマイクロサービス化を開始しました。 ZOZOTOWNの中長期目標である「商品取扱高5000億円」を達成するために、リプレイス後は以下の要件をシステムが満たしている必要があります。 セールなどの高負荷イベント時にスケール可能であること キャパシティコントロールが可能であること Datadog、SentryなどのSaaSを利用した運用監視の効率化できること CI/CDなどを取り入れ、開発生産性を向上できること レガシー技術をモダン化できること そして、カート決済機能はZOZOTOWNの中でも最も大きな機能であり、最も重要な機能です。そのため、リプレイスは慎重に進めなければなりません。 本記事では、そのリプレイスのPhase1として先日リリースし、キャパシティコントロールを実現させた事例を紹介します。 カート投入機能のリプレイス 本章では、リプレイスしたカートの1つの機能である「カート投入機能」の概要を説明します。 カート投入機能の仕様 まず、ZOZOTOWNにおけるカート投入の仕様を説明します。 「カートに入れる」ボタンを押すことで、以下の処理が行われます。 在庫を引き当てる 同時に、在庫数を減らす カートテーブルへ、その情報を登録する ZOZOTOWNのカート機能の大きな特徴は、カート投入時に在庫の引き当てをしている点です。今回のリプレイスでは、それらのカート投入の仕様は変更せず、アーキテクチャの変更のみを行っています。 つまり、カート投入時に在庫の引き当てをする仕様を変更しないため、「FIFO(First-In First-Out)であること」が重要な要件となりました。 アーキテクチャの変更 これまでは、下図に示すアーキテクチャでした。リクエストをIISで受け、Classic ASPからストアドプロシージャを呼び出し、処理を実行していました。 そして、今回のリプレイスでは、下図のアーキテクチャに変更しています。IISとストアドプロシージャの間にCart Queuing Systemを新規で作成します。これにより、Cart Queuing Systemとストアドプロシージャ呼び出し用のIISを新たに追加しています。 リプレイスの目的 現在のシステムでは、ZOZOTOWNの成長に伴い、高負荷に耐えられなくなる可能性があります。そのため、今回のカート機能のリプレイスの目的は、キャパシティコントロールを可能にすることです。 それを実現するために、Cart Queuing Systemは、下図に示す構成にしました。 リクエストのステータス管理をAmazon DynamoDB(以下、DynamoDB)で管理します。そして、Amazon Kinesis Data Streams(以下、KDS)には、IISへのリクエスト情報やカート投入情報テーブルのキー情報を送信します。 全体の流れは、Cart APIの登録APIが呼び出されるところから始まります。その登録APIでは、以下の処理を行います。 商品情報などのカート投入に必要な情報をDynamoDBへ登録する 上記の 1. で登録した情報のキーと、Cart WorkerがAPIへリクエストするのに必要な情報をKDSへ送信する 次に、Cart Workerで以下の処理を行います。 KDSに送信された情報を取得する DynamoDBの対象レコードに対し、ステータスを「処理中」に更新する 上記の 1. で取得した情報を元に、ストアドプロシージャ呼び出しAPIへリクエストする 上記の 3. のレスポンスを元に、DynamoDBの対象レコードに対し、ステータスを「処理完了」に更新する そして、ステータス取得APIでは、以下の処理を行います。 登録APIで登録したDynamoDBのキーを元に、対象レコードのステータスを取得する ステータスがCart Workerによって「処理完了」に変更されている場合、DynamoDBに登録されている情報を返却する Cart Workerの処理ステータスが「処理中」の場合、一定間隔で指定期間までポーリングを行う 上記の 3. のポーリングの間にステータスが変更された場合、上記の 2. の処理を行う 上記の 3. で指定期間内にステータスが更新されなかった場合、タイムアウトとして返却する なお、Cart APIにあるステータス取得APIのレスポンスがタイムアウトである場合、IISからステータス更新APIを呼び、DynamoDBのレコードのステータスを「タイムアウト」に更新します。 技術スタック Cart APIとCart Workerの技術スタックをご紹介します。 Cart API Cart Worker 言語 Java Java フレームワーク Spring Boot - データベース DynamoDB DynamoDB なお、Cart WorkerはKinesis Client Libraryを使用したアプリケーションであり、フレームワークを使用していません。 過熱商品への対応 ZOZOTOWNでは、福袋のように限定で発売されるような商品があり、このような商品を「過熱商品」と呼んでいます。過熱商品は、発売開始のタイミングで一時的にアクセスが急増する特徴があります。これまでのシステム構成では、そのアクセス急増により、過熱商品以外の通常の商品をカートに投入しようとしているユーザーにも影響が出ていました。そのため、今回のリプレイスでは、過熱商品への対応も行っています。 その対応として、前述した登録APIの処理の中には、過熱商品かどうかを判定する処理が追加されています。そのため、過熱商品の情報のみを持つテーブルが新たに必要になります。そして、すべてのリクエストが、新しく用意した過熱商品用テーブルを参照しているため、パフォーマンスを考慮してAmazon DynamoDB Accelerator(以下、DAX)を使用しています。 過熱商品の判定を含めた、登録APIからCart Workerへの処理の流れは以下の通りです。 リクエスト時の商品情報を元に、過熱商品用テーブルへ問い合わせをする 商品情報などの情報をDynamoDBのカート投入情報テーブルに登録する 上記の 2. で登録した情報のキーと、後続のCart Workerが次のIISのAPIへリクエストする際に必要な情報を、KDSへ送信する 上記の 1. でデータが存在した場合は、過熱商品用のストリームを使用する 上記の 1. でデータが存在しない場合は、通常用のストリームを使用する KDSの情報を取得する 上記の 4. で取得した情報を元に、カート投入情報テーブルのステータスを更新する 上記の 4. で取得した情報を元に、IISへリクエストする カート投入ストアドプロシージャを呼び出す 通常商品と過熱商品でストリームを分けたことで、過熱商品が発売するタイミングであっても、通常商品をカート投入するユーザーに大きな影響を与えることがなくなりました。また、商品単位でKDSの同一のシャードを使用しており、FIFOのカート投入順も担保できるようになっています。 問題点とその対処法 本章では、リプレイスを進める中で見つかった問題点と、その対処法の一部を紹介します。 DAXへのアクセスをローカルで試行できない AWSを利用したシステムを開発するため、ローカル環境で LocalStack を使用していました。DynamoDBやKDSは、LocalStackを使用してモック環境を構築し、開発を進めていました。 しかし、DAXはLocalStackでは構築できないため、動作確認ができない状態で実装を進める必要がありました。さらに、ローカル環境で実行していたものを、クラウド上で実行させる場合には、SpringのDIコンテナに管理するクラスを変更するという対応も必要でした。 最終的には、ローカル環境で開発する際にはDAXへの通信ではなく、LocalStackで構築したDynamoDBを参照する形式で開発を進めました。 モニタリングが1画面でできない ZOZOTOWNのモニタリングには、Datadogを導入しています。Datadogは、トレースをリクエスト単位で収集します。そのため、Cart APIからCart Workerまでのトレース情報が別々に表示されてしまい、エラー調査時にそれらの関連付けに苦労していました。 そこで、必要な情報をAPIのリクエストに渡すことで、すべてのトレース情報を1つの画面で表示できるようにしました。その結果、下図のようにDatadogのトレース情報を集約できました。 登録APIからCart Worker、ステータス取得APIまで1つのトレース情報として表示されていることがわかります。これにより、Cart Queuing Systemのエラー発生時には、情報が1つの画面に集約して表示されるため、エラー調査もスムーズになりました。 おわりに 冒頭で説明した通り、カート決済機能はZOZOTOWNの中で最も大きい機能です。今回のカート機能のリプレイスは、キャパシティコントロールを目的にした「カート決済機能リプレイス Phase1」です。今後も、マイクロサービス化を半年から1年にフェーズを分けて、段階的に進めていきます。 ECプラットフォーム部 カート決済ブロックでは、仲間を募集しています。ご興味のある方は、こちらからご応募ください。 hrmos.co
はじめに こんにちは、技術本部 データサイエンス部 MLOpsブロックの鹿山( @Ash_Kayamin )です。 みなさんは2021年4月にGCPから「GKE Gateway コントローラによる Kubernetes ネットワーキングの進化」という記事が投稿されたのを覚えていますでしょうか。 cloud.google.com この記事は、Kubernetesコミュニティが発表したKubernetes Gateway APIに対し、そのGKE(Google Kubernetes Engine)版実装であるGKE Gateway Controllerのリリースをアナウンスするものでした。 それから半年が経ち、本番導入の可能性を模索するためにKubernetes Gateway APIとGKE Gateway Controllerを調査、動作検証しました。本記事では、Kubernetes Gateway APIの概要と、APIで定義されるトラフィックのルーティングがGKE Gateway ControllerによってどのようにGCP上で実現されるのかを、動作検証の流れに沿って解説します。 なお、2021年11月時点で、Kubernetes Gateway APIの最新バージョンは v1alpha2 、GKE Gateway ControllerがサポートするGateway APIのバージョンは v1alpha1 であり、今後仕様が大きく変わる可能性がある点にご注意ください。 目次 はじめに 目次 Kubernetes Gateway APIの開発背景と特徴 Kubernetes Gateway APIが開発された背景 Kubernetes Gateway APIを構成する3種類のリソース Gateway APIを用いる利点 利点1:ルーティングの設定に必要最小限な権限をRBACで付与できる 利点2:プロバイダー依存性の低いManifestでルーティングの設定を定義できる 利点3:拡張性が高い GCPにおけるGateway APIの実装 GKE Gateway Controllerの動作検証 1. GKE Gateway Controllerに対応したシングルクラスタを構築する 2. Gateway APIのCRDをインストールし、GatewayClassが作成されることを確認する 3. Gatewayリソースを作成することで、GCLBが作成されることを確認する 4. HTTPRouteリソースを作成して、GCLBにルーティングのルールが追加されることを確認する 5. HTTPRouteで定義したルーティングルールの優先順位とGCLB上でのルールの優先順位の定義を確認する おわりに Kubernetes Gateway APIの開発背景と特徴 よりスムーズに理解していただくために、 Kubernetes Gateway API が作成された背景から順にご紹介します。 Kubernetes Gateway APIが開発された背景 Gateway API(Kubernetes Gateway API)は Ingress API(Kubernetes Ingress API) の課題を解消するために開発されました 1 。 そのIngress APIは、Kubernetesクラスタ外部からクラスタ内 Service (Kubernetes Service) に対し、アプリケーション層でHTTPやHTTPSを用いたルーティングを制御するAPIです。多数のプロバイダーでIngress APIの仕様に則った Ingress Controller が実装されています。また、Ingress Controllerの実装によっては負荷分散やSSL終端といった機能も提供します。MLOpsブロックでもGKEで コンテナネイティブな負荷分散 を利用するために、GCPが提供するIngress Controllerである GLBC を利用しています。GLBCはIngress APIを通して、 GCLB(Google Cloud Load Balancing) を用いたルーティングの設定が可能です。 実は、Ingress APIでは非常にシンプルな機能を実現するための仕様しか定義されていません。そのため、Ingress Controllerのプロバイダー、Ingress Controllerの利用者それぞれに以下の負担が発生していました。 Ingress APIでは定義されていないトラフィックの荷重ルーティング等の機能を追加するには、プロバイダーは Ingress のManifestに独自のannotationを定義する必要がある 開発者はプロバイダー毎にannotationが大きく異なるManifestを書かなくてはならない 例えば、各Ingress Controller毎にannotationへ定義可能な設定項目数を比較すると以下のように大きな差があります。 GLBC:6個 GLBCで利用可能なannotation 内部LBの場合 GLBCで利用可能なannotation 外部LBの場合 AWS Load Balancer Controller:40個 AWS Load Balancer Controllerで利用可能なannotation NGINX Ingress Controller:110個 NGINX Ingress Controllerで利用可能なannotation これは、対応している機能や、設定項目の表現方法(annotationのみを使うのか、annotationと CR(Custom Resource) を組み合わせるのか等)が異なるため、結果としてannotationの数に大きな差が生じています。 例えば、L7外部ロードバランサーを定義して、ロードバランサーをSSL終端とし、バックエンドのサービスへのヘルスチェックを設定することを考えてみます。 AWS Load Balancer Controllerを用いる場合のManifestは以下のように定義します。 apiVersion : networking.k8s.io/v1 kind : Ingress metadata : name : zozo-techblog annotations : kubernetes.io/ingress.class : alb alb.ingress.kubernetes.io/scheme : internet-facing alb.ingress.kubernetes.io/target-type : ip alb.ingress.kubernetes.io/certificate-arn : arn:aws:acm:xxxx alb.ingress.kubernetes.io/healthcheck-protocol : HTTP alb.ingress.kubernetes.io/healthcheck-port : '80' alb.ingress.kubernetes.io/healthcheck-path : /health spec : rules : - http : paths : - path : /* backend : serviceName : zozo-techblog servicePort : 80 一方、GLBCを用いる場合のManifestは以下のように定義します。 apiVersion : networking.k8s.io/v1 kind : Ingress metadata : name : zozo-techblog-ingress annotations : kubernetes.io/ingress.allow-http : "false" ingress.gcp.kubernetes.io/pre-shared-cert : "api-cert" spec : defaultBackend : service : name : zozo-techblog-service port : number : 80 --- apiVersion : v1 kind : Service metadata : name : zozo-techblog-service annotations : cloud.google.com/neg : '{"ingress": true}' # ref. https://cloud.google.com/kubernetes-engine/docs/how-to/container-native-load-balancing # BackendConfigを用いてヘルスチェック等をサービス毎にカスタマイズする cloud.google.com/backend-config : '{"default": "zozo-techblog-backendconfig"}' spec : selector : app : zozo-techblog-pod ports : - port : 80 protocol : TCP targetPort : 8080 --- apiVersion : cloud.google.com/v1 kind : BackendConfig metadata : name : zozo-techblog-backendconfig spec : healthCheck : checkIntervalSec : 15 type : HTTP port : 8080 requestPath : /health annotationが異なるのはもちろんですが、ヘルスチェックの指定方法が大きく異なっていることが分かります。AWS Load Balancer Controllerでは Ingress のannotationにヘルスチェックの定義を記載します。一方、GLBCを用いる場合は BackendConfig というCRにヘルスチェックの定義を記載します。そこでは、 Ingress で指定する Service のannotationで BackendConfig を指定する必要があります。 また、L7ロードバランサーの機能への対応状況も大きく差があります。例えば、AWS Load Balancer Controllerでは Service 毎に割合を指定することでトラフィックの細かな分割ができます 。一方、GLBCではトラフィックの分割割合の指定はできません。GLBCで設定をするL7ロードバランサーGCLBにはトラフィックの分割割合を指定する機能は存在します。しかしながら、現状GLBCにはGCLBでのトラフィック分割割合を設定する機能は実装されていません。L7ロードバランサーを構成するなら当然利用できるはずだと思う機能も、現状では利用できるかどうかはプロバイダー次第となってしまっています。 このように、Ingress Controller毎に対応している機能や設定項目の表現方法が大きく異なります。そのため、開発者が普段とは異なるIngress Controllerを利用する際には、Manifestを書くことに苦労します。 Gateway APIは、この問題を解消するために開発されました。Gateway APIはL4/L7ロードバランサーで実現可能なルーティングをできる限り共通の仕様で実現できるように配慮しています。 また、Ingress APIでは、L7での Service へのルーティングの定義を1つのリソースで定義していました。一方、Gateway APIではルーティングの定義を責務毎に、3種類のリソースに分割しています。リソースを分割することで権限管理の対象が細分化されるため、 RBAC(Role-based access control) を用いて「 最小権限の原則 」に基づいた 安全な運用 が可能です。 Kubernetes Gateway APIを構成する3種類のリソース Gateway APIは、Kubernetesクラスタ外部からクラスタ内の Service へのL4/L7でのルーティングを、3種類リソース GatewayClass 、 Gateway 、 Route を用いて定義するAPIです。 GatewayClass Gateway を構成するためのテンプレートを示すリソース Gateway を構成するために使用するGateway Controllerをパラメータと共に指定する このパラメータで Gateway 構成時に構築されるロードバランサーの設定項目(L4、L7、外部、内部等)を指定する Gateway リクエストをクラスタ内へルーティングするルールを定義するリソース 指定した GatewayClass の定義を元にロードバランサーやプロキシ等を実際に構築する クラスタ内のどこにルーティングするかは Route によって定義する Route Gateway から Service に対するルーティングのルールを定義するリソース ロードバランサーでのパスベースのルーティングの指定等に対応 対応するプロトコル毎に HTTPRoute 、 TCPRoute 、 TLSRoute 、 UDPRoute が存在する 以下、公式ドキュメントにある図に描かれているように、 GatewayClass 、 Gateway 、 Route (図では HTTPRoute )の3つのリソースを組み合わせることで、 Service へのルーティングを定義します。 引用 https://gateway-api.sigs.k8s.io/ 2021年11月時点で、GKEや Istio を含む複数のプロジェクトがGateway APIで定義された挙動を実現する Gateway Controller を実装しています。 Gateway APIを用いる利点 Gateway APIにはIngress APIと比べて以下の利点があります。 ルーティングの設定に必要最小限な権限をRBACで付与できる プロバイダー依存性の低いManifestでルーティングの設定を定義できる 拡張性が高い 順に説明します。 利点1:ルーティングの設定に必要最小限な権限をRBACで付与できる 前述の通り、Gateway APIでは Service へのルーティングを、3種類の責務に対応したリソース GatewayClass 、 Gateway 、 Route で定義します。リソースが分かれていることで、RBACを用いて「誰が何をできるのか」をリソース毎に管理できます。つまり、開発者の責務に対して必要なルーティング設定を行う権限のみを付与できます。こうすることで、Kubernetesのリソースを通して行うルーティング設定に「最小権限の原則」に基づいた運用を導入できます。Ingress APIでは、1つのリソースでロードバランサーと Service ヘのルーティングの定義を兼ねており、権限の分離はできませんでした。 例として、下図の公式ドキュメントの図が示すような、1つのロードバランサーに対して複数の Namespace に存在する異なるアプリケーションを紐づけたシステムを考えます。このシステムで、クラスタ管理者とアプリケーション開発者の権限を分けてみましょう。 引用 https://gateway-api.sigs.k8s.io/ Step1 クラスタの管理者にはクラスタ内のアプリケーションが共通で利用するロードバランサーを管理できるように、 Gateway リソースを閲覧、作成、編集、削除できる権限を与える 一方、アプリケーション開発者の権限は Gateway リソースの閲覧のみに絞ることで、サービス全体で利用するロードバランサーを誤って削除できないようにする Step2 各アプリケーション開発者へは、特定の Namespace でのみ Route リソースの閲覧、作成、編集、削除できる権限を与える その結果、開発者が管理している特定の Namespace 配下のアプリケーションに対してのみ、ロードバランサーからトラフィックをどのように割り振るのかを管理できるようになる このように権限を分離することで、各アプリケーション開発者に必要最小限の権限を与え、安全に開発を進めることができます。 利点2:プロバイダー依存性の低いManifestでルーティングの設定を定義できる Gateway APIでは、 3種類の実装サポートレベル CORE 、 EXTENDED 、 OPTIONAL が定義 されています。そして、このサポートレベルは 機能毎に設定 されています。 CORE 全てのGateway Controllerで実装される重要な機能 Gateway APIでManifestの仕様が定義されている EXTENDED 全てのGateway Controllerで実装されるわけではないが、重要な機能 Gateway APIでManifestの仕様が定義されている CUSTOM プロバイダー依存のオプショナルな機能 Gateway APIではCRを指定できるようにManifestの仕様が定義されており、プロバイダーは任意のCRを用いた機能を実装できる 上記の説明で用いている「重要な機能」とは、一般にL7ロードバランサーに備わっている機能(ヘッダーベースのルーティング等)で、プロバイダーに依らず可搬性のある機能を指します。 Ingress では 独自のannotationを用いる手段しかサポートしていませんでした 。なお、どういった機能がどのサポートレベルまで対応するのか、明確な判断基準は示されていません。機能のサポートレベルについて詳しく知りたい方は API仕様のドキュメント をご参照ください。 Gateway APIのリソースでは、 Service へのルーティングを管理するのに必要な機能が一通り CORE 、 EXTENDED で定義されています。例えば、 Gateway における静的IPやSSL証明書の指定、 HTTPRoute におけるヘッダーベース・パスベースのルーティングやトラフィック分割の指定等が含まれています。そのため、これらの必要となる機能は各プロバイダーで、ある程度等しく実装されることが期待できます。その結果、Gateway API利用時に使用するManifestを汎用的に使えるようになることも期待できます。必要な機能が汎用的なManifestの仕様として定義されていれば、Manifestを見れば実現されている機能が一目で分かる利点があります。 一方、従来のIngress APIでは非常にシンプルな機能を実現するための仕様しか定義されておらず、各プロバイダーがManifestに独自のannotationを定義して機能を拡張する必要がありました。その結果、プロバイダー毎に提供される機能、必要なManifestのフォーマットが大きく異なっていました。 これに対して、Gateway APIでは、より高度なルーティング管理機能をAPIで最初から定義することと、以下で説明するCRを用いた拡張方法を提供することで、このIngress APIの問題を解消しようとしています。 利点3:拡張性が高い Gateway APIはCRを用いた拡張ができるように設計されています。拡張のために、Gateway APIで定義されているManifestの中には CRを指定できるポイント がいくつか用意されています。Ingress APIでは、annotationで拡張するしか手段がありませんでした。annotationは単なる文字列であり、必要な項目が設定されているかの確認等のバリデーションはできません。また、Manifestにどんなannotationを設定できるのかを知るにはドキュメントを確認する必要がありました。一方、CRによる拡張はManifestのバリデーションが可能です。 kubectl get crd や kubectl explain 等でSpecを確認できるため、設定可能な項目を知るのも容易になっています。Gateway APIがサポートしているCRによる拡張は、Controller開発者、利用者双方にとって、より好ましい拡張方法と言えます。 Gateway APIのバージョン gateway.networking.k8s.io/v1alpha2 における、各種 Route では spec.rules[].backendrefs[].kind で Service の代わりににCRを指定できます。例えば、 Cloud Functions へルーティングするためのCRを作成し、指定することを考えてみます。Gateway Controllerの実装者はCloud Functionsへのルーティングに必要な情報を CRD(Custom Resource Definition) として定義しておきます。CRDに指定した通りにCRが作成され、 Route リソースで指定された際には、CRの情報を元にCloud Functionsへルーティングできるよう、Gateway Controllerを実装します。それにより、利用者がCRを作成し、 Route リソースにCRを指定すれば、Cloud Functionsへのルーティングを実現できるようになります。このように、Gateway APIを拡張し、Cloud Functionsへのルーティングを設定する機能を実現できます。 この他にも、Gatewayでは spec.listener.allowedRoutes[].kinds[] でCRを指定でき、既存の Route 以外の独自の Route リソースも使用できます。このように、Gateway APIではCRを用いた拡張がしやすいように配慮されています。 # HTTPRouteでspec.rules[].backendrefs[]を指定する例 apiVersion : gateway.networking.k8s.io/v1alpha2 kind : HTTPRoute metadata : name : example-route namespace : gateway-api-example-ns2 spec : parentRefs : - name : prod-gateway hostnames : - "example.com" rules : - backendRefs : - kind : Service # kindを指定できるのでCRを指定することも可能。 デフォルトではServiceを指定するようになっている。 name : example-svc # 対象とするkindのmetadata.nameを指定 port : 80 # kind: Serviceの場合は必須 GCPにおけるGateway APIの実装 ここまでのGateway APIに関する説明は、2021年11月時点でのGateway APIの最新バージョン gateway.networking.k8s.io/v1alpha2 に対するものです。本章で説明する、GKE Gateway Controllerはバージョン networking.x-k8s.io/v1alpha1 への対応となっており、動作検証ではバージョン networking.x-k8s.io/v1alpha1 で定義されたManifestを利用しているのでご注意ください。 GCPでは、その networking.x-k8s.io/v1alpha1 に対応した、GKE Gateway Controllerがプレビュー機能として公開されています。GKE Gateway Controllerを利用することで、単一または複数のGKEクラスタにまたがる内部、外部HTTP(S)負荷分散を管理できます。2021年11月時点で、 GatewayClass 、 Gateway 、 HTTPRoute リソースのみがサポートされています。また、 4種類のGatewayClass が定義されており、各 GatewayClass 毎にサポートする機能が異なっています。 4種類の GatewayClass は以下の通りです。 gke-l7-rilb シングルクラスタ内部ロードバランサー gke-l7-rilb-mc マルチクラスタ内部ロードバランサー gke-l7-gxlb シングルクラスタ外部ロードバランサー gke-l7-gxlb-mc マルチクラスタ外部ロードバランサー GKE Gateway Controllerの動作検証 ここまで、Gateway APIと、その実装であるGKE Gateway Controllerを紹介しました。本章では、GCP公式ドキュメントにある 「Gateway のデプロイ」 に従って、実際にGKE上でGateway APIを利用し、APIで定義されるトラフィックのルーティングがGKE Gateway Controllerによって、どのようにGCP上で実現されるのかを見ていきます。 1. GKE Gateway Controllerに対応したシングルクラスタを構築する GKEで単一のKubernetesクラスタを構築し、Gateway APIを用いて、下図に示す内部負荷分散を実現します。コンテナネイティブ負荷分散を行うため、GKEクラスタは VPCネイティブクラスタ である必要があります。 検証で実現する内部負荷分散 以下のサンプルのように、TerraformでGCP上に検証環境を構築します。なお、2021年11月時点で 公式ドキュメント記載のGKE Gateway Controller利用可能リージョン には asia-northeast1 は含まれていませんでしたが、試してみたところ asia-northeast1 に構築したGKEクラスタでも利用できました。 # VPC作成 resource " google_compute_network " " gke_vpc " { name = " gke-vpc " auto_create_subnetworks = false } # 内部LBを作成する場合に必要なproxy only subnetを作成 # ref. https :/ /cloud.google.com / load - balancing /docs/l7 - internal /proxy - only - subnets resource " google_compute_subnetwork " " proxy_only_subnet " { name = " proxy-only-subnetwork " ip_cidr_range = " 10.0.3.0/24 " # cider for gke node region = " asia-northeast1 " network = google_compute_network.gke_vpc.id purpose = " INTERNAL_HTTPS_LOAD_BALANCER " role = " ACTIVE " } # VPCネイティブクラスタを作成する際に指定するサブネットを雑に作成 # 本来は下記リンク先を参考にCIDRを要件に応じて設計するべきです # ref. https :/ /cloud.google.com /kubernetes - engine /docs/how - to /flexible - pod - cidr resource " google_compute_subnetwork " " gke_subnet " { name = " gke-subnetwork " ip_cidr_range = " 10.0.1.0/24 " # cider for gke node region = " asia-northeast1 " network = google_compute_network.gke_vpc.id secondary_ip_range { range_name = " gke-pod " ip_cidr_range = " 10.0.0.0/24 " } secondary_ip_range { range_name = " gke-service " ip_cidr_range = " 10.0.2.0/24 " } private_ip_google_access = true } # GKEのノードに割り当てるサービスアカウントを作成 resource " google_service_account " " gke_node_pool " { account_id = " gke-node-pool " display_name = " gke-node-pool " description = " A service account for GKE node " } # サービスアカウントに必要最低限のIAMロール(権限)を付与 resource " google_project_iam_member " " gke_node_pool " { for_each = toset ([ " roles/logging.logWriter ", " roles/monitoring.metricWriter ", " roles/monitoring.viewer ", " roles/datastore.owner ", " roles/storage.objectViewer ", ]) role = each.value member = " serviceAccount:${google_service_account.gke_node_pool.email} " } # GKEクラスタを定義、VCP - Native、公開クラスタ resource " google_container_cluster " " main " { name = " gke-cluster " location = " asia-northeast1-a " # デフォルトノードプールは削除して別途ノードプールを作成する remove_default_node_pool = true initial_node_count = 1 # クラスタを作成するVPC、subnetを指定 network = google_compute_network.gke_vpc.self_link subnetwork = google_compute_subnetwork.gke_subnet.self_link # vpc native clusterにするための設定 networking_mode = " VPC_NATIVE " ip_allocation_policy { cluster_secondary_range_name = " gke-pod " services_secondary_range_name = " gke-service " } } # GKEクラスタのノードを定義 resource " google_container_node_pool " " primary_nodes " { name = " node-pool " location = " asia-northeast1-a " cluster = google_container_cluster.main.name node_count = 1 node_config { machine_type = " e2-medium " metadata = { disable - legacy - endpoints = " true " } # アクセススコープでは全てのサービスへの権限を付与し,サービスアカウント側で付与する権限を絞る service_account = google_service_account.gke_node_pool.email oauth_scopes = [ " https://www.googleapis.com/auth/cloud-platform " ] } } 2. Gateway APIのCRDをインストールし、 GatewayClass が作成されることを確認する 手順 1. でGKEクラスタを作成したら、下記のように kubectl コマンドを用い、クラスタにGateway APIの CRD(Custom Resource Definition) をインストールします。CRDをインストールすると、GKE Gateway Controllerによって、GKEクラスタ内に自動的にシングルクラスタ用の GatewayClass が作成されます。 $ CLUSTER_NAME =gke-cluster $ ZONE =asia-northeast1-a $ gcloud container clusters get-credentials $CLUSTER_NAME --zone $ZONE $ kubectl kustomize " github.com/kubernetes-sigs/gateway-api/config/crd?ref=v0.3.0 " \ | kubectl apply -f - customresourcedefinition.apiextensions.k8s.io/backendpolicies.networking.x-k8s.io created customresourcedefinition.apiextensions.k8s.io/gatewayclasses.networking.x-k8s.io created customresourcedefinition.apiextensions.k8s.io/gateways.networking.x-k8s.io created customresourcedefinition.apiextensions.k8s.io/httproutes.networking.x-k8s.io created customresourcedefinition.apiextensions.k8s.io/tcproutes.networking.x-k8s.io created customresourcedefinition.apiextensions.k8s.io/tlsroutes.networking.x-k8s.io created customresourcedefinition.apiextensions.k8s.io/udproutes.networking.x-k8s.io created $ kubectl get gatewayclass NAME CONTROLLER AGE gke-l7-gxlb networking.gke.io/gateway 11s gke-l7-rilb networking.gke.io/gateway 11s $ kubectl describe gatewayclass gke-l7-ril ~~~ Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal ADD 20s sc-gateway-controller gke-l7-rilb 3. Gateway リソースを作成することで、GCLBが作成されることを確認する 次に、 Gateway リソースを作成し、GKE Gateway Controller経由で内部ロードバランサーを作成します。 Gateway では、 spec.gatewayClassName で GatewayClass を指定します。そこでは、作成したいロードバランサーの種別に応じて適切な GatewayClass を選択します。今回は、シングルクラスタ内部ロードバランサーを作成したいので、 gke-l7-rilb を指定します。そして、 spec.listeners で利用するプロトコル、ポート番号、 Gateway との紐付けを許可する Route の条件を指定します。 また、 Gateway で紐付けを許可する Route の条件を指定しますが、逆に Route 側でも紐付けを許可する Gateway の条件を指定できます。そして、双方向に条件が満たされた場合にのみ、 該当の Gateway と Route が紐づけられます 。なお、 Gateway では、許可する Route の条件として、 Route の kind 、 label 、 hostnames 、 Route を作成する Namespace の label を指定できます。つまり、この条件を満たす Route を作成する権限があれば自由にルーティングルールを追加できることを意味します。Gateway APIでは、ルーティングルールを一元管理する仕組みが定義されていないので、ルーティングルールの適切な管理は運用でカバーする必要があります。 kind : Gateway apiVersion : networking.x-k8s.io/v1alpha1 metadata : name : internal-http spec : gatewayClassName : gke-l7-rilb listeners : - protocol : HTTP port : 80 routes : # 紐づけを許可するRouteの条件を指定 kind : HTTPRoute selector : matchLabels : gateway : internal-http Gateway リソースを作成すると、GKE Gateway Controllerにより、GCLB及びGCLBに紐づけられた バックエンドサービス 、 ヘルスチェック が新規に作成されます。 $ kubectl apply -f gateway.yaml gateway.networking.x-k8s.io/internal-http created $ kubectl get gateway NAME CLASS AGE internal-http gke-l7-rilb 39s $ kubectl describe gateway internal-http ~~~ Status: Addresses: Type: IPAddress Value: 10 . 0 . 1 . 4 Conditions: Last Transition Time: 1970-01-01T00:00:00Z Message: Waiting for controller Reason: NotReconciled Status: False Type: Scheduled Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal ADD 4m1s sc-gateway-controller default/internal-http Warning SYNC 3m42s sc-gateway-controller generic::invalid_argument: error ensuring load balancer: Insert: The resource ' projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 ' is not ready Normal UPDATE 17s ( x3 over 4m1s ) sc-gateway-controller default/internal-http Normal SYNC 17s sc-gateway-controller SYNC on default/internal-http was a success # GCLBが作成されている $ gcloud compute url-maps list NAME DEFAULT_SERVICE gkegw-8r5w-default-internal-http-2jzr7e3xclhj # バックエンドサービスが作成されている $ gcloud compute backend-services list NAME BACKENDS PROTOCOL gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 HTTP # ヘルスチェックが作成されている $ gcloud compute health-checks list NAME PROTOCOL gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 HTTP Gateway リソースから作成されたロードバランサーはUI及びCLIから確認できます。しかしながら、 公式ドキュメント には「Gatewayによって作成されたGoogle CloudロードバランサのリソースはGoogle Cloud Console UIに表示されません」と記載されています。正しい値が表示される保証はないのでご注意ください。 GKE Gateway Controllerによって作成されたロードバランサーのGoogle Cloud Console上での表示 4. HTTPRoute リソースを作成して、GCLBにルーティングのルールが追加されることを確認する 次に、 Deployment と Service を作成した上で、 Service と Gateway を紐づける HTTPRoute を作成します。 まず、以下のManifestで4組の Deployment と Service を追加します。 apiVersion : apps/v1 kind : Deployment metadata : name : store-v1 spec : replicas : 2 selector : matchLabels : app : store version : v1 template : metadata : labels : app : store version : v1 spec : containers : - name : whereami image : gcr.io/google-samples/whereami:v1.1.3 ports : - containerPort : 8080 env : - name : METADATA value : "store-v1" --- apiVersion : v1 kind : Service metadata : name : store-v1 spec : selector : app : store version : v1 ports : - port : 8080 targetPort : 8080 --- apiVersion : apps/v1 kind : Deployment metadata : name : store-v2 spec : replicas : 2 selector : matchLabels : app : store version : v2 template : metadata : labels : app : store version : v2 spec : containers : - name : whereami image : gcr.io/google-samples/whereami:v1.1.3 ports : - containerPort : 8080 env : - name : METADATA value : "store-v2" --- apiVersion : v1 kind : Service metadata : name : store-v2 annotations : # BackendConfigを用いてヘルスチェック等をサービス毎にカスタマイズする cloud.google.com/backend-config : '{"default": "store-v2-backendconfig"}' spec : selector : app : store version : v2 ports : - port : 8080 targetPort : 8080 --- apiVersion : cloud.google.com/v1 kind : BackendConfig metadata : name : store-v2-backendconfig spec : healthCheck : checkIntervalSec : 15 port : 8080 type : HTTP requestPath : /v2 connectionDraining : drainingTimeoutSec : 60 --- apiVersion : apps/v1 kind : Deployment metadata : name : store-german spec : replicas : 2 selector : matchLabels : app : store version : german template : metadata : labels : app : store version : german spec : containers : - name : whereami image : gcr.io/google-samples/whereami:v1.1.3 ports : - containerPort : 8080 env : - name : METADATA value : "Gutentag!" --- apiVersion : v1 kind : Service metadata : name : store-german annotations : # BackendConfigを用いてヘルスチェック等をサービス毎にカスタマイズする cloud.google.com/backend-config : '{"default": "store-german-backendconfig"}' spec : selector : app : store version : german ports : - port : 8080 targetPort : 8080 --- apiVersion : cloud.google.com/v1 kind : BackendConfig metadata : name : store-german-backendconfig spec : healthCheck : checkIntervalSec : 15 port : 8080 type : HTTP requestPath : /healthz connectionDraining : drainingTimeoutSec : 60 --- apiVersion : apps/v1 kind : Deployment metadata : name : store-mirror-target spec : replicas : 2 selector : matchLabels : app : store version : mirror-target template : metadata : labels : app : store version : mirror-target spec : containers : - name : whereami image : gcr.io/google-samples/whereami:v1.1.3 ports : - containerPort : 8080 env : - name : METADATA value : "store-mirror-target" --- apiVersion : v1 kind : Service metadata : name : store-mirror-target spec : selector : app : store version : store-mirror-target ports : - port : 8080 targetPort : 8080 このマニフェストを store-deployment-service.yaml というファイル名で保存し、applyします。 $ kubectl apply -f store-deployment-service.yaml deployment.apps/store-v1 created service/store-v1 created deployment.apps/store-v2 created service/store-v2 created deployment.apps/store-german created service/store-german created deployment.apps/store-mirror-target created service/store-mirror-target created $ kubectl get pod --show-labels NAME READY STATUS RESTARTS AGE LABELS store-german-66dcb75977-4lnkf 1 / 1 Running 0 86m app =store,pod-template-hash = 66dcb75977,version =german store-german-66dcb75977-plqtx 1 / 1 Running 0 86m app =store,pod-template-hash = 66dcb75977,version =german store-mirror-target-c6b945fdf-4tqj9 1 / 1 Running 0 86m app =store,pod-template-hash = c6b945fdf,version =mirror-target store-mirror-target-c6b945fdf-9lnbt 1 / 1 Running 0 86m app =store,pod-template-hash = c6b945fdf,version =mirror-target store-v1-65b47557df-5m6xc 1 / 1 Running 0 86m app =store,pod-template-hash = 65b47557df,version =v1 store-v1-65b47557df-65p42 1 / 1 Running 0 86m app =store,pod-template-hash = 65b47557df,version =v1 store-v2-6856f59f7f-cczqb 1 / 1 Running 0 86m app =store,pod-template-hash = 6856f59f7f,version =v2 store-v2-6856f59f7f-dsbnc 1 / 1 Running 0 86m app =store,pod-template-hash = 6856f59f7f,version =v2 $ kubectl get service NAME TYPE CLUSTER-IP EXTERNAL-IP PORT ( S ) AGE kubernetes ClusterIP 10 . 0 . 2 . 1 < none > 443 /TCP 101m store-german ClusterIP 10 . 0 . 2 . 204 < none > 8080 /TCP 86m store-mirror-target ClusterIP 10 . 0 . 2 . 165 < none > 8080 /TCP 86m store-v1 ClusterIP 10 . 0 . 2 . 35 < none > 8080 /TCP 86m store-v2 ClusterIP 10 . 0 . 2 . 89 < none > 8080 /TCP 86m この store-deployment-service.yaml に記載の通り、GKE Gateway Controllerでは、GLBC同様に BackendConfig リソースを用いて、 Service 毎にヘルスチェックやコネクションドレイニングの設定を変更できます。しかし、この機能はGA前に別のリソースに置き換えられることが ドキュメント に明記されています。そして、10月にリリースされ、GKE Gateway ControllerではまだサポートされていないGateway API Version gateway.networking.k8s.io/v1alpha2 においては、ヘルスチェック等を定義するための仕組みとして Policy Attachment が定義されています。 BackendConfig リソースはこの仕組みを利用するリソースに置き換えられると考えられます。 また、 HTTPRoute では、 spec.gateways で処理を担当するホスト名、 spec.gateways で紐付けを許可する Gateway の条件、 spec.rules でリクエストをどのように処理するかのルールを指定できます。 spec.rules[].matches リクエストのパス、ヘッダー、クエリパラメータでルールを適用する対象のリクエストを指定 spec.rules[].forwardTo spec.rules[].matches で指定した条件に合致するリクエストをルーティングする先を指定 ルーティング先として、複数のサービス、portの組みを指定可能 複数サービス間でのルーティングの分割割合も指定可能 spec.rules[].filter spec.rules[].matches で指定した条件に合致するリクエストのヘッダー修正、ミラーリングを指定 以下、Manifestで定義される HTTPRoute store によって、再掲する下図に示すルーティングルールを設定します。 再掲:検証で実現する内部負荷分散 kind : HTTPRoute apiVersion : networking.x-k8s.io/v1alpha1 metadata : name : store labels : gateway : internal-http spec : hostnames : - "store.example.com" rules : - matches : - path : type : Prefix value : /de forwardTo : - serviceName : store-german port : 8080 filters : # /deへのリクエストをService store-mirror-targetにミラーリングする - type : RequestMirror requestMirror : serviceName : store-mirror-target port : 8080 - matches : - path : type : Prefix value : /mirror forwardTo : - serviceName : store-mirror-target port : 8080 - matches : - headers : type : Exact values : env : canary forwardTo : - serviceName : store-v2 port : 8080 # matches未指定のルールは、合致するmatchesが存在しないリクエストに対して適用される - forwardTo : - serviceName : store-v1 port : 8080 # このルールが適用されるリクエストの9割をService store-v1にルーティングする weight : 90 - serviceName : store-v2 port : 8080 # このルールが適用されるリクエストの1割をService store-v2にルーティングする weight : 10 HTTPRoute リソースを作成し、しばらく待ちます。すると、GKE Gateway Controllerによって、ルーティング先に指定した4つのサービス毎にバックエンドサービス、ヘルスチェックならびに NEG(Network Endpoint Group) が新規に作成されます。 NEGはKubernetesクラスタ内に動的に作成される Service 、 Pod と直接通信できるエンドポイントを管理し、VPC内に提供する仕組みです。NEGの詳細は、以下記事が分かりやすいのでご参照ください。 medium.com $ kubectl apply -f store-route.yaml httproute.networking.x-k8s.io/store created $ kubectl get httproute NAME HOSTNAMES AGE store [" store.example.com "] 25s $ kubectl describe httproute store Name: store Namespace: default Labels: gateway =internal-http Annotations: < none > API Version: networking.x-k8s.io/v1alpha1 Kind: HTTPRoute ~~~ Status: Gateways: Conditions: Last Transition Time: 2021-11-10T06:47:18Z Message: Reason: RouteAdmitted Status: True Type: Admitted Last Transition Time: 2021-11-10T06:47:18Z Message: Reason: ReconciliationSucceeded Status: True Type: Reconciled Gateway Ref: Name: internal-http Namespace: default Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal ADD 90s sc-gateway-controller default/store Normal SYNC 7s sc-gateway-controller Bind of HTTPRoute " default/store " to Gateway " default/internal-http " was a success Normal SYNC 7s sc-gateway-controller Reconciliation of HTTPRoute " default/store " bound to Gateway " default/internal-http " was a success そして、GCLBには HTTPRoute で指定した各サービスへのルーティングのルールが追加されます。GCLBに機能はあるものの、GKE Ingress Controllerでは実現できなかった、ルーティングの分割割合の指定等が設定できていることが確認できます。 $ gcloud compute url-maps list NAME DEFAULT_SERVICE gkegw-8r5w-default-internal-http-2jzr7e3xclhj # バックエンドサービスが4つ追加されており、それぞれのBACKENDSにNEGが指定されている $ gcloud compute backend-services list NAME BACKENDS PROTOCOL gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 HTTP gkegw-8r5w-default-store-german-8080-o9g73h4mk3ob asia-northeast1-a/networkEndpointGroups/k8s1-8db9299d-default-store-german-8080-e803f15f HTTP gkegw-8r5w-default-store-mirror-target-8080-zcxtgvjcck1r asia-northeast1-a/networkEndpointGroups/k8s1-8db9299d-default-store-mirror-target-8080-de687243 HTTP gkegw-8r5w-default-store-v1-8080-t7d6vxl1jy1d asia-northeast1-a/networkEndpointGroups/k8s1-8db9299d-default-store-v1-8080-52e6fd60 HTTP gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c asia-northeast1-a/networkEndpointGroups/k8s1-8db9299d-default-store-v2-8080-70e3804f HTTP # ヘルスチェックも新たに4つ追加されている $ gcloud compute health-checks list NAME PROTOCOL gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 HTTP gkegw-8r5w-default-store-german-8080-o9g73h4mk3ob HTTP gkegw-8r5w-default-store-mirror-target-8080-zcxtgvjcck1r HTTP gkegw-8r5w-default-store-v1-8080-t7d6vxl1jy1d HTTP gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c HTTP # store-deployment-service.yamlで追加した、Serviceに対応するNEGが新たに4つ追加されている $ gcloud compute network-endpoint-groups list NAME LOCATION ENDPOINT_TYPE SIZE k8s1-8db9299d-default-store-german-8080-e803f15f asia-northeast1-a GCE_VM_IP_PORT 2 k8s1-8db9299d-default-store-mirror-target-8080-de687243 asia-northeast1-a GCE_VM_IP_PORT 2 k8s1-8db9299d-default-store-v1-8080-52e6fd60 asia-northeast1-a GCE_VM_IP_PORT 2 k8s1-8db9299d-default-store-v2-8080-70e3804f asia-northeast1-a GCE_VM_IP_PORT 2 そして、 HTTPRoute で指定したルーティングのルールが、GCLBに追加されていることが確認できます。 $ gcloud compute url-maps describe gkegw-8r5w-default-internal-http-2jzr7e3xclhj --region asia-northeast1 creationTimestamp: ' 2021-11-12T01:46:24.204-08:00 ' defaultRouteAction: faultInjectionPolicy: abort: httpStatus: 404 percentage: 100 . 0 weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 weight: 1 fingerprint: AczSXReW744 = hostRules: - hosts: - store.example.com pathMatcher: hostffxyqcv3l2rgbj3v3jakx7trkfuw01ei id: ' 1800894912999426335 ' kind: compute#urlMap name: gkegw-8r5w-default-internal-http-2jzr7e3xclhj pathMatchers: - defaultRouteAction: faultInjectionPolicy: abort: httpStatus: 404 percentage: 100 . 0 weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 weight: 1 name: hostffxyqcv3l2rgbj3v3jakx7trkfuw01ei routeRules: - matchRules: - prefixMatch: /mirror priority: 1 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-mirror-target-8080-zcxtgvjcck1r weight: 1 - matchRules: - prefixMatch: /de priority: 2 routeAction: requestMirrorPolicy: backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-mirror-target-8080-zcxtgvjcck1r weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-german-8080-o9g73h4mk3ob weight: 1 - matchRules: - prefixMatch: /de priority: 3 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c weight: 1 - matchRules: - headerMatches: - exactMatch: canary headerName: env prefixMatch: / priority: 4 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c weight: 1 - matchRules: - prefixMatch: / priority: 5 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v1-8080-t7d6vxl1jy1d weight: 90 - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c weight: 10 region: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1 selfLink: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/urlMaps/gkegw-8r5w-default-internal-http-2jzr7e3xclhj 実際に、 HTTPRoute で指定した条件に合致するリクエストを飛ばすと、パスベース、ヘッダーベースのルーティング、トラフィック分割ルールに従ってルーティングされることが確認できます。 # Gateway(内部GCLB)に付与されたIPを確認 $ kubectl get gateway internal-http -o = jsonpath = " {.status.addresses[0].value} " 10 . 0 . 1 . 4 $ kubectl run curlpod --image curlimages/curl:7. 78 . 0 --command -- sleep 3600 $ kubectl exec curlpod -it -- /bin/sh # トラフィック分割によって、store-v1、時折store-v2にルーティングされることを確認 / $ curl -H " host: store.example.com " 10 . 0 . 1 . 4 " pod_name " : " store-v1-65b47557df-5m6xc " / $ curl -H " host: store.example.com " 10 . 0 . 1 . 4 " pod_name " : " store-v1-65b47557df-65p42 " ~~~ / $ curl -H " host: store.example.com " 10 . 0 . 1 . 4 " pod_name " : " store-v2-6856f59f7f-cczqb " # ヘッダーベースのルーティングでstore-v2にルーティングされることを確認 / $ curl -H " host: store.example.com " -H " env: canary " 10 . 0 . 1 . 4 " pod_name " : " store-v2-6856f59f7f-cczqb " # パスベースのルーティングでstore-germanにルーティングされることを確認 / $ curl -H " host: store.example.com " 10 . 0 . 1 . 4 /de " pod_name " : " store-german-66dcb75977-plqtx " # パス/deへのリクエストがService store-mirror-targetにミラーリングされていることを、Podで出力しているアクセスログから確認 $ kubectl logs store-mirror-target-c6b945fdf-9lnbt ~~~ 2021-11-12 11:00:25, 291 - werkzeug - INFO - 10 . 0 . 3 . 37 - - [ 12 /Nov/ 2021 11:00:25 ] " GET /de HTTP/1.1 " 200 - ~~~ 5. HTTPRoute で定義したルーティングルールの優先順位とGCLB上でのルールの優先順位の定義を確認する また、GKE Gateway Controllerでは spec.rules[].matches を ドキュメント 記載の以下の基準に従って優先順位付けします。 ホスト 最も長い、または最も具体的なホスト名と一致するものを優先 パス 最も長い、または最も具体的なパスと一致するものを優先 ヘッダー 一致するHTTPヘッダーの数が多いものを優先 リクエストに合致するルーティングルールが複数ある場合、より優先度の高いものが適用されます。また、 spec.rules[].matches が全く同じルーティングルールが存在する場合は、作成されたタイムスタンプがより古いルーティングルールが適用されます。 以下が検証のサンプルです。既存の HTTPRoute に定められたパスベースによるルーティングに競合するルールを追加し、ルーティングルールの優先順位を検証します。 kind : HTTPRoute apiVersion : networking.x-k8s.io/v1alpha1 metadata : name : store-conflict labels : gateway : internal-http spec : hostnames : - "store.example.com" rules : # /deでのパスベースのルーティングはHTTPRoute storeで既に定義されているため、競合する - matches : - path : type : Prefix value : /de forwardTo : - serviceName : store-v2 port : 8080 # 競合するルーティングルールを持つHTTPRouteでも正常にapplyできる $ kubectl apply -f store-route-conflict.yaml httproute.networking.x-k8s.io/store-conflict created $ kubectl get httproute NAME HOSTNAMES AGE store [" store.example.com "] 66m store-conflict [" store.example.com "] 13s ヘッダー、パスを両方指定した場合、パスベースの条件の方がヘッダーベースの条件よりも優先されることが確認できます。また、競合するルーティングルールが存在するパス /de を指定した場合は、競合するルール群の中で最初に作成されたものが適用されることを確認できます。 # ヘッダー、パスを両方指定した場合、パスベースの条件の方がヘッダーベースの条件よりも優先され、 # store-germanにルーティングされることを確認 $ curl -H " host: store.example.com " -H " env: canary " 10 . 0 . 1 . 4 /de " pod_name " : " store-german-66dcb75977-plqtx " # 競合するルーティングルールがあるパスを指定した場合、競合するルールの内、先に作成したstore-germanへのルーティングルールが適用され、 # 後から作成したstore-v2へのルーティングルールは適用されないことを確認 $ curl -H " host: store.example.com " 10 . 0 . 1 . 4 /de " pod_name " : " store-german-66dcb75977-4lnkf " 次に、GCLBに設定されたルーティングルールの優先順位を確認します。ルールの優先順位はGCLBに定義された routeRule の priority に設定されていることが分かります。 $ gcloud compute url-maps describe gkegw-8r5w-default-internal-http-2jzr7e3xclhj --region asia-northeast1 creationTimestamp: ' 2021-11-12T01:46:24.204-08:00 ' defaultRouteAction: faultInjectionPolicy: abort: httpStatus: 404 percentage: 100 . 0 weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 weight: 1 fingerprint: AczSXReW744 = hostRules: - hosts: - store.example.com pathMatcher: hostffxyqcv3l2rgbj3v3jakx7trkfuw01ei id: ' 1800894912999426335 ' kind: compute#urlMap name: gkegw-8r5w-default-internal-http-2jzr7e3xclhj pathMatchers: - defaultRouteAction: faultInjectionPolicy: abort: httpStatus: 404 percentage: 100 . 0 weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 weight: 1 name: hostffxyqcv3l2rgbj3v3jakx7trkfuw01ei routeRules: - matchRules: - prefixMatch: /mirror priority: 1 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-mirror-target-8080-zcxtgvjcck1r weight: 1 - matchRules: - prefixMatch: /de priority: 2 routeAction: requestMirrorPolicy: backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-mirror-target-8080-zcxtgvjcck1r weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-german-8080-o9g73h4mk3ob weight: 1 - matchRules: - prefixMatch: /de priority: 3 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c weight: 1 - matchRules: - headerMatches: - exactMatch: canary headerName: env prefixMatch: / priority: 4 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c weight: 1 - matchRules: - prefixMatch: / priority: 5 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v1-8080-t7d6vxl1jy1d weight: 90 - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c weight: 10 region: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1 selfLink: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/urlMaps/gkegw-8r5w-default-internal-http-2jzr7e3xclhj 競合するルーティングルールがあった場合でも、 Gateway , HTTPRoute リソースのStatusがエラー等になることはありません。 Gateway と Route は多対多の紐付けが可能なため、複数箇所で HTTPRoute を定義した結果、気づかないうちに競合するルーティングルールを定義しないように注意が必要です。しかし、Gateway APIではルーティングルールを一元管理する仕組みは特に定義されていません。そのため、ルーティングルールの競合への対応方針としてGateway APIの ドキュメント に以下の記載があります。 Where possible, this should be communicated by setting appropriate status conditions on relevant resources. GKE Gateway ControllerがGAになる際には、Gateway APIで定義されるリソースのStatusに警告が表示されるようになるかもしれません。 おわりに 本記事では、Kubernetes Gateway APIの概要と、APIで定義されるトラフィックのルーティングがGKE Gateway ControllerによってどのようにGCP上で実現されるのかの仕組みを紹介しました。Kubernetes Gateway APIとRBACを組み合わせることで、よりセキュアなマルチテナント構成を実現できます。そして、GKE Ingress ControllerではなかなかサポートされなかったGCLBの各種機能がGKE Gateway Controllerでサポートされるようなので、GAになるのが非常に楽しみです。 ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com hrmos.co Kubernetes Blog:Evolving Kubernetes networking with the Gateway API ↩
こんにちは、AndroidエンジニアのAndyです。これまでにZOZOSUIT、ZOZOMAT、ZOZOGLASSのアプリ機能開発に取り組んできました。 ZOZOGLASS は肌の色を計測するデバイスで、オンラインでファンデーションを購入する際の手助けをします。ZOZOGLASSのユーザーは下図のような専用の眼鏡をかけ、アプリを使用して顔の肌の色を計測します。 この技術の開発中に、私たちはクロスプラットフォームであるが故の技術的ハードルに直面しました。本記事では、そこで使用されているテクノロジーの一部と、それらの課題をどのように解決していったのかを紹介します。 クロスプラットフォームにおける技術的課題 前述の通り、開発を進めていく中でさまざまな技術的課題に直面しました。その原因はiOSとAndroidを同時にカバーするため、クロスプラットフォームである必要があったからです。それに起因し、肌の色の計測パフォーマンスの課題やフェイストラッキングの課題が発生しました。 現在では、フェイストラッキングにはARCore Augmented Facesを使用し、肌の色の計測と色補正アルゴリズムにはC++ライブラリを使用し、この課題に対応しています。本記事では、その中でも活用している、KotlinでC++ライブラリを使用する方法を簡単なサンプルを用いて説明します。 C++による計測ライブラリの作成 ユーザーの肌の色を計測するためのライブラリを、C++で開発しました。C++は、高性能アプリケーションの作成に使用できるクロスプラットフォームな言語です。これにより、開発者はシステムリソースとメモリを高度に制御することが可能になります。C#やJavaとも類似する点ですが、オブジェクト指向プログラミング言語としてプログラムに明確な構造を与えてコードを再利用できます。 計測専用の眼鏡はさまざまな配色のユニークな模様でデザインされています。そして、C++ライブラリはスマートフォンのカメラでユーザーの顔をスキャンする際に、このユニークな模様を活用して色の補正をします。検出した顔の各領域を識別し、それらの領域に対して肌の色のカラーマップを生成します。 ネイティブアプリへのC++ライブラリの組み込み パフォーマンスとUXの向上のために、スマートフォン向けのアプリはネイティブアプリとして開発することにしました。iOSアプリはSwift、AndroidアプリはKotlinを使用して開発されています。 しかし、iOSアプリでは、SwiftがC++と直接通信できないため、手動で中間レイヤーを追加する必要があります。例えば、すべてのC++の機能をObjective-Cモジュールにラップさせる方法があります。その場合、SwiftのアプリケーションからObjective-Cフレームワークを使用するだけなので、実装は容易です。 一方、Androidアプリの場合は、iOSアプリのように容易には実装できません。Android Native Development Kit(Android NDK。以下、NDK)を使用する必要があります。このNDKは、開発者がアプリの一部をネイティブコード(C++)で記述できるようにするツールセットです。 次章では、このNDKを利用したAndroidアプリの実装方法を説明します。 NDKを用いたAndroidアプリの実装方法 NDKには、以下のデフォルトツールが含まれています。 デバッガー CMake Java Native Interface(JNI) JNIは、Kotlin/JavaとネイティブC++間のインタラクションの処理を司るインタフェースです。これは、Androidによって生成されたバイトコードがネイティブコードと通信する方法を定義してくれます。その結果、Kotlinのコードは、JNIを使用することでC++コードと通信が可能となります。 それらのAndroidによって生成されたバイトコードとネイティブコードは、双方で関数と変数を保持しています。JNIを使用すると、KotlinからC++で記述された関数を呼び出したり、その逆も可能になります。また、言語間で変数に格納されている値を読み取って変更することも可能です。 前述のように、C++で記述されたネイティブコードを処理する場合、ネイティブ関数を呼び出し、引数を渡し、結果を取得する必要があります。これを処理するために「プリミティブ型」が使用されます。そして、引数をネイティブコードの関数に渡したり、プリミティブ型の形式で結果を取得したりするためにJNIで定義された特別なネイティブ型が存在します。具体的には Javaのドキュメント で記載されているように、Kotlin/Javaの各プリミティブに対応するネイティブ型が用意されています。 docs.oracle.com Android StudioでサンプルコードのNDKを動かしてみる 本章では、Android Studioを使い、簡単なサンプルを動かしながらNDKの利用方法を説明します。 環境準備とプロジェクト作成 Android StudioでNDKをサポートするには、以下のSDKを追加する必要があります。 LLDB Android Studioでプロジェクトに存在するネイティブコードをデバッグするために使用 NDK Androidのネイティブ言語であるC++でコーディングするために使用 CMake OSでコンパイラに依存しない方法でビルドプロセスを管理するために使用 Android Studioで「Native C++」のテンプレートでプロジェクを作成すると、下図に示す構造のプロジェクトが生成されます。すべてのネイティブファイルと CMakeLists.txt ファイルを含む cpp ディレクトリが作成されます。なお、C++のコードは native-lib.cpp ファイルに含まれています。 サンプルコードによる動作確認 最も簡単な、文字列を扱うサンプルコードを見ていきます。 C++のコード、つまり native-lib.cpp を以下のように変更します。 extern "C" JNIEXPORT jstring JNICALL Java_jp_zozo_sample_library_jni_Native_stringFromJNI( JNIEnv *env, jobject /* this */ ) { std::string hello = "Hello, World!" ; return env->NewStringUTF(hello.c_str()); } 次に、Kotlinのコードにて、アプリケーションの起動時に System.loadLibrary("native-lib") メソッドを呼び出し、先程のC++のネイティブコードをロードするようにします。 System.loadLibrary( "native-lib" ) 続いて、ライブラリによって実装されたネイティブメソッドを、以下のようにKotlinの関数として宣言します。 external fun stringFromJNI(): String すると、以下のようにKotlinから関数を使用できるようになります。 sample_text.text = stringFromJNI() 以上のように、ネイティブコードの関数をKotlinから簡単に使用できることが分かりました。 先程のサンプルは文字列を扱っていましたが、同様に数値を扱うこともできます。 C++は以下のように変更します。 extern "C" JNIEXPORT jint JNICALL Java_jp_zozo_sample_library_jni_Native_add( JNIEnv *env, jobject, jint x, jint y) { return x + y; } そして、Kotlinのコードは以下のように変更します。 external fun add(x: Int , y: Int ): Int val result = add(x = 1 , y = 1 ).toString() sample_text.text = result 数値でも文字列同様、簡単に実装ができました。 ZOZOGLASSではカメラから取得された画像データをリアルタイムに処理して計測処理を行っています。そのため、最後にKotlin側で取得された画像データをC++で処理するサンプルコードを見ていきましょう。 C++を以下のように変更します。 extern "C" JNIEXPORT void JNICALL Java_jp_zozo_sample_library_jni_Native_imageProcessing( JNIEnv *env, jobject, jbyteArray byteArray) { jbyte* buffer = env->GetByteArrayElements(byteArray, nullptr); // 重い画像処理 env->ReleaseByteArrayElements(byteArray, buffer, 0 ); } そして、Kotlinのコードは以下のように変更します。 external fun imageProcessing(image: ByteArray ): Unit ZOZOGLASSではこのように画像を処理しています。以上でサンプルコードを用いた解説は終了です。 ARCoreライブラリによるフェイストラッキングの実装 フェイストラッキングをするために、 ARCore ライブラリを使用しました。ARCore SDKは、モーショントラッキング、環境理解、光推定などのAR機能用のAPIを提供しています。この機能を活用すると、新しいARエクスペリエンスを構築したり、既存のアプリをAR機能で強化できます。 developers.google.com Augmented Faces はARCoreの一部です。Augmented Facesイメージトラッキング機能を使用すると、ユーザーの顔がカメラによって検出できます。そして、ARCoreは検出された顔の各領域を自動的に識別するために、拡張された顔のメッシュを生成します。その際のメッシュは顔の仮想表現であり、頂点、ユーザーの頭や顔の領域で構成されます。 developers.google.com そのようにARCoreで拡張された顔により、アプリはカメラから検出された顔の各領域を自動的に識別できます。 なお、拡張された顔の位置は以下のように生成されます。 センターポーズの特定 鼻のうしろにあり、ユーザーの頭の物理的な中心点をセンターポーズとして特定 顔の各領域を識別 顔のメッシュとセンターポーズを使用し、ユーザーの顔の左額、右額、鼻の先の領域を識別 このように取得された要素は、Augmented Faces APIによって、肌の色のテクスチャを顔にオーバーレイするための配置ポイントおよび領域として使用されます。そして、アタッチされている顔の領域に基づいてレンダリングします。 このようにユーザーの肌の色のデータを収集することは、ユーザーの統計データの傾向を見つけるのに役立ちます。アルゴリズムを使用し、ユーザーのデータに基づいて下図のように適したファンデーションの色の推薦が可能になります。 まとめ 本記事で紹介した技術を使用することで、ユーザーの肌の色をスムーズに測定し、データを効果的に表示および使用できるユーザーフレンドリーなクロスプラットフォームなアプリを作成できました。アプリの開発前から、想定される機能を計画し、解決すべき課題に基づいた適切な技術選定をすることが重要です。 また、プロジェクトのスコープを考慮することも重要です。時間とリソースの制約があるプロジェクトでは、より迅速なサービスの提供が求められます。要件を満たす技術が既に存在する場合、必ずしも高度な技術を自ら作り出すのではなく、それらの既存の技術を活用することが重要です。 ZOZOでは、Androidエンジニアを募集しています。興味のある方はこちらからご応募ください! corp.zozo.com
こんにちは、 『地球の歩き方ムー』創刊のニュース に心を踊らせている、データ基盤ブロックの塩崎です。 本記事では、データ基盤の管理者としてBigQueryのストレージコストの削減に取り組んだ事例を紹介します。 BigQuery費用はクエリ費用だけではない ZOZOのデータ基盤として利用されているBigQueryは、非常にパワフルなDWH(Data WareHouse)です。しかし、それ故に利用者の意図しないところで費用が高騰することもしばしば発生します。よく問題になるのはクエリ費用の高騰であり、以下のQiita記事はBigQuery利用者の中でも有名です。 qiita.com このクエリ費用の高騰に対し、我々データ基盤ブロックはこれまでに、いくつもの方法で対処してきました。具体的な取り組みの一部は以下の記事で紹介しているので、併せてご覧ください。 techblog.zozo.com techblog.zozo.com しかし、BigQueryの費用はクエリに関するもののみではありません。以下のドキュメントによると、BigQueryの費用はクエリに関する費用(Analytis)とストレージに関する費用(Storage)の2つがメインであることが分かります。 BigQuery pricing has two main components: Analysis pricing is the cost to process queries, including SQL queries, user-defined functions, scripts, and certain data manipulation language (DML) and data definition language (DDL) statements that scan tables. Storage pricing is the cost to store data that you load into BigQuery. cloud.google.com このストレージに関する費用は、USマルチリージョンの場合、1か月1GBあたり0.020 USDであり、90日間変更のないテーブルはその半額の0.010 USDに自動的に割引されます。 この単価は、 Google Cloud Storage や Amazon S3 などと比較しても安価であり、BigQueryの導入初期はあまり気にならないことも多いです。しかし、BigQueryをデータ基盤として長年利用すると、徐々にストレージ利用量が増加することもしばしば発生します。 現在のZOZOのデータ基盤は約100のGCPプロジェクト、約1000のデータセット、数十万以上のテーブルにまたがる大規模なものへと成長しました。これらの全てのデータを1つのチームが把握することは非現実的であるため、各GCPプロジェクト毎に管理者を立て分割統治を行っています。そのため、全てのプロジェクトの中にある、全てのテーブルのデータサイズを一覧で表示して可視化を行うダッシュボードを作成しました。そして、そのダッシュボードに基づき、不必要にストレージコストが高騰している疑いのあるテーブルを洗い出しました。それらのテーブルの情報を個別に管理者に連絡することでコストの削減に成功しました。 以降で、その具体的な流れを説明していきます。 ストレージコストの可視化 本章では、ストレージ利用量の調査から、Data Studioで可視化するまでの流れを説明します。 ストレージ利用量の調査方法 はじめに、BigQueryのストレージ利用量をダンプして1つのテーブルに集約します。BigQueryのストレージ利用量は INFORMATION_SCHEMA.PARTITIONS に格納されているので、それを参照します。 cloud.google.com このビューの STORAGE_TIER 列を参照すると ACTIVE か LONG_TERM かが分かり、1GBあたりの単価が分かります。しかし、今回は分かりやすさのために、この部分はあえて無視していることをご了承ください。全てのプロジェクトのPARTITIONSビューを一括で取得する方法があれば楽なのですが、現時点ではそのような仕組みがないため、分割して取得します。大量のテーブルの情報を分割して取得するにあたり、特に以下の2点に注意する必要があります。 PARTITIONSビューから多くのテーブルの情報を取得するとエラーになる 1つのテーブルに対するINSERTは1日あたり1000回の上限がある PARTITIONSビューから多くのテーブルの情報を取得するとエラーになる注意点 1点目は、PARTITIONSビューのドキュメントにも記載のない罠であり、特に注意が必要です。多くのテーブルを保持しているデータセットに対して無邪気に以下のようなクエリを実行するとエラーになります。 SELECT * FROM `project_id`.`dataset_name`.INFORMATION_SCHEMA.PARTITIONS 発生するエラー: INFORMATION_SCHEMA.PARTITIONS query attempted to read too many tables. Please add more restrictive filters. このエラーが発生する閾値はドキュメントに記載がないため、正確な値は不明です。テーブルの数を変えながら実験した結果、テーブルの数が1000程度であればエラーが発生しないため、以下のようなWHERE句を使い参照するテーブルの数を限定するようにしました。 WHERE table_id IN (table_name1, table_name2, ..., table_name1000) 1つのテーブルに対するINSERTは1日あたり1000回の上限がある注意点 次に2つ目の注意点です。前述の通り、PARTITIONSビューからの情報取得は1000テーブル毎に分割されます。そのため、ストレージ容量をまとめるテーブルに対するINSERTの回数もそれに応じて増加します。 INSERT INTO bq_storage_stats SELECT * FROM `project_id`.`dataset_name`.INFORMATION_SCHEMA.PARTITIONS WHERE table_id IN (table_name1, table_name2, ..., table_name1000); INSERT INTO bq_storage_stats SELECT * FROM `project_id`.`dataset_name`.INFORMATION_SCHEMA.PARTITIONS WHERE table_id IN (table_name1001, table_name1002, ..., table_name2000); ... 一方で、BigQueryは1つのテーブルに対するDML操作の上限が1日あたり1500回に設定されています。 cloud.google.com そのため、多くのプロジェクト・データセットに関する情報を取得する際には、この上限に気をつける必要があります。我々の環境では上限に達してしまったため、Streaming Insertを行うことでエラーを回避しました。Streaming Insertの上限は先程のDMLの上限とは別であり、閾値がかなり大きいため回避策として利用できます。 cloud.google.com 複数のGCPプロジェクトのBigQueryのストレージ利用量を収集するスクリプトを以下に示します。 from google.cloud import bigquery from itertools import zip_longest, groupby import time import string import random import concurrent.futures # 集計対象のGCPプロジェクトIDの配列 project_ids = [ 'project_id1' , 'project_id2' , ... ] # ストレージ利用量を集約するテーブル destination_table = 'project_id.dataset_id.table_name' # Ref: https://docs.python.org/3/library/itertools.html#itertools-recipes def grouper (iterable, n, fillvalue= None ): args = [ iter (iterable)] * n return zip_longest(*args, fillvalue=fillvalue) def create_destination_table (client, destination_table): schema = [ bigquery.SchemaField( "project_id" , "STRING" , mode= "NULLABLE" ), bigquery.SchemaField( "dataset_name" , "STRING" , mode= "NULLABLE" ), bigquery.SchemaField( "table_name" , "STRING" , mode= "NULLABLE" ), bigquery.SchemaField( "table_rows" , "INTEGER" , mode= "NULLABLE" ), bigquery.SchemaField( "total_logical_bytes" , "INTEGER" , mode= "NULLABLE" ), bigquery.SchemaField( "total_billable_bytes" , "INTEGER" , mode= "NULLABLE" ), ] table = bigquery.Table(destination_table, schema=schema) client.create_table(table) def get_dataset_names (client, project_id): query = f "SELECT SCHEMA_NAME AS dataset_name FROM `{project_id}`.`region-us`.INFORMATION_SCHEMA.SCHEMATA ORDER BY SCHEMA_NAME ASC" rows = client.query(query) return [row[ 'dataset_name' ] for row in rows] def get_table_names (client, project_id): query = f "SELECT table_schema AS dataset_name, table_name from `{project_id}`.`region-us`.INFORMATION_SCHEMA.TABLES ORDER BY table_schema ASC, table_name ASC" rows = client.query(query) return rows def generate_table_size_job (client, project_id, dataset_name, table_names, destination_table): table_names_str = "," .join([ '"' + t + '"' for t in table_names]) query = f """ SELECT table_catalog AS project_id, table_schema AS dataset_name, table_name, sum(total_rows) AS total_rows, sum(total_logical_bytes) AS total_logical_bytes, sum(total_billable_bytes) AS total_billable_bytes FROM `{project_id}`.`{dataset_name}`.INFORMATION_SCHEMA.PARTITIONS WHERE table_name IN ({table_names_str}) GROUP BY table_catalog, table_schema, table_name """ return client.query(query, bigquery.job.QueryJobConfig(priority= "BATCH" )) def retvieve_rows (query_job): exception = query_job.exception() if exception is not None : print (exception) print ( "Error occurred during the execution of the following query" ) print (query_job.query) raise exception results = [] for row in query_job.result(): results.append(row) return results client = bigquery.Client() temp_destination_table = destination_table + "_" + '' .join(random.choices(string.ascii_letters + string.digits, k= 16 )) print (f "Temp Table: {temp_destination_table}" ) create_destination_table(client, temp_destination_table) print (f "created {temp_destination_table}" ) # 高速化のためにBigQueryへのJobを並列して投げる with concurrent.futures.ThreadPoolExecutor(max_workers= 20 ) as executor: for project_id in project_ids: dataset_names = get_dataset_names(client, project_id) dataset_count = len (dataset_names) print (f "{dataset_count} dataset(s) found in {project_id}." ) dataset_table_names = get_table_names(client, project_id) query_job_futures = [] for dataset_name, rows in groupby(dataset_table_names, lambda r: r[ 'dataset_name' ]): table_names = [row[ 'table_name' ] for row in rows] table_count = len (table_names) print (f "{table_count} table(s) found in {project_id}.{dataset_name}." ) # 1000テーブル毎に分割してPARTITIONSビューにクエリを投げる for table_names_chunk in grouper(table_names, 1000 ): table_names_chunk = [t for t in table_names_chunk if t is not None ] query_job = executor.submit(generate_table_size_job, client, project_id, dataset_name, table_names_chunk, temp_destination_table) query_job_futures.append(query_job) print (f "waiting for all query jobs have been created" ) concurrent.futures.wait(query_job_futures) query_jobs = [f.result() for f in query_job_futures] query_job_count = len (query_jobs) print (f "{query_job_count} query jobs has been created" ) while not all ([q.done() for q in query_jobs]): print ( "waiting for all jobs completed" ) time.sleep( 1 ) print (f "{query_job_count} query jobs has been completed" ) rows_futures = [] for query_job in query_jobs: rows_future = executor.submit(retvieve_rows, query_job) rows_futures.append(rows_future) concurrent.futures.wait(rows_futures) results = [] for rows_future in rows_futures: results += rows_future.result() result_count = len (results) print (f "{result_count} rows retvieved." ) if results: for results_chunk in grouper(results, 1000 ): results_chunk = [r for r in results_chunk if r is not None ] insert_errors = client.insert_rows(client.get_table(temp_destination_table), results_chunk) results_chunk_count = len (results_chunk) print (f "{results_chunk_count} rows inserted." ) if insert_errors: print ( "Error occured during inserting the following rows" ) print (insert_errors) print (f "saved storage stats of {project_id}" ) copy_job = client.copy_table(temp_destination_table, destination_table) copy_job.result() print ( "Copy Temp table to Destination table" ) delete_job = client.delete_table(temp_destination_table, not_found_ok= True ) delete_job.result() print ( "Delete Temp table" ) Data Studioで可視化 上記の手順で、複数のGCPプロジェクト内のストレージ利用量を1つのテーブルに集約しました。次に、この情報を可視化し、大量のストレージを利用しているGCPプロジェクト・データセット・テーブルを見つけていきます。可視化には、Google Data Studioを利用します。 datastudio.google.com 完成したダッシュボードを以下に示します。画面上部のフィルターでプロジェクト・データセットを絞り込み、その中でストレージ利用量の多いTOP 5のデータセット・テーブルを確認できるようにしました。 これは余談ですが、せっかくなので下図のようなバブルチャートを利用し、「見た目がカッコ良いダッシュボード」を作ろうともしました。しかし、上図の表形式のダッシュボードの方が役に立ちました。「見た目がカッコ良いダッシュボード」が必ずしも実用的だとは言えないことを実感しました。 テーブル利用状況の調査 ストレージ利用量の大きいテーブルが発見できたので、次に利用状況を調査します。ストレージを大量に利用していたとしても、それが利用されているテーブルであれば無闇に消すことはできません。そのために、テーブルが最近どの程度参照されたのかを確認します。 INFORMATION_SCHEMA.JOBS_BY_ORGANIZATION ビューを用いると、特定のテーブルに対して実行されたクエリを確認できます。なお、 JOBS_BY_ORGANIZATION ビューには実行されたSQL文が格納されていないので、必要に応じて JOBS_BY_PROJECT ビューも併用してクエリの利用状況を確認します。そして、BigQueryのストレージを多く消費しており、かつ最近の利用実績が乏しいテーブルを「無駄遣い疑惑」のテーブルとしてリストアップしていきます。 SELECT job_id, creation_time, project_id, user_email, job_type, statement_type, destination_table, referenced_tables FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_ORGANIZATION WHERE creation_time > TIMESTAMP ( ' 2021-09-01 ' ) AND ( SELECT LOGICAL_OR(rt.dataset_id = " データセット名 " AND rt.table_id = " テーブル名 " ) FROM UNNEST(referenced_tables) AS rt ) ORDER BY creation_time DESC 担当者への対応依頼 最後に「無駄遣い疑惑」テーブルの削除を前提とした対応を担当者に依頼しました。その際には、テーブル名・ストレージ利用量だけではなく、以下の情報も併せて伝えました。それにより、迅速に対処をしてもらうように心がけました。 1年あたりのストレージコスト見積もり 最近数カ月間でそのテーブルに実行されたクエリ 実は必要だということが後から判明しても、 削除から7日以内であれば復元可能 であること 関係部署の協力も得られ、結果として合計で約1000TBの無駄遣いテーブルを削除できました。再発防止策として、アドホック分析の中間結果を配置する場合には、データセット毎にテーブルのデフォルト有効期間を設定するように働きかけました。 cloud.google.com まとめ BigQueryはコンピュートだけではなく、ストレージも非常にパワフルなDWHです。そのため、利用者の意図しないところでストレージ費用が高騰する恐れもあります。ZOZOのデータ基盤は多くの部署が利用しているため、それぞれの利用者の努力に依存するだけでは発生を抑制することは困難です。そのため、 INFORMATION_SCHEMA というBigQueryに備わっている仕組みを活用することで、横断的かつ効率的に費用を無駄遣いしているテーブルの発見・削除をしました。 ZOZOではデータ基盤のガバナンスを強化し、利用者にとって安全安心なデータ基盤を整備していく仲間を募集中です。ご興味のある方は以下のリンクからご応募ください。 hrmos.co
はじめに こんにちは、MA部MA基盤ブロックの齋藤( @kyoppii13 )です。 ZOZOTOWNではキャンペーンやセール情報などをメールマガジン(以下、メルマガ)で配信しています。そして、そのメルマガの最下部にバナーを掲載しています。従来のメルマガバナー運用方法は、スプレッドシートでバナー掲載スケジュールを管理し、DBに対して直接クエリを実行するという手作業による運用でした。この運用方法だと、人的なミスが発生しやすく、掲載されるバナーがイメージしづらいという問題がありました。そこで、バナー管理のためのCMSを開発し、既存配信システムへのデータ連携によって従来のバナー運用方法における問題点を解決しました。 メルマガバナー運用の移行は、急いでいたこと、開発メンバーが限られていることから開発工数を極力抑える必要がありました。そこで、本記事ではメルマガバナー運用の配信を限られた開発工数で、かつ安全に新システムへ移行するために取り組んだ事例を紹介します。 はじめに メール配信システムの概要 メルマガバナーとは メール配信システム 1. 配信対象者とメールで使用するデータを抽出 2. 抽出したデータをメールテンプレートへ埋め込み 3. ユーザへメルマガ配信 従来の運用フローと問題点 従来の運用フロー 問題点 バナー管理システムの導入 バナー管理アプリケーションの仕組み 1. バナー情報の登録 2. バナーの配信設定 データ連携の仕組み 1. データ連携処理の分離 2. 冪等性の担保 安全に移行作業を進めるための手順 1. 配信が停止されていることの確認 2. 既存のバナーデータをアプリケーションテーブルにインポート 3. 連携対象となる既存の配信システムのテーブルをリネームによってバックアップ 4. 連携対象テーブルと同じスキーマの空テーブルを本番用のテーブル名にリネーム 5. データ連携処理の開始 MBM導入後の運用フロー MBMの導入効果 将来の展望 バナー配信システム部分のマイクロサービスとしての切り分け 管理ツールの統一化 まとめ さいごに メール配信システムの概要 本章では、運用を移行するメール配信システムの概要を説明します。 メルマガバナーとは ZOZOTOWNではキャンペーンやセール情報などをメルマガで配信しています。そして、そのメルマガの最下部には各種キャンペーンやセール情報のバナーを掲載しています。下図はバナーの一例です。 なお、バナーデータはDBに保存されており、バナー画像のURL・遷移先URL・表示優先度・掲載開始の日時・掲載終了の日時が含まれます。バナーは1つのメールに複数掲載されるため、表示優先度が設定されています。また、バナーはキャンペーンによって掲載期間がそれぞれ異なります。そのため、バナーごとに掲載期間が設定されています。 メール配信システム メルマガは下図に示す構成のシステムにより配信しています。 配信の流れは以下の通りです。 配信対象者とメールで使用するデータを抽出 抽出したデータをメールテンプレートへ埋め込み ユーザへメール配信 メール配信ごとに上記の処理を実行します。 それぞれの処理を順に説明します。 1. 配信対象者とメールで使用するデータを抽出 はじめに、DBから配信対象ユーザとバナーデータを含むメールで使用するデータ抽出します。配信システムではメルマガ配信時にバナー掲載期間を確認し、配信時点で対象となるバナーデータを抽出します。メルマガは内容によって対象者が異なるため、同時に配信対象者の抽出も行います。 配信メールの種類は大きく分けてパーソナライズとマスの2種類です。そして、種類によって配信システムが別れています。パーソナライズは、お気に入りなどの情報に基づいて特定のユーザに対して配信するメールです。一方、マス配信は一定の条件に基づく複数のユーザに対して一斉に配信するメールです。 ここで抽出したデータはメール配信サービスへと送られます。 2. 抽出したデータをメールテンプレートへ埋め込み メール配信にはSaaSのメール配信サービスを利用しています。このサービスでは、1. で抽出した配信対象者とメールデータをメールテンプレートに埋め込んで配信されます。メールテンプレートはテンプレートエンジンのようにHTMLとデータの埋め込み位置を定義することで作成します。なお、作成したメールテンプレートはメール配信サービスへ事前にアップロードしておきます。 3. ユーザへメルマガ配信 で組み立てたメルマガを配信対象のユーザに送信します。 従来の運用フローと問題点 本章では、従来の運用フローと、そこで生じた問題点を説明します。 従来の運用フロー メルマガバナーの内容は月ごとに決定します。そのため、従来のメルマガバナーの運用は下記のフローで行われています。 施策担当者が施策立案 掲載スケジュール決定後、スプレッドシートに1か月分のスケジュールを記載 デザイナーがバナー画像を作成しアップロード バナー担当部署がバナーを配信システムのDBに直接登録 なお、上記の「バナー担当部署」とは、バナー掲載の調整を担当する部署を指します。 問題点 既存の運用方法では下記の問題点が存在していました。 バナー担当部署で工数が発生する 直接DBを操作するため、人的なミスが発生し得る スプレッドシート管理のため、実際に掲載されるバナーがイメージしづらい 既存の運用方法は、手動でのDB操作が必要でした。しかし、DB操作をできる人は限られているため、運用負荷が一部のメンバーに集中していました。また、手動運用のため人的なミスの発生がありました。他にも、バナー画像のURL・掲載順・遷移先URLをスプレッドシート管理していたため、実際に配信されるフォーマットと同じ見た目での事前確認ができませんでした。 また、この運用だと掲載期間などの調整もすべてバナー担当部署を介す必要があり、調整に時間がかかります。その結果、調整が間に合わずに適切なバナーを配信できないことになれば、ユーザへの価値提供の機会を失うことになります。 バナー管理システムの導入 前述の課題を解決するために、バナー管理システム(Mail Banner Manager。以下、MBM)というアプリケーションを作成しました。システムのアーキテクチャを下図に示します。 MBMはバナー管理アプリケーションとデータ連携アプリケーションの2つで構成されています。バナー管理アプリケーションはCMSツールで、メルマガバナー登録・管理が可能です。そして、データ連携アプリケーションは登録したバナーを2つの配信システムのDB(SQL ServerとIIAS)へ連携します。 このアプリケーションの開発は、限られた工数で行う必要がありました。なぜならば、開発リソースに既存のバナー担当部署のメンバーをアサインすることが難しいからです。もし、既存の運用を新しいメンバーが担当すると、DBを直接操作することにより人的ミスの起きる可能性が高まります。また、限りある開発リソースでは、メインのアプリケーション開発者は1人のみの状況でした。 また、スプレッドシート運用であったことから、バナー管理アプリケーションに当たる部分は AppSheet のようなスプレッドシートをDBとするNoCodeツールの利用も検討しました。しかし、画面設計の自由度が不足している点などから要件を満たせないと判断し、採用は見送りました。 バナー管理アプリケーションの仕組み 新しいアプリケーションは、ECS上でSPAとして動作しています。バックエンドはRuby on Rails、フロントエンドはVue.jsを採用しています。 また、ECS上でFargate環境を使っている理由は、サーバレスなサービスであることと冗長構成にするのが容易であるためです。ECSではDockerコンテナをタスクという単位で動作させることができます。 なお、Ruby on Rails + Vue.jsという組み合わせは、以前にアサインされた別の社内ツールの開発でも同じ技術スタックを採用していたためです。開発に慣れていること、機能としても似ている部分があったため、リソースを再利用できることが選定理由です。 MBMでバナーを配信設定するまでの流れは以下の通りです。 バナー情報の登録 バナーの配信設定 1. バナー情報の登録 バナーの基本情報を登録します。 具体的には、バナー画像、遷移先URL、タイトル、施策種別を入力し登録します。なお、登録時は画像のサイズ・拡張子、遷移先URLのフォーマットのバリデーションチェックをします。 下図がその画面です。 また、登録済みバナーは下図のように一覧で確認できます。検索機能もあり、任意のクエリに一致するバナーを表示できます。 2. バナーの配信設定 で登録したバナーの掲載期間と掲載順の設定をします。 下図は配信登録の画面です。この画面で登録済みのバナーと掲載期間、掲載順を設定します。 なお、日ごとの登録以外に、下図のように日をまたいだ期間指定による登録も可能です。 また、ドラッグ操作によるバナーの並び替えや編集もできます。下図がその画面です。 最終的に配信設定したバナーは、カレンダー表示や日次で確認できます。下図がその画面です。 MBMの導入により、バナー担当部署を介す必要なく、施策担当者が自身でバナー登録をできるようになりました。また、視覚的にもわかりやすくバナーを事前確認できるようになりました。 データ連携の仕組み 従来の運用方法でも、バナーデータを直接DBに登録するインタフェースが存在していました。その部分をシステム化し、開発工数を抑えつつも要件を満たすようにしました。 既存のインタフェースを利用するためには、DBを直接参照する必要があります。しかし、アプリケーションからは直接参照させずデータ連携部分から参照するように分離することで、障害となり得る部分を分けました。 データは、MBMで登録されたバナーをDBに保存し、その後、バッチ処理で配信システムのDBに連携される仕組みです。データ連携のアーキテクチャを下図に示します。 データ連携は1分間隔のバッチ処理で実行されます。そのバッチ処理では、MBMにバナー登録・更新があるかを確認し、配信システムDBにデータを連携します。 データ連携では、以下のポイントを考慮し、安定した連携を実現しました。 データ連携処理の分離 冪等性の担保 それぞれのポイントを順に説明します。 1. データ連携処理の分離 データ連携はアプリケーションから切り分けています。これにより、柔軟性と安定性、安全性が得られます。 データ連携を別アプリケーションとして切り出すことで、バナー管理アプリケーションが配信システムのDBを直接参照しなくて済みます。その結果、配信システムと疎結合になります。将来的にはバナーを含む様々なデータを管理するツールになることが見込まれています。このような改修が発生した場合に疎結合であることで、もう一方のシステムに与える影響がより少なくなり、改修しやすくなります。 2. 冪等性の担保 冪等性の担保はデータ連携アプリケーションの処理内で実現しています。 具体的には、DBごとにトランザクション内で DELETE INSERT をするようにしています。 以下に連携処理の擬似コードを示します。 MBM・SQL Server(パーソナライズ配信用システム)・IIAS(マス配信用システム)からn日分のデータ取得 MBM・SQL Server(パーソナライズ配信用システム)・IIAS(マス配信用システム)それぞれのデータ件数を取得 MBM・SQL Server(パーソナライズ配信用システム)・IIAS(マス配信用システム)それぞれの最終更新日を取得 // パーソナライズメールでの連携処理 if MBMとSQL Serverのデータを比較し、データ数が異なる OR MBMの方が最終更新日が新しい トランザクション開始 SQL ServerのDELETE処理 SQL ServerのINSERT処理 トランザクション終了 // マスメールでの連携処理 if MBMとIIASのデータを比較し、データ数が異なる OR MBMの方が最終更新日が新しい トランザクション開始 IIASのDELETE処理 IIASのINSERT処理 トランザクション終了 データ連携ではMBMと配信システムのデータを比較し、MBMのデータに更新があった場合に配信システム側のデータを DELETE 、その後 INSERT し直すことでデータを入れ替えています。これにより、連携元と連携先の対応関係や連携状態を保持する必要がなくなります。また、 DELETE INSERT を採用することで、対応関係と連携状態を持たずとも更新日時とデータ件数の2つを比較することで、データ連携済みであるかの判断が可能となります。 DELETE INSERT が途中で失敗した場合はロールバックされます。連携されていない場合は DELETE INSERT で一定期間のデータを入れ替えるため、連携に失敗しても次回以降の連携で成功すれば問題ありません。「複数回のバッチ処理で最終的に連携されていれば良い」という結果整合性を担保することにより、開発工数も抑えることができました。 このような工夫により、データ連携アプリケーションで状態を管理する必要がなくなります。その結果、障害発生時に考慮すべき点が減ります。例えば、逆に連携時刻をデータ連携アプリケーションで保持する場合、連携処理はできたが連携時刻の更新のみができていない状況になってしまうと、次の連携でも不必要に連携処理が実施されてしまいます。 また、データ連携はデータの更新がない場合はスキップしています。 DELETE INSERT で連携の度にデータをすべて入れ替えても連携はできます。しかし、毎回すべてを入れ替えると、Lambdaの実行時間によるコストが発生したり連携先DBに負荷を与えかねません。 安全に移行作業を進めるための手順 本章では、安全に新システムへ移行するために取り組んだことを紹介します。 リリース当日は以下の手順でリリースを実施しました。 配信が停止されていることの確認 既存のバナーデータをアプリケーションテーブルにインポート 連携対象となる既存の配信システムのテーブルをリネームによってバックアップ 連携対象テーブルと同じスキーマの空テーブルを本番用のテーブル名にリネーム データ連携処理の開始 各手順のポイントを紹介します。 各手順のポイントを紹介します。 1. 配信が停止されていることの確認 リアルタイムにメールが配信される時間帯は1日のうちで決まった時間帯のみです。指定した時間帯でのみ、リアルタイム配信によるメールが配信されます。MBMの導入に伴い、このリアルタイム配信用システムが参照するDBを変更するので、配信を停止する必要がありました。リリース当日は、このリアルタイム配信が停止していることを最初に確認し、以降の作業を実施しました。 2. 既存のバナーデータをアプリケーションテーブルにインポート データ連携の処理はMBMから配信システムに向かって一方向です。そのため、MBMの導入前に配信システムで保存されていたバナーデータは、MBMのDBへインポートする必要があります。インポートしない場合、既に配信システムに登録されているバナーデータをMBMで参照できなくなります。 また、インポートせずともMBMから1つずつ手動でバナー登録できますが、配信システムには数年分のバナーが登録されているため、時間がかかります。 そこで、インポート用のスクリプトを作成し、自動でインポートできるようにしました。こうすることで、スムーズかつ安全な移行作業を実現できます。さらに、手作業による工程を減らすことは、リリースが失敗した場合に同じ手順で再度リリースをしやすくできます。 3. 連携対象となる既存の配信システムのテーブルをリネームによってバックアップ MBMを導入すると、データ連携アプリケーションによって既存のテーブルに対し DELETE や INSERT の処理が実行されます。つまり、リリース作業の失敗や、導入後のシステム障害や操作ミスによりデータを破損してしまう可能性があります。そこで、運用されていたテーブルをリネームし、事前にバックアップを取得しておきます。バックアップを取ることで、障害が発生した場合にも、この時点までロールバックできるようになります。 4. 連携対象テーブルと同じスキーマの空テーブルを本番用のテーブル名にリネーム MBM導入以前に運用されていたテーブルと同じスキーマの空テーブルをリリース前に作成し、当日はリネームのみ実施しました。テーブルは作成後にスキーマが正しいか照合する作業が必要です。そのため、リリース前に空テーブルを作成しておくことで、このような確認作業を事前に終わらせることが可能です。その結果、リリース当日の作業を減らすことができます。 そして、上記の目的で事前に用意していたテーブルをリリース時に配信システムで利用されていたテーブル名にリネームします。MBMリリース後は、ここでリネームした空テーブルに対し、データ連携をします。 5. データ連携処理の開始 データ連携アプリケーションは、リリースの前日までにデプロイ済みです。なお、デプロイ時点では、連携処理は停止しています。そして、リリースのタイミングでこの停止していた連携処理を開始します。これにより、MBMで登録したインポート分を含むバナーデータが作成した空テーブルに対して連携されます。 リネームによってバックアップしたことで、仮にリリース後に障害が発生した場合でも、データ連携処理を停止して再度リネームし直せばロールバックが完了します。ロールバック手順を簡単にすることで、何かあった場合にもすぐにロールバック可能なので、余計な心配をせずにリリースできます。 MBM導入後の運用フロー MBM導入により、メルマガへのバナー掲載フローが下図のように変わりました。 MBM導入により、バナー担当部署が実施していた掲載枠の調整や、DBへの登録作業を施策担当者が自身で実施できるようになりました。 MBMの導入効果 MBMにより、前述の以下の問題を解決できました。 バナー担当部署で工数が発生する バナー担当部署を通さず施策担当者が登録できるようになったことで、バナー担当部署のコストが0になった 直接DBを操作するため、人的なミスが発生し得る MBMで登録したバナーはデータ連携によって既存の配信システムDBに連携されるため、直接のDB操作が不要になった その結果、DB操作時の人的なミスがなくなった スプレッドシート管理のため、実際に掲載されるバナーがイメージしづらい MBMの導入により、施策担当者が自身でバナー登録や掲載状況の確認ができるようになった バナー掲載の調整かかるコストが低減し、バナー掲載枠を効率的に使えるようになった その結果、これによって利益の最大化が見込まれる その他にも、データ連携アプリケーションを分離し冪等にできました。これにより、ネットワーク障害で連携処理ができなかった場合でも、次回以降の処理で成功すれば回復できる結果整合性を担保できました。リリース後、連携先DBで障害発生したことがありましたが、自動で復旧したため特別な対応は不要でした。 メール配信システムのインタフェースは変更していないため、仮にMBM起因の障害が発生しアプリケーション上からバナー登録ができなくなった場合でも、手動のクエリ実行によってバナー登録は可能です。 将来の展望 今回はメルマガバナーの手動運用の課題を、MBMというツールの新規開発・導入によって解決しました。将来の展望としては以下の施策を考えています。 バナー配信システム部分のマイクロサービスとしての切り分け パーソナライズ配信用システムやマス配信用システムはメールバナー施策以外にも様々なチャネルへキャンペーンの配信を担うシステムです。そのため、モノリシックなシステムとなっています。今回のバナーデータ登録までをアプリケーションによって解決するという方法も、短期間の開発期間において既存の配信システムに手を加えることが困難であることから考えた手段です。将来的にはバナー配信システムをマイクロサービスとして再構築し、バナー配信機能のサービス化をして、柔軟なバナー管理をできるようにしていきます。 管理ツールの統一化 今回は開発納期が短期間であったことから、独立したアプリケーションを作成しました。しかし、このように問題解決の度に新規でアプリケーションを開発していくと、管理コストが増えてしまいます。ZOZOTOWNではメール以外にも様々なチャネルを利用してコンテンツを配信しています。現在、配信に関する管理ツールが複数あります。このような管理ツールを統一化することで、管理コストを下げていきます。 まとめ 従来のバナー運用フローを改善するために、管理ツールを開発・導入する取り組みを紹介しました。そこでは、限られた工数の中で安全な移行を実現するためのアーキテクチャや移行作業にも触れました。本記事が皆様の参考になりましたら幸いです。 さいごに ZOZOでは一緒にプロダクトを開発してくれるエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください! corp.zozo.com
ZOZOTOWN開発本部 ZOZOTOWNアプリ部 Androidブロックの山田です。現在、私を含めた10名チームのブロック長としてZOZOTOWN Androidアプリの開発に取り組んでいます。 私がチームのマネジメント業務に携わったのは2019年4月からです。それ以降、常に7名以上のチームでマネジメント業務を務めてきました。経営学の用語で「スパン・オブ・コントロール」というものがありますが、そこにおいては「1人のマネージャー管理できる人数は5〜7人が適切」とされています。私個人の感覚では7名でも正直多く、5名ぐらいが適切のように感じています。 ともあれ、その状況を2年以上続けてきました。この経験を通し、多人数チームのマネジメントにおいて存在する課題が2つ見えてきました。 各個人に対するコミュニケーション時間減少に伴う、フィードバック量の低下と評価の難しさ チーム全体のパフォーマンス向上に伴う、リーダーのボトルネック化 本記事では、これらの課題解決のために実施した2つの施策を紹介します。 施策1:今週のいいね フィードバックが効率化される 評価へ活用できる 評価のエビデンスとしての活用 活躍のキャッチアップ 雰囲気が良くなる 施策1「今週のいいね」のまとめ 施策2:オーナーシップ制 クオリティ低下を予防しながら施策を進める必要がある リーダーが仕様検討に関わることへの是非を考える チームワークの和を広げられる 業務量のコントロールが難しくなる 施策2「オーナーシップ制」のまとめ 最後に 施策1:今週のいいね 各個人に対するコミュニケーション時間減少に伴う、フィードバック量の低下と評価の難しさを解決するための施策です。 本施策は、チーム内で毎週実施している振り返りの中で行っています。一言でまとめると「お互いの良かった行動を褒めたり感謝を伝え合う」ものです。 方法は簡単です。まず5分時間を測ります。その間にチームメンバー全員が他のチームメンバーの良いと思った行動や尊敬する行動を、以下のように書き出していきます。 そして、5分経過したら書き出しタイムは終了です。次に、1つずつそれを書いた人が読み上げて行きます。 本施策のポイントは下記3点です。 フィードバックが効率化される 1つ目のポイントは、行動に対するフィードバックを公に行うことで、フィードバックした本人以外にも間接的にフィードバックができるという点です。「間接的なフィードバック」というと少し大袈裟な表現ですが、要は1人の人を褒めることで、他の人にも「ああいう行動が求められているんだ」ということを伝えられるということです。特に、組織・チームが必要としている行動をピックアップすると効果的です。 例として、現在のZOZOTOWNのAndroidチームで考えてみます。チームには10名所属しており、チーム分割できる体制作りを目標に掲げています。しかし、具体的な手段はこれからアイデアを出していく段階なため、明確になっていません。そんな中、メンバーが属人化解消につながる行動を見せてくれたとします。属人化解消もチーム分割には重要な要素です。そのため、それを公の場で賞賛することにより、他のメンバーへのアイデアの共有につなげます。少し改変していますが、以下のような内容を「今週のいいね」でフィードバックしたことがあります。 XXの案件振り返り実施ありがとうございます。いつもは資料などリーダーが準備していたのですが準備方法を確認しに来てくれてスケジュールの設定から司会進行までやってくれてありがたかったです。どんどん周りの業務を奪いに行く姿勢が良いと思いました。 ここではまず感謝を伝えるとともに、その行動の具体的に何が良かったのかをフィードバックしています。伝えたい良かったポイントは、今までリーダーがやっていた業務を全てメンバーが代行してくれた点です。それをメンバーの前で伝えることにより、「それはリーダーがやるもの」と思い込んでいた人の意識改革につなげ、同時にチーム分割へのヒントとなるアイデアを共有したことになります。その結果、他の案件でも別のメンバーが同様に代行してくれるようになりました。このように、公の場で良い行動を称賛することは、その良い行動を連鎖させることにつながります。 評価へ活用できる 2つ目のポイントは、評価への活用が可能な点です。本施策を始めたきっかけも、評価への活用のためでした。 評価のエビデンスとしての活用 弊社では、「他のメンバーに良い影響を与える」ということが評価軸の1つにあります。評価は半期ごとに行い、自己評価を上長に評価面談でアピールしていく形式です。そこで自身の行動が他のメンバーに良い影響を与えていたかをアピールします。しかし、それを客観的に判断することは容易ではありません。 評価面談の際に、この項目について「わからない」と言ってくるメンバーもいました。私はメンバー全員との日頃の1on1の中で「あのピンチのときに○○さん(「わからない」と言っていたメンバー)が、ああいう行動をしてくれて救われた」という話を聞いていました。そのため、まったく該当する行動ができていなかったわけではないはずです。 しかし、それを感謝している本人から直接ではなく、上長経由で伝えたとしてもエビデンスにはしにくいです。そこで、毎週の振り返りでメンバー同士で直接伝え合ってもらい、それをエビデンスにしようという試みを始めました。 「毎週行う」というのもポイントです。メンバーの良いところを指摘するのは義務ではないため、忘れてしまったとしても罪ではありません。しかし、せっかく気付いていたのに、それらを忘れられてしまうと、みんなが損をしてしまうことになります。それを防ぐためにも毎週行うようにしました。実際には、1週間の期間でも忘れてしまうことはあります。もし、半期ごとの評価のタイミングだけ実施するようにした場合は、おそらくほとんどの有益な内容が忘れ去られているでしょう。 その結果、評価時にこれをエビデンスとして利用するメンバーもいました。活用事例まで出てきたので、本施策はやって良かったです。「メンバーがエビデンスを用意しやすい」ということは、「評価する側も判断しやすい」と言えます。そのため、多くの人数を評価すればするほど、この効果による恩恵の差はより大きく出てきます。 活躍のキャッチアップ 上記の評価への活用と同時に、上長が見逃した活躍のキャッチアップにも活用できます。現在、私のチームでは案件ごとに、さらに小さいチームを作って開発に取り組んでいます。その小さいチームには、私自身が参加する場合も、参加しない場合もあります。その結果、同じチームで活動しているかどうかで、そのメンバーの活躍に気付けるかどうかの差が生じてしまいます。そのため、個人の活躍を「今週のいいね」を利用して共有してもらい、上長である私が評価の要素として取り入れることを可能にしています。 そして、これは「自分が評価する立場でなくとも他の人の評価に貢献できる」ことを意味します。そのため、次のリーダー候補を見つけるのにも活用できます。それは、私個人としては、メンバーの良いところに目を向けられる人が、次のリーダーになって欲しいからです。 雰囲気が良くなる 当初から狙っていたわけではありませんが、「今週のいいね」の時間により、チームの雰囲気が良くなりました。お互いを認めあっていることが表面化され、より信頼関係が構築しやすくなっています。 施策1「今週のいいね」のまとめ 誰かを褒めることで他の人へ意識改革を促したり、アイデアのヒントを共有したりすることで、フィードバックの効率化を図りました。さらに、メンバーの活躍に対するフィードバックを上長だけでなく、他のメンバーからもできるようにすることで、上長が見逃してしまいがちな活躍をキャッチアップできるようにしました。また、副次的な効果として、チーム内の雰囲気向上にもつながりました。 続いてもう1つの施策を紹介します。 施策2:オーナーシップ制 本施策は、案件に対して各部署(Android、iOS、バックエンド、デザイナー)ごとに案件オーナーという名目で代表者をたて、その案件の仕様決定をオーナー間で行うという施策です。 以前は、上記の代表者の役割を各チームリーダーが担っていました。そして、リーダーが決まった内容をメンバーに展開し、開発するスタイルでした。その際に課題が2つ出てきました。1つ目は、やりたい案件が増えていったときにリーダーがスケールのボトルネックとなってしまう課題です。2つ目の課題は、社員でありながらも、メンバーから見たら受託開発のような形になってしまう課題です。 そんな折、メンバーのひとりが1on1で本施策のオーナーシップ制を提案してくれました。とても良いアイデアだと思い、一緒に詳細を詰め、部内で合意をとり、取り組みを開始しました。 ここでは、本施策のポイントを3つ紹介します。 クオリティ低下を予防しながら施策を進める必要がある 本施策の開始当初は、プロダクトマネージャーやリーダーが仕様検討に参加することもありました。他にもオーナーが一度チームに持ち帰ってアイデアを議論をしたのち、他のオーナーと仕様のすり合わせを行うこともありました。そうすることで、急激なクオリティ低下のリスクを予防しながら進めていました。 その結果、取り組みを始めて1年以上経過しましたが、現在までに大きな問題が発生することはありませんでした。並行して権限委譲も進み、今では仕様をリーダーと相談するかどうかは、そのオーナー自身が判断するようになりました。そのため、私が仕様検討にまったく関わらずにリリースされた機能も存在します。 リーダーが仕様検討に関わることへの是非を考える もちろん、リーダーが全ての案件の仕様検討に関わった方が、より良いサービスになる可能性もあります。しかし、それは「リーダーだから」というわけではなく、リーダー以外の全員が当てはまります。そのため、現在は「リーダーだから全ての案件の仕様検討に関わる」という考え方はしていません。こう考えるようになった理由の1つに、私の育休取得があります。実は、この取り組みを始めた直後に、私は2か月ほど育休を取得しました。しかし、その際にも問題は発生しませんでした。 この「リーダーが仕様検討に関わるべきか」に対しては賛否両論あると思います。私のチームでは、本施策を実施した結果、大きな事故も起きずにスピード感を手に入れられたため、このスタンスは継続していきます。 チームワークの和を広げられる 本施策でも副次的な効果を得られました。それは、チームの枠を越えたメンバー同士の理解が深まったことです。 これまでは、他チームの状態や考え方は、リーダーを介してメンバーに伝わっていました。しかし、それだけでは全ての情報を伝えきることはできません。そのため、他チームメンバーの考えを理解することが難しくなっていました。本施策を開始し、メンバー同士が直接やりとりするようになりました。そこでは、仕様以外の情報もやりとりできるようになりました。その結果、お互いのことをより知ることができ、チームワークの和がチームの枠を越えて広がっていきました。 業務量のコントロールが難しくなる ここまでは良かった点を紹介してきましたが、課題も存在します。それは、案件が並行して行えるようになったことにより、メンバーの業務量コントロールが難しくなったことです。仕様検討をリーダーが行なっていた頃は、リーダーのスケジュールが空いているタイミングでしか、仕様の議論ができませんでした。そのため、結果としてリーダーの業務量が増えてくると、自然とメンバーの業務量増加のブレーキがかかっていました。 一方、現在ではリーダーのスケジュールが空いてなくとも、メンバーのスケジュールが空いていれば議論を進めることができます。そのため、どこかで意識的にブレーキをかける必要がでてきましたが、今はそのタイミングがかなり掴みにくくなっています。この課題は部長・リーダー陣で共通して認識しており、対策を検討中です。 施策2「オーナーシップ制」のまとめ メンバーが上流工程に関わる機会を増やすことで、サービス全体の開発スピード向上や、メンバー1人1人の主体性の向上を図りました。その結果、現在のAndroidチームではその目的を達成できている状態です。さらに、リーダーが関わらずとも問題なく案件が進行される場面も増え、リーダーのボトルネック問題も解消したと言えます。ただし、メンバー1人1人の業務量が増え、そのコントロールが難しくなる新しい課題に現在は取り組んでいます。 最後に メンバーの人数が多くなってくると、リーダーが1人1人に関わることのできる時間は少なくなってしまいます。そこで生じる課題を解決するために、効率的にフィードバックと評価をする方法として、「今週のいいね」の施策を紹介しました。また、メンバーの人数が多くなってくるとリーダーがボトルネックになってしまう課題を解決する手段として、「オーナーシップ制」の施策も紹介しました。 どちらも副次効果として、人間関係の構築にも良い影響を与えることができました。この他にも、1on1やDiscord活用などの取り組みもあるので、「それらの話も聞いてみたい」という方は、まずはカジュアル面談でお話しましょう! hrmos.co もちろんAndroidエンジニアの採用も積極的に行っています。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
はじめに こんにちは、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