服务发现(Service Discovery)模式提供了一个稳定的端点,服务的客户可以通过这个端点访问提供服务的实例。为此,Kubernetes 根据服务消费者和生产者位于集群内还是集群外,提供了多种机制。

问题描述

部署在 Kubernetes 上的应用很少单独存在,通常,它们必须与集群内的其他服务或集群外的系统进行交互。交互可以通过内部发起,也可以通过外部刺激发起。内部启动的交互通常是通过轮询消费者来进行的:一个应用程序在启动后或之后连接到另一个系统并开始发送和接收数据。典型的例子是一个在 Pod 内运行的应用程序到达文件服务器并开始消耗文件,或连接到消息代理并开始接收或发送消息,或连接到关系数据库或键值存储并开始读取或写入数据。

这里的关键区别在于,在 Pod 内运行的应用程序在某个时刻决定打开与另一个 Pod 或外部系统的外向连接,并开始在任何一个方向交换数据。在这种情况下,我们没有应用程序的外部刺激,也不需要在 Kubernetes 中进行任何额外的设置。

为了实现第 7 章 “批处理作业” 或第 8 章 “周期性作业” 中描述的模式,我们经常使用这种技术。此外,DaemonSet 或 ReplicaSet 中长期运行的 Pod 有时会通过网络主动连接到其他系统。Kubernetes 工作负载比较常见的用例是,当我们有长期运行的服务 — 期望外部刺激时,最常见的形式是来自集群内其他 Pod 或外部系统的传入 HTTP 连接。在这些情况下,服务消费者需要一种机制来发现由调度器动态放置的 Pod,有时还需要弹性地扩大和缩小。

如果我们必须自己执行动态 Kubernetes Pod 的跟踪、注册和取消覆盖端点,这将是一个重大的挑战。这就是为什么 Kubernetes 通过不同的机制来实现服务发现模式的原因,我们在本章将对其进行探讨。

解决方案

如果我们看一下 “Kubernetes 时代之前”,最常见的服务发现机制是通过客户端发现。在这种架构中,当一个服务消费者需要调用另一个可能被扩展到多个实例的服务时,该服务消费者将拥有一个发现代理,能够查看服务实例的注册表,然后选择一个来调用。典型的做法是,例如,通过消费者服务中的嵌入式代理(如 ZooKeeper 客户端、Consul 客户端或 Ribbon),或通过另一个联合进程(如 Prana)在注册表中查找服务,如图 12-1 所示。
image.png
图 12-1 通过客户端的服务发现

在 “后 Kubernetes 时代”,许多非功能职责的分布式系统,如放置、健康检查、愈合、资源隔离等都在向平台转移,服务发现和负载均衡也在转移。如果我们使用面向服务架构(SOA)的定义,服务提供者实例在提供服务能力的同时,仍然要在服务注册表中注册自己,而服务消费者则要访问注册表中的信息来达到服务。

在 Kubernetes 世界中,所有这些都发生在幕后,因此服务消费者调用一个固定的虚拟服务端点,该端点可以动态地发现作为 Pod 实现的服务实例。图 12-2 显示了 Kubernetes 是如何拥抱注册和查找的。
image.png
图 12-2 通过服务端的服务发现

乍一看,服务发现似乎是一个简单的模式。然而,可以使用多种机制来实现这种模式,这取决于服务消费者是在集群内部还是外部,以及服务提供商是在集群内部还是外部。

内部服务发现

假设我们有一个 Web 应用,并想在 Kubernetes 上运行它。只要我们创建一个有几个副本的 Deployment,调度器就会将 Pod 放置在合适的节点上,每个 Pod 在启动前都会得到一个分配的集群 IP 地址。如果不同 Pod 内的另一个客户端服务希望消费 Web 应用端点,就没有一个简单的方法可以提前知道服务提供者 Pod 的 IP 地址。

这个挑战正是 Kubernetes 服务资源所要解决的。它为提供相同功能的 Pod 集合提供了一个稳定的入口点。创建服务最简单的方法是通过 kubectl expose,它为一个部署或 ReplicaSet 的一个 Pod 或多个 Pod 创建一个 Service。该命令创建了一个称为 clusterIP 的虚拟 IP 地址,并从资源中提取 Pod 选择器和端口号来创建服务定义。但是,为了完全控制定义,我们手动创建服务,如例 12-1 所示。

  1. # 例 12-1 一个简单的 Service 示例
  2. ---
  3. apiVersion: v1
  4. kind: Service
  5. metadata:
  6. name: random-generator
  7. spec:
  8. # 选择器匹配 Pod 标签
  9. selector:
  10. app: random-generator
  11. ports:
  12. # 可与该 Service 联系的端口
  13. - port: 80
  14. # Pod 正在监听的端口
  15. targetPort: 8080
  16. protocol: TCP

本例中的定义将创建一个名为 random-generator 的 Service(这个名字对后面的发现很重要),类型为。ClusterIP(这是默认的),接受端口 80 上的 TCP 连接,并将它们路由到所有匹配的 Pod 上的端口 8080,选择器 app: random-generator。无论何时或如何创建 Pod,任何匹配的 Pod 都会成为路由目标,如图 12-3 所示。
image.png
图 12-3 内部服务发现

这里需要记住的要点是,一旦一个服务被创建,它就会被分配一个 clusterIP,这个 IP 只有在 Kubernetes 集群内才能访问(因此而得名),而且只要服务定义存在,这个 IP 就不会改变。然而,集群内的其他应用如何才能知道这个动态分配的 clusterIP 是什么呢?有两种方法。

  • 通过环境变量发现:当 Kubernetes 启动一个 Pod 时,它的环境变量会被填入到该时刻存在的所有服务的详细信息。例如,我们在 80 端口监听的随机生成器服务会被注入到任何新启动的 Pod 中,如例 12-2 所示的环境变量。运行该 Pod 的应用程序将知道它需要消耗的 Service 的名称,并可以通过编码来读取这些环境变量。这个查询是一个简单的机制,可以从任何语言编写的应用程序中使用,也很容易在 Kubernetes 集群之外进行仿真,以达到开发和测试的目的。这种机制的主要问题是对 Service 创建的时间依赖性。由于环境变量不能注入到已经运行的 Pod 中,因此只有在 Kubernetes 中创建 Service 之后启动的 Pod 才能获得 Service 坐标。这就需要在启动依赖 Service 的 Pod 之前定义 Service,如果不是这样,则需要重新启动 Pod。 ```go

    服务相关的环境变量在 Pod 中自动设置

RANDOM_GENERATOR_SERVICE_HOST=10.109.72.32 RANDOM_GENERATOR_SERVICE_PORT=8080


- **通过 DNS 查询发现**:Kubernetes 运行一个 DNS 服务器,所有的 Pod 都会自动配置使用。此外,当一个新的服务被创建时,它会自动获得一个新的 DNS 条目,所有的 Pod 都可以开始使用。假设客户端知道它要访问的 Service 的名称,它可以通过一个完全合格的域名(FQDN)到达该 Service,比如 `random-generator.default.svc.cluster.local`。这里,`random-generator` 是服务的名称,`default` 是名称空间的名称,`svc` 表示它是服务资源,`cluster.local` 是集群的特定后缀。如果需要,我们可以省略集群后缀,当从同一个命名空间访问 Service 时,也可以省略命名空间。DNS 发现机制没有基于环境变量机制的缺点,因为只要定义了一个 Service,DNS 服务器就允许查找所有 Service 到所有 Pod。但是,如果服务消费者使用的端口号是非标准端口号或者未知端口号,你可能还是需要使用环境变量来查找使用。

Service(`type: ClusterIP`)的服务还有一些其他类型的特征,以下这些特征是建立在其他配置的基础上的。

- **多个端口**:一个服务定义可以支持多个源端口和目标端口。例如,如果你的 Pod 同时支持 8080 端口的 HTTP 和 8443 端口的 HTTPS,就不需要定义两个服务。例如,一个服务可以同时暴露 80 和 443 端口。
- **会话亲和性(Session Affinity)**:当有新请求时,服务会随机选择一个 Pod 作为默认连接。这一点可以通过 `sessionAffinity: ClientIP` 来改变,使得所有来自同一客户端 IP 的请求都会连接到同一个 Pod。请记住,Kubernetes 服务执行的是 L4 传输层的负载均衡,它不能查看网络数据包,也不能执行应用层的负载均衡,比如基于 HTTP Cookie 的会话亲和性。
- **就绪探针**:在第 4 章 “健康探针” 中,您学习了如何为容器定义一个 `readinessProbe`。如果一个 Pod 已经定义了就绪检查,并且检查失败,那么即使标签选择器与 Pod 相匹配,也会从要调用的服务端点列表中删除该 Pod。
- **虚拟 IP**:当我们创建一个类型为 ClusterIP 的服务时,它得到一个稳定的虚拟 IP 地址。但是,这个 IP 地址并不对应任何网络接口,在现实中并不存在。是运行在每个节点上的 kube-proxy 选择了这个新的 Service,并通过规则更新节点的 iptables 来捕获指向这个虚拟 IP 地址的网络工作包,并将其替换为选定的 Pod IP 地址。iptables 中的规则不添加 ICMP 规则,只添加 Service 定义中指定的协议,如 TCP 或 UDP。因此,不可能 `ping` 服务的 IP 地址,因为该操作使用 ICMP 协议。然而,当然可以通过 TCP 访问服务的 IP 地址(例如,对于 HTTP 请求)。
- **指定 **`**ClusterIP**`:在创建服务期间,我们可以使用 `.spec.clusterIP` 字段指定要使用的 IP。它必须是一个有效的 IP 地址,并在预定义的范围内。虽然不推荐使用该选项,但在处理配置为使用特定 IP 地址的遗留应用程序时,或者在我们希望重用现有 DNS 条目时,该选项可能会很方便。

Kubernetes 中 `type: ClusterIP` 类型的服务只能从集群内部访问,它们用于通过匹配选择器来发现 Pod,是最常用的类型。接下来,我们将看看其他类型的服务,它们允许发现手动指定的端点。

:::info
然而,还有另一种类型的服务是可用的:无头服务(Headless Service),你不需要专用的 IP 地址,你可以通过在服务的 `.spec` 部分中指定 `clusterIP: None` 来创建无头服务。您可以通过在服务的规格:配置文件中指定 `clusterIP: None` 来创建无头服务。对于无头服务,后台的 Pod 被添加到内部 DNS 服务器,对于实现 Service 到 StatefulSet 是最有用的,详见第 11 章 “有状态服务”(Stateful Service)。
:::
[第 10 章 · Kubernetes 网络](https://www.yuque.com/serviceup/managing-kubernetes/co9eu6?view=doc_embed)

<a name="yrsAx"></a>
## 人工服务发现
当我们创建一个带有选择器的 Service 时,Kubernetes 会在端点资源列表中跟踪匹配和准备服务的 Pod 列表。对于例 12-1,你可以用 `kubectl get endpoints random-generator` 检查所有代表 Service 创建的端点。我们可以不将连接重定向到集群内的 Pod,也可以将连接重定向到外部 IP 地址和端口。我们可以通过省略服务的选择器定义,手动创建端点资源来实现,如例 12-3 所示。
```yaml
# 例 12-3 不带有选择器的 Service

---
apiVersion: v1 
kind: Service 
metadata:
    name: external-service 
spec:
    type: ClusterIP 
  ports:
    - protocol: TCP
        port: 80

接下来,在示例 12-4 中,我们定义了一个端点(Endpoint)资源,它的名称与服务,并包含目标 IP 和端口。

# 例 12-4 外部服务的端点

---
apiVersion: v1 
kind: Endpoints 
metadata:
    # 名称必须与访问这些端点的服务相匹配
    name: external-service 
subsets:
    - addresses:
        - ip: 1.1.1.1 
    - ip: 2.2.2.2 
    ports:
        - port: 8080

该服务也只有在集群内才能访问,并且可以通过环境变量或 DNS 查询的方式与前面的服务一样进行消费。这里的不同之处在于,端点列表是人工维护的,其中的值通常指向集群外的 IP 地址,如图 12-4 所示。
image.png
图 12-4 人工服务发现

虽然连接到外部资源是这种机制最常见的用途,但它不是唯一的用途。端点可以持有 Pod 的 IP 地址,但不能持有其他 Service 的虚拟 IP 地址。服务的一个好处是,它允许添加和删除选择器并指向外部或内部提供者,而不需要删除资源定义,这将导致服务 IP 地址的改变。因此,服务消费者可以继续使用他们最初指向的相同的 Service IP 地址,而实际的服务提供商实现从内部迁移到 Kubernetes,而不会影响客户端。

在这一类人工目标配置中,还有一种 Service,如例 12-5 所示。

# 例 12-5 有外部目的地的服务

---
apiVersion: v1 
kind: Service 
metadata:
    name: database-service 
spec:
    type: ExternalName
    externalName: my.database.example.com 
  ports:
    - port: 80

这个 Service 定义也没有选择器,但它的类型是 ExternalName。从实现的角度来看,这是一个重要的区别。这个 Service 定义只使用 DNS 映射到 externalName 指向的内容。它是一种使用 DNS CNAME 为外部端点创建别名的方式,而不是通过代理的 IP 地址。但从根本上说,它是为位于集群外部的端点提供 Kubernetes 抽象的另一种方式。

集群外的服务发现

本章到目前为止所讨论的服务发现机制都是使用一个指向 Pod 或外部端点的虚拟 IP 地址,而虚拟 IP 地址本身只能从 Kubernetes 集群内部访问。然而,Kubernetes 集群的运行并不是与外界断开的,除了从 Pod 连接到外部资源外,很多时候还需要反过来 — 外部应用想要到达 Pod 提供的端点。让我们来看看如何让生活在集群之外的客户端能够访问 Pod。

第一种方法是通过 type: NodePort 来创建一个 Service,并将其暴露在集群外部。例 12-6 中的定义和前面一样创建了一个 Service,为符合选择器 app: random-generator 的 Pod 提供服务,接受虚拟 IP 地址上 80 端口的连接,并将每个连接路由到所选 Pod 的 8080 端口。然而,除了这些,这个定义还在所有节点上保留了 30036 端口,并将传入的连接转发给 Service。这种预留使得服务在内部可以通过虚拟 IP 地址访问,在外部也可以通过每个节点上的专用端口访问。

# 例 12-6 类型为 NodePort 的服务

---
apiVersion: v1 
kind: Service 
metadata:
    name: random-generator 
spec:
    # 在所有节点上打开端口
    type: NodePort 
  selector:
        app: random-generator 
  ports:
  - port: 80 
    targetPort: 8080 
    # 指定一个固定的端口(必须是可用的),或者不写这个,以获得分配给运行中的选定端口
    nodePort: 30036 
    protocol: TCP

虽然图 12-5 所示的这种暴露服务的方法看起来是个好方法,但它也有缺点。让我们来看看它的一些突出特点。

  • 端口号:你可以让 Kubernetes 在其范围内挑选一个自由端口,而不是用 nodePort: 30036 来挑选一个特定的端口。
  • 防火墙规则:由于这种方法在所有节点上都打开了一个端口,因此您可能需要配置附加的防火墙规则来让外部客户端访问节点端口。
  • 节点选择:外部客户端可以打开连接到集群中的任何节点。然而,如果该节点不可用,客户端应用程序有责任连接到另一个健康的节点。为此,在节点前放置一个负载均衡器(Load Balancer)可能是个好主意,它可以选择健康节点并执行故障转移。
  • Pod 选择:当客户端通过节点端口打开一个连接时,它被路由到一个运行选择的 Pod,这个 Pod 可能在打开连接的同一个节点上,也可能是不同的节点。通过添加 externalTrafficPolicy: Local 到服务定义中,可以避免这种额外的跳转,并总是强制 Kubernetes 在打开连接的节点上选择一个 Pod。当设置这个选项时,Kubernetes 不允许连接到位于其他节点上的 Pod,这可能是一个问题。为了解决这个问题,你必须确保每个节点上都有放置的 Pod(例如,通过使用 Daemon 服务),或者确保客户端知道哪些节点上放置了健康的 Pod。
  • 来源地址:发送到不同类型的服务的数据包的源地址有一些特殊性。具体来说,当我们使用 NodePort 类型时,客户端地址是源 NAT’d,这意味着包含客户端 IP 地址的网络数据包的源 IP 地址被替换为节点的内部地址。例如,当客户端应用向节点 1 发送数据包时,将源地址替换为其节点地址,将目的地址替换为 Pod 的地址,并将数据包转发到 Pod 所在的节点 2。当 Pod 收到网络数据包时,源地址不等于原客户端的地址,而是与节点 1 的地址相同。为了防止这种情况发生,我们可以设置 externalTrafficPolicy: Local,只将流量转发到位于节点 1 的 Pod上。

image.png
图 12-5 基于 NodePort 的服务发现

外部客户端的服务发现的另一种方式是通过负载均衡器。你已经看到了 type:NodePort 服务是如何建立在一个普通的 type: ClusterIP 的常规服务之上,通过在每个节点上也开放一个端口。这种方法的局限性在于,我们仍然需要一个负载平衡器来为客户端应用挑选一个健康的节点。服务类型 LoadBalancer 解决了这个限制。

除了创建一个常规的 Service,并在每个节点上开放一个端口,就像 type: NodePort,它还使用云提供商的负载平衡器向外部暴露服务。图 12-6 显示了这种设置:一个专有的负载平衡器作为 Kubernetes 集群的网关。
image.png
图 12-6 基于负载均衡器端服务发现

:::danger 所以这种类型的 Service 只有在云提供商支持 Kubernetes 并提供了一个负载平衡器的情况下才能工作。 :::

我们可以通过指定 LoadBalancer 类型来创建一个带有负载平衡器的服务。然后 Kubernetes 会在 .spec.status 字段中添加 IP 地址,如例 12-7 所示。

# 例 12-7 类型为 LoadBalancer 的服务

---
apiVersion: v1 
kind: Service 
metadata:
    name: random-generator 
spec:
    type: LoadBalancer 
  # Kubernetes 会在集群 IP 和 loadBalancerIP 可用时分配它们
  clusterIP: 10.0.171.239 
  loadBalancerIP: 78.11.24.19 
  selector:
        app: random-generator 
  ports:
    - port: 80 
      targetPort: 8080 
    protocol: TCP
# 状态字段由 Kubernetes 管理,并添加 Ingress IP
status: 
    loadBalancer:
        ingress:
        - ip: 146.148.47.155

有了这个定义,外部客户端应用程序就可以打开与负载均衡器的连接,负载均衡器会选择一个节点并定位 Pod。负载均衡器供应和服务发现的具体方式在云提供商之间有所不同。有些云提供商允许定义负载平衡器地址,有些则不允许。有些提供了保留源地址的机制,有些则用负载平衡器地址替换源地址。您应该检查您所选择的云提供商提供的具体实现。

应用层服务发现

与迄今为止讨论的机制不同,Ingress 不是一种服务类型,而是一种独立的 Kubernetes 资源,它位于 Service 前面,作为智能路由器和集群的入口点。Ingress 通常通过外部可到达的 URL、负载均衡、SSL 终止(SSL Termination)和基于名称的虚拟主机来提供对 Service 的基于 HTTP 的访问,但也有其他专门的 Ingress 实现。

为了使 Ingress 工作,集群必须有一个或多个 Ingress 控制器运行。例 12-8 中显示了一个简单的 Ingress,它暴露了一个服务。

# 例 12-8 一个简单的 Ingress 声明

---
apiVersion: networking.k8s.io/v1beta1 
kind: Ingress
metadata:    
    name: random-generator 
  spec:
        backend:
            serviceName: random-generator 
      servicePort: 8080

根据 Kubernetes 所运行的基础架构以及 Ingress 控制器的实现,这个定义会分配一个外部可访问的 IP 地址,并在 80 端口上暴露 random-generator Service。但这与类型为 type: LoadBalancer 的 Service 没有太大区别,它要求每个 Service 定义一个外部 IP 地址。Ingress 的真正威力来自于重复使用一个外部负载均衡器和 IP 来服务多个 Service,并降低基础设施成本。

基于 HTTP URI 路径将一个 IP 地址路由到多个服务的简单扇出(fan-out)配置如下例 12-9。

# 例 12-9 一个带路由映射的 Ingress 声明

apiVersion: networking.k8s.io/v1beta1 
kind: Ingress
metadata:
    name: random-generator 
     annotations:
        nginx.ingress.kubernetes.io/rewrite-target: / 
spec:
    # Ingress 控制器的专用规则,用于根据请求路径调度请求
    rules: 
      - http:
        paths:
        # 将每个请求重定向到 random-generator 服务...
        - path: /
          backend:
            serviceName: random-generator 
            servicePort: 8080
        # ... 除了 /cluster-status 会转到另一个服务上
        - path: /cluster-status 
          backend:
            serviceName: cluster-status 
            servicePort: 80

由于每个 Ingress 控制器的实现都是不同的,除了通常的 Ingress 定义外,一个控制器可能需要额外的配置,这些配置是通过注释来传递的。假设 Ingress 配置正确,前面的定义将提供一个负载均衡器,并得到一个外部 IP 地址,在两个不同的路径下服务两个 Service,如图 12-7 所示。
image.png
图 12-7 应用从服务发现

:::tips Ingress 是 Kubernetes 上最强大,同时也是最复杂的服务发现机制。它对于在同一 IP 地址下暴露多个服务,以及当所有服务使用相同的 L7(通常是 HTTP)协议时最有用。 :::

OpenShift Routes

Red Hat OpenShift 是 Kubernetes 的一个流行的企业发行版。除了与 Kubernetes 完全兼容之外,OpenShift 还提供了一些额外的功能。其中一个功能是 Routes,它与 Ingress 非常相似。事实上,它们是如此相似,差异可能很难被发现。首先,Routes 早于 Kubernetes 中 Ingress 对象的引入,所以 Routes 可以说是 Ingress 的一种前身。

但是,Routes 和 Ingress 对象之间还是存在一些技术上的差异。

  • Routes 会被 OpenShift 集成的 HAProxy 负载均衡器自动拾取,所以不需要额外安装 Ingress 控制器。但是,你也可以替换 OpenShift 负载平衡器中的构建。
  • 您可以使用额外的 TLS 终止模式,如重新加密或通过服务的转接。
  • 可以使用多个加权后端来分割流量。
  • 支持通配符域。

说了这么多,你也可以在 OpenShift 上使用 Ingress。所以你在使用 OpenShift 的时候可以选择。

一些讨论

在本章中,我们介绍了 Kubernetes 上最受欢迎的服务发现机制。从集群内部发现动态 Pod 总是通过 Service 资源来实现,不过不同的选择会导致不同的实现。服务抽象是一种高级的云原生方式,可以配置虚拟 IP 地址、iptables、DNS 记录或环境变量等低级细节。来自集群外部的服务发现建立在服务抽象之上,并专注于将服务暴露给外部世界。虽然 NodePort 提供了暴露服务的基本要素,但高可用的设置需要与平台形式的基础架构提供商集成。

表 12-1 总结了 Kubernetes 中实现服务发现的各种方式。该表旨在将本章中的各种服务发现机制从更简单到更复杂进行组织。我们希望它能帮助你建立一个心理模型,更好地理解它们。
image.png
表 12-1 服务发现机制

本章全面概述了 Kubernetes 中访问和发现服务的所有核心概念。然而,旅程并没有到此为止。通过 Knative 项目,在 Kubernetes 之上引入了新的基本元素,这些基本元素有助于应用开发者进行高级服务、构建和消息传递。

在服务发现的背景下,Knative Serving 子项目尤其值得关注,因为它引入了一种新的 Service 资源,其种类与这里介绍的 Service 相同(但 API 组不同)。Knative Serving 不仅提供了对应用修改的支持,而且还提供了对负载均衡器后的服务进行非常灵活的扩展的支持。我们在第 230 页的 “Knative Build” 和第 210 页的 “Knative Serving” 中对 Knative Serving 进行了简短的介绍,但对 Knative 的全面讨论超出了本书的范围。在第 219 页,你会发现指向 Knative 详细信息的链接。

参考资料