UDPのマルチキャストをDockerコンテナ・ネットワーク上で動かしてみた

※半年ほど前に書いた記事をQiitaに持ってきました

JavaのMulticastSocketを使って、UDPのマルチキャストを送受信するクライアントを作り、Dockerコンテナ・ネットワーク上で動かしてみようと思います。

今回書いたコードは[こちらのリポジトリ](https://github.com/FukumotoRyota/mcast_on_docker_network)に置いてあります。

## UDPのマルチキャストとは
コネクションの確立をせず、パケットを一方的に送りつけることが特徴なUDPですが、以下のような送信方式があります。

- ユニキャスト
- ブロードキャスト
- マルチキャスト

ユニキャストは、特定の一台のホストに対して送信します。ブロードキャストは、あるネットワークに属する全てのホストに対して送信します。マルチキャストは、あるネットワークに属する特定のホスト（一台でも複数台でもよい）に対して送信します。

マルチキャストにおいて、送信側はマルチキャストアドレスを宛先アドレスとして指定してデータを送信します。マルチキャストアドレスはIPアドレスクラスのうち、クラスDのIPアドレスです。受信側はこのマルチキャストアドレスにJoinすることでデータを受け取ることができるようになります。

また、ユニキャストを複数台に行うことに対して、マルチキャストを行うことのメリットは以下のようになります。

- 宛先のIPアドレスを知らなくても、マルチキャストアドレスだけ分かっていれば、Joinしているホスト全員に対して送信できる
- 一つのパケットで複数台に向けて送信できるので通信効率が良く、送信元の負荷が低い

## Dockerコンテナ・ネットワークとは
Dockerコンテナ・ネットワークとは、Dockerコンテナが属するネットワークのことです。`docker network ls`コマンドを実行すると一覧が表示されます。今回はマルチキャストを検証するために、同一ネットワーク内にコンテナを立てなければなりません。しかし、実は難しいことをする必要はなく、docker-composeを使えば自動でネットワークを作成してくれるます。なので、今回はdocker-composeを使って複数のクライアントを動かそうと思います。

## 簡単なチャットアプリを作ってみる
それではUDPのマルチキャストを送受信するクライアントとして、今回は簡単なチャットアプリを作ってみます。一つのクライアントで送信と受信の両方を行うようにしてみます。

### ソースコード
MulticastClient.javaというファイルに以下を記述します。

```java
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.InetAddress;
import java.net.MulticastSocket;
import java.util.Scanner;

class Sender extends Thread {
 private final int toPort;
 private final String mcastAddrName;

 public Sender(int toPort, String mcastAddress) {
   this.toPort = toPort;
   this.mcastAddrName = mcastAddress;
 }

 public void run() {
   Scanner scan = null;
   MulticastSocket socket = null;
   try {
     InetAddress mcastAddress = InetAddress.getByName(mcastAddrName);
     scan = new Scanner(System.in);
     socket = new MulticastSocket();

     String message;
     while ( (message = scan.nextLine()).length() > 0 ) {
       byte[] bytes = message.getBytes();
       DatagramPacket packet = new DatagramPacket(bytes, bytes.length, mcastAddress, toPort);
       socket.send(packet);
     }
   } catch (IOException e) {
     e.printStackTrace();
   } finally {
     if (scan != null) scan.close();
     if (socket != null) socket.close();
   }
 }
}

class Receiver extends Thread {
 private final int fromPort;
 private final String mcastAddrName;
 private static final int PACKET_SIZE = 1024;

 public Receiver(int fromPort, String mcastAddrName) {
   this.fromPort = fromPort;
   this.mcastAddrName = mcastAddrName;
 }

 public void run() {
   MulticastSocket socket = null;
   byte[] buf = new byte[PACKET_SIZE];
   DatagramPacket packet = new DatagramPacket(buf, buf.length);

   try {
     socket = new MulticastSocket(fromPort);
     InetAddress mcastAddress = InetAddress.getByName(mcastAddrName);
     socket.joinGroup(mcastAddress);

     while (true) {
       socket.receive(packet);
       String message = new String(buf, 0, packet.getLength());
       System.out.println(packet.getSocketAddress() + " : " + message);
     }
   } catch (IOException e) {
     e.printStackTrace();
   } finally {
     if (socket != null) {
       socket.close();
     }
   }
 }
}

public class MulticastClient {
 public static final int PORT = 3000;
 public static final String MCAST_ADDR = "224.0.1.1";

 public static void main(String args[]) {
   Receiver receiver = new Receiver(PORT,  MCAST_ADDR);
   Sender sender = new Sender(PORT,  MCAST_ADDR);
   receiver.start();
   sender.start();
 }
}
```

### ソースコードの説明
上記のコードには以下の3つのクラスが含まれています。

- Senderクラス
- Receiverクラス
- MulticastClientクラス

それぞれについて説明します。

#### Senderクラス
Senderクラスは、データを送信するためのクラスです。受信と並列して動かすためにThreadクラスを継承して、runメソッドをオーバーライドしています。

**フィールド**

```java
 private final int toPort;
 private final String mcastAddrName;
```
toPortは送信先のポート番号（受信側が受信するポート番号）です。mcastAddrNameは送信先のマルチキャストアドレスの名前です。

**コンストラクタ**

```java

 public Sender(int toPort, String mcastAddress) {
   this.toPort = toPort;
   this.mcastAddrName = mcastAddress;
 }
```
変数を初期化します。

**runメソッド**

```java
 public void run() {
   Scanner scan = null;
   MulticastSocket socket = null;
   try {
     InetAddress mcastAddress = InetAddress.getByName(mcastAddrName);
     scan = new Scanner(System.in);
     socket = new MulticastSocket();

     String message;
     while ( (message = scan.nextLine()).length() > 0 ) {
       byte[] bytes = message.getBytes();
       DatagramPacket packet = new DatagramPacket(bytes, bytes.length, mcastAddress, toPort);
       socket.send(packet);
     }
   } catch (IOException e) {
     e.printStackTrace();
   } finally {
     if (scan != null) scan.close();
     if (socket != null) socket.close();
   }
 }
```
whileループ内だけ説明します。`message = scan.nextLine()`では標準入力から一行読み取って、`String message`に入れます。`byte[] bytes = message.getBytes()`では、`message`をバイト配列に変換します。`DatagramPacket packet = new DatagramPacket(bytes, bytes.length, mcastAddress, toPort)`では、上記のバイト配列、宛先のマルチキャストアドレス、宛先のポート番号を指定し、`DatagramPacket`を生成します。`socket.send(packet)`でパケットを送信します。

#### Receiverクラス
Receiverクラスは、データを受信するためのクラスです。送信と並列で動かすためにThreadクラスを継承して、runメソッドをオーバーライドしています。

**フィールド**

```java
 private final int fromPort;
 private final String mcastAddrName;
 private static final int PACKET_SIZE = 1024;
```
fromPortはパケットを受け取るポート番号です。mcastAddrNameはJoinするマルチキャストアドレスです。PACKET_SIZEは受け取るパケットのサイズです。

**コンストラクタ**

```java
 public Receiver(int fromPort, String mcastAddrName) {
   this.fromPort = fromPort;
   this.mcastAddrName = mcastAddrName;
 }
```
変数を初期化します。

**runメソッド**

```java
 public void run() {
   MulticastSocket socket = null;
   byte[] buf = new byte[PACKET_SIZE];
   DatagramPacket packet = new DatagramPacket(buf, buf.length);

   try {
     socket = new MulticastSocket(fromPort);
     InetAddress mcastAddress = InetAddress.getByName(mcastAddrName);
     socket.joinGroup(mcastAddress);

     while (true) {
       socket.receive(packet);
       String message = new String(buf, 0, packet.getLength());
       System.out.println(packet.getSocketAddress() + " : " + message);
     }
   } catch (IOException e) {
     e.printStackTrace();
   } finally {
     if (socket != null) {
       socket.close();
     }
   }
 }
```
whileループの中だけ説明します。`socket.receive(packet)`でパケットの受信を待機します。パケットを受信したら`String message = new String(buf, 0, packet.getLength())`によりバイト配列をStringに変換します。そして`System.out.println(packet.getSocketAddress() + " : " + message)`により、標準出力に出力します。

#### MulticastClientクラス

MulticastClientクラスでは、mainメソッドを定義しています。

**フィールド**

```java
 public static final int PORT = 3000;
 public static final String MCAST_ADDR = "224.0.1.1";
```
SenderクラスとReceiverクラスに渡すポート番号とマルチキャストアドレスを定義しています。

**mainメソッド**

```java
 public static void main(String args[]) {
   Receiver receiver = new Receiver(PORT,  MCAST_ADDR);
   Sender sender = new Sender(PORT,  MCAST_ADDR);
   receiver.start();
   sender.start();
 }
```
ReceiverクラスとSenderクラスをインスタンス化し、Threadをstartさせています。

## 検証環境を準備する

上記のチャットアプリを動かす環境をDockerで構築します。同一ネットワーク内に複数のクライアントを立ち上げるためにdocker-composeファイルを定義します。

### Dockerfile
Dockerfileは以下のようになります。

```dockerfile
FROM openjdk:14
COPY . /usr/src/myapp
WORKDIR /usr/src/myapp
RUN javac MulticastClient.java
CMD ["/bin/bash"]
```

Imageはopenjdk:14を使います。`javac MulticastClient.java`でコンパイルした後、`/bin/bash`でbashを開きます。実行するときは`docker container exec -it [CONTAINER ID] bash`で中に入ってから、`java MulticastClient`を叩きます。

本当は`CMD ["/bin/bash"]`のところを`CMD ["java", "MulticastClient"]`として実行しておき、containerの中に入りたかったのですが、やり方がわかりませんでした。

### docker-compose

docker-compose.ymlは以下のようになります。

```yml

version: '3'
services:
 client1:
   build:
     context: .
     dockerfile: Dockerfile
   tty: true
 client2:
   build:
     context: .
     dockerfile: Dockerfile
   tty: true
```

先ほどのDockerfileからImageをBuildします。`tty: true`を記述しておくとコンテナが起動したままになります。

client1と同じように、client2, client3...とコンテナを作成します。これらのコンテナは`docker-compose up`を実行すると自動で作成されるnetworkに属することになります。プライベートIPアドレスは自動で割り振られます。

## 検証してみる

それではクライアントを複数立ち上げてマルチキャストが動くか検証してみます。

コンテナを起動させるために以下のコマンドを実行します。

```shell
docker-compose up --build
```

コンテナに入るには以下のコマンドを実行します。CONTAINER IDは`docker container ls`で調べることができます。

```shell
docker container exec -it [CONTAINER ID] bash
```

アプリケーションを実行するにはコンテナ内で以下のコマンドを実行します。

```shell
java MulticastClient
```

3つコンテナを作って、それぞれクライアントを実行してみたのが以下の画像です。ターミナルを横に3つ並べています。

![20200609202314.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/263052/131d402b-a599-b473-372c-fe99cde350b3.png)


左のターミナルでHello!と入力すると、他のターミナルにもメッセージが表示されました！左のターミナルで実行しているコンテナのプライベートIPアドレスは`172.29.0.3`だということも分かりますね！

![20200609202323.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/263052/7ba11329-bdec-e138-5f70-aee964be3b2e.png)

真ん中のターミナルでHello!Hello!と入力しても他のターミナルに表示されます。真ん中は`172.29.0.2`ですね。

![20200609203052.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/263052/56a106a0-14c5-28b2-c2a2-6830d8525493.png)


右のターミナルも同じく動きます。

![20200609203100.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/263052/7ee61ad2-c887-6d92-9664-f0d95893e198.png)


## 感想

今回はJavaのMulticastSocketを使って、UDPのマルチキャストを送受信するチャットクライアントを作り、Dockerコンテナ・ネットワーク上で動かしてみました。UDPについて調べる過程でかなり勉強できたので良かったです。また、Dockerの動かし方についても学べたので一石二鳥でした！

## 参考

https://www.techscore.com/tech/Java/JavaSE/Network/5/

https://milestone-of-se.nesuke.com/nw-advanced/multicast/multicast-summary/

https://qiita.com/yosisa/items/a5670e4da3ff22e9411a