应用级 MQTT 服务搭建完全指南:Mosquitto + 宿主机 Nginx
结论先行
这套方案的核心是:Mosquitto 跑在 Docker 里,只绑定宿主机 127.0.0.1:1883,公网入口完全交给宿主机 Nginx。设备和管理客户端走 mqtt.example.com:8883(Nginx stream 代理到 Mosquitto 的 1883),TLS 由 Nginx 终止,证书用 Let’s Encrypt 免费申请并自动续期。不配置 healthcheck,避免探测连接刷日志;真实客户端的连接日志正常记录。认证用密码文件插件,ACL 按业务前缀隔离 hytd 和 tang,管理账号 backend 只授予 $SYS/# 读权限用于监控。
一、整体架构
公网设备/管理端 ──TLS(8883)──► 宿主机 Nginx ──明文(127.0.0.1:1883)──► Docker Mosquitto
| 组件 | 位置 | 职责 |
|---|---|---|
| Mosquitto | Docker,只绑 127.0.0.1:1883 | MQTT Broker |
| Nginx | 宿主机 | 公网 8883 的 TLS 终止与四层代理 |
关键点:
- Mosquitto 不暴露公网端口,只绑定宿主机本地回环。
- Nginx 是唯一公网入口,8883 负责 MQTT over TLS。
- 证书只存在于宿主机的
/etc/letsencrypt/,不挂载进容器。 - 不配置 healthcheck,Docker 只靠
restart: unless-stopped覆盖进程崩溃场景。
二、目录结构
/opt/mosquitto/
├── docker-compose.yml
├── mosquitto/
│ ├── config/
│ │ ├── mosquitto.conf
│ │ ├── passwd
│ │ └── acl
│ ├── data/
│ └── log/
创建命令:
sudo mkdir -p /opt/mosquitto/{mosquitto/config,mosquitto/data,mosquitto/log}
sudo chown -R 1883:1883 /opt/mosquitto/mosquitto/data /opt/mosquitto/mosquitto/log
三、Docker Compose 编排
/opt/mosquitto/docker-compose.yml:
services:
mosquitto:
image: eclipse-mosquitto:2.1.2-alpine
container_name: mosquitto
restart: unless-stopped
ports:
- "127.0.0.1:1883:1883"
volumes:
- ./mosquitto/config:/mosquitto/config:ro
- ./mosquitto/data:/mosquitto/data
- ./mosquitto/log:/mosquitto/log
关键说明:
127.0.0.1:1883:1883 让 Mosquitto 只绑定宿主机本地,公网无法直接访问。没有 healthcheck,避免每 30 秒一次的探测连接刷日志。restart: unless-stopped 仍然保留,进程崩溃时 Docker 会自动重启容器。
四、Mosquitto 配置
/opt/mosquitto/mosquitto/config/mosquitto.conf:
# ============================================================
# 认证(官方 2.1 推荐的插件方式)
# ============================================================
global_plugin /usr/lib/mosquitto_password_file.so
plugin_opt_password_file /mosquitto/config/passwd
global_plugin /usr/lib/mosquitto_acl_file.so
plugin_opt_acl_file /mosquitto/config/acl
allow_anonymous false
# ============================================================
# 持久化
# ============================================================
persistence true
persistence_location /mosquitto/data/
autosave_interval 300
# ============================================================
# 日志(保留真实客户端连接日志,没有探测噪音)
# ============================================================
log_dest file /mosquitto/log/mosquitto.log
log_dest stdout
log_type error
log_type warning
log_type notice
log_type information
# ============================================================
# MQTT 监听器
# ============================================================
listener 1883
protocol mqtt
# ============================================================
# $SYS 更新频率(默认 10 秒)
# ============================================================
sys_interval 10
# ============================================================
# 性能限制
# ============================================================
max_connections 5000
max_inflight_messages 100
message_size_limit 1048576
4.1 关于连接日志
因为去掉了 healthcheck,日志里不会再出现每 30 秒一次的探测连接。log_type 保留了 notice,所以真实设备的连接和断开仍然会被记录:
New client connected from ... as RT00001 (p4, c1, k60, u'RT00001').
Client RT00001 ... disconnected.
末尾的 u'RT00001' 表示认证通过。如果看到没有 u'...' 的连接,说明有匿名连接被允许了,需要检查 allow_anonymous 是否被误改。
4.2 关于 sys_interval
$SYS 主题的更新频率,默认 10 秒。设为 0 可完全关闭 $SYS 发布。对于你的监控需求,保持 10 秒即可。
4.3 关于 connection_messages
这个选项保持默认(true)或不写。因为 healthcheck 已经去掉,不存在探测噪音,不需要关闭它。真实客户端的连接日志对排查问题有价值,保留。
五、创建密码文件
# tang 管理者
docker run --rm -it \
-v /opt/mosquitto/mosquitto/config:/mosquitto/config \
eclipse-mosquitto:2.1.2-alpine \
mosquitto_passwd -c /mosquitto/config/passwd tang
# hytd 管理者
docker run --rm -it \
-v /opt/mosquitto/mosquitto/config:/mosquitto/config \
eclipse-mosquitto:2.1.2-alpine \
mosquitto_passwd /mosquitto/config/passwd hytd
# 管理端/监控账号 backend
docker run --rm -it \
-v /opt/mosquitto/mosquitto/config:/mosquitto/config \
eclipse-mosquitto:2.1.2-alpine \
mosquitto_passwd /mosquitto/config/passwd backend
hytd 设备账号(共享密码,批量):
for dev in $(cat /opt/mosquitto/hytd-devices.txt); do
docker run --rm -v /opt/mosquitto/mosquitto/config:/mosquitto/config \
eclipse-mosquitto:2.1.2-alpine \
mosquitto_passwd -b /mosquitto/config/passwd "$dev" "hytd-shared-pwd"
done
tang 设备账号(独立密码):
docker run --rm -it -v /opt/mosquitto/mosquitto/config:/mosquitto/config \
eclipse-mosquitto:2.1.2-alpine \
mosquitto_passwd /mosquitto/config/passwd TD00001
重载:
docker exec mosquitto killall -HUP mosquitto
密码文件权限:保持属主 1883:1883,权限 600。不要 chown root,否则容器内的 mosquitto 用户读不到文件,容器起不来。
六、ACL 配置
/opt/mosquitto/mosquitto/config/acl:
# ============================================================
# 默认拒绝一切未明确授权的操作
# 这行必须放在最前面,形成"白名单"模型
# ============================================================
topic deny #
# ============================================================
# hytd 设备:动态绑定到自己的用户名
# 只写自己的 status,只读自己的 commands
# ============================================================
pattern write hytd/devices/%u/status
pattern read hytd/devices/%u/commands
# ============================================================
# tang 设备:动态绑定到自己的用户名
# ============================================================
pattern write tang/devices/%u/status
pattern read tang/devices/%u/commands
# ============================================================
# hytd 管理者:只能管 hytd/ 下的设备
# ============================================================
user hytd
topic read hytd/devices/+/status
topic write hytd/devices/+/commands
# ============================================================
# tang 管理者:只能管 tang/ 下的设备
# ============================================================
user tang
topic read tang/devices/+/status
topic write tang/devices/+/commands
# ============================================================
# 管理端/监控账号:只读 $SYS
# 不碰任何设备业务主题,降低凭证泄露影响范围
# ============================================================
user backend
topic read $SYS/#
6.1 ACL 语法精要
topic read/write/readwrite/deny <主题模式> 定义主题级权限。read 对应订阅,write 对应发布,readwrite 两者皆可,deny 用于显式拒绝(优先级最高,会覆盖其他允许规则)。如果省略访问类型,默认为 readwrite。
user <用户名> 开启一个用户专属的权限块,其后所有 topic 规则只对该用户生效。用户名必须与密码文件中的一致。
通配符与 MQTT 订阅语法一致:+ 匹配单层,# 匹配多层。例如 hytd/devices/+/status 匹配 hytd/devices/RT00001/status,hytd/devices/# 匹配 hytd/devices/ 下的所有层级。
6.2 动态模式(最实用的特性)
pattern 关键字支持 %u(用户名)和 %c(客户端 ID)的替换。pattern write hytd/devices/%u/status 意味着用户 RT00001 自动获得对 hytd/devices/RT00001/status 的写权限,用户 RT00002 获得对 hytd/devices/RT00002/status 的写权限。这比逐条列举用户规则更简洁,也避免了遗漏。新增设备时无需修改 ACL 文件,只要在密码文件里加个用户就行。
6.3 ACL 规则顺序与 deny 优先级
deny 规则的优先级高于所有 read/write/readwrite 规则。即使后面有 topic readwrite # 允许一切,只要前面有 topic deny somespecific/#,该主题仍会被拒绝。这个特性可以用来从宽泛的允许中抠掉敏感主题。
6.4 backend 只读 $SYS 的设计意图
当前 ACL 中 backend 只授予 $SYS/# 读权限,不碰任何设备业务主题。这样监控凭证泄露的影响范围极小——攻击者拿到 backend 密码,只能看到 Broker 的运行指标,看不到设备数据,也不能下发命令。
如果你以后需要 Dashboard 或管理工具读取设备状态、下发命令,应该单独创建一个账号(比如 manager),授予业务主题的读写权限,而不是扩大 backend 的权限。
6.5 需要留意的坑
ACL 不报错,只静默失败:订阅一个未被 ACL 允许的主题时,Broker 在 SUBACK 中返回拒绝码,但很多客户端库不会报错,只是收不到消息。发布时更是完全静默丢弃。调试 ACL 时,建议临时把 log_type 设为 all,观察日志中是否有 denied 记录。
修改 ACL 后必须重启或发送 SIGHUP:Mosquitto 不会自动重载 ACL 文件。修改后执行 docker exec mosquitto killall -HUP mosquitto 即可热加载,已连接的客户端不受影响。
用户名 ≠ Client ID:user 块和 %u 占位符使用的是用户名(连接时提供的),而 %c 使用的是 Client ID。两者可以相同,但概念上要区分清楚。
业务前缀必须匹配:设备端代码里硬编码的业务前缀必须和 ACL 里的前缀一致。hytd 设备用 hytd/ 前缀和 RT 开头的 ID,tang 设备用 tang/ 前缀和 TD 开头的 ID。前缀写错会导致 ACL 拒绝,且错误是静默的。
设备 ID 必须全局唯一:RT + 5 位数字支持 99999 台 hytd 设备,TD + 5 位数字支持 99999 台 tang 设备。如果超过,扩展到 6 位。设备 ID 同时是 MQTT 用户名和主题里的设备标识,重复会导致权限串号和主题冲突。
6.6 如何验证 ACL 生效
用 mosquitto_pub 和 mosquitto_sub 做反向测试:
# 用 RT00001 尝试向 tang 的主题发布(应该被静默拒绝)
mosquitto_pub -h 127.0.0.1 -p 1883 -u RT00001 -P hytd-shared-pwd \
-t "tang/devices/TD00001/status" -m "hack"
# 用 RT00001 向自己的主题发布(应该成功)
mosquitto_pub -h 127.0.0.1 -p 1883 -u RT00001 -P hytd-shared-pwd \
-t "hytd/devices/RT00001/status" -m "ok"
# 检查 broker 日志确认拒绝记录
docker logs mosquitto | grep denied
如果日志中出现 denied PUBLISH 或 denied SUBSCRIBE,说明 ACL 正在起作用。
6.7 hytd 共享密码在代码里的风险边界
攻击者逆向一台 hytd 设备,可以拿到共享密码,从而冒充所有 hytd 设备。但攻击者仍然无法:
- 冒充 tang 设备(tang 密码独立)
- 冒充管理者(管理者密码独立)
- 越权访问其他主题(ACL 隔离)
对于“设备不重要、挂了也无所谓”的场景,这个风险可以接受。如果要降低风险,可以改用 HMAC 派生(共享密钥 + 用户名算出每台设备的独立密码),但共享密钥仍在固件里,逆向一台设备仍可算出所有密码,只是提高了网络层面密码泄露的门槛。
七、宿主机 Nginx 配置
7.1 安装 nginx-full
sudo apt update
sudo apt install -y nginx-full
nginx -V 2>&1 | grep -o with-stream
输出 with-stream 说明 stream 模块可用。
7.2 stream 配置(MQTT 四层代理)
编辑 /etc/nginx/nginx.conf,在 http 块之外添加:
stream {
upstream mosquitto_backend {
server 127.0.0.1:1883;
}
server {
listen 8883 ssl;
listen [::]:8883 ssl;
ssl_certificate /etc/letsencrypt/live/mqtt.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mqtt.example.com/privkey.pem;
proxy_pass mosquitto_backend;
proxy_timeout 1h;
}
}
关键点:
127.0.0.1:1883指向宿主机上 Docker 映射出来的 Mosquitto 端口。proxy_timeout 1h设置长连接超时,MQTT 是长连接,超时要设大。- 证书路径是宿主机的
/etc/letsencrypt/live/mqtt.example.com/,必须用fullchain.pem。
7.3 重载 Nginx
sudo nginx -t && sudo systemctl reload nginx
八、申请证书(宿主机 Certbot)
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d mqtt.example.com
Certbot 会自动通过 80 端口验证并修改 Nginx 的 http 配置。stream 块里的证书路径需要手动写,指向 Certbot 生成的 /etc/letsencrypt/live/mqtt.example.com/。
自动续期 hook:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF'
#!/bin/bash
systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
验证自动续期:
sudo certbot renew --dry-run
九、启动与验证
cd /opt/mosquitto
docker compose up -d
docker compose logs -f mosquitto
验证认证生效:
# 匿名应该被拒绝
docker run --rm eclipse-mosquitto:2.1.2-alpine \
mosquitto_pub -h 127.0.0.1 -p 1883 -t test -m hello
# backend 应该成功
docker run --rm eclipse-mosquitto:2.1.2-alpine \
mosquitto_pub -h 127.0.0.1 -p 1883 -u backend -P '密码' -t test -m hello
验证外网 8883:
mosquitto_sub -h mqtt.example.com -p 8883 \
--cafile /etc/ssl/certs/isrgrootx1.pem \
-u backend -P '密码' -t '$SYS/broker/uptime' -C 1 -W 5
如果收到 9 seconds 或类似数字,说明外网 TLS 代理、认证、ACL 全通了。
验证日志:启动后观察 docker logs mosquitto,应该只有真实客户端的连接/断开日志,没有每 30 秒一次的探测噪音。
十、设备端连接
设备通过 ssl://mqtt.example.com:8883 连接,Nginx 转发到 Mosquitto 的 127.0.0.1:1883。
const std::string BROKER_ADDRESS = "ssl://mqtt.example.com:8883";
const std::string CLIENT_ID = "RT00001";
const std::string USERNAME = "RT00001";
const std::string PASSWORD = "hytd-shared-pwd";
const std::string CA_CERT = "/etc/ssl/certs/ca-certificates.crt";
mqtt::connect_options conn_opts;
conn_opts.set_user_name(USERNAME);
conn_opts.set_password(PASSWORD);
conn_opts.set_keep_alive_interval(60);
conn_opts.set_clean_session(false);
conn_opts.set_automatic_reconnect(true);
mqtt::ssl_options ssl_opts;
ssl_opts.set_trust_store(CA_CERT);
ssl_opts.set_enable_server_cert_auth(true);
conn_opts.set_ssl(ssl_opts);
设备发布状态到 hytd/devices/RT00001/status(QoS 0),订阅 hytd/devices/RT00001/commands(QoS 1)。消息格式和命令处理按你之前的设计文档执行。
十一、MQTTX 客户端简要使用
11.1 内网测试(1883 明文)
新建连接:
| 字段 | 值 |
|---|---|
| Protocol | mqtt:// |
| Host | 127.0.0.1 |
| Port | 1883 |
| Username | backend |
| Password | 对应密码 |
连接成功后,新建订阅:Topic 填 $SYS/#,QoS 选 0。消息列表会持续刷新 Broker 的 uptime、clients/connected、messages/received 等指标。
11.2 外网测试(8883 TLS)
| 字段 | 值 |
|---|---|
| Protocol | mqtts:// |
| Host | mqtt.example.com |
| Port | 8883 |
| Username | backend |
| Password | 对应密码 |
| SSL/TLS | 开启 |
| CA File | isrgrootx1.pem(从 https://letsencrypt.org/certs/isrgrootx1.pem 下载) |
11.3 常用的 $SYS 主题
| 主题 | 含义 |
|---|---|
$SYS/broker/uptime | 运行秒数 |
$SYS/broker/clients/connected | 当前连接数 |
$SYS/broker/messages/received | 累计接收消息数 |
$SYS/broker/messages/sent | 累计发送消息数 |
$SYS/broker/publish/messages/dropped | 被丢弃的消息数,非 0 需关注 |
$SYS/broker/load/messages/received/1min | 最近 1 分钟接收速率 |
注意:# 通配符不匹配 $SYS 开头的主题,必须显式订阅 $SYS/#。命令行里 $ 要用单引号包住,避免 shell 展开。
十二、运维要点
12.1 数据备份
mosquitto/data/ 目录下的 mosquitto.db 是持久化文件,包含会话、订阅关系和保留消息。定期备份该目录和 mosquitto/config/ 目录即可保留所有配置和状态。恢复时停止容器、替换文件、重启即可。
12.2 日志轮转
长时间运行后 mosquitto/log/mosquitto.log 会持续增长。在宿主机上配置 logrotate:
/opt/mosquitto/mosquitto/log/mosquitto.log {
daily
rotate 7
compress
missingok
notifempty
postrotate
docker exec mosquitto killall -USR1 mosquitto
endscript
}
SIGUSR1 让 Mosquitto 重新打开日志文件,轮转后不需要重启容器。
12.3 监控:$SYS 主题树
Mosquitto 的 $SYS/# 主题树包含 Broker 自身的运行指标。当前配置下,backend 账号只有 $SYS/# 读权限,适合做专用监控账号。可以订阅该主题接入 Prometheus、Grafana 或自定义脚本。
日常最值得盯的几个指标:
| 主题 | 异常信号 |
|---|---|
$SYS/broker/clients/connected | 突然暴跌 = 大量设备掉线 |
$SYS/broker/publish/messages/dropped | 非 0 = 有消息被丢弃,检查队列限制 |
$SYS/broker/load/messages/received/1min | 突增 = 可能有设备在异常高频上报 |
$SYS/broker/uptime | 归零 = Broker 刚重启过 |
12.4 证书续期
Certbot 的 systemd timer 会自动检查续期。用 --nginx 插件时,续期后 Certbot 自动重载 Nginx。stream 块引用的证书文件路径由 Certbot 生成,续期后文件更新,Nginx 重载时自动加载新证书。
12.5 版本升级
修改 Compose 文件中的镜像标签(如从 2.1.2-alpine 到未来的 2.1.x-alpine),执行 docker compose up -d 即可。升级前务必备份 mosquitto.db,因为持久化格式可能随版本改变。
十三、需要留意的关键问题
13.1 stream 块的证书路径
Certbot 的 --nginx 插件只管理 http 块里的证书配置,不会自动修改 stream 块。stream 块里引用的证书路径需要手动写,指向 /etc/letsencrypt/live/mqtt.example.com/。续期后文件更新,Nginx 重载时自动加载新证书。
13.2 Mosquitto 看到的客户端 IP 是 127.0.0.1
Nginx 的 stream 模块代理 MQTT 后,Mosquitto 日志里的客户端 IP 变成 Nginx 的 IP(127.0.0.1),不是设备的真实 IP。
如果你需要记录真实 IP(比如为了 fail2ban 封禁暴力破解的 IP),需要启用 PROXY protocol。Mosquitto 2.1 支持 PROXY protocol,Nginx 的 stream 模块也支持。在 Nginx 的 server 块里加 proxy_protocol on;,Mosquitto 的 listener 加 proxy_protocol true。
PROXY protocol v1 不支持传递 TLS 信息,对你现在的单向 TLS + 密码认证场景够用。如果你不需要 fail2ban,可以忽略这一点。
13.3 加用户时千万别带 -c
-c 是“创建新文件”,会覆盖整个密码文件,之前加的所有账号都会消失。只有第一次建文件时才用 -c,之后追加用户一律不加。
13.4 密码文件修改后必须重载
mosquitto_passwd 改完密码文件后,Broker 不会自动重载,必须发信号:
docker exec mosquitto killall -HUP mosquitto
13.5 ACL 静默失败
订阅未被允许的主题时,Broker 不会返回明确的“拒绝”错误,只是不发送消息。发布时更是完全静默丢弃。调试 ACL 时,查看日志中的 denied 记录:
docker logs mosquitto 2>&1 | grep denied
13.6 密码字符限制
密码本身几乎没有字符限制,MQTT 密码最高支持 65535 字节。唯一的硬性限制是不能为空,且用户名里不能包含冒号 :(因为密码文件用冒号分隔用户名和哈希)。
用 mosquitto_passwd -b 时,如果密码里有特殊符号(如 #、$、!),Shell 可能会把它当成控制字符。建议:
- hytd 共享密码:用大小写字母和数字的组合,避免符号转义。
- tang 独立密码:用交互式输入(不带
-b),不经过 Shell 解析,可以放心用复杂密码。
13.7 密码文件属主与权限
密码文件属主保持 1883:1883,权限 600。不要 chown root——官方 Docker 镜像以 UID 1883 运行 Mosquitto,chown root 后容器读不到文件,会无限重启。那条 Warning: File ... owner is not root 是 Mosquitto 2.1 对非 Docker 场景的提示,在 Docker 环境下只能忽略。
总结
这套方案的完整配置链是:Docker Compose 定义 Mosquitto,端口只绑定 127.0.0.1 → 宿主机 Nginx 的 stream 块负责公网 8883 的 TLS 终止 → Certbot 申请和续期证书 → ACL 按业务前缀隔离,backend 只读 $SYS → 密码认证 + 单向 TLS 保证安全。
不配置 healthcheck,彻底消除探测连接产生的日志噪音;真实客户端的连接日志通过 log_type notice 正常记录,排查问题时仍然可用。监控账号 backend 只读 $SYS/#,最小化权限。MQTTX 可以分别用内网 1883 和外网 8883 两种方式连接,订阅 $SYS/# 即可实时观察 Broker 运行状态。