Jenkins 結合 Docker 爲 .NET Core 項目實現低配版的 CI&CD

隨着項目的不斷增多,最開始單體項目手動執行 docker build 命令,手動發佈項目就再也不適用了。一兩個項目可能還吃得消,10 多個項目天天讓你構建一次仍是夠嗆。即使你的項目少,每次花費在發佈上面的時間累計起來都夠你改幾個 BUG 了。html

因此咱們須要自動化這個流程,讓項目的發佈和測試再也不這麼繁瑣。在這裏我使用了 Jenkins 做爲基礎的 CI/CD Pipeline 工具,關於 Jenkins 的具體介紹這裏就再也不贅述。在版本管理、構建項目、單元測試、集成測試、環境部署我分別使用到了 GogsDockerDocker Swarm(已與 Docker 整合) 這幾個軟件協同工做。java

如下步驟我參考了 Continuous Integration with Jenkins and Docker 一文,並使用了做者提供的 groovy 文件和 slave.py 文件。node

關於 Docker-CE 的安裝,請參考個人另外一篇博文 《Linux 下的 Docker 安裝與使用》python

1、Jenkins 的部署

既然都用了 Docker,我是不想在實體機上面安裝一堆環境,因此我使用了 Docker 的形式來部署 Jenkins 的 Master 和 Slave,省時省力。Master 就是調度管道任務的主機,也是惟一有 UI 供用戶操做的。而 Slave 就是具體的工做節點,用於執行具體的管道任務。linux

1.1 構建 Master 鏡像

第一步,咱們在主機上創建一個 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 .

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 內部了,以下圖。

2、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 便可,以後你就能夠建立遠程倉庫了。

3、Gogs 與 Jenkins 的集成

雖然大部分都推薦 Jenkins 的 Gogs Webhook 插件,不過這個插件好久不更新了,並且不支持 版本發佈 事件。針對於該問題雖然官方有 PR #62,但一直沒有合併,等到合併的時候都是猴年馬月了。這裏仍是建議使用 Generic Webhook Trigger ,用這個插件來觸發 Jenkins 的管道任務。

3.1 建立流水線項目

首先找到 Jenkins 的插件中心,搜索 Generic Webhook Trigger 插件,並進行安裝。

20190924210101.gif

繼續新建一個管道任務,取名叫作 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 上面的機器人賬號了。

4、完整的項目示例

在上一節咱們經過 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}

5、編寫 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'
}

6、最後的效果

上述操做完成以後,將這些文件放在項目根目錄。

回到 Jenkins,你能夠手動執行一下任務,而後項目就被成功執行了。

至此,咱們的 「低配版」 CI、CD 環境就搭建成功了。

相關文章
相關標籤/搜索