シンクロ・フード エンジニアブログ

飲食店ドットコムを運営する、株式会社シンクロ・フードの技術ブログです

AWS WAF レートリミット導入と誤検知対策:正規トラフィックを保護するアーキテクチャを設計した話

はじめに

こんにちは。開発部 SRE チームの柴山です。
技術構成の記事でもご紹介した通り、弊社ではWebアプリケーションの脆弱性保護を目的として Scutum を中心としたセキュリティ対策を実施してきました。
しかし、昨今のAI技術の急速な発展に伴い、悪意のある攻撃だけでなく、データ取得(AI学習等)を目的とした突発的なスクレイピングといった「予測不可能なトラフィックスパイク」に直面することが増えてきました。
これらはバックエンドのリソース逼迫を招き、正規のユーザー様にも影響を与えかねません。
こういった機械的なアクセスには、アクセス数を制限するレートリミットを設定するのが定石として挙げられますが、導入には様々な検討事項があり、単純に導入するだけでは誤検知によって正規のトラフィックさえも巻き添えにしてしまうリスクがあります。
インフラを保護できても、正規ユーザーまでブロックして機会損失を生んでしまっては本末転倒です。

弊社では「正規トラフィックを保護するためのアーキテクチャ設計」として、最終的に AWS WAF を採用しました。
本記事では、その採用に至った経緯や設計思想についてご紹介いたします。

技術選定

突発的なトラフィックスパイクを制御するにあたり、まずは「どこで制限をかけるか」という実装ポイントの比較検討を行いました。
弊社ではWebサーバー(Nginx) + AWS ALBという構成で、サービスを提供しています。導入のしやすさから、以下二つのアプローチを比較検討しました。

  1. マネージドサービスでの制御(AWS WAF の レートベース ルールステートメント)
  2. サーバーサイドでの制御(Nginx の limit_req + geo モジュール)

それぞれの特性と優位性について、弊社での運用環境をもとに評価した結果がこちらです。

評価軸 AWS WAF Nginx
初期リソースコスト △ 中 ◎ 低
運用保守コスト ◎ ほぼゼロ(フルマネージド) △ 運用保守のフローを要検討
GeoIP/Bot DB更新 ◯ 自動 △ 手動(要自前構築・保守)
導入時の安全性 (DryRun) ◎ 可能 (Countモード) △ 工夫が必要(OSS版)
DDoS対応 ◯ 優位(専用ルール有/一時的な適用が可能) △ 困難(分散型は対応不可)
ログの可視性 ◯ 優位(専用ダッシュボード/Log ツール連携) △ 自前構築(ログ転送・別途可視化のコストが発生)

表からもわかる通り、初期のリソースコスト面では Nginx に分がありますが、保守・運用の観点で AWS WAF が弊社の要件にマッチしていました。
その中でも以下の点は非常にメリットとして大きく、採用の決め手となりました。

  • フルマネージドの恩恵
    GeoIP データベースやBotリストを都度更新し続けるのは長期的に人的な運用コストがかかることになります。
    ここをAWS側にほぼ丸投げできるのは大きなメリットの一つです。
  • Countモードによる段階的導入
    表題通り、今回一番懸念していた点が正規ユーザーの誤ブロックです。
    AWS WAF にはブロックせずにルール評価だけを行う Countモード が存在するため、事前にルールの妥当性を精度高く検証することができます。

設計の壁

いざ、AWS WAF でレートリミットを導入しようと思いましたが、早速いくつかの壁にぶつかりました。
「どうやって閾値を決定しよう」「正規ユーザーの誤ブロックを防ぐには?」「これって一律に制限かけていいの?」
たとえば、レートリミットの閾値を「1IPアドレスあたり、5分間に100リクエストまで」と決め打ちしたとします。
それだけで単純に適用をすると、以下のような絶対に弾いてはいけない正規のトラフィックまで遮断してしまいます。

  • 優良Bot(クローラー): Googlebot 等の検索エンジンや外部連携先の巡回Bot
  • 正規ユーザーアクセス: 本命の絶対にブロックしてはいけないトラフィック
  • アプリ・サービス連携: 外部連携サービスからのWebhookや、自社アプリなどからの通信
  • 監視系: 外形監視など

これらのトラフィックを踏まえた上で、悪意のあるトラフィックだけをそぎ落とし、可能な限り正規のトラフィックを通す必要がありました。

アーキテクチャ

今回の肝である誤検知の対策として、フラットにレートリミットをかけるのではなく WAF の評価順位の仕様を利用した多段フィルターを設計しました。
AWS WAF は設定された優先順位(Priority)の小さい順にルールが評価されていきます。
全ての通信に対してフラットにレートリミットをかけるのではなく、「既知の脅威をブロック」->「正規トラフィックの救済」->「その他トラフィックへのレートリミット」という段階的にふるいへかけるアーキテクチャを構築しました。

  1. 前段フィルター: 既知の脅威をブロック
    最前段では明らかに不正なトラフィックをAWSが提供するマネージドルールなどを用いて遮断します。

  2. 中段バイパス: 正規トラフィックの保護
    ここが正規トラフィック保護の要になります。
    前段を通過したトラフィックに対して、前述した項目(優良Bot、アプリ・サービス連携、監視系等)を独自の複合的な条件で識別を行います。
    条件に合致した場合は Allow(明示的な許可)とし、この段階で以降のルール評価をスキップさせてバックエンドへのトラフィックを許可します。
    ここに引き算のルールを挟むことにより、死守すべき正規トラフィックが後段のレートリミットにかかることを防ぎます。

  3. 後段フィルター: レートリミットの適用
    最終的に残ったトラフィックの大量アクセスにのみレートリミットを適用します。
    ルールステートメントにより、特定の属性を持つ通信が閾値を超えた場合にのみ遮断をすることができます。

誤検知を防ぐための Tips

静的ファイルの考慮
Webページへの初回アクセス時、ブラウザはページにリンクされた大量の静的ファイル(画像、CSS、JS等)を同時にリクエストします。
これをレートリミットのカウントに含めると、正常なユーザーでも一瞬で閾値に達してしまうリスクがあります。
これらを考慮した閾値を設定するか、静的ファイルはCDN等でキャッシュさせて、静的ファイルをカウント対象外にすることが重要です。

閾値の決定について
レートリミットの閾値を「だいたい1分間にn回くらいだろう」と勘で決めるのは非常に危険です。
過去のアクセスログを分析し、以下のようなステップで閾値を決定しました。一例として共有させていただきます。

  1. 外れ値を除外してベースとなる値の算出
    まずサービスのアクセスログから各時間(分)あたりで上限の基準値として95パーセンタイルを抽出しました。
    分析対象のログにはすでに悪意のあるアクセスやスクレイピングなど異常なスパイク(外れ値)が含まれていました。
    そのまま最大値を基準にしてしまうと、閾値がかなり甘めに出てしまうことになります。
    外れ値除外の例
    上位5%のノイズを取り除くことによって、Botなどによる外れ値を極力排除した値を抽出することができました。

  2. 安全係数の適用と基準値の厳格化
    抽出したベース値に対し、トラフィックの属性(エンドポイント等)ごとに数値を比較し、より厳格な(小さい)値を採用しました。
    その上で、正常な突発的アクセスを許容して安全側に寄せるため、安全係数(1.x)を掛け合わせて最終的な閾値を決定しています。
    これにより、正規トラフィックの多少の突発的なアクセスは許容しつつ、Botによる攻撃だけを制限するような閾値を導き出すことができました。

導入

アーキテクチャと閾値の設計が完了し、いよいよ導入フェーズですが、いきなり本番環境で遮断を開始するのは危険です。
いくら念入りに計算をしていても、あくまで絵に描いた餅で正規トラフィックの誤ブロックリスクは依然として存在します。
そこで、技術選定の項で紹介したCountモードを活用し、以下のような4段階のフェーズに分けて慎重にリリースを行いました。

フェーズ 環境 モード 実施内容のハイライト
フェーズ1 テスト環境 Count -> Block IaC(Terraform)でのインフラ構築と、テストシナリオに基づく動作(遮断)検証。
フェーズ2 ステージング環境 Block 本番同等環境でのリハーサル。
正規系動作の確認と、意図した通信のみが遮断されるかの最終確認。
フェーズ3 本番環境 Count 【最重要】 本番環境へCountモードで適用。
およそ二週間かけて全曜日・時間帯の実際のトラフィックログを取得・精査し、机上の計算とズレがないかを確認
フェーズ4 本番環境 Block ログ精査で正規トラフィックの巻き込みがないことを確信した後、本番運用(Block)へ切り替え。

このリリース計画の中で最も重要なのが フェーズ3(本番環境でのCount運用) になります。
本番のトラフィックがWAFに流れ、CloudWatch Logs に出力された検出結果を2週間かけて分析しました。
ここでは「正規ユーザーを誤って検出してしまっていないか」「正常にレートリミットが効いているか」「連携や監視に考慮漏れの項目が無いか」の答え合わせを実施しました。
結果として、設計時には検討できていなかった「優良Bot」や「連携系のアクセス」、「状況に応じて遮断されてしまう正規ユーザー」の存在に気づくことができました。
もし最初からBlockモードで導入していた場合、これらの通信も遮断してしまい、サービス影響に繋がっていた可能性があります。

誤検知の可能性を事前に全て摘み取ることで、満を持して最終フェーズのBlockモードへの切り替えを実施しました。
その成果が、導入後に発生した突発的な大量アクセス時のメトリクスに表れました。

実際の検知事例

上記グラフの通り、トータルアクセス(赤線)が急増した際、レートリミットが即座に反応し、悪質なトラフィックのみを選択的にブロック(紫線)してくれています。
一方で、正規ユーザーの通信(緑線)はスパイクの影響を一切受けることなく一定に保たれていることが見て取れます。
無事、正常なトラフィックを保護したまま、悪質なトラフィックのみを遮断するレートリミット環境を本番稼働させることに成功しました。

まとめ

今回は、AWS WAFを用いたレートリミットの導入と、正規トラフィックを保護するためのアーキテクチャ設計についてご紹介しました。
単に「アクセスを一定数でブロックする」という安易な設定をせず、以下のステップを踏むことで、「正規トラフィックを保護するアーキテクチャの設計」を実現できました。

  1. アーキテクチャの工夫: WAFのPriority仕様を活用した「段階的なフィルター」と、静的ファイルの考慮。
  2. 統計に基づいた閾値設計: 勘に頼らず、過去のアクセスログのp95値から算出した「ノイズを極力排除した閾値」の決定。
  3. 安全なリリース戦略: 本番環境でのCountモード運用と、ログ精査による誤検知の事前排除。

Scutumに加えてAWS WAFを導入したことで、より堅牢な構成を実現できました。
細かなチューニング内容や今後の展望があれば、またこの場でご紹介させていただけたらと思います。
この記事が、もし今後レートリミットを導入しようと検討されている方の参考になれば幸いです。

AppsFlyerのUDLを使ったディファードディープリンク実装で学んだこと

シンクロ・フード開発部モバイルアプリチームの横山です。
普段は、飲食店と飲食店で働きたい求職者を繋ぐ「求人飲食店ドットコム」アプリのAndroid開発を担当しています。

今回は、アプリにディファードディープリンクを実装した経緯と、調査・設計・実装を通じて得た知見をまとめます。同じような機能を検討している方の参考になれば幸いです。

実装の背景

弊社アプリではすでにUniversal Links(iOS)とApp Links(Android)によるディープリンクを実装していましたが、これらはアプリがインストール済みのユーザーにしか機能しないという制約があります。リンクを踏んだ時点でアプリが入っていなければ、Webページへのフォールバックで終わってしまいます。

今回、求人飲食店ドットコムの求人詳細画面に「アプリで開く」ボタンを追加することになりました。このボタンはアプリをインストールしていないユーザーにも利用してもらう想定です。 つまり、「未インストールのユーザーをApp Store / Google Playへ誘導し、インストール後に目的の画面へ遷移させる」 という仕組みが必要でした。この課題を解決するのがディファードディープリンクです。

ディファードディープリンク

ディープリンクの種類と選定理由

ひとくちに「ディープリンク」といっても、実装方式はいくつかに分類されます。

  • Custom URL Schememyapp://path のようなカスタムスキームでアプリを起動する方式です。シンプルな反面、アプリが未インストールの場合はエラーになるためユーザー体験上の課題があります。また、同じスキームを別のアプリが乗っ取れてしまう「URLスキームハイジャック」のような脆弱性もあり、現在は積極的に使われるケースは少なくなっています。

  • Universal Links(iOS)/ App Links(Android):通常の https:// URLでアプリを起動できるOSレベルの仕組みです。アプリ未インストール時はそのままWebページとして開くため自然なフォールバックが実現でき、現在のスタンダードといえます。弊社アプリもこの方式をすでに導入していました。

  • サードパーティ製SDK(AppsFlyer / Adjust等):SDKが提供するリンク機能を利用する方式です。ディープリンクとしての機能に加えて、インストール前後の計測やアトリビューションにも対応しており、マーケティング施策と連携しやすいのが特徴です。今回はもともとAppsFlyerのOneLinkを利用していたため、AppsFlyerが提供するUDL(Unified Deep Linking)を採用する方針となりました。

UDL(Unified Deep Linking)とは

UDLはAppsFlyerが提供するディープリンク取得APIで、従来は別々のコールバックで制御していた「Direct Deep Linking」と「Deferred Deep Linking」のAPIを、1つのインターフェースに統合した仕組みのようです。

  • Direct Deep Linkingは、アプリがすでにインストールされている状態でリンクから起動するケースです。
  • Deferred Deep Linkingは、未インストールの状態でリンクを踏んだユーザーがストアからインストールし、初回起動した際に遷移先を復元するケースです。コールバックはアプリ起動後に非同期で返ってくるため、「いつデータが返ってくるか」というタイミングの揺らぎへの対応が、実装上の重要な課題となります。

UDLの調査

UDLはSDKの初期化とともにコールバックを購読し、非同期でパラメータを取得する仕組みです。そのため、「いつデータが返ってくるか」というタイミングの揺らぎを考慮し、実装前に以下のユースケースに沿って挙動を検証しました。

  • パラメータの伝搬: リンクに付与した情報がストア経由でも欠落せずに受け取れるか、また Direct / Deferred で受け取れる値に差異がないか
  • コールバックのタイミング: Direct / Deferred でそれぞれで、どのタイミングでデータが返ってくるか
  • 「15分制約」の挙動: インストールから起動までのタイムリミットがどの程度厳密に運用されているか

調査を通じて浮き彫りになった課題は、「コールバックの即時性は必ずしも保証されない」という点でした。ネットワーク環境や端末の状態によって、数秒以内にデータが取得できることもあれば、タイムアウトで情報が返ってこないケースの可能性もあるようでした。このため、実装側では「情報が取得できなかった場合」を前提としたハンドリングが必須ということが分かりました。

また、実機検証の結果、ストアを経由する際(Deferred)には利用できるパラメータに制約があることも分かりました。 取得可否の詳細は以下の通りです。

パラメータ取得可否の検証結果

キー Direct Deferred 備考
deep_link_value ✅ 取得可 ✅ 取得可 管理画面で設定可
deep_link_sub1 ✅ 取得可 ✅ 取得可 管理画面で設定可
deep_link_sub210 ✅ 取得可 ✅ 取得可 管理画面で設定不可
deep_link_sub11 ✅ 取得可 取得不可 -
custom_key ✅ 取得可 取得不可 -

上記の内容は執筆時点(2026年3月)の調査結果です。AppsFlyerの仕様変更により情報が更新される可能性があるため、実装の際は必ず最新の公式ドキュメントをご参照ください。

実装にあたって考えたこと

URLの命名規則

ディファードディープリンクで渡すURLは、一度決めたら変更しにくい性質上、設計には慎重になりました。 今回アプリに渡す必要がある情報は、画面識別子・エリア情報・求人詳細ID・職種情報の4つです。ただし、これらの情報の性質は均一ではありません。画面識別子・エリア情報・求人詳細IDは遷移先の画面を決定するために必須の情報である一方、職種情報は絞り込み条件として画面に渡す付加的な情報です。後者はパラメータの種類や組み合わせが変わりやすく、将来的に増減したり仕様が変わったりする可能性もあります。こうした性質の違いを踏まえ、どのようにURLに乗せるかを以下の3案で検討しました。

案1:パラメータ分割方式

  • AppsFlyerの標準フィールド(deep_link_valuedeep_link_sub110)に各値を個別に割り振る方式
  • パラメータの意味がURL上で明確になる反面、sub10 までという上限があり将来的にパラメータが増えた際に対応できなくなるリスクがある
  • 「sub1はエリア情報」「sub2は職種情報」といったマッピング定義を厳密に管理し続ける必要があり、運用コストの面でも懸念があった

案2:URL埋め込み方式

  • deep_link_value にWebの完全なURLをエンコードして丸ごと格納する方式
  • 個数制限を気にせず済み、WebのURL構造をそのまま流用できる点は魅力的だった
  • アプリ独自の画面(Webに対応するURLが存在しない画面)を開きたい場合にダミーURLの設計が別途必要になる点が課題として残った

案3:ハイブリッド方式(採用)

  • deep_link_value に画面識別子、deep_link_sub1 に職種情報を含むWebの完全URL、deep_link_sub2 にエリア情報、deep_link_sub3 に求人詳細IDを入れる方式
  • アプリは deep_link_value だけを見れば遷移先を即座に判断でき、必須の求人詳細IDとエリア情報はそれぞれ deep_link_sub2deep_link_sub3 で確実に受け取れる。一方、職種情報など付加的な絞り込み条件は、WebのURLにクエリパラメータとして存在しているため、そのURL自体を deep_link_sub1 に格納することで、OneLinkのパラメータを増やすことなく柔軟に渡せる
  • URL生成ロジックが若干複雑になるデメリットはあるものの、拡張性と可読性のバランスが最もよいと判断し採用

議論を行い最終的に決まったパラメータの構成ルールは以下のとおりです。

パラメータキー 役割 値の例
deep_link_value 画面識別子 shopDetail など
deep_link_sub1 職種情報を含むURL https://example.com/kanto/work/100245?param={職種情報}...
deep_link_sub2 エリア情報 kanto
deep_link_sub3 求人詳細ID 100245
deep_link_sub4〜10 未使用 -

非同期での情報取得のタイミング

実装で最も苦労したのは、ディファードディープリンクの情報を非同期で取得するタイミングの制御です。

UDLのコールバックはアプリ起動後に非同期で返ってくるので、アプリの初期化処理との兼ね合いが厄介でした。 スプラッシュ画面にタイムアウト時間を設けて取得できなければコールバックを破棄する案なども検討しましたが、最終的には2つのタイミングでコールバックを受け取る方針に落ち着きました。コールバック受信時点でのアプリの状態に応じて処理を切り替えることで、データの到着が早くても遅くても、スムーズに画面遷移を繋げられるようになりました。

パターン1:スプラッシュ画面中にコールバックが返ってくるケース

  • スプラッシュ画面表示中にUDLから deep_link_value 等を取得
  • 取得した情報を解析し必要情報を端末に保存
  • ウォークスルーをスキップし、指定された求人詳細画面へ直接遷移

パターン2:スプラッシュ画面中にコールバックが返ってこないケース

  • コールバックが間に合わなかった場合、通常通りウォークスルーへ遷移
  • 求人一覧(TOP)画面に到達したタイミングで改めてUDL情報の取得を購読し、deep_link_value 等を受け取る
  • 遷移確認ダイアログをユーザーに表示し、同意が得られれば指定された求人詳細画面へ遷移する

なお、リンクを踏んでから15分以上経過して起動した場合は、UDLによる情報復元は行われず通常のアプリ起動として処理されます。

処理

まとめ

今回の開発を通じて、チームとしてディファードディープリンクを含むディープリンク周りの実装経験を得ることができました。Universal LinksやApp Linksといったシンプルなディープリンクとは異なり、ディファードはSDKの非同期処理やOS・端末ごとの挙動の差異など、実際に手を動かして初めてわかる複雑さがありました。そういった泥臭い部分を含め、チームで一つひとつ検証しながら実装を進めたことは、今後の開発においても大きな財産になると感じています。

また、ネット上には、SDKの繋ぎ込み方の記事はあっても、「URLの構成をどう設計したか」や「起動後の具体的な処理」にまで踏み込んだ事例は意外と少ないと感じました。

この記事が、同じようにディープリンクの実装で悩み、試行錯誤しているエンジニアの方々にとって、解決のヒントや一助となれば幸いです。
最後までお読みいただき、ありがとうございました。

新卒として第四開発Gに参加してからの働き方

まえがき

こんにちは!シンクロフード開発部25卒の小林です!入社してから約1年、第四開発Gに配属されてから9ヶ月が経ちまだまだ勉強の日々ですが、楽しく開発に取り組んでいます!
私の所属する第四開発Gは、求人インテリア農業ジョブグルメバイトちゃんなどのサービスの開発を行っているチームです。

本ブログでは、IT業界やシンクロ・フードに興味がある未来の新卒の方に向けて、第四開発Gに参加後の新卒エンジニアの1日をお届けしたいと思います!
※チームごとに運用が違う場合があるので、あくまで私が所属する「第四開発G」の例ということをご留意ください。
開発Gに参加する前に実施されている、新卒研修に興味のある方は是非、以下の記事をご覧ください
tech.synchro-food.co.jp

1日の流れ

  • 9:00 出勤
  • 9:00 ~ 9:15 タスク整理
  • 11:30 ~ 12:00 第四開発G朝会
  • 12:00 ~ 12:15 雑談会
  • 12:30 ~ 13:30 お昼休憩
  • 14:30 ~ 15:30 第四開発G振り返り
  • 17:00 ~ 18:00 新卒振り返り
  • 18:00 退勤

ミーティング以外の時間は、開発業務をしています!

※上記のスケジュールは、振り返りMTGが集中している「金曜日」の例です。 月〜木曜日は朝会以外のMTGは少なく、午後いっぱいは開発に集中できることが多いです!
ミーティングの回数の平均は、約1日2回以下です。朝会の他にミーティングが一つある、というようなイメージですね。

出勤

私はいつも、9時~10時の間に出勤しています!
弊社はフルリモート制度と、フレックスタイム制が採用されているため、第四開発Gのメンバーの出勤時間もバラバラです。

コアタイムの11:00〜16:00に出勤していれば、フレキシブルタイムの7:00〜22:00の間で好きな時間に業務を行うことができ、月の労働時間が所定の時間を満たすように気をつければ出勤時間と退勤時間は自由に調整できます。

そのため、私は日によって勤務時間が変わることが多いです。
08:31 ~ 18:54 のように少し長く働いた日もあれば
10:52 ~ 17:58 のように短い日もあります。

下の写真は、とある日の第四開発Gの出勤時間です!朝早い人から遅い人まで様々です。
とある日の第四開発Gの出勤時間

メールや、Slackのメッセージを確認して、第四開発G朝会まで開発などの業務を行います!

タスク整理

第四開発GではNotionというツールを使ってタスク管理をしています!
画像は私のタスクの一例です!
タスクの粒度は様々で、文言修正のような小さめのタスクから、1つページを作成するような大きめのタスクまであります。

一週間のはじめに自分の開発工数がどのくらい残っているかを共有して、それに合わせてタスクを振られるのでタスクが多すぎて開発が間に合わない!ということはないので安心してください。
一週間で開発が終わらなかった場合は、残っている分を差し引いた工数のタスクが割り振られるので、その部分も安心です。

Notionで管理されていることでひと目見てタスクの状態がわかりやすく、タスクにメモを書いたりする事ができるのでタスクごとの進捗を管理しやすいです。
自分でタスクの場所を動かしたり、タスクの状態を変更できるので今自分は何をするべきなのかを把握しやすいです。
私はたくさんのタスクを抱えることが苦手なのですが、Notionでタスクを整理することでどのタスクをやればいいかがわかると一つずつタスクをこなしていけるのでとても便利に感じています。
Notionでタスクを整理

開発業務

基本的には複数の開発要件をもっていて、期日や進捗に合わせて開発を進めています!
Ruby on Railsを触ることが最も多く、次点でJavaScript、たまにJavaを触るような感じです。
現在は、冒頭で紹介した「求人インテリア」などの機能改修を担当しています。
集中して黙々と開発することが多いですが、わからなかったり詰まったりしたらSlackで相談しています。

例えば、テキストだけでは状況が伝わりにくい場合、下の画像のようにハドル(通話機能)を使って画面共有をしながら相談することもあります。
ハドル(通話機能)を使って画面共有をしながら相談

開発業務は大まかに以下の流れで行っています。

  1. タスク割り振り
  2. 調査&実装
    1. 大きめのタスクの場合実装方針を相談します
  3. レビュー
    1. 他のメンバーに見てもらえるので、ここでもアドバイス等をもらえます
  4. テスト
  5. 本番アップ

第四開発G朝会

第四開発G朝会は毎日行っています!
内容は以下のとおりです。

  • メンバーの今日のタスク確認
  • 共有事項の確認
  • エラー確認
  • 気になったことの共有、議論

この中でも特徴的なのは、「気になったことの共有、議論」です。
第四開発Gでは、気になったことを書くSlackチャンネルがあります。
そのチャンネルに普段の業務で気になったことを書き込んでおくことで、第四開発G朝会のタイミングでメンバーのみんなに共有をしつつ議論することができます。
リモート故にちょっとしたことを話しづらい、という問題を解消しています!

画像は実際に私が書き込んだものです!
「グルメバイトちゃん」で使用している特定の色について、微妙に異なる2色が混在しており、統一されていなかったので、影響範囲を確認つつ「どのタイミングでどう対応をすべきか」について話し合いました!
実際に私が書き込んだもの

雑談会

第四開発Gは、火曜日と金曜日に雑談会を開催しています!
メンバーからランダムに選ばれた3人で15分間雑談をします。
休日の過ごし方や、最近ハマっていることなどのプライベートな話から、時事的な話まで話題は様々です。
フルリモートでも、こういった雑談会をとおしてメンバーと交流を深められています!

お昼休憩

リモートワークなので、お昼休憩にちょっとした家事をする方も多いそうです。
私はお皿洗いをしています。

第四開発G振り返り

週に一回、第四開発Gでの振り返りを行っています!
チームで決める必要があることを話し合ったり、第四開発G朝会では時間が足りなくて議論しきれなかった議題などについて話し合います。
また、実装共有と言ってメンバーが開発した機能などの実装内容を共有することもあります。
どのような仕組みで実装しているかや、用いた技術、仕様について共有します。
気になった点や、わからなかったことは質問をして、他メンバーの開発内容も理解することを心がけています!

新卒振り返り

週に一回、新卒である私の振り返りを行っています!
この振り返りでは、私が一週間で行った開発について振り返っていきます。
私の他には新卒二年目のメンバー、チームリーダー、あと一人は月替りで他のメンバーが参加します!

実装の進め方だったり、技術的な話、一週間の振り返りをしてアドバイスをもらいます。
自分のうまくいかない部分を他のメンバーがどうやって対応しているかや、便利なツールの共有などをしています!
自分の開発の進め方や実装方法について色々な視点でアドバイスをもらえる機会があるのがとても成長に繋がっています。

例えば、以前は実装時の小さなミスが多かったのですが、「レビュー依頼の前だけじゃなくgitのコミット単位でもセルフレビューで確認をすると良いよ」とアドバイスをもらったことで小さなミスが大幅に減りました!

画像は開発部外の人とのやり取りで認識のズレがあったときの振り返りの議事録です。
先輩から対策についてのアドバイスをもらいました!
振り返りの議事録

また、KPTといって自分が一週間で成長できているかを確認する場もあります。
Keep(うまくできていて、続けて伸ばしたいこと)
Problem(うまくいかなかったこと)
Try(うまくいかなかったことを改善する方法)
の頭文字を取ったもので、振り返りで浮かんできた課題についてKPTを設定することが多いです。

画像は、開発部外の人とのやり取りで認識のズレがあったときのKPTの抜粋です。
他のメンバーがどう意識しているかアドバイスをもらえるので改善しやすいです!
KPTの抜粋

退勤

急ぎのタスクがないかを確認して、退勤します!
リモートワークなので、退勤後すぐにご飯を食べたりお風呂に入ることができます!

配属前後での心境の変化

配属直前や直後は、チームへの貢献度や自分の成長度合いなどに不安を抱いていました。しかし、新卒振り返りや、Slackでの相談しやすい環境があったり、今は焦らずいろんな知識を業務を通じて吸収していってほしいという言葉をもらって、今ではとても前向きに楽しく働いています!

結び

いかがでしたでしょうか?
今回は、シンクロ・フード第四開発Gに配属された新卒エンジニアの1日をご紹介しました。

配属当初は「リモートワークで質問できるかな?」「技術についていけるかな?」という不安もありましたが、
紹介したような「雑談会」や手厚い「振り返り」のおかげで、孤独を感じることなく楽しく開発できています。

シンクロ・フードには、年次に関係なく意見を言えるフラットな空気と、挑戦を後押ししてくれる環境があります。
私もまだまだ勉強中の身ですが、先輩たちの背中を追いかけて、早く一人前のエンジニアとして価値を届けられるよう頑張ります!

この記事を読んで、少しでもシンクロ・フードのエンジニアの働き方に興味を持っていただけたら嬉しいです。
最後までお読みいただき、ありがとうございました!

求人飲食店ドットコムの性能改善のために Datadog APM を導入しました

はじめまして、開発部の熊谷です。
今回は弊社のサービスである求人飲食店ドットコムの性能改善をするため、Datadog の Application Performance Monitoring(以下APM)を導入した際の話をしたいと思います。

docs.datadoghq.com

なぜAPMを導入したか

ここ数年、当チームでは開発・保守を担当している「求人飲食店ドットコム」の性能改善に対する関心が高まっていました。
主な動機は、Core Web Vitals の各指標を向上させることで検索順位やユーザー体験を改善したいという点にあります。
しかし、フロントエンドには PageSpeed Insights のような手軽な計測ツールがある一方、サーバーサイドのレスポンスタイムについては具体的なボトルネックを特定するためのモニタリング環境が整っていない状態でした。

そこで1年ほど前、開発プロセスの中で性能劣化を防ぐためローカル環境(各エンジニアの開発機)に rack-mini-profiler という Rails 用のプロファイラを導入しました。
github.com このツールは Controller や View ごとに発行されたSQLのクエリ数や実行時間を可視化できるため、これを使い新規開発や機能改修を行った際のレビュー前に「意図せぬクエリ増加(N+1問題など)が起きていないか」や「意図して追加した処理の実行時間が許容範囲内か」を開発者自身がチェックするフローを確立しました。
この取り組みによって明らかなパフォーマンス劣化をリリース前に検知できるようにはなりましたが、運用を続ける中で新たな課題も見えてきました。

最大の課題は、ローカル環境での最適化が必ずしも本番環境のパフォーマンス改善に直結しないという点です。
ローカル環境はWebサーバー、アプリケーションサーバー、DBや全文検索エンジンなどの各種サービスが単一のマシン内で完結するオールインワン構成ですが、本番環境では各サービスがネットワークを通じて連携しており通信レイテンシが発生します。また、サーバーのスペック差に加えて Rails の設定(config.eager_load など)も異なるため、同一のコードであっても処理時間の内訳やボトルネックとなる箇所が本番とは異なる可能性が高いという状態にあります。

また、運用の手間という観点でも課題がありました。rack-mini-profiler は詳細なプロファイリングには適していますが、あくまで単発のリクエストを分析するためのツールです。サービス全体を俯瞰出来るようなサマライズ機能もなく、蓄積されたデータを使った時系列での傾向把握なども出来ないため継続的なパフォーマンス監視として使うには不向きで、そこを人手でカバーするのは工数的に現実的ではないというのが問題でした。

APM導入

上述の背景から、本番環境の実態を正確に把握し継続的な改善をしていくための仕組みとして Datadog APM の導入を決定しました。
弊社ではすでにSREチームが各種インフラリソースの監視目的で運用している実績があり、アカウントの作成や Datadog エージェントの導入は済んでいる状態だったため、求人飲食店ドットコムへの導入はスムーズに進みました。
導入に関して Web アプリケーション側で必要だったのは Datadog エージェントへトレース情報を送信するための gem のインストールと、それに伴う十数行程度の設定のみでした。

ただしトレース情報を採取する頻度(サンプリングレート)の設定には注意を払いました。APM のトレース情報はスパンと呼ばれる単位で採取されますが、採取したスパンはその数と容量が一定数を超えると基本料金+超過分の従量課金となる料金体系であるため、サイトに対するリクエストすべてのスパンを採取していると高額になる恐れがあります。
そのためサイトへの平均リクエスト数などを元に基本料金内に収まるスパン数を事前に試算し、まずはリクエスト全体のうち10%のみのスパンを保存するように設定しました。念の為保存しているスパンの量をモニタリングしながら運用していますが、今のところ超過の傾向はないうえ分析に十分なサンプル数は確保できているのでこのレートで運用を続ける予定です。

APMの活用

まず求人飲食店ドットコムの中で特に重要ないくつかのページを対象として、それらに対するリクエストのレイテンシを時系列の折れ線グラフで表示するダッシュボードを作成し、毎週末に実施している振り返りのミーティングで前週のグラフと比較して明らかな劣化がないかを確認するという時間を試験的に設けてみました。

それからしばらくしたある週のミーティングで、サイト上で掲載している求人の詳細情報を表示するページのレイテンシがある日の本番リリースの後から定常的に悪化していることに気づきました。
緑の線のタイミング(本番リリース)を境に、前週のレイテンシ(赤の点線)と比較して当該週のレイテンシ(青の実線)のベースラインが明確に増加していることが一目で分かるかと思います。

ここまでなら Datadog APM ほど高機能なツールを使わずとも「パフォーマンス監視を定期的に行う」というルールを決めるだけでも実現できたかもしれませんが、ここからさらに Datadog APM の別の機能を使うことで原因となった開発要件の特定まで迅速に行うことが出来ました。
Datadog APM にはサーバー側の処理の総実行時間を処理の種類ごとに可視化する機能があります。それを使用して下記のように実行時間の合計を積み上げ面グラフとして表示することで、本番リリースのタイミングから全文検索エンジンである OpenSearch の処理(一番下の水色の層)が新たに増えていることが一目で分かり、それが手がかりとなって原因となった開発要件を短時間で特定することが出来ました。

運用の工夫

しかしこの運用では目視によるチェックが必要かつ定量的で明確な基準がないので、運用コストと属人性の観点でもう少し改良が必要だと感じました。そこでAPMモニターという機能を使い、異常検知アラートを設定することを試みました。

docs.datadoghq.com

異常検知アラートとは、蓄積された過去のメトリクスデータをもとに機械学習アルゴリズムが「予測される変動幅」を動的に算出し、その範囲を逸脱した場合にアラートを上げる機能です。
固定の閾値を設定する方式とは異なり時間帯や曜日ごとの傾向も考慮されるため、例えば「夜間はアクセスが減るが、日中は増える」といったサイト毎の特性も加味した柔軟な検知が出来る点が強力です。
下記の画像は動作確認時に発生させたときの通知内容ですが、現在ではこのように自動でSlackに通知が来るように設定をしているため即時に異常に気付けるような状態になっています。

今後について

本記事では、APM の導入からダッシュボードによる可視化、そして APM モニターによる異常の自動通知までを紹介しました。これにより、チームメンバー全員がフロントエンド・バックエンド問わずシステムの性能を定量的に把握出来る状態になったので、実際に性能改善活動を進めるための準備が整ったと言えます。

次のステップとして、Datadog Continuous Profiler の活用を進めています。

docs.datadoghq.com

APM がリクエストの流れを追うのに対し、Continuous Profiler はプログラム実行中の CPU やメモリの使用状況をメソッドや行単位で記録する機能です。これらを可視化したフレームグラフを読み解くことで、処理遅延の原因であるコード箇所をピンポイントで特定できるようになります。
現在は、実際にフレームグラフの分析手法を学習しながら、特定のエンドポイントのボトルネック改善にトライしている最中です。具体的な改善事例や成果を出すことが出来たら、また別の記事として共有したいと思います。

新卒研修でシステムを開発して苦労したことと得られた知見

初めまして、開発部2025年度新卒の清水です。

シンクロ・フードの新卒エンジニア研修ではWeb開発技術の基礎からシステムの設計・開発までがカリキュラムとして組み込まれています。今回はシステムの設計・開発を学ぶ「システム設計研修」で私が開発を担当したテーマ「プロンプトシェア」を題材にして、主に苦労した点とそこから得られた知見を紹介します。

弊社の新卒研修については、以下の記事で詳細に紹介されています。

tech.synchro-food.co.jp

「プロンプトシェア」開発の背景

弊社の新卒研修における「システム設計研修」は、発注者(現場のメンバー)からの要求に応え、仕様をすり合わせながらシステムを開発するという実務に近い形式で進められます。私が開発を担当したテーマは「プロンプトシェア」でした。

このテーマが生まれた背景として、弊社ではAIの利用を推進しており全従業員が Google「Gemini」の有料版を利用可能となっていますが、各自のプロンプトがSlackやスプレッドシート等に散らばっていて、せっかくの知見が埋もれてしまっているという課題がありました。

この課題を解決し組織全体でのAI活用を加速させるため、設計から開発までを約1ヶ月で行う「システム設計研修」の題材の一つとして、プロンプトを社内で横断的に探せる「プロンプトシェア」システムを開発することにしました。

プロンプトシェアの主な仕様

「プロンプトシェア」は、以下の機能を備えた社内向けプロンプト共有システムです。

1. プロンプトの管理・閲覧

プロンプト

各プロンプトはタイトル、プロンプトの説明、プロンプトの本文、タグの情報を持っており、ユーザーは作成・閲覧・編集・削除をすることができます。

プロンプト一覧

登録されているすべてのプロンプトを一覧表示します。
お気に入り数・作成日・更新日でソートできます。 プロンプト一覧

プロンプト詳細

各プロンプトのタイトル・説明・プロンプト本文を閲覧できます。プロンプト本文はワンクリックでクリップボードにコピー可能です。
タグ名のボタンを押下すると、同じタグが含まれたプロンプトの一覧画面に遷移します。 プロンプト詳細

プロンプト登録・編集・削除

新規プロンプトの登録や、既存プロンプトの編集や削除が可能です。

2. 検索機能

作成者、タグでの絞り込みと、フリーワードでの検索が可能です。

3. お気に入り機能

ユーザーは気に入ったプロンプトをお気に入りに登録・解除でき、自分だけのお気に入りプロンプト一覧を閲覧することができます。
また、直近30日間のお気に入り獲得数ランキングも表示でき、よく使われているプロンプトを素早く確認することができます。

開発で苦労した点と得られた知見

実際にシステムを一から開発しようとすると、想定外の壁にぶつかることが多々ありました。
私が苦労した点について、問題とその解決策、得られた知見を2点紹介していきます。

工数見積の甘さ

システム設計研修では開発期間とリリース日があらかじめ定められており、期日に間に合うことが求められます。私も、リリースに余裕を持って間に合うように機能の実装工数を見積もり、スケジュールを設定していました。

開発中盤までは、機能単位では見積もりより時間がかかった箇所もあるものの全体としてはバッファで消化でき、概ねスケジュール通りに進めることができました。しかし、終盤のデザインや機能の修正事項が多く、リリース当日もデザインの修正作業を行うこととなり、予定のリリース時刻に少々遅れてしまいました。

全体実装後の発注者との受け入れテスト・テスト後修正・再テストのやり取りを1往復と見積もっていましたが、実際は4往復行うこととなり、想定よりも修正箇所や回数が多くなったため、実装後の確認期間や修正期間をより長く見積もった方が良かったと感じました。

得られた知見

予想工数との乖離があった箇所については見直していきたいと思います。
また、開発にかかる時間以外にも、コミュニケーションにかかる時間や、開発後に修正が複数回入る可能性があることも考慮してスケジュールを立てることの重要性を学びました。チーム配属後も工数を見積もって開発を行う機会はあるので、繰り返しながら見積もりの精度を上げていきたいと思います。

gitの操作ミス

リモートにpush済のコミットのコミットメッセージがしっくり来なかったので、変更しようと思い方法をwebで調べました。調べたところ、 git commit --amend コマンドを使用してコミットメッセージを変更した後、 force push する方法を見つけたためこれを実行しました。

しかし、なぜかうまくいかずにエラーが発生し、場当たり的に色々なコマンドを試し続けるうちに、作業内容が消えブランチを切った時の状態に戻ってしまいました。この結果、手戻りが発生してしまいました。

得られた知見

この失敗から、コミットの書き換えに関して以下の点を学びました。

1. 強制プッシュは履歴を書き換えるものということ

git commit --amendgit rebase は、新しいコミットを作って古いコミットを「なかったこと」にするコマンドです。このような履歴を書き換える操作をリモート共有ブランチで行う際には最新の注意が必要です。そのブランチで作業している人が他にいないか、既にレビューが完了していないかを確認した上、どうしても必要な場合にのみ使用するようにします。

2. 履歴を破壊しない別の方法もあること

「コミットメッセージのtypoを直したい」などの場合は以下のような方法があることも学びました。

  • 新しいコミットを追加して修正する(git revert) 履歴を書き換えずに、過去のコミットを取り消すコミットを追加します。コミット履歴には操作の記録が残りますが、安全に元に戻せます。
  • 新しいブランチを切って修正をプッシュする 元ブランチはそのままにして、新しいブランチに修正内容をプッシュし、プルリクエストを出し直します。

また、ブランチの変更を消してしまったとき、当時は同じ作業を繰り返して復旧しましたが、 git reflog コマンドで履歴を確認し git resetの引数に指定することで、その作業を行っていた時の状態に戻す手段があることも後から学びました。

これからは、Gitのコマンドを実行する前に、そのコマンドがGitの木構造にどのような影響を与えるかをイメージすることを常に意識して作業に取り組んでいきます。この経験を通して、ただコマンドを覚えるだけでなく、その背景にある概念を理解することの重要性を感じました。

おわりに

このシステム設計研修は、3ヶ月間の研修で得た知識を形にするよい機会となりました。

「プロンプトシェア」システムは2025年7月より運用を開始し、2026年3月現在約450件のプロンプトが保存されています。AIに指示する際にプロンプトを組む手間を削減し、業務効率化に役立っています。
この経験を活かし、今後はより複雑な課題にも挑戦しチーム開発に貢献できるよう頑張ります。

最後までお読みいただき、ありがとうございました。

下書き機能とマイソク一括アップロードで物件登録業務をさらに効率化した話

こんにちは。開発部の宮城です。

今回は、弊社のサービスである「飲食店ドットコム 店舗物件探し」において、物件登録に関わる業務を効率化するために開発した機能についてお話しします。

以前のブログ記事「生成AIを利用して物件登録にかかる時間を大幅削減した話」では、マイソク(物件情報をまとめた資料)の画像をアップロードし、OCRと生成AIで物件情報を自動抽出する機能について紹介しました。今回はその後続として、下書き機能マイソクの一括アップロード機能の2つを紹介します。

物件登録補助機能の振り返り

まず、以前のブログ記事で紹介した物件登録補助機能を簡単に振り返ります。

物件の登録は入力項目が多く、不動産会社の担当者にとって手間と時間がかかる作業でした。そこで、マイソクをアップロードするだけで、Cloud Vision APIによるOCR処理とOpenAIのGPT-4oによる情報抽出を組み合わせ、フォームに自動入力する機能を開発しました。

この機能によって、半数以上の利用者が物件登録の際に3分以上の短縮を実感するなど、一定の成果を得ることができました。

この物件登録補助機能は不動産会社向けに提供したものですが、社内の事業部向け管理画面にも同様のマイソク読み取り機能を展開しました。当事業部では、不動産会社様とエンドユーザー様を繋ぐカスタマーサポートを行っており、パートナー制度で提携いただいている企業様に向けて、業務負荷の軽減や掲載スピード向上、成約の最大化を目的とした物件登録のサポート業務を担っています。
しかし、実際に機能を提供してみると、事業部特有の業務フローにはまだカバーしきれない課題があることが分かりました。今回は、その課題を解決するために追加で開発した2つの機能を紹介します。

下書き機能

業務的な背景

弊社の事業部では、物件を登録する際にダブルチェック(二重確認)を行う運用フローがあります。しかし、これまでは登録内容を一時保存する仕組みがなかったため、登録作業中は途中保存ができず、一度登録を完了する必要がありました。そのため、登録作業を段階的に進めることが難しく、作業の進め方に制約がありました。
そこで、登録フォームの入力内容を「下書き」として保存し、確認後に本登録できる機能を追加しました。

技術的に工夫したポイント

フォームオブジェクトの属性をJSONでまるごと保存する

下書き機能の実装において、最も特徴的なのは登録フォームの入力内容をJSON形式でそのままデータベースに保存するという設計です。

物件登録のフォームには50項目以上の入力フィールドがあります。これらの項目ごとに下書き用のカラムを用意するのは、スキーマの管理が煩雑になりますし、登録フォームに項目が追加されるたびに下書き用のテーブルも修正する必要が生じます。

そこで、Railsのフォームオブジェクトの属性をまるごとJSONとしてシリアライズし、1つのカラムに保存するアプローチを採りました。

def save_as_draft!
  draft = Draft.find_by(id: draft_id) || Draft.new

  # フォームオブジェクトの全属性をJSONに変換して保存
  draft.register_form_json = JSON.parse(to_json)
                                 .except('error_attributes',
                                         'draft_id')  # 内部状態やIDは除外
  draft.save!
end

to_jsonでフォームオブジェクトの全属性をシリアライズし、exceptで不要な属性(バリデーション用の内部状態など)を除外しています。これにより、登録フォームに新しい項目が追加されても、下書きテーブルの変更は不要になっています。また、JSONカラムに保存することで、バリデーションをスキップした状態での保存が容易になるというメリットもあります。例えば、数値項目に日本語が入力されているような途中状態でも、そのまま下書きとして保存できます。

下書きから本登録へのライフサイクル

下書きから本登録を行う際には、物件の登録処理と下書きの削除を1つのトランザクション内で行います。

def save!
  ApplicationRecord.transaction do
    # ... 物件登録処理 ...

    # 下書きから登録した場合は下書きを削除する
    draft = Draft.find_by(id: draft_id)
    if draft.present?
      draft.bukken = bukken  # 登録された物件との紐付けを保持
      draft.save!
      draft.destroy!         # 論理削除(acts_as_paranoid)
    end
  end
end

下書き物件ではacts_as_paranoidによる論理削除を採用しているため、下書きから登録された物件の履歴を追跡することも可能になっています。

マイソクの一括アップロード機能

業務的な背景

パートナー制度の開始

2025年4月より、弊社では不動産会社向けの「パートナー制度」を開始しました。これは、不動産会社にて行っていただいていた物件登録作業を、弊社の事業部が無料で代行する制度です。

この制度の開始に伴い、事業部が一度に大量の物件を登録する必要が出てきました。以前のブログ記事で紹介した物件登録補助機能は単体のマイソクアップロードにしか対応しておらず、マイソクアップロード→読み取り待ち→入力確認を1件ずつ繰り返す必要がありました。数十件の物件を登録する場合、この繰り返しだけでもかなりの時間がかかります。

さらに、飲食店ドットコムでは取り扱う不動産会社が異なるだけの住所が重複した物件は掲載しない仕様になっています。そのため、マイソクをアップロードして住所を抽出し、重複バリデーションにかけた結果、重複物件だと判明して登録できないケースも多く、1件ずつ試しては次へ進むという非効率な作業が発生していました。

そこで、複数のマイソクを一括でアップロードし、まとめてOCRと生成AIによる読み取り処理を行い、下書き物件として一括登録できる機能を開発しました。

処理全体の流れ

一括アップロード機能の処理は、大きく3つのフェーズに分かれます。以下のシーケンス図で全体像を示します。

sequenceDiagram
    actor User as ユーザー(ブラウザ)
    participant Rails as Rails サーバー
    participant S3 as Amazon S3
    participant Sidekiq as Sidekiq Worker
    participant Vision as Cloud Vision API
    participant OpenAI as OpenAI API

    rect rgb(232, 245, 253)
    Note over User, S3: フェーズ1: アップロード
    User ->> Rails: Presigned URL を取得
    Rails -->> User: Presigned URL を返却
    User ->> S3: マイソクを並列アップロード(最大20枚)
    Note right of S3: 一時ディレクトリに保存
    User ->> Rails: ファイルトークン + ファイル名を送信
    end

    rect rgb(232, 253, 235)
    Note over Rails, OpenAI: フェーズ2: 読み取り処理(非同期)
    Rails ->> Sidekiq: 一括読み取りジョブを起動
    loop マイソクごとに直列処理
        Sidekiq ->> S3: 一時ディレクトリ → 本番ディレクトリに移動
        Sidekiq ->> Vision: OCR 処理
        Vision -->> Sidekiq: テキストデータ
        Sidekiq ->> OpenAI: 物件情報の抽出
        OpenAI -->> Sidekiq: 抽出結果(JSON)
    end
    Sidekiq -->> Rails: 処理完了(ステータス更新)
    end

    rect rgb(253, 243, 226)
    Note over User, Rails: フェーズ3: 下書き登録
    User ->> Rails: 読み取り結果を確認
    User ->> Rails: 選択したマイソクの一括下書き登録
    Note right of Rails: フォームの属性を<br/>JSON として DB に保存
    end

技術的に工夫したポイント

S3 Presigned URLによるダイレクトアップロード

最大20枚のマイソクを扱うため、すべてのファイルをRailsサーバー経由でアップロードすると、サーバーへの負荷が大きくなります。そこで、S3のPresigned URLを使い、ブラウザからS3に直接アップロードする方式を採用しました。

async function uploadAllFiles(files) {
  // 最初に1回だけ Presigned URL を取得
  const presignedData = await getPresignedUrl(presignedUrlEndpoint)

  // 全ファイルを並列アップロード(同じPresigned URLを使用)
  const uploadPromises = files.map((file) => {
    if (file.size > MAX_FILE_SIZE) {
      return Promise.resolve({ success: false })
    }
    return uploadToS3(presignedData, file)
  })

  const results = await Promise.all(uploadPromises)
  uploadedFiles = results.filter((r) => r.success).map((r) => r.result)
}

ポイントは以下の通りです。

  • Presigned URLは1回だけ取得し、全ファイルで共有する。ファイルごとに crypto.randomUUID() でユニークなキー(ファイルトークン)を生成することで、同じPresigned URLでも異なるS3キーにアップロードできるようにしている
  • Promise.all による並列アップロードで、20枚のマイソクを効率的にアップロードする
  • Railsサーバーにはファイル本体ではなく、ファイルトークン(S3キー)とファイル名だけを送信する
function uploadToS3(presignedData, file) {
  return new Promise((resolve, reject) => {
    const extension = file.name.includes('.')
      ? file.name.substring(file.name.lastIndexOf('.'))
      : ''
    const fileToken = self.crypto.randomUUID() + extension

    const formData = new FormData()
    for (const key in presignedData.formData) {
      if (key === 'key') {
        // Presigned URLのkeyテンプレートにファイルトークンを埋め込む
        formData.append(key, presignedData.formData[key].replace('${filename}', fileToken))
      } else {
        formData.append(key, presignedData.formData[key])
      }
    }
    formData.append('file', file)

    const xhr = new XMLHttpRequest()
    xhr.open('POST', presignedData.url)
    xhr.send(formData)

    xhr.onreadystatechange = function () {
      if (xhr.readyState === 4) {
        resolve({ file_token: fileToken, file_name: file.name })
      }
    }
  })
}

これにより、ファイルのアップロード自体はRailsサーバーの負荷をほとんどかけずに完了します。

AI読み取り処理の直列実行

アップロード完了後、OCRと生成AIによる読み取り処理はSidekiqのバックグラウンドジョブとして実行します。

既存の単体マイソクアップロード機能でも同じAI読み取りワーカーを使用しており、このワーカーはSidekiq::Throttledで同時実行数が制限されています。一括アップロードで複数のマイソクを並列に読み取ろうとすると、この枠を占有してしまい、単体アップロード機能を同時に使っているユーザーが待たされてしまいます。

直列にすると処理時間が長くなる懸念がありますが、事前に検証したところ20枚でも2〜3分程度で完了し、Sidekiqのタイムアウトにも抵触しないことを確認できたため、一括アップロードジョブの中ではAI読み取り処理を直列で実行するようにしました。

class BulkUploadWorker
  include Sidekiq::Worker

  def perform(files, job_id)
    # マイソクごとにAI読み取り処理を直列で実行する
    files.each do |file_params|
      # 非同期ではなく同期的に呼び出すことで、同時実行枠を占有しない
      ExtractMaisokuWorker.new.perform(file_params['file_token'])
    end
  end
end

1件の失敗が全体を止めない設計

一括処理で重要なのは、1件の失敗が残りの処理をすべて止めてしまわないことです。20件中1件だけOCRに失敗したために残り19件が処理されないのでは、一括処理の意味がなくなります。

def process_file(file_token, file_name)
  maisoku = create_maisoku_record(file_token, file_name)

  begin
    extract_property_info(maisoku)
  rescue StandardError => e
    maisoku.fail!(message: 'ファイルから項目が読み取れませんでした。')

    # 失敗しても次の処理を続行するために例外は投げない
    # ただし、エラー監視サービスを通じて開発チームに通知する
    notify_error(e, maisoku)
  end
end

個々のマイソクの読み取り処理で例外が発生した場合は、そのマイソクを「失敗」状態にマークしつつ、次のファイルの処理に進みます。ユーザーには簡潔なエラーメッセージを表示し、詳細なエラー情報はエラー監視サービスを通じて開発チームに通知することで、問題に素早く気づける仕組みにしています。

下書き機能との連携

一括アップロードで読み取りが完了したマイソクは、前述の下書き機能と連携して一括で下書き物件として登録できます。

def draft_bulk_insert(maisoku_ids)
  maisokus = Maisoku.includes(:company).where(id: maisoku_ids)

  ApplicationRecord.transaction do
    maisokus.each do |maisoku|
      register_form = RegisterForm.new(
        company: maisoku.company,
        maisoku_id: maisoku.id,
      )
      register_form.save_as_draft!
    end
  end
end

ここで、先ほど紹介した save_as_draft! がそのまま再利用されています。一括アップロードで読み取った情報がフォームオブジェクト経由でJSONに変換され、下書きとして保存されます。このように、下書き機能を汎用的に設計しておいたことで、一括アップロード機能との連携がスムーズに実現できました。

事業部からの反応

リリースから1週間後に事業部へヒアリングを実施したところ、便利になった という声をいただくことができました。特に印象的だったフィードバックを紹介します。

  • 一番良かった点は 住所の重複チェックの穴埋め。一括アップロード機能の導入により、住所の重複チェックの漏れが減少し、一括での重複チェックも可能になった
  • 従来1件ずつ行っていた作業を一括で行えるようになり、待機時間が削減された
  • 作業時間は1回につき約30秒短縮。月500件の物件登録に換算すると、十分な作業時間削減に貢献できている
  • 下書き機能の追加により登録前の状態で入力内容を保存できるようになり、段階的に進められるようになったことで作業性が向上した
  • 下書き機能と一括アップロード機能と併用することで、物件データの読み込み後の状態から登録作業を開始できるようになり、登録作業の業務負荷は 軽くなった と実感。今後も使い続けたい

下書き機能の設計時に意識していた物件登録を段階的に進めることや、一括アップロード機能で目指していた待機時間の削減が、実際の業務現場でも効果を実感していただけた結果となりました。

まとめ

今回は、物件登録業務を効率化するために開発した「下書き機能」と「マイソクの一括アップロード機能」について紹介しました。

  • 下書き機能では、フォームオブジェクトの属性をJSONでまるごと保存するアプローチにより、項目の追加にも強い柔軟な設計を実現しました
  • 一括アップロード機能では、S3 Presigned URLによるダイレクトアップロード、Sidekiq Throttledによる並列数制御、エラー時の継続処理など、大量データを安定して処理するための工夫を盛り込みました
  • 両機能を組み合わせることで、「一括アップロード → OCRと生成AIによる読み取り → 下書き登録 → 確認 → 本登録」という一連の業務フローを効率化できました

事業部からも便利になった、負荷が軽くなったという声をいただけており、約4時間の作業時間削減に貢献できています。

以前のブログ記事で紹介した物件登録補助機能を起点に、業務の変化(パートナー制度の開始)に合わせて機能を拡張してきた事例として、少しでも参考になれば幸いです。

Claude Codeを活用したIT統制監査の証跡収集の効率化

シンクロ・フード開発部の小室です。

シンクロ・フードの開発部には、チームをまたいで特定テーマに取り組む「横串チーム」と呼ばれるプロジェクトチームの仕組みがあります。各チーム2週間に1度・2時間程度のペースでコツコツと改善活動に取り組んでおり、CI改善やマークアップの品質向上など、さまざまなテーマのチームが活動しています。IT統制チームもその1つで、輪読会を通じたIT統制の知識習得や、監査作業の効率化に取り組んでいます。

今回はこのIT統制チームでの取り組みとして、IT統制監査の対応の中で「証跡(エビデンス)収集」の作業を自動化した事例を紹介します。Webページのスクリーンショットを撮り、設計書をダウンロードし、所定のフォルダにアップロードする。1件1件は単純な作業ですが、件数が多いと地味に手間がかかります。この証跡収集作業をClaude Code(AIエージェント)とMCP(Model Context Protocol)を組み合わせて自動化しました。

IT統制監査と証跡収集

なぜ証跡収集が必要なのか

上場企業はJ-SOX法(金融商品取引法における内部統制報告制度)に基づき、内部統制の有効性について外部監査を受ける義務があります。このうちIT全般統制(ITGC)は、「システムの開発・保守」「システムの運用・管理」「アクセス管理」「外部委託管理」といった領域を対象とし、ITに関わるリスクが適切に管理されているかを評価するものです。

IT全般統制の監査では、大きく「整備評価」と「運用評価」の2段階があります。

整備評価は、リスクを低減するためのルールや仕組みが整備されているかを確認する段階です。たとえば、システム開発フローの規程が存在するか、本番データの変更に承認プロセスが定義されているかといった点を確認し、該当する規程やセキュリティ設定画面などのスクリーンショットを証跡として提出します。

運用評価は、整備されたルールが実際に守られているかを検証する段階です。評価対象期間中に実施された作業(開発案件や本番データ変更など)から母集団を特定し、そこからサンプルを抽出して、定められたプロセスに沿って運用されていたかを1件ずつ確認します。

サンプリングでは実在性・網羅性・期間帰属の適切性といった観点から適切に抽出する必要があり、現状は人の手で行っています。抽出した対象はExcelファイルにまとめ、これをもとに証跡を収集していきます。

たとえば、ある開発案件について「コードレビューが実施されていたか」「テストが完了しているか」「承認を経てリリースされたか」を確認するために、案件管理画面のスクリーンショット、設計書、Pull Requestのレビュー記録などを収集し、監査法人に提出します。

つまり、監査対応にはそれなりの数の証跡を正確に収集・整理する作業が必要になります。

証跡収集の課題

収集対象は多岐にわたります。

  • 社内規程類のスクリーンショット
  • 案件管理画面のスクリーンショット
  • 設計書(Google Slides等)
  • Pull Requestのスクリーンショット
  • 本番データ変更管理画面のスクリーンショット

これらを1件ずつ「ページを開く → スクリーンショットを撮る → 所定のフォルダにアップロードする」という手順で処理していきます。単純作業の繰り返しですし、手作業では以下のようなリスクもあります。

  • スクリーンショットの撮り忘れ
  • ファイル名の付け間違い
  • アップロード先フォルダの間違い
  • 撮影時刻の記録漏れ

この定型的な作業を効率化できないか、というのが今回の取り組みのきっかけです。

Claude Code + MCPによる自動化

Claude CodeとMCPについて

今回の自動化で中心となっているのがClaude CodeとMCPです。

Claude Codeは、Anthropicが提供するAIエージェントツールです。ターミナル上で自然言語の指示を与えると、ファイルの読み書きやコマンドの実行を自律的に行ってくれます。たとえば「このExcelを解析して対象の案件を一覧にして」と指示すれば、適切なスクリプトを実行して結果を返してくれる、というイメージです。

MCP(Model Context Protocol)は、Claude Codeのようなツールに外部の機能を追加するための仕組みです。MCPに対応した「サーバー」を接続することで、Claude Code単体ではできない操作が可能になります。

今回はChrome DevTools MCPというMCPサーバーを利用しています。これを接続することで、Claude Codeからブラウザを直接操作できるようになります。ページを開く、JavaScriptを実行する、スクリーンショットを撮る、といった操作をAIが自動で行えるわけです。

全体としては、Claude Codeを中心に、ブラウザ操作はChrome DevTools MCP経由で、Excel解析やGoogle Driveへのアップロードといったファイル処理はPythonスクリプト+Docker経由で実行する構成です。

AIへの指示から証跡がGoogle Driveにアップロードされるまでの流れは、以下のとおりです。

sequenceDiagram
    actor User as 作業者
    participant CC as Claude Code
    participant MCP as Chrome DevTools<br/>MCP
    participant Py as Pythonスクリプト<br/>(Docker)
    participant GD as Google Drive

    User->>CC: スラッシュコマンド実行
    CC->>Py: サンプリング結果のExcel解析
    Py-->>CC: 収集対象の情報
    loop 対象ごとに繰り返し
        CC->>MCP: ページ表示・スクリーンショット取得
        CC->>Py: 画像結合(ImageMagick)
        opt 設計書リンクあり
            CC->>Py: 設計書ダウンロード
        end
    end
    CC->>Py: Google Driveへアップロード
    Py->>GD: 証跡ファイル

具体的な実装

CLAUDE.mdとスキルによる作業手順の定義

今回のツールでは、Claude Codeの2つの仕組みを使って作業手順を定義しています。

CLAUDE.mdは、リポジトリ内に配置するとClaude Codeが自動的に読み込むマークダウンファイルです。ここにはスクリーンショットの撮影方式やGoogle Driveへのアップロード手順など、証跡収集全体に共通する作業手順を記述しています。

スキルは、よく使う作業フローをスラッシュコマンドとして定義する機能です。プロジェクトの .claude/skills/ ディレクトリ配下にスキルごとのディレクトリを作成し、その中にスキル定義ファイル(SKILL.md)を配置すると、Claude Code上で /スキル名 と入力するだけで定義済みの作業を開始できます。統制手続きの種類ごとに異なる部分(対象のExcel解析方法やファイル命名規則など)はスキルとして分離しています。

つまり、共通手順は CLAUDE.md に集約し、個別の手順はスキルに分けるという構成です。今回は以下のスキルを作成しました。

/gather-evidence-dev:開発案件の証跡収集

ソフトウェアの開発・変更手続きに関する統制の証跡を収集するスキルです。以下の一連の作業を自動で実行します。

  • サンプリング結果のExcelファイルから収集対象の案件情報を読み取り
  • 案件管理画面のスクリーンショットを取得
  • 設計書(Google Slides)をダウンロード
  • テスト記録ページのスクリーンショットを取得
  • 案件の分類に応じてサブフォルダを分けてGoogle Driveにアップロード

/gather-evidence-data-change:本番データ変更の証跡収集

システム運用に関する統制の証跡を収集するスキルです。

  • サンプリング結果のExcelファイルから収集対象のデータ変更情報を読み取り
  • データ更新依頼の詳細画面のスクリーンショットを取得
  • GitHub上のPull Requestレビュー記録のスクリーンショットを取得

Pull Requestページのスクリーンショットでは、折りたたまれたコメントを自動で展開したり、相対日時(「3 months ago」等)を絶対日時に変換するJavaScriptを実行してから撮影するなど、証跡として必要な情報を漏れなく記録する工夫もスキル内に組み込んでいます。

// GitHub Pull Requestの相対日時を絶対日時に変換するスクリプト
() => {
  const nodes = [
    ...document.getElementsByTagName("relative-time"),
    ...document.getElementsByTagName("time-ago"),
  ];
  nodes.forEach((node) => {
    const datetimeAttr = node.getAttribute("datetime");
    if (!datetimeAttr) return;
    const date = new Date(datetimeAttr);
    const datetimeStr = date.toLocaleString("ja-JP", { timeZone: "JST" });
    node.innerHTML = datetimeStr;
    if (node.shadowRoot) {
      node.shadowRoot.innerHTML = datetimeStr;
    }
  });
  return "datetime converted: " + nodes.length + " elements";
};

いずれのスキルも、ターミナルでスラッシュコマンドを実行するだけで一連の証跡収集が開始されます。

スクリーンショット取得の工夫:分割キャプチャ方式

証跡のスクリーンショットは、ページ全体を1枚の画像として保存する必要があります。Chrome DevTools MCPには fullPage オプションによる一発撮影の機能がありますが、iframe内のコンテンツ(社内サイトに埋め込まれた表など)がビューポート外でレンダリングされず空白になる問題がありました。また、Chromeには高さが16384pxを超えると正常にキャプチャを取得できない制限があり、長いページではこちらも問題になります。viewportをページ全体のサイズに設定する方式も試しましたが、同じ制限に該当するため対応できませんでした。

最終的に採用したのが 分割キャプチャ方式です。ページを一定の高さごとに分割し、各セグメントにスクロールしてからスクリーンショットを撮り、Docker上のImageMagickで1枚の画像に結合します。

lazy loading画像がスクリーンショットに含まれない問題はどの方式でも発生するため、事前にページ全体をスクロールして画像の読み込みをトリガーする処理を入れています。分割キャプチャ方式では、これに加えてスクロールに伴うヘッダー等のfixed/sticky要素の重複防止も必要になります。こうした対処の手順もすべて CLAUDE.md に含めています。

証跡としての信頼性を担保するため、撮影前にJavaScriptで撮影日時のオーバーレイをページ上に挿入する処理も組み込んでいます。

Google Driveへの自動アップロード

スクリーンショットの取得後、Google Driveへのアップロードも自動化しています。各証跡フォルダ内に設定ファイル(.drive_config.json)を配置し、アップロード先のGoogle DriveフォルダIDを管理しています。この設定ファイルはスキルの実行時にClaude Codeが自動で生成します。監査作業の管理Excelからアップロード先のフォルダIDを読み取り、証跡フォルダの作成と同時に .drive_config.json を配置する流れです。

導入の効果

このツールの導入により、以下の改善が得られました。

  • 作業の効率化:1つの統制手続きの証跡収集(複数件のスクリーンショット取得からアップロードまで)を、AIへの1回の指示で完結できるようになった
  • ヒューマンエラーのリスク低減:ファイル名の付与やアップロード先の振り分けといった細かい作業をAIが処理し、人間は最終確認に集中できるようになった
  • 属人化の解消:作業手順がコードとして定義されており、スラッシュコマンド1つで実行できるため、担当者が変わっても同じ品質で証跡を収集できる

まとめ

IT統制監査の証跡収集は、正確さが求められる一方で作業内容は定型的であり、AIエージェントによる自動化と相性のよい業務です。

今回の取り組みのポイントを整理すると、以下のとおりです。

  1. Claude Code + Chrome DevTools MCPで、ブラウザ操作を含む証跡収集を自動化
  2. CLAUDE.mdとスキルで作業手順を定義し、再現性と保守性を確保

AIエージェントの活用というと大がかりな仕組みを想像するかもしれませんが、今回のケースではPythonスクリプトとMCPを組み合わせることで、比較的シンプルに実現できました。また、これらのPythonスクリプトやCLAUDE.md、スキル定義といったツール自体の構築にもClaude Codeを活用しています。やりたいことを自然言語で伝えながらコードや手順書を生成・修正していくことで、ツールの開発も効率的に進められました。

今回は証跡収集の自動化に取り組みましたが、今後、他の作業についてもClaude Codeを活用して効率化を検討していきたいと思っています。

定型的な業務の自動化を検討されている方の参考になれば幸いです。