基于 Jenkins 实现 SpringBoot 项目自动化构建与部署

4,516 阅读8分钟

目的

搭建这个项目的目的在于,自己的项目若想每次发布到线上,每次都需要经过一下这样一段流程化操作。

打包项目->上传到服务器->构建镜像->运行容器。

因此期望通过 Jenkins 这样的平台,实现代码一旦 push 后,自动完成以上的流程。 搭建期间遇到了许多问题,花了两天时间搭建,希望能帮助一些小伙伴避雷。

本文作为本人学习所记录,仅供参考。

项目地址: github.com/ylhao666/si…

实现过程

开发环境

软件版本备注
Centos8.2.2004腾讯云服务器
Docker20.10.12运行在云服务器上
Jenkinslatestjenkinsci/blueocean 官方镜像
SpringBoot2.6.3实现简单的 Web 应用

基于 Docker 搭建 Jenkins

搭建 Jenkins 的方式有多种,具体可以查看 官方文档,本次使用容器运行,将 Jenkins 部署在 Docker 中,运行语句如下:

docker run \
  -dp 8080:8080 \
  -v jenkins-data:/var/jenkins_home \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v "$HOME":/home \
  --restart=always
  jenkinsci/blueocean

大概解释一下各个参数的意思

  1. -dp 映射宿主机 8080 端口到 Jenkins 8080 端口上,用于访问 Jenkins 主页,同时将 Jenkins 运行在后台
  2. -v jenkins-data:/var/jenkins_home 映射卷到 /var/jenkins_home 路径,保存了 Jenkins 的基本信息,此项确保重新运行容器后,数据不会被清空,建议映射
  3. -v /var/run/docker.sock:/var/run/docker.sock 映射 docker.sock 文件,这样执行 docker 命令时,响应的是宿主机,具体可以查看此文章此操作是实现本次部署的关键
  4. -v "$HOME":/home 映射宿主机 home 目录到 Jenkins home
  5. --restart=always Docker 重启时,跟随重启

运行成功后,访问 http://your_ip:8080,跟随指引完成 Jenkins 的安装

注意:安装插件时,选择安装推荐的插件

创建项目

编写 SpringBoot 项目

接下来,基于 SpringBoot 搭建一个简易的 Web 应用

引入依赖

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>

写一个 HelloController,提供一个 /hello 的接口,用于测试

@RestController
public class HelloController {

    @GetMapping("hello")
    public String hello() {
        return "Hello World";
    }

}

指定默认运行端口,因为 8080 已经被 Jenkins 使用了,换成其他端口

server:
  port: 9092

指定构建参数,指定生成的 Jar 包名称,引入 SpringBoot maven 插件,用于构建 Jar 包

<build>
  <finalName>My-App</finalName>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
  </plugins>
</build>

在本地测试,运行成功,访问 127.0.0.1:9092/hello,返回 Hello World

把项目托管到 Github 上,用于被 Jenkins 拉取

github.com/ylhao666/si…

Jenkins 创建项目
  1. 打开 Jenkins 页面,新建任务

image.png

  1. 任务名写项目名,勾选流水线

image.png

  1. 选择拉取代码的方式,运行时从 Git 代码库拉取代码

image.png

注意:需要添加凭证,以便于拉取代码,设置 ssh 方式或者 username & password 方式都可以

image.png

  1. 指定构建分支,已经默认的 Jenkinsfile 名称

image.png

新建项目的初始工作就完成了,接下来开始编写 Jenkinsfile,定义流水线逻辑

编写 Jenkinsfile

使用 Jenkins 的关键在于 Jenkinsfile 的编写,需要了解一点 shell

实现 Jenkinsfile 自动补全

IDEA 似乎不支持 Jenkinsfile 的语法提醒,需要手动配置一下,配置完写起来方便一点

  1. 获取 gdsl 定义文件,首先访问 http://{{your_ip}}:8080/job/{{your_project_name}}/pipeline-syntax/gdsl 获取 gdsl 文件,复制下来,到项目的 src.main.java 目录下,新建 pipeline.gdsl 文件,粘贴复制的内容。

image.png

  1. 设置 IDEA ,令其识别 Jenkinsfile 文件支持 Groovy

image.png

  1. 在项目根目录,新建 Jenkinsfile 文件,尝试发现已经可以自动补全

image.png

注意:从 Jenkins 获取的 gdsl 文件可能存在一些未自动补全的字段

可以从 git 地址获取完整的 pileline.gdsl 文件

编写 Jenkinsfile

本次实现的流水线,首先进行 Jar 包的构建,并将其打包成 Docker 镜像,然后将镜像运行在宿主机的 Docker 容器上

完整的 Jenkinsfile 可以在附录查看

编写构建阶段 Build Stage

  1. 首先定义一个环境变量 PACKAGE_NAME,用于 Maven 进行打包时使用
environment {
    PACKAGE_NAME = 'My-App'
}
  1. 定义 Build Stage,将构建过程运行在 Maven 容器上
// 构建阶段
stage('Build') {
    agent {
        docker {
            image 'maven:3.6.3-slim'
            // 挂载在宿主机上,复用依赖文件
            args '-v /root/.m2:/root/.m2'
        }
    }
}

由于我们的 Jenkins 是运行于宿主机的 Docker 上的,并且在运行时指定了 docker.sock 文件的映射,因此构建阶段运行的 Maven 容器是运行在宿主机上的,相当于在宿主机上运行 docker run -v /root/.m2:/root/.m2 maven:3.6.3-slim,同时映射宿主机的本地 Maven 仓库,以便于复用依赖。

  1. 指定运行时步骤 steps,定义 build.sh 脚本,把所有指令放在脚本里执行
steps {
    sh 'sh ./jenkins/scripts/build.sh'
    // 暂存 Jar 包,避免不同 agent 下取不到文件
    stash includes: '**/target/*.jar', name: 'jar'
}

注意:因为流水线上不同的 stage 可以指定不同的 agent,以便运行在不同的环境。所以不同的 agent 下,数据是不共享的。因此 Build Stage 下构建完成的 Jar 包,可以通过 stash 命令进行暂存,后续在 Deploy Stage,可以通过 unstash 获取。

  1. 编写 build.sh 脚本
# 构建 Jar 包,跳过测试
mvn -B -DskipTests clean package

build.sh 进行的工作很简单,只是对项目进行打包。

编写测试阶段 Test Stage

  1. 编写 Test Stage
// 单元测试
stage('Test') {
    steps {
        sh 'sh ./jenkins/scripts/test.sh'
    }
}

Build Stage 一样,将命令抽离到脚本执行

  1. 编写 test.sh 脚本
# test
echo "Test"

在这里可以对代码做一些测试,Jenkins 也支持对测试的结果进行展示,具体可以查看官方文档

编写部署阶段 Deploy Stage

  1. 定义新的环境变量
environment {
    IMAGE_NAME = 'my-app'
    IMAGE_VERSION = '1.0.0'
    SERVER_PORT = '7072'
    APP_NAME = 'My-App'
    APP_VERSION = '1.0.0'
}

用于指定镜像名,镜像版本,服务运行的端口,应用名称,应用版本

  1. 定义 Deploy Stage
// 部署容器
stage('Deploy') {
    steps {
        // 获取 Build Stage 构建的 Jar 包
        unstash 'jar'
        sh 'sh ./jenkins/scripts/deploy.sh'
    }
    post {
        failure {
            echo "部署失败"
        }
    }
}

定义 steps,首先从暂存中获取 Build Stage 阶段构建的 Jar 包,然后运行 deploy.sh 脚本。 post 可以根据不同的运行结果进行不同的响应,这里如果部署失败的话,打印 部署失败,可以使用 email 进行告警,具体可以查看清理和通知

post {
    failure {
        mail to: 'team@example.com',
             subject: "Failed Pipeline: ${currentBuild.fullDisplayName}",
             body: "Something is wrong with ${env.BUILD_URL}"
    }
}
  1. 定义 Dockerfile
FROM openjdk:8-jre-slim
ARG PACKAGE_NAME
WORKDIR /app
COPY ${PACKAGE_NAME}.jar ./${PACKAGE_NAME}.jar
RUN echo "java -jar ${PACKAGE_NAME}.jar \${@}" > ./entrypoint.sh 
    && chmod +x ./entrypoint.sh
ENTRYPOINT ["sh", "entrypoint.sh"]

在项目目录下,创建 docker/Dockerfile 文件,用于构建镜像。

大概解释一下 Dockerfile 的内容

  • 根据 openjdk:8-jre-slim 镜像进行构建
  • 定义构建参数 PACKAGE_NAME,用于构建时传递 Jar 包名称
  • 指定工作目录为 /app
  • 复制 Jar 包到工作目录下
  • 定义运行脚本 entrypoint.sh,赋予运行权限。${@} 用于运行时接受从命令传递的参数。本来期望直接使用 ENTRYPOINT 命令,尝试后似乎无法同时接受 ARG 已经运行时参数,因此只能使用脚本,具体可以查看此提问
  • 执行脚本
  1. 编写 deploy.sh 脚本
# 复制 Jar 包到 docker 目录
cp "target/${PACKAGE_NAME}.jar" "docker/${PACKAGE_NAME}.jar"

# 构建镜像
docker build -t "${IMAGE_NAME}:${IMAGE_VERSION}" --build-arg PACKAGE_NAME="${PACKAGE_NAME}" docker

# run container
# 删除旧容器
containerId=$(docker ps -f name="${APP_NAME}-${APP_VERSION}" -aq)
if [ "${containerId}" != "" ]; then
    docker rm -f "${containerId}"
fi

# 运行新容器
docker run --restart=always -dp "${SERVER_PORT}:${SERVER_PORT}" --name "${APP_NAME}-${APP_VERSION}" "${IMAGE_NAME}:${IMAGE_VERSION}" --server.port="${SERVER_PORT}"

# 判断容器运行情况,未运行则抛出异常
docker ps -f name="${APP_NAME}-${APP_VERSION}"
containerId=$(docker ps -f name="${APP_NAME}-${APP_VERSION}" -q)

if [ "${containerId}" = "" ]; then
    exit 42
fi
  • 首先,复制从 Build Stage 构建的 Jar 包到 docker 目录下
  • 开始构建镜像,镜像名从环境变量中获取,同时传递构建参数 PACKAGE_NAME,指定上下文为 docker 目录
  • 根据容器名称获取 Docker 中运行的旧容器id,删除旧容器
  • 运行新容器,${SERVER_PORT}:${SERVER_PORT} 映射运行端口,"${APP_NAME}-${APP_VERSION}" 指定容器名称,--server.port 传递运行时参数,指定运行端口
  • 判断容器运行情况,未运行则抛出异常,终止流水线进行异常告警

Jenkinsfile 到这里就编写完了,接下来可以尝试运行流水线。

所有完整的 sh 文件均可以在附录中获取

运行流水线

首先提交代码,然后访问 Jenkins 主页,点击刚刚创建的项目

image.png

点击打开 BlueOcean

image.png

点击 Run,开始运行流水线

image.png

可以发现运行成功,同时可以查看每个 steps 打印的内容

image.png

绿了绿了芜湖

访问 {{your_ip}}:7072/hello,返回 Hello World,可以发现已经成功部署到服务器上

设置 Git Webhooks 实现自动化部署

上面已经实现了手动部署,提交完代码后,点击一下就可以自动构建,部署。下面尝试配置自动化部署,这里使用到了 GitWebhooks

  1. 配置 JenkinsJenkis 的配置中,配置 Github 服务器。名称随便填写,API URL 默认即可,关键是凭证,需要在 Github 中进行申请 access_token,可以点击这里进行申请。按照官方的要求,至少申请如下的权限:
  • admin:repo_hook - for managing hooks (read, write and delete old ones)
  • repo - to see private repos
  • repo:status - to manipulate commit statuses

把生成得到的 access_token 填写到凭证里

image.png

可以点击连接测试,检查是否配置成功

image.png

  1. 设置 Webhooks 打开你的 git 项目地址,选择 Settings - Webhooks,大概 url 如下 github.com/{{your_account}}/{{your_project_name}}/settings/hooks

image.png

点击 Add webhook 新增

image.png

Payload URL 填写 http://{{your_jenkins_url}}/github-webhook/,同时选择触发事件,当 push 后进行触发,生成后,可以点击尝试触发,查看 Jenkins 日志,是否有收到触发事件。

image.png

  1. 配置项目触发器

打开任务,查看构建触发器,勾选 GitHub hook trigger for GITScm polling,保存

image.png

  1. 验证

修改代码,push 后,到任务主页,点击 Github Hook Log,刷新,可以看到 Push 记录

image.png

查看 Blue Ocean,可以看到流水线已经在运行中,这样就自动化部署就配置好了。

最终效果

最终,我们实现了,代码从 push 到构建,到运行的全过程。

真不错,终于不用手动进行部署了

如果本文对你有所帮助,就请点个 👍 ,点个关注吧。

附录

Jenkinsfile

pipeline {
    agent any
    environment {
        APP_NAME = 'My-App'
        APP_VERSION = '1.0.0'
        PACKAGE_NAME = 'My-App'
    }
    stages {
        // 构建 jar
        stage('Build') {
            agent {
                docker {
                    image 'maven:3.6.3-slim'
                    // 挂载在宿主机上,复用依赖文件
                    args '-v /root/.m2:/root/.m2'
                }
            }
            steps {
                sh 'sh ./jenkins/scripts/build.sh'
                // 暂存 Jar 包,避免不同 agent 下取不到文件
                stash includes: '**/target/*.jar', name: 'jar'
            }
        }

        // 单元测试
        stage('Test') {
            steps {
                sh 'sh ./jenkins/scripts/test.sh'
            }
        }

        // 部署容器
        stage('Deploy') {
            environment {
                IMAGE_NAME = 'my-app'
                IMAGE_VERSION = '1.0.0'
                SERVER_PORT = '7072'
            }
            steps {
                unstash 'jar'
                sh 'sh ./jenkins/scripts/deploy.sh'
            }
            post {
                failure {
                    echo "部署失败"
                }
            }
        }
    }
    //    全局post
    post {
        always {
            echo "Always"
        }
        success {
            echo "Success"
        }
        failure {
            echo "Failure"
        }
    }
}

deploy.sh

# 校验 Jar 包是否存在
if ! test -f "target/${PACKAGE_NAME}.jar"; then
    echo "${PACKAGE_NAME}.jar 不存在"
    exit 43
fi

echo "复制 Jar 包到 Docker 文件夹"
cp "target/${PACKAGE_NAME}.jar" "docker/${PACKAGE_NAME}.jar"

# 构建镜像
echo "开始构建镜像"
docker build -t "${IMAGE_NAME}:${IMAGE_VERSION}" --build-arg PACKAGE_NAME="${PACKAGE_NAME}" docker

# run container
# 删除旧容器
containerId=$(docker ps -f name="${APP_NAME}-${APP_VERSION}" -aq)
if [ "${containerId}" != "" ]; then
    echo "删除旧容器 ${containerId}"
    docker rm -f "${containerId}"
fi

# 运行新容器
echo "运行新容器, ContainerName: ${APP_NAME}-${APP_VERSION}"
docker run --restart=always -dp "${SERVER_PORT}:${SERVER_PORT}" --name "${APP_NAME}-${APP_VERSION}" "${IMAGE_NAME}:${IMAGE_VERSION}" --server.port="${SERVER_PORT}"

# 判断容器运行情况,未运行则抛出异常
echo "容器运行情况:"
docker ps -f name="${APP_NAME}-${APP_VERSION}"
containerId=$(docker ps -f name="${APP_NAME}-${APP_VERSION}" -q)

if [ "${containerId}" = "" ]; then
    echo "容器未运行"
    exit 42
fi