浅谈 Kubernetes 数据持久化方案

栏目: 服务器 · 发布时间: 5年前

内容简介:缺省情况下,一个运行中的容器对文件系统的写入都是发生在其分层文件系统的可写层。一旦容器运行结束,所有写入都会被丢弃。如果数据需要长期存储,那就需要对容器数据做持久化支持。Kubernetes 和 Docker 类似,也是通过 Volume 的方式提供对存储的支持。Volume 被定义在 Pod 上,可以被 Pod 里的多个容器挂载到相同或不同的路径下。Kubernetes 中 Volume 的 概念与Docker 中的 Volume 类似,但不完全相同。具体区别如下:Volume 的核心是目录,可以通过

Kubernetes Volume 相关概念

缺省情况下,一个运行中的容器对文件系统的写入都是发生在其分层文件系统的可写层。一旦容器运行结束,所有写入都会被丢弃。如果数据需要长期存储,那就需要对容器数据做持久化支持。

Kubernetes 和 Docker 类似,也是通过 Volume 的方式提供对存储的支持。Volume 被定义在 Pod 上,可以被 Pod 里的多个容器挂载到相同或不同的路径下。Kubernetes 中 Volume 的 概念与Docker 中的 Volume 类似,但不完全相同。具体区别如下:

  • Kubernetes 中的 Volume 与 Pod 的生命周期相同,但与容器的生命周期不相关。当容器终止或重启时,Volume 中的数据也不会丢失。

  • 当 Pod 被删除时,Volume 才会被清理。并且数据是否丢失取决于 Volume 的具体类型,比如:emptyDir 类型的 Volume 数据会丢失,而 PV 类型的数据则不会丢失。

Volume 的核心是目录,可以通过 Pod 中的容器来访问。该目录是如何形成的、支持该目录的介质以及其内容取决于所使用的特定卷类型。要使用 Volume,需要为 Pod 指定为 Volume ( spec.volumes 字段) 以及将它挂载到容器的位置 ( spec.containers.volumeMounts 字段)。Kubernetes 支持多种类型的卷,一个 Pod 可以同时使用多种类型的 Volume。

容器中的进程看到的是由其 Docker 镜像和 Volume 组成的文件系统视图。 Docker 镜像位于文件系统层次结构的根目录,任何 Volume 都被挂载在镜像的指定路径中。Volume 无法挂载到其他 Volume 上或与其他 Volume 的硬连接。Pod 中的每个容器都必须独立指定每个 Volume 的挂载位置。

Kubernetes 目前支持多种 Volume 类型,大致如下:

  • awsElasticBlockStore

  • azureDisk

  • azureFile

  • cephfs

  • csi

  • downwardAPI

  • emptyDir

  • fc (fibre channel)

  • flocker

  • gcePersistentDisk

  • gitRepo

  • glusterfs

  • hostPath

  • iscsi

  • local

  • nfs

  • persistentVolumeClaim

  • projected

  • portworxVolume

  • quobyte

  • rbd

  • scaleIO

  • secret

  • storageos

  • vsphereVolume

注:这些 Volume 并非全部都是持久化的,比如: emptyDir、secret、gitRepo 等,就会随着 Pod 的消亡而消失。

Kubernetes 非持久化存储方式

下面我们对一些常见的 Volume 做一个基本的介绍。

emptryDir

emptryDir,顾名思义是一个空目录,它的生命周期和所属的 Pod 是完全一致的。emptyDir 类型的 Volume 在 Pod 分配到 Node 上时会被创建,Kubernetes 会在 Node 上自动分配一个目录,因此无需指定 Node 宿主机上对应的目录文件。这个目录的初始内容为空,当 Pod 从 Node 上移除(Pod 被删除或者 Pod 发生迁移)时,emptyDir 中的数据会被永久删除。

emptyDir Volume 主要用于某些应用程序无需永久保存的临时目录,在多个容器之间共享数据等。缺省情况下,emptryDir 是使用主机磁盘进行存储的。你也可以使用其它介质作为存储,比如:网络存储、内存等。设置 emptyDir.medium 字段的值为 Memory 就可以使用内存进行存储,使用内存做为存储可以提高整体速度,但是要注意一旦机器重启,内容就会被清空,并且也会受到容器内存的限制。

apiVersion: v1
kind: Pod
metadata:
  name: test-pd
spec:
  containers:
  - image: gcr.io/google_containers/test-webserver
    name: test-container
    volumeMounts:
    - mountPath: /cache
      name: cache-volume
  volumes:
  - name: cache-volume
    emptyDir: {}

hostPath

hostPath 类型的 Volume 允许用户挂载 Node 宿主机上的文件或目录到 Pod 中。大多数 Pod 都用不到这种 Volume,其缺点比较明显,比如:

  • 由于每个节点上的文件都不同,具有相同配置(例如:从 podTemplate 创建的)的 Pod 在不同节点上的行为可能会有所不同。

  • 在底层主机上创建的文件或目录只能由 root 写入。您需要在特权容器中以 root 身份运行进程,或修改主机上的文件权限才可以写入 hostPath 卷。

当然,存在即合理。这种类型的 Volume 主要用在以下场景中:

  • 运行中的容器需要访问 Docker 内部的容器,使用 /var/lib/docker 来做为 hostPath 让容器内应用可以直接访问 Docker 的文件系统。

  • 在容器中运行 cAdvisor,使用 /dev/cgroups 来做为 hostPath。

  • 和 DaemonSet 搭配使用,用来操作主机文件。例如:日志采集方案 FLK 中的 FluentD 就采用这种方式来加载主机的容器日志目录,达到收集本主机所有日志的目的。

apiVersion: v1
kind: Pod
metadata:
  name: test-pd
spec:
  containers:
  - image: k8s.gcr.io/test-webserver
    name: test-container
    volumeMounts:
    - mountPath: /test-pd
      name: test-volume
  volumes:
  - name: test-volume
    hostPath:
      # directory location on host
      path: /data
      # this field is optional
      type: Directory

Kubernetes 持久化存储方式

Kubernetes 目前可以使用 PersistentVolume、PersistentVolumeClaim、StorageClass 三种 API 资源来进行持久化存储,下面分别介绍下各种资源的概念。

PV

PV 的全称是:PersistentVolume(持久化卷)。PersistentVolume 是 Volume 的一种类型,是对底层的共享存储的一种抽象。PV 由集群管理员进行创建和配置,就像节点 (Node) 是集群中的资源一样,PV 也是集群资源的一种。PV 包含存储类型、存储大小和访问模式。PV 的生命周期独立于 Pod,例如:当使用它的 Pod 销毁时对 PV 没有影响。

PersistentVolume 通过插件机制实现与共享存储的对接。Kubernetes 目前支持以下插件类型:

  • GCEPersistentDisk

  • AWSElasticBlockStore

  • AzureFile

  • AzureDisk

  • FC (Fibre Channel)

  • FlexVolume

  • Flocker

  • NFS

  • iSCSI

  • RBD (Ceph Block Device)

  • CephFS

  • Cinder (OpenStack block storage)

  • Glusterfs

  • VsphereVolume

  • Quobyte Volumes

  • HostPath

  • VMware Photon

  • Portworx Volumes

  • ScaleIO Volumes

  • StorageOS

PVC

PVC 的全称是:PersistentVolumeClaim(持久化卷声明),PVC 是用户对存储资源的一种请求。PVC 和 Pod 比较类似,Pod 消耗的是节点资源,PVC 消耗的是 PV 资源。Pod 可以请求 CPU 和内存,而 PVC 可以请求特定的存储空间和访问模式。对于真正使用存储的用户不需要关心底层的存储实现细节,只需要直接使用 PVC 即可。

StorageClass

由于不同的应用程序对于存储性能的要求也不尽相同,比如:读写速度、并发性能、存储大小等。如果只能通过 PVC 对 PV 进行静态申请,显然这并不能满足任何应用对于存储的各种需求。为了解决这一问题,Kubernetes 引入了一个新的资源对象:StorageClass,通过 StorageClass 的定义,集群管理员可以先将存储资源定义为不同类型的资源,比如快速存储、慢速存储等。

当用户通过 PVC 对存储资源进行申请时,StorageClass 会使用 Provisioner(不同 Volume 对应不同的 Provisioner)来自动创建用户所需 PV。这样应用就可以随时申请到合适的存储资源,而不用担心集群管理员没有事先分配好需要的 PV。

  • 自动创建的 PV 以 ${namespace}-${pvcName}-${pvName} 这样的命名格式创建在后端存储服务器上的共享数据目录中。

  • 自动创建的 PV 被回收后会以 archieved-${namespace}-${pvcName}-${pvName} 这样的命名格式存在后端存储服务器上。

Kubernetes 访问存储资源的方式

Kubernetes 目前可以使用三种方式来访问存储资源。

  • 直接访问

该种方式移植性比较差,可扩展能力差。把 Volume 的基本信息完全暴露给用户,有安全隐患。

浅谈 Kubernetes 数据持久化方案

  • 静态 PV

集群管理员提前手动创建一些 PV。它们带有可供集群用户使用的实际存储的细节,之后便可用于 PVC 消费。

浅谈 Kubernetes 数据持久化方案

注:这种方式请求的 PVC 必须要与管理员创建的 PV 保持一致,如:存储大小和访问模式,否则不能将 PVC 绑定到 PV 上。

  • 动态 PV

当集群管理员创建的静态 PV 都不匹配用户的 PVC 时,PVC 请求存储类 StorageClass,StorageClass 动态的为 PVC 创建所需的 PV。

浅谈 Kubernetes 数据持久化方案

注:此功能需要基于 StorageClass。集群管理员必须先创建并配置好请求的 StorageClass,只有请求的 StorageClass 存在的情况下才能进行动态的创建。

使用 PV 进行持久化存储实例

这里我们将介绍如何使用 PV 资源进行数据持久化,这也是本文的重点内容。我们将以 NFS 做为后端存储结合 PV 为例,讲解 Kubernetes 如何实现数据持久化。

部署 NFS 服务器

安装 NFS 服务端

# Ubuntu / Debian
$ sudo apt install nfs-kernel-server

新建数据目录和设置目录权限

$ sudo mkdir -p /data/kubernetes/
$ sudo chmod 755 /data/kubernetes/

配置 NFS 服务端

NFS 的默认配置文件是 /etc/exports ,在该配置文件中添加下面的配置信息。

$ sudo vim /etc/exports
/data/kubernetes  *(rw,sync,no_root_squash)

配置文件说明:

  • /data/kubernetes  设置共享的数据的目录。

  • * 表示任何人都有权限连接,当然也可以设置成是一个网段、一个 IP、或者是域名。

  • rw 设置共享目录的读写权限。

  • sync 表示文件同时写入硬盘和内存。

  • no_root_squash 当登录 NFS 主机使用共享目录的使用者是 root 时,其权限将被转换成为匿名使用者,通常它的 UID 与 GID,都会变成 nobody 身份。

启动 NFS 服务端

  • 启动 NFS 服务端

$ sudo systemctl restart nfs-kernel-server
  • 验证 NFS 服务端是否正常启动

$ sudo rpcinfo -p|grep nfs
100003    3   tcp   2049  nfs
100003    4   tcp   2049  nfs
100003    3   udp   2049  nfs
  • 查看具体目录挂载权限

$ cat /var/lib/nfs/etab
/data/kubernetes  *(rw,sync,wdelay,hide,nocrossmnt,secure,no_root_squash,no_all_squash,no_subtree_check,secure_locks,acl,no_pnfs,anonuid=65534,anongid=65534,sec=sys,rw,secure,no_root_squash,no_all_squash)

如果以上步骤都正常的话,到这里 NFS 服务端就已经正常安装完成。

安装 NFS 客户端

  • 安装 NFS 客户端

$ sudo apt-get install nfs-common

注:所有 Node 宿主机都需要安装 NFS 客户端。

  • 验证 RPC 服务状态

$ sudo systemctl status rpcbind.service
● rpcbind.service - RPC bind portmap service
Loaded: loaded (/lib/systemd/system/rpcbind.service; enabled; vendor preset: enabled)
Active: active (running) since Tue 2018-08-07 09:54:29 CST; 49s ago
Docs: man:rpcbind(8)
Main PID: 17501 (rpcbind)
Tasks: 1 (limit: 2313)
CGroup: /system.slice/rpcbind.service
└─17501 /sbin/rpcbind -f -w
  • 检查 NFS 服务端可用的共享目录

$ sudo showmount -e 192.168.100.213
Export list for 192.168.100.213:
/data/kubernetes *
  • 挂载 NFS 共享目录到本地

$ sudo mkdir -p /data/kubernetes/
$ sudo mount -t nfs 192.168.100.213:/data/kubernetes/ /data/kubernetes/
  • 验证 NFS 客户端

挂载成功后,在客户端上面的目录中新建一个文件,然后检查在 NFS 服务端的共享目录下是否也会出现该文件。

# 在 NFS 客户端新建
$ sudo touch /data/kubernetes/test.txt

# 在 NFS 服务端查看
$ sudo ls -ls /data/kubernetes/
total 0
0 -rw-r--r-- 1 root root 0 Aug  7 09:59 test.txt

实现静态 PV

新建 PV 资源

完成上面的共享存储后,我们就可以来使用 PV 和 PVC 来管理和使用这些共享存储。PV 作为存储资源主要包括存储能力、访问模式、存储类型、回收策略等关键信息。

下面我们来新建一个 PV 对象并使用 NFS 做为后端存储类型,该 PV 包括 1G 的存储空间、访问模式为 ReadWriteOnce、回收策略为 Recyle。

$ vim pv1-nfs.yaml

apiVersion: v1
kind: PersistentVolume
metadata:
  name:  pv1-nfs
spec:
  capacity:
    storage: 1Gi
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Recycle
  nfs:
    path: /data/kubernetes
    server: 192.168.100.213

注:Kubernetes 支持的 PV 类型有很多,比如常见的 Ceph、GlusterFs、NFS 等。更多的支持类型可以查看官方文档。

我们先使用 Kubectl 创建该 PV 资源。

$ kubectl create -f pv1-nfs.yaml
persistentvolume "pv1-nfs" created

从下面的结果,我们可以看到 pv1-nfs 已经创建成功。状态是 Available,这表示 pv1-nfs 准备就绪,可以被 PVC 申请。

$ kubectl get pv
NAME      CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS      CLAIM     STORAGECLASS   REASON    AGE
pv1-nfs   1Gi        RWO            Recycle          Available                                      46s

我们对上面的 PV 属性来做一个简单的解读。

Capacity(存储能力)

一般来说,一个 PV 对象都要指定一个存储能力,通过 PV 的 Capacity 属性来设置。这里的 storage=1Gi 表示设置存储空间的大小。

AccessModes(访问模式)

AccessModes 是用来对 PV 进行访问模式的设置,用于描述用户应用对存储资源的访问权限,访问权限包括下面几种方式:

  • ReadWriteOnce(RWO):读写权限,但是只能被单个节点挂载。

  • ReadOnlyMany(ROX):只读权限,可以被多个节点挂载。

  • ReadWriteMany(RWX):读写权限,可以被多个节点挂载。

注:一些 PV 可能支持多种访问模式,但是在挂载的时候只能使用一种访问模式,多种访问模式是不会生效的。

下图是一些常用的 Volume 插件支持的访问模式:

浅谈 Kubernetes 数据持久化方案

PersistentVolumeReclaimPolicy(回收策略)

当前 PV 设置的回收策略,我们这里指定的 PV 的回收策略为 Recycle。目前 PV 支持的策略有三种:

  • Retain(保留)- 保留数据,需要管理员手工清理数据。

  • Recycle(回收)- 清除 PV 中的数据,效果相当于执行 rm -rf /thevoluem/*

  • Delete(删除)- 与 PV 相连的后端存储完成 Volume 的删除操作,这种方式常见于云服务商的存储服务,比如 ASW EBS。

注:目前只有 NFS 和 HostPath 两种类型支持回收策略。设置为 Retain 这种策略会更加保险一些。

状态

一个 PV 的生命周期中,可能会处于 4 种不同的阶段。

  • Available(可用):表示可用状态,还未被任何 PVC 绑定。

  • Bound(已绑定):表示 PV 已经被 PVC 绑定。

  • Released(已释放):PVC 被删除,但是资源还未被集群重新声明。

  • Failed(失败): 表示该 PV 的自动回收失败。

新建 PVC 资源

我们平时真正使用的资源其实是 PVC,就类似于我们的服务是通过 Pod 来运行的,而不是 Node,只是 Pod 跑在 Node 上而已。

首先,我们新建一个数据卷声明,向 PV 请求 1Gi 的存储容量。其访问模式设置为 ReadWriteOnce。

$ vim pvc1-nfs.yaml

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: pvc1-nfs
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi

在新建 PVC 之前,我们可以看下之前创建的 PV 的状态。

$ kubectl get pv
NAME      CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS      CLAIM     STORAGECLASS   REASON    AGE
pv1-nfs   1Gi        RWO            Recycle          Available                                      28m

我们可以看到当前 pv1-nfs 是在 Available 的一个状态,所以这个时候我们的 PVC 可以和这个 PV 进行绑定。

$ kubectl create -f pvc1-nfs.yaml
persistentvolumeclaim "pvc1-nfs" created

$ kubectl get pvc
NAME       STATUS    VOLUME    CAPACITY   ACCESS MODES   STORAGECLASS   AGE
pvc1-nfs   Bound     pv1-nfs   1Gi        RWO                           34s

从上面的结果可以看到 pvc1-nfs 创建成功了,并且状态是 Bound 状态。这个时候我们再看下 PV 的状态。

$ kubectl get pv
NAME      CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS    CLAIM              STORAGECLASS   REASON    AGE
pv1-nfs   1Gi        RWO            Recycle          Bound     default/pvc1-nfs                            31m

同样我们可以看到 PV 也是 Bound 状态,对应的声明是 default/pvc1-nfs,表示 default 命名空间下面的 pvc1-nfs,表示我们刚刚新建的 pvc1-nfs 和 pv1-nfs 绑定成功。

PV 和 PVC 的绑定是系统自动完成的,不需要显示指定要绑定的 PV。系统会根据 PVC 中定义的要求去查找处于 Available 状态的 PV。

  • 如果找到合适的 PV 就完成绑定。

  • 如果没有找到合适的 PV 那么 PVC 就会一直处于 Pending 状态,直到找到合适的 PV 完成绑定为止。

下面我们来看一个例子,这里声明一个 PVC 的对象,它要求 PV 的访问模式是 ReadWriteOnce、存储容量 2Gi 和标签值为 app=nfs。

$ vim pvc2-nfs.yaml

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: pvc2-nfs
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 2Gi
  selector:
    matchLabels:
      app: nfs

我们先查看下当前系统的所有 PV 资源。

$ kubectl get pv
NAME      CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS    CLAIM              STORAGECLASS   REASON    AGE
pv1-nfs   1Gi        RWO            Recycle          Bound     default/pvc1-nfs                            52m

从结果可以看到,目前所有 PV 都是 Bound 状态,并没有 Available 状态的 PV。所以我们现在用上面新建的 PVC 是无法匹配到合适的 PV 的。我们来创建 PVC 看看:

$ kubectl create -f pvc2-nfs.yaml
persistentvolumeclaim "pvc2-nfs" created

$ kubectl get pvc
NAME       STATUS    VOLUME    CAPACITY   ACCESS MODES   STORAGECLASS   AGE
pvc1-nfs   Bound     pv1-nfs   1Gi        RWO                           28m
pvc2-nfs   Pending                                                      16s

从结果我们可以看到 pvc2-nfs 当前就是 Pending 状态,因为并没有合适的 PV 给这个 PVC 使用。现在我们来新建一个合适该 PVC 使用的 PV。

$ vim pv2-nfs.yaml

apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv2-nfs
  labels:
    app: nfs
spec:
  capacity:
    storage: 2Gi
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Recycle
  nfs:
    server: 192.168.100.213
    path: /data/kubernetes

使用 Kubectl 创建该 PV。

$ kubectl create -f pv2-nfs.yaml
persistentvolume "pv2-nfs" created

$ kubectl get pv
NAME      CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS    CLAIM              STORAGECLASS   REASON    AGE
pv1-nfs   1Gi        RWO            Recycle          Bound     default/pvc1-nfs                            1h
pv2-nfs   2Gi        RWO            Recycle          Bound     default/pvc2-nfs                            18s

创建完 pv2-nfs 后,从上面的结果你会发现该 PV 已经是 Bound 状态了。其对应的 PVC 是 default/pvc2-nfs,这就证明 pvc2-nfs 终于找到合适的 PV 且完成了绑定。

$ kubectl get pvc
NAME       STATUS    VOLUME    CAPACITY   ACCESS MODES   STORAGECLASS   AGE
pvc1-nfs   Bound     pv1-nfs   1Gi        RWO                           36m
pvc2-nfs   Bound     pv2-nfs   2Gi        RWO                           9m

注:如果 PVC 申请的容量大小小于 PV 提供的大小,PV 同样会分配该 PV 所有容量给 PVC,如果 PVC 申请的容量大小大于 PV 提供的大小,此次申请就会绑定失败。

使用 PVC 资源

这里我们已经完成了 PV 和 PVC 创建,现在我们就可以使用这个 PVC 了。这里我们使用 Nginx 的镜像来创建一个 Deployment,将容器的 /usr/share/nginx/html 目录通过 Volume 挂载到名为 pvc2-nfs 的 PVC 上,并通过 NodePort 类型的 Service 来暴露服务。

$ vim nfs-pvc-deploy.yaml

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: nfs-pvc
spec:
  replicas: 3
  template:
    metadata:
      labels:
        app: nfs-pvc
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 80
          name: web
        volumeMounts:
        - name: www
          mountPath: /usr/share/nginx/html
      volumes:
      - name: www
        persistentVolumeClaim:
          claimName: pvc2-nfs

---

apiVersion: v1
kind: Service
metadata:
  name: nfs-pvc
  labels:
    app: nfs-pvc
spec:
  type: NodePort
  ports:
  - port: 80
    targetPort: web
  selector:
    app: nfs-pvc

使用 Kubectl 创建这个 Deployment。

$ kubectl create -f nfs-pvc-deploy.yaml
deployment.extensions "nfs-pvc" created
service "nfs-pvc" created

$ kubectl get pods -o wide|grep nfs-pvc
nfs-pvc-789788587b-ctp58                        1/1       Running     0          4m        172.30.24.6   dev-node-02
nfs-pvc-789788587b-q294p                        1/1       Running     0          4m        172.30.92.6   dev-node-03
nfs-pvc-789788587b-rtl5s                        1/1       Running     0          4m        172.30.87.9   dev-node-01

$ kubectl get svc|grep nfs-pvc
nfs-pvc                         NodePort       10.254.5.24      <none>                                            80:8682/TCP                5m

通过 NodePort 访问该服务。

$ curl -I http://192.168.100.211:8682
HTTP/1.1 403 Forbidden
Server: nginx/1.7.9
Date: Tue, 07 Aug 2018 03:42:30 GMT
Content-Type: text/html
Content-Length: 168
Connection: keep-alive

我们可以看到 Nginx 返回了 403,这是因为我们用 NFS 中的共享目录做为 Nginx 的默认站点目录,目前这个 NFS 共享目录中没有可用的 index.html 文件。

$ ls  /data/kubernetes/
test.txt

在 NFS 服务端共享目录下新建一个 index.html 的文件。

$ sudo sh -c "echo '<h1>Hello Kubernetes~</h1>' > /data/kubernetes/index.html"
$ ls /data/kubernetes/
index.html  test.txt

再次通过 NodePort 访问该服务。

$ curl  http://192.168.100.211:8682
<h1>Hello Kubernetes~</h1>

使用 subPath 对同一个 PV 进行隔离

从上面的例子中,我们可以看到容器中的数据是直接放到共享数据目录根目录下的。如果有多个容器都使用一个 PVC 的话,这样就很容易造成文件冲突。Pod 中 volumeMounts.subPath 属性可用于指定引用卷内的路径,只需设置该属性就可以解决该问题。

修改刚才创建 Deployment 的 YAML 文件,增加 subPath 行。

$ vim nfs-pvc-deploy.yaml
...
volumeMounts:
- name: www
  subPath: nginx-pvc-test
  mountPath: /usr/share/nginx/html
...

更改完 YAML 文件后,重新更新下 Deployment 即可。

$ kubectl apply -f nfs-pvc-deploy.yaml
Warning: kubectl apply should be used on resource created by either kubectl create --save-config or kubectl apply
deployment.extensions "nfs-pvc" configured
Warning: kubectl apply should be used on resource created by either kubectl create --save-config or kubectl apply
service "nfs-pvc" configured

更新完后,NFS 的数据共享目录下就会自动新增一个同 subPath 名字一样的目录。

$ ls  /data/kubernetes/
index.html      nginx-pvc-test/ test.txt

同样 nginx-pvc-test 目录下默认是空的。

$ ls /data/kubernetes/nginx-pvc-test/

新增一个 index 文件后访问该服务,一切安好。

$ sudo sh -c "echo '<h1>Hello Kubernetes~</h1>' > /data/kubernetes/nginx-pvc-test/index.html"
$ curl  http://192.168.100.211:8682
<h1>Hello Kubernetes~</h1>

验证 PVC 中的数据持久化

上面我们已经成功的在 Pod 中使用了 PVC 来做为存储,现在我们来验证下数据是否会丢失。我们分两种情况来验证:一种是直接删除 Deployment 和 Service,另一种是先删除 PVC 后再删除 Deployment 和 Service。

直接删除 Deployment 和 Service

在这种情况下数据会永久保存下来,删除 Deployment 和 Service 不会对数据造成任何影响。

  • 删除 Deployment 和 Service

$ kubectl delete -f nfs-pvc-deploy.yaml
service "nfs-pvc" deleted
deployment.extensions "nfs-pvc" deleted
  • 查看数据共享目录下面的数据

$ ls /data/kubernetes/nginx-pvc-test/
index.html

先删除 PVC 后再删除 Deployment 和 Service

  • 删除 PVC

$ kubectl delete pvc pvc2-nfs
persistentvolumeclaim "pvc2-nfs" deleted

我们可以看到 PVC 状态已经变成了 Terminating,但是现在数据共享目录中的文件和服务都是可以正常访问的。

$ kubectl get pvc
NAME       STATUS        VOLUME    CAPACITY   ACCESS MODES   STORAGECLASS   AGE
pvc1-nfs   Bound         pv1-nfs   1Gi        RWO                           3h
pvc2-nfs   Terminating   pv2-nfs   2Gi        RWO                           6m

$ ls /data/kubernetes/nginx-pvc-test/
index.html

$ curl  http://192.168.100.211:8928
<h1>Hello Kubernetes~</h1>

这是因为还有 Pod 正在使用 pvc2-nfs 这个 PVC,那么对应的资源依然可用。如果无 Pod 继续使用 pvc2-nfs 这个 PVC,则相应 PVC 对应的资源就会被收回。

  • 删除 Deployment 和 Service

$ kubectl delete -f nfs-pvc-deploy.yaml
deployment.extensions "nfs-pvc" deleted
service "nfs-pvc" deleted

$ kubectl get pvc
NAME       STATUS    VOLUME    CAPACITY   ACCESS MODES   STORAGECLASS   AGE
pvc1-nfs   Bound     pv1-nfs   1Gi        RWO                           3h

$ kubectl get pv
NAME      CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS      CLAIM              STORAGECLASS   REASON    AGE
pv1-nfs   1Gi        RWO            Recycle          Bound       default/pvc1-nfs                            4h
pv2-nfs   2Gi        RWO            Recycle          Available                                               25m

$ ls /data/kubernetes/

从上面的结果我们可以看到 pvc2-nfs 这个 PVC 已经不存在了,pv2-nfs 这个 PV 的状态也变成 Available 了。由于我们设置的 PV 的回收策略是 Recycle,我们可以发现 NFS 的共享数据目录下面的数据也没了,这是因为我们把 PVC 给删除掉后回收了数据。

使用 StorageClass 实现动态 PV

上面的例子中我们学习了静态 PV 和 PVC 的使用方法,所谓静态 PV 就是我要使用的一个 PVC 的话就必须手动去创建一个 PV。

这种方式在很多使用场景下使用起来都不灵活,需要依赖集群管理员事先完成 PV 的建立。特别是对于 StatefulSet 类型的应用,简单的使用静态的 PV 就不是很合适了。这种情况下我们就需要用到动态 PV,动态 PV 的实现需要用到 StorageClass。

创建 Provisioner

要使用 StorageClass,我们就得安装对应的自动配置程序。比如:我们这里存储后端使用的是 NFS,那么我们就需要使用到一个对应的自动配置程序。支持 NFS 的自动配置程序就是 nfs-client,我们把它称作 Provisioner。这个程序可以使用我们已经配置好的 NFS 服务器,来自动创建持久卷,也就是自动帮我们创建 PV。

  • 以 Deployment 方式部署一个 Provisioner

根据实际情况将下面的环境变量 NFS_SERVERNFS_PATH 和 NFS 相关配置替换成你的对应的值。

$ vim nfs-client.yaml

kind: Deployment
apiVersion: extensions/v1beta1
metadata:
  name: nfs-client-provisioner
spec:
  replicas: 1
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: nfs-client-provisioner
    spec:
      serviceAccountName: nfs-client-provisioner
      containers:
        - name: nfs-client-provisioner
          image: quay.io/external_storage/nfs-client-provisioner:latest
          volumeMounts:
            - name: nfs-client-root
              mountPath: /persistentvolumes
          env:
            - name: PROVISIONER_NAME
              value: fuseim.pri/ifs
            - name: NFS_SERVER
              value: 192.168.100.213
            - name: NFS_PATH
              value: /data/kubernetes
      volumes:
        - name: nfs-client-root
          nfs:
            server: 192.168.100.213
            path: /data/kubernetes

使用 Kubectl 命令建立这个 Deployment

$ kubectl create -f nfs-client.yaml
deployment.extensions "nfs-client-provisioner" created
  • 给 nfs-client-provisioner 创建 ServiceAccount

从 Kubernetes 1.6 版本开始,API Server 启用了 RBAC 授权。Provisioner 要想在 Kubernetes 中创建对应的 PV 资源,就得有对应的权限。

这里我们新建一个名为 nfs-client-provisioner 的 ServiceAccount 并绑定在一个名为 nfs-client-provisioner-runner 的 ClusterRole 上。该 ClusterRole 包含对 PersistentVolumes 的增、删、改、查等权限。

$ vim nfs-client-sa.yaml

apiVersion: v1
kind: ServiceAccount
metadata:
name: nfs-client-provisioner

---
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: nfs-client-provisioner-runner
rules:
- 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: ["list", "watch", "create", "update", "patch"]

---
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: run-nfs-client-provisioner
subjects:
- kind: ServiceAccount
name: nfs-client-provisioner
namespace: default
roleRef:
kind: ClusterRole
name: nfs-client-provisioner-runner
apiGroup: rbac.authorization.k8s.io

使用 Kubectl 命令建立这个 ServiceAccount。

$ kubectl create -f nfs-client-sa.yaml
serviceaccount "nfs-client-provisioner" created
clusterrole.rbac.authorization.k8s.io "nfs-client-provisioner-runner" created
clusterrolebinding.rbac.authorization.k8s.io "run-nfs-client-provisioner" created
  • 创建 StorageClass 对象

这里我们创建了一个名为 course-nfs-storage 的 StorageClass 对象,注意下面的 Provisioner 对应的值一定要和上面的 Deployment下面 PROVISIONER_NAME 这个环境变量的值一样。

$ vim nfs-client-class.yaml

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: course-nfs-storage
provisioner: fuseim.pri/ifs # or choose another name, must match deployment's env PROVISIONER_NAME'

使用 Kubectl 命令建立这个 StorageClass。

$ kubectl create -f nfs-client-class.yaml
storageclass.storage.k8s.io "course-nfs-storage" created

以上都创建完成后查看下相关资源的状态。

$ kubectl get pods|grep nfs-client
NAME                                            READY     STATUS      RESTARTS   AGE
nfs-client-provisioner-9d94b899c-nn4c7          1/1       Running     0          1m

$ kubectl get storageclass
NAME                 PROVISIONER      AGE
course-nfs-storage   fuseim.pri/ifs   1m

手动创建的一个 PVC 对象

  • 新建一个 PVC 对象

我们这里就来建立一个能使用 StorageClass 资源对象来动态建立 PV 的 PVC,要创建使用 StorageClass 资源对象的 PVC 有以下两种方法。

方法一:在这个 PVC 对象中添加一个 Annotations 属性来声明 StorageClass 对象的标识。

# 这里我们声明了一个 PVC 对象,采用 ReadWriteMany 的访问模式并向 PV 请求 100Mi 的空间。
$ vim test-pvc.yaml

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: test-pvc
annotations:
volume.beta.kubernetes.io/storage-class: "course-nfs-storage"
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 100Mi

方法二:把名为 course-nfs-storage 的 StorageClass 设置为 Kubernetes 的默认后端存储。

$ kubectl patch storageclass course-nfs-storage -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
storageclass.storage.k8s.io "course-nfs-storage" patched

上面这两种方法都是可以的,为了不影响系统的默认行为,这里我们采用第一种方法,直接使用 YAML 文件创建即可。

$ kubectl create -f test-pvc.yaml
persistentvolumeclaim "test-pvc" created

创建完成后,我们来看看对应的资源是否创建成功。

$ kubectl get pvc
NAME       STATUS    VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS         AGE
pvc1-nfs   Bound     pv1-nfs                                    1Gi        RWO                                 4h
test-pvc   Bound     pvc-3d8d6ecf-9a13-11e8-9a96-001c42c61a79   100Mi      RWX            course-nfs-storage   41s

$ kubectl get pv
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS      CLAIM              STORAGECLASS         REASON    AGE
pv1-nfs                                    1Gi        RWO            Recycle          Bound       default/pvc1-nfs                                  5h
pv2-nfs                                    2Gi        RWO            Recycle          Available                                                     1h
pvc-3d8d6ecf-9a13-11e8-9a96-001c42c61a79   100Mi      RWX            Delete           Bound       default/test-pvc   course-nfs-storage             2m

从上面的结果我们可以看到一个名为 test-pvc 的 PVC 对象创建成功并且状态已经是 Bound 了。对应也自动创建了一个名为 pvc-3d8d6ecf-9a13-11e8-9a96-001c42c61a79 的 PV 对象,其访问模式是 RWX,回收策略是 Delete。STORAGECLASS 栏中的值也正是我们创建的 StorageClass 对象 course-nfs-storage。

  • 测试

我们用一个简单的示例来测试下用 StorageClass 方式声明的 PVC 对象是否能正常存储。

$ vim test-pod.yaml

kind: Pod
apiVersion: v1
metadata:
name: test-pod
spec:
containers:
- name: test-pod
image: busybox
imagePullPolicy: IfNotPresent
command:
- "/bin/sh"
args:
- "-c"
- "touch /mnt/SUCCESS && exit 0 || exit 1"
volumeMounts:
- name: nfs-pvc
mountPath: "/mnt"
restartPolicy: "Never"
volumes:
- name: nfs-pvc
persistentVolumeClaim:
claimName: test-pvc

上面这个 Pod 的作用非常简单,就是在一个 busybox 容器里的 /mnt 目录下面新建一个 SUCCESS 的文件,而 /mnt 目录是挂载到 test-pvc 这个资源对象上的。

$ kubectl create -f test-pod.yaml
pod "test-pod" created

完成 Pod 创建后,我们可以在 NFS 服务器的共享数据目录下面查看数据是否存在。我们可以看到下面有一个文件夹,这个文件夹的命名方式是: ${namespace}-${pvcName}-${pvName}

$ ls /data/kubernetes/
default-test-pvc-pvc-3d8d6ecf-9a13-11e8-9a96-001c42c61a79

再看下这个文件夹下面的文件。

$ ls /data/kubernetes/default-test-pvc-pvc-3d8d6ecf-9a13-11e8-9a96-001c42c61a79/
SUCCESS

我们看到下面有一个 SUCCESS 的文件,说明 PV 对应的存储里可以成功写入文件。

自动创建的一个 PVC 对象

在上面的演示过程中,我们可以看到是手动创建的一个 PVC 对象,而在实际使用中更多使用 StorageClass 的是 StatefulSet 类型的服务。

StatefulSet 类型的服务是可以通过一个 volumeClaimTemplates 属性来直接使用 StorageClass。volumeClaimTemplates 其实就是一个 PVC 对象的模板,类似于 StatefulSet 下面的 template,而这种模板可以动态的去创建相应的 PVC 对象。

  • 创建一个 StatefulSet 对象

$ vim test-statefulset-nfs.yaml
apiVersion: apps/v1beta1
kind: StatefulSet
metadata:
name: nfs-web
spec:
serviceName: "nginx"
replicas: 3
template:
metadata:
labels:
app: nfs-web
spec:
terminationGracePeriodSeconds: 10
containers:
- name: nginx
image: nginx:1.7.9
ports:
- containerPort: 80
name: web
volumeMounts:
- name: www
mountPath: /usr/share/nginx/html
volumeClaimTemplates:
- metadata:
name: www
annotations:
volume.beta.kubernetes.io/storage-class: course-nfs-storage
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi

使用 Kubectl 命令建立这个 StatefulSet 对象。

$ kubectl create -f test-statefulset-nfs.yaml
statefulset.apps "nfs-web" created
  • 检查相应资源对像是否已完成创建

创建完成后可以看到上面 StatefulSet 对象中定义的 3 个 Pod 已经运行成功。

$ kubectl get pods
NAME                                            READY     STATUS      RESTARTS   AGE
nfs-web-0                                       1/1       Running     0          19s
nfs-web-1                                       1/1       Running     0          16s
nfs-web-2                                       1/1       Running     0          6s

再查看下 PVC 和 PV 对象。

$ kubectl get pvc
NAME            STATUS    VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS         AGE
www-nfs-web-0   Bound     pvc-16ba792f-9a15-11e8-9a96-001c42c61a79   1Gi        RWO            course-nfs-storage   1m
www-nfs-web-1   Bound     pvc-18c631d4-9a15-11e8-9a96-001c42c61a79   1Gi        RWO            course-nfs-storage   1m
www-nfs-web-2   Bound     pvc-1ed50c38-9a15-11e8-9a96-001c42c61a79   1Gi        RWO            course-nfs-storage   1m

$ kubectl get pv
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS      CLAIM                   STORAGECLASS         REASON    AGE
pvc-16ba792f-9a15-11e8-9a96-001c42c61a79   1Gi        RWO            Delete           Bound       default/www-nfs-web-0   course-nfs-storage             3m
pvc-18c631d4-9a15-11e8-9a96-001c42c61a79   1Gi        RWO            Delete           Bound       default/www-nfs-web-1   course-nfs-storage             3m
pvc-1ed50c38-9a15-11e8-9a96-001c42c61a79   1Gi        RWO            Delete           Bound       default/www-nfs-web-2   course-nfs-storage             3m

我们可以看到生成了 3 个 PVC 对象,名称由模板名称加上 Pod 的名称组合而成,而这 3 个 PVC 对象也都是绑定状态。

  • 检查 NFS 服务器上面的是否生成相应的数据目录

$ ls  /data/kubernetes/ -l
total 16
drwxrwxrwx 2 root root 4096 Aug  7 15:32 default-test-pvc-pvc-3d8d6ecf-9a13-11e8-9a96-001c42c61a79
drwxrwxrwx 2 root root 4096 Aug  7 15:40 default-www-nfs-web-0-pvc-16ba792f-9a15-11e8-9a96-001c42c61a79
drwxrwxrwx 2 root root 4096 Aug  7 15:40 default-www-nfs-web-1-pvc-18c631d4-9a15-11e8-9a96-001c42c61a79
drwxrwxrwx 2 root root 4096 Aug  7 15:40 default-www-nfs-web-2-pvc-1ed50c38-9a15-11e8-9a96-001c42c61a79

部署一个使用 StorageClass 的应用

上面的例子中都是简单的运行了一个 Nginx 来演示功能,接下来我们用 Helm 来部署一个具体的应用看看效果。如果你对 Helm 还不够了解,可以先读读 「Helm 入门指南」一文。

这里我们同样以部署 DokuWiki 的为例。在「 利用 Helm 快速部署 Ingress 」一文中我们在部署时关闭了 PersistentVolume。现在我们就演示加上 PersistentVolume 的效果。

DokuWiki 默认会启用 Persistence 特性,这里主要通过 persistence.apache.storageClasspersistence.apache.sizepersistence.dokuwiki.storageClasspersistence.dokuwiki.size 几个参数来设置 Apache 和 DokuWiki 两个应用对应的 storageClass 名称和存储大小 。

  • 使用 helm install 进行一键部署

$ cd /home/k8s/charts/stable
$ helm install --name dokuwiki --set "ingress.enabled=true,ingress.hosts[0].name=wiki.hi-linux.com,persistence.apache.storageClass=course-nfs-storage,persistence.apache.size=500Mi,persistence.dokuwiki.storageClass=course-nfs-storage,persistence.dokuwiki.size=500Mi"  dokuwiki
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/Secret
NAME               TYPE    DATA  AGE
dokuwiki-dokuwiki  Opaque  1     6m

==> v1/PersistentVolumeClaim
NAME                        STATUS  VOLUME                                    CAPACITY  ACCESS MODES  STORAGECLASS        AGE
dokuwiki-dokuwiki-apache    Bound   pvc-1bfd0981-9af0-11e8-9a96-001c42c61a79  500Mi     RWO           course-nfs-storage  6m
dokuwiki-dokuwiki-dokuwiki  Bound   pvc-1bffad3d-9af0-11e8-9a96-001c42c61a79  500Mi     RWO           course-nfs-storage  6m

==> v1/Service
NAME               TYPE          CLUSTER-IP     EXTERNAL-IP  PORT(S)                   AGE
dokuwiki-dokuwiki  LoadBalancer  10.254.95.241  <pending>    80:8592/TCP,443:8883/TCP  6m

==> v1beta1/Deployment
NAME               DESIRED  CURRENT  UP-TO-DATE  AVAILABLE  AGE
dokuwiki-dokuwiki  1        1        1           1          6m

==> v1beta1/Ingress
NAME               HOSTS              ADDRESS  PORTS  AGE
dokuwiki-dokuwiki  wiki.hi-linux.com  80       6m

==> v1/Pod(related)
NAME                               READY  STATUS   RESTARTS  AGE
dokuwiki-dokuwiki-bf9fb965c-d9x2w  1/1    Running  1         6m

NOTES:

** Please be patient while the chart is being deployed **

1. Get the DokuWiki URL indicated on the Ingress Rule and associate it to your cluster external IP:

export CLUSTER_IP=$(minikube ip) # On Minikube. Use: `kubectl cluster-info` on others K8s clusters
export HOSTNAME=$(kubectl get ingress --namespace default dokuwiki-dokuwiki -o jsonpath='{.spec.rules[0].host}')
echo "Dokuwiki URL: http://$HOSTNAME/"
echo "$CLUSTER_IP  $HOSTNAME" | sudo tee -a /etc/hosts

2. Login with the following credentials

echo Username: user
echo Password: $(kubectl get secret --namespace default dokuwiki-dokuwiki -o jsonpath="{.data.dokuwiki-password}" | base64 --decode)
  • 查看部署完成后状态

$ helm list
NAME          REVISION  UPDATED                   STATUS    CHART               NAMESPACE
dokuwiki      1         Wed Aug  8 17:47:48 2018  DEPLOYED  dokuwiki-2.0.3      default
  • 在后端存储上查看对应的数据目录

# 应用对应的数据目录已经自动创建
$ ls /data/kubernetes/*dokuwiki* -ld
drwxrwxrwx 3 root   root   4096 Aug  8 17:52 /data/kubernetes/default-dokuwiki-dokuwiki-apache-pvc-1bfd0981-9af0-11e8-9a96-001c42c61a79
drwxrwxrwx 5 daemon daemon 4096 Aug  8 17:53 /data/kubernetes/default-dokuwiki-dokuwiki-dokuwiki-pvc-1bffad3d-9af0-11e8-9a96-001c42c61a79

# 查看应用对应的数据目录下文件
$ ls  /data/kubernetes/default-dokuwiki-dokuwiki-dokuwiki-pvc-1bffad3d-9af0-11e8-9a96-001c42c61a79/ -l
total 12
drwxr-xr-x  2 daemon daemon 4096 Aug  8 17:49 conf
drwxr-xr-x 12 daemon daemon 4096 Aug  8 17:48 data
drwxr-xr-x  5 daemon daemon 4096 Aug  8 17:48 lib

$ ls  /data/kubernetes/default-dokuwiki-dokuwiki-apache-pvc-1bfd0981-9af0-11e8-9a96-001c42c61a79/conf/
bitnami  deflate.conf  extra  httpd.conf  magic  mime.types  original  vhosts
  • 访问 Web

根据提示生成相应的登陆用户名和密码。

$ echo Username: user
Username: user

$ echo Password: $(kubectl get secret --namespace default dokuwiki-dokuwiki -o jsonpath="{.data.dokuwiki-password}" | base64 --decode)
Password: e2GrABBkwF

通过浏览器访问该应用。效果图如下:

浅谈 Kubernetes 数据持久化方案

参考文档

http://www.google.com

http://t.cn/RmDscuQ

http://t.cn/RDqXk2U

http://t.cn/RDqX1qi

http://t.cn/RDqT4Xw

http://t.cn/RmDscuQ

http://t.cn/RDqg4D0

http://t.cn/RDqkyoC

http://t.cn/RDVE0bW

http://t.cn/R6GaBUK


今日思想

当遇上难以克服的困难,认为已经不行了的时候,其实并不是终点,而恰是重新开始的起点。

—— 稻盛和夫

推荐阅读

浅谈 Kubernetes 数据持久化方案


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

网站分析实战

网站分析实战

王彦平 吴盛峰 / 电子工业出版社 / 2013-1 / 59.00元

《网站分析实战:如何以数据驱动决策,提升网站价值》由王彦平、吴盛峰著。目前,越来越多的网站开始重视数据,并期望从中发现新的机会,不管你是做网络营销、互联网产品设计、电子商务运营、个人站点运营维护,我们都希望从数据中寻找有价值的结论,并且指导公司管理层的决策,最终创造更大的网站价值。《网站分析实战:如何以数据驱动决策,提升网站价值》以通俗易懂的方式来讲解网站分析所需掌握的知识,剖析日常工作中遇到的问......一起来看看 《网站分析实战》 这本书的介绍吧!

在线进制转换器
在线进制转换器

各进制数互转换器

随机密码生成器
随机密码生成器

多种字符组合密码

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试