
1. 为什么把 Redis Cluster 放进 Kubernetes 这么折腾Redis Cluster 本身不算新鲜东西官方从 3.0 版本就开始支持分片集群靠 16384 个哈希槽把数据打散到多个主节点上每个主节点再挂若干个从节点做高可用。这套机制在物理机或者虚拟机上跑了快十年运维套路早就成熟了。可一旦把它塞进 Kubernetes事情就变得微妙起来——Pod 的 IP 是漂的重启一次换一个地址而 Redis Cluster 的节点发现和槽位分配又强依赖节点地址的稳定性。这两件事凑一块就是各种集群起不来节点互相找不到槽位分配卡死的根源。我前后在三个不同规模的环境里落地过 Redis Cluster on K8s从最初用 StatefulSet 硬扛到后来上 Operator中间踩的坑能写满两页纸。这篇文章不打算给你念官方文档而是把整个部署过程中真正需要想清楚的问题、真正会卡住你的环节一个一个拆开讲。核心关键词就三个StatefulSet 稳定网络标识、集群初始化与槽位分配、持久化与故障自愈。搞明白这三件事剩下的都是体力活。这篇文章适合谁看如果你已经会用 Docker 跑单机 Redis也大致知道 K8s 的 Pod、Service、StatefulSet 是什么但一想到要把一个有状态的分片集群搬上去就头大那这篇就是写给你的。我会从架构选型讲到具体 YAML再到集群初始化脚本和排错手册尽量做到你照着抄就能跑起来。如果你是完全没碰过 K8s 的新手建议先把 StatefulSet 和 Headless Service 的基本概念过一遍再回来不然有些设计意图你体会不到。先说结论在 K8s 上部署 Redis Cluster最稳的路径是 StatefulSet Headless Service 固定节点序号 初始化 Job而不是 Deployment 或者随便搞几个 Pod。原因后面会展开但你可以先记住这个组合。至于 Operator它是锦上添花不是必需品而且引入 Operator 会带来额外的抽象层出问题时排查链路更长。我的建议是先用原生资源把集群跑通理解每个环节在干什么再考虑要不要上 Operator 做自动化运维。2. 架构选型为什么是 StatefulSet 而不是 Deployment2.1 有状态服务的核心诉求稳定网络标识Redis Cluster 的每个节点在集群里都有一个唯一身份这个身份不是靠 Pod 名字而是靠 IP:Port 来标识的。集群内部通过 Gossip 协议互相通信节点之间会交换我是谁、我负责哪些槽位、我的从节点是谁这些信息。如果某个节点的 IP 变了其他节点还拿着旧 IP 去连就会认为这个节点失联触发故障转移。更麻烦的是如果失联节点重新以新 IP 加入集群里就会出现同一个节点 ID 对应两个地址的混乱状态槽位分配直接崩掉。Deployment 管理的 Pod 是无状态的名字随机、IP 随机、重启后一切归零。你没法保证某个 Pod 重启后还能拿到原来的网络身份。StatefulSet 就不一样了它给每个 Pod 分配一个稳定的序号比如 redis-cluster-0、redis-cluster-1配合 Headless Service每个 Pod 还能获得一个稳定的 DNS 名称比如 redis-cluster-0.redis-headless.default.svc.cluster.local。这个 DNS 名称在 Pod 重建后依然指向新的 Pod但名字不变。Redis 节点配置里就可以用这个 DNS 名称来互相发现而不是写死 IP。注意StatefulSet 的稳定 DNS 依赖 Headless Service也就是 clusterIP: None 的 Service。普通 Service 会给每个 Pod 做负载均衡DNS 解析出来是一个虚拟 IP没法定位到具体 Pod。Headless Service 直接把 Pod 的 DNS 记录暴露出来这才是 StatefulSet 节点间互相寻址的基础。2.2 存储的稳定性PVC 模板与数据持久化Redis Cluster 的数据虽然大部分在内存里但持久化不能省。RDB 和 AOF 文件得落在磁盘上Pod 重启后要能挂回原来的数据。StatefulSet 的 volumeClaimTemplates 会为每个 Pod 自动创建一个 PVC命名规则是模板名-StatefulSet名-序号比如>apiVersion: v1 kind: Service metadata: name: redis-headless namespace: redis-cluster spec: clusterIP: None publishNotReadyAddresses: true selector: app: redis-cluster ports: - name: redis port: 6379 targetPort: 6379 - name: cluster-bus port: 16379 targetPort: 16379这里开了两个端口6379 是客户端通信端口16379 是集群总线端口也就是 Redis Cluster 节点之间 Gossip 通信用的。集群总线端口固定是客户端端口加 10000这个规则要记住后面配置里要用。3.2 StatefulSet 的关键字段解读StatefulSet 的 YAML 比较长我挑几个决定成败的字段说。serviceName: redis-headless必须指向刚才创建的 Headless Service这是稳定 DNS 的来源。podManagementPolicy: Parallel让所有 Pod 并行启动而不是默认的逐个启动。Redis Cluster 的节点需要同时在线才能互相发现串行启动会导致第一个 Pod 一直等不到其他节点卡在初始化阶段。apiVersion: apps/v1 kind: StatefulSet metadata: name: redis-cluster namespace: redis-cluster spec: serviceName: redis-headless replicas: 6 podManagementPolicy: Parallel selector: matchLabels: app: redis-cluster template: metadata: labels: app: redis-cluster spec: affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchLabels: app: redis-cluster topologyKey: kubernetes.io/hostname containers: - name: redis image: redis:7.2-alpine command: - redis-server - /etc/redis/redis.conf ports: - containerPort: 6379 name: redis - containerPort: 16379 name: cluster-bus volumeMounts: - name: data mountPath: /data - name: config mountPath: /etc/redis resources: limits: memory: 8Gi cpu: 2 requests: memory: 4Gi cpu: 1 volumeClaimTemplates: - metadata: name: data spec: accessModes: [ReadWriteOnce] resources: requests: storage: 20GipodAntiAffinity用 preferred 而不是 required是因为如果你集群节点数不够 6 个required 会导致部分 Pod 永远 Pending。preferred 会尽量打散实在打不散也能跑但你要知道这种情况下单节点故障风险更高。3.3 配置文件的设计用环境变量注入节点地址Redis 的配置文件里需要写cluster-announce-ip和cluster-announce-port告诉集群我对外暴露的地址是什么。在 K8s 里Pod IP 是动态的但 DNS 名称是稳定的。所以我们可以用 Pod 的 DNS 名称作为 announce-ip。问题是怎么在配置文件里动态填入当前 Pod 的 DNS 名称方案是用一个启动脚本在容器启动时读取 Pod 的 hostnameStatefulSet 的 Pod hostname 就是 Pod 名字比如 redis-cluster-0拼出完整的 DNS 名称然后生成 redis.conf。这个脚本可以放在 ConfigMap 里挂载进去。#!/bin/sh # 启动脚本生成 redis.conf POD_NAME$(hostname) DOMAINredis-headless.redis-cluster.svc.cluster.local FULL_HOSTNAME${POD_NAME}.${DOMAIN} cat /etc/redis/redis.conf EOF port 6379 cluster-enabled yes cluster-config-file /data/nodes.conf cluster-node-timeout 5000 cluster-announce-ip ${FULL_HOSTNAME} cluster-announce-port 6379 cluster-announce-bus-port 16379 appendonly yes appendfsync everysec maxmemory 4gb maxmemory-policy allkeys-lru protected-mode no EOF exec redis-server /etc/redis/redis.conf这里cluster-announce-ip填的是完整 DNS 名称而不是 IP。Redis 从 4.0 版本开始支持在 announce-ip 里写主机名集群其他节点会通过 DNS 解析这个名称来连接。这样就彻底摆脱了对 Pod IP 的依赖Pod 重建后 IP 变了但 DNS 名称不变集群自动恢复。提示cluster-node-timeout 5000是 5 秒这个值在 K8s 环境里比较合适。设太短会因为网络抖动频繁触发故障转移设太长故障恢复慢。如果你的集群跨可用区部署网络延迟较高可以适当调到 10000。4. 集群初始化从零到可用的完整流程4.1 等待所有 Pod 就绪StatefulSet 创建后6 个 Pod 会并行启动。你需要等它们全部 Running并且 DNS 记录可解析。可以用这个命令检查kubectl get pods -n redis-cluster -l appredis-cluster -w当所有 Pod 都是 Running 状态后再验证 DNSkubectl run -n redis-cluster dns-test --rm -it --imagebusybox --restartNever -- \ nslookup redis-cluster-0.redis-headless.redis-cluster.svc.cluster.local如果解析出 Pod IP说明 Headless Service 工作正常。这一步很重要DNS 不通后面全白搭。4.2 用 redis-cli 创建集群Redis 官方提供了一个--cluster create命令来初始化集群。在 K8s 里我们可以起一个临时 Pod 来执行这个命令或者直接 exec 进第一个 Redis Pod 操作。我习惯用临时 Pod因为不污染业务容器。kubectl run -n redis-cluster redis-init --rm -it --restartNever \ --imageredis:7.2-alpine -- \ redis-cli --cluster create \ redis-cluster-0.redis-headless.redis-cluster.svc.cluster.local:6379 \ redis-cluster-1.redis-headless.redis-cluster.svc.cluster.local:6379 \ redis-cluster-2.redis-headless.redis-cluster.svc.cluster.local:6379 \ redis-cluster-3.redis-headless.redis-cluster.svc.cluster.local:6379 \ redis-cluster-4.redis-headless.redis-cluster.svc.cluster.local:6379 \ redis-cluster-5.redis-headless.redis-cluster.svc.cluster.local:6379 \ --cluster-replicas 1--cluster-replicas 1表示每个主节点配一个从节点6 个节点正好 3 主 3 从。命令执行后会提示你确认槽位分配方案输入 yes 回车。如果一切顺利你会看到[OK] All 16384 slots covered的输出。4.3 验证集群状态集群创建完成后用cluster info和cluster nodes检查状态kubectl exec -n redis-cluster redis-cluster-0 -- redis-cli cluster info kubectl exec -n redis-cluster redis-cluster-0 -- redis-cli cluster nodescluster info里cluster_state应该是okcluster_slots_assigned应该是 16384。cluster nodes会列出所有节点及其角色确认 3 个 master 和 3 个 slave 都在线。4.4 把初始化做成 Job手动执行一次没问题但如果你需要反复重建环境最好把初始化逻辑做成一个 K8s Job。Job 里跑一个脚本先检查集群是否已初始化如果没有就执行 create。这样每次部署都是幂等的。apiVersion: batch/v1 kind: Job metadata: name: redis-cluster-init namespace: redis-cluster spec: template: spec: restartPolicy: OnFailure containers: - name: init image: redis:7.2-alpine command: - /bin/sh - -c - | # 等待所有节点 DNS 可解析 for i in $(seq 0 5); do until nslookup redis-cluster-$i.redis-headless.redis-cluster.svc.cluster.local; do sleep 2 done done # 检查集群是否已初始化 if redis-cli -h redis-cluster-0.redis-headless.redis-cluster.svc.cluster.local cluster info | grep -q cluster_state:ok; then echo Cluster already initialized exit 0 fi # 创建集群 redis-cli --cluster create \ redis-cluster-0.redis-headless.redis-cluster.svc.cluster.local:6379 \ redis-cluster-1.redis-headless.redis-cluster.svc.cluster.local:6379 \ redis-cluster-2.redis-headless.redis-cluster.svc.cluster.local:6379 \ redis-cluster-3.redis-headless.redis-cluster.svc.cluster.local:6379 \ redis-cluster-4.redis-headless.redis-cluster.svc.cluster.local:6379 \ redis-cluster-5.redis-headless.redis-cluster.svc.cluster.local:6379 \ --cluster-replicas 1 --cluster-yes这个 Job 先等 DNS 就绪再判断集群状态最后执行初始化。--cluster-yes参数自动确认槽位分配省去交互。5. 客户端如何访问Service 与拓扑感知5.1 普通 ClusterIP Service 的局限集群建好了客户端怎么连最直接的想法是创建一个普通 Service把 6 个 Pod 都选上客户端连 Service 的 ClusterIP。但这样做有个致命问题Redis Cluster 客户端需要知道所有节点的地址才能做槽位路由。普通 Service 只暴露一个虚拟 IP客户端连上去后Redis 返回 MOVED 重定向告诉客户端这个 key 在另一个节点上但客户端拿到的地址是 Pod 的真实地址不是 Service 地址。如果客户端在集群外这些 Pod IP 根本不可达。5.2 方案一客户端在集群内直接用 Headless Service如果你的客户端也跑在同一个 K8s 集群里最简单的方式是让客户端直接使用 Headless Service 的 DNS。客户端配置里写redis-cluster-0.redis-headless...:6379等所有节点地址或者用 Headless Service 的域名做种子节点。Redis Cluster 客户端库比如 Jedis、Lettuce、redis-py-cluster都支持从种子节点发现完整拓扑。5.3 方案二客户端在集群外用 NodePort 或 LoadBalancer如果客户端在 K8s 外部你需要把每个 Redis Pod 的端口暴露出去。可以用 NodePort Service但每个 Pod 需要一个独立的 NodePort配置起来很繁琐。更优雅的方式是用 LoadBalancer Service 配合externalTrafficPolicy: Local但同样需要为每个 Pod 创建独立的 Service。我实际用过的做法是为每个 StatefulSet Pod 创建一个独立的 Service类型是 NodePort 或 LoadBalancer然后客户端配置里写这些 Service 的外部地址。虽然有点笨但稳定可靠。如果你有 Ingress Controller 支持 TCP 转发也可以走 Ingress但配置复杂度更高。注意无论用哪种方式暴露都要确保cluster-announce-ip填的是客户端能访问到的地址。如果客户端在集群外announce-ip 就不能填 Pod DNS而要填外部可达的 IP 或域名。这是很多人在混合环境里翻车的地方。6. 常见问题与排查技巧实录6.1 集群创建卡在 Waiting for the cluster to join这是最常见的问题原因通常是节点之间网络不通。排查步骤检查 Headless Service 的publishNotReadyAddresses是否为 true。进入任意 Pod用nslookup解析其他 Pod 的 DNS确认能解析。用redis-cli -h 其他节点 ping测试连通性。检查集群总线端口 16379 是否在 Service 和容器端口里都开了。如果 DNS 能解析但 ping 不通可能是 NetworkPolicy 挡住了。检查是否有 NetworkPolicy 限制了 Pod 间通信。6.2 槽位分配失败 Not all 16384 slots are covered这个错误通常发生在集群创建过程中有节点掉线。可能是某个 Pod 因为资源不足被 OOMKill或者调度到了不健康的节点。解决方法是删掉所有 Pod 的 nodes.conf 文件重新初始化。kubectl exec -n redis-cluster redis-cluster-0 -- rm -f /data/nodes.conf # 对所有 Pod 执行 kubectl delete pod -n redis-cluster -l appredis-cluster # 等 Pod 重建后重新跑初始化 Job6.3 Pod 重启后集群状态异常如果 Pod 重启后 IP 变了但 nodes.conf 里还记着旧 IP集群会认为该节点失联。因为我们用了 DNS 作为 announce-ip理论上不受 IP 变化影响。但如果 nodes.conf 里存的是旧 IP就需要清理。检查方法kubectl exec -n redis-cluster redis-cluster-0 -- cat /data/nodes.conf如果看到的是 IP 而不是 DNS 名称说明配置没生效。检查启动脚本是否正确生成了 redis.conf以及cluster-announce-ip是否被正确设置。6.4 故障转移不触发如果主节点挂了从节点应该在cluster-node-timeout后发起选举。如果没触发检查从节点是否与主节点在同一分片cluster nodes 里看 slave 的 master 字段。集群总线通信是否正常。cluster-node-timeout是否设得太大。问题现象可能原因排查命令解决方案集群创建卡住DNS 不通或端口未开nslookup、redis-cli ping检查 Headless Service 和端口配置槽位未全覆盖节点掉线或 nodes.conf 冲突cluster info、kubectl get pods清理 nodes.conf 重建Pod 重启后失联announce-ip 配置错误cat nodes.conf确保 announce-ip 用 DNS故障转移不触发从节点未正确关联cluster nodes检查 slave 的 master 字段6.5 持久化导致的性能抖动Redis 做 RDB 持久化时 fork 子进程如果内存数据量大fork 会阻塞主线程几百毫秒甚至几秒。在 K8s 里如果容器内存 limit 设得太紧fork 可能直接失败。我的经验是容器内存 limit 至少是 maxmemory 的 2 倍。如果数据量超过 10GB考虑关闭 RDB只用 AOF或者调整save策略降低频率。监控latest_fork_usec指标如果经常超过 100ms说明 fork 压力大。7. 生产环境加固探针、PDB 与监控7.1 就绪探针与存活探针Redis 的探针不能简单用 TCP 检查因为端口开着不代表集群状态正常。我建议用redis-cli cluster info的输出作为就绪判断readinessProbe: exec: command: - sh - -c - redis-cli cluster info | grep -q cluster_state:ok initialDelaySeconds: 10 periodSeconds: 5 livenessProbe: exec: command: - sh - -c - redis-cli ping initialDelaySeconds: 30 periodSeconds: 10就绪探针检查集群状态存活探针只检查进程是否响应。这样集群没建好时 Pod 不会接收流量但也不会被误杀。7.2 PodDisruptionBudget 防止同时驱逐在节点维护或升级时K8s 会驱逐 Pod。如果没有 PDB可能一次性驱逐多个 Redis Pod导致集群不可用。创建一个 PDB保证至少 4 个 Pod 在线apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: redis-cluster-pdb namespace: redis-cluster spec: minAvailable: 4 selector: matchLabels: app: redis-cluster7.3 监控指标采集Redis 自带INFO命令输出大量指标可以用 redis_exporter 采集并暴露给 Prometheus。在 StatefulSet 里加一个 sidecar 容器- name: redis-exporter image: oliver006/redis_exporter:latest ports: - containerPort: 9121 env: - name: REDIS_ADDR value: redis://localhost:6379关键监控指标包括redis_cluster_state集群状态、redis_connected_slaves从节点数、redis_memory_used_bytes内存使用、redis_cluster_slots_assigned已分配槽位。这些指标能帮你提前发现集群异常。8. 我踩过的几个坑和最后的经验第一个坑是publishNotReadyAddresses忘了设。当时集群创建一直卡在 Waiting for the cluster to join排查了半天才发现 DNS 解析不了未就绪的 Pod。这个字段在官方文档里不起眼但没有它集群根本建不起来。第二个坑是内存 limit 设得太紧。有一次给 Redis 设了 4Gi limitmaxmemory 设了 3.5Gi结果 RDB fork 时直接 OOMKill。后来改成 limit 8Gimaxmemory 4Gi再也没出过问题。记住那个 2 倍原则这是血的教训。第三个坑是 Pod 反亲和性用了 required。在一个只有 3 个节点的测试集群里6 个 Redis Pod 要求每个节点最多一个结果 3 个 Pod 永远 Pending。改成 preferred 后正常调度但我也知道了这种情况下单节点故障会影响多个 Redis 实例生产环境必须保证节点数足够。最后一个建议如果你只是想要一个高可用的 Redis数据量不大其实可以考虑 Redis Sentinel 而不是 Cluster。Sentinel 在 K8s 上的部署复杂度低很多3 个 Sentinel 加 1 主 2 从就够了。Cluster 适合数据量大、需要水平扩展的场景但运维复杂度确实高一个量级。选型时想清楚你的真实需求别为了集群而集群。