Skip to content

devops/jenkins基于kubernetes集群的ci/cd集成 #34

@kaybinwong

Description

@kaybinwong

截止目前我们上线了5个核心系统,20+个基础服务,高峰期也经受住了百万级别的API调用,10k+的并发,系统整体运行良好。
虽说取得了一点成绩,但实际上很多配套实施都没还来得及完善。前期只是忙于开发,对于运维却是几乎没一个全盘的计划,最终导致开发跟运维的脱节导致软件开发这最后一公里(开发完成到上线运维)走得很艰难,最主要体现在:

  1. 工作效率低下,交付周期长。多次跨部门协作导致每次发布流程相当漫长,少则1天,多则要花上好几天。
  2. 工作方式原始,增加工作量。在我刚入职那时,交付都是把工程打包成jar包,放到附件里面去,然后再由运维逐台上传更新,很难相信还存在这样的操作,活该加班。
  3. 人为干预过多,导致经常出现事故。比如开发的包放到生产上面去,指令操作失误,导致没备份,出错无法回滚等。
  4. 运维跟不上,导致出问题开发却束手无策,难以快速定位解决问题。

上述的诸多问题实际上都是可以避免的,三个月以前我们就开始思考怎么通过技术手段避免类似的问题,最终我们确定了我们部门自己的Devops计划,目前已经在一些应用上成功实施,其他系统正在陆续迁移。今天来总结一下我们是如何走这最后一公里的,其中涉及3个阶段,8个过程:

  • Dev
    • 代码质量
    • 应用配置化
    • 应用镜像化
  • CI/CD
    • 集成测试
    • 自动化测试
    • 灰度发布
  • OPS
    • 运行监控
    • 自动伸缩

Dev

开发这块由于前期起草制定的规范比较多,也有自己内部的组件框架,所以涉及的改动不多,但是还是有一些变化。

代码质量

前期只是在框架上集成了checkstyle来统一代码的风格质量,至于其他就没有硬性要求;单元测试的质量也只是在例会上口头说核心的API要有单元测试,但结果很多时候系统上线了也没发现任何单元测试,大家很不乐意去写单元测试,甚至还有人说这是测试干的事情,相当尴尬。
所以趁着PMO考核的时候,将代码覆盖率纳入了考核指标,变相逼着大家去落实。这里我也建议大家要养成写单元测试的习惯。
为了小伙伴们更好地开展工作,我还特意写了一份单元测试准则,简化大家的认知以及操作。2个月下来,单元测试覆盖率也从0.x%上升到20+%。
image

至于单元测试覆盖率,我们用的工具是Junit+Powermock+Jacoco+MockMvc,覆盖率要求是最低的语句覆盖
至于代码质量,以前的100%Review,变成以Sonar为准,不定期抽查源码。

应用配置化

之所以要应用配置化,主要有2个考量,

  • 配置统一管理以及保密,以便将来统一使用CMDB
  • 配置权限最小化,避免发生误配置的情况

目前我们所有的系统都是前后端分离的,后端统一用的是Spring Boot,前端统一使用Vue。

后端这一块比较好处理,Spring Boot本身就支持了多环境的配置,我们的配置规定如下:

├── src
│   ├── main
│   │   ├── java
│   │   ├── resources
│   │   │   ├── application.yaml        # 默认,不涉及任何环境配置
│   │   │   ├── application-h2.yaml     # 单元测试
│   │   │   ├── application-dev.yaml    # 开发环境
│   │   │   ├── application-test.yaml   # 测试环境
│   │   │   ├── application-prod.yaml   # 生产环境
│   │   │   ├── schema.sql              # 数据库schema
│   │   │   ├── data.sql                # 单元测试所需数据

前端相对麻烦一点,前期都是针对不同环境打不同的包。这里也花了一点功夫来说明配置分离的好处,最后提供的方案是统一将配置配置config.js文件,这个文件不包含在git上,需要由各个环境自行配置加载。

# add api config
# add  file ./static/config/config.js ,content:
SDApi = {
    "API_ROOT":"http://test-open.seedland.cc/vip-point/api"
}

配置与代码的分离后,很明显的一点就是没有再发生开发包部署到线上的低级错误。

应用镜像化

为了让大家省去了学习成本,给前后端编写了通用的Dockerfile模板,当然这个控制权完全在各个项目上,模板只是给了样例,让大家省去开发的时间。
对于后端,Dockerfile如下:

FROM java:8-jre-alpine
MAINTAINER huangkaibin <huangkaibin@seedland.cc>

#add timezone and default it to Shanghai
RUN apk --update add --no-cache tzdata
ENV TZ=Asia/Shanghai
  
RUN mkdir -p logs
COPY  ./target/*.jar  /app.jar
  
EXPOSE 8080
VOLUME ["logs"]
WORKDIR /
  
ENTRYPOINT ["java","-Xms1024m", "-Xmx1024m", "-Xss512k", "-jar","app.jar", "--server.port=8080"]
CMD []

对于前端,Dockerfile也是相当简单:

FROM nginx:latest

MAINTAINER huangkaibin <huangkaibin@seedland.cc>

# 将dist文件夹下的文件copy到nginx下面去
COPY dist/  /usr/share/nginx/html/

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

由于 Docker 镜像内的时区是 UTC 时间,和宿主机的东 8 区不一致,所以必须安装 timezone 工具并设置 TZ,才能使容器内时间和宿主机保持一致,对数据库的写入和日志的输出都是非常必要的一环。

在完成了第一阶段后,我们就开始着手持续集成、交付方面的事情。

CI/CD

自动化的持续集成和交付在整个 DevOps 流中起了重要的角色,他是衔接开发和运维的桥梁。如果这一环做的不好,那么这最后一公里将是寸步难行,甚至无法支撑日后大量微服务的快速的迭代和高效运维。

从一开始我们就决定用kubernetes来做容器编排,所以在完成应用容器化之后,我们很容易地持续集成、部署到k8s这个平台上来。从长远看来(下图),我们最希望的开发过程是:开发,自动发布到测试环境,自动运行测试脚本,自动上线。
ci_cd

我们持续交付的是docker镜像,而不是每次都自动重新打包,一个Prod的Tag一定跟Dev的Tag保持一致。

那么在资源有限情况下,如何以最少的资源,最快的速度做好CI/CD,涉及到的方方面面我们都必须有所考量。

策略

在落地方面,我们的策略就是开源优先,优先选择开源的产品,在开源的基础上,发现不足的地方做补缺。

方法

在新的环境里应用了这样的开源技术,Jenkins、Gitlab 、Sonar、Prometheus、Traefik。

Jenkins
Jenkins 作为成熟的 CI/CD 工具,它能够帮我们自动化完成代码编译、上传静态代码分析、制作镜像、部署测试环境、冒烟测试、部署上线等步骤。尤其是Jenkins2.0 引入 Pipeline 概念后,以上步骤变的如此行云流水。它让我们从步骤 4 开始,完全可以无人值守完成整个集成和发布过程。

Step 1、检出代码
这个步骤使用 Git 插件,把开发好的代码检出。

    stage('Preparation') {
        git url: "your-gitlab-url", branch: "develop", changelog: false, credentialsId: "inf-gitlab"
    }

Step 2、编译测试
代码编译测试打包,由于我们使用的是 spring boot 框架,生成物应该是一个可执行的 jar 包。

    stage('Compile Source') {
        def mvn3 = tool 'M3'
        sh "'${mvn3}/bin/mvn' clean package"
    }

Step 3、代码分析

    stage('SonarQube analysis') {
        def sonarHome = tool 'sonar-3.2.0'
        withSonarQubeEnv('SonarQube-Prod') {
            sh "${sonarHome}/bin/sonar-scanner -e -Dsonar.links.scm=your-gitlab-url -Dsonar.sources=. -Dsonar.java.binaries=. -Dsonar.projectVersion=your-version -Dsonar.projectKey=your-project -Dsonar.projectName=your-project"
        }
    }

需要注意的是,这些需要结合Jacoco等插件生成代码覆盖率分析。

Step 4、镜像打包
此步骤会调用 Docker Pipeline 插件通过预先写好的 Dockerfile,把 jar 包和配置文件、三方依赖包一起打入 Docker 镜像中,并上传到私有 Docker 镜像仓库Harbor中。

    stage('Build image') {
	    for (module in modules) {
	        docker.build("inf/${module}:$buildTag", "${module}")
	    }

	    pushImages(dockerRegistry, dockerRegistryCredentialsId, modules, buildTag, "DEV")
    }

需要说明的是,我们没有明确地指定某个模块的部署,因为一般我们一个工程对应的只有一个可部署模块。
Step 5、部署开发环境
镜像打包后我们直接部署到开发环境。

    stage('Deploy on Development') {
        echo "Deploy on Development"
	
    	def configArgs = [
    	    "APP_NAMESPACE=inf-dev",
    	    "IMAGE_SECRET=registry-secret",
    	    "APP_VERSION=${dateStr}-${BUILD_ID}.DEV", 
    	    "APP_ARGS=--spring.profiles.active=dev"
    	]

    	for (module in modules) {
    	    deploy(module, 'dev-inf-k8s-config', dockerRegistry, dockerRegistryCredentialsId, configArgs)
    	}
    }

需要注意的是,这里为了灵活控制,并没有统一管理各个模块的配置,而是有项目方按照约定自己维护好,但实际上长远还是要统一管理比较妥。

Step 6、部署Test环境
我们的Test环境既是我们内部测试环境,也是对外就是联调环境。

    stage('Deploy on Test') {
        echo "Deploy on Test"

	pushImages(dockerRegistry, dockerRegistryCredentialsId, modules, buildTag, "TEST")
	
    	def configArgs = [
    	    "APP_NAMESPACE=inf-test",
    	    "IMAGE_SECRET=registry-secret",
    	    "APP_VERSION=${dateStr}-${BUILD_ID}.TEST", 
    	    "APP_ARGS=--spring.profiles.active=test"
    	]

    	for (module in modules) {
    	    deploy(module, 'test-inf-k8s-config', dockerRegistry, dockerRegistryCredentialsId, configArgs)
    	}
    }

Step 7、自动化测试
运行事先写好的自动化测试脚本来检验程序是否运行正常。

    // 脚本测试
    stage('Auto Test') {
        echo "auto test"
        sh "docker run -it --rm -v $PWD:/code nosetests nosetests -s -v -c conf\run\api_test.cfg --attr safeControl=1"
    }

以前都是用jmeter来编写jmx的,但jmeter不能很好地模拟一些复杂的测试用例,另外我们api很多地方都需要用到编码,所以我们选择nosetests来编写自动化测试用例,nosetests使用起来相当简单,而且能生成html报告。

Step 8、人工测试
如果对自动化测试不放心,那么可以进行人工测试,待检测完后再继续操作。

    stage('Sanity check') {
        echo "Sanity check"
        dingding_confirm("是否将$JOB_NAME${currentBuild.displayName}部署到生产环境?", "请在7天内点击同意进行部署,或者拒绝中断部署", "3")
        timeout(time: 7, unit: 'DAYS') {
            input message: 'Do you want to deploy to prod?'
        }
    }

Step 9、灰度发布
当所有测试通过后,Pipeline 自动发布生产环境。

    stage('Deploy on Production') {
        echo "Deploy on Production"

	pushImages(dockerRegistry, dockerRegistryCredentialsId, modules, buildTag, "PROD")
	
    	def prodArgs = [
    	    "APP_NAMESPACE=inf-prod",
    	    "IMAGE_SECRET=registry-secret",
    	    "APP_VERSION=${dateStr}-${BUILD_ID}.PROD", 
    	    "APP_ARGS=--spring.profiles.active=prod"
    	]

    	for (module in modules) {
    	    deploy(module, 'prod-inf-k8s-config', dockerRegistry, dockerRegistryCredentialsId, prodArgs)
    	}
    }

需要说明的是,部署到生产环境还有一个重要的过程,灰度发布,这个需要结合k8s后台来操作。
目前我们使用的策略比较简单,就是通过不断地调整流量比例来替换服务,即通过上线 -> 观察 -> 停掉三个步骤来如此循环直到所有新版本都可以正常提供服务为止,然后删除下线旧版本。

关于k8s的灰度发布可以参考我以前的文章:k8s/实践金丝雀部署,当然你也可以使用其它方式,比如Linkerd + Namerd。

整个开发部署过程看起来是如此的简单快捷,但是不是每个人都能方便地操作呢?实际上不是这样的,除了技术之外我们还要考虑很多东西,比如权限。
根据业务的需要,我们在各个环节上涉及到审核机制,不同的阶段需要不同的人去操作,这就涉及到人机交互,我们通过钉钉很好地来解决这个问题。
1) 开发通知
image

2) 测试通知
image

3) 生产通知
image

至此,我们已经完成了从开发到部署的无缝集成,那么应用部署完之后,我们是怎么访问的呢?这里就得说一说Traefik。

Traefik
Traefik是用 Go 实现的的一个反向代理服务软件,跟我们以前用的Nginx很类似。实际上我们在发布过程中,每一个需要被外接访问到的服务都会配备一个Ingress的配置模板,每次部署时会自动填充模板发布,这样外部系统就可以访问到集群的服务。

tree ./kubernetes -L 2
./kubernetes
├── configmap.yaml
├── deployment.yaml
├── ing.yaml
└── service.yaml

0 directories, 4 files

cat kubernetes/ing.yaml
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: vip-points-h5-server
  annotations:
    kubernetes.io/ingress.class: traefik
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
  - host: $APP_DOMAIN
    http:
      paths:
      - path: /
        backend:
          serviceName: vip-points-h5-server
          servicePort: 80

以往我们都是在主机上去手动配置Nginx,测试完了之后再reload,但是这样相当繁琐,时不时发现短暂的认为操作失误。
Traefik与Nginx相比,其操作就简单得多,但性能特性却不逊色于Nginx:

  • 动态加载 ingress 更新路由规则
  • 根据 service 的定义动态更新后端 pod
  • 根据 pod 的 liveness 检查结果动态调整可用 pod
  • 请求直接发送到 pod
  • 自带熔断功能
-traefik.backend.circuitbreaker:NetworkErrorRatio() > 0.5
  • 动态权重的轮询策略
-traefik.backend.loadbalancer.method:drr

image

Ops

那么系统上线之后,就进入了运维阶段了。在容器时代,我们的目标是尽量实现自动化运维,这一步还未正式开始,简单说说下一步的计划:

容器监控

基于Prometheus+Grafana的主机、容器监控。

自动伸缩

有了监控指标以后,那么我们就可以实现自动化伸缩,当业务请求发生大量增加或减少,可以在没有人工参与的情况下,可以来收缩我们的服务实例数、甚至是我们的集群规模,但是这里我们一定需要建立一套自动伸缩的机制。
1、服务实例数的伸缩

  • 通过 Restful 的接口通知 AutoScaler 程序需要监控的应用服务。
  • AutoScaler 程序开始读取每台 Agent 上部署相关应用的 Metrics 数据,其中包括 CPU,内存的使用状况。

2、集群主机数的伸缩
由于目前我们使用的是混合云,即自建机房+阿里云,要做到集群规模的自动伸缩比较简单,即通过监控集群状态来调用云服务商的相关API。

写在最后

尽管目前已经将部分服务迁移到容器上来,但距离长远目标还有一定的路程需要走,这一过程也可能还会有很多想不到的问题等着我们,能否解决问题的关键就在于能否指引团队从传统开发模式转变到新模式上来。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions