OpenAICloudflare2026/06/12 13:00

Scaling Security Insights: how we achieved a 10x increase in global scanning capacity

要点だけを先に読めるように短く再構成したセクションです。

元記事

Quick Digest

要約

要点だけを先に読めるように短く再構成したセクションです。

openaijamodel: gpt-5-mini-2025-08-07

Security Insightsのスケーリング:グローバルスキャン能力を10倍にした手法

Key Points

  • Kafkaの並列消費で処理10x向上
  • UNNEST/COPYハイブリッドでDB書き込み最適化
  • スケジューラをゾーン単位と適応レートで均等化

Summary

Security Insightsのスキャン頻度と対象を拡大するため、スループットを約10倍(10→100/s目標)に引き上げる改修を行いました。対策はKafka消費の並列化とレーン分離、Postgresへの一括挿入戦略、APIのレイテンシ対策、スケジューラ改修(ゾーン単位スケジュール化・ランダム化・適応型レートリミット)を中心に実施。結果、ピークで120+ scans/s を安定達成し、全フリープランの自動スキャンと全顧客のスキャン頻度向上を実現しました。

Key Points

  • Kafkaとコンシューマ
    • パーティション内の順序性を保ちつつ、メッセージをバッチで取得して各メッセージを個別のgoroutineで処理し並列化。
    • head-of-lineブロッキング対策としてチェックャを「fast lane / slow lane」に分離し、遅いメッセージが高速処理を阻害しないようにした。
  • DB書き込みの最適化
    • 1件ずつのINSERTを避ける。小〜中量はUNNEST、大量はCOPYを使うハイブリッド戦略で高速挿入とシステムテーブル肥大の抑制を両立。
  • APIレイテンシと接続プール
    • APIをactive-passiveにしてアクティブをプライマリDBのリージョンに合わせ、遠隔リージョンからの往復レイテンシで接続プールを枯渇させる問題を解消。
  • スケジューラ改修
    • ゾーン単位でlast_scheduled_atを持たせ、既存のタイムスタンプはランダム化して負荷の偏りを解消。
    • 適応型レートリミッタを導入し(半時間ごとにアカウント/ゾーン数と頻度から再計算)、スケール変更時の一括トリガーによるスパイクを防止。
  • 成果
    • 7日移動平均でチェックあたりスループットが10x以上向上。ピークで120/s超、APIタイムアウト解消、Kafkaラグの改善を確認。

Practical recommendations

  • まずKafkaラグ、API往復レイテンシ、DB往復回数を計測してボトルネックを特定する。
  • Kafkaパーティションを増やす前に、コンシューマ側のバッチ並列化と遅延メッセージのレーン分離を試す。
  • 大量データの挿入はUNNEST/COPYのハイブリッド戦略を検討し、COPYでのシステムテーブル肥大を監視する。
  • APIは可能な範囲でDBの近傍にアクティブを固定し、接続プール/タイムアウト設定を見直す。
  • スケジューラは個別単位でのスケジュール管理・ランダム化・適応レート制御で均等化すること。

Full Translation

翻訳

原文の流れを保ったまま読める翻訳セクションです。

openaijamodel: gpt-5-mini-2025-08-07

Security Insightsのスケーリング:グローバルなスキャン能力を10倍にした方法

Security Insightsは、すべてのCloudflareアカウントに対して実行可能なセキュリティ推奨を提供します。これらのインサイトを検出するために、すべてのアカウント、ゾーン、およびDNSレコードを定期的にスキャンし、潜在的なセキュリティリスクや設定ミスを探しています。しかし、2つの主要な課題が明らかになりました。まずスキャン頻度が低すぎたことです。スキャンは週に1回か2回しか行われておらず、新たに導入されたセキュリティリスクが最大で2週間検出されない可能性がありました。次に、自動スキャンが多くの無料プランアカウントでオプトイン式だったため、多数のアカウントがまったくスキャンされていませんでした。自動化攻撃が加速する中で、スキャンが稀または存在しないことのリスクは高まっています。すべてのお客様に対してこれらの問題を確実に発見することは、「より良いインターネットを構築する」という我々の目標にとって非常に重要です。

我々は、スキャン頻度を上げ、すべてのアカウントで自動スキャンを有効にするには、平均してスキャンスループットを約10倍に増やす必要があると算出しました — 10 scans/秒 から 100 scans/秒へ。しかし、当時のシステムは既に負荷で苦しんでおり、数百万件のイベントがバックログに溜まり、APIは頻繁にタイムアウトし、プロセスがクラッシュしていました。システムを修正し、スケールさせる必要がありました。

以下は、Security Insightsのスキャンスループットを10倍以上に引き上げ、数百万の顧客に対してセキュリティインサイトの自動化を有効にし、すべての顧客のスキャン頻度を倍増させた取り組みの記録です。

どのようにセキュリティインサイトをスキャンしているか

高レベルでは、自動スキャンはスケジューラによってトリガーされます。アカウントやゾーンがスキャン対象になると、スケジューラはApache Kafkaにメッセージ(または複数のメッセージ)を公開します。これらのメッセージは複数のチェッカー(特定のアセットや設定をスキャンする専門のGoマイクロサービス)にファンアウトします。各メッセージについて、各チェッカーは検出した結果(セキュリティインサイト)を内部APIに送信し、内部APIはそれらをPostgresデータベースに永続化します。

スケールさせるために行ったこと

Scaling Kafka

Apache Kafkaは厳密にはキューではなく、パーティション化されたイベントストリームです(最近ではキュー的なセマンティクスも得ています)。各パーティション内では、メッセージは順序通りに消費・処理されなければなりません。一般的なキューでは消費順序は保たれても処理は並び替えられることがあるのに対し、Kafkaではそうではありません。その結果、consumer group内の各パーティションに対してアクティブなコンシューマは1つだけしか持てません。これには2つの影響があります:

  • 処理に時間がかかるメッセージが次のメッセージへの進行をブロックする
  • 各チェッカーについて、作成できるコンシューマ数はパーティション数を上限とする(各チェッカーは独自のconsumer groupを持つ)

パーティションを増やしてスケールしようとすることも可能でしたが、Kafkaブローカー自体(多くの他サービスと共有している)のリソース使用量が増えるため、最終手段として残し、まずはコードとアーキテクチャの改善を目指しました。

並列処理の導入

メッセージは順序で消費しなければなりませんが、同時に複数のメッセージを消費することは妨げられません。チェッカーを変更して、メッセージをバッチで消費し、各メッセージを別々のgoroutineで処理するようにしました。トレードオフとしては、プロセスがバッチ途中でクラッシュした場合にやり直す作業が増えることと、メモリ使用量が少し増えることですが、我々のケースではこれらは許容できるものでした。

ヘッドオブライン(head-of-line)ブロッキングの回避

一部のチェッカーが処理するメッセージの中には、他より圧倒的に処理に時間がかかるものがあります。たとえば、あるアカウント/ゾーンが非常に多くのアセットを持っている場合などです。最悪の場合、平均が数秒またはミリ秒であるのに対し、そのようなメッセージは数分〜数時間かかることがあります。簡潔なアプローチとして、consumer groupとチェッカーを「slow lane」と「fast lane」の2つに分割しました。メッセージが速く処理できるか遅いかを素早く判定し、fast lane のチェッカーが遅いメッセージに出会った場合はそれをスキップするようにしました。これにより、遅いメッセージは専用のリソースで処理され最小限の遅延で済み、速いメッセージは高速度を維持できます。

データベースクエリの最適化

検出した各インサイトはPostgresに書き込まれます。これはチェッカーがインサイトのリストを渡す単一のAPIエンドポイントで処理されます。実装は当初次のようになっていました:

for _, issue := range issues {
    _, err = tx.Exec(ctx, `INSERT INTO table ... VALUES ($1, $2, ...) ON CONFLICT DO UPDATE ...`, ...)
    if err != nil {
        return err
    }
}

ご明察の通り、大きなインサイト集合ではこのコードはインサイトごとにデータベースへの往復を行い、最大で観測されたサイズ500,000の場合、単一のAPIコールで50万回の往復、クエリ、トランザクションが発生していました。最初はPostgresでのバルク挿入のゴールドスタンダードであるCOPYを一時テーブルに対して使ってみましたが、Postgresのシステムテーブルのbloat(膨張)を引き起こすことが分かりました。

最終的にハイブリッドアプローチを採用しました:

  • issuesの数が閾値未満の場合はUNNESTを使用
  • 閾値を超える場合はCOPYを使用

これにより、巨大なセットに対しては秒単位での挿入が可能になり、小さなセットに対してはミリ秒単位でのさらに高速な挿入が可能になりました。

APIタイムアウトの調査

スケールさせようとする中で、内部APIにいくつか奇妙な振る舞いが見られました:

  • 大量のリクエストがクライアント側のタイムアウトを引き起こす
  • 多くのチェッカーが単一のAPIコールに対して処理時間の20〜90%を費やしている
  • 多量のスキャンをトリガーすると、スループットが高めに始まりその後劣化する

これらの問題の根本原因は全てレイテンシでした。プライマリデータベースはポートランド(オレゴン)にありましたが、APIはポートランドとアムステルダムでactive-active構成で稼働していました。光速でもポートランドとアムステルダム間の往復レイテンシは約50msになります。そのため、アムステルダムのAPIインスタンスからのデータベースクエリははるかに時間がかかり、クライアント側のコネクションプールの接続を長時間占有してしまいました。大量のAPIリクエストが来ると、コネクションプールはすぐに枯渇し、空き接続を待つタイムアウトが発生していました。ポートランドでは平均APIコールが10msで完了したのに対し、アムステルダムではほぼ3秒かかっていました。

ではなぜメッセージスループットが低下したのでしょうか。各チェッカープロセスはKafkaストリームのパーティション群を割り当てられて消費します。APIはロードバランスされており、各プロセスは処理のライフサイクルを通じて一つのAPI接続を保持します。その結果、あるプロセスはアムステルダムのAPIに接続し、別のプロセスはポートランドのAPIに接続することになりました。ポートランドに紐づくパーティションは速やかに処理されましたが、アムステルダムに紐づくものは遅延し、結果としてKafkaのlagがパーティションごとに偏ってしまいました。ロードバランサがトラフィックを均等に分配していたため、この問題は顕著になりました。

簡単な対処として、APIをactive-passiveに切り替え、アクティブなAPIがプライマリデータベースに従うようにしました。これによりレイテンシ問題は一夜で解消しました。

スケジューラの再設計

Kafkaをスケールし、データベースクエリを最適化し、APIの問題を修正しましたが、まだ解決すべき問題が残っていました:スキャンが時間的に概ね均等に分散されることを保証する必要がありました。一度にすべてのスキャンをキューに入れることはできませんでした。なぜなら我々のKafkaトピックは時間ベースの保持ポリシーを使っているため、スキャンがKafka内に積み上がり、処理される前に削除されてしまうからです。元のスケジューラはスキャンを均等に分散するのが不得手で、トリガーされるスキャン数は時間によってスパイクし予測不可能でした。週の特定のタイミングでは、数十万件のスキャンが数分のうちにトリガーされてしまうことがありました。

スケジューラは固定の再発期間でスキャンをトリガーしていました(擬似コード):

Loop forever:
    Find accounts where last_scheduled_at + scanning frequency <= now
    For each account:
        Trigger scan for account
        Trigger scan for all zones in the account
        Update last_scheduled_at = now

last_scheduled_atが多くのアカウントで類似していたためデータベース内に不均一性があり、これが不均一性の一因でした。しかし、完全に均等に分布していても、スキャン頻度を上げるとこの問題は拡大します。例えばスキャン頻度を15日ごとから7日ごとに変えると、アカウントの約53%が同時にスキャン対象になります。

もう一つの問題は、一部のアカウントが非常に大量のゾーンを持っている場合、そのアカウントがスケジュールされるとすべてのゾーンに対して一斉にスキャンが発生し、Kafkaパーティションを飽和させ、小規模アカウントのスキャンが遅延することでした。

これらの問題を解決するため、我々は3つの主要な変更を加えました:

  • ゾーンをアカウントから独立してスケジュールする:各ゾーンに独自のlast_scheduled_atフィールドを持たせる
  • 既存のアカウントとゾーンのlast_scheduled_at時刻をランダム化する(この過程でスキャンが遅延しないように注意)
  • スキャンスケジューリングに対して適応的なレート制限を導入する

ゾーンを独立してスケジュールすることは大きなアカウント問題の明白な解決策です。last_scheduled_atをランダム化することで既存データベースの不均一性を解消できます。適応的レート制限はもう少し興味深いアイデアです。レート制限により、スキャン頻度を変更した際に発生するスパイクを平滑化できます。例えばスキャン頻度を7日に変更し、アカウント数が5,000万件ある場合、約83 scans/秒のレート制限を設定すれば7日間に均等に広げられます。しかしもし1,000万件のアカウントを追加した場合、その固定レートは全体のスキャンに8日を要することになります。そこで適応的にする利点が出てきます:レート制限は半時間ごとに非同期で、アカウントとゾーンの総数およびスキャン頻度に基づいて再計算されます。これにより、数千〜数百万のアカウントやゾーンをオンボードしても、スキャンを時間通りに続けられます。

次の関数はその計算の例です:

func computeRate(free, pro, biz, ent int64) rate.Limit {
    r := float64(free)/freeScanInterval.Seconds() + float64(pro)/proScanInterval.Seconds() + float64(biz)/bizScanInterval.Seconds() + float64(ent)/entScanInterval.Seconds()
    // Guard against zero counts. We always want to schedule at least one scan per second.
    if r < 1 {
        r = 1
    }
    // Increase rate limit beyond the 'perfect' value, to have a buffer in case of any downtime
    // or spikes in load.
    r *= rateLimitBufferFactor
    return rate.Limit(r)
}

現在の状況

これらの修正により、チェッカーごとの7日移動平均スループットは時間を通じて10倍以上に増加しました。改善前はおよそ10 scans/秒で実行していましたが、目標の100 scans/秒との差は大きく見えました。リソースを増やす、Kafkaのパーティションを増やす、あるいはアーキテクチャ全体を投げ捨てるといった話も出ましたが、これらの修正が決定的な効果をもたらしました。現在、Security Insightsはピークスケジューリング時に120 scans/秒以上を維持しており、10倍改善目標を上回っています。内部APIはタイムアウトしなくなり、Kafkaのlag指標も健全になりました。

これらのスケーラビリティ改善により、無料アカウントとゾーンに対して自動スキャンを有効化し、すべての顧客のスキャン頻度を引き上げることができました:

  • Free: every 7 days
  • Pro and Business: every
Security Insightsのスケーリング:グローバルなスキャン能力を10倍にした方法 | Cloudflare | DocsDigest