5. Service 控制循环
Service 解决什么问题
前面两章讲的 Deployment 和 StatefulSet,不管是滚动更新还是扩缩容,Pod 都在不停出生和死亡——名字变、IP 变。下游怎么连?
打比方:公司前台。员工换工位了、离职了、新来了,外面的访客不用关心——前台电话号码不变,访客打前台,前台转给当前在岗的员工。
Service 就是 K8S 里的前台。它有个稳定的 IP(ClusterIP),背后管一组 Pod。Pod 变了,Service 自动更新转发表。
但这件事不是 Service Controller 一个角色能搞定的——它牵涉到三个 Controller 协作:
你写: Service (selector: app=nginx)
│
▼
Service Controller ──→ 分配 ClusterIP、配 LB
│
▼
EndpointSlice Controller ──→ 监听 Pod 变化,维护"哪些 Pod 满足 selector"
│
▼
kube-proxy ──→ 把转发规则写到每个节点的 iptables/IPVS
Service Controller:分配入口
Service Controller 的 Reconcile 逻辑相对简单——它的主要职责是给 Service 分配外部入口:
func Reconcile(service *corev1.Service) error {
// 1. ClusterIP 类型的 Service
// 分配 ClusterIP(如果没指定)
if service.Spec.ClusterIP == "" {
allocateClusterIP(service)
}
// 2. NodePort 类型
// 分配节点端口
if service.Spec.Type == corev1.ServiceTypeNodePort ||
service.Spec.Type == corev1.ServiceTypeLoadBalancer {
if service.Spec.Ports[0].NodePort == 0 {
allocateNodePort(service)
}
}
// 3. LoadBalancer 类型
// 调用云厂商的 LB controller,创建外部负载均衡器
if service.Spec.Type == corev1.ServiceTypeLoadBalancer {
ensureCloudLoadBalancer(service)
}
// 4. 更新 status(LoadBalancer 类型写入外部 IP)
updateServiceStatus(service)
return nil
}
打比方:Service Controller 像物业经理——给它一栋楼,它负责分门牌号(ClusterIP)、开侧门(NodePort)、或者申请对接市政道路(LoadBalancer)。但物业经理不关心楼里住着谁——那是 EndpointSlice Controller 的活。
四种 Service 类型
| 类型 | 入口在哪 | 谁能访问 |
|---|---|---|
| ClusterIP | 集群内一个虚拟 IP | 集群内的 Pod |
| NodePort | 每个节点的一个端口(30000-32767) | 集群外,通过 节点IP:NodePort |
| LoadBalancer | 云厂商的 LB | 公网,通过 LB 的外部 IP |
| ExternalName | DNS CNAME 记录 | 把集群内服务指向外部域名 |
ClusterIP: 集群内 ──→ 10.96.0.10 ──→ Pods
NodePort: 集群外 ──→ 节点IP:30080 ──→ 集群内 ──→ Pods
LoadBalancer: 公网 ──→ LB(1.2.3.4) ──→ 节点IP:30080 ──→ Pods
ExternalName: 集群内 ──→ DNS CNAME ──→ external.example.com
LoadBalancer 类型最复杂——Service Controller 调云厂商 API 创建一个真正的负载均衡器(ELB、SLB、CLB),把流量从公网引到节点上。
EndpointSlice Controller:维护"谁在线"
Service Controller 不管 Pod,那"Service 背后有哪些 Pod"谁维护?EndpointSlice Controller。
这是整个服务发现的核心。
它在干什么
func Reconcile(service *corev1.Service) error {
// 1. 查所有匹配 Service selector 的 Pod
pods := listPods(service.Spec.Selector)
// 2. 过滤掉不 ready 的 Pod
// (Pod 还在启动、健康检查没过、正在终止)
readyPods := filterReady(pods)
// 3. 把 ready Pod 的 IP + Port 写到 EndpointSlice
endpoints := buildEndpoints(readyPods, service.Spec.Ports)
// 4. 更新 EndpointSlice 对象
updateOrCreateEndpointSlice(service, endpoints)
return nil
}
逻辑就是:Service 的 selector 决定要哪些 Pod,EndpointSlice Controller 把这些 Pod 的实际 IP 收集起来。
Service: EndpointSlice:
selector: app=nginx addresses:
- 10.244.1.5 (Pod nginx-aaa)
- 10.244.2.3 (Pod nginx-bbb)
- 10.244.3.7 (Pod nginx-ccc)
为什么不直接写在 Service 里
早期 K8S 确实把 endpoints 写在 Service 对象里——叫 Endpoints 资源。后来拆出来变成 EndpointSlice,原因有两个:
原因 1:分离关注点
Service 是用户写的——"我要一个叫 nginx 的服务"。Endpoints 是系统维护的——"nginx 服务现在指向这些 Pod"。混在一个对象里,用户改 Service 时可能误触 Endpoints 字段;Controller 更新 Endpoints 时可能跟用户的 Service 更新冲突。
分开后:Service 用户管,EndpointSlice Controller 管,各管各的。
原因 2:扩展性
一个 Service 后面可能有几千个 Pod(比如一个大规模 nginx 集群)。Endpoints 是单个对象,存几千个 IP 会超出 etcd 单对象大小限制(1.5MB)。
EndpointSlice 把一个 Service 的端点切成多个片——每个片默认最多 100 个端点。Service 后面 1000 个 Pod,切成 10 个 EndpointSlice,分散存储和 Watch 压力。
Service nginx (1000 个 Pod)
├── EndpointSlice nginx-abc (100 端点)
├── EndpointSlice nginx-def (100 端点)
├── EndpointSlice nginx-ghi (100 端点)
...
└── EndpointSlice nginx-xyz (100 端点)
ready 判断的门道
readyPods := filterReady(pods)
这一行背后的逻辑比看起来复杂。一个 Pod "ready" 要同时满足:
- Pod phase == Running:容器在跑
- 所有容器都 ready:不只是启动了,还要通过
readinessProbe - Pod.Spec.TerminationGracePeriodSeconds 没在执行:没在被删除
第 3 点容易被忽略。Pod 被 kubectl delete 时,先进入 Terminating 状态——这时它的 IP 还在,但 EndpointSlice Controller 已经把它从端点列表里移除了。
打比方:员工提了离职,工牌被收回——虽然人还在工位上收拾东西,但前台已经不把电话转给他了。这样不会把新客户转给一个要走的人。
这就是为什么 Service 滚动更新时零中断——旧 Pod 还在跑,但从 Endpoints 里摘掉了;新 Pod ready 了,加进 Endpoints。流量平滑切换。
kube-proxy:把规则下发到每个节点
EndpointSlice Controller 更新了对象,但流量真正转发是在每个节点上发生的——kube-proxy 干这个活。
EndpointSlice (etcd 里)
│
▼ Watch
每个节点的 kube-proxy
│
▼ 写入
iptables / IPVS 规则
│
▼
访问 ClusterIP 的流量被重写到 Pod IP
kube-proxy 严格说不是 Controller——它不做"调谐",只做"同步"。但它的工作模式跟 Controller 像:Watch EndpointSlice 变化,把变化翻译成节点上的转发规则。
iptables 模式
# 访问 10.96.0.10:80 的流量
-A KUBE-SERVICES -d 10.96.0.10/32 -p tcp --dport 80 -j KUBE-SVC-NGINX
# 随机选一个 Pod(默认随机轮询)
-A KUBE-SVC-NGINX -m statistic --mode random --probability 0.333 -j KUBE-SEP-POD1
-A KUBE-SVC-NGINX -m statistic --mode random --probability 0.5 -j KUBE-SEP-POD2
-A KUBE-SVC-NGINX -j KUBE-SEP-POD3
# DNAT 到 Pod IP
-A KUBE-SEP-POD1 -p tcp -j DNAT --to-destination 10.244.1.5:80
-A KUBE-SEP-POD2 -p tcp -j DNAT --to-destination 10.244.2.3:80
-A KUBE-SEP-POD3 -p tcp -j DNAT --to-destination 10.244.3.7:80
每个 Service 一组 iptables 规则。Pod IP 变了,kube-proxy 删旧规则加新规则。
iptables 模式的问题——规则是线性匹配,Service 一多性能急剧下降。1000 个 Service 每个后面 10 个 Pod,最坏要匹配 10000 条规则。
IPVS 模式
IPVS 用哈希表查找,O(1) 复杂度,跟 Service 数量无关。大规模集群标配。
IPVS 表:
Service 10.96.0.10:80
├── -> 10.244.1.5:80 (weight 1)
├── -> 10.244.2.3:80 (weight 1)
└── -> 10.244.3.7:80 (weight 1)
kube-proxy 在 IPVS 模式下,每个 Service 一个 IPVS 虚拟服务,后面挂真实 Pod IP。查找走哈希,不扫描规则链。
三个角色的协作
完整的服务发现流程:
你写: Service (selector: app=nginx, port: 80)
│
▼
Service Controller
分配 ClusterIP 10.96.0.10
│
▼
EndpointSlice Controller (Watch Service 和 Pod)
发现 3 个 app=nginx 的 ready Pod
写 EndpointSlice: [10.244.1.5, 10.244.2.3, 10.244.3.7]
│
▼
kube-proxy (Watch EndpointSlice)
每个节点更新 iptables/IPVS 规则
访问 10.96.0.10:80 → DNAT 到某个 Pod IP
│
▼
Pod 变化(滚动更新/扩缩容/故障)
EndpointSlice Controller 重新计算 ready Pod 列表
更新 EndpointSlice
kube-proxy 同步新规则
流量自动切到新 Pod
整个链条靠 Watch 事件驱动,不用轮询。Pod IP 变化的延迟通常是 1-2 秒——EndpointSlice Controller 收到 Pod 事件到更新对象,kube-proxy 收到 EndpointSlice 事件到写规则,各几百毫秒。
Headless Service:不要 ClusterIP
apiVersion: v1
kind: Service
metadata:
name: mysql
spec:
clusterIP: None # ← Headless
selector:
app: mysql
ports:
- port: 3306
clusterIP: None 告诉 Service Controller:"我不要虚拟 IP"。
这时 Service Controller 不分配 ClusterIP,kube-proxy 也不写转发规则。DNS 查询直接返回 Pod IP 列表:
$ dig mysql.default.svc.cluster.local
mysql.default.svc.cluster.local. 30 IN A 10.244.1.5
mysql.default.svc.cluster.local. 30 IN A 10.244.2.3
mysql.default.svc.cluster.local. 30 IN A 10.244.3.7
客户端拿到一组 IP,自己选一个连。
为什么需要 Headless?因为有些场景客户端要直接连 Pod——比如 MySQL 主从,客户端要知道哪个是主(通过 mysql-0.mysql 这种带序号的 DNS),而不是被 Service 随机负载均衡到一个 Pod。
StatefulSet 配 Headless Service 是经典组合——每个 Pod 有独立 DNS(mysql-0.mysql、mysql-1.mysql),客户端按名字直连。
前置阅读
- 理解控制循环 — 本章前置,Service Controller 和 EndpointSlice Controller 的 Reconcile 原理
- 服务发现与网络 — Starter 第 6 章,Service 的使用层面介绍
- StatefulSet控制循环 — 上一章,StatefulSet + Headless Service 的组合
- Deployment控制循环 — 第 3 章,工作负载 Controller 的基础
- Pod:最小的调度单元 — Starter 第 4 章,Service 背后管的就是 Pod
思考题
问题 1:Pod 被 kubectl delete 删除时,EndpointSlice Controller 会先收到 Pod 的 Delete 事件,还是先收到 Service 的 selector 变化?为什么?
问题 2:iptables 模式下,一个 Service 后面 1000 个 Pod,访问这个 Service 会比 10 个 Pod 慢吗?
问题 3:Headless Service 没有 ClusterIP,kube-proxy 也不管它。流量怎么到 Pod?
解答
问题 1:先收到 Pod 的 Delete 事件。EndpointSlice Controller 同时 Watch Service 和 Pod——Pod 被删时,Controller 收到 Pod 的 Delete 事件,重新计算 selector 匹配的 Pod 列表,发现少了一个,更新 EndpointSlice。Service 的 selector 没变,跟 Service 无关。整个链条靠 Pod 事件驱动,不靠 Service。
问题 2:iptables 模式下会慢。iptables 是线性匹配——1000 个 Pod 意味着 1000 条 --probability 规则,最坏要匹配 1000 次。10 个 Pod 只匹配 10 次。但 IPVS 模式不受影响——哈希查找 O(1),1000 个 Pod 和 10 个 Pod 一样快。这就是大规模集群用 IPVS 的原因。
问题 3:靠 DNS。Headless Service 的 DNS 记录直接返回 Pod IP 列表,客户端拿到 IP 自己直连——不经过 kube-proxy,不经过 iptables/IPVS。CoreDNS 负责 Watch EndpointSlice 变化,更新 DNS 记录。所以 Headless Service 的"服务发现"是 DNS 层的,不是网络层的。
下一章
内置 Controller 看完了。下一章我们回到 Controller 自己写的视角——GVK 与 GVR,理解 K8S API 的寻址方式,为写 CRD 做准备。