一个完整的Jenkis Docker pipeline

本文主要用于展现CI/CD相关的流程,首先展示如何基于Docker 容器搭建Jenkins环境,该Jenkins容器环境可以编译Java、NodeJS,并且可以在Docker容器内编译Jenkins镜像的基本环境搭建。

同时将展示利用JenkinsFile编写混合声明式pipline和脚本式pipline来实现通过maven编译一个Java程序为Jar,通过Dockerfile将Jar和相关环境配置编译成为docker镜像并发布到阿里云的私有Regisery上,最后发布的过程。

一. Jenkins环境的搭建

Jenksin容器的编译

这里Jenkins环境将基于docker容器来实现,选用的基础镜像是
jenkinsci/blueocean:latest。 这个镜像是Jenkins官方的含有blue ocean镜像,基于alphine Linux,有效的减少了镜像的基本大小。由于日常环境中的需要,在容器中同时按照了一些nodejs和py相关的一些包。由于需要编译docker镜像也安装了docker相关组件。在国内下载Jenkins的插件是非常缓慢的过程,所以在该镜像中也配置了利用jenkins-zh.cn镜像来加速镜像下载的相关配置。下面看下Dockerfile的内容。

FROM jenkinsci/blueocean:latest
ENV JENKINS_UC https://updates.jenkins-zh.cn
ENV JENKINS_UC_DOWNLOAD https://mirrors.huaweicloud.com/jenkins/

ENV JENKINS_OPTS="-Dhudson.model.UpdateCenter.updateCenterUrl=https://updates.jenkins-zh.cn/update-center.json"
ENV JENKINS_OPTS="-Djenkins.install.runSetupWizard=false"

COPY init.groovy /usr/share/jenkins/ref/init.groovy.d/init.groovy
COPY hudson.model.UpdateCenter.xml /usr/share/jenkins/ref/hudson.model.UpdateCenter.xml
COPY mirror-adapter.crt /usr/share/jenkins/ref/mirror-adapter.crt


USER root

RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories

USER root

RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories

RUN apk update && apk add --no-cache --repository=http://mirrors.ustc.edu.cn/alpine/edge/main curl vim maven nodejs yarn python3 py3-pip

COPY UpdateReleaseTool /usr/local/UpdateReleaseTool

RUN chmod 777 /usr/local/UpdateReleaseTool

USER jenkins


RUN jenkins-plugin-cli --plugins generic-webhook-trigger:1.72
RUN jenkins-plugin-cli --plugins ssh-steps:2.0.0

解释:
2-19行代码主要配置了国内的Jenkins更新中心的镜像,并且复制了相关证书
21行安装了需要的一些组件,包括CURL、VIM、Maven、nodejs和python相关
23-25行:是一个自己用golang开发的发布到公司服务器工具。
30-31行:安装了generic-webhook-trigger用于在pipline中通过git提交来触发Job,ssh-steps是一个可以在jenkinsfile中连接远程Linux服务器执行相关命令的插件

由于我们需要在容器内编译docker镜像,因此需要使用 docker:dind 镜像来启动一个容器,在容器内运行容器来编译容器,这主要是用来开发。

Jenkins容器的运行

1.建立容器网络

通过建立一个容器间的网络,将docker:dind 和 jenkins放在这个网络中,并隔离网络环境,使得这两个容器互相可以通信

    docker network create jenksin

2. 运行docker:dind 容器

    docker run \
      --name jenkins-docker \
      --detach \
      --privileged \
      --network jenkins \
      --network-alias docker \
      --env DOCKER_TLS_CERTDIR=/certs \
      --volume jenkins-docker-certs:/certs/client \
      --volume /external-data/jenkins:/var/jenkins_home \
      --volume /data/docker:/var/lib/docker/ \
      --publish 2376:2376 \
      --restart=always \ 
      docker:dind --registry-mirror https://u7g484go.mirror.aliyuncs.com

这里设置容器镜像加速到阿里云相关容器加速工具,暴露来2376端口用来给Jenkins容器连接。

3. 运行Jenkins相关容器

    docker run \
      --name jenkins-blueocean \
      --detach \
      --network jenkins \
      --env DOCKER_HOST=tcp://docker:2376 \
      --env DOCKER_CERT_PATH=/certs/client \
      --env DOCKER_TLS_VERIFY=1 \
      --publish 8080:8080 \
      --publish 50000:50000 \
      --volume /external-data/jenkins:/var/jenkins_home \
      --volume jenkins-docker-certs:/certs/client:ro \
      --restart=always macco_jenkins:latest 

这里设置了Docker的容器host变量为docker:dind 的容器实例。macco_jenkis镜像是用之前的dockerfile编译出来的镜像。当容器启动后,稍等一些时就可以从 localhost:8080 访问jenkins。
第一次启动的时候会要求输入token,并进行一些配置,token可以通过docker logins命令获取,如下图

获取Token

输入Token后,进行基本的配置,可以安装一些插件,如ssh-agent,publish-over-ssh等,也可以之后安装,之后就是设置用户名和密码,完成后jenkins会重启,稍等就可以了。

4. 修改mvn的镜像地址

同样由于国内网络的原因,必须修改mvn的镜像地址到阿里云或者其他镜像,用以加速mvn的下载。

    docker exec -it --user root jenkins-blueocean bash

以为在dockerfile中,容器采用jenkins这个用户运行,所以默认 情况下是没有办法修改mvn的配置文件的。所以采用--user 切换到root用户。先通过如下命令获取maven的安装路径

     mvn -v

得到如下输出

    Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)
    Maven home: /usr/share/java/maven-3
    Java version: 1.8.0_275, vendor: AdoptOpenJDK, runtime: /opt/java/openjdk/jre
    Default locale: en_US, platform encoding: UTF-8
    OS name: "linux", version: "3.10.0-1127.8.2.el7.x86_64", arch: "amd64", family: "unix"
    

很明显mvn被安装在 /usr/share/java/maven-3下,之后修改配置文件到阿里云,方法如下:

     vi /usr/share/java/maven-3/conf/settings.xml

在mirros下修改加入如下节点:

<mirror>
    <id>aliyunmaven</id>
    <mirrorOf>*</mirrorOf>
    <name>阿里云公共仓库</name>
    <url>https://maven.aliyun.com/repository/public</url>
</mirror>

5. 设置jenkins的全局工具配置

通过系统管理-》全局工具配置来配置工具的位置,主要是jdk mvn、node、docker的,如图:

配置JDK相关

配置Maven:

配置NodeJS:

6. 安装docker相关插件

在系统管理》插件管理安装如下docker相关插件

  1. Docker commons
  2. Docker pipeline
  3. docker-build-step

4.docker compose build step

安装完成后,在系统管理-》全局工具配置来配置工具的位置中配置docker相关的位置

7. 配置gitlab的私钥和公钥

首先在私有的gitlab账号下,通过管理员权限配置一个共用的deploy key,如图

需要在jenkins容器中产生一对公钥和私钥

     docker exec -it jenkins-blueocean bash
    ssh-keygen -t rsa -b 4096 -C "lokie@5imakeup.com"
    cat ~/.ssh/id_rsa.pub  #获取公钥

之后在Jenkins系统管理》》凭据管理 中增加这个共钥的私钥,这里我们将在全局凭据中添加。

输入ID,这个ID可以在pipeline中使用,作为凭据的唯一标示。
输入用户名,输入私钥,私钥通过如下方式获取:

     docker exec -it jenkins-blueocean bash
     cat ~/.ssh/id_rsa

到这里完成了关于jenkins基本环境的配置,之后的设置是和工程特定pipeline以及任务相关的的。

二. Pipeline和Jenkinsfile的编写

1. Pipeline的基本概念

简单的来说,pipeline是Jenkins从2.0开始引入的功能,使得Jenkins能够通过编程或者脚本文件的方式来定义如何使用CI/CD相关的功能,而不是通过UI界面上的操作和配置。编写一个pipeline可以通过创建一个pipeline项目,将相关的代码在其中输入,也可以通过在需要集成CI/CD功能的项目中创建一个Jenkinsfile的文本文件并在文件中编写pipeline相关的代码来实现相关功能。

从以上描述可以知道Jenkinsfile具有明显的优势,其本身就能被源代码管理工具管理,具有最大的灵活性。

2. 声明式Pipeline和脚本式Pipeline

存在两种不同类型的方式来创建一个pipeline即声明式和脚本式。Jenkins官方推荐初学者采用声明式来编写,声明式Pipeline脚本是一种特定的DSL语言,而脚本式就是Groovy脚本,具有更强大的功能,当然在声明式Pipeline中是可以集成groovy脚本的。

3 基本的声明Pipeline列子

    // 标示一个Pipeline 脚本的开始
    pipeline{
    // 指定运行的代理,Jenkins本身是一个分布式的理念,可以在不同的agent上执行不同的功能,agent可以理解为一个机器
    agent{
        label "node"
    }
    // pipeline有多个不同的Stage 组成,这里是顺序执行的
    stages{
        stage("A"){
           // 运行的条件
           when {
               
           }
           // 一个Stage又有1个或多个Step,step就是一个基本的CI/CD操作
            steps{
                echo "========executing A========"
            }
            
            // Stage 中所有的steps执行后的一些动作
            post{
                always{
                    echo "========always========"
                }
                success{
                    echo "========A executed successfully========"
                }
                failure{
                    echo "========A execution failed========"
                }
            }
        }
    }
    
    // // 所有的stages s执行后的一些动作
    post{
        always{
            echo "========always========"
        }
        success{
            echo "========pipeline executed successfully ========"
        }
        failure{
            echo "========pipeline execution failed========"
        }
    }
}

4 script Pipeline

    node {
            stage("Prepare") {
                def a = “”
                if(env.BRANCH_NAME == 'PPE') {
                   
                }
            }
    }

可以看到script pipeline是由node开头的,其中可以包含groovy的脚本。

5 实际需求描述

下面的例子是我司一个实际的需求

  1. 通过Webhook在私有的Gitlab上提交代码,触发Jenkins相关Job,仅仅触发某些分支
  2. 编译Springboot项目为Jar文件
  3. 将Jar文件根据项目中的Dockerfile的要求打包成为相关镜像并推送到阿里云私有Register上
  4. 镜像版本根据当前日期和发布次数分支自动产生,如v2021022801-ppe
  5. 推送后选择远程服务器,根据环境运行docker-compose自动部署
  6. 整个部署过程中相关信息通过企业微信机器人自动发布

6 所用pipeline选择和相关技术

由于需要一些自定义版本号类似的功能,为实现相关需求选择了结合声明式和脚本一起的功能来时实现。
对于企业微信提示,选择自己开发了基于Groove的脚本作为Jenkins全局库引用。

7 相关插件和说明

  1. SSH Pipeline Steps

该插件主要用于在Jenkinsfile文件中执行远程的SSH命令,详细见:https://plugins.jenkins.io/ssh-steps/ 支持从远程执行ssh命令,主要用于在远程节点上执行ssh命令用于部署

  1. Credentials Binding Plugin

该插件主要用于在pipeline中用脚本的方式获取存储在Jenkins Credentials中的各种凭据,这样就可以不要在代码中暴露生产环境的各种凭据信息,见https://plugins.jenkins.io/credentials-binding/

  1. Docker Pipeline

用Script的方式编程做各种docker操作,这里用于镜像的产生。见https://plugins.jenkins.io/docker-workflow/

  1. Generic Webhook Trigger

用于Webhhok的实现,实现当gitlab提交后自动触发Jenkins的相关Job,见https://plugins.jenkins.io/generic-webhook-trigger/

8 详细过程

  1. 相关凭据设置和gitlab相关配置

首先需要增加一些凭据,比如gitlab的私有ssh key,阿里云相关Regisery的登陆用户和密码、远端部署服务器的用户名和密码等,具体操作都是通过系统管理》凭据管理来实现的,如图:

添加ssh key:

这里的SSH key可以从通过 在某一个Linux或者macos下运行,如下命令:

    ssh-keygen -t rsa -b 4096 -C "email@example.com"
    cat ~/.ssh/id_rsa #私钥
    cat cat ~/.ssh/id_rsa.pub #公钥

将公钥加入Jenkins的Deploy keys上

私钥则输入到

添加阿里云相关Regisery的登陆用户和密码:

  1. 用于发布企业微信信息的Groovy库编写

利用Grovvy发送企业微信机器信息主要就是调用restful接口,代码如下:
首先定义个Http clinet:

    package com.imacco.devops
    class HttpClient {
    static String PostJsonRequest(String urlStr,String jsonInputString) {
        def result = ""
        try {
            URL url = new URL(urlStr)
            def conn = (HttpURLConnection)url.openConnection()
            conn.setRequestMethod("POST")
            conn.setRequestProperty("Content-Type", "application/json; utf-8")
            conn.setRequestProperty("Accept", "application/json")
            conn.setDoOutput(true)
            // 设置不用缓存
            conn.setUseCaches(false);
            // 设置传递方式
            conn.setRequestMethod("POST");
            // 设置维持长连接
            conn.setRequestProperty("Connection", "Keep-Alive");
            // 设置文件字符集:
            conn.setRequestProperty("Charset", "UTF-8");
            //转换为字节数组
            byte[] data = jsonInputString.getBytes();
            // 设置文件长度
            conn.setRequestProperty("Content-Length", String.valueOf(data.length))

            // 设置文件类型:
            conn.setRequestProperty("contentType", "application/json")
            // 开始连接请求
            conn.connect()
            OutputStream  out = conn.getOutputStream()
            // 写入请求的字符串
            out.write(data)
            out.flush()
            out.close()
            println("connection:"+conn.getResponseCode())
            // 请求返回的状态
            if (conn.getResponseCode() == 200) {
                println("http success 200");
                // 请求返回的数据
                InputStream inputStream = conn.getInputStream()
                String a = null;
                try {
                    byte[] data1 = new byte[inputStream.available()]
                    inputStream.read(data1);
                    // 转成字符串
                    a = new String(data1);
                    println(a);
                } catch (Exception e1) {
                    e1.printStackTrace()
                }
            }
        } catch(IOException ioException) {
            println(ioException.getMessage())
        }

        return result
    }
    }

Jenkins groovy 库必须遵守如下规范:

src中用于存放相关包和自己的帮助类而vars就是入口点:

代码如下:

    import com.imacco.devops.HttpClient
    def call(Map config=[:]) {
    println("message:${config["message"]}")
    if(config["message"] != '') {
        def url = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=微信机器Key"
        def jsonMessage = """
            {
                "msgtype": "text",
                "text": {
                    "content": "${config.message}"
                }
           }
        """
        def result = HttpClient.PostJsonRequest(url,jsonMessage)
        println(result)
    }
}

库必须是一个名为call的函数,可以用Map config=[:]来传值,当然也可以用其他类型,但是Jenksin会认为这个类型是不安全,需要在设置中确认。

  1. pipeline库的安装

首先将上面的代码放到gitlab上建立一个单独的分支并正确配置gitlab相关的私钥和公钥,并设置权限等
在Jenkins系统管理》系统配置中找到Global Pipeline Libraries,增加一个新的

这里的Name可以填写和vars中文件一样的名字,版本号可以是git的分支名字或者任意一个commit sha值,通过设置后,在Jenkinsfile文件中我们可以通过如下方式引入

    @Library('WechatAlerter')
  1. 完整Jenkins文件说明

下面看下完整的Jenkinsfile

    @Library('WechatAlerter')     // 导企业微信库
    import java.text.SimpleDateFormat  // 导入java日期计算的库
    pipeline {
    agent any
    triggers {
       // 声明一个GenericTrigger用于gitlab的触发,具体见后面的描述
         GenericTrigger (    
            genericVariables: [
                [key: 'ref', value: '$.ref']
            ],
            causeString: ' Triggered on $branch' ,
            printContributedVariables: true,
            printPostContent: true,
            silentResponse: false,
            regexpFilterText: '$ref',
            regexpFilterExpression: 'refs/heads/' + BRANCH_NAME,
            token: 'tokn‘'
        )
    }  
    stages {
     // 准备docker镜像相关版本好
        stage("Prepare") {
            steps {
                echo "prepare"
                script {
                    checkout scm
                    docker_host = "registry.cn-shanghai.aliyuncs.com/test"
                    img_name = "xunzhao_businessapi"
                    docker_img_name = "${docker_host}/${img_name}"

                    def date = new Date()
                    def sdf = new SimpleDateFormat("yyyyMMdd")
                    def datestr = sdf.format(date)
                    def version = ""
                    def pcount = ""
                    def pversion = ""
                    def flag = ""
                    if("${ref}" == 'refs/heads/ppe') {
                       // 在Jenkins服务器上执行Docker images命令,并获取已经有的版本情况
                        pcount = sh(
                            script: "docker images  | grep xunzhao_businessapi | awk '{print \$2}' | grep 'ppe\$' | grep ${datestr} | wc -l",
                            returnStdout: true
                        ).trim()
                        echo 'pcount = ${pcount}'
                        pversion = sh(
                            script: "docker images  | grep xunzhao_businessapi | awk '{print \$2}' | grep 'ppe\$' | grep ${datestr} | head -n 1",
                            returnStdout: true
                        ).trim()
                        echo "pversion=${pversion}"
                        flag = "-ppe"
                    } else if("${ref}" == 'refs/heads/master') {
                        pcount = sh(
                            script: "docker images  | grep xunzhao_businessapi | awk '{print \$2}' | grep -v 'ppe\$' | grep ${datestr} | wc -l",
                            returnStdout: true
                        ).trim()
                        echo 'pcount = ${pcount}'
                        pversion = sh(
                            script: "docker images  | grep xunzhao_businessapi | awk '{print \$2}' | grep -v 'ppe\$' | grep ${datestr} | head -n 1",
                            returnStdout: true
                        ).trim()
                        echo "pversion=${pversion}"
                    }

                    //只有是PPE或者master环境触发
                    if("${ref}"  == 'refs/heads/ppe' || "${ref}" == 'refs/heads/master') {
                        if(pcount == '0') {
                            echo "pcount equal zero"
                            version="v" + datestr + "01"
                        } else {
                            echo "pcount largeten zero"
                            def tag='v' + datestr
                            def sn = pversion.replace(tag,'').replace(flag,'')
                            def seq = Integer.parseInt(sn) + 1
                            if(seq < 10) {
                                version = "v" + datestr + "00" + seq.toString()
                            } else if(seq>=10 && seq<=99) {
                                version = "v" + datestr + "0" + seq.toString()
                            } else {
                                version = "v" + datestr + seq.toString()
                            }
                        }
                        echo "version=${version}"
                        build_tag = version


                        WechatAlerter([message:"Business Api start build,Version:${build_tag}"])
                    }

                }
            }
        }
        //用maven编译Java 的jar
        stage("Build Jar") {
            steps {
                echo "Build Jar"
                script  {
                    if ("${ref}"  == 'refs/heads/ppe') {
                        sh 'mvn install -Pppe'
                    } else if("${ref}"  == 'refs/heads/master') {
                        sh 'mvn install -Pproduction'
                    }
                }
            }
        }
      // 编译Dockerfile
        stage("Build Docker image") {
            steps {
                echo "Build Docker image"
                script {
                    if ("${ref}"  == 'refs/heads/ppe') {
                    // 因为dockerfile中用到这个register先登陆,然后根据版本号编译并推送
                        docker.withRegistry('http://registry.cn-shanghai.aliyuncs.com', 'xunzhao') {
                            def dockImage = docker.build "${docker_img_name}:${build_tag}-ppe"
                            dockImage.push()
                        }
                        // 发送企业微信
                        WechatAlerter([message:"Business Api PPE Build Success"])
                    } else if("${ref}"  == 'refs/heads/master') {
                        docker.withRegistry('http://registry.cn-shanghai.aliyuncs.com', 'xunzhao') {
                            def dockImage = docker.build "${docker_img_name}:${build_tag}"
                            dockImage.push()
                        }
                        WechatAlerter([message:"Business Api Production Build Success"])
                    }
                }
            }
        }
        // 用ssh执行 docker-compose部署
        stage("Deploy with docker-compose") {
            steps {
                echo "Deploy with docker-compose"
                script {
                    if("${ref}"  == 'refs/heads/ppe') {
                        withCredentials([usernamePassword(credentialsId: 'localppddev', passwordVariable: 'password', usernameVariable: 'userName')]) {
                            def remote = [:]
                            remote.name = "node-ppe"
                            remote.host = "mup007.5imakeup.com"
                            remote.port = 2222
                            remote.allowAnyHosts = true
                            remote.user = userName
                            remote.password = password
                            withCredentials([usernamePassword(usernameVariable:'dockerUserName',passwordVariable:'dockerPass',credentialsId:'xunzhao')]) {
                                sshCommand remote: remote,command: "docker login --username=${dockerUserName} registry.cn-shanghai.aliyuncs.com --password ${dockerPass}"
                                sshCommand remote: remote,command: "export businessapiversion=${build_tag}-ppe;docker-compose -f /workspace/xunzhao/docker-compose.yml rm -f -s xunzhao_businessapi && docker-compose -f /workspace/xunzhao/docker-compose.yml up -d xunzhao_businessapi"
                            }
                        }
                        WechatAlerter([message:"Business Api Production Deploy Success:${build_tag}"])
                    }
                }
            }
        }
    }
}

GenericTrigger 相关说明:
GenericTrigger可以从http://jenkis-url/generic-webhook-trigger/invoke 中获取各种scm来的webhook,通过解析JSON或者xml来实现那

    GenericTrigger (    
            genericVariables: [
                [key: 'ref', value: '$.ref'] //定义一个ref变量,就是gliblab的中相关字段
            ],
            causeString: ' Triggered on $branch' ,
            printContributedVariables: true,
            printPostContent: true,
            silentResponse: false,
            regexpFilterText: '$ref', // 通过正则表达式匹配
            regexpFilterExpression: 'refs/heads/' + BRANCH_NAME,
            token: 'tokn‘'  // gitlab中填写一个安全相关Token,自己生成一个,注意保密
        )
        

gitlab设置:

总结

至此完成一个比较完善的CI/CD流程,当然不包含自动化测试,下个这个系列文字,将会展现Jenkins pipeline关于nodejs编译和应用。

Lokie博客
请先登录后发表评论
  • 最新评论
  • 总共0条评论
  • 本博客使用免费开源的 laravel-bjyblog v5.5.1.1 搭建 © 2014-2018 lokie.wang 版权所有 ICP证:沪ICP备18016993号
  • 联系邮箱:kitche1985@hotmail.com