k8s下Patroni自动选主Sidecar实践记录
实践记录:K8s Ingress 502排查 → LiteLLM数据库只读 → Patroni自动选主Sidecar
2026-03-13 | 标签:#Kubernetes #PostgreSQL #Patroni #Ingress #LiteLLM
环境
| 角色 | IP | 说明 |
|---|---|---|
| node1 (control-plane) | 10.0.181.190 | K8s Master,Ingress入口 |
| node2 | 10.0.181.191 | Worker |
| node3 | 10.0.181.192 | Worker |
K8s版本:v1.34.1
Ingress Controller:nginx-ingress-controller v1.8.2
数据库:Patroni 4.0.6 + PostgreSQL 17
应用:LiteLLM v1.82.0-stable(AI Router代理)
前端:litellm-front-zhcn 1.81.16(Nginx静态页面)
第一阶段:Ingress 502 Bad Gateway
现象
访问 http://example.com/ 返回:
1 | |
域名解析到 10.0.181.190,也就是 node1。
排查过程
1. 检查 Ingress 规则和后端 Pod
1 | |
Ingress 规则正常:
/v1、/v2→litellm:4000(3副本 Running)/→litellm-front-zhcn:80(1副本 Running)
Endpoints 也都有值,Pod IP 正确,不是 <none>。
排除 Service selector 不匹配的可能。
2. 检查 Ingress Controller
1 | |
1 | |
关键发现:Service 类型是 LoadBalancer,但 External IP 一直 <pending>。
这是裸机(bare-metal)集群,没有云厂商的 LB 控制器(如 MetalLB),LoadBalancer 类型的 Service 永远拿不到 External IP。
知识点:LoadBalancer vs NodePort vs hostNetwork
LoadBalancer:需要云厂商控制器或 MetalLB 分配外部 IP。裸机上会退化为 NodePort,但不绑定 80 端口。NodePort:在每个节点的高位端口(30000-32767)上监听。本例中 HTTP 是 32441。hostNetwork: true:Pod 直接使用宿主机网络命名空间,绑定宿主机的 80/443 端口。最适合裸机环境的 Ingress Controller。
3. 验证 NodePort 可用性
1 | |
通过 NodePort 32441 访问是正常的。问题就是域名指向 80 端口,但 Ingress Controller 没监听 80。
4. 确认 80 端口没被占用
1 | |
宿主机 80 端口空闲,可以使用 hostNetwork。
方案决策
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| A. hostNetwork: true | 直接绑定 80/443,零额外组件 | Pod 只能运行在一个节点(端口独占);需要 nodeSelector 固定节点 | 裸机、单入口节点 |
| B. MetalLB | 标准 LoadBalancer 体验,支持多节点 | 需要额外部署 MetalLB,分配 IP 池 | 裸机、需要多入口 |
| C. NodePort + 外部反代 | 不改 Ingress Controller | 多一层 Nginx/HAProxy 转发 | 已有独立反代时 |
| D. NodePort 固定 80 端口 | 简单 | 需要改 kube-apiserver 的--service-node-port-range(默认 30000-32767 不含 80) |
不推荐 |
最终决定:方案 A — hostNetwork: true
原因:单入口节点、环境简单、零额外依赖、域名已指向 node1。
修复操作
第一步:启用 hostNetwork
1 | |
知识点:hostNetwork 与 dnsPolicy 的关系
启用
hostNetwork: true后,Pod 使用宿主机的/etc/resolv.conf,默认dnsPolicy: ClusterFirst会失效(因为它依赖 Pod 自己的网络命名空间来路由到 CoreDNS)。必须改为ClusterFirstWithHostNet,否则 Pod 内无法解析 K8s Service DNS(如litellm.default.svc.cluster.local)。
第二步:发现 Pod 调度到了错误节点
1 | |
Pod 跑到了 node2,但域名指向 node1(10.0.181.190)。
原因:**node1 是 control-plane 节点,默认有 taint node-role.kubernetes.io/control-plane:NoSchedule**,普通 Pod 不会调度上去。
知识点:Control-Plane Taint
K8s 1.24+ 的 control-plane 节点默认带 taint:
1node-role.kubernetes.io/control-plane:NoSchedule只有声明了对应
toleration的 Pod 才能调度到 control-plane 节点。查看 taint:
1kubectl describe node node1 | grep -A5 Taints
第三步:固定调度到 node1
1 | |
知识点:nodeSelector vs nodeAffinity
nodeSelector:简单的标签匹配,Pod 只调度到满足条件的节点。nodeAffinity:更灵活的调度规则,支持In、NotIn、Exists等操作符,还支持软约束(preferredDuringScheduling)。这里用
nodeSelector就够了,因为只需要精确匹配一个节点。
第四步:等待 rollout 并验证
1 | |
持久化 YAML
kubectl patch 的修改不会保存到 yaml 文件,下次 kubectl apply 原始 yaml 会覆盖掉。必须更新 yaml 文件持久化。
最终文件 ingress-nginx-controller.yaml 关键片段:
1 | |
第二阶段:LiteLLM 登录 Invalid Credentials
现象
页面正常加载了 LiteLLM Dashboard,但登录时报错:
1 | |
排查过程
1. 理解 LiteLLM 的前后端架构
本次部署的 LiteLLM 是前后端分离的:
- 前端(
litellm-front-zhcn):Nginx 提供静态页面,路径/和/ui/ - 后端(
litellm):Python proxy server,监听 4000 端口,处理/v1/、/v2/API
登录流程:前端页面 → POST /v2/login → 由 Ingress 路由到后端 → 后端读取 UI_USERNAME/UI_PASSWORD 环境变量校验
2. 第一次错误:把 env 加到了前端
1 | |
Nginx 不会读这些环境变量,登录请求是发给后端的。
3. 正确做法:加到后端 Deployment
1 | |
验证:
1 | |
教训:前后端分离架构中,认证相关的环境变量必须配在处理认证逻辑的后端服务上。
第三阶段:数据库只读事务错误
现象
登录请求 POST /v2/login 返回 400:
1 | |
排查过程
1. 分析错误
read-only transaction → 连接到了 PostgreSQL 的 只读副本(replica)。
2. 查看 LiteLLM 的 DATABASE_URL
1 | |
指向 postgres Service。
3. 检查 postgres Service 的 Endpoints
1 | |
3 个 Endpoints —— 对应 3 个 Patroni Pod。
1 | |
Selector 是 app=patroni,匹配了所有 Patroni Pod(1 master + 2 replica)。
知识点:ClusterIP Service 的负载均衡
ClusterIP Service 通过 kube-proxy(iptables/IPVS)做 L4 负载均衡,请求会被随机/轮询分发到所有 Endpoints。如果 Endpoints 包含只读 replica,写请求有 2/3 概率落到 replica 上,导致
read-only transaction。
4. 确认哪个 Pod 是 Master
1 | |
知识点:pg_is_in_recovery()
f(false)= 主库(可读写)t(true)= 备库(只读,WAL recovery 状态)
5. 临时修复:直连 Master
将 DATABASE_URL 改为 StatefulSet 的 stable DNS 直连 patroni-0:
1 | |
知识点:StatefulSet Pod DNS
StatefulSet 的每个 Pod 有固定的 DNS:
<pod-name>.<headless-service>.<namespace>.svc.cluster.local这依赖于 StatefulSet 的
spec.serviceName字段指定的 headless service(ClusterIP: None)。
1
2
3
4kubectl get statefulset patroni -o jsonpath='{.spec.serviceName}'
# patroni
kubectl get svc patroni
# TYPE: ClusterIP, CLUSTER-IP: None (headless)
验证登录:
1 | |
登录成功。但问题是:如果 Patroni failover,master 变成 patroni-1 或 patroni-2,这个硬编码就失效了。
方案决策
| 方案 | 优点 | 缺点 |
|---|---|---|
| A. 硬编码 patroni-0 DNS | 最简单,秒级修复 | failover 后失效,需手动改 |
| B. Label Watcher + Service Selector | 自动跟随 leader,failover 透明 | 需要额外 Pod 和 RBAC |
C. Patronikubernetes.use_endpoints |
原生支持,Patroni 自己更新 K8s endpoints | 需改 Patroni DCS 从 ZooKeeper 切到 Kubernetes,改动太大 |
| D. PgBouncer/HAProxy 读写分离 | 生产级方案 | 架构更复杂,本场景只需写入路由 |
最终决定:方案 B — Patroni Label Watcher Sidecar
原因:
- 当前 Patroni 用 ZooKeeper 做 DCS(不是 K8s),无法用方案 C
- 不需要引入 PgBouncer(已有但在另一组节点上)
- Watcher 方案轻量、自包含,5 秒检测间隔足够
第四阶段:Patroni 自动选主 Sidecar
架构设计
1 | |
实现细节
Patroni REST API
Patroni 每个节点暴露 8008 端口的 REST API:
1 | |
知识点:Patroni REST API 的 HTTP 状态码
GET /→ Leader 返回 200,Replica 返回 503GET /primary→ 只有 Leader 返回 200GET /replica→ 只有 Replica 返回 200响应体的 JSON 始终包含
"role"字段,无论 HTTP 状态码如何。
镜像选择的踩坑过程
| 尝试 | 镜像 | 结果 |
|---|---|---|
| 1 | bitnami/kubectl:latest |
拉取超时,DockerHub 在内网太慢 |
| 2 | nginx:alpine(已有 swr 镜像源) |
✅ 镜像已在节点上,秒级启动 |
nginx:alpine 自带 curl 和 wget。但遇到了一个问题:
Alpine 的 wget 在 HTTP 503 时不输出响应体,而 Patroni replica 的 GET / 返回的就是 503。用 wget -qO- 拿到的是空字符串,导致判断为 “unreachable”。
解决:**改用 curl -s**,它不管 HTTP 状态码,始终输出响应体。
踩坑总结:BusyBox/Alpine wget vs curl
1
2
3
4
5
6
7# wget:503 时不输出 body
wget -qO- http://patroni-replica:8008/
# (空)
# curl:始终输出 body
curl -s http://patroni-replica:8008/
# {"role":"replica", ...}
不用 kubectl 二进制,直接调 K8s API
最初设计用 bitnami/kubectl 镜像执行 kubectl label pod 命令。镜像拉不下来后,改为直接调用 K8s REST API。
Pod 内可以通过 ServiceAccount 的 token 和 CA 证书访问 API Server:
1 | |
知识点:Pod 内访问 K8s API
每个 Pod 自动挂载 ServiceAccount 的凭证:
文件路径 用途 /var/run/secrets/kubernetes.io/serviceaccount/tokenBearer Token(JWT) /var/run/secrets/kubernetes.io/serviceaccount/ca.crtAPI Server 的 CA 证书 /var/run/secrets/kubernetes.io/serviceaccount/namespace当前 namespace API Server 地址固定为
https://kubernetes.default.svc:443。但默认的
defaultServiceAccount 没有任何权限(RBAC),需要创建专用 SA + Role + RoleBinding。
RBAC 配置
1 | |
知识点:Role vs ClusterRole
Role+RoleBinding:限定在单个 namespace 内ClusterRole+ClusterRoleBinding:集群级别最小权限原则:watcher 只需要操作 default namespace 的 pods,用 Role 就够了。
verbs 解释:
get:查询单个 Pod 的当前标签list:可选,用于列出所有 Podpatch:修改 Pod 的 metadata.labels
修改 postgres Service Selector
1 | |
这样 Service 只路由到带 role=master 标签的 Pod,即当前 Patroni leader。
验证:
1 | |
DATABASE_URL 回退
既然 postgres Service 现在只指向 master,litellm 的 DATABASE_URL 可以改回 Service DNS:
1 | |
不再需要硬编码 patroni-0。
Watcher 核心脚本逻辑
1 | |
Failover 场景下的行为:
1 | |
最终文件清单
/opt/yaml/litellm-yaml/ 目录下:
| 文件 | 内容 |
|---|---|
ingress-nginx-controller.yaml |
Ingress Controller Deployment(hostNetwork + nodeSelector + toleration) |
litellm-ingress.yaml |
Ingress 路由规则 |
litellm-config.yaml |
LiteLLM ConfigMap(master_key、redis) |
litellm-deployment.yaml |
LiteLLM 后端(3副本,含 UI 凭据 + DATABASE_URL) |
litellm-front-zhcn.yaml |
前端 Nginx + ConfigMap + Service |
litellm-svc.yaml |
LiteLLM 后端 Service |
postgres-master-svc.yaml |
postgres Service(selector 含 role=master) |
patroni-label-watcher.yaml |
SA + RBAC + Watcher Deployment |
涉及的 K8s 知识点汇总
| 知识点 | 出现场景 | 关键理解 |
|---|---|---|
| Service 类型(ClusterIP/NodePort/LoadBalancer) | Ingress 502 | 裸机无 LB 控制器时 LoadBalancer 永远 pending |
| hostNetwork | Ingress 修复 | Pod 直接使用宿主机网络栈,绑定 80/443 |
| dnsPolicy | hostNetwork 联动 | hostNetwork 下必须改为 ClusterFirstWithHostNet |
| Control-Plane Taint | Pod 调度失败 | 需要 toleration 才能调度到 master 节点 |
| nodeSelector | 固定节点 | 简单标签匹配调度 |
| StatefulSet stable DNS | 临时修复数据库 | <pod>.<headless-svc>.<ns>.svc.cluster.local |
| Headless Service | StatefulSet DNS | ClusterIP: None,不做负载均衡 |
| Service Endpoints | 数据库只读排查 | 所有匹配 selector 的 Pod 都进 endpoints |
| Service Selector | 自动选主 | 多标签 AND 匹配,增加 role=master |
| RBAC (SA/Role/RoleBinding) | Watcher 权限 | 最小权限:只需 pods 的 get/list/patch |
| Pod 内访问 K8s API | Watcher 实现 | token + ca.crt 自动挂载 |
| K8s API PATCH | Label 更新 | merge-patch+json 类型的 PATCH |
| pg_is_in_recovery() | 主备判断 | f=主库 t=备库 |
| Patroni REST API | Leader 检测 | 200=leader 503=replica,body 始终有 role |
| kubectl patch vs apply | 配置持久化 | patch 是临时的,apply 才是声明式 |
排查思路复盘
整个排查从一个 502 开始,实际穿越了 网络层 → 应用层 → 数据库层 三个层面:
1 | |
每一层的问题都被上一层的修复暴露出来。这也是排障的典型模式:修好一层,才能看到下一层的错误。
关键决策节点:
- Ingress 502:选 hostNetwork 而非 MetalLB → 因为环境简单、单入口
- 登录失败:第一次加错位置(前端) → 需要理解前后端分离架构中请求的实际流向
- 数据库只读:先临时硬编码 master DNS 快速恢复业务 → 再设计 Watcher 长期方案
- 镜像拉不下来:从
bitnami/kubectl切到已有的nginx:alpine+ 直接调 K8s API → 内网环境要优先用本地已有镜像 - wget 503 不输出 body:切到 curl → 工具选型要考虑边界行为
最深刻的一课:K8s Service 的 selector 匹配是”隐式”的——你不看 endpoints 就不知道流量去了哪里。对于有状态服务(主从数据库),Service selector 必须精确到只匹配写入节点。