7

k8s网络学习(4)——Service的艺术

 3 years ago
source link: https://niyanchun.com/k8s-network-4-service.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

k8s网络学习(4)——Service的艺术

2021-02-16 云原生 Kubernetes 59次阅读

K8s的网络主要需要解决4个问题:

  1. 高度耦合的容器间的通信问题
  2. Pod与Pod之间的通信
  3. Pod和Service之间的通信(本文)
  4. 外部系统和Service之间的通信(本文涉及一部分)

上篇文章讨论了第2个问题,本文继续讲述第3个问题和第4个问题:Pod和Service之间的通信、外部系统和Service之间的通信。其核心就在Service,官方文档(英文中文)已经介绍的比较详细了,本文主要是学习笔记和总结。

我们在k8s中部署应用时典型的操作是下面这样的:

图中通过Deployment部署了两个应用:

  • app1:有3个副本:pod r1、pod r2、pod r3,且暴露了一个service:app1-svc;
  • app2:有2个副本:pod r1'、pod r2',且暴露了一个service:app2-svc。

其中app2需要访问app1,所以app2的Pod通过app1暴露的app1-svc访问app1。这个流程是生产中常用的一种部署方式,可以看到Service是访问服务的核心所在。

为什么需要Service

答案大家都知道,因为在k8s设计中,Pod是非永久性资源(nonpermanent resources),也就是它可能会随时挂掉。所以还设计了Deployment/RC来保证挂掉之后能重新创建新的Pod(当然实际的工作是由kube-controller-manager做的,再具体点就是Replication Controller),然而新创建的Pod IP地址一般都会发生改变。所以尽管按照k8s的网络模型,pod和pod之间的网络是通畅的,我们也不会直接使用Pod的IP去访问Pod,而是通过一个更加“稳定”的抽象资源Service。当创建一个Service的时候,集群就会给他分配一个集群内唯一的IP,这个IP称之为“ClusterIP”,该IP只能在集群内访问。只要不主动删除这个Service,它就会一直存在,IP也不会改变。需要注意的是,这个ClusterIP是一个虚拟IP(Virtual IP),只有和Service中定义的端口(port)配合使用时才有意义,其它端口都是没用的(所以不要尝试ping它,不会有响应的)。

当然光稳定还不够,Service是一个虚拟的资源,自身不能提供应用功能,要对外提供服务,必须要能够找到后端真正提供服务的应用Pod。在查找方式上面,又分为两类Service:

1,带selector的Service。这是最常用的,比如上面图中app1-svc的定义可能就类似下面这样:

apiVersion: v1
kind: Service
metadata:
  name: app1-svc
spec:
  selector:
    app: app1
  ports:
    - protocol: TCP
      port: 80
      targetPort: 9376

带selector的Service匹配后端Pod的方式就是看哪些Pod具有和自己selector中一样的label,匹配到的Pod会被加到这个Service的Endpoint(端点,也是k8s的一种资源)中去,这个匹配的工作是由Endpoints Controller去做的。比如我的集群里面有一个kube-dns Service,我们可以通过下面的命令查询这个service的endpoints:

➜  ~ kubectl -n kube-system get svc
NAME       TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)                  AGE
kube-dns   ClusterIP   10.96.0.10   <none>        53/UDP,53/TCP,9153/TCP   49d
➜  ~ kubectl -n kube-system get endpoints kube-dns
NAME       ENDPOINTS                                                      AGE
kube-dns   10.244.0.140:53,10.244.1.32:53,10.244.0.140:9153 + 3 more...   49d

2, 不带selector的Service。把上面service里面的selector去掉,就是不带selector的service了:

apiVersion: v1
kind: Service
metadata:
  name: app1-svc
spec:
  ports:
    - protocol: TCP
      port: 80
      targetPort: 9376

因为没有了selector,所以k8s无法自己去匹配端点,这个时候需要用户自己定义端点,比如像下面这样:

apiVersion: v1
kind: Endpoints
metadata:
  name: app1-svc
subsets:
  - addresses:
      - ip: 192.0.2.42
    ports:
      - port: 9376

从这个定义可以看出来,其实Service不仅仅能匹配Pod,还可以匹配其它服务(比如非容器服务),因为端点的定义里面填的是一个连接信息。这也是不带selector的Service的用途:在当前k8s集群使用集群之外的服务/应用。比如另外一个k8s集群的服务,抑或是非容器化的服务,典型的比如部署在物理机上面的DB。

为什么用Service而不是DNS

从上面的分析可以看到,Service的核心作用就是在不稳定的Pod之上做了一层封装,将Pod所代表的服务稳定的暴露出来,同时提供一些负载均衡或其它方式的路由功能。而这种需求场景在没有容器产生之前就有了,而且有很多种策略方案,最常用的一个就是DNS。比如Google的搜索服务,后台会成千上万台机器支撑,但对外就暴露了一个www.google.com域名,不同地区的用户访问这个域名,会被DNS解析到不同的机器上面去,基本实现的功能和上面说的Service是一样的,那k8s为什么没有使用DNS技术,而是设计了一个基于虚拟IP(ClusterIP)的Service呢?

官方是这样解释的:

  • There is a long history of DNS implementations not respecting record TTLs, and caching the results of name lookups after they should have expired.
  • Some apps do DNS lookups only once and cache the results indefinitely.
  • Even if apps and libraries did proper re-resolution, the low or zero TTLs on the DNS records could impose a high load on DNS that then becomes difficult to manage.

大致翻译一下:

  • 长久以来,DNS的实现都没有遵守TTL记录,而且在名称查找结果到期后仍然进行缓存。
  • 有些应用程序仅执行一次DNS查找,然后无限期地缓存结果。
  • 即使应用和库进行了适当的重新解析(即没有上面2条问题),比较低甚至为0的DNS TTL会给DNS服务带来很高的负载,从而使管理变得困难。

可以看到,核心问题就在解析,所以k8s直接使用虚拟IP,而不是DNS。

Service到Pod的流量转发——kube-proxy

通过selector或者手动创建Endpoints(没有selector的service)的方式找到后端只是实现了第一步,当客户端访问Service的时候,还需要把流量也转发过去,这样才算完成功能的闭环。这个流量转发的工作就是通过运行在每个节点上面的网络代理kube-proxy实现的。目前支持代理TCP、UDP和SCTP协议的流量,默认是TCP,具体见Supported protocols

kube-proxy支持3种代理模式:

  1. user space proxy mode
  2. iptables proxy mode
  3. IPVS proxy mode

下面分别介绍。

user space proxy mode

在user space proxy模式下,kube-proxy会watch k8s控制平面Service和Endpoints对象的增加和删除。每当有Service创建的时候,就会在本地随机挑选一个端口进行监听,当有请求发到这个代理端口的时候,就会被转发到后端的Endpoint,也即转发到实际的Pod。同时,kube-proxy也会创建iptable规则,将发送到ClusterIP:port上面的流量转发到上面的随机端口上。整个流程图如下(图片来自官网):

在user space proxy模式下,kube-proxy默认使用round-robin算法向后端转发流量。

iptables proxy mode

user space proxy如其名字所示,是在用户空间的,工作的时候需要从用户态切换到内核;而iptables是工作在内核空间的(准确的说iptables只是个配置规则的工具,实质解析规则、发挥作用的是内核中的netfilter模块),所以基于iptables的iptables proxy也是直接工作在内核空间的。在该模式下,kube-proxy依旧会watch k8s控制平面Service和Endpoints对象的增加和删除。当监测到增加Service的时,就会创建iptable规则,将发送到ClusterIP:port上面的流量转发到后端Endpoints上面(最终会转发到Pod上面)。当监测到Endpoints增加时,就会创建iptables,选择一个后端的Pod。默认情况下,工作在该模式的kube-proxy是随机选择后端的。

因为工作在内核空间,iptables proxy会比user space proxy更加可靠一些。但有一个弊端就是在iptables proxy模式下,如果最终转发的某个Pod没有响应,那请求就会失败。而user space proxy模式下,则会自动重试其它后端。如果要避免这种问题,可以在部署Pod时增加readiness probes检测,这样就可以提前剔除不正常的Pod了。

iptables proxy模式的流程如下(图片来自官网):

IPVS proxy mode

讲解IPVS proxy mode之前先了解一些背景知识。

维基百科的定义是这样的:

IPVS (IP Virtual Server) implements transport-layer load balancing, usually called Layer 4 LAN switching, as part of the Linux kernel. It's configured via the user-space utility ipvsadm(8) tool.

IPVS is incorporated into the Linux Virtual Server (LVS), where it runs on a host and acts as a load balancer in front of a cluster of real servers. IPVS can direct requests for TCP- and UDP-based services to the real servers, and make services of the real servers appear as virtual services on a single IP address. IPVS is built on top of the Netfilter.

Linux Virtual Server (LVS) is load balancing software for Linux kernel–based operating systems.

以下摘自IPVS-Based In-Cluster Load Balancing Deep Dive

什么是 IPVS ?

IPVS (IP Virtual Server)是在 Netfilter 上层构建的,并作为 Linux 内核的一部分,实现传输层负载均衡。
IPVS 集成在 LVS(Linux Virtual Server,Linux 虚拟服务器)中,它在主机上运行,并在物理服务器集群前作为负载均衡器。IPVS 可以将基于 TCP 和 UDP 服务的请求定向到真实服务器,并使真实服务器的服务在单个IP地址上显示为虚拟服务。 因此,IPVS 自然支持 Kubernetes 服务。

为什么为 Kubernetes 选择 IPVS ?

随着 Kubernetes 的使用增长,其资源的可扩展性变得越来越重要。特别是,服务的可扩展性对于运行大型工作负载的开发人员/公司采用 Kubernetes 至关重要。
Kube-proxy 是服务路由的构建块,它依赖于经过强化攻击的 iptables 来实现支持核心的服务类型,如 ClusterIP 和 NodePort。 但是,iptables 难以扩展到成千上万的服务,因为它纯粹是为防火墙而设计的,并且基于内核规则列表。
尽管 Kubernetes 在版本v1.6中已经支持5000个节点,但使用 iptables 的 kube-proxy 实际上是将集群扩展到5000个节点的瓶颈。 一个例子是,在5000节点集群中使用 NodePort 服务,如果我们有2000个服务并且每个服务有10个 pod,这将在每个工作节点上至少产生20000个 iptable 记录,这可能使内核非常繁忙。
另一方面,使用基于 IPVS 的集群内服务负载均衡可以为这种情况提供很多帮助。 IPVS 专门用于负载均衡,并使用更高效的数据结构(哈希表),允许几乎无限的规模扩张。

可以看到,IPVS底层技术和iptables一样,都是基于内核的Netfilter,不过使用哈希表作为底层数据结构,所以更加高效。在IPVS proxy模式下,kube-proxy同样监控Service和Endpoints,根据两者的变动调用netlink接口创建对应的IPVS规则,并定期将规则与Service和Endpoints进行同步。当客户端访问Service的时候,IPVS根据规则将请求转发到后端Pod。流程图如下:

相比于前面的两种proxy,IPVS proxy支持更多的转发规则:

  • rr: round-robin
  • lc: least connection (smallest number of open connections)
  • dh: destination hashing
  • sh: source hashing
  • sed: shortest expected delay
  • nq: never queue

如果使用IPVS proxy模式,必须确保在kube-proxy启动前就安装好IPVS(IPVS依赖6个内核模块:ip_vs、ip_vs_rr、ip_vs_wrr、ip_vs_sh、nf_conntrack_ipv4)。如果kube-proxy没有检测到必须的内核模块,就会回退使用iptables proxy。

几种代理模式注意点

1, 如何设置基于Session Affinity进行转发?从上面的介绍可以看到,不同的proxy模式,转发的默认规则也不一样。有默认基于round-robin的,也有随机的。如果想使用Session Affinity的话,在Service定义时将service.spec.sessionAffinity定义为ClientIP即可(默认值为None),比如下面的示例:

apiVersion: v1
kind: Service
metadata:
  name: app1-svc
spec:
  sessionAffinity: ClientIP   # 默认值为 None
  ports:
    - protocol: TCP
      port: 80
      targetPort: 9376

2, 如何指定使用哪种proxy?k8s 1.2版本之前默认使用user space proxy。1.1版本中支持了iptables proxy,并且从1.2版本开始将iptables proxy设置为默认proxy。1.8版本中引入了IPVS模块,但默认没有开启。用户可以通过kube-proxy的命令行参数--proxy-mode选择要使用的proxy模式,可选值为:userspaceiptablesipvs。如果为空(默认),则使用对应版本的默认值。需要注意的是,iptables proxy和ipvs proxy都对内核版本有要求,因为他们依赖的内核功能是在后来的内核中加入的。所以即使用户设置了ipvs,如果节点内核不满足需求,就会自动降级使用iptables;如果内核版本或者iptables版本仍然太低,就会降级为userspace。当然,不论使用哪种proxy,对于最终使用service的人都是无感知的。

多端口Service

多端口服务就是一个服务暴露多个端口,比如像下面这样同时暴露80和443端口:

apiVersion: v1
kind: Service
metadata:
  name: my-service
spec:
  selector:
    app: MyApp
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 9376
    - name: https
      protocol: TCP
      port: 443
      targetPort: 9377

当暴露多个端口的时候,name字段必须填写。

有了Service以后,其它Pod还需要能够发现这个Service才可以,k8s支持两种方式:环境变量和DNS。

这种方式很简单,当我们创建一个Service的时候,kubelet会生成两个环境变量注入到之后启动的所有Pod里面。这两个环境变量是:{SVCNAME}_SERVICE_HOST{SVCNAME}_SERVICE_PORT,服务名称会全部转为大写,如果包含中划线还会转为下划线。有了服务名和端口,就可以访问服务了。以系统自带的kubernetes Service为例:

➜  ~ kubectl get svc
NAME         TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   32d

# 部署一个busybox pod,然后查看其环境变量:
➜  ~ kubectl exec -it busybox -- env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=busybox
TERM=xterm
KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1
KUBERNETES_SERVICE_HOST=10.96.0.1   # {SVCNAME}_SERVICE_HOST
KUBERNETES_SERVICE_PORT=443         # {SVCNAME}_SERVICE_PORT
KUBERNETES_SERVICE_PORT_HTTPS=443
KUBERNETES_PORT=tcp://10.96.0.1:443
KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443
KUBERNETES_PORT_443_TCP_PROTO=tcp
KUBERNETES_PORT_443_TCP_PORT=443
HOME=/root

这种方式很简单,但弊端似乎也很明显:当服务多了以后,环境变量会非常非常多,不利于维护。另外一个非常大的弊端是这种方式对于顺序有要求:如果要一个Pod要能够被注入某个Service相关的环境变量信息,那这个Pod就必须在Service之后才创建,这种顺序依赖对于大型系统几乎是不可接受的,所以环境变量这种方式在大型系统中使用的较少。更多的都是使用DNS。

手动部署过k8s集群的人都知道,DNS组件在k8s中是一个可选的组件,是以扩展(addons)的形式提供的。但因为环境变量有上述所说的问题,所以一般都会部署一个DNS集群,常用的有kube-dns和core-dns(推荐)。DNS组件会监控新服务的产生,当监控到一个服务被创建后,就会给该服务创建对应的DNS记录。如果DNS功能是整个集群可用的话,所有的Pod就能根据这个DNS记录解析到对应的服务。

每个服务的DNS名创建的规则是:{service-name}.{namespace}。处于同一个Namespace的Pod访问服务时,可以直接使用{service-name};处于不同Namespace事,需要带上Namespace。DNS会将该DNS名字解析为服务的ClusterIP。

无头服务(Headless Service)

当我们不需要Service提供的负载均衡或虚拟IP时,就可以使用无头服务。定义无头服务的方式就是将service.spec.clusterIP字段设置为None。此时,如何找到后端需要根据是否定义了service.spec:selector进行区分:

  1. 定义了selector:根据selector匹配后端即可;
  2. 没有定义selector时,分两种情况:

    • 如果是ExternalName类型的Service(后面有介绍),DNS则直接返回定义的CNAME记录;
    • 如果是其它类型的Service,DNS则查找与Service名称相同的任何Endpoints的记录。

比如ElasticSearch的9300端口是ES集群内部通信使用的,不需要暴露给用户(即不需要虚拟IP),所以该端口上面的服务可以定义成无头服务:

apiVersion: v1
kind: Service
metadata:
  name: elasticsearch-cluster
  namespace: elasticsearch
spec:
  clusterIP: None
  selector:
    app: es-cluster
  ports:
    - name: transport
      port: 9300

集群外访问Service

有的时候我们需要从集群外部访问Service,目前有两大类方式:

  1. 通过k8s提供的Service Type(service.spec.type)里面的一些类型:NodePortLoadBalancerExternalName。这些方式都是工作在L4的,只能提供基础的负载均衡功能。
  2. 使用Ingress。Ingress不是Service type,它也是一种独立的k8s API资源。Ingress是专门用于解决集群外访问Service问题的,它工作在L7,所以拥有更加强大的功能,比如高级的流量转发、SSL等,后面单独文章专门介绍。

下面介绍几种Service Type。

ClusterIP

ClusterIPservice.spec.type的默认值,即将服务暴露到集群内部的一个虚拟IP(ClusterIP)上面,此时是无法从外部访问集群的。所以该方式不算是从集群外部访问Service的方式,这里放在这里,以方面是为了完整介绍service.spec.type的可选值,另一方面,下面介绍的其它几种Service Type一般也会自动创建ClusterIP,而且外部的流量进来一般也是转发到这个内部的ClusterIP上面。

NodePort

NodePort的原理是这样的:当我们定义一个使用NodePort的Service时,k8s控制平面就会从kube-apiserver的--service-node-port-range参数定义的端口范围内选择一个未使用的端口,然后每个节点上面都打开这个端口。如果用户定义了service.spec.ports[*].nodePort,则使用用户定义的端口;若该端口已经被占用,则Service创建失败。

NodePort几乎是相对较小的k8s集群对外暴露服务最常用的方式了,因为它无依赖,且使用比较简单。一个示例资源文件如下:

apiVersion: v1
kind: Service
metadata:
  name: elasticsearch-nodeport
  namespace: elasticsearch
spec:
  selector:
    app: es-cluster
  type: NodePort
  ports:
    - name: http
      port: 80
      # 如果不设置`targetPort`,则默认与`port`一样.
      targetPort: 9200 
      # 如果不设置,则会在`--service-node-port-range` (default: 30000-32767)设置的范围内选一个可用的端口
      nodePort: 30091
      protocol: TCP

这里涉及了3个端口:

  • port: 即ClusterIP暴露的端口,用户通过ClusterIP:port来访问后端的应用。需要注意的是这个端口并不监听在节点上面,所以在节点上面是看不到的,所以也无法通过节点IP:port来访问服务。
  • targetPort: 即后端Pod里面的容器服务监听的端口,前面介绍的kube-proxy就是要将port的流量转发到targetPort去。
  • nodePort: 即NodePort这种方式对外暴露的端口。这个端口是暴露在各个节点上的,所以我们可以直接通过任意一个节点IP:nodePort的方式访问服务。

上面的elasticsearch-nodeport Service创建后如下:

➜  ~ kubectl get svc
NAME                         TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)                                          AGE
elasticsearch-nodeport       NodePort       10.98.96.32      <none>        80:30091/TCP                                     21d

➜  ~ kubectl describe svc elasticsearch-nodeport
Name:                     elasticsearch-nodeport
Namespace:                default
Labels:                   <none>
Annotations:              Selector:  app=es-cluster
Type:                     NodePort
IP:                       10.98.96.32   # 即使使用NodePort,也会分配一个ClusterIP
Port:                     http  80/TCP
TargetPort:               9200/TCP
NodePort:                 http  30091/TCP
Endpoints:                <none>
Session Affinity:         None
External Traffic Policy:  Cluster
Events:                   <none>

也就是通过上面定义的Service,我们有3种方式可以访问后端Pod里面9200端口上面的ElasticSearch服务:

    • 直接访问Pod:PodIP:targetPort,即PodIP:9200
    • 通过ClusterIP访问:ClusterIP:port,即10.98.96.32:80
  1. 集群外:通过NodePort暴露的端口访问:任意一个节点IP:30091

默认情况下,通过NodePort这种方式暴露出来的服务,可以使用任意节点的任意IP访问,但如果一个节点有多个网卡,我们只想让某个网卡可以访问的话(生产环境经常有多个网卡用于不同用途,且相互隔离),可以给kube-proxy增加--nodeport-addresses=10.0.0.0/8参数,这样kube-proxy就只会在处于当前IP范围的IP上暴露服务了。

LoadBalancer

这种方式一般需要云厂商支持,定义方式类似下面这样:

apiVersion: v1
kind: Service
metadata:
  name: elasticsearch-loadbalancer
  namespace: elasticsearch
spec:
  type: LoadBalancer
  selector:
    app: es-cluster
  ports:
    - name: http
      port: 9200
      targetPort: 9200

LoadBalancer方式的Service创建是异步的,所以即使你的k8s集群没有LoadBalancer功能,你也可以创建该类型的Service,只不过会一直分配不到外部IP(EXTERNAL-IP),比如我的k8s集群:

➜  ~ kubectl -n logkeeper-adv get svc
NAME                         TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)                                          AGE
elasticsearch-loadbalancer   LoadBalancer   10.101.94.173    <pending>     9200:31763/TCP                                   21d

LoadBalancer方式的很多功能都是由云厂商自己实现的,所以不同厂商的产品可能不太一样,需要结合自己使用的产品查看。

ExternalName

这种类型主要用于以Service的方式集成外部服务,比如外部数据库。定义方式如下:

apiVersion: v1
kind: Service
metadata:
  name: my-service
  namespace: prod
spec:
  type: ExternalName
  externalName: my.database.example.com

定义该服务后,当访问my-service.prod.svc.cluster.local时,集群的DNS就会返回my.database.example.com这样一个CNAME记录。该类型需要注意的是:

  • externalName字段需要填写DNS名称,如果直接填写IP,也会被当成是DNS名称去解析,而不是直接当成IP使用;
  • 该服务类型的工作方式与前面介绍的几种大致相同,唯一区别在于重定向发生在DNS解析时,而之前介绍的服务类型重定向或者转发是由kube-proxy做的。

External IPs

当我们有一些可用的外部IP可以路由到集群的话,也可以通过External IPs这种方式将服务暴露出去。看个例子吧:

apiVersion: v1
kind: Service
metadata:
  name: my-service
spec:
  selector:
    app: MyApp
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 9376
  externalIPs:
    - 80.11.12.10

当我们创建一个这样的Service之后,就可以通过externalIP:port(即80.11.12.10:80)访问后端的9376端口上面的服务。此时Service Type设置为上面介绍的任意一种都行。这里省略了,默认就使用ClusterIP。

本文内容篇幅较长,但核心内容就3个:

  1. 为什么需要Service?因为Service能够提供一个稳定的虚拟IP,并且将流量负载均衡到后端Pod上面。
  2. 如何将Service的流量转发到后端Pod上面?这个是通过运行在各个节点上面的kube-proxy实现的,而kube-proxy则支持3种运行模式:userspace proxy、iptables proxy,IPVS proxy。
  3. 如何从集群外部访问Service?可以通过NodePort、LoadBalancer、ExternalName这几种Service Type或者ExternalIPs将Service暴露到集群外面。除了这些,还有更高级的工作在L7的Ingress,下篇文章介绍。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK