为什么写这篇
前段时间遇到一个棘手问题:EMQX 在 Kubernetes 环境里崩了之后,怎么都起不来。Pod 一直卡在 CrashLoopBackOff,日志里全是连接超时。我对 K8s 和 EMQX 都不算熟,排查过程踩了不少坑。
这篇文章不是写给专家看的,是写给和我一样”能用但不精通”的人。我会用最直白的类比解释那些术语,然后给出能直接用的配置文件。你不需要完全理解底层原理,但看完应该能知道”为什么会挂”和”怎么防止再挂”。
故障现场
症状:
– EMQX Pod 反复重启,退出码 137(OOM 被杀)
– 重启后卡在启动阶段,日志显示 waiting for Mnesia to start...
– 双节点集群,一个挂了另一个也跟着不工作
根本原因(后来才搞清楚的):
1. 内存爆了:数据桥接的缓冲队列(ReplayQ)用的是内存模式,下游 Kafka 慢了之后消息全堆在内存里,最后被 Linux 内核强杀
2. 重启死锁:Pod 重启后读到旧硬盘里残留的旧 IP 地址,一直尝试连接已经不存在的节点,陷入无限等待
3. 集群规模不够:只有 2 个节点,挂掉 1 个后剩下那个无法形成”多数派”,自愈机制失效

名词映射表:把复杂术语翻译成人话
在开始配置之前,先理解几个关键概念。我把它们分成”基础设施层(K8s)”和”应用层(EMQX)”两部分。
K8s 基础设施层
把 K8s 想象成一个五星级酒店大楼:
| 术语 | 人话翻译 | 作用 |
|---|---|---|
| Pod | 酒店房间 | K8s 最小运行单位,里面跑着 EMQX 进程。随时可能被销毁重建 |
| StatefulSet | 固定房间号 | 保证你退房再回来还能住同一个房间(如 801),不会被分配到随机房间 |
| Deployment | 临时工位 | 适合无状态应用,重启后名字和位置都是随机的 |
| PVC (持久卷) | 房间保险柜 | 外接硬盘,Pod 崩了数据还在,重启后能找回 |
| Init Container | 开门前的保洁 | 在 EMQX 启动前运行,清理旧的残留数据 |
| FQDN | 固定门牌号 | 类似域名,比 IP 地址稳定(IP 重启后会变) |
| QoS (Guaranteed) | VIP 保护 | 内存申请和限制设为一致,系统压力大时不会被优先杀掉 |
EMQX 应用层
EMQX 是基于 Erlang 的 MQTT 消息中间件:
| 术语 | 人话翻译 | 作用 |
|---|---|---|
| Mnesia | 内置通讯录 | 分布式数据库,存储集群成员信息、路由表、会话 |
| Schema | 通讯录目录 | 记录集群有哪些成员。如果记了错的 IP,节点就启动不了 |
| Node Name | 身份证号 | 格式如 emqx@主机名,节点通过这个互相识别 |
| Data Bridge | 消息转发器 | 把 MQTT 消息转发到外部系统(MySQL/Kafka) |
| ReplayQ | 消息等候区 | 外部系统慢的时候,消息暂存在这里。配置不当会吃光内存 |
| Core/Replicant | 核心/从属 | 核心节点负责写数据(需 3+ 个),从属节点只处理连接 |
故障相关核心概念
| 术语 | 人话翻译 | 在本案中的表现 |
|---|---|---|
| OOM / Exit 137 | 内存爆了被强杀 | ReplayQ 堆满内存,Linux 内核直接杀进程 |
| Quorum (多数派) | 投票机制 | 2 节点集群挂 1 个,剩下那个无法形成多数,自愈失效 |
| 死锁循环 | 鬼打墙 | 新 Pod 读到旧 IP,一直连接不存在的地址,卡死 |
| Force GC | 强制大扫除 | 定期清理内存碎片,防止缓慢泄漏 |
解决方案一:StatefulSet + 持久化存储
为什么需要 StatefulSet
如果用 Deployment 部署 EMQX,就像让员工住临时工位——每次重启后名字和位置都是随机的。Mnesia 数据库里记录的节点名称(如 emqx-0)和新启动的 Pod 名称(如 emqx-7a8b9c)对不上,集群就认不出来。
StatefulSet 保证三件事:
1. Pod 名称固定(emqx-0, emqx-1, emqx-2)
2. 重启后能找回原来的 PVC(持久化硬盘)
3. 启动顺序可控(虽然我们会改成并行启动)
完整配置文件
apiVersion: v1
kind: Service
metadata:
name: emqx-headless
namespace: emqx
spec:
clusterIP: None # Headless Service,用于生成稳定的 FQDN
selector:
app: emqx
ports:
- name: mqtt
port: 1883
- name: dashboard
port: 18083
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: emqx
namespace: emqx
spec:
serviceName: emqx-headless
replicas: 3 # 至少 3 个节点,保证多数派
podManagementPolicy: Parallel # 并行启动,避免顺序依赖
selector:
matchLabels:
app: emqx
template:
metadata:
labels:
app: emqx
spec:
# ========== Init Container:启动前清理旧数据 ==========
initContainers:
- name: cleanup-stale-mnesia
image: busybox:1.36
command:
- /bin/sh
- -c
- |
MNESIA_DIR="/opt/emqx/data/mnesia"
CURRENT_NODE="emqx@$(hostname).emqx-headless.emqx.svc.cluster.local"
if [ -d "$MNESIA_DIR" ]; then
echo "检查 Mnesia 目录: $MNESIA_DIR"
# 删除所有不匹配当前节点名的目录(清理旧 IP 残留)
find $MNESIA_DIR -maxdepth 1 -type d ! -name "*$(hostname)*" -exec rm -rf {} + 2>/dev/null || true
echo "清理完成,当前节点: $CURRENT_NODE"
fi
volumeMounts:
- name: emqx-data
mountPath: /opt/emqx/data
# ========== 主容器 ==========
containers:
- name: emqx
image: emqx/emqx:5.4.0
env:
# 使用 FQDN 作为节点名(关键配置)
- name: EMQX_NODE_NAME
value: "emqx@$(HOSTNAME).emqx-headless.$(POD_NAMESPACE).svc.cluster.local"
- name: HOSTNAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
# 集群发现配置
- name: EMQX_CLUSTER__DISCOVERY_STRATEGY
value: "k8s"
- name: EMQX_CLUSTER__K8S__SERVICE_NAME
value: "emqx-headless"
- name: EMQX_CLUSTER__K8S__ADDRESS_TYPE
value: "hostname" # 使用主机名而非 IP
- name: EMQX_CLUSTER__K8S__NAMESPACE
value: "emqx"
# 资源限制(QoS Guaranteed)
resources:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "2Gi" # 和 requests 一致,防止被优先杀掉
cpu: "2000m"
volumeMounts:
- name: emqx-data
mountPath: /opt/emqx/data
- name: emqx-config
mountPath: /opt/emqx/etc/emqx.conf
subPath: emqx.conf
volumes:
- name: emqx-config
configMap:
name: emqx-config
# ========== 持久化存储声明模板 ==========
volumeClaimTemplates:
- metadata:
name: emqx-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: "standard" # 根据你的集群修改
resources:
requests:
storage: 10Gi
关键配置解释
| 配置项 | 作用 | 为什么重要 |
|---|---|---|
serviceName: emqx-headless |
生成稳定的 FQDN | Pod 重启后域名不变,节点能互相找到 |
podManagementPolicy: Parallel |
并行启动 | 避免第一个节点等第二个节点超时 |
EMQX_NODE_NAME 用 FQDN |
节点名用域名而非 IP | IP 会变,域名不会变 |
resources.limits = requests |
QoS Guaranteed | 内存压力大时不会被优先杀掉 |
volumeClaimTemplates |
每个 Pod 独立硬盘 | Mnesia 数据持久化,重启后能恢复 |
解决方案二:ReplayQ 磁盘模式 + 内存保护
为什么会 OOM
这次故障的直接原因是 ReplayQ 用内存模式存消息。当下游 Kafka 慢了或者挂了,消息全堆在内存里,最后把 2GB 内存吃光,被 Linux 内核强杀(Exit Code 137)。
形象化类比:
– Memory 模式:快递全堆在办公桌上,桌子堆满了就崩了
– Disk 模式:快递放进仓库(PVC),桌上只留几个待处理的
EMQX 配置文件(emqx.conf)
创建 ConfigMap 挂载到 /opt/emqx/etc/emqx.conf:
apiVersion: v1
kind: ConfigMap
metadata:
name: emqx-config
namespace: emqx
data:
emqx.conf: |
# ========== 节点配置 ==========
node {
name = "emqx@${HOSTNAME}.emqx-headless.${POD_NAMESPACE}.svc.cluster.local"
cookie = "emqx_cluster_secret" # 集群认证密钥,所有节点必须一致
}
# ========== 集群配置 ==========
cluster {
discovery_strategy = k8s
k8s {
service_name = "emqx-headless"
address_type = hostname
namespace = "emqx"
}
}
# ========== 内存保护配置 ==========
# 强制垃圾回收:每处理 8000 条消息清理一次内存
force_gc {
count = 8000
bytes = 16MB
}
# 离线消息队列限制:单个客户端最多存 500 条
mqtt {
max_mqueue_len = 500
}
# 会话过期时间:1 小时后自动清理僵尸连接
zone.external {
session_expiry_interval = 1h
}
# 内存过载保护:内存水位超过 85% 时拒绝新连接
sysmon {
os {
mem_check_interval = 60s
sysmem_high_watermark = 85%
}
}
# ========== 数据桥接配置(关键)==========
# 示例:Kafka 桥接
bridges.kafka.my_kafka_bridge {
enable = true
servers = "kafka.default.svc.cluster.local:9092"
# ReplayQ 磁盘模式配置
resource_opts {
buffer_mode = volatile_offload # 关键:启用磁盘缓冲
buffer_seg_bytes = 100MB # 每个段文件 100MB
max_buffer_bytes = 2GB # 最大缓冲 2GB(写入 PVC)
# 过载保护
health_check_interval = 15s
request_ttl = 45s
# 工作线程
worker_pool_size = 4
max_queue_bytes = 2GB
}
}
关键参数解释
| 配置项 | 推荐值 | 作用 |
|---|---|---|
force_gc.count |
8000 | 每处理 8000 条消息强制清理内存碎片 |
max_mqueue_len |
500 | 限制单客户端离线消息数,防止内存暴涨 |
session_expiry_interval |
1h | 1 小时后清理僵尸连接 |
sysmem_high_watermark |
85% | 内存超过 85% 时拒绝新连接 |
buffer_mode |
volatile_offload |
核心配置:启用磁盘缓冲 |
max_buffer_bytes |
2GB | 缓冲区上限,超过后丢弃旧消息 |
为什么磁盘模式不会拖慢性能
你可能会担心”写磁盘会不会很慢”。实际上:
1. 只在下游阻塞时才写磁盘:正常情况下消息直接转发,不经过磁盘
2. 顺序写入:磁盘顺序写入速度接近内存(现代 SSD 可达 500MB/s)
3. 异步刷盘:不会阻塞主线程
解决方案三:集群规模 + 多数派机制
为什么 2 节点不够
分布式系统有个”多数派”原则:超过半数的节点存活,集群才能做决定。
| 集群规模 | 允许挂掉的节点数 | 能否自愈 |
|---|---|---|
| 2 节点 | 0 个(挂 1 个就剩 50%) | ❌ 无法形成多数 |
| 3 节点 | 1 个(剩 2 个 = 66%) | ✅ 可以自愈 |
| 5 节点 | 2 个(剩 3 个 = 60%) | ✅ 可以自愈 |
推荐配置:
– 生产环境:至少 3 个 Core 节点
– 高可用场景:5 个 Core 节点 + N 个 Replicant 节点
Core vs Replicant 架构
如果你的集群主要处理连接(而非写数据),可以用这种架构:
# Core 节点(负责写 Mnesia)
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: emqx-core
spec:
replicas: 3 # 固定 3 个
template:
spec:
containers:
- name: emqx
env:
- name: EMQX_NODE__DB_ROLE
value: "core"
---
# Replicant 节点(只处理连接)
apiVersion: apps/v1
kind: Deployment # 可以用 Deployment,因为不需要持久化
metadata:
name: emqx-replicant
spec:
replicas: 10 # 可以随意扩缩容
template:
spec:
containers:
- name: emqx
env:
- name: EMQX_NODE__DB_ROLE
value: "replicant"
优势:
– Core 节点稳定(3 个就够),负责数据一致性
– Replicant 节点可以根据连接数动态扩缩容
– Replicant 挂了不影响集群决策
验证和排查
部署后检查清单
# 1. 检查 Pod 状态
kubectl get pods -n emqx -o wide
# 期望输出:
# NAME READY STATUS RESTARTS AGE
# emqx-0 1/1 Running 0 5m
# emqx-1 1/1 Running 0 5m
# emqx-2 1/1 Running 0 5m
# 2. 检查集群状态
kubectl exec -n emqx emqx-0 -- emqx ctl cluster status
# 期望输出:
# Cluster status: #{running_nodes =>
# ['emqx@emqx-0.emqx-headless.emqx.svc.cluster.local',
# 'emqx@emqx-1.emqx-headless.emqx.svc.cluster.local',
# 'emqx@emqx-2.emqx-headless.emqx.svc.cluster.local']}
# 3. 检查 PVC 绑定
kubectl get pvc -n emqx
# 期望输出:
# NAME STATUS VOLUME CAPACITY STORAGECLASS
# emqx-data-emqx-0 Bound pvc-xxx 10Gi standard
# emqx-data-emqx-1 Bound pvc-yyy 10Gi standard
# emqx-data-emqx-2 Bound pvc-zzz 10Gi standard
# 4. 检查内存使用
kubectl top pods -n emqx
# 期望输出:内存使用应该稳定在 1.5GB 以下
常见问题排查
| 问题 | 排查命令 | 可能原因 |
|---|---|---|
| Pod 一直 Pending | kubectl describe pod emqx-0 -n emqx |
PVC 无法绑定,检查 StorageClass |
| 集群无法形成 | kubectl logs emqx-0 -n emqx |
FQDN 配置错误,检查 Service 名称 |
| 重启后卡住 | kubectl exec emqx-0 -n emqx -- ls /opt/emqx/data/mnesia |
Init Container 未清理旧数据 |
| 内存持续增长 | kubectl exec emqx-0 -n emqx -- emqx ctl broker stats |
ReplayQ 仍在用内存模式 |
模拟故障测试
验证配置是否生效的最好方法是主动制造故障:
# 1. 删除一个 Pod,看能否自动恢复
kubectl delete pod emqx-1 -n emqx
# 观察:
# - 新 Pod 应该在 30 秒内启动
# - 集群状态应该自动恢复到 3 节点
# - 不应该出现 CrashLoopBackOff
# 2. 模拟下游阻塞(如果有 Kafka 桥接)
# 停掉 Kafka,观察 EMQX 内存是否稳定
kubectl scale deployment kafka -n default --replicas=0
# 观察:
# - 内存应该稳定在 2GB 以下
# - 日志应该显示 "buffer offloaded to disk"
# - 不应该出现 OOM
# 3. 恢复 Kafka,检查消息是否补发
kubectl scale deployment kafka -n default --replicas=3
# 观察:
# - 磁盘缓冲的消息应该逐步发送到 Kafka
# - 内存使用应该回落
核心配置总结
把上面的方案整合一下,关键配置点:
| 层级 | 配置项 | 推荐值 | 解决的问题 |
|---|---|---|---|
| K8s 层 | 使用 StatefulSet | – | Pod 名称固定,重启后能找回 PVC |
podManagementPolicy |
Parallel |
避免顺序启动超时 | |
serviceName |
emqx-headless |
生成稳定的 FQDN | |
resources.limits = requests |
2Gi |
QoS Guaranteed,防止被优先杀 | |
| Init Container | 清理旧 Mnesia | 防止读到旧 IP 导致死锁 | |
| EMQX 层 | EMQX_NODE_NAME |
FQDN 格式 | 节点名用域名而非 IP |
buffer_mode |
volatile_offload |
启用磁盘缓冲,防止 OOM | |
max_buffer_bytes |
2GB |
限制缓冲区上限 | |
force_gc.count |
8000 |
定期清理内存碎片 | |
max_mqueue_len |
500 |
限制单客户端离线消息 | |
session_expiry_interval |
1h |
清理僵尸连接 | |
| 集群层 | replicas |
3+ |
保证多数派,允许 1 个节点挂掉 |
写在最后
这次故障排查让我学到几个教训:
-
分布式系统不要凭直觉:2 节点看起来有冗余,实际上挂 1 个就全废。多数派机制是硬性要求,不是可选项。
-
内存模式是定时炸弹:ReplayQ 默认用内存,在测试环境可能没问题,但生产环境下游一慢就爆炸。磁盘模式应该是默认选项。
-
IP 地址不可靠:K8s 环境下 Pod 重启 IP 必然变,用 FQDN 才是正道。这个坑不只 EMQX 有,所有有状态服务都要注意。
-
Init Container 是救命稻草:启动前清理旧数据,能避免 90% 的重启死锁问题。这个模式可以推广到其他有状态应用。
如果你也在用 K8s 跑 EMQX(或其他 Erlang 集群),希望这篇文章能帮你少踩点坑。配置文件可以直接拿去用,记得改 namespace 和 storageClassName。





AI周刊:大模型、智能体与产业动态追踪
程序员数学扫盲课
冲浪推荐:AI工具与技术精选导航
Claude Code 全体系指南:AI 编程智能体实战
最新评论
开源的AI对话监控面板很实用,正好团队在找这类工具。准备试用一下。
折叠屏市场确实在升温,不过售罄也可能是备货策略。期待看到实际销量数据。
从磁盘I/O角度解释B树的设计动机,这个切入点很好。终于理解为什么数据库不用二叉树了。
IT术语转换确实是个痛点,之前用搜狗总是把技术词汇转成奇怪的词。智谱这个方向值得期待。
这个工具结合LLM和搜索API的思路很有意思,正好解决了我在做知识管理时遇到的问题。请问有没有部署文档?
这个漏洞确实严重,我们团队上周刚遇到类似问题。建议补充一下如何检测现有项目是否受影响的方法。
从简单规则涌现复杂性这个思路很有意思,让我想起元胞自动机。不过数字物理学在学术界争议还挺大的。
我也遇到了指令跟随变差的问题,特别是多轮对话时容易跑偏。不知道是模型退化还是负载优化导致的。