TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

976

はじめに こんにちは。Developer Engagementブロックの @wiroha です。3月23日(月)に、ZOZOにて中高生女子を対象とした体験イベント「 ZOZOTOWN・WEARを支える技術と働き方を知ろう! 」を開催しました。 これは 公益財団法人山田進太郎D&I財団 が実施する「 Girls Meet STEM 」プログラムの一環です。中高生女子がSTEM(科学・技術・工学・数学)分野で働く人やSTEM分野で学ぶ学生、実際の現場に触れることで、将来の可能性を広げる機会を提供することを目的としています。ZOZOではこの活動の意義に共感し2024年より参画しており、今回は3度目の開催です。 今回は18名の参加者が集まり、オフィスツアー、サービス体験&技術紹介、女性エンジニアとの交流を通じて、ファッションと技術の面白さを体感しました。本記事では、当日の様子をご紹介します。 イベント概要 日時:2026年3月23日(月)13:00~15:30 会場:ZOZO西千葉本社 対象:中学1年生~高校3年生までの戸籍上または性自認が女性の方 定員:20名 www.shinfdn.org オープニング まずは会社紹介や事業紹介により、ZOZOのことを知ってもらう時間を設けました。ZOZOTOWNやWEAR by ZOZO(以下、WEAR)のサービス、計測事業などについて解説することで、この後のサービス体験&技術紹介の内容をより深く理解してもらうことを目指しました。 サービス体験&技術紹介 2つのグループにわかれ、「サービス体験&技術紹介」と「オフィスツアー」を交代で実施しました。「サービス体験&技術紹介」では、ZOZOTOWNのARメイク、フェイスカラー計測ツール「ZOZOGLASS」、WEAR by ZOZOのファッションジャンル診断を体験してもらいました。 AR技術でメイクが施された画面上の自分の顔に驚き、カラフルで見慣れないZOZOGLASSを手に取り笑顔が出るなど、ZOZOの技術を楽しんでいる様子でした。体験した後は各サービスに使用されている技術を紹介し、技術によってファッションが楽しくなることを感じてもらいました。 オフィスツアー こだわりの社屋である、西千葉本社のオフィスツアーを実施しました。メッセージが込められたアートや遊び心のある会議室、絨毯の模様や色使いの工夫など、ZOZOらしいデザインが施されたオフィス内を案内しました。クイズを交えながらの紹介で、参加者の皆さんも考えながら楽しんでいました。 昨年竣工したばかりの会議棟「ZOZOTENT(ゾゾテント)」も案内し、最新のオフィス環境を体験してもらいました。最初は緊張していた参加者も、オフィス内を歩くうちにリラックスできた様子でした。 パネルトーク 次にパネルトークを開催し、新卒1〜2年目の若手女性エンジニアから話を聞きました。学生時代の経験やエンジニアになろうと思ったきっかけ、中学・高校時代の進路選択などについて語ってもらいました。 年齢の近いエンジニアからの話は身近に感じられたようで、熱心に聞き入っていました。転学科した話もあり、タイミングに合わせて進路やキャリアを考えながら、自らアクションすることの大切さを感じてもらえたのではないでしょうか。 質問会 その後は少人数のグループに分かれて参加者からの質問に答える時間を設け、ZOZOの女性エンジニア4名が一緒にお話ししました。 Slido を活用したところ、非常にたくさんの質問が寄せられました。「ZOZOにはどんな職種がありますか?」「エンジニアに文系の人はいますか?」「就活で一番必要だと思ったスキルは何ですか?」など、学習や進路に関する質問に対してエンジニアたちが自身の経験を交えながら丁寧に答えました。 お土産 参加者の皆さんに、ZOZOオリジナルグッズなどをお土産としてお渡ししました。イベントの思い出として楽しんでもらえたら嬉しいです。今回の体験時間に入りきらなかったZOZOMATもお渡ししており、自宅で足の3Dサイズ計測を体験してもらえればと思います。 最後に 参加者の皆さんからは、次のような感想をいただきました。 文理選択のみならず、学部や職業決めの体験談を聞くことができたので、とても参考になりました。 実際に働いている方々が感じていることや、大切にしている考え方などを教えていただき、自分の視野が広がったように感じました。 施設もとても綺麗でとても楽しそうに仕事していて、私もこんなところで働きたいなと思いました。 将来の職についてたくさん不安があったのですが、悩みを沢山聞いていただけて本当に参加してよかったと思いました。 ZOZOはこれまでもさまざまな女性活躍推進のための活動に取り組んできており、今後もこうした機会を提供していきたいと考えています。本イベントにより中高生女子の皆さんがファッションと技術の面白さを感じ、将来の可能性を広げるきっかけになれば幸いです。
アバター
はじめに こんにちは、データ・AIシステム本部の冨田です。ファッションコーディネートアプリ「WEAR」において、ユーザーのコーディネート投稿データを分析し、「似合う」を届けるための機能開発を担当しています。 WEARには日々膨大な数のコーディネートが投稿されています。それらを活用して、経営戦略でもある「ワクワクできる『似合う』を届ける」ためには、画像やテキストからファッションに関する特徴を抽出する必要があります。本記事では、リサーチャーとの協業による評価サイクルを構築しながら、プロンプトエンジニアリングのみで特徴抽出の精度目標を達成した事例を紹介します。 背景・課題 独自定義「似合う4大要素」の抽出 現在私たちは、WEARのコーディネートデータから 「似合う」を構成する4大要素 を抽出するプロジェクトを進めています。本システムでは、まずLLMを用いてコーディネートの画像やテキストから言語化された特徴を抽出します。その後、説明可能なルールベースのロジックに入力して最終的な4大要素を判定するというハイブリッドな構成をとっています。この仕組みを正しく機能させるためには、まずは前段となるLLMが「オーバーサイズ」や「丈感」といったファッション特有の曖昧な特徴を正確に抽出する必要があります。 一般的なプロンプトの限界 ファッションの言語化は非常に曖昧です。例えば「オーバーサイズ」といっても、少しゆとりがある程度を指すのか、極端にシルエットが大きいものを指すのか、人によって解釈が異なります。単純に「この画像はオーバーサイズですか?」とLLMに尋ねるだけでは、サービスが求める基準(ZOZOとしての正解)とLLMの出力が乖離してしまい、実用レベルの精度が得られないという課題がありました。 アプローチ(技術選定) 手法の比較検討 LLMの回答精度を向上させる手法として、一般的に以下の3つが検討されます。私たちは開発コスト・運用コスト・データ準備の観点から比較しました。 手法 概要 メリット デメリット 今回の判断 プロンプトエンジニアリング 指示文(Prompt)の工夫のみで精度を上げる 開発・運用コストが最小。即時反映が可能。 モデルの知識外のことは回答できない。 採用 RAG 外部知識を検索してプロンプトに含める 最新情報や独自データに対応できる。 検索システムの構築・運用コストがかかる。 不採用 ファインチューニング 追加データでモデル自体を再学習させる 特定のタスクや出力形式に特化できる。 高品質な大量の学習データと計算コストが必要。 不採用 選定理由 近年、LoRA(Low-Rank Adaptation) 1 などの効率的な手法の普及により、ファインチューニングのハードルは大きく下がりました。それでも、まずはプロンプトエンジニアリングで限界まで性能を引き出し、ベースラインを確立してから次の手法を検討する、というワークフローがベストプラクティスとなっています。 OpenAIの公式ドキュメント内のOptimizing LLM Accuracy 2 では、モデルの最適化を「Context(知識)」と「Behavior(振る舞い)」の2軸で定義しています。まずはプロンプトでベースラインを測定します。その上で、独自の知識が不足していればRAGを、特定の振る舞いや出力形式の徹底が必要であればファインチューニングを選択する、というアプローチです。また、多くの場合、プロンプトエンジニアリングだけで本番レベルの精度に到達できるという旨も記載されています。 今回のタスクにおいても、ファッションの一般的な知識自体はLLMが既に学習済みであり、最大の課題は「ZOZO独自の定義へのすり合わせ」にありました。そのため、いきなりコストや運用負荷のかかる手法に移行するのではなく、まずはプロンプトを徹底的に磨き込むことにしました。 プロンプト改善・評価サイクル Google Cloudの「プロンプト設計の戦略」ドキュメント 3 より、プロンプト設計は反復的なプロセスであるとされており、継続的なテストと評価の重要性が説かれています。私たちはこれに則り、本格的なプロンプトチューニングへ着手する前に、以下のプロセスで評価サイクルを構築しました。 1. 開発用データセットの作成 エンジニアがプロンプト改善を試行錯誤するための正解データを用意します。社内のリサーチャー(ドメインエキスパート)に依頼し、WEARに投稿されたコーディネートの中から評価対象の特徴を持つ画像を探してラベルを作成してもらいました。 今回は100項目以上の特徴抽出が必要になるため、全件に対して十分なアノテーションを用意することは工数面で非現実的でした。そこで、本施策では各特徴量につき10件という最小限のデータで精度を検証するアプローチを採用しました。少数のデータでは特定のアイテムへの過学習(汎化できているか)が課題になります。これについては後述する定性評価にて、後段のルールベースを通した最終結果で担保する割り切ったアプローチをとりました。 2. プロンプト改善 開発用の評価データセットがあるおかげで、エンジニアは「なんとなく良さそう」といった感覚値ではなく、目標とした定量指標に向かってプロンプトを改善できるようになりました。今回は正解率70%を目標に設定しています。もちろん100%が理想ですが、開発リソースやリリースまでの期間には限りがあります。そこで、「抽出した特徴でコーディネート検索を行った際、結果として並んだ10枚のうち、何枚までならノイズが混ざっても体験を損なわないか」というシナリオをもとにプロジェクト内で議論しました。その結果、リリースに向けた開発コストとユーザー体験のバランスをとる現実的な落とし所として、この70%という目標値を決定しました。このように明確な基準と評価データが揃ったことで、エンジニアが手元で自律的かつ高速にチューニングを回すことができました。 3. ルールベースのロジックを通した最終出力による定性評価 開発用データセットに対する過学習を防ぎ、本番環境での網羅性を確認するために定性評価します。本来はLLMが抽出した特徴を直接評価したいところですが、無作為に収集した画像に対する抽出結果では出現頻度の低い特徴をうまく引き当てられません。また、評価する特徴が多過ぎるため、効率的な評価が困難です。そこで、後段のルールベースのロジックを通した結果のラベルを使って定性評価することにしました。開発段階でWEAR上の全データを推論すると時間とコストがかかり過ぎてしまうため、ファッションの季節性を網羅するように評価用データセットを作成しました。評価用データセットに特徴抽出とルールベースの判定をしてラベルを付与しました。最終的に付与されたラベルごとに300枚をサンプリングし、リサーチャーによる定性評価(こちらも目標正解率70%)をしました。 評価サイクルで得られた効果と結果 エラー分析による「曖昧さ」の解消 定量評価が可能になったことで、冒頭で触れた「ファッションの曖昧さ」に対して、「具体的に何ができていないのか」が可視化されるようになりました。例えば、評価結果のFalse Positive(誤検知)を分析した結果、以下のような原因が判明しました。 「厚底」の特徴:LLMの持つ一般的な厚底の基準と、ZOZOが求める基準にズレがある。 「柄や装飾」の特徴:服のシワや影を、柄として誤認識してしまっている。 原因が具体的に特定できたことで、リサーチャーと「どうすればLLMに伝わるか」を擦り合わせることが容易になりました。結果として、単純な2値(Yes/No)で判定させるのではなく、「度合いを複数のクラスに分類させてから判定する」といったプロンプトの改善に繋げられました。こうした改善の積み重ねにより、目標の正解率70%を達成しました。 適切なモデル選択 今回のプロジェクトでは非常に多くの特徴量を抽出するため、色や柄などのファッション特徴のカテゴリごとにプロンプトを分けています。 タスクの難易度に応じて、より上位のモデルを採用したものもあれば、逆に軽量なモデルへ落としても精度を維持できたものもありました。定量評価によって「どこまでモデルを落としても許容できるか」が数値化されたことで、システム全体での推論コストや処理時間の最適化を安全に進められました。 今回の手法の確からしさ 全ての特徴の開発とラベルの評価が完了したあとに、4か月分のデータセットに対してラベルの付与率などを分析しました。また、サービスリリース前にWEAR上のコーディネート画像全件に対しても同様に推論・分析し、付与率に大きな差がないことを確認しました。非常に少ないアノテーション画像からのスタートでしたが、特定の期間やアイテムに特化した調整(過学習)にはなっておらず、本番環境のデータに対しても適切に汎化できていることが確認できました。 課題と展望 今後の展望:LLM-as-a-judgeの導入 一方で、評価用データセットの作成にはリサーチャーの人手コストがかかるという課題も残りました。今後は、作成した正解データと評価基準を用いて別のLLMに評価担当を任せるLLM-as-a-judgeの導入を検討しています。LLMによる一次評価で大まかな傾向を掴み、判断が分かれる際どいケースのみリサーチャーが確認するフローにすることで、評価コストを下げつつ、より高速な改善サイクルを実現できます。 まとめ 本記事では、WEARの機能開発におけるLLM活用事例として、RAGやファインチューニングを使わずに高精度な特徴抽出を実現したプロセスをご紹介しました。 ZOZOでは、ファッションの曖昧な感性を技術で解き明かし、ユーザーに新しい体験を届けるエンジニアを募集しています。ご興味のある方は、ぜひ採用ページをご覧ください。 corp.zozo.com 参考文献 Hu, E. J., et al. (2021). LoRA: Low-Rank Adaptation of Large Language Models. ↩ Optimizing LLM Accuracy ↩ プロンプト戦略の概要 ↩
アバター
はじめに こんにちは、WEAR開発部バックエンドブロックの小山です。普段は弊社サービスである WEAR のバックエンド開発を担当しています。 WEARではハイブリッド検索などの新たな検索体験の実現を目指しています。その実現に必要な ハイブリッド検索 はOpenSearch 2.11で導入された機能です。Elasticsearch 7.10.2では利用できないため、Amazon OpenSearch Service上のエンジンをOpenSearch 2.11.0以上へ移行する必要がありました。今回はOpenSearch 2系の最新バージョンだった2.19.0を採用しました。本記事では、この移行にあたり対応したSearchkickの導入、ダブルライト戦略によるインデクシング移行、カナリアリリースによる段階的トラフィック切り替えについてご紹介します。 目次 はじめに 目次 抱えていた課題 Elasticsearch 7.10.2の限界 既存のアーキテクチャ 課題を解決したアプローチ 1. Searchkickとopensearch-rubyへの移行 elasticsearch-modelからSearchkickへ elasticsearchからopensearch-rubyへ 既存Searchableとの並存 2. インデクシングのダブルライト戦略 embulk-outputの変更 RakeタスクとDigdagワークフローの追加 3. クエリ種別ごとの動作確認 確認の目的と方針 確認対象の抽出方法 確認したクエリ種別 確認方法 4. 負荷試験 試験条件 試験結果 5. カナリアリリースによる段階的トラフィック移行 リリーススケジュール 各段階での確認項目 確認結果 効果と得られた知見 移行後のアーキテクチャ Searchkickとopensearch-rubyへの移行による保守性向上 並行稼働時のインデクサー移行方法 カナリアリリースの有効性 おわりに 抱えていた課題 Elasticsearch 7.10.2の限界 WEARではコーディネートや動画、メイクの投稿検索にAmazon OpenSearch Service上でElasticsearch 7.10.2を利用していました。しかし、以下の課題がありました。 新機能の利用不可:WEARではハイブリッド検索などの新たな検索体験を計画していたが、Elasticsearch 7.10.2はハイブリッド検索に対応しておらず、実現できない状態 サポートの先行き不透明:Elasticsearch 7.10.2は、Amazon OpenSearch Serviceで提供される最終のオープンソースElasticsearchバージョン。今後の新機能追加やセキュリティパッチの提供が見込めない状態。Elasticsearch 7.1〜7.8の標準サポートは2025年11月に終了しており、7.10.2も同様のサポート終了が予想される状態。AWS側でもOpenSearchエンジンへの移行を推奨 ライブラリのメンテナンス性: elasticsearch gem 7.14.0以降ではAmazon OpenSearch Service上のElasticsearchへ接続不可。gemのバージョンを7.13.3に固定せざるを得ず、アップデートができない状態 既存のアーキテクチャ WEARの検索基盤は、以下のシステム構成で運用していました。 検索機能: elasticsearch-model gemを利用し、検索メソッドを提供。内部では elasticsearch gemが提供する Elasticsearch::Client を通じてOpenSearch Serviceと通信 マッピング定義: elasticsearch-model gemを利用し、モデルにマッピング定義を記述 インデックス操作: elasticsearch gemを利用し、Rakeタスクによるインデックス作成、エイリアス切り替え、旧インデックス削除、ドキュメント削除 インデクシング:トラフィックを考慮し、レコード更新ごとではなくDigdagワークフローと Embulk による定時バッチ(日次の洗い替えと差分更新)でインデクシング 課題を解決したアプローチ 今回の移行では、既存ドメインのインプレースアップグレードではなく、OpenSearch 2.19.0の新規ドメインを作成し、エンドポイントを段階的に切り替える方法を採用しました。その理由は以下の通りです。 インプレースアップグレードでは、Elasticsearch 7.10.2からOpenSearch 2.19.0へ直接移行できず、 OpenSearch 1.xを経由する必要がある elasticsearch-model / elasticsearch から searchkick / opensearch-ruby へのgem移行が必要であり、アプリケーションコードに破壊的変更が生じる 検索基盤は影響範囲が大きいため、カナリアリリースで段階的にリリースしたい これらを踏まえ、Elasticsearchをダウンタイムなく移行させるために以下のアプローチで段階的に進めました。 Searchkickとopensearch-rubyへの移行 インデクシングのダブルライト戦略 クエリ種別ごとの動作確認 負荷試験 カナリアリリースによる段階的トラフィック移行 1. Searchkickとopensearch-rubyへの移行 移行前後のgemの対応関係は以下の通りです。 責務 Elasticsearch利用時 OpenSearch移行後 検索機能 elasticsearch-model (内部で elasticsearch を利用) searchkick (内部で opensearch-ruby を利用) マッピング定義 elasticsearch-model searchkick インデックス操作 elasticsearch 直接利用 opensearch-ruby 直接利用 elasticsearch-modelからSearchkickへ 検索機能とマッピング定義については、既存の elasticsearch-model の代わりに、 searchkick に移行しました。Searchkickを選定した理由は以下の通りです。 OpenSearchを公式にサポートしている リポジトリが継続的にメンテナンスされている nested型への対応など、 elasticsearch-model との互換性がある reindex時のアトミックなエイリアス切り替えが組み込まれているほか、ハイブリッド検索やセマンティック検索にも対応しており、高度な機能を備えている elasticsearchからopensearch-rubyへ インデックス操作のRakeタスクでは、 elasticsearch を使用していました。OpenSearch移行に伴い、これを opensearch-ruby に置き換えました。 - require 'elasticsearch' - client = Elasticsearch::Client.new(client_options) + require 'opensearch-ruby' + client = OpenSearch::Client.new(client_options) client.indices.update_aliases(...) client.indices.delete(...) opensearch-ruby は elasticsearch とAPIの互換性が高いため、クライアントの初期化部分とエラークラスの変更で、既存のインデックス操作ロジックをそのまま利用できました。 唯一の例外がインデックス作成タスクで、ここではSearchkick経由でマッピング定義を取得して作成しています。 task :create_index , [ :index_name ] => :environment do |_, args| index_class = index_class_name(args[ :index_name ]).singularize.capitalize.constantize index = Searchkick :: Index .new(args[ :index_name ]) model_config = index_class.search_index.index_options # Searchkickからマッピング取得 index.create(model_config) # Searchkick経由で作成 end このように、マッピング定義はSearchkickに一元化しつつ、その他のインデックス操作は opensearch-ruby を直接使用する構成としました。 既存Searchableとの並存 WEARでは、モデルごとに *Searchable というconcernを定義し、 elasticsearch-model を利用した検索用のデータ定義とマッピング定義を集約していました。 移行期間中は、Elasticsearchを利用するサーバーとOpenSearchを利用するサーバーを並行稼働させる必要がありました。そこで、モデルごとに *OpensearchSearchable concernを新設し、既存の *Searchable と並存させる構成をとりました。 既存の *Searchable はElasticsearch用のconcernです。 # 既存: Elasticsearch用 module Searchable extend ActiveSupport :: Concern # elasticsearch-model を利用したデータ定義とマッピング定義 end 新設した *OpensearchSearchable はOpenSearch用のconcernです。 # 新規: OpenSearch用 module OpensearchSearchable extend ActiveSupport :: Concern included do searchkick index_name : Rails .configuration.x.application[ :opensearch ][ :index_name ], settings : Rails .configuration.x.application[ :opensearch ][ :settings ], callbacks : false , merge_mappings : true , mappings : search_mappings def search_data # searchkick を利用したデータ定義 end end module ClassMethods def search_mappings # searchkick を利用したマッピング定義 end end end merge_mappings: true を指定することで、独自に定義したマッピングをSearchkickの自動生成マッピングにマージしています。 callbacks: false を指定することで、Searchkickの自動インデクシングを無効化し、既存のEmbulkによるインデクシングとの競合を防いでいます。 2. インデクシングのダブルライト戦略 移行期間中、ElasticsearchとOpenSearchの両方にデータを投入するダブルライトを実施しました。WEARのインデクシングは日次バッチによる洗い替え方式のため、ダブルライトを開始した時点で既存データも含めてOpenSearchに自動で同期されます。そのため、既存データの移行作業を別途行う必要はありませんでした。 embulk-outputの変更 前述の通り、既存の構成ではEmbulkを介して、BigQueryからデータを取得してElasticsearchにインデクシングしていました。インデクシング時のBigQueryのクエリコストが高額なため、OpenSearchにもインデクシングを行う際に単純にジョブを複製してしまうと、費用が2重に掛かってしまうという課題がありました。 そこで、embulk-outputの出力先をElasticsearchとOpenSearchの両方に向けることで、SQLの実行は一度だけで双方にデータを転送できるようにしました。 移行前はElasticsearchのみに出力していました。 # Elasticsearchへのインデクシング時 out : type : elasticsearch mode : insert nodes : - { host : {{ elasticsearch_host }} , port : {{ elasticsearch_port }}} index : {{ elasticsearch_index }} { % Elasticsearchの設定値 % } ダブルライト時は type: multi を使い、ElasticsearchとOpenSearchの両方に出力しました。 # ElasticsearchとOpenSearchにダブルライトするインデクシング時 out : type : multi outputs : - type : elasticsearch mode : insert nodes : - { host : {{ elasticsearch_host }} , port : {{ elasticsearch_port }}} index : {{ elasticsearch_index }} { % Elasticsearchの設定値 % } - type : elasticsearch mode : insert nodes : - { host : {{ opensearch_host }} , port : {{ opensearch_port }}} index : {{ opensearch_index }} { % OpenSearchの設定値 % } ダブルライトのために embulk-output-multi を新たに導入し、複数出力先への分岐を実現しました。OpenSearch側の出力も type: elasticsearch を指定しています。 embulk-output-elasticsearch はOpenSearchとのAPI互換性により、そのままOpenSearchへの出力にも利用できました。 RakeタスクとDigdagワークフローの追加 OpenSearch向けのインデックス操作のRakeタスクとDigdagワークフローを作成し、OpenSearchに対しても実行できるようにしました。 # 既存のElasticsearchのインデックス作成 +create_index_elasticsearch: sh>: ... rails "elasticsearch:create_index[${index_name}]" # 追加したOpenSearchのインデックス作成 +create_index_opensearch: sh>: ... rails "opensearch:create_index[${index_name}]" 3. クエリ種別ごとの動作確認 OpenSearch移行後にすべてのクエリ種別が正常に動作するかをQA環境で確認しました。 確認の目的と方針 Elasticsearchに送信されるクエリの種別ごとに、OpenSearch上でも同等の結果が返ることを確認しました。クエリ種別が重複するエンドポイントは確認対象外とし、効率的に網羅性を担保しました。 確認対象の抽出方法 確認対象の抽出は以下の手順で行いました。 対象エンドポイントの洗い出し:リポジトリ内でElasticsearchのQueryクラスを呼び出している箇所をリストアップ WEAR Webの対象画面の特定:Webマスタ仕様書から対象エンドポイントが使用されている画面を確認 クエリの特定:APIのリクエストパラメーターから生成されるOpenSearchのクエリJSONを特定し、使用されているクエリ種別を分類 確認したクエリ種別 以下のクエリ種別を対象に、WEAR iOS・Android・Webの各プラットフォームで動作確認を実施しました。 分類 クエリ種別 検索クエリ term 、 terms 、 range 、 nested 、 bool ( filter / must_not / must / should )、 function_score 、 exists ソート sort ページング from 、 size グループ化 collapse 複合検索 msearch 確認方法 WEAR iOS・Android・Webの各プラットフォームで、以下の方法で確認しました。また、対応するRSpecテストを実行し、OpenSearchに対するクエリが正常に動作することはCI上で確認しています。 WEAR iOS・Android:QA環境のAPIに対してcurlコマンドでリクエストを送信し、レスポンスを確認。 WEAR Web:ブラウザ上で対象画面を操作し、APIレスポンスと画面表示を目視確認。 すべてのクエリ種別で正常な動作を確認し、負荷試験に進みました。 4. 負荷試験 本番リリース前に、OpenSearchクラスターがElasticsearch利用時と同等のリクエスト量を処理できるかを確認するため、QA環境で負荷試験を実施しました。 試験条件 QA環境のOpenSearchクラスターを本番環境のElasticsearchと同等のスペックに設定 検索エンドポイントのRedisキャッシュを無効化し、OpenSearchへの直接的な負荷を計測 k6を用いて、各検索エンドポイントに対して本番のピーク帯のMAX rps相当のリクエストを6時間継続 試験結果 レイテンシ :Datadog APMで各検索エンドポイントのp99レイテンシを直近1か月の平均と比較した結果、OpenSearchがボトルネックとなるレイテンシ劣化は観測されなかった エラー :Datadog APMで各検索エンドポイントを確認した結果、OpenSearch起因のエラーは発生しなかった クラスターメトリクス :本番のピーク帯MAX値相当のリクエストを6時間継続した。CPUUtilizationはリクエスト量に対して許容範囲内、JVMMemoryPressureは本番環境と同程度であり、各種メトリクスに大きな影響はなかった この結果をもとに、カナリアリリースによる段階的な本番投入を判断しました。 5. カナリアリリースによる段階的トラフィック移行 本番リリースでは、カナリアリリースによって段階的にトラフィックを移行しました。 リリーススケジュール 日時 内容 2025/9/30 13:00 canary podの作成、APIの正常確認、1%リリース 2025/9/30 17:00 10%リリース 2025/10/1 14:00 50%リリース 2025/10/2 13:30 100%リリース 2025/10/2〜10/6 正常性の継続監視 各段階での確認項目 各段階で以下の項目を確認し、問題がなければ次の段階に進みました。 OpenSearchのレイテンシ比較とエラー確認:Datadog APMでOpenSearchとElasticsearchのレイテンシを比較し、劣化がないことを確認。OpenSearchのエラーがないことを確認。 各検索エンドポイントのレイテンシ比較とエラー確認:Datadog APMで各検索エンドポイントのレイテンシを比較し、劣化がないことを確認。OpenSearch起因のエラーがないことを確認。 クラスターメトリクス:SearchLatency、IndexingLatency、CPUUtilization、JVMMemoryPressureを監視し、劣化がないことを確認。 インデックスの整合性:ElasticsearchとOpenSearchのドキュメント件数に差異がないことを確認。 確認結果 OpenSearchでレイテンシが低い傾向を確認した(平均・最小・最大いずれもOpenSearchの方が高速) OpenSearch起因のエラーが発生しなかった OpenSearchでJVMMemoryPressureがやや高い傾向にあったが、MAXでも60%未満であり問題なかった CPUUtilizationはOpenSearchの方が低い傾向だった 100%リリース後の監視でも劣化が見られず、移行完了を判断した 効果と得られた知見 移行後のアーキテクチャ 移行後の検索基盤は、以下のシステム構成になりました。 検索機能: searchkick gemを利用し、検索メソッドを提供。内部では opensearch-ruby gemが提供する OpenSearch::Client を通じてOpenSearch Serviceと通信 マッピング定義: searchkick gemを利用し、モデルにマッピング定義を記述 インデックス操作: opensearch-ruby gemを利用し、Rakeタスクによるインデックス作成、エイリアス切り替え、旧インデックス削除、ドキュメント削除 インデクシング:既存のDigdagワークフローと Embulk による定時バッチ(日次の洗い替えと差分更新)でのインデクシングを継続 Searchkickとopensearch-rubyへの移行による保守性向上 elasticsearch-model から searchkick 、 elasticsearch から opensearch-ruby に移行し、以下の効果と知見がありました。 OpenSearchの将来的なバージョンアップへの追随が容易になった reindex処理のアトミックなエイリアス切り替えが組み込みで利用可能になった ハイブリッド検索の機能が利用可能になった opensearch-ruby はAPI互換性が高く、Rakeタスクの移行コストが低かった 並行稼働時のインデクサー移行方法 ダブルライト戦略により、以下のメリットがありました。 ElasticsearchとOpenSearchを並行稼働させることで、いつでも切り戻し可能な状態を維持 Embulkを利用した既存のインデクシングパイプラインを最小限の変更で拡張 移行時のクエリコスト増大を防止 Digdagワークフロー層での制御により、アプリケーションコードへの影響を最小化 カナリアリリースの有効性 段階的なトラフィック移行により、以下の知見が得られました。 1%リリースと10%リリースで、JVMMemoryPressureの変動が大きく見られた。これは、リリース後の低トラフィック時にキャッシュヒット率が低いことに起因する可能性が高く、50%リリース以降は安定した。 検索基盤のような影響範囲の大きいミドルウェアの移行にはカナリアリリースが有効であることを実感した。 おわりに 本記事ではWEARにおけるElasticsearch 7.10.2からOpenSearch 2.19.0への移行プロセスを紹介しました。同様の移行を検討している方の参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
はじめに こんにちは、YSHP部の三上です。Yahoo!ショッピングに出店しているZOZOTOWNの店舗である ZOZOTOWN Yahoo!店 のバックエンド開発を担当しています。私は2023年10月、社内公募を経てYSHP部へ異動しました。それまでは長らくビジネス部門に所属しており、開発は未経験でした。ZOZOTOWN Yahoo!店に携わるのも初めてで、APIという言葉の意味も曖昧な状態からのスタートでした。 そんな中、2025年9月末にジョインしたのが、ZOZOTOWN Yahoo!店へのギフトラッピング機能導入プロジェクトです。この取り組みは2021年頃から構想はあったものの、Yahoo!ショッピングとZOZOTOWNの仕様差分が大きく、実現に至っていませんでした。私がジョインした時点では、仕様の多くが確定していない状態でした。一方で、クリスマス商戦前にリリースするという目標だけは明確に決まっており、開発側のメイン窓口として推進を担うことになりました。 この記事で伝えたいこと 本記事では、以下の3点についてお伝えします。 仕様が異なるシステム統合における差分整理と責任分界の設計方法 未確定事項の多いプロジェクトの推進方法 開発未経験からのキャリアチェンジでの学び 目次 はじめに この記事で伝えたいこと 目次 ギフト導入までに取り組んだこと まず着手したのは「実装」ではなく「未確定事項の可視化」 仕様差分をどう吸収したか ギフト種別の違い 包装選択肢の違い ギフト利用NG条件の違い 既存アーキテクチャに沿った拡張 本番注文データでの実運用テスト 振り返り クリスマス前のリリースと反応 仕様差分のある統合で重要だったこと キャリアチェンジ直後でも推進できた理由 おわりに ギフト導入までに取り組んだこと まず着手したのは「実装」ではなく「未確定事項の可視化」 私がプロジェクトにジョインした時点では、クリスマス商戦前のリリースというゴールは明確でした。一方で、仕様の8割近くは未確定のまま、詳細はほとんど決まっていない状態でした。社内にQA表や議事録はありましたが、以下のような課題が散在していました。 一度議題に上がったものの結論を明文化できていない事項 一部で合意しているが全体として整合が取れていない内容 社内外で認識が揃っているかどうか確信が持てない論点 そのため、最初に着手したのは実装ではなく、ドキュメントやSlackでのやりとりを横断的に確認し、未確定事項と仕様差分を一覧化することでした。 何が決まっているのか 何が決まっていないのか どこに認識差異が生まれそうか を1つずつ整理しました。社内だけでも10〜20件程度の未確定事項があり、それらをもとに社内外のMTGを設定し、「最終的にどのレイヤーで何を制御するのか」という責任範囲を明確にしていきました。実装へ進む前に、制御方針と関係者の認識を揃えることを優先しました。 仕様差分をどう吸収したか 本プロジェクトにおいて最大のハードルの1つが、Yahoo!ショッピングとZOZOTOWNの仕様差分です。両システム間では、ギフト機能の仕様や前提となる設計思想が大きく異なっており、単純な横展開ができるものではありませんでした。 代表的な差分や特に判断が必要だったポイントをいくつか紹介します。 項目 Yahoo!ショッピング ZOZOTOWN 今回の対応 ギフト種別 通常ギフト/ソーシャルギフト ギフトラッピング ZOZOTOWN側の構造は変更せずYahoo!ショッピング側で選択肢を制御 包装選択 「指定なし」あり 必須 API連携時に必ず包装が設定されるよう制御 利用NG条件 独自の制御ロジック 対応上限数・在庫種別等の複合条件 APIレスポンスで可否を返却し責任を分界 ギフト種別の違い Yahoo!ショッピングには「通常ギフト」と「ソーシャルギフト」の2種類あります。ソーシャルギフトでは、購入者がURLを共有し、受取人がお届け先を入力する仕組みを提供しています。一方で、ZOZOTOWNにはこの仕組みがなく、ギフトの前提構造が異なる状態でした。 この差分に対しては、ZOZOTOWN側のデータ構造は変更せず、Yahoo!ショッピング側で選択肢を制御する方針としました。ZOZOTOWN側に新たな概念を持ち込むと既存の注文フローや配送処理への影響範囲が大きいため、既存構造の中で成立させることを優先した判断です。 包装選択肢の違い Yahoo!ショッピングでは包装に「指定なし」を選択できますが、ZOZOTOWNではギフト注文時に包装指定が必須です。この違いは単なるUIの差ではなく注文データの構造にも影響するため、ZOZOTOWNのデータ構造に落とし込む必要がありました。 そのため、「指定なし」をそのまま連携せずに、API連携時に必ず包装が設定されるよう制御する設計としました。UIの見え方ではなく、データ連携時にどう変換するかという観点で解決しました。 ギフト利用NG条件の違い 両社では、ギフト利用可否に関する制約も異なっていました。例えばZOZOTOWNでは、以下のような条件でギフト利用可否を制御しています。 発送拠点での1日のギフト上限数到達 外部在庫の商品を含む注文 予約商品を含む注文 ギフト利用不可の商品を含む注文 また、ギフトを選択した場合には代引き・置き配との併用が不可になるほか、即日配送も一部エリアを除き利用が制限されるなど、配送・決済オプションにも影響があります。Yahoo!ショッピング側にも独自の制御ロジックはありますが、今回のプロジェクトではZOZOTOWN側の制約を確実に担保することが前提でした。そのため、これらの条件をどのように両社で役割分担しながら制御するのかを決める必要がありました。 ZOZOTOWNのギフト利用NG条件は、発送拠点の対応上限数や在庫種別など内部状況に依存します。そのため、ギフト選択有無に関わらず、ZOZOTOWN側から常にギフト可否(OK/NG)をAPIレスポンスで返却する方針を取りました。また、即日配送・置き配・代引きといった配送・決済オプションについても、ギフト設定有無に応じてレスポンスを切り分ける設計としました。一方で、システム間の責任分界の観点から、Yahoo!ショッピング側で完結できる制御についてはYahoo!ショッピング側へ委ねる形としています。 既存アーキテクチャに沿った拡張 仕様差分を吸収するためには、API設計だけでなく、商品情報の連携にも対応が必要でした。 ZOZOTOWN Yahoo!店では、商品情報の連携にDBトリガーを用いた既存の仕組みがあります。対象テーブルのカラムに更新が走ると、DBトリガーがそれを検知してログテーブルに商品IDを書き込みます。既存のバッチ処理がこのログテーブルを参照し、Yahoo!ショッピング連携用のCSVを生成・FTP連携する、という流れです。今回のギフト導入では、この既存フローを2点拡張しました。 1つ目は、トリガーの追加です。商品情報テーブルのギフトNGフラグが変更された際に、ログテーブルへ書き込まれるようトリガーを新設しました。これにより、商品単位のギフト可否が変わったタイミングで、自動的に連携対象としてキューへ入る仕組みとなります。 2つ目は、CSV出力項目の追加です。既存のバッチ処理が生成するCSVに、ギフト可否を示す項目を追加しました。この項目は、ギフトNGフラグやギフト対象カテゴリの情報をもとに「対象/対象外」を判定し、Yahoo!ショッピング側に連携します。 いずれも新たな連携の仕組みを作るのではなく、既存のトリガー・バッチ処理の延長線上で対応しています。実績のあるフローに乗せることで、影響範囲を最小限に抑えることを意図しました。 本番注文データでの実運用テスト リリース前には、本番注文データをギフト扱いに変更し、実際の運用フローが回るかを確認しました。ギフトラッピングの実作業を管轄するZOZOBASEやお客様対応を担うCSも巻き込み、実運用に近い形で検証しました。検証を通じて、ZOZOTOWN Yahoo!店の注文では、ZOZOTOWNで利用できる一部機能(梱包サイズ超過時にZOZOBASEからCSへ引き継ぐ機能)を利用できないことが判明しました。この機能はZOZOBASEで使用されるハンディ端末に依存しており、私は実機を扱った経験もありませんでした。 そこで、関連システムのソースコードを追い、仕様を読み解くところから始めました。まずHTMLテンプレートの表示制御を確認し、条件分岐によってZOZOTOWNの注文でのみ「ギフト資材超過」の機能を利用できる仕組みに気づきました。次にサーバーサイドのコードでSQL文を追い、この機能が利用された場合にDBへどのような値が書き込まれるかを特定しました。 この仕様理解をもとに、ZOZOTOWN Yahoo!店でも同じ機能を利用できるよう、関係部署と連携して修正しました。結果的に、リリース前に運用上の課題を解消できました。 振り返り クリスマス前のリリースと反応 最終的に、本機能は2025年12月10日にリリースできました。クリスマス商戦前という目標に対し、余裕を持ったスケジュールでのサービスインとなりました。本格的な訴求前の段階でも、ギフト注文は順調に発生し、一定のニーズがあることを確認できました。長年構想止まりだった取り組みを、実際の売上につなげられたことは大きな成果だったと感じています。9月末のアサインから約2か月半という期間は、決して余裕のあるスケジュールではありませんでした。それでも予定通りにリリースできたのは、序盤に未確定事項を解消したことで、後半の開発・テストに集中できたからだと振り返っています。 仕様差分のある統合で重要だったこと 今回のプロジェクトを通じて強く感じたのは、実装よりも前の整理こそが統合の成否を分けるということです。異なる仕様を持つシステム同士をつなぐ場合、以下が重要になります。 表示上の違いだけに着目するのではなく、データ構造や制御レイヤーの差分を整理すること 最終的な制御を担うレイヤーを明確にすること 一方のシステムに過度な責務を集中させず、役割を分割すること 今回も、ソーシャルギフトの扱い、包装「指定なし」の吸収、制約に関する責任分界など、すべてにおいて「既存構造を壊さず、どこで整合を取るか」という判断が求められました。仕様差分は避けられませんが、構造と責任を整理すれば前に進める、という実感を得ることができました。 キャリアチェンジ直後でも推進できた理由 開発未経験で異動した私にとって、今回の案件はこれまでで最も規模の大きなプロジェクトでした。実装そのものは外部パートナーの方にお願いしていますが、キャリアチェンジ直後でもプロジェクトを前に進められたのは、以下を徹底したからだと考えています。 未確定事項を放置せず、可視化すること 認識が揃っているかを細かく確認すること 合意事項を文章として残し、曖昧さを減らすこと 技術力だけでなく、課題整理力や調整力といったスキルも、設計や推進の一部であると今回あらためて実感しました。 おわりに 仕様が異なるシステム同士をつなぐことは、単純な機能追加より難易度が高い場合もあります。しかし、構造を整理して責任を明確にし、1つずつ前提を揃えていけば、前に進めることもまた事実です。今回の取り組みが、仕様差分や責任分界に悩むプロジェクトの参考になれば幸いです。 また、技術力そのものに自信がなくても、整理する力や問い続ける姿勢は、プロジェクトを推進する大きな力になります。同じようにキャリアチェンジ直後で不安を抱えている方の後押しにもなれば嬉しく思います。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
はじめに こんにちは、商品基盤部の杉浦、小原、寺嶋です。普段はZOZOTOWNのお気に入り基盤・商品レビュー基盤といった商品サブドメインを担当しています。 私たちのチームでは運用コスト削減を目的として、お気に入りデータベースをオンプレミスのSQL ServerからAWS Aurora MySQLへの移行に取り組んでいます。お気に入りデータは数十億レコードに及び、移行中もデータが増え続けるためデータの静止点が作れないという課題がありました。本記事では、この大規模データ移行における初期移行の取り組みと、Embulkを用いた差分同期について紹介します。 なお、新規データの書き込みを担保するダブルライト戦略については 前回の記事 で紹介しています。あわせてご覧ください。 目次 はじめに 目次 お気に入りリプレイスの概要 技術スタックの老朽化 オンプレミスSQL Serverの運用限界 背景・課題 初期移行 制約と課題 検証と最適化 本番移行の結果 得られた学び Embulkによる差分同期 ジョブ設計 ソースDBへの負荷制御 データ整合性の担保 設定管理とチューニング まとめ お気に入りリプレイスの概要 ZOZOTOWNのお気に入り機能は、会員が興味のある商品・ブランド・ショップを登録し、お気に入り一覧から確認できる機能です。まず、ユーザー種別として 会員 と ゲスト会員 の2種類が存在し、それぞれ独立したテーブルで管理されています。お気に入り登録の対象も 商品・ブランド・ショップ の3種類があり、ユーザー種別との掛け合わせにより、合計6パターンのテーブルが移行対象となります。さらに、 過去に削除されたお気に入りの履歴(アーカイブデータ) も保持されており、これらを含めると移行対象のテーブルは多岐に及びます。テーブルによってレコード数は数千万レコードから数十億レコードまで幅があり、合計すると数十億レコード規模のデータ移行となりました。 この構成は長年にわたりZOZOTOWNを支えてきましたが、以下のような課題を抱えていました。 技術スタックの老朽化 ZOZOTOWNは2004年の開始当初からClassic ASP(VBScript)とSQL Serverのストアドプロシージャでビジネスロジックを実装してきました。しかし、VBScriptは開発元のMicrosoftも積極的に開発しておらず、クラウドベンダーのSDKが提供されていないなど技術的な制約が大きくなっていました。こうした背景からZOZOTOWN全体で リプレイスプロジェクト が進められており、お気に入り機能もその一環としてマイクロサービスへの刷新に取り組んでいます。 オンプレミスSQL Serverの運用限界 ZOZOTOWNは運営開始から10年以上にわたりオンプレミス環境でシステムを拡大してきましたが、スケーラビリティや保守コストの面で課題を抱えていました。2017年より ストラングラーフィグパターンによる段階的なマイクロサービス移行 が進められています。お気に入り機能のデータベースもその一環として、オンプレミスのSQL ServerからAWS上のAurora MySQLへの移行が必要でした。しかし、以下の制約がありました。 Read/Writeが常時発生しており、 システム停止を伴う移行は不可能 書き込んでから読み取れるまでの許容タイムラグが短く、 レプリケーション方式では要件を満たせない オンプレミスDBへの設定変更が必要なマネージドサービス(AWS DMS等)は、 他機能への影響を考慮し使用を見送り お気に入りデータが膨大なため、 インデックス設定などのチューニングにも数時間を要する状態 これらの課題を踏まえ、移行方式を設計し技術検証しました。移行戦略の全体像は以下の3フェーズで構成されています。 フェーズ1 : SQL Server単体での運用(移行前) フェーズ2 : SQL ServerとAurora MySQLのデュアル運用(移行期間) フェーズ3 : Aurora MySQL単体での運用(移行完了) フェーズ2におけるダブルライトの仕組みやフェーズ切り替えの実装については 前回の記事 で紹介しています。本記事ではこのフェーズ2にフォーカスします。 背景・課題 初期移行 初期移行は、ソースDB(オンプレミスSQL Server)からターゲットDB(Aurora MySQL)へのデータ一括移行です。全体の流れは以下の通りです。 抽出 : SQL Serverから bcp でCSV出力 転送 : CSVファイルをS3へアップロード ロード : LOAD DATA FROM S3 でAurora MySQLへインポート インデックス構築 : ALTER TABLE でインデックスを追加 制約と課題 今回の初期移行には、以下の制約がありました。 ソースDB(本番稼働中) : 影響を最小限に抑える必要がある ターゲットDB(サービスイン前) : 大胆な最適化が可能 この非対称な条件から、「 抽出は慎重に、インポートは大胆に 」という方針を採用しました。抽出には bcp (Bulk Copy Program)を採用しました。 bcp はSQL Server標準のバルクエクスポートツールであり、SELECT文による抽出と比較して以下の利点があります。 高スループット : 200,000〜500,000行/秒の安定した出力性能 シンプルな運用 : 追加のミドルウェアやライセンスが不要 転送ではS3を中継することで、ロード失敗時に再抽出せず再実行できる設計としています。 一方、事前試算では最大規模テーブルのインポートに 数日〜1週間 を要することが判明しました。ロード時間が長期化すると、以下のリスクが高まります。 接続切断・タイムアウト : 数日に及ぶ処理は中断リスクが高い 障害時の復旧困難 : 失敗時のデバッグと再実行に多大な時間を要する 移行スケジュールへの影響 : ダブルライト期間が長期化し、運用負荷が増大する ロールバック困難 : 問題発覚時に手戻りできる時間的余裕がなくなる これらのリスクを軽減するため、インポート処理の最適化が必須でした。 検証と最適化 本番移行に先立ち、約6,000万レコードを持つテーブルを用いて3つの観点で検証しました。 1. 並列化の効果 LOAD DATA FROM S3 MANIFEST でマニフェスト分割による並列実行を検証しました。CSVファイルを4分割・8分割・16分割と変化させましたが、スループットは 約51,000〜53,000行/秒で横ばい でした。 今回のAurora構成はProvisioned(単一ライターノード)であり、並列ロードを実行してもCPUおよびストレージI/O帯域がボトルネックとなります。Aurora Serverless v2のような動的スケーリング構成であれば結果が異なる可能性もありますが、今回の構成では並列化による改善は限定的でした。 2. インデックス戦略 方式 内容 処理効率 パターンA インデックスなしでLOAD → 後からALTERで追加 約61,000〜68,000行/秒 パターンB インデックスありでLOAD 約39,000〜42,000行/秒 パターンAが 最大59%高速 でした。行挿入ごとのインデックス更新はランダムI/Oを発生させますが、一括構築ならソート後、シーケンシャルに処理できます。ターゲットDBは未稼働のため、この最適化を採用しました。 3. インスタンスサイズ インスタンスタイプ別のスループットを比較しました。料金は Amazon Aurora の料金 を参照しています。 インスタンス インポート効率 ALTER効率 オンデマンド時間単価 r6i.2xlarge 約125,500行/秒 約120,300行/秒 約$0.63/時 r6i.16xlarge 約162,200行/秒 約162,800行/秒 約$5.00/時 r6i.16xlargeはr6i.2xlargeと比較して約30%のスループット向上が見られた一方、コストは約8倍です。このスループット差がテーブル規模によって処理時間に与える影響は以下の通りです。 大規模テーブル(数十億レコード) : 2〜3時間の短縮 → リスク低減に寄与 小規模テーブル(数千万レコード) : 数分の短縮 → コスト対効果が低い この結果から、大規模テーブルはr6i.16xlargeで時間短縮とリスク低減を図り、中小規模テーブルはr6i.2xlargeでコスト効率を最大化する ハイブリッド戦略 を採用しました。 本番移行の結果 検証結果をもとに本番移行を実施しました。最終的な移行実績は以下の通りです。 テーブル規模 テーブル数 LOAD DATA ALTER TABLE 総所要時間 最大規模(数十億レコード) 2 約4日 約7時間 約4日半 中規模(数億レコード) 1 約3時間 約20分 約3時間 小規模(数千万レコード) 5 約1時間 約10分 約1時間 合計 8 - - 約5日 数十時間に及ぶロードでは、以下のクエリで進捗を監視しました。 SET @target_rows = ?; -- 目標件数(テーブルの総行数) SET @thread_id = ?; -- 監視対象のスレッドID SELECT CONCAT ( ' Thread ' , trx.trx_mysql_thread_id) AS target_name, CONVERT_TZ(trx.trx_started, ' UTC ' , ' Asia/Tokyo ' ) AS 開始時刻_JST, ROUND (TIMESTAMPDIFF(SECOND, trx.trx_started, UTC_TIMESTAMP()) / 3600 , 2 ) AS 経過時間 _ 時間, trx.trx_rows_modified AS 挿入済み行数, @target_rows AS 目標件数, ROUND (trx.trx_rows_modified / TIMESTAMPDIFF(SECOND, trx.trx_started, UTC_TIMESTAMP()), 1 ) AS スループット _ 行毎秒, ROUND (trx.trx_rows_modified / @target_rows * 100 , 2 ) AS 進捗率 _ パーセント, ROUND ( (@target_rows - trx.trx_rows_modified) / (trx.trx_rows_modified / TIMESTAMPDIFF(SECOND, trx.trx_started, UTC_TIMESTAMP())) / 3600 , 2 ) AS 残り時間 _ 時間, DATE_ADD( CONVERT_TZ(NOW(), ' UTC ' , ' Asia/Tokyo ' ), INTERVAL ROUND ( (@target_rows - trx.trx_rows_modified) / (trx.trx_rows_modified / TIMESTAMPDIFF(SECOND, trx.trx_started, UTC_TIMESTAMP())) ) SECOND ) AS 完了見込み時刻_JST FROM information_schema.innodb_trx trx WHERE trx.trx_mysql_thread_id = @thread_id; information_schema.innodb_trx の trx_rows_modified から処理済み件数を取得し、経過時間で割ってスループットを算出します。目標件数との差分から残り時間と完了見込み時刻を推定し、数日に及ぶ処理においても見通しを立てられるようにしました。 得られた学び 学び 根拠 並列化は万能ではない マニフェスト分割を試みたが、単一ノードのI/O帯域がボトルネックとなり効果は限定的でした。闇雲に並列化するのではなく、律速段階を特定することが重要です インデックスは後付けが基本 ロード後に一括構築することで最大59%高速化。行挿入ごとのインデックス更新はランダムI/Oを発生させるが、一括構築ならソート後シーケンシャルに処理できる インスタンスサイズはテーブル規模で使い分ける 大規模テーブルはr6i.16xlargeで時間短縮とリスク低減、小規模テーブルはr6i.2xlargeでコスト効率を最大化。スループット向上率とコスト増加率のバランスを見極める 必ず本番同等データでリハーサルする 6,000万レコードでの検証結果を数十億レコードに線形外挿すると誤差が生じる。I/Oやメモリの振る舞いはデータ規模で変化するため、全量リハーサルが不可欠 やり直せる設計が安心を生む S3を中継することでロード失敗時も再抽出不要で再実行できる。数日かかる処理では「失敗しても復旧できる」という安心感が運用の質を高める この工程が安定したことで、後続の増分同期フェーズへ安全に進められました。 Embulkによる差分同期 初期移行が完了した後も、オンプレミスのSQL Serverには新規データが書き込まれ続けます。この増加分をAurora MySQLへ反映するため、 Embulk を用いた差分同期の仕組みを構築しました。 図中の「 マスタ 」はマイクロサービスがSQL Serverをマスタ(書き込みの主系)として参照・更新することを示しています。「 非同期 」はマイクロサービスがSQL Serverと同じ結果をAurora MySQLへ非同期に反映されることを示しています。「 保存 」はEmbulkジョブ完了後に差分の起点となる状態(config-diff)をS3へアップロードすることを指しています。「 復元 」は次回ジョブ起動時にS3からその状態をダウンロードすることを指しています。これにより前回の続きから差分取得を再開できます。 ジョブ設計 Embulkのインクリメンタル同期では、 updated_at のような更新日時カラムを差分キーとして利用するのがベストプラクティスです。しかし、今回の移行元テーブルはInsert/Deleteのみの操作で設計されており、レコードの更新(Update)が発生しないため updated_at に相当するカラムが存在しません。このテーブルの特性を踏まえ、操作種別ごとに差分キーを使い分ける設計を採用しました。 1つのテーブルに対して役割の異なる最大3つのジョブを用意しています。 ジョブ種別 インクリメンタル列 対象レコード 通常ジョブ 登録日( registered_at ) 新規追加されたレコード 削除ジョブ 削除日( deleted_at ) 論理削除されたレコード アーカイブジョブ 連番ID 削除テーブルへ移動済みのレコード 通常ジョブは登録日、削除ジョブは削除日をそれぞれ基準にレコードを取得します。 -- 通常ジョブ WHERE registered_at >= :registered_at -- 削除ジョブ WHERE deleted_at IS NOT NULL AND deleted_at >= :deleted_at アーカイブジョブでは、Embulkの before_load と after_load フックを活用し、以下の3ステップを1つのジョブ内で完結させています。 out : mode : merge_direct before_load : > UPDATE watermark SET id = (SELECT COALESCE(MAX(id), 0) FROM archived_favorites) after_load : > DELETE FROM favorites WHERE EXISTS ( SELECT 1 FROM archived_favorites WHERE archived_favorites.favorite_id = favorites.id AND archived_favorites.id >= (SELECT id FROM watermark) ) before_load でロード前のアーカイブテーブルの最大IDをウォーターマークとして記録し、 after_load でウォーターマーク以降の新規アーカイブ分に対応するお気に入りレコードを物理削除します。ウォーターマークがなければアーカイブテーブル全レコードが削除対象となり、毎回全件スキャンが発生します。ウォーターマークにより、今回のジョブで追加された差分だけに処理を限定しています。この設計により、お気に入り商品・ブランド・ショップの各テーブルに対してゲスト・会員の2種類を掛け合わせた複数パターンの差分同期を体系的に管理しています。 ソースDBへの負荷制御 差分同期では稼働中のオンプレミスSQL Serverからデータを読み取ります。本番サービスへの影響を抑えるため、複数のパラメータで負荷を制御しました。 # 共通入力設定(抜粋) in : type : sqlserver transaction_isolation_level : NOLOCK # ロック競合を回避 fetch_rows : 1000 # メモリ消費を抑制 SELECT TOP 10000 -- 1回あたりの取得行数を制限 registered_at, id, member_id, ... FROM favorites WITH (NOLOCK) WHERE registered_at >= :registered_at ORDER BY registered_at OPTION (MAX_GRANT_PERCENT = 25 ) -- クエリのメモリグラント上限を設定 NOLOCK ヒントでロック競合を回避し、 TOP N 句で1回あたりの取得行数を制限しています。 fetch_rows でJDBCのフェッチサイズを制御し、 MAX_GRANT_PERCENT でSQL Serverのクエリメモリグラント上限を設定しました。 また、embulk-input-sqlserverのインクリメンタルロードでは、対応する列型が整数型・文字列型・ datetime2 型に 限定されています 。しかし、移行元テーブルの日時カラムは smalldatetime 型であり、そのままではインクリメンタル列として使用できません。この制約の回避策として、クエリ内で CAST(削除日カラム AS DATETIME) と明示的に型変換しています。 データ整合性の担保 差分取得では > ではなく >= を使用しています。 > の場合、同一タイムスタンプに複数レコードが存在すると一部を取りこぼすリスクがあります。 >= では前回の最終レコードを重複取得する可能性があります。しかし、Embulkの出力モードを merge_direct に設定すれば、重複分はUPSERTとして吸収されます。 out : mode : merge_direct 「取りこぼし」と「重複」のトレードオフにおいて、 重複を許容しつつ冪等性で吸収する 方針を採用しました。 差分の起点となる状態管理にも工夫が必要でした。Embulkは --config-diff オプションにより、前回処理の最終レコード( last_record )をYAMLファイルに記録します。 in : last_record : [ '2023-12-23T09:00:30.000000' ] out : {} しかし、Kubernetes Jobとして実行する場合、Podはジョブ完了後に破棄されます。ローカルファイルシステム上の差分状態は失われるため、S3に永続化する仕組みを構築しました。 ジョブ開始時にS3から前回の差分状態をダウンロード Embulkによる差分同期の実行と差分状態の更新 ジョブ完了時に更新された差分状態をS3にアップロード ここで、ダウンロードとアップロードの失敗は致命的エラーとしてジョブを失敗させます。 設定管理とチューニング 複数パターンの設定ファイルは、対象テーブルやカラム名が異なるものの接続情報やパラメータは共通しています。EmbulkのLiquidテンプレート機能を活用し、共通部分を3つのテンプレートに集約しました。 共通テンプレート 役割 入力設定 SQL Server接続情報、トランザクション分離レベル、フェッチサイズ 出力設定 MySQL接続情報、出力モード SELECT句生成 環境変数に基づく TOP N 句の条件付き生成 個別の設定ファイルでは共通テンプレートをインクルードし、テーブル名・カラム名・WHERE句のみを定義します。SELECT句の共通テンプレートでは、環境変数が未設定の場合は TOP 句自体を生成せず、設定されている場合のみ行数制限を付与する条件分岐を実現しています。これにより、本番環境では制限なし、検証環境では制限ありという切り替えが可能です。 負荷制御パラメータ( TOP N 、 fetch_rows 、 MAX_GRANT_PERCENT 等)もすべて環境変数に切り出しており、コンテナイメージの再ビルドなしに変更を反映できます。テーブル単位で処理時間を計測してボトルネックを特定し、検証環境での調整結果を本番環境へ反映するサイクルを効率的に回せる設計です。 まとめ 本記事では、ZOZOTOWNのお気に入りデータベースにおける数十億レコード規模のデータ移行について、初期移行の最適化とEmbulkを用いた差分同期の取り組みを紹介しました。 初期移行では、インデックスの後付けやテーブル規模に応じたインスタンスサイズの使い分けにより、約5日間で全テーブルの移行を完了しました。差分同期では、 updated_at カラムが存在しない制約に対し、役割の異なる複数ジョブを設計することで、サービス無停止のまま増分データの反映を実現しました。 大規模データ移行やEmbulkによる異種DB間の差分同期を検討されている方にとって、本記事が参考になれば幸いです。今後はAurora MySQL単体運用への切り替えを進め、お気に入り機能のマイクロサービス化を完遂していきます。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
ZOZO開発組織の2026年2月分の活動を振り返り、ZOZO TECH BLOGで公開した記事や登壇・掲載情報などをまとめたMonthly Tech Reportをお届けします。 ZOZO TECH BLOG 2026年2月は、前月のMonthly Tech Reportを含む計16本の記事を公開しました。特に次の3記事は反響も大きく、とても多くの方に読まれています。いずれも「Claude Code」に関連した記事です。ぜひご一読ください。 techblog.zozo.com techblog.zozo.com techblog.zozo.com 登壇 CA DATA NIGHT#8 〜ZOZO×CA:マーケティングの意思決定を支えるデータサイエンス〜 2月5日に開催された「 CA DATA NIGHT#8 〜ZOZO×CA:マーケティングの意思決定を支えるデータサイエンス〜 」に、ビジネスアナリティクス部の茅原( @yusukekayahara )が登壇しました。 ZOZO.swift #2 2月10日にZOZOで主催した「 ZOZO.swift #2 」に、ZOZOTOWN開発1部の濵田( @ios_hamada )、ZOZOTOWN開発2部の森口( @laprasdrum )と續橋( @tsuzuki817 )、FAANS部の上田( @15531b )、ZOZOFIT開発部の渡邊が登壇しました。 techblog.zozo.com モバイルアプリの長期運用と向き合う ~10年以上続くアプリで重ねてきた判断と工夫~ 2月19日に開催された「 モバイルアプリの長期運用と向き合う ~10年以上続くアプリで重ねてきた判断と工夫~ 」に、ZOZOTOWN開発本部の髙井が登壇しました。 findy-code.io 掲載 Think IT Think ITに、昨年開催された「 GitHub Universe 2025 」に現地参加したFAANS部の輿水が座談会メンバーのひとりとして参加し、そのインタビュー記事が掲載されました。 GitHub Universe 2025、日本からの参加者による座談会を開催 | GitHub Universe 2025レポート | Think IT(シンクイット) ZOZO TECH BLOGに公開した、輿水の参加レポートもあわせてご覧ください。 techblog.zozo.com 以上、2026年2月のZOZOの活動報告でした! ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
はじめに こんにちは。基幹システム本部・リプレイス推進部・リプレイス推進ブロックの岡本です。 私たちのチームでは、ZOZOの基幹システムリプレイスの一環として、会計領域のシステムを新規構築しています。アーキテクチャにはCQRS(Command Query Responsibility Segregation)+ES(Event Sourcing)を採用しました(以降、CQRS+ESと略記します)。 本記事では、CQRS+ESを実務へ適用する中で直面した「小さな集約を保ちながら、大量の集約をまたいだ業務出力をどう実現するか」という課題と、その解決で得られた知見を紹介します。 会計システムでは、決済に関連する明細データを決済ID単位の小さな集約(Aggregate)として設計しています。一方で、消込結果を月次でまとめた帳票を出力するようなユースケースでは数万件規模の集約を横断する必要があり、集約の境界と業務出力のスコープに不一致が生じます。この不一致により、Sagaによる協調の結果を1つのイベントでQuery側に届ける必要が生まれ、イベントペイロードの肥大化が問題となりました。私たちはこの問題を共有テーブルとシグナルイベントを組み合わせたパターンで解決しました。 なお、本記事で述べる会計システムの仕様は、実装上の問題構造を説明するために簡略化・抽象化したものであり、実際のシステム仕様とは異なります。CQRS+ESを実務に適用する中で同様の課題に直面している方々の一助となれば幸いです。 目次 はじめに 目次 背景 基幹システムリプレイスの概要 会計システムの概要 本記事のスコープと想定読者 なぜCQRS+ESを選んだか インフラ構成の選択 ── RDB 1つでCQRS+ESを実現する 集約の境界と業務出力のスコープの不一致 小さな集約と大きな出力 スコープの不一致が生む課題 Sagaで複数集約を協調させる Sagaによる協調の構成 協調の次に来る問題:Query側へのデータ伝達 Query側へのデータ伝達 ── イベントに載せきれないとき ベストプラクティス:イベントに全情報を載せてQuery側に渡す 数万件規模のデータをイベントに載せるべきか? 採用したパターン:共有テーブル+シグナルイベント このパターンの解釈 CQRS+ESを実践してみて まとめ 背景 基幹システムリプレイスの概要 ZOZOの基幹システムは、20年以上にわたり機能追加を重ねてきた大規模モノリスです。技術的負債の蓄積により保守・拡張コストが増大していたことから、現在、全社的な基幹システムリプレイスプロジェクトが進行しています。 このリプレイスでは、重要度と移行コストの両面を考慮した上で優先度をつけ、モノリスからの段階的な移行を進めています。リプレイスプロジェクトの背景や先行事例については「 モノリスからマイクロサービスへ─ZOZOBASEを支える発送システムリプレイスの取り組み 」で詳しく紹介しています。 最新の基幹システムリプレイスの状況については「 巨大モノリスのリプレイス──機能整理とハイブリッドアーキテクチャで挑んだ再構築戦略 」の発表資料にまとめています。発表の様子は「 アーキテクチャConference 2025 協賛&参加レポート 」で紹介しています。 会計システムの概要 私たちが取り組んでいる会計システムリプレイスは、発送システムと同様に基幹システムから独立したマイクロサービスとして新規に構築しています。 会計システムが扱うドメインの中核は、「弊社システムの売上実績のデータ」と「決済代行会社などの外部システムの入金実績のデータ」を突合する処理です。 会計用語でいう「入金の消込」にあたります。売上と入金の明細は各々任意のタイミングで到着します。その都度、決済ID単位で明細を照合し消込処理を実行する必要があります。 本記事のスコープと想定読者 このシステムのアーキテクチャとして、CQRS+ESを採用しました。本記事ではCQRS+ESの採用理由にも軽く触れますが、本題は Aggregateの整合性境界と業務出力のスコープが一致しない場合 に生じる設計課題と、その解法です。具体的には、数万件規模のデータをどのようにQuery側に届けるかという問題を扱います。 想定読者はCQRS+ESの基本的な概念を理解している方です。何らかのCQRS+ESフレームワークに触れたことがある方は、より興味深く読んでいただけます。 なぜCQRS+ESを選んだか 会計システムでは、すべての業務操作の履歴を厳密に記録し、後から追跡可能にすることが求められます。 Event Sourcing では、ビジネスエンティティの状態を「状態変更イベントの列」として永続化します。そのため、業務イベントの履歴がそのまま監査ログとして機能するという性質が、会計ドメインの要件と合致しました。 ここで重要なのは、ログとイベントの違いです。ログを記録するだけでは、ログと実際のシステムの動作が整合している保証はありません。一方、ESではイベント(事実)がすべての起点であり、イベントと動作が必ず整合します。会計システムにおいて「何が起きたか」を正確に追跡できることは、監査の観点から本質的な要件です。そのため、ESの採用が適切であると判断しました。 また、Queryの都合を気にしてドメインモデルを構築すると、最も重要なCommand側のロジック管理が複雑化します。CQRSによりCommandとQueryのモデルを分離することで、それぞれの関心事に集中した設計が可能になります。 社内の技術スタックをJavaに統一しており、Java上でCQRS+ESを実現するフレームワークとして Axon Framework を採用しました。Axon Frameworkを選定した理由の1つは、CQRS+ESの実践に必要なプラクティスがフレームワークレベルで用意されている点です。具体的には、以下のような仕組みがフレームワークとして提供されています。 イベントの永続化とリプレイ スナップショットによる集約の復元最適化 Sagaによる複数集約の協調 Processing Groupとセグメントによる並列処理の制御 これらを自前で実装する必要がないことで、CQRS+ESの基盤構築ではなく、ドメインの設計に集中できると判断しました。 インフラ構成の選択 ── RDB 1つでCQRS+ESを実現する 一般的なCQRSアーキテクチャでは、Command側とQuery側を別々のデータストアに分離し、メッセージブローカーを介してイベントを伝達する構成が採用されます。下図は、 Axon公式ドキュメント に示されている一般的なCQRSアプリケーションの技術概要を参考に再作成したものです 1 。 公式図では、Event Store・Event Bus・Query側のデータベースがそれぞれ独立したコンポーネントとして描かれています。これらのインフラ構成には複数の選択肢があります。たとえばイベントストアとメッセージルーティングを一体で提供するAxon Serverや、Event BusにKafkaなどのメッセージブローカーを採用する構成が考えられます。 私たちのシステムではESの主な採用動機が監査ログの実現であり、高いスケーラビリティや外部システムへのイベント連携は要件ではありませんでした。そのため、これらの選択肢を以下の2つの観点から評価した結果、いずれも採用を見送りました。 金銭的コスト :Axon Serverのクラスタ構成のライセンス費用や、メッセージブローカーの追加インフラコストが発生する 学習コスト :チームにとってなじみの薄い技術スタックを導入した場合、学習コストと運用負荷が高くなる チームに知見のあるRDBのみの構成でも要件を満たせることがわかり、 Event Store・Event Bus・Read Modelをすべて単一のRDB上で実現する構成 を採用しました。下図は、今回採用した単一RDB構成を示しています。 今回の構成では、独立したEvent Busコンポーネントは存在しません。Axon FrameworkがEvent Store( domain_event_entry テーブル)をポーリングすることで、Event Busの役割を実現しています。また、RDB上でのパフォーマンスを確保するために、Axon公式の RDBMSチューニングガイド を参考にインデックス設定等のチューニングを行っています。 私たちの構成では、同一データベース内にCommand側テーブル、Query側テーブル、そして共有テーブルが同居しています。Command側のテーブル( domain_event_entry や token_entry 等)はAxon Frameworkが内部的に利用するテーブルであり、フレームワークが必要とするスキーマをそのまま作成しています。Query側のテーブルはRead Modelを表す rm_ プレフィックスで管理しています。共有テーブルは標準構成ではなく私たちが独自に導入したものであるため、図中では点線で表記しています。詳細は次章以降で説明しますが、この「すべてが同一データベース内に存在する」という構成が、共有テーブルパターンの前提条件として重要な役割を果たします。 集約の境界と業務出力のスコープの不一致 小さな集約と大きな出力 私たちのシステムでは、 Aggregate(集約) を小さな単位で保つ設計を採用しています。Vaughn Vernon氏は「Effective Aggregate Design」の中で、集約の設計について以下のように述べています。 Limit the Aggregate to just the Root Entity and a minimal number of attributes and/or Value-typed properties. (...) A large-cluster Aggregate will never perform or scale well. (日本語訳)集約はルートエンティティと最小限の属性やValue型プロパティに限定すべきである。(中略)大きなクラスタの集約は、パフォーマンスもスケーラビリティも決して良くならない。 ── Vaughn Vernon, " Effective Aggregate Design Part I " この指針に従い、私たちのシステムでも集約を小さな単位で保っています。「背景」で述べた通り、売上と入金の明細を決済ID単位で照合するため、各集約も同じ粒度で設計しており、毎日膨大な数の集約インスタンスが生まれます。 決済ID単位の小さな集約にする必然性は、各明細が自身の状態に基づいて独立した判断・振る舞いを行う必要があるためです。各集約は消込に関するステータスを内部に保持しています。さらに、各明細に対しては削除コマンドを受け付ける要件があります。削除コマンドを受けた際、その明細がすでに帳票出力済みであれば打ち消しの帳票を出力してから削除するといった、明細単位の状態(消込ステータス、帳票出力済/未済等)に応じた振る舞いの分岐が求められます。このように、個々の明細が自身の状態に基づいて独立して判断する必要があるため、小さな集約としての設計が必然です。 一方で、帳票出力という業務処理は、これら数万件規模の集約を横断する大きなスコープで実行されます。 帳票出力時には数万件規模の集約のステータスを「出力済」に更新し、さらにQuery側(Read Model)では、ステータスが更新された数万件規模のデータをもれなく帳票として出力する必要があります。 スコープの不一致が生む課題 下図は、この「スコープの不一致」を示しています。各集約は決済ID単位の小さな境界を持っていますが、帳票出力のスコープは数万件規模の集約を横断します。 1つの集約のスコープと業務出力のスコープには大きなギャップが存在します。この構造は、小さな集約という設計が正しいからこそ生まれる問題です。集約を大きくすれば解消できますが、それはVernon氏が指摘する「大きな集約のアンチパターン」に陥ることを意味します。したがって、集約の境界はそのまま維持した上で、数万件規模の集約を横断的に協調させる仕組みが必要になります。 Sagaで複数集約を協調させる Sagaによる協調の構成 前章で示した「数万件規模の集約を横断的に協調させる」という課題に対して、 Saga を採用しました。Sagaは、複数のローカルトランザクションを協調させるパターンです 2 。 私たちの構成では、Sagaが数万件規模の集約にCommandを送信し、各集約が処理完了後にEventを返却し、Sagaがそれらを収集して全体の完了を判断します。実際にはSagaを親子に階層化し、親Sagaが子Sagaを複数起動して、子Sagaがバッチ単位で集約を管理する構成を採用しています。これにより、並列処理の流量制御も実現しています。下図は、この協調フローの概念を示しています。 子Sagaは各集約からの完了イベントを受け取るたびに処理済みの件数をカウントし、すべての集約の処理が完了した時点で親Sagaに完了を通知します。なお、集約が別のユースケースで削除済み、またはすでに帳票出力済みであった場合は、帳票出力の対象外であることを示すイベントを返却します。Sagaはこのイベントも処理済みとしてカウントし、帳票には出力しないものとして扱います。親Sagaはすべての子Sagaの完了をもって「全体完了」と判断します。数万件規模の集約を横断的に協調させるという課題自体は、このSagaの階層構造で解決できます。 協調の次に来る問題:Query側へのデータ伝達 Sagaが「全集約の処理が完了した」と判断した次のステップで、新たな問題が生まれます。数万件規模の処理結果を、Query側にどのように届ければよいのでしょうか。 Query側へのデータ伝達 ── イベントに載せきれないとき ベストプラクティス:イベントに全情報を載せてQuery側に渡す CQRS+ESにおけるベストプラクティスは、 イベントに必要な情報をすべて載せてQuery側に渡す ことです。 Microsoftの CQRS Patternガイド では、Command側とQuery側の同期について次のように述べています。 When you use separate data stores, you must ensure that both remain synchronized. A common pattern is to have the write model publish events when it updates the database, which the read model uses to refresh its data. (日本語訳)別々のデータストアを使用する場合、両方の同期を保つ必要があります。一般的なパターンは、書き込みモデルがデータベースを更新する際にイベントを発行し、読み取りモデルがそのイベントを使用してデータを更新するというものです。 ── Microsoft Azure Architecture Center, "CQRS Pattern" イベントがすべての情報を運ぶことにより、Query側はCommand側のデータストアを直接参照する必要がなくなります。この「イベントを通じた疎結合」こそがCQRSの根幹です。Query側のProjection(イベントからRead Modelを導出する処理)は、受信したイベントのペイロードだけでRead Modelを構築できます。そのため、Command側とQuery側の独立性が保たれます。 数万件規模のデータをイベントに載せるべきか? 私たちのケースでこのベストプラクティスをそのまま適用できるでしょうか。前章で示した通り、Sagaが全集約の完了を検知した時点で数万件規模の処理結果をQuery側に届ける必要があります。ベストプラクティスに従えば、これらすべてのデータを完了イベントのペイロードに含めるべきです。 しかし、ここには2つの問題があります。1つ目は ペイロードの肥大化 です。数万件規模の集約に関するデータを1つのイベントに詰め込むことは、シリアライズ・デシリアライズのコストやメモリ使用量の観点から非効率です。2つ目は Query側での利用形態との不一致 です。帳票出力の後続処理では、前段のProjectionで構築済みの rm_ テーブルとのJOINが必要です。仮にイベントペイロードにデータを収められたとしても、Query側で結局テーブルに展開してJOINすることになるため、イベント経由で運ぶ利点は薄れます。 採用したパターン:共有テーブル+シグナルイベント 先述の問題に対して、いくつかの方針を検討しました。 1つ目は Query側のProjectionで完結させるアプローチ です。各集約の処理完了イベントをProjectionが受信して rm_ テーブルに書き込み、すべての書き込みが終わった後に帳票を出力する方式です。 しかし、数万件規模のイベントを実用的な時間内に処理するにはProjectionの並列化が必須です。Axon FrameworkのTracking Processorでは、複数のセグメントがイベントを分担して並列に処理します。同一セグメント内ではイベントの処理順序が保証されますが、完了イベント(シグナルイベント)と各集約の処理完了イベントは異なるセグメントに振り分けられうることが問題です。 異なるセグメント間では処理の進行度が異なるため、あるセグメントが完了イベントを処理した時点で、別セグメントではまだ処理が完了していない可能性があります。 つまり、シグナルイベントがProjectionに届いた時点で rm_ テーブルへの書き込みが完了していない可能性があり、データの欠損が生じます。これを防ぐにはProjectionに協調ロジックが必要ですが、それはSagaの責務であり、Projectionの関心事の分離を崩すため、見送りました。 2つ目は イベントの分割送信 (チャンク化)です。数万件のデータをN件ずつ複数のイベントに分割して送信する方式です。しかし、この方式ではQuery側のProjectionが「すべてのチャンクが届いたか」を判定する協調ロジックを持つ必要があり、1つ目と同じ問題構造を抱えるため、見送りました。 3つ目は Claim Checkパターン です。イベントにはデータ本体を載せず、外部ストレージへの参照のみを含める方式です。技術的には実現可能ですが、以下の理由から見送りました。 外部ストレージの導入は「インフラ構成の選択」で述べた単一RDB構成の方針を崩す 外部ストレージへの書き込みはEvent Storeと別トランザクションになり、障害時の整合性担保が複雑化する これらの検討を経て、私たちは単一RDB構成の利点を活かした 共有テーブルとシグナルイベントを組み合わせたパターン を採用しました。前述の通り、個々の明細データは通常のProjectionでRead Modelに構築済みです。不足しているのは、どの明細がどの帳票に属するかという対応関係です。このパターンの構成は以下の通りです。 Sagaは帳票出力フローの開始時に帳票IDを採番し、各集約にCommandを送信する。処理完了イベントを受信するたびに、 同一トランザクション内で 帳票IDと明細IDの対応関係を 共有テーブル に逐次書き込む すべての集約の処理が完了したら、Sagaは 完了イベント を発行する(ペイロードは最小限のシグナルのみ) Query側のProjectionは完了イベントをトリガーとして受信し、帳票出力が可能になったことを示すRead Model( rm_ テーブル)を作成する 後続のレポート生成処理がこのRead Modelを検知し、帳票のRead Model・共有テーブル・明細のRead Modelを順にJOINして帳票データを取得する ステップ1のポイントは、Axon FrameworkのSagaがイベントハンドラの処理をUnit of Work(UoW)パターンで管理している点です。イベントの受信と共有テーブルへの書き込みが同じトランザクションで実行されるため、すべての集約の処理が完了した時点では、対応するデータが共有テーブル上にも確実にそろっています。 ここで重要なのは、「インフラ構成の選択」で説明した 単一RDB構成 です。Command側テーブル、Query側テーブル、そして共有テーブルがすべて同一のデータベース内に存在するため、共有テーブルへの書き込みとJOINによる読み取りが自然に実現できます。もしCommand側とQuery側が異なるデータストアに分離されていたら、このパターンは成立しません。 先述のProjection完結アプローチで問題となったセグメント間の進行度の差は、本パターンでは構造的に発生しません。共有テーブルへの書き込みをSagaが担い、すべての書き込みが完了した後に初めて完了イベントを発行するためです。 このパターンの解釈 このパターンでは文字通りCommand側とQuery側でテーブルを共有しています。これはCQRSの原則「Command側とQuery側はイベントを通じてのみ情報をやり取りする」からの意図的な逸脱です。将来的なデータストアの物理分離が難しくなるトレードオフはありますが、以下の2点を考慮し採用しました。 現時点でCommand側とQuery側の物理分離は想定されないこと 共有テーブルは明示的に設計・管理されており、暗黙の依存ではないこと。将来的に物理分離が必要になった場合も、共有テーブルの参照箇所が明確であるため、段階的な移行が可能であること 実際にこの設計で運用してみて、Projectionのロジックがシンプルに保たれ、Event Storeのペイロード肥大化も回避できている点に手応えを感じています。一方で、共有テーブルのスキーマ変更がCommand側とQuery側の両方に影響する点には注意が必要です。通常のCQRSでは、Command側とQuery側のスキーマを独立に変更できることが利点の1つですが、共有テーブルに関してはこの利点が失われます。 CQRS+ESを実践してみて 本記事で紹介したSagaによる数万件規模の集約の協調は、Axon FrameworkのSagaサポートがなければ実現が困難でした。その場合、Sagaの状態管理やイベントとの紐付けといった基盤部分の実装から始める必要がありました。同様に、スナップショットによる集約の復元最適化やProjectionの進捗管理(Tracking Processor)も、自前で実装していたら多大な工数を費やしていたと考えられます。前述したこれらの基盤が揃っていたからこそ、アーキテクチャレベルの設計課題に対して検討と試行錯誤の時間を確保できました。 加えて、Axon FrameworkでESを実現する中で、集約内部のロジックが関数的な構造になる点にも良さを感じています。集約のCommand Handlerは、Commandを受け取ってEventを発行し、Event Sourcing Handlerは、Eventを受け取って集約の状態を更新します。テストも、Axon Frameworkが提供する テストフィクスチャ を用いて「Given(過去のイベント列)→ When(コマンド)→ Then(期待されるイベント)」という宣言的な形式で記述できます。この構造は、AIによるテスト駆動開発と相性が良いと感じています。入力と出力が明確に定義されているため、AIがテストケースを生成しやすく、またテストの意図が宣言的に表現されるため、AIが生成したテストコードのレビューもしやすいという実感があります。 一方で、ESを本格的に運用する難しさも実感しています。 ESではすべての状態変更が「コマンド → 集約 → イベント」のパイプラインを通ります。ステートソーシングであれば一括更新で済む処理も、集約ごとにコマンドを送信し、個別にイベントを発行しなければなりません。 本記事で扱った集約横断の協調は、まさにこの制約から生まれた設計課題です。 この課題に関連して、近年提唱されている Dynamic Consistency Boundary(DCB) という概念に注目しています。DCBは、一貫性の境界を集約に固定せず、イベントへ付与するタグに基づいて動的に伸縮させるアプローチです。従来のESでは集約の境界が設計時に固定されるため、本記事で扱ったようなSagaによる協調が避けられませんでしたが、DCBによってこの複雑さを軽減できる可能性があります。私たちのユースケースにどこまで適用できるかはまだ未知数ですが、ESの実践的な課題を構造的に解決しうるアプローチとして、今後の動向を追っています。 まとめ 本記事では、会計システムへのCQRS+ES適用において、小さな集約を保ちながら大量の集約をまたいだ業務出力を実現する過程で得られた知見を紹介しました。 小さな集約を正しく設計するほど、業務出力のスコープとの不一致が顕在化します。Sagaで数万件規模の集約を協調させることはできますが、その結果をQuery側に届ける段階で「イベントに載せきれない」という壁にぶつかりました。共有テーブルとシグナルイベントを組み合わせたパターンを採用し、CQRSの原則からは逸脱しつつも、実用的な解決策にたどり着きました。 CQRS+ESの実装事例はまだ多くなく、今回の実装についても正しいものであるかという不安と向き合いながら進めてきました。リリースしてみて大きな問題は発生しておらず、ポジティブな状況であると捉えています。しかし、ベストプラクティスがさらに確立されてきた際には、それに適応していく姿勢を持ち続けたいと考えています。 本記事では会計領域のリプレイスを紹介しましたが、同じ基幹システムリプレイスの物流領域でもメンバーを募集しています。大規模モノリスからのサービス分割に取り組むポジションで、ドメイン駆動設計やイベント駆動アーキテクチャの知識を活かせる環境です。物流システムの刷新に興味のある方は、ぜひご覧ください。 hrmos.co さらにZOZOでは、一緒にサービスを作り上げてくれる仲間を広く募集中です。ご興味のある方は、以下のリンクからぜひご覧ください。 corp.zozo.com この図はAxon公式ドキュメント「Architecture Overview」の図を参考に、本記事で必要な構成要素に絞って再作成したものです。 ↩ 厳密には、SagaとProcess Managerは 異なる概念 です。Sagaは補償トランザクションに焦点を当てたパターンであるのに対し、Process Managerは状態マシンとしてモデリングされ、受信イベントと現在の状態に基づいて判断を下します。Axon Frameworkでは @Saga アノテーションを使用して、Orchestration方式のProcess Managerを実装しています。本記事では、フレームワークの慣例に合わせて「Saga」と表記します。 ↩
アバター
はじめに こんにちは。グローバルプロダクト開発本部 グローバルアプリ部 アプリ基盤ブロックの桂川です。普段はZOZOFIT・ZOZOMETRYなどの計測アプリのAndroid開発に携わっています。本記事ではZOZOFITのAndroidアプリで取り組んだMVVMからMVIへの移行と、独自MVIライブラリの開発について紹介します。なお、独自MVIライブラリを使ったMVIアーキテクチャへの移行は2024年9月に開始しました。 目次 はじめに 目次 用語 ZOZOFIT MVVM SSOT UDF MVI 私たちのMVVMアーキテクチャの問題点 ViewModelでのState管理が複雑に ViewとViewModelの責務が曖昧に イベント通知と画面遷移の不統一 私たちのMVVMアーキテクチャの改善方針 UiStateによるState管理の単純化 ユーザー操作ごとのメソッド定義による責務の明確化 Channelによるイベント通知と画面遷移の統一 私たちのMVVMアーキテクチャの改善方針を運用できるか MVIアーキテクチャの導入と独自ライブラリの作成 データフロー 実装 インタフェースの定義 移譲を用いたインタフェースの実装 MVIアーキテクチャを独自MVIライブラリで実装する Contract: State・Action・SideEffectの定義 ViewModel: Actionの処理とState更新 View: MviContentによるCompose連携 テスト: Actionを送信してState・SideEffectを検証 MVVMアーキテクチャからMVIアーキテクチャに移行してみて チーム全体で一貫した実装ができるようになった PRレビューの質が向上した AIコーディングエージェントとの協業がしやすくなった まとめ 用語 まず、本記事で使用する用語を整理します。 ZOZOFIT ZOZOFITは、自宅で手軽に高精度な3Dボディスキャンができる体型管理サービスです。ZOZOSUITと専用スマートフォンアプリを活用し、全身3Dスキャンが可能です。計測データに基づき、体の変化を3Dモデルと数値で可視化できます。栄養素を記録・分析するフードジャーナル機能など、計測以外の機能でも総合的な健康管理をサポートしています。本記事ではアメリカなど海外で展開しているZOZOFITのAndroidアプリでの改善についてお話しします。 zozofit.com MVVM MVVM(Model-View-ViewModel)は、UIの状態を管理するアーキテクチャスタイルの1つです。Model・View・ViewModelの3要素で構成され、ViewModelがModelとViewの仲介役を担います。ViewはViewModelが公開する状態を監視して画面に反映し、ユーザー操作はViewModelのメソッドを呼び出すことで処理されます。Androidアプリ開発で広く採用されているアーキテクチャです。データの流れは次のとおりです。 Viewがユーザー操作をViewModelのメソッド呼び出しとして送る ViewModelが状態を更新し、StateFlowで公開する ViewがStateFlowを購読して画面に反映する SSOT SSOT(Single Source of Truth)は、各データ型に対して唯一の信頼できるデータソースを持つ考え方です。SSOTだけがデータを変更でき、不変の型で公開します。これによりデータの変更が1箇所に集約され、他の型による改ざんを防ぎ、バグの追跡を容易にします。 UDF UDF(Unidirectional Data Flow)は、SSOTと組み合わせて使用されるパターンです。状態(データ)は上位から下位へ一方向に流れ、状態を変更するイベントはその逆方向に流れます。具体的には次の流れでデータが更新されます。 Android公式ドキュメント でも、堅牢なアーキテクチャの原則としてSSOTとUDFが示されています。この2つをセットで守ることで、データの整合性が保たれ、デバッグ・テスト・レビューがしやすくなります。本記事で紹介するMVIアーキテクチャもこの原則に基づいており、SSOTとUDFの理解が必要です。 ユーザー操作(ボタン押下など)が下位スコープで発生する イベントが下位スコープから上位スコープ(SSOT)へ向かって流れる SSOTでデータが変更され、不変の型として公開される 変更された状態が上位スコープから下位スコープへ流れる 下位スコープが新しい状態を受け取り、表示を更新する MVI MVI(Model-View-Intent)は、UDFの原則に基づいてUIの状態を管理するアーキテクチャスタイルの1つです。データの流れが一方向に固定されるため、状態変更の起点と結果が追跡しやすくなります。MVIの名前はModel・View・Intentの頭文字に由来しており、以下の3要素で構成されます。なお、本記事では用語の紛らわしさを避けるため、以降ModelをState、IntentをActionと呼びます。 要素 役割 Model(State) 画面の現在状態を表すデータ。UIはこの値のみから構築される。 View Stateを受け取って画面に反映し、ユーザー操作をActionとして発行する。 Intent(Action) ユーザー操作や外部イベントなど、状態更新のきっかけとなる入力。 Viewがユーザーの操作をActionとして発行する ActionをもとにStateが更新される 更新されたStateがViewへ通知され、画面に反映される 私たちのMVVMアーキテクチャの問題点 ZOZOFITのAndroidアプリは2022年のリリース当初からJetpack Composeを採用しており、当時からMVVMアーキテクチャを採用して開発を続けていました。私たちのMVVMアーキテクチャではViewModelで定義したStateFlowをViewで購読し、ViewModelのメソッドをViewから呼び出して状態を更新する、というシンプルな設計でした。 class CounterViewModel : ViewModel() { private val _counter = MutableStateFlow( 0 ) val counter: StateFlow< Int > = _counter.asStateFlow() fun increment() { _counter.value + = 1 } fun decrement() { _counter.value - = 1 } fun reset() { _counter.value = 0 } } しかし開発が進み画面数や機能が増えるにつれて、Jetpack ComposeとMVVMの組み合わせにおいて、いくつかの問題が顕在化していきました。特にStateFlowの管理やイベント通知の設計がチーム内で統一されておらず、不具合やレビュー負荷の増加につながっていました。具体的には以下のような課題がありました。 ViewModelでのState管理が複雑に 表示データごとに個別のStateFlowを定義していたため、画面が複雑になるほど Flow.map や combine による合成が増えていきました。各Flowの更新タイミングが把握しづらくなり、意図しない再Composeや画面のチラつきが発生していました。 // CounterViewModel.kt: 表示データごとに個別のFlowが定義されている class CounterViewModel : ViewModel() { private val _counter = MutableStateFlow( 0 ) val counter: StateFlow< Int > = _counter.asStateFlow() // Flow.mapで派生StateFlowを作成 → 更新タイミングが分かりにくい val doubleCount: StateFlow< Int > = _counter.map { it * 2 } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 0 ) val tripleCount: StateFlow< Int > = _counter.map { it * 3 } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 0 ) } またView側のComposable関数でも引数が増えていく傾向がありました。View側のコードに多くの collectAsState が定義され、見通しが悪く、管理が難しいコードになることも多々ありました。 // CounterScreen.kt: Flowごとに個別にcollectし、引数が増えていく @Composable fun CounterScreen(viewModel: CounterViewModel, navController: NavController) { val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() val counter by viewModel.counter.collectAsStateWithLifecycle() val doubleCount by viewModel.doubleCount.collectAsStateWithLifecycle() val tripleCount by viewModel.tripleCount.collectAsStateWithLifecycle() CounterScreenContent( isLoading = isLoading, counter = counter, doubleCount = doubleCount, tripleCount = tripleCount, onIncrement = { /* ... */ }, onDecrement = { /* ... */ }, onReset = viewModel :: reset, // ... ) } ViewとViewModelの責務が曖昧に ViewがViewModelの構造を知りすぎるコードになりがちで、本来ViewModelで完結すべきロジックがView側に漏れ出していました。ViewModelのプロパティを直接読み取って条件分岐する実装や、複数メソッドを特定の組み合わせで呼び出す実装が各所に存在していました。 // ViewがViewModelのプロパティを直接読み取ってToast表示を制御している val context = LocalContext.current Button( onClick = { viewModel.increment() if (viewModel.currentCount == 10 ) { Toast.makeText(context, "10に到達しました" , Toast.LENGTH_SHORT).show() } } ) { Text( "Increment" ) } // 1つのユーザー操作に対してView側が複数メソッドを組み合わせて呼んでいる Button( onClick = { viewModel.increment() viewModel.checkLimit() } ) { Text( "Increment" ) } このようにViewがViewModelの構造を知りすぎているため、機能変更時の影響範囲が広がりやすくなり、レビュー負荷や不具合の原因になっていました。 イベント通知と画面遷移の不統一 Toast表示や画面遷移といった一度きりの処理について、実装パターンが明確に統一されていませんでした。Toast表示ではViewModelからイベントを発行してView側で購読するパターンと、View側でStateを直接監視して処理するパターンが混在していました。 // CounterScreen.kt: ViewModelのイベント経由でToast表示 LaunchedEffect( Unit ) { viewModel.event.collect { event -> when (event) { is CounterEvent.ShowToast -> Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() } } } Button(onClick = { viewModel.increment() }) { Text( "Increment" ) } // CounterScreen.kt: View側でStateを直接監視してToast表示 val counter by viewModel.counter.collectAsStateWithLifecycle() LaunchedEffect(counter) { if (counter >= 10 ) { Toast.makeText(context, "10に到達しました" , Toast.LENGTH_SHORT).show() } } Button(onClick = { viewModel.increment() }) { Text( "Increment" ) } 画面遷移についてもViewModelのイベント経由で遷移するパターンと、Composable関数から直接Navigatorを呼び出すパターンが混在していました。 // CounterScreen.kt: ViewModelのイベント経由で画面遷移 LaunchedEffect( Unit ) { viewModel.event.collect { event -> when (event) { is CounterEvent.NavigateSetting -> navController.navigateSetting() } } } Button(onClick = { viewModel.navigateSetting() }) { Text( "Setting" ) } // CounterScreen.kt: Composable関数から直接Navigatorを呼び出して画面遷移 Button(onClick = { navController.navigateSetting() }) { Text( "Setting" ) } 方式が統一されていないため、新しい画面を実装する際にどの方式へ合わせるべきか判断しづらく、開発者ごとの実装のばらつきを招いていました。さらにStateを直接監視する方式では、画面に戻ってきた際にイベントが再発火して意図しない動作が発生する不具合も起きていました。 私たちのMVVMアーキテクチャの改善方針 これらの問題を放置すれば開発効率・品質ともに低下し続けるため、各課題に対して以下のような解決方針を考え、まずは既存のMVVMアーキテクチャの枠組みの中で改善できないか検討を進めました。 課題 解決方針 State管理の複雑化 画面の状態を1つのdata classに集約し、単一のStateFlowで管理する ViewとViewModelの責務が曖昧 ユーザー操作をイベントとして定義し、処理をViewModel内に集約する イベント通知と画面遷移の不統一 イベント通知をChannelに統一し、画面遷移もイベント経由に統一する UiStateによるState管理の単純化 SSOTの原則に従い、画面の状態を1つのdata classに集約して単一のStateFlowで管理する方針を考えました。Viewは信頼できる唯一のソースを購読して画面に反映するだけのシンプルな構造になります。また状態の更新が _state.update に集約されるため、 Flow.map や combine による合成が不要になり、更新タイミングも制御しやすくなると考えました。 // CounterUiState.kt: 画面の状態を1つのdata classに集約し、派生値もdata class内で計算する data class CounterUiState( val count: Int = 0 , ) { val doubleCount: Int get () = count * 2 val tripleCount: Int get () = count * 3 } // CounterViewModel.kt: 単一のStateFlowで管理し、ユーザー操作ごとにメソッドを定義 class CounterViewModel : ViewModel() { private val _state = MutableStateFlow(CounterUiState()) val state: StateFlow<CounterUiState> = _state.asStateFlow() fun onIncrementClicked() { _state.update { it.copy(count = it.count + 1 ) } } } // CounterScreen.kt: View側は単一のStateを購読するだけ @Composable fun CounterScreen(viewModel: CounterViewModel, /* ... */ ) { val state by viewModel.state.collectAsStateWithLifecycle() CounterScreenContent( state = state, onIncrement = viewModel :: onIncrementClicked, // ... ) } ユーザー操作ごとのメソッド定義による責務の明確化 UDFの原則に従い、ViewからのAction(ユーザー操作)に反応してStateが更新されるシンプルな構造を考えました。ユーザー操作ごとにメソッドを定義し、関連する更新処理をすべてそのメソッド内に集約します。これによりView側はユーザー操作をViewModelに伝えるだけの役割になり、具体的な処理はすべてViewModel側で完結するため、責務が明確になると考えました。 // CounterViewModel.kt: ユーザー操作(Action)ごとにメソッドを定義し、処理をViewModel内に集約 class CounterViewModel : ViewModel() { private val _state = MutableStateFlow(CounterUiState()) val state: StateFlow<CounterUiState> = _state.asStateFlow() fun onIncrementClicked() { viewModelScope.launch { _state.update { it.copy(count = it.count + 1 ) } checkLimit() } } private suspend fun checkLimit() { /* ... */ } } // CounterScreen.kt: ViewはActionを発行するだけ CounterScreenContent( state = state, onIncrement = viewModel :: onIncrementClicked, onDecrement = viewModel :: onDecrementClicked, onReset = viewModel :: onResetClicked, ) Channelによるイベント通知と画面遷移の統一 イベント通知と画面遷移の方式をChannelに統一する方針を考えました。一度限りのイベントをsealed classで定義し、Channelで配信することで、StateFlowのように状態として保持されず再受信による不具合を防げます。 画面遷移もイベントの一種として扱い、すべてViewModel経由で発行する形に統一します。単純な遷移であればViewから直接呼び出す方がシンプルですが、実際には遷移前の条件チェックやパラメータの組み立てが必要になるケースが多いです。そのためViewModel側に集約する方が一貫性を保ちやすいと判断しました。 // CounterEvent.kt: イベントと画面遷移をsealed classで定義 sealed class CounterEvent { data class ShowToast( val message: String ) : CounterEvent() data object NavigateSetting : CounterEvent() } // CounterViewModel.kt: イベント通知と画面遷移をChannelで統一的に配信 class CounterViewModel : ViewModel() { private val _state = MutableStateFlow(CounterUiState()) val state: StateFlow<CounterUiState> = _state.asStateFlow() private val _event = Channel<CounterEvent>(Channel.BUFFERED) val event: Flow<CounterEvent> = _event.receiveAsFlow() fun onIncrementClicked() { viewModelScope.launch { _state.update { it.copy(count = it.count + 1 ) } checkLimit() } } fun onSettingClicked() { viewModelScope.launch { _event.send(CounterEvent.NavigateSetting) } } private suspend fun checkLimit() { val count = _state.value.count if (count >= 10 ) { _event.send(CounterEvent.ShowToast( "10に到達しました" )) } } } // CounterScreen.kt: イベントをChannelで統一的に購読し、画面遷移やToastを一元的に処理 LaunchedEffect( Unit ) { viewModel.event.collect { event -> when (event) { is CounterEvent.ShowToast -> Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() CounterEvent.NavigateSetting -> onNavigateSetting() } } } 私たちのMVVMアーキテクチャの改善方針を運用できるか ここまで紹介した改善方針は、SSOTに基づくState集約、UDFに基づくAction定義、Channelによるイベント通知の統一です。これらは既存のMVVMアーキテクチャの枠組みで実現できることがわかりました。しかしルールとして定めるだけでは、複数人開発の中で徐々に形骸化していくことが課題としてありました。 UiStateにまとめるルールがあっても、急ぎの対応で新しいStateFlowが追加され、元の設計に戻ってしまう ユーザー操作ごとにメソッドを定義する方針でも、View側から複数メソッドを直接呼び出す実装がレビューをすり抜けてしまう Channelに統一するルールがあっても、既存コードを参考にStateFlowでイベント通知を実装してしまう また改善方針を各画面で愚直に実装すると、StateFlowやChannelの定義・購読といったボイラープレートが画面ごとに増加することも課題でした。 MVIアーキテクチャの導入と独自ライブラリの作成 これらの課題から、ルールではなく仕組みとして正しい実装に導かれるよう、MVIアーキテクチャを導入することにしました。 MVIアーキテクチャの導入にあたり、既存のOSSライブラリも検討しました。しかし私たちが必要としているのはシンプルなMVIのデータフローであり、既存のOSSライブラリは多機能で学習コストが高いと感じました。実現に必要なコード量も少なく自分たちで開発できる規模だったため、プロジェクトの特性に合わせた独自MVIライブラリを作成することにしました。 データフロー 独自MVIライブラリでは、前述の改善方針をMVIの設計思想に沿って整理することにしました。MVIのState・View・Actionに加えて、画面遷移やToast表示といった一度限りのイベントを扱うSideEffectを導入しています。 要素 役割 対応する改善方針 State 画面の現在状態を表す単一のdata class。UIはこの値のみから構築される。 SSOTに基づくState集約 View Stateを受け取って画面に反映し、ユーザー操作をActionとして発行する。 - Action ユーザー操作をViewからViewModelへ伝える入力。 UDFに基づくAction定義 SideEffect 画面遷移やToast表示など、一度限りのイベント。ChannelでViewに配信される。 Channelによるイベント通知統一 ViewからActionが送信されると、ViewModelがそれを受け取ってStateを更新するか、SideEffectを発行します。このシンプルなデータフローにより、ユーザー操作がどのように処理されるかを一貫した流れで追えるようにしています。 実装 インタフェースの定義 まず、MVIの各要素に対応するマーカーインタフェースとして MVIState ・ MVIAction ・ MVISideEffect を定義しました。各画面のState・Action・SideEffectクラスへこれらを実装させることで、型パラメータの制約として利用し、誤った型の組み合わせをコンパイル時に検出できます。 次に、MVIのデータフローを実現するための MVI インタフェースを定義しました。Stateの購読( state )、Actionの受け取り( onAction )、Stateの更新( update )、SideEffectの発行( sideEffect )を集約しています。 interface MVIState interface MVIAction interface MVISideEffect interface MVI<State : MVIState, Action : MVIAction, SideEffect : MVISideEffect> { val state: StateFlow<State> val currentState: State val sideEffect: Flow<SideEffect> fun onAction(action: Action) suspend fun update(block: suspend (State) -> State) suspend fun sideEffect(effect: SideEffect) } 移譲を用いたインタフェースの実装 次に、このインタフェースの実装クラスとして MVIDelegate を用意しました。内部ではStateをMutableStateFlowで管理し、SideEffectをChannelで配信しています。ViewModelではKotlinのデリゲートパターン( by mvi(...) )を使うことで、 MVI インタフェースの機能をViewModelへ追加できるようにしました。 class MVIDelegate<State : MVIState, Action : MVIAction, SideEffect : MVISideEffect>( initialState: State, ) : MVI<State, Action, SideEffect> { private val _state = MutableStateFlow(initialState) override val state: StateFlow<State> = _state.asStateFlow() override val currentState: State get () = _state.value private val _sideEffect by lazy { Channel<SideEffect>() } override val sideEffect: Flow<SideEffect> by lazy { _sideEffect.receiveAsFlow() } override fun onAction(action: Action) {} override suspend fun sideEffect(effect: SideEffect) { ... } override suspend fun update(block: suspend (State) -> State) { ... } } fun <State : MVIState, Action : MVIAction, SideEffect : MVISideEffect> mvi( initialUiState: State, ): MVI<State, Action, SideEffect> = MVIDelegate( initialState = initialUiState, savedStateHandle = null , savedStateName = null , ) また、Jetpack ComposeとMVIを接続するための MviContent コンポーザブルも提供しています。内部でStateとSideEffectを購読し、Content層には state と onAction のみが渡されます。開発者は購読の仕方を意識せず純粋なComposable関数を書くだけで済むようにしました。 @Composable fun <State : MVIState, Action : MVIAction, SideEffect : MVISideEffect> MviContent( viewModel: MVI<State, Action, SideEffect>, sideEffect: suspend (SideEffect) -> Unit , content: @Composable (state: State, onMviAction: (Action) -> Unit ) -> Unit , ) { LaunchedEffect( Unit ) { viewModel.sideEffect.collect { sideEffect(it) } } val state by viewModel.state.collectAsStateWithLifecycle() content(state, viewModel :: onAction) } MVIアーキテクチャを独自MVIライブラリで実装する ここからは、独自MVIライブラリを使って実際にCounter画面をMVIアーキテクチャで実装した例を紹介します。Contract・ViewModel・Screen・テストの順に、改善方針がどのようにコードに反映されるかを確認していきます。 Contract: State・Action・SideEffectの定義 画面に必要なState・Action・SideEffectを、1つのContractファイルにまとめて定義します。SSOTの原則に従い画面の状態を CounterState に集約し、UDFの原則に従いユーザー操作を CounterAction として列挙しています。一度限りのイベントは CounterSideEffect として定義します。画面が扱うデータの全体像がこのファイルだけで把握できます。 // CounterContract.kt // SSOT: 画面の状態を1つのdata classに集約 data class CounterState( val count: Int = 0 , ) : MVIState { val doubleCount: Int get () = count * 2 val tripleCount: Int get () = count * 3 companion object { val initialState = CounterState() } } // UDF: ユーザー操作をActionとして型で定義 sealed class CounterAction : MVIAction { data object Increment : CounterAction() data object Decrement : CounterAction() data object Reset : CounterAction() data object ClickSetting : CounterAction() } // Channel: 一度限りのイベントと画面遷移をSideEffectとして定義 sealed class CounterSideEffect : MVISideEffect { data class ShowToast( val message: String ) : CounterSideEffect() data object NavigateSetting : CounterSideEffect() } ViewModel: Actionの処理とState更新 ViewModelでは MVI インタフェースをデリゲートパターン( by mvi(...) )で利用します。 by mvi() を使うことでStateFlowを用いたState管理とChannelを通じたSideEffect配信がライブラリ側で強制されるため、開発者が独自にFlowを定義する余地がなくなります。すべてのユーザー操作は onAction で一元的に受け取ります。Actionの種類に応じて update でStateを更新し、 sideEffect を通じてイベントを送信します。 // CounterViewModel.kt @HiltViewModel class CounterViewModel @Inject constructor () : ViewModel(), MVI<CounterState, CounterAction, CounterSideEffect> by mvi(CounterState.initialState) { override fun onAction(action: CounterAction) { viewModelScope.launch { when (action) { CounterAction.Increment -> reduceIncrement() CounterAction.Decrement -> reduceDecrement() CounterAction.Reset -> reduceReset() CounterAction.ClickSetting -> sideEffect(CounterSideEffect.NavigateSetting) } } } private suspend fun reduceIncrement() { update { it.copy(count = it.count + 1 ) } checkLimit() } private suspend fun reduceDecrement() { update { it.copy(count = it.count - 1 ) } } private suspend fun reduceReset() { update { CounterState.initialState } } private suspend fun checkLimit() { val count = currentState.count if (count == 10 ) { sideEffect(CounterSideEffect.ShowToast( "10に到達しました" )) } } } ViewからActionが送信され、 onAction 内でそのActionに対する処理がすべて完結します。View側が複数メソッドを組み合わせて呼び出す必要がなくなり、呼び忘れや順序ずれが構造的に発生しなくなります。画面遷移もSideEffectとして onAction 内から発行されるため、遷移の起点がViewModel側に集約されます。 View: MviContentによるCompose連携 この例では、View層をScreenとContentに分けて実装しています。Screenでは MviContent を使ってStateの購読とSideEffectの処理を接続します。 MviContent の内部でStateとSideEffectの購読が行われるため、Contentには state と onAction のみが渡されます。ContentはStateを表示してActionを送信するだけの純粋なComposable関数になります。 // CounterScreen.kt @Composable fun CounterScreen( mvi: MVI<CounterState, CounterAction, CounterSideEffect>, onNavigateSetting: () -> Unit , modifier: Modifier = Modifier, ) { val context = LocalContext.current MviContent( viewModel = mvi, sideEffect = { effect -> when (effect) { is CounterSideEffect.ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() CounterSideEffect.NavigateSetting -> onNavigateSetting() } }, ) { state, onAction -> CounterScreenContent( state = state, onIncrement = { onAction(CounterAction.Increment) }, onDecrement = { onAction(CounterAction.Decrement) }, onReset = { onAction(CounterAction.Reset) }, onSettingClick = { onAction(CounterAction.ClickSetting) }, modifier = modifier, ) } } @Composable private fun CounterScreenContent( state: CounterState, onIncrement: () -> Unit , onDecrement: () -> Unit , onReset: () -> Unit , onSettingClick: () -> Unit , modifier: Modifier = Modifier, ) { Column(modifier = modifier) { Text(text = "Count: ${ state.count } " , fontSize = 32 .sp) Button(onClick = onIncrement) { Text(text = "+" ) } Button(onClick = onDecrement) { Text(text = "-" ) } Button(onClick = onReset) { Text(text = "Reset" ) } Button(onClick = onSettingClick) { Text(text = "Setting" ) } } } Flowごとに collectAsState を並べる必要がなくなり、View側がnavControllerやViewModelの内部状態に依存する構造も解消されます。画面遷移やToast表示はすべてSideEffect経由のコールバックに統一されるため、Contentの責務がシンプルに保たれます。ViewModelに依存しないComposable関数を用意することで、Preview関数も定義しやすくなります。 テスト: Actionを送信してState・SideEffectを検証 MVIアーキテクチャではデータフローが一方向に固定されているため、テストも「Actionを送信して、Stateの変化またはSideEffectの発行を検証する」というパターンに統一されます。テスト対象の入力と出力が明確なので、何をテストすべきかが自然と定まります。 // CounterViewModelTest.kt class CounterViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() private lateinit var target: CounterViewModel @BeforeTest fun setup() { target = CounterViewModel() } // Stateの変化を検証 @Test fun `Action - Increment - increases count by 1`() = runTest { target.state.test { assertEquals( 0 , awaitItem().count) target.onAction(CounterAction.Increment) val state = awaitItem() assertEquals( 1 , state.count) assertEquals( 2 , state.doubleCount) assertEquals( 3 , state.tripleCount) } } // SideEffectの発行を検証 @Test fun `Action - ClickSetting - emits NavigateSetting side effect`() = runTest { target.sideEffect.test { target.onAction(CounterAction.ClickSetting) assertEquals(CounterSideEffect.NavigateSetting, awaitItem()) } } // State更新とSideEffectの組み合わせを検証 @Test fun `Action - Increment - emits ShowToast when count reaches 10`() = runTest { repeat( 9 ) { target.onAction(CounterAction.Increment) } target.sideEffect.test { target.onAction(CounterAction.Increment) assertEquals(CounterSideEffect.ShowToast( "10に到達しました" ), awaitItem()) } } } MVVMアーキテクチャからMVIアーキテクチャに移行してみて このような独自MVIライブラリを使ったMVIアーキテクチャへの移行は2024年9月に開始しました。既存画面を一括で移行するのではなく、「新規画面は原則MVI」「既存画面は改修タイミングで置き換え」というルールにより画面単位で段階的に進めています。これにより開発を止めることなく移行を進められ、画面ごとのリスクを小さく保ったまま適用範囲を広げることができており、2026年2月現在も段階的な移行を継続しています。 2024年9月 2025年4月 2025年10月 現在 MVI 1(2.2%) 11(24.4%) 21(38.9%) 31(50.8%) MVVM 44(97.8%) 34(75.6%) 33(61.1%) 30(49.2%) 合計 45 45 54 61 このようにMVIの実装が徐々に増える中で、前述のアーキテクチャ上の課題が解消されたことに加え、開発工程そのものにも以下のようなメリットが出てきています。 チーム全体で一貫した実装ができるようになった 独自MVIライブラリを作り実装方針を決め、あわせてドキュメントを整備・公開したことで、ライブラリとドキュメントの両面からチーム全体で一貫した実装を進められるようになりました。 新しいメンバーが加わった際も、1つの画面のContract・ViewModel・Viewを読めばプロジェクト全体の実装パターンを理解できます。オンボーディングの負荷も軽減されていると感じています。 PRレビューの質が向上した チーム全体で実装方針を統一できるようになり、基本的なデータフローに関する指摘は大きく減りました。以前は、実装パターンの統一に関するコメントがレビューの多くを占めていました。MVIライブラリによってこれらが構造的に解消されたことで、レビューの焦点が変わりました。現在は、仕様の妥当性の確認やコードのブラッシュアップに、より多くの時間を使えるようになりました。 AIコーディングエージェントとの協業がしやすくなった 現在、AIコーディングエージェントのDevinを活用した既存画面のMVI移行にもチャレンジしています。MVIアーキテクチャではState・Action・SideEffectという明確な構造があるため、Devinが生成したコードでも処理の流れを追いやすく、レビューしやすいです。アーキテクチャが統一されていることは、人間同士の開発だけでなく、AIとの協業においても大きなメリットになると感じています。 まとめ 本記事では、ZOZOFITのAndroidアプリにおけるMVVMアーキテクチャの課題と、MVIアーキテクチャへの移行、独自MVIライブラリの開発について紹介しました。MVIアーキテクチャは、ユーザー体験の低下を未然に防ぐ仕組みとしても機能していると感じています。ZOZOFITの利用者が日々増えるなかでも体験を安定して支えられるよう、これからもアーキテクチャの改善を進めていきます。最後までお読みいただき、ありがとうございました。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
はじめに こんにちは、FAANS部フロントエンドブロックの 加藤 です。普段はFAANSのiOSアプリを開発しています。FAANSは、ショップスタッフの販売サポートツールであり、アプリ上でコーディネートの投稿や売上などの成果を確認できます。 成果の確認画面では以下の動画のように成果を棒グラフで可視化しています。これまでFAANS iOSでは、棒グラフの生成にサードパーティライブラリである DGCharts を用いていました。一方で、FAANSではiOS 15のサポートを終了しているため、iOS 16以上で利用可能なApple標準のグラフ生成フレームワーク「Swift Charts」を利用できます。そこで、この度、DGChartsからSwift Chartsへの移行を実施しました。 この記事では、DGChartsからSwift Chartsへの移行にあたり検討した実装アプローチについて紹介します。 目次 はじめに 目次 成果画面のレイアウトと機能 Swift Chartsのみで実装 Swift Charts + UICollectionViewで実装 Swift Charts + 表示データの工夫で実装 DGChartsとSwift Chartsの比較 まとめ さいごに 成果画面のレイアウトと機能 FAANSにおける成果画面のレイアウトと機能は以下の画像のようになっています。 成果画面では、横軸が日付、縦軸が売上の棒グラフが表示されます。棒グラフは横方向のスクロール(画像の1)、およびタップが可能で、選択した日付の売上が画面上に表示される仕組みです(画像の2)。また、棒グラフは3〜4種類の値で構成されており、それぞれの値を色分けして積み上げています(画像の3)。さらに、棒グラフは1画面に7.5日分表示されており、左端に0.5日分が見切れた状態です。これにより、スクロールが可能であることを示唆しています(画像の4)。 以上がFAANSの成果画面におけるレイアウトと機能です。本記事では、これらの機能をSwift Chartsで実装するにあたり検討した3つのアプローチについて、比較・検証した過程を紹介します。 実装方法は以下の3つです。 Swift Chartsのみで実装する方法 Swift ChartsとUICollectionViewを組み合わせて実装する方法(今回採用した方法) 表示するデータを工夫したSwift Chartsの実装方法(採用には至らなかったが、Swift Chartsのみで完結させる代替案として紹介) また、実装要件と3つの実装方法に対する評価方法は以下の通りです。 実装要件 横スクロール、タップアクション、値の積み上げ、7.5日分の表示の4種類の機能を実装する 評価方法 InstrumentsのHitches(フレームの描画遅延の回数・タイミングを可視化するツール) 検証端末:iPhone 16 Pro(iOS 26.2.1) Swift Chartsのみで実装 まずはSwift Chartsのみで実装する方法についてです。プログラムは以下の通りです。 // グラフデータの構造体 struct Sales : Identifiable { var id = UUID() var type : String var date : Date var sales : Double } private let salesChannels = [ "zozotown" , "wear" , "yahoo!Shopping" , "ownedEc" ] //------以下、グラフの生成 struct BarChartsView : View { private let visibleLength : TimeInterval = 24 * 60 * 60 * 7.5 private let dateFormatter = DateFormatter(with : .weeklyChart) // 自作の拡張 // データの作成 private let barData : [ Sales ] = [ (month : 9 , days : 1 ... 30 ), (month : 10 , days : 1 ... 30 ) ].flatMap { month, days in days.flatMap { day -> [ Sales ] in salesChannels.map { type in Sales( type : type , date : date (year : 2025 , month : month , day : day ), // Dateの作成 sales : round (Double.random( in : 0 ... 50000000 )) ) } } } @State private var scrollPosition : Date = barData.last ! .date var body : some View { Chart(barData, id : \.id) { row in BarMark( x : .value( "Day" , row.date, unit : .day), // x座標のデータ(日付) y : .value( "Sales" , row.sales) // y座標のデータ(売上) ) .foregroundStyle(by : .value( "Type" , row.type)) // ③データの積み上げ } .chartScrollableAxes(.horizontal) // ①横方向のスクロール方向(iOS 17+) .chartLegend(.hidden) // 凡例の非表示 .chartXVisibleDomain(length : visibleLength ) // ④可視化幅を7.5日分に設定(iOS 17+) .chartScrollPosition(x : $scrollPosition ) // 最初に右端が映るように設定(iOS 17+) // 積み上げる色の定義 .chartForegroundStyleScale([ "zozotown" : Color (.Token.serviceZozotown), "wear" : Color (.Token.serviceWear), "yahoo!Shopping" : Color (.Token.serviceYahoo), "ownedEc" : Color (.Token.serviceBrandEc) ]) // ②グラフタップ時の挙動(iOS 17+) .chartGesture { chart in SpatialTapGesture() .onEnded { value in guard let (date, _) = chart.value( at : value.location , as : ( Date , Double ) . self ) else { return } // ↑dateがタップした日付 } } // x軸のラベル定義 .chartXAxis { AxisMarks(values : .stride(by : .day)) { value in if let date = value. as ( Date.self ) { AxisValueLabel(centered : true ) { Text(dateFormatter.string(from : date )) // MM/dd\nEEE .multilineTextAlignment(.center) } } } } // y軸のラベル定義 .chartYAxis { AxisMarks(values : .automatic(desiredCount : 4 )) { value in AxisValueLabel(multiLabelAlignment : .leading) { if let raw = value. as ( Double.self ) { Text( // 中身は省略 ) } } } } } } 上記プログラムでは、横スクロール、タップアクション、値の積み上げ、7.5日分の表示の4種類の機能をそれぞれ以下の方法で実装しています。 横スクロール: chartScrollableAxes(.horizontal) タップアクション: chartGesture 値の積み上げ: foregroundStyle 7.5日分の表示: chartXVisibleDomain 注意が必要なのは、 chartScrollableAxes と chartGesture はiOS 17以降で利用できる機能である点です。また、 chartScrollPosition で初期の表示位置を指定している点や、chartXAxisやchartYAxisで目盛りのレイアウトを調整している点も重要です。 これで、実装したかった成果画面のレイアウトと機能を全て実装できました。しかし、スクロール時の動作を確認してみると、スクロールが重たく感じます。主観では判断できないため、InstrumentsのHitchesを用いてパフォーマンスを計測しました。パフォーマンス計測では、グラフの表示画面を表示して、数回のスクロールを実施しました。パフォーマンス計測結果は以下の画像のようになりました。 上記画像におけるタイムライン上の赤線は、フレームの描画遅延が発生した時刻を表しています。Swift Chartsのみの実装では赤線が密集しており、スクロール中に連続してフレームの描画遅延が発生していることが確認できました。また、サマリーを見ると338回発生しており、最大Hitchは25msでした。ここで比較のため、DGChartsを用いた既存実装におけるHitchesを示します。 Swift Chartsのみで実装した場合と比較して、赤線が密集している箇所が少なく、最大Hitchも12.50msであることが分かります。 Swift Chartsのみで実装された場合におけるパフォーマンス低下の原因を調査した結果、データ数の多さ(約2か月分)が主な要因のようです。また、 multilineTextAlignment(.center) の指定や、 chartScrollPosition の利用も影響していました(正確な原因の特定には至りませんでした)。 multilineTextAlignment(.center) をやめると軽くなりますが、データ数は減らせないので、Swift Chartsのみの実装方法は採用しませんでした。 Swift Charts + UICollectionViewで実装 Swift Chartsにおけるスクロールのパフォーマンス問題を解消するために、UICollectionViewを用いる方法を検討しました。具体的には、UICollectionViewの scrollDirection で横スクロールを実現して、UICollectionViewCellとしてSwift Chartsを表示します。UICollectionViewはUICollectionViewCellを再利用して描画するため、データ量が多い場合でもパフォーマンスへの影響を抑えられます。これまでのDGChartsを用いた実装でも、この方法を採用していました。 また、UICollectionViewを用いた実装では、y軸を別途実装する必要があります。FAANSの成果画面では右端にy軸が固定されており、棒グラフのみがスクロールできるデザインです。そのため、UICollectionViewCellに載せるViewではy軸は非表示にして、別のViewとして実装する必要があります。図にすると下記のような構成です。 UICollectionViewCellに載せるSwift Chartsの実装は以下の通りです。 // 表示するデータのチャンネル enum StackedOutcomeChannel : String , Plottable, CaseIterable { case zozotown case wear case yahooShopping case ownedEc } // グラフデータの構造体 struct StackedOutcomeBarMarkEntry : Hashable { var type : StackedOutcomeChannel var date : Date var value : Double } struct StackedOutcomeBarMarkView : View { // 外部から代入する値 struct ChartModel { var colors : [ UIColor ] var entries : [ StackedOutcomeBarMarkEntry ] var yAxisMax : Double var selectedDate : Date? var onSelectDate : (( Date ) -> Void ) ? } let chartModel : ChartModel @State private var selectDate : Date? // 選択されたグラフ日時の格納先 // グラフの色(chartForegroundStyleScaleで利用するためにKeyValuePairsで定義) private var barMarkColors : KeyValuePairs < StackedOutcomeChannel , Color > { return [ StackedOutcomeChannel.zozotown : Color (chartModel.colors[ 0 ]), StackedOutcomeChannel.wear : Color (chartModel.colors[ 1 ]), StackedOutcomeChannel.yahooShopping : Color (chartModel.colors[ 2 ]), StackedOutcomeChannel.ownedEc : Color (chartModel.colors[ 3 ]) ] } init (chartModel : ChartModel ) { self .chartModel = chartModel _selectDate = State(initialValue : chartModel.selectedDate ) } var body : some View { Chart(chartModel.entries, id : \. self ) { row in BarMark( x : .value( "Day" , row.date), y : .value( "Value" , row.value) ) .foregroundStyle(by : .value( "Type" , row.type)) } .chartLegend(.hidden) // 凡例の非表示 .chartForegroundStyleScale(barMarkColors) // 積み上げる色の定義 // グラフタップ時の挙動(iOS 17+) .chartGesture { chart in SpatialTapGesture() .onEnded { value in guard let (date, _) = chart.value( at : value.location , as : ( Date , Double ) . self ) else { return } self .selectDate = date chartModel.onSelectDate?(date) } } // x軸のラベル定義 .chartXAxis { AxisMarks(values : .stride(by : .day)) { value in if let date = value. as ( Date.self ) { AxisValueLabel(centered : true ) { Text(DateFormatter(with : .weeklyChart).string(from : date )) .multilineTextAlignment(.center) } } } } .chartYScale(domain : 0 ... chartModel.yAxisMax) // 重要: y軸スケールの定義 .chartYAxis(.hidden) // y軸の非表示 } } Swift Chartsのみで実装した場合と異なり、横スクロールの設定や chartScrollPosition による初期位置の調整は不要です。また、y軸は非表示にしたいので、 .chartYAxis(.hidden) を設定しています。このとき、 chartYScale を用いて、y軸の最小値と最大値を設定しておくことがポイントです。この定義で、独立したy軸のみのViewと棒グラフの目盛りの整合性を取ります。 続いて、右側に固定するy軸のViewを下記のプログラムで実装します。 struct BarMarkYAxis : View { // 外部から代入する値(仕様の関係) final class YAxisModel : ObservableObject { @Published var yAxisMax : Double = 100 } @ObservedObject var model : YAxisModel = YAxisModel() var body : some View { Chart { // y軸最大値のルールの定義(あってもなくてもよい) RuleMark(y : .value( "max" , model.yAxisMax)) .foregroundStyle(.clear) } .chartXAxis(.hidden) // x軸の非表示 .chartYScale(domain : 0 ... model.yAxisMax) // y軸範囲の定義 // y軸のラベル定義 .chartYAxis { // おおよそ6つの目盛りで構成 AxisMarks(values : .automatic(desiredCount : 6 )) { value in // 補助線の非表示化 AxisGridLine(stroke : StrokeStyle (lineWidth : 0 )) AxisValueLabel(multiLabelAlignment : .leading) { if let raw = value. as ( Double.self ) { Text( // 中身は省略 ) } } } } .chartPlotStyle { plot in plot.frame(width : 0 ) // y軸だけ欲しいのでグラフのプロット幅を0に } .frame(width : 39 ) } } このプログラムでは、 chartXAxis(.hidden) でx軸を非表示にしており、棒グラフとして表示するデータも与えていません。一方で、これだけではグラフのプロット領域が確保されてしまうので、 chartPlotStyle で plot.frame(width: 0) を定義して、プロット領域の幅を0にしています。また、Swift ChartsのViewと同様に chartYScale を定義しており、 chartYAxis でy軸の目盛りを設定しています。加えて、 chartYAxis 内の AxisMarks(values: .automatic(desiredCount: 6)) で、おおよそ6つの目盛りをy軸上に表示しています。 以上のSwift ChartsのViewをCellとしたUICollectionViewと、Swift Chartsで作成したy軸を組み合わせて実装した成果画面の完成版が下記の動画です。最初に述べたFAANSにおけるレイアウトと機能を実装できていることが確認できます。 また、InstrumentsのHitchesを用いてパフォーマンスを計測した結果、以下の画像のように赤線の密集が少なく、パフォーマンスの著しい低下が発生していないことが確認できました。 Swift Charts + 表示データの工夫で実装 先に述べた通り、Swift Chartsのみの実装では横スクロールが重たく感じる事象を確認したため、UICollectionViewと組み合わせた方法を採用しました。一方で、UICollectionViewを使わずSwift Chartsのみで完結させたいケースもあるかと思います。そこで、一度に渡すデータ量を制限すればスクロール時のパフォーマンス低下を緩和できると考え、試作しました。今回は採用に至りませんでしたが、Swift Chartsのみで実装する際の代替案として紹介します。データ量の制限方法は以下の図の通りです。 図の例では、1/31をデータの最終日とした場合、最初に1/31から1か月前までのデータをSwift Chartsに渡します(図の上段)。その後、ユーザが1/1までスクロールした際には、1/1を中心とした前後15日分、すなわち合計30日分(約1か月)を新たな表示データとしてSwift Chartsに渡します(図の下段)。このように実装することで、Swift Chartsは常に1か月分のデータのみ描画することになり、大量データを渡したときと比較して、スクロールが重くなりにくいと考えられます。実装は下記の通りです。 struct BarChartsView : View { private let visibleLength : TimeInterval = 24 * 60 * 60 * 7.5 private let stopDebounce : TimeInterval = 0.25 private let dateFormatter = DateFormatter(with : .weeklyChart) @State private var scrollPosition : Date = barData.last ! .date // barDataは1つ目の実装例と同様の定義 @State private var scrollStopTask : Task < Void , Never > ? @State private var visibleData : [ Sales ] = [] // 表示するデータを格納(1か月分) @State private var pendingScrollTarget : Date? @State private var isProgrammaticScroll = false @State private var chartEpoch : Int = 0 init () { let center = barData.last ! .date _visibleData = State(initialValue : extractWindowData (around : center )) } var body : some View { Chart(visibleData, id : \.id) { row in BarMark( x : .value( "Day" , row.date, unit : .day), y : .value( "Sales" , row.sales) ) .foregroundStyle(by : .value( "Type" , row.type)) } .id(chartEpoch) // visibleData差し替え時にChartも再構築 .chartScrollableAxes(.horizontal) .chartLegend(.hidden) .chartXVisibleDomain(length : visibleLength ) .chartScrollPosition(x : $scrollPosition ) // スクロール時に左端のグラフが見切れる位置で止まるように制御(iOS 17+) .chartScrollTargetBehavior( .valueAligned(matching : DateComponents (hour : 12 , minute : 0 , second : 0 )) ) .chartForegroundStyleScale([ // (省略) ]) .chartXAxis { // (省略) } .chartYAxis { // (省略) } .onChange(of : scrollPosition ) { _, newValue in // 自動スクロールでscrollPositionが更新された場合、scrollStopCheckを呼ばない if isProgrammaticScroll { isProgrammaticScroll = false return } // ユーザ操作でスクロールされた際に呼び出し scrollStopCheck(after : stopDebounce ) } // 表示するデータの差し替え後に、差し替え前に表示していた位置に遷移 .onChange(of : visibleData ) { _, _ in guard let target = pendingScrollTarget else { return } pendingScrollTarget = nil Task { @MainActor in isProgrammaticScroll = true scrollPosition = target } } } // グラフがスクロールされた場合の処置 func scrollStopCheck (after delay : TimeInterval ) { scrollStopTask?.cancel() scrollStopTask = Task { @MainActor in // Task.sleepで待機中に次のタスクが来たら前のタスクをキャンセル do { try await Task.sleep(nanoseconds : UInt64 (delay * 1_000_000_000 )) } catch { return } guard ! Task.isCancelled else { return } let center = alignToNoon(scrollPosition) // データ更新後の遷移先の指定 let next = extractWindowData(around : center ) // 新たなデータの抽出(centerを中心として前後15日のおよそ1か月分) chartEpoch += 1 // idの更新 visibleData = next // 表示するデータ位置の更新 let pendingPosition = Calendar.current.date(byAdding : .day, value : 1 , to : center ) ! pendingScrollTarget = pendingPosition // データ更新後の遷移位置の指定 } } // 引数: centerの値から前後15日分の1か月分を親配列から抽出 func extractWindowData (around center : Date , days : Int = 15 ) -> [ Sales ] { let cal = Calendar.current let start = cal.date(byAdding : .day, value : - days, to : center ) ?? center let end = cal.date(byAdding : .day, value : days , to : center ) ?? center return barData.filter { $0 .date >= start && $0 .date <= end } } // 入力されたDateの時間を12時に固定 func alignToNoon (_ date : Date ) -> Date { var comps = Calendar.current.dateComponents([.year, .month, .day], from : date ) comps.hour = 12 comps.minute = 0 comps.second = 0 return Calendar.current.date(from : comps ) ?? date } } 上記プログラムのポイントは、以下の3つです。 chartScrollPosition によるスクロールの監視 表示データとChartのidの更新 scrollPosition によるグラフ位置の調整 まず、 chartScrollPosition に scrollPosition の変数を設定して、現在のスクロール位置を監視します(ポイント1)。スクロールがあった場合には、 onChange(of: scrollPosition) が呼ばれ、内部に定義されている scrollStopCheck(after: stopDebounce) が呼ばれます。この関数では、スクロール後、一定の時間静止した場合に表示データを更新します。更新後のデータは、 extractWindowData という自作の関数を用いて取得しています。また、データの更新時には chartEpoch を更新してChart自体を新しく構築し直す必要があります(ポイント2)。Chartを再構築しない場合、データを更新する度に、Chartのスクロールが重くなっていきます。 最後にデータを更新した際の表示位置を調整します。表示位置を調整せず、データの更新のみを行った場合、更新前に表示されていた日付からずれます。これは、Swift Chartsがスクロール位置を座標として記録しているためです。例えば、先ほどの図の上段において1/1までスクロールしたとします。すなわち、左端のデータが表示されている状態です。この状態で図の下段のようにデータを更新すると、左端のデータがそのまま表示されるので、1/1ではなく、12/16が表示されてしまいます。データ更新後も1/1が表示されている状態を維持したいので、データ更新前の表示位置をあらかじめ記録します。上記プログラムでは、 pendingScrollTarget に表示位置を記録しています。そして、記録した表示位置を用いて、 scrollPosition を更新することでデータ更新後の表示位置を調整します。 また、InstrumentsのHitchesを用いてパフォーマンスを計測した結果、下記画像に示すように赤線の密集が発生していません。すなわち、データの量を制限していない場合と比較して、大幅にパフォーマンスを改善できていることが確認できました。 このプログラムを用いることでSwift Chartsのみで実装できます。一方で、 chartScrollPosition はスクロール位置の同期が主な用途です( 公式ドキュメント )。そのため、データ差し替え後の位置制御に用いる場合は意図しない挙動が発生するかもしれません。また、端までスクロールした際にデータを更新すると、見切れている棒グラフとの位置関係によるグラフのずれが発生します。採用には注意が必要です。 DGChartsとSwift Chartsの比較 最後に、Swift Chartsへの置き換えで学んだDGChartsとSwift Chartsの違いを表で示します。基本的にはApple純正のフレームワークであるSwift Chartsを用いるのが良いと考えています。 項目 DGCharts Swift Charts フレームワーク種別 サードパーティ Apple純正 対応OS iOS 12+ iOS 16+ UI基盤 UIKit SwiftUI 積み上げ棒グラフの実現方法 x座標を指定して、積み上げる値の配列を渡す 配列内でx座標が同じ要素を重ねて表示 グラフのハイライト色指定 highlightAlphaで色の指定 専用の色指定APIはない スクロール挙動の制御 スナップやページングは自前実装が必要 .chartScrollTargetBehaviorで単位揃えやスナップを指定可能(iOS 17+) 大量データのスクロール(パフォーマンスの問題) UICollectionViewのセル再利用により、大量データでもパフォーマンスの問題は発生しにくい 標準の横スクロール(chartScrollableAxes)では大量データで描画遅延が発生。UICollectionViewとの併用や表示データ量の制限で対処が必要 まとめ 本記事では、DGChartsからSwift Chartsへの移行にあたり、3つの実装アプローチを比較・検証した過程を紹介しました。 Swift Chartsは宣言的な記述で手軽にグラフを実装できる一方、大量データのスクロール描画ではパフォーマンス上の課題があります。そのため、UICollectionViewとの併用やデータの動的な差し替えといった工夫が求められる場面もあります。今回はUICollectionViewとの組み合わせを採用しましたが、要件やデータ量に応じて最適な方法は異なるため、本記事で紹介した各アプローチが実装方針の判断材料になれば幸いです。 さいごに ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
はじめに こんにちは。グローバルシステム部 バックエンドブロックの髙橋と松浦です。私たちはZOZOMETRY・ZOZOMAT・ZOZOGLASSなどのシステムを開発、運用しています。 今回、エンジニアリング全般の知見を深めるため、2026年2月21日にオーストラリア・メルボルンで開催された DDD Melbourne に参加しました。この記事ではDDD Melbourneに現地参加した経験や、セッションを通じて学んだ内容を紹介します。 はじめに DDD Melbourneとは 現地の様子 気になったセッション紹介 How To Write Awful Unmaintainable Code 副作用 技術的負債 まとめ The Safety First App: 12 product patterns that turn failures into recoveries Autosave + draft states Explainable errors まとめ Managing for Failure 心に残ったポイント まとめ Throw Away The Vibes: Context Engineering Is All You Need コンテキストの4つの失敗モード Breadcrumb Protocol Research → Plan → Implement → Review(RPIR)フロー コンテキストサイズの60%ルール まとめ 最後に DDD Melbourneとは DDD Melbourneは、非営利団体Oz Dev Inc.が主催するソフトウェアコミュニティのカンファレンスです。 このカンファレンスでの「DDD」とは「Developer! Developer! Developer!」の略で、英国・オーストラリア・ヨーロッパ各地で開催されており、ソフトウェアに関わるさまざまな業種の方が、開発についてのプラクティスを共有しています。 セッションのテーマは毎年コミュニティの投票によって決定されるため、今年のトレンドや関心事が反映されているのも、このカンファレンスのポイントです。登壇者にはエンジニア、デザイナー、プロダクトマネージャーなど多様なバックグラウンドを持つ方々がいます。そのため、実践的な知見や生々しい失敗談など、現場のリアルな声を聞けることが特徴です。今回発表された内容も、AIについての問題点や開発手法から、エンジニアとしてのマインドセットまでかなり幅広く、発表者の興味や経験が伝わってくる内容でした。 現地の様子 本カンファレンスは150年以上の歴史を持つMelbourne Town Hallで行われました。 Melbourne Town Hall オープニング前の様子 オープニングの様子 気になったセッション紹介 How To Write Awful Unmaintainable Code このセッションでは、ベストプラクティスとアンチパターンを対比して、何が悪いかをコントのように共有していたのが印象的でした。 前半では、命名・バージョニング・コミットといった基本的なテーマから始まり、コミットを小さく保つことやセマンティックバージョニングを正しく使うことの重要性が語られていました。対比として、政治的・組織的にバージョンが決められるEnigmatic Versioningの話がスライドに出され、会場も笑いに包まれました。 Enigmatic Versioning 後半は、副作用と技術的負債が主な話題でした。 副作用 良いコードとは「関数が1つのことだけを行い、関数名が正確にそのことだけを説明している」という定義の話から入り、無害に見える関数が副作用を持っていたという例が挙げられていました。 意図した副作用は時に必要ですが、基本的には避けるのがベターだと考えますし、名前や仕組みで判断できないのはバグの原因になりかねないので、改めて注意が必要だと認識しました。 技術的負債 米国の金融企業Knight Capitalは2012年、高値で買い、安値で売るロジックを含むデッドコードが本番環境で動作してしまいました。約45分間で約4億4,000万ドルの損失を出し、最終的にGetco社との合併に至りました。近しい金額のNASAの火星探査機喪失と比較されるほど、デッドコードの危険性を示す象徴的な事例として紹介されていました。 まとめ あるあるネタから始まりつつ、後半になるにつれて話の重みがどんどん増していきました。Knight Capitalの事例は特に衝撃的で、コードの管理不足が会社の経営危機にまで繋がってしまった話はとても印象的な事例でした。技術的負債や構造の問題は、放置すれば取り返しのつかない事態になり得るため、改めてコード管理には気をつけたいと再認識したセッションでした。 The Safety First App: 12 product patterns that turn failures into recoveries このセッションは、「ほとんどのアプリケーションは晴れの日のために作られている」という一言から始まりました。しかし、ユーザーは必ずミスをしますし、ネットワークは揺らぎます。APIはタイムアウトします。そして人は焦ると間違ったボタンを押します。それでもなお、多くのプロダクトは正常系という「晴れの日」だけを前提に設計されています。 このセッションでは、異常系「雨の日」を前提にした設計をどうプロダクトに組み込むかを、12のパターンに分解して紹介していました。単なるUX改善の話にとどまらず、プロダクトマネージャー・デザイナー・エンジニアが共通言語を持つための内容でした。 The Safety First App: 12 product patterns that turn failures into recoveries 以下が本セッションで紹介された12パターンです。 Pattern 1: Undo Everywhere(どこでもUndo) Pattern 2: Auto-save & Draft States(自動保存とドラフト状態) Pattern 3: Guard Destructive Actions(破壊的アクションの防御) Pattern 4: Resilient Forms(回復力のあるフォーム) Pattern 5: Explainable Errors(説明可能なエラー) Pattern 6: Quick Recovery Links(クイックリカバリーリンク) Pattern 7: Degraded States(劣化状態) Pattern 8: Idempotent Actions(べき等アクション) Pattern 9: Long-running Work with Receipts(レシート付き長時間処理) Pattern 10: Outbox & Offline Queue(アウトボックスとオフラインキュー) Pattern 11: Rescue Mode(レスキューモード) Pattern 12: Customer-facing Runbooks(カスタマー向けランブック) この中でも、印象深かったものは以下の2つです。 Autosave + draft states このセッションで重要視されていたのは、意味のある変更ごとにドラフト状態を永続化することでした。保存処理をアプリケーションのインフラとして扱ってほしいということです。例えば長いフォームがあった時、タイムアウトしてデータが消失したら、大きなストレスになります。「また入力し直しだ」と感じた経験は多くの方にあるはずです。自動的にドラフトが保存されていれば、そういったことはなくなり、ユーザーは安心して入力を行えます。 また、現在の状態をユーザーに通知することも重要です。インジケーターがあると、ユーザーは正しく保存されていることを確認できるため、安心につながります。 身近な例だと、ドキュメント編集ツールの「保存しました」「保存中...」といったインジケーターが挙げられます。編集中に保存状態が常に表示されていることで、ユーザーはデータが失われていないことを確認でき、安心して作業を続けられます。 Explainable errors エンジニアならお馴染みのHTTPステータスコードでは、404エラーや500エラーがあると思います。しかしそれらのエラーだけだと、ユーザー目線では何が起きたのかほぼわかりません。ユーザーには可能な限りわかりやすい、システム的な言語ではなくユーザーの言語で返してあげる必要があります。 例えばカード支払いだと 「支払いが失敗しました、別のカードをお試しください」 「△⚪︎の追加に失敗しました。再度試すか、別の方法を使ってください」 などです。これが、 「支払いに失敗しました」 「サーバーでエラーが発生しました」 だけだと、ユーザーは何が間違いかが分からないので、解決しようがありません。 何が発生しており、次にユーザーが何をすべきかを示すことが重要です。リトライすべきか、編集し直すべきか、サポートに連絡すべきか。明確なエラーメッセージを返していると、ユーザーが主体的に問題を解決する糸口になります。サポートやインシデントトリアージを行いやすくするために、問い合わせ用のIDをレスポンスに含めることも大切です。 まとめ Autosave + draft statesは、データを扱うと必ず発生する保存と復元の話ですが、ただ保存と読み込みをするだけでは、ユーザーにとって大変になるケースもあることを再認識させられました。ユーザーが意識しなくても困らない仕組みや、いざという時にいつでも状況を確認できる状態を作っておくと、ユーザー自身で解決できることも増えます。また、仮に問題が起こったとしてもより細かく対応ができると思うので、UIを考える上で今後の開発で意識していきます。 Explainable errorsでは、プロダクト開発の現場で見落とされがちなユーザー向けのエラー通知の改善が紹介されていました。エラーの内容と具体的な対処法が適切に提示されていれば、ユーザー自身で解決できるケースは決して少なくありません。今後の開発では、ユーザーができるだけ自力で問題を解消できるようにするには、どのような情報をどの粒度で見せるべきか、という観点でインタフェースを設計していきたいと感じました。 本セッションでは触れられていませんでしたが、参考例としてMetaのGraph APIがあります。このAPIでは、開発者向けの詳細なエラー情報に加えて、ユーザー向けタイトルとメッセージを返却するフィールドも用意されています。 { " error ": { " message ": " Message describing the error ", " type ": " OAuthException ", " code ": 190 , " error_subcode ": 460 , " error_user_title ": " A title ", " error_user_msg ": " A message ", " fbtrace_id ": " EJplcsCHuLu " } } https://developers.facebook.com/docs/graph-api/guides/error-handling?locale=ja_JP もちろんこれらを行うには、相応の管理コストがかかります。そのため、どこまでを丁寧に設計・運用するかという境界を意識することが重要です。現実的には、ユーザー体験への影響が大きい部分には優先的にコストをかけ、それ以外の内部的な部分は、開発・運用の生産性とのバランスを取りながら設計していく姿勢が大事だと考えています。 今回紹介された12パターンの多くは、障害やユーザーの「ミスが起きてから」ではなく「ミスが起きないようにする」設計の話です。安全機構は後付けではなく、最初から盛り込むべきものであることを、改めて実感しました。 Managing for Failure このセッションは「なぜ失敗が必要なのか? それは、失敗を減らすためだ」という問いかけから始まりました。「失敗がないチームは一見健全に見えるが、学習も、成長も止まっている可能性がある。そこで、失敗を安全に経験させる仕組みをどう設計するか」という話が展開されていきました。 このセッションでは、「失敗を減らすために、あえて失敗を設計する」という話が、精神論ではなくかなり具体的なやり方まで踏み込んで展開されていました。 Managing for Failure 心に残ったポイント 30/15 Fail 30分詰まったら15分離れ、戻っても進まなければ「失敗達成」でヘルプを出す方法が紹介されました。失敗をゴールにすることで、行き詰まったこと自体が「達成」になります。助けを求めるハードルが下がる仕組みです。「失敗をゴールにする」という発想が面白かったです。 Fire Drill 実際の障害が起きる前に、意図的に壊して復旧練習をします。本番で強くなるには、練習で失敗しておくことが重要です。これはインフラでもアプリでも同じだと感じました。 私たちのチームでもカオスエンジニアリングの考え方を取り入れて います。 まとめ 印象的だったのは、「失敗は避けるものではなく、慣れるもの。そして、ちゃんと扱えるようにするもの」という考え方です。「うまくいきすぎているチームは、一見健全に見えるが、挑戦していないだけかもしれない」という指摘も印象的でした。 失敗を減らすために、まずは安全に失敗できる場をつくる。何事も慣れや練度が大切だと実感しました。 Throw Away The Vibes: Context Engineering Is All You Need このセッションでは、主にVibe codingについての出力問題とその解決策を模索する話がされていました。生成されたコードは一見、問題なさそうに見えるのですが、実際にそのコードをプロジェクトで使おうとすると、さまざまな問題を抱えておりそのまま使うことはできないコードになることが多いです。たとえプロンプトをうまく書いても、間違ったコードが出てくることがあります。それは、前提のコンテキストが誤っているから、という話でした。 コンテキストの4つの失敗モード LLMのコンテキストには「Poisoning(汚染)」「Distraction(注意散漫)」「Confusion(混乱)」「Clash(衝突)」という4つの失敗パターンがあります。 コンテキストの4つの失敗モード Poisoning(汚染) これは巷でよく言われている、ハルシネーションがコンテキスト内に含まれている状態を指します。ハルシネーションによる誤った情報がコンテキスト内に残ると、そのスレッドではずっと間違った情報をもとに回答を出力してしまいます。 Distraction(注意散漫) 大きいコンテキストの中に複数の要素が保存されている場合、AIが見る場所を間違えると誤った情報に注目してしまい、出力結果が悪くなってしまいます。 Confusion(混乱) 不必要な情報が存在していると、AIは判断ができなくなります。複数の無関係な情報を1つの文脈と誤認し、出力が不安定になるためです。 Clash(衝突) 矛盾した情報が存在していても、Confusionと同様にAIは判断ができなくなります。 これらを解決するには、単純ですが「適切なタイミングで適切なコンテキストを提供すること」という原則を守ることが大切ということでした。以降は、適切にコンテキストを共有するには、どのようなテクニックがあるかが解説されました。 Breadcrumb Protocol スクラッチパッドとしてMarkdownファイルを作り、エージェントと人間がそこに計画やタスクの進捗を書き込んでいく手法です。セッションが壊れても、このファイルさえあれば新しいセッションで続きから再開できる。間違った判断があれば、ファイルを更新するだけで次回からエージェントが正しい振る舞いをするようになる。 「コンテキストを外部に永続化して、セッションに依存しない」という発想が面白かったです。私たちのチームでも同様のアプローチでエージェントへの指示を管理しているので、共感する部分が多かったです。 Research → Plan → Implement → Review(RPIR)フロー いきなりコードを書かせるのではなく、まずリサーチさせて計画を立て、タスク分割してから実装し、最後にレビューするという流れです。LLMは「やれと言われたこと」は何でもやろうとする。逆に言えば「やるなと言わないとやってしまう」。だからこそ、やることを制約する(constrain)のが安定した出力を得る鍵になる。PRレビューで大量の差分を見るのではなく、RPIRの各ステップで段階的にレビューするという考え方は、実務にすぐ取り入れられそうです。 コンテキストサイズの60%ルール コーディングエージェントのコンテキストサイズが60%を超えたら、圧縮(compaction)するか新しいスレッドを始めるべきだという実践的なアドバイスがありました。100万トークン入るからといって全部使えるわけではなく、一定量を超えるとモデルの出力品質が落ちるという話は、普段の開発でも意識しておきたいポイントです。 まとめ AIコーディングの成果は「モデルの性能」ではなく「コンテキストの質」で決まるという話でした。そして、コンテキストの質は「AI」ではなく、「人間」が設計するものです。AIの性能は日々向上していますが、最終的なコア部分は人間の管理がものを言うという結論が印象的でした。 ツールをあれこれ試すよりも、1つのツールに腰を据えて、コンテキストの渡し方を磨くことが重要です。人間の専門性、足場づくり(scaffolding)、方向づけ(steering)は今後もAIコーディングを行う上で不可欠な技術になっていくと思われます。 最近のAIコーディングエージェントでは、まさにこれらの内容をアシストするための仕組みが、システムの仕様として組み込まれている印象があります。AIが進化していくと、品質部分はますますコンテキストの質が担うことになりそうです。コンテキストの整理はAIだけではなく、自身の頭を整理するという意味でも、もちろん有用なので、うまく整理できる能力を磨いていきたいと思います。 最後に 今回DDD Melbourneに参加し、世界のエンジニアが持つ課題意識と、その対応策を学びました。特に、AI周りはZOZOでも幅広く活用しているため、コンテキストについての話は参考になりました。また、海外カンファレンスへの現地参加を通じて、日本とのカンファレンス文化の違いも体感でき、貴重な経験になりました。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
はじめに こんにちは、SRE部カート決済SREブロックの伊藤( @_itito_ )です。普段はZOZOTOWNのカート決済機能のリプレイス・運用・保守に携わっています。また、DB領域でのテックリードを務めており、データベース周りの運用・保守・構築も担当しています。 ZOZOでは全社的に生成AIの活用が推奨されており、SRE部においてもAmazon Q Developer(以下、Q Dev)のPoCを実施しました。 本記事では、Q DevのPoCをどのように実施したのか、PoCを通じて得られた知見なども含めてご紹介します。 目次 はじめに 目次 Amazon Q DeveloperとKiroについて Kiro CLI Kiro IDE PoCの概要 背景 PoC体制 利用状況の取得方法について フィードバックと分析 Kiro CLIの評価 デフォルトモデルの違いによる体感品質の差 AWS操作の利便性について Kiro IDEの評価 仕様駆動開発の活用事例 PoCの総括 Amazon Q Developer ProとKiroのプラン選定について まとめ Amazon Q DeveloperとKiroについて まず本PoCで扱ったツールの全体像を整理します。 Amazon Q Developer (以下、Q Dev)はAWSが提供するAI搭載の開発者向けアシスタントです。コード補完やチャットなどのIDE支援、CLI、AWSリソース管理といった機能を備えています。一方、 Kiro はAWSが提供するAI搭載の開発ツールで、 Kiro IDE と Kiro CLI の2つの製品で構成されています。 本PoCは、Q Devをターゲットとして開始しましたが、PoC期間中にKiroがGA(一般提供)され、Amazon Q Developer CLIがKiro CLIに改名されました(後方互換性あり)。本記事ではサービス全体をPoC開始時の名称に基づき「Q Dev」と表記しますが、CLIについては現行名称の「Kiro CLI」と表記します。 以下では、PoCで特に評価対象となったKiro CLIとKiro IDEの概要を説明します。 Kiro CLI Kiro CLIは、Amazon Q Developer CLIを引き継いだターミナルベースのAIエージェントツールで、ターミナルからAIと対話しながら開発・運用作業を行えます。主な機能は以下の通りです。 インタラクティブチャット :ターミナル上で自然言語を使い、コード生成やAWS操作を可能 MCP統合 :MCPサーバーを介して外部ツールと接続することで、AIの回答精度を向上させることが可能 カスタムエージェント :使用するツールや権限、コンテキストを定義した設定ファイルを用意することで、特定のワークフロー向けに特化したエージェントを構築・実行 Kiro IDE Kiro IDEは、VS Code互換のGUIベースの統合開発環境です。最大の特徴は 仕様駆動開発(Spec-driven Development) です。プロンプトから直接コードを生成する「バイブコーディング」とは異なり、以下の3段階のワークフローで構造的に開発を進めます。 Requirements(要件定義) :ユーザーストーリーをEARS記法 1 で形式化した requirements.md を生成 Design(設計) :アーキテクチャやデータフローを記述した design.md を生成 Tasks(タスク) :実装タスクを細分化した tasks.md を生成し、追跡可能な形で管理 KiroのGA前は、Q Devの契約でKiro IDEが正式にサポートされるかは不明瞭でした。しかしGA時に正式にサポートが発表されたため、PoCの評価対象に含めることとしました。 PoCの概要 背景 弊社では、全社的に生成AIの活用が推奨されています。全エンジニアを対象に1人あたり月額200米ドルの基準のもと、開発AIエージェントの導入が許可されています。 corp.zozo.com SRE部でもClaude Codeを利用できる環境でしたが、AWSとの親和性の観点からQ DevのPoCを実施することとしました。Kiro CLIにはデフォルトでAWSリソースと連携できる use_aws ツールなどの機能が組み込まれています。そのため、リソース管理やトラブルシューティングでより優れた体験を得られると考えました。 aws.amazon.com PoC体制 項目 内容 期間 2025年11月〜2026年1月までの3か月間 対象 SRE部37名 目的 Q DevがZOZOTOWNの運用業務の効率化に有用か判断すること 契約プラン Amazon Q Developer Pro PoCの進め方としては、初期設定およびハンズオンをAWS社のサポートのもと実施した後、各メンバーに自由に使ってもらい、フィードバックを収集する形をベースとしました。2週間に1度の定例で各チームの代表者から利用状況やフィードバックを共有してもらいつつ、必要に応じてKiroに関する共有や追加のハンズオンを実施しながら進めました。 Kiro IDEのハンズオンにおいては、以下のような記事を参考に、仕様駆動開発の流れを体験してもらう内容としました。 aws.amazon.com 利用状況の取得方法について Q Devではダッシュボードを有効にすることで全体の利用状況を把握できますが、ユーザーごとの利用状況は確認できません。 そこで、 ユーザーアクティビティレポート を有効にし、S3にCSVを出力して分析する方法を採用しました。 出力したCSVはAIツールに以下のようなプロンプトを渡して可視化しています。 S3バケット `{バケット名}` に保存されたAmazon Q Developerのユーザーアクティビティレポートをダウンロードして、この情報を可視化したHTMLファイルを以下の仕様で作成してください。 - IAM Identity Centerからユーザー情報を取得し、そのUserIDとcsvのUserIDをマッピングしてユーザー名を表示できるようにする - 1つのHTMLの中に全ユーザーの情報が含まれていてユーザーをボタンで切り替えることができるようにする - 土日は除外する - 3ヶ月分まとめたデータを1つのグラフで見れるようにする - Chat_MessageSentとChat_AICodeLinesにフォーカスしたグラフを作る。その際単位の違いを考慮して2軸とし、左軸がChat_MessageSent、右軸がChat_AICodeLinesとする フィードバックと分析 各チームから集めたフィードバック内容はカテゴリ別に整理して分析しました。大きく分けると、Kiro CLI・Kiro IDEの2つの観点となり、それぞれについていくつか紹介します。 Kiro CLIの評価 Kiro CLIの使用用途としては、主に以下のようなものが挙げられました。 CloudFormationなどのコード生成や修正 AWSに限らないコードの生成や修正 MCP経由でのAWSコスト確認 AWSリソースやEKSで発生したトラブルの原因調査 デフォルトモデルの違いによる体感品質の差 今回のPoCでは、自由に使ってもらったうえでフィードバックを収集する形式としており、モデルや設定の制限は行っていないため、厳密な精度検証ができる状態ではありません。 その前提のもとで、「バイブコーディングで生成したコードの品質がClaude Codeよりも低いと感じた」という意見がありました。これはデフォルトモデルの違いに起因すると考えられます。 項目 Kiro CLI Claude Code デフォルトモデル Auto(自動切り替え) Opus(最上位モデル固定)※Claude Maxプラン利用時 モデル選択の方針 コストパフォーマンスを重視し、タスクに応じて最適なモデルを自動選択 Claude Maxプランではデフォルトで最上位モデルを使用 特徴 コスト効率が高い 一貫して高い生成品質 このデフォルト設定の違いが、体感的な品質差につながった可能性があります。 AWS操作の利便性について 前述の通り、本PoCのきっかけの1つはAWSとの親和性の評価でした。比較しながら使っている中で、例えば以下のような違いが見られました。 ケース例 :EKS側の設定不備によってAWS FIS(Fault Injection Simulator)のアクション pod-cpu-stress が失敗した原因を調査する。 ツール プロンプト 結果 Kiro CLI FIS のアクションpod-cpu-stressが失敗する理由を調べて。 awsコマンドを実行し、実際のAWSリソースを調査した上で具体的な原因と修正手順を回答 Claude Code FIS のアクションpod-cpu-stressが失敗する理由を調べて。 Web Searchなどを活用して一般的な知識に基づき回答 この結果だけを見るとKiro CLIの方がAWSとの親和性が高いように見えますが、Claude Code側のプロンプトに以下のように1文付け加えるだけで同様の結果が得られました。 ツール プロンプト 結果 Claude Code FIS のアクションpod-cpu-stressが失敗する理由を調べて。 awsコマンドはインストール済みで、AWS_PROFILEも設定済みです。 awsコマンドを実行し、実際のAWSリソースを調査した上で具体的な原因と修正手順を回答 さらに、毎回プロンプトに付け加えなくても、MCPの設定や ~/.claude/CLAUDE.md に以下のように記載しておくだけで、同様にAWS操作を活用した回答が得られるようになります。 ## ツール - AWS CLI ( ` aws ` ) はインストール済み。AWSのトラブルシューティング時に積極的に使用してよい デフォルトの状態ではKiro CLIの方がAWS操作の利便性が高いと言えます。しかし、Claude Code側も簡単な設定次第で同等の操作が可能になるため、設定込みで比較すると大きな差は見られませんでした。 Kiro IDEの評価 Kiro IDEについては、 仕様駆動開発(Spec-driven Development) が使用できるという点が大きな評価ポイントとなりました。 仕様駆動開発ではエージェントやプロンプトを調整せずにRequirements → Design → Tasksを構造化されたドキュメントとして自動生成してくれるため、以下のような点が評価されました。 開発プロセスのトレーサビリティ :要件・設計・タスクが構造化されたドキュメントとして残るため、なぜその実装に至ったのかを後から追跡しやすい 属人化の抑制 :個人のプロンプト技術や暗黙知に依存せず、チームの誰が見ても開発の意図と経緯を理解できる 仕様駆動開発の活用事例 実際に仕様駆動開発が活用された例として、設計したアーキテクチャの妥当性を確認するための技術検証が挙げられます。この技術検証はインフラだけでなくアプリケーションの改修も含むもので、通常であればアプリケーションレイヤーとインフラレイヤーで担当者を分けて進めるものでした。 ここでKiro IDEのSpecモード(仕様駆動開発モード)が威力を発揮しました。アーキテクチャや設計方針が固まっている状態でそれをSpecに落とし込むことで、要件定義・設計・タスク分割が構造的に整理され、ゼロベースからの実装が非常に高速に進みました。結果として、通常は複数人で分担するような規模のPoCを1人で完遂でき、技術戦略の意思決定に必要な検証を迅速に行えました。 PoCの総括 PoCの結果、Kiro CLI自体は非常に便利なものの、既にClaude CodeなどのAIエージェントツールを利用している環境では、Kiro CLIだけでは追加導入の決め手としては弱いと感じました。 一方で、Kiro IDEの仕様駆動開発ワークフローは非常に好評でした。他ツールでも工夫次第で近い進め方は可能ですが、Spec→Design→Tasksが一貫して組み込まれた” デフォルト体験 ”として非常に高い価値があるという結論に至りました。 そのため、 必要なメンバーがKiro IDEを追加で利用できる環境を整備する という方針としました。 Amazon Q Developer ProとKiroのプラン選定について PoCはAmazon Q Developer Proで契約していましたが、本番導入にあたってはKiro Proを選定しました。両プランの比較は以下の通りです。 Amazon Q Developer Pro Kiro Pro 料金(月額) US$19/月 US$20/月 使用可能な機能 Kiro CLI・Kiro IDE Kiro CLI・Kiro IDE 使用量の単位 1,000リクエスト(推論呼び出し10,000回が1,000リクエスト相当) 1,000クレジット(1リクエスト≠1クレジット。消費クレジットはリクエスト内容により変動し、簡単なものなら1クレジット未満) 超過時の扱い リセットされるまで利用不可 上位プランへのアップグレードや従量課金の設定で利用を継続可能 ユーザーアクティビティレポートに含まれる情報 Kiro CLIでの使用量のみ Kiro CLI・Kiro IDE両方の使用量 使用量リセット 月次 月次 出典: Amazon Q Developerの料金プラン , Kiroの料金プラン (2026年3月5日現在) 料金差はKiro Proの方が月額1ドル高いですが、以下の2点を重視してKiro Proを選定しました。 クレジット枯渇時の柔軟性 :Amazon Q Developer Proではクレジットを使い切ると翌月のリセットまで利用できなくなる。一方、Kiro Proでは上位プランへのアップグレードや従量課金の設定により利用を継続できるため、業務が止まるリスクを回避できる。 利用状況の可視性 :Amazon Q Developer ProのユーザーアクティビティレポートにはKiro CLIでの使用量しか含まれず、Kiro IDEでの使用量を把握できない。Kiro ProではKiro CLI・Kiro IDE両方の使用量がレポートに含まれるため、チーム全体の利用傾向を正確に把握できる。 まとめ 本記事では、SRE部で実施したAmazon Q DeveloperのPoCの進め方と結果についてご紹介しました。 Amazon Q Developer/Kiroの導入を検討している方や、Kiroを使おうとしている方の参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com Easy Approach to Requirements Syntaxの略。「WHEN〜, THE SYSTEM SHALL〜」(「〜の場合、システムは〜しなければならない」)などの定型文で要件を自然言語で記述する手法。 ↩
アバター
こんにちは、技術戦略部CTOブロックの塩崎です。 当社ZOZOには1人あたり月額200ドルの基準のもと、Claude CodeやGemini CLIをはじめとした各種AI開発ツールを利用可能にする制度を2025年7月にスタートさせました。 corp.zozo.com 現在ではこの制度を用いて数百名という非常に多くの社員がClaude Codeを利用しています。このような中で組織全体のAI活用を推進するためには、それぞれの社員や部署のClaude Codeの利用状況をモニタリングすることが重要です。そのためにClaude CodeのOpenTelemetry機能を利用して、全社員のClaude Code利用状況を収集したので、本記事ではその手法を紹介します。 ccusageを使った利用情報の収集の課題 Claude CodeのOTel機能の紹介 作ったものの全体像紹介 利用情報を送信する部分 利用情報を受け取る部分 利用情報を分析する部分 利用情報の活用事例 まとめ ccusageを使った利用情報の収集の課題 Claude Codeの利用情報を収集する方法と言いますと、まずccusageを思い浮かべる人が多いかと思います。 ccusage.com 当社でも最初はこのccusageを利用しようとしましたが、課題に遭遇しました。まず利用者にccusageを実行してもらうという点が課題でした。ccusageはコマンド一発で利用状況を出力でき、プログラムから扱いやすい構造化されたJSON出力もサポートしています。そういう意味で非常に便利なツールではあるものの、数百名の社員から漏れなくccusageの出力結果を回収しようとすると手間がかかります。さらにこの作業は1回だけ実施すればOKというものではなく、継続的なモニタリングのためには都度ccusageを回収する必要もあります。 実際に全社員からccusageを集めるということを1回実施してみましたが、これを定期的に実施することは運用負荷が高いという結論になりました。数名から十数名の組織であれば定期的なccusageの収集が十分現実的に実施できるかもしれませんが、ZOZOの規模感では厳しい結果になりました。 Claude CodeのOTel機能の紹介 ccusageの代わりに注目した機能が、Claude CodeのOpenTelemetry出力機能です。 code.claude.com LLM APIのコールやユーザーのプロンプト入力などのイベントを設定したエンドポイントに対してOpenTelemetry仕様で送信する機能です。なお、入力したプロンプトは、プライバシーを考慮して文字数のみを取得して本文は取得していません。 この機能を用いてClaude Codeの利用情報を収集すれば、前述した課題が解決できると考えました。以降では収集するための仕組みを解説します。 作ったものの全体像紹介 まずは構築した仕組みの概要を紹介します。 Claude Codeから送信された利用情報はGoogle Cloudで動作しているCloud Runに送られ、最終的にBigQueryに格納されます。上の図からも分かるように利用情報を送信する部分・受け取る部分・分析する部分という3つのコンポーネントからなっているため、順番に解説していきます。 利用情報を送信する部分 まずは、利用情報を送信する部分を解説します。 各自の環境で動いているClaude CodeにOpenTelemetryの設定を入れています。全社員に対して設定を入れるように依頼をしたとしても、どうしても漏れが生じてしまうため、そのような依頼ベースの手法に頼らず、ファイルを配布することを考えます。ZOZOはMDMツールとしてIntuneを利用しているため、Intuneの仕組みを使って以下のパスにJSONファイルを配置しました。 Windows: C:\Program Files\ClaudeCode\managed-settings.json macOS: /Library/Application Support/ClaudeCode/managed-settings.json この場所に配置したJSON設定ファイルはManaged settingsと呼ばれ、優先順位が最も高い設定ファイルとして認識されます。 code.claude.com そのため、以下のような内容のファイルを配布し、全社員のClaude CodeにOpenTelemetryの設定を追加しています。基本的には公式ドキュメントの通りの設定なので詳細な解説は省略しますが、Resource Attributeだけは少々工夫をしました。AWS Bedrockをモデルプロバイダーとして利用している時に利用者のメールアドレスが取得できなかったため、Resource Attributeにメールアドレスを入れるような設定を追加しています。また、OpenTelemetry情報を受け取るサーバーに認証を設定しているため、そのための認証トークンも埋め込んでいます。 { " env ": { " CLAUDE_CODE_ENABLE_TELEMETRY ": " 1 ", " OTEL_METRICS_EXPORTER ": " otlp ", " OTEL_LOGS_EXPORTER ": " otlp ", " OTEL_EXPORTER_OTLP_PROTOCOL ": " http/protobuf ", " OTEL_EXPORTER_OTLP_ENDPOINT ": " https://<OpenTelemetry エンドポイント> ", " OTEL_EXPORTER_OTLP_HEADERS ": " Authorization=Bearer <認証トークン> ", " OTEL_RESOURCE_ATTRIBUTES ": " user.email=<会社メールアドレス> ", " OTEL_METRICS_INCLUDE_VERSION ": " true " } } 利用情報を受け取る部分 次にOpenTelemetry情報を受け取る部分を説明します。Cloud Runの周りのアーキテクチャ図をより詳細に書くとこのようになります。 図からGoogle Cloudをメインにした構成であることが分かります。ZOZOは分析基盤としてBigQueryを活用しており、最終的にBigQueryに情報を格納すると便利なため、Google Cloudをメインとしています。AWSやSnowflakeなどに分析基盤を持っている方は、それらの中にClaude Codeの利用情報も入れると既存のアセットをうまく活用できます。AWSの上で似たような仕組みを構築する場合は、以下のドキュメントなどが参考になるかと思います。 github.com (2026-03-16 追記) また、DatadogもOpenTelemetry情報を受け取ってダッシュボード化する機能を提供しているので、Datadogを導入している方はこちらも参考になるかと思います。 www.datadoghq.com (2026-03-16 追記ここまで) Claude Codeから送信されたOpenTelemetry情報はCloud Load Balancingで受け取ってからCloud Runに転送しています。Cloud Runで直接受け取る構成にもできますが、独自ドメインの対応やCloud Armorとの統合などを考慮してCloud Load Balancingを挟む構成にしています。この部分のTerraformのコードを以下に貼ります。 resource "google_dns_record_set" "otel_collector" { name = "<Domain of OTel Collector>" type = "A" ttl = 300 managed_zone = google_dns_managed_zone.coding_ai.name rrdatas = [ google_compute_global_address.otel_collector.address ] } resource "google_compute_global_address" "otel_collector" { name = "otel-collector-ip" } resource "google_compute_global_forwarding_rule" "otel_collector" { name = "otel-collector-forwarding-rule" target = google_compute_target_https_proxy.otel_collector.id port_range = "443" ip_address = google_compute_global_address.otel_collector.id load_balancing_scheme = "EXTERNAL_MANAGED" } resource "google_compute_managed_ssl_certificate" "otel_collector" { name = "otel-collector-cert" managed { domains = [ "<Domain of Otel Collector>" ] } } resource "google_compute_ssl_policy" "otel_collector" { name = "otel-collector-ssl-policy" profile = "MODERN" min_tls_version = "TLS_1_2" } resource "google_compute_target_https_proxy" "otel_collector" { name = "otel-collector-https-proxy" url_map = google_compute_url_map.otel_collector.id ssl_certificates = [ google_compute_managed_ssl_certificate.otel_collector.id ] ssl_policy = google_compute_ssl_policy.otel_collector.id } resource "google_compute_url_map" "otel_collector" { name = "otel-collector-url-map" default_service = google_compute_backend_service.otel_collector.id } resource "google_compute_backend_service" "otel_collector" { name = "otel-collector-backend" protocol = "HTTPS" load_balancing_scheme = "EXTERNAL_MANAGED" backend { group = google_compute_region_network_endpoint_group.otel_collector.id } log_config { enable = true sample_rate = 1 . 0 } } resource "google_compute_region_network_endpoint_group" "otel_collector" { name = "otel-collector-neg" region = "asia-northeast1" network_endpoint_type = "SERVERLESS" cloud_run { service = google_cloud_run_v2_service.otel_collector.name } } resource "google_artifact_registry_repository" "otel_collector" { location = "asia-northeast1" repository_id = "otel-collector" description = "OpenTelemetry Collector images" format = "DOCKER" } resource "google_secret_manager_secret" "otel_auth_token" { secret_id = "otel-collector-auth-token" replication { auto {} } } resource "google_secret_manager_secret_iam_member" "otel_collector_secret_accessor" { secret_id = google_secret_manager_secret.otel_auth_token.secret_id role = "roles/secretmanager.secretAccessor" member = "serviceAccount:$ { google_service_account.otel_collector.email } " } resource "google_cloud_run_v2_service" "otel_collector" { name = "otel-collector" location = "asia-northeast1" ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" invoker_iam_disabled = true template { scaling { min_instance_count = 1 max_instance_count = 10 } service_account = google_service_account.otel_collector.email containers { image = "$ { google_artifact_registry_repository.otel_collector.location } -docker.pkg.dev/$ { local.project_id } /$ { google_artifact_registry_repository.otel_collector.repository_id } /otel-collector:latest" ports { container_port = 4318 } resources { limits = { cpu = "1" memory = "1Gi" } } env { name = "GCP_PROJECT_ID" value = local.project_id } env { name = "OTEL_AUTH_TOKEN" value_source { secret_key_ref { secret = google_secret_manager_secret.otel_auth_token.secret_id version = "latest" } } } } timeout = "300s" } traffic { type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST" percent = 100 } lifecycle { ignore_changes = [ scaling ] } depends_on = [ google_secret_manager_secret_iam_member.otel_collector_secret_accessor ] } Cloud Runの中にはOSSのOpenTelemetry Collectorが動いています。 github.com 以下のような設定で動いており、受け取った情報をCloud LoggingとCloud Metricsに転送していることが分かります。 extensions : bearertokenauth : token : ${env:OTEL_AUTH_TOKEN} receivers : otlp : protocols : http : endpoint : 0.0.0.0:${env:PORT} auth : authenticator : bearertokenauth processors : batch : timeout : 10s send_batch_size : 1024 transform : error_mode : ignore log_statements : - context : log statements : - 'set(body, {"message": body}) where IsString(body)' - 'merge_maps(attributes, resource.attributes, "upsert")' - 'merge_maps(body, attributes, "upsert")' exporters : googlecloud : project : ${env:GCP_PROJECT_ID} metric : prefix : "custom.googleapis.com/claude_code" log : default_log_name : "claude-code-telemetry" service : extensions : [ bearertokenauth ] pipelines : metrics : receivers : [ otlp ] processors : [ batch ] exporters : [ googlecloud ] logs : receivers : [ otlp ] processors : [ batch, transform ] exporters : [ googlecloud ] traces : receivers : [ otlp ] processors : [ batch ] exporters : [ googlecloud ] telemetry : logs : level : info YAMLには基本的な設定しか書いていませんが、transformの部分がやや特殊なので解説をします。Claude Codeが送信するログに含まれるResource AttributeをそのままCloud Loggingに送信したところ、その情報がCloud Loggingに保存されませんでした。そのため、Resource Attributeの情報を全て抜き出してLog Attributeにコピーしています。 また、Cloud Loggingの標準的なログの保持期限は30日ですので、保持期限を伸ばしています。 _Default ログバケットの保持期限を伸ばすと影響範囲が大きいため、Claude Code用のログバケットを新規に作成し、そちらに流れるようにLog Routerを設定しています。該当箇所のTerraformコードを以下に示します。 resource "google_logging_project_bucket_config" "claude_code_logs" { project = local.project_id location = "global" bucket_id = "claude_code_logs" retention_days = 3650 enable_analytics = true } resource "google_logging_project_sink" "claude_code_logs" { project = local.project_id name = "claude-code-logs-sink" destination = "logging.googleapis.com/projects/$ { local.project_id } /locations/global/buckets/$ { google_logging_project_bucket_config.claude_code_logs.bucket_id } " filter = "logName=\"projects/$ { local.project_id } /logs/claude-code-telemetry\"" unique_writer_identity = true } 利用情報を分析する部分 最後はCloud Loggingに格納されているClaude Codeの利用情報をBigQueryから参照できるようにする部分を解説します。 ここ数年でCloud LoggingとBigQueryはかなり高度に統合されています。特に以下の機能を使うとCloud Loggingに保存されたデータに対して直接BigQueryからクエリを実行できます。Cloud Loggingの中身はBigQueryそのものかと思えるほど統合されています。 cloud.google.com そのため、Cloud Loggingに情報を入れることとBigQueryに情報を入れることはほぼ等しくなっています。以下のようにLinked Datasetを作成すれば2つの世界がシームレスにつながり、BigQueryからのクエリを実行できます。 resource "google_logging_linked_dataset" "claude_code_logs" { bucket = google_logging_project_bucket_config.claude_code_logs.id link_id = "claude_code_logs_bq_link" description = "Linked dataset for querying Claude Code logs from BigQuery" } Claude Codeの利用情報は以下のようにJSON形式で半構造化されたデータが json_payload フィールドに格納されています。 ここに対していちいちJSONパースをするのは手間なので、パース後のVIEWをイベントに応じて作成しています。 SELECT -- Standard attributes JSON_VALUE(json_payload, ' $."session.id" ' ) AS session_id, CAST (JSON_VALUE(json_payload, ' $."event.sequence" ' ) AS INT64) AS event_sequence, JSON_VALUE(json_payload, ' $."service.name" ' ) AS service_name, JSON_VALUE(json_payload, ' $."service.version" ' ) AS service_version, JSON_VALUE(json_payload, ' $."app.version" ' ) AS app_version, JSON_VALUE(json_payload, ' $."organization.id" ' ) AS organization_id, JSON_VALUE(json_payload, ' $."user.account_uuid" ' ) AS user_account_uuid, JSON_VALUE(json_payload, ' $."user.id" ' ) AS user_id, JSON_VALUE(json_payload, ' $."user.email" ' ) AS user_email, JSON_VALUE(json_payload, ' $."host.arch" ' ) AS host_arch, JSON_VALUE(json_payload, ' $."os.type" ' ) AS os_type, JSON_VALUE(json_payload, ' $."os.version" ' ) AS os_version, JSON_VALUE(json_payload, ' $."terminal.type" ' ) AS terminal_type, -- Attributes JSON_VALUE(json_payload, ' $."event.name" ' ) AS event_name, TIMESTAMP (JSON_VALUE(json_payload, ' $."event.timestamp" ' )) AS event_timestamp, JSON_VALUE(json_payload, ' $.model ' ) AS model, CAST (JSON_VALUE(json_payload, ' $.cost_usd ' ) AS FLOAT64) AS cost_usd, CAST (JSON_VALUE(json_payload, ' $.duration_ms ' ) AS INT64) AS duration_ms, CAST (JSON_VALUE(json_payload, ' $.input_tokens ' ) AS INT64) AS input_tokens, CAST (JSON_VALUE(json_payload, ' $.output_tokens ' ) AS INT64) AS output_tokens, CAST (JSON_VALUE(json_payload, ' $.cache_read_tokens ' ) AS INT64) AS cache_read_tokens, CAST (JSON_VALUE(json_payload, ' $.cache_creation_tokens ' ) AS INT64) AS cache_creation_tokens FROM <Cloud LoggingとLinkされたデータセット> WHERE JSON_VALUE(json_payload, ' $."event.name" ' ) = ' api_request ' ZOZOのBigQueryは以下の仕組みでkintoneの情報をリアルタイムで取得できるようにしてあります。そのため、kintoneに格納されている組織図情報などとも組み合わせて、どの組織がClaude Codeをよく利用しているのかを分析できます。 techblog.zozo.com 利用情報の活用事例 OpenTelemetry機能を使って収集した利用情報の活用事例を1つ紹介します。 Claude Codeを利用するための課金体系はいくつかあります。Pro / Max / Teamプランのような費用が固定されるものもあれば、Anthropic API / AWS Bedrockなどのような従量課金のものもあります。Claude Codeの利用量が少ない人には、前者の方法はコストパフォーマンスが悪いため、後者の従量課金制の仕組みに移行してもらっています。この移行のために、 api_request イベントの cost_usd フィールドを集計して、各自に最も適したプランをアナウンスしています。 SELECT DATE (event_timestamp, " Asia/Tokyo " ) AS DATE , user_email, SUM (cost_usd) AS cost_usd, COUNT (*) AS api_call_count, FROM <APIリクエストログのVIEW> GROUP BY ALL まとめ Claude Codeの利用状況をOpenTelemetryで収集する仕組みを紹介しました。組織のAI活用を推進するためにはClaude CodeなどのAIツールの利用状況を集計・分析することが肝心です。同じような課題に直面している人の助けになると嬉しいです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.table-of-contents > li > ul { display: none; } はじめに こんにちは、データサイエンス部コーディネートサイエンスブロックの清水です。私たちのチームでは、WEARへ投稿されているコーディネート画像からVLM(Vision Language Model)で特徴を自動抽出するシステムを開発・運用しています。 プロンプト設計から推論パイプラインの構築、大規模推論まで、VLM・LLMを本番環境で活用する中、いくつかの運用課題に直面しました。本記事では、LLMOpsの全体像を整理した上で、観測基盤としてLangfuseを導入し、原因特定と改善の事例を紹介します。 目次 はじめに 目次 1. 直面した運用課題 モニタリングの不足 プロンプトとパラメーターの管理が分散 コスト管理の不透明さ 生成AIモデルのライフサイクルへの追従 2. LLMOpsの全体像とLangfuseの導入 LLMOpsとは Langfuseの選定理由 3. Langfuseの機能紹介 Tracing — モニタリングの不足を解決 Prompt Management — プロンプト管理の分散を解決 Cost Tracking — コスト管理の不透明さを解決 Tags・Session — モデルライフサイクルへの追従を支援 4. トレースによるエラー調査と改善事例 ダッシュボードによる問題の発見 ケース1:503エラー(APIの接続失敗) ケース2:Langfuseプロンプト取得のレイテンシー増加 ケース3:無限文字列の繰り返し出力 改善の全体的な効果 まとめ おわりに 1. 直面した運用課題 私たちは、小規模なデータを用いた実験や検証を経て、VLM・LLMの本番運用フェーズに移行しました。その中で、以下の4つの課題が浮かび上がりました。 モニタリングの不足 API呼び出し時のエラーや構造化出力のJSONパースエラーなど、想定されるエラーの監視が実行時のロギングのみに留まっていました。ログの粒度を細かく設定することで対処していましたが、推論対象のデータ数が増加するにつれ運用上の限界が顕在化し、生成AIの処理を体系的に記録・監視する仕組みの整備が求められていました。 プロンプトとパラメーターの管理が分散 運用中の特徴抽出プロンプトは10個を超えており、今後も増加が見込まれます。当時はプロンプトをExcel、パラメーター・configをGitHubで管理しており、バージョン管理が分散していました。プロンプト更新時にはGitHub側のパラメーター設定との整合性を都度確認する必要があり、一元的に管理する仕組みが整っていませんでした。 コスト管理の不透明さ APIの利用コストは請求画面上の合算値や日次の概算でしか把握できず、コスト急増時に原因となるリクエストや処理を特定することが困難でした。生成AIのモデルは世代ごとに料金体系が変動するため、日次推論の運用を見据えると、原因を追跡可能なコスト監視体制の構築が不可欠でした。 生成AIモデルのライフサイクルへの追従 生成AIモデルはライフサイクルが短く、迅速な更新サイクルへの追従が求められます。例えば私たちが利用しているGeminiでは、Stableモデルのリリースから概ね半年〜1年程度で提供終了を迎えるペースです 1 。モデル更新時には、データセットを用いた更新前後の精度比較やレイテンシーへの影響評価が不可欠です。 2. LLMOpsの全体像とLangfuseの導入 LLMOpsとは LLMOpsとは、大規模言語モデルの開発・運用・改善を体系的に管理するための一連のプラクティスです。従来のMLOpsがモデルの学習・デプロイ・監視を対象としているのに対し、LLMOpsではLLM特有の運用課題をカバーします。具体的には、プロンプトエンジニアリングやモデルの選択と更新、入出力のトラッキング、コスト管理などが含まれます。 IBM 2 、NVIDIA 3 、Databricks 4 、Dify 5 など各社のLLMOpsに関するドキュメントを調査しました。LLMOpsの全体像はDesign(設計)・Development(開発)・Operation(運用)の3フェーズに分類しました。特にDevelopmentフェーズではプロンプト管理や入出力のトレーシングと評価が重要です。Operationフェーズではエラー監視やコストトラッキングが中心的なプラクティスとして位置づけられています。 セクション1で挙げた4つの課題は、いずれもこのDevelopmentとOperationの領域に該当します。そこで、トレーシング・プロンプト管理・コスト監視を備えたLLMOpsツールを導入する方針としました。 Langfuseの選定理由 今回は観測基盤としてLangfuse 6 を採用しました。選定にあたってはLangSmith 7 やDify 8 を含む複数のツールを候補とし、以下の3軸で比較評価した結果、最も適していると判断しました。 セルフホスティングの可否 :社内のインフラ要件として、GCP上に自前でホスティングできることが重要でした。Langfuseはオープンソースで、この要件に最も合致しました。 既存の技術スタックとの統合のしやすさ :LangfuseはPython SDKを提供しています。私たちが利用しているVertex AI・LangChainなど主要フレームワークとの互換性もあり、既存のコードベースに自然に統合できました。 必要な機能の充足度 :Langfuseはトレーシング、プロンプト管理、コスト監視をワンストップで提供しており、マルチモーダル(画像入力のトレース)にも対応していることが決め手になりました。 3. Langfuseの機能紹介 ここからは、実際にLangfuseを導入した上で活用している主要な機能を、セクション1の課題との対応とあわせて紹介します 9 。 Tracing — モニタリングの不足を解決 Langfuseのトレーシングは、1回のリクエスト処理全体を Trace として記録し、その中の個々の処理ステップを Observation としてネストする階層構造をとります 10 。 上記の画像は、私たちの特徴抽出における実際のTrace画面です。左側のObservationツリーでは、1回の推論リクエスト全体が langfuse_gemini_request_with_retry というTraceとして記録されています。その配下に以下のObservationがネストされています。 fetch_langfuse_prompt (Span)— Langfuseからプロンプトを取得 append_feedback (Span)— フィードバック情報を付与 request_to_gemini (Generation, 8.36s, 4,986→302トークン、$0.000929)— Gemini APIの呼び出し validate_gemini_response (Span)— レスポンスの検証 parse_gemini_result (Span)— 結果のパース Observationには処理の期間を記録する Span と、LLM呼び出し特有の情報を記録する Generation の2種類があります。3番目の request_to_gemini がGenerationに該当し、実行時間・トークン数・コストといったLLM固有の情報が自動的に記録されます。右側のパネルでは入出力やメタデータも一覧表示され、1画面でリクエストの全容を把握できます。 従来のロギングでは個別のAPIコールしか追えず、エラー発生時にログを手動で突き合わせる必要がありました。Traceとして構造化することで、セクション1の「モニタリングの不足」を直接的に解決しました。導入も @observe デコレータを関数に付与するだけで済み、既存コードへの変更は最小限です。 Prompt Management — プロンプト管理の分散を解決 LangfuseのPrompt Management 11 は、プロンプトのバージョン管理・デプロイをLangfuse上で完結させる仕組みです。私たちが抱えていた「プロンプトはExcel、パラメーターはGitHub」という分散管理の課題に対して、以下の機能が直接的な解決策となりました。 バージョン管理とラベル 12 :プロンプトを更新するたびにバージョンが自動で作成され、変更履歴がイミュータブルに保持されます。各バージョンには production ・ staging などのラベルを付与でき、SDKからラベル指定で取得可能です。Diff表示機能もあり、バージョン間の差分をハイライトで確認できます。 Config 13 :プロンプトにモデル名・temperature・top_pなどのパラメーターを付与し、プロンプトと一緒にバージョン管理できます。コードの変更・再デプロイなしに、UI上でプロンプトとパラメーターをまとめて更新できるようになり、分散管理の解消に最も効いた機能です。 Traceとのリンク 14 :プロンプトをTraceに紐付けることで、どのバージョンがどの出力を生成したかを追跡できます。バージョンごとのレイテンシーやコストを比較でき、プロンプト改善の効果を定量的に測定可能です。 これにより、Excelとコードに分散していたプロンプトとパラメーターがLangfuse上に一元化されました。「どのバージョンが本番で動いているか」「何を変えたか」「変更の効果はどうか」を1つのツールで把握できます。 Cost Tracking — コスト管理の不透明さを解決 ダッシュボード 15 でモデルごとのコストやトークン数を時系列で可視化でき、運用時にコスト推移を一目で監視できます。セクション1で挙げた「コスト管理の不透明さ」について、従来は請求画面で合算値しか確認できませんでした。Langfuseの導入によりTrace単位・Generation単位で分解でき、異常なトークン消費の検知も容易になりました。 Tags・Session — モデルライフサイクルへの追従を支援 Langfuseでは、TraceにTags 16 やSession 17 といった属性を付与し、目的に応じてトレースデータを整理・フィルタリングできます。Tagsは任意の文字列をTraceやObservationに複数付与でき、アプリバージョン・LLM手法・実験IDなどの軸でUIやAPIからフィルタリング・グルーピングが可能です。Sessionは複数のTraceを1つのまとまりとしてグルーピングする仕組みで、 session_id を指定するだけで関連するTraceがセッション単位で集約されます。 私たちの運用では、評価実験やモデル更新のたびにTagsでTraceをグルーピングし、バージョン間の精度・レイテンシー・コストを比較しています。これにより、モデルのライフサイクルが短い環境でも、更新前後の品質を定量的に検証した上で移行でき、精度を担保した運用が可能になりました。 4. トレースによるエラー調査と改善事例 Langfuseを導入したことで、本番運用時に感じていた課題を解決できました。その中でも最も効果を実感したのはエラー調査と改善のフェーズです。ダッシュボードから問題を発見し、原因特定から改善まで行った実例を紹介します。 ダッシュボードによる問題の発見 日次での推論実行において、「早く実行が終わる日もあれば、非常に時間がかかる日もある」という現象が発生していました。実行ログからは、それぞれの推論対象となる入力データ数が大きく異なっていないことが事前に分かっていました。まずは原因を調査するためにLangfuseのダッシュボードを確認しました。 ダッシュボードで実行が完了したTrace数の推移を確認すると、最初は短時間で多くのAPIコールが成功するものの、その後に推論完了数が大幅に減少するパターンが確認できました。下図はその時に観測されたものです。 TraceやObservationを詳細に分析することで、以下の3つのケースを特定しました。 ケース1:503エラー(APIの接続失敗) 事象 :Geminiへの初回のAPIコールが503エラーで失敗し、その後に複数回の503エラーが起こった後にようやく成功するパターンが多発していました。 対策 :503エラーはAPI接続時のエラーであることから、API接続設定を調査しました。Vertex AIのPython SDKにはデフォルトで指数関数的バックオフ(Exponential Backoff)を利用したリトライ機構が備わっています 18 。私たちはこの仕組みを活かしつつも、システム全体が長時間ブロックされるのを防ぐため、リトライの上限回数(例:3回)やタイムアウト設定をクライアント側で適切にチューニングしました。結果として、一時的なエラーを許容しつつ、実行時のレイテンシー増加をコントロールできるようになりました。 ケース2:Langfuseプロンプト取得のレイテンシー増加 事象 :一部のTraceで、数時間〜最大10時間も処理がブロックされているケースが発生していました。Traceの実行時間を確認したところ、API呼び出しそのものではなく、Langfuseからのプロンプト取得処理のSpanに異常な時間がかかっていることが特定できました。 対策 :原因を調査した結果、プロンプト取得処理がリトライループの中に組み込まれていたことが判明しました。加えて、ネットワーク通信のタイムアウトが適切に設定されておらず、一時的な通信障害時に長時間プロセスがハングしていました。対策として、プロンプトの取得を最初の1回のみとし、オンメモリで保持するよう初期化処理を最適化しました。さらに、通信時のタイムアウト値を明示的に設定したことで、レイテンシーの異常な増加を根絶できました。 ケース3:無限文字列の繰り返し出力 Geminiの出力で特定の文字列が延々と繰り返され、構造化出力を想定していたJSONのパース処理で失敗してリトライが頻発しました。Trace Detail画面で出力内容がそのまま記録されていたため、無限に繰り返される文字列パターンを直接確認できました。あるTraceでは入力9,616トークンに対して出力64,999トークンという異常なトークン消費も記録されていました。 対策 :temperatureが0の場合、出力は決定的であるため、同じ入力に対してリトライしても同一の異常出力が再現されるだけで意味がありません。根本的な原因は特定の画像データとプロンプトの組み合わせにあると考えられます。しかし、膨大なコーディネート画像すべてのエッジケースを網羅する完璧なプロンプトの追求は困難です。そこで、エラー発生ごとにtemperatureを+0.1ずつインクリメントする実装を導入しました。temperatureを上げることで出力にランダム性が加わり、リトライ時に異なる出力が生成されるため、無限繰り返しから抜け出せる可能性が高まります。また max_tokens を明示的に指定し、万が一再発した場合でも異常な出力トークン数を制限できるようにしました。 改善の全体的な効果 それぞれ対策した結果、Traceのグラフも安定し、推論のスループットが一定で保たれるようになりました。Langfuse導入以前はVertex AIのログを手動で調査する必要があり、問題の全体像を把握するのに多大な時間を要していました。導入後は以下のような改善を実感しています。 エラー調査時間の短縮 :Trace単位で調査が完結するようになり、Trace一覧からエラーが起きていたAPI呼び出しが一目瞭然になった 入出力の精緻な監視 :各プロンプトの入力・出力・トークン数・コストを精緻に調査でき、異常検知が容易になった リトライ戦略の最適化 :リトライ回数や各リトライの出力がObservationとして記録され、定量的なデータに基づく改善が可能になった チーム内のコミュニケーション改善 :TraceのURLを共有するだけで、エンジニア間のエラー議論が具体的なデータに基づいて行えるようになった まとめ 本記事では、LLMの本番運用で直面した課題と、LangfuseによるLLMOps基盤の構築、トレースを活用したエラー調査と改善の事例を紹介しました。 Geminiのモデルライフサイクルに見られるように、Stableモデルのリリースから半年〜1年程度でRetirementを迎えるケースもあります。LLM特有の運用課題に対応するためには可観測性(Observability)の基盤を整えることが重要です。Langfuseは、トレーシング・プロンプト管理・コスト監視を統合的に提供するオープンソースのLLMOpsツールとして私たちの開発環境にフィットしました。特に、Traceの構造的な記録によってエラーの特定から対策実施までのサイクルを大幅に短縮できたことが最大の成果です。 今後は、Langfuseカスタムダッシュボードの活用、評価用データセットの構築とモデル更新時の自動評価パイプラインとの連携などに取り組んでいきます。さらなるLLMの安定した運用に活かしていきたいと考えております。 おわりに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com Model versions and lifecycle ↩ LLMOpsとは ↩ LLM の手法をマスターする: LLMOps ↩ LLMOps ↩ What is Ops in LLMOps? ↩ Langfuse Documentation ↩ LangSmith Documentation ↩ Dify Documentation ↩ Why Langfuse? ↩ Tracing Overview - Langfuse ↩ Prompt Management Overview - Langfuse ↩ Prompt Versioning - Langfuse ↩ Prompt Config - Langfuse ↩ Link Prompts to Traces - Langfuse ↩ Model Usage & Cost Tracking - Langfuse ↩ Tags - Langfuse ↩ Sessions - Langfuse ↩ Vertex AI Generative AI inference API errors ↩
アバター
はじめに こんにちは、WEAR開発部 バックエンドブロックのaao4seyです。普段は WEAR というプロダクトのバックエンド開発を担当しています。WEARバックエンドシステムでは2025年夏頃からパフォーマンス課題が顕在化し、SLOの悪化や運用負荷の増大といった問題に直面しました。本記事ではこれらの課題に対し、チームとしてどのように改善サイクルを構築し継続的に取り組んできたかをご紹介します。 目次 はじめに 目次 WEARバックエンドシステムが抱えていたパフォーマンス課題 DB負荷上昇の要因 SLOへの影響 課題解決に向けたアプローチ 継続的な現状確認と課題の洗い出し SLO定例(バックエンドブロック全員 / 隔週) パフォーマンス定点観測(SRE + バックエンドブロック 各数名 / 隔週) 2つの定例の関係性 改善サイクルを加速する仕組み Database Monitoringの活用 パフォーマンス改善に特化したAgent Skills 取り組みの成果 定量的な改善 チームの意識変化 今後の展望 まとめ WEARバックエンドシステムが抱えていたパフォーマンス課題 WEARバックエンドシステムには大きく2種類のアクセスがあります。1つはWEARアプリやWebからのユーザーリクエスト(コーディネート検索など)です。もう1つは ZOZOTOWN や FAANS といった自社の他サービスや、自社EC連携企業などの外部システムからのAPIアクセスです。 2025年7月頃からRailsサーバのレスポンス悪化やAPIアクセスのエラー数増加が目立つようになり、監視アラートの発報頻度が増えてきました。また、定期バッチの失敗も以前より増える傾向にありました。 これらの問題に対処すべく調査したところ、リクエストを処理するDBのCPU使用率が徐々に上昇し始めていることがパフォーマンス悪化やエラーの原因の多くであることがわかりました。 DB負荷上昇の要因 調査した結果、シンプルにAPIリクエスト数が増加しつつあることがわかりました。WEARはtoCサービスに加え前述の通り自社の他サービスや外部システムへAPIを提供しています。ユーザーの行動によるリクエスト数の変化に加え、システム間連携のAPIのリクエスト数も増加していることがわかりました。また、この時期にリリースした機能にもパフォーマンスを悪化させる要因が含まれていそうであることもわかりました。 SLOへの影響 WEARではSLOを「最低限」と「理想」の2段階で設定し、7日・30日・90日の各期間でレイテンシを定期的に監視しています。DB負荷の上昇に伴い、最低限の目標値こそ達成できていたものの、理想値は明らかに悪化していました。 「最低限」と「理想」ともに、全リクエストの99%以上が目標レイテンシ内に収まることをしきい値として設定していますが、「理想」のSLOは7日間平均で80%前後まで落ち込むこともありました。 負荷が上がることでAPIのレスポンスタイムの悪化に加えて、バッチ処理の失敗といった悪い影響も出始めました。また、Sentryのアラートも増加する傾向にあり、対応に追われている状況でした。 これらからDBの負荷の軽減が急務となりました。 課題解決に向けたアプローチ 継続的な現状確認と課題の洗い出し パフォーマンス課題に継続的に取り組むために、現状を定期的に把握することが必要と感じ、まずはシステムの課題を抽出する時間を設けることにしました。2025年秋から2つの定例会を隔週で運営しています。 SLO定例(バックエンドブロック全員 / 隔週) SLO定例はバックエンドブロック全員が参加する場で、SLOの達成状況の共有と改善タスクの進捗確認・成果報告を目的としています。実はこの定例は以前から存在していたのですが、パフォーマンスが悪化し始めた時期の前後でさまざまな事情により開催が途絶えていました。状況の悪化を受けて再開した形です。 この定例には主に3つの役割があります。 役割 内容 チーム全体での課題感の共有 SLOダッシュボードを全員で確認し、どのAPIのレイテンシがどの程度悪化しているのかを目線合わせする 改善の知見共有 インデックス追加、クエリの書き換え、実行計画の制御など、各メンバーが取り組んだ改善の解法を発表しチーム内に知見を蓄積する Sentryエラーのトリアージ しきい値を超えたエラーについて対応方針を決め、担当者をアサインする 各回の事前準備として、担当者がDatadogのパフォーマンス定点観測ダッシュボードのスクリーンショットを取得し、Sentryのエラーを確認します。Sentryでは7日間で設定したしきい値以上発生しているエラーをピックアップし、GitHub Issuesに起票して優先的に対処する運用としています。 パフォーマンス定点観測(SRE + バックエンドブロック 各数名 / 隔週) パフォーマンス定点観測はSREチームとバックエンドブロックの合同で実施している定例です。SLO定例がチーム全体の状況共有に重きを置いているのに対し、こちらはDB周りの技術的な深掘りを行う場として機能しています。「DBのCPU負荷が高騰する前の2025年8月の状態に戻す」ことを目標に掲げています。 この定例には主に3つの役割があります。 役割 内容 DB周りのシステム状況の共有 SREがDatadog上のDB負荷やクエリパフォーマンスの直近の状況を共有する ストアドプロシージャ等の改善計画 DB上で動いている業務ロジックのパフォーマンス改善方針を議論し、バックエンドブロックでアサイン可能な状態にする クエリチューニングの相談 バックエンドブロック単独では解決困難なSQL Server特有の問題について、SREの知見を借りて解決策を検討する 各回では表に挙げた情報の共有に加え、具体的な改善方針を議論します。また、徐々に目先の課題だけでなく中長期的な方針について意見を出し合う場としても機能し始めています。 2つの定例の関係性 2つの定例は独立して運営しているわけではなく、相互に連携しています。 SLO定例はバックエンドブロックが主体となり、実際のコード変更を伴う改善を推進する場です。一方、パフォーマンス定点観測はSREと連携してシステムの詳細な状況を把握する場です。SLO定例で対処が難しい課題はパフォーマンス定点観測に持ち込み、SREの知見を借りて解決策を検討します。逆に、パフォーマンス定点観測で得られたシステム状況の知見はSLO定例にフィードバックされ、改善の優先度判断に活用されます。 例えば、クエリチューニングの方法として複数の選択肢がある場合、パフォーマンス定点観測で共有されたDBのリソース状況を踏まえて、どちらがより効果的かを判断できます。 改善サイクルを加速する仕組み 個々の改善をスピーディに進めるために以下のような仕組みを活用しています。 Database Monitoring の活用 パフォーマンス改善の起点となるのは「どのクエリが遅いのか」の特定です。WEARではDatadogの Database Monitoring (以下、DBM)を活用しています。DBMはSREチームが以前から導入してくれていたものですが、今回のパフォーマンス改善の取り組みをきっかけに、バックエンドブロックでもより積極的に利用するようになりました。 DBMを活用すると、遅いエンドポイントの発見から原因クエリの特定、実行計画の確認まで、ほとんどの場合Datadog上で完結します。具体的には以下の流れで調査を進められます。 APMで遅いエンドポイントを特定する そのエンドポイントから発行されているクエリの一覧をDBMで確認する 問題のクエリの実行計画をDBM上で直接確認する 特に有用なのは、実行計画の確認が容易な点です。DBMでは実行計画を常に取得できるわけではありませんが、取得できた場合にはインデックス追加やHINT句の付与といった改善の後、実行計画が想定通り変化したかをすぐに検証できます。SQL Serverは統計情報の更新タイミング次第で実行計画が不安定になることがあります。DBMで継続的に観測し、そうした変動も素早く検知できるようになりました。 パフォーマンス改善に特化したAgent Skills クエリチューニングの作業をさらに効率化するために、SQL Serverの実行計画の分析に特化したAgent Skillsを作成し、チーム内で共有しています。 WEARバックエンドブロックではAgent Skillsを共有するリポジトリを運用しています。その中にパフォーマンス改善向けのSkillsを追加しました。このSkillsは、実行計画のXMLやSentry IssueのURLを入力として受け取ります。MCP経由でSentryの情報も取得しながらタイムアウト箇所やボトルネックを特定し、インデックスの追加などの改善策を提案します。 SQL Serverの実行計画の読み解きには専門的な知識が求められます。Agent Skillsを活用することでチームメンバーの経験レベルに関わらず一定の品質で分析を進められるような環境作りに取り組んでいます。 取り組みの成果 まだ取り組みを始めたばかりであり道半ばではあるのですが、これらの取り組みを始めて徐々に成果が出始めています。 定量的な改善 一番根本的な課題となっていたDBのCPU使用率は、取り組みを始めてから少しずつ緩和される傾向にあります。リソースの使用率は外部環境にも依存するため、すべてが取り組みの成果とは言い切れません。しかし、少なくとも改善の兆しが見えつつある状況です。 また、SLOラベルが付与されたパフォーマンス改善PRの件数にも変化が現れています。定例再開前の2025年1月〜10月は月平均1.2件だったのに対し、再開後の2025年11月〜2026年2月は月平均6.0件と、約5倍に増加しました。 チームの意識変化 定量的な改善だけでなく、チーム全体の意識にも以下のような変化が出始めています。 早期検知 :定例でダッシュボードを定期的に確認する習慣が根付き、レイテンシ悪化やエラー増加に早い段階で気づけるようになった 影響把握の迅速化 :機能リリース後のパフォーマンス悪化を定例サイクルの中で早期に検知でき、原因特定から修正までのリードタイムが短縮された 知見の蓄積 :SLO定例での発表を通じてインデックス設計やHINT句の使い方、実行計画の読み方といった知見がチーム全体で共有されるようになった 今後の展望 現在の改善サイクルは順調に機能していますが、さらなる効率化に向けて以下のような取り組みも進めていきたいと考えています。 1つ目は、SentryやDatadogの通知を起点とした改善の自動化です。現在は定例で検知した課題をトリアージし、GitHub Issuesに起票しています。将来的にはこのプロセスを自動化し、エラーの検知から調査、改善PRの作成までをLLMで効率化したいと考えています。極力人手を介さず課題の解決にたどり着ける状態を目指します。 2つ目は、コンテキスト情報の自動収集です。クエリチューニングを行う際には、実行計画やテーブル定義、インデックス情報など多くのコンテキストが必要になります。これらの情報をLLMが自動で収集・整理できる環境を整備することで、改善の初動をさらに早めたいと考えています。例えば本記事内で紹介したDBM上のクエリの実行計画などにAI Agentが直接アクセスできるようにすることで、より素早く精度の良い結果を得られるのではと考えています。 まとめ 本記事では、WEARバックエンドシステムにおけるパフォーマンス課題と、その解決に向けたバックエンドブロックの取り組みを紹介しました。 パフォーマンス改善は一度やって終わりではなく、サービスの成長とともに継続的に取り組むべきテーマです。本記事が同様の課題に取り組むチームの参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.table-of-contents > li > ul { display: none; } こんにちは、MA部配信基盤ブロックの田島です。ZOZOTOWNではユーザへのコミュニケーション手段の1つとしてアプリへのPush通知を活用しており、配信にはFirebase Cloud Messaging(以降、FCM)を利用しています。 FCMではPush通知の送信先となるデバイスごとに「FCMトークン」と呼ばれる一意の識別子が発行され、このトークンを宛先としてFCMにリクエストを行うことで、特定のデバイスにPush通知が届きます。 FCMでは無効なトークンに対して UNREGISTERED エラーを返します。Firebaseの公式ドキュメントでは、このエラーが返されたトークンを無効として扱うことが推奨されています。しかし、我々の調査により、 一度 UNREGISTERED エラーを受けたトークンがその後復活し、再び有効になるケース の存在を確認しました。復活したトークンで配信すると正常にPush通知が届きクリックイベントも取得できることから、確実に有効なトークンであることを確認しています。 本記事では、このトークン復活の実態調査と、FCMの validate_only APIを活用したエラートークン管理の精緻化について紹介します。 目次 目次 背景と課題 FCMトークンとは 既存のエラートークン管理の問題 エラートークン復活の調査 調査内容 調査方法 調査結果 トークン復活に関する補足 方針の検討 方針1: 一定期間UNREGISTEREDが続いたトークンをエラー扱い 方針2: 即時エラー登録 + validate_onlyで定期解除 決定した方針 FCM validate_only フラグを利用したトークンの検証 validate_only フラグ 動作検証 エラートークンの収集と検証バッチの実装 テーブル設計 エラートークンテーブル(error_fcm_tokens) 再有効化テーブル(reactivated_fcm_tokens) エラートークンの収集・再検証ワークフロー 1. エラートークンの収集 2. 検証用一時テーブルの作成 3. エラートークンの再検証(並列処理) 検証対象トークンの取得 シャード単位の検証処理 FCM APIによるトークン検証 4. エラートークンテーブルの更新 パフォーマンス 既存の全トークンの再検証と本番リリース 初回実行:全期間のエラートークンを検証 通常運用の開始 まとめ 最後に 背景と課題 FCMトークンとは 最初にも紹介しましたが、FCMトークンとは、FCMがPush通知の送信先を識別するために、アプリがインストールされた各デバイスに対して発行する一意の識別子です。アプリの初回起動時にFCM SDKがこのトークンを生成し、このトークンを指定してFCMにリクエストを行うことで、特定のデバイスにPush通知が届きます。 配信フローとしては、サーバからFCMにメッセージリクエストが送られます。FCMはプラットフォーム固有の転送層(Androidの場合はATL、iOSの場合はAPNs)を経由して対象デバイスにメッセージを届けます。 FCMトークンは永続的でなく、以下のような理由で無効化や更新が発生します。 トークンがリフレッシュされた場合 トークンの保持期間を超過した場合 アプリがアンインストールされた場合 無効になったトークンを使ってFCMにリクエストを行うと、 UNREGISTERED エラーが返されます。 firebase.google.com 既存のエラートークン管理の問題 Firebaseの公式ドキュメントでは、 UNREGISTERED エラーが返されたトークンを無効として扱うことがベストプラクティスとして紹介されています。 firebase.google.com こちらに則り、 UNREGISTERED エラーが返されたトークンを無効として記録し、以降の配信対象から除外していました。しかし、 UNREGISTERED エラーを受けたトークンがその後再び有効になるケースの存在を確認しました。この場合、本来配信すべきユーザにPush通知が届かなくなってしまいます。 まずはユーザへの配信が確実にできることを優先し、エラートークンの登録処理を一時的に停止した上で、復活の頻度や傾向を正確に把握するための調査を実施しました。 エラートークン復活の調査 調査内容 エラートークンの管理方針を決めるにあたり、以下の点を調査しました。 SUCCESS → UNREGISTERED → SUCCESS が発生する頻度 UNREGISTERED が何回連続した後 SUCCESS へ復帰するケースがあるか UNREGISTERED がどれくらいの期間続いた後 SUCCESS へ復帰するケースがあるか SUCCESS に復帰後、どれくらいの回数成功が続くか 調査方法 約2.5か月分(2025年8月以降)の配信ログを対象に、同一トークンにおけるステータス遷移を分析しました。 分析に使用した push_logs テーブルは、Push通知の配信結果を1リクエストごとに記録したログテーブルです。主なカラムは以下の通りです。 カラム名 型 説明 token STRING 配信先のFCMトークン delivered_at TIMESTAMP 配信日時 status STRING 配信結果( SUCCESS , FAILED ) status_detail STRING 失敗時の詳細( UNREGISTERED など) fcm_message_id STRING FCMが発行したメッセージID 以下のクエリで、 SUCCESS と SUCCESS の間に UNREGISTERED が挟まるケースを抽出しています。 WITH base AS ( SELECT token, delivered_at, status, status_detail, fcm_message_id FROM `project.dataset.push_logs` WHERE TIMESTAMP_TRUNC(delivered_at, DAY) >= TIMESTAMP ( " 2025-08-01 " ) AND TIMESTAMP_TRUNC(delivered_at, DAY) <= TIMESTAMP ( " 2025-10-15 " ) AND token IS NOT NULL AND status IN ( ' SUCCESS ' , ' FAILED ' ) ), -- トークンごとに時系列でインデックスを付与 ordered AS ( SELECT token, delivered_at, status, status_detail, fcm_message_id, ROW_NUMBER() OVER (PARTITION BY token ORDER BY delivered_at, fcm_message_id) AS rn FROM base ), -- 累積のUNREGISTERED失敗数などを付与 ord AS ( SELECT o.*, SUM ( CASE WHEN o.status = ' FAILED ' AND o.status_detail = ' UNREGISTERED ' THEN 1 ELSE 0 END ) OVER (PARTITION BY o.token ORDER BY o.rn ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cum_unreg_failed, MIN ( IF (o.status != ' SUCCESS ' , o.rn, NULL )) OVER (PARTITION BY o.token ORDER BY o.rn ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS next_non_success_rn, COUNT (*) OVER (PARTITION BY o.token) AS total_rows FROM ordered o ), -- SUCCESS行から直前のSUCCESSとの関係を取得 success_pairs AS ( SELECT s.token, s.rn AS success_rn, s.delivered_at AS success_at, LAG(s.rn) OVER (PARTITION BY s.token ORDER BY s.rn) AS prev_success_rn, LAG(s.delivered_at) OVER (PARTITION BY s.token ORDER BY s.rn) AS prev_success_at FROM ord s WHERE s.status = ' SUCCESS ' ), -- 直前SUCCESS〜今回SUCCESSの間にあるUNREGISTERED失敗件数を算出 final AS ( SELECT sp.token, sp.prev_success_at, sp.success_at AS recover_success_at, (oc.cum_unreg_failed - COALESCE (op.cum_unreg_failed, 0 )) AS unreg_failed_between_successes, ( COALESCE (oc.next_non_success_rn, oc.total_rows + 1 ) - oc.rn) AS consecutive_success_count_after FROM success_pairs sp JOIN ord oc ON oc.token = sp.token AND oc.rn = sp.success_rn LEFT JOIN ord op ON op.token = sp.token AND op.rn = sp.prev_success_rn WHERE sp.prev_success_rn IS NOT NULL ) SELECT * FROM final WHERE unreg_failed_between_successes > 0 ORDER BY recover_success_at; 調査結果 項目 結果 SUCCESS → UNREGISTERED → SUCCESS の発生頻度 2.5か月で約230件 UNREGISTERED の最大の連続回数 約80回 UNREGISTERED が続く最大期間 約14日 SUCCESS 復帰後の成功回数 ケースにより異なる この結果から、 UNREGISTERED の返されたトークンが復活するケースは確かに存在することがわかりました。また、 UNREGISTERED の連続する回数・期間も把握できました。 トークン復活に関する補足 FCMの公式ドキュメントでは、 UNREGISTERED エラーが返されたトークンについて「it will never again be valid(二度と有効にはならない)」と明記されています。そのため、即座に削除することが推奨されています。 firebase.google.com ただし、FCMのエラーコードに関するドキュメントでは「This usually means that the token used is no longer valid and a new one must be used.(通常、使用されたトークンはもはや有効ではなく、新しいトークンを使用する必要があることを意味します)」という表現になっており、「usually」という留保がついています。 firebase.google.com 実際に復活したトークンを使って配信すると正常にPush通知が届き、クリックイベントも取得できることを確認しています。トークンがリフレッシュされて新しいものが発行されたわけでもなく、同一のトークンがそのまま再び有効になっていました。公式ドキュメントの記述と実際の挙動に乖離がある状況です。 なお、この挙動は2026年3月時点でも確認されています。将来的にFCM側で修正される可能性もあるため、最新の挙動については各自で検証されることをお勧めします。 方針の検討 調査により、トークンの復活は2.5か月で約230件と少数ながら確実に発生しており、最長で約14日間 UNREGISTERED が続いた後に復活するケースも確認されました。 この結果を踏まえると、エラートークンの管理には以下の2点を両立させる必要があります。 無効なトークンへの無駄な配信を早期に止めること 復活する可能性のあるトークンを誤って永久に除外しないこと これらを考慮し、以下の2つの方針を検討しました。 方針1: 一定期間UNREGISTEREDが続いたトークンをエラー扱い 1つめの方針は、1か月ずっと UNREGISTERED となっているトークンをエラー扱いにする方式です。調査結果から14日以上 UNREGISTERED が続くケースはなかったため、1か月の閾値で安全にエラートークンを判定できます。これにより、本当に無効となったトークンのみをエラートークンとして保持できます。 方針2: 即時エラー登録 + validate_onlyで定期解除 従来通り UNREGISTERED になったトークンをエラー扱いとしつつ、定期的に validate_only でトークンの有効性を再検証し、復活したトークンをエラーリストから除外する方式です。 validate_only については後ほど説明します。これにより、無効と判定したトークンを即時無効にしつつ、復活したトークンに対しても配信を継続できます。 決定した方針 両方針を比較した結果、以下の理由から 方針2 を採用しました。 方針1だと、一度 UNREGISTERED となったトークンが復活しない場合、1か月の間無効なトークンに配信し続けてしまう 初回の validate_only 検証を既存の全エラートークンに実施することで、これまでに蓄積したエラートークンを有効活用できる 既存のエラートークン登録フローを大きく変更する必要がない FCM validate_only フラグを利用したトークンの検証 validate_only フラグ FCMの messages.send API(FCMにPush通知送信を依頼するAPI)には validate_only フラグがあります。これを true に設定すると、実際にメッセージを配信せずにトークンの有効性のみを検証できます。 動作検証 validate_only (Firebase Admin SDKでは dry_run パラメータに対応)が本当に配信しないことを事前に検証しました。 dry_run=True の場合、レスポンスの message_id が fake_message_id となり、実際のメッセージ配信は行われません。これにより、安全にトークンの有効性を確認できることが実証されました。 dry_run の詳細な検証については、以下の記事にまとめています。 qiita.com エラートークンの収集と検証バッチの実装 ここからは、エラートークンの収集と検証の方法について紹介します。 テーブル設計 本施策では主に2つのテーブルを使用します。 エラートークンテーブル( error_fcm_tokens ) UNREGISTERED エラーが返されたトークンを記録するテーブルです。FCMトークンそのものをキーとして管理することで、トークンの有効性を直接的に判定できるようにしています。 カラム名 型 説明 fcm_token STRING エラーとなったFCMトークン first_errored_at TIMESTAMP 初めて UNREGISTERED エラーが発生した日時 registered_at TIMESTAMP エラートークンとして登録した日時 再有効化テーブル( reactivated_fcm_tokens ) 一度エラーとなったが、 validate_only による再検証で有効と判定されたトークンの履歴を記録するテーブルです。 カラム名 型 説明 fcm_token STRING 再有効化されたFCMトークン validated_at TIMESTAMP validate_only で検証した日時 reactivated_at TIMESTAMP エラートークンテーブルから削除し再有効化した日時 エラートークンの収集・再検証ワークフロー エラートークンの収集と再検証を日次で行うワークフロー( refresh_error_fcm_tokens )を作成しました。バッチ処理にはワークフローエンジンの Digdag を使用しています。Digdagのワークフロー定義は以下の通りです。Digdagでは + で始まるブロックがタスクを表し、上から順に実行されます。 timezone : Asia/Tokyo schedule : daily> : 00:00:00 # 毎日0時に実行 # ワークフロー全体で使う変数の定義 _export : # 検証結果を格納する一時テーブル名(実行日ごとに一意になるようにする) validated_fcm_tokens_temp_table_id : "project.temp.validated_fcm_tokens_temp_${moment(session_time).format('YYYYMMDD')}" # 並列処理のシャード数 total_shards : 50 # 1. 配信ログからUNREGISTEREDエラーのトークンを収集し、エラートークンテーブルに登録 +collect_fcm_error_tokens : py> : app.collect_fcm_error_tokens # 2. 検証結果を格納する一時テーブルを作成 +create_temp_table : py> : app.refresh_error_fcm_tokens.create_validation_temp_table # 3. エラートークンを50シャードに分割し、並列でFCM APIに検証リクエストを送信 # loop>: 0〜49のインデックス(${i})で繰り返し、_parallel: trueで全シャードを同時実行 +validate_fcm_tokens_parallel : _parallel : true loop> : ${total_shards} _do : +validate_shard : py> : app.refresh_error_fcm_tokens.validate_fcm_tokens_shard shard_index : ${i} total_shards : ${total_shards} # 4. 一時テーブルの検証結果をもとに、有効なトークンをエラートークンテーブルから削除し、 # 再有効化テーブル(reactivated_fcm_tokens)に記録 +update_error_and_reactivated_fcm_tokens : py> : app.refresh_error_fcm_tokens.update_error_and_reactivated_fcm_tokens 以下でそれぞれについて具体的に説明します。 1. エラートークンの収集 はじめに配信ログテーブルから UNREGISTERED エラーのトークンを以下のSQLで収集し、エラートークンテーブルに追加します。このワークフローは日次で実行されますが、対象期間を直近3日間としています。これは、ワークフローが2日連続で失敗した場合でも3日目の実行で未収集分をカバーできるようにするためです。 -- エラートークンの収集クエリ SELECT token AS fcm_token, MIN (delivered_at) AS first_errored_at, CURRENT_TIMESTAMP AS registered_at FROM `project.ma_batch.push_logs` AS push_logs LEFT OUTER JOIN `project.push.error_fcm_tokens` AS target ON push_logs.token = target.fcm_token WHERE status = " FAILED " AND status_detail = " UNREGISTERED " -- 日次実行だが、2日連続WF失敗時でも3日目に回復できるよう3日分のバッファを確保 AND DATE (delivered_at) >= DATE_ADD( CURRENT_DATE ( ' Asia/Tokyo ' ), INTERVAL -3 DAY) AND target.fcm_token IS NULL GROUP BY token 2. 検証用一時テーブルの作成 トークンの有効性の検証結果を格納するための一時テーブルを作成します。各シャードがFCM APIの検証結果をこのテーブルに書き込み、最後にまとめてエラートークンテーブルを更新します。 DROP TABLE IF EXISTS `{validated_fcm_tokens_temp_table_id}`; CREATE TABLE `{validated_fcm_tokens_temp_table_id}` ( fcm_token STRING NOT NULL , -- 検証対象のFCMトークン validated_at TIMESTAMP NOT NULL , -- 検証日時 valid BOOLEAN NOT NULL , -- 有効かどうか error_code STRING, -- 無効だった場合のエラーコード ); 3. エラートークンの再検証(並列処理) エラートークンテーブルに登録済みのトークンに対し、FCMの validate_only APIでトークンの有効性を再検証します。この処理は50シャードに分割して並列実行されます。 検証対象トークンの取得 各シャードが担当するトークンを取得するSQLは以下の通りです。 FARM_FINGERPRINT でトークンをハッシュ化し、シャード数で剰余を取ることで均等に分割しています。また、 first_errored_at が直近30日以内のトークンのみを対象とし、復活の見込みが低い古いトークンへの無駄な検証を避けています。この期間は、調査でわかった UNREGISTERED が続く最大期間の約14日に余裕をもたせて設定しています。 SELECT error_tokens.fcm_token, error_tokens.first_errored_at FROM `project.push.error_fcm_tokens` AS error_tokens LEFT JOIN `{validated_fcm_tokens_temp_table_id}` AS temp_tokens ON error_tokens.fcm_token = temp_tokens.fcm_token WHERE -- 直近30日以内に登録されたエラートークンのみを対象 error_tokens.first_errored_at >= TIMESTAMP_SUB( CURRENT_TIMESTAMP (), INTERVAL 30 DAY) AND -- FARM_FINGERPRINTでトークンをハッシュ化し、シャードに均等分割 MOD ( ABS (FARM_FINGERPRINT(error_tokens.fcm_token)), {total_shards}) = {shard_index} AND -- リトライ時に既に処理済みのトークンを除外 temp_tokens.fcm_token IS NULL シャード単位の検証処理 各シャードでは上記のSQLで取得したエラートークンに対し、FCMの dry_run ( validate_only に対応)でトークンの有効性を検証しています。検証対象のトークン数は数百万件に及ぶため、メモリ効率を考慮して5,000件ごとのバッチに分割して処理しています。検証結果は一時テーブルに書き込まれます。 def validate_fcm_tokens_shard (self, shard_index, total_shards, ...) -> None : # リトライ時に途中から再開できるよう、5,000件ずつ処理する BATCH_SIZE = 5000 # BigQueryからこのシャードが担当するエラートークンを取得 # (例: shard_index=0, total_shards=50 なら、全体の1/50を担当) result = self._bq_client.execute_bigquery_result( query_path= "get_error_fcm_tokens_shard.sql" , params={ "shard_index" : shard_index, "total_shards" : total_shards}, ) fcm_client = FCMClient(fcm_gcp_project) # 5,000件ずつFCM APIで検証し、結果を一時テーブルに書き込む for batch_tokens in self._create_batches(result, BATCH_SIZE): valid_tokens, invalid_tokens = fcm_client.validate_tokens_batch(batch_tokens) self._insert_validation_results(valid_tokens, invalid_tokens) FCM APIによるトークン検証 FCMトークンの実際の検証では、Firebase Admin SDKの messaging.send_each を dry_run=True で呼び出しています。実際にメッセージを配信せずにトークンの有効性のみを検証できます。 send_each は1リクエストあたり最大500件のため、500件単位で分割してリクエストを送信しています。 class FCMClient : BATCH_SIZE = 500 # send_eachの1リクエストあたりの最大件数 def validate_tokens_batch (self, tokens: List[ str ]) -> Tuple[List[ str ], List[Tuple[ str , str ]]]: valid_tokens = [] # 有効と判定されたトークンのリスト invalid_tokens = [] # 無効と判定されたトークンと、そのエラーコードのリスト # 500件ずつに分割してFCM APIにリクエスト for i in range ( 0 , len (tokens), self.BATCH_SIZE): batch = tokens[i:i + self.BATCH_SIZE] # 各トークンに対してダミーのメッセージオブジェクトを生成 messages = [ messaging.Message(token=token, data={ 'validation' : 'true' }) for token in batch ] # dry_run=True により実際の配信は行わず、トークンの有効性のみ検証 batch_response = messaging.send_each(messages, dry_run= True ) # レスポンスからトークンごとの有効/無効を判定 for idx, response in enumerate (batch_response.responses): token = batch[idx] if response.success: valid_tokens.append(token) else : error_code = response.exception.code if response.exception else "Unknown" invalid_tokens.append((token, error_code)) return valid_tokens, invalid_tokens 4. エラートークンテーブルの更新 全シャードの検証が完了した後、一時テーブルの結果をもとにエラートークンテーブルと再有効化テーブルをトランザクション内で一括更新します。有効と判定されたトークンを再有効化テーブルにMERGEし、エラートークンテーブルから削除しています。 BEGIN TRANSACTION; -- 一時テーブルから有効と判定されたトークンを重複排除して抽出 CREATE TEMP TABLE deduped_tokens AS SELECT DISTINCT fcm_token, MAX (validated_at) AS validated_at, CURRENT_TIMESTAMP () AS reactivated_at FROM `{validated_fcm_tokens_temp_table_id}` WHERE valid = TRUE GROUP BY fcm_token; -- 有効なトークンを再有効化テーブルに記録 MERGE `project.push.reactivated_fcm_tokens` AS target USING deduped_tokens AS source ON (target.fcm_token = source.fcm_token AND target.validated_at = source.validated_at) WHEN NOT MATCHED THEN INSERT (fcm_token, validated_at, reactivated_at) VALUES (source.fcm_token, source.validated_at, source.reactivated_at); -- 有効なトークンをエラートークンテーブルから削除 DELETE FROM `project.push.error_fcm_tokens` WHERE fcm_token IN ( SELECT DISTINCT fcm_token FROM `{validated_fcm_tokens_temp_table_id}` WHERE valid = TRUE ); COMMIT TRANSACTION; パフォーマンス 上記の処理がどれくらいの時間で完了するのか、パフォーマンス計測をした結果は以下の通りです。 対象件数 並列数 処理時間 10万件 1並列 約25分 約800万件(全量) 50並列 約50分 また、FCM APIのQuotaについても確認し、日中に実行しても問題ない余裕があることを確認しました。 既存の全トークンの再検証と本番リリース 初回実行:全期間のエラートークンを検証 初回実行では、過去に蓄積された全エラートークン(約754万件)を対象に検証しました。通常運用では直近30日以内のエラートークンのみを検証対象としていますが、初回は既存の全トークンの検証が必要でした。そのため、検証対象を取得するSQLの30日の条件を一時的にコメントアウトしてワークフローを実行しました。 WHERE -- 初回実行時は全期間のエラートークンを対象にするため一時的にコメントアウト -- error_tokens.first_errored_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY) AND MOD ( ABS (FARM_FINGERPRINT(error_tokens.fcm_token)), {total_shards}) = {shard_index} AND temp_tokens.fcm_token IS NULL 実行前のエラートークン数:約7,500,000件 エラートークン収集直後:約+70件(新規エラートークン) 再検証後:約ー170件(復活トークン) 約170件のトークンが validate_only で有効と判定され、エラートークンから解除されました。 通常運用の開始 初回実行後、1か月以内に登録されたエラートークンを対象とする通常運用を開始しました。 実行前のエラートークン数:約7,500,000件 エラートークン収集直後:約+6,500件(新規エラートークン) 再検証後:約ー10件(復活トークン) 日次で約10件のトークンが再有効化されていることが確認できました。 まとめ 本記事では、FCMエラートークンの管理を精緻化した取り組みについて紹介しました。 従来は UNREGISTERED エラーの返されたトークンを即時かつ永続的にエラー扱いとしていました。しかし調査の結果、一度無効になったトークンが復活するケースの存在を確認しました。そこでFCMの validate_only APIを活用した定期的な再検証の仕組みを導入し、復活したトークンを自動的にエラーリストから解除するようにしました。 この改善により、以下の効果が得られました。 無効トークンへの無駄な配信リクエストの削減によるコスト最適化 セグメントのボリューム把握の精度向上 トークン復活時の配信漏れ防止 FCMトークンの管理は、Push配信の品質とコストに直結する重要な要素です。同様の課題をお持ちの方の参考になれば幸いです。 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.table-of-contents > li > ul > li > ul { display: none; } はじめに こんにちは、ZOZOTOWN開発本部 ZOZOTOWN開発1部 Android2ブロックの高橋です。普段はZOZOTOWNのAndroidアプリ開発を担当しています。 アプリ開発において、Google Analyticsなどのイベントトラッキング機能はプロダクトの改善のための重要な機能です。しかし、「正しいデータが送信されているか」だけでなく「正しいタイミングで送信されているか」の検証が難しいという課題もあります。ZOZOTOWNのAndroidアプリ開発においても課題となっていました。本記事では、この課題を解決するために開発したAndroid Studioプラグインと、その技術選定・設計についてご紹介します。 目次 はじめに 目次 背景・課題 1. 値の妥当性確認 2. 「どの操作で発火したか」の再現・確認 3. PRレビューでの説明 求められる解決策 解決アプローチ: Android Studioプラグインの開発 処理フロー 1. ログの取得とパース 2. イベントの保存とUI表示 3. 画面キャプチャの取得 4. ノードクリックによるDVR再生 イベントログの収集 イベントの統合・可視化 画面キャプチャ DVR:イベントとの同期 セッションのエクスポート・インポート PRレビューでの活用 実装について 効果 今後の展望 まとめ 背景・課題 イベントの実装時やPRレビュー時、以下のような作業に時間を取られていました。 1. 値の妥当性確認 これまでイベントのデバッグにはLogcatに出力されるログを使用していました。GAに関してはFirebase DebugViewでリアルタイムにイベントを確認できますが、GAと自社ログなど複数の送信先を横断的に確認できません。Logcatであれば送信先を問わず確認できますが、大量のログの中から目的のイベントログを見つけ出すには、タグやキーワードでフィルタリングしながら目視で探す必要がありました。GAや自社ログなど送信先ごとにフォーマットも異なるため、それぞれの形式を把握しておく必要もあります。 そのような環境で、必須パラメータの過不足や値の妥当性をテキストとして流れるログから目視で確認するのは手間がかかります。 2. 「どの操作で発火したか」の再現・確認 特に難しかったのが、イベント発火のタイミング確認です。 イベントが短期間で複数発生するケースでは、ログを見ただけでは「どの操作がどのイベントに対応するのか」を判別するのが困難でした。一定期間内に一度しか発火しないイベントであれば操作との対応は明らかですが、リスト画面でのスクロールや連続タップなど、似たイベントが立て続けに発火する場面では確認が難しくなります。 3. PRレビューでの説明 「このイベントは正しいタイミングで送信されていますか?」というレビュー指摘に対して、ログのテキストだけで説明するのは困難です。「画面Aから画面Bへ遷移したときに発火する」という仕様を操作と紐づけて証明する手段がありませんでした。 求められる解決策 これらの課題を解決するには、以下の要件を満たすツールが必要でした。 複数の送信先のイベントを統一的に可視化できる イベントの発火タイミングを実際の操作映像と紐づけて確認できる 確認した結果を他のメンバーに共有できる 開発フローに組み込みやすい(IDEとの統合) 解決アプローチ: Android Studioプラグインの開発 課題を解決するため、3つの機能を統合したAndroid Studioプラグインを開発しました。 機能 役割 イベントビューア ログをパースし、時系列のノード型UIで可視化 DVR(Digital Video Recorder)・アーカイブ再生 キャプチャ映像とイベントの同期記録・再生 セッションの共有 キャプチャしたセッションをファイルとして共有 イベントビューアは、Logcatから取得したログをパースし、イベントをノード型のUIで時系列に表示します。UIはFirebase DebugViewのイベント表示を参考にしています。GAや自社ログなど送信先が異なるイベントも統一的に可視化でき、各ノードにはイベント名と送信先が表示されます。ノードをクリックすると、画面右のパネルにイベントのパラメータが表示されます。 DVRおよびアーカイブ再生は、イベントビューアとキャプチャ映像を時刻ベースで結びつける機能です。ノードをクリックすると、そのイベントが発火する直前からの操作映像を再生できます。これにより「どの操作がどのイベントを発火させたか」を視覚的に検証できます。 イベント発火時のエフェクト 映像再生時のイベント到達エフェクト 新しいイベントが追加されると青白いグロー・アニメーションで視覚的に強調されるため、リアルタイムにどのイベントが発火したかを把握できます。また、映像の再生位置がイベントのタイムスタンプに到達すると、該当ノードが赤いグロー・アニメーションでハイライトされます。 キャプチャしたセッションはエクスポート・インポート可能になっており、他の開発者の環境でもセッションを視覚的に確認できます。 処理フロー プラグインの動作をイベント発火からDVR再生までの流れで説明します。 1. ログの取得とパース 端末と接続すると、ADB経由でLogcatストリームの取得を開始します。取得したログは、ログフォーマットに応じたパーサが自動選択・適用されます。 パースの結果、各ログ行はタイムスタンプ(デバイス時刻)やイベント名、パラメータなどを持つ構造化されたイベントオブジェクトに変換されます。 2. イベントの保存とUI表示 パースされたイベントはインメモリのイベントストアに蓄積されます。UIはリアクティブなデータフローを通じてイベント追加を検知し、画面を更新します。 3. 画面キャプチャの取得 ログ収集と並行して画面キャプチャが行われます。映像ストリームをデコードし、各フレームにタイムスタンプを付与しバッファに保存します。 4. ノードクリックによるDVR再生 ユーザーがタイムライン上のイベントノードをクリックすると、そのイベントのタイムスタンプを録画開始からの経過時間(相対時刻)に変換し、一定時間だけ巻き戻した位置から映像の再生を開始します。これにより、「どの操作がこのイベントを発火させたか」を視覚的に確認できます。 イベントログの収集 イベントログを収集する方法として、以下の選択肢を検討しました。 方式 概要 採用判断 adb reverse経由 アプリからホストへ直接送信 不採用 Logcat経由 ADBでLogcatストリームを取得 採用 adb reverse を使用すると、アプリから直接ホスト(開発マシン)にデータを送信できます。この方式であれば、Logcatのパースが不要になり、構造化されたデータをそのまま受け取れるメリットがあります。しかし、この方式には以下の課題がありました。 アプリ側の改修が必要:ログ送信用のHTTPクライアントやソケット通信の実装が必要 既存のログ出力との二重管理:Logcatへの出力と並行して別の送信処理を実装することになる 一方、Logcat経由の方式には以下の利点があります。 アプリ側の改修が不要:既存のログ出力をそのまま利用できる 既存資産の活用:すでにログ出力の仕組みが整っているアプリであれば、追加実装なしで利用可能 アプリ側を改修せずにプラグイン単体で動作させる設計方針を優先し、Logcat経由の方式を採用しました。 イベントの統合・可視化 複数の送信先のイベントを統一的に扱うため、パーサレジストリパターンを採用しました。各ログフォーマットに対応するパーサは、共通のインタフェースを実装します。 interface LogParser { val id: String val displayName: String fun canParse(rawLine: String ): Boolean fun parse(rawLine: String , deviceSerial: String ? = null ): LogEvent? } パーサレジストリはログを受け取ると登録されたパーサを順番にチェックし、最初にマッチしたパーサでパースを行います。 class ParserRegistry { private val parsers = mutableListOf<LogParser>() private var defaultParser: LogParser = DefaultLogcatParser() init { // 各フォーマット用パーサを登録(登録順にマッチングされる) register(GoogleAnalyticsLogParser()) register(InternalBusinessLogParser()) } fun register(parser: LogParser) { parsers.add(parser) } fun detectParser(rawLine: String ): LogParser { return parsers.firstOrNull { it.canParse(rawLine) } ?: defaultParser } } detectParser は登録されたパーサを順に試行し、最初にマッチしたパーサを返します。どのパーサにもマッチしない場合は defaultParser (標準のLogcat形式パーサ)にフォールバックする設計です。 この設計により、新しいログフォーマットへの対応はパーサクラスを1つ追加・登録するだけで完了します。 画面キャプチャ 端末の画面をキャプチャする方法として、以下の選択肢を検討しました。 方式 概要 採用判断 MediaProjection API Android APIによる画面キャプチャ 不採用 scrcpy USB/TCP経由のミラーリングツール 採用 MediaProjection API はAndroidアプリ内から画面をキャプチャできるAPIです。しかし、この方式を採用するとデバッグ対象のアプリ自体に手を加えるか、別途キャプチャ用アプリを用意する必要があります。 scrcpy はオープンソースのミラーリングツールで、以下の点で要件に合致しました。 リアルタイム性:ミラーリングと同程度の低遅延で画面を取得できる アプリ側の改修が不要:ADB経由で動作するため、デバッグ対象アプリへの変更が不要 組み込みやすさ:プロトコルが公開されており、プラグインへの統合が可能 上記の特性を考慮し、scrcpyを採用しました。 本プラグインではscrcpyのH.264ストリームを利用しています。受信したストリームをデコードしてRAW RGB形式のフレームを出力し、リアルタイムのミラーリング表示とDVR用の録画バッファの両方に供給しています。 DVR:イベントとの同期 本プラグインの核となる機能がDVRです。端末のキャプチャ映像とイベントを時刻ベースで同期し、後から任意の時点の映像を再生できます。 イベントと映像の同期を実現するうえで、2つの課題を解決する必要があります。 1つ目は、イベントと映像のタイムスタンプの基準を揃えることです。イベントのタイムスタンプはデバイスの絶対時刻で記録されますが、映像フレームのタイムスタンプはホスト側で録画開始からの経過時間(相対時刻)として付与されます。イベントのタイムスタンプも同じ相対時刻へ変換するために、録画開始時のデバイス時刻を記録しておき、イベントのタイムスタンプからこの値を引くことで録画開始からの経過時間を算出しています。 2つ目は、映像パイプラインの遅延です。scrcpyでキャプチャされたフレームは、デバイス上でのエンコード → ホストへの転送 → デコードという過程を経てホストに到達します。フレームのタイムスタンプはホスト受信時に付与するため、実際のキャプチャ時刻よりパイプライン遅延の分だけ遅れた値になります。この遅延を補正するために、録画開始から最初のフレームが到着するまでの時間をもとにパイプライン遅延を推定し、各フレームのタイムスタンプからその分を差し引いています。 これらの補正により、DVR再生時にイベントと映像のタイミングがある程度正確に一致するようになります。 セッションのエクスポート・インポート DVR機能により「操作映像とイベントの同期再生」が可能になりましたが、これだけではキャプチャした本人のマシンでしか確認できません。イベントトラッキング関連の不具合が発生した際に、その状況を他のメンバーに正確に伝えるには、キャプチャしたセッションそのものを共有できる仕組みが必要でした。そこで、イベントとキャプチャ映像をまとめてエクスポート・インポートできる機能を実装しました。 エクスポートされるファイルは .edb という拡張子を持つZIPアーカイブです。内部にはJSON形式のセッションメタデータとシリアライズされたイベント、映像フレーム群が格納されます。 セッションメタデータにはアーカイブ再生で必要な時刻同期パラメータ(録画開始時のデバイス時刻、映像パイプライン遅延の推定値など)を含めています。これにより、別のマシンでインポートした場合でも、元のキャプチャ環境と同じ精度でイベントと映像の同期再生が可能になります。 PRレビューでの活用 この機能により、以下のようなワークフローが可能になります。 開発者:イベント発火の再現手順を実行しながらキャプチャを行う 開発者:セッションを .edb ファイルとしてエクスポートし、PRやIssueに添付する レビュアー: .edb ファイルをインポートし、アーカイブ再生で操作映像とイベントの同期再生を確認する 従来は「ある操作をしたときに特定のイベントが発火しています」とテキストで説明するしかありませんでした。エクスポート・インポート機能により、レビュアーは自分の環境で操作映像を再生しながらイベントの発火タイミングを直接確認できます。端末やアプリの準備も不要なため、レビューの負荷も軽減されます。 実装について 本プラグインの実装は、その大部分をAIとの協業で進めました。開発者が利用可能な技術の候補を挙げ、そこから先の技術選定はAIとのディスカッションを通じて行いました。例えば画面キャプチャの方式では、候補となる技術をそれぞれ試作し、要件に合わないとわかれば別の方式に切り替えるというサイクルを短期間で回せました。設計面でもAIにアイデアを出してもらい、開発者が将来の機能追加などを考慮して判断するという進め方です。IntelliJ Platform SDKの使い方やscrcpyプロトコルの解析など、ドキュメントを読み込んで実装に落とし込む作業はAIが得意とする領域でした。 効果 イベントがノード型UIで可視化されたことで、Logcatに出力されるテキストを目で追う手間がなくなり、イベントの検証効率が向上しました。 また、DVR・アーカイブ再生機能により、これまで困難だった「イベント発火タイミングの検証」が可能になりました。具体的には以下のことが行えます。 イベントノードをクリックするだけで、発火時の操作映像を再生できる 「画面遷移時に発火」「ボタンタップ時に発火」といった仕様の検証が視覚的に行える セッションの共有機能により、操作映像を見せながら「この操作でこのイベントが発火しています」と説明できる 同種イベントが連続発火するケースでも、どの操作がどのイベントに対応するかを明確に示せるようになりました。 今後の展望 現状では「アプリがログを出力した」ことは確認できますが、「実際にサーバへ送信された」ことは確認できません。実際の通信ログも取り込めるようになれば、エンドツーエンドでの検証が可能になります。 まとめ 本記事では、イベントトラッキングのデバッグ課題を解決するために開発したAndroid Studioプラグインを紹介しました。本プラグインは社内利用を前提としているため現時点で一般公開の予定はありませんが、イベントトラッキングのデバッグに課題を感じている方の参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.table-of-contents > li > ul > li > ul { display: none; } はじめに こんにちは。データシステム部・MA推薦ブロックの伊藤( @rabbit_x86 )です。私たちのチームでは、メール配信などのマーケティングオートメーション(MA)に関する推薦システムを開発・運用しています。 従来、ZOZOTOWNのMA施策における推薦システムでは、 開発リードタイムと推薦精度のトレードオフ が課題でした。この課題を解決するため、ユーザーとアイテムをベクトルで表現したEmbeddingとBigQuery Vector Searchを活用し、施策を横断して利用可能な 汎用推薦システム を開発しました。本システムにより、開発リードタイムを 約1/3 に短縮し、A/Bテストで 配信当たりのMA経由流入数・購入数の改善 を達成しました。 本記事では、このシステムの設計思想・アーキテクチャ・構築時の技術的な課題と工夫、そして実際の事例を紹介します。 目次 はじめに 目次 背景と課題 従来の推薦アプローチとそのトレードオフ 機械学習ベースの開発リードタイム ルールベースによる推薦の限界 システム要件 アプローチ 前提知識:EmbeddingとEmbedding基盤 汎用推薦システム 全体構成 1. セグメント抽出 2. Embedding取得 3. Vector Index作成 4. Vector Search 5. スコアブースト・フィルタリング 6. 評価・バリデーション 技術的な課題と工夫 Vector Indexの非同期構築への対処 Vector Searchのスロット消費問題 運用事例 開発リードタイムの短縮 推薦精度の向上 まとめ 今後の展望 最後に 背景と課題 従来の推薦アプローチとそのトレードオフ MA施策では、対象ユーザー(ユーザーセグメント)と対象アイテム(アイテムセグメント)を施策ごとに定義し、パーソナライズされた商品を提供しています。その推薦システムは、「ルールベース」と「機械学習ベース」の2つのアプローチで構築されています。ルールベースは閲覧したカテゴリの商品を推薦するなど、行動ログに基づくルールでスコアリングするアプローチです。機械学習ベースは行動ログを活用しつつ、モデルが学習した潜在的な嗜好をもとに推薦するアプローチです。 これらのアプローチには精度と開発リードタイムのトレードオフが存在し、ルールベースは 高速だが推薦精度が低く 、機械学習ベースは 精度が高い一方で開発リードタイムが長い という課題がありました。 ルールベース 機械学習ベース ロジック 閲覧履歴・お気に入りなどの行動ログに基づくヒューリスティクス 専用の推薦モデルが学習した潜在的な嗜好に基づくスコアリング 開発リードタイム 短い 長い 精度 低い 高い 機械学習ベースの開発リードタイム 機械学習ベースの推薦にはモデルを実装する必要があり、施策ごとに以下の一連の開発工程を繰り返します。 工程 所要期間 探索的データ分析 約2週間 モデルの設計・実装 約3週間 パイプラインの設計・実装 約2週間 実験・評価・チューニング 約3週間 この結果、 1施策あたり約10週間の開発リードタイム が必要となり、仮説検証のサイクルが遅くなっていました。 ルールベースによる推薦の限界 一方、ルールベースのロジックでは、閲覧履歴やお気に入りブランドなど、ユーザーの顕在的な嗜好に基づく推薦が中心です。たとえば、「ブランドAを閲覧したユーザーにブランドAの値下げ商品を推薦する」といったルールなどです。こうしたルールは設計が容易な反面、ユーザーが触れた商品のみを推薦し、ユーザーの潜在的な嗜好を考慮した推薦ができないという課題がありました。 システム要件 そこで、 高速なモデル構築と高い推薦精度を両立 する仕組みが必要でした。 要件をまとめると以下のとおりです。 要件 詳細 高速な推薦システム構築 推薦システムを短期間で構築できること 高い推薦精度 ユーザーの潜在的な嗜好を捉えた推薦ができること アプローチ 上記の要件を満たすため、社内のEmbedding基盤とBigQuery Vector Searchを活用した汎用推薦システムを開発しました。 前提知識:EmbeddingとEmbedding基盤 Embeddingとは、データを固定長のベクトルとして表現する手法です。社内のEmbedding基盤では、ユーザーの行動履歴をもとにTwo-Towerモデルを使い、ユーザーとアイテムの類似度が意味を持つように共通の次元数の埋め込み空間へそれぞれエンコードします。ベクトル間のコサイン類似度を計算することで、ユーザーの潜在的な嗜好に近いアイテムを特定できます。 Embedding基盤については、推薦基盤ブロックで執筆した以下の記事で詳しく紹介しています。 techblog.zozo.com 汎用推薦システム 本システムは1つのモデルで複数の施策に対応できる汎用的な仕組みです。セグメントを定義してEmbeddingを抽出し、BigQuery Vector Searchで類似度を計算することで、パーソナライズされた推薦結果を生成します。従来必要だった 特徴量作成やモデル学習が不要 になるため、開発リードタイムを短縮できます。 さらに、Embeddingを利用することで、ルールベースでは捉えられなかったユーザーの潜在的な嗜好を反映した 高い推薦精度 を実現します。施策の目的に応じて関連スコアの調整やフィルタリングなどの後処理も適用でき、細かなチューニングにも対応できます。 全体構成 本システムは、社内のMLパイプライン基盤であるVertex AI Pipelinesで実行されます。 パイプラインの主要ステップを以下の表にまとめます。 ステップ 処理内容 実行環境 1. セグメント抽出 ユーザーセグメント・アイテムセグメントをSQLで抽出 BigQuery 2. Embedding取得 セグメントに対応するEmbeddingをEmbedding基盤から取得 BigQuery 3. Vector Index作成 アイテムEmbeddingにTREE_AHインデックスを作成し、完了まで待機 BigQuery 4. Vector Search ユーザーEmbedding × アイテムEmbeddingの関連スコアを算出 BigQuery 5. スコアブースト・フィルタリング 関連スコアのブースト・ペナルティによるリランキング BigQuery 6. 評価・バリデーション 定量評価(Vertex AI Experiments)、ポリシーチェック BigQuery / Vertex AI 1. セグメント抽出 施策ごとに定義されたSQLクエリで、対象ユーザーと対象アイテムを抽出します。たとえば、「過去30日以内にアクティブなユーザー」や「特定カテゴリの新着アイテム」などです。このSQLを差し替えるだけで、さまざまな施策へ対応できる設計です。 2. Embedding取得 Embedding基盤から、抽出したユーザー・アイテムに対応するEmbeddingを取得します。 3. Vector Index作成 Vector Searchの高速化のため、アイテムEmbeddingテーブルへ CREATE VECTOR INDEX でインデックスを作成します。本システムでは大規模バッチ向けの TREE_AH (GoogleのScaNNアルゴリズムベース)を採用しています。Vector Indexの構築にまつわる課題と対処法は、後述の「技術的な課題と工夫」で説明します。 4. Vector Search BigQueryの VECTOR_SEARCH 関数を用いてユーザーEmbeddingとアイテムEmbeddingのコサイン類似度を計算し、ユーザーごとに関連スコアの高い上位N件のアイテムを取得します。 -- Vector Searchの実行例(簡略化) SELECT query.member_id, base.product_id, distance FROM VECTOR_SEARCH( ( SELECT * FROM candidate_embeddings), -- アイテムEmbedding ' embedding ' , ( SELECT * FROM query_embeddings), -- ユーザーEmbedding ' embedding ' , top_k => 100 , distance_type => ' COSINE ' ) 5. スコアブースト・フィルタリング Vector Searchで得られた関連スコアは、そのままでは施策の目的に最適化されていません。そこで、ブーストやペナルティによるリランキングとフィルタリングを行い、最終的な推薦結果を生成します。 生成された推薦結果はBigQueryのテーブルに保存され、MAの配信システムがこのテーブルを読み込むことで連携します。 6. 評価・バリデーション パイプラインの最終ステップとして、推薦結果の品質を評価・検証します。 評価種別 内容 記録先 定量評価 NDCG、Precision、Recall等の指標を記録 Vertex AI Experiments ポリシーチェック 推薦結果がセグメント条件を満たすか、1ユーザーあたりの推薦数が閾値以上かなどを検証 BigQuery 技術的な課題と工夫 Vector Indexの非同期構築への対処 BigQueryのVector Indexは 非同期で構築 されるため、実行直後にはインデックスが利用可能になりません。インデックスが未完成の状態でVector Searchを実行すると、ブルートフォース(全件スキャン)で計算するため、実行時間とスロット消費が膨大になります。 この問題に対処するのが、全体構成図における Index完了待ち のコンポーネントです。以下のクエリで INFORMATION_SCHEMA.VECTOR_INDEXES の coverage_percentage を定期的にポーリングし、インデックス構築の完了を確認しています。 SELECT table_name, coverage_percentage FROM `{project_id}.{dataset_id}`.INFORMATION_SCHEMA.VECTOR_INDEXES WHERE table_name IN UNNEST(@expected_tables) coverage_percentage が100%に達した後、Vector Searchステップへ進むことでブルートフォース計算を回避しています。 Vector Searchのスロット消費問題 もう1つの大きな課題は、 共有スロット の大量消費による他ジョブへの影響 でした。Vector Searchはユーザーとアイテムの全組み合わせに対してコサイン類似度を計算するため、1回の実行で大量のスロットを占有します。 社内ではBigQueryのジョブを共通の容量ベースプロジェクトで実行しています。そのため、Vector SearchがBigQueryの共有スロットを圧迫すると自チームの実行時間の増大やSLO超過だけでなく、他チームのクエリ遅延・タイムアウトを引き起こすリスクがありました。 また、今回のケースではBigQueryのスキャン量が少ないという特徴がありました。そこで、 オンデマンド課金用の専用プロジェクト を用意してVector Searchのみをそのプロジェクトで実行するようにしました。オンデマンド課金はスキャン量に対して課金されるため、コストを抑えつつ共有スロットへの影響を回避し、十分な計算リソースを確保できました。 運用事例 上記の汎用推薦システムを実際のMA施策に適用し、開発スピードと推薦精度の両面で効果を検証しました。 開発リードタイムの短縮 施策ごとに約10週間かかっていた推薦システムの構築が 約3週間 で完了し、 約1/3 に短縮されました。以下の表に、従来と汎用推薦システムの工程比較を示します。 工程 従来 汎用推薦システム 探索的データ分析 約2週間 不要(Embedding基盤を利用) モデルの設計・実装 約3週間 不要(Embedding基盤を利用) パイプラインの設計・実装 約2週間 約1週間(セグメント設定と既存パイプラインの利用) 実験・評価・チューニング 約3週間 約2週間(後処理によるチューニング) Embeddingを活用することで、探索的データ分析やモデルの設計・実装が不要になりました。また、パイプラインの設計・実装についても、セグメント抽出用のSQLを変更するだけで新しい施策に対応できるため、短期間で実装できるようになりました。さらに、実験・評価・チューニングではモデルのパラメータの調整が不要であり、過去の評価コンポーネントや実験の仕組みも再利用できるため、後処理のチューニングへ集中できるようになりました。 推薦精度の向上 従来のルールベースの推薦(Control)と汎用推薦システム(Treatment)のA/Bテストを実施し、以下の結果を得ました。 指標 有意差の有無 配信当たりのMA経由流入数 有意差ありの勝ち 配信当たりのMA経由購入数 有意差ありの勝ち 配信当たりのMA経由受注額 有意差なしの勝ち 主要KPIである配信当たりのMA経由流入数・購入数で統計的に有意な改善を確認したため、汎用推薦システムの本番導入に至りました。 まとめ 本記事では、ZOZOTOWNのMA施策向けに構築した汎用推薦システムについて紹介しました。 本システムは、EmbeddingとBigQuery Vector Searchを活用し、施策を横断して利用できる汎用的な推薦システムです。従来必要だった特徴量作成やモデル学習が不要になることで開発スピードを向上させつつ、Embeddingによりユーザーの潜在的な嗜好を反映した高い推薦精度を実現しています。 実際のMA施策への適用では、開発リードタイムを約10週間から約3週間に短縮しました。さらに、A/Bテストでは配信当たりのMA経由流入数・購入数の改善を確認し、本番導入に至っています。 今後の展望 今後は以下の取り組みを予定しています。 Rerankerの導入 : 現在のスコアブースト・フィルタリングはルールベースで煩雑なため、機械学習ベースのRerankerを導入し、MA施策に最適化されたチューニングを実現する セグメント設定の効率化 : セグメント定義をviewなどで共通管理し、パイプラインごとの再実装をなくす 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.entry-content ul > li > ul { display: none; } はじめに こんにちは。グローバルプロダクト開発本部SREブロックの纐纈です。 弊チームでは、Kubernetes上で動作する4つのサービス(ZOZOMAT、ZOZOGLASS、ZOZOMETRY、お試しメイク)のリリースを自動化しています。これまでにArgo CDによるGitOpsやArgo Rolloutsによるカナリアリリースを導入してきました。 techblog.zozo.com techblog.zozo.com リリースパイプラインの全体像については以下の記事で紹介しています。 techblog.zozo.com 本記事では、このリリースパイプラインのトリガー方式を見直した取り組みについて紹介します。改善にあたり、Argo EventsとArgo Workflowsを活用しました。Argo Eventsはイベント駆動型の自動化フレームワークで、EventSourceで様々なイベントを受信しSensorで後続処理をトリガーできます。Argo WorkflowsはKubernetes上でDAG形式のワークフローを実行するエンジンです。 argoproj.github.io argoproj.github.io 目次 はじめに 目次 リリースパイプラインの全体像 トリガー方式の変遷と課題 Phase 1: Argo CD PostSync Hook 課題: 不要なトリガーの発生 Phase 2: Argo EventsによるRollout監視 課題: HPAスケーリングによるリリースイベントの大量発生 Webhook EventSourceへの改善 Webhook方式の選定理由 改善後の流れ 実装の詳細 Argo CD NotificationsからWebhookを送信する Webhook EventSourceの作成 Sensorの簡素化 release-gate ClusterWorkflowTemplateの導入 release-gateの処理フロー 出力パラメータ ClusterWorkflowTemplateによるテンプレート共通化 パイプラインDAGの全体構成 スクリプトの簡素化 導入効果 パイプラインの可視化 不要なトリガーとイベントフラッディングの解消 Sensorの大幅な簡素化 マルチサービスへの横展開の容易さ まとめ おわりに リリースパイプラインの全体像 まず現在運用しているリリースパイプラインの全体像を説明します。 弊チームでは、アプリケーションコードを管理するサーバーリポジトリと、Kubernetesマニフェストを管理するKubernetesリポジトリを分離しています。サーバーリポジトリにPRがマージされると、GitHub Actionsがコンテナイメージをビルドし、ECRにプッシュします。Argo CD Image Updaterが新しいイメージを検知すると、KubernetesリポジトリにPRを自動作成します。イメージ更新PRがマージされるとArgo CDがステージング環境にデプロイし、リリースパイプラインが起動します。 リリースパイプラインでは、負荷試験、リリース用PRの作成、自動マージを行い、最終的に本番環境へデプロイします。 この「Argo CD Sync → リリースパイプライン」のトリガー方式が、今回の改善対象です。 トリガー方式の変遷と課題 これまでトリガー方式を2度見直してきました。以降では、各方式で明らかになった課題と改善の経緯を説明します。 Phase 1: Argo CD PostSync Hook 最初は、Argo CDのPostSync Hookを使用していました。Argo CD Syncが完了すると、PostSync Hookとして定義されたKubernetes Jobが自動的に起動する仕組みです。Sync Wavesを活用してJobの実行順序を制御していました。 argo-cd.readthedocs.io ArgoCD Sync完了 → PostSync Hook(Kubernetes Job) → 負荷試験 → リリースPR作成 → ... 課題: 不要なトリガーの発生 PostSync HookはすべてのArgo CD Sync完了時にトリガーされます。そのため、意図しないタイミングでパイプラインを起動する問題がありました。例えばConfigMapやSecretの変更時にも負荷試験が実行され、開発者の待ち時間を長くしていました。 Phase 2: Argo EventsによるRollout監視 PostSync Hookの課題を受けて、Argo Eventsを使ったRollout監視方式に移行しました。弊チームではArgo Rolloutsを利用しており、DeploymentではなくRolloutオブジェクトでPodを管理しています。RolloutはDeploymentを拡張したカスタムリソースで、カナリアリリースなどの高度なデプロイ戦略をサポートします。 この方式では、RolloutオブジェクトのステータスをKubernetes API Watchで直接監視します。イメージ更新によるロールアウト完了時のみパイプラインをトリガーする方式です。 ArgoCD Sync完了 → EventSource(Rollout監視) → Sensor → Workflow実行 また、この移行と同時にKubernetes JobからArgo Workflowsへ切り替えました。Sync Wavesによる順序制御では、各Jobの実行状況を把握しにくいという課題がありました。Argo Workflowsを採用することで、DAGによる柔軟な依存関係の定義やUIでの実行状況の可視化が可能になりました。 Sensorでは複雑なフィルタリングを行っていました。 updatedReplicas 、 replicas 、 availableReplicas を比較する式フィルタと、 NewReplicaSetAvailable を確認するLuaスクリプトの組み合わせです。 filters : data : - path : body.metadata.namespace type : string value : [ "${service}" ] - path : body.metadata.name type : string value : [ "api-server-rollout" ] exprs : - expr : updatedReplicas == replicas && updatedReplicas == availableReplicas && replicas > 0 fields : - name : updatedReplicas path : body.status.updatedReplicas - name : replicas path : body.status.replicas - name : availableReplicas path : body.status.availableReplicas script : |- local conditions = event.body.status.conditions if conditions == nil then return false end for i, cond in ipairs(conditions) do if cond.type == "Progressing" and cond.reason == "NewReplicaSetAvailable" then return true end end return false ConfigMap変更時の不要なトリガーは解消されましたが、別の課題が浮上しました。 課題: HPAスケーリングによるリリースイベントの大量発生 上記のSensorフィルタは、新しいバージョンのロールアウト完了を検知する想定で設計していました。 NewReplicaSetAvailable 条件とレプリカ数の一致で、新バージョンへの切り替え完了を判定しています。 しかし、HPAによるスケーリングでもRolloutオブジェクトの replicas や availableReplicas が更新されます。スケーリング完了時にレプリカ数が一致するため、フィルタ条件を満たしてしまいます。つまり、このフィルタでは「新バージョンのロールアウト完了」と「HPAスケーリング完了」を区別できませんでした。 その結果、この問題はステージング環境で障害として顕在化しました。HPAスケーリングを起点としたイベントフラッディングにより、負荷試験が3並列で実行され、以下の問題が発生しました。 DB CPU使用率が100%に到達 api-serverがレスポンス不能に CrashLoopBackOffが発生 この障害をきっかけに、トリガー方式を根本的に見直す必要があると判断しました。 Webhook EventSourceへの改善 Webhook方式の選定理由 新しいトリガー方式として、Argo CD NotificationsからWebhookでArgo Events EventSourceに通知する方式を採用しました。 ArgoCD Sync完了 → ArgoCD Notifications → Webhook → EventSource → Sensor → Workflow実行 この方式を選んだ理由は4つあります。 1. コミット単位でトリガーを制御できる Argo CD Notificationsの oncePer: revision により、同一コミットSHAに対して厳密に1回だけ発火します。Rollout監視方式のようにHPAスケーリングやPod再起動でイベントが大量発生する問題は構造上発生しません。 2. トリガーソースを識別できる Webhookペイロードにrevision(コミットSHA)が含まれるため、GitHub APIでそのコミットの変更内容を特定できます。PostSync HookやRollout監視方式ではこの情報が得られませんでした。 3. Sensorの大幅な簡素化 Rollout監視方式では、namespace、ステータス式、Luaスクリプトの3層フィルタリングが必要でした。一方、Webhook方式では body.app の単純な文字列マッチのみで済みます。 4. 既存基盤の活用 Slack通知で既に使用しているArgo CD Notificationsに、Webhookサービスを追加するだけで導入できます。そのため、新たなコンポーネントの導入が不要でした。 改善後の流れ 改善後の全体像は以下の通りです。 実装の詳細 Argo CD NotificationsからWebhookを送信する Argo CD NotificationsのConfigMapにWebhookサービスとトリガーを追加します。 # Webhookの定義 service.webhook.argo-events-sync : | url : http://argocd-sync-webhook-eventsource-svc.argo-events.svc.cluster.local:12000/sync headers : - name : Content-Type value : application/json # テンプレート: ペイロードの定義 template.app-sync-webhook : | webhook : argo-events-sync : method : POST body : | { "app" : "{{.app.metadata.name}}" , "revision" : "{{.app.status.operationState.syncResult.revision}}" } # トリガー: Sync成功時、同一revisionに対して1回のみ発火 trigger.on-sync-succeeded-webhook : | - when : app.status.operationState != nil and app.status.operationState.phase in [ 'Succeeded' ] oncePer : app.status.operationState.syncResult.revision send : [ app-sync-webhook ] oncePer がポイントです。同一のコミットSHAに対して一度しかWebhookが送信されないため、リリースパイプラインの重複実行を構造的に防止できます。 argo-cd.readthedocs.io 各環境のサブスクリプション設定で、対象のArgo CD Applicationにこのトリガーを紐付けます。 # サブスクリプション設定 defaultTriggers : | - recipients : - argo-events-sync triggers : - on-sync-succeeded-webhook Webhook EventSourceの作成 Argo CD NotificationsからのWebhookを受信するEventSourceを作成します。 apiVersion : argoproj.io/v1alpha1 kind : EventSource metadata : name : argocd-sync-webhook namespace : argo-events spec : service : ports : - port : 12000 targetPort : 12000 webhook : argocd-app-sync : port : "12000" endpoint : /sync method : POST 1つのEventSourceで全サービスのWebhookを受信します。サービスごとの振り分けはSensor側で行います。 Sensorの簡素化 Rollout監視方式の時代に必要だった複雑なフィルタリングが、 body.app の文字列マッチのみに簡素化されました。 apiVersion : argoproj.io/v1alpha1 kind : Sensor metadata : name : ${service}-release-pipeline namespace : argo-events spec : dependencies : - name : argocd-sync-completed eventSourceName : argocd-sync-webhook eventName : argocd-app-sync filters : data : - path : body.app type : string value : - ${service}-server-kubernetes triggers : - template : name : trigger-release-pipeline conditions : argocd-sync-completed k8s : operation : create source : resource : apiVersion : argoproj.io/v1alpha1 kind : Workflow metadata : generateName : ${service}-release-pipeline- namespace : ${service} spec : synchronization : mutexes : - name : ${service}-release-pipeline workflowTemplateRef : name : ${service}-release-pipeline arguments : parameters : - name : revision value : "" parameters : - src : dependencyName : argocd-sync-completed dataKey : body.revision dest : spec.arguments.parameters.0.value WebhookペイロードからコミットSHAを抽出し、Workflowのパラメータとして渡します。さらに synchronization.mutexes を設定し、同一サービスのパイプラインが並列実行されることを防止しています。 argo-workflows.readthedocs.io release-gate ClusterWorkflowTemplateの導入 改善前のリリースパイプラインでは、トリガーPRの特定や負荷試験の判定ロジックが各スクリプトに散在していました。これを release-gate ClusterWorkflowTemplateに集約し、パイプライン制御を整理しました。 release-gateの処理フロー release-gateは4つのステップで構成されています。 Step 1: リリース差分チェック GitHub APIで release...main ブランチを比較し、リリースすべき変更があるか確認します。差分がない場合はパイプラインを終了します。 Step 2: トリガーPR特定 mainブランチの最新マージコミットメッセージからPR番号を抽出します。「Merge pull request #42」のようなメッセージからPR番号を取得します。抽出に失敗した場合は、Deploymentのイメージタグ(コミットSHA)でPRを検索するフォールバックも用意しています。 Step 3: 負荷試験の判定 トリガーPRのラベルを確認します。 skip_load_test ラベルが付与されている場合は負荷試験をスキップし、それ以外は負荷試験を実行します。Image Updater PRは自動生成でラベルが付かないため、通常のイメージ更新では負荷試験が常に実行されます。 Step 4: auto-merge判定 リリースPR(main → release)に人間のコミットがあるか確認します。botコミット(argocd-image-updater、GitHub Actionなど)のみの場合は自動マージを有効にし、人間のコミットがある場合は無効にします。 出力パラメータ release-gateの出力は後続のステップで条件分岐に使用されます。 パラメータ 説明 run_load_test 負荷試験の実行判定(true/false) run_release リリースPR作成判定(true/false) run_auto_merge auto-merge判定(true/false) trigger_pr_number トリガーPR番号 deployment_image_tag 現在のDeploymentイメージタグ ClusterWorkflowTemplateによるテンプレート共通化 改善前は、 create-release-pr や auto-merge のJobを各サービスのリポジトリにそれぞれ定義していました。4サービス分のマニフェストを個別に管理する必要があり、メンテナンスコストが高くなっていました。 ClusterWorkflowTemplateを利用することで、テンプレートをインフラリポジトリで一元管理できるようになりました。各サービスはDAGから clusterScope: true で参照し、サービス固有の値( git-repository など)はパラメータで渡します。 # 各サービスのDAGからの参照例 - name : create-release-pr templateRef : name : create-release-pr template : create-release-pr clusterScope : true arguments : parameters : - name : git-repository value : "${service}-server-kubernetes" - name : trigger-pr-number value : "{{tasks.gate.outputs.parameters.trigger_pr_number}}" 新たに追加した共通テンプレートを含め、ClusterWorkflowTemplateの全体像は以下の通りです。 ClusterWorkflowTemplate 役割 release-gate リリース判定(差分チェック、トリガーPR特定、負荷試験の要否/auto-merge判定) create-release-pr リリースPRの自動作成 auto-merge PRの自動マージ load-test-pr-comment 負荷試験結果をリリースPRにコメント release-pipeline-summary パイプライン全体の結果をSlackに通知 パイプラインDAGの全体構成 最終的なパイプラインのDAG構成です。 spec : entrypoint : release-pipeline arguments : parameters : - name : revision value : "" templates : - name : release-pipeline dag : tasks : - name : release-gate templateRef : name : release-gate template : release-gate clusterScope : true arguments : parameters : - name : git-repository value : "${service}-server-kubernetes" - name : revision value : "{{workflow.parameters.revision}}" - name : deployment-name value : "${service}-server-deployment" - name : deployment-namespace value : "${service}" - name : load-test dependencies : [ release-gate ] when : "{{tasks.release-gate.outputs.parameters.run_load_test}} == true" # ... - name : create-release-pr templateRef : name : create-release-pr template : create-release-pr clusterScope : true dependencies : [ load-test ] when : "{{tasks.release-gate.outputs.parameters.run_release}} == true" # ... - name : load-test-pr-comment templateRef : name : load-test-pr-comment template : load-test-comment clusterScope : true dependencies : [ create-release-pr, load-test ] when : "{{tasks.release-gate.outputs.parameters.run_load_test}} == true && {{tasks.release-gate.outputs.parameters.run_release}} == true" # ... - name : auto-merge templateRef : name : auto-merge template : auto-merge clusterScope : true dependencies : [ create-release-pr, load-test-pr-comment ] when : "{{tasks.release-gate.outputs.parameters.run_auto_merge}} == true" # ... - name : release-pipeline-summary templateRef : name : release-pipeline-summary template : summary clusterScope : true dependencies : [ release-gate, load-test, create-release-pr, auto-merge ] when : "{{tasks.release-gate.outputs.parameters.run_release}} == true" # ... dependencies と when を組み合わせることで、各ステップの実行条件を柔軟に制御しています。 dependencies はタスクの依存関係(実行順序)を定義します。一方、 when はrelease-gateの出力パラメータに基づいてタスクの実行可否を判定します。 例えば create-release-pr は load-test に依存しつつ、 run_release == true の場合にのみ実行されます。負荷試験がスキップ(Omitted)された場合も依存関係は満たされるため、後続のタスクは実行されます。 スクリプトの簡素化 release-gateにロジックを集約したことで、 create-release-pr と auto-merge のスクリプトを大幅に簡素化できました。 削除した機能 移動先 トリガーPR特定ロジック release-gate 人間コミットチェック release-gate Slack通知 release-pipeline-summary 両スクリプトは TRIGGER_PR_NUMBER 環境変数をrelease-gateから受け取るだけのシンプルな実装になりました。 導入効果 パイプラインの可視化 以前は、PostSync HookとKubernetes Jobを使用していたため、パイプラインの進行状況を把握しにくい状態でした。Argo Workflowsに移行したことで、DAGの実行状況をArgo Workflows UIで視覚的に確認できるようになりました。 さらに、release-pipeline-summaryによるSlack通知でパイプライン全体の実行結果を一目で把握できます。負荷試験結果はリリースPRにもコメントされるため、手動マージ時の判断も容易です。 不要なトリガーとイベントフラッディングの解消 Phase 1の課題であった不要なトリガーについては、release-gateのリリース差分チェックで解消しました。Webhook方式ではすべてのSync成功時にパイプラインが起動しますが、release-gateが差分を判定し、リリースすべき変更がなければ早期終了します。 Phase 2の課題であったイベントフラッディングについては、 oncePer: revision により解消しました。HPAスケーリングやPod再起動に起因するイベントの大量発生を防げるようになりました。 Sensorの大幅な簡素化 3層フィルタリング(namespace + ステータス式 + Luaスクリプト)から、 body.app の文字列マッチのみに簡素化されました。これにより、Sensorの定義が大幅にシンプルになり、メンテナンス性が向上しました。 マルチサービスへの横展開の容易さ ClusterWorkflowTemplateとして共通ロジックを一元管理しているため、新しいサービスへの展開が容易です。Sensorの追加とDAGの定義、負荷試験用のWorkflowTemplateの作成だけで完了します。 まとめ リリースパイプラインのトリガー方式は、PostSync Hook → Rollout監視 → Webhook EventSourceと変遷してきました。今回Argo WorkflowsとArgo Eventsを活用し、Webhook EventSourceへの移行を実現しています。 各方式の課題を段階的に解消できたのは、Argo Eventsの柔軟なイベントソースのおかげです。特に、Argo CD Notificationsの oncePer 機能とWebhook EventSourceの組み合わせは、イベント駆動型パイプラインの制御に有効でした。 また、今回の改善を通じて、複数サービスで共通するパイプラインの変更を安全に進める方法を見直すきっかけにもなりました。改善の過程でパイプラインが検証通りに動作せず、リリースが停止するトラブルも発生しました。リリースパイプラインの変更は4サービスに同時に影響するため、慎重なアプローチが求められます。今後の改善においては、変更によるリスクを最小化する方法も検討していきたいと考えています。 本記事がArgo EventsやArgo Workflowsを活用したリリースパイプラインの構築を検討している方の参考になれば幸いです。 おわりに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター
.table-of-contents > li > ul > li > ul { display: none; } はじめに こんにちは、データサイエンス部コーディネートサイエンスブロックの 大川 です。私たちは、WEARにおける「似合う」をユーザーに届けるため、LLMやマルチモーダルAIを活用してコーディネートの特徴抽出や似合うに関する独自の判定処理のR&Dを行っています。 LLMが台頭して以降、LLMに構造化出力を要求するタスクは増えています。数百件のテストでは問題なく動いていたシステムが、本番運用で10万件・100万件規模の推論を回すと思わぬエラーに直面することがあります。 本記事では、ファッション画像から柄の特徴を抽出するタスクを本番運用する過程で直面した課題と、その解決策を共有します。具体的には、エラー内容をプロンプトにフィードバックしてリトライする手法により、87%のエラー削減を達成しました。この手法はLLMの構造化出力タスク全般に応用可能です。 目次 はじめに 目次 サマリー 前提条件 発生した問題 問題1: 不正な出力(68件) 問題2: トークンが繰り返される出力(9件) 原因分析 原因1: 不正な出力 原因2: トークンが繰り返される出力 解決策 解決策1: バリデーション&リトライの追加 解決策2: プロンプトへのエラーフィードバック追加 ValueErrorの場合 JSONDecodeErrorの場合 GoogleAPIError(APIエラー)の場合 結果 エラー削減効果 性能への影響 まとめ おわりに サマリー LLMの構造化出力で発生する「不正な値の出力」と「トークン繰り返し」問題に対し、バリデーション+エラーフィードバックプロンプトで87%のエラー削減を達成(68件→9件) エラー内容だけでなく、リトライ回数とtemperatureもフィードバックに含めると効果が大きい(21件→9件) F1スコアへの影響は約0.02の低下にとどまり、安心して導入できる 前提条件 前提条件を揃えるため、タスク内容とLLMの仕様を共有します。 項目 内容 タスク ファッション画像から複数の柄の特徴を抽出するタスク(マルチラベル分類) 推論規模 約10万件の全身コーディネート画像 使用モデル gemini-2.5-flash-lite 出力形式 JSON(許可された値のリストから選択) リトライ 最大3回 構造化出力では、柄の種類( pattern_type )などの特徴に対して、事前定義された値のみを出力するようLLMに指示しています。例えば、ニュアンス柄( nuance_pattern )やグレンチェック柄( glen_check_pattern )などが定義済みの値です。この制約の実装にはGemini APIの response_schema パラメータを利用しています 1 。 ただし、 response_schema はJSONの 構文的な正しさ(型やフィールド名)は保証しますが、値の意味的な正しさは保証しません 。公式ドキュメントでも「最終的な出力は、使用する前に必ずアプリケーションコードで検証してください」と明記されています 2 。この仕様上の限界が、後述する「不正な出力」問題の背景にあります。 LLMにより画像から抽出されたpattern_typeのマルチラベル分類例 発生した問題 約10万件の画像を推論したところ、以下の2つの問題が発生しました。 問題1: 不正な出力(68件) 定義外の値が出力されるケースです。例えば pattern_type に対して、 logo 、 patchwork_pattern 、 graphic_pattern のような、あらかじめ指定したリストに含まれない値が返ってきました。 不正な値 件数 logo 26 patchwork_pattern 17 graphic_pattern 14 camouflage_pattern 6 その他 5 これらの不正な値は、いずれもファッション領域では実在する概念です。LLMが持つ一般知識から「もっともらしい値」を生成してしまったと考えられます。 問題2: トークンが繰り返される出力(9件) 同じトークンが無限に繰り返され、JSONパースに失敗するケースです。Gemini API公式ドキュメントでも「トークンの繰り返しに関する問題」として同様の事象が取り上げられています 3 。 実際のトークン繰り返しエラーのログ(Langfuse) この問題が厄介なのは、JSONパースエラーでリトライしても同様の事象が繰り返される点です。その結果、以下の影響が生じます。 出力が得られない : 3回リトライしても正常な結果を取得できない レイテンシーの悪化 : 1件あたり10分程度かかるケースも発生 コストの増加 : 無駄なトークンを大量に消費する この問題をスケールで考えると深刻さが分かります。10万件中9件の発生率(0.009%)は一見小さく見えますが、本番の全件推論で400万件を処理する場合、約360件でこの問題が発生する計算です。1件あたり10分の遅延とすると、トークン繰り返し問題だけで 約60時間(2.5日分)の遅延 が発生します。 原因分析 原因1: 不正な出力 不正な出力の原因は、出力値のバリデーションとリトライの仕組みが不十分だったことです。前述のとおり、Geminiの response_schema はJSONの構文を制約するものであり、enum値の完全な制約までは保証しません。従来の実装ではこれを検知してリトライする機能がなく、不正な出力がそのまま通過していました。 原因2: トークンが繰り返される出力 この問題の背景には、 再現性とトークン繰り返しのトレードオフ があります。分類タスクでは temperature=0 で出力を安定させたい一方、それがトークン繰り返し問題を引き起こします。実際、Gemini API公式のトラブルシューティングガイドでも、temperatureを低く設定すると「ループや性能劣化などの予期しない動作を引き起こす可能性がある」と警告されています 4 。 技術的には、 temperature=0 の貪欲デコーディングにより、特定の入力に対して同じ出力トークンが延々と選ばれ、適切にEOSトークンで終了できない状態に陥ります。この問題に対処するため、リトライ時にtemperatureを0.1ずつ増やす施策を導入していましたが、それだけでは完全には回避できませんでした。 解決策 2つのアプローチを組み合わせて改善を図りました。 解決策1: バリデーション&リトライの追加 不正な値が出力された際に、許可された値のリストと照合してバリデーションし、失敗時はリトライする機能を追加しました。 解決策2: プロンプトへのエラーフィードバック追加 単にリトライするのではなく、前回のエラー内容をプロンプトの末尾にフィードバックして再試行させることでLLMの注意を問題点に向けさせました。このとき、エラーの種類によってフィードバック内容を変えるように設計しました。 ValueErrorの場合 ValueErrorの場合、問題1(不正な出力)の発生が予想されます。どの値が不正で、どの値が許可されているかをエラーメッセージとしてそのままフィードバックするようにしました。 前回の推論で以下のようなエラーが発生しましたので注意してください。 ** 前回のConfig・エラー情報 ** - 試行: {N} 回目 - temperature: {current_temp} - 前回エラー: ValueError: invalid result for feature=pattern_type: 'logo' (allowed: ['ethnic_pattern', 'geometric_pattern', ...]) JSONDecodeErrorの場合 JSONDecodeErrorの場合、トークンが繰り返されている可能性が高いと判断し、通常のプロンプトの末尾に以下のフィードバックを追加しました。この問題は公式ドキュメントでも言及されており、「同じことを繰り返さないでください」という指示を追記することが推奨されています 5 。 前回の推論で以下のようなエラーが発生しましたので注意してください。 ** 前回の Config・エラー情報 ** - 試行: {N} 回目 - temperature: {current_temp} - 前回エラー: JSONDecodeError: ... 無限にトークンが繰り返される問題が発生している可能性があります。**同じことを繰り返さないでください。** GoogleAPIError(APIエラー)の場合 GoogleAPIError(APIエラー)の場合、レート制限やネットワークエラーが主な原因となるため、プロンプトを改善しても解決しません。この場合はフィードバックを追加せず、指数バックオフによるリトライのみとしました。 結果 エラー削減効果 解決策の効果を検証するため、不正な出力を起こした68件を評価データとして用い、施策前後での改善度合いを比較しました。なお、トークンが繰り返される問題については、エラーの再現ができなかったため今回は評価データから除外しています。 3つの条件を用意して比較実験を行いました。 解決策1: バリデーションのみを追加 解決策2-1: バリデーションとエラーフィードバック(エラー内容のみ) 前回の推論で以下のようなエラーが発生しましたので注意してください。 - 前回エラー: ValueError: invalid result for feature=pattern_type: 'logo' (allowed: ['ethnic_pattern', 'geometric_pattern', ...]) 解決策2-2: バリデーションとエラーフィードバック(エラー内容 + リトライ数 + temperature) 前回の推論で以下のようなエラーが発生しましたので注意してください。 ** 前回のConfig・エラー情報 ** - 試行: {N} 回目 - temperature: {current_temp} - 前回エラー: ValueError: invalid result for feature=pattern_type: 'logo' (allowed: ['ethnic_pattern', 'geometric_pattern', ...]) 施策 バリデーションの有無 エラーFBの有無 エラー件数 削減率 ベースライン ✗ ✗ 68件 — 解決策1 ✓ ✗ 40件 41% 解決策2-1 ✓ ✓ 21件 69% 解決策2-2 ✓ ✓ 9件 87% 実験結果として、 87%のエラー削減 (68件 → 9件)を達成しました。 重要な発見として、 エラー内容だけでなく、リトライ数とtemperatureも付与したほうが効果的である ことを確認しました(解決策2-1の21件→解決策2-2の9件)。これらの情報を付与することで、LLMが「何回目の試行で、どのような生成条件なのか」を把握でき、前回と異なる出力を生成しやすくなったと推察されます。 トークンが繰り返される問題についても、定量的な評価には至っていないものの、定性的には出現頻度の低下と出力の安定化を確認しています。 性能への影響 エラーフィードバックを追加することで性能への悪影響がないか検証しました。柄の評価データセットを用意し、エラーFBの有無で3回ずつ実行した平均値を比較しました。リトライ時にtemperatureを0.1ずつ増やす運用を想定し、temperature 0.0〜0.2の範囲で検証しています。 モデル temperature エラーFBの有無 F1スコア gemini-2.5-flash-lite 0.0 ✗ 0.8417 gemini-2.5-flash-lite 0.0 ✓ 0.8208 gemini-2.5-flash-lite 0.1 ✗ 0.8434 gemini-2.5-flash-lite 0.1 ✓ 0.8208 gemini-2.5-flash-lite 0.2 ✗ 0.8425 gemini-2.5-flash-lite 0.2 ✓ 0.8217 性能への大きな影響はないことを確認しました 。数値上ではF1スコアに約0.02の低下が見られますが、エラーフィードバックが適用されるのはバリデーション失敗時のリトライのみです。正常に出力された大多数のケースではフィードバックが付与されないため、システム全体への影響は軽微です。 まとめ 本記事では、LLMの構造化出力で発生するエラーを87%削減した手法を紹介しました。 本記事の貢献は以下のとおりです。 バリデーション+エラーフィードバック をプロンプトに含めることでエラー件数を87%削減できる エラー内容だけでなく、 リトライ数とtemperatureも付与すると効果が高い フィードバックを追加してもF1スコアへの大きな悪影響はなく、安心して導入できる この手法はGeminiに限らず、 LLMの構造化出力タスク全般 に応用可能 LLMの構造化出力や、Gemini APIの出力の安定化(トークン繰り返し問題の回避)に悩むエンジニアの方々にとって、本手法が何らかのヒントになれば幸いです。 おわりに ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! corp.zozo.com Generative AI on Vertex AI - 構造化出力 ↩ Gemini API - 構造化出力 ↩ トラブルシューティング ガイド - トークンの繰り返しに関する問題 ↩ 朗読に関する問題 ↩ トークンの繰り返しに関する問題 ↩
アバター
.table-of-contents > li > ul > li > ul { display: none; } はじめに こんにちは、検索基盤部の倉澤です。ZOZOTOWNの検索機能のバックエンドの開発を担当しています。検索基盤部の一部システムではGoを採用しています。 2026年2月21日(土)にGo Conference mini in Sendai 2026が開催されました。本記事では、会場の様子や個人的に印象に残ったセッション・LTについて紹介します。また、私もLT枠で登壇したため当日話しきれなかった内容もあわせて紹介します。 目次 はじめに 目次 Go Conference mini in Sendai 2026とは 会場の様子 セッション AI時代のGo開発2026 爆速開発のためのガードレール 個人的に気になった点 Go パッケージのサプライチェーン攻撃を防ぐ CI を作ってみた 個人的に気になった点 Go 1.26 で生まれ変わった go fix をプロダクト開発の運用に乗せる 個人的に気になった点 encoding/json/v2のUnmarshalはこう変わった ~内部実装で見る設計改善~ このテーマを選んだきっかけ さいごに Go Conference mini in Sendai 2026とは Go Conferenceは、プログラミング言語Goに関するカンファレンスです。今回は「東北から広がるGoコミュニティ」というテーマで仙台にて4年ぶりに開催されました。18セッション(20分)と12のLT(5分)によって構成され、Goに関するさまざまなテーマについて発表されました。参加者は117人と大盛況のうちに終わりました。 sendaigo.jp 会場の様子 会場は仙台市青葉区にあるアーバンネットビル仙台中央 カンファレンスルームでした。ワンフロアにスポンサーブースと2部屋のセッションルームがあり、同時に発表が行われました。 オープニングの様子 スポンサーブースでは、参加者向けのさまざまなコンテンツが用意されていました。 株式会社UPSIDERさんのブース 株式会社UPSIDERさんのブースでは、「Goの挑戦Goっそり教えて!」をテーマに意見が募られていました。「TinyGoを使って何かを作ってみたい」等の声があり、TinyGoへの関心の高さがうかがえました。 株式会社SODAさんのブース 株式会社SODAさんのブースでは、「あなたのやらかしエピソードや懺悔したいことを教えてください」をテーマに意見が募られていました。生成AIが書いたコードによってやらかしたエピソードが昨今の開発事情を表していて面白いなと思いました。また、SODAさんのブースではGopherの16タイプに分ける診断を実施しておりました。 snkrdunk.github.io 私は、「数学的な賢者」でした! Gopherの16タイプ診断 会場では参加者全員にステッカーなどのノベルティが配布されており、どれもとても可愛らしいデザインでした。 ノベルティのステッカー また、ネームタグの裏側には「すぐに使える仙台弁」が記載されており、参加者同士の会話のきっかけになっていました。仙台開催ならではの遊び心が感じられる演出でした。 ネームタグ さらに登壇者にはTシャツが配布され、登壇の良い記念になりました。運営の皆さまのお心遣いに感謝です。 登壇者用のTシャツ セッション AI時代のGo開発2026 爆速開発のためのガードレール www.docswell.com こちらのセッションでは、生成AIにおける開発の「速さ」と「治安(コード秩序やルール)」をいかに両立させるのかについて紹介されています。 課題 生成AIの発達・普及により実装速度が飛躍的に向上した一方で、アーキテクチャのルール違反などコードの治安が悪化しやすくなっている。 対策 Rules/Skillsのような非決定的な制約(ソフト制約)だけに頼るのではなく、決定的な制約(ハード制約)をガードレールとして整備することが重要。 紹介されているハード制約の例 Goの internal パッケージによるアクセス制御 depguard 等のLintによる依存ルールの強制 Fuzzing testやMutation testによるテスト品質の担保 個人的に気になった点 アーキテクチャの依存ルールを生成AIに守らせるという観点で、Goの internal パッケージを用いるというのは面白い発想だと思いました。一方で、ドメイン単位でパッケージを分割する Package by Feature だからこそ威力を発揮する一面もあるのかなと思いました。私が携わっているプロジェクトでは、アーキテクチャの技術的な役割(レイヤー)毎にパッケージを分割する Package by Layer を採用しています。 internal パッケージの配下に各レイヤーのパッケージを切る構成が一般的です。この場合、 internal が守れるのは外部モジュールからのアクセスであり、 internal 内部のレイヤー間の依存方向までは防げません。 発表後に登壇者の方へ質問したところ、 Package by Layer でも internal パッケージが活きるケースを共有していただきました。各層でしか使わない関数を他の層から使われないように守るという観点です。例えば、 presentation 層でレスポンスに対して処理する関数を internal に配置すれば、他の層からの誤った利用を防げるとのことでした。レイヤー間の依存方向の制御とは別に、各層の内部実装の隠蔽という観点で internal が有効に機能するというのは納得感がありました。 Go パッケージのサプライチェーン攻撃を防ぐ CI を作ってみた speakerdeck.com こちらのセッションでは、Goパッケージのサプライチェーン攻撃をCIで防ぐ取り組みについて紹介されています。 課題 typosquatting (タイプミスを狙った攻撃)や slopsquatting (AIのハルシネーションを狙った攻撃)により、悪意のあるパッケージの混入リスクがある 対策 Googleが公開している capslock を活用し、パッケージがアクセスし得る特権的操作(ファイルシステム操作、ネットワーク通信など)を静的解析で検知 PRで新しいパッケージが追加された際に、 main ブランチとのCapabilityの差分をCIで検出 その結果をClaude Code Actionに読み込ませることで、セキュリティリスクを診断する仕組みを構築 個人的に気になった点 こちらのセッションは、昨年開催されたGo Conference 2025の「 サプライチェーン攻撃に学ぶmoduleの仕組みとセキュリティ対策 」に続く内容だと感じました。昨年の発表では、Goのパッケージ管理システムを利用したサプライチェーン攻撃が3年以上見つからず、その根本的な対策も難しいという話がありました。本発表はLT枠で5分と短かったですが、昨年のGo Conferenceで発表された課題に対して対策を検討し、同じくGo Conferenceで発表するという流れにとても感心しました。 発表内容で気になったのは、新しく追加されたパッケージのCapabilityから悪意の有無をClaude Codeがどう判断しているかという点です。登壇者の方に質問したところ、依存先パッケージのメソッド名や周辺の実装をもとに判断していると考えられるとのことでした。また、サードパーティの公式パッケージを追加した際にも、依存先パッケージでCapabilityの警告が出るケースもあったそうです。ただし公式パッケージである以上、対処は難しく、まだ改善の余地があるとのことでした。 Go 1.26 で生まれ変わった go fix をプロダクト開発の運用に乗せる speakerdeck.com こちらのセッションでは、Go 1.26で大幅に刷新された go fix コマンドをプロダクト開発の現場にどう組み込むかについて紹介されています。 運用フローの設計 「検知」と「適用」を分けて考えるのがポイント 検知(毎PR): golangci-lint の modernize を有効化し、CIで古い書き方を常時警告する 適用(Goバージョン更新時): go fix ./... を2回実行して既存コードを一括変換する go fixに関する3つのアプローチと使い分け modernize :組み込みルールによるコードのモダン化。go fixを実行するだけ SuggestedFix :自作Analyzerに修正提案を追加し、プロジェクト固有のパターンを自動修正する go:fix inline :非推奨関数に //go:fix inline を付与し、利用者側でgo fixを実行するだけでAPI移行を自動化する 個人的に気になった点 先日公開された公式ブログ「 Using go fix to modernize Go code 」を読んでおり、最近私も go fix を実行した経験がありました。そのため、運用観点の話はとても興味深い内容でした。特に気になっていたのは、 go fix の「2回実行が必要」という点の仕組み化です。ある modernize ルールの適用が別のルールの適用機会を生むため、公式ブログでも2回の実行が推奨されていますが、これを仕組み化するのは難しいと感じていました。登壇者の方に質問したところ、以下のような回答をいただきました。 まだ完全な仕組み化はできていないが、 pre-commit フックでコミット前に go fix を実行する方法を検討している ただしpre-commitの導入はチームにより意見が分かれるため、Claude CodeのSkillsで実行させるのも有効ではないか 生成AIのSkillsは、こうした「毎回やるべきだが柔軟さも求められるルール」の適用に向いているという点に納得感がありました。また、 golangci-lint の modernize リンターについても質問しました。内部的にはgo fixと同じ modernize アナライザが動いているため、こちらも同様に複数回の実行が必要とのことでした。 encoding/json/v2のUnmarshalはこう変わった ~内部実装で見る設計改善~ speakerdeck.com 私も今回LT枠で登壇いたしました。このセッションでは、Go 1.25で実験的に追加された encoding/json/v2 パッケージの Unmarshal 関数を取り上げました。従来の encoding/json パッケージが抱えているパフォーマンス上の課題と、v2での改善点を内部実装の観点から紹介しました。 v1での課題点 パッケージの構成 :1つのパッケージに「JSONを解析する処理」と「Goの構造体に変換する処理」がすべて混在しており、変更時の影響範囲も広かった エラーメッセージ :JSONのパース(解析)に失敗したとき、どの項目でなぜ失敗したのかがエラーメッセージから読み取りにくかった メモリの使い方 :Unmarshalを呼ぶたびに内部で使うオブジェクト(Decoder)を毎回新しく作成しており、高頻度で呼び出すとメモリ確保やGC(ガベージコレクション)の負担が大きかった データの読み取り方 :JSONデータを読み取るたびに内部でコピーが発生しており、メモリ効率が悪かった v2での改善点 パッケージの分離 :「JSONの解析」を担う jsontext パッケージと「Goの型へのマッピング」を担う json パッケージに分離し、それぞれの役割を明確にした 構造化されたエラー :エラー情報にJSONのどの位置で、どんなJSON型が原因で失敗したかを含めるようにし、原因の特定が容易になった オブジェクトの再利用 :sync.Poolパッケージを使い、一度作った Decoder を使い回すことで、メモリ確保の回数とGCの負担を大幅に削減した 効率的なバッファ管理 :1つのバッファ(データを一時的に保管する領域)を論理的に分割して管理することで、データのコピーなしに必要な部分へアクセスできるようになった このテーマを選んだきっかけ 普段の業務ではREST APIを実装する機会が多く、 encoding/json パッケージを利用する場面も多くありました。しかし、 encoding/json には以前から課題が多く、 golang/go#71497 でも長期にわたって議論が続いています。そんな中、Go 1.25で実験的にv2が追加されました。 go-json-experiment/jsonbench のベンチマーク結果を見ると、v2の Unmarshal 関数は以下の点で大きく改善されていることがわかります。 大幅な速度改善 :具象型で2.7〜10.2倍、RawValue型では最大21.1倍と、v1から劇的に高速化されている 安全性を犠牲にしていない : unsafe パッケージを使用せず、UTF-8の検証や重複キーの拒否などRFC準拠の正確性も向上している ストリーミング対応 :v1では非対応だった Unmarshal のストリーミングにも設計当初から対応している 速度・正確性・安全性のいずれも改善されているという結果から、「なぜこれほど改善できたのか?」を内部設計から理解したいと思い、 アドベントカレンダーの記事 で調査しました。その調査がきっかけとなり、今回プロポーザルを提出しました。 さいごに 今回LT枠ではありますが、初めてGo Conferenceにプロポーザルを提出し、採択していただきました。発表後には「あと20分くらい聞きたかった」や「よく5分でまとめましたね」などとても温かいお声をいただきました。登壇を機に、さまざまな方と繋がれたことは非常に貴重な経験でした。アウトプットがきっかけで生まれる繋がりの大切さを改めて実感しました。また、登壇を機に初めて仙台へ行きました。牛タンやずんだ餅など仙台グルメも堪能でき、カンファレンスと合わせて充実した思い出となりました。 最後に、このような素晴らしい場を作ってくださった運営の皆さまに心から感謝いたします。準備から当日の進行まで、細やかな配慮が行き届いており、登壇者・参加者いずれの立場でも安心して楽しむことができました。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
アバター