Skip to content

Kubernetes服务发现:Service、EndpointSlice、CoreDNS与流量传播原理

Kubernetes服务发现不是“注册一个服务名,然后自动负载均衡”这么简单。它是一条由多个控制器、缓存和数据面共同完成的异步链路:Pod标签被Service选择,EndpointSlice Controller生成后端快照,CoreDNS提供名称解析,kube-proxy或eBPF数据面把Service虚拟地址映射到Pod,而Readiness、滚动发布、连接复用和DNS缓存又决定流量何时真正切换。

本页站在微服务调用者视角讲清楚“地址从哪里来、请求为什么这样走、实例下线为什么不是瞬时生效”。包转发、CNI、NetworkPolicy的更底层内容见Kubernetes网络专栏,探针执行机制见Kubernetes探针专栏

一、学习目标

学完后应能独立解释和排查:

  1. Service、EndpointSlice、CoreDNS、kube-proxy分别负责什么。
  2. Pod从启动到真正接收Service流量经历哪些异步步骤。
  3. ClusterIP为什么不是普通监听地址,iptables、IPVS和eBPF有什么边界。
  4. Service为什么通常按连接而不是按HTTP请求选择Pod。
  5. HTTP Keep-Alive、HTTP/2和gRPC为什么会造成流量倾斜。
  6. Headless Service为什么属于客户端发现。
  7. readyservingterminating分别表达什么。
  8. Pod终止时为什么已有连接仍可能继续请求旧实例。
  9. Spring Cloud客户端负载均衡与Kubernetes Service为什么不能盲目叠加。
  10. 如何从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连接。
  • 调用超时仍可能是“结果未知”,业务写操作仍需幂等和查询确认。

三、标签怎样变成可调用地址

mermaid
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没有后端。

yaml
apiVersion: v1
kind: Service
metadata:
  name: order-api
  namespace: trade
spec:
  selector:
    app: order-api
  ports:
    - name: http
      port: 80
      targetPort: http

Pod模板必须提供匹配标签和同名端口:

yaml
spec:
  template:
    metadata:
      labels:
        app: order-api
    spec:
      containers:
        - name: order-api
          image: example/order-api:1.0.0
          ports:
            - name: http
              containerPort: 8080

containerPort主要是声明和工具元数据,并不会让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启动到接收流量的完整过程

mermaid
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请求完整怎么走

mermaid
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
eBPFBPF Map和内核程序实现Service转发CNI专用CLI、BPF Map、Agent状态

不能在eBPF集群中因为找不到预期iptables链就断言Service坏了。先确认集群数据面和版本,再选取证命令。

七、为什么Service常常是连接级负载均衡

四层数据面通常在TCP连接建立时选定一个Endpoint,并由Conntrack维持映射。同一连接后续承载的多个HTTP请求通常继续到同一个Pod。

mermaid
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负载不均怎么治理

  1. 创建足够但有上限的Channel或子连接,使连接能覆盖后端。
  2. 使用支持Endpoint解析和客户端负载均衡的gRPC resolver,而不是只解析一个ClusterIP。
  3. 使用Headless Service,把端点交给客户端管理。
  4. 使用Service Mesh代理做请求或连接层治理。
  5. 控制连接最大生命周期或空闲时间,让扩缩容后逐步重建。

不能每个请求都新建连接,这会引入TCP/TLS握手、临时端口和CPU成本。也不能让客户端、代理和服务端同时无预算重试,否则一次业务请求会被乘法放大。

八、Headless Service:把选择权交给客户端

yaml
apiVersion: v1
kind: Service
metadata:
  name: mysql
  namespace: data
spec:
  clusterIP: None
  selector:
    app: mysql
  ports:
    - name: mysql
      port: 3306

Headless没有普通ClusterIP转发,DNS可以返回后端Pod地址或StatefulSet成员记录。它适合数据库、Kafka等成员发现,以及具备Endpoint快照和客户端负载能力的RPC客户端。

它不天然更快,而是把以下责任交给客户端:

  • 合并和刷新地址列表。
  • 尊重DNS TTL并处理负缓存。
  • 选择实例和复用连接。
  • 地址失败时熔断、移除和重试。
  • 处理扩缩容、升级和陈旧地址。

如果Java应用只在启动时解析一次DNS并永久保存数组,下线实例会长期残留。JVM、操作系统、DNS和应用连接池都可能持有旧状态,必须结合运行时及客户端实现检查缓存。

九、CoreDNS与名称解析

同Namespace常用短名inventory,跨Namespace推荐inventory.trade,完整名称通常类似:

text
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下线与滚动发布的真实时间线

mermaid
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 ServiceEndpointSlice控制链Node四层数据面普通HTTP/TCP微服务
Headless ServiceDNS/EndpointSlice应用客户端StatefulSet、客户端LB
Nacos注册中心Spring Cloud或Dubbo客户端Java微服务治理
ConsulCatalog/HealthDNS、客户端或Connect代理VM/K8s混合、多DC
DubboRegistryDirectoryDubbo Cluster/LoadBalanceJava RPC
etcdPrefix/Lease/Watch自定义客户端控制面和协调
Service MeshxDS等控制面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调用:

properties
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客户端兼容性必须按版本矩阵选择。

yaml
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边界

java
@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快照选择一次,连接上的请求继续使用同一后端。

java
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+可使用更现代语法,但连接边界相同。

十五、商业场景:订单服务滚动升级

  1. 新Pod启动,startup成功后才执行正常readiness。
  2. Readiness通过,EndpointSlice出现新端点,各Node逐步同步。
  3. 新TCP连接开始命中新Pod,旧Keep-Alive仍在旧Pod。
  4. 监控按版本观察错误率、P99、连接数和下游调用。
  5. 旧Pod进入终止,先摘流并停止接受新业务。
  6. 在途支付确认必须完成或转为可查询状态,不能粗暴中断。
  7. 客户端逐步关闭旧连接,旧Pod在宽限期内退出。

新旧实例会同时在线,因此HTTP契约、数据库Schema和MQ事件必须向前、向后兼容。Deployment发布成功不等于业务兼容。

十六、对象与网络取证命令

bash
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

  1. 使用完整FQDN排除搜索域问题。
  2. 查看Service是否存在、Namespace是否正确。
  3. 检查调用Pod的resolv.confndots和DNS地址。
  4. 查看CoreDNS Pod、日志和Service。
  5. 检查UDP/TCP 53是否被NetworkPolicy阻断。
  6. 检查负缓存和应用Resolver缓存。

17.2 DNS成功但ClusterIP超时

  1. 确认返回预期ClusterIP。
  2. 查看Service的port/targetPort/protocol
  3. 合并查看全部EndpointSlice和Ready地址。
  4. 直连PodIP:targetPort,区分应用与Service数据面。
  5. 对比同Node和跨Node访问,定位CNI、路由或MTU。
  6. 检查kube-proxy/eBPF Agent同步、NetworkPolicy和Conntrack。

17.3 Pod Ready却返回503

  1. 确认503由应用、Ingress还是Mesh产生。
  2. 检查Readiness是否固定返回200。
  3. 对照EndpointSlice端口与真实监听端口。
  4. 查看线程池、连接池、GC、下游超时和熔断。
  5. 按版本PodIP定点请求,定位坏实例。

17.4 滚动发布少量失败

  1. 按版本、Pod、Node、调用方和连接类型拆分错误。
  2. 对齐DeletionTimestamp、Ready、EndpointSlice、SIGTERM和连接关闭时间线。
  3. 检查preStop、优雅关闭和宽限期是否覆盖最大在途时间。
  4. 检查Ingress、Mesh和客户端是否继续复用旧连接。
  5. 检查新旧接口、Schema和事件兼容性。
  6. 写请求必须幂等并支持结果查询。

17.5 扩容后新Pod没有流量

  1. 确认新Pod进入全部EndpointSlice且Ready。
  2. 查看客户端TCP连接数量和生命周期。
  3. 判断是否HTTP/2或gRPC多路复用。
  4. 检查SessionAffinity、拓扑策略和客户端缓存。
  5. 观察新连接分布,不只看总请求数。
  6. 逐步调整连接池,避免每请求握手风暴。

17.6 删除Pod仍收到请求

  1. 确认是否仍在终止宽限期。
  2. 查看EndpointSlice的ready/serving/terminating
  3. 区分新连接和旧长连接。
  4. 检查Headless DNS、直接PodIP和旧快照。
  5. 检查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服务发现的本质是“声明对象、控制器收敛、名称解析、节点数据面和应用连接”的协作。Service解决稳定身份,EndpointSlice保存动态后端,CoreDNS解决名称,kube-proxy或eBPF完成四层转发;Readiness和终止沿异步链路传播,无法瞬间消除缓存和已有连接。生产设计必须同时覆盖对象状态、网络数据面、协议连接和业务幂等。