凌晨一点,我守着监控大屏,看到那台Redis虚拟机的磁盘占用曲线冲到 100%,进程挂掉,然后业务侧开始刷屏报错。因为没有副本,主节点一死,整个缓存层直接停摆。等人工重启、恢复完数据,已经过去快一个小时。那次之后,我在改造清单上写了一行字:缓存层必须带副本,必须能自动故障转移。落地的时候,选的就是题目里这个方案——在k8s上部署Redis三主三从。
这篇文章不聊官方文档那套漂亮话,只讲我从架构选型、StatefulSet设计、配置注入、到集群初始化和故障演练这一路踩过的坑,以及最后稳定跑下来的那套配置。适合两类人看:一是正准备把Redis迁进k8s、但还没想清楚怎么管“状态”的团队;二是已经能在k8s上跑无状态服务,却对着有状态应用无从下手的同学。看完你至少能知道:为什么三主三从比单机靠谱、StatefulSet比Deployment合理、cluster-announce-ip这个参数又是怎么把无数人坑到怀疑人生的。
1. 先想明白一个问题:三主三从到底要解决什么
网上搜“三主三从”,能搜到一堆指令和YAML,但很少有人先把这个问题讲透。我在动手之前,反复确认过需求,因为“三主三从”这四个字背后其实藏着两种完全不同的架构取向:一种是数据分片,一种是高可用。你不分清楚,后面所有配置都可能是白做。
1.1 主从复制和数据分片,是两套逻辑
用一个生活里的类比来解释。
主从复制就像你把一份重要合同复印了三份,分别锁在三个保险柜里。原件被火烧了,拿着复印件还能继续办事。数据在三个节点上完全一样,多出来的副本只是用来兜底。
数据分片则完全不同,相当于你把一个大仓库拆成了三个独立的隔间,一号隔间放前一万件商品,二号隔间放一万到两万件,三号隔间放剩下的。每个隔间只有一部分数据,合在一起才构成完整数据集。
Redis里的三主三从,其实是把上面两件事叠加在一起:三个主节点分别持有数据的一部分,相当于分片;每个主节点各自带一个从节点,主节点上的数据在从节点上有一份全量复制,相当于每个分片都做了高可用。这就是为什么我们经常说,三主三从是Redis Cluster的最小高可用骨架。
这个区分不是学术抠字眼,它直接决定了你的选型。如果业务数据量很小,全部塞进单台机器内存也绰绰有余,你需要的只是“挂了别丢数据、别停服务”,那做一主两从加Sentinel哨兵更合理,没必要拆三份。反过来,如果单机内存已经放不下全量数据,或者写入并发已经打到单实例瓶颈,就必须把数据拆到多个主节点,也就是走上Cluster这条路。
我做这套系统的时候,项目里缓存数据量已经超过单机可承受的合理范围,而且对缓存命中率、单点故障容忍度都有硬性要求。对应到技术上,Cluster模式是当时唯一可以同时满足“数据自动分片”和“故障自动转移”的方案。
1.2 为什么放在k8s上,而不是继续用虚拟机
我遇到过一个很常见的观点:Redis这么成熟的东西,虚拟机里跑了几十年,非要搬到k8s上不是自找麻烦吗?这个质疑有一定道理。Redis是有状态服务,容器化之后最大的难点就是状态怎么保存、网络怎么固定、节点身份怎么稳定。虚拟机里这些是天经地义的事情,容器里却要你手动设计。
但我还是选择了k8s,核心原因有两个。
第一,故障自愈。虚拟机方案下,主节点挂了之后,我只能靠外部监控脚本或者人工介入去触发Sentinel切换,整个过程慢且脆。而Pod异常之后,k8s本身会自动重建容器,Cluster模式又会自动把从节点提升为主节点,运维不需要在半夜爬起来敲命令。
第二,环境一致性。以前测试环境一套Redis、生产环境又是另一套,配置漂移是家常便饭。容器化之后,镜像、配置、网络策略全部用代码描述,测试环境验证通过的部署方式,拿到生产环境跑,结果是一致的。对于多环境维护来说,这个收益很高。
代价当然也有:你需要理解StatefulSet、PersistentVolumeClaim、Headless Service这些概念,还要处理Pod重建后IP变化对Redis集群的冲击。后面这几节,我主要就是围绕这些代价展开的。
1.3 三种方案对比,别一上来就照抄
我在选型阶段列过一张对照表,三种方案各有各的适用场景,不存在绝对优劣。
| 方案 | 数据分片 | 自动故障转移 | 客户端要求 | 适合场景 |
|---|---|---|---|---|
| 单机主从+Sentinel | 不支持,所有节点数据一样 | 支持,由Sentinel完成 | 普通客户端即可 | 数据量小、单机内存够用,主要诉求是高可用 |
| Redis Cluster三主三从 | 支持,按哈希槽分片 | 支持,集群内部自动完成 | 必须支持Cluster协议的客户端 | 数据量较大、写入并发较高,同时需要高可用 |
| 客户端分片/代理分片 | 由客户端或代理决定数据位置 | 通常依赖额外组件 | 取决于实现 | 业务访问模式特殊,需要定制分片策略 |
我最后选了第二种,Redis Cluster三主三从,因为项目里数据规模已经不允许单机一把梭了。后面所有实操内容,也全部基于Redis Cluster模式。如果你只需要小数据量高可用,可以去参考一主两从+Sentinel的方案,不必硬套这篇文章的Cluster配置。
2. 架构设计:StatefulSet、Headless Service和反亲和怎么配合
架构设计阶段,我反复推敲过三件事:Pod用什么控制器管理、客户端通过什么方式访问、6个Pod怎么分散到不同物理节点上。这三件事想清楚,后面的YAML写起来就顺了。
2.1 为什么必须是StatefulSet,Deployment不行
第一次想到用Deployment部署Redis的时候,我差点就把方案定了,后来细想才发现不行。
Deployment管理的是无状态应用,它的Pod名字是随机的,比如redis-abc12、redis-def34。这种随机性对Redis是致命的。Cluster模式启动之后,每个节点会把其他节点的地址写进自己的nodes.conf文件。当某个Pod因为故障被重建,Deployment会生成一个全新的Pod名字和IP,新的IP地址跟其他节点记录的旧地址对不上,整个集群的通信就会出问题。
StatefulSet解决的就是这个问题。它给每个Pod一个稳定的、带序号的标识,例如redis-cluster-0、redis-cluster-1,一直到redis-cluster-5。这个命名是固定的,Pod重建后名字不变。虽然IP仍然会变,但配合后面要说的集群配置,可以把IP变化的影响降到最低。
另外,StatefulSet天生支持volumeClaimTemplates,也就是每个Pod都会自动绑定一份独立的持久化存储。这一点非常关键。Redis的AOF文件和RDB快照必须跟着Pod走,Pod被重新调度到另一台机器之后,数据还要能挂回来。用Deployment的话,你得手动维护一堆独立的存储和Pod的绑定关系,每天运维能烦死。
所以我给的结论是:在k8s上跑Redis Cluster,控制器就用StatefulSet,不要折腾其他方案。
2.2 Headless Service在这里扮演什么角色
StatefulSet通常要搭配一个Headless Service一起使用。所谓Headless,就是Service的clusterIP设为None。这个Service不提供负载均衡IP,而是给每个Pod分配一条固定的DNS记录。
这样做的意义在于:客户端或者集群内部组件可以通过稳定的Pod名字去解析到某个具体的Pod IP。比如redis-cluster-0这个Pod被重建了,IP从10.244.1.5变成了10.244.2.8,但只要通过DNS查询redis-cluster-0这个记录,拿到的就是最新的IP,不需要手工更新配置。
在Redis Cluster里面,节点之间本身是通过IP通信的,所以Headless Service不是用来做负载均衡的,它的作用是保证Pod的DNS名字稳定、可解析。我实践中也确实遇到过因为Service类型选错,导致集群初始化找不到节点的情况。
Headless Service的YAML比较简单,先把形态放在这里,后面部署部分还会给完整版本。
apiVersion: v1 kind: Service metadata: name: redis-cluster-hs spec: clusterIP: None selector: app: redis-cluster ports: - name: redis port: 6379 targetPort: 6379 - name: cluster-bus port: 16379 targetPort: 163792.3 反亲和:别让主和从睡在同一张床上
三主三从共六个节点,如果k8s调度器把它们全塞到一台物理机,那这个集群跟单机没有任何区别。机器一挂,六个Pod团灭,高可用无从谈起。
所以必须给Pod配置反亲和,让它们尽可能分散到不同节点。这里面还要特别强调一点:不光要防止两个主节点在同一台机器,更要防止某一对主从节点在同一台机器。
主从配对的含义是:假如redis-cluster-0是某个分片的主,redis-cluster-3是这个分片的从,那么这两个Pod一定不能落到同一台物理节点上。否则这台节点宕机,主挂了、从也跟着挂,这个分片就彻底失去副本了。
我在第一次部署时,只用了一个很粗糙的podAntiAffinity配置,让所有redis-cluster标签的Pod均匀分散。结果主从刚好落到了同一台机器上,第一次故障演练就翻车了。
后来改进的方案是在配置里明确指定反亲和的拓扑域,一般以kubernetes.io/hostname为粒度。下面是一个软反亲和的配置示例,意思是在调度时优先把相同标签的Pod分散到不同节点,但不是强制约束。
affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchLabels: app: redis-cluster topologyKey: kubernetes.io/hostname为什么用“优先”而不是“必须”?因为强制反亲和时,k8s要求同一标签的Pod不能出现在同一节点,那6个Pod就需要至少6台节点,否则调度直接Pending。很多测试环境根本没有那么多Node,所以生产环境如果节点充裕,可以用requiredDuringSchedulingIgnoredDuringExecution做硬约束;节点不足时,使用preferred加高weight是更现实的选择。
严格区分主从配对的反亲和,在单StatefulSet里其实比较难做到,因为StatefulSet所有Pod的标签都一样,调度器不会知道哪个是主哪个是从。我后续会用两个StatefulSet来做主从分离,一个管理master节点,一个管理replica节点,给它们打上不同的标签,再分别配置反亲和。这个属于进阶内容,本篇给出一套可行的单StatefulSet方案,日常测试和小规模生产够用。
3. 配置细节:把Redis的集群模式揉进容器
写到这一节的时候,我先说一句大实话:Redis本身的配置文件并不复杂,最折磨人的是“配置文件怎么塞进容器”以及“容器启动时怎么让配置生效”。我在这上面踩过两个大坑,后面会详细说。
3.1 ConfigMap里的redis.conf,这些参数一个都不能少
Redis的配置我用ConfigMap来管理。这样做的好处是配置与镜像分离,改配置不需要重新构建镜像,Pod滚动重启即可生效。下面是我在项目里使用的redis.conf配置文件内容,带注释方便你理解每个参数的作用。
# 开启集群模式,这是三主三从架构的核心开关 cluster-enabled yes # 集群状态文件,保存当前节点视角下的集群节点信息 # 必须放在持久化目录,否则Pod重启后节点身份信息会丢 cluster-config-file /data/nodes.conf # 节点超时时间,单位毫秒 # 如果另一个节点在这个时间内没有响应,会被判定为故障 # 设成5000毫秒,故障切换的时间就在几秒到十几秒的区间 cluster-node-timeout 5000 # 开启AOF持久化 appendonly yes # AOF刷盘策略 # everysec表示每秒刷一次,性能影响小,最多丢一秒数据 # always最安全但性能损耗大,不要轻易用 appendfsync everysec # 这里我把RDB快照的默认策略注释掉了,只用AOF # 如果你的场景需要快速全量恢复,可以保留RDB策略 # save 3600 1 300 100 60 10000 # 关闭保护模式 # 容器环境下必须设置,因为Redis默认只监听回环地址 protected-mode no # 最大内存限制 # 这个值必须小于容器内存Limit,因为Redis在做持久化时 # 会通过fork子进程复制一份内存页表,需要额外预留内存 maxmemory 1536mb # 内存淘汰策略 # allkeys-lru对绝大多数缓存场景都合适 maxmemory-policy allkeys-lru这里有一个非常容易踩的坑:千万别在容器里的Redis配置中设置daemonize yes。Redis默认是前台运行,也就是进程不退出、日志直接输出到标准输出。而容器要求主进程不能脱离前台。如果你开了daemonize,redis-server会fork到后台,容器会以为主进程已退出,直接触发Pod重启,形成CrashLoopBackOff。这个坑我亲眼见过有人踩过,反复重启还找不到原因。
ConfigMap挂载到容器里的路径,我用的是/etc/redis/redis.conf,后面启动命令会引用这个路径。
3.2 密码、masterauth和cluster-announce-ip,三个关键点
密码部分,我的建议是不管集群是否暴露到外网,都要设置密码。Redis在云上被扫描爆破的案例太多了,不要裸奔。
在Cluster模式下,需要同时配置两个东西:一个是requirepass,表示客户端访问Redis时需要输入的密码;另一个是masterauth,表示当前节点在成为从节点时,连接主节点需要用到的密码。很多人只配了requirepass,忘记配masterauth,导致主从切换时副本同步失败。这是Cluster模式下很典型的问题。
密码和PodIP我都没有写死在redis.conf里,而是通过k8s的Secret和Downward API注入到容器的环境变量,再在启动命令里传给redis-server。具体实现是这样:Secret存放密码,环境变量REDIS_PASSWORD和REDIS_MASTERAUTH从Secret中读取;环境变量POD_IP则从pod status.podIP字段读取。下面给出Secret和StatefulSet中的环境变量片段。
apiVersion: v1 kind: Secret metadata: name: redis-cluster-secret type: Opaque stringData: redis-password: "请换成你自己的强密码" redis-masterauth: "请换成你自己的强密码"env: - name: POD_IP valueFrom: fieldRef: fieldPath: status.podIP - name: REDIS_PASSWORD valueFrom: secretKeyRef: name: redis-cluster-secret key: redis-password - name: REDIS_MASTERAUTH valueFrom: secretKeyRef: name: redis-cluster-secret key: redis-masterauth接下来是cluster-announce-ip。Redis Cluster启动时,节点会把自己当前的IP记录到nodes.conf中,然后通过这个IP通知其他节点来连接自己。问题在于,Pod在k8s里的IP不是固定的,一旦Pod重建,IP就会变化。如果Redis还拿着旧IP去通知别人,客户端拿着返回的地址去连接,会直接连不上,表现为各种MOVED重定向失败。
解决办法就是显式告诉Redis:对外宣告的IP用当前Pod的IP。通过环境变量POD_IP把IP传给启动参数,配置如下。
args: - redis-server - /etc/redis/redis.conf - --requirepass - "$(REDIS_PASSWORD)" - --masterauth - "$(REDIS_MASTERAUTH)" - --cluster-announce-ip - "$(POD_IP)" - --cluster-announce-port - "6379" - --cluster-announce-bus-port - "16379"这里面$(VAR)的写法是k8s容器启动时的变量替换,不是shell的变量替换。k8s会用容器环境变量的实际值替换它。这个技巧可以说是整套方案里最关键的细节,少了cluster-announce-ip,外部客户端大概率会在故障转移后失联。
3.3 存储与资源规划:PVC不是可选项
Redis是一个内存型数据库,但它还要把数据持久化到磁盘。没有持久卷,Pod重建之后数据直接蒸发,这个集群就失去了意义。
存储我使用StatefulSet自带的volumeClaimTemplates,每个Pod自动生成一个PVC。PVC对应的存储类型,我建议用本地SSD或者低延迟的云盘。Redis的AOF是边写边刷盘,磁盘IOPS直接决定了写入性能上限。如果给Redis挂一个NFS网络存储,AOF每次刷盘的延迟会高到让你怀疑人生。
资源规划方面,我给每个Pod的配置是:CPU request 500m、limit 1000m,内存request 1Gi、limit 2Gi。注意Redis的maxmemory配的是1536mb,加上fork子进程所需的内存,总体还有约400多MB的余量。如果limit和maxmemory设置得太接近,Redis在触发RDB或AOF重写时可能会因为内存超限被k8s直接OOM杀掉。
这里有一条计算公式供参考:容器内存limit至少应该是maxmemory的1.5倍以上。2Gi的limit配1536mb的maxmemory,比例大概在1.3倍,接近临界,如果你的数据量更大,建议把limit扩到3Gi,maxmemory设成2Gi。
4. 手把手部署:从零到六节点Redis Cluster跑起来
前面铺垫了这么多,现在进入核心落地环节。我按实际操作的顺序,把Service、StatefulSet、集群初始化三部分完整走一遍。
4.1 先打通网络:普通Service和无头Service一起上
我在这次部署中创建了两个Service:一个是给客户端访问用的ClusterIP Service,暴露6379端口;另一个是Headless Service,给StatefulSet的Pod提供稳定的DNS记录。
普通的ClusterIP Service可以简单理解为一个入口,客户端只需要连接这个固定IP,不需要关心后面的Pod怎么变。但要注意一个细节:Redis Cluster在做MOVED重定向时,返回给客户端的是Pod的真实IP,而不是Service的ClusterIP。所以客户端必须能够直接访问PodIP。如果客户端跑在同一个k8s集群内,这个没问题;如果客户端在集群外,那还要额外处理Pod网络的暴露方式。我把两种场景整理成了表格。
| 客户端位置 | 访问方式 | 需要注意的问题 |
|---|---|---|
| 集群内Pod | ClusterIP Service + 允许访问PodCIDR | 客户端必须支持Redis Cluster协议,能跟随MOVED重定向 |
| 集群外机器 | NodePort或LoadBalancer,端口映射6379和16379 | 需要在Redis配置里把cluster-announce-ip改成客户端实际可达的地址 |
正常业务场景下,我建议Redis客户端也部署在同一个k8s集群里,这样网络模型最简单,跨Pod访问不需要额外打洞。
下面是两个Service的完整YAML。
apiVersion: v1 kind: Service metadata: name: redis-cluster-svc spec: selector: app: redis-cluster ports: - name: redis port: 6379 targetPort: 6379 - name: cluster-bus port: 16379 targetPort: 16379 --- apiVersion: v1 kind: Service metadata: name: redis-cluster-hs spec: clusterIP: None selector: app: redis-cluster ports: - name: redis port: 6379 targetPort: 6379 - name: cluster-bus port: 16379 targetPort: 16379这里要提醒一下,16379端口是Redis Cluster的节点间总线端口,不要漏掉。我之前有一回只暴露了6379,结果集群初始化时节点之间互相通信失败,排查了很久才发现是总线端口没通。
4.2 StatefulSet完整YAML,逐块拆开讲
这是整套部署的核心。StatefulSet副本数设为6,每个Pod分配一块持久卷,并配置前面提到的环境变量、挂载、启动参数和反亲和。
apiVersion: apps/v1 kind: StatefulSet metadata: name: redis-cluster spec: serviceName: redis-cluster-hs replicas: 6 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 command: - /usr/local/bin/docker-entrypoint.sh args: - redis-server - /etc/redis/redis.conf - --requirepass - "$(REDIS_PASSWORD)" - --masterauth - "$(REDIS_MASTERAUTH)" - --cluster-announce-ip - "$(POD_IP)" - --cluster-announce-port - "6379" - --cluster-announce-bus-port - "16379" env: - name: POD_IP valueFrom: fieldRef: fieldPath: status.podIP - name: REDIS_PASSWORD valueFrom: secretKeyRef: name: redis-cluster-secret key: redis-password - name: REDIS_MASTERAUTH valueFrom: secretKeyRef: name: redis-cluster-secret key: redis-masterauth volumeMounts: - name: data mountPath: /data - name: config mountPath: /etc/redis ports: - containerPort: 6379 name: redis - containerPort: 16379 name: cluster-bus resources: requests: cpu: 500m memory: 1Gi limits: cpu: 1000m memory: 2Gi livenessProbe: exec: command: - sh - -c - redis-cli -a "$REDIS_PASSWORD" ping | grep PONG initialDelaySeconds: 15 periodSeconds: 10 timeoutSeconds: 5 readinessProbe: exec: command: - sh - -c - redis-cli -a "$REDIS_PASSWORD" ping | grep PONG initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 5 volumes: - name: config configMap: name: redis-cluster-config items: - key: redis.conf path: redis.conf volumeClaimTemplates: - metadata: name: data spec: accessModes: - ReadWriteOnce resources: requests: storage: 5Gi这段YAML里有几个地方值得单独说明。
探针这块,我用redis-cli ping的方式来确认Redis进程真的能响应命令,而不是只检查端口活着。这里有个小细节:探针命令里直接用$(REDIS_PASSWORD),但探针的command是在容器启动后执行的shell命令,它能不能访问环境变量取决于容器环境变量的注入,这里因为REDIS_PASSWORD已经通过env定义,所以能被shell访问到。这样探针就不会因为密码没传而误判失败。
volumes里config这一段,是把ConfigMap中的redis.conf文件挂载到容器的/etc/redis目录。这里要注意,ConfigMap挂载默认会覆盖整个目录,所以如果目录下还有其他文件,要先想清楚。我这个场景没有,所以直接挂没问题。
volumeClaimTemplates生成的PVC名字会有规律,比如data-redis-cluster-0对应redis-cluster-0的存储,data-redis-cluster-1对应redis-cluster-1,以此类推。这个规律在排查问题时很有用,可以通过PVC名字快速定位是哪个Pod的存储。
4.3 初始化集群:让六个节点“认识”彼此
六个Pod都起来之后,它们各自都还是独立的Redis实例,并没有形成集群。初始化这一步,我建议用Redis自带的redis-cli来创建,它会自动把16384个哈希槽均匀分配到三个主节点,并且给每个主节点配上从节点。
首先查看六个Pod的IP。
kubectl get pods -l app=redis-cluster -o wide拿到IP之后,比如假设依次是10.244.1.10、10.244.1.11、10.244.1.12、10.244.1.13、10.244.1.14、10.244.1.15,然后在任意一个Pod内执行集群创建命令。
kubectl exec -it redis-cluster-0 -- redis-cli -a "换成你的密码" --cluster create \ 10.244.1.10:6379 10.244.1.11:6379 10.244.1.12:6379 \ 10.244.1.13:6379 10.244.1.14:6379 10.244.1.15:6379 \ --cluster-replicas 1注意,在执行这条命令之前,确认这六个Pod的数据目录都是空的。如果某个节点以前被初始化过、数据目录里已经存在nodes.conf或者AOF文件,redis-cli会报错“Node is not empty”,拒绝继续。处理方式是把对应PVC里的数据清掉,或者重新创建一个干净的PVC。
--cluster-replicas 1这个参数的意思是:每创建完一个主节点,自动指定一个从节点。在输入节点顺序上,前三个会成为主节点,后三个自动成为从节点。至于每个从节点具体配给哪个主节点,redis-cli会随机匹配,不一定是我前面举例里的对应关系。
执行过程中,redis-cli会打印出每个主节点的slot分配计划,然后询问是否继续,确认后进入正式创建。如果想跳过交互,可以加--cluster-yes。
初始化成功之后,用cluster info检查一下集群状态。
kubectl exec -it redis-cluster-0 -- redis-cli -a "换成你的密码" --no-auth-warning cluster info如果看到cluster_state:ok,说明集群已经处于正常状态。如果显示cluster_state:fail,说明slot没有全部分配,或者部分节点失联,回去查网络和IP。
5. 验证、故障演练与避坑
集群初始化成功只是第一步,真正的考验在故障演练。这一节我把验证方法、演练过程和常见问题排查整理一遍,这些经验是内部文档里不会写的东西。
5.1 怎么判断三主三从真的建好了
不要只看Pod数量是六个就以为大功告成。我通常会用两步验证。
第一步,用cluster nodes查看集群拓扑。执行命令:
kubectl exec -it redis-cluster-0 -- redis-cli -a "换成你的密码" --no-auth-warning cluster nodes输出每一行代表一个节点,重点看两列:第一列是节点ID,第二列包含IP、端口和角色标志。角色标志中,master表示主节点,slave表示从节点。正常情况下,你会看到三个master,三个slave,且每个master都对应一个slave。
第二步,检查哈希槽分配是否完整。用cluster slots命令更直观:
kubectl exec -it redis-cluster-0 -- redis-cli -a "换成你的密码" --no-auth-warning cluster slots输出会显示每个slot区间的起始、结束以及对应的主从节点。对照一下,16384个槽应该不多不少全部分配完毕。
还有一个运维层面的检查:主从节点不能落在同一台物理节点上。这个通过综合cluster slots和kubectl get pods -o wide两步来核对。先看每个Pod的NODE列,再看每个主从对分别属于哪个Pod,最后确认没有任意一对主从属于同一台机器。这一步很繁琐,但很重要。我建议写成一个小脚本,定期巡检。
5.2 主节点挂了会怎样:一次实际故障演练记录
理论说得再多,不如亲手杀一个主节点看看效果。我通常用两种方式模拟故障。
第一种方式,删除Pod,模拟Pod异常重启。这种方式最贴近真实的k8s场景。
kubectl delete pod redis-cluster-0StatefulSet会自动重建redis-cluster-0,新Pod的名字不变,但IP会变。删除瞬间,集群会检测到redis-cluster-0失联,超过cluster-node-timeout之后,它的从节点会发起failover成为新的主节点。执行完命令后等大约10到30秒,再查看cluster nodes。
我在演练中看到的现象是:原主节点标记为fail状态,它的从节点角色从slave变成了master,所有原本属于旧主的slot分配到了新主上。旧Pod重建完成后,作为新成员重新加入集群,并自动在新的主节点上补齐数据,角色变成slave。整个过程对客户端来说有短暂的连接中断和重定向,但不影响整体服务。
第二种方式,模拟进程卡死,不触发Pod重建。有时候我们想观察集群层面的故障转移而不想让Pod被重建,可以在容器内执行debug sleep,让Redis主进程阻塞一段时间。
kubectl exec -it redis-cluster-0 -- redis-cli -a "换成你的密码" --no-auth-warning debug sleep 30这条命令会阻塞Redis主线程,让它无法响应集群的心跳。等待超过node-timeout后,集群同样会触发故障转移。这种方式对观察failover过程更有帮助,因为它没有新旧Pod切换的干扰。
演练之后务必确认两件事:一是新主节点上的数据完整,二是旧主恢复后能正常同步回最新数据。否则故障转移虽然发生了,但数据一致性出问题,服务依然不可用。
5.3 常见问题速查与避坑记录
在我维护这套系统的过程中,遇到过不少奇奇怪怪的问题。我挑几个典型的高频问题整理成速查表,希望你能少走弯路。
| 现象 | 根因 | 处理办法 |
|---|---|---|
| Pod一直Pending,提示节点资源不足 | PVC无法绑定,或者反亲和过度约束 | 检查storageclass是否可用;节点数量是否满足反亲和要求;必要时改用软反亲和 |
| 集群初始化时报Node is not empty | 数据目录已有旧数据或nodes.conf | 清空/data目录,或删除对应PVC重新创建 |
| 客户端报MOVED错误但重定向连不上 | cluster-announce-ip未配置,或配置的是旧IP | 确认Pod的env里有POD_IP,启动参数里正确传入了--cluster-announce-ip |
| 主从切换后复制关系断掉 | 只配了requirepass,没配masterauth | 保持一致,同时配置masterauth |
| 6379端口可访问,16379不通 | 总线端口未暴露,或网络策略阻止 | 检查Service、NetworkPolicy以及云平台安全组 |
| Redis容器反复重启 | redis.conf里开了daemonize yes | 删除daemonize配置,让redis-server前台运行 |
| 内存使用超限被OOM掉 | maxmemory设置过高,接近容器内存limit | 按照limit的60%-70%设置maxmemory,预留fork所需内存 |
还有一个很隐蔽的坑,单独拿出来说一下:如果你在部署过程中改了ConfigMap里的redis.conf,记得要通过重启Pod来让配置生效。修改ConfigMap本身不会自动触发Pod滚动,很多人在配置改了之后发现Redis行为没变化,以为是配置没生效,其实是Pod压根没有重新加载配置。
写在最后的一点体会
这套三主三从的Redis Cluster在k8s上跑通之后,我再回头看,最大的收获不是会写那几个YAML,而是想明白了有状态服务在容器平台上的生命周期管理:状态要交给PVC、身份要交给StatefulSet、节点发现要交给地址宣告、容灾要交给反亲和和Cluster自身的故障转移机制。这几件事环环相扣,少一环都会在故障来临的时候显形。
如果你正准备做类似的迁移,我的建议是先别急着抄YAML,回到业务需求上想清楚两件事:数据量到底需要不需要分片,以及你的k8s集群有没有足够的节点来兜底。想清楚这两个问题,后面的每一步就都顺了。