【WebRTCのお勉強】2つのDockerコンテナ間を利用してWebRTCのシグナリングの基礎を考える


2021/07/14
蛸壺の中の工作室|2つのDockerコンテナ間を利用してWebRTCのシグナリングの基礎を考える

WebRTCを習得すると非常に魅力的な応用テーマが考えられますが、使い慣れるまでは中々難しいと感じてしまいます。

今回はnode.jsベースの2つのDockerコンテナで、Dockerネットワークブリッジを跨いだ模擬WebRTC環境を構築し、シグナリングをさせる方法を検証してみます。


テスト環境の事前準備

まずはWebRTCのローカル動作確認用のDockerコンテナを説明していきます。

テスト環境の確認

クライアント側からWebRTCを利用する場合には本来ブラウザを想定したものになります。

ブラウザ内で使えるAPIライブラリ群をnode.jsのネイティブアプリでも利用できるようにするためには、
node-webrtcをパッケージインストールすることで対応します。

この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でnode-webrtcを再ビルドできるようにするためのチョイスです。他のディストリビューションのベースイメージは試していませんが、おそらくDebian系ならば同様の方法でnode-webrtcが動作するように思います。

            $ 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つのコンテナを立ち上げる方法

今回のお題は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側へ送り出します。

さらに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でアクセスできます。

サーバーの処理の中身は以下のようになります。

            ///server.js
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」を送信する
        
この配信用のソースコードが以下のようにしてみます。

            ///client_emitter.js
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がサーバーコンテナのIP、自身のコンテナIPをCLIENT_IPとして持たせておくことができます。


受信側クライアント側(client_listener.js)の実装

最後にクライアント側ピアの実装ポイントは以下のようになります。

            1. コンテナが起動した直後、受信の開始を合図するために「call」を送る
2. サーバーから送られてくる配信側のオファーを受け取り、処理を行った上で「answer」を送信する
3. アンサーを送信した後にコネクションが確立するので、続けてサーバーへ「ice」が送られる
        
これらのポイントを踏まえてコードを実装すると以下のようになります。

            ///client_listener.js
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がサーバーコンテナのIP、自身のコンテナ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はなかなか複雑な知識が交差する技術であること相まって、実際にコードを動かさないと内部処理がどうなっているか理解できないかも知れませんので、今回ご紹介したようなコードを自分の手で弄ってみて、理解を深められては以下がかと思います。

参考サイト

websocket-node

node-webrtc

How to Implement a Video Conference with WebRTC and Node

Node.js with WebRTC DataChannel