蛮荆

Kubernetes Service 类型和会话亲和性

2023-10-16

Service 概述

Service 被抽象为一组或多组 Pod 的静态 IP 地址 (也可以将 Service 看作是一个代理),任何到达 Service 的请求都会被转发到属于该服务背后的某个 Pod 中的应用。

与 Service 紧密关联的两个概念是 Pod 和 EndPoint, 其中,Pod 是 Kubernetes 中应用运行的最小单位,而 EndPoint 是 Service 指向的实际地址,定义了 Service 所指向的具体 Pod 的 IP 地址和端口。

EndPoint 二元组 = Pod IP + Pod Port

  • 创建一个 Service 时,Kubernetes 会自动创建关联的 EndPoint
  • EndPoint 中包含了 Service 对应的所有 Pod 的 IP 地址和端口
  • 当有 Pod 加入 Service 或者从 Service 的中删除时 (通过标签选择器),Kubernetes 会自动更新 EndPoint 相关信息

三者的关系如下图所示:

Service, EndPoint, Pod 关系图

上面的图中定义了 2 个 Service, 每个 Service 有 1 个 EndPoint, 每个 EndPoint 包含 3 个 Pod 的网络位置。

查看 Service 代理的 Pod

可以通过 kubectl describe 命令查看 Service 代理了哪些后端 Pod:

$ kubectl describe svc svc-name

Name:              api-demo-svc
Namespace:         default
Labels:            <none>
Annotations:       <none>
Selector:          app=api-demo
Type:              ClusterIP
IP Family Policy:  SingleStack
IP Families:       IPv4
IP:                None
IPs:               None
Port:              api-demo-svc  8080/TCP
TargetPort:        8080/TCP

# 代理的 Pod IP 地址列表
Endpoints:         172.24.102.212:8080,172.24.102.59:8080,172.24.102.23:8080 + 2 more...

Session Affinity:  None
Events:            <none>

通过输出结果中的 Endpoints 字段,可以看到 Service 代理的后端 Pod IP 地址列表。

EndpointSlice

每个 Service 可以关联一个或多个 Endpoint,随着集群规模的扩增,单个 EndPoint 对象可能会变得非常巨大,当 Service 关联很多服务 Pod 时, EndPoint 限制了关联的 Pod 数量,因为 etcd 中存储的对象默认大小限制为 1.5 MB。

此外,频繁地扩容和更新操作会引发性能和扩展限制,例如某个 Service 关联了 5000 个 Pod, 最终资源数据大小为 1.5 MB, 即使这 5000 个 Pod 中只有一个 Pod 发生了变化, 也需要将更新后的完整资源分发到集群中的每个 Node 节点,假设集群节点个数为 3000, 那么单次更新时,发送的数据量为:

3000 * 1.5 MB = 4.5 GB

即使节点全部位于同一数据中心,也是一笔不小的网络开销,而且只要有任一 Pod 更新时都会产生 4.5 GB 流量,想象一下,如果超过一半以上的 Pod 发生变化时, 仅仅是同步 EndPoint 产生的流量就是 TB 级别的,为了解决这个问题,官方为 EndPoint 新增了一个扩展: EndpointSlice。

EndpointSlice 采用分组的方式,将单个 EndPoint 中的多个 Pod 分成多个切片 (Slice), 每个 Slice 包含了一部分 Pod,并包含了这些 Pod 的 IP 地址和端口。:

下图中的 EndPoint 包含 15 个 Pod, 通过每 5 个 Pod 逐个分组,最终得到 3 个不同的 EndpointSlice。

EndpointSlice 示例

EndpointSlice 通过索引和标签等元数据与 Service 关联起来,可以更高效地分发 Pod 的变更数据,降低 Endpoint 在大规模集群中扩展和更新操作时的负载, 同时也解决了 etcd 的默认大小限制问题。

最佳实践

中大规模集群中使用 EndpointSlice API 替换 Endpoints, Kubernetes 从 1.19 版本开始默认启用 EndpointSlice。


Service 示例

apiVersion: v1
kind: Service
metadata:
  name: user-service
spec:
  selector:
    app: api-user
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80

上面的声明式 yaml 代码将创建一个名称为 user-service 的 Service, 指向标签中包含 name:api-uesr 的所有 Pod 都在监听的 80 端口。 Service 可以随意将 spec.ports.port 端口映射到 spec.ports.targetPort, 不过实际开发中为了方便,两者的端口值会保持一致。

最佳实践

Pod 中的端口可以定义名字,所以 Service 的 spec.ports.targetPort 字段可以直接指向 Pod 的端口名字。

apiVersion: v1
kind: Pod
metadata:
  name: nginx
  labels:
    app: api-user
spec:
  containers:
  - name: nginx
    image: nginx:stable
    ports:
      - containerPort: 80
        name: http-web-svc # 定义 Pod 端口名字

---

apiVersion: v1
kind: Service
metadata:
  name: nginx-service
spec:
  selector:
    app: api-user
  ports:
    - protocol: TCP
      port: 80
      targetPort: http-web-svc # 使用 Pod 的端口名字

通过使用 Pod 的端口名字而不是端口号,可以为 Service 提供更高的灵活性,例如可以将 Pod 的声明端口从 80 改为 443, 而 Service 的声明无需任何修改。


Service 类型

Service 的类型分为以下四种。

1. ClusterIP

默认的 Service 类型,自动分配一个仅在集群内部可以访问的虚拟 IP 地址,可以通过标签选择器关联对应的 Pod, 当不需要负载均衡时,也就不需要单独的虚拟 IP, 可以使用 Headless Services, 将 ClusterIP 的值设置为 “None” 即可。

# Headless Service 示例
apiVersion: v1
kind: Service
metadata:
  name: test-headless-service
spec:
  clusterIP: None
  selector:
    app: test-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080

kube_proxy 会忽略 Headless Services 类型,DNS 会根据 Headless Services 是否定义了标签选择符来配置不同的解析行为:

  • 对于定义了标签选择符 的 Headless Services, Kubernetes 控制面板会创建对应的 EndPoint, 并且 DNS 解析返回 Service 标签选择对应的 Pod 的 IP 地址集合
  • 对于未定义标签选择符 的 Headless Services, spec.portspec.targetPort 必须相等, Kubernetes 控制面板不会创建对应的 EndPoint, 并且 DNS 会根据 Service 类型执行不同的行为:
    • 如果 Service 类型等于 ExternalName, 查找和解析对应的 CNAME 记录
    • 如果 Service 类型不等于 ExternalName, 针对 Service 已有的 EndPoint 的所有 IP 地址, 查找和配置 DNS A(IPV4), AAAA(IPV6) 记录

最佳实践

  • Headless Services 配合 StatefulSet 有状态应用,StatefulSet 可以确保每个 Pod 都有唯一的标识符和稳定的网络标识,可以满足与特定 Pod 直接通信的业务场景
  • 通过 Headless Services, 每个 Pod 都会获得一个 DNS 记录,格式为 <pod-name>.<headless-service-name>.<namespace>.svc.cluster.local, 这样就可以通过这条 DNS 记录直接访问某个 Pod
  • 通过给 Headless Services 设置标签选择器,来决定需要暴露和不需要暴露的 Pod 集合

2. NodePort

NodePort 在 ClusterIP 的基础上增加了对集群外部访问的能力,默认情况下,通过在每个节点上随机选择一个端口 (范围 30000-32767) 映射到 Service, 并将流量转发到 Service 关联的 Pod 上面。

这样就可以通过 <ClusterIP>:<ServicePort> 或者 <NodeIP>:<NodePort> 来访问 Service, 例如现在有一个 Service 名称为 user-svc,类型为 NodePort,分配到的 ClusterIP 为 172.17.0.4, 同时集群有 3 个工作节点并将各自的 31234 端口映射到了 user-svc, 此时可以直接请求 172.17.0.4:31234 来访问,最终流量会转发到具体的 Pod 中。

NodePort 示例

注意: 如果要使用 <NodeIP>:<NodePort> 方式访问 Service, 需要确保 <NodeIP> 对应的节点上面有 Service 关联的 Pod 在运行。

最佳实践

  • 可以在集群外搭建一个反向代理实现负载均衡,将流量分发到不同的节点上,但更好的方案是使用 Ingress 替代 NodePort Service
  • 由于 NodePort Service 是直接暴露在公网上的,可以使用网络策略 NetworkPolicy 限制从集群外部访问 Service 的源 IP 地址和端口,增强 Service 安全性
  • 指定 NodePort 映射端口避免端口冲突 (内核文件 net.ipv4.ip_local_port_range 默认端口范围 32768 - 60999, 表示任意进程可以使用的端口号)
# 指定 NodePort 映射端口示例 

apiVersion: v1
kind: Service
metadata:
  name: test-headless-service
spec:
  type: NodePort
  selector:
    app: test-app
  ports:
    - port: 80
      targetPort: 80
      # 指定端口为 31234
      nodePort: 31234

3. LoadBalancer

在 NodePort 的基础上,基于云服务提供商创建一个外部的负载均衡器,并将流量从负载均衡转发到集群中的 Pod, 具体的内部负载均衡和流量转发过程由云服务提供商实现。

最佳实践

因为 LoadBalancer 是由云计算服务商提供的,所以使用时要确保为负载均衡器分配了足够的资源。

4. ExternalName

将 Service 映射到集群外部的 CNAME 记录,通常用于将集群内部的服务映射到外部的 DNS 名称,隐藏集群内部的细节。

apiVersion: v1
kind: Service
metadata:
  name: test-external-service
  namespace: prod
spec:
  type: ExternalName
  externalName: dns.example.com

使用了上面的声明式服务定义之后,在集群内部访问服务 test-external-service 时,集群 DNS 服务返回 CNAME 记录 dns.example.com,

最佳实践

  • 确保集群内 DNS 服务 (例如 CoreDNS) 配置和解析正确,同时确保外部 DNS 服务没有单点故障
  • 避免过度使用,因为 ExternalName 不是常规的集群内部通信方式

会话亲和性

默认情况下,单个客户端每次请求都会通过服务随机转发到一个 Pod 中,多次请求可能会转发到多个 Pod, 这对 “用户专有数据” 业务场景非常不太友好, 本来单个用户的数据只需要在一个 Pod 中有缓存数据即可,现在的结果可能是单个用户的数据在大多数 Pod 中都有缓存数据,造成大量的资源浪费。

可以通过设置服务亲和性 (sessionAffinity) 来制定单个客户端的每次请求都转发到同一个 Pod, Kubernetes 仅支持两种亲和性类型:

  • ClientIP
  • None
apiVersion: v1
kind: Service
metadata:
  name: test-service
spec:
  selector:
    app: user-api
  sessionAffinity: ClientIP # 基于客户端 IP 设置亲和性
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 1800  # 设置亲和性 IP 有效时间为 1800 秒

注意: 使用亲和性规则可能会导致 Pod 负载不均衡,因为大部分业务场景中,流量的地域特征符合 “2/8 原则”。

Service 本质上是一个由 kube-proxy 控制的四层负载均衡,在 TCP/IP 协议栈上转发流量,所以提供的功能十分有限,只能根据 IP + Port 做一些简单的判断和组合

如果希望使用 HTTP Header, Token, Cookie 等七层网络字段值作为服务亲和性类型,可以使用 Ingress Controller 提供的会话亲和性功能。

小结

类型 优点 缺点 适用场景
ClusterIP 默认类型,简单易用 集群外部无法访问 Service 集群内部进行服务发现和负载均衡
Headless 支持 DNS 记录解析 不支持负载均衡,需要配合其他方式实现 (如反向代理转发) StatefulSet 有状态服务 (例如数据库集群)
NodePort 支持集群外部直接访问 暴露了端口号,可能引发安全问题 远程调试或与第三方进行集成测试
LoadBalancer 直接集成云计算服务商的负载均衡器 1. 费用 2. 负载均衡机制是黑盒,无法定制调度过程 公开服务,一般会使用 Ingress 替换 LoadBalancer Service 类型
ExternalName 将 Service 映射到外部 DNS 服务 集群内部无法直接访问 Service 集群内部服务与外部 DNS 简单映射

Reference

扩展阅读

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。