阶段一 · Kubernetes 核心

Service 与服务发现

前置回顾

第 02 篇:Pod 有自己的 IP,但 Pod 是短暂的--重建后 IP 就变了
第 03 篇:Deployment 管理一组 Pod,但客户端怎么知道这组 Pod 的 IP 分别是什么?更别提 Pod 随时可能被滚动更新替换
本篇的 Service 就是解决"如何用一个稳定入口访问一组随时在变的 Pod"这个问题.

Pod IP 靠不住

先看一个具体场景:前端 Pod 需要访问后端 API(3 个副本),如果让前端直接用 Pod IP 去连,会遇到三个问题.

问题 1:Pod IP 不稳定

Pod 是短命的.滚动更新,扩缩容,节点故障重建--任何一种情况都会导致旧 Pod 被删除,新 Pod 被创建,IP 随之改变:

滚动更新前:
后端 Pod-A IP: 10.244.2.3 ← 前端代码写死了这个地址
后端 Pod-B IP: 10.244.2.4
后端 Pod-C IP: 10.244.3.7

滚动更新后:
后端 Pod-A' IP: 10.244.2.9 ← 新 Pod,新 IP
后端 Pod-B' IP: 10.244.3.2 ← 甚至可能调度到不同 Node
后端 Pod-C' IP: 10.244.1.8

前端还在连 10.244.2.3 → 连接失败,服务中断

问题 2:没有负载均衡

3 个后端副本,前端只知道其中一个 IP → 所有请求打到同一个 Pod → 单点压力,另外两个副本闲着.水平扩展的意义为零.

问题 3:应用不该关心网络拓扑

每个 Node 分到一段 Pod 子网(如 Node-1 分到 10.244.1.0/24,Node-2 分到 10.244.2.0/24).不同 Node 上的 Pod 之间能通信,是因为集群安装时选择的 CNI 插件(如 Calico/Flannel/Cilium)在底层铺设了跨节点路由(VXLAN 隧道,BGP 路由,或 eBPF 转发).

也就是说:Pod IP 在网络层确实是全集群可达的--CNI 已经解决了"通不通"的问题.但应用代码不应该直接用 Pod IP,因为前两个问题(IP 不稳定,无负载均衡)依然存在.网络连通性是基础设施的事,服务发现和流量分发是更上一层的问题.

CNI是什么

CNI(Container Network Interface)是 K8s 的网络插件标准. 你装集群时必须选一个 CNI 实现(Calico / Flannel / Cilium 等), 它负责:

  1. 给每个 Pod 分配 IP
  2. 让不同 Node 上的 Pod 能互相通信 -- 具体手段因插件而异: - Flannel: 用 VXLAN 隧道把包封装后跨节点转发 - Calico: 用 BGP 路由协议让各 Node 知道"10.244.2.x 走 Node-2" - Cilium: 用 eBPF 在内核层做转发

所以技术上, Pod C(10.244.2.3)和 Pod A(10.244.1.5)之间 curl 10.244.1.5 是通的


Service 的核心价值就一句话:给一组 Pod 分配一个稳定的虚拟 IP + DNS 名称,并自动做负载均衡.

Service 的基础模型

一个最基本的 Service:

apiVersion: v1
kind: Service
metadata:
name: backend-svc # 1. Service 名称 → 自动成为 DNS 名
spec:
selector: # 2. 标签选择器 → "哪些 Pod 是我的后端?"
app: backend
ports: # 3. 端口映射
- port: 80 # Service 暴露的端口(客户端连这个)
targetPort: 8080 # Pod 实际监听的端口(流量转到这里)
protocol: TCP

提交后 K8s 做了三件事:

1. 分配 ClusterIP(如 10.96.0.100)
→ 集群内任何 Pod 连 10.96.0.100:80 就能到达后端

2. 在 CoreDNS 注册记录
→ backend-svc.default.svc.cluster.local → 10.96.0.100
→ 同 namespace 内可以直接用 "backend-svc" 当域名

3. kube-proxy 在每个节点写转发规则
→ 10.96.0.100:80 → 负载均衡到所有 app=backend 的 Pod
→ Pod 增减时自动更新规则

selector 必须匹配 Pod 的 labels,不是 Deployment 的

Service 的 selector 直接匹配 Pod 的 metadata.labels.它完全不知道 Deployment 的存在--只要 Pod 带着 app: backend 标签且处于 Ready 状态,就会被纳入这个 Service 的后端池.这意味着你可以用一个 Service 对接来自不同 Deployment 的 Pod(只要标签一致).

port 与 targetPort 的关系

port: 80 是 Service 自己对外暴露的端口(调用方连这个),targetPort: 8080 是流量最终转发到的 Pod 端口.两者可以不同--Service 相当于做了一次端口映射.同一个 Service 的所有后端 Pod 必须在同一端口监听(它们本就是同一 Deployment 的同质副本).

如果一个 Pod 需要暴露多个端口,在 ports 数组中写多条即可:

ports:
- name: http # 多端口时 name 必填
port: 80
targetPort: 8080
- name: grpc
port: 9000
targetPort: 9090

如果担心 Pod 端口未来会变,targetPort 还支持引用 Pod 中定义的端口名称(而非写死数字):

# Pod 定义
containers:
- name: app
ports:
- name: http
containerPort: 8080 # 未来可能改成 3000

# Service 定义
ports:
- port: 80
targetPort: http # 引用名称,Pod 改端口号时 Service 无需变动

四种 Service 类型

Service 通过 spec.type 决定暴露范围.四种类型是逐层递进的包含关系:

┌─────────────┐
│ ClusterIP │
├─────────────┤
│ • 集群内部访问 │
└─────────────┘
┌────────────┐
│ NodePort │
├────────────┤
│ • 节点端口暴露 │
│ • A │
└────────────┘
┌────────────────┐
│ LoadBalancer │
├────────────────┤
│ • 云 LB 外部 IP │
│ • B │
└────────────────┘

ClusterIP - 集群内部访问(默认)

不指定 type 时的默认类型.分配一个仅集群内可达的虚拟 IP.

apiVersion: v1
kind: Service
metadata:
name: redis-svc
spec:
type: ClusterIP # 默认值,可省略
selector:
app: redis
ports:
- port: 6379 # Service 端口
targetPort: 6379 # Pod 端口(相同时可省略 targetPort)
# 集群内的任何 Pod 都可以连
$ kubectl exec frontend-pod -- curl http://redis-svc:6379
# → 经过 kube-proxy 转发到某个 redis Pod

# 从集群外部(你的笔记本)无法直接访问 ClusterIP
$ curl 10.96.0.100:6379
# → 超时,10.96.x.x 网段只在集群内路由
特性 说明
可访问范围 仅集群内部(Pod 之间)
分配 IP 从 Service CIDR 分配虚拟 IP
适用场景 内部微服务通信(数据库,缓存,RPC 服务)

NodePort - 在每个节点开一个固定端口

NodePort 在 ClusterIP 基础上,额外在每个节点 的某个端口上监听.外部流量可以通过 <任意节点IP>:<NodePort> 访问 Service.

apiVersion: v1
kind: Service
metadata:
name: web-svc
spec:
type: NodePort
selector:
app: web
ports:
- port: 80 # ClusterIP 上的端口
targetPort: 8080 # Pod 端口
nodePort: 30080 # 节点端口(范围 30000-32767,不写则随机分配)
流量路径:
外部客户端 → 192.168.1.10:30080(任意节点 IP)

kube-proxy 拦截

负载均衡到 Pod(可能在本节点,也可能跨节点)

注意:即使 Pod 不在 node-1 上,node-1:30080 仍然能转发到正确的 Pod
因为 kube-proxy 在每个节点都写了转发规则
特性 说明
可访问范围 集群外部(通过节点 IP + NodePort)
端口范围 30000-32767(可通过 API Server 参数修改)
适用场景 开发测试环境,没有云 LB 的裸机集群,端口已知的内部工具
局限 端口号不友好(30000+),需要暴露节点 IP,没有自动故障摘除节点

生产环境慎用 NodePort

NodePort 的问题:客户端需要知道节点 IP(节点挂了怎么办?),端口号不是 80/443(用户体验差),且每个 Service 占一个全局端口(资源紧张).
生产通常用 LoadBalancer 或 Ingress,NodePort 更适合开发调试.

LoadBalancer - 自动创建云负载均衡器

LoadBalancer 在 NodePort 基础上,额外请求云平台(AWS/GCP/Azure/腾讯云)创建一个外部负载均衡器,并分配一个公网 IP.

apiVersion: v1
kind: Service
metadata:
name: web-svc
spec:
type: LoadBalancer
selector:
app: web
ports:
- port: 80
targetPort: 8080
$ kubectl get svc web-svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
web-svc LoadBalancer 10.96.0.50 203.0.113.10 80:31234/TCP

# EXTERNAL-IP 是云平台分配的公网 IP
# 用户访问 203.0.113.10:80 → 云 LB → NodePort 31234 → kube-proxy → Pod
流量路径:
互联网用户 → 203.0.113.10:80(云 LB 公网 IP)

云负载均衡器(健康检查 + 流量分发)

Node-1:31234 / Node-2:31234 / Node-3:31234

kube-proxy → Pod
特性 说明
可访问范围 公网(通过云 LB 的外部 IP)
前提条件 集群运行在支持 LoadBalancer 的云平台,或安装了 MetalLB 等实现
适用场景 需要对外暴露的生产服务
局限 每个 Service 独占一个 LB(成本高);裸机集群无原生支持

一个 LB 只服务一个 Service?

是的--LoadBalancer 类型的 Service 每个都会创建一个独立的云 LB 实例.如果你有 20 个微服务,就是 20 个 LB,成本很高.
这正是 Ingress 存在的原因:一个 Ingress Controller(本身是一个 LoadBalancer Service)承载所有七层路由规则,下一篇详解.

ExternalName - DNS 别名

ExternalName 完全不同于前三种--它不代理流量,只做 DNS 层面的 CNAME 重定向.

apiVersion: v1
kind: Service
metadata:
name: external-db
spec:
type: ExternalName
externalName: prod-mysql.us-east-1.rds.amazonaws.com
# 没有 selector -- 它不关联任何 Pod
# 没有 ClusterIP -- 不代理流量
# 集群内解析 external-db → CNAME → prod-mysql.us-east-1.rds.amazonaws.com
$ kubectl exec app-pod -- nslookup external-db
external-db.default.svc.cluster.local → prod-mysql.us-east-1.rds.amazonaws.com

# 应用代码只需要连 "external-db:3306"
# 后续迁移数据库只改 Service 的 externalName,应用无感
特性 说明
流量代理 不代理,纯 DNS CNAME
适用场景 集群内应用访问外部服务(RDS,SaaS API),保留一层抽象方便迁移
局限 不支持端口映射,不经过 kube-proxy,不能用 IP 只能用域名

四种类型对比

类型 ClusterIP 外部 IP 流量路径 典型场景
ClusterIP Pod → ClusterIP → kube-proxy → 后端 Pod 内部微服务
NodePort 节点 IP 外部 → NodeIP:Port → kube-proxy → Pod 开发调试
LoadBalancer 云 LB IP 外部 → 云LB → NodePort → kube-proxy → Pod 生产对外服务
ExternalName DNS CNAME 重定向 对接外部服务

kube-proxy - Service 背后的流量引擎

Service 本身只是一个 API 对象(存在 etcd 里的一段 YAML).真正让流量"从 ClusterIP 到达 Pod"的是 kube-proxy--它运行在每个节点上,watch Service 和 Endpoints 的变化,然后在节点的网络栈中写转发规则.

三种代理模式

模式 实现方式 状态 特点
userspace kube-proxy 自己监听端口做转发 已废弃 流量经过用户态,性能极差
iptables 写 iptables DNAT 规则 默认 纯内核转发,性能好;规则多时更新慢
IPVS 利用 Linux IPVS(IP Virtual Server) 推荐(大规模) 哈希表查找 O(1),支持多种负载均衡算法

iptables 模式详解

iptables 模式是当前默认.理解它的工作方式,就理解了 Service 流量转发的本质.

Pod-A 访问 backend-svc (ClusterIP: 10.96.0.100:80)
后端有 3 个 Pod: 10.244.1.5:8080, 10.244.2.3:8080, 10.244.3.7:8080

┌─────────────────────────────────────────────────────┐
│ Pod-A 发出报文:dst = 10.96.0.100:80 │
│ │
│ → 进入节点的 iptables 链 │
│ │
│ PREROUTING → KUBE-SERVICES 链 │
│ 匹配规则: dst == 10.96.0.100:80 │
│ → 跳转到 KUBE-SVC-XXXX 链 │
│ │
│ KUBE-SVC-XXXX(Service 链 - 负载均衡) │
│ ├── 33% 概率 → KUBE-SEP-AAA(Endpoint 1) │
│ ├── 33% 概率 → KUBE-SEP-BBB(Endpoint 2) │
│ └── 34% 概率 → KUBE-SEP-CCC(Endpoint 3) │
│ │
│ KUBE-SEP-AAA: │
│ DNAT dst 10.96.0.100:80 → 10.244.1.5:8080 │
│ → 报文 dst 变成了真实 Pod IP │
│ │
│ → 走正常的 Pod 网络路由到达目标 Pod │
└─────────────────────────────────────────────────────┘

几个关键事实:

  • ClusterIP 是虚拟的:没有任何网卡绑定这个 IP,它只存在于 iptables 规则中.ping ClusterIP 不通(因为没人回复 ICMP),但 TCP 连接能通(被 DNAT 转走了).
  • 负载均衡是概率性的:iptables 用 -m statistic --mode random --probability 实现,不是轮询(Round Robin).3 个后端的概率分别是 1/3,1/2(剩余的一半),1(最后一个兜底).
  • 规则数量 = O(Service × Endpoint):每个 Service 的每个后端 Pod 对应一条规则.1000 个 Service,每个 10 个 Pod → 10000+ 条 iptables 规则.更新时需要全量刷新 iptables,大规模集群会出现延迟.

IPVS 模式

IPVS(IP Virtual Server)是 Linux 内核的四层负载均衡模块,专为高性能转发设计.

iptables 模式:
规则是链式匹配 → O(n) 查找
更新需要全量刷新 → 规则多时耗时长
只支持随机负载均衡

IPVS 模式:
规则存在哈希表 → O(1) 查找
增量更新 → 只改变动的 Endpoint
支持多种算法:rr(轮询)/ lc(最少连接)/ sh(源地址哈希)等
# 查看 kube-proxy 当前模式
$ kubectl get configmap kube-proxy -n kube-system -o yaml | grep mode
mode: "ipvs"

# IPVS 模式下查看虚拟服务器
$ ipvsadm -Ln
TCP 10.96.0.100:80 rr
-> 10.244.1.5:8080 Masq 1 0 0
-> 10.244.2.3:8080 Masq 1 0 0
-> 10.244.3.7:8080 Masq 1 0 0

什么时候需要从 iptables 切换到 IPVS?

经验法则:Service 数量超过 1000,或 Endpoint 总数超过 5000 时,iptables 的规则刷新延迟会变得明显(秒级).此时切换 IPVS 可以显著改善性能.中小规模集群用 iptables 完全够用.

Endpoints 与 EndpointSlice

Service 怎么知道后端有哪些 Pod?答案是 Endpoints(旧)和 EndpointSlice(新)对象.

Endpoints 对象

创建 Service 后,Endpoints Controller 自动创建一个同名的 Endpoints 对象,列出所有匹配 selector 且处于 Ready 状态的 Pod IP + Port:

$ kubectl get endpoints backend-svc
NAME ENDPOINTS AGE
backend-svc 10.244.1.5:8080,10.244.2.3:8080,10.244.3.7:8080 5m

# Pod 不 Ready → 自动从 Endpoints 移除
# Pod 重新 Ready → 自动加回来
# 新 Pod 创建(扩容)→ 自动追加
# Pod 被删除(缩容)→ 自动移除
控制循环:
Endpoints Controller(在 Controller Manager 中)
watch: Service + Pod
逻辑:
for each Service with selector:
找到所有匹配 selector 的 Pod
过滤出 status.conditions 中 Ready=True 的
更新 Endpoints 对象的地址列表

kube-proxy(在每个 Node 上)
watch: Service + Endpoints
逻辑:
Endpoints 变了 → 重写本节点的 iptables/IPVS 规则

EndpointSlice - 大规模集群的优化

Endpoints 的问题:一个对象存所有后端地址.如果一个 Service 有 5000 个 Pod,这个 Endpoints 对象就有 5000 条记录.每次任何一个 Pod 变动,整个对象都要更新并推送给所有节点的 kube-proxy.

Endpoints 的问题(大规模场景):
Service "big-app" 有 5000 个 Pod
→ 一个 Endpoints 对象包含 5000 个地址
→ 某个 Pod 重启 → 更新整个 5000 条的对象
→ etcd 存储/网络传输的开销随 Pod 数量线性增长
→ 推送给集群所有 Node 的 kube-proxy → 放大 N 倍

EndpointSlice 的解法:
Service "big-app" 有 5000 个 Pod
→ 拆成 50 个 EndpointSlice(每个最多 100 条)
→ 某个 Pod 重启 → 只更新它所在的那 1 个 Slice
→ 传输量:1/50
→ 其他 49 个 Slice 不动,kube-proxy 增量更新
# EndpointSlice 是自动创建的,通常不需要手动管
$ kubectl get endpointslice -l kubernetes.io/service-name=backend-svc
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
backend-svc-abc12 IPv4 8080 10.244.1.5,10.244.2.3... 5m
backend-svc-def34 IPv4 8080 10.244.3.7,10.244.4.1... 5m

Endpoints 还是 EndpointSlice?

Kubernetes 1.21+ 默认使用 EndpointSlice.旧的 Endpoints 对象仍然存在(向后兼容),但 kube-proxy 优先读取 EndpointSlice.
你不需要选择用哪个--集群自动管理.只有当你排查 Service 后端不对时,使用 kubectl get endpointskubectl get endpointslice 确认后端列表.

无 selector 的 Service - 手动管理后端

如果 Service 不写 selector,K8s 不会自动创建 Endpoints.你可以手动创建,让 Service 指向集群外部的 IP(如裸机数据库,遗留系统).

# 1. 创建无 selector 的 Service
apiVersion: v1
kind: Service
metadata:
name: legacy-db
spec:
ports:
- port: 3306
targetPort: 3306
# 没有 selector → 不会自动关联 Pod

---
# 2. 手动创建同名 Endpoints
apiVersion: v1
kind: Endpoints
metadata:
name: legacy-db # 必须和 Service 同名
subsets:
- addresses:
- ip: 192.168.1.100 # 外部数据库 IP
- ip: 192.168.1.101 # 备库 IP
ports:
- port: 3306
# 集群内 Pod 连 legacy-db:3306 → 负载均衡到 192.168.1.100 和 192.168.1.101
# 等数据库迁到 K8s 集群后,加上 selector 即可无缝切换

Headless Service 深入

第 03 篇已介绍 Headless Service 与 StatefulSet 的配合.这里从 Service Discovery 的角度补充更完整的视图.

工作机制

apiVersion: v1
kind: Service
metadata:
name: my-svc
spec:
clusterIP: None # ← 就这一行,变成 Headless
selector:
app: my-app
ports:
- port: 80
普通 Service(clusterIP: 10.96.0.100):
DNS 查询 my-svc → A 记录 → 10.96.0.100(ClusterIP)
→ kube-proxy 负载均衡到后端 Pod
→ 客户端不知道有几个后端,各自 IP 是什么

Headless Service(clusterIP: None):
DNS 查询 my-svc → A 记录 → 10.244.1.5, 10.244.2.3, 10.244.3.7
→ 直接返回所有后端 Pod 的 IP(不经过 kube-proxy)
→ 客户端自己决定连谁

如果配合 StatefulSet,还有逐 Pod 的 DNS:
DNS 查询 pod-0.my-svc → A 记录 → 10.244.1.5(只有这个 Pod)

典型使用场景

场景 原因
StatefulSet 配套 需要稳定的逐 Pod DNS(pod-0.svc),用于主从复制,分片等
客户端负载均衡 gRPC 长连接场景--kube-proxy 只在连接建立时选后端,长连接会一直打到同一个 Pod.客户端拿到全部 IP 自己做轮询
服务网格(Service Mesh) Envoy sidecar 需要知道所有后端 IP 来做智能路由,不需要 kube-proxy 的简单随机分发

gRPC + 普通 Service = 负载均衡失效

gRPC 基于 HTTP/2 长连接.
普通 Service(kube-proxy)只在 TCP 连接建立时做一次负载均衡选择.之后这条连接上的所有 RPC 请求都发给同一个 Pod: 即使后端有 10 个 Pod,流量也可能全压在一个上.
解法:使用 Headless Service + 客户端负载均衡(如 gRPC 内置的 round_robin),或引入服务网格.

客户端负载均衡不是牺牲长连接

常见误解:

  • Q: 用 Headless + 客户端 round_robin 是不是把长连接变成了短连接?
  • A: 并不是.客户端拿到所有 Pod IP 后,会同时建立 N 条长连接(每个后端一条),每条连接都保持不断开.HTTP/2 多路复用允许一条连接承载多个并发 stream,gRPC 的 round_robin 做的是:每个新 RPC 请求在 N 条持久连接中轮着选一条发出去.
普通 Service:
Client → 1 条长连接 → kube-proxy 选了 Pod-2 → 所有 RPC 都走 Pod-2

Headless + round_robin:
Client → 3 条长连接(Pod-1,Pod-2,Pod-3 各一条,从不断开)
→ RPC-1 走 Pod-1
→ RPC-2 走 Pod-2
→ RPC-3 走 Pod-3
→ RPC-4 走 Pod-1 ...(请求级轮询,连接级不变)

本质是"1 条长连接 → N 条长连接 + 请求级分发",TCP 握手复用和 HTTP/2 头部压缩的优势完全保留.

服务发现机制

前面讲的 kube-proxy + iptables 解决的是 报文到了 ClusterIP 之后怎么转发到 Pod .
但还有一个前置问题:应用代码怎么知道该连哪个 ClusterIP?你不可能在代码里硬编码 10.96.0.100, 这个 IP 是 Service 创建时随机分配的,换个集群就变了.

服务发现解决的就是这一步:把 Service 名称翻译为可连接的地址.名称 → IP 的翻译完成后,后续的流量转发才交给 kube-proxy.

应用代码: curl http://backend-svc:80

① 服务发现(DNS)
"backend-svc" → 10.96.0.100

② 流量转发(kube-proxy / iptables)
dst 10.96.0.100:80 → DNAT → 10.244.1.5:8080(某个 Pod)

K8s 提供两种服务发现机制:

DNS 发现(推荐)

集群内的 CoreDNS 为每个 Service 自动注册 DNS 记录:

完整格式:
<service-name>.<namespace>.svc.cluster.local

解析规则:
同 namespace 内可以省略后缀:
backend-svc → 同 ns 直接用名字
backend-svc.default → 带 namespace
backend-svc.default.svc → 带 svc
backend-svc.default.svc.cluster.local → 完整 FQDN

跨 namespace 至少带 namespace:
backend-svc.production → 访问 production ns 的服务
# 应用代码中直接使用 Service 名称
# Go
conn, err := grpc.Dial("backend-svc:8080", ...)

# Python
requests.get("http://backend-svc/api/users")

# 环境变量中配置
DATABASE_URL=postgresql://db-svc:5432/mydb

DNS 搜索域

Pod 的 /etc/resolv.conf 里配置了一组搜索域(search domain),使得你在代码里只写短名就能解析到完整的集群内 DNS 记录:

# Pod 内的 /etc/resolv.conf(假设 Pod 在 default namespace)
nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local

当你写 backend-svc 时,DNS 解析器会按 search 列表依次拼接尝试:

  1. backend-svc.default.svc.cluster.local → 命中,返回 ClusterIP
  2. (如果第 1 步没命中才继续)backend-svc.svc.cluster.local
  3. backend-svc.cluster.local

跨 namespace 访问时,短名找不到,需要至少写到 backend-svc.other-ns(会被拼为 backend-svc.other-ns.svc.cluster.local).

环境变量发现(了解即可)

K8s 在 Pod 启动时,把同 namespace 中已存在的 Service 信息注入为环境变量:

# Pod 中自动注入的环境变量(以 backend-svc 为例)
$ env | grep BACKEND_SVC
BACKEND_SVC_SERVICE_HOST=10.96.0.100
BACKEND_SVC_SERVICE_PORT=80
BACKEND_SVC_PORT=tcp://10.96.0.100:80
BACKEND_SVC_PORT_80_TCP=tcp://10.96.0.100:80
BACKEND_SVC_PORT_80_TCP_PROTO=tcp
BACKEND_SVC_PORT_80_TCP_PORT=80
BACKEND_SVC_PORT_80_TCP_ADDR=10.96.0.100

环境变量发现的局限:

  • 顺序依赖:Service 必须在 Pod 之前创建,否则环境变量不会注入
  • 不会动态更新:Service 的 ClusterIP 变了(极少发生),已运行的 Pod 看到的还是旧值
  • 命名冲突:Service 名中的 - 被转为 _ 后可能和其他变量冲突

结论:始终使用 DNS 发现.环境变量只在极少数无法使用 DNS 的遗留场景下使用.

Session Affinity(会话亲和性) - 不推荐用于无状态服务

默认情况下,Service 每次连接随机选后端 Pod.如果需要同一客户端的请求始终打到同一个 Pod(如基于内存的 session),可以配置 Session Affinity:

apiVersion: v1
kind: Service
metadata:
name: web-svc
spec:
selector:
app: web
sessionAffinity: ClientIP # 基于客户端 IP 的会话亲和
sessionAffinityConfig:
clientIP:
timeoutSeconds: 1800 # 30 分钟内同一客户端 IP → 同一 Pod
ports:
- port: 80
targetPort: 8080

Session Affinity 不是银弹

依赖 Session Affinity 说明应用有状态: 这和 K8s 推崇的无状态设计相矛盾.
更好的做法是把 session 存到 Redis/数据库,让应用真正无状态.

Session Affinity 适用于:遗留应用暂时无法改造,或性能敏感的缓存场景(如本地内存缓存).

Service 问题排查清单

Service 不通是 K8s 最常见的问题之一.按这个顺序排查:

# 1. Service 是否存在?ClusterIP 是否分配?
$ kubectl get svc backend-svc
# 确认 CLUSTER-IP 不是 <none>(除非是 Headless)

# 2. Endpoints 是否有后端?
$ kubectl get endpoints backend-svc
# 如果 ENDPOINTS 为空 → selector 没匹配到任何 Ready 的 Pod

# 3. Pod 是否 Running + Ready?
$ kubectl get pods -l app=backend
# STATUS=Running 但 READY=0/1 → readinessProbe 失败 → 不会被纳入 Endpoints

# 4. Pod 内端口是否在监听?
$ kubectl exec backend-pod -- netstat -tlnp | grep 8080
# 或
$ kubectl exec backend-pod -- curl localhost:8080/health

# 5. selector 标签是否匹配?
$ kubectl get svc backend-svc -o yaml | grep -A3 selector
$ kubectl get pods -l app=backend --show-labels
# 比对两边的标签是否一致

# 6. 端口映射是否正确?
# Service port(客户端连的) vs targetPort(Pod 监听的)
$ kubectl get svc backend-svc -o yaml | grep -A5 ports

# 7. 网络策略是否阻断?
$ kubectl get networkpolicy -A
# 有 NetworkPolicy 时,确认没有 deny 规则阻断 Service → Pod 的流量
Service 不通排查流程:

kubectl get svc → 有 ClusterIP?
│ │
│ 没有 │ 有
↓ ↓
检查 type/定义 kubectl get endpoints → 有后端 IP?
│ │
│ 空 │ 有
↓ ↓
selector 匹配? curl Pod IP:Port → 通?
Pod Ready? │ │
│ 不通 │ 通
↓ ↓
Pod 内部问题 kube-proxy/
(进程/端口) NetworkPolicy

小结

快速回顾

  • Service 的核心价值:给一组 Pod 提供稳定的虚拟 IP + DNS + 负载均衡,解耦调用方与后端 Pod 的生命周期
  • ClusterIP:默认类型,仅集群内访问;适合内部微服务通信
  • NodePort:在每个节点开端口(30000-32767),适合开发调试;生产不推荐
  • LoadBalancer:自动创建云 LB + 公网 IP,生产对外服务首选;每个 Service 占一个 LB(成本高)
  • ExternalName:纯 DNS CNAME,不代理流量;用于对接外部服务保留一层抽象
  • kube-proxy:iptables(默认,规则链式匹配)vs IPVS(哈希表 O(1),大规模推荐);是 Service 流量转发的实际执行者
  • Endpoints/EndpointSlice:记录 Service 当前后端地址列表;EndpointSlice 拆小对象,解决大规模更新放大问题
  • Headless Service:clusterIP: None,DNS 直接返回 Pod IP 列表;StatefulSet 配套,gRPC 客户端 LB,Service Mesh 必备
  • 服务发现:始终用 DNS(Service 名即域名);环境变量方式有顺序依赖和不更新的问题

动手练习

  1. 创建一个 Deployment(3 副本,nginx)+ ClusterIP Service.用 kubectl exec 进入另一个 Pod,反复 curl http://<svc-name>,观察返回的 Pod hostname 是否均匀分布.
  2. 将 Service 改为 NodePort,从宿主机用 curl localhost:<nodePort> 验证外部访问是否成功.
  3. 创建一个 Headless Service(clusterIP: None),用 nslookup <svc-name> 观察 DNS 返回的是 Pod IP 列表而非 ClusterIP.
  4. 手动将一个 Pod 的 readinessProbe 改为必定失败(如探测一个不存在的端口),观察 Endpoints 列表中该 Pod IP 是否被自动移除.
  5. 创建一个无 selector 的 Service + 手动 Endpoints,指向集群外部 IP(如你笔记本的 IP),验证集群内 Pod 能否通过 Service 名访问到外部服务.
  6. 配置 sessionAffinity: ClientIP,反复 curl 同一个 Service,验证请求是否始终落到同一个 Pod.移除配置后再试,观察差异.