TOC

Kubernetes资源类型

    Kubernetes的主节点或者说控制面板当中主要有三个组件,其中ApiServer可以被理解为整个系统的网关,那么ApiServer借助于Cluster Store也就是etcd来保存当前集群资源状态的定义,但是etcd本身是一个通用的键值存储系统,这个存储系统可以存储用户所定义的任何可流式化的数据, 事实上各种各样的分布式应用程序几乎都会用到一个高可用的存储系统,所以etcd的应用领域是非常广泛的,但是ApiServer将etcd所提供的这种存储接口做了高度抽象,使得用户通过ApiServer来完成数据的存取时只能使用ApiServer内建支持的数据范式,抽象成用户不能随意存储键值数据,而只能根据它所定义的格式来存储数据,比如一个本地文件系统可以存储各式各样的数据包括结构化非结构化的,但是我们一旦在本地文件系统之上额外附加了一个抽象层,比如关系型数据抽象层MySQL,而后我们向文件系统能存入的数据就不再是任意数据了,而必须是能遵循MySQL的支持的数据范式的数据;
    但是如果说,在某种情况下,我们所期望的管理资源或管理的存储对象,在现有的Kubernetes不能满足时,比如Pod,Service,Deployment,PV等,如果这些核心的基础资源,或者更高级的抽象资源无法满足用户需求的怎么办,比如我们要想管理一个更加抽象的利用基础资源完成redis集群的管理,在Kubernetes之上托管一个Redis Cluster,如果要想管理Redis Cluster这样的集群架构的话,在Redis之上,用户存储数据时,通过数键的hash计算来计算数据项落在Redis的哪一个节点之上,因为Redis Cluster是一个有状态应用,因此要使用StatefullSet来管理,但是StatefullSet本身是有缺陷的,它虽然能让我们的来管理有状态应用当中的每一个Pod实例,拥有自己固定的名称标识,拥有自己专用的存储系统等等,但是它却没办法去帮用户处理这种分布式的协作系统内部的,比如扩容缩容以及备份恢复的各种管理逻辑;
    那么这个时候我们一般用的是Operator来管理,因为Operator本身上就是建构在Statefullset以及Pod Service等基础的Kubernetes资源之上由开发者自定义的更高级的更抽象自定义资源类型,因为它可以借助于底层的Pod和Service等功能,再一次抽象出一个比如就叫做Redis Cluster的资源类型,当我们的创建一个资源叫做Redis Cluster的时候,它能够下载到Redis镜像,启动为一个Pod,并且整个集群本身可以统一抽象为一个Redis Cluster资源,无论有多少个Pod,都被抽象为一个单一资源,但是这个单一资源至少会用到Pod,Service或者是volumes,但是这些资源只是底层拿来被作为调用的资源类型,我们高级中就有这么一个资源类型就叫做Redis Cluster;
    它大概是这样的逻辑,Redis Cluster这个资源,我们如果要创建的时候,它是一体把整个Cluster创建出来,但是这个Cluster有可能会调用底层的Pod、Service等类型的资源,才能让这个Cluster工作起来,但就像使用Deployment一样,我们创建一个Deployment,它底层会自动去调用Pod,我们只需要给定有几个Pod副本,它会自动的去创建足量的Pod副本;
    如果我们要创建一个Redis Cluster资源,它也是一样的逻辑,通过编排机制,创建出足量的Pod,足量的Service的,足量的volume,甚至如果有必要的话,有可能还有其他资源,但是Redis Cluster这个资源类型本身在Kubernetes是没有内建的,因此为了实现更高级的资源管理,我们就需要利用已有的基础资源类型,给他们做一层更高级的抽象,来定义成更能符合用户需要的而且可以单一管理的资源类型,而不至于用户还要分类的去管理这些每一个资源,因为这样子就违背了一个编排系统所应该拥有的编排的意义,Kubernetes官方并没有给出这种特定资源的资源类型,因为这种应用逻辑是非常非常具体的,因此这应该是一个项目的第三方维护者所需要完成的任务,比如Redis Cluster那么这应该Redis这个项目维护的官方来完成这个任务;
    因为每一个应用所需要调用的资源和逻辑各不相同,因此Kubernetes所需要做的就是为有这样需求的项目或者个人提供一个开发接口,让这些人能够自己去开发Kubernetes本身所不具备的这些功能,从而能让他们去定义自己的应用以更加优化的方式管理他们所理解的资源管理机制,但是在Kubernetes之上我们向ApiServer提交一个Pod创建时,这个Pod本身的创建的请求是由ApiServer存储在etcd当中的,但是Pod应该是一个具体的容器的外壳,那也就意味着一个Pod被用户提交以后,它的核心目标是应该运行在Kubernetes集群当中的某个节点之上的,运行为一个容器,但是现在有一个问题,谁来负责把这个Pod跑起来呢,这个容器一旦出现故障之后,如何在必要时重启销毁容器等相关管理操作,对于自助式Pod或者由Pod控制器控制的Pod来讲,它是由每一个节点的Kubelet来负责监视ApiServer资源的变动,并且在必要时将自己负责监视的Pod的定义拉去过来然后在本地将Pod运行起来,这里有一个非常重要的组件是Kubelet,如果没有Kubelet那么可能调度器调度一下就结束了,Kubelet是借助于本地的容器引擎去运行的,这也就是为什么每一个节点都得部署一个Docker的原因;
    如果用户不小心删除的Pod,那这个Pod在etcd当中就没了,在etcd当中没了,也就意味着Kubelet发现了资源状态这个Kubelet发现了这个资源状态变动了之后,它也只需要把容器停掉并删除就可以了,但是这个Pod是被误删除的它也不会被重建,因此这个时候我们就需要一个Pod控制器,用Pod控制器来控制Pod,如果用户一不小心删除了Pod,在etcd当中这个Pod的定义也确实不存在了,在节点之上这个Pod也确实被删掉了,但由于控制器没有删除,控制器要求得有足量的Pod存在,因此它会再一次通过自己内部的和解循环,这个和解循环的工作逻辑是,在正常情况下我们的资源如果没有变动,但是一旦资源不符合用户所定义的期望时,它一定会检查到用户定义在etcd当中的期望状态与运行在节点之上的当前状态有何种不同之处,更重要的是它接下来要执行一段控制器内部的代码,由这代码来确保当前的状态要吻合用户期望的状态,如果不符合用户所期望的状态,那么Kubernetes就会将其转为吻合用户所期望的状态,这也就是和解循环的功能;
    因此控制器是确保Kubernetes设计范式当中叫声明式配置接口得以运行的基础逻辑,用户只需要告诉Kubernetes来一个Pod,或者来一个Pod控制器,这个Pod控制器有几个基本需求,比如有三个Pod副本,至于接下来如何去运行这三个Pod副本,用户无需关心,由Kubernetes自行通过控制器中的工作逻辑来实现,所以说控制器来是整个Kubernetes的大脑;
    对于Kubernetes来讲,每一个控制器应该都是一个守护进程,核心控制器有很多个,所以不便一一运行为守护进程,因此将他们打包成为了一个单一守护进程kube-controller-manger,它在运行过程当中看上去只有一个守护进程,但它内部运行着每一个控制器的和解循环,监视器其所关联的每一个或者多个相关的资源类型,比如Pod控制器的守护进程就要关乎Pod控制器自身也要关注Pod控制器逻辑之下所管理的Pod,Controller Manger负责管理用户自己创建的各个Controller ,这个Controller再去负责下面的每一个Pod是否工作正常;
    如果没有运行kube-controller-manger,我们向APIServer请求创建一个deployment,首先我们将这个请求提交给APIServer,APIServer把它保存在etcd当中就行了,但是接下来就必须得有一个保存etcd当中的定义,必须能够转为Kubernetes集群上真真正正的组件,比如用户给定的就是一个yaml清单,这些yaml格式的配置清单无非就是一些数据项,当你提交给APIServer进行认证授权等评估之后将其存入到etcd之后APIServer的使命到这个时候也就结束了,但是如果是一个deployment控制器,它要真真正正的在整个集群节点上以Pod方式运行起来,运行Pod不是APIServer的事,它是kube-controller-manger当中的controller的工作,controller也是APIServer的客户端,它要监视与自己相关的资源,比如我们创建了一个deployment,这个deployment中的定义需要由kube-controller-manger为其生成一个和解循环,这个和解循环就代表这个deployment控制器,它主要是用来检测用户期望的状态和当前的实际状态,如何不符合用户的请求,那么于是它自己直接要指挥着整个集群,创建出这个三个Pod来,因为任何资源的增删改查都要经过ApiServer,所以它会请求ApiServer把Pod创建出来,那么这个时候我们的ApiServer就会进行调度,调度到节点之后,那么这个时候就是我们节点上面的Kubelet的事了,由Kubelet来去创建Pod;
    那么如果万一用户删除了这个Pod,于是ApiServer当中获取到了etcd的Pod资源数量也发生了变动,一旦发生变动之后这个和解循环通过标签选择器选择到的Pod数量就少了一个,那么此时和用户所期望的数量一对比少了一个,因此它会再向ApiServer请求,创建一个新的,而这个创建的定义放在etcd当中,由Scheduler来负责调度,调度到对应的节点,然后对应节点的Kubelet收到信息之后,于是再创建出一个Pod,大概内部就是这么一个逻辑,所以说Controller是至关重要的;

CRD

    那么通过上面的讲解,如果使用了自定义资源类型,那么我们的ApiServer可以支持用户自定义的资源定义格式,比如说用户自己的新建了一张表,那么其他人就可以在这个表当中插入行数据了,比如这个表有20个资源,这个表也是我们的ApiServer上实现的,那么此时用户就可以向里面插入数据了,那么先的问题是,插入了这一行数据如果对应它调用了基础资源类型,比如Pod、Service等等,而Pod的创建,不是由Pod进行自我管理的,Service创建也不是由Service自我管理的,而是由各自Pod控制器,Service控制器来管理;
    但是现在这是一个自定义的资源类型,假如没有使用Pod控制器,也没有使用Service控制器,请求是可以存储在etcd当中的,但它并不能实实在在创建起来,因此我们只有自定义资源的类型,是远远不够的,必要的时候如果自定义资源调用自定义的内部的控制器就能够完成任务,那固然很好,那也就意味着我们既要封装基础资源,还要封装内部的控制器资源,那如果没有任何一个内建控制器能满足你的复杂的自定义的资源类型的话,我们就需要额外配套开发一个控制器,那么你的自定义资源才能工作起来的,所以说在Kubernetes之上资源和控制器的密不可分的;
扩展Kubernetes所支持的方式
CRD:自定义的资源类型,先定义类型,而后还要定义CRD类型的资源,它的前身是TPR,也就是第三方资源,它也是最简单的一种方式,因为不需要Kubernetes自己的源代码,使用内建的CRD类型的资源创建出一个自定义的类型,然后再在这个类型创建出自定义的资源;
自定义ApiServer:如果我们是一个Go或者是一个Python程序员的话,你可以自己写一段代码,这个段代码就是一个自定义的ApiServer,其功能类似内建的ApiServer一样,自己写的ApiServer可以附加在原有的ApiServer之上,作为一个Pod来跑,与内建的ApiServer进行关联,里面只不过又额外扩展ApiServer的资源定义而已,额外提供其他的类型的资源;
修改原有ApiServer源码:修改原有ApiServer源码添加自定义资源类型的定义,从而加入我们所需要的自定义资源类型;
CRD
    Kubernetes内部有一个资源类型叫做CRD,因为每一种对应的资源类型,我们都可以拿来创建一个对应的资源,比如Pod这是一种类型,于是我们可以创建一个Redis Pod,创建一个Nginx Pod都没问题,这叫实例化,同样的,它既然有CRD类型的资源,那我们也可以创建CRD类型的资源,比如myapp的CRD,但是虽然我们说myapp是一个类型的实例化资源,但是CRD不同,由CRD创建出来的资源本身也是一个资源类型,而我们的Pod创建的资源就是一个资源,和我们的CRD不同,所以我们接下来,还要根据这个CRD的资源实例化出来的资源类型的定义再实例化出具体的CRD类型的资源来;
    我们既要定义出类型来,又要定义出资源来,为什么要这么做呢,因为内建的类型可能不足以符合我们的需要,因此,比如我们要管理一个Redis Cluster,因为Redis Cluster需要调基础的Basic类型的资源还有一些略高级的一点资源,但是我们需要更高级的资源,利用内建的多种内建资源组合成一个更加复杂的高级的资源,它就叫做Redis Cluster,于是我们就不得不自定义了,于是我们就可以使用CRD这个资源定义出一个类型来,就叫做Redis Cluster类型,然后在这个Redis Cluster类型上再去创建一个Redis Cluster;
自定义ApiServer
    因为我们Kubernetes本身自己就有ApiServer,原有的ApiServer是不动的,依然能用,而自定义ApiServer本身可以运行在整个Kubernetes集群上作为一个Pod来跑,所以我们自己写一个ApiServer,并且把这个ApiServer定义成一个镜像,把它跑成我们整个Kubernetes之上的一个Pod,它里面只不过有扩展内建Kubernetes的资源定义而已,但是这些资源调用不到,因为客户端访问ApiServer时,唯一入口就是内建的ApiServer,因此,用户的自定义资源检查是否合理的时候,都是由内建的ApiServer来检查的,如果我们使用了一个自定义格式的资源类型,而内建ApiServer不支持默认是被拒绝的,并且会告诉Kubernetes无法识别这个资源类型,因为自己定义的资源Pod只是当前Kubernetes集群上一个托管的Pod资源而已,那因此我们接下来就需要让自定义ApiServer与内建的ApiServer建立起关联关系来;
    ApiServer对外是一个ApiServer程序,其实内部有两个自建,第一个组件是代理组件Aggregator,第二个组件才是我们的ApiServer,只不过它打包成了一个单一的ApiServer程序而已,用户去访问ApiServer时,事实上是先到达代理的Aggregator,由代理再代理给其内部内建的ApiServer程序,由它来负责执行用户所请求的任务,这个代理服务器我们成为ApiServer的聚合器,叫Aggregator,这个聚合器能够将对ApiServer的请求进行路由,如果我们自定义的一个ApiServer并且在Kubernetes集群之上运行为一个Pod,那么这个时候我们的Aggregator也可以直接将请求转发给这个自定义ApiServer的Pod;
    所以用户去创建某一个资源类型时,Aggregator会根据这个所谓的路由逻辑,比如Nginx的动静分离,动态资源分配给动态服务器,静态资源分配给静态服务器,从这个角度来讲Aggregator代理服务器本身就是一个路由器,所以Aggregator必须得知道在何种情况下代理给谁,如果没有做额外的任何路由策略的话,Aggregator会默认把所有的请求直接代理给内建的ApiServer,如果需要代理给我们自定义这个服务器时,我们就需要在Aggregator上添加一条路由信息,只不过这个路由信息,这路由信息称之为ApiService,也就是说我们在Aggregator或者说在Kubernetes内部自定义一个ApiService就相当于说在Aggregator之上添加了一条路由信息,这个ApiService主要功能是告诉Aggregator对于哪些资源类型,我们应该代理给哪个或者哪些外部的自定义ApiServer上去的,如果没添加这个定义,默认都到内建的ApiServer;
    而ApiService也是Kubernetes内建的一种资源类型,我们需要在部署好一个ApiServer以后还要在内建的ApiServer上定义一个Aggregator资源类型的ApiService,使得用户请求某些符合这个ApiService资源类型的格式时能够被路由至自定义的ApiServer上来,而非内建的ApiServer;
自定义控制器
    无论是哪一种方式去扩展Kubernetes资源类型,扩展完以后,你最多只能定义出资源来,但是这个资源本身能不能跑起来那已经不是资源本身的问题,而应该是控制器的事情了,那因此,接下来还需要控制器,来控制所自定义的资源,否则我们能创建资源这个资源最多只能保存在etcd当中,有一些资源,特别是必须要运行程序时,那么通常必须定义控制器,以确保Pod能跑起来;
    不一定每一种自定义资源都需要控制器,比如你所定义的这个自定义资源类型,内部它使用了deployment来控制Pod,它调用Service控制器来控制Service等等,我们只是把他们组合起来,那么这个时候只要资源被创建,那么这个资源本身所关联到的控制器也会创建,那么这个控制器就会自行进行控制其他资源;
    但是如果要完成一些复杂逻辑的自定义模式的自定义资源,那么就需要自己创建控制器了,比如我们定义一个Redis Cluster,很显然使用原生的deployment或者statefulset是无法达到要求的,正是因为我们需要自定义资源类型来完成内建类型所不具备的功能,很显然我们就必须要自定义控制器;
    自定义控制器是一样的逻辑,和我们的自定义资源差不多,所谓自定义控制器,就理解为它运行为一个Kubernetes之上托管的Pod控制器程序,这个程序可托管于Kubernetes之上以Pod形式运行,当然也可以在Kubernetes集群之上找一个主机专门来运行也可以,自定义控制器程序的工作方式正常的代码逻辑是这样的,因为控制器都是ApiServer的客户端,自定义控制器也是一样的,它需要作为ApiServer客户端连入ApiServer之上,并且注册监听,它所关心的资源的变动,如果有变动ApiServer就会通知给它,那因此它内部应该有一段代码,可以有获取资源的当前状态和用户的期望状态比较二者不同之处,来确保两者是一样的,进行和解循环;
    当然功能更强大的控制器,比如官方的Redis Cluster,它不光能检查不同,而且必要时应该还可以更为简单的方式让用户触发备份恢复等操作,还有一些扩展并不是和解循环所需要的功能;
    因为在Kubernetes集群中,Pod是可以作为ApiServer的客户端运行的,因此ApiServer的客户端有可能来自于集群中,它也可以在集群外,只要有集群外的可达地址就行了,至于究竟应该运行为Pod,还是一个宿主机的服务,那是用户自行考虑的,但一般来讲,我们应该让他们运行为Kubernetes之上的Pod,这样才能使得不至于有脱离于Kubernetes系统之外的内容;
    它既然以Pod来运行,那么此时它也应该由Pod控制器来管控,它本身就是一个控制器,那么这个控制器运行为Pod,并且受到Pod控制器的管控,所以好处在于,如果这个控制器宕机了,这个Pod控制器也能确保将这个控制器的Pod重建出来,如果没有托管于Kuernetes之上,那就需要你自己手动管理;
    虽然看上去CRD,确确实实不用写代码,利用Kubernetes的内建功能使用标准的内建资源类型,就能定义出自定义类型来,但是这个自定义类型的工作逻辑则有可能依然比较复杂,因为这里定义的只是资源类型,而资源类型要想能够实现具体的操作得靠控制器,而控制器只能是代码,没有别的选择性了,好在如果能绕过去,不需要写代码就能够完成自定义资源类型实现管理功能,那固然很好,但通常情况下是不如人意的;
    于是就有很多人可能就需要自定义控制器了,因为既然用到了自定义资源类型,大部分也需要自定义控制器,那么为了能够更快速的开发自定义控制器程序,互联网上就有人开发了一些开发框架,因为开发一个控制器它通常都需要特定的代码格式,那么我们就可以使用了这个开发框架能快速生成代码的一个框架;
    于是,就有些人开发出了自定义控制器的开发框架,但是在这个逻辑上这个开发机制仍然不是特别的完善,而且没准自定义的控制器和自定义的资源类型他们是分隔开的,因此,我们能不能用一种更加便捷的方式来实现这些功能呢?
    从某种意义上来讲,自定义控制器的开发框架,还是kubernetes原生的,于是后来就有了另外一种自定义控制器程序的开发框架,但是它不称为自定义控制器程序的开发框架,而且成为Operator SDK开发工具箱,事实上它和上面说的有人开发的开发框架没什么本质的区别,都是自定义控制器,只不过早些时候,在Kubernetes的社区当中,人们把它称之为自定义控制器,但是CoreOS,他们在同时也研发了一种扩展Kubernetes自定义控制器的方式,只不过,他们把它取名为Operator,现在甚至可以理解为两个不同的阵营了,那么显然Operator是占据了上风的,而Operator SDK它也是一个开发工具箱开发框架,能够让用户快速的去开发一个自定义控制器,只不过,它被称之为Operator,而且在这个Operator SDK内部,我们也可以使用这一种机制,我们可以把ApiServer这个功能直接整合进Operator内部来,于是我们把Operator SDK所开发的Operator部署在Kubernetes之上作为一个Pod运行时,一方面既可以作为自定义的ApiServer使用,同事这个自定义的ApiServer自定义的资源类型,其内部本身就带有自定义代码,以确保这些自定义资源能够被管理,也就是说一个Pod可以作为自定义ApiServer使用,又可以作为自定义控制器使用;
    当然也可以把Operator SDK所开发的Operator与CRD结合使用,他们其实没有本质区别,比如有一个比较流行的Kubebuilder;
    到这里可以发现Operator其实大部分就是为了管控有状态应用的,有状态应用几乎也都要引入第三方的或者自定义的资源类型;
CR及CRD
    在Kubernetes的API当中,一个资源通常代表一类对象的结合,所谓这个资源就是这个类型,用一个专用的URL路径端点进行表示:
CRD spec
    CRD本身也是Kubernetes API支持资源类型之一,它有着专用的spec定义;
group:该CRD属于哪个API群组;
version:该CRD属于哪个API群组的哪个版本;
versions:该CRD属于哪个API群组的哪些版本;
scope:指定改资源是集群级别还是名称空间级别;
validation:对用户提交的配置清单做字段校验;
CRD names
kind:告诉用户定义在CR时使用的kind是什么,首字母必须大写;
plural:CRD名字的复数形式,必须使用小写字母;
singular:CRD名字的单数形式,必须用小写字母,默认为kind中字符的小写格式;
shortNames:CRD名字的简写格式,短格式,必须使用小写字母;
listKind:此CRD相关的列表资源名称,比如Pod的列表资源名称是podList,Service的列表资源名称是serviceList;
categories:当前CRD资源所属的资源类型,例如“all”,如果没有这样的类别回头用户使用kubectl get all是不会显示这个资源的;
CRD和CD示例
    这里只是给出了CRD和CR的一些基础示例,一般来讲CR还是需要借助于自定义控制器来管理的,但是一般人都不具备这样的开发功能,所以此处只是给出了自定义CRD的示例而已,仅做CRD的基础了解;
// CRD
apiVersion: apiextensions.k8s.io/v1beta1  # CRD自身也是个群组,也需要指明内建CRD的API群组
kind: CustomResourceDefinition # 属于的类型
metadata: # 自定义CRD的元信息
  name: users.auth.magedu.com # 自定义CRD的名字
spec:
  group: auth.magedu.com # 自定义CRD属于哪个群组
  version: v1beta1 # 自定义CRD属于版本
  names: # 内嵌字段
    kind: User # 定义CR的时候需要指明这个CRD的类别,也就kiind
    plural: users # 复数形式
    singular: user # 单数形式
    shortNames: # 简写形式
    - u
  scope: Namespaced # 作用域

// CR
apiVersion: auth.magedu.com/v1beta1 # CR版本
kind: User # CR类型
metadata:
  name: admin # 这个CR的名字
  namespace: default # 这个CR所属的namespace
spec: # 下面下这字段是在CRD里面没有定义的,那么就说明这些字段用户可以随意给定
  userID: 1 #
  email: k8s@magedu.com
  groups:
  - superusers
  - administrators
  password: ikubernetes
创建一个CRD并且带数据校验
# 定义CRD
[root@node1 chapter13]# cat users-crd-with-validation.yaml
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: users.auth.ilinux.io # CRM名称
spec:
  group: auth.ilinux.io # 该CRD属于哪个API群组
  version: v1beta1 # 该CRD属于哪个API群组的哪个版本
  names:
    kind: User # CRD的类型名称
    plural: users # 复数格式名称
    singular: user # 单格式名称
    shortNames:  # 段名称
    - u
  scope: Namespaced # 该CRD属于namespace级别的资源
  validation: # 定义数据校验
    openAPIV3Schema:
      properties:
        spec:
          properties:
            userID: # 限制userID字段必须为integer并且最小为1最大为65535
              type: integer
              minimum: 1
              maximum: 65535
            groups: # 定义groups为一个array
              type: array
            email: # 定义email为一个string
              type: string
            password: # 定义password为string
              type: string
              format: password
          required: ["userID","groups"]  # 必须的字段
# 创建CR
[root@node1 chapter13]# cat users-with-invalid-field.yaml              
apiVersion: auth.ilinux.io/v1beta1
kind: User
metadata:
  name: tony
  namespace: default
spec:
  userID: 999999
# 可以看到不符合数据校验要求的CR是无法通过的
[root@node1 chapter13]# kubectl apply -f users-with-invalid-field.yaml    
error: unable to recognize "users-with-invalid-field.yaml": no matches for kind "User" in version "auth.ilinux.io/v1beta1"

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注