隨著項目的不斷增多,最開始單體項目手動執行 命令,手動發佈項目就不再適用了。一兩個項目可能還吃得消,10 多個項目每天讓你構建一次還是夠嗆。即便你的項目少,每次花費在發佈上面的時間累計起來都夠你改幾個 BUG 了。 所以我們需要自動化這個流程,讓項目的發佈和測試不再這麼繁瑣。在這裡我使用了 Jenk ...
隨著項目的不斷增多,最開始單體項目手動執行 docker build
命令,手動發佈項目就不再適用了。一兩個項目可能還吃得消,10 多個項目每天讓你構建一次還是夠嗆。即便你的項目少,每次花費在發佈上面的時間累計起來都夠你改幾個 BUG 了。
所以我們需要自動化這個流程,讓項目的發佈和測試不再這麼繁瑣。在這裡我使用了 Jenkins 作為基礎的 CI/CD Pipeline 工具,關於 Jenkins 的具體介紹這裡就不再贅述。在版本管理、構建項目、單元測試、集成測試、環境部署我分別使用到了 Gogs、Docker、Docker Swarm(已與 Docker 整合) 這幾個軟體協同工作。
以下步驟我參考了 Continuous Integration with Jenkins and Docker 一文,並使用了作者提供的 groovy 文件和 slave.py
文件。
關於 Docker-CE 的安裝,請參考我的另一篇博文 《Linux 下的 Docker 安裝與使用》 。
一、Jenkins 的部署
既然都用了 Docker,我是不想在實體機上面安裝一堆環境,所以我使用了 Docker 的形式來部署 Jenkins 的 Master 和 Slave,省時省力。Master 就是調度管道任務的主機,也是唯一有 UI 供用戶操作的。而 Slave 就是具體的工作節點,用於執行具體的管道任務。
1.1 構建 Master 鏡像
第一步,我們在主機上建立一個 master 文件夾,並使用 vi
創建兩個 groovy 文件,這兩個文件在後面的 Dockerfile 會被使用到,下麵是 default-user.groovy
文件的代碼:
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
文件,並輸入以下內容:
import jenkins.model.*
Jenkins.instance.setNumExecutors(0)
以上動作完成之後,在 master 文件夾下麵應該有兩個 groovy 文件。
兩個 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 build -t jenkins-master .
1.2 構建 Slave 鏡像
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 .
1.3 編寫 Docker Compose 文件
這裡的 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 內部了,如下圖。
二、Gogs 的部署
我們內部開發使用的 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 即可,之後你就可以創建遠程倉庫了。
三、Gogs 與 Jenkins 的集成
雖然大部分都推薦 Jenkins 的 Gogs Webhook 插件,不過這個插件很久不更新了,而且不支持 版本發佈 事件。針對於該問題雖然官方有 PR #62,但一直沒有合併,等到合併的時候都是猴年馬月了。這裡還是建議使用 Generic Webhook Trigger ,用這個插件來觸發 Jenkins 的管道任務。
3.1 創建流水線項目
首先找到 Jenkins 的插件中心,搜索 Generic Webhook Trigger 插件,併進行安裝。
繼續新建一個管道任務,取名叫做 TestProject,類型選擇 Pipeline 。
首先配置項目的數據來源,選擇 SCM,並且配置 Git 遠程倉庫的地址,如果是私有倉庫則還需要設置用戶名和密碼。
3.2 Jenkins 的 Webhook 配置
流水線項目建立完成後,我們就可以開始設置 Generic WebHook Trigger 的一些參數,以便讓遠程的 Gogs 能夠觸發構建任務。
我們為 TestProject 創建一個 Token,這個 Token 是跟流水線任務綁定了,說白了就是流水線任務的一個標識。建議使用隨機 Guid 作為 Token,不然其他人都可以隨便觸發你的流水線任務進行構建了。
3.3 Gogs 的 Webhook 配置
接著來到剛剛我們建好的倉庫,找到 倉庫設置->管理 Web 鉤子->添加 Web 鉤子->Gogs 。
因為觸發構建不可能每次提交都觸發,一般來說都是創建了某個合併請求,或者發佈新版本的時候就會觸發流水線任務。因此這裡你可以根據自己的情況來選擇觸發事件,這裡我以合併請求為例,你可以在鉤子設置頁面點擊 測試推送。這樣就可以看到 Gogs 發送給 Jenkins 的 JSON 結構是怎樣的,你就能夠在 Jenkins 那邊有條件的進行處理。
不過測試推送只能夠針對普通的 push 事件進行測試,像 合併請求 或者 版本發佈 這種事件只能自己模擬操作了。在這裡我新建了一個用戶,Fork 了另一個帳號建立的 TestProject 倉庫。
在 Fork 的倉庫裡面,我新建了一個 Readme.md 文件,然後點擊創建合併,這個時候你看 Gogs 的 WebHook 推送記錄就有一條新的數據推送給 Jenkins,同時你也可以在 Jenkins 看到流水線任務被觸發了。
3.4 限定任務觸發條件
通過上面的步驟,我們已經將 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}
五、編寫 Jenkinsfile
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 環境就搭建成功了。