zoukankan      html  css  js  c++  java
  • Kubernetes 学习(十)Kubernetes 容器持久化存储

    0. 前言

    • 最近在学习张磊老师的 深入剖析Kubernetes 系列课程,最近学到了 Kubernetes 容器持久化存储部分
    • 现对这一部分的相关学习和体会做一下整理,内容参考 深入剖析Kubernetes 原文,仅作为自己后续回顾方便
    • 希望详细了解的同学可以移步至原文支持一下原作者
    • 参考原文:深入剖析Kubernetes

    1. PV、PVC、StorageClass 关系梳理

    1.1 相关概念

    • Volume:其实就是将一个宿主机上的目录,跟一个容器里的目录绑定挂载在了一起
    • 持久化 Volume:指的就是这个宿主机上的目录,具备“持久性”
      • 即:这个目录里面的内容,既不会因为容器的删除而被清理掉,也不会跟当前的宿主机绑定
      • 这样,当容器被重启或者在其他节点上重建出来之后,它仍然能够通过挂载这个 Volume,访问到这些内容
      • 大多数情况下,持久化 Volume 的实现,往往依赖于一个远程存储服务,比如:远程文件存储(比如,NFS、GlusterFS)、远程块存储(比如,公有云提供的远程磁盘)等等
      • 而 Kubernetes 需要做的工作,就是使用这些存储服务,来为容器准备一个持久化的宿主机目录,以供将来进行绑定挂载时使用
      • 而所谓“持久化”,指的是容器在这个目录里写入的文件,都会保存在远程存储中,从而使得这个目录具备了“持久性”
    • PV:表示是持久化存储数据卷对象。这个 API 对象定义了一个持久化存储在宿主机上的目录(如 NFS 的挂载目录)
      • 通常情况下,PV 对象由运维人员事先创建在 Kubernetes 集群里,比如:
    apiVersion: v1
    kind: PersistentVolume
    metadata:
      name: nfs
    spec:
      storageClassName: manual
      capacity:
        storage: 1Gi
      accessModes:
        - ReadWriteMany
      nfs:
        server: 10.244.1.4
        path: "/"
    View Code
    • PVC:表示 Pod 所希望使用的持久化存储的属性(如:Volume 存储的大小、可读写权限等等) 
      • PVC 对象通常由开发人员创建,或者以 PVC 模板的方式成为 StatefulSet 的一部分,然后由 StatefulSet 控制器负责创建带编号的 PVC。比如:
    apiVersion: v1
    kind: PersistentVolumeClaim
    metadata:
      name: nfs
    spec:
      accessModes:
        - ReadWriteMany
      storageClassName: manual
      resources:
        requests:
          storage: 1Gi
    View Code
    • StorageClass:其实就是创建 PV 的模板。具体地说,StorageClass 对象会定义如下两个部分内容:
      • 第一,PV 的属性。比如,存储类型、Volume 的大小等等
      • 第二,创建这种 PV 需要用到的存储插件。比如,Ceph 等等
      • Kubernetes 只会将 StorageClass 相同的 PVC 和 PV 绑定起来
    apiVersion: storage.k8s.io/v1
    kind: StorageClass
    metadata:
      name: block-service
    provisioner: kubernetes.io/gce-pd
    parameters:
      type: pd-ssd
    View Code

    1.2 绑定条件

    • PVC 要真正被容器使用起来,就必须先和某个符合条件的 PV 通过两个条件进行绑定:
      • 首先是 PV 和 PVC 的 spec 字段,比如 PV 的存储(storage)大小,必须满足 PVC 的要求
      • 其次是 PV 和 PVC 的 storageClassName 字段必须一样
    • 在成功地将 PVC 和 PV 进行绑定之后,Pod 就能够像使用 hostPath 等常规类型的 Volume 一样,在自己的 YAML 文件里声明使用这个 PVC 了,如:
      • Pod 可以在 volumes 字段里声明自己要使用的 PVC 名字
      • 接下来,等这个 Pod 创建之后,kubelet 就会把这个 PVC 所对应的 PV,挂载在这个 Pod 容器内的目录上
    apiVersion: v1
    kind: Pod
    metadata:
      labels:
        role: web-frontend
    spec:
      containers:
      - name: web
        image: nginx
        ports:
          - name: web
            containerPort: 80
        volumeMounts:
            - name: nfs
              mountPath: "/usr/share/nginx/html"
      volumes:
      - name: nfs
        persistentVolumeClaim:
          claimName: nfs
    View Code

    1.3 绑定关系

    • 从面相对象的角度思考,PVC 可以理解为持久化存储的“接口”
    • 它提供了对某种持久化存储的描述,但不提供具体的实现
    • 而这个持久化存储的实现部分则由 PV 负责完成
    • 如果创建 Pod 的时候,系统里并没有合适的 PV 跟它定义的 PVC 绑定,Pod 的启动就会报错
    • 在 Kubernetes 中,实际上存在着一个专门处理持久化存储的控制器,叫作 Volume Controller
    • 这个 Volume Controller 维护着多个控制循环,其中有一个循环,扮演的就是撮合 PV 和 PVC 的“红娘”的角色:PersistentVolumeController
      • PersistentVolumeController 会不断地查看当前每一个 PVC,是不是已经处于 Bound(已绑定)状态
      • 如果不是,那它就会遍历所有可用的 PV,并尝试将其与这个未绑定的 PVC 进行绑定
      • 这样,Kubernetes 就可以保证用户提交的每一个 PVC,只要有合适的 PV 出现,它就能够很快进入绑定状态
      • 而所谓将一个 PV 与 PVC 进行绑定,其实就是将这个 PV 对象的名字,填在了 PVC 对象的 spec.volumeName 字段上
      • 接下来 Kubernetes 只要获取到这个 PVC 对象,就一定能够找到它所绑定的 PV

    1.4 持久化

    • 所谓容器的 Volume,其实就是将一个宿主机上的目录,跟一个容器里的目录绑定挂载在了一起
    • 而所谓的“持久化 Volume”,指的就是这个宿主机上的目录,具备“持久性”:
      • 这个目录里面的内容,既不会因为容器的删除而被清理掉,也不会跟当前的宿主机绑定
      • 这样,当容器被重启或者在其他节点上重建出来之后,它仍然能够通过挂载这个 Volume,访问到这些内容
    • 前面使用的 hostPath 和 emptyDir 类型的 Volume 并不具备这个特征:
      • 它们既有可能被 kubelet 清理掉,也不能被“迁移”到其他节点上
    • 所以,大多数情况下,持久化 Volume 的实现,往往依赖于一个远程存储服务,比如:远程文件存储(比如 NFS、GlusterFS)、远程块存储(比如公有云提供的远程磁盘)等等

    1.4.1 两阶段处理

    • 而 Kubernetes 需要做的工作,就是使用这些存储服务,来为容器准备一个持久化的宿主机目录,以供将来进行绑定挂载时使用
    • 而所谓“持久化”,指的是容器在这个目录里写入的文件,都会保存在远程存储中,从而使得这个目录具备了“持久性”
    • 这个准备“持久化”宿主机目录的过程,称为“两阶段处理”:
      • 当一个 Pod 调度到一个节点上之后,kubelet 就要负责为这个 Pod 创建它的 Volume 目录
      • 默认情况下,kubelet 为 Volume 创建的目录是如下所示的一个宿主机上的路径:/var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 类型 >/<Volume 名字 >

    1.4.1.1 Attach

    • 如果 Volume 类型是远程块存储,那么 kubelet 就需要先调用相应的 API,将它所提供的 Persistent Disk 注册到 Pod 所在的宿主机上
    • 这一步为虚拟机注册远程磁盘的操作,对应的正是“两阶段处理”的第一阶段
    • 在 Kubernetes 中,我们把这个阶段称为 Attach
    • Kubernetes 提供的可用参数是 nodeName,即宿主机的名字

    1.4.1.2 Mount

    • Attach 阶段完成后,为了能够使用这个远程磁盘,kubelet 还要进行第二个操作,即:格式化这个磁盘设备,然后将它挂载到宿主机指定的挂载点上
    • 这个挂载点,正是在前面反复提到的 Volume 的宿主机目录
    • 所以,这一步相当于执行:将磁盘设备格式化并挂载到 Volume 宿主机目录的操作,对应的正是“两阶段处理”的第二个阶段:Mount
    • Kubernetes 提供的可用参数是 dir,即 Volume 的宿主机目录
    • Mount 阶段完成后,这个 Volume 的宿主机目录就是一个“持久化”的目录了,容器在它里面写入的内容,会保存在远程磁盘中
    • 而如果你的 Volume 类型是远程文件存储(比如 NFS)的话,kubelet 的处理过程就会更简单一些
    • 因为在这种情况下,kubelet 可以跳过 Attach 阶段,因为一般来说,远程文件存储并没有一个“存储设备”需要注册在宿主机上
    • 所以,kubelet 会直接从 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 宿主机目录的“持久化”

    1.4.2 后续工作

    • 经过两阶段处理,就得到了一个“持久化”的 Volume 宿主机目录
    • 接下来,kubelet 只要把这个 Volume 目录通过 CRI 里的 Mounts 参数,传递给 Docker,然后就可以为 Pod 里的容器挂载这个“持久化”的 Volume 了
    • 其实,这一步相当于执行了如下所示的命令:docker run -v /var/lib/kubelet/pods/<Pod 的 ID>/volumes/kubernetes.io~<Volume 类型 >/<Volume 名字 >:/< 容器内的目标目录 > 我的镜像 ...
    • 在 Kubernetes 中,上述关于 PV 的“两阶段处理”流程,是靠独立于 kubelet 主控制循环(Kubelet Sync Loop)之外的两个控制循环来实现的:
      • Attach(以及 Dettach)操作,是由 Volume Controller 负责维护的:AttachDetachController(不断地检查每一个 Pod 对应的 PV,和这个 Pod 所在宿主机之间挂载情况。从而决定,是否需要对这个 PV 进行操作)
      • 作为一个 Kubernetes 内置的控制器,Volume Controller 是 kube-controller-manager 的一部分
      • 所以,AttachDetachController 也一定是运行在 Master 节点上的
      • Mount(以及 Unmount)操作,必须发生在 Pod 对应的宿主机上,是 kubelet 组件的一部分,叫作 VolumeManagerReconciler,是一个独立于 kubelet 主循环的 Goroutine
    • 通过这样将 Volume 的处理同 kubelet 的主循环解耦,Kubernetes 就避免了这些耗时的远程挂载操作拖慢 kubelet 的主控制循环,进而导致 Pod 的创建效率大幅下降的问题

    1.5 StorageClass

    • 一个大规模的 Kubernetes 集群里很可能有成千上万个 PVC,这就意味着运维人员必须得事先创建出成千上万个 PV
    • 更麻烦的是,随着新的 PVC 不断被提交,运维人员就不得不继续添加新的、能满足条件的 PV,否则新的 Pod 就会因为 PVC 绑定不到 PV 而失败
    • 在实际操作中,这几乎没办法靠人工做到
    • 所以,Kubernetes 提供了一套可以自动创建 PV 的机制,即:Dynamic Provisioning
    • 相比之下,前面人工管理 PV 的方式就叫作 Static Provisioning
    • Dynamic Provisioning 机制工作的核心,在于一个名叫 StorageClass 的 API 对象
    • 而 StorageClass 对象的作用,其实就是创建 PV 的模板
    • 具体地说,StorageClass 对象会定义如下两个部分内容:
      • PV 的属性。比如存储类型、Volume 的大小等等
      • 创建这种 PV 需要用到的存储插件。比如 Ceph 等等
    • 有了这样两个信息之后,Kubernetes 就能够根据用户提交的 PVC,找到一个对应的 StorageClass
    • 然后,Kubernetes 就会调用该 StorageClass 声明的存储插件,创建出需要的 PV。比如:
    apiVersion: storage.k8s.io/v1
    kind: StorageClass
    metadata:
      name: block-service
    provisioner: kubernetes.io/gce-pd
    parameters:
      type: pd-ssd
    View Code
    • 在这个 YAML 文件里,我们定义了一个名叫 block-service 的 StorageClass
    • provisioner 字段的值是:kubernetes.io/gce-pd,这正是 Kubernetes 内置的 GCE PD 存储插件的名字
    • parameters 字段,就是 PV 的参数。比如:上面例子里的 type=pd-ssd,指的是这个 PV 的类型是“SSD 格式的 GCE 远程磁盘”
    • 作为应用开发者,我们只需要在 PVC 里指定要使用的 StorageClass 名字即可,如:
    apiVersion: v1
    kind: PersistentVolumeClaim
    metadata:
      name: claim1
    spec:
      accessModes:
        - ReadWriteOnce
      storageClassName: block-service
      resources:
        requests:
          storage: 30Gi
    View Code
    • 在 PVC 里添加了一个叫作 storageClassName 的字段,用于指定该 PVC 所要使用的 StorageClass 的名字是:block-service
    • 通过 kubectl create 创建上述 PVC 对象之后,Kubernetes 就会调用 Google Cloud 的 API,创建出一块 SSD 格式的 Persistent Disk。然后,再使用这个 Persistent Disk 的信息,自动创建出一个对应的 PV 对象
    • 这个自动创建出来的 PV 的 StorageClass 字段的值,也是 block-service
    • 这是因为,Kubernetes 只会将 StorageClass 相同的 PVC 和 PV 绑定起来
    • 有了 Dynamic Provisioning 机制,运维人员只需要在 Kubernetes 集群里创建出数量有限的 StorageClass 对象就可以了
    • 当开发人员提交了包含 StorageClass 字段的 PVC 之后,Kubernetes 就会根据这个 StorageClass 创建出对应的 PV

    1.6 小结

    • PVC 描述的是 Pod 想要使用的持久化存储的属性,比如存储的大小、读写权限等
    • PV 描述的,则是一个具体的 Volume 的属性,比如 Volume 的类型、挂载目录、远程存储服务器地址等
    • 而 StorageClass 的作用,则是充当 PV 的模板。并且,只有同属于一个 StorageClass 的 PV 和 PVC,才可以绑定在一起
    • 当然,StorageClass 的另一个重要作用,是指定 PV 的 Provisioner(存储插件)
    • 如果你的存储插件支持 Dynamic Provisioning 的话,Kubernetes 就可以自动为你创建 PV 了

     2. CSI 插件体系的设计原理

    • 存储插件实际担任的角色,仅仅是 Volume 管理中的 Attach 阶段和 Mount 阶段的具体执行者
    • 而像 Dynamic Provisioning 这样的功能,不是存储插件的责任,而是 Kubernetes 本身存储管理功能的一部分,如图:

    • CSI 插件体系的设计思想,就是把这个 Provision 阶段,以及 Kubernetes 里的一部分存储管理功能,从主干代码里剥离出来,做成了几个单独的组件
    • 这些组件会通过 Watch API 监听 Kubernetes 里与存储相关的事件变化,比如 PVC 的创建,来执行具体的存储管理动作
    • 而这些管理动作,比如 Attach 阶段和 Mount 阶段的具体操作,实际上就是通过调用 CSI 插件来完成的。设计思路如图:

    • 这套存储插件体系多了三个独立的外部组件(External Components),即:Driver Registrar、External Provisioner 和 External Attacher
    • 对应的正是从 Kubernetes 项目里面剥离出来的那部分存储管理功能
    • 需要注意的是,External Components 虽然是外部组件,但依然由 Kubernetes 社区来开发和维护
    • 而右侧的部分,就是需要编写代码来实现的 CSI 插件
      • 一个 CSI 插件只有一个二进制文件,但它会以 gRPC 的方式对外提供三个服务(gRPC Service),分别叫作:CSI Identity、CSI Controller 和 CSI Node

    2.1 External Components

    2.1.1 Driver Registrar

    • Driver Registrar 组件,负责将插件注册到 kubelet 里面(这可以类比为将可执行文件放在插件目录下)
    • 而在具体实现上,Driver Registrar 需要请求 CSI 插件的 Identity 服务来获取插件信息

    2.1.2 External Provisioner 

    • External Provisioner 组件,负责的正是 Provision 阶段
    • 在具体实现上,External Provisioner 监听了 APIServer 里的 PVC 对象
    • 当一个 PVC 被创建时,它就会调用 CSI Controller 的 CreateVolume 方法,为你创建对应 PV
    • 此外,如果你使用的存储是公有云提供的磁盘(或者块设备)的话,这一步就需要调用公有云(或者块设备服务)的 API 来创建这个 PV 所描述的磁盘(或者块设备)
    • 不过,由于 CSI 插件是独立于 Kubernetes 之外的,所以在 CSI 的 API 里不会直接使用 Kubernetes 定义的 PV 类型,而是会自己定义一个单独的 Volume 类型

    2.1.3 External Attacher

    • External Attacher 组件,负责的正是 Attach 阶段
    • 在具体实现上,它监听了 APIServer 里 VolumeAttachment 对象的变化
    • VolumeAttachment 对象是 Kubernetes 确认一个 Volume 可以进入 Attach 阶段的重要标志
    • 一旦出现了 VolumeAttachment 对象,External Attacher 就会调用 CSI Controller 服务的 ControllerPublish 方法,完成它所对应的 Volume 的 Attach 阶段
    • 而 Volume 的 Mount 阶段,并不属于 External Components 的职责
    • 当 kubelet 的 VolumeManagerReconciler 控制循环检查到它需要执行 Mount 操作的时候,会通过 pkg/volume/csi 包,直接调用 CSI Node 服务完成 Volume 的 Mount 阶段
    • 在实际使用 CSI 插件的时候,我们会将这三个 External Components 作为 sidecar 容器和 CSI 插件放置在同一个 Pod 中。由于 External Components 对 CSI 插件的调用非常频繁,所以这种 sidecar 的部署方式非常高效

    2.2 CSI 插件服务

    2.2.1 CSI IdentityCSI 插件的 CSI Identity 服务,负责对外暴露这个插件本身的信息

    2.2.2 CSI Controller

    • CSI Controller 服务,定义的则是对 CSI Volume 的管理接口,比如:创建和删除 CSI Volume、对 CSI Volume 进行 Attach/Dettach,以及对 CSI Volume 进行 Snapshot 等
    • CSI Controller 服务里定义的这些操作有个共同特点,那就是它们都无需在宿主机上进行,而是属于 Kubernetes 里 Volume Controller 的逻辑,也就是属于 Master 节点的一部分
    • CSI Controller 服务的实际调用者,并不是 Kubernetes(即:通过 pkg/volume/csi 发起 CSI 请求),而是 External Provisioner 和 External Attacher
    • 这两个 External Components,分别通过监听 PVC 和 VolumeAttachement 对象,来跟 Kubernetes 进行协作

    2.2.3 CSI Node

    • 而 CSI Volume 需要在宿主机上执行的操作,都定义在了 CSI Node 服务里面

    2.3 小节

    • CSI 的设计思想,把插件的职责从两阶段处理,扩展成了 Provision、Attach 和 Mount 三个阶段
    • 其中,Privision 等价于“创建远程磁盘块”,Attach 等价于“注册磁盘到虚拟机”,Mount 等价于“将该磁盘格式化后,挂载在 Volume 的宿主机目录上”
    • 当 AttachDetachController 需要进行 Attach 操作时,它实际上会执行到 pkg/volume/csi 目录中,创建一个 VolumeAttachment 对象,从而触发 External Attacher 调用 CSI Controller 服务的 ControllerPublishVolume 方法
    • 当 VolumeManagerReconciler 需要进行 Mount 操作时,它实际上也会执行到 pkg/volume/csi 目录中,直接向 CSI Node 服务发起调用 NodePublishVolume 方法的请求。
    • 以上,就是 CSI 插件最基本的工作原理了

    3. CSI 插件部署

    3.1 常用原则

    • 第一,通过 DaemonSet 在每个节点上都启动一个 CSI 插件,来为 kubelet 提供 CSI Node 服务
    • 这是因为,CSI Node 服务需要被 kubelet 直接调用,所以它要和 kubelet“一对一”地部署起来
    • 此外,在上述 DaemonSet 的定义里面,除了 CSI 插件,我们还以 sidecar 的方式运行着 driver-registrar 这个外部组件
    • 它的作用,是向 kubelet 注册这个 CSI 插件
    • 这个注册过程使用的插件信息,则通过访问同一个 Pod 里的 CSI 插件容器的 Identity 服务获取到
    • 需要注意的是,由于 CSI 插件运行在一个容器里,那么 CSI Node 服务在 Mount 阶段执行的挂载操作,实际上是发生在这个容器的 Mount Namespace 里的
    • 可是,我们真正希望执行挂载操作的对象,都是宿主机 /var/lib/kubelet 目录下的文件和目录
    • 所以,在定义 DaemonSet Pod 的时候,我们需要把宿主机的 /var/lib/kubelet 以 Volume 的方式挂载进 CSI 插件容器的同名目录下
    • 然后设置这个 Volume 的 mountPropagation=Bidirectional,即开启双向挂载传播,从而将容器在这个目录下进行的挂载操作“传播”给宿主机,反之亦然
    • 第二,通过 StatefulSet 在任意一个节点上再启动一个 CSI 插件,为 External Components 提供 CSI Controller 服务
    • 所以,作为 CSI Controller 服务的调用者,External Provisioner 和 External Attacher 这两个外部组件,就需要以 sidecar 的方式和这次部署的 CSI 插件定义在同一个 Pod 里
    • 而像我们上面这样将 StatefulSet 的 replicas 设置为 1 的话,StatefulSet 就会确保 Pod 被删除重建的时候,永远有且只有一个 CSI 插件的 Pod 运行在集群中
    • 这对 CSI 插件的正确性来说,至关重要

    3.2 小结

    • 当用户创建了一个 PVC 之后,部署的 StatefulSet 里的 External Provisioner 容器,就会监听到这个 PVC 的诞生
    • 然后调用同一个 Pod 里的 CSI 插件的 CSI Controller 服务的 CreateVolume 方法,为你创建出对应的 PV
    • 这时候,运行在 Kubernetes Master 节点上的 Volume Controller,就会通过 PersistentVolumeController 控制循环,发现这对新创建出来的 PV 和 PVC,并且看到它们声明的是同一个 StorageClass
    • 所以,它会把这一对 PV 和 PVC 绑定起来,使 PVC 进入 Bound 状态
    • 然后,用户创建了一个声明使用上述 PVC 的 Pod,并且这个 Pod 被调度器调度到了宿主机 A 上
    • 这时候,Volume Controller 的 AttachDetachController 控制循环就会发现,上述 PVC 对应的 Volume,需要被 Attach 到宿主机 A 上
    • 所以,AttachDetachController 会创建一个 VolumeAttachment 对象,这个对象携带了宿主机 A 和待处理的 Volume 的名字
    • 这样,StatefulSet 里的 External Attacher 容器,就会监听到这个 VolumeAttachment 对象的诞生
    • 于是,它就会使用这个对象里的宿主机和 Volume 名字,调用同一个 Pod 里的 CSI 插件的 CSI Controller 服务的 ControllerPublishVolume 方法,完成 Attach 阶段
    • 上述过程完成后,运行在宿主机 A 上的 kubelet,就会通过 VolumeManagerReconciler 控制循环,发现当前宿主机上有一个 Volume 对应的存储设备(比如磁盘)已经被 Attach 到了某个设备目录下
    • 于是 kubelet 就会调用同一台宿主机上的 CSI 插件的 CSI Node 服务的 NodeStageVolume 和 NodePublishVolume 方法,完成这个 Volume 的 Mount 阶段
    • 至此,一个完整的持久化 Volume 的创建和挂载流程就结束了

    4. 总结

    • 通过学习,基本了解了 Kubernetes 持久化存储的基本原理和流程
    • 当前内容还是以张磊老师的原文为主,后续还需要继续思考和提炼
    • 本文所有涉及的知识点汇总至图 Kubernetes 容器持久化存储 中,刚兴趣的同学可以点击查看

    5. 参考文献

  • 相关阅读:
    week2 About BuildTools
    week2 GIT and Version Control
    学习RFC相关知识以及心得
    Week 1 Functional Language
    8.C语言_中文字符存储问题
    7.C语言_字符与字符串
    6.C语言_整数类型
    4.C语言_变量与参数的内存布局
    3.C语言_变量
    2.C语言_参数传递与返回值
  • 原文地址:https://www.cnblogs.com/wangao1236/p/11152023.html
Copyright © 2011-2022 走看看