NIKKEI TECHNOLOGY AND CAREER

P2PでローカルのHTTPサーバーへ外部からトンネリングする

この記事はNikkei Advent Calendar 2021の 14日目 の記事です。

こんにちは。電子版Webチームの谷出です。

概要

開発のため、PCに立てたHTTPサーバーへ他の端末からアクセスしたいことがあります。この用途ではngrokなどの便利なツールが存在しています。
試しに自分も作ってみたのでその報告をします。

github.com/rikuTanide/p2p_tunnel

用語

便宜上、ここではこのように用語を使います。
公開したいローカルのHTTPサーバーをoriginと呼びます。
originを公開したい側を配信側/publisher、そのoriginにアクセスしたい側を購読側/subscriberと呼びます。
originへ別のLANからアクセスできるようにすることをトンネリングと呼びます。

実演

配信側PC

まず、適当なサンプルアプリを用意します。

riku@riku-MacBook-Pro example-app % npx create-react-app .
riku@riku-MacBook-Pro example-app % yarn run start &
サンプルアプリ

p2p_tunnelをインストールし、publisherを起動します。
--port--host でoriginのポートを指定します。

riku@riku-MacBook-Pro ~% yarn global add https://github.com/rikuTanide/p2p_tunnel.git
riku@riku-MacBook-Pro ~ % p2p_tunnel pub --port=3000 --host=localhost
bind to localhost:3000
publisherID is YmMZhwo5pjRxrtvx

購読側PC

p2p_tunnelをインストールし、subscriberを起動します。

--host--port でsubscriberを建てるポートを指定します。
--id には上記で出力されたpublisherIDを指定します。

C:\Users\riku\example_app>p2p_tunnel sub --port=3000 --host=localhost --id=YmMZhwo5pjRxrtvx

表示できました。 購読側

subscriberの--hostにローカルIPアドレスを指定すれば、LAN内の他の端末でもsubscribeできます。 スマートフォン

こだわった点

配信側と購読側をP2Pでトンネリングしました。
多くのトンネリングツールはクラウド上のサーバーで通信を中継しています。
このメリットとして以下の点が挙げられます。

  • 購読側に特殊なツールがいらない
  • 簡単にhttpsで動作確認ができる
  • Fastlyなどの外部ネットワークと簡単に連携できる

しかし、このようなデメリットが存在します。

  • 原理上、誰かがクラウドのネットワーク利用料金を負担している
  • 中継サーバー自体が中間者である

そこで、P2Pでトンネリングします。

仕組み

使うツール

P2Pを実現するためにSkyWayを使いました。
SkyWayはWebRTCのシグナリングサーバやラッパーライブラリなどを提供してくれるサービスです。
SkyWayを使う手段は複数ありますが、今回はpuppeteer経由でHeadless Chromeの中のWebRTC機能を使うことにしました。
最も環境構築や互換性で躓かないだろうという理由です。

アーキテクチャ

バイナリフォーマット

HTTPリクエストとHTTPレスポンスをWebRTCで送受信する都合上、それらを一つのバイナリに固めました。
このようなフォーマットです。

リクエストID: subscriber内でPromiseを解決するためのランダムな英数字
Start Line: HTTPリクエストの最初の一行の事。HTTPメソッドとパス

リクエスト

項目 長さ
リクエストID 36バイト
Start Lineの長さ 4バイト
ヘッダーの長さ 4バイト
ボディの長さ 4バイト
Start Line 可変長
ヘッダー 可変長
ボディ 可変長

レスポンス

項目 長さ
リクエストID 36バイト
ステータスコード 4バイト
ヘッダーの長さ 4バイト
ボディの長さ 4バイト
ヘッダー 可変長
ボディ 可変長

躓いたところ

node-fetchのcompress

publisherからoriginへアクセスするためにnode-fetchを使いました。
node-fetchには”compress”というオプションパラメータがあり、デフォルトは『有効』であり、gzip/deflateで圧縮されたHTTPレスポンスを自動で解凍してくれます。
つまりこれが有効になっていた場合、HTTPヘッダーのcontent-lengthで指定されたbodyサイズと、渡ってくるbodyサイズにずれが生じます。
自分はここに気づかず思わぬ不具合に遭遇しました。
解決方法としては、各中継地点で適宜content-lengthを付け替えるか、もしくはcompress: false,にしておくのが良いと思います。
今回は後者を選択しました。
その代わりWebRTCを通す前後でbinaryの塊を丸ごとgzipしました。

Uint8Arrayのコンストラクタ

HTTPリクエストとレスポンスを一塊のbinaryにするときに躓いたのが、Uint8Arrayのコンストラクタの仕様です。
Uint8Arrayには幾つかのコンストラクタがありますが、ここではこの二つの違いについて説明します。

Uint8Array() コンストラクター

new Uint8Array(typedArray); typedArray typedArray 引数付きで呼び出されると、これはあらゆる型付き配列型 (例えば Int32Array) にすることができますが、 typedArray を新しい型付き配列にコピーします。

new Uint8Array(buffer [, byteOffset [, length]]); buffer, byteOffset, length buffer と、オプションで byteOffset と length 引数を指定して呼び出されると、指定された ArrayBuffer を表示する型付き配列ビューが生成されます。

引数にUint8Arrayを渡した場合は コピーされます が、ArrayBufferを渡した場合は コピーされません
ここを間違えると、挙動やパフォーマンスが大きく変わってしまいます。
しかし、分かり辛い事にTypeScriptではUint8ArrayはArrayBufferにキャストできます。
例えば、下記のfromArrayBuffer関数とfromUint8Array関数は、静的にはほとんど同じに見えます が、 前者は1GB のメモリしか使わないのに対し 後者は100GB も使います。


function multiplication(original: ArrayBuffer) {
  const list: Uint8Array[] = [];
  for(let i = 0; i < 100; i ++) {
    const next = new Uint8Array(original);
    list.push(next);
  }
  return list;
}

function fromArrayBuffer() {
  const original = new Uint8Array(1024 * 1024 * 1024);
  copy(original.buffer);
}
function fromUint8Array() {
  const original = new Uint8Array(1024 * 1024 * 1024);
  copy(original);
}

ここで間違えないようにするために、自分が管理できるコードの範囲内ではUint8ArrayかArrayBufferのどちらか片方しか使わないようにするといいと思います。
そして、入出力時に適宜変換します。
今回は、Uint8Arrayに統一することにしました。
ArrayBufferを引き回して必要に応じてUint8Arrayに変換するよりも、最初から最後までUint8Arrayで引き回す方が事故が少ないと思ったからです。

今後の課題

現在このような課題があります。

  • 実装上Server Sent EventやWebSocketを転送することができない
    • webpackのような開発ツールと連携できない
  • 仕組み上、subscriber側に特別なツールをインストールしなければいけない
  • 仕組み上、httpsでsubscribeできない

そこで、今後このような取り組みをしたいです。

  • Server Sent EventとWebSocketに対応
  • Service Workerからプロキシする仕組みに変更する
    • スタンドアロンで動くようにする
    • httpsでsubscribeできるようにする

まとめ

  1. P2Pを使って、ローカルのHTTPサーバーへ外部からアクセスできるものを作りました
  2. P2PのためにはHeadless ChromeでSkyWayを使うのが便利です
  3. Uint8Arrayのコンストラクタには気を付けましょう

終わりに

WebチームにはWebが好きなメンバーが集っています。
一緒に日経電子版のパフォーマンスやアクセシビリティ、セキュリティなどに貢献してくださるという方は、
ぜひJobsからご連絡ください!

明日は15日目、玉越さんによる『DX を支える BigQuery の安心・安全・便利・効率的な運用の実現』です。お楽しみに!

谷出 陸
ENGINEER谷出 陸

Entry

各種エントリーはこちらから

キャリア採用
Entry
新卒採用
Entry
短期インターン
Entry