K8s的网络主要需要解决4个问题:
- 高度耦合的容器间的通信问题
- Pod与Pod之间的通信
- Pod和Service之间的通信(本文)
- 外部系统和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种代理模式:
- user space proxy mode
- iptables proxy mode
- 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-robinlc
: least connection (smallest number of open connections)dh
: destination hashingsh
: source hashingsed
: shortest expected delaynq
: 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模式,可选值为:userspace、iptables、ipvs。如果为空(默认),则使用对应版本的默认值。需要注意的是,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
进行区分:
- 定义了selector:根据selector匹配后端即可;
没有定义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,目前有两大类方式:
- 通过k8s提供的Service Type(
service.spec.type
)里面的一些类型:NodePort、LoadBalancer、ExternalName。这些方式都是工作在L4的,只能提供基础的负载均衡功能。 - 使用Ingress。Ingress不是Service type,它也是一种独立的k8s API资源。Ingress是专门用于解决集群外访问Service问题的,它工作在L7,所以拥有更加强大的功能,比如高级的流量转发、SSL等,后面单独文章专门介绍。
下面介绍几种Service Type。
ClusterIP
ClusterIP
是service.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
;
- 直接访问Pod:
- 集群外:通过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个:
- 为什么需要Service?因为Service能够提供一个稳定的虚拟IP,并且将流量负载均衡到后端Pod上面。
- 如何将Service的流量转发到后端Pod上面?这个是通过运行在各个节点上面的kube-proxy实现的,而kube-proxy则支持3种运行模式:userspace proxy、iptables proxy,IPVS proxy。
- 如何从集群外部访问Service?可以通过NodePort、LoadBalancer、ExternalName这几种Service Type或者ExternalIPs将Service暴露到集群外面。除了这些,还有更高级的工作在L7的Ingress,下篇文章介绍。
评论已关闭