Redis Operator 升到 0.26.0:哨兵打散与 Harbor 循环依赖
2026-09-27 凌晨,把 OT Redis Operator 从 v0.24.0 升到了 v0.26.0。起因是检查哨兵落点时发现:九套 Redis 都配置了三个 Sentinel,但其中四套只分布在两台节点上,同一台机器承载了两张票。
这次先经历了一轮回滚。过早更新 Redis 的 init 容器镜像,让多套 StatefulSet 开始滚动,其中就包括 Harbor 自己依赖的 Redis。Harbor 随之返回 503,需要启动的容器又要经过 Harbor 拉镜像,恢复链路绕成了一个圈。
第二轮把预拉镜像、升级控制器、应用调度、升级 init 分开,最终停在 Helm revision 10。写作时于 **02:59(UTC+8)**复核,45 个 Redis/哨兵 Pod 均为 Running、Ready,每套哨兵分布在三台节点上。
下文根据当晚 Cursor 操作记录、Helm 历史 manifest、旧 Chart 备份和现网只读检查整理。节点名、示例实例名、域名和路径做了泛化,版本与 revision 保留实际记录。这次 Redis 和 Sentinel 的镜像仍是 v7.0.15,没有同时升级 Redis 数据面版本。
三个哨兵,为什么仍然不够
原来的结构是三台 Kubernetes 节点、九个 RedisReplication。每套两个 Redis 数据 Pod,嵌套三个 Sentinel,quorum: "2"。
问题不在副本数量,而在它们落在哪里。升级前的 Pod 清单里,Harbor 那套哨兵分布类似这样:
| 节点 | Sentinel 数量 |
|---|---|
| A | 0 个 |
| B | 2 个 |
| C | 1 个 |
如果 B 故障,一次就失去两个 Sentinel。quorum 用来判断主节点是否客观下线,真正发起故障转移还需要多数 Sentinel 授权;它不参与 Kubernetes 的节点选择。三个进程不等于三个故障域。Redis Sentinel 官方说明
检查配置时要分清两层:
RedisReplication.spec的调度字段作用于 Redis 数据 Pod。RedisReplication.spec.sentinel的字段作用于这套实例的嵌套 Sentinel。
v0.24.0 的 CRD 已经有顶层调度字段,但嵌套 sentinel 没有 affinity、topologySpreadConstraints 等字段。旧备份和当时的 kubectl explain 都能确认这一点。直接把新字段写进旧 CR,不能指望它生效;根据提交方式和校验设置,未知字段可能被拒绝或裁剪。
上游 PR #1786 给嵌套 Sentinel 补上了调度字段,并把它们传到 StatefulSet 的 PodSpec;v0.26.0 发布说明包含这项变更。因此这次继续保留嵌套 Sentinel,升级 Operator 和 CRD,再把调度写回 CR。
直接编辑生成的 StatefulSet 不能作为长期配置:后续 reconcile 仍以 CR 为准。本次也没有启用 webhook,最终保持 webhook: false。
三节点上的取舍:哨兵打散,避开 Redis 用软约束
三台机器上,每套两个数据 Pod、三个哨兵,不可能同时满足“哨兵每台一个”和“哨兵完全不与本套 Redis 同机”。
如果给哨兵配置硬反亲和,禁止它与本套 Redis 同机,两个 Redis 分别占住两台后,哨兵就只剩第三台可选。没有哨兵互斥规则时会集中到一台;再加严格打散规则,又可能使 Pod Pending。
本次采用下面这组规则:
| 对象 | 规则 | 作用 |
|---|---|---|
| 两个 Redis 数据 Pod | hostname 拓扑打散,maxSkew: 1、DoNotSchedule | 在本次三个可用节点之间分散 |
| 三个 Sentinel | 独立的 hostname 拓扑打散,同样限制偏斜 | 本次最终每台一个 |
| Sentinel 与本套 Redis | preferredDuringSchedulingIgnoredDuringExecution | 尽量避开,节点不足时允许同机 |
| 数据面 PDB | minAvailable: 1 | 限制通过 Eviction API 发起的自愿中断 |
| Sentinel PDB | minAvailable: 2 | 维护驱逐时保留两只健康哨兵 |
| Sentinel 资源 | requests 50m / 64Mi,limits 200m / 128Mi | 补上原来为空的资源声明 |
下面是合并到原 RedisReplication 的 spec 片段。example-redis 要替换为自己的实例名;原有镜像、凭据引用、存储和 Redis 配置继续保留,不能拿这个片段替换完整 CR。
spec: clusterSize: 2 topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: example-redis role: replication pdb: enabled: true minAvailable: 1
sentinel: size: 3 quorum: "2" resources: requests: cpu: 50m memory: 64Mi limits: cpu: 200m memory: 128Mi topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: DoNotSchedule labelSelector: matchLabels: app: example-redis-s role: sentinel affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: topologyKey: kubernetes.io/hostname labelSelector: matchLabels: app: example-redis role: replicationselector 必须对应 Operator 实际生成的 Pod 标签。这里同时使用实例的 app 和 role,避免把同一命名空间的其他 Redis 也算进来。
v0.26.0 的嵌套 Sentinel 仍没有 pdb 字段。所以数据面 PDB 交给 Operator,哨兵 PDB 单独保存为 sentinel-pdb.yaml:
apiVersion: policy/v1kind: PodDisruptionBudgetmetadata: name: example-redis-s namespace: examplespec: minAvailable: 2 selector: matchLabels: app: example-redis-s role: sentinel这里有两个需要保留的边界。
首先,maxSkew: 1 控制的是符合条件的拓扑域之间的数量偏差。本次三台节点可参与调度时,三个 Sentinel 最终是 1 / 1 / 1;配置没有设置 minDomains,不能据此承诺节点减少后仍一定每台最多一个。它也不会自动搬迁已经运行的 Pod,本次重新分布发生在模板变更后的滚动过程中。Kubernetes 拓扑打散说明
其次,PDB 约束的是 Eviction API,例如正常的 kubectl drain。它不能防止机器故障,也不会拦住 StatefulSet 控制器自身的滚动更新。因此“有 PDB”不能推出“这次升级不会影响业务”。Kubernetes Disruptions
第一次升级:Harbor 等 Redis,Redis 等 Harbor
这套部署开启了 GenerateConfigInInitContainer=true。Redis 数据 Pod 里有一个 init-config 容器,使用的也是 Operator 镜像,但它的版本可以通过 redisOperator.initContainerImageTag 单独控制。
当晚 Helm 历史 manifest 展示了关键转折:
| 时间(UTC+8) | revision | Operator | init-config | Operator 拉取策略 |
|---|---|---|---|---|
| 升级前 | 2 | v0.24.0 | v0.24.0 | Always |
| 02:05:07 | 3 | v0.26.0 | v0.24.0 | Always |
| 02:07:00 | 4 | v0.26.0 | v0.26.0 | Always |
| 02:29:24 | 8 | v0.24.0 | v0.24.0 | Always |
第一步已经分开了控制器和 init 的版本,但紧接着就更新了 init,尚未把新镜像在所有节点上的可用性处理好。init 镜像变化进入 Redis StatefulSet 的 Pod 模板,多套数据 Pod 随之滚动,其中包含 Harbor 自己的 Redis。
操作记录中的恢复阻塞链是:
更新 init-config 镜像 → 多套 Redis 开始滚动 ↓ Harbor 的 Redis 暂时不可用 ↓ Harbor core 连接失败,入口 503 ↓ 待启动容器经 Harbor 拉镜像失败 ↓ Redis 恢复被镜像拉取挡住Harbor 的 Redis 主容器原本就使用 quay.io/opstree/redis,但 init-config 仍使用 Harbor 代理地址。只检查业务容器的镜像来源,会漏掉这条启动依赖。控制器自身当时又是 Always,重建时也需要访问仓库。
Always 不表示每次都重新下载全部镜像层,但容器运行时仍需联系仓库解析镜像。仓库返回 503 时,已有缓存不一定能让它启动。IfNotPresent 才会在本地已有所需镜像时跳过拉取。Kubernetes 镜像拉取策略
随后几次 revision 尝试恢复旧 init、暂停控制器并预热镜像。02:27 决定全量撤回这轮变更,02:29:24 的 revision 8 明确记录为 Rollback to 2。
回退也不只有一条 helm rollback:旧 Chart、CRD 和实例配置一并恢复,新增调度字段、18 个 PDB 及预热资源被撤掉。Redis 再滚动后恢复,Harbor /v2/ 回到 401。这个回退恢复的是部署配置,不能把它等同于 Redis 持久数据恢复。
第二轮:把会引发滚动的变化拆开
先准备 Chart,确认拿到的版本
第二轮从 02:38 开始,只修改本地 Chart 和九套实例文件。来源是上游 v0.26.0 tag 的代码归档,保留既有实例配置,更新模板和 CRD。
这里还发现一个版本标识问题:该 tag 下的 Chart.yaml仍写着 version: 0.25.0、appVersion: "0.25.0"。当晚把本地这两个字段明确改成了 0.26.0。所以现网显示的 redis-operator-0.26.0 是这份本地整理后的 Chart,不能据此声称下载到了同版本的官方 Helm 发布包。
上游 Deployment 模板对 init 版本的取值顺序是:
initContainerImageTag → imageTag → v + Chart.appVersion这意味着 init tag 留空时,修改 Chart 应用版本可能顺带改动 init。第二轮在本地配置和升级命令里都显式固定它,关键 values 如下:
replicas: 1redisOperator: imageName: harbor.example.com/quay/opstree/redis-operator imageTag: "" initContainerImageTag: v0.24.0 imagePullPolicy: IfNotPresent webhook: falsefeatureGates: GenerateConfigInInitContainer: true这里 imageTag 为空依赖的是已核对的本地 appVersion: "0.26.0"。渲染时分别检查控制器镜像和 INIT_CONTAINER_IMAGE,确认前者是 v0.26.0,后者仍是 v0.24.0,再继续操作。
Harbor 正常时,先把镜像拉到三台节点
02:41 先检查 Harbor:Redis master Service 有 endpoint,core Ready,/v2/ 返回 401。对于这个启用了认证的入口,401 表示已回到认证响应;它不同于前面的 503,但仍不能单独证明镜像下载成功。
随后在三台节点逐一执行了真实的 crictl pull。泛化后的命令如下,节点别名和 CRI 工具路径需要对应自己的环境:
for node in node-a node-b node-c; do ssh "$node" \ 'crictl pull harbor.example.com/quay/opstree/redis-operator:v0.26.0' \ || exit 1done当晚三台都拉取成功,也保留了 v0.24.0 镜像。预拉必须使用 Pod 实际引用的完整镜像名:拉了 quay.io/...,不能就假定本地已有 harbor.example.com/quay/... 这个引用。
这里没有继续保留额外的预热 Job 或 DaemonSet。对这次固定三节点的维护,逐节点拉取并检查结果就足够了。
先更新 CRD,再只升级 Operator
这份 Chart 的 CRD 位于 crds/,不能依赖 helm upgrade 自动更新已有 CRD。Helm CRD 管理说明
当晚先更新 CRD,确认 API Server 已接受新字段,再发布控制器。下列命令是对实际操作的路径泛化:
kubectl apply --server-side --force-conflicts \ -f ./redis-operator/crds/crds.yaml
kubectl explain redisreplication.spec.sentinel.affinitykubectl explain redisreplication.spec.sentinel.topologySpreadConstraints
helm upgrade redis-operator ./redis-operator \ -n ot-operators \ --reuse-values \ --set replicas=1 \ --set redisOperator.imagePullPolicy=IfNotPresent \ --set redisOperator.initContainerImageTag=v0.24.0 \ --wait --timeout 3mCRD 是集群级资源。这里的 --force-conflicts 是当晚实际使用的参数,会接管冲突字段的所有权;复用前需要核对管理者和差异,不能把它当成默认必需参数。--reuse-values 则保留该 release 已有设置,同时显式覆盖副本数、拉取策略和 init 版本。
02:43:16,Helm revision 9 完成。Operator 已是 v0.26.0,init 仍为 v0.24.0。操作记录比较了升级前后的 StatefulSet 和 Pod 状态,确认这一阶段没有因 init 版本变化触发九套 Redis 滚动。
这个混用只作为本次短暂的过渡状态;它不是对任意 Operator/init 版本组合的兼容性承诺。
再应用调度,Harbor 放在最后
之后给九套 RedisReplication 应用调度和资源配置,并创建各自的 Sentinel PDB:
kubectl apply \ -f ./example/redis-replication.yaml \ -f ./example/sentinel-pdb.yaml实际顺序是先其他八套,再 Harbor。记录中的命令连续提交了这些配置,没有逐套等待业务验收;因此这里只能说“Harbor 最后提交”,不能写成“每套验收完成才升级下一套”。
这一步才真正改变数据面和 Sentinel 的 Pod 模板。滚动过程中,Harbor 的 master Service 曾暂时没有 endpoint,需要等待 Operator 补齐 redis-role 标签,core 才恢复连接。最后每套两个数据 Pod 分布在两台节点,三个哨兵分布在三台节点,18 个 PDB 已创建。
Harbor 入口仍短暂出现过 503。这次预拉镜像与 IfNotPresent 解决的是“恢复又被拉镜像卡住”的问题,没有消除 Redis 切换、服务标签收敛和应用重连带来的中断。
最后再把 init 升到 v0.26.0
调度稳定后,最初可选的第四步也在确认后执行了。先重新确认 Harbor 正常、三台节点保留目标镜像,再把本地 values 和 release 的 init tag 一起更新:
helm upgrade redis-operator ./redis-operator \ -n ot-operators \ --reuse-values \ --set redisOperator.initContainerImageTag=v0.26.0 \ --set redisOperator.imagePullPolicy=IfNotPresent \ --wait --timeout 3m02:49:34,Helm revision 10 完成。九套 Redis 数据 StatefulSet 再次因 init 镜像变化滚动,Harbor 也经历了短暂 503;随后主从标签、master endpoint 和 core/jobservice 恢复,入口回到 401。
最终的 init 版本是 v0.26.0。v0.24.0 是第二轮拆分升级时的中间状态,不是最后遗留的配置。
最后验收到了哪一层
02:59 的只读复核结果如下:
| 检查项 | 结果 |
|---|---|
| Helm release | revision 10,deployed,Chart/appVersion 为 0.26.0 |
| Operator | v0.26.0,1/1 Ready,IfNotPresent |
| Redis 的 init-config | 九套共 18 个数据 Pod 均引用 v0.26.0,IfNotPresent |
| 数据面与哨兵 | 18 个 Redis Pod、27 个 Sentinel Pod,45 个全部 Running、Ready |
| 调度结果 | 九套数据面各跨两台节点;九套哨兵各跨三台节点 |
| 主从标签 | 每套一个 redis-role=master、一个 redis-role=slave |
| PDB | 数据面 9 个、Sentinel 9 个;分别观察到 2、3 个健康 Pod |
| Harbor | master EndpointSlice 有 Ready endpoint;core、jobservice 各 1/1 Ready;/v2/ 为 401 |
| CRD | 嵌套 Sentinel 包含 affinity、拓扑打散等字段,仍无嵌套 pdb |
复核时的核心只读命令可以保留:
helm history redis-operator -n ot-operators
kubectl get pods -A -l 'role in (replication,sentinel)' \ -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,NODE:.spec.nodeName,PHASE:.status.phase,ROLE:.metadata.labels.redis-role'
kubectl get pdb -A
kubectl -n harbor get endpointslices \ -l kubernetes.io/service-name=harbor-redis-master
curl -sS -o /dev/null -w '%{http_code}\n' \ --connect-timeout 5 --max-time 10 \ https://harbor.example.com/v2/上面的 Pod 命令展示落点和 phase;Ready 还需要检查 Pod conditions 和容器状态。主从标签也只是 Operator 的观察结果,不能代替 Redis 协议层的复制状态检查。
这份记录没有据此宣称零停机、零数据丢失,也没有把它当成九套业务的完整回归、主动故障转移测试或数据恢复演练。短暂 503 在第二轮仍然发生,只是恢复没有再次卡在镜像获取上。
还留下了一条启动依赖
最终镜像来源没有统一改成同一个仓库:
| 组件 | 镜像来源 |
|---|---|
| Operator、所有 Redis 的 init-config | Harbor 的 Quay 代理 |
| Harbor 自己的 Redis、Sentinel、exporter | 原有 quay.io 引用 |
| 其他八套 Redis、Sentinel、exporter | Harbor 的 Quay 代理 |
因此这次完成的是:用预拉缓存和拉取策略,让这轮维护在 Harbor 短暂不可用时仍能继续恢复。Harbor Redis 的 init 仍依赖 Harbor 镜像引用,循环依赖没有从架构上完全消失。 新增节点、缓存被回收或下一次换 tag 时,还需要重新核对所有启动镜像,包括 init 容器和 sidecar。
这次升级解决了最初那个具体问题:嵌套 Sentinel 的调度终于能由 CR 声明,并在本次三节点环境中按预期打散;数据面也补上了分布约束,维护驱逐有了 PDB,哨兵有了资源声明。
升级顺序则留下了另一条经验:控制器版本、init 镜像和业务 Pod 模板是不同的变更面。先确认恢复时需要的镜像在哪里,再决定哪一步允许业务滚动,才能避免把自己的镜像仓库一起困在恢复路径里。