[Astro] #139 Canvas × Web Worker で超高速ブラウザ PCAP アナライザを作る — メモリ効率化とリアルタイムフィルタリング実装記録

[Astro] #139 Canvas × Web Worker で超高速ブラウザ PCAP アナライザを作る — メモリ効率化とリアルタイムフィルタリング実装記録

はじめに

Wireshark や tcpdump で取得したキャプチャファイル(.pcap / .pcapng)を、専用アプリを立ち上げることなくブラウザ上(Astro プロジェクト内)で即座に解析・視覚化したい。そんな動機から、「PROTOCOL.LAIN // PCAP ANALYZER」 の開発を進めてきました。

数万件規模のネットワークパケットをブラウザでスムーズに描画・検索するには、DOM によるリスト構築ではすぐに描画パフォーマンスの限界に達します。そこで、HTML5 Canvas (2D Context) による仮想スクロールと、Web Worker によるパケットバイナリの並列デコード&フィルタリング構成を採用しました。

本記事では、大容量バイナリの解析パフォーマンスを維持するためのアーキテクチャ設計と、リアルタイムフィルタリング機能を実装する中で遭遇した「描画が更新されない地雷トラブル」とその解決策について詳しく記録します。

スクリーンショット

[Astro] #139 PCAP Analyzer — パケットグリッド描画とフィルタリングUI

動画(GIF)

[Astro] #139 PCAP Analyzer — パケットグリッド描画とフィルタリングUI

Sample data

サンプルデータは以下のサイトよりお借りしています。

1. 全体アーキテクチャとパイプライン

UIの応答性を殺さないため、ファイル読み込み・バイナリパース・フィルタリング演算はすべて Web Worker にオフロードし、メインスレッドは Canvas 描画とユーザーインタラクションのみに専念する分離構造としています。


[ メインスレッド (UI / Astro) ]
│  ├─ DOM / Canvas イベント(スクロール、ファイルドロップ、Filter入力)
│  ├─ PcapCanvasRenderer (仮想スクロール描画)
│  └─ TCP Stream / Hex Viewer / Traffic Stats の同期
│        │
│        │ postMessage({ type: 'filter', query })
│        │ postMessage({ type: 'file', file })
▼        ▼
[ Web Worker スレッド ]
│  ├─ Magic Byte 判定 (pcap / pcapng)
│  ├─ パケットヘッダー / IP / TCP / UDP バイナリ解析
│  ├─ IndexData (Float64Array) バッファ構築
│  └─ matchFilter() によるリアルタイムインデックス抽出演算
│        │
│        │ Transferable Objects (indexData.buffer)
▼        ▼
[ PcapCanvasRenderer (Canvas 2D) ]
└─ visibleIndices マッピングに基づく画面内パケット(20〜30行)のみ高速描画

2. 開発で直面した 4 大地雷と解決策

開発を進める中で、エラーログを出さずに描画結果が正しく更新されない等のトラブルが発生しました。

番号地雷カテゴリ主な症状根本原因
1メインスレッドのUI凍結大容量ファイル読み込み時に画面が固まるパケット解析と文字列生成をメインスレッドで実行
2Canvas 内での参照抜けフィルタ結果が届いているのに画面が変化しないRenderer 内部で visibleIndices を保持しても render() やスクロール・クリック判定で未参照
3全件マッチによる錯覚フィルタを入力しても絞り込めていないように見えるワード検索時、全パケットの共通文字列(192 等)にヒットしてしまっている
4DPR とリサイズ歪みパネル調整時に Canvas がぼやける / 表示崩れスプリッター移動時の ResizeObserver / DPR (DevicePixelRatio) スケーリング未同期

地雷 1: 大容量バイナリ解析による UI 凍結と Transferable 転送

数十MB以上の PCAP ファイルをパースする際、パケットごとにオブジェクトを生成してメインスレッドに転送すると、ガベージコレクション (GC) のオーバーヘッドで UI が停止します。

  • 解決策: Worker 内部で固定長数値(オフセット、タイムスタンプ、長、プロトコル種別)を Float64Array にパックし、Transferable Objects として零コピー転送します。
// Worker 内部での高速バッファ確保と転送
const finalBuffer = indexData.slice(0, packetCount * 8);

self.postMessage({
  type: 'complete',
  packetCount,
  summaries,
  stats,
  indexData: finalBuffer.buffer
}, [finalBuffer.buffer]); // Transferable として配列バッファを所有権移転

地雷 2: フィルタ用インデックスを準備しても Canvas が動かない

Worker からフィルタ結果 visibleIndices(マッチしたパケットの元インデックス配列)を受け取り、Renderer の setVisibleIndices(indices) を呼び出しているのにもかかわらず、画面上の描画内容が全く変わらない問題が発生しました。

  • 原因: PcapCanvasRenderersetVisibleIndices メソッドを追加して配列を保持させるだけでは不十分で、描画メソッド render()、ホイールスクロール処理、マウスクリック選択処理の すべての箇所で『現在の表示行 index』から『実際のパケット index』へ変換するマッピングロジック が抜けていたためです。
// pcap-canvas.js の修正例

export class PcapCanvasRenderer {
  constructor(canvas) {
    // ...
    this.visibleIndices = null; // フィルタ非適用時は null
  }

  setVisibleIndices(indices) {
    this.visibleIndices = indices;
    this.scrollTop = 0; // 検索結果更新時は先頭へスクロール
    this.render();
  }

  // 表示対象の総行数を動的に返却
  getDisplayCount() {
    return this.visibleIndices ? this.visibleIndices.length : this.packetCount;
  }

  render() {
    const displayCount = this.getDisplayCount();
    if (!this.indexData || !displayCount) return;

    const startRow = Math.floor(this.scrollTop / this.lineHeight);
    const visibleRowCount = Math.ceil((height - 24) / this.lineHeight) + 1;
    const endRow = Math.min(displayCount, startRow + visibleRowCount);

    for (let row = startRow; row < endRow; row++) {
      // 【重要】表示行 row から実際のパケットインデックス actualIndex に変換
      const actualIndex = this.visibleIndices ? this.visibleIndices[row] : row;

      const base = actualIndex * 8;
      const tsSec = this.indexData[base + 1];
      const inclLen = this.indexData[base + 3];
      const proto = this.indexData[base + 6];

      // actualIndex を元に Canvas に1行描画
      this.drawRow(row, actualIndex, tsSec, inclLen, proto);
    }
  }
}

地雷 3: フィルタ入力テスト時の検索対象文字列のかぶり

コードを修正してテストした際、192 と入力しても画面に変化がなく「まだフィルタが動いていないのか?」と錯覚するケースがありました。

  • 原因: 解析対象の全パケットサマリー(192.168.0.114 → 192.168.0.193)に IP アドレスの一部として 192 が含まれていたため、全件マッチ(38 / 38 件)となって画面が不変だっただけでした。
  • 確認方法: 特定フラグ(FIN)やポート番号(4844)、存在しないプロトコル(udp)を入力することで、正しく件数が絞り込まれて再描画されることが確認できました。

地雷 4: UI スプリッター操作と Canvas のリアルタイム追従

上下(detail-split)および左右(panel-right)の UI スプリッター(resizer-h, resizer-v)をドラッグした際、Canvas の解像度と高解像度ディスプレイ (DPR) のスケーリングがズレて描画が引き伸ばされる問題。

  • 解決策: ドラッグ中の mousemove イベントハンドラ内で、キャンバスの width/height 属性と CSS サイズを同期し、devicePixelRatio に合わせた ctx.scale() 再設定をリアルタイムに行います。
resize(width, height) {
  const dpr = window.devicePixelRatio || 1;
  this.canvas.width = width * dpr;
  this.canvas.height = height * dpr;
  this.ctx.scale(dpr, dpr);
  this.render();
}

3. 核心実装コード解剖

Worker 側の検索ロジック (matchFilter)

Wireshark 互換の簡便な構文(ip.src == x.x.x.xtcp.port == xx)と自由ワード検索を両立させています。

function matchFilter(meta, summary, query) {
  if (!query) return true;
  const q = query.trim().toLowerCase();

  // プロトコル検索
  if (q === 'tcp') return meta?.proto === 6 || summary.includes('[TCP');
  if (q === 'udp') return meta?.proto === 17 || summary.includes('[UDP]');
  if (q === 'dns') return meta?.srcPort === 53 || meta?.dstPort === 53 || summary.includes('DNS');

  // IPアドレス指定検索 (ip.src / ip.dst / ip.addr)
  const matchSrc = q.match(/^ip\\.src\\s*==\\s*['"]?([\\d\\.]+)['"]?$/);
  if (matchSrc) return meta?.srcIp === matchSrc[1];

  const matchAddr = q.match(/^ip\\.addr\\s*==\\s*['"]?([\\d\\.]+)['"]?$/);
  if (matchAddr) return meta?.srcIp === matchAddr[1] || meta?.dstIp === matchAddr[1];

  // ポート検索
  const matchPort = q.match(/^(?:tcp|udp)\\.port\\s*==\\s*(\\d+)$/);
  if (matchPort) {
    const port = parseInt(matchPort[1], 10);
    return meta?.srcPort === port || meta?.dstPort === port;
  }

  // サマリー全文検索(TCPフラグ文字やペイロード等)
  return summary.toLowerCase().includes(q);
}

4. まとめ

  • バイナリ解析のオフロード: 大容量ファイルのパース処理は Worker + TypedArray (Float64Array) 転送でメインスレッドを解放する。
  • Canvas 仮想描画のインデックス管理: フィルタリング導入時は「表示行(ビュー)」と「実データ(モデル)」のインデックスマッピング(visibleIndices[row])を描画・スクロール・クリックの全イベントで徹底する。
  • DPRスケーリングの同期: 可変スプリッターやウィンドウリサイズに合わせて DPR 調整を入れることで、サイバー感のある極小フォントも鮮明に表示可能。

これでブラウザ上で動く高機能なパケット解析ツール(v1.0)が完成しました。

「ブラウザで Wireshark もどきを作るなんて……」と思っていましたが、Web Worker と Canvas を組み合わせれば、数万件のパケットデータであっても一切引っかかることなく快適に動作します。

Web フロントエンドでのバイナリ処理や仮想描画でハマっている方の参考になれば幸いです。