「理解 k8s 网络模型」service

2,839 阅读6分钟

在本系列的第一篇文章,我看着k8s如何利用虚拟网络设备和路由规则,允许运行在一个集群节点上的pod与运行在另一个节点上的pod进行通信,只要发送方知道接收方的pod网络IP地址。

如果你还不熟悉pod是如何通信的,那么在继续阅读之前还是值得看一看 之前那篇文章

集群中的Pod网络设计很好并巧妙,但它本身不足以实现整体系统。是因为k8s中的pod是短暂的。你可以使用pod IP地址作为endpoint,但不能保证该地址在下次重新创建pod时不会改变,这可能由于各种原因而发生。

你可能已经意识到这是一个老生常谈的问题,它有一个标准的解决方案:反向代理/负载平衡

客户端连接到代理,代理负责维护一个健康的服务器列表,来转发请求。这意味着对代理本身有一些要求:它本身必须持久且抗故障;它必须有一个可以转发的服务器列表;而且它必须有某种方式知道特定的服务器是否健康,是否能够响应请求。

k8s的设计者以一种优雅的方式解决了这个问题,它建立在平台的基本功能之上,以满足所有这三种需求。这就需要从一种叫做 service 的资源类型开始。

Services

在第一篇文章中,我展示了一个带有两个pod的虚拟集群,并描述了它们如何能够跨节点通信。这里,我想重新以这个示例为基础,描述 k8s service 如何在一组服务器pod之间实现负载均衡,从而使客户端pod能够独立和持久地运行。为了创建服务器pod,我们可以使用如下的部署:

kind: Deployment
apiVersion: extensions/v1beta1
metadata:
  name: service-test
spec:
  replicas: 2
  selector:
    matchLabels:
      app: service_test_pod
  template:
    metadata:
      labels:
        app: service_test_pod
    spec:
      containers:
      - name: simple-http
        image: python:2.7
        imagePullPolicy: IfNotPresent
        command: ["/bin/bash"]
        args: ["-c", "echo \"<p>Hello from $(hostname)</p>\" > index.html; python -m SimpleHTTPServer 8080"]
        ports:
        - name: http
          containerPort: 8080

这个部署创建了两个非常简单的http服务器pod,它们在8080端口响应,并带有它们所运行的pod的主机名。在使用 kubectl apply 创建这个 deployment 之后,我们可以看到pod正在集群中运行,我们也可以查询pod的网络地址:

$ kubectl apply -f test-deployment.yaml
deployment "service-test" created

$ kubectl get pods
service-test-6ffd9ddbbf-kf4j2    1/1    Running    0    15s
service-test-6ffd9ddbbf-qs2j6    1/1    Running    0    15s

$ kubectl get pods --selector=app=service_test_pod -o jsonpath='{.items[*].status.podIP}'
10.0.1.2 10.0.2.2

我们可以通过创建一个简单的客户端pod来进行请求,然后查看输出,来证明pod网络正在运行。

apiVersion: v1
kind: Pod
metadata:
  name: service-test-client1
spec:
  restartPolicy: Never
  containers:
  - name: test-client1
    image: alpine
    command: ["/bin/sh"]
    args: ["-c", "echo 'GET / HTTP/1.1\r\n\r\n' | nc 10.0.2.2 8080"]

这个pod创建后将运行到完成,然后进入 "完成" 状态,然后可以用 kubectl logs 输出。

$ kubectl logs service-test-client1
HTTP/1.0 200 OK

<!-- blah --><p>Hello from service-test-6ffd9ddbbf-kf4j2</p>

在这个例子中,没有显示客户端pod是在哪个节点上创建的,但是无论它运行在集群中的什么位置,它都能够联系到服务器pod并得到响应。然而,如果服务器pod死亡并被重新启动,或者被重新安排到不同的节点上,它的IP肯定会改变,客户端通信就会中断。

我们通过创建一个 service 来规避这种情况。

kind: Service
apiVersion: v1
metadata:
  name: service-test
spec:
  selector:
    app: service_test_pod
  ports:
  - port: 80
    targetPort: http

service 是一种k8s资源,它导致将代理配置为将请求转发给一组pod。接收流量的pod由选择器决定,与创建pod时分配给它们的标签相匹配。一旦创建了 service,可以看到它已经被分配了一个IP地址,并在80端口上接受请求。

$ kubectl get service service-test
NAME           CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
service-test   10.3.241.152   <none>        80/TCP    11s

请求可以直接发送到service的IP上,但最好是使用一个可以解析到IP地址的主机名。幸运的是,k8s提供了一个内部集群DNS,可以解析服务名称,只要对客户端的pod稍作改动,我们就可以利用它了。

apiVersion: v1
kind: Pod
metadata:
  name: service-test-client2
spec:
  restartPolicy: Never
  containers:
  - name: test-client2
    image: alpine
    command: ["/bin/sh"]
    args: ["-c", "echo 'GET / HTTP/1.1\r\n\r\n' | nc service-test 80"]

在这个pod运行完成后,输出显示:该服务将请求转发给了其中一个服务器pod。

$ kubectl logs service-test-client2
HTTP/1.0 200 OK
<!-- blah -->

<p>Hello from service-test-6ffd9ddbbf-kf4j2</p>

你可以不断尝试运行客户端pod,你会看到来自两个服务器pod的响应,每个得到大约50%的请求。如果你想了解这实际上是如何工作的,那么我们的服务被分配的那个IP地址开始是一个好的切入点。

service network

分配给test服务的IP代表 "网络" 上的一个地址。不过你可能已经注意到,这个 "网络" 和pod所在的网络不一样。

thing        IP               network
-----        --               -------
pod1         10.0.1.2         10.0.0.0/14
pod2         10.0.2.2         10.0.0.0/14
service      10.3.241.152     10.3.240.0/20

它也与节点所在的私有网络不同,下面会更清楚地说明这一点。在第一篇文章中,我没有把pod网络地址范围通过kubectl公开,因此你需要使用服务供应商特定命令来查询这个集群属性。service网络也是如此。如果你运行在 Google Container 引擎,你可以这样做:

$ gcloud container clusters describe test | grep servicesIpv4Cidr

servicesIpv4Cidr: 10.3.240.0/20

由这个地址空间指定的网络称为 "service网络" 。这个网络会给每个类型为 "ClusterIP" 的服务分配一个IP地址。还有其他类型的服务,我将在下一篇关于ingress的文章中讨论其中的几个,但是ClusterIP是默认的,它意味着 "该 service 将被分配一个IP地址,集群中的任何pod都可以到达。" 你可以通过运行带有service名称的 kubectl describe services 命令来查看服务的类型。

$ kubectl describe services service-test

Name:                   service-test
Namespace:              default
Labels:                 <none>
Selector:               app=service_test_pod
Type:                   ClusterIP
IP:                     10.3.241.152
Port:                   http    80/TCP
Endpoints:              10.0.1.2:8080,10.0.2.2:8080
Session Affinity:       None
Events:                 <none>

像pod网络一样,service网络也是虚拟的,但它在一些有意思的方面与pod网络不同。现在我们考虑一个情况:pod网络的地址范围10.0.0.0/14,如果你在构成集群中的节点的主机上寻找,列出网桥和接口,你会看到这个网格中实际的设备配置。这些是每个pod的虚拟接口,以及连接它们彼此和外部世界的网桥。

现在看看 service网络10.3.240.0/20。你可以尝试一下ifconfig,你不会在这个网络上找到任何配置了地址的设备。你可以检查连接所有节点的网关的路由规则,你也不会发现这个网络的任何路由。这个服务网络并不存在,至少不存在连接的接口。

然而,正如我们在上面看到的,当我们向这个网络上的一个IP发出请求时,不知怎么的,这个请求就到了我们运行在pod网络上的服务器。这是怎么发生的呢?让我们跟随一个数据包看看。

想象一下,我们上面运行的命令在一个测试集群中创建了以下pod:

image.png

这里有两个节点,连接它们的网关(它也有pod网络的路由规则)和三个pod:节点1上的客户端pod、服务器pod和节点2上的另一个服务器pod。

客户端使用DNS:service-test向 service 发出http请求。集群DNS系统将该名称解析为服务集群IP 10.3.241.152,然后客户端pod创建一个http请求,目标地点就是这个IP。

IP网络通常配置这样的路由:当一个接口由于本地不存在具有该指定地址的设备而不能将报文发送到其目的地时,它将把报文转发到其上游网关。

我们来缕一下整个流程:

  1. 在本例中看到数据包的第一个接口是客户端pod中的虚拟接口。该接口在pod网络10.0.0.0/14 上,它本身是不知道地址为 10.3.241.152 的设备;
  2. 因此它将包转发到它的网关,也就是网桥cbr0;
  3. 网桥非常简单,只是来回传递流量,因此网桥将数据包发送到主机/节点的以太网接口。

image.png

本例中的主机/节点接口位于网络10.100.0.0/24上,它也不知道地址为10.3.241.152的任何设备,所以通常情况下,数据包会被转发到这个接口的网关,也就是图中所示的顶层路由器。相反,实际发生的是数据包在传输中被拦截,并被重定向到其中一个活跃的服务器pod。

image.png

当我初学k8s时,上面的图表中所发生的事情似乎很神奇。不知为什么,我的客户端能够连接到一个没有与之关联的地址,并且这些包在集群中正确的位置跳出。我后来才知道,这个问题的答案都在一个叫做 kube-proxy 的组件上。


未完待续,实在是太长了,把 kube-proxy 内容放到下一篇讲。放到一篇文章中内容过多。

本文正在参与 “网络协议必知必会”征文活动