【WebRTCのお勉強】2つのDockerコンテナ間を利用してWebRTCのシグナリングの基礎を考える
※ 当ページには【広告/PR】を含む場合があります。
2021/07/14

WebRTCを習得すると非常に魅力的な応用テーマが考えられますが、使い慣れるまでは中々難しいと感じてしまいます。
今回はnode.jsベースの2つのDockerコンテナで、Dockerネットワークブリッジを跨いだ模擬WebRTC環境を構築し、シグナリングをさせる方法を検証してみます。
テスト環境の事前準備
まずはWebRTCのローカル動作確認用のDockerコンテナを説明していきます。
テスト環境の確認
クライアント側からWebRTCを利用する場合には本来ブラウザを想定したものになります。
ブラウザ内で使えるAPIライブラリ群をnode.jsのネイティブアプリでも利用できるようにするためには、
このnode-webrtcですが、利用する前に全てのOS環境で動く訳ではないので
なお手元の環境ではDebianOS(64bit)を利用して、そこでDockerを利用することにします。
$ lsb_release -a
No LSB modules are available.
Distributor ID: Debian
Description: Debian GNU/Linux 10 (buster)
Release: 10
Codename: buster
$ docker --version
Docker version 20.10.5, build 55c4c88
検証用コンテナの準備
WebRTCのシグナリング検証用のコンテナを作りましょう。 適当なフォルダを作り、その中に以下の内容で空のリソースファイルを3つ用意しておきます。
$ tree
.
├── Dockerfile
├── client_listener.js
├── client_emitter.js
├── server.js
└── package.json
まずは、package.jsonファイルを以下の内容にしておきます。
{
"name": "wrtc-dckr",
"version": "0.0.1",
"license": "UNLICENSED",
"dependencies": {
"websocket": "~1.0.34",
"wrtc": "~0.4.7",
"@mapbox/node-pre-gyp": "^1.0.5"
}
}
Dockerfileは以下のようにしておきます。
FROM ubuntu:18.04
LABEL your_name <your-name@email.com>
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update -y
RUN apt-get install -y sudo
RUN apt-get install curl git zip -y
RUN apt-get install python2.7 libncurses5-dev libexpat-dev -y
RUN apt-get install w3m -y
RUN apt-get install build-essential -y
#Add sudo user & group
RUN groupadd -g 1000 developer && \
useradd -g developer -G sudo -m -s /bin/bash your_name && \
echo 'your_name:developer' | chpasswd
RUN echo 'Defaults visiblepw' >> /etc/sudoers
RUN echo 'your_name ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
#Install Node12
RUN curl -sL https://deb.nodesource.com/setup_12.x | bash
RUN apt-get install --yes nodejs
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
RUN apt-get update && apt-get install yarn
ENV NODE_ENV development
WORKDIR /usr/src/app
COPY package.json /usr/src/app/
RUN cd /usr/src/app && yarn install && yarn cache clean
COPY client_listener.js /usr/src/app/
COPY client_emitter.js /usr/src/app/
COPY server.js /usr/src/app/
USER your_name
ENV PS1="[\u@\h:\w]$"
CMD ["bash"]
とりあえずはアタッチモード(インタラクティブ)で使えるコンテナで使いますので、肝心のclient_***.jsとserver.jsの中身の実装は後述するとし、ここでは空のファイルのままにしておきます。
まずは
node-webrtc
今回ベースを
ubuntu:18.04
node-pre-gyp
$ docker build -t wrtc-dckr:ubuntu18-node12 .
Sending build context to Docker daemon 6.656kB
#...中略
Successfully tagged wrtc-dckr:ubuntu18-node12
ビルド後にイメージが出来上がっているか以下のコマンドで確認します。
$ docker images | grep wrtc
wrtc-dckr ubuntu18-node12 6ef36753bafe 5 hours ago 542MB
なおスリム化は考えていなかったので、若干重い感じもしますが、イメージの総サイズは540MBオーバーになりました。 他のスリムなベースイメージを利用したり、要らないパッケージを減らしたら、もう少しサイズを抑えられるかもしれませんが、今回はあまり拘らない方針で進めます。
同一イメージから2つのDockerコンテナを立ち上げる方法
今回のお題は2つのDockerコンテナをピアに見立て、WebRTC通信を模擬的に行わせてみるのが狙いです。
先程作ったイメージをベースに、先に同一Dockerネットワーク内に2つのコンテナを同時に展開しておくための方法にも簡単に触れておきます。
1つ目のコンテナを
wrtcc1
#👇コンテナ1生成 + bashでターミナル起動
$ docker run --name wrtcc1 -it wrtc-dckr:ubuntu18-node12 bash
f05393335494
bash-5.0# hostname
f05393335494
bash-5.0# hostname -i
172.17.0.2
#デタッチで外に出る: Ctrlキー + Pキー → Ctrlキー + Qキー と入力
bash-5.0# read escape sequence
ちなみに以下のようにデタッチオプション
-d
#👇コンテナ1生成 + bashでターミナル起動 + デタッチで外に出る
$ docker run --name wrtcc1 -d -it wrtc-client:node12 bash
次に2つ目のコンテナも
wrtcc2
#コンテナ2生成 + デタッチで外に出る + bashでターミナル起動
$ docker run --name wrtcc2 -it wrtc-dckr:ubuntu18-node12 bash
b96ce0f8224e
bash-5.0# hostname
b96ce0f8224e
bash-5.0# hostname -i
172.17.0.3
#デタッチ : Ctrl + P → Ctrl + Q と入力する
bash-5.0# read escape sequence
デタッチを用いることで、シェル起動状態をそのまま維持してすることができます。
この状態でdocker psコマンドで2つ同時に立ち上がっているか確認すると、
$ docker ps | grep wrtcc
b96ce0f8224e wrtc-client:node12 "docker-entrypoint.s…" 12 minutes ago Up 11 minutes wrtcc2
f05393335494 wrtc-client:node12 "docker-entrypoint.s…" 16 minutes ago Up 16 minutes wrtcc1
きちんと同じイメージから複数のコンテナを作成できていることが分かります。
デタッチで一度脱出したコンテナであっても、
docker attachコマンド
1つ目のコンテナにアタッチしたい場合:
#Attach Container#1
$ docker attach wrtcc1
bash-5.0# hostname
f05393335494
bash-5.0# hostname -i
172.17.0.2
2つ目のコンテナにアタッチしたい場合:
#Attach Container#2
$ docker attach wrtcc2
bash-5.0# hostname
b96ce0f8224e
bash-5.0# hostname -i
172.17.0.3
これで別々のターミナルからそれぞれのコンテナにアタッチすることで、WebRTCテスト用の各ピアと見立てて利用していきます。
WebRTCテスト環境の構成

上の図は今回のシグナリングサーバーを介した2つのピア間の処理の流れを示しています。
この場合、コンテナの一つを信号の受け手として
受信側(wrtccl)
配信側(wrtcce)
コネクションの確立方法やタイミングには色々とやり方があると思いますが、ここでは先に配信側(wrtcce)をシグナリングサーバーに接続させておいて、情報の受け手であるwrtcclのシグナリングサーバー接続時に配信側に
call
wrtcce側がcallを受け取ると、配信側からオファーを打診するためにシグナリングサーバーへ
offer
さらにwrtcclはそのオファーを受けとり、
answer
これでコネクションが確立したら、お互いのICE candidateを交換することでWebRTC接続は完了します。
サーバー側(server.js)の実装
シグナリングサーバーも別のDockerコンテナでWebSocketで作成します。
やっていることはさほど難しくなく、シグナリングサーバーの役目はWebSocketを通じて、クライアントピア同士の情報(SDPとICE Candidate)を交換させるだけです。
今回はシグナリングサーバーも独立した
コンテナ(wrtcs)
$ docker run -d -it --name wrtcs -p 41337:41337 \
wrtc-dckr:ubuntu18-node12 bash
なおWebSocket用のサービスポートは41337を利用することにして、このコンテナの起動後に割り振られたIPが172.17.0.1ならば、外部からは
ws://172.17.0.1:41337
サーバーの処理の中身は以下のようになります。
const webSocketsServerPort = 41337;
const webSocketServer = require('websocket').server;
const http = require('http');
const server = http.createServer((request, response) => {});
const clients = [];
server.listen(webSocketsServerPort, () => {
console.log(`${(new Date()).toISOString()} | Server is listening on port# ${webSocketsServerPort} .`);
});
const wsServer = new webSocketServer({httpServer: server});
//👇登録されたクライアントから接続requestを受け取った
wsServer.on('request', (request) => {
console.log(`${(new Date()).toISOString()} | Connection from Origin ${request.origin} .`);
const connection = request.accept(null, request.origin);
console.log(`${(new Date()).toISOString()} | Connection accepted.`);
//👇クライアントを番号で管理
const index = clients.push(connection) - 1;
//👇クライアントからmessageを受信した
connection.on('message', (message) => {
for (let i=0; i < clients.length; i++) {
if (connection === clients[i]) {
console.log('SDPの逆転送はキャンセルします');
} else {
console.log(message);
//👇接続中のすべてのwsクライアントへメッセージ送信
clients[i].sendUTF(message.utf8Data);
}
};
});
//👇クライアントが切断された
connection.on('close', (connection) => {
console.log(`${(new Date()).toISOString()} | Peer# ${connection} disconnected.`);
//👇切断したクライアントを削除
clients.splice(index, 1);
});
});
なお修正したローカルのコードをコンテナ側へ転送する場合には以下の
docker cpコマンド
$ docker cp server.js wrtcs:/usr/src/app/server.js
これでサーバー側の準備が整いました。
なおこのプログラムは
docker execコマンド
$ docker exec -it wrtcs node server.js
配信側クライアント側(client_emitter.js)の実装
次に配信側をピアコンテナを作成します。
このコンテナでは、
1. 他のピアから送られてくる「call」を受け取ったら、
データチャネルを作成し、サーバーへ「offer」のSDP情報を送る
2. サーバーの「answer」が送られてくるまで待ってからコネクションの確立する
3. コネクションが確立した後に自身の「ice」を送信する
この配信用のソースコードが以下のようにしてみます。
const WebSocketClient = require('websocket').client;
const client = new WebSocketClient();
const {
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate
} = require('wrtc');
const remoteIp = process.env.REMOTE_HOST_IP || '0.0.0.0';
const localIp = process.env.CLIENT_IP || '127.0.0.1';
let rtcPeerConnection = null;
let dataChannel = null;
//👇接続エラー時のイベント
client.on('connectFailed', (err) => {
console.log(`Connect Error: ${err}`);
});
//👇Websocket接続中のイベント
client.on('connect', (connection) => {
//👇初期化処理
console.log('シグナリングサーバーに接続中...');
//👇接続時のエラー
connection.on('error', (err) => {
console.log(`Connect Error: ${err}`);
});
//👇サーバーからのコマンド受信時
connection.on('message', async (message) => {
if (message.type === 'utf8') {
const command = JSON.parse(message.utf8Data);
if (command.type == 'call') {
console.log('CALL受信処理');
if (rtcPeerConnection == null) {
rtcPeerConnection = new RTCPeerConnection({ iceServers: [] });
//👇Media StreamかDataChannelが無ければICE Candicateが発行されない
await startDataChannel(rtcPeerConnection);
rtcPeerConnection.onicecandidate = sendIceCandidate;
}
await createOffer(rtcPeerConnection);
}
else if (command.type == 'offer') {
console.log('OFFER受信処理');
if (command.sdp) {
if (rtcPeerConnection == null) {
rtcPeerConnection = new RTCPeerConnection({ iceServers: [] });
rtcPeerConnection.onicecandidate = sendIceCandidate;
await rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(command.sdp));
await createAnswer(rtcPeerConnection);
}
}
}
else if (command.type == 'answer') {
console.log('ANSWER受信処理');
if (rtcPeerConnection && command.sdp) {
//👇リモートのsdpを記憶
await rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(command.sdp));
}
}
else if (command.type == 'candidate') {
console.log('ICE受信処理');
const candidate_ = new RTCIceCandidate(command.candidate);
await rtcPeerConnection.addIceCandidate(candidate_);
} else {
console.log(`Unknown ${command.type} Type Deteced!`);
}
}
});
//👇切断時のイベント
connection.on('close', () => {
peer.close();
console.log('ピアのコネクションを切断しました');
});
//👇DataChannelを始める
async function startDataChannel(peer_) {
if (peer_ == null) return false;
try {
//👇コネクション確立後にDataChannelを用意する
dataChannel = peer_.createDataChannel('msg_channel');
dataChannel.onopen = () => {
//👇接続時の処理
sendNumber();
};
dataChannel.onerror = (err) => {
//👇エラー時の処理
console.error(err);
};
dataChannel.onclose = () => {
//👇切断時の処理
dataChannel = null;
};
function sendNumber() {
if (dataChannel) {
const number = Math.round(Math.random() * 0xFFFFFF);
//👇dataChannel.send( data )でデータを送信
dataChannel.send(number.toString());
setTimeout(sendNumber, 1000);
}
};
return true;
} catch (err) {
console.error(err);
}
}
//👇WebSocketでコマンドを送信する
function wsSend(command) {
console.log('WebSocketから通信中...');
connection.sendUTF(JSON.stringify(command));
}
async function createOffer(peer_) {
let sdp_;
try {
sdp_ = await peer_.createOffer();
peer_.setLocalDescription(sdp_);
} catch (err) {
console.error(err)
}
wsSend({
type: 'offer',
sdp: sdp_
});
}
async function createAnswer(peer_) {
let sdp_;
try {
sdp_ = await peer_.createAnswer();
peer_.setLocalDescription(sdp_);
} catch (err) {
console.error(err);
}
wsSend({
type: 'answer',
sdp: sdp_
});
}
function sendIceCandidate(e) {
if (e.candidate) {
console.log('ICE情報の送信');
wsSend({
type: 'candidate',
candidate: e.candidate
});
}
}
});
//👇サーバーに接続
client.connect(`ws://${remoteIp}:41337`, null, `docker://${localIp}`, null, null);
配信用のピアなので、サーバーからのメッセージの内
call
answer
candidate
ではこのコンテナをデタッチモードで作成しておきます。
$ docker run -d -it --name wrtcce \
wrtc-dckr:ubuntu18-node12 bash
修正したローカルのコードをコンテナ側へ転送する場合には以下の
docker cpコマンド
$ docker cp client_emitter.js wrtcce:/usr/src/app/client_emitter.js
これで配信側の準備が整いました。 このプログラムは
docker execコマンド
$ docker exec -it \
-e REMOTE_HOST_IP=172.17.0.1 \
-e CLIENT_IP=172.17.0.2 \
wrtcce node client_emitter.js
なおnodeが使う環境変数の内、
REMOTE_HOST_IP
CLIENT_IP
受信側クライアント側(client_listener.js)の実装
最後にクライアント側ピアの実装ポイントは以下のようになります。
1. コンテナが起動した直後、受信の開始を合図するために「call」を送る
2. サーバーから送られてくる配信側のオファーを受け取り、処理を行った上で「answer」を送信する
3. アンサーを送信した後にコネクションが確立するので、続けてサーバーへ「ice」が送られる
これらのポイントを踏まえてコードを実装すると以下のようになります。
const WebSocketClient = require('websocket').client;
const client = new WebSocketClient();
const {
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate
} = require('wrtc');
const remoteIp = process.env.REMOTE_HOST_IP || '0.0.0.0';
const localIp = process.env.CLIENT_IP || '127.0.0.1';
let rtcPeerConnection = null;
let dataChannel = null;
//👇接続エラー時のイベント
client.on('connectFailed', (err) => {
console.log(`Connect Error: ${err}`);
});
//👇Websocket接続中のイベント
client.on('connect', (connection) => {
//👇初期化処理
console.log('シグナリングサーバーに接続中...');
//👇データチャネルの開始・発信を要求
wsSend({
type: 'call',
channel_id: 'DataChannelテスト'
});
//👇接続時のエラー
connection.on('error', (err) => {
console.log(`Connect Error: ${err}`);
});
//👇サーバーからのコマンド受信時
connection.on('message', async (message) => {
if (message.type === 'utf8') {
const command = JSON.parse(message.utf8Data);
if (command.type == 'call') {
console.log('CALL受信処理');
if (rtcPeerConnection == null) {
rtcPeerConnection = new RTCPeerConnection({ iceServers: [] });
rtcPeerConnection.onicecandidate = sendIceCandidate;
}
await createOffer(rtcPeerConnection);
}
else if (command.type == 'offer') {
console.log('OFFER受信処理');
if (command.sdp) {
if (rtcPeerConnection == null) {
rtcPeerConnection = new RTCPeerConnection({ iceServers: [] });
await recieveDataChannel(rtcPeerConnection);
rtcPeerConnection.onicecandidate = sendIceCandidate;
await rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(command.sdp));
await createAnswer(rtcPeerConnection);
}
}
}
else if (command.type == 'answer') {
console.log('ANSWER受信処理');
if (rtcPeerConnection && command.sdp) {
//👇リモートのsdpを記憶
await rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(command.sdp));
}
}
else if (command.type == 'candidate') {
console.log('ICE受信処理');
const candidate_ = new RTCIceCandidate(command.candidate);
await rtcPeerConnection.addIceCandidate(candidate_);
} else {
console.log(`Unknown ${command.type} Type Deteced!`);
}
}
});
//👇切断時のイベント
connection.on('close', () => {
peer.close();
console.log('ピアのコネクションを切断しました');
});
//👇DataChannelを受信する
async function recieveDataChannel(peer_) {
if (peer_ == null) return false;
try {
if (dataChannel) dataChannel = null;
peer_.ondatachannel = (e) => {
//👇DataChannelを作るのではなく、配信側から渡されたものを使う
dataChannel = e.channel;
dataChannel.onopen = () => {
//👇接続時の処理
console.log('DataChannelによる配信を開始します!');
};
dataChannel.onmessage = (message) => {
//👇データ受信時の処理
console.log(message);
};
dataChannel.onerror = (err) => {
//👇エラー時の処理
console.error(err);
};
dataChannel.onclose = () => {
//👇切断時の処理
dataChannel = null;
};
};
return true;
} catch (err) {
console.error(err)
}
}
//👇WebSocketでコマンドを送信する
function wsSend(command) {
console.log('WebSocketから通信中...');
connection.sendUTF(JSON.stringify(command));
}
async function createOffer(peer_) {
let sdp_;
try {
sdp_ = await peer_.createOffer();
peer_.setLocalDescription(sdp_);
} catch (err) {
console.error(err)
}
wsSend({
type: 'offer',
sdp: sdp_
});
}
async function createAnswer(peer_) {
let sdp_;
try {
sdp_ = await peer_.createAnswer();
peer_.setLocalDescription(sdp_);
} catch (err) {
console.error(err);
}
wsSend({
type: 'answer',
sdp: sdp_
});
}
function sendIceCandidate(e) {
if (e.candidate) {
console.log('ICE情報の送信');
wsSend({
type: 'candidate',
candidate: e.candidate
});
}
}
});
//👇サーバーに接続
client.connect(`ws://${remoteIp}:41337`, null, `docker://${localIp}`, null, null);
データ受信用のピアなので、サーバーからのメッセージの内
offer
candidate
ではこのコンテナをデタッチモードで作成しておきます。
$ docker run -d -it --name wrtccl \
wrtc-dckr:ubuntu18-node12 bash
修正したローカルのコードをコンテナ側へ転送する場合には以下の
docker cpコマンド
$ docker cp client_listener.js wrtccl:/usr/src/app/client_listener.js
これで受信側の準備が整いました。 このプログラムは
docker execコマンド
$ docker exec -it \
-e REMOTE_HOST_IP=172.17.0.1 \
-e CLIENT_IP=172.17.0.3 \
wrtccl node client_listener.js
なおnodeが使う環境変数の内、
REMOTE_HOST_IP
CLIENT_IP
WebRTC通信の検証
では、このブログの最後の仕上げとして、WebRTCによるデータチャネル通信が出来るのか検証します。
まずは最初にシグナリングサーバーを開始します。
$ docker exec -it wrtcs node server.js
次に配信側コンテナのサーバーへの接続を行います。
$ docker exec -it \
-e REMOTE_HOST_IP=172.17.0.1 -e CLIENT_IP=172.17.0.2 \
wrtcce node client_emitter.js
これで配信側がデータチャネルのスタンバイ状態になりました。
最後に受信側コンテナをサーバーへと接続させてみましょう。
$ docker exec -it \
-e REMOTE_HOST_IP=172.17.0.1 -e CLIENT_IP=172.17.0.3 \
wrtccl node client_listener.js
接続したと同時にデータチャネル通信が開始され、ランダムな数字がデータチャネルを通して1秒おきに配信されていれば成功です。

以上、ご清聴ありがとうございました。
まとめ
今回はローカルに構築したDockerコンテナネットワークを利用して、WebRTCの通信の模擬テスト環境の簡単な構築方法と、テスト方法をしてみました。
まだまだ実用には程遠いのですが、最初にWebRTC(とDocker)を学ぶにはもってこいの内容になっていると思います。
WebRTCはなかなか複雑な知識が交差する技術であること相まって、実際にコードを動かさないと内部処理がどうなっているか理解できないかも知れませんので、今回ご紹介したようなコードを自分の手で弄ってみて、理解を深められては以下がかと思います。