Kubernetes服务发现:Service、EndpointSlice、CoreDNS与流量传播原理
Kubernetes服务发现不是“注册一个服务名,然后自动负载均衡”这么简单。它是一条由多个控制器、缓存和数据面共同完成的异步链路:Pod标签被Service选择,EndpointSlice Controller生成后端快照,CoreDNS提供名称解析,kube-proxy或eBPF数据面把Service虚拟地址映射到Pod,而Readiness、滚动发布、连接复用和DNS缓存又决定流量何时真正切换。
本页站在微服务调用者视角讲清楚“地址从哪里来、请求为什么这样走、实例下线为什么不是瞬时生效”。包转发、CNI、NetworkPolicy的更底层内容见Kubernetes网络专栏,探针执行机制见Kubernetes探针专栏。
一、学习目标
学完后应能独立解释和排查:
- Service、EndpointSlice、CoreDNS、kube-proxy分别负责什么。
- Pod从启动到真正接收Service流量经历哪些异步步骤。
- ClusterIP为什么不是普通监听地址,iptables、IPVS和eBPF有什么边界。
- Service为什么通常按连接而不是按HTTP请求选择Pod。
- HTTP Keep-Alive、HTTP/2和gRPC为什么会造成流量倾斜。
- Headless Service为什么属于客户端发现。
ready、serving、terminating分别表达什么。- Pod终止时为什么已有连接仍可能继续请求旧实例。
- Spring Cloud客户端负载均衡与Kubernetes Service为什么不能盲目叠加。
- 如何从DNS、Service、EndpointSlice、Pod、Node数据面和应用连接池逐层取证。
二、发现、解析、转发不是一件事
| 层次 | 对象或组件 | 负责什么 | 不负责什么 |
|---|---|---|---|
| 工作负载 | Pod、Deployment、StatefulSet | 运行应用、声明标签和就绪状态 | 不提供稳定访问地址 |
| 稳定身份 | Service | 提供稳定名称、端口和可选ClusterIP | 不主动探测业务健康 |
| 后端快照 | EndpointSlice | 保存候选地址、端口和条件 | 不转发网络包 |
| 名称发现 | CoreDNS | 把Service域名解析为ClusterIP或端点记录 | 不保证应用可用 |
| 四层数据面 | kube-proxy、eBPF实现 | 把新连接映射到一个后端 | 不理解订单接口是否成功 |
| 应用连接 | HTTP客户端、连接池、gRPC Channel | 建立和复用连接 | 不一定立即感知端点摘除 |
必须牢记以下边界:
- Service是期望状态对象,不是每个Service对应一个用户态代理进程。
- EndpointSlice是后端事实快照,不是负载均衡器。
- DNS解析成功只能证明名称阶段成功。
- Readiness摘除主要影响后续新连接,不能强制迁移已有TCP连接。
- 调用超时仍可能是“结果未知”,业务写操作仍需幂等和查询确认。
三、标签怎样变成可调用地址
flowchart TD
A["Pod带有app=order-api标签"] --> B["Service selector选择同命名空间Pod"]
B --> C["EndpointSlice Controller计算后端集合"]
C --> D["写入地址、端口和Endpoint条件"]
D --> E["CoreDNS观察Service与EndpointSlice"]
D --> F["节点数据面观察Service与EndpointSlice"]
E --> G["客户端解析order-api名称"]
F --> H["新连接被映射到Ready Endpoint"]3.1 Pod标签与Service选择器
Service selector是精确标签匹配条件,不是应用名的模糊搜索。普通selector Service只选择同一Namespace中的Pod。标签写错、Namespace不一致或滚动发布模板标签变化,都会让Service没有后端。
apiVersion: v1
kind: Service
metadata:
name: order-api
namespace: trade
spec:
selector:
app: order-api
ports:
- name: http
port: 80
targetPort: httpPod模板必须提供匹配标签和同名端口:
spec:
template:
metadata:
labels:
app: order-api
spec:
containers:
- name: order-api
image: example/order-api:1.0.0
ports:
- name: http
containerPort: 8080containerPort主要是声明和工具元数据,并不会让Java进程自动监听8080。真实监听端口由应用配置决定。Service的targetPort: http最终要解析到EndpointSlice端口;名字不一致会出现“Pod Ready、Endpoint也有地址,但请求拒绝或超时”。
3.2 EndpointSlice为什么存在
旧式单一Endpoints对象把大量后端放在一个大对象中,任何变化都可能导致大对象重新分发。EndpointSlice把后端拆成多个切片,降低大规模服务的更新压力,并承载双栈、拓扑和终止条件等信息。
EndpointSlice通常包含:
- 所属Service标签
kubernetes.io/service-name。 - 地址类型,如IPv4、IPv6或FQDN。
- 端口名、协议和端口号。
- 每个Endpoint的地址、目标引用、Zone和条件。
一个Service可能有多个Slice。自定义发现客户端必须合并全部Slice,不能只读取第一个;处理Watch事件时应重新计算不可变完整快照,避免调用线程看到半更新列表。
四、ready、serving、terminating
| 条件 | 主要语义 | 常见误解 |
|---|---|---|
ready | 端点是否应接收普通新流量 | 等于进程存活 |
serving | 端点当前是否仍在服务,尤其用于终止语义 | 所有数据面版本都完全相同处理 |
terminating | 关联Pod是否处于终止过程 | 一旦为true所有连接立刻消失 |
Readiness Probe由kubelet执行,结果写入Pod Condition;EndpointSlice Controller观察变化并更新Slice;各Node数据面最后同步规则。因此不存在“探针失败的同一时刻,所有节点都停止访问”的原子切换。
具体版本和数据面如何解释终止条件存在边界。排查必须查看实际EndpointSlice、节点规则和连接,不能只背字段定义。
五、Pod启动到接收流量的完整过程
flowchart TD
A["调度器为Pod选择Node"] --> B["kubelet与运行时启动容器"]
B --> C["应用加载配置并监听端口"]
C --> D["startupProbe通过"]
D --> E["readinessProbe达到成功阈值"]
E --> F["kubelet写回Pod Ready Condition"]
F --> G["EndpointSlice Controller更新端点"]
G --> H["各Node数据面异步更新后端"]
H --> I["后续新连接可选中该Pod"]5.1 Running为什么不等于Ready
Running不证明:
- Spring容器初始化完毕。
- 数据库连接池已经建立。
- 配置、证书和模型文件已经加载。
- 请求线程池有能力接收流量。
- Service和Ingress数据面已经收敛。
慢启动Java服务应使用startupProbe给初始化预留窗口,再由readiness决定接流量。不要用liveness替代startup,也不要让liveness依赖短暂抖动的远程数据库,否则所有Pod可能一起被重启。
5.2 健康契约怎样划分
- 启动条件:核心配置加载、端口绑定、必要缓存完成。
- 接流条件:请求线程池可接受新任务,关键本地资源可用。
- 业务降级:推荐、短信等非关键能力失败时仍保持Ready并降级。
- 存活条件:只判断进程是否陷入不可恢复状态,不把瞬时远程故障当成重启理由。
Readiness固定返回200会让线程池耗尽的Pod继续接流量;把所有下游都塞进Readiness又会让一个辅助服务抖动时摘掉全部实例。健康检查必须体现业务边界。
六、一次ClusterIP请求完整怎么走
flowchart TD
A["订单服务请求inventory.trade"] --> B["Resolver得到Service FQDN"]
B --> C["CoreDNS返回Service ClusterIP"]
C --> D["客户端连接ClusterIP和Service Port"]
D --> E["Node数据面匹配Service规则"]
E --> F["选择PodIP和targetPort"]
F --> G["Conntrack记录该连接映射"]
G --> H["数据包经Pod网络到目标Pod"]
H --> I["应用处理业务并返回"]6.1 ClusterIP为什么可能不在网卡上
ClusterIP通常来自Service CIDR,是虚拟服务地址。iptables模式用规则匹配VIP和端口并DNAT;IPVS模式维护虚拟服务和真实后端;部分CNI使用eBPF程序实现同类能力。因此不需要为每个ClusterIP创建普通网卡地址,也不需要每个Service启动一个监听进程。
6.2 数据面实现对比
| 实现 | 核心思路 | 取证重点 |
|---|---|---|
| iptables | 规则链匹配Service,新连接选择并DNAT | 规则链、计数、Conntrack、对象同步 |
| IPVS | 内核虚拟服务维护真实后端和调度算法 | Virtual Service、Real Server、Conntrack |
| eBPF | BPF Map和内核程序实现Service转发 | CNI专用CLI、BPF Map、Agent状态 |
不能在eBPF集群中因为找不到预期iptables链就断言Service坏了。先确认集群数据面和版本,再选取证命令。
七、为什么Service常常是连接级负载均衡
四层数据面通常在TCP连接建立时选定一个Endpoint,并由Conntrack维持映射。同一连接后续承载的多个HTTP请求通常继续到同一个Pod。
flowchart TD
A["客户端建立TCP连接"] --> B["数据面选择Pod B"]
B --> C["Conntrack保存五元组映射"]
C --> D["HTTP请求1复用连接到Pod B"]
D --> E["HTTP请求2仍到Pod B"]
E --> F["连接关闭后新连接才重新选择"]因此:
- 100个HTTP请求不等于做100次后端选择。
- 连接池只有两条长连接时,十个Pod不可能均匀承载请求。
- HTTP/2或gRPC在一条连接上多路复用大量请求,倾斜更明显。
- 扩容只改变后续可选集合,旧连接不会迁移到新Pod。
- Endpoint摘除后,已有连接可能继续通信。
7.1 gRPC负载不均怎么治理
- 创建足够但有上限的Channel或子连接,使连接能覆盖后端。
- 使用支持Endpoint解析和客户端负载均衡的gRPC resolver,而不是只解析一个ClusterIP。
- 使用Headless Service,把端点交给客户端管理。
- 使用Service Mesh代理做请求或连接层治理。
- 控制连接最大生命周期或空闲时间,让扩缩容后逐步重建。
不能每个请求都新建连接,这会引入TCP/TLS握手、临时端口和CPU成本。也不能让客户端、代理和服务端同时无预算重试,否则一次业务请求会被乘法放大。
八、Headless Service:把选择权交给客户端
apiVersion: v1
kind: Service
metadata:
name: mysql
namespace: data
spec:
clusterIP: None
selector:
app: mysql
ports:
- name: mysql
port: 3306Headless没有普通ClusterIP转发,DNS可以返回后端Pod地址或StatefulSet成员记录。它适合数据库、Kafka等成员发现,以及具备Endpoint快照和客户端负载能力的RPC客户端。
它不天然更快,而是把以下责任交给客户端:
- 合并和刷新地址列表。
- 尊重DNS TTL并处理负缓存。
- 选择实例和复用连接。
- 地址失败时熔断、移除和重试。
- 处理扩缩容、升级和陈旧地址。
如果Java应用只在启动时解析一次DNS并永久保存数组,下线实例会长期残留。JVM、操作系统、DNS和应用连接池都可能持有旧状态,必须结合运行时及客户端实现检查缓存。
九、CoreDNS与名称解析
同Namespace常用短名inventory,跨Namespace推荐inventory.trade,完整名称通常类似:
inventory.trade.svc.cluster.local解析链可能经过应用Resolver、JVM或libc缓存、NodeLocal DNSCache、CoreDNS和Kubernetes插件。常见边界:
- search domain会扩展短名称。
- 名称点数低于
ndots时可能先尝试多个搜索域,增加查询和延迟。 - NXDOMAIN可能被负缓存,Service刚创建后仍短暂失败。
- CoreDNS健康不代表外部上游DNS健康。
- NetworkPolicy可能允许业务端口,却漏掉UDP/TCP 53。
- ClusterIP DNS通常稳定返回VIP,Pod变化由数据面收敛。
- Headless返回端点,DNS缓存直接影响后端收敛。
Java 8存量服务和Java 17+服务都要实测实际Resolver。Netty、gRPC、HTTP客户端可能有独立Resolver和缓存,不能只改一个JVM参数就断言全部生效。
十、Pod下线与滚动发布的真实时间线
flowchart TD
A["Pod出现DeletionTimestamp"] --> B["端点进入终止相关状态"]
B --> C["Readiness摘流并异步传播"]
C --> D["执行preStop并开始应用摘流"]
D --> E["主进程收到SIGTERM"]
E --> F["停止接新请求并等待在途请求"]
F --> G["宽限期内正常退出"]
G --> H["超时后可能被SIGKILL"]删除流程和Hook、信号的精确时序受版本和运行时影响,不能把图当成纳秒级同步承诺。工程目标是设计可重叠窗口:先停止获得普通新流量,给EndpointSlice和Node数据面传播留时间,再完成有界在途请求,最后在总宽限期内退出。
10.1 为什么摘流后仍有请求
- Node数据面尚未同步最新EndpointSlice。
- 已有Keep-Alive、HTTP/2或gRPC连接仍存在。
- Ingress、云LB或Mesh有独立后端缓存。
- 客户端使用Headless并缓存旧PodIP。
- 调用方直接访问PodIP,绕过Service。
- 批处理线程已取得旧地址快照。
preStop: sleep 10只能粗略等待传播,不是正确性证明。高风险接口仍需幂等;应用必须实现优雅关闭并限制最长在途时间。
十一、拓扑、会话亲和与流量策略
11.1 SessionAffinity
sessionAffinity: ClientIP尝试让同一来源IP在窗口内命中同一后端。它不是登录Session保证:NAT会让大量用户共享IP,网络变化会改变IP,Pod下线也会重新选择。用户状态应放入Token或共享存储。
11.2 internalTrafficPolicy
本地流量策略可以使用本Node端点,减少跨Node流量;但本地没有Ready Endpoint时可能无后端。它适合明确的节点本地语义,不应只为“更快”盲目启用。
11.3 Topology Aware Routing
拓扑提示可帮助按Zone分流、降低跨区成本,但需要端点和容量分布合理。某区流量超过本区容量时,强行本地化会把少量Pod打满。功能名称和约束随Kubernetes版本演进,应按目标版本验证。
十二、与Nacos、Consul、Dubbo、etcd比较
| 方案 | 地址由谁维护 | 流量由谁选择 | 典型适用 |
|---|---|---|---|
| ClusterIP Service | EndpointSlice控制链 | Node四层数据面 | 普通HTTP/TCP微服务 |
| Headless Service | DNS/EndpointSlice | 应用客户端 | StatefulSet、客户端LB |
| Nacos | 注册中心 | Spring Cloud或Dubbo客户端 | Java微服务治理 |
| Consul | Catalog/Health | DNS、客户端或Connect代理 | VM/K8s混合、多DC |
| Dubbo | RegistryDirectory | Dubbo Cluster/LoadBalance | Java RPC |
| etcd | Prefix/Lease/Watch | 自定义客户端 | 控制面和协调 |
| Service Mesh | xDS等控制面 | Sidecar或节点代理 | 多语言流量治理和mTLS |
ClusterIP是平台侧稳定寻址和四层连接转发;Nacos/Dubbo是客户端维护实例列表并选择;Mesh是代理侧维护路由和Endpoint。可以组合,但每叠一层都要明确谁负责负载、重试、熔断和观测。
十三、Java 8/Boot 2与Java 17+/Boot 3怎样接入
13.1 Java 8与Boot 2.x存量基线
Boot 2.7、Spring Cloud 2021.x和JDK 8服务可直接通过Service DNS调用:
inventory.base-url=http://inventory.trade.svc.cluster.local也可使用Spring Cloud Kubernetes DiscoveryClient取得Pod实例,再由Spring Cloud LoadBalancer选择。必须确认是否同时又访问ClusterIP,否则会形成双层负载,排查和重试边界更复杂。
13.2 Java 17+与Boot 3.x当前基线
Boot 3基于Spring Framework 6,要求Java 17起步并迁移到Jakarta命名空间。Kubernetes发现对象链没有改变,但依赖坐标、Spring Cloud发行列、Actuator探针、Micrometer Observation和Kubernetes客户端兼容性必须按版本矩阵选择。
management:
endpoint:
health:
probes:
enabled: true
health:
livenessstate:
enabled: true
readinessstate:
enabled: true不要把Boot 2教程的javax.*依赖直接复制到Boot 3,也不要混用不兼容的Spring Cloud发行列。迁移原理见Java 17+与Spring Boot 3.x专题。
13.3 OpenFeign边界
@FeignClient(name = "inventoryClient", url = "${inventory.base-url}")
public interface InventoryClient {
@GetMapping("/internal/inventory/{sku}")
InventoryView get(@PathVariable("sku") String sku);
}URL模式把目标当稳定Service地址,Pod选择由Kubernetes数据面完成;DiscoveryClient模式由客户端选实例。两者都合理,但架构文档必须明确地址来源、选择层、重试层、Deadline预算、连接刷新和指标目标。
十四、JDK 8 Demo:连接级选择与扩容
下面用纯Java模拟:新建连接时从Endpoint快照选择一次,连接上的请求继续使用同一后端。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class ConnectionLevelLoadDemo {
static final class EndpointSnapshot {
private volatile List<String> endpoints;
private final AtomicInteger cursor = new AtomicInteger();
EndpointSnapshot(String... values) { replace(values); }
void replace(String... values) {
endpoints = new ArrayList<String>(Arrays.asList(values));
}
String openConnection() {
List<String> current = endpoints;
int index = Math.floorMod(cursor.getAndIncrement(), current.size());
return current.get(index);
}
}
public static void main(String[] args) {
EndpointSnapshot snapshot = new EndpointSnapshot("pod-A", "pod-B");
String oldChannel = snapshot.openConnection();
for (int i = 1; i <= 3; i++) {
System.out.println("old-channel request " + i + " -> " + oldChannel);
}
snapshot.replace("pod-A", "pod-B", "pod-C");
System.out.println("expanded, old channel -> " + oldChannel);
System.out.println("new channel -> " + snapshot.openConnection());
System.out.println("new channel -> " + snapshot.openConnection());
System.out.println("new channel -> " + snapshot.openConnection());
}
}地址快照更新和连接迁移是两件事。扩容只改变后续可选集合;旧Channel若一直复用,不会自动把请求迁移给新Pod。Demo兼容JDK 8;Java 17+可使用更现代语法,但连接边界相同。
十五、商业场景:订单服务滚动升级
- 新Pod启动,startup成功后才执行正常readiness。
- Readiness通过,EndpointSlice出现新端点,各Node逐步同步。
- 新TCP连接开始命中新Pod,旧Keep-Alive仍在旧Pod。
- 监控按版本观察错误率、P99、连接数和下游调用。
- 旧Pod进入终止,先摘流并停止接受新业务。
- 在途支付确认必须完成或转为可查询状态,不能粗暴中断。
- 客户端逐步关闭旧连接,旧Pod在宽限期内退出。
新旧实例会同时在线,因此HTTP契约、数据库Schema和MQ事件必须向前、向后兼容。Deployment发布成功不等于业务兼容。
十六、对象与网络取证命令
kubectl -n trade get svc order-api -o yaml
kubectl -n trade get endpointslice \
-l kubernetes.io/service-name=order-api -o yaml
kubectl -n trade get pod -l app=order-api -o wide
kubectl -n trade describe pod <pod-name>
kubectl -n trade exec <client-pod> -- cat /etc/resolv.conf
kubectl -n trade exec <client-pod> -- nslookup order-api.trade.svc.cluster.local
kubectl -n trade exec <client-pod> -- curl -sv http://order-api/actuator/health/readiness
kubectl -n kube-system get pod -l k8s-app=kube-dns -o wide
kubectl -n trade get events --sort-by=.lastTimestamp数据面命令必须按实现选择:iptables看规则链,IPVS看虚拟服务,Cilium等eBPF实现看Agent和BPF Map。业务容器也不一定具备这些工具和权限。
十七、生产Runbook
17.1 Service域名NXDOMAIN
- 使用完整FQDN排除搜索域问题。
- 查看Service是否存在、Namespace是否正确。
- 检查调用Pod的
resolv.conf、ndots和DNS地址。 - 查看CoreDNS Pod、日志和Service。
- 检查UDP/TCP 53是否被NetworkPolicy阻断。
- 检查负缓存和应用Resolver缓存。
17.2 DNS成功但ClusterIP超时
- 确认返回预期ClusterIP。
- 查看Service的
port/targetPort/protocol。 - 合并查看全部EndpointSlice和Ready地址。
- 直连PodIP:targetPort,区分应用与Service数据面。
- 对比同Node和跨Node访问,定位CNI、路由或MTU。
- 检查kube-proxy/eBPF Agent同步、NetworkPolicy和Conntrack。
17.3 Pod Ready却返回503
- 确认503由应用、Ingress还是Mesh产生。
- 检查Readiness是否固定返回200。
- 对照EndpointSlice端口与真实监听端口。
- 查看线程池、连接池、GC、下游超时和熔断。
- 按版本PodIP定点请求,定位坏实例。
17.4 滚动发布少量失败
- 按版本、Pod、Node、调用方和连接类型拆分错误。
- 对齐DeletionTimestamp、Ready、EndpointSlice、SIGTERM和连接关闭时间线。
- 检查preStop、优雅关闭和宽限期是否覆盖最大在途时间。
- 检查Ingress、Mesh和客户端是否继续复用旧连接。
- 检查新旧接口、Schema和事件兼容性。
- 写请求必须幂等并支持结果查询。
17.5 扩容后新Pod没有流量
- 确认新Pod进入全部EndpointSlice且Ready。
- 查看客户端TCP连接数量和生命周期。
- 判断是否HTTP/2或gRPC多路复用。
- 检查SessionAffinity、拓扑策略和客户端缓存。
- 观察新连接分布,不只看总请求数。
- 逐步调整连接池,避免每请求握手风暴。
17.6 删除Pod仍收到请求
- 确认是否仍在终止宽限期。
- 查看EndpointSlice的
ready/serving/terminating。 - 区分新连接和旧长连接。
- 检查Headless DNS、直接PodIP和旧快照。
- 检查Ingress、LB、Mesh的Drain。
十八、常见错误与后果
| 错误做法 | 后果 | 正确方向 |
|---|---|---|
| 把Running当Ready | 初始化中的Pod提前接流量 | startup与readiness分工 |
| Readiness固定返回200 | 线程池耗尽仍接流量 | 建立有边界的接流契约 |
| 所有下游都放进Liveness | 下游抖动引发全体重启 | 存活只判断本进程 |
| 认为Service按请求轮询 | 长连接下流量倾斜 | 按连接池和协议分析 |
| Headless只解析一次 | 下线Pod长期残留 | TTL、快照刷新、失败剔除 |
| 多层都独立重试 | 重试乘法压垮下游 | 统一Deadline和重试预算 |
| 只看Pod不看Slice | 错过端口和传播问题 | 沿对象链取证 |
| 直接配置PodIP | 重建后失效 | 使用稳定发现机制 |
| 用亲和保存登录 | Pod下线导致会话丢失 | Token或共享状态 |
| 终止时立即杀进程 | 在途请求中断、结果未知 | 摘流、排空、退出 |
十九、面试回答主线
完整回答应按以下顺序:Service提供稳定身份;EndpointSlice Controller形成动态后端;CoreDNS解析名称;kube-proxy或eBPF为新连接选择Endpoint;Conntrack和连接池形成连接级粘性;Readiness和摘流异步传播且已有连接不会自动迁移;生产按DNS、Service、Slice、Pod、Node数据面和应用逐层取证。
标准回答见Kubernetes服务发现面试题。
二十、关联知识点
- Kubernetes网络
- Kubernetes探针
- Kubernetes架构
- Consul服务发现
- etcd与List-Watch
- Dubbo注册发现
- Spring Cloud调用链
- Java 17+与Spring Boot 3.x
本章小结
Kubernetes服务发现的本质是“声明对象、控制器收敛、名称解析、节点数据面和应用连接”的协作。Service解决稳定身份,EndpointSlice保存动态后端,CoreDNS解决名称,kube-proxy或eBPF完成四层转发;Readiness和终止沿异步链路传播,无法瞬间消除缓存和已有连接。生产设计必须同时覆盖对象状态、网络数据面、协议连接和业务幂等。
