TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

938

はじめに こんにちは、計測プラットフォーム開発本部SREブロックの渡辺です。普段はZOZOMATやZOZOGLASSなどの計測技術に関わるシステムの開発、運用に携わっています。 先日私達のチームでは、Argo CDと拡張ツールArgo CD Image Updaterを導入した開発環境のCDリアーキテクトを行いました。本記事では、開発環境のCI/CDリアーキテクト設計とArgo CD Image Updaterの導入手順について紹介します。 目次 はじめに 目次 Argo CDとArgo CD Image Updaterについて Argo CD Image Updater導入前の課題 Argo CD Image Updater導入による開発環境CI/CD設計 導入手順 Argo CD Image UpdaterのECR操作権限設定 IAMRoleの作成とPodへのアタッチ PodのECR認証トークン発行 Argo CD Image Updaterリソースの展開(kustomizeの設定) Argo CD Applicationの設定 動作確認 リアーキテクト前後の比較 まとめ 終わりに Argo CDとArgo CD Image Updaterについて Argo CDは、Kubernetes環境での GitOps を実現するためのCDツールです。Gitリポジトリで管理しているKubernetesマニフェストを監視して、Kubernetesクラスターに適用します。 Argo CD Image UpdaterとはArgo CDの拡張ツールで、デプロイされたコンテナイメージを最新のバージョンに自動更新します。こちらはイメージを管理しているリポジトリを監視して、条件に一致した最新のイメージを検知した場合にKubernetesクラスターのコンテナイメージを更新します。 Argo CD Image Updater導入前の課題 計測プラットフォーム開発本部のプロダクトのインフラ基盤は、EKS on Fargateで運用しており、Skaffoldを用いたデプロイを行っていました。また、GitHubで管理しているアプリケーションのリポジトリにKubernetesマニフェストも含まれていました。 例として、ZOZOMATの開発環境CI/CDアーキテクチャを下図に示します。 以下のような流れになります。 対象ブランチをCircleCIのブランチフィルターの定義に追記する 1.をコミットし、GitHubに変更をpushする CircleCIのイメージビルド用のジョブが発火する skaffold buildを実行し、イメージをビルドしてECRにpushする ビルド時に生成されたイメージタグが記録されたjsonファイル(以下、イメージタグファイルとする)をS3にアップロードする ビルド完了を待ち、CDをトリガーするスクリプトを実行する CircleCIからCodePipelineのソースS3にソースコードをアップロードする CodeBuildがソースとイメージタグファイルをS3から取得し、Skaffoldを使ってapplyする 作業完了後、2のコミットを消す 「イメージタグファイル」について補足します。イメージビルド時にSkaffoldの--file-outputオプションを利用してイメージタグをファイルに記録しS3へ配置します。デプロイ時にS3のイメージタグファイルをダウンロードし、Skaffoldの--build-artifactsオプションでファイルを指定することで適切なタグ情報によるデプロイを実行しています。 このフローには、いくつかの問題点がありました。 各開発者の作業量が多く、デプロイまでに多くの時間を要する デプロイ先の環境を指定してCD用スクリプトを実行するため、オペレーションミスによりステージングや本番環境のCDが実行される 作業手順が複雑でツールも散在しているため、リバースエンジニアリングしづらく、他プロダクトへの横展開も難しい 特に作業開始からデプロイまでの時間の長さがネックでした。 また、計測プラットフォーム開発本部は複数のプロダクトを管理しているため、CI/CDは全て統一されることが望ましい状態と言えます。しかし、現状は各プロダクトで異なる手順となっているため、管理コストも大きな課題となっていました。 Argo CD Image Updater導入による開発環境CI/CD設計 前述の課題を解決するために、まずは開発環境にArgo CDと拡張ツールArgo CD Image Updaterを導入することで、デプロイ時間の短縮を図りました。Argo CD導入後の開発環境のCI/CDアーキテクチャを下図に示します。 ここでは深く言及しませんが、GitOpsではアプリケーションのソースコードとKubernetesマニフェストを分けて管理することが推奨されています。このため、既存のGitHubリポジトリからKubernetesマニフェストを抜き出し、別GitHubリポジトリで管理するよう対応しました。 以下のような流れになります。 開発者が対象ブランチを設定したGitHub Actionsの手動ワークフローを実行する イメージをビルドしてECRにpushする Argo CD Image UpdaterがECRリポジトリのイメージを検知する Argo CDが新しいイメージのPodを自動デプロイする 開発者が行う作業は、任意のタイミングでGitHub Actionsを手動実行するだけです。ブランチを指定するだけで開発環境用のイメージがECRに配置されます。 Argo CD単体では、GitHub上のソースコードを更新する必要があるため、Pushしたイメージタグに更新するロジックが必要になります。そこで、Argo CD Image Updaterを導入し、よりスピーディな開発環境へのデプロイを実現します。レジストリに新しいイメージが追加された際に、自動で新しいイメージをデプロイすることが可能になります。 従来の方法では、ビルドしたイメージがECRに配置されるのを待ってからデプロイを行う必要がありました。待っている間に別の作業をしているとついビルドの完了に気づくのが遅れてしまい、結果的にデプロイも遅れるという悩みがメンバー間でありました。自動デプロイによりこうした悩みも解決できました。 承認フローや厳格なGitOpsを求めない開発環境にこそArgo CD Image Updaterは強力なツールとして開発をサポートしてくれます。 導入手順 ここからは、Argo CD Image Updaterの導入手順について紹介していきます(Argo CDは 公式ドキュメント を参考に導入しました)。 Argo CD Image UpdaterのECR操作権限設定 Argo CD Applicationの設定 Argo CD Image UpdaterのECR操作権限設定 Argo CD Image UpdaterがECRリポジトリのイメージタグ情報を取得するため、認証周りの設定が必要になります。ここでは、認証に必要な各リソースについて説明します。 IAMRoleの作成とPodへのアタッチ KubernetesのServiceAccountでIAM Roleを使用するには、クラスター用のIAM OIDCプロバイダーが必要です。クラスター用のIAM OIDCプロバイダー作成後、CloudFormationでIAMRoleを作成します。 IAMRoleArgoCDImageUpdater : Type : 'AWS::IAM::Role' Properties : RoleName : 'argocd-image-updater' AssumeRolePolicyDocument : !Sub | { "Version" : "2012-10-17" , "Statement" : [ { "Effect" : "Allow" , "Principal" : { "Federated" : "arn:aws:iam::${AWS::AccountId}:oidc-provider/oidc.eks.<リージョン>.amazonaws.com/id/xxxxxxxxxxxxxx" } , "Action" : "sts:AssumeRoleWithWebIdentity" , "Condition" : { "StringEquals" : { "oidc.eks.<リージョン>.amazonaws.com/id/xxxxxxxxxxxxxx:sub" : "system:serviceaccount:<argocd-image-updaterを配置するKubernetes namespace名>:<argocd-image-updaterに設定するServiceAccount名>" } } } ] } Path : '/' Policies : - PolicyDocument : Statement : - Effect : 'Allow' Action : - 'ecr:GetAuthorizationToken' - 'ecr:ListImages' - 'ecr:BatchGetImage' - 'ecr:GetDownloadUrlForLayer' Resource : '*' PolicyName : 'argocd-image-updater-ecr' そして、Argo CD Image UpdaterのServiceAccountに作成したIAMRoleをアタッチします。 apiVersion : v1 kind : ServiceAccount metadata : name : argocd-image-updater annotations : eks.amazonaws.com/role-arn : arn:aws:iam::<AWSアカウントID>:role/argocd-image-updater これでArgo CD Image UpdaterにECRリポジトリのタグを取得する権限を付与できました。 PodのECR認証トークン発行 Argo CD Image Updaterがスクリプトを実行し、クレデンシャルを設定する方法で実装しました。 registries.confにはレジストリやスクリプトを設定し、ecr-login.shにはECRの認証トークンを発行するスクリプトを設定しています。 apiVersion : v1 kind : ConfigMap metadata : name : argocd-image-updater-config data : registries.conf : | registries : - name : ECR api_url : https://<AWSアカウントID>.dkr.ecr.<リージョン>.amazonaws.com prefix : <AWSアカウントID>.dkr.ecr.<リージョン>.amazonaws.com credentials : ext:/app/scripts/ecr-login.sh credsexpire : 10h ecr-login.sh : | #!/bin/sh aws ecr --region <リージョン> get-authorization-token --output text --query 'authorizationData[].authorizationToken' | base64 -d argocd-image-updaterのDeploymentに上記ConfigMapで作成したecr-login.shをマウントします。これにより、Argo CD Image Updaterがecr-login.shを実行できます。 apiVersion : apps/v1 kind : Deployment metadata : name : argocd-image-updater spec : template : spec : containers : - name : argocd-image-updater volumeMounts : - name : ecr-login-script mountPath : /app/scripts volumes : - name : ecr-login-script configMap : defaultMode : 0755 items : - key : ecr-login.sh path : ecr-login.sh name : argocd-image-updater-config optional : true なお、マウントしたConfigMapのファイルの権限はデフォルトで0644なので、defaultMode: 0755と設定しています。デフォルトのままではecr-login.shが実行できず、ECRの認証トークンが発行されないので注意してください。 Argo CD Image Updaterリソースの展開(kustomizeの設定) 私達のチームは環境ごとの差分を管理しやすくするためにkustomizeを用いています。開発環境ディレクトリのkustomization.ymlを以下のとおり設定し、Argo CD Image Updaterと上記で作成したリソースをEKS環境に展開します。 namespace : argocd resources : - https://raw.githubusercontent.com/argoproj-labs/argocd-image-updater/stable/manifests/install.yaml patchesStrategicMerge : - configmap.yml # ConfigMap - deployment.yml # Deployment - rbac.yml # ServiceAccount Argo CD Applicationの設定 Argo CD Image Updater管理下に置くApplicationのmetadata.annotationsを以下のとおり設定します。 apiVersion : argoproj.io/v1alpha1 kind : Application metadata : annotations : argocd-image-updater.argoproj.io/write-back-method : argocd argocd-image-updater.argoproj.io/image-list : my-image=<AWSアカウントID>.dkr.ecr.<リージョン>.amazonaws.com/<リポジトリ> argocd-image-updater.argoproj.io/my-image.update-strategy : latest argocd-image-updater.argoproj.io/my-image.ignore-tags : latest 省略 write-back-methodにargocdを設定することで、GitHubのソースコードを更新せずに最新のイメージをデプロイできます。 今回私達のチームはArgo CDの特徴である GitOpsの原則 を崩していますが、承認フローなしでソースコードを変更されることに抵抗感があったからです。 一方、write-back-methodにgitを設定するとGitHub上でデプロイするイメージタグを確認できるメリットがあります。こちらはチームの運用などを考慮して選択すると良いと思います。 update-strategyにはlatestを設定し、最新の作成日でタグを更新するようにしています。また、ignore-tagsにlatestを設定しlatestタグを除外しています。 この他にも柔軟な設定ができるので、詳しくは 公式ドキュメント を参照してください。 動作確認 以下のコマンドで動作を確認できます。 kubectl exec -it < ArgoCD Image Updater Pod > -- argocd-image-updater test < AWSアカウントID > .dkr.ecr. < リージョン > .amazonaws.com/ < リポジトリ > --credentials ext:/app/scripts/ecr-login.sh --registries-conf-path /app/config/registries.conf リアーキテクト前後の比較 リアーキテクト前 リアーキテクト後 デプロイ時間 約8分 3分以内 開発者の作業数 2 1 利用するツール CircleCI, CodePipeline, CodeBuild, Shell Script, Skaffold GitHub Actions, Argo CD, Argo CD Image Updater オペレーションミスの可能性 別環境へデプロイする可能性あり 別環境へデプロイする可能性なし 横展開のしやすさ × ◯ Argo CDの変更チェックをデフォルトの3分に設定しているので、リアーキテクト後のデプロイ時間を3分以内としています。 これまでCodeBuildで行っていたデプロイ処理をArgo CDに移行したことで、約5分デプロイ時間を短縮できました。CodeBuildでの各種ツールのダウンロードに要していた時間を削減できたことが一番の要因です。 また、Argo CD Image Updaterを導入したことで、GitHubへのソース変更ロジックを自前で管理せずに済んだのは横展開しやすい大きな要因になります。同じEKSクラスターで管理する他プロダクトに同様の仕組みを導入するためには、各プロダクトのArgo CD Applicationにてアノテーションを追加するだけで完了します。 まとめ 開発環境にArgo CDとArgo CD Image Updaterを導入したことで、デプロイ時間の短縮と管理コストを抑えることができました。 Argo CDを導入しているが、GitHubのコードを変更せずに自動でイメージのデプロイを行いたい、という開発環境の要求にはArgo CD Image Updaterがピッタリのツールでした。 AWS環境での導入に関しては、公式ドキュメントだけでは難しい部分(特にECR認証トークン発行)もありますが、本記事が導入の手助けになれば幸いです。 なお、Argo CD Image Updater導入を進める途中で v0.12.0 がリリースされました。この中で イメージ変更コミットつきブランチを作成する機能 も追加されたので、今後は承認フローが必要な本番環境での導入も検討していきたいと思います。ブランチ戦略やリリース手順の統一化などやるべきこともたくさんありますが、SREチームのメンバーと協力しながらやり遂げたいと思います。 終わりに 計測プラットフォーム開発本部では、今後も ZOZOFIT 等の新しいサービスのローンチを予定しています。更にスピード感を持った開発が求められますが、このような課題に対して楽しんで取り組み、サービスを一緒に盛り上げていける方を募集しています。少しでもご興味のある方は以下のリンクからぜひご応募ください。 hrmos.co
アバター
こんにちは、ZOZO CTOブロックの @ikkou です。 WWDC Extendedとは WWDC Extendedは、WWDCのメインセッション(Keynote)をさらに楽しむためのイベントです。これまでのWWDC Extendedはヤフーが単独で開催していましたが、今年のWWDC Extended Tokyo 2022はヤフーに加え、LINEとPayPay、そして私たちZOZOの4社で運営しました。今回のイベントもApple公式の Beyond WWDC にも掲載されています。 yj-meetup.connpass.com 今年のWWDC Extended ZOZOで普段開催しているMeetupやTech Talkでは、ZoomとOBSを用いてYouTube Liveでライブ配信し、質疑応答には Slido を用いています。 WWDC Extendedでは、YouTube LiveとSlidoを用いている点は同じでしたが、ZoomとOBSではなく StreamYard で用いて実施しました。また、休憩中にはMiroを、イベント終了後にはDiscordを用いた交流など、オンラインイベントを盛り上げる複数の取り組みを実施しました。 当日のアーカイブ動画は公開されていませんが、イベントの雰囲気は先行して公開されているYahoo! JAPAN Tech Blogのレポート記事をご覧ください。 techblog.yahoo.co.jp ZOZOからはZOZOTOWNアプリ部の @banjun が登壇し、WWDC参加にあたってZOZOで実施している作戦会議やラボ戦略を惜しげなく披露しました。 speakerdeck.com WWDC22での実際のMiro活用事例や、現地参加した3名のエンジニアによる写真を交えたレポート記事は既に公開しているので、ぜひご覧ください。 techblog.zozo.com 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。また、今後もグループ間のシナジー効果を生かしたイベントを開催していきたいと考えています。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
はじめに こんにちは、ML・データ部推薦基盤ブロックの寺崎( @f6wbl6 )と佐藤( @rayuron )です。 ZOZOTOWNのホーム画面は2021年3月にリニューアルされ、「モジュール」と呼ばれる単位で商品が表示されるようになりました。 本記事ではユーザーごとにパーソナライズされたモジュール(以降、パーソナライズモジュール)のロジックやシステム構成、および導入時に実施したA/Bテストの内容と結果をご紹介します。 先に結論から言ってしまいますが、今回のパーソナライズモジュールでは機械学習モデルを使わず、ユーザーの回遊行動を分析した結果を元にしたルールベースのロジックを使用しています。本記事のポイントは大きく以下の3点です。 ルールベースのパーソナライズロジック 機械学習モデル導入を見越したシステム設計 ホーム画面のパーソナライズによる効果 本記事がこれから同様のタスクに取り組む方の参考になれば幸いです。 はじめに ZOZOTOWNのホーム画面とモジュールについて モジュールのパーソナライズ 目的と背景 アルゴリズム選定 機械学習モデル導入コストの問題 機械学習モデル導入による効果が未知 アルゴリズム概要 前提 推薦するブランドを決定するルール 推薦するカテゴリを決定するルール アーキテクチャ概要と処理の流れ ワークフロー管理 データ取得と書き込み 機械学習モデルを導入する場合はどうなるか? A/Bテスト 概要 結果 課題と今後の展望 パーソナライズモジュールを表示されるユーザーが限定されている ルールベースロジックによるパーソナライズに限界がある リアルタイム性に欠く 最後に ZOZOTOWNのホーム画面とモジュールについて まずはじめに、ZOZOTOWNのホーム画面とモジュールについて簡単にご紹介します。 ZOZOTOWNのホーム画面は「すべてのアイテム」・「シューズアイテム」・「コスメアイテム」の3つのモールと「すべて」・「メンズ」・「レディース」・「キッズ」の4つの属性に分かれています。この3モール×4属性の合計12個の組み合わせで表示するモジュールを切り替えています。 ※ 上記は2022年6月10日時点でのZOZOTOWNホーム画面です またモジュールは更新頻度や用途に応じた「定常モジュール」と「運用モジュール」の2種類に大別されます。各モジュールの違いを以下の表にまとめます。 用途 更新頻度 定常モジュール 施策内容に依らず常に同じ訴求をする ほぼ更新なし 運用モジュール ビジネスサイドの要望に応じて訴求内容を変える 1か月あたり数回更新 ファッションECの特性上、トレンドや季節に応じてユーザーへ訴求する商品は常にアップデートし続けなければいけません。またZOZOTOWNへ出店していただいているブランド様やショップ様で販売する新商品を訴求する場合もあるため、ビジネスサイドの担当者が月に数回運用モジュールを企画し、開発サイドでリリースしています。 モジュールのパーソナライズ 目的と背景 ホーム画面のモジュールをパーソナライズする目的は複数ありますが、 ZOZOTOWNでの購入経験の有無 という観点では以下の2つに大別できます。 新規購入者の増大 既存購入者の来訪頻度・購入頻度の増大 1点目は「ZOZOTOWNに訪れたことがあるが、まだ商品購入に至っていない」というユーザーを想定しています。ZOZOTOWNに訪れてはいるものの商品を購入していない様々な理由があると思いますが、ユーザーの嗜好に合った商品を訴求できていないような場合は適切なパーソナライズにより購入に繋げられると考えられます。ただ、こうしたユーザーの多くは会員登録をしていないため、ZOZOTOWN上での回遊行動を捕捉しにくいという難しさがあります。 2点目は既存顧客に対してより良いコンテンツを提供し、ZOZOTOWNで再度購入してもらうことを意図しています。ユーザーが興味を持ちそうな商品群をホーム画面で訴求できれば来訪頻度が増え、結果として購入頻度も増加すると考えられます。 どちらもZOZOTOWNの成長には必要な切り口ですが、今回は 回遊行動ログの集めやすさ と 既存顧客へのパーソナライズによる効果の大きさ から、2点目に挙げた「既存購入者の来訪頻度・購入頻度の増大」を目的としてパーソナライズを進めることとしました。 アルゴリズム選定 パーソナライズアルゴリズムを構築する上で機械学習モデルを使用するか、ルールベースを使用するかを検討し、以下2点の理由からルールベースのアルゴリズムを採用しました。 機械学習モデル導入コストの問題 機械学習モデル導入による効果が未知 機械学習モデル導入コストの問題 機械学習モデルを導入する場合、ルールベースと比べてシステムアーキテクチャが複雑化することに加え、機械学習モデルに関連する様々な資産の導入・管理コストが発生します。また、機械学習モデルが正しくワークしていることを担保するためのデータドリフト検知やモデルメトリクスのモニタリングなど、 システムに機械学習を導入する際には様々なコストを支払う ことになります。 今回はベースラインとなるモデルをリリースするにあたってできるだけコストをかけないこと、また既存アーキテクチャの複雑化を避けることを優先しました。 機械学習モデル導入による効果が未知 今回が初めてのパーソナライズモジュールの導入となるため、当然ですがパーソナライズモジュールの効果はリリースしてみないと分かりません。つまり、 パーソナライズモジュールのリリースによる効果を最適化するためのオフライン評価の設計 が難しい状況でした。 そのため今回は機械学習モデルを最適化するための指標に時間を費やすのではなく、ヒューリスティックに設計したルールでベースとなるパーソナライズの効果を測ることとしました。 アルゴリズム概要 前提 今回のパーソナライズモジュールでは開発スピードと売り上げへの影響の大きさという観点から、訴求対象について以下の前提を置いています。 ZOZOTOWN会員かつお気に入りブランドを持つユーザー 直近で何らかの回遊行動があるユーザー ZOZOTOWNでは以下の図のように特定のブランドをお気に入り登録する機能があります。今回のパーソナライズでは 開発スピードを優先して推薦ブランドリストを一から作ることはせず、このお気に入り登録されたブランドから商品を推薦 します。 パーソナライズのアルゴリズムを考えるにあたり、以下の2点を検討の軸にしました。 推薦するブランド 推薦するカテゴリ いずれも商品の閲覧や購買行動といったImplicit Feedbackを使って分析を行い、「 ユーザーが直近で閲覧している商品カテゴリほど購入に結びつきやすい 」というシンプルな仮説のもとルールを策定しました。 推薦するブランドを決定するルール 前述した通り、今回のモジュールではユーザーのお気に入りブランドの中からブランド群を選択します。お気に入り登録しているブランドの数は人によってまちまちで、登録数が1つだけのユーザーもいれば数十個登録しているユーザーもいます。そこでまずは、今回設ける条件によって推薦対象ユーザー数が大きく減ることを防ぐためにユーザー毎のお気に入りブランドの登録数の分布を概観しました。 その後お気に入りブランドの中でも、ユーザーにとって興味があるブランド群のみを選別するための条件を作成することを考えました。今回は購入があったアイテムの最初の閲覧日から購入日までの期間を分析し、 ユーザー毎に直近n日間で商品閲覧があるブランド群を推薦する というルールを作成しています。 推薦するカテゴリを決定するルール 推薦するカテゴリは以下の二軸で決めました。 除外するカテゴリの選定 推薦するカテゴリの優先順位付け 今回のモジュールで推薦可能なカテゴリは300個弱あったため、ここから推薦候補となる一覧を決定します。購入済みのカテゴリの商品が再度推薦される状況を避けることと、多様なカテゴリを訴求することを目的として、「直近で購入済みのカテゴリは訴求しない」という方針を決めました。 この方針のもと、各ユーザーの商品カテゴリごとの購入周期を分析し、 購入履歴のあるカテゴリのモジュールはm日間表示しない というルールを作成しました。 次に、候補となったカテゴリのうち、どのカテゴリをユーザーへ訴求するかを決めます。今回は前述した「直近のユーザーが多く閲覧している商品カテゴリほど購入に結びつきやすい」という仮説に基づき、以下の流れで推薦カテゴリを決定しています。 カテゴリの閲覧回数に時系列の重みを掛け合わせたものを評価値として算出 その評価値順に推薦するカテゴリを決定 アーキテクチャ概要と処理の流れ ここからはモジュールのパーソナライズを行うためのシステム構成と処理の流れをご紹介します。前述の通り今回のパーソナライズでは機械学習モデルを使っていませんが、将来的に機械学習モデルを導入することを見越したシステム構成を意識しています。 モジュールAPIはホーム画面リニューアル時に導入されたもので、前述したモールやユーザーの属性に応じてホーム画面に表示するモジュールの設定情報を返却しています。この構成のうち、パーソナライズモジュールの開発に合わせて導入された要素について説明します。 ワークフロー管理 パーソナライズを行うための一連のワークフローはVertex AI Pipelinesで管理しており、このワークフローが毎時間実行される構成となっています。Vertex AI Pipelinesは今や弊社の機械学習パイプライン実行基盤であり、MLをプロダクトに載せて運用に携わる全てのチームが利用していると言っても過言ではありません。Vertex AI Pipelinesに関する知見は弊社テックブログで公開されていますので、こちらも是非参照ください。 techblog.zozo.com techblog.zozo.com Vertex AI Pipelinesによるワークフロー構築に際し、 こちらの記事で今後の展望として挙げられていた パイプラインテンプレートというものを利用しています。パイプラインテンプレートはGitHubの テンプレートリポジトリ と呼ばれる機能を利用したもので、Vertex AI Pipelinesの実行・スケジュール登録・CI/CD・実行監視等をテンプレート化しています。例えばパイプラインのRunとスケジュール登録は以下のコマンドで実行できます。 # 単発のRunを実行 $ poetry run python pipelines/sample-features run-pipeline --pipeline-name sample-features --env dev # スケジュール実行の登録 $ poetry run python pipelines/sample-features schedule-pipeline --pipeline-name sample-features --env dev またパイプライン実行状態の監視や 公式ドキュメントに記載されている定期実行の仕組み まで、パイプラインテンプレートで簡単にデプロイ・実行できるようになっています。このパイプラインテンプレートによりワークフローのデバッグやデプロイのサイクルを高速に回すことができました。パイプラインテンプレートのより詳細な機能や具体的な実装については別途テックブログで公開できればと思います。 パイプラインは毎時間実行してユーザーの情報を差分更新します。この更新処理バッチが落ちた場合、その時点で再実行等はせずに1日1回の全件更新するバッチを別で実行してリカバリする構成となっています。 データ取得と書き込み パーソナライズに使用するユーザーログは弊社のリアルタイムデータ基盤であるBigQueryから取得し、集計した結果をGoogle Cloud Storage(GCS)へと出力します。GCSへのデータはデータサイズ節約の観点からparquet形式で出力しており、このデータをDataflowで読み込んでBigtableへ投入しています。実際に投入されるデータは概ね以下のような形式となっています。 002-SS3 r:h @ 2022/06/09-03:30:00.000000 "{\"id\": \"075-A20\", \"field_A\": \"xxx\", \"personalize_category\": [{\"id\": 1234, \"title\": \"カテゴリA\", \"category_url\": \"category_A\"}, {\"id\": 5678, \"title\": \"カテゴリB\", \"category_url\": \"category_B\"}]}" ---------------------------------------- 075-A20 r:h @ 2022/06/09-04:30:00.000000 "{\"id\": \"002-SS3\", \"field_A\": \"yyy,zzz\", \"personalize_category\": []}" 上記はユーザーのIDがそれぞれ 002-SS3 と 075-A20 のデータを想定したダミーデータです。1つのセルにまとめて文字列形式でパーソナライズ用のデータを格納しており、モジュール返却用のAPIにリクエストが来るとこの文字列をパースしてパーソナライズされた情報を取得する形にしています。 パイプライン側の処理は全て自前で実装していますが、2022年6月現在ではBigQueryやDataflowにジョブを投げるといった汎用的な処理はGCPの公式コンポーネントとして提供されています。他にも様々なコンポーネントが提供されていますので、これから実装する際には用途に適したコンポーネントがないかドキュメントを一読することをオススメします。 cloud.google.com cloud.google.com 機械学習モデルを導入する場合はどうなるか? 今回はルールベースでのパーソナライズのためBigQueryでの集計で事足りていますが、仮に今回の推論バッチで機械学習モデルでのパーソナライズを行う場合のアーキテクチャを以下に示します。 変更点はBigQueryとGCSの処理の間に機械学習モデルによる予測が入るだけで、データの入出力方法に変更はありません。今回BigQueryからGCSへ一度データを配置してDataflowでの処理を行う構成としているのは、こうしたロジックの追加・変更に柔軟に対応する意図があります。 当然、機械学習モデルを再学習・デプロイするための学習パイプラインやCI/CDの構築は別途必要になりますが、推論パイプラインの構成を大きく変更する必要がないのは大きなメリットになります。 A/Bテスト 概要 パーソナライズモジュールの効果を評価するために、リリース時にパーソナライズ対象のユーザーに対して約1か月間A/Bテストを行いました。今回のA/Bテストではテスト対象となるユーザーがControl群とTreatment群で1:1の振り分けとなるように設定しています。Treatment群に対しては以下画像のようにパーソナライズモジュールが表示されます。 モジュールの並び順に関してはビジネス的な要望もあり、定常モジュールの間に挟む形で決め打ちしています。 また、パーソナライズモジュールは推薦するカテゴリに応じてモジュールのタイトル・表示される商品・「すべて見る」を押下した際の遷移先が変わるようになっています。 結果 A/Bテストの結果サマリを以下に示します。 指標 備考 結果(T/C比・T/C差) ZOZOTOWN全体の受注金額 GMVと代替となる指標 100.4 (%) ホーム画面経由の受注金額 ホーム画面に表示されている商品をクリックしたセッション内での受注金額 104.6 (%) ホーム画面ランディングセッション直帰率 ホーム画面の直帰セッション数÷ランディングセッション数 -0.1 (pt) 訪問者1人あたりホーム画面クリックセッション率 ホーム画面でのクリックセッション数÷全セッション数 +0.8 (pt) ZOZOTOWN全体の受注金額で有意差は見られませんでしたが、ホーム画面経由の受注金額は増加していました。またホーム画面経由の受注金額をKPIツリーに分解してみると、ホーム画面全体でのクリック率向上に伴うクリックセッション数の増加が影響していることがわかります。 またモジュール全体の指標を見ると、パーソナライズモジュールに引っ張られる形で各種指標が増加していることが確認できます。 指標 T/C比率 モジュールインプレッション数 103.4 (%) モジュールインプレッションUU数 107.8 (%) 商品クリック数 108.5 (%) 商品クリックUU数 122.7 (%) 「すべて見る」クリック数 106.7 (%) 「すべて見る」クリックUU数 126.5 (%) 商品CTR(PVベース) 104.9 (%) 商品CTR(UUベース) 113.9 (%) 「すべて見る」CTR(PVベース) 103.2 (%) 「すべて見る」CTR(UUベース) 117.4 (%) 結論として、パーソナライズモジュールの導入によりZOZOTOWN全体での指標改善までは及んでいませんが、少なくともホーム画面ではユーザーの関心を引けているものと考えられます。 課題と今後の展望 ホーム画面へのパーソナライズモジュール導入により、当初目的としていた「既存購入者の来訪頻度・購入頻度の増大」に一定の効果があるというポジティブな結果となりました。一方で、以下のような課題もあります。 パーソナライズモジュールを表示されるユーザーが限定されている ルールベースロジックによるパーソナライズに限界がある リアルタイム性に欠く パーソナライズモジュールを表示されるユーザーが限定されている 現在パーソナライズモジュールが表示されるユーザーはZOZOTOWN会員かつお気に入りブランド登録しているユーザーであり、限定されたユーザーセグメントへの訴求にとどまっています。今回はパーソナライズモジュールの第一歩としてこのような限定されたセグメントへの訴求としましたが、今後はより多くのユーザーにパーソナライズした商品をモジュールという形で届けたいと考えています。 ルールベースロジックによるパーソナライズに限界がある 仮にユーザーセグメントを広げた場合、ルールベースのロジックではパーソナライズできる商品に限界があります。今回作成したルールで言えば「ユーザーがお気に入りブランドに追加しているブランド」に限定した商品を訴求しており、「ユーザーが興味を持ちそうなまだ見ぬブランド」という軸では訴求できません。 ホーム画面という多くのユーザーが最初に訪れる場所だからこそ、見知っているブランドだけではない新しい出会いを機械学習モデルで提供していきたいと考えています。 リアルタイム性に欠く 現状のシステム構成では1時間に1回推論パイプラインが実行されてユーザーのパーソナライズ情報を更新しています。 ユーザーの閲覧に基づいて商品を訴求するのであれば、ユーザーが商品を閲覧してホーム画面へ戻る度、閲覧履歴に基づいた推薦商品が表示されていることが好ましいと考えられます。 一方で閲覧履歴に基づいて表示される商品がリアルタイムで変わっていると、「後でこの商品を見よう」と思ってブラウザバックしてもその商品はホーム画面で表示されていない、という状態となることも考えられます。 ユーザー体験を考えると表示される商品がリアルタイムで入れ替わることは必ずしも良いと一概には言えないため、ここの塩梅を今後探っていきたいと考えています。 最後に 本記事ではZOZOTOWNホーム画面へ導入したパーソナライズモジュールを紹介しました。今後の展望に挙げた項目については絶賛進行中で、これからもZOZOTOWNのパーソナライズはどんどん進んでいきます。 一緒にZOZOTOWNのパーソナライズ化を進めることに興味のある方は以下リンクから是非ご応募ください! hrmos.co hrmos.co
アバター
こんにちは、ZOZOTOWNアプリ部の @inokinn です。 日本時間の6月7日から11日にかけて WWDC22 が開催されました。 今年のハイライトは、iOS 16でのロック画面のアップデートをはじめ、WeatherKitやSwift Charts、Passkeysなどの、数多くの新機能の発表だったかと思います。 今年は去年と一昨年に続いてのオンライン開催に加え、抽選に当選すれば現地であるApple Parkでのパブリックビューイングにも参加できました。そして、なんと幸運にもZOZOからも3名が当選し、現地に赴きました! 本記事では、WWDC22でZOZOのiOSアプリ開発メンバーが取り組んだことを紹介します。また、ラボでAppleのスタッフから得られたフィードバックや、海外出張したメンバーによる現地レポートも可能な範囲で公開します。是非最後までご覧ください。 WWDCについて 現地で楽しむWWDC22 6月5日 - イベント前日 6月6日 - イベント当日 ライブビューイング Meet with Teams オンラインで楽しむWWDC22 Digital Lounges Challenges Labs & Sessions Design Lab × FAANS 投稿フローに対するフィードバック 着用アイテムを登録する機能に対するフィードバック 通知の活用 Design Labを利用した感想 WeatherKitはWEARを拡張するか まとめ さいごに WWDCについて WWDC(Worldwide Developers Conference)は、Appleが年に1度開催している開発者向けのカンファレンスです。今年は2019年以来、3年ぶりに現地でもイベントが開催されたので、当選したメンバーは業務の一環として現地参加しました。ZOZOの開発部門では、海外カンファレンスを含むセミナー・カンファレンス参加支援制度が用意されています。 現地で楽しむWWDC22 こんにちは、ZOZOTOWNアプリ部の小松、荻野とWEAR部の坂倉です。 コロナ禍になってから完全オンライン開催のWWDCでしたが、なんと今年は一部オフライン開催もありました。案内としては、6月6日のKeynoteやPlatforms State of the UnionなどをApple Parkでライブビューイングできるとのことでした。 現地参加のスケジュールは以下の通りです。 時間(PST) コンテンツ 場所 7:00 AM チェックイン Apple Park Visitor Center 8:00 AM 朝食 Apple Park - Caffè Macs 10:00 AM Keynote Apple Park 12:00 PM 昼食 Apple Park - Caffè Macs 1:00 PM Platforms State of the Union Apple Park 2:30 PM Meet the Teams Apple Park - Caffè Macs 4:30 PM Apple Design Award Apple Park また、今年は参加記念品として以下のものをもらえました。個人的にお気に入りなのは、Swiftロゴの入ったトートバックです。MacBook Proも入るサイズで愛用しています。 6月5日 - イベント前日 6月5日はイベント前日ではありますが、アーリーチェックインが可能になっており、先行でDeveloper Centerのオープンハウスも開催されていました。 会場はお祭りさながらの雰囲気で非常に盛り上がっていました。 早々にチェックインを済ませ、Developer Centerへ。 Developer Centerはエンジニアやデザイナーが交流したり学ぶための施設とのことですが、Apple Storeさながらのお洒落な部屋がいくつも用意されていました。 実際にコードを書いて学べるワークショップ用の部屋や、壁に巨大なホワイトボードが設置されたUI設計用の部屋、製品設計用の部屋などがありました。あまりの綺麗さに一度はここで仕事をしてみたいと思いました(ちなみに各部屋はこれまでのmacOSの名前が付けられています)。 一番驚きだったのが、Big Surと呼ばれる放送スタジオです。 そこはまるで映画館のような空間になっており、小さな文字もしっかり読める高解像度の超巨大モニターや、色々な角度から音を鳴らすことができるサウンドシステムには度肝を抜かれました。 放送スタジオということで、ここでAppleが観客の前でKeynoteを催すことはないのかもしれませんが、この空間で一度Keynoteを観てみたいと思いました。 6月6日 - イベント当日 ライブビューイング 6月6日、イベント当日。朝の入場待ちの列ではコーヒーが配られ、これから初公開のApple Parkへと足を踏み入れる人たちの熱気に包まれていました。 Appleのスタッフに歓迎されながら道を進むと、ついにApple Parkが姿を現します。汚れひとつない全面ガラス張りの外観は息を呑む美しさでした。 Apple Parkの中に入って朝食を受け取り、大きなスクリーンが置かれている広場であるCaffè Macsへ進みます。 メインスクリーンの正面の部分は窓ガラスが可動式になっており、屋内・屋外どちらからでもスクリーンが見られるように開かれていました。こんなに大きな窓ガラスが本当に動くのか…と規格外の大きさに圧倒されてしまいます。 Keynoteが始まるまでの時間ではAppleのスタッフがいたるところで踊っていたりとお祭りムードでした。 画面が暗転し、ついにKeynoteのライブビューイングが始まると思いきや、そこに現れたのはなんとティム・クックとクレイグ・フェデリギ! 座っていた参加者は全員立ち上がり、先ほどまでの熱気がより一層強まりました。 Keynote本編では、iOS 16や新型MacBook Airをはじめ、さまざまな新機能の発表がありました。新しい機能が発表されるたびに起こる拍手や歓声で喜びを共有できるのは、現地ならではの良さだなぁと感じました。 ちなみに、Keynoteのライブビューイングの中で最大の盛り上がりを見せたのは、超高速で移動するフェデリギ氏がスーパースローで髪を掻き上げているシーンでした(笑)。 Keynoteが大盛況の中終わると、お昼休憩を挟んでPlatforms State of the Unionが始まります。 State of the UnionではXcode CloudやSwiftUI、iOS 16のアップデートに伴うWidgetKitなど、新機能の紹介が行われました。ここでもKeynoteと同様に、リアクションが会場全体から漏れてきます。 ライブビューイングが終了した後は、Apple Park内の公開されているエリアを探索しました。 Appleのスタッフによると、3階からのCaffè Macsの景色がApple Parkで1番美しいスポットとのことでした。3階まで登ると円形になっている建物の全体を見渡すことができ、1番というのも納得の光景が広がっていました。 また、探索中にAppleのスタッフからAppleの環境や運動に対する取り組みに関する話を聞くことができました。 Apple Parkは建物部分が敷地全体の20%しかないらしく、緑との共生を大切にしているとのことでした。また、こうして緑を多くすることによって積極的に外に出るような環境を作り、他のチームとの交流や歩きながらのミーティングを促進しているらしいです。 Apple Park内にはバスケットボールのコートやテニスコート、サッカー場などがあり、日本では絶対にできないような土地の使い方を見ると、さすがアメリカだなぁと感じます。 Meet with Teams Platforms State of the Unionが終わると、Caffè MacsではMeet with Teamsというイベントが行われていました。ここではさまざまな分野のAppleのエンジニアが常駐しており、気軽に雑談ができました。実際に、自分はXcodeエンジニアとFitnessエンジニア、UIKitエンジニアと話しました。 Xcodeエンジニアとは、Xcode AppはXcodeで作られているのか、Appleのエンジニアだったら常に最新のMacに交換し放題なのかといった話をしました。こういったカジュアルな雑談ができるのはラボとの大きな違いだった思います。専門的な話になるとラボを勧められるといった雰囲気でした。 オンラインで楽しむWWDC22 海外出張した3名以外の、ほとんどのメンバーはオンラインでWWDC22に参加しました。開催期間中、ZOZOTOWNアプリ部のメンバーは、現地の時間に合わせて日本時間2:00〜11:00を勤務時間としていました。 チーム内で情報を共有するため、毎日1回、ビデオ通話でのミーティングを行っていました。また、分担して視聴したセッションのサマリや、ラボやラウンジで得た情報はMiroに一元管理して共有していました。色々な情報が共有されたため、WWDC22の全日程が終了した後にはボードの様子は以下のようになっていました。 このやり方は去年のノウハウを活かしたもので、下記のWWDC21参加レポートにより詳しく公開しているので、よろしければこちらもご覧ください。 techblog.zozo.com Digital Lounges WWDC22では、去年に引き続きオンラインならではの取り組みが実施されていました。 「Digital Lounges」はその1つで、Slackを用いて、Appleのエンジニアやデザイナーにチャットで質問することが出来ました。 質問以外にも、ラウンジ上では「Trivia Night」というイベントも開催されていました。これは、Appleプラットフォームに関するトリビアクイズが出題されるというものです。プログラムの実行結果に関する問題や「Apple社の祝日はいつ?」といったApple愛を試されるクイズも出題されました。 Challenges 「Challenges」も、オンラインならではのコンテンツでした。こちらは、いくつかの提示されたお題の中から好きなものに取り組むことが出来るコンテンツです。取り組んだ結果をラウンジでAppleのスタッフや世界中のiOSアプリ開発者たちに公開して意見をもらったり、SNSで共有して盛り上がったりすることが出来ます。お題にはバリエーション豊かなものが毎日追加されるので、興味のあるものに挑むのが楽しかったです。 私も「 Pixel perfect design 」という、アプリのアイコンをピクセルアートで表現するお題に挑戦し、弊社アプリ「ZOZOTOWN」のアイコンを描いてみました! Digital Loungesで見ていただいたところ、お褒めの言葉と同時に「文字のラインを2pxではなく1pxで表現してみては?」というフィードバックをいただけました。得られたフィードバックを反映し、ピクセルアートをこのように改善することが出来ました。 TAKE 1 TAKE 2 TAKE 3 この作品はApple公式サイトの WWDC22 Daily Digest: Wednesday にも取り上げていただけました! Source: https://developer.apple.com/news/?id=pcfa7nkx Labs & Sessions 今年も、ラボではAppleのスタッフからフィードバックを得ることが出来ました。WWDC22に参加したZOZOメンバーから、それぞれが参加したラボで得たフィードバックや、視聴したセッションで得た内容を一部紹介します。 Design Lab × FAANS こんにちは、FAANS部iOSチームの中島です。Design LabでAppleのデザイナーにFAANSアプリのフィードバックを頂いたので、紹介いたします。 投稿フローに対するフィードバック FAANSはWEARと同様にコーディネートを投稿でき、投稿写真の明るさ調整の機能があります。スライダーで「明るさ、コントラスト、彩度」の数値を変更するのですが、この機能に対し「プリセットを提供し、ユーザーが簡易的に明るさを調整できるようにしてはどうか」というフィードバックをいただきました。投稿フローにかかる時間の短縮方法を検討していましたので、今後の改善の参考にしたいと思います。細かいフィードバックをいただいたものの、コーディネート投稿フロー全体として完成度が高い、といっていただけたので今後の開発の励みになりました。 着用アイテムを登録する機能に対するフィードバック 投稿するコーディネートに着用アイテムを紐付けする機能があります。具体的にブランドをどのように選べばいいのか、という質問を受けました。品番/バーコードでの登録、お気に入り(クローゼット)からの登録、カテゴリ、カラー、ブランドを入力して該当の商品を一覧から探して登録、の3種類の方法があります。選択肢が多いことはユーザーを迷わせる原因になると思いましたので改善していきたいです。 通知の活用 FAANSはショップスタッフが主なユーザーです。コーディネート投稿機能に加え、在庫取り置きの機能があります。今のUIだとコーディネート投稿の結果確認がメインになっており、店舗取り置きの導線が少しわかりにくいという問題点に対し、どのように思うか質問しました。店舗取り置きがある場合、見逃さないようにする必要があるのでアイコンに赤い丸をつける等、通知という形でわかれば問題ないという回答をいただきました。 Design Labを利用した感想 FAANSアプリはWEARやZOZOTOWNとも連携する部分があり、英語での説明も相まって、そもそもどのようなアプリなのか説明するのが難しかったです。ユーザーが限られているというのはあるのですが、対象のユーザーでない人でも直感的に理解、操作できるように改善していきたいです。Design Labを利用したのは初めてでしたが、とても有意義なラボでしたので来年も利用したいです。 WeatherKitはWEARを拡張するか こんにちは、WEAR部iOSブロックのしょうごです。個人的にファッションと天気の相関性について、以前より注目をしていました。 なぜなら、冷夏や暖冬といった異常気象の季節には、季節物の販売不振に陥るケースがあります。そのため、ファッションコーディネートアプリのWEARとしても、ファッション業界の動向は注視する必要があると思います。そこで、異常気象によるWEARへの悪影響を想定した場合、アパレル商品の流通量の低下の影響から、ユーザーログイン頻度の低下などは考えられると思います。故に、異常気象の影響を少しでも緩和できる仕組みが備わっていれば尚良いと思っており、以下の様な事を考えていました。 WEAR自体が天気情報をユーザーにPUSH通知を用いて知らせ、WEARアプリに誘導する 多くの人々は外出する前に天気予報を確認するため、アプリのユーザーにとってもメリットがある アプリを開いた後ユーザーの位置情報をもとに、気温や湿度情報などからよく使われる服をレコメンドする 結果として、人々のファッションの悩みを解決するというWEARのミッションも果たせるかもしれない 今回発表のあった内容によるとWeatherKitは、非常に詳細な気象データを取得できるようです。詳細な気象データを取得可能な理由として、高解像度の気象モデルと機械学習および予測アルゴリズムを使用し導き出しているとセッション中で説明がありました。取得できる情報の詳細は Meet WeatherKit - WWDC22 をご参照下さい。 読者の皆さんはWeatherKitに関してどの様な印象をお持ちになりましたか? 技術は使い方次第で可能性は無限大です。WEARに関して考えてみると、個人的にWeatherKitとの親和性は高いと考えています。なぜなら、WeatherKitの精度次第では、天気情報をベースとした新たなファッションの提案が可能になると考えたからです。また、既存のWEARを拡張してユーザーとのタッチポイントを増やす事ができるというシナジーも期待できます。今後WEARならではの新たなサービスを生み出す事も、可能かもしれません。そして、コーディネートと天気の親和性が高いファッション業界に、ZOZOらしくテクノロジーの力を用いて一石を投じる可能性もあると考えています。 まとめ 本記事では、WWDC22の参加レポートをお伝えしました。 海外出張で現地参加したメンバーも、オンラインでリアルタイムに情報をキャッチアップしたメンバーも、それぞれに実りあるイベントでした。今回得られた知見やフィードバックをもとに、より良いサービスの向上に努めていきたいと思います! さいごに ZOZOでは、一緒にモダンなサービス作りをしてくれる仲間を募集しています。ご興味のある方は、以下のリンクから是非ご応募ください! corp.zozo.com hrmos.co hrmos.co hrmos.co
アバター
はじめに こんにちは、検索基盤部 検索基盤ブロックの可児( @KanixT )とSRE部 ECプラットフォーム基盤SREブロックの大澤です。 本記事では、ZOZOTOWNの商品検索で利用しているElasticsearchをバージョンアップした知見と、その際に実施した検索基盤の改善についてご紹介します。 目次 はじめに 目次 背景 バージョンアップの流れ 主な作業 変更箇所の調査 新バージョンのMappingやQueryなどの調査 Deprecation logsが有効になっていることの確認 バージョン7.16.0でタイプ(type)を利用 Javaクライアント LTRプラグインのバージョンアップにともなうJavaのバージョンアップ 特徴量キャッシュの機能がマージされた Javaクラスファイルのバージョン確認方法 Elasticsearchクラスタのコード管理化 IaC方法の選択 TerraformによるIaC化 検証環境での負荷試験 負荷試験の実施方法 インスタンスタイプ検証結果 最大負荷時におけるバージョンアップ前後の比較結果 別クラスタに新しいバージョンのElasticsearchを構築 新旧の両クラスタに対してのインデクシング 各種サービスの参照を旧クラスタから新クラスタへ切替え 旧クラスタの削除 Slowlog 最後に 背景 ZOZOTOWNでは商品の検索エンジンとして、Elastic社が提供するElastic Cloudを利用しています。公式サポートの恩恵を受けるためElasticsearchの EOL に気を遣う必要がありました。Elasticプロダクトのサポート期限は、一般公開日(GA)から18か月と定義されており、弊社で利用しているElasticsearchの期限も迫っていました。ZOZOで実施したバージョンアップ、およびバージョンアップのタイミングに合わせて実施した検索基盤の改善について、知見をご紹介します。 なお現在は8.xがリリースされていますが、作業当時の最新は7.xだったため7.10.xからのマイナーバージョンアップについての知見となります。 バージョンアップの流れ Elasticsearchをバージョンアップするタイミングに合わせて、LTRプラグインの独自ビルド廃止やクラスタのコード管理化など、以前からチーム内で課題感のあった点も改善しています。作業は以下の流れで進めました。 なおバージョンアップに関して、当初は Rolling upgrade による更新を検討していました。しかし検索機能で利用しているindexはドキュメントの更新が常時動いており、Rolling Upgradeで失敗した際にデータの復旧が難しくなり、リスクが高いと判断しました。そのため別クラスタに新しいバージョンのElasticsearchを構築し、切替えを行う方針を採用しました。 主な作業 変更箇所の調査 新バージョンのMappingやQueryなどの調査 Javaクライアント LTRプラグインのバージョンアップにともなうJavaのバージョンアップ Elasticsearchクラスタのコード管理化 IaC方法の選択 TerraformによるIaC化 検証環境での負荷試験 負荷試験の実施方法 インスタンスタイプ検証結果 サービスイン試験結果 本番環境の構築 別クラスタに新しいバージョンのElasticsearchを構築 新旧の両クラスタに対してのインデクシング 各種サービスの参照を旧クラスタから新クラスタへ切替え 旧クラスタの削除 変更箇所の調査 新バージョンのMappingやQueryなどの調査 バージョンアップの事前準備としてまずは Migration guide を確認し、利用予定の新バージョンまでにリリースされた機能でMappingやQueryに影響がある変更を一通り確認しました。 次にアップデートターゲットとなるバージョンのElasticsearchを新クラスタに検証目的で構築しました。その環境で現在動作している検索クエリとインデキシングを実行します。Deprecation logsを有効にすると非推奨のElasticsearchの機能を確認できるため、その方法についてご紹介します。 なお、今回のバージョンアップでは検索クエリとインデクシングの両方でクエリ修正は1件もありませんでした。 Deprecation logsが有効になっていることの確認 Kibana Dev Toolsを使用して下記リクエストを実行し、Deprecation logsの設定を確認します。詳細は Deprecation logs の公式ページをご覧ください。 GET /_cluster/settings?include_defaults&filter_path=defaults.cluster.deprecation_indexing 実行結果は次のようになり、 "deprecation_indexing.enabled" : "true" の場合に非推奨のログが出力されます。 { "defaults" : { "cluster" : { "deprecation_indexing" : { "enabled" : "true", "x_opaque_id_used" : { "enabled" : "true" } } } } } Deprecation logsの有効が確認できましたので試しにログを出力し、出力内容を確認します。 非推奨のログメッセージを確認するため、Elasticsearch 7.16.0の環境にて、バージョン7.0で廃止された タイプ(type) を利用します。 バージョン7.16.0でタイプ(type)を利用 次のPUTクエリを実行し、廃止されたタイプ(type)を利用します。 PUT /corp/employee/1 { "first_name" : "hakoneko", "last_name" : "max", "age" : 25 } 実行結果のレスポンスはこちらです。タイプ(type)を廃止した旨のワーニングが表示されました。 #! [types removal] Specifying types in document index requests is deprecated, use the typeless endpoints instead (/{index}/_doc/{id}, /{index}/_doc, or /{index}/_create/{id}). { "_index" : "corp", "_type" : "employee", "_id" : "1", "_version" : 1, "result" : "created", "_shards" : { "total" : 2, "successful" : 1, "failed" : 0 }, "_seq_no" : 0, "_primary_term" : 1 } 次にDeprecation logに出力された内容を確認します。ログを検索するクエリはこちらです。 GET .logs-deprecation.elasticsearch-default/_search { "size": 1, "sort": [ { "@timestamp": { "order": "desc" } } ] } 検索結果のレスポンスはこちらです。 { "_index" : ".ds-.logs-deprecation.elasticsearch-default-2022.05.19-000006", "_type" : "_doc", "_id" : "tZ9Q8YAB8Tww7jHGnFO2", "_score" : null, "_source" : { "event.dataset" : "deprecation.elasticsearch", "@timestamp" : "2022-05-23T14:27:11,423Z", "log.level" : "CRITICAL", "log.logger" : "org.elasticsearch.deprecation.rest.action.document.RestIndexAction", "elasticsearch.cluster.name" : "es-docker-cluster", "elasticsearch.cluster.uuid" : "********************", "elasticsearch.node.id" : ""********************",", "elasticsearch.node.name" : "elasticsearch", "trace.id" : "", "message" : "[types removal] Specifying types in document index requests is deprecated, use the typeless endpoints instead (/{index}/_doc/{id}, /{index}/_doc, or /{index}/_create/{id}).", "data_stream.type" : "logs", "data_stream.dataset" : "deprecation.elasticsearch", "data_stream.namespace" : "default", "ecs.version" : "1.7", "elasticsearch.event.category" : "types", "event.code" : "index_with_types", "elasticsearch.http.request.x_opaque_id" : "" } PUTクエリのワーニングと同じように、タイプ(type)を廃止した旨のワーニングが表示されました。このように非推奨の機能が確認可能なため、バージョンアップの際は是非ご利用ください。 Javaクライアント ZOZOTOWNの商品検索API(Spring Boot)は、Elasticsearchへ接続するクライアントに下記テックブログで紹介している通り、 High Level Rest Client (以下、HLRC)を使用しています。 techblog.zozo.com しかしながらHLRCは7.15.0で非推奨になり、新たな Java API Client がリリースされました。そのため今回のバージョンアップ作業として、新Java API Clientに移行するかを 移行ドキュメント と 検索クエリのドキュメント で確認し検討しました。ドキュメントから実装方法が大きく異なっていることを確認したため、改修にはある程度の期間が必要であると想定出来ました。そのためEOLの迫っている現状での対応は見送ることとしました。新Java API Clientを利用することで得られる恩恵は少なからずあると思うので早めの移行したいと思います。 LTRプラグインのバージョンアップにともなうJavaのバージョンアップ こちらのテックブログに記載があるように、弊社ではLTRプラグインを利用しており、特徴量を出力する過程で利用している特徴量キャッシュの機能をコントリビュートしました。 techblog.zozo.com 特徴量キャッシュの機能がマージされた 特徴量キャッシュの機能がリリースされるまでの期間は、本家のリポジトリをForkした独自ビルドを利用していました。本家のリポジトリに送った プルリクエスト がマージされ、Elasticsearch v7.16.3以降を対象にリリースされました。そのため独自ビルドを止め本家のLTRプラグインを利用することとしました。 対象 バージョン LTRプラグイン v1.5.8-es7.16.3 LTRプラグインを利用しているAPI(Spring Boot)は、Java 11で開発していました。 そのためSpring BootのPOM.xmlに依存関係を追加します。 <!-- https://mvnrepository.com/artifact/com.o19s/elasticsearch-learning-to-rank --> <dependency> <groupId>com.o19s</groupId> <artifactId>elasticsearch-learning-to-rank</artifactId> <version>1.5.8-es7.16.3</version> </dependency> いざv1.5.8-es7.16.3以上のLTRプラグインを利用しようとするとこのようなエラーが出ました。 クラス・ファイル/xxxx/.m2/repository/com/o19s/elasticsearch-learning-to-rank/1.5.8-es7.16.3/elasticsearch-learning-to-rank-1.5.8-es7.16.3.jar!/com/o19s/es/ltr/logging/LoggingSearchExtBuilder.classは不正です クラス・ファイルのバージョン58.0は不正です。55.0である必要があります 削除するか、クラスパスの正しいサブディレクトリにあるかを確認してください。 このエラーの内容は、LTRプラグインはJava 14(クラス・ファイルのバージョン58.0)でコンパイルされ、開発環境で利用しているJava 11(クラス・ファイルのバージョン55.0)ではLTRプラグインを利用できないことを意味します。 そのためAPI開発で利用しいているJavaを14以上にバージョンアップする必要が出てきたため、Javaのバージョンアップも急遽実施しました。 Javaクラスファイルのバージョン確認方法 ここではJavaクラスファイルのバージョン確認方法をご紹介します。コンパイルで生成されたクラスファイルに対してjavapコマンドを実行します。 javap -v test.class 実行結果より、major versionの記述がある箇所を確認します。 ・・(抜粋)・・ major version: 55 ・・(抜粋)・・ この例では major version: 55 のため、Java 11をターゲットにコンパイルされたクラスファイルであることが分かります。クラスファイルのバージョンを確認する必要がある場合は、javapコマンドを利用して確認してみてください。 Elasticsearchクラスタのコード管理化 SREチームで運用しているバージョンアップ前のクラスタには以下の課題がありました。 Webコンソールからの操作でクラスタを作成しており、再作成時に必要な初期設定などの再現性が低い ノード拡張はecctl(Elastic Cloud Control)をラップしたスクリプトで操作し、プラグイン設定はWebコンソールから操作する、といった半手動運用によりオペレーションミスが混入し易い 手動運用が入りIaC化出来ていない箇所があるため、インフラ構成変更のレビューコストが高い ecctlをラップしたスクリプトのメンテナンスコストが高い こうした課題を解決するため、SREチームではElasticsearchの構築・運用を全てIaC化し管理したいモチベーションがありました。今回は新しいクラスタへ立て替える機会に合わせてIaC化を実施しました。 IaC方法の選択 IaC化するにあたって幾つかの選択肢が考えらました。 ecctlを利用する Elasticsearch Service APIを利用する Elastic社より提供される Terraform provider を利用する 前述の通り既に一部運用にecctlを利用していますが、ノード拡張といった特定の操作を簡略化するためにecctlをラップしたスクリプトを作り込んでいる状況があります。Elasticsearch Service APIを利用した場合も同様にラップしたスクリプトを作り込む必要が想定されました。またスクリプトを作り込んでいった結果、Terraformで提供されている機能を再現してしまった、という車輪の再発明に至る可能性もあります。 そのため今回は独自な作り込みが不要なこと、またSREチームで既に運用しているTerraform用CI/CDが利用可能なメリットもあることから、TerraformによるIaC化を選択しました。 TerraformによるIaC化 TerraformでのIaC化を進めるにあたり、運用に必要な機能が提供されているか検証する必要があります。 resource “ec_deployment” “poc_cluster” { region = “ap-northeast-1” version = “7.17.0" deployment_template_id = “aws-cpu-optimized-arm” name = “poc_cluster” elasticsearch { autoscale = “ false ” topology { id = “hot_content” size = “1g” zone_count = “1" size_resource = “memory” } extension { name = ec_deployment_extension.poc_plugin.name type = “bundle” version = “*” url = ec_deployment_extension.poc_plugin.url } } kibana {} apm {} enterprise_search {} } resource “ec_deployment_extension” “poc_plugin” { name = “poc_plugin” description = “poc_plugin” version = “*” extension_type = “bundle” file_path = “./poc_plugin.zip” file_hash = “xxxxxxxxx” } これは最小ノード構成のクラスタを作る場合のサンプルコードです。例えばノードサイズの変更ができるか確認する場合は、以下のようにコードを変更しterraform applyを実行する方法で検証しました。 topology { id = “hot_content” size = “1g” -> “60g // 1GBから60GBへ変更できるか確認 zone_count = “1” size_resource = “memory” } このような方法で検証を進め、現在SREチームで行っている運用業務が全てコード変更で行えるか確認した上で、TerraformでのIaC化を最終決定しました。 また、Elasticsearchクラスタは開発環境〜本番環境それぞれ個別に存在しており、クラスタ構成自体に差分もあります。ある環境への変更が他の環境に影響を及ぼすことは避けなければなりません。 こうした環境間の問題を考慮し採用したディレクトリ構成がこちらです。 elastic_cloud ├── main.tf // Terraform verや使用するproviderを定義 ├── plugin │ └── xxxx.zip // 各種プラグインファイルをこのフォルダに配置 ├── dev │ ├── main.tf -> ../main.tf │ ├── local.tf // 使用するプラグインpathなど、環境変数を定義 │ └── elasticsearch.tf // Elasticsearchクラスタを定義 ├── stg │ ├── main.tf -> ../main.tf │ ├── local.tf │ └── elasticsearch.tf └── prd ├── main.tf -> ../main.tf ├── local.tf └── elasticsearch.tf ※.tfstateの出力先管理ファイル等、本解説に関係のないファイルについては割愛しています。 本番環境クラスタは専用のマスタノードを構成する、など環境毎にクラスタ構成の差異が存在します。こういった差異は環境変数では吸収できないため共通化ファイルとはせず、各環境で定義する方式としています。 Elastic Cloud上でのプラグインはクラスタ単位ではなくアカウント単位での管理となります。 全環境で共通に使用されているプラグインを更新すると全環境へ同時に反映されてしまいます。これを防ぐためプラグイン定義を環境毎に分離しました。 このようにElasticsearchクラスタのIaC化を行いました。まだSREチームで運用しているクラスタ全てがIaC化されてはおりませんが、引き続きバージョンアップ作業などを機にIaC化を取り組む予定です。 検証環境での負荷試験 バージョンアップ前のクラスタはインスタンスタイプにm5d(general purpose)インスタンスを選択していました。その後、日々運用していく中でパフォーマンス改善に期待できるc6gd(CPU optimized)インスタンスが提供されました。SREチーム内でも検証したいインスタンスタイプではありましたが、一度作成したクラスタのインスタンスタイプは変更できないこともあり低い優先度となっていました。今回クラスタを作り直す機会に合わせて、m5dインスタンスからc6gdインスタンスへの変更を検討するため負荷試験を実施しました。 また、今回はElasticsearchのバージョンアップの他に、LTRプラグインのバージョンアップもありパフォーマンスの変化が予想されます。 そのため、バージョンアップ後クラスタをサービスインさせるにあたり、年末年始の安定稼働を保証するためZOZOTOWNが想定する最大負荷を掛ける負荷試験を実施しました。 負荷試験の実施方法 負荷試験は検証環境に本番環境と同等構成のアプリケーションpod・バージョンアップ後クラスタを用意し、試験パターンに応じてpod・ノード数を可変させ実施しております。また負荷をかける方法としてOSS負荷試験フレームワークであるgatlingを用いており、API Gatewayから検索API・Elasticsearchを通したレイテンシを計測しています。 gatlingの実行には分散負荷試験ツールGatling Operatorを用いました。Gatling Operatorは分散負荷試験のライフサイクルを自動化するKubernetes Operatorです。先日SRE部より紹介しておりますので詳細はこちらをご覧ください。 techblog.zozo.com インスタンスタイプ検証結果 バージョンアップ前のクラスタを基準にした、バージョンアップ後のm5dクラスタとc6gdクラスタの傾向は以下です。 インスタンスタイプ CPU使用率 99パーセンタイルレイテンシ m5d 同等 同等 c6gd 改善 悪化 ※あくまでZOZOにおける検索の利用方法による結果となります。一概に全ての利用方法で同じ傾向になるということではありません。 SREチームでは99パーセンタイルレイテンシに基準値を設けており、基準値を超えた場合はリリースNG判定をしています。 c6gdインスタンスは、CPU使用率について改善が見られたものの99パーセンタイルレイテンシがリリース基準値を超えてしまいました。 m5dクラスタとc6gdクラスタは異なるCPUアーキテクチャを採用しており、この差異が今回の結果となったと想定しています。 ただし今回はEOLまでの期間が迫っていたこともあり、この差異の詳細についての深掘りはせず引き続きm5dクラスタを使う方針で決定しました。パフォーマンス改善のためにもCPU optimizedインスタンスへの切替は、今後も機を見て挑戦する予定です。 最大負荷時におけるバージョンアップ前後の比較結果 最大負荷を想定したノード数まで拡張を行い実施した負荷試験のバージョンアップ前後の比較です。 CPU使用率 99パーセンタイルレイテンシ 同等 同等 バージョンアップに伴う性能変化はないことを確認した上でサービスインを実施しました。 別クラスタに新しいバージョンのElasticsearchを構築 前述の通り、Terraformコードを基にクラスタを自動構成するため、IaC化が完了した時点で本番環境の構築は簡単に進む想定でした。しかしながら、実際にはクラスタ構築をしたところ以下のようなエラーが発生しました。 Error: failed creating deployment: 2 errors occurred: * api error: clusters.cluster_plan_version_not_permitted: The requested version of [7.17.0] set in [elasticsearch.version] is not permitted as it violates the ESS version policy (resources.elasticsearch[0].elasticsearch.version) * set “request_id” to “l1vwuu43qlzgkgppw51j1lncgc2mboe78cqf1g3g913nyq8co0pbut6xdmtbz81l” to recreate the deployment resources これはElastic Cloudサポートへ確認したところバージョン7.17.0m5dインスタンスクラスタの対応が終了していたことが原因でした。そのため、Elastic Cloudサポートより一時的にクラスタ作成の制限を解除する対応をとっていただきました。 制限の解除後、m5dインスタンスに対応する最終バージョンである7.13.0でクラスタを構築し、構築後7.17.0にアップデートする手順でクラスタを構成しました。 resource “ec_deployment” “elasticsearch” { region = “ap-northeast-1” version = “7.13.0" -> “7.17.0” こちらはTerraformによるクラスタバージョンアップ時の変更コードです。図らずもTerraformによるバージョンアップ作業が正常に行えることの確認にもなり、今後のバージョンアップにも役立つ結果となりました。 新旧の両クラスタに対してのインデクシング 冒頭にも記載しました通りRolling upgradeでのバージョンアップはリスクがあると判断したため新クラスタを準備しました。そのため旧クラスタと新クラスタの両方にインデクシングを行い、同様の頻度で更新を実施することで全く同じ環境を構築しました。 新旧の両クラスタへのインデクシングには単純に直列で行うと2倍の時間がかかりますが、ZOZOTOWNのインデクシングの仕組みには、以前より並列で動作させる仕組みがありました。そのため並列度を上げることで、インデクシングのサービスレベルを落とすこと無くインデックスを構築しました。 構築した新旧クラスタのインデックスの検証には、インデックス比較用にPythonスクリプトを作成し、定期的にインデックス差分が無いかをチェックしました。また、品質管理部にも協力いただき、ZOZOの画面レベルでも検索結果の比較テストを実施することで品質を担保しました。 各種サービスの参照を旧クラスタから新クラスタへ切替え 旧クラスタから新クラスタへの切替作業に関して、弊チームが管理しているAPIを経由するリクエストは容易に切替えることができました。しかしZOZOTOWNの検索機能で利用しているインデックスには他チームが管理しているモデルやインデックスもあり、さらに直接Elasticsearchを参照しているチームもありました。そのため、切替時には各チームと事前に日程を調整し、切替えを実施しましたが、想像以上に連携・調整コストが掛かりました。 バージョンアップでクラスタを切替える度に、他チームと連携・調整するコストはとても負担が大きいです。そのため今後はElasticsearchの直接参照は出来る限り廃止し、Elasticsearchへのリクエストは弊チームが開発したAPIを一律経由させるよう継続して改善を進めています。 旧クラスタの削除 新クラスタへの切替が完了した後に旧クラスタを削除しました。その際、Elasticsearchを直接参照している機能があるため、旧クラスタへの参照が残っていないことの確認はSlowlogを用いて確認しました。 Slowlog インデックス毎にquery/fetch/indexの処理時間をwarn, info, debug, traceレベルで設定できます。設定時間を上回ったクエリは専用ログに出力され、ログを検索することでどのような処理が動いたかを確認出来ます。詳細は Slowlog の公式ページをご覧ください。 インデックスに対する設定内容はこちらです。0sにすることで全てのクエリがログ出力されるようになります。 PUT /zozo-demo-index/_settings { "index.search.slowlog.threshold.query.debug": "0s" } Slowlogを確認するクエリはこちらで、ログ出力時のタイムスタンプを降順にソートさせています。 GET /elastic-cloud-logs-7/_search { "size":1000, "query": { "bool": { "must": [ { "term": { "log.level": { "value": "DEBUG" } } }, { "range": { "@timestamp": { "from":"2022-04-27T08:55", "to":"now" } } } ] } }, "sort": { "@timestamp": { "order": "desc" } }, "_source": ["@timestamp","message"] } 最後に Elasticsearchのバージョンアップサイクルは早いため、追従するにはとても労力が必要な作業です。今回紹介した知見でElasticsearchのバージョンアップが少しでも楽になれば幸いです。 またElasticsearchは新機能の追加やパフォーマンス向上も積極的に行われているため、バージョンアップで得られる恩恵は少なからずあると思います。そのため手が付けられないほど最新バージョンと差が広がる前に、定期的なバージョンアップをおすすめ致します。 弊社では、検索機能を開発・改善していきたいエンジニアを募集中です。 hrmos.co https://hrmos.co/pages/zozo/jobs/0000010 hrmos.co
アバター
こんにちは。技術本部SRE部ZOZO-SREブロックに所属している杉山です。SRE部のテックリードとして、オンプレ/クラウドのインフラを担当しています。 ZOZOTOWNでは、既存システムのリプレイスプロジェクトを進めています。各サービスのマイクロサービス化は進んでいますが、バックエンドでは「WindowsServer + IIS」で稼働しているシステムがまだ多く残っています。そのリプレイスプロジェクトを進めるうえで重要なポイントとなる、セッションストアのリプレイス「セッションオフロードPhase 2」が完了しました。本記事では、リプレイスしていくうえでの工夫や課題への対応を紹介します。 目次 目次 セッションオフロードPhase 2について プロジェクト概要 Phase 1:CacheStoreのリプレイス Phase 2:SessionStoreのリプレイス ZOZOTOWNが抱える、セッション管理の課題 1:スティッキーセッションのため、スケーリング運用に支障がある 2:スティッキーセッションのため、サーバー負荷が偏る 3:オフロードしないとフロントエンドリプレイスができない リプレイス前後の構成 採用技術について Amazon Elasticache Redis RedisClusterのシャードとは 冗長構成について セッション期限切れ処理について 本番リリース 「Cookie Persistence」を利用した、N%リリース リリース後に発生した想定外のトラブル まとめ セッションオフロードPhase 2について プロジェクト概要 セッションオフロードプロジェクトは、CacheStoreリプレイスのPhase 1と、SessionStoreリプレイスのPhase 2で構成されています。 Phase 1:CacheStoreのリプレイス Phase 2:SessionStoreのリプレイス Phase 1:CacheStoreのリプレイス Phase 1:CacheStoreのリプレイスについては、こちらの記事をご覧ください。 techblog.zozo.com Phase 2:SessionStoreのリプレイス セッションオフロードPhase 2は、WebサーバーであるIISのサーバー内セッションの機能を無効にし、外部SessionStoreにオフロードさせるリプレイスプロジェクトです。ZOZOTOWNが抱えていた、スケーリング運用やフロントエンドのリプレイスの課題を解決することを目的としています。 ZOZOTOWNが抱える、セッション管理の課題 以下のような課題がありました。 スティッキーセッションのため、スケーリング運用に支障がある スティッキーセッションのため、サーバー負荷が偏る オフロードしないとフロントエンドリプレイスができない それぞれの課題について、具体的に説明します。 1:スティッキーセッションのため、スケーリング運用に支障がある ユーザーセッションにIISの機能であるセッションを利用しており、ユーザーセッションがWebサーバーに紐づきます。LoadBlancerでは、Cookie Persistenceと呼ばれる機能で振り分け先の固定が必要です。そのため、Webサーバーのスケーリング運用のうち「スケールイン」に注意が必要です。ユーザーのセッションが期限切れになるのを待ったり、夜中の時間帯を狙ってスケールインする必要があるなど、運用に支障がありました。 2:スティッキーセッションのため、サーバー負荷が偏る ユーザーセッションごとにWebサーバーを固定する必要があるので、リクエストのロードバランシングで偏りが発生します。これにより、一部のサーバーは負荷が高くなってしまうこともありました。 3:オフロードしないとフロントエンドリプレイスができない ユーザーセッションがWebサーバーに対してスティッキーなため、フロントエンドを段階的にモダンな技術でリプレイスするという手法が取れません。例えば、まずはトップページをコンテナ化しEKSで運用したいが、セッション情報が特定のWebサーバーに保存されているため、セッション情報を維持しにくいです。 Webサーバー内のセッションを、外部のデータストアにオフロードすることで、これらの課題を解決しユーザーリクエストがどのWebサーバーに割り振られてもセッションを維持できるようにする事が目的です。 リプレイス前後の構成 リプレイス前(左)は、WebサーバーのIIS内のセッションを、独自ライブラリを介して利用していました。リプレイス後(右)は、外部SessionStoreにセッションデータをオフロードし、独自ライブラリに実装したクライアントを用いて利用します。 また、リプレイス時のアプリケーションの改修コストを抑えるため、独自ライブラリ内でRedisクライアントをラップして実装しました。そして、アプリケーションコードの改修を最小限に抑えて、Redisへの接続に切り替えることができるようにしました。 採用技術について Amazon Elasticache Redis 世間で広く使われている技術のRedisを採用しました。クラウドサービスとしては「Amazon Elasticache Redis」(以下、Redisという)の「Clusterモード有効」を採用しました。 Redisは、負荷特性に応じてClusterモードの有効/無効を選択できます。ホットキーの多い負荷特性の場合は、Clusterモード無効にして複数のリードレプリカとリーダーエンドポイント使う方が効果的な場合もあります。 セッションをオフロードすることでRedisは全Webサーバーから多くのリクエストを受けることになりますが、セッションデータはユーザー毎のデータなのでホットキーはありません。このことから、Clusterモード有効のRedis(以下、RedisClusterという)を利用してシャードのスケールアウトで負荷分散できるようにしました。 例えば、平常時は30シャード、セール時は40シャード、ZOZOTOWN最大の負荷となる冬セールは60シャード。というように、必要に応じてシャード数を増やし負荷分散させています。 RedisClusterのシャードとは シャード:1~6個のRedisノードで構成される集合の単位。 ノード:Reidsノード単体のことで、「Primary / Replica」の種類がある。 本記事では、「シャード」をスケーリングの単位。「ノード」をRedisノードとして表現します。 冗長構成について RedisClusterの各シャードは、プライマリノードの他に別AZのレプリカを持たせることができ、ノード障害時に「プライマリノードのフェイルオーバー」で自動復旧させることが可能です。検証では、プライマリノードのフェイルオーバーでの復旧は障害の内容にもよりますが、1分弱程度から数分でフェイルオーバーが実現できました。しかし、ユーザーセッションを取り扱うとても大事なシステムとなるため、弊社では30秒程度の復旧を目標としていました。 この要件を満たすために、シングルAZのRedisCluster(レプリカ無し)において、プライマリクラスター/セカンダリクラスターでAZ違いの2クラスター運用にしました。独自ヘルスチェックシステムをEKSで動かし、ノード障害発生時には速やかにクラスターフェイルオーバーを行う事で、約15秒~30秒程度でのフェイルオーバーを実現しました。 2クラスター運用としたことにより、メンテナンスの際にセカンダリを活用することで、ローリングでメンテナンスを実施可能になったことも1つのメリットとなっています。 セッション期限切れ処理について ZOZOTOWNは、ユーザーの買い物体験を向上するための機能として「在庫引き当て」という機能があります。この機能は、商品をカートに投入したユーザーがセッションを維持している間は、決済前でも在庫を確保するという機能です。この在庫引き当ては、セッション情報が紐づいているため、セッション期限切れ(Expire)時に在庫を戻す処理が必要です。 Redisでは、keyにTTLを設定し自動でExpireさせる機能がありますが、検証当時はExpireをトリガーとして任意の処理を実行できませんでした。Pub/Subモードを利用するという事も考えましたが、何らかの障害でSubscriberがメッセージを受信できなかった場合、その商品の在庫引き当て解除ができない事態となることが考えられました。 可能な限り確実に処理を実行できることを担保したい。障害が発生した後でも、在庫引き当て解除の処理が確実に実行されるようにする。これらを実現するために、外部ワーカーでRedisのデータをスキャンし「セッション期限切れの処理」と「在庫引き当て解除」の処理を行っています。 このワーカーは、EKSでJobを稼働させ、後ろで待ち構えているQueueに「在庫引き当て解除リクエスト」を流しています。 本番リリース 「Cookie Persistence」を利用した、N%リリース 本番リリースでは、AkamaiALBを活用することで、スティッキーを外しながらN%リリースしました。リリース時の構成は以下のような構成になります。 AkamaiALBで「スティッキーON / サーバー内セッション」のサーバープールAに向けたOrigin-Aと「スティッキーOFF / セッションオフロード状態」のサーバープールBに向けたOrigin-Bを用意します。 AkamaiALBの「Cookie Persistence」を利用してリクエストの振り分け先をパーセンテージで制御します。これにより、一度Origin-Bに振り分けられたユーザーのリクエストはOrigin-Bに振り続けられます。 切り戻しを行う場合には、Origin-Bのパーセンテージを0%にしてから、CookiePersistence用のCookie設定を再設定します。これにより、Origin-Bへリクエストが振り分けられなくなります。 AkamaiALBを利用した振り分け手法は、過去のブログでも紹介しています。 techblog.zozo.com リリース後に発生した想定外のトラブル ZOZOTOWNは、オンプレ/クラウドのハイブリッド環境で運用しており、Direct Connect(以下DXという)を利用しています。 セール時にはAmazon Elastic Compute Cloud(以下、EC2という)も活用することで、Webサーバーをスケーリング運用していますが、平常時のWebサーバーの多くはオンプレで稼働している運用でした。 N%リリースが50%の時のとある日。平常時よりも負荷が高い状況ではあったのですが、負荷のピーク時間帯なると急激にDXのデータ通信量が増え、10G回線の帯域が上限に達してしまうという事態となりました。この時は切り戻しを行い10%リリースまで縮小させることで対応しました。 分析した結果、アプリケーションのあるロジックが非常に大きなデータをセッション情報に読み書きを行っていて、ユーザーリクエスト数に応じてDXのデータ通信量が増加するというロジックになっていました。 特定の条件下において発生する事象でした。全てのWebサーバーがEC2であればDXの帯域枯渇は起こらなかったでしょう。突き止めた原因に対してロジックの改修を行い、セッションデータの最適化を行う事で、現在はデータ通信量が急増することは少なくなりました。 この対応の後、2週間ほどかけて無事100%リリースとなりました。ハイブリッド環境では、DXにも注意が必要であると痛感しました。 まとめ セッションオフロードプロジェクトは、Phase 1 / Phase 2と合わせると1年以上の長い期間をかけたプロジェクトでした。ZOZOTOWNの大規模サービスを支えるためのインフラの工夫やハイブリッド環境ならではのトラブルなど、いろいろなことを経て無事リリース完了できました。何よりも、今後のフロントエンドリプレイスを加速させていく準備を整えられたことが良かったです。 ZOZOでは、インフラSREを募集しています。ご興味がある方は以下のリンクから是非ご応募ください! corp.zozo.com
アバター
はじめに 初めまして。ZOZOTOWN開発本部ZOZOTOWNアプリ部Android2ブロックの下川と申します。ZOZOTOWNアプリ部ではAndroidを担当するチームが今年の4月から2つになりました。1つのチームで運営するには人数が多くなってきたためです。そして私は新しくできたチームのリーダーを務めています。 この記事では、そんな2つになる前のAndroidチームがメンバーを増やすために、オンボーディングで抱えていた課題をどのように解決していったかを紹介します。 目次 はじめに 目次 オンボーディングに対する課題感 課題解決に向けたアプローチ 様々な取り組みの成果 最後に オンボーディングに対する課題感 ZOZOでは入社後に人自部によるオンボーディングが実施されますが、部署配属後にも配属先ごとにオンボーディングを行っています。その中で私が所属している部署では、以前からオンボーディングに対して課題がありました。また、ZOZOではコロナ禍を機に多くの社員が自宅からのリモート勤務になりましたが、オンラインでのオンボーディング実施に対する課題も新たに出始めているところでした。それらの課題を列挙してみると、以下のようなものがありました。 毎回オンボーディングの準備に時間がかかる 実施すべきことが漏れてしまう リモートだと新しいメンバーの状況が把握しづらい 所属している部署では、スムーズに新メンバーを受け入れられるよう上記の課題を解決するオンボーディングの仕組み化が急務でした。なぜなら、オンボーディングに時間がかかると新メンバーは中々チームや業務に馴染めず、チームの生産性が向上しないからです。さらに次の新しいメンバーを迎え入れることも難しくなってしまいます。そこでまずはAndroidブロックというチームの中で少しずつ改善していき、良かった部分は将来的に他の部署へシェアしていく形を目指して改善に着手しました。 課題解決に向けたアプローチ 最初に取り組んだのは、新しく配属されてくるメンバーにメンターを設定することです。メンターは新メンバー配属前に、チーム内で議論して決めるようにしました。時期や新メンバーのスキルに合わせてメンターを選出したいという意図があります。そのためメンターを担当するメンバーは固定ではありません。メンターになった人は主に以下のような役割を担います。 新メンバーの業務のサポート 必要なSlackチャンネルへの招待 参加が必要なミーティングへの招待 誰に聞けば良いか迷うような質問の1次受けの窓口 次にオンボーディングに関するドキュメントの整備をしました。ZOZOではドキュメント・ツールとしてConfluenceを使用しています。「オンボーディングの準備にかかる時間」と「実施すべきことが漏れてしまう」ことを改善するために、新しいメンバーが行うべき項目や手順をConfluence上にまとめました。具体的には、以下のような項目をドキュメント化しました。 メンターのやることリスト 勤怠に関する注意事項 業務上必要なツールの申請や設定手順 業務の進め方に関する注意事項 各種会議体についての説明 ドキュメントの整備は基本的なことですが、社員であれば誰でもアクセス可能な場所に情報が整理されている状態にすることは、最新の情報に安定してアクセスするのに最も効果の高い方法かと思います。特に「誰でもアクセス可能な場所」というのが重要で、もし情報が古くなっていた場合でも気づいた人がすぐに更新することが出来ます。 最近では社内ツールの利用申請で一部の申請方法が変わっていた場合などは、実際に申請した新メンバーがドキュメントを更新してくれることもあり、新メンバーが気軽にコミット出来る点でも良いなと感じています。また、適切に更新されているドキュメントがあると初めてメンターを担当する際の敷居も低くなりますし、オンボーディングを進める上でもメンターの負担がとても軽くなるなどといったメリットもあります。 続いての改善は、新メンバーのサポート面です。Slack上にオンボーディング専用チャンネルを作成し、オンボーディングに関するコミュニケーションを全てそのチャンネルに集約しました。これには2つ目的があります。 1つ目はメンター以外のメンバーでも、積極的に新メンバーのサポートを出来るようにすることです。オンボーディング中はメンターが中心となってサポートしますが、メンターの人も通常業務を抱えているためすぐにサポート出来るとは限りません。そのため、新メンバーの困り事をメンターでなくても素早くキャッチ出来るよう、通常の開発業務で使用しているチャンネルと分けました。 2つ目は、オンボーディングに関する過去のやり取りを見つけ易くするためです。ドキュメント化を進めていても、やはり「前回はどうしたのだろう」や「なぜ今の形になったのだろう」など過去のやり取りを見返したいケースは発生するため、専用チャンネル化することで解決しようという狙いです。 また、新メンバーの自主性に頼り過ぎないよう、新メンバーの状況把握も改善しました。ZOZOでは原則週1回の頻度で上長との1on1を行っていますが、新メンバーはそれにプラスしてメンターと毎日1on1を行うようにしています。新メンバーが分からないことや困っていることを、少しでも早く解決することが目的です。これらのサポート体制の仕上げとして、メンターは1か月間のオンボーディングの終わりに上長や新メンバーと一緒に次のオンボーディングに活かせるよう振り返りを実施しています。 最後に紹介する改善は、コミュニケーションに関するものです。普段使用しているコミュニケーション・ツールはSlackとGoogle Meetです。それ以外にも気軽に話せるようにという目的でDiscordも併用しています。最近ではあるメンバーがメンターをしたときの発案で、それらを活用してWelcome Coffee Chatという時間をオンボーディング期間中に設ける試みも行いました。 1 内容としては、新メンバー+既存メンバー2〜3人くらいの規模を複数回に分けて開催し、新メンバーが全員と話せる会としました。話す内容も学生時代のことや趣味についてなど、お互いのことを知ることが目的となるようなテーマを設定しました。どうしても業務中に新メンバーへ話し掛けるタイミングが(とりわけリモートだと尚更)難しいため、交流する時間をイベントとして設定することで新メンバーと雑談出来るのはとても良かったです。 様々な取り組みの成果 以上のような改善を段階的に進めてきた結果、次のような成果が得られました。 1つ目はオンボーディングに関するドキュメントを整備および継続して更新してきたことによって、準備にかける時間が短縮され、オンボーディング開始後もスムーズに進行出来るようになったことです。また、新メンバーが配属後に数日経ってから「利用申請してないものがある」や「アレまだやってない」などということがほぼ無くなりました。 2つ目はサポート体制を改善したことによって、リモート勤務下でもスムーズにチームや業務に慣れていってもらうことが出来るようになったことです。その結果、その時の業務の状況にもよりますが、新メンバーが配属後3日〜5日でPull Requestを出せるようにもなりました。 ただし、まだ改善が足りていないなと感じる部分もあります。冒頭にもご紹介しました通りチームの規模も大きくなってきました。そのため新メンバーがメンターや上長以外の既存メンバー全員とのコミュニケーションの機会を得るのが難しくなってきています。先のWelcome Coffee Chatやオンラインでの歓迎会などを試しているものの、まだまだ改善の余地があると考えています。また、新メンバーが最初に取り組む丁度良い粒度のタスクが常にあるとは限らないため、チュートリアル的なタスクなどが用意出来ると良いのか、などといった検討も必要だと感じています。 最後に直近1年のオンボーディングの振り返りで、新メンバーからもらったフィードバックを紹介します。 良い点 改善系タスクから入ったのはやりやすかった 3日目ぐらいからコード書けたのが良かった 入社してからやることがドキュメントにまとまっていて、漏れなくスムーズに環境整備ができた メンターの方と毎日1on1をしていただけたため、些細な疑問や相談事を解消しながら業務に取り組むことができた リモートワークでのスタートだったためチームに馴染めるか不安だったが、Welcome Coffee Chatや歓迎会などでチームのメンバーと趣味や休日の過ごし方など雑談する場があったのでよかった 今後改善が必要だと思われる点 コードの量が多くてオンボーディング中に把握しきれない 担当が付くことで他のメンバーとのコミュニケーションが少なくなってしまう 申請が通るまで使えないツールがあり、作業をブロックされることがしばしばあった 自分は導入に最適な軽い案件が運良くあったのでキャッチアップが楽だったが、そのような案件が無い場合のプランが未定 Coffee Chat以降案件メンバー以外との会話する場があまりない このように新メンバーからも積極的にフィードバックをもらうことで、既存メンバーの認識が薄い箇所もしっかりと改善していけるよう工夫しています。 最後に ZOZOではAndroidエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください。 hrmos.co いくつかのチームではGatherというコミュニケーション・ツールを試用していおり、Welcome Coffe Chatを行ったときもGather上で実施しました。 ↩
アバター
はじめに こんにちは。検索基盤部の倉澤です。 私たちは、ZOZOTOWNの検索機能の改善に取り組んでいます。ZOZOTOWNのおすすめ順検索ではランキング学習を用いた検索機能の改善に取り組んでおり、A/Bテストにて効果を測定しています。 ランキング学習やElasticsearch Learning to Rankプラグインについては過去の記事で紹介していますので、併せてご覧ください。 techblog.zozo.com techblog.zozo.com 私たちは、機械学習モデルの開発からデプロイまでの一連の処理を実行するワークフローの構築にGoogle Cloud Platform(GCP)の Vertex AI Pipelines を利用しています。 本記事では、Vertex AI Pipelines採用前の運用とその課題点について説明し、次にVertex AI Pipelinesで構築したワークフロー概要とその運用について紹介します。 目次 はじめに 目次 Vertex AI Pipelines採用の背景 従来の運用 papermillとは 抱えていた課題と解決策 Vertex AI Pipelinesによるワークフローの構築 1. 学習データセット生成に必要な期間のデータが揃っているかの確認 2. 学習データセットの生成 3. ハイパーパラメータチューニング及び最適なパラメータによる学習 4. 評価及びオフライン評価結果の描画 5. デプロイ判定 Vertex AI Pipelines導入後の運用 A/Bテストのモデル開発時のブランチ戦略について まとめ Vertex AI Pipelines採用の背景 Vertex AI Pipelines採用に至った背景として、従来の運用と抱えていた課題点を紹介します。 従来の運用 Vertex AI Pipelinesを採用する以前は、GitHubで管理されているスクリプトを、開発者が各自GCPに立てたインスタンスのJupyter Notebook上で順に実行していました。 機械学習モデルの学習期間や特徴量などのパラメータは設定ファイルで管理しており、 papermill でNotebookを自動実行して機械学習モデルを生成します。そして、Elasticsearchの Learning to Rankプラグイン で指定された形式にモデルを変換し、手動でデプロイを行っていました。 papermillとは Jupyter Notebookを実行するライブラリとして記載したpapermillについて簡単に説明します。 papermillは、Jupyter Notebookに定義された各セルを実行するPythonライブラリです。 実行時にパラメータを渡すことで予めセルに定義されたデフォルトのパラメータの上書きが可能です。実行されたJupyter Notebookは別名のNotebookに保存できます。 私たちは、papermillをCLIで実行していました。 papermill input.ipynb output.ipynb -f parameter.yaml 設定ファイルは以下のようにYAMLファイルとして定義できます。 # parameter.yaml train_start_date : 20220101 train_end_date : 20220102 valid_start_date : 20220103 valid_end_date : 20220104 test_start_date : 20220105 test_end_date : 20220106 features : - feature1 - feature2 - feature3 抱えていた課題と解決策 従来の運用フローでは、モデルの数だけ同様の作業を手動で繰り返しており、以下の点を課題に感じていました。 各タスクの実施作業と実施完了の確認作業の工数が多い 実行前に設定ファイルの変更に対するレビューが無いので、誤りがあった場合は機械学習モデルを再度生成し直す必要がある 機械学習モデル生成の一連のタスクが途中で失敗した際に、一から再実行する必要がある これらの課題を解決するために、各タスクを依存関係通りに実行でき、さらに再実行時にはキャッシュが利用できるワークフローエンジンの導入を検討しました。 候補となるワークフローエンジンはいくつかありましたが、弊社MLOpsブロックがVertex AI Pipelinesの実行環境の整備を進めていることもあり、より導入コストが低いVertex AI Pipelinesを選びました。 Vertex AI Pipelinesの実行環境については過去の記事で紹介していますので、併せてご覧ください。 techblog.zozo.com Vertex AI Pipelinesによるワークフローの構築 私たちが構成した機械学習モデルの開発からデプロイまでのワークフローの概要を紹介します。以下の図は、実際に運用しているVertex AI Pipelinesのコンソール画面から確認できるワークフローの全体像です。 本ワークフローではおおよそ以下のことを行っております。 学習データセット生成に必要な期間のデータが揃っているかの確認 学習データセットの生成 ハイパーパラメータチューニング及び最適なパラメータによる学習 評価及びオフライン評価結果の描画 デプロイ判定 それぞれ順に説明します。 1. 学習データセット生成に必要な期間のデータが揃っているかの確認 学習に必要となる期間のデータが、対象のBigQueryテーブルに存在しているかの欠損チェックを行います。以下のようなAssertionクエリをコンポーネントから実行し、指定期間のデータが存在しているかを確認します。 -- check_bq_table.sql DECLARE target_dates ARRAY< DATE >; DECLARE x INT64 DEFAULT 1 ; SET target_dates = ( SELECT ARRAY_AGG( date ORDER BY date ) FROM UNNEST(GENERATE_DATE_ARRAY( ' {{ start_date }} ' , ' {{ end_date }} ' , INTERVAL 1 DAY) ) AS date ); WHILE x <= ARRAY_LENGTH(target_dates) DO ASSERT EXISTS ( SELECT {{ period_column }} FROM `{{ full_table_id }}` WHERE DATE ({{ period_column }}) = target_dates [ORDINAL(x)] ) AS ' target date does not exist in this table ' ; SET x = x+ 1 ; END WHILE; 2. 学習データセットの生成 学習・検証・テストのデータセットの生成をします。 以下のYAMLファイルはコンポーネントの入出力を定義し、学習・検証・テストのデータセットを出力しています。 # component.yaml name : Extract Dataset description : Prepare train/valid/test data inputs : - name : project_id type : String - name : job_name type : String - name : execute_date type : String outputs : - name : train_valid_data description : train/validデータセット type : Dataset - name : test_data description : testデータセット type : Dataset implementation : container : image : gcr.io/your_project_id/sample_component:gitsha-xxxx command : [ python, -m, src, --project_id, { inputValue : project_id } , --job_name, { inputValue : job_name } , --execute_date, { inputValue : execute_date } , --output_train_valid_data_path, { outputPath : train_valid_data } , --output_test_data_path, { outputPath : test_data } , ] 生成したデータセットは Cloud Storage FUSE によってマウントされたGoogle Cloud Storageのバケットに格納され、そのパスを後続のコンポーネントへと渡しています。コマンドライン引数で定義されている output_train_valid_data_path と output_test_data_path がこれに該当します。 実行ファイルの中でデータセットの出力先となるパスをコマンドライン引数として受け取り、データセットを保存します。その後、後続のコンポーネントにてこのパスからデータセットを読み込むという流れになります。 3. ハイパーパラメータチューニング及び最適なパラメータによる学習 前段で生成された学習データセットと検証データセットを用いてモデルのハイパーパラメータチューニングを行います。その結果出力された最適なパラメータでモデルの学習をします。 4. 評価及びオフライン評価結果の描画 テストデータセットを用いて学習済みモデルの評価をします。コンポーネント内でオフライン指標として定めているnDCGを計算します。Vertex AI Pipelinesのコンソール画面はマークダウン形式での表示が可能なので、オフライン指標の計算結果を以下のように出力しています。 また、評価時にはベースラインモデルのオフライン指標も計算し、ベースラインモデルからのアップリフト値も併せて表示しています。 5. デプロイ判定 学習したモデルのオフライン指標及びベースラインモデルからのアップリフト値によって、デプロイして良いモデルなのか判定します。このデプロイ判定のコンポーネントの後にElasticsearchへモデルをアップロードするコンポーネントを用意しています。 Vertex AI Pipelines導入後の運用 A/Bテスト時には、コントロール群に適用するモデル(以下、コントロールモデル)とトリートメントモデル群に適用するモデル(以下、トリートメントモデル)をそれぞれ開発する必要があります。また、複数の実験を同時に行う場合はさらに多くのモデルが必要になります。 このA/Bテスト時のモデル開発における運用について紹介します。 A/Bテストのモデル開発時のブランチ戦略について Vertex AI Pipelinesで利用するコンポーネントやパイプラインのソースコードなどはGitHubで管理しています。ここでは、A/Bテストで用いるコントロールモデル及びトリートメントモデル開発時のGitHubのブランチ戦略について簡単に紹介します。 コントロールモデルとトリートメントモデルのパイプラインの構成自体には基本的に大きな違いはなく、データセットを取得するSQLクエリや学習時のパラメータ値が異なります。 A/Bテストの度にトリートメントモデル用のSQLファイルや設定ファイルを新規に作成すると冗長な構造となってしまいます。そこで私たちは、ブランチごとにモデルの開発を分ける運用を採用しました。 main : コントロールモデルのデプロイ用ブランチ コントロールモデルは定期的に学習及びデプロイされるようにスケジューリング .*-abtest-treatment-[1-9] : トリートメントモデルの開発用及びデプロイ用ブランチ prefixには各A/Bテストの名前がわかる任意の値を付与 suffixにはトリートメントモデルの数に応じて番号を付与 トリートメントモデルのパイプラインは、開発ブランチからマージされた時にCIが実行するようにしています。 A/Bテストの結果、トリートメントモデルが勝った場合はそのブランチをmainブランチへマージし、負けた場合はそのままブランチを削除する運用にしています。 まとめ Vertex AI Pipelinesを導入したことにより、冒頭に記載した以下の課題はおおよそ解決しました。 各タスクの実施作業と実施完了の確認作業の工数が多い 実行前に設定ファイルの変更に対するレビューが無いので、誤りがあった場合は機械学習モデルを再度生成し直す必要がある 機械学習モデル生成の一連のタスクが途中で失敗した際に、一から再実行する必要がある コントロールモデルにおいては一通りの開発が終わり、現在はモデルの学習やデプロイの作業に工数を割くことはほとんどなくなりました。ただ、トリートメントモデルの開発やデプロイは現在も運用によりカバーしている側面もあるので、改善に向けて開発に取り組んでいます。 さいごに、ZOZOでは検索エンジニア・MLエンジニアのメンバーを募集しています。検索機能の改善に興味のある方は、以下のリンクからご応募ください。 hrmos.co hrmos.co
アバター
はじめに こんにちは、MA基盤の @gachi-muchi-engineer です。 私達のチームでは、Digdagを利用してユーザーにメールを配信したり、データ連携を定期的に行うような様々なワークフローを運用しています。今回その中でも特定の対象者にポイントを付与したり、メールを配信するなどのビジネス要素が強いワークフローを、エンジニアでない運用者が運用していくなかで課題がいくつか出てきました。そこで、動的にワークフローを起動する仕組みを構築することで課題を解決したので、その方法について紹介します。 目次 はじめに 目次 Digdag 背景 1. スケジュール設定の柔軟性 2. パラメータ定義の柔軟性 課題点のまとめ 解決策 仕組み CMSとDBについて 管理するデータについて シーケンス図 1. select dynamic_workflow_config 2. execute workflow by Digdag rest api 3. response session_id attempt_id 4. running workflow 5. update dynamic_workflow_config with session_id and attempt_id ワークフローでの工夫 +get_dynamic_workflow_config_list +loop 導入結果 今後の展望 運用管理ツール スケジュール設定の分散 リトライ方法 まとめ さいごに Digdag Digdagはワークフローエンジンと呼ばれるOSSのソフトウェアです。複数個のタスク間の依存関係からなるワークフローを定義し、そのワークフローの実行及び管理をします。ワークフローはdigという拡張子のファイルにワークフローのスケジュールやタスクの定義を記述します。詳しくは、 公式サイト を確認してください。MA部では、digファイルをGitHubで管理し、GitHub Actionsを用いてリリースする形で運用しています。 背景 今回動的にワークフローを起動する仕組みを導入した背景には、特にビジネス要素が強いワークフローをエンジニアでない運用者が運用していくなかで、以下の課題があったからです。 スケジュール設定の柔軟性 パラメータ定義の柔軟性 それぞれについて詳しく説明します。 1. スケジュール設定の柔軟性 ビジネス要素が強いワークフローの場合、柔軟にスケジューリングを行いたいケースがあります。例えば、月や週毎に実行する時間や曜日を変更したり、曜日ごとに実行日時を変更したいなどのケースがあります。しかし、Digdagでは以下のようなスケジューリングしかできません。 Syntax Description Example hourly>: MM:SS Run this job every hour at MM:SS hourly>: 30:00 daily>: HH:MM:SS Run this job every day at HH:MM:SS daily>: 07:00:00 weekly>: DDD,HH:MM:SS Run this job every week on DDD at HH:MM:SS weekly>: Sun,09:00:00 monthly>: D,HH:MM:SS Run this job every month on D at HH:MM:SS monthly>: 1,09:00:00 minutes_interval>: M Run this job every this number of minutes minutes_interval>: 30 cron>: CRON Use cron format for complex scheduling cron>: 42 4 1 * * ※ setting-up-a-schedule また、このようなワークフローの運用者はエンジニアではありません。そのため、変更するためにdigファイルを修正しGitHubへPRを作成する作業のハードルが高く、結局運用者が実行したいタイミングでWeb UIから手動でワークフローを実行する状態になっていました。 2. パラメータ定義の柔軟性 Digdagのワークフローは call や !include を利用して別のワークフローやタスクを呼び出すことができます。この機能を利用して、パラメータ化された共通のワークフローを用意していました。しかし、利用する際のパラメータの組み合わせの追加や変更することがエンジニア以外に実施できず、そのために運用コストがかかってしまうケースがありました。具体例として、対象者やタイトル、本文がパラメータ化されたメールを送るというワークフローがあったとして、それを利用するためにワークフローを増やさなければならない状態になっていました。 課題点のまとめ これらの課題点をまとめると解決するべきことは以下の通りになります。 エンジニアではない運用者がスケジュール設定を簡単かつ柔軟に設定できるようにしたい エンジニアではない運用者が実行パラメータの変更/追加をもっと簡単にできるようにしたい 複数のワークフローで同じ課題を抱えている 解決策 解決策として、Digdagのワークフローを起動するワークフローを開発し、ワークフローを動的に実行できる仕組みを導入することを考えました。具体的には、以下のイメージ図のようにCMSから運用者がワークフローのスケジュールと設定を登録し、それを元に実行するワークフローです。この仕組みの導入によって運用者はdigファイルを編集することなくワークフローを動的に実行できるようになります。Dynamic Workflow Starterと名付けましたが、長いのでワークフロースターターとします。 仕組み ここでは具体的な仕組みを紹介します。 CMSとDBについて CMSは今回 Google Sheets と Google Apps Script を利用、DBはBigQueryを採用し、CMSと合わせて簡易的なCMSを作りました。 管理するデータについて ワークフロースターターが実行するワークフローの設定、実行日時を管理し、その値を元にワークフローを実行します。DBで管理するデータは以下のようになります。 テーブル:dynamic_workflow_config column type null note uuid string x uuid start_at timestamp x ワークフローの実行日時 project string x 実行したいワークフローのプロジェクト workflow string x 実行したいワークフロー parameters json x 実行するワークフローに渡すパラメータ session_id string ○ 実行したワークフローのsession_id attempt_id string ○ 実行したワークフローのattempt_id parametersをjsonで持つことによってどんなパラメータでも対応できるようにしています。 シーケンス図 ここでは、シーケンス図を元にワークフロースターターの説明します。 1. select dynamic_workflow_config まずは、シーケンス図の通りのクエリで実行するべきワークフローが存在するか確認します。条件に指定しているsession_id,attempt_idについては後述のシーケンスで説明します。 2. execute workflow by Digdag rest api シーケンス1のクエリの結果で実行するべきワークフローがあればDidgagのAPI( PUT /api/attempts ) を利用してワークフローを実行します。リクエストボディに指定するsessionTimeにはstart_atを指定しparamsにparametersを指定しています。sessionTimeにstart_atを指定するのは、2以降のシーケンスで失敗した際にリトライ時のワークフローの重複実行を防ぐためです。これは「sessionTimeはワークフローの履歴で一意になる」というDigdagの仕様を利用するためです。 該当のDigdagの仕様は以下のようになります。 まず、Digdagのワークフローとセッション、アテンプトの関係は下図のようになっています。 ワークフローにおいて、セッションは実行計画を表しており、アテンプトは実際の実行を表します。sessionTimeはセッションが実行される時間を表しており、ワークフロー単位で一意になります。 参考 * sessions-and-attempts * scheduled-execution-and-session-time また、DigdagのAPI( PUT /api/attempts ) は同じsessionTimeを指定した場合は、すでに実行されているアテンプトがレスポンスされます。今回この仕様を利用して2以降のシーケンスで失敗しても、次回実行時や自動リトライを行った際に同じsessionTimeを指定することによって重複実行が行われないようにしました。 3. response session_id attempt_id シーケンス2で実行したsession_id,attempt_idを後続のBigQueryに保存するため保持します。 4. running workflow DigdagのAPIを利用して実行されたワークフローは非同期で実行されています。 5. update dynamic_workflow_config with session_id and attempt_id シーケンス3で取得したsession_id,attempt_idをBigQueryに保存します。ここでdynamic_workflow_configのsession_id,attempt_idに保存することによって次回の実行時にシーケンス1で実行済みと判断されるようにしています。 ワークフローでの工夫 ここでは、実際に開発したワークフローの一部抜粋から工夫した点を紹介します。 ... +start_workflows: +get_dynamic_workflow_config_list: _retry: 5 ... py>: get_dynamic_workflow_config +loop: for_each>: workflow_config: ${workflow_config_list} _parallel: true _do: +execute_workflow_and_update_table: _retry: 5 _export: start_at: ${workflow_config.start_at} project_name: ${workflow_config.project_name} workflow_name: ${workflow_config.workflow_name} parameters: ${workflow_config.parameters} py>: execute_workflow_and_update_table ... +get_dynamic_workflow_config_list このタスクはシーケンス図の1にあたるタスクになります。 +loop このタスクをパラレルで実行することによりシーケンス図2以降が並列で動作するようにしました。理由は、もしシーケンシャルに実行した場合途中で失敗してしまうと後続のワークフローの実行に影響があるためです。 導入結果 もともとの課題だったスケジュール設定の柔軟性とパラメータ定義の柔軟性の課題を、ワークフロースターターを導入したことによって解決できました。これらの課題によって手動実行しないといけなかったり、パラメータ変更のためにdigファイルを変更しリリースするなどの作業がなくなり運用コストをさげることがきました。また、汎用的に利用できる仕組みにできたので、今後ワークフローを設計する時の実行方法の選択肢を増やすことができました。 今後の展望 運用管理ツール CMSやDB部分に関して簡単に実装できる方法を選択しましたが、やはり今後運用が増えていくにあたってちゃんとした管理ツールの準備が必要だと感じております。管理ツールを用意しなかった理由としては、ワークフロースターターをスモールスタートさせたかったためです。実際に導入してから利用するケースが増えてきたので、管理ツール準備の検討を進めていきたいと思います。 スケジュール設定の分散 スケジュール設定が外部のDBとDigdagで分散してしまった点はデメリットになってしまったと感じました。今後、Digdagのスケジューリングされたワークフローの一覧と、ワークフロースターターで実行されるワークフローの一覧をあわせて確認できる仕組みを検討しています。 これは、そもそも現状のDigdagのUIで、スケジューリングされたワークフローの確認などができない課題も一緒に解決したい思いがあります。一覧で確認したい理由は、新しくワークフローを作成する際や依存するシステムを停止させる際に、いつ/どのワークフローが実行されるのかを知りたいときがあるためです。 リトライ方法 今までパラメータが原因でワークフローが失敗した場合、基本的にはdigファイルを修正してリトライする運用をしていました。ですが、今回導入したワークフロースターターで実行したものはAPIでリトライが必要になってしまいました。この課題に関しては、DigdagのCLIのretry commandで少しでも楽ができるように以下のようなPRを提案しております。 github.com まとめ 今回MA部で導入した、動的にワークフローを実行する仕組みを紹介しました。この仕組みを導入したことによって課題だった点が解決され、汎用的に利用できるようにしたため同様の課題を抱えたワークフローに対しても課題を解決できました。また、ワークフローを起動する方法に選択肢を増やすことができたため、とても良い改善を行えたと思っています。 さいごに ZOZOでは一緒にプロダクトを開発してくれるエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください! hrmos.co
アバター
はじめに こんにちは。MA部MA施策・運用改善チームの辻岡です。MA部では、ZOZOTOWNのメルマガ・アプリPUSH通知などの配信・分析等の用途で約数十TBのデータを運用しています。今回は長年MAのデータ基盤として利用してきたオンプレDWHをBigQueryに移行したおはなしをします。 この記事はこんな方におすすめ オンプレDWHからBigQuery移行を検討・実施してる方 ジョブ・スケジューラ、ETLツールの移行を検討・実施してる方 概要 オンプレDWHからBigQuery移行する前後の構成イメージを元に、今回の移行の話について概要を説明します。 次の図が移行前の構成図です。オンプレ環境のWindowsサーバ上でジョブ・スケジューリングと実行を基盤処理として、データウェアハウス(以後オンプレDWH)に対してデータ生成や外部システムとの連携をしていました。 今回、以下を目的にオンプレDWHを廃止してBigQueryにデータを集約しました。 分析効率化 更なる発展性の向上 オンプレDWHの運用負荷を下げる その後、移行スコープを検討した結果、オンプレDWHの移行だけではなくオンプレDWHに直接関連するオンプレWindowsサーバ上の処理全てをGCPへ移行することにしました。 既存のオンプレ基盤処理のままオンプレDWHだけ移行した場合、BigQueryへの接続すら既存のライブラリでは困難な状態でした。よって、オンプレDWHの移行だけではなくオンプレ基盤処理を一緒に移行する方が、安全かつ効率的に移行できると考えました。そこで、以下のように移行を進めることにしました。 移行した結果がこちらです。2022年1月に移行しました。 なんということでしょう。オンプレDWHをBigQueryに移行するだけでなく、オンプレ環境をまるっとGCPに移すことでとてもシンプルな構成に様変わりしました。 次は、移行前の課題と解決策についてお話しします。 オンプレDWHについて オンプレDWHはメルマガなど各種配信用のデータやその実績データを格納しており、約10年もの間MAのデータ基盤として利用されてきました。 課題 オンプレDWHでは、社内外含めて当該DWHに関する情報不足かつ独特な運用のため、保守・運用負荷が高い状態でした。さらにクエリの並行数には制限があるため、分析者が気軽にDWHにクエリを実行できない課題がありました。 解決策:BigQueryへ移行 そこで、移行先としてBigQueryを選択しました。著名かつクエリの並列実行が可能な数も(slot数に応じた遅延はあれど)いくらでも増やせるBigQueryは先の課題を解決してくれます。また、全体的なデータ基盤として既にBigQueryが採用されていたため、BigQueryへデータ集約することにしました。 オンプレ基盤処理について オンプレDWHをメインのデータソースとする基盤処理がありました。基盤処理は、Javaを使ったSQLの実行処理やメール配信SaaSへのリクエスト、ファイル生成処理等をオンプレ環境のWindowsサーバ上で行っていました。 課題 オンプレ基盤処理では、古いバージョンのライブラリを使った処理が複数存在し、環境に関しても開発と本番で分離がされていませんでした。また既存のオンプレWindows環境を含めて知見者も不足している状態でした。この基盤でBigQueryへ移行すると接続方法ですらボトルネックになり、開発効率も下げるリスクがありました。さらに、オンプレ環境への接続都合により、リリース時は必ず手動でのデプロイ作業が入っていました。 解決策:Digdag on GKEへ移行 課題の解決策として、GKE上に作成したDigdagへ移行することを選択しました。オンプレ基盤処理でも既にワークフローツールが使われており、既存の基盤をやめるにしても類似の機能が必要でした。MAでは既に別の処理でDigdagを利用しており運用経験や知見が豊富だったこともあり、Digdag on GKEという統一したバッチ処理基盤を作ることにしました。 クラウド移行する場合GCPを使うことは予め決まっていたのですが、GKEを使う事でよりスケールが楽な状態になりました。 例えば、ファイル連携処理の中でそれなりに大きいファイルの文字コード変換や加工によってリソースを消費する処理がありました。ですが、KubernetesCommandExecutorのおかげで他のtaskへの影響はなく、taskに対してpodのリソース設定するだけで問題なく実装できました。他にもたくさん恩恵を受けていますので以下のテックブログをぜひご覧ください。 techblog.zozo.com また、GitHub Actionsを利用してリリースを自動化しました。 移行手順と工夫 ここからは、リプレイスの泥臭い過程をお話しします。技術的な話はあまりありません。これから移行を実施する方の少しでも事例の参考になればよいという目的で書いています。興味のない方は、この後の"今後の展望"あたりまで飛ばすことをお勧めします。 移行は以下のように行いました。 クエリのリファクタリング 解体新書の作成 未知の領域、夜間バッチからスタート アプリケーションのリプレイス実装 クエリ切り替え 移行テーブル洗い出し 進行期間中にあった移行前基盤の過不足の反映 クエリのリファクタリング 2020年の10月辺りから2021年の5月頃まで、ビジネス案件と並行しつつ移行対象のクエリの一部リファクタリングを移行前に行いました。リファクタリングすることにより処理内容を把握できるといったメリットがありました。 解体新書の作成 ドキュメントがなく、処理が複雑でも、リプレイスは既存の処理を必ず知る必要があります。複雑な処理はリプレイスのためだけに既存の処理の流れを書いたドキュメントを作りました。私はこれを「解体新書」と呼んでいました。本物の 解体新書 とは一切関係ありませんが、作成経緯は似たようなものを想像しました。嘘じゃないよという証明を兼ねて、当時のドキュメント件名の一部を載せておきます。 実装前の内部を把握する目的は果たせたので実施効果はあったと考えます。 未知の領域、夜間バッチからスタート 一番最初に手をつけたのは夜間バッチと呼ばれる処理でした。これは、その名の通り夜間に動く処理です。移行前に使っていたジョブ・スケジューラは、GUIでぽちぽち処理フローを作ることができました。歴代の処理注ぎ足し運用により、蜘蛛の巣のようにジョブが連なっている状態でした。念の為モザイクをかけさせてもらいますが、以下の図が移行前の夜間バッチの処理フローの一部です。図のコメントはわかりやすいように追記したものですが、このGUIと中の処理を見ながらDigdagのWFを実装していきました。 夜間バッチから手をつけた理由は2つあります。 夜間バッチに依存している処理が沢山あったから 誰一人全てを知ってる人がいないパンドラの箱状態だったから 1つめは夜間バッチに依存してる処理が沢山あったため、先に作っておくとその後のリプレイスがスムーズになると考えたためです。2つ目は一番先行きの見えないこの処理を移行しておくことで終わりの見通しを良くするためです。 実際に夜間バッチの移行実装後はリプレイスの見通しがよくなり、プロジェクトの進行がスムーズになりました。よって一番見通しがつかないかつ依存関係の多い処理から移行することは、プロジェクトの進行に大きく寄与しました。 手を動かすことで先が見えない状態から、少し先行きが見えてきた状況の変化が大きな進歩でした。とにかく不明点を潰しながら移行作業をすることで、実装速度を上げる効果があったと考えます。 アプリケーションのリプレイス実装 続いて、ジョブ・スケジューラと既存のソースを読みながらDigdagに処理を移行していきました。バッチの各処理の区切り、クエリの区切りを読み取り、taskをなるべく細かく切ってWFを組みました。例えるとひたすら因数分解をするような作業です。リファクタリングの意図もありましたが、因数分解をしないと難読なコードやクエリが多く、ある程度整理したかったことが本意です。BigQueryの実行にはDigdagのbqオペレータとPythonを使いました。この辺りは以前Meetupで紹介した以下の資料に載ってますので興味のある方はご覧ください。 speakerdeck.com オンプレDWHからBigQueryのクエリに切り替え 続いてクエリをオンプレDWHの文法からBigQueryの文法へ書き換えました。 オンプレDWHの文法とBigQueryの文法の互換性に関する公式ドキュメントは存在しました。しかし、利用しているクエリは公式ドキュメントにない記法が随所にありました。そのため簡単な変換ができる場合に限り変換スクリプトで変換しましたが、基本的には1つ1つ手動でクエリを切り替えました。特に以下に留意しながら進めました。 Timezoneについて、オンプレDWHはJST、BigQueryはUTCであることを考慮 Query is too complexエラー回避 テーブル名をアッパーキャメルケースにした 1つめは、BigQueryの既存データが既にUTCで入っていたので合わせてクエリを変えることです。日付チェックが多いので DATE(target_date, 'Asia/Tokyo') のようなJST変換を全てのクエリに対して行いました。2つめは、BigQueryに携わる誰もが遭遇したであろうクエリの複雑性によるエラーの解消です。これは、クエリを細かく刻むことで解消しました。 最後は少し毛色が違いますが、テーブル名を全て大文字(例:USERTABLE)からアッパーキャメルケース(例:UserTable)に変えました。BigQueryはテーブル名の大文字と小文字を区別するため、この機会しかないと踏んで切り替えました。 上記と同等に留意すべき点だったのに漏れてしまったことが1つありました。それは処理が動く時間です。例えば、翌日用の処理が前日の夜に動き前日データを削除したい場合、指定するのは昨日ではなく今日を指定する必要がある、といったケースです。ほとんどの日次データ生成処理は利用当日の深夜に行う処理が担っていたのですが、前日に翌日分のデータを生成する処理は存在し、残念ながらリリース後にその考慮漏れを発見する形となってしまいました。今思えばとても単純な事とはわかりつつ、同じ境遇の方が同じミスをしないように、考慮不足の例も併せて記載しました。 データ移行対象テーブルの洗い出し 続いて、どのデータをどのように移行するのかをまとめたテーブルリストを作成しました。以下はリストの例です。 毎日洗い替えを行うデータの場合はデータ移行不要ですし、積み上げデータの場合はデータ量に応じてデータ移行の時間がかかる可能性を踏まえ、例のようにデータについての特徴を洗い出しました。 移行期間中に行われたオンプレ基盤処理での修正差分を反映 運用によるオンプレ基盤処理の改修は移行期間中も行なっていたため、移行後の基盤に反映しながら進めました。差分反映は二重の実装期間がかかることはもちろんですが、反映漏れやバグのリスクも増えます。リスクをなるべく避けるため、ビジネス部門と交渉し移行期間中はオンプレ基盤処理の改修を最小限に留める調整を実施しました。 移行直後に発生したこと リリース直後に不具合が発見され、その修正対応に約1か月ほど費やしました。処理自体のエラー検知はDigdagが行ってくれますが、データの中身についてはデータチェック処理や各種検知をリプレイス前と同等の仕組みしか入れなかったため不十分な状態でした。また、GUIのジョブ・スケジューラが廃止されたことでビジネス側の運用効率が大きく下がったと報告を受けました。さらに冪等でない箇所があったこととGKEでの運用スタートがあいまって、自部門のアラート対応負荷も上がりました。開発改善を行い、前述の通り稼働後1か月程度でこれらを安定稼働といえる状態までに収束しました。 移行結果 課題は解決したのか 分析効率 アプリケーションと分析用でBigQueryのJob実行プロジェクトを分けることで、アプリケーション処理に影響なく、分析者がクエリを実行できるようになりました。移行前はオンプレDWHを使った分析の代替方法として、夜間にオンプレDWHからBigQueryに連携してBigQueryから分析していました。しかし、データが1日遅れのためリアルタイムな分析ができない状態でした。また、連携が遅延するとさらに分析が遅れるリスクも負っていました。BigQuery移行後はリアルタイムにBigQueryへデータ格納されるようになったので分析効率は上がったと言えます。 発展性 移行した結果データの鮮度が上がり、GCP基盤で基盤処理が動くようになったため、機械学習でのモデル生成時の精度向上や今後さらなるAI活用などの発展性が増えました。 費用 インフラ費用はBigQueryおよびGCP基盤にしたことで年間費用を約50%削減しました。また、固定費用で支払っていた移行前に対して、BigQuery・GCP基盤にしたことで今後さらなるコスト減も見込めます。 保守・運用コスト 以下の理由により、保守・運用コストが下がりました。 環境分離により、本番影響を気にしながら開発作業をする必要がなくなった リプレイスと処理の整理により、コード量を90%以上削ったため可読性が向上 オンプレ環境独特の保守・運用作業がなくなった 拡張性が向上し、やりたかった事が続々とできるようになっている 想定していなかったメリット 解決したかった課題の他にも、いくつか移行メリットがあったのでご紹介します。 タイムトラベル BigQueryには、 タイムトラベル という過去7日間であればデータ復旧可能な機能があります。オンプレDWHの時は、夜間に毎日dumpを取る処理がありましたが、タイムトラベルの機能を使えば過去データは復旧可能と判断し廃止しました。この機能はデータ不備発生時の調査に非常に役に立ちます。またMAで利用しているデータ基盤のBigQueryテーブルについては、データ基盤チームが更に時をいい感じに戻す仕組みを社内で提供してくれており、こちらも調査で使わせてもらっています。時を遡るBigQueryに関するテックブログをデータ基盤チームが出していますので、興味のある方はこちらをご覧ください。 techblog.zozo.com パーティション分割 BigQueryには、 パーティション分割機能 という機能もあります。パーティション項目を指定することでデータ量の大きいデータでもローコスト、ハイパフォーマンスでのクエリ実行を実現できます。オンプレDWHの時は、ある大きなテーブルで分散キーを指定してもクエリが劇的に遅延する事象が発生していました。先の通り情報が不足していたため原因究明には至りませんでした。事象解消のため、dailyのBigQueryへのデータ連携にデータを残しつつ夜間に該当の過去データをオンプレDWHから削除をする処理がありました。オンプレDWHから削除した過去データを全て蓄積してもこういったデータ量起因と思われる謎の遅延は発生せず、パーティション分割項目も有効に働いたため、該当テーブルの過去データ削除処理を廃止しました。 今後の展望 自動テスト・データチェックの強化 今回は自動テストの不足により、移行後に一定日数が経過した後でデータの不備を発見しました。データ不備は検知がなければ何も気付けません。後日各所からの連絡でじんわり気づくことになります。 恒久的にデータチェックやテストコードを充実し、検知を素早く、開発をより安全に効率よく進めるための改善を引き続き進めています。 配信作業の効率化 配信作業はGUIのジョブ・スケジューラを使ってビジネス部門が対応してくれていましたが、GUIによってポチポチできていた作業が、移行後にできなくなったため、配信作業の効率を急激に下げてしまいました。開発改善を重ねてなんとか移行前とほぼ同等の状態まで戻せましたが、移行前よりも拡張性が向上したため、現在はさらなる効率化に向けて改善を進めています。 リファクタリング 機能の必要有無を確認しながら1つ1つ実装を進めたので処理が冗長な状態で残っているため、より最適な設計を検討しつつリファクタリングを進めていきます。 スキーマ管理 移行作業を優先したためBigQueryのスキーマ管理は移行時にスコープから外しました。一部Terraformで管理されていますが、データを洗い替える処理の中で制約が外れてしまうケースもあり、今後最適な方法を検討しつつ進めていきます。 発展性の実現に向けたアプローチ MAのデータを使った機械学習やAI活用がより正確にかつ利用しやすい状態になったり、GCP基盤になったことで出来ることが増えたため、今後の発展性の実現に向けたアプローチを進めていきます。 まとめ 以下、まとめです。これから同じようなチャレンジをする方の少しでもお役に立てれば幸いです。 移行の経緯は分析と運用の効率化 移行スコープはオンプレDWHの関わるオンプレ基盤全てが対象 ドキュメント、着手順、切り替え時の留意点などを考慮して作業を進行 目的達成に加え、さまざまなメリットあり テスト、運用効率化などについて今後の展望あり 記事は以上です。読んでいただきありがとうございました! さいごに MAでは一緒に働く仲間を募集中です。実は今回のリプレイスで、MAの募集要項に記載の利用技術から移行前の技術の記載がごっそりなくなりました。そんな話も踏まえて、MAに興味を持った方は、以下のリンクからぜひ閲覧・ご応募ください! hrmos.co hrmos.co
アバター
※2022-06-07 システムアーキテクチャの画像を修正しました。 はじめに こんにちは、MA部MA基盤ブロックの齋藤( @kyoppii13 )です。 ZOZOTOWNではアプリ向けのキャンペーンやセール情報などの配信でプッシュ通知を利用しています。プッシュ通知で配信するキャンペーンはセグメントに向けたマス配信のみで、ユーザごとにパーソナライズして配信するためのパーソナライズ配信には利用していませんでした。また、パーソナライズ配信の中にはリアルタイム性が求められるキャンペーン配信も含まれます。そこで、リアルタイムキャンペーンでプッシュ通知するための配信基盤を作成しました。 本記事では、リアルタイムなプッシュ通知を実現するために作成したシステムの紹介と、安定した配信を実現するために行った工夫について紹介します。 はじめに 従来のプッシュ通知と課題 従来のプッシュ通知 問題点 導入した配信基盤 配信基盤の入力について 配信処理 バリデーションチェック 重複配信の制御 FCMへのメッセージ送信 実績登録 品質担保の方法 配信処理のリトライ パフォーマンステスト HTTP/HTTPSのみならずSDKを使用したテストが可能 分散実行が可能 Pythonによるテストケースの記述 監視 今後の展望 配信基盤でのFCMトークンの取得 複数システムからの配信基盤の利用 まとめ さいごに 従来のプッシュ通知と課題 本章では、従来のプッシュ通知と課題について説明します。 従来のプッシュ通知 ZOZOTOWNではアプリ向けのキャンペーンやセール情報をプッシュ通知を含む様々なチャネルで配信しています。配信するキャンペーンの種類は大きく分けて、マス配信とパーソナライズ配信の2種類があります。 マス配信は、ある条件に一致する一定のユーザに対してバッチ処理によって一括で配信しています。パーソナライズ配信はユーザごとにパーソナライズしてキャンペーン配信します。中にはリアルタイム性が求められるキャンペーン配信も含まれます。例えばカートに入れたままで注文が確定していない商品のリマインドなどがあります。マス配信とパーソナライズ配信の基盤はそれぞれ分かれて存在しています。 マス配信ではすでにプッシュ通知を利用していました。プッシュ通知にはFirebase Cloud Messaging(以降、FCM)を利用しています。FCMに対象者と送信するメッセージ内容をリクエストすることでプッシュ通知が可能です。 マス配信でのプッシュ通知について、詳しくはこちらをご覧ください。 techblog.zozo.com 問題点 パーソナライズ配信は施策効果も大きいことから、プッシュ通知を利用したいというニーズがありました。 パーソナライズ配信基盤のみでプッシュ通知をするのであればパーソナライズ配信基盤を改修し、直接プッシュ通知もできました。しかし、パーソナライズ配信基盤は改修コストが大きく、システムが複雑になってしまいます。また、将来的には配達状況などを通知するトランザクションメッセージなどでもプッシュ通知をしたいというニーズから、複数のシステムから利用できる統一的な配信基盤が必要でした。 導入した配信基盤 前述の課題を解決するために、プッシュ通知のための配信基盤を作成しました。システムのアーキテクチャを下図に示します。 プッシュ通知を配信したい場合はこのシステムに対して対象者と通知内容をリクエストします。システムはそれを受けてデータを整形し、FCMにリクエストすることでユーザにプッシュ通知が届きます。また、配信時に配信実績をBigQueryに保存します。 配信基盤の入力について 配信基盤の入力は下図の赤枠で示した部分です。 配信基盤は入力にPub/Subを利用しています。以下のようなフォーマットのメッセージを受け取ります。 { " source ": Integer , " source_uid ": Integer , " registration_token ": String , " member_id ": Integer , " push_info ": { " title ": String , " body ": String , " url ": String , " image_url ": String } } 各パラメータの説明を以下の表に示します。 パラメータ名 説明 source リクエスト元システム識別用パラメータ source_uid リクエスト元システムごとにメッセージに付与するパラメータ registration_token FCMトークン member_id ZOZO TOWNメンバーID push_info.title プッシュ通知タイトル push_info.body プッシュ通知本文 push_info.url プッシュ通知本文 push_info.image_url 画像つきプッシュで表示する画像URL sourceはリクエスト元システムを識別するためのパラメータです。source_uidはリクエスト元システムごと、メッセージへユニークに割り当てるパラメータです。sourceとsource_uidは後述の重複配信の制御にて利用します。 registration_tokenはFCMトークンです。配信端末ごとユニークに割り振られるトークンです。これで対象者を指定します。push_infoは通知内容を指定します。FCMには通知メッセージとデータメッセージの2種類があります。違いとしては、メッセージの処理をする場所が異なります。通知メッセージはFCMで、データメッセージはユーザ端末で処理をします。セグメント配信ではデータメッセージを利用しており、セグメント配信と同様のフォーマットになっています。 配信処理 前述のフォーマットでPub/Subが受け取ったメッセージを、続くCloud Functionsにて配信処理をします。配信処理の流れは次の通りです。 メッセージのバリデーションチェック 重複配信の制御 FCMへメッセージ送信 実績登録 配信処理はPythonにて実装しています。 バリデーションチェック 処理対象となるメッセージのフォーマットが正しいかをチェックします。具体的には、必須パラメータと型チェックを実施します。例えば、配信基盤で受け取るメッセージのうちpush_info.image_urlは画像付きプッシュのときだけ受け取る値で、それ以外のパラメータは必須です。 重複配信の制御 重複配信の制御は下図の赤枠に示した部分で実施します。 メッセージ配信時の信頼性を表すものに以下のようなものがあります。 種類 説明 exactly once 必ず1回配信される at least once 最低1回配信される at most once 最大1回配信される 必ず1回の配信が保証されるexactly onceは理想的ですが、at least onceとat most onceの両方を満たす必要があり、設計難易度が高まります。今回はキャンペーンの特性上、重複配信を避けられればよいため、at most onceを採用することにしました。 重複配信の制御にはNoSQLデータベースであるCloud Datastoreを利用しています。Cloud Datastoreに保存されるデータはsourceとsource_uidの組み合わせでユニークです。このペアを利用して、配信済みかどうかの確認と登録を実施します。 素直に配信フローを考えれば以下のようになります。 配信済み確認 FCMへメッセージ送信 配信済み登録 しかし、このフローで配信処理が完了後の配信登録前で中断した場合、配信処理の全体をリトライすると重複配信の可能性があります。そこで、今回実装した配信フローは、次の通りです。 配信済み確認 配信済み登録 FCMへメッセージ送信 配信済み確認と配信済み登録を配信処理より前に実施することで、配信登録後に中断した場合はリトライしても配信されないことになり、at most onceが実現できます。 今後、トランザクションメッセージなどの必ず届けたい通知をする場合はat exactly onceを検討する必要があります。この点は課題です。 FCMへのメッセージ送信 FCMトークン(registration_token)と通知内容(push_info)をFCMへ送信します。FCMへのリクエストが成功した場合はメッセージを一意に識別するmessage_idを含むレスポンスが返ってきます。 実績登録 実績登録は下図の赤枠に示した部分で実施します。 Pub/SubとDataflowを組み合わせて、ストリーミング処理にてBigQueryに実績登録しています。 Cloud FunctionsでFCMへのリクエストが終了後、通知内容を含むメッセージをPub/SubにPublishします。実績として保存するデータは通知内容、対象者、配信日時、配信の成功可否です。その後、ストリーミング処理にてDataflowからBigQueryへの書き込みがされます。 実績を保存しておくことで、集計に使用できるのはもちろんのこと、パーソナライズ配信基盤でキャンペーン配信時に実施している、ユーザごとの最適化処理にも利用できます。最適化処理によって、最適なキャンペーンを最適なチャネルでユーザに届けることができ、キャンペーンによる施策効果を向上できます。 品質担保の方法 配信処理のリトライ Cloud Functionsで動いている配信処理はそれぞれでリトライを入れています。また、Cloud Functions自体のリトライも入れています。配信数が瞬間的に増えた場合などCloud Functionsの起動に失敗することがあるためです。Cloud Functionsのデプロイ時にretryオプションを付けることで、Cloud Functions自体のリトライが可能です。以下がデプロイ時のコマンド例です。 $ gcloud functions deploy test_function --runtime=python39 --trigger-topic = push-test --retry パフォーマンステスト 作成した配信基盤がリアルタイム基盤からのリクエストに対してパフォーマンスに問題がないかをテストする必要がありました。今回、パフォーマンステストには、以下の理由からLocustというテストツールを採用しました。 HTTP/HTTPSのみならずSDKを使用したテストが可能 分散実行が可能 Pythonによるテストケースの記述 HTTP/HTTPSのみならずSDKを使用したテストが可能 配信基盤の入力部分にPub/Subを利用しています。リアルタイム基盤からのリクエストではPub/Subのクライアントライブラリを使用する予定でした。 多くの負荷テストツールはHTTP/HTTPSを前提としており、SDKを利用したテストが容易ではありません。その点、LocustではSDKを利用したテストが容易なため採用しました(参考: Locustドキュメント ) 分散実行が可能 将来的には複数のシステムからの利用が想定されます。Locustではmaster/worker構成によってテストを分散実行できます。workerを増やすことで、並列数を上げることができます。これによって、テスト対象システムへ大きな負荷をかけることが可能です。 Pythonによるテストケースの記述 テストケースをPythonによって記述できます。今回、Cloud Functionsの実装などでもPythonを利用しています。システム開発における使用言語を統一できました。 パフォーマンステストでは、現状のリアルタイム基盤でのスループットに係数をかけたものを目標としました。リリース前にパフォーマンステストを実施することで、重複配信していないかの確認やエラー率を出せたため、安心してリリースできるようになりました。 監視 配信時の異常にすぐ気付けるようCloud Monitoringによる監視も入れています。異常なログがあった場合はSlackとPagerDutyによる通知がされます。 今後の展望 配信基盤でのFCMトークンの取得 配信基盤へリクエストする際、通知内容とともにFCMトークンも含めるようになっています。今後は受け取ったmemberIdをもとに配信基盤側でFCMトークンを取得するようにしたいと考えています。 複数システムからの配信基盤の利用 重複配信の制御の章でも触れましたが、将来的にはトランザクションメッセージの通知など他システムからの利用も考えています。トランザクションメッセージの場合は必ずユーザにメッセージを送信する必要があります。そのため、配信処理ではat exactly onceまたはat most onceにする必要もあります。 まとめ リアルタイムなプッシュ通知を実現するために作成した配信基盤について紹介しました。配信基盤の導入によって、プッシュ通知によるリアルタイムキャンペーンができるようになりました。本記事が皆様の参考になりましたら幸いです。 さいごに ZOZOでは一緒にプロダクトを開発してくれるエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは、SRE部の廣瀬です。 弊社のシステムには、基幹DBであるSQL ServerからBigQueryへと低遅延でデータを同期する「リアルタイムデータ連携基盤」が存在します。詳しい仕組みについて以下の記事で紹介されているので、よろしければご覧ください。 techblog.zozo.com 上記の記事の中でも紹介されていますが、SQL Server側のテーブルの変更を検知するために「 Change Trakcking 」という機能を使用しています。本記事では、このChange Trackingをプロダクション環境に導入したあとに発生した問題と、どのように調査・対応していったのかを紹介します。 発生した問題 あるDBに対する全クエリの内、一部のクエリでタイムアウト多発が約10分間ほど継続した後、自然解消しました。同タイミングでDBのブロッキングが多発しているwarningもあがっていたため、DB観点での調査を実施しました。調査は以前紹介した障害調査フローに従って実施したので、併せてご覧ください。 techblog.zozo.com また、調査に使用するログは以下の仕組みを使って収集していますので、よろしければこちらも合わせてご覧ください。 techblog.zozo.com 調査内容 ヘッドブロッカーの特定 ブロッキングが多発していることは事前に分かっていたため、拡張イベントおよびDMVのダンプテーブルからブロッキング関連の情報を確認しました。 --拡張イベントのblocked_process_reportイベントを保存しているログテーブルに対してのクエリ select top 1000 * from xevent_dump with (nolock) where time_stamp between ' yyyy/mm/dd hh:mm ' and ' yyyy/mm/dd hh:mm ' order by time_stamp, is_headblocker desc その結果、大半が特定のObjectIDへのIXロック獲得を待っている状態であることが分かったため、このオブジェクトを解決すると以下のことが分かりました。 ブロッキングは「change_tracking_<object_id>」というテーブル起因で発生していた このテーブルは<object_id>に紐づくテーブル(以下、テーブルAと呼ぶ)に関して、変更履歴を保存していくサイドテーブルだった このテーブルはChange Trackingを有効化した各テーブルごとに1つずつ作成される Change Trackingのサイドテーブルに対してロックを獲得し、かつ自分自身は実行中である「ヘッドブロッカー」はsession_idが50より小さいシステムプロセスでした。拡張イベントに該当プロセスのexecutionStackが残っていたので、解決を試みました。 select * from sys.dm_exec_sql_text(0x0200000xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx) select * from sys.dm_exec_sql_text(0x0400ff7xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx) select * from sys.dm_exec_sql_text(0x0300ff7xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx) その結果、ヘッドブロッカーのシステムプロセスでは、「sp_changetracking_remove_tran」というシステムストアドプロシージャを実行していたことが分かりました。定義は以下のようになっています。 CREATE PROCEDURE sys.sp_changetracking_remove_tran ( @objid INT ,@csn BIGINT ,@batch_size INT ,@stat_value BIGINT OUTPUT ) AS BEGIN SET NOCOUNT ON DECLARE @stmt NVARCHAR( 1000 ) SELECT @stat_value = 0 IF object_name(@objid) IS NOT NULL AND @csn IS NOT NULL BEGIN IF @csn IS NOT NULL BEGIN SELECT @stmt = N ' delete top(@batch_size) from sys. ' + quotename(object_name(@objid)) + ' where sys_change_xdes_id in (select xdes_id from sys.syscommittab ssct where ssct.commit_ts <= @csn) ' EXEC sp_executesql @stmt = @stmt ,@params = N ' @csn bigint, @batch_size int ' ,@csn = @csn ,@batch_size = @batch_size SELECT @stat_value = @@rowcount END END END パラメータ「@batch_size」で削除レコード数を指定してChange Trackingのサイドテーブルを削除しているようです。ブロックされているプロセスはテーブルへのIXロックの獲得待ちとなっていましたので、テーブル全体にロックをかけている、つまりロックエスカレーションが発生していた可能性が考えられます。ロックエスカレーションは、 sys.dm_db_index_operational_stats の「index_lock_promotion_count」カラムで発生回数を確認できます。ロギングの仕組みにより1分ごとにこのテーブルのレコードを保存しているため「index_lock_promotion_count」の変化を確認しました。その結果、エラー多発のタイミングでロックエスカレーションが多発していることが確認できました。 そのため、エラー多発の原因としては、Change Trackingに関するサイドテーブルを自動クリーンアップするバックグラウンドプロセスが、テーブルAのサイドテーブルに対して排他ロックを長時間獲得していたため、と結論づけました。 Change Trackingについて SQL ServerのChange Trackingは、テーブルの中で変更があった行を追跡できる機能です。テーブル単位で設定を有効化・無効化できます。この機能を有効化すると、以下の2種類のサイドテーブルにデータが溜まっていきます。どちらのサイドテーブルも、設定したデータ保持期間を過ぎると自動クリーンアッププロセスによってデータが削除されます。 sys.syscommittab 1DBあたり1つ作成されます。Change Trackingを設定しているテーブルに更新があった際、コミット日時やトランザクションの内部IDなどがINSERTされます。内部テーブルのためDACでないとSELECTできませんが、sys.dm_tran_commit_tableでラップしてあるので、そこからSELECT可能です。1トランザクションごとに1レコードINSERTされます。 sys.change_tracking_<object_id> Change Trackingを有効化したテーブルごとに、対応するテーブルが作成されます。例えば、テーブルAのobject_idが「123456」なら、作成されるサイドテーブルは「sys.change_tracking_123456」という名前になります。元テーブルの更新時は、サイドテーブルに対しても変更データの投入が同期的に発生し、このタイミングでサイドテーブルに対してIXロック等のロックが獲得されます。そのため、クリーンアップ処理でサイドテーブルに対してロックエスカレーションが発生すると、ユーザープロセスが元テーブルのデータを更新しようとしてもサイドテーブルへのINSERTがブロックされます。それに伴い、元テーブルの更新処理もブロックされることになります。同じくDACでないと直接はSELECTできませんが、changetable()を経由してアクセスできます。 SELECT top ( 10 ) * FROM CHANGETABLE(CHANGES <table_name>, 0 ) AS CT 対応策の検討と実施 まず、ロックエスカレーションをテーブル単位で無効化できないか確認しました。ユーザーテーブルであればテーブル単位でロックエスカレーションの発生有無を制御可能です。今回問題となったテーブルに対してロックエスカレーションを無効化するクエリは以下の通りです。 ALTER TABLE sys.change_tracking_<object_id> SET LOCK_ESCALATION = DISABLE しかし、システムテーブルのため以下のようなエラーが発生して設定を変更できませんでした。 そのため、インスタンスレベルのロックエスカレーション無効化を検討しました。インスタンスレベルでの無効化のためには、2種類の トレースフラグ が用意されています。 引用元は こちら メモリ負荷に基づくロックエスカレーションまで無効化するのは危険と判断し、トレースフラグ1224の設定に向けて現状のロックエスカレーション発生状況を確認しました。パフォーマンスモニタの「SQL Server:Access Methods\Table Lock Escalations/sec」で直近24時間のロックエスカレーション発生回数の推移を確認しましたが、ほとんど起きていませんでした。そのため、インスタンスレベルでロックエスカレーションを無効化しても影響は小さいと判断し、下記のクエリでトレースフラグを有効化しました。 DBCC TRACEON( 1224 , -1 ) 稼働中のシステムについては、本対応によってトレースフラグを有効化できますが、再起動時は無効になります。したがって、SQL Server構成マネージャーによる再起動時のトレースフラグの自動反映の設定も合わせて行っています。この対応によって、同様の原因によるエラー多発の抑制を期待しました。しかし、後日ブロッキングによるタイムアウトエラー多発が再発してしまい、改めて調査を実施しました。 再調査 トレースフラグ1224を有効化したことで、テーブルAのサイドテーブルに対するロックエスカレーションは発生していませんでした。しかし、同様のテーブルにページ単位で長時間のブロッキング発生が確認できました。しばらくは問題が起きていなかったため、実行プランの変化とブロッキング多発の関係性を疑いました。そこで、過去1週間のsys.dm_exec_query_statsから実行プランの変化を確認した結果、以下のことが分かりました。 クリーンアップ用のシステムストアドプロシージャは、「大半が並列処理で実行されるプラン」と「常にmaxdop=1で処理されるプラン」の2種類が存在していた エラー多発時は「大半が並列処理で実行されるプラン」がmaxdop=1で実行されており、スロークエリ化していた 「大半が並列処理で実行されるプラン」の方が、平均の実行時間は短い傾向にあるが、実行時間の最大値が顕著に長くなってしまう場合がありエラー多発につながっていた 以上の結果から、「常にmaxdop=1で処理されるプラン」に固定化してしまえば良いと考え、プランガイド設定の実現可能性について調査しました。その結果、システムストアドプロシージャに対してもプランガイドは設定可能であると分かりました。しかし、「何故実行プランが揺れるのか」可能な限り調査しておいた方が良いと考え、クエリを分析しました。該当のステートメントは以下の通りです。 ( @csn BIGINT ,@batch_size INT ) DELETE TOP (@batch_size) FROM sys.[change_tracking_<object_id>] WHERE sys_change_xdes_id IN ( SELECT xdes_id FROM sys.syscommittab ssct WHERE ssct.commit_ts <= @csn ) このクエリに対して以下のように考察しました。 sys_change_xdes_idはクラスタ化インデックスの第1列目のキーである したがって、sys.syscommittabのサブクエリの行数が十分に少なければnested loopによる結合が採用されmaxdop=1で実行されやすいはず しかし、サブクエリの行数が多すぎるとオプティマイザが判断してhash matchによる結合を並列で実行することがある模様 sys.syscommittabには「commit_ts」「xdes_id」の順番でインデックスが作成されており、サブクエリもindex seekで問題なく処理できそう しかし、基数推定で大量のレコードを返すと推定した場合にフルスキャンが選ばれている模様 以上の考察から、Change Trackingのサイドテーブルの行数がどの程度あるのか、保持期限過ぎのレコードは削除されているのか気になり確認しました。 select min (commit_time), max (commit_time), count (*) from sys.syscommittab with (nolock) where xdes_id in ( select sys_change_xdes_id from sys.[change_tracking_<object_id>] with (nolock)) 上記クエリを実行した結果、データの保持期間は1日で設定しているものの、2か月前のデータまで残っておりレコード数は億単位になることが判明しました。原因としては、大量にデータ変更が行われている環境では、自動クリーンアップによるデータ削除が追い付かずに保持期間を超えたデータが保持されている等が考えられます。レコード数を本来のあるべき姿である1日分にまで削減できれば、基数推定でも自然と少ないレコード数を予測してくれそうです。その結果、index seek+nested loopな「常にmaxdop=1で処理されるプラン」に安定するだろうと考えました。 sys.syscommittabの手動クリーンアップ sys.syscommittabの手動削除には「sys.sp_flush_commit_table_on_demand」というシステムストアドプロシージャを使用しました。 こちら の記事で言及されているように、タイミングによっては削除対象のレコードが存在するのに1件も削除できない場合がありました。対応策としては、記事中に記載されている通り「何度もリトライ」するために以下クエリを実行しました。 set nocount on set lock_timeout 1000 SET DEADLOCK_PRIORITY LOW declare @ rows bigint select top ( 1 ) @ rows = rows from sys.partitions with (nolock) where object_id = object_id( ' sys.syscommittab ' ) and index_id = 1 while (@ rows > 100000000 ) begin exec sys.sp_flush_commit_table_on_demand 1000000 waitfor delay ' 00:00:01 ' select top ( 1 ) @ rows = rows from sys.partitions with (nolock) where object_id = object_id( ' sys.syscommittab ' ) and index_id = 1 end こちらの方法で、サイドテーブルのレコードを大量に削除できましたが、削除すべきレコードを全て削除できませんでした。原因としては、各テーブルに対応するサイドテーブル「sys.change_tracking_<object_id>」に紐づくコミット日時の最小値に紐づくデータまでしか、sys.syscommittabの削除ができないようです。したがって、sys.change_tracking_<object_id>も手動で削除すべきということが分かりました。自動クリーンアッププロセスにおいても、sys.syscommittabのDELETEは動いているものの、where句で指定される最小コミット日時が更新されないため1件も削除できない事態に陥っていました。一方で、sys.change_tracking_<object_id>はレコードの削除自体は少しずつ行えているものの、大量のデータ更新が行われたことで期限切れデータが溜まり続けているようでした。 sys.change_tracking_<object_id>の手動削除は こちら で紹介されている「sp_flush_CT_internal_table_on_demand」を使うことで実現できるようです。しかし、バージョンやSPで一定の条件があり、当時の環境ではこのストアドプロシージャは使用できないことが分かりました。 Change Trackingの全サイドテーブルの手動クリーンアップ そこで、Microsoftのサポートサービスを利用して解決策を問い合わせたところ、Change Trackingの全サイドテーブルを手動削除するためのストアドプロシージャを共有いただきました。実装のイメージとしては、 こちらの記事 で紹介されているプロシージャが参考になると思います。実行時は以下のようにパラメータを指定して実行します。 EXEC sp_ManualChangeTrackingMetaDataCleanupProc @NoOfRowsToDeletePerIteration = 10000 , --1イテレーションあたりの削除対象行数 @NoOfIterations = 1000 ; --繰り返し回数 注意点としては、SSMSで実行する場合は「ツール」「オプション」から「実行後に結果を破棄する」にチェックいれておかないと、結果が多すぎると途中でエラーになることがあります。 このストアドプロシージャを使い、削除可能なサイドテーブルのレコードを手動で全て削除しました。そのあと、以下の手順で恒久対策を完了としました。 sp_ManualChangeTrackingMetaDataCleanupProcを使って定期的にサイドテーブルを一定数削除するジョブを作成 削除レコード数が不十分で期限切れデータが溜まり続ける予兆を検知するための検知ジョブを別途作成 sp_ManualChangeTrackingMetaDataCleanupProcの実行にはDAC接続が必須となっています。DAC接続をSQL Serverエージェントジョブで実現するには、例えばジョブの種類を「オペレーティングシステム(CmdExec)」に指定して以下のように「-A」オプションを付ける方法が考えられます。 sqlcmd.exe -S "xxx" -A -E -d xxx -Q "EXEC sp_ManualChangeTrackingMetaDataCleanupProc @NoOfRowsToDeletePerIteration = 10000, @NoOfIterations = 1000" また、検知ジョブのためのクエリは以下のようなイメージです。 declare @diff_days int select @diff_days = datediff(day, min (commit_time), max (commit_time)) from sys.syscommittab with (nolock) if @diff_days > 3 --この場合の閾値は3日 begin --msdb.dbo.sp_send_dbmailなどで通知 end まとめ 本記事では、SQL ServerのChange Trackingを有効にした環境で発生した問題と、その原因調査から対応策の実施までの流れを紹介しました。期限切れデータが溜まり続けることで、自動クリーンアップ処理が遅延しやすくなり、ブロッキングの多発につながることがあります。ロックエスカレーションを無効化するにはインスタンスレベルで実施する必要がありますが、無効化したとしてもブロッキングの発生を抑制できないケースに遭遇しました。自動クリーンアップ処理とは別に、自前で作成したクリーンアップ処理をジョブ化して定期実行することでサイドテーブルのレコード数の不要な増加を抑制できます。 本対応実施により、自動クリーンアップ処理の中でブロッキングの原因となっていたステートメントの実行速度が大幅に改善されました。以前は最大で60秒かかることもありましたが、今では直近1週間で最大でも1.5秒で完了するようになっています。同様の事象で困っている方の参考になれば幸いです。 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは。生産プラットフォーム開発部の中嶋です。生産プラットフォーム開発部はアパレル生産のDXを進めている部門です。具体的には服作りのIT化を含めたアパレル生産の効率化の促進と「生産支援」のシステムを主にGoで開発しています。今回はその運用の中でGoプログラムの実行時間をどのように短縮したのかを紹介します。 目次 目次 学べること・解決できること 背景 エラー発生 調査・対応 インスタンスの変更 原因 実装アプローチの見直し ゴルーチンを使ったタイムアウト処理 サンプルコード チャネルのクローズについて Goのメモリマネジメントについて スタックとヒープ ゴルーチンとメモリについて ヒープについて 問題の仮説 どのように解決したか 実装イメージ 利用したパッケージ サンプルコード 結果 まとめ 最後に 参考リンク 学べること・解決できること Goのメモリエラーに対するアプローチ例 Go視点からみるメモリの基本的な知識 チャネルの基本について ゴルーチンの具体的な実装例 時間がかかる重い処理の分析とGoを用いた最適化 背景 生産プラットフォームのバックエンドシステムは、複数のクラウドや自社・他社システムと連携をしながら製品に関する生産データを収集・デジタル化しています。例えば生産工程の進捗や検品データを収集してシステムに定期的に取り込みを行うなどです。 主にデータ連携はdigdagを利用し定期的に行っています。タスク自体はGoで開発し、各連携タスクは30分以内で終了することをシステム要件としています。 エラー発生 その連携タスクの1つが30分で完了しなくなり、タイムアウト時間を60分、さらに120分まで延長して問題を先送りしていました。それがある日を境に以下のようなエラーが発生しはじめます。いわゆるOOM(out of memory)です。 docker: Error response from daemon: failed to create shim: OCI runtime create failed: container_linux.go:380: starting container process caused: process_linux.go:545: container init caused: Running hook #0:: error running hook: exit status 2, stdout: , stderr: fatal error: out of memory allocating heap arena map 調査・対応 まずはSREチームとインフラ面での調査・対応を試みました。 エラーの発生するタイミングのプロセス数が一気に上がっており、その影響で処理が追いつかなくなっているようでした。エラーが発生した当日に運用作業で新たにデータ登録作業がありました。そのデータは該当タスクの対象母数となるものです。 エラーの一因としてUbuntu18で起動時のメモリ消費量が増加している影響がありそうでした。 Increase in memory consumption after updating Ubuntu to 18.04LTS インスタンスの変更 リソース不足が原因であると仮説を立て、冗長化と世代を上げる対応でスケールアップを試みました。 処理自体が動くようdigdagエージェントのインスタンス数のスケールアップを行いました。問題発生前まではインスタンスが1だったのを最大4まで増加させました。一時的にエラーは止まりますが、複数タスクが起動する時間帯によっては同じエラーが発生していました。 今回問題が発生したインスタンスタイプは t2.medium です。入れ替えたインスタンスは t3.medium 、そして m5.large とスケールアップを試みました。下図にあるように t2.medium と t3.medium はOSのメモリサイズは同値のため効果はありませんでした。 タイプ メモリ ネットワーク帯域幅 考察 t2.medium 4GB 低~中 このインスタンスで問題発生 t3.medium 4GB 最大5 このスケールアップでは効果なし m5.large 8GB 最大10 ここで安定したが処理時間は1時間以上かかる m5.large へインスタンスタイプを変えて以降は確かにタスク終了までの所要時間は120分以内で収まるようになりましたが、システム要件の30分以内で処理が終了することは達成できていません。大幅に増加したOSメモリをタスク処理が効率良く使いこなしているようには思えませんでした。 原因 最初のきっかけはdigdagのOSイメージが古くなったことと、タスク負荷が増加した影響でDockerが起動しなくなりました。それを解消するためにOSをアップデートしました。その直後にDockerの以下のエラーが頻発します。いずれにしてもメモリ不足が原因であることは間違いなさそうでした。 docker: Error response from daemon: failed to create shim: OCI runtime create failed タスク処理の母数が増える度にスケールアップするというのは非現実的です。インフラ面ではこれ以上できることがないように思えました。ということでソフト面(実装・プログラム)の見直しが必要になりました。 実装アプローチの見直し 問題が発生したdigdagタスクはGoのゴルーチン(goroutine)で実装しています。 ゴルーチンとはGoのランタイムによって管理される 軽量な並行処理スレッド です。通常のコルーチン(co-routine)とは、異なり開発者が処理の操作・制御はできません。その代わりスレッドやメモリアクセスの管理など複雑な作業はGoランタイムが管理します。 *ゴルーチンについては書籍など多く出版されているので こちらの書籍 などを参考にしてください。 ゴルーチンを使ったタイムアウト処理 実装ではゴルーチンとチャネル(channel)を使用しています。その目的としては並行処理というより タイムアウト を実現するためです。下図に概要を示しました。チャネルは一度アクティブにすると受信が来るまで待ち続けるものです。その特性を利用してタイムアウトを実現しています。 サンプルコード 下記の実装は問題が発生したプログラムとほぼ同じサンプルです。時間内に結果が返ってこなかったらタイムアウト処理に入りプログラムが終了します。並行実行はせず、1つのスレッドで処理を完結させる実装です。 package main import ( "fmt" "time" ) func main() { // タイムアウトを5秒に設定 ctx := context.Background() // WithTimeoutメソッドを使ってタイムアウトコンテクストを作ります。 ctx, cancel := context.WithTimeout(ctx, 5 * time.Second) defer cancel() // string型のデータを受信するチャネル作成 c := make ( chan string ) go func () { // 時間がかかる処理をここに記述(サンプルなので3秒処理のダミー) // timeoutの再現は5秒以上にするとタイムアウトにはいります。サンプルは3秒で終わる処理なのでタイムアウトにはなりません。 longTask( 3 ) // 処理終了後にチャネルに文字列を送信 c <- "タスク正常終了!" }() select { case res := <-c: fmt.Println(res) case <-ctx.Done(): fmt.Println( "タイムアウトしました" ) os.Exit( 1 ) } } func longTask(costTimeSecond int ) { time.Sleep(time.Duration(costTimeSecond) * time.Second) } playground チャネルのクローズについて ログの out of memory allocating heap arena map を見て「メモリリークではないか、リークするとしたらチャネルのクローズ漏れがあるかも」と思いました。実際に上の実装ではチャネルのクローズ処理は記述されていません。 それについて調べてみると以下のようなドキュメントを見つけました。 Goチャネルを使用する一般的な原則の1つは、 受信側からチャネルを閉じないこと、およびチャネルに複数の同時送信者がある場合はチャネルを閉じないことです。 チャネルはファイルとは異なります。通常、それらを閉じる必要はありません。ループを終了するなど、受信者にこれ以上値が来ないことを通知する必要がある場合にのみ、閉じる必要があります。 参考: A Tour Of Go - Range and Close クローズを必要とするケースは複数のゴルーチン(goroutine)を利用しているケースなどです。チャネルの送受信が終了したことを別のゴルーチンに示すため、チャネルのクローズを呼び出します。 実装ではゴルーチン1つなのでクローズ処理は不要です。念の為チャネルのクローズ処理を追加しましたが改善は見られませんでした。 ではどこに問題があるのでしょうか。ログの allocating heap arena map というメッセージは明らかにメモリ割り当てができていないと示しています。そこでGoのメモリについて少し深堀りしてみました。 Goのメモリマネジメントについて Goの実装でメモリ管理をコーディングでは通常意識する必要はありません。なぜならGoのランタイムにそれを任せて実装者はコーディングだけに集中できるからです。しかしメモリについて基本的なポイントは押さえておくことが今回は原因の特定に役立つと考えました。 スタックとヒープ Goはスクリプト実行時にメモリを確保する領域として スタック と ヒープ の2つがあります。 参考: How do I know whether a variable is allocated on the heap or the stack? スタック:ローカル変数、引数、返り値を含む全ての静的変数は、型に限らず直接スタックへ保持される。 ヒープ:全ての動的型データはヒープ上に作成される。プロセスが完了すると、ヒープ上にあるオブジェクトはスタックから参照されるポインタがなくなり、参照されないオブジェクトになる。 ゴルーチンとメモリについて ゴルーチンにはメモリ領域として1つのスタックが存在します。2KBの最小スタックサイズから始まり、不足するリスクなしに、必要に応じて拡大・縮小します。スタックはOSによって自動的に管理されています。 一方、ヒープはOSによって管理されていません。ヒープは動的なデータを保持しているため大きなメモリ空間です。そのためメモリ領域は指数関数的に成長する可能性がありメモリ不足に陥る可能性が高い箇所です。また、時間の経過とともに断片化され、アプリケーションの動作が遅くなることもあります。 ヒープはガベージコレクションによって管理されます。参照されていないオブジェクトが使用していたメモリアドレスを解放して、新しいオブジェクトを作成するためのメモリスペースを確保します。 ヒープについて ログに出力されている アリーナ(arena) とは、メモリ領域とメモリ領域の管理をひとつのまとまりとした単位です(下図)。 ユーザからのメモリ確保要求に対してどこが使用可能なのかを管理しています。動的なデータの要求が大きくなるプログラムは、当然必要な領域を確保するためにこのアリーナへの確保要求ボリュームは大きくなることがわかります。 問題の仮説 今回問題が2つありました。 処理時間が非常に長くなる OOMの発生で処理が進まない、落ちる 発生したOOM(out of memory)は、プログラムで必要となる動的データを格納するヒープ部分で割り当てできないことが原因であることはログからわかります。具体的にはプログラム中で使用しているSDK仕様と実装方法(使用方法)がマッチしていなかったことが原因だと思いました。なぜならループする母数が少ない場合はなんの問題も出ないのですが、ループする母数が増えることでメモリ確保量が指数関数的に増加します。 では指数関数的に増加するメモリ要求に対してどのように対応すればよいのかが次の課題になりました。 メモリを調査してみて1つの仮説にたどり着きました。 1つのタスクが必要とするメモリ量はその中で行われる処理の必要メモリ量の総量 各処理が必要とするメモリ領域は親であるタスク中でヒープにとどまり続けてしまっている 1ゴルーチンに対するスタックは1つであるため、ヒープとのやり取り増加でGC効率が低下する 結果GC効率が悪くメモリ解放されにくくなりメモリ空間を逼迫させる メモリ確保の要求量が増加することで同時に処理時間が長くなっている どのように解決したか 改修量を極力少なくしたかったので、既存コード部分を生かしながら実装し直しました。 時間のかかるタスク(多くのメモリが必要な重い処理)を複数サブタスクに分けました。それらを並行実行させることにより処理単位で必要となる確保メモリ量を小さくかつ時間短縮を同時に実現できると考えました。 それを実現するのに改めてGoのゴルーチン( goroutine )が利用できると考えました。軽量な並行処理スレッドであることと、メモリアクセスの管理など複雑な作業はGoランタイムが管理してくれるからです。必要とする総メモリ量は変わらないですが、ヒープ領域のGC効率は格段に向上すると考えました。1処理を分割することで、全体の処理回転率が上がるイメージです。 実装イメージ 下図のようにゴルーチンから更に枝分かれしたゴルーチンが重い処理を手分けするイメージです。 利用したパッケージ パッケージは golang.org/x/sync/errgroup と golang.org/x/sync/semaphore を使いました。 errgroup は Go メソッドでサブタスク(ゴルーチン)を簡易に実行できます。またサブタスクの1つでエラー発生する場合、1つのタスク処理としてキャンセルできます。今回は1タスクを複数に分割したので、サブタスクでのエラーはタスク全体のエラーとして処理したいため都合がよかったです。 semaphore はゴルーチンの同時実行数を制御するために利用しました。並行処理を制限なしに実行してパフォーマンスが下がることを防ぐためです。 サンプルコード 実際の処理は以下のようなコードで実現しています。 今まで時間のかかっていた処理を1つのメソッドとしてまとめて、サブタスク毎にゴルーチンとして並行実行するように修正しました。 *サンプルはエラーハンドリング等、一部内容を省略しています。 package main import ( "context" "fmt" "golang.org/x/sync/errgroup" "golang.org/x/sync/semaphore" ) type person struct { name string order, age int } var persons = []person{ {name: "A" , order: 1 , age: 24 }, {name: "B" , order: 2 , age: 29 }, {name: "C" , order: 3 , age: 20 }, {name: "D" , order: 4 , age: 21 }, {name: "E" , order: 5 , age: 29 }, {name: "F" , order: 6 , age: 25 }, {name: "G" , order: 7 , age: 45 }, {name: "H" , order: 8 , age: 19 }, {name: "I" , order: 9 , age: 36 }, {name: "J" , order: 10 , age: 29 }, } // goroutine safe func execute() error { ctx := context.Background() // 並列処理を開始 eg, ctx := errgroup.WithContext(context.Background()) // 同時実行できるゴルーチンを設定する。この場合は3個まで同時に並行処理走らせます // 4個目からは実行待ちにはいる。 sem := semaphore.NewWeighted( 3 ) for _, aPerson := range persons { // 無名関数にする意図は受け取るデータが変わらないようにするための実装です。 // そうしないと通常処理前にaPersonが入れ替わってしまいます。 func (p person) { // Goメソッドでgoroutine化します eg.Go( func () error { if err := sem.Acquire(ctx, 1 ); err != nil { // semaphore取得エラー return err } defer sem.Release( 1 ) select { case <-ctx.Done(): // エラーが発生した場合は後続処理をキャンセルして終了する println ( "cancel" ) return nil default : // 通常時の処理 return longProcess(p) } }) }(aPerson) } // errgroupは全ての処理が終わるまたはエラーが返るまで 待ち合わせします if err := eg.Wait(); err != nil { fmt.Println(err) } return nil } // 今まで時間のかかっていた重い処理は変更せずメソッド化した func longProcess(p person) error { // 簡易的に出力しているのみですが、実際は重い処理です fmt.Printf( "名前:%s 番号:%d 年齢:%d \n " , p.name, p.order, p.age) return nil } // サンプルタスク func main() { fmt.Println( "Start" ) execute() fmt.Println( "End" ) } 上のサンプルを動かすとその都度出力される順番が変わります。非同期で処理されていることが体感できると思います。 Start 名前:J 番号:10 年齢:29 名前:A 番号:1 年齢:24 名前:B 番号:2 年齢:29 名前:F 番号:6 年齢:25 名前:D 番号:4 年齢:21 名前:C 番号:3 年齢:20 名前:E 番号:5 年齢:29 名前:I 番号:9 年齢:36 名前:G 番号:7 年齢:45 名前:H 番号:8 年齢:19 End Playgroundで確認する 結果 タスクをサブタスクに分割し、再実装してタスクを実行した結果を下図に示します。 同時スレッド数を30に設定しました。2時間かかっても終わらなかった処理が2分程度まで短縮できました! Goのゴルーチンの実力を改めて実感できました。 まとめ 処理時間のかかるタスクを改修・再実装し、大幅に時間短縮できたポイントと発見を振り返ります。 スタックには静的データ、ヒープには動的データが格納される。スタックはGoが管理するが、ヒープはOSが管理している。 ゴルーチン毎に1つのスタックが用意されている。 ゴルーチンの使い所の1つは重い処理を複数のサブタスク化できるような場合、ゴルーチンによってパフォーマンス改善の可能性がある。 semaphore は並列タスクで動作するゴルーチンの数を制限できる。 errgroup は1つの共通タスクのサブタスク間で動作するゴルーチンの同期、エラーの伝播、コンテキスト単位のキャンセルができる。 処理の実行順が結果に影響を与える場合は、 WaitGroup などを利用して処理順序を管理する考慮・実装が必要。 Goのチャネルのクローズの基本はデータ送信側が行う場合を除きクローズしなくても良い。GCが自動的にマークして破棄してくれる。 Goの実装はメモリを意識してプログラミングする必要はないが、メモリ管理の概要を理解していれば問題解決に最適な選択ができることを学びました。例えば今回のようにゴルーチンを実装することによって劇的にパフォーマンスが向上します。 最後に 今回は私達が運用しているシステムの改善の1つを紹介しました。 生産プラットフォーム開発部の今までの活動について興味のある方はこちらの記事もぜひご覧ください。 techblog.zozo.com techblog.zozo.com techblog.zozo.com techblog.zozo.com techblog.zozo.com チームにはまだ様々な課題があります。その課題解決を一緒に行い、生産支援のプラットフォームを作り上げてくれる方を募集しています。 ご興味のある方は、 こちら から是非ご応募ください。 corp.zozo.com 参考リンク Understanding Allocations: the Stack and the Heap - GopherCon SG 2019 Understanding Allocations in Go Run strikingly fast parallel file searches in Go with sync.ErrGroup golang.org/x/sync/errgroup golang.org/x/sync/semaphore
アバター
こんにちは。ZOZOTOWN開発本部 バックエンド1ブロックの山本です。普段はZOZOTOWNのバックエンドやマイクロサービスAPIなどの開発に携わっています。 ZOZOTOWNは膨大なデータを有しており、テーブルやカラムの数も膨大です。しかし、ER図やテーブル定義に関するドキュメントは手動で更新されていたため情報遅れが生じ、信頼性が低いものとなっていました。 本記事ではその問題を解決するための取り組み、「データカタログ作成プロジェクト」について紹介します。 目次 目次 データカタログとは Dataedo dbdocs 背景・目的 課題の解決手段 内製したソフトウェアのアーキテクチャと基本機能 ER図作成UI 利用実績に基づく仮想外部キーの作成、カーディナリティの推定 リレーションシップを持っているテーブルペアの洗い出し 1:N or 1:1の推定 0以上か1以上の推定 リレーションシップ、カーディナリティのUI表現 導入効果 おわりに データカタログとは はじめに、データカタログという言葉について説明します。 データカタログとは、データベースのテーブル定義やカラム定義、メタ情報などをまとめた、辞書のようなものをさす言葉です。 データベースの情報はアナリストやビジネスサイドのチームなど開発者以外からも需要がありますが、情報が複雑なため人力で資料を整え続けるのは困難です。 そこで、ER図の出力やメタ情報の管理などを自動化してくれる製品やツールを活用することで資料を効率よくまとめることができます。 参考として、データカタログ関連の既存製品を2つ紹介します。 Dataedo https://dataedo.fbpp.jp DataedoはポーランドのDataedo社によって開発されたデータカタログツールであり、データベース関連の資料管理を効率化、資料をもとにER図の出力や情報の分類などを行う製品です。 日本ではFBP Partners社が公認のセールスパートナーとして、2020年7月よりDataedoのサービス提供を行っています。 以下の機能を持っており、情報整理の工数を大幅に削減できることが特徴です。 データベースからメタデータを抽出 テーブル定義書の描画 ER図の描画 dbdocs https://dbdocs.io こちらは2022年5月現在まだベータ版の製品です。 DBMLというマークアップ言語で記述されたデータベース定義を読み込み、テーブル定義書やER図を生成します。 メタ抽出機能は付いていないためDBMLファイルを利用者側で用意する必要がありますが、利用者側でDBMLファイルの出力を自動化できれば優れたソリューションとなるでしょう。 ER図やリレーションシップの表現がとてもわかり易く、UIが高品質であることが特徴です。 背景・目的 「データカタログ作成プロジェクト」を開始した背景、目的について話します。 私は2020年4月に新卒で入社し、同じ年の5月にバックエンドブロック配属となりました。 当時のZOZOTOWNでは、データベースの資料を以下のように管理していました。 テーブル定義書の管理方法 テーブルリスト、カラムリストを手動で追加・削除 説明文やメタ情報は任意で手動入力 ER図の管理方法 ER図は画像出力されたものを共有(文字列検索などはできない) テーブル定義書の情報とは独立して管理 また、ZOZOTOWNに関わる多くの人がデータベースに関わりデータベースの定義を知る必要がありますが、資料が整備されておらず以下のような問題も発生していました。 ドキュメント・ER図が手動更新されているため 情報遅れが発生していた 管理・更新のための工数発生していた ER図が画像で共有されていたため ER図内で文字列検索ができなかった 更新が面倒だった(実際に4年以上放置されていた) その結果以下のような状況が生まれ、自分のチームにも多数の問い合わせが発生し、それに答えるための工数が多く発生していました。 情報が更新されなくなる 調べたくても資料が整っておらず、資料の信頼性が低い 知るためには、知っていると思われる人・部署に問い合わせるしか無い 質問のたび両者に工数が発生している そういった工数を削減するとともに、資料を整備することで開発者体験を向上したいと思いデータカタログ作成プロジェクトをスタートしました。 課題の解決手段 既存製品がカバーしている点は以下でした。 メタデータなど定義の抽出 定義のER図化 リレーションシップの可視化(外部キー制約が貼られているものと、手動で設定したリレーションシップの可視化) しかしZOZOTOWNでは、既存製品ではカバーできない以下のような需要がありました。 「外部キー制約は貼られていないけどリレーションシップを持っているものとして運用されているテーブル」が、手動で整理するのは難しいほどたくさんある データベース1つあたりのテーブル数が膨大なため、ER図は分割する必要がある 他にも様々な需要を考慮すると既存製品ではカバーできないと判断し、内製することにしました。 内製したソフトウェアのアーキテクチャと基本機能 大きく分けて、以下2つのアプリケーションを作成しました。 Webアプリケーション 使用技術 Vue.js, Ruby on Rails, MySQL 主要機能 DBから取り込んだ情報の保持 データカタログの閲覧 メタデータの追加・編集 ER図の作成・閲覧 バッチアプリケーション 使用技術 Python 主要機能 利用実績に基づくリレーションシップ・カーディナリティの推定 本番DBサーバーから情報取得 取り込んだ情報や推定した情報をWebアプリケーション側に定期送信 ▼アーキテクチャ図 ▼テーブル定義の閲覧ページ ▼ER図の閲覧ページ ※非公開情報には table_〇〇〇 、 column_〇〇〇 のようにマスクしてあります。実際には本番環境で利用されているテーブル名、カラム名が入っています。 仕様やロジックを一部紹介します。 ER図作成UI 気軽にER図を生成・共有できる画面を導入しました。 また、生成されたER図を一覧化し、気軽に共有や検索ができる機能を持っています。 利用実績に基づく仮想外部キーの作成、カーディナリティの推定 ZOZOTOWNでは、外部キー制約は貼られていないがリレーションシップを持っているものとして運用されているテーブルが多数存在しており、その関係を手動でまとめるのは困難でした。 そこで、本システムでは以下のような仕組みを用意して「仮想外部キー」を制定しました。 リレーションシップを持っているテーブルペアの洗い出し SQL Serverの sys.dm_exec_query_stats より、本番環境で実際に実行されたクエリを大量に入手。その中からJOIN句でつながっているテーブル名とカラム名を抜き出し、どちらかがPKであれば仮想外部キーの候補とする。 取得した候補は、続く推定作業で利用する。 1:N or 1:1の推定 上で取得した候補に対して以下のクエリを実行し、結果が1行なら1:Nとした。 ※ 例で示すクエリはSQL Server用の文法です。 -- @ColumnNameMain 解析対象のカラム名(1) -- @ColumnNameSub 解析対象のカラム名(2) -- @TableNameMain 解析対象のカラム(1)が属しているテーブル名 -- @TableNameSub 解析対象のカラム(2)が属しているテーブル名 -- 結果が1行であれば、 @TableNameMain : @TableNameSub = 1 : N SELECT DISTINCT TOP 1 @ColumnNameMain FROM @TableNameMain WITH (NOLOCK) WHERE EXISTS ( SELECT @ColumnNameSub FROM @TableNameSub WITH (NOLOCK) WHERE @ColumnNameMain = @ColumnNameSub GROUP BY @ColumnNameSub HAVING COUNT (*) > 1 ) AND @ColumnNameMain IS NOT NULL 0以上か1以上の推定 1で取得した候補に対して以下のクエリを実行し、結果が0行なら1以上、1行なら0以上とした。 ※ 例で示すクエリはSQL Server用の文法です。 -- @ColumnNameMain 解析対象のカラム名(1) -- @ColumnNameSub 解析対象のカラム名(2) -- @TableNameMain 解析対象のカラム(1)が属しているテーブル名 -- @TableNameSub 解析対象のカラム(2)が属しているテーブル名 -- 結果が0行であれば、 @TableNameMainに@TableNameSubは1個以上紐づく。 -- 結果が1行であれば、 @TableNameMainに@TableNameSubは0個以上紐づく。 SELECT DISTINCT TOP 1 @ColumnNameMain FROM @TableNameMain WHERE NOT EXISTS ( SELECT * FROM @TableNameSub WHERE @ColumnNameMain = @ColumnNameSub ) リレーションシップ、カーディナリティのUI表現 このようなルールで自動推定されたリレーションシップやカーディナリティは、以下のようにER図やカラム定義から確認できるようにしました。 ▼カラムの詳細画面 ▼ER図閲覧画面 導入効果 情報の自動更新と自動推定により、発生していた課題が解決されました。 情報遅れが発生していた → 常に最新の情報へアクセス可能となった。 管理・更新のための工数発生していた → 管理・更新のための工数はほぼ0となった。 また、ターゲットユーザーの母数およそ400人に対し、UU数とPV数は以下のようになっています。 1日あたり 約40UU, 300~1000PV 1週間あたり 約80UU, 2000~4000PV 完成したデータカタログはテーブル定義の調査だけでなく、新入社員の研修など様々な場面で活用されており、活用の場面を増やすべく今も機能拡張中です。 おわりに 本記事ではデータカタログ作成プロジェクトの背景と実装内容について紹介しました。新鮮で信頼性の高いドキュメントが自動的に整う環境は、開発者体験の向上においても大きなメリットとなるかと思います。 今後運用と改善を重ねて、より働きやすい環境を整えていきたいと思います。 ZOZOでは一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com hrmos.co
アバター
こんにちは、ZOZO CTOブロックの @ikkou です。 ZOZOでは、5/23に ZOZO Tech Talk #7 - Android を開催しました。 zozotech-inc.connpass.com 本イベントは、これまで夕刻に開催してきたMeetupとは異なり、ランチタイムに開催する「ZOZO Tech Talk」シリーズです。ZOZO Tech Talkでは、ZOZOがこれまで取り組んできた事例を紹介していきます。 第7回はネイティブアプリ開発の中で、特にAndroidにフォーカスした内容を発表しました。 登壇内容 まとめ 弊社の社員2名が登壇しました。 GitHub Actionsを使用してGoogle Play Consoleに自動アップロード (ブランドソリューション開発本部 WEAR部 Androidブロック / 武永 芙侑香) 既存画面のJetpack Composeでの書き換え: FAANSでの事例紹介 (ブランドソリューション開発本部 FAANS部 / 堀江 亮介) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
こんにちは、ZOZO CTOブロックの @ikkou です。 ZOZOでは、5/16に ZOZO Tech Talk #6 - iOS を開催しました。 zozotech-inc.connpass.com 本イベントは、これまで夕刻に開催してきたMeetupとは異なり、ランチタイムに開催する「ZOZO Tech Talk」シリーズです。ZOZO Tech Talkでは、ZOZOがこれまで取り組んできた事例を紹介していきます。 第6回はネイティブアプリ開発の中で、特にiOSにフォーカスした内容を発表しました。 登壇内容 まとめ 弊社の社員2名が登壇しました。 Hapticをカスタマイズしてみよう (ZOZOTOWN開発本部 ZOZOTOWNアプリ部 / 遠藤 万里) Apple silicon導入のウラガワテックブログに盛り込めなかった話、公開します (ZOZOTOWN開発本部 ZOZOTOWNアプリ部 / 小松 悟) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
はじめに こんにちは、SRE部の秋田と伊藤です。普段はZOZOTOWNのオンプレミスとクラウドの運用・保守・構築に携わっています。 新春セールはZOZOTOWNの中でも最も力を入れているイベントの1つであり、セール開始直後は毎年最大級のアクセスやトラフィックが発生しています。この新春セールを無事に乗り越えるために2020年度から負荷試験を実施しています。負荷試験のシナリオでは機能ごとの試験ではなく、ユーザー導線に合わせてZOZOTOWNにセール同等のトラフィックを再現します。 本記事は、様々な変化をするZOZOTOWNにおける新春セールを乗り越えるための負荷試験を実施するまでにあった課題とその課題解決に向けた取り組みについてご紹介します。 はじめに 目的 負荷試験における課題 課題1:一気通貫で負荷試験することが困難 課題2:限られた試験時間で最も効果を得られる負荷試験の実施 課題3:2021年に新たにリリースされたマイクロサービスへの理解 課題4:過去の負荷試験で生じた課題 負荷試験の準備 負荷試験の実施に向けた準備 シナリオの準備 Splunkによる分析 負荷試験の実施環境の作成 Gatling Operatorの利用 1つのAZにNatGateway及びElasticIPを複数用意する AWSへの負荷試験の申請 負荷試験の実施結果 IP枯渇問題の検出と対策 効果的な箇所へのコンテンツカットによるパフォーマンス対策 2022年度新春セールの結果について まとめ 最後に 目的 負荷試験の目的は、ボトルネックとなりうる箇所の特定、新春セールに耐えうるインフラリソースを算出することです。また、新春セールで必要な準備やトラブルが発生してしまった際の迅速な連携・対処の練習も兼ねています。 負荷試験における課題 負荷試験を実施する上で、以下のような課題がありました。 一気通貫で負荷試験することが困難 限られた試験時間で最も効果を得られる負荷試験の実施 2021年に新たにリリースされたマイクロサービスへの理解 過去の負荷試験で生じた課題 課題1:一気通貫で負荷試験することが困難 ZOZOTOWNは、パブリッククラウドを活用したマイクロサービス化が着実に進む一方で、まだアーキテクチャの再設計に着手できていないシステムも多く存在します。例えばオンプレミス環境でもWebサーバーや基幹データベースなど多くの重要システムが稼働しています。すさまじい勢いで成長するZOZOTOWNのトラフィックに耐えうるインフラ構成をオンプレミス環境で実現するため、これらのシステムは幾度となくスケールアウト・スケールアップを繰り返してきました。オンプレミス環境で負荷試験のために本番同等のインフラ環境を用意するのはコスト的にも難しい現状があります。 課題2:限られた試験時間で最も効果を得られる負荷試験の実施 ZOZOTOWNではアクセス数以外にも日々の施策によってWebサーバやAPIサーバの負荷状況が大幅に変化します。新春セールでは毎年多くの施策が実施されるため、負荷試験のシナリオ以外にも高負荷の要因となる状況を再現する必要がありました。 課題3:2021年に新たにリリースされたマイクロサービスへの理解 2021年には以下のリプレイスを実施しました。 Home画面のリプレイス カート・決済機能のリプレイス セッションのオフロード オンプレミスサーバーのAmazon Elastic Compute Cloudへのリフト 「2021年ZOZO開発組織の進捗」でリプレイスの進捗についても紹介されています。 qiita.com 1年間で多くのマイクロサービスのリリースが行われました。多くはバックエンド機能のリプレイスです。これらはユーザー側から見たリクエスト先のURLは変えずに実現するケースが多いです。つまりバックエンド処理をモノリシックな構成から各APIを呼び出すマイクロサービス化を進めています。そのため、各マイクロサービスへ負荷が適切にかかるエンドポイントをリプレイス状況に合わせて精査、必要に応じて前年度利用した負荷試験のシナリオ改修が必要です。 課題4:過去の負荷試験で生じた課題 新春セールのような大規模な負荷を想定した場合、負荷をかける側がボトルネックとならないように負荷試験の環境を用意する必要があります。試験中にスムーズにスケールアウトを行うために2020年頃から 分散負荷試験 を採用するようになりました。 負荷試験の準備 前述した課題を解決するために負荷試験を実施するメンバーで様々な準備を1か月に渡って行いました。 負荷試験の実施に向けた準備 シナリオの準備 負荷試験の実施環境の作成 負荷試験の実施に向けた準備 課題1で挙げた問題は、すぐに解決できなかったため本番環境を利用して負荷試験を実施しました。本番環境では通常のユーザーのトラフィックを受けるため、いくつかのことを考慮して負荷試験を行う必要がありました。 ユーザーのトラフィックが少ない時間を選定 万が一の障害が起きた場合の対処準備 各部署への周知連絡 負荷試験の実施日と時間の選定は課題2で挙げた施策数が多い日を数日含めるように調整しました。負荷試験を実施する回数は4回で、施策数が多い日を2日間、シナリオ調整を含めた施策数の少ない2日間を選定しました。この施策数が多い日はビジネスサイドに相談した上で決定しています。 リモート環境でのコミュニケーション方法はGoogle MeetとSlackを活用しました。Google Meetは負荷試験の実施者の画面共有、各チームと会話するために利用します。Slackは負荷試験の実行前の簡易連絡や負荷試験の実施中の簡易メモなどで利用します。負荷試験の実施中の実行結果は記録用スプレッドシートを事前に共有して、なるべく負荷試験中に今何やっているかすぐにわかるように準備しました。 障害が起きた場合の対処に関しては、シナリオを迅速に停止できるよう準備をしていました。各チームにはメトリクスを常に監視してもらい何かエラーが増えた時点ですぐコミュニケーションをとるようにお願いしていました。また、周知に関しては各チームに関連部署へ連携をお願いしました。 シナリオの準備 シナリオ作成にはアクセスログを活用しました。過去のアクセスログからアクセス数や各URLごとのリクエスト数などの分析をし、なるべくユーザーの導線に近いものを再現できるようにします。ZOZOTOWNのWebサーバーのアクセスログの分析にはSplunkを用いています。 テックブログやQiitaでもTipsなどを紹介しているので興味のある方はご覧になってください。 techblog.zozo.com qiita.com Splunkによる分析 Splunkを用いてアクセスログから以下の情報を抽出し、シナリオ作成に役立てました。 総リクエスト数 各機能ごとのリクエスト割合 秒間・分間リクエスト数の平均値 各エンドポイントに対応するParameter毎の統計情報 また、課題3で挙げた各マイクロサービスへの負荷がかかるエンドポイントの調査にもSplunkを利用しています。 Splunk App for Stream を使ってWebサーバーやAPIサーバーから出ていくHTTP Requestを分析し、各マイクロサービスのエンドポイントに対しどのようなParameter、Bodyでリクエストを実行するか精査しました。不透明だった各マイクロサービスの呼び出しを正確に分析することで、シナリオの精度をあげられました。 以下はStreamを使ったSPLの実行例になります。 index=main host=XXX sourcetype="stream:http" site=XXX 負荷試験の実施環境の作成 本負荷試験は以下の構成で実施しました。 構築するにあたって考慮したポイントを紹介します。 Gatling Operatorの利用 課題4を回避するために開発されたのが Gatling Operator となります。 Gatling Operatorを利用する主なメリットは次の通りです。 一連の分散負荷試験のタスクが自動化された Gatling用Podに柔軟にノードリソースの配分ができるようになった 分散負荷試験がマニフェストで宣言的に定義できるようになった Gatling Operatorについては詳しく川崎・巣立のテックブログにて、分散負荷試験環境の必要性や詳細な利用方法など紹介しているのでご覧ください。 techblog.zozo.com 1つのAZにNatGateway及びElasticIPを複数用意する 2020年の負荷試験時に、負荷をかける側で発生した問題として、 Nat Gateway のErrorPortAllocationエラーが発生していました。 NatGateWayには次の制限があり、最大55,000の同時接続数を超えてしまうとErrorPortAllocationエラーが発生してしまいます。 A NAT gateway can support up to 55,000 simultaneous connections to each unique destination. This limit also applies if you create approximately 900 connections per second to a single destination (about 55,000 connections per minute). docs.aws.amazon.com 回避のためにはNatGatewayを複数台用意し、コネクションを分散させることが有効となります。 今回の負荷試験のおいては同様の事象を起こさないよう、十分な数のNatGatewayを準備し、拡張可能な構成で用意しました。 AWSへの負荷試験の申請 AWSが関連した負荷試験で秒間1Gbpsを越えるトラフィックが1分間以上、継続する場合には申請が必要です。 aws.amazon.com 申請を怠った場合には不正利用者として検出され、本番ワークロードに影響する可能性もあります。本試験は秒間1Gbpsを超えるトラフィック量を見込んでおり、 フォーム にて申請しました。 負荷試験の実施結果 関係各部署との協力体制のもとアクセスの少ない時間帯で負荷試験を実施させていただきました。負荷試験を通して新春セール前に2つの問題を見つけ、解決することに成功しました。また、社内開発OSSのGatling Operatorの大規模な利用時のユースケースを作ることできました。Gatling Operatorを利用することで限られた時間の中での負荷試験の実施の効率化は非常に喜ばしいものとなりました。 IP枯渇問題の検出と対策 ZOZOTOWNでは現行のシステム基盤のリプレイスとして Amazon Elastic Kubernetes Service を利用したマイクロサービス化が進められています。今回の負荷試験の実施において環境増強のためpodのスケールアウトをしている際にノードの割り当てが失敗し、podがpending状態から進まなくなる事象が発生しました。原因はAmazon VPC CNIのdaemonsetのaws-nodeのパラメータ WARM_ENI_TARGET にありました。パラメータが初期設定であったことで必要以上のIPアドレスがノードごとに確保されてしまっていました。 詳しくはアドベントカレンダーの記事をご覧ください。 qiita.com 試験において問題の検出及び対策ができたことで、セール本番時にオートスケールで増強しようとしても増強ができないといった予期せぬ事態を回避することにつながりました。 効果的な箇所へのコンテンツカットによるパフォーマンス対策 負荷試験でボトルネックが検出されたサービスに対してパフォーマンスチューニングが必要です。しかし、簡単に対応できるもののみとは限りません。例えば、ウェブページにおいて全てのユーザーが閲覧するコンテンツで同一のSQLが実行されてデータベースの負荷となっているのであれば、静的コンテンツへの置き換えやキャッシュ化などが考えられます。しかし「○○という商品を見た××さんに向けた情報」のようにユーザーや商品の組み合わせ毎に結果が異なるコンテンツに対してのキャッシュ化はあまり効果的ではなく、取得ロジックに手を入れる必要があります。 セールの開始が迫っている中での大掛かりな修正は工数面と品質面、どちらにおいてもリスクとなります。そのため対応困難な箇所に関してはセールを期日とした根本的な修正は行わず、セール中はその項目を表示しない、部分的なコンテンツカットを実施しました。 コンテンツカットはエンジニア的側面よりもビジネス的な側面が必要な対応となります。各方面のエンジニアを巻き込んでいることと、会社自体がBizDevOpsを掲げ、ビジネス部門とも距離が近く連携できました。結果として、ビジネス的なリスクは小さいがパフォーマンス影響が大きい箇所に対してのコンテンツカットを検討から実現まで円滑に進められました。 2022年度新春セールの結果について ZOZOTOWNの新春セールは0:00から始まり、トラフィックが徐々に増えていき最大トラフィックに到達します。2021年の新春セールでは初動でかなりのエラーが発生し、ユーザーはZOZOTOWNにアクセスしづらい状況が発生していました。 上記のグラフは、新春セール初動の2021年と2022年のエラー数を比較したグラフです。明らかに2022年はエラー数がかなり減っていることがわかります。この成果は負荷試験を通して、事前に問題が発生する箇所の解決、予測トラフィックを処理するために必要なインフラリソースを用意できたことが要因だと考えられます。 まとめ ZOZOTOWNの最大級のイベントである新春セールを乗り越えるための負荷試験に関する取り組みを紹介しました。負荷試験を実施したことによって新春セールを安心して迎えられました。コストやリソースの問題で本番と同等のサイジングの試験環境を用意できなかったという課題は残りましたが、更なるサイトの信頼性向上のため、より簡易的に試験を行える状態を目指し引き続き取り組んでいきます。 改めてこの場を借りて負荷試験に関わってくださったみなさまに感謝の言葉を述べさせていただければと思います。関係各部署のみなさま、新春セールの準備でお忙しい中、負荷試験にご協力いただきありがとうございました。 最後に ZOZOTOWNのシステムは現在リプレイス真っ只中です。秋田とフロントエンドリプレイスへ取り組んでいただける方、伊藤とカートリプレイスに取り組んでいただける方、興味がある方は以下のリンクから是非ご応募ください。 hrmos.co また、カジュアル面談も随時実施中です。「話を聞いてみたい」のような気軽な感じで大丈夫です。是非ご応募ください。 hrmos.co
アバター
はじめに こんにちは、データシステム部データ基盤ブロックSREの纐纈です。 本記事では、過去に遡ってBigQueryのデータを参照する方法(以下、タイムトラベルと呼びます)をご紹介します。また、この機能はBigQueryが提供している、変更または削除されたデータにアクセスする タイムトラベル とは異なることをご了承ください。 開発背景 この機能は過去データを日次スナップショットより細かい粒度で見たい、また障害対応時に障害発生前などピンポイントで時間指定して参照したいという要望を受け、開発することになりました。 さらに、BigQueryからこの機能を作るのに役立ちそうなテーブル関数という機能がリリースされたのもきっかけとなりました。 cloud.google.com テーブル関数とは、事前にパラメータを使って定義したクエリをエイリアスのようにテーブルとして保存して、そのテーブルに対して関数を実行するかのようにクエリを書ける機能です。例えば、以下のようにテーブル関数を定義するとします。 CREATE TABLE FUNCTIONS `some_dataset.foo_records_by_name`(name_param STRING) AS SELECT * FROM `some_dataset.foo` WHERE name = name_param その上で、このようなクエリを実行するとします。 SELECT * FROM `foo_records_by_name`( ' bar ' ) すると、事前に定義したテーブル関数がパラメータを代入して、結果としてこちらのクエリが実行されます。 SELECT * FROM `some_dataset.foo` WHERE name = ' bar ' 短いクエリだと受けられる恩恵が少ないですが、長いクエリに対しては重宝される機能かと思います。 タイムトラベルの機能 SELECT * FROM `< table ID>`( ' 2021-01-01 ' ) テーブル関数を使用して上のようにクエリを打つと、指定した日時の状態のデータを参照できます。 実際に実行されているクエリは、こちらです。クエリ内のpast_timeはTIMESTAMP型で、テーブル関数から渡されるパラメータです。 WITH snapshot_validation AS ( SELECT ' <base_table> ' AS table_id, MAX (creation_time) AS snapshot_validation_time, FROM `<snapshot_dataset>.INFORMATION_SCHEMA.TABLES` WHERE REGEXP_CONTAINS( table_name, CONCAT ( ' <base_table> ' , ' _ ' ,FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " ) ))), streaming_data_validation AS ( SELECT table_id, min_bigquery_insert_time AS streaming_validation_time FROM `<changetracking validation table ID>` WHERE dataset_id = ' <changetracking_dataset> ' AND table_id = ' <changetracking_table> ' ), validation AS ( SELECT a.table_id, snapshot_validation_time, streaming_validation_time FROM snapshot_validation AS a INNER JOIN streaming_data_validation AS b ON a.table_id = b.table_id), nearest_snapshot AS ( SELECT *, CONCAT (${ join ( " , " , primary_key)}) AS primary_key FROM `<snapshot_dataset>.<base_table> _ *` AS snapshot_table WHERE _TABLE_SUFFIX IN (FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " ))), changetracking_for_two_days_until_specified_time AS ( SELECT * FROM ( SELECT *, id AS primary_key FROM `changetracking_dataset.changetracking_table` WHERE bigquery_insert_time BETWEEN TIMESTAMP_SUB(past_time, INTERVAL 2 DAY) AND past_time ) AS changetracking ), changetracking_latest_version_key_group AS ( SELECT primary_key, MAX ( CAST (changetrack_ver AS int64)) AS changetrack_ver, MAX (changetrack_start_time) AS changetrack_start_time FROM changetracking_for_two_days_until_specified_time GROUP BY primary_key ), changetracking_latest_version AS ( SELECT a.* FROM changetracking_for_two_days_until_specified_time AS a INNER JOIN changetracking_latest_version_key_group AS b ON a.primary_key = b.primary_key AND a.changetrack_ver = b.changetrack_ver ), changetracking_without_duplication AS ( SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY primary_key ORDER BY primary_key) AS row_number FROM changetracking_latest_version) WHERE row_number = 1 ), nearest_snapshot_except_what_changetracking_included AS ( SELECT * FROM nearest_snapshot WHERE primary_key NOT IN ( SELECT primary_key FROM streaming_diff ) ) SELECT ... -- columns in the base table (cannot use *) to align with changetracking FROM nearest_snapshot_except_what_changetracking_included UNION ALL SELECT ... -- columns in the base table (cannot use *) since changetracking_without_duplication has more columns FROM changetracking_without_duplication WHERE changetrack_type != ' D ' AND IF (snapshot_validation_time IS NOT NULL , TRUE , ERROR( CONCAT ( " Cannot time-travel since snapshot data does not exist for the specified time. " ) )) AND IF (past_time > streaming_validation_time, TRUE , ERROR( CONCAT ( " Cannot time-travel since recording changetracking had not started at the time. check nearest daily snapshot directly. Specify time after: " , streaming_validation_time))) このクエリの中では、パタメータに渡された日時をもとに以下の内容を実行しています。 指定された日のテーブルコピーがあるかチェック 差分データがあるかチェック 日次で取っているテーブルのコピーからデータを取得する テーブルコピーに記録されている最終時刻と指定した時間までの差分データを変更履歴ログから摘出する 組み合わせて指定された時刻のテーブルの状態を再現する そして、そのテーブルに対して元々のSELECT文のクエリを実行するという仕組みになっています。 使われているテーブルについて、簡単に説明します。 base_table:元となるテーブルで、このテーブルの過去データを見ることがタイムトラベル機能の目的です。 daily_snapshot:base_tableの日次テーブルコピー。データ基盤を構築するために、日次バッチによってBigQueryにテーブルデータを転送しており、その際にその日時点でのテーブルのコピーを取っています。データ転送用の日次バッチは日本時間0時に動かしていますが、必ずしも0時時点のデータとは限りません。テーブル定義はbase_tableと全く同じです。 change_tracking:base_tableの変更追跡ログ。これはSQL ServerのChange trackingという機能によって保存されているテーブルです。データベース上のテーブルに対してinsert, update, deleteの変更が入る度に、変更に関する情報が記録されています。 changetrackingのテーブルは、base_tableのカラムと変更追跡のカラム、また転送バッチが実行された時刻のカラムによって定義されています。この機能に使われている追加のカラムのみ、説明します。 カラム名 型 説明 changetrack_ver INTEGER 変更された行のバージョン番号(初めて変更された場合は1、max(changetrack_ver)で最新の変更情報が取得できる) changetrack_type STRING 変更追跡のタイプ(I - insert, U - update, D - delete) changetrack_start_time TIMESTAMP 変更追跡の開始日時 bigquery_insert_time TIMESTAMP 転送バッチによってBigQueryに追加された日時 詳しい仕組みなどは、公式ドキュメントをご参照ください。 docs.microsoft.com 弊チームでは、リアルタイムデータ基盤を構築する際、Change trackingの機能を使いました。そのため既にBigQuery上に転送される仕組みが構築されており、今回の機能に必要な条件も満たしていたため、こちらを利用することにしました。リアルタイムデータ基盤について詳しく知りたい場合は、こちらをご参照ください。 techblog.zozo.com SQL Serverだけでも変更履歴を取得する方法はいくつかあるので、Change trackingでなくCDC(Change Data Capture)でも実装可能です。Change Trackingからは変更後のレコードの値が取得できるため、ベースとなるテーブルコピーに対してChange Trackingの変更後の値を追加するという方式を取っています。しかし、CDCのように変更前の値も取れるものを採用するのであれば、ベースから遡ることもできます。 docs.microsoft.com では、ここからは実際にクエリの中身を解説しつつどう実装したのか見ていきます。 実施したこと データ基盤にあるテーブルの日次コピーを取るようにする タイムトラベルにあたって過去の状態のデータを再現するには、ベースとなる日次テーブルコピーが必要です。 元々分析チームの要望などによって、いくつかのテーブルは過去データが参照できるよう日次データ転送時にテーブルのコピーを取って保存していました。そのため、その機能を元にタイムトラベル機能を使えるよう日次コピーを取るテーブルを拡張しました。 change trackingに関しては既に転送設定がされているので、この時点でタイムトラベルに必要なデータが揃います。 クエリの作成 テーブルコピーと変更ログの準備ができたので、タイムトラベル用のクエリを生成していきます。分割しつつ、順を追って説明します。 まず、テーブル関数を定義します。任意の指定した時刻をパラメータとして受け取るテーブル関数を作るため、以下のような形式となります。SELECT以降にこのパラメータを使いつつ、取得するデータを決めていきます。 CREATE OR REPLACE TABLE FUNCTION `project.dataset. table `(past_time TIMESTAMP ) AS SELECT ... ここからは、SELECT文以降の説明に移ります。前述した通り、今回のタイムトラベル機能はテーブルコピーと変更ログからデータを取得し、それを組み合わせて結果を返します。フローチャートを描くと、以下のようになります。 フロートチャートに沿って、順に説明します。 バリデーション (a, b) 今回のタイムトラベルの機能はどこまでも過去に遡れるというわけではありません。ベースとなるテーブルコピーと変更ログの両方がないと過去テーブルの再現はできないので、もしどちらかの機能が運用開始される前の時点にタイムトラベルしようとした場合は、警告を出す必要があります。 a. テーブルコピーの存在チェック まず、テーブルコピーを取得する前に、指定された日時の1日前のテーブルコピーが存在するか確認します。 なぜ1日前なのかというと、指定された日の当日だとスナップショットが作られる前の時刻が指定された時に、スナップショットの作成時刻の方が指定された時刻より新しいという状態になってしまうためです。転送バッチには他の処理もあるため0時ちょうどに始まるわけではなく、スナップショットが作成される時刻も0時ではありません。そのためバッファーを持たせてあります。 該当するテーブルコピーがあるかはINFORMATION_SCHEMAを参照すると確認できます。INFORMATION.TABLESには任意のデータセットに含まれるテーブルのメタ情報が入ってます。そのため、 <base_table_name>_YYYYmmdd のフォーマットで指定した日時より1日前のテーブル名が存在しているかを確認します。 past_timeを指定した時刻のパラメータとして、その1日前の日付を YYYYmmdd 形式で表すと以下のようになります。 FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " )) その上で、バリデーションのクエリはERROR関数を使って、以下のように行えます。ERROR関数については、公式ドキュメントをご参照ください。 cloud.google.com -- past_time: テーブル関数から渡されるパラメータ、TIMESTAMP型 WITH snapshot_validation AS ( SELECT ' <base_table_name> ' AS table_id, MAX (creation_time) AS snapshot_validation_time, FROM `<snapshot_dataset>.INFORMATION_SCHEMA.TABLES` WHERE REGEXP_CONTAINS( table_name, CONCAT ( ' <base_table_name> ' , ' _ ' , FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " )) ))) SELECT ... FROM snapshot_validation WHERE IF (snapshot_validation_time IS NOT NULL , TRUE , ERROR( CONCAT ( " Cannot time-travel since snapshot data does not exist for the specified time. " ) )) b. Change Trackingの存在チェック 指定された時刻からテーブルコピーまでの差分データが存在しているかを調べるためには、変更ログを保存しているテーブルをスキャンしなければなりません。変更ログのテーブルはデータ量も膨大なので、毎度スキャンをしていたら使い勝手が悪くなります。 そこで新たにこのバリデーション用のテーブルを作り、キャッシュのように利用することにしました。新たにバッチ処理を追加して、テーブルごとの連携が開始された時期をテーブルが追加され次第書き込むようにします。バッチで動かすクエリは以下になります。 SELECT database_name as dataset_id, table_name as table_id, MIN (bigquery_insert_time) AS min_bigquery_insert_time FROM `<change tracking table >` WHERE database_name IS NOT NULL AND table_name IS NOT NULL GROUP BY dataset_id, table_id これで変更ログが存在しているかを確認するときは、このテーブルを見ることで容易に参照できるようになりました。よって、シンプルかつ高速にバリデーションを行えるようになりました。クエリとしては、以下のようになります。 -- past_time: テーブル関数から渡されるパラメータ、TIMESTAMP型 WITH changetracking_data_validation AS ( SELECT table_id, min_bigquery_insert_time AS changetracking_validation_time FROM `<validation table ID for changetracking>` WHERE dataset_id = ' <dataset_name> ' AND table_id = ' <base_table_name> ' ) SELECT ... FROM changetracking_data_validation WHERE IF (past_time > changetracking_validation_time, TRUE , ERROR( CONCAT ( " Cannot time-travel since recording changetracking had not started at the time. Specify time after: " , changetracking_validation_time, " Or check nearest daily snapshot directly. " ))) データの取得 (1, 2, 3) 1. テーブルコピーの取得 まず、テーブルコピーからデータを持ってくるクエリは以下のようになります。テーブルコピーの名前はTable_20220224のような形式で保存しているので、_TABLE_SUFFIXを使って対象のテーブルコピーを見つけます。バリデーションの箇所で述べた通り、パラメータで指定された時刻より1日前のテーブルコピーを取得します。 -- past_time: テーブル関数から渡されるパラメータ、TIMESTAMP型 SELECT * FROM `snapshot_dataset.base_table_*` AS snapshot_table WHERE _TABLE_SUFFIX IN (FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " )) 2. Change Trackingの取得 次に、テーブルコピーからパラメータで指定された日時までの差分データを取得します。 ChangeTrackingから差分データを取得するのは、少し複雑なのでさらに分割して説明します。 2.1. テーブルコピーが取得された日時より前から指定された時間までのChange Trackingデータを取得 差分データを取得する際に、考慮しないといけないことはテーブルコピーがいつ取得されたかということです。とはいえ、テーブルコピーが取られる時刻は固定ではなく、バッチの遅延や前処理にかかった時間によって変わります。 そのため、前日のテーブルコピーがいつ作成されていても対応できるよう、指定された日時から2日間分の変更ログを取得します。その後、テーブルコピーと重複した部分を組み合わせる際に排除すると、2つのデータに重複がなくなります。クエリにすると、以下のようになります。 -- past_time: テーブル関数から渡されるパラメータ、TIMESTAMP型 SELECT * FROM `changetracking_dataset.changetracking_table` WHERE bigquery_insert_time BETWEEN TIMESTAMP_SUB(past_time, INTERVAL 2 DAY) AND past_time 2.2. Change Trackingから最新のキーを取得 Change Trackingには変更後の値が主キーと紐づけられて保存されています。複数回の更新が走った時に、どのデータが最新のバージョンかを確認するためには、changetrack_verというカラムが使えます。 削除されていた場合を除いて、changetrack_verが指定された時点で最大のものが、その時点でのデータとなっています。削除されていた場合は、そのデータを排除しなければいけませんが、これは3のセクションで後述します。 まず、主キーに応じてそれぞれchangetrack_verの最大値を取得します。 WITH -- step 2.1 changetracking_for_two_days_until_specified_time AS ( SELECT * FROM ( SELECT *, id AS primary_key FROM `changetracking_dataset.changetracking_table` WHERE bigquery_insert_time BETWEEN TIMESTAMP_SUB(past_time, INTERVAL 2 DAY) AND past_time ) AS changetracking ) SELECT primary_key, MAX ( CAST (changetrack_ver AS int64)) AS changetrack_ver, MAX (changetrack_start_time) AS changetrack_start_time FROM changetracking_for_two_days_until_specified_time GROUP BY primary_key 2.3 Change Trackingから最新のキーに紐づくデータを取得 次に、前のステップで取得した主キーとchangetrack_verを使って、変更ログから差分データを取り出します。 WITH -- step 2.1 changetracking_for_two_days_until_specified_time AS ( SELECT * FROM ( SELECT *, id AS primary_key FROM `changetracking_dataset.changetracking_table` WHERE bigquery_insert_time BETWEEN TIMESTAMP_SUB(past_time, INTERVAL 2 DAY) AND past_time ) AS changetracking ), -- step 2.2 changetracking_latest_version_key_group AS ( SELECT primary_key, MAX ( CAST (changetrack_ver AS int64)) AS changetrack_ver, MAX (changetrack_start_time) AS changetrack_start_time FROM changetracking_for_two_days_until_specified_time GROUP BY primary_key ) SELECT a.* FROM changetracking_for_two_days_until_specified_time AS a INNER JOIN changetracking_latest_version_key_group AS b ON a.primary_key = b.primary_key AND a.changetrack_ver = b.changetrack_ver ) これで差分データが取得できたので、次にテーブルコピーとの組み合わせに移ります。 3. テーブルコピーと差分データを組み合わせる 2の工程までで、テーブルコピーのデータと差分データは取得できました。最後にこの2つのデータを組み合わせていきますが、いくつかまだ手を加える必要があるので、また細かくして説明します。 3.1. テーブルコピーからChange Trackingに含まれているデータを削除 現時点では、変更ログとテーブルコピーのデータには重複している部分があり、UNIONする前に除外する必要があります。これは、テーブルコピーのデータから変更ログに存在しているデータを除外することで対応できます。 SELECT * FROM nearest_snapshot -- step 1 WHERE primary_key NOT IN ( SELECT primary_key FROM changetracking_latest_version ) 3.2. 差分データからDELETE用のデータを削除 また、変更ログをUNIONする際に変更ログのタイプが'削除'でないもののみを抽出する必要があります。なぜなら、そのデータは指定された時間では削除されているべきデータだからです。ここで、changetrack_typeのカラムを使います。 changetrack_type = 'D' が削除の変更がされたというログなので、 changetrack_ver != 'D' であるデータのみを差分データとして利用します。クエリは以下のようになります。 SELECT * FROM changetracking_latest_version -- step 2.3 WHERE changetrack_type != ' D ' 3.3. テーブルコピーと差分データをUNIONする 以上をまとめて、最後にテーブルコピーのデータとChange Trackingから取得した差分データをUNIONすると、SELECT文が完成します。 -- past_time: テーブル関数から渡されるパラメータ、TIMESTAMP型 WITH -- step 1 nearest_snapshot AS ( SELECT *, CONCAT (${ join ( " , " , primary_key)}) AS primary_key FROM `${project_snapshot}.${dataset_snapshot}.${table_base} _ *` AS snapshot_table WHERE _TABLE_SUFFIX IN (FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " ))), -- step 2.1 changetracking_for_two_days_until_specified_time AS ( SELECT * FROM ( SELECT *, id AS primary_key FROM `changetracking_dataset.changetracking_table` WHERE bigquery_insert_time BETWEEN TIMESTAMP_SUB(past_time, INTERVAL 2 DAY) AND past_time ) AS changetracking ), -- step 2.2 changetracking_latest_version_key_group AS ( SELECT primary_key, MAX ( CAST (changetrack_ver AS int64)) AS changetrack_ver, MAX (changetrack_start_time) AS changetrack_start_time FROM changetracking_for_two_days_until_specified_time GROUP BY primary_key ), -- step 2.3 changetracking_latest_version AS ( SELECT a.* FROM changetracking_for_two_days_until_specified_time AS a INNER JOIN changetracking_latest_version_key_group AS b ON a.primary_key = b.primary_key AND a.changetrack_ver = b.changetrack_ver ), -- step 3.1 nearest_snapshot_except_what_changetracking_included AS ( SELECT * FROM nearest_snapshot WHERE primary_key NOT IN ( SELECT primary_key FROM changetracking_latest_version ) ) SELECT ... -- columns in the base table (cannot use *) to align with changetracking FROM nearest_snapshot_except_what_changetracking_included UNION ALL SELECT ... -- columns in the base table (cannot use *) since changetracking_without_duplication has more columns FROM changetracking_latest_version WHERE changetrack_type != ' D ' -- step 3.2 最後に、バリデーションを組み合わせて、最終的なテーブル関数の完成です。 CREATE OR REPLACE TABLE FUNCTION `time_travel_dataset.some_table`(past_time TIMESTAMP ) AS WITH -- step a snapshot_validation AS ( SELECT ' <base_table> ' AS table_id, MAX (creation_time) AS snapshot_validation_time, FROM `<snapshot_dataset>.INFORMATION_SCHEMA.TABLES` WHERE REGEXP_CONTAINS( table_name, CONCAT ( ' <base_table> ' , ' _ ' ,FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " ) ))), -- step b streaming_data_validation AS ( SELECT table_id, min_bigquery_insert_time AS streaming_validation_time FROM `<changetracking validation table ID>` WHERE dataset_id = ' <changetracking_dataset> ' AND table_id = ' <changetracking_table> ' ), validation AS ( SELECT a.table_id, snapshot_validation_time, streaming_validation_time FROM snapshot_validation AS a INNER JOIN streaming_data_validation AS b ON a.table_id = b.table_id), -- step 1 nearest_snapshot AS ( SELECT *, CONCAT (${ join ( " , " , primary_key)}) AS primary_key FROM `${project_snapshot}.${dataset_snapshot}.${table_base} _ *` AS snapshot_table WHERE _TABLE_SUFFIX IN (FORMAT_TIMESTAMP( " %Y%m%d " , TIMESTAMP_SUB(past_time, INTERVAL 1 DAY), " Asia/Tokyo " ))), -- step 2.1 changetracking_for_two_days_until_specified_time AS ( SELECT * FROM ( SELECT *, id AS primary_key FROM `changetracking_dataset.changetracking_table` WHERE bigquery_insert_time BETWEEN TIMESTAMP_SUB(past_time, INTERVAL 2 DAY) AND past_time ) AS changetracking ), -- step 2.2 changetracking_latest_version_key_group AS ( SELECT primary_key, MAX ( CAST (changetrack_ver AS int64)) AS changetrack_ver, MAX (changetrack_start_time) AS changetrack_start_time FROM changetracking_for_two_days_until_specified_time GROUP BY primary_key ), -- step 2.3 changetracking_latest_version AS ( SELECT a.* FROM changetracking_for_two_days_until_specified_time AS a INNER JOIN changetracking_latest_version_key_group AS b ON a.primary_key = b.primary_key AND a.changetrack_ver = b.changetrack_ver ), -- step 3 nearest_snapshot_except_what_changetracking_included AS ( SELECT * FROM nearest_snapshot WHERE primary_key NOT IN ( SELECT primary_key FROM changetracking_latest_version ) ) SELECT ... -- columns in the base table (cannot use *) to align with changetracking FROM nearest_snapshot_except_what_changetracking_included UNION ALL SELECT ... -- columns in the base table (cannot use *) since changetracking_without_duplication has more columns FROM changetracking_without_duplication WHERE changetrack_type != ' D ' AND IF (snapshot_validation_time IS NOT NULL , TRUE , ERROR( CONCAT ( " Cannot time-travel since snapshot data does not exist for the specified time. " ) )) AND IF (past_time > streaming_validation_time, TRUE , ERROR( CONCAT ( " Cannot time-travel since recording changetracking had not started at the time. check nearest daily snapshot directly. Specify time after: " , streaming_validation_time))) Terraformでの管理 Terraformでこの機能を管理するにあたって、上のクエリをテンプレート化してリアルタイム連携されているテーブルの全てに対して適応しました。テンプレート化する際に気をつけることとしては、プライマリーキーとカラムはテーブルによって異なるので、そこをケアする必要があります。弊チームの基盤ではテーブルごとのカラムやプライマリーキーの情報は自動スクリプトで取得されているので、そちらを使いました。 また、テーブル関数の機能は比較的新しく、タイムトラベルの機能を実装した直後にはTerraformに実装されていませんでした。当初はBiqQuery_jobを一時的に使って対応していましたが、現在ではテーブル関数も対応しているのでこちらに切り替えました。 registry.terraform.io 注意点 タイムトラベルのクエリを作る際にいくつか気をつけなければならないポイントがあったので、注意点として紹介します。 ストレージ料金とクエリ料金のバランス この機能は日次のテーブルコピーと変更履歴ログの保存が必須です。ストレージ料金がかかることは念頭に置いた上で、クエリの実行によるスキャン量も多くなることを忘れてはいけません。 多くの変更が走るようなテーブルでは、変更ログが膨大になり、クエリ料金が大きくなります。またストレージ料金の増大を恐れてテーブルコピーの頻度を下げると、ストレージ料金は節約できるかもしれませんが、クエリのスキャン量つまりクエリ料金が増えパフォーマンスは落ちます。 スナップショット名を動的に取ってはいけない スナップショットを取る際、以下のようなクエリをご紹介しましたがこれはテーブルコピーを日次で取ることを前提としています。 SELECT * FROM `snapshot_dataset.base_table_*` AS snapshot_table WHERE _TABLE_SUFFIX IN (FORMAT_TIMESTAMP( " %Y%m%d " , past_time, " Asia/Tokyo " )) こちらは当初の案から修正が入っており、以前は日次コピーのストレージ料金を懸念して古いコピーを後々間引けるよう、以下のような形で動的にテーブル名を取得しようとしていました。 SELECT MAX (creation_time) AS nearest_snapshot FROM `snapshot_dataset.INFORMATION_SCHEMA.TABLES` WHERE REGEXP_CONTAINS( table_name, r " base_table_(?:19|20)[0-9]{2}(0?[1-9]|1[0-2])(0?[1-9]|[12][0-9]|3[01]) " ) -- regexp for yyyymmdd AND creation_time <= past_time ) しかし、この方法だとスキャン前にパーティションが効かないため、クエリが非常に重くなるという問題が起きました。そのため、テーブルコピーの間引きは保留にし、日次ベースで取得するようになっています。 Dataflowのリトライでデータの重複が起きる これは運用し始めてから、判明したことです。差分データの取得時にデータの重複が発生し、原因を調べたところ、Dataflowのリトライが起因していました。 リアルタイム連携の記事でも紹介した通り、データの転送バッチにはDataflowを使っています。Change Trackingデータの転送がうまくいかず、リトライをかけるとデータが重複して保存されてしまうことがあります。データ転送にDataflowを使っている場合は、以下のようなクエリを追加すると重複を排除できます。 SELECT *, ROW_NUMBER() OVER (PARTITION BY primary_key ORDER BY primary_key) AS row_number FROM changetracking_latest_version) WHERE row_number = 1 まとめ 今回BigQueryのテーブル関数という機能を使って、過去のデータの状態のテーブルを再現し、簡単にクエリを実行する機能を作りました。現在まだ試験運用中ではありますが、ある程度使ってもらったチームからは好評を頂いています。開発した自分としてもより多くの人に使ってもらえる機能になればと思っています。また、開発当初は想定していなかった、データベースの断面が揃えられるという副次的なメリットもあるという発見もありました。この記事が同じような問題を抱えている開発者の方の助けになれば幸いです。 最後に、ZOZOでは利用者にとって使いやすいデータ基盤を整備していく仲間を募集しています。ご興味のある方は、以下のリンクからご応募ください。 hrmos.co
アバター
こんにちは、ZOZO CTOブロックの @ikkou です。 ZOZOでは、4/22に ZOZO Tech Talk #5 - チーム開発と運用 を開催しました。 zozotech-inc.connpass.com 本イベントは、これまで夕刻に開催してきたMeetupとは異なり、ランチタイムに開催する「ZOZO Tech Talk」シリーズです。ZOZO Tech Talkでは、ZOZOがこれまで取り組んできた事例を紹介していきます。 第5回はTBSテレビ「がっちりマンデー!!」でも紹介いただいた弊社のZOZOGLASSやZOZOMATの開発を行う計測プラットフォーム開発本部エンジニアより「チーム開発と運用」をテーマに発表しました。 登壇内容 まとめ 弊社の社員2名が登壇しました。 計測プラットフォームSREチームとシステム障害対応 (計測プラットフォーム開発本部 計測システム部 / 高木 貴之) 僕らチームとモデリング (メディア開発本部 FAANS部 / 鈴木 亮太) 最後に ZOZOでは、プロダクト開発以外にも、今回のようなイベントの開催など、外部への発信も積極的に取り組んでいます。 一緒にサービスを作り上げてくれる方はもちろん、エンジニアの技術力向上や外部発信にも興味のある方を募集中です。 ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com
アバター
はじめに こんにちは。WEAR部iOSチームの坂倉です。先日、WEARにコーディネート動画の投稿機能を実装しました。 iOSで動画を扱うにはAVFoundationを使う必要がありますが、原因がわかりにくいエラーを引き起こすことが多々あり、実装になかなか苦労しました。 この記事では、動画投稿の開発中に起きた問題とその解決法をお伝えします。 WEARの動画投稿には以下の機能が存在します。 動画を選択する 動画をプレビューする 動画に付与する音楽を選択する 動画に付与する音楽の範囲を指定してトリミングする 動画に関する情報を付与する 動画と音楽をミックスしてエンコードする 完成した動画を投稿する これらを実装する中で、2つの問題に直面しました。 特定の動画が原因不明のエラーでエンコードできない 音楽の再生と合わせて一部の波形のみをアニメーション付きで着色する処理の設計 それぞれの解決方法を下記にてお伝えいたします。 特定の動画が原因不明のエラーでエンコードできない 動画といっても色々な種類があり、この動画のエンコードは問題ないが、あの動画はエラーが出てしまうといったことが多々ありました。 ここでは、その時の対処法について解説します。 ちなみに、WEARで使用しているエンコードのコードは以下の通りです(一部省略)。 エンコードコード(クリックで展開) // 動画と音楽のURLからAVURLAssetを生成 let videoURLAsset : AVURLAsset = . init (url : videoPath ) let audioURLAsset : AVURLAsset = . init (url : audioPath ) guard let videoAssetTrack = videoURLAsset.tracks(withMediaType : .video).first, let audioAssetTrack = audioURLAsset.tracks(withMediaType : .audio).first else { return } // AVURLAssetから動画+音楽を追加し、AVAssetExportSessionに必要なコンポジションを作成 let composition : AVMutableComposition = . init () // 動画をAVMutableCompositionに追加 guard let videoTrack = composition.addMutableTrack(withMediaType : .video, preferredTrackID : kCMPersistentTrackID_Invalid ) else { return } try ? videoTrack.insertTimeRange(videoAssetTrack.timeRange, of : videoAssetTrack , at : .zero) // 範囲を指定し音楽をAVMutableCompositionに追加(ここでは音楽の長さは動画の長さと同じにする) guard let audioTrack = composition.addMutableTrack(withMediaType : .audio, preferredTrackID : kCMPersistentTrackID_Invalid ) else { return } let audioTimeRange : CMTimeRange = . init (start : .zero, end : videoTrack.timeRange.end ) try ? audioTrack.insertTimeRange(audioTimeRange, of : audioAssetTrack , at : .zero) // 動画を回転させるためにAVMutableVideoCompositionLayerInstructionを生成する let videoCompositionLayerInstruction : AVMutableVideoCompositionLayerInstruction = . init (assetTrack : videoTrack ) let transform = makeTransform(with : videoTrack ) videoCompositionLayerInstruction.setTransform(transform, at : .zero) // AVMutableVideoCompositionInstructionに動画の時間と回転情報を渡す let videoCompositionInstruction : AVMutableVideoCompositionInstruction = . init () videoCompositionInstruction.timeRange = videoTrack.timeRange videoCompositionInstruction.layerInstructions = [videoCompositionLayerInstruction] // 動画の解像度やframeDurationカラー情報をAVAssetExportSessionに渡すためのAVMutableVideoCompositionを生成 let videoComposition : AVMutableVideoComposition = . init () // iOSの画面収録で撮った動画がiOS 14でエンコードできないで解説します videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2 videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2 videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2 // 写真アプリでトリミングした動画がエンコード出来ないで解説します let fps = max(videoAssetTrack.nominalFrameRate, 1.0 ) videoComposition.frameDuration = CMTime(value : 1 , timescale : CMTimeScale (fps)) videoComposition.renderSize = CGSize(width : 1080.0 , height : 1920.0 ) // 解像度を指定 videoComposition.instructions = [videoCompositionInstruction] // AVMutableCompositionとAVMutableVideoCompositionで動画+音楽ソース+解像度などの詳細を渡し、動画を生成 guard let assetExportSession : AVAssetExportSession = . init (asset : composition , presetName : AVAssetExportPresetHighestQuality ) else { return } guard let documentPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true ).first else { return } assetExportSession.videoComposition = videoComposition // AVMutableVideoCompositionInstructionにも指定しているがここでも指定しないとエラーが出る assetExportSession.timeRange = videoTrack.timeRange assetExportSession.outputFileType = .mp4 let videoFilePath = " \( documentPath ) /tmp.mp4" assetExportSession.outputURL = URL(fileURLWithPath : videoFilePath ) assetExportSession.shouldOptimizeForNetworkUse = true assetExportSession.exportAsynchronously { switch assetExportSession.status { case .completed : print (assetExportSession.outputURL ! ) @unknown default : return } } iOSの画面収録で撮った動画がiOS 14でエンコードできない 色々な動画のエンコードを試す中で、どうしてもエンコードできない動画がありました。それは、iOSの画面収録で撮影した動画です。 AVAssetExportSessionはエラーを出力してくれますが、エラーを見ても原因を突き止めるのが困難な内容でした。 Error Domain=AVFoundationErrorDomain Code=-11800 "操作を完了できませんでした" UserInfo={NSLocalizedFailureReason=原因不明のエラーが起きました(-12212), NSLocalizedDescription=操作を完了できませんでした, NSUnderlyingError=xxxx {Error Domain=NSOSStatusErrorDomain Code=-12212 "(null)"}} そのため、エラーコードに注目しました。 -12212 と表示されていたので調べたところkVTColorCorrectionPixelTransferFailedErrというエラーだとわかりました。 stackoverflow.com developer.apple.com 色に問題ありということで、問題の動画をQuickTime Playerのムービーインスペクタで確認しました。 すると、エンコードできる動画と比べ、画面収録で撮った動画はTransfer FunctionがsRGBとなっており、Appleの Setting Color Properties for a Specific Resolution に記述されている設定例に無い値になっていました。 そのため、「この動画はAVAssetExportSessionに対応していない」という仮説を立て、色指定を変更することでエンコード出来るか試してみました。 AVVideoCompositionは、動画の色空間情報を設定するためのプロパティを3つ持っています。 colorPrimaries 動画の色空間を指定するタグ colorTransferFunction 動画の色空間変更で使用する伝達関数 colorYCbCrMatrix 動画の色空間変更で使用するYCbCrマトリックス これらを、Appleの Setting Color Properties for a Specific Resolution に記述されている例を元に設定してみました。(10-bit wide gamut HDはAVVideoSettingsのドキュメントコメントに記述されています)。 なんと、OSのバージョンによって違いが出る結果になりました。 OS別エンコード確認表(○は可、×は不可) 設定無 HD SD wide gamut HD 10-bit wide gamut HD iOS 15 ○(HD化) ○ ○(HD化) ○ ○(HD化) iOS 14 × ○ × ○ × iOS 13 ○ ○ ○ ○ × iOS 15は、どのパターンでもエンコードできました。iOS 13/14は、HD/wide gamut HD以外だとエンコードエラーになりました。 面白いのは、iOS 15の場合、設定なし/SD/10-bit wide gamut HDの場合、色空間がHDと同じになることがわかりました。この結果を見る限り、iOS 15は変換できなかった場合システム側でHDに色を揃えていそうですね。 この結果を見て、HD(Rec.709)が全ての対応OSでもエンコードできて一般的な規格である事から、WEARはHDの設定を使用する事にしました。 // For HD colorimetry, specify let videoComposition : AVMutableVideoComposition = . init () videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2 videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2 videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2 ちなみに、動画の色指定に関して丁寧に説明しているAppleのドキュメントがあるので一見の価値ありです。 developer.apple.com 写真アプリでトリミングした動画がエンコードできない 写真アプリでトリミングした時間が短い動画を指定するとエンコードエラーになる事象もありました。デバッグログを見ると以下のようなエラーが出ていました。 *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[AVAssetExportSession setVideoComposition:] video composition must have a positive frameDuration' CMTimeScale は整数であることが求められますが、あまりに短い動画だとnominalFrameRateで得られるフレームレートは1を下回ることもあります。 なので、最低値を1.0にして対応しました。 let fps = max(videoTrack.nominalFrameRate, 1.0 ) videoComposition.frameDuration = CMTime(value : 1 , timescale : CMTimeScale (fps)) 動画をmp4で出力する場合、mp3を使うと出力できない問題 Appleのドキュメントには記述が見つからなかったのですが、mp3の音楽ファイルを取り込んでmp4の形式で動画を出力する場合エラーになりました。 StackOverflowによると、movとcaf以外の形式ではmp3を使うことはできないようです。 stackoverflow.com そのため、WEARではm4aのみを使いmp3は使わないようにしています。 AVMutableVideoCompositionLayerInstructionは、AVAssetTrackを使って生成するとエンコード出来ない場合がある 特定の動画で、なぜかエンコードできないことがありました。エラーの内容は以下の通り。 Error Domain=AVFoundationErrorDomain Code=-11841 "操作が停止しました" UserInfo={NSLocalizedFailureReason=ビデオを作成できませんでした。, NSLocalizedDescription=操作が停止しました, NSUnderlyingError=... {Error Domain=NSOSStatusErrorDomain Code=-17390 "(null)"}} 結論から言うと、AVMutableVideoCompositionLayerInstructionを生成するときに渡していたassetTrackに原因がありました。 🙅‍♀️な例 guard let videoAssetTrack = videoURLAsset.tracks(withMediaType : .video).first else { return } let videoCompositionLayerInstruction : AVMutableVideoCompositionLayerInstruction = . init (assetTrack : videoAssetTrack ) 🙆‍♂️な例 guard let videoTrack = composition.addMutableTrack(withMediaType : .video, preferredTrackID : kCMPersistentTrackID_Invalid ) else { return } let videoCompositionLayerInstruction : AVMutableVideoCompositionLayerInstruction = . init (assetTrack : videoTrack ) AVMutableVideoCompositionLayerInstructionのイニシャライザを見ると、assetTrackの型がAVAssetTrackだったので間違ってしまいましたが、AVMutableCompositionのaddMutableTrackのAVMutableCompositionTrackを指定するのが正でした(ちなみにAVMutableCompositionTrackの親の親はAVAssetTrack)。 気付きづらいのが、全ての動画がエンコードできないというわけではないということです。完全に原因を特定できてはいませんが、写真アプリ以外で編集した動画はこの現象が起こりやすい印象でした。 StackOverflowに対処法があったことで気づくことができましたが、これはなかなかの罠ですね。皆さんもご注意ください。 stackoverflow.com 音楽の再生と合わせて一部の波形のみをアニメーション付きで着色する処理の設計 WEARの動画投稿には、動画に付与する音楽をトリミングできる画面が存在します。 この画面には、スクロールを止めたタイミングで音楽のループ再生に合わせて枠内(UIScrollViewのスクロール領域のみ)の波形をアニメーション付きで着色する実装が求められました。 開発当初はなかなか上手い実装方法が思い浮かびませんでした。 「音楽のループ再生とアニメーションをどう同期させるか?」「波形画像の一部だけを着色するにはどんな方法でやるのがシンプルか?」「スクロールを邪魔せずどう実装するか?」などを一つ一つ考慮した結果、AVQueuePlayer、AVPlayerLooper、UIGraphicsImageRenderer、UIViewのmask、CAKeyframeAnimationを組み合わせることで実装できました。 踏んだ手順は以下の通りです。 AVQueuePlayer+AVPlayerLooperでループ再生を実装 UIScrollViewに波形の画像を入れる スクロールが止まったタイミングで音楽を再生しスクロール量を元に枠内の波形を切り出す 切り出した波形の画像をアニメーション用のViewのmaskに追加 音楽再生のタイミングでCAKeyframeAnimationを使って波形をアニメーション付きで塗る 各項目を最小限のコードで説明します。 1. AVQueuePlayer+AVPlayerLooperでループ再生機能を実装する まず、音楽を繰り返し再生する必要があるためAVQueuePlayer+AVPlayerLooperでループ再生を実装します(AVAudioEngineを使う方法もありますが今回は再生だけで良いのでAVQueuePlayerを使いました)。 また、音楽の再生が完了するたびに着色を初めからやり直したいため、NSKeyValueObservationを使ってAVPlayerLooperのloopCountの状態を監視しています。 final class AudioPlayer { private let asset : AVAsset private let playerItem : AVPlayerItem private let player : AVQueuePlayer private var playerLooper : AVPlayerLooper? private var playerLooperObservation : NSKeyValueObservation? init (withAudioFilePath audioFilePath : URL ) { asset = . init (url : audioFilePath ) playerItem = . init (asset : asset ) player = AVQueuePlayer(items : [ playerItem ] ) } // rangeは音楽の再生範囲(秒) func play (range : ClosedRange < Double > , completion : @escaping (()) -> Void ) { player.removeAllItems() // 再生範囲を変えるにはAVPlayerLooperを作り直す必要があるためリセット。これがないとクラッシュする。 playerLooper = AVPlayerLooper( player : player , templateItem : playerItem , timeRange : CMTimeRange (range : range , timescale : asset.duration.timescale ) ) player.play() playerLooperObservation = playerLooper?.observe(\.loopCount, options : [ .new ] ) { playerLooper, _ in guard playerLooper.loopCount > 0 else { return } completion(()) } } } extension CMTimeRange { init (range : ClosedRange < Double > , timeScale : CMTimeScale ) { let start : CMTime = . init (seconds : range.lowerBound , preferredTimescale : timeScale ) let end : CMTime = . init (seconds : range.upperBound , preferredTimescale : timeScale ) self = . init (start : start , end : end ) } } 2. UIScrollViewに波形の画像を入れる 次に、音楽の波形画像をUIScrollViewに追加します。 UIScrollViewに入れた波形は、UIScrollViewのframeから出ても描画したいので、 scrollView.clipsToBounds = false にします。 final class AudioRangeView : UIView { @IBOutlet private var scrollView : UIScrollView! override func awakeFromNib () { super .awakeFromNib() guard let waveFormImageView : UIImageView = . init (image : waveFormImage ) else { return } scrollView.addSubview(waveFormImageView) scrollView.contentSize = CGSize( width : waveFormImageView.bounds.size.width , height : waveFormImageView.bounds.size.height ) scrollView.clipsToBounds = false // スクロールバーのframe以外のcontentViewを描写する } } 3. スクロールが止まったタイミングでスクロール量を元に枠内の波形を切り出す 次は、枠内の波形のみを着色するためUIScrollViewの枠内に入っている波形画像を切り出します。 波形の着色は、スクロールが止まった時に行うため、UIScrollViewDelegateのscrollViewDidEndDeceleratingとscrollViewDidEndDragging上で行います。 scrollView.contentOffset.x(スクロール量)を使って枠内の波形のポジションを取得しUIGraphicsImageRendererで切り出します。 final class AudioRangeView : UIView { // 以下略 func scrollViewDidEndDecelerating (_ scrollView : UIScrollView ) { let cropImage : UIImage = UIGraphicsImageRenderer(size : scrollView.bounds.size ).image { context in waveFormImageView.image?.draw(at : CGPoint (x : - scrollView.contentOffset.x, y : .zero)) } animationView.startAnimation(image : image , animationDuration : 10.0 ) } } 4. 切り出した波形の画像をアニメーション用のViewのmaskに追加 次に、UIScrollViewの上に同サイズのアニメーション用のViewを置きます。 このViewに先ほど切り出した波形画像を渡し、これを着色することで、UIScrollViewの中に収まった波形だけが塗られていくように見せます。 波形画像を着色するにはmaskを利用します。maskに切り出した波形画像を入れます。 final class WaveFormColoringAnimationView : UIView { @IBOutlet private var liquidView : UIView! func startAnimation (image : UIImage , animationDuration : Double ) { mask = UIImageView(image : image ) } } そして、着色用のViewをアニメーション用のViewの一番上に貼ります。 このViewには波形に着色したい色をアニメーションを開始するタイミングでbackgroundColorに指定します。 func startAnimation (image : UIImage , animationDuration : Double ) { mask = UIImageView(image : image ) liquidView.backgroundColor = .blue } 次に、CAKeyframeAnimationを用いて切り出した波形を左から右に着色します。 durationには音楽の再生時間を指定します。こうすることで再生と共にアニメーションが進むようになります。 final class WaveFormColoringAnimationView : UIView { private var liquidView : UIView! func startAnimation (image : UIImage , animationDuration : Double ) { mask = UIImageView(image : image ) liquidView.backgroundColor = .blue let animation : CAKeyframeAnimation = . init (keyPath : "position.x" ) animation.values = [ - (liquidView.bounds.size.width * 0.5 ), liquidView.bounds.size.width * 0.5 ] animation.duration = CFTimeInterval(animationDuration) animation.isRemovedOnCompletion = false liquidView.layer.add(animation, forKey : "coloringAnimation" ) } } ループ再生が終わったタイミングでアニメーションを止めたいので、そのためのメソッドも用意しておきましょう。 final class WaveFormColoringAnimationView : UIView { // 以下略 func stopWaveFormColoringAnimation () { liquidView.layer.removeAllAnimations() liquidView.backgroundColor = .clear mask = nil } } 5. 再生と同時にアニメーションを実行して枠内の波形を着色する あとはスクロールが止まったタイミングで、音楽の再生と着色処理を同時に実行すれば、ループ再生中、枠内の波形だけ着色します。 アニメーションの時間指定には、音楽の再生時間と同じ値を入れるのをお忘れなく。 final class AudioRangeView : UIView { private let audioPlayer : AudioPlayer @IBOutlet private var animationView : WaveFormColoringAnimationView! override init (frame : CGRect ) { super . init (frame : frame ) audioPlayer = AudioPlayer(url : audioFilePath ) // 音楽のローカルパスを指定 } // 以下略 func scrollViewDidEndDecelerating (_ scrollView : UIScrollView ) { let cropImage : UIImage = UIGraphicsImageRenderer(size : scrollView.bounds.size ).image { context in waveFormImageView.image?.draw(at : . init (x : - scrollView.contentOffset.x, y : .zero)) } let playRange : ClosedRange < Double > = 0.0 ... 10.0 let animationDuration = playRange.upperBound - playRange.lowerBound animationView.startAnimation(image : cropImage , animationDuration : animationDuration ) audioPlayer.play(range : playRange , completion : { [ weak self ] in guard let self = self else { return } DispatchQueue.main.async { self .animationView.stopWaveFormColoringAnimation() self .animationView.startAnimation(image : cropImage , animationDuration : 10.0 ) } }) } func scrollViewDidEndDragging (_ scrollView : UIScrollView , willDecelerate decelerate : Bool ) { guard ! decelerate else { return } // 同様の処理 } } さいごに AVFoudationは扱いづらい事もありますが、段々慣れてくると非常に楽しくなってきますね。この記事が、誰かの動画開発の一助になれば幸いです。 ぜひ、お気に入りのコーデ動画を投稿してくれると嬉しいです。よろしくお願いします。 WEARでは、今後もどんどん動画の開発を進めていきます。ご興味のある方は以下のリンクからぜひご応募してください! hrmos.co
アバター