目录
- 一、引言
- 二、方式1:在K8S上部署Spring Cloud Alibaba
- 三、方式2:在K8S上部署Spring Cloud K8S
- 3.1 第1次优化:移除Spring Cloud K8S DiscoveryClient
- 四、方式3:在K8S上部署SpringBoot应用
- 4.1 第2次优化:移除Spring Cloud K8S Config
- 4.2 支持配置自动刷新
- 五、关于3种方式的选择
- 六、方式4:拥抱Service Mesh
- 七、关于Devops
一、引言
传统后端Java & Spring单体架构在向微服务架构升级的过程中,我们引入了Spring Cloud(Netflex、Alibaba等)框架,Spring Cloud帮我们解决了分布式服务的注册发现、服务的动态配置等等。以目前国内比较流行的Spring Cloud Alibaba为例,我们需要在我们的代码中集成Spring Cloud Alibaba的相关依赖,然后单独部署Nacos服务端、Seata服务端等。我们的确借助Spring Cloud框架完成了微服务架构下的绝大部分功能,但是代价就是我们的代码不得不依赖Spring Cloud框架,后续随着Java版本、Spring版本的升级,我们的代码也需要不断去适配新版本的Spring Cloud框架,同时我们还需要维护和升级服务注册中心、配置中心等的部署,给开发和运维人员都带来不小的压力。
随着云原生时代的到来,容器与K8S已成为PasS平台的事实标准。
容器(如Docker)
为更轻量的虚拟化技术,将应用打包成容器,能够保持多环境运行的一致性,快速部署迁移。K8S 可以理解为负责集群节点编排、容器编排的平台,管理集群中的部署节点、容器的部署与调度编排、容器间的访问路由编排等。
云原生
是在云计算环境中构建、部署和管理现代应用程序的软件方法。CNCF 将不可变基础设施、微服务、声明式 API、容器和服务网格列为云原生架构的核心技术。
云原生应用程序开发
描述了开发人员如何以及在何处构建和部署云原生应用程序。开发人员采用特定的软件实践来缩短软件交付时间,并提供满足不断变化的用户期望的准确功能。一些常见的云原生开发实践包括CI、CD、Devops、Serverless。
Service Mesh服务网格(如Istio)
可以简单理解为K8S容器管理平台之上的微服务管理平台,不侵入代码(跨编程语言),通过Sidecar(伴生容器)的形式将原本微服务框架中的基础功能(服务注册发现、服务路由、流量分发、熔断、限流、监控、安全等)提取到基础设施层,但额外引入的Sidecar会增加集群的资源损耗、请求延时等。
Serverless无服务器
无服务器计算是一种云原生模式,云提供商完全管理底层服务器基础设施。开发人员之所以使用无服务器计算,是因为云基础设施会自动扩展和配置以满足应用程序要求。开发人员只需为应用程序使用的资源付费。当应用程序停止运行时,无服务器架构会自动移除计算资源。
参考:https://aws.amazon.com/cn/what-is/cloud-native/
我们在将后端微服务架构向云原生迁移的过程中,也出现了如下几种方式。
二、方式1:在K8S上部署Spring Cloud Alibaba
有的团队之前使用过Spring Cloud Alibaba,所以在向云原生K8S平台迁移的时候,保持原代码不变,直接在K8S平台上部署了一套Nacos服务端等组件,然后将应用发布到K8S平台,应用依旧使用Nacos进行服务注册发现和配置管理。
注: 通常会将Nacos也部署到K8S集群内,
若Nacos部署到K8S集群外,集群外IP地址空间和K8S集群内IP地址空间不通,会导致Nacos与K8S集群内应用间无法进行通信。
三、方式2:在K8S上部署Spring Cloud K8S
有的团队调研发现了Spring Cloud K8S,可以将Spring Cloud框架和K8S平台进行融合,所以在代码中集成Spring Cloud K8S框架,Spring Cloud K8S同样包括了DiscoveryClient和Config两大模块,分别实现了Spring Cloud的服务发现、配置管理等接口。集成Spring Cloud K8S框架的应用通过查询K8S API获取服务的Endpoint、获取对应的ConfigMap,所以应用部署到K8S时,还需赋予应用查询K8S API的K8S平台相关权限,K8S相关权限定义可参见Spring Cloud K8S官网示例。
集成Spring Cloud K8S,除了代码端需要依赖Spring Cloud K8S框架,还需要在K8S平台定义:
- Service、Deployment - 用于获得服务的注册信息
- ConfigMap - 用来指定应用的配置信息
同时还需要区分本地开发环境(无K8S)和线上运行环境(有K8S)的服务间调用方式(URL或服务发现)、配置读取(本地配置或ConfigMap配置及其优先级),通常会在代码中bootstrap.yaml默认关闭Spring Cloud K8S(默认开发环境禁用Spring Cloud K8S),
# bootstrap.yaml
spring:
cloud:
kubernetes:
enabled: false
然后在线上K8S环境通过环境变量开启Spring Cloud K8S:
# deployment.yaml
...
env:
- name: SPRING_CLOUD_KUBENETERS_ENABLED
value: "true"
...
Spring Cloud K8S、Spring Cloud Alibaba等Spring Cloud方案,都需要侵入代码,都有一定的学习成本,且后续升级还需要各种适配,还需要考虑本地和线上运行环境的不同。Spring Cloud K8S方案相较于Spring Cloud Alibaba,不需要部署单独的中间件(如Nacos),充分利用了K8S平台本身提供的Service、ConfigMap等特性,减少了单独维护Spring Cloud各方案本身提供的服务注册中心、配置中心(如Nacos、Eureka等)的工作量,但如果有分布式事务、熔断限流等需求,还是要在代码层面解决,目前K8S平台暂不提供相关功能。
3.1 第1次优化:移除Spring Cloud K8S DiscoveryClient
前文提到过Spring Cloud K8S包括DiscoveryClient和Config两大模块,分别实现了Spring Cloud的服务发现、配置管理接口。由于K8S本身支持Service概念,所以可以直接借助K8S平台本身基于Service的服务注册发现机制,将代码中的Spring Cloud K8S Discovery Client完全移除。如此在本地开发环境通过应用URL进行相互调用,在线上K8S环境同样通过URL(对应K8S Service)进行调用,二者都是通过直接指定URL进行调用(行为一致),在线上K8S环境通过指定Service URL(如:http://serviceName.namespace:8080),底层的服务注册发现、服务转发等均有K8S平台来完成,代码端从此不再需要关注服务注册发现、负载均衡(由K8S底层代理模式决定)等问题。
此种方式下,代码端仅依赖Spring Cloud K8S Config模块,本地开发和线上环境均通过URL进行服务间相互调用,充分利用了K8S平台本身的Service机制。
四、方式3:在K8S上部署SpringBoot应用
之前通过集成Spring Cloud K8S框架,貌似让我们的微服务架构更倾向于云原生了,但是开发者需要同时对Spring Cloud、Spring Cloud K8S、K8S平台本身都有所了解,即便前文提到的移除Spring Cloud K8S DiscoveryClient的方式,开发者还是需要关注K8S上ConfigMap中的配置格式及配置优先级、本地与线上运行环境行为不一致等问题,给开发者带来了较大的学习成本和不确定性。
既然我们已经利用K8S Service替代了原来的Spring Cloud K8S DiscoveryClient模块,那我们是否也可以利用K8S平台的某些特性替代Spring Cloud K8S Config模块呢?如此我们的代码端完全不依赖Spring Cloud K8S框架,在线上K8S环境充分利用K8S平台的特性,从而使我们的代码端与K8S彻底解耦。
4.1 第2次优化:移除Spring Cloud K8S Config
我们的后端Java应用(SpringBoot)通常都是以jar包形式运行,jar包内的配置文件即为内置的配置文件,通常也是我们开发环境代码端的配置,在线上运行环境时我们需要一种方式来替换jar包内原有的配置,如之前使用Spring Cloud框架时代码端会集成相应的配置中心(如Spring Cloud Alibaba依赖Nacos),又或者集成单独的配置管理中心(如Apollo等),此种方式的代价就是侵入代码,且需要依赖独立的配置管理中心。
回到外部配置覆盖jar内配置这个问题本身,SpringBoot自身提供了自定义config/目录的方案,config/目录需位于执行java启动命令的工作目录下,且config/目录内的配置优先级高于jar内配置的优先级,此种方式不需要依赖独立的配置中心。
而在K8S平台中ConfigMap本身就是用于管理配置的,并支持挂载到容器中的指定目录,ConfigMap中的内容发生改变时,也会近实时地更新容器内挂载的文件内容。如此在K8S环境中通过ConfigMap定义线上环境的配置,然后通过挂载到应用运行容器的config/目录下,即可达到K8S平台上的外部配置ConfigMap替换jar包内配置的效果。此种方式充分利用了SpringBoot自身的外部配置特性,对应用代码没有任何改动,也无需集成配置中心组件(如Spring Cloud K8S Config、Spring Cloud Alibaba Config等),而在线上利用K8S ConfigMap挂载到容器目录的方式完成了外部配置的替换。具体K8S Deployment挂载ConfigMap到容器的脚本示例如下:
# 应用配置描述文件
kind: ConfigMap
apiVersion: v1
metadata:
name: app-atom
data:
application.yaml: |-
osmium:
# atom相关属性
atom:
props:
name: myName
age: 30
other: otherValue
---
# 应用部署描述文件
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-atom
# ...
spec:
# ...
spec:
# 声明卷
volumes:
# 声明ConfigMap卷
- name: app-config
configMap:
# 对应K8s ConfigMap名称
name: app-atom
containers:
- name: app-atom
image: $DOCKER_REGISTRY/$DOCKER_NAMESPACE/$APP_NAME_ATOM:$IMAGE_TAG
# ...
# 挂载ConfigMap配置文件到/config目录下(config目录需位于执行java启动命令的工作目录下)
volumeMounts:
- name: app-config
mountPath: /config/
# ...
综上,完整的K8S脚本Deployment、Service、ConfigMap定义如下图:
K8S各资源间的关系如下图:
4.2 支持配置自动刷新
使用config/目录定义外部配置文件这种方式,在config/目录下的配置文件发生改变时,默认应用端是不支持动态刷新的。若想支持外部配置的动态刷新,可通过集成spring-cloud-context组件,该组件结合spring-boot-starter-actuator组件,在外部配文件发生变更后,可手动调用POST /actuator/refresh
端点触发配置的动态刷新,此方式会对Spring Environment
、标记@ConfigurationProperties的属性类
及标记@RefreshScope(包含@Value属性)的类
进行动态更新,从而达到刷新配置的效果。若想支持在外部配置发生变更时自动刷新应用配置,可通过监听config/目录下的配置文件是否发生变更,若发生变更则调用Spring Cloud Context的配置刷新方法ContextRefresher.refresh()
以刷新应用的配置。此种集成Spring Cloud Context及创建config/目录以支持配置动态刷新完全是Spring框架自身的特性,仅依赖文件系统,不依赖K8S平台,即便后续切换到不同PasS平台也完全没有关系。监听配置目录及触发配置变更的核心代码如下:
/**
* 配置监听属性
*
* @author luohq
* @date 2023-04-13 15:44
*/
@Data
@ConfigurationProperties(prefix = ConfigMonitorProps.PREFIX)
public class ConfigMonitorProps {
/**
* 配置前缀
*/
public static final String PREFIX = "osmium.config.monitor";
/**
* 是否启用配置监听
*/
private Boolean enabled = true;
/**
* 外部配置文件所在的文件夹<br/>
* 注:推荐将配置文件统一放到单独的config文件下,且位于运行java命令的工作目录下,如此Spring会默认读取config文件夹下的配置文件,且config文件下的配置优先级高于jar包内(classpath)配置文件
*/
private String dirPath;
}
---
/**
* 配置监听、通知服务
*
* @author luohq
* @date 2023-04-13
*/
public class WatchNotifyService {
private static final Logger log = LoggerFactory.getLogger(WatchNotifyService.class);
private ConfigMonitorProps configMonitorProps;
private ContextRefresher contextRefresher;
public WatchNotifyService(ConfigMonitorProps configMonitorProps, ContextRefresher contextRefresher) {
this.configMonitorProps = configMonitorProps;
this.contextRefresher = contextRefresher;
}
@PostConstruct
void initStartWatch() {
new Thread(() -> {
startWatch();
}, "CONFIG-WATCHING-THREAD").start();
}
/**
* 启动配置文件夹监听
*/
public void startWatch() {
try {
if (!StringUtils.hasText(this.configMonitorProps.getDirPath())) {
log.warn("Stop Config Directory Watching because the dir-path is blank!");
return;
}
//监听目录
Path path = Paths.get(this.configMonitorProps.getDirPath());
if (!Files.isDirectory(path)) {
log.warn("Stop Config Directory Watching because the dir-path: {} is not a directory!", this.configMonitorProps.getDirPath());
return;
}
log.info("Start Config Directory Watching in dir: '{}'", this.configMonitorProps.getDirPath());
//创建一个文件系统的监听服务
WatchService watchService = FileSystems.getDefault().newWatchService();
//为该文件夹注册监听,监听新建、修改、删除事件。只能为文件夹(目录)注册监听,不能为单个文件注册监听
path.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE);
//编写事件处理
while (true) {
//拉取一个WatchKey。当触发监听的事件时,就会产生一个WatchKey,此WatchKey封装了事件信息。
WatchKey watchKey = watchService.take();
//使用循环是因为这一个WatchKey中可能有多个文件变化了,比如Ctrl+A全选,然后删除,只触发了一个WatchKey,但有多个文件变化了
for (WatchEvent event : watchKey.pollEvents()) {
log.info("WATCHING {}/{} - {}", this.configMonitorProps.getDirPath(), event.context(), event.kind());
}
//刷新配置
this.refreshConfig();
//虽然是while()循环,但WatchKey和ByteBuffer一样,使用完要重置状态,才能继续用。
//如果不重置,WatchKey使用一次过后就不能再使用,即只能监听到一次文件变化。
watchKey.reset();
}
} catch (Throwable ex) {
log.error("Config Directory Watching Exception!", ex);
}
}
/**
* 刷新配置
*
* @return 被刷新的属性名
*/
public Set<String> refreshConfig() {
log.info("START REFRESH CONTEXT CONFIG");
//更新Spring Cloud配置
Set<String> refreshKeys = this.contextRefresher.refresh();
return refreshKeys;
}
}
综上,在代码中我们仅仅只是搭建了一个SpringBoot应用,无需依赖各种Spring Cloud实现方案,应用间的相互调用使用URL,而外部配置的替换遵循SpringBoot自带的config/目录形式,而在线上K8S环境则通过K8S Service支持应用服务的注册发现,通过K8S ConfigMap挂载config/目录的形式支持外部配置的替换。此种方式也是笔者推崇的Spring应用向K8S迁移的方式,此种方式使得代码端更清爽,不必依赖繁重的Spring Cloud框架,也不用考虑后续Spring Cloud框架的升级,开发人员可以更专注于业务功能的实现,而微服务架构相关的服务注册发现、配置管理都可以交由K8S平台完成,做到了代码端即不依赖微服务框架(Spring Cloud)、也不依赖于线上K8S环境,而线上K8S平台完全以非侵入的方式来完成微服务框架的工作。此种方式中,K8S平台可以很好的解决服务注册发现、服务负载均衡、外部配置管理的问题,但如果有分布式事务、熔断、限流、服务追踪等需求,还是要在代码层面解决,目前K8S平台暂不提供相关功能。
此种方式中,我们依赖K8S平台做了很多功能,可能有的人会顾虑架构的实现过多依赖于K8S,强绑定到了K8S平台。K8S作为目前云原生时代的PasS平台事实上的标准,即便后续出现了替代品,那想必能替代K8S的必定强于K8S,K8S能做到的它应该都能做到甚至做的更多、更好。并且此种方式中我们并没有像Spring Cloud K8S强耦合K8S平台专有的API,而是已非侵入代码的方式、URL、文件目录等形式,保证了代码端可以在完全不依赖K8S平台的环境下运行,做到了没有强绑定K8S平台,但是K8S平台又能为我们的应用架构锦上添花,即便后续切换到了不同PaaS平台,也可以利用相似的机制保证应用的正常运行。
五、关于3种方式的选择
回到最开始的方式1:在K8S上部署Spring Cloud Alibaba
,我们需要:
- 在代码端维护Spring Cloud Alibaba相关依赖并且后续每次的升级都要小心谨慎
- 通过单独部署Nacos来完成服务注册发现、配置管理,上线后还需在K8S环境部署Nacos
- 开发人员在代码端反复调试以确认是否正确集成Spring Cloud Alibaba
若采用方式2:在K8S上部署Spring Cloud K8S
,我们需要:
- 在代码端维护Spring Cloud K8S相关依赖并且后续每次的升级都要小心谨慎
- 本地开发环境禁用Spring Cloud K8S,线上K8S环境需要开启Spring Cloud K8S
- 无需单独部署Nacos,上线后由K8S平台负责服务的注册发现、外部配置管理
- 开发人员需要同时对Spring Cloud K8S、K8S平台本身都有所了解,且开发人员需在线上K8S环境反复调试以确认是否正确集成Spring Cloud K8S(使用Spring Cloud K8S框架需要在K8S环境中进行调试,而对于开发人员来说部署K8S集群远比部署一个Nacos服务端要困难的多)
若采用方式3:在K8S上部署SpringBoot应用
,我们需要:
- 在代码端仅仅维护一个SpringBoot应用,无需依赖Spring Cloud框架的各种实现依赖,代码端更清爽
- 代码端无需关注服务的注册发现、线上环境的配置替换,开发人员可以更多的关注核心业务的实现
- 线上K8S环境完全以非侵入的方式完成服务的注册发现(Service)、配置替换(ConfigMap),而应用部署到线上K8S由架构师团队、运维团队统一制定规则,不与代码相耦合。
综合考量,以上3种方式中:
- 最推荐
方式3:在K8S上部署SpringBoot应用
,由K8S平台负责服务注册发现、服务间负载均衡、外部配置管理 - 坚决不推荐
方式2:在K8S上部署Spring Cloud K8S
,不要给自己找麻烦,夹在Spring Cloud和K8S之间进退两难,建议直接切换到方式3:在K8S上部署SpringBoot应用
- 若已采用了
方式1:在K8S上部署Spring Cloud Alibaba
,同样建议切换到方式3:在K8S上部署SpringBoot应用
,做减法远比做加法更容易,切换后会发现负担更小了,神清气爽…
六、方式4:拥抱Service Mesh
K8S平台能帮我们以不侵入代码的方式解决微服务架构下的服务注册发现、服务负载均衡、外部配置管理的问题,但是微服务架构下还包括许多其他的需求,例如熔断、限流、服务追踪等,在K8S平台之上我们还可以选择另一种方式:服务网格(Istio)。
Service Mesh服务网格(如Istio) 可以简单理解为K8S容器管理平台之上的微服务管理平台,不侵入代码(跨编程语言),通过Sidecar(伴生容器)的形式将原本微服务框架中的基础功能(服务注册发现、服务路由、流量分发、熔断、限流、监控、安全等)提取到基础设施层,但额外引入的Sidecar会增加集群的资源损耗、请求延时等。
比如我之前经历过的一次架构决策,当时整个团队刚刚从Spring升级到SpringBoot生态,我们并没有选择Spring Cloud框架,而是直接跨过了Spring Cloud,选择了K8S + ServiceMesh Istio的架构方案。
之所以选择K8s + Istio,主要出于以下几个方面考虑:
- Service Mesh不侵入代码,对我们的代码改动量最小(仅在需要支持分布式服务追踪功能时改造Http调用工具 - 透传追踪请求头)
- Service Mesh - Istio可以满足我们对服务注册发现、服务多版本路由、Http接口级别的熔断、服务追踪、更细粒度的负载均衡策略等的基础需求
- 我们的开发团队不需要大规模修改之前的代码(无需纠结集成Spring Cloud框架及后续框架的升级),可以更多的关注核心业务功能的开发
- Istio可以满足我们灰度发布的需求(如根据用户ID分发流量到不同版本的服务)
- 除了后端Java技术栈,我们还有前端NodeJs技术栈、Python技术栈,同样可以借助Istio实现跨开发语言的微服务架构的基础功能
- 拥有富有经验的运维和架构团队
整个迁移过程对开发团队几乎无感,由运维团队负责K8S的搭建与运维,然后由架构团队、运维团队协作完成Devops流水线编排、Istio服务编排规则的制定。整个迁移过程历时将近3个月,拥有极陡峭的学习曲线,对运维与架构团队也是提出了一定的挑战,好在最终顺利完成迁移工作。此次迁移过程可以理解为一次拥抱未来的尝试,虽并不是当下最稳妥的方式,但对于开发团队来说是成本相对较低的方式。
七、关于Devops
之前见过在代码中通过Maven插件做Docker镜像构建、K8S部署的,此种方式使得我们代码构建的生命周期和Devops功能相耦合,并且其对构建环境也是有特定要求的(如依赖docker命令、kubectl命令等),可能还会在代码库中暴露相关凭证信息(如Harbor账号密码、kubeconfig配置等),开发者很难在本地开发环境对其进行调试,且增加了维护成本。还有就是通过自定义sh脚本的方式,此种方式虽不与代码构建的生命周期相绑定,但同样存在对特定执行环境的要求、暴露相关凭证的风险,同时不具备行业共识性,同样的脚本换到新团队肯定是要重新进行测试、评估以确认其可行性的。可以参见之前的思路,我们已经尽可能将原来耦合在代码里的微服务架构的功能都转移到K8S Pass平台,那我们是否也可以将耦合在代码中的Devops功能也抽取到云原生时代所推崇的Devops平台呢?笔者是推荐将Devops相关的功能都交由专用的Devops平台来做的,例如使用Jenkins平台,所有的凭证(Docker凭证、K8s凭证、Gitlab凭证)都在Jenkins平台进行管理,可由Devops工程师在相应代码库中(或者独立的代码库中)建立单独的文件夹维护Jenkins流水线Pipeline脚本、Dockerfile、K8S部署脚本等,此种方式可以将我们的代码和Devops解耦,所有和Devops相关的流程、运行环境都交给标准的Devops平台来完成,如此也有助于在团队内部形成通用的Devops构建流程,达成共识,形成复用。