云原生存储的两个关键领域:Docker 存储卷、K8s 存储卷;
容器服务之所以如此流行,一大优势即来自于运行容器时容器镜像的组织形式。容器通过复用容器镜像的技术,实现在相同节点上多个容器共享一个镜像资源(更细一点说是共享某一个镜像层),避免了每次启动容器时都拷贝、加载镜像文件,这种方式既节省了主机的存储空间,又提高了容器启动效率。
为了提高节点存储的使用效率,容器不光在不同运行的容器之间共享镜像资源,而且还实现了在不同镜像之间共享数据。共享镜像数据的实现原理:镜像是分层组合而成的,即一个完整的镜像会包含多个数据层,每层数据相互叠加、覆盖组成了最终的完整镜像。
为了实现多个容器间共享镜像数据,容器镜像每一层都是只读的。而通过实践我们得知,使用镜像启动一个容器的时候,其实是可以在容器里随意读写的,这是如何实现的呢?
容器使用镜像时,在多个镜像分层的最上面还添加了一个读写层。每一个容器在运行时,都会基于当前镜像在其最上层挂载一个读写层,用户针对容器的所有操作都在读写层中完成。一旦容器销毁,这个读写层也随之销毁。
如上图所示例子,一个节点上共有 3 个容器,分别基于 2 个镜像运行。
镜像存储层说明如下:
该节点上共包含 6 个镜像层:Layer 1~6。
所以两个镜像共享了 Layer 3、5 两个镜像层;
容器存储说明:
容器 1 和容器 2 共享镜像 1,且每个容器有自己的可写层;
容器 1(2)和容器 3 共享镜像 2 个层(Layer3、5);
通过上述例子可以看到,通过容器镜像分层实现数据共享可以大幅减少容器服务对主机存储的资源需求。
上面给出了容器读写层结构,而读写的原则:
对于读:容器由这么多层的数据组合而成,当不同层次的数据重复时,读取的原则是上层数据覆盖下层数据;
对于写:容器修改某个文件时,都是在最上层的读写层进行。主要实现技术有:写时复制、用时配置。
写时复制(CoW:copy-on-write),表示只在需要写时才去复制,是针对已有文件的修改场景。CoW 技术可以让所有的容器共享 image 的文件系统,所有数据都从 image 中读取,只有当要对文件进行写操作时,才从 image 里把要写的文件复制到最上面的读写层进行修改。所以无论有多少个容器共享同一个 image,所做的写操作都是对从 image 中复制后在复本上进行,并不会修改 image 的源文件,且多个容器操作同一个文件,会在每个容器的文件系统里生成一个复本,每个容器修改的都是自己的复本,相互隔离,相互不影响。
用时分配:在镜像中原本没有某个文件的场景,只有在要新写入一个文件时才分配空间,这样可以提高存储资源的利用率。比如启动一个容器,并不会为这个容器预分配一些磁盘空间,而是当有新文件写入时,才按需分配新空间。
存储驱动是指如何对容器的各层数据进行管理,已达到上述需要实现共享、可读写的效果。即:容器存储驱动实现了容器读写层数据的存储和管理。常见的存储驱动:
以 AUFS 为例,我们来讲述一下存储驱动的工作原理:
AUFS 是一种联合文件系统(UFS),是文件级的存储驱动。
AUFS 是一个能透明叠加一个或多个现有文件系统的层状文件系统,把多层文件系统合并成单层表示。即:支持将不同目录挂载到同一个虚拟文件系统下的文件系统。
可以一层一层地叠加修改文件,其底层都是只读的,只有最上层的文件系统是可写的。
当需要修改一个文件时,AUFS 创建该文件的一个副本,使用 CoW 将文件从只读层复制到可写层进行修改,结果也保存在可写层。
在 Docker 中,底下的只读层就是 image,可写层就是 Container 运行时。
其他各种存储驱动这里不再细讲,有兴趣的同学可以到网上查询资料。
容器中的应用读写数据都是发生在容器的读写层,镜像层+读写层映射为容器内部文件系统、负责容器内部存储的底层架构。当我们需要容器内部应用和外部存储进行交互时,需要一个类似于计算机 U 盘一样的外置存储,容器数据卷即提供了这样的功能。
另一方面:容器本身的存储数据都是临时存储,在容器销毁的时候数据会一起删除。而通过数据卷将外部存储挂载到容器文件系统,应用可以引用外部数据,也可以将自己产出的数据持久化到数据卷中,所以容器数据卷是容器进行数据持久化的实现方式。
容器存储组成:只读层(容器镜像) + 读写层 + 外置存储(数据卷)
容器数据卷从作用范围可以分为:单机数据卷 和 集群数据卷。单机数据卷即为容器服务在一个节点上的数据卷挂载能力,docker volume 是单机数据卷的代表实现;集群数据卷则关注的是集群级别的数据卷编排能力,K8s 数据卷则是集群数据卷的主要应用方式。
Docker Volume 是一个可供多个容器使用的目录,它绕过 UFS,包含以下特性:
Bind:将主机目录/文件直接挂载到容器内部。
Volume:使用第三方数据卷的时候使用这种方式。
Tmpfs:非持久化的卷类型,存储在内存中。
数据易丢失。
-v: src:dst:opts 只支持单机版。
示例:
$ docker run -d --name devtest -v /home:/data:ro,rslave nginx
$ docker run -d --name devtest --mount type=bind,source=/home,target=/data,readonly,bind-propagation=rslave nginx
$ docker run -d --name devtest -v /home:/data:z nginx
-v: src:dst:opts 只支持单机版。
示例:
$ docker run -d --name devtest -v myvol:/app:ro nginx
$ docker run -d --name devtest --mount source=myvol2,target=/app,readonly nginx
Docker 数据卷使用方式:
docker run -d -v /test:/data nginx
如果主机上没有/test目录,则默认创建此目录。
数据卷容器是一个运行中的容器,其他容器可以继承此容器中的挂载数据卷,则此容器的所有挂载都会在引用容器中体现。
docker run -d --volumes-from nginx1 -v /test1:/data1 nginx
继承所有来自配置容器的数据卷,并包含自己定义的卷。
Docker volume 支持挂载传播的配置:Propagation。
示例:
$ docker run –d -v /home:/data:shared nginx
表示:主机/home下面挂载的目录,在容器/data下面可用,反之可行;
$ docker run –d -v /home:/data:slave nginx
表示:主机/home下面挂载的目录,在容器/data下面可用,反之不行;
Volume 挂载可见性:
Bind 挂载可见性:以主机目录为准。
Docker 数据卷实现了将容器外部存储挂载到容器文件系统的方式。为了扩展容器对外部存储类型的需求,docker 提出了通过存储插件的方式挂载不同类型的存储服务。扩展插件统称为 Volume Driver,可以为每种存储类型开发一种存储插件。
Docker Daemon 与 Volume driver 通信方式有:
实现接口:
Create, Remove, Mount, Path, Umount, Get, List, Capabilities;
使用示例:
$ docker volume create --driver nas -o diskid="" -o host="10.46.225.247" -o path=”/nas1" -o mode="" --name nas1
Docker Volume Driver 适用在单机容器环境或者 swarm 平台进行数据卷管理,随着 K8s 的流行其使用场景已经越来越少,关于 VolumeDriver 的详细介绍这里不在细讲,有兴趣可以参考:https://docs.docker.com/engine/extend/plugins_volume/
根据之前的描述,为了实现容器数据的持久化我们需要使用数据卷的功能,在 K8s 编排系统中如何为运行的负载(Pod)定义存储呢?K8s 是一个容器编排系统,其关注的是容器应用在整个集群的管理和部署形式,所以在考虑 K8s 应用存储的时候就需要从集群角度考虑。K8s 存储卷定义了在 K8s 系统中应用与存储的关联关系。其包含以下概念:
数据卷定义了外置存储的细节,并内嵌到 Pod 中作为 Pod 的一部分。其实质是外置存储在 K8s 系统的一个记录对象,当负载需要使用外置存储的时候,从数据卷中查到相关信息并进行存储挂载操作。
K8S Volume 常用类型:
一些 volume 模板示例如下:
volumes:
- name: hostpath
hostPath:
path: /data
type: Directory
---
volumes:
- name: disk-ssd
persistentVolumeClaim:
claimName: disk-ssd-web-0
- name: default-token-krggw
secret:
defaultMode: 420
secretName: default-token-krggw
---
volumes:
- name: "oss1"
flexVolume:
driver: "alicloud/oss"
options:
bucket: "docker"
url: "oss-cn-hangzhou.aliyuncs.com"
PVC 是 PersistentVolumeClaim 的缩写,译为存储声明;PVC 是在 K8s 中一种抽象的存储卷类型,代表了某个具体类型存储的数据卷表达。其设计意图是:存储与应用编排分离,将存储细节抽象出来并实现存储的编排(存储卷)。这样 K8s 中存储卷对象独立于应用编排而单独存在,在编排层面使应用和存储解耦。
PV 是 PersistentVolume 的缩写,译为持久化存储卷;PV 在 K8s 中代表一个具体存储类型的卷,其对象中定义了具体存储类型和卷参数。即目标存储服务所有相关的信息都保存在 PV 中,K8s 引用 PV 中的存储信息执行挂载操作。
应用负载、PVC、PV 的关联关系为:
从实现上看,只要有了 PV 既可以实现存储和应用的编排分离,也能实现数据卷的挂载,为何要用 PVC + PV 两个对象呢?K8s 这样设计是从应用角度对存储卷进行二次抽象;由于 PV 描述的是对具体存储类型,需要定义详细的存储信息,而应用层用户在消费存储服务的时候往往不希望对底层细节知道的太多,让应用编排层面来定义具体的存储服务不够友好。这时对存储服务再次进行抽象,只把用户关系的参数提炼出来,用 PVC 来抽象更底层的 PV。所以 PVC、PV 关注的对象不一样,PVC 关注用户对存储需求,给用户提供统一的存储定义方式;而 PV 关注的是存储细节,可以定义具体存储类型、存储挂载使用的详细参数等。
使用时应用层会声明一个对存储的需求(PVC),而 K8s 会通过最佳匹配的方式选择一个满足 PVC 需求的 PV,并与之绑定。所以从职责上 PVC 是应用所需要的存储对象,属于应用作用域(和应用处于一个名词空间);PV 是存储平面的存储对象,属于整个存储域(不属于某个名词空间);
下面给出 PVC、PV 的一些属性:
PVC 定义的模板如下:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: disk-ssd-web-0
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
storageClassName: alicloud-disk-available
volumeMode: Filesystem
PVC 定义的存储接口包括:存储的读写模式、资源容量、卷模式等;主要参数说明如下:
accessModes:存储卷的访问模式,支持:ReadWriteOnce、ReadWriteMany、ReadOnlyMany 三种模式。
注意:这里定义的访问模式只是编排层面的声明,具体应用在读写存储文件的时候是否可读可写,需要具体的存储插件实现确定。
storage:定义此 PVC 对象期望提供的存储容量,同样此处的数据大小也只是编排声明的值,具体存储容量要看底层存储服务类型。
volumeMode:表示存储卷挂载模式,支持 FileSystem、Block 两种模式;
FileSystem:将数据卷挂载成文件系统的方式供应用使用;
Block:将数据卷挂载成块设备的形式供应用使用。
下面为云盘数据卷 PV 对象的编排示例:
apiVersion: v1
kind: PersistentVolume
metadata:
labels:
failure-domain.beta.kubernetes.io/region: cn-shenzhen
failure-domain.beta.kubernetes.io/zone: cn-shenzhen-e
name: d-wz9g2j5qbo37r2lamkg4
spec:
accessModes:
- ReadWriteOnce
capacity:
storage: 30Gi
flexVolume:
driver: alicloud/disk
fsType: ext4
options:
VolumeId: d-wz9g2j5qbo37r2lamkg4
persistentVolumeReclaimPolicy: Delete
storageClassName: alicloud-disk-available
volumeMode: Filesystem
PVC 只有绑定了 PV 之后才能被 Pod 使用,而 PVC 绑定 PV 的过程即是消费 PV 的过程,这个过程是有一定规则的,下面规则都满足的 PV 才能被 PVC 绑定:
满足上述所有需要的 PV 才可以被 PVC 绑定。
如果同时有多个 PV 满足需求,则需要从 PV 中选择一个更合适的进行绑定;通常选择容量最小的,如果容量最小的也有多个,则随机选择。
如果没有满足上述需求的 PV 存储,则 PVC 会处于 Pending 状态,等待有合适的 PV 出现了再进行绑定。
从上面的讨论我们了解到,PVC 是针对应用服务对存储的二次抽象,具有简洁的存储定义接口。而 PV 是具有繁琐存储细节的存储抽象,一般有专门的集群管理人员定义、维护。
根据 PV 的创建方式可以将存储卷分为动态存储和静态存储卷:
一般先由集群管理员分析集群中存储需求,并预先分配一些存储介质,同时创建对应的 PV 对象,创建好的 PV 对象等待 PVC 来消费。如果负载中定义了 PVC 需求,K8s 会通过相关规则实现 PVC 和匹配的 PV 进行绑定,这样就实现了应用对存储服务的访问能力。
由集群管理员配置好后端的存储池,并创建相应的模板(storageclass),等到有 PVC 需要消费 PV 的时候,根据 PVC 定义的需求,并参考 storageclass 的存储细节,由 Provisioner 插件动态创建一个 PV。
两种卷的比较:
提供动态存储卷的优势:
当用户声明一个 PVC 时,如果在 PVC 中添加了 StorageClassName 字段,其意图为:当 PVC 在集群中找不到匹配的 PV 时,会根据 StorageClassName 的定义触发相应的 Provisioner 插件创建合适的 PV 供绑定,即创建动态数据卷;动态数据卷时由 Provisioner 插件创建的,并通过 StorageClassName 与 PVC 进行关联。
StorageClass 可译为存储类,表示为一个创建 PV 存储卷的模板;在 PVC 触发自动创建PV的过程中,即使用 StorageClass 对象中的内容进行创建。其内容包括:目标 Provisioner 名字,创建 PV 的详细参数,回收模式等配置。
StorageClasss 模板定义如下:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: alicloud-disk-topology
parameters:
type: cloud_ssd
provisioner: diskplugin.csi.alibabacloud.com
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
用户创建一个 PVC 声明时,会在集群寻找合适的 PV 进行绑定,如果没有合适的 PV 与之绑定,则触发下面流程:
某种存储(阿里云云盘)在挂载属性上有所限制,只能将相同可用区的数据卷和 Node 节点进行挂载,不在同一个可用区不可以挂载。这种类型的存储卷通常遇到如下问题:
StorageClass 中的 volumeBindingMode 字段正是用来解决此问题,如果将 volumeBindingMode 配置为 WaitForFirstConsumer 值,则表示 Provisioner 在收到 PVC Pending 的时候不会立即进行数据卷创建,而是等待这个 PVC 被 Pod 消费的时候才执行创建流程。
其实现原理是:
本文系作者在时代Java发表,未经许可,不得转载。
如有侵权,请联系nowjava@qq.com删除。