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

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

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

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

service

图中通过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上面的流量转发到上面的随机端口上。整个流程图如下(图片来自官网):

services-userspace-overview

在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模式的流程如下(图片来自官网):

services-iptables-overview

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。流程图如下:

services-ipvs-overview

相比于前面的两种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。

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服务:

  1. 集群内:

    • 直接访问Pod:PodIP:targetPort,即PodIP:9200
    • 通过ClusterIP访问:ClusterIP:port,即10.98.96.32:80
  2. 集群外:通过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,下篇文章介绍。