一、Pod是什么?
pod用于解决应用间的紧密交互问题,它只是一个逻辑概念,我们可以将pod和虚拟机做一个类比。每个虚拟机里边可以运行多个进程,每个pod里边也可以运行多个容器,容器的本质其实就是进程,多个容器之间可以通过localhost进行通信( 容器之间都是共享的pod的网络 ),容器间也可以共享数据卷。
有这么一个有意思的pod分类:
◆ 一种是自主式的Pod
自主式的Pod意思就是没有控制器管理的Pod,当Pod意外死掉了,就不会被重新拉起,它自己也不会自动重启,也不会用副本什么的达到同样的期望值
◆ 一种是控制器(RC\RS\Deployment\HPA\StatefullSet\DaemonSet\Job\Cronjob)管理的Pod
控制器管理的Pod,当其意外down了,会被重新拉起,也能通过特定的控制器实现滚动更新和回滚等特性
Pod是Kubernetes创建或部署的最小/最简单的基本单位,一个Pod代表集群上正在运行的一个进程。
一个Pod封装一个应用容器(也可以有多个容器),存储资源、一个独立的网络IP以及管理控制容器运行方式的策略选项。Pod代表部署的一个单位:Kubernetes中单个应用的实例,它可能由单个容器或多个容器共享组成的资源。
Pods提供两种共享资源:网络和存储。
网络
每个Pod被分配一个独立的IP地址,Pod中的每个容器共享网络命名空间,包括IP地址和网络端口。Pod内的容器可以使用localhost相互通信。当Pod中的容器与Pod 外部通信时,他们必须协调如何使用共享网络资源(如端口)。
存储
Pod可以指定一组共享存储volumes。Pod中的所有容器都可以访问共享volumes,允许这些容器共享数据。volumes 还用于Pod中的数据持久化,以防其中一个容器需要重新启动而丢失数据。
Pod生命周期:Pod 的 status 定义在 PodStatus 对象中,其中有一个 phase 字段。
下面是 phase 可能的值:
挂起(Pending):Pod 已被 Kubernetes 系统接受,但有一个或者多个容器镜像尚未创建。等待时间包括调度 Pod 的时间和通过网络下载镜像的时间,这可能需要花点时间。
运行中(Running):该 Pod 已经绑定到了一个节点上,Pod 中所有的容器都已被创建。至少有一个容器正在运行,或者正处于启动或重启状态。
成功(Succeeded):Pod 中的所有容器都被成功终止,并且不会再重启。
失败(Failed):Pod 中的所有容器都已终止了,并且至少有一个容器是因为失败终止。也就是说,容器以非0状态退出或者被系统终止。
未知(Unknown):因为某些原因无法取得 Pod 的状态,通常是因为与 Pod 所在主机通信失败。
为什么需要Pod?
有了容器,为什么要用Pod呢?
在操作系统中,一个程序的运行实际上由多个线程组成,这多个线程共享进程的资源,相互协作,完成程序的工作,这也是操作系统中进程组的概念
。
在容器中,
PID = 1
的进程就是该应用本身,比如 main 进程。如果我们通过创建一个容器,在容器中启动 Helloworld 的4个进程这种方法来跑这个Helloworld程序。那么问题来了。
容器是“单进程模型”,这个单进程并不是指容器只能运行一个进程,而是说 容器 = 应用 = 进程,如果容器中有多个进程,只有一个可以作为该容器的 PID = 1 的进程。容器本身也只能管理 PId=1 的进程,其它启动的进程都是托管的状态。
容器本身不具有同时管理多个进程的能力。诸如,上述 Helloworld 程序其有4个进程,分别是
main、api、log、compute
,如果 PID = 1 的进程是main进程,那么谁来管理剩余的3个进程呢?而如果在程序运行过程中,PID = 1 的进程死掉了,HelloWorld 的剩余三个进程是不会被回收的,这是一个非常严重的问题.
那么上述的 HelloWorld程序应该如何以容器的形式跑起来呢?1、使应用程序本身具备“进程管理”的功能(这意味着该程序需要具备systemed的能力)。这就平白增添了开发者的负担
2、直接将容器 PID = 1 的进程改为systemed或者在容器中运行一个systemed来达到管理多进程的目的。这将会导致
管理容器 = 管理systemed != 管理应用本身
.这样做的问题是,如果容器中实际run的进程是systemed,那么再接下来的过程中,提供服务的应用是不是退出了,是不是出现异常失败了,都是无法直接感知的,因为实际上管理的是systemed
.这也就是为什么在容器中运行一个复杂容器困难程度很大的原因。简述第二点的问题就是,如果将容器 PID =1 的程序设置为systemed,那么管理容器就相当于管理systemed,那么这时候实际提供服务的应用的生命周期就不与容器的生命周期一致了。
在K8S中,Pod就相当于一个进程组,比如 具有四个进程的HelloWorld程序就会被定义为一个具有四个容器的Pod。也就是说职责不同、相互协作的进程需要放在容器内运行的这样的一个场景下,k8s并不会把它们塞到一个容器中,因为这会导致上述提到的两个问题
。k8s会把诸如 Helloworld 的 4个独立的进程 放到4个不同的容器中,然后把他们定义在一个Pod中。同时也就是说在Kubernetes中,Pod只是一个逻辑概念,真正在物理上存在的东西是真实运行的那几个容器。
那么Pod中的容器是如何实现共享存储和网络的呢?
其实pod里边最先启动的并不是应用容器,而是一个辅助容器Infra( 即下图中的Infra Container )。Infra永远是pod里边最先启动的容器,它负责为pod创建网络和共享存储,而我们的应用容器只是连接到了Infra创建的网络和数据卷上,这样就达到了共享网络空间和数据卷的目的( 同一个pod中的容器端口不能冲突,否则将会无法启动pod )
在 Kubernetes 项目里,Infra 容器一定要占用极少的资源,所以它使用的是一个非常特殊的镜像,这个镜像是一个用汇编语言编写的、永远处于“暂停”状态的容器,解压后的大小也只有 100~200 KB 左右。
而对于同一个 Pod 里面的所有用户业务容器来说,它们的进出流量,可以认为都是通过 Infra 容器完成的。这一点很重要,因为将来如果要为 Kubernetes 开发一个网络插件时,应该重点考虑的是如何配置这个 Pod 的 Network Namespace,而不是每一个用户业务容器如何使用你的网络配置,这是没有意义。这意味着,如果网络插件需要在容器里安装某些包或者配置才能完成的话,是不可取的(网络插件等需要做到业务容器无感知):Infra 容器镜像的 rootfs 里几乎什么都没有,没有随意发挥的空间。
当然,这同时也意味着网络插件完全不必关心用户业务容器的启动与否,而只需要关注如何配置 Pod,也就是 Infra 容器的 Network Namespace 即可
容器的设计模式
详见此篇文章
有些配置并不会被打包到容器里,因为它们变动频繁,我们通常启动容器时将它们动态挂载到容器里,为了应付这种需求,就有了ConfigMap
资源。而又因某些配置信息中存在着敏感信息需加密,为了应对这种需求,就有了Secret
这种资源。
创建ConfigMap的步骤如下:
1. 首先我们编写这个ConfigMap,然后通过Kubctl客户端提交给Kubernetes集群,Kubernetes API Server收到请求后将配置存入etcd存储中
2. 容器启动后,kublet发送请求给API Server,将ConfigMap挂载到容器中
详细ConfigMap笔记可见于Kubernetes存储
那我们为什么要启动多个pod副本?
答:当某一个挂掉了,其他仍然可以正常访问,而且当访问量过大时,也可以通过多启用几个Pod来达到分流的目的。
那我们要怎么启用多个Pod呢?
那就是ReplicaSet(Replication Controller已过时)这种资源,ReplicaSet就是为了提供多个Pod副本而设计的。
ReplicaSet
ReplicaSet( 副本控制器 )保证集群的资源处于期望状态( 也就是维护用户定义的副本数目 ),举个例子,想要运行几个副本,就是其进行控制的,一旦副本数目没有达到期望值,其就要负责将副本改写到期望值( 也就是如果有容器异常退出,会自动创建新的Pod来替代;而如果异常多出来的容器也会自动回收 )
新版本Kubernetes中,建议使用ReplicaSet来取代ReclicationController:
ReplicaSet与ReclicationController没有本质的不同,只是名字不一样,但是ReplicaSet支持集合式的selector
集合式的selector意思就是:集群创建pod的时候,会为其添加许多的标签,当需要对pod进行操作(删除、修改等)的时候,rs(ReplicaSet)就支持通过其标签来操作的集合方案,而rc就不支持。所以在大型项目管理中,rs比rc更简单、更有效。
selector:标签选择器。不加标签,就会在所有PV找最佳匹配
二、如何部署多个应用副本?
当我们部署的应用有了新版本,我们想升级它但是又不想影响线上用户的访问,我们应该怎么做呢?滚动升级是一个比较好的方案。
deployment
那么kubernetes是如何实现这种滚动升级的呢?
答:就是通过Deployment这种资源,实际上Deployment与ReplicaSet与Pod是一种层层控制的关系。(Deployment控制ReplicaSet,ReplicaSet控制pod) ReplicaSet负责通过控制器模式保证系统中的Pod的个数永远等于指定的个数。这也正是Deployment只允许容器的restart = Always的原因,只有在容器能保证自己始终是running状态的前提下,ReplicaSet调整Pod的个数才有意义。在这个基础上,Deployment同样通过控制器模式来操作ReplicaSet的个数和属性,从而实现水平扩展,收缩he滚动升级这两个动作。
deployment被创建出来之后,它会去创建一个rs( 也就意味着这个rs不是用户定义的,而是deployment定义的,而rs就负责去创建pod。当需要deployment将镜像升级的时候,deployment会新创建一个rs )
三、如何对应用进行滚动升级?
起初一个Deployment控制一个副本集,这个副本集的版本是唯一的版本。流程是:deployment会新建一个新版本v2的ReplicaSet,V2版本的ReplicaSet则会启动一个v2版本的pod,同时会退出一个v1版本的pod,新老版本的pod交替启动和停止,直到所有v1版本的pod都已更新为v2版本,此次滚动更新便完成了。但是需要注意的是,老版本的ReplicaSet在滚动升级的过程中并不会被删除,原因是当有回滚的需求的时候,可以直接根据老版本的ReplicaSet就行回滚
四、Pod控制器类型
在k8s中,控制器模式依赖于声明式的API. 而另外一种常见的API是命令式API,为什么k8s采用声明式API而不采用命令式API呢?
这里给出命令式API与声明式API的简单对比:
如图中所示,
命令式API交互
就像跟孩子进行交流一样,因为孩子一般欠缺目标意识,并不能理解家长的期望,家长呢则是通过明确的命令来教孩子具体的动作,比如吃饭睡觉打豆豆。类似的在容器编排体系中,命令式API也就是向系统发出明确的操作而执行的,比如新建/删除一个Pod。
声明式API
则类似于老板与员工的交流方式,老板一般并不会向下属发出明确的指令,事实上对于要执行的操作本身可能还并不如员工本身清楚。因此,老板一般通过给员工制定可量化的业务目标的方式来发挥员工自身的主观能动性,比如说市场占有率达到80%,而并不会指出要达到80%具体要做的每一步操作细节。类似的在容器编排中,我们可以指定一个业务Pod的副本数要保持在3个,而没有明确说要使副本数保持在3个是要增加副本数目还是减少副本数目。
HPA(Horizontal Pod AutoScale )
HPA就是平滑扩展,在早期的版本中仅支持根据cpu利用率所扩容,新版本中,支持根据内存和用户自定义的metric进行扩缩容
CPU Utilization Percentage(cpu利用百分率) 是一个计算平均值,即目标 Pod 所有副本的 CPU 利用率的平均值。一个 Pod 自身的 CPU 利用率是该Pod当前的 CPU 的使用量除以它的 Pod Request 的值,比如定义一个 Pod 的 Pod Request 为0.4,当前的CPU 使用量为 0.2,则它的 CPU 使用率为 50% 。如此一来,就可以算出来一个 RS 控制的所有 Pod 副本的 CPU 利用率的算术平均值了。如果某一时刻 cpu利用率 的值超过 80%,则意味着当前的 Pod 副本数很可能不足以支撑接来下更多的请求,需要进行动态扩容;而当前请求高峰时段过去后,Pod 的 CPU 利用率又会降下来,此时对应的 Pod 副本数应该自动减少到一个合理的水平。这就是根据cpu利用率自动进行扩缩容
HPA基于rs定义,当rs的pod的cpu利用率大于80%的时候,进行扩展的最大值是10个pod,当cpu利用率降下来时,最小值是2个pod。
StatefulSet
StatefulSet是为了解决有状态服务的问题(对应Deploymenet和ReplicaSet是为无状态服务而设计),其应用场景包括:
* 稳定的持久化存储,即Pod重新调度后还是能访问到相同的持久化数据,基于PVC来实现(当一个pod死亡之后被调度回来取代之前的老的pod时,用到的存储还是之前的存储,并且里面的数据也不会丢失 )
* 稳定的网络标志,即Pod重新调度后其PodName和Hostname不变,基于Headless Service(即没有Cluster IP的Service)来实现(重新调度回来的pod的主机名不会变,不需要重新写入)
* 有序部署,有序扩展,即Pod是有顺序的,在部署或者扩展的时候要根据定义的顺序依次进行(从0到N-1,当前一个Pod处于Running 和Ready 状态时,第二个pod才会被创建。原因是:当我们要创建一个集群时,比如LNMP,他们的启动顺序应该是MySQL -> PHP -> Nginx,因为有依赖关系,所以在大型环境中是由一定启动关系的 ,关闭时也需要有序。即为有序部署与有序回收 ),基于init Containers
(Pod中的初始化容器)来实现
详见Pod生命周期中介绍的
init C
* 有序回收,有序删除
StatefulSet 也只是针对有状态服务给我们提供了以上几种特点,如果要想有状态服务能够稳定、安全的运行在pod中,还是需要使用者去自定义一些功能
DaemonSet
DaemonSet
控制器能够让所有的(或者一些特定的)Node节点上运行同一个Pod( 同一个DaemonSet也只能指定特定的Node节点上运行有且仅有一个Pod副本数目,不能更多。如果需要在指定的所有Node节点上运行多个指定的Pod副本,那么就需要指定多个DaemonSet )。当使用DaemonSet已经指定了特定的Pod,且如果有新Node节点新加入k8s集群时,指定的 Pod 就会被(DaemonSet)调度到该Node节点上运行。当Node节点从k8s集群被移除时,被(DaemonSet)调度的Pod也会被移除回收。如果删除DaemonSet,所有跟这个DaemonSet相关的pods都会被删除。
(“一些特定的Node节点可以不被调度DaemonSet的Pod” 是因为可以在Node上打一些污点,这些污点就表明该Node可以不被调度,所以在DaemonSet创建的时候,这些打了污点的Node就不会运行这个Pod,但是默认情况下所有的Node都会运行一个Pod副本,有且只有一个)
在使用kubernetes来运行应用时,很多时候需要在一个区域(zone)或者所有Node上运行同一个守护进程(pod),例如如下场景,这就是DaemonSet发挥作用的一些经典用处:
* 每个Node上运行一个分布式存储的守护进程,例如glusterd,ceph
* 在每个Node上运行日志收集daemon,例如fluented、logstash
* 在每个Node上运行监控daemon,例如Prometheus Node Exporter
Job、Cronjob
Job负责批处理任务,即仅执行一次的任务,它保证批处理任务的一个或多个Pod成功结束
( 使用Job与在linux直接执行脚本的区别在于:① job如果判断这个脚本不是正常退出,它就会重新执行一遍,直到正常退出为止,并且还可以设置正常退出的次数,比如正常退出3次,才允许此脚本执行成功 )
Cron Job管理基于时间的Job,即:
( 总的来说就是可以在特定的时间执行 )
* 在给定的时间点只运行一次
* 周期性地再给定时间点运行
五、服务发现
① Service(SVC)概述
为什么需要Service?
k8s中的Pod是随时可以消亡的(节点故障、容器内应用程序错误等原因)。如果使用 Deployment 运行了应用程序,Deployment 将会在 Pod 消亡后再创建一个新的 Pod 以维持所需要的副本数。每一个 Pod 有自己的 IP 地址,然而,对于 Deployment 而言,对应的 Pod 集合是动态变化的。动态变化就导致了如下结果:
如果某些 Pod(假设是 'backends')为另外一些 Pod(假设是 'frontends')提供接口,在 'backends' 中的 Pod 集合不断变化(IP 地址也跟着变化)的情况下,'frontends' 中的 Pod 如何才能知道应该将请求发送到哪个 IP 地址?
Service 存在的意义,就是为了解决这个问题。Service 是 Kubernetes 中的一种服务发现机制:
• Pod 有自己的 IP 地址
• Service 被赋予一个唯一的 dns name
• Service 通过 label selector 选定一组 Pod
• Service 实现负载均衡,可将请求均衡分发到选定这一组 Pod 中
图中,Service 先连线到 Controller,Controller 在连线到容器组,这种表示方式只是概念上的,期望用户在使用 Kubernetes 的时候总是通过 Controller 创建 Pod,然后再通过 Service 暴露为网络服务,通过 Ingress 对集群外提供互联网访问。
事实上,Service 与 Controller 并没有直接联系,Service 通过 label selector 选择符合条件的 Pod,并将选中的 Pod 作为网络服务的提供者。从这个意义上来讲,可以有很多种方式去定义 Service 的 label selector,然而,最佳的实践是,在 Service 中使用与 Controller 中相同的 label selector。例如上图所示。
客户端需要访问一组Pod,如果这些Pod无相干性是不能够通过service进行代理的,这些Pod要具有相关性,比如都是通过同一个rc或者deployment创建的,或者拥有同一组标签。也就是说service是通过标签进行选择到这些Pod的(Deployment或者ReplicaSet创建的Pod组默认都是拥有相同的label)。
选择到Pod之后,这个service就会有自己的IP+端口,client就通过访问service的IP+端口间接“轮询->RR算法”访问到Pod
六、网络通讯方式
kubernetes的网络模型假定所有的Pod都在一个可以直接连通的扁平的网络空间,这在GCE( Google Compute Engine )里面是现成的网络模型,kubernetes假定这个网络已经存在。而在私有云里面搭建kubernetes集群,就不能假定这个网络已经存在了。我们需要自己实现这个网络假设,将不同节点上的docker容器之间的互相访问先打通,然后运行Kubernetes
下图中的“集群网络”、“节点网络”详见k8s学习笔记二中1.2.2 service的网络服务模式
在k8s中,扁平化的概念就是所有的pod都可以通过对方的IP( 这个IP是私网地址 ) “ 直接到达 ”( 带引号的意思是:pod认为自己是直接到达,其实底层还有一堆的转换机制存在 )
Pod间的网络通讯方式其实有以下几种方式:
1、同一个pod内的多个容器的通讯:lo
2、不同pod之间容器的通讯:Overlay Network( 通过Flannel来建立 )
Flannel是CoreOS团队针对Kubernetes设计的一个网络规划服务,其功能是让集群中的不同节点主机所创建的Docker容器都具有全集群唯一的虚拟IP地址。而且他还能在这些IP地址之间建立一个Overlay Network,通过这个网络,将数据包原封不动地传递到目标容器。
Flannel实质上也就是将TCP数据包装在另一种网络包里面进行路由转发和通信,Flannel的设计目的就是为集群中的所有节点重新规划IP地址的使用规则,从而使得不同节点上的容器能够获得“同属一个内网”且”不重复的”IP地址,并让属于不同节点上的容器能够直接通过这个"内网IP"通信
。
那么如何解决同主机甚至跨主机的网络通讯问题呢?
① 同主机间不同pod的通讯过程:
首先,真实的 Node 服务器上安装有一个Flanneld守护进程,Flanneld会监听一个 udp 端口,这个端口就是后期进行转发数据包以及接收数据包的一个服务端口,flanneld进程启动之后会开启一个网桥叫flannel 0,这个flannel 0专门接收docker 0 转发出来的数据报文。docker 0 本身会分配自己的IP到Pod上,如果是同一台主机上的不同Pod的话,它的数据报文走的其实是docker 0这个网桥,因为它们都是在同一个网桥下面的子IP。这很简单就能实现,难就难在跨主机之间的通信,还得通过对方的IP( 私网地址 )到达。
----------非常重要分割线----------
② 不同主机间的pod通讯过程( 参照下图进行分析 )
我们以Web app2
和Backend
进行数据通信来说明跨主机间的Pod网络通信的流程:
首先web app2
的数据包源地址要写自己的私有IP地址10.1.15.2/24,目的IP地址写想要访问另一个Node上的名叫Backend的Pod的私有IP地址10.1.20.3/24,因为不是同一个网段,所以要将这个数据包发送到网关docker 0(10.1.15.1/24),flannel 0就通过特殊方法( 构置函数,我不太懂 )将数据包从docker 0抓取过来,然后在从etcd中获取到路由表,判断路由到哪台机器( 集群节点IP )。 因为flannel 0 是flanneld开启的一个网桥,当数据包到达flanneld的时候,它会对数据包进行封装。( 数据包结构如右上角所示,三层outerIP封装的是这两个物理主机的源IP和目的IP,数据传输层则使用udp封装,原因是更快。在往上一层是InnerIP,这一层封装的就是对应Pod的源IP和目的IP,封装到这一层之后,里面封装了一个数据包实体 )然后就转发到目的物理主机192.168.66.12,根据三层outerIP地址所转发,这个数据包到达目的主机之后,因为端口是flanneld的服务端口,所以报文肯定会被flanneld所截获,然后进行拆封,拆封之后就会送到flannel 0,随后flannel 0会将其转发到docker 0,然后数据报文就会被送到Pod Backend
。这样就实现了跨主机的扁平化网络,但是其实这个过程消耗的资源还是比较高的!
其实简单的说flannel做了三件事情:
1. 数据从源容器中发出后,经由所在主机的docker0虚拟网卡转发到flannel0虚拟网卡,这是个P2P的虚拟网卡,flanneld服务监听在网卡的另外一端。 Flannel也是通过修改Node的路由表实现这个效果的。
2. 源主机的flanneld服务将原本的数据内容UDP封装后根据自己的路由表投递给目的节点的flanneld服务,数据到达以后被解包,然后直接进入目的节点的flannel0虚拟网卡,然后被转发到目的主机的docker0虚拟网卡,最后就像本机容器通信一样由docker0路由到达目标容器。
3. 使每个结点上的容器分配的地址不冲突。Flannel通过Etcd分配了每个节点可用的IP地址段后,再修改Docker的启动参数。“--bip=X.X.X.X/X”这个参数,它限制了所在节点容器获得的IP范围。
----------非常重要分割线----------
Flannel与Etcd的调用关系:
Etcd会存储flannel可分配的IP地址段资源( 也就意味着,当flannel启动之后向etcd插入其可以被分配的网段,并且要记录上哪个网段分配给了哪个pod,防止这个已分配的网段再次被flannel利用分配给其他的Node节点,)
* Flanneld通过api-server监控Etcd中每个Pod的实际地址,并在内存中建立维护Pod节点路由表( 也就是映射Pod的IP地址到物理主机的IP地址,这样在转发数据包时才知道要将去往其他物理主机上的某一个Pod的数据包转发去哪 )
3、pod与Service之间的通讯:各节点的Iptables规则( 最新已启用lvs )
4、Pod到外网:
Pod向外网发送请求,查找路由表,转发数据包到宿主机的网卡,宿主网卡完成路由选择后,iptables执行Masquerade,把源IP更改为宿主网卡的IP,然后向外网服务器发送请求
5、Service( 虽然是一个扁平型网络,但是其始终是一个私有网络 )
小结:
1、deploment并不负责pod的创建,它通过与ReplicaSet的机制来达到滚动升级,甚至还可以进行回滚操作,即当发现新更新的版本有问题之后,还可以通过rs回退到老版本( 原因是,当进行完滚动升级之后,老旧版本的rs并不会被删除,而是被停用,当需要回滚的时候,老旧版本的rs则会被启用 )
2、从kubernetes的最小单位Pod开始讲起,为了解决服务编排时两个服务的亲密关系,kubernetes引入了Pod这个概念,定义处在一个Pod内的容器共享网络空间、存储卷,并且一起被调度。
3、我们解决了服务间的亲密关系以后,需要为服务运行多个副本同时对外提供服务,于是引入了ReplicaSet这个控制器。
为了解决服务滚动升级的问题,又引入了Deployment这个控制器来控制ReplicaSet控制器,通过Deployment这个控制器就能轻松的将我们的应用部署到kubernetes集群上并且对其进行滚动升级。
4、pod控制器
5、pod的五种通讯方式:( 非常重要 )
学习笔记部分内容来源:
1、尚硅谷汪洋老师学习视频
2、https://www.cnblogs.com/xinfang520/p/11819924.html
3、https://kuboard.cn/learning/
4、https://blog.csdn.net/weixin_29115985/article/details/78963125?utm_source=blogxgwz9
5、https://www.cnblogs.com/fuyuteng/p/10623245.html