TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

938

こんにちは、データシステム部推薦基盤ブロックの寺崎( @f6wbl6 )です。現在、推薦基盤ブロックではデータサイエンス部MLOpsブロックのメンバーと協力しながらMLOps基盤の構築を進めています。本記事ではMLOps基盤構築の一環として進めている Vertex Feature Store の機械学習システムへの導入に関する知見およびVertex Feature Storeを導入する上での制限や課題をご紹介します。 MLOps基盤に関する取り組みについては以下のテックブログでも取り上げていますので、こちらもご参照ください。 techblog.zozo.com techblog.zozo.com techblog.zozo.com 推薦基盤ブロックが抱える機械学習システムの課題 機械学習システムの課題に対する取り組み Feature Store 概要 Feature Storeの選定 Vertex Feature Store Vertex Feature StoreをMLシステムに導入する 特徴量の登録 システム構成 実装例 実装上の注意点 特徴量取り込みのための仕組み作り システム構成 なぜ特徴量への変換処理をDataflow等へオフロードしていないのか? なぜ定期実行の仕組みにCloud Composerを使っていないのか? 実装例 実装上の注意点 取り込み対象のデータソースはFeature Storeと同一リージョンに配置する 特徴量の更新時間を示すタイムスタンプの形式はデータソースごとに異なる 登録した特徴量と型の不整合があっても処理自体は正常終了するが取り込みはされない 特徴量の管理基盤を設計・運用してみてわかったこと 再利用できる特徴量を作成・更新することの難しさ バッチ実行のMLモデルに与える特徴量の鮮度 まとめと今後の展望 参考 推薦基盤ブロックが抱える機械学習システムの課題 Vertex Feature Storeを説明する前に、推薦基盤ブロックでの機械学習システム(以降、MLシステム)に関する課題について紹介します。 私たちのチームではこれまでに様々なMLモデルやシステムを開発し運用してきています。そもそもMLシステムを構築・運用していくことは、今後継続的にメンテナンスコストがかかることとトレードオフです。このことはMLOps領域に携わっているエンジニアなら何度も目にしたことがある例の図がそれを表しています。 引用: Hidden Technical Debt in Machine Learning Systems 私たちのチームでもその例に漏れず、開発・運用するモデルが増えるにつれて以下のような課題が浮き彫りとなっていました。 案件の度に様々なコードや特徴量を生成するクエリが流用・改変され、車輪の再発明がなされている モデルの入力として与えているデータが適切なものであることを保証できていない モデルの出力として得ている予測結果が適切なものであることを保証できていない まず、様々な案件で似たようなコードや特徴量が量産されているという課題があります。特に、特徴量に関しては基本的にBigQueryのデータマートやリアルタイムデータ基盤からユーザー情報や商品情報を取得して生成するというのはどの案件でも共通しています。その結果、一度作ったクエリをマイナーアップデートしたものが各所に散らばっている・もしくはそれを認識できていない状態となる傾向があります。つまり、 知らず知らずのうちに車輪の再発明をし、メンテナンスの及ばないものが増えていっている ことを意味します。 2つ目と3つ目は運用しているMLモデルのモニタリングに関する課題です。MLシステムにおいて、「 システムがエラーを起こしていない状態 」の定義が困難であることは、MLシステムを運用したことのある方なら容易に想像がつくものと思います。特にMLモデルが正しい振る舞いをしていると保証するには、 モデルに与えるデータ(特徴量)が適切 であり、また モデルのアウトプットも想定していたもの であることを検証する必要があります。 MLモデルの状態や入出力データが正常であるとする定義が非常に難しいということは言うまでもありませんが、まずはそれらを定常的に観測するための仕組みを設けないことには検証自体ができません。 機械学習システムの課題に対する取り組み 前述したもの以外にも様々な課題はありますが、現在は「MLシステム開発の高速化および標準化」を主軸としながら優先順位をつけてそれぞれの課題に取り組んでいます。具体的な取り組みについて以前別の記事で今後の展望として取り上げられているのでこちらもご一読ください。 techblog.zozo.com 私たちのチームでは上記の「 案件の度に様々なコードやクエリについて車輪の再発明がされている 」という課題に対する取り組みの一環で、Vertex Feature Storeの導入を進めてきました。Vertex Feature Storeを導入することで、 特徴量の再発明を防止して適切に再利用し、また特徴量が適切にモニタリングできる 状態の実現を目指しました。 Feature Store 概要 Vertex Feature Storeの説明の前に、まずFeature Storeとはどのようなものなのかを簡単に説明します。 Feature Storeは機械学習システムで扱う特徴量を管理・提供するための基盤の総称であり、2017年にUberの以下のブログで紹介されたのが初出とされています。 eng.uber.com Feature Storeは機械学習で用いる特徴量を共有・再利用することを主な目的としたもので、現在までにOSS・マネージドサービス問わず様々な形でFeature Storeが公開されています。以下にFeature Storeの概念図を示します。 各構成要素の詳細については以下の記事が詳しいので、ぜひ参考にしてみてください。 www.tecton.ai いくつかFeature Storeを紹介すると、OSSとして公開されているものとしては Feast や Hopsworks などがあり、マネージドFeature Storeとしては Tecton や2020年の AWS re:Invent で発表された Amazon SageMaker Feature Store などがあります。その他にもAirbnbの Zipline やFacebookの FBLearner 、Appleの Overton といった、各社で構築しているML基盤にもその要素の1つとしてFeature Storeが組み込まれているようです。 Feature Storeの選定 このように様々なFeature Storeが公開されている中で、私たちのチームでは2021年5月の Google I/O で発表された Vertex AI のコンポーネントの1つである Vertex Feature Store を使うことにしました。 選定にあたっての理由は大きく以下の2点です。 MLOps基盤の関連資産をGCPに集約させている Feature Storeの技術検証を高速に実施するため、マネージドサービスが望ましい 1点目については大前提とも言えるのですが、GCPをベースとしてMLOps基盤を構築している以上、GCPの外部に依存関係を極力持ちたくないという意図です。 2点目はFeature Storeという、まだ発展途上の概念を導入する上での保険です。調査を進めていく中で有用そうであることはわかったものの、導入することで何がどこまで解決できるのか・できないのかの技術検証を高速に行いたいという意図からマネージドで手軽に使えるものを選んでいます。 Vertex Feature Store Vertex Feature Storeは、GCPが提供しているFeature Storeのフルマネージドサービスです。2022年1月現在でどのような機能が提供されているかを以下の表にまとめます。 機能 提供されている機能の概要 Serving バッチ/オンラインサービング可能。ただしストリーミングデータのサービングは不可。 Storage オフライン/オンラインストアが提供されている。特徴量の取り込み処理は自前で用意する必要がある。 Transform サポートなし Monitoring Feature Store自体に対する負荷等のメトリクスはCloud Monitoring経由で監視可能。 特徴量の監視は1日単位のスナップショットを確認可能。 Registry GUIベースでの特徴量共有・探索が可能。Python SDKでの取得も可能。 上記表の中で最もボトルネックとなる点は Transformのサポートがない ことと、 Storageに特徴量を取り込む処理は自前で用意する必要がある 点です。Feature Storeは特徴量を管理・提供するための基盤ですが、その特徴量自体をFeature Storeに取り込む処理が必要となります。また特徴量として扱われるデータはほとんどの場合何らかのrawデータを加工したものであるため、rawデータを加工する何らかの変換処理も必要です。Vertex Feature Storeを利用する場合はこのデータを取り込む処理と加工する処理(=Transform)を用意しなければいけません。 次に、Vertex Feature StoreをMLシステムへ導入していく上でこれらのボトルネックをどのように解消したかをご紹介します。 Vertex Feature StoreをMLシステムに導入する Vertex Feature Storeの導入にあたり、大きく以下の2点に分けて説明します。 特徴量の登録・管理方法 特徴量取り込みのための仕組み作り 特徴量の登録 システム構成 まず、管理対象にしたい特徴量名とそのメタデータをVertex Feature Storeに登録します。システム構成は以下の通りです。 特徴量の一覧を記述した以下のようなyamlファイルをGitHubでバージョン管理し、新しく特徴量を登録する場合はこのyamlファイルに追記していく形としています。 - feature_id : user_id description : zozotown user_id value_type : int64 - feature_id : pv_per_day description : ... value_type : float64 - feature_id : frequent_device_type description : ... value_type : object ... 登録対象となる特徴量はyamlファイルに記述された特徴量とVertex Feature Storeに登録されている特徴量の差分です。GitHub Actionsでその差分となる特徴量を抽出し、特徴量を登録するためのVertex Pipelinesを実行する構成としています。 Vertex Pipelinesでは指定されたyamlファイルの内容を元に、Feature Storeへ特徴量を登録するようなコンポーネントを作成して実行しています。 実装例 上記yamlファイルを入力として特徴量の登録処理を行うコンポーネントの実装例を以下に示します。 import logging import time import fire from google.api_core.client_options import ClientOptions from google.cloud.aiplatform_v1 import FeaturestoreServiceClient from google.cloud.aiplatform_v1.types import feature, featurestore_service def main ( project_id: str , region: str , featurestore_id: str , entity_id: str , feature_requests: str , ): logging.basicConfig(level=logging.INFO) vfs_feature_types = { "int64" : feature.Feature.ValueType.INT64, "object" : feature.Feature.ValueType.STRING, "float64" : feature.Feature.ValueType.DOUBLE, "array" : feature.Feature.ValueType.STRING_ARRAY, } fs_client = FeaturestoreServiceClient( client_options=ClientOptions(api_endpoint=f "{region}-aiplatform.googleapis.com" ) ) entity_type_path = fs_client.entity_type_path( project_id, region, featurestore_id, entity_id ) create_feature_requests = [ featurestore_service.CreateFeatureRequest( feature=feature.Feature( value_type=vfs_feature_types[fr[ "value_type" ]], description=fr[ "description" ], ), feature_id=fr[ "feature_id" ], ) for fr in feature_requests ] # 一度のリクエスト数の上限は 100 features のためミニバッチに分ける split_requests = [ create_feature_requests[i: i + 100 ] for i in range ( 0 , len (create_feature_requests), 100 ) ] for requests in split_requests: logging.info(f "requests: {requests}" ) lro_response = fs_client.batch_create_features( parent=entity_type_path, requests=requests ) create_feature_response = lro_response.result(timeout= 3600 ) logging.info( "create_feature_response:" , create_feature_response) time.sleep( 60 ) # quota if __name__ == "__main__" : fire.Fire(main) 実装上の注意点 ドキュメントにも記載されている内容ではありますが、特徴量の登録時には以下の制約に注意する必要があります。 特徴量名はアルファベット小文字・数字・アンダースコアのみで構成されていること 特徴量の登録は1分間に100件まで 特徴量の登録処理はステップ実行される 1点目は既に運用されているモデルの特徴量名でこの制約が守られていないものがあった場合に関する注意点です。Vertex Feature Storeに登録する特徴量名は上記のような制約があるため、 特徴量の登録時とFeature Storeから特徴量を取得した時に変換処理を加える ことになります。 2点目はまとめて特徴量を登録したいような場合には Feature Storeのquota を意識しなければいけないため、以下のように 100件ずつのバッチに分ける・かつ1分のsleepを挟むなどの処理が必要 となります。 # requests は最大100件 for requests in split_requests: logging.info(f "requests: {requests}" ) lro_response = fs_client.batch_create_features( parent=entity_type_path, requests=requests ) create_feature_response = lro_response.result(timeout= 3600 ) logging.info( "create_feature_response:" , create_feature_response) time.sleep( 60 ) # quotaによる制約のため 3点目は、Vertex Feature Storeへの特徴量の登録処理としてSDKではバッチ実行用のメソッドが提供されているものの内部では各特徴量が一件ずつ登録されている、ということです。例えば登録対象の特徴量100件があったうちの50件目に不正な特徴量名などがあった場合、 100件の登録失敗ではなく50件の登録が成功し残り50件の登録が失敗 します。このような場合、特徴量の登録処理をリトライしても同名の特徴量が登録できない旨のエラーで弾かれるため、登録できなかった残りの50件を抽出して登録処理を実行する必要があります。 特徴量取り込みのための仕組み作り システム構成 続いて、Vertex Feature Storeに対して具体的な特徴量の値を取り込む方法について設計します。設計にあたり、 特徴量のサービングはバッチ実行にのみ対応する ことを前提としています。理由としてVertex Feature Storeではオンラインサービング機能が提供されているものの、特徴量の値をリアルタイムに更新する仕組みが現時点では提供されていません。そのため、まずはバッチサービングという軽めの要件で設計して検証します。 これを念頭に設計した業務フローが以下となります。 クエリ・変換部分と特徴量取り込み部分をそれぞれパイプラインとして実装し、この実行順を制御する大元のパイプラインとしてrecurring ingest pipelineを設けています。query transform pipelineではBigQueryからデータを取得して特徴量へと変換してGCSにCSVファイルとして保存します。ここで得られた前処理済みの特徴量をingest pipelineによってFeature Storeへ取り込みます。これらの一連の流れを管理するワークフローツールにはVertex Pipelinesを利用しています。 更にこの特徴量の取り込み処理(=更新処理)は定期的に実行する必要があるため、 Vertex Pipelinesの公式ドキュメント で紹介されている、Cloud SchedulerとCloud Functionsでの定期実行の構成を組んでいます。 AI Platform PipelinesにおけるRecurring Runのような機能がVertex Pipelinesには存在しないため、スケジュール実行やパイプラインの重複実行の制御についてはこのように自前で構成することになります。パイプラインの重複実行を制限する方法については以下の記事で取り上げています。 techblog.zozo.com なぜ特徴量への変換処理をDataflow等へオフロードしていないのか? これらの構成を検証する上で、既存MLシステムでの特徴量を取得する部分をVertex Feature Storeに切り替えることを最初のステップとして想定しています。そしてこの既存MLシステムでは特徴量を取得する部分がDataflowで処理されていないことが主な理由です。本来はデータの取得と変換をDataflowで完結させる構成を取りたいものの、既存処理をApache Beamに書き換える作業は検証段階としてはコストがかかりすぎるため今回はこのような構成としました。 なぜ定期実行の仕組みにCloud Composerを使っていないのか? これは理由として以下の2点が挙げられます。 定期実行の仕組みのためだけにCloud Composerを使うのはコストがかかりすぎる Vertex Pipelines + Cloud Scheduler + Cloud Functionsという構成が楽だった コストという点ではCloud Composerの料金的な面もありますが、私自身がCloud Composerに慣れていなかったことによる学習コストの側面も含んでいます。Cloud SchedulerとCloud Functionsを使うことで依存するサービスが増えて管理が煩雑になるように思えますが、やっていることは定期的にVertex Pipelinesを実行しているだけなので見た目以上にシンプルな構成となっています。 実装例 以下に特徴量の取り込み処理を行うコンポーネントの実装例を示します。BigQueryにクエリを投げて変換処理を加えた結果がCSV形式でGCSに出力された後の状態を想定をしています。 import logging import fire import gcsfs from google.api_core.client_options import ClientOptions from google.cloud.aiplatform_v1 import FeaturestoreServiceClient from google.cloud.aiplatform_v1.types import featurestore_service, io def main ( project_id: str , region: str , featurestore_id: str , entity_id: str , feature_ids: str , gcs_ingest_csv_folder: str , entity_id_field: str , timestamp_field: str , worker_count: int = 1 , ): logging.basicConfig(level=logging.INFO) fs = gcsfs.GCSFileSystem() csv_files = [f "gs://{path}" for path in fs.glob(f "{gcs_ingest_csv_folder}/*.csv" )] fs_client = FeaturestoreServiceClient( client_options=ClientOptions(api_endpoint=f "{region}-aiplatform.googleapis.com" ) ) entity_type_path = fs_client.entity_type_path( project_id, region, featurestore_id, entity_id ) feature_specs = [ featurestore_service.ImportFeatureValuesRequest.FeatureSpec( id =feature_id) for feature_id in feature_ids ] import_request = featurestore_service.ImportFeatureValuesRequest( entity_type=entity_type_path, csv_source=io.CsvSource(gcs_source=io.GcsSource(uris=csv_files)), entity_id_field=entity_id_field, feature_specs=feature_specs, feature_time_field=timestamp_field, worker_count=worker_count, ) lro_response = fs_client.import_feature_values(import_request) import_feature_values_response = lro_response.result(timeout= 3600 ) logging.info( "import_feature_values_response:" , import_feature_values_response) if __name__ == "__main__" : fire.Fire(main) 実装上の注意点 特に特徴量の取り込み部分にいくつかハマりどころがあったのでご紹介します。 取り込み対象のデータソースはVertex Feature Storeと同一リージョンに配置する 特徴量の更新時間を示すタイムスタンプの形式はデータソースごとに異なる 登録した特徴量と型の不整合があっても処理自体は正常終了するが取り込みはされない ドキュメントに記載されている内容がほとんではありますが、これらについて1つずつ説明していきます。 取り込み対象のデータソースはFeature Storeと同一リージョンに配置する 2022年1月時点ではVertex Feature StoreのデータソースとしてBigQueryのテーブルかGCSのCSVまたはAvro形式のファイルを利用できます。これらのデータを読み込んでVertex Feature Storeに特徴量を取り込みますが、このデータを配置しているリージョンをVertex Feature Storeと一致させる必要があります。 これは特徴量のバッチ取得の際には大きな問題になりませんが、オンラインサービングをする場合には構成を工夫する必要があります。例えば日本だけで展開しているサービスを考えた場合、レイテンシを下げるためにasia-northeast1(東京リージョン)にVertex Feature Storeを配置したとします。こうした場合データソースとして指定できるのはasia-northeast1のみとなるため、特徴量取り込み用のデータの配置先が制限されることになります。 例えば「BigQueryのデータセットはUSリージョンのみを使っていた」というユーザーも多いかと思うので、上記のような場合には例外的に別なリージョンへデータセットを作るといった運用が必要となります。 特徴量の更新時間を示すタイムスタンプの形式はデータソースごとに異なる これは ドキュメントに記載されている内容 ではありますが、データソースごとに以下のような制約があります。 BigQuery テーブルの場合、タイムスタンプは TIMESTAMP 列に入ります。 Avro の場合、タイムスタンプは long 型かつ、論理型が timestamp-micros でなければなりません。 CSV ファイルの場合、タイムスタンプは RFC 3339 形式にする必要があります。 例えばCSVファイルの場合は %Y-%m-%dT%H:%M:%SZ といった形式でタイムスタンプを持たせることになります。様々なデータソースから特徴量の取り込みを行いたい場合はデータソースに合ったタイムスタンプへの変換処理が必要となります。 登録した特徴量と型の不整合があっても処理自体は正常終了するが取り込みはされない 当然と言えば当然ではありますが、取り込む特徴量の型は事前に登録した特徴量の型と合致させなければいけません。以下の図で正しく特徴量が取り込まれているジョブは一番下のもので、それ以外のジョブは Finished となっているものの実際には特徴量の取り込みに失敗しています。 初めは一度に取り込む特徴量数・ジョブ数の量が原因かと思って調査していましたが、結論はタイトルの通り特徴量の型の不整合が原因でした。例えば特徴量として int64 型を期待していても、pandasではnull値が存在しているとカラムの型は float64 となってしまいます。 import pandas as pd df = pd.DataFrame([[ 1 , 2 ],[ 3 , None ]]) df.dtypes """ 0 int64 1 float64 dtype: object """ これと同様の現象が起こり、期待した型での取り込みが行われていない状態でした。 この事象の暫定対策として、特徴量の登録で使用したyamlファイルを元に特徴量の型を参照してキャストする方法を取りました。ここで保存した特徴量を取り込み対象として指定するイメージです。 import yaml with open ( "path-to-yaml/feature_set.yaml" , "r" ) as f: feature_set = yaml.safe_load(f) # features は前処理済みの特徴量セット # 既に null のレコードは drop した後である想定 for fs in feature_set: features[fs[ "feature_id" ]] = features[fs[ "feature_id" ]].astype(fs[ "value_type" ]) features.to_csv( "path-to-csv/features.csv" , index= False ) 上記の例ではVertex Feature Storeに登録した内容と辻褄を合わせるために型を変換していますが、本来はここで特徴量の型不一致としてアラートを投げるか否かの検証が入ります。今回はVertex Feature Storeの検証が目的なのでそういった監視は入れていませんが、今後の課題として検討していきたいと考えています。 特徴量の管理基盤を設計・運用してみてわかったこと ここで、冒頭に述べた弊チームでのMLシステムに対する課題を再掲します。 私たちのチームでは上記の「案件の度に様々なコードやクエリについて車輪の再発明がされている」という課題に対する取り組みの一環で、特徴量の再発明を防止して適切に再利用できること・特徴量が適切にモニタリングすることを目的として、Vertex Feature Storeの導入を検討することとしました。 実際にVertex Feature Storeをシステムの一部に導入してみた結果、上記の課題感全てがクリアになるというわけではなく、同時に運用上の新たな課題が生まれてきました。以下ではその課題のいくつかを紹介します。 再利用できる特徴量を作成・更新することの難しさ 例えばあるMLモデルAで利用する、「直近30日間でサイトにアクセスした回数を示す特徴量」として access_count_30days を作成するとします。別なMLモデルBではより長いスパンでの行動傾向を見るために集計期間を60日間に広げて access_count_60days を作成…となると、結局似たような特徴量が量産されることになります。 ここでは集計期間を変更した例を挙げていますが、集計定義を微修正した特徴量を作る場合は同様の問題が生じます。また似たような特徴量を作りすぎると、 検索性が悪くなる上に更新の度に集計期間が重複している似たようなクエリを大量に実行する ことになるため、コストがかかりすぎてしまう可能性もあります。 こうした問題に対するアプローチは 予め決めた複数のウィンドウサイズで集計し、そのウィンドウの組み合わせで特徴量を定義する という方法があります。特にオンラインで特徴量をサービングするような場合だと、リアルタイムに特徴量を集計して更新する仕組みが必要となります。具体的な実現方法について、マネージドFeature Storeを提供している Tecton.ai の以下の記事が参考になるかと思います。 towardsdatascience.com バッチ実行のMLモデルに与える特徴量の鮮度 本記事で紹介した特徴量更新の仕組みを利用してFeature Storeに保存されている特徴量をデイリー更新するとします。更にデイリーバッチで実行される予測モデルがあった場合、予測モデルには予測する直前までの最新の特徴量を与えるかと思います。このような場合、特徴量の更新状態を確認した後にFeature Storeの特徴量を更新する必要があり、結果として予測バッチがシステムの外部に依存関係を持つことになります。 特徴量の更新処理は並行で実行できないので、こうした予測バッチが増えてくるとFeature Storeの更新処理の実行タイミングがMLシステムのボトルネックとなってしまう可能性があります。 また、特徴量を共用する場合はどの予測バッチでその特徴量を更新するか検討するなど、各処理の依存関係が複雑になることが予想されます。 このようにFeature Storeの導入を検討すると、MLシステム単独で動いている際には問題にならなかったことが新たな問題として出てきます。 そのため、既存システムでできていたことを完全に再現するのではなく、特徴量の一元管理による制約が生じることを念頭に置かなければいけません。 システムがFeature Storeに対する依存関係を持つことは避けられないことなので、この依存性は許容しつつFeature Storeを利用する上で何を制約とするのかが設計の鍵となるでしょう。 まとめと今後の展望 ここまでの内容を見てきてわかるように、Feature Storeはまだ「とりあえず使えるものの、長期的に特徴量基盤として運用を続けていくには一朝一夕で使えるものではない」状況と言えます。近頃Feature Storeの話は色々な場所で耳にするものの、いざ導入してみるとML基盤の課題を解決する銀の弾丸というわけではないことを実感しました。 上述した課題はこれまでに名だたる会社が導入してきた先行事例があるため、これからはそれらを参考にしながら課題解決に向けて改善を進めていこうと思っています。また、今回はFeature StoreとしてマネージドサービスのVertex Feature Storeを利用していますが、並行してOSSの Feast をセルフホスティングする技術検証も進めています。Feature Storeとしての課題を解決するためにどちらが適しているか、今後判断していきたいと思います。 MLOps基盤構築をもっと高速に進めたいと思っているものの全く人手が足りていないので、ご興味のある方は以下のリンクからぜひご応募ください。一緒に最高のMLOps基盤を作りましょう! hrmos.co hrmos.co 参考 Vertex AI Feature Store の概要 Cloud Scheduler でパイプライン実行をスケジュールする Vertex Feature Store で特徴量管理の MLOps はこう変わる Feast What is a Feature Store? Feature Store Milestones 【書き起こし】Vertex PipelinesとFeature Storeを活用した不正防止システム – Liu Songjie【Merpay Tech Fest 2021】 How to implement CI/CD for your Vertex AI Machine Learning Pipeline
アバター
はじめに こんにちは。ECプラットフォーム部のAPI基盤ブロックに所属している籏野 @gold_kou と申します。普段は、GoでAPI GatewayやID基盤(認証マイクロサービス)のバックエンド開発をしています。 ZOZOでは、API Gatewayを内製しています。これまでも以下の記事を公開し、ご好評いただいております。ありがとうございます。 【ZOZOTOWNマイクロサービス化】API Gatewayを自社開発したノウハウ大公開! 【ZOZOTOWNマイクロサービス化】API Gatewayの可用性を高めるノウハウを惜しみなく大公開 今回は、API Gatewayのスロットリング機能を開発しましたので、そこで得られた知見を共有いたします。ソースコードもたくさん掲載しております。マイクロサービスに興味ある方や、API Gatewayを内製する方の参考になれば幸いです。 また、本記事に掲載されているソースコードは分かりやすさを優先するため、実際とは異なる部分がありますので、あらかじめご了承ください。 はじめに スロットリングとは 要件 クライアントタイプ毎に同時接続数で制限する データストアを利用しない Canary Deploymentsに対応する 機能の概要 実装 取得処理 初回起動時のみの取得 最大の同時接続数 Podのデプロイメント種別 定期的な取得 独自エラー Primary/CanaryのPod数 Primary/Canaryへの加重比率 エラーハンドリング 定期実行 APIリクエスト処理 同時接続数のカウントアップ・カウントダウン 閾値の計算式 カナリアリリース状態の場合 カナリアリリース状態でない場合 閾値の計算と閾値判定 注意点 閾値計算の繰り上げによる弊害 Dockerイメージが約1.4倍重くなった インフラ側と依存する命名規則ができてしまった 人気商品の対策で活用 We are hiring スロットリングとは スロットリングとは、何らかのリソースに対して使用量の上限を設定し、上限を超えるものについてはその使用を制限するような処理です。本記事ではリクエストに対するスロットリングを扱うため、設定した上限を超えたリクエストを制限する機能を指します。過剰な数のリクエストを制限することで、システムが停止してしまうことや一部のクライアントがリソースを占有してしまうことを防ぎます。制限方法はいくつかあります。例えば、 HTTPステータスコード429 を即時に返す方法です。 要件 今回開発するスロットリング機能の要件です。 クライアントタイプ毎に同時接続数で制限する ZOZOTOWNのAPIでは外部サービスやクライアント端末のOSなどに応じて、クライアントをいくつかの分類に分けています。その分類をこの記事では「クライアントタイプ」と呼ぶこととします。このクライアントタイプ毎に「同時接続数」でリクエストを制限します。同時接続とは、API Gatewayがリクエストを転送処理している間の接続のことです。同時接続数はマイクロサービスへのリクエスト処理時に1つ増え、マイクロサービスからのレスポンス処理時に1つ減ります。 データストアを利用しない スロットリング機能を開発しようとすると、閾値判定するための情報をサーバ間で共有するデータストアが欲しくなります。しかし、API Gatewayはデータベースなどの外部のデータストアサービスをこれまで必要としてきませんでした。そして、今後も出来る限り、使用したくありません。なぜならば、利用しない方が可用性の低下を抑えられるからです。データストアサービスを利用するとどうしても、全体の可用性は落ちてしまいます。そこで、各サーバで動作するAPI Gatewayがそれぞれのオンメモリの情報を使ってスロットリングする要件としました。この場合、サーバ間で情報を共有しないため、スロットリングが適用されるサーバとそうでないサーバが混在してしまいますが、今回は許容できる点でした。 仮に外部のデータストアを利用する設計では、 Amazon ElastiCache for Redis の導入を検討していました。導入しなかったことにより、以下の問題を避けることができました。 可用性の低下 インフラコストの増加 ノード数が3、キャッシュエンジンがRedis、インスタンスタイプがr6g.xlarge、料金モデルがOnDemandとします。その条件で AWS Pricing Calculator を参考に計算すると、年間約12,000 USDの節約となります。 Canary Deploymentsに対応する API Gatewayは Amazon Elastic Kubernetes Service 上に構築されており、複数のPodにデプロイされています。また、 Istio の VirtualService により、API Gatewayを Canary Deployments しています。Canary Deploymentsが発生しても、変わらずにスロットリングできる必要があります。 なお、本記事ではCanary DeploymentsによりPrimaryとCanaryのそれぞれに加重がかかっている状態を「カナリアリリース状態」と呼ぶこととします。 機能の概要 スロットリング機能の処理内容は、大きく分けて、閾値の計算に必要な情報を取得する処理(図の「取得処理」)とAPIリクエスト毎に閾値判定する処理(図の「APIリクエスト処理」)に分かれます。 取得処理では、初回起動時のみ「ホスト名」と「最大の同時接続数」を取得します。また、定期的にKubernetesとIstioから「Primary/CanaryのPod数」と「Primary/Canaryへの加重比率」の情報を取得します。定期的に取得するのは、これらの値が変動するからです。 APIリクエスト処理では、リクエストを処理する際に、スロットリング対象のクライアントタイプに関して同時接続数の閾値判定をします。閾値を超していれば、転送処理をせずにクライアントへHTTPステータスコード429を即時に返します。閾値は、取得処理により取得した情報を使用して、リクエストの処理ごとに計算し直されます。計算し直すのは、閾値がインフラの状態により、変動するためです。 実装 各処理の実装とその説明です。 取得処理 以下は初回起動時のみ取得する情報です。 最大の同時接続数 ホスト名 Podのデプロイメント種別(Primary用あるいはCanary用) 以下は定期的に取得する情報です。 Primary/CanaryのPod数 Primary/Canaryへの加重比率 それぞれの取得方法について説明します。 初回起動時のみの取得 最大の同時接続数 全Pod合算での最大の同時接続数を max_concurrent_requests.yml というYAMLファイルに設定可能としています。設定する最大の同時接続数の単位は、単一のPodでなく全Pod合算としています。Podは Horizontal Pod Autoscaler(HPA) でスケーリングします。仮に単一のPod単位で設定できてしまうと、Podが増える分だけリクエストを制限しなくなります。これでは正しくスロットリングできません。したがって、全Pod合算での設定単位としています。 以下は設定例です。 client1 : 100 clinet2 : 200 アプリケーションでは起動時に max_concurrent_requests.yml を以下のように読み込み、keyがクライアントタイプでvalueが最大の同時接続数のmapで保持しています。 var maxConcurrentMap map [Client] int func init() { maxConcurrentData, e := ioutil.ReadFile(constants.ProjectDir + "config/max_concurrent_requests.yml" ) if e != nil { panic (e) } e = yaml.Unmarshal(maxConcurrentData, &maxConcurrentMap) if e != nil { panic (e) } } Podのデプロイメント種別 PodがPrimary用かCanary用かをこの記事では「デプロイメント種別」と呼ぶこととします。API Gatewayのアプリケーションが動作しているPod上で、そのPodのデプロイメント種別を判別します。Canary用Podの場合、ホスト名は api-gateway-canary-b4dd8dc5c-88n74 のような値になります。したがって、ホスト名に文字列 "canary" が含まれていればCanary用、そうでなければPrimary用と判別しています。 var depType string func SetDepType() error { hostName, e := os.Hostname() if e != nil { return e } if strings.Contains(hostName, "canary" ) { depType = "canary" } else { depType = "primary" } return nil } 定期的な取得 定期的な「Primary/CanaryのPod数」と「Primary/Canaryへの加重比率」の取得について説明します。 独自エラー 取得処理の失敗に関して2つの独自エラーを用意しました。 fetchError は、取得失敗となった場合のエラーです。 terminatingError は、Podがterminatingになった場合のエラーです。 type terminatingError struct { message string } func (e *terminatingError) Error() string { return e.message } type fetchError struct { message string } func (e *fetchError) Error() string { return e.message } Primary/CanaryのPod数 kubernetes/client-go というライブラリを使用して、KubernetesのPod情報とDeployment情報を取得しています。 まずはAPI Gatewayが動作しているPodのPod情報を取得しています。ライブラリの実行でエラーになった場合は、 [FETCH_ERROR] というプレフィックスをメッセージに付けて fetchError を返します。Podがterminatingになれば terminatingError を返します。Kubernetesの Rolling Update などにより、Podがterminatingになると、fetch処理は失敗してしまうからです。Podがterminatingになる際は、まずPodに .metadata.deletionTimestamp が設定されます。したがって、 GetObjectMeta().GetDeletionTimestamp() の返り値がnilでなければ terminatingError を返すようにしています。 次に、Deployment情報を取得しています。各Deployment情報にはPod数が含まれています。Deployment名が api-gateway のものがPrimary用で、 api-gateway-canary のものがCanary用です。 var interval = time.Second type InstanceCounts struct { Primary int Canary int } func (FetcherImpl) fetchInstanceCounts() (InstanceCounts, error ) { timeoutContext, cancel := context.WithTimeout(context.Background(), interval) defer cancel() // Pod情報の取得 pod, e := k8sClientSet.CoreV1().Pods(podNameSpace).Get(timeoutContext, podName, metav1.GetOptions{}) if e != nil { return InstanceCounts{}, &fetchError{ message: "[FETCH ERROR] " + e.Error(), } } if pod.GetObjectMeta().GetDeletionTimestamp() != nil { return InstanceCounts{}, &terminatingError{ message: "this pod is terminating" , } } // Deployment情報の取得 var i = int64 (interval) deployments, e := k8sClientSet.AppsV1().Deployments(podNameSpace).List(timeoutContext, metav1.ListOptions{TimeoutSeconds: &i}) if e != nil { return InstanceCounts{}, &fetchError{ message: "[FETCH ERROR] " + e.Error(), } } var primaryPodCount, canaryPodCount int for _, d := range deployments.Items { if d.Name == "api-gateway" { primaryPodCount = int (d.Status.AvailableReplicas) } else if d.Name == "api-gateway-canary" { canaryPodCount = int (d.Status.AvailableReplicas) } } return InstanceCounts{ Primary: primaryPodCount, Canary: canaryPodCount, }, nil } Primary/Canaryへの加重比率 istio/client-go というライブラリを使用して、IstioのVirtualService情報を取得します。VirtualServiceの各destinationにweightが設定されており、それを取得します。subsetの値に文字列 "canary" が含まれていれば、Canary用の設定です。そうでなければPrimary用です。PrimaryとCanaryのweightの合算は100です。 var interval = time.Second type TrafficWeight struct { Primary int Canary int } func (FetcherImpl) fetchTrafficWeight() (TrafficWeight, error ) { timeoutContext, cancel := context.WithTimeout(context.Background(), interval) defer cancel() vs, e := istioClientSet.NetworkingV1beta1().VirtualServices(podNameSpace).Get(timeoutContext, virtualServiceName, metav1.GetOptions{}) if e != nil { return TrafficWeight{}, &fetchError{ message: "[FETCH ERROR] " + e.Error(), } } // istioによるルーティングは実施しないためGetHttpのインデックスは必ず0。Canaryがない環境も存在するためGetRoute()[1]はしない。 httpRoute := vs.Spec.GetHttp()[ 0 ].GetRoute()[ 0 ] var weight = httpRoute.GetWeight() if strings.Contains(httpRoute.GetDestination().Subset, "canary" ) { return TrafficWeight{ Primary: 100 - int (weight), Canary: int (weight), }, nil } return TrafficWeight{ Primary: int (weight), Canary: 100 - int (weight), }, nil } エラーハンドリング fetch 関数の内部で fetchInstanceCounts 関数と fetchTrafficWeight 関数を実行しています。 fetchInstanceCounts 関数のエラーハンドリングでは、 fetchError と terminatingError に対してcase文を用意しています。 fetchError であればエラー回数も含めてWARNログを出力します。ログは Amazon CloudWatch Logs により Amazon S3 へ集約されます。単位時間あたりに一定数以上の fetchError によるWARNログが出力された場合は、Slackで通知されるようにしています。また、 fetchError の回数は fetchErrCount でカウントしています。 fetchErrCount > fallbackThreshold の場合、つまり fetchError が一定数より多く発生した場合はスロットリング機能を一時的にOFFにする「機能縮退」をします。 fetchInstanceCounts と fetchTrafficWeight の両方の実行が成功した場合のみ fetchErrCount を0に戻します。 terminatingError であればそのままエラーを返します。エラーを返した先で定期処理が停止します。これにより、不要にWARNログが出力されることを回避しています。 fetchTrafficWeight 関数のエラーハンドリングでは、エラー種類は1つなので、 fetchError に対してif文を用意しています。 var instanceCounts InstanceCounts var trafficWeight TrafficWeight var fetchErrCount int const fallbackThreshold = 60 func fetch(f Fetcher) error { counts, e := f.fetchInstanceCounts() if e != nil { switch e.( type ) { case *fetchError: fetchFailedLog.Warn(fmt.Sprintf( "(%v/%v) " , fetchErrCount, fallbackThreshold) + e.Error()) fetchErrCount++ // fetchErrorが一定数発生した場合は機能縮退 if fetchErrCount > fallbackThreshold { instanceCounts = InstanceCounts{ Primary: 0 , Canary: 0 , } } return nil case *terminatingError: return e } } else { instanceCounts = counts } w, e := f.fetchTrafficWeight() if e != nil { if err, ok := e.(*fetchError); ok { fetchFailedLog.Warn(fmt.Sprintf( "(%v/%v) " , fetchErrCount, fallbackThreshold) + err.Error()) fetchErrCount++ // fetchErrorが一定数発生した場合は機能縮退 if fetchErrCount > fallbackThreshold { trafficWeight = TrafficWeight{ Primary: 0 , Canary: 0 , } } return nil } } else { trafficWeight = w fetchErrCount = 0 } return nil } 定期実行 Pod数と加重比率は変動する値です。例えばHPAでPod数は変化しますし、Canary Deploymentsで加重比率も変化します。そこで、Pod数と加重比率に関しては、無限forループの中で Ticker により毎秒取得するようにしています。 Poll 関数内で fetch 関数を実行しています。 fetch 関数がエラーを返すのは terminatingError の場合のみです。エラーの場合は、 ticker.Stop() して無限forループを break 文で抜けます。 func Poll(f Fetcher) { ticker := time.NewTicker(time.Duration(time.Second) defer ticker.Stop() for { <-ticker.C if e := fetch(f); e != nil { ticker.Stop() break } } } Poll 関数はmain.goにて、goroutineの中で実行されています。ソースコードは割愛します。 APIリクエスト処理 APIリクエスト処理についてです。 各クライアントタイプはそれぞれに応じたAPIクライアントトークンをヘッダーに付与してリクエストします。API Gatewayでは、このAPIクライアントトークンを利用して、 APIクライアントトークン認証 を行なっています。また、APIクライアントトークン認証に関する処理はミドルウェア化されています。そのミドルウェア処理の中で、同時接続数のカウントアップ・カウントダウンや閾値判定をしています。 同時接続数のカウントアップ・カウントダウン 同時接続数はリクエスト処理時にカウントアップし、レスポンス処理時にカウントダウンします。クライアントタイプと同時接続数の組み合わせを clientCount というmapで保持します。他のリクエストと処理が混じるのを防ぐため、 Mutex で排他制御し、その中でインクリメントしています。一度変数 count に代入しているのは、カウントアップした値を使った処理(ここでは簡略化のためソースコードを省略しております)の完了を待たずに、素早く排他制御を終了するためです。ミドルウェアなので、 defer に実装されているカウントダウンはレスポンス処理時に実施されます。 var mutex = &sync.Mutex{} var clientCount = map [client.Client] int {} func ClientTokenMiddleware(next http.Handler) http.Handler { return http.HandlerFunc( func (w http.ResponseWriter, r *http.Request) { ... mutex.Lock() clientCount[c]++ count := clientCount[c] mutex.Unlock() defer func () { mutex.Lock() clientCount[c]-- mutex.Unlock() }() ... next.ServeHTTP(w, r.WithContext(ctx)) }) } 閾値の計算式 カナリアリリース状態かどうかで計算式が異なります。 カナリアリリース状態の場合 Primary用Podにおける閾値の計算式は以下です。 最大の同時接続数 × Primaryへの加重比率 / Primary用Pod数 Canary用Podにおける閾値の計算式は以下です。 最大の同時接続数 × Canaryへの加重比率 / Canary用Pod数 以下は計算の具体例です。 最大の同時接続数:100 Primary:Canaryの加重比率:90:10 Primary:CanaryのPod数:9:1 Primary用Podにおける閾値計算は 100 × 0.9 / 9 = 10 となります。Canary用Podにおける閾値計算は 100 × 0.1 / 1 = 10 となります。 カナリアリリース状態でない場合 閾値の計算式は以下です。 最大の同時接続数 / Primary用Pod数 ここで「全てのPod数」でなく、「Primary用Pod数」としているのは、 カナリアリリース状態でなくても最低1台は常にCanary用Podが起動しているためです。最低1台は常にCanary用Podが起動しているのは、HPAを利用しているため、minReplicasを1より小さい値で設定できないためです。 以下は計算の具体例です。 最大の同時接続数:300 PrimaryのPod数:20 閾値計算は 300 / 20 = 15 となります。 閾値の計算と閾値判定 IsOverLimit 関数は、取得した情報を使って閾値を算出し、閾値判定します。閾値を超していればtrueを返し、以下であればfalseを返します。上記の計算式で閾値を算出し、現在の同時接続数と比較をします。閾値の計算結果がint型の切り捨てにより0になった場合は、閾値を1に繰り上げます。これにより全てのリクエストがスロットリングされるのを防ぎます。 max_concurrent_requests.yml に設定していないクライアントタイプはスロットリング対象外のためfalseを返します。スロットリングに必要な情報を未取得の場合、あるいは機能縮退時にはfalseを返します。第一引数はクライアントタイプです。第二引数は現在の同時接続数です。第三引数はデプロイメント種別です。第四引数はPod数です。第五引数は加重比率です。 fetch 関数では fetchError が一定数より多く発生した場合には、 instanceCounts と trafficWeight のそれぞれの両フィールドに0を代入しています。そして、 IsOverLimit 関数ではいずれかの両フィールドが0だった場合には、 false を返します。これにより、機能縮退をしています。 func IsOverLimit(client Client, count int , depType string , instanceCounts deployment.InstanceCounts, trafficWeight deployment.TrafficWeight) bool { // ymlに設定がないクライアントタイプはfalse maxConcurrentValue, ok := maxConcurrentMap[client] if !ok { return false } // スロットリングに必要な情報を未取得の場合、あるいは機能縮退時はfalse if depType == "" || instanceCounts.Primary+instanceCounts.Canary == 0 || trafficWeight.Primary+trafficWeight.Canary == 0 { return false } // カナリアリリース状態でない場合 if trafficWeight.Canary == 0 { threshold := maxConcurrentValue / instanceCounts.Primary if threshold == 0 { threshold = 1 } return count > threshold } // カナリアリリース状態の場合 var w int var c int if depType == "primary" { w = trafficWeight.Primary c = instanceCounts.Primary } else { w = trafficWeight.Canary c = instanceCounts.Canary } if c == 0 { return false } threshold := maxConcurrentValue * w / 100 / c if threshold == 0 { threshold = 1 } return count > threshold } APIクライアントトークン認証ミドルウェアの中で、カウントアップ処理の後に IsOverLimit 関数を実行します。trueであればHTTPステータスコード429をHeaderに書き込みます。 func ClientTokenMiddleware(next http.Handler) http.Handler { return http.HandlerFunc( func (w http.ResponseWriter, r *http.Request) { ... if client.IsOverLimit(c, count, deployment.DepType(), deployment.GetInstanceCounts(), deployment.GetTrafficWeight()) { w.WriteHeader(http.StatusTooManyRequests) return } ... }) } 注意点 今回開発したスロットリング機能にはいくつか注意すべき点があります。 閾値計算の繰り上げによる弊害 設定値よりも多くのリクエストが来てもスロットリングしない可能性があります。理由は、閾値の計算結果を0から1に繰り上げるケースがあるからです。 カナリアリリース状態でない場合、最大の同時接続数の設定値が「PrimaryのPod数」より小さい場合は、各Podでスロットリング判定に使用する閾値の計算結果は1に繰り上げとなります。例えば、PrimaryのPod数が100で最大の同時接続数を50で設定した場合です。 カナリアリリース状態の場合、設定値が「100*pod数/加重値」より小さい場合は、各Podでスロットリング判定に使用する閾値の計算結果は1に繰り上げとなります。例えば、CanaryのPod数が5で、加重値が20の場合に設定値が25より小さい場合です。 上述の通り、設定値やPod数などによっては、設定値通りの制限ができなくなり、本来のスロットリング機能の役割を果たせなくなる可能性があります。しかしながら、限定的なケースであることと、リクエストに対して常にHTTPステータスコード429を返してしまう可能性があるよりは良いと判断してこの仕様としています。 Dockerイメージが約1.4倍重くなった スロットリング機能の開発前時点で Amazon Elastic Container Registry にpushしていたイメージサイズは36.6MBでしたが、開発後は51.6MBに増えました。理由は、 go.mod で以下のパッケージが追加され、それらの依存パッケージがたくさん追加されたためです。 istio.io/client-go k8s.io/api k8s.io/apimachinery k8s.io/client-go インフラ側と依存する命名規則ができてしまった 今回開発したスロットリングの仕様上、インフラのリソース名と依存するようになりました。以下の命名をインフラ側で変更する場合には、バックエンドの修正も必要となります。しかしながら、変更される見込みは基本的にはありません。 Deployment名 Deployment情報に含まれるPod数を使用しています。Deployment名が api-gateway であればそのDeploymentのPod数をPrimaryのPod数として計上しています。 api-gateway-canary であればCanaryのPod数として計上しています。 VirtualServiceのsubset subsetの値に文字列 "canary" が含まれていれば、そのsubsetが属するdestinationのweightをCanaryの加重値としています。含まれていなければPrimaryの加重値としています。 ホスト名 ホスト名に文字列 "canary" が含まれていれば、そのPodのデプロイメント種別をCanaryとしています。そうでなければPrimaryとしています。 人気商品の対策で活用 開発時点では想定していなかったのですが、人気商品の対応にスロットリング機能を活用しています。ZOZOTOWNでは、福袋のように限定で発売されるような人気商品があります。人気商品は、発売開始のタイミングで一時的にアクセスが急増する傾向にあります。人気商品に対するPOSTリクエストに関しては、専用の特別なAPIクライアントトークンを中継システムにて付与し、スロットリング対象としています。これにより、通常商品の購入に影響がでにくくなりました。 We are hiring ZOZOTOWNのマイクロサービス化は現在進行中です。今後も既存のマイクロサービスの追加開発や、新規のマイクロサービス立ち上げなど、やりがいのあるたくさんの仕事があります。エンジニア絶賛募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。お待ちしております。 corp.zozo.com
アバター
デバイス管理に従事する全国の情シスの皆様、日々の業務お疲れ様です。コーポレートエンジニアリング部ファシリティブロックの佐藤です。いわゆる”情シス”と呼ばれる役割のチームに所属し、PCやネットワーク機器などの社内インフラの管理・運用に携わっています。 本記事では、MDM(Microsoft Intune)と自動デバイス登録(ADE)を併用したMacの自動キッティングの事例を紹介します。さらに、自動キッティングに必要なアプリ展開や、業務用としてMacを制御するための設定方法も説明します。 目次 目次 Mac自動キッティング導入前の課題と解決策 利用するAppleサービス 事前準備 IntuneとADEのデバイス連携 IntuneとADEのアプリ連携 先進認証を備えたセットアップアシスタント ポータルサイト展開 ユーザーによるセットアップの流れ デバイスグループ アプリの展開方法 Microsoft Defender for Endpointによるアプリ展開 VPPアプリによるアプリ展開 Webリンクによるアプリ展開 基幹業務アプリ(.intunemacファイル)によるアプリ展開 基幹業務アプリ(.dmgファイル)によるアプリ展開 構成プロファイルによるデバイス制御 FileVaultの自動有効化 まとめ さいごに Mac自動キッティング導入前の課題と解決策 ZOZOグループでは、2020年からWindows 10のゼロタッチキッティングを導入しています。これにより、ユーザーによる手作業を無くし、初回セットアップを短時間で完了させることを実現しています。 具体的な取り組みは、以下の記事で紹介しているので、併せてご覧ください。 techblog.zozo.com 一方、開発業務に従事するエンジニアに貸与しているMacのセットアップは全てが手作業の状態でした。そのため、下記の問題が生じていました。 セットアップ手順を説明するために、ユーザーと管理者間のスケジュール調整が必要になる 全て手動でアプリインストールやシステム環境を設定するため、作業漏れのリスクがある 手作業で対応するため、手順書やセットアップに要する時間が長くなる 上記の課題を解決するために、 IntuneとADEの連携に加えてデバイス登録プロファイル「先進認証を備えたセットアップアシスタント」 を導入しました。その結果、Macの初回セットアップ時にAzure Active Directory登録と二要素認証の両方を要求することが可能となり、キッティング作業の簡略化に成功しました。 本記事では、このIntune+ADEで簡略化した環境構築手順とエンドユーザー側でのセットアップの流れを紹介します。 利用するAppleサービス 今回のMac自動キッティングでは、下記のAppleサービスを利用します。 Automated Device Enrollment(以下、ADE) Apple社が提供する企業向けデバイス導入支援サービス Apple Business Manager(以下、ABM) 組織単位でデバイス登録作業やアプリ購入を一括管理するWebポータル Volume Purchase Program(以下、VPP) 企業向けにアプリ購入・展開を効率化するためのプログラム デバイスのシリアル番号登録 弊社では、デバイス購入時にリース会社へADE登録を依頼することで、ABMへデバイス情報を登録している support.apple.com 事前準備 まず、Microsoft Intune(以下、Intune)で、Appleデバイスを管理するための事前準備をします。 Apple ID の用意 IntuneとABMの作業に必要となる、組織で利用するApple IDを用意する Apple MDMプッシュ証明書 の取得 IntuneでAppleデバイスを管理するために必要となる、Apple MDMプッシュ証明書を下図の流れで取得する docs.microsoft.com IntuneとADEのデバイス連携 本章の作業は、ABMに登録されたデバイス情報をIntuneに同期させるためのものです。 手順は以下の通りです。 Microsoft Endpoint Manager管理センターから、[デバイス] > [macOS] > [macOS 登録] > [Enrollment Program トークン]に移動し、[追加]をクリックする [基本]ページで下記の2項目を設定する [ユーザー情報とデバイス情報の両方を Apple に送信するためのアクセス許可を Microsoft に付与します。]に同意する [トークンを作成するために必要な Intune 公開キー証明書をダウンロードしてください。]の[公開キーをダウンロードします]をクリックし、トークンの作成に必要な証明書をダウンロードし、保管しておく トークンを作成するために、ABMを別ウィンドウで開く [設定]> [デバイス管理の設定]から[MDMサーバを追加]をクリックする [名称未設定のMDMサーバ]ページで、以下の情報を入力し、[保存]をクリックする [MDMサーバ情報] > [MDMサーバ名]:Intuneに表示するMDMサーバの名前を設定する [MDMサーバ情報] > [MDMサーバがデバイスを解除することを許可します]:チェックを付け、Intune経由でデバイスをABMから解除可能にする [MDMサーバの設定] > [ファイルを選択]:前述の手順でダウンロードした公開鍵証明書をアップロードする [MDMサーバの設定] > [新規サーバトークンを作成]:生成されたサーバトークンをダウンロードする Intune画面に戻る [Apple ID]:トークン作成時にABMで使用されたApple IDを保存する [Apple トークン]:ABMでダウンロードしたサーバトークンを追加する 設定完了後、[次へ]をクリックする 以上の作業が完了すると、[状態] がアクティブに変わります。 この状態になれば、ABMに登録されたデバイス情報がIntuneに同期されます。 IntuneとADEのアプリ連携 本章の作業は、ABMで購入したAppleストアアプリをIntuneに同期させるためのものです。 手順は以下の通りです。 Microsoft Endpoint Manager管理センターから、[テナント管理] > [コネクタとトークン] > [Apple VPP トークン] に移動し、[作成] をクリックする [基本]ページで、下記の3項目を設定し、[次へ]をクリックする [トークン名]:Intuneに表示するVPPトークンの名前を設定する [Apple ID]:トークン作成時にABMで使用されたApple IDを保存する [VPP トークン ファイル]:ABMで[設定] > [Appとブック]でダウンロードしたサーバトークンを追加する 下図に示す各種設定を行い、[次へ]をクリックする 以上の作業が完了すると、[状態] がアクティブに変わります。その状態でABM側でライセンス購入すると、Intuneのアプリ一覧に同期されるようになります。 先進認証を備えたセットアップアシスタント 本章の作業は、ABMから同期されたデバイス用の登録プロファイルを作成するものです。 プロファイルの設定から 先進認証を備えたセットアップアシスタント(以下、セットアップアシスタント) を有効化することで、初回セットアップ時にAzure Active Directory認証などを実施可能です。その結果、デバイス情報がどこにも登録されていない、という状況を回避できます。 しかし、本記事の執筆時点では、 セットアップ後にユーザー自身でポータルサイトアプリにサインインする必要がある という点に注意が必要です。もし、サインインの作業がされていない場合、デバイスはIntuneに登録されますが、コンプライアンス状態が報告されない状況になります。この状態で、条件付きアクセスと組み合わせて利用する場合、ユーザーは会社のデータやリソースにアクセスできなくなります。そのため、セットアップ完了後にユーザーとデバイスを紐づけるために、ポータルサイトアプリへのサインインの依頼を入念に行っています。 手順は以下の通りです。 Microsoft Endpoint Manager管理センターから、[デバイス] > [macOS] > [macOS 登録] > [Enrollment Program トークン] > [<設定したトークン名>] > [プロファイル] に移動し、[プロファイルの作成] > [macOS] をクリックする [基本]ページで、下記の3項目を設定し、[次へ]をクリックする [名前]:Intuneに表示するプロファイル名を設定する [説明(オプション)]:プロファイルの説明を任意で設定する [プラットフォーム]:自動的に設定される [ユーザー アフィニティと認証方法]で、以下の2項目を設定する [ユーザー アフィニティ]:[ユーザー アフィニティに登録する]を選択する [認証方式]:[先進認証を備えたセットアップアシスタント]を選択する [設定アシスタント]で、デバイスのセットアップ中に表示/非表示する設定を指定する 後述のFileVault自動化を行う場合は、FileVaultの設定項目を非表示にしておく ポータルサイト展開 前述の通り、Mac自動キッティングを完了させるには、 ポータルサイト へのサインインが必須です。 弊社では、シェルスクリプトによるポータルサイトアプリ展開を採用しています。以下のサイトを参考にし、GitHubからスクリプトをダウンロードして、全デバイスにIntuneからインストール用シェルスクリプトを展開する仕組みを用意しています。 docs.microsoft.com ユーザーによるセットアップの流れ 本章では、Macを受け取ったユーザーが実施する作業を紹介します。前述の登録プロファイルの作成やデバイス情報の同期が完了した後、ユーザーにセットアップ作業を依頼します。 ユーザーがMacを自宅で受け取り、初回起動時に接続先ネットワークを設定すると自動でリモートマネージメント画面が表示されます。 すると、Azure Active Directoryへの認証画面が表示されるので、ユーザーは自身の組織アカウントを入力します。 MFA登録画面が表示されるので、二要素認証を設定します。 設定アシスタントで指定した設定画面が表示された後、Macのデスクトップ画面が表示されます。そして、ユーザーがポータルサイトへサインインすれば、Intune登録は完了です。 デバイスグループ 環境内に、手動でキッティングされたMacと自動キッティングされたMacが混在しており、自動キッティングされたMacのみにアプリや構成プロファイルを適用したいケースがあります。その場合、自動キッティングされたMacのみが所属するグループを作成します。 Azure Active Directoryの動的グループを利用して、自動キッティングされたMacのみが所属するグループを作成します。なお、ルールの作成にはデバイス属性 enrollmentProfileName を利用します。[enrollmentProfileName]で指定する値は、作成した登録プロファイルの名前です。 動的グループにデバイスが追加されるタイミングはリアルタイムではない点に注意が必要です。アプリや構成プロファイルの適用には遅延が生じるため、その点を認識しておきましょう。 アプリの展開方法 これまでの手動セットアップでは、業務上必須となるアプリをユーザーが自らインストールしていました。セットアップ完了までの時間もかかるため、全てのアプリをIntuneで展開する形式に切り替えました。本章では、弊社がMac自動キッティングで採用しているアプリ展開方法の事例を紹介します。 Microsoft Defender for Endpointによるアプリ展開 弊社では、EDRとして Microsoft Defender for Endpoint (以下、Defender)を全デバイスに導入しています。 そのDefenderを利用すると、Intuneから簡単にアプリ展開が可能です。 Microsoft Endpoint Manager管理センターから、[アプリ] > [macOS]をクリックする [追加]から、アプリの種類で[Microsoft Defender for Endpoint]を選択する docs.microsoft.com VPPアプリによるアプリ展開 ABMで購入したアプリをIntune経由でアプリ展開可能です。 ABMで、[Appとブック]から購入するアプリを検索する ライセンス購入画面で、アプリ情報を割り当てるVPPトークンを指定し、ライセンス数量を入力して[入手]をクリックする Intuneとの同期により、Intune上のアプリ一覧に表示される Webリンクによるアプリ展開 アプリを特定のURLからダウンロードする場合は、[Web リンク]を利用してアプリ展開が可能です。Macの場合、Dockにロゴが表示され、ロゴをクリックすることで指定URLからアプリをダウンロードさせることができます。ユーザーが自らインストールした方がスムーズな場合には、こちらの手法を採用しています。 ロゴを指定することで、ユーザーが直感的に理解しやすくなります。そのため、それぞれのロゴを用意することをおすすめします。 基幹業務アプリ(.intunemacファイル)によるアプリ展開 基幹業務アプリとして展開可能なファイル形式は .pkg のみです。しかし、そのままIntuneに登録できるわけではなく、.intunemac 形式に変換する必要があります。Mac用のIntuneアプリラッピングツールをGitHubからダウンロードし、ターミナルで .pkg ファイルを .intunemac ファイルに変換してから展開します。 docs.microsoft.com 基幹業務アプリ(.dmgファイル)によるアプリ展開 .dmg 形式のアプリをIntuneから展開する機能がプレビュー公開されています。 なお、この手順には前提条件があるため、Microsoft公式サイトでご確認ください。 docs.microsoft.com Slackを例に、展開までの設定の流れを紹介します。 Microsoft Endpoint Manager管理センターから、[アプリ] > [macOS] に移動し、[追加] から[macOS のアプリ (DMG)]をクリックする [アプリ情報]ページで、[アプリ パッケージ ファイルの選択]をクリックする 展開するアプリの .dmg ファイルを選択する [アプリ情報]ページで、下記の3項目を設定し、[次へ]をクリックする [名前]:Intuneに表示するアプリ名を設定する [説明]:アプリの説明を任意で設定する [発行元]:アプリの発行元を任意で設定する [要件]ページで、展開対象とするmacOSのバージョンの最小値を選択し、 [次へ]をクリックする [検出ルール]ページで、アプリが展開されているかを検出するルールを設定する Mac側へ展開対象アプリをインストールし、下記のコマンドで得られる情報を設定する [割り当て]ページで、展開するグループを追加し、[次​​へ]をクリックする アプリ展開後にデスクトップ上に通知が表示やアプリケーションディレクトリに表示されることを確認する 構成プロファイルによるデバイス制御 手動設定が必要だった作業を、Intuneの構成プロファイルによって自動化します。これにより、ユーザーの作業負担の軽減と作業漏れの防止が可能です。 本章では、弊社で採用している事例を紹介します。 FileVaultの自動有効化 弊社では、Macの初回セットアップ時にFileVaultの有効化を必須としています。FileVaultとは、macOSに搭載されたディスクの暗号化機能です。手動でセットアップ対応する場合は、FileVaultの有効化をユーザーが実施した後に、回復キーをデバイス管理者に伝える必要がありました。現在は、Intuneで構成プロファイルを適用することにより、FileVaultを自動的に有効化し、Intune上で回復キーを保管できる仕組みを構築できています。 Microsoft Endpoint Manager管理センターから、[デバイス] > [macOS] > [構成プロファイル]に移動し、[プロファイルの作成] をクリックする [プロファイルの種類]は、[テンプレート] > [Endpoint Protection]をクリックする [FileVault]にある、[FileVault を有効にする]を[はい]と設定する 作成した構成プロファイルをデバイスへ展開する Macを再起動すると、FileVaultを有効化する旨のポップアップ画面が表示される [今すぐ有効にする]をクリックし、FileVaultを自動的に有効化する IntuneにFileVaultの回復キーが保管されていることを確認する まとめ セットアップアシスタントの導入により、Macのセットアップ対応がスムーズになりました。また、ユーザーによる初期キッティングの作業負担を大幅削減することもできました。 しかし、これで全ての課題が解消した訳ではありません。アプリの展開やプロファイル適用には、想像以上の時間を要します。そのため、ユーザーの想像を超える部分に対しては、サポートの強化も必要です。 さいごに ZOZOでは、社内の課題をITの力で解決する仲間を募集中です。WindowsやMacはもちろん、スマホやタブレットなど、様々なデバイスを効率的に管理・制御するための取り組みにチャレンジしていきます。カジュアル面談も実施していますので、デバイス管理について語りましょう! ご興味のある方は、下記のリンクからぜひご応募ください。 hrmos.co
アバター
こんにちは、ZOZO CTOブロックの @ikkou です。 ZOZOでは、2/25に ZOZO Tech Talk #4 - Webフロントエンド を開催しました。 zozotech-inc.connpass.com 本イベントは、これまで夕刻に開催してきたMeetupとは異なり、ランチタイムに開催する「ZOZO Tech Talk」シリーズです。ZOZO Tech Talkでは、ZOZOがこれまで取り組んできた事例を紹介していきます。 そして、第4回はWebフロントエンド開発の中でも「新規事業」をテーマに弊社エンジニアが発表しました。 登壇内容 まとめ 弊社の社員2名が登壇しました。 GraphQLのあまり知られていない魅力(スキーマの表現力編) (計測プラットフォーム開発本部 計測システム部 / 西田 雅博) ZOZOのショップスタッフ向け新規サービス「FAANS」におけるチームの取り組みとWeb技術 (メディア開発本部 FAANS部 / 田中 翔) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは、WEAR部の繁谷です。SREとして WEAR の運用・保守・開発をしています。 WEARでは、 以前の記事 で説明した通り、画像配信のリプレイスを行ってきました。本記事ではSRE観点で画像配信のリプレイスや Akamai Image & Video Manager (以下、Image Manager)を利用した画像リサイズの導入の事例を説明します。 techblog.zozo.com WEARにおける画像配信の課題 前述の記事でも紹介している通り、リプレイス前のWEARの画像配信は下図の構成でした。コーディネート投稿時などのタイミングでIISのAPIを叩き、オリジナル画像をS3に保存します。その書き込みをフックとし、オリジナル画像をリサイズするAWS Lambdaが実行され、派生画像を作成します。そして、作成された派生画像をCDNで配信します。 図1 : リプレイス前の構成図 しかし、この構成は下記の諸々の課題を抱えていました。 画像アップロードのアプリケーションがメンテナンスできないIISやVBScriptで稼働している IaC(Infrastructure as Code)されていないAWSのリソースがある 画像のリサイズを行うLambdaアプリケーションが古いNode.jsで書かれている 膨大なオリジナル画像と、それらをリサイズした派生画像によりS3のコストが増加している 上記の課題のうち、上の2つは前回の記事で、APIのリプレイス、新しいAWSアカウントへの移行によってそれぞれ解決できました。本記事では、主に下の2つの課題に対するアプローチを紹介します。 リプレイス戦略 本章では、前章同様に前回の記事の再掲を含みますが、上記の課題を解決するためのリプレイス戦略を説明します。 リプレイスのゴール まず始めに、リプレイスで目指す完成形を紹介します。 図2 : リプレイス後の構成図 図1で示したリプレイス前の構成と比較すると、非常にシンプルな構成になっています。以下が変更点です。 AWSのリソースをIaCされた新アカウントへ移行 画像アップロードAPIをECS上のRuby on Railsアプリケーションへリプレイス CDNをAkamaiへ変更 LambdaによるリサイズをImage Managerに変更し、動的に画像リサイズを行うことで派生画像が削除可能な構成に変更 なお、このゴールを目指すなかで、以下の制約も存在していました。 ダウンタイムをなるべくなくす 画像のURLは変更しない 前者のダウンタイムを短くすることは当たり前のことです。それに加え、リプレイスによりURLのドメインやパスを変更してしまうとアプリケーションコードを変更しなければならないため、後者の制約も課しています。これらの制約をクリアしつつ、リリースを4つのフェーズに分けてリプレイスを進めていきます。 各フェーズの内容を順に説明します。 フェーズ1 : アップロードAPIのリプレイス 本フェーズの内容は、前述の過去記事で詳しく説明しているため、詳細は割愛します。下図に示す通り、新しいAWSのリソースを作成し、新規のアップロードは新しいAPIで行うようにしました。 図3 : フェーズ1の構成図 フェーズ2 : 旧環境のデータ移行 フェーズ2では、以下の2点を行います。Image Managerを使う前準備として新環境にAkamaiを導入することと、旧環境のAWS S3にある膨大な画像データを新環境に移行することです。 図4 : フェーズ2の構成図 Akamaiの導入手順を簡単に説明します。Amazon Route 53で、AレコードによってCloudFrontに向いている画像配信用のドメインを、CNAMEでAkamaiの Property Manager で得られたホストに向けます。なお、AWS CloudFormationからは、ダウンタイムなしでAレコードからCNAMEへの変更ができないのため、CLIを使って実施します。 下記のコマンドを試してみましょう。 aws route53 change-resource-record-sets --hosted-zone-id [ ホストゾーンID ] --change-batch file://example.json { " Comment ": " example ", " Changes ": [ { " Action ": " DELETE ", " ResourceRecordSet ": { " Name ": " example.com ", " Type ": " A ", " ResourceRecords ": [ { " Value ": " exmaple.cloudfront.net. " } ] } } , { " Action ": " CREATE ", " ResourceRecordSet ": { " Name ": " exmaple.com ", " Type ": " CNAME ", " ResourceRecords ": [ { " Value ": " exmaple.akamaized.net " } ] } } ] } コマンドを実行してみたところ、以下のエラーにより更新できません。 An error occurred ( InvalidChangeBatch ) when calling the ChangeResourceRecordSets operation: [ Tried to delete resource record set [ name = '[ドメイン名]' , type = 'A' ] but the rdata provided is invalid, RRSet of type CNAME with DNS name [ ドメイン名 ] . is not permitted as it conflicts with other records with the same DNS name in zone [ ゾーン ]] これは RFC1912 の制約に引っかかるためです。よって、この場合のみ、メンテナンスで変更しています。 データ移行をするためにはクロスアカウントのS3間のデータコピーが必要です。また、旧環境のS3バケットへ書き込みのあるAPIがまだ残っているため、それらのAPIで書き込まれたデータを随時新環境にコピーする必要もあります。 まず検討したのは Amazon S3 バッチオペレーション で全データをコピーし、旧環境に書き込まれる新しいデータはS3の レプリケーション によってコピーする方法です。試してみたところ、バッチオペレーションは大量のデータを簡単にコピーできました。しかし、レプリケーションは想定通りにいきませんでした。我々の場合、レプリケーション時に下記のように送信先のパスを指定する必要がありましたが、レプリケーションでは送信先のパスを指定できないことが判明したためです。 s3://src-bucket/images -> s3://dist-bucket/dist/images その他にも、マネージドな方法として考えられるのが AWS DataSync です。ところが、これも要件にマッチしませんでした。 DataSyncで扱えるオブジェクト数には5000万の上限が存在しますが 、15億ほどある我々のオブジェクト数では、はるかにその上限を超えているためです。 結局、旧環境で画像が書き込まれた際にリサイズするLambdaで、同時に新環境にも対象の画像を書き込むようにコードを追記することで解決しました。 フェーズ3 : Image Managerの導入 この段階で、ようやくImage Managerを導入する準備が整いました。導入方法を説明する前に、Image Managerの特徴を紹介します。 Image & Video Manager Image & Video Manager はAkamaiのCDN Platform上で動作するモジュールです。このサービスは、画像や動画に様々な最適化をポリシーベースで行うことができます。類似サービスには、Fastlyの Image Optimizer が挙げられます。 Image Managerの選定理由 弊社の技術スタックから、候補はImage ManagerとFastlyのImage Optimizerに絞られました。そして、下記の観点で比較しながら、最終的に採用するサービスを選定していきます。 設定の柔軟性 料金 IaC 設定の柔軟性は、前述の「画像のURLは変更しない」という制約を守れるかどうかに着目します。 WEARでは、下記の例のようなURLで画像を配信しています。オリジナル画像のファイル名のサフィックスとして横幅のサイズを数値で指定し、リサイズされた画像を参照します。 オリジナル画像 : https://example.com/path/filename.jpg 横幅500pxでリサイズされた画像 : https://example.com/path/filename_500.jpg そのため、 filename_500.jpg といったファイル名から、横幅を抽出してリサイズされた画像を作成可能な設定が必要です。両サービスとも、先方にヒアリングを行った結果、どちらも柔軟に設定できることが判明しました。なお、Image Managerにおける設定方法は後述します。 次に料金の観点です。両社に今回の用途を伝えながら見積もりを依頼したところ、Image Managerの方が大幅に安価で提供頂けることが判明しました。 3つ目の観点のIaCです。Akamaiでは Terraformのprovider が提供されており、ほとんどの設定がTerraform上で管理可能です。ただし、Image Managerの Custom Policy は、Terraformは対応していません。一方、Image Optimizerは、VCLで管理できます。 以上の比較結果を踏まえ、両者の違いをまとめたものが下表です。料金の優位性が高いと判断し、Image Managerを選定しました。 観点 Image Manager Image Optimizer 設定の柔軟性 ○ ○ 料金 ◎ △ IaC △ ○ Image Managerの設定 次に、選定したImage Managerの設定方法を説明します。Image Managerでリサイズをするには、以下の処理が必要です。 横幅が指定されたURLからオリジナル画像の特定 URLからリサイズ後の横幅の抽出 Image Managerの適用 1, 2を元にリサイズを行うCustom Policyの作成 1. 横幅が指定されたURLからオリジナル画像の特定 前述の通り、リクエストはオリジナル画像のファイル名にリサイズ後の横幅が付随した形式で送られます。 https://example.com/path/filename_500.jpg filename_500.jpg という画像は存在せず、 filename.jpg というオリジナル画像のみ存在します。そのため、CDNからオリジンへリクエストする際には、パスを書き換える必要があります。 AkamaiのProperty Managerは、リクエストに対して様々な条件(Criteria)と動作(Behaviors)を定義できます。今回は、 Modify outgoing request path というBehaviorで正規表現によってパスを置換することで要件をクリアしました。下図の例では、パスの _[横幅](.拡張子) に当たる部分を Perl Compatible Regular Expression と Replacement で (.拡張子) に置換しています。 図5 : Modify outgoing request path 2. URLからリサイズ後の横幅の抽出 このステップでも、Property ManagerのBehaviorで、ファイル名から正規表現で抽出した値を変数として管理して対応しています。下図の例では、ファイル名に対して Regex と Replacement で _ の後の横幅を抽出し、 PMUSER_WIDTH という変数で管理しています。 図6 : Set Variable 3. Image Managerの適用 Image Managerの適用の仕方は、 ドキュメント の通りなため、本記事では省略します。 4. 1, 2を元にリサイズを行うCustom Policyの作成 Custom Policyの設定も、 ドキュメント に沿って設定しています。 Image Managerは、デフォルトではクエリパラメータによって横幅などの値を受け取り最適化しています。しかし、前述のようにProperty Managerを組み合わせることで、柔軟に対応できることがわかりました。 AkamaiのIaC 前述の通り、Akamai社がTerraformのproviderを提供しているため、Property Managerなどの設定はコードで管理できます。 しかし、今回は初期構築で複雑な設定をする必要があるため、コンソールから設定したあとにimportする方が適切でした。そのため、Akamai社から提供されているDockerを使い、その開発環境でimportします。 github.com Akamaiの認証情報を記載した .edgerc を用意し、下記のコマンドでコンテナを立ち上げると akamai terraform などが使えるシェルが起動します。 docker run --rm -it -v $HOME /.edgerc:/root/.edgerc:ro akamai/shell Property Managerをimportする場合は、下記のように実行すると terraform apply に必要な全ての設定を生成できます。 akamai terraform create-property [ property name ] akamai terraform に関する情報は、以下のリポジトリでマニュアルとして公開されています。 github.com 以上でAkamiのImage Managerによる画像のリサイズの仕組みを導入できました。構成は下図の通りです。 図7 : フェーズ3の構成図 フェーズ4 : ドメインの切り替え 最後に旧環境への書き込みがなくなったことを確認し、旧環境を指しているドメインを新環境に切り替えます。その後、不要なリソースを削除してリプレイスは完了です。 図8 : フェーズ4の構成図 おわりに WEARにおける画像配信のリプレイス戦略とAkamai Image & Video Managerの導入事例を紹介しました。 Akamai Image & Video Managerの導入に関心のある皆さんの参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com hrmos.co
アバター
こんにちは、ZOZO CTOブロックの池田( @ikenyal )です。 ZOZOでは、2/21に ZOZO Tech Talk #3 - Webフロントエンド を開催しました。 zozotech-inc.connpass.com 本イベントは、これまで夕刻に開催してきたMeetupとは異なり、ランチタイムに開催する「ZOZO Tech Talk」シリーズです。ZOZO Tech Talkでは、ZOZOがこれまで取り組んできた事例を紹介していきます。 そして、第3回はWebフロントエンド開発の中でも「リプレイス」をテーマに弊社エンジニアが発表しました。 登壇内容 まとめ 弊社の社員2名が登壇しました。 ZOZOTOWNのリプレイスにおけるWebフロントエンドのこれから (ZOZOTOWN開発本部 ZOZOTOWNWEB部 / 武井 勇也) WEAR Webのこれまでの課題とリプレイスの方針 (メディア開発本部 WEAR部 / 藤井 麻衣) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは、ZOZO CTOブロックの池田( @ikenyal )です。 ZOZOでは、2/17に ZOZO.go Meetup を開催しました。 zozotech-inc.connpass.com 本イベントでは、ZOZOの開発において「Go言語」にフォーカスした技術選定や設計手法、設計時の考え方などを具体的な事例を交えながら紹介します。 登壇内容 まとめ 弊社の社員3名が登壇しました。 FAANSを支えるアーキテクチャ (メディア開発本部 / 脇阪 博成) アパレル生産のGo活用 (生産プラットフォーム開発本部 生産プラットフォーム開発部 / 池田 悠司) ZOZOTOWNリプレイスにおけるGoの活用紹介 (技術本部 ECプラットフォーム部 / 山添 貴哉) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは、データ基盤の開発・運用をしている谷口( case-k )です。 本記事では、BigQueryで秘密情報を守るためのリソースである、ポリシータグをご紹介します。ポリシータグの概要から採用理由、仕様を考慮したデータ連携の仕組みや運用における注意点まで幅広くお伝えします。 ポリシータグとは ポリシータグを採用した理由 匿名化による機密性の高さ 機密性と利便性の両立 データ基盤を保守運用しやすい 秘密情報をテーブルに新規追加しやすい 秘密情報の権限管理がしやすい ポリシータグを活用したデータ連携の仕組み 利用者が参照するデータ連携後のテーブル 2つのデータ連携基盤 日次データ連携基盤 基幹DBからBigQuery(Private)へのロード BigQuery公開環境への書き込み リアルタイムデータ連携基盤 BigQueryロード前にマスクしたカラムを追加 BigQueryへのストリーミングインサート ポリシータグ運用の注意点 カラムの削除・変更時に「ALTER TABLE」が使えない テーブルにマスキングしたカラムが必要 秘密情報の管理を分類マスタで行う まとめ ポリシータグとは ポリシータグは、BigQueryでカラム単位のアクセス制御が可能なリソースです。BigQueryに保存されている秘密情報を、機密性高く管理するために利用します。 これまで、BigQueryではテーブルやデータセット単位でのアクセス制御はできましたが、カラム単位でのアクセス制御はできませんでした。1つのテーブルに複数の秘密情報が管理されている場合には、テーブル単位のアクセス制御だと、必要以上の秘密情報を参照できてしまいます。 例えば会員情報を扱う場合、1つのテーブルに名前や性別、年齢など様々な秘密情報が管理されます。それらは下図のように機密性も様々です。それらの秘密情報を掛け合わせることで個人の特定に繋がってしまいます。カラム単位のアクセス制御を導入することにより、個人を匿名化し、個人情報の漏洩リスクを防ぐことに役立ちます。 引用: BigQuery でポリシータグを使用する際のベスト プラクティス  |  Google Cloud BigQueryのスキーマ情報からどのスキーマが秘密情報で、どのようなポリシータグが付与されているか、下図のように把握できます。 A column can have only one policy tag. A table can have at most 1,000 unique policy tags. 引用: 制限事項 - BigQuery の列レベルのセキュリティの概要  |  Google Cloud そして、ポリシータグはTerraformでも管理できます。ここではその流れを紹介します。 まず、親となる分類「taxonomy」リソースを作ります。 resource " google_data_catalog_taxonomy " " taxonomy " { project = var.project_private provider = google - beta region = " us " display_name = " policytag-taxonomy-${var.env} " description = " A collection of policy tags " activated_policy_types = [ " FINE_GRAINED_ACCESS_CONTROL " ] } 次に、子のリソースとなる「ポリシータグ」を作ります。名前や年齢など、秘密情報の分類に基づいたポリシータグを作ります。 resource " google_data_catalog_policy_tag " " child_policy_name " { provider = google - beta taxonomy = google_data_catalog_taxonomy.taxonomy.id display_name = " name " description = " name " parent_policy_tag = google_data_catalog_policy_tag.parent_policy_secret.id } 作成したポリシータグは、GCPコンソール上で確認できます。例えば、親子関係でリソースを作った場合、下図のような表示になります。弊社では、親リソースとして「secret」を用意し、子リソースとして分類ごとのポリシータグを作成しています。 そして、コードとして取り扱うので、Git上で「誰にどの権限を付与しているのか」を管理できます。 resource " google_data_catalog_policy_tag_iam_binding " " child_policy_name_viewer " { provider = google - beta policy_tag = google_data_catalog_policy_tag.child_policy_name.name role = " roles/DataCatalog.categoryFineGrainedReader " members = var.zozo_datapool_private_policy_tag_name_viewer } なお、ポリシータグを付与したカラムは、権限がないと参照できなくなります。参照するには「Fine-Grained Reader role」権限が必要です。権限がない状態で参照しようとすると、以下のエラーが発生します。これは、オーナー権限を持っている管理者であっても同様です。 Access Denied: Policy tag projects/<project_id>/locations/<locations>/taxonomies/<taxonomies-id>/policyTags/<policyTags-id>: User does not have permission to access policy tag "<policytag-taxonomy-name> : tell" attached to table(s) <project>.<dataset>.<table> 引用: きめ細かい読み取りのロール - BigQuery の列レベルのセキュリティによるアクセス制限  |  Google Cloud 前述の通り、ポリシータグはカラム単位のアクセス制御により個人情報を匿名化します。最初は「データベース × テーブル」でポリシータグを作ろうと考えましたが、分類あたりのポリシータグの上限が100と決まっているため、それは実現できませんでした。「テーブル × カラム」で秘密情報を保護する思想にはなっていないようです。 Maximum number of policy tags per taxonomy 100 引用: 割り当てと上限  |  Data Catalog のドキュメント  |  Google Cloud ポリシータグを採用した理由 BigQueryにおける秘密情報への参照を制限する方法は、ポリシータグ以外にも、 承認済みビュー などの承認済みリソースを使う方法も考えられます。 承認済みビューを使うと、元テーブルへの参照権限がなくても承認済みビューからは参照できるようになります。秘密情報のカラムを除いた承認済みビューを用意することで、秘密情報ではないレコードのみを参照できます。 なぜ承認済みビューではなく、ポリシータグを採用したのか、その判断理由を順に説明します。 匿名化による機密性の高さ ポリシータグは、秘密情報の分類に基づき、カラムごとに閲覧者を制限できます。前述の通り、年齢だけでは個人の特定に至る可能性は低いですが、複数の秘密情報を掛け合わせることで個人の特定に繋がるため、カラムごとの制限は効果的です。 一方、承認済みビューは、テーブルやデータセットごとのアクセス制御です。そのため、テーブルに複数の秘密情報が管理されている場合、必要でないカラムの秘密情報まで参照できてしまうことになります。それを回避するために、カラムレベルでアクセス制御できるよう、特定のカラムのみ参照する承認済みビューを作る方法も考えられます。しかし、大量の承認済みビューを管理する必要がでてきます。我々は少人数のチームなため、その運用面で現実的ではありませんでした。 また、前述の通りポリシータグを付与するとオーナー権限を持っていたとしても参照できません。参照するには「Fine-Grained Reader role」権限が必要です。オーナーが誤って秘密情報を参照してしまうことも防げます。なお、オーナー自身で権限付与は可能ですが、監査ログにはその証跡が残るため検知可能です。 しかし、テーブル単位のアクセス制御の場合は、オーナー権限があれば参照が可能です。誤って参照してしまうことも考えられます。 以上の違いにより、ポリシータグの方が、より安全に秘密情報を管理できると判断しました。 機密性と利便性の両立 ポリシータグの良い点は、機密性を担保しつつも、利用者が使いやすい点も挙げられます。ポリシータグを付与したテーブルでも、参照権限があれば下図のようにプレビューでクエリ実行前にレコードを確認できます。参照できないのは、ポリシータグでアクセス制御されているカラムのみです。 下図のようにBigQueryテーブルのスキーマを見れば、どのカラムが参照できないのか、どの分類のポリシータグが付与されているかを確認できます。 なお、権限がないカラムは、以下のように対象カラムを除外すれば参照できます。 SELECT * EXCEPT(policy-tag- column ) FROM TABLE 一方、承認済みビューの場合テーブルへのアクセス権限はありません。そのため、プレビューを使ったレコードの確認もできなくなってしまいます。プレビューする必要がある際には、以下のようなクエリを実行し、全カラムの値を確認する必要があります。また、ポリシータグのように、どのカラムが秘密情報としてアクセス制御されているかは分かりません。 SELECT * FROM Table LIMIT 100 このように承認済みビューでも機密性は担保できますが、利用者の立場だと使いにくい環境です。また、データを確認するために全件取得する必要がある点は、費用面でも懸念がありました。BigQueryの定額料金プランで運用しているプロジェクトもあれば、オンデマンドで運用しているプロジェクトもあるからです。オンデマンドの場合、従量課金なため、全件取得に伴う費用は無視できません。 以上の違いにより、ポリシータグの方が機密性を担保しつつ、利用者が分析しやすい環境を提供できると判断しました。 データ基盤を保守運用しやすい ポリシータグを利用することで、下記の理由により保守運用がしやすくなります。 秘密情報をテーブルに新規追加しやすい 秘密情報が存在していなかったテーブルに、新しく秘密情報を追加する場合があります。ポリシータグであれば、新しく追加された秘密情報カラムに付与するのみで対応完了です。 一方、承認済みビューの場合、閲覧先を切り替えるために、テーブルへの閲覧権限を剥奪する必要があります。そのため、対象テーブルを参照しているクエリの利用状況を確認し、関係者と調整した上でクエリの書き換えや承認済みビューへの差し替え対応が必要です。もちろん、最初から全テーブルの閲覧を制限し、承認済みビューのみ参照している状況であれば新規追加の運用も容易です。しかし、前述の通り、プレビューや秘密情報の有無を利用者が確認できないため、使いやすさの点は課題です。 秘密情報の権限管理がしやすい ポリシータグを使うことで、秘密情報の権限管理がしやすくなります。ポリシータグは、同じリージョンであれば他のプロジェクトにも共有できます。ポシリータグリソースの共有には「roles/datacatalog.viewer」権限が必要です。ポリシータグをBigQueryのスキーマに付与するには bigquery.tables.setCategory 権限が必要となるため、「roles/bigquery.dataOwner」も必要になります。共有先のGCPプロジェクトのガイドライン化することで、BigQueryにある全ての秘密情報に共通のポリシータグを付与できます。 resource " google_project_iam_binding " " private_bigquery_datacatalog_viewer " { project = var.project_private role = " roles/datacatalog.viewer " members = var.zozo_datapool_private_datacatalogviewer } Taxonomies are regional resources, like BigQuery datasets and tables. When you create a taxonomy, you specify the region, or location, for the taxonomy. 引用: ポリシータグ - BigQuery の列レベルのセキュリティの概要  |  Google Cloud また、Terraformを使ってコード管理できるのも良い点です。ポリシータグごとに「roles/DataCatalog.categoryFineGrainedReader」を付与することで「誰がどの秘密情報を参照できるのか」を一元管理できます。秘密情報を必要とする分析が発生した場合には、付与した権限は分析完了後にリバートすれば良いので剥奪もしやすいです。 resource " google_data_catalog_policy_tag_iam_binding " " child_policy_name_viewer " { provider = google - beta policy_tag = google_data_catalog_policy_tag.child_policy_name.name role = " roles/DataCatalog.categoryFineGrainedReader " members = var.zozo_datapool_private_policy_tag_name_viewer } 一方、承認済みビューの場合、テーブル単位のアクセス制御になるため、オーナー権限であれば参照が可能な状況です。「誰が秘密情報を見れるのか」を把握するのは困難です。 ポリシータグを活用したデータ連携の仕組み 本章では、ポリシータグを活用したデータ連携の仕組みを紹介します。 利用者が参照するデータ連携後のテーブル ポリシータグを活用したデータ連携を行い、最終的に利用者は下図のようなテーブルを参照します。BigQueryのスキーマを見ると、秘密情報であるメールアドレスにポリシータグが付与されていることが分かります。また、分析用にメールアドレスをマスキングしたカラムも確認できます。このように、カラムのアクセス制御の状況が一覧で確認できます。 なお、ポリシータグやマスキングしたカラムは、下図のような秘密情報の分類マスタに基づいて付与されます。 秘密情報の分類マスタでは、秘密情報カラムの分類情報「classification」を管理しています。この秘密情報カラムに付与されている分類に基づき、ポリシータグとマスキングしたカラムを追加しています。 また、マスキングしたカラムは次の命名規則で管理しています。 <カラム名>_<マスキングアルゴリズム>_masked 「mmEmail_sha256_with_salt_masked」は、ソルトを付与したメールアドレスにSHA-256でハッシュ化したカラムです。そして、「mmEmail_extract_domain_masked」は、メールアドレスから@以降のドメインのみを抽出したカラムです。 特別な事情により、どうしても秘密情報が必要な場合を除いては、このマスキングしたカラムを参照します。メルマガ配信など、秘密情報が必須の場合は、対象のポリシータグに権限を付与します。 なお、ポリシータグの権限がないカラムは、前述の通り、以下のように対象のカラムを除外すれば参照できます。 SELECT * EXCEPT(policy-tag- column ) FROM TABLE 2つのデータ連携基盤 ポリシータグを活用したデータ連携は、日次で全量転送している日次データ連携基盤と、リアルタイムで差分連携しているリアルタイムデータ連携基盤の2つを運用しています。それぞれ、ポリシータグを付与するタイミングや、マスク処理したカラムを追加する方法が異なります。順に、その違いを紹介します。 日次データ連携基盤 ポリシータグを用いた日次データ連携基盤を紹介します。日次データ連携基盤では、オンプレのSQL ServerにあるテーブルをBigQueryへ全量転送しています。 全体のインフラ構成は以下の通りです。 処理の流れを順に説明します。 基幹DBからBigQuery(Private)へのロード まず、基幹DBに存在するテーブルを、利用者が参照できない非公開のGCPプロジェクトへロードします。これは、Embulkを利用するための工夫です。当初は、SQL ServerのETLツールであるBCPとbqコマンドを用いて、BigQueryへロードしようと考えていました。なぜならば、BCPによるパフォーマンスの改善が見込まれたことと、bqコマンドを使うことでロードのタイミングでポリシータグを付与できると考えたためです。 しかし、SQL Serverのバージョンによる制約やBCPの仕様の問題もあり、この手法によるロードは困難でした。そのため、諸々の煩雑な処理を吸収してくれるEmbulkをETLツールとして採用しました。ところが、Embulkだとポリシータグを付与できない事情があるため、まずは利用者が参照できない非公開環境へロードするようにしています。 Other ways to set a policy tag on a column You can also set policy tags when you: Use bq mk to create a table. Pass in a schema to use for creation of the table. Use bq load to load data to a table. Pass in a schema to use when you load the table. For general schema information, see Specifying a schema. 引用: 列にポリシータグを設定するその他の方法 - BigQuery の列レベルのセキュリティによるアクセス制限  |  Google Cloud BigQuery公開環境への書き込み 非公開環境にロードしたテーブルを、利用者が参照できる公開環境に転送します。そして、公開環境へ書き込む際には、ポリシータグを付与します。 しかし、秘密情報へポリシータグに付与するためには、そのカラムが「どの分類に該当するのか」を判定する必要があります。 その際に、前述の秘密情報の分類マスタを利用します。秘密情報カラムの分類情報「classification」に基づき、ポリシータグとハッシュカラムを追加します。 なお、公開環境への書き込みは「上書きジョブ」で行います。上書きジョブでポリシータグを付与するには、以下のように、スキーマにポリシータグのリソースIDを含める必要があります。 { " name ": " birthday ", " type ": " TIMESTAMP ", " mode ": " NULLABLE ", " description ": null , " policyTags ": { " names ": [ " projects/<project-id>/locations/us/taxonomies/<taxonomies-id>/policyTags/<policyTags-id> " ] } } , そして、分類に基づいて、分析用にマスキングしたカラムも作ります。弊社の場合、全ての秘密情報にSHA-256でハッシュ化したカラムを追加しています。その他にも、分類に基づいて共通のマスキング処理を施したカラムを追加しています。 例えば、分類が「birthday」の場合は、誕生月で情報を丸めています。分類が「mail」の場合は、メールアドレスからドメインのみ抽出したカラムを追加しています。 [ { " name ": " birthday_sha256_with_salt_masked ", " type ": " BYTES ", " mode ": " NULLABLE " } , { " name ": " birthday_truncate_to_month_masked ", " type ": " TIMESTAMP ", " mode ": " NULLABLE " } ] 次に、上書きジョブで利用するクエリを生成します。ポリシータグを付与する秘密情報に加え、分析に必要なマスキング処理を施したカラムを追加するために、以下のようなクエリを生成します。カラムの追加は、非公開環境から公開環境へ書き込む際にBigQueryで実施しています。 SELECT birthday, SHA256( CONCAT ( " <salt> " , CAST (birthday AS STRING))) AS birthday_sha256_with_salt_masked, TIMESTAMP_TRUNC(birthday, MONTH) AS birthday_truncate_to_month_masked FROM <private-gcp-project>.<dataset>.< table > そして、秘密情報の分類マスタを元に、生成したクエリとスキーマを用いて bq query で公開環境へ上書きします。 日次データ基盤では、上書きジョブで全量更新しているため、後述するリアルタイムデータ基盤のように事前にテーブルを用意する必要はありません。なお、非公開環境から公開環境へ書き込む際に、ポリシータグを付与しています。 #!/bin/bash echo ${BQ_SCHEMA} > schema.json echo ${GCP_CREDENTIAL} > gcp_credential.json gcloud auth activate-service-account --key-file=gcp_credential.json gcloud config set project ${GCP_PROJECT_ID_PUBLIC} bq query --destination_table ${PUBLIC_DATASET} . ${PUBLIC_TABLE} \ --use_legacy_sql=false \ --destination_schema=schema.json \ --replace=true --max_rows=0 \ " ${QUERY} " テーブルに対してポリシータグを付与するためには「roles/bigquery.dataOwner」権限が必要です。しかし、非公開環境にあるデータにはポリシータグが付与されていないため、参照時に「Fine-Grained Reader role」は必要ありません。一方、ポリシータグを付与した公開環境のデータを参照するには「Fine-Grained Reader role」が必要です。 なお、我々の場合は、公開環境への書き込み後に日次でスナップショットを取得しているテーブルがあります。コピージョブでスナップショットを取得するため、「Fine-Grained Reader role」権限をサービスアカウントに付与しています。 resource " google_project_iam_binding " " bigquery_data_owner " { role = " roles/bigquery.dataOwner " members = var.zozo_datapool_dataowner } 以上のように、ポリシータグを活用した日次データ基盤では、非公開環境にロードしてから上書きジョブで公開環境へ書き込んでいる点がポイントです。 リアルタイムデータ連携基盤 次に、ポリシータグを用いたリアルタイムデータ基盤を紹介します。リアルタイムデータ基盤では、オンプレ環境のSQL Serverで変更のあったレコードをBigQueryへ書き込んでいます。 以前の記事 で紹介しているので、併せてご覧ください。その記事の執筆時に比べ、現在ではGKEへの移行と、Kinesisからの参照が追加されています。 techblog.zozo.com 全体のインフラ構成は以下の通りです。 BigQueryロード前にマスクしたカラムを追加 リアルタイムデータ連携基盤は、日次データ連携基盤とは異なり、BigQueryへロードする前にマスキングしたカラムを追加しています。リアルタイムデータ基盤の場合、Cloud Pub/SubやKinesisからも参照されるためです。Fluentdを利用し、元のデータは残したまま、秘密情報をマスキングしたカラムを追加しています。 Flunetdの設定ファイルは、秘密情報の分類マスタに基づいて生成します。その際のマスキングカラムの追加は、Flunetdのfilterプラグインを使っています。SQL Serverから取得した差分データに、元データを残したままにし、マスク処理したカラムを追加しています。 <filter <database_name>.<table_name>> <record> <column_name>_sha256_with_salt_masked ${ require " /usr/src/app/templates/db2bigquery/masked_util.rb " ; case record[ ' <column_name> ' ] when nil then record[ ' <column_name> ' ] else MaskedUtil .to_sha256_with_salt_masked(record[ ' <column_name> ' ]) end } < / record> < / filter> なお、BigQueryのハッシュ関数とRubyのハッシュ関数は、同じアルゴリズムでも仕様が異なります。そのため、それぞれの仕様を確認の上、実装内容を揃える必要があります。 例えば、SHA-256の場合、BigQueryだとBase64でエンコードされた結果が返ってきますが、Rubyだと16進数の文字列が返ってきます。リアルタイデータ基盤では、BigQueryのハッシュ関数の結果をと一致するようにしています。 -- Note that the result of MD5 is of type BYTES, displayed as a base64-encoded string. 引用: 標準 SQL のハッシュ関数  |  BigQuery  |  Google Cloud data のダイジェストを SHA256 で計算し、16進文字列で返します。 引用: class OpenSSL::Digest::SHA256 (Ruby 3.0.0 リファレンスマニュアル) また、FluentdからCloud Pub/Subへのメッセージ転送は、outputプラグインを利用しています。Cloud Pub/Subへ送信する際に「attribute_key_values」を使い、秘密情報のカラムを渡しています。そして、渡された秘密情報カラムは、後述するDataflowで秘密情報をDROPするために使われます。 <match database_name>.<table_name>> @type gcloud_pubsub ・・・・ attribute_key_values { " database " : " <database_name> " , " table " : " <table_name> " , secret_columns : " column_a,column_b " } ・・・・ < / match> 次に、先程触れたDataflowで秘密情報をDROPする処理を紹介します。 秘密情報をDROPする目的は、Cloud Pub/SubやKinesisから参照する際に、秘密情報を参照できないようにするためです。 そのため、Cloud Pub/SubやKinesisから参照することも考慮し、秘密情報を保持しているCloud Pub/Subと、保持していないCloud Pub/Subの2種類を用意します。秘密情報を保持しているCloud Pub/Subは、BigQueryへの書き込み処理、もしくはCloud Pub/SubやKinesisから秘密情報を参照する必要がある場合に利用します。 以下のようにして「attribute_key_values」から渡された秘密情報を使い、秘密情報をDROPします。 @ProcessElement public void processElement(ProcessContext context) { PubsubMessage message = context.element(); if (message.getAttribute(masked_attribute_key)!= null ) { String [] masked_target_columns = message.getAttribute(masked_attribute_key).split( "," ); if (masked_target_columns.length != 0 ) { JSONObject jsonObject = new JSONObject( new String(message.getPayload(), StandardCharsets.UTF_8)); for (String s : masked_target_columns) { jsonObject.put(s, masked_event_key_value); } byte [] masked_message_byte = jsonObject.toString().getBytes(StandardCharsets.UTF_8); Map<String, String> attribute = message.getAttributeMap(); message = new PubsubMessage(masked_message_byte, attribute, null ); } } context.output(message); } BigQueryへのストリーミングインサート 秘密情報を残したPub/Subトピックから、BigQueryへ書き込みます。 日次データ連携の上書きジョブとは異なり、リアルタイムデータ基盤の書き込みは、既存テーブルへのレコード追加をしています。あらかじめ、Terraformを用いてBigQueryのテーブルを作成します。スキーマには、ポリシータグのリソースIDを付与します。そして、tfファイルは秘密情報の分類マスタに基づいて自動生成しています。 resource "google_bigquery_table" "<dataset_table>" { dataset_id = "<dataset>" table_id = "<table>" time_partitioning { type = "DAY" field = "bigquery_insert_time" } schema = <<EOF [ { "name": "orTel", "type": "STRING", "mode": "NULLABLE", "policyTags": { "names": [ "${var.policy_tag_tell}" ] } }, { "name": "orTel_sha256_with_salt_masked", "type": "BYTES", "mode": "NULLABLE" } ] EOF } 前述の通り、ポリシータグはプロジェクト間で共有できます。現状でも、リアルタイムデータ基盤と日次データ基盤のGCPプロジェクトは分離されていますが、共通のポリシータグを利用しています。具体的には、リアルタイムデータ基盤で、日次データ連携基盤の全量データとリアルタイムデータ基盤の差分データをUNIONしたビューを管理しています。ビューを参照することで、SQL Serverにあるテーブルの最新状態を取得できます。このように共通のポリシータグを利用することで、秘密情報の権限管理がしやすくなります。 このように、リアルタイムデータ基盤では、BigQueryへロード前にマスキングカラムを追加しています。追加したカラムをCloud Pub/SubやKinesis、BigQueryで参照できるようにしています。リアルタイムデータ基盤の場合、事前にTerraformで秘密情報にポリシータグを付与したテーブルを作り、差分データを既存のテーブルに追加しています。GCPプロジェクトは異なりますが、日次データ基盤、リアルタイムデータ基盤で共通のポリシータグを利用することで権限管理のしやすい環境を構築できます。 ポリシータグ運用の注意点 本章では、ポリシータグ運用における注意点を紹介します。前述のデータ連携は、ここで紹介する注意点を考慮した設計になっています。 カラムの削除・変更時に「ALTER TABLE」が使えない ポリシータグを付与しているテーブルのカラムの変更や削除は、やや煩雑です。ポリシータグを付与しているテーブルは「ALTER TABLE」でカラムを削除できない制約があります。 カラムの削除や変更をする際には、以下のように「ALTER TABLE」を使うことが一般的です。ポリシータグを付与していないテーブルであれば、以下のクエリでカラムの削除ができます。 ALTER TABLE <project>.<dataset>.< table > DROP COLUMN IF EXISTS < column > 引用: ALTER TABLE DROP COLUMN statement 一方、ポリシータグを付与しているテーブルは、ポリシータグを付与していないカラムでも、以下のようなエラーが発生して削除できません。 Alpha category references are no longer a supported type, please use policy tags instead: '' なお、スキーマ変更はポリシータグを付与していても利用できます。しかし、「ALTER TABLE」でスキーマ変更ができるのは限定的です。詳細は ドキュメント をご確認ください。 ALTER TABLE <project>.<dataset>.< table > ALTER COLUMN IF EXISTS <column_name> SET DATA TYPE <data_type> 引用: ALTER COLUMN SET DATA TYPE ステートメント - 標準 SQL のデータ定義言語(DDL)ステートメント  |  BigQuery  |  Google Cloud そのため、スキーマの変更や削除をする場合には、基本的にはクエリを使った上書きにする必要があります。単純な上書きジョブであれば、さほど手間はかかりません。日次連携のような上書きジョブで書き込む際には、スキーマも更新されます。リアルタイムデータ基盤の場合、既存のテーブルにレコードを常時追加しています。そのため、BigQueryへの書き込みを止め、以下のようなクエリでテーブルのスキーマを変更すれば対応可能です。 SELECT CAST ( column AS STRING) column FROM table しかし、ポリシータグを付与している場合、単純な上書きジョブでは参照したテーブルのポリシータグを引き継げません。ポリシータグのリソースIDを含んだスキーマを指定しないと、参照元のテーブルでポリシータグが付与されていても引き継げない仕様です。カラムの削除や変更をする場合、スキーマを生成し、生成したスキーマを使い、上書きジョブを実行する必要があります。 なお、手動運用だと運用コストが大きくなるため、自動化することをお勧めします。そして、スキーマを指定しないとポリシータグが外れ、秘密情報が閲覧可能な状況になってしまうので注意が必要です。 テーブルにマスキングしたカラムが必要 ポリシータグを利用する場合、テーブルにマスキングしたカラムを追加する必要があります。一見すると、ポリシータグとあまり関係なさそうですが、ポリシータグの仕様を考慮すると必要になります。 我々の場合、基本的には秘密情報の参照権限は付与していません。秘密情報をマスキングしたカラムを参照してもらうことで、ほとんどの場合に要件を満たせるためです。誕生月を丸めたり、メールアドレスからドメインのみ抽出したり、身長・体重の異常値を丸めたりしています。 承認済みビューを使う場合、このようなマスキングしたカラムはビュー内に作れるので、わざわざBigQueryにカラムを追加する必要はありません。 一方、ポリシータグの場合は、テーブルに対してマスク処理したカラムを追加する必要があります。承認済みビューであれば、元テーブルへのアクセス権限がなくても参照できますが、元テーブルにポリシータグを付与している場合、ポリシータグの権限がなければ承認済みビューを使っても秘密情報は参照できません。これらの関係は、下図のようにまとめられます。 引用: ビューのクエリ - BigQuery の列レベルのセキュリティの概要  |  Google Cloud ポリシータグを使う場合、分析に使うマスキングしたカラムは、元のテーブルに追加する必要があります。我々の場合、リアルタイムデータ基盤でPub/SubやKinesisからマスクしたカラムを参照する必要がありました。また、マスク処理したカラムをBigQuery以外からも参照しているため、どちらにしろテーブルに対して付与する必要がありました。このように、テーブルにマスキングしたカラムを追加する前提で連携方法を考える必要があります。ポリシータグの参照権限を持たない利用者が、承認済みビューでマスキングしているカラムを参照できないので注意が必要です。 秘密情報の管理を分類マスタで行う 前述の通り、ポリシータグは以下のような秘密情報の分類マスタに基づいて付与しています。 この分類マスタが正しくなければ、適切なポリシータグを付与できません。 過去に、秘密情報の分類マスタにあるカラムが、オンプレ環境のSQL Server内のカラムと表記ずれを起こしていたことがありました。表記がずれていたため、秘密情報の判定時にマッチせず、「秘密情報なのにポリシータグが付与されない」状況が発生していました。このような状況が再発しないよう、秘密情報の分類マスタに表記揺れがないか、カラム名が一致してるかを検知できる仕組みを導入しました。 また、「正しい分類」が秘密情報の分類マスタでされていることも重要です。誤った分類が付与されていると、その間違った分類に基づいたポリシータグが付与されてしまいます。その結果、分類に基づいて追加されるマスク処理したカラムも間違った状態になってしまいます。 ポリシータグを運用する上で、秘密情報の分類マスタの管理は非常に重要です。間違っていると秘密情報なのにポリシータグが付与されていない状態になるため、本記事で紹介してきた施策の努力も無駄になってしまいます。 まとめ 本記事では、ポリシータグの活用事例や運用における注意点を紹介しました。実際に運用してみると、秘密情報の権限管理や分類マスタに基づいたマスクカラムの追加など、かなり柔軟に対応できるようになりました。同じように、BigQueryの秘密情報の管理方法を検討されている方の参考になれば幸いです。 本記事を読んで、もしご興味をもたれた方は、是非採用ページからご応募ください。 hrmos.co
アバター
はじめに こんにちは。家系らーめん好きが高じて鶏油を自分で取得するようになり、金色に輝く液体を見るだけで パブロフの犬 的に涎が止まらない、SRE部の横田です。普段はSREとしてZOZOTOWNのリプレイスや運用に携わっています。 先日、弊社の高橋が執筆したZOZOTOWNカート機能リプレイスに関する記事が公開されました。 techblog.zozo.com 本記事では、上記記事で紹介したリプレイスのキーポイントとなる、キューイングシステムに焦点を当てます。キューイングシステムをDeep Diveし、サービス選定からプロダクションレディにするまでの取り組みを紹介します。 目次 はじめに 目次 キューイングシステムの概要と選定 検討したサービス SQS KDS KDSの選定理由 理由1:カート投入処理数への懸念がない 理由2:Consumerの回復性が高い KCLアプリを利用したFailoverの実現 プロダクションレディに向けた取り組み シャード数拡張戦略 検証1:シャード数変更に要する時間の検証 検証2:シャード数変更を2倍または2分の1に限定した場合に要する時間の検証 ワーカーのLiveness Probe Lease Tableのキャパシティモード KCLアプリケーションの監視 キューイングシステムの導入効果 今後の展望 最後に キューイングシステムの概要と選定 本章では、今回のテーマとなる「キューイングシステム」の概念を、ZOZOTOWNのカート投入システムを例に解説します。 ZOZOTOWNのカート機能は、カート投入や決済など、さらに細かい様々な機能から構成されています。そして、ZOZOTOWNにとって、最も重要な機能の1つと言えます。しかし、関連システムも多いため、一度にすべてをリプレイスするのではなく、段階的にいくつかのPhaseに分けたリプレイスを現在進めています。カート機能のリプレイス Phase1では、カート投入処理でWebサーバーとカート情報を扱うデータベース(以下、カートDB)間にキューイングシステムを挟みました。これにより非同期処理となり、イベント時のアクセス集中に伴うカートDBの負荷上昇を抑えることを可能にしました。 上図で用いられている用語を簡単に説明します。 Queue キュー(Queue)とは、「待機、待ち行列」を意味する 本システムでは、カート投入処理をキューで一時的に預かることで、後段のシステム負荷軽減を実現させている First In First Out(以下、FIFO)、つまり順序性を保証したい場合は、要件に合うキューを選択する必要があるため、本システムではAmazon Kinesis Data Streams(以下、KDS)を採用している KDSでは、キューの役割を持つリソースをシャードと呼び、シャードに投入するデータをレコードと呼ぶ Producer キューにデータを投入する役割を持つ 本システムでは、上図のzozo-cart-apiがProducerの役割を果たす 前段のWebサーバーからリクエストを受け、KDSにレコードを投入するまでがzozo-cart-apiの役割である Consumer キューからデータを取り出し、後続の処理を行う役割を持つ 本システムでは、上図のzozo-cart-workerがConsumerの役割を果たす KDSから読み込んだレコードを後段のシステムに繋げる役割を持つ Polling Consumerがキューの状態変化を検知するために、一定間隔でリクエストを送る処理をポーリングと呼ぶ 本システムでは、Consumerであるzozo-cart-workerがKDSにポーリングを行うことで、未処理のレコードに対するアクションを行う このように、本システムではカート投入要求に対する非同期処理システムを、KDSを活用して実現しています。次に、なぜキューイングシステムとしてKDSを採用したのかを説明します。 検討したサービス AWSが提供するキューイングシステムから、以下の選択肢を検討しました。 Amazon Simple Queue Service(以下、SQS) KDS それぞれの特徴は以下の通りです。 SQS SQSはメッセージキューサービスであり、以下の特徴があります。 1リージョン内で複数AZの冗長構成なため、可用性が高い キューサービスのため、Consumerはメッセージの取り出しと削除が役割となる 標準キューとFIFOキューが存在し、タイプにより性能や動作が異なる デッドレターキューを利用することで、処理に失敗したメッセージに対するアクションが可能である なお、標準キューとFIFOキューには、以下の違いがあります。 特徴 標準キュー FIFOキュー 順序性 ベストエフォートのため、厳密な順序性の保証はされない 順序性が保証される 重複 重複の可能性がある 重複メッセージがないように設計されている APIコール制限 無制限 APIメソッド毎に300リクエスト/secのAPIコールをサポート KDS KDSは、ストリーミングデータサービスで、ログやイベントデータの収集、リアルタイム分析などで活用可能なサービスです。以下のような特徴があります。 複数AZ間でデータ同期されるため、可用性が高い ストリームサービスのため、Consumerは「レコードを削除する」という考えが無く、どこまで処理したかを記録する役割を持つ レコードは指定した期間の経過後に破棄される(最短24時間) FIFOをシャード単位で担保可能である 処理性能はシャード単位で考える データ取り込みは、1シャードあたり1000レコード/secまたは1MB/sec Kinesis Client Library(以下、KCL)が提供されている なお、より詳しくSQSとKDSを知りたい方は、公式ドキュメントをご参照ください。 What is Amazon Simple Queue Service? What Is Amazon Kinesis Data Streams? KDSの選定理由 本システムでは、KDSを採用しました。その選定理由を説明します。 理由1:カート投入処理数への懸念がない 前提条件として「カート投入機能は同一商品に限り順序性を担保したい」という要件がありました。つまり、FIFOキューを利用する必要があります。 ここで懸念として挙がったのは、SQSのFIFOキューにおけるAPIコール数制限です。APIメソッド毎に最大300リクエスト/secのAPIコールがサポートされていますが、この数値はZOZOTOWNのカート投入数に対してボトルネックになる可能性が考えられました。一方、KDSのデータ投入のクォータはシャードあたり1000レコード/secとなっており、高いイベント処理能力を有しています。1つのストリームでシャード数を増加させることで、同時に処理可能なレコード数を引き上げられます。この点がKDSの魅力の1つでした。 理由2:Consumerの回復性が高い SQSとKDS、共に高可用性を有したマネージドサービスであるため、利用者側はあまり深く意識しなくとも回復性や信頼性が備わっています。しかし、ProducerやConsumerに関しては、利用者自身で別のサービスを利用するなどの対策が回復性や信頼性を向上させるために必要です。 可用性や回復性の担保において、ProducerはAPIがその役割を担うため、複数台のAPIを水平スケールさせるといった既存システムのナレッジで容易に実現が可能でした。しかし、Consumerは順序保証のために1つのキューに対して1つのConsumerである必要があり、水平スケールの実現が困難でした。そこで、障害発生時にConsumerが可能な限り早く処理を再開させるためには「Failover動作が好ましい」と考えました。これは、KCLを利用することで比較的容易に実現できることも分かりました。 KCLアプリを利用したFailoverの実現 本システムではKCLを活用しています。その特徴を簡単に説明します。 KDSからレコードを取得して処理をするConsumerアプリケーションを開発する場合、その方法の1つとしてKCLを利用する方法が挙げられます。KCLは、KDSとレコードに対する後続処理の仲介役を担う存在です。KCLアプリケーションでは、Consumerをワーカーと呼び、主に以下の機能を提供します。 KDSへの接続 シャードとワーカーのバインディング レコードをどこまで処理したか管理するためのチェックポイント記録 ワーカー数やシャード数変更に伴うリバランス処理 KCLアプリケーションは、ワーカーとシャード間のバインディングを定義します。また、定義されたデータをLeaseと呼び、Lease Table(DynamoDB)を利用してワーカーにより処理されるシャードの追跡や、ワーカー数増減・シャード増減の追従を可能にします。Lease Tableでは、各Itemに対して下図の内容が定義されます。 そして、チェックポイントを利用したレコードの追跡により、ワーカーで障害が発生した際に、他のワーカーが処理を引き継ぎ、未処理のレコードから処理を再開することが可能になります。 前述のように、下記2点が採用を決めた大きな理由です。 KDSが高いイベント処理能力を有していること KCLを利用することで、ワーカーのFailoverなど回復性向上が比較的容易に実現可能であること プロダクションレディに向けた取り組み 前述の通り、キューイングシステムとしてKDSを採用することが決まりました。本章では、本システムをプロダクションレディにするため取り組んだことを紹介します。 シャード数拡張戦略 KDSでキューイングシステムを構築する上で、シャード数は並列処理数を左右する非常に重要な要素です。現在稼働しているシャード数は、過去数年のカート投入リクエストログによる分析結果と、実際にそのシャード数でカートDBの負荷低減に繋がるかという検証を繰り返しながら決定した値です。 一方、今後の成長などを考慮するとシャード数の増加、場合によっては減少させることも十分考えられます。その際に備え、拡張・縮退にどの程度の時間を要するのかを把握しておくことが重要です。そこで、シャード拡張・縮退に要する時間の検証を行いました。 検証1:シャード数変更に要する時間の検証 シャード拡張・縮退に要する時間の検証を行った結果が下記表です。 変更前シャード数 変更後シャード数 シャード数差分 拡張に要した時間 2 4 +2 41s 4 8 +4 40s 8 16 +8 41s 16 32 +16 1m13s 32 64 +32 1m13s 64 100 +36 11m29s 100 200 +100 2m44s 200 300 +100 15m35s 300 400 +100 29m38s 400 500 +100 44m13s 500 600 +100 59m8s 500 501 +1 1h11m20s 現在はKDSをCloudFormationで構成管理しているため、拡張に要した時間はCloudFormationのステータスから計測しています。試行回数2回の平均値を記載しています。上記の結果にはシャード数の増加のみ掲載していますが、減少の検証でも概ね同様の結果が得られました。増減数が少ない時は高速で拡張・縮退が完了しますが、64→100への変更時点から、かなり時間が安定しなくなっています。500→501への変更では、1シャード増が完了するまでに1時間を要しています。しかし、100→200への変更では、処理が早く完了しているように見えます。 本検証の結果を分析していったところ、この結果はKDSのリシャーディング動作が大きく関わっていることが分かりました。 公式ドキュメント によると、リシャーディングにはシャードの分割(Split)と結合(Merge)というオペレーションが存在します。また、 FAQ によれば、これらのオペレーションは1シャードずつ行われることが分かります。 Q: How often can I and how long does it take to change the throughput of my Kinesis stream? A: resharding operation such as shard split or shard merge takes a few seconds. You can only perform one resharding operation at a time. Therefore, for a Kinesis stream with only one shard, it takes a few seconds to double the throughput by splitting one shard. For a stream with 1000 shards, it takes 30K seconds (8.3 hours) to double the throughput by splitting 1000 shards. We recommend increasing the throughput of your stream ahead of the time when extra throughput is needed 引用: Amazon Kinesis Data Streams FAQs | Amazon Web Services この説明から、シャード数が増えれば増える程、総処理時間も増加することが分かります。また、先程の検証結果で100→200への変更の変化時だけ極端に処理時間が早かったのは、シャードの分割だけで処理が完了していたためだと言えます。CloudFormationを使って、拡張前の2倍ではないシャード数の変更オペレーションを行った場合、全シャードを分割した後に目的の値まで結合処理が繰り返し行われるため、極端に時間がかかっています。 検証2:シャード数変更を2倍または2分の1に限定した場合に要する時間の検証 シャードの分割・結合の発生頻度を下げるために、拡張時は2倍、縮退時は2分の1でシャード数を変更する検証を行いました。その結果が下記表です。 変更前シャード数 変更後シャード数 シャード数差分 拡張に要した時間 64 128 +64 1m43s 128 256 +128 3m16s 256 512 +256 6m20s 512 256 -256 6m22s 256 128 -128 3m16s 128 64 -64 2m13s 検証結果を比較すると分かるように、拡張・縮退に要する時間が大きく短縮されました。 AWS CLIなどを利用し、API経由で任意のシャードのみを Split する方法も考えられます。しかし、各シャードで管理するキーレンジのバランスを意識する必要が出てくるデメリットや、可能な限りリソースをCloudFormationで構成管理したいという思いから、現在は拡張の必要がある際にはシャード数を倍にする運用方法を選択しています。 また、KDSを東京リージョンで利用する場合は、デフォルトで200シャード(バージニア北部など一部のリージョンでは500シャード)という クォータ が存在します。200シャード以上の拡張が必要になる場合は、あらかじめクォータ引き上げの申請をしておくことが重要です。また、シャード数の拡張・縮退は1度のオペレーションで2倍以上へのスケールアップ、または半分以下にスケールダウンすることができない点も注意が必要です。 ワーカーのLiveness Probe ワーカーであるzozo-cart-workerは、アプリケーション回復性や伸縮性のために、Amazon Elastic Kubernetes Service(以下、EKS)上で稼働させています。Failoverの仕組みが利用可能とはいえ、問題が発生したPodをKubernetesの ライフサイクル の仕組みである Liveness Probe を使って稼働可能な状態へ戻したい、という狙いがありました。 これまでにも、私たちの管理するEKS上の様々なマイクロサービスで、同様の取り組みを行っています。しかし、HTTPリクエストに対する処理を行うAPIと異なり、ワーカーのような処理を行うアプリケーションに対するLiveness Probeをどう実現するかが新たな課題として出てきました。 今回採用した方法は「ワーカー内に特定の条件でファイル(Example: /tmp/health )を作成・更新させ、最終更新からのタイムスタンプをLiveness Probeの条件にする」というものでした。ファイルの作成・更新タイミングには、以下のものがあります。 KCLの初期化完了 レコードのフェッチ レコードの処理開始時 なお、レコードが空であったり、Putされたレコードがない場合にもフェッチ処理が行われるため、作成したファイルのタイムスタンプが更新されるようになっています。 Manifestのサンプルは下記の通りです。 apiVersion : apps/v1 kind : Deployment metadata : labels : run : zozo-cart-worker name : zozo-cart-worker spec : template : spec : containers : - name : zozo-cart-worker image : <YOUR_IMAGE_URL> livenessProbe : exec : command : - sh - -c - find /tmp/ -name "health" -mmin -`echo $KINESIS_IDLE_TIME_BETWEEN_READS_MILLIS | awk '{print $1/60000*5}' ` | grep /tmp/health initialDelaySeconds : 180 periodSeconds : 5 env : - name : KINESIS_IDLE_TIME_BETWEEN_READS_MILLIS value : 1000 KDSへのPolling間隔は、環境変数 KINESIS_IDLE_TIME_BETWEEN_READS_MILLIS で指定します。この変数は、KCLで指定可能な idleTimeBetweenReadsInMillis プロパティの値を指定しています。ファイルの更新が、Polling間隔のN倍(上記サンプルでは1000msの5倍)以上行われない場合、Liveness Probeに失敗したと判断させるように実装しています。これにより、回復性の向上を実現しています。 Lease Tableのキャパシティモード KCLアプリケーションでは、Lease Tableを利用してレコードの処理状況を管理します。なお、KCLアプリケーションは、対象のDynamoDBのテーブルが存在しない場合、自動的にLease Tableを作成します。DynamoDBには、利用者側でRead/Write共に上限を設けるプロビジョニングモードと、トラフィック状況に応じて動的に拡張・縮退を行うオンデマンドモードという2種類の キャパシティモード が存在します。 KCLアプリケーションの起動時に自動生成されるLease Tableは、プロビジョニングモードで読み書き共に上限10で作成されます。シャード数やワーカーの増加に応じてLease Tableへの読み書き頻度が上がります。そのため、今回は事前に作成したオンデマンドモードのDynamoDBをLease Tableとして利用することにしました。以下がCloudFormationのサンプルです。 AWSTemplateFormatVersion : '2010-09-09' Description : 'DynamoDB KCL Lease Table Resources' Resources : DynamoDBLeaseTable : Type : 'AWS::DynamoDB::Table' Properties : TableName : leasetable AttributeDefinitions : - AttributeName : leaseKey AttributeType : S KeySchema : - AttributeName : leaseKey KeyType : HASH BillingMode : PAY_PER_REQUEST Lease TableとなるDynamoDBのパーティションキーは leasekey(String) を指定し、BillingModeで PAY_PER_REQUEST を指定することでオンデマンドモードのLease Tableを作成します。その後、KCLアプリケーション側で作成したLease Table名を利用するように指定し、起動します。以上により、「利用状況に応じてLease Tableのキャパシティを意識する」という手間を1つカットできました。 KCLアプリケーションの監視 安定した継続運用を行う上で、監視項目は非常に重要です。Producerにあたるzozo-cart-apiは、これまで運用してきた他のマイクロサービスのAPIのナレッジが利用でき、DatadogのAPMを利用したTracingやメトリクス監視がすぐに実現できました。また、KDSでは 拡張メトリクス を有効化することで、シャード単位の監視を実現しています。ワーカーの監視に関しては、KCLが発行するAmazon CloudWatchの CustomMetrics を活用しています。 このメトリクスは、KCLアプリケーションのチューニングに非常に役立ちます。過去に、負荷試験を進める中で大幅にシャード数を増加させるシーンがありました。その際には、シャード数を増やした直後にアプリケーションの動作が不安定になり、時間経過と共に改善していく事象がありました。しかし、ワーカーであるzozo-cart-workerのCPUやメモリの負荷状況は目立って悪化している部分もなく、ボトルネックがどこか分からない状態でした。そこで、KCLアプリケーションから発行されるCustomMetricsであるNeededLeasesやCurrentLeases、LeasesToTakeを確認したところ、極端にスパイクしていることに気がつきました。 NeededLeases 現在のワーカーがシャード処理の負荷を分散するのに必要なシャードリースの数 CurrentLeases すべてのリースの更新後にワーカーによって所有されているシャードリースの数 TakenLeases ワーカーが取得に成功したリースの数 上記のメトリクスから、シャード数増加直後に複数台存在するワーカー間で管理しているシャード数にかなり偏りが発生しており、動作が不安定になっていたことが推察されます。上記の結果を受け、KCLアプリケーションの maxLeasesForWorker や maxLeasesToStealAtOneTime といったパラメーターを見直すきっかけになりました。 maxLeasesForWorker 単一のワーカーが受け入れるリースの最大数 maxLeasesToStealAtOneTime スティールを試みるリースの最大数 チューニング可能なその他のパラメーターは、 デベロッパーガイド から確認可能です。KCLアプリケーションを運用する上で、CustomMetricsは様々な気づきを与えてくれます。ただし、シャード数やConsumerの数を増やすことでCloudWatchへのPutMetircDataの頻度が増加する点に注意が必要です。場合によっては、スロットリング( デフォルトは150TPS )が発生する恐れもあるため、上限の緩和申請を行うか、 メトリクスのPUT頻度を下げる といった対応が必要になります。 さらに、ワーカーとAPIの処理をDatadog APMを使って紐付け、一貫したトランザクションを確認できる仕組みも導入しています。この取り組みは、 以前の記事 で紹介しているので、併せてご覧ください。 techblog.zozo.com キューイングシステムの導入効果 本章では、キューイングシステムの導入効果を紹介します。 下図は、リプレイス前後でカート投入が大量に発生した際の、Webサーバーのメトリクスの一例です。以下の3項目をピックアップし比較しています。 カート投入のリクエスト数 カート投入に要する処理時間(Latency) リクエストスパイク時のエラー発生率 リプレイス前は、リクエスト数が急激にスパイクすることで、Latencyが上昇しています。その結果、ユーザーにエラーを返してしまっていることがグラフから読み解けます。 一方、リプレイス後はキューイングシステムによりカートDBへの流量をコントロールが可能になりました。その結果、リクエスト数のスパイク時のLatencyやエラー発生率を大幅に改善できています。 今後の展望 本記事では深く触れていませんが、カート投入処理が集中すると予測される商品は過熱商品用のストリームにレコードをPutする仕組みを導入しています。その際に、過熱商品であるかどうかの判定はDynamoDBの過熱商品用テーブルを参照する仕組みですが、この登録作業は手動で実施しています。トイルを可能な限り減らしていくために、過熱商品の検知と自動登録の仕組みを導入していく予定です。 今後もリリースしたシステムの運用改善を行いながら、カート機能のリプレイスはPhase2、3と段階的に進めていきます。 最後に ZOZOでは、本記事で紹介したカート投入機能など、様々な機能のリプレイスやマイクロサービス化を進めていく仲間を募集しています。興味のある方は、以下のリンクから是非ご応募ください。 https://hrmos.co/pages/zozo/jobs/0000010 hrmos.co また、カジュアル面談も随時実施中です。是非ご応募ください。 hrmos.co
アバター
検索基盤部の内田です。私たちは、約1年前よりヤフー株式会社と協力し、検索機能の改善に取り組んでいます。現在、ZOZOTOWNのおすすめ順検索に用いている、ランキング学習を利用した検索機能も、その取り組みの一部です。 本記事では、Elasticsearch上で、ランキング学習により構築した機械学習モデルを用いた検索を行うためのプラグイン「 Elasticsearch Learning to Rank 」の簡単な使い方を紹介します。また、このプラグインをZOZOTOWNに導入し、実際に運用して得られた知見をご紹介します。ランキング学習の話題性が世の中で増していますが、検索エンジンを絡めた情報はまだ世の中に少ない印象があります。そのため、本記事が皆さんの参考になれば幸いです。 ランキング学習のイメージ ランキング学習(Learning to Rank, LTR)とは、機械学習の枠組みのひとつであり、情報検索におけるランキングを予測するモデルを構築する手法です。イメージとしては「機械学習で、入力されたパラメータに基づいて賢く候補を並び替えるもの」と言えます。 Learning to Rankプラグインの使い方 ZOZOTOWNの検索システムは、Elasticsearchを利用して構築しています。弊社のElasticsearchに関する取り組みは、過去の記事でも紹介しているので、併せてご覧ください。 techblog.zozo.com Elasticsearchには、ランキング学習で構築したモデルを利用して検索する機能がデフォルトでは用意されていません。Learning to Rankプラグイン(以下、LTRプラグイン)の利用は、その機能を導入するための比較的低コストな手段の1つです。本章では、LTRプラグインを使った検索の実行方法を簡単に紹介します。なお、Elasticsearchへのプラグインの導入方法は解説しません。プラグインの導入に関する情報は プラグインのREADME や Elasticsearchのドキュメント を参考ください。 LTRプラグインを用いて、ランキング学習で構築したモデルを利用した検索を実現するには、下記の3つの手順を踏む必要があります。 Elasticsearchで計算する特徴量セットを定義する 機械学習モデルを構築してElasticsearchにアップロードする アップロードしたモデルを検索クエリで指定して検索する なお、LTRプラグインを初めて利用する際は、 feature store の有効化が必要です。 feature store は、特徴量セットやモデルに関するメタデータを格納するインデックスに相当します。 PUT _ltr 1. Elasticsearchで計算する特徴量セットを定義する 本記事では、概要を説明します。詳細を確認したい場合は、公式ドキュメントを参照ください。 elasticsearch-learning-to-rank.readthedocs.io 機械学習モデルを用いたスコアリングでは、Elasticsearchが各特徴量の値を計算します。ただし、それらの特徴量の定義は、事前にElasticsearchへ登録しておく必要があります。そして、その特徴量の定義方法は、以下の3種類の方法が存在します。 mustache テンプレート言語 mustache を交えたElasticsearchのクエリDSLで記述する方法 derived_expression Luceneの式で記述する方法 基本的な演算子や算術関数が利用可能で、他の特徴量を参照する簡単な特徴量も定義可能 なお、LTRプラグインの公式ドキュメントには derived_expressions と書かれている箇所があるが、正しくは derived_expression script_feature Elasticsearchのスクリプト言語(Painlessなど)で記述する方法 複雑な特徴量を定義可能 上記の選択肢にある、 script_feature による特徴量定義は、検索の実行パフォーマンスを低下させる可能性が高いため、可能な限り避けることをおすすめします。基本的には mustache で特徴量を定義し、 mustache で定義した特徴量の値を参照する特徴量がある場合は derived_expression の記法で定義します。 例えば、検索対象となる商品が「商品名( title )」と「人気度( popularity )」を持つとします。 PUT my_index { " mappings ": { " properties ": { " title ": { " type ": " text " } , " popularity ": { " type ": " integer " } } } } ここで、下記の特徴量ベクトルを定義するとします。 商品名と検索キーワードのマッチ度 人気度の値 それらの積 この場合、以下のように記述できます。 POST _ltr/_featureset/my_featureset { " featureset ": { " features ": [ { " name ": " title_relevance ", " params ": [ " keyword " ] , " template_language ": " mustache ", " template ": { " match ": { " title ": " {{keyword}} " } } } , { " name ": " popularity_value ", " params ": [] , " template_language ": " mustache ", " template ": { " function_score ": { " query ": { " match_all ": {} } , " field_value_factor ": { " field ": " popularity ", " missing ": 0 } } } } , { " name ": " product_of_relevance_and_popularity ", " params ": [] , " template_language ": " derived_expression ", " template ": " title_relevance * popularity_value " } ] } } 2. 機械学習モデルを構築してElasticsearchにアップロードする elasticsearch-learning-to-rank.readthedocs.io 特徴量が定義できたら、機械学習でモデルを構築し、そのモデルをアップロードします。LTRプラグイン自体には、機械学習の機能は備わっていないため、モデルの学習には外部の機械学習ライブラリを利用する必要があります。なお、本記事の執筆時点でLTRプラグインが対応している機械学習ライブラリは Ranklib と XGBoost の2つです。 POST _ltr/_featureset/my_featureset/_createmodel { " model ": { " name ": " my_model ", " model ": { " type ": " model/xgboost+json ", " definition ": " (機械学習ライブラリを使って構築し保存したモデル) " } } } なお、学習に用いるデータは、サービスで収集した実際のユーザの行動ログを利用するのが良いです。また、特徴量ベクトルの生成には、LTRライブラリの 特徴量ログ出力機能 が役に立つ場合があるので、確認しておくと良いでしょう。 3. アップロードしたモデルを検索クエリで指定して検索する elasticsearch-learning-to-rank.readthedocs.io 前述までの手順で、機械学習モデルを用いた検索システムを動かす準備は整いました。しかし、1つ大きな問題点があります。 機械学習モデルを用いたスコア計算は計算コストが高い という問題です。もし、検索クエリに一致した商品(ドキュメント)全てに対して機械学習モデルを適用しスコアを算出した場合、レスポンスタイムの悪化などを招きます。その結果、検索の総合的なパフォーマンスを劣化させる可能性が高まります。 そのため、機械学習モデルを検索に取り入れる際には、基本的に リランキング を行います。簡単なクエリで計算したスコアを用いて一次的なランキングを行い、それらの上位N件に対して機械学習モデルでスコアを再計算し並び替え、最終的な検索結果を出力します。リランキング対象とする件数は、Elasticsearchクラスタのマシンリソースなどを考慮し、検索の精度向上とスループットやレスポンスタイム悪化のトレードオフを見ながら許容できる範囲で調整します。 GET my_index/_search { " query ": { // 簡単なクエリで絞り込みと一次ランキング " match ": { " title ": " ジャケット " } } , " rescore ": { // 機械学習モデルでのリランキング " window_size ": 1000 , // 一次ランキングの上位1000件をリランキングする " query ": { " rescore_query ": { " sltr ": { " params ": { " keyword ": " ジャケット " } , " model ": " my_model " } } } } } 実運用から得られた知見と注意点 本章では、LTRプラグインをサービスに適用し、実運用から得られた知見や注意点を紹介します。LTRプラグインの導入に迷われている方、困っている方の参考になれば幸いです。なお、運用する中で独自に行った改修のいくつかは、LTRプラグインへコミットし、コミュニティに還元しています。 レスポンスタイムに悪影響を与える設定項目の調査 前述の通り、機械学習モデルによるリランキングを行うため、レスポンスタイムの悪化が想定されました。そのため、事前に負荷試験を実施し、レスポンスタイムに影響を与える設定項目の発見、及び一定のスループットを保てるラインを調査しました。 その調査の結果、想定していた通り、リランキングによりレスポンスタイムが悪化することが分かりました。また、モデルの特徴量数・複雑度やリランキング対象を操作することで、悪化の程度を調整できることも分かりました。 調査結果の概要は以下の通りです。 モデルを構成する特徴量数の調整 モデルを構成する特徴量の数を増やすと、レスポンスタイムが悪化することが確認された モデルの複雑度の調整 モデルを構成する決定木の数やそれらの高さをコントロールしたところ、モデルが複雑になるとレスポンスタイムが悪化することが確認された 特徴量セットで定義する特徴量数の調整 レスポンスタイムに変化はなかった 特徴量セットに定義された特徴量は毎回全て計算されるのではなく、モデルで参照される特徴量のみが都度計算されるように見受けられる リランキング対象数の調整 対象数を増やすと、レスポンスタイムが悪化することが確認された 特徴量が負の値を持つ場合の注意点 Elasticsearchは、クエリを実行して得られるスコアに負の値を許容しません。そのため、前述の特徴量セットの定義で紹介した以下のクエリでは、対応するフィールドの値を参照しているため、 popularity フィールドが負の値を持つ場合にエラーとなります。これは、Luceneの仕様によるものです。クエリ側で回避することが難しいため、特徴量セットを定義する際や、学習用のデータを作る際には負の値の扱いに注意が必要です。 { " name ": " popularity_value ", " params ": [] , " template_language ": " mustache ", " template ": { " function_score ": { " query ": { " match_all ": {} } , " field_value_factor ": { " field ": " popularity ", " missing ": 0 } } } } クライアントライブラリが存在しない LTRプラグインが提供する拡張DSL( "sltr" など)に対応したクライアントライブラリが存在しません。LTRプラグインの機能を利用するには、クライアントを利用せずにクエリを構築しAPIにリクエストを投げるか、Elasticsearchのクライアントを独自に拡張DSLに対応させる必要があります。 弊社では、LTRプラグインのソースコードに含まれるQueryBuilder系のコードを参考にし、クライアントライブラリを拡張しています。 プラグインのバージョン制約 基本的に、Elasticsearchのプラグインは、使用しているElasticsearchのバージョンに合わせてビルドされたパッケージをインストールする必要があります。 しかし、LTRプラグインは、自身のバージョンアップがあっても、Elasticsearchの旧バージョン向けにはパッケージの配布が行われません。基本的に最新の機能や修正は、Elasticsearchの最新バージョンでしか利用できないという問題が発生しています。 例えば、LTRプラグインが更新された時点で、最新のElasticsearchのバージョンが7.16.2だったとしましょう。この場合、バージョン7.16.1以前のElasticsearchでは、LTRプラグインの最新バージョンで追加された機能を使ったり、バグの修正を適用することができません。 弊社では、LTRプラグインのリポジトリをクローンし、本家で新機能がリリースされた場合は、利用しているElasticsearchのバージョンに合わせて独自にプラグインをビルドし運用しています。 まとめ 本記事では、ElasticsearchのLTRプラグインの簡単な使い方と、運用を通して得られた知見や注意点を紹介しました。今後も継続的に改善していきますので、是非ZOZOTOWNの検索機能をお試しください。 さいごに、ZOZOでは検索エンジニア・MLエンジニア・サーバサイドエンジニアのメンバーを募集しています。検索機能の改善にご興味のある方は、以下のリンクからご応募ください。 hrmos.co hrmos.co また、本記事で紹介した施策にご協力いただいたヤフー株式会社の皆さんに改めて感謝いたします。
アバター
はじめに こんにちは、データシステム部データ基盤ブロックの纐纈です。9月から22卒内定者として、チームにジョインしました。 本記事では、弊社のデータ基盤チームが抱えていた課題と、その解決のために公開したOSSツール「Coppe」を紹介します。Coppeは、以下のような方にお勧めできるツールです。 BigQueryを使用したデータ基盤の監視に興味がある BigQueryの監視ツールとしてRedashを採用しているが、運用が面倒に感じている インフラの設定なしにBigQueryの監視を行えるツールが欲しい なお、本OSSはMonotaRO Tech Blogの記事「 SQLを使った監視でデータ基盤の品質を向上させる 」で紹介されていた仕組みを参考にし、より柔軟に監視項目を設定できるように新規開発しています。 OSSとして公開しているため、本記事と併せてご覧ください。 github.com 開発の経緯 現在、ZOZOはデータ基盤としてBigQueryを採用しています。そこには、オンプレやAWS、アプリケーションのログなど、あらゆるデータを集めており、タイミングも日次収集のものや、リアルタイム収集のものが存在します。その収集時に、遅延やオペレーションミス、意図しないデータの肥大化により、データ品質が下がってしまうことがあります。 その結果、データ基盤を利用した関連サービスに最新の正しい情報を反映できなくなってしまいます。そうなってしまうと、ZOZOが提供するサービスを利用するユーザーに、直接的な影響を与えてしまう可能性もあります。そのため、データの品質劣化には、いち早く気づき、対応する必要があります。 その対応策として、現在はRedashを使用しています。Redashは、SQLの分析結果をダッシュボードに可視化するOSSのBIツールです。これを利用し、BigQueryに定期的な監視クエリを実行し、その結果が期待値から外れる場合には、Slack通知で検知できるようにしています。一見すると、Redashで事足りているように見えますが、監視ツールとしては物足りない部分もあります。 redash.io 1点目の課題は、Redash自体をホスティングするためにWebサーバーやデータベース、Redisなどを自前で用意する必要がある点です。これは導入時に手間がかかるだけでなく、用意した環境の1つに障害が起きた際には、データ品質の監視ができなくなるという欠点があります。加えて、障害が発生したサーバーやサービスを立ち上げ直すのに手間と時間を要する点も懸念点です。 techblog.zozo.com また、いつ誰によってどんな目的でその監視項目を追加したのかといったことが不明瞭になったり、他チームからの監視項目の追加の要請をRedashを管理する弊チーム以外ができなかったりという課題点もあります。 そこで、Redashよりも気軽に運用が可能で、監視項目の管理をGitHub上で行える監視ツールを開発することにしました。 Coppeの機能 監視ツールCoppe(以下、Coppe) は、BigQueryへの定期的な監視を実施します。また、非機能要件として、以下の点を目的にしています。 障害発生時に、可能な限り自動再生できるインフラ構成 導入時のセットアップや監視項目の追加を気軽に行える仕様 なお、「Coppe」という名前は蜘蛛から着想を得ています。監視項目を「蜘蛛の巣」と見立て、エラーを検知したらすぐに検知して動き出すイメージで名付けました。「Coppe」は英語で昔使われていた蜘蛛を意味する単語です。私は虫が苦手なため、「Spider」のような蜘蛛を直接連想しやすい名前ではないので、この名前を気に入っています。 Coppeは、監視項目をYAMLとSQLで指定することで、指定されたスケジュールに沿ってBigQueryへの定期的なチェックを実行し、データ品質の監視を行います。監視項目が検知された場合には、Slackにアラートメッセージを通知します。アラートメッセージは、監視項目ごとにクエリの実行結果などを設定可能です。また、監視項目の追加は、YAMLとSQLで記述してGitHubにプッシュすると、GitHub ActionsによってGCPに自動デプロイされます。インフラのセットアップも、GitHub ActionsからTerraformを利用して、必要な環境を自動的にセットアップします。詳しいインフラ構成は後述します。 次に、Coppeの監視項目をサンプルを用いて説明します。 監視項目の追加は、以下のようなフォーマットでYAMLファイルに記述します。 - schedule : "*/5 * * * *" sql : SELECT COUNT(*) AS error_log_cnt FROM `project.schema.table` WHERE ... expect : row_count : 0 description : 直近5分の間にエラーログを検知しました。 上記の例で示したパターン以外にも、様々なオプションを用意しています。基本となる設定項目は以下の4つです。 監視スケジュール crontab形式による指定 BigQueryで実行するクエリ 期待するクエリ結果 アラートメッセージの内容 さらに複雑な監視項目を設定したい場合、以下のようなフォーマットにも対応しています。 - schedule : "0 * * * SUN,TUE,WED,THU,FRI" sql_file : streaming-datatransfer-delay-alert.sql matrix : env : [ stg, prd ] params : interval_minute : 5 expect : row_count : 0 description : | 次のテーブルで5分以上の遅延が発生しています {{ range . }} {{ .table_name }} : {{ .cnt }} (cnt) : {{ .delay_avg }} (delay_avg) : {{ .delay_max }} (delay_max) {{ end }} # streaming-datatransfer-delay-alert.sql SELECT label, table_name, COUNT (*) AS cnt, AVG (diff) AS delay_avg, MAX (diff) AS delay_max FROM ( SELECT table_name, changetrack_start_time, bigquery_insert_time, TIMESTAMP_DIFF(bigquery_insert_time, changetrack_start_time, SECOND) AS diff, CASE WHEN TIMESTAMP_DIFF(bigquery_insert_time, changetrack_start_time, SECOND) > 600 THEN 1 ELSE 0 END AS label FROM `streaming-datatransfer-{{.env}}.streaming_datatransfer.streaming_changetracktransfer_T*` WHERE bigquery_insert_time >= TIMESTAMP_SUB( CURRENT_TIMESTAMP (), INTERVAL {{.interval_minute}} MINUTE)) WHERE label = 1 GROUP BY label, table_name 上記の例は、以下の設定をしています。 日曜日と火〜金曜日に、指定のSQLファイルに記載されたクエリを毎時実行する streaming-datatransfer-stg と streaming-datatransfer-prd のプロジェクトで実行する クエリ結果を評価し、期待されている結果と照らし合わせる アラートメッセージにクエリ結果を展開し、パースした状態で通知する 監視項目のフィールドや書式について、詳しく説明します。 スケジュール schedule : "* * * * *" 監視のスケジュールは、crontab形式を採用しています。そのため、crontab同様に以下の順で指定します。 分 時 日 月 曜日 書式に従い、以下のように使用します。 毎分: "* * * * *" 10分おき: "*/10 * * * *" 毎時0分: "0 * * * *" 毎日0時: "0 0 * * *" 毎週月〜金の18時: "* 18 * * MON,TUE,WED,THU,FRI" クエリ BigQueryで実行するクエリは、 直接SQLを記載する方法と、SQLを記載した別ファイルへの相対パスを指定する方法があります。 sql: に直接SQLを記載する場合は、以下のようにYAML内にSQLを埋め込みます。 sql : SELECT * FROM ... また、SQLを記載した別ファイルの相対パスを指定する場合は、 sql_file: に記載します。 sql_file : some_dir/file_name.sql 他にも、テキストテンプレートの書式を使用し、以下のように params: を使用してSQL内にパラメータを入れることもできます。さらに、1つの監視項目を複数の組み合わせのパラメータに対して実行したい場合は、GitHub Actionsのマトリックスの仕様と同様に、 matrix: に配列を指定することも可能です。 sql : SELECT * FROM `sample-{{ .env }}-svc.some_schema.some_table_{{ .platform }}` WHERE timestamp > {{ .since }} params : since : TIMESTAMP_SUB(CURRENT_TIMESTAMP, INTERVAL 1 HOUR) matrix : env : [ dev, stg, prd ] platform : [ android, ios ] 期待するクエリ結果 監視対象となるクエリの期待値は、以下のように指定します。クエリ結果の列数を row_count: に指定したり、クエリ結果を使用した式 expression: を指定可能です。なお、式を利用することで、より複雑な監視条件の設定ができます。 expectation : row_count : 0 expectation : expression : column_name_1 == "foo" && column_name_2 == 0 アラートメッセージ 監視対象に指定したクエリの期待値から外れた結果を得た場合、Slackチャンネルにアラートを通知します。そのアラートの通知内容は description: にて指定可能です。 なお、アラートメッセージには、以下のように、クエリ結果やSQLで使用した params: や matrix: の値を展開して利用可能です。 # サンプルの監視項目 - schedule : "0 * * * *" sql : SELECT column_1, column_2 FROM `sample-{{ .env }}-svc.some_schema.some_table_{{ .platform }}` WHERE timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP, INTERVAL {{ .since_hour }} HOUR) params : since_hour : 1 matrix : env : [ prd, stg, dev ] platform : [ android, browser, ios, server ] expect : row_count : 0 description : | `sample-{{ .matrix.env }}-svc.some_schema.some_table_{{ .matrix.platform }}`にて、{{ .params.since_hour }}時間前から現在までの間に、以下のレコードが検出されました クエリ結果 {{ range .query_result }} - column_1 : {{ .column_1 }} , column_2: {{ .column_2 }} {{ end }} 上記の設定をした場合、実際には以下のようなアラートメッセージが生成されます。 # 実際のアラートメッセージ `sample-prd-svc.some_schema.some_table_ios`にて、1時間前から現在までの間に、以下のレコードが検出されました クエリ結果 - column_1 : aa, column_2: bb - column_1 : ab, column_2: bc Coppeのインフラ構成 本章では、Coppeのインフラ構成と、その選定理由を説明します。 Coppeのインフラ構成は、上図に示す通りです。Cloud Functionsをデプロイ先として、Pub/SubやCloud Schedulerを使用します。また、上図では省略していますが、Cloud Functions自体を監視するために、Cloud Monitoringも使用しています。 具体的な仕組みを説明します。Cloud Schedulerにより、毎分の間隔でPub/Subを介して上図左側のCloud Functionsを起動します。ここでは、YAMLファイルを元にスケジュールを実行するか判断し、SQLファイルのパースを行った上で、上図右側のPub/Subに監視項目のデータを渡します。そして、上図右側のCloud Functionsは、Pub/Subから監視項目を受け取り、BigQueryに問い合わせ、その結果が期待される値と等しいかどうかを確認します。期待される値と異なった場合は、指定したSlackチャンネルに通知します。 上記のインフラ構成は、前述のRedash運用の課題も考慮し、以下の選定基準で策定しました。 基準1:運用の手間が可能な限り不要である 基準2:費用が可能な限り抑えられる 実際に、デプロイ環境の候補に挙がったのは、以下の5つでした。なお、クエリの実行先がBigQueryということもあり、今回のインフラ選定ではGCP環境のみを検証対象にしています。 Cloud Run App Engine Cloud Functions Compute Engine Kubernetes Engine この5つの候補から、上記の2つの基準から選定していきます。アプリケーションだけでなく、インフラ面の運用が必要となるCompute Engine、クラスター自体に固定費がかかるKubernetesは基準から外れるため、候補から除外されました。アプリケーションであれば、BigQueryから取得した内容の計算が必要となる場合が多いです。しかし、Coppeでは、ほとんど計算力や拡張性を必要としておらず、2つの基準にある運用面と金銭面で、費用に見合わないため、この判断をしました。 また、Cloud Runも別途コンテナを用意する必要があるため、運用面の基準により候補から除外しました。Cloud Runはコンテナを使うため、Googleがサポートする言語以外も利用可能になるメリットがあります。しかし、今回のアプリケーションの要件では、特定の言語に依存する必要性もなく、Googleがサポートしている言語で十分に開発可能でした。そのため、このメリットの恩恵は受けられないと判断しました。 ここまでの検討の結果で、App EngineとCloud Functionsの2択に絞られました。どちらも、機能面ではCoppeで実現させたいことが可能であり、費用も少額、そして運用の手間も少なくて済む特徴を持っています。 しかし、App Engineは、プロジェクト毎に1つのアプリケーションしかデプロイできない条件があります。これは、「既にApp Engineを使っているプロジェクトでは、Coppeを使うことができない」という制約を発生させます。そのため、最終的にCloud Functionsを選定しました。Cloud Functionsは、ファンアウトパターンを容易に実現でき、コードがシンプルに書けるといった利点も持っています。 また、Coppeに必要なインフラ構成はTerraformを使って管理しています。初回に限り、以下の処理が必要ですが、それ以降はGitHub Actionsによって自動デプロイされる仕組みです。 SlackのWebhook URLとGCPのプロジェクト名を環境変数用のファイルに書き込む Slackへの通知をCloud Console上で許可する Terraformによるインフラ構築の下準備に必要なスクリプトを実行する まとめ 本記事では、データ品質担保のためのBigQuery基盤のデータ監視ツールである「Coppe」を紹介しました。Coppeの開発により、YAMLやSQLを使った監視項目の追加が可能になり、複数の環境を横断する設定も容易に実現可能になりました。その結果、BigQueryのデータに異常がないか、容易に定期的なチェックができるようになりました。 運用を開始してから、まだ日は浅いですが、Redashを活用していた時と同様の監視をCoppeで実現できています。本記事を読んで、使ってみたいと思った方は、是非使ってみてください。IssueやPull Requestもお待ちしております。 ZOZOではデータ基盤のガバナンスを強化し、利用者にとって安全安心なデータ基盤を整備していく仲間を募集しています。ご興味のある方は、以下のリンクからご応募ください。 hrmos.co
アバター
こんにちは、ZOZO CTOブロックの池田( @ikenyal )です。 エンジニアが12月に思い浮かべるキーワードは何でしょう。「アドベントカレンダー」ですね。 弊社も毎年アドベントカレンダーに参加しており、今年は合計125本の記事公開を完走しましたので、概要をお伝えします。 ZOZO Advent Calendar 2021 今年は合計5個のカレンダーを実施したため、12/1-25の期間に合計125本の記事を公開しました。 qiita.com 実施概要 アドベントカレンダーは任意参加で実施しています。 アドベントカレンダーはエンジニアのアウトプットの練習に適したイベントです。弊社ではテックブログをアウトプットの主軸に置いていますが、「まだテックブログを書く自信が無い」「テックブログに書くにはネタが小粒」のような場合に、アドベントカレンダーは良い機会です。 人気があった記事 はてなブックマークのブックマーク数が上位だったものを紹介します。 今年の数値を確認したところ、上位2記事の執筆者は新卒の2名でした。この2本の記事のブックマーク数は僅差であり、3位とも差を付けており、新卒エンジニアの力を改めて実感しました。 12/19 カレンダー1 の記事、 y_takaya による「 Goを学ぶときに参照した学習リソースまとめ 」 zenn.dev 皆さん、新しいプログラミング言語を学ぶ時、どのように学習しているでしょうか?私は4月に新卒でエンジニアになり、業務でGoを使うことになりました。その際、とりあえず公式チュートリアルであるTour of Goをやりましたが、その後にどうやって学習を進めれば良いか迷ってしまいました。 考えてみると、新しい言語を学ぶ際、毎回学習方法に困っている気がします。ネットでサンプルを探す、動画を見る、書籍を読む、などさまざまな学習方法があると思いますが、私は手を動かしながらいろいろなパターンを学んでいくのが好きです。 そこで今回Goを学ぶ際も、手を動かしてさまざまなコーディングのパターンを学習するために、ネットや書籍でサンプルを探して実践しました。この学習方法は私にとっては楽しみながら続けることができて、他の言語を学ぶ際も今回実装したサンプルを使って学習しようと考えています!そこで自分と同じ様な悩みを持っている人や未来の自分のために、手を動かしながら新しい言語(今回はGo)を学ぶ際に、自分が取り組んだ方法をまとめてみます! 12/10 カレンダー5 の記事、 tmrekk による「 Kubernetesを使う上で知っておきたいツールやプラグイン 」 zenn.dev 本記事では、普段Kubernetesを触っている中で便利に感じたツールやコマンドをざっくばらんに紹介します。 Kubernetes初心者からベテランまで幅広く楽しんでいただければ幸いです。 過去のアドベントカレンダー ZOZOでは、2018〜2020年もアドベントカレンダーに参加しています。なお、10/1に株式会社ZOZOテクノロジーズは組織再編が行われ、株式会社ZOZO及び株式会社ZOZO NEXTとして再始動しています。 ZOZOテクノロジーズ Advent Calendar 2020 qiita.com qiita.com qiita.com qiita.com ZOZOテクノロジーズ Advent Calendar 2019 qiita.com qiita.com qiita.com qiita.com qiita.com ZOZOテクノロジーズ Advent Calendar 2018 qiita.com qiita.com qiita.com 最後に ZOZOでは、プロダクト開発以外にも、今回のような外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは、ZOZO CTOブロックの池田( @ikenyal )です。 ZOZOでは、12/7に ZOZO Tech Talk #2 - iOS を開催しました。 zozotech-inc.connpass.com 本イベントは、これまで夕刻に開催してきたMeetupとは異なり、ランチタイムに開催する「ZOZO Tech Talk」シリーズです。ZOZO Tech Talkでは、ZOZOがこれまで取り組んできた事例を紹介していきます。 そして、第2回はネイティブアプリ開発の中で、特にiOSにフォーカスし、弊社エンジニアがお話ししました。 登壇内容 まとめ 弊社の社員2名が登壇しました。 歩みを振り返ると見えてくる 今、新たな「仲間」がWEARアプリ開発に必要な理由 (メディア開発本部 WEAR部 / 小野寺 賢) 大公開!ZOZOTOWN iOSのコードレビューを覗きながらレビューの必要性を再確認しよう! (ZOZOTOWN開発本部 ZOZOTOWNアプリ部 / 松井 彩) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは、ZOZO CTOブロックの池田( @ikenyal )です。 ZOZOでは、12/6に ZOZO Tech Talk #1 - Android を開催しました。 zozotech-inc.connpass.com 本イベントは、これまで夕刻に開催してきたMeetupとは異なり、ランチタイムに開催する「ZOZO Tech Talk」シリーズです。ZOZO Tech Talkでは、ZOZOがこれまで取り組んできた事例を紹介していきます。 そして、第1回はネイティブアプリ開発の中で、特にAndroidにフォーカスし、弊社エンジニアがお話ししました。 登壇内容 まとめ 弊社の社員2名が登壇しました。 ZOZOの新規サービス「FAANS」の開発 Android編 (メディア開発本部 FAANS部 / 山田 尚吾) Android Lintでコードの宣言順をチェックする (ZOZOTOWN開発本部 ZOZOTOWNアプリ部 / 鈴木 優佑) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは。ECプラットフォーム部 カート決済ブロックの高橋です。 ZOZOTOWNでは、数年前よりClassic ASPからJavaへのリプレイスが実施されています。そのリプレイスの一環として、2021年4月からカート決済機能のマイクロサービス化を開始しました。 ZOZOTOWNの中長期目標である「商品取扱高5000億円」を達成するために、リプレイス後は以下の要件をシステムが満たしている必要があります。 セールなどの高負荷イベント時にスケール可能であること キャパシティコントロールが可能であること Datadog、SentryなどのSaaSを利用した運用監視の効率化できること CI/CDなどを取り入れ、開発生産性を向上できること レガシー技術をモダン化できること そして、カート決済機能はZOZOTOWNの中でも最も大きな機能であり、最も重要な機能です。そのため、リプレイスは慎重に進めなければなりません。 本記事では、そのリプレイスのPhase1として先日リリースし、キャパシティコントロールを実現させた事例を紹介します。 カート投入機能のリプレイス 本章では、リプレイスしたカートの1つの機能である「カート投入機能」の概要を説明します。 カート投入機能の仕様 まず、ZOZOTOWNにおけるカート投入の仕様を説明します。 「カートに入れる」ボタンを押すことで、以下の処理が行われます。 在庫を引き当てる 同時に、在庫数を減らす カートテーブルへ、その情報を登録する ZOZOTOWNのカート機能の大きな特徴は、カート投入時に在庫の引き当てをしている点です。今回のリプレイスでは、それらのカート投入の仕様は変更せず、アーキテクチャの変更のみを行っています。 つまり、カート投入時に在庫の引き当てをする仕様を変更しないため、「FIFO(First-In First-Out)であること」が重要な要件となりました。 アーキテクチャの変更 これまでは、下図に示すアーキテクチャでした。リクエストをIISで受け、Classic ASPからストアドプロシージャを呼び出し、処理を実行していました。 そして、今回のリプレイスでは、下図のアーキテクチャに変更しています。IISとストアドプロシージャの間にCart Queuing Systemを新規で作成します。これにより、Cart Queuing Systemとストアドプロシージャ呼び出し用のIISを新たに追加しています。 リプレイスの目的 現在のシステムでは、ZOZOTOWNの成長に伴い、高負荷に耐えられなくなる可能性があります。そのため、今回のカート機能のリプレイスの目的は、キャパシティコントロールを可能にすることです。 それを実現するために、Cart Queuing Systemは、下図に示す構成にしました。 リクエストのステータス管理をAmazon DynamoDB(以下、DynamoDB)で管理します。そして、Amazon Kinesis Data Streams(以下、KDS)には、IISへのリクエスト情報やカート投入情報テーブルのキー情報を送信します。 全体の流れは、Cart APIの登録APIが呼び出されるところから始まります。その登録APIでは、以下の処理を行います。 商品情報などのカート投入に必要な情報をDynamoDBへ登録する 上記の 1. で登録した情報のキーと、Cart WorkerがAPIへリクエストするのに必要な情報をKDSへ送信する 次に、Cart Workerで以下の処理を行います。 KDSに送信された情報を取得する DynamoDBの対象レコードに対し、ステータスを「処理中」に更新する 上記の 1. で取得した情報を元に、ストアドプロシージャ呼び出しAPIへリクエストする 上記の 3. のレスポンスを元に、DynamoDBの対象レコードに対し、ステータスを「処理完了」に更新する そして、ステータス取得APIでは、以下の処理を行います。 登録APIで登録したDynamoDBのキーを元に、対象レコードのステータスを取得する ステータスがCart Workerによって「処理完了」に変更されている場合、DynamoDBに登録されている情報を返却する Cart Workerの処理ステータスが「処理中」の場合、一定間隔で指定期間までポーリングを行う 上記の 3. のポーリングの間にステータスが変更された場合、上記の 2. の処理を行う 上記の 3. で指定期間内にステータスが更新されなかった場合、タイムアウトとして返却する なお、Cart APIにあるステータス取得APIのレスポンスがタイムアウトである場合、IISからステータス更新APIを呼び、DynamoDBのレコードのステータスを「タイムアウト」に更新します。 技術スタック Cart APIとCart Workerの技術スタックをご紹介します。 Cart API Cart Worker 言語 Java Java フレームワーク Spring Boot - データベース DynamoDB DynamoDB なお、Cart WorkerはKinesis Client Libraryを使用したアプリケーションであり、フレームワークを使用していません。 過熱商品への対応 ZOZOTOWNでは、福袋のように限定で発売されるような商品があり、このような商品を「過熱商品」と呼んでいます。過熱商品は、発売開始のタイミングで一時的にアクセスが急増する特徴があります。これまでのシステム構成では、そのアクセス急増により、過熱商品以外の通常の商品をカートに投入しようとしているユーザーにも影響が出ていました。そのため、今回のリプレイスでは、過熱商品への対応も行っています。 その対応として、前述した登録APIの処理の中には、過熱商品かどうかを判定する処理が追加されています。そのため、過熱商品の情報のみを持つテーブルが新たに必要になります。そして、すべてのリクエストが、新しく用意した過熱商品用テーブルを参照しているため、パフォーマンスを考慮してAmazon DynamoDB Accelerator(以下、DAX)を使用しています。 過熱商品の判定を含めた、登録APIからCart Workerへの処理の流れは以下の通りです。 リクエスト時の商品情報を元に、過熱商品用テーブルへ問い合わせをする 商品情報などの情報をDynamoDBのカート投入情報テーブルに登録する 上記の 2. で登録した情報のキーと、後続のCart Workerが次のIISのAPIへリクエストする際に必要な情報を、KDSへ送信する 上記の 1. でデータが存在した場合は、過熱商品用のストリームを使用する 上記の 1. でデータが存在しない場合は、通常用のストリームを使用する KDSの情報を取得する 上記の 4. で取得した情報を元に、カート投入情報テーブルのステータスを更新する 上記の 4. で取得した情報を元に、IISへリクエストする カート投入ストアドプロシージャを呼び出す 通常商品と過熱商品でストリームを分けたことで、過熱商品が発売するタイミングであっても、通常商品をカート投入するユーザーに大きな影響を与えることがなくなりました。また、商品単位でKDSの同一のシャードを使用しており、FIFOのカート投入順も担保できるようになっています。 問題点とその対処法 本章では、リプレイスを進める中で見つかった問題点と、その対処法の一部を紹介します。 DAXへのアクセスをローカルで試行できない AWSを利用したシステムを開発するため、ローカル環境で LocalStack を使用していました。DynamoDBやKDSは、LocalStackを使用してモック環境を構築し、開発を進めていました。 しかし、DAXはLocalStackでは構築できないため、動作確認ができない状態で実装を進める必要がありました。さらに、ローカル環境で実行していたものを、クラウド上で実行させる場合には、SpringのDIコンテナに管理するクラスを変更するという対応も必要でした。 最終的には、ローカル環境で開発する際にはDAXへの通信ではなく、LocalStackで構築したDynamoDBを参照する形式で開発を進めました。 モニタリングが1画面でできない ZOZOTOWNのモニタリングには、Datadogを導入しています。Datadogは、トレースをリクエスト単位で収集します。そのため、Cart APIからCart Workerまでのトレース情報が別々に表示されてしまい、エラー調査時にそれらの関連付けに苦労していました。 そこで、必要な情報をAPIのリクエストに渡すことで、すべてのトレース情報を1つの画面で表示できるようにしました。その結果、下図のようにDatadogのトレース情報を集約できました。 登録APIからCart Worker、ステータス取得APIまで1つのトレース情報として表示されていることがわかります。これにより、Cart Queuing Systemのエラー発生時には、情報が1つの画面に集約して表示されるため、エラー調査もスムーズになりました。 おわりに 冒頭で説明した通り、カート決済機能はZOZOTOWNの中で最も大きい機能です。今回のカート機能のリプレイスは、キャパシティコントロールを目的にした「カート決済機能リプレイス Phase1」です。今後も、マイクロサービス化を半年から1年にフェーズを分けて、段階的に進めていきます。 ECプラットフォーム部 カート決済ブロックでは、仲間を募集しています。ご興味のある方は、こちらからご応募ください。 hrmos.co
アバター
はじめに こんにちは、技術本部 データサイエンス部 MLOpsブロックの鹿山( @Ash_Kayamin )です。 みなさんは2021年4月にGCPから「GKE Gateway コントローラによる Kubernetes ネットワーキングの進化」という記事が投稿されたのを覚えていますでしょうか。 cloud.google.com この記事は、Kubernetesコミュニティが発表したKubernetes Gateway APIに対し、そのGKE(Google Kubernetes Engine)版実装であるGKE Gateway Controllerのリリースをアナウンスするものでした。 それから半年が経ち、本番導入の可能性を模索するためにKubernetes Gateway APIとGKE Gateway Controllerを調査、動作検証しました。本記事では、Kubernetes Gateway APIの概要と、APIで定義されるトラフィックのルーティングがGKE Gateway ControllerによってどのようにGCP上で実現されるのかを、動作検証の流れに沿って解説します。 なお、2021年11月時点で、Kubernetes Gateway APIの最新バージョンは v1alpha2 、GKE Gateway ControllerがサポートするGateway APIのバージョンは v1alpha1 であり、今後仕様が大きく変わる可能性がある点にご注意ください。 目次 はじめに 目次 Kubernetes Gateway APIの開発背景と特徴 Kubernetes Gateway APIが開発された背景 Kubernetes Gateway APIを構成する3種類のリソース Gateway APIを用いる利点 利点1:ルーティングの設定に必要最小限な権限をRBACで付与できる 利点2:プロバイダー依存性の低いManifestでルーティングの設定を定義できる 利点3:拡張性が高い GCPにおけるGateway APIの実装 GKE Gateway Controllerの動作検証 1. GKE Gateway Controllerに対応したシングルクラスタを構築する 2. Gateway APIのCRDをインストールし、GatewayClassが作成されることを確認する 3. Gatewayリソースを作成することで、GCLBが作成されることを確認する 4. HTTPRouteリソースを作成して、GCLBにルーティングのルールが追加されることを確認する 5. HTTPRouteで定義したルーティングルールの優先順位とGCLB上でのルールの優先順位の定義を確認する おわりに Kubernetes Gateway APIの開発背景と特徴 よりスムーズに理解していただくために、 Kubernetes Gateway API が作成された背景から順にご紹介します。 Kubernetes Gateway APIが開発された背景 Gateway API(Kubernetes Gateway API)は Ingress API(Kubernetes Ingress API) の課題を解消するために開発されました 1 。 そのIngress APIは、Kubernetesクラスタ外部からクラスタ内 Service (Kubernetes Service) に対し、アプリケーション層でHTTPやHTTPSを用いたルーティングを制御するAPIです。多数のプロバイダーでIngress APIの仕様に則った Ingress Controller が実装されています。また、Ingress Controllerの実装によっては負荷分散やSSL終端といった機能も提供します。MLOpsブロックでもGKEで コンテナネイティブな負荷分散 を利用するために、GCPが提供するIngress Controllerである GLBC を利用しています。GLBCはIngress APIを通して、 GCLB(Google Cloud Load Balancing) を用いたルーティングの設定が可能です。 実は、Ingress APIでは非常にシンプルな機能を実現するための仕様しか定義されていません。そのため、Ingress Controllerのプロバイダー、Ingress Controllerの利用者それぞれに以下の負担が発生していました。 Ingress APIでは定義されていないトラフィックの荷重ルーティング等の機能を追加するには、プロバイダーは Ingress のManifestに独自のannotationを定義する必要がある 開発者はプロバイダー毎にannotationが大きく異なるManifestを書かなくてはならない 例えば、各Ingress Controller毎にannotationへ定義可能な設定項目数を比較すると以下のように大きな差があります。 GLBC:6個 GLBCで利用可能なannotation 内部LBの場合 GLBCで利用可能なannotation 外部LBの場合 AWS Load Balancer Controller:40個 AWS Load Balancer Controllerで利用可能なannotation NGINX Ingress Controller:110個 NGINX Ingress Controllerで利用可能なannotation これは、対応している機能や、設定項目の表現方法(annotationのみを使うのか、annotationと CR(Custom Resource) を組み合わせるのか等)が異なるため、結果としてannotationの数に大きな差が生じています。 例えば、L7外部ロードバランサーを定義して、ロードバランサーをSSL終端とし、バックエンドのサービスへのヘルスチェックを設定することを考えてみます。 AWS Load Balancer Controllerを用いる場合のManifestは以下のように定義します。 apiVersion : networking.k8s.io/v1 kind : Ingress metadata : name : zozo-techblog annotations : kubernetes.io/ingress.class : alb alb.ingress.kubernetes.io/scheme : internet-facing alb.ingress.kubernetes.io/target-type : ip alb.ingress.kubernetes.io/certificate-arn : arn:aws:acm:xxxx alb.ingress.kubernetes.io/healthcheck-protocol : HTTP alb.ingress.kubernetes.io/healthcheck-port : '80' alb.ingress.kubernetes.io/healthcheck-path : /health spec : rules : - http : paths : - path : /* backend : serviceName : zozo-techblog servicePort : 80 一方、GLBCを用いる場合のManifestは以下のように定義します。 apiVersion : networking.k8s.io/v1 kind : Ingress metadata : name : zozo-techblog-ingress annotations : kubernetes.io/ingress.allow-http : "false" ingress.gcp.kubernetes.io/pre-shared-cert : "api-cert" spec : defaultBackend : service : name : zozo-techblog-service port : number : 80 --- apiVersion : v1 kind : Service metadata : name : zozo-techblog-service annotations : cloud.google.com/neg : '{"ingress": true}' # ref. https://cloud.google.com/kubernetes-engine/docs/how-to/container-native-load-balancing # BackendConfigを用いてヘルスチェック等をサービス毎にカスタマイズする cloud.google.com/backend-config : '{"default": "zozo-techblog-backendconfig"}' spec : selector : app : zozo-techblog-pod ports : - port : 80 protocol : TCP targetPort : 8080 --- apiVersion : cloud.google.com/v1 kind : BackendConfig metadata : name : zozo-techblog-backendconfig spec : healthCheck : checkIntervalSec : 15 type : HTTP port : 8080 requestPath : /health annotationが異なるのはもちろんですが、ヘルスチェックの指定方法が大きく異なっていることが分かります。AWS Load Balancer Controllerでは Ingress のannotationにヘルスチェックの定義を記載します。一方、GLBCを用いる場合は BackendConfig というCRにヘルスチェックの定義を記載します。そこでは、 Ingress で指定する Service のannotationで BackendConfig を指定する必要があります。 また、L7ロードバランサーの機能への対応状況も大きく差があります。例えば、AWS Load Balancer Controllerでは Service 毎に割合を指定することでトラフィックの細かな分割ができます 。一方、GLBCではトラフィックの分割割合の指定はできません。GLBCで設定をするL7ロードバランサーGCLBにはトラフィックの分割割合を指定する機能は存在します。しかしながら、現状GLBCにはGCLBでのトラフィック分割割合を設定する機能は実装されていません。L7ロードバランサーを構成するなら当然利用できるはずだと思う機能も、現状では利用できるかどうかはプロバイダー次第となってしまっています。 このように、Ingress Controller毎に対応している機能や設定項目の表現方法が大きく異なります。そのため、開発者が普段とは異なるIngress Controllerを利用する際には、Manifestを書くことに苦労します。 Gateway APIは、この問題を解消するために開発されました。Gateway APIはL4/L7ロードバランサーで実現可能なルーティングをできる限り共通の仕様で実現できるように配慮しています。 また、Ingress APIでは、L7での Service へのルーティングの定義を1つのリソースで定義していました。一方、Gateway APIではルーティングの定義を責務毎に、3種類のリソースに分割しています。リソースを分割することで権限管理の対象が細分化されるため、 RBAC(Role-based access control) を用いて「 最小権限の原則 」に基づいた 安全な運用 が可能です。 Kubernetes Gateway APIを構成する3種類のリソース Gateway APIは、Kubernetesクラスタ外部からクラスタ内の Service へのL4/L7でのルーティングを、3種類リソース GatewayClass 、 Gateway 、 Route を用いて定義するAPIです。 GatewayClass Gateway を構成するためのテンプレートを示すリソース Gateway を構成するために使用するGateway Controllerをパラメータと共に指定する このパラメータで Gateway 構成時に構築されるロードバランサーの設定項目(L4、L7、外部、内部等)を指定する Gateway リクエストをクラスタ内へルーティングするルールを定義するリソース 指定した GatewayClass の定義を元にロードバランサーやプロキシ等を実際に構築する クラスタ内のどこにルーティングするかは Route によって定義する Route Gateway から Service に対するルーティングのルールを定義するリソース ロードバランサーでのパスベースのルーティングの指定等に対応 対応するプロトコル毎に HTTPRoute 、 TCPRoute 、 TLSRoute 、 UDPRoute が存在する 以下、公式ドキュメントにある図に描かれているように、 GatewayClass 、 Gateway 、 Route (図では HTTPRoute )の3つのリソースを組み合わせることで、 Service へのルーティングを定義します。 引用 https://gateway-api.sigs.k8s.io/ 2021年11月時点で、GKEや Istio を含む複数のプロジェクトがGateway APIで定義された挙動を実現する Gateway Controller を実装しています。 Gateway APIを用いる利点 Gateway APIにはIngress APIと比べて以下の利点があります。 ルーティングの設定に必要最小限な権限をRBACで付与できる プロバイダー依存性の低いManifestでルーティングの設定を定義できる 拡張性が高い 順に説明します。 利点1:ルーティングの設定に必要最小限な権限をRBACで付与できる 前述の通り、Gateway APIでは Service へのルーティングを、3種類の責務に対応したリソース GatewayClass 、 Gateway 、 Route で定義します。リソースが分かれていることで、RBACを用いて「誰が何をできるのか」をリソース毎に管理できます。つまり、開発者の責務に対して必要なルーティング設定を行う権限のみを付与できます。こうすることで、Kubernetesのリソースを通して行うルーティング設定に「最小権限の原則」に基づいた運用を導入できます。Ingress APIでは、1つのリソースでロードバランサーと Service ヘのルーティングの定義を兼ねており、権限の分離はできませんでした。 例として、下図の公式ドキュメントの図が示すような、1つのロードバランサーに対して複数の Namespace に存在する異なるアプリケーションを紐づけたシステムを考えます。このシステムで、クラスタ管理者とアプリケーション開発者の権限を分けてみましょう。 引用 https://gateway-api.sigs.k8s.io/ Step1 クラスタの管理者にはクラスタ内のアプリケーションが共通で利用するロードバランサーを管理できるように、 Gateway リソースを閲覧、作成、編集、削除できる権限を与える 一方、アプリケーション開発者の権限は Gateway リソースの閲覧のみに絞ることで、サービス全体で利用するロードバランサーを誤って削除できないようにする Step2 各アプリケーション開発者へは、特定の Namespace でのみ Route リソースの閲覧、作成、編集、削除できる権限を与える その結果、開発者が管理している特定の Namespace 配下のアプリケーションに対してのみ、ロードバランサーからトラフィックをどのように割り振るのかを管理できるようになる このように権限を分離することで、各アプリケーション開発者に必要最小限の権限を与え、安全に開発を進めることができます。 利点2:プロバイダー依存性の低いManifestでルーティングの設定を定義できる Gateway APIでは、 3種類の実装サポートレベル CORE 、 EXTENDED 、 OPTIONAL が定義 されています。そして、このサポートレベルは 機能毎に設定 されています。 CORE 全てのGateway Controllerで実装される重要な機能 Gateway APIでManifestの仕様が定義されている EXTENDED 全てのGateway Controllerで実装されるわけではないが、重要な機能 Gateway APIでManifestの仕様が定義されている CUSTOM プロバイダー依存のオプショナルな機能 Gateway APIではCRを指定できるようにManifestの仕様が定義されており、プロバイダーは任意のCRを用いた機能を実装できる 上記の説明で用いている「重要な機能」とは、一般にL7ロードバランサーに備わっている機能(ヘッダーベースのルーティング等)で、プロバイダーに依らず可搬性のある機能を指します。 Ingress では 独自のannotationを用いる手段しかサポートしていませんでした 。なお、どういった機能がどのサポートレベルまで対応するのか、明確な判断基準は示されていません。機能のサポートレベルについて詳しく知りたい方は API仕様のドキュメント をご参照ください。 Gateway APIのリソースでは、 Service へのルーティングを管理するのに必要な機能が一通り CORE 、 EXTENDED で定義されています。例えば、 Gateway における静的IPやSSL証明書の指定、 HTTPRoute におけるヘッダーベース・パスベースのルーティングやトラフィック分割の指定等が含まれています。そのため、これらの必要となる機能は各プロバイダーで、ある程度等しく実装されることが期待できます。その結果、Gateway API利用時に使用するManifestを汎用的に使えるようになることも期待できます。必要な機能が汎用的なManifestの仕様として定義されていれば、Manifestを見れば実現されている機能が一目で分かる利点があります。 一方、従来のIngress APIでは非常にシンプルな機能を実現するための仕様しか定義されておらず、各プロバイダーがManifestに独自のannotationを定義して機能を拡張する必要がありました。その結果、プロバイダー毎に提供される機能、必要なManifestのフォーマットが大きく異なっていました。 これに対して、Gateway APIでは、より高度なルーティング管理機能をAPIで最初から定義することと、以下で説明するCRを用いた拡張方法を提供することで、このIngress APIの問題を解消しようとしています。 利点3:拡張性が高い Gateway APIはCRを用いた拡張ができるように設計されています。拡張のために、Gateway APIで定義されているManifestの中には CRを指定できるポイント がいくつか用意されています。Ingress APIでは、annotationで拡張するしか手段がありませんでした。annotationは単なる文字列であり、必要な項目が設定されているかの確認等のバリデーションはできません。また、Manifestにどんなannotationを設定できるのかを知るにはドキュメントを確認する必要がありました。一方、CRによる拡張はManifestのバリデーションが可能です。 kubectl get crd や kubectl explain 等でSpecを確認できるため、設定可能な項目を知るのも容易になっています。Gateway APIがサポートしているCRによる拡張は、Controller開発者、利用者双方にとって、より好ましい拡張方法と言えます。 Gateway APIのバージョン gateway.networking.k8s.io/v1alpha2 における、各種 Route では spec.rules[].backendrefs[].kind で Service の代わりににCRを指定できます。例えば、 Cloud Functions へルーティングするためのCRを作成し、指定することを考えてみます。Gateway Controllerの実装者はCloud Functionsへのルーティングに必要な情報を CRD(Custom Resource Definition) として定義しておきます。CRDに指定した通りにCRが作成され、 Route リソースで指定された際には、CRの情報を元にCloud Functionsへルーティングできるよう、Gateway Controllerを実装します。それにより、利用者がCRを作成し、 Route リソースにCRを指定すれば、Cloud Functionsへのルーティングを実現できるようになります。このように、Gateway APIを拡張し、Cloud Functionsへのルーティングを設定する機能を実現できます。 この他にも、Gatewayでは spec.listener.allowedRoutes[].kinds[] でCRを指定でき、既存の Route 以外の独自の Route リソースも使用できます。このように、Gateway APIではCRを用いた拡張がしやすいように配慮されています。 # HTTPRouteでspec.rules[].backendrefs[]を指定する例 apiVersion : gateway.networking.k8s.io/v1alpha2 kind : HTTPRoute metadata : name : example-route namespace : gateway-api-example-ns2 spec : parentRefs : - name : prod-gateway hostnames : - "example.com" rules : - backendRefs : - kind : Service # kindを指定できるのでCRを指定することも可能。 デフォルトではServiceを指定するようになっている。 name : example-svc # 対象とするkindのmetadata.nameを指定 port : 80 # kind: Serviceの場合は必須 GCPにおけるGateway APIの実装 ここまでのGateway APIに関する説明は、2021年11月時点でのGateway APIの最新バージョン gateway.networking.k8s.io/v1alpha2 に対するものです。本章で説明する、GKE Gateway Controllerはバージョン networking.x-k8s.io/v1alpha1 への対応となっており、動作検証ではバージョン networking.x-k8s.io/v1alpha1 で定義されたManifestを利用しているのでご注意ください。 GCPでは、その networking.x-k8s.io/v1alpha1 に対応した、GKE Gateway Controllerがプレビュー機能として公開されています。GKE Gateway Controllerを利用することで、単一または複数のGKEクラスタにまたがる内部、外部HTTP(S)負荷分散を管理できます。2021年11月時点で、 GatewayClass 、 Gateway 、 HTTPRoute リソースのみがサポートされています。また、 4種類のGatewayClass が定義されており、各 GatewayClass 毎にサポートする機能が異なっています。 4種類の GatewayClass は以下の通りです。 gke-l7-rilb シングルクラスタ内部ロードバランサー gke-l7-rilb-mc マルチクラスタ内部ロードバランサー gke-l7-gxlb シングルクラスタ外部ロードバランサー gke-l7-gxlb-mc マルチクラスタ外部ロードバランサー GKE Gateway Controllerの動作検証 ここまで、Gateway APIと、その実装であるGKE Gateway Controllerを紹介しました。本章では、GCP公式ドキュメントにある 「Gateway のデプロイ」 に従って、実際にGKE上でGateway APIを利用し、APIで定義されるトラフィックのルーティングがGKE Gateway Controllerによって、どのようにGCP上で実現されるのかを見ていきます。 1. GKE Gateway Controllerに対応したシングルクラスタを構築する GKEで単一のKubernetesクラスタを構築し、Gateway APIを用いて、下図に示す内部負荷分散を実現します。コンテナネイティブ負荷分散を行うため、GKEクラスタは VPCネイティブクラスタ である必要があります。 検証で実現する内部負荷分散 以下のサンプルのように、TerraformでGCP上に検証環境を構築します。なお、2021年11月時点で 公式ドキュメント記載のGKE Gateway Controller利用可能リージョン には asia-northeast1 は含まれていませんでしたが、試してみたところ asia-northeast1 に構築したGKEクラスタでも利用できました。 # VPC作成 resource " google_compute_network " " gke_vpc " { name = " gke-vpc " auto_create_subnetworks = false } # 内部LBを作成する場合に必要なproxy only subnetを作成 # ref. https :/ /cloud.google.com / load - balancing /docs/l7 - internal /proxy - only - subnets resource " google_compute_subnetwork " " proxy_only_subnet " { name = " proxy-only-subnetwork " ip_cidr_range = " 10.0.3.0/24 " # cider for gke node region = " asia-northeast1 " network = google_compute_network.gke_vpc.id purpose = " INTERNAL_HTTPS_LOAD_BALANCER " role = " ACTIVE " } # VPCネイティブクラスタを作成する際に指定するサブネットを雑に作成 # 本来は下記リンク先を参考にCIDRを要件に応じて設計するべきです # ref. https :/ /cloud.google.com /kubernetes - engine /docs/how - to /flexible - pod - cidr resource " google_compute_subnetwork " " gke_subnet " { name = " gke-subnetwork " ip_cidr_range = " 10.0.1.0/24 " # cider for gke node region = " asia-northeast1 " network = google_compute_network.gke_vpc.id secondary_ip_range { range_name = " gke-pod " ip_cidr_range = " 10.0.0.0/24 " } secondary_ip_range { range_name = " gke-service " ip_cidr_range = " 10.0.2.0/24 " } private_ip_google_access = true } # GKEのノードに割り当てるサービスアカウントを作成 resource " google_service_account " " gke_node_pool " { account_id = " gke-node-pool " display_name = " gke-node-pool " description = " A service account for GKE node " } # サービスアカウントに必要最低限のIAMロール(権限)を付与 resource " google_project_iam_member " " gke_node_pool " { for_each = toset ([ " roles/logging.logWriter ", " roles/monitoring.metricWriter ", " roles/monitoring.viewer ", " roles/datastore.owner ", " roles/storage.objectViewer ", ]) role = each.value member = " serviceAccount:${google_service_account.gke_node_pool.email} " } # GKEクラスタを定義、VCP - Native、公開クラスタ resource " google_container_cluster " " main " { name = " gke-cluster " location = " asia-northeast1-a " # デフォルトノードプールは削除して別途ノードプールを作成する remove_default_node_pool = true initial_node_count = 1 # クラスタを作成するVPC、subnetを指定 network = google_compute_network.gke_vpc.self_link subnetwork = google_compute_subnetwork.gke_subnet.self_link # vpc native clusterにするための設定 networking_mode = " VPC_NATIVE " ip_allocation_policy { cluster_secondary_range_name = " gke-pod " services_secondary_range_name = " gke-service " } } # GKEクラスタのノードを定義 resource " google_container_node_pool " " primary_nodes " { name = " node-pool " location = " asia-northeast1-a " cluster = google_container_cluster.main.name node_count = 1 node_config { machine_type = " e2-medium " metadata = { disable - legacy - endpoints = " true " } # アクセススコープでは全てのサービスへの権限を付与し,サービスアカウント側で付与する権限を絞る service_account = google_service_account.gke_node_pool.email oauth_scopes = [ " https://www.googleapis.com/auth/cloud-platform " ] } } 2. Gateway APIのCRDをインストールし、 GatewayClass が作成されることを確認する 手順 1. でGKEクラスタを作成したら、下記のように kubectl コマンドを用い、クラスタにGateway APIの CRD(Custom Resource Definition) をインストールします。CRDをインストールすると、GKE Gateway Controllerによって、GKEクラスタ内に自動的にシングルクラスタ用の GatewayClass が作成されます。 $ CLUSTER_NAME =gke-cluster $ ZONE =asia-northeast1-a $ gcloud container clusters get-credentials $CLUSTER_NAME --zone $ZONE $ kubectl kustomize " github.com/kubernetes-sigs/gateway-api/config/crd?ref=v0.3.0 " \ | kubectl apply -f - customresourcedefinition.apiextensions.k8s.io/backendpolicies.networking.x-k8s.io created customresourcedefinition.apiextensions.k8s.io/gatewayclasses.networking.x-k8s.io created customresourcedefinition.apiextensions.k8s.io/gateways.networking.x-k8s.io created customresourcedefinition.apiextensions.k8s.io/httproutes.networking.x-k8s.io created customresourcedefinition.apiextensions.k8s.io/tcproutes.networking.x-k8s.io created customresourcedefinition.apiextensions.k8s.io/tlsroutes.networking.x-k8s.io created customresourcedefinition.apiextensions.k8s.io/udproutes.networking.x-k8s.io created $ kubectl get gatewayclass NAME CONTROLLER AGE gke-l7-gxlb networking.gke.io/gateway 11s gke-l7-rilb networking.gke.io/gateway 11s $ kubectl describe gatewayclass gke-l7-ril ~~~ Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal ADD 20s sc-gateway-controller gke-l7-rilb 3. Gateway リソースを作成することで、GCLBが作成されることを確認する 次に、 Gateway リソースを作成し、GKE Gateway Controller経由で内部ロードバランサーを作成します。 Gateway では、 spec.gatewayClassName で GatewayClass を指定します。そこでは、作成したいロードバランサーの種別に応じて適切な GatewayClass を選択します。今回は、シングルクラスタ内部ロードバランサーを作成したいので、 gke-l7-rilb を指定します。そして、 spec.listeners で利用するプロトコル、ポート番号、 Gateway との紐付けを許可する Route の条件を指定します。 また、 Gateway で紐付けを許可する Route の条件を指定しますが、逆に Route 側でも紐付けを許可する Gateway の条件を指定できます。そして、双方向に条件が満たされた場合にのみ、 該当の Gateway と Route が紐づけられます 。なお、 Gateway では、許可する Route の条件として、 Route の kind 、 label 、 hostnames 、 Route を作成する Namespace の label を指定できます。つまり、この条件を満たす Route を作成する権限があれば自由にルーティングルールを追加できることを意味します。Gateway APIでは、ルーティングルールを一元管理する仕組みが定義されていないので、ルーティングルールの適切な管理は運用でカバーする必要があります。 kind : Gateway apiVersion : networking.x-k8s.io/v1alpha1 metadata : name : internal-http spec : gatewayClassName : gke-l7-rilb listeners : - protocol : HTTP port : 80 routes : # 紐づけを許可するRouteの条件を指定 kind : HTTPRoute selector : matchLabels : gateway : internal-http Gateway リソースを作成すると、GKE Gateway Controllerにより、GCLB及びGCLBに紐づけられた バックエンドサービス 、 ヘルスチェック が新規に作成されます。 $ kubectl apply -f gateway.yaml gateway.networking.x-k8s.io/internal-http created $ kubectl get gateway NAME CLASS AGE internal-http gke-l7-rilb 39s $ kubectl describe gateway internal-http ~~~ Status: Addresses: Type: IPAddress Value: 10 . 0 . 1 . 4 Conditions: Last Transition Time: 1970-01-01T00:00:00Z Message: Waiting for controller Reason: NotReconciled Status: False Type: Scheduled Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal ADD 4m1s sc-gateway-controller default/internal-http Warning SYNC 3m42s sc-gateway-controller generic::invalid_argument: error ensuring load balancer: Insert: The resource ' projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 ' is not ready Normal UPDATE 17s ( x3 over 4m1s ) sc-gateway-controller default/internal-http Normal SYNC 17s sc-gateway-controller SYNC on default/internal-http was a success # GCLBが作成されている $ gcloud compute url-maps list NAME DEFAULT_SERVICE gkegw-8r5w-default-internal-http-2jzr7e3xclhj # バックエンドサービスが作成されている $ gcloud compute backend-services list NAME BACKENDS PROTOCOL gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 HTTP # ヘルスチェックが作成されている $ gcloud compute health-checks list NAME PROTOCOL gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 HTTP Gateway リソースから作成されたロードバランサーはUI及びCLIから確認できます。しかしながら、 公式ドキュメント には「Gatewayによって作成されたGoogle CloudロードバランサのリソースはGoogle Cloud Console UIに表示されません」と記載されています。正しい値が表示される保証はないのでご注意ください。 GKE Gateway Controllerによって作成されたロードバランサーのGoogle Cloud Console上での表示 4. HTTPRoute リソースを作成して、GCLBにルーティングのルールが追加されることを確認する 次に、 Deployment と Service を作成した上で、 Service と Gateway を紐づける HTTPRoute を作成します。 まず、以下のManifestで4組の Deployment と Service を追加します。 apiVersion : apps/v1 kind : Deployment metadata : name : store-v1 spec : replicas : 2 selector : matchLabels : app : store version : v1 template : metadata : labels : app : store version : v1 spec : containers : - name : whereami image : gcr.io/google-samples/whereami:v1.1.3 ports : - containerPort : 8080 env : - name : METADATA value : "store-v1" --- apiVersion : v1 kind : Service metadata : name : store-v1 spec : selector : app : store version : v1 ports : - port : 8080 targetPort : 8080 --- apiVersion : apps/v1 kind : Deployment metadata : name : store-v2 spec : replicas : 2 selector : matchLabels : app : store version : v2 template : metadata : labels : app : store version : v2 spec : containers : - name : whereami image : gcr.io/google-samples/whereami:v1.1.3 ports : - containerPort : 8080 env : - name : METADATA value : "store-v2" --- apiVersion : v1 kind : Service metadata : name : store-v2 annotations : # BackendConfigを用いてヘルスチェック等をサービス毎にカスタマイズする cloud.google.com/backend-config : '{"default": "store-v2-backendconfig"}' spec : selector : app : store version : v2 ports : - port : 8080 targetPort : 8080 --- apiVersion : cloud.google.com/v1 kind : BackendConfig metadata : name : store-v2-backendconfig spec : healthCheck : checkIntervalSec : 15 port : 8080 type : HTTP requestPath : /v2 connectionDraining : drainingTimeoutSec : 60 --- apiVersion : apps/v1 kind : Deployment metadata : name : store-german spec : replicas : 2 selector : matchLabels : app : store version : german template : metadata : labels : app : store version : german spec : containers : - name : whereami image : gcr.io/google-samples/whereami:v1.1.3 ports : - containerPort : 8080 env : - name : METADATA value : "Gutentag!" --- apiVersion : v1 kind : Service metadata : name : store-german annotations : # BackendConfigを用いてヘルスチェック等をサービス毎にカスタマイズする cloud.google.com/backend-config : '{"default": "store-german-backendconfig"}' spec : selector : app : store version : german ports : - port : 8080 targetPort : 8080 --- apiVersion : cloud.google.com/v1 kind : BackendConfig metadata : name : store-german-backendconfig spec : healthCheck : checkIntervalSec : 15 port : 8080 type : HTTP requestPath : /healthz connectionDraining : drainingTimeoutSec : 60 --- apiVersion : apps/v1 kind : Deployment metadata : name : store-mirror-target spec : replicas : 2 selector : matchLabels : app : store version : mirror-target template : metadata : labels : app : store version : mirror-target spec : containers : - name : whereami image : gcr.io/google-samples/whereami:v1.1.3 ports : - containerPort : 8080 env : - name : METADATA value : "store-mirror-target" --- apiVersion : v1 kind : Service metadata : name : store-mirror-target spec : selector : app : store version : store-mirror-target ports : - port : 8080 targetPort : 8080 このマニフェストを store-deployment-service.yaml というファイル名で保存し、applyします。 $ kubectl apply -f store-deployment-service.yaml deployment.apps/store-v1 created service/store-v1 created deployment.apps/store-v2 created service/store-v2 created deployment.apps/store-german created service/store-german created deployment.apps/store-mirror-target created service/store-mirror-target created $ kubectl get pod --show-labels NAME READY STATUS RESTARTS AGE LABELS store-german-66dcb75977-4lnkf 1 / 1 Running 0 86m app =store,pod-template-hash = 66dcb75977,version =german store-german-66dcb75977-plqtx 1 / 1 Running 0 86m app =store,pod-template-hash = 66dcb75977,version =german store-mirror-target-c6b945fdf-4tqj9 1 / 1 Running 0 86m app =store,pod-template-hash = c6b945fdf,version =mirror-target store-mirror-target-c6b945fdf-9lnbt 1 / 1 Running 0 86m app =store,pod-template-hash = c6b945fdf,version =mirror-target store-v1-65b47557df-5m6xc 1 / 1 Running 0 86m app =store,pod-template-hash = 65b47557df,version =v1 store-v1-65b47557df-65p42 1 / 1 Running 0 86m app =store,pod-template-hash = 65b47557df,version =v1 store-v2-6856f59f7f-cczqb 1 / 1 Running 0 86m app =store,pod-template-hash = 6856f59f7f,version =v2 store-v2-6856f59f7f-dsbnc 1 / 1 Running 0 86m app =store,pod-template-hash = 6856f59f7f,version =v2 $ kubectl get service NAME TYPE CLUSTER-IP EXTERNAL-IP PORT ( S ) AGE kubernetes ClusterIP 10 . 0 . 2 . 1 < none > 443 /TCP 101m store-german ClusterIP 10 . 0 . 2 . 204 < none > 8080 /TCP 86m store-mirror-target ClusterIP 10 . 0 . 2 . 165 < none > 8080 /TCP 86m store-v1 ClusterIP 10 . 0 . 2 . 35 < none > 8080 /TCP 86m store-v2 ClusterIP 10 . 0 . 2 . 89 < none > 8080 /TCP 86m この store-deployment-service.yaml に記載の通り、GKE Gateway Controllerでは、GLBC同様に BackendConfig リソースを用いて、 Service 毎にヘルスチェックやコネクションドレイニングの設定を変更できます。しかし、この機能はGA前に別のリソースに置き換えられることが ドキュメント に明記されています。そして、10月にリリースされ、GKE Gateway ControllerではまだサポートされていないGateway API Version gateway.networking.k8s.io/v1alpha2 においては、ヘルスチェック等を定義するための仕組みとして Policy Attachment が定義されています。 BackendConfig リソースはこの仕組みを利用するリソースに置き換えられると考えられます。 また、 HTTPRoute では、 spec.gateways で処理を担当するホスト名、 spec.gateways で紐付けを許可する Gateway の条件、 spec.rules でリクエストをどのように処理するかのルールを指定できます。 spec.rules[].matches リクエストのパス、ヘッダー、クエリパラメータでルールを適用する対象のリクエストを指定 spec.rules[].forwardTo spec.rules[].matches で指定した条件に合致するリクエストをルーティングする先を指定 ルーティング先として、複数のサービス、portの組みを指定可能 複数サービス間でのルーティングの分割割合も指定可能 spec.rules[].filter spec.rules[].matches で指定した条件に合致するリクエストのヘッダー修正、ミラーリングを指定 以下、Manifestで定義される HTTPRoute store によって、再掲する下図に示すルーティングルールを設定します。 再掲:検証で実現する内部負荷分散 kind : HTTPRoute apiVersion : networking.x-k8s.io/v1alpha1 metadata : name : store labels : gateway : internal-http spec : hostnames : - "store.example.com" rules : - matches : - path : type : Prefix value : /de forwardTo : - serviceName : store-german port : 8080 filters : # /deへのリクエストをService store-mirror-targetにミラーリングする - type : RequestMirror requestMirror : serviceName : store-mirror-target port : 8080 - matches : - path : type : Prefix value : /mirror forwardTo : - serviceName : store-mirror-target port : 8080 - matches : - headers : type : Exact values : env : canary forwardTo : - serviceName : store-v2 port : 8080 # matches未指定のルールは、合致するmatchesが存在しないリクエストに対して適用される - forwardTo : - serviceName : store-v1 port : 8080 # このルールが適用されるリクエストの9割をService store-v1にルーティングする weight : 90 - serviceName : store-v2 port : 8080 # このルールが適用されるリクエストの1割をService store-v2にルーティングする weight : 10 HTTPRoute リソースを作成し、しばらく待ちます。すると、GKE Gateway Controllerによって、ルーティング先に指定した4つのサービス毎にバックエンドサービス、ヘルスチェックならびに NEG(Network Endpoint Group) が新規に作成されます。 NEGはKubernetesクラスタ内に動的に作成される Service 、 Pod と直接通信できるエンドポイントを管理し、VPC内に提供する仕組みです。NEGの詳細は、以下記事が分かりやすいのでご参照ください。 medium.com $ kubectl apply -f store-route.yaml httproute.networking.x-k8s.io/store created $ kubectl get httproute NAME HOSTNAMES AGE store [" store.example.com "] 25s $ kubectl describe httproute store Name: store Namespace: default Labels: gateway =internal-http Annotations: < none > API Version: networking.x-k8s.io/v1alpha1 Kind: HTTPRoute ~~~ Status: Gateways: Conditions: Last Transition Time: 2021-11-10T06:47:18Z Message: Reason: RouteAdmitted Status: True Type: Admitted Last Transition Time: 2021-11-10T06:47:18Z Message: Reason: ReconciliationSucceeded Status: True Type: Reconciled Gateway Ref: Name: internal-http Namespace: default Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal ADD 90s sc-gateway-controller default/store Normal SYNC 7s sc-gateway-controller Bind of HTTPRoute " default/store " to Gateway " default/internal-http " was a success Normal SYNC 7s sc-gateway-controller Reconciliation of HTTPRoute " default/store " bound to Gateway " default/internal-http " was a success そして、GCLBには HTTPRoute で指定した各サービスへのルーティングのルールが追加されます。GCLBに機能はあるものの、GKE Ingress Controllerでは実現できなかった、ルーティングの分割割合の指定等が設定できていることが確認できます。 $ gcloud compute url-maps list NAME DEFAULT_SERVICE gkegw-8r5w-default-internal-http-2jzr7e3xclhj # バックエンドサービスが4つ追加されており、それぞれのBACKENDSにNEGが指定されている $ gcloud compute backend-services list NAME BACKENDS PROTOCOL gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 HTTP gkegw-8r5w-default-store-german-8080-o9g73h4mk3ob asia-northeast1-a/networkEndpointGroups/k8s1-8db9299d-default-store-german-8080-e803f15f HTTP gkegw-8r5w-default-store-mirror-target-8080-zcxtgvjcck1r asia-northeast1-a/networkEndpointGroups/k8s1-8db9299d-default-store-mirror-target-8080-de687243 HTTP gkegw-8r5w-default-store-v1-8080-t7d6vxl1jy1d asia-northeast1-a/networkEndpointGroups/k8s1-8db9299d-default-store-v1-8080-52e6fd60 HTTP gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c asia-northeast1-a/networkEndpointGroups/k8s1-8db9299d-default-store-v2-8080-70e3804f HTTP # ヘルスチェックも新たに4つ追加されている $ gcloud compute health-checks list NAME PROTOCOL gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 HTTP gkegw-8r5w-default-store-german-8080-o9g73h4mk3ob HTTP gkegw-8r5w-default-store-mirror-target-8080-zcxtgvjcck1r HTTP gkegw-8r5w-default-store-v1-8080-t7d6vxl1jy1d HTTP gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c HTTP # store-deployment-service.yamlで追加した、Serviceに対応するNEGが新たに4つ追加されている $ gcloud compute network-endpoint-groups list NAME LOCATION ENDPOINT_TYPE SIZE k8s1-8db9299d-default-store-german-8080-e803f15f asia-northeast1-a GCE_VM_IP_PORT 2 k8s1-8db9299d-default-store-mirror-target-8080-de687243 asia-northeast1-a GCE_VM_IP_PORT 2 k8s1-8db9299d-default-store-v1-8080-52e6fd60 asia-northeast1-a GCE_VM_IP_PORT 2 k8s1-8db9299d-default-store-v2-8080-70e3804f asia-northeast1-a GCE_VM_IP_PORT 2 そして、 HTTPRoute で指定したルーティングのルールが、GCLBに追加されていることが確認できます。 $ gcloud compute url-maps describe gkegw-8r5w-default-internal-http-2jzr7e3xclhj --region asia-northeast1 creationTimestamp: ' 2021-11-12T01:46:24.204-08:00 ' defaultRouteAction: faultInjectionPolicy: abort: httpStatus: 404 percentage: 100 . 0 weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 weight: 1 fingerprint: AczSXReW744 = hostRules: - hosts: - store.example.com pathMatcher: hostffxyqcv3l2rgbj3v3jakx7trkfuw01ei id: ' 1800894912999426335 ' kind: compute#urlMap name: gkegw-8r5w-default-internal-http-2jzr7e3xclhj pathMatchers: - defaultRouteAction: faultInjectionPolicy: abort: httpStatus: 404 percentage: 100 . 0 weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 weight: 1 name: hostffxyqcv3l2rgbj3v3jakx7trkfuw01ei routeRules: - matchRules: - prefixMatch: /mirror priority: 1 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-mirror-target-8080-zcxtgvjcck1r weight: 1 - matchRules: - prefixMatch: /de priority: 2 routeAction: requestMirrorPolicy: backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-mirror-target-8080-zcxtgvjcck1r weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-german-8080-o9g73h4mk3ob weight: 1 - matchRules: - prefixMatch: /de priority: 3 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c weight: 1 - matchRules: - headerMatches: - exactMatch: canary headerName: env prefixMatch: / priority: 4 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c weight: 1 - matchRules: - prefixMatch: / priority: 5 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v1-8080-t7d6vxl1jy1d weight: 90 - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c weight: 10 region: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1 selfLink: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/urlMaps/gkegw-8r5w-default-internal-http-2jzr7e3xclhj 実際に、 HTTPRoute で指定した条件に合致するリクエストを飛ばすと、パスベース、ヘッダーベースのルーティング、トラフィック分割ルールに従ってルーティングされることが確認できます。 # Gateway(内部GCLB)に付与されたIPを確認 $ kubectl get gateway internal-http -o = jsonpath = " {.status.addresses[0].value} " 10 . 0 . 1 . 4 $ kubectl run curlpod --image curlimages/curl:7. 78 . 0 --command -- sleep 3600 $ kubectl exec curlpod -it -- /bin/sh # トラフィック分割によって、store-v1、時折store-v2にルーティングされることを確認 / $ curl -H " host: store.example.com " 10 . 0 . 1 . 4 " pod_name " : " store-v1-65b47557df-5m6xc " / $ curl -H " host: store.example.com " 10 . 0 . 1 . 4 " pod_name " : " store-v1-65b47557df-65p42 " ~~~ / $ curl -H " host: store.example.com " 10 . 0 . 1 . 4 " pod_name " : " store-v2-6856f59f7f-cczqb " # ヘッダーベースのルーティングでstore-v2にルーティングされることを確認 / $ curl -H " host: store.example.com " -H " env: canary " 10 . 0 . 1 . 4 " pod_name " : " store-v2-6856f59f7f-cczqb " # パスベースのルーティングでstore-germanにルーティングされることを確認 / $ curl -H " host: store.example.com " 10 . 0 . 1 . 4 /de " pod_name " : " store-german-66dcb75977-plqtx " # パス/deへのリクエストがService store-mirror-targetにミラーリングされていることを、Podで出力しているアクセスログから確認 $ kubectl logs store-mirror-target-c6b945fdf-9lnbt ~~~ 2021-11-12 11:00:25, 291 - werkzeug - INFO - 10 . 0 . 3 . 37 - - [ 12 /Nov/ 2021 11:00:25 ] " GET /de HTTP/1.1 " 200 - ~~~ 5. HTTPRoute で定義したルーティングルールの優先順位とGCLB上でのルールの優先順位の定義を確認する また、GKE Gateway Controllerでは spec.rules[].matches を ドキュメント 記載の以下の基準に従って優先順位付けします。 ホスト 最も長い、または最も具体的なホスト名と一致するものを優先 パス 最も長い、または最も具体的なパスと一致するものを優先 ヘッダー 一致するHTTPヘッダーの数が多いものを優先 リクエストに合致するルーティングルールが複数ある場合、より優先度の高いものが適用されます。また、 spec.rules[].matches が全く同じルーティングルールが存在する場合は、作成されたタイムスタンプがより古いルーティングルールが適用されます。 以下が検証のサンプルです。既存の HTTPRoute に定められたパスベースによるルーティングに競合するルールを追加し、ルーティングルールの優先順位を検証します。 kind : HTTPRoute apiVersion : networking.x-k8s.io/v1alpha1 metadata : name : store-conflict labels : gateway : internal-http spec : hostnames : - "store.example.com" rules : # /deでのパスベースのルーティングはHTTPRoute storeで既に定義されているため、競合する - matches : - path : type : Prefix value : /de forwardTo : - serviceName : store-v2 port : 8080 # 競合するルーティングルールを持つHTTPRouteでも正常にapplyできる $ kubectl apply -f store-route-conflict.yaml httproute.networking.x-k8s.io/store-conflict created $ kubectl get httproute NAME HOSTNAMES AGE store [" store.example.com "] 66m store-conflict [" store.example.com "] 13s ヘッダー、パスを両方指定した場合、パスベースの条件の方がヘッダーベースの条件よりも優先されることが確認できます。また、競合するルーティングルールが存在するパス /de を指定した場合は、競合するルール群の中で最初に作成されたものが適用されることを確認できます。 # ヘッダー、パスを両方指定した場合、パスベースの条件の方がヘッダーベースの条件よりも優先され、 # store-germanにルーティングされることを確認 $ curl -H " host: store.example.com " -H " env: canary " 10 . 0 . 1 . 4 /de " pod_name " : " store-german-66dcb75977-plqtx " # 競合するルーティングルールがあるパスを指定した場合、競合するルールの内、先に作成したstore-germanへのルーティングルールが適用され、 # 後から作成したstore-v2へのルーティングルールは適用されないことを確認 $ curl -H " host: store.example.com " 10 . 0 . 1 . 4 /de " pod_name " : " store-german-66dcb75977-4lnkf " 次に、GCLBに設定されたルーティングルールの優先順位を確認します。ルールの優先順位はGCLBに定義された routeRule の priority に設定されていることが分かります。 $ gcloud compute url-maps describe gkegw-8r5w-default-internal-http-2jzr7e3xclhj --region asia-northeast1 creationTimestamp: ' 2021-11-12T01:46:24.204-08:00 ' defaultRouteAction: faultInjectionPolicy: abort: httpStatus: 404 percentage: 100 . 0 weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 weight: 1 fingerprint: AczSXReW744 = hostRules: - hosts: - store.example.com pathMatcher: hostffxyqcv3l2rgbj3v3jakx7trkfuw01ei id: ' 1800894912999426335 ' kind: compute#urlMap name: gkegw-8r5w-default-internal-http-2jzr7e3xclhj pathMatchers: - defaultRouteAction: faultInjectionPolicy: abort: httpStatus: 404 percentage: 100 . 0 weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-gw-serve404-80-mcfti8ucx6x5 weight: 1 name: hostffxyqcv3l2rgbj3v3jakx7trkfuw01ei routeRules: - matchRules: - prefixMatch: /mirror priority: 1 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-mirror-target-8080-zcxtgvjcck1r weight: 1 - matchRules: - prefixMatch: /de priority: 2 routeAction: requestMirrorPolicy: backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-mirror-target-8080-zcxtgvjcck1r weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-german-8080-o9g73h4mk3ob weight: 1 - matchRules: - prefixMatch: /de priority: 3 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c weight: 1 - matchRules: - headerMatches: - exactMatch: canary headerName: env prefixMatch: / priority: 4 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c weight: 1 - matchRules: - prefixMatch: / priority: 5 routeAction: weightedBackendServices: - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v1-8080-t7d6vxl1jy1d weight: 90 - backendService: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/backendServices/gkegw-8r5w-default-store-v2-8080-sau4ah4scq2c weight: 10 region: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1 selfLink: https://www.googleapis.com/compute/v1/projects/techblog/regions/asia-northeast1/urlMaps/gkegw-8r5w-default-internal-http-2jzr7e3xclhj 競合するルーティングルールがあった場合でも、 Gateway , HTTPRoute リソースのStatusがエラー等になることはありません。 Gateway と Route は多対多の紐付けが可能なため、複数箇所で HTTPRoute を定義した結果、気づかないうちに競合するルーティングルールを定義しないように注意が必要です。しかし、Gateway APIではルーティングルールを一元管理する仕組みは特に定義されていません。そのため、ルーティングルールの競合への対応方針としてGateway APIの ドキュメント に以下の記載があります。 Where possible, this should be communicated by setting appropriate status conditions on relevant resources. GKE Gateway ControllerがGAになる際には、Gateway APIで定義されるリソースのStatusに警告が表示されるようになるかもしれません。 おわりに 本記事では、Kubernetes Gateway APIの概要と、APIで定義されるトラフィックのルーティングがGKE Gateway ControllerによってどのようにGCP上で実現されるのかの仕組みを紹介しました。Kubernetes Gateway APIとRBACを組み合わせることで、よりセキュアなマルチテナント構成を実現できます。そして、GKE Ingress ControllerではなかなかサポートされなかったGCLBの各種機能がGKE Gateway Controllerでサポートされるようなので、GAになるのが非常に楽しみです。 ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com hrmos.co Kubernetes Blog:Evolving Kubernetes networking with the Gateway API ↩
アバター
こんにちは、AndroidエンジニアのAndyです。これまでにZOZOSUIT、ZOZOMAT、ZOZOGLASSのアプリ機能開発に取り組んできました。 ZOZOGLASS は肌の色を計測するデバイスで、オンラインでファンデーションを購入する際の手助けをします。ZOZOGLASSのユーザーは下図のような専用の眼鏡をかけ、アプリを使用して顔の肌の色を計測します。 この技術の開発中に、私たちはクロスプラットフォームであるが故の技術的ハードルに直面しました。本記事では、そこで使用されているテクノロジーの一部と、それらの課題をどのように解決していったのかを紹介します。 クロスプラットフォームにおける技術的課題 前述の通り、開発を進めていく中でさまざまな技術的課題に直面しました。その原因はiOSとAndroidを同時にカバーするため、クロスプラットフォームである必要があったからです。それに起因し、肌の色の計測パフォーマンスの課題やフェイストラッキングの課題が発生しました。 現在では、フェイストラッキングにはARCore Augmented Facesを使用し、肌の色の計測と色補正アルゴリズムにはC++ライブラリを使用し、この課題に対応しています。本記事では、その中でも活用している、KotlinでC++ライブラリを使用する方法を簡単なサンプルを用いて説明します。 C++による計測ライブラリの作成 ユーザーの肌の色を計測するためのライブラリを、C++で開発しました。C++は、高性能アプリケーションの作成に使用できるクロスプラットフォームな言語です。これにより、開発者はシステムリソースとメモリを高度に制御することが可能になります。C#やJavaとも類似する点ですが、オブジェクト指向プログラミング言語としてプログラムに明確な構造を与えてコードを再利用できます。 計測専用の眼鏡はさまざまな配色のユニークな模様でデザインされています。そして、C++ライブラリはスマートフォンのカメラでユーザーの顔をスキャンする際に、このユニークな模様を活用して色の補正をします。検出した顔の各領域を識別し、それらの領域に対して肌の色のカラーマップを生成します。 ネイティブアプリへのC++ライブラリの組み込み パフォーマンスとUXの向上のために、スマートフォン向けのアプリはネイティブアプリとして開発することにしました。iOSアプリはSwift、AndroidアプリはKotlinを使用して開発されています。 しかし、iOSアプリでは、SwiftがC++と直接通信できないため、手動で中間レイヤーを追加する必要があります。例えば、すべてのC++の機能をObjective-Cモジュールにラップさせる方法があります。その場合、SwiftのアプリケーションからObjective-Cフレームワークを使用するだけなので、実装は容易です。 一方、Androidアプリの場合は、iOSアプリのように容易には実装できません。Android Native Development Kit(Android NDK。以下、NDK)を使用する必要があります。このNDKは、開発者がアプリの一部をネイティブコード(C++)で記述できるようにするツールセットです。 次章では、このNDKを利用したAndroidアプリの実装方法を説明します。 NDKを用いたAndroidアプリの実装方法 NDKには、以下のデフォルトツールが含まれています。 デバッガー CMake Java Native Interface(JNI) JNIは、Kotlin/JavaとネイティブC++間のインタラクションの処理を司るインタフェースです。これは、Androidによって生成されたバイトコードがネイティブコードと通信する方法を定義してくれます。その結果、Kotlinのコードは、JNIを使用することでC++コードと通信が可能となります。 それらのAndroidによって生成されたバイトコードとネイティブコードは、双方で関数と変数を保持しています。JNIを使用すると、KotlinからC++で記述された関数を呼び出したり、その逆も可能になります。また、言語間で変数に格納されている値を読み取って変更することも可能です。 前述のように、C++で記述されたネイティブコードを処理する場合、ネイティブ関数を呼び出し、引数を渡し、結果を取得する必要があります。これを処理するために「プリミティブ型」が使用されます。そして、引数をネイティブコードの関数に渡したり、プリミティブ型の形式で結果を取得したりするためにJNIで定義された特別なネイティブ型が存在します。具体的には Javaのドキュメント で記載されているように、Kotlin/Javaの各プリミティブに対応するネイティブ型が用意されています。 docs.oracle.com Android StudioでサンプルコードのNDKを動かしてみる 本章では、Android Studioを使い、簡単なサンプルを動かしながらNDKの利用方法を説明します。 環境準備とプロジェクト作成 Android StudioでNDKをサポートするには、以下のSDKを追加する必要があります。 LLDB Android Studioでプロジェクトに存在するネイティブコードをデバッグするために使用 NDK Androidのネイティブ言語であるC++でコーディングするために使用 CMake OSでコンパイラに依存しない方法でビルドプロセスを管理するために使用 Android Studioで「Native C++」のテンプレートでプロジェクを作成すると、下図に示す構造のプロジェクトが生成されます。すべてのネイティブファイルと CMakeLists.txt ファイルを含む cpp ディレクトリが作成されます。なお、C++のコードは native-lib.cpp ファイルに含まれています。 サンプルコードによる動作確認 最も簡単な、文字列を扱うサンプルコードを見ていきます。 C++のコード、つまり native-lib.cpp を以下のように変更します。 extern "C" JNIEXPORT jstring JNICALL Java_jp_zozo_sample_library_jni_Native_stringFromJNI( JNIEnv *env, jobject /* this */ ) { std::string hello = "Hello, World!" ; return env->NewStringUTF(hello.c_str()); } 次に、Kotlinのコードにて、アプリケーションの起動時に System.loadLibrary("native-lib") メソッドを呼び出し、先程のC++のネイティブコードをロードするようにします。 System.loadLibrary( "native-lib" ) 続いて、ライブラリによって実装されたネイティブメソッドを、以下のようにKotlinの関数として宣言します。 external fun stringFromJNI(): String すると、以下のようにKotlinから関数を使用できるようになります。 sample_text.text = stringFromJNI() 以上のように、ネイティブコードの関数をKotlinから簡単に使用できることが分かりました。 先程のサンプルは文字列を扱っていましたが、同様に数値を扱うこともできます。 C++は以下のように変更します。 extern "C" JNIEXPORT jint JNICALL Java_jp_zozo_sample_library_jni_Native_add( JNIEnv *env, jobject, jint x, jint y) { return x + y; } そして、Kotlinのコードは以下のように変更します。 external fun add(x: Int , y: Int ): Int val result = add(x = 1 , y = 1 ).toString() sample_text.text = result 数値でも文字列同様、簡単に実装ができました。 ZOZOGLASSではカメラから取得された画像データをリアルタイムに処理して計測処理を行っています。そのため、最後にKotlin側で取得された画像データをC++で処理するサンプルコードを見ていきましょう。 C++を以下のように変更します。 extern "C" JNIEXPORT void JNICALL Java_jp_zozo_sample_library_jni_Native_imageProcessing( JNIEnv *env, jobject, jbyteArray byteArray) { jbyte* buffer = env->GetByteArrayElements(byteArray, nullptr); // 重い画像処理 env->ReleaseByteArrayElements(byteArray, buffer, 0 ); } そして、Kotlinのコードは以下のように変更します。 external fun imageProcessing(image: ByteArray ): Unit ZOZOGLASSではこのように画像を処理しています。以上でサンプルコードを用いた解説は終了です。 ARCoreライブラリによるフェイストラッキングの実装 フェイストラッキングをするために、 ARCore ライブラリを使用しました。ARCore SDKは、モーショントラッキング、環境理解、光推定などのAR機能用のAPIを提供しています。この機能を活用すると、新しいARエクスペリエンスを構築したり、既存のアプリをAR機能で強化できます。 developers.google.com Augmented Faces はARCoreの一部です。Augmented Facesイメージトラッキング機能を使用すると、ユーザーの顔がカメラによって検出できます。そして、ARCoreは検出された顔の各領域を自動的に識別するために、拡張された顔のメッシュを生成します。その際のメッシュは顔の仮想表現であり、頂点、ユーザーの頭や顔の領域で構成されます。 developers.google.com そのようにARCoreで拡張された顔により、アプリはカメラから検出された顔の各領域を自動的に識別できます。 なお、拡張された顔の位置は以下のように生成されます。 センターポーズの特定 鼻のうしろにあり、ユーザーの頭の物理的な中心点をセンターポーズとして特定 顔の各領域を識別 顔のメッシュとセンターポーズを使用し、ユーザーの顔の左額、右額、鼻の先の領域を識別 このように取得された要素は、Augmented Faces APIによって、肌の色のテクスチャを顔にオーバーレイするための配置ポイントおよび領域として使用されます。そして、アタッチされている顔の領域に基づいてレンダリングします。 このようにユーザーの肌の色のデータを収集することは、ユーザーの統計データの傾向を見つけるのに役立ちます。アルゴリズムを使用し、ユーザーのデータに基づいて下図のように適したファンデーションの色の推薦が可能になります。 まとめ 本記事で紹介した技術を使用することで、ユーザーの肌の色をスムーズに測定し、データを効果的に表示および使用できるユーザーフレンドリーなクロスプラットフォームなアプリを作成できました。アプリの開発前から、想定される機能を計画し、解決すべき課題に基づいた適切な技術選定をすることが重要です。 また、プロジェクトのスコープを考慮することも重要です。時間とリソースの制約があるプロジェクトでは、より迅速なサービスの提供が求められます。要件を満たす技術が既に存在する場合、必ずしも高度な技術を自ら作り出すのではなく、それらの既存の技術を活用することが重要です。 ZOZOでは、Androidエンジニアを募集しています。興味のある方はこちらからご応募ください! corp.zozo.com
アバター
こんにちは、 『地球の歩き方ムー』創刊のニュース に心を踊らせている、データ基盤ブロックの塩崎です。 本記事では、データ基盤の管理者としてBigQueryのストレージコストの削減に取り組んだ事例を紹介します。 BigQuery費用はクエリ費用だけではない ZOZOのデータ基盤として利用されているBigQueryは、非常にパワフルなDWH(Data WareHouse)です。しかし、それ故に利用者の意図しないところで費用が高騰することもしばしば発生します。よく問題になるのはクエリ費用の高騰であり、以下のQiita記事はBigQuery利用者の中でも有名です。 qiita.com このクエリ費用の高騰に対し、我々データ基盤ブロックはこれまでに、いくつもの方法で対処してきました。具体的な取り組みの一部は以下の記事で紹介しているので、併せてご覧ください。 techblog.zozo.com techblog.zozo.com しかし、BigQueryの費用はクエリに関するもののみではありません。以下のドキュメントによると、BigQueryの費用はクエリに関する費用(Analytis)とストレージに関する費用(Storage)の2つがメインであることが分かります。 BigQuery pricing has two main components: Analysis pricing is the cost to process queries, including SQL queries, user-defined functions, scripts, and certain data manipulation language (DML) and data definition language (DDL) statements that scan tables. Storage pricing is the cost to store data that you load into BigQuery. cloud.google.com このストレージに関する費用は、USマルチリージョンの場合、1か月1GBあたり0.020 USDであり、90日間変更のないテーブルはその半額の0.010 USDに自動的に割引されます。 この単価は、 Google Cloud Storage や Amazon S3 などと比較しても安価であり、BigQueryの導入初期はあまり気にならないことも多いです。しかし、BigQueryをデータ基盤として長年利用すると、徐々にストレージ利用量が増加することもしばしば発生します。 現在のZOZOのデータ基盤は約100のGCPプロジェクト、約1000のデータセット、数十万以上のテーブルにまたがる大規模なものへと成長しました。これらの全てのデータを1つのチームが把握することは非現実的であるため、各GCPプロジェクト毎に管理者を立て分割統治を行っています。そのため、全てのプロジェクトの中にある、全てのテーブルのデータサイズを一覧で表示して可視化を行うダッシュボードを作成しました。そして、そのダッシュボードに基づき、不必要にストレージコストが高騰している疑いのあるテーブルを洗い出しました。それらのテーブルの情報を個別に管理者に連絡することでコストの削減に成功しました。 以降で、その具体的な流れを説明していきます。 ストレージコストの可視化 本章では、ストレージ利用量の調査から、Data Studioで可視化するまでの流れを説明します。 ストレージ利用量の調査方法 はじめに、BigQueryのストレージ利用量をダンプして1つのテーブルに集約します。BigQueryのストレージ利用量は INFORMATION_SCHEMA.PARTITIONS に格納されているので、それを参照します。 cloud.google.com このビューの STORAGE_TIER 列を参照すると ACTIVE か LONG_TERM かが分かり、1GBあたりの単価が分かります。しかし、今回は分かりやすさのために、この部分はあえて無視していることをご了承ください。全てのプロジェクトのPARTITIONSビューを一括で取得する方法があれば楽なのですが、現時点ではそのような仕組みがないため、分割して取得します。大量のテーブルの情報を分割して取得するにあたり、特に以下の2点に注意する必要があります。 PARTITIONSビューから多くのテーブルの情報を取得するとエラーになる 1つのテーブルに対するINSERTは1日あたり1000回の上限がある PARTITIONSビューから多くのテーブルの情報を取得するとエラーになる注意点 1点目は、PARTITIONSビューのドキュメントにも記載のない罠であり、特に注意が必要です。多くのテーブルを保持しているデータセットに対して無邪気に以下のようなクエリを実行するとエラーになります。 SELECT * FROM `project_id`.`dataset_name`.INFORMATION_SCHEMA.PARTITIONS 発生するエラー: INFORMATION_SCHEMA.PARTITIONS query attempted to read too many tables. Please add more restrictive filters. このエラーが発生する閾値はドキュメントに記載がないため、正確な値は不明です。テーブルの数を変えながら実験した結果、テーブルの数が1000程度であればエラーが発生しないため、以下のようなWHERE句を使い参照するテーブルの数を限定するようにしました。 WHERE table_id IN (table_name1, table_name2, ..., table_name1000) 1つのテーブルに対するINSERTは1日あたり1000回の上限がある注意点 次に2つ目の注意点です。前述の通り、PARTITIONSビューからの情報取得は1000テーブル毎に分割されます。そのため、ストレージ容量をまとめるテーブルに対するINSERTの回数もそれに応じて増加します。 INSERT INTO bq_storage_stats SELECT * FROM `project_id`.`dataset_name`.INFORMATION_SCHEMA.PARTITIONS WHERE table_id IN (table_name1, table_name2, ..., table_name1000); INSERT INTO bq_storage_stats SELECT * FROM `project_id`.`dataset_name`.INFORMATION_SCHEMA.PARTITIONS WHERE table_id IN (table_name1001, table_name1002, ..., table_name2000); ... 一方で、BigQueryは1つのテーブルに対するDML操作の上限が1日あたり1500回に設定されています。 cloud.google.com そのため、多くのプロジェクト・データセットに関する情報を取得する際には、この上限に気をつける必要があります。我々の環境では上限に達してしまったため、Streaming Insertを行うことでエラーを回避しました。Streaming Insertの上限は先程のDMLの上限とは別であり、閾値がかなり大きいため回避策として利用できます。 cloud.google.com 複数のGCPプロジェクトのBigQueryのストレージ利用量を収集するスクリプトを以下に示します。 from google.cloud import bigquery from itertools import zip_longest, groupby import time import string import random import concurrent.futures # 集計対象のGCPプロジェクトIDの配列 project_ids = [ 'project_id1' , 'project_id2' , ... ] # ストレージ利用量を集約するテーブル destination_table = 'project_id.dataset_id.table_name' # Ref: https://docs.python.org/3/library/itertools.html#itertools-recipes def grouper (iterable, n, fillvalue= None ): args = [ iter (iterable)] * n return zip_longest(*args, fillvalue=fillvalue) def create_destination_table (client, destination_table): schema = [ bigquery.SchemaField( "project_id" , "STRING" , mode= "NULLABLE" ), bigquery.SchemaField( "dataset_name" , "STRING" , mode= "NULLABLE" ), bigquery.SchemaField( "table_name" , "STRING" , mode= "NULLABLE" ), bigquery.SchemaField( "table_rows" , "INTEGER" , mode= "NULLABLE" ), bigquery.SchemaField( "total_logical_bytes" , "INTEGER" , mode= "NULLABLE" ), bigquery.SchemaField( "total_billable_bytes" , "INTEGER" , mode= "NULLABLE" ), ] table = bigquery.Table(destination_table, schema=schema) client.create_table(table) def get_dataset_names (client, project_id): query = f "SELECT SCHEMA_NAME AS dataset_name FROM `{project_id}`.`region-us`.INFORMATION_SCHEMA.SCHEMATA ORDER BY SCHEMA_NAME ASC" rows = client.query(query) return [row[ 'dataset_name' ] for row in rows] def get_table_names (client, project_id): query = f "SELECT table_schema AS dataset_name, table_name from `{project_id}`.`region-us`.INFORMATION_SCHEMA.TABLES ORDER BY table_schema ASC, table_name ASC" rows = client.query(query) return rows def generate_table_size_job (client, project_id, dataset_name, table_names, destination_table): table_names_str = "," .join([ '"' + t + '"' for t in table_names]) query = f """ SELECT table_catalog AS project_id, table_schema AS dataset_name, table_name, sum(total_rows) AS total_rows, sum(total_logical_bytes) AS total_logical_bytes, sum(total_billable_bytes) AS total_billable_bytes FROM `{project_id}`.`{dataset_name}`.INFORMATION_SCHEMA.PARTITIONS WHERE table_name IN ({table_names_str}) GROUP BY table_catalog, table_schema, table_name """ return client.query(query, bigquery.job.QueryJobConfig(priority= "BATCH" )) def retvieve_rows (query_job): exception = query_job.exception() if exception is not None : print (exception) print ( "Error occurred during the execution of the following query" ) print (query_job.query) raise exception results = [] for row in query_job.result(): results.append(row) return results client = bigquery.Client() temp_destination_table = destination_table + "_" + '' .join(random.choices(string.ascii_letters + string.digits, k= 16 )) print (f "Temp Table: {temp_destination_table}" ) create_destination_table(client, temp_destination_table) print (f "created {temp_destination_table}" ) # 高速化のためにBigQueryへのJobを並列して投げる with concurrent.futures.ThreadPoolExecutor(max_workers= 20 ) as executor: for project_id in project_ids: dataset_names = get_dataset_names(client, project_id) dataset_count = len (dataset_names) print (f "{dataset_count} dataset(s) found in {project_id}." ) dataset_table_names = get_table_names(client, project_id) query_job_futures = [] for dataset_name, rows in groupby(dataset_table_names, lambda r: r[ 'dataset_name' ]): table_names = [row[ 'table_name' ] for row in rows] table_count = len (table_names) print (f "{table_count} table(s) found in {project_id}.{dataset_name}." ) # 1000テーブル毎に分割してPARTITIONSビューにクエリを投げる for table_names_chunk in grouper(table_names, 1000 ): table_names_chunk = [t for t in table_names_chunk if t is not None ] query_job = executor.submit(generate_table_size_job, client, project_id, dataset_name, table_names_chunk, temp_destination_table) query_job_futures.append(query_job) print (f "waiting for all query jobs have been created" ) concurrent.futures.wait(query_job_futures) query_jobs = [f.result() for f in query_job_futures] query_job_count = len (query_jobs) print (f "{query_job_count} query jobs has been created" ) while not all ([q.done() for q in query_jobs]): print ( "waiting for all jobs completed" ) time.sleep( 1 ) print (f "{query_job_count} query jobs has been completed" ) rows_futures = [] for query_job in query_jobs: rows_future = executor.submit(retvieve_rows, query_job) rows_futures.append(rows_future) concurrent.futures.wait(rows_futures) results = [] for rows_future in rows_futures: results += rows_future.result() result_count = len (results) print (f "{result_count} rows retvieved." ) if results: for results_chunk in grouper(results, 1000 ): results_chunk = [r for r in results_chunk if r is not None ] insert_errors = client.insert_rows(client.get_table(temp_destination_table), results_chunk) results_chunk_count = len (results_chunk) print (f "{results_chunk_count} rows inserted." ) if insert_errors: print ( "Error occured during inserting the following rows" ) print (insert_errors) print (f "saved storage stats of {project_id}" ) copy_job = client.copy_table(temp_destination_table, destination_table) copy_job.result() print ( "Copy Temp table to Destination table" ) delete_job = client.delete_table(temp_destination_table, not_found_ok= True ) delete_job.result() print ( "Delete Temp table" ) Data Studioで可視化 上記の手順で、複数のGCPプロジェクト内のストレージ利用量を1つのテーブルに集約しました。次に、この情報を可視化し、大量のストレージを利用しているGCPプロジェクト・データセット・テーブルを見つけていきます。可視化には、Google Data Studioを利用します。 datastudio.google.com 完成したダッシュボードを以下に示します。画面上部のフィルターでプロジェクト・データセットを絞り込み、その中でストレージ利用量の多いTOP 5のデータセット・テーブルを確認できるようにしました。 これは余談ですが、せっかくなので下図のようなバブルチャートを利用し、「見た目がカッコ良いダッシュボード」を作ろうともしました。しかし、上図の表形式のダッシュボードの方が役に立ちました。「見た目がカッコ良いダッシュボード」が必ずしも実用的だとは言えないことを実感しました。 テーブル利用状況の調査 ストレージ利用量の大きいテーブルが発見できたので、次に利用状況を調査します。ストレージを大量に利用していたとしても、それが利用されているテーブルであれば無闇に消すことはできません。そのために、テーブルが最近どの程度参照されたのかを確認します。 INFORMATION_SCHEMA.JOBS_BY_ORGANIZATION ビューを用いると、特定のテーブルに対して実行されたクエリを確認できます。なお、 JOBS_BY_ORGANIZATION ビューには実行されたSQL文が格納されていないので、必要に応じて JOBS_BY_PROJECT ビューも併用してクエリの利用状況を確認します。そして、BigQueryのストレージを多く消費しており、かつ最近の利用実績が乏しいテーブルを「無駄遣い疑惑」のテーブルとしてリストアップしていきます。 SELECT job_id, creation_time, project_id, user_email, job_type, statement_type, destination_table, referenced_tables FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_ORGANIZATION WHERE creation_time > TIMESTAMP ( ' 2021-09-01 ' ) AND ( SELECT LOGICAL_OR(rt.dataset_id = " データセット名 " AND rt.table_id = " テーブル名 " ) FROM UNNEST(referenced_tables) AS rt ) ORDER BY creation_time DESC 担当者への対応依頼 最後に「無駄遣い疑惑」テーブルの削除を前提とした対応を担当者に依頼しました。その際には、テーブル名・ストレージ利用量だけではなく、以下の情報も併せて伝えました。それにより、迅速に対処をしてもらうように心がけました。 1年あたりのストレージコスト見積もり 最近数カ月間でそのテーブルに実行されたクエリ 実は必要だということが後から判明しても、 削除から7日以内であれば復元可能 であること 関係部署の協力も得られ、結果として合計で約1000TBの無駄遣いテーブルを削除できました。再発防止策として、アドホック分析の中間結果を配置する場合には、データセット毎にテーブルのデフォルト有効期間を設定するように働きかけました。 cloud.google.com まとめ BigQueryはコンピュートだけではなく、ストレージも非常にパワフルなDWHです。そのため、利用者の意図しないところでストレージ費用が高騰する恐れもあります。ZOZOのデータ基盤は多くの部署が利用しているため、それぞれの利用者の努力に依存するだけでは発生を抑制することは困難です。そのため、 INFORMATION_SCHEMA というBigQueryに備わっている仕組みを活用することで、横断的かつ効率的に費用を無駄遣いしているテーブルの発見・削除をしました。 ZOZOではデータ基盤のガバナンスを強化し、利用者にとって安全安心なデータ基盤を整備していく仲間を募集中です。ご興味のある方は以下のリンクからご応募ください。 hrmos.co
アバター
はじめに こんにちは、MA部MA基盤ブロックの齋藤( @kyoppii13 )です。 ZOZOTOWNではキャンペーンやセール情報などをメールマガジン(以下、メルマガ)で配信しています。そして、そのメルマガの最下部にバナーを掲載しています。従来のメルマガバナー運用方法は、スプレッドシートでバナー掲載スケジュールを管理し、DBに対して直接クエリを実行するという手作業による運用でした。この運用方法だと、人的なミスが発生しやすく、掲載されるバナーがイメージしづらいという問題がありました。そこで、バナー管理のためのCMSを開発し、既存配信システムへのデータ連携によって従来のバナー運用方法における問題点を解決しました。 メルマガバナー運用の移行は、急いでいたこと、開発メンバーが限られていることから開発工数を極力抑える必要がありました。そこで、本記事ではメルマガバナー運用の配信を限られた開発工数で、かつ安全に新システムへ移行するために取り組んだ事例を紹介します。 はじめに メール配信システムの概要 メルマガバナーとは メール配信システム 1. 配信対象者とメールで使用するデータを抽出 2. 抽出したデータをメールテンプレートへ埋め込み 3. ユーザへメルマガ配信 従来の運用フローと問題点 従来の運用フロー 問題点 バナー管理システムの導入 バナー管理アプリケーションの仕組み 1. バナー情報の登録 2. バナーの配信設定 データ連携の仕組み 1. データ連携処理の分離 2. 冪等性の担保 安全に移行作業を進めるための手順 1. 配信が停止されていることの確認 2. 既存のバナーデータをアプリケーションテーブルにインポート 3. 連携対象となる既存の配信システムのテーブルをリネームによってバックアップ 4. 連携対象テーブルと同じスキーマの空テーブルを本番用のテーブル名にリネーム 5. データ連携処理の開始 MBM導入後の運用フロー MBMの導入効果 将来の展望 バナー配信システム部分のマイクロサービスとしての切り分け 管理ツールの統一化 まとめ さいごに メール配信システムの概要 本章では、運用を移行するメール配信システムの概要を説明します。 メルマガバナーとは ZOZOTOWNではキャンペーンやセール情報などをメルマガで配信しています。そして、そのメルマガの最下部には各種キャンペーンやセール情報のバナーを掲載しています。下図はバナーの一例です。 なお、バナーデータはDBに保存されており、バナー画像のURL・遷移先URL・表示優先度・掲載開始の日時・掲載終了の日時が含まれます。バナーは1つのメールに複数掲載されるため、表示優先度が設定されています。また、バナーはキャンペーンによって掲載期間がそれぞれ異なります。そのため、バナーごとに掲載期間が設定されています。 メール配信システム メルマガは下図に示す構成のシステムにより配信しています。 配信の流れは以下の通りです。 配信対象者とメールで使用するデータを抽出 抽出したデータをメールテンプレートへ埋め込み ユーザへメール配信 メール配信ごとに上記の処理を実行します。 それぞれの処理を順に説明します。 1. 配信対象者とメールで使用するデータを抽出 はじめに、DBから配信対象ユーザとバナーデータを含むメールで使用するデータ抽出します。配信システムではメルマガ配信時にバナー掲載期間を確認し、配信時点で対象となるバナーデータを抽出します。メルマガは内容によって対象者が異なるため、同時に配信対象者の抽出も行います。 配信メールの種類は大きく分けてパーソナライズとマスの2種類です。そして、種類によって配信システムが別れています。パーソナライズは、お気に入りなどの情報に基づいて特定のユーザに対して配信するメールです。一方、マス配信は一定の条件に基づく複数のユーザに対して一斉に配信するメールです。 ここで抽出したデータはメール配信サービスへと送られます。 2. 抽出したデータをメールテンプレートへ埋め込み メール配信にはSaaSのメール配信サービスを利用しています。このサービスでは、1. で抽出した配信対象者とメールデータをメールテンプレートに埋め込んで配信されます。メールテンプレートはテンプレートエンジンのようにHTMLとデータの埋め込み位置を定義することで作成します。なお、作成したメールテンプレートはメール配信サービスへ事前にアップロードしておきます。 3. ユーザへメルマガ配信 で組み立てたメルマガを配信対象のユーザに送信します。 従来の運用フローと問題点 本章では、従来の運用フローと、そこで生じた問題点を説明します。 従来の運用フロー メルマガバナーの内容は月ごとに決定します。そのため、従来のメルマガバナーの運用は下記のフローで行われています。 施策担当者が施策立案 掲載スケジュール決定後、スプレッドシートに1か月分のスケジュールを記載 デザイナーがバナー画像を作成しアップロード バナー担当部署がバナーを配信システムのDBに直接登録 なお、上記の「バナー担当部署」とは、バナー掲載の調整を担当する部署を指します。 問題点 既存の運用方法では下記の問題点が存在していました。 バナー担当部署で工数が発生する 直接DBを操作するため、人的なミスが発生し得る スプレッドシート管理のため、実際に掲載されるバナーがイメージしづらい 既存の運用方法は、手動でのDB操作が必要でした。しかし、DB操作をできる人は限られているため、運用負荷が一部のメンバーに集中していました。また、手動運用のため人的なミスの発生がありました。他にも、バナー画像のURL・掲載順・遷移先URLをスプレッドシート管理していたため、実際に配信されるフォーマットと同じ見た目での事前確認ができませんでした。 また、この運用だと掲載期間などの調整もすべてバナー担当部署を介す必要があり、調整に時間がかかります。その結果、調整が間に合わずに適切なバナーを配信できないことになれば、ユーザへの価値提供の機会を失うことになります。 バナー管理システムの導入 前述の課題を解決するために、バナー管理システム(Mail Banner Manager。以下、MBM)というアプリケーションを作成しました。システムのアーキテクチャを下図に示します。 MBMはバナー管理アプリケーションとデータ連携アプリケーションの2つで構成されています。バナー管理アプリケーションはCMSツールで、メルマガバナー登録・管理が可能です。そして、データ連携アプリケーションは登録したバナーを2つの配信システムのDB(SQL ServerとIIAS)へ連携します。 このアプリケーションの開発は、限られた工数で行う必要がありました。なぜならば、開発リソースに既存のバナー担当部署のメンバーをアサインすることが難しいからです。もし、既存の運用を新しいメンバーが担当すると、DBを直接操作することにより人的ミスの起きる可能性が高まります。また、限りある開発リソースでは、メインのアプリケーション開発者は1人のみの状況でした。 また、スプレッドシート運用であったことから、バナー管理アプリケーションに当たる部分は AppSheet のようなスプレッドシートをDBとするNoCodeツールの利用も検討しました。しかし、画面設計の自由度が不足している点などから要件を満たせないと判断し、採用は見送りました。 バナー管理アプリケーションの仕組み 新しいアプリケーションは、ECS上でSPAとして動作しています。バックエンドはRuby on Rails、フロントエンドはVue.jsを採用しています。 また、ECS上でFargate環境を使っている理由は、サーバレスなサービスであることと冗長構成にするのが容易であるためです。ECSではDockerコンテナをタスクという単位で動作させることができます。 なお、Ruby on Rails + Vue.jsという組み合わせは、以前にアサインされた別の社内ツールの開発でも同じ技術スタックを採用していたためです。開発に慣れていること、機能としても似ている部分があったため、リソースを再利用できることが選定理由です。 MBMでバナーを配信設定するまでの流れは以下の通りです。 バナー情報の登録 バナーの配信設定 1. バナー情報の登録 バナーの基本情報を登録します。 具体的には、バナー画像、遷移先URL、タイトル、施策種別を入力し登録します。なお、登録時は画像のサイズ・拡張子、遷移先URLのフォーマットのバリデーションチェックをします。 下図がその画面です。 また、登録済みバナーは下図のように一覧で確認できます。検索機能もあり、任意のクエリに一致するバナーを表示できます。 2. バナーの配信設定 で登録したバナーの掲載期間と掲載順の設定をします。 下図は配信登録の画面です。この画面で登録済みのバナーと掲載期間、掲載順を設定します。 なお、日ごとの登録以外に、下図のように日をまたいだ期間指定による登録も可能です。 また、ドラッグ操作によるバナーの並び替えや編集もできます。下図がその画面です。 最終的に配信設定したバナーは、カレンダー表示や日次で確認できます。下図がその画面です。 MBMの導入により、バナー担当部署を介す必要なく、施策担当者が自身でバナー登録をできるようになりました。また、視覚的にもわかりやすくバナーを事前確認できるようになりました。 データ連携の仕組み 従来の運用方法でも、バナーデータを直接DBに登録するインタフェースが存在していました。その部分をシステム化し、開発工数を抑えつつも要件を満たすようにしました。 既存のインタフェースを利用するためには、DBを直接参照する必要があります。しかし、アプリケーションからは直接参照させずデータ連携部分から参照するように分離することで、障害となり得る部分を分けました。 データは、MBMで登録されたバナーをDBに保存し、その後、バッチ処理で配信システムのDBに連携される仕組みです。データ連携のアーキテクチャを下図に示します。 データ連携は1分間隔のバッチ処理で実行されます。そのバッチ処理では、MBMにバナー登録・更新があるかを確認し、配信システムDBにデータを連携します。 データ連携では、以下のポイントを考慮し、安定した連携を実現しました。 データ連携処理の分離 冪等性の担保 それぞれのポイントを順に説明します。 1. データ連携処理の分離 データ連携はアプリケーションから切り分けています。これにより、柔軟性と安定性、安全性が得られます。 データ連携を別アプリケーションとして切り出すことで、バナー管理アプリケーションが配信システムのDBを直接参照しなくて済みます。その結果、配信システムと疎結合になります。将来的にはバナーを含む様々なデータを管理するツールになることが見込まれています。このような改修が発生した場合に疎結合であることで、もう一方のシステムに与える影響がより少なくなり、改修しやすくなります。 2. 冪等性の担保 冪等性の担保はデータ連携アプリケーションの処理内で実現しています。 具体的には、DBごとにトランザクション内で DELETE INSERT をするようにしています。 以下に連携処理の擬似コードを示します。 MBM・SQL Server(パーソナライズ配信用システム)・IIAS(マス配信用システム)からn日分のデータ取得 MBM・SQL Server(パーソナライズ配信用システム)・IIAS(マス配信用システム)それぞれのデータ件数を取得 MBM・SQL Server(パーソナライズ配信用システム)・IIAS(マス配信用システム)それぞれの最終更新日を取得 // パーソナライズメールでの連携処理 if MBMとSQL Serverのデータを比較し、データ数が異なる OR MBMの方が最終更新日が新しい トランザクション開始 SQL ServerのDELETE処理 SQL ServerのINSERT処理 トランザクション終了 // マスメールでの連携処理 if MBMとIIASのデータを比較し、データ数が異なる OR MBMの方が最終更新日が新しい トランザクション開始 IIASのDELETE処理 IIASのINSERT処理 トランザクション終了 データ連携ではMBMと配信システムのデータを比較し、MBMのデータに更新があった場合に配信システム側のデータを DELETE 、その後 INSERT し直すことでデータを入れ替えています。これにより、連携元と連携先の対応関係や連携状態を保持する必要がなくなります。また、 DELETE INSERT を採用することで、対応関係と連携状態を持たずとも更新日時とデータ件数の2つを比較することで、データ連携済みであるかの判断が可能となります。 DELETE INSERT が途中で失敗した場合はロールバックされます。連携されていない場合は DELETE INSERT で一定期間のデータを入れ替えるため、連携に失敗しても次回以降の連携で成功すれば問題ありません。「複数回のバッチ処理で最終的に連携されていれば良い」という結果整合性を担保することにより、開発工数も抑えることができました。 このような工夫により、データ連携アプリケーションで状態を管理する必要がなくなります。その結果、障害発生時に考慮すべき点が減ります。例えば、逆に連携時刻をデータ連携アプリケーションで保持する場合、連携処理はできたが連携時刻の更新のみができていない状況になってしまうと、次の連携でも不必要に連携処理が実施されてしまいます。 また、データ連携はデータの更新がない場合はスキップしています。 DELETE INSERT で連携の度にデータをすべて入れ替えても連携はできます。しかし、毎回すべてを入れ替えると、Lambdaの実行時間によるコストが発生したり連携先DBに負荷を与えかねません。 安全に移行作業を進めるための手順 本章では、安全に新システムへ移行するために取り組んだことを紹介します。 リリース当日は以下の手順でリリースを実施しました。 配信が停止されていることの確認 既存のバナーデータをアプリケーションテーブルにインポート 連携対象となる既存の配信システムのテーブルをリネームによってバックアップ 連携対象テーブルと同じスキーマの空テーブルを本番用のテーブル名にリネーム データ連携処理の開始 各手順のポイントを紹介します。 各手順のポイントを紹介します。 1. 配信が停止されていることの確認 リアルタイムにメールが配信される時間帯は1日のうちで決まった時間帯のみです。指定した時間帯でのみ、リアルタイム配信によるメールが配信されます。MBMの導入に伴い、このリアルタイム配信用システムが参照するDBを変更するので、配信を停止する必要がありました。リリース当日は、このリアルタイム配信が停止していることを最初に確認し、以降の作業を実施しました。 2. 既存のバナーデータをアプリケーションテーブルにインポート データ連携の処理はMBMから配信システムに向かって一方向です。そのため、MBMの導入前に配信システムで保存されていたバナーデータは、MBMのDBへインポートする必要があります。インポートしない場合、既に配信システムに登録されているバナーデータをMBMで参照できなくなります。 また、インポートせずともMBMから1つずつ手動でバナー登録できますが、配信システムには数年分のバナーが登録されているため、時間がかかります。 そこで、インポート用のスクリプトを作成し、自動でインポートできるようにしました。こうすることで、スムーズかつ安全な移行作業を実現できます。さらに、手作業による工程を減らすことは、リリースが失敗した場合に同じ手順で再度リリースをしやすくできます。 3. 連携対象となる既存の配信システムのテーブルをリネームによってバックアップ MBMを導入すると、データ連携アプリケーションによって既存のテーブルに対し DELETE や INSERT の処理が実行されます。つまり、リリース作業の失敗や、導入後のシステム障害や操作ミスによりデータを破損してしまう可能性があります。そこで、運用されていたテーブルをリネームし、事前にバックアップを取得しておきます。バックアップを取ることで、障害が発生した場合にも、この時点までロールバックできるようになります。 4. 連携対象テーブルと同じスキーマの空テーブルを本番用のテーブル名にリネーム MBM導入以前に運用されていたテーブルと同じスキーマの空テーブルをリリース前に作成し、当日はリネームのみ実施しました。テーブルは作成後にスキーマが正しいか照合する作業が必要です。そのため、リリース前に空テーブルを作成しておくことで、このような確認作業を事前に終わらせることが可能です。その結果、リリース当日の作業を減らすことができます。 そして、上記の目的で事前に用意していたテーブルをリリース時に配信システムで利用されていたテーブル名にリネームします。MBMリリース後は、ここでリネームした空テーブルに対し、データ連携をします。 5. データ連携処理の開始 データ連携アプリケーションは、リリースの前日までにデプロイ済みです。なお、デプロイ時点では、連携処理は停止しています。そして、リリースのタイミングでこの停止していた連携処理を開始します。これにより、MBMで登録したインポート分を含むバナーデータが作成した空テーブルに対して連携されます。 リネームによってバックアップしたことで、仮にリリース後に障害が発生した場合でも、データ連携処理を停止して再度リネームし直せばロールバックが完了します。ロールバック手順を簡単にすることで、何かあった場合にもすぐにロールバック可能なので、余計な心配をせずにリリースできます。 MBM導入後の運用フロー MBM導入により、メルマガへのバナー掲載フローが下図のように変わりました。 MBM導入により、バナー担当部署が実施していた掲載枠の調整や、DBへの登録作業を施策担当者が自身で実施できるようになりました。 MBMの導入効果 MBMにより、前述の以下の問題を解決できました。 バナー担当部署で工数が発生する バナー担当部署を通さず施策担当者が登録できるようになったことで、バナー担当部署のコストが0になった 直接DBを操作するため、人的なミスが発生し得る MBMで登録したバナーはデータ連携によって既存の配信システムDBに連携されるため、直接のDB操作が不要になった その結果、DB操作時の人的なミスがなくなった スプレッドシート管理のため、実際に掲載されるバナーがイメージしづらい MBMの導入により、施策担当者が自身でバナー登録や掲載状況の確認ができるようになった バナー掲載の調整かかるコストが低減し、バナー掲載枠を効率的に使えるようになった その結果、これによって利益の最大化が見込まれる その他にも、データ連携アプリケーションを分離し冪等にできました。これにより、ネットワーク障害で連携処理ができなかった場合でも、次回以降の処理で成功すれば回復できる結果整合性を担保できました。リリース後、連携先DBで障害発生したことがありましたが、自動で復旧したため特別な対応は不要でした。 メール配信システムのインタフェースは変更していないため、仮にMBM起因の障害が発生しアプリケーション上からバナー登録ができなくなった場合でも、手動のクエリ実行によってバナー登録は可能です。 将来の展望 今回はメルマガバナーの手動運用の課題を、MBMというツールの新規開発・導入によって解決しました。将来の展望としては以下の施策を考えています。 バナー配信システム部分のマイクロサービスとしての切り分け パーソナライズ配信用システムやマス配信用システムはメールバナー施策以外にも様々なチャネルへキャンペーンの配信を担うシステムです。そのため、モノリシックなシステムとなっています。今回のバナーデータ登録までをアプリケーションによって解決するという方法も、短期間の開発期間において既存の配信システムに手を加えることが困難であることから考えた手段です。将来的にはバナー配信システムをマイクロサービスとして再構築し、バナー配信機能のサービス化をして、柔軟なバナー管理をできるようにしていきます。 管理ツールの統一化 今回は開発納期が短期間であったことから、独立したアプリケーションを作成しました。しかし、このように問題解決の度に新規でアプリケーションを開発していくと、管理コストが増えてしまいます。ZOZOTOWNではメール以外にも様々なチャネルを利用してコンテンツを配信しています。現在、配信に関する管理ツールが複数あります。このような管理ツールを統一化することで、管理コストを下げていきます。 まとめ 従来のバナー運用フローを改善するために、管理ツールを開発・導入する取り組みを紹介しました。そこでは、限られた工数の中で安全な移行を実現するためのアーキテクチャや移行作業にも触れました。本記事が皆様の参考になりましたら幸いです。 さいごに ZOZOでは一緒にプロダクトを開発してくれるエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください! corp.zozo.com
アバター
ZOZOTOWN開発本部 ZOZOTOWNアプリ部 Androidブロックの山田です。現在、私を含めた10名チームのブロック長としてZOZOTOWN Androidアプリの開発に取り組んでいます。 私がチームのマネジメント業務に携わったのは2019年4月からです。それ以降、常に7名以上のチームでマネジメント業務を務めてきました。経営学の用語で「スパン・オブ・コントロール」というものがありますが、そこにおいては「1人のマネージャー管理できる人数は5〜7人が適切」とされています。私個人の感覚では7名でも正直多く、5名ぐらいが適切のように感じています。 ともあれ、その状況を2年以上続けてきました。この経験を通し、多人数チームのマネジメントにおいて存在する課題が2つ見えてきました。 各個人に対するコミュニケーション時間減少に伴う、フィードバック量の低下と評価の難しさ チーム全体のパフォーマンス向上に伴う、リーダーのボトルネック化 本記事では、これらの課題解決のために実施した2つの施策を紹介します。 施策1:今週のいいね フィードバックが効率化される 評価へ活用できる 評価のエビデンスとしての活用 活躍のキャッチアップ 雰囲気が良くなる 施策1「今週のいいね」のまとめ 施策2:オーナーシップ制 クオリティ低下を予防しながら施策を進める必要がある リーダーが仕様検討に関わることへの是非を考える チームワークの和を広げられる 業務量のコントロールが難しくなる 施策2「オーナーシップ制」のまとめ 最後に 施策1:今週のいいね 各個人に対するコミュニケーション時間減少に伴う、フィードバック量の低下と評価の難しさを解決するための施策です。 本施策は、チーム内で毎週実施している振り返りの中で行っています。一言でまとめると「お互いの良かった行動を褒めたり感謝を伝え合う」ものです。 方法は簡単です。まず5分時間を測ります。その間にチームメンバー全員が他のチームメンバーの良いと思った行動や尊敬する行動を、以下のように書き出していきます。 そして、5分経過したら書き出しタイムは終了です。次に、1つずつそれを書いた人が読み上げて行きます。 本施策のポイントは下記3点です。 フィードバックが効率化される 1つ目のポイントは、行動に対するフィードバックを公に行うことで、フィードバックした本人以外にも間接的にフィードバックができるという点です。「間接的なフィードバック」というと少し大袈裟な表現ですが、要は1人の人を褒めることで、他の人にも「ああいう行動が求められているんだ」ということを伝えられるということです。特に、組織・チームが必要としている行動をピックアップすると効果的です。 例として、現在のZOZOTOWNのAndroidチームで考えてみます。チームには10名所属しており、チーム分割できる体制作りを目標に掲げています。しかし、具体的な手段はこれからアイデアを出していく段階なため、明確になっていません。そんな中、メンバーが属人化解消につながる行動を見せてくれたとします。属人化解消もチーム分割には重要な要素です。そのため、それを公の場で賞賛することにより、他のメンバーへのアイデアの共有につなげます。少し改変していますが、以下のような内容を「今週のいいね」でフィードバックしたことがあります。 XXの案件振り返り実施ありがとうございます。いつもは資料などリーダーが準備していたのですが準備方法を確認しに来てくれてスケジュールの設定から司会進行までやってくれてありがたかったです。どんどん周りの業務を奪いに行く姿勢が良いと思いました。 ここではまず感謝を伝えるとともに、その行動の具体的に何が良かったのかをフィードバックしています。伝えたい良かったポイントは、今までリーダーがやっていた業務を全てメンバーが代行してくれた点です。それをメンバーの前で伝えることにより、「それはリーダーがやるもの」と思い込んでいた人の意識改革につなげ、同時にチーム分割へのヒントとなるアイデアを共有したことになります。その結果、他の案件でも別のメンバーが同様に代行してくれるようになりました。このように、公の場で良い行動を称賛することは、その良い行動を連鎖させることにつながります。 評価へ活用できる 2つ目のポイントは、評価への活用が可能な点です。本施策を始めたきっかけも、評価への活用のためでした。 評価のエビデンスとしての活用 弊社では、「他のメンバーに良い影響を与える」ということが評価軸の1つにあります。評価は半期ごとに行い、自己評価を上長に評価面談でアピールしていく形式です。そこで自身の行動が他のメンバーに良い影響を与えていたかをアピールします。しかし、それを客観的に判断することは容易ではありません。 評価面談の際に、この項目について「わからない」と言ってくるメンバーもいました。私はメンバー全員との日頃の1on1の中で「あのピンチのときに○○さん(「わからない」と言っていたメンバー)が、ああいう行動をしてくれて救われた」という話を聞いていました。そのため、まったく該当する行動ができていなかったわけではないはずです。 しかし、それを感謝している本人から直接ではなく、上長経由で伝えたとしてもエビデンスにはしにくいです。そこで、毎週の振り返りでメンバー同士で直接伝え合ってもらい、それをエビデンスにしようという試みを始めました。 「毎週行う」というのもポイントです。メンバーの良いところを指摘するのは義務ではないため、忘れてしまったとしても罪ではありません。しかし、せっかく気付いていたのに、それらを忘れられてしまうと、みんなが損をしてしまうことになります。それを防ぐためにも毎週行うようにしました。実際には、1週間の期間でも忘れてしまうことはあります。もし、半期ごとの評価のタイミングだけ実施するようにした場合は、おそらくほとんどの有益な内容が忘れ去られているでしょう。 その結果、評価時にこれをエビデンスとして利用するメンバーもいました。活用事例まで出てきたので、本施策はやって良かったです。「メンバーがエビデンスを用意しやすい」ということは、「評価する側も判断しやすい」と言えます。そのため、多くの人数を評価すればするほど、この効果による恩恵の差はより大きく出てきます。 活躍のキャッチアップ 上記の評価への活用と同時に、上長が見逃した活躍のキャッチアップにも活用できます。現在、私のチームでは案件ごとに、さらに小さいチームを作って開発に取り組んでいます。その小さいチームには、私自身が参加する場合も、参加しない場合もあります。その結果、同じチームで活動しているかどうかで、そのメンバーの活躍に気付けるかどうかの差が生じてしまいます。そのため、個人の活躍を「今週のいいね」を利用して共有してもらい、上長である私が評価の要素として取り入れることを可能にしています。 そして、これは「自分が評価する立場でなくとも他の人の評価に貢献できる」ことを意味します。そのため、次のリーダー候補を見つけるのにも活用できます。それは、私個人としては、メンバーの良いところに目を向けられる人が、次のリーダーになって欲しいからです。 雰囲気が良くなる 当初から狙っていたわけではありませんが、「今週のいいね」の時間により、チームの雰囲気が良くなりました。お互いを認めあっていることが表面化され、より信頼関係が構築しやすくなっています。 施策1「今週のいいね」のまとめ 誰かを褒めることで他の人へ意識改革を促したり、アイデアのヒントを共有したりすることで、フィードバックの効率化を図りました。さらに、メンバーの活躍に対するフィードバックを上長だけでなく、他のメンバーからもできるようにすることで、上長が見逃してしまいがちな活躍をキャッチアップできるようにしました。また、副次的な効果として、チーム内の雰囲気向上にもつながりました。 続いてもう1つの施策を紹介します。 施策2:オーナーシップ制 本施策は、案件に対して各部署(Android、iOS、バックエンド、デザイナー)ごとに案件オーナーという名目で代表者をたて、その案件の仕様決定をオーナー間で行うという施策です。 以前は、上記の代表者の役割を各チームリーダーが担っていました。そして、リーダーが決まった内容をメンバーに展開し、開発するスタイルでした。その際に課題が2つ出てきました。1つ目は、やりたい案件が増えていったときにリーダーがスケールのボトルネックとなってしまう課題です。2つ目の課題は、社員でありながらも、メンバーから見たら受託開発のような形になってしまう課題です。 そんな折、メンバーのひとりが1on1で本施策のオーナーシップ制を提案してくれました。とても良いアイデアだと思い、一緒に詳細を詰め、部内で合意をとり、取り組みを開始しました。 ここでは、本施策のポイントを3つ紹介します。 クオリティ低下を予防しながら施策を進める必要がある 本施策の開始当初は、プロダクトマネージャーやリーダーが仕様検討に参加することもありました。他にもオーナーが一度チームに持ち帰ってアイデアを議論をしたのち、他のオーナーと仕様のすり合わせを行うこともありました。そうすることで、急激なクオリティ低下のリスクを予防しながら進めていました。 その結果、取り組みを始めて1年以上経過しましたが、現在までに大きな問題が発生することはありませんでした。並行して権限委譲も進み、今では仕様をリーダーと相談するかどうかは、そのオーナー自身が判断するようになりました。そのため、私が仕様検討にまったく関わらずにリリースされた機能も存在します。 リーダーが仕様検討に関わることへの是非を考える もちろん、リーダーが全ての案件の仕様検討に関わった方が、より良いサービスになる可能性もあります。しかし、それは「リーダーだから」というわけではなく、リーダー以外の全員が当てはまります。そのため、現在は「リーダーだから全ての案件の仕様検討に関わる」という考え方はしていません。こう考えるようになった理由の1つに、私の育休取得があります。実は、この取り組みを始めた直後に、私は2か月ほど育休を取得しました。しかし、その際にも問題は発生しませんでした。 この「リーダーが仕様検討に関わるべきか」に対しては賛否両論あると思います。私のチームでは、本施策を実施した結果、大きな事故も起きずにスピード感を手に入れられたため、このスタンスは継続していきます。 チームワークの和を広げられる 本施策でも副次的な効果を得られました。それは、チームの枠を越えたメンバー同士の理解が深まったことです。 これまでは、他チームの状態や考え方は、リーダーを介してメンバーに伝わっていました。しかし、それだけでは全ての情報を伝えきることはできません。そのため、他チームメンバーの考えを理解することが難しくなっていました。本施策を開始し、メンバー同士が直接やりとりするようになりました。そこでは、仕様以外の情報もやりとりできるようになりました。その結果、お互いのことをより知ることができ、チームワークの和がチームの枠を越えて広がっていきました。 業務量のコントロールが難しくなる ここまでは良かった点を紹介してきましたが、課題も存在します。それは、案件が並行して行えるようになったことにより、メンバーの業務量コントロールが難しくなったことです。仕様検討をリーダーが行なっていた頃は、リーダーのスケジュールが空いているタイミングでしか、仕様の議論ができませんでした。そのため、結果としてリーダーの業務量が増えてくると、自然とメンバーの業務量増加のブレーキがかかっていました。 一方、現在ではリーダーのスケジュールが空いてなくとも、メンバーのスケジュールが空いていれば議論を進めることができます。そのため、どこかで意識的にブレーキをかける必要がでてきましたが、今はそのタイミングがかなり掴みにくくなっています。この課題は部長・リーダー陣で共通して認識しており、対策を検討中です。 施策2「オーナーシップ制」のまとめ メンバーが上流工程に関わる機会を増やすことで、サービス全体の開発スピード向上や、メンバー1人1人の主体性の向上を図りました。その結果、現在のAndroidチームではその目的を達成できている状態です。さらに、リーダーが関わらずとも問題なく案件が進行される場面も増え、リーダーのボトルネック問題も解消したと言えます。ただし、メンバー1人1人の業務量が増え、そのコントロールが難しくなる新しい課題に現在は取り組んでいます。 最後に メンバーの人数が多くなってくると、リーダーが1人1人に関わることのできる時間は少なくなってしまいます。そこで生じる課題を解決するために、効率的にフィードバックと評価をする方法として、「今週のいいね」の施策を紹介しました。また、メンバーの人数が多くなってくるとリーダーがボトルネックになってしまう課題を解決する手段として、「オーナーシップ制」の施策も紹介しました。 どちらも副次効果として、人間関係の構築にも良い影響を与えることができました。この他にも、1on1やDiscord活用などの取り組みもあるので、「それらの話も聞いてみたい」という方は、まずはカジュアル面談でお話しましょう! hrmos.co もちろんAndroidエンジニアの採用も積極的に行っています。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
アバター