背景:
1.當前CI/CD是企業級運維發布體系的核心組成部分。特別是當前微服務化理念越來越重,服務拆分的情況越來越多,會有很多的業務程序需要部署,發布,迭代至生產環境。這對運維人員,開發人員的維護是及其困難的。
2.jenkins的出現允許開發人員對需要服務進行持續的CI/CD操作 CI 持續集成 CD 持續部署。 但是,網絡上大部分的文章都是針對java的jenkins流水線自動化部署 。 .net core下寥寥無幾 ,因此筆者開立博客,算是為.net生態貢獻一下自己的力量!
整個CI/CD 單機的流程如下: 筆者先演示單機實現CI/CD
環境准備如下:
docker-ce 最新版本(筆者推薦使用docker高版本 因為docker低版本有一些特殊的bug 有興趣的童鞋可以看看docker的官方說明)
linux CentOS 8.4 64位 2台 服務器 (筆者需要演示 微服務 分布式架構下的發布 及平滑下線 因此需要至少2台服務器. 一台足夠我們搭建jenkins的CI/CD環境)
git倉庫 (筆者此篇使用git方式做代碼倉庫 此方式配置連接就行 git源無所謂 碼雲 coding github gitlab 都行 服務器能訪問到就行)
jenkins 客戶端 只需要其中一台安裝jenkins就行了 就像實際生產上jenkins的客戶端也需要一台服務器就足夠, 當然如果是多節點的jenkins多終端發布 那么也只需要配置一下即可
正片開始:
如何在linux安裝docker-ce 這里就不多說了 重點說明Jenkins相關的幾個點
1.開始在docker中安裝jenkins,執行命令如下:

此時我們已看到 jenkins 已經初始化了
特別說明:至於jenkins初始化配置 這里筆者不做過多說明 那些網上都有 這里筆者直接跳過
2.jenkins中執行docker命令
前面有提到過 我們的jenkins跟.net的應用程序 都是docker跑 因此jenkins必須要有能執行docker命令的權限
所以 cd 到上一個步驟中 docker的掛載目錄下 執行命令 chown -R 1000:1000 /data/jenkins 給掛載目錄賦予讀寫權限
同時因為jenkins是在docker中運行的 因此需要在docker容器 也就是jenkins中 執行docker命令
當然想在docker容器中運行docker命令的方式有很多 官方有一種 docker in docker的方式有興趣的童靴可以嘗試一下 或者更簡單的方式 docker run 容器的時候 帶上指定賬戶root 比如 docker run -u root 的方式擁有權限
但是 筆者不推薦這樣做 因為root的權限實在太大了 如果讓jenkins擁有這個權限 是十分危險的一件事情
這里筆者推薦一種賦予權限的方式 就是 chmod 777 docker.sock 命令 其實 docker本身具有掛載相應的目錄,文件到容器之中的能力,我們直接將docker命令以及docker daemon的socket文件掛載進運行容器之中即可運行docker命令
因此 cd到宿主機對應目錄下執行 chmod 777 docker.sock 便會允許docker容器中執行docker命令 但是 本身docker.sock 是在docker重啟之后才生成的 所以這種方案有個缺陷就是每次重啟 都需要重新執行命令 解決辦法當然是有的 就是add一個docker的用戶 添加到docker組中 然后以此用戶重新run一次 jenkins鏡像 就可以一勞永逸了!!!
3.創建流水線項目 已發布.net core
3.1 筆者新建一個流水線項目 如圖所示:
3.2 我們安裝流程需要的jenkins插件可能會有點多.請逐個安裝確認 插件列表如下:
Build With Parameters 輸入框式的參數(使用參數化構建需要用到此選項)
Persistent Parameter 下拉框格式參數(使用參數化構建需要用到此選項)
http request http請求插件 用於后期 實現服務注冊 服務發現 平滑下線使用
Config File Provider 用於統一化構建配置文件使用
Git Parameter git 參數插件
docker 相關插件 用於 構建docker鏡像 登錄docker倉庫 push docker鏡像的時候使用
3.3 然后 到項目中配置具體參數 這個根據各位童靴實際的業務場景來
比如我這邊參數 有 端口號 git倉庫地址 docker倉庫地址 統一的密碼 健康檢查url 等參數
3.4 推薦新建2個配置文件 一個是dockefile 因為作為運維當然希望dockerfile配置文件的統一規范化 因此把dockerfile 統一配置存放在jenkins中 如圖所示
完成的配置文件如下: 遠端的.net5.0運行時 跟SDK 筆者用XXX來替代了。 通常在dockerhub總會有很多的鏡像用來打包 推薦各位童靴尋找合適的包然后push到自己私有倉庫 dockerfile中用私有倉庫的地址進行打包操作
FROM XXXXXXX AS base
WORKDIR /app
EXPOSE #PORT
FROM XXXXX AS build
WORKDIR /src
COPY ["#MODULE/#MODULE.csproj", "#MODULE/"]
RUN dotnet restore "#MODULE/#MODULE.csproj"
COPY . .
WORKDIR "/src/#MODULE"
RUN dotnet build "#MODULE.csproj" --configuration Release -o /app/build
FROM build AS publish
RUN dotnet publish "#MODULE.csproj" --configuration Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "#MODULE.dll"]
同理 新建一個這樣的配置
#!/bin/bash
JOB_NAME=$1
PORT=$2
dockerImageName=$3
HOST_IP=$4
ENV=$5
GRAY_VERSION=$6
# 獲取容器的ID
containerID=`docker -H ${HOST_IP}:2375 ps -a |grep -w ${JOB_NAME} | awk '{print $1}'`
echo "${containerID}"
# 獲取容器的imageID
imageID=`docker -H ${HOST_IP}:2375 images |grep -w ${JOB_NAME}| awk '{print $3}'`
echo "${imageID}"
if [ "${containerID}" != "" ] ; then
#刪除容器
docker -H ${HOST_IP}:2375 ps -a |grep -w ${JOB_NAME} | awk '{print $1}'| xargs docker -H ${HOST_IP}:2375 rm -f
echo "成功刪除容器"
fi
# 刪除本地鏡像
if [ "${imageID}" != "" ] ; then
#刪除鏡像
docker -H ${HOST_IP}:2375 images |grep -w ${JOB_NAME}| awk '{print $3}'| xargs docker -H ${HOST_IP}:2375 rmi -f
echo "成功刪除鏡像"
fi
# ENV=${ENV^}
#二次登錄dcoker倉庫
docker -H ${HOST_IP}:2375 login -u XXXXX-p XXXXX registry.cn-hangzhou.aliyuncs.com
echo ${ENV}
echo ${GRAY_VERSION}
# 運行鏡像
docker -H ${HOST_IP}:2375 run -d \
-m 1G \
--memory-swap=1G \
-e ASPNETCORE_ENVIRONMENT=${ENV} \
-e ASPNETCORE_HOSTINGSTARTUPASSEMBLIES=SkyAPM.Agent.AspNetCore \
-e grayscale_version=${GRAY_VERSION} \
--restart=always \
--net=host \
--name ${JOB_NAME} \
-p ${PORT}:${PORT} \
${dockerImageName}
docker -H ${HOST_IP}:2375 ps
# 應用健康檢查
containerID=`docker -H ${HOST_IP}:2375 ps -a |grep -w ${JOB_NAME} | awk '{print $1}'`
echo $containerID
if [ $? = 0 ] ; then
echo "**應用${JOB_NAME}已經運行**"
else
echo "**應用${JOB_NAME}啟動失敗**"
fi
上面配置的意思 就是傳入多個參數 用來去判斷容器是否存在 如果存在 停止並刪除容器 然后刪除本地對應鏡像 然后通過docker 2375的端口去執行docekr run的命令 最后去監聽對應端口是否有掛載服務 如果有 輸出 應用XXX 已經運行
特別說明: docker 具有開放端口允許外部調用的方式. 比如開啟2375端口 可以允許遠端來調用當前ip下的docker 執行對應的docker命令 實現jenkins遠端發布的場景。 但是 因為筆者實際生產跑的都是docker環境 因此docker上面的容器會非常多,docker的安全性非常重要 針對類似於2375的端口允許外部調用的,請一定將2375 設置安全組規則 並且設置對應的白名單 具體到ip 此項非常重要
最后就是筆者的流水線語法了:
// def tools = new org.devops.tools()
def AppName = "${JOB_NAME}"
def HOST_IP = ['ip1','ip2']
def createVersion() {
return new Date().format('yyyyMMddHHmmss') + "_${env.BUILD_ID}"
}
def deployment(HOSTIP){
timestamps {
script {
NacosRequestUrl = "http://${HOSTIP}:${PORT}/nacos/getStatus"
try {
result = httpRequest "${NacosRequestUrl}"
print("輸出狀態碼")
print("${result.status}")
// 判斷是否返回 200
if ("${result.status}" == "200") {
print "Http 請求成功"
sh """
echo "======== 執行 nacos 服務優雅下線 ========"
curl http://${HOSTIP}:${PORT}/nacos/deregister
sleep 10
sh ./docker_deploy.sh ${AppName} ${PORT} ${dockerImageName} ${HOSTIP} ${ENVIRONMENT}
"""
}
}
catch(Exception e){
sh """
echo ""======== 服務已下線,不需要執行優雅下線命令 "========"
sh ./docker_deploy.sh ${AppName} ${PORT} ${dockerImageName} ${HOSTIP} ${ENVIRONMENT}
"""
}
}
}
}
def healthcheck(HOSTIP){
timestamps {
script {
// 設置檢測延遲時間 10s,10s 后再開始檢測
sleep 30
// 健康檢查地址
httpRequestUrl = "http://${HOSTIP}:${PORT}/${params.HTTP_REQUEST_URL}"
// 循環使用 httpRequest 請求,檢測服務是否啟動
for(n = 1; n <= "${params.HTTP_REQUEST_NUMBER}".toInteger(); n++){
try{
// 輸出請求信息和請求次數
print "訪問服務:${AppName} \n" +
"訪問地址:${httpRequestUrl} \n" +
"訪問次數:${n}"
// 如果非第一次檢測,就睡眠一段時間,等待再次執行 httpRequest 請求
if(n > 1){
sleep "${params.HTTP_REQUEST_INTERVAL}".toInteger()
}
// 使用 HttpRequest 插件的 httpRequest 方法檢測對應地址
result = httpRequest "${httpRequestUrl}"
// 判斷是否返回 200
if ("${result.status}" == "200") {
print "Http 請求成功,流水線結束"
break
}
}
catch(Exception e){
print "監控檢測失敗,將在 ${params.HTTP_REQUEST_INTERVAL} 秒后將再次檢測。"
// 判斷檢測次數是否為最后一次檢測,如果是最后一次檢測,並且還失敗了,就對整個 Jenkins 任務標記為失敗
if (n == "${params.HTTP_REQUEST_NUMBER}".toInteger()) {
currentBuild.result = "FAILURE"
}
}
}
}
}
}
pipeline {
agent { label 'master' }
environment {
version = createVersion()
AppName = "${JOB_NAME}"
}
//清理空間
stages {
stage('Clean階段') {
steps {
timestamps {
cleanWs(
cleanWhenAborted: true,
cleanWhenFailure: true,
cleanWhenNotBuilt: true,
cleanWhenSuccess: true,
cleanWhenUnstable: true,
cleanupMatrixParent: true,
disableDeferredWipeout: true,
deleteDirs: true
)
}
}
}
stage('Git 階段') {
when {
environment name: 'mode',value:'Deploy'
}
steps {
echo "start fetch code from git ${GIT_PROJECT_URL}"
buildDescription "發布機器:${HOST_IP} 構建模塊: ${MODULE} 構建構建分支:${GIT_BRANCH}"
deleteDir()
checkout([$class: 'GitSCM',
branches: [[name: '*/master']],
extensions: [],
userRemoteConfigs: [[credentialsId: '4738804f-6a89-4149-9efa-a7cfa3d94536',
url: "${GIT_PROJECT_URL}"
]]])
script {
BUILD_TAG = sh(returnStdout: true, script: 'git rev-parse --short HEAD').trim()
}
echo "${BUILD_TAG}"
}
}
stage('Docker構建階段') {
when {
environment name: 'mode',value:'Deploy'
}
steps {
timestamps {
script {
// 創建 Dockerfile 文件,但只能在方法塊內使用
configFileProvider([configFile(fileId: "${params.DOCKER_DOCKERFILE_ID}", targetLocation: "Dockerfile-Template")]){
// 設置 Docker 鏡像名稱
dockerImageName = "${params.HARBOR_URL}/${params.ENVIRONMENT}:${BUILD_TAG}"
// 讀取 Dockerfile 文件
dockerfile = readFile encoding: "UTF-8", file: "Dockerfile-Template"
// 替換 Dockerfile 文件中的變量,生成新的 NewDockerfile 文件
NewDockerfile = dockerfile.replaceAll("#PORT","${params.PORT}")
.replaceAll("#MODULE","${params.MODULE}")
writeFile encoding: 'UTF-8', file: './Dockerfile', text: "${NewDockerfile}"
// 輸出新的Dockerfile 文件內容
sh "cat Dockerfile"
echo "${dockerImageName}"
// 判斷 DOCKER_HUB_GROUP 是否為空,有些倉庫是不設置倉庫組的
if ("${params.ENV}" == '') {
dockerImageName = "${params.HARBOR_URL}:${BUILD_TAG}"
}
// 提供 Docker 環境,使用 Docker 工具來進行 Docker 鏡像構建與推送
docker.withRegistry("http://${params.HARBOR_URL}", "${params.HARBOR_CREADENTIAL}") {
def customImage = docker.build("${dockerImageName}")
customImage.push()
}
}
configFileProvider([configFile(fileId: "docker_deploy", targetLocation: "docker_deploy.sh")]){
sh "cat docker_deploy.sh"
sh "chmod 755 docker_deploy.sh"
}
}
}
}
}
stage('Docker xxxxxx 發布階段'){
when {
environment name: 'MODE',value:'Deploy'
}
steps{
deployment("${HOST_IP[0]}")
}
}
stage('Docker xxxxxx 健康檢查階段'){
steps {
healthcheck("${HOST_IP[0]}")
}
}
stage('Docker xxxxxx 發布階段'){
when {
environment name: 'MODE',value:'Deploy'
}
steps{
deployment("${HOST_IP[1]}")
}
}
stage('Docker xxxxxxx 健康檢查階段'){
steps {
healthcheck("${HOST_IP[1]}")
}
}
}
//構建后操作
post{
success{
script{
if(params.MODE == 'Deploy'){
//tools.PrintMes("========pipeline executed successfully========",'green')
} else {
// tools.PrintMes("========pipeline executed successfully========",'green')
}
}
}
failure{
script{
if(params.MODE == 'Deploy'){
//tools.PrintMes("========pipeline execution failed========",'red')
} else {
//tools.PrintMes("========pipeline execution failed========",'red')
}
}
}
unstable{
script{
if(params.MODE == 'Deploy'){
//tools.PrintMes("========pipeline execution unstable========",'red')
} else {
//tools.PrintMes("========pipeline execution unstable========",'red')
}
}
}
aborted{
script{
if(params.MODE == 'Deploy'){
//tools.PrintMes("========pipeline execution aborted========",'blue')
} else {
// tools.PrintMes("========pipeline execution aborted========",'blue')
}
}
}
}
}
流水線語法中有幾個點 筆者這里說明一下:
1.checkout 流水線中的checkout 是jenkins的統一的流水線生成的語法 可直接在jenkins中生成 主要是為了 git相關的操作
2.docker.withRegistry 這也是流水線生成的語法 是統一封裝好的 去登錄docker倉庫 打包當前的docker鏡像 push鏡像到遠端
好了 最后我們來構建一次!
此時我們可以看到已經構建成功 那么這一次的CI/CD 就成功結束了
筆者下一篇 將配合nacos 實現服務注冊 服務發現 平滑下線的功能說明
特別鳴謝:醉仙桃