
マーケティングオートメーション
イベント
マガジン
技術ブログ
はじめに こんにちは、データシステム部推薦研究ブロックの寺崎( @f6wbl6 )です。 ZOZOTOWNでは、ユーザーと商品を同じベクトル空間で表現するembeddingをTwo-Towerモデルで学習しています。このembeddingを複数の推薦施策で共有できるEmbedding基盤を運用しており、過去のテックブログで紹介しました。 techblog.zozo.com このEmbedding基盤を活用することで、新しい推薦施策の立ち上げ時に新たなモデル構築やモデルのためのインフラ整備等をゼロから行う必要がなくなり、立ち上げ期間を大きく短縮できました。この基盤を使った汎用推薦システムの構築事例も以下の記事で紹介しています。 techblog.zozo.com 一方で、embeddingを利用する施策が増えるにつれて、モデルを改善してA/Bテストを実施するには利用側システムごとの改修が必要になり、改善そのものに着手しづらいという課題が生まれました。 本記事ではこの課題を解決するために構築した embedding配信基盤、REP(Recommendation Embedding Platform) を紹介します。REPは既存のEmbedding基盤を拡張したものです。バッチ推薦システム向けにBigQueryのテーブル関数をインタフェースとしてembeddingを配信し、利用側の実装を修正せずにA/Bテストを開始・終了する仕組みを提供します。複数のシステムに機械学習モデルの出力を配信している方の参考になれば幸いです。 目次 はじめに 目次 背景・課題 ZOZOTOWNにおけるembeddingの利用の広がり モデル改善のボトルネック REPの全体像 テーブル関数をインタフェースとする設計 A/Bテストの仕組み A/Bテストを開始するときの作業 バリアントに応じたembeddingの返却 リリース判断フロー 信頼性の担保 モデル切り替えの原子性 embeddingの品質保証 本番A/Bテストでの成果 結果と分析 改善サイクルの高速化 今後の展望 最後に 背景・課題 ZOZOTOWNにおけるembeddingの利用の広がり Embedding基盤が生成するembeddingは、ユーザーと商品の「相性スコア」を計算する共通部品として、以下のような機能で利用されています。 ホーム画面の各種パーソナライズ施策 メールマガジンなどのマーケティング配信のパーソナライズ施策 商品詳細画面のおすすめ商品のスコアリング このうち、ホーム画面のモジュール並び順と商品のパーソナライズは過去のテックブログで紹介しています。 techblog.zozo.com techblog.zozo.com モデル改善のボトルネック Embedding基盤のおかげで新しい推薦施策の立ち上げが効率化されましたが、embeddingを作り出すモデルそのものを改善するには大きな手間がかかる状態でした。従来の構成では各機能がembeddingのテーブルを直接参照しており、モデルを差し替えるA/Bテストのたびに参照先のテーブルを利用側で書き換える必要があったためです。 この改修コストの高さは、次の2つの事態につながりました。 モデル改善のデリバリーが遅くなる 古くなったモデルをいつまで運用し続ければよいか判断できない 1つ目は、Embedding基盤を利用する機能の数だけA/Bテスト用の改修と調整が必要になり、その分だけモデル改善そのものが着手しづらくなることです。仮に調整コストがなかったとしても、このままの構成でモデル改善を進めるとA/Bテストの開始時に複数のシステムでのリリース作業が必要になり、A/Bテスト実施工数も大きくなります。 2つ目は、どの機能がどのモデル種別を参照しているかを基盤側が把握できないために生じる事態です。利用状況が分からないと古いモデル種別をいつまでサービングしておけばよいのか判断できず、縮退時に確認漏れがあれば障害につながります。 そこで、 「利用側の実装修正なしでA/Bテストを開始・終了できる」「基盤側が利用状況を把握できる」 という要件を軸にembeddingの配信基盤を再設計し、REPを構築しました。 REPの全体像 REPの主な役割はユーザーと商品のembeddingをBigQuery上で一元管理し、スコアを事前に計算するバッチ推薦システムと、リアルタイムに処理するオンライン推薦システムの両方へ配信することです。オンライン推薦向けにはVector Search(旧Vertex AI Vector Search)へのインデキシングを行う仕組みもありますが、本記事ではバッチ推薦向けの仕組みに絞って紹介します。 バッチ推薦向けの配信では、 テーブル関数(TVF: Table-Valued Function) をインタフェースとしてembeddingを提供します。全体のシステム構成は以下のようになっています。 REPは大きく3つのレイヤーで構成されています。 レイヤー 実行基盤 役割 学習 Gemini Enterprise Agent Platform Pipelines(旧Vertex AI Pipelines) Two-Towerモデルを日次で学習し、評価指標が基準を満たしたモデルを登録する embedding生成 Cloud Run Jobs 学習済みモデルで全ユーザー・全商品のembeddingを日次で再生成する。加えて直近1時間に行動したユーザーと商品メタデータが変化した商品を1時間ごとに差分更新する 配信 BigQueryのTVF 利用側サービスへembeddingを返却する。A/Bテストのバリアント振り分けもこのレイヤーで行う 学習とembedding生成で実行基盤を分けているのは、必要とする特性が異なるためです。学習は日次1回のバッチ処理で、大きな計算リソースを必要とします。一方embedding生成、特に差分更新は毎時のような高頻度で軽量に起動できることが重要なため、Cloud Run Jobsを採用しています。将来的に差分更新をイベント駆動にする際、Cloud Run Serviceへ移行しやすいという狙いもあります。 テーブル関数をインタフェースとする設計 REPでは利用側サービスにembeddingのテーブルを直接参照させず、TVFだけをインタフェースとして公開しています。 利用側サービスは次のようなクエリでembeddingを取得しスコアリングします。 with users as ( select member_id, variant, embedding as user_embedding from `<project>.rep.get_user_embeddings`( ' <service_id> ' ) ) , products as ( select product_id, variant, embedding as product_embedding from `<project>.rep.get_product_embeddings`( ' <service_id> ' ) ) select u.member_id , p.product_id , 1 - ml.distance(u.user_embedding, p.product_embedding, ' COSINE ' ) as score from users as u inner join products as p on u.variant = p.variant 利用側は機能ごとに発行された service_id を渡すだけで、TVFではその service_id に登録されたモデル種別を解決し、有効化されているembeddingバージョンを返します。この構造により、利用側はモデル種別とembeddingバージョンを意識せずにembeddingを利用できます。 ここで、「モデル種別」は tt-v1 のようなモデルのアーキテクチャを表す識別子で、「embeddingバージョン」は同じモデル種別を再学習するたびに更新される世代を指しています。 TVF内部でのembeddingバージョン解決を支えているのが次の2つのテーブルです。 テーブル 役割 serving_config サービスごとに配信するモデル種別とA/Bテスト設定を保持する embedding_status embeddingのバージョンごとに有効化されているかどうかを管理する TVFの内部では、次の resolve_active_models というTVFが serving_config と embedding_status を結合しています。この関数が service_id からバリアント・モデル種別・有効化されているembeddingバージョンを解決します。 get_user_embeddings や get_product_embeddings は、この解決結果をもとにembeddingを返却します。 create or replace table function `<project>.rep.resolve_active_models`(service_id string) as ( select sc.variant , sc.model_type -- モデル種別を指す , es.embedding_version -- embeddingバージョンを指す from `<project>.rep.serving_config` as sc inner join `<project>.rep.embedding_status` as es on sc.model_type = es.model_type where sc.service_id = service_id and sc.is_active and es.state = ' ACTIVE ' ) serving_config の内容はリポジトリ上のYAMLで管理し、CIでBigQueryへ同期しています。この形にしたのは、A/Bテストの開始・終了をコードレビューのプロセスに乗せるためです。新しいモデルのembeddingを配信する準備が済んでいれば、A/Bテストの設定変更はYAMLを編集するプルリクエストで完結し、レビューと承認の記録が残ります。また利用側はTVFを経由してしかembeddingを取得できず、TVFは serving_config に登録された service_id にしか応答しません。そのため、YAMLファイルの一覧がそのままREPの利用状況の全体になり、基盤側が漏れなく把握できます。 A/Bテストの仕組み REPはembeddingの配信だけでなく、A/Bテストの開始から終了までの運用も担っています。ここでは、A/Bテストを実施する際にREPがどう動くかを説明します。 A/Bテストを開始するときの作業 A/Bテストの事前確認と結果分析の手順は、Claude Code向けのSKILLとして整備しています。事前確認のSKILLは、A/Bテストに必要なシステムとembeddingがサービング可能な状態になっているかを確認します。結果分析のSKILLは、テスト結果を集計し、リリース判断フローに基づいて判定し、分析レポートを作成します。 A/Bテストを開始するときに必要な作業は次の通りです。 challengerとするモデルを学習し、新しいモデル種別として登録する embedding生成ジョブでchallengerに対応するembeddingを生成して有効化する Claude Code向けのSKILLを使い、A/Bテストに必要なデータが揃っているかを確認する serving_config のYAMLへ ab_test セクションを追加し、プルリクエストでレビューを経てマージする マージ後、SKILLを使って対象サービスへ正しく配信されているかを確認する 手順4で編集するYAMLは次のような形です。オンライン推薦向けのVector Searchの設定など、説明に不要な項目は省略しています。 service_id : <service_id> default_model_type : tt-v1 ab_test : name : tt_v2_ab bucket : range : from : 0 to : 99 variants : - name : control model_type : tt-v1 ratio : 50 - name : treatment model_type : tt-v2 ratio : 50 default_model_type は、A/Bテストを実施していない期間と、 bucket.range の対象外のユーザーに配信するモデル種別です。 ab_test では、 bucket.range で対象とするユーザーバケットの範囲を、 variants でバリアントごとのモデル種別と配分比率を指定します。 ユーザーのバケットはユーザーIDと ab_test.name のハッシュ値から決定論的に計算しており、割り当てを保持するテーブルは持ちません。 ab_test.name をハッシュの入力に含めているため、同じ ab_test.name を複数の service_id のYAMLに設定すれば、サービスをまたいでも同じユーザーは同じバリアントに入ります。ただし variants の配分比率が揃っていないと、同じユーザーでもサービスによって別のバリアントに入ることがあるため、比率も揃える必要があります。 CIが serving_config をBigQueryへ同期した時点で、配信されるembeddingが切り替わります。この一連の作業の中で利用側のリリース作業は発生しません。 バリアントに応じたembeddingの返却 A/Bテストの実施中、TVFは次のように動作します。 get_user_embeddings はユーザーごとに割り当てられた1つのバリアントのembeddingだけを返す get_product_embeddings は全バリアントの商品embeddingを返す A/Bテストを実施していない期間は、TVFがすべての行を variant = 'default' として返します。 利用側は前述のクエリ例のとおり u.variant = p.variant で結合することで、ユーザーごとに正しいモデルの組み合わせでスコアを計算できます。異なるモデルのユーザーと商品のembeddingを掛け合わせてしまう事故は、 variant による正しい結合で防げます。裏を返せば、 variant で適切に結合することがREPを利用する上での契約条件です。 リリース判断フロー REPのA/Bテストは複数の機能に同時に影響するため、リリースの可否には全体と個別の両方の視点が必要です。サニティチェックとガードレールをパスしたことを確認したうえで、全体指標、推薦経由の指標の順に見て、有意な改善か悪化があればその時点でリリース可否を決めます。どちらにも変化がなければ、機能や画面単位の指標で判断します。複数のシステムから参照される基盤だからこそ、個別システムの増減を細かく追うよりも大局的な指標で意思決定することを重視して設計しています。REPの仕組み上はシステムごとにモデル種別を切り替えられますが、複数のモデルを運用し続けるとインフラコストがかかります。そのため、個別最適な状態を極力避ける意思決定フローにしました。 このリリース判断フローの実行とA/Bテストレポートの生成はClaude Code向けのSKILLで自動化しており、担当者に依存しない同じ分析手順で、A/Bテストごとの結果が得られます。 リリースを決定した場合は、 serving_config の default_model_type を新しいモデル種別に更新し、比較対象だった旧モデル種別のYAML登録を削除します。これにより、古いモデル種別をいつまでも稼働させ続ける状態を避けています。 信頼性の担保 モデル切り替えの原子性 embedding生成ジョブは日次で全ユーザー・全商品のembeddingを再生成し、新しいembeddingバージョンへ切り替えます。この切り替えで配信が一瞬でも壊れないよう、書き込み順序を次のように設計しています。 新バージョンのembeddingをテーブルへ書き込む embedding_status で新バージョンの有効化と旧バージョンの無効化を単一トランザクションで行う この書き込み順序とトランザクションにより「有効化されているのにembeddingが存在しない」「有効化されているバージョンが0件になる」という状態が発生しないことを保証しています。 embeddingの品質保証 REPで配信するembeddingはさまざまなシステムで利用されるため、その品質を保証することが必須です。REPでは日次の自動チェックで継続的に監視しています。監視内容は、行数・null率・ゼロベクトル率・鮮度・主キー重複といったデータの健全性と、 uniformityやalignment といったembeddingが表現力を保っているかを診断する指標です。これらを記録し、傾向の変化を確認します。さらに、embedding単体のチェックでは捉えられない劣化を検知するため、スコア分布の推移も日次で監視しています。 本番A/Bテストでの成果 REPを使った初回のA/Bテストとして既知のモデル実装バグを修正したchallengerモデルを用意し、2週間配信しました。対象はホーム画面のモジュール並び順パーソナライズなどREPを参照するバッチ推薦機能です。 初回のA/Bテストではモデルの大幅な性能向上よりも「REP上でA/Bテストを回しきり、リリース判断まで至れること」の実証を主目的としました。 結果と分析 初回のA/Bテストでは、サービス全体や推薦経由の指標を動かすほどの効果は見られませんでした。一方、機能単位の指標では一部で有意な改善を確認できました。あらかじめ判断基準を整備しておいたことで、全体指標で決着しない結果でも機能単位の指標へスムーズに判断を移せ、challengerモデルのリリースを決定しました。これにより、REP上でA/Bテストの実施からリリース判断まで一貫して行えることを実証できました。 改善サイクルの高速化 従来はA/Bテストのたびに利用側システムの実装変更や関係者との調整が必要で、モデルの改善案があってもテストの計画を立ち上げられずにいました。REPの導入後は、利用側システムを改修せずに複数の機能で同時にA/Bテストを実施できるようになりました。 関係者との調整も必要最低限になったことで、REPの本番導入から記事公開までの約2か月で4本のA/Bテストを実施できました。加えてA/Bテストの準備からレポート作成までの作業フローも標準化されているため、モデル改善のアイデアを持つMLエンジニアであれば誰でもA/Bテストを回せる環境になりました。 REPの価値は、すでに実績のあるモデル改善を他の機能へ展開できることにも表れています。マーケティングオートメーション(MA)関連の推薦を担当するチームは、商品画像のembeddingを特徴量に加えたモデル改善で、メール配信施策のクリック率・購入率・経由売上を有意に改善していました。詳細は 過去のテックブログ記事 で紹介しています。このモデルはすでにREPへ取り込み済みで、前述の4本のA/Bテストにも含まれています。1つのチームが実施した改善施策を、別のチームの機能にもそのまま届けられるようになりました。 今後の展望 REPによってembeddingの配信とA/Bテストは標準化できましたが、基盤としての真価はこれから発揮されると考えています。REPが配信するembeddingは、複数の推薦タスクに共通するFoundation Modelの役割を担えるはずです。今後はユーザーの長期的な嗜好や商品の汎用的な表現を獲得できるよう、最適化の方針を見直していきます。現時点ではREPを利用するシステムの多くが、embeddingの類似度だけでスコアリングしています。REPのembeddingを特徴量として扱い、システムのKPIに応じてembeddingを変換するquery encoderを用意することが、次の進化の方向性です。 また、現在のembedding品質保証では、データとしての健全性やembedding空間の構造は捉えられますが、推薦タスクへの適合度までは評価できていません。もちろん、利用先の機能側でKPIやモデルメトリクスをモニタリングすることが前提です。ただ、特徴量としてのユースケースが増えるほどembedding自体の良さも担保すべきだと考えており、この評価軸の整備が次のテーマです。 モデル改善のサイクルをさらに人手から切り離すことにも取り組んでいます。エージェントによる開発では、試行を繰り返すループと結果を客観的に検証できる仕組みの2つが必要で、MLモデルの改善サイクルはこの条件を満たしやすい領域です。参考になる事例として、YouTubeの Self-Evolving Recommendation System やMetaの Ranking Engineer Agent (REA) があり、私たちも同様にAIエージェントをベースとした実験基盤の構築を検討しています。REPの導入によって「良いモデルがあれば誰でもA/Bテストを実施できる」環境は整いました。アイデア出し、実装、実験と評価を繰り返すループのうち、少なくとも実装から実験と評価まではエージェントで実行できるため、まずはこの区間で人間が律速にならない状態を目指しています。 最後に 本記事では、ZOZOTOWNの推薦システムを支えるembedding配信基盤REPを紹介しました。1つの基盤でモデルの改善効果を複数の機能へ波及させ、その効果をA/Bテストで検証しながら改善サイクルを回し続けられる環境を作れたことが、REPの一番の成果だと考えています。ここからは配信基盤としての機能拡充にとどまらず、embeddingそのものの質を高める取り組みや、実験サイクルを自動で回し続ける仕組みづくりに力を入れていきます。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは。データシステム部・MA推薦ブロックの伊藤( @rabbit_x86 )です。私たちのチームでは、ユーザーに最適な配信を届けるために、LINE・Push通知・メールなどのマーケティングオートメーション(MA)に関する推薦システムを開発・運用しています。 本記事では、推薦システムの運用で発生するアラート対応をAIで自動化した取り組みを紹介します。人間の対応フローを「障害分析・対応の考案・敵対的レビュー・実行」の4つのタスクに分解してAIで再現し、対応にかかる工数とコンテキストスイッチを減らすことを狙います。本番環境で安全に動かすため、AIの権限と実行できる操作を段階的に絞り込むハーネスも組み込んでいます。この仕組みの導入後、約3週間で本番環境の8件のアラートをAIが自動で解決しました。 目次 はじめに 目次 背景と課題 アラート対応によるコンテキストスイッチ AIを活用しても残っていた課題 課題に対するアプローチ 業務内容を分解する AIに任せるタスクと人間に残すタスク アラート対応を自動化するシステム 既存の監視の仕組み システムの全体の流れ AIによる自律的なアラート対応の4つのタスク 敵対的レビューの設計 採用基準の明文化による改善 ハーネスによる安全設計 AIの操作が届く範囲を権限で絞る 実行できるコマンドをallowedToolsで限定する スクリプトで操作を細かく制御する 運用事例と効果 再実行で解決した事例 効果と課題 まとめと今後の展望 最後に 背景と課題 アラート対応によるコンテキストスイッチ 私たちのチームは、メール配信のコンテンツなどをパーソナライズするための推薦システムを作っています。この推薦システムは、 Agent Platform Pipelines (旧Vertex AI Pipelines)のパイプラインとして実装しています。パイプラインが失敗すると最新の推薦結果を配信できなくなるため、アラートには早急に対応する必要があります。 ただ、そのアラート対応は割り込みで発生し、その度に担当者は他作業を止めて対応に当たる必要があります。対応では、エラーログから原因を調査し、必要に応じて再実行や修正などの処置をします。このコンテキストスイッチにより、元の作業に使えたはずの時間は失われ、作業に戻るための切り替え時間も必要になります。 AIを活用しても残っていた課題 私たちはすでに、AIを活用してアラート対応を効率化していました。エラーログをAIに与えて原因を調査させ、対応方針を検討させるところまでは日常的に行っており、1件あたりの対応は楽になっていました。ただ、調査結果の確認や方針の承認、再実行や修正の判断は人間のままで、アラートのたびに時間と集中を割く構造は変わっていませんでした。 さらに、対応の証跡はSlackのスレッドに残す程度で、ナレッジとして蓄積されていないという問題がありました。そのため、同種のアラートでも過去の調査結果を再利用できず、同じ調査を繰り返していました。また、対応の進め方は担当者ごとの判断に任されており、質とスピードもばらついていました。 そこで、アラート対応の調査から実行までをAIが自律的に行い、その証跡を残して次の対応で参照できるシステムを構築することにしました。 課題に対するアプローチ 業務内容を分解する 業務をAIに任せるにあたり、まず業務内容を人間の判断や作業を抽象化したタスクへ分解することを考えました。その結果、どのような業務にも当てはまる「タスクの発見」から「知見の蓄積」までの7つの一般的な単位に整理できました。それぞれの内容は次のとおりです。 タスク 一般的な内容 タスクの発見 アラート・Issue・定常業務の中から、対応すべきタスクを見つける タスクの理解 背景・制約・完了条件を理解し、整理する 解決方法の考案・承認 解決方法を考え、実行するかを判断する 実行と再試行 解決方法を実行し、失敗した場合は考案からやり直す 最終確認 完了条件を満たしたかを自身で確認する 第三者確認・完了 担当者以外が内容をレビューし、完了を判断する 知見の蓄積 成功・失敗から得た学びを、次のタスクに活かす AIに任せるタスクと人間に残すタスク 次に、分解したタスクを1つずつAIに置き換えることで、AIが自律的に業務を進める状態を目指しました。この方法なら、AIへ任せる範囲を段階的に広げつつ、意思決定を人間に残す部分も選べます。どのタスクをAIに置き換えるかは、次のように決めました。 タスク アラート対応での作業 担当 理由 タスクの発見 アラートが鳴り、対応が必要なことを認識する 人間 AIに対応させるアラートの選別を人間が握るため タスクの理解 アラート対象のシステムとエラー内容を理解し、完了条件を整理する AI 対応の中核であり、人間の時間と集中を奪っていた部分のため 解決方法の考案・承認 過去の対応事例を参考に原因への対応方法を複数考え、最適な案を選ぶ AI 同上 実行と再試行 選んだ案を実行し、失敗した場合は別の案を試す AI 同上 最終確認 完了条件を満たしているかを確認する 人間 Issueに残る対応記録を見て、解決したことを人間が確認するため 第三者確認・完了 Pull Request(PR)のレビューを依頼して第三者に確認してもらい、完了を判断する 人間 完了の判断とIssueのクローズを人間が担い、責任の所在を人間に置くため 知見の蓄積 対応の成功・失敗を知識として蓄え、チームにも共有する AI GitHub Issueへのコメント蓄積を自動化するため このように、対応の開始と終了は人間が判断します。人間が関与しないと、仕組み自体が止まっていても気づけず、障害を放置してしまうためです。加えて、開始を人間が判断することで、対応不要な通知や検証用のアラートに自動対応が走ることも防げます。 アラート対応を自動化するシステム 既存の監視の仕組み 前提として、アラートの検知と通知の仕組みはすでに運用されていました。パイプラインは、 Cloud Scheduler によりスケジュール実行されています。その失敗は、 Cloud Run functions が実行状態のポーリングで検知し、パイプラインのURLとともにSlackのチャンネルへ通知します。従来は、この通知を見た人間がアラート対応を始めていました。 システムの全体の流れ アラート対応は、Slack通知を受けたところから、次の図の流れで進みます。図中の丸数字は、表のステップ1〜3に対応します。 ステップ 内容 担当 対応するタスク 1 Slack通知のリンクをクリックし、アラート情報が本文に事前入力された画面から専用ラベル付きのGitHub Issueを作成する 人間 タスクの発見 2 専用ラベル付きIssueの作成をトリガーに Claude Code Action (CCA)が起動し、障害分析・対応の考案・敵対的レビュー・実行の4つのタスクを順に実行して、結果をIssueにコメントする AI タスクの理解、解決方法の考案・承認、実行と再試行、知見の蓄積 3 対応の記録を確認し、解決していればIssueをクローズする 人間 最終確認、第三者確認・完了 ステップ1では、Slack通知の末尾にあるAuto-Triage欄のTriggerリンクからIssue作成画面を開き、Issueを作成します。実際のSlack通知は次のような形です。 開いたIssue作成画面では、画面右のLabelsに2つの専用ラベルが事前に設定されています。alert-auto-responseは、自動対応の起動トリガーとなるラベルです。pipeline-failureは、次回以降のアラート対応で過去の類似Issueを検索する際の目印となるラベルです。 ステップ2では、4つのタスクが順に実行され、分析内容・対応案・レビュー結果・実行結果といった各タスクの出力がIssueのコメントとして残ります。 ステップ3で何を確認してクローズするかは、実行タスクの対応内容によって決まります。再実行であれば、パイプラインの正常終了を人間が確認した時点でクローズします。修正PRであれば、担当者がPRの内容を最終確認し、さらにレビューによる第三者確認を経てからクローズします。 対応を進めて記録を残す場所をGitHub Issueにしたのは、チームの タスク管理とAI活用による工数削減の計測 をIssueで行っており、アラート対応も同じ管理に載せたかったためです。対応の記録がIssueに蓄積されるため、次のアラート対応でAIが過去の類似Issueを検索して再利用できる利点もあります。AIの実行環境であるCCAは、AIコーディングエージェントである Claude Code を GitHub Actions のワークフローとして実行するための公式アクションです。Issueの作成をトリガーに起動できることと、チームの運用実績があることから選びました。 AIによる自律的なアラート対応の4つのタスク アラート対応は「障害分析・対応の考案・敵対的レビュー・実行」の4つのタスクによって構成され、Issueの作成をトリガーに起動した1回のCCAの実行の中で順に進みます。各タスクは結果をIssueのコメントとして出力し、次のタスクはそのコメントを入力として受け取ります。 各タスクは、 skill と呼ばれるMarkdownの指示書として定義しています。全体像は次のとおりです。 図の各タスクが行うことは、次のとおりです。 タスク skill 行うこと 障害分析 diagnose-pipeline-failure パイプラインの実行情報・エラーログ・実装コードを読み、問題・原因・解決策の候補を洗い出す 対応の考案 propose-pipeline-remedies 分析結果をもとに、再実行や修正PRの作成といった対応案を根拠つきで挙げる 敵対的レビュー review-pipeline-remedies 対応案の粗を意図的に探して審査し、実行案を承認する。※ Claude Codeの ベストプラクティス で推奨されているレビュー方法 実行 review-pipeline-remedies 承認された案を実行する なお、CCAが実行するClaude Codeへ渡すプロンプトには、各タスクの内容を直接書かず、skillを順に呼び出す指示だけを書いています。実際のプロンプトは次のとおりです。 以下の skill を順に実行してください: 1. /diagnose-pipeline-failure <Issue番号> 2. 1 がエスカレーション (needs-human) で終了した場合はここで終了。 そうでなければ /propose-pipeline-remedies <Issue番号> 3. /review-pipeline-remedies <Issue番号> 各 skill の進め方・出力フォーマット・エスカレーション基準はすべて skill 側に定義されています。 プロンプトにあるエスカレーションは、AIが対応を打ち切って人間へ引き継ぐ終了経路です。エラーログを取得できない場合や、上流チームとの調整や課金に関わる変更のように人間の判断が必要な場合、AIはタスクを問わずエスカレーション用のラベル(needs-human)を付けて対応を停止します。 敵対的レビューの設計 敵対的レビューの設計は、Claude Codeのベストプラクティスにある Add an adversarial review step の項を参考にしました。この項では、レビュアーへ成果物と判定基準だけを渡し、そこに至る経緯や背景といったコンテキストの影響を受けないレビューが推奨されています。 そこで、案を考える「対応の考案」と案を承認する「敵対的レビュー」を別のタスクに分けました。敵対的レビューには、元の会話とは別のコンテキストで動くエージェントである subagent を使います。このsubagentに渡すのは、Issueに書かれた障害分析コメントと対応案コメント、そしてレビューの指示の3つだけです。この指示は案を中立に評価させるのではなく、否定する理由を探させる敵対的な内容にしました。 採用基準の明文化による改善 ただし、この敵対的レビューは最初から狙いどおりに機能したわけではなく、本番導入前の検証では採用すべき案が却下され続けました。たとえば、修正PRを作成する案に対して、敵対的レビューは「修正が本番環境へ影響を与えないか確認できない」という懸念を挙げて却下しました。修正PRはdraftでのみ作成され、マージも人間が担うため安全なはずの案でも、こうした曖昧な懸念による却下やエスカレーションが続きました。 実際、 Add an adversarial review step には、粗を探すよう指示されたレビュアーは成果物に問題がなくても何かを報告する、という注意書きがあります。 A reviewer prompted to find gaps will usually report some, even when the work is sound, because that is what it was asked to do. そこで、採用の基準を明確に定めました。却下してよいのは、次の2つを両方示せた場合だけです。 案を実行した場合に本番環境へ与える悪影響といった、実害が発生するシナリオを具体的に示せること そのシナリオが、後述するハーネスの制約で防げず実際に起こりうると示せること どちらか一方でも示せなければ、承認します。実際にレビューの指示へ書いた判定手順は次のとおりです。 1. この案を実行した場合に起こりうる、具体的な実害シナリオを考える (「念のため」「本番影響が心配」は実害シナリオではない) 2. そのシナリオが、再実行の上限・draft強制・人間によるマージ・操作対象の限定(同一パイプライン・対象リポジトリのみ)の いずれによっても防がれないことを示す 3. 1と2の両方を具体的に示せた場合のみ却下する。示せなければ承認する 4. 例外として、実行手順自体に技術的な矛盾がある場合は、その矛盾を指摘したうえで却下してよい 5. 「前提条件が完全には確認できていない」など、案の安全性と無関係な懸念は単独では却下根拠にならない 6. エスカレーション案を承認するのは、他のすべての案が却下された場合のみ これによって、直後の検証ではAIがパイプライン内のバグを修正するPRの作成まで完了できました。敵対的レビューを機能させるには、否定する理由を探させるだけではなく、却下する際の基準を設けることが重要でした。 ハーネスによる安全設計 アラート対応をAIが自律的に進めるには、Google CloudやGitHubといった外部サービスへアクセスする権限をAIに与える必要があります。ただ、与えた権限は安全に扱わせなければなりません。ここで鍵になるのがハーネス(harness)です。ハーネスとは、AIモデルの外側にあって、モデルが自律的かつ信頼できる形で仕事をこなせるようにする仕組み全体のことです。具体的には、使えるツールや権限の定義・実行環境・コンテキストの引き継ぎ・テストやレビューによるフィードバックループ・ルールの機械的な強制などを指します。 OpenAI や Anthropic も、エージェント設計の文脈でハーネスを整理しています。本節では、このうち安全に関わる部分である、権限とツールの制限、そしてスクリプトによるルールの強制を説明します。 今回、AIが外部に対して行う操作は、次の3つです。 目的 操作 タスク 種別 エラーの調査 パイプラインの実行情報・エラーログ・実装コードの取得 障害分析 読み取り(Google Cloud・GitHub repository) 対応の証跡の記録 Issueへのコメント投稿・ラベル操作 すべてのタスク 書き込み(GitHub Issue) 対応の実行 パイプラインの再実行・修正PR(draft)の作成 実行 書き込み(Pull Request・Google Cloud) これらの操作を、3つの段階に分けて安全に扱っています。 AIの操作が届く範囲を権限で絞る 1つ目の段階は、AIに与える権限です。CCA専用の認証の仕組みを用意し、そこへ必要最小限の権限だけを付与しています。具体的には次のとおりです。 対象 認証に使う仕組み 付与した権限 Google Cloud Service Account パイプラインジョブの参照・作成とログの読み取りのみ GitHub GitHub App Issueへの書き込み(コメント投稿・ラベル操作)、リポジトリの読み取り、修正PR作成に必要なブランチ・PRの作成のみ この権限により、AIはService Accountを通じたパイプラインの再実行や、GitHub Appを通じたIssueへの書き込みなどを実行できます。一方で、権限を絞るだけでは不十分です。GitHub Actionsの実行環境には、Service AccountとGitHub Appの認証情報が載っています。そのため、gcloudコマンドやcurlを使えば、AIは権限の範囲内のAPIを直接呼び出せてしまいます。たとえばジョブの作成権限を使えば、失敗したジョブをそのまま複製するのではなく、パラメータやコンポーネントを書き換えた別のジョブを本番環境で起動できてしまいます。 実行できるコマンドをallowedToolsで限定する この権限の範囲内の操作を縛るために、2つ目の段階では実行できるコマンドを限定します。CCAのワークフローでは、実行を許可するツールを allowedTools という設定で列挙し、列挙していないコマンドは権限があっても実行できません。シェルの任意コマンド、gcloudやbqコマンド、curl、生のAPI呼び出しはここで塞がれます。 実際のワークフローに書いている設定の一部は次のとおりです。 claude_args : | --allowedTools 'Bash(python3 .claude/skills/diagnose-pipeline-failure/scripts/fetch_pipeline_job.py:*)' 'Bash(python3 .claude/skills/diagnose-pipeline-failure/scripts/fetch_error_logs.py:*)' 'Bash(python3 .claude/skills/diagnose-pipeline-failure/scripts/fetch_repo.py:*)' 'Bash(python3 .claude/skills/review-pipeline-remedies/scripts/rerun_pipeline.py:*)' 'Bash(python3 .claude/skills/review-pipeline-remedies/scripts/create_fix_pr.py:*)' 'Bash(gh issue view:*)' 'Bash(gh issue comment:*)' 許可しているのは、スクリプトと、Issueのコメントを読み書きする GitHub CLI のgh issue系サブコマンドだけです。このスクリプトの中身を、次の3つ目の段階で説明します。 スクリプトで操作を細かく制御する さらに3つ目の段階として、スクリプトで実行内容を細かく制御しています。コマンドを何回・どの対象に使えるかという条件を、スクリプトのコードへ埋め込んでいます。スクリプトを使うタスクと内容は次のとおりです。 種別 スクリプト 使うタスク 内容 読み取り fetch_pipeline_job.py 障害分析 対象の失敗ジョブに限定して、詳細(失敗タスク・エラー)を読み取る 読み取り fetch_error_logs.py 障害分析 対象の失敗ジョブのエラーログだけを、 Cloud Logging から整形して読み取る 読み取り fetch_repo.py 障害分析 パイプラインを実装しているリポジトリに絞って、コードを読み取り専用で取得する 書き込み rerun_pipeline.py 実行 失敗したジョブの複製のみを、同一パイプラインに2回まで再実行する 書き込み create_fix_pr.py 実行 修正PRをdraftでのみ作成する このうちrerun_pipeline.pyは、失敗したジョブの設定を複製し、新しいジョブとして再実行するスクリプトです。複製したジョブにはrerun_count=1のような再実行の回数を表すラベルを付け、次の再実行でその値を読み取って上限を判定します。具体的には次のように、上限の2回に達していれば実行前に拒否し、エスカレーションへ進ませます。 MAX_RERUN_COUNT = 2 rerun_count = int (source_labels.get( "rerun_count" , "0" )) if rerun_count >= MAX_RERUN_COUNT: print ( f "REJECTED: rerun limit reached (rerun_count={rerun_count}). " "これ以上の自動rerunは禁止。needs-humanでエスカレーションしてください。" , file=sys.stderr, ) return 1 このように、条件を満たさない実行はAIの判断にかかわらずスクリプトがエラーで拒否し、エラーを受け取ったAIはエスカレーションなどの別の対応へ進みます。 権限が「どこまで届くか」を、allowedToolsが「どのコマンドを実行できるか」を、スクリプトが「どの条件とどの対象なら操作してよいか」を制限します。この3つの段階を重ねることで、本番環境でAIが暴走しないように設計しています。 運用事例と効果 ここでは、本番環境でAIが解決したアラートのうち、再実行で解決した1件の事例を紹介します。 再実行で解決した事例 対象のアラートは、Google Cloud側の一過性のエラー(internal error)でパイプラインが失敗したものです。このとき各タスクが実際に行った対応は次のとおりです。 タスク 内容 障害分析 エラーメッセージは「Internal error happened.」のみで、詳細なログを取得できないことを確認。これを一過性のエラーの兆候と推定し、原因としてIssueにコメント 対応の考案 分析コメントを受けて過去の類似Issueを検索し、同じパターンの失敗が再実行で復旧している実績を発見。これを根拠に再実行案を提案 敵対的レビュー 提案された再実行案を審査し、ハーネスの制約で防がれない実害シナリオがないことを確認して承認 実行 承認された案をハーネス経由で再実行し、パイプラインは正常に完了 一連の対応は、Issueの作成から再実行の開始まで約8分で完了しました。人間が行っていた「ログを見て、過去の事例を思い出し、再実行で直りそうだと判断する」という思考の流れを、AIがそのまま再現できました。 効果と課題 本システムの目的は、アラート対応のたびに発生していたコンテキストスイッチと対応の時間を、AIで減らすことでした。ただ、この効果は本番環境でまだ実証できていません。 実証できたのは、アラート対応の自動化です。導入から約3週間で8件のアラートをAIが解決し、調査から実行までを人間が担わずに完了できました。また、対応は毎回同じskillの手順で進むため、担当者ごとに異なっていた進め方も統一されました。ただし、8件はいずれも再実行で解決できる、もともと対応コストの小さい障害でした。そのため、導入の前後でコンテキストスイッチや対応の工数の変化は小さいです。 本システムの真価が出るのは、調査や判断に時間のかかる障害です。本番環境ではまだこの種のアラートは発生していませんが、本番導入前の検証では、パイプライン内のバグに対して原因の特定から修正PRの作成までをAIが完了できています。ただ、この種のアラートは頻発せず、導入から日も浅いため、本番環境での効果の実証がこれからの課題です。 まとめと今後の展望 本記事では、推薦システムのアラート対応を題材に、人間の業務をAIへ置き換えた取り組みを紹介しました。ポイントは2つです。 業務を人間の思考フローに沿って分解してAIで再現することで、どこをAIに任せてどこを人間に残すかをタスク単位で設計する 権限・allowedTools・スクリプトの3段階からなるハーネスでAIに制約を与えることで、本番環境でも安全に実行させる 一方で、運用を通じて課題も見えてきました。今後は次の2点に取り組みます。 調査や判断に時間のかかる障害への対応で、本システムによるコンテキストスイッチと対応時間の削減を定量的に実証する 人間が担っている対応の開始と終了まで自動化を広げ、アラート対応をAIに完全移譲する アラート対応は一例にすぎません。ほかの業務でも、フローを分解して一つひとつAIに置き換えていけば、AIが自律的に業務を進める状態へ近づけるはずです。業務をAIに任せることを考えている方の参考になれば幸いです。 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、MA部MAシステム開発ブロックのまんさくです。普段はマーケティングオートメーションシステムの開発・運用を担当しています。 ZOZOTOWNでは、アプリのPush通知やLINE、メールでキャンペーンを配信しています。MA部は、これらの配信を担うマーケティングオートメーション(MA)システムを開発しています。 配信には大きく2種類あります。特定の会員セグメントへ一括で送る「バッチ配信」と、会員一人ひとりの行動に合わせて送る「パーソナライズ配信」です。本記事で扱うのは後者です。その配信可否を決める前段階のジョブについて、DB・ジョブ起動基盤・メッセージングの3つの層で改善に取り組みました。うまくいった2つの改善と、検証の結果見送った1つの改善を、判断の根拠とあわせて紹介します。 次のような方に読んでいただければと思います。 PostgreSQLで更新の多いテーブルのbloatやパフォーマンス劣化に悩んでいる方 Cloud Tasksを使ったシャード分割処理の構成と流量制御を検討している方 Pub/Subのordering keyとスケールの関係、特にカーディナリティを上げる際のトレードオフを知りたい方 目次 はじめに 目次 背景・課題 1. 過剰な書き込みを削減する 何が起きていたか なぜ深刻だったか autovacuumの設定と限界 対策1:書き込みを減らす 対策2:インデックスを小さくする ここまでの効果と残る課題 対策3:収集の起点を小さいテーブルへ移す 2. Cloud SchedulerジョブをCloud Tasksへ移行する 最初はシンプルなScheduler増加を選んだ Cloud Tasksへの集約 タイムアウトと運用上の注意 3. Pub/Sub ordering keyのカーディナリティを上げる試み なぜordering keyを使っているか 高カーディナリティ化の狙い 受信側はスケールしたが、送信側が重い 見送りの判断と残る選択肢 まとめ 背景・課題 パーソナライズ配信では、会員ごとの配信候補を収集する処理と、候補を最適化して配信判断へ渡す処理が連携して動きます。ここでの最適化は、会員ごとに配信する時間帯を調整したり、Push通知・LINE・メールのどのチャネルで届けるかを選んだりする処理です。基盤の全体像とリプレイスの経緯は、次の記事の「リプレイス後のアーキテクチャ」で紹介しています。 techblog.zozo.com この基盤はGoで実装し、Google Cloud上で動かしています。DBはAlloyDB、ジョブの起動にはCloud Scheduler、処理間の連携にはPub/Subを使っています。 負荷を分散するため、会員IDを100で割った余りによって100グループに分け、それを2グループずつまとめた50シャード単位で毎分処理しています。 この構成で運用を続けるなかで、3つの層それぞれに課題が表面化しました。本記事で扱う内容を先にまとめます。 層 課題 対策 結果 DB(AlloyDB) 実際には配信しない候補まで書き込み・更新を繰り返し、テーブルとインデックスが肥大化 書き込み回数の削減とインデックス再設計 採用 ジョブ起動(Cloud Scheduler) 50個のジョブがシャード範囲を直書きしており、流量調整も監視も煩雑 Cloud Tasksへ集約 採用 メッセージング(Pub/Sub) ordering keyがシャードと結びつき、下流の並列度がシャード数で頭打ち ordering keyを会員ID単位へ細分化 見送り 以降では、それぞれの対策を紹介します。 1. 過剰な書き込みを削減する 1つ目は、配信候補テーブルへの過剰な書き込みの削減です。 何が起きていたか ここまで配信候補テーブルと呼んできたものは、AlloyDB(PostgreSQL互換)上の offer_candidates テーブルです。最適化の過程で扱う候補を1行ずつ持ち、実際には配信しない判定で終わる行も多いため、行数が膨らみます。処理状態や配信時刻に加えてパーソナライズ判定用の項目も持つため、1行あたりのサイズも大きめです。 問題の中心は、候補の配信時刻を付け直す処理でした。同一の会員・チャネルに紐づく複数行へ、同じ内容のUPDATEを繰り返し発行していました。本来まとめられるはずの更新が、処理のたびにばらばらと走っていました。書き込みが必要以上に増え、テーブルとインデックスのbloat、HOT更新の効きにくさ、VACUUMの遅延が重なっていました。 ここでいうbloatとは、削除・更新によって使われなくなった領域がファイル内に残り、実データの量に対してテーブルやインデックスのサイズが膨らんだ状態を指します。PostgreSQLでは更新のたびに古い行が残るため、回収が追いつかないと領域が積み上がっていきます。 なぜ深刻だったか PostgreSQLではUPDATEは実質的に「新しい行バージョンの追加」です。古いバージョンはVACUUMが回収するまで残ります。更新列がインデックスに含まれるとHOT(Heap-Only Tuple)更新も効きにくく、インデックス側にもエントリが増えます。大きなインデックスが複数ある状態では、1回のUPDATEのコストも、その後の回収コストも両方上がります。 回収をさらに難しくしていたのが、リードレプリカ参照です。ここで効いてくるのが hot_standby_feedback というパラメータです。onにすると、レプリカは「いま自分が読んでいる最も古い行」をプライマリへ伝え、プライマリはその行を回収せずに残します。レプリカ上のクエリが途中で消えた行を参照して失敗する事態を防ぐ仕組みです。 このパラメータをoffにはできません。offにすると、プライマリのVACUUMとレプリカ上の長いクエリが衝突し、キャンセルが多発して処理が進まなくなるためです。onのままにすると、レプリカで長いクエリが走っているあいだプライマリのVACUUMが死んだ行を回収しにくくなります。 どこまで回収できるかはxmin horizonという基準で決まります。実行中のトランザクションが見ている可能性のある行は、まだ回収できないためです。この基準を押し戻しているセッションを1日分記録してみると、最も多く現れていたのはレプリカ側の処理ではなく、収集処理そのものでした。2分を超えるトランザクションがシャードごとに並走し、そのあいだは死んだ行を回収できません。つまり書き込みを減らすことは、生成されるデッドタプルの量を減らすだけでなく、回収を妨げる時間そのものを縮めることでもありました。 bloatの影響は読み取りにも及びます。 EXPLAIN にIndex Only Scanと出ていても、Heap Fetches(ヒープへの参照)が多ければインデックスだけでは完結せず、本体ページを大量に読みます。実効コストはSeq Scanに近づきます。bloatが進むとvisibility mapが効きにくくなるため、Index Only Scanを選んでいてもHeap Fetchesが増えやすくなります。 これらは独立した問題ではなく、互いを悪化させる循環になっていました。 autovacuumの設定と限界 デッドタプルの回収は主にautovacuumに依存しています。 offer_candidates には、デフォルトより早め・強めに動かす設定を入れています。 ALTER TABLE offer_candidates SET ( autovacuum_vacuum_scale_factor = 0 . 01 , autovacuum_analyze_scale_factor = 0 . 01 , autovacuum_vacuum_cost_limit = 1000 ); VACUUMの起動判定は、おおむね次の式です。 autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × ライブ行数 thresholdのデフォルトは50、scale_factorのデフォルトは0.2です。scale_factorを0.01に下げているため、デッドタプルが行数の約1%に達したあたりでVACUUMが検討されます。デフォルトの約20%まで待たずに動き始める、という意図です。analyzeも同様に0.01へ下げ、統計情報の更新を早めます。 autovacuum_vacuum_cost_limit = 1000 は、1回のコスト計算あたりに許す仕事量の上限です。値を大きくすると、スリープを挟む前により多く進めるため、VACUUM自体は積極的になります。その分、稼働中のI/O負荷も上がりやすい点には注意が必要です。あわせて、プライマリ側では autovacuum_work_mem を大きめに取り、VACUUM中のメモリ不足で効率が落ちにくいようにしています。 それでもautovacuumには限界があります。テーブル本体のVACUUMに付随するインデックス掃除は、基本的に直列で進みます。手動の VACUUM ならインデックス掃除を並列化できますが、日常運用の主戦場であるautovacuumではそれが使えません。インデックス本数が多いほど1回のautovacuumが長引き、その間に次のデッドタプルが溜まりやすくなります。 そのため、書き込み回数を減らす、インデックスを必要最小限にする、HOTが効く形へ寄せる、という3方向で改善を進めています。前の2つは適用済みで、最後の1つは収集起点を移す構成変更として進行中です。 対策1:書き込みを減らす まずアプリケーション側で、書き込み回数そのものを減らしました。方針は次のとおりです。 配信時刻の付け直しは、会員とチャネルの組ごとに必要な分だけ行う。同じ組に複数行あっても、何度も書き換えない まだ処理すべきでない組は、その回の対象から外す。途中段階で何度も同じ更新を繰り返さない 配信対象外と判定できる処理を後工程から前工程へ移し、不要な行のINSERT自体を抑える。後からDELETEするより、そもそも書き込まない方がDB負荷は小さい 処理の流れは、まず配信対象になりうる会員を探し、その後に更新対象の候補行を拾う、という二段構えです。会員抽出側の条件も同じルールに揃えています。 対策2:インデックスを小さくする 書き込み削減とあわせて、インデックスも見直しました。前述のとおり、autovacuumではインデックス掃除が直列に進むため、更新の多いテーブルでは本数そのものが運用コストになります。 まず未使用・非効率だったインデックスを3本削除しました。そのうえで、参照頻度の高い状態の行だけを対象とする部分インデックスを2本追加しています。なお本記事では、DDLやインデックス定義の列名を、役割が伝わりやすいものに置き換えています。 会員IDと配信時刻で探すクエリ向け: (member_id, due_at) シャード範囲と配信時刻で会員を拾うクエリ向け: ((member_id % 100), due_at) に必要列を INCLUDE したカバリングインデックス どちらも WHERE status = 'waiting' 相当の述語付きです。適用後の EXPLAIN では、主要な2本のクエリがこれらのインデックスを使うことを確認しました。 インデックスのサイズは次のように変わりました。 見直し前 見直し後 収集用インデックスの合計 約23GB(3本) 約9.6GB(2本) 主キー 7,046MB 7,046MB UUIDのユニーク制約 7,676MB 7,676MB 見直しの対象外である主キーとユニーク制約のサイズが変わっていないので、テーブル全体の規模が大きく動いたわけではありません。そのうえで収集用のインデックスだけが3分の1近くまで小さくなりました。特に大きかったのは、状態と配信時刻を含む1本が単独で14GBを占めていたことです。 運用上の注意点も2つあります。 1つ目は、想定したインデックスが選ばれないケースです。ユニークキーでの探索を期待していても、plannerが別プランを選ぶことがあります。部分インデックスの述語と、実際のWHERE句・統計情報の組み合わせを見ながら調整が必要でした。 2つ目は、スキーマ管理にpsqldefを使う場合の差分です。ステータスは可読性のため文字列で持っています。その結果、部分インデックスの述語はPostgreSQLカタログの正規化形で書く必要があります。 status = 'waiting' と書くだけでは足りず、 status::text = 'waiting'::text のように書かないと毎回DROP / CREATEの差分が出続けます。数値やenumならこの種の差分は出にくい一方、ログやアドホックな調査では文字列の方が追いやすい、というトレードオフです。 ここまでの効果と残る課題 ここまでの対策だけでも、現在の運用上はSLAを担保できる状態になりました。一方で、前述のリードレプリカ制約により、根本のbloat要因は残っています。回収できないまま書き込みが続くと、ファイルの末尾が伸びていきます。VACUUMがファイルを小さくできるのは、末尾のページがまとめて空いたときだけです。このテーブルでは末尾に新しい行が入り続けるため、その状態にはほとんどなりません。途中にできた空きは次の書き込みに再利用されるだけで、ファイルサイズは下がりません。テーブルとインデックスのサイズは、「回収が間に合わなかった時点の最大値」で切り上がっていきます。 実測で課題になっているのが、先に追加した部分インデックス2本の肥大と、VACUUM自体の長時間化です。この2本は、状態が waiting の行だけを対象にして小さく保つ意図でした。ところが先ほどの約9.6GBは均等な内訳ではなく、カバリングインデックス側だけで8GBを超えています。配信を待っている行の実数から考えれば、本来これほど必要ありません。回収が追いつかず、過去の肥大が切り上がったまま残っている状態です。肥大したインデックスはスキャンを遅くします。そして遅くなったクエリがさらにVACUUMを止めるため、悪化が加速します。Index Only Scanに見えてもHeap Fetchesが増える状態も、この連鎖の一部です。加えて、今後もデータの更新量はさらに増える想定であり、いまのままでは余裕が足りません。 対策3:収集の起点を小さいテーブルへ移す そこで、収集の起点を、調べに行く会員だけを持つ小さな「きっかけテーブル」へ移す構成変更に取り組んでいます。 候補本体の大きな行はそのまま残します。きっかけテーブルは会員を探すきっかけだけを持ち、収集対象かどうかの判定は、従来どおり候補本体側で行います。早すぎる行や古い行が残っても空振りするだけで済むよう、意図して選んでいます。 きっかけテーブルには、候補を書き込むときに同時に行を登録します。候補の配信時刻を付け直したときも、既存行を書き換えるのではなく、新しい時刻の行を別途登録します。起点のテーブルまで頻繁に更新していては、bloat対策として本末転倒だからです。古い時刻の行は空振りになるだけで、新しい時刻の行が収集を起こすため、配信を取りこぼすことはありません。 収集処理が読んだ行は、結果に応じて次のように扱います。 候補の処理に成功した行は削除する 失敗した行は残し、次の実行で拾い直す 候補が見つからず空振りした行は、レプリカ遅延の許容内なら残す。許容を超えたら恒久的に不要とみなして削除する 失敗や許容内の空振りを残すのは、毎分全件を見ていたころの自己修復性を保つためです。逆に許容を超えた空振りを消すのは、きっかけテーブルが空振り行で膨らみ続けるのを防ぐためです。 削除するときは、読んだときの登録時刻と一致する行だけを消します。収集処理が動いているあいだに同じ会員・時刻の候補が追加されると、その行の登録時刻は新しくなります。その行は削除条件から外れるので残り、追加された候補は次の実行で拾われます。登録時刻を見ずに消すと、読んだ後に増えた候補を起こすものがなくなります。 ここまでの動きを支える列は、次の4つだけです。 CREATE TABLE offer_candidate_due_members ( member_group smallint NOT NULL , due_at TIMESTAMP WITH TIME ZONE NOT NULL , member_id bigint NOT NULL , created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP , PRIMARY KEY (member_group, due_at, member_id) ); 主キーは (member_group, due_at, member_id) の順にしています。収集処理は担当するグループを等価条件で列挙して引きます。この形なら、各グループの先頭から「いま処理すべき時刻」まで読んだところで走査が止まります。先頭列を範囲条件にすると、B-treeは due_at で打ち切れず、未来の行まで毎回なめてしまいます。収集はこの主キーだけで足りるので、専用のインデックスは持ちません。 このテーブルにも配信候補テーブルと同じautovacuum設定を入れています。全行が1日で「INSERT →(再登録があれば登録時刻のUPDATE)→ 読まれる → DELETE」を辿るため、1日の終わりには中身のほとんどがデッドタプルになるからです。 そのため fillfactor も80に下げています。HOT更新は同じページ内に新しい行バージョンを置く余白を必要とするので、その分を空けておきます。1行が細くページの埋まりやすいテーブルなので、配信候補テーブルより効きやすい調整です。 収集起点が移ることで、配信候補テーブル側のインデックスは次のように変わります。太字がbloatの主因でした。 変更前 変更後 主キー id id ユニーク uuid uuid 収集用 (member_id, due_at) WHERE status='waiting' (member_id) 収集用 (member_id % 100, due_at) WHERE status='waiting' 削除 部分インデックス2本を削除でき、更新の対象になる due_at と status はどのインデックスにも含まれなくなります。これで状態遷移や時刻の付け直しがHOT更新の候補になります。HOTの成立には同じページ内に新しい行バージョンを置く空きも必要なため、すべての更新がHOTになるわけではありません。それでも、HOTになった更新はインデックスにdead entryを追加しないため、VACUUMが回収すべき仕事量は減っていきます。 2. Cloud SchedulerジョブをCloud Tasksへ移行する 2つ目は、ジョブ起動基盤の改善です。 最初はシンプルなScheduler増加を選んだ 配信ジョブの性能を上げるため、処理をシャード分割して分散させる方針を取りました。リリースを優先し、まずはCloud Schedulerのジョブをシャード数分用意して、各ジョブが配信候補の収集処理を直接呼ぶシンプルな形にしました。 立ち上げとしては合理的でしたが、運用が続くと次のつらさが出てきました。 シャード分割や流量制限を変えるたびに、Schedulerジョブの作り直しが必要になる 流量を落としたいときに、ジョブを手動でenable / disableする手間がかかる Schedulerが50本あると、監視もコンソール上の見通しも悪くなる 導入してしばらくは、障害時にPagerDutyのアラートもシャード数分飛び、Ackするだけで手が塞がった 通知の爆発については、Schedulerが50本のままでも、PromQL側でまとめて1通知にするよう監視を直して吸収しました。ジョブ自体の集約とは別に、先に運用面の痛みを下げました。 Cloud Tasksへの集約 そこで、Cloud Schedulerは毎分1回だけ振り分け用のエンドポイントを呼ぶ形に変えました。振り分け側は会員IDの剰余空間(現状は100分割)を shardSize 刻みに切り、各シャードの収集処理をCloud Tasksキューへ積みます。Tasks側のタスクが、実際の収集処理をOIDC付きで呼び出します。 流量制御はキュー側に集約できます。分割粒度( shardSize )はSchedulerのクエリパラメータで渡せるため、アプリの再デプロイなしに変えられます。剰余の母数そのもの(100)は、配信候補テーブルのインデックス定義( member_id % 100 )に埋め込まれているため、簡単には変えられません。 採用している主なパラメータは次のとおりです。 項目 値 役割 Scheduler 毎分1ジョブ / attempt_deadline=60s 振り分けだけを起動 shardSize 2(現状) 100空間を50タスクに分割 max_concurrent_dispatches 50 同時に走る収集処理数の上限。負荷調整時はここを下げる max_dispatches_per_second 500 秒間の起動上限 リトライ max 4回、backoff 1s〜10s 一時失敗の再試行 enqueue並行度 50(ベストエフォート) 全シャードのenqueue完了を待って2xx。設計上は通常1秒未満 Cloud Traceで1週間分の実行を見ると、振り分け処理の所要時間は大半が1秒未満で、まれに数秒かかることがあります。Schedulerの attempt_deadline は60秒なので、この程度のばらつきは吸収できます。ただし振り分けが遅れた分だけ収集処理の開始も遅れるため、極端に長い場合は監視で気づけるようにしています。 振り分け側では重複防止をしません。毎分必ずタスクを積み、二重に来た分は収集処理側の実行中ガード(HTTP 204)で吸収します。enqueueに失敗したシャードは、次の毎分サイクルで再度積まれる前提です。 監視も、Scheduler 50本それぞれの状態を見る形から次の観点へ整理しました。 振り分けジョブ自体の未起動 / 実行失敗 収集処理のシャード別連続エラー enqueue失敗(ベストエフォートで2xxを返すため、別途必須) Cloud Tasksキューの滞留 収集処理のジョブSLA超過 運用開始後は、キュー滞留アラートの継続時間を収集処理のSLA(20分)に揃えたり、一過性のenqueue失敗で誤発火しないよう条件を入れたりと、閾値のチューニングも続けています。 タイムアウトと運用上の注意 注意したいのが、Cloud TasksからCloud Runの収集処理を呼ぶときのタイムアウトです。Cloud Tasks側の dispatch_deadline と、Cloud Run側のリクエストタイムアウトは揃える必要があります。 Cloud TasksのGoクライアントのリファレンス にも、 dispatch_deadline はアプリハンドラのタイムアウトより数秒長い程度に設定する、という推奨があります。App Engineターゲット向けの記載ですが、期限が切れるとCloud Tasksが応答を待つのをやめて再試行する動きは共通です。Cloud Runを呼ぶ場合にも同じ理屈が当てはまります。Cloud Tasks側だけ先に切れると、Cloud Run上では処理が続いているのにTasksが失敗とみなして再送し、二重実行の温床になります。 ただし、期限を揃えれば二重実行がなくなるわけではありません。Cloud Runはタイムアウトで504を返した後も、ハンドラの処理がすぐに止まるとは限らないためです。重なった実行を最終的に吸収するのは前述の実行中ガードです。期限合わせは、無駄な失敗判定と再送を減らすための調整です。収集処理のように時間が伸びうる処理では、この関係を意識して設計する必要があります。 shardSize の変更にも注意が必要です。範囲の重なる新旧のタスクが並走しないよう、振り分けを一時停止し、キューが空になるのを確認します。キューが空でも、前述のとおり収集処理が続いていることはあり得ます。実行中ガードはシャード範囲を単位にしているため、範囲の形が変わると旧タスクの抑止には使えません。そのため、走っている収集処理が終わるまで待ちます。 下流の最適化処理が終わるのも待ちます。収集処理から送るメッセージの順序も、シャード範囲を単位にしているためです。範囲が変わると同じ会員のメッセージが別の単位で流れるので、前の範囲を処理し終えてから変更します。 3. Pub/Sub ordering keyのカーディナリティを上げる試み 3つ目は、Pub/Subのordering keyに関する改善です。こちらは実装・検証まで進めたうえで、採用を見送った事例です。 なぜordering keyを使っているか ordering keyは、パーソナライズ配信の整合性を保つために使っています。同一会員の候補を順序付きで扱う必要があるためです。 当初は複数会員の候補を1メッセージにまとめていたため、ordering keyをシャード範囲と結びつける必要がありました。その結果、受信側の並列度がシャード数で頭打ちになっていました。前章の shardSize 変更で、下流の処理完了まで待つ必要があるのも、この結合が理由です。 高カーディナリティ化の狙い 並列度を上げるため、次の方針で実装して検証しました。 メッセージを細かくし、ordering keyをシャード範囲から会員IDへ変える 同一会員内の順序は、送信側の並びと受信側の処理順で担保する 受信側では細かいメッセージをまとめて処理し、DBへまとめて渡せる形を残す 送信側・受信側の流量制御(outstanding messages)もあわせて調整する メッセージを細かくすると受信側はスケールしやすい一方、DB側でまとめて処理する利点とのバランスは取りにくくなります。そこで、送信は細かく、受信で再バッチする形を採りました。 受信側はスケールしたが、送信側が重い 受信側は期待どおりスケールしました。一方で、送信側の負荷が想定以上に上がりました。 次の図は、STG環境で取得したCloud Runのメトリクスです。左の3つの山が変更前、右の2つの山が変更後です。この処理は毎分起動して候補をまとめてpublishするため、1リクエストの所要時間はもともと秒から分のオーダーです。ここで見るべきは絶対値ではなく、同じ仕事に対する変化です。 変更後はCPU使用率が100%に到達し、リクエストのテールレイテンシも跳ね上がりました。注目したいのはLatency breakdownの内訳です。伸びているのは青色のUser execution、つまりアプリケーション自身の実行時間でした。ネットワークやルーティングではなく、publish処理そのものが重くなっていました。 変更前にもUser executionが伸びる山はありました。変更後に新しく現れた現象ではなく、スパイクがより高く鋭くなった、という変化です。 原因はGoクライアントの実装から見えてきました。publishのbundleは「同じbatch内のmessageは同じordering keyを持つ」前提で組まれています。次のコメントは cloud.google.com/go/pubsub の Topic.publishMessageBundle にあるものです。 extract the ordering key for this batch. since messages in the same batch share the same ordering key, it doesn't matter which we read from. ( googleapis/google-cloud-go pubsub/topic.go より引用) つまり、ordering keyが異なるメッセージは同じbundleにまとめられません。keyのカーディナリティが高く、各keyあたりのpublish rateが低い場合、batch効率は落ちやすくなります。結果として、同じメッセージ数でもRPC数は増えます。クライアント内部でpublishを管理するschedulerの状態やタイマーも膨らみ、publisher側の負荷につながります。先ほどのUser executionの伸びは、この増加分として説明できます。なお、これは公式ドキュメントに明記された挙動ではなく、実装からの推論です。 この推論は、Pub/Subトピック側のメトリクスからも確認できました。 上段のPublished message countは、publishしたメッセージ数です。1メッセージ1候補にしたため、変更後は毎秒20,000近くまで増えています。ここは設計どおりです。 問題は下段のPublish requestsで、こちらはpublishのリクエスト数、つまりRPCの数です。変更後は毎秒15,000から18,000に達しています。メッセージ数が毎秒20,000程度であることと合わせると、1回のRPCで運べているメッセージはごくわずかです。bundleがほとんどまとまらないまま送信されていた、という状態です。 送信側と受信側の流量制御パラメータや、上流の shardSize も変えて試しました。しかし、いずれも負荷の出方を変えるだけで、bundleが分かれる構造そのものには効きませんでした。 見送りの判断と残る選択肢 受信側のスケール自体は成功しました。しかし、パラメータ調整では回避できない送信側の負荷増と実行時間の悪化に見合うだけの利点がないと判断し、本番採用は見送りました。ordering keyのカーディナリティは、下流の並列度だけでなくpublishスループットにも効きます。ドキュメントが勧める「キーを細かくする」方向と、クライアント実装上のbatch効率は、単純には両立しません。 並列度の頭打ち自体は残っている課題です。今回試したのはシャード範囲と会員IDという両端で、その中間にあたる粒度は試せていません。keyを今の50通りより細かく、会員IDよりは粗く保てば、並列度とbatch効率が両立する粒度を見つけられるはずです。 中間の粒度は、いまの収集分割のまま試せます。 shardSize を1にすれば、シャード範囲をキーにしたままでも、現行の100分割で100通りになります。キーを独立させるなら、送信側で member_id % N をordering keyにすれば足ります。収集ジョブの分割やインデックスを変えなくても、Nを選べます。 収集の分割とPub/Subのキー空間が結びついているのは、複数会員を1メッセージにまとめていた名残です。キーを独立させれば、収集側の流量調整とordering keyのカーディナリティを別々に動かせます。中間の最適点を探すのは、その先の課題として残しています。 まとめ 本記事では、毎分50シャードで動くパーソナライズ配信ジョブに対する、3つの負荷・運用改善を紹介しました。 配信候補テーブルへの過剰なUPDATE / INSERTを抑え、インデックスを必要最小限の部分インデックスへ再設計して、現行運用のSLAを担保できる状態まで改善した。一方で、リードレプリカ制約下では部分インデックスのbloatが残りうるため、収集起点を小さいテーブルへ移す構成変更にも取り組んでいる Cloud Scheduler 50本を、振り分け1本とCloud Tasksへ集約し、流量調整と監視を再デプロイなしで扱いやすい形にした Pub/Subのordering keyを細かくする改修は、受信側のスケールには効いた一方、Goクライアントのbatch実装により送信側の負荷が増え、本番採用は見送った うまくいった改善だけでなく、見送りの判断とその根拠も、同様の構成を運用する方の参考になれば幸いです。今後は、きっかけテーブルへの構成変更を本番へ適用し、bloatの根本要因にどこまで効くのかを実測で確かめていきます。ordering keyについては、シャード範囲と会員IDの間の粒度を検証していきます。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com





















