了解了云原生的定义之后,我们来看一下云原生的特性,本节主要围绕12要素展开,介绍云原生的核心设计理念。
Heroku创始人Adam Wiggins在“The Twelve-Factor App”一文中提出了12要素方法论,如表1-1所示,给出了开发人员应该遵循的一组原则和实践,以构建针对现代云环境优化的应用。相比于不符合这些特征的传统应用服务,具备这些特征的应用更适合云化。
表1-1 12要素
续表
要理解“一套代码,多次部署”的理念,首先要区分什么是应用,什么是部署。一个应用对应的是一个代码仓库,比如一个“消息推送应用”的名称是“api-push”,对应的Git地址是 https://gitlab.ziroom.com/tech-base/api-push ,由此可见,一套代码是一个静态的概念。一次部署对应的是一个运行起来的应用,更多的是一个运行时的表述。代码与部署是一对多的关系,这种关系体现了代码的可重用性,一套代码可以多次、反复部署,不同部署之间区分的是配置。代码基线是相同的,比如常见的多环境部署,一次需求的特性代码分支一般会部署到开发环境、测试环境、预发环境、生产环境中,而生产环境可能根据业务需要有不同的4个服务或8个服务。不同的环境中一天可能有多次重新部署,中型互联网公司平均一天500~1000次部署是非常常见的。
同时,这一理念也是对康威定律的证明,即团队的组织形态最终将反映在团队构建的产品架构图中。功能障碍、组织不力和团队间缺乏纪律将导致代码出现相同的问题。
每个微服务显式声明、隔离自己的依赖项(Explicitly Declare and Isolate Dependencies)。
在经典的企业环境中,我们习惯了“Mommy server”(妈妈服务器)这个概念,给应用提供需要的一切,并满足应用的依赖关系。云是经典企业模式的成熟阶段,我们的应用也应该像云一样成熟,应用不能假设云服务器能提供它需要的一切,相反,应用需要将其依赖项与自身聚合到一起。迁移到云,使开发变得成熟,意味着我们的组织可以摆脱对“Mommy server”的依赖。
这条理念强调了两点:一是依赖要显式声明,Java体系有成熟且完整的Maven体系,通过Maven配置加上可视化的工具,能够很好地查看依赖树;二是做好依赖的隔离,一般可以通过分组、名称、版本区分不同的依赖。开发人员经常会遇到版本冲突的问题,很多是因为依赖的三方包中存在间接依赖,导致层级低的版本覆盖了所需的高版本,常见的如Log4j、Logback。做好显式声明和隔离格外重要。
将配置信息存储到环境的上下文中(Store Configuration in the Environment)。
不同的部署共享一套代码,但是不共享同套配置。代码是存储在代码仓库中的,而配置往往不是存储在代码仓库中,更多是根据环境变量来动态读取或加载的。我们通常会有开发环境、测试环境、预发环境、生产环境等用于不同生命周期的环境,不同的环境一般会有网络或存储的隔离,对应的数据库地址、缓存连接、注册中心的集群配置显然都是不一样的,这些信息如果与代码一起写到代码仓库中,极有可能造成生产环境读取配置信息错误,进而引发灾难性故障。在更成熟的企业中,一般都是配置与代码分离,通过Apollo、Nacos、Dockerfile、不同的YAML等手段来动态加载。
这里要注意,并不是所有以.xml结尾的文件都是配置文件,比如web.xml、log4j.xml、mybatis.xml等,这些形似配置信息的文件其实仍属于代码的范畴。大家对此可能有些疑惑,在此推荐有一个很好用的判断标准——代码变化的频率。代码的变动往往会导致产品功能的迭代,使产品更新。而配置的变更往往只是环境信息的变化,不会导致产品功能版本更新。
以上标准适合大部分代码与配置的区分,当然不是绝对的,比如秒杀开关、大促价格开关等属于配置项,有时候也会影响前台业务的信息。我们也可以把代码想象成推送到GitHub开源后的后果,如果公众都能访问到你的代码,你是否暴露了应用所依赖的资源或服务的敏感信息,比如内部网址、支持服务的凭据等。
Beyond the Twelve-Factor App中有一个比喻,将配置、凭据、代码视为组合时会爆炸的易变物质。我们需要把配置(尤其是密钥、功能开关、策略类配置)的重要性提升到很高的级别,并小心翼翼地管理。
将后台服务作为挂载资源来使用(Treat Backing Services as Attached Resources)。
这一理念强调把中后台的服务看成黑盒,且都是等价的,可以随时挂载和切换。所有依赖的基础组件或者其他应用服务,比如数据库、缓存服务、消息队列、二方/三方服务,都视为外部资源,独立部署,通过网络访问。不同服务间的区别只是URL的差异,即环境变量不同,而应用本身不会因为环境变量的不同而有所区别。
这一理念的本质就是解耦,把微服务与基础设施从强关联变成弱连接,服务本身会关注容错、降级的逻辑,增强自身的鲁棒性。比如,常见的后台服务数据库、缓存,换一套环境,做一个主备切换,虽然后台资源变了,但不会导致服务无法运行。
严格区分构建和运行阶段(Strictly Separate Build and Run Stages)。
企业中常见的构建、发布、运行流程如图1-4所示。
早期的应用发布形态比较粗放,往往是开发人员直接告诉运维人员需要上线A文件、B文件、C文件,运维人员从线上环境的SVN仓库中抽取对应的最新版本文件,重启应用后即完成了发布。无论对开发运维还是产品人员,这样的过程都是极大的灾难,产品经理不知道上线了什么功能,运维人员不知道故障是如何发生的。这个现象就是因为没有把构建、发布、运行流程区分开。
本条规则本质上也是为了区分运行时和非运行时。构建阶段是将应用的代码抽取、打包、编译成可运行制品的过程,是非运行时行为。运行阶段就是应用部署后拉起进程的运行状态,运行状态禁止改动代码,这样才能保证运行中应用的稳定性。构建、发布、运行3个阶段的分离有两个好处。
图1-4 构建、发布、运行生命周期图
·职责分离:在传统的开发流程下,开发人员更关注构建阶段,产品和项目人员更关注发布阶段,运维人员更关注运行阶段。
·效率提升:流水线使构建和发布阶段更加清晰,每个阶段都有成熟的工具和方法论。
将应用作为无状态进程来运行(Execute the App as One or More Stateless Processes)。
先介绍什么是状态,有状态的服务是指多次请求都是为了同一个用户或ID服务的,通常通过session、cookie、数据库等来持久化状态信息,进而实现同一个用户或事务。以电商平台为例,用户张三购买一件商品,会经历浏览、加购、确认订单、付款等多个环节,而每次请求都是通过HTTP,HTTP本身是无状态的,这时如何确认每次请求都是张三这个用户发出的呢?就要借助session、cookie等信息。有状态服务经常用于实现本地事务,它在不需要考虑水平伸缩时是比较好的选择。如果服务是有状态的,那么就可能发生张三的身份信息只保留在server1上,下次如果请求路由到了server2,就会找不到有效的身份信息,需要借助额外的方案实现session共享,比如通过负载均衡让张三的请求恒定路由到server1,或者通过session复制方案让server2也能读取到server1的session。
图1-5 水平伸缩图
无状态服务更多考虑的是水平伸缩。还是以张三购买商品为例,session可以放到共享存储中,独立于server存在,这样底层server就是无状态的,状态的信息可以直接通过共享session实现,server还可以进行平滑、无限制的水平伸缩。至于每次请求是落到server1还是server2,都没有关系,因为状态信息已经被持久化到共享存储里了,如图1-5所示。
这一理念表达了应用本身应该是无状态的,以方便更好地实现水平伸缩,从而利用云平台的弹性能力。
通过端口绑定暴露服务(Export Services via Port Binding)。
这一理念强调应用本身对于发布环境不应该有过多的要求或依赖,而应该是自包含的。也就是说,不需要云平台提供的运行时容器,只要云平台提供一个端口,即可对外发布服务。这一理念保证应用可以使用云平台任意分配的端口,而不耦合某个指定的端口。以Java应用为例,传统的发布部署依赖于Tomcat,需要把War包放到Tomcat中,Tomcat的server.xml用来描述通信的端口,以此对外提供服务。而Spring Boot应用则内嵌了Tomcat、Undertow、Jetty等Java Web容器,直接构建出绑定了端口的Fat-Jar。显然,后者更符合此项原则,应用的完整性更好。
对进程模型进行水平伸缩(Scale Out via the Process Model)。
想要提升传统应用的服务性能,往往依靠的是提升单机配置,升级、扩容、迁移都比较低效。而对于云原生应用来说,无状态的服务很容易实现水平伸缩,自身不会制约并发能力。
可以快速启动和优雅关闭(Maximize Robustness with Fast Startup and Graceful Shutdown)。
快速启动是为了充分利用云平台的资源调度优势,按需以最小的时延快速扩展服务。优雅关闭一方面为了释放资源,另一方面为了保障业务逻辑的完整性,将未处理完的请求正确结束。
为了实现更好的弹性伸缩能力,服务本身应该具备很好的机动创建和销毁能力。如果启动不够快速,水平扩容的速度就会受到制约。如果不具备优雅退出的能力,缩容或滚动发布时就会影响到未处理的业务请求。传统应用模式下,有的应用启动会花费数分钟,杀掉进程只能通过“kill -9”命令,这些都是此项规则的反例。
许多应用在启动过程中会执行大量的长期活动,比如获取数据以填充缓存或准备其他运行时之间的依赖关系。要真正采用云原生架构,此类活动需要单独处理。比如,可以将缓存外化为支持服务,以便应用可以在不执行前置操作的情况下快速启停。
保持开发、预发、生产环境尽量一致(Keep Development,Staging and Production as Similar as Possible)。
对以往的线上故障进行复盘,往往会发现这样一个场景:明明在测试环境运行正常,上线后发现因为某个配置项不对,或者Tomcat的参数不对,导致生产事故。环境是服务稳定的基础,保持环境一致,能更好地避免这类低端问题,更好地保障单元测试、功能测试和集成测试的有效性。
将日志作为事件流来处理(Treat Logs as Event Streams)。
这条理念有些违反开发的常识,我们打印日志时通常会避免打印到标准输出,业务类的日志会有特殊的日志配置“appender”,比如配置存放路径、滚动方式、保留多久删除等内容。而在云原生环境下,如果日志不通过简单统一的方式来处理,任由应用个性化配置,将会给日志收集、系统空间、运维造成极大压力。此条理念建议应用仅保留打印到标准输出和标准错误,把日志作为一个事件流抛出即可,我们应该将日志的聚合、处理和存储视为非功能性需求,不是由应用满足,应该交给云提供商或其他配套工具来实现,比如ELK、Graylog、Splunk等。
应用不应该控制其日志的众多原因之一是弹性伸缩。当我们在固定数量的服务器上拥有固定数量的实例时,在磁盘上存储日志也许是有意义的。当应用可以动态地从1个运行实例转到100个实例,并且我们不知道实例在哪里运行时,只能由云提供商来处理这些日志。
简化应用的日志输出过程可以减少代码,并更加关注应用的核心业务价值,这本质上也是一种日志的解耦。
将应用管理任务当作一次性进程来运行(Run Admin/Management Tasks as One-Off Processes)。
对于管理类的任务,比如数据库DDL 、权限配置、黑白名单、单次程序触发等,不建议与业务应用耦合在一起,将它们独立作为一个Admin后台应用更合适。
Kevin Hoffman在2016年写的Beyond the Twelve-Factor App一书中修订了12要素,另外添加了“API第一”“遥测”“认证和授权”3个要素。
以API为中心的协作模式,先定义好API再做其他事情。“API第一”这一要素首先使团队能够在不干扰内部开发流程的情况下签订公共合同,达成约定。将API作为团队间沟通协作的桥梁,先定义好API,再做具体的实现。这样前后端或者服务的上下游就可以并行开发,从而大大提效。
遥测属于可观测性的一部分。德鲁克说过:“如果你不能度量它,你将无法管理它。”对于云原生系统也一样,尽量不要让应用变成一个黑盒,要通过一切可视化的工具将应用的链路、大盘、上下游展示清楚,比如常用的监控、报警、链路追踪体系。
认证和授权属于安全性方面的要素,同样适用于传统的应用服务。云原生应用实现认证和授权的方式有所不同,对终端用户的认证和授权往往在网关层就通过OAuth 2.0/OpenID Connect等协议统一处理了,对服务之间调用的认证和授权通过服务网格可以建立零信任安全模式。
第13、14个要素对于微服务系统非常重要,第15个要素则是安全性的核心保障机制。