モバイル&ワイヤレスブロードバンドでインターネットへ

gwaw.jp
 
MongoDB × WebLLM ①/集計API

集計API — MongoDBは、
答えだけを返す

ブラウザに生ログを渡してはいけない。重いし、IPが漏れるし、LLMに食わせるには大きすぎる。 だからサーバー側で「答え」まで作る。Aggregation Pipeline で分析済みの結論を組み、数KBの集計JSONだけを返す—— 2GB VPS を無傷に保つ、薄い集計APIの設計と実装です。

MongoDB Aggregation Pipeline PHP Driver 集計JSON pipeline builder

[ § 0 ] 導入

ブラウザに、生ログを渡してはいけない

この連載でやりたいのは、サーバーは集計だけ、推論はブラウザ(WebLLM)という構成です。 「今日いちばん怪しいアクセス元は?」と自然言語で問える仕組みを、2GB VPS に一切の推論負荷をかけずに作ります。

素朴には「生ログを全部ブラウザに送って、そこで分析すればいい」と思うかもしれません。でも、それは筋が悪い。 数万行は重いし、IPやUAが生で漏れるし、LLMに食わせるには大きすぎる

だからサーバー側で「答え」まで作ります。この記事の主役は、MongoDB が返すのは生ログではなく、 集計済みの結論(数KB)だ、という設計です。

IP はすべてマスク済み。 例に出す 203.0.113.x / 198.51.100.x / 192.0.2.x はドキュメント用予約レンジで、実在のアクセス元ではありません。
[ § 1 ] find() の限界

生ドキュメントを引くだけでは、足りない

いちばん素朴なのは、怪しいログを find() で全部引くことです。

mongosh — 素朴な find()
db.access_log.find({ bot: true })   // → 大量の生ドキュメントがそのまま返る

これだと、転送量が大きく、IPやUAが生で出て、そして「で、結局どのIPが何回?」は 受け取った側が自分で数える羽目になります。数える・グループ化する・並べる——これは本来 DBが得意な仕事です。クライアントに数えさせてはいけません。

[ § 2 ] Aggregation Pipeline

答えを作る、流れ作業

Aggregation Pipeline は、データが管(pipe)を流れながら答えに変わる仕組みです。 $match(絞る)→ $group(まとめる)→ $sort(並べる)→ $limit(切る)。

mongosh — サブネット別に集計する
db.access_log.aggregate([
  { $match:  { created_at: { $gte: since } } },        // 期間で絞る
  { $group: {
      _id:  "$subnet",                              // サブネット単位でまとめる
      req:  { $sum: 1 },
      c404: { $sum: { $cond: [ { $eq: ["$status", 404] }, 1, 0 ] } },
      ips:  { $addToSet: "$addr" },
      uas:  { $addToSet: "$uah" }
  }},
  { $addFields: {
      rate404:  { $divide: ["$c404", "$req"] },       // 404率を計算
      ip_count: { $size: "$ips" },
      ua_count: { $size: "$uas" }
  }},
  { $sort:  { req: -1 } },                            // 多い順に並べる
  { $limit: 5 }                                     // 上位だけ
])

生ログ数万行が、この管を流れると集計数行になります。しかも iseeit の検出記事で語った4観点(頻度・分散・404率・UA数)を、パイプラインの中で計算してしまう。 DBから出てくる時点で、すでに「スコア済みの候補」です。

[ § 3 ] PHP で叩く

MongoDB ドライバの実装

このサイト群は Apache + PHP。yf-proxy.php と同じ土俵で、 重いランタイムは要りません。PHP の MongoDB ドライバで、同じパイプラインを実行します。

log-api.php — 集計を実行してJSONを返す
$manager = new MongoDB\Driver\Manager("mongodb://127.0.0.1:27017");

$pipeline = [
  ['$match'  => ['created_at' => ['$gte' => $since]]],
  ['$group'  => [
      '_id'  => '$subnet',
      'req'  => ['$sum' => 1],
      'c404' => ['$sum' => ['$cond' => [['$eq' => ['$status', 404]], 1, 0]]],
  ]],
  ['$addFields' => ['rate404' => ['$divide' => ['$c404', '$req']]]],
  ['$sort'   => ['req' => -1]],
  ['$limit'  => 5],
];

$cmd = new MongoDB\Driver\Command([
  'aggregate' => 'access_log',
  'pipeline'  => $pipeline,
  'cursor'    => new stdClass,
]);
$rows = $manager->executeCommand('logs', $cmd)->toArray();

header('Content-Type: application/json');
echo json_encode(['period' => $period, 'top_offenders' => $rows]);

パイプラインを組んで executeCommand で実行し、結果を JSON で返すだけ。 サブネット集計も 404率も、DB側で計算済みです。PHP は「集計を叩いて返す」薄い層に徹します。

[ § 4 ] 返すJSONを設計する

LLMが、後で読むことを見据える

この集計JSONは、次回ブラウザの WebLLM が読みます。だから、LLMが解釈しやすい形に設計します。 フラットに・意味のあるキー名で・数を絞って

response.json — 返すスキーマ(骨子)
{
  "period": "直近60分",
  "top_offenders": [
    { "subnet": "192.0.2.0/24", "req": 21,
      "rate404": 0.62, "ip_count": 6, "ua_count": 2, "score": 76 }
  ],
  "summary": { "total_req": 32, "unique_ip": 9, "candidates": 1 }
}

肝は「数KB」を守ること。top N だけ、必要なフィールドだけ、生ログは1行も含めない。 転送量とプライバシーを同時に満たします。これは TF.js 記事の normalize や、ログ書き込みの規律と同じ—— 「後段が扱いやすい形に揃える」思想です。

[ § 5 ] 守り

集計APIも、素通しにしない

yf-proxy.php と同じ思想で守ります。Origin検証・レート制限・パラメータのホワイトリスト (期間や観点の値を検証)。集計APIは軽いとはいえ、公開する窓口です。

集計そのものが負荷になりうる。 重いパイプラインを叩かれ続けると、集計がDB負荷になります。created_at / addr にインデックスを張り、 結果を APCu で短時間キャッシュする。キャッシュ・レート制限の運用詳細は appw.jp の記事に譲ります。
[ § 6 ] 体感デモ

pipeline が、生ログを答えに変える

左の生ログが、パイプラインを流れて右の集計JSONに変わります。各ステージを ON/OFF し、パラメータを動かして、 数十行・生IP → 数行・匿名への変換を見てください。

Interactive · Aggregation pipeline builder
pipeline ビルダー
サーバー通信なし。パイプラインはブラウザ内で模擬実行しています(実MongoDBと同じ段構成)。IP はマスク済み。
$match
期間
$group
単位
$sort
req 降順
$limit
top 3
db.access_log(生ログ)
出力(集計JSON)

$group を切ると、右は生ドキュメントのまま(大きい・生IP)。ONにすると、まとまって一気に縮みます。 $sort + $limit で「上位の攻撃者候補」だけに絞る。これが、WebLLM に渡す数KBのJSONです。

[ § 7 ] まとめ

サーバーは、結論を返す

サーバーが返すのは生ログではなく、集計済みの答え(数KB)。数える・まとめる・並べるはDBの仕事です。 Aggregation Pipeline + PHP ドライバで実装し、返すJSONは「LLMが後で読む」前提で設計する。

この記事のまとめ

  • 生ログをブラウザに渡さない。重い・漏れる・LLMに大きすぎる。
  • $match → $group → $sort → $limit で「答え」まで作る。数える処理はDBの仕事。
  • PHP の MongoDB ドライバでパイプラインを実行し、JSONで返す薄い層。
  • 返すJSONは フラット・意味のあるキー・数KB。生ログは1行も含めない。
  • 集計APIも yf-proxy.php と同じく Origin検証・レート制限・インデックス・キャッシュで守る。

関連記事

『集計API — MongoDBは、答えだけを返す(Aggregation Pipeline × PHP)』を公開しました。