Skip to main content

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
ExternalNameDNS 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" 要同时满足:

  1. Pod phase == Running:容器在跑
  2. 所有容器都 ready:不只是启动了,还要通过 readinessProbe
  3. 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),客户端按名字直连。

前置阅读​

思考题​

问题 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 做准备。