kubernetes存储

存储

在Kubernetes中,pod本身有生命周期,其应用容器及生成的数据自身均无法独立于该生命周期之外持久存在,当容器被销毁时,容器中保存的数据也会被清除。并且同一pod中的容器可共享PID、Network、IPC和UTS名称空间,但mount和user名称空间却各自独立,因而跨容器的进程彼此间默认无法基于共享的存储空间交换文件或数据。因此,借助特定的存储机制甚至是独立于pod生命周期的存储设备完成数据持久化也是必然之需。为了解决这个问题,Kubernetes引入了存储卷(Volume)的概念,允许容器将数据持久地存储在容器自身文件系统之外的存储空间中。

以Docker为代表的容器运行时通常都支持配置容器使用存储卷将数据持久存储于容器自身文件系统之外的存储空间中,这些存储空间可来自宿主机文件系统或网络存储系统。Kubernetes也支持类似的存储卷功能以实现短生命周期的容器应用数据的持久化,kubernetes通过Volume实现同一个Pod中不同容器之间的数据共享以及数据的持久化存储。

Kubernetes的Volume是绑定于Pod对象而非单个容器级别的,所以同一个pod中容器之间的数据是共享的。Volume的生命容器不与Pod中单个容器的生命周期相关,当容器终止或者重启时,Volume中的数据也不会丢失。简单来说,存储卷是定义在Pod资源之上可被其内部的所有容器挂载的共享目录,该目录关联至宿主机或某外部的存储设备之上的存储空间,可由Pod内的多个容器同时挂载使用。Pod存储卷独立于容器自身的文件系统,因而也独立于容器的生命周期,它存储的数据可于容器重启或重建后继续使用。
在这里插入图片描述
kubernetes的Volume支持多种类型,查看支持的存储

kubectl explain pods.spec.volume

pod使用volume步骤:
1、在Pod上定义存储卷,并关联至目标存储服务上
2、在需要用到存储卷的容器上,挂载其所属Pod的存储卷

volume的资源清单详解:

spec:
  volumes:
  - name <string> 			#存储卷名称标识,仅可使用DNS标签格式的字符,在当前Pod中必须唯一
    VOL_TYPE <Object> 		#存储卷插件及具体的目标存储供给方的相关配置
  containers:
  - name: ...
    image: ...
    volumeMounts:
    - name <string> 		#要挂载的存储卷的名称,必须匹配存储卷列表中的某项定义
      mountPatch <string> 	#容器文件系统上的挂载点路径
      readOnly <boolean> 	#是否挂载为只读模式,默认为"否"
      subPath <string> 		#挂载存储卷上的一个子目录至指定的挂载点
      subPathExpr <string> 	#挂载由指定的模式匹配到的存储卷的文件或目录至挂载点
      mountPropagation <string> #挂载卷的传播模式

emptyDir

emptyDir存储卷可以理解为pod对象上的一个临时目录,主要用于Pod中容器之间的数据共享。在pod对象启动时即被创建,而在pod对象被移除时一并被移除,生命周期和pod完全一致。因此emptyDir存储卷只能用于某些特殊场景中,例如同一pod内的多个容器间的文件共享或作为容器数据的临时存储目录用于数据缓存系统等。

通过一个容器之间文件共享的案例来使用一下emptyDir。在一个Pod中准备两个容器busybox1和busybox2,然后声明一个Volume分别挂在到两个容器的目录中,busybox1容器负责向数据卷写入数据,busybox2容器从文件中读取数据,并将数据内容输出到控制台(可以通过日志查询方式读取输出到控制台的文本)。emptyDir存储卷名称为 txt-volume,例子中的两个容器虽然数据卷地址不同(一个是/write_dir,一个是/read_dir),但它们都映射到同一个空目录,所以本质上仍在同一个文件夹内操作。
创建一个 volume-emptydir.yaml

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: volume-emptydir
  namespace: dev
spec:
  containers:
  - name: busybox1
    image: busybox
    imagePullPolicy: IfNotPresent
    command: ['sh','-c']
    args: ['echo "hello k8s" > /write_dir/test.txt; sleep 6000']  # 初始命令,写入内容到指定文件
    volumeMounts:    # 容器内的数据卷地址为/write_dir,它引用的存储卷为txt-volume
    - name: txt-volume
      mountPath: /write_dir
  - name: busybox2
    image: busybox
    command: ["/bin/sh","-c","cat /read_dir/test.txt; sleep 6000"] # 初始命令,读取指定文件中内容
    volumeMounts: # 容器内的数据卷地址为/read_dir,它引用的存储卷为txt-volume
    - name: txt-volume
      mountPath: /read_dir
  volumes:    # 声明volume, name为logs-volume,类型为emptyDir
  - name: txt-volume
    emptyDir: {}
EOF

查看 pod

[root@master ~]# kubectl get pods volume-emptydir -n dev -o wide
NAME              READY   STATUS    RESTARTS   AGE   IP            NODE    NOMINATED NODE   READINESS GATES
volume-emptydir   2/2     Running   0          68s   10.244.2.19   node1   <none>           <none>

查看 volume-emptydir pod 中两个容器文件的内容

[root@master ~]# kubectl exec -it volume-emptydir -n dev -c busybox1 -- cat /write_dir/test.txt
hello k8s
[root@master ~]# kubectl exec -it volume-emptydir -n dev -c busybox2 -- cat /read_dir/test.txt
hello k8s

通过kubectl logs命令查看指定容器的标准输出

[root@master ~]# kubectl logs volume-emptydir -n dev -c busybox2
hello k8s

K8S 会在当前的 Node 自动创建一个目录来实际承载这个卷,目录的位置在 Node 的 /var/lib/kubelet/pods 路径下。要查看这个目录中的内容,需要先找到 Pod Id 和对应的 Node,然后登录到这个 Node,就能找到这个目录了。查找方法如下所示:

[root@master ~]# kubectl get pod volume-emptydir -n dev -o custom-columns=PodName:.metadata.name,PodUID:.metadata.uid,PodNode:.spec.nodeName
PodName           PodUID                                 PodNode
volume-emptydir   80a1a999-1b30-4a62-a925-9b7a40bb86ee   node1

登录到node1节点,在node1节点上查看临时存储的文件位置及内容

[root@node1 ~]# cat /var/lib/kubelet/pods/80a1a999-1b30-4a62-a925-9b7a40bb86ee/volumes/kubernetes.io~empty-dir/txt-volume/test.txt 
hello k8s

emptyDir Volume限制

应用程序可以控制自己使用的存储空间,如果不对 emptyDir Volume 做一些限制,也是有很大的风险会使用过多的磁盘空间。设置 emptyDir.sizeLimit,比如设置为 100Mi。

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: volume-emptydir
spec:
  containers:
  - name: count
    image: busybox
    args: [/bin/sh, -c, 'while true; do dd if=/dev/zero of=/cache/$(date "+%s").out count=1 bs=5MB; sleep 1; done']
    volumeMounts:
    - mountPath: /cache
      name: cache-volume
  volumes:
  - name: cache-volume
    emptyDir:
      sizeLimit: 100Mi
EOF

稍等几分钟,然后查询 Pod 的事件,可以看到 kubelet 发现 emptyDir volume 超出了 100Mi 的限制,然后就把 Pod 关掉了。
在这里插入图片描述

内存的临时目录

默认情况下,emptyDir在主机硬盘上创建一个临时目录,还可以将emptyDir.medium设置为Memory来生成一个基于内存的临时目录,其速度会比硬盘快,但机器重启之后数据就会丢失。定义临时目录的方式如下:

volumes:
  - name: data
    emptyDir:
      medium: Memory	
      sizeLimit: 10Mi	#限制最大使用

注意pod内容器重启不会导致数据卷丢失,只有当pod被删除重新拉起,此时临时卷就没了

hostPath

emptyDir中数据不会被持久化,它会随着Pod的结束而销毁,如果想将数据持久化到主机中,可以选择hostPath。hostPath主要用于把宿主机上指定的目录映射到pod中的容器上,使pod与该宿主机共享存储,数据会永久保存在宿主机上,不会随着pod的结束而销毁。如果pod销毁后,又在这台机器上重建,则可以读取原来的内容,但如果机器出现故障或者pod被调度到其他机器上,就无法读取原来的内容了。这种方式特别适合DaemonSet控制器,运行在DaemonSet控制器下的pod可直接操作和使用主机上的文件,如日志或监控类应用可以读取主机指定目录下的日志或写入信息等。

接下来,我们创建一个pod向数据卷写入数据,它会向/data/test.txt文件写入hello kubernetes内容,容器内的数据卷为/data,它引用的存储卷名为txthostpath,存储卷类型为hostPath,指定宿主机目录为/data/hostPath

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: volumehostpath
  namespace: dev
spec:
  containers:
  - name: containerhostpath
    image: busybox
    imagePullPolicy: IfNotPresent
    command: ['sh','-c']
    args: ['echo "hello kubernetes" > /data/test.txt; sleep 3600']
    volumeMounts:
    - name: txthostpath
      mountPath: /data      #容器中的/data目录会映射到宿主机上的/data/hostPath目录
  volumes:
  - name: txthostpath
    hostPath:
      path: /data/hostPath
      type: DirectoryOrCreate 
          # 默认不写
          # DirectoryOrCreate 目录存在就使用,不存在就先创建0755权限的空目录,属主属组同为kubelet,然后再使用
          # Directory   	  目录必须存在
          # FileOrCreate      文件存在就使用,不存在就先创建0644权限的空文件,属主和属组同为kubelet,然后再使用
          # File              文件必须存在 
          # Socket            unix套接字必须存在
          # CharDevice        字符设备必须存在
          # BlockDevice       块设备必须存在 
EOF

查看这个pod所在宿主机节点,NODE属性为node1,表示这个pod被调度到node1节点上

[root@master ~]# kubectl get pod volumehostpath -n dev -o wide
NAME             READY   STATUS    RESTARTS   AGE   IP            NODE    NOMINATED NODE   READINESS GATES
volumehostpath   1/1     Running   0          59s   10.244.2.26   node1   <none>           <none>

在node1这台机器上,可以发现容器中写入文件/data/test.txt中的内容已成功写入/data/hostPath/test.txt

[root@master ~]# ssh node1 cat /data/hostPath/test.txt
hello kubernetes

修改宿主机文件内容,容器内文件内容也会被修改,虽然容器读取的是/data/test.txt文件,但本质读取的还是宿主机/data/hostPath/test.txt 文件

[root@master ~]# ssh node1 'echo "HELLO KUBERNETES" > /data/hostPath/test.txt'
[root@master ~]# kubectl exec -it volumehostpath -n dev -c containerhostpath -- cat /data/test.txt
HELLO KUBERNETES

删除pod,发现本地文件并没有随着pod的删除而删除

[root@master ~]# kubectl delete pod volumehostpath-n dev
pod "volumehostpath" deleted
[root@master ~]# ssh node1 'cat /data/hostPath/test.txt'
HELLO KUBERNETES

解下来,我们重新创建一个新的pod,把这个pod调度到node2节点上,并挂载存储卷

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: volumehostpath
  namespace: dev
spec:
  containers:
  - name: containerhostpath
    image: busybox
    command:
      - /bin/sh
      - -c
      - |
        while true; do
          sleep 3600
        done
    volumeMounts:
    - name: txthostpath
      mountPath: /data         #容器中的/data目录会映射到宿主机上的/data/hostPath目录
  volumes:
  - name: txthostpath
    hostPath:
      path: /data/hostPath
      type: DirectoryOrCreate 
  nodeName: node2
EOF

查看pod内文件内容,发现一旦Node节点出现故障或者pod被调度到其它机器上,将无法读取原来的数据

[root@master ~]# kubectl get pod -n dev -owide 
NAME             READY   STATUS    RESTARTS   AGE   IP           NODE    NOMINATED NODE   READINESS GATES
volumehostpath   1/1     Running   0          78s   10.244.1.9   node2   <none>           <none>
[root@master ~]# kubectl exec -it volumehostpath -n dev -c containerhostpath -- cat /data/test.txt
cat: can't open '/data/test.txt': No such file or directory

那么接下来我们将创建一个新的pod,把这个pod定向调度到node1节点

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: hostpathnode1
  namespace: dev
spec:
  containers:
  - name: containerhostpath
    image: busybox
    command:
      - /bin/sh
      - -c
      - |
        while true; do
          sleep 3600
        done
    volumeMounts:
    - name: txthostpath
      mountPath: /data         #容器中的/data目录会映射到宿主机上的/data/hostPath目录
  volumes:
  - name: txthostpath
    hostPath:
      path: /data/hostPath
      type: DirectoryOrCreate 
  nodeName: node1
EOF

查看pod可以发现,pod销毁又在这台机器上重建,则可以读取到原来的内容

[root@master ~]# kubectl get pod -n dev 
NAME            READY   STATUS    RESTARTS   AGE
hostpathnode1   1/1     Running   0          95s
[root@master ~]# kubectl exec -it hostpathnode1 -n dev -c containerhostpath -- cat /data/test.txt
HELLO KUBERNETES

解决容器时间问题

在容器环境下,除了业务镜像外,我们有很多情况都是使用的官方镜像或第三方镜像,而这些镜像一般都不是国人制作。因此使用这些镜像的时候,自然会有一个问题,即容器镜像的默认时区不正确。简而言之,在容器环境中需要处理时间(时区)问题的原因一般有:
1、时间不对,和正确的(例如北京时间)有偏差
2、时区不对,镜像默认时区和当前时区不符合
3、某些特殊业务需要临时修改时间。例如电商秒杀业务,将时间设置超前或滞后,在内部测试业务的时间控制功能

下面我们运行一个pod容器,查看pod时间

[root@master1 ~]# kubectl run -it --image busybox test --restart=Never --rm /bin/sh
/ # date +"%Y-%m-%d %H:%M.%S" 
2024-07-06 02:54.28

我们再来看宿主机节点时间,发现pod容器的时间与宿主机时间不同

[root@master1 ~]# date +"%Y-%m-%d %H:%M.%S" 
2024-07-06 10:54.39

那么我们是否可以在容器中通过date修改日期时间呢?答案是不可以的,这是因为容器的隔离是基于Linux的Capability机制实现的,可以通过给容器添加--privileged--cap-add SYS_TIME来实现目的,但并不推荐,因为这样会直接影响到容器所在主机的时间。Linux内核中将timekeeper设置为全局变量,所以只要去修改系统时间,这个影响就是内核层面的,所以在docker的实现中默认是禁止在容器内修改时间的,因为容器与虚拟化的区别就在于是否共享内核,这就意味着一旦在容器中修改了时间,这个影响就是全局性的

在Dockerfile中我么可以通过添加时区来解决这个问题,这种做法对于自制的业务镜像来说很方便,也很容易操作,毕竟只需要在通过Dockerfile制作业务镜像添加此内容即可

# Set timezone
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
		 && echo "Asia/Shanghai" > /etc/timezone

在kubernetes中,在定义pod上层控制器的时候,添加一个用于挂载时区的卷,挂载宿主机的时区文件,下面以pod进行演示

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: busybox
spec:
  containers:
  - name: busybox
    image: busybox
    command: ["sleep", "3600"]
    volumeMounts:
      - name: timezone
        mountPath: /etc/localtime  #挂载到容器这个路径
  volumes:
    - name: timezone
      hostPath:     #主机的路径
        path: /usr/share/zoneinfo/Asia/Shanghai
EOF

查看创建的pod

[root@master1 ~]# kubectl get pod
NAME      READY   STATUS    RESTARTS   AGE
busybox   1/1     Running   0          11s

查看pod时间和宿主机时间,发现pod时间与当前环境时间一致

[root@master1 ~]# kubectl exec busybox -- date +"%Y-%m-%d %H:%M.%S" 
2024-07-06 11:03.23
[root@master1 ~]# date +"%Y-%m-%d %H:%M.%S" 
2024-07-06 11:03.49

网络存储卷NFS

如果使用emptyDir把数据存储到pod内,一旦节点出现故障删除,数据也随之删除。如果使用hostPath存储数据,一旦Node节点出现故障或者pod被调度到其他机器上,将无法读取原来的数据。此时需要使用网络存储卷跨节点持久存储,比较常用的用NFS、CIFS。由于kubernetes是分布式容器集群,网络存储解决了多个pod和多个node之间数据共享存储的问题,网络存储还能够满足数据持久化的要求,将这些数据永久保存。

本文章使用的是网络文件系统NFS,由于NFS是第三方系统,需要进行下载安装。NFS是一个网络文件存储系统,可以搭建一台NFS服务器,然后将Pod中的存储直接连接到NFS系统上,这样无论Pod在节点上怎么转移,只要Node跟NFS的对接没问题,数据就可以成功访问。

1、首先要准备 NFS 的服务器,这里为了简单,直接使用master节点做nfs服务器
安装NFS服务端

yum -y install nfs-utils rpcbind

创建一个目录,将其作为NFS共享目录,以便客户端访问共享目录中的内容

mkdir -p /data/k8snfs && chmod 755 /data/k8snfs

将共享目录以读写权限暴露给 192.168.139 .0/24 网段中的所有主机

[root@master ~]# vim /etc/exports
/data/k8snfs  192.168.139.0/24(rw,sync,insecure,no_subtree_check,no_root_squash)

#第一个参数是NFS共享目录的路径
#第二个参数是允许共享目录的网段,* 表示不限制
#小括号中的参数为权限设置,rw表示允许读写访问,sync表示所有数据在请求时写入共享目录,insecure表示NFS通过1024以上的端口进行发送,no_root_squash表示root用户对根目录具有完全的管理访问权限,no_subtree_check表示不检查父目录的权限。

启动nfs服务

service rpcbind restart			
service nfs restart	

检查服务端是否正常加载了/etc/exports的配置

[root@master ~]# showmount -e localhost
Export list for localhost:
/data/k8snfs 192.168.139.0/24

2、每台需要使用NFS的Node都需要安装NFS客户端,这样的目的是为了node节点可以驱动nfs设备

yum -y install nfs-utils

检查是否能访问NFS服务器

[root@node01 ~]# showmount -e 192.168.139.101
Export list for 192.168.139.101:
/data/k8snfs 192.168.139.0/24

3、使用NFS,将NFS挂载到pod当中,NFS中的数据可以永久保存,可以被多个pod同时读写

cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: volume-nfs
  namespace: dev
spec:
  replicas: 3
  selector:
    matchLabels:
      example: examplenfs
  template:
    metadata:
      labels:
        example: examplenfs
    spec:
      containers:
      - name: busyboxmnfs
        image: busybox
        imagePullPolicy: IfNotPresent
        command: ['sh','-c']
        args: ['echo "the host is $(hostname)" >> /data/test.txt; sleep 3600']
        volumeMounts:
        - name: nfsdata
          mountPath: /data
      volumes:
      - name: nfsdata
        nfs:
          server: 192.168.139.101   #nfs服务器地址
          path: /data/k8snfs        #共享文件路径
EOF

查看pod

[root@master ~]# kubectl get -n dev pod -o wide
NAME                          READY   STATUS    RESTARTS   AGE    IP           NODE    NOMINATED NODE   READINESS GATES
volume-nfs-7d7749c8bd-2prn9   1/1     Running   0          101m   10.244.2.5   node2   <none>           <none>
volume-nfs-7d7749c8bd-j8dgp   1/1     Running   0          101m   10.244.2.4   node2   <none>           <none>
volume-nfs-7d7749c8bd-rqkkh   1/1     Running   0          101m   10.244.1.6   node1   <none>           <none>

查看任意pod文件内容

[root@master ~]# kubectl exec -it volume-nfs-7d7749c8bd-2prn9 -n dev  -- cat /data/test.txt
the host is volume-nfs-7d7749c8bd-rqkkh
the host is volume-nfs-7d7749c8bd-j8dgp
the host is volume-nfs-7d7749c8bd-2prn9

修改NFS服务器上的文件

echo 'hello kubernetes' > /data/k8snfs/test.txt 
```bash
pod共享宿主机文件内容,该控制器下的pod,都是直接引用NFS服务器上的文件。查看各个pod文件内容
```bash
[root@master ~]# kubectl exec -it volume-nfs-7d7749c8bd-2prn9 -n dev -- cat /data/test.txt
hello kubernetes
[root@master ~]# kubectl exec -it volume-nfs-7d7749c8bd-j8dgp -n dev -- cat /data/test.txt
hello kubernetes
[root@master ~]# kubectl exec -it volume-nfs-7d7749c8bd-rqkkh -n dev -- cat /data/test.txt
hello kubernetes

pod删除后,宿主机还会保存该数据

[root@master ~]# kubectl delete deployment -n dev volume-nfs
[root@master ~]# cat /data/k8snfs/test.txt 
hello kubernetes

持久存储卷

由于kubernetes支持的存储系统有很多,要求客户全都掌握显然不现实。为了能够屏蔽底层存储实现的细节,方便用户使用,kubernetes引入PVPVC两种资源对象。PV是全局的,不需要指定命名空间;而PVC则需要指定命名空间。

PV(PersistentVolume):持久存储卷,定义了kubernetes集群中可用的存储资源,是对底层共享存储的一种抽象,将共享存储定义为一种资源,它属于集群级别资源,不属于任何 Namespace,用户使用 PV 需要通过 PVC 申请。PV 由管理员进行创建和配置,其中包含存储资源实现的细节,它与底层具体的共享存储技术有关,并通过插件完成与共享存储的对接。
PVC(persistentVolumeClaim):是持久卷声明的意思,是用户对于存储需求的一种声明。换句话说,PVC其实就是用户向kubernetes系统发出的一种资源需求申请,它属于一个 Namespace 中的资源,可用于向 PV 申请存储资源。所以对于真正使用存储的用户不需要关心底层的存储实现细节,只需要直接使用 PVC 即可。PVC 和 Pod 比较类似,Pod 消耗的是 Node 节点资源,而 PVC 消耗的是 PV 存储资源,Pod 可以请求 CPU 和 Memory,而 PVC 可以请求特定的存储空间和访问模式。

普通VolumePod之间是一种静态绑定关系,也就是在定义pod时,同时要将pod所使用的Volume一并定义好,Volume是Pod的附属品。volume会随着pod创建而被创建,我们无法单独创建一个Volume,因为Volume不是一个独立的K8S资源对象。而Persistent Volume则是一个K8S资源对象,它是独立于Pod的,能单独创建。Persistent Volume 不与Pod发生直接关系,而是通过 Persistent Volume Claim(PVC) 来与Pod绑定关系。在定义Pod时,为Pod指定一个PVC,Pod在创建时会根据PVC要求,从现有集群的PV中,选择一个合适的PV绑定,或动态建立一个新的PV,再与其进行绑定。

StorageClass简称SC,PV和PVC都可以属于某个特定的SC
模拟名称空间:一个PVC可以绑定任何一个PV,StorageClass为了限制绑定,只能在同一SC下找PV。一个PVC只能够在自己所处的SC内找PV,类似名称空间
创建PV的模版:可以将某个存储服务与SC关联起来,并且将该存储服务的管理接口提供给SC,从而让SC能够在存储服务商CRUD存储单元。因此在同一个SC商声明PVC时,若无现存可匹配的PV,则SC能够调用管理接口直接创建出一个符合PVC声明需求的PV来,这种PV的提供机制,简称 Dynamic Provisioning

————————————————————————————————
|namespace                     |
|                              |
|    [pod1]     [pod2]         |
|      ↓          ↓            |
|   [volume1]  [volume2]       |
|      ↑          ↑            |
|      |         /             |
|      ↓        ↓              |
|    [pvc]    [pvc]    [pvc]   |
——————↑—————————↑————————↑——————
     /           \       |_ _ _ _ _ __
   /              \                   ↓
  ↓                ↓                  ↓
[pv]  [pv] |    | [pv] [pv] [pv]  | [pv] [pv] [pv]
           |    |                 |
static     |    | storageClass    | storageClass
———————————      ——————————————    ————————————————
                      ↑↑                ↑↑
                      ↑↑                ↑↑
                ————————————————  ——————————————————————
                 [NFS] [ISCSI]    [Ceph RDB] [Glusterfs]

在这里插入图片描述

PV 模板

PV是存储资源的抽象,下面是资源清单文件:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: 		# pv是全局的,不需要指定名称空间
spec:
  nfs: 			# 存储类型,底层实际存储的类型。kubernetes支持多种存储类型,每种存储类型的配置都有所差异
  capacity:  	# 存储能力,目前只支持存储空间的设置,不过未来可能会加入IOPS、吞吐量等指标的配置
    storage: 2Gi
  accessModes: 		 	# 访问模式,用于描述用户应用对存储资源的访问权限,需要注意的是底层不同的存储类型可能支持的访问模式不同
    #- ReadWriteOnce	# RWO:读写权限,但是只能被单个节点挂载 
    #- ReadOnlyMany		# ROX:只读权限,可以被多个节点挂载
    #- ReadWriteMany	# RWX:读写权限,可以被多个节点挂载
  storageClassName: 	# 存储类别,未设定类别的PV则只能与不请求任何类别的PVC进行绑定,具有特定类别的PV只能与请求了该类别的PVC进行绑定
  persistentVolumeReclaimPolicy: 	# 回收策略,当PV不再被使用了之后,对其的处理方式。注意底层不同的存储类型可能支持的回收策略不同
	#- Retain 	#保留,保留数据,需要管理员手工清理数据
	#- Recycle	#回收,清除 PV 中的数据,效果相当于执行 rm -rf /thevolume/*
	#- Delete 	#删除,与 PV 相连的后端存储完成 volume 的删除操作,当然这常见于云服务商的存储服务

PVC模板

创建完成了 PV,如果我们需要使用这个 PV 的话,就需要创建一个对应的 PVC 来和它进行绑定。如果有多个 PVstorageClassName相同,均满足PVC的申请,那么PVC和PV随机绑定。如果想绑定指定的PV,需要设置selector,明确去绑定具有该标签的PV

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-hostpath
  namespace :
spec:
  storageClassName: 	# 必须要和要绑定的pv名一致
  accessModes:
  - ReadWriteOnce
  resources:			#存储资源声明
    requests:			#请求的存储容量,这里的值要小于等于要绑定的pv中设置的capacity值 
      storage: 3Gi

PVC 准备好过后,接下来我们就可以来创建 Pod 了,该 Pod 使用我们声明的 PVC 作为存储卷

apiVersion: v1
kind: Pod
metadata:
  name: pv-hostpath-pod
spec:
  volumes:
  - name: pv-hostpath
    persistentVolumeClaim:			
      claimName: pvc-hostpath		#要关联测pvc名
  nodeSelector:						#如果是hostPath类型的存储,一般结合nodeSelector固定在一个节点上
    kubernetes.io/hostname: node1		# 要固定的节点名
  containers:
  - name: task-pv-container
    image: nginx
    ports:
    - containerPort: 80
    volumeMounts:
    - mountPath: "/usr/share/nginx/html"
      name: pv-hostpath

hostPath PV

创建一个 hostPath 类型的 PV,这里将测试的应用固定在node1节点。首先需要在该节点上创建一个 /data/k8s/hostpathpv 的目录,然后在该目录中创建一个 index.html 的文件。

mkdir -p /data/k8s/hostpathpv
echo 'Kubernetes hostpathpv storage' > /data/k8s/hostpathpv/index.html

创建一个 hostPath 类型的 PV 资源对象

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-hostpath
  labels:
    type: local
spec:
  storageClassName: hostpathpv
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteOnce
  hostPath:
    path: "/data/k8s/hostpathpv"
EOF

PV创建完成后,STATUS属性为Available,表示资源空闲,尚未被PVC申请使用

[root@node1 ~]# kubectl get pv pv-hostpath
NAME          CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS      CLAIM   STORAGECLASS   REASON   AGE
pv-hostpath   10Gi       RWO            Retain           Available           hostpathpv              18s

查看PV资源的详情

kubectl describe pv pv-hostpath

创建完成了 PV,如果我们需要使用这个 PV 的话,就需要创建一个对应的 PVC 来和他进行绑定

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-hostpath
  namespace: dev
spec:
  storageClassName: hostpathpv
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 3Gi
EOF

PVC创建完成后,STATUS属性为Bound,表示已成功绑定到符合PVC资源申请条件的PV上,VOLUME属性显示了绑定的PV的名称

[root@node1 ~]# kubectl get pvc pvc-hostpath -n dev 
NAME           STATUS   VOLUME        CAPACITY   ACCESS MODES   STORAGECLASS   AGE
pvc-hostpath   Bound    pv-hostpath   10Gi       RWO            hostpathpv     32s

再查看之前已创建的PV,发现其STATUS属性由之前的Available变为Bound

[root@node1 ~]# kubectl get pv pv-hostpath
NAME          CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM              STORAGECLASS   REASON   AGE
pv-hostpath   10Gi       RWO            Retain           Bound    dev/pvc-hostpath   hostpathpv              95s

PVC 创建好以后,接下来我们就可以创建 pod 了,该 pod 使用上面我们声明的 PVC 作为存储卷

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: pv-hostpath-pod
  namespace: dev
spec:
  volumes:
  - name: pv-hostpath
    persistentVolumeClaim:
      claimName: pvc-hostpath
  nodeSelector:
    kubernetes.io/hostname: node1	#由于创建的 PV 真正的存储在节点 node1 上面,所以我们这里必须把 Pod 固定在这个节点下面
  containers:
  - name: task-pv-container
    image: nginx
    ports:
    - containerPort: 80
    volumeMounts:
    - mountPath: "/usr/share/nginx/html"
      name: pv-hostpath
EOF

查看pod已经正常运行在node1节点

[root@node1 ~]# kubectl get pod pv-hostpath-pod -n dev -o wide
NAME              READY   STATUS    RESTARTS   AGE   IP               NODE    NOMINATED NODE   READINESS GATES
pv-hostpath-pod   1/1     Running   0          30s   10.244.166.187   node1   <none>           <none>

访问pod内容,输出的结果正是我们前面写到 hostPath 卷种的 index.html 文件中的内容

[root@node1 ~]# curl 10.244.166.187
Kubernetes hostpathpv storage

同样我们可以把 Pod 删除,然后再次重建再测试一次,可以发现内容还是我们在 hostPath 中设置的内容

Local PV

使用 hostPath PV 有一个局限性就是,我们的 Pod 不能随便漂移,因为一旦漂移到其他节点上去了宿主机上面就没有对应的数据了,所以我们在使用 hostPath 的时候都会搭配 nodeSelector 来进行使用,将其固定到一个节点上。使用 hostPath 明显也有一些好处,因为 PV 直接使用的是本地磁盘,尤其是 SSD 盘,它的读写性能相比于大多数远程存储来说,要好得多,所以对于一些对磁盘 IO 要求比较高的应用,比如 etcd 就非常实用了。不过呢,相比于正常的 PV 来说,使用了 hostPath 的这些节点一旦宕机数据就可能丢失,所以这就要求使用 hostPath 的应用必须具备数据备份和恢复的能力,允许你把这些数据定时备份在其他位置。

所以在 hostPath 的基础上,Kubernetes 依靠 PV、PVC 实现了一个新的特性,这个特性的名字叫作Local Persistent Volume,也就是我们说的 Local PV。其实Local PV实现的功能就非常类似于 hostPath 加上 nodeAffinity(节点亲和性 )。比如一个 Pod 可以声明使用类型为 Local 的 PV,而这个 PV 其实就是一个 hostPath 类型的 Volume。如果这个 hostPath 对应的目录已经在节点 A 上被事先创建好了,那么我只需要再给这个 Pod 加上一个 nodeAffinity=nodeA,不就可以使用这个 Volume 了吗?理论上确实是可行的,但是事实上,我们绝不应该把一个宿主机上的目录当作 PV 来使用,因为本地目录的存储行为是完全不可控,它所在的磁盘随时都可能被应用写满,甚至造成整个宿主机宕机。所以一般来说 Local PV 对应的存储介质是一块额外挂载在宿主机的磁盘或者块设备,我们可以认为就是一个 PV 一块盘

另外一个 Local PV 和普通的 PV 有一个很大的不同在于 Local PV 可以保证 Pod 始终能够被正确地调度到它所请求的 Local PV 所在的节点上,对于普通的 PV 来说,Kubernetes 都是先调度 Pod 到某个节点上,然后再持久化节点上的 Volume 目录,进而完成 Volume 目录与容器的绑定挂载,但是对于 Local PV 来说,节点上可供使用的磁盘必须是提前准备好的,因为它们在不同节点上的挂载情况可能完全不同,甚至有的节点可以没这种磁盘,所以这时候,调度器就必须能够知道所有节点与 Local PV 对应的磁盘的关联关系,然后根据这个信息来调度 Pod,实际上就是在调度的时候考虑 Volume 的分布。

接下来我们来演试下 Local PV 的使用,当然按照上面的分析我们应该给宿主机挂载并格式化一个可用的磁盘,我们这里就暂时将 node1 节点上的 /data/k8s/localpv 这个目录看成是挂载的一个独立的磁盘。现在我们来声明一个 Local PV 类型的 PV,如下所示:

[root@node1 ~]# mkdir -p /data/k8s/localpv
[root@node1 ~]# cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-local
spec:
  capacity:
    storage: 5Gi
  volumeMode: Filesystem
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Delete
  storageClassName: local-storage
  local:       #local 字段,表明它是一个 Local PV
    path: /data/k8s/localpv  # node1节点上的目录
  #节点亲和性 nodeAffinity 字段指定 node1 这个节点。这样调度器在调度 Pod 的时候,就能够知道一个 PV 与节点的对应关系,从而做出正确的选择
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - node1
EOF

PV 创建后,进入了 Available(可用)状态

[root@master ~]# kubectl get pv pv-local
NAME       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS      CLAIM   STORAGECLASS    REASON   AGE
pv-local   5Gi        RWO            Delete           Available           local-storage            22s	

创建一个 PVC 和 PV 进行绑定

cat <<EOF | kubectl apply -f -
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: pvc-local
  namespace: dev
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  storageClassName: local-storage
EOF

可以看到现在 PVC 和 PV 已经处于 Bound 绑定状态了

[root@master ~]# kubectl get pvc -n dev pvc-local
NAME        STATUS   VOLUME     CAPACITY   ACCESS MODES   STORAGECLASS    AGE
pvc-local   Bound    pv-local   5Gi        RWO            local-storage   17s

比如现在我们的 Pod 声明使用这个 pvc-local,并且我们也明确规定,这个 Pod 只能运行在 node2 这个节点上,如果按照上面我们这里的操作,这个 pvc-local 是不是就和我们这里的 pv-local 这个 Local PV 绑定在一起了,但是这个 PV 的存储卷又在 node1 这个节点上,显然就会出现冲突了,那么这个 Pod 的调度肯定就会失败了

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: pv-local-pod
  namespace: dev
spec:
  volumes:
  - name: example-pv-local
    persistentVolumeClaim:
      claimName: pvc-local
  containers:
  - name: example-pv-local
    image: nginx
    ports:
    - containerPort: 80
    volumeMounts:
    - mountPath: /usr/share/nginx/html
      name: example-pv-local
  nodeSelector:
    kubernetes.io/hostname: node2
EOF

查看pod状态发现为pending

[root@master ~]# kubectl get pod -n dev -owide
pv-local-pod             0/1     Pending            0          104s   <none>           <none>   <none>           <none>
[root@master ~]# kubectl describe pod -n dev pv-local-pod 
Events:
  Type     Reason            Age                 From               Message
  ----     ------            ----                ----               -------
  Warning  FailedScheduling  3s (x4 over 2m12s)  default-scheduler  0/3 nodes are available: 1 node(s) didn't match Pod's node affinity/selector, 1 node(s) had taint {node-role.kubernetes.io/master: }, that the pod didn't tolerate, 1 node(s) had volume node affinity conflict.

所以我们在使用 Local PV 的时候,必须想办法延迟这个“绑定”操作。我们可以通过创建 StorageClass 来指定这个动作,在 StorageClass 种有一个 volumeBindingMode=WaitForFirstConsumer 的属性,就是告诉 Kubernetes 在发现这个 StorageClass 关联的 PVC 与 PV 可以绑定在一起,但不要现在就立刻执行绑定操作(即设置 PVC 的 VolumeName 字段),而是要等到第一个声明使用该 PVC 的 Pod 出现在调度器之后,调度器再综合考虑所有的调度规则,当然也包括每个 PV 所在的节点位置,来统一决定,这个 Pod 声明的 PVC,到底应该跟哪个 PV 进行绑定。通过这个延迟绑定机制,原本实时发生的 PVC 和 PV 的绑定过程,就被延迟到了 Pod 第一次调度的时候在调度器中进行,从而保证了这个绑定结果不会影响 Pod 的正常调度。

所以我们需要创建对应的 StorageClass 对象

cat <<EOF | kubectl apply -f -
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: local-storage   #StorageClass 的名字
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
EOF

现在我们删除对应的pod、pvc和pv对象,重新创建

kubectl delete pod pv-local-pod -n dev
kubectl delete pvc -n dev pvc-local
kubectl delete pv pv-local

现在我们重新重新创建PV和PVC对象

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-local
spec:
  capacity:
    storage: 5Gi
  volumeMode: Filesystem
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Delete
  storageClassName: local-storage
  local:
    path: /data/k8s/localpv
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - node1
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: pvc-local
  namespace: dev
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  storageClassName: local-storage
EOF

查看这个 PVC 发现处于 Pending 状态,也就是等待绑定的状态,这就是因为上面我们配置的是延迟绑定,需要在真正的 Pod 使用的时候才会来做绑定

[root@master ~]# kubectl get pv
NAME       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS      CLAIM   STORAGECLASS    REASON   AGE
pv-local   5Gi        RWO            Delete           Available           local-storage            5s
[root@master ~]# kubectl get pvc -n dev
NAME        STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS    AGE
pvc-local   Pending                                      local-storage   19s

创建这个 Pod

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: pv-local-pod
  namespace: dev
spec:
  volumes:
  - name: example-pv-local
    persistentVolumeClaim:
      claimName: pvc-local
  containers:
  - name: example-pv-local
    image: nginx
    ports:
    - containerPort: 80
    volumeMounts:
    - mountPath: /usr/share/nginx/html
      name: example-pv-local
EOF

创建完成后我们这个时候去查看前面我们声明的 PVC,会立刻变成 Bound 状态,与前面定义的 PV 绑定在了一起:

[root@master ~]# kubectl get pvc -n dev
NAME        STATUS   VOLUME     CAPACITY   ACCESS MODES   STORAGECLASS    AGE
pvc-local   Bound    pv-local   5Gi        RWO            local-storage   2m18s

这时候,我们可以尝试在这个 Pod 的 Volume 目录里,创建一个测试文件

kubectl exec -it pv-local-pod -n dev -- sh -c 'echo "Hello from Kubernetes local pv storage" > /usr/share/nginx/html/test.txt'

然后查看node1节点 /data/k8s/localpv 目录下的内容,你就可以看到刚刚创建的这个文件

[root@master ~]# ssh node1 cat /data/k8s/localpv/test.txt 
Hello from Kubernetes local pv storage

如果重新创建这个 Pod 的话,就会发现,我们之前创建的测试文件,依然被保存在这个持久化 Volume 当中,基于本地存储的 Volume 是完全可以提供容器持久化存储功能的,对于 StatefulSet 这样的有状态的资源对象,也完全可以通过声明 Local 类型的 PV 和 PVC,来管理应用的存储状态。

PV的生命周期(解绑与回收)

PV和PVC只能一对一绑定,不能多对一。如果PVC已经绑定到PV上,那么在创建一个新的PVC将无法申请到合适的PV资源,需要再创建一个新的PV资源,或者让之前的PVC和PV资源解除绑定。PV和PVC之间的相互作用遵循以下生命周期:ProvisioningBindingUsingReleasingRecycling
在这里插入图片描述
资源供应 (Provisioning):Kubernetes支持两种资源的供应模式:静态模式(Static)动态模式(Dynamic),资源供应的结果就是创建好的PV。

  • 静态模式:集群管理员手工创建许多PV,在定义PV时需要将后端存储的特性进行设置
    在这里插入图片描述
  • 动态模式:集群管理员无须手工创建PV,而是通过StorageClass的设置对后端存储进行描述,标记为某种类型。此时要求PVC对存储的类型进行声明,系统将自动完成PV的创建及与PVC的绑定。PVC可以声明Class为"",说明该PVC禁止使用动态模式。
    在这里插入图片描述
    资源绑定 (Binding):用户创建完PVC后,kubernetes负责根据PVC的声明在已存在的PV中选择一个满足条件的PV。一旦找到,就将该PV与用户定义的PVC进行绑定,然后用户的应用就可以使用这个PVC了。如果系统中没有满足PVC要求的PV,PVC则会无限期处于Pending状态,直到等到系统管理员创建了一个符合其要求的PV。PV一旦绑定到某个PVC上,就会被这个PVC独占,不能再与其他PVC进行绑定了。在这种情况下,当PVC申请的存储空间比PV的少时,整个PV的空间都能够为PVC所用,可能会造成资源的浪费。如果资源供应使用的是动态模式,则系统在PVC找到合适的StorageClass后,将会自动创建PV并完成PVC的绑定

资源使用 (Using):pod使用Volume的定义,将PVC挂载到容器内的某个路径进行使用

资源释放 (Releasing):当用户对存储资源使用完毕后,用户可以删除PVC,与该PVC绑定的PV将会被标记为Released(已释放),但还不能立刻与其它PVC进行绑定。通过之前PVC写入的数据可能还留在存储设备上,只有在清除之后该PV才能继续使用

资源回收 (Reclaiming):对于PV,管理员可以设定回收策略(Reclaim Policy)用于设置与之绑定的PVC释放资源之后,对于遗留数据如何处理。只有PV的存储空间完成回收,才能供新的PVC绑定和使用。

资源回收策略
当PV不再被使用了之后,kubernetes根据PV设置的回收策略进行资源的回收,用于解决绑定的PVC释放资源之后如何处理遗留数据的问题。只有PV的存储空间完成回收,才能供新的PVC绑定和使用。PV可以设置三种回收策略:保留(Retain)回收(Recycle)删除(Delete)

  • 保留策略:意味着在删除PVC之后、kubernetes系统不会自动删除PV,而仅仅是将它置于"释放(releases)状态"。不过,此种状态的PV尚且不能被其他PVC申请所绑定、因为此前的申请生成的数据仍然存在,需要由管理员手动决定其后续处理方案
  • 删除策略:将删除pv和外部关联的存储资源,需要插件支持。
  • 回收策略:清除 PV 中的数据,效果相当于执行 rm -rf /thevolume/*。目前只有NFS和HostPath类型卷支持回收策略,AWS EBS,GCE PD,Azure Disk和Cinder支持删除(Delete)策略。

一个 PV 的生命周期中,可能会处于4中不同的状态,可以通过命令kubectl get pv <pvname>进行查看

状态说明
Available(可用)表示可用状态,还未被任何 PVC 绑定
Bound(已绑定)表示 PV 已经被 PVC 绑定
Released(已释放)表示 PVC 被删除,但是资源还未被集群重新声明
Failed(失败)表示该 PV 的自动回收失败

删除pod时需要按以下顺序:删除使用这个 PV 的 Pod→删除 PVC→删除 PV

StorageClass

静态存储需要用户申请PVC时,保证容量和读写类型与预制PV的容量及读写类型完全匹配。但是在大规模的生产环境里,这其实是一个非常麻烦的工作,因为一个大规模的 Kubernetes 集群里很可能有成千上万个 PVC,这就意味着运维人员必须得事先创建出成千上万个 PV。更麻烦的是随着新的 PVC 不断被提交,运维人员就不得不继续添加新的、能满足条件的 PV,否则新的 Pod 就会因为 PVC 绑定不到 PV 而失败。在实际操作中,这几乎没办法靠人工做到。
为了更好地应对动态变化的环境,Kubernetes 提供了一套可以自动创建 PV 的机制,也称为 Dynamic Provisioning。在这种方式中,引入了一个名为 StorageClass 的概念,它的作用就是创建PV的模板。具体地说,StorageClass 对象会定义如下两个部分内容:PV 的属性(比如,存储类型、Volume 的大小等)、创建这种 PV 需要用到的存储插件(比如Ceph)。有了这样两个信息之后,Kubernetes 就能够根据用户提交的PVC的需求自动选择一个合适的 StorageClass,然后使用这个模板来自动创建一个新的PV,而无需管理员手动介入。这使得整个过程更加自动和灵活,特别适用于需要频繁创建和销毁容器的动态环境。

基于StorageClass的动态存储供应整体过程如下图所示:
在这里插入图片描述
A)集群管理员预先创建存储类(StorageClass);
B)用户创建使用存储类的持久化存储声明(PVC:PersistentVolumeClaim);
C)存储持久化声明通知系统,它需要一个持久化存储(PV: PersistentVolume);
D)系统读取存储类的信息;
E)系统基于存储类的信息,在后台自动创建PVC需要的PV;
F)用户创建一个使用PVC的Pod;
G)Pod中的应用通过PVC进行数据的持久化;
H)而PVC使用PV进行数据的最终持久化处理。

定义存储类

每个 StorageClass 都包含 provisionerparametersreclaimPolicy 字段, 这些字段会在 StorageClass 需要动态分配 PersistentVolume 时会使用到StorageClass作为对存储资源的抽象定义,对用户设置的PVC申请屏蔽后端存储的细节,一方面减少了用户对于存储资源细节的关注,另一方面减轻了管理员手工管理PV的工作,由系统自动完成PV的创建和绑定,实现了动态的资源供应。基于StorageClass的动态资源供应模式将逐步成为云平台的标准存储配置模式。
StorageClass一旦被创建出来,则将无法修改。如需更改,则只能删除原StorageClass的定义重建。下例定义了一个名为standard的StorageClass,提供者为aws-ebs,其参数设置了一个type,值为gp2:

kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: standard
provisioner: kubernetes.io/aws-ebs	# 指定存储类的供应者
parameters:
  type: gp2
reclaimPolicy: Retain				# 指定回收策略
mountOptions:
  - debug

提供者(Provisioner):描述存储资源的提供者,也可以看作后端存储驱动。目前Kubernetes支持的Provisioner都以 kubernetes.io/ 为开头,用户也可以使用自定义的后端存储提供者。为了符合StorageClass的用法,自定义Provisioner需要符合存储卷的开发规范。
参数(Parameters):后端存储资源提供者的参数设置,不同的Provisioner包括不同的参数设置。某些参数可以不显示设定,Provisioner将使用其默认值。

NFS动态存储

csi-driver-nfs 是一个用于 Kubernetes 的 NFS CSI 驱动程序,它可以让 Kubernetes 访问 Linux 节点上的 NFS 服务器。它的 CSI 插件名称是 nfs.csi.k8s.io。这个驱动程序需要已经存在并配置好的 NFSv3 或 NFSv4 服务器,它支持通过创建 NFS 服务器下的新子目录来动态分配持久卷(Persistent Volumes)。这个驱动程序的项目状态是 GA(正式发布)。
这个驱动程序的主要功能和特点有:
支持 NFSv3 和 NFSv4 协议
支持快照(Snapshot)和卷克隆(Volume cloning)
支持 fsGroupPolicy,可以在 Pod 中设置 fsGroup
支持多种安装方式,包括 helm charts 和 kubectl
支持多种参数设置,包括 mountOptions,server,share,subPath,readOnly 等
支持 Kubernetes 1.21+ 版本

部署nfs server
方法1:使用物理机/虚拟机搭建nfs服务

apt install nfs-kernel-server nfs-common -y
mkdir -p /data
echo "/data *(rw,sync,no_root_squash,no_subtree_check)" >> /etc/exports

#重启
exportfs -a
systemctl restart nfs-kernel-server.service
systemctl enable nfs-kernel-server.service

方法2:容器部署方式,直接使用k8s的pod当做nfs服务,这里使用的是容器部署

kubectl apply -f https://github.com/kubernetes-csi/csi-driver-nfs/blob/master/deploy/example/nfs-provisioner/nfs-server.yaml

查看部署的结果

[root@master ~]# kubectl get pod
NAME                          READY   STATUS    RESTARTS   AGE
nfs-server-56dfcc48c8-zv5lv   1/1     Running   0          28s

部署csi驱动
安装包地址:https://github.com/kubernetes-csi/csi-driver-nfs/tags
部署参考链接:https://github.com/kubernetes-csi/csi-driver-nfs/blob/master/docs/install-csi-driver-v4.6.0.md

wget https://github.com/kubernetes-csi/csi-driver-nfs/archive/refs/tags/v4.6.0.tar.gz
tar zxvf v4.6.0.tar.gz && cd csi-driver-nfs-4.6.0

替换镜像仓库

#registry.cn-hangzhou.aliyuncs.com/google_containers如果下面的仓库无镜像可以替换这个
sed -i 's#registry.k8s.io/sig-storage#registry.aliyuncs.com/image-storage#p' deploy/v4.6.0/*

查看替换完的镜像

registry.cn-hangzhou.aliyuncs.com/image-storage/nfspluginnfsplugin:v4.6.0
registry.cn-hangzhou.aliyuncs.com/image-storage/livenessprobe:v2.11.0
registry.cn-hangzhou.aliyuncs.com/image-storage/csi-node-driver-registrar:v2.9.1
registry.cn-hangzhou.aliyuncs.com/image-storage/snapshot-controller:v6.3.2
registry.cn-hangzhou.aliyuncs.com/image-storage/csi-provisioner:v3.6.2
registry.cn-hangzhou.aliyuncs.com/image-storage/csi-snapshotter:v6.3.2

部署nfs-csi,默认源码里面把kubelet的数据目录写死成/var/lib/kubelet了,如果有改过kubelet数据目录,可以进行更改

./deploy/install-driver.sh v4.6.0 local

看到NFS CSI driver installed successfully证明安装好了,然后我们来看看这个程序

[root@master csi-driver-nfs-4.6.0]# kubectl get pod -n kube-system -l app=csi-nfs-node
NAME                 READY   STATUS    RESTARTS   AGE
csi-nfs-node-48dvc   3/3     Running   0          55s
csi-nfs-node-6bphm   3/3     Running   0          53s
csi-nfs-node-q8p4z   3/3     Running   0          52s
[root@master csi-driver-nfs-4.6.0]# kubectl get pod -n kube-system -l app=csi-nfs-controller
NAME                                  READY   STATUS    RESTARTS   AGE
csi-nfs-controller-7fff69bf7d-866nl   4/4     Running   0          55s

看到Pod之后我们基本就算是部署好了,但是我们不能直接使用CSI,而是利用StorageClass来调用它,然后我们来创建这个StorageClass,参考文件csi-driver-nfs/deploy/example/storageclass-nfs.yaml
方法1:pod方式运行nfs-server

[root@master csi-driver-nfs-4.6.0]# kubectl get svc nfs-server 
NAME         TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)            AGE
nfs-server   ClusterIP   10.100.237.105   <none>        2049/TCP,111/UDP   26m
kubectl apply -f - <<EOF
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: nfs-csi
provisioner: nfs.csi.k8s.io
parameters:
  #nfs-server的svc域名,如果部署在非默认的ns,则需要修改
  server: nfs-server.default.svc.cluster.local
  share: /
  # csi.storage.k8s.io/provisioner-secret is only needed for providing mountOptions in DeleteVolume
  # csi.storage.k8s.io/provisioner-secret-name: "mount-options"
  # csi.storage.k8s.io/provisioner-secret-namespace: "default"
reclaimPolicy: Delete
volumeBindingMode: Immediate
mountOptions:
- nfsvers=4.1
EOF

方法2:对应虚拟机搭建nfs-server

kubectl apply -f - <<EOF
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: nfs-csi
  annotations:
    # 此操作是1.25的以上的一个alpha的新功能,是将此storageclass设置为默认
    storageclass.kubernetes.io/is-default-class: "true"
# 此处指定了csidrivers的名称,动态供给插件
provisioner: nfs.csi.k8s.io
parameters:
  # NFS的Server
  server: 192.168.80.45
  # NFS的存储路径
  share: /data
reclaimPolicy: Delete
volumeBindingMode: Immediate
mountOptions:
  # 这里不只可以配置nfs的版本
  - nfsvers=4.1
EOF

创建StorageClass,这里使用的是方法1,查看创建的StorageClass

[root@master ~]# kubectl get sc nfs-csi  
NAME      PROVISIONER      RECLAIMPOLICY   VOLUMEBINDINGMODE   ALLOWVOLUMEEXPANSION   AGE
nfs-csi   nfs.csi.k8s.io   Delete          Immediate           false                  11s

测试动态pvc申请

kubectl apply -f - <<EOF
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-nfs
spec:
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 1Gi
  storageClassName: nfs-csi
EOF

查看pvc,发现自动创建了pv

[root@master ~]# kubectl get pvc pvc-nfs 
NAME      STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
pvc-nfs   Bound    pvc-f5bff58d-20d9-4a62-9498-a7b1b081c87c   1Gi        RWX            nfs-csi        29s
[root@master ~]# kubectl get pv pvc-f5bff58d-20d9-4a62-9498-a7b1b081c87c
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM             STORAGECLASS   REASON   AGE
pvc-f5bff58d-20d9-4a62-9498-a7b1b081c87c   1Gi        RWX            Delete           Bound    default/pvc-nfs   nfs-csi                 48s

卸载/清理

kubectl delete pvc pvc-nfs-dynamic
#卸载驱动
./deploy/uninstall-driver.sh ${version} local

VolumeController

在Kubemetes中实际上存在一个专门处理持久化存储的控制器叫作 VolumeController,它维护着多个控制循环。其中有一个循环PersistentVolume Controller 会不断查看当前每个PVC是否已经处于Bound(已绑定)状态。如果不是Bound状态,它就会遍历所有可用的PV,并尝试将其与这个PVC进行绑定。这样Kubernetes就可以保证用户提交的每一个PVC,只要有合适的PV出现,就能很快的进行绑定。所谓将一个PVPVC进行绑定,其实就是将这个PV对象的名字填在了PVC对象的spec.volumeName字段上。

kubectl get pvc xxx -o yaml | grep volumeName
kubectl get pv xxx

所谓容器的Volume其实就是将一个宿主机上的目录跟一个容器里的目录进行绑定,挂载在了一起。所谓的持久化存储,指的就是该宿主机上的目录具备持久性,即该目录里面的内容不会因为容器的删除而被清理,也不会跟当前的宿主机绑定。这样当容器重启或在其他节点上重建之后,它仍能通过挂载这个Volume访问到这些内容。而 hostPath 和 emptyDir 类型的 Volume 并不具备这个特征,它们既可能被kubelet清理也不能迁移到其它节点。

一般情况下持久化存储的实现往往依赖于远程存储服务,而kubernetes则需要将远程存储服务来为容器提供一个持久化的宿主机目录,持久化宿主机目录的两阶段:
第一阶段Attach阶段:当一个Pod被调度到node节点上之后,默认情况下kubelet会为这个Pod在宿主机上创建它的Volume目录在 kubelet 工作目录下面,路径为/var/lib/kubelet/pods/podID/volumes/kubernetes.io~volume类型/volume名字
第二阶段mount阶段:如果你的volume类型为远程存储,为了能够使用该远程磁盘,那么kubelet就需要调用远程存储的API接口,格式化这个磁盘设备,然后把它挂载到pod所在的宿主机指定的挂载点上。mount完成后,这个Volume的宿主机目录就是一个持久化的目录了,容器在其中写入的内容就会保存在这个远程磁盘中。kubelet 需要作为 client,将远端 NFS 服务器的目录(比如:“/”目录),挂载到 Volume 的宿主机目录上,即相当于执行如下所示的命令

mount -t nfs <NFS服务器地址>:/ /var/lib/kubelet/pods/<Pod的ID>/volumes/kubernetes.io~<Volume类型>/<Volume名字> 

通过这个挂载操作,Volume 的宿主机目录就成为了一个远程 NFS 目录的挂载点,后面你在这个目录里写入的所有文件,都会被保存在远程 NFS 服务器上。所以,我们也就完成了对这个 Volume 宿主机目录的持久化。

这样在经过了上面的阶段过后,我们就得到了一个持久化的宿主机上面的 Volume 目录了,接下来 kubelet 只需要把这个 Volume 目录挂载到容器中对应的目录即可,这样就可以为 Pod 里的容器挂载这个持久化的 Volume 了,这一步其实也就相当于执行了如下所示的命令:

# docker 或者 nerdctl
docker run -v /var/lib/kubelet/pods/<Pod的ID>/volumes/kubernetes.io~<Volume类型>/<Volume名字>:/<容器内的目标目录> 我的镜像 ...

配置存储

在Kubernetes中有几种特殊的 Volume 它们存在的意义不是为了存放容器里的数据,也不是用于容器和宿主机之间的数据交换。而是为容器提供预先定义好的数据。所以从容器的角度来看,这些 Volume 里的信息就仿佛是被 kubernetes 投射进入容器中的。想让业务更顺利的运行,有一个问题不容忽视,那就是应用的配置管理。通常来说应用程序都会有一个配置文件,它把运行时需要的一些参数从代码中分离出来,让我们在实际运行的时候能更方便地调整优化,比如说Nginx有nginx.conf、Redis有redis.conf、MySQL有my.cnf等等。

在docker中,配置文件的使用方式有两种:第一种是编写Dockerfile,用 COPY 指令把配置文件打包到镜像里;第二种是在运行时使用 docker cp 或者 docker run -v,把本机的文件拷贝进容器。在Kubernetes中使用 ConfigMap(明文配置)和Secret(秘密配置) 来灵活地配置、定制我们的应用。

configmap明文配置

ConfigMap主要是以键值对的方式来存储配置信息的,这些数据信息可以在pod里面使用。在企业运营中,一般都会有多个部署环境,如开发环境、测试环境、预发布环境、生产环境等,这几种环境的配置也各有不同。如果在Pod模板中直接配置,会发现管理非常困难,每个环境都要准备不同的模板。利用 ConfigMap 可以解耦部署与配置之间的关系,只需要在各个环境的机器上预先完成不同的配置即可,也就是配置ConfigMap。而对于同一个应用部署,Pod模板无须变化,只要将明文编写的配置设置为对ConfigMap的引用,就可以降低环境管理和部署的复杂度。
在这里插入图片描述
比如创建一个ConfigMap用来保存 nginx.conf 配置文件,后期创建pod的时候引入这个configmap nginx.conf配置文件,这个配置文件就会注入到这个pod文件中,不管多少个pod过来以后都能去申请同一个configmap nginx.conf,后期修改configmap nginx.conf以后,所有引入configmap nginx.conf配置文件的pod也会发生变化。

创建ConfigMap

ConfigMap的4种创建方式:
通过直接在命令行中指定configmap参数创建,即--from-literal
通过指定文件创建,即将一个配置文件创建为一个ConfigMap--from-file=<文件>
通过指定目录创建,即将一个目录下的所有配置文件创建为一个ConfigMap,--from-file=<目录>
通过事先写好标准的configmap的yaml文件,然后kubectl create -f 创建

根据目录、文件或直接值创建ConfigMap对象,命令的语法格式为:

kubectl create configmap <map-name> <data-source>

即为ConfigMap对象的名称
是数据源,它可以通过直接值、文件或目录来获取。无论是哪一种数据源供给方式,它都要转换为ConfigMap对象中的Key-Value数据,其中Key由用户在命令行给出或是文件数据源的文件名,它仅能由字母、数字、连接号和点号组成,而Value则是直接值或文件数据源的内容

通过yaml文件创建

你可能会有点惊讶,ConfigMap的YAML和之前我们学过的Pod、Job不一样,除了熟悉的apiVersion、kind、metadata,居然就没有其它的了,最重要的字段spec也不见了,这是因为ConfigMap存储的是配置数据,是静态的字符串,并不是容器,所以它们就不需要用spec字段来说明运行时的规格

创建一个 configmap

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
  name: configmap-yaml
  namespace: dev
data:              #其中配置数据在 data 属性下面进行配置,前两个被用来保存单个属性,后面一个被用来保存一个配置文件
  configmap-yaml-data1: data1
  configmap-yaml-data2: data2
  # 竖线符 表示保留换行,每行的缩进和行尾空白都会被去掉,而额外的缩进会保留。
  # 除了使用竖线,还可以使用 > 表示折叠换行,只有空白行才会被识别为换行,原来的换行符都会被转换为空格。还有其它许多格式
  config: |
    configmap-yaml.1=value-1      #缩进和行尾空白都被去掉
      configmap-yaml.2=value-2    #额外的缩进保留	
    configmap-yaml.3=value-3
EOF

# config对应的JSON格式
#{
#	"config": "configmap-yaml.1=value-1\n  configmap-yaml.2=value-2\nconfigmap-yaml.3=value-3"
#}

查看创建的configmap,DATA的值为3,表示有三个key

[root@master ~]# kubectl get configmaps configmap-yaml -n dev
NAME             DATA   AGE
configmap-yaml   3      25s

查看这个 configmap 里保存的信息

[root@master ~]# kubectl get configmap configmap-yaml -n dev -o yaml
apiVersion: v1
data:
  config: |
    configmap-yaml.1=value-1
      configmap-yaml.2=value-2
    configmap-yaml.3=value-3
  configmap-yaml-data1: data1
  configmap-yaml-data2: data2
kind: ConfigMap
......

查看configmap详情

[root@master ~]# kubectl describe configmap configmap-yaml -n dev 
Name:         configmap-yaml
Namespace:    dev
Labels:       <none>
Annotations:  <none>

Data
====
config:
----
configmap-yaml.1=value-1
  configmap-yaml.2=value-2
configmap-yaml.3=value-3

configmap-yaml-data1:
----
data1
configmap-yaml-data2:
----
data2
Events:  <none>
通过目录创建

通过指定目录来创建configmap对象, 比如我们有一个 data 目录,该目录下面包含两个配置文件 mysql.conf 和 redis.conf

mkdir /data
echo -e "host=127.0.0.1\nport=3306" > /data/mysql.conf
echo -e "host=127.0.0.1\nport=6379" > /data/redis.conf

查看创建的文件

[root@master ~]# cat /data/mysql.conf 
host=127.0.0.1
port=3306
[root@master ~]# cat /data/redis.conf
host=127.0.0.1
port=6379

通过 from-file 关键字,将这个目录下的所有文件作为配置文件,来创建 ConfigMap

[root@master ~]# kubectl create configmap cm-dir-mysql-reids --from-file=/data -n dev
configmap/cm-file created
[root@master ~]# kubectl get cm cm-dir-mysql-reids -n dev
NAME                 DATA   AGE
cm-dir-mysql-reids   2      32s

from-file 参数指定的目录下面的所有文件,都会被用在 ConfigMap 里面创建一个键值对,键的名字就是文件名,值就是文件的内容。

[root@master ~]# kubectl get cm cm-dir-mysql-reids -n dev -oyaml
apiVersion: v1
data:
  mysql.conf: |
    host=127.0.0.1
    port=3306
  redis.conf: |
    host=127.0.0.1
    port=6379
kind: ConfigMap
...
通过文件创建

除了通过目录进行创建,还可以使用指定的文件进行创建 ConfigMap。与目录创建的区别:一个指定目录,文件创建指定到具体文件。
以 mysql 配置文件为例,创建一个单独 ConfigMap 对象:

# --from-file 这个参数可以使用多次,你可以使用两次分别指定上个实例中的那两个配置文件,效果就跟指定整个目录是一样的
[root@master ~]# kubectl create configmap cm-file-mysql --from-file=/data/mysql.conf -n dev
configmap/cm-mysql created
[root@master ~]# kubectl get configmaps cm-file-mysql -o yaml -n dev
apiVersion: v1
data:
  mysql.conf: |
    host=127.0.0.1
    port=3306
kind: ConfigMap
......
通过命令行创建

直接在命令行中利用 --from-literal 参数传递配置信息,该参数可以使用多次,格式如下:

[root@master ~]# kubectl create configmap cm-literal --from-literal=host=127.0.0.1 --from-literal=port=80 -n dev
configmap/cm-literal created
[root@master ~]# kubectl get configmaps cm-literal -o yaml -n dev
apiVersion: v1
data:
  host: 127.0.0.1
  port: "80"
kind: ConfigMap
......

使用ConfigMap

ConfigMap创建完成后就可以在Pod中使用了,ConfigMap 这些配置数据可以通过三种方式在 Pod 里使用:
通过环境变量的方式,直接传递给pod
在容器里设置命令行参数
在数据卷里面挂载配置文件

环境变量引用

使用env或envFrom引用 ConfigMap 来填充我们的环境变量,前提是pod中的容器得支持从环境变量加载配置信息,如下所示的 Pod 资源对象:

使用env的方式传递给pod,这里我们使用的是上面通过yaml文件创建的configmap-yaml

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: cm-env-pod
  namespace: dev
spec:
  containers:
    - name: cm-env-pod
      image: busybox
      command: [ "/bin/sh", "-c", "env" ]     #命令执行完就退出了
      env:
        - name: pod_env_1       #环境变量名
          valueFrom:            #表示环境变量的值,来自外部引用,
            configMapKeyRef:    #表示从ConfigMap中引用
              name: configmap-yaml        #表示要引用的ConfigMap的名称
              key: configmap-yaml-data1   #表示要引用的键值对的键名,它的值会映射到环境变量上           
        - name: pod_env_2
          valueFrom:
            configMapKeyRef:
              name: configmap-yaml
              key: config
EOF

创建Pod,发现pod的状态是CrashLoopBackOff,这个是正常现象,因为容器内命令执行完,容器就退出了

[root@master ~]# kubectl get pod cm-env-pod -n dev
NAME         READY   STATUS             RESTARTS      AGE
cm-env-pod   0/1     CrashLoopBackOff   2 (14s ago)   82s
[root@master ~]# kubectl logs cm-env-pod -n dev
......
pod_env_1=data1
pod_env_2=configmap-yaml.1=value-1
  configmap-yaml.2=value-2
configmap-yaml.3=value-3
......

使用envFrom的方式传递给pod,这里我们使用的还是上面通过yaml文件创建的configmap-yaml

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: cm-envfrom-pod
  namespace: dev
spec:
  containers:
    - name: cm-envfrom-pod
      image: busybox
      command: [ "/bin/sh", "-c", "env" ]     #命令执行完就退出了
      envFrom:
        - configMapRef:
            name: configmap-yaml       #这个name为configmap名,envFrom属性将整个环境变量从外部引用整个文件
EOF

创建Pod,发现pod的状态是CrashLoopBackOff,这个是正常现象,因为容器内命令执行完,容器就退出了

[root@master ~]# kubectl get pod cm-envfrom-pod -n dev
NAME             READY   STATUS             RESTARTS   AGE
cm-envfrom-pod   0/1     CrashLoopBackOff   2          91s
[root@master ~]# kubectl logs cm-envfrom-pod -n dev
......
configmap-yaml-data1=data1
configmap-yaml-data2=data2
config=configmap-yaml.1=value-1
  configmap-yaml.2=value-2
configmap-yaml.3=value-3
......
命令行引用变量

在命令行中引用这个变量,在容器里设置命令行参数

[root@master ~]# vim cm-command-pod 
apiVersion: v1
kind: Pod
metadata:
  name: cm-command-pod
  namespace: dev
spec:
  containers:
    - name: cm-command-pod
      image: busybox
      #在命令行中引用这个变量
      command: [ "/bin/sh", "-c", "echo $(configmap-yaml-data1) $(configmap-yaml-data2)" ]
      envFrom:
        - configMapRef:
            name: configmap-yaml

创建pod

kubectl apply -f cm-command-pod 

查看创建Pod的日志,通过日志信息就可以看到echo的输出信息

[root@master ~]# kubectl logs cm-command-pod -n dev
data1 data2
存储卷引用

因为ConfigMap本身是一种特殊的存储卷,所以也可以通过存储卷方式配置到Pod中。这种引用方式会将每个键值对都转换成对应的实体文件,键就是文件名,键值就是文件内容。

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: cm-volume-pod
  namespace: dev
spec:
  volumes:
    - name: volume-config     #存储卷名
      configMap:              #存储卷的类型
        name: configmap-yaml  #要引用的ConfigMap名
  containers:
    - name: cm-volume-containers
      image: busybox
      imagePullPolicy: IfNotPresent
      command: [ "/bin/sh", "-c", "sleep 6000"]
      volumeMounts:
      - name: volume-config
        mountPath: /data      #引用volume-config存储卷,并将其映射到容器的 /data 目录下
EOF

查看挂载的configmap,文件名是以key的名命名的

[root@master ~]# kubectl exec cm-volume-pod -n dev -- /bin/sh -c 'ls -l /data/'
lrwxrwxrwx    1 root     root            13 Jul  5 16:39 config -> ..data/config
lrwxrwxrwx    1 root     root            27 Jul  5 16:39 configmap-yaml-data1 -> ..data/configmap-yaml-data1
lrwxrwxrwx    1 root     root            27 Jul  5 16:39 configmap-yaml-data2 -> ..data/configmap-yaml-data2

可以看到已经映射成功,每个configmap的key都映射成了一个目录,value为文件中的内容

[root@master ~]# kubectl exec cm-volume-pod -n dev -- /bin/sh -c 'cat /data/config'
configmap-yaml.1=value-1
  configmap-yaml.2=value-2
configmap-yaml.3=value-3
[root@master ~]# kubectl exec cm-volume-pod -n dev -- /bin/sh -c 'cat /data/configmap-yaml-data1'
data1[root@master ~]# kubectl exec cm-volume-pod -n dev -- /bin/sh -c 'cat /data/configmap-yaml-data2'
data2[root@master ~]# 

当然如果想要挂载到指定的文件上面,可以在 spec.volumes.configMap 下面添加 items 指定 key 和 path

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: cm-volume-pod-items
  namespace: dev
spec:
  volumes:
    - name: volume-config     #存储卷名
      configMap:              #存储卷的类型
        name: configmap-yaml  #要引用的ConfigMap名
        items:
        - key: configmap-yaml-data1
          path: data
  containers:
    - name: cm-volume-containers
      image: busybox
      imagePullPolicy: IfNotPresent
      command: [ "/bin/sh", "-c", "sleep 6000"]
      volumeMounts:
      - name: volume-config
        mountPath: /data      #引用volume-config存储卷,并将其映射到容器的 /data 目录下
EOF

查看挂载的secret

[root@master ~]# kubectl exec cm-volume-pod-items -n dev -- /bin/sh -c 'ls -l /data/'
total 0
lrwxrwxrwx    1 root     root            11 Jul  6 10:43 data -> ..data/data
[root@master ~]# kubectl exec cm-volume-pod-items -n dev -- /bin/sh -c 'cat /data/data'
hello[root@master ~]# 

ConfigMap热更新

更新 ConfigMap 后,使用该 ConfigMap 挂载的 Env 不会同步更新,使用该 ConfigMap 挂载的 Volume 中的数据需要一段时间才能同步更新。因为 ENV 是在容器启动的时候注入的,启动之后 kubernetes 就不会再改变环境变量的值,且同一个 namespace 中的 pod 的环境变量是不断累加的。为了更新容器中使用 ConfigMap 挂载的配置,可以通过滚动更新 pod 的方式来强制重新挂载 ConfigMap,也可以在更新了 ConfigMap 后,先将副本数设置为 0,然后再扩容。

kubelet 在每次周期性同步时都会检查已挂载的 ConfigMap 是否是最新的。 但是,它使用其本地的基于 TTL 的缓存来获取 ConfigMap 的当前值。 因此,从更新 ConfigMap 到将新键映射到 Pod 的总延迟可能等于 kubelet 同步周期 (默认 1 分钟) + ConfigMap 在 kubelet 中缓存的 TTL(默认 1 分钟)。

下面我们将configmap键为configmap-yaml-data1的值改为hello

kubectl patch cm configmap-yaml -n dev --type='json' -p='[{"op": "replace", "path": "/data/configmap-yaml-data1", "value":"hello"}]'

查看pod对应的值已经修改为hello

[root@master ~]# kubectl exec cm-volume-pod -n dev -- /bin/sh -c 'cat /data/configmap-yaml-data1'
hello[root@master ~]# 

删除configmap后原pod不受影响。删除pod后,重启的pod的events会报找不到cofigmap的volume。

k8s部署nginx挂载configmap

使用configmap提供nginx配置

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
  name: web-nginx-config
data:
  nginx.conf: |
    user nginx;
    worker_processes  2;
    error_log  /var/log/nginx/error.log;
    events {
      worker_connections  1024;
    }

    http {
      include       mime.types;
      #sendfile        on;
      keepalive_timeout  1800;
      log_format  main
              'remote_addr:$remote_addr '
              'time_local:$time_local   '
              'method:$request_method   '
              'uri:$request_uri '
              'host:$host       '
              'status:$status   '
              'bytes_sent:$body_bytes_sent      '
              'referer:$http_referer    '
              'useragent:$http_user_agent       '
              'forwardedfor:$http_x_forwarded_for       '
              'request_time:$request_time';
      access_log        /var/log/nginx/access.log main;
      server {
          listen       80;
          server_name  localhost;
          location / {
              root /data/nginx/html;
              index  index.html index.htm;
          }
          error_page   500 502 503 504  /50x.html;
      }
      include /etc/nginx/conf.d/*.conf;
    }
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: web-nginx
          image: nginx:1.14.2
          imagePullPolicy: IfNotPresent
          ports:
          - containerPort: 80
          volumeMounts:
          - name: nginx-static-dir
            mountPath: /data/nginx/html
          - name: web-nginx-config  #调用configmap卷
            mountPath: /etc/nginx/nginx.conf
            subPath: nginx.conf
      volumes:
        - name: nginx-static-dir
          persistentVolumeClaim:
            claimName: nginx-static-pvc-local
        - name: web-nginx-config
          configMap:
            name: web-nginx-config
            items:
            - key: nginx.conf
              path: nginx.conf  #将value保存在nginx.conf中
---
apiVersion: v1
kind: Service
metadata:
  name: nginxsvc
spec:
  type: NodePort
  sessionAffinity: ClientIP
  ports:
    - name: web-nginx-out
      port: 80
      targetPort: 80
      nodePort: 30080
  selector:
    app: nginx
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: nginx-static-local   #StorageClass 的名字
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: nginx-static-pv-local
spec:
  capacity:
    storage: 5Gi
  volumeMode: Filesystem
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Delete
  storageClassName: nginx-static-local
  local:
    path: /data/nginx/html
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - node1
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: nginx-static-pvc-local
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  storageClassName: nginx-static-local
EOF

在node1节点上创建目录

mkdir -p /data/nginx/html
echo "hello nginx" > /data/nginx/html/index.html

查看pod是否运行正常

[root@master ~]# kubectl get pod -owide 
NAME                               READY   STATUS    RESTARTS   AGE     IP               NODE    NOMINATED NODE   READINESS GATES
nginx-deployment-b644b56d9-pz7jq   1/1     Running   0          3m43s   10.244.166.177   node1   <none>           <none>
[root@master ~]# curl 10.244.166.177 
hello nginx

查看pod挂载的configmap文件nginx.conf

[root@master ~]# kubectl exec nginx-deployment-b644b56d9-pz7jq -- cat /etc/nginx/nginx.conf
user nginx;
worker_processes  2;
error_log  /var/log/nginx/error.log;
events {
  worker_connections  1024;
}

http {
  include       mime.types;
  #sendfile        on;
  keepalive_timeout  1800;
  log_format  main
          'remote_addr: '
          'time_local:   '
          'method:   '
          'uri: '
          'host:       '
          'status:   '
          'bytes_sent:      '
          'referer:    '
          'useragent:       '
          'forwardedfor:       '
          'request_time:';
  access_log        /var/log/nginx/access.log main;
  server {
      listen       80;
      server_name  localhost;
      location / {
          root /data/nginx/html;
          index  index.html index.htm;
      }
      error_page   500 502 503 504  /50x.html;
  }
  include /etc/nginx/conf.d/*.conf;
}

查看容器中挂载/data/nginx/html目录

[root@master ~]# kubectl exec nginx-deployment-b644b56d9-pz7jq -- cat /data/nginx/html/index.html
hello nginx

查看创建的service地址

[root@master ~]# kubectl get svc nginxsvc
NAME       TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
nginxsvc   NodePort   10.105.18.246   <none>        80:30080/TCP   33s

通过service访问nginx

[root@master ~]# curl 10.105.18.246
hello nginx
[root@master ~]# curl localhost:30080
hello nginx

Secret加密配置

ConfigMap 用于传递普通的配置信息,是明文存储的。Secret 用于传递敏感、加密的配置信息。例如,用户名和密码等敏感信息。 这样的信息可能会被放在 Pod 规约中或者镜像中,使用 Secret 意味着你不需要在应用程序代码中包含机密数据。由于创建 Secret 可以独立于使用它们的 Pod, 因此在创建、查看和编辑 Pod 的工作流程中暴露 Secret 及其数据的风险较小。实际上Secret的安全性并不高,因为它本质上通过base64格式对信息进行编码,连加密都算不上,这些编码后的信息只需要解码就可以变回原始值。对于重要信息,建议采用其他自定义方式进行加密并在Pod中按自定义算法进行解密。

kubernetes可支持的不同的secret类型
Opaque:base64 编码格式的Secret,用来存储密码、密钥等;但数据也可以通过base64 decode解码得到原始数据,所有加密性很弱。
kubernetes.io/service-account-token:用于 ServiceAccount, ServiceAccount 创建时 Kubernetes 会默认创建一个对应的 Secret 对象,Pod 如果使用了 ServiceAccount,对应的 Secret 会自动挂载到 Pod 目录 /run/secrets/kubernetes.io/serviceaccount 中。
kubernetes.io/dockerconfigjson:用来存储私有docker registry的认证信息。
bootstrap.kubernetes.io/token:用于节点接入集群的校验的 Secret
kubernetes.io/ssh-auth:用于SSH身份认证的凭据
kubernetes.io/basic-auth:用于基于身份认证的凭据

下面我们使用Sercet-Opaqu提供认证,OpaqueSecret完全就是ConfigMap的翻版,它们的定义方式和使用方式类似,都是使用键值对形式,但区别在于OpaqueSecret中各个键对应的值必须通过base64进行编码才能配置。比如我们现在创建一个OpaqueSecret,来存储自定义的用户名和密码,在本例中用户名为admin,密码为123456。首先,需要对用户名和密码进行base64编码。

#必须要加参数 -n 去掉字符串里隐含的换行符,否则Base64编码出来的字符串就是错误的。
[root@master ~]# echo -n "admin" | base64
YWRtaW4=
[root@master ~]# echo -n "123456" | base64
MTIzNDU2

将这些编码后的值配置到Secret中,创建一个名为 secret-opaque 的Secret,它拥有两个键值对,它们的值正是刚才编码后的用户名和密码

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
  name: secret-opaque
  namespace: dev
type: Opaque  #secret类型
data:
  username: YWRtaW4=
  password: MTIzNDU2
EOF

查看创建的secret

[root@master ~]# kubectl describe secrets secret-opaque -n dev
Name:         secret-opaque
Namespace:    dev
Labels:       <none>
Annotations:  <none>

Type:  Opaque

Data
====
password:  6 bytes
username:  5 bytes

[root@master ~]# kubectl get secrets secret-opaque -n dev -o yaml
apiVersion: v1
data:
  password: MTIzNDU2
  username: YWRtaW4=

查看发现设置的两个键值对,使用base64编码显示,只要稍微解码就可以得到原始值

[root@master ~]# echo "MTIzNDU2" | base64 --decode 
123456[root@master ~]# echo "YWRtaW4=" | base64 --decode 
admin[root@master ~]# 

使用Secret

Secret创建完成后就可以在Pod中引用了,Pod 可以用三种方式的任意一种来使用 Secret:
作为挂载到一个或多个容器上的卷中的文件(crt文件、key文件)。
作为容器的环境变量。
由 kubelet 在为 Pod 拉取镜像时使用(与镜像仓库的认证)。

环境变量引用

通过环境变量中定义的 secretKeyRef 字段,和我们前文的 configMapKeyRef 类似,一个是从 Secret 对象中获取,一个是从 ConfigMap 对象中获取

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: secret-env-pod
  namespace: dev
spec:
  containers:
  - name: secret
    image: busybox
    command: [ "/bin/sh", "-c", "env" ]
    env:
    - name: USERNAME     #容器里的环境变量的名字
      valueFrom:
        secretKeyRef:    
          name: secret-opaque   #指定secret的名字
          key: username         
    - name: PASSWORD
      valueFrom:
        secretKeyRef:
          name: secret-opaque
          key: password
EOF

查看Pod的日志输出,可以看到有 USERNAME 和 PASSWORD 两个环境变量输出出来

[root@master ~]# kubectl logs secret-env-pod -n dev
......
USERNAME=admin
PASSWORD=123456

使用envFrom的方式传递给pod,这里我们使用的还是上面通过yaml文件创建的secret-opaque

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: secret-envfrom-pod
  namespace: dev
spec:
  containers:
    - name: secret
      image: busybox
      command: [ "/bin/sh", "-c", "env" ]     #命令执行完就退出了
      envFrom:
        - secretRef:
            name: secret-opaque       #这个name为secret名,envFrom属性将整个环境变量从外部引用整个文件
EOF

创建Pod,发现pod的状态是CrashLoopBackOff,这个是正常现象,因为容器内命令执行完,容器就退出了

[root@master ~]# kubectl get pod secret-envfrom-pod -n dev
NAME                 READY   STATUS             RESTARTS   AGE
secret-envfrom-pod   0/1     CrashLoopBackOff   1          36s
[root@master ~]# kubectl logs secret-envfrom-pod -n dev
......
USERNAME=admin
PASSWORD=123456
......
存储卷引用

将secret以存储卷方式配置到Pod中

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: secret-volume-pod
  namespace: dev
spec:
  containers:
  - name: secret-volume-pod
    image: busybox
    command: ["/bin/sh", "-c", "sleep 6000"]
    volumeMounts:
    - name: secret
      mountPath: /data
  volumes:
  - name: secret
    secret:
     secretName: secret-opaque
EOF

查看挂载的secret,可以看到 Secret 把两个 key 挂载成了两个对应的文件

[root@master ~]# kubectl exec secret-volume-pod -n dev -- /bin/sh -c 'ls -l /data/'
lrwxrwxrwx    1 root     root            15 Jul  6 10:10 password -> ..data/password
lrwxrwxrwx    1 root     root            15 Jul  6 10:10 username -> ..data/username

可以看到已经映射成功,每个 Secret 的key都映射成了一个目录,value为文件中的内容

[root@master ~]# kubectl exec secret-volume-pod -n dev -- /bin/sh -c 'cat /data/username'
admin[root@master ~]# kubectl exec secret-volume-pod -n dev -- /bin/sh -c 'cat /data/password'
123456[root@master ~]# 

当然如果想要挂载到指定的文件上面,可以在 spec.volumes.secret 下面添加 items 指定 key 和 path

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: secret-volume-pod-items
  namespace: dev
spec:
  containers:
  - name: secret-volume-pod
    image: busybox
    command: ["/bin/sh", "-c", "sleep 6000"]
    volumeMounts:
    - name: secret
      mountPath: /data
  volumes:
  - name: secret
    secret:
      secretName: secret-opaque
      items:
      - key: username        #只挂载secret-opaque里面的username这个key
        path: name           #文件名
EOF

查看挂载的secret

[root@master ~]# kubectl exec secret-volume-pod-items -n dev -- /bin/sh -c 'ls -l /data/'
lrwxrwxrwx    1 root     root            11 Jul  6 10:21 name -> ..data/name
[root@master ~]# kubectl exec secret-volume-pod -n dev -- /bin/sh -c 'cat /data/username'
admin[root@master ~]# 

Secret 热更新

同 ConfigMap 一样,更新 Secret 后,使用该 Secret 挂载的 Env 不会同步更新,使用该 Secret 挂载的 Volume 中的数据需要一段时间才能同步更新。

下面我们将Secret键为username的值改为ADMIN

[root@master ~]# echo "ADMIN" | base64 
QURNSU4K
kubectl patch secret secret-opaque -n dev --type='json' -p='[{"op": "replace", "path": "/data/username", "value":"QURNSU4K"}]'

查看pod对应的值已经修改为ADMIN

[root@master ~]# kubectl exec secret-volume-pod -n dev -- /bin/sh -c 'cat /data/username'
ADMIN

secret保存harbor仓库账号密码

在企业中,大多用的是私有镜像仓库。当节点下载镜像时需要login,所以可以做一个认证。创建私有镜像仓库Secret 的两种方式
1、先在docker上登录harbor镜像仓库地址,得到 /root/.docker/config.json 文件,使用该文件进行创建,可以查看官网https://kubernetes.io/zh-cn/docs/tasks/configure-pod-container/pull-image-private-registry
2、使用命令行镜像创建,这里主要讲解使用命令行创建。

这里我们直接使用命令行创建一个名称叫做harbor-registry的secret,配置harbor私有镜像仓库地址

# docker-registry是关键字,harbor-registry才是我们起的名称
kubectl -n default create secret docker-registry harbor-registry  \		
  --docker-email=baidu.com@example \
  --docker-username=admin \
  --docker-password=TestHarbor123 \
  --docker-server=192.168.118.119:443

查看secret,已经创建成功

[root@master ~]# kubectl get secret harbor-registry
NAME              TYPE                             DATA   AGE
harbor-registry   kubernetes.io/dockerconfigjson   1      16s

查看secret

[root@master ~]# kubectl describe  secret harbor-registry
Name:         harbor-registry
Namespace:    default
Labels:       <none>
Annotations:  <none>

Type:  kubernetes.io/dockerconfigjson

Data
====
.dockerconfigjson:  141 bytes

.data.dockerconfigjson 内容进行转储并执行 base64 解码

[root@master ~]# kubectl get secret harbor-registry -o jsonpath='{.data.*}' | base64 -d                   
{"auths":{"192.168.118.119:443":{"username":"admin","password":"TestHarbor123","email":"baidu.com@example","auth":"YWRtaW46SGFyYm9yMTIzNDU="}}}

格式化一下,其实这就是一个有效的docker用户配置文件

{
	"auths": {
		"192.168.118.119:443": {
			"username": "admin",
			"password": "TestHarbor123",
			"email": "baidu.com@example",
			"auth": "YWRtaW46SGFyYm9yMTIzNDU="
		}
	}
}

还记的当你登陆docker的时候吗, docker login -u admin -p TestHarbor123 192.168.118.119:443 其实生成的用户信息配置文件就
/root/.docker/config.json,这个文件和上面的基本是一样的

注意在node节点上需要docker能正常请求到私有镜像仓库地址,还需要将harbor镜像仓库地址添加到/etc/docker/daemon.json ,如下所示

[root@node1 ~]# cat /etc/docker/daemon.json 
{
    "registry-mirrors": ["https://b9pmyelo.mirror.aliyuncs.com"],
    "exec-opts": ["native.cgroupdriver=systemd"],
    "insecure-registries": ["192.168.118.119:443"]		#添加这一句,不加的话docker login将会报错的
}

重启docker,生产环境不要随便重启docker

systemctl  daemon-reload		#重载
systemctl  restart docker		#重启

接下来创建pod,使用imagePullSecrets参数指定镜像拉取秘钥

cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata: 
  name: test-nginx
  labels: 
    env: dev
    tiar: front
  namespace: default         #命名空间,要与secret的一致,不要找不到对应的secret
spec:
  replicas: 1
  selector:
     matchLabels:
         app: test-nginx
  template:
     metadata:
       labels:
         app: test-nginx
     spec:
         imagePullSecrets:              #使用imagePullSecrets参数指定镜像拉取秘钥
         - name: harbor-registry        #使用我们的secret,即harbor-registry 
         containers:
         #指定拉取的镜像,注意这里的镜像名称只需要写到ip地址,端口号,仓库名,镜像名,版本
         - image: 192.168.118.119:443/my_harbor/nginx:v1
           name: nginx-container
           imagePullPolicy: IfNotPresent
           ports:
           - name: http 
             containerPort: 80
EOF

查看创建的pod

[root@master ~]# kubectl get pods
NAME                                READY   STATUS              RESTARTS      AGE
test-nginx-bcb85475b-6tcwz          0/1     ContainerCreating   0             10s
[root@master deployment]# kubectl describe  pods test-nginx-bcb85475b-6tcwz		#查看pods,pods被分配到node1上,镜像正常拉取
  ----    ------     ----  ----               -------
  Normal  Scheduled  37s   default-scheduler  Successfully assigned default/test-nginx-75c88cc97d-556lt to node1
  Normal  Pulling    35s   kubelet            Pulling image "192.168.118.119:443/my_harbor/nginx:v1"
  Normal  Pulled     35s   kubelet            Successfully pulled image "192.168.118.119:443/my_harbor/nginx:v1" in 110.79546ms
  Normal  Created    35s   kubelet            Created container nginx-container
  Normal  Started    35s   kubelet            Started container nginx-container

在node1上查看,镜像已经正常拉取下来了

[root@node1 ~]# docker images | grep nginx
192.168.118.119:443/my_harbor/nginx                      v1        605c77e624dd   9 months ago    141MB

日志总量限制

K8S 对写入标准输出的日志有一个轮转机制,默认情况下每个容器的日志文件最多可以有 5 个,每个文件最大允许 10Mi,如此每个容器最多保留最新的 50Mi 日志,再加上 Node 也可以对 Pod 数量进行限制,日志使用的本地存储空间就变得可控了。这个控制也是 kubelet 来执行的,有两个参数:
containerLogMaxSize 单个日志文件的最大尺寸,默认为 10Mi。
containerLogMaxFiles 每个容器的日志文件上限,默认为 5。

不过如果没有意外,意外将要发生了,K8S 的限制不起作用。这是因为我们使用的容器运行时是 docker,docker 有自己的日志处理方式,这套机制可能过于封闭,K8S 无法适配或者不愿意适配。可以更改 docker deamon 的配置来解决这个问题,在 K8S Node 中编辑这个文件 /etc/docker/daemon.json (如果没有则新建),增加关于日志的配置:

{
    "log-opts": {
        "max-size": "10m",
        "max-file": "5"
    }
}

然后重启 Node 上的 docker:systemctl restart docker。注意还需要重新创建这个 Pod,因为这个配置只对新的容器生效。在 docker 运行时下,容器日志实际上位于 /var/lib/docker/containers 中,先找到容器 id,然后就可以观察到这些日志的变化了:
在这里插入图片描述
存储的限制方法
除了容器镜像是系统机制控制的,其它的内容都跟应用程序有关。应用程序完全可以控制自己使用的存储空间,比如少写点日志,将数据保存到远程存储,及时删除使用完毕的临时数据,使用 LRU 等算法控制存储空间的使用量,等等。不过完全依赖开发者的自觉也不是一件很可靠的事,万一有 BUG 呢?所以 K8S 也提供了一些机制来限制容器可以使用的存储空间。

K8S 的 GC:K8S 有一套自己的 GC 控制逻辑,它可以清除不再使用的镜像和容器。这个清理工作是 kubelet 执行的,它有三个参数来控制如何执行清理,可以根据自己的镜像大小和数量的水平来更改这几个阈值:
imageMinimumGCAge 未使用镜像进行垃圾回收时,其存在的时间要大于这个阈值,默认是 2 分钟。
imageGCHighThresholdPercent 镜像占用的磁盘空间比例超过这个阈值时,启动垃圾回收。默认 85。
ImageGCLowThresholdPercent 镜像占用的磁盘空间比例低于这个阈值时,停止垃圾回收。默认 80。

临时数据的总量限制

对于所有类型的临时性本地数据,包括 emptyDir 卷、容器可写层、容器镜像、日志等,K8S 也提供了一个统一的存储请求和限制的设置,如果使用的存储空间超过限制就会将 Pod 从当前 Node 逐出,从而避免磁盘空间使用过多。我们创建一个 Pod,它会每秒写 1 个 5M 的文件,同时使用 spec.containers[].resources.requests.limits 给存储资源设置了一个限制,最大100Mi

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: ephemeral-storage-limit
spec:
  containers:
  - name: count
    image: busybox
    args: [/bin/sh, -c, 'while true; do dd if=/dev/zero of=$(date "+%s").out count=1 bs=20MB; sleep 1; done']
    resources:
      requests:
        ephemeral-storage: "50Mi"
      limits:
        ephemeral-storage: "100Mi"
EOF

稍等几分钟,然后查询 Pod 的事件,可以看到 kubelet 发现 Pod 使用的本地临时存储空间超过了限制的 100Mi,然后就把 Pod 驱逐了。通过这些存储限制,基本上就可以说是万无一失了。当然还要在节点预留足够的本地存储空间,可以根据 Pod 的数量和每个 Pod 最大可使用的空间进行计算,否则程序也会因为总是得不到所需的存储空间而出现无法正常运行的问题。
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值