K8s 部署 ActiveMQ Artemis 集群与外部通信实战

发布时间:2026/10/27 21:05:18
K8s 部署 ActiveMQ Artemis 集群与外部通信实战

消息中间件搬到容器平台这件事,真正让人头疼的从来不是把进程拉起来,而是让它以集群形态跑起来之后,内网的节点能互相认出对方、外网的业务系统又能稳稳地连进来。Kubernetes 部署 ActiveMQ Artemis 集群并打通外部通信,涉及的东西比想象中多:StatefulSet 的稳定标识、Headless Service 的 DNS 解析、broker.xml里静态连接器与集群连接的配合、NodePort/LoadBalancer 暴露之后客户端拿到内部拓扑导致的重连失败……每一个环节单拎出来都不难,凑在一起就很容易翻车。这篇内容面向已经在写 YAML、但还没把 Artemis 集群跑顺的运维和后端同学,也适合那些刚接手一套"能收消息但不知道为啥老断"的中间件服务的同学。我会按真实的搭建顺序走一遍,把每一步为什么这么做、参数为什么这么给、踩过哪些坑讲透,最后给出可直接复制的清单文件和排查速查表。有基础的同学可以直接跳到部署章节抄作业,小白建议从头看,前面两章的概念对齐决定了后面少调三天。

1. 需求拆解与部署形态选型

1.1 集群要解决的三个真实问题

很多人一提集群就想到高可用,其实这两件事得拆开看。Artemis 集群解决的是三个层次不同的问题:第一是消息分发的负载均衡,多个 broker 节点通过集群连接互相转发消息,把生产者的压力摊到多个节点上,消费端无论连到哪个节点都能拿到消息;第二是容量横向扩展,单个 broker 的 journal 写入、内存分页、连接数都有天花板,加节点是最直接的扩容手段;第三才是故障转移,也就是某个节点挂掉之后,它上面的消息能被其他节点接管。

前两个靠纯集群(cluster connection)就能实现,第三个必须是集群 + HA 策略(主备复制或共享存储)组合才能做到。这个区分非常关键,因为它直接决定了存储怎么配、副本数给多少。我见过不少团队一开始就按主备设计,每个 primary 配一个 backup,结果副本数翻倍、资源翻倍,实际业务的消息重要性根本用不上,纯属浪费。

所以动手之前先问自己一句:这套集群里,一条消息丢了业务能不能接受?不能接受,就必须上 HA(复制模式或共享存储模式);能接受,纯集群就够了,节点数按吞吐量和连接数来定,别为了"看起来高可用"硬凑偶数。这个判断做完,后面的存储选型、副本数规划、升级流程才有依据。

1.2 为什么是 StatefulSet 而不是 Deployment

Artemis 是有状态服务,它的集群依赖一个东西:每个节点有稳定、可预测的网络标识。因为集群连接需要预先知道其他节点的地址,而基于 DNS 的服务发现又要求主机名在 Pod 重建后保持不变。Deployment 的 Pod 名是随机后缀,重建一次名字就变一次,集群连接列表根本没法写死;StatefulSet 的 Pod 名是artemis-0、artemis-1这种带序号的,配合 Headless Service 会自动生成artemis-0.artemis-hl.mq.svc.cluster.local这样稳定可解析的域名,这才是能写进配置里的东西。

存储也是同样的逻辑。StatefulSet 的volumeClaimTemplates会让每个 Pod 绑定一块专属的 PVC,artemis-0永远挂>kubectl create namespace mq kubectl -n mq create secret generic artemis-secret \ --from-literal=admin-user=admin \ --from-literal=admin-password='ChangeMe_2024'

然后准备broker.xml模板。这里用了一个__POD_NAME__占位符,后面由 initContainer 替换成实际的 Pod 名,目的是让每个节点的 broker name 唯一,日志和监控里能一眼看出是哪个节点。broker name 唯一不是集群的硬性要求(集群靠随机生成的 nodeID 识别节点),但对排障帮助极大,强烈建议做。

apiVersion: v1 kind: ConfigMap metadata: name: artemis-broker-tpl namespace: mq data: broker.xml: | <?xml version="1.0" encoding="UTF-8"?> <configuration xmlns="urn:activemq" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:activemq /schema/artemis-configuration.xsd"> <core xmlns="urn:activemq:core"> <name>__POD_NAME__</name> <persistence-enabled>true</persistence-enabled> <journal-type>NIO</journal-type> <journal-directory>data/journal</journal-directory> <paging-directory>data/paging</paging-directory> <large-messages-directory>data/large-messages</large-messages-directory> <journal-file-size>10M</journal-file-size> <journal-min-files>2</journal-min-files> <journal-pool-files>10</journal-pool-files> <journal-buffer-timeout>33333</journal-buffer-timeout> <paging-enabled>true</paging-enabled> <max-disk-usage>90</max-disk-usage> <disk-scan-period>5000</disk-scan-period> <security-enabled>true</security-enabled> <security-settings> <security-setting match="#"> <permission type="createNonDurableQueue" roles="admin"/> <permission type="deleteNonDurableQueue" roles="admin"/> <permission type="createDurableQueue" roles="admin"/> <permission type="deleteDurableQueue" roles="admin"/> <permission type="createAddress" roles="admin"/> <permission type="deleteAddress" roles="admin"/> <permission type="consume" roles="admin"/> <permission type="browse" roles="admin"/> <permission type="send" roles="admin"/> <permission type="manage" roles="admin"/> </security-setting> </security-settings> <address-settings> <address-setting match="#"> <dead-letter-address>DLQ</dead-letter-address> <expiry-address>ExpiryQueue</expiry-address> <redelivery-delay>0</redelivery-delay> <max-size-bytes>104857600</max-size-bytes> <max-size-messages>100000</max-size-messages> <address-full-policy>PAGE</address-full-policy> <message-counter-history-day-limit>10</message-counter-history-day-limit> <auto-create-queues>true</auto-create-queues> <auto-create-addresses>true</auto-create-addresses> <auto-delete-queues>false</auto-delete-queues> <auto-delete-addresses>false</auto-delete-addresses> </address-setting> </address-settings> <acceptors> <acceptor name="artemis"> tcp://0.0.0.0:61616?protocols=CORE,AMQP,STOMP,MQTT,OPENWIRE; tcpSendBufferSize=1048576;tcpReceiveBufferSize=1048576; amqpMinLargeMessageSize=102400;useNIO=true; connectionTtl=60000;connectionSetupTimeout=30000 </acceptor> </acceptors> <connectors> <connector name="artemis-0"> tcp://artemis-0.artemis-hl.mq.svc.cluster.local:61616 </connector> <connector name="artemis-1"> tcp://artemis-1.artemis-hl.mq.svc.cluster.local:61616 </connector> <connector name="self-connector"> tcp://__POD_NAME__.artemis-hl.mq.svc.cluster.local:61616 </connector> </connectors> <cluster-connections> <cluster-connection name="artemis-cluster"> <address>#</address> <connector-ref>self-connector</connector-ref> <retry-interval>1000</retry-interval> <retry-interval-multiplier>1.5</retry-interval-multiplier> <max-retry-interval>10000</max-retry-interval> <initial-connect-attempts>-1</initial-connect-attempts> <reconnect-attempts>-1</reconnect-attempts> <use-duplicate-detection>true</use-duplicate-detection> <message-load-balancing>ON_DEMAND</message-load-balancing> <max-hops>1</max-hops> <static-connectors> <connector-ref>artemis-0</connector-ref> <connector-ref>artemis-1</connector-ref> </static-connectors> </cluster-connection> </cluster-connections> <graceful-shutdown-enabled>true</graceful-shutdown-enabled> <graceful-shutdown-timeout>15000</graceful-shutdown-timeout> </core> </configuration>

这里有几个参数值得单独说清楚。journal-type我用了NIO而不是ASYNCIO,原因是异步 IO 依赖 Linux 的 libaio 和足够大的aio-max-nr内核参数,容器里这两个条件经常不满足,一旦达不到就会在启动时报错或者悄悄退化成同步写,性能反而不如直接用 NIO。除非你确认宿主机内核参数调过、镜像里有 libaio,否则别碰 ASYNCIO。

message-load-balancing设成ON_DEMAND是生产环境的常规选择:只有当某个节点的消费者"已经准备好接收但队列空了"时,才会从其他节点把消息转发过来。设成STRICT的话,消息会严格轮流分发到每个节点,即使那个节点根本没有消费者,消息会堆在那儿直到有人来取,既浪费空间又增加延迟。

address配成#表示这个集群连接对所有地址生效。老版本的配置示例里经常写成jms,那是历史遗留写法,只对 JMS 前缀的地址生效,用通配符更省心。

3.2 StatefulSet 关键字段逐个说明

apiVersion: apps/v1 kind: StatefulSet metadata: name: artemis namespace: mq spec: serviceName: artemis-hl replicas: 2 podManagementPolicy: Parallel selector: matchLabels: app: artemis template: metadata: labels: app: artemis spec: terminationGracePeriodSeconds: 60 securityContext: fsGroup: 1001 runAsUser: 1001 runAsGroup: 1001 initContainers: - name: render-config image: apache/activemq-artemis:2.33.0 command: ["/bin/sh", "-c"] args: - | set -e mkdir -p /data/etc-override sed "s/__POD_NAME__/${POD_NAME}/g" /conf-tpl/broker.xml > /data/etc-override/broker.xml echo "rendered broker.xml for ${POD_NAME}" env: - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name volumeMounts: - name: data mountPath: /data - name: conf-tpl mountPath: /conf-tpl containers: - name: artemis image: apache/activemq-artemis:2.33.0 imagePullPolicy: IfNotPresent ports: - name: core containerPort: 61616 - name: amqp containerPort: 5672 - name: web containerPort: 8161 env: - name: ARTEMIS_USER valueFrom: secretKeyRef: name: artemis-secret key: admin-user - name: ARTEMIS_PASSWORD valueFrom: secretKeyRef: name: artemis-secret key: admin-password - name: ANONYMOUS_LOGIN value: "false" - name: JAVA_ARGS value: "-XX:+UseG1GC -XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=70 -Xms1g -Xmx2g" resources: requests: cpu: "1" memory: 2Gi limits: cpu: "2" memory: 3Gi startupProbe: tcpSocket: port: 61616 initialDelaySeconds: 15 periodSeconds: 10 failureThreshold: 30 readinessProbe: tcpSocket: port: 61616 periodSeconds: 10 timeoutSeconds: 3 livenessProbe: tcpSocket: port: 61616 initialDelaySeconds: 60 periodSeconds: 20 failureThreshold: 5 volumeMounts: - name: data mountPath: /var/lib/artemis-instance volumes: - name: conf-tpl configMap: name: artemis-broker-tpl volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] storageClassName: standard resources: requests: storage: 20Gi

配置渲染的方式值得解释一下。官方镜像约定,/var/lib/artemis-instance/etc-override/目录下的文件会在启动时覆盖etc/下的同名配置,这给了我们一个干净的自定义入口——不用改镜像,不用自定义 entrypoint,把渲染好的broker.xml放进去就生效。所以 initContainer 干的事很单纯:拿 ConfigMap 里的模板,把 Pod 名替换进去,写到那个目录。initContainer 和主容器挂同一块 PVC,配置就落在 PVC 上了。

有人会问为什么不直接把 ConfigMap 挂到etc-override目录。因为__POD_NAME__每个 Pod 不一样,ConfigMap 挂载的内容对所有 Pod 是相同的,没法做差异化渲染。用 initContainer 是最省事且不污染镜像的做法。

关于fsGroup: 1001这个设置:官方镜像里的 artemis 用户 uid 是 1001,PVC 挂上去之后默认属主是 root,如果 Pod 以 1001 运行又没有fsGroup,写入 journal 会直接报权限拒绝,启动就挂。这个错误日志通常只写一句java.io.IOException: Permission denied,不仔细看很容易误判成存储插件的问题。

3.3 Service 的两种形态与各自的用途

集群内部需要的是Headless Service,它不分配 ClusterIP,直接为每个 Pod 生成一条 DNS A 记录,这正是broker.xml里那些完整域名能解析的前提。

apiVersion: v1 kind: Service metadata: name: artemis-hl namespace: mq spec: clusterIP: None publishNotReadyAddresses: true selector: app: artemis ports: - name: core port: 61616 targetPort: 61616

publishNotReadyAddresses: true这行很容易被忽略,但很关键。默认情况下,Pod 没通过 readiness 探针就不会出现在 Endpoints 里,DNS 也解析不到。而 Artemis 集群的问题在于:节点启动时就要去连其他节点,如果其他节点还没 Ready 就解析不到,initial-connect-attempts虽然设成了 -1 会一直重试,但拓扑建立会被拖慢很多,滚动升级期间甚至可能出现新旧节点互不认的情况。打开这个开关,只要 Pod 被调度起来,DNS 就能解析,集群连接可以尽早开始重试。

对外则用一个普通 Service,类型按环境选:

apiVersion: v1 kind: Service metadata: name: artemis-external namespace: mq spec: type: NodePort selector: app: artemis ports: - name: core port: 61616 targetPort: 61616 nodePort: 30616 - name: amqp port: 5672 targetPort: 5672 nodePort: 30572

这里要清楚一个事实:这个 Service 背后是两个 Pod,客户端每次新建连接会被随机分到其中一个。对于普通的生产/消费场景这没问题,消息会自动转发到有消费者的节点。但对于使用临时队列(temporary queue)或者非持久订阅的场景,一旦连接断开重连到另一个节点,原来的临时资源就找不到了,客户端会收到InvalidDestinationException之类的错误。这是设计使然,不是 bug。

3.4 起服务并验证集群真的连上了

部署完之后别急着接业务,先用三组命令确认三件事。

第一组,确认 Pod 和 PVC 都正常:

kubectl -n mq get pods -o wide kubectl -n mq get pvc kubectl -n mq logs artemis-0 | grep -iE "cluster|bridge|AMQ"

日志里应该能看到AMQ221024: Backup server ... started之类的内容,更关键的是这类信息:AMQ221000: successfully connected to ...或者Bridge is active。如果只看到自己启动、没有任何跨节点的连接日志,说明集群连接根本没建起来。

第二组,在 Pod 里用 Artemis 自带的命令行工具查集群拓扑:

kubectl -n mq exec -it artemis-0 -- /opt/artemis/bin/artemis queue stat \ --user admin --password 'ChangeMe_2024' --url tcp://localhost:61616

如果工具版本支持,还可以用artemis check node --up做一次连通性检查。更直接的办法是进 Web 控制台看,把artemis-0的 8161 端口临时转发出来:

kubectl -n mq port-forward artemis-0 8161:8161

浏览器打开http://localhost:8161/console,登录后在 Clustered 或者 Topology 相关页面里能看到两个节点互相连接的关系,这是最直观的验证方式。

第三组,发一条消息看是否真的能在集群里流转。这里有个小技巧:往节点 0 发,从节点 1 收,如果收不到,说明集群转发没生效;如果收得到,说明ON_DEMAND分发和 bridge 都是通的。

# 在 artemis-0 上生产 kubectl -n mq exec -it artemis-0 -- /opt/artemis/bin/artemis producer \ --user admin --password 'ChangeMe_2024' \ --url tcp://localhost:61616 \ --destination queue://demo.queue \ --message-count 5 \ --message "hello-cluster" # 在 artemis-1 上消费 kubectl -n mq exec -it artemis-1 -- /opt/artemis/bin/artemis consumer \ --user admin --password 'ChangeMe_2024' \ --url tcp://localhost:61616 \ --destination queue://demo.queue \ --message-count 5

注意消费者的 destination 用的是queue://前缀,这样即使队列还不存在也会自动创建(前提是auto-create-queues打开)。如果消费命令挂在那儿一直等,先检查生产者那边是不是真的发成功了,再检查address-settings里的auto-create-*配置。

4. 外部通信专项:让集群外的系统连进来

4.1 一个端口对外,为什么会有"连上又断"的现象

把 61616 通过 NodePort 暴露出去之后,往往会遇到一个奇怪的现象:外部客户端第一次连接成功、能发消息,但过一会儿就报连接失败,重连又好了,过一会儿又断。日志里一大堆AMQ214013: Failed to decode packet或者Connection reset。

根本原因是CORE 协议的拓扑发现机制。CORE 客户端连上一个节点后,服务端会把整个集群的拓扑(也就是所有节点的地址)下发给客户端,客户端默认会用这份拓扑做负载均衡和故障转移,于是它会主动去连拓扑里的其他节点地址。而这些地址是 `artemis-0.artemis-hl.mq