kubernetes 核心技术-controller
Kubernetes 通常不会直接创建 Pod,而是通过 Controller 来管理 Pod 。Controller 中定义了 Pod 的部署特性,比如有几个副本,在什么样的 Node 上运行等,pod和controller之间通过yaml文件中的selector与label标签建立关系。
为了满足不同的业务场景,Kubernetes 提供了多种 Controller,包括 Deployment、ReplicaSet、DaemonSet、StatefuleSet、Job 等。
ReplicaSet
实现了 Pod 的多副本管理,目的是在任何时候都维持一组稳定的pod副本,常用来确保指定数量的pod副本可用。
使用 Deployment 时会自动创建 ReplicaSet,也就是说 Deployment 是通过 ReplicaSet 来管理 Pod 的多个副本,我们通常不需要直接使用 ReplicaSet,而是通过Deployment去使用ReplicaSet,这样的话我们就不需要担心和其他机制的冲突(如:ReplicaSet不支持滚动更新,但是Deployment支持)。
一个ReplicaSet的示例:
apiVersion: apps/v1 kind: ReplicaSet metadata: name: frontend labels: app: guestbook tier: frontend spec: # modify replicas according to your case replicas: 3 selector: matchLabels: tier: frontend template: metadata: labels: tier: frontend spec: containers: - name: php-redis image: gcr.io/google_samples/gb-frontend:v3
ReplicaSet中.spec.template.metadata.labels必须跟spec.selector
相同,不然会被API拒绝。
使用kubectl delete可以删除ReplicaSet及其pod,Garbage collection默认会删除所有相关pod。若只想删除ReplicaSet而不删除pod,需要指定--cascade=false。删除ReplicaSet后,可以创建具有相同selector的ReplicaSet来管理原先的pod,但不会使用新ReplicaSet的podTemplate来更新原先的pod。若想自动更新pod,使用Deployment。
总之,不建议直接使用 ReplicaSet,而是通过Deployment去使用ReplicaSet。
Deployment
这是在使用kubernetes时候,大家部署系统或者是服务经常使用的一种controller类型。因为我们可以通过它来使用ReplicaSet来为我们部署的应用进行副本的创建。
一般在实际的运用中,大家用 Deployment 做应用的真正的管理,而 Pod 是组成 Deployment 最小的单元。
使用deployment部署应用(基于yaml方式)
1.编写或生成yaml文件:kubectl create deployment nginx --image=nginx:1.8 --dry-run -o yaml > nginx.yaml
[root@master ~]# kubectl create deployment nginx --image=nginx:1.8 --dry-run -o yaml > nginx.yaml W0413 03:47:05.133957 72422 helpers.go:535] --dry-run is deprecated and can be replaced with --dry-run=client. [root@master ~]# ll -rw-r--r-- 1 root root 388 Apr 13 03:47 nginx.yaml
注意,上一步的目的是快速的生成一个yaml模板,方便修改后使用,当然也可以自己直接写yaml文件。
在生成的yaml文件中,可以看到selector与label的匹配关系:
2.使用已生成的yaml文件部署应用:kubectl apply -f nginx.yaml
[root@master ~]# kubectl apply -f nginx.yaml deployment.apps/nginx created [root@master ~]# kubectl get pod NAME READY STATUS RESTARTS AGE nginx-7c96855774-7h7bq 0/1 ContainerCreating 0 9s
3.对外发布(暴露对外端口),并重新生成yaml文件:kubectl expose deployment nginx --port=80 --type=NodePort --target-port=80 --name=nginx-server -o yaml > nginx-server.yaml
[root@master ~]# kubectl expose deployment nginx --port=80 --type=NodePort --target-port=80 --name=nginx-server -o yaml > nginx-server.yaml [root@master ~]# kubectl get pod,svc NAME READY STATUS RESTARTS AGE pod/nginx-7c96855774-7h7bq 1/1 Running 0 10m NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE service/kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 27d service/nginx-server NodePort 10.111.125.48 <none> 80:31491/TCP 26s [root@master ~]# ll total 32 -rw-r--r-- 1 root root 1074 Apr 14 00:39 nginx-server.yaml -rw-r--r-- 1 root root 388 Apr 13 03:47 nginx.yaml
这里两个yaml文件,nginx.yaml是pod的,nginx-server.yaml是service的(真实服务)。
应用升级与回滚(以nginx的pod为例)
1.新建版本为1.8 nginx的pod
yaml文件为:其中副本数为2
apiVersion: apps/v1 kind: Deployment metadata: creationTimestamp: null labels: app: nginx name: nginx spec: replicas: 2 selector: matchLabels: app: nginx strategy: {} template: metadata: creationTimestamp: null labels: app: nginx spec: containers: - image: nginx:1.8 name: nginx resources: {} status: {}
使用yaml文件部署应用:由于yaml文件中副本数为2,会有2个pod
[root@master ~]# kubectl apply -f nginx.yaml deployment.apps/nginx created [root@master ~]# kubectl get pods NAME READY STATUS RESTARTS AGE nginx-7c96855774-jg8hm 1/1 Running 0 36s nginx-7c96855774-ntwlw 1/1 Running 0 36s
2.升级nginx版本1.8至1.9:kubectl set image deployment nginx nginx=nginx:1.9
[root@master ~]# kubectl set image deployment nginx nginx=nginx:1.9 deployment.apps/nginx image updated [root@master ~]# kubectl get pods NAME READY STATUS RESTARTS AGE nginx-77766dd89-fcm9r 0/1 ContainerCreating 0 16s nginx-7c96855774-jg8hm 1/1 Running 0 16m nginx-7c96855774-ntwlw 1/1 Running 0 16m
在这个过程中,可以使用命令来查看升级的状态:kubectl rollout status deployment nginx
[root@master ~]# kubectl rollout status deployment nginx Waiting for deployment "nginx" rollout to finish: 1 out of 2 new replicas have been updated... Waiting for deployment "nginx" rollout to finish: 1 out of 2 new replicas have been updated... Waiting for deployment "nginx" rollout to finish: 1 out of 2 new replicas have been updated... Waiting for deployment "nginx" rollout to finish: 1 old replicas are pending termination... Waiting for deployment "nginx" rollout to finish: 1 old replicas are pending termination... deployment "nginx" successfully rolled out
等到升级成功完毕后,再去查看pods:
[root@master ~]# kubectl get pods NAME READY STATUS RESTARTS AGE nginx-77766dd89-fcm9r 1/1 Running 0 5m20s nginx-77766dd89-rld2x 1/1 Running 0 4m51s
可以发现,在升级的过程中,并不是直接kill掉原来的两个pod,而是启动玩新版本的pod后,再去kill掉老的,这种替换的机制保证了服务提供不间断。
另一方面,在node节点上也会发现已经有新版本的nginx镜像被下载:
[root@node1 ~]# docker images nginx* REPOSITORY TAG IMAGE ID CREATED SIZE nginx 1.9 c8c29d842c09 4 years ago 183MB nginx 1.8 0d493297b409 5 years ago 133MB
3.回滚版本
每次rollout都会创建revision,默认系统会保存deployment的rollout,可通过指定revision来rollback deployment。
首先可以查看历史升级的版本: kubectl rollout history deployment nginx
[root@master ~]# kubectl rollout history deployment nginx deployment.apps/nginx REVISION CHANGE-CAUSE 1 <none> 2 <none>
其中第一个版本就是1.8,第二个版本都是1.9
如果是回滚到上一个版本:kubectl rollout undo deployment nginx
[root@master ~]# kubectl rollout undo deployment nginx
deployment.apps/nginx rolled back
如果是回滚到指定版本:kubectl rollout undo deployment nginx --to-revision=2,其中--to-revision就是上述通过rollout history查询出来的版本号
[root@master ~]# kubectl rollout undo deployment nginx --to-revision=2 deployment.apps/nginx rolled back
最后可以通过查看deployment的详细信息来验证当前的版本:kubectl describe deployment nginx
[root@master ~]# kubectl describe deployment nginx Name: nginx Namespace: default CreationTimestamp: Wed, 14 Apr 2021 05:50:54 +0800 Labels: app=nginx Annotations: deployment.kubernetes.io/revision: 4 Selector: app=nginx Replicas: 2 desired | 2 updated | 2 total | 2 available | 0 unavailable StrategyType: RollingUpdate MinReadySeconds: 0 RollingUpdateStrategy: 25% max unavailable, 25% max surge Pod Template: Labels: app=nginx Containers: nginx: Image: nginx:1.9 。。。。。。。。
应用弹性伸缩
当前只有2个pod,复制为10个:kubectl scale deployment nginx --replicas=10
[root@master ~]# kubectl get pods NAME READY STATUS RESTARTS AGE nginx-7c96855774-8m6q9 1/1 Running 0 7m31s nginx-7c96855774-nzvld 1/1 Running 0 7m30s [root@master ~]# kubectl scale deployment nginx --replicas=10 deployment.apps/nginx scaled [root@master ~]# kubectl get pods NAME READY STATUS RESTARTS AGE nginx-7c96855774-2dk89 1/1 Running 0 9s nginx-7c96855774-2ttfw 1/1 Running 0 9s nginx-7c96855774-8m6q9 1/1 Running 0 8m41s nginx-7c96855774-cn6vr 1/1 Running 0 9s nginx-7c96855774-gfzrq 1/1 Running 0 9s nginx-7c96855774-nzvld 1/1 Running 0 8m40s nginx-7c96855774-r4sdt 1/1 Running 0 9s nginx-7c96855774-rtdrb 1/1 Running 0 9s nginx-7c96855774-vjzg2 1/1 Running 0 9s nginx-7c96855774-vrqck 1/1 Running 0 9s
若cluster启动HPA(horizontal Pod autoscaling),还可以设置deployment的autoscaler:
kubectl autoscale deployment nginx --min=10 --max=15 --cpu-percent=80
表示数量在10-15之间,cpu使用率为80%。
DaemonSet
用于每个 Node 最多只运行一个 Pod 副本的场景。正如其名称所揭示的,DaemonSet 通常用于运行 daemon(守护程序)。当有Node加入集群时,也会为他们新增一个Pod,当有Node从集群中移除时,这个Pod同时也会回收。在删除DaemonSet的时候,会删除他创建的所有Pod。
使用DaemonSet的一些例子:
1.在每个node上运行一个集群存储服务,例如glusterd,ceph(分布式文件系统);
2.在每个node上运行一个日志收集服务,例如fluentd、logstash;
3.在每个node上运行一个节点监控服务,例如Prometheus Node Exporter。
DaemonSet的描述文件和Deployment非常相似,只需要修改Kind,并去掉副本数量的配置即可:
apiVersion: apps/v1 kind: DaemonSet metadata: name: nginx-daemonset labels: app: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.9 ports: - containerPort: 80
查看Pod在Node上的分布:一个node上一个pod
[root@master service-daemonset]# kubectl get pods -o wide NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES nginx-daemonset-fl44w 1/1 Running 0 20s 10.244.1.28 node1 <none> <none> nginx-daemonset-wgkbv 1/1 Running 0 20s 10.244.2.30 node2 <none> <none>
删除一个pod,会自动重新创建一个,保持一个node上一个pod:
[root@master service-daemonset]# kubectl get pods -o wide NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES nginx-daemonset-fl44w 1/1 Running 0 20s 10.244.1.28 node1 <none> <none> nginx-daemonset-wgkbv 1/1 Running 0 20s 10.244.2.30 node2 <none> <none> [root@master service-daemonset]# kubectl delete pod nginx-daemonset-fl44w pod "nginx-daemonset-fl44w" deleted [root@master service-daemonset]# kubectl get pods -o wide NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES nginx-daemonset-8q4sn 1/1 Running 0 3s 10.244.1.29 node1 <none> <none> nginx-daemonset-wgkbv 1/1 Running 0 10m 10.244.2.30 node2 <none> <none>
StatefulSets
RC、Deployment、DaemonSet都是面向无状态的服务,它们所管理的Pod的IP、名字,启停顺序等都是随机的。
跟Deployment一样,StatefulSet也用来管理Pod,但不同的是,StatefulSet管理所有有状态的服务。
StatefulSet特点:
Pod一致性:包含次序(启动、停止次序)、网络一致性。此一致性与Pod相关,与被调度到哪个node节点无关;
稳定的次序:对于N个副本的StatefulSet,每个Pod都在[0,N)的范围内分配一个数字序号,且是唯一的;
稳定的网络:Pod的hostname模式为 (statefulset名称)−(序号);
稳定的存储:通过VolumeClaimTemplate为每个Pod创建一个PV。删除、减少副本,不会删除相关的卷。
StatefulSet本质上是Deployment的一种变体,在v1.9版本中已成为GA版本,它为了解决有状态服务的问题,它所管理的Pod拥有固定的Pod名称,启停顺序,在StatefulSet中,Pod名字称为网络标识(hostname),还必须要用到持久存储。
使用的场景有:MySQL集群、MongoDB集群、Akka集群、ZooKeeper集群等。
任何可以提供实际请求处理能力的pod最终都要封装成统一的service才能对外提供服务。在Deployment中,与之对应的服务是service,而在StatefulSet中与之对应的headless service,即无头服务,与service的区别就是它没有Cluster IP,解析它的名称时将返回该Headless Service对应的全部Pod的Endpoint列表,即当访问该service时,将直接获得Pod的IP,进行直接访问。
除此之外,StatefulSet在Headless Service的基础上又为StatefulSet控制的每个Pod副本创建了一个DNS域名,这个域名的格式为:
$(podname).(headless server name)
FQDN(全限定域名):$(podname).(headless server name).namespace.svc.cluster.local
一个StatefulSet控制器的组成:
Headless Service:用来定义Pod的唯一网络标识( DNS domain);
volumeClaimTemplates :存储卷申请模板,创建PVC,指定pvc名称大小,将自动创建pvc,且pvc必须由存储类供应;
StatefulSet :定义具体应用,比如名为Nginx,有三个Pod副本,并为每个Pod定义了一个域名部署statefulset。
为什么需要 headless service 无头服务?
在用Deployment时,每一个Pod名称是没有顺序的,是随机字符串,因此是Pod名称是无序的,但是在statefulset中要求必须是有序 ,每一个pod不能被随意取代,pod重建后pod名称还是一样的。而pod IP是变化的,所以是以Pod名称来识别。pod名称是pod唯一性的标识符,必须持久稳定有效。这时候要用到无头服务,它可以给每个Pod一个唯一的名称 。
为什么需要volumeClaimTemplate?
对于有状态的副本集都会用到持久存储,对于分布式系统来讲,它的最大特点是数据是不一样的,所以各个节点不能使用同一存储卷,每个节点有自已的专用存储,但是如果在Deployment中的Pod template里定义的存储卷,是所有副本集共用一个存储卷,数据是相同的,因为是基于模板来的 ,而statefulset中每个Pod都要自已的专有存储卷,所以statefulset的存储卷就不能再用Pod模板来创建了,于是statefulSet使用volumeClaimTemplate,称为卷申请模板,它会为每个Pod生成不同的pvc,并绑定pv,从而实现各pod有专用存储。这就是为什么要用volumeClaimTemplate的原因。
部署一个StatefulSet服务:
yaml文件:
#首先需要一个无头service,其中 clusterIP: None apiVersion: v1 kind: Service metadata: name: nginx-headless namespace: default spec: selector: run: nginx clusterIP: None ports: - port: 80 targetPort: 80 --- #StatefulSet控制器,说明3个nginx副本都是有状态服务 apiVersion: apps/v1 kind: StatefulSet metadata: name: nginx-statefulset namespace: default spec: serviceName: nginx replicas: 3 selector: matchLabels: run: nginx template: metadata: labels: run: nginx spec: containers: - image: nginx:1.8 name: nginx ports: - containerPort: 80
查看pod与svc:
[root@master service-statefulsets]# kubectl get pods NAME READY STATUS RESTARTS AGE nginx-statefulset-0 1/1 Running 0 7s nginx-statefulset-1 1/1 Running 0 6s nginx-statefulset-2 1/1 Running 0 3s [root@master service-statefulsets]# kubectl get svc NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 37d nginx-headless ClusterIP None <none> 80/TCP 18s
如果观察pod的创建过程,会发现是有序创建的(如果delete,同样也是有序删除,删除的顺序正好相反,从N-1到0),按照0-1-2的顺序,并且每个 Pod 都拥有一个基于其顺序索引的稳定的主机名。
Pod的hostname由StatefulSet的name以及其ordinal index组成,$(statefulset name)-$(ordinal)。若StatefulSet存在N个pod, ordinal index为0~N-1。示例中创建3个pod,分别名为nginx-statefulset-0,nginx-statefulset-1,nginx-statefulset-2。
如果重启pod会发现,pod中的ip会变化,但是pod的名称不会发生变化;这就是为什么不要在其他应用中使用 StatefulSet 中的 Pod 的 IP 地址进行连接,而应该使用唯一domain进行连接,因为domain是稳定的,这点很重要。
StatefulSet使用headless service来控制pod的domain。Service控制的domain的格式为:$(service name).$(namespace).svc.cluster.local,其中cluster.local为cluster domain。当Pod创建后,获得匹配的DNS subdomain,格式为$(podname).$(governing service domain),即$(podname).$(service name).$(namespace).svc.cluster.local。如示例中的:nginx-statefulset-0.nginx-headless.default.svc.cluster.local。
另外,StatefulSet一般都会使用VolumeClaimTemplate(本例未使用),比如:
volumeClaimTemplates: - metadata: name: www spec: accessModes: [ "ReadWriteOnce" ] storageClassName: "my-storage-class" resources: requests: storage: 1Gi
K8s会为每个VolumeClaimTemplate创建一个PersistentVolume,每个pod将获取一个PersistentVolume,其storage class为my-storage-class并具有1GB的存储。当不指定storage class时,将使用默认的storage class。当Pod被schedule到node上时,它的volumeMounts将mount生成的PersistentVolume。
注意:PersistentVolumes在pod或StatefulSet删除后并不会被删除,需要人工删除。
Job
服务类容器通常持续提供服务,需要一直运行,比如 http server,daemon 等;而工作类容器则是一次性任务,比如批处理程序,完成后容器就退出。
Kubernetes 的 Deployment、ReplicaSet 和 DaemonSet 都用于管理服务类容器;对于工作类容器,我们用 Job。
Job创建一个或多个pod并确保指定数量的pod成功终止,当指定数量的pod成功终止后,job结束。
一个Job示例,计算2000位π:
apiVersion: batch/v1 kind: Job metadata: name: pi spec: template: spec: containers: - name: pi image: perl command: ["perl", "-Mbignum=bpi", "-wle", "print bpi(2000)"] restartPolicy: Never backoffLimit: 4
restartPolicy :是指当前的重启策略。对于 Job,只能设置为 Never (从不)或者 OnFailure(失败时)。对于其他 controller(比如 Deployment)可以设置为 Always 。
backoffLimit :设置pod失败后的重试次数,默认为6
启动这个job并查看:
[root@master service-job]# kubectl apply -f job.yaml job.batch/pi created [root@master service-job]# kubectl get job NAME COMPLETIONS DURATION AGE pi 0/1 14s 14s [root@master service-job]# kubectl get pods NAME READY STATUS RESTARTS AGE nginx-daemonset-8q4sn 1/1 Running 0 144m nginx-daemonset-wgkbv 1/1 Running 0 155m pi-x5m4s 0/1 ContainerCreating 0 21s
过一会,当completions为 1/1 表示成功运行了这个job,同时pod的状态为的Completed表示这个job已经运行完成:
[root@master service-job]# kubectl get job NAME COMPLETIONS DURATION AGE pi 1/1 75s 2m57s [root@master service-job]# kubectl get pods NAME READY STATUS RESTARTS AGE nginx-daemonset-8q4sn 1/1 Running 0 146m nginx-daemonset-wgkbv 1/1 Running 0 157m pi-x5m4s 0/1 Completed 0 3m
此时通过pod日志来查看job的执行结果:kubectl logs pi-x5m4s
[root@master service-job]# kubectl logs pi-x5m4s 3.1415926535897932384626433832795028841971693993751058209749445923078164062862089986280348253421170679821480865132823066470938446095505822317253594081284811174502841027019385211055596446229489549303819644288109756659334461284756482337867831652712019091456485669234603486104543266482133936072602491412737245870066063155881748815209209628292540917153643678925903600113305305488204665213841469519415116094330572703657595919530921861173819326117931051185480744623799627495673518857527248912279381830119491298336733624406566430860213949463952247371907021798609437027705392171762931767523846748184676694051320005681271452635608277857713427577896091736371787214684409012249534301465495853710507922796892589235420199561121290219608640344181598136297747713099605187072113499999983729780499510597317328160963185950244594553469083026425223082533446850352619311881710100031378387528865875332083814206171776691473035982534904287554687311595628638823537875937519577818577805321712268066130019278766111959092164201989380952572010654858632788659361533818279682303019520353018529689957736225994138912497217752834791315155748572424541506959508295331168617278558890750983817546374649393192550604009277016711390098488240128583616035637076601047101819429555961989467678374494482553797747268471040475346462080466842590694912933136770289891521047521620569660240580381501935112533824300355876402474964732639141992726042699227967823547816360093417216412199245863150302861829745557067498385054945885869269956909272107975093029553211653449872027559602364806654991198818347977535663698074265425278625518184175746728909777727938000816470600161452491921732172147723501414419735685481613611573525521334757418494684385233239073941433345477624168625189835694855620992192221842725502542568876717904946016534668049886272327917860857843838279679766814541009538837863609506800642251252051173929848960841284886269456042419652850222106611863067442786220391949450471237137869609563643719172874677646575739624138908658326459958133904780275901
有时也会同时运行多个pod来提高job的执行效率,此时可以通过设置.spec.completions和.spec.parallelism属性来完成不同的任务。
completions表示成功完成的任务数量,parallelism表示并行任务数量,默认都为1.当parallelism设置为0时,job将被暂停。比如:
当Job complete,将不会创建pod,old pod也不会被删除,这样可以查看log。同时,Job也存在,这样可以查看status。可以通过kubectl delete来人工删除。
[root@master service-job]# kubectl delete -f job.yaml job.batch "pi" deleted 或者 [root@master service-job]# kubectl delete job pi job.batch "pi" deleted
Job终止可能因为失败次数超过.spec.backoffLimit,或者因为job的运行时间炒超过设置的.spec.activeDeadlineSeconds。当Job的运行时间超过activeDeadlineSeconds,所有运行的pod被终止,Job状态变为Failed,reason: DeadlineExceeded。
CronJob
Linux 中有 cron 程序定时执行任务,Kubernetes 的 CronJob 提供了类似的功能,可以定时执行 Job,解决了某些批处理任务需要定时反复执行的问题。
一个CronJob示例:
apiVersion: batch/v1beta1 kind: CronJob metadata: name: hello spec: schedule: "*/1 * * * *" jobTemplate: spec: template: spec: containers: - name: hello image: busybox command: ["echo","hello k8s job!"] restartPolicy: OnFailure
schedule 指定什么时候运行 Job,其格式与 Linux cron 一致。这里 */1 * * * * 的含义是每一分钟启动一次。
等待几分钟,然后通过 kubectl get jobs
查看 Job 的执行情况:
[root@master service-cronjob]# kubectl get cronjob NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE AGE hello */1 * * * * False 0 39s 7m32s [root@master service-cronjob]# kubectl get job NAME COMPLETIONS DURATION AGE hello-1619459040 1/1 16s 2m46s hello-1619459100 1/1 17s 106s hello-1619459160 1/1 16s 46s
通过AGE发现 每个job都比之前的多了 60s,即每一分钟执行一次job
使用 kubect get pods 和 kubectl logs 命令查看:
[root@master service-cronjob]# kubectl get pods NAME READY STATUS RESTARTS AGE hello-1619459100-gxn27 0/1 Completed 0 2m51s hello-1619459160-f5p8x 0/1 Completed 0 111s hello-1619459220-f2k76 0/1 Completed 0 51s [root@master service-cronjob]# kubectl logs hello-1619459100-gxn27 hello k8s job!
参考:
https://www.pianshen.com/article/80111234551/
https://zhuanlan.zhihu.com/p/88202304
StatefulSet:https://blog.csdn.net/weixin_44729138/article/details/106054025