TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

988

はじめに こんにちは。計測プラットフォーム開発本部SREブロックの西郷です。普段はZOZOSUITやZOZOMAT、ZOZOGLASSなどの計測技術に関わるシステムの開発、運用に携わっています。先日私達のチームでは、シングルクラスタ・マルチテナントを前提として構築したEKSクラスタにZOZOMATシステムを移行しました。本記事では移行ステップや作業時に工夫した点について紹介したいと思います。 目次 はじめに 目次 移行の概要とそのアプローチ 前提 要件 移行方針 各移行ステップとその詳細 STEP1:移行先CFnスタックへのAWSリソース作成、インポート STEP2:移行先へのデータマイグレーション S3 注意点 RDS 注意点 STEP3:移行先のクラスタにkubernetesリソースを追加 STEP4:EKSクラスタの切り替え external-dnsのdomain-filterをマルチドメインに変更する 既存EKSクラスタのexternal-dnsのpolicyをcreate-onlyに変更する ingressのhost指定を有効にし、 Route53レコードのALBの値を変更する 振り返り 終わりに 参照記事 移行の概要とそのアプローチ マルチテナントのEKSクラスタに移行した理由は、EKSクラスタが多く存在することで管理コストの高さが目立ってきたためです。 というのも、EKS導入当初はまだ手探りな部分が多かったため、各サービスごとにEKSクラスタを作成していました。しかしながら当初の想定以上にサービスが増えてしまったため、今回単一のクラスタで複数のサービスを運用するマルチテナントのEKSクラスタへ移行するに至りました。 この辺については先だって公開したこちらの記事 (1) で詳しく書かれているので、是非併せてご覧ください。 まずは今回の前提と要件を整理します。 前提 AWSリソースはCloudFormation(以降CFnと記載します)で管理している 要件 無停止で切り替えること ロールバックが容易であること コンピューティングタイプをEC2からFargateに変える コンピューティングタイプを変える点について少し補足すると、マルチテナント化に伴うマシンリソースの競合および権限の分離や、運用負荷の軽減を目的としたEC2からの脱却が背景にあります。 移行方針 以上を踏まえ、EKSクラスタ間の切り替えについてはマルチテナントのEKSクラスタにZOZOMATリソースを追加した上で、Route 53に設定済みのALBの値を変更しました。図で示すと以下のようになります。 また、ZOZOMATで利用しているAWSリソースについても一部を除いて新しいCFnスタックに新規作成し、管理するCFnスタックを切り替えることにしました。理由は日々の運用作業と干渉せず移行作業をしやすかったことや、リソースの命名則を変更したかったためです。各AWSリソースのスタック間での移行方針はリソースごとに異なるため、わかりやすくまとめると以下のようになります。 AWSサービス 移行方針 特記事項 EKSクラスタ 既存のマルチテナントのEKSクラスタを利用 - FargateProfile IAMRole等 新規作成 - CodePipeline CodeDeploy CodeBuild 新規作成 - ECR 新規作成 作業時点でCFnのResource Importが未対応だったため。1世代分のイメージのみ移行 Redis 新規作成 - DynamoDB Resource Import - RDS Snapshotを元に新規作成&データ同期 - S3 新規作成&データ同期 - そもそものZOZOMATの構成については以前の記事 (2) に詳しく書かれているので、興味がある方は是非そちらをご覧ください。 まとめると、今回の移行のポイントは以下のようになります。 ZOZOMATが利用するEKSクラスタをマルチテナントのEKSクラスタに切り替える 付随して、ZOZOMATが利用するAWSリソースも新しいCFnスタック管理に切り替える 各移行ステップとその詳細 今回の移行作業は大きく4段階に分けて行いました。 STEP1:移行先CFnスタックへのAWSリソース作成、インポート STEP2:移行先へのデータマイグレーション STEP3:移行先のEKSクラスタにkubernetesリソースを追加 STEP4:EKSクラスタの切り替え ここから先は各ステップについて、工夫した点や注意点を交えて説明いたします。 STEP1:移行先CFnスタックへのAWSリソース作成、インポート まずは前述のとおり、新しいCFnスタックに必要なAWSリソースを作成していきます。 ECRは、この作業を行なった2021年5月時点ではCFnの Resource Import に未対応でした。そのため、既存のECRを一度削除して再作成し、あらかじめ取得しておいた最新1世代分のイメージをpushすることにしました。現在はResource Importに対応している (3) ようです。 DynamoDBは命名則の修正が必要なく、Resource Importも対応していたため、既存のテーブルをそのまま新しいスタックにインポートしました。 移行の流れをわかりやすくするため、現時点の状態を図で示します。 STEP2:移行先へのデータマイグレーション このステップでデータのマイグレーションが発生するデータソースはRDSとS3です。まずはZOZOMAT環境に存在するデータソースとその中に含まれるデータについて簡単に説明します。 DynamoDB 計測データ IDやメタデータを含むsession 足のサイズや3Dデータを含むscan(左、右) RDS 計測データ DynamoDBの計測データ(session、左右のscan)を統合し、1レコードとして管理 S3 計測データ RDSと同じ形式のデータをJSON形式で保存 ログデータ ALBやCloudFront等のログ ネイティブアプリのデバッグログ 計測データは1次データソースとしてDynamoDBに投入された後、以下のようにDynamoDBStreamと後続のLambdaを通してRDSとS3にも保存されます。 この構成について、RDSにデータを保存している理由を説明します。ZOZOMATでは1つのsessionに対して左右の足それぞれの計測値と3Dデータを持つため、計測結果を参照する際にはこれらのデータの結合が必要になります。DynamoDBには結合するためのAPIがないため、アプリケーションで結合処理を行うとsessionで1回、scanで左足、右足の2回、合計で3回クエリの発行が必要になります。ZOZOMATではこれらのデータを1つのレコードに結合した状態でRDSに保存しているのですが、これによってアプリケーションからのクエリ発行は1回で済み、通信コストを抑えることができます。 こういった背景もありZOZOMATではCQRSを採用し、更新系(Command)をDynamoDBに、参照系(Query)をRDSに分離しています。より詳しくはこちらの記事 (4) を参照ください。 S3についてはデータ連携の観点からです。分析やサイズ推奨のモジュール開発に役立てるため、計測結果のデータを関連チームに連携する必要がありました。そのため、S3にJSON形式で保存しています。 さて、STEP1で説明した通りDynamoDBはCFnのResource Importによって既存のテーブルをそのまま利用するため、データのマイグレーションは発生しませんでした。一方、RDSとS3は新規作成するため、過去のデータはもちろんのこと、継続的に同期し続ける必要がありました。ここからはどのようにデータ同期を実現したのかについて説明していきます。 S3 S3に保管されているデータはオブジェクトの最終更新日時を保持したまま同期したかったこと、工数を割かずに継続的なデータ同期を実現する仕組みが必要だったこと、リアルタイムでなくとも同期されていればよかったことから、S3の レプリケーション機能 を利用しました。これはS3間でオブジェクトを自動で非同期的にコピーしてくれる仕組みです。 最終更新日時を保持したかった背景としては、普段の監視業務やユーザからの問い合わせ対応等において、それらの情報が重要だったことが挙げられます。 s3 sync や s3 cp コマンド等では最終更新日時が変わってしまいますが、レプリケーション機能であればオブジェクトのメタデータを保持したまま同期が可能です。 さて、デフォルトではレプリケーション対象のオブジェクトはレプリケーション有効化後にputされたオブジェクトのみです。しかしながら、移行完了後は移行前に利用していたS3を削除する予定だったため、今回は有効化前にputされたオブジェクトの同期も必要でした。解決策としてはサポートケースを起票することで既存オブジェクトのレプリケーション有効化が可能だったため、そちらで対応しました。申請時に必要な情報は下記AWSのドキュメントを御覧ください。 既存のオブジェクトのレプリケーション 注意点 レプリケーションを利用する際、特に注意が必要だと感じた点は次のとおりです。 既存オブジェクトのレプリケーション有効化の際、サポートケース起票から機能の有効化が完了するまでに3週間程かかる 利用者は有効化されるタイミングを指定できない 今回はスケジュールに猶予があったため問題にはならなかったが、考慮した上でのスケジュール設定が必要 オブジェクトのバージョンIDを指定せず削除した際、レプリケーション設定が最新バージョンでない場合は削除マーカーをレプリケートする 最新バージョンの場合、削除マーカーはレプリケートされない オブジェクトバージョンを指定して削除した場合は削除マーカーをレプリケートしない 既存オブジェクトのレプリケーションルールはCFnでは設定できない それ以外にもレプリケーション機能の利用についてはいくつか制約があるため、利用の際にはAWSのドキュメント (5) を確認することをおすすめします。 RDS 次にRDSのデータの同期についてです。こちらはRDSをSnapshotから復元し、その後DynamoDBStreamに既存と同じLambdaをもう1つ紐付けることでデータ同期を実現しました。 ただし、Snapshot作成から新しいRDSがRunningになるまでの間、新たに作成されたデータをどのように同期するか、という点は一工夫必要でした。というのもDynamoDBStreamへ紐づけているLambdaの開始位置がLATESTになっており、最新のレコードから読み込む設定になっていたためです。 これについてはDynamoDBStreamへ紐づけているLambdaの開始位置をLATESTからTRIM_HORIZONに変更することで解消しました。図にすると以下のとおりです。 TRIM_HORIZONの場合は、ストリームに保存されている24時間以内のレコードを古いものから順に読みとる挙動となります。全ての項目を処理し追えるまで実行され、その後は新しいレコード分に対して処理が実行されます。この時すでにRDSに含まれるデータも処理対象になる可能性はあるのですが、Lambdaで行っている処理は冪等なため問題ないと判断しました。 CFnテンプレート上は以下のように指定します。 LambdaEventSourceMappingDynamoDBStreamSessions : Type : 'AWS::Lambda::EventSourceMapping' Properties : EventSourceArn : !GetAtt DynamoDBTableSessions.StreamArn FunctionName : !GetAtt LambdaFunctionDynamoDBStreamSessions.Arn StartingPosition : 'TRIM_HORIZON' #ここをLATESTから変更 注意点 DynamoDBStreamレコードが保持されるのは24時間のため、DynamoDBStreamにLambdaを紐付けるまでの時間がそれ以上になる場合は適さない DynamoDBStreamに3つ以上のLambdaを紐付けるとリクエスト失敗の可能性が高くなる この場合はファンアウトパターンが推奨されている ファンアウトパターンについてはAWSのブログ (6) で詳しく説明されているので、興味のある方は是非ご覧ください。 ここまででAWSリソースの対応は完了です。 STEP3:移行先のクラスタにkubernetesリソースを追加 まずはkubernetesのマニフェストファイルに対して、今回の移行に際して必要な以下の修正を加えていきます。これらの作業の背景や詳細はこちら (1) に詳しく書かれているので、本記事では割愛させていただきます。 ZOZOMAT専用のEKSクラスタ独自で管理していたmetrics-serverやexternal-dns等の廃止 ZOZOMAT用ネームスペースの作成及び指定 Fargate化に伴うIRSA対応 上記の修正をし、マルチテナントのEKSクラスタにkubernetesリソースをデプロイするのですが、ingressのspec.rules.host部分が指定されているとexternal-dnsによってRoute53のAレコードの値が上書きされてしまいます。そのため、以下のようにコメントアウトした状態でデプロイすることにより回避しました。 apiVersion : networking.k8s.io/v1beta1 kind : Ingress metadata : name : mat-api-ingress # --------- omit spec : rules : - #host: mat-api.zozo.com http : paths : - path : /healthz backend : serviceName : api-server-service servicePort : 8000 # --------- omit この対応を含め、本ステップによりzozomatネームスペースにFargateで稼働するpod群が作成されます。 STEP4:EKSクラスタの切り替え 当初はingressのkubernetesマニフェストでhost指定を有効化するだけの作業を想定していたのですが、最終的に行なった手順は以下になります。 external-dnsのdomain-filterをマルチドメインに変更する 既存EKSクラスタのexternal-dnsのpolicyをcreate-onlyに変更する ingressのspec.rules.hostのコメントアウトを解除し、有効にする 以降は各作業が必要になった背景を踏まえながら、詳しく見ていきたいと思います。 external-dnsのdomain-filterをマルチドメインに変更する ZOZOGLASSとZOZOMATは利用するHosted Zoneが異なります。しかしながらマルチテナントのEKSクラスタに存在するexternal-dnsのdomain-filterは、ZOZOGLASSが利用するHosted Zoneのみ指定していました。ZOZOMATが利用するHosted Zoneは指定されていないため、そのままだとingressのspec.rules.hostを有効にしてもAレコードに設定されているALBの値は変わりません。そのため、予めdomain-filterを複数指定することで解消しました。 apiVersion : apps/v1 kind : Deployment metadata : name : external-dns spec : template : spec : containers : - name : external-dns args : - --source=service - --source=ingress - --domain-filter=glass-domain.zozo.com - --domain-filter=mat-domain.zozo.com # ここを追加 - --provider=aws - --policy=upsert-only - --aws-zone-type=public - --registry=txt - --txt-owner-id=my-hostedzone-identifier 既存EKSクラスタのexternal-dnsのpolicyをcreate-onlyに変更する 既存のEKSクラスタとマルチテナントのEKSクラスタの両方に、それぞれexternal-dnsが存在します。external-dnsのpolicyにはsync、upsert-only、create-onlyがあり、それぞれ変更を検知すると次のように動作します。 sync レコードの作成、変更、削除全てを行う upsert-only レコードの作成、変更のみ行う create-only レコードの作成のみ行う 私達の環境では誤ってレコードを削除してしまう事態を防ぐため、両external-dnsのpolicyをupsert-onlyにしていました。そのため、片方でingressのspec.rules.hostを有効化しRoute53のレコードの値を上書きすると、もう片方が変更を検知し値を更に上書きする事態が発生してしまいます。 これについては既存のEKSクラスタのexternal-dnsで、予めpolicyをcreate-onlyに変更することで解消しました。 apiVersion : apps/v1 kind : Deployment metadata : name : external-dns spec : template : spec : containers : - name : external-dns args : - --source=service - --source=ingress - --domain-filter=glass-domain.zozo.com - --domain-filter=mat-domain.zozo.com - --provider=aws - --policy=create-only #ここを変更 - --aws-zone-type=public - --registry=txt - --txt-owner-id=my-hostedzone-identifier ingressのhost指定を有効にし、 Route53レコードのALBの値を変更する 最後にingressのkubernetesマニフェストでspec.rules.hostのコメントアウトを解除し、デプロイします。これによってRoute53のAレコードの値が移行元のALBのDNS名から移行先のALBのDNS名に切り替わりました。 apiVersion : networking.k8s.io/v1beta1 kind : Ingress metadata : name : mat-api-ingress # --------- omit spec : rules : - host : mat-domain.zozo.com #コメントアウトを外す http : paths : - path : /healthz backend : serviceName : api-server-service servicePort : 8000 # --------- omit 以上の作業を踏まえ、ZOZOMATが利用するEKSクラスタをマルチテナントのEKSクラスタに切り替えることができました。 振り返り 今回のような稼働中サービスのシステム移行は個人的に初めてだったので、当初は完遂出来るか不安な部分もありました。特にデータ同期というとスクリプトを書いて定期実行するイメージでしたが、S3レプリケーションやDynamoDBStreamといったAWSの仕組みや、それらを利用したZOZOMATのデータ投入の仕組みをフル活用して大きなトラブルなく終えることができたのは良い経験になりました。また、事あるごとに躓いていましたが、リーダーとのオフィスアワーやチームメンバーとのペアプロ等、周りの力を借りやすかったチーム環境も非常に有り難かったです。 終わりに 計測プラットフォーム開発本部では今後も ZOZOSUIT 2 等の新しいサービスのローンチを予定しています。更にスピード感を持った開発が求められますが、このような課題に対して楽しんで取り組み、サービスを一緒に盛り上げていける方を募集しています。少しでもご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co また、カジュアル面談も随時実施中ですのでお気軽にご応募ください。 hrmos.co 参照記事 1: EKSのマルチテナント化を踏まえたZOZOGLASSのシステム設計 2: ZOZOMATにおけるEKSやgRPCを用いたシステム構成と課題解決 3: AWS::ECR::Repository support for importing into existing stack 4: ZOZOSUITからZOZOMATへ - CQRSによる解決アプローチ 5: レプリケーションの要件 6: Amazon DynamoDB ストリームを使用して、順序付けされたデータをアプリケーション間でレプリケーションする方法
こんにちは、MA基盤チームの田島です。私達のチームでは複数のワークフローエンジンを利用し、メールやLINEなどへの配信を含むバッチ処理を行っていました。今回それらのワークフローエンジンをすべてDigdagに統一しました。そして実行環境としてGKEのAutopilot環境を選択したことにより、柔軟にスケールするバッチ処理基盤を実現しましたのでそれについて紹介します。 また、その中で得られた運用Tipsについても合わせて紹介します。 目次 目次 Digdag on GKE Autopilotの構成 Digdagの4つの役割 Worker Scheduler Web API Kubernetes Command Executor Workerでのタスク実行の問題 Command Executor Kubernetes Command Executorの利用 GKE Autopilot環境でのKubernetes Command Executorの利用 Kubernetes Command Executorの使い方 Workerのオートスケーリング Custom Metricsを利用したオートスケーリング スケーリングの設定 スケールイン時の問題 PrometheusAdapterの利用の注意点 運用上の工夫と注意点 ノードの立ち上げの待ち時間が発生する タスクはすべて冪等にする たまにログが出ない 終了したPodが消えない 今後の展望 まとめ Digdag on GKE Autopilotの構成 今回構築したDigdag on GKE Autopilot環境の最終構成は次のとおりです。 GKE Standard環境における、Digdagの構築はすでに弊社の別チームで行われており、スケーリング部分以外はほぼそれを踏襲する形で構築しました。以下は当時の発表資料です。 参考にした構成から一部拡張した部分について、またAutopilot環境だからこその利点についてなどを含め、改めて構成を紹介します。 Digdagの4つの役割 Digdagは役割ごとに以下の「Worker」「Scheduler」「Web」「API」のDeploymentを作成し、クラスタを構成しています。 Worker Digdagではワークフローのなかの1つ1つの処理のことをタスクと呼びます。Workerは実際にタスクを実行する役割を担います。DigdagのタスクはPostgreSQL(CloudSQL)に一度キューという形で登録され、Workerは登録されているタスクのうち実行可能なタスクを取得して実行します。 WorkerのDeploymentのマニフェストは次のとおりです。Digdag起動時に disable-scheduler を指定することで、次で紹介するSchedulerの役割を除外しています。Kubernetes関連のオプションに関しては後ほど紹介します。 apiVersion : apps/v1 kind : Deployment metadata : labels : run : digdag-worker name : digdag-worker namespace : digdag spec : progressDeadlineSeconds : 600 revisionHistoryLimit : 10 selector : matchLabels : run : digdag-worker strategy : rollingUpdate : maxSurge : 2 maxUnavailable : 0 type : RollingUpdate template : metadata : labels : run : digdag-worker spec : serviceAccountName : digdag dnsPolicy : ClusterFirst restartPolicy : Always schedulerName : default-scheduler securityContext : {} terminationGracePeriodSeconds : 5400 volumes : - name : digdag-config-volume configMap : name : digdag-config containers : - name : digdag-worker image : <YOUR_DIGDAG_IMAGE> imagePullPolicy : Always volumeMounts : - name : digdag-config-volume mountPath : /etc/config command : [ "/bin/bash" ] args : - "-cx" - | digdag server \ --disable-scheduler \ --log-level <LOG_LEVEL> \ --max-task-threads <MAX_TASK_THREADS> \ --config /etc/config/digdag.properties \ -p environment=<ENVIRONMENT> \ -X database.host=<POSTGRES_IP> \ -X database.password=$POSTGRES_PASSWORD \ -X digdag.secret-encryption-key=$SECRET_ENCRYPTION_KEY \ -X archive.gcs.bucket=<DIGDAG_ARCHIVE_BUCKET> \ -X log-server.gcs.bucket=<DIGDAG_LOG_BUCKET> \ -X agent.command_executor.type=kubernetes \ -X agent.command_executor.kubernetes.config_storage.in.gcs.bucket=<DIGDAG_ARCHIVE_BUCKET> \ -X agent.command_executor.kubernetes.config_storage.out.gcs.bucket=<DIGDAG_ARCHIVE_BUCKET> \ -X agent.command_executor.kubernetes.name=<KUBERNETS_CLUSTER_NAME> \ -X agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.master=$KUBERNETS_MASTER \ -X agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.certs_ca_data=`cat /var/run/secrets/kubernetes.io/serviceaccount/ca.crt | base64 -w 0` \ -X agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.oauth_token=`cat /var/run/secrets/kubernetes.io/serviceaccount/token` \ -X agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.namespace=digdag \ -X agent.command_executor.kubernetes.config_storage.in.type=gcs \ -X agent.command_executor.kubernetes.config_storage.out.type=gcs \ -X agent.command_executor.kubernetes.config_storage.out.gcs.direct_upload_expiration=<GCS_DIRECT_UPLOAD_EXPIRATION> \ -X executor.task_ttl=<TASK_TTL> resources : requests : cpu : 1000m memory : 2Gi limits : cpu : 1000m memory : 2Gi また、configmapは以下のように定義しています。 configMapGenerator : - name : digdag-config namespace : digdag files : - config/digdag.properties server.bind=0.0.0.0 server.port=8080 database.type=postgresql database.port=5432 database.user=digdag database.database=digdag archive.type=gcs log-server.type=gcs Scheduler Digdagでは、ワークフローごとに実行のスケジューリングを行うことができます。これもまた、PostgreSQLにスケジュールが登録されます。そして、Schedulerは実行時間になったワークフローの実行を開始します。 SchedulerのDeploymentのマニフェストは次のとおりです。Digdag起動時に disable-executor-loop を指定することでWorkerの役割を除外しています。 apiVersion : apps/v1 kind : Deployment metadata : labels : run : digdag-scheduler name : digdag-scheduler namespace : digdag spec : progressDeadlineSeconds : 600 revisionHistoryLimit : 10 selector : matchLabels : run : digdag-scheduler strategy : rollingUpdate : maxSurge : 2 maxUnavailable : 0 type : RollingUpdate template : metadata : labels : run : digdag-scheduler spec : serviceAccountName : digdag dnsPolicy : ClusterFirst restartPolicy : Always schedulerName : default-scheduler securityContext : {} volumes : - name : digdag-config-volume configMap : name : digdag-config containers : - name : digdag-scheduler image : <YOUR_DIGDAG_IMAGE> imagePullPolicy : Always volumeMounts : - name : digdag-config-volume mountPath : /etc/config command : [ "/bin/bash" ] args : - "-cx" - | digdag server \ --disable-local-agent \ --disable-executor-loop \ --log-level <LOG_LEVEL> \ --config /etc/config/digdag.properties \ -p environment=<ENVIRONMENT> \ -X database.host=<POSTGRES_IP> \ -X database.password=$POSTGRES_PASSWORD \ -X digdag.secret-encryption-key=$SECRET_ENCRYPTION_KEY \ -X archive.gcs.bucket=<DIGDAG_ARCHIVE_BUCKET> \ -X log-server.gcs.bucket=<DIGDAG_LOG_BUCKET> resources : requests : cpu : 200m memory : 300Mi limits : cpu : 200m memory : 300Mi Web DigdagにはDigdag UIと言って、ワークフローをGUIから確認・実行できるものがあります。そのDigdag UIを提供するのがWebになります。また、Digdag UI上からリクエストされるAPIの処理もこのWebが担います。 WebのDeploymentのマニフェストは次のとおりです。Digdag起動時に disable-scheduler と disable-executor-loop を指定することで、SchedulerとWorkerの役割を除外しています。そして、外部からアクセスできるようポートの設定をしています。 apiVersion : apps/v1 kind : Deployment metadata : labels : run : digdag-web name : digdag-web namespace : digdag spec : progressDeadlineSeconds : 600 revisionHistoryLimit : 10 selector : matchLabels : run : digdag-web strategy : rollingUpdate : maxSurge : 2 maxUnavailable : 0 type : RollingUpdate template : metadata : labels : run : digdag-web spec : serviceAccountName : digdag dnsPolicy : ClusterFirst restartPolicy : Always schedulerName : default-scheduler terminationGracePeriodSeconds : 20 securityContext : {} volumes : - name : digdag-config-volume configMap : name : digdag-config containers : - name : digdag-web image : <YOUR_DIGDAG_IMAGE> imagePullPolicy : Always volumeMounts : - name : digdag-config-volume mountPath : /etc/config command : [ "/bin/bash" ] args : - "-cx" - | digdag server \ --disable-local-agent \ --disable-scheduler \ --disable-executor-loop \ --log-level <LOG_LEVEL> \ --config /etc/config/digdag.properties \ -p environment=<ENVIRONMENT> \ -X server.http.io-idle-timeout=60 \ -X server.http.no-request-timeout=30 \ -X database.host=<POSTGRES_IP> \ -X database.password=<POSTGRES_PASSWORD> \ -X digdag.secret-encryption-key=<SECRET_ENCRYPTION_KEY> \ -X archive.gcs.bucket=<DIGDAG_ARCHIVE_BUCKET> \ -X log-server.gcs.bucket=<DIGDAG_LOG_BUCKET> ports : - containerPort : 8080 protocol : TCP resources : requests : cpu : 1000m memory : 4Gi limits : cpu : 100m memory : 4Gi readinessProbe : httpGet : path : / port : 8080 initialDelaySeconds : 5 periodSeconds : 5 timeoutSeconds : 4 successThreshold : 1 failureThreshold : 3 livenessProbe : tcpSocket : port : 8080 initialDelaySeconds : 5 periodSeconds : 5 timeoutSeconds : 4 successThreshold : 1 failureThreshold : 3 API DigdagはDigdagClientを利用したり先程紹介したDigdag UIを利用してコントロールします。クライアントやUIはDigdagサーバへのAPIリクエストをすることでDigdagを操作します。そのAPIを直接利用したいというケースがあったため、API専用のDigdagを今回作成しました。例えば私達のチームではAPIを利用して、特定のワークフローの完了待ちをすると言った処理を別のDigdagや、同一のDigdagから行っています。 最初はWebと同じサーバーを利用していましたが、Webの処理によりPodが落ちるということがたまに発生していました。Webだけであれば画面が使えなくなるだけですから、数秒から数分でPodが復旧すれば問題ありませんでした。しかし、APIの場合では他のアプリケーションから参照されるため、それでは困るケースがあり役割を分離しました。APIのDeploymentはWebのものとほぼ同じ構成となります。 Kubernetes Command Executor Workerでのタスク実行の問題 タスクの実行はWorkerで処理すると説明しました。私達のチームではメールやLINE・PUSH通知などの配信をしたり、データマートの集計をしたりと様々な種類のバッチ処理がDigdagで実行されます。中には大量のデータを処理するようなものもあれば、単純にHTTPリクエストするだけのものなどワークロードがバラバラです。そのため、Workerは高負荷なタスクに合わせて作成しておく必要があります。それにより、高負荷なタスクが無い場合にはWorkerのPodがオーバースペックになるため、コスト的にかなりのデメリットになります。 Command Executor この課題を解決するためにKubernetes Command Executorを利用しました。Digdagは Command Executor といってKubernetes等の環境でShellやRuby/Pythonといった処理を実行できる機能があります。 2022年1月にリリースされた Digdag v0.10.4 にて、 Command Executorのプラグイン化 がリリースされました。それにより設定ファイル等でどのCommand Executorを利用するかが選択できるようになりました。Command Executorには現在以下の種類が存在します。 Docker Command Executor ECS Command Executor Kubernetes Command Executor Docker Command ExecutorはWorker内でDockerコンテナを起動しタスクを実行します。また、ECS Command ExecutorはECSでタスクを実行します。そして、Kubernetes Command Executorでは、Kubernetes上にPodを作成しタスクを実行します。 Kubernetes Command Executorの利用 それでは、なぜKubernetes Command ExecutorでWorkerの問題が解決できるのかを紹介します。Kubernetes Command Executorを利用することでWorkerはタスク実行用のPodを作成し、作成したPodの処理完了をポーリングするだけとなります。よって、Worker自体の処理はすごく軽く、負荷は均一となります。それによりPodのサイズは小さくかつ無駄なく利用できるようになります。 GKE Autopilot環境でのKubernetes Command Executorの利用 さらにKubernetes Command Executorを利用するときに、実行するPodのマニフェストを指定できるため、ワークロードに合わせてタスクのPodを作成・実行できます。このときAutopilot環境だと事前にNodePoolを作っておく必要がないため、アプリケーション側ではノードのサイズのことを気にせず必要なキャパシティを指定し処理を実行できます。 Kubernetes Command Executorの使い方 Kubernetes Command Executorを利用するには以下のように設定ファイルを記述することで利用できます。 agent.command_executor.type=kubernetes agent.command_executor.kubernetes.config_storage.in.gcs.bucket=<DIGDAG_ARCHIVE_BUCKET> agent.command_executor.kubernetes.config_storage.out.gcs.bucket=<DIGDAG_ARCHIVE_BUCKET> agent.command_executor.kubernetes.name=<KUBERNETS_CLUSTER_NAME> agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.master=$KUBERNETS_MASTER agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.certs_ca_data=`cat /var/run/secrets/kubernetes.io/serviceaccount/ca.crt | base64 -w 0` agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.oauth_token=`cat /var/run/secrets/kubernetes.io/serviceaccount/token` agent.command_executor.kubernetes.<KUBERNETS_CLUSTER_NAME>.namespace=digdag agent.command_executor.kubernetes.config_storage.in.type=gcs agent.command_executor.kubernetes.config_storage.out.type=gcs agent.command_executor.kubernetes.config_storage.out.gcs.direct_upload_expiration=<GCS_DIRECT_UPLOAD_EXPIRATION> この状態で以下のようなタスクを定義することで、タスクがPodとして作成され実行されます。 +set_use_rate : _export : docker : image : ${docker_image} kubernetes : Pod : resources : requests : cpu : 100m memory : 0.2Gi limits : cpu : 100m memory : 0.2Gi sh> : echo 'hello' また、設定ファイルにKubeterntes Command Executorの設定をしなければ、上記のように定義されたタスクはKubernetes Command ExecutorではなくDocker Command Executorが利用されます。そのため、ローカルでの開発ではわざわざKubernetesクラスタの構築をすることなくタスクの実行ができるため効率的に開発を進めることができます。 Workerのオートスケーリング Kubernetes Command Executorを利用することで、ワークロードに合わせたタスクの実行を実現していることを紹介しました。ただし、Kubernetes Command Executor実行時にPodを作成し、Podの完了を待つという処理に関してはWorkerが担います。Workerはノードごとに max_task_threads で指定した数しか並列でタスクを実行できません。このとき、Workerのノード数を固定にしてしまうと、並列数を上げることができません。そこで、Workerノード自体のスケーリングも必要となってきます。 Custom Metricsを利用したオートスケーリング Workerのスケーリングに関しては弊社エンジニアの繁谷が考案したスケーリング手法を参考にしました。以下がその構成です。 github.com また、その説明を以下の記事で行っています。 qiita.com 構成を図示したものは次のとおりです。 Digdagでは、PostgreSQL利用しワークフローやタスクを管理しています。その中で現在実行中または実行待ちのタスクキュー数が取得できるため、それを利用することでWorker数を決定できます。そこで、PostgreSQL Exporterを利用しそのキューサイズをPrometheusに溜め込みます。そして、Prometheus Adapterを利用し、KubernetesのCustom Metrics APIに登録します。APIに登録できたら、その値をHPA(Horizontal Pod Autoscaler)で利用しスケーリングを実現します。実際の実装に関しては紹介したリポジトリを参照いただければと思います。 スケーリングの設定 私達のチームでは、紹介した構成からHPAのメトリクスの条件に「averageValue」の項目を追加しています。これを各ノードの max_task_threads の数を設定することで、現状のクラスター全体のスレッド数が足りなくなった分だけスケールアウトできるようになります。そして逆に使われているスレッド数が少なくなった場合にスケールインを行います。 実際のHPAの設定は次のとおりです。 apiVersion : autoscaling/v2beta1 kind : HorizontalPodAutoscaler metadata : name : digdag-worker namespace : digdag spec : scaleTargetRef : apiVersion : apps/v1 kind : Deployment name : digdag-worker minReplicas : 1 maxReplicas : 10 metrics : - type : Object object : target : kind : Service name : postgres-exporter metricName : queued_tasks targetValue : <MAX_TASK_THREADS> averageValue : <MAX_TASK_THREADS> スケールイン時の問題 このような設定ではスケールインのタイミングで弊害が生じます。例えば以下のように各Workerで実行中のタスクが一部終了しスケールインしたとします。その時まだ実行中のタスクは処理が中断されてしまいます。 そこでDeploymentに「terminationGracePeriodSeconds」を設定することでタスクの中断を回避しました。このパラメータはpreStopのタイムアウト時間を指定するものです。詳細に関しては以下のドキュメントをご参照ください。 kubernetes.io Workerの動きとしてはSIGTERMを受け取ると新たなタスクをキューから取得しない状態になります。また、動いているタスクは完了するまで動き続けます。よって、スケールイン時に動いているタスクが中断しないようにするには「terminationGracePeriodSeconds」をDigdag上で動作しうるすべてのタスクの中で最大の時間以上にします。タスクの中で最大の実行時間が90分以下の場合の設定例は次のとおりです。 terminationGracePeriodSeconds: 5400 そうすることで確実にタスクが終了してからノードがスケールインされます。 PrometheusAdapterの利用の注意点 Prometehus AdapterでCustom Metrics APIにAPIを登録するときに、Kubernetes Masterへの 6443 ポートのアクセス許可が必要となります。ファイアウォール等でアクセスを絞っている場合は注意が必要です。 github.com 運用上の工夫と注意点 実際に運用してみると工夫をしないと運用しづらい面や注意点などがあったので、それらについて紹介します。上記で紹介したDigdag APIを独立した構成にしたことも、実際に運用して得られた運用Tipsの1つです。 ノードの立ち上げの待ち時間が発生する 今回GKE Autopilotを利用しているため事前に必要な数だけノードの準備をしておくといったことができません。そのため、タスクのPod作成時に最大で1分ほどのノードの作成待ちが発生します。Webなどの場合HPAの閾値を緩めることで、早めにPodを用意するなどで問題を回避できます。しかし今回の場合タスクの開始時にPodが作成されるためその制御はできません。バッチ処理として利用しているため私達のユースケースにおいてはノードの起動時間は誤差の範囲であって問題とはなっていません。よって、もし数十秒・数秒といった起動時間が許容できない場合はAutopilot環境ではなくStandard環境を選ぶなどをしたほうが良いでしょう。 タスクはすべて冪等にする 各種タスクの実行はKubernetes Command Executorを利用して、毎回Podを作成していると説明しました。運用してみてわかったのですが、Kubenetes Command Executorを利用せずにタスクを実行する場合に比べてPodの起動が失敗するなどタスクの失敗頻度が高くなりました。 そこで、すべてのタスクを冪等にしておくことで安心してリトライ処理を行うことができます。また、Digdagでは、タスクごとにRetryの設定を入れることができるため、すべての処理にリトライ処理を入れることで安定したワークフローの実行ができるようになります。 docs.digdag.io 私達のチームではまだすべてのタスクが冪等になっているというわけではないため、やはりそこのリカバリ処理の運用コストが高くなっています。そのため、現在すべての処理の冪等化と自動リトライの導入を進めています。Digdagに限らず、バッチ処理では冪等化することによって安定性がかなり変わってくるので積極的に取り入れると良いでしょう。 たまにログが出ない Kubernetes Command Executorでは、処理の最後にDigdag UIなどからログが確認できるよう起動したPodからログを取得しています。しかしタスクのPodが何らかの原因で異常終了した場合、それらのログが取得できずにタスクが終了してしまいDigdag UIからログが確認できません。その場合は、タスクに紐づくPod Nameがタスク実行前に決定されるため、Cloud LoggingでPod Nameを指定してログを確認しています。実際以下のようにDigdag UIからPod Nameを確認できます。 commandStatus : cluster_name : ma-autopilot-stg executor_state : log_offset : 590 io_directory : .digdag/tmp/digdag-py-70356-1834671494158116161 pod_creation_timestamp : 1646198947 pod_name : digdag-pod-70356-0-280485a4-a27a-4e70-bb5c-c557ffc0b7f3 ただしDigdagでログを確認できることがベストであるため、これの回避方法が実装等できないかを検討しています。 終了したPodが消えない Kubernetes Command Executorでは、Podを作成しその中でタスクを実行すると説明しました。成功したPodはステータスがSuccessになりますが、いつまでたってもPodが消えずに残るということが発生しました。それにより、Podのために用意したIPを使い尽くし新たなPodが作成できないという事態が発生しました。そこで以下のようなスクリプトをCronJobで実行し、終了したPodを定期削除するようにしました。 apiVersion : batch/v1beta1 kind : CronJob metadata : name : pod-cleaner-cronjob spec : schedule : "*/30 * * * *" jobTemplate : spec : template : spec : serviceAccountName : pod-cleaner containers : - name : pod-cleaner image : YOU_SHOULD_OVERWRITE imagePullPolicy : Always command : [ "/bin/bash" , "-c" ] args : - | curl -s https://kubernetes.default.svc/api/v1/namespaces/digdag/pods?fieldSelector=status.phase=Succeeded \ --header "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" --insecure \ | jq -r --arg target_date `date +%s -d '30 minutes ago' ` \ '.items[] | { name: .metadata.name, finishedAt: .status.containerStatuses[0].state.terminated.finishedAt|fromdate} | select(.finishedAt < ($target_date|tonumber)) | .name' \ | xargs -i \ curl -X DELETE -s https://kubernetes.default.svc/api/v1/namespaces/digdag/pods/{} \ --header "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" --insecure \ | jq .metadata.name restartPolicy : OnFailure 上記スクリプトで削除対象Podを30分より前に絞っています。その理由としては、タスク成功後にDigdag Workerが成功したPodのステータスを確認し ログを取得する ためです。ステータス確認完了前にPodを消してしまうとタスクが失敗したとDigdagは判断してしまいます。よってそれを防ぐために確実にステータスチェックが終わっているであろう30分より前のPodに条件を絞っています。 ただし、終了した直後にPodがちゃんと消えてくれることがベストなため、以下のようなPRをDigdagに提案をしています。 github.com 今後の展望 最初に説明したように、Digdag以外のワークフローエンジンはすべて廃止しました。しかし、Digdag自体は複数のクラスターを運用しています。それらDigdagも今回作成したDigdagに処理をすべて統一させ運用負荷を下げたいと考えています。 また、Digdagはすごく素晴らしいツールでかなり愛用しています。ですがまだ改善の余地が残されているとも考えています。そこで、私達のチームとしても積極的にDigdagの発展に貢献できたらと考えています。 まとめ 今回、DigdagをGKE Autopilot環境に作成することで柔軟にスケールするバッチ処理基盤ができましたのでその紹介をしました。また、実際に運用してみて分かった注意点や運用Tipsについて紹介しました。 上記で挙げたように、まだまだ改善の余地は残っています。また、今回作った基盤上で動かすMAアプリケーションは他にもたくさんあります。興味があれば以下のリンクからご応募ください。 hrmos.co
はじめに こんにちは、技術本部SRE部ZOZOSREチームの堀口/柳田です。普段はZOZOTOWNのオンプレミスとクラウドの構築・運用に携わっています。 ZOZOTOWNではSQL Serverを中心とした各種DBMSが稼働しています。 その中で検索処理における参照に特化された役割を持つデータベース群をReadOnlyデータベース(以下、RODB)と呼んでいます。これらは日々増加するZOZOTOWNのトラフィックに耐えられるよう定期的にオンプレミスサーバを増台することでスケールしています。 これらのRODBは日々トラフィックの増減が激しいZOZOTOWNのサービスにおいて、オンデマンドでスケール可能なクラウド基盤上に構築した方が望ましいと判断し、クラウド化を実現しました。 本記事では、オンプレRODBをAWS RDS for SQL Server(以下、RDS)へクラウドリフトする中で解決すべき課題とそれをどのように解決したのかを紹介させて頂きます。 目次 はじめに 目次 クラウド化する上での課題 アクセス元のアプリケーションロジックを変更せずに実現する必要がある AWS Managed Microsoft ADの落とし穴 オンプレ⇔クラウド間の通信量を削減する必要がある Webサーバ上で動作するアプリケーションからの参照処理(select)における通信量削減 データ更新のためのデータレプリケーション(update/insert/delete)における通信量削減 クラウド側障害でもサービス影響を最小限に抑える必要がある マルチAZ マルチAZの特徴 マルチAZに関する時間測定 データ連携経路のAZ分散 コストを最小化する必要がある まとめ おわりに クラウド化する上での課題 RODBをクラウド化するにあたって以下の課題がありました。 アクセス元のアプリケーションロジックを変更せずに実現する必要がある オンプレ⇔クラウド間の通信量を削減する必要がある クラウド側の障害でもサービス影響を最小限に抑える必要がある コストを最小化する必要がある これらの課題を1つずつ説明していきます。 アクセス元のアプリケーションロジックを変更せずに実現する必要がある RODBのアクセス種類は2種類あります。 Webサーバ上で動作するアプリケーションからの参照処理(select) データ更新のためのデータレプリケーション(update/insert/delete) アプリケーションからの参照処理におけるRODBへ接続する際のDBログイン方式は、ユーザ名+パスワード指定による一般的なSQL Server認証方式ではなく、Windows認証方式となっています。 今回のクラウド化で置き換えるサービスはAWSのマネージド型データベースであるRDS for SQL Serverと決定していました。しかし、RDSはPaaSであるゆえに既存のRODBが所属するWindowsドメインに所属させることができません。 これではWindowsログオンユーザでDB接続するWindows認証方式が使用できないことになります。 RODBへのログイン方式を変更することは、Webサーバ上で動作するアプリケーションソースを書き換えることになります。これではオンプレRODB向けなのかクラウドRODB向けなのかをアプリケーション側に意識させる必要性が生まれてしまいます。 これはインフラとアプリケーションが密結合してしまい、本来目指すべき方向から外れてしまいます。ましてやアプリケーションソースをオンプレRODB向け/クラウドRODB向けと二重管理することは絶対に避けたいものです。 RDSへのログイン方法をWindows認証でログインさせる方法がないか模索したところ、AWS側に用意されていました。 「AWS Managed Microsoft AD」というマネージド型のディレクトリサービスにRDSを所属させ、それと既存のドメイン間で信頼関係を作成するというものです。 これによりRDSへのログイン時に既存ドメイン上のユーザがWindows認証によりSQL Serverにログインが可能となりました。 より詳細な情報は下記に記載されていますので興味のある方はこちらを参照してください。 docs.aws.amazon.com この仕組みによりアプリケーション側のログインロジックを変更することなく、オンプレDBとRDSへのログインを実現できました。 AWS Managed Microsoft ADの落とし穴 AWS Managed Microsoft ADについては当初知見がなく、構築時にハマった箇所を少し紹介します。 AD自身のアクセス制御を行うSecurityGroupについてです。AWS Managed Microsoft AD上にディレクトリを作成すると、AWSの命名規約に則ってSecurityGroupが自動的に生成されます。このSecurityGroupはAWSマネジメントコンソールから該当ディレクトリを参照しても変更できません。その存在すら見当たりません。SecurityGroupを変更したい場合は、自分で該当SecurityGroupをEC2サービス側から探し出し、変更する必要があります。 また、上記SecurityGroupを発見できたとしても、信頼関係を結ぶ他のドメインコントローラーとの通信で必要なポートが不明瞭です。AWSのマニュアルで指定されたポートだけでは不十分で、実際には他のポートも開ける必要があります。不足分はトライ&エラーで調査しました。 オンプレ⇔クラウド間の通信量を削減する必要がある ZOZOTOWNではオンプレミスサーバ群が配置されているデータセンターからAWSサービスまでの通信はAWS Direct Connect(以下、DX)を利用しております。 今回も既設のDX回線を利用するのですが、RODBへのアクセスによりネットワーク帯域を枯渇させることがないように、また枯渇させないまでも通信量の増加を最小限にするよう配慮する必要がありました。 繰り返しになりますが、RODBのアクセス種類は2種類あります。 Webサーバ上で動作するアプリケーションからの参照処理(select) データ更新のためのデータレプリケーション(update/insert/delete) Webサーバ上で動作するアプリケーションからの参照処理(select)における通信量削減 ZOZOTOWNのWebサーバ群はオンプレミス、AWSの両方で稼働しています。 Webサーバ群はオンプレミス上で稼働しておりそこからRDSへアクセスすると、発行されたクエリ自身とその結果セットの通り道はDXとなります。 これを削減するためRDSへアクセスするWebサーバ群はEC2で稼働するWebサーバのみに限定しました。 オンプレミスのWebサーバからのアクセスはオンプレRODBにするといった棲み分けを行うことでオンプレ⇔RDS間の通信をゼロにしました。 データ更新のためのデータレプリケーション(update/insert/delete)における通信量削減 RODBのデータはSQL Serverのトランザクションレプリケーション機能により他のデータベース群より取得し最新化しています。 RODBにデータを供給するデータベースはオンプレミスで稼働しているため、RDSへ同期するデータはDXを通ってくることになります。 そして、RODBはRDSの台数の増減によりスケールさせる運用を考えているため、RDSが増えていくごとにDXの通信が増えてしまうことになります。 この対策として、オンプレミスのデータベースとRDSとの間に中継用のSQL Serverを立てることによりRDS台数の増減に影響されず、通信量を一定に担保するような構成としました。なお、中継用のSQL ServerはEC2上でSQL Serverを稼働させるIaaS型としました。 クラウド側障害でもサービス影響を最小限に抑える必要がある 今回のRDSはAZ障害時に被害を最小化するためいくつかの対策をしています。 マルチAZ データ連携経路のAZ分散 マルチAZ RDSの機能であるマルチAZ方式を採用しています。マルチAZ方式を採用した理由は以下のとおりです。 人間(運用担当者)よりも迅速に切り替えるため クライアント側から見た場合に透過的に切り替えるため マルチAZの特徴 RDS for SQL ServerにおけるマルチAZの挙動は以下のとおりです。 Active/Standby構成でのフェイルオーバー型 フェイルオーバーが発動してもRDSのエンドポイント名は変わらない IPアドレスは変更されるため一時的に接続断が発生する フェイルオーバー発動のトリガーはRDS側で判断する Active/Standby構成でのフェイルオーバー型について説明します。通常時はActive側のみでサービスしStandby側へのアクセスはできないが、障害時にはStandbyがActiveに切り替わりサービスを継続するというものです。Standby側はデータベースとしてのサービスは行えません。それにも関わらずActiveと同額のランニングコストが発生することは大きなネックとなります。 Active側の障害によりフェイルオーバーが発生してもクライアントが接続時に指定するエンドポイント名(DNS名)は変らずにアクセスできるので、切り替わり時にクライアント側の操作は不要となります。 ただしエンドポイント名で解決されるIPアドレスは変更されるため、クライアント側でIPアドレスを指定したDB接続をすると、接続先の切り替え作業が必要となります。また切り替えの際に発行中のクエリはロールバックされRDS内でのリトライは行われないため、必要な場合はクライアント側でエラーとなったクエリを再実行する必要があります。 フェイルオーバー発動条件は、Active側のホスト異常やネットワーク異常などRDS側のルールによって定められています。 詳しくはこちらを参照して下さい。 docs.aws.amazon.com マルチAZに関する時間測定 弊社環境で計測したマルチAZに関わる作業/処理時間について以下にまとめます。 処理 時間 手動フェイルオーバー 1分以内 オンライン中のSingleAZのRDSをMultiAZに変更 30分 オンライン中のMultiAZのRDSをSingleAZに変更 10分 ただしSingleAZ⇔MultiAZに変更する作業については、同時に実施するRDSの数、タイミングによっては上記の3倍程度の時間がかかったこともありました。 データ連携経路のAZ分散 過去に発生したアベイラビリティゾーン(以下、AZ)障害対策として、特定のAZで何かしらの障害が発生してもサービス継続可能にするため、データ連携経路を複数のAZに分散しています。 上図の通り、AZ#1で障害が発生してAWSサービスがダウンしても、AZ#2でサービスを継続可能な仕組みとしています。 コストを最小化する必要がある RDSに関するAWSのコストを考えるために必要な要素は次のとおりです。 要素 内容 インスタンスタイプ キャパシティが大きい程コスト増 ストレージ キャパシティとIOPSが大きいほどコスト増 マルチAZ シングルAZと比較してマルチAZはほぼ倍額 バックアップ バックアップ対象サイズが大きいほどコスト増 ※マルチAZの場合は最低1世代のバックアップが必要 弊社の環境では、セール時などRODBへのアクセス増が見込まれる時に、インスタンスタイプのキャパシティを事前に増加させマルチAZ化を行います。また、アクセス減となる際には、インスタンスタイプのキャパシティを最小化しマルチAZをシングルAZ化する等の作業をすることでコストの最小化を行っております。 なお上記変更は、IaCによりコード化されたyamlを変更することで管理しており、人的な作業コストの削減も実現しております。 まとめ 本記事では、ZOZOTOWNで本番稼働するReadonlyデータベースをクラウドリフトした際の課題と対策を中心に事例を紹介しました。 弊社のReadonlyデータベースはその性質上、柔軟なスケールが可能なクラウドに適しているためクラウド化を行いました。しかし、全てをクラウド化するということを推奨するものではなく、それをクラウド化する価値があるかどうかはしっかりと判断するべきだと考えています。 おわりに ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
こんにちは。ブランドソリューション開発部プロダクト開発ブロックの岡元です。普段は Fulfillment by ZOZO とZOZOMOのブランド実店舗の在庫確認・在庫取り置きサービスの開発、保守をしています。 本記事では、ブランド実店舗の在庫確認・在庫取り置きサービスで実装したCQRSアーキテクチャについて紹介させていただきます。 CQRSの実装においては、データベース(以下、DB)分割まで行い、コマンド側DBにはAmazon DynamoDB(以下、DynamoDB)、クエリ側DBにはAmazon Aurora MySQL(以下、Aurora MySQL)を用いています。また、コマンド側DBとクエリ側DBの橋渡しを担うメッセージングにおいてはOutboxパターンと変更データキャプチャを用いました。DBとメッセージングシステムへの二重書き込みを避けることで障害などのタイミングで顕在化する潜在的なデータ不整合を回避しています。本記事がCQRS実装の一事例として参考になれば幸いです。 目次 目次 ブランド実店舗の在庫確認・在庫取り置きサービスとは CQRSの概要 店舗在庫連携サービスにおけるCQRSの利点 DBを分割することによる柔軟なクエリの実現、処理効率の向上 モデルを分割することによるモデルの保守性、処理効率の向上 店舗在庫連携サービスにおけるCQRSの実装 CQRSにおけるコマンド側の構成概要 メッセージングのためのDynamoDB DynamoDBでOutboxパターンを実現する Outboxパターンとは 店舗在庫連携サービスにおけるDynamoDBを用いたOutboxパターンの実現方法 変更データキャプチャを用い、メッセージを送出する 変更データキャプチャとは 店舗在庫連携サービスにおける変更データキャプチャの利用方法 ドメインイベントのスキーマ定義にProtocol Buffersを利用する まとめ ブランド実店舗の在庫確認・在庫取り置きサービスとは ブランド実店舗の在庫確認・在庫取り置きサービス(以下、店舗在庫連携サービス)は、2021年11月に発表したOMOプラットフォーム「ZOZOMO」が展開するサービスの1つです。お客様は、ZOZOTOWN上でブランド実店舗の在庫を確認できることに加え、在庫の取り置きもできます。ZOZOMOのサービスに詳細ついては、こちらの プレスリリース で紹介しております。 CQRSの概要 店舗在庫連携サービスでは、アーキテクチャにCQRSを採用しました。CQRSは、コマンド(書き込み)とクエリ(読み取り)の操作を分離するパターンです。 CQRSを用いることで、コマンド側のモデルとクエリ側のモデルを分けて管理できます。複雑なビジネスロジックが必要とされることの多い書き込み操作を読み取り操作の関心事から分離することで、それぞれのモデルを比較的シンプルに保つことができます。 コマンドとクエリで共通のDBを用いることもできますが、別々のDBを用いることも可能です。コマンドとクエリでDBを分割した場合はコマンド側のDBからクエリ側のDBに更新を同期する必要があり、システム全体としてはより複雑になります。 CQRSパターンの詳細については、Microsoft社の以下記事で詳しく説明されているので、そちらを参照してください。 docs.microsoft.com 店舗在庫連携サービスにおけるCQRSの利点 店舗在庫連携サービスではドメイン駆動設計(以下、DDD)を参考に開発しており、ビジネスロジックを反映したドメインモデルをなるべくシンプルに保つという思想で設計を進めました。また、DDDにおける集約の状態の保存にDynamoDBを用いています。DynamoDBを用いている理由についてはメッセージングが関係しています。詳細は後述します。 また、店舗在庫連携サービスは既存のZOZOTOWN内に組み込む形でサービスを提供しており、以下のように利用目的が異なる3種類のサービスから利用される予定がありました。そのため、将来的にも多種多様なクエリを処理できる必要があると予想されました。 ZOZOTOWN - お客様が、実店舗の在庫確認、在庫の取り置き依頼などを行う FAANS - 店舗スタッフが、確保が必要な在庫などを確認する ZOZOTOWNのバックオフィスシステム - カスタマーサポートが、お客様や店舗スタッフからの問い合わせを受け、取り置き依頼状況の確認などを行う これを踏まえ、店舗在庫連携サービスでは、主に以下の点が利点になると考えCQRSを採用しました。 DBを分割することによる柔軟なクエリの実現、処理効率の向上 モデルを分割することによるモデルの保守性、処理効率の向上 DBを分割することによる柔軟なクエリの実現、処理効率の向上 前述の通り、店舗在庫連携サービスでは、DDDにおける集約の状態の保存にDynamoDBを用いています。 しかし、多種多様で複雑なクエリを処理するのが困難なDynamoDBでこのようなクエリを実現しようとすると非効率な実装をせざるを得ない懸念がありました。同様に、モデルの観点からも、集約を処理の基本単位とするドメインモデルで集約をまたぐクエリを実現しようとすると非効率な実装となる懸念がありました。 CQRSを用いることでコマンド側とクエリ側のDBを分割し、それぞれ別々のDBを用いることができるようになります。クエリ側のDBにはAurora MySQLを用いることで、柔軟なクエリを実現できるだけでなく効率的にクエリを処理できると考えました。 モデルを分割することによるモデルの保守性、処理効率の向上 前述の通り、店舗在庫連携サービスでは、ビジネスロジックを反映したドメインモデルをなるべくシンプルに保つという思想で設計を進めました。 しかし、多種多様な要件を持つクエリの関心事にドメインモデルが巻き込まれることで、必要以上にモデルが複雑となる恐れがありました。 CQRSを用いることで、ドメインモデルからクエリの関心事を分離し、コマンドとクエリでそれぞれ別々のモデルを作成できます。モデルを分割することで、それぞれ以下のような利点があると考えました。 ドメインモデル(コマンド側)- ビジネスロジックに集中することで、モデルをより洗練させることができる クエリモデル(クエリ側)- シンプルなモデルになり、効率的に処理を行うことができる 店舗在庫連携サービスにおけるCQRSの実装 以下に店舗在庫連携サービスの構成図を示します。 上図の通り、店舗在庫連携サービスでは、コマンド側DBとクエリ側DBにそれぞれDynamoDBとAurora MySQLを用いています。DynamoDBからAurora MySQLへのデータの同期には Amazon Kinesis Data Streams for DynamoDB (以下、Kinesis Data Streams for DynamoDB)を用いました。 各モデルについては、コマンド側のドメインモデルはクエリの関心事を気にせず実装できたことで、より洗練されたモデルにできました。クエリ側のモデルもDBにAurora MySQLを用い、リクエストに対応するSQLをほぼそのまま実行し、結果を返すような構成にすることでよりシンプルで効率的なモデルになりました。 また、今回、非同期的な処理ではDDDにおけるドメインイベントを参考にしました。ドメインイベントを用いることで、ドメイン内で発生する何かの出来事についても重要なドメインモデルの一部として扱うことができました。ドメインイベントはKinesis Data Streams for DynamoDBによって送出され、クエリ側DBの更新に用いられています。それだけでなく、メール送信や外部サービスへの連携などのドメインイベントを契機に発生する処理の実行にも利用できました。 CQRSにおけるコマンド側の構成概要 以降では、店舗在庫連携サービスの開発で特に工夫したコマンド側の構成について紹介させていただきます。 メッセージングのためのDynamoDB 店舗在庫連携サービスで行う処理には以下のようなものがあります。 在庫の取り置きがされたとき お客様へ、取り置き依頼を受け付けた旨のメールを送信する FAANSのシステムへ、店舗在庫の確保を依頼する旨の通知を送る 外部サービスへ、取り置き依頼された商品の在庫情報を連携する 店舗で商品の在庫が確保されたとき お客様へ、来店の準備が完了した旨のメールを送信する これらはドメイン内で発生する出来事であるドメインイベントが契機となり、非同期的に実行される処理です。こういったイベントの発生を元に実行される処理は、店舗在庫連携サービスにおいて多く存在しました。 そこで、DynamoDBの変更データキャプチャ機能(今回はKinesis Data Streams for DynamoDB)と トランザクション書き込み を利用したOutboxパターンによってメッセージングを実現しました。 コマンド側DBにDynamoDBを用いたのは、以下のような理由からです。 変更データキャプチャが第一級のインタフェースとしてサポートされており、容易に利用できる 複数テーブルをまたぐトランザクション書き込みをサポートしているため、Outboxパターンと変更データキャプチャによるメッセージングを容易に実現できる 変更データキャプチャやOutboxパターンは他のDBでも実現できます。しかし、変更データキャプチャを第一級のインタフェースとしてサポートしないDBでは、効率的な変更データキャプチャを利用するために Debezium などのツールや追加のサービスを導入する必要があります。このようなツールやサービスの導入により追加のメンテナンスコストが発生してしまう懸念があったため、店舗在庫連携サービスではDynamoDBを利用しました。 DynamoDBでOutboxパターンを実現する Outboxパターンとは Outboxパターン は分散トランザクションをサポートしないDB、メッセージングシステムにおいて、データの更新とメッセージの書き込みをアトミックに行うためのパターンです。実際には後述する変更データキャプチャを組み合わせて利用します。 分散トランザクションをサポートしないDBとメッセージングシステムに変更を加える場合、それぞれに書き込みを実行する 二重書き込み(Dual Writes) を利用する方法が考えられます。しかし、二重書き込みではどちらかの書き込みは成功し、もう一方の書き込みは失敗するような場合や障害が発生するタイミングなどが原因でデータの不整合が生じます。 二重書き込みの問題点については以下の記事が参考になります。 thorben-janssen.com 一方、分散トランザクションを用いる場合は、DBとメッセージングシステム両方で分散トランザクションのサポートが必要となるため、利用できる技術が限定されるという問題があります。実際、本記事の執筆時点でDynamoDBやKinesis Data Streams、Apache Kafkaも分散トランザクションをサポートしていません。 Outboxパターンでは、DBとメッセージングシステムへの分散トランザクションを用いた書き込みは行わず、DB上に追加で作成したOutboxテーブルにメッセージの内容を書き込みます。Outboxテーブルへの書き込みにはDBがサポートするローカルトランザクションを用いることができます。 Outboxテーブルへ書き込まれたメッセージは、後述の変更データキャプチャを利用することでメッセージングシステムへ書き込まれます。こうすることで、DBへの書き込みとメッセージングシステムへのメッセージの書き込みをアトミックに実行できます。 Outboxパターンと変更データキャプチャはSagaパターンにおいても、安全なメッセージングを実現するための重要な構成要素として機能するようです。Outboxパターンと変更データキャプチャをSagaパターンに適用する話は以下の記事が参考になります。こちらの記事でも二重書き込みについて触れられています。 www.infoq.com 店舗在庫連携サービスにおけるDynamoDBを用いたOutboxパターンの実現方法 店舗在庫連携サービスではDynamoDB上に以下の2種類のテーブルを用意し、DynamoDBのトランザクションを用いて書き込みを行っています。 集約ステートテーブル ドメインイベントテーブル 集約の状態を保存する 集約の状態が変更する際に発生したドメインイベントを保存する レコードにはバージョン(数値)を持たせておき、集約の状態が変わるたびに1インクリメントする レコードには保存する集約と同じバージョンをもたせておく バージョンはDynamoDBの条件付き書き込みによって楽観的ロックを実現するために利用する バージョンはコンシューマの重複除去、順序のチェックに利用する(詳細は後述) ドメインイベントテーブルに加えられた変更は、Kinesis Data Streams for DynamoDBによってメッセージとして送出されます。こうすることで、分散トランザクションを利用すること無く、集約の状態の更新とメッセージングシステムへのメッセージの書き込みをアトミックに実行できます。 また、DynamoDBを用いた楽観的ロックの実装方法については以下のAWSのドキュメントが参考になります。 docs.aws.amazon.com 店舗在庫連携サービスではDynamoDBMapperを使用していませんが、同等の実装を 低レベルインタフェース を用いて行っています。 変更データキャプチャを用い、メッセージを送出する 変更データキャプチャとは 変更データキャプチャ(Change Data Capture、CDC) は、DBの変更を追跡し処理を行うためのパターンです。前述のOutboxパターンと組み合わせて利用することでOutboxテーブルの内容をメッセージングシステムに書き込むことができます。 CDCの種類には様々なものがありますが、ここではQuery-based CDCとLog-based CDCの特徴を取り上げます。それぞれの特徴は以下のとおりです。 Query-based CDC(Timestamp-based CDC、 Polling publisherパターン ) DBテーブルの内容を定期的にポーリングし、変更が追加されていれば処理を実行する ポーリングを行うためDBに余計な負荷をかける場合がある DBテーブルのタイムスタンプを見て変更を追跡する場合、範囲クエリのサポートが必要になる Log-based CDC( Transaction log tailingパターン ) DBのトランザクションログを追跡し処理を実行する ポーリング、範囲クエリのサポートが必要ない トランザクションログを利用するのでDB固有のソリューションが必要になる CDCの種類とそれぞれの特徴については以下の記事が参考になります。 datacater.io 店舗在庫連携サービスにおける変更データキャプチャの利用方法 店舗在庫連携では、DynamoDBが提供しているKinesis Data Streams for DynamoDBという機能を用いています。前述の分類に当てはめると、Log-based CDCのような特徴を持った機能です。 DynamoDBがサポートするCDCの機能には、DynamoDB StreamsとKinesis Data Streams for DynamoDBの2つがあります。それぞれのメリット、デメリットの概要は以下のとおりです。 DynamoDB Streams メリット:レコードが更新順に現れ、順序が保証される デメリット:許容されるコンシューマ数が少ない(1シャードあたり2つまで) Kinesis Data Streams for DynamoDB メリット:許容されるコンシューマ数が多いなど拡張性が高い デメリット:レコードが変更順に現れない その他の特徴については以下の記事に記載されています。 docs.aws.amazon.com このようなメリットとデメリットがありますが、店舗在庫連携サービスでは、拡張性の高さからKinesis Data Streams for DynamoDBを採用しています。ただし、レコードの順序保証がされないデメリットについての対策が必要です。そこで、店舗在庫連携サービスではドメインイベントテーブルのレコードに1ずつ増加するバージョンを含めるようにしています。メッセージの処理順序が重要なタスクではコンシューマ側でレコードの順序が崩れていないか(取得されたレコードのバージョンが1ずつ増加しているか)のチェックを行っています。また、同様にメッセージの重複除去もバージョンを用いて行っています。 店舗在庫連携サービスのユースケースでは、同じ集約に対して短時間にリクエストが集中することはまれなためレコードの順序の崩れが起きる可能性は低いと考えています。そのため、万が一順序の崩れが発生した場合は、アラートを発火しリカバリを行うような運用を考えています。 ドメインイベントのスキーマ定義にProtocol Buffersを利用する 店舗在庫連携サービスでは、メッセージ(ドメインイベント)のスキーマの定義にProtocol Buffersを用いています。これは、DDDの「公表された言語」を参考にしました。店舗在庫連携サービス内で発生したイベントに関心のある他サービスや今後の機能拡張もこの公表された言語を利用し、ドメインイベントを介することで、店舗在庫連携サービスとは独立して開発できると考えています。 実際の定義は以下のようなものです。 syntax = "proto3" ; package order.events; import "google/protobuf/timestamp.proto" ; message OrderEvent { string order_id = 1 ; int32 version = 2 ; google.protobuf.Timestamp created_at = 3 ; oneof body { OrderAccepted order_accepted = 4 ; OrderCanceled order_canceled = 5 ; } } message OrderAccepted { string order_id = 1 ; string order_status = 2 ; int32 goods_id = 3 ; } まとめ 本記事では、店舗在庫連携サービスで実装したDynamoDBを用いたCQRSの実装について紹介しました。DB分割したCQRSの実装で肝となるメッセージングにはOutboxパターンとCDCを利用することで、障害などのタイミングで顕在化する潜在的な不整合を回避できました。 今回、非同期的な処理ではドメインイベントの考えを取り入れたことで、モデルにより豊かな表現を取り込むことができました。それだけでなく、システムを疎結合に保つことができたおかげで機能の追加についても比較的容易に対応できました。噂通りそれなしでは生きられなくなるほどの強力なツールだと実感しています。 ブランドソリューション開発部では仲間を募集しています。ご興味のある方は、以下のリンクからご応募ください! hrmos.co
はじめに こんにちはZOZOデータサイエンス部MLOpsブロック松岡です。 本記事では先日リリースされたGCP( Google Cloud Platform ) Cloud Composer の最新バージョンCloud Composer 2について紹介します。 ZOZOTOWNでは、多種多様な商品が毎日新たに出品されています。現在MLOpsブロックでは、機械学習で商品情報の登録を補佐するシステムを開発しています。 このシステムでは商品情報を保存するデータベースへ大量の書き込み処理が発生します。このアクセスによる負荷が日常業務に影響を及ぼすリスクを最小限に抑えるため、推論処理は夜間に行います。夜間に処理を完了させるには強力なマシンリソースを使用する必要があります。コストの観点から処理が行われていない時間はマシンリソースを使用停止する必要もあります。また、人手を介さずに安定して稼働出来る仕組みも求められます。 上記の要件を満たすためにワークフローエンジンを使用することになりました。 MLOpsブロックでは当初 Vertex AI Pipelines を検討しました。 しかし、 類似アイテム検索機能にCloud Composerを採用していた ことや、スケジューリング機能やリトライ処理が充実していることからCloud Composerについても検討することとしました。 類似アイテム検索ではCloud Composer 1を使用していたため、そのバージョンアップにおける技術調査を兼ねて、Cloud Composer 2における変更点について調査しました。併せて Apache Airflow (以下Airflowと記述)2についても調査しています。 目次 はじめに 目次 Airflowはワークフローエンジン ワークフローエンジンを使うメリット Airflowの強み ワークフローの定義にPythonを使用 多種多様なOperator スケジューリング機能を備えている ワークフローの流れ Cloud Composer 2について 柔軟なマシンスペックの指定 より細かいマシンスペックを指定可能 ワーカーの水平スケールが可能 Airflow 2での強化点 スケジューラーの強化 スケジューラーのパフォーマンス改善 スケジューラーがHA(High Availability)構成に対応 更新されたAIRFLOW UI DAGs一覧画面 DAG詳細画面 シンプルなDAGの記法に対応 RBAC UIに標準対応 デフォルトで作成されたロールの設定 カスタムロールの作成 RBAC UIはAIRFLOW UI上でのみ有効 最後に Airflowはワークフローエンジン Cloud ComposerはGCP上にてAirflowをマネージドに提供するサービスです。Cloud Composerについて説明する前にまずAirflowについて簡単に紹介します。 ワークフローエンジンを使うメリット Airflowは様々な処理を行うワークフローをスケジュールして実行出来るワークフローエンジンです。例えばデータを推論するワークフローを次のような複数のタスクに分けて協調動作させることができます。 外部APIから必要なデータを取得するタスク 取得したデータに対して分類ごとに並列で推論処理を行うタスク 推論の結果を出力するタスク 複数のタスクが依存関係に基づく順序で実行され、各タスクを異なるワーカーインスタンスが処理し、エラー時にはリトライさせることが出来ます。これによりネットワークエラーのような不測の事態が発生した場合でも、人手を介さずにワークフローを復旧させることが出来ます。 Airflowの強み ワークフローの定義にPythonを使用 AirflowはPythonにより実装されており、ワークフローの定義にもPythonを使用します。そのためXMLなどの設定ファイルでワークフローを記述する方式に比べてプログラマーにとって理解しやすく感じます。 多種多様なOperator タスクはOperatorをインスタンス化することで定義します。Airflowには 標準的なOperator に加え GKEインスタンスを起動するOperator など多様なOperatorがすでに用意されていることも魅力です。適切なOperatorを使用することでタスクを簡単に記載できます。 スケジューリング機能を備えている Airflow自身にスケジューリング機能を備えており、特定の時間や一定時間ごとにワークフローを自動実行することが出来ます。 ワークフローの流れ ワークフローはタスクを組み合わせてDAG(有向非巡回グラフ)として定義します。実行可能となったタスクはスケジューラーによりワーカーと呼ばれる実行用のマシンインスタンスに割り当てられます。ワーカーがタスクを完了するとDAGに基づいて次のタスクが実行可能となります。実行中のタスクと実行可能なタスクが全て無くなればDAGが終了します。 Cloud Composer 2について Cloud ComposerはAirflowをGCP上で実行するマネージドなサービスです。詳細な アーキテクチャ はCloud Composerのバージョンによって異なりますが、どちらも大部分はGKE( Google Kubernetes Engine )上で動作しています。 Cloud Composer 2では更にGKEに寄ったアーキテクチャとなっています。例えばAirflow Web Serverは GAE(Google App Engine) からGKE上のDeployment上で動作するよう変更されています。 現在はCloud Composer 1とCloud Composer 2が提供されています。Cloud Composer 2になっての変更点は Cloud Composer のバージョニングの概要 に記載されていますが、ここではその中でも特に便利に感じた部分を紹介します。 柔軟なマシンスペックの指定 Cloud Composer 2では実行環境がより柔軟に指定出来るようになりました。 より細かいマシンスペックを指定可能 Cloud Composer 1のGKE環境は GKE Standard 上に構築されていました。そのため、 Kubernetes のNodeを実行するvCPUやメモリーを予め決められたマシンタイプの中から選ぶ必要がありました。 Cloud Composer 2のGKE環境は GKE Autopilot 上に構築されています。スケジューラー/ウェブサーバー/ワーカーのPodsで使うvCPU、メモリ、ストレージをそれぞれ個別に指定出来るようになりました。 Cloud Composer 1では環境構築後は変更不可であったワーカー、管理用Webサーバー、データベースのマシンスペックも後から変更出来るようになりました。このため環境構築時に将来のスケーリングについて正確に見積もる必要がなくなります。 また、ワークフローの性質に基づいて次のような運用も可能です。 安定稼働しているワークフローではウェブサーバーの性能を落とし、緊急対応時のみウェブサーバーの性能を上げる DAGは単純だが個別の処理が重たい場合、スケジューラーの性能を落としてワーカーの性能を上げる ワーカーの水平スケールが可能 ワーカー数を負荷に応じて自動でスケールさせられるようになりました。特別な操作や設定は不要で、必要な性能に応じて性能を保ったまま低負荷時のコストを削減できます。 実際にCloud Composer 2の環境を用意して水平スケールを試してみます。Cloud Composer 2の水平スケールは Custom Metrics - Stackdriver Adapter を用いて得られる、未割り当てタスクと現在のワーカー状況を指標として使用します。 未割り当てタスク数とワーカーにより実行可能なタスク数が一致しなくなると 次のようにスケーリングが実行されます。 cluster-autoscaler と node-auto-provisioning によりノード数とノードサイズをスケーリング HPA(HorizontalPodAutoscaler) によってPods数をスケーリング 今回はワーカーに0.5個のvCPU、1.875GBメモリ、1GBストレージを使用します。ワーカーの自動スケーリングは、ワーカーの最小数を1、最大数が3で試します。 Cloud Composer 2ではワーカー1vCPUあたり デフォルトで12個のタスクを同時に実行します 。これではワーカーへの割当が0.5vCPUの場合でさえ、6タスクまで同時に実行可能となり、なかなかスケーリングが発生しません。 そこで1vCPUあたりの同時実行タスク数を減らしてスケーリングが起こりやすくします。この設定は「AIRFLOW構成のオーバーライド」タブから celery の worker_concurrency を書き換えることで変更ができます。 値に 1 を設定することでそれぞれのワーカーは一度に1つのタスクしか処理しなくなり、ワーカーが枯渇しやすくなります。 実行するDAGは次のとおりです。タスクを12並列で実行するDAGを用意しました。スケーリングは時間がかかるためスケール前にDAGが完了しないよう各タスクで60秒待機します。 import logging import time from airflow.operators.python_operator import PythonOperator from airflow.utils.dates import days_ago from airflow import models default_dag_args = { "start_date" : days_ago( 2 )} from airflow.operators.dummy_operator import DummyOperator from airflow.operators.python_operator import PythonOperator with models.DAG( "parallel_tasks" , default_args=default_dag_args, schedule_interval= None , ) as dag: def task_method (i: int , **context): time.sleep( 60 ) logging.info(f "Hello: {i} Task" ) start_task = DummyOperator(task_id= "start" , dag=dag) for i in range ( 0 , 12 ): task = PythonOperator( task_id=f "hello{i}" , python_callable=task_method, provide_context= True , dag=dag, op_kwargs={ "i" : i}, ) start_task >> task DAGのGraphは次のとおりです。 上記のDAGを実行すると、最初はワーカーが1つしかないので1タスクずつ実行されます。 しばらくするとワーカーがスケーリングされ3つのタスクを同時実行するようになります。 kubectlの kubectl get -w pods --all-namespaces を実行して airflow-worker で始まるワーカーPodsが増えるのを確認できます。 実行前はワーカーのPodsは1つだけです。 未割り当てのタスクが増えるとともにPodsも増加します。 タスクが消化され未割り当てのタスクが減ると、次第に過剰となったワーカーPodsは破棄されます。 Cloud Composerで測定する環境を選びモニタリングでアクティブワーカー数を見て、ワーカーがオートスケールされたことを時系列で確認することも出来ます。 注意点として、ワーカーの立ち上がりと終了には時間がかかります。 今回の検証ではPodsが立ち上がって実際にタスクが振られるまでに3分程度かかりました。試しに上記のDAGの内容を書き換え、各タスクの待ち時間を60秒から10秒に短縮して試してみました。この場合オートスケールは行われますが、オートスケールされたワーカーへタスクが割り振られる前にDAG全体が終了してしまいました。これではオートスケールの恩恵を受けられず、料金だけ掛かってしまうことになります。オートスケールが効率よく働くようにするには同時実行するタスク数が短時間で増減しないようにDAGを組むと良さそうです。 Airflow 2での強化点 Cloud Composer 2へ移行する時に考慮すべき点はCloud Composer 2がAirflow 2しかサポートしていないことです。Cloud Composer1は当初Airflow 2系をサポートしていなかったので、Cloud Composer 2へ移行するにはAirflow 2への移行も必要となる場合が多いと思われます。 Airflow2への移行コストを理由にCloud Composer 2の移行を悩まれている人、移行コストを払ってでもAirflow2に移行したくなるAirflow2の改善点をいくつか紹介します。 スケジューラーの強化 Airflowのスケジューラは、DAGを解析して実行可能なタスクをワーカーに割り当てます。Airflow2になってからこのスケジューラが大幅に強化されています。 スケジューラーのパフォーマンス改善 Airflow1ではスケジューラーが一定時間ごとに未割り当ての実行可能なタスクを探しワーカーを割り当てており、タスクが終了後次のタスクにワーカーが割り当てられるまで一定の待ち時間が発生していました。タスクを分割するほどワーカーの割り当て待ち時間が増えるため、タスクの粒度をあまり細かくできませんでした。 Airflow2ではワーカーがタスクを終了時に後続の実行可能なタスクの存在を確認するようになりました。実行可能なタスクがあった場合 mini scheduling でワーカーが自身をそのまま即時スケジューリングします。 これによりワーカーがスケジュールされるのを待つ必要がなくなり、ワーカーはすでにDAGを解析済みであることから再解析も不要となるため後続タスクを速やかに実行できます。 実際に、どれくらいスケジューラーのパフォーマンスが向上されているかを調べてみます。Cloud Composerのバージョンにより使用するマシン性能の指定方法が変わっているので可能な限り性能を合わせました。Cloud Composer 1ではノード数3、マシンタイプはn1-standard-1を指定します。Cloud Compsoer 2ではマシンスペックをスケジューラー、ウェブサーバー、ワーカーの性能をそれぞれで選べるのでn1-standardに合わせて1vCPU、メモリ3.75GBとします。ワーカーの最大数は1としてオートスケールによるメリットが発生しないようにしています。 実行するDAGは次のとおり、ログを出力するだけの簡単なタスクを10回繰り返します。 import logging from airflow import models from airflow.utils.dates import days_ago from airflow.operators.dummy_operator import DummyOperator from airflow.operators.python_operator import PythonOperator default_dag_args = { "start_date" : days_ago( 2 )} with models.DAG( "serial_tasks" , default_args=default_dag_args, schedule_interval= None , ) as dag: def task_method (i: int , **context): logging.info(f "Hello: {i} Task" ) start_task = DummyOperator(task_id= "start" , dag=dag) latest_task = start_task for i in range ( 0 , 10 ): task = PythonOperator( task_id=f "hello{i}" , python_callable=task_method, provide_context= True , dag=dag, op_kwargs={ "i" : i}, ) latest_task >> task latest_task = task 測定は5回繰り返して、平均と最速、最遅のデータを取得しました。 環境 最速 平均 最遅 Cloud Composer1.17.7 Airflow1.10.15 03:46 04:10 04:47 Cloud Composer 2.0.0 Airflow2.1.4 00:28 00:31 00:35 Cloud Composer 1とCloud Composer 2の処理時間の差(少ないほうが高速) 約8倍も高速にDAGを完了することが出来ました。今回の例では3分半程度の差ですが、タスクの数が増えるほどこの差は比例して広がっていくことになります。 例えばタスク数を20に増やすと7分ほどの差が付きました。 環境 最速 平均 最遅 Cloud Composer1.17.7 Airflow1.10.15 07:39 08:08 08:29 Cloud Composer 2.0.0 Airflow2.1.4 00:56 00:59 01:08 スケジューラーがHA(High Availability)構成に対応 Airflow2ではスケジューラーを複数起動して、HA構成を取ることが出来るようになりました。各スケジューラーはデータベースのロック機能を使って作業を同期しているため、スケジューラ同士は直接通信せず独立して動作します。このためスケジューラーの1つが障害をおこしても、別スケジューラーへは影響なく、別スケジューラーにより作業を継続できます。これにより可用性を向上させられます。 実際にスケジューラーをクラッシュさせてHA構成が有効に働くか試してみます。10秒待つタスクを20回繰り返すワークフローを用意しました。 import logging import time from airflow.utils.dates import days_ago from airflow.operators.python_operator import PythonOperator from airflow import models default_dag_args = { "start_date" : days_ago( 2 )} from airflow.operators.dummy_operator import DummyOperator from airflow.operators.python_operator import PythonOperator from airflow.operators.dummy_operator import DummyOperator from airflow.operators.python_operator import PythonOperator with models.DAG( "serial_wait_tasks" , default_args=default_dag_args, schedule_interval= None , ) as dag: def task_method (i: int , **context): time.sleep( 10 ) logging.info(f "Hello: {i} Task" ) start_task = DummyOperator(task_id= "start" , dag=dag) latest_task = start_task for i in range ( 0 , 20 ): task = PythonOperator( task_id=f "hello{i}" , python_callable=task_method, provide_context= True , dag=dag, op_kwargs={ "i" : i}, ) latest_task >> task latest_task = task ※注意:この操作は最悪の場合、環境を壊す可能性があります。自己責任でお試しください。特に全てのスケジューラーを同時に落とさないように注意してください。 ワークロードの構成でスケジューラーの数に2を設定してスケジューラーのHA構成を有効にします。 airflow-scheduler podsが2つ起動するのを待ちます。 ワークフローを実行します。黄緑色の点がスケジューリングされて実行しているタスクです。 ワークフローを実行中に kubectl delete pod でairflow-schedulerを1つ止めます。 スケジューラーが1つ止まった後も、別のスケジューラーによりタスクが正常にスケジューリングされます。 たまたま動作していなかったスケジューラーを止めてしまっただけではないことを確認するために、もう片方のスケジューラーも停止します。これで当初スケジューリングしていたスケジューラーはいなくなったことになります。 それでもタスクはスケジュールされ続けます。新たに起動したスケジューラーによりタスクが正常に動作し続けていることがわかります。 このように、HA構成を取ることでタスクスケジューラーの可用性が上がるのを確認できました。 先程はスケジューラーを明示的に停止しましたが、スケジューラーを停止しなくても複数のスケジューラーは常に動作しています。 スケジューラーのログを見るとDAGを実行時にスケジューラーを停止しなくても複数のスケジューラーがタスクをスケジューリングしていることがわかります。 このことからスケジューラーはActive-Active構成で動いていることがわかります。これはスケジューラーを複数建てることで可用性だけでなく性能を向上させることが出来ることを意味します。 更新されたAIRFLOW UI AirflowはWebでワークフローの管理ができるAIRFLOW UIを備えています。Airflow2ではこのAIRFLOW UIが全面的に作り直されモダンな構造となりました。新しいAIRFLOW UIは単に見た目が良くなっただけでなく、よく使う機能がより探しやすくなっています。それでいながらAirflow1を使い慣れているユーザーが違和感ない操作性を実現できており、秀逸なデザインとなっています。 実際に見比べてみましょう。 DAGs一覧画面 Airflow1では、Linksに大量のアイコンが並んでいました。アイコンは直感的と言い難く、アイコンの説明もカーソルを乗せるまで表示されませんでした。 Airflow2では、アイコンが整理されよく使うタスクの実行/更新/削除のみがActionsとして表示されるようになりました。 その他の機能はLinks内に収められアイコンとラベル表示でわかりやすくなりました。直感的ではなかったアイコン表示も改められ初見でも機能を理解しやすくなっています。 また、管理画面のタイムゾーンを指定することが出来るようになったのも見逃せないポイントです。Airflow1ではAIRFLOW UIの時刻表示がUTCから変更できずJSTで動いている業務とのマッチングが手間でした。Airflow2はAIRFLOW UIで表示するタイムゾーンを指定出来るようになりJSTも指定可能になりました。 DAG詳細画面 DAGの詳細画面もモダンになっています。 やはりここでもDAGの実行/更新/削除のアイコンが分離して見やすくなりました。加えて Auto-refresh 機能が追加されDAG実行時にリロード不要でタスクの進行具合がリアルタイム更新されるようになりました。 シンプルなDAGの記法に対応 DAGを構築する上では、シンプルに記述出来るようになったのも嬉しいポイントです。特に Python Operator の記載法は一般的なPythonで関数を記載するスタイルに近くなりわかりやすくなりました。 それでは、実際にDAGを作って見比べてみます。次のようにそれぞれのタスク間で依存関係があるDAGを作ります。 get_week :曜日のCSVを作成する。 parse_week_(0〜6) : get_week が作成したCSVから曜日ごとに文字列を作成する。 print_week : parse_week_(0〜6) が作成した曜日の文字列を表示する。 Airflow1では次のように記載していました。 from airflow import models from airflow.operators.python_operator import PythonOperator from airflow.utils.dates import days_ago default_dag_args = { "start_date" : days_ago( 2 ), "provide_context" : True } with models.DAG( "parallel_tasks_dag1" , default_args=default_dag_args, schedule_interval= None , ) as dag: def get_week (**kwargs): return "Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday" def parse_week (**kwargs): ti = kwargs[ "task_instance" ] week = ti.xcom_pull(task_ids= "get_week_task" ) return week.split()[kwargs[ "index" ]] def print_week (**kwargs): ti = kwargs[ "task_instance" ] for i in range ( 7 ): day = ti.xcom_pull(task_ids=f "parse_week_{i}" ) print (day) get_week_task = PythonOperator( task_id= "get_week_task" , python_callable=get_week, provide_context= True , dag=dag, ) print_week_task = PythonOperator( task_id= "print_week" , python_callable=print_week, provide_context= True , dag=dag, ) for i in range ( 7 ): parse_week_task = PythonOperator( task_id=f "parse_week_{i}" , python_callable=parse_week, provide_context= True , dag=dag, op_kwargs={ "index" : i}, ) get_week_task >> parse_week_task >> print_week_task タスクを記述するには、 Python Operator のインスタンスを生成して行います。タスクに値を渡したり、他タスクの戻り値を受け取るには dict を使う必要があります。このようにやや特殊で冗長な記法が必要でした。また、タスクの順序は >> で明示する必要がありました。 同じタスクをAirflow2では次のように記述出来ます。 from airflow.decorators import dag, task from airflow.utils.dates import days_ago @ dag (default_args={ "owner" : "airflow" }, schedule_interval= None , start_date=days_ago( 2 )) def parallel_tasks_dag2 (): @ task def get_week () -> str : return "Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday" @ task def parse_week (week, index): return week.split()[index] @ task def print_week (day_list: dict ): for day in day_list: print (day) week = get_week() day_list = [] for i in range ( 7 ): day_list.append(parse_week(week=week, index=i)) print_week(day_list=day_list) dags = parallel_tasks_dag2() タスクと引数や戻り値をやり取りする際も、まるで普通にPythonの関数を呼ぶように記述できます。タスクの順序は明示する必要がなく、変数のやり取りを通じて自動的に解析されます。タスク間で依存するデータが見やすいこと、冗長で特殊な記法が減ったことでDAGが書きやすく読みやすくなりました。 RBAC UIに標準対応 Cloud Composer上では IAM(Identity and Access Management) によるAIRFLOW UIまでのアクセス権限がおこなわれます。 Airflow2では加えてRBAC(Role Based Access Control) UI機能が追加されました。RBAC UIにより AIRFLOW UI上でのユーザーの権限を個別にロールとして付与できる ようになりました。 たとえば Viewer ロールのみが付与されたユーザーの場合、DAGの実行状況を見れるだけで、実行や停止は出来なくなります。 デフォルトで作成されたロールの設定 試しに Viewer ロールを付与してみます。 Cloud Composer 2では新規アカウントに標準でOpロールが付与される ため Op ロールを剥奪します。 ロールを剥奪するには gcloud コマンドを使用します。 gcloud beta composer environments run ENVIRONMENT_NAME \ --location LOCATION \ users remove-role -- -e USER_EMAIL -r ROLE 大文字部分は次のように置き換えます。 ENVIRONMENT_NAME:Cloud Composerの環境名 LOCATION:Cloud Composerのロケーション USER_EMAIL:ユーザーのメールアドレス ROLE:剥奪するロール 今回はROLEに Op を指定します。 ロールを付与するには剥奪時と同様に gcloud コマンドを実行します。指定方法はロールの剥奪時と同じで remove-role の代わりに add-role とします。 gcloud beta composer environments run ENVIRONMENT_NAME \ --location LOCATION \ users add-role -- -e USER_EMAIL -r ROLE ROLE にデフォルトで用意されている Viewer を指定します。 Viewer ロールのみを設定したユーザーでAIRFLOW UIを開くと、DAGの実行状況は見えますがActionsはグレーアウトされ実行などは出来なくなっています。 カスタムロールの作成 Op や Viewer のようなデフォルトで設定されているロールだけでなく、 より細かな制御を行えるカスタムロール の作成もできます。 次のDAGsから normal_tasks のみが表示可能なカスタムロールを作ってみます。 カスタムロールを作るにはまず、 gcloud コマンドを使用して管理者ユーザーに Admin ロールを付与します。 Admin権限が付与されたユーザーでAIRFLOW UIの Security - List Roles を開きます。 Permissions に次の権限を追加します。 Viewer ロールを付与したときと同じ要領で作成したロールを対象ユーザーへ付与します。 このロールが付与されたユーザーでAIRFLOW UIにアクセスすると normal_tasks が表示はできるが実行は出来ないこと、他のタスクはその存在自体が見えなくなっていることがわかります。 RBAC UIはAIRFLOW UI上でのみ有効 RBAC UIによるロールの制御はAIRFLOW UI上でのみで有効であることに注意が必要です。 そのため上記ユーザーであってもIAMで roles/composer.environmentAndStorageObjectViewer を保持していた場合。 gcloud コマンドを使って、次のコマンドを実行すると非表示にしたかったDAGsも見えてしまいます。 gcloud beta composer environments storage dags list --environment=ENVIRONMENT_NAME --location LOCATION ユーザーを管理するにはユーザーに対して roles/composer.user のみを設定するというようにIAMの権限設定も必要です。IAMでの権限設定については、 IAM を使用したアクセス制御 が参考になります。 最後に 本記事では新しくなったCloud Composer 2とAirflow2の特性を紹介しました。 Cloud Composer 2とAirflow2を組み合わせることで、可用性が高く低コストで高速なワークフロー環境を簡単に作ることが出来るようになりました。 ZOZOではこの他にも Vertex AI Pipelines など複数の仕組みを採用してワークフローを実装しています。 ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com hrmos.co
はじめに こんにちは。検索基盤部 検索基盤チームの佐藤( @satto_sann )です。検索基盤チームでは、 ZOZOTOWNの検索周りのシステム開発に日々取り組んでいます。 本記事では、ZOZOTOWNの検索マイクロサービスにおけるキャッシュ導入で得られた知見や工夫点について紹介します。検索に限らずマイクロサービスへキャッシュの導入を検討されている方の参考になれば幸いです。 目次 はじめに 目次 キャッシュの導入背景 負荷とレイテンシの悪化 ABテストの仕組みをマイクロサービスへ移設する上での問題 キャッシュ導入の検証 Cache Stampede Cache Stampedeの対策 2重キャッシュ 分散していたキャッシュの統合 キャッシュの有効期限 定められたタイミングでの情報反映 キャッシュ導入後の構成 キャッシュ制御の設計 【A案】アプリケーション層とドメイン層の間 【B案】ドメイン層とインフラ層の間 【C案】A案とB案の両方を採用する キャッシュ導入による効果 レイテンシの減少 APIへのリクエスト数が少ない場合 負荷への耐久性の向上 工夫した取り組み API毎にTTLを個別設定 キャッシュキーのハッシュ化 キャッシュの圧縮 キャッシュ圧縮や解凍の実現方法 実装 キャッシュ圧縮による効果 まとめ おわりに キャッシュの導入背景 なぜ検索マイクロサービスにキャッシュを導入する必要があったのかについて紹介します。 負荷とレイテンシの悪化 現在ZOZOTOWNの検索システムは、既存の肥大化したシステムから検索に関連したAPI(以下、検索APIと呼ぶ)を切り離し、検索機能に特化したマイクロサービスで構成されています。 マイクロサービス化への道のりについては、以下の記事をご参照ください。 techblog.zozo.com 検索APIのマイクロサービス化に伴い、ZOZOTOWN以外のマイクロサービスから直接呼び出される機会が増加していました。 ZOZOTOWNからリクエストされる場合には、ZOZOTOWNを配信しているWebサーバ上にキャッシュする機構が以前からあったため、特に性能的な問題はありませんでした。 一方で他のサービスから直接呼び出された場合、検索APIはキャッシュ処理を行うような機構(以下、キャッシュ機構と呼ぶ)を持っていないため、負荷やレイテンシの悪化が課題となっていました。 ABテストの仕組みをマイクロサービスへ移設する上での問題 ZOZOTOWNではより良い検索機能を提供するためにABテストを実施しています。以下では、ABテストが振り分けられる様子を表しています。 これまでのABテストの仕組みでは、ZOZOTOWN上でABテストを設定していたため、以下のようなロジックの修正や設定の変更が必要でした。 ZOZOTOWNのABテストの設定 既存キャッシュ処理のロジック 検索マイクロサービスのパラメータ 検索マイクロサービスの内部ロジック ABテスト毎にこれらの変更作業が発生するため、短期的にABテストを実施する上で課題となっていました。 そこで、以下の図のように検索マイクロサービス上でABテストを実施する仕組み(以下、ABテスト基盤と呼ぶ)を構築して、ABテストの設定をこの基盤上で行えるよう変更します。 マイクロサービスに完結したABテストの実施が可能になり、設定や改修は以前と比べ少なくなります。 ABテスト基盤の設定 検索マイクロサービスの内部ロジック 検索APIを直接利用していたネイティブアプリについては、これまでABテストを実施出来ていませんでしたが、ABテスト基盤が出来たことでWebと合わせてABテストが実施可能になります。 この仕組を実現するためにも検索APIにキャッシュ機構を導入する必要がありました。 キャッシュ導入の検証 検索APIにキャッシュ機構を導入すると前述した複数の課題が解決できますが、下記のような懸念事項がありました。 Cache Stampede(キャッシュスタンピード) 2重キャッシュ キャッシュの有効期限 定められたタイミングでの情報反映 以下では、これらの懸念事項とその対策について詳しく説明します。 Cache Stampede キャッシュが有効期限切れなどで破棄された際に、データ提供元(以下、オリジンと呼ぶ)へのアクセスが瞬間的に集中することで、APIやデータベースの負荷が高まります。 このような現象を Cache Stampede(キャッシュスタンピード) と呼びます。 アクセスが少ない場合は特に問題になりません。しかし、検索APIが提供する検索機能はZOZOTOWNの多くのユーザが利用するため、キャッシュが破棄されたタイミングで Cache Stampede の発生が予想されました。 Cache Stampedeの対策 Cache Stampedeの対策はいくつかあります。 別プロセスで事前にキャッシュを生成する(事前作成) 期限切れ前に一定の確率で期限を更新する(期限更新) 裏側のAPIへアクセスするプロセスを絞る(ロック) 今回は、ロック方式を採用しました。任意のタイミングで更新情報を商品結果に反映する検索要件を実現しやすいことと、他マイクロサービスにて安定して運用している実績があったためです。 ロック方式の利点やその他Cache Stampede対策については以下の記事をご参照ください。 techblog.zozo.com 2重キャッシュ 背景で述べたとおり、現状リクエスト元のWebサーバ上で検索APIのレスポンスがキャッシュされています。検索APIにキャッシュ機構を導入すると、既存のキャッシュ処理を廃止するまで両方でキャッシュ処理される2重キャッシュ状態となり、キャッシュ効率の観点で懸念がありました。 しかし、どちらも一度キャッシュしてしまえば次の更新まで高速にレスポンスを返却できるため、この問題は許容出来ると判断しました。実際にリリース後の計測では2重キャッシュ状態でレイテンシは10ミリ秒増加しましたが、運用上は許容範囲内でした。 リリース後、リクエスト元で行われるキャッシュ処理をエンドポイント単位で無効化していき、段階的に2重キャッシュ状態を解消しました。これによりパフォーマンスとしてはレイテンシが数十ミリ秒減少し、大きな効果が見られました。 分散していたキャッシュの統合 パフォーマンス以外にも、ZOZOTOWNではWebとアプリの両方のリクエスト元でキャッシュが分散管理されている状態でしたが、検索APIに統合したことで運用が容易になるといった副次的効果も得られました。 キャッシュの有効期限 キャッシュの有効期限(以下、TTLと呼ぶ)は長ければ長いほどキャッシュヒット率は向上します。一方で、新たな商品やショップが頻繁に追加されるといったオリジンの更新頻度が高い場合には、APIはキャッシュのTTLが切れるまで更新された新しい情報を返せなくなってしまいます。 そこで、API毎に扱うオリジンのデータ更新頻度を調査し、更新頻度に応じた適切なTTLを検討・設定しました。 定められたタイミングでの情報反映 ZOZOTOWNでは、10時や12時などの特定の時間にセールや商品の販売が開始されます。開始直後にこれらの情報を検索結果に反映させるには、意図的にキャッシュを切り替える必要がありました。 例えば、セールの開始時間が12時でキャッシュの有効期限が5分だった場合、11時59分に新たにキャッシュが作成されると、12時04分までセール情報を含まない結果を返してしまいます。 そこで、キャッシュの有効期限とは別にキャッシュキーに有効期間(以下、タイムセクションと呼ぶ)を加えて意図的に特定の時間で切り替えるように工夫しました。 例えば、現在時刻が0分から14分の間であれば、 FROM0TO14 を加えたキャッシュキーを生成します。15分から29分の間であれば FROM15TO29 を加えます。このように15分ごとに異なるキャッシュキーが生成されるようにタイムセクションを付与します。 タイムセクションがあることで11時59分にキャッシュが生成された後、12時に同様のリクエストがあっても生成されるキャッシュキーが異なるためキャッシュミスを誘発できます。結果として開始時刻に合わせて最新の情報を反映したキャッシュに切り替えが可能となります。 キャッシュ導入後の構成 キャッシュ導入後の検索マイクロサービスの構成イメージは下図の通りです。一部詳細は省略しています。 キャッシュストアは 他のマイクロサービスでも導入実績 があったAWS ElastiCacheを採用しました。同様の理由で、キャッシュエンジンはRedisを使用します。 キャッシュ制御の設計 検索APIはSpring Bootフレームワークを使用しており、"アプリケーション層"と"ドメイン層"、"インフラ層"からなる3層アーキテクチャで構成されています。 各層について、以下の通り責務を分割しています。 - "アプリケーション層":クライアントとの入出力とビジネスロジックを繋ぐ。 - "ドメイン層":複数のビジネスロジックを集約。 - "インフラ層":Elasticsearchやその他マイクロサービスとのやり取り。 このアーキテクチャ内にキャッシュ制御をどのように取り入れるか検討しました。 【A案】アプリケーション層とドメイン層の間 【B案】ドメイン層とインフラ層の間 【C案】A案とB案の両方を採用する 結論として、B案を採用しました。以下では、採用に至った経緯について説明します。 【A案】アプリケーション層とドメイン層の間 A案では、リクエスト毎にキャッシュの有無を問い合わせ、ヒットすればキャッシュからデータを返却できます。一方で、キャッシュが存在しない場合はドメイン層へと処理が移り、その後処理の結果を返却すると同時にキャッシュを保存します。 この案を採用した場合、1リクエストあたり1キャッシュの関係となり設計はシンプルになりそうです。しかし、アプリケーション層が複数のドメイン層でやり取りする場合、複数の処理結果が1つのキャッシュに保存されるため肥大化が懸念されました。 【B案】ドメイン層とインフラ層の間 この案を採用した場合、ドメイン層で処理された結果ごとにキャッシュを保存できます。そのため、A案で問題となっていたキャッシュの肥大化を防ぐことが可能です。 例えば、2つの異なるリクエストがあり、一部を同様のビジネスロジックで処理していたとします。B案であれば、ビジネスロジック毎にキャッシュできるため、共通する処理の結果は1つのキャッシュを流用してそれぞれのリクエストに対して返却できます。このようにキャッシュ対象の粒度が小さくなることでA案の課題を解決すると同時にキャッシュミス減少も期待できます。 【C案】A案とB案の両方を採用する この案はB案をベースに複数のビジネスロジックを利用する箇所にはA案を採用する良いところ取り設計になります。 この方法であればより効率的にキャッシュを運用できそうです。しかし、3層を横断した煩雑な設計となり、今後の運用コストが高まると考え採用しませんでした。 キャッシュ導入による効果 検索APIにキャッシュ機構を導入したことで、以下の効果が得られました。 レイテンシの減少 負荷への耐久性の向上 レイテンシの減少 キャッシュ導入によって、ZOZOTOWNが配信されているWebサーバから検索APIへリクエストされた後、レスポンスが返却されるまでのレイテンシが減少しました。 以下の画像は、リリース前後のレイテンシの様子を表しています。 リリースは15時頃行われ、その後レイテンシはリリース前と比較すると、どのパーセンタイルでも減少が見られました。特に、p99では約30%減少と大きな効果が得られました。 このような効果が得られた要因としては、以下が考えられます。 似たような条件で検索される割合が高い リクエストの量が多い 似たような条件で検索される割合が高い というのは、言い換えるとキャッシュのヒット率が高いことを意味します。実際に、弊チームで利用しているモニタリングツールからヒット率を確認すると高い割合で推移していました。 また、検索APIでは常に膨大なリクエストを受けるため、キャッシュが効果的に働いたことも要因として挙げられます。 APIへのリクエスト数が少ない場合 一方で、APIへのリクエスト数が少ない場合、キャッシュ処理が新たに加わったことで実行時間が増加してレイテンシに影響を与える可能性があります。 この場合、解決策の1つとしてAPI毎にキャッシュ適用の有無を切り替えられるような仕組みを導入する方法が考えられます。幸い検索APIでは今のところこの影響を受けませんが、一部APIでキャッシュ処理に不備があったなどの障害対策としてもこの方法は有効であると考え、導入しました。 負荷への耐久性の向上 検索APIがどの程度の負荷に耐えられるか検証するための負荷試験を実施しました。負荷の規模は、年間で一番大きい正月セールを想定しました。このセールでは、通常時の数倍のリクエストが発生します。 試験の結果、キャッシュ未使用時と比べてレイテンシが大幅に減少し、負荷への耐久性の向上が確認できました。また、一部APIではp50が約60%減少するなど大きな効果が得られました。 工夫した取り組み キャッシュ機構を導入する上で工夫した取り組みについて、いくつか紹介します。 API毎にTTLを個別設定 キャッシュの有効期限 で述べた通り、API毎にオリジンのデータ更新頻度が異なるため、TTLを環境変数上で個別設定できるようにしました。 application.yaml に記述したTTLの例を紹介します。 ttl : default-millis : ${REDIS_TTL_DEFAULT_MILLIS:60000} api-1-mills : ${REDIS_TTL_API_1_MILLIS:600000} api-2-mills : ${REDIS_TTL_API_2_MILLIS:3600000} default-millis は、TTLの初期値を表しています。単位はミリ秒なので 60000 は1分を意味します。次に、 api-1-mills や api-2-mills はAPI毎のTTLの設定になります。 これらの値は、キャッシュ機構を導入した後に延長対応するなど、キャッシュストアの状況やヒット率を鑑みて最適化を図っています。 キャッシュキーのハッシュ化 キャッシュのキーが長すぎると、メモリ消費やキャッシュ検索の観点で良くないとされています。詳しくは、 Redis公式ドキュメント をご参照ください。 そこで以下の実装の通り、MD5を利用してキャッシュキーのハッシュ化を行い、常に固定長となるようにしました。 import org.springframework.util.DigestUtils; /* 中略 */ String beforeHsahedKey = "/v1/search/sample_param1&param2_FROM0TO29" ; String hashedKey = DigestUtils.md5DigestAsHex(beforeHsahedKey.getBytes(StandardCharsets.UTF_8)); // hashedKey = ea7da06d698ebb8beb84c230e84d698f 実装では、 DigestUtils のクラスの md5DigestAsHex メソッドを利用してハッシュ値を16進数で表現しています。 この例では、 beforeHsahedKey の値をハッシュ化して ea7da06d698ebb8beb84c230e84d698f が得られました。 beforeHsahedKey の値が大きくなっても常に32文字に抑えられます。 キャッシュの圧縮 検索APIは数百件の商品結果やショップ情報など大きなデータを頻繁に扱います。また、リクエスト量も常に多いため、必然的にキャッシュのメモリ消費が問題となりえます。そこで、キャッシュをgzipによって圧縮する仕組みを構築して、この問題を解決しました。 キャッシュ圧縮や解凍の実現方法 次に、実現方法について説明します。通常、Spring Bootではキャッシュを扱う場合、データをシリアライズ化した後にキャッシュとして保存します。一方で、キャッシュからデータを返却する場合、保存されたデータに対してデシリアライズ化します。この中でキャッシュの圧縮や解凍を実現するには、データをシリアライズ化した後に圧縮し、デシリアライズ化する前に解凍処理を行う必要があります。 まとめると、キャッシュ圧縮までの大まかな流れは以下の通りです。 対象オブジェクトをシリアライズ化 gzipを使ってシリアライズ化されたデータを圧縮 圧縮したデータを返却 解凍については、圧縮の反対の流れになります。 圧縮されたデータを解凍 解凍したデータをデシリアライズ化 デシリアライズ化したデータを返却 実装 これらを実装すると以下のようになります。また、実装は こちらのサイト を参考にしました。 import org.apache.commons.io.IOUtils; import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; import org.springframework.data.redis.serializer.SerializationException; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import javax.annotation.Nonnull; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; @Component public class RedisCacheGzipSerializer extends JdkSerializationRedisSerializer { private final CacheParameter cacheParameter; public RedisCacheGzipSerializer(CacheParameter cacheParameter) { this .cacheParameter = cacheParameter; } @Override public Object deserialize( byte [] bytes) { if (cacheParameter.isGzipEnabled()) { return super .deserialize(decompress(bytes)); } else { return super .deserialize(bytes); } } @Override public byte [] serialize(Object object) { if (cacheParameter.isGzipEnabled()) { return compress( super .serialize(object)); } else { return super .serialize(object); } } @Nonnull private byte [] compress( @Nullable byte [] content) { if (content == null ) { return new byte [ 0 ]; } ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); try (GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteArrayOutputStream)) { gzipOutputStream.write(content); } catch (IOException ex) { throw new SerializationException( "Unable to compress data" , ex); } return byteArrayOutputStream.toByteArray(); } @Nullable private byte [] decompress( @Nullable byte [] contentBytes) { if (contentBytes == null || contentBytes.length == 0 ) { return null ; } ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); try { IOUtils.copy( new GZIPInputStream( new ByteArrayInputStream(contentBytes)), byteArrayOutputStream); } catch (IOException ex) { throw new SerializationException( "Unable to decompress data" , ex); } return byteArrayOutputStream.toByteArray(); } } 実装では、 対象オブジェクトをシリアライズ化 や 解凍したデータをデシリアライズ化 などの圧縮解凍に関係ない処理については、既存のキャッシュ処理で用いられていたものを流用します。つまり、既存のクラス JdkSerializationRedisSerializer が提供する serialize や deserialize メソッドを利用しています。今回作成した RedisCacheGzipSerializer クラスでは、これらのメソッドをラップして圧縮処理を行う compress や解凍処理を行う decompress メソッドを追加しています。 注意点として、参考にしたサイトのコードでは decompress メソッドの引数 contentBytes がNullだった場合に問題が起きました。 具体的には、 new ByteArrayInputStream(contentBytes) で NullPointException が発生したので、これを回避する処理を追加しています。 if (contentBytes == null || contentBytes.length == 0 ) { return null ; } また、 decompress メソッドでは圧縮されたデータの解凍と、解凍されたデータをバイト配列へ書き込む処理を IOUtils クラスが提供する copy メソッドで実装しています。このメソッドを利用することで、煩雑化しやすい InputStream から読み込んだデータを OutputStream に書き込む一連の処理を簡略化できます。 このクラスを利用するために、以下の依存関係を追加しています。 <!-- https://mvnrepository.com/artifact/commons-io/commons-io --> <dependency> <groupId> commons-io </groupId> <artifactId> commons-io </artifactId> <version> 2.11.0 </version> </dependency> 今回、作成した RedisCacheGzipSerializer は RedisTemplate で呼び出しました。 @Bean public RedisTemplate<String, Object> redisTemplate( RedisCacheGzipSerializer redisCacheGzipSerializer) { RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>(); /* 中略 */ redisTemplate.setValueSerializer(redisCacheGzipSerializer); /* 中略 */ return redisTemplate; } 使用するSpring BootのRedisプロバイダーによって呼び出し元は異なるので、適宜 JdkSerializationRedisSerializer から置き換えください。 キャッシュ圧縮による効果 キャッシュ圧縮によって、以下の効果が得られました。 メモリ使用率の減少 ネットワーク通信量の減少 gzipの圧縮率は、データや使用するアルゴリズムにより異なりますが60%から80%ほどです。メモリ使用率も同程度の減少が期待出来ます。事実、リリース後のメモリ使用率は以下の通り大幅な減少が見られました。 また、キャッシュの保存や参照時に発生するネットワーク通信量もキャッシュ圧縮に伴って、メモリ使用率と同程度の減少が見られました。 一方で、キャッシュ圧縮処理が加わったことによるレイテンシやCPU使用率の増加を懸念していました。レイテンシについては、前述したネットワーク通信量の減少によって相殺されるため問題になりませんでした。CPU使用率についても同様にリリース前後と比較して変化は見られませんでした。 まとめ これまでZOZOTOWNの検索機能を提供するマイクロサービスでは、ABテストの実施や負荷の面で課題がありました。本記事では、これらの課題を解決する方法の内の1つとして、マイクロサービスへのキャッシュ導入事例を紹介しました。キャッシュの導入により、課題が解決されただけではなく、レイテンシが大幅に減少するなど大きな改善が得られました。 また、「キャッシュの圧縮」や「API毎にTTLを設定」などの工夫もいくつか紹介しました。これらを取り入れることで、効率的なキャッシュの運用が可能になりました。 その他、効果としてAPIのレイテンシをより意識するようになりました。数ミリ秒を改善するため想定したレイテンシに至らなかった場合はTTLを見直して、最も効果が得られそうな値を模索します。その過程で、レイテンシやヒット率といった指標も以前に比べて確認する機会が増えました。 ZOZOTOWNの検索機能はユーザにとって「求める商品を見つける」ための重要な機能です。日々多くのユーザが利用するため、リクエスト数は膨大です。ほんの少しの改善により大きな効果を得られる可能性があります。キャッシュの導入によって大きな効果が得られましたが、まだまだ工夫の余地は残っています。今後も、より高速な検索をユーザへ提供するために改善を続けたいと思います。 おわりに ZOZOでは、検索機能を開発・改善していきたいエンジニアを全国から募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
こんにちは、FAANS部の田中です。普段は、WebのフロントエンドエンジニアとしてFAANSの開発を行なっています。 FAANSの由来は「Fashion Advisors are Neighbors」で、「ショップスタッフの効率的な販売をサポートするショップスタッフ専用ツール」です。現在正式リリースに向けて、 WEAR と連携したコーディネート投稿機能やその成果を確認できる機能など開発中です。プラットフォームとしてはWeb、iOS、Androidが存在し、今回取り上げるWebはショップ店長をはじめとした管理者向けツールという立ち位置です。 本記事では、FAANSのWebにおけるStorybook × MSW × Chromaticを使ったUIの影響範囲を自動検知するための取り組みを紹介します。 はじめに FAANSのWebはReact、TypeScriptで構成されています。設計に関しては、ロジックとビューの責務を分けるためにContainer Presenterパターンを採用しています。ContainerでAPIのレスポンスやユーザー情報などの共通の状態をContextから取得してPresenterに注入し、ページを表示させています。 FAANSのWebの課題 FAANSのWebでは以下の2つの課題がありました。 各状態ごとのUIを把握しきれない。 デザインの修正をしたときに、修正した箇所以外の影響範囲を検知する仕組みがない。 それぞれの課題に対して取り組んできたことについて紹介します。 1. 各状態ごとのUIを把握しきれない プロジェクトの振り返り会にて、UI改修が頻繁に発生するプロジェクトの現状において、「各状態ごとのUIを把握しきれない」という課題が挙がりました。具体的には、一覧取得APIがエラーレスポンスを返した場合のUIという具合です。 また、FAANSにはショップスタッフやショップ店長のような権限が存在します。権限によって機能が異なり、権限ごとのページのUIも把握しづらい状況でもありました。 その問題を解決するために、 Storybook を導入しました。Storybookとは独立してUIコンポーネントを管理できるツールで、それを使って各状態ごとのUIを管理して一覧表示しようと試みました。 そのための設計として、以下の図のようにPages Presenterを作成し、その中でTemplates、Organisms、Molecules、Atoms層のコンポーネントを呼ぶ設計にしていました。これは、UI設計のメンタルモデルである Atomic Design を参考にしています。 Storybook上で各storyごと、Pages Presenterのpropsに必要な値を注入して、その値に応じたページのUIを表示させていました。 ただこの「Pages Presenterに値を注入する」方法だと以下のような壁がありました。 Storybookで各状態のページのUIを表示させるためにContainerからPages Presenterに注入している値をすべて用意する必要がある 各Pages Presenterにヘッダーやフッターなどを含むTempletesを都度定義する必要があるので冗長 Mock Service WorkerとMSW Storybook Addonについて そこで、それらの壁を乗り越えるために Mock Service Worker (以下、MSW)と MSW Storybook Addon を導入しました。 MSWとはネットワークレベルでAPIのリクエストをインターセプトしてmockのレスポンスを返すライブラリです。 以下のように、handlerでAPIのパスとmockのレスポンスを定義すれば、その定義したパスにリクエストが送られて来たタイミングでmockレスポンスを返すことができます。 // handler export const handlers = { mockGetMyClosetItemsForWear: rest.get( ` ${process.env.REACT_MSW_DOMAIN} /v1/wear/members/@me/coordinate_items` , mockGetMyClosetItemsForWear, ), } ; また、MSW Storybook Addonとはstory単位でMSWのmockのレスポンスを定義できるライブラリです。 以下のように、storyに定義すれば、MSWのhandlerで定義されているmockレスポンスと別のレスポンスを返すことが可能です。 // Storybook export const Test: Story = { parameters: { msw: { handlers: { mockGetMyClosetItemsForWear: rest. get( ` ${process .env.REACT_MSW_DOMAIN } /v1/wear/members/@me/coordinate_items` , ( req , res , ctx ) => { return res ( ctx. status( 500 ), ctx.json ( '' )); } , ), } , } , } , } ; MSWとMSW Storybook Addonの導入した結果 storyごとにmockのレスポンスを定義して、そのレスポンスに応じたページのUIをStorybook上で一覧表示させることができました。「Pages Presenterに値を注入する」方法とは違い、必要なのはmockのレスポンスになるため、Containerの値をすべてを用意する必要はなくなりました。また、各Pages Presenterにヘッダーやフッターなどを含むTempletesを都度定義する必要はなくなり、TemplatesをRouter側に書くことができました。 一部抜粋したStorybookのソースコードとしては以下のようになります。 1 // Storybook 2 const BaseStory = () => { 3 return ( 4 < MemoryRouter 5 initialEntries = {[ '/coordinates/:coordinateId/edit/items/new/edit' ]} 6 > 7 < CoordinateTemplate > 8 < CoordinateProvider initialCoordinate = { initialMockCoordinate } > 9 < CoordinateItemProvider initialCoordinateItem = { initialMockCoordinateItem } > 10 < CoordinateItemEditContainer / > 11 < /CoordinateItemProvider > 12 < /CoordinateProvider > 13 < /CoordinateTemplate > 14 < /MemoryRouter > 15 ); 16 } ; 17 18 export default { 19 title: 'pages/コーデ投稿/着用アイテム登録画面' , 20 component: BaseStory , 21 } ; 22 23 type Story = ComponentStoryObj <typeof BaseStory >; 24 25 export const Case1: Story = { 26 storyName: 27 'モーダルを表示させ、一覧が表示できた(200)場合' , 28 play: async ( { canvasElement } ) => { 29 const canvas = within ( canvasElement ); 30 const coordinateItemOpenModalButton = await canvas.findByTestId ( 31 'coordinateItemOpenModalButton' , 32 ); 33 userEvent.click ( coordinateItemOpenModalButton ); 34 } , 35 } ; 36 37 export const Case2: Story = { 38 storyName: 39 'モーダルを表示させ、一覧が表示できなかった(500)の場合' , 40 parameters: { 41 msw: { 42 handlers: { 43 mockGetMyClosetItemsForWear: rest. get( 44 ` ${process .env.REACT_MSW_DOMAIN } /v1/wear/members/@me/coordinate_items` , 45 ( req , res , ctx ) => { 46 return res ( ctx. status( 500 ), ctx.json ( '' )); 47 } , 48 ), 49 } , 50 } , 51 } , 52 play: async ( { canvasElement } ) => { 53 const canvas = within ( canvasElement ); 54 const coordinateItemOpenModalButton = await canvas.findByTestId ( 55 'coordinateItemOpenModalButton' , 56 ); 57 userEvent.click ( coordinateItemOpenModalButton ); 58 } , 59 } ; MSWのhandlerで /v1/wear/members/@me/coordinate_items のAPIは200レスポンス返すように定義されており、Case1ではその定義通り200を返します。よって、Case1は200を返した場合のUIを確認できます。それに対して、Case2に関しては500を返すように定義しているため、エラーのUIを確認できます。 以下、Case1とCase2のページの表示となります。 実際に動いているWebではAPIが500エラーを返すことは稀なので、このようにUI上で確認が難しいイベントを確認できるのはメリットです。 *上のページはコンポーネント間でグローバルに共有している状態をContextから取得しています。Storybookで表示させるためその状態もmockする必要があります。それはソースコードの8行目と9行目で、Provider経由でmockした状態を注入することで実現しています。 *上のページはあるボタンをクリックしてモーダルを開いた後のページです。これはStorybookのPlay functionの機能を使っており、それを使うとレンダリング後のページに対してイベントを発火させることができます。ソースコードの33行目と57行目の userEvent.click でクリックイベントを発火させてモーダルを開いた後のページを表示しています。 このようにMSWとStorybookを組み合わせることで「各状態ごとのUIを把握しきれない」問題を解決できました。付随して、バックエンド側でSwaggerにリクエストとレスポンスが定義されていれば、MSWがそれを元にmockレスポンスを返すため、APIが実装されていなくても並列で開発ができるメリットもありました。 2. デザインの修正をしたときに、修正した箇所以外の影響範囲を検知する仕組みがない デザインの修正時やライブラリのアップデート時において、UIに影響がでていないか検知する仕組みがありませんでした。 この問題を解決するために Chromatic を利用しました。ChromaticとはStorybook上のUIのコミットごとの差分を取れるツールで、GitHubのようにその差分に対してレビューできたり、StorybookのHosting機能も備わっています。Storybookで作成した多様なUIに対して、変更前と変更後でスナップショットを撮影・差分比較して予期せぬUIの影響がでていないかを確認できます。 具体的な例として、以下の画像の赤枠のように、検索窓が追加したときに他の箇所で影響が出ていないか確認してみましょう。 差分がある箇所は緑で表示されるので、確認する際は緑の箇所だけ注目すれば良くなります。確認したところ、それは検索窓の追加による差分であることが分かります。また、ヘッダーなど別の箇所をみると緑の差分は表示されていないので、検索窓を追加した前と後では予期せぬUIの影響は出ていないことが分かります。 また、同じページでモーダルを開いた後の差分を確認したところ、赤枠の箇所には緑の差分がないので、影響が出ていないのがわかります。 このChromaticの導入によって、「2. デザインの修正をしたときに、修正した箇所以外の影響範囲を検知する仕組みがない」課題を解決できました。 まとめ 「1. 各状態ごとのUIを把握しきれない」課題の解決策は以下の通りです。 MSWでAPIのレスポンスをmockする グローバルな状態はProvider経由でmockした状態を注入する イベント発火後のUIはPlay functionを使う これらの方法によって、Storybook上で多様なページを確認できます。 図で表すと以下の通りです。 また、「2. デザインの修正をしたときに、修正した箇所以外の影響範囲を検知する仕組みがない」課題の解決策は以下の通りです。 1で作成した多様なページに対して、Chromaticを使ってスナップショットを撮影・差分比較する。 この方法によって、UIの影響範囲を検知できます。 さいごに Storybook × MSW × Chromaticを使ったUIの影響範囲を自動検知するための取り組みついて紹介しました。デザインレビューの効率化に興味がある皆さんの参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com hrmos.co
はじめに こんにちは、 ZOZO NEXT ZOZO Research のSai Htaung Khamです。ZOZO NEXTは、ファッション領域におけるユーザーの課題を想像しテクノロジーの力で解決すること、より多くの人がファッションを楽しめる世界の創造を目指す企業です。 ZOZO NEXTでは多くのアルゴリズムを研究開発しており、その中で JAX というライブラリを使用しています。JAXは高性能な機械学習のために設計されたPythonのライブラリです。NumPyに似ていますが、より強力なライブラリであると考えることができます。NumPyとは異なり、JAXはマルチGPU、マルチTPU、そして機械学習の研究に非常に有用な自動微分(Autograd)をサポートしています。 JAXはNumPyのAPIのほとんどをミラーリングしているので、NumPyライブラリに慣れている人なら非常に導入しやすいです。Autogradを使えば、Pythonのネイティブ関数とNumPyの関数を自動的に微分できます。JAXの詳細な機能については、JAXの公式 GitHub リポジトリを参照してください。 はじめに そもそも、なぜJAXなのか? 本記事を読むことで分かること データって本当に大きいの? いつ、どこで、どうやって処理するの? ボトルネックに要注意! TF Dataってヒーローなの? 処理を高速化してみよう! 環境設定 まとめ そもそも、なぜJAXなのか? 機械学習アルゴリズムを構築する際、多くのMLエンジニアはTensorflowやPyTorchといった信頼性の高いMLフレームワークを利用することでしょう。成熟したMLフレームワークには成熟したエコシステムがあり、本番環境への統合や保守が容易になるため、良い決断です。当研究所では、これらのフレームワークを用いて実装された多くのアルゴリズムを持っています。 しかし、いくつかのアルゴリズムはNumPyライブラリを用いて、純粋なPythonで実装されています。その中には例えば、研究者やMLエンジニアが社内用に設計した埋め込みアルゴリズムがあります。埋め込みアルゴリズムは類似商品を効率よく抽出できるため、商品推薦などに有用です。実装がPythonであるため、このアルゴリズムは計算の実行時間にボトルネックがあります。お気づきのように、フレームワークを使用しない場合、パラメータの更新やモデルのダンプ等も自前で実装する必要があります。そのため、新しいアイデアをすぐに試すことが難しく、なかなか前に進めません。また、ライブラリや学習プロセスもCPUデバイスに限定されるため、拡張性がありません。共有メモリアーキテクチャを利用してマルチプロセスでアルゴリズムを実行できましたが、GPUやTPUなどの複数のホストやデバイスで実行し、垂直方向・水平方向にスケールできる状態が望ましいです。 そこで、拡張性・保守性の高い別のフレームワークにプログラムを移植する方法を検討した結果、以下のような特徴を持つJAXを採用しました。 Single Program Multiple Dataアーキテクチャによる水平方向のスケーラビリティ NumPyのAPIをミラーリング Pythonに対応 Autogradのサポート エコシステムまでオープンソース化されている(FlaxやHaikuなど) 特に(2)の性質によってNumPyで書かれたアルゴリズムを効率よく移植できるという点が、既存の他のフレームワークにはない利点でした。 本記事を読むことで分かること 本記事では、実世界のデータを使った機械学習を、JAXライブラリで実行する方法について説明します。通常、機械学習の理論を学び問題を解くときには、理解を深めるために小さなデータを使用します。しかし、実世界のデータに応用するとデータ量、モデルを格納するメモリサイズ、学習と評価のスケーラビリティなど多くの困難に直面することになります。ありがたいことに、現代のクラウドコンピューティングの革命と価格設定により、スケーラブルな機械学習は誰でも利用できるようになりました。 典型的な機械学習プロジェクトはデータの準備からモデルのサービングまで多くのステージで構成されますが、本記事で取り扱うのはデータの準備とモデルの学習に当たる部分です。特にJAXライブラリのパフォーマンスと、クラウドコンピューティング上でのスケーラビリティを実現する方法について説明します。 データって本当に大きいの? いつ、どこで、どうやって処理するの? データと質の高いデータ変換が機械学習プロジェクトの成功の中心であることは、すべてのMLエンジニアが理解していることです。現実の機械学習プロジェクトでは、1台のマシンでETL(抽出、変換、ロード)プロセスを行えるような量のデータを扱うことは稀です。当研究所では、Google CloudやAWSなどのクラウドコンピューティングリソースに広く依存しており、通常、クラウドストレージやクラウドデータウェアハウスを使用してデータを管理しています。 ボトルネックに要注意! クラウドストレージは、1台のマシンに収まりきらない大量のデータを保存するのにとても役立ちます。しかし、モデルの学習に利用するためには、ストレージからデータを読み出す効率的な方法を見つける必要があります。多くのMLエンジニアがGPUデバイスを使った学習中に遭遇する問題の1つは、GPUデバイスが十分に活用し切れず、学習プロセスに必要以上の時間がかかってしまうことです。次のTensorFlowモデルのプロファイリング結果をご覧ください。 参照:[ モデルのプロファイリング ] よく観察すると、ディスクからデータを取得している間、GPUデバイスはほとんどの時間、アイドル状態であることに気づかれると思います。一般的には、学習中はGPUデバイスをビジー状態にしたいものです。これは、データ入力パイプラインにボトルネックがあることを示しています。 TF Dataってヒーローなの? データ入力パイプラインのボトルネックを解消するために、TF DataというTensorFlowが提供する便利なツールを利用することにします。従来の方法では、下図のようにディスクからデータを順次読み込んでいました。下図のMapは、正規化、画像補強などのデータの変換処理です。 参照:[ モデルへのデータの順次取り込み ] しかし、この方法では学習処理にデータ転送待ちが発生し、GPUデバイスがアイドル状態になってしまうというボトルネックが発生しています。そこで下図のように読み込みとデータ変換を並列に行うことで、学習の待ち時間が少なくなります。 参照:[ TF Data Pipelineで効率的なデータ変換 ] TFデータパイプラインのコンポーネントは再利用可能です。トレーニングやサービングフェーズに適用できます。TF DataライブラリはホストCPUを利用してデータを並列に処理しているので、CPUの性能が高ければ高いほど、データの読み込みや前処理が高速になることを念頭に置いておくことが重要です。 データ前処理パイプラインとして、Apache BeamやTFX Transformを使用する方法もありますが、今回は説明しません。本記事では、TF DataとJAXを使用して、スケーラブルな機械学習を共有します。 処理を高速化してみよう! 効果的なデータ前処理パイプラインを手に入れたことで、モデルの学習と評価のステップに移行します。JAXの便利なライブラリにvmapとpmapがあります。本記事では、vmapとpmapを使用してマルチGPUデバイスでの学習処理を高速化します。 #vmapによるauto-vectorization import numpy as np import jax.numpy as jnp import jax def convolve (x, w): output = [] for i in range ( 1 , len (x)- 1 ): output.append(jnp.dot(x[i- 1 :i+ 2 ], w)) return jnp.array(output) x = np.arange( 5 ) w = np.array([ 3. , 1. , 3. ]) batch_size = 10 xs = np.arange( 5 * batch_size).reshape(- 1 , 5 ) ws = np.stack([w] * batch_size) print (f "The shape of the x and w : {xs.shape, ws.shape}" ) print ( "Process each sample." ) for sample in xs: print (convolve(sample, w)) print ( "Auto-vectorization with vmap:" ) print (jax.vmap(convolve)(xs, ws)) #vmap処理とサンプル単位処理の比較結果 The shape of the x and w : ((10, 5), (10, 3)) Process each sample. [ 7. 14. 21.] [42. 49. 56.] [77. 84. 91.] [112. 119. 126.] [147. 154. 161.] [182. 189. 196.] [217. 224. 231.] [252. 259. 266.] [287. 294. 301.] [322. 329. 336.] Auto-vectorization with vmap: [[ 7. 14. 21.] [ 42. 49. 56.] [ 77. 84. 91.] [112. 119. 126.] [147. 154. 161.] [182. 189. 196.] [217. 224. 231.] [252. 259. 266.] [287. 294. 301.] [322. 329. 336.]] まずはvmapに関して説明します。vmapはコードを変更することなく関数をベクトル化(auto-vectorization)するものです。auto-vectorizationにより、vmap APIで関数をラップする以外にコードを変更することなく処理を高速化できます。これは、特にバッチ処理の際に非常に便利です。vmapの機能はまだまだあるので、以下のリンクから確認してください。 jax.readthedocs.io pmapの使い方は、vmapとよく似ています。しかし、pmapはMPIのようなCollective operationを提供し、プログラムが複数のデバイス上で通信しデバイスをまたいで合計や平均などの演算「MapReduce」を実行できます。このAPIにより、プログラムはスケールアウトできます。 #マルチデバイスでpmapを適用する @ partial (jax.pmap, axis_name= "num_devices" ) def update (params: Params, x: jnp.ndarray, y: jnp.ndarray) -> Tuple[Params, jnp.ndarray]: loss, grads = jax.value_and_grad(loss_func)(params, x, y) grads = jax.lax.pmean(grads, axis_name= "num_devices" ) loss = jax.lax.pmean(loss, axis_name= "num_devices" ) new_params = jax.tree_multimap( lambda param, g: param - g * step_size, params, grads ) return new_params, loss 上記のコードサンプルでは、異なるデバイスでloss_func関数から返された結果に対してCollective meanを実行し、パラメータを更新しています。このコードブロックは、アクセラレータデバイスの数を気にすることなく、どのマシン上でも実行できます。バックグラウンドでJAXによって自動的にスケールアウトし、管理されます。ただし、アクセラレータデバイスの数に応じて、デバイスの次元を一致させる必要があります。デバイスの次元とは、デバイス間でデータを均等に分割するために使用される仮想的なメトリックディメンジョンのことです。例えば、8台のデバイスがある場合、同時に処理するサンプルは少なくとも8個必要です。 環境設定 本記事では、JAXライブラリを用いて2つのデータセットを検証します。1つ目はMNISTの手書き数字データセット、2つ目はカスタムデータセットです。まずはMNIST手書き数字データセットのためのシンプルな多層パーセプトロン(MLP)を構築しました。 以下の図は使用したインフラ設定です。 MLPのハイパーパラメータ設定です。 以下の図はアクセラレータ毎の平均実行の時間、異なるアクセラレータでのアルゴリズム実行時間の比較です。 JAXが提供するpmap APIを使えば、簡単に複数のデバイスでモデルを実行し、学習と配信のためにスケールアウトさせることができました。CPUでは各エポックに約3.34秒かかるのに対し、4GPUでは1.09秒であることが確認されました。この図は、より多くの並列処理を行うほど、この特定のアルゴリズムの実行時間が短縮することを示しています。 以下の4GPUでの学習と各エポックでの実行時間図は、4つのGPUアクセラレータを用いた場合の、各エポックにおけるモデル学習と実行時間の性能を示しています。 上の図から、モデルはトレーニングデータセットでうまく学習し、バリデーションデータセットでもうまくいっていることが確認できました。また、学習処理は4つのGPUデバイス全てに均等に分散されています。最初のエポックを終えるのに約1.8秒、その後のエポックでは約1.09秒かかっています(左下図)。最初のエポックでは、クラウドストレージから画像をリモートで読み込んで、パイプラインデータ変換に応じた前処理を行う必要があります。その後、パイプラインのキャッシュ機能を使ってデータをローカルにキャッシュし、次のエポックに備えることで、実行時間を大幅に短縮しています。 GPU使用率の観点から、GPUは最初からビジー状態であり、トレーニングの最初のエポックの終わりである1.87秒付近にいくつかのピークあることがわかりました(右下図)。これは、GPU(特にgpu_2)がパイプラインからのデータロードと変換同時にいくつかの処理を保持していることを物語っています。データパイプラインがリモートストレージデバイスからデータをロードするのと並行して、学習処理が開始されていることがわかります。GPUデバイスは約40%のピークにあり、すべてのGPUを100%利用するにはMLPのレイヤーがかなり小さいので妥当なところです。 私たちが発見した興味深い事実は、クラウドストレージの場所は私たちのアルゴリズムをホストしているマシンと異なる場合、リモートデータ取得により最初のエポックに余分な時間が追加されるということです。これは通常、クラウドインフラの仕様で、ユーザーがアクセスするエッジロケーションにデータをダウンロードする必要があるためです。ホストマシンから初めてアクセスした後、データはエッジロケーションに保存され、トレーニングの実行時間が大幅に改善されます。 以下の図はトレーニングマシンとクラウドストレージが異なる地域または場所にある場合の結果です。 最初のアクセス後、エッジロケーションのキャッシュにより、ランタイムが改善されました。 計算量が大きいほどGPUのデバイス使用率が高くなることを検証するため、より大きなMLPレイヤーと大きな画像でテストを行いました。ハードウェアの仕様は、前回のMNIST手書きデータセットでの実験と同じにしています。 以下の図は各エポックにおける異なるアクセラレータのアルゴリズム実行時間の比較です。 このシナリオでは、シングルGPUでの学習が最高のランタイムパフォーマンスをもたらすことが観察され、興味深いです。シングルGPUでは、CPUよりも約12倍高速になります。この特定のデータセットとマルチGPUデバイスの場合、マルチデバイスで実行する際のMap-Reduce操作のオーバーヘッドが原因だと思われます。 予想通り、層数と中間素子数が増えれば増えるほど、計算負荷が大きくなります。最初のエポック(0-28秒)の間(右下図)、ホスト上で起きているデータの前処理とGPU上のトレーニングステップが同時に実行されていることが観察されます。もちろん、MLPのレイヤーに生のピクセルを入力しているため、モデルの学習にはあまり期待できません。より良い結果を得るためには、畳み込みニューラルネットワークを使用することが望ましいでしょう。 まとめ 結論として、並列処理とキャッシュを備えたTF Dataライブラリを使用することでGPUデバイスのポテンシャルを引き出し、より高速な学習が可能になることが確認できました。GoogleやAWSのような大手ベンダーのクラウドストレージにデータを保存しつつ、データ取得を高速に行うことを説明しました。TF Dataではクラウドストレージだけでなく、BigQueryやBigtableなどからもリモートでデータを読み込むことができます。詳しい使い方はドキュメントをご覧ください。 また、マルチデバイス処理に関するvmapやpmapなど、JAXの便利な機能のデモンストレーションをしました。JAXは、NumPyのAPIのほとんどがJAXでミラーリングされているため、NumPyに慣れている人であれば簡単に使用できます。さらに、Autogradは、Pythonのネイティブ関数とNumPyの関数の微分を自動化できます。pmapの使用に適合するようにプログラムを開発すれば、JAXがバックグラウンド処理を引き受けてくれるのでCPUやマルチGPUデバイスに関係なくどこでもこのプログラムを実行できます。 私見ですが、JAXは非常に柔軟な使い方ができ、PoCを素早く行うためのコーディングが容易です。しかし、機械学習アルゴリズムを複数のプラットフォームで運用することを目指すのであれば、JAXはまだ成熟していないと言えます。TensorflowやPyTorchのような、強力なエコシステムを持ち、広く採用されている他のフレームワークに目を向けたほうがよいと思います。 さらに、JAXによるスケーラブルなインフラを実証するため、シンプルなMLPアルゴリズムを採用しました。JAXの複雑で高度なモデルを使って、MLの問題を解決出来ます。コードを入れ替えるだけ、本記事で取り上げたことはほとんど同じです。私は、FlaxやHaikuのような深層学習用のJAXフレームワークを使用することをお勧めします。JAXの公式GitHubリポジトリのチェックを忘れないでください。JAXを使ったハンズオンを楽しんでください。 本記事をシンプルにするため、コードブロックでの説明を省略し、すべて Jupyter Notebook にまとめました。ぜひご覧ください。 ZOZO NEXTでは、機械学習を適切に使用して課題を解決できるMLエンジニアを募集しています。今回は、JAXを使ってレガシーアルゴリズムを改善した方法を紹介しました。MLアルゴリズムをファッションビジネスへ応用することに興味がある方は、以下のリンクからぜひご応募ください。 hrmos.co hrmos.co hrmos.co
こんにちは。SRE部の川崎( @yokawasa )、巣立( @tmrekk_ )です。私たちは、ZOZOTOWNのサイト信頼性を高めるべく日々さまざまな施策に取り組んでおり、その中の1つに負荷試験やその効率化・自動化があります。本記事では、私たちが負荷試験で抱えていた課題解決のために開発、公開したOSSツール、Gatling Operatorを紹介します。 github.com はじめに ZOZOTOWNは非常にピーク性のあるECシステムであることから、常にそのシステムが受けうる負荷の最大値を意識しております。想定しうる最大規模の負荷を受けてもユーザー体験を損なうことなくサービス継続できることをプロダクションリリースの必須条件としています。したがって、新規リリースやアップデート、大規模セールなどのシステム負荷に影響を与えうるイベント前など、比較的頻繁に負荷試験を実施しています。そして、社内でもっとも利用実績のある負荷試験ツールが Gatling になります。 Gatlingとは、Webアプリケーション向けのOSS負荷試験フレームワークです。テストシナリオをScala( Gatling 3.7 からはJavaやKotlinもサポート)のDSLで記述でき、結果レポートをHTMLで自動生成してくれます。 本記事で紹介するGatling Operatorは、このGatlingをベースとした分散負荷試験のライフサイクルを自動化するKubernetes Operatorです。 Kubernetes Operatorとは、カスタムリソース(以下、CR)とそのCRにリンクされたカスタムコントローラーによりKubernetesを拡張するための仕組みであり、Kubernetes上で稼働するワークロードのライフサイクル管理の自動化を可能にします。ワークロードの目的の状態を定義したCRをクラスターにデプロイすると、カスタムコントローラーが 制御ループ を通じてその目的の状態に近づくように制御します。 Gatling Operatorの場合は、分散負荷試験の内容を定義したGatling CRがクラスターにデプロイされると、Gatling CRにリンクされたコントローラーが目的の状態に近づくように制御することで一連の分散負荷試験のタスクが自動化されます。 なぜ開発したのか? 開発の発端は、ZOZOTOWN冬セール対策の負荷試験における課題感からでした。 冬セールはZOZOTOWNにおいて一年でもっともユーザーアクセスが多いイベントです。これを安定的に乗り越えるべく、2021年冬セールから事前にオンプレ・クラウドを横断した大規模な負荷試験を本番相当の環境を使って実施しております。この負荷試験は、機能ごとの単体の負荷試験ではなくユーザー導線に合わせてZOZOTOWNにセール同等のトラフィックを再現し、ボトルネックとなりうる箇所を事前に潰すことを目的としています。なお、今年の2022年冬セール向け負荷試験の詳細については別記事にて紹介される予定です。 さて、この冬セール向けの負荷試験ですが、当然ながら目標スループットを再現するためにはGatling実行用ノードを大量に並べて並列実行させる必要があります。これが単一システムの負荷試験であれば、試験用にチューニングされた一台の仮想マシンからの実行で事足りることが多く、多くても数台並べてタイミングを合わせて実行することで目標スループットを再現できます。ただし、冬セール規模となればそうも行かず、大量のGatling実行用ノードの準備、大量ノードからの実行タイミング調整やレポート生成などさまざまな運用面での課題感がありました。 そこで、2021年冬セール向けの負荷試験では、運用面での課題感を解決すべくAmazon ECSからAWS Fargateをデータプレーンとして利用する方式を採用しました。そこに大量のGatling実行用ノードを並べて分散負荷試験の実行やレポート生成などを自動化しました。これにより当初感じていた運用面での課題はある程度解消されました。ただし、逆にFargateの制約から生ずる課題に直面しました。 Fargateはオンデマンドでコンピューティングリソースを提供する仕組みであり、タスク実行毎にホストリソース確保と準備処理が行われるため、EC2と比べPod起動までの待ち時間が長くなりがちでした。 タスク用に予約可能なvCPUとメモリの選択の幅が狭く、したがって目標スループットを再現するためには必要ノード数が多くなりがちになりました。これによりFargateの同時に実行可能なタスク数の上限に達しやすくなり、目標スループットを安定的に再現できないという課題がありました。なお、当時と比べるとFargateのインスタンスあたりの性能は向上し、同時に実行可能なタスク数の上限も上がっていることから問題は緩和されているといえます。 これらの課題を解消すべく、2022年冬セール対策負荷試験に向けてGatling Operatorを開発することになりました。これにより、分散負荷試験の自動化はもとより、Gatling用Podに柔軟にノードリソースの配分ができるようになりました。また、分散負荷試験がマニフェストで宣言的に定義できるようになったことも大きなメリットといえます。 Gatling Operatorの処理概要 Gatling Operatorの処理概要を簡単に説明します。利用者が分散負荷試験の内容を定義したGatling CR(後述)をクラスターにデプロイすると、カスタムコントローラーにより、次のような一連のタスクが自動実行されます。 Gatling Runner Jobの作成 Gatling Runner Jobは、指定された並列実行数(Parallelism)分のGatling Runner Podを作成します 各Gatling Runner Podでは、Gatlingテストシナリオを実行して、出力された結果レポート用ログファイル(simulation.log)をクラウドストレージにアップロードします。次の「Gatling Runner Podのマルチコンテナー構成」でGatling Runner Podについてさらに詳しく解説します Gatling Reporter Jobの作成(オプショナル) Gatling Runner Jobが完了すると、Gatling Reporter Jobを作成し、そのJobがGatling Reporter Podを作成します Gatling Reporter Podはすでにクラウドストレージにアップロードされた全Pod分の結果レポート用ログファイルをローカルファイルシステムにダウンロードします。そして、すべてのログファイルを元に集約したHTML結果レポートを生成し、それをクラウドストレージにアップロードします 試験結果をメッセージ通知プロバイダーに送信(オプショナル) 前のすべてのステップが完了すると、試験の実行結果をメッセージ通知プロバイダー用Webhookに送信します 関連リソースのクリーンアップ(オプショナル) すべてのステップが完了すると、Gatling CRとその関連リソースであるJobやPodを削除します Gatling Runner Podのマルチコンテナー構成 分散負荷試験のメインワークロードであるGatling Runner Podのコンテナー構成について解説します。 上図のようにGatling Runner Podはマルチコンテナーで構成されています。gatling-runnerによるGatling負荷試験の実行以外に、gatling-waiterとgatling-result-transfererでそれぞれ次のような前処理と後処理が実行されます。 gatling-waiterコンテナー Gatling Runner Jobにより作成される並列実行数(Parallelism)分のすべてのGatling Runner Podが開始されるまで待機します Gatling Runner Podが複数作成される場合、すべてのPodが同じタイミングでスケジューリングされる保証がないため、待機処理によりgatling-runner実行のタイミングを同期させます gatling-runnerコンテナー Gatlingテストシナリオを実行します 生成された結果レポート用ログファイルは共有Volume(emptyDir)に出力します gatling-result-transfererコンテナー gatling-runnerで生成された結果レポート用ログファイルを共有Volumeより読み込み、クラウドストレージにアップロードします gatling-waiterとgatling-runnerはinitコンテナーとして、gatling-result-transferはメインコンテナーとして作成していることが特徴として挙げられます。initコンテナーはPod内でメインコンテナーの前に実行されます。また、initには1つ以上のコンテナーを定義でき、それらは1つずつ順番に実行されます。 なお、結果レポート生成を選択しない場合はgatling-result-transfererによる処理は不要であるため、gatling-waiterがinitコンテナーとして、gatling-runnerがメインコンテナーとして作成されます。 使い方(Quickstart) ここでは、Gatling OperatorのインストールとGatling CRのデプロイ手順を説明します。 事前準備 kubectl と kind のインストール gatling-operator リポジトリのクローン クラスターの構築 今回使用するクラスターはkindを使って構築します。まずは、kindを使ってクラスターを構築します。 なお、kindで構築するクラスターは、1.18以上を推奨します。また、kindで使用するNodeのImageバージョンは リリースノート から確認できます。 $ kind create cluster $ kubectl config current-context kind-kind Gatling Operatorのインストール kindで構築したクラスターにGatling Operatorをインストールします。 $ kubectl apply -f https://github.com/st-tech/gatling-operator/releases/download/v0. 5 . 0 /gatling-operator.yaml namespace/gatling-system created customresourcedefinition.apiextensions.k8s.io/gatlings.gatling-operator.tech.zozo.com created serviceaccount/gatling-operator-controller-manager created role.rbac.authorization.k8s.io/gatling-operator-leader-election-role created clusterrole.rbac.authorization.k8s.io/gatling-operator-manager-role created rolebinding.rbac.authorization.k8s.io/gatling-operator-leader-election-rolebinding created clusterrolebinding.rbac.authorization.k8s.io/gatling-operator-manager-rolebinding created deployment.apps/gatling-operator-controller-manager created 以上を実行することにより、CRDやManagerなどのリソースがデプロイされGatling CRを実行する準備ができます。 $ kubectl get crd NAME CREATED AT gatlings.gatling-operator.tech.zozo.com 2022-02-02T06:00:25Z $ kubectl get deploy -n gatling-system NAME READY UP-TO-DATE AVAILABLE AGE gatling-operator-controller-manager 1 / 1 1 1 44s 今回はv0.5.0のマニフェストを使用しています。必要に応じてバージョンを変更してください。なお、バージョンはリリース一覧ページより確認できます。 github.com Gatling CRのデプロイ 続いて、Gatling CRをデプロイします。 ここでは、gatling-operatorリポジトリの サンプル を使用します。 $ git clone https://github.com/st-tech/gatling-operator.git $ cd gatling-operator $ kustomize build config/samples | kubectl apply -f - serviceaccount/gatling-operator-worker unchanged role.rbac.authorization.k8s.io/pod-reader unchanged rolebinding.rbac.authorization.k8s.io/read-pods configured secret/gatling-notification-slack-secrets unchanged gatling.gatling-operator.tech.zozo.com/gatling-sample01 created 上記を実行することでGatling Runner Podの実行に必要なServiceAccountやGatling CRがデプロイされます。 Gatling CRのデプロイ後、Gatling CR、Gatling Runner Job、Gatling Runner Podが生成され、Gatlingテストシナリオが実行されます。 $ kubectl get gatling NAME AGE gatling-sample01 16s $ kubectl get job NAME COMPLETIONS DURATION AGE gatling-sample01-runner 0/3 19s 19s $ kubectl get pod NAME READY STATUS RESTARTS AGE gatling-sample01-runner-4dk6z 0/1 PodInitializing 0 22s gatling-sample01-runner-nlxcm 0/1 PodInitializing 0 22s gatling-sample01-runner-zdqgq 0/1 PodInitializing 0 22s PodのログからもGatlingが実行されていることが確認できます。 $ kubectl logs gatling-sample01-runner-4dk6z -c gatling-runner -f Wait until 2022-02-03 09:00:12 GATLING_HOME is set to /opt/gatling Simulation MyBasicSimulation started... ================================================================================ 2022-02-03 09:01:42 5s elapsed ---- Requests ------------------------------------------------------------------ > Global (OK=2 KO=0 ) > request_1 (OK=1 KO=0 ) > request_1 Redirect 1 (OK=1 KO=0 ) ---- Scenario Name ------------------------------------------------------------- [--------------------------------------------------------------------------] 0% waiting: 0 / active: 1 / done: 0 ================================================================================ ================================================================================ 2022-02-03 09:01:47 10s elapsed ---- Requests ------------------------------------------------------------------ > Global (OK=3 KO=0 ) > request_1 (OK=1 KO=0 ) > request_1 Redirect 1 (OK=1 KO=0 ) > request_2 (OK=1 KO=0 ) ---- Scenario Name ------------------------------------------------------------- [--------------------------------------------------------------------------] 0% waiting: 0 / active: 1 / done: 0 ================================================================================ ================================================================================ 2022-02-03 09:01:51 14s elapsed ---- Requests ------------------------------------------------------------------ > Global (OK=6 KO=0 ) > request_1 (OK=1 KO=0 ) > request_1 Redirect 1 (OK=1 KO=0 ) > request_2 (OK=1 KO=0 ) > request_3 (OK=1 KO=0 ) > request_4 (OK=1 KO=0 ) > request_4 Redirect 1 (OK=1 KO=0 ) ---- Scenario Name ------------------------------------------------------------- [##########################################################################]100% waiting: 0 / active: 0 / done: 1 ================================================================================ Simulation MyBasicSimulation completed in 14 seconds このサンプルではGatlingの結果レポートの通知やクラウドプロバイダーへの結果レポートの保存は行われません。 以降の章で説明する .spec.cloudStorageSpec や .spec.notificationServiceSpec を設定することで可能になります。 設定例の紹介 Gatling CRの設定項目についてサンプルを用いて説明します。なお、Gatlingカスタムリソースの定義についてはこちらの CRDリファレンスページ を参照ください。 Gatling CRについて Gatling CRでは大きく次の5つを定義します。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : ## 実行フラグ generateReport : true notifyReport : true cleanupAfterJobDone : true ## Gatling Runner PodのSpec定義 podSpec : ## 結果レポート格納用のクラウドストレージの定義 cloudStorageSpec : ## 結果レポート通知先の定義 notificationServiceSpec : ## Gatlingテストシナリオと実行方法の定義 testScenarioSpec : 5つの定義について詳しく説明していきます。 実行フラグの設定 Gatling CRでは、Gatlingの実行に関する設定やGatling CRの挙動の設定が可能です。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : generateReport : true generateLocalReport : true notifyReport : true .spec.generateReport ではGatlingの実行結果レポートを生成するかどうかを指定できます。 .spec.generateReport が true の場合、後述する .spec.cloudStorageSpec の設定も必要になります。 .spec.generateLocalReport ではGatlingの実行結果レポートをPod毎に生成するかどうかを指定できます。 .spec.notifyReport ではGatlingの実行結果を通知するかどうかを指定できます。 .spec.notifyReport が true の場合、後述する .spec.notificationServiceSpec の設定も必要になります。 他にも、 .spec.cleanupAfterJobDone ではGatling Operatorが生成するJobの実行完了後の挙動を設定できます。 true の場合、Runner Jobの実行が完了するとGatling CRは削除されます。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : cleanupAfterJobDone : true Gatling Runner Podをカスタマイズする podSpecでは、Runnner Jobが生成するPodの設定が可能です。 podSpecでは、 .spec.serviceAccountName が必須項目となっています。 このサービスアカウントはgatling-waiterコンテナーがGatling実行タイミングの同期目的で他のPodの状態を取得するために必要となります。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : testScenarioSpec : serviceAccountName : "gatling-operator-sa-sample" --- apiVersion : v1 kind : ServiceAccount metadata : name : gatling-operator-sa-sample --- apiVersion : rbac.authorization.k8s.io/v1 kind : Role metadata : name : pod-reader rules : - apiGroups : [ "" ] resources : [ "pods" ] verbs : [ "get" , "list" , "patch" ] --- apiVersion : rbac.authorization.k8s.io/v1 kind : RoleBinding metadata : name : read-pods subjects : - kind : ServiceAccount name : gatling-operator-sa-sample apiGroup : "" roleRef : kind : Role name : pod-reader apiGroup : "" 以下が、 .spec.podSpec の例になります。 .spec.podSpec.serviceAccountName にてさきほどのServiceAccountを指定しています。 他にも、 resources や affinity など標準のPodと同様の設定が可能です。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample01 spec : podSpec : serviceAccountName : "gatling-operator-sa-sample" gatlingImage : ghcr.io/st-tech/gatling:latest rcloneImage : rclone/rclone resources : limits : cpu : "500m" memory : "500Mi" affinity : nodeAffinity : requiredDuringSchedulingIgnoredDuringExecution : nodeSelectorTerms : - matchExpressions : - key : kubernetes.io/os operator : In values : - linux tolerations : - key : "node-type" operator : "Equal" value : "non-kube-system" effect : "NoSchedule" Gatlingテストシナリオと実行方法を設定する testScenatioSpec では、Gatlingを実行する上で必要になるリソースの配置場所や定義方法などの設定が可能です。 .spec.testScenarioSpec.startTime ではGatlingの実行開始時間の設定が可能です。 フォーマットは %Y-%m-%d %H:%M:%S となっており、UTCで設定します。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : testScenarioSpec : startTime : 2022-01-01 12:00:00 .spec.testScenarioSpec.parallelism ではGatlingの同時実行数、すなわちRunner Jobが生成するPod数の設定が可能です。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : testScenarioSpec : parallelism : 4 続いて、Gatlingのテストシナリオ、テストデータ、gatling.confの設定方法について説明します。 以下の2種類のデプロイ方法が用意されています。 Gatlingコンテナーにまとめてパッケージ化してデプロイ ConfigMapとしてデプロイ まず、Gatlingコンテナーにまとめてパッケージ化してデプロイする方法を説明します。 .spec.testScenarioSpec.simulationsDirectoryPath では、Gatlingのテストシナリオのファイルパスの設定が可能です。 設定されていない場合は、デフォルトで /opt/gatling/user-files/simulations が使用されます。 .spec.testScenarioSpec.resourcesDirectoryPath では、テストに使用するデータのファイルパスの設定が可能です。 設定されていない場合は、デフォルトで /opt/gatling/user-files/resources が使用されます。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : testScenarioSpec : simulationsDirectoryPath : "dir-path-to-simulation" resourcesDirectoryPath : "dir-path-to-resources" 上記で設定したデータをGatlingコンテナーにまとめてパッケージ化する方法についてはこちらのGatling Operatorユーザーガイドをご覧ください。 github.com 続いて、GatlingテストシナリオをConfigMapとしてデプロイする方法を説明します。Gatlingが使用するテストシナリオやテストデータ、gatling.confなどをGatling CRのマニフェストに直接記述できます。 .spec.testScenarioSpec.simulationData では、シナリオファイルを記述できます。 .spec.testScenarioSpec.resourceData では、テストデータを記述できます。 .spec.testScenarioSpec.gatlingConf では、gatling.confを記述できます。 ここで記述されたものは、ConfigMapへと変換されControllerによって処理されます。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : testScenarioSpec : simulationData : MyBasicSimulation.scala : | # scalaファイルをここに書く resourceData : sample.csv : | # テストデータをここに書く gatlingConf : gatling.conf : | # gatling.confをここに書く Gatling OperatorリポジトリにGatling CRマニフェストへ直接記述する サンプル も用意されています。 結果レポート格納用クラウドストレージプロバイダーを設定する Gatling OperatorではGatligが生成したレポートを任意のクラウドプロバイダーへ格納できます。 執筆時点では、AWS(S3)・GCP(GCS)に対応しています。 cloudStorageSpec では、格納するクラウドプロバイダーの情報を設定します。 以下の例では、Amazon S3にて ap-northeast-1 の gatling-operator-reports という名前のバケットにレポートを格納します。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : cloudStorageSpec : provider : "aws" bucket : "gatling-operator-reports" region : "ap-northeast-1" なお、レポートの格納には他にもPodからAmazon S3にアクセスできるようにAWSクレデンシャルの設定が必要になります。詳しくは、 ユーザーガイド を参照ください。 通知用メッセージプロバイダーを設定する Gatlingの実行終了後にレポートのリンクと共に通知サービスプロバイダーに通知できます。 notificationServiceSpec にて通知先の設定が可能です。 以下の例では、Slackに完了通知を送信します。 apiVersion : gatling-operator.tech.zozo.com/v1alpha1 kind : Gatling metadata : name : gatling-sample spec : notificationServiceSpec : provider : "slack" secretName : "gatling-notification-slack-secrets" .spec.notificationServiceSpec.secretName では、通知先であるSlackのWebhook URLが登録されたSecret名を指定します。 apiVersion : v1 data : incoming-webhook-url : # base64-encoded webhook URL for slack kind : Secret metadata : name : gatling-notification-slack-secrets type : Opaque base64暗号化するとはいえWebhook URLをマニフェストに直接記載したくない場合もあります。そのような場合は、AWS Secrets Managerのような外部の秘匿情報を管理できるサービスに保存することを検討ください。 外部サービスに登録した秘匿情報からKubernetesのSecretリソースを生成するツールはいくつかあります。その中の1つの External Secrets の利用例を紹介します。 以下の例では、AWS Secrets Managerにnotification-slack-gatling-noticeという名前でシークレットを作成し、Webhook URLを保存しています。そのSecretをExternal Secretsを経由して取得するようにしています。 apiVersion : "kubernetes-client.io/v1" kind : ExternalSecret metadata : name : gatling-notification-slack-secrets spec : backendType : secretsManager data : - key : notification-slack-gatling-notice name : incoming-webhook-url property : incoming-webhook-url 実際に送られたメッセージがこちらです。 Report URLへアクセスするとGatlingが生成した結果レポートを確認できます。 まとめ 本記事では、Gatlingをベースとした分散負荷試験のライフサイクルを自動化するGatling Operatorを紹介しました。 Gatling Operatorにより、分散負荷試験の自動化を始め、Gatling用ノード選択、マニフェストによる分散負荷試験の宣言的管理が実現可能になりました。 今後は、AWSやGCP以外のクラウドプロバイダーへのレポート送信や、S3などの外部リソースのクリーンアップなどの機能を追加予定です。詳しくは、 Issue をご確認ください。また、Gatling OperatorではIssueやPull Requestを歓迎しています。興味を持った方は、ぜひ使ってみてください。 さいごに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 https://hrmos.co/pages/zozo/jobs/0000010 hrmos.co
はじめに こんにちは、検索基盤部 検索研究ブロックの真鍋です。ヤフー株式会社から一部出向していて、主にZOZOTOWNの検索機能へのランキングモデルの導入に従事しています。 本記事では、Elasticsearch上でランキングモデルを扱うための有名なプラグインの仕組みと、同プラグインにZOZOが実装した機能を紹介します。 まず、本記事の背景を説明します。ZOZOTOWNでキーワード検索すると、結果の商品が並びます。結果の商品は非常に多数になることも多いので、ユーザ体験を損なわないためには、その並び順も重要です。ここで言うランキングモデルとは、この並び順の決定のために、商品のスコアを計算する式のことを指します。このような式は機械学習によって生成され、非常に複雑になることもあります。そのため、検索エンジンの標準機能では実行できず、プラグインを導入して初めて実行できることもあります。 ZOZOTOWNでは検索エンジンとしてElasticsearchを使用しています。そして、Elasticsearch上でランキングモデルを実行するために、OpenSource Connectionsが提供するLearning to Rank pluginを使用しています。以下、このプラグインを指して、単に本プラグインと呼びます。 github.com 本記事の前半では、本プラグインの仕組みを説明します。まずランキングモデルを実行する仕組みを紹介し、次にランキングモデルを学習するための特徴量の値を出力する仕組みを紹介します。後半では、ZOZOが本プラグインに実装した、特徴量キャッシュの機能を紹介します。これは、ランキングモデルの実行と特徴量の値の出力を併用する際に、後者を効率化するための機能です。 具体的なコードとしては、本プラグインのバージョンv1.5.8-es7.16.2を例に説明します。 github.com 本記事では本プラグインの詳しい使い方は紹介しませんが、過去の記事で紹介しておりますので、ぜひ合わせてご覧ください。 techblog.zozo.com 目次 はじめに 目次 ランキングモデルの実行の仕組み LtrQueryParserPluginによるクエリのパースと、RankerQueryオブジェクトの生成 StoredLtrQueryBuilder RankerQuery Query#rewrite RankerQuery#createWeightによるRankerWeightオブジェクトの生成 RankerWeight#scorerによるRankerScorerオブジェクトの生成 RankerScorerによるドキュメントのスコアリング RankerScorer#score 特徴量ロギングの仕組み LtrQueryParserPlugin#getFetchSubPhasesによるLoggingFetchSubPhaseの挿入 LoggingFetchSubPhase#getProcessorにおける特徴量ロギングの下準備 RankerQuery#toLoggerQuery HitLogConsumer LoggingFetchSubPhaseProcessor#processにおける実際のロギング 特徴量キャッシュの仕組み StoredLTRQueryBuilderがcache要素をサポートするように拡張 未指定であったことを覚えておく キャッシュ本体の設計 ドキュメントIDについて キャッシュの受け渡し キャッシュへのエントリの挿入 キャッシュからのエントリの引き当て DisjunctionDISI#advanceでの引き当て RankerScorer#scoreでの引き当て まとめ ランキングモデルの実行の仕組み まず、本プラグインでランキングモデルを実行する仕組みを紹介します。本プラグインでランキングモデルを実行するには、例えば以下のクエリをElasticsearchに送信します(本プラグインの公式ドキュメントより引用)。 { " query ": { " match ": { " _all ": " rambo " } } , " rescore ": { " window_size ": 1000 , " query ": { " rescore_query ": { " sltr ": { " params ": { " keywords ": " rambo " } , " model ": " my_model " } } } } } https://elasticsearch-learning-to-rank.readthedocs.io/en/latest/searching-with-your-model.html#rescore-top-n-with-sltr このクエリでは、以下のことが指定されています。 クエリキーワードが rambo 既存の検索結果の上位1,000件をランキングモデルで並べ替える その際に使うランキングモデルの名前が my_model LtrQueryParserPluginによるクエリのパースと、RankerQueryオブジェクトの生成 例のクエリは、まず本プラグインのコードのうち LtrQueryParserPlugin に入力されます。 LtrQueryParserPlugin はElasticsearch本体が提供するインタフェース SearchPlugin を実装しています。このため、 LtrQueryParserPlugin はElasticsearch本体の SearchModule から見えるようになっています。 SearchModule はクエリの各要素(例のクエリで言うと match や sltr )をどのクラスにパースさせるかを管理しています。具体的には、組み込みのクラスのほか、各プラグインが SearchModule#getQueries で指定してくるクラスも考慮します。 LtrQueryParserPlugin#getQueries では、以下の通り sltr 要素を StoredLtrQueryBuilder にパースさせるという指定をしています。ただし、 StoredLtrQueryBuilder.NAME は sltr であることに注意してください。この指定のため、次は本プラグイン独自の StoredLtrQueryBuilder に制御が移ります。 new QuerySpec<>(StoredLtrQueryBuilder.NAME, (input) -> new StoredLtrQueryBuilder(getFeatureStoreLoader(), input), (ctx) -> StoredLtrQueryBuilder.fromXContent(getFeatureStoreLoader(), ctx)), https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/LtrQueryParserPlugin.java#L144-L146 StoredLtrQueryBuilder StoredLtrQueryBuilder はクエリのJSONをパースし、メモリ上の表現(後述の RankerQuery )をビルドするのに使います。ビルドのために、以下の主要なメンバーを持っています。 ランキングモデル名 ストア名 ランキングモデルが保存されているElasticsearchインデックスの名前(デフォルトは .ltrstore ) Map<String, Object> クエリの params に対応するオブジェクト(例のクエリを参照) StoredLtrQueryBuilder#doToQuery を呼ぶと、 RankerQuery が返ります。このとき、ランキングモデル本体もメモリにロードされます。 StoredLtrQueryBuilder のコードは以下にあります。 https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/StoredLtrQueryBuilder.java RankerQuery Elasticsearchは検索ライブラリLuceneに依存しています。 RankerQuery は、Luceneが提供する抽象クラス Query の実装です。これはクエリのメモリ上の表現にあたります。以下の主要なメンバーを持っています。 FeatureSet ランキングモデルで使用する特徴量のリスト LtrRanker ランキングモデルのうち、 FeatureSet 以外の部分に対応するオブジェクト List<Query> 子 Query のリスト 詳しくは説明しませんが、ランキングモデルにおける特徴量とは、スコアを計算するためのクエリキーワードやドキュメントに関する値です。例えば、ドキュメント中でクエリキーワードが出現する回数などです。 LtrRanker は、具体的な特徴量を覚えておくオブジェクト FeatureVector の用意とスコアの計算を責務とします。 List<Query> が必要なのは、本プラグインでは、1つの特徴量は1つの子 Query に対応するためです。 RankerQuery のコードは以下にあります。 https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java Query#rewrite ここで、クエリ1つに Query オブジェクト1つが対応するのであれば分かりやすいです。しかし、実際にはそうではありませんので注意してください。すなわち、同一のクエリでも処理の途中で Query オブジェクトが変わることもあります。具体的には、 Query#rewrite を呼ぶと、別の Query オブジェクトが返ります。これは、抽象的なクエリを具体的で実行可能なクエリに書き換えたり、実行の効率が悪いクエリを良いクエリに書き換えたりするメソッドです。 RankerQuery そのものに書き換えるべきところはありません。ただし、 RankerQuery#rewrite は子 Query の rewrite も呼び出します。そして、そこで書き換えが行われた場合は、新しい RankerQuery を生成して返します。 @Override public Query rewrite(IndexReader reader) throws IOException { List<Query> rewrittenQueries = new ArrayList<>(queries.size()); boolean rewritten = false ; for (Query query : queries) { Query rewrittenQuery = query.rewrite(reader); rewritten |= rewrittenQuery != query; rewrittenQueries.add(rewrittenQuery); } return rewritten ? new RankerQuery(rewrittenQueries, features, ranker, featureScoreCache) : this ; } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L122-L132 RankerQuery#createWeightによるRankerWeightオブジェクトの生成 ここまで、クエリに対応する Query オブジェクトの生成を説明してきました。ここから、クエリを実行するためのオブジェクトの生成を説明していきますが、そのためにはElasticsearchにおけるクエリの実行の基本を知る必要があります。まず、Elasticsearchのインデックスは複数のセグメントに分かれています。そして、複数のセグメントに分かれたインデックス上でクエリを実行するために、異なる役割を持つ以下のオブジェクトを生成していきます。 Query クエリに対応するオブジェクト(インデックスの状態とは独立) Weight あるクエリをある時点のインデックスに対して実行するためのオブジェクト Scorer あるクエリをある時点のインデックスのあるセグメントに対して実行するためのオブジェクト さて、本節では Weight を説明します。前述の通り、あるクエリをある時点のインデックスに対して実行するためのオブジェクトが Weight です。 Weight の生成は、 Query#createWeight を IndexSearcher を引数として呼び出すことで行われます。これは、 IndexSearcher が、ある時点のインデックスの状態に対応するためです。 RankerQuery#createWeight の実装は以下です。ただし、スコアが不要という特殊な場合の処理が入っていますので、そこは省略しました。 @Override public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost) throws IOException { (省略) List<Weight> weights = new ArrayList<>(queries.size()); // XXX : this is not thread safe and may run into extremely weird issues // if the searcher uses the parallel collector // Hopefully elastic never runs MutableSupplier<LtrRanker.FeatureVector> vectorSupplier = new Suppliers.FeatureVectorSupplier(); FVLtrRankerWrapper ltrRankerWrapper = new FVLtrRankerWrapper(ranker, vectorSupplier); LtrRewriteContext context = new LtrRewriteContext(ranker, vectorSupplier); for (Query q : queries) { if (q instanceof LtrRewritableQuery) { q = ((LtrRewritableQuery) q).ltrRewrite(context); } weights.add(searcher.createWeight(q, ScoreMode.COMPLETE, boost)); } return new RankerWeight( this , weights, ltrRankerWrapper, features, featureScoreCache); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L182-L214 特徴的な処理としては、子 Query についても、それぞれ対応する Weight を生成しています。 FeatureVectorSupplier と LtrRewritableQuery は、あまり本記事の主題の処理に関わっていないと思われますので、これらの説明は省略します。 RankerWeight#scorerによるRankerScorerオブジェクトの生成 Elasticsearch (Lucene) のインデックスはインデックスセグメントに分かれています。そして、 IndexSearcher や Weight は、ある時点での検索対象のインデックスセグメントのリストを保持しています。これに対して、単一のインデックスセグメントに関するクエリ処理を行うのが Scorer です。 Weight#scorer を単一のインデックスセグメントに対応する LeafReaderContext を引数として呼び出すことで Scorer を生成できます。 RankerWeight#scorer の場合は、以下の通り RankerScorer を生成します。 @Override public RankerScorer scorer(LeafReaderContext context) throws IOException { List<Scorer> scorers = new ArrayList<>(weights.size()); DisiPriorityQueue disiPriorityQueue = new DisiPriorityQueue(weights.size()); for (Weight weight : weights) { Scorer scorer = weight.scorer(context); if (scorer == null ) { scorer = new NoopScorer( this , DocIdSetIterator.empty()); } scorers.add(scorer); disiPriorityQueue.add( new DisiWrapper(scorer)); } DisjunctionDISI rankerIterator = new DisjunctionDISI( DocIdSetIterator.all(context.reader().maxDoc()), disiPriorityQueue, context.docBase, featureScoreCache); return new RankerScorer(scorers, rankerIterator, ranker, context.docBase, featureScoreCache); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L270-L286 例によって、子 Query に対応する子 Weight についても、それぞれ対応する子 Scorer を生成する必要があります。このとき、子 Scorer については2通りに保持しておきます。 List<Scorer> DisiPriorityQueue 通常は List<Scorer> で持っておけば良さそうですが、あえて DisiPriorityQueue でも持っています。詳しくは後述しますが、 Scorer はスコアリング対象のドキュメントのイテレータを持ちます。 DisiPriorityQueue は、 Scorer の優先度付きキューで、 Scorer のイテレータが注目しているドキュメントのIDを優先度とします。これは、全ての子 Scorer のイテレータを効率的に対象のドキュメントまで進める ( advance ) ために用意されているデータ構造だと思われます。ちなみにここで、Disi = Doc ID set iteratorです。 以下、 RankerScorer による具体的なスコアリングについて見ていきます。 RankerScorerによるドキュメントのスコアリング Scorer によるドキュメントのスコアリングは、スコアリング対象の各ドキュメントについて、以下を繰り返すことで行われます。 Scorer#iterator で DocIdSetIterator を取得し、 DocIdSetIterator#advance を、スコアリング対象のドキュメントのIDを引数として呼び出します。 advance の返り値は、イテレータが実際に注目しているドキュメントのIDです。これがスコアリング対象のドキュメントのIDと異なる場合には、スコアリング対象のドキュメントは Scorer になった元の Query とマッチしなかったということです。このとき、実際にはスコアリングの対象外になります。 Scorer#score を呼び出すと、イテレータが実際に注目しているドキュメントのスコアが返ります。 この処理は本プラグインの外で行われます。例えば例のクエリのように rescore_query に sltr を入れた場合は、 QueryRescorer#rescore で行われます。この場合、実際にはスコアリングの対象外になったドキュメントのスコアは0です。 具体的な Scorer の実装である RankerScorer の場合は、 RankerScorer#iterator で独自実装の DisjunctionDISI が返ります。 そして DisjunctionDISI#advance (下記)では、まず、 RankerScorer 自身が Scorer ですので、 main.advance として自身のイテレータを進めます。 RankerScorer にとってはどんなドキュメントもスコアリング対象ですので、自身のイテレータは全ドキュメントのイテレータ(無名クラス。 DocIdSetIterator#all で取得)です。その後、全ての子 Scorer のイテレータも進める必要があります ( advanceSubIterators )。このとき、先ほどの DisiPriorityQueue を使います。 この処理は、特徴量キャッシュにヒットした場合には行われませんが、この工夫については後にZOZOの取り組みの説明で詳しく説明します。 @Override public int advance( int target) throws IOException { int docId = main.advance(target); if (featureScoreCache != null && featureScoreCache.containsKey(docBase + target)) { return docId; // Cache hit. No need to advance sub iterators } advanceSubIterators(docId); return docId; } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L414-L422 RankerScorer#score RankerScorer#iterator の advance の処理が終わったら、 RankerScorer#score を呼ぶことができます。ここでランキングモデルが実行され、結果がスコアとして返ります。具体的には以下の処理が行われます。ただし、特徴量キャッシュについては後でまとめて説明しますので、ここでは特徴量キャッシュが無効の場合の処理を説明します。 FeatureVector を初期化します。 子 Scorer (1つの特徴量に対応)ごとに: スコアリング対象のドキュメントが元のクエリとマッチするか確認します。 マッチするなら(必ずマッチする想定ですが)、 FeatureVector に子 Scorer のスコア(1つの特徴量の値に対応)をセットします。 FeatureVector に基づいて、ランキングモデルを実行します。 @Override public float score() throws IOException { fv = ranker.newFeatureVector(fv); if (featureScoreCache == null ) { // Cache disabled int ordinal = - 1 ; // a DisiPriorityQueue could help to avoid // looping on all scorers for (Scorer scorer : scorers) { ordinal++; // FIXME : Probably inefficient, again we loop over all scorers.. if (scorer.docID() == docID()) { // XXX : bold assumption that all models are dense // do we need a some indirection to infer the featureId? fv.setFeatureScore(ordinal, scorer.score()); } } } else { (省略) } return ranker.score(fv); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L315-L358 特徴量ロギングの仕組み ここまで、ランキングモデルの実行について説明してきました。本プラグインの主要な機能として、他に特徴量ロギング (feature score logging) があります。これは、特徴量の値をレスポンスに含めるという機能です。Elasticsearchの外部のツールでランキングモデルを機械学習するためには、Elasticsearchから特徴量の値を出力する必要があります。特徴量ロギングは、これを実現するための機能です。 特徴量ロギングのためには、例えば以下のクエリをElasticsearchに送信します(本プラグインの公式ドキュメントに基づき作成)。これは、前の例のクエリをベースとして、特徴量ロギングの指定を加えたものです。具体的には、0はじまりで0番目の rescore_query について、そこで使用しているランキングモデル ( my_model ) の特徴量の値をレスポンスに含めるという指定です。 ここからは、以下のようなクエリに対して、本プラグインがどのように動作しているかを説明します。 { " query ": { " match ": { " _all ": " rambo " } } , " rescore ": { " query ": { " rescore_query ": { " sltr ": { " params ": { " keywords ": " rambo " } , " model ": " my_model " } } } } , " ext ": { " ltr_log ": { " log_specs ": { " name ": " log_entry1 ", " rescore_index ": 0 } } } } https://elasticsearch-learning-to-rank.readthedocs.io/en/latest/logging-features.html#logging-values-for-a-live-feature-set LtrQueryParserPlugin#getFetchSubPhasesによるLoggingFetchSubPhaseの挿入 Elasticsearch本体の SearchModule は、前述の通りクエリの各要素をどのコンポーネントにパースさせるかの他、 FetchSubPhase のリストも管理しています。これは、ドキュメントの情報を集めて検索結果を組み立てるためのfetch phaseで行う処理のリストです。 本プラグインでは、検索結果に特徴量を挿入する必要があるので、fetch phaseに処理を追加する必要があります。このために、 LtrQueryParserPlugin では、 SearchPlugin#getFetchSubPhases というAPIを実装しています。 LtrQueryParserPlugin#getFetchSubPhases で独自実装の LoggingFetchSubPhase を返し、 SearchModule がそれを読みます。そして、fetch phaseに LoggingFetchSubPhase の処理が追加されます。 @Override public List<FetchSubPhase> getFetchSubPhases(FetchPhaseConstructionContext context) { return singletonList( new LoggingFetchSubPhase()); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/LtrQueryParserPlugin.java#L153-L156 LoggingFetchSubPhase#getProcessorにおける特徴量ロギングの下準備 FetchSubPhase における処理は、 FetchSubPhase#getProcessor から、 FetchSubPhaseProcessor#process へと流れます。本記事では詳しくは触れませんが、この流れ自体はElasticsearch本体のコードで記述されていますので、詳しくは FetchPhase#execute あたりを参照してください。 さて、 FetchSubPhase#getProcessor はクエリごとに一度だけ走る処理です。その実装である LoggingFetchSubPhase#getProcessor では特徴量ロギングの下準備をします。具体的な下準備は以下の通りです。 特徴量ロギング対象のクエリごとに、ロギング用のクエリ(後述します)に書き換え、対応する HitLogConsumer (これも後述します)も生成します。 特徴量ロギング対象のクエリがnamed queryの場合は LoggingFetchSubPhase#extractQuery で行います。 Rescore queryの場合は、同 extractRescore で行います。 ロギング用のクエリを収集し、全てを BooleanQuery (いわゆるORクエリ)としてまとめたものを用意します。以下のコードで言うと BooleanQuery.Builder builder = new BooleanQuery.Builder() から builder.build() までです。これがメインのクエリになります。 メインの BooleanQuery に対応する Weight もここで用意します。 最終的にはメインの Weight と、 HitLogConsumer のリストを LoggingFetchSubPhaseProcessor に渡して、処理が終了します。 @Override public FetchSubPhaseProcessor getProcessor(FetchContext context) throws IOException { LoggingSearchExtBuilder ext = (LoggingSearchExtBuilder) context.getSearchExt(LoggingSearchExtBuilder.NAME); if (ext == null ) { return null ; } // NOTE: we do not support logging on nested hits but sadly at this point we cannot know // if we are going to run on top level hits or nested hits. // Delegate creation of the loggers until we know the hits checking for SearchHit#getNestedIdentity CheckedSupplier<Tuple<Weight, List<HitLogConsumer>>, IOException> weigthtAndLogSpecsSupplier = () -> { List<HitLogConsumer> loggers = new ArrayList<>(); Map<String, Query> namedQueries = context.parsedQuery().namedFilters(); BooleanQuery.Builder builder = new BooleanQuery.Builder(); ext.logSpecsStream().filter((l) -> l.getNamedQuery() != null ).forEach((l) -> { Tuple<RankerQuery, HitLogConsumer> query = extractQuery(l, namedQueries); builder.add( new BooleanClause(query.v1(), BooleanClause.Occur.MUST)); loggers.add(query.v2()); }); ext.logSpecsStream().filter((l) -> l.getRescoreIndex() != null ).forEach((l) -> { Tuple<RankerQuery, HitLogConsumer> query = extractRescore(l, context.rescore()); builder.add( new BooleanClause(query.v1(), BooleanClause.Occur.MUST)); loggers.add(query.v2()); }); Weight w = context.searcher().rewrite(builder.build()).createWeight(context.searcher(), ScoreMode.COMPLETE, 1.0F ); return new Tuple<>(w, loggers); }; return new LoggingFetchSubPhaseProcessor(Suppliers.memoizeCheckedSupplier(weigthtAndLogSpecsSupplier)); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/logging/LoggingFetchSubPhase.java#L50-L80 RankerQuery#toLoggerQuery LoggignFetchSubPhase#extractQuery や extractRescore を辿っていくと、 RankerQuery#toLoggerQuery に辿り着きます。これは、特徴量ロギング対象のクエリを、特徴量ロギング用に書き換える処理です。具体的には以下のように書き換えます。 まず、特徴量ロギングのためには特徴量の値が出れば良く、モデルのスコアは不要です。 そこで、モデルのスコアを計算する部分である Ranker をダミーの NullRanker に置き換えます。 さらに、 Ranker は特徴量の値を受け取れるので、それを利用して特徴量ロギングを行います。なので、その動作をする LogLtrRanker で NullRanker をラップする形にします。 public RankerQuery toLoggerQuery(LogLtrRanker.LogConsumer consumer) { NullRanker newRanker = new NullRanker(features.size()); return new RankerQuery(queries, features, new LogLtrRanker(newRanker, consumer), featureScoreCache); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L117-L120 HitLogConsumer HitLogConsumer は、大まかにいうと LogLtrRanker で受け取った特徴量の値を保存しておくためのオブジェクトです。 HitLogConsumer は、ドキュメントごとに呼ばれる HitLogConsumer#nextDoc でドキュメントへフィールドを追加し、そこへの参照を維持しておきます。そして、特徴量ごとに呼ばれる HitLogConsumer#accept で得られた特徴量の値を、参照を通じてフィールドへ追加します。 HitLogConsumer は LogLtrRanker.LogConsumer インタフェースを実装しており、この名前で呼ばれることもあるので注意が必要です。例えば、前述の RankerQuery#toLoggerQuery の引数の型は LogLtrRanker.LogConsumer ですが、その実装は HitLogConsumer です。 LoggingFetchSubPhaseProcessor#processにおける実際のロギング FetchSubPhaseProcessor#process はドキュメントごとに走る処理です。 LoggingFetchSubPhaseProcessor#process では、ここで実際のロギングを行います。基本的には特徴量ロギング抜きのランキングモデルの実行と同様で、イテレータを対象のドキュメントまで進めて、スコアを計算するという流れになります。このとき、これまで述べてきた通り、以下の流れで特徴量ロギングの処理が走ります。 特徴量ロギング対象のクエリそれぞれについて、 HitLogConsumer#nextDoc でドキュメントにフィールドを追加します。 メインの Scorer#score ( BlockMaxConjunctionScorer#score ) で、全ての子 Scorer ( RankerScorer ) につき以下を行います。ただし、以下2点に注意してください。(1) メインのクエリは BooleanQuery ですが、対応するメインの Scorer は BooleanScorer ではなく BlockMaxConjunctionScorer です。(2) 1つの子 Scorer が特徴量ロギング対象の1つの Query に対応しますが、これらは必ず RankerScorer と RankerQuery になるはずです。 子の RankerScorer#score において: LogLtrRanker#newFeatureVector を呼びます。ここで HitLogConsumer を FeatureVector に見せかけるため LogLtrRanker.VectorWrapper でラップします。 全ての孫 Scorer (これ1つが1つの特徴量に相当します)について: Scorer#score を呼び、具体的な特徴量の値を計算します。 その値を引数として、 LogLtrRanker.VectorWrapper#setFeatureScore を呼びます。 HitLogConsumer#accept で、特徴量の値をドキュメントのフィールドに追加します。 LogLtrRanker#score を呼びます。 NullRanker#score を実行し、モデルのスコアの計算をスキップします。 LoggingFetchSubPhaseProcessor#process の実装は以下の通りです。 public void process(HitContext hitContext) throws IOException { if (hitContext.hit().getNestedIdentity() != null ) { // we do not support logging nested docs return ; } Tuple<Weight, List<HitLogConsumer>> weightAndLoggers = loggersSupplier.get(); if (scorer == null ) { scorer = weightAndLoggers.v1().scorer(currentContext); } List<HitLogConsumer> loggers = weightAndLoggers.v2(); if (scorer != null && scorer.iterator().advance(hitContext.docId()) == hitContext.docId()) { loggers.forEach((l) -> l.nextDoc(hitContext.hit())); // Scoring will trigger log collection scorer.score(); } } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/logging/LoggingFetchSubPhase.java#L146-L162 また参考のため、 RankerScorer#score の実装も以下に再掲します。 @Override public float score() throws IOException { fv = ranker.newFeatureVector(fv); if (featureScoreCache == null ) { // Cache disabled int ordinal = - 1 ; // a DisiPriorityQueue could help to avoid // looping on all scorers for (Scorer scorer : scorers) { ordinal++; // FIXME : Probably inefficient, again we loop over all scorers.. if (scorer.docID() == docID()) { // XXX : bold assumption that all models are dense // do we need a some indirection to infer the featureId? fv.setFeatureScore(ordinal, scorer.score()); } } } else { (省略) } return ranker.score(fv); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L315-L358 特徴量キャッシュの仕組み ここまでランキングモデルの実行と、特徴量ロギングについて説明してきました。ランキングモデルを継続的に改善する場合、既存のランキングモデルを実行して検索結果を返しつつ、新しいランキングモデルを学習するために特徴量ロギングを行うことになります。そしてこのとき、ランキングモデルの実行と、特徴量ロギングのためには、それぞれ特徴量の値を計算することになります。 ZOZOでは、この重複する特徴量の値の計算を効率化するため、特徴量キャッシュの機能を実装しました。ここでいう特徴量キャッシュとは、以下の動作により特徴量の値の計算を一度で済ませ、クエリ処理を高速化する機能です。 ランキングモデルの実行の際に、計算した特徴量の値をキャッシュする 特徴量ロギングの際には、特徴量の値をキャッシュから取り出して返す(つまり、計算しない) 本記事の残りでは、本プラグインへの特徴量キャッシュ機能の実装について説明します。この機能はすでに追加されていますが、この機能の追加による変更点は以下のプルリクエストにまとまっていますので、適宜このプルリクエストを参照しながら説明します。 github.com StoredLTRQueryBuilderがcache要素をサポートするように拡張 まず、特徴量キャッシュ機能のエントリーポイントとして、 StoredLTRQuerybuilder に独自パラメータ cache を実装しています。プルリクエストで言うとこのファイルの変更です。 https://github.com/o19s/elasticsearch-learning-to-rank/pull/397/files#diff-2a71488e163f2d8274bb9bb2ae27b4583eb12986fcaaf07c7b6def85cc603149 この変更により、特徴量ロギングの対象のクエリに以下の指定を入れることで、特徴量キャッシュ機能が有効になります。 "sltr": { "model": "...", + "cache": true, "params": { ... 未指定であったことを覚えておく 他の既存のパラメータと同様に実装しているため、注意点は少ないです。ただし、このパラメータが未指定(デフォルト値 false が使われる)だったのか、明示的に false 指定だったのかは覚えておく必要があることには注意してください。これは、 StoredLTRQuerybuilder をクエリのJSONとして書き出すことがあり、未指定か明示するかでJSON上の表現が変わるからです。 具体的には、 boolean 型ではなく Boolean 型で持っておくということです。 JSONのパラメータの値 Javaの Boolean の値 未指定 null 明示的に false 指定 false 明示的に true 指定 true キャッシュ本体の設計 キャッシュのデータ構造は、シャード別ドキュメントIDから特徴量の値の配列への連想配列としました。具体的には Map<Integer, float[]> (ただし、特徴量キャッシュ機能が無効の際は null )です。 RankerQuery.build で生成して、 RankerQuery のコンストラクタに渡す実装としています。 private static RankerQuery build(LtrRanker ranker, FeatureSet features, LtrQueryContext context, Map<String, Object> params, Boolean featureScoreCacheFlag) { List<Query> queries = features.toQueries(context, params); Map<Integer, float []> featureScoreCache = null ; if ( null != featureScoreCacheFlag && featureScoreCacheFlag) { featureScoreCache = new HashMap<>(); } return new RankerQuery(queries, features, ranker, featureScoreCache); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L100-L108 ドキュメントIDについて 上で シャード別 ドキュメントIDに言及しましたが、Lucene/ElasticsearchにおけるドキュメントIDは少なくとも3種類あります。 _id フィールドの値 シャードをまたいでもユニークなドキュメントID 通常、ユーザの目に触れるのはこれ シャード別ドキュメントID 特定のシャード内のドキュメントの通し番号 セグメント別ドキュメントID 特定のシャード内の、さらに特定のインデックスセグメント内のドキュメントの通し番号 この値と、インデックスセグメントごとの docBase という値との和が、シャード別ドキュメントID Query オブジェクトは特定のシャード内のドキュメントだけを扱いますので、扱うドキュメントはシャード別ドキュメントIDで一意に特定できます。ですので、今回はシャード別ドキュメントIDをキャッシュのキーにすれば良いと考えられます。 キャッシュの受け渡し キャッシュへのエントリの挿入時とキャッシュからのエントリの引き当て時には、もちろん同一の Map を使う必要があります。本プラグインにおいて、各種オブジェクトの生成フローは下図のようになっています。図中左側がランキングモデルの実行に関わるオブジェクト群で、右側が特徴量ロギングに関わるオブジェクト群です。 ですので、まず起点の RankerQuery (図中左上)に Map を持たせておいて、各メソッドで適切に受け渡していく必要があります。エントリの挿入は左側の RankerScorer#score で、引き当ては右側の DisjunctionDISI#advance と RankerScorer#score で行います。これらのオブジェクトは、偶然ですが全て同一のファイル RankerQuery.java に実装されており、プルリクエストで言うと以下のファイルに全ての受け渡しの処理が含まれます。 https://github.com/o19s/elasticsearch-learning-to-rank/pull/397/files#diff-07788001c91b0b5c03be973de2a368900204bab6c6fc6d3255ec34bcf6184c09 キャッシュへのエントリの挿入 ここからは、挿入と引き当てに分けて具体的な処理を説明していきます。キャッシュへのエントリの挿入は、初回の RankerScorer#score の呼び出し(ランキングモデルの実行時になるはず)で行います。キャッシュが有効の場合、ランキングモデルの実行時に、前述の既存の処理に加えて、以下の処理を行います。 まず、特徴量の値の配列を実際に確保します。 次に、そこに特徴量の値を詰めます。 最後に、 Map にシャード別ドキュメントIDと配列のペアを入れます。 例外処理として、もし対象ドキュメントが孫 Scorer (ある特徴量に対応する Scorer )のイテレータに含まれていなかった場合は、 NaN を詰めておくことにしています。ただし、既存の処理のコメントにもある通り、この状況は本プラグイン全体を通して起こらない想定です。 } else { // Cache miss int ordinal = - 1 ; float [] featureScores = new float [scorers.size()]; for (Scorer scorer : scorers) { ordinal++; float score = Float.NaN; if (scorer.docID() == docID()) { score = scorer.score(); fv.setFeatureScore(ordinal, score); } featureScores[ordinal] = score; } featureScoreCache.put(perShardDocId, featureScores); } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L342-L355 キャッシュからのエントリの引き当て キャッシュからのエントリの引き当ては、2回目以降の DisjunctionDISI#advance および RankerScorer#score の呼び出し(特徴量ロギング時になるはず)で行います。 DisjunctionDISI#advanceでの引き当て 特徴量ロギングの対象ドキュメントまで全ての孫 Scorer (ある特徴量に対応する Scorer )のイテレータを進める処理にはコストがかかります。しかし、キャッシュにエントリが含まれている場合、そもそも特徴量を改めて計算する必要もイテレータを進める必要もないため、いわゆるearly returnを実装しました。以下に当該のソースコードを再掲します。 @Override public int advance( int target) throws IOException { int docId = main.advance(target); if (featureScoreCache != null && featureScoreCache.containsKey(docBase + target)) { return docId; // Cache hit. No need to advance sub iterators } advanceSubIterators(docId); return docId; } https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L414-L422 RankerScorer#scoreでの引き当て 2回目以降の RankerScorer#score の呼び出し(特徴量ロギング時になることを想定)では、特徴量の値を計算する必要はなく、キャッシュから引き当てることができます。具体的には、前述の既存の処理に比べて、孫 Scorer の score メソッドを呼ばなくなっています。ここが特徴量キャッシュの主要な効果になると期待されます。コードでは以下の箇所です。 if (featureScoreCache.containsKey(perShardDocId)) { // Cache hit float [] featureScores = featureScoreCache.get(perShardDocId); int ordinal = - 1 ; for ( float score : featureScores) { ordinal++; if (!Float.isNaN(score)) { fv.setFeatureScore(ordinal, score); } } } else { // Cache miss https://github.com/o19s/elasticsearch-learning-to-rank/blob/37d8542c78b816c67a34fd206e6f4dc08ba7006f/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L333-L342 まとめ 本記事では、以下を解説しました。 OpenSource Connections Elasticsearch Learning to Rank pluginの仕組み ランキングモデルの実行の仕組み 特徴量ロギングの仕組み 本プラグインの処理の効率化のためにZOZOで実装した、特徴量キャッシュ機能 ZOZOでは、検索機能を開発・改善していきたいエンジニアを全国から募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! hrmos.co
こんにちは、データシステム部推薦基盤ブロックの寺崎( @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 また、本記事で紹介した施策にご協力いただいたヤフー株式会社の皆さんに改めて感謝いたします。