阿里云聚石塔云原生改造

阿里云聚石塔云原生改造

七月 18, 2020

阿里云聚石塔云原生迁移

项目结构

  • Java单体应用,使用Spring+Quartz+Hibernate
  • Ruby on Rails的Web服务
  • Ruby on Rails应用安装了Delayed Job gem,单独运行一个服务,专门处理Excel表格导入导出
  • MySQL。因为业务原因,处理的都是淘宝店家的数据,各个店铺数据天然隔离,因此很自然采用水平分库。没有采用MyCat等中间件,Web端手撸了一个简单的中间件处理多数据源切换。Java服务则是每个进程对应一个MySQL实例,不存在多数据源切换

改造前

通过阿里云ECS部署(即虚拟机部署),购置多台ECS,分别部署Java进程和Ruby on Rails进程。MySQL则是购买阿里云RDS实例,无需部署。

Java部署

  1. 由于是定时器调度执行后台任务,系统内设置了调度执行的开关,在部署前首先关闭开关,防止有任务在部署时被调度

  2. 本地代码合并后打包复制到跳板机(线上唯一一台开放SSH端口的服务器)

  3. 在跳板机运行脚本,解压,将jar包分发、复制到各个服务器的部署目录下,目录层次大致如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    .
    ├── current -> releases/20201118203840
    ├── releases
    │   ├── 20201104212202
    │   │   ├── ......
    │   └── 20201118203840
    │   │   ├── config -> /var/javaapp/server/shared/config
    │   │   ├── server-1.0.jar
    │   │   ├── log -> /var/javaapp/server/shared/log
    │   │   ├── pids -> /var/javaapp/server/shared/pids
    │   │   ├── start.sh -> /var/javaapp/server/shared/script/start.sh
    │   │   ├── stop.sh -> /var/javaapp/server/shared/script/stop.sh
    └── shared
    ├── config
    │   └── application.properties
    ├── libs
    │   ├── ......
    ├── log -> /var/log/server/
    ├── pid
    │   ├── server.pid
    └── script
    ├── start.sh
    ├── stop.sh

    每次发布根据时间戳生成一个release发布号,发布至releases目录下。然后将current目录的soft link指向最新的发布。而第三方依赖、配置、脚本等相对变更频率较低的则放置在shared目录下。

  4. 使用起停脚本,重启服务。起停脚本以current目录作为working dir根据通过记录在pid目录下的pid文件,当前运行中的进程发送SIGTERM;或者启动程序并记录pid。

问题

  1. 每个服务实例的配置独立,修改时需要逐个修改(接入Nacos可以解决该问题)
  2. 每个服务运行独立,需要手动单独起停(使用运维工具或者编写完善的脚本可以解决该问题)
  3. 整个环境的搭建不算简单,扩容起来不够轻松(环境搭建脚本化)
  4. 日志也是分布在各个服务器上,并没有统一收集(接入ELK)

尽管各个问题都能单独解决,然而云原生改造可以一口气解决以上所有问题。

Ruby on Rails部署

  1. 在跳板机上搭建了专用的git repository
  2. 在本地测试、合并完代码后,git push到跳板机上的git repo
  3. 在跳板机上更新代码到最新
  4. 使用Capistrano工具,cap production deploy部署服务到Web服务器。Capistrano根据定义好的步骤,可以自动ssh到一台或多台远程服务器并安装、启动Ruby on Rails服务。主要流程与上述Java应用的部署类似(因为Java应用的部署我们就是借鉴的Capistrano的标准做法),不同点在于,Ruby on Rails应用通过一个Gemfile(类似maven项目的pom.xml或gradle项目的build.gradle)来管理应用的第三方依赖。Capistrano会在部署过程中执行bundle install确保最新的代码所需的依赖都已安装(而不像Java项目,是在本地下载、获取完依赖,统一打包复制到服务器上)
  5. Capistrano的部署过程中会自动重启Delayed Job
  6. 我们的Ruby on Rails配合nginx+passenger运行。nginx主要负责http跳转https、静态页面和资源的供给,以及内部使用的后台管理页面的访问控制。
  7. passenger管理Rails应用服务的进程数量和生命周期等。passenger会监听应用目录下tmp/restart.txt文件的修改时间。Capistrano在部署完成后会执行touch tmp/restart.txt命令更新文件的修改时间,passenger获知之后就会将流量接入新版本的应用上来并结束旧版本应用的进程。这一套组合拳可以保证Rails部署过程中0 downtime

Ruby on Rails的部署比较流畅,但最大的问题是只有线上会使用nginx+passenger,与本地环境有很大差异,导致开发不了解真正运行的架构。

改造

云原生改造最核心的变更在于修改了应用发布的流程和结构。一个云原生服务的程序主要分为4大部分:

  1. 基础镜像
  2. 代码包
  3. 程序配置
  4. 云服务配置

其中最重要的概念是基础镜像代码包的组合。所谓“基础“镜像,就是一个包含了应用代码正常运行,且在正常迭代过程中保持相对稳定的基础环境,以及云应用的容器实例的启动指令entrypoint的Docker镜像。没有代码包,这个镜像并不能正常启动成为一个运行的容器。代码包,对于Java项目来说就是一个包含了所有依赖libs的fatjar,对于Ruby on Rails则会是一个zip包。基础镜像和代码包二者组合在一起就是完整的可运行镜像。

通过分离了大部分时间下稳定的基础镜像,和频繁变动的代码包,达成的效果则是减少了我们需要提交上传的文件量,不再需要在本地进行频繁的构建。在正常迭代发布过程中,我们只需在提交发布单时指定版本号,上传新的代码包,阿里云平台就会自动将代码包与我们为云应用指定的基础镜像一起进行构建,生成最终运行的镜像。

用一段Java代码来举例描述它们二者可能会是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// BaseImage rarely changed and stays on the platform
public abstract class BaseImage implements Image {

protected CodePackage codePackage;

// Interface method of Image, called by `docker run` when application starts
@Override
public void entrypoint() {
codePackage.run();
}
}

// When we deploy a new version of our code, we upload the code package
// then the platform will build the runtime image for us
public class Platform {
public void deploy(CodePackage codePackage) {
Image runnableImage = build(codePackage);
runImage(runnableImage);
}

private Image build(CodePackage codePackage) {
return new BaseImage(codePackage) {};
}

private void runImage(Image runnableImage) {
runnableImage.entrypoint();
}
}

程序配置指的是程序本身的配置文件,一般会通过平台提供的配置页面挂在到容器内。

云服务配置指的则是包括Kubernetes配置在内的所有描述云服务运行架构状态的配置,如实例数、流量接入配置、日志服务接入配置等等。

改造流程

  1. Docker环境安装
  2. Dockerfile准备
    1. Java应用
    2. Ruby on Rails应用
  3. Java应用配置调整
  4. Ruby on Rails应用配置调整
  5. Kubernetes环境安装
  6. Kubernetes配置准备
  7. 测试
  8. 部署上线

Docker环境安装

  1. Windows与OSX可以直接通过docker-desktop安装,非常方便:https://www.docker.com/products/docker-desktop。而Linux环境,在国内通过yum或apt安装的话要先处理源,比较麻烦,推荐直接使用get-docker脚本安装:

    1
    2
    curl -fsSL get.docker.com -o get-docker.sh
    sudo sh get-docker.sh --mirror Aliyun
  2. 安装完毕后将自己的用户添加入docker用户组,否则必须以root用户使用docker:

    1
    sudo usermod -aG docker ${whoami}

Dockerfile准备

Java应用

Java应用是最普遍的云应用,因此官方提供的官方镜像基本就可以直接使用。官方镜像的目录结构是

1
2
3
acs
├── code
├── config

镜像的工作目录是/acs,构建时会将jar包放到/acs/code下,应用的配置文件则挂载至/acs/config下。容器启动时则会以/acs为工作目录,执行java -jar code/app.jar

当然,也可以自定义镜像,选择合适的操作系统版本,选择合适的或者魔改的JDK。指定entrypoint执行一个自定义的脚本,脚本同样可以选择挂载到容器内,灵活地在启动时执行指令。比如我们的启动脚本:

1
2
3
#!/bin/sh

exec java ${JAVA_OPTS} -XX:HeapDumpPath=logs/dump_${POD_NAME}_$(date +%F%T).hprof -jar code/app.jar
  1. 执行指令必须要用exec,可以让java -jar命令直接接替当前shell进程,保持PID不变。如果容器运行出现了问题(比如健康检查通不过,亦或者是发生了OOM),K8S杀死进程时,SIGTERM能够正确地发送到java进程中,触发应用的shutdownhook,完成优雅关闭。
  2. ${JAVA_OPTS}环境变量是为了方便应用部署时通过环境变量配置JVM参数。
  3. -XX:HeapDumpPath=logs/dump_${POD_NAME}_$(date +%F%T).hprof的参数是在进程OOM时导出heapdump。由于在dump完毕后容器就会重启,因此会需要挂载一个宿主机目录,或者一个网络存储(如NAS)到dump路径。由于运行时会有多个POD,因此通过${POD_NAME}环境变量来区分。这个环境变量需要在后续的Kubernetes配置文件中配置。

Ruby on Rails应用

Ruby on Rails应用的基础镜像与Java应用的不同点主要体现在三个方面:

  1. 环境依赖较多,不像Java镜像,装个JDK就万事大吉
  2. Rails应用不是以代码包的形式,而是直接以源代码方式部署的
  3. Rails应用的运行方式借助于nginx与passenger

话不多说,直接上Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
FROM centos:7.5.1804

ENV LC_ALL en_US.utf8

COPY start.sh /bin/start.sh

WORKDIR /var/www/html

RUN chmod +x /bin/start.sh \
&& rm -rf /etc/localtime \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo 'Asia/Shanghai' > /etc/timezone \
&& yum install -y zlib-devel openssl-devel pcre pcre-devel make autoconf gcc gcc-c++ curl-devel mysql-devel perl-devel ruby-devel libxml2 libxml2-devel libxslt libxslt-devel libyaml-devel libffi-devel gdbm-devel tcl-devel tk-devel patch readline readline-devel zlib zlib-devel bison mysql \
&& curl cache.ruby-china.com/pub/ruby/2.1/ruby-2.1.2.tar.gz -o ruby-2.1.2.tar.gz \
&& tar zxf ruby-2.1.2.tar.gz \
&& rm -f ruby-2.1.2.tar.gz \
&& cd ruby-2.1.2 \
&& ./configure \
&& make \
&& make install \
&& ln -s /usr/local/bin/rake /usr/bin/rake \
&& cd /var/www/html \
&& rm -rf ruby-2.1.2 \
&& gem sources --add https://gems.ruby-china.com/ --remove https://rubygems.org/ \
&& gem install bundler -v 1.15.4 \
&& gem install rack -v 1.6.10 \
&& gem install daemon_controller -v 1.1.0 \
&& gem install passenger -v 4.0.5 \
&& passenger-install-nginx-module --auto --auto-download --prefix=/opt/nginx --extra-configure-flags="--without-http-cache --with-http_ssl_module --with-http_realip_module"

ENTRYPOINT ["/bin/start.sh"]

为了保证应用稳定,因此选择了与当前生产环境相同的CentOS系统版本作为基础,ruby、passenger版本也都保证一致。主要完成了系统组件(yum install),ruby,passenger(gem install passenger),nginx(passenger-install-nginx-module)的安装。

passenger-install-nginx-module的作用是,在完成passenger安装后可以使用该命令安装配套的nginx模块。有以下几个注意点:

  1. 需要指定--auto避免默认的交互性的安装过程
  2. 通过--prefix指定nginx的安装目录(默认即为/opt/nginx
  3. --extra-configure-flags用于指定安装过程中,需要传递给nginx的安装参数。其中--with-http_realip_module是因为Web的应用管理后台做了IP白名单保护,指定只有公司的IP可以访问。当Web应用迁入云原生环境后,nginx直接获取到的IP将全都为云原生集群流量接入的SLB的IP,因此需要通过该模块获取真实IP

start.sh脚本的内容非常简单,运行一个真正启动应用的脚本:

1
/bin/bash /bin/application.sh

这样做的目的是因为这个镜像将会被两种云应用共享使用:一个是Ruby on Rails的Web应用,另一个也是Ruby on Rails,但是专门运行Delayed Job的后台应用。它们需要同样的环境,但是启动命令有所不同。通过这样的方式可以在部署云应用时动态挂载不同的application.sh达到启动不同的应用的目的。

但到这里基础镜像还未构建完,Ruby on Rails应用的依赖Gem还未安装。在长期的应用开发过程中,上一个Dockerfile中的内容几乎稳定不变,而Gem依赖还是比较有可能会变化的,因此将这一步单独拆分了出来:

1
2
3
4
5
6
FROM centos7.5-ruby2.1.2:1.0

COPY Gemfile* ./

RUN gem sources --add https://gems.ruby-china.com/ --remove https://rubygems.org/ \
&& bundle install

在实际构建时,需要获取最新的项目代码中的Gemfile来进行

未完待续……