Kubernetes服务发现需同时监听Service和EndpointSlice(或Endpoints),通过client-go informer获取就绪Pod地址,结合健康检查与动态路由实现高可用;注意权限、命名空间及服务类型差异。
用 client-go 直接监听 Service 和 Endpoints 变化
Kubernetes 原生服务发现不依赖第三方注册中心,核心就是监听 Service 和对应 Endpoints(或 EndpointSlice)资源的变更。Golang 项目要实现服务发现,最直接的方式是用 client-go 的 informer 机制——它比轮询 API 更高效、更可靠,且自带本地缓存和事件队列。
关键点在于:不要只 watch Service,必须同时 watch Endpoints 或 EndpointSlice,因为 Service 本身不包含真实后端地址,真正承载 IP:Port 列表的是后者。
-
Endpoints是传统方式,兼容所有 K8s 版本,但大集群下性能差(单个对象可能含数千地址) -
EndpointSlice是 1.21+ 推荐方式,按 service + topology 分片,支持更大规模和更快更新 - 若集群启用了
EndpointSlice功能(默认开启),优先使用EndpointSliceInformer,否则 fallback 到EndpointsInformer
informer := clientset.DiscoveryV1().EndpointSlices("").Informer(context.TODO())
informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
slice := obj.(*discoveryv1.EndpointSlice)
if slice.Labels["kubernetes.io/service-name"] == "my-service" {
// 解析 slice.Endpoints 和 slice.Ports,提取 ready 状态的 endpoint
}
},
UpdateFunc: func(old, new interface{}) { /* 类似处理 */ },
})解析 EndpointSlice 中的就绪 endpoint 地址
EndpointSlice 的 Endpoints 字段里每个元素都有 Conditions.Ready、Hostname、TargetRef 等字段,但真实可调用的地址由 Addresses + Ports 组合得出。常见错误是忽略 Topology 或误读 TargetRef。
-
Addresses是字符串切片(如["10.244.1.5"]),不是 pod 名;TargetRef指向 Pod 对象,仅用于关联,不能直接用于网络请求 -
Ports可能为空(表示继承 Service port name),需回查对应Service的spec.ports获取实际端口 - 只取
Conditions.Ready == true的 endpoint,避免将正在终止的 Pod 加入负载列表 - 若
EndpointSlice含多个Ports,需根据客户端调用的目标 port name 匹配(比如调用http端口,就找name: "http"的 port)
for _, ep := range slice.Endpoints { if !ep.Conditions.Ready || len(ep.Addresses) == 0 { continue } for _, port := range slice.Ports { if port.Name != nil && *port.Name == "http" && port.Port != nil { for _, ip := range ep.Addresses { addr := net.JoinHostPort(ip, strconv.Itoa(int(*port.Port))) // addr 形如 "10.244.1.5:8080",可直接用于 http.Client } } } }
在 HTTP 客户端中动态更新 endpoints 并做健康探测
拿到地址列表后,不能简单轮询或随机选一个就长期复用——Pod 可能随时重建,endpoint 列表会突变。需要结合连接池管理、失败熔断和主动健康检查。
立即学习“go语言免费学习笔记(深入)”;
- 用
http.Transport的DialContext+ 自定义RoundTripper控制底层连接目标,避免 DNS 缓存干扰(K8s 内部不用 DNS 解析 service 名) - 每次请求前从当前缓存的 endpoint 列表中选取(例如加权轮询),并设置短超时(如 3s)防止卡死
- 对连续失败的 endpoint 做临时剔除(比如 3 次 5xx 或连接拒绝),并在后台定时重试(如每 30s ping 一次 /healthz)
- 避免在请求路径中同步 reload 全量 endpoint 列表——应由 informer 异步更新内存缓存,业务代码只读缓存
典型陷阱:直接把 http://my-service:8080 丢给 http.Client,依赖 kube-proxy 的 iptables/IPVS 转发。这绕过了服务发现逻辑,无法感知实例级故障,也无法做精细化路由或灰度。
跨命名空间和服务类型(ClusterIP/Headless)的处理差异
服务发现逻辑必须区分 Service 的 spec.type 和 metadata.namespace,否则会拿到错误地址或权限拒绝。
-
ClusterIP类型:只应在集群内访问,Endpoints或EndpointSlice中的地址是 Pod IP,直接可用 -
Headless类型(spec.clusterIP: None):不会创建 ClusterIP,但会为每个 Pod 生成独立的 DNS 记录;此时EndpointSlice仍存在,但常被用于直接寻址 Pod,适合有状态服务 - 跨 namespace 访问:watch 时指定 namespace(如
clientset.CoreV1().Endpoints("other-ns")),或 watch 全局("")后按 label 过滤;RBAC 必须授权对应 namespace 的endpoints和endpointslices权限 - ExternalName 类型无 endpoints,不应参与服务发现流程
最容易被忽略的是权限配置——即使代码逻辑全对,rbac.authorization.k8s.io/v1 中漏掉 get/list/watch 对 endpoints 或 endpointslices 的规则,informer 就会静默失败,日志里只有 generic cache sync error,没有具体提示。

ndpoints {
if !ep.Conditions.Ready || len(ep.Addresses) == 0 {
continue
}
for _, port := range slice.Ports {
if port.Name != nil && *port.Name == "http" && port.Port != nil {
for _, ip := range ep.Addresses {
addr := net.JoinHostPort(ip, strconv.Itoa(int(*port.Port)))
// addr 形如 "10.244.1.5:8080",可直接用于 http.Client
}
}
}
}






