CI/CD并不是陌生的东西,大部分企业都有自己的CI/CD,不过今天我要介绍的是使用Jenkins和GitOps实现CI/CD。

整体架构如下:
devops.png

涉及的软件以及版本信息如下:

软件 版本
kubernetes 1.17.9
docker 19.03.13
jenkins 2.249.3
argocd 1.8.0
gitlab 社区版11.8.1
sonarqube 社区版8.5.1
traefik 2.3.3
代码仓库 阿里云仓库

涉及的技术:

  • Jenkins shareLibrary
  • Jenkins pipeline
  • Jenkinsfile
  • Argocd
  • sonarqube api操作

软件安装

软件安装我这里不贴具体的安装代码了,所有的代码我都放在了github上,地址:https://github.com/cool-ops/kubernetes-software-yaml.git

所以这里默认你已经安装好所以软件了。

在Jenkins上安装如下插件

  • kubernetes
  • AnsiColor
  • HTTP Request
  • SonarQube Scanner
  • Utility Steps
  • Email Extension Template
  • Gitlab Hook
  • Gitlab

在Jenkins上配置Kubernetes集群信息

在系统管理—>系统配置—>cloud
image.png

在Jenkins上配置邮箱地址

系统设置—>系统配置—>Email
(1)设置管理员邮箱
image.png
配置SMTP服务
image.png

在Gitlab上准备一个测试代码

我这里有一个简单的java测试代码,地址如下:https://gitee.com/jokerbai/springboot-helloworld.git

可以将其导入到自己的gitlab仓库。

在Gitlab上创建一个共享库

首先在gitlab上创建一个共享库,我这里取名叫shareLibrary,如下:
image.png
然后创建src/org/devops目录,并在该目录下创建一下文件。
image.png
它们的内容分别如下:
build.groovy

  1. package org.devops
  2. // docker容器直接build
  3. def DockerBuild(buildShell){
  4. sh """
  5. ${buildShell}
  6. """
  7. }

sendEmail.groovy

  1. package org.devops
  2. //定义邮件内容
  3. def SendEmail(status,emailUser){
  4. emailext body: """
  5. <!DOCTYPE html>
  6. <html>
  7. <head>
  8. <meta charset="UTF-8">
  9. </head>
  10. <body leftmargin="8" marginwidth="0" topmargin="8" marginheight="4" offset="0">
  11. <table width="95%" cellpadding="0" cellspacing="0" style="font-size: 11pt; font-family: Tahoma, Arial, Helvetica, sans-serif">
  12. <tr>
  13. 本邮件由系统自动发出,无需回复!<br/>
  14. 各位同事,大家好,以下为${JOB_NAME}项目构建信息</br>
  15. <td><font color="#CC0000">构建结果 - ${status}</font></td>
  16. </tr>
  17. <tr>
  18. <td><br />
  19. <b><font color="#0B610B">构建信息</font></b>
  20. </td>
  21. </tr>
  22. <tr>
  23. <td>
  24. <ul>
  25. <li>项目名称:${JOB_NAME}</li>
  26. <li>构建编号:${BUILD_ID}</li>
  27. <li>构建状态: ${status} </li>
  28. <li>项目地址:<a href="${BUILD_URL}">${BUILD_URL}</a></li>
  29. <li>构建日志:<a href="${BUILD_URL}console">${BUILD_URL}console</a></li>
  30. </ul>
  31. </td>
  32. </tr>
  33. <tr>
  34. </table>
  35. </body>
  36. </html> """,
  37. subject: "Jenkins-${JOB_NAME}项目构建信息 ",
  38. to: emailUser
  39. }

sonarAPI.groovy

  1. package ore.devops
  2. // 封装HTTP请求
  3. def HttpReq(requestType,requestUrl,requestBody){
  4. // 定义sonar api接口
  5. def sonarServer = "http://sonar.devops.svc.cluster.local:9000/api"
  6. result = httpRequest authentication: 'sonar-admin-user',
  7. httpMode: requestType,
  8. contentType: "APPLICATION_JSON",
  9. consoleLogResponseBody: true,
  10. ignoreSslErrors: true,
  11. requestBody: requestBody,
  12. url: "${sonarServer}/${requestUrl}"
  13. return result
  14. }
  15. // 获取soanr项目的状态
  16. def GetSonarStatus(projectName){
  17. def apiUrl = "project_branches/list?project=${projectName}"
  18. // 发请求
  19. response = HttpReq("GET",apiUrl,"")
  20. // 对返回的文本做JSON解析
  21. response = readJSON text: """${response.content}"""
  22. // 获取状态值
  23. result = response["branches"][0]["status"]["qualityGateStatus"]
  24. return result
  25. }
  26. // 获取sonar项目,判断项目是否存在
  27. def SearchProject(projectName){
  28. def apiUrl = "projects/search?projects=${projectName}"
  29. // 发请求
  30. response = HttpReq("GET",apiUrl,"")
  31. println "搜索的结果:${response}"
  32. // 对返回的文本做JSON解析
  33. response = readJSON text: """${response.content}"""
  34. // 获取total字段,该字段如果是0则表示项目不存在,否则表示项目存在
  35. result = response["paging"]["total"]
  36. // 对result进行判断
  37. if (result.toString() == "0"){
  38. return "false"
  39. }else{
  40. return "true"
  41. }
  42. }
  43. // 创建sonar项目
  44. def CreateProject(projectName){
  45. def apiUrl = "projects/create?name=${projectName}&project=${projectName}"
  46. // 发请求
  47. response = HttpReq("POST",apiUrl,"")
  48. println(response)
  49. }
  50. // 配置项目质量规则
  51. def ConfigQualityProfiles(projectName,lang,qpname){
  52. def apiUrl = "qualityprofiles/add_project?language=${lang}&project=${projectName}&qualityProfile=${qpname}"
  53. // 发请求
  54. response = HttpReq("POST",apiUrl,"")
  55. println(response)
  56. }
  57. // 获取质量阈ID
  58. def GetQualityGateId(gateName){
  59. def apiUrl = "qualitygates/show?name=${gateName}"
  60. // 发请求
  61. response = HttpReq("GET",apiUrl,"")
  62. // 对返回的文本做JSON解析
  63. response = readJSON text: """${response.content}"""
  64. // 获取total字段,该字段如果是0则表示项目不存在,否则表示项目存在
  65. result = response["id"]
  66. return result
  67. }
  68. // 更新质量阈规则
  69. def ConfigQualityGate(projectKey,gateName){
  70. // 获取质量阈id
  71. gateId = GetQualityGateId(gateName)
  72. apiUrl = "qualitygates/select?projectKey=${projectKey}&gateId=${gateId}"
  73. // 发请求
  74. response = HttpReq("POST",apiUrl,"")
  75. println(response)
  76. }
  77. //获取Sonar质量阈状态
  78. def GetProjectStatus(projectName){
  79. apiUrl = "project_branches/list?project=${projectName}"
  80. response = HttpReq("GET",apiUrl,'')
  81. response = readJSON text: """${response.content}"""
  82. result = response["branches"][0]["status"]["qualityGateStatus"]
  83. //println(response)
  84. return result
  85. }

sonarqube.groovy

  1. package ore.devops
  2. def SonarScan(projectName,projectDesc,projectPath){
  3. // sonarScanner安装地址
  4. def sonarHome = "/opt/sonar-scanner"
  5. // sonarqube服务端地址
  6. def sonarServer = "http://sonar.devops.svc.cluster.local:9000/"
  7. // 以时间戳为版本
  8. def scanTime = sh returnStdout: true, script: 'date +%Y%m%d%H%m%S'
  9. scanTime = scanTime - "\n"
  10. sh """
  11. ${sonarHome}/bin/sonar-scanner -Dsonar.host.url=${sonarServer} \
  12. -Dsonar.projectKey=${projectName} \
  13. -Dsonar.projectName=${projectName} \
  14. -Dsonar.projectVersion=${scanTime} \
  15. -Dsonar.login=admin \
  16. -Dsonar.password=admin \
  17. -Dsonar.ws.timeout=30 \
  18. -Dsonar.projectDescription="${projectDesc}" \
  19. -Dsonar.links.homepage=http://www.baidu.com \
  20. -Dsonar.sources=${projectPath} \
  21. -Dsonar.sourceEncoding=UTF-8 \
  22. -Dsonar.java.binaries=target/classes \
  23. -Dsonar.java.test.binaries=target/test-classes \
  24. -Dsonar.java.surefire.report=target/surefire-reports -X
  25. echo "${projectName} scan success!"
  26. """
  27. }

tools.groovy

  1. package org.devops
  2. //格式化输出
  3. def PrintMes(value,color){
  4. colors = ['red' : "\033[40;31m >>>>>>>>>>>${value}<<<<<<<<<<< \033[0m",
  5. 'blue' : "\033[47;34m ${value} \033[0m",
  6. 'green' : ">>>>>>>>>>${value}>>>>>>>>>>",
  7. 'green1' : "\033[40;32m >>>>>>>>>>>${value}<<<<<<<<<<< \033[0m" ]
  8. ansiColor('xterm') {
  9. println(colors[color])
  10. }
  11. }
  12. // 获取镜像版本
  13. def createVersion() {
  14. // 定义一个版本号作为当次构建的版本,输出结果 20191210175842_69
  15. return new Date().format('yyyyMMddHHmmss') + "_${env.BUILD_ID}"
  16. }
  17. // 获取时间
  18. def getTime() {
  19. // 定义一个版本号作为当次构建的版本,输出结果 20191210175842
  20. return new Date().format('yyyyMMddHHmmss')
  21. }

在Gitlab上创建一个YAML管理仓库

我这里创建了一个叫devops-cd的共享仓库,如下:
image.png

然后以应用名创建一个目录,并在目录下创建以下几个文件。
image.png
它们的内容分别如下。
service.yaml

  1. kind: Service
  2. apiVersion: v1
  3. metadata:
  4. name: the-service
  5. namespace: default
  6. spec:
  7. selector:
  8. deployment: hello
  9. type: NodePort
  10. ports:
  11. - protocol: TCP
  12. port: 8080
  13. targetPort: 8080

ingress.yaml

  1. apiVersion: extensions/v1beta1
  2. kind: Ingress
  3. metadata:
  4. name: the-ingress
  5. namespace: default
  6. spec:
  7. rules:
  8. - host: test.coolops.cn
  9. http:
  10. paths:
  11. - backend:
  12. serviceName: the-service
  13. servicePort: 8080
  14. path: /

deploymeny.yaml

  1. apiVersion: apps/v1
  2. kind: Deployment
  3. metadata:
  4. name: the-deployment
  5. namespace: default
  6. spec:
  7. replicas: 3
  8. selector:
  9. matchLabels:
  10. deployment: hello
  11. template:
  12. metadata:
  13. labels:
  14. deployment: hello
  15. spec:
  16. containers:
  17. - args:
  18. - -jar
  19. - /opt/myapp.jar
  20. - --server.port=8080
  21. command:
  22. - java
  23. env:
  24. - name: HOST_IP
  25. valueFrom:
  26. fieldRef:
  27. apiVersion: v1
  28. fieldPath: status.hostIP
  29. image: registry.cn-hangzhou.aliyuncs.com/rookieops/myapp:latest
  30. imagePullPolicy: IfNotPresent
  31. lifecycle:
  32. preStop:
  33. exec:
  34. command:
  35. - /bin/sh
  36. - -c
  37. - /bin/sleep 30
  38. livenessProbe:
  39. failureThreshold: 3
  40. httpGet:
  41. path: /hello
  42. port: 8080
  43. scheme: HTTP
  44. initialDelaySeconds: 60
  45. periodSeconds: 15
  46. successThreshold: 1
  47. timeoutSeconds: 1
  48. name: myapp
  49. ports:
  50. - containerPort: 8080
  51. name: http
  52. protocol: TCP
  53. readinessProbe:
  54. failureThreshold: 3
  55. httpGet:
  56. path: /hello
  57. port: 8080
  58. scheme: HTTP
  59. periodSeconds: 15
  60. successThreshold: 1
  61. timeoutSeconds: 1
  62. resources:
  63. limits:
  64. cpu: "1"
  65. memory: 2Gi
  66. requests:
  67. cpu: 100m
  68. memory: 1Gi
  69. terminationMessagePath: /dev/termination-log
  70. terminationMessagePolicy: File
  71. dnsPolicy: ClusterFirstWithHostNet
  72. imagePullSecrets:
  73. - name: gitlab-registry

kustomization.yaml

  1. # Example configuration for the webserver
  2. # at https://github.com/monopole/hello
  3. commonLabels:
  4. app: hello
  5. resources:
  6. - deployment.yaml
  7. - service.yaml
  8. - ingress.yaml
  9. apiVersion: kustomize.config.k8s.io/v1beta1
  10. kind: Kustomization
  11. images:
  12. - name: registry.cn-hangzhou.aliyuncs.com/rookieops/myapp
  13. newTag: "20201127150733_70"
  14. namespace: dev

在Jenkins上配置共享库

(1)需要在Jenkins上添加凭证
image.png
(2)在Jenkins的系统配置里面配置共享库(系统管理—>系统配置)
image.png
然后点击应用并保存

然后我们可以用一个简单的Jenkinsfile测试一下共享库,看配置是否正确。

在Jenkins上创建一个项目,如下:
image.png

然后在最地下的pipeline处贴入以下代码:

  1. def labels = "slave-${UUID.randomUUID().toString()}"
  2. // 引用共享库
  3. @Library("jenkins_shareLibrary")
  4. // 应用共享库中的方法
  5. def tools = new org.devops.tools()
  6. pipeline {
  7. agent {
  8. kubernetes {
  9. label labels
  10. yaml """
  11. apiVersion: v1
  12. kind: Pod
  13. metadata:
  14. labels:
  15. some-label: some-label-value
  16. spec:
  17. volumes:
  18. - name: docker-sock
  19. hostPath:
  20. path: /var/run/docker.sock
  21. type: ''
  22. containers:
  23. - name: jnlp
  24. image: registry.cn-hangzhou.aliyuncs.com/rookieops/inbound-agent:4.3-4
  25. - name: maven
  26. image: registry.cn-hangzhou.aliyuncs.com/rookieops/maven:3.5.0-alpine
  27. command:
  28. - cat
  29. tty: true
  30. - name: docker
  31. image: registry.cn-hangzhou.aliyuncs.com/rookieops/docker:19.03.11
  32. command:
  33. - cat
  34. tty: true
  35. volumeMounts:
  36. - name: docker-sock
  37. mountPath: /var/run/docker.sock
  38. """
  39. }
  40. }
  41. stages {
  42. stage('Checkout') {
  43. steps {
  44. script{
  45. tools.PrintMes("拉代码","green")
  46. }
  47. }
  48. }
  49. stage('Build') {
  50. steps {
  51. container('maven') {
  52. script{
  53. tools.PrintMes("编译打包","green")
  54. }
  55. }
  56. }
  57. }
  58. stage('Make Image') {
  59. steps {
  60. container('docker') {
  61. script{
  62. tools.PrintMes("构建镜像","green")
  63. }
  64. }
  65. }
  66. }
  67. }
  68. }

然后点击保存并运行,如果看到输出有颜色,就代表共享库配置成功,如下:
image.png
到此共享库配置完成。

编写Jenkinsfile

整个java的Jenkinsfile如下:

  1. def labels = "slave-${UUID.randomUUID().toString()}"
  2. // 引用共享库
  3. @Library("jenkins_shareLibrary")
  4. // 应用共享库中的方法
  5. def tools = new org.devops.tools()
  6. def sonarapi = new org.devops.sonarAPI()
  7. def sendEmail = new org.devops.sendEmail()
  8. def build = new org.devops.build()
  9. def sonar = new org.devops.sonarqube()
  10. // 前端传来的变量
  11. def gitBranch = env.branch
  12. def gitUrl = env.git_url
  13. def buildShell = env.build_shell
  14. def image = env.image
  15. def dockerRegistryUrl = env.dockerRegistryUrl
  16. def devops_cd_git = env.devops_cd_git
  17. pipeline {
  18. agent {
  19. kubernetes {
  20. label labels
  21. yaml """
  22. apiVersion: v1
  23. kind: Pod
  24. metadata:
  25. labels:
  26. some-label: some-label-value
  27. spec:
  28. volumes:
  29. - name: docker-sock
  30. hostPath:
  31. path: /var/run/docker.sock
  32. type: ''
  33. - name: maven-cache
  34. persistentVolumeClaim:
  35. claimName: maven-cache-pvc
  36. containers:
  37. - name: jnlp
  38. image: registry.cn-hangzhou.aliyuncs.com/rookieops/inbound-agent:4.3-4
  39. - name: maven
  40. image: registry.cn-hangzhou.aliyuncs.com/rookieops/maven:3.5.0-alpine
  41. command:
  42. - cat
  43. tty: true
  44. volumeMounts:
  45. - name: maven-cache
  46. mountPath: /root/.m2
  47. - name: docker
  48. image: registry.cn-hangzhou.aliyuncs.com/rookieops/docker:19.03.11
  49. command:
  50. - cat
  51. tty: true
  52. volumeMounts:
  53. - name: docker-sock
  54. mountPath: /var/run/docker.sock
  55. - name: sonar-scanner
  56. image: registry.cn-hangzhou.aliyuncs.com/rookieops/sonar-scanner:latest
  57. command:
  58. - cat
  59. tty: true
  60. - name: kustomize
  61. image: registry.cn-hangzhou.aliyuncs.com/rookieops/kustomize:v3.8.1
  62. command:
  63. - cat
  64. tty: true
  65. """
  66. }
  67. }
  68. environment{
  69. auth = 'joker'
  70. }
  71. options {
  72. timestamps() // 日志会有时间
  73. skipDefaultCheckout() // 删除隐式checkout scm语句
  74. disableConcurrentBuilds() //禁止并行
  75. timeout(time:1,unit:'HOURS') //设置流水线超时时间
  76. }
  77. stages {
  78. // 拉取代码
  79. stage('GetCode') {
  80. steps {
  81. checkout([$class: 'GitSCM', branches: [[name: "${gitBranch}"]],
  82. doGenerateSubmoduleConfigurations: false,
  83. extensions: [],
  84. submoduleCfg: [],
  85. userRemoteConfigs: [[credentialsId: '83d2e934-75c9-48fe-9703-b48e2feff4d8', url: "${gitUrl}"]]])
  86. }
  87. }
  88. // 单元测试和编译打包
  89. stage('Build&Test') {
  90. steps {
  91. container('maven') {
  92. script{
  93. tools.PrintMes("编译打包","blue")
  94. build.DockerBuild("${buildShell}")
  95. }
  96. }
  97. }
  98. }
  99. // 代码扫描
  100. stage('CodeScanner') {
  101. steps {
  102. container('sonar-scanner') {
  103. script {
  104. tools.PrintMes("代码扫描","green")
  105. tools.PrintMes("搜索项目","green")
  106. result = sonarapi.SearchProject("${JOB_NAME}")
  107. println(result)
  108. if (result == "false"){
  109. println("${JOB_NAME}---项目不存在,准备创建项目---> ${JOB_NAME}!")
  110. sonarapi.CreateProject("${JOB_NAME}")
  111. } else {
  112. println("${JOB_NAME}---项目已存在!")
  113. }
  114. tools.PrintMes("代码扫描","green")
  115. sonar.SonarScan("${JOB_NAME}","${JOB_NAME}","src")
  116. sleep 10
  117. tools.PrintMes("获取扫描结果","green")
  118. result = sonarapi.GetProjectStatus("${JOB_NAME}")
  119. println(result)
  120. if (result.toString() == "ERROR"){
  121. toemail.Email("代码质量阈错误!请及时修复!",userEmail)
  122. error " 代码质量阈错误!请及时修复!"
  123. } else {
  124. println(result)
  125. }
  126. }
  127. }
  128. }
  129. }
  130. // 构建镜像
  131. stage('BuildImage') {
  132. steps {
  133. withCredentials([[$class: 'UsernamePasswordMultiBinding',
  134. credentialsId: 'dockerhub',
  135. usernameVariable: 'DOCKER_HUB_USER',
  136. passwordVariable: 'DOCKER_HUB_PASSWORD']]) {
  137. container('docker') {
  138. script{
  139. tools.PrintMes("构建镜像","green")
  140. imageTag = tools.createVersion()
  141. sh """
  142. docker login ${dockerRegistryUrl} -u ${DOCKER_HUB_USER} -p ${DOCKER_HUB_PASSWORD}
  143. docker build -t ${image}:${imageTag} .
  144. docker push ${image}:${imageTag}
  145. docker rmi ${image}:${imageTag}
  146. """
  147. }
  148. }
  149. }
  150. }
  151. }
  152. // 部署
  153. stage('Deploy') {
  154. steps {
  155. withCredentials([[$class: 'UsernamePasswordMultiBinding',
  156. credentialsId: 'ci-devops',
  157. usernameVariable: 'DEVOPS_USER',
  158. passwordVariable: 'DEVOPS_PASSWORD']]){
  159. container('kustomize') {
  160. script{
  161. APP_DIR="${JOB_NAME}".split("_")[0]
  162. sh """
  163. git remote set-url origin http://${DEVOPS_USER}:${DEVOPS_PASSWORD}@${devops_cd_git}
  164. git config --global user.name "Administrator"
  165. git config --global user.email "coolops@163.com"
  166. git clone http://${DEVOPS_USER}:${DEVOPS_PASSWORD}@${devops_cd_git} /opt/devops-cd
  167. cd /opt/devops-cd
  168. git pull
  169. cd /opt/devops-cd/${APP_DIR}
  170. kustomize edit set image ${image}:${imageTag}
  171. git commit -am 'image update'
  172. git push origin master
  173. """
  174. }
  175. }
  176. }
  177. }
  178. }
  179. // 接口测试
  180. stage('InterfaceTest') {
  181. steps{
  182. sh 'echo "接口测试"'
  183. }
  184. }
  185. }
  186. // 构建后的操作
  187. post {
  188. success {
  189. script{
  190. println("success:只有构建成功才会执行")
  191. currentBuild.description += "\n构建成功!"
  192. // deploy.AnsibleDeploy("${deployHosts}","-m ping")
  193. sendEmail.SendEmail("构建成功",toEmailUser)
  194. // dingmes.SendDingTalk("构建成功 ✅")
  195. }
  196. }
  197. failure {
  198. script{
  199. println("failure:只有构建失败才会执行")
  200. currentBuild.description += "\n构建失败!"
  201. sendEmail.SendEmail("构建失败",toEmailUser)
  202. // dingmes.SendDingTalk("构建失败 ❌")
  203. }
  204. }
  205. aborted {
  206. script{
  207. println("aborted:只有取消构建才会执行")
  208. currentBuild.description += "\n构建取消!"
  209. sendEmail.SendEmail("取消构建",toEmailUser)
  210. // dingmes.SendDingTalk("构建失败 ❌","暂停或中断")
  211. }
  212. }
  213. }
  214. }

需要在Jenkins上创建两个凭证,一个id叫dockerhub,一个叫ci-devops,还有一个叫sonar-admin-user。

dockerhub是登录镜像仓库的用户名和密码。

ci-devops是管理YAML仓库的用户名和密码。

sonar-admin-user是管理sonarqube的用户名和密码。

然后将这个Jenkinsfile保存到shareLibrary的根目录下,命名为java.Jenkinsfile。
image.png

在Jenkins上配置项目

在Jenkins上新建一个项目,如下:
image.png
然后添加以下参数化构建。
image.png
image.png
image.png
image.png
image.png
image.png
image.png
然后在流水线处配置Pipeline from SCM
image.png
image.png
此处需要注意脚本名。

然后点击应用保存,并运行。

image.png
也可以在sonarqube上看到代码扫描的结果。
image.png

在Argocd上配置CD流程

在argocd上添加代码仓库,如下:
image.png
image.png
然后创建应用,如下:
image.png
image.png
点击创建后,如下:
image.png
点进去可以看到更多的详细信息。
image.png

argocd有一个小bug,它ingress的健康检查必须要loadBalance有值,不然就不通过,但是并不影响使用。

然后可以正常访问应用了。
image.png

node项目的Jenkinsfile大同小异,由于我没有测试用例,所以并没有测试。

集成Gitlab,通过Webhook触发Jenkins

在Jenkins中选择项目,在项目中配置gitlab触发,如下:
image.png
生成token,如下
image.png
在gitlab上配置集成。进入项目—>项目设置—>集成
image.png
配置Jenkins上生成的回调URL和TOKEN
image.png
到此配置完成,然后点击下方test,可以观察是否触发流水线。
image.png
也可以通过修改仓库代码进行测试。

写在最后

本片文章是纯操作步骤,大家在测试的时候可能会对Jenkinsfile做细微的调整,不过整体没什么问题。