1. 程式人生 > >Jenkins 結合 Docker 為 .NET Core 專案實現低配版的 CI&CD

Jenkins 結合 Docker 為 .NET Core 專案實現低配版的 CI&CD

隨著專案的不斷增多,最開始單體專案手動執行 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 環境就搭建成功了。