随着项目的不断增多,最开始单体项目手动执行 docker build
命令,手动发布项目就再也不适用了。一两个项目可能还吃得消,10 多个项目天天让你构建一次仍是够呛。即使你的项目少,每次花费在发布上面的时间累计起来都够你改几个 BUG 了。html
因此咱们须要自动化这个流程,让项目的发布和测试再也不这么繁琐。在这里我使用了 Jenkins 做为基础的 CI/CD Pipeline 工具,关于 Jenkins 的具体介绍这里就再也不赘述。在版本管理、构建项目、单元测试、集成测试、环境部署我分别使用到了 Gogs、Docker、Docker Swarm(已与 Docker 整合) 这几个软件协同工做。java
如下步骤我参考了 Continuous Integration with Jenkins and Docker 一文,并使用了做者提供的 groovy 文件和 slave.py
文件。node
关于 Docker-CE 的安装,请参考个人另外一篇博文 《Linux 下的 Docker 安装与使用》 。python
既然都用了 Docker,我是不想在实体机上面安装一堆环境,因此我使用了 Docker 的形式来部署 Jenkins 的 Master 和 Slave,省时省力。Master 就是调度管道任务的主机,也是惟一有 UI 供用户操做的。而 Slave 就是具体的工做节点,用于执行具体的管道任务。linux
第一步,咱们在主机上创建一个 master 文件夹,并使用 vi
建立两个 groovy 文件,这两个文件在后面的 Dockerfile 会被使用到,下面是 default-user.groovy
文件的代码:git
import jenkins.model.* import hudson.security.* def env = System.getenv() def jenkins = Jenkins.getInstance() jenkins.setSecurityRealm(new HudsonPrivateSecurityRealm(false)) jenkins.setAuthorizationStrategy(new GlobalMatrixAuthorizationStrategy()) def user = jenkins.getSecurityRealm().createAccount(env.JENKINS_USER, env.JENKINS_PASS) user.save() jenkins.getAuthorizationStrategy().add(Jenkins.ADMINISTER, env.JENKINS_USER) jenkins.save()
接着再用 vi
建立一个新的 executors.groovy
文件,并输入如下内容:github
import jenkins.model.* Jenkins.instance.setNumExecutors(0)
以上动做完成以后,在 master 文件夹下面应该有两个 groovy 文件。web
两个 master 所须要的 groovy 文件已经编写完成,下面来编写 master 镜像的 Dockerfile 文件,每一步的做用我已经用中文进行了标注。正则表达式
# 使用官方的 Jenkins 镜像做为基础镜像。 FROM jenkins/jenkins:latest # 使用内置的 install-plugins.sh 来安装插件。 RUN /usr/local/bin/install-plugins.sh git matrix-auth workflow-aggregator docker-workflow blueocean credentials-binding # 设置 Jenkins 的管理员帐户和密码。 ENV JENKINS_USER admin ENV JENKINS_PASS admin # 跳过初始化安装向导。 ENV JAVA_OPTS -Djenkins.install.runSetupWizard=false # 将刚刚编写的两个 groovy 脚本复制到初始化文件夹内。 COPY executors.groovy /usr/share/jenkins/ref/init.groovy.d/ COPY default-user.groovy /usr/share/jenkins/ref/init.groovy.d/ # 挂载 jenkins_home 目录到 Docker 卷。 VOLUME /var/jenkins_home
接着咱们经过命令构建出 Master 镜像。docker
docker build -t jenkins-master .
Slave 镜像的核心是一个 slave.py
的 python 脚本,它主要执行的动做是运行 slave.jar
并和 Master 创建通讯,这样你的管道任务就可以交给 Slave 进行执行。这个脚本所作的工做流程以下:
咱们再创建一个 slave 文件夹,并使用 vi
将 python 脚本复制进去。
slave.py
的内容:
from jenkins import Jenkins, JenkinsError, NodeLaunchMethod import os import signal import sys import urllib import subprocess import shutil import requests import time slave_jar = '/var/lib/jenkins/slave.jar' slave_name = os.environ['SLAVE_NAME'] if os.environ['SLAVE_NAME'] != '' else 'docker-slave-' + os.environ['HOSTNAME'] jnlp_url = os.environ['JENKINS_URL'] + '/computer/' + slave_name + '/slave-agent.jnlp' slave_jar_url = os.environ['JENKINS_URL'] + '/jnlpJars/slave.jar' print(slave_jar_url) process = None def clean_dir(dir): for root, dirs, files in os.walk(dir): for f in files: os.unlink(os.path.join(root, f)) for d in dirs: shutil.rmtree(os.path.join(root, d)) def slave_create(node_name, working_dir, executors, labels): j = Jenkins(os.environ['JENKINS_URL'], os.environ['JENKINS_USER'], os.environ['JENKINS_PASS']) j.node_create(node_name, working_dir, num_executors = int(executors), labels = labels, launcher = NodeLaunchMethod.JNLP) def slave_delete(node_name): j = Jenkins(os.environ['JENKINS_URL'], os.environ['JENKINS_USER'], os.environ['JENKINS_PASS']) j.node_delete(node_name) def slave_download(target): if os.path.isfile(slave_jar): os.remove(slave_jar) loader = urllib.URLopener() loader.retrieve(os.environ['JENKINS_URL'] + '/jnlpJars/slave.jar', '/var/lib/jenkins/slave.jar') def slave_run(slave_jar, jnlp_url): params = [ 'java', '-jar', slave_jar, '-jnlpUrl', jnlp_url ] if os.environ['JENKINS_SLAVE_ADDRESS'] != '': params.extend([ '-connectTo', os.environ['JENKINS_SLAVE_ADDRESS' ] ]) if os.environ['SLAVE_SECRET'] == '': params.extend([ '-jnlpCredentials', os.environ['JENKINS_USER'] + ':' + os.environ['JENKINS_PASS'] ]) else: params.extend([ '-secret', os.environ['SLAVE_SECRET'] ]) return subprocess.Popen(params, stdout=subprocess.PIPE) def signal_handler(sig, frame): if process != None: process.send_signal(signal.SIGINT) signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) def master_ready(url): try: r = requests.head(url, verify=False, timeout=None) return r.status_code == requests.codes.ok except: return False while not master_ready(slave_jar_url): print("Master not ready yet, sleeping for 10sec!") time.sleep(10) slave_download(slave_jar) print 'Downloaded Jenkins slave jar.' if os.environ['SLAVE_WORING_DIR']: os.setcwd(os.environ['SLAVE_WORING_DIR']) if os.environ['CLEAN_WORKING_DIR'] == 'true': clean_dir(os.getcwd()) print "Cleaned up working directory." if os.environ['SLAVE_NAME'] == '': slave_create(slave_name, os.getcwd(), os.environ['SLAVE_EXECUTORS'], os.environ['SLAVE_LABELS']) print 'Created temporary Jenkins slave.' process = slave_run(slave_jar, jnlp_url) print 'Started Jenkins slave with name "' + slave_name + '" and labels [' + os.environ['SLAVE_LABELS'] + '].' process.wait() print 'Jenkins slave stopped.' if os.environ['SLAVE_NAME'] == '': slave_delete(slave_name) print 'Removed temporary Jenkins slave.'
上述脚本的工做基本与流程图的一致,由于 Jenkins 针对 Python 提供了 SDK ,因此原做者使用 Python 来编写的 “代理” 程序。不过 Jenkins 也有 RESTful API,你也可使用 .NET Core 编写相似的 “代理” 程序。
接着咱们来编写 Slave 镜像的 Dockerfile 文件,由于国内服务器访问 Ubuntu 的源很慢,常常由于超时致使构建失败,这里切换成了阿里云的源,其内容以下:
FROM ubuntu:16.04 # 安装 Docker CLI。 RUN sed -i s@/archive.ubuntu.com/@/mirrors.aliyun.com/@g /etc/apt/sources.list && apt-get clean RUN apt-get update --fix-missing && apt-get install -y apt-transport-https ca-certificates curl openjdk-8-jre python python-pip git # 使用阿里云的镜像源。 RUN curl -fsSL http://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg | apt-key add - RUN echo "deb [arch=amd64] http://mirrors.aliyun.com/docker-ce/linux/ubuntu xenial stable" > /etc/apt/sources.list.d/docker.list RUN apt-get update --fix-missing && apt-get install -y docker-ce --allow-unauthenticated RUN easy_install jenkins-webapi # 安装 Docker-Compose 工具。 RUN curl -L https://github.com/docker/compose/releases/download/1.21.2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose && chmod +x /usr/local/bin/docker-compose RUN mkdir -p /home/jenkins RUN mkdir -p /var/lib/jenkins # 将 slave.py 文件添加到容器。 ADD slave.py /var/lib/jenkins/slave.py WORKDIR /home/jenkins # 配置 Jenkins Master 的一些链接参数和 Slave 信息。 ENV JENKINS_URL "http://jenkins" ENV JENKINS_SLAVE_ADDRESS "" ENV JENKINS_USER "admin" ENV JENKINS_PASS "admin" ENV SLAVE_NAME "" ENV SLAVE_SECRET "" ENV SLAVE_EXECUTORS "1" ENV SLAVE_LABELS "docker" ENV SLAVE_WORING_DIR "" ENV CLEAN_WORKING_DIR "true" CMD [ "python", "-u", "/var/lib/jenkins/slave.py" ]
继续使用 docker build
构建 Slave 镜像:
docker build -t jenkins-slave .
这里的 Docker Compose 文件,我取名叫 docker-compose.jenkins.yaml
,主要工做是为了启动 Master 和 Slave 容器。
version: '3.1' services: jenkins: container_name: jenkins ports: - '8080:8080' - '50000:50000' image: jenkins-master jenkins-slave: container_name: jenkins-slave restart: always environment: - 'JENKINS_URL=http://jenkins:8080' image: jenkins-slave volumes: - /var/run/docker.sock:/var/run/docker.sock # 将宿主机的 Docker Daemon 挂载到容器内部。 - /home/jenkins:/home/jenkins # 将数据挂载出来,方便后续进行释放。 depends_on: - jenkins
执行 Docker Compose 以后,咱们经过 宿主机 IP:8080
就能够访问到 Jenkins 内部了,以下图。
咱们内部开发使用的 Git 仓库是使用 Gogs 进行搭建的,Gogs 官方提供了 Docker 镜像,那咱们能够直接编写一个 Docker Compose 快速部署 Gogs。
docker-compose.gogs.yaml
文件内容以下:
version: '3.1' services: gogs: image: gogs/gogs container_name: 'gogs' expose: - '3000:3000' expose: - 22 volumes: - /var/lib/docker/Persistence/Gogs:/data # 挂载数据卷。 restart: always
执行如下命令后,便可启动 Gogs 程序,访问 宿主机 IP:3000
按照配置说明安装 Gogs 便可,以后你就能够建立远程仓库了。
虽然大部分都推荐 Jenkins 的 Gogs Webhook 插件,不过这个插件好久不更新了,并且不支持 版本发布 事件。针对于该问题虽然官方有 PR #62,但一直没有合并,等到合并的时候都是猴年马月了。这里仍是建议使用 Generic Webhook Trigger ,用这个插件来触发 Jenkins 的管道任务。
首先找到 Jenkins 的插件中心,搜索 Generic Webhook Trigger 插件,并进行安装。
继续新建一个管道任务,取名叫作 TestProject,类型选择 Pipeline 。
首先配置项目的数据来源,选择 SCM,而且配置 Git 远程仓库的地址,若是是私有仓库则还须要设置用户名和密码。
流水线项目创建完成后,咱们就能够开始设置 Generic WebHook Trigger 的一些参数,以便让远程的 Gogs 可以触发构建任务。
咱们为 TestProject 建立一个 Token,这个 Token 是跟流水线任务绑定了,说白了就是流水线任务的一个标识。建议使用随机 Guid 做为 Token,否则其余人均可以随便触发你的流水线任务进行构建了。
接着来到刚刚咱们建好的仓库,找到 仓库设置->管理 Web 钩子->添加 Web 钩子->Gogs 。
由于触发构建不可能每次提交都触发,通常来讲都是建立了某个合并请求,或者发布新版本的时候就会触发流水线任务。所以这里你能够根据本身的状况来选择触发事件,这里我以合并请求为例,你能够在钩子设置页面点击 测试推送。这样就能够看到 Gogs 发送给 Jenkins 的 JSON 结构是怎样的,你就可以在 Jenkins 那边有条件的进行处理。
不过测试推送只可以针对普通的 push 事件进行测试,像 合并请求 或者 版本发布 这种事件只能本身模拟操做了。在这里我新建了一个用户,Fork 了另外一个账号创建的 TestProject 仓库。
在 Fork 的仓库里面,我新建了一个 Readme.md 文件,而后点击建立合并,这个时候你看 Gogs 的 WebHook 推送记录就有一条新的数据推送给 Jenkins,同时你也能够在 Jenkins 看到流水线任务被触发了。
经过上面的步骤,咱们已经将 Gogs 和 Jenkins 中的具体任务进行了绑定。不过还有一个比较尴尬的问题是,Gogs 的合并事件不只仅包括建立合并,它的原始描述是这样说的。
合并请求事件包括合并被开启、关闭、从新开启、编辑、指派、取消指派、更新标签、清除标签、设置里程碑、取消设置里程碑或代码同步。
若是咱们仅仅是依靠上面的配置,那么上述全部行为都会触发构建操做,这确定不是咱们想要的效果。还好 Generic Webhook 为咱们提供了变量获取,以及 Webhook 过滤。
咱们从 Gogs 发往 Jenkins 的请求中能够看到,在 JSON 内部包含了一个 action
字段,里面就是本次的操做标识。那么咱们就能够想到经过判断 action
字段是否等于 opened
来触发流水线任务。
首先,咱们增长 2 个 Post content parameters 参数,分别获取到 Gogs 传递过来的 action
和 PR 的 Id,这里我解释一下几个文本框的意思。
除了这两个 Post 参数之外,在请求头中,Gogs 还携带了具体事件,咱们将其一块儿做为过滤条件。须要注意的是,针对于请求头的参数,在转换成变量时,插件会将字符转为小写,并会使用 "_" 代替 "-"。
最后咱们编写一个 Optional filter ,它的 Expression 参数是正则表达式,下面的 Text 便是源字符串。实现很简单,当 Text 里面的内容知足正则表达式的时候,就会触发流水线任务。
因此咱们的 Text 字符串就是由上面三个变量的值组成,而后和咱们预期的值进行匹配便可。
固然,你还想整一些更加炫酷的功能,可使用 Jenkins 提供的 Http Request 之类的插件。由于 Gogs 提供了 API 接口,你就能够在构建完成以后,回写给 Gogs,用于提示构建结果。
这样的话,这种功能就有点像 Github 上面的机器人账号了。
在上一节咱们经过 Jenkins 的插件完成了远程仓库推送通知,当咱们合并代码时,Jenkins 会自动触发执行咱们的管道任务。接下来我将创建一个 .NET Core 项目,该项目拥有一个 Controller,接收到请求以后输出 “Hello World”。随后为该项目创建一个 xUnit 的测试项目,用于执行单元测试。
整个项目的结构以下图:
咱们须要编写一个 UnitTest.Dockerfile
镜像,用于执行 xUnit 单元测试。
FROM mcr.microsoft.com/dotnet/core/sdk:2.2 # 还原 NuGet 包。 WORKDIR /home/app COPY ./ ./ RUN dotnet restore ENTRYPOINT ["dotnet", "test" , "--verbosity=normal"]
以后为部署操做编写一个 Deploy.Dockerfile
,这个 Dockerfile 首先还原了 NuGet 包,而后经过 dotnet publish
命令发布了咱们的网站。
FROM mcr.microsoft.com/dotnet/core/sdk:2.2 as build-image # 还原 NuGet 包。 WORKDIR /home/app COPY ./ ./ RUN dotnet restore # 发布镜像。 COPY ./ ./ RUN dotnet publish ./TestProject.WebApi/TestProject.WebApi.csproj -o /publish/ FROM mcr.microsoft.com/dotnet/core/aspnet:2.2 WORKDIR /publish COPY --from=build-image /publish . ENTRYPOINT ["dotnet", "TestProject.WebApi.dll"]
两个 Dockerfile 编写完成以后,将其存放在项目的根目录,以便 Slave 进行构建。
Dockerfile 编写好了,那么咱们还要分别为两个镜像编写 Docker Compose 文件,用于执行单元测试和部署行为,用于部署的文件名称叫作 docker-compose.Deploy.yaml
,内容以下:
version: '3.1' services: backend: container_name: dev-test-backend image: dev-test:B${BUILD_NUMBER} ports: - '5000:5000' restart: always
而后咱们须要编写运行单元测试的 Docker Compose 文件,名字叫作 docker-compose.UnitTest.yaml
,内容以下:
version: '3.1' services: backend: container_name: dev-test-unit-test image: dev-test:TEST${BUILD_NUMBER}
node('docker') { stage '签出代码' checkout scm stage '单元测试' sh "docker build -t dev-test:TEST${BUILD_NUMBER} -f UnitTest.Dockerfile ." sh "docker-compose -f docker-compose.UnitTest.yaml up --force-recreate --abort-on-container-exit" sh "docker-compose -f docker-compose.UnitTest.yaml down -v" stage '部署项目' sh "docker build -t dev-test:B${BUILD_NUMBER} -f Deploy.Dockerfile ." sh 'docker-compose -f docker-compose.Deploy.yaml up -d' }
上述操做完成以后,将这些文件放在项目根目录。
回到 Jenkins,你能够手动执行一下任务,而后项目就被成功执行了。
至此,咱们的 “低配版” CI、CD 环境就搭建成功了。