2026-01-10 · 架构
32
架构 · 2026-01-10

EMQX 在 K8s 重启失败:从死锁到自愈的完整配置指南

为什么写这篇

前段时间遇到一个棘手问题: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 个节点挂掉

写在最后

这次故障排查让我学到几个教训:

  1. 分布式系统不要凭直觉:2 节点看起来有冗余,实际上挂 1 个就全废。多数派机制是硬性要求,不是可选项。

  2. 内存模式是定时炸弹:ReplayQ 默认用内存,在测试环境可能没问题,但生产环境下游一慢就爆炸。磁盘模式应该是默认选项。

  3. IP 地址不可靠:K8s 环境下 Pod 重启 IP 必然变,用 FQDN 才是正道。这个坑不只 EMQX 有,所有有状态服务都要注意。

  4. Init Container 是救命稻草:启动前清理旧数据,能避免 90% 的重启死锁问题。这个模式可以推广到其他有状态应用。

如果你也在用 K8s 跑 EMQX(或其他 Erlang 集群),希望这篇文章能帮你少踩点坑。配置文件可以直接拿去用,记得改 namespace 和 storageClassName。

目录 最新
← 左侧翻上一屏 · 右侧翻下一屏 · 中间唤出菜单