Dockerで、音声ストリーミング再生機能を作ってみた話

こんにちは。
Web・iOSエンジニアの三浦です。

今回は、HTTP_Live_Streaming（HLS）を使った音声ストリーミング再生機能を Docker 上で作ってみました。

ちなみに今回説明するコードは、以下のgithubで公開しています。
https://github.com/takayuki-miura0203/docker-hls-sample

# 準備
## 環境
Docker が実行できる必要があります。
また、 `/etc/hosts` に以下を記述してください。

```
127.0.0.1 front storage
```

## HTTP_Live_Streaming とは？
HTTP_Live_Streaming（HLS） は、 Apple が提供する動画・音声のストリーミング再生技術です。
https://developer.apple.com/streaming/

動画・音声ファイルに処理を加えて分割し、分割されたファイルを読み込むようにすることで、ストリーミング再生を実現します。
また分割されたファイルの読み込みについても特別なことをする必要はなく、通常の動画・音声ファイルを読み込む代わりにファイル分割時に生成されたメタファイルを読み込むようにすれば、通常の動画・音声ファイルを使うのと同じ方法で使用することができます。

## Dockerコンテナの構成
今回は、ユーザーから処理を受け付けるフロントと、作成した音声分割ファイルを保存しておくストレージを分けたかったため、以下のようなコンテナ構成にしています。

### web
nginx を使ったウェブサーバ。
ユーザーから受け付けた処理を、ドメインをもとにフロント・ストレージに振り分ける。

### front
主にユーザーからのアクセスを受け付ける php のバックエンド。
音声ファイルを POST で受け取って分割してストレージに渡す機能と、ストレージに存在する音声ファイルを再生できる画面を GET で提供する機能を持つ。

### storage
分割された音声データをフロントから受け取り、保持する。
php + apache で構成する。

# 実装してみる
## web
### Dockerfile
念の為 vim を入れていますが、基本的には `nginx` だけで十分です。

```dockerfile
FROM nginx:latest

RUN apt-get update && apt-get install -y \
    vim
```

### 設定ファイル
今回は、ドメイン名が `front` ならフロントに、 `storage` ならストレージに、それぞれ飛ばすようにしました。
また、音声ファイルのサイズが大きい場合はデフォルトの POST 可能サイズでは足りなくなるため、 `client_max_body_size` を大きめに指定しています。

```config
# front 用
server {
    index index.php index.html;
    server_name front;
    include /etc/nginx/mime.types;
    error_log  /var/log/nginx/error.log;
    access_log /var/log/nginx/access.log;
    root /app/public;
    client_max_body_size 20M;

    location / {
        try_files $uri $uri/ =404;
    }

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass front:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }
}

# storage 用
server {
    index index.php index.html;
    server_name storage;
    include /etc/nginx/mime.types;
    error_log  /var/log/nginx/error.log;
    access_log /var/log/nginx/access.log;
    root /var/www/html;
    client_max_body_size 20M;

    location / {
        try_files $uri $uri/ =404;
    }

    location ~ \.(php|m4a|m3u8|aac)$ {
        proxy_pass http://storage:80;
    }
}
```

## front
### Dockerfile
nginx と通信するため、 `php:fpm` を入れています。
また、音声ファイルの分割に使用するため、 `apt-get` で `ffmpeg` を入れています。

```dockerfile
FROM php:7.3.5-fpm

RUN apt-get update && apt-get install -y \
    git \
    unzip \
    vim \
    ffmpeg
```

### php.ini
デフォルトの `php.ini` では音声ファイルのサイズに対して POST での受け入れ可能サイズが小さい場合があるので、受け入れ可能サイズを変更します。
今回は `php.ini` ごとこちらの管理配下におき、マウントするようにしています。

```ini
post_max_size = 20M 
upload_max_filesize = 20M
```

### php
#### GET
GET でアクセスが来たらストリーミング・オリジナル両方の再生ができるようにしています。
なお、 `m3u8` の拡張子のファイルが、ファイル分割時に生成されるメタファイルです。

```php
<?php

// show audio player when GET requested
if ($_SERVER["REQUEST_METHOD"] == "GET") {
    $streamingUrl = "http://storage:8080/{$_GET['name']}/playlist.m3u8";
    $originalUrl = "http://storage:8080/{$_GET['name']}/original.m4a";
?>

<span>streaming</span>
<br>
<audio src=<?php echo $streamingUrl ?> controls />
</audio>

<br>
<br>

<span>original</span>
<br>
<audio src=<?php echo $originalUrl ?> controls />
</audio>
```

#### POST
POST でアクセスが来た場合は、 `body` にて渡された音声ファイルを分割し、ストレージに渡します。
音声ファイル分割には `ffmpeg` コマンドを利用しています。

```php
<?php

// create streaming file when POST requested
if ($_SERVER["REQUEST_METHOD"] == "POST") {
    // delete audio file if exists
    $fileInfo = pathinfo($_FILES['audio']['name']);
    if (file_exists($fileInfo['filename'])) {
        exec("rm -rf {$fileInfo['filename']}");
    }

    // get audio file
    exec("mkdir {$fileInfo['filename']}");
    move_uploaded_file(
        $_FILES['audio']['tmp_name'],
        "./{$fileInfo['filename']}/original.{$fileInfo['extension']}"
    );

    // create streaming files
    exec("
ffmpeg \
-i {$fileInfo['filename']}/original.{$fileInfo['extension']} \
-map 0 \
-f segment \
-acodec aac \
-segment_list {$fileInfo['filename']}/playlist.m3u8 \
-segment_time 5 \
{$fileInfo['filename']}/stream-%03d.aac
    ");

    // post files to storage
    $postFields = ['directory' => $fileInfo['filename']];
    $audioFiles = array_filter(glob($fileInfo['filename'] . '/*'), function($audioFile) {
        return is_file($audioFile);
    });
    $audioFiles = array_values($audioFiles);
    foreach ($audioFiles as $key => $audioFile) {
        $postFields += ["audio[{$key}]" => new CURLFile(htmlspecialchars($audioFile))];
    }

    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, 'http://storage:80/index.php');
    curl_setopt($ch, CURLOPT_POST, 1);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields);
    curl_exec($ch);

    // delete audio files
    exec("rm -rf {$fileInfo['filename']}");
}
```

## storage
### Dockerfile
ストレージではフロントと異なり `php` 以外のファイルも通信したかったため、 `php:apache` を入れています。

```dockerfile
FROM php:7.3.5-apache

RUN apt-get update && apt-get install -y \
    git \
    unzip \
    vim
```

### php.ini
フロントと同じく、 POST 通信のサイズ上限を上げるため `php.ini` に変更を加えています。

```ini
post_max_size = 20M 
upload_max_filesize = 20M
```

### php
フロントから受け取った音声ファイルを保存します。

```php
<?php

// delete audio files if exists
if (file_exists($_POST['directory'])) {
    exec("rm -rf {$_POST['directory']}");
}

// get and save audio files
exec("mkdir {$_POST['directory']}");
foreach ($_FILES['audio']['tmp_name'] as $key => $tmpName) {
    move_uploaded_file(
        $tmpName,
        "./{$_POST['directory']}/{$_FILES['audio']['name'][$key]}"
    );
}
```

## docker-compose
docker-compose.yml にて、ウェブサーバとフロント・ストレージをリンクさせるようにします。

```yml
version: '3.7'

services:
  web:
    build: ./web
    ports:
      - 8080:80
    links:
      - front
      - storage
    volumes:
      - ./web/conf.d:/etc/nginx/conf.d
  front:
    build: ./front
    volumes:
      - ./front/php.ini:/usr/local/etc/php/php.ini
      - ./front/app:/app
  storage:
    build: ./storage
    volumes:
      - ./storage/php.ini:/usr/local/etc/php/php.ini
      - ./storage/app/public:/var/www/html
```

# 実行！
## コンテナ作成
まずはコンテナを作成します。

```bash
docker-compose up
```

## 音声ファイル分割
続いて音声ファイルを渡し、ストリーミング用の音声ファイルを作成します。
**今回のコードではm4aファイルにのみ対応しているので、注意してください。**

```bash
curl -i -X POST \
   -H "Content-Type:multipart/form-data" \
   -F "audio=@\"{audio_file}\";type=audio/x-m4a;filename=\"{audio_file_name}\"" \
 'http://front:8080/index.php'
```

これで音声ファイルが分割され、準備ができました。

## ストリーミングで聞いてみる
ブラウザで以下の URL を叩き、実際にストリーミングで聞いてみましょう。
ただし、 **2019/05/25現在Chromeには対応していません。**
safari で聞くようにしましょう。

http://front:8080/index.php?name={audio_file_name}

## iOSで聞いてみる
せっかくなので、 iOS アプリで聞けるか試してみましょう！
これには xcode が必要ですので、ご注意ください。

`info.plist` で、 HTTP が使用できるようにしてください。
その上で StoryBoard上で AVPlayerViewController を作成し、以下のコードを紐づけてください。

```swift
import Foundation
import AVFoundation
import AVKit

class AVAudioPlayerViewController: AVPlayerViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        // storage の URL を指定することで、直接ストリーミング用のメタファイルを fetch することが可能です
        let url = URL(string: "http://storage:8080/{audio_file_name}/playlist.m3u8")
        let avAsset = AVURLAsset(url: url!)
        let playerItem = AVPlayerItem(asset: avAsset)
        let player = AVPlayer(playerItem: playerItem)
        
        self.player = player
        self.player?.play()
    }
}
```

シミュレータなどでこの画面を表示すれば、ストリーミングで音声が流れ出すはずです！

# さいごに
ストリーミング再生には特殊な技術が必要で大変そう、何らかのサービスが必要なのでは…と思っていたのですが、やってみると案外簡単にできました。
Chromeで再生できないので Web サイトでどのくらい使えるかはまだ未知数ですが、アプリで使うのであれば十分使えそうです！

# 参考文献
以下のサイトを参考にさせていただきました。
ありがとうございました！

https://qiita.com/mochizukikotaro/items/b398076cb57492980447
https://qiita.com/bossunn24/items/85ca5c3bfbba07b4e0cc
https://qiita.com/okumurakengo/items/5627326ee833a3a5ea03
https://heartbeats.jp/hbblog/2012/04/nginx05.html
https://koni.hateblo.jp/entry/2017/01/28/150522
https://qiita.com/takecian/items/639deeae094466de6546
http://blogs.rastafactory.co.jp/art/2013/01/28/php-%E3%82%A2%E3%83%83%E3%83%97%E3%83%AD%E3%83%BC%E3%83%89%E3%81%AE%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%B5%E3%82%A4%E3%82%BA%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6/
https://heartbeats.jp/hbblog/2012/04/nginx04.html
https://gray-code.com/php/get-kind-of-file/
https://www.javadrive.jp/phpappli/keijiban/index3.html
https://qiita.com/Quantum/items/7e6e3e7a3bdf605c306a
https://qiita.com/momoto/items/b34fb9b908ffb26c76a4
https://qiita.com/tukiyo3/items/4162bd793be47d8651a8
https://blog.sioyaki.com/entry/2018/04/20/102344
http://ysklog.net/php/2873.html
http://omega.lid-inc.com/%E3%80%90php%E3%80%91%E3%80%80%E6%8C%87%E5%AE%9A%E3%83%87%E3%82%A3%E3%83%AC%E3%82%AF%E3%83%88%E3%83%AA%E3%83%95%E3%82%A9%E3%83%AB%E3%83%80%E3%82%92%E3%82%B5%E3%83%96%E3%83%87%E3%82%A3%E3%83%AC/
https://qiita.com/yakimeron/items/34a65397c1041c0b2a0c
https://teratail.com/questions/135190