MQTT 是一種輕量的發布/訂閱訊息協定,專為低頻寬、連線不穩的裝置設計;Mosquitto 則是最多人自架的開源 MQTT broker。用 Docker 起一台只要一個 compose 檔,加上密碼檔、ACL 與 TLS 就是可上線的組態,閒置記憶體約 3.5 MB。
這篇不是概念導讀,而是把一台 broker 從零建到「有帳密、有權限、有加密、有健康檢查」的完整流程,每一步都附上實際跑出來的輸出。測試環境:Ubuntu 24.04、Docker 27、eclipse-mosquitto:2(broker 2.1.2)。
為什麼是 Mosquitto
| 項目 | 實測值/說明 |
|---|---|
| 版本 | 2.1.2(官方 :2 映像,2026-02-09 發布) |
| 映像大小 | 41.8 MB |
| 閒置記憶體 | 3.5 MiB(匿名模式)/12.3 MiB(開 TLS+ACL) |
| 授權 | EPL-2.0 或 EDL-1.0 雙授權 |
| 慣用埠 | 1883(MQTT)、8883(TLS)、9001(WebSocket)、9883(2.1 起的 HTTP API);只有 1883 是真的預設值 |
| 叢集 | 無。單節點設計,要橫向得靠 bridge 或換 EMQX/HiveMQ |
選型邏輯很簡單:裝置數量在數千台以下、只需要一個可靠的中介,Mosquitto 的資源足跡幾乎可以忽略;需要百萬連線或跨區容錯時再談其他 broker。
先搞懂五個概念
| 概念 | 一句話 | 常見誤解 |
|---|---|---|
| Topic | 分層字串,如 home/kitchen/plug/state | 以為要先「建立」主題;其實 publish 即建立 |
| QoS 0 | 送出即忘,可能遺失 | 用在高頻溫濕度沒問題,掉一筆不影響趨勢 |
| QoS 1 | 至少送達一次,可能重複 | 開關指令要冪等(重送兩次不能變成「開→關」) |
| QoS 2 | 恰好一次,兩次往返 | 多花一倍延遲,只在計費、門鎖這類場景值得 |
| Retain | 保留最後一筆給新訂閱者 | 以為是「歷史訊息」;它只留最後一筆 |
MQTT 2.1 的預設封包上限是 2,000,000 bytes(舊版實質上接近 256 MB)。也就是說,想用 MQTT 傳 firmware 或攝影機影格,升級後會突然被切斷——這一點後面有實測。
萬用字元只有兩種
| 符號 | 匹配 | 限制 |
|---|---|---|
+ | 剛好一層 | home/+/temp 中 home/a/temp 命中、home/a/b/temp 不命中 |
# | 多層 | 只能放在結尾,home/# 可以,home/#/temp 不行 |
$SYS/... | broker 自身統計 | 不會被 # 匹配,要明確訂閱 |
$share/<組名>/<主題> | 共享訂閱,同組只送一份 | 用來讓多個 worker 分攤工作 |
實測 home/+/+ 的行為:只會收到兩層的主題,三層的 home/a/b/c 不會進來。
五分鐘起一台(可上線的組態)
先建目錄與設定檔:
mkdir -p ~/mosquitto/{config,data,log}
cd ~/mosquitto
config/mosquitto.conf:
listener 1883 0.0.0.0
allow_anonymous false
password_file /mosquitto/config/pwfile
acl_file /mosquitto/config/acl
listener 9001 0.0.0.0
protocol websockets
allow_anonymous false
password_file /mosquitto/config/pwfile
acl_file /mosquitto/config/acl
persistence true
persistence_location /mosquitto/data/
log_dest stdout
connection_messages true
max_keepalive 120
docker-compose.yaml:
services:
mosquitto:
image: eclipse-mosquitto:2
container_name: mosquitto
restart: unless-stopped
ports:
- "1883:1883"
- "9001:9001"
volumes:
- ./config/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
- ./config/pwfile:/mosquitto/config/pwfile:ro
- ./config/acl:/mosquitto/config/acl:ro
- ./data:/mosquitto/data
- ./log:/mosquitto/log
healthcheck:
test: ["CMD-SHELL", "mosquitto_sub -h 127.0.0.1 -p 1883 -u healthcheck -P $$MQTT_HEALTH_PASS -t '$$SYS/broker/uptime' -C 1 -W 3 >/dev/null 2>&1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
environment:
MQTT_HEALTH_PASS: ${MQTT_HEALTH_PASS:?set MQTT_HEALTH_PASS in .env}
建立帳號(mosquitto_passwd 產生 bcrypt 雜湊,檔案裡看不到明碼):
docker run --rm -v ./config:/mosquitto/config eclipse-mosquitto:2 \
mosquitto_passwd -c -b /mosquitto/config/pwfile dennis 'Str0ng-Pass!2026'
docker run --rm -v ./config:/mosquitto/config eclipse-mosquitto:2 \
mosquitto_passwd -b /mosquitto/config/pwfile healthcheck 'hc-secret'
config/acl:
user dennis
topic readwrite home/#
topic read $SYS/#
user healthcheck
topic read $SYS/broker/uptime
pattern readwrite clients/%u/#
echo "MQTT_HEALTH_PASS=hc-secret" > .env
docker compose config --quiet && echo COMPOSE_OK
docker compose up -d
實測啟動約 3 秒後即可連線,docker compose up -d 回傳 0;健康檢查在 15 秒 start_period 後轉為 healthy:
$ docker inspect --format '{{.Name}} health={{.State.Health.Status}}' mosquitto
/mosquitto health=healthy
$ docker exec mosquitto mosquitto_sub -h 127.0.0.1 -u healthcheck -P hc-secret \
-t '$SYS/broker/uptime' -C 1 -W 3
21 seconds
健康檢查刻意用獨立帳號只讀 $SYS/broker/uptime,原因是這個探針每 30 秒連一次,用主帳號會讓 $SYS 的連線數統計失真,也讓主帳號的密碼躺在 compose 檔裡。
驗證:發布、訂閱、Retain
# 終端機 A
docker exec -it mosquitto mosquitto_sub -h localhost -u dennis -P 'Str0ng-Pass!2026' -t 'home/#' -v
# 終端機 B
docker exec mosquitto mosquitto_pub -h localhost -u dennis -P 'Str0ng-Pass!2026' \
-t 'home/livingroom/temp' -m '26.4' -q 1
home/livingroom/temp 26.4
home/kitchen/plug/state ON
Retain 的行為(新訂閱者立刻補到最後一筆):
$ mosquitto_pub ... -t 'home/sensor/status' -m 'online' -r -q 1
$ mosquitto_sub ... -t 'home/sensor/status' -C 1 -v
home/sensor/status online
清除 retained 訊息要用「空訊息 + retain」:
mosquitto_pub ... -t 'home/sensor/status' -n -r
這一點很多人卡住:把 retain 欄位設 false 只影響「這一筆不再保留」,不會刪掉已經存在的那筆。
帳密與 ACL:三個實測行為
① 匿名連線直接被拒
$ mosquitto_sub -h localhost -t 'home/#' -C 1
Connection error: Connection Refused: not authorised
broker 端的日誌同時留下紀錄:Client ... disconnected: not authorised.
② 已授權的寫入成功、未授權的寫入報錯
$ mosquitto_pub -V 5 -u sensor01 -P 'Sensor-Pass-01' -t 'home/sensor/temp' -m '22.1' -q 1
(無輸出,exit 0)
$ mosquitto_pub -V 5 -u sensor01 -P 'Sensor-Pass-01' -t 'home/kitchen/light' -m 'ON' -q 1
Warning: Publish 1 failed: Not authorized.
③ 訂閱被 ACL 過濾時,「不會」報錯
這是本文最重要的一個坑。用 sensor01 去訂閱沒有權限的 home/module/#,MQTT 5 的 SUBACK 仍然回 Granted QoS 1,客戶端就靜靜等,永遠等不到訊息:
[suback] home/module/x -> [(1, 'Granted QoS 1')]
原因是 Mosquitto 的 ACL read 是在投遞時逐筆比對,不是訂閱時就拒絕。所以排查「明明訂閱了卻收不到」時,第一步看 broker 日誌、第二步用 mosquitto_sub -d 確認 SUBACK,第三步才是懷疑網路。
ACL 的兩個好用寫法:
# %u 會換成登入帳號,每個使用者自動有自己的命名空間
pattern readwrite clients/%u/#
# 反向:沒有明講的一律拒絕(2.0 起支援 deny)
topic deny $SYS/#
自簽 CA 開 TLS(含三個常見誤解)
# 1) CA
openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 3650 \
-subj "/CN=most.lab Test CA"
# 2) server 憑證(SAN 一定要寫)
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr \
-subj "/CN=mqtt.most.lab"
printf 'subjectAltName=DNS:mqtt.most.lab,DNS:localhost,IP:127.0.0.1\n' > san.cnf
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out server.crt -days 825 -extfile san.cnf
mosquitto.conf 加上:
listener 8883 0.0.0.0
allow_anonymous false
password_file /mosquitto/config/pwfile
acl_file /mosquitto/config/acl
cafile /mosquitto/certs/ca.crt
certfile /mosquitto/certs/server.crt
keyfile /mosquitto/certs/server.key
tls_version tlsv1.2
實測結果:
| 情境 | 結果 |
|---|---|
帶 --cafile 連 8883 | 成功,日誌顯示 negotiated TLSv1.3 cipher TLS_AES_256_GCM_SHA384 |
| 不帶 CA | certificate verify failed |
帶 --cafile 但主機名不在 SAN | Error: Protocol error,broker 端記 tlsv1 alert unknown ca |
帶 --cafile --insecure | 仍然失敗 |
第四點是最容易誤會的:mosquitto_pub/mosquitto_sub 的 --insecure 只關閉主機名驗證,不關閉 CA 驗證。要真的跳過驗證,MQTT 客戶端得自己做(例如 Python 的 cert_reqs=ssl.CERT_NONE),但那等於放棄中間人防護,只適合實驗室。
還有一個更隱蔽的失敗:把明文客戶端連到 TLS 埠,不會出現明顯錯誤。實測 paho 連 8883 只收到 Unspecified error 的斷線事件、完全沒有 CONNACK——看起來像「伺服器沒回應」,其實是握手階段就被丟掉。
2.1 的新東西:內建儀表板、WebSocket、封包上限
① 官方映像自帶一組「開放」設定。 不掛任何 config 直接 docker run -p 1883:1883,容器內的預設設定是:
listener 1883
allow_anonymous true
listener 9883
protocol http_api
http_dir /usr/share/mosquitto/dashboard
也就是說,docker run 一行指令就把一台匿名可寫的 broker 掛上公網,還附一個網頁儀表板。要對外之前務必換掉設定檔。
② HTTP API 與儀表板實測回應。 9883 埠提供三個唯讀端點:
GET /api/v1/version -> 2.1.2
GET /api/v1/listeners -> {"listeners":[{"port":1883,"protocol":"mqtt","tls":false,"allow_anonymous":true}, ...]}
GET /api/v1/systree -> {"$SYS/broker/clients/total":0,"$SYS/broker/messages/stored":55, ...}
官方文件明講這個 listener 目前無法加認證,所以只能綁 127.0.0.1,或放在有認證的反向代理後面。它適合內網除錯,不適合直接對外。
③ WebSocket 是內建的(不再依賴 libwebsockets)。 用 paho 以 transport="websockets" 連 9001 實測:
[('connect', 'Success'), ('msg', 'home/ws/probe', 'from-websocket'), ('disconnect', 'Normal disconnection')]
④ 超大封包會被直接斷線。 送 3 MB:
$ mosquitto_pub ... -f /tmp/big.bin -q 1
Error: The connection was lost
# broker 日誌:disconnected: oversize packet.
同一個 broker 送 1.5 MB 則正常。2.1 的預設 max_packet_size 是 2,000,000 bytes,要傳更大的東西請改設定,或改用 HTTP 物件儲存。
⑤ 共享訂閱讓多個 worker 分攤工作。 兩個 worker 都訂 $share/workers/home/jobs/#,連送 4 筆任務,實測分配結果:
worker1: home/jobs/task job-2
worker2: home/jobs/task job-1
同一組只會有一個人收到,天然做到「多實例不重複處理」。
Python 客戶端:paho-mqtt 2.x 的正確寫法
paho 2.x 改了 callback 簽章,舊教學的 on_connect(client, userdata, flags, rc) 會直接壞掉。用 CallbackAPIVersion.VERSION2:
import json, time
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
def on_connect(c, userdata, flags, reason_code, properties=None):
print(f"[connect] rc={reason_code}")
c.subscribe("home/sensor/+/readings", qos=1)
def on_message(c, userdata, msg):
print(f"[message] {msg.topic} qos={msg.qos} {json.loads(msg.payload)}")
c = mqtt.Client(CallbackAPIVersion.VERSION2, client_id="py-demo",
protocol=mqtt.MQTTv5)
c.username_pw_set("dennis", "Str0ng-Pass!2026")
c.on_connect = on_connect
c.on_message = on_message
c.connect("127.0.0.1", 1883, keepalive=30)
c.loop_start()
info = c.publish("home/sensor/sensor01/readings",
json.dumps({"seq": 1, "t": 26.4}), qos=1, retain=True)
info.wait_for_publish(timeout=3)
print("rc=", info.rc, "mid=", info.mid)
time.sleep(2)
c.loop_stop()
c.disconnect()
實際輸出(paho-mqtt 2.2.0):
[connect] rc=Success session_present=False
[publish] seq=1 qos=0 rc=0 mid=2
[message] topic=home/sensor/sensor01/readings qos=0 retain=False payload={'seq': 1, 't': 27.4, 'rssi': -58}
[message] topic=home/sensor/sensor01/readings qos=1 retain=False payload={'seq': 2, 't': 28.4, 'rssi': -58}
TLS 版本要記得帶 ca_certs,不帶會用系統信任庫而驗證失敗:
import ssl
c.tls_set(ca_certs="/path/ca.crt", tls_version=ssl.PROTOCOL_TLS_CLIENT)
八個實際踩到的坑
| 坑 | 現象 | 解法 |
|---|---|---|
| Client ID 撞號 | 後連的把前一個踢掉,先連的「安靜消失」 | 每個實例用唯一 ID;日誌會寫 session taken over |
| ACL 訂閱靜默過濾 | SUBACK 回 Granted,但收不到任何訊息 | 權限檢查在投遞端;對照 acl_file 的 read 規則 |
| 官方映像預設匿名 | 一行 docker run 就對外開放 | 一律掛自己的 mosquitto.conf |
--insecure 沒救 | 少了 CA 仍驗證失敗 | 它只跳過主機名驗證,不是跳過 CA |
| 明文連 TLS 埠 | 沒有 CONNACK,只看到不明斷線 | 檢查協定與埠是否配對 |
| 2.1 封包上限 | 大檔發布時 connection was lost | 調整 max_packet_size 或改走 HTTP |
| Retain 沒清掉 | 設 retain=false 以為刪了 | 要用 -n -r 送空訊息 |
| 選項被棄用 | 日誌出現 per_listener_settings ... deprecated | 2.1 已把 per_listener_settings、acl_file、password_file 標為棄用,3.0 移除;新寫法改用 plugin 與 listener 層級選項 |
最後一項值得先規劃:本次實測的組態在 2.1.2 完全可用,但 broker 啟動時就明確印出棄用警告。若是長期專案,現在就把設定抽成檔案(本來就該這樣做),未來換 plugin 版本時只是換幾行。
接到 Home Assistant 與 Frigate
Mosquitto 單獨跑沒什麼樂趣,它的價值在於當第三方服務的匯流排:
# Home Assistant 的 configuration.yaml
mqtt:
broker: 192.168.1.10
port: 1883
username: dennis
password: !secret mqtt_password
Frigate 的 mqtt.host 指到同一台,事件就會以 frigate/events 發布;Zigbee2MQTT 也是同一個模式。共用一台 broker 的好處是「一個帳號、一組 ACL、一份備份」,壞處是任何一支服務的權限設定錯了,都可能看到別人的主題——所以前面那個 pattern readwrite clients/%u/# 的命名空間設計值得一開始就採用。
站內延伸閱讀
- Home Assistant 完整教學 2026:MQTT broker 在智慧家庭中樞裡的位置,以及 Add-on 與獨立容器的差別。
- Frigate NVR 完整教學 2026:事件如何經由 Mosquitto 送到 HA,含
frigate/events的實際主題結構。 - Docker Compose healthcheck 實戰:本文的 healthcheck 寫法與
up -d --wait的判讀方式。
官方來源:mosquitto.org 文件、2.1.0 發布說明、官方 Docker 映像。本文數字皆在 2026-11-15 以 eclipse-mosquitto:2(broker 2.1.2)實測,測試時把 host 埠改為 18835/19005 以避免與既有服務衝突,設定內容與文中一致。
