一、应用部署流程认识
上一小节中,我们的通过上一次的部署一个nginx应该分析一下一个应用部署的流程大概会经过那几个阶段调用,这样对后续整个运行过程和排查问题也有一定好处。
回顾我们的k8s的整体基础架构(图来自网络,侵删!):
- master节点主要包含有:
- kube-apiserver :负责访问入口,和接收相关信息指令经常和etcd进行通信)
- etcd: 主要是保存集群状态和节点数据或其他数据信息
- kube-scheduler :主要负责进行节点调度,Pod节点调度管理,监控节点状态等
- kube-controller-manager:负责监控集群状态,控制节点、副本、端点、账户与令牌等
- cloud-controller-manager: 负责管理控制和云交互的节点、路由、服务、数据卷等
- node工作节点主要包含有:
- kubelet:负责操作和管理POD
- kube-proxy:负责节点上运行的网络代理,维护节点网络转发规则
- 容器运行时环境CRI:负责实现支持多种K8sCRI的容器技术(不限于Docker)
图示是一个简单流程概要图:
具体整体的步骤大致为(仅仅是我个人理解):
1:Deployment资源类型对象
Deployment(部署,调度的意思) 是k8s资源对象一种,主要是用于提供一种比较便捷的机制,来定义更新RC和Pod。用于更好的进行Pod编排问题。可以理解其实Deployment是对Pod一种管理的实现的机制。
1.1 Deployment特性:
-
可以定义一组Pod的副本数目
-
可以查看当前应用部署的时间和状态
-
通过控制器Controller维持Pod数目(可以轻松的对版本进行回滚操作,或异常情况下自动恢复失败的Pod)
-
可以指定相关滚动升级策略和版本控制策略
- 滚动升级是值指:采取一个一个进行替换策略升级Pod,避免同一时间全部迭代升级全部Pod出现的服务中断。
-
用于部署无状态应用
-
管理理Pod和ReplicaSet(副本控制)
PS: 无状态应用:不依赖上一次的会话请求的客户端数据,下一次的不需要携带,也可以访问应用
有状态应用:依赖客户端会话数据,每次的新的请求都需要携带最初数据
1.2 Deployment和ReplicaSet:
2:pod资源类型对象
- 是对容器的一种组织管理方式
- 一个Pod可以只有一个容器也可以有多个容器组成
- 一个Pod中所有的容器共享同一个网络(根容器)
- 是K8s最小的调度单元
- 是 Kubernetes 抽象出来的,表示一组一个或多个应用程序容器
- 运行再Node工作节点上
- Pod被运行在工作节点的上的Kubelet进程进行管理
- 每个Pod都有属于自己的Ip地址
2.1、pod调度流程:
-
1、通过Kubectl提交一个创建RC (Replication Controller)请求(其实就是编排好yaml资源应用描述),然后我们的请求通过APIServer之后被写入etad数据库
-
2、Controller Manager监听到RC事件,根据节点情况分析pod的实例情况,如果我们的当前集群的节点没有Rc模板中定义的Pod对象,此时他会根据模板生成对应模板里面描述的Pod对象的实例,且通过APlIServer把实例的信息写入etod数据库
-
3、Scheduler监听发现RC模板对pod实例的调度指令,根基模板要求执行相关节点对Pod安置或启动调度流程,为这个最后安置调度成功后,继续通过API Server讲这一结果写入到etad中,存贮节点信息
-
4 、目标工作节点Node上运行的Kubelet进程通过APIServer监测关于Pod的安置处理,根据模板定义相关信息,启动该Pod,
2.2、Pod 创建流程:
- 1:首先RC模板定义我们的Pod信息
- 2:Controller Manager监听到RC事件创建Pod,并存储到Etcd
- 3: Master根据Scheduler的调度管理,进行Pod的调度和Node节点进行绑定
- 4:调度到Node节点上的Pod被Node节点上的kubelet进程进行实例化相关模板定义总Pod实例对象(其实就是启动容器对象组)
- 如果存在POD异常停止,k8s自动检测并重启相关Pod
- 如果Pod所在的节点异常故障,则会将故障Node节点的Pod自动调度到其他Node节点,保证预期足够的Pod副本数
2.3、Pod 访问流程(Service对象):
Service是k8s中音资源对象之一。
-
它的存在主要是是为相同标识的一系列Pod提供唯一的访问地址,定义一组Pod的访问策略
-
它使用的唯一的地址(ClusterIP)仅当再集群内的容器才可以通过这个IP访问到Servier
-
Service可以通过定义Label Selector(Label查询规则)来选择Pod对象实例
-可以理解为是它是Pod的负载均衡,提供一个或者多个Pod的稳定访问地址
- 甚至它还支持多种方式(如:ClusterIP,NodePort,LoadBalance)
-
1、通过Kubectl创建一个Service资源类型,并映射到对应的到该Pod
-
2、 ControllerManager通过我们定义的Label标签查询到关联的Pod实例对象,然后生成了当前一组Pod的Service的Endpoints信息,然后通过APlServer写入到etad
-
3、Node上运行的Proxy进程通过APIServer查询和监听Service对象和pod绑定生成Endpoints信息,建立类似负载均衡器的方式来实现了通过Service访问到后端Pod的流量转发
2.4、Pod 中的(Service和Label绑定):
Label的作用:
- 对资源对象打标签,用来归纳资源对象组,对对象资源查询筛选
Label一些说明:
-
可以对Pod、Service、RC、Node 等进行标记
-
一个资源对象可以定义多个label
-
一个label可以对应多个资源对象上
-
label创建后可以动态的添加和删除
-
Service、Deployments 和 Pods 之间的关联都是通过 label 来实现的
-
所以Node每个节点也可以都拥有 label
-
通过设置 label 相关的策略可以使得 pods 关联到对应 label 的节点上
2.5、pod资源对象的探针健康监测机制:
探测情况的官方文档地址:
-
startupProbe探针 (启动检查):
- 用于判断容器内应用程序是否已经启动,如果配置了startuprobe,就会先禁用其他的探测,直到它成功为止,成功后将不再进行探测,这个是对一个Pod启动情况的检查机制。
-
livenessProbe探针(存活检查):
-
探测的是容器是否运行
-
根据用户自定义规则来判定pod对象健康状态,如果livenessProbe探针探测到容器不健康,则kubelet会根据启动的时候Pod重启策略来决定当前的不健康的Pod是否重启
-
如果一个容器不包含livenessProbe探针,则kubelet会认为容器的livenessProbe探针的返回值永远成功。
-
-
ReadinessProbe探针 (就绪检查):
-
探测的是的容器内的程序健康情况,如果它的返回值为success,那么就代表这个容器已经完成启动,且程序已处于可以接受流量的状态.
-
根据用户自定义规则来判定pod对象健康状态,如果探测失败,控制器会将此pod从对应service的endpoint列表中移除,从此不再将任何请求分发调度到这个Pod上,直至下次探测成功为止。
-
Label在Pod中扮演的角色:
-
Pod定义的时候,可以设置对应的Label(给Pod打标签)
-
Service定义的时候,给它指定Label Selector
-
在进行访问的时候, Service的负载均衡是通过Node节点上的kube-proxy 进程通过 Service中定义的Label Selector 来选择对应的 Pod组对象,且自动创建Service 中绑定的Pod的请求转发路由表来串联起来的。
3:关于控制器的几种形式
- Deplotment :无状态应用部署
- StatefulSet :有状态应用部署
- Daemonset:确保所有Node都运行一个指定Pod
- Job :一次性的任务资源类型
- Cronjob: 定时任务类型
- ReplicaSet:确保预期Pod副本数量(使用Deplotment的时候,我们不应该再手动的管理Deplotment创建出来的 ReplicaSet的副本)
二、应用部署yaml文件认识
从上一小节的介绍中,其实我们的可以看到,后续我们的部署应用,进行相关资源创建调配什么的,基本都是编写一个yaml,然后直接的应用更新,一个ymal文件基本上是对K8S 资源对象的说明,所以这里有必要的对我们的yaml文件进行相关的字段信息进行了解一下:
1:nginx示例解析:
关于应用部署的使用yaml文件:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
selector:
matchLabels:
app: nginx
replicas: 2 # tells deployment to run 2 pods matching the template
strategy:
rollingUpdate:
maxSurge:1
maxUnavailable:1
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80
字段信息说明:
-
apiVersion 必填属性,填字符串类型 和我们的k8s版本有关联,通常需要根据打当前安装的Kubernetes版本和资源类型进行变化
-
kind 必填属性,填字符串类型 是资源的类型,主要可以有几种:
- Deployment
- Job
- Ingress
- Service
-
metadata 必填属性,填字符串类型 表示的是当前kind所属的资源类型的一些元数据信息,主要的可以描述元数据信息有
- name 必填属性,填字符串类型 : 资源类型的名称
- namespace 必填属性,填字符串类型 当前资源类型所属命名空间
- labels 可选: 自定义标签
- xxxxx: xxxx 自定义标签的名称
- annotations 自定义的注释的列表
- xxxxxxx
-
spec 关于资源详细的定义描述(资源规格描述)
- selector 资源的选择器(通过标签来刷选所属的pod副本)
- matchLabels: #选择器使用的匹配的标签
- app: nginx # 标签的名称
- matchLabels: #选择器使用的匹配的标签
- selector 资源的选择器(通过标签来刷选所属的pod副本)
-
replicas 当前资源期望的pod副本数是多少个
- strategy 副本策略:
- rollingUpdate 副本更新策略:因为replicas为2,所以升级的时候,pod个数会会再2-3个之间,
- maxSurge 滚动升级的时候,滚动升级的Pod个数
- maxUnavailable 滚动升级的时候允许最大的Unavilable的pod数
- strategy 副本策略:
-
template 资源使用的模板(主要是包括镜像,容器元数据的描述)
-
metadata :模板的所包含的元数据信息
- labels 模板的标签
- xxxxx:xxxx 模板-标签-的名称
- labels 模板的标签
-
sepc # 当前资源模板下的容器信息,该模板可以包含多个容器
- containers: 包含的所有的容器组信息(和docker差不多)
-
name: # 容器名称
-
image: # 容器使用的镜像
-
imagePullPolicy: 获取镜像策略[Always | Never | IfNotPresent] #获取镜像的策略 Alawys表示下载镜像 IfnotPresent表示优先使用本地镜像,否则下载镜像,Nerver表示仅使用本地镜像
-
workingDir :容器的工作目录
-
CMD :容器运行的命令
-
args:容器接收的参数信息
-
ports: 端口
- name: xxxxx 容器端口的名称
- containerPort 容器内部的开启的端口,容器需要监听的端口号
- hostPort: int #容器所在主机需要监听的端口号,默认与Container相同
-
protocol :TCP #端口协议,支持TCP和UDP,默认TCP
-
- containers: 包含的所有的容器组信息(和docker差不多)
-
2:yaml格式的pod定义文件完整内容补充说明
以下的信息摘录自(感谢大佬的总结): blog.csdn.net/w_y_x_y/art…
# yaml格式的pod定义文件完整内容:
apiVersion: v1 #必选,版本号,例如v1
kind: Pod #必选,Pod
metadata: #必选,元数据
name: string #必选,Pod名称
namespace: string #必选,Pod所属的命名空间
labels: #自定义标签
- name: string #自定义标签名字
annotations: #自定义注释列表
- name: string
spec: #必选,Pod中容器的详细定义
containers: #必选,Pod中容器列表
- name: string #必选,容器名称
image: string #必选,容器的镜像名称
imagePullPolicy: [Always | Never | IfNotPresent] #获取镜像的策略 Alawys表示下载镜像 IfnotPresent表示优先使用本地镜像,否则下载镜像,Nerver表示仅使用本地镜像
command: [string] #容器的启动命令列表,如不指定,使用打包时使用的启动命令
args: [string] #容器的启动命令参数列表
workingDir: string #容器的工作目录
volumeMounts: #挂载到容器内部的存储卷配置
- name: string #引用pod定义的共享存储卷的名称,需用volumes[]部分定义的的卷名
mountPath: string #存储卷在容器内mount的绝对路径,应少于512字符
readOnly: boolean #是否为只读模式
ports: #需要暴露的端口库号列表
- name: string #端口号名称
containerPort: int #容器需要监听的端口号
hostPort: int #容器所在主机需要监听的端口号,默认与Container相同
protocol: string #端口协议,支持TCP和UDP,默认TCP
env: #容器运行前需设置的环境变量列表
- name: string #环境变量名称
value: string #环境变量的值
resources: #资源限制和请求的设置
limits: #资源限制的设置
cpu: string #Cpu的限制,单位为core数,将用于docker run --cpu-shares参数
memory: string #内存限制,单位可以为Mib/Gib,将用于docker run --memory参数
requests: #资源请求的设置
cpu: string #Cpu请求,容器启动的初始可用数量
memory: string #内存清除·,容器启动的初始可用数量
livenessProbe: #对Pod内个容器健康检查的设置,当探测无响应几次后将自动重启该容器,检查方法有exec、httpGet和tcpSocket,对一个容器只需设置其中一种方法即可
exec: #对Pod容器内检查方式设置为exec方式
command: [string] #exec方式需要制定的命令或脚本
httpGet: #对Pod内个容器健康检查方法设置为HttpGet,需要制定Path、port
path: string
port: number
host: string
scheme: string
HttpHeaders:
- name: string
value: string
tcpSocket: #对Pod内个容器健康检查方式设置为tcpSocket方式
port: number
initialDelaySeconds: 0 #容器启动完成后首次探测的时间,单位为秒
timeoutSeconds: 0 #对容器健康检查探测等待响应的超时时间,单位秒,默认1秒
periodSeconds: 0 #对容器监控检查的定期探测时间设置,单位秒,默认10秒一次
successThreshold: 0
failureThreshold: 0
securityContext:
privileged:false
restartPolicy: [Always | Never | OnFailure]#Pod的重启策略,Always表示一旦不管以何种方式终止运行,kubelet都将重启,OnFailure表示只有Pod以非0退出码退出才重启,Nerver表示不再重启该Pod
nodeSelector: obeject #设置NodeSelector表示将该Pod调度到包含这个label的node上,以key:value的格式指定
imagePullSecrets: #Pull镜像时使用的secret名称,以key:secretkey格式指定
- name: string
hostNetwork:false #是否使用主机网络模式,默认为false,如果设置为true,表示使用宿主机网络
volumes: #在该pod上定义共享存储卷列表
- name: string #共享存储卷名称 (volumes类型有很多种)
emptyDir: {} #类型为emtyDir的存储卷,与Pod同生命周期的一个临时目录。为空值
hostPath: string #类型为hostPath的存储卷,表示挂载Pod所在宿主机的目录
path: string #Pod所在宿主机的目录,将被用于同期中mount的目录
secret: #类型为secret的存储卷,挂载集群与定义的secre对象到容器内部
scretname: string
items:
- key: string
path: string
configMap: #类型为configMap的存储卷,挂载预定义的configMap对象到容器内部
name: string
items:
- key: string
三、应用一些验证
1、故障转移测试验证
之前我们节点服务都是正常的状态,我们的Pod的运行也是分布再各自的最初的节点上如:
通过查看pods信息情况了解:
[root@k81-master01 k8s-install]# kubectl get pods -o wide
主要关注的是Node2节点运行的Pods:
关闭我们的Node2节点后(需要等待一下),再观察我们Pods:
且看看我们的对应的节点情况:
此时说明我们的Node2节点真的已经故障,那么这时候开执行转移了,转移到了Node3节点上运行了4个Pod,再过一下下之后:再看我们的Pods:
就算我们的重新恢复了Node2节点,但是还是没有自动重新再Node2上重新启动我们的Pod.
2、扩容的测试验证
首先的是扩容,我们的看我们的nginx-deployment当前额Pod运行情况为:
[root@k81-master01 k8s-install]# kubectl get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
chaoge-nginx-586ddcf5c4-bg8f4 1/1 Running 0 12m 10.244.2.13 k81-node03 <none> <none>
nginx-deployment-574b87c764-4q5ff 1/1 Running 0 3d20h 10.244.2.11 k81-node03 <none> <none>
nginx-deployment-574b87c764-745gt 1/1 Running 0 3d20h 10.244.2.10 k81-node03 <none> <none>
nginx-deployment-574b87c764-cz548 1/1 Running 0 9m54s 10.244.2.15 k81-node03 <none> <none>
nginx-deployment-574b87c764-j9bps 1/1 Running 0 9m54s 10.244.2.16 k81-node03 <none> <none>
zyx-nginx-5c559d5697-dhz2z 1/1 Running 0 5d22h 10.244.2.3 k81-node03 <none> <none>
zyx-nginx-5c559d5697-qvkjl 1/1 Running 0 9m54s 10.244.2.14 k81-node03 <none> <none>
[root@k81-master01 k8s-install]# ^C
[root@k81-master01 k8s-install]#
当前我们的有4个Pod都运行在Node3节点上那现在我们的需要扩容:
通过命令的方式进行扩容:
[root@k81-master01 k8s-install]# kubectl scale deployment nginx-deployment --replicas=5
deployment.apps/nginx-deployment scaled
[root@k81-master01 k8s-install]#
此时查看Pod情况,有一个特新的Pod被调用到了Node2节点上:
3、维持副本数验证
执行删除一个Node3上的一个Pod,再观察:
[root@k81-master01 k8s-install]# kubectl delete pods nginx-deployment-574b87c764-4q5ff
pod "nginx-deployment-574b87c764-4q5ff" deleted
查看:
4、指定Pod部署节点
通常有些时候我们的需要指定的方式把我们的一些服务指定的再那个节点上运行,此时,晚年需要做相关打标签的方式来实现:
- 第一步:给nodes节点打标签
- 第二部:部署应用的时候,通过NodeSelect来来指定节点的标签就可以了!
示例如:
在Masters节点上执行查看所有标签:
[root@k81-master01 k8s-install]# kubectl get nodes --show-labels
NAME STATUS ROLES AGE VERSION LABELS
k81-master01 Ready master 6d2h v1.16.2 beta.kubernetes.io/arch=amd64,beta.kubernetes.io/os=linux,kubernetes.io/arch=amd64,kubernetes.io/hostname=k81-master01,kubernetes.io/os=linux,node-role.kubernetes.io/master=
k81-node02 Ready <none> 6d2h v1.16.2 beta.kubernetes.io/arch=amd64,beta.kubernetes.io/os=linux,kubernetes.io/arch=amd64,kubernetes.io/hostname=k81-node02,kubernetes.io/os=linux
k81-node03 Ready <none> 6d2h v1.16.2 beta.kubernetes.io/arch=amd64,beta.kubernetes.io/os=linux,kubernetes.io/arch=amd64,kubernetes.io/hostname=k81-node03,kubernetes.io/os=linux
[root@k81-master01 k8s-install]#
找出我们的需要给哪个节点打标签: 如我们的需要给我们的k81-node2进行打标签: 1:给节点打标签
# 错误的方式
[root@k81-master01 k8s-install]# kubectl label node 192.168.219.139 name=xiaozhong-node
Error from server (NotFound): nodes "192.168.219.139" not found
# 正确的方式
[root@k81-master01 k8s-install]# kubectl label node k81-node02 name=xiaozhong-node
node/k81-node02 labeled
[root@k81-master01 k8s-install]#
PS :删除指定的标签可以使用- 即可
[root@k81-master01 k8s-install]# kubectl label node k81-node02 name- node/k81-node02 labeled [root@k81-master01 k8s-install]#
2:打了标签后,再查看我们的标签信息:
[root@k81-master01 k8s-install]# kubectl get nodes --show-labels
NAME STATUS ROLES AGE VERSION LABELS
k81-master01 Ready master 6d2h v1.16.2 beta.kubernetes.io/arch=amd64,beta.kubernetes.io/os=linux,kubernetes.io/arch=amd64,kubernetes.io/hostname=k81-master01,kubernetes.io/os=linux,node-role.kubernetes.io/master=
k81-node02 Ready <none> 6d2h v1.16.2 beta.kubernetes.io/arch=amd64,beta.kubernetes.io/os=linux,kubernetes.io/arch=amd64,kubernetes.io/hostname=k81-node02,kubernetes.io/os=linux,name=xiaozhong-node
k81-node03 Ready <none> 6d2h v1.16.2 beta.kubernetes.io/arch=amd64,beta.kubernetes.io/os=linux,kubernetes.io/arch=amd64,kubernetes.io/hostname=k81-node03,kubernetes.io/os=linux
[root@k81-master01 k8s-install]#
查看对应的标签名称:
3: 定义我们的无状态服务yaml文件信息:
apiVersion: apps/v1
kind: Deployment
metadata:
name: xiaozhong-nginx-deployment
spec:
selector:
matchLabels:
app: nginx
replicas: 2 # tells deployment to run 2 pods matching the template
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80
nodeSelector:
name: xiaozhong-node
4: 开始部署应用
[root@k81-master01 k8s-install]# kubectl apply -f xiaozhong-ceshinging.yaml
deployment.apps/xiaozhong-nginx-deployment created
[root@k81-master01 k8s-install]#
5: 查看部署结果
5、删除Deployment应用
PS:删除Deployment应用,旗下所属的ReplicaSet和pod都会删除!
删除命令:
[root@k81-master01 k8s-install]# kubectl delete deployment xiaozhong-nginx-deployment
deployment.apps "xiaozhong-nginx-deployment" deleted
[root@k81-master01 k8s-install]#
四、通过Service服务访问应用
1、服务的一些关键点梳理总结
- Pod启动和销毁都是导致自身的IP重新创建
- 使用Service来关联一组Pod,不关系Pod的IP的变化
- 解耦Pod之间的关联性
- 实现Pod服务发现和负载均衡
- 服务通过标签关联相关的Pod对象实例
- 作用于Proxy之后访问服务再转发对应的Pod里面
2、服务对象分类(个人理解梳理)
- ClusterIP类型(仅用于集群内部通信)
- NodePort类型(接入集群外部请求,如集群需外部访问,但仅限于访问节点)
- LoadBalancer类型(k8S 工作在云环境中的时候直接调用云环境所创建负载均衡器)
- ExternalName类型(将集群外部的服务引入到当前K8S集群内部,方便在集群内部使用)
-
ClusterIP类型:该类型是默认的类型,提供一个集群内部虚拟ip,这个虚拟IP可以给Pod之间进行通信使用,相当于固定的给一组Pod定义一个固定IP地址的中间层。这样就可以解耦Pod的Ip关联性。
在 k8s 中,所有的服务可以通过 格式为:
svn名称.svn所属的命名空间.svc.cluster.local
做服务发现
可在节点执行:curl svn名称.svn所属的命名空间.svc.cluster.local 也可以访问到集群内的Pod
-
NodePort类型(外部访问节点的Pod的时候,使用宿主机的端口):当我们的需要通过外部访问集群内部的Pod服务的时候,我们可通过NodePort(使用宿主机的端口),根据某个Node的IP+端口(NodePort)进行请求,即可把我们外部的请求代理转发到响应的Service对象的Cluster IP上的服务端口,然后再由Service对象把请求代理至服务中的Pod组对象的PodIP及应用程序监听的端口。
NodePort类型具体流程是: Client---->Node的IP:端口(NodePort)--->Cluster IP:Service端口--->再分发到某一个Pod的ip+容器的端口ContainerPort
通过Service的NodePort方式访问,此时会同时在所有节点监听同一个端口,比如:30000,访问节点的流量会被重定向到对应的Service对象上
-
LoadBalancer类型(三层转发):因为NodePort类型仅限于通过某个节点的宿主机的IP+NodePort进行访问指定某节点的Service,无法进行整个服务集群分布式的访问。所以如果需要整个服务集群节点都可以访问,还需要再外置一个统一入口器,所以需要一种LoadBalancer类型的服务,来做整个集群节点的负载均衡器,然后均衡分发都不同节点的NodePort类型服务上!
-
externalName类型:如果一个Pod需要访问一个集群外部的服务的时候,externalName类型的资源对象可以映射一个集群外部的服务到集群内部用于给集群内的Pod进行访问使用。(可以理解为:把外部一个域名地址映射到集群内coredns 解析的一个内部地址,然后我们的集群内的Pod就可以直接的访问集群内的coredns的地址)
以上仅仅是个人结合自己的实际需求,做学习的实践笔记!如有笔误!欢迎批评指正!感谢各位大佬!
结尾
END
简书:www.jianshu.com/u/d6960089b…
公众号:微信搜【小儿来一壶枸杞酒泡茶】
小钟同学 | 文 【欢迎一起学习交流】| QQ:308711822