株式会社ZOZOのブログ - TECH PLAY

TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

全1062件

はじめに こんにちは。EC基盤開発本部 SRE部 カート決済SREの遠藤です。 普段はカート決済に関連する機能のリプレイスや保守運用をメインに、ZOZOTOWNのDBREとして活動しています。 近年、ランサムウェア攻撃が世界中で猛威を振るっており、企業のデータ保護は喫緊の課題となっています。特にデータベースは企業活動の根幹を支える重要な資産であり、万が一の攻撃に備えた対策が不可欠です。 本記事では、Everpure社(旧Pure Storage社)のストレージ製品「FlashArray」を活用したランサムウェア対策をご紹介します。対象はZOZOTOWNの基幹データベースであるMicrosoft SQL Serverです。 はじめに 概要 前提:Everpure FlashArrayとSafeMode FlashArrayの主な特徴 主な特徴・機能 スナップショットの容量効率と多世代保持 SafeModeによる不変スナップショット SafeModeのランサムウェア対策の仕組み 課題:FlashArray標準スナップショットによる運用上の問題 解決策:VSS連携によるSQL Serverスナップショットの取得 VSS連携の仕組み VSS連携の流れ VSSの3つのコンポーネント 検証:I/Oフリーズによるサービス影響 検証方法 検証結果 実装:スナップショット取得バッチの作成 全体構成 事前設定 クレデンシャルの登録 バックアップジョブの登録 バッチ実装 スナップショットの実行 運用:構成とモニタリング Protection Group と Snapshot Schedule の設計 実行タイミングの最適化 日常運用とモニタリング 展望:T-SQLスナップショットへの移行 各方式の比較 SQL Server 2022:VSSに依存しないスナップショットバックアップ SQL Server 2025:T-SQLで完結したスナップショットバックアップ まとめ 本取り組みのポイント 今後の展望 最後に 概要 本取り組みでは以下を行いました。 Everpure FlashArrayの「SafeMode」機能を利用した不変スナップショット運用の導入 データベースの整合性を担保したスナップショット取得の設計実装 今後のSQL Serverのバージョンアップに伴うFlashArrayとの連携強化の調査と検証 これらの取り組みにより、万が一ランサムウェア攻撃を受けた場合にデータを復旧できる体制を強化しました。 前提:Everpure FlashArrayとSafeMode FlashArrayは、ストレージベンダーであるEverpure社が提供するオールフラッシュストレージです。 ZOZOTOWNの基幹データベースは、オンプレミス環境のWindows Server上で稼働するMicrosoft SQL Serverです。そのデータを保存するストレージとして、Everpure FlashArrayを採用しています。 Everpure社は、2026年2月にPure Storage社からの社名変更を発表しました。本記事ではSDKなど一部の名前に「Pure Storage」という名前が残っていることをご了承ください。 www.everpuredata.com FlashArrayの主な特徴 主な特徴・機能 FlashArrayは、データベース運用において有用な特徴や特性を備えています。 機能・特性 説明 データベース運用上のメリット 重複排除・データ圧縮 同一ブロックの集約とリアルタイム圧縮 ストレージ容量を効率化し、多世代スナップショットを低コストで保持できる 高速なスナップショット ボリュームの瞬間的なコピーを作成 高速なバックアップ・復元が可能 SafeMode機能 データやスナップショットの物理削除を不可能にする ランサムウェア対策に有効 スナップショットの容量効率と多世代保持 FlashArrayのスナップショットは元データの参照情報を記録した軽量データです。スナップショット作成時にはデータの物理的なコピーは行わず、その後書き換えられたデータの旧版のみが容量を消費します。保持されるデータにも重複排除と圧縮が適用されます(弊社環境のアレイ全体では平均5:1程度のデータ削減率を実現しています)。 管理画面上では最新以外のスナップショットは数GB程度と小さく表示される一方、最新のスナップショットだけが数百GB規模で表示される場合があります。これは複数世代で共有しているデータが最新のスナップショットにまとめて計上されるためで、実際の消費量はスナップショット全体の合計で見る必要があります。この合計容量は、データの増加量ではなく既存データの書き換え量で決まります。追記中心のDBでは小さく収まりますが、インデックスの再構築や大量の更新・削除、集計テーブルの洗い替えなど、既存ページを広く書き換える処理があると大きくなります。 いずれの場合も、世代ごとに全量のコピーを持つわけではないため、ストレージ内に多世代のスナップショットを容量効率良く保持し続けられます。 SafeModeによる不変スナップショット FlashArrayによってランサムウェア対策を実現するためには、SafeMode機能 *1 の有効化が必要です。 SafeModeが有効なスナップショットは、事前に設定された保持期間の間は削除・変更できなくなります。前述の容量効率の高さにより、この不変スナップショットも多世代にわたって保持できるため、ランサムウェア被害時に確実に復旧できます。 SafeModeのランサムウェア対策の仕組み 昨今のランサムウェア攻撃はデータを暗号化するだけでなく、バックアップやスナップショットを削除して復旧手段を奪おうとするケースが増えています。通常のストレージであれば、管理者の認証情報まで取られた場合、攻撃者はスナップショットを削除できます。 しかし、SafeModeで保護されたスナップショットは、管理者権限でも物理削除できないため、攻撃前の正常なデータを確実に保持し続けます。仮に攻撃者がホストサーバー(Windows Server等)の管理者権限を取得し、FlashArrayのボリューム上のデータを暗号化したとしても、スナップショット自体は守られます。 万が一、SafeModeの無効化や設定変更を行いたい場合は、事前に登録された複数の管理者がEverpureのサポートと連絡を取り本人確認する必要があります。この多要素・多人数の認証により、内部犯行や認証情報の漏洩があった場合でも、攻撃者が単独でSafeModeを無効化できないようになっています。 以下の図は、ランサムウェア攻撃に対してSafeModeがどのように機能するかを示しています。 攻撃を受けた場合は、FlashArrayの管理画面やREST APIから攻撃前の正常なSafeModeスナップショットを特定し、ボリュームをリストアすることで復旧できます。 課題:FlashArray標準スナップショットによる運用上の問題 ここからは、FlashArrayのSafeModeによる不変スナップショットを導入するにあたり判明した課題と解決へのアプローチを紹介します。 ZOZOTOWNの基幹データベースでは、以前からFlashArrayを採用していました。 FlashArray導入時からSafeModeも有効化しスナップショットを取得していました。しかし、ランサムウェア対策の運用を見直す中で、FlashArrayの標準設定ではSQL Serverの運用に適したスナップショットが取得できていないことが判明しました。 FlashArrayをはじめとするストレージ製品の標準的なスナップショットは、「クラッシュ整合性」と呼ばれる整合性レベルのスナップショットです。 SQL Serverなどのトランザクションを持つデータベースでは、書き込みI/Oを一時停止して静止点を確保し、復元可能な状態を保証する「アプリケーション整合性」レベルのスナップショットが求められます。 それぞれの整合性の違いは下記の通りです。 比較項目 クラッシュ整合性 アプリケーション整合性 データ状態 突然の電源断と同じ状態。メモリ上のバッファキャッシュや未コミットのログはディスクに書き込まれていない。 書き込みI/Oが停止された状態。書き込み中断によるページ破損のリスクがなく、復元可能な状態が保証されている。 復旧プロセス 起動時にクラッシュリカバリが走る。不整合な箇所を自動修復する。 リカバリ処理は不要。バックアップ時点の状態ですぐに起動・利用可能。 復旧時間 (RTO) 時間がかかる。未処理のログ量が多いほど、起動時のロールフォワード/ロールバックに時間を要する。 極めて短い。修復プロセスをスキップできるため、即座にサービスを再開できる。 データ破損リスク 稀にページ破損(Partial Write)が起きるリスクがある。 リスクは極めて低い。 実現方法 ストレージやハイパーバイザのスナップショット機能のみで実行。 DBのI/Oを一時的に凍結し、静止点を作れる機能と連携して実行 本番への影響 負荷が無い。DB側での処理が不要なため、スナップショット作成時のパフォーマンス低下はない。 一時的な負荷あり。メモリのフラッシュやI/Oの一時凍結(フリーズ)により、レスポンスの瞬断や遅延が発生する場合がある。 クラッシュ整合性スナップショットから復元するとクラッシュリカバリが必ず走ります。ログ量や実行中のトランザクションの状況によって復旧にかかる時間は大きく変動するため、RTOを見通せないことが問題となります。 また、MicrosoftはSQL Serverのスナップショットバックアップの取得方法としてVSSとの連携を案内しており *2 、ストレージ単独でのスナップショット取得は推奨されていません。 解決策:VSS連携によるSQL Serverスナップショットの取得 この課題を解決するための方法として、Windows VSS *3 (Volume Shadow Copy Service、以下VSS)と連携しスナップショットを取得する必要があります。 しかし、過去に別のバックアップソリューションでVSSと連携したスナップショットを取得した際、深刻な障害へと発展した経験がありました。 VSS連携時にはディスクに対して瞬間的にI/Oフリーズを発生させる必要がありますが、このI/Oフリーズが長時間続いた場合、クエリタイムアウトが発生し、大量のエラーを引き起こすことにつながります。 この経験から、VSSの利用は慎重なアプローチが必要でした。 VSS連携の仕組み ここでFlashArrayとVSSの連携方法を解説します。VSSはある瞬間の整合性が取れたデータスナップショットを取得するためのサービスで、Windows OSに組み込まれている機能です。 VSS連携の流れ 以下がVSSによるFlashArrayの不変スナップショットの全体フローです。 VSSの3つのコンポーネント VSSを使ったスナップショットは、以下の3つのコンポーネントを連携することで実現します。 (1) VSS Requester VSSによるボリュームのスナップショットを取得するためにはVSS Requesterによる実行指示が必要です。 Backup SDKによってインストールされるPure Storage Requesterがこれに該当します。PowerShellのスクリプトを定期実行し、SDKの Invoke-PfaBackupJob コマンドによりPure Storage Requesterに対してスナップショット取得を要求します。 (2) VSS Writer SQL Serverなどのアプリケーション側と連携するためのコンポーネントです。今回はSQL Server VSS Writerがこれに該当し、SQL Server構築時に自動でインストールされています。VSSからの指示を受けて、静止点が取れる状態に準備します。その後データベースへの書き込みI/Oの一時停止(Freeze)と再開(Thaw)をします。 (3) VSS Hardware Provider ストレージ側と連携するためのコンポーネントです。今回はPure Storage VSS Hardware Providerがこれに該当します。VSSからの指示を受けて、FlashArrayのREST APIを呼び出し、スナップショットを作成します。 検証:I/Oフリーズによるサービス影響 前述の通り、過去にVSSのI/Oフリーズを原因とした障害を経験しており、今回のVSS連携も慎重に検討する必要がありました。 検証方法 以下の2つの検証を実施しました。 STG環境で過去に障害となったバックアップツールを起動し、VSSによるI/Oフリーズの発生時間を確認 商用環境でI/Oフリーズを再現し、段階的にフリーズ時間を延長してサービス影響が出ないか確認 検証2では以下のクエリをSQL Serverに実行しI/Oフリーズを再現しました。 DBCC FREEZE_IO ( ' DB名 ' ) WAITFOR DELAY ' 00:00:00.100 ' ; --(例)100msの間I/Oフリーズを発生させる DBCC THAW_IO ( ' DB名 ' ) DBCC FREEZE_IOコマンドはMicrosoft公式サポート対象外(Undocumented)です。本来は本番環境での利用は推奨されませんが、問題がないことを確認した上で利用しています。 検証結果 2つの検証を通じて、VSSのI/Oフリーズの与えるサービス影響が許容範囲であることを確認し、VSS連携によるSafeMode導入は問題ないと判断しました。 また、検証のなかで、VSSによるI/Oフリーズ発生時の具体的な影響範囲も判明しました。 Insert/Update/Deleteは全て影響を受ける Selectについてはメモリにデータが載っていないときだけ影響を受ける メモリに余裕がある場合Select句で影響を受ける可能性は少なくなる 実装:スナップショット取得バッチの作成 全体構成 今回は、VSSと連携してスナップショットを取得するために、Pure Storage Backup SDKを利用したバッチスクリプトを実装しました。このSDKはPowerShellモジュールのため、バッチもPowerShellで実装しています。 バッチから呼び出される各機能の関連は下記の通りです。 このうち、SQL Server VSS WriterとVSSはSQL Server構築時とWindows OSに標準で含まれているため、追加インストールは不要です。Pure Storage Requesterについても、初回のバックアップジョブ実行時にBackup SDKが自動でインストールしてくれました。手動でのインストールが必要なのは、バッチ実行サーバーのSSMS Extension(Backup SDKが同梱されています)と、DBサーバーのVSS Hardware Providerの2つです。 FlashArrayへのREST API接続は2経路あります。1つはVSS Hardware Providerからのもので、VSSからの指示を受けてスナップショットを作成します。もう1つはBackup SDKからのもので、こちらはスナップショット取得そのものではなく、バックアップ構成と取得履歴の管理に使われます。 なお、DBサーバーとFlashArrayの間はFC Switch経由のFC接続で、SQL Serverのデータファイル・ログファイルはFlashArray上のボリュームに配置されています。 事前設定 設定にはPure Storage Backup SDKのPowerShellモジュールに含まれるコマンドレットを使用します。 クレデンシャルの登録 バッチ実行サーバーから、SQL ServerとFlashArrayのREST APIに接続するためのCredentialを登録します。 # SQL Serverへの接続情報(Windows認証) Add-PfaBackupCred -CredentialName "<任意の名前>" ` -Address "<SQL Server IP>" ` -CredentialType "Windows" ` -Credential ( Get-Credential ) # FlashArrayへの接続情報 Add-PfaBackupCred -CredentialName "<任意の名前>" ` -Address "<FlashArray IP>" ` -CredentialType "FlashArray" ` -Credential ( Get-Credential ) バックアップジョブの登録 同様に、バッチ実行サーバーからスナップショット取得先の設定を登録します。 Add-PfaBackupJob -ConfigName "<設定名>" ` -Component "<インスタンス名>\<データベース名>" ` -ComputerName "<SQL Server名>" ` -FAName "<FlashArrayクレデンシャル名>" ` -VolumeType "Physical" ` -MetadataDir "<メタデータ保存先>" ` -CopyOnly SQL Serverのバックアップ運用と併用する場合は -CopyOnly を指定します。未指定の場合、スナップショットが差分バックアップの基点としてリセットされ、既存のフルバックアップ+差分バックアップの復元手順に影響が出てしまいます。 バッチ実装 スナップショットの実行 バッチから Invoke-PfaBackupJob コマンドを実行しスナップショットを取得できます。ConfigNameには Add-PfaBackupJob コマンドで事前に設定したジョブ設定名を指定します。 $result = Invoke-PfaBackupJob -ConfigName "<設定名>" if ( $result [ "Status" ] -ne "Succeeded" ) { throw "スナップショット取得に失敗しました" } 運用:構成とモニタリング VSS連携によるスナップショット運用を安定して回すため、Protection Groupの設計、実行タイミング、日常的なモニタリングの観点で運用ルールを整理しました。 Protection Group と Snapshot Schedule の設計 Protection Groupは、FlashArrayにおけるスナップショットの管理単位です。複数のボリュームをグループ化し、まとめてスナップショットを取得・管理できます。 同じProtection Group内にある複数ボリュームのスナップショット取得や復元は、まとめて処理されます。しかし、VSS連携をした今回のスナップショットは指定したボリュームのみに適用されます。また、SafeModeを有効化するためには、Protection GroupにSnapshot ScheduleというFlashArray標準のスナップショットも有効化する必要があります。 この仕様を理解し、以下のようにProtection Groupの設計を見直しました。 SystemDBとUserDBをそれぞれ独立してリストアできるよう、Protection GroupとVSS連携をDBごとに分ける Protection GroupのSnapshot Scheduleは必ず有効化する(SafeMode有効化の前提条件) 弊社環境での具体的なProtection Group構成は以下の通りです。 ProtectionGroupA :DBではないその他の複数ボリューム ProtectionGroupB :SQL ServerのSystemDBが存在する単一ボリューム。VSS連携によるスナップショット対象 ProtectionGroupC :SQL ServerのUserDBが存在する単一ボリューム。VSS連携によるスナップショット対象 ProtectionGroupD :SQL ServerのTempDBが存在する単一ボリューム(SQL Server起動時に自動再作成されるため、VSS連携の対象外) 実行タイミングの最適化 他の処理との競合を避けるため、実行タイミングを慎重に設計しました。考慮事項としては以下が挙げられます。 他の処理のピーク時間帯を避ける 通常のSQL Serverバックアップ(BACKUP DATABASE)とVSSスナップショットを同時に実行すると、ロック競合を起こす可能性があるため起動時間をずらす 同一のFlashArray内で複数のデータベースが稼働している場合はそれらの起動時間が被らないようにずらす 日常運用とモニタリング 日常的なモニタリング項目としては以下が挙げられます。 I/Oフリーズ時間の監視 スナップショットストレージ容量の推移監視 I/Oフリーズは負荷やDBの特性によって変化が大きく、数十msから1500ms程度かかるDBも存在しました。データの更新量やキャッシュ容量の大きいDBではスナップショットの所要時間が伸びる傾向にあります。許容できるフリーズ時間をあらかじめ見積もり、フリーズ時間が徐々に延びていないかを監視する必要があります。 また、FlashArrayのスナップショットは、重複排除の仕組みにより非常に小さいサイズで済みます。しかし、データの更新量や重複率によってサイズが大きく変わるため、正確な見積もりは困難です。そのためスナップショットを含めたFlashArrayの容量監視は重要です。 なお、万が一ランサムウェアに感染しデータを改ざんされた場合、重複排除が効かなくなりデータ容量の肥大化を起こすと考えられます。その際の早期発見としても容量推移のモニタリングは有効です。 展望:T-SQLスナップショットへの移行 ここからは、今後のバージョンアップに備えて調査・検証した最新の連携方法を整理します。 今回導入したVSS方式は安定して運用できていますが、SQL Server外部のWindows VSSがI/Oフリーズを制御する仕組みです。そのため、VSS WriterやVSS Hardware Provider、Backup SDKなどの依存コンポーネントが多い構成となっています。また、VSS方式のスナップショットはLSN情報を持たないため、スナップショット復元後に既存のトランザクションログバックアップを適用したポイントインタイムリストア(PITR)ができないという制約もあります。ランサムウェア被害時はサーバー上のデータが信用できないためSafeModeスナップショットから復元しますが、VSS方式ではその後のPITRに対応できません。 SQL Server 2022で追加された「T-SQLスナップショットバックアップ」はSQL Server自身が静止点を制御する方式で、VSSへの依存がない構成です。加えて、 .bkm メタデータファイルによってスナップショット後のログバックアップを適用したPITRも可能になります。さらにSQL Server 2025ではFlashArrayのAPI呼び出しまでT-SQLで行えるようになり、外部スクリプトを排した運用が可能になりそうです。 各方式の比較 項目 VSS連携方式 T-SQL方式 (2022) T-SQL方式 (2025) SQL Serverバージョン 2016以降 2022以降 2025以降 I/Oフリーズの制御 VSS(Windows OS側) SQL Server自身 SQL Server自身 ストレージ呼び出し VSS Hardware Provider PowerShell + Backup SDK T-SQLから直接(REST API) 外部スクリプト 必要(PowerShell等) 必要(PowerShell等) 不要 ポイントインタイム復旧 困難 可能(.bkmファイル) 可能(.bkmファイル) 各方式の構成は以下のようになります。バージョンが上がるにつれて依存コンポーネントが段階的に削減されます。 SQL Server 2022:VSSに依存しないスナップショットバックアップ SQL Server 2022では、VSSを介さずSQL Server自身がI/Oフリーズを直接制御します *4 。 VSS WriterやVSS Hardware Providerへの依存がなくなり、構成がシンプルになります。 スナップショット取得後に出力される .bkm メタデータファイルにはLSN(Log Sequence Number)情報が記録されます。これにより、スナップショットから復元後にトランザクションログバックアップを適用したポイントインタイム復旧が可能になります。VSS方式では実現が難しかった任意時点への復旧が、標準的な手順で行えるようになる点が大きな改善です。 なお、FlashArrayのREST API呼び出しはPowerShell *5 で行う必要があります。 SQL Server 2025:T-SQLで完結したスナップショットバックアップ SQL Server 2025では、T-SQLからFlashArrayのREST APIを直接呼び出せるようになります( sp_invoke_external_rest_endpoint ) *6 。 これにより外部PowerShellスクリプトが不要になり、認証情報の管理を含めたスナップショット運用をSQL Server内部に完全に閉じることができます。スナップショットへのタグ付けやカタログ機能も提供され、T-SQLのみでバックアップ運用を完結できます。 このように、今後SQL ServerとFlashArrayの連携が強化され、よりシンプルな運用が可能になる予定です。 まとめ 本取り組みのポイント 本記事では、Everpure FlashArrayのSafeModeとVSS連携を組み合わせ、SQL Serverの確実なリストア体制を整えた取り組みを紹介しました。 FlashArray SafeModeによる不変性の確保 :導入当初から有効化し、攻撃者による削除・改ざんを防止 VSS連携の導入(今回の対応) :Microsoftが推奨するVSS連携を導入し、クラッシュリカバリなしに確実に復旧できる体制を整えた 重複排除による容量効率 :多世代のスナップショットを効率的に保持 段階的な導入 :過去のVSS障害の教訓を活かし、影響を最小限に抑えながら展開 今後の展望 今後も様々な改善を検討中です。 SQL Server 2022環境でVSSに依存しない方式への移行 SQL Server 2025でのT-SQLで完結したスナップショットへの移行 異なる環境へのスナップショットの転送。多層防御化 定期的な復旧訓練の実施 最後に ランサムウェア対策は単一の施策で完結するものではありません。多層的な防御を構築し、継続的に改善していくことが重要です。本記事が同様の課題を抱える方々の参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com *1 : https://www.everpuredata.com/jp/solutions/cyber-resilience/ransomware/safemode.html *2 : https://learn.microsoft.com/ja-jp/sql/relational-databases/backup-restore/create-a-transact-sql-snapshot-backup *3 : https://learn.microsoft.com/ja-jp/windows-server/storage/file-server/volume-shadow-copy-service *4 : https://learn.microsoft.com/ja-jp/sql/relational-databases/backup-restore/create-a-transact-sql-snapshot-backup *5 : https://github.com/PureStorage-Connect/PowerShellSDK2 *6 : https://techcommunity.microsoft.com/blog/sqlserver/reimagining-data-excellence-sql-server-2025-accelerated-by-pure-storage/4470810
はじめに こんにちは、MA部のMA基盤開発ブロックの @gachi-muchi-engineer と松岡( @pine0619 )です。 MA部では、メール、LINE、プッシュ通知などを配信しています。配信に必要なデータの生成や夜間の集計など、複数のワークフローを運用しています。これまで、これらのワークフローはGKE上に構築したDigdagで実行していました。Digdagの構成や運用については、以下のテックブログで紹介しています。 techblog.zozo.com Digdagは、ワークフローを定義して実行するための便利なツールでした。一方で、近年は開発が活発ではなく、利用中に発生した問題や機能不足を自分たちで調査・修正しなければならないケースが増えていました。また、Digdagそのものだけでなく、GKE上で実行基盤を運用するための負担も課題になっていました。 そこで、ワークフローの実行基盤を自分たちで維持するのではなく、マネージドなワークフローオーケストレーションサービスへの移行を検討しました。その結果、Google Cloudが提供するManaged Service for Apache Airflowへの移行を開始しました。 ※旧称:Cloud Composer、以下Managed Airflowと表記します。 移行は一度にすべてのワークフローを切り替えるのではなく、ワークフローごとに段階的に進めています。まずは依存関係が比較的単純で、移行後の動作を確認しやすいワークフローから移行し、動作や運用方法を確認したうえで、対象範囲を広げています。 本記事では、移行の背景、Managed Airflowを選定した理由、MA部での構成、Airflowの監視を紹介します。 目次 はじめに 目次 背景と課題 Digdag on GKEの構成 Digdagのメンテナンスコスト アラート対応の複雑さ Managed Service for Apache Airflowを選んだ理由 総合評価 MA部での活用方法 基本的な構成 タスク間のデータ受け渡し ログの取り扱い 監視 Managed Airflow基盤の監視 コンポーネントの状態監視 DAG・タスクの監視 DAG・タスクの失敗監視 通知の共通化と柔軟な設定 通知が届かないことへの備え SLA監視 AirflowのSLA機能との違い SLA監視の実装 おわりに 背景と課題 Digdag on GKEの構成 MA部では、DigdagをGKE上に構築して運用していました。 GKE上には、GKEが管理するリソースに加えて、Digdag用のNamespaceを作成し、Digdagに必要な各種リソースを構築していました。Digdagがタスクを実行する際には、Podを起動して各種処理を実行する構成です。運用を続ける中で、次のような作業が必要となり、運用負荷が増加していました。 Digdag本体や関連コンポーネントのメンテナンス Digdagのデプロイ GKEクラスタやPodの状態確認 ワーカー数のスケーリングやリソース調整 バージョンアップ対応 ログやメトリクスの確認 障害発生時の復旧対応 Digdagのメンテナンスコスト Digdagの開発が活発に行われていないため、問題が発生した場合には、公式のアップデートを待つのではなく、自分たちで原因を調査する必要があります。場合によっては、Digdagのコードや実行環境を確認し、回避策を検討しなければなりません。 実際に、独自にパッチを適用したバージョンを利用するケースもありました。本来、MAの開発チームが集中したいのは、配信に必要なデータ処理や施策の実現です。 しかし実際には、ワークフローを動かすための基盤自体を継続的に運用する必要があり、運用コストが高くなっていました。 アラート対応の複雑さ ワークフローで問題が発生した場合、原因がどのレイヤーにあるのかを切り分ける必要があります。 例えば、タスクが失敗した場合でも、原因は次のように複数考えられます。 特に、DigdagやGKEのリソースに起因する問題では、アラート対応者が複数のシステムを確認しなければなりません。このように、ワークフローの実行基盤や運用に関するさまざまな課題が表面化していました。 これらの課題を解決するため、ワークフロー基盤のリプレイスを検討し、複数のワークフローエンジンを比較しました。 Managed Service for Apache Airflowを選んだ理由 複数のワークフローエンジンを比較・検討した結果を紹介します。 なお、以下の評価は、MA部の要件や既存の運用状況を前提としたものです。各ワークフローエンジンの一般的な優劣を示すものではありません。 比較項目 Managed Airflow Cloud Workflows Argo Workflows Prefect 開発・拡張性 Pythonで柔軟に記述でき、動的タスク生成にも対応 YAMLによるシンプルな制御が中心 YAMLによる定義。並列・分岐に強い Pythonで柔軟に記述できる 運用負荷 マネージドサービスだが、サービス固有の設定・監視が必要 フルマネージドだが、実処理の基盤は別途必要 GKEなどKubernetesの運用が必要 ワークフロー実行基盤の構築・運用が必要 GCPとの統合 GCPサービスとの連携機能が充実 GCPサービスとの連携に適している Kubernetes経由で連携可能 GCP連携機能はあるが、Managed Airflowほど充実していない サポート・継続性 Google Cloudのマネージドサービスで、サポートやエコシステムが充実 Google Cloudのマネージドサービス OSS。基盤運用やサポートは自分たちで対応 比較的新しいサービスで、実績や情報量が限定的 コスト感 環境の常時稼働に伴う費用が発生 軽量で実行時課金 GKEの維持費と実行費用が発生 構成によって異なり、実行基盤の運用費用も必要 総合評価 ワークフローエンジン 総合評価 Managed Airflow 必要なワークフロー機能、GCPとの親和性、運用機能、サポートのバランスがよく、今回の要件に最も適している Cloud Workflows 軽量でマネージドな点は魅力だが、複雑なワークフローや大規模なデータ処理には追加実装が必要になる Argo Workflows 並列・分散処理には強いが、GKEやKubernetesの運用が必要となり、今回の課題である基盤の運用負荷を解消しにくい Prefect Pythonによる柔軟な記述や運用面は魅力だが、実績や情報量が少なく、実行基盤の構築・運用も必要になる 比較の結果、Managed Airflowは必要な機能を備え、GCPとの親和性も高いと判断しました。 また、社内でもManaged Airflowの利用実績があり、既存の知見や運用ノウハウを活用できる点も、選定を後押ししました。 基盤の運用負荷やコストについては考慮が必要ですが、機能・継続性・サポート・社内実績を含めた総合的なバランスが最もよいと判断し、移行先としてManaged Airflowを採用しました。 社内でのManaged Airflowの利用事例については、以下のテックブログでも紹介しています。あわせてご覧ください。 techblog.zozo.com :embed:cite] MA部での活用方法 なお、本構成は移行対象のワークフローから順次適用しています。既存のDigdag上で稼働しているワークフローとManaged Airflow上のワークフローが並行して稼働する期間を設け、処理結果や実行時間を確認しながら移行を進めています。 基本的な構成 今回の構成では、Managed Airflowに業務処理を持たせず、ワークフローの起動・依存関係・リトライ・実行状態の管理に専念させています。 実際のデータ処理はCloud Run Jobsなどの外部の実行基盤で実行し、処理内容やワークロードに応じて実行基盤を選択できる構成としました。 Managed AirflowからCloud Run Jobsを起動するためのサービスアカウントと、Cloud Run Jobsの実処理で利用するサービスアカウントは分離しています。 集計系の処理と配信系の処理など、ワークフローの用途ごとにサービスアカウントを分けることで、それぞれに必要な権限のみを付与できます。Managed AirflowにはCloud Run Jobsの起動権限を、Cloud Run Jobsには実処理に必要な権限のみを付与します。 このように権限を分離することで、ある処理で問題が発生した場合の影響範囲を限定できます。 タスク間のデータ受け渡し Airflowには、タスク間で値を受け渡すためのXComという仕組みがあります。 ただし、Airflow公式ドキュメントでは、XComは少量のデータを扱うための仕組みであり、大きな値やDataFrameなどの受け渡しには使用しないよう説明されています。 XComs are only designed for small amounts of data; do not use them to pass around large values, like dataframes. Airflow公式ドキュメント そのため、今回の構成ではデータ本体をXComに保存せず、GCSなどの外部ストレージへ出力しています。後続タスクは、GCS上の処理結果や完了マーカーを参照することで、前段タスクの処理結果を確認します。これにより、Airflowのメタデータデータベースに大きなデータを保存することを避けています。 Managed Airflow上のDAGでは、主に次の処理を定義します。 タスクの実行順序 タスク間の依存関係 実処理の起動 リトライやタイムアウト ワークフロー全体の実行状態の管理 一方で、データの取得・加工・登録といった実処理は、DAGの中に基本的には直接記述しません。現在はCloud Run Jobsを中心に利用しています。 今後は、Pub/SubやCloud Run Service、Dataflowなど、ワークロードに応じて実行基盤を選択していく予定です。 ログの取り扱い Managed Airflowでは、データベース保持ポリシーを設定することで、設定した期間より古いAirflowデータベースのレコードが毎日自動的に削除されます。そのため、Airflowの画面から過去の実行履歴やログを確認できる期間には限りがあります。詳細は、 Airflowデータベースをクリーンアップする を参照してください。 また、Cloud Loggingにもログの保持期間があるため、長期間保存したいログや、後から横断的に調査したいログは、 ログルーター を使ってBigQueryへエクスポートしています。対象には、Scheduler、Worker、DAG ProcessorなどのManaged Airflowの各コンポーネントに加えて、Cloud Run Jobsのログも含めています。 これにより、AirflowデータベースやCloud Loggingの保持期間を過ぎた後でもログを参照できます。また、複数のコンポーネントにまたがるログをBigQueryのSQLで横断的に検索できるため、ワークフローの遅延や失敗が発生した際の原因調査にも利用できます。 監視 ワークフロー基盤では、処理が失敗したことを検知するだけでなく、「必要な処理が、想定した時間内に完了したか」を確認することが重要です。 GKE上でDigdagを運用する中で得た監視の知見に加え、Managed Airflowへの移行にあたって実施した障害試験の結果を踏まえ、Managed Airflowに必要な監視項目を整理しました。 次のような観点で監視しています。 監視対象 確認内容 Managed Airflow基盤 Scheduler・DAG Processorなど各コンポーネントの状態 DAG・タスク 実行結果(成功・失敗)。Cloud Run Jobsは起動するだけでなく、Jobの完了・失敗まで待機した上でタスクの成否として一体的に扱う。また、DAGごとに定めた時間内に完了したか(SLA)もあわせて監視する これらを組み合わせることで、Managed Airflow基盤自体の異常、DAG・タスクおよびバッチ処理の異常や遅延を切り分けて検知できるようにしています。 Managed Airflow基盤の監視 コンポーネントの状態監視 Airflowは、Scheduler・DAG Processor・Triggerer・Worker・Webserver・メタデータDBなど、複数のコンポーネントで構成されています。 これらのコンポーネントに問題が発生すると、個々のタスク失敗ではなく、「DAG自体の未実行」や「DAG変更の未反映」といった形で影響があります。 タスクの失敗監視だけでは、こうした問題に気づけません。そのため、コンポーネントごとに次のような観点で状態を確認しています。 コンポーネント 確認する内容 監視する理由 Scheduler 稼働状況 停止すると新しいDAG Runが作られず、実行中タスクの管理も止まる。個々のタスク失敗としては現れない DAG Processor 稼働状況、DAGファイルの読み込みエラー、パースにかかる時間 停止・遅延すると、新規や更新したDAGが反映されなくなる。1つのDAGファイルの記述ミスや重い処理が、他のDAGに気づかれないまま影響を及ぼすこともある Triggerer 稼働状況 他の処理の完了を待つタスクを非同期に監視する役割のため、停止するとそうしたタスクが先に進まなくなる Worker タスクキューの滞留状況、稼働ワーカー数 タスク自体は失敗していなくても、実行開始が遅れればDAGごとに定めた時間内に処理が終わらなくなる Webserver 稼働状況 UIやAPIが使えなくなり、API経由のDAG実行や情報取得ができなくなる メタデータDB 接続可否、CPU・メモリ・ディスクの使用率 DAG定義やタスクの状態を保持しており、複数のコンポーネントから利用されている。不調になると、メタデータDBを利用する複数のコンポーネントに影響が及ぶ Managed Airflowはこれらの状態を示すメトリクスをCloud Monitoringに公開しており( 公開されているメトリクスの一覧 )、コンポーネントごとに主に次のメトリクスで状態を確認しています。 コンポーネント 主なメトリクス Scheduler environment/scheduler_heartbeat_count DAG Processor environment/active_dag_processors 、 environment/dag_processing/total_parse_time 、 environment/dag_processing/parse_error_count Triggerer environment/active_triggerers Worker environment/num_celery_workers 、 environment/task_queue_length Webserver environment/active_webservers メタデータDB environment/database_health DAG Processorについては、パース時間とパースエラー件数を確認します。これらは、アクティブなインスタンス数だけでは気づきにくい、処理の遅延やエラーを検知するためのメトリクスです。 また、CPU・メモリの逼迫を検知するため、各コンポーネントのリソース使用量も監視しています。 CPUには composer.googleapis.com/workload/cpu/usage_time 、メモリには composer.googleapis.com/workload/memory/bytes_used を利用しています。 CPUの usage_time は累積CPU使用時間です。そのままでは割り当てCPU数と比較できないため、Cloud Monitoringの ALIGN_RATE を使って単位時間あたりの使用量に変換します。 メモリの bytes_used はバイト単位の使用量です。そのため、Terraformで定義している割り当てメモリをバイトへ変換し、割り当てメモリの80%をしきい値としています。 以下は、SchedulerのCPU使用量に対するアラート設定例です。 local.scheduler_cpu には、Composer環境に割り当てられたSchedulerのCPU数を指定します。 resource "google_monitoring_alert_policy" "airflow_scheduler_cpu_pressure" { display_name = "Airflow: Scheduler CPU pressure" combiner = "OR" conditions { display_name = "Scheduler CPU usage exceeds allocated capacity" condition_threshold { filter = <<EOT resource.type="cloud_composer_workload" AND resource.labels.type="SCHEDULER" AND metric.type="composer.googleapis.com/workload/cpu/usage_time" EOT comparison = "COMPARISON_GT" threshold_value = local.scheduler_cpu * 0 . 8 duration = "600s" aggregations { alignment_period = "60s" per_series_aligner = "ALIGN_RATE" } } } notification_channels = [ var.slack_notification_channel_id ] } このように、CPUの累積した使用時間を単位時間あたりの使用量に変換したうえで、割り当てられたCPU数の80%を超えた状態が10分間続いた場合に通知します。 この考え方を各コンポーネントに適用することで、環境サイズやコンポーネントのスペックを変更した際にも、監視側のしきい値を個別に修正する必要がありません。 メモリについても同様に、各コンポーネントに割り当てられたメモリ量を基準にしきい値を算出しています。 DAG・タスクの監視 DAG・タスクの失敗監視 最も基本的な監視は、DAGやタスクの失敗監視です。 MA部では24時間365日のオンコール体制を敷いており、業務上問題となるシステムエラーや障害が発生した場合に、Slack/PagerDutyへの通知を通じて検知・対応しています。 タスクが Failed 状態になった場合は、後述する on_failure_callback の仕組みでSlack/PagerDutyへ通知されます。DAGの実行が失敗した場合は、Cloud LoggingやAirflowの実行履歴から、失敗したタスクとログを確認します。 一方で、タスクが明示的に失敗していなくても、実行時間が長い場合は業務上の問題になり得ます。このような異常は、経過時間を基準に検知します。 前述の「Managed Airflow基盤の監視」ではタスクの滞留などを、後述する「SLA監視」ではDAGごとの実行時間の超過を扱います。 通知の共通化と柔軟な設定 タスク失敗時の通知( on_failure_callback )をDAGごとに個別実装すると、実装漏れが発生しやすくなります。 MA部では、Airflow標準の @dag デコレータをラップした独自のデコレータ( airflow_default_dag )を用意しています。DAG定義にこのデコレータを付与するだけで、 on_failure_callback などの共通設定が自動的に適用されます。 @ airflow_default_dag ( dag_id= "example_dag" , schedule= "0 * * * *" , start_date=pendulum.datetime( 2026 , 4 , 1 , tz= "Asia/Tokyo" ), ) def example_dag (): ... デコレータの内部では、おおよそ次のようなことをしています。 def airflow_default_dag (schedule= None , start_date= None , **kwargs): def wrapper (f): default_args = kwargs.pop( "default_args" , {}) default_args.setdefault( "on_failure_callback" , functools.partial(on_failure, channel=..., mentions=..., pagerduty_enabled=...), ) return dag(schedule=schedule, start_date=start_date, default_args=default_args, **kwargs)(f) return wrapper default_args に on_failure_callback をデフォルトで設定してから、Airflow標準の @dag デコレータへ委譲しています。DAGの実装者は、この仕組みを意識せず @airflow_default_dag を付けるだけで、失敗時の通知が有効になります。 一方で、DAGによって重要度や運用ポリシーは異なります。例えば、1時間に1回実行されるバッチでは、次の実行が成功すれば業務上問題にならない場合があります。 また、分析用途のデータマート生成など、翌営業日の対応で問題ない処理もあります。このようなDAGの失敗まで電話で通知すると、本当に急ぎで対応すべき通知が埋もれてしまいます。 そのため、通知先チャンネル・メンション先・PagerDuty連携の有無・深夜帯のミュート時間帯などを、デコレータの引数としてDAG側から指定できるようにしています。 @ airflow_default_dag ( dag_id= "example_dag" , schedule= "0 * * * *" , start_date=pendulum.datetime( 2026 , 4 , 1 , tz= "Asia/Tokyo" ), slack_channel= "#example-alert" , slack_mentions=[ "@here" ], pagerduty_enabled= True , pagerduty_mute_hours_jst= "01:00-06:00" , ) def example_dag (): ... これにより、すべての失敗をSlackで通知し、失敗の発生を把握できます。 さらに、即時対応が必要なDAGにはPagerDutyによる電話呼び出しを設定できます。また、Slack通知とPagerDuty通知は独立して実行しており、どちらか一方の送信が失敗しても、もう一方の通知処理がブロックされない設計にしています。 通知が届かないことへの備え ここまで紹介した監視は、いずれも「問題が発生したこと」をSlackやPagerDutyへ通知する仕組みを前提としています。 しかし、通知処理が失敗すると、タスクの失敗やSLA超過を検知できません。その結果、問題が発生していても誰も気づけない可能性があります。例えば、通知先サービス側の一時的なエラーにより、通知処理内で例外発生のケースが考えられます。 そのため、通知処理(Slack・PagerDutyへの送信)が失敗した場合には、その旨をログへ出力し、 ログベースの指標(log-based metric) としてCloud Monitoringで検知するようにしています。 SLA監視 SLA監視では、DAGの実行開始からの経過時間が、DAGごとに設定した時間を超えていないかを監視します。 例えば、毎朝3時に開始し、7時までにデータを作成する必要があるDAGであれば、「4時間」というSLAを設定します。実行開始から4時間が経過してもDAGが完了していない場合に、SLA超過として通知されます。 タスクが最終的に成功していても、SLAを超過していれば業務上は問題となります。 そのため、Airflowのタスク成否監視とは別に、DAGごとに設定した時間を基準としたSLAの監視をします。 なお、前述の基盤監視のしきい値は環境のリソース割り当て量から自動的に算出していますが、SLAは業務要件に基づく値であるため、こうした自動算出はせずDAGごとに個別に設定しています。 AirflowのSLA機能との違い Airflowには、従来 SLAs という機能がありました。この機能はAirflow 2系にのみ存在し、Airflow 3.0で廃止されています。 従来のSLAでは、DAG Runの完了時に logical_date + sla を基準として、SLA違反を判定していました。そのため、DAG Run未終了時はSLA違反を通知できない可能性があります。 Airflow 3.1では、SLAの後継として Deadline Alerts が追加されました。Deadline Alertsでは、DAG Runの開始時点で期限を計算・保存し、Scheduler側が定期的にチェックすることで、DAG Runの完了を待たずに通知できます。 MA部のManaged Airflowでは、Airflow 3系を利用しています。そのため、従来のSLA機能は利用できません。後継機能であるDeadline Alertsは、Airflow 3.1で追加された実験的な機能です。将来のバージョンで仕様が変更される可能性もあります。 そこで今回はAirflowの機能に依存せず、MAの業務要件に合わせた独自のSLA監視の仕組みを採用しました。「いつまでに、どの処理が完了していればよいか」を定義し、処理の遅延を監視しています。Deadline Alertsが正式な機能として安定した際には、移行を検討する予定です。 SLA監視の実装 具体的には、SLA監視専用のDAGを別途用意し、Airflow REST API経由で実行中のDAG Runを定期的にポーリングして、経過時間が設定値を超えていないかを確認しています。 SLA監視専用DAGも他のDAGと同様に監視対象です。ポーリング処理自体が失敗すればDAG失敗監視で検知でき、実行開始の遅延や滞留は前述のWorker監視(タスクキューの滞留状況の監視)で検知できます。 SLAの値は、独自に用意したDAGデコレータの引数として指定します。 @ airflow_default_dag ( schedule= "0 * * * *" , start_date=pendulum.datetime( 2026 , 4 , 1 , tz= "Asia/Tokyo" ), sla= "30m" , ) def example_dag (): ... 独自デコレータの内部では、指定されたSLAを"sla=30m"のようなDAGタグに変換します。 当初はAirflow Variables(key-value store)にSLA値を持たせる案も検討しました。しかし、DAG側で指定した値を最新に保つには、DAGの解析(パース)のたびにVariableへ書き込む処理が必要になります。 そこで、Variableへ値を書き込む処理を追加せず、DAG定義に紐づく静的な情報として扱える tags を採用しました。 AirflowのタグはUI上の分類や検索にも利用されるため、SLA用タグは sla= で始める形式に統一し、通常の分類用タグと区別しています。 SLA監視専用DAGでは、まずAirflow REST APIでDAG一覧を取得し、各DAGに設定されたタグからSLAを読み取ります。その後、SLAが設定されたDAGの実行中DAG Runを取得し、DAG Runの開始時刻からの経過時間をもとにSLA超過を判定します。 SLA_TAG_PATTERN = re.compile( r"^sla=(\d+)(m|h)$" ) def parse_sla_tag (tags): for tag in tags: tag_name = tag.get( "name" , "" ) if match := SLA_TAG_PATTERN.match(tag_name): value, unit = match.groups() if unit == "m" : return timedelta(minutes= int (value)) return timedelta(hours= int (value)) return None def check_sla_violations (): # DAG一覧から、DAG IDとSLAの対応を作成する dags = list_all_dags() sla_by_dag_id = {} for dag in dags: sla = parse_sla_tag(dag.get( "tags" , [])) if sla is not None : sla_by_dag_id[dag[ "dag_id" ]] = sla # SLAが設定されたDAGの実行中DAG Runを取得する dag_runs = fetch_running_dag_runs( list (sla_by_dag_id.keys()) ) for dag_run in dag_runs: sla = sla_by_dag_id.get(dag_run[ "dag_id" ]) if sla is None : continue start_time = datetime.fromisoformat( dag_run[ "start_date" ].replace( "Z" , "+00:00" ) ) elapsed = datetime.now(timezone.utc) - start_time if elapsed > sla: notify_sla_violation(dag_run) なお、上記は簡略化した例になります。 おわりに 今回の移行では、Digdagの実行基盤をManaged Airflowへ置き換えるだけでなく、ワークフロー基盤の責務も見直しました。 Managed Airflowにはワークフローの制御を担わせ、実際のデータ処理はCloud Run Jobsへ分離しています。また、処理ごとにサービスアカウントを分離し、必要な権限のみを付与できる構成としました。 移行は段階的に進めており、現在は移行対象のワークフローから順次Managed Airflowへ切り替えています。 現時点で、移行済みのワークフローでは次のようなことを実現できています。 ワークフロー制御とデータ処理の責務を分離できた 処理ごとに実行環境を選択できるようになった 処理ごとにサービスアカウントを分離できるようになった AirflowやCloud Run JobsのログをBigQueryへ集約し、横断的に調査できるようになった DAGやタスクの失敗だけでなく、SLAを超過した処理も検知できるようになった 一方で、すべてのワークフローの移行が完了したわけではありません。今後も、依存関係の単純さや影響範囲の小ささを基準に優先順位をつけながら、残りのワークフローの移行を進めます。 移行後の運用状況を確認しながら、GKEやDigdag本体の運用負荷、障害発生時の原因切り分けや復旧時間の変化を確認していく予定です。ワークフロー追加時の運用コストについても、削減効果を継続的に評価します。 また、マネージドサービスを利用しても、すべての運用が不要になるわけではありません。ワークフローの設計、リトライやタイムアウトの方針、権限設計、アラートの通知先、業務上のSLAなどは、引き続き自分たちで設計・運用する必要があります。 今後も監視や運用ルールを整備し、MAのデータ処理を安定して実行できる基盤を目指します。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、ECプラットフォーム部の権守です。普段はZOZOTOWNの会員基盤の開発に携わっています。 会員基盤では機能追加やリプレイスを継続しており、それに伴ってテストコードも増え続けています。テストが増えること自体は品質を維持する上で必要ですが、GitHub Actions上のテストにかかる時間も徐々に長くなっていました。具体的には、ワークフロー開始から完了までの実経過時間(以下、wall-clock)は13分前後となることが多い状態でした。 そこで、wall-clockを短縮するとともに、今後のテスト数の増加にも対応できるよう、テストのシャーディングを中心に実行方式を見直しました。 目次 はじめに 目次 テスト実行時間の課題 Controllerテストの並列化の検討 テストのシャーディング 複数のランナーへの分割 テスト単位でのシャーディング テスト関数の抽出 テストバイナリのビルド ビルドと実行の分離 ビルドキャッシュの再利用 SQL Serverの初期化の高速化 シャード間の実行時間の均等化 単純なラウンドロビンの課題 DB利用に基づくテストコストの推定 LPTによる割り当て ヒューリスティックの妥当性の確認 高速化によって顕在化した問題(MySQLのヘルスチェック) 改善結果 まとめ テスト実行時間の課題 会員基盤のテストは、wall-clockを短縮するため、以前からControllerとそれ以外(以下、Other)の2つに分けて実行していました。どちらもGoのパッケージ単位では並列実行されますが、Otherが多数のパッケージに分かれているのに対し、Controllerのテストは1つのパッケージに集中しています。そのため、Otherでは複数のパッケージが並列に実行される一方、Controllerのテストはほぼ直列に実行される状態でした。 ControllerとOtherの実行時間は同程度であり、2つに分けることで実行時間の偏りは抑えられていました。一方、テストの増加に伴ってどちらの実行時間も長くなり、wall-clockは13分前後となることが多くなっていました。 Otherは複数のパッケージに分かれているため、パッケージを複数のジョブへ振り分けることで、さらに並列化する余地があります。一方、Controllerのテストは1つのパッケージに集中しているため、パッケージを実行単位としている限り、ジョブを増やしてもこれ以上分割できません。 そこで、まずControllerのテストをパッケージより細かい単位で分割し、並列実行する方法を検討しました。 Controllerテストの並列化の検討 Controllerのテストをより細かい単位で並列実行するためには、DBを利用するテスト同士が干渉しないようにする必要があります。 既存のテスト実行方式では、複数のテスト用DBとロックファイルによる排他制御の仕組みを利用しています。各テストの実行時には、他のテストが使用していないDBを取得することで、複数のプロセスから実行した場合でも同じDBを同時に操作しないようにしています。 テスト単位の並列化であれば、 testing.T.Parallel を利用する方法も考えられます。しかし、今回のテストには同一プロセス内での並列実行を難しくする事情がありました。 アプリケーションでは接続先のDB名など、実行中に変化しない値をグローバル変数として保持しています。一方、テストでは前述した仕組みで取得したテスト用DBへ接続するため、この値を書き換えています。 DB自体はロックファイルによって排他制御できますが、同一プロセス内で複数のテストを並列実行すると、接続先DB名を保持するグローバル変数はテスト間で共有されます。そのため、あるテストが設定した接続先を別のテストが上書きしてしまう可能性があります。 そこで、 testing.T.Parallel ではなく、Controllerのテストを複数のグループに分割し、それぞれを別プロセスで実行しました。プロセスを分けることでメモリ空間も分離されるため、グローバル変数をテスト間で共有せずに並列実行できます。 しかし、GitHub Actions上で計測すると、1つのランナー内で複数のプロセスを並列実行しても期待したほど高速化せず、むしろ実行時間が長くなる結果となりました。 1つのランナー内でプロセスを増やしても、利用できるCPUやメモリなどのリソースは共有されます。より高性能なLarger Runnerを利用し、1つのランナー内の並列度を上げる方法も考えられます。一方、Larger Runnerはあらかじめ用意されたマシンサイズから選択するため、テスト数に応じてリソース量を細かく調整できません。 そこで今回は、通常のGitHub-hosted Runnerを複数利用し、ランナー数によって並列度を調整する方針としました。 テストのシャーディング 複数のランナーへの分割 複数のランナーへの分割には、GitHub Actionsのマトリックスを利用しました。 strategy : fail-fast : false matrix : shard : [ 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 ] ランナーを分けることで、複数のプロセスが1つのランナーのCPUやメモリを共有するのではなく、それぞれが別のランナーのリソースを利用してテストを実行できます。また、MySQLやSQL Serverなどのテスト環境もランナーごとに独立して用意されるため、テストを互いに独立した環境で並列実行できます。 マトリックスの定義を変更するだけでシャード数を増減できるため、今後テスト数が増えた場合にも並列度を調整できます。 テスト単位でのシャーディング 次に、各ランナーへどのテストを割り当てるかを決める必要があります。 Goではパッケージ単位でテストを実行することが一般的ですが、今回のリポジトリではパッケージごとのテスト数に大きな偏りがあります。パッケージ単位でシャードへ割り当てると、テスト数の偏りがそのままシャード間の処理量の差につながる可能性があります。 そこで、パッケージではなくトップレベルのテスト関数単位で分割することにしました。これまでControllerとOtherではパッケージ構成の違いから実行方法を分けていましたが、テスト関数単位で分割すればこの違いを意識する必要もありません。そのため、両者をまとめてリポジトリ内のテスト全体を同じ仕組みでシャーディングします。 テスト単位で分割する方法として、既存ツールの gotesplit を利用することも検討しました。しかし、今回は後述するDB利用状況を基にしたコストを分配に利用したかったため、テストの抽出からシャードへの割り当てまでを独自に実装しました。 テスト関数の抽出 テスト一覧の取得には go/ast と go/parser を利用しました。 Goの testing パッケージでは、テスト関数を以下の形式で定義しています。 func TestXxx(*testing.T) where Xxx does not start with a lowercase letter. 出典: testing package - testing - Go Packages (最終閲覧日:2026/09/15) この定義に従って、 *_test.go を構文解析し、トップレベルの関数宣言からテスト関数を抽出します。 file, err := parser.ParseFile( fset, path, nil , parser.SkipObjectResolution, ) if err != nil { return nil , err } for _, decl := range file.Decls { fn, ok := decl.(*ast.FuncDecl) if !ok || !isTestFunction(fn) { continue } tests = append (tests, testInfo{ name: fn.Name.Name, cost: estimateTestCost(fn), }) } isTestFunction では、引数や戻り値などのシグネチャに加えて、関数名がこの命名規則を満たしているかを確認しています。ここでは、関数名の判定部分を抜粋します。 func isTestName(name string ) bool { if !strings.HasPrefix(name, "Test" ) { return false } if len (name) == len ( "Test" ) { return true } r, _ := utf8.DecodeRuneInString(name[ len ( "Test" ):]) return !unicode.IsLower(r) } これにより、パッケージとトップレベルのテストの組み合わせを一覧として取得できるようになりました。 抽出したテストは、後述する推定コストと合わせて次のようなTSV形式で出力します。 ./app/adapter/http/controller TestGetMember 3 ./app/domain/model TestValidate 1 この一覧を各シャードへ振り分けることで、パッケージの大きさに依存せずテスト単位で並列実行できるようにしました。 テストバイナリのビルド ビルドと実行の分離 各ランナーで通常通り go test を実行すると、それぞれのランナーで同じソースコードからテストバイナリをビルドすることになります。 ビルド自体も各ランナーで並列に実行されるため、同じビルドを繰り返した分だけwall-clockが増えるわけではありません。一方、シャード数を増やすほど同じビルド処理が繰り返され、ランナーの総使用時間は増加します。今後テスト数の増加に合わせてシャード数を増やすことも考えると、ビルドコストまでシャード数に比例して増える構成は避けたいと考えました。 そこで、 go test -c を利用してテストバイナリのビルドと実行を分離しました。一度だけビルドしたテストバイナリを、GitHub Actionsのアーティファクト経由で各ランナーへ配布する構成です。 テストバイナリを一括生成している部分を抜粋すると、次のようになります。 packages =$ ( go list -f ' {{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}} ' ./... | grep -v ' ^$ ' ) GOOS =linux GOARCH =amd64 CGO_ENABLED = 0 go test -c -o /tmp/test-binaries/ $packages パッケージごとに別々の go test -c を起動するよりも、対象パッケージをまとめて渡した方がビルド時間を短縮できたため、ビルド処理の並列化はGoコマンドに任せています。 なお、同名のパッケージが複数存在すると、出力するテストバイナリのファイル名が重複し、 go test -c がエラーになります。そのため、該当するパッケージのみ一括ビルドの対象から外し、パッケージパスを基に一意なファイル名を付けて個別にビルドしています。 各シャードでは、割り当てられたテストだけを -test.run で実行します。 " $binary_path " -test .v -test . shuffle =on -test .run " ^( ${test_regex} )$ " これにより、テストバイナリのビルドを1回に集約し、ビルド済みのバイナリを各ランナーへ配布してテスト実行だけを並列化できるようになりました。 ビルドキャッシュの再利用 ビルドキャッシュがコールドな状態では、テストバイナリのビルドに数分かかっており、wall-clockを短縮する上で無視できない時間でした。同一環境で続けてビルドしたところ、 GOCACHE が利用される2回目には40秒前後まで短縮されました。この結果から、 GOCACHE を異なるワークフロー実行間でも再利用することで、テストバイナリのビルド時間を短縮できると考えました。 GitHub ActionsでGoのビルドキャッシュを再利用する場合、まず actions/setup-go のキャッシュ機能を利用する方法が考えられます。しかし、今回はソースコード変更後にも以前の GOCACHE をフォールバックとして利用したかったため、 actions/cache で別途管理しました。 キャッシュキーにはコミットSHAを含め、完全一致するキャッシュがない場合には、 restore-keys を利用して以前のコミットで保存したキャッシュを復元します。 - name : Restore test build cache uses : actions/cache@v4 with : path : ~/.cache/go-build key : test-go-build-${{ runner.os }}-${{ github.sha }} restore-keys : | test-go-build-${{ runner.os }}- Goのビルドキャッシュは入力に応じて有効性が判定されるため、一部のソースコードが変更されても、影響を受けないビルド結果は再利用できます。これにより、キャッシュ利用時のテストバイナリのビルドは40秒前後まで短縮できました。 SQL Serverの初期化の高速化 テストを複数のランナーへ分割することでテスト本体は並列化できますが、各ランナーではテスト開始前にSQL Serverのテスト用DBを作成する必要があります。この初期化処理はテストの並列化では短縮できないため、テスト本体の実行時間を短縮するにつれて相対的に無視できないコストになりました。 そこで、テスト用DBを作成済みのDockerイメージを管理するジョブをGitHub Actionsに追加しました。イメージはGitHub Container Registry(GHCR)へ保存し、各ランナーから利用します。DockerfileやSQL Serverの初期化処理に変更があった場合のみ再ビルドし、変更がなければ保存済みのイメージを再利用します。 これにより、各ランナーで繰り返していたDB作成処理を省略し、テスト環境の準備時間を短縮しました。 シャード間の実行時間の均等化 単純なラウンドロビンの課題 最初は抽出したテストを順番に各シャードへ割り当てるラウンドロビンを採用しました。この方法は単純で、各シャードのテスト件数もほぼ同数になります。 しかし、テストごとの実行時間は均一でないため、テスト数を揃えるだけではシャードごとの実行時間に偏りが生じます。 過去の実行時間を保存し、その値を基に分配する方法も考えられますが、履歴の保存や新規テストの扱いなど、仕組みが複雑になります。 そこで、まずはコードから静的に判定できる情報を使って、テストのおおまかなコストを推定することにしました。 DB利用に基づくテストコストの推定 テストの実行コストに影響する要素として、DBの利用に着目しました。会員基盤のテストでは、多くのテストでDBへのアクセスやテストデータのセットアップが発生します。 また、会員基盤ではオンプレミス環境からクラウド環境へのリプレイスを進めており、一部の処理ではMySQLとSQL Serverへのダブルライトを行っています。そのため、テストによって利用するDBも異なります。 今回のテストコードでは、DBを利用する際に共通のテストヘルパーを呼び出します。そこで、テスト関数を構文解析し、呼び出しているテストヘルパーからMySQLとSQL Serverの利用有無を判定することにしました。 実行時間を正確に予測することが目的ではないため、コストは単純なルールで設定しました。すべてのテストに基本コストとして1を設定し、MySQLまたはSQL Serverを利用する場合にそれぞれ2を加算します。これにより、DBを利用しないテストを1、いずれか一方を利用するテストを3、両方を利用するテストを5として扱います。 このコストは、DBを利用するテストが特定のシャードへ偏ることを避けるための相対的な重みとして利用します。 const ( baseCost = 1 mysqlCost = 2 mssqlCost = 2 ) func estimateTestCost(fn *ast.FuncDecl) int { cost := baseCost var usesMySQL bool var usesMSSQL bool ast.Inspect(fn.Body, func (node ast.Node) bool { call, ok := node.(*ast.CallExpr) if !ok { return true } name, ok := calledFunctionName(call) if !ok { return true } switch name { case "SetupDBTest" : usesMySQL = true case "SetupMSSQLFrontDBTest" , "SetupMSSQLEtcDBTest" : usesMSSQL = true case "SetupDoubleWriteDBTest" : usesMySQL = true usesMSSQL = true } return true }) if usesMySQL { cost += mysqlCost } if usesMSSQL { cost += mssqlCost } return cost } この結果、テストは次の3種類に分類されます。 コスト 分類 1 DBを利用しない 3 MySQLまたはSQL Serverのどちらかを利用する 5 MySQLとSQL Serverの両方を利用する LPTによる割り当て コストを推定した後は、Longest Processing Time First(以下、LPT)の考え方を利用し、推定したコストの大きいテストから順に割り当てます。 コストの高いテストから順番に処理する 現時点で合計コストが最も小さいシャードへ割り当てる アルゴリズム自体は単純なため、専用ツールは作らず sort と awk で実装しました。 sort -s -t $' \t ' -k 3,3nr " $ALL_TESTS_FILE " | awk \ -F ' \t ' \ -v shard_index = " $TEST_SHARD_INDEX " \ -v shard_total = " $TEST_SHARD_TOTAL " ' BEGIN { OFS = "\t" for (i = 0; i < shard_total; i++) { costs[i] = 0 } } { target = 0 for (i = 1; i < shard_total; i++) { if (costs[i] < costs[target]) { target = i } } costs[target] += $3 if (target == shard_index) { print } } ' 8つのシャードへ分割した結果は次のようになりました。 シャード テスト数 推定コスト 0 110 286 1 112 286 2 112 286 3 111 285 4 111 285 5 111 285 6 111 285 7 111 285 ヒューリスティックの妥当性の確認 このコストがテストの実行負荷を表す指標として利用できるか確認するため、GitHub Actionsのログからトップレベルのテストの実行時間を抽出し、推定コストごとに集計しました。 コスト テスト数 平均値 中央値 P75 P90 P95 最大値 1 384 0.051s 0.000s 0.000s 0.000s 0.010s 12.220s 3 313 0.949s 0.510s 1.040s 2.490s 3.350s 10.890s 5 192 2.336s 1.635s 3.270s 5.310s 6.760s 9.250s 中央値だけでなくP75、P90、P95でも、コストが高いグループほど実行時間が長くなる傾向を確認できました。一方、コストが1のグループにも12秒を超えるテストが存在するため、個々のテストの実行時間を正確に予測できるわけではありません。 次に、このコストを分配に利用することでシャード間の偏りを抑えられるか確認しました。テストの順序をランダムに変更し、ラウンドロビンとコストベースのLPTによる分配をそれぞれ10,000回シミュレーションしました。 指標 ラウンドロビン コストベースのLPT 最も遅いシャードの実行時間 P50 119.31s 115.61s 最も遅いシャードの実行時間 P95 137.62s 131.21s シャード間の実行時間差 P50 46.63s 39.19s シャード間の実行時間差 P95 71.44s 59.65s コストベースのLPTによって最も遅いシャードの実行時間が大幅に短縮されるわけではありませんが、シャード間の実行時間差は改善しました。この結果から、DB利用の有無から算出した単純なコストでも、重いテストが特定のシャードへ偏ることを抑えるための相対的な重みとして利用できると判断しました。 より精度の高い分配方法として、事前のテスト実行で得られた実測時間を利用する方法についても検討しました。比較のため、各テストの実行時間を事前に完全に把握できる理想的な条件を仮定してLPTで分配すると、最も遅いシャードの実行時間は約96秒となりました。一方、コストベースのLPTでは約111秒であり、その差は約15秒です。 実際には、事前のテスト実行で得られた実測時間と次回の実測時間が一致するとは限りません。また、実測時間を利用するには、実測値の保存や新規テストの扱いなども考慮する必要があります。実行時間を完全に把握できる理想的な条件でも改善幅はこの程度であることから、仕組みを複雑にして分配精度をさらに高める必要はないと判断しました。 一方で、今回採用したコストの算出方法を固定する必要はありません。現在はDB利用の有無を基にしていますが、今後の改善によってDB関連の処理が支配的でなくなった場合には、テストの実行時間を左右する要素に応じてコストの算出方法を見直すことができます。 高速化によって顕在化した問題(MySQLのヘルスチェック) ここまでの高速化を進める中で、テスト開始直後にMySQLへの接続が connection refused となり、稀にテストが失敗するようになりました。 docker compose up --wait を利用しており、MySQLのヘルスチェックも設定していたため、テスト開始時点ではMySQLの準備が完了している想定でした。 失敗時にコンテナの状態とヘルスチェック履歴、MySQLログを出力するようにして調査したところ、MySQLの起動とテスト開始が以下の順序になっていることが分かりました。 初期化用mysqld起動 ↓ ヘルスチェック成功 ↓ 初期化用mysqld停止 ↓ テスト開始 ↓ connection refused ↓ 本番用mysqld起動 原因はヘルスチェックで指定していた localhost でした。MySQLクライアントでは、UNIX系OS上で localhost を指定するとUNIXソケットを利用します。そのため、TCPポートをリッスンしていない初期化用mysqldに対してもヘルスチェックが成功していました。 一方、実際のテストは別のコンテナから db-test:3306 へTCP接続します。そこでヘルスチェックをTCP接続に限定しました。 healthcheck : test : [ "CMD-SHELL" , "MYSQL_PWD=$$MYSQL_PASSWORD mysqladmin ping --protocol=TCP -h 127.0.0.1 -u$$MYSQL_USER | grep -q 'mysqld is alive' && MYSQL_PWD=$$MYSQL_PASSWORD mysql --protocol=TCP -h 127.0.0.1 -u$$MYSQL_USER memberdb_$$DB_NUM -e 'SELECT 1 FROM prefectures LIMIT 1' > /dev/null" , ] この問題は、以前の構成でも発生し得る状態でした。しかし、以前はSQL Serverのテスト用DBの作成に時間がかかっており、その待ち時間の間にMySQLの本番プロセスが起動していました。前述した事前に初期化済みのSQL Serverイメージを利用することでこの待ち時間が短くなり、潜在していたタイミング依存の問題が顕在化しました。 改善結果 改善前は、GitHub Actions上でテストが正常に完了する場合でも13分前後かかることが多く、15分以上かかるケースもありました。さらに、実行に時間がかかった場合には、Goのパッケージ単位で適用される10分のテストタイムアウトに達するケースもありました。 改善後に同一条件で5回計測したところ、wall-clockの中央値は約7分となりました。実行タイミングによってwall-clockにばらつきがあるため、単一の実行結果ではなく複数回の中央値を用いて評価しています。 ここまでの改善を反映したテスト実行の全体像は、次のようになります。 まとめ 本記事では、増え続ける会員基盤のテストに対応するため、テストのシャーディングを中心にGitHub Actionsの実行方式を見直しました。今回行った主な改善をまとめると以下のとおりです。 改善 内容 テストのシャーディング GitHub Actionsのマトリックスを利用して8つのシャードへ分割 テスト単位の分割 パッケージ単位ではなくトップレベルのテスト単位で割り当て ビルドと実行の分離 go test -c でテストバイナリを1度だけ生成 アーティファクトによる配布 ビルド済みのテストバイナリを各ランナーへ配布 GOCACHEの再利用 キャッシュ利用時のテストバイナリのビルドを40秒前後まで短縮 SQL Serverの事前初期化 テスト用DBを作成済みのイメージをGHCRから再利用 コストベースのシャーディング DB利用を基に1・3・5のコストを静的に推定 LPTによる分配 推定コストの合計が小さいシャードへテストを順次割り当て 今回の改善では、wall-clockを計測しながら実行方式を見直し、テストのシャーディングだけでなく、ビルドやテスト環境の準備にかかる時間も短縮しました。また、DB利用の有無からテストの実行コストを推定する単純な方法でも、シャード間の偏りを抑えられることを確認しました。 今後もテストが増え続けることを想定し、wall-clockを継続的に計測しながら、必要に応じてシャード数や分配方法を見直していきたいと思います。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は以下のリンクからご応募ください。 corp.zozo.com
はじめに こんにちは、データシステム部推薦研究ブロックの寺崎( @f6wbl6 )です。 ZOZOTOWNでは、ユーザーと商品を同じベクトル空間で表現するembeddingをTwo-Towerモデルで学習しています。このembeddingを複数の推薦施策で共有できるEmbedding基盤を運用しており、過去のテックブログで紹介しました。 techblog.zozo.com このEmbedding基盤を活用することで、新しい推薦施策の立ち上げ時に新たなモデル構築やモデルのためのインフラ整備等をゼロから行う必要がなくなり、立ち上げ期間を大きく短縮できました。この基盤を使った汎用推薦システムの構築事例も以下の記事で紹介しています。 techblog.zozo.com 一方で、embeddingを利用する施策が増えるにつれて、モデルを改善してA/Bテストを実施するには利用側システムごとの改修が必要になり、改善そのものに着手しづらいという課題が生まれました。 本記事ではこの課題を解決するために構築した embedding配信基盤、REP(Recommendation Embedding Platform) を紹介します。REPは既存のEmbedding基盤を拡張したものです。バッチ推薦システム向けにBigQueryのテーブル関数をインタフェースとしてembeddingを配信し、利用側の実装を修正せずにA/Bテストを開始・終了する仕組みを提供します。複数のシステムに機械学習モデルの出力を配信している方の参考になれば幸いです。 目次 はじめに 目次 背景・課題 ZOZOTOWNにおけるembeddingの利用の広がり モデル改善のボトルネック REPの全体像 テーブル関数をインタフェースとする設計 A/Bテストの仕組み A/Bテストを開始するときの作業 バリアントに応じたembeddingの返却 リリース判断フロー 信頼性の担保 モデル切り替えの原子性 embeddingの品質保証 本番A/Bテストでの成果 結果と分析 改善サイクルの高速化 今後の展望 最後に 背景・課題 ZOZOTOWNにおけるembeddingの利用の広がり Embedding基盤が生成するembeddingは、ユーザーと商品の「相性スコア」を計算する共通部品として、以下のような機能で利用されています。 ホーム画面の各種パーソナライズ施策 メールマガジンなどのマーケティング配信のパーソナライズ施策 商品詳細画面のおすすめ商品のスコアリング このうち、ホーム画面のモジュール並び順と商品のパーソナライズは過去のテックブログで紹介しています。 techblog.zozo.com techblog.zozo.com モデル改善のボトルネック Embedding基盤のおかげで新しい推薦施策の立ち上げが効率化されましたが、embeddingを作り出すモデルそのものを改善するには大きな手間がかかる状態でした。従来の構成では各機能がembeddingのテーブルを直接参照しており、モデルを差し替えるA/Bテストのたびに参照先のテーブルを利用側で書き換える必要があったためです。 この改修コストの高さは、次の2つの事態につながりました。 モデル改善のデリバリーが遅くなる 古くなったモデルをいつまで運用し続ければよいか判断できない 1つ目は、Embedding基盤を利用する機能の数だけA/Bテスト用の改修と調整が必要になり、その分だけモデル改善そのものが着手しづらくなることです。仮に調整コストがなかったとしても、このままの構成でモデル改善を進めるとA/Bテストの開始時に複数のシステムでのリリース作業が必要になり、A/Bテスト実施工数も大きくなります。 2つ目は、どの機能がどのモデル種別を参照しているかを基盤側が把握できないために生じる事態です。利用状況が分からないと古いモデル種別をいつまでサービングしておけばよいのか判断できず、縮退時に確認漏れがあれば障害につながります。 そこで、 「利用側の実装修正なしでA/Bテストを開始・終了できる」「基盤側が利用状況を把握できる」 という要件を軸にembeddingの配信基盤を再設計し、REPを構築しました。 REPの全体像 REPの主な役割はユーザーと商品のembeddingをBigQuery上で一元管理し、スコアを事前に計算するバッチ推薦システムと、リアルタイムに処理するオンライン推薦システムの両方へ配信することです。オンライン推薦向けにはVector Search(旧Vertex AI Vector Search)へのインデキシングを行う仕組みもありますが、本記事ではバッチ推薦向けの仕組みに絞って紹介します。 バッチ推薦向けの配信では、 テーブル関数(TVF: Table-Valued Function) をインタフェースとしてembeddingを提供します。全体のシステム構成は以下のようになっています。 REPは大きく3つのレイヤーで構成されています。 レイヤー 実行基盤 役割 学習 Gemini Enterprise Agent Platform Pipelines(旧Vertex AI Pipelines) Two-Towerモデルを日次で学習し、評価指標が基準を満たしたモデルを登録する embedding生成 Cloud Run Jobs 学習済みモデルで全ユーザー・全商品のembeddingを日次で再生成する。加えて直近1時間に行動したユーザーと商品メタデータが変化した商品を1時間ごとに差分更新する 配信 BigQueryのTVF 利用側サービスへembeddingを返却する。A/Bテストのバリアント振り分けもこのレイヤーで行う 学習とembedding生成で実行基盤を分けているのは、必要とする特性が異なるためです。学習は日次1回のバッチ処理で、大きな計算リソースを必要とします。一方embedding生成、特に差分更新は毎時のような高頻度で軽量に起動できることが重要なため、Cloud Run Jobsを採用しています。将来的に差分更新をイベント駆動にする際、Cloud Run Serviceへ移行しやすいという狙いもあります。 テーブル関数をインタフェースとする設計 REPでは利用側サービスにembeddingのテーブルを直接参照させず、TVFだけをインタフェースとして公開しています。 利用側サービスは次のようなクエリでembeddingを取得しスコアリングします。 with users as ( select member_id, variant, embedding as user_embedding from `<project>.rep.get_user_embeddings`( ' <service_id> ' ) ) , products as ( select product_id, variant, embedding as product_embedding from `<project>.rep.get_product_embeddings`( ' <service_id> ' ) ) select u.member_id , p.product_id , 1 - ml.distance(u.user_embedding, p.product_embedding, ' COSINE ' ) as score from users as u inner join products as p on u.variant = p.variant 利用側は機能ごとに発行された service_id を渡すだけで、TVFではその service_id に登録されたモデル種別を解決し、有効化されているembeddingバージョンを返します。この構造により、利用側はモデル種別とembeddingバージョンを意識せずにembeddingを利用できます。 ここで、「モデル種別」は tt-v1 のようなモデルのアーキテクチャを表す識別子で、「embeddingバージョン」は同じモデル種別を再学習するたびに更新される世代を指しています。 TVF内部でのembeddingバージョン解決を支えているのが次の2つのテーブルです。 テーブル 役割 serving_config サービスごとに配信するモデル種別とA/Bテスト設定を保持する embedding_status embeddingのバージョンごとに有効化されているかどうかを管理する TVFの内部では、次の resolve_active_models というTVFが serving_config と embedding_status を結合しています。この関数が service_id からバリアント・モデル種別・有効化されているembeddingバージョンを解決します。 get_user_embeddings や get_product_embeddings は、この解決結果をもとにembeddingを返却します。 create or replace table function `<project>.rep.resolve_active_models`(service_id string) as ( select sc.variant , sc.model_type -- モデル種別を指す , es.embedding_version -- embeddingバージョンを指す from `<project>.rep.serving_config` as sc inner join `<project>.rep.embedding_status` as es on sc.model_type = es.model_type where sc.service_id = service_id and sc.is_active and es.state = ' ACTIVE ' ) serving_config の内容はリポジトリ上のYAMLで管理し、CIでBigQueryへ同期しています。この形にしたのは、A/Bテストの開始・終了をコードレビューのプロセスに乗せるためです。新しいモデルのembeddingを配信する準備が済んでいれば、A/Bテストの設定変更はYAMLを編集するプルリクエストで完結し、レビューと承認の記録が残ります。また利用側はTVFを経由してしかembeddingを取得できず、TVFは serving_config に登録された service_id にしか応答しません。そのため、YAMLファイルの一覧がそのままREPの利用状況の全体になり、基盤側が漏れなく把握できます。 A/Bテストの仕組み REPはembeddingの配信だけでなく、A/Bテストの開始から終了までの運用も担っています。ここでは、A/Bテストを実施する際にREPがどう動くかを説明します。 A/Bテストを開始するときの作業 A/Bテストの事前確認と結果分析の手順は、Claude Code向けのSKILLとして整備しています。事前確認のSKILLは、A/Bテストに必要なシステムとembeddingがサービング可能な状態になっているかを確認します。結果分析のSKILLは、テスト結果を集計し、リリース判断フローに基づいて判定し、分析レポートを作成します。 A/Bテストを開始するときに必要な作業は次の通りです。 challengerとするモデルを学習し、新しいモデル種別として登録する embedding生成ジョブでchallengerに対応するembeddingを生成して有効化する Claude Code向けのSKILLを使い、A/Bテストに必要なデータが揃っているかを確認する serving_config のYAMLへ ab_test セクションを追加し、プルリクエストでレビューを経てマージする マージ後、SKILLを使って対象サービスへ正しく配信されているかを確認する 手順4で編集するYAMLは次のような形です。オンライン推薦向けのVector Searchの設定など、説明に不要な項目は省略しています。 service_id : <service_id> default_model_type : tt-v1 ab_test : name : tt_v2_ab bucket : range : from : 0 to : 99 variants : - name : control model_type : tt-v1 ratio : 50 - name : treatment model_type : tt-v2 ratio : 50 default_model_type は、A/Bテストを実施していない期間と、 bucket.range の対象外のユーザーに配信するモデル種別です。 ab_test では、 bucket.range で対象とするユーザーバケットの範囲を、 variants でバリアントごとのモデル種別と配分比率を指定します。 ユーザーのバケットはユーザーIDと ab_test.name のハッシュ値から決定論的に計算しており、割り当てを保持するテーブルは持ちません。 ab_test.name をハッシュの入力に含めているため、同じ ab_test.name を複数の service_id のYAMLに設定すれば、サービスをまたいでも同じユーザーは同じバリアントに入ります。ただし variants の配分比率が揃っていないと、同じユーザーでもサービスによって別のバリアントに入ることがあるため、比率も揃える必要があります。 CIが serving_config をBigQueryへ同期した時点で、配信されるembeddingが切り替わります。この一連の作業の中で利用側のリリース作業は発生しません。 バリアントに応じたembeddingの返却 A/Bテストの実施中、TVFは次のように動作します。 get_user_embeddings はユーザーごとに割り当てられた1つのバリアントのembeddingだけを返す get_product_embeddings は全バリアントの商品embeddingを返す A/Bテストを実施していない期間は、TVFがすべての行を variant = 'default' として返します。 利用側は前述のクエリ例のとおり u.variant = p.variant で結合することで、ユーザーごとに正しいモデルの組み合わせでスコアを計算できます。異なるモデルのユーザーと商品のembeddingを掛け合わせてしまう事故は、 variant による正しい結合で防げます。裏を返せば、 variant で適切に結合することがREPを利用する上での契約条件です。 リリース判断フロー REPのA/Bテストは複数の機能に同時に影響するため、リリースの可否には全体と個別の両方の視点が必要です。サニティチェックとガードレールをパスしたことを確認したうえで、全体指標、推薦経由の指標の順に見て、有意な改善か悪化があればその時点でリリース可否を決めます。どちらにも変化がなければ、機能や画面単位の指標で判断します。複数のシステムから参照される基盤だからこそ、個別システムの増減を細かく追うよりも大局的な指標で意思決定することを重視して設計しています。REPの仕組み上はシステムごとにモデル種別を切り替えられますが、複数のモデルを運用し続けるとインフラコストがかかります。そのため、個別最適な状態を極力避ける意思決定フローにしました。 このリリース判断フローの実行とA/Bテストレポートの生成はClaude Code向けのSKILLで自動化しており、担当者に依存しない同じ分析手順で、A/Bテストごとの結果が得られます。 リリースを決定した場合は、 serving_config の default_model_type を新しいモデル種別に更新し、比較対象だった旧モデル種別のYAML登録を削除します。これにより、古いモデル種別をいつまでも稼働させ続ける状態を避けています。 信頼性の担保 モデル切り替えの原子性 embedding生成ジョブは日次で全ユーザー・全商品のembeddingを再生成し、新しいembeddingバージョンへ切り替えます。この切り替えで配信が一瞬でも壊れないよう、書き込み順序を次のように設計しています。 新バージョンのembeddingをテーブルへ書き込む embedding_status で新バージョンの有効化と旧バージョンの無効化を単一トランザクションで行う この書き込み順序とトランザクションにより「有効化されているのにembeddingが存在しない」「有効化されているバージョンが0件になる」という状態が発生しないことを保証しています。 embeddingの品質保証 REPで配信するembeddingはさまざまなシステムで利用されるため、その品質を保証することが必須です。REPでは日次の自動チェックで継続的に監視しています。監視内容は、行数・null率・ゼロベクトル率・鮮度・主キー重複といったデータの健全性と、 uniformityやalignment といったembeddingが表現力を保っているかを診断する指標です。これらを記録し、傾向の変化を確認します。さらに、embedding単体のチェックでは捉えられない劣化を検知するため、スコア分布の推移も日次で監視しています。 本番A/Bテストでの成果 REPを使った初回のA/Bテストとして既知のモデル実装バグを修正したchallengerモデルを用意し、2週間配信しました。対象はホーム画面のモジュール並び順パーソナライズなどREPを参照するバッチ推薦機能です。 初回のA/Bテストではモデルの大幅な性能向上よりも「REP上でA/Bテストを回しきり、リリース判断まで至れること」の実証を主目的としました。 結果と分析 初回のA/Bテストでは、サービス全体や推薦経由の指標を動かすほどの効果は見られませんでした。一方、機能単位の指標では一部で有意な改善を確認できました。あらかじめ判断基準を整備しておいたことで、全体指標で決着しない結果でも機能単位の指標へスムーズに判断を移せ、challengerモデルのリリースを決定しました。これにより、REP上でA/Bテストの実施からリリース判断まで一貫して行えることを実証できました。 改善サイクルの高速化 従来はA/Bテストのたびに利用側システムの実装変更や関係者との調整が必要で、モデルの改善案があってもテストの計画を立ち上げられずにいました。REPの導入後は、利用側システムを改修せずに複数の機能で同時にA/Bテストを実施できるようになりました。 関係者との調整も必要最低限になったことで、REPの本番導入から記事公開までの約2か月で4本のA/Bテストを実施できました。加えてA/Bテストの準備からレポート作成までの作業フローも標準化されているため、モデル改善のアイデアを持つMLエンジニアであれば誰でもA/Bテストを回せる環境になりました。 REPの価値は、すでに実績のあるモデル改善を他の機能へ展開できることにも表れています。マーケティングオートメーション(MA)関連の推薦を担当するチームは、商品画像のembeddingを特徴量に加えたモデル改善で、メール配信施策のクリック率・購入率・経由売上を有意に改善していました。詳細は 過去のテックブログ記事 で紹介しています。このモデルはすでにREPへ取り込み済みで、前述の4本のA/Bテストにも含まれています。1つのチームが実施した改善施策を、別のチームの機能にもそのまま届けられるようになりました。 今後の展望 REPによってembeddingの配信とA/Bテストは標準化できましたが、基盤としての真価はこれから発揮されると考えています。REPが配信するembeddingは、複数の推薦タスクに共通するFoundation Modelの役割を担えるはずです。今後はユーザーの長期的な嗜好や商品の汎用的な表現を獲得できるよう、最適化の方針を見直していきます。現時点ではREPを利用するシステムの多くが、embeddingの類似度だけでスコアリングしています。REPのembeddingを特徴量として扱い、システムのKPIに応じてembeddingを変換するquery encoderを用意することが、次の進化の方向性です。 また、現在のembedding品質保証では、データとしての健全性やembedding空間の構造は捉えられますが、推薦タスクへの適合度までは評価できていません。もちろん、利用先の機能側でKPIやモデルメトリクスをモニタリングすることが前提です。ただ、特徴量としてのユースケースが増えるほどembedding自体の良さも担保すべきだと考えており、この評価軸の整備が次のテーマです。 モデル改善のサイクルをさらに人手から切り離すことにも取り組んでいます。エージェントによる開発では、試行を繰り返すループと結果を客観的に検証できる仕組みの2つが必要で、MLモデルの改善サイクルはこの条件を満たしやすい領域です。参考になる事例として、YouTubeの Self-Evolving Recommendation System やMetaの Ranking Engineer Agent (REA) があり、私たちも同様にAIエージェントをベースとした実験基盤の構築を検討しています。REPの導入によって「良いモデルがあれば誰でもA/Bテストを実施できる」環境は整いました。アイデア出し、実装、実験と評価を繰り返すループのうち、少なくとも実装から実験と評価まではエージェントで実行できるため、まずはこの区間で人間が律速にならない状態を目指しています。 最後に 本記事では、ZOZOTOWNの推薦システムを支えるembedding配信基盤REPを紹介しました。1つの基盤でモデルの改善効果を複数の機能へ波及させ、その効果をA/Bテストで検証しながら改善サイクルを回し続けられる環境を作れたことが、REPの一番の成果だと考えています。ここからは配信基盤としての機能拡充にとどまらず、embeddingそのものの質を高める取り組みや、実験サイクルを自動で回し続ける仕組みづくりに力を入れていきます。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは。データシステム部・MA推薦ブロックの伊藤( @rabbit_x86 )です。私たちのチームでは、ユーザーに最適な配信を届けるために、LINE・Push通知・メールなどのマーケティングオートメーション(MA)に関する推薦システムを開発・運用しています。 本記事では、推薦システムの運用で発生するアラート対応をAIで自動化した取り組みを紹介します。人間の対応フローを「障害分析・対応の考案・敵対的レビュー・実行」の4つのタスクに分解してAIで再現し、対応にかかる工数とコンテキストスイッチを減らすことを狙います。本番環境で安全に動かすため、AIの権限と実行できる操作を段階的に絞り込むハーネスも組み込んでいます。この仕組みの導入後、約3週間で本番環境の8件のアラートをAIが自動で解決しました。 目次 はじめに 目次 背景と課題 アラート対応によるコンテキストスイッチ AIを活用しても残っていた課題 課題に対するアプローチ 業務内容を分解する AIに任せるタスクと人間に残すタスク アラート対応を自動化するシステム 既存の監視の仕組み システムの全体の流れ AIによる自律的なアラート対応の4つのタスク 敵対的レビューの設計 採用基準の明文化による改善 ハーネスによる安全設計 AIの操作が届く範囲を権限で絞る 実行できるコマンドをallowedToolsで限定する スクリプトで操作を細かく制御する 運用事例と効果 再実行で解決した事例 効果と課題 まとめと今後の展望 最後に 背景と課題 アラート対応によるコンテキストスイッチ 私たちのチームは、メール配信のコンテンツなどをパーソナライズするための推薦システムを作っています。この推薦システムは、 Agent Platform Pipelines (旧Vertex AI Pipelines)のパイプラインとして実装しています。パイプラインが失敗すると最新の推薦結果を配信できなくなるため、アラートには早急に対応する必要があります。 ただ、そのアラート対応は割り込みで発生し、その度に担当者は他作業を止めて対応に当たる必要があります。対応では、エラーログから原因を調査し、必要に応じて再実行や修正などの処置をします。このコンテキストスイッチにより、元の作業に使えたはずの時間は失われ、作業に戻るための切り替え時間も必要になります。 AIを活用しても残っていた課題 私たちはすでに、AIを活用してアラート対応を効率化していました。エラーログをAIに与えて原因を調査させ、対応方針を検討させるところまでは日常的に行っており、1件あたりの対応は楽になっていました。ただ、調査結果の確認や方針の承認、再実行や修正の判断は人間のままで、アラートのたびに時間と集中を割く構造は変わっていませんでした。 さらに、対応の証跡はSlackのスレッドに残す程度で、ナレッジとして蓄積されていないという問題がありました。そのため、同種のアラートでも過去の調査結果を再利用できず、同じ調査を繰り返していました。また、対応の進め方は担当者ごとの判断に任されており、質とスピードもばらついていました。 そこで、アラート対応の調査から実行までをAIが自律的に行い、その証跡を残して次の対応で参照できるシステムを構築することにしました。 課題に対するアプローチ 業務内容を分解する 業務をAIに任せるにあたり、まず業務内容を人間の判断や作業を抽象化したタスクへ分解することを考えました。その結果、どのような業務にも当てはまる「タスクの発見」から「知見の蓄積」までの7つの一般的な単位に整理できました。それぞれの内容は次のとおりです。 タスク 一般的な内容 タスクの発見 アラート・Issue・定常業務の中から、対応すべきタスクを見つける タスクの理解 背景・制約・完了条件を理解し、整理する 解決方法の考案・承認 解決方法を考え、実行するかを判断する 実行と再試行 解決方法を実行し、失敗した場合は考案からやり直す 最終確認 完了条件を満たしたかを自身で確認する 第三者確認・完了 担当者以外が内容をレビューし、完了を判断する 知見の蓄積 成功・失敗から得た学びを、次のタスクに活かす AIに任せるタスクと人間に残すタスク 次に、分解したタスクを1つずつAIに置き換えることで、AIが自律的に業務を進める状態を目指しました。この方法なら、AIへ任せる範囲を段階的に広げつつ、意思決定を人間に残す部分も選べます。どのタスクをAIに置き換えるかは、次のように決めました。 タスク アラート対応での作業 担当 理由 タスクの発見 アラートが鳴り、対応が必要なことを認識する 人間 AIに対応させるアラートの選別を人間が握るため タスクの理解 アラート対象のシステムとエラー内容を理解し、完了条件を整理する AI 対応の中核であり、人間の時間と集中を奪っていた部分のため 解決方法の考案・承認 過去の対応事例を参考に原因への対応方法を複数考え、最適な案を選ぶ AI 同上 実行と再試行 選んだ案を実行し、失敗した場合は別の案を試す AI 同上 最終確認 完了条件を満たしているかを確認する 人間 Issueに残る対応記録を見て、解決したことを人間が確認するため 第三者確認・完了 Pull Request(PR)のレビューを依頼して第三者に確認してもらい、完了を判断する 人間 完了の判断とIssueのクローズを人間が担い、責任の所在を人間に置くため 知見の蓄積 対応の成功・失敗を知識として蓄え、チームにも共有する AI GitHub Issueへのコメント蓄積を自動化するため このように、対応の開始と終了は人間が判断します。人間が関与しないと、仕組み自体が止まっていても気づけず、障害を放置してしまうためです。加えて、開始を人間が判断することで、対応不要な通知や検証用のアラートに自動対応が走ることも防げます。 アラート対応を自動化するシステム 既存の監視の仕組み 前提として、アラートの検知と通知の仕組みはすでに運用されていました。パイプラインは、 Cloud Scheduler によりスケジュール実行されています。その失敗は、 Cloud Run functions が実行状態のポーリングで検知し、パイプラインのURLとともにSlackのチャンネルへ通知します。従来は、この通知を見た人間がアラート対応を始めていました。 システムの全体の流れ アラート対応は、Slack通知を受けたところから、次の図の流れで進みます。図中の丸数字は、表のステップ1〜3に対応します。 ステップ 内容 担当 対応するタスク 1 Slack通知のリンクをクリックし、アラート情報が本文に事前入力された画面から専用ラベル付きのGitHub Issueを作成する 人間 タスクの発見 2 専用ラベル付きIssueの作成をトリガーに Claude Code Action (CCA)が起動し、障害分析・対応の考案・敵対的レビュー・実行の4つのタスクを順に実行して、結果をIssueにコメントする AI タスクの理解、解決方法の考案・承認、実行と再試行、知見の蓄積 3 対応の記録を確認し、解決していればIssueをクローズする 人間 最終確認、第三者確認・完了 ステップ1では、Slack通知の末尾にあるAuto-Triage欄のTriggerリンクからIssue作成画面を開き、Issueを作成します。実際のSlack通知は次のような形です。 開いたIssue作成画面では、画面右のLabelsに2つの専用ラベルが事前に設定されています。alert-auto-responseは、自動対応の起動トリガーとなるラベルです。pipeline-failureは、次回以降のアラート対応で過去の類似Issueを検索する際の目印となるラベルです。 ステップ2では、4つのタスクが順に実行され、分析内容・対応案・レビュー結果・実行結果といった各タスクの出力がIssueのコメントとして残ります。 ステップ3で何を確認してクローズするかは、実行タスクの対応内容によって決まります。再実行であれば、パイプラインの正常終了を人間が確認した時点でクローズします。修正PRであれば、担当者がPRの内容を最終確認し、さらにレビューによる第三者確認を経てからクローズします。 対応を進めて記録を残す場所をGitHub Issueにしたのは、チームの タスク管理とAI活用による工数削減の計測 をIssueで行っており、アラート対応も同じ管理に載せたかったためです。対応の記録がIssueに蓄積されるため、次のアラート対応でAIが過去の類似Issueを検索して再利用できる利点もあります。AIの実行環境であるCCAは、AIコーディングエージェントである Claude Code を GitHub Actions のワークフローとして実行するための公式アクションです。Issueの作成をトリガーに起動できることと、チームの運用実績があることから選びました。 AIによる自律的なアラート対応の4つのタスク アラート対応は「障害分析・対応の考案・敵対的レビュー・実行」の4つのタスクによって構成され、Issueの作成をトリガーに起動した1回のCCAの実行の中で順に進みます。各タスクは結果をIssueのコメントとして出力し、次のタスクはそのコメントを入力として受け取ります。 各タスクは、 skill と呼ばれるMarkdownの指示書として定義しています。全体像は次のとおりです。 図の各タスクが行うことは、次のとおりです。 タスク skill 行うこと 障害分析 diagnose-pipeline-failure パイプラインの実行情報・エラーログ・実装コードを読み、問題・原因・解決策の候補を洗い出す 対応の考案 propose-pipeline-remedies 分析結果をもとに、再実行や修正PRの作成といった対応案を根拠つきで挙げる 敵対的レビュー review-pipeline-remedies 対応案の粗を意図的に探して審査し、実行案を承認する。※ Claude Codeの ベストプラクティス で推奨されているレビュー方法 実行 review-pipeline-remedies 承認された案を実行する なお、CCAが実行するClaude Codeへ渡すプロンプトには、各タスクの内容を直接書かず、skillを順に呼び出す指示だけを書いています。実際のプロンプトは次のとおりです。 以下の skill を順に実行してください: 1. /diagnose-pipeline-failure <Issue番号> 2. 1 がエスカレーション (needs-human) で終了した場合はここで終了。 そうでなければ /propose-pipeline-remedies <Issue番号> 3. /review-pipeline-remedies <Issue番号> 各 skill の進め方・出力フォーマット・エスカレーション基準はすべて skill 側に定義されています。 プロンプトにあるエスカレーションは、AIが対応を打ち切って人間へ引き継ぐ終了経路です。エラーログを取得できない場合や、上流チームとの調整や課金に関わる変更のように人間の判断が必要な場合、AIはタスクを問わずエスカレーション用のラベル(needs-human)を付けて対応を停止します。 敵対的レビューの設計 敵対的レビューの設計は、Claude Codeのベストプラクティスにある Add an adversarial review step の項を参考にしました。この項では、レビュアーへ成果物と判定基準だけを渡し、そこに至る経緯や背景といったコンテキストの影響を受けないレビューが推奨されています。 そこで、案を考える「対応の考案」と案を承認する「敵対的レビュー」を別のタスクに分けました。敵対的レビューには、元の会話とは別のコンテキストで動くエージェントである subagent を使います。このsubagentに渡すのは、Issueに書かれた障害分析コメントと対応案コメント、そしてレビューの指示の3つだけです。この指示は案を中立に評価させるのではなく、否定する理由を探させる敵対的な内容にしました。 採用基準の明文化による改善 ただし、この敵対的レビューは最初から狙いどおりに機能したわけではなく、本番導入前の検証では採用すべき案が却下され続けました。たとえば、修正PRを作成する案に対して、敵対的レビューは「修正が本番環境へ影響を与えないか確認できない」という懸念を挙げて却下しました。修正PRはdraftでのみ作成され、マージも人間が担うため安全なはずの案でも、こうした曖昧な懸念による却下やエスカレーションが続きました。 実際、 Add an adversarial review step には、粗を探すよう指示されたレビュアーは成果物に問題がなくても何かを報告する、という注意書きがあります。 A reviewer prompted to find gaps will usually report some, even when the work is sound, because that is what it was asked to do. そこで、採用の基準を明確に定めました。却下してよいのは、次の2つを両方示せた場合だけです。 案を実行した場合に本番環境へ与える悪影響といった、実害が発生するシナリオを具体的に示せること そのシナリオが、後述するハーネスの制約で防げず実際に起こりうると示せること どちらか一方でも示せなければ、承認します。実際にレビューの指示へ書いた判定手順は次のとおりです。 1. この案を実行した場合に起こりうる、具体的な実害シナリオを考える (「念のため」「本番影響が心配」は実害シナリオではない) 2. そのシナリオが、再実行の上限・draft強制・人間によるマージ・操作対象の限定(同一パイプライン・対象リポジトリのみ)の いずれによっても防がれないことを示す 3. 1と2の両方を具体的に示せた場合のみ却下する。示せなければ承認する 4. 例外として、実行手順自体に技術的な矛盾がある場合は、その矛盾を指摘したうえで却下してよい 5. 「前提条件が完全には確認できていない」など、案の安全性と無関係な懸念は単独では却下根拠にならない 6. エスカレーション案を承認するのは、他のすべての案が却下された場合のみ これによって、直後の検証ではAIがパイプライン内のバグを修正するPRの作成まで完了できました。敵対的レビューを機能させるには、否定する理由を探させるだけではなく、却下する際の基準を設けることが重要でした。 ハーネスによる安全設計 アラート対応をAIが自律的に進めるには、Google CloudやGitHubといった外部サービスへアクセスする権限をAIに与える必要があります。ただ、与えた権限は安全に扱わせなければなりません。ここで鍵になるのがハーネス(harness)です。ハーネスとは、AIモデルの外側にあって、モデルが自律的かつ信頼できる形で仕事をこなせるようにする仕組み全体のことです。具体的には、使えるツールや権限の定義・実行環境・コンテキストの引き継ぎ・テストやレビューによるフィードバックループ・ルールの機械的な強制などを指します。 OpenAI や Anthropic も、エージェント設計の文脈でハーネスを整理しています。本節では、このうち安全に関わる部分である、権限とツールの制限、そしてスクリプトによるルールの強制を説明します。 今回、AIが外部に対して行う操作は、次の3つです。 目的 操作 タスク 種別 エラーの調査 パイプラインの実行情報・エラーログ・実装コードの取得 障害分析 読み取り(Google Cloud・GitHub repository) 対応の証跡の記録 Issueへのコメント投稿・ラベル操作 すべてのタスク 書き込み(GitHub Issue) 対応の実行 パイプラインの再実行・修正PR(draft)の作成 実行 書き込み(Pull Request・Google Cloud) これらの操作を、3つの段階に分けて安全に扱っています。 AIの操作が届く範囲を権限で絞る 1つ目の段階は、AIに与える権限です。CCA専用の認証の仕組みを用意し、そこへ必要最小限の権限だけを付与しています。具体的には次のとおりです。 対象 認証に使う仕組み 付与した権限 Google Cloud Service Account パイプラインジョブの参照・作成とログの読み取りのみ GitHub GitHub App Issueへの書き込み(コメント投稿・ラベル操作)、リポジトリの読み取り、修正PR作成に必要なブランチ・PRの作成のみ この権限により、AIはService Accountを通じたパイプラインの再実行や、GitHub Appを通じたIssueへの書き込みなどを実行できます。一方で、権限を絞るだけでは不十分です。GitHub Actionsの実行環境には、Service AccountとGitHub Appの認証情報が載っています。そのため、gcloudコマンドやcurlを使えば、AIは権限の範囲内のAPIを直接呼び出せてしまいます。たとえばジョブの作成権限を使えば、失敗したジョブをそのまま複製するのではなく、パラメータやコンポーネントを書き換えた別のジョブを本番環境で起動できてしまいます。 実行できるコマンドをallowedToolsで限定する この権限の範囲内の操作を縛るために、2つ目の段階では実行できるコマンドを限定します。CCAのワークフローでは、実行を許可するツールを allowedTools という設定で列挙し、列挙していないコマンドは権限があっても実行できません。シェルの任意コマンド、gcloudやbqコマンド、curl、生のAPI呼び出しはここで塞がれます。 実際のワークフローに書いている設定の一部は次のとおりです。 claude_args : | --allowedTools 'Bash(python3 .claude/skills/diagnose-pipeline-failure/scripts/fetch_pipeline_job.py:*)' 'Bash(python3 .claude/skills/diagnose-pipeline-failure/scripts/fetch_error_logs.py:*)' 'Bash(python3 .claude/skills/diagnose-pipeline-failure/scripts/fetch_repo.py:*)' 'Bash(python3 .claude/skills/review-pipeline-remedies/scripts/rerun_pipeline.py:*)' 'Bash(python3 .claude/skills/review-pipeline-remedies/scripts/create_fix_pr.py:*)' 'Bash(gh issue view:*)' 'Bash(gh issue comment:*)' 許可しているのは、スクリプトと、Issueのコメントを読み書きする GitHub CLI のgh issue系サブコマンドだけです。このスクリプトの中身を、次の3つ目の段階で説明します。 スクリプトで操作を細かく制御する さらに3つ目の段階として、スクリプトで実行内容を細かく制御しています。コマンドを何回・どの対象に使えるかという条件を、スクリプトのコードへ埋め込んでいます。スクリプトを使うタスクと内容は次のとおりです。 種別 スクリプト 使うタスク 内容 読み取り fetch_pipeline_job.py 障害分析 対象の失敗ジョブに限定して、詳細(失敗タスク・エラー)を読み取る 読み取り fetch_error_logs.py 障害分析 対象の失敗ジョブのエラーログだけを、 Cloud Logging から整形して読み取る 読み取り fetch_repo.py 障害分析 パイプラインを実装しているリポジトリに絞って、コードを読み取り専用で取得する 書き込み rerun_pipeline.py 実行 失敗したジョブの複製のみを、同一パイプラインに2回まで再実行する 書き込み create_fix_pr.py 実行 修正PRをdraftでのみ作成する このうちrerun_pipeline.pyは、失敗したジョブの設定を複製し、新しいジョブとして再実行するスクリプトです。複製したジョブにはrerun_count=1のような再実行の回数を表すラベルを付け、次の再実行でその値を読み取って上限を判定します。具体的には次のように、上限の2回に達していれば実行前に拒否し、エスカレーションへ進ませます。 MAX_RERUN_COUNT = 2 rerun_count = int (source_labels.get( "rerun_count" , "0" )) if rerun_count >= MAX_RERUN_COUNT: print ( f "REJECTED: rerun limit reached (rerun_count={rerun_count}). " "これ以上の自動rerunは禁止。needs-humanでエスカレーションしてください。" , file=sys.stderr, ) return 1 このように、条件を満たさない実行はAIの判断にかかわらずスクリプトがエラーで拒否し、エラーを受け取ったAIはエスカレーションなどの別の対応へ進みます。 権限が「どこまで届くか」を、allowedToolsが「どのコマンドを実行できるか」を、スクリプトが「どの条件とどの対象なら操作してよいか」を制限します。この3つの段階を重ねることで、本番環境でAIが暴走しないように設計しています。 運用事例と効果 ここでは、本番環境でAIが解決したアラートのうち、再実行で解決した1件の事例を紹介します。 再実行で解決した事例 対象のアラートは、Google Cloud側の一過性のエラー(internal error)でパイプラインが失敗したものです。このとき各タスクが実際に行った対応は次のとおりです。 タスク 内容 障害分析 エラーメッセージは「Internal error happened.」のみで、詳細なログを取得できないことを確認。これを一過性のエラーの兆候と推定し、原因としてIssueにコメント 対応の考案 分析コメントを受けて過去の類似Issueを検索し、同じパターンの失敗が再実行で復旧している実績を発見。これを根拠に再実行案を提案 敵対的レビュー 提案された再実行案を審査し、ハーネスの制約で防がれない実害シナリオがないことを確認して承認 実行 承認された案をハーネス経由で再実行し、パイプラインは正常に完了 一連の対応は、Issueの作成から再実行の開始まで約8分で完了しました。人間が行っていた「ログを見て、過去の事例を思い出し、再実行で直りそうだと判断する」という思考の流れを、AIがそのまま再現できました。 効果と課題 本システムの目的は、アラート対応のたびに発生していたコンテキストスイッチと対応の時間を、AIで減らすことでした。ただ、この効果は本番環境でまだ実証できていません。 実証できたのは、アラート対応の自動化です。導入から約3週間で8件のアラートをAIが解決し、調査から実行までを人間が担わずに完了できました。また、対応は毎回同じskillの手順で進むため、担当者ごとに異なっていた進め方も統一されました。ただし、8件はいずれも再実行で解決できる、もともと対応コストの小さい障害でした。そのため、導入の前後でコンテキストスイッチや対応の工数の変化は小さいです。 本システムの真価が出るのは、調査や判断に時間のかかる障害です。本番環境ではまだこの種のアラートは発生していませんが、本番導入前の検証では、パイプライン内のバグに対して原因の特定から修正PRの作成までをAIが完了できています。ただ、この種のアラートは頻発せず、導入から日も浅いため、本番環境での効果の実証がこれからの課題です。 まとめと今後の展望 本記事では、推薦システムのアラート対応を題材に、人間の業務をAIへ置き換えた取り組みを紹介しました。ポイントは2つです。 業務を人間の思考フローに沿って分解してAIで再現することで、どこをAIに任せてどこを人間に残すかをタスク単位で設計する 権限・allowedTools・スクリプトの3段階からなるハーネスでAIに制約を与えることで、本番環境でも安全に実行させる 一方で、運用を通じて課題も見えてきました。今後は次の2点に取り組みます。 調査や判断に時間のかかる障害への対応で、本システムによるコンテキストスイッチと対応時間の削減を定量的に実証する 人間が担っている対応の開始と終了まで自動化を広げ、アラート対応をAIに完全移譲する アラート対応は一例にすぎません。ほかの業務でも、フローを分解して一つひとつAIに置き換えていけば、AIが自律的に業務を進める状態へ近づけるはずです。業務をAIに任せることを考えている方の参考になれば幸いです。 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、ブランドソリューション開発本部FAANS部の田中です。ショップスタッフの販売サポートツール「FAANS」のAndroidアプリを担当しています。本記事では、FAANSアプリでFragmentベースのナビゲーションからNavigation Composeへの移行を、機能開発と並行してモジュール単位で進めた取り組みを紹介します。 約半年で、8つの機能モジュールのうち5つの移行が完了しました。ゴールは、 画面遷移の管理をActivityに1つ置いたNavHost(Navigation Composeで画面遷移を管理するComposable)へ集約する ことです。ただし機能開発とリリースを止めるわけにはいかないため、Fragmentを残したまま モジュール単位で移行を積み上げる 形を取っています。以下、この進め方と移行の中で見えてきた課題、今後の見通しを書きます。 目次 はじめに 目次 背景・課題 マルチモジュール構成と画面遷移 UIのCompose化と二重管理 移行対象の規模 移行方針 ゴールと段階 共存させるための選択肢 Navigation 3ではなくNavigation Composeを選んだ理由 1. モジュール単位の移行 1-1. 採用した構造 未移行のFragmentへの橋渡し 1-2. 進め方の整備 1-3. 移行前の遷移の記録 1-4. 画面の移行 ダイアログの移行 遷移をイベントで通知しているViewModelの移行前対応 Fragmentのライフサイクルとの対応 1-5. PRの分け方 2. 親子関係にあるモジュールの統合 2-1. NavGraphBuilder拡張としてのグラフ分離 2-2. 一時的なモジュール間依存の扱い 3. Activity直下の単一NavHostへの統合 3-1. ルート側の現状 3-2. 統合の見通し 移行の中で見えてきた課題 課題1:戻るボタンの連打で画面がホワイトアウトする 課題2:写真ピッカーから戻った直後の遷移が発火しない 課題3:未移行の画面を挟んでもバックスタックは失われなかった 課題4:ボトムシートを開き直しても前の内容が表示される 課題5:画面遷移テストを書かずにどう担保するか 移行の進捗と現時点の効果 最後に 背景・課題 マルチモジュール構成と画面遷移 FAANSアプリは、モジュールごとにナビゲーショングラフを持つマルチモジュール構成です。各モジュールのグラフをルートのグラフから <include> で取り込み、遷移先はすべてFragmentとして定義しています。モジュールは機能単位で分けることを意図していますが、歴史的な経緯でモジュールの境界と機能の境界が一致していない箇所もあり、それ自体が技術的負債の1つになっています。それでも、ナビゲーショングラフと画面遷移のまとまりとしてはモジュールが単位として機能しているため、今回の移行は このモジュールを単位として 進める方針を取りました。 ナビゲーショングラフ(XML)は次のように定義しています。ルートのグラフが各モジュールのグラフを <include> で取り込み、モジュールのグラフには画面の <fragment> と遷移の <action> が並びます。 <!-- ルートのグラフ: 各モジュールのグラフを取り込む --> <navigation android : id = "@+id/nav_root" app : startDestination = "@id/SplashFragment" > <fragment android : id = "@+id/SplashFragment" android : name = "...SplashFragment" /> <include app : graph = "@navigation/module_a" /> <include app : graph = "@navigation/module_b" /> </navigation> <!-- モジュールAのグラフ: 画面ごとに fragment と action を定義する --> <navigation android : id = "@+id/nav_module_a" app : startDestination = "@id/SettingsFragment" > <fragment android : id = "@+id/SettingsFragment" android : name = "...SettingsFragment" > <action android : id = "@+id/action_Settings_to_Account" app : destination = "@id/AccountFragment" /> </fragment> <fragment android : id = "@+id/AccountFragment" android : name = "...AccountFragment" > <action android : id = "@+id/action_Account_to_ChangeEmail" app : destination = "@id/ChangeEmailFragment" /> </fragment> <fragment android : id = "@+id/ChangeEmailFragment" android : name = "...ChangeEmailFragment" /> </navigation> UIのCompose化と二重管理 XMLのレイアウトをJetpack Composeに書き換える作業を続け、機能モジュールの画面はすべてCompose化が完了しました。残っていたのが画面遷移の層です。画面遷移がFragmentベースのままなので、Compose化した画面も遷移先として登録する必要がありました。そのために「 ComposeView を setContent するだけのFragment」を1画面ごとに用意していました。あわせて、ナビゲーショングラフに <fragment> と <action> も書き続ける必要がありました。 // 移行前: 中身はComposeなのに、遷移のためだけに残っているFragment @AndroidEntryPoint class ExampleFragment : Fragment() { private val viewModel: ExampleViewModel by viewModels() private val args: ExampleFragmentArgs by navArgs() // 引数はXMLの <argument> から生成されたクラスで受け取る override fun onCreateView(...): View = ComposeView(requireContext()).apply { setContent { FaansTheme { ExampleRoute( id = args.id, viewModel = viewModel, onNavigateBack = { findNavController().popBackStack() }, ) } } } } 画面の実装はComposeに移っているのに、遷移まわりは3か所に分かれたままです。遷移先の定義はナビゲーショングラフに、遷移の実行はFragmentの findNavController() に書きます。画面間で渡す引数は、XMLの <argument> から生成されるSafe Argsのクラスで受け取ります。Composeの画面を1つ足すたびにFragmentとXMLの両方を触る「 二重管理 」が積み上がっていました。 移行対象の規模 項目 移行前 ナビゲーショングラフ(XML) 12ファイル(機能モジュール分9、アプリ共通3) 遷移先( <fragment> ) 90 遷移先( <dialog> ) 50 画面のCompose化をやり切り、ようやく画面遷移の層に手を付けられる状態になりました。公式の移行ガイドも、Navigation Composeへ切り替える前提として「 遷移先がすべてComposableであること 」を挙げています。 You can migrate to Navigation Compose once you're able to replace all of your Fragments with corresponding screen composables . Screen composables can contain a mix of Compose and View content, but all navigation destinations must be composables to enable Navigation Compose migration. Until then, you should continue using Fragment-based Navigation component in your interop View and Compose codebase. — Migrate Jetpack Navigation to Navigation Compose — Android Developers 要約:すべての遷移先がComposableになるまでは、Fragmentベースのナビゲーションを使い続ける必要がある。 一方で、アプリ全体を一度に切り替える選択肢はありませんでした。 機能開発と並行して少しずつ、しかし後戻りなく進める方法 が必要でした。 移行方針 ゴールと段階 Navigation Composeでは、 NavHost という1つのComposableが画面の切り替えとバックスタックを管理します。公式の基本構成では、これをActivityに1つ置きます。 ゴールは、Activity直下に1つのCompose NavHostを置き、XMLのナビゲーショングラフと NavHostFragment をなくすことです。 そこまでを一気には進められないので、3段階に分けています。 モジュール単位の移行 :起点のFragmentを残し、その内部にNavHostを置いて、モジュール内の画面をComposeへ移す(コードは1-1) 親子関係にあるモジュールの統合 :特定の1つのモジュールからしか遷移されないモジュール(この記事では親子関係と呼びます)の移行が完了したら、そのグラフを親側のNavHostへ組み込む(コードは2-1) Activity直下の単一NavHostへの統合 :すべてのモジュールが揃った段階で、Activityに1つだけ置いたNavHostへ一括で組み込む(コードは3-2) 以下、この3段階を順に説明します。コードは設定モジュールを通しの例にして、同じコードがこの3段階でどう変わるかを追える形にしています。3は次のフェーズとして計画している段階で、実際の移行作業は本記事のスコープ外のため、現状と見通しだけを書きます。 共存させるための選択肢 FragmentベースのナビゲーションとNavigation Composeを共存させる方法は、公式の相互運用APIを含めていくつかあります。 方法 概要 向いている場面 ナビゲーショングラフ(XML)に composable を置く Navigation 2.8.0 の navigation-fragment-compose 。 ComposableNavHostFragment を使うと、グラフに <composable> を書ける 既存のグラフ構造を保ったまま、画面単位でComposeに置き換えていきたい場合 ComposeのNavHostの中にFragmentを置く Fragment 1.8.0 の fragment-compose 。 AndroidFragment でCompose階層の中にFragmentを配置でき、状態の保存・復元にも対応している NavHostを先にCompose側へ移し、残ったFragment画面を後から置き換えたい場合 起点のFragmentを残し、その内部にNavHostを置く 外側はFragmentベースのナビゲーションのまま。モジュール内の画面だけを composable<T> へ移していく モジュール単位で移行を完結させたい場合(今回採用) 私たちは「 1つのモジュールを最後までやり切ってから次へ進む 」進め方を徹底していたため、3つ目を選びました。モジュールの内側は「遷移先がすべてComposable」という公式の前提を満たし、外側は既存のFragmentベースのナビゲーションがそのまま動きます。画面単位で少しずつ混ぜたい場合は1つ目、NavHostを先に移したい場合は2つ目が選択肢になります。 Navigation 3ではなくNavigation Composeを選んだ理由 執筆時点では Navigation 3 が安定版として公開されています。それなら最初からNavigation 3へ移行すればよいのでは、と思われるかもしれません。しかし Navigation 3はCompose専用で、Fragmentを遷移先にできません 。そのためFragmentが残る段階ではまずNavigation Composeへ移行し、Navigation 3への移行はその後の段階としました。型安全ルートを採用しているので、 公式の移行ガイド が前提とする形はそのまま満たします。 1. モジュール単位の移行 1-1. 採用した構造 1つのモジュールに注目すると、移行後のグラフはComposeでこう書けます。遷移先は @Serializable なクラスで表し、 NavHost に composable<T> として登録します。 // 遷移先の定義: XMLの <fragment> の代わり sealed interface SettingsDestination { @Serializable data object Settings : SettingsDestination @Serializable data object Account : SettingsDestination @Serializable data class ChangeEmail( val currentEmail: String ) : SettingsDestination } // モジュールのNavHost: XMLの <action> の代わりにラムダで遷移する @Composable fun SettingsNavHost( navController: NavHostController, onClickBackButton: () -> Unit , ) { NavHost(navController, startDestination = SettingsDestination.Settings) { composable<SettingsDestination.Settings> { SettingsRoute( onClickBackButton = onClickBackButton, onClickAccount = { navController.navigate(SettingsDestination.Account) }, ) } composable<SettingsDestination.Account> { AccountRoute(onClickChangeEmail = { email -> navController.navigate(SettingsDestination.ChangeEmail(email)) }) } composable<SettingsDestination.ChangeEmail> { backStackEntry -> val args = backStackEntry.toRoute<SettingsDestination.ChangeEmail>() ChangeEmailRoute(currentEmail = args.currentEmail) } } } このNavHostをどこに置くかが共存の要点です。モジュールAのグラフ(XML)に属していたFragmentのうち、 起点の1つだけを残し、その中にNavHostを置きます 。モジュール内の画面はすべてNavHostの中の composable<T> になります。 公式ガイドの手順ではNavHostをActivityに置きますが、外側のグラフ(XML)を残す必要があったため、モジュールの起点となるFragmentにNavHostを置きました。 1モジュール=1NavHost という単位で、背景で挙げた「遷移先がすべてComposable」という前提をモジュールの内側で満たします。 起点のFragmentの責務は「NavHostを置く」ことと「外側との接続」だけになります。 @AndroidEntryPoint class SettingsFragment : Fragment() { override fun onCreateView(...): View = ComposeView(requireContext()).apply { setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner) ) setContent { FaansTheme { val navController = rememberNavController() SettingsNavHost( navController = navController, onClickBackButton = { findNavController().popBackStack() }, ) } } } } NavHost はそのまま使わず、画面遷移アニメーションを None に統一した薄いラッパーを共通モジュールに置いて、各モジュールはこれを使います。Fragment時代の遷移と見た目の差を出さないためです。 なお、上の SettingsNavHost の例では簡略化のため NavHost を直接呼んでいますが、実際にはこのラッパー( FaansNavHost )を使っています。 @Composable fun FaansNavHost( navController: NavHostController, startDestination: Any , modifier: Modifier = Modifier, builder: NavGraphBuilder.() -> Unit , ) { NavHost( navController = navController, startDestination = startDestination, modifier = modifier, enterTransition = { EnterTransition.None }, exitTransition = { ExitTransition.None }, popEnterTransition = { EnterTransition.None }, popExitTransition = { ExitTransition.None }, builder = builder, ) } 未移行のFragmentへの橋渡し 共存期には「モジュール内では移行済みだが、遷移先がまだFragment」という組み合わせが必ず出ます。 内側のNavHostからいったん抜け、外側の findNavController() で遷移させます 。次の移行対象をTODOで明示しておくと、後から探す手間が省けます。 onNavigateToLegacyScreen = { args -> // TODO LegacyFragment をScreen化したら compose の navController で遷移する findNavController().navigate(R.id.LegacyFragment, args) } 1-2. 進め方の整備 最初のモジュールには、業務の中心ではないものの一定の利用がある、設定画面のモジュールを選びました。利用が少なすぎると問題が表に出ず、多すぎると問題が出たときの影響が大きいためです。画面数が少なく、遷移が一直線であることも理由の1つです。 このモジュールは私が1人で移行し、判断に迷うところはチームに相談しながら進めました。移行後は、 移行済みと未移行のモジュールが混在した状態のまま通常のリリースに載せ 、問題が出ないことを確かめました。 そのうえで手順と注意点をClaude Codeのスキル(手順書を登録し、呼び出して実行させる仕組み)に落とし込み、2モジュール目以降はチームで分担しています。スキルは移行手順の実行だけでなく、その前後の段取りも自動化していて、使いながら手直しを続けています。 分担して進めるために、次のものを整えました。 進捗を一覧できる管理ページ :どのモジュールがどこまで進んでいるかを1ページで見える化。作業チケット(課題管理はJira)にモジュール名のラベルを付け、このページからモジュールごとに絞り込める モジュールごとの集約ブランチ :各画面のPRを集約ブランチに積み、モジュール全体の動作確認が終わってから開発のメインブランチへ取り込む。機能開発側のリリースと衝突しにくくなる 骨組みのPRを先に入れる :起点のFragmentにNavHostを導入するPRを最初に入れておくと、子画面の移行はそれぞれ独立したPRになり、複数人で並行して進められる 進捗の管理ページはこのような形です。 1-3. 移行前の遷移の記録 画面遷移のテストは書かない判断をしました 。Navigation Composeにはテスト用のAPIが用意されていますが、それでも書かないと決めるまでの経緯は課題5で書きます。とはいえ、移行前と同じ遷移ができることの確認は必要です。そこで途中から Maestro で画面遷移のシナリオを作り、移行の前後で同じシナリオを流すという軽い確認を試しています。 Maestroは、UI操作のシナリオ(Maestroではフローと呼びます)をYAMLで書くと、実機で同じ操作を再生するツールです。テストコードを書くより手軽で、移行のたびに繰り返せる点が今回の用途に向いていました。 シナリオのYAMLは、 mobile-mcp 経由でClaude Codeに実機の画面を操作させながら生成しています。 AIに任せるのは作成までで、できあがったシナリオの実行はMaestro CLIだけで行います 。実行のたびにAIの判断が入らないので、同じ操作を同じ結果で繰り返せます。 シナリオで確認しているのは、画面を開いて表示を確認し、起点に戻るという 単純な画面遷移だけ です。削除や投稿のようにデータを変更する操作や、複数の入力を伴うフローは含めていません。そこは従来どおりQAと手動確認で担保しています。 シナリオは画面ごとに「起点画面で始まり、起点画面に帰着する」ように書き、それを runFlow で束ねた1本を用意します。単体でも束ねても実行でき、ステップを二重に持ちません。 実行すると、左のログが1ステップずつ進み、右の端末で同じ操作が再生されます。 Maestroは画面上の要素を、表示テキストかIDで指定して操作します。Viewの画面なら android:id がそのままIDになりますが、Composeの要素にはIDに当たるものがありません。そこで操作したい要素に Modifier.testTag("...") を付けます。さらに、アプリ全体を包むテーマのComposable(すべての画面が必ず通る、いちばん外側のComposable)で testTagsAsResourceId = true を設定します。これで testTag の文字列がAndroidの resource-id として外から見えるようになり、Maestroの id: で指定できます。 // アプリ全体を包むテーマのComposableで一度だけ設定する(配下のすべての画面に効く) Modifier.semantics { testTagsAsResourceId = true } // 操作したい要素に testTag を付ける Button(modifier = Modifier.testTag( "settings_change_password_button" ), ...) # Maestro のシナリオからは id で指定できる - tapOn : id : "settings_change_password_button" Android Compose: Use Modifier.semantics { testTagsAsResourceId = true } to ensure your test tags are discoverable as IDs. — Core Selectors — Maestro Documentation 要約:Composeでは testTagsAsResourceId を有効にすることで、 testTag をIDセレクタとして使える。 maestro record で取得した動画をPRに添付し、レビュアーが遷移の維持を確認できるようにしています。CIには組み込まず、移行PRごとにローカルで実行しています。書いたシナリオは移行後も残るので、画面の追加や共通コンポーネントの差し替え、ライブラリ更新のときにも同じシナリオで回帰確認ができます。 1-4. 画面の移行 検証用のシナリオが揃ったら、いよいよ移行に取りかかります。進める順番は次のとおりです。 起点のFragmentに空のNavHostを導入し、起点画面だけを composable<T> で配線する骨組みのPRを入れる 子画面を1つずつ移行する。 Fragmentを削除し、画面のComposableを composable<T> としてNavHostに登録する 不要になった定義をナビゲーショングラフから削除する。その画面の <fragment> と、そこへ向かう <action> が消える <!-- 移行前: ナビゲーショングラフに書いていた定義。移行後はこの2つを削除する --> <fragment android : id = "@+id/ChangeEmailFragment" android : name = "...ChangeEmailFragment" /> <action android : id = "@+id/action_Account_to_ChangeEmail" app : destination = "@id/ChangeEmailFragment" /> // 移行後: NavHost側に composable<T> を登録し、遷移はラムダで渡す composable<SettingsDestination.ChangeEmail> { backStackEntry -> val args = backStackEntry.toRoute<SettingsDestination.ChangeEmail>() ChangeEmailRoute( currentEmail = args.currentEmail, onNavigateBack = dropUnlessResumed { navController.navigateUp() }, ) } FAANSでは画面のComposableを、ViewModelを受け取って状態を購読する XxxRoute と、状態とコールバックだけを受け取る純粋なUIの XxxScreen に分けています。 Fragmentが担っていたViewModelの生成・引数の受け取り・遷移コールバックの提供は、NavHost側の composable<T> ブロックと Route へ移ります。 // 画面側: ViewModelを受け取って状態を購読し、Screenに渡す @Composable fun ChangeEmailRoute( currentEmail: String , onNavigateBack: () -> Unit , viewModel: ChangeEmailViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() ChangeEmailScreen( state = state, onAction = viewModel :: dispatchAction, onNavigateBack = onNavigateBack, ) } ViewModel : by viewModels() の代わりに hiltViewModel() を使う。遷移先( NavBackStackEntry )にスコープされるので、画面を抜ければ破棄される 引数 : Bundle やSafe Argsの代わりに backStackEntry.toRoute<T>() で型付きの引数を受け取る 遷移 : findNavController() の代わりに、NavHost側で navController を使うラムダを渡す ダイアログの移行 ナビゲーショングラフには遷移先の <dialog> が50ありました。移行後は種類ごとに扱いが変わります。 確認ダイアログ( AlertDialog ) :共通のComposeダイアログに置き換える。親画面の状態( showDeleteConfirmDialog のようなフラグ)で表示を切り替え、遷移先としては登録しない BottomSheetDialogFragment : ModalBottomSheet に置き換える。確認ダイアログと同じく、親画面の状態で表示を切り替える 全画面のDialogFragment :通常の画面と同じく composable<T> として登録する 引数を受け取り、独立した遷移先として扱う必要があるもの : dialog<T> でNavHostに登録する 遷移先として登録しないダイアログは、ナビゲーショングラフの <dialog> とそこへ向かう <action> が消えます。 // 親画面の状態でダイアログを出し分ける。遷移先としては登録しない if (state.showDeleteConfirmDialog) { FaansAlertDialog( message = stringResource(R.string.delete_account_confirm), onConfirm = { onAction(AccountAction.OnClickDeleteConfirm) }, onDismiss = { onAction(AccountAction.OnDismissDeleteConfirmDialog) }, ) } 遷移をイベントで通知しているViewModelの移行前対応 FAANSの新しい画面は、UI状態を StateFlow に集約する形です。しかし古い画面には、画面遷移やダイアログ表示を LiveData<Event> のような1回限りのイベントで通知しているViewModelが残っていました。 公式のアーキテクチャガイド は、ViewModelで発生するイベントを UI状態の更新として表現する ことを推奨しています。こうした画面は、遷移の要否を状態( shouldNavigateBack のようなフラグ)として持つ形に変換してから移行します。画面側では状態をキーにした LaunchedEffect で遷移を呼び、呼んだことをViewModelに戻して状態をリセットします。 LaunchedEffect(state.shouldNavigateBack) { if (state.shouldNavigateBack) { onNavigateBack() onAction(DetailAction.ConsumeNavigateBack) } } この対応は挙動の変更を含むので、移行PRとは分けて「移行前対応」としてレビューしています。 Fragmentのライフサイクルとの対応 Fragmentを削除すると、 onViewCreated や onResume で行っていた初期化やログ送信の置き場所がなくなります。処理の種類ごとに、Fragmentでの書き方とComposeでの書き方を並べると次のようになります。 画面が表示されたときの初期化 // Fragment override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super .onViewCreated(view, savedInstanceState) viewModel.dispatchAction(ChangeEmailAction.Initialize) } // Compose: Compositionに入ったときに1回実行される LaunchedEffect( Unit ) { onAction(ChangeEmailAction.Initialize) } 前面に戻るたびのデータ再取得と、画面表示のログ送信 // Fragment override fun onResume() { super .onResume() viewModel.dispatchAction(ChangeEmailAction.Refresh) analytics.logScreenView(SCREEN_NAME) } // Compose: ON_RESUME ごとに実行される。後始末が要るものは LifecycleResumeEffect、 // ログのように投げるだけのものは LifecycleEventEffect LifecycleResumeEffect( Unit ) { onAction(ChangeEmailAction.Refresh) onPauseOrDispose { } } LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { analytics.logScreenView(SCREEN_NAME) } LifecycleResumeEffect と LifecycleEventEffect は 別のAPI です。どちらも ON_RESUME でブロックが動きますが、前者は onPauseOrDispose とペアで「再開で始めて中断で止める」処理を扱い、後者はイベントのたびに処理を実行するだけです。 Fragmentのライフサイクルとの対応表は公式ドキュメントには載っていないため、 Composeのライフサイクル対応APIの説明 をもとに自分たちで整理し、移行前に決めておきました。 Fragmentで書いていたこと Composeでの置き換え 補足 onViewCreated での初期化・購読 LaunchedEffect(Unit) Compositionに入るたびに実行。NavHost内で戻ってきたときも再実行される onResume でのデータ再取得 LifecycleResumeEffect ON_RESUME に結び付き、 onPauseOrDispose で後始末 onResume での画面表示ログ送信 LifecycleEventEffect(ON_RESUME) 公式ドキュメントがログや分析などのワンショットイベント向けとしているAPI 一度だけ行う初期化 ViewModelの init Composableのライフサイクルに依存させない 1-5. PRの分け方 PRは役割で分けています。 Fragmentの削除、 composable<T> への登録、不要になったナビゲーショングラフの定義の削除だけを「移行PR」に入れます 。ViewModelをUI状態の形に寄せる作業は「移行前対応」、残ったレイアウトXMLや不要importの整理は「移行後対応」として別PRに切り出します。レビュアーが「これは移行の差分か、挙動変更か」を迷わないためです。 最初のモジュールは手順を確立するために選びましたが、手順が定まってからは、 機能開発の案件で手を入れるモジュールを優先して移行 しています。移行だけを目的に工数を確保するのは難しく、案件で触るモジュールであれば動作確認の機会も自然に得られるためです。ただしPRとチケットは案件とは必ず分けます。 2. 親子関係にあるモジュールの統合 モジュールの移行が完了したら、そのモジュールのNavHostを他のNavHostへ組み込めます。統合には2種類あり、判断基準が異なります。 統合の種類 タイミング 根拠 親子関係にあるモジュールの統合(子モジュールは親モジュールからしか遷移されない) 子モジュールの全画面移行が完了したら、すぐ 実際の画面階層に沿う。XML側のエントリを早く減らせる。PRが小さい Activity直下の単一NavHostへの統合(トップレベルのモジュール群) 複数モジュールがまとまってから一括 1つだけ先に統合すると非対称な中間状態になる。Activity側のNavHost配線やXMLの大規模削除はまとめて行う方がリスクが低い この章では前者を扱います。後者は次章です。 2-1. NavGraphBuilder拡張としてのグラフ分離 統合に備えて、モジュールのグラフは NavGraphBuilder の拡張関数として切り出し、遷移の入り口を NavController の拡張関数として公開します。これは公式の Encapsulate your navigation code が示している形です。Googleの公式サンプルアプリであるNow in Androidも、各featureモジュールで同じ構成を取っています。 // SettingsGraph.kt — グラフ・Destination・navigateToXxx() を1ファイルに fun NavGraphBuilder.settingsGraph(navController: NavHostController) { // ネストグラフの入り口。1-1 の SettingsDestination に data object Graph を追加する navigation<SettingsDestination.Graph>( startDestination = SettingsDestination.Settings, ) { composable<SettingsDestination.Settings> { ... } composable<SettingsDestination.Account> { ... } composable<SettingsDestination.ChangeEmail> { ... } } } fun NavController.navigateToSettings() = navigate(SettingsDestination.Graph) // 統合先(MypageGraph.kt): 親モジュールの NavHost に子のグラフを組み込む fun NavGraphBuilder.mypageGraph(navController: NavHostController) { composable<MypageDestination.Home> { MypageRoute(onClickSettings = { navController.navigateToSettings() }) } settingsGraph(navController) } 2-2. 一時的なモジュール間依存の扱い 公式の モジュール化ガイド では、機能モジュールはデータ層のモジュールに依存し、機能同士のやり取りは仲介するモジュール(通常はappモジュール)を通す形が示されています。FAANSでも「機能モジュール同士は直接依存しない」をルールにしています。この統合では親モジュールが子モジュールへ依存する形になり、このルールと一時的に矛盾します。単一NavHostへの統合までの 期限付きの例外 と位置づけ、次の条件を設けています。 依存は一方向のみとする 解消タイミングをTODOコメントで必ず明示する 統合元の起点Fragmentは、他モジュールからの <action> が消えるまで削除しない 3. Activity直下の単一NavHostへの統合 ここは次のフェーズなので、現状と見通しだけを書きます。 3-1. ルート側の現状 Activityは NavHostFragment を1つ持ち、ルートのグラフ(XML)から各モジュールのグラフを <include> で取り込んでいる。スプラッシュや認証などアプリ共通の画面はまだFragment ディープリンクはActivityが受け取り、 findNavController(...) でXMLの <deepLink> に解決している Activityスコープで共有するViewModelが数種類あり、別Activityで動くフローもXMLの <activity> で繋がっている 3-2. 統合の見通し 統合の本体は「Activityの下に1つのNavHostを置き、各モジュールのグラフを並べる」という配線の置き換えで、画面のコードには手を入れません。以下は現時点の計画で、実装と検証はこれからです。 準備 :各モジュールのグラフを NavGraphBuilder 拡張の xxxGraph() に揃える。2章の統合で使っている形と同じで、単一NavHostへの統合ではこれを並べるだけになる。残りのモジュールとアプリ共通の画面の移行もここに含まれる 切り替え :Activityの setContent にルートのNavHostを置き、 NavHostFragment ・ルートのグラフ(XML)・各モジュールの起点Fragmentを削除する。モジュール横断の変更なので、揃った段階で一度に行う 付随する置き換え :ディープリンクは navDeepLink<T> と handleDeepLink(intent) で置き換える。共有ViewModelはネストグラフへのスコープ( hiltViewModel(parentEntry) )、別Activityの起動は activity<T> を使う。いずれもNavigation Composeに型安全なAPIがある // ルートの NavHost(計画) setContent { FaansTheme { val navController = rememberNavController() FaansNavHost(navController, startDestination = RootDestination.Splash) { composable<RootDestination.Splash> { ... } homeGraph(navController) // 各モジュールの xxxGraph() を並べる mypageGraph(navController) // 2章で settingsGraph() を組み込み済み } } } 切り替えの前後でも、移行前にシナリオを揃えて統合後に同じシナリオを流す手順をそのまま使います。 モジュール単位で積み上げてきたものを、最後に一度だけ配線し直して この移行を終える計画です。 移行の中で見えてきた課題 共存構造そのものは公式ガイドの延長です。しかし実際に運用すると、「外側と内側にNavControllerが2つある」ことや、FragmentとComposeでライフサイクルやスコープの単位が変わることに起因する課題がいくつか出てきました。 課題1:戻るボタンの連打で画面がホワイトアウトする 最初のモジュールを移行した直後のデザインレビューで、「戻るボタンを連続でタップすると画面が真っ白になる」という指摘を受けました。特定の端末でだけ再現する現象でした。 原因は、 1回目の戻るで画面が切り替わっている最中に、2回目の戻るが実行されていた ことです。モジュール内のNavHostは画面が少ないため、2回目の popBackStack() で起点画面まで消え、NavHostに表示するものがなくなって白い画面になります。 端末の戻るはNavHostが処理し、バックスタック1件では無効になるため、起点画面までは消えません。問題はツールバーの戻るボタンで、ここで popBackStack() を呼んでいました。対応は次のとおりです。 Compose側の戻る処理を dropUnlessResumed で包み、画面が RESUMED でないときのタップを無視する ツールバーの戻るを popBackStack() から navigateUp() に替える。 navigateUp() は バックスタックが1件だけのときはpopしない ので、起点画面が消えない 外側のNavControllerを呼ぶ経路でも同じことが起きるため、 RESUMED 未満なら何もしない safePopBackStack 拡張を用意する // 対応1: dropUnlessResumed で包む / 対応2: navigateUp() を使う onNavigateBack = dropUnlessResumed { navController.navigateUp() } // 対応3: 外側の NavController 用の拡張 fun NavController.safePopBackStack(): Boolean { val currentEntry = currentBackStackEntry ?: return false return if (currentEntry.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { popBackStack() } else { false } } Lifecycle 2.8.0 で追加された dropUnlessResumed は、画面( NavBackStackEntry )が RESUMED でなければブロックを捨てます。 For Navigation users, it's recommended to safeguard navigate methods when using them while a composable is in transition as a result of navigation. — dropUnlessResumed — androidx.lifecycle.compose (KDoc) 要約:遷移アニメーション中のnavigate呼び出しは、この関数でガードすることが推奨されている。 対応2で popBackStack() を navigateUp() に替えたのは、端末の戻ると同じ挙動に寄せるためです。どちらもバックスタック1件でpopしないことは、androidxの navigateUpの実装 と 戻るコールバックの有効条件 で確認できます。 遷移中のタップは無視されるため、まれにもう一度押す必要がある場面はあり得ますが、白い画面になるよりは許容できると判断しました。 課題2:写真ピッカーから戻った直後の遷移が発火しない 課題1で入れた RESUMED のガードを、OSの写真ピッカーから戻った直後の遷移処理にも付けたところ、 遷移が発火しなくなりました 。原因はActivityの仕様です。 An activity can never receive a result in the resumed state. You can count on onResume being called after this method, though not necessarily immediately after. — Activity.onActivityResult — Android API reference 要約:Activityは RESUMED 状態で結果を受け取ることはなく、 onResume はその後に呼ばれる。 結果が配信される時点でActivityは RESUMED ではなく、 NavBackStackEntry のライフサイクルもホストの状態を超えられません。 dropUnlessResumed や RESUMED ガードは画面内のタップ起点にだけ付け、ActivityResultや非同期完了コールバック起点には付けない、という使い分けに落ち着きました。 課題3:未移行の画面を挟んでもバックスタックは失われなかった モジュール内で設定画面からアカウント設定へ進み、そこから未移行のFragmentへ遷移して、戻るを押したとします。期待するのはアカウント設定に戻ることです。ところが未移行のFragmentから戻ると起点のFragmentのビューが再生成されるため、その中のNavHostも作り直されて 設定画面まで巻き戻るのではないか 、と心配していました。 結果は、アカウント設定に戻ります 。 rememberNavController は rememberSaveable で状態を保持しています。そしてFragmentは、バックスタック上でビューを破棄する際に ビューツリーの SavedStateRegistry の状態を保存し、ビューの再生成時に復元します。Navigation Compose側のバックスタックはこの経路で引き継がれるので、問題ありませんでした。 課題4:ボトムシートを開き直しても前の内容が表示される 一覧の項目をタップすると開くボトムシートで、2つ目の項目をタップしても1つ目の内容が表示される現象をQAで見つけました。ボトムシートの中で hiltViewModel() をキーなしで取得していたのが原因です。 hiltViewModel() は 呼び出し元の遷移先( NavBackStackEntry )にスコープされます 。そのため同じ画面の中で条件付きに表示されるボトムシートは、どの項目から開いても同一のViewModelインスタンスを受け取ります。Fragment時代は DialogFragment ごとにViewModelが作られていたので起きなかった挙動でした。 // 修正前: 同じ画面内では常に同じインスタンスが返る viewModel: ItemDetailViewModel = hiltViewModel() // 修正後: 対象IDをキーにして、項目ごとに別のインスタンスにする viewModel: ItemDetailViewModel = hiltViewModel(key = itemId) 対応は対象IDをキーにすることです。ただし同じ形の問題はダイアログや LaunchedEffect でも起きるため、コーディング規約とレビュー観点を2つ追加しました。1つは「条件付きで表示するボトムシート・ダイアログは対象IDをViewModelのキーにする」です。もう1つは「パラメータ付きコンポーネントの初期化は LaunchedEffect(Unit) ではなく識別パラメータをキーにする」です。 課題5:画面遷移テストを書かずにどう担保するか 1-3で書いたとおり、 NavHostの画面遷移テストは書きませんでした 。Navigation Composeには TestNavHostController などテスト用のAPIが用意されています。それなのになぜ書かなかったのか。検討した順に書きます。 TestNavHostController と createComposeRule を使えば、JVM上のRobolectricで遷移テスト自体は書けます。ViewModelを持たない画面では実際に動きました。つまずいたのは hiltViewModel() です。画面のComposableの中で呼んでいると、テスト用の ComponentActivity がHiltのエントリポイントではないため失敗します。解決策は4つ検討しました。 解決策 メリット デメリット 1. NavHostの引数でViewModelを受け取る モックを渡すだけで済む 起点Fragment表示時に全ViewModelが生成される 2. テスト用にNavHostのコピーを作る 本番の引数を増やさない NavHost更新時にテストも同時更新(漏れリスク) 3. Factory引数を持たせ、画面のComposable呼び出し時に生成 1の問題を回避しつつモック可能 NavHostの引数が増える 4. Hiltテスト環境を構築する 本番コードを変えない データ層のテストダブル群が前提 案4は Now in Android が採る方式で、 @HiltAndroidTest で本物のActivityを起動し、 @TestInstallIn でデータ層をテスト用の実装に差し替えます。ただし データ層を最初から差し替えられる設計になっていることが前提 です。FAANSでは遷移テストの前にデータ層を作り替えることになり、移行そのものより大きな作業です。残る案3が最もバランスの良い形でしたが、実装へ進む前に、遷移テストで何を確かめたいのかを整理し直しました。 確かめたいことを分解すると、ボタンからラムダが呼ばれることはScreenのユニットテストで、 navigate(X) でXが表示されることはNavigationライブラリ自身のテストで担保されています。残るのは「ラムダに正しい navigate を渡しているか」というNavHostの配線だけです。公式のテストガイドも、テスト対象をここに限定しています。 The Navigation component handles all the work of managing navigation between destinations, passing arguments, and working with the FragmentManager . These capabilities are already rigorously tested, so there is no need to test them again in your app. What is important to test, however, are the interactions between the app-specific code in your fragments and their NavController . — Test Navigation — Android Developers 要約:ライブラリの機能は再テスト不要。テストすべきなのは、アプリ固有のコードとNavControllerのインタラクション。 その配線も、型安全ルートにしたことで大半のミスはコンパイル時に検出され、残るのはラムダ1行の宣言だけです。 そのためにHiltを含むテスト環境を整えるコストは見合わない と判断し、Screenのユニットテスト・コードレビュー・QAで担保することにしました。1-3のMaestroによる確認は、その後に加わったものです。遷移が複雑でテストが必要な画面については、ダミーデータを用意した結合テストで書く方針です。 移行の進捗と現時点の効果 約半年のあいだ、機能開発と並行して移行を進め、月次のリリースも継続できました。8つの機能モジュールのうち5つの移行が完了し、そのうち1つは親モジュールのNavHostへ統合され、XML側のエントリも削除できています。移行が完了したモジュールでは、「背景・課題」で挙げた二重管理が次のように解消されています。 移行前 移行後(完了したモジュール) 画面を1つ追加するときの作業 Fragmentクラスを作る / XMLに <fragment> と <action> を書く / Safe Argsを生成する / findNavController() で遷移を書く Destinationを追加し、 composable<T> を登録する。遷移のラムダと引数の受け取りも同じ場所に書く 遷移の定義・実行・引数の置き場所 XML / Fragment / Safe Args NavHostの1か所に集まり、引数は @Serializable なクラスでコンパイル時に検査される 副産物として、モジュールごとの画面遷移シナリオが揃い、Maestroで回帰確認できるようになりました。移行の手順もスキルとして残っているので、残りのモジュールは担当者が変わっても同じ進め方ができます。 最後に 残る3つは画面数の多い大型モジュールですが、同じパターンとシナリオで進められる見通しが立っています。その先にあるActivity直下の単一NavHostへの統合は3章の見通しに沿って進め、型安全ルートで揃えてあるのでNavigation 3への移行も同じ延長線上にあります。 Maestroのシナリオは今のところローカルで実行しているだけです。シナリオが揃ってきたらCIで継続的に回し、画面遷移が壊れていないことを自動で確かめられる状態にできればと考えています。実現できるかはまだ見えていませんが、揃ったシナリオの次の使い道として試したいことの1つです。 Fragment資産を抱えたままComposeを進めている方にとって、 既存の画面遷移を止めずに移行を進める1つのやり方 として参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
データシステム部データ基盤ブロックの小泉です。データ基盤ブロックでは、社内のさまざまな業務領域のリレーショナルデータベースを、DatastreamでBigQueryへリアルタイム連携しています。この基盤は各部門が分析やダッシュボード等で活用しており、私たちが横断的に管理しています。多くのチームが参照する基盤はクエリ費用がかさみやすく、かつ各テーブルの更新頻度を利用者側で一元的に把握することも難しいという課題がありました。 本記事では、staleness limit(データの更新頻度)の見直しによるクエリ費用削減の取り組みを紹介します。あわせて、その設定を一度きりで終わらせず、CI/CDによる自動適用と設定状況の可視化(カタログ化)まで仕組み化した内容も紹介します。 目次 目次 staleness limit(max_staleness)とは 取り組みの全体像 課題:デフォルトの更新頻度のままだと費用が膨らむ 業務に必要な更新頻度を見極める ── 利用者へのヒアリング staleness limitの調整と費用削減 手動運用の限界 設定をYAMLで宣言的に管理し、CIで自動適用する 新規テーブルの更新頻度を自動化する 設定状況をカタログで可視化する 注意点・工夫 まとめ staleness limit(max_staleness)とは まず、本記事で扱うstaleness limitを簡単に説明します。Datastreamを使ってリレーショナルデータベースの変更をBigQueryへリアルタイム連携すると、BigQuery側では変更データ(CDC)を随時マージしてテーブルを最新の状態に保ちます。このとき、どのくらいまでデータの更新を待ってよいかを決めるのが max_staleness というテーブルオプションです。Datastream側ではこの値をstaleness limit(data freshness)として設定します。 ポイントは、この値が「データの更新頻度」と「クエリ費用」のトレードオフである点です。 max_staleness を短くする(例:5分)と、常に最新に近いデータが返る一方、更新(マージ)が頻繁に走り費用が高くなる max_staleness を長くする(例:1時間)と、最大その時間だけ古いデータを許容する代わりに、更新が間引かれて費用が安くなる Datastream・BigQuery CDCにおける max_staleness の挙動については、以下の公式ドキュメントをご確認ください。 cloud.google.com 取り組みの全体像 費用削減から運用の仕組み化までをまとめると、以下の通りです。各工程の詳細は後述します。 リアルタイム連携テーブルの参照実績を確認し、利用者にヒアリングして「業務に必要なデータの更新頻度」を見極め、合意する 合意した内容にもとづき、テーブルごとに max_staleness を調整して費用を削減する max_staleness の設定をYAMLで宣言的に管理し、CIが自動でBigQueryへ適用する仕組みを構築する 新規に連携されるテーブルには、デフォルトの更新頻度が自動で付与される設計にする どのテーブルがどの更新頻度で運用されているかをカタログとして可視化する ここから各工程を詳しく紹介します。 課題:デフォルトの更新頻度のままだと費用が膨らむ Datastreamのリアルタイム連携テーブルは、デフォルトでは max_staleness が短い値(今回のケースでは5分)に設定されていました。これは「常に最新に近いデータが返る」という点ではメリットですが、その分だけバックグラウンドのマージが頻繁に走り、BigQueryのクエリ費用を押し上げる要因になっていました。 一方で、リアルタイム連携テーブルの利用実態を見てみると、必ずしも5分間隔の更新頻度を必要としていないケースが多くありました。「日次のダッシュボードで参照しているだけ」「多少古くても業務上問題ない」といった用途であれば、更新頻度を落として費用を下げられる余地があります。 そこで、利用者が本当に必要とする更新頻度を見極めた上で、 max_staleness を調整して費用を削減することにしました。 業務に必要な更新頻度を見極める ── 利用者へのヒアリング 費用削減のために更新頻度を落とすとはいえ、いきなり設定を変えてしまうと、リアルタイム性を前提に業務をしている利用者に影響が出かねません。そこで、利用者に確認して合意を得てから変更するという進め方を取りました。 まず、ヒアリングの準備として、各テーブルについて直近3か月の参照者と参照頻度をすべて洗い出し、ヒアリング用の一覧表(スプレッドシート)にまとめました。クエリの実行ログをもとに「誰が」「どのテーブルを」「どのくらいの頻度で」参照しているかを可視化することで、確認すべき相手と、更新頻度を落とせそうなテーブルの当たりを付けられるようにしました。 続いて、作成した一覧表をもとにテーブル参照者へのヒアリングを実施しました。確認範囲は、次のように広げていきました。 直近の参照者と、連携を立ち上げたときにやり取りしていた担当者へ確認する サービスアカウント経由で参照している連携元にも確認する 別プロジェクトから参照している利用者にも範囲を広げて確認する すべての参照者への確認を終え、合意を形成する この「参照実績の全数洗い出し → 一覧表化 → 参照者へのヒアリング → 合意形成」というプロセスを踏みました。その結果、各テーブルについて、業務に必要な更新頻度を利用者と見極めて合意できました。結果として、本番環境で短い更新頻度(5分)を維持したのはごく一部の数テーブルのみで、それ以外の大半のテーブルは更新頻度を延ばす方針を安全に決められました。 staleness limitの調整と費用削減 合意した内容にもとづき、 max_staleness をテーブルごとに調整しました。 max_staleness はテーブル単位で設定するオプションのため、テーブルごとに必要な更新頻度を設定できます。今回は、本番環境のテーブルは1時間、検証・開発環境のテーブルは8時間を基本とし、短い更新頻度が必要な一部のテーブルだけ5分を維持しました。 max_staleness は、既に連携済みのテーブルに対しては次のような ALTER TABLE で変更できます。 ALTER TABLE `<project>.<dataset>.< table >` SET OPTIONS (max_staleness = INTERVAL 1 HOUR); 当初は「更新頻度を5分から10分に延ばして約50%削減」という控えめな見込みを立てていましたが、ヒアリングの結果、業務上許容できる範囲まで思い切って更新頻度を延ばせることが分かりました。最終的に本番環境のテーブルは5分から1時間へ変更したため、理論上の削減率は 1 − 5/60 ≒ 約92% となり、見込みを大きく上回りました。 実際の効果は、適用前後それぞれ2か月の費用を比較して検証しました。その結果、環境別に次の費用削減を確認できました。 本番環境: 約92%削減 (5分 → 1時間) 検証環境: 約98%削減 (5分 → 8時間) 開発環境: 約98%削減 (5分 → 8時間) 本記事のタイトルにある「92%削減」は、このうち本番環境の対象テーブルでの実測値です。 なお、staleness limit(data freshness)をコンソール上で変更した場合、その値は新規に作成されるテーブルにしか反映されない点に注意が必要です。既に連携済みのテーブルには、前述の ALTER TABLE を実行する必要があります。詳細は以下をご確認ください。 cloud.google.com 手動運用の限界 費用削減そのものは達成できましたが、運用面では3つの課題が残っていました。 既存テーブルへの変更は1つずつ ALTER TABLE が必要で、依頼のたびに手作業も発生する 新規テーブルの扱いが分かりにくい:新しく連携されたテーブルの max_staleness がどうなるか把握しづらい 設定状況が見えない:各テーブルの更新頻度を一覧できる手段がない 今後も「このテーブルの更新頻度を変えたい」という依頼は継続的に発生します。そのたびに手作業でクエリを打ち、台帳もないまま設定状況を管理する運用は続けられません。 そこで、設定をコードで管理してCIで自動適用し、設定状況を誰でも見られるように可視化するところまで仕組み化することにしました。 設定をYAMLで宣言的に管理し、CIで自動適用する まず、 max_staleness の設定をYAMLで宣言的に管理する形にしました。設定用のリポジトリに、データベース・テーブルごとの更新頻度を記述します。 - database : <データベース名> default_max_staleness : 3600 # デフォルトは1時間 tables : - name : <テーブル名> max_staleness : 300 # デフォルトと異なる値にしたいテーブルのみ指定(例:5分) このYAMLをマージすると、CIが差分を検出し、変更のあったテーブルへ ALTER TABLE を自動で実行します。処理の流れは次の通りです。 設定リポジトリのYAMLを編集してマージする インフラ用リポジトリへ設定が同期され、変更のPRが自動で作成される PRをレビューしてマージする CIが差分を検出し、対象テーブルへ max_staleness を自動適用する ここで工夫した点として、この適用処理を他のインフラ適用(IaC)とは独立したジョブにしたことが挙げられます。仮に他のインフラ適用が別の理由で失敗しても、 max_staleness の適用だけは巻き込まれず実行される設計とし、運用の堅牢性を確保しました。 新規テーブルの更新頻度を自動化する 次に、新規に連携されるテーブルの更新頻度を自動化しました。前述の通り、Datastreamのstaleness limit(data freshness)は新規に作成されるテーブルへ自動的に反映されます。この性質を利用し、data freshnessを環境ごとのデフォルト値(本番は1時間、検証・開発は8時間)に設定しておきました。これにより、新しく連携されたテーブルには、作成時にデフォルトの更新頻度が自動で付与されます。 この設計により、YAMLで管理するのは「デフォルトと異なる更新頻度にしたいテーブル(override)」だけでよくなります。デフォルトの更新頻度でよいテーブルはYAMLに書く必要がなく、今後テーブルが増えても手作業が増えない運用を実現しました。 設定状況をカタログで可視化する 最後に、どのテーブルがどの更新頻度で運用されているかをカタログとして可視化しました。 max_staleness の実際の値は、BigQueryの INFORMATION_SCHEMA から取得できます。 SELECT table_name, option_value AS max_staleness FROM `<project>.region-us`.INFORMATION_SCHEMA.TABLE_OPTIONS WHERE option_name = ' max_staleness ' これを利用して、次のようなパイプラインを組みました。 各環境で、利用者が参照するテーブルと max_staleness を突き合わせたカタログテーブルを日次で生成する 全環境のカタログテーブルを束ねる横断ビューを用意する その横断ビューをスプレッドシートに接続し、日次で自動更新する こうすることで、非エンジニアを含む利用者が、スプレッドシートを開くだけで各テーブルの更新頻度を確認できるようになりました。棚卸しや「このテーブルの更新頻度は今どうなっているか」という問い合わせにも、カタログを見れば答えられます。 注意点・工夫 実装を通して得られた学びをいくつか共有します。 staleness limitの変更は「新規テーブルのみ」に効く:既存テーブルへは ALTER TABLE が必要になる。この性質を押さえておかないと「設定を変えたのに反映されない」と混乱しやすい YAMLから消しても、BigQueryの設定値は変わらない:YAMLからテーブルを削除しても、そのテーブルの max_staleness はデフォルト値のまま残る。デフォルト値に戻すには、YAMLに残したままoverrideの指定だけ外す(CIがデフォルト値を再適用する)か、手動で ALTER TABLE する 可視化は「実際に設定されている値」を読む:カタログはYAMLではなく INFORMATION_SCHEMA の実値を参照するため、手動で変更されたテーブルも含めて常に実態を反映できる まとめ 本記事では、Datastreamのリアルタイム連携におけるstaleness limit(データの更新頻度)の見直しによる費用削減と、それを継続的に運用するためのCI/CD化・可視化を紹介しました。 利用者にヒアリングして必要な更新頻度を見極め、安全に更新頻度を延ばすことで約92%の費用削減を実現した 設定をYAMLで宣言的に管理し、CIが自動で適用する仕組みにしたことで、手作業を不要化した 新規テーブルにはデフォルトの更新頻度が自動で付与される設計とし、将来の運用負荷を抑えた 設定状況をカタログとして可視化し、利用者自身が更新頻度を確認できるようにした 単発の費用削減で終わらせず、「効果の確認 → 自動適用 → 可視化」という再現性のある型として構築できた点が、今回の取り組みのポイントだと考えています。利用者へのヒアリングを挟むため多少の手間と時間はかかりますが、実施すれば確実に費用削減を実現できるのでおすすめです。リアルタイム連携の費用にお悩みの方の参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、ZOZOTOWNのカート・決済・注文作成を担うバックエンドエンジニアの斉藤です。普段は開発に加えて、チームのマネジメントを担当しています。 影響調査そのものは、どのチームでも日常的に発生します。私たちの場合に問題だったのは、待たせる相手がいることでした。何か手を入れようとするたびに、影響範囲の洗い出し・実現可能性の判断・概算見積もりが必要です。これに毎回まとまった時間がかかります。その間、問い合わせ元である他部門は次に進めません。私たちの調査速度が、そのまま他部門の意思決定の速さを決めていました。 本記事で紹介するのは、この一次調査をAIエージェントの Devin に一次請けとして任せた取り組みです。といっても、Devin単体の性能を見せる記事ではありません。決済ドメインでは、ミスが金銭的な事故や障害に直結します。そこで本記事が扱うのは、エージェントを任せられる状態にするまでの工夫です。精度を左右するのは、Devin本体よりも次の2つの準備でした。どちらも、影響の抜け漏れをできるだけ減らすためのものです。 領域の構造をまとめたナレッジベース(影響の波及先が抜けない) 調べ残しを自分から洗い出させるプレイブック(確認の抜けが減る) なお、この取り組みはまだPoC(Proof of Concept)の段階です。効果測定は今後実施する予定であり、現時点で具体的な数値は提示できません。本記事で紹介するのは成果報告ではなく、エージェントを実業務に組み込むための設計と運用から得られた知見です。 目次 はじめに 目次 背景・課題 解決の取り組み 設計の出発点:完璧を目指さず、見落としだけは防ぐ アプローチの全体像 なぜDevinを選んだか 精度を左右する2つの準備 準備1:ナレッジベース 登録するもの 登録しないもの 確認しきれなかった部分に、確度タグを残す 準備2:プレイブック 運用フロー:一次調査はDevin、最終判断は人間 工数は、単一の人日で出させない 案を比べて選ぶのは人間、AIは一案を深掘り 運用の設計と、これから測るもの 何を効果として見るか 見るべきは判定反転率 自動化の線引きはリスク階層で レビューが精度の生命線 最初に作るなら、この順番 かかったコスト 作るより、維持するほうが難しい まとめ 背景・課題 改修や施策の検討を進める際、エンジニアは常に次の問いに向き合います。 そもそも自部門の管轄か、他部門の管轄か 実現は可能か。既存の設計と矛盾しないか どこに影響が及ぶか(抜け漏れなく) ざっくりどれくらいの工数か これらを検討する際、次の課題が顕在化していました。 調査待ちが意思決定を止める :検討のたびに調査が発生し、その都度エンジニアの時間が取られる。進めたい側からすれば、実施の可否という入口の判断すら、エンジニアの空き時間待ちになる。 横断調査の重さ :ZOZOTOWNのカート決済領域は複数のマイクロサービスに分かれている。そのため小さな改修でも、領域内のサービスをすべて網羅しないと影響が抜け落ちる。 解決の取り組み 設計の出発点:完璧を目指さず、見落としだけは防ぐ 仕組みの形を決める前に、何を諦めるかを先に決めました。誤りを2つに分けて考えます。 許容できる誤り(余計な指摘=偽陽性) 許容できない誤り(影響範囲の見落とし=偽陰性) 厄介なのは後者です。実現可能と判断して進んでから見落としが発覚し、手戻りにつながる可能性があるからです。AI活用の話は「精度が上がった」に流れがちですが、私たちはあえて精度を主目的から外しました。ある程度の雑さは受け入れて、設計はこの見落としを潰すことだけに寄せています。 この割り切りには、もうひとつ理由があります。一次調査で見積もりが大きくズレる原因を分解すると、支配的な要因はひとつでした。見えていない影響を後から発見すること、つまり影響範囲の見落としです。そのため狙うのは、個々の精度ではなく影響範囲の網羅という一点です。以降の設計判断は、ほぼすべてここから導かれています。 アプローチの全体像 そこで私たちは、要望に対する一次調査をAIエージェントに任せ、自動化することにしました。ここでいう一次調査とは、スコープ判定・影響範囲の洗い出し・実現可能性の判断・概算見積もりまでを指します。エンジニアの調査コストを下げ、意思決定の入口で待たせないのが目的です。 決済ドメインを扱う以上、エージェントに渡すものには線を引いています。一次調査で見せるのはリポジトリのソースコードだけで、本番環境や顧客データには触れさせていません。 この仕組みで危ないのは、調査を走らせることではなく、返ってきた結論が誤っている場合です。間違った結論は、レビューで止めない限り下流に残り、いずれ障害につながります。だからこそ、後述する人によるレビューがこの仕組みの要です。 なぜDevinを選んだか 必要だったのは、複数のリポジトリを跨いで調べられることです。各リポジトリへのアクセスとcloneをエージェント側が代行するので、ローカルには何も用意せずに済みます。調査であってもコマンドは実行されますが、それがサンドボックスの内側で完結するのも都合のよいところでした。 もっとも、ここまでは他のコーディングエージェントでも実現できます。本記事で紹介する内容も、Devinに固有の機能には依存していません。決め手は、Devinが社内にすでに導入されていたことです。横断調査用の環境を自前で用意・運用せずに始められました。 精度を左右する2つの準備 実際にやってみて分かったのは、要望をそのまま投げるだけでは足りない、ということでした。単一のリポジトリで完結する調査なら、Devinはそのままでも十分に働きます。しかし領域を跨ぐ調査では、どこまで探せば足りたのかをエージェント自身が判断できません。私たちが時間をかけたのは、エージェントが機能するための2つの準備です。ひとつはナレッジベース、もうひとつはプレイブックです。 準備1:ナレッジベース 対象領域の構造を明文化し、Devinが読み込む土台にしました。 これを作るとき、最後まで悩んだのは線引きでした。どこまで書き、どこから書かないかです。ここで基準がぶれると、ナレッジはすぐに使われなくなります。最終的に、基準はひとつに絞りました。 コードから読めるものは、登録しません。1つのリポジトリを読むだけでは分からない知識だけを、登録します。 実装の詳細を書き写しても調査の質は上がりません。リプレイスの進む領域では、写した記述がすぐ古びて足を引っ張るだけです。 登録するもの 5つあります。どれも形は同じで、 実装はコードにあるのに、その意味づけがどこにも書かれていない という共通点を持ちます。 # 登録するもの なぜコードから読めないか 1 領域の範囲とリポジトリ一覧 各リポジトリに「自分が何か」は書いてあるが、「この領域は全部でこれだけ」はどこにも書かれていない 2 スコープの境界 実装はあるが、どちらの部門の管轄かは書かれていない。取り違えると要望は誰にも拾われず宙に浮く 3 横断の波及 呼び出し関係は、呼ぶ側にしか書かれていない 4 コードに書かれない本番の前提 機能フラグの分岐は読めるが、本番値は設定側にあるため、実際どちらを通るかは分からない 5 守るべき設計意図 「あえてリトライしない」といった作りは読めるが、それが二重処理を防ぐためだとは書かれていない この5つは横並びではありません。1と2が土台となり、その上に3から5が乗ります。 調べる範囲が決まっていなければ、エージェントは手元にあるリポジトリだけを調べて、それで完了だと報告します。 波及マップも同じで、波及先の全体像がなければ逆引きの表を書きようがありません。 波及は呼び出しだけで終わりません。あるテーブルへの変更が、データ連携を通じて別のサービスや、その先のキャッシュまで届くこともあります。そこで「この種の変更はどこへ波及するか」を逆引きできる形で登録します。表向きは「メール文面の変更だけ」に見える要望が、裏で別のシステムの改修を呼びます。そうした隠れた依存をあぶり出すためです。 同じリポジトリの中だけなら、まず見落としません。危ないのはその一歩外です。どこまで辿ったかで、起きることの重さが変わります。 これとは別に、分かっていないことも登録します。ただし答えではなく、問いの形で残します。残っているのは「調べれば分かるが、優先順位を付けて後回しにしたもの」ばかりです。書いておかないと、 調べていないことと、調べて問題のなかったことを区別できなくなります 。答えはすぐ変わりますが、問いはほとんど変わりません。 登録しないもの 登録しないもの 代わりに残すもの コードから読める実装詳細(テーブル一覧、クエリの全文、メソッドの定義) 所在を指すポインタと、辿り方の手順 流動的なマスタ値・設定値(決済手段の種別、機能フラグの現在値、件数の統計) その値がどの設定ファイルにあるか 工数の人日 何も残さない。算出はプレイブックの仕事 書き写せば二重管理になります。コードが変わればナレッジは取り残され「ナレッジは古く、実物は新しい」というズレが生まれ、 整った古い一覧は、かえって誤った安心を与えます 。古い設定値が残っていれば、Devinは古い前提のまま「影響なし」と誤って判断しかねません。 ここまでの判断を1枚にすると、次のフローになります。どの経路でも何かは登録します。違うのは、中身を書くか、所在だけ書くか、問いとして書くかです。 確認しきれなかった部分に、確度タグを残す 本来なら、登録する内容はすべて実装で裏を取るか、チームで合意してからにすべきです。しかし全部は確認しきれませんでした。範囲が広かったこともありますし、そもそも実装からは確定できないもの(本番でどちらの分岐を通るか、など)もあります。 そこで、記述ごとにどこまで確認できたかを、確度タグとして残しました。 タグ 意味 [実装確認] コードを直接読んで確認した事実 [推測] 論理的に推論したが、実装は未確認 [要確認] 不明点。人間の確認が必要 ここでは代表的な3つだけ挙げました。実際は根拠の出所に応じて、もう少し細かく分けています。コードを直接読んだのか、設定ファイルからか、人間と合意した知識か、といった区別です。 実際の記述は、次のような見た目になります。1行が1つの事実で、行ごとにタグが付きます。 ## ある共有テーブル - 参照元 : 自部門の2サービスと、他部門の集計バッチから参照されている [実装確認] - 波及 : 変更は連携処理を通じて別サービスへ届く。その先のキャッシュまで 伝播するかは追い切れていない [要確認] - 設計意図 : 更新時の条件式が二重処理を防いでいる。緩めてはいけない [実装確認] - 管轄 : 集計バッチは他部門。スキーマ変更は事前調整が要る [人間と合意] - 未解決 : 退避処理がどこで動いているか特定できていない。 担当・実行頻度・条件は要ヒアリング [要確認] 確定した事実と、まだ分かっていないことが、同じリストに並びます。 節を分けずに同じ粒度で並べて、タグだけで区別する のがポイントです。「未確認事項」という別の節にまとめると、そこは読み飛ばされます。 確度タグは、付けて終わりにはしませんでした。整備の過程で、残った「推測」を一件ずつ実装で裏取りしました。確認できたものは「実装確認」へ格上げし、確認しきれなければ「要確認」へ格下げしました。この潰し込みで、推測はほとんど残らなくなりました。 確度タグを入れる前は、推測と事実が同じ平坦な文章で並んでいて、レビューで何度かヒヤッとさせられました。渡した知識の確からしさを、そのままDevinの回答に引き継がせます。これがナレッジベースの役割です。出力そのものの言い切りを抑えるのは、次のプレイブックの仕事です。 準備2:プレイブック 調査の進め方と出力フォーマットをテンプレート化し、Devinに毎回守らせます。プレイブックを影響調査用と見積もり用の2本に分けました。なぜなら、調査の途中で工数の話が混ざると、影響範囲の洗い出しが雑になるからです。 なかでも効果的なのが、自己反証を強制する指示です。 - 最終判断はエンジニアが行う。あなたは判断せず、材料を網羅的に提示せよ - 「見ていない範囲」を必ず明示せよ(誤った安心を与えない) - このレポートへの反証を2つ挙げよ - 最も危うい見落としを1つ挙げよ - 工数は単一値で出さず、楽観〜悲観のレンジで出せ 指示と対にして、禁則事項も置いています。「〜せよ」より「〜するな」の方が、守られたかどうかを後から確認しやすいためです。 - 推測で埋めるな(不明な点は「不明」「要確認」と明記せよ) - 見ていない範囲を隠すな - 致命傷の見落としリスクがある領域を「問題なし」と断定するな - トレードオフと技術的リスクを隠すな 3つ目が一番重要です。私たちが最も恐れているのは「影響なし」という出力だからです。 ただし、汎用の自己反証だけでは「どこを重点的に疑うべきか」がエージェント任せになります。そこで、シニアエンジニアが暗黙にもっている「ここは毎回見る」という観点を、固定のチェックリストとして毎回通過させます。 金銭計算への影響(税や割引の適用順序が変わらないか) 二重処理を防ぐ条件や状態遷移を壊していないか コンプライアンス上のスコープに触れていないか システム間の境界を越えて波及しないか 探索そのものにも型を置いています。影響範囲の調査は、次の3観点を必ず並列で実行させます。 呼び出し元トレース :直接の呼び出し元、2〜3段階の間接呼び出し、インタフェース実装、DI経由、イベントハンドラ 類似コード検出 :同じロジックのコピー、変数名だけ違う類似実装 データフロー下流 :DB操作、キャッシュ、イベントの下流消費者 2つ目は地味ですが、コピーされた実装の片方だけを直す事故を防げます。 ナレッジベースに登録した勘所を、プレイブックの側で毎回強制的に再確認させます。この二段構えによって、対象システムへの理解が浅いエージェントや、ドメインに不慣れなレビュアーでも、ベテランの確認観点をそのまま受け取れます。 出力も「スコープ判定 → 実現可能性 → 影響範囲 → リスク → 工数の手がかり → 覆り条件 → 見ていない範囲」という型に固定しました。Devin自身に調査の穴を開示させ、自分の推測を言い切らせないための型です。 運用フロー:一次調査はDevin、最終判断は人間 完成形のフローは次のとおりです。まずDevinが一次調査を担当し、その結果をエンジニアが検証して回答します。これまで各機能の担当に個別に問い合わせていた横断調査を、Devinがまとめて肩代わりするので、調査が特定の人の手を介さずに回り始めます。 要望や検討テーマを受け取る(他部門からでも、自分たち発信でもよい) Devinが一次調査する(スコープ判定・影響範囲・実現可能性・概算見積もり)。確度タグ・覆り条件・見ていない範囲をつけて出力する エンジニアが検証する(見落としや不確実な点を補う) 実現可能性が「可能」か「条件付き可能」なら回答して意思決定を速める。「不可」なら早期に足切りする これで、エンジニアの仕事は初動調査から、たたき台のレビューに変わります。検証もエンジニアが行う以上、人手がまるごと消えるわけではありません。浮くのは初動です。ゼロから全リポジトリを読む代わりに、Devinが挙げた候補の検証に集中できます。 出力イメージは次のとおりです。内容は説明のために構成した架空の例です。 【入力(要望・検討テーマ)】 ある施策の対象になる条件を変更したい。 【Devinの一次調査(抜粋)】 ■ スコープ判定 : 混在 [推測] - 条件そのものの設定は他部門、適用の判定と計算は自部門 ■ 影響範囲 - 金額計算の割引適用段階で条件を参照している [実装確認] - 条件は定期取得する設定値のため、変更の反映に時間差がある [実装確認] - 対象かどうかを表示する画面側にも独立したキャッシュがあり、 反映のタイミングが揃わない [実装確認] - 割引は税計算より前段のため、課税対象額が変わる [実装確認] ■ リスク : 切替の前後で、画面の表示と実際の適用結果がずれる時間帯が生じる(高) ■ 覆り条件 : 時刻を指定した切り替えが要件なら、現在の取得方式では実現できず 設計変更が要る [要確認] ■ 見ていない範囲 - 呼び出し元 : 管理画面や外部向けAPIからの条件参照は未調査 - 類似コード : 他の施策に同じ判定処理のコピーがないか未確認 - データフロー : 注文確定後の金額整合チェックへの影響は未調査 ポイントは2つです。どの項目にも確度タグが付くこと。そして最後に「覆り条件」と「見ていない範囲」が必ず添えられること。「見ていない範囲」は、調査の観点ごとに書かせます。まとめて1行にすると、書きやすい観点だけを書いて終わるからです。エンジニアはこの [要確認] と「見ていない範囲」を起点に検証へ入れるので、ゼロから読むより当たりを付けやすくなります。 ひとつ補足します。出力の確度タグには、ナレッジ由来のものと、Devinが自分の調査結果へ付けたものが混じります。後者は自己申告です。そのためレビューでは、特に [実装確認] の裏取りを優先します。 工数は、単一の人日で出させない 見積もりで一線を引いた点があります。単一の人日を出させないことです。Devinは私たちのチームの生産性を把握していないので、1点で当てにいけば構造的に外れます。それ以上に危ういのは、整った数字がそのまま事業の期日として約束されることです。 そこでDevinには、工数を左右する要素を分解させ、工程ごとの重みとバッファを掛けた楽観・標準・悲観のレンジまでを出させます。狙いは精度ではありません。実装以外の工程を毎回思い出させることです。テストとレビューは抜けにくいのですが、リリース準備、関連チームとの調整、運用手順の更新は、見積もりから抜け落ちがちです。 先ほどの一次調査に続けて実行すると、次のような出力になります。単位は人日ではなく、実装を1.0とした相対的な作業量です。 【入力】 一次調査の結果 + 採用する実装案 【Devinの見積もり(抜粋)】 ■ 前提 : 工程の重み合計 2.8(実装を1.0とした相対値)、バッファ率 30% ■ 実装ベース : 3(中央値) ■ 工程の積み上げ 実装 3.0 / テスト 2.7 / レビュー 0.6 / リリース準備 0.6 運用手順・監視 0.6 / 他部門との調整 0.6 / ドキュメント 0.3 → バッファ前 8.4 ■ レンジ : 楽観 9.7 / 標準 10.9 / 悲観 16.0 ■ 覆り条件の影響 - 時刻を指定した切り替えが要件に入る → 取得方式の設計変更が必要(+5.1、発生確率:中) ■ 確度 : 中。他部門側の設定変更の要否が未確定 [要確認] 実装3に対して、標準は10.9です。実装は全体の3割にも届きません。悲観が跳ね上がっているのは、一次調査で挙がった覆り条件を1つ積んだからです。この差分は、そのまま「先に潰すべき不確実性」の大きさを示します。 この数字を実際の期日に落とすのは、エンジニアの仕事です。 案を比べて選ぶのは人間、AIは一案を深掘り 役割の線引きは、工数だけではありません。複数の案から選ぶのも人間の仕事です。エージェントには推奨する一案だけを深掘りさせ、採用理由やトレードオフ、固有のリスクまで調べさせます。 当初は複数案を出させていましたが、外しました。一案に絞った方が深く掘れるからです。プレイブックも同じで、盛り込みすぎると現場で回らなくなります。 運用の設計と、これから測るもの 何を効果として見るか 何を効果として見るかは、仕組みを作る前に決めておく必要があります。後から指標を決めると、都合のよい数字を選んでしまうからです。私たちが追うのは、次の3つです。 判定反転率 :エンジニアの検証でDevinの結論が覆った割合。見落としをどれだけ防げているか 規模感レンジ命中率 :実績がDevin起点の見積もりレンジに収まった割合。工数の規模感の確からしさ 初動リードタイム :要望の受領から一次回答までの日数。入口をどれだけ速く返せるか 見るべきは判定反転率 影響調査の誤りには2種類あり、重大度がまったく異なります。 誤りの種類 重大度 余計な指摘(偽陽性) エンジニアが切るだけで済む。軽い 見落とし(偽陰性) 影響点を見逃し、実装後に障害化する。致命的 主指標に据えるのは判定反転率です。スコープ判定や実現可能性をひっくり返す主因は、ほぼ見落とし(偽陰性)です。そのため反転率が高ければ、見落としが多いということです。 反転がなぜ起きたのかは、案件ごとに見落とし率で振り返ります。エンジニアがレビューで追加発見した影響点を、全影響点で割った値です。ただし、これはあくまで近似です。分母の「全影響点」は本来観測できず、エンジニアのレビューを正解とみなして数えているにすぎません。Devinとエンジニアがそろって見落とした影響点は、そもそも分母に入りません。そのため実際より小さめに出ます。その前提込みで眺めています。 この反転率を十分に低く保てるかどうかが、本格導入の判断ラインです。 自動化の線引きはリスク階層で すべてを完全に自動化するのではなく、タスクのリスクに応じて人間の関与度を変えます。 タスク 方針 スコープ判定(自部門と判定) おおむね低リスク。Devin主導でよい スコープ判定(対象外と判定) 取りこぼし(偽陰性)になりうるので人間が確認 一般的な影響調査 判定反転率が安定したら委譲を広げる 高リスク領域の影響調査 結果によらず人間の検証を必須にする スコープ判定にはひとつただし書きがあります。「スコープ外」と判定されたものだけは低リスクとして扱わず、人間が一度目を通すようにしています。誰にも拾われないまま宙に浮く要望を防ぐためです。 レビューが精度の生命線 Devinが担うのは一次調査までで、最終判断は必ずエンジニアが行います。構造化された調査結果には、独特の説得力があります。きれいに整って出てくる分、そのまま信じてしまうと、探索の範囲がDevinの枠組みに縛られます。すると、Devinが見落とした箇所を人間も一緒に見落としかねません。 そのためDevinの出力は、確定した結論ではなく「裏取りすべき手がかり」として扱っています。このレビューを省いた瞬間に、仕組みはむしろ危ういものになります。 最初に作るなら、この順番 ここまで書いたものを、全部揃える必要はありません。 そもそも、 単一のリポジトリで完結する領域なら、ナレッジベースはほぼ不要です。 Devinはリポジトリを読むこと自体は得意なので、そのままでも十分に働きます。プレイブックに禁則事項を数行足すだけで足ります。作る価値が出るのは、リポジトリを跨ぐ波及があり、かつ本番の挙動がコードから確定できない領域です。 自分のチームに導入するなら、ナレッジベースは次の順で整備することを勧めます。 領域の範囲と、関係するリポジトリの一覧。 これがないと、エージェントは「全部見た」と言えない。他より軽く、効果が一番早く出る 他部門との紛らわしい境目の判定表。 管轄の取り違えという最悪の見落としが、これで減る 横断の波及マップ。 一番重く、一番効果的。1と2ができてから着手する 1と2は土台なので、ここだけでも仕組みは回り始めます。3は継続的な更新が要るので、誰が維持するかを決めてから手を付けた方が安全です。 かかったコスト ナレッジベースの構築にかけた人手は、ドメインに詳しいエンジニアの約3人日です。 項目 規模 対象リポジトリ 15本 ナレッジベース 14ファイル、4,107行 プレイブック 2本、481行 4,000行を3人日で書けたのは、下書きをエージェントに任せたからです。人間の時間は、推測に確度タグを付け、一件ずつ実装で裏を取る作業に充てています。 一次調査1件は、依頼から出力まで5分前後です。消費はDevinの課金単位で1〜2ACU(Agent Compute Unit)程度でした。準備にかけた3人日と比べれば、1件あたりの利用料は桁が違うほど小さい規模です。 作るより、維持するほうが難しい いま一番の課題はナレッジの更新です。 ナレッジは放っておけば古くなります。厄介なのは、 ナレッジがない状態より、古いナレッジのほうが悪い ということです。空欄なら人は調べますが、それらしい記述があれば信じます。しかも、劣化は目立たないまま進みます。コードは壊れればテストが落ちますが、ナレッジは古くなっても何も起きません。誰かが古い前提のまま判断を下すまで、誰も気づきません。 「定期的に見直す」は機能しませんでした。棚卸しの予定を決めても、日々の案件に押されて後回しになります。いま考えているのは、次の2つです。 使うたびに直す。 調査でDevinが古い記述に引きずられて間違えたら、その場でナレッジを直す。棚卸しの日は待たない 判定反転率を陳腐化の検知に使う。 反転が増えてきたら、それはDevinの問題ではなく、ナレッジが古くなったサインかもしれない そのうえで、 維持できる量に絞ることが、維持する努力より先に来ます。 前半で書いた登録の基準は、抜け漏れを防ぐためのものであると同時に、量を抑えて維持コストを下げるための線引きでもあります。コードから読めるものを書き写さず、値ではなく所在だけを残し、答えではなく問いの形で書きます。どれも、更新しなくても腐りにくい形です。 最後は人の問題です。作った人が異動したら止まる、という状態のままなら、いずれ誰も信じないドキュメントになります。 まとめ 本記事では、決済ドメインの一次調査をDevinに一次請けとして任せる取り組みを紹介しました。 効果的だったのは、エージェントの性能そのものより、それを取り囲む準備と運用モデルです。 目指しているのは、「実現できるのか」という入口の判断で他部門を待たせないことです。調査の効率化は、そのための手段です。 本記事の要点は、次の3つです。 知識に付けた確度タグで、確からしさを出力へ引き継ぐ プレイブックで自己反証を強制し、断定が独り歩きしないようにする 主指標は判定反転率(見落としの多さを映す)。人間の検証を前提に、リスク階層ごとに少しずつ自動化していく うまくいくことばかりではありません。整った出力に人間が引きずられ、Devinの見落としをそのまま見逃しかけたことも一度ではありませんでした。レビューを軽く見た瞬間に崩れる仕組みだと、運用しながら何度も思い知らされています。 いまはまだ自部門だけの取り組みです。同じ枠組みを共通フォーマットとして他部門にも広げられれば、部門を跨いだ影響調査も見えてきます。ただし各部門がばらばらの形式で作ると、境界で連携できなくなります。横展開の成否は、フォーマットを統一できるかにかかっています。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、EC基盤開発本部SRE部カート決済SREブロックの金田です。テックリードとして、ZOZOTOWNのカート決済機能のリプレイスや運用を担当しています。 カート決済SREブロックでは、Slackを起点としたChatOpsで運用作業の自動化に取り組んでいます。以前の記事で紹介したAWS Chatbotベースの基盤を約2年運用する中で、アクセス制御とツール追加コストの課題が見えてきました。本記事ではその課題と、Argo Eventsを軸にChatOps基盤を再構築した取り組みを紹介します。 目次 はじめに 目次 背景・課題 課題1: アクセス制御が粗い 課題2: ツール追加のハードルが高い 新基盤の要件 新しいChatOps基盤の全体像 Slackの制約と信頼チェーンによる認可 制約1: Workflowの投稿からは実行者が分からない 制約2: 3秒ルールとリトライ 制約3: 署名検証には生のリクエストボディが必要 制約4: Request URLはSlack Appごとに1つ 信頼チェーンで実行者を特定する Gatekeeperによる認可、承認、監査の一元化 SSMポリシーによる宣言的な認可 危険な操作への承認フロー 許可も記録する構造化監査ログ なぜArgo Eventsなのか Argo Eventsを本番運用に載せる設計判断 EventBusは高可用性をあえて求めない 監視の組み込み Sensorはルーティングだけ、実行権限はexecuteだけ K8s Jobによるツール実行機構 PodTemplateでツールのJob定義を配布する マルチテナント設計とツール追加の体験 共通の入口と、テナントごとの実行スタック ツールに渡す標準ペイロード ツール追加はSSMポリシーとマニフェストだけ 導入の効果 まとめ 背景・課題 カート決済SREブロックでは、過熱商品(発売開始時などにアクセスが急激に集中する人気商品)の登録などの運用作業をSlackから実行できるようにしています。従来の基盤は「Slack Workflow → AWS Chatbot → AWS Lambda」という構成でした。非エンジニアでもSlack Workflowのフォームに入力するだけで運用ツールを実行できます。構成の詳細や、コマンドラインツールをLambda関数化した工夫は以下の記事で紹介しています。 techblog.zozo.com 本記事は、この構成を約2年運用した先の話です。ツールと利用者が増え、この先さらに運用業務のツール化を進めるにあたって、次の2つの課題が無視できなくなりました。 課題1: アクセス制御が粗い AWS Chatbotのアクセス制御は、チャネルに紐付くIAMロールとガードレールポリシーが基本です。前回の記事でも当時できる範囲の制御を紹介しましたが、次の要素は仕組みとして実現できませんでした。 「どのチャネルで、誰が、どのコマンドを」実行できるかという、コマンド単位かつユーザー単位の認可 削除などの危険な操作に対する、実行前の承認フロー 「誰が何を実行し、なぜ許可されたのか」を機械的に追跡できる監査ログ 実行したコマンドはSlackのメッセージとして残ります。しかし、それは人が目視で追える履歴であって、拒否された実行や許可の判断根拠まで含めて検索や集計ができる監査ログではありません。運用ツールの中にはデータを書き換える操作も含まれるため、利用者が増えるほどこの粗さがリスクになっていきました。 課題2: ツール追加のハードルが高い AWS Chatbotから実行できるのはLambdaをはじめとするAWSリソースの操作だけです。運用ツールをChatOpsに載せるには、1コマンドで完結するような簡単な処理でも、ツールごとにLambda関数を実装する必要がありました。これが新しいツールを導入する際の障壁になっていました。 さらに、カート決済SREブロックの運用ツールにはEKS上のJobとして動かしたいものもあります。コンテナイメージとして完成しているツールでも、ChatOpsに載せるためだけにLambdaへ移植するのは本末転倒です。実行形態がLambdaに縛られていることが、この障壁をさらに高くしていました。 新基盤の要件 課題を整理して、新しいChatOps基盤に求める要件を次のように定めました。 分類 要件 認可 コマンド、ユーザー、チャネルの単位で実行可否を制御できる 承認 危険な操作は、権限を持つ承認者の承認を経てから実行される 監査 許可も拒否も、すべての実行判断が構造化ログとして残る 実行形態 K8s JobとLambdaのどちらでもツールを実装できる UX 非エンジニアは従来と同じSlack Workflowのフォームから実行できる 拡張性 ツールの追加や利用チームの拡大時に、基盤側の変更が不要である 新しいChatOps基盤の全体像 この要件を満たすために構築した、新基盤の全体像を示します。 図の中心は、EKSの手前に置いた Gatekeeper Lambda です。認証、認可、承認、監査を一手に担う関門です。その先のEKSでは、 Argo Events が認可済みのイベントを受けてツールを起動します。 1リクエストの流れを追うと次のようになります。 利用者がSlack Workflowのフォームに入力して送信すると、WorkflowがBotとして所定のチャネルにコマンドを投稿する Slack AppがEvents APIでこの投稿を受け取り、API Gateway経由でGatekeeper Lambdaに配送する Gatekeeperが署名検証、実行者の特定、ポリシー評価を済ませ、許可した場合のみペイロードを正規化してEKSのinternal ALBへ転送する ALBの先にいるArgo EventsのWebhook EventSourceがイベントを受け、EventBusへ流す Sensorがイベントの種別と承認要否を見てK8s Jobを作成する。承認が必要なコマンドは承認リクエストを投稿するJob(dispatch)へ、それ以外は実行を担うJob(execute)へ振り分ける ツール本体がK8s JobまたはLambdaとして実行され、結果をSlackのスレッドに通知する この流れはWorkflow経由のものです。エンジニア向けには、Botへの直接メンションとSlash Commandの経路もあり、いずれも同じAPI GatewayとGatekeeperを通ります。本記事では、非エンジニアも使うWorkflow経由を軸に説明します。 設計の方針は次の通りです。認証、認可、承認、監査は、実行基盤であるEKSの手前のGatekeeperで一元化します。EKS側はArgo Eventsでイベントを受けて実行に専念します。Slackとの接点は単一のSlack Appに集約し、ツールを追加してもSlack App、API Gateway、Gatekeeperには手を入れない構造にします。 この分担により、Argo Events以降には「誰が何を実行するか」が確定した正規化済みイベントだけを流します。SlackプロトコルとのやりとりはGatekeeperで完結しているため、EventSourceはSlackの署名を検証する必要がありません。Gatekeeperと共有するBearerトークンで、Gatekeeper自身を認証するだけで済みます。 Slackの制約と信頼チェーンによる認可 設計で最初に向き合ったのは、Slackプラットフォーム特有の制約です。 制約1: Workflowの投稿からは実行者が分からない Slack Workflowが投稿したメッセージの送信者は、実行した人ではなくWorkflowのBotです。Events APIで受け取るイベントの user フィールドを見ても、そこにいるのはBotであって「フォームに入力した人」ではありません。コマンド単位かつユーザー単位の認可には実行者の特定が必須なので、これは基盤の成立に関わる制約です。 制約2: 3秒ルールとリトライ SlackのEvents APIは、3秒以内に応答がなければ配送失敗とみなしてリトライします。Gatekeeperの中で重い処理はできません。そこで、EKSへの転送タイムアウトを2秒に設定し、認可判断と転送だけを行って即座に応答を返す構造にしました。また、リトライによる同一イベントの再配送に備えて、Gatekeeperで重複を排除しています。 制約3: 署名検証には生のリクエストボディが必要 Slackのリクエスト署名は、タイムスタンプとリクエストボディを連結した文字列から計算されます。リクエストの認証は API GatewayのLambda Authorizer へ分離するのが定石ですが、Authorizerにはリクエストボディが渡されません。つまりSlackの署名検証はAuthorizerでは実現できません。そのため認証を分離せず、署名検証から認可までをGatekeeperという1つのLambdaに集めています。 制約4: Request URLはSlack Appごとに1つ SlackがAppへリクエストを届ける先のURL(Request URL)は、機能ごとにApp全体で1つです。Events API、Slash Commands、Interactivity(承認ボタン)のいずれも、テナント別に別のURLを設定できません。そこで全テナント共通の単一エンドポイント POST /chatops ですべてを受け、どのチームのコマンドかの解決はGatekeeperがchannel-mapで行う構造にしました。経路ごとにペイロードの形式は異なりますが、Gatekeeperがパースして同じ正規化ペイロードに変換します。 一方、環境の軸では同じ制約に悩まされます。dev、stg、prdでURLを分けられないため、Slack Appは環境別に作ります。Appの複製にはApp Manifestが使え、設定ファイルをリポジトリ管理すればレビューも可能です。さらに、Slash Commandの名前はワークスペース内でグローバルです。そのためdevとstgのコマンド名には環境サフィックスを付け(例: /example-tool-dev )、Gatekeeperがコマンドの解釈時にサフィックスを除去します。 つまり「単一Slack App」とは、同一環境内のテナント間で1つという意味です。テナント軸は1つのAppに集約し、環境軸はAppごとに分離します。この線引き自体が、Slackプラットフォームの制約から導かれた設計です。 信頼チェーンで実行者を特定する 制約1で挙げた「実行者が分からない」問題への答えが、信頼チェーンです。 Workflowのフォームには実行者を示す入力欄があり、投稿されるメッセージ本文に実行者のユーザーIDが埋め込まれます。ただし、本文に書かれた実行者を無条件に信じると、任意のユーザーが他人になりすませてしまいます。そこで「本文中の実行者情報を信頼してよい条件」を、次の連鎖として構成しました。 Slackの署名検証により、イベントがSlackから来たことを保証する イベントの bot_id が、事前に登録した「信頼できるWorkflow」のものと完全一致することを確認する 要求されたコマンドが、そのWorkflowに許可されたコマンドの範囲内であることを確認する ここまで通って初めて、本文中の実行者情報を「Workflowのフォームが埋め込んだ値」として信頼する bot_id はWorkflowごとに払い出される識別子で、そのWorkflow以外は同じ bot_id で投稿できません。人間が手打ちで偽装したメッセージは投稿者が人間のuser_idになるため、Bot投稿とは必ず区別できます。実行者の情報は、Workflowのテンプレートに埋め込んだ「このワークフローを開始した人」という変数でSlackが自動的に挿入します。フォームの入力者はこの値を偽装できません。そしてWorkflowの編集コラボレーターを管理者に限定することで、テンプレート自体の改変も防いでいます。この運用条件までを含めて、実行者情報の信頼が成立します。 一方、Botを経由しない人間の直接メンションでは、イベントの user をそのまま実行者として使います。この経路では本文中の実行者情報を信頼しません。経路によって、何を信頼するかを切り替えています。 この認可はfail-closedに設計しています。 bot_id が未登録なら拒否、Workflowに許可されていないコマンドなら拒否、実行者情報が取れなければ拒否です。拒否はすべて理由コード( untrusted_bot 、 requester_missing など)付きでログに残します。 なお、移行にあたって既存のSlack Workflowは作り直さず、投稿先の差し替えで対応しました。Workflowを作り直すと bot_id が変わってしまうためです。 bot_id を登録制にする設計は、こうした運用上の注意点とセットになります。 Gatekeeperによる認可、承認、監査の一元化 信頼チェーンで実行者が特定できたら、次はポリシー評価です。Gatekeeperが1つのリクエストに対して行う処理は、順に次の通りです。 Slack署名の検証と、リトライによる重複リクエストの排除 チャネルIDからのテナント解決 信頼チェーンによる実行者の特定 ポリシー評価(チャネル、コマンド、ユーザー、グループ、Workflow) 承認要否の判定と、正規化ペイロードのEKSへの転送 1〜3はここまでに説明した通りです。残るは4と5、すなわちポリシーによる認可と承認フロー、そしてその記録です。 SSMポリシーによる宣言的な認可 認可のルールはAWS Systems Manager Parameter Store(以下、SSM)に置いています。構造は2段階です。 1つ目は /chatops/channel-map で、SlackのチャネルIDからテナント(チーム)とチャネルラベルを解決します。チャネルIDという環境依存の値を持つのはこのマップだけで、各ツールのポリシーはラベルだけを参照します。 { " C0123456789 ": { " tenant ": " zozo-cart ", " label ": " ops " } } 2つ目は /chatops/policy/{テナント}/{ツール} で、ツールごとに次を宣言します。 コマンドごとの許可チャネル(ラベル)と、許可ユーザーおよび許可グループ(SlackのUser Groupを利用) コマンドごとの承認要否と承認者 ツールの実行形態(K8s JobまたはLambda)と失敗時の連絡先 { " invoke ": { " type ": " k8s-job " } , " failure_contact ": " <!subteam^S0AAAAAAA> ", " commands ": { " info ": { " allowed_channels ": [ " ops " ] , " allowed_users ": " * " } , " update ": { " allowed_channels ": [ " ops " ] , " allowed_groups ": [ " S0BBBBBBB " ]} , " delete ": { " allowed_channels ": [ " ops " ] , " allowed_groups ": [ " S0BBBBBBB " ] , " requires_approval ": true , " approver_groups ": [ " S0CCCCCCC " ] , " allow_self_approval ": false } } , " trusted_workflows ": [ { " bot_id ": " B0XXXXXXXXX ", " allowed_commands ": [ " update " ]} ] } コマンドのキーは、ツールが実際に受け付けるサブコマンド名と一致させます。ツール側も宣言にないコマンドを拒否するため、ずれているとGatekeeperの認可を通ったコマンドさえツールが弾いてしまいます。信頼チェーンで使うWorkflowの bot_id も、このポリシーの trusted_workflows で登録します。 ユーザー単位の認可は、個人ID( allowed_users )とSlack User Group( allowed_groups )のORで判定します。メンバーの異動のたびにポリシーを書き換えずに済むよう、基本はUser Groupで宣言し、GatekeeperがSlack APIでメンバーを展開して判定します。この展開に失敗した場合は、許可側に倒さず拒否します(fail-close)。判断材料が欠けた状態で通すと、認可そのものが形骸化するためです。 ポリシーの追加や変更はSSMパラメータの更新だけで済み、Gatekeeperのデプロイは不要です。ただし、誰でも更新できるわけではありません。ポリシーはCloudFormationで管理し、変更にはPRレビューを必須にしています。信頼チェーンの起点になる trusted_workflows への bot_id の登録も、このレビューを通ります。認可ルールを書き換える手段が野放しでは、認可の仕組み全体が意味を失うためです。 GatekeeperはSSMとSlack APIのスロットリングを避けるため、ポリシーとUser Groupを5分間キャッシュします。変更の反映が最大5分遅れることは、運用上許容できるトレードオフと判断しました。この遅延は、緊急のアクセス権限の剥奪でも同じだけ発生します。即時に止める必要があれば、Lambdaの実行環境を入れ替えてキャッシュごと破棄できます。 危険な操作への承認フロー ポリシーで承認が必要と宣言されたコマンドは、即座には実行されません。まずdispatch Jobが、実行内容と承認ボタンを含むメッセージをSlackに投稿します。承認者がボタンを押すと、そのコールバックが再びGatekeeperに届きます。Gatekeeperは押した人がポリシー上の承認者であることを検証し、DynamoDBを使ったワンショット制御で同じ承認が二度実行されないことを保証してから、実行イベントをEKSへ転送します。 承認の細部もポリシーで宣言できます。承認には有効期限( approval_ttl_minutes )があり、期限を過ぎた承認リクエストは失効します。実行を依頼した本人による自己承認を許すかどうか( allow_self_approval )も制御でき、危険な操作では自己承認を禁止しています。 承認のコールバックも入口はコマンド投稿と同じGatekeeperです。認可の判断ロジックが1か所に集まっているため、「承認ボタンを押せる人の検証が漏れる」といった抜け道が生まれにくい構造になっています。 許可も記録する構造化監査ログ Gatekeeperは、拒否だけでなく許可した実行も構造化ログとして記録します。テナント、チャネル、コマンド、実行者、経路(Workflow経由または直接メンション)、判断結果と理由コードがワンレコードに収まっています。そのため「先月このコマンドを実行したのは誰か」「特定ユーザーの実行が拒否された理由は何か」をログ検索だけで答えられます。 { " event ": " chatops_allowed ", " team ": " zozo-cart ", " channel_id ": " C0123456789 ", " command ": " example-tool update ", " user_id ": " U001 ", " via ": " workflow/B0XXXXXXXXX " } { " event ": " chatops_denied ", " team ": " zozo-cart ", " channel_id ": " C0123456789 ", " command ": " example-tool delete ", " user_id ": " U003 ", " reason ": " user_not_allowed " } 拒否ログだけでは監査になりません。インシデント調査で本当に知りたいのは「誰が実行できたのか」だからです。許可の記録を含めて初めて、実行の全体像を後から再構成できます。 もう1つの設計点は網羅性です。Gatekeeperのすべてのレスポンス経路は、許可、拒否、リトライ破棄、転送失敗といったいずれかのイベント種別で必ずログに残ります。「ログに現れない実行」は構造上存在しないという性質が、監査ログとして信頼するための前提です。 なぜArgo Eventsなのか 入口の関門はこれで揃いました。次はEKS側、受け取ったイベントを実行する基盤です。Argo Eventsは、Kubernetes上でイベント駆動の処理を実現するためのツールです。次の3つのコンポーネントで構成されます。 EventSource : Webhookなどでイベントを受信してEventBusへ流す EventBus : イベントを配送するメッセージバス(NATS JetStreamを使用) Sensor : イベントをフィルタし、条件に合致したらトリガー(K8s Jobの作成など)を発火する 採用の決め手は次の3点です。 イベントを受けてK8s Jobを起動する仕組みを、自作せずに宣言的なマニフェストで実現できる EventSource、EventBus、SensorがすべてKubernetesリソースなので、既存のGitOps(Flux)の管理に乗る ZOZOではArgo WorkflowsのCronWorkflowを運用済みで、Argoエコシステムの運用知見がある これに加えて、他チームの案件でArgo Events自体の導入がすでに決まっており、クラスタにはコントローラも導入済みでした。技術的な適合とは別の要素ですが、この状況も採用を後押ししました。 Argo Eventsを本番運用に載せる設計判断 そのArgo Eventsを本番の実行基盤として運用するうえで、設計判断がいくつかありました。 EventBusは高可用性をあえて求めない EventBusはNATS JetStreamで構成しています。ストリームには300秒の重複排除窓を設定しており、再送があってもJobが二重に走らない構造です。Slackのリトライは手前のGatekeeperで破棄済みのため、この窓が受け持つのはArgo Events内部の再配送だけです。 レプリカ数は、本番も含めて1にしています。運用コマンドの実行基盤は、決済のようなミッションクリティカルなデータパスではありません。EventBusが短時間止まっても、利用者がコマンドを打ち直せば回復できます。 むしろ判断が要ったのは永続化です。JetStreamはPersistentVolumeへの永続化を構成できますが、この基盤では設定せずemptyDirのまま使っています。EventBusを通るのは、EventSourceが受けてからSensorがJobを作るまでのごく短命なイベントだけです。承認待ちのような長寿命の状態はDynamoDB側が持つため、JetStreamには残りません。そしてPVを付けても「EventBusの再起動中に届いたコマンド」は受けられないので、取りこぼしはゼロにならず、EBSのアタッチを挟むぶん復帰はかえって遅くなります。どちらの失敗でも受付通知が返らないため、利用者は気付いて再実行できます。それなら復帰が速い構成を選ぶ、という判断です。 PodDisruptionBudgetはクラスタポリシーが存在を要求するため置いていますが、 minAvailable: 0 でevictionを妨げない値にしています。単一レプリカで止まることを受け入れる方針を、PDBの値でも一貫させています。 なお、これはテナント単位の判断です。EventBusを含むArgo Eventsのリソース一式はテナントごとに独立しているため、高い可用性が必要なチームは、自分のテナントだけレプリカ数や永続化を引き上げられます。 監視の組み込み EventBus、EventSource、SensorはいずれもPrometheus形式のメトリクスを公開しています。このメトリクスをDatadogのAutodiscoveryアノテーションでスクレイプして、NATSとArgo Eventsの状態を既存の監視基盤に載せています。イベントの滞留や配送失敗を、他のサービスと同じダッシュボードとアラートの体系で扱えます。 Sensorはルーティングだけ、実行権限はexecuteだけ Sensorのフィルタは、イベント種別と承認要否によるルーティングだけです。承認が必要なコマンドは承認リクエストを投稿するdispatch Jobへ、承認不要なコマンドと承認済みのコールバックは実行を担うexecute Jobへ振り分けます。認可はGatekeeperで完結済みなので、Sensorに認可のロジックはありません。 実行(Lambda invokeとK8s Jobの作成)をexecute Jobに一本化しているのは、実行権限を1か所に集約するためです。Lambdaを呼べるIAMロールとJobを作れるRBACは、execute JobのServiceAccountだけに与えます。承認リクエストを投稿するだけのdispatch Jobは、実行権限を一切持ちません。なお、dispatchとexecuteは2つのサブコマンドを持つ1つのバイナリで、ペイロードの型とSlack通知の実装を共有しています。 K8s Jobによるツール実行機構 PodTemplateでツールのJob定義を配布する ツールをK8s Jobとして実行するには、「どんなPodを起動するか」の定義が必要です。この定義を v1 PodTemplate リソースとしてテナントのnamespaceに配布し、実行時にexecute Jobが PodTemplate を読んでJobを組み立てる方式にしました。 検討のポイントは、このテンプレートをどこに置いておくかでした。テンプレートもGitOps(Flux)の管理に乗せたいので、Kubernetesのオブジェクトとしてクラスタ上に置けるリソースが優先候補になります。素直に kind: Job のマニフェストを置く案が最初に浮かびますが、Jobは作成された時点で1回実行されるリソースなので、配布と同時に動いてしまい成立しません。 suspend: true のCronJobなら起動せずに置いておけます。ただし、suspendの解除ミスで意図せず動いてしまう危うさが残ります。PodTemplateは単体では何も実行しないKubernetesネイティブのリソースで、スキーマ検証も効きます。「実行されないテンプレート」という意図を、運用ルールではなく構造で表現できます。 Jobレベルの設定はテンプレート側に持たせず、実行機構側で決めています。リトライはさせず( backoffLimit: 0 )、完了したJobは1時間で自動削除します( ttlSecondsAfterFinished: 3600 )。例外は実行時間の上限( activeDeadlineSeconds )で、これはツールごとに変えたい値です。ただしPodTemplateが持てるのはPodのspecだけで、Jobレベルのフィールドは書けません。そこで、この値はPodTemplateのannotationで宣言してもらい、実行機構がJobを組み立てる際に読み取って反映する形にしました。テナントが管理するのはPodの中身だけ、Jobとしての振る舞いは基盤が統一する、という責務の線引きです。 テナントが配布するPodTemplateのマニフェストは、次のような形です(抜粋)。 apiVersion : v1 kind : PodTemplate metadata : name : zozo-cart-chatops-template-example-tool annotations : # JobレベルのactiveDeadlineSeconds(実行機構がJob specに設定する) chatops.zozo.com/job-active-deadline-seconds : "900" template : metadata : labels : app : zozo-cart-chatops-tool spec : restartPolicy : Never containers : - name : example-tool image : example-tool:<tag> # イメージのフルURIはoverlayのpatchが与える command : - example-tool - chatops env : - name : TOOL_ENV value : "" # 環境名もoverlayのpatchが与える # DB認証などのシークレットはsecretKeyRefで注入する なお、マニフェストにシークレットの生の値は置きません。実体はAWS Secrets Managerにあり、それをExternal SecretsがKubernetes Secretへ同期し、Podは secretKeyRef で参照します。 実行機構がPodTemplateからJobを組み立てる部分の実装は、次のような形です。 // Jobレベルの機構ポリシー。ツールによらず共通の値のため固定する const ( jobBackoffLimit int32 = 0 // リトライは利用者の再実行に任せる jobTTLSecondsAfterFinished int32 = 3600 // 完了したJobの掃除 ) // ツール別に調整するJobレベルの実行時間上限(テンプレートのannotationで宣言する) const activeDeadlineAnnotation = "chatops.zozo.com/job-active-deadline-seconds" template, err := client.CoreV1().PodTemplates(namespace).Get(ctx, templateName, metav1.GetOptions{}) // ...(annotationから実行時間上限を読み取り、Pod specを検証する) job := &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ GenerateName: "chatops-" + tool + "-" , }, Spec: batchv1.JobSpec{ BackoffLimit: ptr.To(jobBackoffLimit), TTLSecondsAfterFinished: ptr.To(jobTTLSecondsAfterFinished), ActiveDeadlineSeconds: ptr.To(activeDeadline), // annotation由来 Template: corev1.PodTemplateSpec{ ObjectMeta: podMeta, Spec: *template.Template.Spec.DeepCopy(), }, }, } マルチテナント設計とツール追加の体験 共通の入口と、テナントごとの実行スタック この仕組みは、カート決済SREブロック専用ではなく、社内の複数チームで使える共通基盤として設計しています。Slack App、API Gateway、Gatekeeperは全テナント共通です。一方、EKS側のEventSource、EventBus、Sensor、Jobの一式は、テナントのnamespaceごとに独立しています。Gatekeeperがchannel-mapで解決したテナントに応じて転送先を切り替えるため、あるテナントのイベントが他のテナントの実行スタックに流れることはありません。 ツールに渡す標準ペイロード 実行機構からツールへは、次の形の標準ペイロードを渡します。 { " kind ": " chatops-tool ", " tool ": " example-tool ", " command ": " update ", " args ": [ " ... " ] , " requester ": " U12345678 ", " channel ": " C12345678 ", " team ": " zozo-cart ", " ts ": " 1756... " } 引数( args )の解釈はツール自身が行います。基盤側がツールごとの引数仕様を知る構造にすると、ツールを追加するたびに基盤へ変更が波及するためです。ツールはこのペイロードを受け取れる形にさえなっていれば、K8s JobでもLambdaでも構いません。 Lambdaツールの場合は、実行機構のJobがこのペイロードを渡してLambda関数をinvokeします。旧基盤から移行したLambdaツールは、関数やIAMロール、VPC設定をそのまま維持して、標準ペイロードを受理する改修だけでトリガー経路を差し替えられました。実行形態の自由とは、新しいツールの選択肢が増えることだけでなく、既存のLambda資産を作り直さずに済むことでもあります。 ツール追加はSSMポリシーとマニフェストだけ 新しいツールをChatOpsに追加する手順は次の2つだけです。 SSMに /chatops/policy/{テナント}/{ツール} のポリシーを追加する(コマンド、許可、承認、実行形態の宣言) K8s Jobツールの場合は PodTemplate のマニフェストを追加する。Lambdaツールの場合は標準ペイロードを受理するようにする Slack App、API Gateway、Gatekeeperのコードやリソースには一切手を入れません。従来はツールごとにLambdaの新設が必須でしたが、今はコンテナイメージとして動くツールならPodTemplateを書くだけでChatOpsに載ります。 導入の効果 移行によって、冒頭の課題は次のように解消されました。 コマンド、ユーザー、チャネルの単位の認可と、危険操作への承認フローが基盤の機能として動いている 許可を含むすべての実行判断が構造化ログとして残り、監査とインシデント調査で使える状態になった 非エンジニアの利用者は、従来と同じWorkflowフォームのUXのまま移行できた ツールの実行形態がK8s JobとLambdaの2択になり、追加コストが下がった まとめ 本記事では、AWS Chatbotで運用していたChatOps基盤を、Argo EventsとGatekeeper Lambdaを軸にした構成へ再構築した事例を紹介しました。実行者を直接特定できないWorkflow経由の投稿に対しては、信頼チェーンで認可を成立させました。この信頼チェーンと、実行基盤の手前での認可、承認、監査の一元化が設計の要点です。Slackを入口とした運用基盤の構築や、Argo Eventsの本番運用を検討している方の参考になれば幸いです。今後は実行時権限のさらなる最小化など、基盤の堅牢化を進めていきたいと考えています。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは。検索基盤ブロックのXEIです。私たちのチームには、ZOZOTOWNの検索に関する問い合わせが社内から日々寄せられます。 本記事では、この問い合わせ対応を複数のAIエージェントの組み合わせでどう効率化したかを紹介します。読み終わる頃には、役割を分けたAIエージェントをどう組み合わせ、人間の関与をどこに残すと問い合わせの件数を維持したまま対応の負荷を下げられるのか、その設計の勘所を持ち帰っていただけるはずです。なお、本記事は「問い合わせに回答する」部分に絞り、管理・運用の自動化は扱いません。 目次 はじめに 目次 背景と課題 検索チームに問い合わせが集まる理由 問い合わせの負荷 体制の変化と、自動化という課題 解決アプローチの全体像 どのように解決したか 前提:登場するエージェントの役割 過去の知識をどう掘り起こすか なにが難しいか 過去の問い合わせは「意味」で探す 仕様ドキュメントは複数の言い回しで探す 設計で意識したこと ハルシネーションをどう抑えるか 生成と検証を分ける 主張単位の判定と差し戻し コードを追跡しないと答えが出ないケース 知識をどう増やし続けるか 対応した先から知識になる この仕組みの限界 全体をどう協調させるか 状態を判定して呼び分ける Human-in-the-loop:静観と再評価 自律性と安全の線引き 実運用で踏んだ落とし穴 二重発火 ── 同じ依頼を2つの経路が同時に拾う 永久待機 ── 「まだ途中」と誤解したまま止まる 共通する教訓 効果 AIの一次回答はどのくらい採用されたか 数字の読み方 今後の課題と展望 問い合わせ元への自動回答 精度指標の整備 内部実装まで踏み込む問い合わせへの対応 問い合わせから案件への引き継ぎ 人間側の作業手順の蓄積と、実作業の自動化 検索エンジン上の実データとの接続 まとめ 背景と課題 検索チームに問い合わせが集まる理由 ZOZOTOWNにおいて検索は、ユーザーが商品にたどり着くための中心的な導線です。「検索」というと、検索窓にキーワードを入れて結果を出すだけに見えます。しかし実際には、キーワード検索に加えて「絞り込みや並び順のロジック」「検索結果画面の表示」「サジェスト」など、検索ページに関わる機能の多くが対象です。さらに、それらを支える検索用データの生成まで担当範囲に含まれます。検索基盤ブロックはこれらの開発・保守運用を担っており、担当範囲が広い分、仕様と開発環境の双方にまたがる問い合わせがおのずと集まってきます。 問い合わせの負荷 他方で、検索の仕様は「なぜこの並び順なのか」「なぜこのデータを使っているのか」といった過去の経緯の中に答えが埋まっていることも多いものです。そのため、1か所のドキュメントだけを見ても答えはそろいません。回答するには、次の3つを横断して突き合わせる必要がありました。 記録 :Slackの過去スレッド・タスク管理ツール(Jira)・社内Wiki(Confluence)に散らばった断片的な経緯 コード :検索は複数のマイクロサービスで構成されており、問い合わせによっては検索エンジンやバッチ、データ基盤まで確認対象が広がる データの実物 :実際に入っているデータと、それがどう作られているか 担当者はこの調査を案件開発と並行して行い、突き合わせた情報から包括的に判断して回答していました。 この1件ごとの重さが月に数十件積み重なり、問い合わせ対応はチーム工数の5.8%(2025年10月〜2026年5月の平均。月約10人日)を占めていました。 体制の変化と、自動化という課題 この負荷を下げるため、まずは個人の作業としてAIの活用を試しました。調査手順をスキル化し、下調べをローカルのAIエージェントに任せる形です。手元で試した範囲ながら実務に使えると判断できる水準になったため、2026年6月、開発案件の増加に対応する体制見直しの中で、問い合わせ対応の主担当を1名に集約する判断ができました。ただ、スキル自体は誰でも使える形でチームに共有していたものの、下調べの実行そのものは、実行する人の環境と稼働に頼ったままでした。 このパーツ群を、Slack上で自動的に動く仕組みに組み上げれば、もっと人の手を離れた一次回答が作れるはずです。チームとして持続する自動対応の流れを作ることが、次の課題になりました。 解決アプローチの全体像 目指したのは、なるべく正確な一次回答を、人間の手を借りずに作りきることです。人間の関与を「担当者が、AIの作った一次回答を確かめて返す」ところまで後ろへ寄せるため、役割を分けた複数のAIエージェントをSlack上でオーケストレーションする構成をとりました。 本記事では、この構成に切り替えた2026年6月以降を「仕組み化後」と呼びます。 この分担にしたのは、情報源を横断する調査や定型的な検証はAIに任せれば速いと考えた一方、仕様の妥当性の判断や関係者との調整は、現時点では人間の側で担う必要があるためです。AIが過去の問い合わせの深掘り・一次回答の生成・コードによる裏取りまでを担い、人間は判断と最終回答、そしてAIに任せられない実作業(環境の整備や修正リリースの作業、他チームとの調整など)に集中します。 構成要素は次の5つです。 過去の知識の深掘り :過去の問い合わせや社内Wikiを横断して、類似事象と経緯を掘り起こす 一次回答の生成 :掘り起こした情報をもとに、回答ドラフト(一次回答のもとになるもの)をスレッドへ投稿する 裏取り :スレッドに投稿された回答ドラフトの事実主張を、コードと突き合わせて検証する 知識の蓄積 :届いた問い合わせを構造化ページに集約し、対応の経過も含めて次回以降の回答の素材にする オーケストレーション :スレッドの状態を監視して各エージェントを呼び分け、コード追跡が必要と判断した場合はコード調査に特化した自律型エージェント(以下、外部エージェント)へエスカレーションする 最初から全体を作り込んだわけではありません。手元でスキル化していたパーツを1つずつ実務で試し、実用に耐えると確認できたものから順にパイプラインへ組み込んで自動化を進めました。速度と最終的な効率の両立を優先した判断です。これらをどう組み合わせて一次回答の精度と速度を両立させたかを、次のセクションで4つの問いに整理して掘り下げます。 どのように解決したか 前のセクションで紹介した全体像を、4つの問いに分けて説明します。いずれも「個々のパーツをどう作るか」ではなく、「どう組み合わせれば一次回答の精度と速度を両立できるか」という観点で設計しています。なお、前のセクションの構成要素のうち「過去の知識の深掘り」と「一次回答の生成」は、最初のセクションでまとめて扱います。 前提:登場するエージェントの役割 先に、登場するエージェントの役割をまとめておきます。過去の知識を掘り起こして一次回答を生成する役割と、投稿された回答ドラフトの事実主張をコードと突き合わせる裏取りの役割があります。これに、コード調査に特化した外部エージェントと、スレッドの状態を判定してこれらを呼び分けるオーケストレーターが加わります。全体をどう協調させるかは、最後のセクションで詳しく扱います。 過去の知識をどう掘り起こすか なにが難しいか 問い合わせ対応の下調べで最も時間がかかるのは、過去の類似の問い合わせを探す作業でした。体験上、人がSlackを検索しても、当時と質問の言い回しが違うだけで類似の問い合わせにたどり着けないことが頻繁にあります。「なぜこの並び順なのか」への答えが、たとえば「特定の商品が検索結果の上位に出ない」という切り口で書かれた1年前のスレッドに眠っている、ということが実際に起きます。このケースでは、質問は仕様の言葉で、当時のスレッドは目の前の事象の言葉で書かれていて、語彙が重なりません。 過去の問い合わせは「意味」で探す そこで、過去の問い合わせを蓄積し、それを参照して回答を生成するRAG(Retrieval-Augmented Generation)を構築しました。検索は意味で探せるベクトル検索とし、言い回しが違っても類似事例へたどり着けるようにしています。取得した候補はそのまま使わず、関連度で並べ直してから回答生成に渡します。 ここでいうベクトル距離の近さは、おおまかに言えば「似た話題を扱っているか」です。たとえば「並び順が変わった」という質問には、並び順を話題にする過去の問い合わせが幅広く引っかかります。ただその中には、並び順の仕様を正面から説明したものもあれば、別の相談のついでに触れただけのものも混ざります。そこで並べ直す工程では、「この質問への答えを含んでいそうか」という観点で順位を付け直します(いわゆるリランキングです)。似た話題を広く集める工程と、答えを含んでいそうな順に並べる工程は、見ている物差しが違うので分けています。 この並べ直しには、質問と候補のペアを読み、意味的な関連度を採点する機械学習モデル(クラウドサービスとして提供されている既製のセマンティックランカー)を使っています。2つの工程は、モデルへの入力からして異なります。 集める工程 :質問も過去の問い合わせも、それぞれ単独で「意味を数値の列で表したもの(ベクトル)」に変換し、ベクトル同士の距離で比較します。過去の問い合わせ側は蓄積の時点で変換済みのため、大量の候補から似た話題を速く広く集められます。 並べ直す工程 :質問と候補をペアにしてモデルに渡し、「答えを含んでいそうか」を組ごとに採点させます。文章を突き合わせて読むぶん手間はかかりますが、質問と候補を直接見比べた判定ができます。 広く集めるのは距離の比較に任せ、集まった候補だけをペアで読んで並べ直す、という分担です。この入力の違いが、2つの工程の「物差しの違い」の実体です。 仕様ドキュメントは複数の言い回しで探す 一方、社内Wikiに散らばる仕様ドキュメントは、別の方法で探しています。質問文をそのまま検索するのではなく、検索クエリを複数の言い回しに拡張してから横断検索します。たとえば「BFF」は「検索BFF」へ、通称は正式名称へ、といった具合に、同じ対象を指す社内の別の呼び方まで広げてから探します。社内でしか通じない機能名や略称は、ベクトル検索でも拾いきれないことがあるためです。ドキュメントを書いた人と質問する人とで語彙がそろっていない、という社内ドキュメントにありがちな問題への対処でもあります。 つまり「過去の問い合わせは意味で探す」「仕様ドキュメントは複数の言い回しで探す」という、性質の異なる2つの情報源を、それぞれに合った方法で引く設計にしました。これは、人間の担当者が自然にやっていた「あの件どこかで見たな」と「仕様書ならあの辺りにあるはず」の使い分けと同じ構図です。 設計で意識したこと 情報源を増やすこと自体より、「引けなかったときに何が起きるか」を重視しました。類似事例が見つからなければ、回答は一般論に流れて薄くなります。だからこそ、過去の知識を掘り起こすだけで終わらせず、次に述べる裏取りと、知識を増やし続ける仕組みをセットで用意しました。 ハルシネーションをどう抑えるか 生成と検証を分ける 過去の問い合わせを参照して回答を生成するだけの構成では、もっともらしいが事実と異なる回答、いわゆるハルシネーションのリスクが残りました。検索の仕様に関する誤答は、それを前提に動く問い合わせ元の判断や実装を誤らせかねません。 対策は2つの軸で考えました。 生成したエージェント自身に検証させない :自分で書いた回答を自分で検証すると、生成時に参照した情報へ引っ張られて回答を追認しかねない、という懸念がありました。そこで、回答を生成する役割とは別に、裏取りを行う役割を用意しました。役割だけでなく権限も分けており、検証役にはコードを読む権限だけを与えて、書き込みは一切させません。 検証は回答の読み直しではなく、エビデンスとの突合で行う :スレッドに投稿された回答ドラフトから事実主張を1つずつ抽出し、実装のコードと突き合わせて検証します。 主張単位の判定と差し戻し 裏取りの結果、各主張は次のように判定され、判定ごとに扱いが変わります。 CONFIRMED (コードで確認できた):そのまま採用してよい主張として扱う REFUTED (コードと矛盾する):1件でもあればドラフトを差し戻して書き直させる UNVERIFIED (確認できなかった):差し戻しではなく、人間の判断に回す 検証そのものに失敗した場合も、安全側に倒して「確認できなかった」扱いにします。回答全体の雰囲気ではなく主張単位で判定するのは、「9割は正しいが1割は致命的に間違っている」回答こそが最も危険だからです。 差し戻しは無限には繰り返しません。上限を設け、上限に達した場合はAI同士のやり取りを打ち切って人間に判断を渡します。「AIだけで決着をつけない」出口を最初から用意しておくこともHuman-in-the-loopの設計の一部です。 コードを追跡しないと答えが出ないケース さらに、そもそも一次調査の時点で、実装コードを追跡しないと結論が出せない問い合わせもあります。このときはオーケストレーターが判断して、コード調査に特化した外部エージェントへエスカレーションします。その調査結果もいったん一次回答に統合したうえで、上記の裏取り工程を通します。エスカレーション先の指示にも、未確認の仮説をそのまま返さず実データで裏取りする、という原則を明記しています(コードだけでなく、必要ならデータ基盤上の実データまで確認する範囲です)。 調査先を分ける判断でもうひとつ効いたのが、「ないことの証明」の扱いです。過去の問い合わせに記録がないことと、実装コードに存在しないことは別物です。「この機能にそういう仕様はありません」という否定形の結論こそ、実装コードまで裏取りして初めてエビデンス付きで確定できます。記録が見つからないだけの状態を「ない」と断定してしまう誤りを、仕組みの側で防ぐためです。 適材適所と言ってしまえば単純です。ただ、ここでは「自然言語の経緯は蓄積した過去の問い合わせから」「コードの現状はコードを読める外部エージェントから」と、確認したい事実の性質で調査先を分けました。この分け方が、精度と速度の両立につながったと考えています。 知識をどう増やし続けるか 一次回答の質は、参照できる過去の問い合わせの量と質で決まります。そこで、問い合わせ対応用のSlackチャンネルに問い合わせが届いた時点で社内Wikiにページを生成し、対応の経過をそこへ集約するようにしました。残すのはスレッドの写しではなく、質問・調査の経過・結論を整理し直した構造化ページです。スレッドには試行錯誤や脱線が混ざっており、そのまま検索対象にするとノイズが多くなる一方、整理しておけば次回の検索で「使える部分」が引っかかりやすくなるためです。集約したページは、夜間バッチでRAGの参照元へ自動で取り込まれます。 対応した先から知識になる ポイントは、蓄積を「対応が終わった後に誰かがまとめる作業」にしなかったことです。まとめ作業を人に残すと、忙しい時期ほど蓄積が止まり、蓄積が止まるほど回答が薄くなって忙しくなる、という悪循環が起きます。届いた時点でページを作り、経過を機械的に集め、取り込みまで自動で回せています。これにより「今日対応した問い合わせが、明日の一次回答の素材になる」ループができました。対応すればするほど回答が育つ構造にしたことが、後述の効果の土台になったと考えています。 ただし、蓄積されない・浮上しないケースが残課題です。問い合わせ対応用のチャンネルへ転送されなかった依頼は、そもそもページが作られないため蓄積されません。また、内容の構造化に失敗したページは、蓄積されていても検索で浮上しません。 この仕組みの限界 自動でたまるのは、AIが残した記録です。AIが行った調査や一次回答は、スレッドへの投稿や調査の記録として自然に蓄積の対象になります。一方、人間が担った実作業、たとえば環境の整備や修正リリースの作業の手順は、担当者がスレッドに書き残さない限り、仕組みの側には残りません。書き残す運用は始めていますが、徹底は人に依存しているのが現状です。AIの作業は自動で記録に残る一方、人間の作業は書き残されない限り抜け落ちます。この偏りが、この仕組みの限界です(今後の課題と展望のセクションで触れます)。 全体をどう協調させるか 状態を判定して呼び分ける ここまでのパーツを人手でつなぐと、結局その操作が担当者の稼働に依存します。そこで、スレッドの状態を監視するオーケストレーターを置き、「人の判断待ちか」「他のBotの応答待ちか」「コード追跡が必要か」を判定して、各エージェントを呼び分けるようにしました。 スレッドを「状態を持つ1つの作業単位」として扱い、新しい投稿を検知したら「いま誰の番か」を判定し直す、という設計です。この「いま誰の番か」の判定は、ルールで書き切るのではなく、スレッドの文脈を読ませて判断させています。「担当者が他チームと相談している最中」と「担当者の返事を待っているだけ」は、投稿の形式だけでは区別できない、会話の機微だからです。個々のエージェントは自分の役割を果たして結果をスレッドに返すだけで、次に誰が動くかはオーケストレーターが決めます。エージェント同士を直接つなぐ配線を作らなかったことで、パーツの追加や差し替えがしやすくなりました。 Human-in-the-loop:静観と再評価 最も重視したのがHuman-in-the-loopの設計です。AIが自律的に動く一方で、スレッドの主体が人間に移ったと判断したら静観に入ります。担当者が調整や実作業を進めている最中に、AIが割り込んでしまうことを防ぐためです。Human-in-the-loopといっても、進め方の指示を人間へ都度求める形にはしていません。人間に判断を仰ぐのは回答の妥当性のような要所だけに絞り、いつ動いていつ引くかは、スレッドの文脈からAI自身が判断します。人間の操作を極限まで減らすことを狙った設計です。 ただし、静観したまま忘れてしまっては意味がありません。スレッドの状態変化(人間の発言、メンション、一定時間の経過)を検知して自動で再評価し、必要なら動き直します。再開の合図を人間のメンションだけに頼らないのは、「呼ばれるまで動かないBot」は結局呼ぶこと自体が担当者の仕事になってしまうためです。 「AIが勝手に進めて人間の対応と衝突する」ことと「AIが止まったまま誰も気づかない」ことは、どちらか一方だけを防ごうとするともう一方が起きます。両方を、状態の再評価という同じ仕組みで防ぐ設計にしました。 自律性と安全の線引き 自律的に動かす範囲と、動かさない範囲もあらかじめ決めています。エスカレーション先の外部エージェントには、独自の判断でチケットの遷移やリリース操作といった「決定的なアクション」を行わない、という線引きを指示に明記しています。この線引きは、前述の裏取りの原則と同じ指示書に並べて書いています。検証役の書き込みを権限そのもので塞いだのとは対照的に、こちらは指示による規律で、縛り方を場面で使い分けています。調査し、書き、検証し、提案するところまでをこのパイプラインでのAIの領分とします。自律性を上げるほど、「やらせないことの明文化」が安全弁として効いてきます。 実運用で踏んだ落とし穴 複数のエージェントをスレッド上で協調させる仕組みには、状態管理を実運用に載せて初めて分かる落とし穴がありました。代表的な2つを紹介します。 二重発火 ── 同じ依頼を2つの経路が同時に拾う ある問い合わせで、コード調査へのエスカレーションが二重に飛んだことがありました。外部エージェントの呼び出しは費用に直結するので、二重呼び出しは実害です。原因はトリガーの並走です。オーケストレーターの起動経路には、メンションを受けて動く経路と、スレッドの状態変化を定期的に検知して動く経路があります。単体ではどちらも正しく動くのに、同じ依頼をほぼ同時に拾うと、それぞれが「まだ誰も対応していない」と判断して同じ処理を始めてしまいます。 対策は2段のガードにしました。 処理を始めた痕跡をスレッド自体に残し、痕跡があればそもそも発火しない 痕跡の投稿が反映されるまでのわずかな時間を塞ぐため、同じ入口に届く同種のトリガーを一定時間内なら後着スキップする それぞれの穴を別の方式で塞いで重ねる、という考え方です。 永久待機 ── 「まだ途中」と誤解したまま止まる もうひとつは、スレッドが止まっていた事故です。ログを見ると、オーケストレーターは「他のBotの応答待ち」と判定し続けていました。原因は意外なところにありました。待っていた投稿が長文で、判定に渡るテキストが途中で切り詰められていたのです。文が途中で終わっているのを見たオーケストレーターは「投稿がまだ完了していない」と解釈し、待ち続けていました。 対策は2つです。まず、テキストを切り詰めるときは「ここで切り詰めた」という目印を必ず付け、「途中に見えるが完了している」ことをオーケストレーターに伝えます。そのうえで、待機状態は一定時間が経ったら自動で再評価します。再評価が無限に回らないよう上限を設け、それでも待機が解けない場合は別の定期チェックで拾い、「待ち続けたまま誰も気づかない」状態に気づける経路を用意しました。 共通する教訓 どちらも共通するのは、「単体のテストでは想定しづらく、スレッドという共有状態の上で複数の主体が動いて初めて起きる」ことです。そしてどちらの対策も、正常系の機能追加ではなく「多重実行の抑止」と「待機状態の再評価」という守りの設計でした。オーケストレーションを導入するときは、正常系の設計と同じ重さでこの2つを設計することをおすすめします。 効果 仕組み化の前後で、問い合わせ対応の負荷は次のように変わりました。 問い合わせ件数は月数十件の水準で横ばいのまま、対応工数はチーム工数の5.8%(月約10人日)から3.4%(月約5人日・2026年6月〜8月の平均)に下がりました Jiraで計測している問い合わせ対応のリードタイム(起票から解決まで)は、中央値で2026年5月の6日から、7月以降は1日に短縮しました 件数が減っていないのに工数が下がっているのは、1件あたりの下調べと一次回答の生成をAIが担うようになり、人間の作業が判断・最終回答と、AIに任せられない実作業へ寄ったためだと考えています。数字の読み方は、このセクションの最後にまとめています。 AIの一次回答はどのくらい採用されたか 回答の中身の側も1つ数えました。2026年6月〜8月に解決した問い合わせチケットのうち、回答が主目的で、AIの一次回答と問い合わせ元スレッドの最終回答を意味のレベルで突き合わせられたのは34件でした。このうち、最終回答がAIの一次回答と大筋で一致していた(そのまま、または一部の事実を直して使われた)のは25件(74%)、意味を変えずそのまま使われたのは12件(35%)でした。 一方で、内部実装の深い仕様や複数の要因の重なりを理解しないと正しい回答にたどり着けない問い合わせでは、AIの一次回答に大幅な修正が必要になるか、使われずに人間の調査結果に置き換わりました。この課題は今後の課題と展望のセクションで触れます。 数字の読み方 なお、これらの数字は厳密な効果測定ではなく、傾向を示す参考値です。 工数 :各メンバーが工数管理ツールに入力した自己申告値。月ごとの振れが大きく、平均は期間の取り方によって動く 件数 :対象期間の途中で、計測の母体が手動集計からJiraでの管理に切り替わっている リードタイム :Jiraでの運用を始めて以降の集計。比較の起点がその運用を始めた直後にあたるため、起票・解決のタイミングが実態より粗い可能性がある。2026年8月分は集計時点までに解決した問い合わせで数えており、恒久対応の継続や応答待ちなど、調査・回答以外の時間を含む問い合わせもある。短縮幅が工数の減少幅より大きいのは、こうした問い合わせを後続チケットへ切り出す運用が同じ時期に定着したことも影響していると考えている(内訳は今後の課題と展望のセクションを参照) 一次回答の採用状況 :集計時点までに解決した問い合わせを対象にした、1名による判定。割合は突き合わせができなかった問い合わせ(18件)を除いた値で、境界的なケースや担当者本人への確認で分類したもの、担当者がAIに直接起草させた回答を一次回答として数えたものも含む 仕組み化前との比較 :仕組み化前の期間の後半にはすでに個人単位でのAI活用が始まっており、負荷の減少が仕組み化以前から始まっていた可能性は残る こうした前提はあるものの、「対応の件数を維持したまま、問い合わせ対応に割く時間の割合を下げられた」という傾向は、体感とも一致しています。 今後の課題と展望 問い合わせ元への自動回答 現在は、AIが作った一次回答を、人間が妥当性を判断したうえで問い合わせ元へ返しています。ここでいう妥当性は、情報が正しいかどうかだけではありません。質問者の背景に合った粒度や表現になっているか、そもそもその質問の先で本当は何をしたいのか、という「解答としての妥当性」まで含まれます。この判断の自動化に、裏取りの仕組みを足がかりとして次に挑みたいと考えています。 精度指標の整備 本記事では、最終回答がAIの一次回答と大筋で一致した割合を振り返りで数えましたが、回答の「正確性」「わかりやすさ」そのものの計測は担当者の確認に依存しています。継続的に計測して改善につなげる指標づくりはこれからです。 内部実装まで踏み込む問い合わせへの対応 検索語の変換経路や正規化処理といった内部実装の深い仕様、あるいは複数の要因の重なりを理解しないと、正しい回答にたどり着けない問い合わせがあります。こうした問い合わせでは、AIの一次回答に大幅な修正が必要になるか、使われずに人間の調査結果に置き換わりました。過去の問い合わせの蓄積を参照するだけでは届かず、裏取りやコード調査へのエスカレーションを備えたあとも残っている領域で、現在の構成の限界です。コード調査へのエスカレーションと知識の蓄積を、この領域にどう活かせるかを考えています。 問い合わせから案件への引き継ぎ リードタイムの内訳を調べると、最も長引いていたのは、恒久対応や実装、方針調整の継続を問い合わせチケットのまま持ち続けたケースでした。2026年6月〜8月に解決した問い合わせのうち、実装・案件対応へ発展したのは19件で、うち17件は運用を判別できました。この17件では、調査・回答の完了時点で早期に後続チケットへ切り出した9件が中央値4日でクローズしている一方、実装まで同じチケットで抱えた5件は中央値30日でした。切り出した場合でも、長く持ち続けた末の切り出しになったものが3件(31〜62日)あります(切り出した場合の日数には、後続チケットでの実装完了までの時間は含みません)。抱え込みは対象期間の初期に集中しており、7月以降に起票された問い合わせで実装まで抱え込んだ例はありません。なお、中央値4日と30日の比較には、難しい案件ほど抱え込まれやすかったという案件の性質の違いも含まれている可能性があり、切り出し自体の効果だけを示すものではありません。それでも抱え込みを避ける利点は大きいと判断し、追加の対応が必要になった問い合わせは、回答が完了した時点で対応を後続チケットへ切り出して問い合わせをクローズする運用に、チームでそろえることを決めました。次は、この切り出し要否の判断自体をパイプラインが支援する仕組み(回答完了時に、後続チケットの起票とクローズをボタンで提案する構想)を検討しています。 人間側の作業手順の蓄積と、実作業の自動化 同じ2026年6月〜8月の問い合わせの3分の1ほどは、回答ではなくデータ反映などの作業実行が主目的でした。AIが行った調査や回答は、転送された問い合わせについてはおおむね自動で蓄積される一方、人間が担った実作業の手順は、担当者がスレッドに書き残さない限り、仕組みの側には残りません。ここが自然に蓄積されるようになると、回答できる問い合わせの幅がさらに広がります。 検索エンジン上の実データとの接続 現在のパイプラインは、コードとデータ基盤上の実データまでは自動で裏取りできる一方、検索エンジン上のデータを直接確認する接続はまだありません。データの実物を見ないと答えが出ない問い合わせでは、そこが人間の確認として残ります。 まとめ 検索に関する社内問い合わせ対応を、役割を分けた複数のAIエージェントの組み合わせで効率化した取り組みを紹介しました。 個別のパーツは、AIを使えば短期間で作れるようになりました。価値が出るのはパーツそのものではなく、「どう組み合わせるか」と「人間がどこで関与するか」の設計だと私たちは考えています。同じように社内の問い合わせ対応に時間を取られているチームの参考になればうれしいです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、EC基盤開発本部SRE部でテックリードを務めている杉山です。 ZOZOでは、長年運用してきた基幹システムを新しいアーキテクチャへリプレイスする取り組みを進めています。新システムではAPIにJava、フロントエンド+BFFにReact(TypeScript / Node.js)を採用し、インフラはKubernetes上に構築しています。 2025年11月に開催されたファインディ株式会社主催の「アーキテクチャConference 2025」では、「巨大モノリスのリプレイス──機能整理とハイブリッドアーキテクチャで挑んだ再構築戦略」と題して発表しました。基幹システムの課題と、アーキテクチャの方向性を紹介した内容です。 findy-tools.io その中で、基幹フロントエンドリプレイスについても触れています。既存システムと新システムを共存させながら、Kubernetes上で機能ごとにパスルーティングし、段階的に置き換えていく構想です。 しかし、当時このアーキテクチャはまだ「予定」でした。実現するためには、既存システムが長年抱えてきたある制約を取り除く必要があったためです。 それが、Webサーバーがユーザーセッションを保持する「ステートフル」な構成です。 本記事では、既存システムのIISが保持していたセッションをRedisへオフロードし、Webサーバーをステートレス化するまでの取り組みを紹介します。それによって基幹システムの段階的なフロントエンドリプレイスを進められるようになった背景にも触れます。 目次 はじめに 目次 基幹フロントエンドリプレイスの構想 新アーキテクチャを用意するだけでは移行できない 段階的リプレイスを阻んだIISセッション 認証だけではないIISセッションへの依存 認証をOIDC化するだけでは解決できない リプレイスの前に「状態」を切り離す IISセッションをRedisへオフロードする Before / After 独自フレームワークを変更した理由 セッションIDの引き回し シリアライズ方式と互換性 更新タイミングとエラー時のロールバック セッション期限管理の設計 Redis障害時の考え方 Redis Clusterの障害対策とキー設計 段階的な切り替えとフォールバック ステートレス化によって何が変わったのか いよいよ基幹フロントエンドのリプレイスへ まとめ 基幹フロントエンドリプレイスの構想 まず、ZOZOの基幹システムとリプレイスの背景について簡単に紹介します。 既存の基幹システムはClassic ASP(VBScript)/ IISを中心に構築され、長年にわたって機能追加を続けてきました。ZOZOは自社で倉庫を持っており、基幹システムには事業部が利用する業務機能だけでなく、ECサイトで取り扱う商品の物流機能も含まれています。非常に巨大なシステムで、長期間の運用によってモノリス化が進み、保守性・拡張性の低下や技術的負債の蓄積が課題となっています。 そこで現在、既存システムを新しいアーキテクチャへリプレイスする取り組みを進めています。 ただし、巨大な基幹システムを一度にすべて置き換えることは現実的ではありません。 ドメイン分離が容易な部分のマイクロサービス化やデータベースアクセス部分のマイクロサービスAPI化は進んでいますが、基幹システムのメインのフロントエンドのリプレイスはまだこれからです。 既存システムと新システムを一定の期間共存させ、機能単位で徐々に切り替えていく方針を考えました。 具体的には、次のような構成を目指しています。 Kubernetes基盤上にIngress + Istioによるルーティングの仕組みを構築し、URLのパスなどに応じてリクエストを振り分けます。これにより、既存システムを稼働させたまま一部の機能から置き換えていく、いわゆるストラングラーパターンによる段階的なリプレイスを目指しました。 「アーキテクチャConference 2025」でこの構想を紹介した2025年11月時点では、まだ「ステートレス化後のフロントアーキテクチャ(予定)」という位置づけでした。 では、なぜすぐにこの構成へ移行できなかったのでしょうか。 新アーキテクチャを用意するだけでは移行できない 構成図だけを見ると、新システムをKubernetes上に構築し、Ingressなどでパスルーティングすれば、既存システムから少しずつ移行できるように思えるかもしれません。 しかし、実際の既存システムには大きな制約がありました。 Webサーバー自身が状態を持っていた ことです。 既存システムでは、IISのセッション管理やサーバー上の作業用データファイルなど、動作に必要な状態をWebサーバー内部に保持していました。このような構成では、リクエストを処理するサーバーを自由に切り替えられません。 例えば、あるユーザーからの最初のリクエストをWebサーバーAが処理し、そのサーバーのメモリ上にセッションを作成したとします。次のリクエストがWebサーバーBへ送られると、WebサーバーBにはそのセッションが存在しません。 これは単純なスケールアウトだけでなく、Kubernetesへの移行でも問題になります。KubernetesではPodが作成・削除されることを前提としており、特定のWebサーバーやPodのローカルな状態に依存しない、ステートレスなアプリケーションが扱いやすい構成となります。 そして今回、もう1つ大きな問題となったのが 段階的リプレイス でした。 段階的リプレイスを阻んだIISセッション 今回目指しているのは、既存システムをあるタイミングですべて停止し、新システムへ一斉に切り替えるリプレイスではありません。既存システムと新システムを共存させながら、ページや機能単位で少しずつ移行していく、ストラングラーパターンによる段階的なリプレイスです。 この構成を実現するうえで、大きな壁となったのが、既存システムのIISセッションへの依存でした。 認証だけではないIISセッションへの依存 既存の基幹システムでは、IISのインメモリセッションをさまざまな用途で利用しています。代表的なものがユーザーの認証情報ですが、セッションの用途は認証だけではありません。 基幹システムには、複数の画面を遷移しながら1つの業務を完了する機能が数多く存在します。その過程で入力・選択した情報など、画面をまたいで引き継ぐ必要がある一時的なデータの保持にもセッションを利用しています。 そのため、既存システムの画面遷移は、同じIISセッションを継続して参照できることを前提としていました。 ここで、ページ単位の段階的なリプレイスを考えてみます。 既存画面(Classic ASP / IIS) ↓ 新画面(新システム) ↓ 既存画面(Classic ASP / IIS) IngressやIstioを利用すれば、URLのパスに応じてリクエストを振り分けること自体は可能です。しかし、ルーティングだけを切り替えても、画面間で利用しているセッション情報まで引き継げるわけではありません。 例えば、既存画面で保持した認証情報や一時データを後続の画面で必要とする場合を考えます。遷移先がKubernetes上の新システムになると、IISのメモリ上に保持していたセッションをそのまま参照できません。 つまり、ログイン状態の維持だけが問題ではありません。既存システムにおける 画面間の状態の引き継ぎそのものが、IISのインメモリセッションに依存している ことが、段階的リプレイスの障壁でした。 認証をOIDC化するだけでは解決できない この問題に対して、認証方式そのものを変更するアプローチも考えられます。例えば認証をOIDC(OpenID Connect)化し、ID Tokenを利用する構成に変更したとします。認証情報を特定のIISサーバーのインメモリセッションに依存させず、新旧システムの双方でユーザーを識別できるようになります。 認証だけが課題であれば、この方法で解決できる可能性があります。しかし今回の基幹システムでは、OIDC化だけでは要件を満たせません。既存システムがIISセッションに保持しているのは認証情報だけではないこと、そして拠点専用のサーバー構成を脱却しALBによるロードバランシングを実現することも目的としていたためです。 仮にOIDC化によって「誰がログインしているのか」を新旧双方で識別できるようになったとします。それでも、画面で入力・選択した情報など、画面間で引き継いでいる業務上の一時データまでID Tokenで引き継げるわけではありません。 例えば、既存画面でセッションに保存した情報を、次の新システムの画面で必要とするケースを考えます。 既存画面 │ │ 認証情報 │ + │ 画面間で引き継ぐ業務データ ↓ IIS Session │ × │ 新画面(新システム) 認証方式だけを切り替えても、この「×」は残ります。 つまり、今回解決する必要があったのは、認証をステートレスにすることだけではありませんでした。必要だったのは、認証情報や画面間で引き継ぐ一時データを含め、 既存WebアプリケーションがIISに保持している状態そのものを、特定のWebサーバーから切り離すこと でした。 リプレイスの前に「状態」を切り離す このままでは、Kubernetes上に新しいアプリケーションを構築しても、ページ単位の段階的な置き換えは実現できません。 言い換えると、セッションの保存場所という既存システムの実装上の制約が、リプレイスできる単位まで制約していました。 そこで、新システムへの移行を本格化する前に、まず特定のIISサーバーに閉じていたセッションを外部へ切り離すことにしました。認証情報だけでなく、画面をまたいで利用される状態もWebサーバーの外部で管理します。これにより、既存システムと新システムが共存しながら、ページ・機能単位で段階的に移行できる状態を作ります。 そのために採用したのが、IISのインメモリセッションをRedisへオフロードする 「セッションオフロード」 という手法です。 次章では、このセッションオフロードをどのような構成で実現したのかを紹介します。 IISセッションをRedisへオフロードする 目指したのは、Webサーバー自身がユーザーセッションを保持しない構成です。そこで、これまでIISのインメモリに保持していたセッションを、外部のRedisへオフロードすることにしました。 Before / After ポイントは、単純にRedisを追加することではありません。既存アプリケーションから見たセッションの扱いを大きく変えずに、セッションの保存先だけをWebサーバーのメモリから外部へ移す必要があります。 ZOZOの既存基幹システムでは独自フレームワークを利用しています。今回、この独自フレームワークをバージョンアップして、セッションの読み書きをRedisへオフロードできる仕組みを導入しました。これによって、Webサーバー自身はユーザー固有のセッションを保持せず、必要なセッション情報を外部から取得する構成へ変更します。 サーバー内部に保持していたデータファイルも、読み書き先をファイルサーバーへ移行しました。 Webサーバーとセッションのライフサイクルを分離することが、この取り組みの重要なポイントです。 独自フレームワークを変更した理由 既存の基幹システムでは、Classic ASPの各ページから直接IISのSessionオブジェクトを操作するのではなく、独自フレームワークを通じてセッションを読み書きしています。このフレームワークが、セッションへのアクセスを一元的に管理する役割を担っています。 この構成であったことが、今回のセッションオフロードを実現するうえで大きな助けとなりました。 アプリケーションを1つずつ改修して保存先を変更するアプローチでは、膨大な画面数を持つ基幹システムにおいて現実的な工数で対応しきれません。しかし、セッションへのアクセスがフレームワーク層に集約されていたため、そのレイヤーでRedisへの読み書きを吸収すれば、個々のアプリケーションコードを変更せずにセッションの保存先を切り替えられます。 つまり、フレームワーク側を変更することで、 既存アプリケーションへの変更を最小限に抑えながら、セッション管理だけを差し替える ことが可能になりました。 セッションIDの引き回し 新旧システム間でセッションを共有するためには、同一ユーザーのリクエストに対して同じセッションIDでRedisにアクセスする必要があります。 セッションIDはCookieを通じてクライアントに保持させます。リクエストごとにCookieから取得したセッションIDをキーとしてRedisからセッション情報を取得します。この仕組みにより、振り分け先がIIS上の既存システムでもKubernetes上の新システムでも、同じセッションを参照できます。 シリアライズ方式と互換性 IISのインメモリセッションでは、VBScript固有のオブジェクト形式でデータが保持されています。Redisへオフロードするにあたっては、このデータをシリアライズ可能な形式に変換する必要があります。 フレームワーク層でシリアライズ・デシリアライズ処理を実装し、既存のセッションデータとの互換性を維持しながらRedisへの永続化を実現しました。シリアライズフォーマットにはJSONを採用し、VBScript固有の型情報も保持するスキーマ設計としています。これにより、新システム側でも同じフォーマットで正しくデータを読み書きできます。 更新タイミングとエラー時のロールバック セッションデータのRedisへの更新タイミングも、重要な設計ポイントです。 今回は、スクリプトの実行開始時にセッションデータをまとめて取得し、処理中はインメモリで扱い、実行完了時にまとめてRedisへ書き戻す方式を採用しました。この方式には、スクリプト処理中のセッションアクセスを高速化できること、そしてエラー発生時のロールバックを兼ねられることという2つの利点があります。 処理の途中でエラーが発生した場合、Redisへの更新は実行されません。つまり、セッションが中途半端に更新された状態を防ぐことで、セッションデータの自動ロールバックを実現しています。 仮に、スクリプト実行中に都度Redisを更新する方式にすると、エラー発生時点で一部だけ更新が反映された状態となり、ロールバックが難しくなります。更新タイミングをまとめることで、この問題を回避しています。 セッション期限管理の設計 IISのインメモリセッションには、一定時間アクセスがなければ自動的に破棄されるタイムアウトの仕組みがあります。既存システムでは、セッション破棄時にSession_OnEndイベントで業務処理を実行していました。Redisへオフロードするにあたり、このセッション終了時の処理をどう再現するかが課題となりました。 RedisにはKeyspace Notificationsという仕組みがあり、キーの期限切れを検知した際にexpiredイベントを通知できます。ただし、このイベントはTTL満了時刻ちょうどではなく、Redisがキーの期限切れを検知・削除した時点で発行されるため、通知タイミングや時刻の厳密さは保証されません。また、Keyspace Notificationsは永続キューではなく、購読者が停止している間のイベントを後から取得する仕組みもありません。したがって、取りこぼしが許容されない業務処理のトリガーには適さないと判断しました。 そこで、別途、期限管理のサブシステムを稼働させる方式を採用しています。このサブシステムがセッションの期限切れを能動的に検知し、従来Session_OnEndで実行していた業務処理を代替したうえでセッションを削除します。定期スキャンによって確実に処理を実行でき、イベントの取りこぼしがないため安定性に優れます。 この仕組みは、ZOZOTOWNでのセッションオフロード時の仕様を踏襲したものです。 Redis障害時の考え方 セッションの保存先をRedisに一本化することで、Redis障害がシステム全体に影響するリスクが生まれます。 この点については、Amazon ElastiCache for Redisのクラスタモードを採用し、3AZにまたがるレプリケーショングループによる自動フェールオーバー構成としています。さらに、1シャードの障害影響を局所化するために複数シャード構成を採用しました。特定のシャードに障害が発生しても、影響を受けるのはそのシャードに割り当てられたセッションのみです。たとえば、1シャード構成では1ノード障害の影響が100%に及びますが、5シャード構成であればハッシュスロットが均等に分散していると仮定して1ノード障害の影響は20%にとどまります。実際にはハッシュスロット分散の偏りによって上下し、ここはランニングコストと可用性のバランスになります。そのうえで、SLOで定義した可用性や試験で計測したMTTRなども考慮して、最終的なシャード数を決定しました。 Redis Clusterの障害対策とキー設計 Redis Clusterでは、キーのハッシュ値に基づいてデータが各シャードに分散されます。しかし、1つのセッションに関連する複数のキーが異なるシャードに散ると、以下の問題が生じます。 複数シャードをまたいだ読み書きによるレイテンシー悪化。 multi-key commandは同一hash slot内のキーに制限されるため、別slotのキーにはCROSSSLOTエラーが発生する。 single-slot operationの方がパフォーマンスに優れる。 シャード障害時に、同一セッションのキーの一部だけが取得できず、データ不整合を引き起こす。 Redis Clusterには「ハッシュタグ」という仕組みがあります。キーに {...} パターンを含めると、波括弧内の文字列のみをもとにハッシュスロットが計算されます。これにより、同じハッシュタグを持つキーは必ず同一シャードに配置されます。 今回のセッション管理では、1ユーザーの複数のキーにセッションIDをハッシュタグとして埋め込む設計を採用しました。 機能によってHash・String・List・Setなど適したRedisのデータ型が異なるため、セッションを1つのキーにまとめず、ユーザーの認証情報とは別に機能や画面の単位でキーを分けています。これらのキーが別シャードに散らないよう、セッションIDをハッシュタグとして共通で埋め込んでいます。 例: data:{session-id}:<FEATURE_KEY_1> # Hash data:{session-id}:<FEATURE_KEY_2> # String data:{session-id}:<FEATURE_KEY_3> # List data:{session-id}:<FEATURE_KEY_4> # Set このキー設計により、同一ユーザーのセッションに属するすべてのデータが同じシャードに格納されます。これにより、前述の問題を回避しつつ、Redis Clusterによるシャーディングの恩恵を受けられる構成としました。 参考: Redis Cluster Specification - Hash tags 段階的な切り替えとフォールバック セッションオフロードの適用は、全拠点を一斉に切り替えるのではなく、段階的に進めています。 まず、セッションオフロードに対応した環境を構築し、全倉庫拠点での動作確認を事前に実施しました。その後、影響度が小さい拠点から順にアクセス先をセッションオフロード環境へ切り替え、ロングランで安定稼働を確認しながら対象拠点を広げていく方式を採用しています。 問題が発生した場合は、影響のあるユーザー単位で旧ドメインへ切り戻すフォールバックを用意しています。拠点全体を巻き戻す必要がなく、影響範囲を限定した復旧が可能です。 2026年9月現在、この段階的な切り替えを進行中です。 ステートレス化によって何が変わったのか セッションオフロードで得られた一番大きな変化は、「Redisを使えるようになったこと」ではありません。 Webサーバーとユーザーセッションの紐付きを切り離せたこと です。 これまでWebサーバーの中にあった状態を外部へ移しました。その結果、リクエストをどのWebサーバーが処理するかと、ユーザーがどのセッションを利用するかを分離して考えられるようになりました。 これによって、アーキテクチャ上の選択肢が大きく広がります。 特定のWebサーバーにユーザーを固定する前提がなくなる。 ALBによるリクエストの均等な負荷分散が可能になる。 Webサーバーの増減や入れ替えと、ユーザーセッションのライフサイクルを分離できる。 Podが入れ替わることを前提とするKubernetesへの移行にも対応しやすくなる。 従来のステートフルな構成では、拠点ごとに接続先のWebサーバーが固定されていました。「アーキテクチャConference 2025」でもこの点を課題として紹介しています。セッションがサーバーに紐付いているため、リクエストを別のサーバーへ振り分けられず負荷が偏りやすい構造でした。ステートレス化によってこの制約がなくなり、ALBで均等にリクエストを分散できるようになりました。 しかし、今回の基幹リプレイスにおいて最も重要なのは、その先です。既存システムと新システムをまたいだ、段階的なフロントエンドリプレイスを進めるための前提条件が整いました。 これまで、 Webサーバー = アプリケーション + ユーザーの状態 だったものを、 Webサーバー = アプリケーション Redis = ユーザーの状態 へ分離したことで、フロントエンドのルーティングとセッション管理を独立して考えられるようになります。 これは単なるインフラ変更ではなく、基幹システムをどの単位で、どの順番でリプレイスできるかを変えるための アーキテクチャ変更 です。 いよいよ基幹フロントエンドのリプレイスへ ここで、2025年11月の「アーキテクチャConference 2025」で紹介した構成に戻ります。 当時紹介したのは、Kubernetes基盤上にIngress + Istioを配置し、ストラングラーパターンによって既存システムと新システムを共存させる構成でした。そして、機能ごとのパスルーティングによって、既存のClassic ASPから新しいアプリケーションへ段階的に切り替えていくことを予定していました。 当時はまだ「予定」だったこのアーキテクチャに対して、今回のセッションオフロードによって、その前提となるステートレス化を進めることができました。 巨大な基幹システムのリプレイスでは、新しいシステムを作ることだけが課題になるわけではありません。既存システムが長年の運用の中で持つようになった前提や制約を1つずつ解きほぐし、新旧システムが共存できる状態を作ることも、段階的なリプレイスには必要です。 今回取り組んだセッションオフロードは、そのための1つのステップでした。Webサーバーから状態を切り離したことで、既存システムを稼働させたまま、新アーキテクチャへページ・機能単位で移行していくための土台が整いました。 まとめ 本記事では、ZOZOの基幹システムにおけるWebサーバーのステートレス化について紹介しました。 2025年11月の「アーキテクチャConference 2025」では、基幹システムのリプレイスを進めるうえで「ステートフル」であることを課題として挙げました。あわせて、IISセッションをRedisへオフロードする方針と、Kubernetes上での段階的なフロントエンドリプレイス構想を紹介しました。 その構想を実現するため、既存システムで利用している独自フレームワークをバージョンアップし、IISのインメモリに保持していたユーザーセッションをRedisへオフロードしました。 今回の取り組みで重要だったのは、Redis導入そのものではありません。既存システムから「状態」という制約を切り離し、リプレイスの自由度を上げることでした。 長年稼働してきた基幹システムを、一度にすべて刷新できません。だからこそ、新しいアーキテクチャを作るだけではなく、既存システムを少しずつ「置き換えられる状態」に変えていくことが重要だと考えています。 2025年に「予定」として紹介していた基幹フロントエンドの新しいアーキテクチャは、今回のステートレス化によって実現に向けた準備が整いました。ここから、基幹フロントエンドの段階的なリプレイスを進めていきます。 ZOZOでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、ZOZOTOWN開発2部Androidブロックの小林( @kako_351 )です。普段はZOZOTOWN Androidアプリの開発を担当しています。ZOZOTOWN Androidでは、チーム内の運用でさまざまな作業を自動化しています。最近はリリース準備を、Jira AutomationとGitHub Actionsの組み合わせで自動化しました。本記事では、その仕組みと実装を紹介します。 目次 はじめに 目次 背景・課題 改善前の運用 ブランチ運用の整理 改善後の全体構成 GitHub ActionsによるリリースPRの自動作成 Jira REST APIの準備 ブランチ作成 バージョン更新 PR作成 PRの説明欄に記載する本文生成 PRへラベル付与 マージの対象案件メンバーへSlack通知 Jira AutomationからのGitHub Actions実行 GitHub Actions REST APIの準備 トリガー設定 入力値の設定 ブランチ名の自動生成 REST APIでGitHub Actionsを起動 改善後の実行イメージ まとめ 背景・課題 ZOZOTOWN Androidでは、QAチームへテスト対象のアプリを配布する前に、チーム内で次のリリース内容を確認しています。配布担当者はそれまでにリリース用ブランチを用意し、対象案件をマージできる状態にしておく必要があります。 この運用面での課題が2つありました。 1つは所要時間が人によって大きく違ったことです。配布担当はリリースごとに入れ替わるため、担当が回ってくるのは数ヶ月に一度ということもありました。リリース準備は工程が多いうえ、配布パターンによって使うブランチが変わります。記憶だけを頼りに進めるのが難しいときもあり、毎回マニュアルを確認するところから作業が始まっていました。PRやSlackのタイムスタンプを振り返ると、リリース準備にかかる実作業時間は、担当者によって15分程度から1時間程度まで開きがありました。もう1つは、準備が遅れると対象案件のマージがチーム内での確認の直前になることです。マージ先のブランチが存在しないため、担当者の作業タイミングがそのままリードタイムに響いていました。 改善前の運用 まずは改善前の運用を説明します。おおむね以下のようなリリースフローです。 QAはQAチームが行い、他の工程はZOZOTOWN Androidチームが行う リリース準備 チーム内で配布前確認(次のリリース内容をチーム内で確認する工程) QAチームへ配布 QA リリース 本記事では、このうち1のリリース準備を自動化した内容を紹介します。リリース準備には以下の工程が含まれており、すべて手動で行っていました。 すべて手動で行っていたリリース準備の工程 ブランチ作成 バージョン更新 リリース用PR作成 PRの説明欄に記載する本文生成 バージョンに含める案件内容を収集 ラベル付与 マージの対象案件メンバーへSlack通知 これらの作業は配布担当者が行います。担当するのは、そのリリースに含まれる案件のうち重要度の高い案件の実装者です。そのため担当は毎回入れ替わり、上記工程にも時間を要していました。一方でこの工程は標準化されていたため、自動化しやすい部分でした。 ブランチ運用の整理 自動化する上で考慮すべき点は、ブランチ運用です。hotfixや複数バージョンの同日配布といった、通常とは異なる配布パターンにも対応する必要があったためです。前提としてZOZOTOWN AndroidではGit-flowに近い形でブランチ運用をしています。 イメージとしては、Git-flowのdevelopブランチとreleaseブランチの役割を入れ替えたような運用です。配布前確認までにreleaseブランチへ各案件のfeatureブランチをマージし、QAチームへの配布時点でreleaseブランチをdevelopブランチへマージします。 Git-flowを一部カスタマイズした運用 配布のパターンとしては以下の3つが存在します。 通常 毎週の定例リリースをスケジュールどおりに配布するときの運用 ベースブランチをdevelopとする hotfix リリース済みのバージョンに緊急の修正を入れるときの運用 ベースブランチをmasterとする 複数バージョンを同日配布 規模の大きい改修などQA期間を長く取りたいときや、リリース日が近くバージョンを分けたいときの運用 ベースブランチは、先発配布ならdevelop、後発配布なら先行配布ブランチとする これらいずれのパターンでも実行できるように自動化を設計しました。 改善後の全体構成 改善する際、なるべく手作業の手間を減らす方向性で考えました。前提として、ZOZOTOWN Androidではタスク管理にJiraを利用しており、リリースごとに専用のタスクチケットを作成しています。そのタスクチケットを起点にリリース準備が完了するような構成を目指しました。 改善後の全体構成は以下のとおりです。 Jiraチケットでトリガーを実行するだけでリリース準備が完了する構成 処理の大部分はGitHub Actionsで動かしています。配布日やバージョン名といったリリース情報はJiraに記載してあるため、必要な情報はJira REST APIから取得できます。またJiraには自動化機能のJira Automationがあり、今回はGitHub Actionsを起動するトリガーとして利用しています。 GitHub ActionsによるリリースPRの自動作成 GitHub Actionsでは、前述したリリース準備の工程すべてを実行します。以降、工程ごとに実装を説明します。 Jira REST APIの準備 事前にJira REST APIを利用するために必要な準備をします。Pythonスクリプト内で jiraライブラリ を利用するため、pipでインストールします。 - name : Install dependencies run : pip install jira requests ステップごとにPythonファイルを分けたいので、共通して利用するJiraインスタンス生成を個別のファイルとして作成します。Jira REST APIはメールアドレスとトークンによるBasic認証で行うため、事前にトークンの発行が必要です。Jira REST APIの認証方法は Basic auth for REST APIs を参照してください。 """jira_config.py Jiraの設定 """ from jira import JIRA # Jira APIはメールアドレスとトークンによるBasic認証で行う jira_email = os.environ.get( 'JIRA_BOT_MAIL' ) jira_api_token = os.environ.get( 'JIRA_CLOUD_TOKEN' ) JIRA_HOST = 'YOUR_ATLASSIAN_DOMAIN' JIRA_URL = f 'https://{JIRA_HOST}' headers = { 'Host' : JIRA_HOST, 'Origin' : JIRA_URL, 'X-Atlassian-Token' : 'no-check' , 'Content-Type' : 'application/json;charset=UTF-8' , } def create_jira (url=JIRA_URL, headers=headers): return JIRA(url, basic_auth=(jira_email, jira_api_token), options={ "headers" : headers}) 各PythonスクリプトでJira情報を取得したいときにこのファイルをimportして利用します。 ブランチ作成 ブランチを作成するにあたりベースブランチ、作成するブランチ名の2つの情報が必要になります。その値はGitHub Actionsの inputs として渡せるようにします。 name : Release Preparation on : workflow_dispatch : inputs : branch_name : required : true description : "リリースブランチ名" type : string base_branch : required : true description : "ベースブランチ" type : string env : GH_TOKEN : ${{ secrets.GITHUB_TOKEN }} JIRA_BOT_MAIL : ${{ secrets.JIRA_BOT_MAIL }} JIRA_CLOUD_TOKEN : ${{ secrets.JIRA_CLOUD_TOKEN }} jobs : release-preparation : runs-on : ubuntu-latest steps : # ...他の処理 - name : Fetch base branch env : BASE_BRANCH : ${{ github.event.inputs.base_branch }} run : | git fetch origin "$BASE_BRANCH" git checkout "$BASE_BRANCH" - name : Create release branch env : BRANCH_NAME : ${{ github.event.inputs.branch_name }} run : | git checkout -b "$BRANCH_NAME" ブランチ処理自体は単純なGit操作で済むため、ワークフローファイルだけで完結します。ベースブランチをフェッチ、切り替えた後にリリースブランチを作成しています。なお上記のワークフローファイルでは掲載を省略していますが、事前バリデーションも同じファイル内で実行しています。例えば渡されたbranch_nameと同名のブランチが既に存在しないか、チームの運用で定めたフォーマットになっているかを確認します。 バージョン更新 - name : Get Jira ticket info and extract version id : jira run : python .github/script/get_jira_version.py "${{ steps.parse.outputs.jira_ticket }}" - name : Update version in libs.versions.toml id : version run : python .github/script/update_version.py "${{ steps.jira.outputs.version_name }}" versionCode, versionNameを更新します。ZOZOTOWN AndroidのversionCodeは既存の値に1を足すだけです。一方、versionNameはひと工夫しています。 ZOZOTOWN AndroidではリリースバージョンもJiraで管理しています。リリース用のタスクチケットのタイトルは 【Android】Ver x.y.z(N)対応内容 というフォーマットです。そのため、Pythonスクリプト内でJiraチケットのタイトルからversionNameを抽出します。 import jira_config def extract_version_name (title: str ) -> str | None : """JiraチケットタイトルからversionNameを抽出する""" match = re.search( r'(\d+\.\d+\.\d+)' , title) return match.group( 1 ) if match else None def main () -> None : if len (sys.argv) < 2 : print ( '::error::Usage: python get_jira_version.py <jira_ticket>' ) sys.exit( 1 ) jira_ticket = sys.argv[ 1 ] try : jira = jira_config.create_jira() issue = jira.issue(jira_ticket) summary = issue.fields.summary version_name = extract_version_name(summary) if not version_name: sys.exit( 1 ) set_output( 'version_name' , version_name) # チケットタイトルはリリース用PRのタイトルにも利用する set_output( 'jira_title' , summary) except Exception as e: # 例外処理 sys.exit( 1 ) なお、記事中の set_output は値を $GITHUB_OUTPUT へ書き出す自前のヘルパーです。書き出した値は後続のステップから steps.<id>.outputs.<name> で参照します。 versionNameを取得後、versionCodeと合わせてバージョンを更新します。ZOZOTOWN Androidではバージョンをtomlで管理しているのでtomlを更新します。 VERSION_FILE = 'gradle/libs.versions.toml' def read_version_file () -> str : """バージョンファイルを読み込む""" path = Path(VERSION_FILE) if not path.exists(): print (f '::error::Version file not found: {VERSION_FILE}' ) sys.exit( 1 ) return path.read_text() def get_current_version_code (content: str ) -> int : """現在のversion_codeを取得する""" match = re.search( r'^version_code = "(\d+)"' , content, re.MULTILINE) if not match: print ( '::error::Could not find version_code in libs.versions.toml' ) sys.exit( 1 ) return int (match.group( 1 )) def update_version (content: str , new_version_code: int , new_version_name: str ) -> str : """バージョン情報を更新する""" # version_codeを更新 content = re.sub( r'^version_code = "\d+"' , f 'version_code = "{new_version_code}"' , content, flags=re.MULTILINE ) # version_nameを更新 content = re.sub( r'^version_name = "[0-9.]+"' , f 'version_name = "{new_version_name}"' , content, flags=re.MULTILINE ) return content def main () -> None : if len (sys.argv) < 2 : print ( '::error::Usage: python update_version.py <version_name>' ) sys.exit( 1 ) new_version_name = sys.argv[ 1 ] # バージョンファイルを読み込み content = read_version_file() # 現在のversion_codeを取得 current_version_code = get_current_version_code(content) # 新しいversion_codeを計算(インクリメント) new_version_code = current_version_code + 1 # バージョン情報を更新 updated_content = update_version(content, new_version_code, new_version_name) # ファイルに書き込み write_version_file(updated_content) # GitHub Actionsの出力に設定 set_output( 'new_version_code' , str (new_version_code)) set_output( 'new_version_name' , new_version_name) このようにPythonスクリプトを用いて、GitHub Actions内でバージョンを自動更新しています。バージョン更新後、Gitのコミットとプッシュまで行います。ベースブランチとの差分がないとPRを作成できないため、このバージョン更新をコミットしておきます。 - name : Commit version update run : | git config --local user.email "xxxxxxxx+github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" git add gradle/libs.versions.toml git commit -m "Bump version" - name : Push release branch env : BRANCH_NAME : ${{ github.event.inputs.branch_name }} run : | git push --set-upstream origin "$BRANCH_NAME" PR作成 ここまできたらPRを作成します。ZOZOTOWN Androidではリリース用PRに記載する情報も標準化されており、配布時のメッセージ文とバージョンに含める案件内容を記載します。 PRの説明欄に記載する本文生成 メッセージは定型文で、案件内容はJiraから取得できるのでこれらもPythonスクリプト上で処理して整形できます。 import jira_config def get_epics_by_fix_version (jira, fix_version: str ) -> list [ dict ]: """修正バージョンに紐づくエピックを取得する""" # JQLでfixVersionに紐づくエピックを検索 jql = f 'fixVersion = "{fix_version}" AND issuetype = Epic ORDER BY key ASC' issues = jira.search_issues(jql, maxResults= 100 ) epics = [] for issue in issues: epics.append({ 'key' : issue.key, 'summary' : issue.fields.summary, }) return epics def generate_epics_markdown (epics: list [ dict ]) -> str : """エピック一覧をMarkdown形式で生成する""" if not epics: return '' lines = [] for epic in epics: url = f '{JIRA_BASE_URL}/browse/{epic["key"]}' lines.append(f '### [{epic["summary"]}]({url})' ) return ' \n\n ' .join(lines) def generate_pr_body (jira_ticket: str , version_name: str , version_code: str , epics_markdown: str = '' ) -> str : """PR本文を生成する""" jira_url = f '{JIRA_BASE_URL}/browse/{jira_ticket}' # エピック一覧のセクションを生成 epics_section = '' if epics_markdown: epics_section = f ' \n\n {epics_markdown}' pr_body = f '''## 修正内容 {{配布時のメッセージ文}} ## 仕様書 [{jira_ticket}]({jira_url}){epics_section} ...以降テンプレに沿った内容 ''' return pr_body def main () -> None : if len (sys.argv) < 4 : print ( '::error::Usage: python generate_pr_body.py <jira_ticket> <version_name> <version_code>' ) sys.exit( 1 ) jira_ticket = sys.argv[ 1 ] version_name = sys.argv[ 2 ] version_code = sys.argv[ 3 ] try : jira = jira_config.create_jira() # リリースチケットから修正バージョンを取得 fix_version = get_fix_version_from_issue(jira, jira_ticket) # 修正バージョンに紐づくエピック(案件情報)を取得 epics = get_epics_by_fix_version(jira, fix_version) epics_markdown = generate_epics_markdown(epics) # PR本文を生成 pr_body = generate_pr_body(jira_ticket, version_name, version_code, epics_markdown) # 一時ファイルに書き出し with tempfile.NamedTemporaryFile(mode= 'w' , suffix= '.md' , delete= False ) as f: f.write(pr_body) pr_body_file = f.name set_output( 'pr_body_file' , pr_body_file) except Exception as e: sys.exit( 1 ) バージョンに含める案件情報はJira上でバージョンに紐付けされているので、Jira REST APIでバージョンから取得できます。それをMarkdown形式にしてPR本文中に含めるように文字列生成しています。 本文ができたらPRを作成します。PR作成はghコマンドによりワークフローファイルだけで完結できます。 - name : Generate PR body id : pr_body run : | python .github/script/generate_release_pr_body.py \ "${{ steps.parse.outputs.jira_ticket }}" \ "${{ steps.version.outputs.new_version_name }}" \ "${{ steps.version.outputs.new_version_code }}" - name : Create Pull Request id : create_pr env : JIRA_TITLE : ${{ steps.jira.outputs.jira_title }} BASE_BRANCH : ${{ github.event.inputs.base_branch }} BRANCH_NAME : ${{ github.event.inputs.branch_name }} PR_BODY_FILE : ${{ steps.pr_body.outputs.pr_body_file }} run : | PR_URL=$(gh pr create \ -B "$BASE_BRANCH" \ -H "$BRANCH_NAME" \ -t "$JIRA_TITLE" \ -F "$PR_BODY_FILE" ) echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT PRへラベル付与 ZOZOTOWN Androidには、ラベル付与をトリガーに動く別のGitHub Actionsがあります。リリース用PRには「配布前確認」というラベルを付与します。ラベル付与もPR作成と同様、ghコマンドを使えばワークフローファイルだけで完結します。 - name : Add labels to PR env : BRANCH_NAME : ${{ github.event.inputs.branch_name }} run : | PR_NUMBER=$(gh pr view "$BRANCH_NAME" --json number -q '.number' ) gh pr edit "$PR_NUMBER" --add-label "配布前確認" マージの対象案件メンバーへSlack通知 リリースブランチに案件のfeatureブランチをマージしてほしいため、最後にリリースブランチとPRが作成されたことを通知します。通知内容はシンプルなため、Slack公式の slackapi/slack-github-action で済みます。 - name : Post Slack notification uses : slackapi/slack-github-action@vX.XX.XX with : payload : | { "text" : ":github: リリースブランチが作成されました: ${{ github.event.inputs.branch_name }}" , "blocks" : [ { "type" : "section" , "text" : { "type" : "mrkdwn" , "text" : ":github: リリースブランチが作成されました \n *ブランチ*: `${{ github.event.inputs.branch_name }}` \n *Version*: ${{ steps.version.outputs.new_version_name }} (${{ steps.version.outputs.new_version_code }}) \n *PR*: <${{ steps.create_pr.outputs.pr_url }}|Pull Request>" } } ] } env : SLACK_WEBHOOK_TYPE : INCOMING_WEBHOOK SLACK_WEBHOOK_URL : ${{ secrets.SLACK_WEBHOOK_URL }} 以上により、リリース準備の一連の流れをGitHub Actions上で自動化できました。 Jira AutomationからのGitHub Actions実行 GitHub Actionsに workflow_dispatch を設定しているので、外部からAPIでワークフローを起動できます。ZOZOTOWN Androidでは改善後の全体構成でも説明した通り、タスク管理にJiraを利用しているので、Jiraからそのワークフローを起動できないか考えました。 JiraにはJira Automationというさまざまな自動化を設定できる機能があります。今回はこのJira AutomationからGitHub Actionsのワークフローを起動します。 Jira Automation側の全体構成は以下のスクリーンショットのとおりです。Jiraに詳しくなくても、Zapierなどの自動化ツールを触ったことがある方なら近い印象を持てるはずです。 GitHub Actions REST APIの準備 REST APIでGitHub Actionsの操作をする場合、アクセストークンが必要となるため発行しておきます。本記事では詳細を割愛しますが、アクセストークンの権限設定は GitHub Actionsの権限に関するREST APIエンドポイント を参照してください。 トリガー設定 まずどのようなタイミングでJira Automationを実行するかを設定します。チケットのステータス変更時やチケット作成時などさまざまなトリガーがありますが、今回は手動によるトリガーを選択しました。理由としては以下2点です。 リリース準備のタイミングが固定ではない 配布パターンがその時の状況により変わる ZOZOTOWN Androidでは基本的に毎週リリースがあります。ただしお盆や年末年始などの連休時や、開発状況により2週間以上あく場合もあります。また、 ブランチ運用の整理 で記載したようにhotfixや同日配布の場合には任意でベースブランチやブランチ名を指定したいときがあります。 それらの運用面を考えて、配布担当者が手動でJira Automationを起動するようにしました。 入力値の設定 ブランチ作成に必要なベースブランチとブランチ名の2つを、GitHub Actionsのジョブへ渡す入力値として設定します。Jira Automationを手動起動する際に、インプットデータとして任意の値を渡せます。 これを設定すると、起動時のダイアログに入力フォームが表示され、ベースブランチとブランチ名へ任意の値を指定できます。 ここでは入力の手間を減らすため、それぞれにデフォルト値を設定しています。 ベースブランチ:develop ブランチ名:Jiraチケット上にある情報から運用に沿ったブランチ名を自動生成 デフォルト値があることで、通常パターンであれば担当者はボタンをクリックするだけで済みます。 ブランチ名の自動生成 ブランチ名は、チームの運用で release/{{JiraIssueKey}}/{{yyyy_mm_dd}} というフォーマットが決まっています。JiraIssueKeyにはリリース用のチケットのKeyを、yyyy_mm_ddには配布の日付を値として代入します。 配布の日付はJira上でリリースの説明に以下のようなテキストで記載されているので正規表現を使って抽出可能です。 リリースの説明文(抜粋) QA配布予定日:2026-09-03 正規表現による抽出 {{issue.fixVersions.first.description.match(".*QA配布予定日:(\d{4}-\d{2}-\d{2}).*").toDate.format("yyyy_MM_dd")}} REST APIでGitHub Actionsを起動 最後にREST APIで作成したGitHub Actionsのワークフローを実行します。API実行時にinputsとしてデータを渡せるので、ベースブランチと作成するブランチ名を渡します。 これで配布担当者はJiraからワークフローを起動するだけで、リリース準備が完了します。これまで手作業で行っていた複数の工程にかかる時間を短縮できました。 改善後の実行イメージ 改善後の実行イメージは以下のとおりです。 Jiraのリリース用チケットでリリース準備の自動化トリガーを実行。フォームに指定する内容がなければ「続行」ボタンクリックで完了する 自動でPRが作成される PR作成完了後Slackに通知が届く これまで手動で行っていた作業がトリガー実行1つで完了するようになりました。リリース準備のマニュアルを確認する必要はなく、以降の処理はGitHub Actionsが行い、30秒程度で完了します。 まとめ 本記事では、Jira AutomationとGitHub Actionsによるリリース準備の自動化について紹介しました。手作業だったリリースブランチ作成からSlack通知までの工程がJiraのワークフロー1つで完了するようになり、担当者による作業時間やPR内容のばらつきもなくなりました。標準化された手順を手作業で進めているチームがあれば、ぜひ参考にしてみてください。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、ZOZOTOWN開発2部Androidブロックの大江です。普段はZOZOTOWN Androidの開発を担当しています。 ZOZOTOWN Androidは10年以上にわたって開発されています。機能追加を重ねる中で、特定のモジュールが肥大化したり、モジュール構成が複雑になったりしたため、改善に取り組んでいます。 本記事では、こうしたモジュール構成について、課題とその解決に向けて段階的に進めてきた取り組みを中心に紹介します。 目次 はじめに 目次 背景・課題 段階的に整理を進める方針 dataモジュールの疎結合化 kaptの隔離 coreモジュールとinfraモジュールの依存方向の反転 得られた効果 まとめ 背景・課題 まず、ZOZOTOWN Androidの現在のモジュール構成の概要を紹介します。 app :画面や機能を担う各モジュールを束ね、アプリとして組み立てる役割を持つモジュール feature :画面や機能ごとに分割された実装を置くモジュール core :複数の機能から参照される古い共通処理を置くモジュール(将来的には解体したい) legacy :新しいアーキテクチャへの移行が済んでいない実装を置くモジュール(将来的には解体したい) data :APIやDBへのアクセスを担うモジュール infra :外部サービスとの通信など基盤的な処理を担うモジュール domain :APIやDBの実装から独立したユースケース(業務ロジック)を置くモジュール 現在、ZOZOTOWN Androidは77個のモジュールに分割されています。そのうち app モジュールは特に肥大化していて、単体でコードベース全体の約30%を占める規模になっています。これは feature モジュールへの切り出しが済んでいない画面の実装が app モジュールに残り続けていることが主な要因です。また legacy ・ core モジュールは、責務が曖昧になったり依存関係が複雑になったりして、将来的な解体が難しくなっていました。 こうした状況の中、機能開発を進める中で以下の問題が発生していました。 legacy モジュールと core モジュールに対する参照が増え続けて、解体が先送りになり続ける ビルド時間とユニットテストの実行時間が延び続ける 段階的に整理を進める方針 これらの問題を解決するために、大規模になっているZOZOTOWN Androidのモジュール構成を一気に理想形へ整理し直すのは、コストもリグレッションのリスクも大きくなります。そこで対応コストと得られる効果を見比べながら、優先順位をつけて段階的に手を入れていくことを考えました。 優先順位をつける際は、次の3点を意識しました。 今後も機能追加が続く前提で、繰り返し効果が積み重なる対応かどうか 対応コストに対して、ビルド時間や開発のしやすさへの効果がどれくらい見込めるか AIを活用することで対応コストそのものを下げられるか これらを踏まえて、次の3つの取り組みを選びました。 data モジュールの疎結合化:新しく作るRepositoryを疎結合な構造に強制できれば効果が積み重なるうえ、既存の実装にはほとんど手を入れずに進められるため対応コストも小さく、最初に着手した対応 kaptの隔離: @BindingAdapter を使った実装を1つのモジュールへ集約するだけで済み、対応コストが小さい一方、ビルド時間の短縮はチーム全体の開発体験に直結するため優先度を上げた対応 core モジュールと infra モジュールの依存方向の反転:複雑な依存関係を人手で洗い出すのは時間がかかりそうだが、AIに事前検証させることで対応コストを下げられる見通しが立った対応 ここからは、実際に進めた3つの取り組みを順に紹介します。 data モジュールの疎結合化 API・DBアクセスを担う data モジュールに配置されているRepositoryの中には、interfaceが設けられていないものがありました。こうしたRepositoryは legacy モジュールや core モジュールの実装に密結合しており、使用するたびに両モジュールへの参照が増えてしまいます。その結果、 legacy モジュールと core モジュールの解体コストが上がるという悪循環に陥っていました。 そこでまずはこの悪循環を断ち切ることが必要だと判断し、 data モジュールを次の4つに分割して新しく作るRepositoryは疎結合化を強制できるようにしました。 data:definition :interfaceとDTOだけを置くモジュール data:implementation :新しい設計に沿った実装を置くモジュール data:legacy :既存の実装をそのまま引き継ぐ受け皿 data:di :DIのバインディング定義だけを行うモジュール 利用側は data:implementation ではなく data:definition と data:di にだけ依存する構成にしました。実装クラスを直接使おうとすればビルドが失敗するので、コードレビューに頼らずビルド構成で疎結合を保証できました。 data:legacy は単なる未整理の実装置き場ではなく、新しい設計に沿った実装を既存の実装から隔てる腐敗防止層として意図的に位置づけました。この位置づけによって、既存のRepositoryを全件移行しきる前から、新しい設計を安全に並行導入できる状態を作れました。 legacy ・ core モジュールと異なり、 data:legacy はRepositoryの移行が進むにつれて中身が減っていく受け皿であり、移行完了後にはモジュールごと解体できる見通しを持っています。 既存の実装にはほとんど手を入れずに済むため対応コストは小さく、そのうえ新しく作るRepositoryが増えるたびに効果が積み重なります。この2点から、3つの取り組みの中でも最初に着手する対応として選びました。 kaptの隔離 kaptはJavaスタブを生成する必要があるため、ビルド時間を圧迫する要因として知られています。ZOZOTOWNでもモジュールごと、Gradleのタスクごとのビルド時間を計測しました。その結果、kaptに関連する処理がビルド時間の大半を占めていることがわかりました。原因は、Data Bindingの @BindingAdapter を使った実装があちこちのモジュールに散らばっていたことでした。kaptはモジュールごとに個別の注釈処理タスクが実行されるため、同じアノテーションを使うコードの分散は、その分だけ処理コストの積み重なりを招きます。 そこで @BindingAdapter を使う実装だけを ui-databinding という専用モジュールに集約し、それ以外のモジュールからkaptの設定を削除しました。散らばっていた実装を1つのモジュールへ集約するだけで済むため対応コストは小さく、ビルド時間の短縮という効果はチーム全体の開発体験に直結します。この対応コストと効果のバランスから、優先度を上げて取り組みました。 この対応によって複数のモジュールでビルドにkapt関連の処理が実行されなくなり、GitHub Actionsの4コアCI環境でのビルド時間が30分から18分へと、40%程度短縮できました。この数値はCI環境限定のものですが、ローカル開発環境でも同様にビルド時間の短縮を体感できています。なお、現在もkaptが残っているのは core ・ ui-databinding と、機能単位のモジュール2つのみです。 core モジュールの build.gradle には今も「Epoxyを削除できたらkaptも削除する」という趣旨のコメントが残っており、対応がすべて終わったわけではありません。 core モジュールと infra モジュールの依存方向の反転 legacy モジュールと core モジュールは様々なモジュールで使用されていて、依存関係が複雑になっていることもこれらのモジュールの解体を先送りさせる原因になっています。複雑に絡み合っている依存関係を人手で紐解いて解体するのはコストが高く、リファクタリングとして優先度が上がらない状況でした。 しかし、Claude CodeなどのAIが登場し、こうした人手だと時間のかかる調査や検証を短時間で行えるようになりました。 そんな中、ある機能の開発を進めている際にAPI通信を担う infra モジュールが共通処理を置く core モジュールへ依存していて、理想とは逆の方向の依存関係を持っていることに気づきました。この向きの依存関係だと、機能開発に必要だった core モジュール側の新しい実装から infra モジュール側の既存パーサーを直接参照できません。そのためinterfaceと実装を分けてDIで注入するという、本来不要なはずの回り道の実装が必要になっていました。 そこでAIに、依存方向を反転させる案を別ブランチで検証させました。依存関係の定義を反転させて、関連するクラス群も infra モジュール側のパッケージへ移動しました。ロジックの変更を伴わず、ファイルの移動と参照先の付け替えだけで完結する変更だと分かりました。 この見極めが、AIに実装まで任せる決め手になりました。挙動を変えるロジック修正が必要な変更であれば、AIが提案した内容でも人間が変更の妥当性を細かく確認する必要がありますが、機械的な変更だけで済む場合は検証から適用までを任せやすいと感じています。 一般的に依存関係の変更は影響範囲が広く、大量のソースコードを変更することになります。この見通しを短時間で立てられたことが、着手の判断を後押ししました。 実際の対応でも、AIが作成したブランチをベースに実装を進め、既存のビルドとユニットテストがすべて通ることを確認できましたが、影響範囲の広さから対応コストは高そうに見えました。しかし、AIによる事前検証でその判断コスト自体を下げられたことが、優先して取り組む決め手になりました。この実績によって、今後の依存関係の整理や legacy ・ core モジュールの解体を加速させられる目処が立ちました。 得られた効果 この3つの取り組みを通じて、次の効果が得られました。 data モジュールの疎結合化:新しく作るRepositoryが legacy ・ core への密結合を避けられる構造になり、参照が増え続ける悪循環を断ち切れました kaptの隔離:GitHub Actionsの4コアCI環境でビルド時間を30分から18分(40%程度)に短縮できました core モジュールと infra モジュールの依存方向の反転:機能開発に不要だった回り道の実装を解消できました。加えて、AIに事前検証させることで大規模な依存関係の変更に着手する判断を素早く行えるという実績もできました まとめ 本記事ではZOZOTOWN Androidのモジュール構成に対する課題とその解決方法を紹介しました。大規模なアプリのモジュール整理を一括ではなく段階的に進める前提で設計し、移行しきれていない実装の受け皿を腐敗防止層として用意することで、既存実装への影響を抑えながら新しい設計を導入できました。また依存関係の大規模な組み替えは、AIに実現可能性を先に検証させることで、着手の判断を素早く行えました。マルチモジュール構成の整理を検討している方がいれば、ぜひ本記事を参考にしてみてください。今後は残っている legacy ・ core モジュールの解体や、 app モジュールに残る画面の feature モジュールへの切り出しなど、引き続きモジュールの整理を進めていきたいと考えています。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、ZOZOTOWN開発本部Webバックエンドブロックの和氣です。普段はZOZOTOWNのバックエンドを担当しています。 日々の開発にClaude Codeを使っています。使ううちに、仕様や経緯を毎回プロンプトで説明し直していることに気づきました。そこで、Claude Codeとの会話をMarkdownで残し、作業に合わせたコンテキストをClaude Code自身が組み上げるようにしました。以降、残したMarkdownを「メモリ」と呼びます(Claude Code標準のメモリ機能とは別物です。違いは後述します)。 現在、メモリは個人に閉じず、職種を跨いで十数人で共有しています。他の人が調べたこと、意思決定の背景、その人の考えまで、Claude Codeで追えるようになりました。 本記事では、この仕組みと運用、そしてチームで共有してから起きた変化をご紹介します。 目次 はじめに 目次 背景と課題 同じ説明を何度も書いていた 文脈はコードの外にある コンテキストはセッションを跨げない 採用したアプローチ 正本をMarkdownにした 検索用の索引を別に持つ 標準のメモリ機能との違い 仕組みと運用 メモリ専用のGitリポジトリ メモリディレクトリの構成 保存フロー 読み込みフロー 関連メモリの想起 実作業スキルとメモリの接続 メモリの共有 効果 Claude Codeへ同じ説明をくり返さなくなった プロンプトに書くのはまだメモリにない文脈だけ メモリとスキルがつながり人の作業は文脈集めに寄った チームの記憶になった メモリを通してチームにコンテキストが共有された メモリを通して過去の担当者に聞ける 得られた知見 メモリはなんでも残して検索を強くする メモリの矛盾は問題にならない メインセッションはコンテキストのフィルターにする 見えてきた課題 メモリの鮮度 想起の精度 検索のスケール 今後の展望 メモリを使う人をプロジェクトに関わる職種へ広げる Claude Codeから保存を促すようにする 案件・機能についての文脈集めをClaude Codeのルーティンに任せる まとめ 背景と課題 同じ説明を何度も書いていた 同じ案件・機能のなかで、一度Claude Codeに説明したことを、何度もプロンプトに書いていました。調査を依頼するとき、設計を相談するとき、実装を任せるとき、レビューを見てもらうとき、また同じことを書いているな、と思っていました。 思い返すと、中身は毎回ほとんど同じです。作業ごとに組み合わせが変わるだけで、どれも前にどこかで書いたものでした。 文脈はコードの外にある 毎回プロンプトに書いていたのは、コードにない文脈でした。仕様をどう調整したか、なぜその設計にしたか、調べて何が分かったかなどです。レビューで指摘された点や、そのとき考えた懸念と判断の理由も同様です。 こうした文脈があるのは、次のような場所です。 Slackのやり取り Confluenceなどのドキュメント ミーティングでの会話 GitHubのレビューコメント 人の頭の中 どこに何があるかは、担当者しか知りません。 コンテキストはセッションを跨げない セッションは、毎回新しいコンテキストで始まります。文脈を集めて渡しても、Claude Codeが覚えているのはそのセッションのあいだだけです。次のセッションへは持ち越せず、時間をかけて合わせた認識も一緒になくなります。 同じセッションを長く使い続けても、同じです。コンテキストには上限があり、近づくと会話が自動で要約に置き換わって、細かいやり取りが落ちます。 次のセッションで前回の文脈が必要になれば、集め直してもう一度プロンプトで渡します。ただ、渡した本人でも、すべての文脈を渡せているかは分かりません。文脈を1つ落としたまま進めると、アウトプットが期待と異なることがあります。何が足りなかったのかは、そのとき初めて気づきます。 採用したアプローチ セッションでの会話を、Claude Codeにメモリとして残してもらうようにしました。正本はMarkdownファイルで、検索用の索引はそこから切り離してローカルのSQLiteに置いています。 正本をMarkdownにした 人とClaude Codeが同じファイルをそのまま読めます。専用のビューアーは要らず、気になるところは、その場でファイルを修正できます。Gitで管理できるので、いつ何を変えたかは履歴に残ります。 検索用の索引を別に持つ メモリは作業を重ねるほど増えていきます。索引には2026年8月時点で約7,000件が載っていて、この中から欲しい数件を取り出せるかが問題です。 正本のMarkdownは、grepでも探せます。ただしgrepが見るのは、文字列が一致するかどうかです。よく使われる言葉ほど大量に当たり、少し違えば1件も出ません。たとえば「不具合」で探しても、「バグ」と書かれたメモリは出てきません。 そこで、検索用の索引を別に作り、2種類の検索を並走させています。 全文検索 :書いた言葉にそのまま一致する。チケット番号のような識別子に強い 意味検索 :文の意味を数値にして、近さで探す。「不具合」と打っても「バグ」のメモリが候補に入る 検索のたびに両方を走らせ、順位を混ぜて上位だけを返します。検索のログを集計すると、1件も返らなかったのは2.7%、欲しいメモリが3位以内に入っていたのは68.9%でした。 SQLiteにした狙いは、拡張のしやすさです。検索の手法をあとから足したり替えたりできるようにしました。 索引はGitで管理しません。正本のMarkdownから、いつでも作り直せるからです。 標準のメモリ機能との違い この仕組みを作り始めたあとで、Claude Codeにも 標準のメモリ機能 が入りました。ですが、いまも自前のメモリを使い続けています。理由は、量と共有と拡張性です。 標準のメモリは、起動時に MEMORY.md という一覧ファイルだけを読み、必要なときに詳細を開きます。ただしその一覧から読み込まれるのは、先頭200行か25KBまでです。自前の仕組みはもっと多くのメモリをためるつもりで作ったので、この上限では足りませんでした。 共有もできません。標準のメモリは手元のマシンに閉じているため、他のメンバーからは見えません。 調査や設計といった作業のスキルそのものに、メモリの読み書きを組み込みたいと考えていました。標準の機能では、そこまで手を入れられません。 仕組みと運用 メモリ専用のGitリポジトリ メモリ専用のGitリポジトリを1つ用意しています。開発リポジトリに実体は置かず、 .claude/memories からシンボリックリンクで参照します。リンク先はどの開発リポジトリからでも同じで、案件が変わっても、メモリは1か所に集まります。 開発リポジトリA/.claude/memories ─┐ 開発リポジトリB/.claude/memories ─┼─→ メモリ専用リポジトリ 開発リポジトリC/.claude/memories ─┘ メモリディレクトリの構成 置き場所は、案件・職種・担当者・作業単位・フェーズの5つの軸で決まります。 .claude/memories/ メモリ専用リポジトリへのリンク └── <案件>/ └── <職種>/ ├── 01-context/ 前提・決定事項 ├── 02-meeting/ 議事録 ├── 03-research/ 調査結果 ├── 04-feature/ チーム共通の機能作業 ├── 05-users/ │ └── <担当者>/ │ ├── 01-inbox/ 未整理のメモリ │ ├── 02-feature/ 機能単位の作業 │ │ └── <機能名>/ │ │ ├── 01-concern/ 困りごと │ │ ├── 02-context/ 仕様・要件 │ │ ├── 03-research/ 調査 │ │ ├── 04-design/ 設計・計画 │ │ ├── 05-implementation/ 実装記録 │ │ ├── 06-review/ レビュー結果 │ │ └── 07-testing/ テスト・証跡 │ └── 03-issue/ 課題単位の作業 │ └── <課題名>/ │ ├── issue.md 課題そのもの │ ├── research.md 調査 │ ├── plan.md 進め方 │ └── result.md 結果 └── 06-log/ セッションログ 同じ役割のディレクトリが、2つの階層に出てきます。それぞれスコープが異なり、上の 01-context/ や 03-research/ は案件レベル、機能の下の 02-context/ や 03-research/ は機能レベルです。 担当者ごとの階層は、コンフリクト対策です。最初は案件の下にメモリを直接置いていました。同じ案件を複数人で進めると、同じファイルを取り合うようになったので、担当者ごとに分けました。 個人のディレクトリなら、書きかけのメモや雑多な記録をそのまま置けます。チームに共有するものは、案件共通のディレクトリへ置きます。 保存フロー 保存は、「メモリに保存して」とClaude Codeへ頼むようにしました。人が言うのはこれだけで、置き場所や名前は決めません。 メモリの保存を頼むと、保存の手順をまとめたスキルが動きます。案件・職種・担当者・作業単位・フェーズを判定して、先ほどのツリーの置き場所を決めます。あとで探すための要約とタグを作り、ファイルの先頭(frontmatter)に付けます。保存の前には、同じ置き場所に似たメモリがないかを探します。あれば、そのメモリを示して、書き足すか新しく作るかを聞きます。 このフローにしているのは、メモリを読み込む際に検索しやすくするためです。そのための工夫は3つあります。 置き場所をツリーで判定する :メモリを読み込むとき、ディレクトリも検索の要素の1つになる。検索の精度を上げるために、適切なディレクトリへ機械的に保存されるようにしている 要約とタグを先に作る :メモリを読み込むとき、検索の候補として見えるのは、置き場所と要約とタグだけ。だから保存するときに要約とタグを付けておく。付けたあと実際に検索して、そのメモリが上位に出るかまで確かめている 文脈の単位で積む :置き場所と類似度で、文脈が続いているかを判定する。文脈が違えば、似ていても別のメモリになる。検索したときに、文脈を追いやすくしている 役割分担は、判断がClaude Code、実処理がMCPツールです。 判断(Claude Code) :置き場所や名前を決める。要約とタグ、関連メモリへのリンクを付ける 実処理(MCPツール) :ファイルへ書き込む 読み込みフロー 読み込みも同じです。「この機能のメモリを読んで」と頼みます。手がかりは機能名でなくても構いません。案件名やチケット番号でも、ふわっとした言い方でも探してくれます。 絞り込みの手順は2段階です。Claude Codeはまずプロンプトを解析して検索クエリを決め、先ほどの索引で数千件から数十件を取り出し、並べ替えたうえで候補を10件ほどに絞ります。 次に候補の要約に目を通し、質問の意図に合うものを数件選んで、本文を見出しと要点だけに圧縮して読みます。本文にリンクされている別のメモリがあれば、そちらもたどります。 1回分の流れをイメージで示します。機能名、パス、要約はすべて架空のものです。 人のプロンプト 商品ページの表示項目、前回どう決めたか読んで Claude Codeが組み立てた検索クエリ 商品ページ 表示項目 仕様 決定 索引が返した候補(上位10件のうち2件。置き場所と要約) 02-feature/product-page/02-context/requirements.md 商品ページの要件。表示する項目と並びを確定 本文からのリンク先: 01-context/product-info-rule.md (商品情報の共通ルール) 02-meeting/2026-06-18-定例.md 定例の議事録。商品ページの表示方針を合意 Claude Codeが読んだもの 質問の意図に合う1と2を選び、見出しと要点だけに圧縮して読みました。1の本文からリンクされていた共通ルールも、たどって読んでいます。こうして、機能の要件、定例の議事録、案件共通のルールと、置き場所の違うメモリがつながって、1つのコンテキストになります。 この形にしているのは、コンテキストを膨らませずに、数千件から欲しい数件を取り出すためです。そのための工夫は3つあります。 Claude Codeに検索をさせない :本文を読み比べるのは索引の仕事で、Claude Codeは検索クエリを渡して、置き場所と要約とタグを受け取るだけ。メモリが何千件に増えても、検索でコンテキストが膨らむことも、トークンを余計に消費することもない 1件に決め打ちしない :検索は、正解を上位10件に入れるのは得意でも、1位に当てるのは苦手。だから1位だけを読まず、10件の要約を見比べてClaude Codeが選ぶ 本文は圧縮して読む :数件でも全文を読むとコンテキストを圧迫するので、まずは質問に関係する見出しだけに絞った圧縮版で読む。足りなければ圧縮を緩めて、読む範囲を広げる 読み込みの分担は次のとおりです。 判断(Claude Code) :検索クエリを決める。候補の要約とタグを見て、読むものを選ぶ 実処理(MCPツール) :索引を検索する。本文を圧縮する。リンクを解決する 関連メモリの想起 ここまでは、人が「読んで」と頼んでからメモリを読んでいました。想起では頼みません。会話している内容に近いメモリがあれば、Claude Codeが自分から読みにいきます。 プロンプトを送るたびに、hooksが裏で動いて、読み込みフローと同じ方法で検索します。近いかどうかは1回では決めず、ターンを跨いでスコアを積み上げます。流れは次のとおりです。 検索方法 :全文検索と意味検索で、近いメモリを探す スコアリング :プロンプトのたびに候補へスコアを付け、それを累積していく。スコアは、検索での順位と、プロンプトと重なる語の多さで決まる。ただし、チケット番号や識別子のような特徴のある言葉が、プロンプトと候補の両方に出てくるときだけ スコアの減衰 :スコアは会話が進むにつれて下がり、話題が続かない候補は消えていく Claude Codeに知らせる :しきい値を超えたメモリだけが、「近い記憶がある」としてプロンプトに添えられる チケット番号のような特徴のある言葉が一致すれば、1回でしきい値を超え、ありふれた言葉では、同じ話題が何ターンか続いてはじめて超えます。できるだけ、会話に関係のないメモリを想起させないようにしています。知らされたあとに実際に読みにいくかは、Claude Codeに会話の流れを見て決めてもらいます。 実作業スキルとメモリの接続 保存と読み込みは、プロンプトで頼まないと動きません。作業に集中していると頼むのをよく忘れ、どちらも作業とは別の一手間になっていました。 そこで、調査や設計などを進める実作業スキルに、メモリの読み書きを埋め込みました。人が頼むのは「調査して」だけです。メモリのことは何も言いません。 機能を開発するときは、先ほどのツリーにあった <機能名>/ のディレクトリを作り、以降はその中で作業します。スキルはフェーズごとに1つずつあり、上から順に1本のフローとして流れます。どのスキルも、起動すると前のフェーズまでのメモリを自動で読み込み、終わると決まった先へ成果を書き出します。読み書きの場所が決まっているので、スキルどうしは互いを知らなくてもつながり、フェーズを足すときも置き場所を決めるだけで済みます。 メモリは、フェーズが来る前に置いておくこともできます。懸念なら 01-concern/ 、決まっている仕様や会議のメモなら 02-context/ 、調べてあった内容なら 03-research/ です。ディレクトリごと読み込まれるため、1つのファイルにまとめる必要はありません。 この一連のフェーズを、まとめて実行するスキルも用意しました。人が関わるのは、やりたいことを会話で詰めるところまでです。そのあとは、調査からレビューまで無人で進みます。 フローに乗る作業では、メモリの保存や読み込みを頼む場面がなくなりました。 メモリの共有 保存したメモリは、この仕組みを使うメンバー全員が読めます。いま使っているのは十数人です。案件や職種を問わず、同じ1つのリポジトリへ集まります。一人が調べたことや決めた設計を、チームの資産にするためです。 Claude Codeが応答を終えるたびに、hooksがメモリの差分を確認します。あれば、担当者専用のブランチを作り、コミット・プッシュ・プルリクエスト作成まで自動で進めます。人が手元でGitを操作することはありません。 コンフリクトはめったに起きません。書く場所が担当者ごとに分かれているからです。まれに起きてもhooksが自動で直そうとし、できなければ人に知らせます。 mainへのマージだけは自動化せず、担当者に任せています。破壊的な差分が勝手に入るのを避けるためです。区切りのよいところで中身を見てから、手でマージします。 マージされたメモリは、他のメンバーのローカルにも自動で反映されます。プッシュするときと同じく、応答の終わりにhooksが最新のmainを取り込みます。 効果 メモリを使うと、3つの変化がありました。同じ説明のくり返しが消えたこと、プロンプトがメモリにない文脈だけで済むこと、そして人の作業が文脈集めに寄ったことです。 Claude Codeへ同じ説明をくり返さなくなった 以前は、自分で調べた結果や決めた設計、その理由を毎回プロンプトへ書き並べていました。いまは「この機能のメモリを読んで」の一言でClaude Codeとの認識を合わせられます。それまでの作業で書かれたメモリが機能ディレクトリにたまっているからです。 この読み込みが、直近2か月半で約800回ありました。1つの文脈をClaude Codeへ説明し直すたびに、5分や10分はかかります。5〜10分×800回で、67〜133時間ぶんの説明が浮いた計算です。 作業のコンテキストが連続していることも、説明のくり返しがなくなった要因です。保存したメモリは、次の日にはまた読まれます。読み返されたメモリのうち半分は、保存から18.7時間以内に読まれていました。 Claude Codeへ説明し直すことがなくなり、時間削減につながりました。 プロンプトに書くのはまだメモリにない文脈だけ プロンプトに書くのは、主に新しい要望やいま決まったばかりのことです。作業に要るコンテキストを土台とタスク特有の2つに分けると、土台の部分はClaude Codeがメモリをつなぎ合わせて作ります。人が書くのは、タスク特有の部分だけです。 今回プロンプトに書いたタスク特有のコンテキストも、作業が終わればメモリとして残ります。次の作業では、それが土台側に回ります。実際に、保存したメモリの約3割は、後日の別の作業で土台として読み返されています。土台は作業のたびに厚くなり、人がプロンプトに書く分は減っていきます。毎回、いま話したいことに集中できます。 作業によって、必要になる文脈は違います。案件概要のような汎用的な土台を毎回渡すと、今回の作業との間に差が出て、その差を人が説明で埋めることになります。メモリを使えば、作業に合わせた土台が組み上がるので、その差が小さく、人はタスク特有の説明だけに集中できます。実際に、複数のメモリを読んだ作業は113回ありましたが、同じ組み合わせは一度もありませんでした。たとえばある判定ルールのメモリは7つの作業で読まれていて、一緒に読まれたメモリは7回とも別でした。 土台を説明せずに済むので、Claude Codeとの認識合わせが速くなり、細かいところまで合わせられるようになりました。 メモリとスキルがつながり人の作業は文脈集めに寄った 土台のコンテキストはメモリから組み上がるので、人はタスク特有のことだけを説明すれば、Claude Codeと詳しく認識を合わせられます。そうして合わせた認識は、メモリと実作業スキルがつながっているので、そのまま調査・設計・実装へ流れていきます。人が途中で説明し直す場面はありません。 実際に開発の進め方が、これまでと変わってきています。 文脈を集める :案件の情報を集めて、メモリに残す やりたいことを伝える :作る機能の概要を見て、Claude Codeへざっくり伝える。数ターンで認識を合わせる コンテキストが組み上がる :やりたいことと、メモリから組み上げたコンテキストが合わさって、このあとの作業に必要なコンテキストが決まったディレクトリへ置かれる 開発フローが流れる :調査・設計・実装・レビューと順に進み、プルリクエストができる プルリクエストをレビューする 以前は、各作業フェーズで、人がプロンプトで文脈を渡していました。いまは、人の作業が文脈を集めて渡すところに寄っています。 Slackで仕様が決まったとき、会議で前提が変わったとき、レビューで方針が変わったとき。そのたびに、その場の文脈をメモリに残していきます。アウトプットの品質は、タスク開始前に、点在する文脈をどれだけメモリにできるかで決まります。 チームの記憶になった ここまでは、一人で使ったときの話です。 複数人で共有すると、想像を超えた効果がありました。他のメンバーが調べたことや決めたことを、自分のClaude Codeがそのまま読みます。あとからでも、誰が決めたことか、なぜそう決めたかを詳細に確認できます。 メモリを通してチームにコンテキストが共有された コンテキストが渡る方向は3つあります。 縦 :上流から下流へ。意思決定を背景ごと引き継げる 横 :同じ職種の間で。調査や判断を使い回せる 斜め :職種を跨いで。相手の前提に気づける 縦は、上流から下流へ渡ります。 上流の要件整理や他チームとの認識合わせは、プロジェクトリーダーがまとめて引き受けることが多く、上流の文脈はリーダーにたまります。リーダーは決まったところから、メンバーへタスクを渡していきます。その際、人から人へ伝わるのは結論が中心で、そこに至る背景や経緯は薄くなりやすいです。リーダーが決定事項をメモリに残しておけば、上流で決まった背景がそのまま下流へ渡るので、以下のような効果がありました。 リーダーとの会話が認識合わせだけで済んだ :メモリから意思決定とその背景・判断材料を詳細に読み込める。メンバーはリーダーへ聞きに行かず、リーダーもメンバー一人ひとりへ説明し直さずに済む(コミュニケーションコストの削減) 下流のコンテキストが詳細になった :リーダーの意思決定・背景・判断材料が、そのままメンバーのClaude Codeの作業コンテキストになる(アウトプットの品質向上) 実際に、設計と実装で担当の分かれた機能がありました。担当者は、Claude Codeにリーダーが残した設計メモリを読み込ませ、Claude Codeとの会話で認識を固めてからタスクを始めました。引き継ぎのミーティングは開かず、軽い認識確認だけで済みました。 横は、同じ職種のメンバー同士で渡ります。 同じ案件を複数人で進めていると、誰かが調べたエンドポイントの仕様や、決めた実装方針が、そのまま他のメンバーの役に立ちます。メモリなら、それが同じ場所に集まるので、以下のような効果がありました。 同じ調査をやり直さずに済んだ :一人が調べてメモリに残せば、あとの人はメモリを読むだけで済む(調査時間とトークン消費の削減) 実装の判断を、本人へ聞く前に引けた :メモリからその機能を作った人の意思決定を読める。APIの仕様がなぜその形なのかも、担当者へ聞かずに分かる(設計の手戻り抑制・コミュニケーションコストの削減) 他のメンバーの調査と判断が、自分の作業で使えるようになりました。 斜めは、職種を跨いで渡ります。 職種が違えば受け持つ層が異なり、バックエンドはAPIが何を返すかを、フロントエンドはそれをどう見せるかを決めます。会話に上がるのは主要な部分で、細部の前提までは確認できないことがあります。メモリなら、職種が違っても同じ場所に集まるので、以下のような効果がありました。 職種を跨いで食い違いに気づけた :相手が何を前提にしているかが、自分のClaude Codeにも見える。前提・認識のずれが早い段階で見つかる(手戻り抑制・アウトプットの品質向上) 実際に、バックエンドの設計中に、Claude Codeが自発的にフロントエンド担当者の設計メモリを読み込み、設計の食い違いを指摘してきたことがありました。ある画面にタブを出すかどうかを、バックエンドはAPIが判定してフラグで返すつもりでした。フロントエンドは画面側で判定するのでフラグは要らないつもりでした。片方のメモリだけを見ていたら、気づかないまま進んでいました。 メモリを通して過去の担当者に聞ける 案件を進めながら残してきたメモリは、案件が終わったあともそのまま残ります。担当者の頭の中にしかなかった細かい文脈が、チームの記憶として永続化され、あとから誰でも詳細まで見にいけます。 これまでは、設計書をあとから読み直しても決まったことが中心で、その背景・経緯までは分かりませんでした。このようなドキュメントは読み手のために清書するので、背景・経緯が落ちやすいです。メモリには、読み手向けの作り直しが入りません。作業したときのやりとりがそのまま残っていて、設計内容を当時の背景ごと教えてくれます。 こうして残ったメモリがあれば、以前の案件の仕様を聞かれても、担当者でなくても具体的に答えられます。実際に、以前の案件で作った画面の表示仕様について、他チームから影響確認の問い合わせを受けたことがありました。その際に回答したのは、当時担当ではなかったメンバーでした。当時の案件担当者が5か月前に残した調査・設計メモリから表示している値の参照経路を確認し、最新コードをチェックしてから回答していました。 一人ひとりがClaude Codeとの会話で残したメモリが、チームの記憶になりました。他のメンバーの調査や判断はその場で自分の作業に使え、終わった案件の文脈は担当者がいなくても引き出せます。使う人と職種が増えるほど、つながる文脈も増えていきます。 得られた知見 メモリはなんでも残して検索を強くする メモリは、1件で読み切るドキュメントではありません。小さな断片をたくさん残しておき、作業のたびに検索が関係する数件を拾い、Claude Codeがつなぎ合わせて1つのコンテキストにします。1件の中身よりも、メモリどうしがつながることに価値があります。 人が読むドキュメントは、きれいに書いて1か所にまとめ、粒度も揃えます。メモリを読むのは主にClaude Codeです。きれいに整える必要はなく、検索で見つかればそれで十分です。 最初は、メモリも人が読むドキュメントと同じように、矛盾を解消し、重複をまとめてから保存していました。手間がかかるため、保存の回数は減る一方でした。そこで、保存するメモリの取捨選択をやめ、細かい実装の判断も案件全体の方針も、迷ったら残すことにしました。代わりに検索を強くします。作業に関係ないメモリは検索の上位に来ないだけで、邪魔にはなりません。欲しい1件を上位に出せるかが要になるので、検索ロジックには手を入れ続けています。 なんでも残して検索を強くすることで、いいことが3つありました。 小さな判断も引ける :ドキュメントには書かれない細かい実装の判断や、何を心配していたかも残り、あとから検索で引ける あとで必要になるものを落とさない :あとで必要になるかどうかは、残す時点では分からない。1件まるごとではなく、一部分だけがあとで効くこともある 組み合わせが増える :メモリを増やすほど、検索で拾える組み合わせも増え、作業ごとに合った土台が組める メモリの矛盾は問題にならない この仕組みでは、矛盾や間違いはそもそも入りにくい構造になっています。案件を進めながら、その場の事実をメモリに残していくからです。 とはいえ、矛盾や間違いが混ざってしまうこともあります。ですが、それが原因で困ることはありませんでした。理由は2つです。 同じ話題の作業が続く場合 :正しいことを書いたメモリがあとから積み重なる。メモリには更新日が付いていて、Claude Codeは関連するメモリをまとめて読むので、食い違えば新しいほうを正として扱ってくれる 一度きりの話題で後続のメモリがない場合 :メモリを答えにせず、手がかりにして実装や仕様を確かめる。メモリで当たりをつけ、最新のコードで裏を取ってから答える メインセッションはコンテキストのフィルターにする 検索で拾った数件のメモリにも、いまの作業に要らない部分は残ります。そのままメインセッションで作業まで進めると、要らない文脈に引きずられます。そこで、メインセッションには作業をさせず、メモリから拾った文脈のフィルターにします。サブエージェントに、作業ごとに必要な文脈だけを渡して、実行してもらいます。 見えてきた課題 メモリの鮮度 コードや仕様は変わり続けるので、メモリは放っておくと古くなります。いまは、読み込むときにメモリが参照しているコードを見て、保存のあとで変わっていれば「古いかもしれない」と警告するところまでです。まだ、古いメモリが検索の上位に出てくるのは防げていません。次の課題は、鮮度をどう検索の順位に反映するかです。 想起の精度 想起には、課題が2つ残っています。1つはプロンプトです。Claude Codeへ「近い記憶がある」と知らせるだけでは、一度も読みにいきませんでした(110件で0%)。「答える前に思い出してください」と指示として書くと、ようやく3回のうち2回は読むようになりました(208件で65.4%)。適切なプロンプトを探っています。もう1つは誤発火です。関係のない場面でも想起が出てきてしまうので、その回数を減らしていきたいです。 検索のスケール メモリは捨てずに残していくので、件数は増え続けます。増えるほど、欲しい1件を上位に出すのは難しくなります。いまの約7,000件では取り出せていますが、これが数万件、数十万件へ増えたときにどうなるかは分かりません。メモリが増えた状態を擬似的に作って、検証するところから始めようと考えています。 今後の展望 メモリを使う人をプロジェクトに関わる職種へ広げる いまメモリを使っている十数人は、全員がフロントエンド・バックエンドエンジニアです。これからは、PMやQAのようなプロジェクトに関わる他の職種へ広げていきます。この仕組みは職種を選びません。 PM :開発側の調査結果や進捗をメモリから追える。検討の経緯を残してもらえば、開発側もその背景ごと引き継げる QA :設計や実装のメモリから、この機能はどう動くべきかを読み取って、テストの観点に使える 職種を跨いで文脈が1か所に集まれば、誰が決めたことでも背景ごと引けます。チームの記憶を、プロジェクト全体に広げていきます。 Claude Codeから保存を促すようにする 実作業スキル以外では、まだメモリ保存は人が行っています。「メモリに保存して」の一言が必要です。ここをClaude Codeから「保存しますか」と聞いてくる形に変えていきます。仕組みとしては、hooksでセッション内のコンテキストがたまったことを検知して、一定量を超えると保存を促すようなイメージです。残したくない情報もあるので、保存するかどうかは人が判断します。人が意識しなくても保存ができるようになれば、メモリ量の増加につながり、つなげられるコンテキストも増えます。 案件・機能についての文脈集めをClaude Codeのルーティンに任せる メモリを活用した開発では、タスクを始める前に案件・機能についての文脈をどれだけ集められるかで、アウトプットの品質が変わります。文脈を集めてClaude Codeへ渡すのは、まだ人の作業です。SlackやConfluenceに点在する文脈を集めてメモリにする作業を、Claude Codeの ルーティン で自動化します。人が文脈を集めなくても、Claude Codeが自分で文脈を集められる環境を整備します。 まとめ 本記事では、Claude Codeとの会話をMarkdownのメモリとして残し、次の作業で検索して使い回す仕組みをご紹介しました。一人で使うだけでも、Claude Codeへ同じ説明をくり返さずに済み、次の作業は前回の続きから始められます。チームで共有すれば、他のメンバーが調べたことや決めた理由も、自分のClaude Codeから引けます。担当者の頭の中にしかなかった文脈が、チームの記憶になりました。AIに同じ説明をくり返している方や、チームで文脈をどう共有するか悩んでいる方の参考になれば嬉しいです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、ZOZOTOWN開発2部Webバックエンドブロックのぐらです。普段はZOZOTOWNのバックエンド開発を担当しています。ZOZOTOWNのリプレイスでは、既存システムの仕様を正確に把握することが欠かせません。私たちはこの分析に AIを活用 してきました。しかし、人間による追加調査がまだまだ必要という課題を抱えていました。本記事では、AIによる既存システムの分析ワークフローを作り直した過程と、そこで得られた知見を紹介します。 目次 はじめに 目次 背景・課題 ZOZOTOWNリプレイスにおける既存システムの分析 AI分析で直面した3つの課題 AI分析ワークフローを作り直す 改善1 ── 計算で確定できることは計算で出す 改善2 ── 調査を機能単位からシステム横断へ広げる 改善3 ── 出力をMarkdownからHTMLへ変える 改善の効果 精度はどう変わったか レビューの仕方はどう変わったか POC段階で見えている限界 今後の展望 まとめ 背景・課題 ZOZOTOWNリプレイスにおける既存システムの分析 ZOZOTOWNのリプレイスでは、長年運用されてきた既存システムを読み解く場面が数多くあります。対象はVBScriptとASPで書かれた環境です。私たちはこの調査にClaude CodeのAgent Skillsを導入し、その取り組みを以前の記事で紹介しました。 techblog.zozo.com このときは、既存コードの調査を4つのスキルに分解しました。依存関係の追跡から調査結果のドキュメント化までをClaude Codeに任せる仕組みです。 ただし、前回の記事の最後では残った課題にも触れています。ひとつは、調査結果は得られても背景の理解が伴わない点です。もうひとつは、AIが大量の情報を要約して出力するため、人間が全項目を精査しきれない点です。本記事で紹介するのは、この2つに取り組んだ続きの話です。なお、ここで紹介する改善は、現時点ではPOC(概念実証)段階の取り組みです。 AI分析で直面した3つの課題 AIに既存システムを分析させる中で、次の3つの課題が見えてきました。 プロンプトを工夫しても、分析結果に抜け漏れが発生する 分析結果が断片的な情報にとどまり、人間が横断的に追加調査する必要がある 出力先であるMarkdownファイルでは、表現できることが限られる このうち1つ目には、具体例がありません。同じプロンプトで同じコードを分析させても、次に抜けるところまで一致するとは限らないからです。LLMの出力は確率的で、再現しません。 個別の抜け漏れを見つけてプロンプトで塞ぐ、という直し方が効かないのはこのためです。塞いだつもりの穴が、次の実行では別の場所に開きます。 残る2つについては、それぞれの改善とあわせて後述します。 AI分析ワークフローを作り直す これらの課題に対して、次の3つの改善を実施しました。 3つに共通する狙いは、人間のレビュー負担を減らすことです。AIに分析させる以上、その出力を人が確認する工程は残ります。ただ、対象が大きくなるほどレビューは追いつかなくなります。そこで、人が見なければならない範囲を機械で絞り込み、残った範囲は見やすい形にしました。 改善1 ── 計算で確定できることは計算で出す 抜け漏れをなくす手がかりとして、ThoughtworksのBirgitta Böckeler氏による次の記事を参考にしました。 martinfowler.com 「Harness engineering for coding agent users」は、harnessの設計を扱った記事です。harnessとは、モデルそのものを除いたAIエージェントの周辺システムを指します。その構成要素は2種類に整理されています。エージェントに先回りして文脈を与えるguides(feedforward)と、出力を後から検査するsensors(feedback)です。 そのうえで、guidesとsensorsは実行のされ方によってさらに2つに分けられます。記事から引用します。 Computational - deterministic and fast, run by the CPU. Tests, linters, type checkers, structural analysis. Run in milliseconds to seconds; results are reliable. Inferential - Semantic analysis, AI code review, "LLM as judge". Typically run by a GPU or NPU. Slower and more expensive; results are more non-deterministic. Computationalは決定的で速く、結果が信頼できます。テストやリンター、型チェッカー、構造解析がこれにあたります。対するInferentialはLLMによる意味的な判断です。扱える範囲は広いものの、遅くて高価なうえに結果が非決定的です。 この記事が想定しているのはコーディングエージェントですが、2つの分け方は既存システムの分析にもそのまま当てはまりました。本来Computationalに確定できることまで、Inferentialに委ねてしまっていないか。この観点でワークフローを見直し、計算で確定できることは計算で出す方針に切り替えました。推論に委ねる範囲が狭いほど、ハルシネーションが混ざる余地も狭くなります。 やったことは単純です。AIに分析させる前の段階で、まず分析対象のコードを構造化します。以前に作った軽量な言語サーバー(LSP)をスクリプトから呼び出し、静的解析でコードの構造を洗い出しました。実行されるコードを関数の単位で分解し、その一つひとつへ一意な識別子を与えておきます。 そしてAIには、分析結果を書くときに根拠となる識別子を必ず示させます。こうすると、抜け漏れが計算で見つかるようになります。洗い出した識別子のうち、どの分析結果からも参照されていないものがあれば、それが書き漏らしだからです。人間がAIの出力を読み返して抜けを探す作業は、識別子を突き合わせるだけの処理に変わりました。 この形にしてから、抜け漏れへの対処の仕方も変わりました。以前は、抜け漏れが出るたびにプロンプトを書き足していました。いまはまず、計算で洗い出す事実の種類が足りていなかった可能性を疑います。計算の側を直せば、以降は同じ種類の見落としが検証を通らなくなります。プロンプトを厚くするのではなく、計算で確定する範囲を広げる。これが今回の改善の実体です。 Böckeler氏の整理に当てはめてみます。事実の洗い出しがComputationalなguides、識別子の突き合わせがComputationalなsensorsにあたります。意味の解釈はInferentialに残し、その出力は別のAIによるレビューで検証しています。 改善2 ── 調査を機能単位からシステム横断へ広げる リプレイスは段階的に進みます。機能をひとつずつ新しい環境へ移していくのに合わせて、分析も機能単位で進めていました。ただ、この「機能単位」という切り方には、見落としやすい領域が存在します。 典型的なのが、Cookieやセッションのように、複数の機能から共有される「状態(state)」です。移行対象の機能だけを見ていると、書き込み側か読み取り側のどちらか一方しか視界に入りません。どちらか片方だけを記録しても、その状態が何を約束しているのかは定義しきれません。 さらにやっかいなのは、よく使われる状態ほど参照元があちこちに散らばることです。分析結果はどうしても断片的なところで止まり、その先は人間が横断的に追加調査して埋めていました。 そこで、影響のあるCookieやセッションは、機能単位での調査をやめ、システム横断で調べる方式に変えました。書き込んでいる箇所と読み取っている箇所をまとめて洗い出します。これまで人間が追加調査で補っていた部分も、調査ワークフローの中に組み込みました。 改善3 ── 出力をMarkdownからHTMLへ変える これまで分析結果はMarkdownで書き出していました。AIにとって扱いやすい形式だからです。生成と修正が素直に通りますし、差分も追えます。 ただ、読む側から見ると厳しい形式でもありました。分析の内容は情報量が多く、Markdownに落とすとどうしても縦に長くなります。全体をつかむにはスクロールが欠かせません。根拠を確かめるときは、ソースコードを別のウィンドウで開いて並べていました。分かりやすくする工夫の手段は、見出しと箇条書きと表くらいに限られます。 そして、ここまでの2つの改善がこの問題を大きくしました。計算で確定できる範囲を広げ、調査もシステム横断へ広げたぶん、出てくる情報量は確実に増えます。同じ形式のまま情報だけが増えれば、縦に伸びるだけです。 そこで、出力をHTMLに変えました。画面上で関数をひとつ選ぶと、関係する箇所が連動して絞り込まれます。コードから引けば、そのコードを根拠にした記述と、記述から起こした仕様がまとめて見えます。逆からたどっても同じです。 実際の画面ではなく、構成を示すイメージ図です 分析の本体は、結果を構造化してまとめたyamlで、HTMLはそのyamlを読みやすい形にレンダリングしたビューです。ビュー側には新しい情報を持たせていないため、yamlさえあればビューの形式は自在に変更できます。 改善の効果 精度はどう変わったか 一番大きな変化は、網羅を機械が判定するようになったことです。 分析対象の1画面には、分岐や入力、状態の読み書きといった事実が数千件あります。改善前は、この規模の出力に対して「抜けていないか」を人が読んで確かめるしかありませんでした。読み切れないので、確かめたつもりになるしかない、というのが実情です。 いまは、引用されていない識別子を数えるだけで答えが出ます。全部埋まっていれば網羅、埋まっていなければどれが足りないかまで分かります。精度が上がったというより、精度を確認できるようになった、という表現が実態に近いです。 もうひとつ変わったのは、不具合が出たときに疑う先です。以前は確率的な出力そのものを相手にしていました。いまは、まずスクリプトを疑います。スクリプトは決定論的に動くため、同じ入力なら同じ結果が返ります。テストを書いて事前に潰すこともできます。網羅性は安定し、原因の切り分けも早くなりました。 レビューの仕方はどう変わったか HTMLにしたことで、レビュー用途に合わせた画面を用意できるようになりました。以前は根拠を確かめるたびに、ソースコードを別のウィンドウで開き、複数のドキュメントを探して画面を切り替えていました。いまはソースコードも含めて、必要なものが1枚に集まっています。それによりエディタを別で開く必要がなくなり、ドキュメントを探す時間が減りました。 POC段階で見えている限界 本取り組みはまだPOC段階です。 計算で担保できるのは、あくまで「漏れていないか」までです。書かれている内容が正しいかは、別の問題として残ります。コードの語彙を利用者から見た言葉へ翻訳する部分は推論の仕事です。その翻訳が妥当かどうかを機械で判定する方法は、まだありません。ここはいまも人による確認が必要です。 静的解析にも取りこぼしがあります。動的な呼び出しなどは決定論でたどれません。分析した画面には、例外なく不完全の印が立っています。印が立っている以上、網羅を主張できる範囲もそのぶん狭くなります。 今後の展望 この分析の先には、新しい環境の設計と実装、そして新旧を比べた合否の判定が控えています。 直近で進めるのは、分析資料を設計工程へつなぐことです。分析で得た仕様が、そのまま設計の入力として使える状態を目指しています。 新旧システムを比較して「同じ挙動と言えるかどうか」の合否判定は、検出できた差分についてしか下せません。つまり判定の信頼性には、その手前の抽出でどれだけ漏れなく差分を拾えているかで上限がかかります。網羅を計算で確かめられるようにしたねらいは、この信頼性の上限を引き上げることにあります。 抽出した仕様は、合否の判定にそのまま使える形で書いてあります。分析結果を人が読み替えて別の形に起こし直す、という一手間が省けます。 まとめ 本記事では、既存システムのAI分析を作り直した取り組みを紹介しました。計算で確定できることは計算に任せました。その結果へ識別子を振り、AIには引用を義務づけました。こうして、抜け漏れを計算で見つけられるようになりました。人間が追加調査で埋めていた部分も、ワークフローの中へ取り込みました。出力をHTMLにしたことと合わせて、確認のしやすさが変わっています。AIを使った既存システムの調査を検討している方がいれば、ぜひ参考にしてみてください。今後は、抽出した仕様を新旧の合否判定へそのままつないでいきたいと考えています。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、ECプラットフォーム部マイクロサービス戦略ブロックの半澤です。普段は、システムアーキテクチャの全体最適化など、モノリスからマイクロサービスへシステムをリプレイスする過程で生じる、複数チーム間の課題解決を主に担当しています。 本記事では、ZOZOの基幹システムリプレイスにおいてモノリスからマイクロサービスへ移行するために、イベントストーミングを起点にドメイン間の境界と責務の決定をどのように進めたかを紹介します。あわせて、既存コードの解析と付箋への変換に使ったClaude CodeのAgent Skillsの設計を、付録で共有します。なお、イベントストーミング(業務で起きる出来事を時系列に並べ、関係者で業務の流れを理解する手法)自体の詳しい解説は本記事の範囲外とし、実際にどう業務理解・境界検討に活用したかに絞って紹介します。 目次 はじめに 目次 基幹システムリプレイスについて 在庫ドメインについて 在庫ドメイン検討の進め方 1. 基幹の各チームリーダーへのヒアリングで在庫依存度を洗い出す 2. 業務理解のためのイベントストーミングを実施する 3. 既存コードを読みシーケンス図を作成する 4. シーケンス図を付箋形式に変換する Skillによる変換の仕組み Miroボード上での手動調整 付箋形式の利点 シーケンス図をそのまま使わない理由 ステップ2と別に変換し直す理由 イベントストーミング本来の使い方との距離 5. 基幹の各チームへのヒアリングでAs-Isを完成させる 6. To-Beを整理し、在庫ドメインの境界・責務を決定する 7. 他ドメインのチームと合意形成する まとめ さいごに 付録:使用したAgent Skillsの詳細 ステップ3:シーケンス図生成Skill(trace-vbs-flow) ステップ4:付箋変換Skill(seq-to-miro) 基幹システムリプレイスについて まずは、基幹システムリプレイスについて簡単に紹介します。ZOZOの基幹システムは、物流拠点ZOZOBASEにおける入荷・発送業務や、ZOZO社員やZOZOTOWN出店者様が利用するバックオフィス業務のためのシステム全般を指します。2004年から大きなアーキテクチャ変更なく積み重ねられてきたモノリシックなシステムで、その多くはVBScriptで書かれています。 Microsoftは2023年10月にVBScriptの段階的な非推奨化を発表しました。 公開されているタイムライン では、2027年頃にWindowsでのデフォルト無効化、その後の完全削除が見込まれています。基幹システムが動かなくなれば事業が止まるため、VBScriptからの脱却は事業継続のために必須かつ急務です。 また、現行システムはVBScript以外にも負債を抱えています。基幹DBが密結合であるため、基幹DBへのクエリがZOZOTOWNなど他システムの安定性に影響しています。さらに、使われていない機能も多く残り、プログラム構造が業務モデルを表現していないため、属人化も進んでいます。こうした課題もリプレイスで解消しようとしています。 これまでは、入荷・発送といった自明な業務のまとまりはあったものの、はっきりしたドメイン境界はありませんでした。テーブルをドメインごとに管理するのではなく、どの業務も同じ基幹DBのテーブルを自由に参照・更新しながら、業務・会計の整合性を担保してきました。この形は、テーブルごとのオーナーシップを厳密に分けるよりも、必要な案件に柔軟にリソースを集めて対応しやすく、モノリスとして基幹システムを運用してきたフェーズでは合理的でした。 一方で、システム規模の拡大により、全員がすべての仕様や変更を把握することは難しくなってきています。そのため、今後はドメインごとに責務を分け、各チームが自ドメインの専門性を高めながら開発・運用できる状態を目指しています。並行開発によるスピード向上と、自ドメインに集中することによる品質向上が狙いです。リプレイス後は、この方針に沿って、テーブルやカラムごとに各ドメインがオーナーとなり責務を持ちます。DBについては、まずは既存の基幹DBを使用したままモジュラモノリス構成を挟み、将来的にはドメインごとに分離して、他ドメインとはAPIまたはイベントで連携する想定です。 在庫ドメインについて 基幹システムリプレイスにおいて、現在想定されているドメインは次のとおりです。 入荷 発送 商品 注文(返品・交換を含む) 会計 認証・認可 など 会計や認証・認可ドメインは独立性が高く、比較的マイクロサービス化しやすい一方、入荷や発送、注文ドメインなどは在庫データと密に結合しています。そもそも在庫は、特定のドメインに属する機能というより、複数のドメインから参照・更新される「みんなのもの」になっています。在庫を切り離せない限り他ドメインも独立できない、という構造的な要のポジションにあるのです。 また、在庫データの実態を把握しているメンバーは、基幹システムの中でも限られた数人の古参メンバーに留まります。しかもその数人にとってすら、在庫の全体像がすべて見えているわけではありません。「なぜこの処理が存在するのか」「なぜ本番環境にこのような想定外のデータがあるのか」といった疑問に一人で答えられる人はいません。複数チームのリーダーへ横断的にヒアリングしてようやく実態が見えてくる、という状況でした。筆者自身も基幹システムの知識がほぼない状態からのスタートで、ドキュメントや特定の一人への質問だけでは実態を掴みきれず、複数人の記憶と実際のコードを突き合わせながら理解を組み立てていく必要がありました。 そのため在庫リプレイスでは、VBScriptからの脱却に加えて、属人化・ブラックボックス化を解消し、在庫データの品質を担保することを目指しています。また、在庫の責務を明確にして他ドメインとの結合を減らし、将来的に各ドメインが独立できる状態を作ることも重要です。 果たして「みんなのもの」である在庫は、1つのドメインとして成立するのでしょうか。また、成立するとしたら境界はどこに引くべきなのでしょうか。先行きが全く見えない中で、イベントストーミングを起点に境界を探ることにしました。 在庫ドメイン検討の進め方 在庫ドメインの検討は、約半年かけて次の7ステップで進めました。以降、各ステップの説明の中で前後のステップに言及することがあるため、先に全体像を示しておきます。 基幹の各チームリーダーへのヒアリングで在庫依存度を洗い出す 業務理解のためのイベントストーミングを実施する 既存コードを読みシーケンス図を作成する シーケンス図を付箋形式に変換する 基幹の各チームへのヒアリングでAs-Isを完成させる To-Beを整理し、在庫ドメインの境界・責務を決定する 他ドメインのチームと合意形成する 体制は少人数でした。検討を始めた時点では在庫チームはまだ存在せず、筆者がファシリテーターとして先行して着手しました。途中で決まった在庫チームのメンバーと筆者の3名を中心に、解析対象の領域に詳しいエンジニアの応援も得ながら進めました。 1. 基幹の各チームリーダーへのヒアリングで在庫依存度を洗い出す まずは、基幹の各チームリーダーを対象に、在庫リプレイス検討の目的を説明し、それぞれの担当領域における在庫への依存度をヒアリングしました。 ここで知りたいのは、在庫と同時に更新しなければ業務として成り立たないデータ、つまり強整合を必要とする範囲がどこまで広がっているかです。なお本記事では、更新の反映遅れを許容する結果整合性では業務が成り立たず、在庫と常に同時に正しくなければならない性質を「強整合」と呼びます。現在は同一DBのトランザクションがこれを自然に満たしていますが、将来DBをドメインごとに分離すると自明には満たせなくなるため、この範囲がドメイン境界を決めるうえでの重要な制約になります。ただし、その範囲はこの時点では分かりません。そこで、こちらから「在庫」に関係しそうな核となるテーブル・カラムを具体的に挙げ、それを更新しているユースケースを各チームにピックアップしてもらいました。 ヒアリングシートのイメージは、次のような形です(実際の記入内容は載せられないため、形式のみを示すサンプルです)。 チーム 依存先 参照のみ or 更新あり 在庫と同時に更新するデータ リアルタイム性の要否 主なユースケース 不整合時の業務影響 発送 倉庫在庫数 更新あり 発送 注文 入出庫履歴 必要 発送準備 発送完了 在庫不足エラーによる注文キャンセル 入荷 倉庫在庫数 更新あり 発注 入荷 入出庫履歴 必要 入荷検品 棚卸 - 会計 倉庫在庫数 参照のみ ー 不要 会計処理 財務諸表の正確性の低下 このように担当領域ごとに整理することで、「リアルタイム性や同時更新が必要な領域」の当たりをつけ、次のステップで詳しく見るべき対象を絞り込みました。この時点で挙がった在庫更新のユースケースは合計で約30個です。これらのユースケースを「入荷検品をした」のような出来事(イベント)の形へ変換し、時系列に並べて業務全体を俯瞰するBig Pictureとして整理しました。次のステップで実施するイベントストーミングの土台になります。 実際のボードは次のような形です(内容はぼかしています)。 2. 業務理解のためのイベントストーミングを実施する このステップでは、ステップ1で更新ありかつリアルタイム性が必要と回答のあったチームを対象に、業務理解を主な目的としてイベントストーミングを実施します。具体的には、Big Pictureに並べた在庫操作イベントを起点に、その前後の業務イベントを洗い出して補い、業務の流れを詳細化するProcess Modellingまで掘り下げます。 誰が何のためにどのような業務の流れで在庫を操作し、その結果何が起こるのか。 在庫の状態にはどのようなパターンがあるのか。 こうした点を整理しながら、この後のステップのために既存コードとの紐づきもヒアリングしておきます。実施形式はチームごとのオフライン開催で、業務をよく知るエンジニア2〜3名に参加してもらい、半日程度で行いました。 イベントストーミングでは、業務の流れを種別ごとに色分けした付箋で表現します。付箋の凡例は次のとおりです。 この凡例をもとに実施したボードの一部は、次のような形です。赤いホットスポットが多く、この時点ではまだ多くの疑問が残っていることが分かります。 3. 既存コードを読みシーケンス図を作成する ステップ2で紐づけた既存コードから、シーケンス図を作成します。主な目的は、使用しているDB・テーブルの洗い出しです。特に、在庫テーブルの操作を含むトランザクションで他に操作されているテーブルと、在庫テーブルの更新パターン・不変条件を整理します。 既存コードには、いわゆるソフトウェアアーキテクチャが明確に適用されていません。ASP(VBScript)とDBというレイヤの分割はあるものの、業務ロジックのほとんどはストアドプロシージャに集中しており、業務のルールがSQLの羅列として表現されています。そのため、DBへの操作を追うことが、そのまま業務ロジックの把握につながるのです。加えて、リプレイス後はテーブルごとに各ドメインがオーナーとなるため、どのテーブルをどう操作しているかは境界を引く直接の判断材料になります。 また、シーケンス図はファイルを追うだけでは掴みにくい全体像の把握にも役立ちます。ルートとなるASPファイルにすべての処理が平坦に書かれているケースは少なく、同じファイル内の別のFunctionや別ファイルのFunctionを次々と呼び出します。さらに、ストアドプロシージャも何層にもネストしており、処理を追ってファイルを行き来するうちに、どこに何が書かれていたか分からなくなってしまうのです。呼び出しフローを1枚の図に落とし込んでおけば、どこにどのような処理があるかを俯瞰できる地図になります。 シーケンス図の作成には、Claude CodeのAgent Skillsを使用します。Skillには、ルートとなるFunctionから呼び出しフローを静的解析するルールを定義しています。あわせて、ドメインごとの「辞書」(テーブル一覧とユビキタス言語のマッピング)も用意し、Skillに読み込ませています。これまではテーブル名をそのまま呼んだり、同じテーブルに対して複数の呼び名が混在したりしていたため、今回選定したユビキタス言語を意識的に使う狙いです。Skillの詳細な設定は、興味のある方向けに本記事末尾の付録で紹介します。 生成されたシーケンス図では、トランザクション境界がactivateで表現され、更新処理の矢印は強調色で描かれ、在庫ドメインのテーブル更新にはnoteでハイライトが付きます。また、ストアドプロシージャの処理はgroupで囲み、その範囲を分かりやすくします。 次の例では、在庫テーブルの更新と同一トランザクション内で、注文や入荷といった他ドメインのテーブルも更新されている様子が読み取れます。なお、本記事に登場するテーブル名・カラム名・コード値は、いずれも一般的な命名に置き換えたサンプルです。また、以降に登場する「倉庫棚」は在庫データの呼び名です。商品1点(1ピース)ごとに1レコードを持ち、その商品のバーコードや、実際に保管されている倉庫・棚の番号を管理しています。 生成された図はそのまま正とはせず、出力にコメントで付与された根拠(ファイルパス・行番号)をたどってコードと突き合わせてレビューしています。Skillはあくまで、解析の下書きを高速に得るための道具という位置づけです。 4. シーケンス図を付箋形式に変換する ステップ3で作成したシーケンス図を、付箋としてMiroボード上に展開します。ここで作るのはイベントストーミングそのものではありません。付箋・時系列という表現とその語彙をイベントストーミングから借りて、CRUD単位の更新処理を並べた本ワーク独自の表現です。イベントストーミングとの違いは、このステップの最後にまとめて説明します。 Skillによる変換の仕組み この変換には専用のSkill(seq-to-miro)を使用します。シーケンス図のファイルパスとMiroボードURLを入力に、Miro MCPツールで座標計算済みの付箋・矢印をボード上に直接生成します。 Skillは、シーケンス図中のSQLから更新処理を抽出し、「コマンド」「集約」「ドメインイベント」の付箋へ機械的に変換します。また、更新処理を囲む条件があれば「ポリシー」として出力し、更新対象のテーブル名もイベントの下方へ出力します。テーブル名は、既存ソースとの紐づけのために存在する本ワーク独自の付箋種別です。テーブル・カラム名の日本語化には専用の辞書を使います。Skillの詳細な設定は、こちらも本記事末尾の付録にまとめています。 たとえば、ステップ3のシーケンス図例にあった「倉庫棚の新規作成」処理は、次の付箋に変換されます。 INSERT stock_shelves (warehouse_id=9) → [コマンド] 作成する [集約] 倉庫棚 [ドメインイベント] 作成した [テーブル] stock_shelves [ホットスポット] 在庫ドメイン更新: - 倉庫棚の新規作成 warehouse_id=9 シーケンス図全体の出力例は以下のとおりです。同じ形の塊が横に並ぶ全体像を掴むためのもので、文字は読めなくて構いません。 Miroボード上での手動調整 出力はあくまで機械的な一次変換と位置づけ、Miro上で内容の確認と手動による調整をします。複数テーブルの操作が1つの業務的な意味にまとめられそうな場合は、手作業で1つにまとめます。たとえば、returnsとreturn_detailsへのINSERTは「返品を作成した」という1つのまとまりにする、といった具合です。 次の例では、赤い背景が変更の対象とする範囲です。上段が変換直後の状態で、返品と返品明細が別々の付箋列として横に並んでいます。下段では、これを「返品を作成した」という1つの付箋列へ統合し、returnsとreturn_detailsのテーブル付箋をその下へまとめています。 付箋形式の利点 先ほどの機械的な変換だけでも、1つの更新処理は「何に対して(集約)」「何を要求し(コマンド)」「何が起こったのか(ドメインイベント)」という付箋の塊にまとまっています。その塊だけを見れば、処理の内容が一目で分かります。SELECT処理を省き、条件は必要最小限のポリシーとして残して更新の事実に絞るぶん情報量が少なく、その場で処理の流れを理解し議論するのに必要な粒度まで単純化できるのが利点です。 さらに、複数の更新を1つの業務的なまとまりへ手動で統合しておけば、SQL単位で追うよりも粗い、業務の単位で処理の流れを追えるようになります。DBへの操作を追うだけでは、その処理が業務上どのような意図・目的で行われているのかまでは分かりません。そのため、この時点での業務的な処理のまとまりはあくまで在庫チーム側の仮説にすぎず、 warehouse_id = 9 のようなコード値が何を指すかまでは在庫チームだけでは読み解けません。この仮説を基幹の各チームに見せてその場で確認できることが、次のステップの効率を高めます。 もう1つの利点は、複数人でリアルタイムに修正しやすいことです。付箋は1枚ずつが独立した単位なので、ステップ5のAs-Is確認での指摘反映も、ステップ6のTo-Be整理での意味のあるまとまりへの移動・統合・分割も、その場のマウス操作で完結します。こうした理由から、ステップ5以降の基幹の各チームとの議論では主にMiroボード上の付箋を使います。シーケンス図は処理の詳細を確認したくなったときに立ち返る参照先、という役割分担です。 シーケンス図をそのまま使わない理由 シーケンス図をそのまま整理に使わなかったのは、実際のコードから書き起こした図が、筆者自身が読み解くにも、基幹の各チームとの議論の場に持ち込むにも大きすぎたためです。最大のものではPlantUMLのソースが3,000行を超え、更新系SQLだけで約150箇所、更新対象のテーブルは40を超えます。 この規模になると、1つの処理を理解するために必要な情報が図全体に分散し、参照系も含めた大量の処理と条件分岐の中から前後関係を掴むのが困難になります。画面上でも、全体が見える倍率では文字が読めず、文字が読める倍率では全体が見えないため、拡大・縮小とスクロールを繰り返す読み方を、ステップ5以降では基幹の各チームにも強いてしまいます。 そのため、今回はイベントストーミングの記法を借りた付箋での表現を採用しました。対象の規模がもっと小さければ、シーケンス図のまま進められる場面もあるでしょう。 ステップ2と別に変換し直す理由 ステップ2でもイベントストーミングを実施していますが、目的が異なります。ステップ2は、基幹の各チームの記憶をもとに、業務イベントの粒度で業務を理解することが目的でした。しかし業務イベントの粒度では、複数ドメインにまたがる更新の裏側までは見えません。たとえば、ステップ2で「在庫を登録した」と表現されていたある処理は、コードを読むと次のテーブル更新で構成されていました。 発注データを更新した(担当候補:商品) 入荷データを作成した(担当候補:入荷) 在庫管理用バーコードを発行した(担当候補:在庫) 倉庫棚を作成した(担当候補:在庫) 入出庫履歴を作成した(担当候補:在庫) 作成した倉庫棚の評価額を更新した(担当候補:会計) 作成した倉庫棚の販売価格と販売開始・終了日時を更新した(担当候補:商品) 1枚の業務イベントの付箋の裏に、商品・入荷・在庫・会計にまたがる更新が同一トランザクションで詰まっていたことが、ここで初めて見える形になります。リプレイスの検討で知りたいのは、まさにこの「どこに、どのドメインの責務が混ざっているのか」です。そこでこのステップでは、コードで確認した事実をDB更新の粒度で同じMiroボード上に書き出し、ステップ2の業務イベントと突き合わせながら、見えていなかった処理を追加していきます。 イベントストーミング本来の使い方との距離 ここまで読んで、イベントストーミングに詳しい方ほど違和感を持つかもしれません。一般的なイベントストーミングでは、システム詳細やテーブル操作に踏み込みすぎず、業務イベントにフォーカスすることが大切だとされています。CRUDの粒度に引きずられると、現行実装の都合をそのまま未来のドメインモデルへ持ち込んでしまう危険があるためです。 ステップ1・2では、それぞれ業務全体を俯瞰するBig Pictureと個々の業務の流れを見るProcess Modellingで業務を整理しました。ステップ4以降で行っている付箋化は、イベントストーミングそのものではありません。DB更新の粒度で付箋化するのは、後のステップでTo-Beも同じ粒度で描き、As-Isとの差分から「どの更新を、どこへ移すか」というTODOを洗い出すためです。ここで得ようとしているのは、将来のドメインモデルそのものではなく、既存コードの事実とそこからの移行先を同じ物差しで並べる地図なのです。 本来の意味と本ワークでの使い方の対応は、次のとおりです。 付箋種別 イベントストーミングでの本来の意味 本ワークでの使い方 コマンド 何かを起こしたいという意思決定。システムへ送られる(送っただけでは完了を意味しない) SQL更新文を命令形で表したもの ドメインイベント コマンドの結果として起こった、ドメインエキスパートにとって意味のある事実(過去形・変更不可) テーブル更新が行われたという事実(CRUD粒度) 集約 イベントとコマンドの論理的なグループであり、ビジネスプロセスの1ユニット 更新対象をユビキタス言語で表した名前 ポリシー イベントをきっかけに暗黙・明示的に行われるリアクション(自動化されているとは限らず、手動の場合もある) 更新処理を囲む条件分岐( alt / opt )の条件文をそのまま転記したもの テーブル ―(本ワーク独自の種別) 原文へのトレーサビリティ用に残す物理テーブル名 ホットスポット 矛盾・疑問点・意見の相違など、その場で解決できない論点を示すマーカー 本来の意味に加えて、機械変換時に付与するハイライト注記の受け皿としても使用 特にテーブルは、アクター(業務を担う人やシステムなど)が次の意思決定をするために参照する付箋種別であるリードモデルと役割を混同しやすいため、イベントストーミングの用語を流用せず独自の種別としています。 このように、機械変換した直後の付箋が表しているのは、まだ「テーブルを更新した」というコード上の事実にすぎません。次のステップで基幹の各チームによる業務的な意味づけを経て、本来の意味でのドメインイベントに近い表現へ育てていきます。 ステップ3・4は在庫チーム単独の作業で、次のステップからは基幹の各チームとの共同作業になります。 5. 基幹の各チームへのヒアリングでAs-Isを完成させる ステップ4でMiro上に展開した付箋をもとに、ステップ2のイベントストーミングと同じ参加者(基幹の各チームのエンジニア)へヒアリングし、As-Isを完成させます。ステップ2では記憶をもとに業務イベントを教えてもらいましたが、このステップでは逆に、コードから書き起こした事実へ業務的な意味づけをしてもらいます。確認するのは、それぞれの処理が業務的な意味を持つのか、それとも歴史的経緯で残っているだけでリファクタリングによって削除・整理すべきなのか、という点です。本質的な処理と実装都合の処理を、ここで仕分けていきます。このステップ以降のヒアリングは、各チームとのオンラインでの打ち合わせを重ねる形で進めました。チームによって異なりますが、多い場合はステップ7までに10時間弱かかりました。 たとえば、シーケンス図中の INSERT stock_shelves (warehouse_id = 9) は、「倉庫棚を作成する・作成した」というシンプルな付箋に変換されています。このままでは、在庫チーム側では何のための処理か読み取れません。 warehouse_id の 9 が何を指すのかも、マスタを引かなければ分かりません。これを基幹の各チームへ確認したところ、 warehouse_id = 9 は「ユーザーからの返送品専用の倉庫棚」であることが判明しました。そのため、付箋を「返送品専用の倉庫棚」(集約)に対して「ユーザーからの返送品の入庫を要求する」(コマンド)、「ユーザーからの返送品が入庫された」(ドメインイベント)に書き換えました。このように、「stock_shelves」(テーブル)と紐づいたまま業務的な意味が加わります。 次に、在庫ドメインの核である倉庫棚の作成処理が、入荷ドメインに属することが確実な入荷テーブル・入荷明細テーブルの作成処理の間に挟まれている点をヒアリングしました。倉庫棚の作成処理を切り出して、入荷テーブル・入荷明細テーブルの作成処理を1つにまとめられないか確認する意図です。しかしヒアリングする中で、そもそもこの入荷データはZOZOTOWNに出店しているブランド様から送られてくる商品のためのテーブルだと判明しました。ユーザーからの返送品には、本質的に不要なデータだったのです。そのため、「本質的には不要のため処理を削除する」というホットスポットを追加しました。同じ要領で、念のため行われている保険的なデータ作成や、既存処理を再利用するために挟まれた一時的な状態遷移も、削除・整理の候補として浮かび上がりました。 As-Isの完成形は次のとおりです。 あわせて、在庫テーブルを含むトランザクションで他に操作されているテーブルを、次の2つに分類するためのヒアリングも実施しました。 強整合が必要な集合(同時に正しくないと困る) たまたま同一トランザクションなだけの集合(歴史的理由・実装都合) 6. To-Beを整理し、在庫ドメインの境界・責務を決定する 完成したAs-Isをもとに、To-Beを描きます。 As-Isの付箋列では、ドメインに関係なくテーブル更新が入り乱れています。これをできるだけドメインごとに意味のある処理のまとまりへ整理し直し、まとまりごとに主体ドメインと依存先ドメインへ分けていきます。このまとまりの単位は、将来マイクロサービス化した際の1つのAPIに相当する粒度を意識しています。まとまりの単位が将来のドメイン間の呼び出し単位と一致していれば、主体・依存先を分けた結果をそのままインタフェース設計の出発点として使えるためです。この粒度で整理しておくことで、マイクロサービス化できるかどうかの判断もしやすくなります。この整理の結果として、次の2点が決まります。 在庫ドメインに置くテーブル・カラム 在庫ドメインが担う具体的な処理(業務を丸ごとなのか、一部なのか) ステップ5で作成したAs-Isをもとに整理したTo-Beは、次のような形になります。主体ドメインのフローと依存先ドメインのまとまりへ分かれています。 在庫ドメインで管理するテーブルと、他ドメインに属するテーブルは、この時点ですでにいくつか切り分けられていました。一方で、他のドメインが主体と思われる処理のまとまりの多くは、在庫に置くと決めたテーブルを同一トランザクションで更新しています。 そこで、「同一トランザクションを維持したまま、これらの処理のまとまりごと在庫が主体として引き取ればよいのでは」という選択肢も検討しました。引き取ったテーブルは在庫ドメインの所有になるため、将来DBをドメインごとに分離した後も、これまでどおりDBトランザクションで整合性を守れます。分散トランザクションや結果整合性の仕組みを新たに作り込む必要がなく、実装コストを低く抑えられる、一見合理的な案です。 この案を検証するため、倉庫在庫数と同一トランザクションで更新されているテーブルを関連図として書き出しました。すると、引き取り候補となる発送のテーブル自身もまた、発送の別のテーブルと同一トランザクションで更新されていることが分かりました。トランザクションを保つことを理由にテーブルを1つ引き取れば、次は同じ理由でその隣のテーブルが境界の内側に入ってきます。この連鎖の行き着く先は、在庫が発送の業務テーブル一式を丸ごと飲み込んだ姿、つまり「在庫=発送」です。そして同じことが、注文などほかのドメインでも起こります。 では、この連鎖を同一トランザクションが途切れるところまでたどり続けると、どこに行き着くのでしょうか。基幹DBのすべてのテーブルを洗い出したわけではありませんが、関連図に現れた発送・注文・入荷・商品のテーブルは、いずれも同時更新の関係で密につながっていました。その終着点はほぼ基幹DB全体、つまり今回脱却しようとしているモノリスの構造そのものです。実装のしやすさを優先して境界を広げる判断は、1回だけを見れば小さな妥協ですが、同じ判断を繰り返した論理的な帰結はモノリスへの逆戻りでした。ドメインごとにテーブルの責務を分けるはずが、境界を安易に広げると元の状態に戻ってしまうのです。 もう1つ、変更のしやすさという観点でも、この引き取り案は避けたいと考えました。仮に同時更新の連鎖をどこかで断ち切り、業務テーブルの一部だけを引き取る形で境界を引けたとしても、今度は在庫ドメインの変更頻度が上がってしまうためです。 在庫自体の操作は、入庫・出庫・引き当てといった少数の決まったパターンに集約されています。実際、先ほどの関連図には多くの業務テーブルが登場しますが、それらが行う在庫の操作は、この少数のパターンのいずれかに行き着きます。在庫の責務をこの決まった操作にとどめておけば、操作は業務を問わず再利用できる部品になります。各ドメインで業務が増えても、既存の操作を呼び出す側の対応だけで済み、在庫側の変更は発生しない想定です。ところが、他ドメインの業務テーブルまで引き取ると話が変わります。引き取った業務の処理はその業務専用のものとなり、再利用が利きません。業務が増えたり変わったりするたびに、本来他ドメインの関心事であるはずの対応が在庫側で必要になり、取り込んだ業務の数だけ在庫自体の変更頻度が上がっていくのです。しかも、その業務の仕様を一番よく知っているのは、本来その業務を担当するドメインのチームです。業務が変わるたびに在庫チームを経由した開発が必要になる体制は、各チームが自ドメインに集中して並行開発するというリプレイスの狙いに逆行します。複数ドメインの業務都合が同居する在庫は、「みんなのもの」である今の在庫の構造を、ドメインの内側へ再現した姿にほかなりません。 とはいえ、境界を最小にとどめられるかどうかは、強整合を必要とする範囲がどこまで広がっているか次第です。強整合が必要なテーブルであれば、境界の内側へ引き取らざるを得ないためです。そこで、ステップ5で仕分けた「強整合が必要な集合」を確認すると、それらは在庫の核となるテーブルの周辺に小さくまとまっており、他ドメインへ芋づる式に広がることはありませんでした。同一トランザクションで更新されている他ドメインのテーブルの多くは、同時に正しくなければ業務が破綻するわけではありませんでした。同じトランザクションが使えるので、一緒に更新しておくほうが楽だという理由で同居していたのです。境界を最小にとどめても、強整合の要件が壊れる心配はないと確認できました。 そのため、処理のまとまりを丸ごと持つのではなく、在庫は核となるテーブル・カラムと、強整合が必要なものだけを引き受ける形にとどめました。テーブルだけでなくカラムと書いたのは、境界がテーブルの内側を通る箇所があるためです。たとえば倉庫在庫数を持つテーブルには、商品の販売価格や会計の評価額といった、他ドメインの関心事にあたるカラムも同居していました。こうしたテーブルは丸ごとどちらかのドメインへ寄せるのではなく、カラム単位で在庫に置くもの・他ドメインへ移すものを切り分けています。また、特に整合性が重要なのは、倉庫在庫数と引き当て済み数のように在庫数を構成するカラム同士の関係で、これは核の内側に閉じていました。 基幹システムリプレイスでは、マイクロサービス間の依存を一方向に保つため、サービスをレイヤ状に配置し、上位から下位のレイヤへの依存だけを許すルールを設けています。将来のマイクロサービス化をしやすくするため、ドメイン間の依存関係も現時点からこのルールに沿って整理しています。このレイヤ構成に当てはめると、在庫は、多くのユースケースから利用されつつ他ドメインの領域には踏み込まない、最下層のレイヤという位置づけに収まりました。 ただし最下層のレイヤといっても、他ドメインの指示どおりにテーブル操作を代行するだけのCRUDサービスにする意図はありません。在庫ドメインが持つのは、在庫数と、その変動の履歴・理由を表す核となるテーブル・カラム群です。それらに対する入庫・出庫、在庫の引き当てとそのキャンセルといった処理のまとまりを責務として持ち、先ほど触れたカラム同士の関係や、在庫数と入出庫履歴の整合性といった不変条件は在庫自身が守ります。この責務の範囲では、これまで複数ドメインの処理に散らばりブラックボックス化していた在庫の仕様を、在庫ドメインのモデルとして表現し直します。他ドメインが在庫テーブルを直接更新する密結合をやめ、在庫の提供するインタフェースを介した操作に集約することで、冒頭に挙げた属人化・ブラックボックス化の解消にもつなげます。そのうえで責務を最小限にとどめたのは、在庫の範囲を広げるほど他ドメインが在庫から切り離せなくなり、「在庫を切り離せない限り他ドメインも独立できない」という構造を解消できなくなるためです。 この判定をユースケースごとに積み重ねた結果を、ドメイン間の依存関係図として可視化しています。矢印は「元のドメインが先のドメインに依存する」ことを表します。ここでの依存は、同じデータを参照することによるデータの依存ではなく、元のドメインが処理の中で先のドメインへ同期リクエストを送り、更新を依頼するという処理の依存です。そのため、会計のように参照のみの依存はこの図に含めていません。 今回引いた境界は、強整合が必要な集合を丸ごと内側に含んでいます。その帰結として、ドメイン境界をまたいで強整合が必要な箇所は残らず、図の矢印が示す他ドメインとの間は結果整合性で成立します。そのためTo-Beでは、できるだけ結果整合性に倒す方針です。境界をまたぐ処理が失敗した場合も、完了済みの処理を補償処理で巻き戻すのではなく、失敗した処理をリトライして完了へ進めます。 一方で、DBがドメインごとに分離され同一トランザクションが使えなくなる将来に、結果整合性の前提となるこの「リトライで完了へ進める」をどのような仕組みで担保するのかは、この時点では決めていません。恒久的に完了できない処理をどう扱うかも同様です。まずはモジュラモノリス構成で既存のトランザクションを維持したまま、処理のまとまりを今回引いた境界で切れる形へ徐々に近づけていき、境界に沿ったまとまりができてから具体的な実現方式を検討する計画です。 意外な発見もありました。独立性が高くマイクロサービス化しやすいと考えていた会計も、As-Isを追うと、評価額登録など会計のための処理が業務システム側のロジック中に存在していることが分かったのです。 また、実際のモノの動きを伴わない、会計処理のためだけの入出庫履歴を作成している処理も見つかりました。会計処理が入出庫履歴を入力として組み立てられているため、便宜的に入出庫履歴を作成して会計処理へつなげる実装になっていたのです。 つまり、入荷・発送・在庫管理といった業務システム側が「会計処理を成立させるためにどのようなデータを作るべきか」という会計側の知識を抱え込んでいました。これらの入出庫履歴はデータ上で実際の入出庫と判別できるため、会計処理として問題があるわけではありません。しかし、モノが動いていない入出庫履歴を作り続けた結果、入出庫履歴は実質的に会計のためのデータとなり、実際のモノの動きを表す記録という本来の意味が薄れていました。この結合は業務システム側のリファクタリングだけでは剥がせないため、会計側の計上ルールの見直しとあわせて解消していく方針です。今回決定した在庫の責務は、この見直しによって会計の事情が在庫から取り除かれ、入出庫履歴が実際のモノの動きの記録に戻ることを前提にしています。 こうした構造は、ドメインを切り出そうとする今のフェーズでは剥がすべき結合です。その所在は、テーブル更新の粒度でAs-Isを追って初めて具体的に見えてきました。 7. 他ドメインのチームと合意形成する 最後に、As-IsとTo-Beの差分から、「To-Beに到達するためには、どの処理をどう変更・リファクタリングする必要があるのか」というTODOを洗い出します。このTODOを、過渡期戦略(リファクタリングや、To-Beへ到達するまでのステップ)とあわせて資料にまとめ、他ドメインのチームと確認しました。 合意の対象は、To-BeとTODOの両方です。両方を対象にしたのは、To-Beだけでは合意に現実味が出ないためです。 現在、在庫の更新処理はそのほとんどがストアドプロシージャの中に埋まっています。ここからドメイン境界に沿った形へ組み替えるには、ストアドプロシージャの分解が必要で、相応の時間がかかります。加えて、優先すべきはVBScriptの廃止です。まずは各チームが、既存の業務・処理の構造を保ったままVBScriptを新しい言語へ置き換える等価リプレイスを進めます。等価リプレイスでは、発送・入荷といった自明な業務単位のAPIをモジュラモノリス上に作成しますが、DBは既存の基幹DBを共有したままで、テーブルにドメインの境界は入りません。等価リプレイスの取り組みについては、 本番環境における等価比較を活用した言語リプレイス の記事で紹介しています。あわせて、既存システムにはなかったユニットテストも充実させます。ドメイン分割のためのストアドプロシージャ分解に着手できるのは、その後です。 つまりTo-Beは、何年先に到達するのか現時点では見通せません。到達時期の分からない理想形だけを見せて合意を求めても、各チームにとっては自分たちの計画へ落とせない絵に留まります。そこで、To-Beに合意したうえで、そこへ向かって次に何を進めるべきかをTODOとして洗い出し、優先度と着手時期まで含めて認識を揃えました。TODOを互いに確認できた時点をもって、合意としています。 もっとも、すんなり合意できたものばかりではありません。たとえば前述の「会計処理のためだけの入出庫履歴」をやめる方針は、会計ポリシーの見直しを伴うため、経理・監査を含む関係者や基幹システムの責任者への確認が必要で、採用の判断には時間を要しました。それでも、無駄な処理の多い現状と変更後の保守のしやすさは明白だったため、目指す方向性として合意でき、全チームから「自チームにとってもメリットがある」という回答を得られています。対応は、重要度の高い部分のみスケジュールを決めて優先的に実施し、残りは安全性を第一に徐々にリファクタリングしていく計画です。「この形の処理を今後は増やさない」という方針を共有できたこと自体にも、負債の再生産を防ぐ効果があると考えています。 今回の取り組みは、このステップ7まで完了しています。 まとめ 本記事では、モノリシックで密結合な基幹システムをドメイン分割するために、在庫ドメインが成立するのかどうかを検討し、その境界と責務を決定するまでの事例を紹介しました。 DB更新の粒度で検討したことにより、理想形(To-Be)へ至るリファクタリングや進め方を、複数チーム間で同じイメージを持って描けました。また、数人の記憶の中にしかなかった在庫の全体像が、コードと突き合わせた付箋・図・資料の形で蓄積され始めたことも、属人化の解消へ向けた一歩になっています。 冒頭の問いへ立ち返ると、「みんなのもの」だった在庫は、責務を最小限に絞れば1つのドメインとして成立する、という結論に至りました。ただし、これが必ず正解とは限りません。まだ見えていない部分もあるかもしれません。今後は、まずモジュラモノリスの1モジュールとして出発し、必要に応じて境界を見直していく予定です。 さいごに 筆者にとって、基幹システムの案件は今回が初めてでした。新鮮だったのは、ユーザーとの距離の近さです。基幹システムのユーザーは倉庫や社内にいて、ECサイトの開発よりも使う人の顔が見えます。距離が近いからこそ、システムの機能が足りない部分を運用の工夫で乗り越えている現場の実情も聞けました。その実情を踏まえて、As-Is・To-Beの検討では「こういう作りにすれば、そうした現場の課題も解消できるのでは」という議論をたくさんしました。正直なところ、「在庫ドメインをマイクロサービス化する」という目的だけで意味を見出し続けるのは難しいものです。それでも、このリプレイスの先には、現場の業務負担が軽くなり、開発のスピードと品質も間違いなく向上する未来が待っています。その未来で喜ぶ人の顔を想像することは、これから実際にリプレイスを進めていくうえで、何よりの原動力になるはずです。 試行錯誤の記録ではありますが、本記事が、モノリシックなシステムのドメイン分割に取り組む方々にとって、少しでも参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com 付録:使用したAgent Skillsの詳細 ここからは、ステップ3・4で使用したAgent Skillsの設定内容を紹介します。ツールの実装に興味がある方向けの補足なので、不要な方は読み飛ばしていただいて構いません。 ステップ3:シーケンス図生成Skill(trace-vbs-flow) Skillには、ルートとなるFunctionから呼び出しフローを静的解析してPlantUML形式のシーケンス図を生成するための解析ルールをMarkdownで記述しています。トランザクション境界を必ず明示すること、UPDATEはテーブル名と更新カラム一覧を必ず記載すること、解析できなかった箇所は推測で補完せず明示することなどをルール化しています。また、対象ドメインのテーブルに触れる箇所を色で強調し、長いシーケンス図の中からドメインの関与箇所を一目で拾えるようにしています。大きなファイルについては、1セッションで解析を完結させず、解析プランを提示したうえでFunction・ストアドプロシージャ単位に分割して進めることもルールとして定めています。以下は、Skill定義から中核のルールを抜粋したものです(社内固有の表現は一部抽象化し、テーブル名・カラム名は一般的な命名に置き換えています)。 --- name : trace-vbs-flow description : > ASP(VBScript)の処理フローを静的解析し、トランザクション境界を 明示したPlantUMLシーケンス図と使用テーブル一覧表を生成する。 --- # Domain Parameter スキル実行時にドメイン定義ファイル (ドメインのテーブル一覧とユビキタス言語の辞書)を読み込む。 # 操作の記載ルール(抜粋) * UPDATE はテーブル名と更新カラム一覧を必ず記載する。 カラムが特定できない場合は UNKNOWN _ COLUMNS と明示する # ドメインテーブルのハイライト(抜粋) * UPDATE / INSERT / DELETE の矢印はすべて赤(#tomato)で強調する * トランザクションの活性区間は、対象ドメインのテーブル更新を 含むか否かで色を分ける(含む: #00bfff / 含まない: #c0c0c0) * 対象ドメインテーブルへの操作は note で強調する (更新: #FFD24D / 参照: #FFEB99) # ハルシネーション防止ポリシー(Evidence-first) * 出力に確度を付与する: [CONFIRMED] / [INFERRED] / [UNRESOLVED] * 根拠(ファイルパス・行番号・抽出SQL)をコメントで出力する * 見つからないストアドプロシージャの処理内容、未発見Functionの中身、 特定不能なUPDATEカラムは推測で補完しない # ファイルサイズが大きい場合 * 1セッションで完結させようとしてはならない * 最初に解析プランを提示し、Function・ストアドプロシージャ単位に 分解して逐次実行し、各ステップ完了後に次の実行可否を確認する また、ドメインごとに「辞書」にあたるドメイン定義ファイルを用意し、Skillから読み込ませています。ドメインに属するテーブルの一覧と、テーブル・カラム名をユビキタス言語へ変換するマッピングを定義しています。あわせて、コード値を業務上の名称に解決するためのマスタデータも辞書として持たせています。 # Domain Tables 以下を在庫ドメイン対象とする: * stock _ shelves # Ubiquitous Language 補足説明では必ずユビキタス言語を使用する: | テーブル・カラム | 意味 | | --- | --- | | stock _ shelves | 倉庫棚 | | stock _ shelves.quantity | 倉庫在庫数 | | stock _ shelves.allocated _ quantity | 引き当て済み数 | ステップ4:付箋変換Skill(seq-to-miro) Skillの中心は、シーケンス図中のSQL更新文と条件分岐を付箋列へ機械的に変換するルールです。付箋の種別は「Command(命令形)」「Aggregate」「Event(過去形)」「Policy」「Table(物理テーブル名)」「HotSpot(注記)」の6つです。テーブル・カラム名の日本語化には辞書を使い、辞書にない単語が出てきた場合はユーザーに確認して辞書に追記し、語彙を蓄積していきます。以下は、Skill定義の主要なルールを抜粋したものです。 --- name : seq-to-miro description : > PlantUMLシーケンス図とMiroボードURLを受け取り、イベントストーミングの 記法を借りた付箋・矢印を座標計算済みでMiroボード上に直接生成する。 --- # SQL動詞・条件分岐の変換 | シーケンス図 | 付箋 | | --- | --- | | INSERT | [Command]作成する → [Event]作成した | | UPDATE(カラムあり) | [Command]日本語(カラム名)を更新する → [Event]…を更新した | | DELETE | [Command]削除する → [Event]削除した | | alt / opt の条件文 | [Policy] 条件文をそのまま転記(翻訳しない) | | note(注記) | [HotSpot] 注記の内容をそのまま転記 | # テーブル単位への分割 * 1つのSQL文が複数テーブルへの操作なら、テーブル単位で付箋列を分割する 例: INSERT returns, return _ details → 「返品を作成した」「返品明細を作成した」の2列に分ける * 複数の付箋列を1つの業務的なまとまりへ統合する判断はSkillでは行わず、 人間がMiroボード上で行う # カラム名の日本語化(辞書の仕組み) 以下の優先順で辞書ファイルを引く: 1. per-column 上書き辞書(テーブル.カラム → 日本語) 2. 概念表(カラム名を単語に分解し、単語ごとに日本語化して結合) 出力は必ず「日本語(カラム名)」形式にし、原文へのトレーサビリティ 確保のため英カラム名を括弧書きで残す。 # Miroレイアウトルール(抜粋) * 1つの更新処理は Command → Aggregate → Event を横一列に並べ、 テーブル名・注記の付箋はEventの下へ縦に積む * 座標は付箋同士は+300px、次の処理との間は+400pxの足し算で決定する # ユーザーへの確認(human-in-the-loop) * アクターとフロー起点のCommandはSQLから導出できないため、 タイトル・ファイル名からの推測をデフォルト選択肢として提示し、 ユーザーに確認する * 未知語の確認は都度ではなく、最初にまとめて洗い出して 1〜2回の質問で完結させる # ハルシネーション防止 * 推測で訳を作らない。辞書にない単語は必ずユーザーに確認して辞書に追記する * SQLに書かれていないカラムを Command / Event に書かない * シーケンス図にない条件・ループを追加しない
はじめに こんにちは。Developer Engagementブロックの @wiroha です。8月21日(金)に、ZOZOにて中高生女子を対象とした体験イベント「 ZOZOTOWN・WEARを支える技術と働き方を知ろう! 」を開催しました。 これは 公益財団法人山田進太郎D&I財団 が実施する「 Girls Meet STEM 」プログラムの一環です。中高生女子がSTEM(科学・技術・工学・数学)分野で働く人やSTEM分野で学ぶ学生、実際の現場に触れることで、将来の可能性を広げる機会を提供することを目的としています。ZOZOではこの活動の意義に共感し2024年より参画しており、今回は4度目の開催です。 今回は21名の参加者が集まり、オフィスツアー、サービス体験&技術紹介、女性エンジニアとの交流を通じて、ファッションと技術の面白さを体感しました。本記事では、当日の様子をご紹介します。 イベント概要 日時:2026年8月21日(金)13:00~15:30 会場:ZOZO西千葉本社 対象:中学1年生~高校3年生までの戸籍上または性自認が女性の方 gms.shinfdn.org オープニング まずは会社紹介や事業紹介により、ZOZOのことを知ってもらう時間を設けました。ZOZOTOWNやWEAR by ZOZO(以下、WEAR)のサービス、計測事業などについて解説することで、この後のサービス体験&技術紹介の内容をより深く理解してもらうことを目指しました。 サービス体験&技術紹介 2つのグループにわかれ、「サービス体験&技術紹介」と「オフィスツアー」を交代で実施しました。「サービス体験&技術紹介」では、まずWEARのファッションジャンル診断を体験していただき、診断結果のステッカーをプレゼントしました。 「ZOZOGLASS」の体験では、肌の色を高精度に計測し、ZOZOCOSMEで販売しているアイテムの中から計測した肌の色に一番近いベースメイクを見つけてもらいました。肌の色を測る仕組みには理科の知識が生きていること、開発をニュージーランドのチームと英語でやり取りしながら進めていることも紹介しました。 今回は新しく、研究段階にある「触り心地が変わる布」も体験してもらいました。 「触り心地が変わる布」は、電気を流すことで同じ布の触り心地が「つるつる」から「ざらざら」へと変化するプロトタイプです。参加者の皆さんは、電気のオン・オフで変わる感覚に驚いた様子でした。この技術によって、インターネットで服を買うときに分からない「手ざわり」を画面の向こうに伝えられる日が将来来るかもしれません。 3つのサービス体験と技術紹介をとおして、技術によってファッションがより楽しく便利になることを感じてもらいました。 オフィスツアー こだわりの社屋である、西千葉本社のオフィスツアーを実施しました。メッセージが込められたアートや遊び心のある会議室、絨毯の模様や色使いの工夫など、ZOZOらしいデザインが施されたオフィス内を案内しました。クイズを交えながらの紹介で、参加者の皆さんも考えながら楽しんでいました。 今回は、会議棟「ZOZOTENT(ゾゾテント)」もこれまで以上に多くのエリアを回り、最新のオフィス環境をじっくり体験してもらいました。普段はなかなか見られない社内の様子に、参加者の皆さんも興味津々の様子でした。 パネルトーク 次にパネルトークを開催し、新卒1〜2年目の若手女性エンジニアから話を聞きました。学生時代の経験やエンジニアになろうと思ったきっかけ、中学・高校時代の進路選択などについて語ってもらいました。 昔から好きなことを貫いてエンジニアになった人もいれば、転学科を経てたどり着いた人もいるなど、進路の選び方は一人ひとり異なっているということが伝わる内容でした。決まった正解があるわけではなく、自分に合った道を見つけることが大切だと感じてもらえたのではないでしょうか。 質問会 その後は少人数のグループに分かれて参加者からの質問に答える時間を設けました。Web上で質問が投稿できるツールを活用して質問を集めたところ、たくさんの質問が寄せられ、エンジニア社員が自身の経験を交えながら丁寧に答えました。 お土産 参加者の皆さんに、ZOZOオリジナルグッズなどをお土産としてお渡ししました。イベントの思い出として楽しんでもらえたら嬉しいです。今回の体験時間に入りきらなかったZOZOMATもお渡ししており、自宅で足の3Dサイズ計測を体験してもらえればと思います。 最後に 参加者の皆さんからは、次のような感想をいただきました。 どんな仕事があるか上手く想像出来なくて、こんな仕事もあるんだなと知ることが出来ました 布の体験が楽しかったです!! 自分は服が好きなので服関係の仕事の様子が分かって良かったです。 ひとりひとりの社員さんが丁寧に質問に答えてくださってより自分の未来が想像しやすくなるようなイベントでした 勉強の意義が見出だせて良かった ZOZOはこれまでもさまざまな女性活躍推進のための活動に取り組んできており、今後もこうした機会を提供していきたいと考えています。本イベントにより中高生女子の皆さんがファッションと技術の面白さを感じ、将来の可能性を広げるきっかけになれば幸いです。
はじめに こんにちは、検索基盤部 検索グロースブロックの 朝原 です。普段はZOZOTOWNの検索体験の改善を担当しています。2026年6月29日から7月2日までサンフランシスコで開催されたAI Engineer World's Fair 2026に現地参加しました。本記事では、現地の様子に加えて、特に気になったセッションをSoftware FactoriesとAgentic Commerceの2つのテーマに分けて紹介します。 目次 はじめに 目次 AI Engineer World's Fair 2026とは 現地の様子 ノベルティ Expoエリア サイドイベント セッションレポート Software Factories Better Loops & Orchestrating Agents 所感 Self-Improving Software Factories 所感 Don't Ship Skills Without Evals 所感 Agentic Commerce Designing Multimodal Collaborative Agents for Next-Gen Commerce 所感 When AI Agents Pay and Sellers Monetize: Building x402 Apps for Agentic Commerce on AWS 所感 まとめ AI Engineer World's Fair 2026とは AI Engineer World's Fairは、サンフランシスコで開催されるAIエンジニア向けカンファレンスです。公式サイトによると今年は4日間の開催で、6000人以上の参加者と300人のスピーカーが集まりました。 www.ai.engineer 開催テーマにはSoftware Factories、Autoresearch、Harness Engineeringが掲げられていました。私が聴講した発表では、エージェントを単発の便利ツールとして使うのではなく、継続的に動かす仕組みを扱うものが目立ちました。 現地の様子 サンフランシスコの朝はたいてい曇っていましたが、時間が経つにつれて晴れてくる日が多く、過ごしやすい気候でした。 会場のMoscone Westの入口には、「Engineering the future of AI」というスローガンが大きく掲げられていました。 ノベルティ 受付では、ネームプレートやトートバッグなどのノベルティが配布されました。ネームプレートには名前と所属、職種が記載されていたため、会場内でも似たバックグラウンドの方に話しかけやすかったです。 トートバッグには、サンフランシスコの街並みと「AE」の文字を組み合わせたピクセルアートがあしらわれていました。 ユニークだったのが「AGI PILLS」と書かれたサプリメント風のノベルティです。飲めば知能がアップグレードされるという設定のジョークグッズで、AIカンファレンスらしい遊び心を感じました。 Expoエリア 会場のExpoエリアでは、各社がブースを構えて自社のサービスやプロダクトを紹介していました。実際にデモを試しながら説明を聞けるため、各社がエージェント開発のどこに課題意識を持っているのかを知ることができました。MicrosoftやOpenAIのブースには常に人だかりができていました。 ユニークな展示も多く、Bright Data社のブースでは来場者がロボットを操縦して対戦する企画が人気を集めていました。 サイドイベント カンファレンスの前後や夜には、協賛企業や団体が主催するサイドイベントも数多く開催されていました。私もいくつかのイベントに参加し、世界中から集まったAIエンジニアたちと直接話すことができました。 美術館を会場にしたサイドイベントでは、アート作品に囲まれながらの交流という貴重な体験ができました。 Cloudflareがカフェを貸し切って開催したカジュアルなイベントもあり、コーヒー片手にゆったりと話せる雰囲気でした。 セッションレポート 会場では、キーノート、テーマ別の講演、ワークショップが複数のトラックで並行開催されていました。 ここからは、5つのセッションをテーマごとに紹介します。 Software Factories Software Factoriesは今年の開催テーマのひとつで、専用のトラックも用意されていました。各発表において、Software Factoryは、「AIが開発工程を継続的に実行し、人間は要所で判断しながらAIを動かす仕組みを設計・改善する開発モデル」として説明されていました。 この工場が担うのはコードを書く工程だけではなく、ユーザーフィードバックやログといったシグナルの収集から、優先順位付け、実装、検証まで、ソフトウェア開発のライフサイクル全体を自律的に回すことです。 ここでは、人間とエージェントの役割分担を見直すオーケストレーション、開発フロー全体の自動化の実践、エージェントに渡すスキルの品質管理という順で3つのセッションを紹介します。 Better Loops & Orchestrating Agents OpenClawの開発者で、2026年にOpenAIへ入社したPeter Steinbergerさんの発表です。 Steinbergerさんは、現在のコーディングエージェントの運用では、人間がタスクの采配と確認を担うことが多いと指摘していました。人間がタスクを切り出してエージェントに依頼し、出力を待ち、レビューして修正を頼みます。このようにタスクを采配する役割はOrchestratorと呼ばれ、人間がOrchestratorである限り、エージェントの数をいくら増やしても人間の確認速度が全体の上限になります。これまではモデルの性能の問題もあり、エージェントが意図しない行動をしたら人間が止めて指示し直す、という使い方をしてきました。しかしモデルが優秀になってきた今、「エージェントがコードを生成する画面をただ眺めているのはもったいない」とSteinbergerさんは指摘していました。 人間がOrchestratorを担う、これまでの構成 / Better Loops & Orchestrating Agents — Peter Steinberger, OpenAI 49:06より引用 そこで提案されたのが、Orchestratorを人間からエージェントに引き渡す構成です。Managerエージェントが「プロジェクトの目標」「メモ」「将来のビジョン」をもとにタスクを切り出し、Workerエージェントを作ります。Workerが調査・実装・テストを進め、Reviewerエージェントが結果を確認します。タスクの切り出しから実装・レビューまでの日常のループは、人間が介在しなくても回り続けます。 Managerエージェントがタスクを采配する、提案された構成 / Better Loops & Orchestrating Agents — Peter Steinberger, OpenAI 49:13より引用 一方で人間が不要になるわけではなく、ループの外側で意思決定を受け持ちます。ツールの利用許可のように人間の判断が必要な場面では、Managerが確認を求めるようにします。 もうひとつの提言が、エージェントをターミナルに閉じ込めないことです。Slackなど、ターミナル以外の場所から指示や確認ができれば、人間は席にいなくてもループの外から関与できます。こうした、エージェントを動かし続けるための実行環境や仕組みはハーネス(Harness)と呼ばれ、冒頭で触れた開催テーマのひとつでもあります。ハーネスを作ることが、これからのエンジニアの仕事になると述べていました。 締めくくりの「The future isn't twenty terminals. It's better loops.」という言葉が、強く心に残っています。 所感 この発表を聞くまでの私は、まさにSteinbergerさんがアンチパターンと呼ぶ「人間がOrchestratorである」状態で、タスクの切り出しも個々のエージェントの起動も自分で行っていました。発表を聞いてからは、タスクを切り出す工程からエージェントに任せる構成を実践しています。具体的には、社内の情報源をエージェントに参照させ、そこからタスク候補を抽出してIssue化し、優先度を付けて改善を進めるループです。並列実行するエージェントも自分で個々に起動するのではなく、Managerエージェントに任せているので、人間は指示と承認というループの外側の関与に集中できています。 ループが回っている間、人間は要件定義など別の仕事を進められます。実際にこの構成へ変えてから、私の1週間あたりのPR作成・マージ数はそれまでの約4倍になりました。ただしこれは担当タスクの性質や時期の違いを取り除いた厳密な計測ではないため、あくまで手応えを表す目安として捉えています。 一方で、人間がループの外側に出るほど、今度は承認の質そのものが問われるとも感じています。現状はManagerエージェントが判断を求めてきた場面でしか関与していないため、エージェントが問題に気づかないまま進んだケースを検知する手立ては持てていません。ループを回す速さと同じくらい、どこで人間が見るべきかの設計が重要になりそうです。 Self-Improving Software Factories Software Factoryは多くのスピーカーが語ったテーマですが、ここでは評価指標まで示したWarp創業者Zach Lloydさんの発表を紹介します。 主張を一言でいうと「Software EngineeringはFactory Engineeringになる」です。 エンジニアの仕事は、コードを書くことから、工場のオートメーションのように環境を整えて工場全体を制御することへ変わる、という意味です。 背景には、2026年4月に 自社のターミナル製品Warpをオープンソース化 したWarp自身の経験があります。発表では、これを契機に開発速度とコストが改善したと説明されていました。一方で、IssueやPRが乱立する課題も生まれました。個々の実装をエージェントに任せるだけでは、手前のタスク管理と後工程の検証が人手に残り、増え続けるIssueやPRを処理しきれないためです。 そこでWarpは、入力から運用までを次のような一本のAgenticフローとしてつなぎました。 SlackやWiki、Issueなどの情報源から問題を収集する 問題の大きさや複雑さといった観点からタスクへ落とし込む タスクの解決策や方針を立てる コーディングエージェントが実装する エージェントがレビューする(人間のレビューは任意) その変更がユーザーにどう影響するかを、エージェントがComputer Useで検証する CI/CDでリリースする リリース後のモニタリングでエラーを検知すると、エージェントが原因を突き止めて1の情報源に追加する 8で見つかった問題が再び1の入力になるため、不具合の修正までがループの中に入っています。 Software Factoryのループ図 / Self-Improving Software Factories — Zach Lloyd, Warp 4:55:21〜より引用 また、本発表ではFactory Engineeringの品質を評価する概念的な指標として以下が示されました。 Factory efficiency = Software shipped / (token + human cost) 出荷できたソフトウェアの量をトークンコストと人件費の合計で割った値であり、人の作業とトークン消費の双方をコストとして評価する考え方を示しています。 さらに、Issueを処理する日々のループ(Inner loop)とは別に、ループ自体を改善するループ(Outer loop)を回すべきだと述べていました。これによってループを人間が直接改善せず、ループ自体の改善も自動で回すことができます。 Inner loopとOuter loopの図 / Self-Improving Software Factories — Zach Lloyd, Warp 4:55:21〜より引用 所感 前のセッションで紹介したManager構成は、この発表でいうInner loopにあたります。現状の私は、マージ済みPR数というアウトプット量だけを見ており、出荷量や品質、人の工数、トークン費用まで含めた評価はできていません。Factory efficiencyの発想を使えばこうしたコストまで含めてループの良し悪しを評価できるため、まずは自分のループをこの指標で測り、Outer loopも回していきたいです。もっとも、分子である出荷できたソフトウェアの量をPR数で測るのか機能単位で測るのかは自明ではないため、実際に運用するならこの定義を決めるところから始める必要がありそうです。 Don't Ship Skills Without Evals Google DeepMindのPhilipp Schmidさんによる、エージェントに渡すスキルの作り方と品質管理を扱った発表です。 スキルとは、スクリプトやテンプレート、参考資料をSKILL.mdファイルを中心にひとつのフォルダにまとめたもので、特定の業務手順やベストプラクティスをエージェントに教える役割を持ちます。スキル自体は、エージェントに「今の流れを再現するスキルを作って」と頼むだけでも作れますが、作ったスキルがそのまま良いものとは限りません。この発表では、良いスキルを作るためのベストプラクティスが紹介されていました。 What Is a Skill? / Don't Ship Skills Without Evals — Philipp Schmid, Google DeepMind 2:28より引用 紹介された指針は、スキルがコンテキストを圧迫しないようにする、という観点で一貫していました。 まず、スキルの説明文(メタ情報)は最小限にします。多くの実装では、説明文がエージェントの全ターンでコンテキストに入るため、長い説明文はコストがかさむうえ、LLMの性能低下にもつながると説明されていました。 あわせて、説明には「こういう時には使用しない」など明確なユースケースを記載します。たとえばReactのデザインスキルに「Reactで使う」とだけ書くと、デザインと無関係なリファクタリング中にもスキル全体が読み込まれてしまいます。 本文の書き方では、手順を細かく指定せず、目標と制約を書くことが推奨されていました。手順を固定すると、エージェントがエラーから回復したり、より良い方法を選んだりする余地を奪うためです。ステップ1、ステップ2とフロー化したい処理なら、スキルではなくスクリプトにして実行させるべきと指摘されていました。 また、「高品質なコードを書いて」のように挙動へ影響しない記述はNo-Opsと呼ばれ、今のLLMにはこうした飾りの指示は不要でトークンを消費するだけのため、削除すべきだと説明されていました。 そして、モデルは賢くなり続け、スキルがなくてもできることは増えていくため、スキルは作って終わりではなく定期的に見直します。スキルあり・なしで評価を回すablationテストを行い、差がなくなったスキルは引退させることが推奨されていました。 所感 スキルがコンテキストを圧迫する問題は以前から耳にしていましたが、実際にどう減らすかという取り組みまではできていなかったところに、そのまま実践に移せる具体的な指針を得られました。 一方で、これらのベストプラクティスを人間が覚えてスキルを手で書き続けるのは難しいとも感じるため、エージェントにベストプラクティス自体を渡し、スキルの作成や整理を任せる仕組みを整備していきたいです。 Agentic Commerce Agentic Commerceは、ECサイトの開発に携わる立場から個人的に注目していた領域です。エージェントと一緒に買い物する体験の設計と、エージェントが支払いをするための決済インフラの順で、2つのセッションを紹介します。 Designing Multimodal Collaborative Agents for Next-Gen Commerce Google DeepMindのNidhi Vyasさんによる、ユーザーがエージェントとともに欲しいものを見つけていく体験をどう設計するかという発表です。 発表の前提にあるのは、すべてのユーザーが自分の欲しいものを明確に言語化できるわけではなく、曖昧なゴールしか持たないユーザーが多いという点です。そうしたユーザーには、ビジュアル情報を確認しながらインタラクティブに買い物を進められる体験が合う、というのがVyasさんの主張です。発表では、エージェントと一緒にショッピングを進めるこの体験をCo-Shopping Loopとして定義していました。 The Co-Shopping Loop / Designing Multimodal Collaborative Agents for Next-Gen Commerce(Nidhi Vyas, Google DeepMind)のスライドより引用 ループは以下の3ステップで構成されます。 Discovery:商品を提案するステップ 長期記憶として保持した対話履歴、時間や位置情報、予算やスタイルといった制約をもとに最適な候補を選ぶ 制約は、予算の上限のようなハードな制約と、スタイルの好みのようなソフトな制約に分けて扱う Multimodal Elicitation:ユーザーの意図をくみ取るステップ 発話だけでなく、クリックやマウスのホバー、スクロールといった操作を取得してエージェントにフィードバックする あるスタイルの提案に15秒ホバーした、といった行動を、言葉ではない潜在的な好みとして利用する Adaptive Response:応答の仕方を最適化するステップ ここまでに集めたパーソナライズ情報からユーザーの求める答えの形を予測し、探索段階に応じて構造化テキスト・ビジュアルギャラリー・比較表を使い分ける じっくり比較したいのか、多様な商品をまず眺めたいのか、購入を後押しする説明がほしいのか、ユーザーの潜在的な意図はさまざまです。比較ボタンや一覧表示の切り替え動線を用意しても、すべてのユーザーが能動的に探索するとは限りません。そのため、内容だけでなく見せ方まで、その時点のユーザーの状態に合わせて選ぶべきだとVyasさんは述べていました。 Adaptive Response / Designing Multimodal Collaborative Agents for Next-Gen Commerce(Nidhi Vyas, Google DeepMind)のスライドより引用 所感 エージェントと一緒に買いたいものを明確化していく体験は、検索体験を作る立場からも重要だと感じました。特に「比較したい人には比較表、多様な商品を眺めたい人には商品リスト」のようにユーザーの状態に応じて見せ方を出し分ける発想は、体験の質を大きく左右すると思います。 私自身、2つの商品で迷っているときもあれば、多くの商品からまずあたりをつけたいときもあります。ただ、その状態を言葉にしてエージェントへ伝えるのはユーザーにとって負荷の高い作業であるため、その部分をエージェント側が行動から推論して補うアプローチには大きな可能性を感じました。 When AI Agents Pay and Sellers Monetize: Building x402 Apps for Agentic Commerce on AWS AWSのAnil Nadimintiさんによる、エージェントの支払いを支える決済インフラをどう作るかという発表です。 前提となるのが、AIによる自動アクセスの急増です。発表では、botが全Webトラフィックの51%を占め、初めて人間を上回ったという調査結果が紹介されました。また、一部の企業ではWebトラフィックの95%がAIスクレイピングbot由来だと引用されていました。 AIエージェントによるトラフィックの現状 / When AI Agents Pay and Sellers Monetize: Building x402 Apps for Agentic Commerce on AWS(Anil Nadimineti, AWS)のスライドより引用 発表では、エージェントからのアクセスに対するサイト側の対応が、ブロックか受け入れかの二択として整理されていました。 ブロックすれば基盤への負荷は抑えられるが、AI経由でユーザーに見つけてもらう機会を失う 受け入れれば露出の機会は増えるものの、基盤を強化する必要がありコストも高くつく 実際にはレート制限や認証といった中間的な対策もありますが、それだけではコンテンツ利用に応じた対価を得られません。個別のライセンス契約で対価を得る方法もあるものの、少額・高頻度のアクセスには適用しにくい課題があります。 botをブロックする場合と受け入れる場合のトレードオフ / When AI Agents Pay and Sellers Monetize: Building x402 Apps for Agentic Commerce on AWS(Anil Nadimineti, AWS)のスライドより引用 発表では、支払い要求に応じるエージェントに対してアクセス単位で課金する、という第三の選択肢が提示されました。ブロックか受け入れかの二択に、対価を得ながら受け入れる道を加える発想です。ただし、従来の課金モデルはエージェントに合わず、たとえば「月100万円で1万リクエストまで」のようなプランは、少ししか使わない利用者にはハードルが高すぎます。かといって1リクエストごとにクレジットカードで請求すると、代金よりカード手数料の方が高くなることもあります。エージェントのアクセスは少額・高頻度のため、それに合った決済の仕組みが必要です。 そこで登場するのが、Coinbase社が開発したエージェントのための決済プロトコルx402です。2026年4月にLinux Foundationへの移管の意向が発表され、同年7月14日には移管の完了とx402 Foundationの運営開始が発表されました。名前の由来であるHTTPステータスコード402(Payment Required)は、支払いが必要なことを表すコードとしてもともと仕様に存在していたものの、ほとんど使われてきませんでした。x402はこの402を、エージェントへの支払いリクエストとして利用します。 x402による支払いの流れは次のとおりです。 エージェントが有料URLへアクセスする サイト側が金額・支払い先・利用できる決済方法といった支払い要件を含む402レスポンスを返す エージェントがウォレットで支払いに署名し、その支払い証明をリクエストヘッダーに付けて再度リクエストする サイト側が支払い証明を検証・決済し、確認できたら有料情報を返す クレジットカードのフォーム入力を挟まず、HTTPのやりとりだけで支払いが完結します。 この流れはAWSにも広がっており、Amazon BedrockのAgentCore Paymentsがx402をサポートしています(2026年8月時点ではプレビュー提供です)。 x402 Key Milestones / When AI Agents Pay and Sellers Monetize: Building x402 Apps for Agentic Commerce on AWS(Anil Nadimineti, AWS)のスライドより引用 買う側は、Coinbase CDPやStripeのウォレットをAgentCoreに接続し、エージェントはセッションごとに設定した予算の範囲でx402を使って有料情報を取得します。売る側については、AIエージェントのアクセスを検知してカテゴライズし、エンドポイントごとに利用料を設定して売上をダッシュボードで確認できる構成が発表では紹介されていました。 また、別セッション「Why Your AI Agent Needs a Wallet」では、Circle社のHarshal Bhangaleさんが登壇していました。この発表では、x402の直近の取引量と、Agentic Commerceの将来予測に触れられていました。スライドによると、2026年6月30日時点の直近30日間でx402の取引量は2400万ドルを超えています。あわせて、2030年にはAgentic Commerce市場全体が5兆ドル規模になるという予測も紹介されていました。 x402の市場規模 / Why Your AI Agent Needs a Wallet(Harshal Bhangale, Circle)のスライドより引用 所感 AIエージェントにアクセスされることでユーザーへの露出が増える一方、その負荷はサイトの維持費に跳ね返ります。だからこそ、アクセスに対して対価を得るという発想は合理的だと感じました。 エージェント側にとっても、これまでアクセスできなかったデータを利用できるようになるメリットがあります。売る側と買う側の両方が変わることで、AIエージェントを軸にした市場自体が大きく動いていくと感じました。 まとめ 本記事ではAI Engineer World's Fair 2026の現地の様子と、気になったセッションを紹介しました。Software Factoriesの3セッションでは、人間が個々のタスクを采配する構成から、エージェントが実行ループを管理する構成への移行が提案されていました。私自身のManager構成による開発フローでは、品質やコストまで含めた評価方法が今後の課題です。Agentic Commerceの2セッションからは、ユーザーの状態に合わせて見せ方まで出し分ける体験設計と、エージェントのアクセスを収益化する決済インフラの動きを確認できました。ZOZOTOWNの検索体験にどう取り入れられるか、引き続き検証していきます。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは。商品基盤部の藤本です。 私たちのチームでは、生成AIコーディングエージェント(以下、AIエージェント。主にClaude Code)を使って開発に取り組んでいます。以前からAIエージェントに一貫した実装をしてもらうため、ルールを書いて指示する運用を続けてきました。しかし、ルール同士の矛盾やレビューだけでは遵守を保証できないといった課題がありました。本記事ではこの課題と、ArchUnitによる機械的な検証へ落とし込むまでの取り組みを紹介します。 目次 はじめに 目次 背景・課題 ルールの読者はAIであるという前提の転換 自然文のルールからArchUnitの実行可能な検証への変換 ルール文書・テスト・実コードの三者整合性を担保する方法 アドホックな知識からルールへの体系的な移行 複数リポジトリへのルール展開の検討と見送り 検証の必須ゲート化 まとめ 背景・課題 AIエージェントに一貫した実装をしてもらうには、コーディング規約やアーキテクチャの制約をルールとして書き、AIエージェントに読ませる必要があります。私たちのチームでも命名規約やレイヤー間の依存関係に関する制約を、Markdownのルール文書として整備してきました。 ただ、ルールの数が増えるにつれて2つの問題が現れました。 1つ目は、ルール同士の矛盾や重複です。新しいルールを追加するとき、既存のルールと似た内容が別の場所にも書かれていたり、条件が食い違っていたりする場面がありました。ルールの数が少ないうちは目視で気づけますが、数が増えるほど見落としが発生しやすくなります。 2つ目は、ルールの遵守を保証する手段が、レビューに依存してしまう点です。AIエージェントの生成したコードのルール準拠を、毎回目視で確認する運用ではレビュアーの負担は増え続けます。レビューで見逃した違反は、そのまま実装に残ってしまいます。 さらに、ルールの書き方自体にも見直すべき点がありました。人間向けの文書と同じ作法をAIエージェントにそのまま適用しても、意図した通りに機能しない場面がありました。 ルールの読者はAIであるという前提の転換 私たちは当初、人間向けのドキュメントを書くときと同じ作法でルール同士を相互参照させたり、背景の説明を書き添えたりしていました。人間がドキュメントを読むときは関連する複数のルールを渡り歩きながら理解を組み立てるため、相互参照がその手がかりです。 しかし、AIエージェントがルールを読む場面は、実装やレビューのたびに発生します。そのたびに複数のファイルを渡り歩いて文脈を組み立てる必要があると、参照が一段増えるごとに読み落としや誤読の起点が増えてしまいます。 そこで私たちは、「ルールの読者はAIである」という前提に立ち返りました。人間向けの相互参照は、複数の文書をまとめて理解する読み手を助けるための工夫です。AIエージェントに同じ役割を期待する必要はなく、それぞれのルールが単体で完結し、判断に必要な情報がその中でそろうように書き直すことにしました。単体で完結する形にしたことで同じ説明が複数のルール文書に重複することを懸念していました。実際に運用してみると重複は目立って増えておらず、人間のレビュアーがルール文書を読む頻度や体験にも変化はありません。 この転換は書き方を変えるだけでは不十分で、ルールが本当に守られているかを機械的に検証できる形に変換する必要がある、という次の課題につながりました。 自然文のルールからArchUnitの実行可能な検証への変換 前提を転換しただけでは、ルールが実際に守られているかどうかまでは保証できません。次に取り組んだのは、自然文で書いていたルールをArchUnitで実行可能な検証に変換することです。 ArchUnitは、Javaのアーキテクチャ制約をテストコードとして記述し、ビルド時に検証できるライブラリです。パッケージの依存関係やクラスの命名規則、メソッドの呼び出し制約などをDSLとして表現できます。 たとえば、「ドメイン層のコードではリフレクションを使用しない」という規約は、これまでルール文書に自然文で書き、実装時にレビュアーが目視で確認していました。この規約をArchUnitで検証しようとすると、まず思いつくのは、パッケージ単位で禁止する書き方です。 なお、本記事のコード例は、実際にはSpockで実装しているルールをJUnit 5形式に書き直したものです。 import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; @AnalyzeClasses (packages = "com.example.domain" ) class ArchitectureRuleTest { @ArchTest static final ArchRule domain_should_not_use_reflection = noClasses() .should() .dependOnClassesThat() .resideInAPackage( "java.lang.reflect.." ) .because( "リフレクションはドメイン層の意図を分かりにくくするため禁止する" ); } しかし、この書き方には問題がありました。 dependOnClassesThat().resideInAPackage(...) は、対象パッケージのクラスへの依存を広く検出します。メソッド呼び出しだけでなくフィールドや引数の型宣言も依存に含まれるため、リフレクションを直接呼び出していないコードまで違反として検出してしまいます。 私たちのプロジェクトでは、O/Rマッパーが生成するコードの一部が java.lang.reflect.Method 型のフィールドを持っていました。生成コードが呼んでいるのは、内部でリフレクションを使うライブラリのヘルパーメソッドです。生成コード自身がリフレクションAPIを直接呼び出しているわけではありません。しかし、パッケージ指定による型参照チェックでは、この生成コードも違反として検出されてしまいます。 そこで、検出対象を「型参照」ではなく「メソッド呼び出し」に絞った ArchCondition として実装しました。 import java.util.Set; import com.tngtech.archunit.core.domain.JavaCodeUnit; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchCondition; import com.tngtech.archunit.lang.ArchRule; import com.tngtech.archunit.lang.ConditionEvents; import com.tngtech.archunit.lang.SimpleConditionEvent; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.codeUnits; @AnalyzeClasses (packages = "com.example.domain" ) class ReflectionRuleTest { private static final Set<String> CLASS_LOOKUP_METHODS = Set.of( "forName" , "newInstance" , "getMethod" , "getDeclaredMethod" , "getMethods" , "getDeclaredMethods" , "getField" , "getDeclaredField" , "getFields" , "getDeclaredFields" , "getConstructor" , "getDeclaredConstructor" , "getConstructors" , "getDeclaredConstructors" ); @ArchTest static final ArchRule domain_should_not_call_reflection = codeUnits().should( new ArchCondition<JavaCodeUnit>( "not call reflection / dynamic-dispatch APIs" ) { @Override public void check(JavaCodeUnit codeUnit, ConditionEvents events) { codeUnit.getMethodCallsFromSelf().forEach(call -> { var target = call.getTarget(); var ownerName = target.getOwner().getFullName(); var ownerPackage = target.getOwner().getPackageName(); var name = target.getName(); boolean violation = (ownerName.equals( "java.lang.Class" ) && CLASS_LOOKUP_METHODS.contains(name)) || ownerPackage.equals( "java.lang.reflect" ) || ownerPackage.equals( "java.lang.invoke" ); if (violation) { events.add(SimpleConditionEvent.violated( codeUnit, codeUnit.getFullName() + " calls " + ownerName + "#" + name)); } }); } }); } このテストでは、コードが実際に呼び出したメソッドの呼び出し先(オーナーの型とメソッド名)を1件ずつ確認します。 java.lang.reflect パッケージや java.lang.invoke パッケージへの呼び出しは検出対象です。加えて、 java.lang.Class が持つ forName や getDeclaredMethod のような動的なメソッド探索・生成系のメソッド呼び出しも検出対象にしています。 java.lang.Class 自体は java.lang パッケージに属しており、 java.lang.reflect パッケージの外にあります。そのためパッケージ名だけでは判定できず、対象にしたいメソッド名を CLASS_LOOKUP_METHODS として自分たちで列挙しています。これはArchUnitが提供するAPIではなく、プロジェクト側で定義した定数です。型を参照しているだけのコードは対象にならないため、生成コードを誤検出することもありません。 検出範囲もメソッド本体だけでなく、コンストラクタやフィールドの初期化子まで含めた全コードユニット( codeUnits() )にしています。これによって、レビュアーが目視で確認していた範囲を、CIで機械的に検証できるようになりました。 命名規則やレイヤー間の依存関係も同様に、目視確認からArchUnitでの検証へ順次置き換えていきました。自然文のルールをすべて機械的に検証できるわけではありません。ただし、パッケージ構造やクラス間の関係など、構造的に表現できる制約は、この方法でカバーできます。 実際に機械的な検証へ変換したルールには、次のようなものがあります。 日時を扱うクラスの now() メソッドを、 Clock 引数を指定せずに直接呼び出すことを禁止するルール(テストで時刻を固定できるようにするための制約) 特定のインタフェースを実装したrecordのcompact constructorに Objects.requireNonNull 呼び出しを必須にするルール(nullチェックの実装漏れを防ぐ制約) UseCase層のクラスが持つ特定のメソッドに、 @Transactional アノテーションを必須にするルール(トランザクション境界の付け忘れを防ぐための制約) これらはいずれも、以前は自然文のルール文書とレビューでの目視確認に頼っていたものです。 ルール文書・テスト・実コードの三者整合性を担保する方法 ルールをArchUnitのテストに変換しても、それだけでは安心できません。ルール文書とテストコード、そして実コードの3つは、別々のファイルに書かれているため、時間が経つとずれていく可能性があります。 実際に私たちのルール文書にある例を紹介します。あるUseCaseクラスの設計規約では、公開メソッドに @Transactional を必須にし、 isolation 属性はデフォルトのまま変更しないことを原則としています。ただし、バッチ処理で定期的に更新されるデータを参照する1つのUseCaseだけ、例外として REPEATABLE_READ を指定してよいことになっています。 // OK: 分離レベルを上げる場合は理由をコメントで明示する(唯一の例外) @Transactional (isolation = Isolation.REPEATABLE_READ) // バッチで定期的に更新されるデータを参照するため、整合性を保つ public ResultDTO process(SomeCommand command) { /* ... */ } この例外は、ルール文書に書くだけでは終わりません。ArchUnitのテストコード側にも、この特定のクラスを検査対象から除外する条件を書く必要があります。 import com.tngtech.archunit.core.domain.JavaMethod; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchCondition; import com.tngtech.archunit.lang.ArchRule; import com.tngtech.archunit.lang.ConditionEvents; import com.tngtech.archunit.lang.SimpleConditionEvent; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; @AnalyzeClasses (packages = "com.example.usecase" ) class UseCaseTransactionalRuleTest { @ArchTest static final ArchRule isolation_should_be_default = methods() .that().areDeclaredInClassesThat().resideInAPackage( "com.example.usecase.." ) .and().areDeclaredInClassesThat().areAnnotatedWith(Service. class ) .and().areDeclaredInClassesThat().doNotHaveFullyQualifiedName(SampleUseCase. class .getName()) .and().areAnnotatedWith(Transactional. class ) .should( new ArchCondition<JavaMethod>( "have the default isolation level" ) { @Override public void check(JavaMethod method, ConditionEvents events) { var isolation = method.getAnnotationOfType(Transactional. class ).isolation(); if (isolation != Isolation.DEFAULT) { events.add(SimpleConditionEvent.violated( method, method.getFullName() + " has isolation " + isolation)); } } }); } ルール文書の例外規定と、テストコードの doNotHaveFullyQualifiedName(...) は、別々のファイルに書かれた対応関係です。この対応が崩れると、新しく追加した例外が検査対象のままになって意図せずテストが失敗したり、逆に例外ではないクラスが誤って除外されたままになったりします。私たちのルール文書には、「新たに例外が必要になった場合は、テストコードの除外条件も合わせて更新する」という注意書きを直接添えています。これによって、ルール文書とテストコードの対応関係を明示しています。ただし、この注意書き自体も、人が読んで実行することに変わりはありません。例外の宣言をコード側に持たせ、テストがそれを直接参照する形にすれば、注意書きなしでも対応関係を保てます。この点は今後改善する余地として残っています。 もう1つの工夫は、ルール文書自体に「検査の保証範囲」を明記することです。たとえば、先ほどのUseCaseの設計規約には、次のような記載があります。 機械的チェックの現状: process への @Transactional 必須とisolation制約は、ArchUnitのテストで既に機械的に検査されている。一方、メソッドの公開範囲や引数の形に関する規約はまだ自動検査されておらず、レビューでの目視確認に依存する。 この記載があることで、AIエージェントも人間のレビュアーもルール文書を読むだけで、どこまでが保証されていて、どこからが目視確認頼みかを判断できます。 こうした工夫に加えて、ルールを新しく追加したときには、AIエージェントに三者を突き合わせて確認してもらうこともあります。対象は、ルール文書とArchUnitのテストコード、実コードです。矛盾点や、ルール文書の説明と実装の食い違い、目視確認に頼っている部分の取りこぼしがないかを検証してもらう狙いです。 アドホックな知識からルールへの体系的な移行 ルールをArchUnitで検証する仕組みや、三者の整合性を保つ運用を整えても、その対象になるルール自体がどこにあるかが分かりにくいという問題が残っていました。 私たちのプロジェクトには、設計判断の理由や実装パターンをまとめた知識ベースがありました。この知識ベースには、「なぜこの設計を選んだか」という背景説明と、「必ず守るべき制約」が同じ文書に混在していました。背景説明は読み手の理解を助けるものであり、検証の対象にはなりません。一方、制約は本来、検証の対象になり得るものです。両者が同じ文書に混ざっていると、どの記述がArchUnitで検証すべき対象なのかが分かりません。 そこで、知識ベースと制約を別々のディレクトリに分けました。背景説明や実装パターンは .claude/knowledge/ に置き、必ず守るべき制約は .claude/rules/ へ配置しました。なお、 .claude/rules/ は現在Claude Codeが標準で読み込むディレクトリですが、 .claude/knowledge/ はこのプロジェクト独自の配置です。標準的な文書配置がまだ定まっていなかった時期に、リポジトリの直下には置きたくないという理由で .claude/ 配下に作ったものです。 たとえば、アーキテクチャに関する文書は、同じ architecture.md という名前で両方のディレクトリに存在します。 .claude/knowledge/architecture.md には、オニオンアーキテクチャを採用した理由や各層の実装パターンといった、背景の説明が書かれています。 .claude/rules/architecture.md には、Controller・UseCaseをファットにしないための具体的な閾値や、NG・OKのコード例が書かれています。これらは、コード生成時に従うべき強制基準だけに絞られています。後者の冒頭には、次のような記載があります。 各層の責務概要・実装パターン・選択理由は .claude/knowledge/architecture.md に記載してあり、本ファイルはその知識を前提とした上で「コード生成時に従う強制基準」を定義する。 この参照は、ルールを単体で完結させるという前提の転換と矛盾しているように見えます。しかし、コード生成時に守るべき強制基準は .claude/rules/architecture.md 側で完結しています。 .claude/knowledge/architecture.md を読まなくても遵守の判断はできます。knowledge側は、なぜその基準になったかという任意の背景情報であり、参照しなくても強制基準の適用に支障はありません。 .claude/knowledge/ は .claude/rules/ と異なり無条件では読み込まれず、CLAUDE.mdの案内に沿って必要な場面ごとに参照先が示される配置です。そのため、AIエージェントが両者を区別せずまとめて読み込んでしまう事態は今のところ起きていません。 この整理によって、「検証すべき制約の一覧」がルール文書側にまとまりました。新しいルールを追加するときも、書く場所を選ぶ時点で性質を判断するようになりました。単なる背景知識なのか、AIエージェントに守らせたいルールなのか、それとも機械的に検証できる制約なのか、という観点です。 複数リポジトリへのルール展開の検討と見送り 1つのリポジトリでルールとArchUnitによる検証の仕組みが定着したところで、次に考えたのは、他のリポジトリでも同じ仕組みを使えるようにすることでした。命名規約やアーキテクチャの制約には、プロジェクトが違っても通用する部分があります。1つのリポジトリで整備したルールを他のプロジェクトでもそのまま使えれば、同じルールをゼロから作り直す手間を省けます。 そこで、ルールをマーケットプレイス形式で管理し複数のリポジトリへ配布する仕組みと、配布したルールを管理するツールを用意する計画を立てました。ここでの「マーケットプレイス形式」は、既存のプラグイン配布基盤を指すものではなく、複数のリポジトリへルールを公開・取得できるようにする、自前の配布基盤のことを指しています。 しかし、計画を進める中で見えてきたのはツールの設計上の課題ではなく、運用面の課題でした。ルールを共有する仕組み自体は用意できます。ただし、それぞれのリポジトリを担当するメンバーが、他のリポジトリで整備されたルールを積極的に取り込むかどうかは別の問題です。実際には、他のリポジトリのルールを取り込む動きはほとんど生まれませんでした。 取り込みが進まなかった背景には、大きく2つの理由があると考えています。1つは、リポジトリをまたいで本当に共有できるルールが想定していたほど多くなかったことです。プロジェクト固有の事情に依存するルールが大半で、汎用的に使い回せる部分は一部に留まりました。もう1つは、ルールを取り込むこと自体が、前述したルール文書・テスト・実コードの整合性を確認する運用という新たな運用コストを増やしてしまうことです。既存のルールをそのまま使うのではなく、自分たちのリポジトリの実装に合わせて調整し、整合性を保ち続ける必要があり、その手間が取り込みのハードルになっていました。なお、この2つの理由は、実際の取り込み状況から私たちが振り返って推測したものです。 この結果を受けてマーケットプレイスと管理ツールの計画は取りやめました。仕組みを作ることが目的化してしまうと、実際には使われない仕組みを維持するコストだけが残ってしまいます。需要が確認できていない段階で大掛かりな仕組みを作るより、まずは個別のリポジトリでルールと機械的な検証の仕組みを定着させることを優先する判断です。 この判断を見直す条件があるとすれば、共有できるルールの数がたまたま増えることではなく、共通化に必要な材料がそろうことだと考えています。ルールを先に一般化してから展開するトップダウンの手順は、複数チームでの実例が積み重なっていない段階では成立しにくいというのが、今回の見送りから得た実感です。各チームがルールの背景を記録しつつ、記録した内容をチーム横断で比較する仕組みと運用が必要です。ルール文書・テスト・実コードの三者突き合わせと同様に、この比較もAIエージェントに任せられる見通しが立った時点で、改めて展開を検討したいと考えています。 検証の必須ゲート化 ArchUnitのテストを書いても、実行される保証がなければ意味がありません。テストコードとして存在していても、実行タイミングが曖昧だと気づかないうちに検証が素通りしてしまうことがあります。 私たちのプロジェクトには、SpockベースのArchUnitルールとは別の検証ルールもあります。たとえば、参照型を返すメソッドに @Nonnull ・ @Nullable のいずれかを必須にするルールなどがあります。このルールは check タスクに組み込まれていましたが、 test タスクには組み込まれていませんでした。これは意図的な設計ではありませんでした。Gradleの標準的なタスク依存構造( check が test に依存する一方、逆方向の依存はない)上、静的な検証系のタスクが慣習的に check 側へ接続されることによるものでした。この違いに気づかないまま ./gradlew test だけをローカルで実行してプッシュしたところ、 check 側のルール違反がローカルでは検出されず、CIで初めて検出される事態が起きました。テストが通ったことを確認しても、 check の検証は素通りしていたということです。 そこで、プッシュ前に実行すべきコマンドを ./gradlew check に統一しました。 check タスクはSpotlessによるフォーマットチェック・全テスト・ルールの検証をまとめて実行するため、 test だけを実行して安心してしまう事態を防げます。この方針はルール文書側にも明文化しています。 あわせて、CI側でもプルリクエストごとに検証しています。ただし、CIでは check タスクをそのまま呼ぶのではなく、構成要素を複数のジョブに分けて並列実行しています。DBを使うテストと使わないテストを別ジョブに分けてDBコンテナの要否を切り分け、フォーマットは「チェックして落とす」のではなく「自動修正してコミットする」別ジョブにしているためです。ルールを検証するジョブは、GitHubのブランチ保護ルールで必須ステータスチェックに指定されています。ルールの検証に失敗すると、そのプルリクエストはマージできません。ローカルでの実行規律だけでなく、CIでも強制することで、実行を忘れたまま気づかずマージしてしまう事態を防いでいます。 新しいルールを追加するときは、検証が実際に機能しているかどうかも確認します。たとえば、ドメイン層のパッケージ配置に関するルールを追加したときは、意図的にルールに違反する配置のダミークラスを用意し、検証が失敗(FAILED)することを確認しています。ルールを追加した時点で既存のコードに違反がないかどうかも確認し、違反がゼロであることを確かめてからマージしています。 こうして、ArchUnitなどを用いた検証は、書いただけのテストコードではなく、プッシュ前に必ず通過するゲートとして機能するようになりました。 まとめ 本記事では、AIエージェント向けにコーディングルールを書き、ArchUnitで機械的に検証する取り組みを紹介しました。 AIエージェントは、指示すればすぐに多くのコードを書いてくれます。しかし、その分だけルールに違反したコードが生まれる機会も増えます。レビューだけでこれを検出しようとすると、コードが増えるほどレビュアーの負担も増えていき、いずれ追いつかなくなります。ArchUnitでルールを実行可能な形にしておけば、検証の速度をコードが生成される速度に合わせられます。 この検証の仕組みを維持する作業にも同じ考え方が当てはまります。ルール文書とテストコード、実コードの整合性を確認する作業を人手だけで追いかけようとすると、ルールが増えるほど負担が増えていきます。そこで、この確認作業もAIエージェントに依頼しています。ルールを守る対象であるAIエージェントに、ルールを整備する側も手伝ってもらう、という体制です。 ルールの読者はAIであるという前提に立ち返ったことは、書き方を変えるだけの話ではありませんでした。ルールを「読んで理解してもらうもの」から「実行して確認できるもの」に位置づけを変える、という判断につながりました。AIエージェント向けにコーディングルールを整備することを検討している方がいれば、ぜひ参考にしてみてください。今後は、まだレビューでの目視確認に依存している規約も対象に含め、ArchUnitに限らずさまざまな手段で機械的に検証できる範囲を広げていきたいと考えています。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、検索基盤部の検索グロースブロックに所属する しゅがー です。普段はZOZOTOWNの検索体験を改善する施策の企画と開発を担当しています。 近年、CLIPをはじめとするマルチモーダルモデルの登場によって、画像とテキストを同じ特徴空間で比較できるようになりました。一方で、検索結果の並び順を学習するランキング学習(LTR: Learning to Rank)では、行動実績やテキストに基づく特徴量を使うことが一般的です。画像の情報をどう取り込むかには、まだ検討の余地があります。 本記事では、日本語CLIPモデルで算出した商品画像と検索クエリの類似度をランキング学習モデルの特徴量として追加すると精度がどう変わるのか、ZOZOTOWNの検索ログを用いてオフラインで検証します。類似度は、検索クエリをキーワードや性別、カラーといった属性に分解し、属性ごとに計算して複数渡します。検証の結果、nDCGは最大でおよそ0.7%改善し、渡す類似度の数を増やすほど改善幅は大きくなりました。要因として、各類似度が互いに異なる情報を持っていたことが示唆されました。なぜそう言えるのかSHAPを用いて掘り下げます。 目次 はじめに 目次 検証の背景と目的 CLIPを用いた画像・テキスト類似度の算出 CLIPとは 商品画像と検索クエリの類似度 オフライン検証の設計 検証結果 類似度とCTRの関係 類似度を特徴量として追加する 圧縮したベクトルを特徴量として追加する なぜ精度が改善したのか まとめ 検証の背景と目的 ファッションECの検索結果の画面をながめてみると、目に入る情報のほとんどは商品画像です。ブランド名や価格も表示されますが、ユーザーはまず画像を見て、クリックするかどうかを判断していると考えられます。 一方で、ランキング学習の特徴量としてよく使われるのは、クリック数や購入数といった行動実績や、価格やカテゴリーといった商品属性です。これらの特徴量は強力ですが、画像にしか現れない情報は捉えられません。たとえば「きれいめ ワンピース」のような検索では、その商品がきれいめかどうかは商品名や属性情報には必ずしも現れず、多くの場合、商品画像を見て判断するしかありません。 そこで本検証では、ランキング学習モデルの特徴量に商品画像と検索クエリの類似度を追加したとき、並び順の精度がどれだけ変わるのかをオフラインで調べます。 CLIPを用いた画像・テキスト類似度の算出 CLIPとは CLIP(Contrastive Language-Image Pre-training)は、画像とテキストを共通の特徴空間に埋め込むための訓練手法です。OpenAIが論文「 Learning Transferable Visual Models From Natural Language Supervision 」で提案しました。画像を入力するImage Encoderと、テキストを入力するText Encoderの2つを用意し、対応する画像とテキストのペアが近くに配置されるように学習します。 学習の流れは次のとおりです。 画像とテキストのペアをそれぞれのエンコーダーに入力し、特徴ベクトルを得る 画像側とテキスト側の特徴ベクトルの間でコサイン類似度を計算し、類似度の行列を作る 対応するペアの類似度が高く、対応しないペアの類似度が低くなるように最適化する CLIPの対照学習。対応するペア(対角成分)の類似度を最大化する この「画像とテキストを同じ空間で比較できる」という性質を使うと、商品画像と検索クエリがどれくらい似ているかを数値にできます。 商品画像と検索クエリの類似度 ZOZOTOWNの検索ログでは、ユーザーの入力した検索クエリが、キーワードと検索条件(性別やカテゴリーなど)に分解された形で記録されています。たとえば「メンズ シャツ 大きめ」という入力であれば、性別にメンズ、カテゴリーにトップス、サブカテゴリーにシャツ/ブラウスが対応します。 本検証では、この分解された次の7つの属性を対象にしました。 属性 内容 キーワード 検索条件に分解された後に残った検索キーワード 性別 メンズ、レディース、キッズの指定 カテゴリー トップスなどの大カテゴリー サブカテゴリー Tシャツ/カットソーなどの小カテゴリー カラー 選択された色 ブランド 選択されたブランド こだわり条件 ボーダー柄などの条件 キーワード以外は、ユーザーが明示的に指定していなくても値が埋まっていることが多い項目です。 これらの属性を、たとえば性別なら「男性の商品」、カラーなら「ホワイト系の商品」のように自然文へ変換したうえでText Encoderに入力し、商品画像との類似度を属性ごとに計算します。 手法の概要。図中の類似度の数値はダミーです。 利用したモデルは、LINEヤフーが公開している clip-japanese-base-v2 です。選定にあたっては次の2点を重視しました。 日本語で訓練、またはファインチューニングされていること 汎用的な多言語モデルと比べても、日本語の性能と汎化性能が同程度であること この2点を満たす候補モデルを複数選び、小規模なデータで商品画像と検索クエリの類似度を計算したうえで、類似度とCTRの相関がもっとも強かったものを採用しました。 オフライン検証の設計 ベースラインには、商品やユーザーの実績値に基づく特徴量で構成した勾配ブースティング木のランキング学習モデルを、本検証用に用意しました。このベースラインに画像由来の特徴量を追加し、精度の変化を比較します。 検証は2つの段階に分けました。 小規模なデータセットで、類似度とCTRの間に関係があるかを確認する ZOZOTOWNの検索ログを使って特徴量を追加したモデルを訓練し、精度の変化を評価する 1つ目の段階を挟んだのは、そもそも類似度がクリックの傾向と結びついていなければ、特徴量として追加しても意味がないためです。数万件規模のサンプルで7つの属性それぞれの類似度を計算し、CTRとの関係を調べました。 2つ目の段階では、追加する特徴量の入れ方を2つの方針で比較しました。 方針1:属性ごとの類似度を特徴量として追加する。複数の観点から画像の情報を渡すことを狙う 方針2:類似度に加えて、主成分分析で次元を削減した画像とテキストのベクトルを直接渡す。類似度では表せない細かい特徴も渡すことを狙う 評価指標にはnDCG@kを用い、ベースラインからの改善幅を比較します。訓練、検証、テストの各データは期間で分割しています。テストに使うのは、訓練よりも後の期間のログです。 検証結果 類似度とCTRの関係 はじめに、小規模なデータセットで類似度とCTRの関係を確認しました。 属性ごとに類似度の分布を見ると、キーワードと性別はほとんどの検索で類似度を計算できる一方、カラーやこだわり条件は指定される検索が少なく、類似度が欠損になりがちです。仮にCTRとの関係が強くても、欠損が多い属性は予測に貢献しにくいと考えられます。 次の図は、キーワードの類似度とCTRの関係です。類似度をビンに区切り、ビンごとのCTRを折れ線で、サンプル数を背景の棒で示しています。類似度が高いほどCTRも高くなる、右肩上がりの傾向が見て取れます。 キーワードの類似度とCTRの関係。軸の数値は非公開のため省略しています CTRとの相関を調べたところ、キーワードとカラーの類似度には有意な正の相関が見られました。画像と検索クエリが似ている商品ほどクリックされやすい、という関係を確認できたことになります。一方でカテゴリーやサブカテゴリーの類似度は、CTRとの関係がほとんど見られませんでした。 類似度を特徴量として追加する 次に、属性ごとの類似度をランキング学習モデルの特徴量として追加しました。 結果として、いずれの条件でもベースラインを上回るnDCGを記録しました。この傾向はnDCG@1とnDCG@10のどちらの評価位置でも同じです。さらに、追加する類似度の数を増やすほど改善幅も大きくなり、7つの属性すべてを追加したときにもっとも大きな改善が得られています。改善幅は最大でおよそ0.7%でした。 予測への貢献度を見ると、追加した類似度はいずれもベースラインの特徴量と比べて上位に入っていました。なかでもキーワードと画像の類似度の貢献度は高く、モデルがこの特徴量を予測に強く役立てていることが分かります。 また、単体ではCTRとの相関が弱かったカテゴリーやサブカテゴリーの類似度も、追加すると精度の向上に寄与しました。これは、勾配ブースティング木が単変量の相関では捉えられない非線形な関係を利用できるためだと考えられます。 圧縮したベクトルを特徴量として追加する 類似度はベクトル同士の関係を1つの数値に集約したものなので、その過程で捨てられている情報もあります。そこで、CLIPが出力したベクトルそのものを主成分分析で圧縮し、特徴量として渡す方法も試しました。 こちらも類似度だけを追加した場合より精度が改善しました。画像とテキストの両方のベクトルを渡したときの結果が、もっとも良好でした。渡す画像の情報を増やすほど精度が上がる傾向は、類似度の実験と同じです。 補足として、主成分分析の累積寄与率(圧縮後の次元で元のベクトルの分散をどれだけ説明できるかの割合)は、一般的な画像特徴量を圧縮する場合に比べてかなり低い水準にとどまりました。CLIPのベクトル空間では意味の情報が特定の次元に偏らず、多くの次元に分散して表現されているためだと考えられます。 なぜ精度が改善したのか 複数の類似度を追加するほど精度が上がる理由として、次の3つの仮説を立てました。 仮説A:画像に由来する特徴量の数が増え、予測に占める割合が高まったから 仮説B:各類似度が異なる意味を捉えており、情報の多様性が増したから 仮説C:複数の類似度が組み合わさり、決定木の分岐のパターンが豊かになったから 検証にはSHAPを用いました。SHAPは、モデルの個々の予測を特徴量ごとの寄与に分解する手法です。ある特徴量が予測をどれだけ押し上げ、または押し下げたのかを、サンプル単位で数値にできます。さらに、2つの特徴量が組み合わさったときにだけ生まれる寄与を交互作用として分けて算出できるため、仮説Cのような、特徴量の組み合わせに関するものも検証できます。 まず仮説Aについては、画像由来の特徴量が予測全体に占める寄与の割合が、類似度の追加によってどれだけ変わるかを調べました。結果として、類似度を7つに増やしても割合はわずかしか増えず、大半はベースラインの特徴量が占めたままでした。キーワードの類似度の使われ方も、単体で追加したときとほとんど変わりません。画像由来の特徴量が予測を支配するようになったから精度が上がった、という説明は成り立たないため、仮説Aは棄却しました。 次に仮説Cについては、類似度同士の交互作用による寄与の大きさを、それぞれの類似度が単独で持つ寄与と比べました。交互作用は主効果と比べて小さく、それぞれの類似度は独立に予測へ寄与していました。組み合わせの妙で効いているわけではないため、仮説Cも棄却しました。 最後に仮説Bについては、類似度同士がどれだけ重複した情報を持つかを確かめるため、類似度同士の寄与の相関を調べました。もし2つの類似度が同じ情報を捉えているなら、予測への効き方も似るはずで、寄与の相関は高くなります。実際に調べると、値はどれも非常に小さくなりました。唯一やや高い相関を示したのはカテゴリーとサブカテゴリーの組み合わせで、これは両者が親子の関係にあることを考えれば自然な結果です。この寄与の相関の低さは、追加する類似度の数を増やすほど精度が改善したという結果とも一致します。以上から、各類似度は互いに重複の少ない情報をモデルに渡せていたと考えられ、仮説Bを支持する結果が得られました。 この結果が示しているのは、属性ごとに分けて類似度を計算したこと自体が効いていた、ということです。類似度を1つだけ渡すよりも、「何と似ているのか」を属性ごとに分解して複数渡すほうが効く、という示唆が得られました。この考え方は、CLIPに限らず他の特徴量の設計にも応用できそうです。 まとめ 本記事では、ランキング学習モデルに商品画像の情報を追加するオフライン検証を紹介しました。 商品画像と検索クエリの類似度は、CTRと有意な正の相関を持つ 類似度をランキング学習モデルの特徴量として追加すると、オフライン評価でnDCGが最大でおよそ0.7%改善する 類似度だけでなく、圧縮した画像のベクトルを渡すとさらに精度が上がる 精度の改善に効いていたのは特徴量の数ではなく、互いに独立した情報を渡せていたこと ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com