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
2
HTTP/1.1 502 Bad Gateway
Warning: 199 "martian" "dial tcp 10.0.181.190:80: i/o timeout"

域名解析到 10.0.181.190,也就是 node1。

排查过程

1. 检查 Ingress 规则和后端 Pod

1
2
kubectl get ingress -A
kubectl describe ingress litellm -n default

Ingress 规则正常:

  • /v1/v2litellm:4000(3副本 Running)
  • /litellm-front-zhcn:80(1副本 Running)

Endpoints 也都有值,Pod IP 正确,不是 <none>

排除 Service selector 不匹配的可能。

2. 检查 Ingress Controller

1
kubectl get svc -n ingress-nginx
1
ingress-nginx-controller  LoadBalancer  10.107.84.80  <pending>  80:32441/TCP,443:30836/TCP

关键发现: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
2
curl -s -o /dev/null -w '%{http_code}' http://10.0.181.190:32441 -H 'Host: example.com'
# 200

通过 NodePort 32441 访问是正常的。问题就是域名指向 80 端口,但 Ingress Controller 没监听 80。

4. 确认 80 端口没被占用

1
2
ss -tlnp | grep ':80 '
# (空)

宿主机 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
2
3
4
5
kubectl patch deployment ingress-nginx-controller -n ingress-nginx \
--type='json' -p='[
{"op":"add","path":"/spec/template/spec/hostNetwork","value":true},
{"op":"replace","path":"/spec/template/spec/dnsPolicy","value":"ClusterFirstWithHostNet"}
]'

知识点:hostNetwork 与 dnsPolicy 的关系

启用 hostNetwork: true 后,Pod 使用宿主机的 /etc/resolv.conf,默认 dnsPolicy: ClusterFirst 会失效(因为它依赖 Pod 自己的网络命名空间来路由到 CoreDNS)。必须改为 ClusterFirstWithHostNet,否则 Pod 内无法解析 K8s Service DNS(如 litellm.default.svc.cluster.local)。

第二步:发现 Pod 调度到了错误节点

1
2
kubectl get pod -n ingress-nginx -o wide
# ingress-nginx-controller-xxx Running 10.0.181.191 node2

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:

1
node-role.kubernetes.io/control-plane:NoSchedule

只有声明了对应 toleration 的 Pod 才能调度到 control-plane 节点。

查看 taint:

1
kubectl describe node node1 | grep -A5 Taints

第三步:固定调度到 node1

1
2
3
4
5
6
7
kubectl patch deployment ingress-nginx-controller -n ingress-nginx \
--type='json' -p='[
{"op":"replace","path":"/spec/template/spec/nodeSelector","value":{"kubernetes.io/hostname":"node1"}},
{"op":"add","path":"/spec/template/spec/tolerations","value":[
{"key":"node-role.kubernetes.io/control-plane","operator":"Exists","effect":"NoSchedule"}
]}
]'

知识点:nodeSelector vs nodeAffinity

  • nodeSelector:简单的标签匹配,Pod 只调度到满足条件的节点。
  • nodeAffinity:更灵活的调度规则,支持 InNotInExists 等操作符,还支持软约束(preferredDuringScheduling)。

这里用 nodeSelector 就够了,因为只需要精确匹配一个节点。

第四步:等待 rollout 并验证

1
2
3
4
5
6
7
8
9
10
11
12
kubectl rollout status deployment/ingress-nginx-controller -n ingress-nginx --timeout=60s
# 第一次 timeout —— 因为 node1 上没有镜像,需要 pull

# 等待镜像拉取完成后
kubectl get pod -n ingress-nginx -o wide
# Running 10.0.181.190 node1 ✅

ss -tlnp | grep ':80 '
# nginx 进程监听 80 ✅

curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1 -H 'Host: example.com'
# 200 ✅

持久化 YAML

kubectl patch 的修改不会保存到 yaml 文件,下次 kubectl apply 原始 yaml 会覆盖掉。必须更新 yaml 文件持久化。

最终文件 ingress-nginx-controller.yaml 关键片段:

1
2
3
4
5
6
7
8
9
10
11
spec:
template:
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
nodeSelector:
kubernetes.io/hostname: node1
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule

第二阶段:LiteLLM 登录 Invalid Credentials

现象

页面正常加载了 LiteLLM Dashboard,但登录时报错:

1
Invalid credentials used to access UI. Check 'UI_USERNAME', 'UI_PASSWORD' in .env file

排查过程

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
2
3
4
5
6
7
8
# ❌ 错误:加在了 litellm-front-zhcn(nginx 容器)
containers:
- name: litellm-front-zhcn
env:
- name: UI_USERNAME
value: "admin"
- name: UI_PASSWORD
value: "example@2023"

Nginx 不会读这些环境变量,登录请求是发给后端的。

3. 正确做法:加到后端 Deployment

1
2
3
4
5
6
7
8
# ✅ 正确:加在 litellm 后端
containers:
- name: litellm
env:
- name: UI_USERNAME
value: "admin"
- name: UI_PASSWORD
value: "example@2023"

验证:

1
2
3
kubectl exec -n default $(kubectl get pod -l app=litellm -o jsonpath='{.items[0].metadata.name}') -- env | grep UI_
# UI_USERNAME=admin
# UI_PASSWORD=example@2023

教训:前后端分离架构中,认证相关的环境变量必须配在处理认证逻辑的后端服务上。


第三阶段:数据库只读事务错误

现象

登录请求 POST /v2/login 返回 400:

1
2
3
4
5
6
{
"error": {
"message": "cannot execute INSERT in a read-only transaction",
"type": "auth_error"
}
}

排查过程

1. 分析错误

read-only transaction → 连接到了 PostgreSQL 的 只读副本(replica)

2. 查看 LiteLLM 的 DATABASE_URL

1
postgresql://postgres:xxx@postgres.default.svc.cluster.local:5432/litellm

指向 postgres Service。

3. 检查 postgres Service 的 Endpoints

1
2
kubectl get endpoints postgres -n default
# 10.244.1.81:5432, 10.244.2.167:5432, 10.244.2.169:5432

3 个 Endpoints —— 对应 3 个 Patroni Pod。

1
2
kubectl describe svc postgres -n default
# Selector: app=patroni

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
2
3
4
5
6
7
8
for pod in patroni-0 patroni-1 patroni-2; do
echo "=== $pod ==="
kubectl exec -n default $pod -- psql -h 127.0.0.1 -U postgres \
-tAc 'SELECT pg_is_in_recovery();'
done
# patroni-0: f (master)
# patroni-1: t (replica)
# patroni-2: t (replica)

知识点:pg_is_in_recovery()

  • f(false)= 主库(可读写)
  • t(true)= 备库(只读,WAL recovery 状态)

5. 临时修复:直连 Master

将 DATABASE_URL 改为 StatefulSet 的 stable DNS 直连 patroni-0:

1
postgresql://postgres:xxx@patroni-0.patroni.default.svc.cluster.local:5432/litellm

知识点:StatefulSet Pod DNS

StatefulSet 的每个 Pod 有固定的 DNS:<pod-name>.<headless-service>.<namespace>.svc.cluster.local

这依赖于 StatefulSet 的 spec.serviceName 字段指定的 headless service(ClusterIP: None)。

1
2
3
4
kubectl get statefulset patroni -o jsonpath='{.spec.serviceName}'
# patroni
kubectl get svc patroni
# TYPE: ClusterIP, CLUSTER-IP: None (headless)

验证登录:

1
2
3
4
5
curl -s -X POST http://127.0.0.1/v2/login \
-H 'Host: example.com' \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"example@2023"}'
# {"redirect_url":"http://examplet.com/ui/?login=success"}

登录成功。但问题是:如果 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
┌─────────────────┐    每5秒查询     ┌──────────────┐
│ label-watcher │ ──── curl ────→ │ patroni-0 │ :8008 REST API
│ (nginx:alpine) │ │ patroni-1 │ → {"role":"primary"/"replica"}
│ │ │ patroni-2
│ │ └──────────────┘
│ │
│ │ K8s API PATCH ┌──────────────┐
│ │ ─────────────→ │ Pod labels │
│ │ │ role=master │
└─────────────────┘ │ role=replica
└──────┬───────┘


┌────────────────┐
│ postgres svc │
│ selector: │
│ app=patroni │
role=master │
└────────┬───────┘
│ 只路由到 master

┌────────────────┐
│ litellm │
│ DATABASE_URL → │
│ postgres.svc │
└────────────────┘

实现细节

Patroni REST API

Patroni 每个节点暴露 8008 端口的 REST API:

1
2
3
4
5
6
7
# Leader 返回 200
curl http://patroni-0.patroni:8008/
# {"state":"running", "role":"primary", ...}

# Replica 返回 503
curl http://patroni-1.patroni:8008/
# {"state":"running", "role":"replica", ...}

知识点:Patroni REST API 的 HTTP 状态码

  • GET / → Leader 返回 200,Replica 返回 503
  • GET /primary → 只有 Leader 返回 200
  • GET /replica → 只有 Replica 返回 200

响应体的 JSON 始终包含 "role" 字段,无论 HTTP 状态码如何。

镜像选择的踩坑过程

尝试 镜像 结果
1 bitnami/kubectl:latest 拉取超时,DockerHub 在内网太慢
2 nginx:alpine(已有 swr 镜像源) ✅ 镜像已在节点上,秒级启动

nginx:alpine 自带 curlwget。但遇到了一个问题:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# ServiceAccount token 和 CA 证书自动挂载到 Pod 内
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CACERT="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
APISERVER="https://kubernetes.default.svc"

# 读取 Pod 信息
curl -s --cacert $CACERT -H "Authorization: Bearer $TOKEN" \
"${APISERVER}/api/v1/namespaces/default/pods/patroni-0"

# PATCH 标签
curl -s --cacert $CACERT \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/merge-patch+json" \
-X PATCH \
-d '{"metadata":{"labels":{"role":"master"}}}' \
"${APISERVER}/api/v1/namespaces/default/pods/patroni-0"

知识点:Pod 内访问 K8s API

每个 Pod 自动挂载 ServiceAccount 的凭证:

文件路径 用途
/var/run/secrets/kubernetes.io/serviceaccount/token Bearer Token(JWT)
/var/run/secrets/kubernetes.io/serviceaccount/ca.crt API Server 的 CA 证书
/var/run/secrets/kubernetes.io/serviceaccount/namespace 当前 namespace

API Server 地址固定为 https://kubernetes.default.svc:443

但默认的 default ServiceAccount 没有任何权限(RBAC),需要创建专用 SA + Role + RoleBinding。

RBAC 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apiVersion: v1
kind: ServiceAccount
metadata:
name: patroni-label-watcher

---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: patroni-label-watcher
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "patch"] # 只需要读和改标签

---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: patroni-label-watcher
subjects:
- kind: ServiceAccount
name: patroni-label-watcher
roleRef:
kind: Role
name: patroni-label-watcher
apiGroup: rbac.authorization.k8s.io

知识点:Role vs ClusterRole

  • Role + RoleBinding:限定在单个 namespace 内
  • ClusterRole + ClusterRoleBinding:集群级别

最小权限原则:watcher 只需要操作 default namespace 的 pods,用 Role 就够了。

verbs 解释:

  • get:查询单个 Pod 的当前标签
  • list:可选,用于列出所有 Pod
  • patch:修改 Pod 的 metadata.labels

修改 postgres Service Selector

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: patroni
role: master # ← 新增:只匹配 master
ports:
- port: 5432
targetPort: 5432

这样 Service 只路由到带 role=master 标签的 Pod,即当前 Patroni leader。

验证:

1
2
kubectl get endpoints postgres -n default
# 10.244.2.167:5432 (只有 patroni-0,即 master)

DATABASE_URL 回退

既然 postgres Service 现在只指向 master,litellm 的 DATABASE_URL 可以改回 Service DNS:

1
postgresql://postgres:xxx@postgres.default.svc.cluster.local:5432/litellm

不再需要硬编码 patroni-0


Watcher 核心脚本逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
while true; do
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)

for pod in patroni-0 patroni-1 patroni-2; do
# 1. 查询 Patroni REST API(curl 不受 503 影响)
RESP=$(curl -s --connect-timeout 3 --max-time 5 \
"http://${pod}.patroni.default.svc.cluster.local:8008/")

# 2. 解析 role 字段
ROLE=$(echo "$RESP" | grep -o '"role": *"[^"]*"' | ...)
# primary/master → "master"
# replica → "replica"

# 3. 读取 Pod 当前标签
CURRENT=$(curl K8s API GET /pods/${pod} | parse role label)

# 4. 如果变化,PATCH 更新标签
if [ "$CURRENT" != "$LABEL" ]; then
curl K8s API PATCH /pods/${pod} \
-d '{"metadata":{"labels":{"role":"<new>"}}}'
fi
done

sleep 5
done

Failover 场景下的行为:

1
2
3
4
5
6
7
t=0   patroni-0=master, patroni-1=replica, patroni-2=replica
t=10 Patroni failover: patroni-1 变成 primary
t=15 watcher 检测到变化:
- patroni-0: role=master → role=replica (PATCH)
- patroni-1: role=replicarole=master (PATCH)
t=15 postgres Service endpoints 自动切换到 patroni-1
t=15 litellm 的新连接自动路由到新 master

最终文件清单

/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
2
3
4
5
6
7
8
9
502 Bad Gateway
└→ Ingress Controller 没监听 80(网络层)
└→ hostNetwork + nodeSelector + toleration 修复
└→ Invalid Credentials(应用层)
└→ env 加错位置(前端 vs 后端)
└→ read-only transaction(数据库层)
└→ Service selector 匹配了所有 Pod
└→ 临时:硬编码 master DNS
└→ 最终:Label Watcher Sidecar 自动选主

每一层的问题都被上一层的修复暴露出来。这也是排障的典型模式:修好一层,才能看到下一层的错误

关键决策节点:

  1. Ingress 502:选 hostNetwork 而非 MetalLB → 因为环境简单、单入口
  2. 登录失败:第一次加错位置(前端) → 需要理解前后端分离架构中请求的实际流向
  3. 数据库只读:先临时硬编码 master DNS 快速恢复业务 → 再设计 Watcher 长期方案
  4. 镜像拉不下来:从 bitnami/kubectl 切到已有的 nginx:alpine + 直接调 K8s API → 内网环境要优先用本地已有镜像
  5. wget 503 不输出 body:切到 curl → 工具选型要考虑边界行为

最深刻的一课:K8s Service 的 selector 匹配是”隐式”的——你不看 endpoints 就不知道流量去了哪里。对于有状态服务(主从数据库),Service selector 必须精确到只匹配写入节点。


k8s下Patroni自动选主Sidecar实践记录
https://www.fishingrodd.cn/2026/03/13/blog-k8s-litellm-ingress-patroni-sidecar/
作者
FishingRod
发布于
2026年3月13日
更新于
2026年3月13日
许可协议