ブラウザに、生ログを渡してはいけない
この連載でやりたいのは、サーバーは集計だけ、推論はブラウザ(WebLLM)という構成です。 「今日いちばん怪しいアクセス元は?」と自然言語で問える仕組みを、2GB VPS に一切の推論負荷をかけずに作ります。
素朴には「生ログを全部ブラウザに送って、そこで分析すればいい」と思うかもしれません。でも、それは筋が悪い。 数万行は重いし、IPやUAが生で漏れるし、LLMに食わせるには大きすぎる。
だからサーバー側で「答え」まで作ります。この記事の主役は、MongoDB が返すのは生ログではなく、 集計済みの結論(数KB)だ、という設計です。
203.0.113.x / 198.51.100.x / 192.0.2.x はドキュメント用予約レンジで、実在のアクセス元ではありません。
生ドキュメントを引くだけでは、足りない
いちばん素朴なのは、怪しいログを find() で全部引くことです。
db.access_log.find({ bot: true }) // → 大量の生ドキュメントがそのまま返る
これだと、転送量が大きく、IPやUAが生で出て、そして「で、結局どのIPが何回?」は 受け取った側が自分で数える羽目になります。数える・グループ化する・並べる——これは本来 DBが得意な仕事です。クライアントに数えさせてはいけません。
答えを作る、流れ作業
Aggregation Pipeline は、データが管(pipe)を流れながら答えに変わる仕組みです。
$match(絞る)→ $group(まとめる)→ $sort(並べる)→ $limit(切る)。
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から出てくる時点で、すでに「スコア済みの候補」です。
MongoDB ドライバの実装
このサイト群は Apache + PHP。yf-proxy.php と同じ土俵で、 重いランタイムは要りません。PHP の MongoDB ドライバで、同じパイプラインを実行します。
$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 は「集計を叩いて返す」薄い層に徹します。
LLMが、後で読むことを見据える
この集計JSONは、次回ブラウザの WebLLM が読みます。だから、LLMが解釈しやすい形に設計します。 フラットに・意味のあるキー名で・数を絞って。
{
"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 や、ログ書き込みの規律と同じ——
「後段が扱いやすい形に揃える」思想です。
集計APIも、素通しにしない
yf-proxy.php と同じ思想で守ります。Origin検証・レート制限・パラメータのホワイトリスト (期間や観点の値を検証)。集計APIは軽いとはいえ、公開する窓口です。
created_at / addr にインデックスを張り、
結果を APCu で短時間キャッシュする。キャッシュ・レート制限の運用詳細は
appw.jp の記事に譲ります。
pipeline が、生ログを答えに変える
左の生ログが、パイプラインを流れて右の集計JSONに変わります。各ステージを ON/OFF し、パラメータを動かして、 数十行・生IP → 数行・匿名への変換を見てください。
$group を切ると、右は生ドキュメントのまま(大きい・生IP)。ONにすると、まとまって一気に縮みます。
$sort + $limit で「上位の攻撃者候補」だけに絞る。これが、WebLLM に渡す数KBのJSONです。
サーバーは、結論を返す
サーバーが返すのは生ログではなく、集計済みの答え(数KB)。数える・まとめる・並べるはDBの仕事です。 Aggregation Pipeline + PHP ドライバで実装し、返すJSONは「LLMが後で読む」前提で設計する。
この記事のまとめ
- 生ログをブラウザに渡さない。重い・漏れる・LLMに大きすぎる。
$match → $group → $sort → $limitで「答え」まで作る。数える処理はDBの仕事。- PHP の MongoDB ドライバでパイプラインを実行し、JSONで返す薄い層。
- 返すJSONは フラット・意味のあるキー・数KB。生ログは1行も含めない。
- 集計APIも yf-proxy.php と同じく Origin検証・レート制限・インデックス・キャッシュで守る。
この集計JSONを、ブラウザの WebLLM が自然言語に翻訳します。「192.0.2.0/24 からの分散スキャンです。404率が高く…」—— サーバー推論ゼロで、日本語の防御レポートを生成する。実WebLLMをロードして動かします。