背景
homelab 的 K8s 集群用 kube-vip 把 VIP 192.168.50.100 绑在某一台 control-plane 节点上,Kong 通过 LoadBalancer Service 占用了这个 VIP 的 443 端口对外提供 HTTPS。
最近想在集群里自建一个 Git 服务(Forgejo),它需要 SSH 端口供 git clone 使用。一个想法是:再建一个 LoadBalancer Service,把 VIP 的 22 端口转发给 Forgejo 的内建 SSH。
但这里有个让人犹豫的问题:
VIP 是绑在某台宿主机上的,而那台宿主机自己的
sshd也监听着 22 端口。那么发往192.168.50.100:22的流量,到底会进 Forgejo,还是会被宿主机的sshd接走?
先做个小实验
在集群上分三步验证,探测 192.168.50.100:22 返回的是什么。
第一步,不创建任何 22 端口的 LoadBalancer Service,直接探测 VIP 的 22 端口:
nc -w 2 192.168.50.100 22
# SSH-2.0-OpenSSH_10.3 ← 落到了宿主机的 sshd
第二步,建一个 LoadBalancer Service,把 VIP 的 22 端口指向一个普通的 HTTP 测试 Pod:
apiVersion: v1
kind: Service
metadata:
name: lb-port22-test
spec:
type: LoadBalancer
loadBalancerIP: 192.168.50.100
selector:
app: nettest
ports:
- port: 22
targetPort: 80
再探测同一个地址:
printf 'GET / HTTP/1.0\r\n\r\n' | nc -w 2 192.168.50.100 22
# HTTP/1.0 200 OK ← 落到了 Service 后端的 Pod,不再是 sshd
第三步,删掉这个 Service,再探测:
nc -w 2 192.168.50.100 22
# SSH-2.0-OpenSSH_10.3 ← 又变回 sshd
结论很清楚:
| 状态 | 192.168.50.100:22 落到哪 |
|---|---|
| 无 22 端口的 LoadBalancer Service | 宿主机 sshd |
| 有 22 端口的 LoadBalancer Service | Service 后端 Pod |
| 删除该 Service 后 | 又回到宿主机 sshd |
也就是说,宿主机自己监听 22 的 sshd 会抢到流量,但只在没有对应 LoadBalancer Service 的时候。
为什么 Service 一存在,sshd 就抢不到了
关键在于 Linux 内核网络栈的处理顺序:kube-proxy 装的 DNAT 规则,比 sshd 这种本机 socket「更早」拿到数据包。
一个目标为 192.168.50.100:22 的包,在持有 VIP 的那台宿主机里大致这样走:
网卡收包
│
▼
PREROUTING (nat 表) ← kube-proxy 的 DNAT 规则在这里
│
├─ 命中 Service (有 LB Service):目标改写为 PodIP:targetPort
│ │
│ ▼ 路由判定:目标不是本机地址
│ FORWARD ──▶ 转发给 Pod ✅ sshd 收不到
│
└─ 未命中 (无 LB Service):目标仍是 VIP:22
│
▼ 路由判定:VIP 是本机地址
INPUT ──▶ 本机 socket ──▶ sshd ✅ sshd 收到
要点:
sshd监听在0.0.0.0:22,它能收到的只有走到 INPUT 链 → 本机 socket 的包。- kube-proxy 的 DNAT 发生在更早的 PREROUTING(nat 表)。一旦目标地址在这一步被改写成
PodIP,后续路由判定就认为「这个包不是发给我本机的」,于是走 FORWARD 转发出去,根本不进 INPUT,sshd自然碰不到。
所以这不是「谁先抢到端口」,而是内核流水线的固定顺序决定的:
PREROUTING(DNAT) ──▶ 路由决策 ──▶ INPUT(本机) / FORWARD(转发)
▲ ▲
kube-proxy 在这里 sshd 在这里
(永远更早) (永远更晚)
只要 22 端口的 LoadBalancer Service 存在,发往 VIP:22 的包就一定在到达 sshd 之前被改道。这跟 Kong 用同一个 VIP 占着 443 是完全一样的机制——443 的包也是在 PREROUTING 就被 DNAT 到 Kong 的 Pod,宿主机上即便有别的程序想监听 443,也收不到这些包。
一个需要记住的边界情况
唯一会让 sshd 抢到 VIP:22 的情况,是 DNAT 规则不存在:Service 还没创建、被删除,或者 kube-proxy 异常。这时 VIP:22 会落回宿主机 sshd。
但要注意区分两条路径:
- 平时 SSH 登录节点用的是节点自己的 IP(例如
192.168.50.38:22),这条路径永远直达该节点的sshd,不受任何 LoadBalancer Service 影响。 - 只有走 VIP
192.168.50.100:22这一条路径,才会被 22 端口的 LoadBalancer Service 接管。
两者互不干扰,这也是为什么「用 VIP 的 22 端口暴露集群内 SSH 服务」在这套架构下是可行且安全的:它不会影响你 SSH 登录宿主机本身。
externalTrafficPolicy:Cluster 还是 Local
确定了 VIP 的 22 端口可以安全地交给 LoadBalancer Service 之后,还有一个 Service 字段需要决策:externalTrafficPolicy。它决定从集群外进来的流量到达节点后,如何被转发到后端 Pod,有两个取值。
| 值 | 转发行为 | 客户端源 IP |
|---|---|---|
Cluster(默认) |
入口节点收到包后,由 kube-proxy 再转发到任意节点上的 Pod | 经过 SNAT,Pod 看到的是节点 IP,真实源 IP 丢失 |
Local |
只转发给本节点上的 Pod;本节点没有 Pod 就丢弃连接 | 保留客户端真实源 IP |
这个取舍在 kube-vip 这种「VIP 固定在某台节点」的场景里尤其要注意。VIP 绑在某台 control-plane 上,而业务 Pod(如上面的 Git 服务)通常调度在 worker 节点:
客户端 ──▶ VIP:22(绑在 control-plane)
│
├─ Cluster:kube-proxy 转发到 worker 上的 Pod ✅ 能通,但源 IP 被 SNAT
│
└─ Local:只找 control-plane 本机的 Pod
本机没有 Pod ──▶ 连接被丢弃 ❌ 不通
也就是说,如果 Pod 和 VIP 不在同一个节点,Local 会直接导致连不上。要用 Local 保留源 IP,就必须把 Pod 用 nodeSelector 钉到持有 VIP 的那台节点,这又牺牲了调度灵活性。
源 IP 丢失有什么实际影响
Cluster 模式下源 IP 被 SNAT 掉,听起来像个缺点,但要看场景:
- 内网私有服务(如自建 Git):审计追责靠的是账号 + SSH key,不是 IP;也没有按 IP 做限流、封禁、白名单的需求。源 IP 是否真实基本没有影响。
- 对公网开放的服务:需要靠源 IP 做风控、限流、
fail2ban封爆破、合规审计时,源 IP 才重要。
而且 Cluster 下所有客户端的源 IP 都被改写成同一个节点 IP,反而会让 fail2ban 这类按 IP 封禁的工具「一封封一片」,内网场景不开更省心。
结论
对内网、VIP 固定在某节点、Pod 在别处调度的场景,优先选 Cluster:保证「Pod 和 VIP 不在同节点也能连通」,代价(源 IP 丢失)在内网无实际影响。只有当确实需要真实源 IP,且愿意把 Pod 钉到 VIP 所在节点时,才考虑 Local。
小结
- VIP 绑在宿主机上,宿主机的
sshd确实也监听 22,但 kube-proxy 的 DNAT 在 PREROUTING 阶段先改写目标地址,本机sshd在 INPUT 阶段才有机会接收,因此 LoadBalancer Service 一旦存在就会「截胡」。 - 同一个 VIP 上不同端口可以分给不同服务(443 给 Kong、22 给 Git 服务),靠端口区分,互不冲突。
- 真正的风险点不是端口冲突,而是 Service / kube-proxy 缺失时
VIP:22会回落到宿主机sshd;而用节点自身 IP 登录始终不受影响。 - VIP 固定在某节点、Pod 在别处时,
externalTrafficPolicy选Cluster才能保证连通;Local虽保留源 IP,但要求 Pod 与 VIP 同节点。内网场景源 IP 无实际用途,Cluster是更省心的默认选择。