这篇文章用来长期记录我的 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 尽量无状态化,不再挂载
/dataPVC,业务状态放 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 cluster、kubectl 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_SECRET 和 CRYPTO_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-secret 的 SQL_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/redis 和 registry-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.top、grafana.jihw.top 和 kubectl 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-master2 和 k8s-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 调度、业务副本、数据库架构、存储副本和监控告警一起建设。