kubernetes资源对象应用类(二)
Service 的 ClusterIP 地址
既然每个 Pod 都会被分配一个单独的 IP 地址,而且每个 Pod 都提供了一个独立的 Endpoint(Pod IP + containerPort)以被客户端访问,那么现在多个 Pod 副本组成了一个集群来提供服务,客户端如何访问他们呢?传统的做法是部署一个负载均衡器(软件或硬件),为这组 Pod 开启一个对外的服务端口如 8000 端口,并且将这些 Pod 的 Endpoint 列表加入 8000 端口的转发列表中,客户端就可以通过负载均衡器的对外 IP 地址 + 8000 端口来访问此服务了。Kubernetes 也是类似的做法,Kubernetes 内部在每个Node上都运行了一套全局的虚拟负载均衡器,自动注入并自动实时更新集群中所有 Service 的路由表,通过 iptables 或者 IPVS 机制,把对 Service 的请求转发到其后端对应的某个 Pod 实例上,并在内部实现服务的负载均衡与会话保持机制。不仅如此,Kubernetes 还采用了一种很巧妙又影响深远的设计—— ClusterIP 地址。我们知道 Pod 的Endpoint 地址会随着 Pod 的销毁和重新创建而发生改变,因为新的 Pod 的 IP 地址与之前旧的 Pod 的不同。 Service 一旦被创建,Kubernetes 就会自动为它分配一个全局唯一的虚拟 IP 地址—— ClusterIP 地址,而且在 Service 的整个生命周期内,其 ClusterIP 地址不会发生改变,这样一来,每个服务就变成了具备唯一 IP 地址的通信节点,远程服务之间的通信问题就变成了基础的 TCP 网络通信问题。
任何分布式系统都会涉及“服务发现”这个基础问题,大部分分布式系统都通过提供特定的 API 来实现服务发现功能,但这样做会导致平台的侵入性较强。也增加了开发、测试的难度。Kubernetes 则采用了直观朴素的思路轻松解决了这个棘手的问题:只要用 Service 的 Name 与 ClusterIP 地址做一个 DNS 域名映射即可。比如我们定义一个 MySQL Service, Service 的名称是 mydbserver,Service 的端口是 3306,则在代码中直接通过 mydbserver:3306 即可访问此服务,不再需要任何 API 来获取服务的 IP 地址和端口信息。
之所以说 CLusterIP 地址是一种虚拟 IP 地址,原因有以下几点。
- CLusterIP 地址仅仅作用于 Kubernetes Service 这个对象,并由 Kubernetes 管理和分配 IP 地址(来源于 ClusterIP 地址池),与 Node 和 Master 所在的物理网络完全无关。
- 因为没有一个“实体网络对象”来响应,所以 ClusterIP 地址无法被 Ping 通。ClusterIP 地址只能与 Service Port 组成一个具体的服务访问端点,单独的 ClusterIP 不具备 TCP/IP 通信的基础。
- CLusterIP 属于 Kubernetes 集群这个封闭的空间,集群外的节点要访问这个通信端口,则需要做一些额外的工作。
下面是名为 tomcat-service.yaml 的 Service 定义文件,内容如下:
apiVersion: v1
kind: Service
metadata:name: tomcat-service
spec:ports:- port: 8080selector:tier: frontend
以上代码定义了一个名为 tomcat-service 的 Service,它的服务端口为 8080,拥有 tier=frontend 标签的所有 Pod 实例都属于它,运行下面的命令进行创建:
kubectl create -f tomcat-service.yaml
我们之前在 tomcat-service.yaml 里定义的 Tomcat 的 Pod 刚好拥有这个标签,所以刚才创建的 tomcat-service 已经对应了一个 Pod 实例,运行下面的命令可以查看 tomcat-service 的 Endpoint 列表,其中 172.17.1.3 是 Pod 的 IP 地址,8080 端口是 Container 暴露的端口:
kubectl get endpoints
NAME | ENDPOINTS | AGE |
---|---|---|
kubernetes | 192.168.18.131:6443 | 15d |
tomcat-service | 172.17.1.3:8080 | 1m |
你可能有疑问:“说好的 Service 的 ClusterIP 地址呢?怎么没有看到!” 运行下面的命令即可看到 tomcat-service 被分配的 ClusterIP 地址及更多的信息:
kubectl get svc tomcat-service -o yaml
apiVersion: v1
kind: Service
spec:clusterIP: 10.245.85.70ports:- port: 8080protocol: TCPtargetPort: 8080selector:tier: frontendsessionAffinity: Nonetype: ClusterIP
status:loadBalancer: {}
在 spec.ports 的定义中,targetPort 属性用来确定提供该服务的容器所暴露(Expose)的端口号,即具体的业务进程在容器内的 targetPort 上提供 TCP/IP 接入; port 属性则定义了 Service 端口。前面定义 Tomcat 服务时并没有指定 targetPort ,所以 targetPort 默认与 port 相同。除了正常的 Service,还有一种特殊的 Service——Headless Service,只要在 Service 的定义中设置了 clusterIP:None,就定义了一个 Headless Service, 它与普通 Service 的关键区别在于它没有 ClusterIP 地址,如果解析 Headless Service 的 DNS 域名,则返回的是该 Service 对应的全部 Pod 的 Endpoint 列表,这意味着客户端是直接与后端的 Pod 建立 TCP/IP连接进行通信的,没有通过虚拟 ClusterIP 地址进行转发,因此通信性能最高,等同于“原生网络通信”。
接下来看看 Service 的多端口问题。很多服务都存在多个端口,通常一个端口提供业务服务,另一个端口提供管理服务,比如 Mycat、Codis等常见中间件。Kubernetes Service 支持多个 Endpoint,在存在多个 Endpoint 的情况下,要求每个 Endpoint 都定义一个名称进行区分。下面是 Tomcat 多端口的 Service 定义样例:
apiVersion: v1
kind: Service
metadata:name: tomcat-service
spec:ports:- port: 8080name: service-port- port: 8005name: shutdown-portselector:tier: frontend
Service 的外网访问问题
前面提到,服务的 ClusterIP 地址在 Kubernetes 集群内才能被访问,那么如何让集群外的应用访问我们的服务呢?这也是一个相对复杂的问题。要弄明白这个问题的解决思路和解决办法,我们需要先弄明白 Kubernetes 的三种 IP,这三种 IP 分别如下。
- Node IP: Node 的 IP 地址。
- Pod IP:Pod 的 IP 地址。
- Service IP:Service 的 IP 地址。
首先,Node IP 是 Kubernetes 集群中每个节点的物理网卡的 IP 地址,是一个真实存在的物理网络,所有属于这个网络的服务器都能通过这个网络直接通信,不管其中是否有部门节点不属于这个 Kubernetes 集群。这也表明 Kubernetes 集群之外的节点访问 Kubernetes 集群内的某个节点或者 TCP/IP 服务时,都必须通过 Node IP 通信。
其次,Pod IP 是每个 Pod 的 IP 地址,在使用 Docker 作为容器支持引擎的情况下,它是 Docker Engine 根据 docker() 网桥的 IP 地址段进行分配的,通常是一个虚拟二层网络。前面说过,Kubernetes 要求位于不同 Node 上的 Pod 都能够彼此直接通信,所以 Kubernetes 中一个 Pod 里的容器访问另外一个 Pod 里的容器时,就是通过 Pod IP 所在的虚拟二层网络进行通信的,而真是的 TCP/IP 流量是通过 Node IP 所在的物理网卡流出的。
在 Kubernetes 集群内,Service 的 ClusterIP 地址属于集群内的地址,无法在集群外直接使用这个地址。为了解决这个问题,Kubernetes 首先引入了 NodePort 这个概念,NodePort 也是解决集群外的应用访问集群内服务的直接、有效的做法。
以 tomcat-service 为例,在 Service 的定义里做如下扩展即可
tomcat-service.yaml
apiVersion: v1
kind: Service
metadata:name: tomcat-service
spec:type: NodePortports:- port: 8080nodePort: 31002selector:tier: frontend
其中,nodePort:31002 这个属性表明手动指定 tomcat-service 的 NodePort 为 31002 ,否则 Kubernetes 会自动为其分配一个可用的端口。接下来在浏览器里访问 http://:31002/,就可以看到 Tomcat 的欢迎界面了。
NodePort 的确功能强大且通用性强,但也存在一个问题,即每个 Service 都需要在 Node 上独占一个端口,而端口又是有限的物力资源,那能不能让多个 Service 共用一个对外端口呢?这就是后来增加的 Ingress 资源对象所要解决的问题。在一定程度上,我们可以把 Ingress 的实现机制理解为基于 Nginx 的支持虚拟主机的 HTTP 代理。下面是一个 Ingress 的实例:
kind: Ingress
metadata:name: name-virtual-host-ingress
spec:rules:- host: foo.bar.comhttp:paths:- backend:serviceName: service1servicePort: 80- host: bar.foo.comhttp:paths:- backend:serviceName: service2servicePort: 80
有状态的应用集群
我们知道,Deployment 对象是用来实现无状态服务的多副本自动控制功能的,那么有状态的服务,比如 ZooKeeper 集群、MySQL 高可用集群(3 节点集群)、Kafka 集群等是怎么实现自动部署和管理的呢?这个问题就复杂多了,这些一开始是依赖 StatefulSet 解决的,但后来发现对于一些复杂的有状态的集群应用来说,StatefulSet 还是不够通用和强大,所以后面又出现了 Kubernetes Operator。
我们先说说 StatefulSet 。StatefulSet 之前曾用过 PetSet 这个名称,很多人都知道,在 IT 世界里,有状态的应用被类比为宠物(Pet),无状态的应用则被类比为牛羊,每个宠物在主人那里都是“唯一的存在”,宠物生病了,我们是要花很多钱去治疗的,需要我们用心照料,而无差别的牛羊则没有这个待遇。总结下来,在有状态集群中一般有如下特殊共性。
- 每个节点都有固定的身份 ID,通过这个 ID,集群中的成员可以相互发现并通信。
- 集群的规模是比较固定的,集群规模不能随意变动。
- 集群中的每个节点都是有状态的,通常会持久化数据到永久存储中,每个节点在重启后都需要使用原有的持久化数据。
- 集群中成员节点的启动顺序(以及关闭顺序)通常也是确定的。
- 如果磁盘损坏,则集群里的某个节点无法正常运行,集群功能受损。
如果通过 Deployment 控制 Pod 副本数量来实现以上有状态的集群,我们就会发现上述很多特性大部分难以满足,比如 Deployment 创建的 Pod 因为 Pod 的名称是随机产生的,我们事先无法为每个 Pod 都确定唯一不变的 ID,不同 Pod 的启动顺序也无法保证,所以在集群中的某个成员节点宕机后,不能在其他节点上随意启动一个新的 Pod 实例。另外,为了能够在其他节点上恢复某个失败的节点,这种集群中的 Pod 需要挂接某种共享存储,为了解决有状态集群这种复杂的特殊应用集群,Kubernetes 引入了专门的资源对象 StatefulSet。StatefulSet 从本质上来说,可被看作 Deployment/RC 的一个特殊变种,它有如下特性。
- StatefulSet 里的每个 Pod 都有稳定、唯一的网络标识,可以用来发现集群内的其他成员。假设 StatefulSet 的名称为 kafka,那么第 1 个 Pod 叫 kafka-0, 第 2 个叫kafka-1,以此类推。
- StatefulSet 控制的 Pod 副本的启停顺序是受控的,操作第 n 个 Pod 时,前 n - 1个 Pod 已经是运行且准备好的状态。
- StatefulSet 里的 Pod 采用稳定的持久化存储卷,通过 PV 或 PVC 来实现,删除 Pod 时默认不会删除与 StatefulSet 相关的存储卷(为了保证数据安全)。
StatefulSet 除了要与 PV 卷捆绑使用,以存储 Pod 的状态数据,还要与 Headless Service 配合使用,即在每个 StatefulSet 定义中都要声明它属于哪个 Headless Service。StatefulSet 在 Headless Service 的基础上又为 StatefulSet 控制的每个 Pod 实例都创建了一个 DNS 域名,这个域名格式如下:
${podname}.${headless service name}
比如一个 3 节点的 Kafka 的 StatefulSet 集群对应的 Headless Service 的名称为 kfafka,StatefulSet 的名称为 kafka,则 StatefulSet 里的 3 个 Pod 的 DNS 名称分别为 kafak-0.kafka 、 kafka-1.kafka、kafka-2.kafka,这些 DNS名称可以直接在集群的配置文件中固定下来。
StatefulSet 的建模能力有限,面对复杂的有状态集群时显得力不从心,所以就有了后来的 Kubernetes Operator 框架和众多的 Operator 实现了。需要注意的是,Kubernetes Operator 框架并不是面向普通用户的,而是面向 Kubernetes 平台开发者的。平台开发者借助 Operator 框架提供的 API,可以更方便地开发一个类似 StatefulSet 的控制器。在这个控制器里,开发者通过编码方式实现对目标集群的自定义操控,包括集群部署、故障发现及集群调整等方面都可以实现有针对性的操控,从而实现更好的自动部署和智能运维功能。从发展趋势来看,未来主流的有状态集群基本都会以 Operator 方式部署到 Kubernetes 集群中。
批处理应用
除了无状态服务、有状态集群、常见的第三种应用,还有批处理应用。批处理应用的特点是一个或多个进程处理一组数据(图像、文件、视频等),在这组数据都处理完成后,批处理任务自动结束。为了支持这类应用,Kubernetes 引入了新的资源对象——Job,下面是一个计算圆周率的经典例子:
apiVersion: batch/v1
kind: Job
metadata:name: pi
spec:template:spec:containers:- name: piimage: perlcommand: ["perl", "-Mbignum=bpi", "-wle", "print bpi(100)"]restartPolicy: Neverparallelism: 1completions: 5
Jobs控制器提供了两个控制并发数的参数:completions 和 parallelism,completions表示需要运行任务数的总数,parallelism 表示并发运行的个数,例如设置 parallelism 为 1,则会依次运行任务,在前面的任务运行后再运行后面的任务。Job 所控制的 Pod 副本是短暂运行的,可以将其视为一组容器,其中的每个容器都仅运行一次。当 Job 控制的所有 Pod 副本都运行结束时,对应的 Job 也就结束了。Job 在实现方式上与 Deployment 等副本控制器不同,Job 生成的 Pod 副本是不能自动重启的,对应 Pod 副本的 restartPolicy 都被设置为 Never,因此,当对应的 Pod 副本都执行完成时,相应的 Job 也就完成了控制使命。后来,Kubernetes 增加了 CronJob,可以周期地执行某个任务。
应用的配置问题
通过前面的学习,我们初步理解了三种应用建模的资源对象,总结如下。
- 无状态服务的建模:Deployment。
- 有状态集群的建模:StatefulSet。
- 批处理应用的建模:Job。
在进行应用建模时,应该如何解决应用需要在不同的环境中修改配置的问题呢?这就涉及 ConfigMap 和 Secret 两个对象。
ConfigMap 顾名思义,就是保存配置项(key=value)的一个 Map,如果你只是把它理解为编程语言中的一个 Map,那就大错特错了。ConfigMap 是分布式系统中“配置中心”的独特实现之一。我们知道,几乎所有应用都需要一个静态的配置文件来提供启动参数,当这个应用是一个分布式应用,有多个副本部署到不同的机器上时,配置文件的分发就成为一个让人头疼的问题,所以很多分布式系统都有一个配置中心的组件,来解决这个问题。但配置中心通常会引入新的 API,从而导致应用的耦合和侵入。Kubernetes 则采用了一种简单的方案来规避这个问题,如图 1.13 所示,具体做法如下。
- 用户将配置文件的内容保存到 ConfigMap 中,文件名可作为 key,value 就是整个文件的内容,多个配置文件都可被放入同一个 ConfigMap。
- 在建模用户应用时,在 Pod 里将 ConfigMap 定义为特殊的 Volume 进行挂载。在 Pod 被调度到某个 Node 上时,ConfigMap 里的配置文件会被自动还原到本地目录下,然后映射到 Pod 里指定的配置目录下,这样用户的程序就可以无感知地读取配置了。
- 在 ConfigMap 的内容发生修改后,Kubernetes 会自动重新获取 ConfigMap 的内容,并在目标节点上更新对应的文件。
接下来说说 Secret。Secret 也用于解决应用配置的问题,不过它解决的是对敏感信息的配置问题,比如数据库的用户名和密码、应用的数字证书、Token、SSH 密钥及其他需要保密的敏感配置。对于这类敏感信息,我们可以创建一个 Secret 对象,然后被 Pod 引用。Secret 中的数据要求以 BASE64 编码格式存放。注意,BASE64 并不是加密的,在 Kubernetes1.7 版本以后,Secret 中的数据才可以以加密的形式进行保存,更加安全。
应用的运维问题
本节最后说说与应用的自动运维相关的几个重要对象。
首先就是 HPA(Horizontal Pod Autoscaler),如果我们用 Deployment 来控制 Pod 的副本数量,则可以通过手工运行 kubectl scale 命令来实现 Pod 扩容或缩容。如果仅仅到此为止,则显然不符合谷歌对 Kubernetes 的定位目标——自动化、智能化。在谷歌看来,分布式系统要能够根据当前负载的变化自动触发水平扩容或缩容,因为这一过程可能是频繁发生、不可预料的,所以采用手动控制的方式是不现实的,因此就有了后来的 HPA 这个高级功能。我们可以将 HPA 理解为 Pod 横向自动扩容,即自动控制 Pod 数量的增加或减少。通过追踪分析指定 Deployment 控制的所有目标 Pod 的负载变化情况,来确定是否需要有针对性地调整目标 Pod 的副本数量,这是 HPA 的实现原理。Kubernetes 内置了基于 Pod 的 CPU 利用率进行自动扩缩容的机制,应用开发者也可以自定义度量指标如每秒请求数,来实现自定义的 HPA 功能。下面是一个 HPA 定义的例子:
apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:name: php-apachenamespace: default
spec:maxReplicas: 10minReplicas: 1scaleTargetRef:kind: Deploymentname: php-apachetargetCPUUtilizationPercentage: 50
根据上面的定义,我们可以知道这个 HPA 控制的目标对象是一个名为 php-apache 的 Deployment 里的 Pod 副本,当这些 Pod 副本的 CPU利用率的值超过 90%时,会触发自动动态扩容,限定 Pod 的副本数量为 1~10。HPA 很强大也比较复杂,我们在后续章节中会继续深入学习。
接下来就是 VPA(Vertical Pod Autoscaler),即垂直 Pod 自动扩缩容,它根据容器资源使用率自动推测并设置 Pod 合理的 CPU 和内存的需求指标,从而更加精确地调度 Pod,实现整体上节省集群资源的目标,因为无须人为操作,因此也进一步提升了运维自动化的水平。VPA 目前属于比较新的特性,也不能与 HPA 共同操控同一组目标 Pod,它们未来应该会深入融合,建议读者关注其发展状况。