Service
Kubernetes 中的 Service 将一组 Pod 以统一的形式对外暴露成一个服务,它利用运行在内核空间的 iptables 或者 ipvs 高效地转发来自节点内部和外部的流量。作为非常重要的 Kubernetes 对象,Service 不仅在逻辑上提供了微服务的概念,还引入 LoadBalancer 类型的 Service 无缝对接云服务商提供的复杂资源。
Background
我们知道,Kubernetes 中的每一个 Pod 都可以通过 podIP 被直接访问,但是 Pod 是有生命周期的对象,它们可以被创建,而且销毁之后不会再启动。如果 Deployment、ReplicaSet 等对象管理 Pod,则它们可以动态地创建和销毁 Pod。在这种情况下,Deployment当前时刻运行的 Pod 集合可能与稍后运行该应用程序的 Pod 集合不同。
这就造成了一个问题,如果一组 backend Pod 为集群中的另一组frontend Pod 提供服务时,由于每一个 Pod 都有自己的IP地址,并且这组Pod是会动态变化的,那么Frontend如何做服务发现以及会话保持,从而可以使用 Backend Pod 的服务?
为了解决这个问题,Kubernetes提出了 Service 这个概念:Service 是一组 Pod的逻辑集合和访问方式的抽象。举个例子,考虑一个图片处理后端,它运行了 3 个副本。这些副本是可互换的 —— 前端不需要关心它们调用了哪个后端副本。 然而组成这一组后端程序的 Pod 实际上可能会发生变化, 前端客户端不应该也没必要知道,而且也不需要跟踪这一组后端的状态。 Service 定义的抽象能够解耦这种关联。
Introduction
下面是一个 Service 的典型定义:
|
|
这里注意几个Port的定义区分:
- port:service 暴露在cluster ip上的端口,
<cluster ip>:port是提供给集群内部客户访问service的入口 - targetPort:Pod监听的端口,service会把流量转发到对应的Pod,Pod中的容器也需要监听这个端口
- nodePort:对应于 NodePort 类型的Service时指定的节点上的Port,详见 NodePort
在命令行中可以看到集群为Service创建了一个 ClusterIP
|
|
创建 Nginx 实际对应的 Deployment:
|
|
对应于实际服务请求的路径如下图所示:
Publish Services
Kubernetes 中支持四种服务暴露的方式: ClusterIP、NodePort、LoadBalancer、ExternelName
ClusterIP
ClusterIP 类型的 service 是 kubernetes 集群默认的服务暴露方式,它只能用于集群内部通信,可以被各 pod 访问,其访问方式为:
pod ---> ClusterIP:ServicePort --> (iptables)DNAT --> PodIP:containePort
ClusterIP Service 类型的结构如下图所示:
Headless service
当不需要负载均衡以及单独的 ClusterIP 时,可以通过指定 spec.clusterIP 的值为 None 来创建 Headless service,它会给一个集群内部的每个成员提供一个唯一的 DNS 域名来作为每个成员的网络标识,集群内部成员之间使用域名通信。
|
|
NodePort
如果想要在集群外访问集群内部的服务,可以使用这种类型的 service,NodePort 类型的 service 会在集群内部署了 kube-proxy 的每个节点打开一个指定的端口,之后所有的流量直接发送到这个端口,然后会被转发到 service 后端真实的服务进行访问。Nodeport 构建在 ClusterIP 上,其访问链路如下所示:
client ---> NodeIP:NodePort ---> ClusterIP:ServicePort ---> (iptables)DNAT ---> PodIP:containePort
其对应具体的 iptables 规则会在后文进行讲解。
NodePort service 类型的结构如下图所示:
修改 service 定义如下,其中 nodeport 字段表示通过 nodeport 方式访问的端口,port 表示通过 service 方式访问的端口,targetPort 表示 pod port。如果这里的 nodePort 字段不指定,Kubernetes会自动申请一个Node Port。
|
|
执行 kubectl get service 可以看到:
|
|
这时候,在浏览器中选择集群中任意一节点的IP作为 nodeIP,通过浏览器 http://<nodeIP>:<nodePort> 可以看到 Nginx 的欢迎界面。
LoadBalancer
LoadBalancer 类型的 service 通常和云厂商的 LB 结合一起使用,用于将集群内部的服务暴露到外网,云厂商的 LoadBalancer 会给用户分配一个 IP,之后通过该 IP 的流量会转发到你的 service 上。
LoadBalancer service 类型的结构如下图所示:
|
|
ExternelName
类型为 ExternalName 的服务将服务映射到 DNS 名称,而不是典型的选择器,例如 my-service 或者 cassandra。 您可以使用 spec.externalName 参数指定这些服务。
例如,以下 Service 定义将 prod 名称空间中的 my-service 服务映射到 my.database.example.com:
|
|
当查找主机 my-service.prod.svc.cluster.local 时,集群 DNS 服务返回 CNAME 记录, 其值为 my.database.example.com。 访问 my-service 的方式与其他服务的方式相同,但主要区别在于重定向发生在 DNS 级别,而不是通过代理或转发。 如果以后您决定将数据库移到群集中,则可以启动其 Pod,添加适当的选择器或端点以及更改服务的 type。
Controllers
Service 是一组具有相同 label pod 集合的抽象,集群内外的各个服务可以通过 service 进行互相通信。在 Kubernetes 中创建一个新的 Service 对象需要两大模块同时协作:
- Controller:在每次创建新的 Service 对象时,会同时创建一个 Endpoint 对象。Endpoint 是用于容器发现,Service 只是将多个 Pod 进行关联。Endpoints Controller 是负责生成和维护所有 Endpoints 对象的控制器,监听 Service 和对应 Pod 的变化,更新对应 Service 的 Endpoints 对象。当 Pod 处于 running 且准备就绪时,Endpoints Controller 会将 Pod IP 记录到 Endpoints 对象中。
- kube-proxy:它运行在 Kubernetes 集群中的每一个节点上,会根据 Service 和 Endpoint 的变动改变节点上 iptables 或者 ipvs 中保存的规则。
Service
每当有服务被创建或者销毁时,Informer 都会通知 ServiceController,它会将这些任务投入工作队列中并由其本身启动的 Worker 协程消费:
sequenceDiagram
participant I as Informer
participant SC as ServiceController
participant Q as WorkQueue
participant B as Balancer
I->>+SC: Add/Update/DeleteService
SC->>Q: Add
Q-->>SC: return
deactivate SC
loop Worker
SC->>+Q: Get
Q-->>-SC: key
SC->>SC: syncService
SC->>+B: EnsureLoadBalancer
B-->>-SC: LoadBalancerStatus
end
Endpoint
我们在使用 Kubernetes 时虽然很少会直接与 Endpoint 资源打交道,但是它却是 Kubernetes 中非常重要的组成部分。服务和pod不是直接连接,而是通过Endpoint资源 进行连通。
- Endpoint 资源是暴露一个服务的ip地址和port的列表。
- 选择器用于构建ip和port列表,然后存储在endpoint资源中。
- 当客户端连接到服务时,服务代理选择这些列表中的ip和port对中的一个,并将传入连接重定向到在该位置监听的服务器。
- endpoint是一个单独的资源并不是服务的属性,endpoint的名称必须和服务的名称相匹配。
|
|
EndpointController 本身并没有通过 Informer 监听 Endpoint 资源的变动,但是它却同时订阅了 Service 和 Pod 资源的增删事件,对于 Service 资源来讲,EndpointController 会通过以下的方式进行处理:
sequenceDiagram
participant I as Informer
participant EC as EndpointController
participant Q as WorkQueue
participant PL as PodLister
participant C as Client
I->>+EC: Add/Update/DeleteService
EC->>Q: Add
Q-->>EC: return
loop Worker
EC->>+Q: Get
Q-->>-EC: key
EC->>+EC: syncService
EC->>+PL: ListPod(service.Spec.Selector)
PL-->>-EC: Pods
loop Every Pod
EC->>EC: addEndpointSubset
end
EC->>+C: Create/UpdateEndpoint
C-->>-EC: result
end
EndpointController 中的 syncService 方法是用于创建和删除 Endpoint 资源最重要的方法,在这个方法中我们会根据 Service 对象规格中的选择器 Selector 获取集群中存在的所有 Pod,并将 Service 和 Pod 上的端口进行映射生成一个 EndpointPort 结构体:
|
|
对于每一个 Pod 都会生成一个新的 EndpointSubset,其中包含了 Pod 的 IP 地址和端口和 Service 的规格中指定的输入端口和目标端口,在最后 EndpointSubset 的数据会被重新打包并通过客户端创建一个新的 Endpoint 资源。
在上面我们已经提到过,除了 Service 的变动会触发 Endpoint 的改变之外,Pod 对象的增删也会触发 EndpointController 中的回调函数。
sequenceDiagram
participant I as Informer
participant EC as EndpointController
participant Q as WorkQueue
participant SL as ServiceLister
I->>+EC: Add/Update/DeletePod
EC->>+SL: GetPodServices
SL-->>-EC: []Service
EC->>Q: Add
Q-->>EC: return
deactivate EC
getPodServiceMemberships 会获取跟当前 Pod 有关的 Service 对象并将所有的 Service 对象都转换成 <namespace>/<name> 的字符串:
|
|
这些服务最后会被加入 EndpointController 的队列中,等待它持有的几个 Worker 对 Service 进行同步。
这些其实就是 EndpointController 的作用,订阅 Pod 和 Service 对象的变更,并根据当前集群中的对象生成 Endpoint 对象将两者进行关联。
Proxy Mode
在 Kubernetes 集群中的每一个节点都运行着一个 kube-proxy 进程,这个进程会负责监听 Kubernetes 主节点中 Service 的增加和删除事件并修改运行代理的配置,为节点内的客户端提供流量的转发和负载均衡等功能。在整个集群中另一个订阅 Service 对象变动的组件就是 kube-proxy 了,每当 kube-proxy 在新的节点上启动时都会初始化一个 ServiceConfig 对象,就像介绍 iptables 代理模式时提到的,这个对象会接受 Service 的变更事件:
sequenceDiagram
participant SCT as ServiceChangeTracker
participant SC as ServiceConfig
participant P as Proxier
participant EC as EndpointConfig
participant ECT as EndpointChangeTracker
participant SR as SyncRunner
SC->>+P: OnServiceAdd/Update/Delete/Synced
P->>SCT: Update
SCT-->>P: Return ServiceMap
deactivate P
EC->>+P: OnEndpointsAdd/Update/Delete/Synced
ECT-->>P: Return EndpointMap
P->>ECT: Update
deactivate P
loop Every minSyncPeriod ~ syncPeriod
SR->>P: syncProxyRules
end
这些变更事件都会被订阅了集群中对象变动的 ServiceConfig 和 EndpointConfig 对象推送给启动的 Proxier 实例:
|
|
收到事件变动的 Proxier 实例随后会根据启动时的配置更新 iptables 或者 ipvs 中的规则,这些应用最终会负责对进出的流量进行转发并完成一些负载均衡相关的任务。
Userspace
作为运行在用户空间的代理,对于每一个 Service 都会在当前的节点上开启一个端口,所有连接到当前代理端口的请求都会被转发到 Service 背后的一组 Pod 上,它其实会在节点上添加 iptables 规则,通过 iptables 将流量转发给 kube-proxy 处理。
如果当前节点上的 kube-proxy 在启动时选择了 userspace 模式,那么每当有新的 Service 被创建时,kube-proxy 就会增加一条 iptables 记录并启动一个 Goroutine,前者用于将节点中服务对外发出的流量转发给 kube-proxy,再由后者持有的一系列 Goroutine 将流量转发到目标的 Pod 上。
在 userspace 模式下,访问服务的请求到达节点后首先进入内核 iptables,然后回到用户空间,由 kube-proxy 转发到后端的 pod,这样流量从用户空间进出内核带来的性能损耗是不可接受的,所以也就有了 iptables 模式。
为什么 userspace 模式要建立 iptables 规则,因为 kube-proxy 监听的端口在用户空间,这个端口不是服务的访问端口也不是服务的 nodePort,因此需要一层 iptables 把访问服务的连接重定向给 kube-proxy 服务。
这一系列的工作大都是在 OnServiceAdd 被触发时中完成的,正如上面所说的,该方法会调用 mergeService 将传入服务 Service 的端口变成一条 iptables 的配置命令为当前节点增加一条规则,同时在 addServiceOnPort 方法中启动一个 TCP 或 UDP 的 Socket:
|
|
这个启动的进程会监听同一个节点上,转发自所有进程的 TCP 和 UDP 请求并将这些数据包发送给目标的 Pod 对象。
在用户空间模式中,如果一个连接被目标服务拒绝,我们的代理服务能够重新尝试连接其他的服务,除此之外用户空间模式并没有太多的优势。
IPTables
另一种常见的代理模式就是直接使用 iptables 转发当前节点上的全部流量,这种脱离了用户空间在内核空间中实现转发的方式能够极大地提高 proxy 的效率,增加 kube-proxy 的吞吐量。
iptables 模式是目前默认的代理方式,基于 netfilter 实现。当客户端请求 service 的 ClusterIP 时,根据 iptables 规则路由到各 pod 上,iptables 使用 DNAT 来完成转发,其采用了随机数实现负载均衡。
iptables 模式与 userspace 模式最大的区别在于,iptables 模块使用 DNAT 模块实现了 service 入口地址到 pod 实际地址的转换,免去了一次内核态到用户态的切换,另一个与 userspace 代理模式不同的是,如果 iptables 代理最初选择的那个 pod 没有响应,它不会自动重试其他 pod。
iptables 模式最主要的问题是在 service 数量大的时候会产生太多的 iptables 规则,使用非增量式更新会引入一定的时延,大规模情况下有明显的性能问题。
iptables 作为一种代理模式,它同样实现了 OnServiceUpdate、OnEndpointsUpdate 等方法,这两个方法会分别调用相应的变更追踪对象。
sequenceDiagram
participant SC as ServiceConfig
participant P as Proxier
participant SCT as ServiceChangeTracker
participant SR as SyncRunner
participant I as iptable
SC->>+P: OnServiceAdd
P->>P: OnServiceUpdate
P->>SCT: Update
SCT-->>P: Return ServiceMap
deactivate P
loop Every minSyncPeriod ~ syncPeriod
SR->>+P: syncProxyRules
P->>I: UpdateChain
P->>P: writeLine x N
P->>I: RestoreAll
deactivate P
end
变更追踪对象会根据 Service 或 Endpoint 对象的前后变化改变 ServiceChangeTracker 本身的状态,这些变更会每隔一段时间通过一个 700 行的巨大方法 syncProxyRules 同步,在这里就不介绍这个方法的具体实现了,它的主要功能就是根据 Service 和 Endpoint 对象的变更生成一条一条的 iptables 规则,比较感兴趣的读者,可以点击 proxier.go#L640-1379 查看代码。
Iptables 规则
当我们使用 iptables 的方式启动节点上的代理时,所有的流量都会先经过 PREROUTING 或者 OUTPUT 链,随后进入 Kubernetes 自定义的链入口 KUBE-SERVICES、单个 Service 对应的链 KUBE-SVC-XXXX 以及每个 Pod 对应的链 KUBE-SEP-XXXX,经过这些链的处理,最终才能够访问当一个服务的真实 IP 地址。
Iptables 问题
虽然相比于用户空间来说,直接运行在内核态的 iptables 能够增加代理的吞吐量,但是当集群中的节点数量非常多时,iptables 并不能达到生产级别的可用性要求,每次对规则进行匹配时都会遍历 iptables 中的所有 Service 链。
规则的更新也不是增量式的,当集群中的 Service 达到 5,000 个,每增加一条规则都需要耗时 11min,当集群中的 Service 达到 20,000 个时,每增加一条规则都需要消耗 5h 的时间,这也就是告诉我们在大规模集群中使用 iptables 作为代理模式是完全不可用的。
IPVS
ipvs 就是用于解决在大量 Service 时,iptables 规则同步变得不可用的性能问题。与 iptables 比较像的是,ipvs 的实现虽然也基于 netfilter 的钩子函数,但是它却使用哈希表作为底层的数据结构并且工作在内核态,这也就是说 ipvs 在 重定向流量 和 同步代理规则 有着更好的性能。
在处理 Service 的变化时,ipvs 包和 iptables 其实就有非常相似了,它们都同样使用 ServiceChangeTracker 对象来追踪变更,只是两者对于同步变更的方法 syncProxyRules 实现上有一些不同。
sequenceDiagram
participant P as Proxier
participant SR as SyncRunner
participant IP as ipvs
participant I as iptable
loop Every minSyncPeriod ~ syncPeriod
SR->>+P: syncProxyRules
P->>P: writeLine(iptable)
P->>IP: Add/UpdateVirtualServer(syncService)
IP-->>P: result
P->>IP: AddRealServer(syncEndpoint)
IP-->>P: result
P->>I: RestoreAll
deactivate P
end
我们从 ipvs 的源代码和上述的时序图中可以看到,Kubernetes ipvs 的实现其实是依赖于 iptables 的,后者能够辅助它完成一些功能,使用 ipvs 相比 iptables 能够减少节点上的 iptables 规则数量,这也是因为 ipvs 接管了原来存储在 iptables 中的规则。
除了能够提升性能之外,ipvs 也提供了多种类型的负载均衡算法,除了最常见的 Round-Robin 之外,还支持最小连接、目标哈希、最小延迟等算法,能够很好地提升负载均衡的效率。
当集群规模比较大时,iptables 规则刷新会非常慢,难以支持大规模集群,因其底层路由表的实现是链表,对路由规则的增删改查都要涉及遍历一次链表,ipvs 的问世正是解决此问题的,ipvs 是 LVS 的负载均衡模块,与 iptables 比较像的是,ipvs 的实现虽然也基于 netfilter 的钩子函数,但是它却使用哈希表作为底层的数据结构并且工作在内核态,也就是说 ipvs 在重定向流量和同步代理规则有着更好的性能,几乎允许无限的规模扩张。