这篇文章用来长期记录我的 Kubernetes 高可用建设过程。

高可用不是只把 Kubernetes 主节点做成 3 台就结束了。三主节点主要解决的是控制面高可用,也就是 API Server、etcd、Controller Manager、Scheduler 这些组件在单节点故障时还能继续工作。业务是否高可用,还要继续看服务副本、调度分散、数据库主从、Redis、存储卷、Ingress、负载均衡和监控告警。

当前集群节点:

k8s-master1  192.168.3.214
k8s-master2  192.168.3.215
k8s-master3  192.168.3.216
VIP          192.168.3.217

高可用分层

我现在把 Kubernetes 高可用拆成几层来看:

  • 控制面高可用:3 个 control-plane 节点,etcd 保持多数派。
  • 入口高可用:Keepalived 提供 VIP,HAProxy 转发到多个 API Server 和 Ingress NodePort。
  • 服务高可用:业务 Deployment 多副本,副本分散到不同节点。
  • 数据库高可用:PostgreSQL、Redis 不能只是单实例。
  • 存储高可用:Longhorn 卷副本需要分散,节点失联后能恢复。
  • 监控高可用:Prometheus、Grafana、Alertmanager 要能发现异常并保留证据。

三主节点的边界

三主节点断 1 个,Kubernetes 控制面理论上应该还能用。因为 3 个 etcd 节点掉 1 个以后,还剩 2 个节点,仍然满足多数派。

但是控制面可用不等于业务可用。比如 new-api、PostgreSQL、Redis 如果都是单实例,或者都依赖同一个 ReadWriteOnce PVC,那么节点失联后业务 Pod 可能无法立刻在其他节点恢复。

这次压测后 192.168.3.215 失联时,集群里看到:

k8s-master1   Ready
k8s-master2   NotReady
k8s-master3   Ready

控制面没有整体崩溃,kubectl 仍然能查询资源。但是 new-api、PostgreSQL、Redis 和部分 Longhorn 卷受到影响,这说明当前只是控制面有 HA,业务和存储还没有完整 HA。

故障记录:192.168.3.215 网卡失联

现象

压测后,192.168.3.215 无法 SSH:

ssh: connect to host 192.168.3.215 port 22: Connection timed out

从其他节点访问 215:

ping -c 4 192.168.3.215
ip neigh show 192.168.3.215

结果类似:

Destination Host Unreachable
192.168.3.215 dev ens33 INCOMPLETE

这说明问题不是 SSH 进程挂了,也不是 kubelet 单独异常,而是同网段二层 ARP 都找不到这台机器的网卡。

Kubernetes 中 k8s-master2 最后心跳大概在:

Node LastHeartbeatTime: 2026-06-01 20:56:40
Lease RenewTime:       2026-06-01 20:59:56
NodeNotReady:          2026-06-01 21:01:09

HAProxy 也在这个时间附近把 k8s-master2 后端判定为 DOWN:

2026-06-01 21:00:14 Server kubernetes-apiserver/k8s-master2 is DOWN
2026-06-01 21:00:14 Server ingress_http_nodeport/k8s-master2 is DOWN
2026-06-01 21:00:14 Server ingress_https_nodeport/k8s-master2 is DOWN

重启后排查

重启 192.168.3.215 后先确认节点恢复:

kubectl get nodes -o wide

然后查看上一次启动周期的日志:

journalctl --list-boots
last -x | head
journalctl -b -1 --since "2026-06-01 20:50:00" --until "2026-06-01 21:05:00" -p warning
journalctl -k -b -1 --since "2026-06-01 20:50:00" --until "2026-06-01 21:05:00"

关键日志:

2026-06-01 21:00:05 k8s-master2 kernel: e1000 0000:02:01.0 ens33: Detected Tx Unit Hang
2026-06-01 21:00:07 k8s-master2 kernel: e1000 0000:02:01.0 ens33: Detected Tx Unit Hang
2026-06-01 21:00:09 k8s-master2 kernel: e1000 0000:02:01.0 ens33: Detected Tx Unit Hang

这条日志持续重复到重启前。它表示网卡发送队列卡死。

查看网卡型号和驱动:

lspci -nn | egrep -i 'ethernet|network|vmware'
ethtool -i ens33

当时结果是:

Ethernet controller: Intel Corporation 82545EM Gigabit Ethernet Controller
driver: e1000
interface: ens33

这是 VMware 里的 E1000 仿真网卡。结论是:这次 192.168.3.215 失联的直接原因不是 Kubernetes,而是 VMware 虚拟网卡 e1000 在网络流量下出现 Detected Tx Unit Hang,导致节点从网络层消失。

修复方向

把 VMware 虚拟机网卡类型从 E1000 改成 VMXNET3

如果 UI 里看不到网卡类型,可以关闭虚拟机后修改 .vmx 文件:

ethernet0.virtualDev = "vmxnet3"

原来如果是:

ethernet0.virtualDev = "e1000"

就改成:

ethernet0.virtualDev = "vmxnet3"

启动后检查驱动:

ethtool -i ens160

期望看到:

driver: vmxnet3

网卡名变化

修改为 VMXNET3 后,Ubuntu 中网卡名可能从 ens33 变成 ens160

这时需要检查所有写死旧网卡名的配置:

grep -R "ens33" /etc/keepalived /etc/haproxy /etc/netplan /etc/kubernetes /etc/systemd /etc/NetworkManager 2>/dev/null

常见需要修改:

/etc/netplan/*.yaml
/etc/keepalived/keepalived.conf
/etc/NetworkManager/system-connections/*.nmconnection

Keepalived 中如果有:

vrrp_instance VI_1 {
    interface ens33
}

改成:

vrrp_instance VI_1 {
    interface ens160
}

如果有 track_interface,也要一起改:

track_interface {
    ens160
}

NetworkManager 中也可能残留旧网卡名,例如:

/etc/NetworkManager/system-connections/有线连接 1.nmconnection:interface-name=ens33

推荐用 nmcli 修改,而不是直接编辑文件:

# 查看所有连接
nmcli connection show
nmcli connection modify "有线连接 1" connection.interface-name ens160
nmcli connection reload
nmcli connection up "有线连接 1"

如果看到原本 ens160 挂在 有线连接 2 上,切换后变成:

NAME        TYPE      DEVICE
有线连接 1  ethernet  ens160
有线连接 2  ethernet  --

说明当前活动连接已经切到 有线连接 1。为了避免重启后又自动切回 有线连接 2,可以固定自动连接优先级:

nmcli connection modify "有线连接 1" connection.autoconnect yes connection.autoconnect-priority 100
nmcli connection modify "有线连接 2" connection.autoconnect no

再确认 有线连接 1 的 IP、网关和 DNS 是否正确:

nmcli connection show "有线连接 1" | egrep "interface-name|ipv4.method|ipv4.addresses|ipv4.gateway|ipv4.dns|autoconnect"
ip addr show ens160
ip route

如果虚拟机所在宿主机有两个物理网口,需要注意:虚拟机内部的 ens160 只代表虚拟机里的网卡,不决定桥接到宿主机哪个物理网口。桥接到哪个物理网口,要在 VMware 的虚拟网络设置里配置,例如 VMnet0 桥接到指定宿主机网卡。Ubuntu 里只需要保证 ens160 配好固定 IP,Keepalived 也指向 ens160

改完后检查:

netplan apply
systemctl restart keepalived
systemctl status keepalived
ip addr show ens160
ip addr | grep 192.168.3.217

三台主节点建议一台一台改。每改完一台,都确认:

kubectl get nodes -o wide
systemctl is-active kubelet containerd keepalived haproxy
ip addr | grep 192.168.3.217

入口高可用

new-api、PostgreSQL、Redis 都做成高可用以后,还要检查入口层。否则业务 Pod 和数据库都还活着,但域名仍然可能无法访问。

这次关闭 k8s-master3 / 192.168.3.216 后,k8s-master1 立刻出现:

haproxy[1067]: backend ingress_http_nodeport has no server available!
haproxy[1067]: backend ingress_https_nodeport has no server available!

原因是 ingress-nginx-controller 只有 1 个副本,而且刚好运行在 k8s-master3

kubectl -n ingress-nginx get deploy ingress-nginx-controller
kubectl -n ingress-nginx get pods -o wide
kubectl -n ingress-nginx get svc ingress-nginx-controller -o yaml | grep -E "type:|externalTrafficPolicy|nodePort"

当唯一的 Ingress Controller 所在节点关闭后,Service Endpoints 被摘掉,HAProxy 检查的 NodePort 后端就全部不可用:

HAProxy VIP:       192.168.3.231:80/443
Ingress NodePort:  32467/http, 32419/https
Ingress Pod:       只有 1 个,并且在 k8s-master3

这说明入口层仍然是单点。修复思路是把 ingress-nginx-controller 扩成多个副本,并尽量分散到不同节点。

现场恢复 Ingress

如果已经发生故障,可以先用 kubectl patch 现场恢复:

cat <<'EOF' | kubectl -n ingress-nginx patch deploy ingress-nginx-controller \
  --type merge \
  --patch-file /dev/stdin
{
  "spec": {
    "replicas": 3,
    "template": {
      "spec": {
        "topologySpreadConstraints": [
          {
            "maxSkew": 1,
            "topologyKey": "kubernetes.io/hostname",
            "whenUnsatisfiable": "ScheduleAnyway",
            "labelSelector": {
              "matchLabels": {
                "app.kubernetes.io/component": "controller",
                "app.kubernetes.io/instance": "ingress-nginx",
                "app.kubernetes.io/name": "ingress-nginx"
              }
            }
          }
        ],
        "affinity": {
          "podAntiAffinity": {
            "preferredDuringSchedulingIgnoredDuringExecution": [
              {
                "weight": 100,
                "podAffinityTerm": {
                  "topologyKey": "kubernetes.io/hostname",
                  "labelSelector": {
                    "matchLabels": {
                      "app.kubernetes.io/component": "controller",
                      "app.kubernetes.io/instance": "ingress-nginx",
                      "app.kubernetes.io/name": "ingress-nginx"
                    }
                  }
                }
              }
            ]
          }
        }
      }
    }
  }
}
EOF

等待 Ingress Controller 恢复:

kubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller --timeout=300s
kubectl -n ingress-nginx get pods -o wide
kubectl -n ingress-nginx get endpoints ingress-nginx-controller -o wide

检查 HAProxy 是否重新把 214、215 的 NodePort 标记为 UP:

journalctl -u haproxy --since "5 minutes ago" --no-pager | grep ingress

期望看到类似:

Server ingress_http_nodeport/k8s-master1 is UP
Server ingress_https_nodeport/k8s-master1 is UP
Server ingress_http_nodeport/k8s-master2 is UP
Server ingress_https_nodeport/k8s-master2 is UP

最后验证域名:

curl -k -I https://k8s-ai.jihw.top/
curl -k https://k8s-ai.jihw.top/api/status

持久化 Ingress 配置

上面的 kubectl patch 是现场恢复手段。因为 ingress-nginx 是 Helm 安装的,后续 helm upgrade 可能覆盖手工 patch,所以要把配置写进 Helm values。

创建 ingress-nginx-ha-values.yaml

cat <<'EOF' > ingress-nginx-ha-values.yaml
controller:
  # Ingress Controller 至少 2 个副本;三节点集群可以设置为 3。
  replicaCount: 3

  # 让多个 Ingress Controller 尽量分散到不同节点。
  topologySpreadConstraints:
    - maxSkew: 1
      topologyKey: kubernetes.io/hostname
      whenUnsatisfiable: ScheduleAnyway
      labelSelector:
        matchLabels:
          app.kubernetes.io/component: controller
          app.kubernetes.io/instance: ingress-nginx
          app.kubernetes.io/name: ingress-nginx

  affinity:
    podAntiAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 100
          podAffinityTerm:
            topologyKey: kubernetes.io/hostname
            labelSelector:
              matchLabels:
                app.kubernetes.io/component: controller
                app.kubernetes.io/instance: ingress-nginx
                app.kubernetes.io/name: ingress-nginx

  service:
    # 保持 Cluster,任意节点的 NodePort 都可以转发到可用的 Ingress Pod。
    externalTrafficPolicy: Cluster
EOF

用 Helm 持久化:

helm upgrade ingress-nginx ingress-nginx/ingress-nginx \
  -n ingress-nginx \
  --reuse-values \
  -f ingress-nginx-ha-values.yaml

如果当前机器没有 ingress-nginx Helm repo,先添加:

helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update

再次确认:

kubectl -n ingress-nginx get deploy ingress-nginx-controller
kubectl -n ingress-nginx get pods -o wide
kubectl -n ingress-nginx get svc ingress-nginx-controller -o yaml | grep externalTrafficPolicy
curl -k -I https://k8s-ai.jihw.top/

服务高可用

当前 new-api 部署是单副本:

Deployment/new-api replicas: 1
Service/new-api     ClusterIP
Ingress/new-api     k8s-ai.jihw.top

它依赖:

PostgreSQL: postgresql.new-api.svc.cluster.local:5432
Redis:      redis-master.new-api.svc.cluster.local:6379
PVC:        new-api-data

所以 new-api 服务高可用要分两步做:

第一步:先让 new-api 应用本身多副本、多节点分散。
第二步:再继续做 PostgreSQL 和 Redis 高可用。

注意:只做第一步时,如果 PostgreSQL 或 Redis 所在节点挂掉,new-api 仍然可能不可用。服务高可用只是先解决 new-api Pod 单点,不等于数据库高可用。

先检查 PVC

当前部署里 new-api 挂载了 new-api-data

volumeMounts:
  - name: data
    mountPath: /data
volumes:
  - name: data
    persistentVolumeClaim:
      claimName: new-api-data

先查看这个 PVC 的访问模式:

kubectl get pvc new-api-data -n new-api
kubectl describe pvc new-api-data -n new-api

如果是:

ACCESS MODES: RWO

就不能直接把 new-api 改成跨节点多副本。ReadWriteOnce 卷同一时间只能被一个节点以读写方式挂载。强行 replicas: 3 后,副本一旦分散到 214、215、216,可能出现:

Multi-Attach error for volume "new-api-data"
Volume is already exclusively attached to one node

处理方式有两个:

  • 推荐方式:让 new-api 尽量无状态化,不再挂载 /data PVC,业务状态放 PostgreSQL 和 Redis。
  • 备选方式:把 new-api-data 改成支持 ReadWriteMany 的共享存储,例如 Longhorn RWX/NFS,再给多个副本共享挂载。

我当前优先选择第一种:new-api 应用层先无状态化,多副本通过 PostgreSQL 和 Redis 共享业务状态。日志继续使用 emptyDir,应用重启后日志不作为持久数据依赖。

备份当前配置

修改前先导出现有配置:

kubectl -n new-api get deploy new-api -o yaml > new-api-deploy-before-ha.yaml
kubectl -n new-api get svc new-api -o yaml > new-api-svc-before-ha.yaml
kubectl -n new-api get ingress new-api -o yaml > new-api-ingress-before-ha.yaml

更新 new-api Deployment

下面这个版本做了几件事:

1. replicas 改成 3。
2. 移除 /data PVC,让 new-api 先按无状态服务运行。
3. 用 topologySpreadConstraints 尽量分散到 3 个节点。
4. 用 podAntiAffinity 避免多个副本挤在同一个节点。
5. 增加 startupProbe,避免节点重启后 PostgreSQL/Redis 还没好时 new-api 反复被 liveness 杀掉。
6. 保留 readinessProbe,让未就绪的 Pod 不进入 Service endpoints。

应用:

cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: new-api
  namespace: new-api
spec:
  # 副本数改为 3,目标是任意 1 台节点故障时仍有其他副本可服务。
  replicas: 3

  # 保留原来的标签选择器,Service 仍然通过 app=new-api 找到这些 Pod。
  selector:
    matchLabels:
      app: new-api

  # 控制滚动更新节奏,避免升级时一次性停掉太多副本。
  strategy:
    type: RollingUpdate
    rollingUpdate:
      # 更新过程中最多额外创建 1 个 Pod。
      maxSurge: 1
      # 更新过程中至少保留原有可用副本,不主动减少可用容量。
      maxUnavailable: 0

  template:
    metadata:
      labels:
        app: new-api
    spec:
      # 尽量把 new-api 副本分散到不同节点。
      # 当 3 个节点都可调度时,3 个副本会尽量落到 3 台机器上。
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: new-api

      # 软性反亲和:尽量不要把多个 new-api 副本放在同一个节点。
      # preferred 是软约束,资源紧张时仍允许调度,避免 Pod 一直 Pending。
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                topologyKey: kubernetes.io/hostname
                labelSelector:
                  matchLabels:
                    app: new-api

      containers:
        - name: new-api
          image: calciumion/new-api:latest
          imagePullPolicy: IfNotPresent

          # 保留原来的启动参数,把日志写到 /app/logs。
          args:
            - "--log-dir"
            - "/app/logs"

          ports:
            - name: http
              containerPort: 3000

          # 继续从 ConfigMap 读取普通配置,例如 TZ、超时时间等。
          envFrom:
            - configMapRef:
                name: new-api-config
            # 继续从 Secret 读取敏感配置,例如 SQL_DSN、REDIS_CONN_STRING。
            - secretRef:
                name: new-api-secret

          env:
            # 把 Pod 名注入给应用,方便日志和排查区分副本。
            - name: NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name

          volumeMounts:
            # 日志目录使用 emptyDir。Pod 重建后日志会丢失,
            # 后续应通过 Loki/Promtail 或其他日志系统集中采集。
            - name: logs
              mountPath: /app/logs

          # startupProbe 只在容器启动阶段生效。
          # 节点重启后 PostgreSQL/Redis 可能比 new-api 慢,这里给应用更长启动窗口。
          startupProbe:
            httpGet:
              path: /api/status
              port: 3000
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 36

          # readinessProbe 决定 Pod 是否加入 Service endpoints。
          # 如果 /api/status 失败,Ingress/Service 不会把新流量打到这个副本。
          readinessProbe:
            httpGet:
              path: /api/status
              port: 3000
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 6

          # livenessProbe 用来处理应用运行中卡死的情况。
          # 有 startupProbe 后,liveness 会等 startupProbe 成功后才开始。
          livenessProbe:
            httpGet:
              path: /api/status
              port: 3000
            periodSeconds: 30
            timeoutSeconds: 5
            failureThreshold: 3

          # 先给一个保守的资源请求,帮助调度器做容量判断。
          # 后续根据压测和监控再调整。
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 1000m
              memory: 512Mi

      volumes:
        # emptyDir 的生命周期跟随 Pod。
        # 这里只用于应用日志,不承载业务状态。
        - name: logs
          emptyDir: {}
EOF

如果确认 new-api 必须使用 /data 持久目录,就不要直接使用上面的无状态版本。应先把 new-api-data 改成 RWX 存储,再保留下面的挂载:

# 只有在 new-api-data 支持 ReadWriteMany 时,才建议多副本共享挂载。
volumeMounts:
  - name: data
    mountPath: /data
volumes:
  - name: data
    persistentVolumeClaim:
      claimName: new-api-data

确认 Service

Service 不需要大改。它根据 app: new-api 自动把流量负载到所有 Ready 副本:

cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Service
metadata:
  name: new-api
  namespace: new-api
spec:
  # ClusterIP 只在集群内部暴露,外部访问仍走 Ingress。
  type: ClusterIP

  # 选择所有带 app=new-api 标签的 Pod。
  selector:
    app: new-api

  ports:
    - name: http
      # Service 端口。
      port: 3000
      # Pod 容器端口。
      targetPort: 3000
EOF

更严格地分散到三台节点

如果只使用下面这种配置:

topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: kubernetes.io/hostname
    whenUnsatisfiable: ScheduleAnyway
affinity:
  podAntiAffinity:
    preferredDuringSchedulingIgnoredDuringExecution: []

它表达的是“尽量分散”,不是“必须分散”。所以 Kubernetes 可能接受这种结果:

k8s-master1  2 个 new-api Pod
k8s-master2  0 个 new-api Pod
k8s-master3  1 个 new-api Pod

如果想让 3 个 new-api 副本尽量变成:

k8s-master1  1 个
k8s-master2  1 个
k8s-master3  1 个

可以把 podAntiAffinity 改成硬约束:同一个节点上不允许调度两个 app=new-api Pod。

应用下面的 Deployment。重点是 requiredDuringSchedulingIgnoredDuringExecution

cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: new-api
  namespace: new-api
spec:
  # 3 个副本,对应 3 台 master 节点。
  replicas: 3

  selector:
    matchLabels:
      app: new-api

  strategy:
    type: RollingUpdate
    rollingUpdate:
      # 允许滚动更新时多创建 1 个 Pod。
      maxSurge: 1
      # 更新过程中不主动减少可用副本。
      maxUnavailable: 0

  template:
    metadata:
      labels:
        app: new-api
    spec:
      affinity:
        podAntiAffinity:
          # 硬反亲和:同一台节点上不能同时存在两个 app=new-api Pod。
          # topologyKey 使用 kubernetes.io/hostname,表示按节点维度分散。
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchLabels:
                  app: new-api
              topologyKey: kubernetes.io/hostname

      # 拓扑分散也改成硬约束。
      # maxSkew=1 表示各节点之间的副本数量差距最多为 1。
      # DoNotSchedule 表示如果无法满足分散要求,就不要把 Pod 调度上去。
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: new-api

      containers:
        - name: new-api
          image: calciumion/new-api:latest
          imagePullPolicy: IfNotPresent

          args:
            - "--log-dir"
            - "/app/logs"

          ports:
            - name: http
              containerPort: 3000

          envFrom:
            - configMapRef:
                name: new-api-config
            - secretRef:
                name: new-api-secret

          env:
            - name: NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name

          volumeMounts:
            - name: logs
              mountPath: /app/logs

          startupProbe:
            httpGet:
              path: /api/status
              port: 3000
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 36

          readinessProbe:
            httpGet:
              path: /api/status
              port: 3000
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 6

          livenessProbe:
            httpGet:
              path: /api/status
              port: 3000
            periodSeconds: 30
            timeoutSeconds: 5
            failureThreshold: 3

          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 1000m
              memory: 512Mi

      volumes:
        - name: logs
          emptyDir: {}
EOF

注意硬反亲和的代价:

1. 如果只有 2 台节点可调度,而 replicas=3,第 3 个 Pod 会 Pending。
2. 如果某台节点资源不足,Pod 也可能 Pending。
3. 已经运行的 Pod 不会因为规则变化自动搬家,需要触发重建。

因此这个策略适合当前这种 3 节点、3 副本的场景。任意 1 台节点挂掉时,剩下 2 个副本仍然可用;但 Kubernetes 不会在剩余 2 台节点上补出第 3 个副本,因为硬反亲和不允许同节点放两个 new-api。

应用后检查 rollout:

kubectl -n new-api rollout status deploy/new-api --timeout=300s
kubectl -n new-api get pods -o wide

如果发现还是 2/1/0,说明旧 Pod 没有被重新调度。可以重启 Deployment 触发重新创建:

kubectl -n new-api rollout restart deploy/new-api
kubectl -n new-api rollout status deploy/new-api --timeout=300s
kubectl -n new-api get pods -o wide

期望结果:

new-api-xxx   1/1   Running   k8s-master1
new-api-yyy   1/1   Running   k8s-master2
new-api-zzz   1/1   Running   k8s-master3

如果有 Pod Pending,查看原因:

kubectl -n new-api describe pod <pending-pod-name>
kubectl describe node k8s-master1
kubectl describe node k8s-master2
kubectl describe node k8s-master3

常见原因:

didn't match pod anti-affinity rules
Insufficient cpu
Insufficient memory
node(s) had untolerated taint

确认 Ingress

Ingress 也不需要因为多副本而变化。Ingress 访问 Service,Service 再转发到多个 new-api Pod:

cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: new-api
  namespace: new-api
  annotations:
    # 使用已经创建好的 Let's Encrypt + 阿里云 DNS ClusterIssuer。
    cert-manager.io/cluster-issuer: letsencrypt-alidns-prod
spec:
  # 使用 ingress-nginx。
  ingressClassName: nginx

  # HTTPS 证书 Secret,由 cert-manager 自动创建和续期。
  tls:
    - hosts:
        - k8s-ai.jihw.top
      secretName: k8s-ai-jihw-top-tls

  rules:
    - host: k8s-ai.jihw.top
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                # 后端仍然指向 new-api Service。
                name: new-api
                port:
                  number: 3000
EOF

增加 PodDisruptionBudget

PDB 可以避免手动维护、驱逐、升级时一次性赶走太多 new-api 副本。它不能阻止机器突然宕机,但能减少人为操作导致的中断。

cat <<'EOF' | kubectl apply -f -
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: new-api
  namespace: new-api
spec:
  # 至少保持 2 个 new-api 副本可用。
  # 如果 replicas 暂时只设置 2,这里可以改成 minAvailable: 1。
  minAvailable: 2

  # 只约束 app=new-api 的 Pod。
  selector:
    matchLabels:
      app: new-api
EOF

检查发布结果

kubectl -n new-api rollout status deploy/new-api --timeout=300s
kubectl -n new-api get pods -o wide
kubectl -n new-api get endpoints new-api
kubectl -n new-api get endpointslice -l kubernetes.io/service-name=new-api

期望看到 3 个 new-api Pod 分散在不同节点:

new-api-xxx   1/1   Running   k8s-master1
new-api-yyy   1/1   Running   k8s-master2
new-api-zzz   1/1   Running   k8s-master3

测试集群内访问:

kubectl -n new-api run curl-test \
  --rm -it \
  --restart=Never \
  --image=curlimages/curl -- \
  curl -sS http://new-api.new-api.svc.cluster.local:3000/api/status

测试外部域名:

curl -I https://k8s-ai.jihw.top

故障演练

确认当前 new-api 副本分布:

kubectl -n new-api get pods -o wide

如果要模拟 k8s-master2 下线,可以先用非破坏性的方式把节点 cordon:

kubectl cordon k8s-master2

然后删除该节点上的 new-api Pod,让 Deployment 在其他节点补副本:

kubectl -n new-api delete pod -l app=new-api --field-selector spec.nodeName=k8s-master2

观察:

kubectl -n new-api get pods -o wide -w
kubectl -n new-api rollout status deploy/new-api --timeout=300s
curl -I https://k8s-ai.jihw.top

演练结束后恢复调度:

kubectl uncordon k8s-master2

真正关机测试前,先确认:

kubectl -n longhorn-system get volumes.longhorn.io -o wide
kubectl -n new-api get pods -o wide
kubectl get nodes -o wide

如果 PostgreSQL 和 Redis 仍然是单实例,并且刚好在要关机的节点上,那么 new-api 多副本也不能保证业务可用。数据库高可用要放到下一阶段继续做。

数据库高可用

当前数据库和缓存仍然是单点:

postgresql-0      k8s-master2 / 192.168.3.215
redis-master-0    k8s-master2 / 192.168.3.215

即使 new-api 已经有 3 个副本,只要 192.168.3.215 宕机,PostgreSQL 和 Redis 就会一起不可用,new-api 仍然无法正常服务。

当前 Helm release:

postgresql  bitnami/postgresql  standalone
redis       bitnami/redis       standalone

数据库高可用不要直接在旧实例上硬改。我的迁移原则是:

1. 先备份。
2. 新建一套 HA 数据库/Redis。
3. 验证新实例可用。
4. 暂停 new-api 写入。
5. 做最终数据同步。
6. 切换 new-api Secret。
7. 验证通过后再考虑清理旧实例。

已经做过一次备份,保存在 k8s-master1 / 192.168.3.214

/root/k8s-ha-backup-20260602-153911/newapi-postgresql.dump
/root/k8s-ha-backup-20260602-153911/redis-master.rdb

迁移前备份

正式切换前还要再做一次最终备份。先在 k8s-master1 创建备份目录:

TS=$(date +%Y%m%d-%H%M%S)
DIR=/root/k8s-ha-backup-$TS
mkdir -p "$DIR"
echo "$DIR"

备份 PostgreSQL:

PGPASSWORD_VALUE=$(kubectl -n new-api get secret postgresql \
  -o jsonpath='{.data.password}' | base64 -d)

kubectl -n new-api exec postgresql-0 -- bash -lc \
  "PGPASSWORD='$PGPASSWORD_VALUE' pg_dump -U newapi -d newapi --format=custom --file=/tmp/newapi.dump"

kubectl -n new-api cp postgresql-0:/tmp/newapi.dump "$DIR/newapi-postgresql.dump"
kubectl -n new-api exec postgresql-0 -- rm -f /tmp/newapi.dump

备份 Redis:

REDIS_PASSWORD_VALUE=$(kubectl -n new-api get secret redis \
  -o jsonpath='{.data.redis-password}' | base64 -d)

kubectl -n new-api exec redis-master-0 -- bash -lc \
  "redis-cli -a '$REDIS_PASSWORD_VALUE' --rdb /tmp/redis-master.rdb"

kubectl -n new-api cp redis-master-0:/tmp/redis-master.rdb "$DIR/redis-master.rdb"
kubectl -n new-api exec redis-master-0 -- rm -f /tmp/redis-master.rdb

检查备份文件:

ls -lh "$DIR"

PostgreSQL HA 方案

PostgreSQL 不建议只依赖 Longhorn 卷副本来当数据库 HA。Longhorn 解决的是存储副本,不等于 PostgreSQL 主从复制和主库故障转移。

这里优先选择 CloudNativePG:

CloudNativePG Operator
  -> newapi-postgres Cluster
  -> 3 个 PostgreSQL 实例
  -> 自动主从管理和故障转移
  -> newapi-postgres-rw Service 始终指向当前可写主库

参考:

  • CloudNativePG 安装文档:https://cloudnative-pg.io/documentation/current/installation_upgrade/
  • CloudNativePG Bootstrap 文档:https://cloudnative-pg.io/documentation/current/bootstrap/
  • CloudNativePG Service 管理文档:https://cloudnative-pg.io/documentation/current/service_management/
  • CloudNativePG Backup/Recovery 文档:https://cloudnative-pg.io/documentation/current/backup_recovery/
  • CloudNativePG GitHub Release:https://github.com/cloudnative-pg/cloudnative-pg/releases

安装 Operator

安装 CloudNativePG Operator。版本要以 GitHub Release 页面为准,当前记录使用 1.29.1

kubectl apply --server-side -f \
  https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.29/releases/cnpg-1.29.1.yaml

等待 Operator 正常:

kubectl -n cnpg-system get pods
kubectl -n cnpg-system rollout status deploy/cnpg-controller-manager --timeout=300s
kubectl get crd | grep postgresql.cnpg.io

可选安装 cnpg kubectl 插件,方便后续查看集群状态:

curl -sSfL https://github.com/cloudnative-pg/cloudnative-pg/raw/main/hack/install-cnpg-plugin.sh | sh -s -- -b /usr/local/bin
kubectl cnpg version

如果不想安装插件也没关系,常规 kubectl get clusterkubectl describe cluster 已经够用。

创建业务用户 Secret

为了减少切换变量,先复用当前 Bitnami PostgreSQL 的 newapi 用户密码。这样后面 SQL_DSN 只需要把 host 从旧的:

postgresql.new-api.svc.cluster.local

改成新的:

newapi-postgres-rw.new-api.svc.cluster.local

创建 CloudNativePG 使用的 basic-auth Secret:

PG_APP_PASSWORD=$(kubectl -n new-api get secret postgresql \
  -o jsonpath='{.data.password}' | base64 -d)

kubectl create secret generic newapi-cnpg-app \
  -n new-api \
  --type=kubernetes.io/basic-auth \
  --from-literal=username='newapi' \
  --from-literal=password="$PG_APP_PASSWORD" \
  --dry-run=client -o yaml | kubectl apply -f -

确认 Secret 存在:

kubectl -n new-api get secret newapi-cnpg-app

创建 3 实例集群

创建 3 实例 PostgreSQL 集群:

cat <<'EOF' | kubectl apply -f -
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: newapi-postgres
  namespace: new-api
spec:
  # 3 个 PostgreSQL 实例。任意 1 个实例所在节点故障时,仍有多数实例可用。
  # CloudNativePG 会管理主从复制、主库选举和故障转移。
  instances: 3

  # 使用 PostgreSQL 18,和当前 Bitnami PostgreSQL 18 保持同一大版本,减少迁移兼容性风险。
  # 实际生产中建议固定到明确小版本镜像,而不是长期使用浮动大版本。
  imageName: ghcr.io/cloudnative-pg/postgresql:18

  # 初始化业务库和业务用户。
  # CloudNativePG 会创建 newapi 数据库,并把 owner 设置为 newapi。
  bootstrap:
    initdb:
      database: newapi
      owner: newapi
      secret:
        name: newapi-cnpg-app

  # 每个 PostgreSQL 实例一个独立 PVC。
  # Longhorn 负责每个 PVC 的底层副本,CloudNativePG 负责数据库复制和主从切换。
  storage:
    storageClass: longhorn
    size: 10Gi

  # 启用 Pod 反亲和,让 3 个数据库实例尽量分散到不同节点。
  # podAntiAffinityType=required 表示同一节点不放两个 PostgreSQL 实例。
  affinity:
    enablePodAntiAffinity: true
    topologyKey: kubernetes.io/hostname
    podAntiAffinityType: required

  # 升级或维护时优先保障主库可用。
  primaryUpdateStrategy: unsupervised

  # PostgreSQL 参数先保持保守,后续根据监控和压测再调。
  postgresql:
    parameters:
      max_connections: "200"
      shared_buffers: "256MB"
EOF

检查集群:

kubectl -n new-api get cluster
kubectl -n new-api describe cluster newapi-postgres
kubectl -n new-api get pods -l cnpg.io/cluster=newapi-postgres -o wide
kubectl -n new-api get svc | grep newapi-postgres

期望看到类似服务:

newapi-postgres-rw   当前主库写入口
newapi-postgres-ro   只读副本入口
newapi-postgres-r    所有实例入口

其中 new-api 后续只应该连接 newapi-postgres-rw,因为它始终指向当前可写主库。

等待 3 个实例都 Ready:

kubectl -n new-api wait cluster/newapi-postgres \
  --for=condition=Ready \
  --timeout=600s

kubectl -n new-api get pods -l cnpg.io/cluster=newapi-postgres -o wide

如果有 Pod Pending,重点看是不是硬反亲和导致无法调度:

kubectl -n new-api describe pod <pending-pod-name>

导入旧 PostgreSQL 数据

先用已有备份做一次演练恢复。正式切换前,还要停 new-api 后再做最终备份。

创建临时恢复 Pod:

kubectl -n new-api run pg-restore \
  --image=postgres:18-alpine \
  --restart=Never \
  --command -- sleep 3600

把备份文件拷进去。这里的 $DIR 是备份目录,例如 /root/k8s-ha-backup-20260602-153911

kubectl -n new-api cp "$DIR/newapi-postgresql.dump" pg-restore:/tmp/newapi-postgresql.dump
kubectl -n new-api cp "/root/k8s-ha-backup-20260602-153911/newapi-postgresql.dump" pg-restore:/tmp/newapi-postgresql.dump

恢复到 CloudNativePG 的写入口:

CNPG_PASSWORD=$(kubectl -n new-api get secret newapi-cnpg-app \
  -o jsonpath='{.data.password}' | base64 -d)

kubectl -n new-api exec pg-restore -- sh -lc \
  "PGPASSWORD='$CNPG_PASSWORD' pg_restore \
    -h newapi-postgres-rw.new-api.svc.cluster.local \
    -p 5432 \
    -U newapi \
    -d newapi \
    --clean --if-exists \
    --no-owner \
    /tmp/newapi-postgresql.dump"

这里加 --no-owner 是为了避免旧实例和新实例对象 owner 细节不一致时恢复失败。当前 owner 都是 newapi,不加通常也可以。

测试连接:

kubectl -n new-api exec pg-restore -- sh -lc \
  "PGPASSWORD='$CNPG_PASSWORD' psql \
    -h newapi-postgres-rw.new-api.svc.cluster.local \
    -U newapi \
    -d newapi \
    -c 'select now();'"

清理临时 Pod:

kubectl -n new-api delete pod pg-restore

正式切换 new-api 到 CloudNativePG

正式切换时要先暂停 new-api,避免旧 PostgreSQL 继续产生新写入:

kubectl -n new-api scale deploy/new-api --replicas=0
kubectl -n new-api rollout status deploy/new-api --timeout=120s || true

暂停后重新做最终备份:

TS=$(date +%Y%m%d-%H%M%S)
DIR=/root/k8s-ha-backup-final-$TS
mkdir -p "$DIR"

PGPASSWORD_VALUE=$(kubectl -n new-api get secret postgresql \
  -o jsonpath='{.data.password}' | base64 -d)

kubectl -n new-api exec postgresql-0 -- bash -lc \
  "PGPASSWORD='$PGPASSWORD_VALUE' pg_dump -U newapi -d newapi --format=custom --file=/tmp/newapi-final.dump"

kubectl -n new-api cp postgresql-0:/tmp/newapi-final.dump "$DIR/newapi-postgresql-final.dump"
kubectl -n new-api exec postgresql-0 -- rm -f /tmp/newapi-final.dump

把最终备份恢复到 CloudNativePG:

kubectl -n new-api run pg-restore-final \
  --image=postgres:18-alpine \
  --restart=Never \
  --command -- sleep 3600

kubectl -n new-api cp "$DIR/newapi-postgresql-final.dump" \
  pg-restore-final:/tmp/newapi-postgresql-final.dump

CNPG_PASSWORD=$(kubectl -n new-api get secret newapi-cnpg-app \
  -o jsonpath='{.data.password}' | base64 -d)

kubectl -n new-api exec pg-restore-final -- sh -lc \
  "PGPASSWORD='$CNPG_PASSWORD' pg_restore \
    -h newapi-postgres-rw.new-api.svc.cluster.local \
    -p 5432 \
    -U newapi \
    -d newapi \
    --clean --if-exists \
    --no-owner \
    /tmp/newapi-postgresql-final.dump"

kubectl -n new-api delete pod pg-restore-final

更新 new-api-secret。为了避免手动重新填写 SESSION_SECRETCRYPTO_SECRET,先从旧 Secret 读出来:

SESSION_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.SESSION_SECRET}' | base64 -d)

CRYPTO_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.CRYPTO_SECRET}' | base64 -d)

REDIS_CONN_STRING_VALUE=$(kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.REDIS_CONN_STRING}' | base64 -d)

kubectl create secret generic new-api-secret \
  -n new-api \
  --from-literal=SESSION_SECRET="$SESSION_SECRET_VALUE" \
  --from-literal=CRYPTO_SECRET="$CRYPTO_SECRET_VALUE" \
  --from-literal=SQL_DSN="postgresql://newapi:$CNPG_PASSWORD@newapi-postgres-rw.new-api.svc.cluster.local:5432/newapi" \
  --from-literal=REDIS_CONN_STRING="$REDIS_CONN_STRING_VALUE" \
  --dry-run=client -o yaml | kubectl apply -f -

恢复 new-api:

kubectl -n new-api scale deploy/new-api --replicas=3
kubectl -n new-api rollout restart deploy/new-api
kubectl -n new-api rollout status deploy/new-api --timeout=300s

验证 new-api 连接的是新 PostgreSQL:

kubectl -n new-api get pods -o wide
kubectl -n new-api logs deploy/new-api --tail=100
kubectl -n new-api exec deploy/new-api -- wget -qO- http://127.0.0.1:3000/api/status
curl -k https://k8s-ai.jihw.top/api/status

确认 Secret 已经切到新 host:

kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.SQL_DSN}' | base64 -d

期望包含:

newapi-postgres-rw.new-api.svc.cluster.local

CloudNativePG 故障演练

查看当前主库:

kubectl -n new-api get pods -l cnpg.io/cluster=newapi-postgres -o wide
kubectl -n new-api get cluster newapi-postgres

如果安装了插件,可以更直观地看:

kubectl cnpg status newapi-postgres -n new-api

删除当前 primary Pod 模拟主库故障:

kubectl -n new-api delete pod <当前primary-pod-name>

观察故障转移:

kubectl -n new-api get pods -l cnpg.io/cluster=newapi-postgres -o wide -w
kubectl -n new-api get cluster newapi-postgres

业务验证:

curl -k https://k8s-ai.jihw.top/api/status

如果 newapi-postgres-rw 正常漂到新的主库,new-api 不需要改配置。

CloudNativePG 回滚

如果切换后发现异常,先把 new-api 停掉:

kubectl -n new-api scale deploy/new-api --replicas=0

new-api-secretSQL_DSN 改回旧 PostgreSQL:

SESSION_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.SESSION_SECRET}' | base64 -d)

CRYPTO_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.CRYPTO_SECRET}' | base64 -d)

REDIS_CONN_STRING_VALUE=$(kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.REDIS_CONN_STRING}' | base64 -d)

OLD_PG_PASSWORD=$(kubectl -n new-api get secret postgresql \
  -o jsonpath='{.data.password}' | base64 -d)

kubectl create secret generic new-api-secret \
  -n new-api \
  --from-literal=SESSION_SECRET="$SESSION_SECRET_VALUE" \
  --from-literal=CRYPTO_SECRET="$CRYPTO_SECRET_VALUE" \
  --from-literal=SQL_DSN="postgresql://newapi:$OLD_PG_PASSWORD@postgresql.new-api.svc.cluster.local:5432/newapi" \
  --from-literal=REDIS_CONN_STRING="$REDIS_CONN_STRING_VALUE" \
  --dry-run=client -o yaml | kubectl apply -f -

kubectl -n new-api scale deploy/new-api --replicas=3
kubectl -n new-api rollout restart deploy/new-api
kubectl -n new-api rollout status deploy/new-api --timeout=300s

清理旧 PostgreSQL

切换稳定运行一段时间后,再考虑清理旧 postgresql Helm release 和旧 PVC。清理前必须确认:

1. new-api 已经连接 newapi-postgres-rw。
2. CloudNativePG 3 个实例正常。
3. 最近一次最终备份文件存在,并且至少做过一次恢复验证。
4. 已经不打算回滚到旧 postgresql release。

先确认 new-api 当前 SQL_DSN

kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.SQL_DSN}' | base64 -d

期望包含:

newapi-postgres-rw.new-api.svc.cluster.local

确认 CloudNativePG 状态:

kubectl -n new-api get cluster newapi-postgres
kubectl -n new-api get pods -l cnpg.io/cluster=newapi-postgres -o wide
kubectl -n new-api get svc | grep newapi-postgres

确认旧 PostgreSQL 当前还在:

helm list -n new-api | grep postgresql
kubectl -n new-api get pods,svc,pvc | grep postgresql

先只卸载旧 Helm release,不手动删 PVC:

helm uninstall postgresql -n new-api

卸载后检查旧 Pod 和 Service 是否消失:

kubectl -n new-api get pods,svc | grep postgresql || true
kubectl -n new-api get pvc | grep postgresql || true

通常 StatefulSet 的 PVC 会保留下来,例如:

data-postgresql-0

建议先保留这个 PVC 一段时间,给它打个标记,避免误删:

kubectl -n new-api label pvc data-postgresql-0 app.kubernetes.io/archived=true --overwrite
kubectl -n new-api annotate pvc data-postgresql-0 \
  archive-note="old bitnami postgresql pvc, keep temporarily after CloudNativePG migration" \
  --overwrite

等确认 CloudNativePG 稳定运行、备份可恢复、并且不再需要旧数据盘后,再删除旧 PVC:

kubectl -n new-api delete pvc data-postgresql-0

删除 PVC 会触发底层 Longhorn volume 回收,是否真正删除取决于 StorageClass 的 reclaimPolicy。删除前先确认:

kubectl get storageclass longhorn -o yaml | grep reclaimPolicy
kubectl -n longhorn-system get volumes.longhorn.io -o wide | grep pvc-25ed515e || true

如果暂时不确定,就不要删 PVC。保留旧 PVC 比误删数据库更安全。

Redis HA 方案

new-api 官方集群部署文档提到,Redis 可以使用 Cluster 或 Sentinel。不过当前实际使用的 calciumion/new-api:latest 镜像会把 REDIS_CONN_STRING 按普通 Redis URL 解析,直接把 Sentinel 地址写进去会启动失败。因此这里后端仍然使用 Redis Sentinel 做故障转移,new-api 前面再加一个 redis-master-proxy,让 new-api 只看到普通 Redis URL。

参考:

  • New API Cluster Deployment:https://docs.newapi.ai/en/docs/installation/deployment-methods/cluster-deployment
  • Bitnami Redis values:https://github.com/bitnami/charts/blob/main/bitnami/redis/values.yaml
  • Redis Sentinel 官方文档:https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/

这里优先使用 Redis Sentinel:

Redis master + replicas + Sentinel
  -> Sentinel 负责发现主库故障并触发主从切换
  -> redis-master-proxy 只转发到当前 master
  -> new-api 使用普通 Redis URL 连接 redis-master-proxy

当前 Redis 是 Bitnami 单实例:

redis-master-0   k8s-master2 / 192.168.3.215
Service          redis-master.new-api.svc.cluster.local:6379
PVC              redis-data-redis-master-0

Redis HA 迁移原则:

1. 不直接覆盖旧 redis release。
2. 新建 redis-ha release。
3. 验证 Sentinel 能找到 master。
4. 再切换 new-api Secret。
5. 旧 Redis 和旧 PVC 先保留,确认稳定后再清理。

迁移前备份 Redis

Redis 对 new-api 来说主要是缓存和性能增强,但切换前仍然建议导出一份 RDB,保留回滚证据。

TS=$(date +%Y%m%d-%H%M%S)
DIR=/root/k8s-ha-backup-redis-$TS
mkdir -p "$DIR"

REDIS_PASSWORD_VALUE=$(kubectl -n new-api get secret redis \
  -o jsonpath='{.data.redis-password}' | base64 -d)

kubectl -n new-api exec redis-master-0 -- bash -lc \
  "redis-cli -a '$REDIS_PASSWORD_VALUE' --rdb /tmp/redis-master.rdb"

kubectl -n new-api cp redis-master-0:/tmp/redis-master.rdb "$DIR/redis-master.rdb"
kubectl -n new-api exec redis-master-0 -- rm -f /tmp/redis-master.rdb

ls -lh "$DIR"

如果只是缓存数据,后续可以不导入到新 Redis,让新 Redis 从空库启动。new-api 的核心业务数据在 PostgreSQL,不应该依赖 Redis 作为主数据源。

准备 Redis HA values

先从旧 Redis Secret 读取密码。这里选择复用旧密码,减少 new-api 切换变量:

REDIS_PASSWORD_VALUE=$(kubectl -n new-api get secret redis \
  -o jsonpath='{.data.redis-password}' | base64 -d)

创建一个带注释的 values 文件。这里不要使用单引号包裹 EOF,让 shell 能把 $REDIS_PASSWORD_VALUE 写入 values 文件:

cat <<EOF > redis-ha-values.yaml
# 使用主从复制架构,而不是 standalone 单实例。
architecture: replication

auth:
  # 开启 Redis 密码认证。
  enabled: true

  # Sentinel 也使用同一个认证密码。
  sentinel: true

  # 复用旧 Redis 密码,变量来自 REDIS_PASSWORD_VALUE。
  password: "$REDIS_PASSWORD_VALUE"

# Redis 主容器镜像。
# 不直接使用 registry-1.docker.io,是为了避免 Docker Hub 匿名拉取限流。
# Kubernetes 使用 containerd 拉镜像,节点上执行 docker login 不一定会被 containerd 使用。
image:
  registry: public.ecr.aws
  repository: bitnami/redis
  tag: latest

master:
  persistence:
    # master 持久化数据,避免 Pod 重建后数据丢失。
    enabled: true
    storageClass: longhorn
    size: 5Gi

  # master 尽量不要和其他 Redis Pod 落在同一个节点。
  podAntiAffinityPreset: hard

replica:
  # Sentinel 模式下,Bitnami chart 会创建 redis-ha-node StatefulSet。
  # replicaCount=3 表示创建 3 个 redis-ha-node Pod。
  # 每个 Pod 里都有 redis 和 sentinel 两个容器,master 会在这些节点中选出。
  replicaCount: 3

  persistence:
    # replica 也持久化,故障切换后可以继续承接角色。
    enabled: true
    storageClass: longhorn
    size: 5Gi

  # replica 之间也尽量分散到不同节点。
  podAntiAffinityPreset: hard

sentinel:
  # 开启 Sentinel。Sentinel 会监控 master,并在故障时触发 failover。
  enabled: true

  # Sentinel 容器镜像,同样切到 public.ecr.aws,避免 Docker Hub 429。
  image:
    registry: public.ecr.aws
    repository: bitnami/redis-sentinel
    tag: latest

  # Sentinel 使用这个 masterSet 名称管理主从切换。
  masterSet: mymaster

  # 3 个 Sentinel 中至少 2 个认为 master 故障,才触发故障转移。
  quorum: 2

  # master 被认为不可达的时间。家庭集群先用默认值即可。
  # downAfterMilliseconds: 60000

  # 故障转移超时时间。先使用默认值即可。
  # failoverTimeout: 180000
EOF

确认 values 文件已生成。不要把包含真实密码的文件提交到 Git:

grep -n "password:" redis-ha-values.yaml

安装 redis-ha

为了降低风险,不直接改旧的 redis release,先安装新的 redis-ha release:

helm upgrade --install redis-ha \
  oci://registry-1.docker.io/bitnamicharts/redis \
  --namespace new-api \
  -f redis-ha-values.yaml

如果遇到 Docker Hub 未认证拉取限制:

toomanyrequests: You have reached your unauthenticated pull rate limit

优先确认 redis-ha-values.yaml 里已经把 Redis 和 Sentinel 镜像切到了 public.ecr.aws。如果仍然要从 Docker Hub 拉 chart,可以再登录 Docker Hub:

helm registry login registry-1.docker.io
helm pull oci://registry-1.docker.io/bitnamicharts/redis

注意:helm registry login 只解决 Helm 拉 chart 的认证;Pod 镜像由 kubelet/containerd 拉取,普通的 docker login 不会自动让 Kubernetes 使用 Docker Hub 凭据。

如果某个节点拉取失败,而其他节点正常,先不要急着判断节点网络不同。很可能是其他节点已经有本地镜像缓存:

crictl images | grep bitnami
kubectl -n new-api describe pod <redis-ha-node-x> | tail -n 80

例如 214、215 上已经存在 registry-1.docker.io/bitnami/redisregistry-1.docker.io/bitnami/redis-sentinel 缓存,所以 Pod 可以直接启动;216 没有对应缓存时,就会继续访问 Docker Hub 或镜像加速器,从而遇到 429 Too Many Requests 或镜像加速器 403 Forbidden

等待 Redis HA 启动:

kubectl -n new-api get statefulset redis-ha-node -o wide
kubectl -n new-api get pods -l app.kubernetes.io/instance=redis-ha -o wide
kubectl -n new-api get svc | grep redis-ha
kubectl -n new-api get pvc | grep redis-ha

期望 Redis HA Pod 分散到不同节点。注意:开启 Sentinel 后,Bitnami chart 创建的是 redis-ha-node,不是 redis-ha-master

redis-ha-node-0   2/2   Running   k8s-master1
redis-ha-node-1   2/2   Running   k8s-master2
redis-ha-node-2   2/2   Running   k8s-master3

2/2 表示这个 Pod 中的 redis 容器和 sentinel 容器都已经 Running。

如果之前按旧配置装成了 2 个节点,可以先确认:

kubectl -n new-api get statefulset redis-ha-node

如果看到:

redis-ha-node   2/2

说明当前只有 2 个 Redis/Sentinel 节点,不适合作为三节点高可用。应把 redis-ha-values.yaml 中的 replica.replicaCount 改为 3,然后执行:

helm upgrade redis-ha \
  oci://registry-1.docker.io/bitnamicharts/redis \
  --namespace new-api \
  -f redis-ha-values.yaml

如果 Helm 拉 chart 被 Docker Hub 限流,临时扩容可以先执行:

kubectl -n new-api scale statefulset redis-ha-node --replicas=3

但这只是临时修正,后续仍然要把 Helm values 改成 replicaCount: 3,否则下次 Helm upgrade 可能又回到 2 个。

验证 Sentinel

查看 Sentinel Service。开启 Sentinel 后,Bitnami Redis 通常会暴露 26379

kubectl -n new-api get svc | grep redis-ha

查看 Sentinel 是否能发现 master:

REDIS_HA_PASSWORD=$(kubectl -n new-api get secret redis-ha \
  -o jsonpath='{.data.redis-password}' | base64 -d)

kubectl -n new-api run redis-ha-test \
  --rm -it \
  --restart=Never \
  --image=redis:7-alpine \
  -- redis-cli \
    -h redis-ha.new-api.svc.cluster.local \
    -p 26379 \
    -a "$REDIS_HA_PASSWORD" \
    SENTINEL get-master-addr-by-name mymaster

如果返回 master 地址和端口,说明 Sentinel 正常。

也可以看更详细的 master 信息:

kubectl -n new-api run redis-ha-test \
  --rm -it \
  --restart=Never \
  --image=redis:7-alpine \
  -- redis-cli \
    -h redis-ha.new-api.svc.cluster.local \
    -p 26379 \
    -a "$REDIS_HA_PASSWORD" \
    SENTINEL master mymaster

测试通过 Sentinel 找到的 master 是否能写入。先取 master 地址:

MASTER_INFO=$(kubectl -n new-api run redis-ha-test \
  --rm -i \
  --restart=Never \
  --image=redis:7-alpine \
  -- redis-cli \
    -h redis-ha.new-api.svc.cluster.local \
    -p 26379 \
    -a "$REDIS_HA_PASSWORD" \
    SENTINEL get-master-addr-by-name mymaster)

echo "$MASTER_INFO"

输出通常是两行,第一行是 master 地址,第二行是端口。

如果只是验证 Redis 密码和连通性,可以随便选一个 redis-ha-node-* Pod。注意:REDIS_HA_PASSWORD 是宿主机 shell 里的变量,进入 Pod 后不会自动存在。Bitnami Redis 容器里的密码挂载在 /opt/bitnami/redis/secrets/redis-password

kubectl -n new-api exec redis-ha-node-0 -c redis -- sh -lc \
  '/opt/bitnami/redis/bin/redis-cli --no-auth-warning \
    -a "$(cat /opt/bitnami/redis/secrets/redis-password)" ping'

期望返回:

PONG

如果要测试写入,必须写到当前 master。不能随便选一个 Redis Pod 写入,因为 replica 是只读的。

REDIS_HA_PASSWORD=$(kubectl -n new-api get secret redis-ha \
  -o jsonpath='{.data.redis-password}' | base64 -d)

MASTER_HOST=$(kubectl -n new-api exec redis-ha-node-0 -c sentinel -- \
  redis-cli --no-auth-warning --raw \
    -p 26379 \
    -a "$REDIS_HA_PASSWORD" \
    SENTINEL get-master-addr-by-name mymaster | sed -n '1p')

echo "$MASTER_HOST"

kubectl -n new-api exec redis-ha-node-0 -c redis -- \
  /opt/bitnami/redis/bin/redis-cli --no-auth-warning \
    -h "$MASTER_HOST" \
    -a "$REDIS_HA_PASSWORD" \
    set newapi:ha:test ok

kubectl -n new-api exec redis-ha-node-0 -c redis -- \
  /opt/bitnami/redis/bin/redis-cli --no-auth-warning \
    -h "$MASTER_HOST" \
    -a "$REDIS_HA_PASSWORD" \
    get newapi:ha:test

期望返回:

OK
ok

给 new-api 准备 Redis master 代理

实际验证时需要注意:calciumion/new-api:latest 当前会把 REDIS_CONN_STRING 当作普通 Redis URL 解析。如果直接配置成:

redis://redis-ha.new-api.svc.cluster.local:26379/0

new-api 会把 Sentinel 端口当成普通 Redis 端口去 PING,容易出现:

Redis ping test failed: NOAUTH Authentication required.

如果改成纯 Sentinel 地址列表,例如:

redis-ha-node-0.redis-ha-headless.new-api.svc.cluster.local:26379,...

new-api 又会报:

failed to parse Redis connection string: redis: invalid URL scheme

所以这里加一个 redis-master-proxy。它用 HAProxy 检查 3 个 Redis 节点的 role:master,只把流量转发给当前 master。new-api 继续使用普通 Redis URL,Redis master 变更时由 HAProxy 自动切换后端。

cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
  name: redis-master-proxy-config
  namespace: new-api
data:
  haproxy.cfg.tpl: |
    global
      log stdout format raw local0

    defaults
      mode tcp
      log global
      timeout connect 5s
      timeout client 1m
      timeout server 1m

    frontend redis_front
      bind *:6379
      default_backend redis_master

    backend redis_master
      mode tcp
      option tcp-check
      # 使用 Redis 密码做健康检查。
      tcp-check connect
      tcp-check send AUTH\ __REDIS_PASSWORD__\r\n
      tcp-check expect string +OK
      # 只把 role:master 的 Redis 节点标记为可用。
      tcp-check send INFO\ replication\r\n
      tcp-check expect string role:master
      tcp-check send QUIT\r\n

      server redis-0 redis-ha-node-0.redis-ha-headless.new-api.svc.cluster.local:6379 check inter 2s fall 2 rise 1
      server redis-1 redis-ha-node-1.redis-ha-headless.new-api.svc.cluster.local:6379 check inter 2s fall 2 rise 1
      server redis-2 redis-ha-node-2.redis-ha-headless.new-api.svc.cluster.local:6379 check inter 2s fall 2 rise 1
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis-master-proxy
  namespace: new-api
spec:
  # 两个代理副本,避免代理自己变成单点。
  replicas: 2
  selector:
    matchLabels:
      app: redis-master-proxy
  template:
    metadata:
      labels:
        app: redis-master-proxy
    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: redis-master-proxy
      containers:
        - name: haproxy
          image: public.ecr.aws/docker/library/haproxy:2.9-alpine
          imagePullPolicy: IfNotPresent
          ports:
            - name: redis
              containerPort: 6379
          command:
            - sh
            - -ec
            - |
              # Redis 密码来自 redis-ha Secret,避免明文写死到 ConfigMap。
              REDIS_PASSWORD="$(cat /opt/redis-secret/redis-password)"
              awk -v pass="$REDIS_PASSWORD" '{gsub(/__REDIS_PASSWORD__/, pass)}1' /config/haproxy.cfg.tpl > /tmp/haproxy.cfg
              exec haproxy -f /tmp/haproxy.cfg
          readinessProbe:
            tcpSocket:
              port: 6379
            periodSeconds: 5
            timeoutSeconds: 2
            failureThreshold: 6
          livenessProbe:
            tcpSocket:
              port: 6379
            periodSeconds: 10
            timeoutSeconds: 2
            failureThreshold: 6
          resources:
            requests:
              cpu: 20m
              memory: 32Mi
            limits:
              cpu: 200m
              memory: 128Mi
          volumeMounts:
            - name: config
              mountPath: /config
            - name: redis-password
              mountPath: /opt/redis-secret
              readOnly: true
      volumes:
        - name: config
          configMap:
            name: redis-master-proxy-config
        - name: redis-password
          secret:
            secretName: redis-ha
---
apiVersion: v1
kind: Service
metadata:
  name: redis-master-proxy
  namespace: new-api
spec:
  type: ClusterIP
  selector:
    app: redis-master-proxy
  ports:
    - name: redis
      port: 6379
      targetPort: 6379
EOF

等待代理启动:

kubectl -n new-api rollout status deploy/redis-master-proxy --timeout=240s
kubectl -n new-api get pods -l app=redis-master-proxy -o wide

这里 redis-master-proxy 设置为 2 个副本是有意的。它只是代理层,不是 Redis 数据节点:

new-api -> redis-master-proxy -> 当前 Redis master

真正的 Redis 高可用由 redis-ha-node StatefulSet 承担,应该是 3 个 Redis/Sentinel 节点:

kubectl -n new-api get statefulset redis-ha-node
kubectl -n new-api get pods -l app.kubernetes.io/instance=redis-ha -o wide

redis-master-proxy 只需要避免自己成为单点,所以 2 个副本已经可以满足:

proxy-1 故障,还有 proxy-2
proxy-2 故障,还有 proxy-1

三节点家庭集群里,如果节点资源充足,也可以改成 3 个副本:

kubectl -n new-api scale deploy redis-master-proxy --replicas=3

但当前集群 CPU 压力偏高,2 个代理副本更合适。优先保障 Redis 数据节点、PostgreSQL、Ingress 和存储组件稳定。

验证代理是否指向当前 master:

REDIS_HA_PASSWORD=$(kubectl -n new-api get secret redis-ha \
  -o jsonpath='{.data.redis-password}' | base64 -d)

kubectl -n new-api exec redis-ha-node-0 -c redis -- \
  /opt/bitnami/redis/bin/redis-cli --no-auth-warning --raw \
    -h redis-master-proxy.new-api.svc.cluster.local \
    -a "$REDIS_HA_PASSWORD" \
    role

kubectl -n new-api exec redis-ha-node-0 -c redis -- \
  /opt/bitnami/redis/bin/redis-cli --no-auth-warning --raw \
    -h redis-master-proxy.new-api.svc.cluster.local \
    -a "$REDIS_HA_PASSWORD" \
    set newapi:proxy:test ok

kubectl -n new-api exec redis-ha-node-0 -c redis -- \
  /opt/bitnami/redis/bin/redis-cli --no-auth-warning --raw \
    -h redis-master-proxy.new-api.svc.cluster.local \
    -a "$REDIS_HA_PASSWORD" \
    get newapi:proxy:test

期望看到 role 第一行是:

master

切换 new-api 到 Redis master 代理

先读取当前 new-api Secret,保留 PostgreSQL 配置不变,只替换 Redis 相关配置:

SESSION_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.SESSION_SECRET}' | base64 -d)

CRYPTO_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.CRYPTO_SECRET}' | base64 -d)

SQL_DSN_VALUE=$(kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.SQL_DSN}' | base64 -d)

REDIS_HA_PASSWORD=$(kubectl -n new-api get secret redis-ha \
  -o jsonpath='{.data.redis-password}' | base64 -d)

更新 new-api-secret

kubectl create secret generic new-api-secret \
  -n new-api \
  --from-literal=SESSION_SECRET="$SESSION_SECRET_VALUE" \
  --from-literal=CRYPTO_SECRET="$CRYPTO_SECRET_VALUE" \
  --from-literal=SQL_DSN="$SQL_DSN_VALUE" \
  --from-literal=REDIS_CONN_STRING="redis://:$REDIS_HA_PASSWORD@redis-master-proxy.new-api.svc.cluster.local:6379/0" \
  --from-literal=REDIS_PASSWORD="$REDIS_HA_PASSWORD" \
  --dry-run=client -o yaml | kubectl apply -f -

这里的含义:

REDIS_CONN_STRING  指向 redis-master-proxy 的普通 Redis URL,并在 URL 中携带密码
REDIS_PASSWORD     保留 Redis 密码,方便后续脚本或兼容配置读取

重启 new-api,让环境变量生效:

kubectl -n new-api rollout restart deploy/new-api
kubectl -n new-api rollout status deploy/new-api --timeout=300s

验证 new-api:

kubectl -n new-api get pods -o wide
kubectl -n new-api logs deploy/new-api --tail=120
curl -k https://k8s-ai.jihw.top/api/status

如果日志里看到 Redis 连接失败,先把 new-api-secret 打出来确认:

kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.REDIS_CONN_STRING}' | base64 -d
echo

期望看到:

redis://:<redis-password>@redis-master-proxy.new-api.svc.cluster.local:6379/0

如果看到 redis-ha.new-api.svc.cluster.local:26379 或纯 Sentinel 地址列表,说明还没有切到代理方式,new-api 可能会因为 Redis 连接串解析失败而无法启动。

Redis Sentinel 故障演练

先查看当前 master:

REDIS_HA_PASSWORD=$(kubectl -n new-api get secret redis-ha \
  -o jsonpath='{.data.redis-password}' | base64 -d)

kubectl -n new-api run redis-ha-test \
  --rm -it \
  --restart=Never \
  --image=redis:7-alpine \
  -- redis-cli \
    -h redis-ha.new-api.svc.cluster.local \
    -p 26379 \
    -a "$REDIS_HA_PASSWORD" \
    SENTINEL get-master-addr-by-name mymaster

再查看 Redis HA Pod 分布:

kubectl -n new-api get pods -l app.kubernetes.io/instance=redis-ha -o wide

根据 Sentinel 返回的 master 地址,找到当前 master Pod。比如返回:

redis-ha-node-1.redis-ha-headless.new-api.svc.cluster.local
6379

当前 master Pod 就是 redis-ha-node-1

删除当前 master Pod,模拟故障。Pod 名以实际输出为准:

kubectl -n new-api delete pod <redis-ha-node-master-pod>

观察是否选出新 master:

kubectl -n new-api get pods -l app.kubernetes.io/instance=redis-ha -o wide -w

重新查询 master:

kubectl -n new-api run redis-ha-test \
  --rm -it \
  --restart=Never \
  --image=redis:7-alpine \
  -- redis-cli \
    -h redis-ha.new-api.svc.cluster.local \
    -p 26379 \
    -a "$REDIS_HA_PASSWORD" \
    SENTINEL get-master-addr-by-name mymaster

最后验证 new-api:

curl -k https://k8s-ai.jihw.top/api/status
kubectl -n new-api logs deploy/new-api --tail=100

Redis HA 回滚

如果切换 Redis HA 后 new-api 异常,可以把 Secret 改回旧 Redis。

SESSION_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.SESSION_SECRET}' | base64 -d)

CRYPTO_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.CRYPTO_SECRET}' | base64 -d)

SQL_DSN_VALUE=$(kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.SQL_DSN}' | base64 -d)

OLD_REDIS_PASSWORD=$(kubectl -n new-api get secret redis \
  -o jsonpath='{.data.redis-password}' | base64 -d)

kubectl create secret generic new-api-secret \
  -n new-api \
  --from-literal=SESSION_SECRET="$SESSION_SECRET_VALUE" \
  --from-literal=CRYPTO_SECRET="$CRYPTO_SECRET_VALUE" \
  --from-literal=SQL_DSN="$SQL_DSN_VALUE" \
  --from-literal=REDIS_CONN_STRING="redis://:$OLD_REDIS_PASSWORD@redis-master.new-api.svc.cluster.local:6379/0" \
  --dry-run=client -o yaml | kubectl apply -f -

kubectl -n new-api rollout restart deploy/new-api
kubectl -n new-api rollout status deploy/new-api --timeout=300s

清理旧 Redis

Redis HA 稳定运行一段时间后,再清理旧 Redis。

注意:不要只删除 redis-master-0 这个 Pod。旧 Redis 是通过 Helm 安装的 StatefulSet 管理的,只删除 Pod 后,StatefulSet 会自动把它重新拉起来。

正确做法是:

1. 先确认 new-api 已经切到 Redis HA。
2. 再卸载旧的 redis Helm release。
3. 旧 PVC 先保留,用于回滚。
4. 稳定运行一段时间后,再删除旧 PVC。

先确认 new-api 已经指向 Redis master 代理:

kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.REDIS_CONN_STRING}' | base64 -d
echo

期望看到:

redis-master-proxy.new-api.svc.cluster.local:6379

也可以确认旧 Redis 和新 Redis HA 当前都在:

kubectl -n new-api get pods,svc | grep redis

如果业务已经稳定使用 redis-master-proxy,先只卸载旧 Helm release,不急着删除 PVC:

helm uninstall redis -n new-api

再次检查 Redis 相关资源:

kubectl -n new-api get pods,svc | grep redis || true
kubectl -n new-api get pvc | grep redis || true

这时旧的 redis-master-0 Pod 和 redis-master Service 应该已经消失,但旧 PVC 通常会保留,例如:

redis-data-redis-master-0

建议先给旧 PVC 打标记,明确它是旧单实例 Redis 的数据卷:

kubectl -n new-api label pvc redis-data-redis-master-0 app.kubernetes.io/archived=true --overwrite
kubectl -n new-api annotate pvc redis-data-redis-master-0 \
  archive-note="old bitnami standalone redis pvc, keep temporarily after redis-ha migration" \
  --overwrite

如果后续发现 Redis HA 异常,可以参考上一节回滚,把 new-api Secret 改回旧 Redis;所以刚卸载 release 后不要马上删除 PVC。

确认稳定运行一段时间、不再需要回滚后,再删除旧 PVC:

kubectl -n new-api delete pvc redis-data-redis-master-0

可选:一次性切换 new-api 到 PostgreSQL 和 Redis HA 后端

如果 PostgreSQL 的 CloudNativePG 和 Redis Sentinel 都已经验证完成,也可以一次性把 new-api 切到两个 HA 后端。

如果只是单独做 Redis HA,优先使用上一节 切换 new-api 到 Redis Sentinel,不要重复执行这里。

一次性切换前先暂停 new-api,避免新旧数据库之间继续产生写入差异:

kubectl -n new-api scale deploy/new-api --replicas=0

再做一次最终 PostgreSQL 备份并恢复到 CloudNativePG。确认完成后,更新 new-api-secret

先读取需要保留或复用的 Secret 值:

SESSION_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.SESSION_SECRET}' | base64 -d)

CRYPTO_SECRET_VALUE=$(kubectl -n new-api get secret new-api-secret \
  -o jsonpath='{.data.CRYPTO_SECRET}' | base64 -d)

CNPG_PASSWORD=$(kubectl -n new-api get secret newapi-cnpg-app \
  -o jsonpath='{.data.password}' | base64 -d)

REDIS_HA_PASSWORD=$(kubectl -n new-api get secret redis-ha \
  -o jsonpath='{.data.redis-password}' | base64 -d)

更新 new-api-secret

kubectl create secret generic new-api-secret \
  -n new-api \
  --from-literal=SESSION_SECRET="$SESSION_SECRET_VALUE" \
  --from-literal=CRYPTO_SECRET="$CRYPTO_SECRET_VALUE" \
  --from-literal=SQL_DSN="postgresql://newapi:$CNPG_PASSWORD@newapi-postgres-rw.new-api.svc.cluster.local:5432/newapi" \
  --from-literal=REDIS_CONN_STRING="redis://:$REDIS_HA_PASSWORD@redis-master-proxy.new-api.svc.cluster.local:6379/0" \
  --from-literal=REDIS_PASSWORD="$REDIS_HA_PASSWORD" \
  --dry-run=client -o yaml | kubectl apply -f -

恢复 new-api 副本:

kubectl -n new-api scale deploy/new-api --replicas=3
kubectl -n new-api rollout restart deploy/new-api
kubectl -n new-api rollout status deploy/new-api --timeout=300s

验证:

kubectl -n new-api get pods -o wide
kubectl -n new-api logs deploy/new-api --tail=100
curl -k https://k8s-ai.jihw.top/api/status

故障演练

PostgreSQL HA 演练:

kubectl -n new-api get pods -l cnpg.io/cluster=newapi-postgres -o wide
kubectl -n new-api get cluster newapi-postgres

找到当前 primary 所在 Pod 后,删除它模拟故障:

kubectl -n new-api delete pod <cnpg-primary-pod>

观察是否自动切换:

kubectl -n new-api get pods -l cnpg.io/cluster=newapi-postgres -o wide -w
kubectl -n new-api exec deploy/new-api -- wget -qO- http://127.0.0.1:3000/api/status

Redis Sentinel 演练:

kubectl -n new-api get pods -l app.kubernetes.io/instance=redis-ha -o wide

# 根据 SENTINEL get-master-addr-by-name mymaster 的返回结果,
# 删除当前 master 所在的 redis-ha-node-* Pod。
kubectl -n new-api delete pod <redis-ha-node-master-pod>

观察 Sentinel 是否选出新 master:

kubectl -n new-api run redis-ha-test \
  --rm -it \
  --restart=Never \
  --image=redis:7-alpine \
  -- redis-cli \
    -h redis-ha.new-api.svc.cluster.local \
    -p 26379 \
    -a "$REDIS_HA_PASSWORD" \
    SENTINEL get-master-addr-by-name mymaster

最后测试业务域名:

curl -k https://k8s-ai.jihw.top/api/status

回滚思路

切换后先保留旧的 Helm release:

postgresql
redis

如果新 HA 后端异常,回滚方式是把 new-api-secret 里的:

SQL_DSN
REDIS_CONN_STRING
REDIS_PASSWORD

改回旧 PostgreSQL 和旧 Redis,然后重启 new-api:

kubectl -n new-api rollout restart deploy/new-api
kubectl -n new-api rollout status deploy/new-api --timeout=300s

确认 HA 后端稳定运行一段时间后,再考虑卸载旧实例:

helm uninstall postgresql -n new-api
helm uninstall redis -n new-api

卸载前必须确认备份可用,并明确是否保留旧 PVC。

平台组件高可用

业务服务恢复后,还要检查平台组件。关闭 k8s-master3 时,headlamp.jihw.topgrafana.jihw.topkubectl top nodes 都出现过不可用,说明运维入口和指标服务也存在单点。

先检查当前分布:

kubectl get pods -A -o wide | grep -Ei "headlamp|grafana|metrics-server"
kubectl get deploy -A | grep -Ei "headlamp|grafana|metrics-server"
kubectl get ingress -A | grep -Ei "headlamp|grafana"
kubectl top nodes

Headlamp 高可用

Headlamp 是无状态 Web UI,可以直接扩成 2 个副本,并分散到不同节点。

cat <<'EOF' | kubectl -n headlamp patch deploy headlamp \
  --type merge \
  --patch-file /dev/stdin
{
  "spec": {
    "replicas": 2,
    "template": {
      "spec": {
        "topologySpreadConstraints": [
          {
            "maxSkew": 1,
            "topologyKey": "kubernetes.io/hostname",
            "whenUnsatisfiable": "ScheduleAnyway",
            "labelSelector": {
              "matchLabels": {
                "app.kubernetes.io/instance": "headlamp",
                "app.kubernetes.io/name": "headlamp"
              }
            }
          }
        ],
        "affinity": {
          "podAntiAffinity": {
            "preferredDuringSchedulingIgnoredDuringExecution": [
              {
                "weight": 100,
                "podAffinityTerm": {
                  "topologyKey": "kubernetes.io/hostname",
                  "labelSelector": {
                    "matchLabels": {
                      "app.kubernetes.io/instance": "headlamp",
                      "app.kubernetes.io/name": "headlamp"
                    }
                  }
                }
              }
            ]
          }
        }
      }
    }
  }
}
EOF

如果某台节点负载过高,Headlamp 探针可能超时。当前 k8s-master1 上 Longhorn manager 不稳定、CPU 压力也比较高,所以我临时把 Headlamp 限制在 k8s-master2k8s-master3

cat <<'EOF' | kubectl -n headlamp patch deploy headlamp \
  --type strategic \
  --patch-file /dev/stdin
{
  "spec": {
    "template": {
      "spec": {
        "affinity": {
          "nodeAffinity": {
            "requiredDuringSchedulingIgnoredDuringExecution": {
              "nodeSelectorTerms": [
                {
                  "matchExpressions": [
                    {
                      "key": "kubernetes.io/hostname",
                      "operator": "In",
                      "values": ["k8s-master2", "k8s-master3"]
                    }
                  ]
                }
              ]
            }
          },
          "podAntiAffinity": {
            "preferredDuringSchedulingIgnoredDuringExecution": [
              {
                "weight": 100,
                "podAffinityTerm": {
                  "topologyKey": "kubernetes.io/hostname",
                  "labelSelector": {
                    "matchLabels": {
                      "app.kubernetes.io/instance": "headlamp",
                      "app.kubernetes.io/name": "headlamp"
                    }
                  }
                }
              }
            ]
          }
        }
      }
    }
  }
}
EOF

验证:

kubectl -n headlamp rollout status deploy/headlamp --timeout=240s
kubectl -n headlamp get pods -o wide
curl -k -I https://headlamp.jihw.top/

metrics-server 高可用

kubectl top nodes 依赖 metrics-server。如果 metrics-server 只有 1 个副本,并且刚好在故障节点上,Metrics API 会短暂不可用:

error: Metrics API not available

metrics-server 可以扩成 2 个副本:

cat <<'EOF' | kubectl -n kube-system patch deploy metrics-server \
  --type merge \
  --patch-file /dev/stdin
{
  "spec": {
    "replicas": 2,
    "template": {
      "spec": {
        "topologySpreadConstraints": [
          {
            "maxSkew": 1,
            "topologyKey": "kubernetes.io/hostname",
            "whenUnsatisfiable": "ScheduleAnyway",
            "labelSelector": {
              "matchLabels": {
                "k8s-app": "metrics-server"
              }
            }
          }
        ],
        "affinity": {
          "podAntiAffinity": {
            "preferredDuringSchedulingIgnoredDuringExecution": [
              {
                "weight": 100,
                "podAffinityTerm": {
                  "topologyKey": "kubernetes.io/hostname",
                  "labelSelector": {
                    "matchLabels": {
                      "k8s-app": "metrics-server"
                    }
                  }
                }
              }
            ]
          }
        }
      }
    }
  }
}
EOF

验证:

kubectl -n kube-system rollout status deploy/metrics-server --timeout=240s
kubectl -n kube-system get pods -l k8s-app=metrics-server -o wide
kubectl get apiservice v1beta1.metrics.k8s.io
kubectl top nodes

如果某个节点显示 <unknown>,看 metrics-server 日志:

kubectl -n kube-system logs deploy/metrics-server --tail=120

常见原因是 metrics-server 暂时抓不到该节点 kubelet:

Failed to scrape node, timeout to access kubelet

稍等一个采集周期后通常会恢复。如果长期不恢复,再检查目标节点的 kubelet 和 10250 端口。

Grafana 恢复和高可用限制

Grafana 当前使用:

SQLite 数据库
RWO PVC: kube-prometheus-stack-grafana
StorageClass: longhorn-single

这类配置不能简单把 Grafana 改成多副本。两个 Grafana Pod 同时挂同一个 RWO 卷,或者同时访问同一个 grafana.db,容易出现:

Multi-Attach error
Readiness probe failed
grafana.db 锁竞争

所以当前先做“单副本快速恢复”,不是“真正 Grafana 多副本 HA”。

这次 Grafana 503 的直接原因是:旧 Pod 占着 RWO PVC,新 Pod 被调度到其他节点后卷无法 attach。恢复时先避免滚动双开,把策略改成 Recreate,再让 Grafana 只启动 1 个副本。

cat <<'EOF' | kubectl -n monitoring patch deploy kube-prometheus-stack-grafana \
  --type merge \
  --patch-file /dev/stdin
{
  "spec": {
    "strategy": {
      "type": "Recreate",
      "rollingUpdate": null
    }
  }
}
EOF

当前 k8s-master1 在 Longhorn 视角不是 Ready,Grafana 卷不能挂到 master1,所以临时把 Grafana 固定到 k8s-master2

cat <<'EOF' | kubectl -n monitoring patch deploy kube-prometheus-stack-grafana \
  --type merge \
  --patch-file /dev/stdin
{
  "spec": {
    "template": {
      "spec": {
        "nodeSelector": {
          "kubernetes.io/hostname": "k8s-master2"
        }
      }
    }
  }
}
EOF

Grafana 启动较慢,默认 liveness 太激进会在插件和 Dashboard 加载完成前杀掉容器。放宽探针:

cat <<'EOF' | kubectl -n monitoring patch deploy kube-prometheus-stack-grafana \
  --type strategic \
  --patch-file /dev/stdin
{
  "spec": {
    "template": {
      "spec": {
        "containers": [
          {
            "name": "grafana",
            "livenessProbe": {
              "httpGet": {
                "path": "/api/health",
                "port": "grafana"
              },
              "initialDelaySeconds": 300,
              "timeoutSeconds": 30,
              "periodSeconds": 10,
              "failureThreshold": 10
            },
            "readinessProbe": {
              "httpGet": {
                "path": "/api/health",
                "port": "grafana"
              },
              "initialDelaySeconds": 30,
              "timeoutSeconds": 10,
              "periodSeconds": 10,
              "failureThreshold": 12
            }
          }
        ]
      }
    }
  }
}
EOF

如果已经出现两个 Grafana Pod 抢同一个卷,先缩到 0,再恢复:

kubectl -n monitoring scale deploy kube-prometheus-stack-grafana --replicas=0
kubectl -n monitoring wait --for=delete pod -l app.kubernetes.io/name=grafana --timeout=240s
kubectl -n monitoring scale deploy kube-prometheus-stack-grafana --replicas=1
kubectl -n monitoring rollout status deploy/kube-prometheus-stack-grafana --timeout=420s

验证:

kubectl -n monitoring get pods -l app.kubernetes.io/name=grafana -o wide
kubectl -n monitoring get endpoints kube-prometheus-stack-grafana -o wide
curl -k -I https://grafana.jihw.top/

期望看到:

HTTP/2 302
location: /login

真正要把 Grafana 做成多副本 HA,建议后续改成:

Grafana replicas >= 2
Grafana database 使用外部 PostgreSQL
Dashboard/数据源通过 ConfigMap 或 GitOps 管理
不再依赖单个 RWO grafana.db PVC

存储高可用

后续记录 Longhorn 的高可用和故障恢复。

这次 215 失联后出现过:

Multi-Attach error
volume robustness unknown
volume faulted
volume degraded

需要继续确认:

  • Longhorn 卷副本数是否至少为 2 或 3。
  • 卷副本是否分散到不同节点。
  • 节点失联后,卷是否能自动 detach/attach。
  • 数据库类 PVC 的恢复策略是否安全。

压测前检查清单

后续压测前先检查底层状态:

kubectl get nodes -o wide
kubectl get pods -A -o wide
kubectl -n longhorn-system get volumes.longhorn.io -o wide
kubectl top nodes
journalctl -k --since "10 min ago" | egrep -i "e1000|vmxnet3|Tx Unit Hang|oom|panic|I/O error"

压测时重点观察:

  • 节点是否 Ready。
  • Keepalived VIP 是否漂移正常。
  • HAProxy 是否把某个后端判 DOWN。
  • Ingress 是否出现 502、503、504。
  • Longhorn 卷是否 degraded 或 faulted。
  • new-api、PostgreSQL、Redis 是否出现重启或迁移。

当前结论

这次事故说明:三主节点可以保证 Kubernetes 控制面在单节点故障时继续工作,但不能自动保证业务、数据库、存储也高可用。

真正的高可用要从底层虚拟化网卡、负载均衡、Kubernetes 调度、业务副本、数据库架构、存储副本和监控告警一起建设。