AI编程 · 架构思考 · 技术人生

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

#架构好文:技术架构与系统实践精选
智谱 GLM,支持多语言、多任务推理。从写作到代码生成,从搜索到知识问答,AI 生产力的中国解法。

为什么写这篇

前段时间遇到一个棘手问题: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 个后剩下那个无法形成”多数派”,自愈机制失效

EMQX K8s 故障技术架构示意图

名词映射表:把复杂术语翻译成人话

在开始配置之前,先理解几个关键概念。我把它们分成”基础设施层(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。

赞(0)
未经允许不得转载:Toy's Tech Notes » EMQX 在 K8s 重启失败:从死锁到自愈的完整配置指南
免费、开放、可编程的智能路由方案,让你的服务随时随地在线。

评论 抢沙发

十年稳如初 — LocVPS,用时间证明实力

10+ 年老牌云主机服务商,全球机房覆盖,性能稳定、价格厚道。

老品牌,更懂稳定的价值你的第一台云服务器,从 LocVPS 开始