为什么写这篇
前段时间遇到一个棘手问题: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。