株式会社AbemaTV 配信チームの芝田 (@c-bata) です。

AbemaTVの配信システムに関する技術資料は、AbemaTV Developer Conferenceなどを通して公開してきました。特にHLSやMPEG-DASHといったエンドユーザーへの映像配信に利用しているプロトコルは既に弊社エンジニアによる解説記事が存在するため、本記事では生放送現場とトランスコーダー(Wowza)間の映像伝送に利用しているRTMPと呼ばれるプロトコルについて解説していきます。

また今回はGo言語を用いて実際にRTMP 1.0プロトコルに準拠するサーバープログラムを実装してみました。ソースコードはGithubで公開しているので、本記事の解説と合わせてご覧ください。

Server implementation of RTMP 1.0 protocol in Go.

https://github.com/c-bata/rtmp

目次

 

RTMPの概要

RTMP(Real-Time Message Protocol) は、Adobe社が制定したリアルタイムコミュニケーションのためのプロトコルです。HLSやMPEG-DASHは、クライアントがポーリング等によりサーバーから新しい映像情報を取得する必要がありますが、RTMPは送信元が任意のタイミングでデータをプッシュできるため、遅延を抑える事ができます。一方で、HTTPベースのプロトコルではないことからCDNのようなソリューションが利用できず大規模な配信には適していません。

またRTMPはその成り立ちから、Flashと相性がよく音声や映像の配信に広く利用されてきましたが、Adobe社が2020年にFlashのサポートを終了することからRTMPをエンドユーザーへの配信目的で利用することは少なくなってきました。しかし遅延が少ないことから、エンドユーザーがアクセスしない生放送の撮影現場とトランスコーダー (Wowza)間の映像伝送プロトコルとして、AbemaTVでも利用しています。

 

AbemaTVにおけるRTMPの利用箇所
AbemaTVにおけるRTMPの利用箇所

それではRTMPの仕様について解説していきます。RTMPの仕様はAdobe社のWebサイトで公開されています (http://www.adobe.com/devnet/rtmp.html を参照)※1

 

RTMPのハンドシェーク

RTMPの接続時にやりとりするメッセージの1かたまりを「チャンク(chunk)」と呼び、RTMP上でのやりとりはチャンクを基本単位としています。接続確立のためのハンドシェークでは、クライアントおよびサーバーそれぞれが3つの固定長チャンクをお互いに渡します。

RTMPハンドシェークの流れ
RTMPハンドシェークの流れ

クライアントが送信する3つの固定長チャンクはそれぞれ C0・C1・C2 と呼ばれ、サーバーが送信するチャンクは S0・S1・S2 と呼ばれています。さらにC0チャンクとS0チャンクおよびC1チャンクとS1チャンク、C2チャンクとS2チャンクはそれぞれ同じ構造を持ちます。今回はRTMPサーバーを作っていくために、サーバー側の動きを確認しましょう。

  1. クライアントから C0 および C1 を受け取るまで待つ
  2. C0を受け取ったら、S0 および S1 チャンクを生成しクライアントに送信
  3. C1を受け取ったら、S2チャンクを生成しクライアントに送信 ※2
  4. クライアントから C2 が送られるのを待つ
  5. C2 を受け取ったら接続確立完了

ハンドシェークの流れが確認できたところで、次は各チャンクのフォーマットを調べていきます。

C0 および S0 チャンクのフォーマット

C0 および S0 チャンクは、1バイト(オクテット)の固定長です。その構造を次に示します。

C0 / S0 チャンクの構造
C0 / S0 チャンクの構造

C0 および S0 チャンクは version フィールドのみを持ち、ここでは利用するRTMPのバージョンを指定します。RTMP v1.0 では、「3」が割り当てられています。「0」から「2」は、RTMP 1.0において既に非推奨とされており、RTMP 1.0準拠のサーバーでは、C0が示すバージョンに関係なく、S0チャンクでは「3」を指定します。

Goによる実装 – Github

C1 および S1 チャンクのフォーマット

C1 および S1 チャンクは、1536バイト固定長のチャンクです。

C1 / S1 チャンクの構造
C1 / S1 チャンクの構造

先頭4バイトが timestamp フィールド、続く4バイトは zero フィールド、残りは random bytes フィールドとなります。

  • timeフィールド: タイムスタンプ。0 もしくは 任意の値を指定。
  • zeroフィールド: ゼロ埋め
  • random bytes フィールド: 十分に衝突しない乱数値。暗号としてセキュアなものである必要はありません。

RTMPは映像や音声を多重化して送信可能なため、各映像および音声の順序をサーバークライアント間で同期する必要があります。timeフィールドは、接続確立後の同期のための基準時間となります。基準を指すだけですので、0でもその他の好きな値でも構いません。

Goによる実装 – Github

C2 および S2 チャンクのフォーマット

C2 および S2 チャンクも先程と同様に1536バイトの固定長です。

C2 / S2 チャンクの構造
C2 / S2 チャンクの構造

先頭4バイトと続く4バイトはそれぞれタイムスタンプのフィールドとなっており、残りの1528バイトのフィールドには、受け取ったC1およびS1チャンクの乱数値をそのまま格納します。

  • timeフィールド: C2 チャンクは S1 チャンクのタイムスタンプ、S2 チャンクは C1 チャンクのタイムスタンプを格納
  • time2フィールド: サーバーはC1パケットを読み取った時間、クライアントはS1パケットを読み取った時間のタイムスタンプを格納
  • random echo フィールド: C2チャンクは S1チャンクのrandom bytesフィールド、S2チャンクは C1 チャンクのrandom bytesフィールドを格納

最後にサーバーが受け取ったC2チャンクの random echo フィールドと、S1チャンクの random bytes フィールドが一致すれば、ハンドシェークの完了となります。

Goによる実装 – Github

ハンドシェークの実装 (Go 言語)

仕様を理解したところで、Go言語でRTMPサーバー側のハンドシェーク処理を書いてみます。

ハンドシェークを実装したコミットのリビジョンはこちらになります。
https://github.com/c-bata/rtmp/commit/45ca635781b68b13559e49dae0d47a0072aa2eb0

実際にサーバーを起動し、ffmpegからRTMPのリクエストを送信してみます。ソケット上を流れるバイト列から動作を確認してみます。簡易的なプロキシーを用意して出力したバイトストリームを次に示します。

ハンドシェークの様子

C0-C2、S0-S2のチャンクの仕様から既にわかっているので、バイトストリームのどの部分がどのチャンクであるかは各チャンクのバイト数から確認できます。プロキシの出力によると無事にC2チャンクの受け取りも完了しハンドシェークに成功していることが確認できます。

続いてffmpegが送信してきた、バイト列を確認していきましょう。

ハンドシェーク後に受信したメッセージ

このバイトストリームを受け取ったあと、サーバーはもちろんクライアントも何もメッセージを送ってきません。RTMPのハンドシェークは完了しましたが、ffmpegから映像は受け取るにはもう少し必要な手続きがあるようです。次はこのチャンクのフォーマットと意味を調べてみます。

 

チャンクのフォーマット

ハンドシェークが無事に終了し、次は本題のデータの送受信です。RTMPのやりとりは1つのTCP上で行われますが、複数の映像や音声のデータを同時に送受信するために、送信側で多重化して送信したものを受信側では時間の同期をとりながら元の映像・音声に分離します。こうすることで複数の映像や音声を同時に伝送することが可能になっています。

各データ(映像や音声)ごとのチャンクのまとまりを「チャンクストリーム」と呼び、各チャンクがどのチャンクストリームに属しているかは「チャンクストリームID」と呼ばれる一意な値で識別します。

チャンクストリームID

チャンクストリーム上では、メッセージがチャンクに分割され送信されます ※3。ハンドシェークでやりとりしていたC0-C2、S0-S2チャンクは少し特殊な構造でしたが、接続確立後にやりとりされるチャンクの構造は全て「ヘッダー」と「データ」の2つから成ります。さらにヘッダーは次の3つで構成されています。

  • Basic Header (ベーシックヘッダー)
  • Message Header (メッセージヘッダー)
  • Extended Timestamp (拡張タイムスタンプ)

Chunk Basic Headerのフォーマット

Chunk Basic Headerは、チャンクストリームIDやチャンクの種類(後で解説する fmt フィールドが指す)を含んでいます。1〜3バイトの可変長で、チャンクストリームIDに依存しています。この中には fmt とよばれるチャンクメッセージヘッダーの種類をしめすフィールドと、チャンクストリームIDが格納されています。チャンクストリームIDの計算は次の図で確認してください。

チャンクストリームIDの計算

先頭の 2 bits が、fmt ですがこのフィールドの中身は今は必要ありません。「チャンクメッセージヘッダー」のパースに必要な情報のため、次の節で解説します。問題はチャンクストリームID(cs id)の取り出しですが、fmtに続く6 bitsの数値からBasic Headerの長さが分かります。全て 0 もしくは 全て1 でなければその値がそのまま cs id となりますが、全て 0 もしくは全て 1のどちらかの場合は、それぞれ図中の計算式より計算します。※4

Chunk Message Headerのフォーマット

Chunk Message Headerは何度も同じ情報を送るのではなく省略を目的として、 0, 3, 7, 11バイトの長さをもつ4つの形式が用意されています。受けとりたいChunk Message Headerが何バイトの長さであるかは先程取り出した fmt フィールドから求まります。

メッセージヘッダーのフォーマット

  • timestamp (3 バイト): 名前の通りタイムスタンプです。もしこの値が 16777215 (0xFFFFFF) 以上となった場合、Message Headerの後ろに4 バイトExtended Timestampフィールドがくっついているのでこの4 バイトをパースして利用します。
  • message length (3 バイト): 送信されたメッセージの長さがここに入ります。チャンクの長さではなくメッセージの長さであることに注意。
  • message type id (1 バイト): メッセージの種類。
  • message stream id (4 バイト): メッセージストリームID。リトルエンディアン形式で格納。
  • timestamp delta (3 バイト): 前回受け取ったチャンクと現在のチャンクのタイムスタンプとの差分時刻が格納されています。もし16777215 (0xFFFFFF)以上の値になった場合は、16777215 を示す必要があります。その際の実際のタイムスタンプは、Message Headerの後ろにつづく4バイトのExtended Timestampフィールドを使用します。

Extended Timestamp のフォーマット

メッセージヘッダーの「timestamp フィールド」もしくは「timestamp deltaフィールド」が 16777215 (0xFFFFFF) の場合、Message Headerのうしろに4バイトExtended Timestampフィールドが続きます。ここに記述されている数値が実際のtimestamp または timestamp delta フィールドの値となります。

ffmpeg から送信されたチャンクのヘッダーをパースしてみる

チャンクヘッダーの構造がわかったため、先程 ffmpeg から送られていたチャンクのヘッダーを人力でパースしてみます。

ハンドシェーク後にffmpegが送信したチャンクのヘッダー

次はデータペイロードのパースです。この結果から、データの長さは 184 バイトであること、メッセージタイプIDが20であることがわかっています。184バイトのデータが何を表しているか調べていきましょう。

 

チャンクデータのパース

RTMPで送られてくるメッセージの種類は次のようなものがあります。受け取ったチャンクのメッセージ部分が何を表しているのかは、MessageHeader内のMessage Type IDで指定されていて、RTMP v1.0の仕様書では次のメッセージが定義されています。

  • 1 : プロトコル制御メッセージ / チャンクサイズの設定 (Set Chunk Size)
  • 2 : プロトコル制御メッセージ / 中断メッセージ (Abort Message)
  • 3 : プロトコル制御メッセージ / 承認 (Acknowledgement)
  • 4 : ユーザー制御メッセージ
  • 5 : プロトコル制御メッセージ / Window Acknowledgement Size
  • 6 : プロトコル制御メッセージ / 帯域幅の設定 (Set Peer Bandwidth)
  • 8 : 音声メッセージ
  • 9 : 映像メッセージ
  • 15, 18 : データメッセージ
  • 16, 19 : 共有オブジェクトメッセージ
  • 17, 20 : コマンドメッセージ
  • 22 : 集約メッセージ

メッセージタイプは「20」であったことから、先程 ffmpegがRTMPハンドシェークの確立後に送ってきていたのは「コマンドメッセージ(AMF0)」であることが分かりました。コマンドメッセージのペイロードは、少し特殊な構造をしているためもう少し詳しく見ていきましょう。

AMF(Action Message Format) について

コマンドメッセージは、クライアント・サーバー間におけるRPCとして利用できます。リクエストのペイロードは、AMF(Action Message Format) と呼ばれるバイナリフォーマットにシリアライズされていて、Adobe Systems IncのWebサイト上で仕様が公開されています(http://www.adobe.com/devnet/swf.html を参照)。

AMF には、AMF0とAMF3という仕様が存在します。コマンドメッセージのメッセージタイプとして 17 と 20 の2つ用意されていましたが、AMF0 は 20、AMF3 は 17に対応しています。今回は 20 だったため、AMF0 のオブジェクトとしてデータが渡されてきているようです。

本記事はRTMPのプロトコル解説がメインであるため、詳しくは触れませんが、プリミティブな型が少なく仕様書も11ページ程度にまとまっているので興味のある方は目を通しておくとデバッグ時に役立ちます。

今回はサードパーティーのライブラリの中から、AMF0 と AMF3 の両方に対応していて、ライセンスも扱いやすい zhangpeihao/goamf を選びました。ライブラリを使う上ではひとまずMessagepackやProtocol Buffersと同じようなシリアライズフォーマットらしいという理解で問題ありません。

先程受け取ったAMF0のペイロードは次のようにパースされます。

AMF0 ペイロードのパース

connect コマンドメッセージ受け取り後の流れ

仕様書によると「接続 (connect)」以外にも「ストリームの生成 (createStream)」「データの送信開始 (publish)」「再生 (play)」「一時停止 (pause)」といった命令のコマンドメッセージが用意されているようです。またコマンドメッセージの送信者に実行結果を伝えるため、「onStatus」や「result」のようなコマンドも存在します。

接続(connect)コマンドメッセージ受信後に何を返せばいいのかは仕様書には載っていませんでした。しかしチャンクヘッダーのフォーマットなどはすでにわかっているため、既存のRTMPサーバーのプロダクトが実際にどのようなチャンクを送信しているかは一通り解析できそうです。nginx-rtmp-moduleとffmpegの通信をプロキシして、流れているチャンクを解析した結果は次のようになります。 ※5

動画を送信するまでに必要なストリームの確立処理

少し必要なやりとりが多いですが、チャンクヘッダーを見ていけばこのようにパースできます。この挙動をもとに、サーバー側のレスポンスを実装してみましょう。onFCPublishのレスポンスメッセージフォーマットなどはRTMPの仕様書になく、リバースエンジニアリングや既存の実装を追いかける必要があり少し手こずりましたが、実装したものが次のリビジョンです。

https://github.com/c-bata/rtmp/commit/d479fbfb42e9c20ebf485a253420635edbd189c1

コマンドメッセージやユーザー制御メッセージのやりとりが無事に完了し、ffmpegから連続的に送信される動画データを受信できていることが確認できます。

 

発展

RTMPの仕様書を読みながら、RTMPのハンドシェークやその後のストリーム確立、連続的なデータの受信など、サーバーとして最低限クライアントからデータを受け取るために必要な機能をGo言語で実装しました。実用するには、まだ足りない機能も多くありますがRTMPのプロトコルの解説からは外れてしまうため本記事はここまでとします。さらに拡張を続ける際には、次のことに挑戦するのがよいでしょう。

  • RTMPクライアントから受け取った動画データをMPEG2-TSやfMP4にパッケージングし、HLSまたはMPEG-DASHで配信する。
  • Encryptionの仕組みを実装する。Adobe Media Serverの暗号化処理は、RTMPの仕様書には記述されていませんが、コンテンツ保護のために独自の暗号化処理を実装することに関してはライセンス上問題ありません。
  • UDPベースのプロトコルであるRTMFPサーバーを実装する

 

参考文献

 


1. 少し余談ですが、この仕様書は2012年に公開されました。それ以前に開発が始まっていたRed5(2005/2006)やWowza(2007)、rtmpdump(2012)はリバースエンジニアリングによって開発を続けてきたようです。

2. 図を簡略化するため、クライアントはS0およびS1チャンクを受信後C1チャンクを送信していますが、C1チャンクの送信はS0およびS1チャンクの受信を待つ必要はありません。

3. もし高解像度な映像を送信する場合、その映像のキーフレームを送信するだけで100ms以上かかることがあります。1つしかないTCPのコネクション上でそれだけ大きなものを送信していては、そのデータの転送が完了するまで他の音声やメタデータが送信できなくなってしまいます。このように1つのデータソースがRTMPのコネクションを支配的に占有しないようにするためにはチャンクのように細かい単位に分割することが重要でした。

4. 余談ですがチャンクストリームIDの 0-2 は予約されているため、3-65599のどれかの値をとります。これによりRTMPのプロトコル上、多重化できる最大のストリーム数は65597個となります。

5. Wowza Streaming EngineのEULAでは、リバースエンジニアリングを禁止しているため注意してください (参照: https://www.wowza.com/resources/WowzaStreamingEngine-4_LicenseAgreement.pdf )。またクライアント側の挙動を調べるために ffmpeg (および librtmp) に対してリバースエンジニアリングを行っていますが、ffmpegにリンクされているライブラリはEULAからリバースエンジニアリングの禁止項目を削除することが定められているためこちらは問題ありません (参照: https://www.ffmpeg.org/legal.html)。

 

芝田 将
2017年新卒入社。AbemaTV サーバーサイド 配信チーム所属。趣味はボルダリングと筋トレ、好きなものは鶏肉。