K8s 基石下的云原生微服务实践

2022-12-05 11:07:09 浏览数 (2)

前言

微服务架构已经火了很多年了,如:Dubbo、Spring Cloud,再到后来的 Spring Cloud Alibaba,但都是仅限于 Java 语言的瓶颈,如何让各种语言之间的微服务更加有效、快速的通讯,这是当前很多企业需要面临的问题,因为一个企业中,不只是基于单纯的某一种语言开发,这就涉及到多语言服务之间的访问。以 Kubernetes(k8s) 为核心的容器技术掀起的云原生浪潮仍在席卷全球,在轰轰烈烈的数字化转型技术变革中,先行者们开始思考新的技术体系究竟能给行业与社会带来什么,以及如何把 DevOps 等先进的开发管理模型带入各行各业,让更多的企业享受到云原生以及 AI、IoT 等前沿技术革新带来的红利。本专栏的创作重点,则是在于讲述在巨多语言的情况下,该如何设计微服务架构,以及云原生时代的微服务的高可用、自动化等等。

微服务架构

微服务发展史

在微服务到来之前,一切都是单个服务,当然单体应用程序的存在,暴露的缺点也是不少的,主要有:

  • 复杂性高
  • 团队协作开发成本高
  • 扩展性差
  • 部署效率低下
  • 系统很差的高可用性

复杂性,体现在:随着业务的不断迭代,项目的代码量急剧的增多,项目模块也会随着而增加,整个项目就会变成的非常复杂。

开发成本高,体现在:团队开发几十个人在修改代码,然后一起合并到同一地址分支,打包部署,测试阶段只要有一小块功能有问题,就得重新编译打包部署,重新测试,所有相关的开发人员都得参与其中,效率低下,开发成本极高。

扩展性差,体现在:在新增功能业务的时候,代码层面会考虑在不影响现有的业务基础上编写代码,提高了代码的复杂性。

部署效率低,体现在:当单体应用的代码越来越多,依赖的资源越来越多时,应用编译打包、部署测试一次,需要花费的时间越来越多,导致部署效率低下。

高可用差,体现在:由于所有的业务功能最后都部署到同一个文件,一旦某一功能涉及的代码或者资源有问题,那就会影响到整个文件包部署的功能。举个特别鲜明的示例:上世纪八、九十年代,很多的黄页以及延伸到后来的网站中,很多的展示页面与获取数据的后端都是在一个服务模块中。这就造成一个很不好的影响:如果只是修改极小部分的页面展示或图片展示,则需要把整个服务模块进行打包部署,这样会导致时间的严重浪费以及成本的增加。更加糟糕的是,给用户带来非常不好的体验,用户无法理解的是:只是换个网站的某块微小的展示区,导致了整个网站在那一时刻无法正常的访问。当然,也许,对于那个时候互联网的不发达,人们对于这样的体验,已经算是一种幸福的享受了。

由于单体应用具有以上的种种缺点,导致了一个新名词、新概念的诞生——微服务

其实,从早年间的单体应用,到 2014 年起,得益于以 Docker 为代表的容器化技术的成熟以及 DevOps 文化的兴起,服务化的思想进一步演化,演变为今天我们所熟知的微服务。那么,微服务到底是啥?

微服务,英文名:microservice,百度百科上将其定义为:SOA 架构的一种变体。微服务(或微服务架构)是一种将应用程序构造为一组低耦合的服务。

微服务有着一些鲜明的特点:

  • 功能单一
  • 服务粒度小
  • 服务间独立性强
  • 服务间依赖性弱
  • 服务独立维护
  • 服务独立部署

微服务将原来耦合在一起的复杂业务拆分为单个服务,规避了原本复杂度无止境的积累,每一个微服务专注于单一功能,并通过定义良好的接口清晰表述服务边界。

由于微服务具备独立的运行进程,所以每个微服务可以独立部署。当业务迭代时只需要发布相关服务的迭代即可,降低了测试的工作量同时也降低了服务发布的风险。

在微服务架构下,当某一组件发生故障时,故障会被隔离在单个服务中。如通过限流、熔断等方式降低错误导致的危害,保障核心业务的正常运行。

微服务发展到现在,带有以下标志:高内聚、低耦合以业务为中心自治和高可用

微服务划分的粒度

服务的划分,可以从水平的功能划分,也可从垂直的业务划分,粒度的大小,可以根据当前的产品需求来定位,最关键的是要做到:高内聚、低耦合

如电商系统为例,如下图:

电商系统架构图

电商中涉及到业务很可能是最多的,商品、库存、订单、促销、支付、会员、购物车、发票、店铺等等,这个是根据业务的不同来进行模块的划分。微服务划分的粒度一定是要有明确性的,不能因为含糊而新增一个服务模块,这样会导致功能接口的可复用性差。一个好的架构设计,肯定是可复用性很强的结构模式。我喜欢这样的一句话:**微服务的边界 (粒度) 是 "决策", 而不是个 "标准答案"**。即应该将各微服务划分的方式,深度思考,周全的考量各方面的因素下,所作出的一个”最适合”的架构决策,而不是一个人芸亦芸的”标准答案“。

容器化技术

什么是容器

什么是容器呢?自然界的解释:容器是指用以容纳物料并以壳体为主的基本装置。但今天讲的容器也是一个容纳物质的载体。那计算机所指的容器(Container)到底是什么呢?容器是镜像(Image)的运行时实例。正如从虚拟机模板上启动 VM 一样,用户也同样可以从单个镜像上启动一个或多个容器。虚拟机和容器最大的区别是容器更快并且更轻量级,与虚拟机运行在完整的操作系统之上相比,容器会共享其所在主机的操作系统/内核。

为什么要用容器呢?假设你在使用一台电脑开发一个应用,而且开发环境具有特定的配置。其他开发人员身处的环境配置可能稍有不同。你正在开发的应用不止依赖于您当前的配置,还需要某些特定的库、依赖项和文件。与此同时,你的企业还拥有标准化的开发和生产环境,有着自己的配置和一系列支持文件。你希望尽可能多在本地模拟这些环境,而不产生重新创建服务器环境的开销。这时候,就会需要容器来模拟这些环境。

我们常见的容器启动方式是 Docker,Docker 是一个开源的应用容器引擎,基于 Go 语言 并遵从 Apache2.0 协议开源。Docker 可以让开发者打包他们的应用以及依赖包到一个轻量级、可移植的容器中,然后发布到任何 Linux 机器上,也可以实现虚拟化。

Kubernetes

Google 多年来一直使用容器作为交付应用程序的一种重要方式,且运行有一款名为 Borg 的编排工具。Google、RedHat 等公司为了对抗以 Docker 公司为核心的容器商业生态,他们一起成立了 CNCF(Cloud Native Computing Foundation)。当谷歌于 2014 年 3 月开始开发 Kubernetes 时,很明智的选择当时最流行的容器,没错,就是 Docker。Kubernetes 对 Docker 容器运行时的支持,迎来了大量的使用用户。Kubernetes 于 2014 年 6 月 6 日首次发布。这便有了容器编排工具 Kubernetes 的诞生。另外,CNCF 的目的是以开源的 K8S 为基础,使得 K8S 能够在容器编排方面能够覆盖更多的场景,提供更强的能力。K8S 必须面临 Swarm 和 Mesos 的挑战。Swarm 的强项是和 Docker 生态的天然无缝集成,Mesos 的强项是大规模集群的管理和调度。K8S 是 Google 基于公司已经使用了十多年的 Borg 项目进行了沉淀和升华才提出的一套框架。它的优点就是有一套完整的全新的设计理念,同时有 Google 的背书,而且在设计上有很强的扩展性,所以,最终 K8S 赢得了胜利,成为了容器生态的行业标准。

K8s 为什么会成为微服务的基础架构

为什么 K8s 是下一代微服务架构基础

微服务出现后,同样面临着一个重要的话题:高可用。所谓高可用:英文缩写 HA(High Availability),是指当某个服务或服务所在节点出现故障时,其对外的功能可以转移到该服务其他的副本或该服务在其他节点的副本,从而在减少停工时间的前提下,满足业务的持续性,这两个或多个服务构成了服务高可用。同时,这种高可用需要考虑到服务的性能压力,即服务的负载均衡。

我们知道对于服务的高可用,或者说服务的负载来说,有很多方式来解决这些问题。比如:

  • 主从方式,其工作原理是:主机处于工作状态,备机处于监控、准备状态,当主机出现宕机的情况下,备机可以接管主机的一切工作,等到主机恢复正常后,将会手动或自动的方式将服务切换到主机上运行,数据的一致性通过共享存储来实现。
  • 集群方式,其工作原理是:多台机器同时运行一个或几个服务,当其中的某个节点出现宕机时,这时该节点的服务将无法提供业务功能,可以选择根据一定的机制,将服务请求转移到该服务所在的其他节点上,这样可以让逻辑持续的执行下去,即消除软件单点故障。这其实就涉及到负载均衡策略。

对于微服务的高可用,涉及到的其中一个就是其服务的负载均衡。在微服务中,负载均衡的前提是,同一个服务需要被发现多个,或者说多个副本,这样才能实现负载均衡以及服务的高可用。

同时,服务发现后,其实面临的是一个主要的问题就是应该访问哪一个?因为发现了某个服务的多个实例,最终只会访问其中某一个,这就涉及到服务的负载均衡了。

负载均衡在微服务中是一个很常见的话题,实现负载均衡的插件也越来越多。netflix 开源的 Zuul、Gateway 等等。

但这样的微服务,带来的好处就是高度自治,但同时也会带来一定的副作用:所用到的技术栈太过复杂,整个系统看起来很繁重。

K8s 是如何解决这些问题的呢?在 K8s 中提供了一套服务注册与发现的机制:Kubernetes 为服务和 Pod 创建 DNS 记录。您可以使用一致的 DNS 名称而不是 IP 地址联系服务。集群中定义的每个服务(包括 DNS 服务器本身)都分配了一个 DNS 名称。默认情况下,客户端 Pod 的 DNS 搜索列表包括 Pod 自己的命名空间和集群的默认域。DNS 查询可以使用 pod 的 /etc/resolv.conf. Kubelet 为每个 pod 设置这个文件。例如,对查询 data 可以扩展为 test.default.svc.cluster.local。该 search 选项的值用于扩展查询:

代码语言:javascript复制
apiVersion: v1
kind: Service
metadata:
  name: test
spec:
  selector:
    app: MyApp
  ports:
    - protocol: TCP
      port: 80
      targetPort: 9376

该规范创建了一个名为“test”的新服务对象,其目标是任何带有 app=MyApp 标签的 Pod 上的 TCP 端口 9376 。

同时,K8s 提供一种资源 Configmap,可以编写一个 spec 引用 ConfigMap 的 Pod ,并根据 ConfigMap 中的数据配置该 Pod 中的容器。Pod 和 ConfigMap 必须在同一个命名空间:

代码语言:javascript复制
kind: ConfigMap
apiVersion: v1
metadata:
  name: rest-service
  namespace: system-server
data:
  application.yaml: |-
    greeting:
      message: Say Hello to the World
    ---
    spring:
      profiles: dev
    greeting:
      message: Say Hello to the Developers

再者,对于服务的暴露,K8s 提供了一种资源:Ingress controller,Ingress 控制器类似 Nginx,可以帮助我们把服务代理到集群外,提供给前端或外界第三方使用。

这样,对于系统本身的复杂程度,可以摒弃使用 Spring cloud 自带的各种组件:

Spring Cloud 组件图

K8s 的基础以及实战

K8s 基础

在前面,我们讲述了 K8s 为什么可以替换 Springcloud 家族中的组件来统一管理服务、访问服务。接下来,我们讲讲 K8s 都有哪些基础常用的资源。这些资源在 K8s 中有其接口功能,但这里,我们统一用脚本命令的形式来调用接口,生成资源。

首先第一条就是编写配置文件,因为配置文件可以是 YAML 或者 JSON 格式的。为方便阅读与理解,在后面的讲解中,我会统一使用 YAML 文件来指代它们。 Kubernetes 跟 Docker 等很多项目最大的不同,就在于它不推荐你使用命令行的方式直接运行容器(虽然 Kubernetes 项目也支持这种方式,比如:kubectl run),而是希望你用 YAML 文件的方式,即:把容器的定义、参数、配置,统统记录在一个 YAML 文件中,然后用这样一句指令把它运行起来:

代码语言:javascript复制
kubectl create -f xxx.yaml

这样做最直接的一个好处是:你会有一个文件能记录下 K8s 到底 run 了哪些东西。比如下面这个例子:

代码语言:javascript复制
apiVersion: apps/v1
kind: Deployment
metadata:
  name: tomcat-deployment
spec:
  selector:
    matchLabels:
      app: tomcat
  replicas: 2
  template:
    metadata:
      labels:
        app: tomcat
    spec:
      containers:
      - name: tomcat
        image: tomcat:10.0.5
        ports:
        - containerPort: 80

像这样的一个 YAML 文件,对应到 kubernetes 中,就是一个 API Object(API 对象)。当你为这个对象的各个字段填好值并提交给 Kubernetes 之后,Kubernetes 就会负责创建出这些对象所定义的容器或者其他类型的 API 资源。可以看到,这个 YAML 文件中的 Kind 字段,指定了这个 API 对象的类型(Type),是一个 Deployment。Deployment 是一个定义多副本应用(即多个副本 Pod)的对象。此外,Deployment 还负责在 Pod 定义发生变化时,对每个副本进行滚动更新(Rolling Update)。

在上面这个 Yaml 文件中,我给它定义的 Pod 副本个数 (spec.replicas)是:2。但,这些 Pod 副本长啥样子呢?为此,我们定义了一个 Pod 模版(spec.template),这个模版描述了我想要创建的 Pod 的细节。在上面的例子里,这个 Pod 里只有一个容器,这个容器的镜像(spec.containers.image)是 tomcat=10.0.5,这个容器监听端口(containerPort)是 80。

需要注意的是,像这种,使用一种 API 对象(Deployment)管理另一种 API 对象(Pod)的方法,在 Kubernetes 中,叫作“控制器”模式(controller pattern)。在我们的这个 demo 中,Deployment 扮演的正是 Pod 的控制器的角色。而 Pod 是 Kubernetes 世界里的应用;而一个应用,可以由多个容器(container)组成。为了让我们这个 tomcat 服务容器化运行起来,我们只需要执行:

代码语言:javascript复制
tom@PK001:~/damon$ kubectl create -f tomcat-deployment.yaml
deployment.apps/tomcat-deployment created

执行完上面的命令后,你就可以看容器运行情况,此时,只需要执行:

代码语言:javascript复制
tom@PK001:~/damon$ kubectl get pod -l app=tomcat
NAME                                 READY   STATUS              RESTARTS   AGE
tomcat-deployment-799f46f546-7nxrj   1/1     Running             0          77s
tomcat-deployment-799f46f546-hp874   0/1     Running             0          77s

kubectl get 指令的作用,就是从 Kubernetes 里面获取(GET)指定的 API 对象。可以看到,在这里我还加上了一个 -l 参数,即获取所有匹配 app=nginx 标签的 Pod。需要注意的是,在命令行中,所有 key-value 格式的参数,都使用“=”而非“:”表示。 从这条指令返回的结果中,我们可以看到现在有两个 Pod 处于 Running 状态,也就意味着我们这个 Deployment 所管理的 Pod 都处于预期的状态。

此外, 你还可以使用 kubectl describe 命令,查看一个 API 对象的细节,比如:

代码语言:javascript复制
tom@PK001:~/damon$ kubectl describe pod tomcat-deployment-799f46f546-7nxrj
Name:           tomcat-deployment-799f46f546-7nxrj
Namespace:      default
Priority:       0
Node:           ca005/10.10.2.5
Start Time:     Thu, 08 Apr 2021 10:41:08  0800
Labels:         app=tomcat
                pod-template-hash=799f46f546
Annotations:    cni.projectcalico.org/podIP: 20.162.35.234/32
Status:         Running
IP:             20.162.35.234
Controlled By:  ReplicaSet/tomcat-deployment-799f46f546
Containers:
  tomcat:
    Container ID:   docker://5a734248525617e950b7ce03ad7a19acd4ffbd71c67aacd9e3ec829d051b46d3
    Image:          tomcat:10.0.5
    Image ID:       docker-pullable://tomcat@sha256:2637c2c75e488fb3480492ff9b3d1948415151ea9c503a496c243ceb1800cbe4
    Port:           80/TCP
    Host Port:      0/TCP
    State:          Running
      Started:      Thu, 08 Apr 2021 10:41:58  0800
    Ready:          True
    Restart Count:  0
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from default-token-2ww52 (ro)
Conditions:
  Type              Status
  Initialized       True
  Ready             True
  ContainersReady   True
  PodScheduled      True
Volumes:
  default-token-2ww52:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  default-token-2ww52
    Optional:    false
QoS Class:       BestEffort
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute for 300s
                 node.kubernetes.io/unreachable:NoExecute for 300s
Events:
  Type    Reason     Age    From               Message
  ----    ------     ----   ----               -------
  Normal  Scheduled  4m17s  default-scheduler  Successfully assigned default/tomcat-deployment-799f46f546-7nxrj to ca005
  Normal  Pulling    4m16s  kubelet, ca005     Pulling image "tomcat:10.0.5"
  Normal  Pulled     3m27s  kubelet, ca005     Successfully pulled image "tomcat:10.0.5"
  Normal  Created    3m27s  kubelet, ca005     Created container tomcat
  Normal  Started    3m27s  kubelet, ca005     Started container tomcat

在 kubectl describe 命令返回的结果中,可以的清楚地看到这个 Pod 的详细信息,比如它的 IP 地址等等。其中,有一个部分值得你特别关注,它就是 Events(事件)。

在 Kubernetes 执行的过程中,对 API 对象的所有重要操作,都会被记录在这个对象的 Events 里,并且显示在 kubectl describe 指令返回的结果中。这些 Events 中的信息很重要,可以排查容器是否运行、正常运行的原因。

如果你希望升级 tomcat 的版本,那可以直接修改 Yaml 文件:

代码语言:javascript复制
    spec:
      containers:
      - name: tomcat
        image: tomcat:latest
        ports:
        - containerPort: 80

修改完 Yaml 文件后,执行:

代码语言:javascript复制
kubectl apply -f tomcat-deployment.yaml

这样的操作方法,是 Kubernetes“声明式 API”所推荐的使用方法。也就是说,作为用户,你不必关心当前的操作是创建,还是更新,你执行的命令始终是 kubectl apply,而 Kubernetes 则会根据 YAML 文件的内容变化,自动进行具体的处理。

在这里,为什么会以 Deployment 资源来举例呢?因为在 K8s 资源中,Deployment 形式的资源提供了声明更新以及副本集,可以在 Deployment 中描述了“所需的状态”,并且 Deployment 以受控速率将实际状态更改为所需状态。您可以定义部署以创建新的副本集,或删除现有部署并通过新部署采用其所有资源。在 rc 滚动升级时,为了防止服务访问的中断,引入了 Deployment 资源。

接下来,我们看看 K8s 比较重要的资源 ConfigMap,其是为 Pod 的配置信息起作用,通过服务挂载的形式来提供各种配置:

代码语言:javascript复制
kind: ConfigMap
apiVersion: v1
metadata:
  name: rest-service
  namespace: system-server
data:
  application.yaml: |-
    greeting:
      message: Say Hello to the World
    ---
    spring:
      profiles: dev
    greeting:
      message: Say Hello to the Developers

    ---
    spring:
      profiles: test
    greeting:
      message: Say Hello to the Test
    ---
    spring:
      profiles: prod
    greeting:
      message: Say Hello to the Prod

当然,它支持各种形式的挂载,key-value 字符串、文件形式等。这在微服务中解耦合,非常重要,比如:在一次线上环境中,部署的服务可能需要对其某个或某几个参数进行修改,此时,如果之前编码时,将这些参数解耦到配置资源中,则可以通过修改配置来动态刷新服务配置:

代码语言:javascript复制
kubectl edit cm rest-service -n system-server

在执行这个命令编辑这个服务的配置后,我们可以看到服务的日志信息:

代码语言:javascript复制
2021-11-29 07:59:52.860:152 [OkHttp https://10.16.0.1/...] INFO  org.springframework.cloud.kubernetes.config.reload.EventBasedConfigurationChangeDetector -Detected change in config maps
2021-11-29 07:59:52.862:74 [OkHttp https://10.16.0.1/...] INFO  org.springframework.cloud.kubernetes.config.reload.EventBasedConfigurationChangeDetector -Reloading using strategy: REFRESH
2021-11-29 07:59:53.444:112 [OkHttp https://10.16.0.1/...] INFO  org.springframework.cloud.bootstrap.config.PropertySourceBootstrapConfiguration -Located property source: [BootstrapPropertySource {name='bootstrapProperties-configmap.rest-service.system-server'}]
2021-11-29 07:59:53.499:652 [OkHttp https://10.16.0.1/...] INFO  org.springframework.boot.SpringApplication -The following profiles are active: kubernetes,dev
2021-11-29 07:59:53.517:652 [OkHttp https://10.16.0.1/...] INFO  org.springframework.boot.SpringApplication -The following profiles are active: kubernetes,dev
2021-11-29 07:59:53.546:61 [OkHttp https://10.16.0.1/...] INFO  org.springframework.boot.SpringApplication -Started application in 0.677 seconds (JVM running for 968605.422)
2021-11-29 07:59:53.553:61 [OkHttp https://10.16.0.1/...] INFO  org.springframework.boot.SpringApplication -Started application in 0.685 seconds (JVM running for 968617.369)

日志中 Detected change in config mapsReloading using strategy: REFRESH,表示通过修改配置后达到了自动刷新的效果。

接下来,我们再看看服务的注册与发现,如果单纯地从 K8s 原生,那其提供了一种域名访问形式来进行服务间的相互调用:(service name).(namespace).svc.cluster.local,其中 cluster.local 为指定的集群的域名,这里表示本地集群。

同时,Service 既然是定义一个服务的多种 pod 的逻辑合集以及一种访问 pod 的策略。

Service 的类型有四种:

  • ExternalName:创建一个 DNS 别名指向 service name,这样可以防止 service name 发生变化,但需要配合 DNS 插件使用。
  • ClusterIP:默认的类型,用于为集群内 Pod 访问时,提供的固定访问地址,默认是自动分配地址,可使用 ClusterIP 关键字指定固定 IP。
  • NodePort:基于 ClusterIp,用于为集群外部访问 Service 后面 Pod 提供访问接入端口。
  • LoadBalancer:它是基于 NodePort。

从上面讲的 Service,我们可以看到一种场景:所有的微服务在一个局域网内,或者说在一个 K8s 集群下,那么可以通过 Service 用于集群内 Pod 的访问,这就是 Service 默认的一种类型 ClusterIP,ClusterIP 这种的默认会自动分配地址。

那么问题来了,既然可以通过上面的 ClusterIp 来实现集群内部的服务访问,那么如何注册服务呢?其实 K8s 并没有引入任何的注册中心,使用的就是 K8s 的 kube-dns 组件。然后 K8s 将 Service 的名称当做域名注册到 kube-dns 中,每一个 Service 在 kube-dns 中都有一条 DNS 记录,同时,如果有服务的 ip 更换,kube-dns 自动会同步,对服务来说是不需要改动的。通过 Service 的名称就可以访问其提供的服务。那么问题又来了,如果一个服务的 pod 对应有多个,那么如何实现 LB?其实,最终通过 kube-proxy,实现负载均衡。也就是说 kube-dns 通过 servicename 找到指定 clusterIP,kube-proxy 完成通过 clusterIP 到 PodIP 的过程。

说到这,我们来看下 Service 的服务发现与负载均衡的策略,Service 负载分发策略有两种:

  • RoundRobin:轮询模式,即轮询将请求转发到后端的各个 pod 上,其为默认模式。
  • SessionAffinity:基于客户端 IP 地址进行会话保持的模式,类似 IP Hash 的方式,来实现服务的负载均衡。

但这种原生提供的服务访问形式还是带有一点遗憾:就是需要带有 Service 的所在命名空间,这也许 K8s 有其自身的考虑,假如我这里有一个 Service:

代码语言:javascript复制
apiVersion: v1
kind: Service
metadata:
  name: rest-service-service
  namespace: system-server
spec:
  type: NodePort
  ports:
  - name: rest-svc
    port: 2001
    targetPort: 2001
  selector:
    app: rest-service

这个 Service 表示目标是监视 http 协议端口为 2001 的服务的一组 pod,这样,但访问该 Service 时,会通过其域名进行解析到 pod 的信息来访问 pod 的 IP 和 port:

代码语言:javascript复制
system-server   rest-service-deployment-cc7c5b559-6t4lp        1/1     Running   6          11d   10.244.0.188    leinao-deploy-server   <none>           <none>
system-server   rest-service-deployment-cc7c5b559-gpg4m        1/1     Running   6          11d   10.244.0.189    leinao-deploy-server   <none>           <none>

这样,当我们在容器内通过 rest-service-service.system-server.svc.cluster.local:2001/api 访问服务时,这样,我们可以看到默认的类型是 ClusterIP,用于为集群内 Pod 访问时,可以先通过域名来解析到 2 个服务地址信息,然后再通过 LB 策略来选择其中一个作为请求的对象。

好了,以上就是常见的几种 K8s 资源,当然,还有更多的资源(DaemonSet、StatefulSet、ReplicaSet 等)感兴趣可以参见官网。

实战 K8s 下微服务的架构实现

在《Spring Boot 2.x 结合 k8s 实现分布式微服务架构》 Chat 中,我们简单讲述了如何结合 K8s 来实现分布式微服务的架构。

但这里我们遗留了几个问题:

  • Oauth2 高可用的实现
  • 如何实现跨命名空间的服务的访问
  • 如何实现分布式服务的灰度、蓝绿发布

针对以上几点问题,我们来一一破解。

Oauth2 的高可用实现

我们知道,对于 Oauth2 原生中,提供了两种方式来进行服务的鉴权:

  • 获取用户信息来进行鉴权
  • 通过检验 token 来进行鉴权
代码语言:javascript复制
security:
  path:
    ignores: /,/index,/static/**,/css/**, /image/**, /favicon.ico, /js/**,/plugin/**,/avue.min.js,/img/**,/fonts/**
  oauth2:
    client:
      client-id: rest-service
      client-secret: rest-service-123
      user-authorization-uri: ${cas-server-url}/oauth/authorize
      access-token-uri: ${cas-server-url}/oauth/token
    resource:
      loadBalanced: true
      id: rest-service
      prefer-token-info: true
      token-info-uri: ${cas-server-url}/oauth/check_token
      #user-info-uri: ${cas-server-url}/api/v1/user
    authorization:
      check-token-access: ${cas-server-url}/oauth/check_token

配置中,user-info-uritoken-info-uri 就是用来进行服务客户端的鉴权,但不能同时存在,但对于原生的user-info-uri,并没有提供合理的鉴权逻辑,可能存在一些问题:当用户登录后,发现所有的接口都可以正常访问,无论是需要权限的,或者是不需要权限的,存在一定的问题坑

这里,我们就不再使用获取用户信息方式来进行鉴权、授权。我们来看看check_token这种方式是如何进行鉴权授权的呢?

原来,当用户携带 token 请求资源服务器的资源时, OAuth2AuthenticationProcessingFilter 会拦截 token:

最后会进入 loadAuthentication 去进行 token 的检验过程:

至于校验 Token 的处理逻辑很简单,就是调用 redisTokenStore 查询 token 的合法性,及其返回用户的部分信息:

最后如果 ok 的话,返回给在这里 RemoteTokenServices,最重要的是 **userTokenConverter.extractAuthentication(map)**,判断是否有 userDetailsService 实现,如果有的话去根据返回的信息查询一次全部的用户信息,没有实现直接返回 username:

基于此,进行 token 和 userdetails 过程,把无状态的 token 转化成用户信息。

这样其实还是服务间的互相调用,要保证这种调用的高可用,无非就是服务的多节点、Redis 的高可用。当然如果你是采用 Redis 的话,如果是 JWT 模式,那就更简单了,直接无状态形式存储 Token。我们可以把统一认证中心做成 K8s 中的 Deployment 类型:

代码语言:javascript复制
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cas-server-deployment
  namespace: system-server
  labels:
    app: cas-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: cas-server
  template:
    metadata:
      labels:
        app: cas-server
    spec:
      nodeSelector:
        cas-server: "true"
      containers:
      - name: cas-server
        image: {{ cluster_cfg['cluster']['docker-registry']['prefix'] }}cas-server
        imagePullPolicy: Always
        ports:
          - name: cas-server01
            containerPort: 2000
        volumeMounts:
        - mountPath: /home/cas-server
          name: cas-server-path
        - mountPath: /data/cas-server
          name: cas-server-log-path
        - mountPath: /etc/kubernetes
          name: kube-config-path
        - mountPath: /abnormal_data_dir
          name: abnormal-data-dir
        args: ["sh", "-c", "nohup java $JAVA_OPTS -jar -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=128m -Xms1024m -Xmx1024m -Xmn256m -Xss256k -XX:SurvivorRatio=8 -XX: UseConcMarkSweepGC cas-server.jar --spring.profiles.active=dev", "&"]
      volumes:
      - name: cas-server-path
        hostPath:
          path: /var/pai/cas-server
      - name: cas-server-log-path
        hostPath:
          path: /data/cas-server
      - name: kube-config-path
        hostPath:
          path: /etc/kubernetes
      - name: abnormal-data-dir
        hostPath:
          path: /data/images/detect_result/defect

在这里,我们定义了一个名称为 cas-server-deployment 的资源,同时,我们定义了在创建它的时候,会创建三个副本:replicas: 3,这样来保证 cas-server 的高可用。同时,我们为了它更好的被发现,我们利用 Service 资源来进行服务的负载均衡:

代码语言:javascript复制
apiVersion: v1
kind: Service
metadata:
  name: cas-server-service
  namespace: system-server
spec:
  ports:
  - name: cas-server01
    port: 2000
    targetPort: cas-server01
  selector:
    app: cas-server

这里定义的是一个目标为 http 协议,端口为 2000 的 pod 的副本集的资源,这个默认是 ClusterIP 模式的 Service,通过 Service 直接在集群内部进行访问:cas-server-service.system-server.svc.cluster.local:2000/api。这样,利用 K8s 的 Service 来实现服务注册与发现。同时,结合 Deployment 资源进行服务多节点的部署,我们就可以实现服务的高可用。

如何实现跨命名空间的服务的访问

在 K8s 中,前面讲过,只能通过命名空间的访问方式来请求其它 namespace 下的服务,对于原生 K8s 的服务调用是这样的,但是,我们基于 spring-cloud,这里可以对其进行改造。我们引入 spring-cloud-k8s 后,摒弃 基于 Ribbon 的负载均衡,我们采用基于 spring-cloud-loadbalancer 的策略来进行尝试:

代码语言:javascript复制
spring:
  application:
    name: cas-server
  cloud:
    loadbalancer:
      ribbon:
        enabled: false
    kubernetes:
      ribbon:
        mode: SERVICE
      discovery:
        all-namespaces: true

这里有个配置:spring.cloud.kubernetes.ribbon.mode=SERVICE,这个是干嘛的呢?其实际是禁用了 Ribbon 的 LB 能力,此时不会生效,走的还是 Spring cloud LoadBalancer。另外对于 Service,这里都设置为 NodePort 类型,如果是默认类型是否可以实现 LB,需要待确认,因为目前来看,没有实现,可能是网络问题,并不是说默认类型的 Service 不可实现 LB。同时,我们还是需要配置:spring.cloud.loadbalancer.ribbon.enabled = false,因为这个默认是 true 的。

当然,这里既然弃用 Ribbon,那需要引入依赖:

代码语言:javascript复制
<dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-kubernetes-loadbalancer</artifactId>
</dependency>

这样,当我们在我们进行资源服务器访问的时候,资源服务器会调用统一认证中心进行 token 的校验,此时,就可以通过 http://cas-server-service/oauth/check_token 来进行检验,这样就实现了服务的高可用。同时,即使资源服务器和统一认证中心不在同一个 namespace,也可以通过该种方式来进行请求访问。具体原理是,其会获取 K8s 集群中所有可被发现的 Service,这样对于不同 namespace 下的 Service 也就存在可以被互调的可能。

如这里在通过访问 A 命名空间下的服务时,通过 Serice 访问后看到日志:

这就说明,可以通过 Service 方式实现跨命名空间的服务互调。

如何实现分布式服务的灰度、蓝绿发布

在云原生最佳实践中,涵盖了灰度发布、弹性伸缩、集群迁移、网络通信、应用容器化改造等等场景,今天我们就来利用 K8s 原生技术来实现分布式微服务的灰度发布以及蓝绿发布

通常使用无状态负载 Deployment、有状态负载 StatefulSet 等 Kubernetes 对象来部署业务,每个工作负载管理一组 Pod。以 Deployment 为例,示意图如下:

我们为这个工作服务来创建 Service,Service 通过 selector 来选择服务节点 Pod,接下来,我们进行灰度发布该工作应用负载。

灰度发布

通常会为每个 Deployment 类型的应用负载创建一个 Service,但 K8s 并未限制 Service 需与 Deployment 负载是一一对应关系。Service 只通过 selector 匹配负载节点 Pod,若不同 Deployment 的负载节点 Pod 被同一 selector 选中,即可实现一个 Service 对应多个版本 Deployment。调整不同版本 Deployment 的副本数,即可调整不同版本服务的权重,来实现灰度发布。

那么,既然了解到灰度发布的原理,我们来进行实战,假如这里有一个服务提供者 diss-ns-service,此时我们可以对其进行创建不同版本的 Deployment 类型负载 Pod:

代码语言:javascript复制
apiVersion: apps/v1
kind: Deployment
metadata:
  name: diff-ns-service-v1-deployment
  namespace: ns-app
  labels:
    app: diff-ns-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: diff-ns-service
      version: v1
  template:
    metadata:
      labels:
        app: diff-ns-service
        version: v1
    spec:
      nodeSelector:
        diff-ns-service: "true"
      containers:
      - name: diff-ns-service
        image: diff-ns-service:v1
        imagePullPolicy: Always
        ports:
          - name: diff-ns
            containerPort: 2001
        volumeMounts:
        - mountPath: /home/diff-ns-service
          name: diff-ns-service-path
        - mountPath: /data/diff-ns-service
          name: diff-ns-service-log-path
        - mountPath: /etc/kubernetes
          name: kube-config-path
        - mountPath: /abnormal_data_dir
          name: abnormal-data-dir
        args: ["sh", "-c", "nohup java $JAVA_OPTS -jar -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=128m -Xms1024m -Xmx1024m -Xmn256m -Xss256k -XX:SurvivorRatio=8 -XX: UseConcMarkSweepGC diff-ns-service.jar --spring.profiles.active=dev", "&"]
      volumes:
      - name: diff-ns-service-path
        hostPath:
          path: /var/pai/diff-ns-service
      - name: diff-ns-service-log-path
        hostPath:
          path: /data/diff-ns-service
      - name: kube-config-path
        hostPath:
          path: /etc/kubernetes
      - name: abnormal-data-dir
        hostPath:
          path: /data/images/detect_result/defect

以上是 v1 版本的应用负载,接下来 v2 版本也是一样的:

代码语言:javascript复制
apiVersion: apps/v1
kind: Deployment
metadata:
  name: diff-ns-service-v2-deployment
  namespace: ns-app
  labels:
    app: diff-ns-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: diff-ns-service
      version: v2
  template:
    metadata:
      labels:
        app: diff-ns-service
        version: v2
    spec:
      nodeSelector:
        diff-ns-service: "true"
      containers:
      - name: diff-ns-service
        image: diff-ns-service:v2
        imagePullPolicy: Always
        ports:
          - name: diff-ns
            containerPort: 2001
        volumeMounts:
        - mountPath: /home/diff-ns-service
          name: diff-ns-service-path
        - mountPath: /data/diff-ns-service
          name: diff-ns-service-log-path
        - mountPath: /etc/kubernetes
          name: kube-config-path
        - mountPath: /abnormal_data_dir
          name: abnormal-data-dir
        args: ["sh", "-c", "nohup java $JAVA_OPTS -jar -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=128m -Xms1024m -Xmx1024m -Xmn256m -Xss256k -XX:SurvivorRatio=8 -XX: UseConcMarkSweepGC diff-ns-service.jar --spring.profiles.active=dev", "&"]
      volumes:
      - name: diff-ns-service-path
        hostPath:
          path: /var/pai/diff-ns-service
      - name: diff-ns-service-log-path
        hostPath:
          path: /data/diff-ns-service
      - name: kube-config-path
        hostPath:
          path: /etc/kubernetes
      - name: abnormal-data-dir
        hostPath:
          path: /data/images/detect_result/defect

同时,这里我们对负载的副本数都设置为 3,表示 3 个负载节点 pod,此时,我们创建完之后可以看到:

代码语言:javascript复制
ns-app          diff-ns-service-v1-deployment-d88b9c4fd-22lgb     1/1     Running   0          12s
ns-app          diff-ns-service-v1-deployment-d88b9c4fd-cgsqw     1/1     Running   0          12s
ns-app          diff-ns-service-v1-deployment-d88b9c4fd-hmcbq     1/1     Running   0          12s

ns-app          diff-ns-service-v2-deployment-37bf53d4b-43w23     1/1     Running   0          12s
ns-app          diff-ns-service-v2-deployment-37bf53d4b-ce33g     1/1     Running   0          12s
ns-app          diff-ns-service-v2-deployment-37bf53d4b-scds6     1/1     Running   0          12s

这样,我们对负载 diff-ns-service 创建了不同版本的资源 Pod,接下来,我们创建一个 Service,这个是这样的 YAML:

代码语言:javascript复制
apiVersion: v1
kind: Service
metadata:
  name: diff-ns-service-service
  namespace: ns-app
spec:
  ports:
  - name: diff-ns-svc
    port: 2001
    targetPort: 2001
  selector:
    app: diff-ns-service

我们可以看到在 selector 中不指定版本,这样,可以让 Service 同时选中两个版本的 Deployment 的 Pod。此时,我们通过脚本命令来执行访问:

代码语言:javascript复制
for i in {1..10}; do curl http://diff-ns-service-service/getservicedetail?servicename=aaa; done;

我们来看看打印的日志:

代码语言:javascript复制
diff-ns-service-v1
diff-ns-service-v2
diff-ns-service-v1
diff-ns-service-v2
diff-ns-service-v1
diff-ns-service-v2
diff-ns-service-v1
diff-ns-service-v2
diff-ns-service-v1
diff-ns-service-v2

可以看到返回结果一半为 v1 版本的响应,一半为 v2 版本的响应。

接下来,我们通过 kubectl 方式修改负载的副本数:

代码语言:javascript复制
kubectl scale deployment/diff-ns-service-v2-deployment --replicas=4

kubectl scale deployment/diff-ns-service-v1-deployment --replicas=1

因为我们需要作版本的更新,所以把新版本 v2 设置为 4,旧版本 v1 的设置为 1,接下来,我们继续通过 curl 命令来测试:

代码语言:javascript复制
diff-ns-service-v2
diff-ns-service-v2
diff-ns-service-v1
diff-ns-service-v2
diff-ns-service-v1
diff-ns-service-v2
diff-ns-service-v2
diff-ns-service-v2
diff-ns-service-v2
diff-ns-service-v2

我们从结果可以发现,10 次请求访问中,只有 2 次访问的是 v1 的旧版本,v1 与 v2 版本的响应比例与其副本数比例一致,为 4:1。通过控制不同版本服务的副本数就实现了灰度发布。

蓝绿发布

接下来看看蓝绿发布,蓝绿发布的原理与灰度发布稍微不同,集群中已部署两个不同版本的 Deployment,其负载 Pod 拥有共同的 label。但有一个 label 值不同,用于区分不同的版本。Service 使用 selector 选中了其中一个版本的 Deployment 的 Pod,此时通过修改 Service 的 selector 中决定服务版本的 label 的值来改变 Service 后端对应的 Pod,即可实现让服务从一个版本直接切换到另一个版本。

所以从原理上看,我们创建的 Service 除了包括本身的一些信息,还需要包括版本信息:

代码语言:javascript复制
apiVersion: v1
kind: Service
metadata:
  name: diff-ns-service-service
  namespace: ns-app
spec:
  ports:
  - name: diff-ns-svc
    port: 2001
    targetPort: 2001
  selector:
    app: diff-ns-service
    version: v1

同样,执行以下命令,测试访问。

代码语言:javascript复制
for i in {1..10}; do curl http://diff-ns-service-service/getservicedetail?servicename=aaa; done;

返回结果如下,均为 v1 版本的响应:

代码语言:javascript复制
diff-ns-service-v1
diff-ns-service-v1
diff-ns-service-v1
diff-ns-service-v1
diff-ns-service-v1
diff-ns-service-v1
diff-ns-service-v1
diff-ns-service-v1
diff-ns-service-v1
diff-ns-service-v1

我们通过 kubectl 方式修改 Service 的 label:

代码语言:javascript复制
kubectl patch service diff-ns-service-service -p '{"spec":{"selector":{"version":"v2"}}}'

再次,执行以下命令,测试访问。

代码语言:javascript复制
for i in {1..10}; do curl http://diff-ns-service-service/getservicedetail?servicename=aaa; done;

返回结果如下:

代码语言:javascript复制
diff-ns-service-v2
diff-ns-service-v2
diff-ns-service-v2
diff-ns-service-v2
diff-ns-service-v2
diff-ns-service-v2
diff-ns-service-v2
diff-ns-service-v2
diff-ns-service-v2
diff-ns-service-v2

结果均为 v2 版本的响应,成功实现了蓝绿发布。

结束语

云原生技术与微服务架构的天衣无缝

云原生的微服务架构是云原生技术和微服务架构的完美结合。微服务作为一种架构风格,所解决的问题是交纵复杂的软件系统的架构与设计;云原生技术乃一种实现方式,所解决的问题是软件系统的运行、维护和治理。微服务架构可以选择不同的实现方式,如 Java 中的 Dubbo、Spring Cloud、Spring Cloud Alibaba,Golang 中的 Beego,Python 中的 Flask 等。但这些不同语言的服务之间的访问与运行可能存在一定得困难性与复杂性。但,云原生和微服务架构的结合,使得它们相得益彰。这其中的原因在于:云原生技术可以有效地弥补微服务架构所带来的实现上的复杂度;微服务架构难以落地的一个重要原因是它过于复杂,对开发团队的组织管理、技术水平和运维能力都提出了极高的要求。因此,一直以来只有少数技术实力雄厚的大企业会采用微服务架构。随着云原生技术的流行,在弥补了微服务架构的这一个短板之后,极大地降低了微服务架构实现的复杂度,使得广大的中小企业有能力在实践中应用微服务架构。云原生技术促进了微服务架构的推广,也是微服务架构落地的最佳搭配。

云原生时代的微服务的未来

云原生的第一个发展趋势:标准化和规范化,该技术的基础是容器化和容器编排技术,最经常会用到的技术是 Kubernetes 和 Docker 等。随着云原生技术的发展,云原生技术的标准化和规范化工作正在不断推进,其目的是促进技术的发展和避免供应商锁定的问题,这对于整个云原生技术的生态系统是至关重要的。

云原生的第二个发展趋势:平台化,以服务网格技术为代表,这一趋势的出发点是增强云平台的能力,从而降低运维的复杂度。流量控制、身份认证和访问控制、性能指标数据收集、分布式服务追踪和集中式日志管理等功能,都可以由底层平台来提供,这就极大地降低了中小企业在运行和维护云原生应用时的复杂度,服务网格以 Istio 和 Linkerd 为开源代表。

云原生的第三个发展趋势:应用管理技术的进步,如在 Kubernetes 平台上部署和更新应用一直以来都比较复杂,传统的基于资源声明 YAML 文件的做法,已经逐步被 Helm 所替代。操作员模式在 Helm 的基础上更进一步,以更高效、自动化和可扩展的方式对应用部署进行管理。

0 人点赞