其他分享
首页 > 其他分享> > Kubernetes 9 (volumes配置、emptyDir卷、hostPath 卷、持久卷、StatefulSet控制器)

Kubernetes 9 (volumes配置、emptyDir卷、hostPath 卷、持久卷、StatefulSet控制器)

作者:互联网

目录

一、Volumes简介

1、容器中的文件在磁盘上是临时存放的,这给容器中运行的特殊应用程序带来一些问题。首先,当容器崩溃时,kubelet 将重新启动容器,容器中的文件将会丢失,因为容器会以干净的状态重建。其次,当在一个 Pod 中同时运行多个容器时,常常需要在这些容器之间共享文件。 Kubernetes 抽象出 Volume 对象来解决这两个问题。

2、Kubernetes 卷具有明确的生命周期,与包裹它的 Pod 相同。 因此,卷比 Pod 中运行的任何容器的存活期都长**,在容器重新启动时数据也会得到保留**。 当然,当一个 Pod 不再存在时,卷也将不再存在。也许更重要的是,Kubernetes 可以支持许多类型的卷,Pod 也能同时使用任意数量的卷。

3、卷不能挂载到其他卷,也不能与其他卷有硬链接。 Pod 中的每个容器必须独立地指定每个卷的挂载位置。

Kubernetes 支持下列类型的卷:

awsElasticBlockStore 、azureDisk、azureFile、cephfs、cinder、configMap、csi
downwardAPI、emptyDir、fc (fibre channel)、flexVolume、flocker
gcePersistentDisk、gitRepo (deprecated)、glusterfs、hostPath、iscsi、local、
nfs、persistentVolumeClaim、projected、portworxVolume、quobyte、rbd
scaleIO、secret、storageos、vsphereVolume

二、emptyDir卷

1、当 Pod 指定到某个节点上时,首先创建的是一个 emptyDir 卷,并且只要 Pod 在该节点上运行,卷就一直存在。 就像它的名称表示的那样,卷最初是空的。 尽管 Pod 中的容器挂载 emptyDir 卷的路径可能相同也可能不同,但是这些容器都可以读写 emptyDir 卷中相同的文件。 当 Pod 因为某些原因被从节点上删除时,emptyDir 卷中的数据也会永久删除。

2、emptyDir 的使用场景:
缓存空间,例如基于磁盘的归并排序。
为耗时较长的计算任务提供检查点,以便任务能方便地从崩溃前状态恢复执行。
在 Web 服务器容器服务数据时,保存内容管理器容器获取的文件。

默认情况下, emptyDir 卷存储在支持该节点所使用的介质上;这里的介质可以是磁盘或 SSD 或网络存储,这取决于您的环境。 但是,您可以将 emptyDir.medium 字段设置为 “Memory”,以告诉 Kubernetes 为您安装 tmpfs(基于内存的文件系统)。 虽然 tmpfs 速度非常快,但是要注意它与磁盘不同。 tmpfs 在节点重启时会被清除,并且您所写入的所有文件都会计入容器的内存消耗,受容器内存限制约束。

emptyDir卷创建
创建目录volumes,编辑vol1.yaml文件;
containers: %一个pod中创建两个容器共享volumes
mountPath: /cache %vm1中的卷挂载到容器内的/cache
mountPath: /usr/share/nginx/html %vm2中的卷挂载到容器内的/usr/share/nginx/html
medium: Memory %使用内存介质
sizeLimit: 100Mi %可以使用100M内存
请添加图片描述
应用配置
请添加图片描述
真机访问测试,vol1这个pod 的 ip 是 10.244.1.93;
403错误是因为容器没有发布内容
请添加图片描述
进入vol1的vm1容器,在/cache中写入发布内容
请添加图片描述
再次访问测试成功
请添加图片描述
进入vol1的vm2容器,在/usr/share/nginx/html中可以看到发布内容和vm1中的一样,说明vm1和vm2共用卷资源;
然后在原有内容的基础上再追加发布内容
请添加图片描述
连接进入vol1的vm1容器,看到了追加的内容,进一步证明了同一个pod内的两个容器vm1和vm2共用卷资源;
测试写入200M的文件,由于超过了限定的100M内存,可以看到pod会损坏
请添加图片描述
请添加图片描述
查看pod详细信息,可以看到文件超过限制后,则1-2分钟后会被kubelet evict掉,而不是“立即”被evict,这是因为kubelet是定期进行检查的,这里会有一个时间差。
请添加图片描述

emptydir缺点:
不能及时禁止用户使用内存。虽然过1-2分钟kubelet会将Pod挤出,但是这个时间内,其实对node还是有风险的;
影响kubernetes调度,因为empty dir并不涉及node的resources,这样会造成Pod“偷偷”使用了node的内存,但是调度器并不知晓;
用户不能及时感知到内存不可用

三、hostPath卷

1、hostPath 卷能将主机节点文件系统上的文件或目录挂载到您的 Pod 中。 虽然这不是大多数 Pod 需要的,但是它为一些应用程序提供了强大的逃生舱。

2、hostPath 的一些用法有:
运行一个需要访问 Docker 引擎内部机制的容器,挂载 /var/lib/docker 路径。
在容器中运行 cAdvisor 时,以 hostPath 方式挂载 /sys。
允许 Pod 指定给定的 hostPath 在运行 Pod 之前是否应该存在,是否应该创建以及应该以什么方式存在。

3、除了必需的 path 属性之外,用户可以选择性地为 hostPath 卷指定 type。
在这里插入图片描述

当使用这种类型的卷时要小心,因为:
具有相同配置(例如从 podTemplate 创建)的多个 Pod 会由于节点上文件的不同而在不同节点上有不同的行为。
当 Kubernetes 按照计划添加资源感知的调度时,这类调度机制将无法考虑由 hostPath 使用的资源。
基础主机上创建的文件或目录只能由 root 用户写入。您需要在 特权容器 中以 root 身份运行进程,或者修改主机上的文件权限以便容器能够写入 hostPath 卷。

1.主机内目录挂载到pod中

创建host.yaml 文件,
mountPath: /test-pd %把卷挂载到容器内的/test-pd
path: /data %卷的路径在/data
type: DirectoryOrCreate %如果在指定路径上不存在,那么根据需要创建空目录,权限为0755,具有与kubelet相同的组和所有权
请添加图片描述
应用配置后,可以看到test-pd被创建,被调度到了server3上
请添加图片描述
查看server3的/data 此时并没有数据
请添加图片描述
连接到test-pd,在test-pd目录下写入内容到file1
请添加图片描述
再次查看server3的 /data ,可以看到file1,文件拥有者和拥有组都是root
请添加图片描述
当我们把file1文件删除后
请添加图片描述
可以看到 test-pd 容器的 /test-pd目录下的文件已经不存在了
请添加图片描述

2.主机内nfs挂载到pod中

一般文件存储是独立于k8s集群的,所以nfs和仓库一样都放在了server1;
首先安装nfs网络文件系统;
编辑/etc/exports文件,在NFS服务器搭建过程中,当我们在修改配置文件的时候,其中很重要的一环就是权限参数,no_root_squash : 开放客户端使用root使用来操作文件系统,也就是说让root写入的文件仍然具有root权限
请添加图片描述
systemctl start nfs %开启nfs
showmount -e %测试是否正常;
注意:集群内所有节点也都需要安装nfs文件系统,并开启nfs
请添加图片描述
server2编辑nfs.yaml文件,
server: 172.25.36.1 %设置卷的路径是172.25.11.1/mnt/nfs;
mountPath: /usr/share/nginx/html :挂载到test-pd容器内的/usr/share/nginx/html目录下
请添加图片描述
showmount -e 172.25.36.1:server2测试nfs运行正常;
应用配置
请添加图片描述
但是pod没有创建成功,这是因为该pod被分配在server3节点,而server3节点没有安装nfs或nfs未开启
在这里插入图片描述
server3安装nfs
请添加图片描述
现在server2查看test-pd是运行状态
请添加图片描述

四、PersistentVolume持久卷

1、PersistentVolume(持久卷,简称PV)是集群内,由管理员提供的网络存储的一部分。就像集群中的节点一样,PV也是集群中的一种资源。它也像Volume一样,是一种volume插件,但是它的生命周期却是和使用它的Pod相互独立的。PV这个API对象,捕获了诸如NFS、ISCSI、或其他云存储系统的实现细节。

2、PersistentVolumeClaim(持久卷声明,简称PVC)是用户的一种存储请求。它和Pod类似,Pod消耗Node资源,而PVC消耗PV资源。Pod能够请求特定的资源(如CPU和内存)。PVC能够请求指定的大小和访问的模式(可以被映射为一次读写或者多次只读)。

3、有两种PV提供的方式:静态和动态
静态PV:集群管理员创建多个PV,它们携带着真实存储的详细信息,这些存储对于集群用户是可用的。它们存在于Kubernetes API中,并可用于存储使用。

动态PV:当管理员创建的静态PV都不匹配用户的PVC时,集群可能会尝试专门地供给volume给PVC。这种供给基于StorageClass。

PVC与PV的绑定是一对一的映射。没找到匹配的PV,那么PVC会无限期的处于unbound未绑定状态。

1、使用:
Pod使用PVC就像使用volume一样。集群检查PVC,查找绑定的PV,并映射PV给Pod。对于支持多种访问模式的PV,用户可以指定想用的模式。一旦用户拥有了一个PVC,并且PVC被绑定,那么只要用户还需要,PV就一直属于这个用户。用户调度Pod,通过在Pod的volume块中包含PVC来访问PV。
2、释放:
当用户使用PV完毕后,他们可以通过API来删除PVC对象。当PVC被删除后,对应的PV就被认为是已经是“released”了,但还不能再给另外一个PVC使用。前一个PVC的属于还存在于该PV中,必须根据策略来处理掉。
3、回收:
PV的回收策略告诉集群,在PV被释放之后集群应该如何处理该PV。当前,PV可以被Retained(保留)、 Recycled(再利用)或者Deleted(删除)。保留允许手动地再次声明资源。对于支持删除操作的PV卷,删除操作会从Kubernetes中移除PV对象,还有对应的外部存储(如AWS EBS,GCE PD,Azure Disk,或者Cinder volume)。动态供给的卷总是会被删除。

1.静态PV

server1在/mnt/nfs目录下创建pv1和pv2目录;
分别在两个目录下写入不同内容的index.html
请添加图片描述
访问模式
ReadWriteOnce – 该volume只能被单个节点以读写的方式映射;
ReadOnlyMany – 该volume可以被多个节点以只读方式映射;
ReadWriteMany – 该volume可以被多个节点以读写的方式映射;

在命令行中,访问模式可以简写为:
RWO - ReadWriteOnce
ROX - ReadOnlyMany
RWX - ReadWriteMany

回收策略
Retain:保留,需要手动回收;
Recycle:回收,自动删除卷中数据;
Delete:删除,相关联的存储资产,如AWS EBS,GCE PD,Azure Disk,or OpenStack Cinder卷都会被删除;

当前,只有NFS和HostPath支持回收利用,AWS EBS,GCE PD,Azure Disk,or OpenStack Cinder卷支持删除操作。

server2编辑pv.yaml文件;
pv1设定一次读写,nfs路径是172.25.36.1/mnt/nfs/pv1
pv2设定多次读写,nfs路径是172.25.36.1/mnt/nfs/pv2
请添加图片描述
在这里插入图片描述
创建两个持久卷pv1和pv2,查看此时状态为Available模式
请添加图片描述
编辑pvc.yaml文件,pvc1设定一次读写,最低需求存储是1G;
pvc2设定多次读写,最低需求存储是10G
请添加图片描述
创建两个pvc
请添加图片描述
状态:
Available:空闲的资源,未绑定给PVC;
Bound:绑定给了某个PVC;
Released:PVC已经删除了,但是PV还没有被集群回收;
Failed:PV在自动回收中失败了;
命令行可以显示PV绑定的PVC名称,可以看到pvc列表,现在状态是Bound
请添加图片描述
编辑pod.yaml,创建两个pod;
test-pd-1使用pvc1,test-pd-2使用pvc2
请添加图片描述
请添加图片描述
创建两个pod(test-pd-1和test-pd-2),查看状态是running
请添加图片描述
查看两个pod的ip,访问测试,不同的pod可以获得不同的发布内容,内容正是我们开始在pv1和pv2目录内编辑的内容;
test-pd-1 的 ip 是10.244.1.122,test-pd-2 的 ip 是10.244.1.118
请添加图片描述
删除pod后,可以看到pvc和pv的绑定关系还在
请添加图片描述
删除pvc后,仍然可以看到pvc和pv的绑定关系,不能给其他人使用(这属于静态pv的缺点之一)
在这里插入图片描述
删除时,要注意删除的顺序,先删除pod,再删除pvc,最后删除pv;
将pvc.yaml删掉之后,过一会查看pv状态,发现变为failed;
请添加图片描述
查看详细信息
请添加图片描述
这是因为将pvc.yaml删掉之后,server1上的/mnt/nfs/挂载文件是不会回收的,若是需要回收的话就需要在仓库server1上传镜像busybox:1.27
请添加图片描述
server1导入镜像,上传到仓库
请添加图片描述
请添加图片描述
查看pv状态为Available:空闲的资源,未绑定给PVC
请添加图片描述
此时再次删除pvc
请添加图片描述
请添加图片描述
可以看到server1上的/mnt/nfs/挂载文件已经不存在(被回收)
请添加图片描述

2.动态PV

静态卷需要提前创建好,但是实际生产环境中很可能管理员无法提前创建,所以下面使用动态卷,它可以自动创建;

1、StorageClass提供了一种描述存储类(class)的方法,不同的class可能会映射到不同的服务质量等级和备份策略或其他策略等。每个 StorageClass 都包含 provisioner、parameters 和 reclaimPolicy 字段, 这些字段会在StorageClass需要动态分配 PersistentVolume 时会使用到。

2、StorageClass的属性
(1)Provisioner(存储分配器):用来决定使用哪个卷插件分配 PV,该字段必须指定。可以指定内部分配器,也可以指定外部分配器。外部分配器的代码地址为: kubernetes-incubator/external-storage,其中包括NFS和Ceph等。
(2)Reclaim Policy(回收策略):通过reclaimPolicy字段指定创建的Persistent Volume的回收策略,回收策略包括:Delete 或者 Retain,没有指定默认为Delete。

更多属性查看:https://kubernetes.io/zh/docs/concepts/storage/storage-classes/

补充:
a. NFS Client Provisioner是一个automatic provisioner,使用NFS作为存储**,自动创建PV和对应的PVC**,本身不提供NFS存储,需要外部先有一套NFS存储服务;
b.PV以 $ {namespace}-$ {pvcName}-$ {pvName}的命名格式提供(在NFS服务器上);
c.PV回收的时候以 archieved-$ {namespace}-$ {pvcName}-$ {pvName} 的命名格式(在NFS服务器上);
d.nfs-client-provisioner源码地址:https://github.com/kubernetes-incubator/external-storage/tree/master/nfs-client

确认集群状态:kubectl get pod -n kube-system,全部是就绪状态
在这里插入图片描述
真机将nfs-client-provisioner.tar发送给server1;
请添加图片描述
server1导入nfs-client-provisioner镜像
请添加图片描述
上传镜像到仓库
请添加图片描述
请添加图片描述
server2创建在/root/volumes下目录
请添加图片描述
真机将nfs-client-provisioner.yaml配置文件发送给server2的/root/volumes/nfs-client目录下
请添加图片描述
server2修改nfs-client-provisioner.yaml文件

apiVersion: v1
kind: ServiceAccount
metadata:
  name: nfs-client-provisioner
  # replace with namespace where provisioner is deployed
  namespace: nfs-client-provisioner
---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: nfs-client-provisioner-runner
rules:
  - apiGroups: [""]
    resources: ["nodes"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["persistentvolumes"]
    verbs: ["get", "list", "watch", "create", "delete"]
  - apiGroups: [""]
    resources: ["persistentvolumeclaims"]
    verbs: ["get", "list", "watch", "update"]
  - apiGroups: ["storage.k8s.io"]
    resources: ["storageclasses"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["events"]
    verbs: ["create", "update", "patch"]
---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: run-nfs-client-provisioner
subjects:
  - kind: ServiceAccount
    name: nfs-client-provisioner
    # replace with namespace where provisioner is deployed
    namespace: nfs-client-provisioner
roleRef:
  kind: ClusterRole
  name: nfs-client-provisioner-runner
  apiGroup: rbac.authorization.k8s.io
---
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: leader-locking-nfs-client-provisioner
  # replace with namespace where provisioner is deployed
  namespace: nfs-client-provisioner
rules:
  - apiGroups: [""]
    resources: ["endpoints"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: leader-locking-nfs-client-provisioner
  # replace with namespace where provisioner is deployed
  namespace: nfs-client-provisioner
subjects:
  - kind: ServiceAccount
    name: nfs-client-provisioner
    # replace with namespace where provisioner is deployed
    namespace: nfs-client-provisioner
roleRef:
  kind: Role
  name: leader-locking-nfs-client-provisioner
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nfs-client-provisioner
  labels:
    app: nfs-client-provisioner
  # replace with namespace where provisioner is deployed
  namespace: nfs-client-provisioner
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: nfs-client-provisioner
  template:
    metadata:
      labels:
        app: nfs-client-provisioner
    spec:
      serviceAccountName: nfs-client-provisioner
      containers:
        - name: nfs-client-provisioner
          image: nfs-subdir-external-provisioner:v4.0.0
          volumeMounts:
            - name: nfs-client-root
              mountPath: /persistentvolumes
          env:
            - name: PROVISIONER_NAME
              value: westos.org/nfs
            - name: NFS_SERVER
              value: 172.25.36.1
            - name: NFS_PATH
              value: /mnt/nfs
      volumes:
        - name: nfs-client-root
          nfs:
            server: 172.25.36.1
            path: /mnt/nfs
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: managed-nfs-storage
provisioner: westos.org/nfs
parameters:
  archiveOnDelete: "true"		%true表示删除pvc后,目录打包
  								%false表示删除pvc后,目录直接删除

请添加图片描述
创建nfs-client-provisioner命名空间(与配置文件保持一致),应用配置;
查看创建的sc(StorageClass)
请添加图片描述
编辑test-pvc.yaml文件,声明managed-nfs-storage(sc)
请添加图片描述
应用配置,可以看到test-claim被创建;
查看pvc,可以看到test-claim状态已经是Bound,一旦创建了pvc就自动创建了对应的pv
请添加图片描述
在server1中可以看到自动生成了子目录
请添加图片描述
server2删除pvc后,可以看到pv也一起被删除了,这是因为没有指定回收策略,默认为Delete,同时文件会打包
请添加图片描述
删除server1的/mnt/nfs目录下打包的文件夹
请添加图片描述
server2编辑pod.yaml
请添加图片描述
为名为test-pd-2的pod配置pvc,声明名称为test-claim
请添加图片描述
server2编辑nfs-client-provisioner.yaml配置文件;
archiveOnDelete:删除时是否存档,false表示不存档,即删除数据;true表示存档,即重命名路径。当archiveOnDelete参数设定为false,则会自动删除oldPath下的所有数据,即pod对应的数据持久化存储数据
请添加图片描述
应用配置
请添加图片描述
应用test-pvc.yaml文件和pod.yaml文件
请添加图片描述
请添加图片描述
查看test-pd-2的ip(10.244.1.94),访问出现403报错,原因是没有默认发布文件;
写入默认发布文件
请添加图片描述
访问成功
请添加图片描述
删除test-pd-2和test-claim,可以看到环境清空,动态的pv不需要手动回收,pvc删除后pv就没了
请添加图片描述
server1中的pv动态卷也被清除了
请添加图片描述
如果把test-pvc.yaml文件中的sc的指定给注释掉;
应用配置,可以看到虽然test-claim被创建,但其状态是Pending,因为没有指定sc名
请添加图片描述
除了指定sc,还有另外一种方法,删除之前的test-pvc,重新创建;
在这里插入图片描述
把managed-nfs-storage设为默认sc;
重新创建pvc,可以看到状态为bound
请添加图片描述
如果test-pvc.yaml文件中的sc指定的是不存在的,那么应用配置后pvc的状态也为Pending,不能正常使用;
所以要么指定存储类,要么使用默认类,否则创建pvc后一直处于pending状态
请添加图片描述

五、StatefulSet控制器

StatefulSet将应用状态抽象成了两种情况:
(1)拓扑状态:应用实例必须按照某种顺序启动。新创建的Pod必须和原来Pod的网络标识一样
(2)存储状态:应用的多个实例分别绑定了不同存储数据。

StatefulSet给所有的Pod进行了编号,编号规则是:$ (statefulset名称)-$(序号),从0开始。创建时,是一个创建成功再创建下一个。回收时也是逐个删除,不能直接用delete(会同时删除,就无序了),删除的方式是把文件中的副本数量改为0,再次应用,就会删除,后创建的先删除。

Pod被删除后重建,重建Pod的网络标识也不会改变,Pod的拓扑状态按照Pod的“名字+编号”的方式固定下来,并且为每个Pod提供了一个固定且唯一的访问入口,即Pod对应的DNS记录。副本数量变为0,删除了容器,但是数据卷还是在的。等我们再次创建三个副本pod,web-0,web-1,web-2还是对应以前存在的数据卷,不会发生错乱。

首先清理实验环境
请添加图片描述
创建目录,编辑nginx-svc.yaml 文件,创建nginx-svc服务
请添加图片描述
应用配置,查看nginx-svc详细信息,可以看到没有后端服务节点
请添加图片描述
编辑statefulset.yaml 文件,使用StatefulSet控制器产生两个pod副本
请添加图片描述
应用配置,可以看到新创建的两个pod
请添加图片描述
现在再次查看服务,就可以看到后端了
请添加图片描述
查看pod信息
请添加图片描述
修改statefulset.yaml 文件,副本数量增加为6(进行扩容操作);
应用配置,可以看到副本是一个一个生成的,有序的
请添加图片描述
删除时,修改statefulset.yaml 文件,副本数量减少到0(不能直接用delete(否则会同时删除,就无序了))
在这里插入图片描述
可以看到删除时也是有序的,后建立的pod先删除;
在这里插入图片描述
继续编辑stateful.yaml文件,将副本数量恢复到3个,应用statefulset.yaml 文件,可以看到副本是一个一个生成的,有序的
请添加图片描述
通过busybox镜像运行容器demo,连接demo查看nginx-svc详细信息,可以看到负载均衡的3个节点
请添加图片描述
访问服务,可以看到nginx的默认发布页面
请添加图片描述
将镜像改为myapp:v1
请添加图片描述
连接一个demo,可以看到解析
请添加图片描述
访问服务或访问其中任何一个域名,都可以看到应答结果,并且是负载均衡的
在这里插入图片描述

StatefulSet还会为每一个Pod分配并创建一个同样编号的PVC。这样,kubernetes就可以通过Persistent Volume机制为这个PVC绑定对应的PV,从而保证每一个Pod都拥有一个独立的Volume

PV和PVC的设计,使得StatefulSet对存储状态的管理成为了可能;
删除statefulset.yaml文件,继续编辑
请添加图片描述
请添加图片描述
应用statefulset.yaml 文件,可以看到创建了3个pvc,且分别自动创建了对应的pv
请添加图片描述
请添加图片描述
为了测试效果,在nfs端(server1)的/mnt/nfs目录下,给三个pvc文件夹下分别写入标识信息便于测试
请添加图片描述
结构如下
请添加图片描述
server2连接demo,测试访问服务名nginx-svc,可以看到响应是负载均衡的,当访问具体的服务节点,则会有相对应的响应
请添加图片描述
下面测试副本删除后,重新建立,查看原数据是否还在;
先把statefulset.yaml 文件中的副本数量变为0,应用后,再把statefulset.yaml 文件中的副本数量变为3
请添加图片描述
再次连接demo容器测试,可以看到数据还在
请添加图片描述

标签:StatefulSet,PV,删除,Kubernetes,client,hostPath,nfs,provisioner,Pod
来源: https://blog.csdn.net/qq_38664479/article/details/120230350