包包baobaolin.com
date
entry
008
topic
integration
rev

MQTT 連不上的三種壞法,症狀都一樣

手機端看到的永遠是同一件事:連不上,然後無限重連。實際壞掉的可能是 client library 裡的一個 if 判斷、broker 對空 body 的預設解讀、或 Docker 預設網路不做 service name 解析——三個都不會在對方的 log 裡留下線索。

先把鏈畫出來。一個 app 要連上自架的 MQTT broker,中間經過的節點是:

Flutter client  →  CDN (TLS 443)  →  nginx (/mqtt location)
  →  EMQX websocket listener  →  HTTP authn callback  →  backend /mqtt/auth

六節。任何一節壞掉,手機上看到的都是 NoConnectionExceptionMissing CONNACK,然後進重連迴圈。症狀完全沒有鑑別力。

下面三個坑我都踩過,成因分佈在鏈的三個不同位置。

坑一:client library 把你的設定蓋回去

Flutter 的 mqtt_client v10.x 有一個順序敏感的行為:同時設 secure = trueuseWebSocket = true 時,函式庫內部的 if (secure) 分支會把 useWebSocket 蓋回 false。你以為在走 WSS,實際上走的是裸 TCP + TLS,而 nginx 那個 /mqtt location 只接 WebSocket upgrade。

正確的寫法是不要碰 secure

client.useWebSocket = true;
client.useAlternateWebSocketImplementation = true;  // 不要設 secure = true
client.websocketProtocols = ['mqtt'];

同一個函式庫還有第二個坑:WS2 的 handshake 是用 uri.path 拼出 GET 請求行的。如果你只給 host 沒給 path,它會送出 GET ? HTTP/1.1——一個無效的請求行,CDN 直接把連線關掉。所以第一個參數要寫完整的 URI:

MqttServerClient.withPort('wss://$host/mqtt', clientId, port);

要確認自己走在哪條路上,把 library 的 log 打開:

client.logging(on: true);
// 關鍵行:
//   "WS URL is wss://..."                          → path 對不對
//   "alternate websocket implementation selected"  → 有走 WS
//   出現 MqttSecureConnection                       → 走成裸 TCP+TLS,錯了

坑二:broker 把「200 但沒 body」當成拒絕

EMQX 5 的 HTTP 認證回呼,預期後端回傳的是一個帶結果欄位的 JSON:

{"result": "allow"}   // 或 "deny" / "ignore"

如果後端只回一個 200 OK 空 body——這是很多人寫 handler 的直覺——EMQX 會把它解讀成拒絕,客戶端收到 CONNACK code 5(notAuthorized)。

這個坑討厭的地方在於後端 log 看起來完全正常。access log 那行是 POST /api/v1/mqtt/auth 200,沒有錯誤、沒有警告。你從後端那一側看,這次認證是成功的。

// Go / Gin
c.JSON(http.StatusOK, gin.H{"result": "allow"})

這跟狀態碼對但 body 說謊是同一類問題的鏡像:那次是 body 被中間層換掉,這次是 body 從一開始就沒寫——而兩邊的 log 都會告訴你「200,沒事」。

坑三:Docker 預設網路不解析 service name

這是我那次真正的 root cause。EMQX 的認證設定裡寫的是 http://backend:8007/api/v1/mqtt/auth,看起來很合理——docker-compose 就是這樣寫的。

但那幾個 container 是用 docker run 個別啟動的,全部落在 Docker 的 default bridge 網路。而 default bridge 不支援用 service name 做 DNS 解析——那是 user-defined network 才有的功能。所以 backend 這個名字永遠解析不到,認證請求根本沒送出去。

應急修法是走 host gateway:

docker run --add-host=host.docker.internal:host-gateway ...
# 認證 URL 改成 http://host.docker.internal:8007/api/v1/mqtt/auth

長期修法是改用 compose 起,它會建 user-defined network,service name DNS 自動可用:

emqx:
  extra_hosts:
    - "host.docker.internal:host-gateway"

這一節的症狀是 listener 統計裡 current_conn: 0——沒有任何連線建立成功。 但 nginx 的 access log 有請求進來、backend 的 log 一片安靜。三份 log 拼不出一個故事,因為斷點在兩個 container 之間,不在任何一份 log 的視野裡。

從哪裡開始查

症狀沒有鑑別力的時候,正確的策略是先確定請求走到了第幾節,而不是猜哪一節做錯。由內往外問,每一節都有一個直接的問法:

容器內網路通不通(回 000 代表根本沒連上,這就是坑三):

docker exec my-emqx curl -s -o /dev/null -w "%{http_code}\n" \
  -X POST http://host.docker.internal:8007/api/v1/mqtt/auth \
  -H "Content-Type: application/json" -d '{"username":"x","password":"y"}'
# 200 或 401 都算通;000 = 連不到

broker 有沒有收到連線:

docker exec my-emqx /opt/emqx/bin/emqx ctl listeners
# current_conn: 0     → 沒人連進來,問題在 broker 之前
# shutdown_count 增加  → 有連進來但被踢,問題在認證

整條外部路徑通不通(CDN → nginx → EMQX 的 WebSocket upgrade):

curl -ki --http1.1 \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Protocol: mqtt" \
  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  https://api.example.com/mqtt
# 預期 HTTP/1.1 101 Switching Protocols + sec-websocket-protocol: mqtt

最後這條特別有用,因為它把「手機端有問題」和「伺服器端有問題」切開。101 回得來,就代表坑一在手機那側;回不來,就往內找。

三個坑的共通點

它們分佈在鏈的三個不同位置,成因也毫不相干,但有一個一樣的性質:出問題的那一節,自己回報一切正常。

  • library 認為它照你的設定連了(它只是先把設定改掉)
  • backend 認為它成功回應了認證(它只是沒寫 body)
  • EMQX 認為它送出了認證請求(它只是連不到那個主機名)

沒有任何一份 log 是錯的。它們各自誠實,只是視野都停在自己那一節的邊界上,而壞掉的東西正好在邊界之間。

如果只記得一件事

跨層系統偵錯的第一個問題不是「哪一節做錯了」,是「請求走到了第幾節」。前者要猜,後者可以量。每一節找一個能直接問到答案的指令,一節一節往內問——比讀六份 log 快得多,因為 log 只會告訴你各節做了什麼,不會告訴你請求有沒有抵達。

修訂紀錄

  1. 首次發布