はじめに
AWS IoT Coreを使ったデータ収集基盤の構築を進めるにあたり、その土台となるMQTTの理解が不足していると感じたため、備忘録的にまとめてみた。MQTTは単体で読んでも輪郭がつかみにくい。普段Webで使っているHTTPと何が違うのかを軸にすると、なぜこういう設計になっているかが見えてくる。そこでHTTPとの対比で進める。以下のサイトを参照。
MQTT 5.0の仕様書(OASIS標準)
https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html
MQTT公式サイト
https://mqtt.org/
HiveMQによる解説シリーズ
https://www.hivemq.com/blog/mqtt-essentials-part-1-introducing-mqtt/
結論
一言でいうと、こうなる。
| たとえると | |
|---|---|
| HTTP | 客が店に注文しに行く |
| MQTT | 新聞を購読する |
HTTPでは、欲しい人が「ください」と言いに行く。言わなければ何も来ない。
MQTTでは、記者は読者が誰かを知らないまま記事を出し、読者は記者を指定せずに紙面を受け取る。あいだに立つ新聞社が配達を引き受ける。この新聞社にあたるのがBroker(ブローカー)である。
この違いが、以降のほぼすべての設計判断を決めている。
HTTPとMQTTは何が違うのか?
HTTPはリクエスト/レスポンス型
1つの要求に1つの応答が返る。サーバは聞かれるまで黙っている。
MQTTはPub/Sub型
送り手(Publisher)と受け手(Subscriber)が直接つながらない。以降、送る側をPublisher、受け取る側をSubscriber、あいだに立つ仲介者をBrokerと呼ぶ。
PublisherはSubscriberの存在を知らない。Brokerに投げるだけである。Subscriberも、どのPublisherが出したかを知らなくてよい。
両者が互いを知らずに済むことを疎結合という。新たにSubscriberを追加するときも、Publisher側は何も変えなくてよい。ここがPub/Subの効きどころである。
動き方も非対称になる。Publisherは値が変わった、閾値を超えた、タイマーが鳴った、といった契機で投げるイベント駆動である。一方Subscriberは、接続後にSUBSCRIBEを1回送って登録したら、あとはひたすら待ち受ける。届いたら登録しておいた処理が呼ばれる。
待ち受けるといってもポートを開けて待つわけではなく、自分から張った接続の上を逆向きに流れてくるのを待っている。だから受け側にファイアウォールの穴を開けなくて済む。
なぜIoTの分野でHTTPが不利なのか?
HTTPが悪いわけではなく、想定している状況が違う。IoT特有の事情でHTTPが噛み合わなくなる点が4つある。
1. サーバから機器へ話しかけられない
HTTPはクライアントから話しかける前提なので、「クラウドから機器へ設定を配る」ができない。機器が定期的に「何かありますか」と聞きに行く(ポーリング)しかない。
1分おきに1万台が聞きに来れば、その大半は「何もありません」という空振りになる。
2. 接続の張り直しコストが効いてくる
HTTPでは接続が維持されるのは通信が続いているあいだだけで、サーバやプロキシは無通信の接続を数十秒から数分で切る。5分おきに温度を送る機器は、毎回切れた接続を張り直すことになる。
TCPの3ウェイハンドシェイクに加え、HTTPSならTLSハンドシェイクも走る。5バイトの温度値を送るために、その何十倍もの手続きが要る。
3. ヘッダが重い
HTTPは人間が読めるテキスト形式でヘッダが数百バイトになる場合がある。MQTTの最小ヘッダは2バイトである。バイナリ形式で、削れるものは全部削ってある。
4. 一対多が作れない
同じデータを3つのシステムに配りたければ、HTTPでは3回送るか、受け側で分配する仕組みを自作する。MQTTはBrokerが配る。
宛先はどう指定するのか?
HTTPならURLで宛先を指定するが、MQTTは相手を直接指定しない。ではどこへ送るのか。答えがトピックである。
MQTTの宛先はトピックと呼ばれ、スラッシュ区切りの階層で書く。ファイルパスに似ている。
building/3f/room301/temperature
building/3f/room301/humidity
building/4f/room402/temperature
Subscriberはワイルドカードで範囲を指定できる。
| 指定 | 一致する範囲 |
|---|---|
building/3f/+/temperature |
3階の全部屋の温度(+は1階層ぶん) |
building/# |
buildingより下のすべて(#は以下すべて) |
+は1階層に一致し、#は残り全部に一致する。#はトピックの末尾にしか置けない。
HTTPのURLとの違いは、トピックが「資源の場所」ではなく「話題の分類」だという点にある。GET /api/rooms/301/tempは取りに行く場所を指すが、トピックには誰も取りに行かない。掲示板の板の名前に近い。
事前にトピックを登録する必要はなく、Publishした時点で存在する。だからこそ命名規則を先に決めておかないと、あとで収拾がつかなくなる。
MQTTはどんなパケットをやり取りするのか?
宛先が決まったら、次は実際に何が飛び交うかである。
パケットの中身
MQTTのパケットは3層でできている。
| 層 | 大きさ | 中身 |
|---|---|---|
| 固定ヘッダ | 2〜5バイト | 種別、フラグ(DUP / QoS / Retain)、残りの長さ |
| 可変ヘッダ | 種別による | PUBLISHならトピック名とパケットID |
| ペイロード | 任意 | 送りたいデータそのもの |
固定ヘッダだけが全パケット共通で、あとの2つは種別によって有無が変わる。「MQTTの最小ヘッダは2バイト」というのはこの固定ヘッダのことで、生存確認のPINGREQのように可変ヘッダもペイロードも持たないパケットは、本当に2バイトで済む。
ペイロードの形式をMQTTは規定していない。ただのバイト列で、JSONでもCSVでもバイナリでも自由である。HTTPがContent-Typeで形式を宣言するのに対し、MQTTには相当するヘッダがない(5.0で任意項目として追加された)。実務ではJSONが多いが、帯域を詰めたい場合はMessagePackなどを使う。受け手が形式を知っている前提になるので、トピック設計と一緒に取り決めておく。
パケットの種類
種別は全部で14ある。用途ごとに並べると次のようになる。
| 用途 | パケット | 数 |
|---|---|---|
| 接続する | CONNECT、CONNACK | 2 |
| 切断する | DISCONNECT | 1 |
| 生存を確かめる | PINGREQ、PINGRESP | 2 |
| 購読する | SUBSCRIBE、SUBACK | 2 |
| 購読をやめる | UNSUBSCRIBE、UNSUBACK | 2 |
| メッセージを送る | PUBLISH | 1 |
| 送達を確認する | PUBACK、PUBREC、PUBREL、PUBCOMP | 4 |
この14個の名前は、固定ヘッダに入る「種別」の値に対応している。可変ヘッダやペイロードの中身の名前ではなく、パケットそのものの種類を指す。なお14種類はMQTT 3.1.1のもので、5.0では拡張認証用のAUTHが加わって15種類になる。
名前の付き方には規則があり、語尾を見れば役割が分かる。
| 語尾 | 意味 | 例 |
|---|---|---|
| ACK | Acknowledgement(受け取った) | CONNACK、SUBACK、PUBACK |
| REC | Received(受信した) | PUBREC |
| REL | Release(解放してよい) | PUBREL |
| COMP | Complete(完了した) | PUBCOMP |
PUBACKは「PUBLISHを受け取りました」という返事で、パブアックと読む。
上の表で、送達を確認するパケットだけが4つある。ここが次のテーマになる。
届いたかどうかはどう保証するのか?
不安定な回線で使うプロトコルである以上、「送ったが届かなかった」は起こる。そこでMQTTは、どこまで確実に届けるかを選べるようにした。これがQoSである。
QoSはQuality of Serviceの略で、キューオーエスと読む。3段階あり、HTTPにはないMQTT固有の仕組みである。
先に押さえておきたいのは、QoSが1区間ごとの取り決めだという点である。Brokerが決めるものではなく、PublisherがPUBLISHのときに、SubscriberがSUBSCRIBEのときに、それぞれ自分で指定する。したがってPublisher→Brokerと、Broker→Subscriberは別々のQoSを持つ。PublisherがQoS 2で送っても、SubscriberがQoS 0で購読していればそこは投げっぱなしになる。端から端まで見たとき、実際に効くのは両者の小さい方である。
以下では1区間、つまりPublisherとBrokerのあいだを例に見ていく。受領確認の正体はパケットIDである。送信側がメッセージに2バイトの通し番号を振り、受信側がその番号を返す。番号が一致すれば「あの1通が届いた」と分かる。
QoS 0 投げっぱなし
TCPが運ぶので経路上でデータが壊れることはないが、接続が切れれば消える。Brokerが受け取ったかどうかをPublisherは知らない。1秒おきの温度のように、1回落ちても次が来る種類のデータに向く。
QoS 1 最低1回
PUBACKが一定時間たっても返って来なければ、送信側は同じパケットID 42で再送する。このときDUP(重複)フラグを立てる。
ここで重複が生まれる。Brokerには最初のPUBLISHが届いていて、PUBACKだけが落ちた場合、同じメッセージが2回配信される。これが「最低1回」の意味である。
QoS 2 正確に1回
2往復する理由は、「受け取った」と「確定した」を分けるためである。BrokerはPUBRECの時点でID 42を記憶し、同じIDが再送されてきても捨てる。PUBRELで配信を確定し、PUBCOMPで記憶を消す。この記憶があるおかげで重複が消える。
なお、配信の実タイミングは仕様上2通り許されており、PUBLISH受信時に配ってPUBRELでは記憶だけを消す実装もある。どちらでも「重複して配られない」という保証は変わらない。
どれを選ぶか
QoSが上がるほど往復が増え、Brokerが覚えておく状態も増える。確実さと引き換えに遅くなり重くなるので、実務ではQoS 1が基本になる。落ちても次が来るデータはQoS 0、重複が許されない課金や制御はQoS 2、という選び方をする。
なぜ接続を張りっぱなしにするのか?
ここまでの仕組みは、いずれも接続が生きていることを前提にしている。通信が途切れれば切られる、というHTTPの前提では成り立たない。MQTTがTCP接続を意図的に維持し続けるのはそのためで、ここから3つの性質が出てくる。
サーバからいつでも送れる
接続は機器側から張っているので、ファイアウォールにインバウンドの穴を開ける必要がない。外から入ってくるのではなく、内から張った通り道を逆向きに流れてくるだけである。
組織のファイアウォールは、外から中への接続には厳しく、中から外への接続には緩いのが普通なので、この性質が効く。
Keep Alive
接続が生きているかを確かめる必要がある。ケーブルが抜けてもTCPはしばらく気づかない。
そこでクライアントは接続時にKeep Alive間隔(秒)を宣言し、その間に送るものがなければPINGREQを送る。BrokerはPINGRESPを返す。Broker側は、宣言された間隔の1.5倍を過ぎても何も来なければ、その接続を切る。
LWT(遺言)
Last Will and Testamentの略。接続時に「私が突然消えたら、このトピックにこのメッセージを流してください」とBrokerに預けておく仕組みである。
| 項目 | 例 |
|---|---|
| Will Topic | building/3f/gw01/status |
| Will Message | offline |
| Will QoS | 1 |
| Will Retain | true |
停電やケーブル断でBrokerが切断を検知すると、Brokerが代理でこのメッセージをPublishする。監視側はstatusを購読しておくだけで、機器の死亡を知ることができる。
正常なDISCONNECTを送って切った場合、遺言は送られない。「事故で死んだときだけ発動する」という設計になっている。HTTPには相当する概念がない。
Brokerは中で何をしているのか?
遺言を預かって代理で発言する、というのは単なる中継器の仕事ではない。ここまで見てきたQoSの再送管理も含めて、Brokerは状態を持っている。中身を見ていく。
Retain(最後の1通を保持する)
温度センサーが1時間おきに値を送っているとする。10:00に送信し、次は11:00。10:05に画面を開いて購読を始めると、11:00まで画面は空のままになる。10:00のメッセージはすでに配り終わっており、Brokerには残っていないからである。
retainフラグを立てて送ると、Brokerはそのトピックの最後の1通を保管し、新しく購読した相手に即座に配る。
| 時刻 | retainなし | retainあり |
|---|---|---|
| 10:00 | Publish(配り終えて消える) | Publish(Brokerが保管) |
| 10:05 | Subscribe開始。画面は空 | Subscribe開始。10:00の値が即座に届く |
| 11:00 | Publish。ようやく表示される | Publish。保管も上書きされる |
目的は、開いた瞬間に現在値が分かることである。ダッシュボードや機器の稼働状態(online/offline)に使う。
逆に、イベントの通知には使わない。「ボタンが押された」をretainすると、後から接続した相手に過去の押下が配られて誤動作する。状態にはretain、イベントにはretainなし、が原則になる。
トピックあたり1通だけ保持される。消したいときは、同じトピックにretain付きで空のペイロードを送る。
セッション
セッションとは、Brokerがクライアントごとに覚えている情報の束である。中身は主に3つ。
- 購読しているトピックの一覧。
- 未配信のメッセージ(QoS 1以上)。
- 進行中のQoSのやり取り(パケットIDの状態)。
接続時に、これを毎回まっさらにするか、前回の続きから始めるかを選べる。まっさらにする場合、購読は接続のたびに登録し直し、切断中に届いたメッセージは失われる。続きから始める場合、再接続するだけで購読が復活し、切断中のメッセージも受け取れる。
どちらのセッションかはクライアントIDで紐づく。Brokerは接続してきたIDでセッションを検索し、同じIDなら前回の状態を復活させる。したがってIDが毎回変わると、続きから始める設定にしても意味がない。IDは機器ごとに固定し、かつ一意にする(同じIDで2台が同時接続すると、先につないでいた方が切断される)。
使い分けは用途で分かれる。回線が不安定でデータを落としたくない機器側は「残す」、現在値さえ見えればよい画面側は「残さない」が定石である。画面側で残すと、開いた瞬間に古いデータが大量に流れ込んで邪魔になる。現在値はretainで取ればよい。
キューの上限はMQTTの仕様では決まっておらず、Brokerの設定になる。上限は必ず設けられており(クライアントあたり何件、全体で何MBといった形)、超えたら古いものから捨てるか新規を拒否する。長期間つながらない機器を放置するとBrokerの資源を食うためである。
保持期間については、MQTT 3.1.1にはセッションの期限という概念がなく、明示的に消すまで残る。5.0でSession Expiry Interval(秒)が導入され、「切断後1時間で捨てる」といった指定ができるようになった。
なおキューに貯まるのはQoS 1以上だけで、QoS 0は貯まらない。
共有サブスクリプション
通常、同じトピックを購読している相手が3つあれば、3つ全部に同じメッセージが届く。
共有サブスクリプションを使うと、グループ内の1つにだけ配られる。トピックの頭に$share/{グループ名}/を付ける。たとえば$share/workers/building/3f/+/temperatureと指定する。
同じ指定で3つのワーカー(メッセージを受け取って処理するプログラム)が購読すれば、メッセージが1つずつ振り分けられる。通常のSubscribeでは3つ全部に同じものが届いてしまうため、処理を分担したいときにこれを使う。MQTT 5.0で標準化された機能で、3.1.1では独自拡張として実装しているBrokerもある。
結局どこで使えばよいのか?
ここまでの性質が、実際の用途にどう効いているかを並べる。
| 用途 | なぜMQTTなのか |
|---|---|
| 工場設備・ビルの遠隔監視 | 多数の機器から少量のデータが上がる。ヘッダの軽さと1対多が効く。 |
| コネクテッドカー | トンネルや地下で回線が切れ続ける。セッションを残せば圏外のあいだBrokerが貯め、復帰時にまとめて届く。 |
| 物流・資産トラッキング | 電池と従量課金の回線で動く。位置情報は数十バイトなのに、HTTPSだとヘッダとハンドシェイクで数キロバイトかかる。 |
| スマートホーム | クラウドから機器へ制御を送る必要がある。双方向。 |
| モバイルアプリの通知 | サーバから話しかけられないとポーリングになり電池を食う。接続1本を維持するだけで待てる。 |
共通しているのは、多数の端末が、細かいデータを、不安定な回線で、双方向にやり取りする状況である。逆にいえば、少数のクライアントが大きなファイルを取りに行くような用途ではHTTPの方が素直に書ける。
用途で選ぶなら、次の観点になる。
- サーバから機器へ話しかける必要があるか。あるならMQTT。
- 1つのデータを複数の宛先に配るか。配るならMQTT。
- 通信が細かく高頻度か。そうならMQTT。
- 単発のファイル取得やAPI呼び出しか。そうならHTTP。
まとめ
- HTTPはリクエスト/レスポンス型、MQTTはBrokerを挟むPub/Sub型。PublisherとSubscriberが互いを知らずに済む。
- トピックは資源の場所ではなく話題の分類。ワイルドカードで範囲を購読できる。
- パケットは固定ヘッダ・可変ヘッダ・ペイロードの3層。ペイロードの形式は規定がなく、PublisherとSubscriberで取り決める。
- パケット名にはACK・REC・REL・COMPという規則がある。PUBACKはPUBLISHの受領確認。
- QoSは0/1/2の3段階で、パケットIDの往復で確認する。1区間ごとの取り決めで、効くのは両者の小さい方。
- 接続を張りっぱなしにすることで、双方向通信とヘッダの軽さを得ている。代わりにKeep AliveとLWTで生死を管理する。
- Brokerは状態を持つ。Retainは開いた瞬間に現在値を見せるため、セッションは切断中のメッセージを貯めるためにある。