株式会社ZOZOのブログ - TECH PLAY

TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

1025

はじめに こんにちは。ECプラットフォーム部のAPI基盤チームに所属している籏野 @gold_kou と申します。普段は、GoでAPI GatewayやID基盤(認証マイクロサービス)の開発をしています。 ZOZOテクノロジーズでは、2020年11月5日に ZOZO Technologies Meetup〜ZOZOTOWNシステムリプレイスの裏側〜 を開催しました。その中で発表された API Gatewayによるマイクロサービスへのアクセス制御 に関して、当日話せなかった内容も含めて、API Gatewayについてこの記事で網羅的にまとめました。 API Gatewayやマイクロサービスに興味ある方、「API Gateway」という言葉は知っているけど中身はよく分からないという方向けの記事なので、読んでいただけると幸いです。 はじめに ZOZOTOWNのリプレイス マイクロサービス化の目的 ストラングラーパターン API Gateway概要 API Gatewayとは マイクロサービス化による問題(API Gatewayを導入しない場合) API Gateway導入による問題 API Gatewayの自社開発 自社開発をする理由 技術スタック データストア API Gatewayの機能と設定 リバースプロキシ ルーティング ターゲットとターゲットグループ ルーティングの設定 加重ルーティング APIクライアントトークン認証 単体での認証は弱い トークンの管理 IP許可レンジ リトライ リトライ条件 リトライ先 Exponential Backoff And Jitter タイムアウト メンバー認証 トレースIDの付与 開発で工夫したこと コンフィグファイルのスキーマ検証と仕様書作成 リクエスト中断時の処理 テスト 開発用パラメータの導入 ローカル動作検証でのマイクロサービスのモック テストコード中のマイクロサービスのモック シンプルなモック タイムアウトのモック 分析・監視 Athena CloudWatch Alarm Datadog APM スパンの開始 スパンタグの付与 Sentry Sentryへのエラー情報送信 秘匿情報を取り除く PagerDuty API Gatewayの現状とこれから We are hiring ZOZOTOWNのリプレイス ZOZOTOWNがこれからも成長を続けるために、開発効率・運用性・拡張性・柔軟性・回復性の確保を見据えて、技術面や環境面でも刷新するためのリプレイスを進めています。 マイクロサービス化の目的 ZOZOTOWNの開発では、レガシシステムのリプレイスに伴い、モノリシックな開発からマイクロサービス開発への移行を推進しています。ただし、マイクロサービス化はあくまで手段であり、それ自体は我々の目的ではありません。マイクロサービス化の過程において、健全な開発組織の文化醸成を行い、最終的には組織全体のパフォーマンスの向上を目的としています。 ストラングラーパターン ZOZOTOWNは最初にリリースされてから15年以上が経過しています。大規模なZOZOTOWNを一度に全てリプレイスするのは困難です。 そこで、 ストラングラーパターン を採用しています。ストラングラーパターンは、レガシシステムを徐々に新しいシステムに置き換えて移行する方法です。今回ご紹介するAPI Gatewayがストラングラーファサードの役割を担っています。 API Gateway概要 そもそものAPI Gatewayについて説明します。 API Gatewayとは API Gatewayとは、クライアントとAPI群の間に設置される、APIリクエストを各アプリケーション(マイクロサービスおよびレガシアプリケーション)へルーティングするアプリケーションです。 以下は、API Gatewayを使用した、ZOZOTOWNのマイクロサービス化の一例を示した図です。 マイクロサービス化による問題(API Gatewayを導入しない場合) マイクロサービス化自体は、API Gatewayやサービスメッシュを導入せずとも可能です。しかしながら、下記の問題を抱える可能性があります。 マイクロサービス側で同じような処理が複数箇所で実装される 認証/認可 クライアント側で同じようなリクエスト制御が複数箇所で実装される リトライ タイムアウト クライアントとマイクロサービス間のネットワークラウンドトリップによりレスポンス速度が低下する マイクロサービス側の変更がクライアント側に影響しやすい マイクロサービスを外部公開することになる トレーサビリティが低下する API Gatewayを導入することで上記の問題を解決できます。 参考: API ゲートウェイ パターンと、クライアントからマイクロサービスへの直接通信との比較 API Gateway導入による問題 一方で、API Gateway導入による問題もあります。 可用性低下 単一障害点の増加 性能低下 API Gateway通過時のルーティング処理コスト 通信回数の増加の可能性(1APIリクエストのみの場合) スケーリングが間に合わない場合はAPI Gatewayがボトルネックになる可能性 コスト増加 開発する場合は開発コスト 既存サービスを利用する場合はそのサービスの学習コスト 運用コスト インフラコスト 参考: API ゲートウェイ パターンの欠点 API Gatewayの自社開発 ZOZOTOWNの開発では、API Gatewayを自社開発しています。 自社開発をする理由 「API Gateway」といえば、 Amazon API Gateway やOSSの Kong が有名です。しかしながら、今回はマイクロサービス化の開発中に発生する、様々な要求に柔軟に素早く対応するため、自分たちで開発することにしました。 例えば、自分たちが必要としているリトライやタイムアウトの細かい制御機能は、少なくとも導入検討時においては既存のものでは実現が難しそうでした。カスタムのプラグインなどを開発すれば要件を満たせますが、Lua/C++/Lambdaなどを駆使してスピード感を持って開発できるエンジニアがチームにいませんでした。 また、API Gateway導入時点ではID基盤側の要件が定まりきっておらず、多くの変更が発生しても柔軟に対応できる必要がありました。ID基盤は認証マイクロサービスで、ID基盤が発行したトークンの検証などをAPI Gatewayで処理しています。 加えて、開発当初、API Gatewayは全てのAPIリクエスト(マイクロサービス間も含む)がAPI Gatewayを経由することを想定していました。したがって、リクエスト量に応じた従量課金のサービスは避けたかったという理由もありました。 技術スタック Go/Docker/AWS(EKSなど)/GitHub Actionsなどを使っています。 言語は実行速度や学習コストの低さなどから、Goを選択しました。 そして、API Gatewayはマイクロサービスと同様に、コンテナとしてEKS上に構築しています。 また、GitHub Actionsでは、以下のような処理を自動化しています。 テスト Dockerイメージの脆弱性診断 ECRへのイメージプッシュ デプロイ コンフィグ関連のドキュメント作成 データストア 現状、API Gatewayにデータストアは持たせていません。例えば、認証用に使用している公開鍵はPod上のオンメモリに存在しています。これは可用性や性能を意識しているためなのですが、今後どうなるかは未定です。 追加機能として、スロットリング機能の実装を検討しています。その際に、レートリミットを管理する必要があります。複数Podを考慮すると何かしらのデータストア(ElastiCacheなど)は必要になる可能性があります。 API Gatewayの機能と設定 API Gatewayに実装した機能とその設定方法について説明します。 リバースプロキシ API Gatewayの最も基本的な機能の1つとして、クライアントから来たHTTPリクエストをマイクロサービスへ転送する、リバースプロキシ機能があります。 大まかな処理の流れは以下です。 HTTPサーバを起動し、リクエストを受け付ける Goの標準パッケージ net/http の Server 型の ListenAndServe メソッドを使用 受け付けたリクエスト内容から転送先を確定する 同時に、リクエスト内容(パス、ヘッダなど)を一部加工 転送先のマイクロサービスにHTTPリクエストする Goの標準パッケージ net/http の Client 型の Do メソッドを使用 HTTPレスポンスをAPIクライアントへ返す 個人的な話ですが、最初は「リバースプロキシ機能」の開発と言われてもピンと来なかったです。しかしながら、開発していくうちに、「そうか。実体は単なるHTTPサーバとHTTPクライアントなんだな」と理解して、スッキリしました。当たり前と言えば当たり前なのですが。 ルーティング ターゲットとターゲットグループ ターゲットとターゲットグループはルーティングにおいて重要な概念です。 ターゲットは転送先の接続情報(ホストとポート)です。 ターゲットグループは、転送先であるターゲットをまとめた単位です。ターゲットグループ内ではレガシなモノリスシステムと新規のマイクロサービスを混在させるようなこともできます。 ターゲットとターゲットグループの設定には、 target_groups.yml という名前のYAMLファイルを用意します。YAMLファイル上で設定値が指定されていないものに関しては、ハードコーディングされたdefault値が適用されます。 以下は具体例です。TargetGroupAというターゲットグループの中に、target1.example.comとtarget2.example.comの2つのターゲットを指定しています。 TargetGroupA : targets : - host : target1.example.com port : 8080 - host : target2.example.com port : 8081 ルーティングの設定 routes.yml という名前のYAMLファイルを用意します。 ルーティングする送信元と送信先の情報を定義します。もしリクエスト情報が、定義された送信元情報に一致しなければ404を返します。 以下は具体例です。HTTPリクエストのパスが正規表現で ^/sample/(.+)$ に一致した場合、転送先のパスをGoの regexp.ReplaceAllString を使って、 /$1 に置き換えます。正規表現マッチした部分がURLのリライトの対象となるため、例えば /sample/hoge というパスでリクエストがきていた場合は、 /hoge に置き換えられます。 TargetGroupAに指定されたターゲットに対してラウンドロビンで転送先のターゲットを決定します。 - from : path : ^/sample/(.+)$ to : destinations : - target_group : TargetGroupA path : /$1 加重ルーティング target_groups.yml および routes.yml にて重み(weight)を設定できます。 target_groups.yml で指定する重みはターゲットに対する重みで、 routes.yml で指定する重みはターゲットグループに対する重みです。転送先の比重をコントロールすることで、加重ルーティングおよびカナリアリリースを実現できます。 例えば、 target_groups.yml でTargetGroupA内のtarget1.example.comに80%で、target2.example.comに20%の比重で振り分ける、重み付きラウンドロビンの指定が可能です。 TargetGroupA : targets : - host : target1.example.com port : 8080 weight : 4 - host : target2.example.com port : 8081 weight : 1 もし、重みを指定しない場合あるいは全てに同じ重みを指定した場合は、一般的なラウンドロビンになります。つまり、各ターゲットへ順に振り分けられます。 TargetGroupA : targets : - host : target1.example.com port : 8080 weight : 1 - host : target2.example.com port : 8081 weight : 1 APIクライアントトークン認証 APIクライアントトークンは、クライアントタイプ毎に用意したトークンです。以下のように、 api_client_tokens.yml という名前のYAMLファイルに、クライアントタイプとトークンの組み合わせを設定します。 APIクライアントトークン認証では、そのYAMLファイルの値とAPIクライアントトークン用の独自ヘッダに格納された値を比較します。 SampleClient : - abcde12345 また、 api_client_tokens.yml で定義したクライアントタイプを routes.yml の clients に指定します。 - from : path : ^/sample/(.+)$ clients : - SampleClient 単体での認証は弱い Don't rely on API keys as your only means of authentication and authorization for your APIs. Creating and using usage plans with API keys とあるように、これ単体では強い認証を提供することはありません。しかし、無いよりもあった方がセキュリティは強いです。 また、APIキー本来の目的は、トークンごとにアクセス量を制限することです。今後は、このトークンを利用して、クライアントタイプ毎のスロットリング機能の実装を考えています。 トークンの管理 実際のトークンの値は、YAMLファイルでなく、 AWS Secrets Manager で管理しています。これにより、GitHub上に秘匿情報を載せないようにしています。 AWS Secrets Managerは、SREチームのみが管理可能な状態です。管理人数をできるだけ限定することで、可能な限りセキュリティを向上しています。 IP許可レンジ ip_range_groups.yml という名前のYAMLファイルを用意します。ルーティングの送信元として許可するIP情報を指定します。 以下は具体例です。 SampleIPRange : - 127.0.0.1/32 ip_range_groups.yml で定義したIPレンジグループ名を routes.yml の ip_range_groups に指定することで、ルーティング毎に許可するAPIクライアントを指定できます。 - from : path : ^/sample/(.+)$ ip_range_groups : - SampleIPRange リトライ どのようなシステムであっても、なんらかの原因でリクエストが失敗する可能性はあります。例えば、転送先マイクロサービスの一時的なエラー、通信問題、タイムアウトなどです。その失敗をAPIクライアントへそのまま返さずに、API Gatewayとマイクロサービス間でリトライする機能です。最大の試行回数を3に設定していた場合に、1回目と2回目のAPIリクエストに失敗しても、3回目で成功すればAPIクライアントには200 OKが返ります。 リトライ条件 とはいえ、全てのAPIリクエストの失敗をリトライさせると非効率です。例えば、リクエストパラメータに不備がある場合には何度リトライさせたところで失敗になるため、待ち時間やコンピュータリソースの無駄になってしまいます。 そこで、 target_groups.yml でターゲットグループ毎にリトライ条件 retry_cases を設定できるようにしています。 HTTPレスポンスの条件がリトライ条件に一致した場合は、合計の試行回数が max_try_count の値を超えない範囲でリトライする作りにしています。 max_try_count の設定を省略した場合は、 targets で指定したターゲットの数になります。 加えて、 retry_non_idempotent により、冪等でないHTTPメソッド(POST, PATCH)に対してもリトライするかどうかを指定できます。設定を省略した場合は、falseです。 TargetGroupA : targets : - host : target1.example.com port : 8080 - host : target2.example.com port : 8081 max_try_count : 3 retry_cases : [ "server_error" , "timeout" ] retry_non_idempotent : true リトライ先 どのターゲットにリトライするかは target_groups.yml の retry_to で指定します。省略した場合は、 target_groups.yml のリストにしたがって次のターゲットにリトライします。最後のターゲットの場合は最初のターゲットになります。 TargetGroupAB : targets : - host : target-a-1.example.com port : 8080 retry_to : target-b-2.example.com - host : target-a-2.example.com port : 8080 retry_to : target-b-1.example.com - host : target-b-1.example.com port : 8080 retry_to : target-a-2.example.com - host : target-b-2.example.com port : 8080 retry_to : target-a-1.example.com Exponential Backoff And Jitter リトライする場合に、全て即時リトライとしてしまうと、リクエストの多重度が高くなってパフォーマンスの劣化に繋がります。 そこで、 Exponential Backoff And Jitter のFull Jitterというアルゴリズムを採用しています。これは、即時リトライせずに、ランダム性のある待ち時間を経てリトライする方法です。リトライする前に 0 ~ ベースインターバル * 2^試行回数 ミリ秒スリープします。つまり、リトライ回数が増えるたびにスリープ時間は長くなる可能性が高まります。また、スリープの上限(最大インターバル)を設定することもできます。上限のデフォルトはベースインターバルの10倍としています。 target_groups.yml でベースインターバル retry_base_interval と最大インターバル retry_max_interval の指定が可能です。 TargetGroupA : targets : - host : target1.example.com port : 8080 - host : target2.example.com port : 8081 retry_base_interval : 50 retry_max_interval : 500 実装の話でいうと、このようなスリープ関数を用意して、リトライ直前でこの関数を呼び出しています。 func SleepExponentialBackoffAndJitter(tryCount int , baseInterval time.Duration, maxInterval time.Duration) { interval := baseInterval * time.Duration(math.Pow( 2 , float64 (tryCount))) if interval > maxInterval { interval = maxInterval } interval = time.Duration(mathRand.Float64() * float64 (interval)) time.Sleep(interval) } タイムアウト API Gatewayのタイムアウトに関しては target_groups.yml で設定します。 TargetGroupA : targets : - host : target1.example.com port : 8080 connect_timeout : 50 read_timeout : 3000 idle_conn_timeout : 90000 - host : target2.example.com port : 8081 connect_timeout : 40 read_timeout : 2000 idle_conn_timeout : 80000 connect_timeout : 50 read_timeout : 3000 idle_conn_timeout : 90000 max_idle_conns_per_host : 2 connect_timeout は、1リクエストあたりのTCPコネクション確立までの間のタイムアウト値(ミリ秒単位)です。 read_timeout は、1リクエストあたりのリクエスト開始からレスポンスボディを読み込み終わるまでの間のタイムアウト値(ミリ秒単位)です。 idle_conn_timeout は、データが送受信されなかった場合にコネクションを維持する時間(ミリ秒単位)です。指定された時間内にデータが送受信されなかった場合、コネクションを閉じます。 max_idle_conns_per_host は、1ホストあたりに保持するアイドル状態のコネクションの最大数です。 Goの net/httpのClient を生成する時に、これらの値を利用して設定します。 また、これらの値は、 ターゲットの設定>ターゲットグループの設定>デフォルト設定 の順で優先付けされています。 メンバー認証 メンバー認証は、以下の図の流れのような、ID基盤が発行したIDトークンを利用したBearer認証です。 セキュリティ面の考慮から、本記事では詳細については割愛します。 トレースIDの付与 分散トレーシングを実現するために、API GatewayではトレースIDを発行し、リクエストヘッダに付与しています。 分散トレーシングとは、その名の通り、分散システムにおけるリクエストを追跡することです。マイクロサービスのように複数のサービスで構成される場合に、APIリクエストが複数のマイクロサービスにまたがると、トレーサビリティーの低下が懸念されます。分散トレーシングは、障害や遅延が発生した際に、どこに原因があるのかを速く正確に確認するのに役立ちます。 開発で工夫したこと コンフィグファイルのスキーマ検証と仕様書作成 以下の4項目をYAMLファイルから設定できるようにしています。API Gatewayの起動時には、これらの設定が必要です。 ターゲットとターゲットグループ ルーティング IP許可レンジ APIクライアントトークン 各YAMLファイルのスキーマ検証や仕様書作成は自動化されています。 別の記事 で詳細をまとめていますので、よろしければご覧ください。 リクエスト中断時の処理 特に、ネイティブアプリからのリクエストに関しては、ネットワークトラブルなどによる予期せぬリクエスト中断が起こり得ます。 もし、API Gatewayの処理中にクライアント側からの接続が切れた場合は、HTTPのステータスコードが460のエラーを返すように実装しています。なぜ460かというと、ALBの 仕様 に合わせているためです。 当然ながら、このエラー自体はクライアントまで返ることはないので、事実上ログ用途になっています。 テスト 開発用パラメータの導入 リクエストの時刻を改変して、動作を検証するために開発用パラメータを用意しました。 RFC 3339 の形式でヘッダに格納して使用します。例えば、IDトークンの有効期限切れの時刻を待たずに有効期限切れの動作を検証する目的として使用されます。 アプリケーションコード側では、Goの time.Now を都度呼び出す代わりに、引数で渡される context を活用してリクエスト時刻を扱っています。 開発用パラメータが指定された場合のみ、リクエスト時刻は任意の時刻に上書きされます。本番環境やステージング環境でこのヘッダが格納された場合は、無視されます。 ローカル動作検証でのマイクロサービスのモック Prism を使用して、マイクロサービスのモックを動作させています。 ローカルでAPI Gatewayの動作検証をするのに便利です。 例えば、下記のようなOpenAPIのYAMLファイルを用意します。 openapi : "3.0.0" info : version : 0.0.1 title : search service mock servers : - url : http://localhost:4011 description : local api mock server. paths : /api/v1/goods : get : responses : 200 : description : return some response. content : application/json : schema : type : object properties : status : type : string example : success docker-compose.yml では volumes で上記のYAMLファイルを指定して、 mock コマンドを指定します。 services : search : image : stoplight/prism:3 command : mock -h 0.0.0.0 /search-mock.yml volumes : - ./search-mock.yml:/search-mock.yml テストコード中のマイクロサービスのモック テストコード内におけるマイクロサービスのモックにはnginxを利用しています。 シンプルなモック test.conf にモックの定義をします。例えば、 search:4010 の /api/v1/goods にリクエストが来たら200を返します。 server { listen 4010; server_name search; location = /api/v1/goods { add_header Content-Type application/json; return 200 '{"status":"success"}' ; } } docker-compose.test.yml で test.conf をnginxコンテナの /etc/nginx/conf.d/default.conf に配置します。 services : mock : image : nginx:mainline-alpine volumes : - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf - ./docker/nginx/test.conf:/etc/nginx/conf.d/default.conf networks : backend : aliases : - search networks : backend : Goのテストコード側ではこのような target_groups.yml を定義します。 test : targets : - host : search port : 4010 上記の通り、以下を一致させる必要があります。 test.conf の server_name の値 docker-compose.test.yml の aliases の値 target_groups.yml のホスト(ターゲットID) 以上より、マイクロサービスのレスポンスをモック化して、テストコードを実装しています。 タイムアウトのモック さらに、 njs を利用して、スリープ処理するJavaScriptファイルを配置し、マイクロサービスへのリクエストタイムアウトに関する異常系テストをしています。njsは、nginxの内部で使用可能なJavaScriptのサブセットです。 下記のような sleep.js を用意しています。 function sleep(r) { setTimeout(function() { r.return(200, ""); }, 10000) } export default {sleep}; 下記の test.conf では js_import して、 slow:80 の / にリクエストがきたら sleep を実行するにしています。 js_import js/sleep.js; server { listen 80; server_name slow; location / { js_content sleep.sleep; } } docker-compose.test.yml はこちらです。 volumes で sleep.js を指定しています。 aliases に slow を指定しています。 services : mock : image : nginx:mainline-alpine volumes : - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf - ./docker/nginx/test.conf:/etc/nginx/conf.d/default.conf - ./docker/nginx/sleep.js:/etc/nginx/js/sleep.js networks : backend : aliases : - slow networks : backend : 分析・監視 API Gatewayの分析と監視について、ツール毎に説明します。 Athena ログの分析には Athena を使用しています。 ALBとアプリケーションのログはS3に保管しており、そのデータをクエリでSQL検索できます。例えば、マイクロサービスで付与されているトレースIDを検索条件に、エラー状況を確認できます。 Athenaはクエリでスキャンされるデータ量によって課金されます(2020年12月現在の東京リージョンで1GBあたり約0.5円)。したがって、必ずクエリのWHERE句で日付などを指定するように、社内でルール化されています。念の為、各Workgroupには「スキャンできるデータ量の上限」が設定されています。 CloudWatch Alarm 通知条件の判定には CloudWatch Alarm を使用しています。監視対象のメトリクスが閾値を超した場合に、SlackやPagerDutyで通知します。 Datadog APM 分散トレーシングの監視ツールには、 Datadog APM を使用しています。 APMはApplication Performance Monitoringの略称です。従来のアプリケーション監視では、プロセス監視や外形監視などが一般的でしたが、APMではパフォーマンスも監視対象にしています。 API Gatewayだけでなく、Datadog APM側でもトレースIDを発行しています。Datadog APMで発行したトレースIDにより、下図のように、コンソール上でそれぞれの処理が紐づいて表示されます。コンソールではAPI Gatewayとマイクロサービスのレイテンシーやエラー情報、インフラ情報、メトリクスなどを確認できます。加えて、SQLはクエリ単位までドリルダウンして確認できます。 スパンの開始 スパン は、計測単位です。スパン同士は相互にネストできるため、親子関係にできます。スパンを設定することで、ドリルダウンでの可視化を可能にしています。 リバースプロキシ処理内において、マイクロサービスへHTTPリクエストする直前にスパンを開始しています。 opts := []ddtrace.StartSpanOption{ tracer.SpanType(ext.SpanTypeHTTP), tracer.ResourceName( "url" ), tracer.Tag(ext.HTTPMethod, "method" ), tracer.Tag(ext.HTTPURL, "url" ), } span, _ := tracer.StartSpanFromContext(r.Context(), "transfer-request" , opts...) defer func () { span.Finish(tracer.WithError(e)) }() スパンタグの付与 スパンタグ は、スパンに付与するkey-value形式のタグです。以下のスパンタグを設定しています。 トレースID クライアントタイプ マイクロサービスのHTTPレスポンスコード 例えば、転送したマイクロサービスからのHTTPステータスを、スパンタグにセットする実装は次の通りです。 span.SetTag(ext.HTTPCode, response.StatusCode) Sentry アプリケーションのエラー監視には Sentry を使っています。 エラー情報だけでなくクライアント情報も詳しく表示され、Slackと連携したエラー通知も可能です。 Sentryへのエラー情報送信 このようなSentryにエラー情報を送信する関数を用意しています。 func SendSentry(r *http.Request, e error ) { hub := sentry.CurrentHub() if sentry.HasHubOnContext(r.Context()) { hub = sentry.GetHubFromContext(r.Context()) } hub.CaptureException(e) } エラー処理ではこの関数を呼び出しています。 if e != nil { lib.SendSentry(r, e) } 秘匿情報を取り除く ヘッダやボディなどのエラー送信内容には、トークンなどの秘匿情報が含まれます。Sentry側で、それらの秘匿情報を取り除けます。 Using before-send in the SDKs to scrub any data before it is sent is the recommended scrubbing approach Scrubbing Sensitive Data for Go | Sentry Documentation しかしながら、上記の通り、秘匿情報はSentryへの送信時点でアプリケーション側にて取り除いておくことが推奨されています。秘匿情報が送信前に取り除かれていることが管理画面上でわかるように、 XXXXX の値でマスクしています。 PagerDuty オンコールシステムには PagerDuty を使用しています。Slack通知しているものの中でより緊急度が高いものに関しては、PagerDutyからコールが来ます。 今のところサービスの監視担当は週次の交代制になっていて、SREチームとバックエンドチームから1人ずつ当番が割り当てられています。 API Gatewayの現状とこれから 現状、ある程度の機能を有したAPI Gatewayをリリースしています。これにより、ストラングラーパターンで進める準備(土台)ができました。 現在は、一日に約数億回のAPIリクエストがAPI Gatewayを経由しています。しかしながら、まだZOZOTOWNのAPIが全てAPI Gateway経由に置き換わったわけではありません。今後、さらにAPI Gatewayを通るリクエストが増えていくでしょう。例えば、 ZOZOSUIT や ZOZOMAT などの計測サービス、 ZOZOUSED 、 Fulfillment by ZOZO のAPIなどです。 ただし、マイクロサービス間のAPIリクエストに関しては、サービスメッシュ(Envoy)の導入も検討しています。理由は、「API Gatewayの負荷軽減」とAPIクライアント毎に配布している「クライアントトークン管理の手間を削減する」ためです。 We are hiring ZOZOTOWNのマイクロサービス化はまだ始まったばかりです。今後は、API GatewayやID基盤の追加開発に加えて、新たなマイクロサービスの開発も目白押しです。そのためのエンジニアが足りていない状況です。 ご興味のある方は、以下のリンクからぜひご応募ください。お待ちしております。 hrmos.co
はじめまして、ZOZO研究所福岡の家富です。画像検索システムのインフラ、機械学習まわりを担当しています。 今回は画像検索システムでお世話になっているAnnoyについてじっくり紹介したいと思います。 目次 目次 Annoyについて 近傍探索について Annoyのソースコードを読むときのポイント AnnoyIndexというクラスのインスタンスを作る インストール過程について PythonのC/C++拡張 Annoyの実装 1. add_item 2. build 3. get_nns_by_vector 4. build再考 他に問題となる点について CPU依存部分 ディスクかメモリか まとめ さいごに Annoyについて Annoyは、SpotifyによるPython近傍探索ライブラリです。 github.com 弊社のテックブログでも以前に取り上げています。 techblog.zozo.com 今回は実装について紹介していきたいと思います。 近傍探索について 近傍探索が一般的に満たすべき機能は以下の通りです。 まずベクトルの集合 V を用意します。以降「登録ベクトル群」と呼び、この集合に含まれるベクトルは「登録ベクトル」と呼びます。 次に検索ターゲットのベクトル a と個数 k を指定します。なお、 a は V に含まれていなくて良いです。 そして、このとき集合 V から k 個のベクトルを a から近い順に取ってくるというのが近傍探索の処理です。 とても単純に計算するならば a との距離を各 V の要素ごとに計算し、ソートすれば良いです。しかし、それだと V に含まれるベクトルの個数 n に比例して計算時間がかかってしまいます。 そのため、この計算を速くするというのが近傍探索ライブラリの役割です。だいたい log(n) のオーダー程度になることが望まれていると考えて良いです。 Annoyの性能に関しては、他の近傍ライブラリと比べて特別速いといったことはありません。しかし、メインとなるのは annoylib.h の1500行と annoymodule.cc の700行程度でコード量が少なく、他ライブラリに対する依存もないため、OSS入門として取り扱いやすいものとなっています。 Annoyのソースコードを読むときのポイント ソースコードを読む際に、どこから読めばいいか迷うと思います。一般的には、わかるところから、もしくは興味あるところからになると思います。Annoyの場合、私は README にある以下の実行サンプルをスタート地点としました。 from annoy import AnnoyIndex import random f = 40 t = AnnoyIndex(f, 'angular' ) # Length of item vector that will be indexed for i in range ( 1000 ): v = [random.gauss( 0 , 1 ) for z in range (f)] t.add_item(i, v) t.build( 10 ) # 10 trees t.save( 'test.ann' ) u = AnnoyIndex(f, 'angular' ) u.load( 'test.ann' ) # super fast, will just mmap the file print (u.get_nns_by_item( 0 , 1000 )) # will find the 1000 nearest neighbors 参考: Python code example | spotify/annoy このコードがやっていることは、以下の通りです。 AnnoyIndex というクラスのインスタンスを作る。 ベクトル( f =40次元)を1000個ランダムに抽出し、「登録ベクトル群」として入れて、検索高速化のための構造(インデックス)を作る。 作成したインデックスを test.ann というファイル名で保存。 新しいインスタンスを作成し、作成したファイルからインデックスを読み込む。 読み込んだインデックスを利用して、0番目のベクトル(最初のベクトル)と近いもの順に並べたベクトル群を出力。 このコードの各実行部分がソースコードのどこにあたるのかを見ていけば良いかなという発想で読んでいきました。 AnnoyIndexというクラスのインスタンスを作る インスタンス生成では、以下のコードにおいて定義されるAnnoyIndexを呼んでいます。 from .annoylib import Annoy as AnnoyIndex 参考: __init__.py | spotify/annoy .annoylib がどこからきているかというと、以下のコードからきています。 github.com このコードを読んでいくには、もう1つPythonのC/C++拡張についての理解が必要です。そのために、まずAnnoyの「インストール過程について」紹介し、その次に「PythonのC/C++拡張」を紹介します。 インストール過程について Annoyは通常、 pip を使って以下のコマンドでインストールします。 pip install annoy これはソースコードを手動でダウンロードして、以下のコマンドを走らせることと同様です。 python setup.py install setup.py での主要部分は以下の部分です。 ... setup(name= 'annoy' , version= '1.17.0' , description= 'Approximate Nearest Neighbors in C++/Python optimized for memory usage and loading/saving to disk.' , packages=[ 'annoy' ], ext_modules=[ Extension( 'annoy.annoylib' , [ 'src/annoymodule.cc' ], depends=[ 'src/annoylib.h' , 'src/kissrandom.h' , 'src/mman.h' ], extra_compile_args=extra_compile_args, extra_link_args=extra_link_args, ) ], ... 参考: setup.py#L75-L86 | spotify/annoy packages=['annoy'] の部分はカレントディレクトリの annoy というディレクトリをそのままモジュールとして使うことを意味しています。このディレクトリに先程の __init__.py があります。 ext_modules の部分は annoy ディレクトリ以下に annoylib を作ることを意味しています。ライブラリの具体的形式はOS環境により変わりますが setup 関数がよしなに処理してくれます。 このときに使うソースが src/annoymodule.cc です。 depends の部分はビルドする際に、 src/annoylib.h 、 src/kisssrandom.h 、 src/mman.h を必要としていることを示しています。この設定により、OS環境に応じたC/C++コンパイラとそのコンパイル、リンクオプションを自動で設定して、ライブラリを作成してくれます。 上記のようにメインのソースは src/annoymodule.cc であり、このソースは「PythonのC/C++拡張」によって書かれているため、次に「PythonのC/C++拡張」について紹介します。 PythonのC/C++拡張 C/C++のコードをPythonから使えるようにする仕組みは大きく分けると、以下の2種類があります。 1つ目がctypesを使う方法です。 docs.python.org 2つ目は、PythonのC/C++拡張を使う方法です。 docs.python.org 他にはSWIGを使うという方法がありますが、これは上記のPythonのC/C++拡張のためのインタフェース部分を作成するツールです。 ja.dbpedia.org 重要な違いはインタフェースを整える部分をPython側でやるか、C/C++側でやるかの違いです。すでにC/C++のライブラリがある場合はctypesを使ってPython側で調整してやるという方法が簡易です。Pythonライブラリとして公開したいが、ランタイム速度を求める場合などはPythonのC/C++拡張を使い、C/C++側で調整する方法が速度的に有利だと考えられます。 Annoyは後者のPythonのC/C++拡張を使う方法を採用しており、以下のようにモジュールを公開しています。 #if PY_MAJOR_VERSION >= 3 PyMODINIT_FUNC PyInit_annoylib ( void ) { return create_module (); // it should return moudule object in py3 } #else PyMODINIT_FUNC initannoylib ( void ) { create_module (); } #endif 参考: annoymodule.cc#L627-L635 | spotify/annoy Pythonのバージョンが3なのか、2なのかで公開方法が多少異なりますが、 2系統は開発が終了している ため、3の方法を確認しておけば問題ありません。 公開するモジュールは create_module において、以下で作成したオブジェクトを返すようにします。 PyObject *m; ... m = PyModule_Create (&moduledef); 参考: annoymodule.cc#L608-L616 | spotify/annoy Annoyの場合、重要なのは Annoy クラスを登録するところであり、以下の部分です。 PyModule_AddObject (m, "Annoy" , (PyObject *)&PyAnnoyType); 参考: annoymodule.cc#L623 | spotify/annoy ここで annoy モジュールの Annoy クラスとして PyAnnoyType で設定したものをモジュールに加えています。 PyAnnoyType を定義する上で重要な点は以下の通りです。 クラスインスタンスのメモリ容量の登録: sizeof(py_annoy) インスタンスのデストラクタの登録: (destructor)py_an_dealloc クラスのメソッドの登録: AnnoyMethods クラスのメンバーの登録: py_annoy_members インスタンスの初期化関数(下記のメモリ確保関数の後に呼ばれる。Pythonの __init__ の呼ばれるタイミング)の登録: (initproc)py_an_init インスタンスのメモリ確保関数の登録: py_an_new 上記の py_an_new まわりはPythonオブジェクトからC/C++用のデータを取り出す場合、どのように実装すればいいか、とても参考になります。 Annoyは Annoy クラスを管理するモジュールになっているため、動作を見る上で一番注意すべきところは py_an_new です。なお、 py_an_init ではチェックのみです。 距離構造を指定するパラメータ metric については、画像検索システムでは angular を使っているので、ここからは metric=angular と指定されているとしてコードを見ていきます。 py_an_new では以下のようにインスタンスを生成して、 ptr メンバーに設定されていることがわかります。 new AnnoyIndex< int32_t , float , Angular, Kiss64Random, AnnoyIndexThreadedBuildPolicy>(self->f); 参考: annoymodule.cc#L153 | spotify/annoy AnnoyのPythonのメソッドと、C/C++コードのメソッドの対応はAnnoyMethodsを見ることで対処できるので、以降はC/C++コード部分である src/annoylib.h を読んでいきます。 なお、C++のtemplateライブラリとして実装しているので、ヘッダーファイルが実装になっています。しかし、そこまで型を抽象化しているメリットは特にないように思います。 Annoyの実装 以降、引き続き metric の設定として angular が指定されているとしてコードを読んでいきます。 他の metric の場合、さらにヒューリスティックな要素が大きく、コードを読む際にわかりにくいこともあるため、対象を絞りました。一通り読む際も、まずは metric を固定して一読した方が良いです。 アルゴリズムを理解する上で重要となる AnnoyIndex クラスのメソッドは以下の3つです。 add_item : 検索対象となるベクトルの登録 build : 登録されたベクトルに対する検索を高速化するための構造の作成 get_nns_by_vector : 登録されたベクトルに対する検索 C++コードにおいても、メソッド名はPythonインタフェースと同名のメソッドとなっています。 上記のメソッドを紹介する前に、 AnnoyIndex のメンバーを簡単に説明しておきます。 const int _f; // 登録するベクトルの次元(e.x. 512) size_t _s; // nodesの要素のバイトサイズ S _n_items; // 「登録ベクトル群」に登録されているベクトルの個数 void * _nodes; // Nodeインスタンスの配列で、buildで作られるインデックス構造は、このNodeからなるtreeの集合 S _n_nodes; // 実際に登録しているNodeの個数 S _nodes_size; // _nodesが確保している配列の長さで、_n_nodesよりも一般に大きく取られる。この辺りはメモリを気にするC/C++特有のメンバー vector<S> _roots; // buildで作られる複数のtreeの各ルートを保持した配列 S _K; // ひとつのNodeに何個のIDを保持できるかを表した数。末端の1つ上のNodeに対して重要な使われ方をする int _seed; // 乱数のシード。 buildにおいて乱数が使われるので必要となる bool _loaded; // ファイルからロードされたかどうか bool _verbose; // 出力モードフラグ。trueなら出力が丁寧だが量が多くなる int _fd; // ファイルからインデックスをロードする際に使われる bool _on_disk; // _nodesをディスクにおく場合はtrue bool _built; // buildが呼ばれる前か呼ばれた後かを表すフラグ 参考: annoylib.h#L869-L882 | spotify/annoy なお、登録ベクトルの個数 _n_items と検索に使う Node の個数 _n_nodes の違いは重要です。 アルゴリズムとしては Node のメンバーも重要なので、buildの説明の際に行います。 1. add_item ここでは、まず、登録ベクトルに対応する Node インスタンスを作成を行います。IDの割り振りと、ベクトル値の登録をします。 次に、 _nodes メンバーに、先程作成した Node インスタンスを追加します。このとき、 _nodes に割り当てているメモリが足りない場合は確保し直します。 buildのフェーズにおいては Node として、ベクトルに対応しないものを登録していきます。そのため Node は登録ベクトルに対応するものと、しないものを見分けるためのメンバーが必要になりますが、その役割を n_descendants が行います。この値が1のときは登録ベクトルであるということになります。それ以外の値に関しては次の「2. build」で説明します。 2. build 目的は以下のデータ構造を作ることになります。「3. get_nns_by_vector」でデータ構造の詳細と使われ方を説明した後、「4. build再考」にて実装の説明をします。 上図のようなtreeの構造についてまず説明します。 Node は大きく、3つに分けられます。 末端 Node 「登録ベクトル群」に属するベクトルと対応します。 末端の1つ上の Node 末端 Node のIDの配列を保持します。 その他の Node children として子 Node を2つ保持します。この2つの子 Node のどちらかを辿っていけば、登録したベクトルに行き着くようにtreeを作成します。この Node でのベクトル v は、ある登録したベクトルが子 Node のどちらにあるかを示すために使用します。ベクトル x が x・v<=0 のとき、 children[0] , x・v>0 のとき、 children[1] の方から辿れるように Node を構成します。 探索中に辿っている Node が上記3つの Node のどれにあたるのか識別する必要がありますが、それは n_descendants という Node のメンバーを見ることで判断しています。 n_descendants は、その Node 以下にたどり着く登録ベクトルが何個あるのかを表しています。 n_descendants=1 ならば、末端 Node n_descendants<=_K ならば、末端の1つ上の Node _K の決め方が特殊なので「4. build再考」にて説明します それ以外ならば、中間の Node 上記の判断を行っているコードは、以下の部分がわかりやすいです。 if (nd->n_descendants == 1 && i < _n_items) { nns. push_back (i); } else if (nd->n_descendants <= _K) { const S* dst = nd->children; nns. insert (nns. end (), dst, &dst[nd->n_descendants]); } else { T margin = D:: margin (nd, v, _f); q. push ( make_pair (D:: pq_distance (d, margin, 1 ), static_cast<S>(nd->children[ 1 ]))); q. push ( make_pair (D:: pq_distance (d, margin, 0 ), static_cast<S>(nd->children[ 0 ]))); } 参考: annoylib.h#L1365-L1374 | spotify/annoy 3. get_nns_by_vector 実際には、さらに「2. build」で説明したようなtreeを複数個作り保持します。なぜ複数必要なのかは後で説明します。 このように複数のtreeが構成された状態で検索アルゴリズム本体である _get_all_nns が呼ばれます。 検索ターゲットのベクトル a に対して、各treeをどのように探索していくかを見ていきます。末端に到達するまでは各中間の Node においてどちらの children の Node を辿るべきかを判断する必要があります。 ここで a・v の値を計算し、 a・v>0 だったら children[1] 、 a・v<=0 だったら children[0] を探索していきます。 これは children を構成する際に、登録するベクトル x に対して x・v>0 だったら children[1] 、 x・v<=0 だったら children[0] の方に入れていくように構成するからです。具体的な構成方法は、「4. build再考」で紹介します。 厳密に計算するならば、探索ターゲットのベクトル a と「登録ベクトル群」に属するベクトル x の内積を調べる必要があります。しかし、それでは計算量が多くなるので代わりに v・x>0 となる代表ベクトル v との内積 a・v>0 かどうかを使って、近似的に近くになるだろうベクトル群を探します。「 a・v>0 、 x・v>0 、ならば、まあだいたい a・x>0 だろう」という感覚です。 v を基準として反対側よりも同じ側にあるベクトル群から探した方が近いものを見つけられるだろうという捉え方もできます。 「登録ベクトル群」の個数が n のとき、treeがうまく作られていれば、理想的にtreeのルートから辿る Node の個数は log(n) となるため、高速に検索できるということです。 ここで複数のtreeを用意する理由を考えます。 1つのtreeとした場合、ある Node の v との内積が0となるところ(以降、「際」と呼ぶ)の近くに検索ターゲットのベクトル a がある場合、問題が生じます。このようなベクトル a に対して、 children のどちら側のベクトル群もそれぞれ、 a と近いベクトルを含む事態が生じるためです。 このような「際にあるベクトル」は一般的にはいくらでも存在します。各 Node の v を基準とした場合に、検索ターゲットのベクトル a が「際」に近いベクトル(内積が0に近い)となる可能性を低くするため複数のtreeが必要になります。そのため、buildのパラメータ q は、大きければその分精度向上しますが、その分検索速度が落ちます。 では実際に探索するコードを見ていきます。 std::priority_queue<pair<T, S> > q; if (search_k == - 1 ) { search_k = n * _roots. size (); } for ( size_t i = 0 ; i < _roots. size (); i++) { q. push ( make_pair (Distance::template pq_initial_value<T>(), _roots[i])); } std::vector<S> nns; while (nns. size () < ( size_t )search_k && !q. empty ()) { const pair<T, S>& top = q. top (); T d = top.first; S i = top.second; Node* nd = _get (i); q. pop (); if (nd->n_descendants == 1 && i < _n_items) { nns. push_back (i); } else if (nd->n_descendants <= _K) { const S* dst = nd->children; nns. insert (nns. end (), dst, &dst[nd->n_descendants]); } else { T margin = D:: margin (nd, v, _f); q. push ( make_pair (D:: pq_distance (d, margin, 1 ), static_cast<S>(nd->children[ 1 ]))); q. push ( make_pair (D:: pq_distance (d, margin, 0 ), static_cast<S>(nd->children[ 0 ]))); } } 参考: annoylib.h#L1348-L1375 | spotify/annoy なお、 std::priority_queue の top メソッドはデフォルトでは一番大きい値のものを返します。 各treeにおいては、ルートからその Node までに通るすべての Node で「際」に最も近いもの(最も内積の値が小さいもの)を q に入れます。コード上ではこの値を margin (「際」からの距離)と呼んでいます。 q.top でこの値の「一番大きなもの」を取り出すという操作をしていますが、これは「際」から最も遠いものを選んでくることに対応します。同時に内積が負になるもの(つまり検索対象のベクトル a と反対側にあるもの)も取り除いています。 各treeの内部においては一番小さいものをとり、tree同士の比較では一番大きいものを取り出すというところで、混乱しやすいので注意が必要です。 できるだけ「際」に近い Node は辿らず、「際」から遠い Node を辿っていった方が近傍探索の精度は高くなるという発想です。 このように各treeから、より近いベクトルが含まれてそうな「末端の1つ上の Node 」を取り出し、それ以下の Node を nns に入れます。ここで nns に入れられる Node はそれぞれ「登録ベクトル群」に属するベクトルと対応します。あとは nns に入れられたベクトルと a の距離を求めて、ソートして返すだけです。この部分は std::partial_sort で実装されており、一般的にはヒープソートで実装されているようです。 github.com よって、返すベクトルの個数を M とした場合、 nns に対する操作のオーダーは M・log(M) と考えられます。 4. build再考 「3. get_nns_by_vector」で述べたようなアルゴリズムを実行するためにtreeを構成する必要があります。ここで重要なのは、登録されたベクトルを分けるためのベクトル v です。このときtreeの高さを抑えるため、ひいては検索速度をあげるため、「登録ベクトル群」をおおよそ半分に分けるベクトル v が望まれます。 また複数treeを作った際に、それぞれのtreeができるだけランダムな方が望ましいです。そうすることで、検索ターゲットのベクトルがすべてのtreeにおいて「際」になるようなケースを確率的に減らすことができます。 Annoyはrandom projectionを使ってtreeを構成し、そのtreeで検索するアルゴリズムです。一般的に近傍探索アルゴリズムにおいて、random projectionの取り方、treeの実装方法は多岐に渡ります。 arxiv.org ここではAnnoyの実装を述べます。Annoyでのrandom projectionに相当する、 v の取り方は create_split という関数で実装しています(参考: annoylib.h#L486-L493 | spotify/annoy )。 さらにこの中の two_means という関数がメインです。 template<typename T, typename Random, typename Distance, typename Node> inline void two_means ( const vector<Node*>& nodes, int f, Random& random, bool cosine, Node* p, Node* q) { /* This algorithm is a huge heuristic. Empirically it works really well, but I can't motivate it well. The basic idea is to keep two centroids and assign points to either one of them. We weight each centroid by the number of points assigned to it, so to balance it. */ static int iteration_steps = 200 ; size_t count = nodes. size (); size_t i = random. index (count); size_t j = random. index (count- 1 ); j += (j >= i); // ensure that i != j Distance::template copy_node<T, Node>(p, nodes[i], f); Distance::template copy_node<T, Node>(q, nodes[j], f); if (cosine) { Distance::template normalize<T, Node>(p, f); Distance::template normalize<T, Node>(q, f); } Distance:: init_node (p, f); Distance:: init_node (q, f); int ic = 1 , jc = 1 ; for ( int l = 0 ; l < iteration_steps; l++) { size_t k = random. index (count); T di = ic * Distance:: distance (p, nodes[k], f), dj = jc * Distance:: distance (q, nodes[k], f); T norm = cosine ? get_norm (nodes[k]->v, f) : 1 ; if (!(norm > T ( 0 ))) { continue ; } if (di < dj) { for ( int z = 0 ; z < f; z++) p->v[z] = (p->v[z] * ic + nodes[k]->v[z] / norm) / (ic + 1 ); Distance:: init_node (p, f); ic++; } else if (dj < di) { for ( int z = 0 ; z < f; z++) q->v[z] = (q->v[z] * jc + nodes[k]->v[z] / norm) / (jc + 1 ); Distance:: init_node (q, f); jc++; } } } 参考: annoylib.h#L364-L407 | spotify/annoy 引数の nodes は分ける対象となるベクトル群を表しています。まず、2つのベクトルを nodes からランダムに取り出して p 、 q にセットします。次に200個のベクトルをランダムに取り出し、 p 、 q の近い方に足して、足した方を平均化します。 これは以下の操作に相当します。 nodes から200個のベクトルをランダムに取り出しk-meansで2つのグループに分ける。 それぞれのグループの中心ベクトルを p 、 q とする。 ここは厳密なアルゴリズムではなく、ヒューリスティックに分けています。一般的にk-meansアルゴリズムはヒューリスティックなものです。 このように p 、 q を求めて、その差分を v として設定しています。確率的に nodes に属していたベクトルを内積で2分するようなベクトルになっていると考えられます。 上記を踏まえてbuildを読んでいきます。 thread_build というメソッドがメインになっています。元々はbuildによる実装でしたが、Annoyが1.17.0より並列コードを追加したため、 thread_build というメソッドになりました。1.16.3のバージョンのコードと比較してみるとわかりやすいです。 thread_build_policy というものを用いてthreadが共有するデータ構造に対してロックをかけながら実行するコードになっています。最初に読む場合、これらは無視して進むと読みやすいです。実際のところ、具象クラスの AnnoyIndexSingleThreadedBuildPolicy の実装の場合、lockメソッドは何もしていません。 では引き続き thread_build を読んでいきます。 thread_build は indices というローカル変数に「登録ベクトル群」を詰め込んで _make_tree を呼びます。 この _make_tree がtree構造作成のメインになります。 create_split によって区分けするためのベクトルを作り、再帰的に indices を2つのグループに分ける Node を作っていきます。 なお、正確にはtree構造が偏ってしまい、うまく分けられない場合がときおり生じます。そのための処理も入っています。 ここで「末端の1つ上の Node 」に関して _K について実装を説明します。使われている部分として以下のif文があります。 if (indices. size () <= ( size_t )_K && (!is_root || ( size_t )_n_items <= ( size_t )_K || indices. size () == 1 )) { ... 参考: annoylib.h#L1248 | spotify/annoy 残りの indices の個数が少ない場合、「末端の1つ上の Node 」を作成するときに処理される部分です。 _K 以下というところがポイントで、この _K は以下で定義されています。 _K = (S) ((( size_t ) (_s - offsetof (Node, children))) / sizeof (S)); // Max number of descendants to fit into node 参考: annoylib.h#L889 | spotify/annoy かなりアドホックですが、 _K は以下の式の値を計算しています。 (Node構造体のchildren以下のメンバーが保持するバイト数) / (Sのバイト数) これは children 以下のメモリ領域にSのインスタンスを何個保持できるかを表しています。この値を使うことで、残りの Node のIDを children 以下のメモリ領域に保持できるかを判断しています。 そして可能な場合は実際に Node のIDの配列を children 以下にコピーし、「末端の1つ上の Node 」を作り出します。 Node クラスの children[0] 、 children[1] 、 v の領域を配列として使っていて、メモリ破壊的な使い方をしています。しかし、一応、C++の std::vector はメモリ上に連続的に配置しなければならないという規約があるので大丈夫なようです。 www.open-std.org 以上、コードを読む際にひっかかりやすそうな部分を紹介しました。 他に問題となる点について CPU依存部分 バージョン1.16.0以降、Annoyは内積計算の部分でAVX512命令があるCPUにおいてはAVX512命令を使うようになっています。 template<> inline float dot< float >( const float * x, const float *y, int f) { float result = 0 ; if (f > 7 ) { __m256 d = _mm256_setzero_ps (); for (; f > 7 ; f -= 8 ) { d = _mm256_add_ps (d, _mm256_mul_ps ( _mm256_loadu_ps (x), _mm256_loadu_ps (y))); x += 8 ; y += 8 ; } // Sum all floats in dot register. result += hsum256_ps_avx (d); } // Don't forget the remaining values. for (; f > 0 ; f--) { result += *x * *y; x++; y++; } return result; } 参考: annoylib.h#L210-L230 | spotify/annoy これによって高速にはなるのですが、ビルド環境と実行環境において使用しているCPUに差が出るようなコンテナ運用などをしている場合、動かないことがあるので注意が必要です。 なお、AVX512ではないですが、AVX2に対しても同様の問題がありました。 github.com 実際に、画像検索システムではGitHub Actionsでビルドを行っているのですが、以下のような問題が生じました。 ビルド時にはAVX512命令をもっているCPUを割り当てられ、実行環境においてはAVX512命令がないCPUだったので、セグメンテーションフォルトとなることがありました。幸いテスト環境なので運用に支障はありませんでした。 解決方法としては以下の2つが考えられます。 使用するAnnoyのバージョンを落とす ビルド時に環境変数ANNOY_COMPILER_ARGSをコンパイルオプションとして指定する AVX512を抑制するgccの最適化オプションを調べるのは難しく、画像検索システムにおいては現状使用するAnnoyのバージョンを1.15.2としています。 ディスクかメモリか ファイルにセーブしたデータをロードするコードは mmap を使ったコードになっています。これはdisk cacheを使っているのでメモリから追い出される可能性があります。また、経験上、disk cache自体の速度が不安定という問題があります。 弊社瀬尾の以下の記事に問題となった部分の詳細と対応が述べられています。 docs.google.com まとめ Annoyは依存ライブラリがなく、コード量もそこまで多くないため非常にとっつきやすいコードとなっています。そのため、数値計算のOSSの入門として、とてもおすすめです。 また、その他の近傍探索ライブラリのコードを読む際にも、基準とするには良いコードです。 さいごに ZOZOテクノロジーズでは、ZOZO研究所のMLエンジニアも募集しております。 hrmos.co
コーポレートエンジニアリング部ITサービスチームの高橋です。コーポレートエンジニアリング部ではスタッフや組織の課題をテクノロジーの力で解決するということをビジョンに掲げています。その中でも私が所属するITサービスチームでは、ZOZOグループ全体の生産性を上げるため、部門や組織の課題をテクノロジーの力で解決に導く役割を担っています。クラウドベースのツールを活用し「攻めの戦略」を重視し、社内の活性化を目指しています。 その一環として、パスワード管理ツール 1Password を全社導入しました。導入に至った背景、製品選定で重視した点、及び実際の運用を紹介します。なお、弊社の環境は社員の大半がエンジニアで、社員数は400人規模です。 いきなり余談ですが、先日政府がPPAP(パスワード付きzipファイルをメール添付し別途パスワードを送信する意)を廃止する方針であると表明しました。2017年にはパスワードの定期的な変更は非推奨とされましたし、徐々にですが日本も古い慣習がなくなりつつあるように思います。 さて、本題です。 パスワード管理ツールの必要性 パスワード管理の基本は、強固なパスワードを作成し使いまわしせず、なるべく漏洩しないようにすることが挙げられると思います。ありがちなものとしては、以下のような方法があります。 付箋や紙に書いて管理 PCのメモ帳で管理 Excelで管理 ブラウザに保存 ですがセキュリティや管理・運用のしやすさを考えると、上記の方法よりも専門ツールであるパスワード管理ツールを利用する方が優れています。 「パスワードなんてブラウザに保存できるからそれで事足りる」と思う方もいらっしゃると思います。しかし会社としてパスワード管理の基盤がないと、チームごとに管理方法が違ったりパスワードの共有に平文が用いられてしまったり様々なリスクが生じます。 パスワード管理ツールは、便利なだけではなくそういった問題を解決できるので、利用者側、管理者側ともに非常に有益なものと言えます。 パスワード管理ツールの選定 パスワード管理ツールは色々あります。 1Password LastPass パスワードマネージャー Keeper True Key Dashlane Bitwarden ざっと調査しただけで、上記が挙げられます。 上記の全てを比較したわけではありませんが、どれも基本的な機能としては大差ありません。例えば下記のような機能があります。 複雑なパスワードの自動生成 ID・パスワードの自動入力 パスワードの強度や使い回しのチェック 多要素認証 ID・パスワードの共有 強度の高いパスワードを生成でき、利用者は自身のマスタパスワードだけを覚えれば他のパスワードを覚える必要がなく、保存された情報は暗号化され安全に共有できます。もちろんパスワード以外のセンシティブな情報も保存できます。パスワード管理ツールはそのような機能を備えたツールです。 1Passwordの優位性 弊社では主に以下の点で、1Passwordを採用するに至りました。 Secret Keyの仕組みがある 1Passwordには マスタパスワード に加えて Secret Key があり、たとえマスタパスワードが漏洩したとしても、Secret Keyを知らなければアクセスできません。マスタパスワードはデバイス上のデータを保護し、Secret Keyはデバイスからデータを保護してくれるとのことで、この二段構えの構成は安心できます。 グループ単位で管理できる ビジネスプラン以上ではユーザグループを作成できます。グループにユーザを追加し、グループを保管庫(Vault)に紐付けることで権限付与が可能です。 CLI(コマンドライン)ツールがある 1Passwordには コマンドラインツール があります。コマンドラインツールに対応していることは、運用の自動化を考慮する上で重要な要素と捉えています。 例えば以下のようなことができます。 # ユーザ招待 op create user < メールアドレス > < 氏名 > # ユーザの停止と再開 op ( suspend | reactivate ) < user > # ユーザ削除 op delete user < user > # 一覧取得 op list ( users | groups | vaults | items | documents | templates ) [ --vault < vault > | --group < group >] レポーティング(パスワード漏洩チェック)機能がある 1Passwordにはドメイン侵害レポートがあります。自社が管理するドメインを登録しておくと、漏洩に巻き込まれたアドレスを見つけることができます。このレポートを元にしてパスワードの変更をユーザへ促すことができます。 導入にあたっての課題 課題は大きく3つありました。 プランの検討 SSO(シングルサインオン)が可能か プロビジョニングが可能か プランの検討 1Passwordのビジネス向けプラン は3つあります。 Teams Business Enteprise 結論から言うと弊社は Businessプラン を選択しました。 Teamsプランでは、詳細な権限管理ができないため、全社的に導入するとなると機能不足でした。 Businessプランでは、より詳細な権限管理からログ管理やレポート閲覧まで豊富な機能を備えているため、SaaS製品としての機能が十分であると判断しました。また、Azure Active Directory、Okta、OneLoginと連携できるのもこのプラン以上になっています。弊社としては、グループで管理できることが運用上大きなメリットでした。ユーザ単位で権限管理をするのは運用が煩雑になると思います。 Entepriseプランでは、上記の機能に加えて専用窓口を設けてくれたり、導入にあたりトレーニングを受けられるなどのメリットがあるそうです。ですが弊社ではそこまでのサポートは必要なく、Businessプランで利用できる機能さえあれば十分でした。 SSO(シングルサインオン)が可能か 弊社のシステム選定基準では、基本的にSSOが利用できるシステムを選定しています。しかし、 1Passwordの仕様上SSOは不可 でした。SSOできないことは利用者目線に立つとある程度の不便さはあります。ですが1Passwordの認証の堅牢性の土台となっているSecret Keyの有用性とのバランスを考慮して、SSO不可であることを許容しました。 プロビジョニングが可能か プロビジョニングを行うためには 1Password SCIM bridge を構成する必要があります。 Google Cloud Platform Marketplace Docker, Kubernetes or Terraformで構築 SCIM bridgeサーバを構築するために、主要なクラウドサービスにおいて試算を行いました。しかし、現状ではコストメリットが無さそうだったためプロビジョニングの導入は一旦見送りました。会社の規模拡大に合わせ、再度検討したいと思っています。プロビジョニングの代わりに、前述のコマンドラインツールを活用し運用することにしました。 実際の運用 全社導入前に一部の部署で1Passwordを先行利用していたのですが、その時はグループを利用しておらずユーザを保管庫に直接割り当てる運用をしていました。しかしこれでは統一性もなく管理が煩雑だったため、グループベースで管理するように運用を変更しました。ユーザからの利用申請も、kintoneを用いたワークフローで管理し、保管庫とグループの一覧はスプレッドシートにて管理することにしました。 スプレッドシートで管理した理由は2つあります。 1つはグループや保管庫、グループ内メンバーの一覧と、グループがどの保管庫と紐付いているかをユーザが確認できるようにするためです。ワークフロー申請時にどのグループの権限を変更するかなどを記載してもらう際に必要な情報だからです。 もう1つは各保管庫の運用管理者を把握し、ワークフローにおける承認ルートにその保管庫の運用管理者を入れるためです。1Passwordの管理者からでは、各保管庫が実際にどういった使われ方をしているのか分かりません。そのためメンバー追加などの依頼時に各保管庫の運用管理者の承認を確実に得た状態で、管理作業を行っています。 導入効果 パスワード管理の理想的な運用基盤を構築できたことが大きな効果でした。人に依存した運用ルールで安全にパスワードを管理することは限界があります。パスワード管理ツールを用いることで、半強制的にガイドラインに沿った運用へ切り替えることができました。また冒頭で記載した通り、パスワードを平文で保存することはセキュリティリスクになります。そのためパスワードを暗号化できるパスワード管理ツールは、セキュリティの監査に対する解決策の1つとしても有効です。 分かりやすい効果としては以下のようなものがありました。 共有アカウントのパスワードを安全に共有できる 様々なパスワードを覚える必要がなくなり、パスワードジェネレータによって強力なパスワードの生成が容易になりました。例えば自分が共有しているパスワードを変更したとしても、1Password上のパスワードさえ更新されていれば、他の人に新しいパスワードを都度共有し直す必要はありません。利用者は自分のマスタパスワードだけを覚えていればよく、パスワードが変更されたことを知らずともログインできるからです。 また、セキュアにID・パスワードの共有が可能になり、閲覧権限の範囲をコントロールし易くなりました。例えば範囲がチームをまたぐような場合でも、専用のグループを作って該当者を入れてそのグループに保管庫の閲覧権限を割り当ててあげればよいわけです。 多要素認証のワンタイムパスワードの代替 さらに便利だと思ったのは、多要素認証で使用するワンタイムパスワードを1Password上に保存できることです。Authenticator系のアプリと同じように秘密鍵を1Passwordに保存することで、1Password上にワンタイムパスワードが表示されるようになります。 通常、多要素認証ではSMS(ショートメッセージサービス)やAuthenticator系のアプリでワンタイムパスワード(認証コード)を得るため必ずモバイル端末が必要になってしまいます。多要素認証を1Password上に保存すれば端末に縛られない運用が可能になります。 具体的な手順を解説します。 まずは設定したいシステムの設定画面で、多要素認証の追加(もしくは変更)を実行し、その手順の中で秘密鍵を取得 Authenticator系のアプリで読み取るためのQRコードが発行される画面などで、秘密鍵を表示できる箇所があると思いますので調べてみてください。※各システムによって異なります 秘密鍵を入手したら1Passwordのアイテム編集に移動 1Passwordのアイテム編集画面でラベルの欄にある…(三点リーダー)アイコンを選択 ワンタイムパスワードを選択 ワンタイムパスワードの欄に、先程入手した秘密鍵を貼り付けて保存 以上の手順でワンタイムパスワードが表示されるようになりました。元の秘密鍵を入手した画面(手順1)に戻り、6で表示されているワンタイムパスワードを入力して認証し作業は完了です。 まとめ・残課題 実際に導入してみて、パスワード管理ツールに慣れていないユーザからはいまいちよく分からないツールだと思われてしまう印象がありました。そのためマニュアルとは別に使い方を解説する動画を制作し、ユーザがより理解しやすいように工夫しました。 パスワード管理ツールは入れて終わるツールではありません。例えばパスワードをブラウザへ保存してるユーザに対して1Passwordへの移行を促す必要があります。また、ドメイン侵害レポートをチェックし、漏洩したパスワードを使用しているユーザにパスワードの変更を呼びかけることも重要です。活用方法や正しいパスワードの管理方法などは都度啓蒙していく必要があると感じています。 最後に ZOZOテクノロジーズではコーポレートエンジニアリング部のメンバーを募集しております。 https://hrmos.co/pages/zozo/jobs/0000083 hrmos.co
はじめに こんにちは。ZOZO研究所の shikajiro です。主にZOZO研究所のバックエンド全般を担当しています。 先日のテックブログ ZOZOTOWN「おすすめアイテム」を支える推薦システム基盤 をご覧いただけたでしょうか。ZOZO研究所と連携するMLOpsチームのTJこと田島が執筆した記事なので是非御覧ください。 techblog.zozo.com この 推薦システム基盤の推薦アルゴリズム を研究開発する際に利用した 実験基盤 の開発メンバーとして参加し、そこでAI PlatformやKubeflowを活用して効率的なML開発を試みました。今回はこの実験基盤の開発を紹介したいとおもいます。 また、推薦基盤チームのてらちゃんこと寺崎が執筆した AI Platform Pipelines (Kubeflow Pipelines)による機械学習パイプラインの構築と本番導入 はKubeflowの基本を知る上で大変参考になりますので、合わせて御覧ください。Kubeflowの説明についてはこちらの記事が充実していますので、本記事では省略しています。 techblog.zozo.com さらに、私が半年前に執筆した 近似最近傍探索Indexを作るワークフロー を読んでいると少しだけ楽しさが増すのでぜひご覧ください。 techblog.zozo.com 目次 はじめに 目次 AI Platformの導入 Kubeflowを独自構築 ビルドフローの安定化 パラメータを外部ファイル化 TyperでのCLIによる簡単な実行 検証高速化のためのスキップ処理 失敗談と感想 パイプラインの無理な流用でエラーが頻出する Composerと比べてどうだった? まとめ さいごに AI Platformの導入 プロダクション環境の推薦基盤はGCP上で動いているため、ZOZO研究所による推薦アルゴリズムの研究開発もGCP上で行いました。 ZOZO研究所の研究開発メンバーは普段AWS環境での開発に慣れており、GCPで同等のサービスを模索しました。AI Platformである程度の開発効率を見込めそうなので、AI Platform Pipelinesを使って開発を進めました。 当時はAI PlatformやKubeflowに慣れているメンバーが居なかったため、試行錯誤しながら実験基盤の開発体験の向上を行っていきました。 Kubeflowを独自構築 開発当初のAI Platform PipelinesのKubeflowはversion 0.2と古く、何度か実験を動かすと原因不明のエラーで止まったり、実験結果が表示されない事もあり大変不安定でした。 AI Platform PipelinesではブラウザでKubeflow Pipelinesの構築ができます。しかし、バージョンの追従はGCP次第であり、自由に選択できません(執筆時点ではブラウザからKubeflow Pipelines 1.0が構築可能)。 そこで、AI Platform Pipelines運用を一旦諦め、当時最新のKubeflow Pipelines 1.0をGKEに独自に構築することで安定化させました(執筆時点ではKubeflow Pipelines 1.2が最新)。 その後、Kubeflow Pipelines 1.1がリリースされたのでインストールを試みたのですが、 当時のドキュメントの完成度が高くなかった こともあり、うまくいかず一旦諦めました。 ビルドフローの安定化 当初、開発者のローカルマシンでビルドしたDockerコンテナをGCRにアップロードしてパイプラインを実行していました。 latestタグを使ってしまうと予期せぬコンテナが使われてしまったり、かと言って都度異なるtagを指定するとパイプラインの中の実装を変える必要があったりと、なかなか手間がかかっていました。さらに、開発者のローカルマシンのCPUがDockerビルドにより消費してしまい、開発がし辛い状況でした。 そこで、DockerのビルドはすべてCloud Buildに変更しました。これによりローカルマシンのCPUを使うことはなくなり、一貫したビルドフローを使うことができるので、安定した開発を行うことができるようになりました。 この流れはこちらの記事を参考にしています。 cloud.google.com パラメータを外部ファイル化 開発時は開発者の任意のタイミングで何度も何度も学習と予測を行います。Kubeflow Pipelinesは一度作成したパイプラインをWeb画面からパラメータを変えて何度も実行できることが長所の1つです。 しかし、多くのパラメータがあるパイプラインの場合、実験の度にパラメータをすべて入力するのは少々手間です。開発者からは「気軽にコマンドラインから実験したい」と要望があったので、パラメータをyamlで管理してパイプラインにyamlを渡すことで、開発者のマシンから気軽に実行できるようになりました。 ファイルで管理することにより、設定したパラメータにコメントを残したり、Git管理できたり、入力ミスを減らすことができました。 settings.yamlの例 # project 全体の設定値 project_id: hello-zozo webhook: https://hooks.slack.com/services/hogehoge # Kubeflow Pipelines の設定値 experiment_name: Default run_name_base: example gcr_image: gcr.io/{project_id}/example:{tag} # 学習や検証・予測などで使うパラメータ number: 100 TyperでのCLIによる簡単な実行 パイプラインを実行するPythonコードに Typer を導入しました。TyperはPythonのCLIアプリケーションを簡単に作れるライブラリです。代表的なものにargparseやClickがありますが、Typerはさらに使いやすくされたものです。 普段の実験は以下のコマンドで実行しています。 # python <パイプラインを実行するTyper実装> run <パイプラインを定義したPythonファイルがあるディレクトリ> python pipeline.py run helloworld 複数のyamlファイルを指定し、連続して実験を行うこともできます。 python pipeline.py run helloworld --settings hoge.yaml --settings fuga.yaml 前回ビルドしたDockerをそのまま利用する場合、tag名を指定してCloud Buildをスキップすることもできます。 python pipeline.py run helloworld --tag 'docker-tag-name' 検証高速化のためのスキップ処理 パイプラインには学習パートと検証パートがあります。学習部分は前回実行して生成されたモデルを使って、検証部分だけ実装を変更して実行したい要望がありました。 Kubeflow Pipelinesには任意の位置から実行する機能はありません。新しくパイプラインを作ってはじめから実行する必要があります。 そこで、パイプラインの中にスキップフラグによる条件分岐する仕組みを追加し、生成済みのモデルを使って検証パートだけを行える仕組みを作りました。 パイプラインの実装はConditionで分岐させました。スキップする時、しない時両方のパイプラインの流れを定義することで、スキップを実現させています。 @dsl.pipeline(name="hello", description="hello world pipeline") def pipeline(skip_build: bool, ...): first = dsl.ContainerOp(...) last = dsl.ContainerOp(...) with dsl.Condition(skip_build == False, name="build"): # とても重たい処理 build = dsl.ContainerOp(...) build.after(first) last.after(build) with dsl.Condition(skip_build == True, name="skip-build"): last.after(first) パイプライン管理画面のGraphを見ると、スキップされていることが分かります。 失敗談と感想 みんな大好き失敗談を紹介します。 パイプラインの無理な流用でエラーが頻出する パイプラインの実行処理高速化、管理の利便性向上のため、次の改善を行いました。 既にパイプラインがある場合はそれを使い、パイプラインコードやパラメータに追加削除が合った場合はパイプラインの新しいバージョンを作って実行する この対応が原因でパイプライン実行時にエラーが頻出し、MLエンジニアが研究開発し辛い状況を作ってしまいました。 理由の説明の前に、Kubeflow Pipelinesで使われる3つの要素について説明します。 項目 必須 説明 Run 必須 最小実行単位。実行したワークフローをRunと表現する。 Experiment 必須 Runを束ねる存在。Pipelineを指定するか、同等のyamlを直接指定して動かす。Experimentの粒度はチーム内で取り決めるのが良さそう(アルゴリズム単位、日付単位、パラメータ単位など)。 Pipeline 任意 DAGの事。パラメータを指定すればすぐ動かせる。バージョン管理可能。繰り返し実行などする場合はPipelineが必要。 Run、Experiment、Pipelineの関係がちょっとややこしいため、注意が必要です。 Run 実行の最小単位です。Pipelineの1つの実行はRunで表されます。 Experiment Runを分かりやすく分類するために名前をつけるものです。複数のRunを束ねることができます。Runには必ずExperimentが必要です。指定しない場合はDefaultになります。1つのExperimentに異なるPipelineのRunをまとめても構いません。ディレクトリに近い感覚です。 Pipeline ここが曲者です。PipelineはDAGを定義したものになります。DAGを定義したソースコードを登録すれば、Pipelineとして一覧に表示されます。Pipelineを選びExperimentを指定して実行すれば、Runが新たに生成され実行されます。しかし、ワークフローを定義したPythonコードがあれば、Pipelineを登録していなくても実行できます。 「Pipelineが無くてもPipelineが実行できます」 何を言ってるか分からないと思いますが、Kubeflow PipelinesでのRunの実行にPipelineを登録しておく必要は無いのです。ソースコードがあれば直接実行できます。 Pipelines SDKを使って実行する場合、パイプラインとソースコードを同時に指定して実行できてしまいます。その時エラーにはならず、パイプラインが謎の挙動をするため気づくまで解決が困難になります。 これらが原因でしばらくの間、パイプラインの実行がややこしく、エラーが起きがちになっていました。現在はPipelineを登録せず、ExperimentとRunだけを使って実験を行うようにし、ある程度安定したらPipelineとして登録するようにしています。 Composerと比べてどうだった? ワークフローエンジンは他にAirflow(Composer)やDigdagがあります。それぞれ触ってみた私なりの感想を書いてみたいと思います。 その前に、MLにおいてワークフローはどうあるべきかを定義した Manifest for ML in production を紹介します。 Reproducible 9ヶ月前に学習したモデルが全く同じ環境で、同じデータで再学習でき、ほぼ同じ(数%以内の差)の精度を得られるべきである Accountable 本番で稼働しているどのモデルも、作成時のパラメータと学習データ、更に生データまでトレースできるべきである Collaborative 他の同僚の作ったモデルを本人に聞くことなく改善でき、非同期で改善とコードやデータのマージができるべきである Continuous 手動での作業0でモデルはデプロイできるべき。統計的にモニタリングできるべき https://docs.google.com/presentation/d/17RWqPH8nIpwG-jID_UeZBCaQKoz4LVk1MLULrZdyNCs/edit#slide=id.g6ad50e93e5_0_59 docs.google.com docs.google.com ただワークフローを動かすのではなく、関連したデータを正しく管理できるかがMLのワークフローエンジンに求められます。 Airflow GCPではComposerという名前でマネージドサービスになっており、世界的に人気があるワークフローエンジンです。 画像検索の裏側 でもComposerを使っています。少し前まではComposerで動いているAirflowのバージョンがかなり古く不安定でしたが、今は割と落ち着いています。Airflowの仕組み上、「ワークフロー実行中にDAGファイルを更新すると途中からは更新した内容で動き出してしまう」ようになっています。これはインタラクティブに開発できて自由度が高いといえば聞こえは良いですが、過去に動いたワークフローがどのバージョンのDAGで動いたか全く分からず保証も無いため、運用管理コストがとても高くなります。 Digdag GCPではマネージドサービスがなく、自前で構築する必要があります。Airflowと違ってDAGがきちんと管理されるので実行したワークフローがどのDAGで動いたのか、パラメータはなんだったのかが明白でとても扱いやすいです。しかし、最初に書きましたが自前で構築する必要があるため、初期構築・運用管理が大変になります。 Kubeflow AI PlatformとしてマネージドサービスがありDigdagと同じようにDAGがパイプラインとして管理されているので、実行したワークフローに使ったDAGやパラメータが一目瞭然です。実行がすべてk8sのpodとして動くのでCPU/GPU・メモリなどのリソース管理、スケールしやすいのもとても良いです。まだ1.0になったばかりで新機能が続々と追加されており、技術を追っていくのが大変ですが、MLの開発効率には大きく寄与しそうです。 Manifest for ML in production をふまえると、Airflowでは実現できないことが分かります。Digdagでもできそうですが、MLに特化したKubeflowはパラメータ管理に秀でておりManifestを実現するための第一の選択肢になりそうです。ただ、そもそも目的とするものが違うため「Kubeflowは良い、Airflowはだめだ」ということではないです。それぞれにメリット・デメリットがあるので見極める必要があります。 まとめ このような実験基盤の改善を経て、研究開発を日々行い、 推薦システム基盤の推薦アルゴリズム はできあがっていっています。 「Kubeflow Pipelinesを導入すれば全部うまくいく!」ということはなく、チームメンバーで使いやすいように日々改善を行い、安定させていく事が重要になります。まだまだKubeflow Pipelinesの力を最大限発揮できていませんが、今後はより安定したワークフローエンジンとして動かせるよう、改善していきたいと思います。 さいごに ZOZOテクノロジーズではZOZO研究所のMLエンジニア、バックエンドエンジニアのメンバーを募集しております。 https://hrmos.co/pages/zozo/jobs/0000029 hrmos.co hrmos.co
こんにちは、ECプラットフォーム部の濱砂とSRE部の杉山、柴田です。普段はZOZOTOWNのリプレイスや運用に携わっています。 ZOZOTOWNでは、アプリケーションレイヤーで使用しているキャッシュストアをAmazon ElastiCache(以下、ElastiCache)にリプレイスしました。本記事では、リプレイスに至った背景や方法、発生した課題などについてご紹介します。 プロジェクトの概要 キャッシュストアのリプレイスとは ZOZOTOWNでは、現在 システムリプレイス を進めています。その中で、Web(IIS)サーバーのメモリ領域に保持しているセッション情報を外部メモリストアにオフロードするプロジェクト(以下、セッションオフロード)があります。キャッシュストアのリプレイスはセッションオフロードのフェーズ1でターゲットとしていました。 後続のフェーズに本丸であるセッションオフロードや、Cookieやセッションに保持している情報のデバイス間共有などがあります。 リプレイス前のキャッシュストア キャッシュストアとクライアント側のライブラリは、パートナー企業様にご提供頂いている製品を使用しており、VBScriptからそのライブラリを呼び出してキャッシュの機能を実現していました。 キャッシュするデータの種類は主にDBから取得したレコードセットや参照系APIのレスポンスなどがあります。シンプルな構成で、長年運用してきたこともあり、非常に高速で安定したシステムでした。 リプレイスした背景 では、なぜ安定していたシステムをリプレイスする必要があったのかについてご紹介します。 運用コストの削減 リプレイス前は、自前でキャッシュストアを構築、運用していました。現時点で大きな課題ではありませんでしたが、今後サービスの規模が大きくなり、マイクロサービス化も進むことで運用するキャッシュストアの数や規模が増え、運用コストも増え続けていくことが予想されました。 そこで、フルマネージドサービスであるAmazon ElastiCacheやSaaS、PaaSなどのクラウドのサービスを活用して、運用の負担を抑えられるようにしました。 一般的な製品、技術への移行 先述の通り、リプレイス前は、パートナー企業様にご提供いただいているキャッシュストアを使用していました。安定して運用ができていましたが、一般的な製品や技術に移行できると、以下のような理由で今後のリプレイスが進めやすくなると考えていました。 VBScriptから別の言語に移行しやすい サードパーティ製の便利なツールやサービスと連携しやすい 新規参入メンバーがキャッチアップしやすい そこで、これを機に一般的な言語やツールでも扱えて、性能や機能面で要件を満たしていたRedisを採用することにしました。 セッションオフロードの検証 セッションオフロードが実現できると、以下のようなメリットがあり、今後のリプレイスがより進めやすくなります。 スティッキーセッションを外せるため、Webサーバーをクラウドに移行しやすくなる Webサーバーがステートレスになるため、Webサーバーの増減やリリースの作業が自動化しやすくなる セッションを各Webサーバーで共有できるようになるため、カート内の商品など、デバイス間で共有できていない情報を共有できるようになる しかし、実現するためには規模も大きく、ログイン認証や購入などのZOZOTOWNにとって重要な機能と密接に関わっているため、大きなリスクが伴います。 そこで、比較的規模も小さく、セッションよりも移行もしやすいキャッシュのElastiCacheへのオフロードを先に行い、セッションオフロードを実現できるかの検証も兼ねて、開発や運用の体制を整えることにしました。 これらの背景により、キャッシュストアのリプレイスを進めることにしました。 リプレイス後の構成 キャッシュストア キャッシュストアはElastiCacheにリプレイスしました。キャッシュエンジンはRedisを使用しています。構成は、後述する課題があったため、1つのRedisクラスターで各シャードにレプリカノードを持たせるような標準的な構成ではありません。2つのRedisクラスターを使用して、各シャードにプライマリノードを1つだけ持たせる構成にしています。なお、Redisクラスターはシャードを増やしてスケールアウトできるようにクラスターモードを有効にしています。 フェイルオーバー機能の課題 ElastiCacheにはフェイルオーバー機能があります。当初はその機能を使う予定で検証を行っていましたが、課題が2つありました。 1. 標準のフェイルオーバー機能では、ZOZOTOWNが必要とするSLOを満たせない SLOの確認をするために、検証を実施しました。 プライマリノード=1、レプリカノード=1の状態で、手動でフェイルオーバーをトリガーしてみました。裏でRedisのヘルスチェックシステムを動かして、フェイルオーバーしたシャードが使用可能になるまでの時間を計測したところ、弊社がZOZOTOWNに求めるSLOは満たせない結果となりました。 検証結果 ZOZOTOWNが求めるSLO 30秒~3分 30秒以内 2. シャード追加が終わらない事象の発生 サービスインした状態でシャード追加を実施した際に、スロットの移動が終わらず、プロセスが完了しないという事象が発生しました。その際は、サポートにご対応頂きましたが、復旧まで12時間程かかりました。 バックアップから新しいRedisクラスターをリストアして切り替える方法もありますが、データの整合性を保てないことや、復旧するまでに時間がかかってしまうという課題がありました。 以上の2つの課題を回避するために、Redisクラスターやノードに障害が発生しても、別のRedisクラスターにデータの整合性を保ちつつ、迅速に切り替える仕組みを導入することにしました。 Redisクラスターのフェイルオーバー 2つのRedisクラスターのうち、一方に障害が発生した場合は独自で構築したフェイルオーバーシステムでアプリケーション側の接続先を切り替えるようにしました。 正常時 PrimaryとSecondaryのRedisクラスターを稼働 Readは、Primaryにリクエスト、失敗時はSecondaryへリトライ Writeは、PrimaryとSecondaryにダブルライト(処理時間を抑えるために非同期で実行) Primary障害時 Primaryに障害が発生した場合は切り離す SecondaryをPrimaryに昇格させてサービスを継続 Secondary障害時は、Secondaryの切り離しを行い、フェイルオーバーは行わない 障害復旧時 障害から復旧したクラスターはSecondaryとしてサービスイン キャッシュサービスのため、数分程度でPrimaryとSecondaryのデータ整合性は回復 フェイルオーバーシステム フェイルオーバーシステムを構成する要素と利用している技術について紹介します。 スケジューラー 弊社で運用実績のあるKubernetes CronJobをスケジューラーとして利用しています。スケジュールやリトライ回数、多重起動の制御などをKubernetesのyamlファイルで管理して、Argo CDのAutoSyncを利用したGitOpsでのリリースを実現しています。 定期実行ジョブ ヘルスチェックを実行するジョブはBash+Pythonで作成しコンテナ化しています。15秒間隔で後述するヘルスチェックAPIをキックして、レスポンスをDatadogにカスタムメトリクスとして送信しています。インターバルは、SLOに合わせて設定可能となっています。 ヘルスチェックAPI JavaのMicroservices FrameworkであるSpring Bootを使用して、RedisクラスターのHealthCheck APIを作成しています。このAPIは、リクエストがあるとRedisクラスターの全てのノードの状態を確認して、クラスターの状態を管理情報用の冗長化したRedisに保存しています。また、Datadogへメトリクスを送信するために、Jobにヘルスチェックの結果も返すようにしています。 接続情報取得API JavaのMicroservices FrameworkであるSpring Bootを使用しています。保存している管理情報を元にRedisクラスターの接続先情報を返します。このレスポンスをもとに、Webサーバーの設定が切り替わります。 監視・アラート発報 Datadog Integrationsで、RedisクラスターのCPUやコネクションなどのメトリクスを監視しています。フェイルオーバーシステムからのカスタムメトリクス用いて、クラスターの死活、ノードの死活、フェイルオーバーシステムの死活、接続情報の監視など、全体の監視を行っています。 このフェイルオーバーシステムを導入したことで、Redisクラスターに障害が発生しても15~30秒程度でフェイルオーバーが完了するようになりました。 また、クライアント側の接続先情報も2秒程度で自動更新されるようになっており、弊社のSLOの基準を満たすことができています。 クライアントのライブラリ選定 既存のアプリケーションはVBScriptで書かれていますが、VBScriptからReidsクラスターを容易に扱えて、導入できそうなライブラリはありませんでした。また、別のライブラリに置き換えようとすると、呼び出す関数や使い方も変わってしまうため、影響範囲が大きくなるという課題がありました。 そこで、今回は現行で使用していたライブラリにRedisの機能を追加することにしました。このようにしたことで、改修範囲は限定的となり、大幅にコストを抑えることができました。両クラスターのデータを同期させておく必要があるため、アプリケーション側で両クラスターに書き込みを行うようにしています。 なお、ライブラリの入れ替えが必要なWebサーバーは数百台程あり、それらに対してリリース作業を行う必要がありました。従来の運用では、ロードバランサーからの切り離し、ライブラリの配布、レジストリの操作などの作業は手動で行っていたため、ミスも起こりやすく、時間もかかるという課題がありました。 そこで、今回からはAnsibleを用いてコード化、自動化しました。そうすることで、レビューがしやすくなってミスが起こりにくくなり、1日かかっていた作業も1時間程に短縮できました。このように、システムだけではなく、運用や開発体制についても同時にリプレイスを行ってきました。 現在の状況 このような方法でリプレイスを行ってきましたが、現在の状況は以下の通りです。 セールなどの高負荷な状況でもスケールアウトすることで安定稼働できている マネージドサービスやクラウドサービスを活用するようになったことでスケールしやすくなった 一般的な技術に置き換わったことで今後のリプレイスが進めやすくなった ElastiCacheへのリプレイスの知見やノウハウが得られ、開発や運用の体制も整ったため、セッションオフロードを進めやすくなった なお、全体のスケジュールは以下の通りです。去年の11月からスタートして今年の9月にフェーズ1のリリースが全て完了しています。チームのメンバーは数名で、いくつかのプロジェクトを兼務している状況で進めていました。 今後の課題 今のところ大きな課題は発生していませんが、今後、運用や次のフェーズを行っていくうえで解決していきたい課題がいくつかあります。今回はその一部を紹介します。 自動スケール 現状、シャードの追加や削除は手動で行っています。サイトの負荷状況に合わせて頻繁に行う作業なので、今後スケジュールや負荷状況をトリガーに自動化したいと考えています。 データ不整合の解消 2つのRedisクラスターで運用しているため、一方への書き込みに失敗すると、データの不整合が発生します。キャッシュの場合はオリジナルからも取得できるため、大きな影響はありませんが、セッションの場合は処理が中断したり、不具合が生じてしまうケースが多くあります。そのため、現在対策方法を検討しています。 ホットキーの解消 ホットキーが発生すると一部のノードにリクエストが集中してしまい、負荷を分散することが難しくなります。また、一部のノードだけスケールアップすることはできないため、全ノードのリソースを増やす必要があり、余計なコストがかかります。そのため、クライアント側のリファクタリングや、キーの複製などで対策をしたいと考えています。 まとめ ZOZOTOWNで使用しているキャッシュストアをどのようにリプレイスして、どのような課題があったかについて紹介しました。ElastiCacheへのリプレイスや使用を検討されている方の参考になれば幸いです。今後は課題への取り組みと、今回得た技術やノウハウを活かして、引き続きセッションオフロードを進めていきます。 最後に ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれるエンジニアを募集しています。ご興味のある方は、以下のリンクからぜひご応募ください。 tech.zozo.com
はじめに こんにちは、計測プラットフォーム部バックエンドチームのリーダー、児島( @cozima0210 )です。この記事では、今年4月に社内で策定されたOSSポリシーに基づいて、チームでOSSに貢献する活動に取り組んだ話を紹介します。社内のOSSポリシーが策定された経緯については、 こちら の記事をご覧ください。 なお、これは ZOZOテクノロジーズ Advent Calender 2020 #3 の5日目の記事です。 背景 私たちのチームでは、ZOZOSUIT/ZOZOMATから生成されるデータ及びそれを元とする計算データを高速に扱うため、様々なライブラリの使用を試みてきました。それらの中には、調査や試用の段階で不具合を発見したライブラリがありました。しかし、プロダクトの開発及び運用の過程では、そうした不具合の根本原因を探る時間を持つことは難しいものでした。そのため、代替ライブラリの選択を検討したり、使用する側でワークアラウンドの検討をすることにより、その問題を回避する状況が続いていました。 もちろん、その不具合がIssuesとして既出であるかの確認はしていました。ただ、それが認知されていない問題であったとしても、コミュニティに対する関わり方としてはそれ以上のことは何もできていませんでした。というのは、OSSに対する貢献が趣味の範囲であり、業務時間を使ってやるものではないという暗黙の了解があるように感じていたためです。しかし、こうした状況に対し、今年の4月に弊社ではOSSポリシーが策定されました。これにより、会社全体にOSSへの貢献に対するポジティブな機運の高まりを感じられるようになりました。 OSSにPRを送る会 チームの事情として、社内にOSSポリシーが策定された4月頃は、2月にZOZOMATをローンチした直後の時期でした。そして、ZOZOMATのアプリケーションが安定的に稼働するようになり、次のプロダクト開発に備える時期と重なっていました。そこで、これまでに累積的に認知していたライブラリの不具合に対し、チームとして取り組む活動をすることにしました。 具体的には、週に2時間チームで集まって、その不具合の調査をする時間を持つようにしました。まず、その不具合を初めて確認した時点からは時間も経過していたため、依然その不具合が発生する状況かどうかの確認をしました。そのため、改めてOSSのIssuesを網羅的にチェックし、不具合の再現に取り組みました。その不具合が再現可能であることを確認した上で、不具合を発生させている箇所を特定するためのコードリーディングをチームで行いました。そして、その問題が自分たちで修正可能であればPull Request(以降、PR)を送る、それが難しい場合でも少なくともIssueを立てることを目指しました。 こうした活動の中から、実際にIssueを送り、最終的にそのOSSでPRが作成されマージされるまでに至った事例を紹介します。 MessagePackを扱うためのライブラリ ZOZOSUITの開発を始める際、大容量な3Dメッシュデータをサーバーで扱わなければいけない要件がありました。これを扱うにあたり、JSONで扱うにはサイズが大きくなりすぎるため、これをいかに小さく扱うことができるかを調べていました。その時に、シリアライズ及びデシリアライズする方式として、MessagePackの調査をしました。私たちのチームでは、バックエンドの開発言語にScalaを採用しており、候補としては msgpack-java と msgpack4z の2つがありました。そして、Scalaでのライブラリの選定を行う場合、 jmh を使用し、簡易的なベンチマークを取得していました。そのため、以下のようなサンプルクラスのシリアライズとデシリアライズのパフォーマンス比較を行いました。 package jmh case class Mesh( faces: Seq[(Int, Int, Int)], vertices: Seq[(BigDecimal, BigDecimal, BigDecimal)] ) ライブラリの比較と見つかった不具合 まず、以下のコードでシリアライズのパフォーマンスを比較しました。今回の記事用に、Meshのデータサイズを実際よりも小さくしていますが、facesの要素数は約8,000、verticesの要素数は約4,000です。 build.sbt ... scalaVersion := "2.13.4" , libraryDependencies ++= Seq( "io.circe" %% "circe-generic" % "0.13.0" , "com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.12.0" , "org.msgpack" % "jackson-dataformat-msgpack" % "0.8.20" , "com.github.xuwei-k" %% "msgpack4z-circe" % "0.12.0" , "com.github.xuwei-k" %% "msgpack4z-native" % "0.3.6" ) ... MessagePackSerializerBenchmark.scala package jmh import java.util.concurrent.TimeUnit import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.scala.DefaultScalaModule import com.fasterxml.jackson.module.scala.experimental.ScalaObjectMapper import io.circe.generic.auto._ import io.circe.syntax._ import msgpack4z.{CirceMsgpack, CirceUnpackOptions, MsgOutBuffer} import org.msgpack.jackson.dataformat.MessagePackFactory import org.openjdk.jmh.annotations.{Benchmark, BenchmarkMode, Mode, OutputTimeUnit, Scope, State} @State(Scope.Thread) class MessagePackSerializerBenchmark { val mapper = new ObjectMapper( new MessagePackFactory) with ScalaObjectMapper mapper.registerModule(DefaultScalaModule) val codec = CirceMsgpack.jsonCodec(CirceUnpackOptions.default) val mesh = Mesh( Seq( ( 1 , 2 , 3 ), ( 4 , 5 , 6 ), ( 7 , 8 , 9 ) ), Seq( (BigDecimal( 1.11111 ), BigDecimal( 2.22222 ), BigDecimal( 3.33333 )), (BigDecimal( 4.44444 ), BigDecimal( 5.55555 ), BigDecimal( 6.66666 )), (BigDecimal( 7.77777 ), BigDecimal( 8.88888 ), BigDecimal( 9.99999 )) ) ) @Benchmark @BenchmarkMode( Array (Mode.Throughput, Mode.AverageTime)) @OutputTimeUnit(TimeUnit.MILLISECONDS) def byJackson(): Array [Byte] = mapper.writeValueAsBytes(mesh) @Benchmark @BenchmarkMode( Array (Mode.Throughput, Mode.AverageTime)) @OutputTimeUnit(TimeUnit.MILLISECONDS) def byMsgpack4z(): Array [Byte] = { val packer = MsgOutBuffer.create() codec.toBytes(mesh.asJson, packer) } } 結果は、以下のようになりました。 > jmh/jmh:run -i 20 -wi 20 -f 1 .MessagePackSerializerBenchmark. ... [ info ] Benchmark Mode Cnt Score Error Units [ info ] MessagePackSerializerBenchmark.byJackson thrpt 20 94 . 949 ± 4 . 638 ops/ms [ info ] MessagePackSerializerBenchmark.byMsgpack4z thrpt 20 167 . 041 ± 35 . 359 ops/ms [ info ] MessagePackSerializerBenchmark.byJackson avgt 20 0 . 017 ± 0 . 007 ms/op [ info ] MessagePackSerializerBenchmark.byMsgpack4z avgt 20 0 . 006 ± 0 . 001 ms/op この結果から、シリアライズにおいてはmsgpack4zのスコアが優れていることがわかりました。 続いて、以下のコードでデシリアライズのパフォーマンスを比較しました。 MessagePackDeserializerBenchmark.scala package jmh import java.util.concurrent.TimeUnit import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.scala.DefaultScalaModule import com.fasterxml.jackson.module.scala.experimental.ScalaObjectMapper import io.circe.generic.auto._ import io.circe.syntax._ import msgpack4z.{CirceMsgpack, CirceUnpackOptions, MsgInBuffer, MsgOutBuffer} import org.msgpack.jackson.dataformat.MessagePackFactory import org.openjdk.jmh.annotations.{Benchmark, BenchmarkMode, Mode, OutputTimeUnit, Scope, State} @State(Scope.Thread) class MessagePackDeserializerBenchmark { val mapper = new ObjectMapper(new MessagePackFactory) with ScalaObjectMapper mapper.registerModule(DefaultScalaModule) val codec = CirceMsgpack.jsonCodec(CirceUnpackOptions.default) val mesh = Mesh( Seq( (1, 2, 3), (4, 5, 6), (7, 8, 9) ), Seq( (BigDecimal(1.11111), BigDecimal(2.22222), BigDecimal(3.33333)), (BigDecimal(4.44444), BigDecimal(5.55555), BigDecimal(6.66666)), (BigDecimal(7.77777), BigDecimal(8.88888), BigDecimal(9.99999)) ) ) val bytes: Array[Byte] = codec.toBytes(mesh.asJson, MsgOutBuffer.create()) @Benchmark @BenchmarkMode(Array(Mode.Throughput, Mode.AverageTime)) @OutputTimeUnit(TimeUnit.MILLISECONDS) def byJackson(): Unit = mapper.readValue[Mesh](bytes) @Benchmark @BenchmarkMode(Array(Mode.Throughput, Mode.AverageTime)) @OutputTimeUnit(TimeUnit.MILLISECONDS) def byMsgpack4z(): Unit = { val unpacker = MsgInBuffer(bytes) codec.unpack(unpacker) } } しかし、以下のようなログが出力されて、デシリアライズのパフォーマンスは比較できませんでした。 > jmh/jmh:run -i 20 -wi 20 -f1 .MessagePackDeserializerBenchmark. ... [info] # Run progress: 0.00% complete, ETA 00:08:00 [info] # Fork: 1 of 3 [info] # Warmup Iteration 1: <failure> [info] com.fasterxml.jackson.databind.JsonMappingException: Invalid type=DOUBLE (through reference chain: jmh.Mesh["vertices"]->com.fasterxml.jackson.module.scala.deser.GenericFactoryDeserializerResolver$BuilderWrapper[0]) [info] at com.fasterxml.jackson.databind.JsonMappingException.wrapWithPath(JsonMappingException.java:390) [info] at com.fasterxml.jackson.databind.JsonMappingException.wrapWithPath(JsonMappingException.java:361) [info] at com.fasterxml.jackson.databind.deser.std.CollectionDeserializer._deserializeFromArray(CollectionDeserializer.java:363) [info] at com.fasterxml.jackson.databind.deser.std.CollectionDeserializer.deserialize(CollectionDeserializer.java:244) [info] at com.fasterxml.jackson.module.scala.deser.GenericFactoryDeserializerResolver$Deserializer.deserialize(GenericFactoryDeserializerResolver.scala:82) [info] at com.fasterxml.jackson.databind.deser.SettableBeanProperty.deserialize(SettableBeanProperty.java:542) [info] at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeWithErrorWrapping(BeanDeserializer.java:565) [info] at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:449) [info] at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1390) [info] at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:362) [info] at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:195) [info] at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:322) [info] at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4591) [info] at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3641) [info] at com.fasterxml.jackson.module.scala.ScalaObjectMapper.readValue(ScalaObjectMapper.scala:203) [info] at com.fasterxml.jackson.module.scala.ScalaObjectMapper.readValue$(ScalaObjectMapper.scala:202) [info] at jmh.MessagePackDeserializerBenchmark$$anon$1.readValue(MessagePackDeserializerBenchmark.scala:17) [info] at jmh.MessagePackDeserializerBenchmark.byJackson(MessagePackDeserializerBenchmark.scala:40) [info] at jmh.generated.MessagePackDeserializerBenchmark_byJackson_jmhTest.byJackson_thrpt_jmhStub(MessagePackDeserializerBenchmark_byJackson_jmhTest.java:119) [info] at jmh.generated.MessagePackDeserializerBenchmark_byJackson_jmhTest.byJackson_Throughput(MessagePackDeserializerBenchmark_byJackson_jmhTest.java:83) [info] at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) [info] at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) [info] at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) [info] at java.lang.reflect.Method.invoke(Method.java:498) [info] at org.openjdk.jmh.runner.BenchmarkHandler$BenchmarkTask.call(BenchmarkHandler.java:453) [info] at org.openjdk.jmh.runner.BenchmarkHandler$BenchmarkTask.call(BenchmarkHandler.java:437) [info] at java.util.concurrent.FutureTask.run(FutureTask.java:266) [info] at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511) [info] at java.util.concurrent.FutureTask.run(FutureTask.java:266) [info] at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149) [info] at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) [info] at java.lang.Thread.run(Thread.java:823) [info] Caused by: java.lang.IllegalStateException: Invalid type=DOUBLE [info] at org.msgpack.jackson.dataformat.MessagePackParser.getText(MessagePackParser.java:387) [info] at com.fasterxml.jackson.module.scala.deser.BigNumberDeserializer.deserialize(ScalaNumberDeserializersModule.scala:20) [info] at com.fasterxml.jackson.module.scala.deser.TupleDeserializer.$anonfun$deserialize$1(TupleDeserializerModule.scala:48) [info] at com.fasterxml.jackson.module.scala.deser.TupleDeserializer$$Lambda$139/00000000249C9820.apply(Unknown Source) [info] at scala.collection.immutable.Vector1.map(Vector.scala:1872) [info] at scala.collection.immutable.Vector1.map(Vector.scala:375) [info] at com.fasterxml.jackson.module.scala.deser.TupleDeserializer.deserialize(TupleDeserializerModule.scala:45) [info] at com.fasterxml.jackson.module.scala.deser.TupleDeserializer.deserialize(TupleDeserializerModule.scala:10) [info] at com.fasterxml.jackson.databind.deser.std.CollectionDeserializer._deserializeFromArray(CollectionDeserializer.java:347) [info] ... 29 more 具体的には、msgpack-javaでscala.math.BigDecimalをデシリアライズする時に、エラーが発生していました。このエラーを確認した上で、当時としてはmsgpack4zを採用可能として、msgpack-javaについては、課題を認知しながらも放置した状態としていました。 不具合に関するIssues調査 今回、この不具合が変わらず発生している状況を確認した上で、コミュニティに認知されたIssuesとなっているか再確認しました。具体的には、IssuesをBigDecimalというキーワードと、今回発生したエラーメッセージの中からJsonMappingExceptionというキーワードの検索をしました。しかし、当該の問題が取り上げられていることは確認できませんでした。 不具合の修正方法 ここで、改めてコード上で問題となる箇所の特定と、考えられる修正方法の検討を開始しました。主には、例外を吐く直前ではどのような処理コードを通り、どうあれば正常に動作するか調査をしました。具体的な例外を吐く箇所は、BigDecimalのデシリアライズ処理で呼ばれているgetTextというインタフェースの実装内部で、数値を文字列化する想定がされていないことが原因でした。今回の修正としてはインタフェースを提供するライブラリ(jackson-module-scala)と具象実装を提供するライブラリ(msgpack-java)の組み合わせで発生する問題であったため、どちらにも修正パッチを作ることが考えられました。そのため、具体的な修正をする場合の提案実装をしたブランチを自身のforkしたプロジェクト上で作り、それぞれにIssuesを送りました。 Issuesを送る、PR作成、そしてマージ それぞれに送ったIssuesが以下です。 github.com github.com 送ったIssuesのうちjackson-module-scalaの方には、その日のうちにコメントが付きました。 https://github.com/FasterXML/jackson-module-scala/issues/458#issuecomment-664845092 これはいただいたコメントを読んでから気づいたことでしたが、私の提案した修正により、既存のユニットテストを壊してしまっていました。そのため、この方法はjackson-module-scalaを既に使用しているユーザーに影響を与えるため、受け入れられないという内容でした。このコメントをもらってから、この問題はmsgpack-javaの方の修正が取り込まれなければいけないことが見えてきました。ただ、今回の問題がScala側のライブラリとの組み合わせの問題であるために、Java側のライブラリの修正をすることが許容されるかという懸念が湧いてきました。それからしばらくして、Java側のライブラリの作者からもIssueに対するコメントがついていました。 https://github.com/msgpack/msgpack-java/issues/526#issuecomment-668080211 内容としては、BigDecimalでそんな問題は発生しないというものでしたが、これは私の説明が不足していたことで認識の齟齬を発生させていたためでした。改めて、この問題がjava.math.BigDecimalではなく、scala.math.BigDecimalで発生する問題であることを伝えました。 https://github.com/msgpack/msgpack-java/issues/526#issuecomment-668664372 そのコメントをした後で、jackson-module-scalaのコントリビューターから私の修正をフォローするコメントをいただきました。 https://github.com/msgpack/msgpack-java/issues/526#issuecomment-668736359 これについては、msgpack-java側で今回の修正をするモチベーションが低く、このIssueがスルーされてしまう状況をサポートしてくれたものだと感じました。こうしたライブラリのコントリビューター間で、協調をしてくれたことは、とてもありがたい体験でした。 結果、msgpack-javaの方で、以下のPRが作成されました。 https://github.com/msgpack/msgpack-java/pull/527 私の提案した実装についてのユニットテストも追加されたものとなっており、コミュニティの中でのレビューも通り、無事このPRがmasterに取り込まれ、私のIssueもクローズされました。 その後、この修正についてのリリースを出して欲しいというコメントがつけられました。 https://github.com/msgpack/msgpack-java/issues/526#issuecomment-691268437 そして、 0.8.21 のバージョンで修正リリースがされました。 この修正バージョンを使って、MessagePackのデシリアライズのパフォーマンス比較もできるようになりました。 > jmh/jmh:run -i 20 -wi 20 -f1 .MessagePackDeserializerBenchmark. ... [info] Benchmark Mode Cnt Score Error Units [info] MessagePackDeserializerBenchmark.byJackson thrpt 20 108.086 ± 5.613 ops/ms [info] MessagePackDeserializerBenchmark.byMsgpack4z thrpt 20 154.674 ± 7.616 ops/ms [info] MessagePackDeserializerBenchmark.byJackson avgt 20 0.014 ± 0.006 ms/op [info] MessagePackDeserializerBenchmark.byMsgpack4z avgt 20 0.009 ± 0.001 ms/op スコアとしては、msgpack4zがシリアライズとデシリアライズのどちらのケースにおいても、優れていることがわかります。しかし、依存するライブラリの親和性を理由にmsgpack-javaを採用したいケースも考えられるため、同様の不具合に悩まされる人にとっての有効な修正として大きな成果に繋がったと思います。 まとめ 今回の取り組みを振り返り、OSSに貢献するということをチームとして取り組むことができたことは、とてもいい体験でした。また、この取り組みは「OSSにPRを送る会」として開始しましたが、現在では「OSSに貢献する会」と改名しました。その意図として、今回挙げた事例のように、私たちはPRを送ったわけではありません。また、OSSのコミュニティに関わりを持つことは、敷居の高さを感じることも少なくなく、そこにPRを送るということになれば尚更です。しかし、単にIssueを送ることからでも、OSSになんらか貢献するという気持ちを普段OSSを利用する者として継続的に持つということの大切さをメンバー間で再認識できました。 こうして、3か月ほどで累積されていた不具合はIssuesとして全て報告済みとなりました。週に2時間の取り組みも、月に2時間へと頻度は下がりました。しかし、現在もメンバーが興味のあるライブラリのIssuesの中から自身が取り組めるものを探したり、それをきっかけにOSSのコードを読む時間として継続しています。 さいごに 計測プラットフォーム部バックエンドチームでは、ZOZOMATをはじめとする計測技術でよりオンラインでの購入体験を向上させたいバックエンドエンジニアを募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! www.wantedly.com
はじめに こんにちは。ZOZO Researchの千代です。 ZOZO Researchでは類似アイテム検索やおすすめアイテムのレコメンドといった機能開発の他に、様々な技術を用いたバックエンド業務の効率化にも取り組んでいます。 ZOZOTOWNのカスタマーサポートで実施しているワークフォースマネジメント(以下WFM)もその1つです。WFMで必要となるタスク割当て問題を数理最適化問題の一種である混合整数最適化問題として定式化し、最適なタスク割当てを計算しています。 この記事では、カスタマーサポートのWFMでの利用を例に、混合整数最適化でスケジューリング問題を定式化するテクニックについて説明します。 目次 はじめに 目次 ワークフォースマネジメントとは スケジューリング問題とは この記事の問題設定 スケジューリング問題を解くためのアプローチ 数理最適化問題とは 混合整数最適化問題とは 混合整数最適化問題でスケジューリング問題を扱うテクニック 準備 処理能力に関する制約 タスクの最低継続時間 タスク開始変数の導入 最低継続時間制約の表現 複数の目的関数への対応 多目的最適化 2段階最適化 まとめ おわりに ワークフォースマネジメントとは ワークフォースマネジメントとは、人的資源を適切に配置し、より効率的で高いパフォーマンスを発揮することを目指す取り組み全般を表す言葉です。 ZOZOTOWNのカスタマーサポートでは、サービスレベル向上のためのWFMの一環として、時間帯別の問い合わせ数の予測に基づいてスタッフのタスク割当てを求めるスケジューリング問題を解いています。 例えば朝は電話 1 による問い合わせが多いので、電話の対応に当たるスタッフを増やす、といった対応を計算により実施しています。 スケジューリング問題とは スケジューリング問題とは、人や機械といったリソースに対して仕事などのスケジュールを割当てる問題を指す言葉で、具体的な問題としてはシフトスケジューリング問題やジョブショップ・スケジューリング問題などが挙げられます。 この記事では以下のような問題を想定して説明していますが、使っているテクニックはスケジューリング問題全般で利用可能なものです。説明用の簡略化したモデルになっているため、実務で使っているモデルとは詳細が異なります。 この記事の問題設定 各スタッフの1日の時間帯ごとのタスクの割当てを考える問題です。 タスクとは各種チャネルの問い合わせへの対応やその他の個人タスク、休憩のいずれかを表します。 時間は15分のタイムスロットに区切って考えます。 以下のデータは入力として与えられるとします。 その日の出勤者および出勤時間帯(早番、遅番など) 各時間帯、各チャネルの問い合わせ数予測 各スタッフの実施可能なタスク 問い合わせ対応のタスクについては、各スタッフのタスクに対する処理能力 スケジューリング問題を解くためのアプローチ スケジューリング問題は多くの場合NP困難となるので、従来は、 重み付き制約充足問題として定式化し、CPソルバーを使って解く方法 タブーサーチなどメタヒューリスティクスアルゴリズムを使って解を求める方法 といった近似解法を用いたアプローチが一般的でした。しかし近年アルゴリズムやハードウェアの進歩により、「混合整数最適化問題として定式化し、厳密最適解やそれに準ずる解を求める方法」も選択肢として一般的になってきました。 本記事でも、混合整数最適化問題として扱うアプローチを紹介します。 数理最適化問題とは 今回使用する混合整数最適化問題は、数理最適化問題の1つのカテゴリです。混合整数最適化問題について説明する前に、まず数理最適化問題について簡単に紹介します。 数理最適化問題とは、条件を満たす候補の中から目的に対して最適なものを数学的に見つける問題です。数理最適化問題は次の3つの要素でできています。 決定変数:意思決定や制御の対象で、値を決めたいもの 目的関数:決定変数が目的に対して良いか悪いかを判断するための関数 制約条件:候補となる決定変数が満たす必要のある条件 制約条件を満たした上で、目的関数を最小化または最大化する決定変数を見つける問題が数理最適化問題です。 混合整数最適化問題とは 混合整数最適化問題とは数理最適化問題の中で、整数値を取る変数を含むもので、英語ではMixed Integer Programming (MIP)やMixed Integer Optimization (MIO)と呼ばれています。 整数変数を使うことで、割当てや順序のような組合せ的な問題が表現できるようになります。 今回は、混合整数最適化問題のなかでも目的関数と制約式に線形のものだけを含んだ、混合整数線形最適化問題のみを扱います。最近では線形でない制約式や目的関数の問題も解けるようになってきていますが、まだまだ解ける問題の規模は限定的で、混合整数最適化問題といえば多くの場合は混合整数線形最適化問題を指すのが一般的です。 混合整数(線形)最適化問題は例えばPythonのpulpというモデリングライブラリを使うと簡単に実装できます。問題を解くためには最適化ソルバーというソフトウェアが必要ですが、pulpをインストールするとCbcというOSSの最適化ソルバーが一緒にインストールされるため、そのまま計算を実行できます。 github.com projects.coin-or.org 混合整数最適化問題でスケジューリング問題を扱うテクニック ここからはWFMでのタスク割当て問題を例に、スケジューリング問題を混合整数最適化問題として定式化する際のテクニックをいくつか紹介していきます。 数式を使って説明していきますが、前述の通り変数の一次式しか登場しないので意味するところがわかれば非常に簡単です。また説明の簡潔さのため、一部厳密性を欠いた表現をしている箇所があります。ご了承ください。 以下では最適化問題の決定変数は小文字で、入力として与える定数は大文字で表すこととします。 準備 まず準備として、タスクの割当てを表現する変数を以下のように用意します。 : スタッフ が時刻 にタスク を行う時1、それ以外で0をとる0-1整数変数 各スタッフは勤務時間中には同時に1つのタスクを実行し、出勤前や退勤後はタスクを何も実行できません。これは以下のような制約式で表現できます。ただし、前述の通り休憩時間も1つのタスクとして扱っています。 ( が の勤務時間内の場合) ( が の勤務時間外の場合) この変数 を使って各種制約を表現していきます。 処理能力に関する制約 問い合わせ数の予測に対して、すべての問い合わせをまかなう制約を考えます。これはある時刻に稼働している全スタッフの、その問い合わせに対応するタスクの処理能力の合計が、予測された問い合わせ数を上回っているという制約で表現できます。 スタッフ のタスク についての処理能力を 、時刻 のタスク の業務量の予測を とすると、次のような式になります。 (全ての について) この制約式は、全ての時間帯で必ずリソースが問い合わせ予測を上回ることを求めています。しかし現実的には、問い合わせ量の瞬間的な増大などあらゆる事態に対応できるようなリソースを常に確保しておくことは困難です。 上記の制約式のままでは全ての時間帯、問い合わせチャネルで予測量を上回るリソースを用意していなければ最適化問題が実行不可能となり、モデルとして使い勝手がよくありません。そのためこの制約を変更します。新しく0以上の実数値を取る変数として、 : 時刻 にタスク で不足する処理能力を表すペナルティ変数 を用意します。この変数を使って上の制約を以下のように変更します。 (全ての について) また制約式の変更だけでなく、ペナルティ変数の総和 2 を最小化するように目的関数を変更します。これによりもし全ての問い合わせに対応できない状況であっても、対応できない問い合わせの量を最小化する問題として計算でき、常に解が得られるようになります。 常に解が得られることで、例えば対応できない問い合わせの数が多すぎる時に、事前にリソースの調整をするといった対応を行えます。 タスクの最低継続時間 実際のオペレーションでは、割当てられたタスクが頻繁に変わるような運用は好ましくありません。そのため、各タスクを開始したら最低45分間は同じタスクを継続するといった制約を追加したい場合があります。このようなタスクの最低継続時間制約を実現する方法を紹介します。 タスク開始変数の導入 スタッフのタスクの継続時間をはかるために、次のような変数を新たに追加します。 : スタッフ が時刻 にタスク を開始したら1、そうでない時0 変数 と の関係は、一例を挙げると以下の図のようになります。 図のように、 は が0で、 が1のときだけ1をとり、他は0をとる変数です。この関係は以下のような制約を追加することで表現できます。 制約1: 3 制約2: 制約3: この制約によって表される の値の候補を書き出すと次の表のようになり、タスク開始変数が表現できていることがわかります。 最低継続時間制約の表現 前節で定義したタスク開始変数を用いれば、タスクの最低継続時間の制約を簡単に表現できます。例えば一度タスクを開始したら最低3スロット以上継続するという制約を表したい場合は次のようになります。 またこのテクニックを応用することで、例えば「1回目の休憩と2回目の休憩の間は2時間以上の間隔をあける」といった制約も表現できます。 複数の目的関数への対応 WFMのタスク割当てモデルでは、処理能力不足のペナルティを最小化することを目的関数としていました。しかし新型コロナウイルス感染症の影響で、休憩室が密になることを回避するために、同時に休憩を取る人数をできるだけ少なくしたいという要望がでてきました。 同時に休憩を取る人数をできるだけ少なくするという問題は、同時に休憩を取る人数の1日の中での最大値を最小化する問題として表現できます。 多目的最適化 「処理能力不足のペナルティ最小化」と「同時休憩人数最小化」のような複数の目的関数を持った最適化問題は多目的最適化問題と呼ばれています。 多目的最適化問題に対する対処としては、重みをかけて足し合わせ1つの目的関数にする方法がシンプルで一般的ですが、今回のように単位の全く異なる値同士の時は重みのパラメータを決めるのがなかなか難しいです。 ここでは別の方法として2段階最適化というものを紹介します。 2段階最適化 2段階最適化は、優先順位の明確な2つの目的関数について、段階的に最適化問題を解いて最適解を求める方法です。タスク割当ての例で、「同時休憩人数最小化」が第1優先、「処理能力不足のペナルティ」が第2優先という設定を仮定して方法を説明します。 「同時休憩人数最小化」のために、同時休憩人数の最大値を最小化する問題として最適化問題を解きます。この時「処理能力不足のペナルティ最小化」については考慮しません。 1.で求められた最適値(最小な同時休憩人数の最大値)を使って、同時休憩人数がその値以下になるという制約を追加し、「処理能力不足のペナルティ最小化」を目的関数として再度最適化問題を解きます。 この順番で問題を解くことによって、同時に休憩を取る人数を最小にした上で、処理能力不足のペナルティを極力小さくできます。 まとめ この記事ではカスタマーサポートでのタスク割当問題を例に、混合整数最適化問題でスケジューリング問題を扱うときのテクニックとして、 処理能力不足のペナルティ最小化 タスク最低継続時間制約 2段階最適化 を紹介しました。 混合整数最適化問題は不等式制約や整数変数をうまく使うことで、様々な種類の問題を定式化することが可能です。 おわりに ZOZOテクノロジーズでは技術の力で事業に貢献してくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com 新型コロナウイルス感染症の影響で、ZOZOTOWNカスタマーサポートの電話窓口は現在受付停止しています。 ↩ ペナルティという値の性質上2乗和をとりたくなる時がしばしばありますが、混合整数線形最適化では2乗和は扱えません。区分線形関数を使うと線形モデルのままで近似できますが、変数の数が増えモデルは複雑になります。 ↩ 目的関数や他の制約次第では制約1だけで表現できる場合もあります。例えばタスクの切り替え回数(タスク開始変数の和)の最小化など、 を最小化するような項が目的関数に入っているときは、制約1だけで十分です。 ↩
こんにちは。ZOZOテクノロジーズZOZOTOWN部 検索チーム 兼 ECプラットフォーム部 検索基盤チームの有村です。 ZOZOTOWNでは社内勉強会が盛んに行われており、部単位・役職単位・チーム単位・有志の集まりなど様々な単位、様々なテーマで日々開催されています。本記事では今年度上期を通して行ったZOZOTOWN部バックエンドの勉強会を振り返り、より参加者のモチベーションをあげるために設定した目標値や達成のために行ったことについて紹介致します。 この記事は ZOZOテクノロジーズ Advent Calender 2020 #2 の1日目の記事です。 ZOZOTOWN部バックエンド勉強会の歴史 現在ZOZOTOWN部バックエンドで主催している勉強会は2018年度から開催され始めたもので、今年度で3年目の会となります。その会では、これまでは各チームで所有しているノウハウを共有するためのLT大会や、そのノウハウを社内で留めずアウトプットを行うため、記事の投稿を行っていました。アウトプットに関しては、特にテーマに決まりは無く、業務で取り組んだ案件から学んだことや前職での経験、個人的に勉強していることなど思い思いのテーマで行っています。 本勉強会では、現在以下の3点の理由から執筆した記事のアウトプット先としてQiitaを利用しています。 アカウント開設・Organizationへの紐づけが容易である テックブログに比べて、幅広い内容でライトに投稿できる 記事へのフィードバックがLGTM、コメントといった形でもらえるため、モチベーションにつながる 3年目である今年度もアウトプットを文化として根付かせ、さらに強化していくためにQiitaへの投稿を継続しています。 qiita.com コロナ禍での勉強会運営 本記事で紹介した勉強会ですが、開催年月を重ねるごとに参加者が増え、現在ではZOZOTOWN部バックエンドの5チーム計30人以上が参加する規模の勉強会となりました。勉強会自体も昨年度までは全てオフラインで開催されていましたが、昨今のコロナ事情により弊社では原則的に在宅勤務となっており、オンラインでの開催が必須な状況となりました。 弊社ではオンラインのミーティングツールとして、主にGoogle MeetとWebex Teamsを利用しています。両方のツールに一長一短ありましたが、ディスカッションを行うにあたりグループ単位での作業が発生することから、チームの下にグループの概念を持つことができるWebex Teamsを採用しました。 Google Meet Webex Teams インストール 不要 必要 体感パフォーマンス 〇 △ ディスカッションに特化した機能(ブレイクアウトルーム) × △(2020年夏のアップデートで登場、チームとスペースで代用可) 社内での利用浸透率(4月時点) △ 〇 アウトプットを強化するための取り組み 上述した通り、今年度の取り組みは昨年度から継続してアウトプット強化を目的としていますが、実際に書くだけでなくそれをサポートするようなステップを設けています。 昨年度から行ってきた具体的な取り組みとして、全員で記事にする内容の発案・深堀をするネタ出し会、公開前に内部で限定公開状態で意見をしあうレビュー会があります。 今年度からは上記に加えて、新たにスパイ会という取り組みを開始した上で、より求められる記事を書くには何が重要なのかを調査しました。参加人数が多いため、5〜6人毎のグループを作成し、基本的にディスカッションはその単位で行っています。 スパイ会 アウトプットをする上でのモチベーションの拠り所は個人差があると思いますが、公開したものに対する反響が気になるという点は多くの方に共通しているかと思います。今年度の勉強会ではこの反響を数値で観測し、 反響の数字が大きい記事 = 求められている記事 と仮定した上で、いかにしてその数値を伸ばしていくかを調査(スパイ)しました。 調査する上でやはり参考になるのは既に公開された上で反響のあった記事達です。今回利用したQiita上ではLGTMがわかりやすく反響の指標として採用できそうであったため、この数字が大きい記事や小さい記事にはそれぞれどのような共通点があるかディスカッションを行いました。 また、 Organization に紐づくアカウントより公開された記事は一定の質が担保されているであろう、という仮定のもと調査対象を限定しました。 各グループで調査を進めた後は、以下のような形式でまとめ共有を行いました。 調査結果 調査の結果、LGTMが多くついている記事の多くには共通点があり、またその逆に少ない記事の多くにも共通点がありました。 LGTMが多い記事の共通点 タイトルがキャッチーである 見出しが適切に設定してあり、知りたい情報に手早くアクセスできる 実行に必要なコマンドやスクリーンショットが添付してあり、再現性が高い 新鮮なネタへのキャッチアップが早い LGTMが少ない記事の共通点 記事の意図が組み切れないほどの短いメモ書きである タイトルと内容のミスマッチ、タイトルから内容が推測できない 適切なチャプター分けがされておらず、冗長である ボリュームが極端に多すぎる 書き出してみると当たり前に思えることが大半ですが、LGTMが少ない記事の共通点には、いざ自分で書こうとする際にもおろそかになりがちな点や、やりがちな点が多く挙がっている様子が見て取れます。 一方、LGTMが多い記事の共通点の多くは、どんな内容の記事でも意識することで改善可能な点が大半となっており、読者を想定して記事を書くことの重要性を改めて確認できました。 ネタ出し会 ネタ出し会の大まかな流れは以下の通りです。 アウトプットしてみたいお題を、これまで経験したことや気になっている技術などから数個引き出してみる グループ内で気になるお題を掘り下げ、1つにお題を絞る お題について深堀し、概要レベルまで落とし込む 勉強会の参加者は先述した通り30名ほどで、その中でもアウトプットの経験に関してメンバー間でばらつきがあるため、そのばらつきを吸収・サポートする意味で1.の手順を踏んでいます。 また、上記のスパイ会で得た知見を基に、お互いのアウトプットをどのようにしてブラッシュアップするかについても議論するため3.の場を設けました。 レビュー会 上記のネタ出し会が終わった後、各々執筆した記事を持ち寄りレビュー会を行います。とはいえ全員が全員の記事をレビューするのは現実的でないため、執筆者1人に対して1人レビュアーをアサインし、レビュー会の前に事前確認を行います。 全体で行うレビュー会では書いた記事をもとに執筆者が発表を行い、その内容に対してレビュアーが中心となって質問やコメントを行います。Organizationに紐づく記事を公開することになるため、このレビュー会を通して誤った情報の発信を未然に防ぐ役割も担っています。 このレビュー会が終了した後に、各執筆者は記事を公開します。 半期勉強会を運営行った結果 上述した施策を交えつつ、バックエンドメンバー全員の記事公開が無事完了しましたので、昨年度行われていた勉強会との比較を行いました。 昨年度 今年度 記事本数 21 26 平均LGTM数 16.96 40.67 記事本数が増えたことに関しては単純に参加メンバーが年々増加していることもありますが、LGTM数の推移からより求められる記事を公開できている事がわかります。また、それを示すように毎週トレンドに掲載され、社内Slackでも話題となっていました。 勉強会終了後のアンケート結果 半期に渡る勉強会の終了後にとったアンケート結果の一部を紹介します。 Q. アウトプットに対するハードルは下がりましたか? Q. 取り組みの中で最も役に立ったと感じたパートはどれですか? Q. 今回の勉強会を通して新たに得た知見は何ですか? 外部への情報公開って大事だなと改めて感じました タイトルが曖昧だったり、自分の備忘録的なものは伸びにくい タイトルや目次をわかりやすくすることの大事さ Q. 次回以降に改善してほしい点は何ですか? 執筆後、記事の反応を見て「これはやってよかった」「また反応が微妙だった」などを共有して残す作業があっても良かった スパイ会が記事の深堀りから議論まで20分で行ったが時間が足りなかった マネジメント系の記事を書きたい人はQiitaではなくnoteとかでもありだと思った 大人数だと発言しづらそう アンケート結果から、オンラインかつ大人数の勉強会ならではの課題点や、メンバーのアウトプットしたい内容がガイドラインによって制限されるケースなどが見えてきました。一方、今回から新たに行った取り組みであるスパイ会や、技術的なアウトプットを行うことについてはかなり前向きな意見が多かったです。実際に、普段の開発において新たな知見を得た際、自然とアウトプットに持っていこうとする会話を見かけることが増えました。 以前、弊社CTOの今村が公開した記事にもある通り、アウトプットには会社・個人双方に様々なメリットをもたらします。まだまだ発展途上な勉強会ですが、これからもメンバーのアウトプットを最大化できるよう、より改善を加えながら引き続き開催していきます。 techblog.zozo.com 最後に 本記事ではZOZOTOWN部で行われている勉強会の事例と、その中で行われている取り組みについて紹介しました。 最後に、ZOZOテクノロジーズでは本取り組みを行っているZOZOTOWNのバックエンドエンジニア含め、様々な職種で募集を行っています。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
こんにちは。ZOZOテクノロジーズSRE部の西郷です。普段はAWSを用いてマルチサイズプラットフォーム事業(以降MSPと記載します)のシステム構築や運用に携わっています。 このMSPのシステムではRDBにAmazon Aurora PostgreSQLを採用しています。DBを含むネットワークは全てCloudFormationで管理しており、変更は原則テンプレート修正にて行っています。 さて、このAmazon Auroraは定期的なバージョンアップが発生します。この対応についてもテンプレートを更新して行うのですが、組み合わせの悪い部分があり、都度対応を検討してきました。 その問題について、CloudFormationのResource Importを用いることできれいに解決できたため、事例としてご紹介します。 MSPとその生産を支えるインフラ まずは MSP について少し触れておきます。MSPは ZOZOTOWN 上で展開しているサービスです。欲しい商品を選び、身長と体重を選択すると、体型にあったサイズをレコメンドします。対象商品はZOZOTOWNに出店いただいているブランド様と共同で企画・生産を行っています。 MSPの生産を支える取り組みについては、以下のテックブログで詳しく取り上げられていますので、ぜひ御覧ください。 techblog.zozo.com techblog.zozo.com techblog.zozo.com 弊チームで構築・運用しているシステムでは主に発注・生産部分を支える機能を提供し、AWS上はこのような構成になっています。 受発注情報の取得や登録、生産ステータスや納品データの登録といった機能を提供しており、Auroraにはこれらに関する重要なデータが保管されています。 CloudFormationで管理するAuroraのバージョンアップ上の課題 冒頭でも述べた通り、定期的にAWSからバージョンアップがアナウンスされるのですが、その際の対応方法は次の2択です。 定められた期限内に運用者が任意のタイミングで行う 対応を行わず期限後のメンテナンスウィンドウで行われる自動更新に任せる しかし、以下のような課題から任意のタイミングで行うのが一般的かと思います。 DBエンジンのバージョンアップはDBインスタンスの一時的な停止を伴うものである 適用されているパッチにより、アプリケーションで予期せぬ不具合に遭遇する可能性がある 弊チームでは、まずCloudFormationからそのままバージョンアップを行うことを検討しました。ですが、その場合DBクラスタとインスタンスが再作成されてしまい、DB内のデータが失われてしまいます。 具体的にはテンプレート上で EngineVersion というプロパティの値を変更しスタックを更新することになるのですが、 公式ドキュメント を確認すると、 Update requires: Replacement と記載されています。 RDSDBCluster : Type : 'AWS::RDS::DBCluster' Properties : # --------- omit Engine : 'aurora-postgresql' EngineVersion : '10.7' #ここを変更する # --------- omit Update requires は、AWSリソースに変更を加えた際にどのような更新が行われるのかを示すものです。 Replacement はリソースを再作成して古いリソースと置き換える、いわゆる置換が発生する更新方法です。MSP対応商品の生産や納品に関わる重要なデータが保管されているため、この方法でバージョンアップすることはできません。 従来のバージョンアップ手法の課題と今回実現したかったこと この悩みに対して弊チームではこれまで以下の方法によるバージョンアップを検討・実施してきました。 アプローチ メリット デメリット A.Webコンソールからバージョンアップを行う 手順がシンプル、作業時間が短い テンプレートで定義しているDBエンジンバージョンと実際のDBエンジンバージョンが一致しない B.スナップショットを利用してスタックで新しいバージョンのDBクラスタ&インスタンスを新規作成する スタックで認識しているDBエンジンバージョンと実際のDBエンジンバージョンが一致する DBクラスタのエンドポイントが変わる、データの整合性を取るためにアプリケーションを停止しスナップショットを取る必要があるので作業時間が長くなる CloudFormationで全てのAWSリソースを管理している環境においてはテンプレート上の不一致の方が許容しがたい部分だったため、前回はB案で対応を行いました。 とはいえB案の場合はDBクラスタのエンドポイントが変わることになり、できることなら既存のDBクラスタを維持したままバージョンアップできないかと考えていました。 そのため、今回のバージョンアップで実現したかったことをまとめると以下の要件にまとまりました。 既存のDBクラスタとインスタンスを維持したままバージョンアップしたい(エンドポイントも変わらない) テンプレートで定義しているDBエンジンバージョンと実際のDBエンジンバージョンが一致する状態にしたい CloudFormationのResource Import そこで利用したのが Resource Import です。 2019年11月にリリースされた機能で、WebコンソールやCLIから作成されたAWSリソースをスタックに取り込むことができます。新規スタックとしてAWSリソースをインポートすることも可能ですが、既存スタックへのインポートも可能です。 さて、このインポートを行う際はテンプレートにDeletionPolicy属性の記述が必要です。これはCloudFormationのリソース属性の1つで、スタックが削除される際にそのスタックで管理されているAWSリソースの扱いを定義するもので、インポートを行う際は保持する(Retain)、という指定が必要になります。 そのため、手動で作成されたAWSリソースをスタックに取り込む、という使い方はもちろんなのですが、以下のようなシーンへの活用も可能です。 1つのテンプレートで管理していたが、状況が変わりテンプレートを分割したい テンプレートから行うと Replace 扱いになってしまってできない変更をWebコンソールから行い、テンプレートとの定義差分をなくしたい いずれも一度AWSリソースをスタックから削除し、既存or新規のスタックに取り込むことで実現できます。 今回はテンプレートから行うと Replace 扱いになってしまってできないDBエンジンのバージョンアップをWebコンソールから行い、その場合発生するテンプレート上の不一致をResource Importで解消できることから、これを使ってバージョンアップを行うに至りました。 実際に行ったバージョンアップ手順 今回行った作業を図にするとこのような流れになります。 また、変更を加えていくテンプレートは以下のようなものです。 AWSTemplateFormatVersion : 2010-09-09 Resources : RDSDBCluster : Type : 'AWS::RDS::DBCluster' DeletionPolicy : 'Delete' Properties : # --------- omit Engine : 'aurora-postgresql' EngineVersion : '10.7' # --------- omit RDSDBInstance : Type : 'AWS::RDS::DBInstance' DeletionPolicy : 'Snapshot' Properties : # --------- omit AllowMajorVersionUpgrade : false AutoMinorVersionUpgrade : false DBClusterIdentifier : !Ref RDSDBClusterApplication DBInstanceClass : 'db.r4.large' Engine : 'aurora-postgresql' # --------- omit SSMParameterDBClusterEndpoint : Type : 'AWS::SSM::Parameter' Properties : Name : '/postgres_host' Type : 'String' Value : !GetAtt RDSDBCluster.Endpoint.Address 以降は実際に本番環境で行った手順についてまとめていきます。なお、ここで記述する作業はあらかじめ接続するサーバを全て停止、バージョンアップ対象のDBインスタンスのスナップショットを取得した上で行っています。 STEP1:バージョンアップ対象のDBクラスタ、インスタンスに対してDeletionPolicy属性をRetainで設定する まずはDBクラスタとインスタンスをスタックから削除した際にDBクラスタとインスタンスがAWS上に残るようにする必要があります。 テンプレートにて先述のDeletionPolicy属性を Retain にします。 AWSTemplateFormatVersion : 2010-09-09 Resources : RDSDBCluster : Type : 'AWS::RDS::DBCluster' DeletionPolicy : 'Retain' #[STEP1]Retainで指定する Properties : # --------- omit Engine : 'aurora-postgresql' EngineVersion : '10.7' # --------- omit RDSDBInstance : Type : 'AWS::RDS::DBInstance' DeletionPolicy : 'Retain' #[STEP1]Retainで指定する Properties : # --------- omit AllowMajorVersionUpgrade : false AutoMinorVersionUpgrade : false DBClusterIdentifier : !Ref RDSDBClusterApplication DBInstanceClass : 'db.r4.large' Engine : 'aurora-postgresql' # --------- omit このテンプレートで変更セットを作成して差分を確認するのですが、DeletionPolicy属性の変更は差分として検出されません。そのため変更セットを実行し、イベントでバージョンアップ対象のDBクラスタとインスタンスが UPDATE_COMPLETE と記録されることを確認しました。 STEP2:別のリソースから参照している箇所を変更する このままDBクラスタを削除するとDBクラスタのエンドポイントを参照している箇所は参照先のリソースがなくなるため、スタックの更新に失敗します。 そのため、以下のようにコメントアウトする等何らかの形で参照しないようテンプレートを変更します。 AWSTemplateFormatVersion : 2010-09-09 Resources : # --------- omit #[STEP2]参照しないようにする # SSMParameterDBClusterEndpoint: # Type: 'AWS::SSM::Parameter' # Properties: # Name: '/postgres_host' # Type: 'String' # Value: !GetAtt RDSDBCluster.Endpoint.Address STEP3:スタックからバージョンアップ対象のDBクラスタ、インスタンスを削除する この作業でDBクラスタとインスタンスをスタックの管理下から外します。 バージョンアップ対象のDBクラスタとインスタンスの記述をコメントアウトしたテンプレートで変更セットを作成し、反映します。 AWSTemplateFormatVersion : 2010-09-09 Resources : #[STEP3]DBクラスタとインスタンスを削除する # RDSDBCluster: # Type: 'AWS::RDS::DBCluster' # DeletionPolicy: 'Retain' #[STEP1]Retainで指定する # Properties: # # --------- omit # Engine: 'aurora-postgresql' # EngineVersion: '10.7' # # --------- omit # RDSDBInstance: # Type: 'AWS::RDS::DBInstance' # DeletionPolicy: 'Retain' #[STEP1]Retainで指定する # Properties: # # --------- omit # AllowMajorVersionUpgrade: false # AutoMinorVersionUpgrade: false # DBClusterIdentifier: !Ref RDSDBClusterApplication # DBInstanceClass: 'db.r4.large' # Engine: 'aurora-postgresql' # # --------- omit #[STEP2]参照しないようにする # SSMParameterDBClusterEndpoint: # Type: 'AWS::SSM::Parameter' # Properties: # Name: '/postgres_host' # Type: 'String' # Value: !GetAtt RDSDBCluster.Endpoint.Address 注意点としては、変更セットの差分にはDBクラスタとインスタンスが Remove というアクションで検知されることが挙げられます。 実際に更新を行うとイベント上は DELETE_SKIPPED と記録され、DBクラスタとインスタンスは削除されずに残ります。 DeletionPolicy属性が Retain で設定されていない場合、ここでDBクラスタとインスタンスが実際に削除されてしまうため、注意深く行う必要があります。 現在のCloudFormationには、論理ID毎の設定済みDeletionPolicyを確認する方法がありません。反映済みテンプレートを目視確認することは可能ですが、それだけでは不安です。我々は本番環境と同じテンプレートから作られた事前環境を持っているので、そこで入念に動作を確認しました。 STEP4:WebコンソールからDBエンジンのバージョンをアップデートする スタックの管理外になったところで、対象のDBクラスタを選択し、希望のエンジンバージョンにアップデートします。 当然ながら、変更のスケジューリングは「今すぐ」を選択して変更を行い、DBクラスタのステータスが 利用可能 になることを確認しました。 STEP5:バージョンアップしたDBクラスタとインスタンスをResource Importでスタックに取り込む バージョンアップしたDBクラスタとインスタンスを再度スタック管理下に置くため、テンプレートを以下のように変更します。DBのエンジンバージョンはこのタイミングでアップデートしたものに変更しておきます。 AWSTemplateFormatVersion : 2010-09-09 Resources : #[STEP4]DBクラスタとインスタンスのコメントアウトを戻す RDSDBCluster : Type : 'AWS::RDS::DBCluster' DeletionPolicy : 'Retain' #[STEP1]Retainで指定する Properties : # --------- omit Engine : 'aurora-postgresql' EngineVersion : '10.13' #[STEP4]アップグレードしたバージョンを指定する # --------- omit RDSDBInstance : Type : 'AWS::RDS::DBInstance' DeletionPolicy : 'Retain' #[STEP1]Retainで指定する Properties : # --------- omit AllowMajorVersionUpgrade : false AutoMinorVersionUpgrade : false DBClusterIdentifier : !Ref RDSDBClusterApplication DBInstanceClass : 'db.r4.large' Engine : 'aurora-postgresql' # --------- omit #[STEP2]参照しないようにする # SSMParameterDBClusterEndpoint: # Type: 'AWS::SSM::Parameter' # Properties: # Name: '/postgres_host' # Type: 'String' # Value: !GetAtt RDSDBCluster.Endpoint.Address 今回は既存のスタックにインポートしたかったため、 該当スタック > スタックアクション > スタックへのリソースのインポート で操作を行いました。以下のようにDBクラスタとインスタンスの識別子を指定することでインポートが可能です。 この際、テンプレートにインポート対象のDBクラスタやインスタンス以外の変更があると以下のようなエラーになります。 Update, create or delete operations cannot be executed during import operations. そのため、STEP2で行った変更を元に戻す作業は次のSTEPで対応しました。 STEP6:参照している箇所を戻す テンプレートを以下のように修正の上、変更セットを作成し、反映を行って参照する状態に戻します。 AWSTemplateFormatVersion : 2010-09-09 Resources : # --------- omit #[STEP6]STEP2でコメントアウトしていたのを戻す SSMParameterDBClusterEndpoint : Type : 'AWS::SSM::Parameter' Properties : Name : '/postgres_host' Type : 'String' Value : !GetAtt RDSDBCluster.Endpoint.Address スタックの更新が UPDATE_COMPLETE になることを確認した上でAuroraに接続するサーバを起動し、動作確認を行いました。 Resource Importの良さと利用する際の注意点 Resource Importを利用することでDBクラスタのリソースを維持したままテンプレートと実際のDBエンジンバージョンの不一致を解消できました。テンプレートからそのまま行うと置換が必要になってしまう変更に対して、置換を回避できるアプローチがあることは、CloudFormationでAWSリソースを管理する環境において非常に有用だと感じました。 一方、今回のようにResource Importを利用する際の注意点だと感じたことは以下の点です。 インポートと同時に新規作成や更新、削除といった変更を行うことはできない 変更を加えたい場合はインポート後にスタックを更新する 削除対象を参照している箇所がある場合は事前にテンプレートを編集し依存関係を解消しておく必要がある Import対象のリソースによっては、依存が多く修正範囲が広がる場合がある 全てのAWSリソースをImportできるわけではないため意図通りにImportできるか事前によく確認する必要がある DeletionPolicy属性の設定・スタックから削除・取り込みを実際に試してみるのが望ましい Importできるリソースは 公式ドキュメント に記載されている Resource Importを利用する際の注意点というわけではないのですが、DeletionPolicy属性の変更は変更セット作成時に差分として検知されません。こちらも頭の片隅においておくと良いかと思います。 まとめ CloudFormationでAWSリソースを管理していると、置換が発生する変更を行いたい、テンプレートを分割したいといったシーンは比較的よくある悩みかと思います。このような"やりたいけどできなかった"ことを解決できたことは非常に有益であり、今後もより運用しやすい環境になっていくのではないか、という期待が持てました。 ZOZOテクノロジーズでは、ZOZOMATやWEAR、MSPといった事業をテクノロジーで支えるさまざまな職種を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
はじめに こんにちは。WEAR部の鈴木( @zukkey59 )です。 普段は、 「ファッションコーディネートアプリ WEAR」 のAndroidアプリを担当しています。 実は最近、コツコツとやっていたリプレイスがおわり、AndroidアプリのBottomNavigation化がリリースされました! 今回は、ドロワーメニューからBottomNavigationへリプレイスした際に悩んだFragmentの状態保存について、紹介します。 背景 今までのWEARのAndroidアプリは、iOSアプリと異なりドロワーメニューという古いUIのままだったため、BottomNavigationでの実装を行うことにしました。 実装を進めていると、BottomNavigationの項目の切り替えを行うことでリストのデータやスクロールの位置が保存されない現象に遭遇しました。 BottomNavigationの項目切り替えでもデータやスクロールの位置の状態が保存される 要件を満たすために、BottomNavigationで実現可能か調査することにしました。 調査した結果、2020年11月時点では、WEARで使用しているNavigationライブラリにはマルチバックスタックの仕組みが存在しないとissue trackerに記載されており、BottomNavigationの項目を切り替えた際、Fragment自体が作り直されることが原因で状態保存されていないと判明しました。 参考: Support multiple back stacks for Bottom tab navigation 公式サンプルの Navigation Extensions が要件を満たすことができるため、その導入を解決策としました。 まずは公式サンプルの中でやっていることをざっくりとまとめて、状態保存について紹介します。 公式サンプルの実装を読む サンプルの中でやっていることを大きくまとめると、5つのステップに分けられます。 BottomNavigationの項目ごとにNavHostFragmentの存在チェックを行い、初めての場合は作成する BottomNavigationで選択状態に応じて、attachとdetachを行う バック時の挙動を修正する 再選択時の挙動をBottomNavigationのリスナーに合わせて実装する DeepLinkの挙動を追加する 状態保存に関してこれらの中でも特に重要なのが、次の2点です。 ActivityがFragmentManagerにBottomNavigationの項目ごとにFragmentを追加し、Fragmentの状態を保持する点 選択状態に応じてattachとdetachを繰り返すようにするという点 まず、 Navigation Extensions のobtainNavHostFragmentをみていきましょう。 private fun obtainNavHostFragment( fragmentManager: FragmentManager, fragmentTag: String, navGraphId: Int , containerId: Int ): NavHostFragment { // 指定のfragmentTagを持つFragmentがあるかをチェック val existingFragment = fragmentManager.findFragmentByTag(fragmentTag) as NavHostFragment? existingFragment?.let { return it } // ない場合は新しくNavHostFragmentを作成 val navHostFragment = NavHostFragment.create(navGraphId) // FragmentManagerに追加 fragmentManager.beginTransaction() .add(containerId, navHostFragment, fragmentTag) .commitNow() return navHostFragment } ここではまずActivityが持つFragmentManager内に、BottomNavigationの項目ごとに追加されているFragmentTagがあるかチェックを行います。ない場合は新しくNavHostFragmentを追加します。 次に、setupWithNavControllerのコードをみていきましょう。以降、ソースコード中の「...」は省略を意味します。 fun BottomNavigationView.setupWithNavController( navGraphIds: List< Int >, fragmentManager: FragmentManager, containerId: Int , intent: Intent ): LiveData<NavController> { ... navGraphIds.forEachIndexed { index, navGraphId -> ... if ( this .selectedItemId == graphId) { selectedNavController.value = navHostFragment.navController attachNavHostFragment(fragmentManager, navHostFragment, index == 0 ) } else { detachNavHostFragment(fragmentManager, navHostFragment) } } ... } BottomNavigationで選択された項目のIdとnavigationGraphのIdの比較によってattachとdetachを行っています。 attachNavHostFragmentとdetachNavHostFragmentの実装についてもみていきましょう。 private fun attachNavHostFragment( fragmentManager: FragmentManager, navHostFragment: NavHostFragment, isPrimaryNavFragment: Boolean ) { fragmentManager.beginTransaction() .attach(navHostFragment) .apply { ... } .commitNow() } private fun detachNavHostFragment( fragmentManager: FragmentManager, navHostFragment: NavHostFragment ) { fragmentManager.beginTransaction() .detach(navHostFragment) .commitNow() } attachNavHostFragmentでは、 attach という関数を呼ぶことで、前にUIからdetachされたFragmentを再度attachし、ビュー階層が再作成されて表示されます。 detachNavHostFragmentでは、 detach という関数を呼ぶことで、指定されたFragmentをUIから切り離し、バックスタックに配置された時と同じ状態にし、ビュー階層は破棄されます。 attachとdetachをする時の Fragmentのライフサイクル の流れは、バックスタックに置いた状態と同じになるため、onCreateViewからonDestroyViewまで呼ばれることになります。 つまり、状態保存を実現するため内部的にやっていることは次の通りです。 インスタンスを最初に作成したあとに、同じインスタンスのUIの状態を保存してバックスタックに置いた状態にし、Viewの再生成から破棄までを実行する これが状態保存を行うための基本的な考え方になります。 プロダクトに当てはめた時にいくつか出てきた課題 単純な遷移で、特に通信を元にしない表示だけをするのであれば、このままでも問題ありません。 しかし、実際のプロダクトに当てはめた際には、いくつか満たしたい仕様があります。 具体的には、WEARでは下記の仕様が満たさなければなりません。 PagerのTabLayoutタブを再選択した時に一番上までスクロールを行う Pagerが持つFragment全て、タイミングに応じて全更新を行う BottomNavigation化で、仕様を満たすために次の課題が出てきました。 初期化処理、イベントのobserveなどのタイミングについての考慮すること 密結合になっているクラスを疎にして、役割を明確にすること 1.に関しては、BottomNavigation化前は状態保存の仕様がなかったため、タイミングを考慮する必要はありませんでした。しかし、BottomNavigation化後は、Viewの生成時、最後に発行されたイベントの値がobserveのタイミングで即時に流れてくるような場合、切り替えのたびにobserve処理が実行されてしまい、イベントが流れるという意図しない挙動が発生しました。 2.に関しては、密結合になったクラスが多数存在し、役割が曖昧になっていました。例えばViewを作り直す場合、それが原因でAPIを呼び出してデータを取得する処理が密になっているため、1.のタイミングの考慮だけでは解決できませんでした。 これらの課題を解決するために、次のような対応を行いました。 公式サンプルの考え方を元に、状態保存を実現するための対応方針 まずはじめに、密結合になっているクラスを分離するために、アーキテクチャの導入を行い役割を明確にすることを行いました。 今回リプレイスしたことで、BottomNavigationの各FragmentとメインのActivityは次のように変わりました。 リプレイス後の現状のWEAR Androidのそれぞれの役割については次の通りです。 View(etc: Activity / Fragment) Viewに関わる操作を担います。AdapterやViewHolderもこちらに入ります。 UIの操作を受けて、ViewModelにイベントを流す役割を持ちます。 ViewModel Viewのデータを保持し、UIから受け取ったイベントに応じて、UIに必要な情報をLiveDataで渡します。 データ処理のビジネスロジックを含んでいます。 UseCase アプリケーション固有のビジネスロジックを書く場所としています。 Repository DBアクセスとAPI通信を担い、データの変換を行う役割を持ちます。 現状のWEARに最適なアーキテクチャは何かをチーム内で相談して決め、それを導入することでViewとビジネスロジックの分離を行うことができ、最終的に密結合の問題を解決することができました。 次に、初期化処理やイベントのobserveのタイミングについて説明します。 「公式サンプルの実装を読む」で説明した状態保存の根本的な考え方は、attachとdetachによってUIの状態が保存され、onCreateViewからonDestroyViewまでが呼ばれるということでした。 つまり、初回のみ実行したい初期化処理やイベントのobserveは、このライフサイクルの流れの中に入れてはいけません。 ライフサイクルがonCreateViewの前で、かつバックグラウンドでのプロセスキルなどの対応を考えた時に、savedInstanceStateを利用できる条件を満たすのはonCreateとなります。 そのため、WEARではonCreateにて初回に実行したい処理を記載するようにしました。 class HogeFragment : BaseDaggerFragment() { override fun onCreate(savedInstanceState: Bundle?) { super .onCreate(savedInstanceState) // 初期化処理を記載する。バックキル時の対応などはこちらに記載 // 最初の一回だけ行いたい、親Fragmentからのイベントの通知をobserveする (requireParentFragment() as FugaFragment).viewModel.behaviorLive.observe(requireParentFragment(), Observer { behavior -> ... }) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super .onViewCreated(view, savedInstanceState) // Viewの構築、下タブを切り替えた時に行ってほしい処理をこちらに記載 } } これらの対応を行ったことで、下タブ切り替え時の状態保存を実現することができました。 また、WEARのタイムライン画面には独自ヘッダーの切り替えでも状態保存を行うという仕様が存在し、こちらも今まで説明した考え方を応用して実装しました。 公式サンプルの考え方を元に、独自ヘッダーで応用する BottomNavigation化に伴い、新しく独自のヘッダーを作成することになりました。今まで説明した考え方を応用して実装した際のポイントをまとめて紹介します。 リプレイスしたタイムラインの画面が次に示すものです。 独自ヘッダーで実装する際のポイントは大きく5つのステップに分けられます。 親FragmentのインスタンスがonAttachされた際に、子Fragmentのインスタンスを作成する ヘッダーの選択情報をActivity側のViewModelで保持する View生成時、最初にattachされるFragmentをセットする ヘッダー切り替え時、選択状態に応じてattachとdetachを行う 子Fragment側で、最初に処理したいことをonCreateに記載する まずはじめに、親FragmentであるTimelineAdminFragmentに子Fragmentのインスタンスを保持する必要があるため、onCreateにてリストで持たせます。 // タイムラインの親Fragment class TimelineAdminFragment : BaseDaggerFragment() { private lateinit var childFragments: MutableList<Fragment> override fun onCreate(savedInstanceState: Bundle?) { super .onCreate(savedInstanceState) // タイムラインにて保持したいFragmentをリストで持つ childFragments = mutableListOf( TimelineColumnFragment.newInstance(ONE), TimelineColumnFragment.newInstance(TWO), NewsFragment.newInstance(), NewSnapFragment.newInstance(ALL) ) } } 次に、子Fragmentにて独自ヘッダーを持つ場合はその選択状態を保持する必要があるため、Activity側のViewModelにてヘッダー情報を保持します。 // Activity側のViewModel class MainViewModel( private val application: WEARApplication, private val mainUseCase: MainUseCase, private val accountUseCase: AccountUseCase ) : AndroidViewModel(application) { ... // LiveDataで保持する val timelineHeaderDataLive: MutableLiveData<Event<TimelineHeaderData>> = MutableLiveData() ... } // ヘッダーの情報を保持するクラス data class TimelineHeaderData( val timelineTypes: List<TimelineType>, val followTypes: List<FollowType>, val categoryTypes: List<CategoryType>, ... ) 子Fragment側で、ヘッダーを持っているので、切り替えた際にActivityのViewModelのLiveDataにpostValueします。 View生成時、最初にattachされるFragmentをセットします。その際、Activity側でヘッダー情報を保持したので、そちらの情報を元にセットします。 Navigation Extensions の実装を参考にして、タイムラインの親Fragmentに反映させました。 // タイムラインの親Fragment class TimelineAdminFragment : BaseDaggerFragment() { // ヘッダーで切り替えた時にfragmentTagを保持しておくために用意 private val graphIdToTagMap = SparseArray<String>() ... // View生成時(onViewCreated)に最初にattachするFragmentをセットする private fun setUpPrimaryFragment(type: TimelineType, followType: FollowType) { childFragments.forEachIndexed { index, fragment -> val fragmentTag = getFragmentTag(index) val obtainFragment = obtainFragment(fragmentTag, index) val selectedIndex = getSelectedFragmentIndex(type, followType) val mapKey = getLayoutResourceId(fragment) + index graphIdToTagMap[mapKey] = fragmentTag if (index == selectedIndex) { attachFragment(obtainFragment) } else { detachFragment(obtainFragment) } } } ... // BottomNavigationの時と同様にFragmentがすでに存在しているかチェックを行い、されていなければ追加する private fun obtainFragment( fragmentTag: String, childFragmentsIndex: Int ): Fragment { val existingFragment = childFragmentManager.findFragmentByTag(fragmentTag) existingFragment?.let { return it } val fragment = childFragments[childFragmentsIndex] childFragmentManager.beginTransaction() .add(R.id.timeLineAdminFragmentContainer, fragment, fragmentTag) .commitNow() return fragment } // BottomNavigationの時と同様にFragmentのタグを取得する private fun getFragmentTag(index: Int ) = "timeline# $index " // BottomNavigationの時とは異なり、navigationではなくlayoutのResourceIdを取得する private fun getLayoutResourceId(childFragment: Fragment) = when (childFragment) { is TimelineColumnFragment -> R.layout.fragment_timeline_column is NewsFragment -> R.layout.fragment_timeline_news is NewSnapFragment -> R.layout.fragment_new_snap else -> throw IllegalArgumentException( "Not found such a fragment." ) } // typeによって親Fragmentで保持している子Fragmentのどのindexかを取得する private fun getSelectedFragmentIndex(type: TimelineType, followType: FollowType): Int { return when { type == FOLLOW && followType == ONE -> TIMELINE_COLUMN_ONE_INDEX type == FOLLOW && followType == TWO -> TIMELINE_COLUMN_TWO_INDEX type == NEWS -> NEWS_INDEX type == SNAP -> NEW_SNAP_INDEX else -> TIMELINE_COLUMN_TWO_INDEX } } } ヘッダー切り替え時、選択状態に応じてattachとdetachを行います。ヘッダーの「フォロー中」、「ニュース」、「新着」の項目はRecyclerViewになっており、選択されたindexに応じてattachとdetachを行います。 class TimelineAdminFragment : BaseDaggerFragment() { ... private fun switchFragment(type: TimelineType, followType: FollowType, categoryType: CategoryType, shouldChangeCategory: Boolean = false ) { ... val selectedIndex = getSelectedFragmentIndex(type, followType) val mapKey = getLayoutResourceId(childFragments[selectedIndex]) + selectedIndex val newlySelectedItemTag = graphIdToTagMap[mapKey] ?: return // 最初に全てのFragmentをdetachする childFragments.forEach { detachFragment(it) } // 新着のサブ項目である性別の選択の時は、状態を保存しない仕様があるため分岐がある if (shouldChangeCategory) { // 新着サブ項目の性別選択時に指定のFragmentを破棄して作り直すことをしているが、本筋と逸れるため省略 } else { // 選択した項目のFragmentをattachするようにしている val selectedFragment = childFragmentManager.findFragmentByTag(newlySelectedItemTag) ?: throw IllegalArgumentException( "There is no such Fragment. Please review the process." ) attachFragment(selectedFragment) } } ... } ここで行っていることはBottomNavigationの実装と同様です。選択したindexからFragmentManagerに保持しているFragmentを取得しattachする流れになります。 子Fragment側で最初に処理したいことをonCreateに記載すれば完成です。 BottomNavigationの時と異なるのは、ヘッダー情報を保持する点と、fragmentTagのIdが変わっている点です。 根本的な考え方は同じなため、BottomNavigation以外で、 Navigation Extensions の考え方を用いれば容易にどのようなViewでも状態保存の実現が可能です。 まとめ BottomNavigationに限らず状態保存を実現するために、重要なことは4つです。 密結合になっている実装の場合、まず切り離すことを考える インスタンスを最初に一度作成し、attachとdetachで切り替えるようにする 最初の1回だけやりたい処理は、attachとdetachが行われる側のFragmentのonCreateに記載する 独自のViewで実装する場合は、indexなどの情報を生存期間が長い方のActivity側のViewModelで保持するようにする さいごに 今回紹介した事例で、BottomNavigationの方は Navigation Extensions の実装が、Navigationでマルチバックスタックが対応されるまでの解決方法になると思いますが、内部の実装を掘り下げて考え方を学ぶと、他のViewであっても利用することができ、状態保存をしたい時には役立つと思います。 他にもやり方があったり、もっとこうしたほうがいいというアドバイスがございましたら、私のTwitterアカウント @zukkey59 までご連絡をいただけますと嬉しいです! さいごに、ZOZOテクノロジーズでは、一緒にモダンなサービス作りをしてくれる方を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
こんにちは。WEARバックエンドエンジニアの id:takanamito です。先日リリースしたWEARの新プッシュ通知基盤の紹介をしようと思います。 新プッシュ通知基盤開発の背景と目的 WEAR では既にiOS/Androidアプリに向けたプッシュ通知配信基盤が存在していました。 しかし、かなり昔につくられた基盤ということで運用にコストがかかったり、必要な機能が足りていなかったりします。 例えば、ユーザー全体にプッシュ通知を送りたい場合に以下のような問題が存在しました。 ログイン済みユーザーにしかプッシュ通知を送信できない プッシュ通知の送信開始から完了までに半日以上かかる 配信サーバーのスケールに手作業が発生する 1.についてはWEAR開発当初、はじめてプッシュ通知を導入するきっかけとなったキャンペーンが存在したものの、そのキャンペーンの対象がWEARアカウントを持っている人だったために、このような仕様でプッシュ通知の機能が作られてしまい、今まで運用が続いていたことが理由のようでした。今回の新基盤開発のモチベーションの1つに「未ログインユーザーも含めたプッシュ通知配信」の実現が挙げられます。 2.と3.については、詳しいアーキテクチャの説明は割愛しますが、1.の経緯で作られた機能のため、現在のように月間1000万ユーザーを抱えることを想定せず作られていたようです。 また、配信時に以下のような運用が実際になされていました。 DBから配信対象デバイスリストを抽出 手元の開発用マシンでバッチを実行し、配信サーバーに対して1件ずつHTTPで配信リクエスト 配信サーバーからAPNs/FCMに対して1件ずつ配信リクエスト この配信サーバーはオートスケール設定などされておらず、台数を増やしたい場合は秘伝の手作業によって作成されたAWS EC2のAMIを使って配信サーバーを手動で増やすという作業が必要でした。 このように既存の仕組みは配信数が増えるほどコスト(時間とお金)がかかるものであり、この状態でさらに送信対象が増える「未ログインユーザーも含めたプッシュ通知配信」を行うことは非現実的だろうという判断から、プッシュ通知基盤を作り直すことにしました。 新プッシュ通知基盤の要件 新しい基盤を設計する際には以下のような要件を意識していました。 WEARユーザーが増えた場合にも短時間で配信が可能であること 低価格で配信が可能であること 配信サーバーの運用をしなくてもよいこと 結果、Googleが提供するFirebase Cloud Messaging(FCM)を採用することにしました。 以下のポイントが決め手になりました。 ZOZOTOWNで先行して採用されており、配信速度の実績が十分高速だった topic の機能により、API経由の1リクエストで多数のデバイスへの配信が実現可能である 無料 である 我々が配信サーバーを保守運用する必要がない WEARにおけるプッシュ通知の種類 WEARにはいくつかの種類のプッシュ通知が存在します。 全体プッシュ通知:WEARユーザー全員が対象の通知 ユーザー指定プッシュ通知: WEARISTA 向けのお知らせなど、特定のセグメントのユーザーが対象の通知 ユーザーのアクションによるプッシュ通知:「フォローされました」「フォロー中のユーザーがコーデを投稿しました」など、WEARユーザーのアクションをきっかけに配信される通知 今回の新基盤開発では初期段階のスコープはWEARユーザー全体に対する一斉通知を対象としており、ユーザーのアクションによって配信されるプッシュ通知は後から改修をすることにしています。 配信フロー 全体お知らせプッシュ通知は以下のようなフローでFCMのtopicを利用して配信しています。 プッシュ通知シーケンス図 非同期処理を含むシーケンス図なのでいびつですが、流れはイメージしていただけるかと思います。 実装 WEARは以前のテックブログでも紹介したように、Railsにリプレイスをしている最中です。 techblog.zozo.com そのため今回作る新基盤では、プッシュ通知配信機能を持った管理画面をRailsで実装しました。 FCMコンソールにはプッシュ通知を配信できる機能が存在しますが、WEARのネイティブアプリで既に実装されたプッシュ通知payloadと互換性をもった形で配信ができなかったため、APIを通じた配信の仕組みを作っています。 サーバーからFCMの機能を利用するとなると Firebase Admin SDK を使いたくなるのですが、残念なことにRuby SDKは提供されていません。また、gemもいくつか存在したのでその実装方法を確認したのですが、私が確認した範囲ではFCM HTTP v1 APIに対応する新しい認証方式を採用しているgemがまだ存在せず、今回は自分で実装することにしました。 Railsのlib以下にFCM用の実装を置いてます。現状はtopicを使った配信をしているため Fcm::Topic クラスを実装していますが、今後別の配信方式を採用する場合はここに実装が増えていく予定です。 以下にサンプルコードを置いておきます。設定値などは適宜読みかえてください。 require ' googleauth ' module Fcm class BadRequestError < StandardError ; end class UnauthorizedError < StandardError ; end class ForbiddenError < StandardError ; end class UnregisteredError < StandardError ; end class QuotaExceededError < StandardError ; end class InternalServerError < StandardError ; end class ServiceUnavailableError < StandardError ; end class Topic class << self def send_notification (payload) response = Fcm :: Client .connection.post( " /v1/projects/ #{ project_id } /messages:send " , payload) # Errors: https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode case response.status when 400 raise Fcm :: BadRequestError , response.body[ :error ][ :message ] when 401 raise Fcm :: UnauthorizedError , response.body[ :error ][ :message ] when 403 raise Fcm :: ForbiddenError , response.body[ :error ][ :message ] when 404 raise Fcm :: UnregisteredError , response.body[ :error ][ :message ] when 429 raise Fcm :: QuotaExceededError , response.body[ :error ][ :message ] when 500 raise Fcm :: InternalServerError , response.body[ :error ][ :message ] when 503 raise Fcm :: ServiceUnavailableError , response.body[ :error ][ :message ] else response end end end end module Client class << self def connection Faraday .new(base_url) do |builder| builder.request :oauth2 , bearer_token, token_type : :bearer builder.request :json builder.response :json , parser_options : { symbolize_names : true }, content_type : ' application/json ' builder.adapter Faraday .default_adapter end end private def bearer_token authorizer = Google :: Auth :: ServiceAccountCredentials .make_creds( json_key_io : StringIO .new(fcm_key.to_json), scope : ' https://www.googleapis.com/auth/firebase.messaging ' ) response = authorizer.fetch_access_token! response[ ' access_token ' ] end end end end FCM運用方針 Rubyクライアント以外にもFCMを使ったプッシュ通知に関して、いくつか検討した項目があります。 メッセージのカスタマイズについて WEARアプリには既にプッシュ通知の仕組みが実装されています。そのため今回の新プッシュ通知基盤を導入するにあたり過去のバージョンのクライアントアプリと互換性を考慮する必要がありました。 FCMでは送信するメッセージをカスタマイズする機能が存在しますが、細かなカスタマイズはAPIを通じてでしか行えず、FCMコンソールを使ったプッシュ通知配信ではメッセージの表現の幅に制約がありました。 参考: FCM メッセージについて  |  Firebase 既存のクライアントアプリの実装を活かしたかったので、FCMコンソールからの配信を諦め、すべて内製のツールを通じて配信することにしました。 ただし、FCMコンソールでは利用可能な「スケジュール配信」などの仕組みを自分で用意する必要があるため、配信予約をしたい場合は注意が必要です。 topic設計について 先述の通り、今回実装した全体プッシュ通知にはFCMのtopic機能を使っています。サーバーから1リクエストで大量のプッシュ通知配信ができて非常に便利な機能ですが「API経由でtopicの一覧が取得できない」という制約が存在します。 そのためWEARではtopicの命名規則をルール化して運用することにしました。 具体的には以下のようなルールです。 wear-${environment}-${language}-${os}-all WEARは複数環境(本番、開発、QAなど)、複数言語、複数プラットフォームが存在するサービスなので、topic名からそれぞれ識別できるようにした設計です。 全体プッシュ通知用のtopicが増えることは稀なので、命名規則に基づいたtopic名をRailsの設定ファイルで管理して運用しています。 topic削除について topic自体の作成上限はありませんが、1つのアプリインスタンス(FCMトークンと同義だと解釈しています)を登録できるtopicの上限が2000件となっています。 さらに困ったことにtopicを削除するインタフェースが用意されていません。topicを削除したい場合は登録されたアプリインスタンスを全て登録解除する必要があるようです。 1 つのアプリ インスタンスを登録できるのは、2,000 トピックまでです。 参考: iOS でトピックにメッセージを送信する  |  Firebase そのため、一度作成したtopicを削除するにはFCMトークンが必須です。一度でもtopic登録されたFCMトークンはDBで永続化し、いつかtopicを削除したくなった際に対応できるようにしています。 まとめ WEARにおけるFCM導入事例を紹介しました。topicを使ったプッシュ通知配信においてAPIが不足している印象ですが、やはりプッシュ通知配信サーバーの運用から脱却できるなど、利点の方が上回っていると判断し導入しました。 今回は全体プッシュ通知について事例をご紹介しましたが、まだ全てのプッシュ通知を新基盤に移せたわけではありません。引き続き、地道な改善を続け高速にプッシュ通知を届けたり、コストの削減をしていく必要があると考えています。 技術的な基盤改善でサービスをより良くすることに興味がある方は、以下のリンクからぜひお声がけください。 hrmos.co
こんにちは、ZOZOテクノロジーズ CTO室の池田( @ikenyal )です。 ZOZOテクノロジーズでは、11/18に ZOZO Technologies Meetup〜ZOZOが提供するEC支援サービスの裏側〜 を開催しました。 zozotech-inc.connpass.com 本イベントでは、ZOZOが運営する自社EC支援サービス「Fulfillment by ZOZO(FBZ)」の概要や技術、運用に関して各担当者からお伝えしました。 登壇内容 まとめ 弊社の社員3名が登壇しました。 FBZのサービス概要 〜 大規模物流プラットフォームZOZOBASEの解放 (BtoB開発本部 BtoB開発企画 / 大野 公嗣) サーバーレスなAPIサービスの全容 (BtoB開発本部 BtoB開発 / 杉田 尚弥) AWSフルマネージドサービスにおける監視と運用 (SRE部 BtoBチーム / 蔭山 雄介) 最後に ZOZOテクノロジーズでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
はじめに BtoB開発部の木目沢です。 Fulfillment by ZOZO (以下FBZ)で提供しているAPIの開発に携わっております。 FBZに関しては以前、 物流支援サービスを支えるAWSサーバーレスアーキテクチャ戦略 で、AWSサーバーレスアーキテクチャ関連のサービスをどのように活用しているかをご説明しました。 techblog.zozo.com 今回は、サーバーレスアーキテクチャの特徴、設計するうえで課題だった点、そしてそれら課題をどのように克服していったかご紹介します。 イベント駆動と分散処理 FBZではAWSサーバーレスアーキテクチャ関連のサービスを採用しています。その大きな特徴は「イベント駆動」であるという点です。 Lambdaはイベント駆動のサービス 例えば、API GatewayへのリクエストやDynamoDB、S3などへのデータのプッシュイベントをトリガーにLambdaを起動します。起動されたLambdaは関数として処理を実行、そのアウトプットをトリガーに別のLambdaが動いていくという仕組みです。 Lambdaのトリガーとして利用できるAWSのサービスは こちら を見ていただくとわかるように、現時点で20サービス以上あります。 このように、多くのインプット元やアウトプット先があれば、例えば下図のように分散環境を活かしたサービスが自由に構築可能になります。 Lambdaを使った分散環境の例 例では、CloudWatch イベントから起動したLambdaが全商品を取得しています。取得後、商品の更新を行うLambdaが各商品ごとに起動され処理していきます。その結果をSQSに追加すると、また別のLambdaが起動し通知処理を行うという流れです。 分散処理を行うことで全体の処理時間の短縮やバグ発生時の原因切り分けがしやすくなるなど、多くの利点を得ることができます。 設計面での課題 一方、このようなイベント駆動や分散処理のアーキテクチャはソースコードの可読性を下げる要素が2つあります。 多くのAWSサービスへのアクセス 1つの処理で複数Lambdaの実行 多くのAWSサービスへのアクセス 多くのAWSサービスを利用するため、各サービスへのアクセスロジックとビジネスロジックが混在してしまうため、処理が複雑になりソースコードが読みづらくなります。 1つの処理で複数Lambdaの実行 1つの処理で複数のLambdaが実行されるため、Lambdaの処理からビジネスロジックを確認しづらくなります。例えば、商品の更新処理という1つの処理の中で、取得・更新・通知と3つの機能が実行されます。そのため「商品の更新処理」の流れについて3つのLambdaを追う必要があります。 AWSサービス処理とビジネスロジックの徹底分離 この課題を解決するため、以下の2点の考え方が必要でした。 各AWSサービスへのアクセスロジックとビジネスロジックを分離すること 各Lambdaから共通して参照できるようにビジネスロジックを集中した場所に定義すること そこで私達が当初の設計から取り入れたのがドメイン駆動設計(DDD)です。 ドメイン駆動設計ではドメインモデルをAWSの他のサービスと分離するために、いくつかのアーキテクチャが提案されています。私達が採用したのはレイヤアーキテクチャです。 レイヤアーキテクチャ ヘキサゴナルアーキテクチャやクリーンアーキテクチャなど他のアーキテクチャも提案されています。しかし、私達はドメインモデルを分離することだけに集中し、最もわかりやすいレイヤアーキテクチャを採用しました。 分離したモデル層はモジュール化し、他の層を参照できないようにしました。これにより、FBZのモデル層に各AWSサービスへのアクセスロジックが入り込めない設計にできました。 FBZのフォルダ構成例 この設計により、アウトプット先のストレージを変えたい、またはSQSではなくSNSを使いたいなどの要望もinfrastructures層だけを変更すればよくなりました。そして、商品の更新ルールを変えたいなどビジネスロジックを変更したい場合はmodels層の商品モデルだけを修正すればよくなりました。 このようにイベント駆動、分散処理に対する問題点をドメイン駆動設計を採用することで解決してきました。 新しい問題点 サービスの成長に伴い、新しい問題も出てきました。一部ではありますが、2点ご紹介します。 モデルのモノリス化 モデル以外にビジネスロジックが書かれてしまう モデルのモノリス化 私達は各Lambda関数が共通のモデルを見ることでソースコードが追いにくいという問題を解決してきました。しかし、FBZが成長するに連れLambda関数も増えていき、現在では数百単位の関数になっています。このレベルまでLambda関数の数が増えると、今度はモデルのモノリス化が深刻になってきました。 例えば、注文の業務では注文モデルを見ます。同じモデルを発送や返品・交換でも見ることになると、それぞれのロジックが注文モデルに集まってくる状態になります。その結果、複数のLambda関数から同じモデルを見ることで容易に理解できるというメリットから、モデルが大きすぎて逆に理解しづらくなるという新しい問題が発生しています。 大きくなりすぎたモデルを理解しやすい単位に分割することが必要になってきています。 モデル以外にビジネスロジックが書かれてしまう メンバーの増減や至急の要件などをこなしていくうちに、ビジネスロジックがモデルではなくアプリケーション層やハンドラーに書かれていってしまう問題も発生しています。 Lambda関数によってはモデルを一度も使わずに複雑なビジネスロジックを実装しており、さらにそこが何度も修正が入るような重要なロジックであることも少なくありません。修正が入るタイミング、リファクタリングできるタイミングでこういった箇所をモデルに移行していくということも意識していかなくてはいけません。 複雑なビジネス要件だからこそ活きる設計 今回はサーバーレスアーキテクチャを用いたサービス開発の中で生じた課題と、その課題に対しドメイン駆動設計を用いて対応してきた内容をご紹介しました。苦労している点は現在もありますが、約50に及ぶECサイトで利用されるAPIサービスをチームで開発してきたという意味で一定の成果を上げています。サーバーレスアーキテクチャを採用する事例も最近では増えてきたと思いますのでぜひ参考にしていただければ幸いです。 最後に、今回の経験をもとにドメイン駆動設計を採用する上で重要だと感じた点を共有します。 ドメイン駆動設計ではモデルを分離したその先、モデル自体をどのように構築していくかが重要になってきます。サーバーレスアーキテクチャはそれ自体複雑なものですが、もっと複雑で理解が難しいのはユーザーの活動やビジネスに関するロジックです。 FBZも単に在庫を連携するだけのサービスではありません。商品はもちろん、在庫が変動する要素である注文や配送の管理も必要となるため、ビジネスロジックは非常に複雑です。さらにサービスの成長に伴い、このビジネスロジックに多くの変更が入ります。複雑なビジネスロジックをどのようにモデルに表現するかで、修正や追加の難易度が変わります。それがさらなるサービスの成長に影響していきます。 そのため、モデルを改善し続けていくことがサービスを成長させていく上でなにより大切になってきます。私達は引き続き改善を続けていくことで、今後もFBZを成長させていきたいと考えています。 BtoB開発部では、サーバーレスアーキテクチャやドメイン駆動設計などテクノロジーを活用しサービスを成長させたい仲間を募集中です。ご興味ある方は こちら からぜひご応募ください! tech.zozo.com
ZOZOテクノロジーズ推薦基盤チームの寺崎( @f6wbl6 )です。ZOZOでは現在、米Yale大学の経営大学院マーケティング学科准教授である上武康亮氏と「顧客コミュニケーションの最適化」をテーマに共同研究を進めています。 推薦基盤チームでは上武氏のチームで構築した最適化アルゴリズムを本番環境で運用していくための機械学習基盤(以下、ML基盤)の設計と実装を行っています。本記事ではML基盤の足掛かりとして用いた AI Platform Pipelines ( Kubeflow Pipelines ) の概要とAI Platform Pipelinesの本番導入に際して検討したことをご紹介し、これからKubeflow Pipelinesを導入しようと考えている方のお役に立てればと思います。記事の最後には、推薦基盤チームで目指すMLプロダクト管理基盤の全体像について簡単にご紹介します。 上武氏との共同研究のより詳しい内容については弊社のニュース記事を参照ください。 corp.zozo.com 案件概要 推薦基盤チームで抱えていた課題 Kubeflow Kubeflow Pipelines Kubeflow Pipelinesの運用環境 AI Platform Pipelines Pipelineの設計・実装で意識したこと・ハマったこと Pipeline内で日時情報を扱う Slack通知 ノードプールによるリソースと権限の分離 CI/CDの実装 今後の展望 ML基盤として目指す姿 おわりに 参考 案件概要 Yale大学との共同研究に関して、推薦基盤チームで担当する業務の要件概要は以下の通りです。 毎日決まった時間にモデルによる予測を実行する(バッチ実行) モデルに入力する特徴量はBigQuery上の複数のテーブルから取得し、所定の前処理を加える モデルはpickle形式の学習済みモデルを提供していただき、当面の間はモデルの再学習を行わない 予測結果はBigQueryに出力する 具体的な入出力について詳細を書くことはできませんが、入力としてZOZOTOWNユーザーの属性や回遊情報を使い、出力としてユーザーごとに最適なコンテンツを得る最適化問題と考えるのが良いかと思います。一般的に予測モデルはデプロイして終わりではなく継続的に学習・検証とモデル更新を繰り返しますが、今回は共同研究における実験という側面があり、実験期間中は再学習を行わず運用することになりました。 今回運用するモデルはオンライン予測しない + モデルの再学習も行わないため、機械学習モデルの運用としては比較的負荷の少ないケースと言えます。この機械学習モデルの運用方法を検討するにあたり、まず私たちのチームで抱えていた機械学習モデルの運用上の課題について見ていきます。 推薦基盤チームで抱えていた課題 推薦基盤チームではZOZOTOWNの推薦システム全般の構築・運用を担当しており、様々なアルゴリズムが本番環境で動いています。推薦アルゴリズムは弊チームで構築したものだけでなく分析本部やMA部で構築したものもあり、他チームから本番導入を依頼されるようなケースが少なくありません。案件ごとに様々な形でモデルの実装・運用を行っていく中で、以下のような要求に耐え得るML基盤が求められていました。 運用中の予測モデル(ワークフロー)を一元管理できること モデル構築の際に環境構築が容易であること 実験段階からプロダクションへの移行が容易であること 車輪の再発明をしないような仕組みであること(= 似たようなモデル開発をしない) モデルサービングが可能であること 機械学習モデルを本番環境で運用するにあたってこうした課題はよく直面するものと思います。特に私たちのチームでは様々なチームからモデルの実装・運用を依頼されるため、今後管理すべきモデルが増えていく状況の中で「 運用中の予測モデルを一元管理できること 」は最初に対処したい課題でした。仮にモデルごとに管理環境が異なっていた場合、モデル導入に関与した担当者でしかメンテナンスができないという状況にも繋がりモデル管理が属人的になってしまいます。 また推薦基盤チームでもモデルを作ることはあるため、推薦モデルを増やしていく上で「 実験段階からプロダクションへの移行が容易であること 」も重要な項目でした。 こうした課題を背景に、推薦基盤チームではMLOps全体に渡ってカバーしている Kubeflow を導入することにしました。 Kubeflow Kubeflow はモデルの作成・学習・検証、ワークフロー構築、モデルサービングといったMLOpsに関するワークロードをKubernetes上で実行するためのオープンソースツールキットです。要するに、MLプロジェクトで必要となるツールの全部盛りです。 元はGoogle社内で使われていた Tensorflow Extended というML基盤があり、より汎用的に使えるML基盤を目指してオープンソース化した姿がKubeflowというプロジェクトになったようです。2020年11月現在v1.1が最新バージョンですが公式ドキュメントが追いついていない部分が多いため、実際に利用する際にはv1.0からキャッチアップしていくのが良いと思われます。 公式サイトによると、Kubeflowコミュニティの目指すゴールは以下であると述べられています。 Our goal is to make scaling machine learning (ML) models and deploying them to production as simple as possible, by letting Kubernetes do what it’s great at: ・Easy, repeatable, portable deployments on a diverse infrastructure (for example, experimenting on a laptop, then moving to an on-premises cluster or to the cloud) ・Deploying and managing loosely-coupled microservices ・Scaling based on demand 特に"Easy, repeatable, portable..."の項目に関してはドキュメントの中で頻繁に出てくることから、MLプロジェクトで陥りがちな「 開発環境と本番環境の整合性を取るための雑務を取り除く 」という思想が前面に出ているように思います。 こうした思想から、KubeflowにはMLプロジェクトで取り組むタスクをEnd to Endで行えるような要素が盛り込まれています。 MLプロジェクトでのタスク Kubeflowでの機能名 モデル構築・実験 Jupyter Notebooks モデルの学習 TensorFlow Training, PyTorch Training, ... ハイパーパラメータ調整 Katib 特徴量管理 Feast ワークフロー構成 Kubeflow Pipelines モデルサービング KFServing , Seldon Core Serving, ... Jupyter Notebooksによるモデル構築・実験からKFServingによるオンライン予測のエンドポイント作成まで、MLプロジェクトのタスクをEnd to Endで行うことができます。各タスクに最適化されたツールは既に様々な場所で運用されていますが、それらを1つのツールでまとめられるのがKubeflowの強みでしょう。 再三になりますが今回の要件としてモデルの構築・学習・オンライン予測は対象外であるため、Kubeflowの機能のうち Kubeflow Pipelines のみを利用することにしました。 Kubeflow Pipelines Kubeflow Pipelinesは機械学習ワークフローを管理するためのツールで、類似ツールだとAirflowがあります。確かに今回の要件を満たすワークフローを作るだけであればAirflowで事足りるのですが、ここで推薦基盤チームにて抱えていた課題を振り返ります。 実験段階からプロダクションへの移行が容易であること 車輪の再発明をしないような仕組みであること 目の前のタスクを潰していくことを優先的に進めるとこうした課題の解決は後回しになっていき、やがて課題は大きく積み重なって後続のエンジニアの負債となります。モデル構築からパイプライン実装までを分離しない基盤であり、かつ一度作ったワークフローの構成要素を再利用する基盤を作ることの第一歩としてKubeflow Pipelinesを利用することにしました。 Kubeflow Pipelinesではコンテナ単位で機能を開発し、それを繋げて一連の処理を行うワークフロー(DAG)を構成します。ここで作ったコンテナは Component と呼ばれ、それを繋げたものを Pipeline と呼びます。 Componentはコンテナ化されているので、一度作ったComponentは様々な環境で使い回すことができます。汎用的なComponentはKubeflow PipelinesのGitHubリポジトリから利用できるため、どのようなComponentが提供されているかはそちらを参照ください。 github.com 例えば機械学習モデルで予測を行うワークフローは大まかには以下のステップに分解されます。 データ収集 前処理 推論 予測結果を返却 このワークフローをKubeflow Pipelinesで構築すると以下のようなDAGになります。 Kubeflow Pipelinesの運用環境 Kubeflow PipelinesはKubeflowの機能の1つなので、Kubernetes環境があればKubeflowをインストールして利用できます。GCPでKubeflow Pipelinesを利用するには以下の2つの方法があります。 GKEインスタンスを立てて自前でKubeflowをインストールする GCPのマネージドサービスであるAI Platform Pipelinesを使う GKEインスタンスにKubeflowをインストールしてセルフマネージすることで常にKubeflowの最新版をキャッチアップし続けられるというメリットがあります。しかし今回は環境構築と管理の手間を考え、マネージドなAI Platform Pipelinesを利用することにしました。 AI Platform Pipelines AI Platform PipelinesはGCPにおけるKubeflow Pipelinesのマネージドサービスであり、自分で一から環境構築することなくKubeflow Pipelinesを利用できます。GUIでの操作だけでGKEクラスタ作成からAI Platform Pipelinesインスタンスの立ち上げまでが自動的に行われます。なおGKEクラスタについては予め作成しておいたクラスタを指定することもできますが、 同一クラスタに対して複数のインスタンスを立ててはいけない ようです。 以下の図ではインスタンスが2つ存在していますが、実際にはkubeflow-pipelines-2のデプロイに失敗しています。 AI Platform Pipelinesは2020年3月よりベータ版がリリースされており、7月頃まではKubeflowのサポートバージョンがv0.5で止まっていましたが11月現在ではv1.0までサポートされています。v0.5まではKubeflow自体がツールとして成熟していなかったために挙動が不安定になることも多かったようですが、v1.0で運用している現状で変わった動きはあまり確認されていません。 Pipelineの設計・実装で意識したこと・ハマったこと 以下ではAI Platform Pipelines(+ Kubeflow Pipelines)を本番運用するにあたって意識したこと・ハマったことなどを紹介します。Kubeflow Pipelinesに関して「まずは使ってみた」という記事が多い中、実装や実運用面での知見は現状少ないので、これから本番導入を検討している方の一助になれば幸いです。 Pipeline内で日時情報を扱う 定期実行するバッチの場合、実行結果のログや成果物は日付や時間ごとでパーティションを切って保存することが一般的かと思います。例えばComponentの実行結果をあるGCSのバケットに保存する場合、以下のように日付を取得して全てのComponentに保存先を渡すことが考えられます。 # pipeline_1.py from datetime import datetime from kfp import dsl from kfp import components as comp from kfp.components import func_to_container_op @ func_to_container_op def thanks (message: str , gcs_output_path: str ): from myutils import upload_to_gcs # GCSにファイルをアップロードする関数 upload_to_gcs(message, gcs_output_path) @ dsl.pipeline def pipeline (text: str ): """ 毎日thanks.txtをGCSに出力する """ today = datetime.today().strftime( '%Y%m%d' ) thanks_path = f 'gs://my-gcp-project/my-bucket/{today}/thanks.txt' print (f '{thanks_path=}' ) thanks_task = thanks(message= 'byebye' , gcs_output_path=thanks_path) Pipelineにする必要性は全くないサンプルですがご了承ください。 一見正しく動きそうなプログラムですが、実際にはここで取得した today はこのpipeline_1.pyがコンパイルされた日付で固定されています。つまり今日(2020/11/13)にpipeline_1.pyをコンパイルして毎日実行した場合、print文の出力は以下のようになります。 # 2020/11/13に実行した結果 thanks_path = ' gs://my-gcp-project/my-bucket/20201113/thanks.txt ' # 2020/11/14に実行した結果 thanks_path = ' gs://my-gcp-project/my-bucket/20201113/thanks.txt ' # 2020/11/15に実行した結果 thanks_path = ' gs://my-gcp-project/my-bucket/20201113/thanks.txt ' ... today がコンパイルされた日(2020/11/13)で固定されているため、thanks.txtは常に gs://my-gcp-project/my-bucket/20201113 へ出力されることになります。ローカルでデバッグしながら開発していると常にPipelineをコンパイルしながら作業することになるため、この挙動に気付きにくいかもしれません(実際私はデブロイして定期実行の動作を確認している時に初めて気付きました)。 想定した挙動を得るためには以下のようにComponent内で時刻を取得する必要があります。Componentは実行の度にコンテナとして立ち上げられるため、コンテナが起動したタイミングの日付が得られるという算段です。 # pipeline_1_fix.py from kfp import dsl from kfp import components as comp from kfp.components import func_to_container_op from myutils import upload_to_gcs # GCSにファイルをアップロードする関数 @ func_to_container_op def thanks (message: str , gcs_output_path: str ): from myutils import upload_to_gcs # GCSにファイルをアップロードする関数 from datetime import datetime # 追加 today = datetime.today().strftime( '%Y%m%d' ) # 追加 thanks_path = f '{gcs_output_path}/{today}/thanks.txt' # 追加 print (f '{thanks_path=}' ) # 追加 upload_to_gcs(message, thanks_path) @ dsl.pipeline def pipeline (text: str ): """ 毎日thanks.txtをGCSに出力する """ gcs_path = f 'gs://my-gcp-project/my-bucket' thanks_task = thanks(message= 'byebye' , gcs_output_path=gcs_path) ただこの方法では「日付を取得して保存先として使う」という業務ロジックをComponentに含めるため、Component化することのメリットが失われることになります。またComponentの中を見ないとファイルの出力先がわからないという問題もあります。 ここで、Kubeflow Pipelinesのワークフロージョブエンジンとして使われているArgoでは Workflow Variables というPipeline内の様々なメタデータを参照できる変数があります。 例えば現在の処理を実行しているGKEのPod名を以下のように取得できます。 print ( "Current pod name: {}" .format({{ pod.name }})) 同様に、 workflow.creationTimestamp というvariableを使えば現在時刻をstringで取得できるため、この時刻から日付を抽出すれば解決! # pipeline_2.py ... @ func_to_container_op def thanks (message: str , gcs_output_path: str ): from myutils import upload_to_gcs # GCSにファイルをアップロードする関数 upload_to_gcs(message, gcs_output_path) @ dsl.pipeline def pipeline (text: str ): """ 毎日thanks.txtをGCSに出力する """ today = '{{workflow.creationTimestamp}}' # '2020-11-14 01:51:55 +0000 UTC' ymd = today.split( ' ' )[ 0 ] # '2020-11-14' が得られる想定 thanks_path = f 'gs://my-gcp-project/my-bucket/{ymd}/thanks.txt' print (f '{thanks_path=}' ) thanks_task = thanks(message= 'byebye' , gcs_output_path=thanks_path) と思いきや、このprint文の出力は以下のようになります。 thanks_path =gs://my-gcp-project/my-bucket/ {{ workflow.creationTimestamp }} /thanks.txt これはArgoによるWorkflow Variablesの置換がComponentの内部に入るタイミングで実行されるためです。 上記の例ではComponent内で gs://my-gcp-project/my-bucket/2020-11-14 01:51:55 +0000 UTC/thanks.txt と置換され、想定した挙動にはなりません。正しくは以下のように、あらかじめ年月日だけの形にしておく必要があります。 # pipeline_2_fix.py ... @ func_to_container_op def thanks (message: str , gcs_output_path: str ): from myutils import upload_to_gcs # GCSにファイルをアップロードする関数 print (f '{gcs_output_path=}' ) # 追加 upload_to_gcs(message, gcs_output_path) @ dsl.pipeline def pipeline (text: str ): """ 毎日thanks.txtをGCSに出力する """ today = '{{workflow.creationTimestamp.Y}}{{workflow.creationTimestamp.m}}{{workflow.creationTimestamp.d}}' # 変更 thanks_path = f 'gs://my-gcp-project/my-bucket/{today}/thanks.txt' print (f '{thanks_path=}' ) thanks_task = thanks(message= 'byebye' , gcs_output_path=thanks_path) ここで、各print文の出力は以下のようになります。 thanks_path = ' gs://my-gcp-project/my-bucket/{{workflow.creationTimestamp.Y}}{{workflow.creationTimestamp.m}}{{workflow.creationTimestamp.d}}/thanks.txt ' gcs_output_path = ' gs://my-gcp-project/my-bucket/20201114/thanks.txt ' 実際の運用ではComponentの出力先の初期化を行うためのComponentを作り、その内部で必要な日時を生成するようにしました。 # pipeline.py from typing import NamedTuple from kfp import dsl from kfp.components import func_to_container_op @ func_to_container_op def initialize (timestamp: str ) -> NamedTuple( 'Outputs' , [( 't_jst' , str ), ( 't_ymd' , str ), ( 't_hour' , str )]): from datetime import datetime, timezone, timedelta shift_hours = 9 # Create and shift timestamp JST = timezone(timedelta(hours=shift_hours), 'JST' ) jst_dt = datetime.strptime(timestamp, '%Y-%m-%d %H:00:00' ).astimezone(JST) t_jst = jst_dt.strftime( '%Y-%m-%d %H:00:00' ) t_ymd = f '{jst_dt.year:04}{jst_dt.month:02}{jst_dt.day:02}' t_h = f '{jst_dt.hour:02}' return (t_jst, t_ymd, t_h) @ dsl.pipeline (name= 'Prediction Pipeline' ) def pipeline (GCS_OUTPUT_DIR): init_task = ( initialize( timestamp= '{{workflow.creationTimestamp.Y}}-{{workflow.creationTimestamp.m}}-{{workflow.creationTimestamp.d}} {{workflow.creationTimestamp.H}}:00:00' ) .set_display_name( 'Initialize' ) ) # Define gcs path gcs_hourly_output_dir = f '{GCS_OUTPUT_DIR}/{init_task.outputs["t_ymd"]}/{init_task.outputs["t_hour"]}' ... 上記の例はタイムゾーンの変換をするためにinitializeというComponentを設けています。この例では datetime ライブラリを使うのと変わりありませんが、Argoの機能としてタイムゾーンの指定ができるようになればよりスマートにPipeline内で日時情報を扱うことができるでしょう。 https://argoproj.github.io/argo/variables/ argoproj.github.io Slack通知 定期実行しているPipelineが正常に稼働していることを監視するために、Kubeflow Pipelines SDK (kfp)の kfp.dsl.ExitHandler クラス(以下 ExitHandler )を利用しています。 ExitHandler はwithブロックから抜け出す際に実行するComponentを指定するものです。 https://kubeflow-pipelines.readthedocs.io/en/latest/source/kfp.dsl.html#kfp.dsl.ExitHandler kubeflow-pipelines.readthedocs.io 以下のようにSlack通知用のComponentを定義して ExitHandler の実行Componentに指定することで、Pipelineが異常終了した際にSlackで通知を飛ばします。ここではSlack通知のメッセージにRun名やKubeflow PipelinesのURLを記載するために、Workflow VariablesをComponentの引数として指定しています。 # pipeline.py @ dsl.pipeline () def pipeline (): with dsl.ExitHandler(exit_op=slack_notification_op( slack_webhook_url= "<<SLACK_URL>>" , status= "{{workflow.status}}" , job_name= "{{workflow.name}}" , pipelines_url= "<<KUBEFLOW_URL>>" + "/#/runs/details/" + "{{workflow.uid}}" ) ): init_task = ( initialize( timestamp= '{{workflow.creationTimestamp.Y}}-{{workflow.creationTimestamp.m}}-{{workflow.creationTimestamp.d}} {{workflow.creationTimestamp.H}}:00:00' ) .set_display_name( 'Initialize' ) ) ... ただしこの方法はPipelineの実行可否を通知するものであり、Component単位で通知ログを残すことはできません。こうしたログをSlackに通知するには、2020年11月現在ではComponent内部にSlack通知用の関数を埋め込むか、ログに何らかのタグを埋め込んでCloud LoggingとCloud Monitoringで拾うしか方法がないと思われます。 もし何か他にベストプラクティスをご存知の方がいらっしゃいましたら是非ご連絡をお願いします。 ノードプールによるリソースと権限の分離 AI Platform Pipelinesではインスタンス作成時に自動的にGKEクラスタも作成されると前述しましたが、予め用意しておいたGKEクラスタを指定することもできます。自動的に作成されるGKEクラスタには 123456789-compute@developer.gserviceaccount.com のようなCompute Engineのデフォルトサービスアカウント名が割り当てられ保守性に欠けるため、事前にサービスアカウントを作成しGKEクラスタに割り当てておくのが吉と言えます。 今回の案件で使用する特徴量にはユーザーの属性情報や回遊行動を用いるため、様々なBigQueryのテーブルを参照することになります。GKEクラスタと紐づけたサービスアカウントに必要な権限を全て付与しても良いのですが案件ごとに参照するテーブルやGCSのバケットは変わり得るため、権限は案件ごとに分けたいという思いがありました。 そこで今回は案件ごとに使用するノードプールを分けて、ノードプールに対してサービスアカウントを紐づける方法を取るようにしました。Kubeflow PipelinesではComponentごとに使用するノードを指定できるため、ハイメモリインスタンスを使う場合やGPUを使う時にも専用のノードプールに切り替えることができます。 init_task = ( initialize( timestamp= '{{workflow.creationTimestamp.Y}}-{{workflow.creationTimestamp.m}}-{{workflow.creationTimestamp.d}} {{workflow.creationTimestamp.H}}:00:00' ) .set_display_name( 'Initialize' ) .add_node_selector_constraint( 'cloud.google.com/gke-nodepool' , 'high-memory-pool' ) ) add_node_selector_constraint の第一引数にはノードラベルを指定し第二引数にはノード名を指定します。 cloud.google.com https://kubeflow-pipelines.readthedocs.io/en/latest/source/kfp.dsl.html#kfp.dsl.ContainerOp.add_node_selector_constraint kubeflow-pipelines.readthedocs.io Componentの実行に使うノードプールはノード数を0に指定してオートスケーリングするように設定することで、リソースを使わない時に余計なノードプールが残らないようにしました。 なお、以下のようにComponentが利用するノードプールのリソースサイズを指定して垂直スケールさせることもできます。権限管理を考えなければこちらの方がスマートにマシンリソースをスケールさせることができるでしょう。 # Using Large memory preprocess_task = preprocess_op(csv_files=csv_file_path).set_memory_request( "60G" ) https://kubeflow-pipelines.readthedocs.io/en/latest/source/kfp.dsl.html#kfp.dsl.Sidecar.set_memory_request kubeflow-pipelines.readthedocs.io CI/CDの実装 Kubeflow Pipelinesを使った継続的な開発を進めるために検討しなければいけないのがCI/CDの実現方法です。 Google Cloudの公式ドキュメントには Kubeflow Pipelinesに対するCI/CDワークフローのユースケース例 が提示されています。こちらのCI/CDワークフローではCloud Source RepositoriesやGitHubリポジトリに対してCloud Buildでビルドトリガーを設定し、ブランチにcommitやmergeが発生した時にCloud Buildで構築したワークフローを実行するというアーキテクチャになっています。 github.com 一方、推薦基盤チームでの製作物はGitHub ActionsでCI/CDを構築・管理する土壌ができていたため、今回もGitHub Actionsで完結させたいという思いがありました。 そこで調査を進めた結果、同様の課題を抱えた先人が上に示したワークフローをGitHub Actionsで作ってくれていました。 github.com こちらのGitHub Actionsを使うことでコンパイルされたpipeline.yamlファイルをKubeflow Pipelinesにデプロイできます。しかし、今回の要件として定期実行する必要があり、このGitHub ActionsではKubeflow Pipelinesの Recurring Run (cron実行機能)を利用することができませんでした。 追加機能としてPull Requestを出しても良かったのですがログを見る限りではあまりメンテナンスがされていないようだったので、必要な機能を参照しつつRecurring Run機能を追加実装することとしました。 実装内容としては単純で、以下のように recurring_flag というフラグを設けてフラグが立っている時にRecurring Runを登録するようにしました。 # github_actions.py def run_pipeline_func (client: kfp.Client, pipeline_name: str , pipeline_id: str , pipeline_paramters_path: dict , recurring_flag: bool = False , cron_exp: str = '' ): ... if recurring_flag: client.create_recurring_run(experiment_id=experiment_id, job_name=job_name, params=pipeline_params, pipeline_id=pipeline_id, cron_expression=cron_exp) client.run_pipeline(experiment_id=experiment_id, job_name=job_name, params=pipeline_params, pipeline_id=pipeline_id) ... このGitHub Actionsを用いて、最終的に以下のようなワークフローとなりました。 今回作成したGitHub ActionsをFork元にマージするか否かはまだ決めかねていますが、また別のテックブログ記事で使い方を含めて公開したいと思います! 今後の展望 今回はKubeflow導入の足掛かりとして、Kubeflow PipelinesのマネージドサービスであるAI Platform Pipelinesを用いたバッチ実行の予測パイプラインを構築しました。一方で、以下のように利用・検討しきれていないことも多々あります。 モデルの再学習を伴うPipelineの運用 :AI Platform Training + Vizier 学習済みモデルのオンラインサービング :AI Platform Prediction or GKEで自前ホスティング Feature Storeによる特徴量の管理 : Feast AI Platform Pipelinesインスタンスの管理単位 特にAI Platform Pipelinesインスタンスの管理については悩ましいものがあります。今後様々なMLモデルが増えるにつれて、GKEクラスタとAI Platform Pipelinesインスタンスはどのように立てていくのが良いのかという問題が生じます。 AI Platform Pipelines1つにつきGKEクラスタ1つを紐づけるように 公式ドキュメントには記載されている ため、AI Platform Pipelinesインスタンスを立てるにつれてGKEクラスタも増え、コストの増加に繋がってしまいます。またGCPプロジェクトごとにインスタンスを立てるとなると、コストだけでなく管理面でも複雑になり得ます。 この問題の対応については現在SREチームと共に、GKEクラスタに対して自前でKubeflow環境を構築することも視野に入れながら模索中という段階です。 ML基盤として目指す姿 最後に、推薦基盤チームで目指すMLプロダクト管理基盤の全体像について簡単に述べておきます。まだ構想段階ではありますが、大まかには以下のようなアーキテクチャを考えています。 基本的にはGCPのマネージドサービスを積極的に取り入れていくことを考えています。 この中でも特にポイントとなるのは今回ご紹介したワークフロー実行基盤であるKubeflow Pipelinesに加え、特徴量の管理基盤である Feast 、そして成果物を一元管理する AI Hub でしょう。 FeastはKubeflowにてα版でサポートされているFeature Storeです。 www.featurestore.org Feature Storeはモデル構築・運用で使用している特徴量を管理するための基盤の総称で、2017年にUberで使われている Michelangelo というML基盤の紹介記事が初出のようです。 2019 Slides - Michelangelo Palette: A Feature Engineering Platform at Uber from Karthik Murugesan www.slideshare.net モデルを作成する際に必要となる特徴量はFeature Storeから取得し、モデルサービング時にも同様にFeature Storeから同様の特徴量を使用することでモデル構築とサーブ時で使用する特徴量の齟齬を解消できます。AI Platformでも近いうちにFeature Store機能が追加されていくようなので、今後のML基盤の構成要素としてスタンダードなものになっていきそうです。 AI Hub はプロジェクトでの成果物(モデル、Pipeline、Componentなど)や分析結果(ノートブック、クエリなど)といったアセットを一元管理し再利用できるようにする大きな箱、というイメージです。AI Hubでは社内で利用するプライベートなPipelineや学習済みモデルの管理に加えて、パブリックなPipelineやNotebookを探すこともできます。また、登録するアセットの種類やカテゴリ、データ種別でラベル付けをすることで必要なアセットを容易に検索できます。 プロジェクトの成果物や分析結果、各種アルゴリズムの検証結果などをAI Hubで統合管理し組織のナレッジベース化することで、推薦基盤チームが抱えている課題の一つである「 車輪の再発明をしないような仕組み 」を実現できると考えています。 一方、現状AI Platformの各種サービスはベータ版での提供のものが多いため本番環境への導入を慎重に検討しつつ、ZOZOTOWNで利用されるMLモデルの管理・運用基盤の構築を進めていきます! おわりに 本記事ではKubeflow PipelinesのマネージドサービスであるAI Platform Pipelinesの紹介と本番環境への導入に際して直面した問題について述べました。推薦基盤チームではZOZOTOWNで運用する推薦システムをより良くできるように、日々新しい技術のキャッチアップをして様々な可能性を模索していきたいと考えています。 ZOZOテクノロジーズではZOZOTOWNの推薦システム構築・運用に興味のある方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! https://tech.zozo.com/recruit/mid-career/ tech.zozo.com 参考 Cloud Composerで組む機械学習パイプライン MLSE 機械学習基盤 本番適用と運用の事例・知見共有会 Kubeflow Pipelinesで日本語テキスト分類の実験管理 Machine Learning Pipelines with Kubeflow How to carry out CI/CD in Machine Learning (“MLOps”) using Kubeflow ML pipelines (#3) ML Feature Stores: A Casual Tour Michelangelo Palette: A Feature Engineering Platform at Uber Feature Store: The Missing Data Layer in ML Pipelines? Feast: feature store for Machine Learning pachyderm/kfdata Prototype implementation of KFData Proposal Introducing AI Hub and Kubeflow Pipelines: Making AI simpler, faster, and more useful for businesses
はじめに こんにちは。SRE部MLOpsチームの田島( @tap1ma )です。 現在、ZOZOTOWNの「おすすめアイテム」に使われていたアイテム推薦ロジックを刷新するプロジェクトを進めています。既に一部のユーザに向けて新しいアイテム推薦ロジックを使った「おすすめアイテム」の配信を開始しています。その刷新に伴い推薦システムのインフラ基盤から新しく構築したので、本記事ではその基盤について解説したいと思います。 目次 はじめに 目次 「おすすめアイテム」とは 新しい推薦ロジック Recommendations AIを用いた推薦ロジック ZOZO研究所によって独自で開発された推薦ロジック 新しい推薦システム 推薦システムの処理の流れ システム構成 新しい推薦システムで工夫したポイント Bigtableのパフォーマンス改善 アイテム推薦APIのPodの安全停止 ZOZO研究所APIのキャッシュ戦略 推薦ロジックのモデル更新時のワークフロー 推薦ロジックのモデル更新時のワークフローで工夫したポイント Cloud Runの選定 Cloud Pub/Subのat-least-once配信の考慮 Cloud Monitoringを使用したGKE Jobの監視設定 JobのPodステータスがFailedになった時のアラート設定 長時間経過してもJobのPodステータスがSucceededにならない時のアラート設定 まとめ 最後に 「おすすめアイテム」とは この記事で扱う「おすすめアイテム」とは、ZOZOTOWNで取り扱っている各アイテムの詳細ページ内にある「おすすめアイテム」枠のことです。アイテム詳細ページのアイテムやそのページを閲覧しているユーザに合わせて、おすすめのアイテムを複数表示しています。 アイテムの詳細ページは、アイテムの詳細な説明やサイズ毎の在庫状況、商品画像といった情報を含み、ユーザがアイテム購入時に必ず通る重要なページです。そして、アイテム詳細ページ内に設置された「おすすめアイテム」枠もまたZOZOTOWNの重要な要素の1つです。 しかし、これまで使われていた推薦ロジックは10年以上前に開発されたもので、ストアドプロシージャとしてオンプレミスのSQL Serverに保存されているなど非常にレガシー化したシステムの上で動いていました。そのため、大きな技術的負債となっていました。 この度、より高性能な推薦ロジックの導入とそのためのシステムをインフラ基盤から新しく構築することで、推薦ロジックの性能向上と推薦システムの技術的負債の回収を同時に実現できました。 新しい推薦ロジック 推薦ロジックの刷新に際し、以下の2種類の推薦ロジックを開発しました。 Recommendations AIを用いた推薦ロジック ZOZO研究所によって独自で開発された推薦ロジック 2種類の推薦ロジックの開発を並行で行い、互いに性能を競わせながら、より高性能な推薦ロジックの実現を目指して日々開発に取り組んでいます。 Recommendations AIを用いた推薦ロジック Recommdendations AI はECサイトに特化し、ユーザにパーソナライズされた商品の推薦システムを機械学習の高度な知識を必要とせずに簡単に構築できるGCPのフルマネージドサービス(本稿執筆時点でベータ版)です。 推薦ロジックのモデル構築に必要なデータを入力することで推薦ロジックの機械学習モデルの構築から、そのモデルを使って商品の推薦結果を返す推論用のWeb APIのサービングまで自動で行ってくれます。 また、データの入力に対してリアルタイムでモデルを更新できること、推論用のWeb APIがスケーラブルであることといった特徴を持ちます。 詳しくは こちらの資料 をご覧ください。 ZOZO研究所によって独自で開発された推薦ロジック 弊社が有する研究機関「 ZOZO研究所 」によって独自で開発された推薦ロジックです。 現在は、 ランダムウォーク のアルゴリズムをベースとし、高速な推論速度、アイテムのカテゴリ分布の調整が可能(=推薦アイテムの多様性を制御できる)、などの特徴を持つ推薦ロジックとなっています。 手法の詳細は本記事では割愛しますが、現在もより高性能な推薦ロジックの実現を目指して様々な手法を用いた開発が進められています。 新しい推薦システム 本章では、ユーザがアイテムの詳細ページへアクセスした際に詳細ページの「おすすめアイテム」枠に最適なアイテムを選出する新しい推薦システムについて詳しく解説します。 推薦システムの処理の流れ 新しい推薦ロジックを使った推薦システムはGCP上でVPCから新規で構築しました。 以下が新しい推薦システムのシステム構成の概略図です。 推薦システムはAWS上に存在するZOZOTOWNのバックエンドAPIからアクセスされ、推薦結果のアイテム情報をリストで返します。AWS→GCPのプライベート接続には、AWSでは Direct Connect 、GCPでは Dedicated Interconnect という専用線サービスを使用しオンプレ経由での専用線接続を行うことで高可用・低レイテンシーな通信を実現しています。 ZOZOTOWNバックエンドAPIからきたリクエストを推薦システムが処理して推薦結果となるアイテムのリストを返すまでの流れを、図の番号に沿って説明します。なお、登場するコンポーネントの説明は後述します。 ユーザがアイテムの詳細ページを開いた時に非同期でZOZOTOWNバックエンドAPIはアイテム推薦API宛にGETリクエストを投げます。 Internal Load BalancerはZOZOTOWNバックエンドAPIからのリクエストをアイテム推薦APIに振り分けます。 アイテム推薦APIはまずRecommendations AI APIまたはZOZO研究所APIに対してリクエストを投げて、「おすすめアイテム」枠に表示すべきアイテムIDのリストを取得します。 アイテム推薦APIは3.で取得したアイテムIDのリストに対してRedisにアクセスし、キャッシュヒットした場合は、取得したアイテムの詳細情報を推薦結果のアイテムIDのリストに付加します。 Redisアクセス時にキャッシュヒットしなかった場合は、Bigtableへアクセスし取得したアイテムの詳細情報を推薦結果のアイテムIDのリストに付加します。 Bigtableから取得したアイテムの詳細情報をRedisにキャッシュさせた後、推薦結果のアイテムIDのリストに付加します。 推薦結果のアイテム情報のリストをZOZOTOWNバックエンドAPIへ返します。 システム構成 以下、図の各コンポーネントを解説します。 Internal Load Balancer ZOZOTOWNのバックエンドAPIからのアクセスを捌くL7ロードバランサーです。今回GKE(Google Kubernetes Engine)クラスタを新規で構築し、後述する「アイテム推薦API」及び「ZOZO研究所API」のサーバーをGKEクラスタのPod上で稼働させています。このGKEクラスタはプライベートネットワーク内に閉じているため、GKEクラスタの受け口にL7内部負荷分散を立てる必要がありました。GKEのバージョン1.16.5-gke.10からGCPのL7内部負荷分散に対応した Ingress for Internal HTTP(S) Load Balancing が使用できるようになったので、今回初めて採用しました。 アイテム推薦API アイテム推薦APIはZOZOTOWNバックエンドAPIからInternal Load Balancer経由で届いたリクエストに対して、最終的な推薦結果となるアイテムをリストで返すAPIサーバーです。ZOZOTOWNバックエンドAPIから届くリクエストのパラメータには閲覧しているアイテムの情報とユーザの情報、推薦結果として取得したいアイテムの件数が含まれています。Java製フレームワーク Spring Boot を用いて作られており、GKEのPod上で稼働しています。 ZOZO研究所API ZOZO研究所が開発した推薦ロジックを用いた推論用のAPIサーバです。アイテム推薦APIからきたリクエストに対してそのアイテムの詳細ページの「おすすめアイテム」枠に最適なアイテムを推論し、アイテムIDのリストを返します。Python製フレームワークのFlaskを用いて作られており、GKEのPod上で稼働しています。 Recommendations AI API GCPのRecommendations AI上で構築したアイテム推薦の推薦ロジックの機械学習モデルによる推論を行うWeb APIです。ZOZO研究所API同様、アイテム推薦APIからきたリクエストに対して推論したアイテムIDのリストを返します。 アイテムデータベース ZOZOTOWNの最新のアイテム情報のデータが格納されているデータベースで、GCPのフルマネージドな大規模分散データベースであるCloud Bigtableを使用しています。Cloud Pub/Sub経由で送られてくるアイテムのデータの更新情報がリアルタイムで反映されるので、常に最新のアイテムデータが格納されています。 アイテム情報キャッシュ GCPのフルマネージドなRedisサービスを利用しています。Bigtableへの負荷軽減のためにアイテム推薦APIがBigtableから取得したアイテムの情報は一定期間Redisにキャッシュさせています。 新しい推薦システムで工夫したポイント 以下、工夫した点についていくつか紹介します。 Bigtableのパフォーマンス改善 ZOZOTOWNの最新のアイテムの情報を格納しておくデータベースとして以下の要件から高スループットかつ低レイテンシーなGCPの Cloud Bigtable を採用しました。 大量の書き込みに耐えられる 高速な応答性能を持つ推薦システムを実現できる しかし、実際にアイテム推薦APIからBigtableへアクセスしてみると期待通りの応答性能が出なかったため、パフォーマンスチューニングを行い応答性能を改善しました。ここでは、実際に行ったチューニング方法について説明します。 前提として、アイテム推薦APIは以下のような実装となっていました。 アイテム推薦APIではJava製のBigtableクライアントライブラリ bigtable-hbase-2.x のバージョン1.12.0を使用しています。 アイテム推薦APIからBigtableへのアクセスは全て Multi-Get という参照系の操作です。 調査したところ、上記のBigtableクライアントライブラリによるBigtableへのアクセスエラー発生時のリトライ処理は以下のような挙動をしていることが分かりました。 BIGTABLE_RPC_TIMEOUT_MS_KEY で設定されたタイムアウト時間(ミリ秒)を迎えるまでリトライ処理を繰り返す。 参照系処理の場合はタイムアウト後に MAX_SCAN_TIMEOUT_RETRIES の回数だけ更にリトライ処理が走る。なお、ドキュメントに記載は見当たりませんが、実装を確認するとscanだけではなくgetの処理においてもMAX_SCAN_TIMEOUT_RETRIESの回数分リトライ処理が走ることが分かっています。 今回、アイテム推薦システムのタイムアウト時間の要件は5秒であったため、元々の設定であった設定値Aとタイムアウト時間を減らしてその分タイムアウト後のリトライ回数を増やした設定値B、2つの設定値でアイテム推薦APIに負荷をかけてBigtableへのアクセス時の応答性能を測定しました。 実験の結果、設定値Bの応答速度は設定値Aに比べて99パーセンタイル値の比較で平均約29%速いことが分かりました。つまり、応答が想定時間で返ってこない場合はそのまま待つよりも再度リクエストを投げた方がより速く応答を返しやすいようです。 以上の結果を踏まえてBの設定値となるように更新し、パフォーマンスを改善できました。 アイテム推薦APIのPodの安全停止 アイテム推薦APIのデプロイ時にPodがローリングアップデートされた際、Ingress for Internal HTTP(S) Load Balancing(以下、Ingress)でステータスコード503のエラーが出る事象が発生しました。調査したところ、原因はGKEのIngressの コネクションドレインのタイムアウト時間 がデフォルトの0秒であったためコネクションドレインが機能せずリクエスト処理の途中でコネクションが切られてしまっていたからでした。そこで、適切なコネクションドレインのタイムアウト時間を設定したのですが、ここではその設定の際に考えたことについて説明します。 PodがIngressから登録解除されて停止する際にPodでは preStop フックの実行処理が、Ingressではコネクションドレインの処理が同時に非同期で実行されます。そして、PodではpreStopフックの実行終了後にコンテナのルートプロセスに対してSIGTERMが送られ、サーバーがGraceful Shutdownされます。実際に計測してみるとアイテム推薦APIのpreStopフックの処理が始まって10〜15秒後にコネクションドレインの処理が始まっていることが分かりました。そのため、まずはpreStopフックでは15秒のsleep処理を走らすことで、PodがIngressから登録解除されてコネクションドレイン処理開始する前にサーバーのGraceful Shutdownが始まらないように調整しました。また、サーバーのGraceful Shutdownに要する時間は約20秒だったので、この場合のコネクションドレインのタイムアウト時間はPodのpreStopフックの開始から正常にGraceful Shutdownされるまでの時間である(15秒+20秒=)35秒以上に設定すべきであることが分かります。実際には少し余裕を持ってコネクションドレインのタイムアウト時間として45秒を設定しました。 ZOZO研究所APIのキャッシュ戦略 ZOZO研究所APIに対して負荷試験を行ったところ、クエリとなるアイテムIDによって応答速度に大きなばらつきがあり、CPUスパイクも頻繁に起こしていました。 調査したところ、以下のことが判明しました。 推薦ロジックのアルゴリズムの性質上、アイテム詳細ページのアクセス数が多いアイテムほど推論の計算コストが高くなるため、推論速度が遅い。 アイテム詳細ページのアクセス数はアイテム毎に大きな偏りがある。 上記の特性を踏まえて、アクセス数上位のアイテムに関しては推論結果をキャッシュするようにしました。ZOZO研究所APIのPod起動時に推論を行い、サーバーのメモリ上に推論結果を展開しています。キャッシュしたアイテムの数は総アイテム数のわずか0.2%ですが、システムパフォーマンスが大幅に向上し、観測されていたCPUスパイクも起きなくなり安定化できました。 推薦ロジックのモデル更新時のワークフロー ZOZO研究所製の推薦ロジックに使用するモデルの更新は毎日1回行われています。 ここでは、そのモデル更新時のワークフローを解説したいと思います。以下が、そのワークフローの概略図です。 Cloud Composer とBigQueryで日次集計された最新のアイテムデータを読み込み、Pythonスクリプトによって推薦ロジックのモデルファイルを生成します。この日次集計処理は今回のプロジェクト以前から運用されていて、かつ、異なるGCPプロジェクトで存在していました。そのため集計完了の通知をCloud Pub/Subで受け取るようにし、本プロジェクトのモデルファイル生成Jobを実行するトリガーとしています。Cloud RunはCloud Pub/Subからメッセージを受け取りGKEのJobを実行するトリガーとしてのみ利用しています。なお、Cloud Run・GKEのJobの選定に関しては後述します。 モデル更新時の流れを図の番号に対応する手順で説明します。 Cloud Composerの日次集計で最新のアイテムデータをBigQueryに保存後、集計完了を意味するメッセージをCloud Pub/Subにパブリッシュします。 Cloud Pub/SubはCloud Composerから飛んできたメッセージをトリガーにCloud Runのエンドポイントを叩きます。 Cloud Runのエンドポイントが叩かれるとCloud Runではモデル作成用のPythonスクリプトを実行するGKEのJobをアイテム推薦APIと同じGKEクラスタ上で作成します。Cloud RunではGoで書かれたAPIサーバが動いており、そのAPIサーバのエンドポイントが叩かれるとモデル作成用のPythonスクリプトを実行するJobをアイテム推薦APIと同じGKEクラスタ上に作成します。Goの選定理由はKubernetes APIアクセス時に使用するGo言語用のKubernetesクライアントライブラリ client-go が他の言語用のクライアントライブラリに比べて開発が活発で継続的にメンテナンスされることが期待できるためです。 GKEのJobではモデル構築用のPythonスクリプトを実行してBigQueryから最新のアイテムデータを読み込んでモデルファイルを作成し、Cloud Storageにアップロードします。 GKEのJobはCloud Storageにモデルファイルをアップロード後、最後にZOZO研究所APIのPodを kubectl rollout restart コマンドによって再起動させて処理が終了します。 ZOZO研究所APIのPodは再起動時に新しいモデルファイルをCloud Storageからダウンロードし、推論時にそのモデルを使用するようになります。 推薦ロジックのモデル更新時のワークフローで工夫したポイント 以下、推薦ロジックのモデル更新時のワークフローで工夫した点についていくつか紹介します。 Cloud Runの選定 前述の通り、既存のCloud Composerとは別のGCPプロジェクトでモデル更新のジョブを実行する必要がありました。そこで、Cloud Composerの日次集計のワークフローの最後にCloud Pub/Subへ通知を送り、その通知をトリガーに別のGCPプロジェクトでモデル更新のジョブを実行する設計としました。 1日1回Cloud Pub/SubからくるPOSTリクエストをトリガーにワークロードを実行する用途として、リクエストが実際に処理されている時間のみ課金が発生する以下の3つのサーバレスソリューションを検討しました。 App Engine(スタンダード環境) Cloud Functions Cloud Run ただし、現在弊チームでは Anthos 環境を運用していないので、Cloud Runの場合は Cloud Run for Anthos on Google Cloud ではなくフルマネージド版のCloud Runのみに限ります。 また、これらのメモリの上限値は以下の通りです。 App Engine(スタンダード環境) Cloud Functions Cloud Run(フルマネージド) メモリ上限 2Gi 2Gi 4Gi モデル更新ジョブのメモリ消費量は上記のどのソリューションにおいてもそのメモリ上限値を超えてしまうので、モデル更新処理をそれだけで完結することはできません。そこで、モデル更新ジョブをアイテム推薦APIのPodなどが稼働しているGKEクラスタ内のハイメモリなインスタンス上でKubernetes Jobとして実行することにし、そのJobの作成処理を上記のサーバレスソリューションのいずれかで実行することにしました。理想を言うと、Cloud Pub/Subへの通知をトリガーとして直接GKEのJobを作成できるようなソリューションがあったら嬉しいですね。 検討の末、他の2つに比べてワークロードのランタイムに縛りがなく、Dockerfileで管理できる開発のしやすさの点で優れたCloud Runをチームで今回初めて採用しました。また、Cloud Runでは、Cloud Pub/Subからきたリクエストの トークン認証処理 を組み込みでサポートしているので、簡単な設定でCloud Pub/SubとCloud Run間を安全に通信することができます。GCPの公式ドキュメントには記載が見当たりませんでしたが、Cloud RunとCloud Pub/Subが異なるGCPプロジェクトに存在しているケースでもトークン認証処理を利用することができます。便利。 Cloud Pub/Subのat-least-once配信の考慮 Cloud Pub/Subはat-least-once配信方式を採用している ため、同一のメッセージが複数回配信される可能性があります。そのため、たとえCloud ComposerからCloud Pub/Subへのメッセージのパブリッシュは1日1件であっても、そのメッセージが複数回配信されてCloud Runのエンドポイントが1日に複数回叩かれてしまう場合を考慮しないといけません。 まず、Cloud Runで日に複数回GKEのJobの作成処理が実行される可能性があるため、GKEのJobの処理が冪等である必要があります。今回GKEのJobで実行するモデル作成処理は冪等であるため、この要件は満たしていました。 また、Cloud RunによるGKEのリソースへの操作が並列で行われる可能性も考慮する必要があります。Cloud Runの処理ではGKEのJobを作成するためにKubernetes APIを使ってGKEのリソースを操作します。実際の処理ではJobの作成処理だけでなく、昨日分のJobの設定をクリーンアップしたりJob作成時に立ち上がったPodの情報を取得したりと、Cloud Runのエンドポイントへのリクエスト毎に複数回Kubernetes APIへのアクセスが発生します。もしCloud Pub/Subからほぼ同時に複数のリクエストがきた場合、これらのKubernetes APIへの操作が並列で実行されるため、Jobの作成処理が同時に2度実行されてしまい片方の処理がエラーになるといった問題に繋がる可能性があります。一方で、Kubernetes APIを使った操作は非同期なこともあり、このようなエッジケースにも対応した並行性制御を実装するのはなかなか大変です。そこで、複数リクエストが同時にきてもCloud Runではリクエストを並列では処理しないようにすることでGKEのリソースへの一連の操作を排他制御するようにしました。具体的には、Cloud Runではコンテナあたりの最大同時リクエスト数とスケールアウト時の最大コンテナインスタンス数を簡単に設定できるので、どちらも最大値を1とすることで複数リクエストを並列で処理しないようにしました。 Cloud Monitoringを使用したGKE Jobの監視設定 GKEのJobの監視を導入時にいくつか躓いた点があるので、それらの点も踏まえてどのような設定をしたのかをここでは説明したいと思います。 以下の2種類の監視が現在設定されています。 JobのPodステータスがFailedになった時のアラート設定 長時間経ってもJobのPodステータスがSucceededにならない時のアラート設定 JobのPodステータスがFailedになった時のアラート設定 当初はJobが異常終了した時、すなわち、Jobが作成したPodのステータスがFailedとなった時にアラートが鳴るようにCloud Monitoringで監視を導入しようと試みました。しかし、Cloud MonitoringではPodのFailedステータスを直接カウントしアラートのトリガーとするような設定方法はサポートされておりませんでした。そこで、代わりにPod内のコンテナの再起動回数であるrestart countをトリガーに使用できることを利用し、Pod内のコンテナの異常終了後の再起動処理が発生( restart count > 0 )したらアラートが鳴るように設定しました。ただし、このやり方は以下の点において理想的な監視とは言えない妥協策です。 JobのrestartPolicyがOnFailureに設定されている場合においてのみ使える方法である Podの失敗ステータスを直接監視できておらず、間接的な監視となってしまっている もっと適切な方法ご存知の方いましたら教えてください! 長時間経過してもJobのPodステータスがSucceededにならない時のアラート設定 Jobが失敗した時の監視だけでは以下のような異常時のケースを拾うことができません。 そもそもJobが実行されていない場合 Jobの処理途中になんらかの理由でスタックしている場合 上記のケースが発生した際にアラートが飛ぶように、日次で実行されるJobがその日のうちに正常終了していない時にアラートがなるような監視も導入しました。しかし、Cloud MonitoringではPodのFailedステータスと同様Succeededステータスを直接カウントしアラートのトリガーとするような設定方法もサポートされておりませんでした。また、Cloud Monitoringでは24時間周期の監視もサポートされていませんでした。そこで、妥協策として以下の手順で監視を設定しました。 Jobの処理の最後にJobの成功を意味するログをコンテナログとして出力するようにする。 GKEのCronJobで毎日定時に直近24時間でCloud Loggingへ出力されたJobのコンテナログからJobの成功を意味するログを検索する。もし成功ログが見つからなかった場合は、エラーログをCronJobのコンテナログに吐くようにする。 Cloud MonitoringでCronJobが吐いたエラーログをトリガーとしてアラートを鳴らすように設定する。 このやり方もPodの成功ステータスを直接監視できていない複雑な設計となってしまっています。もっと適切な方法ご存知の方いましたら教えてください!(2回目) まとめ 本記事ではZOZOTOWNの「おすすめアイテム」枠に使われている新しい推薦システム基盤のアーキテクチャについて解説しました。私が今年入社して最初に取り組んだプロジェクトでしたが、ネットワーク設計から任せてもらい、個人的に思い入れの深いプロジェクトです。ユーザの皆さんが気に入るアイテムをより簡単に見つけやすくできるように引き続き改善に取り組んでいきます。 最後に SRE部MLOpsチームでは、データや機械学習を用いてサービスを成長させたいエンジニアを募集しています。ご興味のある方は、以下のリンクからぜひご応募ください! www.wantedly.com
こんにちは、ZOZOテクノロジーズ CTO室の池田( @ikenyal )です。 ZOZOテクノロジーズでは、11/5に ZOZO Technologies Meetup〜ZOZOTOWNシステムリプレイスの裏側〜 を開催しました。 zozotech-inc.connpass.com 本イベントでは、ZOZOテクノロジーズがどのようにリプレイスを進めているかをお伝えするイベントで、AWS・Kubernetes・GitHub Actions・Go・ElastiCacheなどをどのように活用しているかをお伝えしました。 登壇内容 まとめ 弊社の社員4名が登壇しました。 ZOZOTOWNリプレイス2020 (SRE部 ECプラットフォーム 瀬尾 直利 / @sonots ) ZOZOTOWNリプレイスにおけるSREの取り組み (SRE部 ECプラットフォーム 高塚 大暉) API Gatewayによるマイクロサービスへのアクセス制御 (ECプラットフォーム部 API基盤チーム 竹中 達志) ZOZOTOWNにおけるキャッシュストアのAWSへのリプレイス (ECプラットフォーム部 マイグレーションチーム 濱砂 晶) 最後に ZOZOテクノロジーズでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
はじめに ZOZO研究所ディレクターの松谷です。 ZOZO研究所では、イェール大学の成田悠輔氏、東京工業大学の齋藤優太氏らとの共同プロジェクトとして機械学習に基づいて作られた意思決定の性能をオフライン評価するためのOff-Policy Evaluation(OPE)に関する共同研究とバンディットアルゴリズムの社会実装に取り組んでいます( 共同研究に関するプレスリリース )。また取り組みの一環としてOPEの研究に適した大規模データセット( Open Bandit Dataset )とOSS( Open Bandit Pipeline )を公開しています。これらのオープンリソースの詳細は、 こちらのブログ記事 にまとめています。 techblog.zozo.com 本記事では、ZOZO研究所で社会実装を行ったバンディットアルゴリズムを活用した推薦システムの構成について解説します。バンディットアルゴリズムを用いた推薦システムの構成と実際にどのように活用されたかについて、また公開されたデータセットがどのようなシステムで収集されたか、みなさまの参考になれば幸いです。 本システムの設計・開発と本記事のシステム概要解説については研究所アドバイザーの粟飯原が担当しています。 バンディットアルゴリズムとは まず本プロジェクトのフォーカスであるバンディットアルゴリズムについて解説します。 アルゴリズムについて 環境に対する不完全な事前知識を活用して行動し、環境を観測してデータを集めながら最適な行動を発見する(探索と活用)アルゴリズムがバンディットアルゴリズムです。システム自身が試行錯誤しながら最適なシステム制御を実現する機械学習手法である強化学習の中で、代表的なアルゴリズムのひとつです。 バンディットアルゴリズムが扱う問題設定は「複数の選択肢または介入からできるだけ良いものを選択したい」というものです。例えば、推薦する商品Aと商品Bのどちらがより顧客にクリックされるか、新薬Aと新薬Bのどちらがある病気を効果的に治癒するのかというような状況です。この問題の難しさは、良い選択肢をできるだけ多く選択したいという活用と、とるべき選択肢が何であるかをできるだけ正確に知りたいという探索のトレードオフがあることによります。 このような状況で選択するためによく用いられるのは、A/Bテストと呼ばれる手法です。AとBの2つの介入をランダムに割り当ててどちらが平均的に優れているのかを統計的仮説検定に基づいて選択します。しかし典型的なA/Bテストでは純粋な探索を一定期間行ってから純粋な活用を行うため、無駄な探索をしてしまったり誤った活用をしてしまうリスクがあります。 バンディットアルゴリズムは過去の経験に基づく予測の活用と探索のトレードオフをデータから最適化し、累積報酬(例えばクリック数)を最大化するように意思決定を行います。 Web広告配信や推薦システムでよく利用されており、またトップ棋士に勝ち越したことで有名なAlphaGo(アルファ碁)にもその技術が応用されています。 Multi-Armed Bandit Algorithm 強化学習において状態が変化しない最も単純な設定です。アーム(選択肢)を引くとスロットマシンがある確率に基づいて報酬が得られるという設定のもと、行動の主体であるエージェントは腕を引くという行動だけ行います。これは、例えば商品アイテムの推薦においては実際に推薦結果として提示する商品アイテムの選択になります。 Contextual Bandit Algorithm バンディットアルゴリズムは拡張によりユーザーの属性に合わせパーソナライズを扱うことが可能です。ユーザー属性/履歴などの埋め込みベクトルを用い、ユーザーそれぞれに最適な行動(何を表示するか)を決定します。どのようにパーソナライズするかは、コンテキストの設計により調整が可能です。実際の応用としては、Spotifyホーム画面のパーソナライズやNetflixのアートワークパーソナライズなど、推薦に力を入れているサービスにおいて利用されている例が挙げられます。 ユースケース 例1)良いクリエイティブの選択 バンディットアルゴリズムは複数ある商品画像のうちどのクリエイティブを表示するかなどにも利用されています。よりクリックされやすいクリエィティブに自動で寄せるなど、無駄なインプレッションを減らしつつKPIの向上を目指すことが可能です。 例2)良い推薦アイテムの選択 バンディットアルゴリズムはEコマースなどの推薦において、対象アイテムを絞り込んだ後のリランキングなどに使用されることもあります。通常のランキング集計よりも新しいアイテムなどの追加にも早く対応でき、機会損失を減らしつつコンバージョンなどKPIの改善を目指すことが可能となります。 ZOZOTOWNにおけるバンディットアルゴリズムを用いた推薦 ZOZO研究所では、多腕バンディットアルゴリズム(Multi-Armed Bandit Algorithm)を用い、数あるファッションアイテムの中からユーザーごとに適したアイテムを推薦するシステムを開発し、ZOZOTOWNのトップページにおいて実際に配信を実施しました。 図1:バンディットを用いたZOZOTOWNにおけるファッションアイテムの推薦 トップページ来訪ユーザーに対してRandomまたはBernoulli Thompson Sampling(BernoulliTS)という2種類の意思決定policyを振り分けて適用しています。 ユーザーの属性に合わせた商品の推薦 コンテキストを合わせて選択するように拡張することで(Contextual Bandit Algorithm)、ユーザーの属性に合わせた商品のパーソナライズ推薦も可能です。ZOZOTOWNではユーザーひとりひとりに、より価値のあるサイト上での発見・経験を提供するべく、推薦や検索結果のパーソナライズをすすめています。本プロジェクトの理論部分の成果でもあるオフライン評価の手法(OPE)を用いることで、どのような特徴量を利用すればよいのか効率的に提案することが可能となります。ZOZO研究所では開発したパイプラインを用いて、効率よくどのようなパーソナライズを実際のサービスで用いるべきかを評価・比較して提案につなげています。 本共同研究プロジェクトの取り組み ZOZO研究所では、 機械学習による予測値などに基づいて作られる意思決定policyの性能を評価する 手法であるOPEに関しての研究を進めています。 機械学習は予測のための技術として広く利用されていますが、実際の応用場面に目を向けてみると、予測値をそのまま使うのではなく予測値に基づいて何かしらの意思決定を行うことが目的である場合が多くあります。 例えばクリック率の予測値に基づいてユーザーごとにどのアイテムを推薦すべきか選択する場合、予測そのものよりも、それに基づいて作られる 推薦や広告配信などの意思決定 が重要です。従って、評価自体もクリック率の予測精度よりも最終的な意思決定policy自体の性能を直接評価する方か適切と言えます。 意思決定policyの性能評価において、実際にサービスへ実装しKPIの挙動を確認するオンライン実験には大きな実装コストやユーザー体験の毀損・KPIへのマイナス影響など大きなリスクを伴うため、オフラインで同様に性能を評価する手法が模索されてきました。 この、新たな意思決定policyの性能を過去の蓄積データを用いて推定する問題のことをOPEと呼びます。 NetflixやSpotify、Criteoなどの研究所がこぞってトップ国際会議でOPEに関する論文を発表しており、特にテック企業から大きな注目を集めています。 正確なOPEは多くの実務的メリットをもたらします。例えば現行の推薦ロジックとは異なる新たな推薦ロジック候補がもたらすKPIの値を既にあるデータを用いて見積もることができます。ハイパーパラメータや機械学習アルゴリズムの組み合わせを変えることによって多数生成される候補のうち、どれをオンライン実験に回すべきなのかを事前に絞り込むこともできます。これにより、実装コストやリスクを抑えつつ、より効率的なビジネス・サービス改善が可能となります。 本共同研究プロジェクトではOPEの実証研究と実サービスへの組み込みを目的とし、バンディットアルゴリズムをZOZOTOWNにおけるファッションアイテム推薦枠に実装しました。これにより、A/Bテストを必要としない低コストな継続的サービス改善のための評価フレームワーク構築を目指しています。 次の章では、そのバンディットシステムの構成について解説します。 バンディットシステムの構成 本プロジェクトの理論的な新規性とデータ・評価パイプライン公開についての詳しい解説は こちらの記事 に任せるとして、本研究を進めるにあたりZOZO研究所で開発を進めてきた配信とログ集計基盤について解説します。 本記事で解説するシステムの導入により、ZOZOTOWN上で実際のサービスを改善しつつオフライン評価手法の構築を進めることが可能となりました。 早速本システムの構成の概観から説明します。 図2:Overview インフラとしてGCPを利用しており、配信・ログ収集・バッチ系などはGKE上で動かしています。 内部の通信にはgRPCを用いており、gRPCのロードバランシングや振り分け制御を行うためにIstioを有効にしています。 ユーザーの画面に推薦対象が表示された場合や、クリックした場合、購買があった場合などはトラッキングサーバーにイベントログを送るようにしており、それらの情報を元に配信パラメータを更新します。現状JavaScriptの表示・イベント送信のSDKを用意しており、Web面への配信をしています。ログ周りはBigQueryに保存して、Contextual Banditのパラメータの学習を行うストリーミング処理系としてCloud Dataflowを使っています。 以下それぞれのコンポーネント毎に簡単に説明していきます。 Gatewayサーバー アプリやブラウザなどのクライアントからのHTTPリクエストを受け取とって、gRPCを喋る推薦サーバーにリクエストをプロキシするサーバーです。Istio Ingress GatewayによるgRPC<->httpブリッジの利用も考えましたが、GKE Ingressの裏にGoで書いたGatewayサーバーをおいています。配信対象やA/Bテスト時の振り分けなどを全てIstioの機能で実現できるような構成も可能であったと考えていますが、以下の2つの理由で独自のGatewayを用意することにしました。 GKE IngressにTLS終端を任せられる 振り分けの条件などを柔軟に構成できる Gatewayサーバー側では、リクエストを元にA/Bテストの振り分けのフラグに用いるヘッダ(Request metadata)を付与して推薦サーバーにリクエストを送るようにしています。 推薦サーバー 推薦サーバーは、GCS上に配置された配信データ(配信対象・配信アルゴリズム・コンテキスト情報・配信パラメータの組)のパスを環境変数で指定してデプロイされます。定期的にファイルの更新をチェックしては配信データに更新があるとデータの取得を行って内部のデータを更新しています。推薦サーバーはGoで実装されており、計算部分はOpenBLASをBLASバックエンドにしたgonumを用いています。そして、ユーザーのコンテキスト情報はCloud Datastoreに配置してgRPCリクエスト毎に取得しています。 推薦サーバーは、それぞれ配信データ毎にgRPCサーバーとしてDeploymentを作成しています。その上で同一の配信対象のDeploymentはIstioのVirtual Serivceとして1つにまとめて、A/Bテストなどが行えるようになっています。Virual ServiceのHttpMatchRequestを用いてどのDeploymentにリクエストを送るかの振り分けを行っています。 上記の設定を行った配信対象毎のVirtual Serviceのイメージは以下のようなものになります。 apiVersion : v1 kind : Service metadata : name : DELIVERYTYPE namespace : bandit-api labels : app : DELIVERYTYPE spec : ports : - port : 3000 targetPort : 3000 protocol : TCP name : grpc-DELIVERYTYPE selector : app : DELIVERYTYPE --- apiVersion : networking.istio.io/v1alpha3 kind : DestinationRule metadata : name : DELIVERYTYPE namespace : bandit-api spec : host : DELIVERYTYPE trafficPolicy : loadBalancer : simple : RANDOM subsets : - name : contexuala labels : deliveryname : contexuala - name : contextualb labels : deliveryname : contexuala - name : random labels : deliveryname : random --- apiVersion : networking.istio.io/v1alpha3 kind : VirtualService metadata : name : DELIVERYTYPE namespace : bandit-api spec : hosts : - "msp" http : - match : - headers : some-header : exact : X route : - destination : host : DELIVERYTYPE subset : random - match : - headers : some-header : exact : Y route : - destination : host : DELIVERYTYPE subset : contexualb - match : route : - destination : host : DELIVERYTYPE subset : contexuala DestinationRule にはsubsets以下に配信を振り分けるDeploymentの名前をdeliverynameに列挙します。 VirtualService のHttpMatchRequestルールに、Gatewayサーバが付与するA/Bテスト用のヘッダの条件を記載します。条件にマッチしたリクエストが設定されたsubsetと対応するDeploymentにルーティングされます。 このようにIstioの機能を利用することでリクエストの振り分けが実現でき、推薦サーバーのコードを変更することなく複数のA/Bテストを実施することが可能になります。 トラッキングサーバー 推薦サーバーが返却したアイテム毎にユニークなID(以下、配信ID)を付与しており、それに紐づく形で以下のような詳細情報を記録しています。以下の項目は一部の項目例です。 配信対象 配信名 配信データのバージョン(配信時に使用したパラメータなどは全てGCS上にバージョニングして保存している) 配信アイテムのID 予測したclick確率 表示あたりの予測収益 アイテムに関するメタデータ 予測時に用いたユーザーのコンテキスト情報 このIDはimpression・click・conversion・その他の関連するユーザの行動がある度にトラッキングサーバーへ送信されます。送信された配信IDを元に、紐付けて記録されている詳細情報と合わせてCloud Pub/Subへ送信されCloud Dataflowを介してBigQuery上に記録されます。 集計・配信データ構築 BigQueryからの集計や、配信データの構築はk8sのCronJobとして実行しています。コンテキストの情報を用いない通常のバンディットにおいては、CTRやCVRなどの計算はBigQueryのログを定期的に集計してパラメータを更新しています。 Contextual Banditの学習をCloud DataflowのStreaming処理でどのように行っているのかについては、次の章で細かく解説します。 Dataflowを用いたContextual Banditパラメータの学習 Contextual Banditの学習はログの突き合わせが必要になるので、ログを一度DWHに保存した後で定時バッチにて実行されることが多いと思われます。tracker上でのオンライン学習も考えられますが、clickがなかったimpressionログをどう扱うのかや、複数台で学習したパラメータの混合など考慮しないといけないことが出てきます。 そこで本システムではStreaming処理系を用いて学習モデルパラメータの更新間隔を早められるのではと考え、Cloud Dataflowを用いた半オンラインでの学習を試みました。 CTRの予測をLogistic Thompson Samplingで行うため、Cloud Dataflowを用いて文献 1 にあるラプラス近似を用いたBayesian Logistic Regressionモデルを学習します。 同様の枠組みでCVRの予測なども可能ではありますが、CVは遅れて来ることも多いためストリーミングでの学習は現状CTRのみ行っています。 シリアライズされたcontextの情報を含むimpressionとclickのログをtrackerからCloud Pub/Subへ書き込み、それをDatadlowで読み込む構成になっています。 Streamingの全体の流れとしては以下のようになります。 配信ID(以下コードではBidId)をキーにSession Windowを掛けてGroup Byしてまとめる clickのみのログ(セッションの時間内にclickが届かなかった物)をフィルタリング まとめたclickありなしのログを更に配信アイテムのIDと配信モデル名のtupleをキーにSession Windowを掛けて更新に十分なログが到達するまで保持 配信アイテムのIDと配信モデル名でセッションにまとめたログの中で、ログが含まれない空のセッションを除去 シリアライズされたコンテキストの情報をデシリアライズ MiniBatchでセッション内のログを用いてパラメータを更新して保存 以下のコードのように上記の処理をpipelineとしてつなげています。 def run (argv): mbu_options = MiniBatchUpdateOption(argv, streaming= True , save_main_session= True ) logging.getLogger().setLevel(mbu_options.log_level.get()) with beam.Pipeline(options=mbu_options) as pipeline: log = pipeline | 'Read log' >> ReadPubSubAndUnmarshalJson(mbu_options.log_subscription) (log | 'GroupBy ID' >> GroupByBidID(mbu_options.session_gap) | 'Filter invalid value' >> beam.Filter(filter_invalid_value) | 'GroupBy item and model' >> GroupByItemAndModel(mbu_options.wait_count, mbu_options.wait_process_time) | 'Filter zero window' >> beam.Filter(filter_zero_window) | 'Deserialize context' >> beam.ParDo(ContextDeserializer()) | 'Mini batch update' >> beam.ParDo( MiniBatchUpdater(mbu_options.output_path, mbu_options.batch_size))) MiniBatchUpdater の中で、GCS上に保存されている前回までの学習結果の取得と、出力されたWindow分のデータの更新とGCSへの保存を行っています。 MiniBatchUpdater の詳細は割愛しますが、以下で、半オンラインでの学習の肝となるSession Windowをどのように適用しているか解説します。 GroupByBidID 一定時間内に到達した同一配信IDのimpressionログとclickログをSession Windowを用いてまとめます。 以下のPythonコードはclickとimpressionをSession Windowを用いてまとめるためのTransformのコードです。同一Window内にimpressionとclickのログがある物を「clickあり」、impressionのみの物を「clickなし」として扱います。clickのみのログは後段の処理で取り除いています。DataflowのSession Windowでは、同一キーのログがギャップ期間以内に到達した場合、同じセッションとしてまとめられます。 cloud.google.com 以下のコードではsession_gapという形でギャップ時間を渡しています。 class GroupByBidID (beam.PTransform): def __init__ (self, session_gap): beam.PTransform.__init__(self) self._session_gap = session_gap.get() self._mandatory_fields = (BID_ID,) def add_timestamp (self, value): """ Add timestamp in order to groupby bid id using session window. """ return beam.window.TimestampedValue(value, datetime.timestamp(datetime.now())) def filter_invalid_value (self, value): return all (field in value for field in self._mandatory_fields) def expand (self, pcoll): return (pcoll | 'Filter invalid value' >> beam.Filter(self.filter_invalid_value) | 'Add key' >> beam.Map( lambda elem: (elem[BID_ID], elem)) | 'Session window' >> beam.WindowInto( window.Sessions(self._session_gap), accumulation_mode=AccumulationMode.DISCARDING) | 'Groupby bid id' >> beam.GroupByKey()) impressionとclickの突き合わせにSession Windowを用いていることから実際のCTRよりは少なく見積もられてしまうことになります。現状はimpressionとclickの突き合わせのギャップ時間は10分とっており、本システムの1日分のログから計算したところ98%は突き合わせができているようです。 GroupByItemAndModel 学習済みのパラメータ自体はメモリ上に保持しているわけではなくGCS上に保存しています。1ログ毎にパラメータの取得と保存を行うのは効率が悪いため、一定量のログが溜まった後、GCSから取得して学習を行います。impressionのログとclickのログをSession Windowでまとめた後は配信アイテムID・配信種別・配信名毎にグルーピングを行います。その後、再度Session Windowを適用して、指定したサイズ以上のチャンクにまとめます。 clickとimpressionがまとめられた後は、腕毎に目的とするbatch size分のログが貯まるまで、再度Session Windowに掛けられます。流量が少なく、指定したサイズ分溜まるまで非常に時間がかかる場合もあり得えます。以下のコードのようにtriggerの設定で、AfterAnyを利用して複数のトリガーを設定して、一定期間内にデータがたまらなかった場合も後段へ流すようにしています。 class GroupByItemAndModel (beam.PTransform): def __init__ (self, wait_count, wait_process_time): beam.PTransform.__init__(self) self._wait_count = wait_count.get() self._wait_process_time = wait_process_time.get() self._mandatory_fields = (ITEM_ID, MODEL_NAME, TARGET, CONTEXT, ACTION) def map_item_model (self, value): key = (value[ 1 ][ 0 ][ITEM_ID], value[ 1 ][ 0 ][MODEL_NAME], value[ 1 ][ 0 ][TARGET]) action_list = [v[ACTION] for v in value[ 1 ] if ACTION in v] # CASE both imp and click exist: 1 # CASE otherwise: -1 # logging.info(action_list) feature = 1 if IMP in action_list and CLICK in action_list else - 1 return (key, [feature, value[ 1 ][ 0 ][CONTEXT]]) def filter_invalid_value (self, value): for val in value[ 1 ]: if not all (field in val for field in self._mandatory_fields): return False return True def expand (self, pcoll): return (pcoll | 'Filter invalid value' >> beam.Filter(self.filter_invalid_value) | 'Map item id and model' >> beam.Map(self.map_item_model) | 'Window into' >> beam.WindowInto( window.Sessions(self._wait_process_time* 60 ), trigger=Repeatedly(AfterAny(AfterWatermark(), AfterCount(self._wait_count))), accumulation_mode=AccumulationMode.DISCARDING) | 'Groupby item id and model' >> beam.GroupByKey()) triggerとしてAfterCountを利用した場合、指定した数ちょうどのチャンクが出力されるわけではなくそれをオーバーしたものが来る点に注意が必要です。 終わりに 本プロジェクトに関する公開データセットとOSSについて 本記事で紹介したバンディットアルゴリズムのシステムを実装した際に収集したデータを、先日 Open Bandit Dataset として一般公開しました。この公開データは合計2600万以上のログを含む大規模なものであり、それぞれのデータは特徴量・方策によって選択されたファッションアイテム・過去の方策による行動選択確率・クリック有無ラベルによって構成されます。これらの特徴により、OPEの正確さを現実的かつ再現可能な方法で評価でき、非常に学術的な価値の高いデータとなっています。 また開発したオフライン評価フレームワーク Open Bandit Pipeline をOSSとして併せて公開しています。このパイプラインにより、研究者はOPEの部分の実装に集中して他の手法との性能比較を行うことができるようになります。 公開データセットとパイプラインに関して、詳しくは こちら のブログ記事をご覧ください。 解説記事・寄稿・学術論文 本取り組みに関連して、以下の雑誌に寄稿しています。 人工知能学会誌2020年7月号 すべての機械学習はA/B テストである また、オープンリソースの特徴やその活用方法などを以下の学術論文としてまとめ、公開しています。 Yuta Saito, Shunsuke Aihara, Megumi Matsutani, Yusuke Narita. A Large-scale Open Dataset for Bandit Algorithms. 公開データセットに関する詳細な記述など、ご興味ある方はぜひチェックしてみてください。 国内外での研究発表 本取り組みに関する研究成果を、トップ国際会議のワークショップを含む国内外の多くの場で発表しています。上に併せまして、ご興味ある方はぜひチェックしてみてください。 ICML 2020 Workshop on Real World Experiment Design and Active Learning (RealML2020) RecSys 2020 Workshop on Bandit and Reinforcement Learning from User Interactions (REVEAL2020) NeurIPS 2020 Workshop on Offline Reinforcement Learning NeurIPS 2020 Workshop on Consequential Decision Making in Dynamic Environments CounterFactual Machine Learning (CFML)勉強会#5 IBIS2020 第五回統計・機械学習シンポジウム 招待講演 メディア 本取り組みに関する成果を以下のメディアなどに取り上げていただきました(抜粋)。 ダイヤモンド・オンライン FASHIONSNAP.COM IoT NEWS ECのミカタ 流通ニュース ITmedia NEWS ZOZOテクノロジーズでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com Chapelle, Olivier and Li, Lihong. An Empirical Evaluation of Thompson Sampling. In Advances in Neural Information Processing Systems 24, 2011. ↩
はじめに こんにちは。SRE部BtoBチームの蔭山です。 Fulfillment by ZOZO (以下FBZ)で提供しているAPIシステムの運用及び監視を担当しております。 FBZではAWS Lambdaを主軸としてAWSが提供しているフルマネージドサービスのみを利用するサーバーレスアーキテクチャを採用し、構築・運用してきました。今回は実際にどのようにサーバーレスアーキテクチャを活用してサービスを構築・運用・監視しているかご紹介します。 これからサーバーレスアーキテクチャを活用してサービスを構築されようとしている方の参考になれば幸いです。 なぜサーバーレスを採用したのか FBZはZOZOTOWNとブランド様が運営されている自社ECサイト間でリアルタイムに在庫情報を連携し、ZOZOTOWNと自社ECサイトでの在庫の一元管理を実現するAPIサービスです。そのため、マスタであるZOZOTOWNの在庫情報を如何に素早く自社ECサイトへ連携できるかが重要な要素の1つです。 ZOZOTOWNではZOZOWEEKをはじめとしたセールやイベントが1年を通して開催されています。その最大トラフィック量は他のアパレルECサイトを見廻しても群を抜いているものになります。このようなイベントで発生する商品や在庫などの最新情報をブランド様が管理する自社ECへリアルタイムで連携していくには、どのようなトラフィックでもスケーラブルに対応できる堅牢なシステムが必要となります。実際にFBZをローンチする上では以下のポイントをクリアしておく必要がありました。 ZOZOTOWN・自社ECサイトのセール時の急激なトラフィック増加にも耐え、問題なくサービスを稼働できること バッチによる大量データ更新・連携を問題なく、かつ高速に完了できること 上記のポイントを低コストでクリアするために、当時浸透し始めていたFaaSであるAWS Lambdaを主要とするサーバーレスアーキテクチャを採用しました。 システム構成 冒頭でもご紹介しましたが、FBZ APIはAWSが提供しているフルマネージドサービスのみで構成しています。実際には以下のようなサービスを利用しています。 Lambda API Gateway S3 DynamoDB Elasticsearch Service SQS SES Cognito CloudFormation CloudWatchメトリクス CloudWatch Logs X-Ray CloudFront AWS WAF Amazon VPC 上記に挙げたようなAWSのフルマネージドサービスを最大限活用しながら、約500ものLambda関数を持つサービスを運用しています。実際にどのように活用しているかはこのあと詳しく解説していきます。 イベントドリブンなアーキテクチャ 基本となるアーキテクチャとしてイベントドリブンなアーキテクチャを採用しています。イベントドリブンなアーキテクチャについて、実際にFBZで稼働している商品情報の連携を元に説明します。 Lambdaを起動しZOZOTOWNから取得した商品情報をS3バケットに保存 S3バケット保存によるイベントからLambdaが起動し、ECカートシステムが取り込みやすいよう商品情報をパースしてDynamoDBのテーブルへ保存 DynamoDBテーブルへの登録イベントからLambdaが起動し、Elasticsearchへ商品情報を保存 このようにイベントの数珠つなぎによってAWS上の各データストアへ商品情報が登録されます。ECカートシステムが公開されているFBZのAPIを利用し自社ECシステムへ最新の商品情報が連携される仕組みです。 セキュリティ FBZ APIは自社ECサイトをご利用いただいたお客様の配送情報も取り扱っているため、お客様の大切な個人情報を漏洩させないようセキュリティ面は厳重な対策がされている必要があります。実際にはAWS WAFを利用してAPIへの接続管理やDDoS攻撃を代表とするサイバー攻撃対策を行っています。またCognitoでのユーザー認証やAPIのエンドポイント単位での認可を実現しています。 また、DynamoDBやElasticsearch Serviceに含まれる本番環境の個人情報を開発者が必要ない時に閲覧できないよう、以下の制限対策も実施しています。 DynamoDBのテーブルにAWSコンソール画面やAWS CLIからアクセスできないようにIAMポリシーによるテーブル単位での閲覧・編集制限 Elasticsearch Serviceのドキュメントに外部から参照できないようにPrivateサブネットへの配置 Kibanaへ必要ない人が閲覧できないようにCognitoによるユーザー認証 アプリケーション管理 Serverlessアプリケーションの管理フレームワークとして Serverless Framework を利用しています。テンプレート上に必要なAWSサービスのリソース定義を記載し、Serverless Frameworkを介してリリースすることによってAWS上に定義されたリソースを展開する仕組みとなっています。 同様のServerlessアプリケーションの管理ツールとしてはAWSが提供している SAM がありますが、SAMと比べて以下の点で優れていると考え現在も利用しています。 Serverless Frameworkコミュニティの活発さ Serverless Frameworkの拡張が可能なPluginの公開数の豊富さ(執筆時点で 1000以上ものPluginが公開されています ) また各アプリケーションが利用するようなAWS VPCなどの環境単位での共通リソースは、別途CloudFormationでテンプレート管理しています。これによりほぼすべての構成・設定値をコードで管理している状態となっています。 またCI/CDの環境としてCodeBuildを利用しています。アプリケーションの静的解析やカバレッジ計測のような開発支援からアプリケーション・インフラのリリース、APIのE2Eテストに至るまですべてCodeBuild上で実行しています。 サービス監視 サービスを運営していくにあたって、システムの監視は切っても切り離せないものです。もちろんFBZのようにフルマネージドサービスのみで構成したサービスでも同様となります。実際にFBZでは下記のようなサービス監視を実施しています。 CloudWatchメトリクスによる異常値検知 Lambda/API Gatewayから出力されたログを解析し、PagerDutyやDatadogのような外部サービスへ連携 AWSの各サービスではCloudWatchメトリクスへ稼働状況が収集されています。収集されたデータから異常値が検知された場合、Slackへ通知されます。その通知を受け運用担当者が対応するフローとなっています。 ログ解析によるサービス監視については過去に記事を公開しているのでぜひ御覧ください! techblog.zozo.com サーバーレスでメリットに感じたこと FBZのシステム構成の一部を紹介してきましたが、ここからはこのようなフルマネージドサービスのみで構成されたサービスを実際に運用して感じたメリット・デメリットについてご紹介します。 まず、サーバーレスのメリットだと感じている点を代表して3点紹介します。 スケーラブルなサービスの実現 データリカバリの容易さ サービス開発への集中 スケーラブルなサービスの実現 サーバーレスアーキテクチャを採用した理由として挙げていましたが、結果としてZOZOTOWNの様々なイベントに対してスケーラブルなサービスを構築できた点です。過去にZOZOTOWNでのセール開催時に大量の商品・在庫情報が差分として上がってくるケースがありました。そのような同時に発生する膨大なデータ連携でも自社ECサイトへリアルタイムに連携することが可能なシステムとなっています。 データリカバリの容易さ イベントドリブンなアーキテクチャでLambda関数を分割しているため、有事の際のデータリカバリが容易な点もメリットだと考えています。もしZOZOTOWNからの商品データ取得に何かしらの理由で失敗した場合でも、指定のS3バケットへ再取得したデータを配置することでその後の自社ECシステムへの連携まで自動的にリカバリされます。また非同期起動のLambda関数は処理が失敗した場合に間隔を開けて自動でリトライする仕組みがあります。ネットワーク起因の一時的な問題などもリトライによって自動で解決してくれる点はサーバーレスならではだと感じています。 サービス開発への集中 サーバーなどのインフラ管理をAWSが管理するマネージドサービスを利用することでなくし、サービスに携わるエンジニアがビジネスロジックの開発に集中できる点です。実際にFBZではインフラ面を管理する専任メンバーはおらず、開発エンジニア全員でマネージドサービスの設定管理やサービス開発を行う環境となっています。 サーバーレスでデメリットに感じたこと ここまでサーバーレスでのメリットを挙げてきましたが、もちろんメリットだけでなくデメリットも存在しています。ここでも代表して3点紹介します。 Lambdaの実行時間の制限 RDBを利用しづらいアーキテクチャ ミドルウェアアップデートへの強制的な追従 Lambdaの実行時間の制限 現在、Lambdaは1処理あたり最大15分まで処理を行うことができます。APIサービスとしての利用ではまず問題になることはないのですが、連携ファイル生成など時間がかかる処理はデータ量によっては15分では終わらないケースもあります。アプリケーション側の処理ロジックを見直して解決できることもありますが、それで解決できるケースはごく一部でした。FBZでも実際に15分以上かかる処理もあり、その場合はAWS BatchやStep Functionsといった別マネージドサービスで実行するように実装しています。Lambdaに限らず、各マネージドサービスそれぞれに上限値は存在しますので制限値を確認しながら設計を進めていくことが大切なポイントだと思います。 RDBを利用しづらいアーキテクチャ 一般的なWebサービスですとMySQLやPostgreSQL、SQL Serverに代表されるようなRDBをデータストアとして利用されているケースが多いかと思います。LambdaのようなFaaSでは水平スケールを得意としているため、LambdaからRDBへの接続はコネクションを枯渇してしまうためアンチパターンとされています。データを検索する場面ではDynamoDBだと細やかな要件を満たすことが困難だったこともありElasticsearchを利用し対応しています。ただ、販売成績など売上データを集計する場面においてはElasticsearchでもFBZ要件を満たすことが困難であり、ロジックの開発が必要でした。RDBであればクエリで実現可能な部分に対し、アプリケーション側で対応するために開発コスト増となる場面もありました。 ですが、最近だとLambdaのようなFaaSでも利用しやすいようAWSでのRDS Proxyのような仕組みもありますので今からサービスを構成していくにはRDBの採用も視野に入れやすいかと思います。 ミドルウェアアップデートへの強制的な追従 マネージドサービスの利点としても良く挙げられますが、クラウドベンダー側でミドルウェアのアップデートは自動的に行われます。そのため、サービス開発を進めながら自動アップデートによってサービスダウンしないよう適宜アプリケーション側もアップデート対応を進めなければならない点はデメリットでもあると感じています。実際に過去Lambdaでとあるランタイムのリリースが廃止され、緊急でバージョンアップ・リリース対応したケースもありました。こういった事象が発生しないようにするためにも、常日頃から情報のキャッチアップが必要なのはもちろんのこと、AWSから来る事前通知の連絡先と現場への展開フローの整備をおすすめします。 まとめ 以上、サーバーレスアーキテクチャでどのようにサービスを構築し運用・監視しているか、またサーバーレスアーキテクチャならではのメリット・デメリットついて紹介しました。 ZOZOテクノロジーズでは、AWSのマネージドサービスを最大限利用しながらBtoB事業のさらなる拡大に取り組んでいただける仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
こんにちは、WEAR部運用改善チームの佐野です。 私たちのチームでは、WEARの日々の運用業務を安全かつ効率的に行えるよう改善をしています。今回は、年初から行っていた不要APIの削除作業についてご紹介します。 背景 残念なことに長い間WEARでは不要になったAPIが放置されてしまっており、どのAPIが実際に使用されているものなのかが分かりにくい状態になっていました。WEARのAPIはWeb・iOS/Androidアプリ・バッチ・社内ツールから参照されているのですが、使用されているのかが明確でないAPIが多数残されていることにより、以下のような問題がありました。 リプレイスや脆弱性診断の対象箇所の洗い出しの際に余計なコストが掛かる 運用業務において何かを調査をする際に、使用されていないAPIがあることで不要なコードも増え、調査がしにくい 実際に、他部署からの問い合わせの調査でとある処理を追っていたら、思っていたAPIではなく実際に使われているのは似たような名前をした別のAPIだった…というような事もありました。 上記の問題を解消するために、不要なエンドポイントや関連ファイルを削除し、現在使用されているエンドポイントのみを残して整理することにしました。 体制・スケジュール 削除対応は、バックエンドエンジニア4名で実施しました。 まず削除候補のAPIの調査を行い、削除対象となったものについては以下の流れで関連ファイルの削除とアクセス監視を行いました。 新たに削除するエンドポイントを毎週1人あたり約3つずつ追加していき、全ての削除が完了するまで約8か月かかりました。 削除する上でのリスク 現在使用されているAPIを誤って削除してしまった場合サービスに影響が出てしまうため、削除前の確認と削除後の監視を慎重に行う必要がありました。確認ポイントについては抜け漏れが無いように後述する一覧表を元にチェックリストを作成して管理しました。 事前調査について APIを削除するにあたり、以下の通り事前の確認を行っていきました。 1. 削除候補API一覧の作成 まず、以下の2つの観点から削除候補のAPIを洗い出してスプレッドシートの一覧表を作成しました。 アプリチームにヒアリングした、iOS, Androidアプリから参照していないAPI Swaggerに記載されていないAPI 作成した管理表には削除対象のエンドポイントであるか、対象である場合は現在のステータスがひと目でわかるように項目を設けて、一連の削除作業を通して使用しました。 2. アクセス有無の確認 担当者ごとに、 Splunk を用いてアクセスログの分析を行いました。 直近2か月で対象のAPIへリクエストが飛んできているかを基準としてアクセスの確認を行いました。なぜ期間を2か月に指定したかというと、月次で動いているバッチ処理からのアクセスを検知するためです。 3. アプリ以外からの参照を確認 前述した通り、WEARのAPIはアプリ以外にもWeb・バッチ・社内ツールから参照されています。まずはアプリ以外の全てのリポジトリ内で削除候補のAPIのエンドポイント名でgrepして、他のリソースから呼び出されていないことを確認しました。 4. アプリからの参照を確認 アプリからの参照有無が確認できていないエンドポイントについて、APIのエンドポイントを定義しているクラスで参照されていないことを確認しました。その後、アプリチームへの最終確認を行い、確認が取れたものを削除対象と判断しました。 削除手順について 上記の確認の結果、削除対象となったエンドポイントは以下の流れで削除していきました。 ブランチの運用 前述した通り、ルーティングの削除→3日間のアクセス監視→アプリケーションコードの削除→3日間のアクセス監視という流れで進めるため、ブランチを分けて作業していきました。毎週火曜日をリリース日と定め、監視期間中に次のリリースの準備を進めていました。 総行数の確認 WEARではこうした不要コードの削除も成果として称える文化があります。そのため、対象のエンドポイントを削除することで何行分のコードを削除できたかを最後にカウントできるように、以下のGitコマンドで削除前後のリポジトリ内の総行数を取得し一覧表に記録しました。 git ls-files | xargs -n1 git --no-pager blame -w | wc -l ルーティングの削除 対象のルーティングの定義を削除した後、 Postman 等を使ってAPIを呼び出して、HTTPステータス404が返却されることを確認しました。 アプリケーションコードの削除 ルーティングから呼び出していたControllerの処理を削除しました。Controllerクラスに紐づくModelクラスも不要になるかを確認し、他から参照されていなければ全て削除しました。 また、WEARのリプレイス前の環境ではなんと ストアドプロシージャ が多用されているのですが、削除した処理でしか使用されていないものは追々削除していけるように一覧表に記録しておきました。 Swaggerの定義削除 削除したAPIに関する記載を全て削除しました。 Splunkダッシュボードでのアクセス監視 ルーティング、アプリケーションコードの削除後にはそれぞれ3日間の監視期間を設けました。事前のアクセス確認と同様に、ここでもSplunkを使用しました。Splunkにはダッシュボードという機能があり、プルダウンから対象のAPIを選択してアクセスの有無がひと目で分かる状態にしていました。 アクセスがない場合 アクセスがある場合 作業中の問題点 Splunkの検索クエリ改善 Splunkを用いたアクセス確認を行う際に、結果が出るまでに数時間かかるという問題がありました。当初は検索期間を短い期間に区切って検索したり、前日の退勤時にバックグラウンドでログの検索を開始して翌日の朝に確認したりしていました。 しかし、あまりにも調査に時間がかかってしまうためクエリの改善を行いました。 改善前のクエリ * uri_path="/api/hoge*" sourcetype="ms:iis:auto" host IN(some-wear-host) source="path/to/log" 改善後のクエリ * sourcetype="ms:iis:auto" host IN(some-wear-host) source="path/to/log" | search uri_path="/api/hoge*" OR uri_path="/api/fuga*"… | stats count as request_count by uri_path 改善した内容は以下の通りです。 Bot用のログは参照しないように検索対象のログを絞る 期間がパフォーマンスに影響するため、1か月分×2並列で動かす 1エンドポイントずつ検索していたものをORで条件を繋げて複数検索にする 最低限の情報だけ返すように、uri_path毎のリクエストカウントを出力する これらの改善をした結果、1つのエンドポイントのログを確認するのにかかる時間を3分の1以下に短縮できました。 さらに、バックグラウンド実行を選択して終了時にメール通知を受け取るようにすることで、ノンストレスで調査を進めることができました。 APIの誤削除について アクセス有無の確認を徹底して行っていましたが、とあるバッチの処理で呼び出されているAPIを誤って消してしまうことがありました。なぜ起きてしまったかと言うと、それらのファイルがGit管理されていないという落とし穴があったためです…。 アクセスログから使用されているAPIだと分かったはずですが、特定のIPアドレスからの大量のアクセスを不正なものと判断してしまっていました。実際は不正アクセスではなく、AWSで固定IPを付与したホストからのアクセスであったことが後の調査で判明しました。 これらを踏まえ、他にもGit管理されていないファイルが存在しないか確認し、全てのファイルを管理下に置いたうえで再調査を行いました。 また、2か月間全くアクセスのないAPIを削除対象とするように改めました。 まとめ 最終的に削除したAPIは201件(全体の36.7%)、行数は52,392行(全体の8.8%)でした。 通常業務と並行して作業を行っていたため長期戦にはなりましたが、不要なAPIを全て削除したことで、より効率の良い安全な運用ができる状態に近づけることができました。また、削除したAPIでしか使用されていなかったストアドプロシージャも順次整理していく予定です。 さいごに ZOZOテクノロジーズでは、一緒に安全かつ効率的にサービスを作り上げてくれる方を募集中です。 つい後回しにされがちな技術的負債の返済作業を、現場が提案して優先順位をあげて取り組める環境があることはWEAR部の強みだと考えています。 ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com
こんにちは。ECプラットフォーム部の廣瀬です。 先日公開したテックブログ 「データベースの秘密情報取扱いルールに関する取り組みのご紹介」 では、データベースに保存している秘密情報の取扱いルールについてご紹介しました。そこでは、秘密情報の取扱いフローの策定として、次の5つのフローの整備を行いました。 新しく追加されるデータの取扱い 既存データで秘密情報に該当する項目の洗い出し 秘密情報にアクセスできるアカウントの制限 権限のないアカウントからのアクセス制限 権限保持者の大幅な削減による運用負荷増への対処 techblog.zozo.com 本記事では、これらのフローで策定された内容をSQL Serverで実装する場合の、具体的な対応内容について紹介します。 1. 秘密情報カラムへのアクセスの制限 秘密情報カラムへのアクセスを制限するためには、以下の2つの要件をSQL Serverの機能で実現させる必要があります。 秘密情報カラムにアクセスできるアカウントの制限 秘密情報カラムのマスク化 SQL Severには 動的なデータマスキング という機能があります。この機能を使用することで、権限を制限したアカウントが該当のデータにアクセスした場合は、自動的にデータをマスクした状態で返すことができます。 上:権限があるアカウントでアクセス / 下:権限がないアカウントでアクセス 権限が制限されたアカウントでは自動的にデータがマスクされ、秘密情報を保護でき、前述の要件を満たせます。しかし、この機能に関してはSQL Server 2016以降でしか使用できません。弊社ではSQL Server 2016以降の環境もありますが、それより前のバージョンも利用しています。そのため、全環境でこの機能を利用することはできません。 そこで、動的なデータマスキングの代替案として次のような対応を行いました。 ロールの活用 秘密情報カラムに対するSELECT権限のはく奪 権限のないアカウントで秘密情報を参照できなくするために、カラム単位でSELECT権限をはく奪(DENY) 秘密情報をSELECTしているVIEWの参照権限をはく奪(DENY) 動的なデータマスキングを使用しない秘密情報カラムのマスク化 1. ロールの活用 権限のはく奪については、各ログイン/ユーザーに対して個別に設定を行うのではなく、 SQL Serverのユーザー定義ロール を活用し、秘密情報へのアクセスを制限するロールを作成しています。作成したロールに対して秘密情報カラムのSELECT権限をはく奪し、そのロールにログイン/ユーザーを参加させます。こうすることで、複数のユーザーに対する秘密情報へのアクセス制限を効率的に実施できます。 策定したルールの「3. 秘密情報にアクセスできるアカウントの制限」では、秘密情報が閲覧不可能なアカウントと閲覧可能なアカウントの2種類を発行していると述べました。これらの権限の設定にもロールを活用し、柔軟に設定を管理できるようにしています。 2. 秘密情報カラムに対するSELECT権限のはく奪 1. 権限のないアカウントで秘密情報を参照できなくするために、カラム単位でSELECT権限をはく奪(DENY) SQL Serverのテーブルのアクセス権は、カラム単位で制御できます。なお、SQL Serverのアクセス権の設定の詳細については、 権限の階層 や Microsoft SQL Server Permissions Posters をご参照ください。 カラム単位でSELECT権限をはく奪(DENY)する場合、次のクエリを実行します。 DENY SELECT ON OBJECT::テーブル名(列名) TO ユーザー名 権限のはく奪を行うと、アクセス権のないユーザーで該当カラムを取得するSELECTを実行した際、エラーが発生します。 2. 秘密情報をSELECTしているVIEWの参照権限をはく奪(DENY) 権限をはく奪したテーブルをVIEW経由で参照している場合の考慮も必要です。VIEWの中ではく奪したカラムをSELECTしている際には、VIEWに対してSELECT権限を持っていると、ベーステーブルで権限がはく奪されていてもSELECTができてしまいます。 そのため、VIEW経由でもアクセスを制限する場合には、「SELECTをはく奪したカラムを参照しているVIEW」に対しても権限をはく奪する必要があります。この設定を行うために、次の2つの情報を組み合わせます。 1. SELECTをDENYしたカラムのリスト作成 データベースのオブジェクトに設定している権限については、 sys.database_permissions から取得できます。この情報から、権限の制御を行うロールに設定されているDENYの情報を取得し、「どのテーブルのどのカラムに対してアクセスが制限されているか」のリストを作成します。 2. VIEWが参照しているテーブルのカラムのリスト作成 VIEWが参照しているテーブルとカラムは sql_dependencies / sys.sql_expression_dependencies / sys.dm_sql_referenced_entities のような、依存関係を管理しているシステムVIEWから確認できます。この情報から、「VIEWで参照しているテーブルとカラム」のリストを作成します。 これらの情報を組み合わせることで、「DENYしたカラムを参照しているVIEW」を把握できます。この情報を基にしてVIEWに対してもDENYを設定することで、VIEWに秘密情報を含むカラムが使用されている場合でもアクセスの制限が可能となります。 3. 動的なデータマスキングを使用しない秘密情報カラムのマスク化 ここまでの内容で「秘密情報へのアクセスの制限」を実現できました。秘密情報へのアクセスを制限できたのであれば、「データへのアクセス制限については、これで完了なのでは」と思われるかもしれません。しかし、ここまでの作業で完了としてしまうとSELECTをDENYしたテーブルの参照時に、 SELECT * FROM テーブル名 というようなクエリを実行した際にエラーとなってしまいます。ルールの策定時に「エンジニアの運用負荷をできるだけ上げずに、秘密情報の閲覧可能者をできるだけ限定することが重要だと考えています」と述べました。ここで、「*」による検索ができない状態で、秘密情報カラムを含むテーブルのデータ調査を行う場合に必要な作業について考えてみます。 SELECTがDENYされているカラムにアクセスした場合、エラーメッセージにアクセス拒否されたカラム名が出力されます。このとき、エンジニアは以下の2つの作業を実施します。 「*」ではなく、テーブルの全カラムのリストを使用してSELECTを実行 エラーメッセージに出力されたカラムをSELECTのリストから除外してクエリを実行 これでは、エンジニアの運用負荷が増加してしまいます。動的なデータマスキングが使用できる環境であれば、カラムのアクセス制御はDENYではなくMASKとなるため、「*」による検索が可能です。ただ、この機能を使用できない環境が存在しているため、今回はそれ以外の方法で実現する必要があります。 そこで今回は「各テーブルに対応したVIEWを作成し、テーブルに秘密情報カラムが存在する場合は、該当のカラムをマスクする」という方法を採用しました。サンプルの情報を使用して、基本的な実装方法を説明します。 CREATE TABLE [Membership] ( [MemberID] [int] IDENTITY( 1 , 1 ) NOT NULL , [FirstName] [ varchar ]( 100 ) NULL , [LastName] [ varchar ]( 100 ) NULL , [Phone] [ varchar ]( 12 ) NULL , [Email] [ varchar ]( 100 ) NULL , PRIMARY KEY CLUSTERED ([MemberID] ASC ) ) INSERT Membership (FirstName, LastName, Phone, Email) VALUES ( ' Roberto ' , ' Tamburello ' , ' 555.123.4567 ' , ' RTamburello@contoso.com ' ), ( ' Janice ' , ' Galvin ' , ' 555.123.4568 ' , ' JGalvin@contoso.com.co ' ), ( ' Zheng ' , ' Mu ' , ' 555.123.4569 ' , ' ZMu@contoso.net ' ) DENY SELECT ON OBJECT::MemberShip(Email) TO TestUser Membershipというテーブルを作成し、TestUserはEmailカラムへのSELECT権限をはく奪しています。そのため、TestUserで次のクエリを実行すると、エラーが発生します。 SELECT * FROM Membership エラーメッセージを元にクエリを修正する必要があり、このままではエンジニアの運用負荷が増加します。そこで、Membershipテーブルに対応したVIEWの作成を行います。 CREATE VIEW V_Membership AS SELECT [MemberID], [FirstName], [LastName], [Phone], ' xxxx@xxxx.com ' AS [Email] FROM [Membership] GO GRANT SELECT ON OBJECT::V_Membership TO TestUser このVIEWでは、秘密情報カラムについては、マスクした状態の固定値が返されます。実際にVIEWを検索すると次のような情報が取得されます。 SELECT * FROM V_Membership この方法では、ベースとなるテーブルの代わりにVIEWを検索する必要があります。ただこの方法であれば、「各テーブルに秘密情報カラムが存在しているか」を意識することなくクエリを書けます。このようなVIEWを秘密情報カラムの存在有無に関わらず、全テーブルに対して作成しています。そしてデータの確認はテーブルを直接SELECTするのではなく、VIEWを使用するというルールにしています。これにより、動的なデータマスキング機能に近い体験をエンジニアへ提供しています。 なお、この対応で作成したVIEWについては、後述のメンテナンスによって再作成される場合があります。そのため、VIEWが一時的にDROPされる可能性を考慮しなくてはなりません。もしアプリケーションがこのVIEWを参照していると一時的なエラー発生は避けられません。そのため、今回の対応で作成したVIEWは「エンジニアがデータを確認するためにのみ使用しアプリケーションでは使用しない」というルールで運用しています。 2. 秘密情報のメンテナンス 秘密情報に該当するデータは、サービスの成長に合わせて追加/削除される可能性があります。このような秘密情報の変化に対応するため、マスクされたVIEWのメンテナンスを自動で実施しています。秘密情報の設定状況が変化した場合、データ参照用のVIEWにも変化の内容を反映させる必要があります。単純な実装としては、定期的な全VIEWの再作成が考えられます。ただ、今回ご紹介する実装では変更が発生するVIEWを最小限に抑えるため、設定の変更が必要なVIEWのみ再作成を行っています。 設定が変化し、再作成が必要となるのは次のようなケースが考えられます。 テーブルの定義変更(カラム追加) 最新の状態をVIEWに反映 テーブルの作成/削除 参照用のVIEWの作成/削除 テーブル内の秘密情報カラム(カラムのDENY)の増減 新しくDENYが設定されたカラムをマスク化 DENYが取り消し(REVOKE)されたカラムを実データ化 1. テーブルの定義変更(カラム追加) SQL Serverでは、テーブルに変更が行われると sys.objects の「modify_date」が変更されるので、この値を使用して直近でテーブルに対して変更が行われたかを確認しています。 2. テーブルの作成/削除 VIEWのメンテナンスを自動化するためには、テーブルの新規作成/削除にも対応する必要があります。新規に作成されたテーブルがあれば対応するVIEWを作成し、テーブルが削除されたのであれば、該当するVIEWを削除します。 この判断については、以下の2種類の比較により実施できます。 テーブルは存在するがVIEWは存在しない VIEWは存在するがテーブルは存在しない このような比較については セット演算子 を使用することで実現できます。テーブル/VIEWの一覧については sys.objects から取得でるので、この情報とセット演算子を利用することで、テーブル/VIEWの存在の不一致を検出できます。次の例では、VIEWは存在するがテーブルは存在しないデータを取得しています。 3. テーブル内の秘密情報カラム(カラムのDENY)の増減 新しく追加されたカラムが秘密情報に該当する場合(DENY)と、今まで秘密情報としていたカラムが秘密情報ではなくなった場合(REVOKE)は、VIEWのマスクの状態に反映する必要があります。前述のとおり、データベースのオブジェクトに設定している権限については、 sys.database_permissions から、VIEWが参照しているテーブルとカラムは sql_dependencies / sys.sql_expression_dependencies / sys.dm_sql_referenced_entities から取得できます。 これらの情報から、以下2点のリストを作成し、両者の比較を行うことでDENY設定の変化を検知しています。 テーブルのカラムに対するDENY設定状況 VIEWのカラムのマスク化の設定状況 以上3つの処理で変更が検知されたテーブルにのみVIEWの再作成を行うことで、VIEWの差分更新を実現しています。 また、データベース上の秘密情報カラムを管理するための仕組みづくりも進めています。秘密情報の管理用テーブルに秘密情報と判断したカラムを登録することで、アクセス制限に使用しているロールに自動的にDENYの設定が行われ、それがVIEWの設定にも反映されるような実装となる予定です。 3. リンクサーバー経由のアクセスの考慮 一般的な環境であれば、ここまでの内容で秘密情報のアクセス制限が完了すると思われます。弊社では複数のSQL Serverを組み合わせて利用するため リンクサーバー を使用している環境があります。リンクサーバーを使用している場合は、リンクサーバー経由でのアクセス制限についても考慮する必要があり、実施した対応についてご紹介します。 1. 設定方法 次の画像は、リンクサーバーの接続を作成する際に設定するセキュリティ設定です。 リンクサーバーを設定する際は、この画像のような設定で接続を作成された方もいらっしゃると思います。「上記一覧で定義されていないログインの接続方法」として「このセキュリティコンテキストを使用する」に、リンクサーバーで接続する先のログインの情報を入力しています。 このような設定が行われていると、リンクサーバー経由で別のサーバーにアクセスした場合、「リモート ログイン」に指定したログインの権限で接続されます。 そのため設定しているログインの権限によっては、「自分で接続した場合は秘密情報にアクセスできないが、リンクサーバー経由ならアクセスできる」という状態になり得ます。 リンクサーバー経由でアクセスした場合も秘密情報へのアクセスを適切に制限するために「ローカル サーバーのログインとリモート サーバーのログインのマッピング」機能を活用しました。リンクサーバー経由のアクセスを制限するログインについては、マッピングに次のような設定を行っています。 「ローカルログイン」には、エンジニアがSQL Serverにアクセスする際のログインを設定します。「リモート ユーザー」には、接続先のSQL Serverには存在しないログインを指定します。これにより、マッピングに設定されたログインは存在しないログインにマッピングされるため、リンクサーバー経由のデータアクセスができません。この仕組みによって特定のログインに対するリンクサーバー経由のアクセス制限を実現しています。この方法は、既存のリンクサーバーに対してアクセスを制限する必要がある場合に、影響を抑えつつ制限をかけたいときに有効です。 2. 注意点 アプリケーションから発行するクエリもリンクサーバーを使用している場合は注意が必要です。「アプリケーションから発行するクエリでは秘密情報カラムのSELECTを許可したいが、個人が手動で秘密情報カラムをSELECTするのは制限したい」といった要求がある場合、以下の実装も考えられます。 アプリケーションからSQL Serverに接続するための専用ログインを作成し、秘密情報カラムへのアクセス制限は許可 開発者専用のログインを作成し、秘密情報カラムへのアクセス権限をはく奪(DENY) リンクサーバーの「上記一覧で定義されていないログインの接続方法」として「ログインの現在のセキュリティ コンテキストを使用する」にチェック この場合、確かにやりたいことは実現できるのですが、「意図しない結果が返ってくる」リスクがあるため解説します。 リンクサーバーに接続するクエリを実行する際、「sp_columns_100_rowset」というシステムストアドプロシージャが事前に実行されることがあります。実行される条件としては、クエリの初回実行時など、コンパイルして実行プランを生成する必要がある場合です。このストアドプロシージャは「接続先ログインの権限で取得可能なカラムリスト」を取得します。そして「取得可能なカラムリスト」は実行プランに反映されキャッシュされます。キャッシュされた実行プランは異なるログインであっても再利用されるため、以下の挙動になる場合があります。 秘密情報カラムへのアクセスが制限されたログインで、リンクサーバーを使ったSELECTクエリを実行し、プランがキャッシュされる アプリケーションから同一のクエリが実行された際、キャッシュ済みのプラン(秘密情報カラムがレコードセットから除外されるプラン)で実行される 本来は取得されるべき秘密情報カラムがアプリケーションで取得できない このように意図しない結果が返ってくる可能性があります。そのため、「秘密情報カラムへのアクセス権限を持っているログイン」と「制限されているログイン」の両者が同一のリンクサーバーを使用できる状況は避けた方が安心です。 まとめ 先日公開したテックブログ 「データベースの秘密情報取扱いルールに関する取り組みのご紹介」 でご紹介した内容をSQL Serverで実装する場合の、具体的な対応内容について紹介しました。特に、バージョンの制約で動的なデータマスキングが使用できない環境下においても、開発者の利便性低下を最小限に抑えながら秘密情報カラムをマスク化する方法について説明しました。本記事の内容がSQL Serverのセキュリティ向上を目指す方の参考になれば幸いです。 最後に ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! tech.zozo.com