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メソッドとパス

リクエスト

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

レスポンス

項目長さ
リクエストID36バイト
ステータスコード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