用 Docker Compose 跑 EMQX 的教程一搜一大把,但很多人只写到容器起来就结束:镜像一拉、端口一映射,完事。结果线上设备一多,连接数冲到几千就上不去,消息吞吐明显下降,最后全甩锅给 EMQX。
我这次在项目里重构 IoT 接入层,同样用 Docker Compose 部署了 EMQX,并专门花时间把系统内核、容器资源限制、EMQX 的监听器和消息队列配置整体理了一遍。这篇内容就是这次部署和性能优化的完整记录,重点讲清楚哪些参数真正值得动、哪些默认值不用改,以及遇到连接数上不去、内存暴涨、Compose 命令不识别这些坑时该怎么处理。
如果你是正在做 IoT 平台、用 EMQX 做设备消息接入,或者已经用 Docker Compose 把所有服务编排好、但不知道该往 EMQX 里塞哪些性能参数,这篇内容应该能帮你省下不少试错时间。
1. 为什么选 Docker Compose 作为 EMQX 的部署方式
1.1 先把基础设施边界画清楚
EMQX 是一个用 Erlang/OTP 写的 MQTT 消息代理,设备接入、主题订阅、消息路由这些核心工作都在它身上。它跟普通 Web 服务不太一样:连接是长连接,单节点经常要扛几万甚至几十万条 TCP 连接,而且消息是持续流动的,不是请求一下响应一下就完事。
所以部署 EMQX 时,不能只想着“把容器跑起来”。容器只是把你的应用进程包起来,网络、文件描述符、内核 TCP 参数、磁盘 IO,这些仍然依赖宿主机。换句话说,Docker 负责的是可移植性和可重复性,性能优化还得从宿主机和容器两层入手。
我选择 Docker Compose,核心原因是它刚好卡在“裸机部署”和“Kubernetes 重武器”之间。单机或少量几台机器时,Compose 能把镜像版本、端口、环境变量、数据卷都写成一个文件,和代码一起提交、一起回滚,省掉了手动装依赖的麻烦。而且团队其他人拉下来就能起一套一样的环境。
1.2 裸机、Compose、K8s 三选一
我在这次项目里没有直接上 Kubernetes,原因很简单:业务量还没到需要自动扩缩容的程度,K8s 的运维成本反而会吃掉产品迭代的精力。下面这个对比是我自己权衡时画的,供你参考。
| 部署方式 | 优势 | 需要解决的问题 |
|---|---|---|
| 裸机手动部署 | 参数调整最直接,没有容器层干扰 | 环境一致性差,升级回滚都靠手工,难以复现 |
| Docker Compose 单机 | 配置版本化、启动快、资源限制清晰 | 需要处理 ulimit、网络模式、内核参数,集群能力弱 |
| Kubernetes 集群 | 弹性伸缩、故障恢复、服务发现完善 | 运维复杂,EMQX 集群还要处理节点间网络和持久化问题 |
我的结论是:第一版先用 Compose 把单节点跑稳定,把性能压明白,等连接数到了单节点承载极限,再往集群方向演进。这个路径对大多数中小团队来说是最务实的。
2. 性能优化的起点:宿主机和容器的资源底座
2.1 连接数为什么上不去:文件描述符和内核参数
这是很多 Docker Compose 部署 EMQX 翻车的第一大原因。Linux 下每一条 TCP 连接都是一个文件描述符,默认的ulimit -n经常只有 1024,这意味着你最多只能打开 1024 个文件,随便压个两三百个 MQTT 连接就到顶了。
我实际操作时会先把下面这些内核参数写进/etc/sysctl.conf,然后执行sysctl -p:
fs.file-max = 2000000 fs.nr_open = 2000000 net.core.somaxconn = 65535 net.core.netdev_max_backlog = 65535 net.ipv4.tcp_max_syn_backlog = 65535 net.ipv4.ip_local_port_range = 1024 65000 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_fin_timeout = 15fs.file-max是系统级文件描述符上限,fs.nr_open是单个进程能打开的最大文件数上限。somaxconn和tcp_max_syn_backlog影响半连接队列和全连接队列长度,EMQX 这种高并发接入服务,如果队列太短,突发连接会直接被内核丢掉。
接着还要改/etc/security/limits.conf:
* soft nofile 1048576 * hard nofile 1048576 root soft nofile 1048576 root hard nofile 1048576改完建议重启 Docker 让守护进程继承新限制,否则你在宿主机改了ulimit,Docker 起来时没带上,等于白改。
2.2 Ubuntu 上安装 Docker Compose 的正确姿势
如果服务器还没有 Docker Compose,Ubuntu 上的安装其实很简单。我自己一般用官方源或者系统源二选一:
sudo apt update sudo apt install docker.io docker-compose-v2 docker compose version如果你装的是 Docker 官方仓库,那对应的包是docker-compose-plugin:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin docker compose version安装完一定要先用docker compose version验证。很多老一点的服务器上只有旧版 Python 写的docker-compose,输入docker compose时就会报docker: unknown command: docker compose。这个坑很典型,我放在后面的排查章节详细说。
2.3 磁盘、文件系统和数据卷选型
EMQX 的高频操作是消息路由和会话内存管理,但数据目录里的持久化文件、日志写入和规则引擎落库仍然会产生磁盘 IO。Compose 里我坚持用命名卷,不要图省事直接 bind mount 到 NFS、SMB 或者虚拟机的共享目录上。
原因很直接:NFS 这类网络存储延迟高,高并发下写日志和写数据容易把 EMQX 的消息收发拖垮。如果你是在群晖 NAS 里跑 EMQX,也需要格外注意磁盘 IO,NAS 的机械盘加网络卷组合并不适合高吞吐场景。
另外 Docker 默认的json-file日志驱动会把容器日志无限写下去,我习惯在/etc/docker/daemon.json里把日志切割做掉,顺便给所有容器一个合理的默认nofile:
{ "default-ulimits": { "nofile": { "Name": "nofile", "Hard": 1048576, "Soft": 1048576 } }, "log-driver": "json-file", "log-opts": { "max-size": "100m", "max-file": "3" } }改完systemctl restart docker。注意default-ulimits会影响所有容器,如果同一个宿主机上还跑着其他服务,建议在 Compose 文件里单独给 EMQX 配ulimits,而不是全局都拉高。
3. Docker Compose 编排文件与部署细节
3.1 一份可用的 Compose 配置
下面这份配置是我这次项目实际用的,EMQX 版本用的 5.8.2,没有直接写latest,避免镜像更新后行为变化:
services: emqx: image: emqx/emqx:5.8.2 container_name: emqx restart: unless-stopped ulimits: nofile: soft: 1048576 hard: 1048576 environment: EMQX_NAME: emqx@127.0.0.1 EMQX_NODE__PROCESS_LIMIT: "2097152" EMQX_NODE__MAX_PORTS: "1048576" EMQX_LISTENERS__TCP__DEFAULT__BIND: "0.0.0.0:1883" EMQX_LISTENERS__TCP__DEFAULT__MAX_CONNECTIONS: "1024000" EMQX_MQTT__MAX_MQUEUE_LEN: "10000" EMQX_LOG__FILE__LEVEL: warning EMQX_DASHBOARD__DEFAULT_USERNAME: admin EMQX_DASHBOARD__DEFAULT_PASSWORD: ChangeMe123 ports: - "1883:1883" - "8083:8083" - "8084:8084" - "8883:8883" - "18083:18083" volumes: - emqx-data:/opt/emqx/data - emqx-log:/opt/emqx/log healthcheck: test: ["CMD", "emqx", "ctl", "status"] interval: 15s timeout: 10s retries: 5 volumes: emqx-data: driver: local emqx-log: driver: local说一下几个关键点。
ulimits.nofile对应容器内进程能打开的文件描述符数量,EMQX 官方建议高并发场景至少给到 1048576。EMQX_NODE__PROCESS_LIMIT和EMQX_NODE__MAX_PORTS是 Erlang VM 层面的限制,很多人只调了 Linux 的ulimit,忘了 Erlang 自己还有一层限制,连接数照样上不去。
端口映射这块,1883 是 MQTT 普通 TCP,8883 是 TLS,8083 是 WebSocket,8084 是 WebSocket over TLS,18083 是 Dashboard。如果暂时用不到 TLS 和 WS,可以不放出来,少一个监听器就少一份资源消耗。
3.2 端口映射与网络模式怎么选
Compose 默认用的是 bridge 网络,通过ports做端口映射。这种方式的好处是隔离性好,一台机器上可以跑很多个容器互不干扰;坏处是流量会经 iptables 和 NAT 转发,高连接数下有一定开销。
如果 EMQX 独占一台机器,可以考虑network_mode: host,让容器直接复用宿主机网络栈。这样少一层转发,抓包也方便,压测时连接数上限更容易顶满。不过 host 模式下就不能再用ports,端口冲突也得自己管理,Compose 不会帮你检查。我这次没直接上 host,是因为同一台机器还要跑 Nginx 和监控服务,bridge 的隔离粒度更适合我。
另外一个容易踩的细节是端口映射时别写成127.0.0.1:1883:1883,那只会让本机访问,外部设备连不上。我总是先写1883:1883,确认部署完能通,再按安全要求收紧。
3.3 数据卷、日志和健康检查
数据卷分开挂载,一个给/opt/emqx/data,一个给/opt/emqx/log,这样做方便备份和日志清理。升级镜像前把数据卷打个快照,就不会出现升级完会话数据丢光的情况。
健康检查我用的是emqx ctl status,这个命令会在 EMQX 正常时返回Node is running。Docker 默认的HEALTHCHECK可以在 Compose 里通过healthcheck写清楚,之后再用docker compose ps看容器状态时,就能看到healthy而不是一直显示running。
4. EMQX 性能优化参数逐项拆解
4.1 Erlang 节点层参数,决定了连接规模的天花板
EMQX 底层是 Erlang/OTP,所有连接都被抽象成 Erlang 进程和 Erlang 端口。理解这一点后,再看官方文档里的那些参数会通透很多。
node.process_limit对应环境变量EMQX_NODE__PROCESS_LIMIT。每个 MQTT 连接、每个会话、每条正在处理的 QoS 消息都可能占据 Erlang 进程,连接数一旦超过 process_limit,EMQX 就会拒绝新连接甚至直接出错。高并发场景下,我会把它设成预期连接数的 2 倍左右,比如预期 10 万连接,就设 2097152。
node.max_ports对应环境变量EMQX_NODE__MAX_PORTS。注意这里的端口不是 TCP 端口,而是 Erlang VM 里的“端口对象”,TCP socket 也会占用。默认值不足时,现象是连接到一个数量级后无论如何都上不去,而且 Dashboard 上不会显示明确错误。我建议直接把EMQX_NODE__MAX_PORTS设成 1048576 以上,避免这种隐性瓶颈。
还要提醒一个老教程陷阱:EMQX 4.x 的环境变量是单下划线风格,比如EMQX_NODE_PROCESS_LIMIT,5.x 改成了双下划线风格EMQX_NODE__PROCESS_LIMIT。网上很多资料还在用旧格式,照抄到 5.x 上完全不生效,这是低版本到高版本迁移时最常见的配置问题。
4.2 监听器和 TCP 层参数
监听器配置对应EMQX_LISTENERS__TCP__DEFAULT__*这一组环境变量。最核心的三个是:
| 参数 | 作用 | 我的建议 |
|---|---|---|
BIND | 监听器绑定的 IP 和端口 | 高可用场景绑0.0.0.0:1883,单机隔离场景可以绑内网 IP |
MAX_CONNECTIONS | 单个监听器最大连接数 | 根据业务预期设,别默认无上限,否则被刷连接时很难受 |
BACKLOG | TCP 监听队列长度 | 建议和内核somaxconn一起调大,突发连接不容易丢 |
MAX_CONNECTIONS我建议显式设置,哪怕设一个比较大的数。原因是你需要知道自己的容量边界在哪里,等它到顶时告警能看到一个明确指标,而不是进程莫名拒绝连接。
如果 EMQX 前面还有负载均衡器或反向代理,TCP 层面的proxy_protocol也要考虑。开启后,EMQX 能从 proxy protocol 头里拿到真实客户端 IP,避免所有流量都显示成代理 IP,导致基于 IP 的认证或限流全部失效。这个配置不在性能优化范围里,但它影响生产环境的功能正确性,值得顺手检查。
4.3 MQTT 会话和消息队列怎么调
MQTT 层的性能和业务模型强相关,不要盲目调大所有参数。
EMQX_MQTT__MAX_MQUEUE_LEN控制的是每个会话在服务端的消息队列长度,对应 QoS 1/2 消息没有得到确认时积压的最大条数。默认值通常够用,但如果设备端经常不在线、消息又不断投递,队列会被打满,超出部分会丢弃。调大的代价非常明确:内存上涨。我通常根据单条消息大小和设备离线时长估算撑峰值的内存,再决定开多少,比如单条 1KB 消息、离线 10 分钟、每秒来 1000 条,那队列至少要能装 60 万条,内存预算是 600MB 上下。
session_expiry_interval也要看业务。如果大量设备连上后很快就断,又不清理会话,EMQX 内存会被僵尸会话吃光。我会把过期时间设置成一个合理业务周期,比如 2 小时,避免无限制保留。
QoS 的max_inflight控制一个客户端最多同时有多少未确认的 QoS 1/2 消息。这个值不是越大越好,很多客户端库默认值就很合适,盲目调高只是把压力往后端推。
4.4 规则引擎、认证和日志的取舍
EMQX 自带规则引擎和数据桥接,功能很强,但不是每个部署都需要。用不上就关掉,省下 CPU 和内存;用上了也要注意,规则引擎里如果接外部数据库或 HTTP 服务,下游慢会拖慢消息处理链路,因为存在背压机制。
日志是性能杀手的一个隐藏点。EMQX 默认日志级别可能是 info,消息量大的时候会把大量收发事件写进日志文件,直接拉高磁盘 IO。我把EMQX_LOG__FILE__LEVEL设成warning,只保留故障信息,需要排查时再临时调到 debug。
下面是我整理的一个速查表,可以对照检查:
| 优化项 | 配置示例 | 解决什么问题 | 注意风险 |
|---|---|---|---|
| 容器文件描述符 | ulimits.nofile=1048576 | 连接数到了一定程度就连不上 | 无 |
| Erlang 进程限制 | EMQX_NODE__PROCESS_LIMIT=2097152 | 新连接被拒、进程耗尽 | 太高会更快耗尽内存,建议 2 倍连接数 |
| Erlang 端口限制 | EMQX_NODE__MAX_PORTS=1048576 | socket 达到上限后连环报错 | 与连接数匹配即可 |
| 监听器连接数 | MAX_CONNECTIONS=1024000 | 被异常流量打满 | 设一个可告警的上限 |
| 消息队列长度 | EMQX_MQTT__MAX_MQUEUE_LEN=10000 | 离线设备消息被丢弃过多 | 内存按队列长度线性增长 |
| 日志级别 | EMQX_LOG__FILE__LEVEL=warning | 日志 IO 挤占业务资源 | 排查问题时需要临时降级到 debug |
这些参数之间不是孤立的,文件描述符再高,Erlang 端口不够照样连接失败;Erlang 内存再大,容器日志无限写,磁盘 IO 也会拖垮吞吐。调优时一定要把系统层和应用层放在一条链路里看。
5. 压测过程与优化前后的对比
5.1 压测工具和场景怎么定
压测工具我用了一个非常简单的 Python 脚本,基于paho-mqtt,方便复现。没有直接依赖重型压测平台,因为我的场景很明确:先测连接建立能力,再测简单发布订阅吞吐。
连接建立场景的核心代码如下:
import paho.mqtt.client as mqtt import threading import time HOST = "127.0.0.1" PORT = 1883 TOTAL = 20000 conn_count = 0 lock = threading.Lock() clients = [] def on_connect(client, userdata, flags, rc): global conn_count if rc == 0: with lock: conn_count += 1 start = time.time() for i in range(TOTAL): c = mqtt.Client(client_id=f"bench-{i}", protocol=mqtt.MQTTv311) c.on_connect = on_connect c.connect(HOST, PORT, keepalive=60) c.loop_start() clients.append(c) if (i + 1) % 1000 == 0: print(f"已发起 {i + 1} 个连接") while conn_count < TOTAL: time.sleep(0.5) print(f"{TOTAL} 个连接建立耗时: {time.time() - start:.1f}s") time.sleep(10) for c in clients: c.disconnect() c.loop_stop()消息吞吐测试我用了另一套思路:订阅端开 200 个客户端订阅同一个主题,发布端用一个客户端连续发 2 万条 QoS 0 消息,统计订阅端收到的总数和时间。这样能很直观地看出 broker 的路由转发能力。
5.2 需要先排掉的压测自身坑
压测机最好不要和 EMQX 在同一台机器。我一开始图方便,直接在宿主机上跑脚本,结果 EMQX 还没到瓶颈,压测机的 CPU 先被 Python 的几千个线程打满了,指标全是失真的。
还有一个非常隐蔽但几乎必踩的坑:EADDRNOTAVAIL。当压测机是一台普通服务器时,它本地的临时端口范围默认只有几千个,几万个客户端同时从这个 IP 建连,源端口很快耗尽。压测机上也要调大net.ipv4.ip_local_port_range,否则你会误判成 EMQX 的问题。
5.3 优化前后的实测记录
我用的测试机是 4 核 8G,Ubuntu 系统,Docker 24,EMQX 5.8.2。第一轮直接用默认配置启动容器,第二轮应用了系统内核参数、容器ulimits、Erlang 节点参数和前面说的 MQTT 参数。数据记录在下面:
| 压测项 | 默认配置 | 调优后 | 说明 |
|---|---|---|---|
| 2 万连接建立耗时 | 约 52 秒 | 约 34 秒 | 中途默认配置下出现过连接失败 |
连接后emqx ctl status响应 | 卡顿数秒 | 秒级返回 | 说明 Erlang VM 负载明显下降 |
| 2 万条消息端到端接收 | 约 13 秒 | 约 6 秒 | QoS 0,1KB payload,简单订阅场景 |
| Broker 内存峰值 | 接近 2GB | 约 1.4GB | 去掉不必要日志和消息积压后下降明显 |
不要把这些数字当成绝对值,硬件不同、消息 payload 不同,结果都会变。我要强调的是趋势:默认配置下先撞到的是 Erlang 进程和文件描述符瓶颈,调优后瓶颈转移到了压测机本地端口和 CPU 上,这本身就说明 EMQX 的容量被释放出来了。
5.4 数据说明什么
跑完压测我最大的感受是:EMQX 本身的性能底子很厚,大多数“连不上、吞吐低”的问题出在部署环境没有配合。比如连接数到 2 万就报错,你先查的不是 EMQX 版本,而是docker exec emqx bash -c 'ulimit -n'和docker exec emqx emqx ctl status的状态。
调优后的单节点,在我的测试机上是能稳定撑住 10 万长连接的,但前提是压测机不再是瓶颈。生产环境如果设备数量不到这个量级,参数不需要全部拉满,留 20% 余量反而是健康的状态。
6. 常见问题与排查技巧实录
6.1docker: unknown command: docker compose
这个报错几乎是新服务器部署必遇。原因是 Docker 客户端里没有 Compose 插件,或者 Docker 版本太旧。先确认:
docker --version docker compose version如果docker compose version报 unknown command,就直接安装插件包。Ubuntu 系统安装docker-compose-v2或者 Docker 官方仓库的docker-compose-plugin后,重新打开终端验证即可。
我遇到过一个特殊场景:插件装了,但用户用的是 root 账号加自定义 shell 环境,PATH里没有 Docker 插件目录,导致docker compose找不到。这种情况直接用绝对路径调用或修一下PATH就能解决。
6.2 容器内文件描述符没生效
宿主机ulimit已经改到 1048576,但容器内还是 1024。这种问题常见于 Docker 守护进程没有重启,或者 Compose 文件里没有写ulimits。一定要在容器内确认:
docker exec emqx bash -c 'ulimit -n' docker exec emqx bash -c 'cat /proc/1/limits'看到Max open files是 1048576 才算真正生效。我习惯在部署脚本里把这个检查写进去,避免手动部署时漏掉。
6.3 连接数高时EADDRNOTAVAIL
如果你已经是压测场景,先看压测机而不是 EMQX。EADDRNOTAVAIL通常意味着本地端口耗尽,调大压测机的net.ipv4.ip_local_port_range,或者把压测脚本分散到多台机器执行。生产环境设备都在不同 IP,一般不会遇到这个问题,但压测环境一定要考虑到。
6.4 连接不稳定、频繁掉线
先检查客户端的keepalive设置和 EMQX 的 keepalive 检查机制是否匹配。有的设备把 keepalive 设成 60 秒,但网络链路做 NAT 超时会先断掉连接,设备又没有按 MQTT 规范重连,表现就是“看起来经常掉线”。
另一个原因是 listener 的 backlog 太小。大量设备同一时间重连时,TCP 连接会在内核队列里堆积,客户端表现为建连超时。对应解法是把内核somaxconn和 EMQX listener 的backlog同时调大。
6.5 内存上涨和 OOMKilled
内存上涨最常见的原因有三个:消息队列积压、会话没清理、日志无限制增长。先看 Dashboard 里当前会话数和队列长度,再结合docker logs判断日志量。如果容器被 OOMKilled,大多数时候不是 EMQX 内存泄漏,而是你没给它留足内存预算。
我给容器设内存上限时一直很谨慎。EMQX 是 Erlang VM,内存管理有自己的伸缩机制,限制太低会直接触发 OOM。建议要么不设硬限制,只做监控告警;要么设一个明显高于常规峰值的内存 limit,给 VM 留出余量。
6.6emqx ctl status卡住
这通常说明 broker 已经很忙,或者节点在做大型 GC。排查时先看docker stats的 CPU 和内存曲线,如果 CPU 持续 100%,说明消息路由或规则引擎是热点;如果内存持续上涨,优先检查消息队列。再不行就抓docker logs,看有没有 Erlang 进程崩溃或out of memory相关日志。
7. 维护、监控与后续扩展思路
7.1 日常监控看哪几个指标
监控不要贪多,我先盯三个核心指标:当前连接数、消息收发速率、内存使用率。EMQX Dashboard 自带这些指标,部署完先看它,确认基线。
等规模再大一点,再上 Prometheus 和 Grafana。EMQX 提供 Prometheus 指标接口,把指标拉取写到监控里,配合告警规则,比每天登录 Dashboard 看一遍高效得多。告警阈值我的建议是连接数达到上限 80% 时先预警,内存超过总内存 70% 时开始查消息队列,CPU 持续超过 80% 优先看规则引擎。
7.2 升级和备份
用 Compose 升级很方便,但 EMQX 升级前一定要先备份数据卷。我的操作顺序是:
docker compose down docker run --rm -v emqx-data:/data -v /backup:/backup alpine tar czf /backup/emqx-data.tar.gz -C /data . docker compose pull emqx docker compose up -d升级后马上看健康状态,观察 10 分钟连接数和消息速率,确认没有回归再结束。镜像 tag 不要一直用latest,锁死一个版本,升级时主动变更 tag,日志里才看得出改动。
7.3 集群扩展的下一步
单节点撑不住后,EMQX 可以走集群扩展。集群模式下要解决两件事:节点发现机制和负载均衡入口。EMQX 5.x 支持静态节点发现,Compose 里把多个 EMQX 服务通过同一个EMQX_NODE__COOKIE连接起来,然后在前面加负载均衡器或者 DNS 轮询,设备连接会分散到多个节点上。
不过集群不是把事情变简单,而是重新定义复杂度。数据备份、节点间消息路由、客户端重连策略都要重新验证。我会在单节点性能稳定后,再专门做一轮集群压测。
7.4 我最后想说的经验
这次 Docker Compose 部署 EMQX 的性能优化做下来,我的体会是:大多数参数不该照搬社区配置,而应该从连接数、消息量、消息大小、设备在线率这些业务数据倒推。我实际调过的参数里,有一半最后又调回了接近默认值的状态,因为业务根本没到那个量级。
先搞清楚你的设备连接模型是什么,再决定开多高的上限。上线前留出 20% 的资源余量,压测时把压测机的瓶颈排除掉,遇到问题时按“系统文件描述符、Erlang VM、监听器连接数、消息队列”这个顺序排查,基本能覆盖 90% 的 EMQX 容器化性能问题。