我说云原生之应用无状态与容器化

446 阅读18分钟
原文链接: wuwenliang.net

“云计算”的概念从提出到现在已经过了十多年,经过了大量的实践之后早已不再是阳春白雪般不接地气的抽象理论。

随着微服务的提出到落地,分布式领域中又诞生了新的基于云的理论–“云原生(Cloud Native)”。

“云原生”的概念由来自Pivotal的Matt Stine于2013年首次提出,被一直延续使用至今……(它涵盖的)内容非常多,包括DevOps持续交付微服务敏捷基础设施以及12要素等几大主题。不但包括根据业务能力对公司进行文化、组织架构的重组与建设,也包括方法论与原则,还有具体的操作工具。 –《云原生技术架构实践》

本文,我将主要介绍云原生的12要素,并着重讲解云原生中关于微服务的主题中的容器化与应用无状态的特性。

什么是“12要素”

“12要素”(The Twelve-Factor App), 起初是由Heroku的工程师整理的。它较为贴切的描述了软件应用的原型,并且解释了为何使用原生云应用架构的原因。

这里,我将12要素的特点及解释通过表格的方式进行呈现。

序号 要素 解释
1 基准代码 一份基准代码,多份部署
2 依赖 不隐式依赖系统级类库,应当通过依赖清单显式声明依赖关系
3 配置 通过环境变量/分布式配置中心等策略将配置排除在代码之外
4 后端服务 将后端资源同等的看做附加资源,不用区分远程还是本地
5 构建、发布、运行 应用应当严格区分构建、发布、运行三个阶段
6 进程 应用进程必须无状态无共享,必要的状态都需要被服务化到后端服务(如:缓存、对象存储中);面向失败进行设计
7 端口绑定 通过端口绑定提供服务,尽量避免通过本地文件或者进程来通信,服务间通过服务发现而服务
8 并发 将进程看做一等公民,就可以通过水平扩展应用程序来实现并发性,通过进程模型进行扩展,即可具备无共享,水平分区等特性
9 易处理 应用具备快速启动及优雅终止的健壮特性,应用架构设计能够支持随时销毁的特点。允许系统进行快速弹性扩缩容,快速改变部署和及时故障恢复的特性。
10 环境等价 尽可能保持开发、测试、预发、线上环境的一致性
11 日志 将日志作为事件流,并作为数据源,通过集中式的日志服务,执行日志收集、聚合、索引及分析等操作
12 管理进程 后台的管理任务当作一次性进程运行,如:Kubernates中的Pod资源或者docker exec,能够随其他应用程序一同发布或在出现异常的情况需要诊断时,通过该一次性程序进行诊断管理等操作

上述的12个特性基本上涵盖了云原生应用的特点,其中2、3、4、6、9、10等几点中,不约而同的提到了 应用无状态无共享

如果应用具有无状态,无共享的特点,则应用天然可扩展,没有对环境的依赖,可以快速的拉起或者销毁一个进程;可以在流量激增时分钟级,甚至秒级对集群进行扩容,使得我们的应用具备高可用的特性。

在传统的应用开发及部署过程中,我们的应用程序与环境是强耦合的,配置信息写在配置文件中,对于外部的依赖常常通过ip进行强指定,类似这样的配置方式就是将状态维护在了应用本地,集群内的每一个节点在实际环境中就很可能出现状态的不一致。当需要进行应用集群的迁移、扩缩容时就倍加痛苦。

因此直接通过物理机/虚拟机的方式对应用进行部署,并不能满足应用无状态的特点,也就是说这种方式开发的应用并不是云原生的。

那么,有没有一种方式能够较为全面的解决传统实体机/虚拟机部署应用的痛点呢?

容器技术就是我们的福音。

容器化与无状态

事实上,任何应用都是有状态的,状态被存储在数据库、缓存、文件或其他形式的存储中。任何需要跨操作使用的状态变化都必须回写到存储中。

传统的开发、部署方式中,我们是将状态写在宿主机的内存、磁盘、文件中,这样就很容易出现跨节点之间的状态不一致的问题。

让我们换个方式,如果我们能够将程序的行为与数据分隔开,那么我们应用程序的组件是可以做到无状态的,只需要在它运行时动态的获取到数据即可。而容器化的目的之一就是将我们开发的程序组件与组件操作的数据进行隔离,从而达到无状态的目的。

这里先对应用程序可能维持的几种状态做一下枚举:

  1. 持久状态
  2. 配置状态
  3. 会话状态
  4. 网络连接状态
  5. 集群状态等

持久状态

首先说一下如何隔离持久状态。

一般情况下,我们都会将数据持久化到数据库中,如果每个容器都带一个数据库的话,它将会是有状态的,并使系统故障的范围更大。如果出现应用程序实例或应用程序崩溃,可能会影响数据库。另一方面,如果容器down机重启或者集群扩缩容,那么数据库中的数据就会丢失,很不利于维护。

解决的方法很简单,只需要让容器集群操作同一个外部数据源即可,这里的数据源可以是对象存储,也可以是关系型数据库。这样,对数据库进行主从复制,也不需要跨网络层进行,更利于DBA对数据库进行维护。

这样我们就能保证每个容器均能读取到同一个数据源的全量数据。至于连接信息的配置,尽量采用域名或者环境变量注入的方式进行,不建议通过ip进行配置。详细的原因将会在下文展开。

PS: 一般情况下,我们只对应用层的组件容器化,对于类似MySQL这样的关系存储,建议还是采用物理部署或者直接购买云资源的方式进行部署,不建议进行容器化。对这个问题,可以看这篇文章:数据库不适合Docker及容器化的7大原因,笔者对其中的大部分观点持赞同态度(软件开发没有银弹)。

配置状态

关于配置,尤其是分布式配置,可以展开说一天一夜,这里我们只对如何进行配置的无状态这一问题进行描述。

配置,一般分为系统配置和应用配置,进一步又可以分为可变配置和不可变配置。

全局配置且配置可变

对于系统配置,也就是全局配置,集群内的每一个容器均使用相同的值,因此建议通过docker容器的-e环境变量的方式配置,如:

docker run -d  --name devops-manage-ui -m 4g -p 8081:8081 \
            -e db_username=shardingtest \
            ......省略......

如果采用了Kubernates作为容器调度系统,那么可以通过values.yaml配置文件结合helm进行配置的设置等操作,values.yaml内容如下:

secret:
  # 注意key不要使用减号'-'连接单词,如果需要一定使用下划线'_'
  # 因为模板会识别key名称,自动并将key对应的值映射到容器中与key名称相同的环境变量
  mysql_db_username: 'shardingtest'

这里以数据库连接用户名的配置作为示例,一般而言,数据库连接的用户名在同一个应用集群内是统一的,因此我们通过系统环境变量进行配置是可行的。

全局配置且配置不可变

当然,我这里是从便于统一管理的角度出发的,如果你能保证某个配置是长期不变的,那么将其写死在程序内部,也是能够接受的。这样能够减少配置的数量,对于长期的维护有好处。我本人也同意“少既是好”的观点。

应用配置且配置可变

对于应用层的可变配置,如果变更不频繁,如:测试环境的开关等配置,在正式上线就不会轻易更改,也可以配置在容器的环境变量中。方法与上述相同,即通过容器的-e或者K8s的values.yaml或者配置集等方式设置。

如果变更很频繁,比如:日志级别、流量阈值、抽奖开关等需要经常变更的配置,配置在环境变量中就不是一种合适的方式了。

原因在于环境变量中的值一旦设置,在容器的生命周期内就不能改变了,如果要更改只能重启应用,而这对于我们的业务的可用性而言是不能容忍的。

对于这个问题,我们一般会采用 分布式配置中心 来解决。

应用层通过连接统一的配置中心,将配置的变更交给配置中心。对于配置的修改,无需重启应用,配置能够动态生效,减少了运维成本和压力。业务人员对于配置的变更能够实时的推送至应用程序,应用程序本身也可以定时拉取配置,保证变更能够及时的反馈到应用内。

业界已经有成熟的配置中心实现,如:Apollo、Nacos、Diamond、Qconf等。

关于配置中心,我之前写过相关的文章,感兴趣的读者可以去阅读:

配置中心文章链接
自己写分布式配置中心[上篇]-单机模式
springboot2.x整合nacos配置服务实现配置获取及刷新

应用配置且配置不可变

对于应用层的不可变配置,也可以同系统级配置一样,设置到环境变量中。

根据前文中 “12要素” 提到的第三点–配置要素,

通过环境变量/分布式配置中心等策略将配置排除在代码之外

对于应用层的不可变配置,从同一管理的角度而言,放在环境变量中确实有助于代码逻辑与配置数据的分离。从而保证应用程序组件的无状态特性。

会话状态

web开发中,难免会涉及到用户的会话保持。

会话保持技术当前也很成熟了,如:进程内会话、Nginx等反向代理实现的会话粘滞以及分布式会话技术等。

首先是应用内会话保持,如果采用了该机制,集群内一定会有进程间数据不一致的情况出现。

Nginx等反向代理可以配置应用层实现会话粘滞,能够保证对于同一个会话id,每次请求都可以触达同一个后端服务。但是这也存在风险,试想,如果某个后端服务down机,那么也会出现进程间数据的不一致现象。

那采用何种方式才能既可以保持会话,又能让应用本身无状态呢?听起来很矛盾,其实解决方法还是蛮好理解的,那就是引入第三方存储机制,应用层通过该三方存储(如:Redis集群、Memcached集群等)进行进程间会话共享。

这也就是所谓的“分布式session共享”,在成熟的框架中如:Spring/Spring Boot中已经有现成的解决方案,名为:spring-session-data-redis,它集成了Spring Session模块,看源码能够很清晰的发现其对HttpSession进行了包装,遵循servlet规范,使用相同的方式获取session,对应用代码无侵入而且对开发者的使用是透明的。通过包装增强,将会话数据保存在Redis中。从而实现了跨进程的会话共享。

在文章的附录部分,我将详细的展示如何基于Spring Boot 2.1.2 Release进行 spring-session-data-redis 的整合,实现会话共享。

网络连接状态

传统的开发部署中,我们常常需要进行网络通信,以HTTP通信为例,我们需要制定目标的ip以及端口。

端口的话一般都是固定的,但是通过写死ip的方式进行调用也不利于无状态化的实现。如果远程更换了ip,那么通信链路就会断开,影响业务的正常运行。

因此,想要实现网络连接的无状态,更好的方法是采用域名的方式进行通信。

对于远程服务,直接通过域名调用即可,域名一般不会频繁变更,因此配置到环境变量中即可。

对于同一网络中的服务间的调用,可以通过架设内网DNS服务器,为每个应用分配一个唯一的名字,然后通过该域名进行通信,这样对于应用的横向扩展很有帮助。

这一思路在Kubernates中展现的淋漓尽致。

在Kubernates中,应用间通过service名进行通信,它是通过一个名为 kube-dns 的组件实现的。

Kubernates通过kube-dns进行服务发现,将Service的名称当做域名注册到kube-dns中,通过Service的名称即可访问其提供的服务。

如果你采用了Kubernates进行容器调度和管理,那么恭喜你,你离云原生其实很近。

集群状态

至于集群状态的维护,通常我们只需要将状态维护在一个三方组件,如:dubbo采用Zk作为注册中心、kafka采用Zk作为NameServer等 均是将集群状态维护给了三方,应用间不需要关心彼此的存活状态,这样只要保证三方组件的高可用,我们就能够做到对应用集群的生命周期的管理甚至进行服务治理了。

小结

我们提到了 “12要素” ,还讲到了如何保持上文提到的五种状态的无状态性,他们都是实现云原生的重要特征。

如果一个应用集群没有做到无状态,那么它一定不是云原生的,无状态的应用集群能够像“云”一样作为一个整体对外提供服务而无需关心其内部细节。

实践无状态

说了这么多概念,那么如何做才能尽量的实现无状态呢?

首先,请拥抱容器,容器天然是为云而存在的,如果不用容器,我们的无状态之路会走的很辛苦。

容器的实现中,我推荐你选择成熟的Docker容器化技术,入门Docker可以看这个教程
docker-architecture

我假定你对Docker有基本的了解,能够编写简单的Dockerfile,完成镜像的打包运行停止等操作。

SpringBoot项目中配置环境变量

这里我以连接数据库的配置为例进行讲解,项目基于Spring Boot 2.1.2 Release进行构建。

首先我们指定项目启动时,使用application-prod.properties配置。

  1. 在application.properties中指定激活的配置为prod

    spring.profiles.active=dev
    
  2. 在application-prod.properties中配置数据库连接参数,使用 ${} 方式进行环境变量的注入,大括号内为环境变量的参数值

    spring.datasource.url=${db_url}
    spring.datasource.username=${db_username}
    spring.datasource.password=${db_password}
    
  3. 在项目的根路径下放置 run.sh,用于docker容器启动时触发该脚本从而启动应用,内容为:

    #!/bin/bash
    java -Xmx3000m -Xms3000m -Dspring.profiles.active=prod -jar /app/demo.jar
    
  4. 编写Dockerfile并放置在项目的根路径下,Dockerfile内容为

    # 基础镜像
    FROM docker.io/fiadliel/java8-jre   
    # 创建jar包放置路径
    RUN mkdir /app
    # 将打包后的jar包复制到镜像/app下
    ADD ./target/demo.jar /app/
    # 复制启动脚本到可执行jar的同一级目录下并给予可执行权限
    COPY ./run.sh /app/run.sh
    RUN chmod u+x /app/run.sh
    # WORKDIR指令用于指定容器的一个目录,容器启动时执行的命令会在该目录下执行,
    # 相当于设置了容器的工作目录
    WORKDIR /app
    # 执行启动脚本
    CMD [ "/app/run.sh" ]
    # 暴露端口
    EXPOSE 8080
    
  5. 在项目的pom.xml中添加docker打包支持

<properties>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

        <maven.repostory.host>这里填写你的maven私服域名</maven.repostory.host>
        <docker.repostory>这里填写你的docker仓库域名</docker.repostory>
        <docker.registry.name>这里填写你的镜像仓库命名空间名</docker.registry.name>
</properties>

<build>
    <finalName>${project.name}</finalName>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
        <!--docker配置-->
        <plugin>
            <artifactId>exec-maven-plugin</artifactId>
            <groupId>org.codehaus.mojo</groupId>

            <executions>
                <execution>
                    <id>clean-images</id>
                    <phase>deploy</phase>
                    <goals>
                        <goal>exec</goal>
                    </goals>
                    <configuration>
                        <workingDirectory>${basedir}</workingDirectory>
                        <executable>sh</executable>
                        <arguments>
                            <argument>clean-images.sh</argument>
                            <argument>${docker.repostory}/${docker.registry.name}/${project.name}:${project.version}</argument>
                        </arguments>
                    </configuration>
                </execution>

                <execution>
                    <id>build-images</id>
                    <phase>deploy</phase>
                    <goals>
                        <goal>exec</goal>
                    </goals>
                    <configuration>
                        <workingDirectory>${basedir}</workingDirectory>
                        <executable>docker</executable>
                        <arguments>
                            <argument>build</argument>
                            <argument>-t</argument>
                            <argument>${docker.repostory}/${docker.registry.name}/${project.name}:${project.version}</argument>
                            <argument>.</argument>
                        </arguments>
                    </configuration>
                </execution>

                <execution>
                    <id>push-images</id>
                    <phase>deploy</phase>
                    <goals>
                        <goal>exec</goal>
                    </goals>
                    <configuration>
                        <executable>docker</executable>
                        <arguments>
                            <argument>push</argument>
                            <argument>${docker.repostory}/${docker.registry.name}/${project.name}:${project.version}</argument>
                        </arguments>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>
  1. 打包项目,上传镜像至镜像仓库(如何搭建Harbor仓库,请看这篇 开源仓库Harbor搭建及配置过程

  2. 在服务端运行docker run命令,并通过 -e 参数指定环境变量的值,注入容器中。

    docker run -d -v /log/snowalker/:/log --name demo \
                -m 4g \
                -p 8081:8081 \
                -e db_url="jdbc:mysql://127.0.0.1:3306/shardingtest_00?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=GMT%2B8" \
                -e db_username=shardingtest \
                -e db_password=shardingtest \
                snowalker.io:5000/test/demo:1.0.1
    

为了查看日志方便,我在启动时将业务日志挂载至宿主机的 /log下。

进入/log 目录,查看启动日志如下,表明数据库连接正常,启动完成。

2019-03-19 10:22:59.782 [main] INFO org.springframework.boot.StartupInfoLogger.logStarted[StartupInfoLogger.java:59] - 
Started Application in 5.509 seconds (JVM running for 7.498)
2019-03-19 10:22:59.784 [main] INFO com.snowalker.devops.manage.ui.Application.main[Application.java:18] - 
[devops-manage-ui] BOOT SUCCESS

到此,我们就完成了基于SpringBoot的配置提取到环境变量的操作,并通过容器化的方式实现了环境变量参数的运行时注入操作。

我们可以随产品需求随时扩缩容集群的规模,无需担心配置的变更对应用启动的影响。

Spring Boot 2.1.2 Release 集成 spring-session-data-redis 实现分布式会话

这里总结下如何基于Spring Boot 2.1.2 Release 集成 spring-session-data-redis 实现分布式会话。

  1. 首先引入spring-session-data-redis依赖,与Spring Boot版本保持一致即可,这里使用 2.1.2 Release

    <!--框架依赖包-->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.2.RELEASE</version>
    </parent>
    
    <!--spring会话同步-->
    <!-- https://mvnrepository.com/artifact/org.springframework.session/spring-session-data-redis -->
    <dependency>
        <groupId>org.springframework.session</groupId>
        <artifactId>spring-session-data-redis</artifactId>
    </dependency>
    

由于会话基于Redis存储,因此需要引入Redis依赖如下,版本依旧与Spring Boot保持一致

<!--redis缓存支持-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  1. 编辑application.properties配置文件,插入Redis及session相关的配置如下

    ###################################################################
    #
    #               session共享配置
    #
    ###################################################################
    spring.redis.host=127.0.0.1
    spring.redis.port=6379
    spring.redis.timeout=10000
    spring.redis.password=123456
    spring.redis.jedis.pool.max-active=8
    spring.redis.jedis.pool.max-wait=-1
    spring.redis.jedis.pool.max-idle=8
    spring.redis.jedis.pool.min-idle=0
    spring.redis.database=1
    # session 存储类型为 Redis
    spring.session.store-type=redis
    

上述Redis为单机配置,对于集群和哨兵模式的配置如下

# (普通集群,不使用则不用开启)在群集中执行命令时要遵循的最大重定向数目。
# spring.redis.cluster.max-redirects=
# (普通集群,不使用则不用开启)以逗号分隔的“主机:端口”对列表进行引导。
# spring.redis.cluster.nodes=

#哨兵模式
# (哨兵模式,不使用则不用开启)Redis服务器的名称。
spring.redis.sentinel.master=
# (哨兵模式,不使用则不用开启)主机:端口对的逗号分隔列表。 
spring.redis.sentinel.nodes=
  1. 在启动类上添加注解开启分布式session支持

    //开启redis session支持,并配置session过期时间,时间单位秒
    @EnableRedisHttpSession(maxInactiveIntervalInSeconds= 1800) 
    @SpringBootApplication
    @Log4j2
    public class Application extends WebMvcConfigurerAdapter {
    
        public static void main(String[] args) {
            SpringApplication.run(Application.class, args);
            log.info("[devops-manage-ui] BOOT SUCCESS");
        }
    }
    

到这里,我们就完成了Spring Boot 2.1.2 Release对spring-session-data-redis分布式会话实现的整合。

结语

云原生是一个大主题,涵盖了研发、运维、项目管理、组织架构、基础设施等多方面的要素。本文只是其中的冰山一角,希望本文能够成为读者朋友们了解云原生的一个指引。

纸上得来终觉浅,绝知此事要躬行。关于云原生的奥秘,我们还有很多路要走,我愿与你公共探秘,攀向技术山峰。