基於Jenkins、Gitlab、Harbor、Helm 和 Kubernetes 的 CI

,接下來的工作就是來實現之前的具體 Pipeline 腳本了。

基於Jenkins、Gitlab、Harbor、Helm 和 Kubernetes 的 CI/CD(二)

Pipeline

第一個階段:單元測試,我們可以在這個階段是運行一些單元測試或者靜態代碼分析的腳本,我們這裡直接忽略。

第二個階段:代碼編譯打包,我們可以看到我們是在一個maven的容器中來執行的,所以我們只需要在該容器中獲取到代碼,然後在代碼目錄下面執行 maven 打包命令即可,如下所示:

 stage('代碼編譯打包') {
try {
container('maven') {
echo "2. 代碼編譯打包階段"
sh "mvn clean package -Dmaven.test.skip=true"
}
} catch (exc) {
println "構建失敗 - ${currentBuild.fullDisplayName}"
throw(exc)
}
}

第三個階段:構建 Docker 鏡像,要構建 Docker 鏡像,就需要提供鏡像的名稱和 tag,要推送到 Harbor 倉庫,就需要提供登錄的用戶名和密碼,所以我們這裡使用到了withCredentials方法,在裡面可以提供一個credentialsId為dockerhub的認證信息,如下:

container('構建 Docker 鏡像') {
withCredentials([[$class: 'UsernamePasswordMultiBinding',

credentialsId: 'dockerhub',
usernameVariable: 'DOCKER_HUB_USER',
passwordVariable: 'DOCKER_HUB_PASSWORD']]) {
container('docker') {
echo "3. 構建 Docker 鏡像階段"
sh """
docker login ${dockerRegistryUrl} -u ${DOCKER_HUB_USER} -p ${DOCKER_HUB_PASSWORD}
docker build -t ${image}:${imageTag} .
docker push ${image}:${imageTag}
"""
}
}
}

其中 ${image} 和 ${imageTag} 我們可以在上面定義成全局變量:

def imageTag = sh(script: "git rev-parse --short HEAD", returnStdout: true).trim()
def dockerRegistryUrl = "registry.qikqiak.com"
def imageEndpoint = "course/polling-app-server"
def image = "${dockerRegistryUrl}/${imageEndpoint}"

docker 的用戶名和密碼信息則需要通過憑據來進行添加,進入 jenkins 首頁 -> 左側菜單憑據 -> 添加憑據,選擇用戶名和密碼類型的,其中 ID 一定要和上面的credentialsId的值保持一致:

基於Jenkins、Gitlab、Harbor、Helm 和 Kubernetes 的 CI/CD(二)

add docker hub credential

第四個階段:運行 kubectl 工具,其實在我們當前使用的流水線中是用不到 kubectl 工具的,那麼為什麼我們這裡要使用呢?這還不是因為我們暫時還沒有去寫應用的 Helm Chart 包嗎?所以我們先去用原始的 YAML 文件來編寫應用部署的資源清單文件,這也是我們寫出 Chart 包前提,因為只有知道了應用如何部署才可能知道 Chart 包如何編寫,所以我們先編寫應用部署資源清單。

首先當然就是 Deployment 控制器了,如下所示:(k8s.yaml)

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: polling-server
namespace: course
labels:
app: polling-server
spec:
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
type: RollingUpdate
template:
metadata:
labels:
app: polling-server
spec:
restartPolicy: Always
imagePullSecrets:
- name: myreg
containers:
- image: <image>:<image>
name: polling-server
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080

name: api
env:
- name: DB_HOST
value: mysql
- name: DB_PORT
value: "3306"
- name: DB_NAME
value: polling_app
- name: DB_USER
value: polling
- name: DB_PASSWORD
value: polling321
---
kind: Service
apiVersion: v1
metadata:
name: polling-server
namespace: course
spec:
selector:
app: polling-server
type: ClusterIP
ports:
- name: api-port
port: 8080
targetPort: api
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: mysql
namespace: course
spec:
template:
metadata:
labels:
app: mysql
spec:
restartPolicy: Always
containers:
- name: mysql
image: mysql:5.7
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3306
name: dbport
env:
- name: MYSQL_ROOT_PASSWORD
value: rootPassW0rd
- name: MYSQL_DATABASE

value: polling_app
- name: MYSQL_USER
value: polling
- name: MYSQL_PASSWORD
value: polling321
volumeMounts:
- name: db
mountPath: /var/lib/mysql
volumes:
- name: db
hostPath:
path: /var/lib/mysql
---
kind: Service
apiVersion: v1
metadata:
name: mysql
namespace: course
spec:
selector:
app: mysql
type: ClusterIP
ports:
- name: dbport
port: 3306
targetPort: dbport
/<image>/<image>

可以看到我們上面的 YAML 文件中添加使用的鏡像是用標籤代替的:<image>:<image>,這是因為我們的鏡像地址是動態的,下依賴我們在上一個階段打包出來的鏡像地址的,所以我們這裡用標籤代替,然後將標籤替換成真正的值即可,另外為了保證應用的穩定性,我們還在應用中添加了健康檢查,所以需要在代碼中添加一個健康檢查的 Controller:(src/main/java/com/example/polls/controller/StatusController.java)/<image>/<image>

package com.example.polls.controller;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/_status/healthz")
public class StatusController {
@GetMapping

public String healthCheck() {
return "UP";
}
}

最後就是環境變量了,還記得前面我們更改了資源文件中數據庫的配置嗎?(src/main/resources/application.properties)因為要儘量通用,我們在部署應用的時候很有可能已經有一個外部的數據庫服務了,所以這個時候通過環境變量傳入進來即可。另外由於我們這裡使用的是私有鏡像倉庫,所以需要在集群中提前創建一個對應的 Secret 對象:

$ kubectl create secret docker-registry myreg --docker-server=registry.qikqiak.com --docker-username=DOCKER_USER --docker-password=DOCKER_PASSWORD --docker-email=DOCKER_EMAIL --namespace course

在代碼根目錄下面創建一個 manifests 的目錄,用來存放上面的資源清單文件,正常來說是不是我們只需要在鏡像構建成功後,將上面的 k8s.yaml 文件中的鏡像標籤替換掉就 OK,所以這一步的動作如下:

stage('運行 Kubectl') {
container('kubectl') {
echo "查看 K8S 集群 Pod 列表"
sh "kubectl get pods"
sh """
sed -i "s/<image>/${image}" manifests/k8s.yaml
sed -i "s/<image>/${imageTag}" manifests/k8s.yaml
kubectl apply -f k8s.yaml
"""
}
}
/<image>/<image>

第五階段:運行 Helm 工具,就是直接使用 Helm 來部署應用了,現在有了上面的基本的資源對象了,要創建 Chart 模板就相對容易了,Chart 模板倉庫地址:https://github.com/cnych/polling-helm,我們可以根據values.yaml文件來進行自定義安裝,模板中我們定義了可以指定使用外部數據庫服務或者內部獨立的數據庫服務,具體的我們可以去看模板中的定義。首先我們可以先使用這個模板在集群中來測試下。首先在集群中 Clone 上面的 Chart 模板:

$ git clone https://github.com/cnych/polling-helm.git

然後我們使用內部的數據庫服務,新建一個 custom.yaml 文件來覆蓋 values.yaml 文件中的值:

persistence:
enabled: true
persistentVolumeClaim:
database:
storageClass: "database"
database:
type: internal
internal:
database: "polling"
# 數據庫用戶
username: "polling"
# 數據庫用戶密碼
password: "polling321"

可以看到我們這裡使用了一個名為database的 StorgeClass 對象,所以還得創建先創建這個資源對象:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: database
provisioner: fuseim.pri/ifs

然後我們就可以在 Chart 根目錄下面安裝應用,執行下面的命令:

$ helm upgrade --install polling -f custom.yaml . --namespace course
Release "polling" does not exist. Installing it now.
NAME: polling
LAST DEPLOYED: Sat May 4 23:31:42 2019
NAMESPACE: course
STATUS: DEPLOYED
RESOURCES:
==> v1/Pod(related)

NAME READY STATUS RESTARTS AGE
polling-polling-api-6b699478d6-lqwhw 0/1 ContainerCreating 0 0s
polling-polling-ui-587bbfb7b5-xr2ff 0/1 ContainerCreating 0 0s
polling-polling-database-0 0/1 Pending 0 0s
==> v1/Secret
NAME TYPE DATA AGE
polling-polling-database Opaque 1 0s
==> v1/Service
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
polling-polling-api ClusterIP 10.109.19.220 <none> 8080/TCP 0s
polling-polling-database ClusterIP 10.98.136.190 <none> 3306/TCP 0s
polling-polling-ui ClusterIP 10.108.170.43 <none> 80/TCP 0s
==> v1beta2/Deployment
NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
polling-polling-api 1 1 1 0 0s
polling-polling-ui 1 1 1 0 0s
==> v1/StatefulSet
NAME DESIRED CURRENT AGE
polling-polling-database 1 1 0s
==> v1beta1/Ingress
NAME HOSTS ADDRESS PORTS AGE
polling-polling-ingress ui.polling.domain 80 0s
NOTES:
1. Get the application URL by running these commands:
http://ui.polling.domain
You have new mail in /var/spool/mail/root
/<none>/<none>/<none>

注意我們這裡安裝也是使用的helm upgrade命令,這樣有助於安裝和更新的時候命令統一。

安裝完成後,查看下 Pod 的運行狀態:

$ kubectl get pods -n course
NAME READY STATUS RESTARTS AGE
polling-polling-api-6b699478d6-lqwhw 1/1 Running 0 3m
polling-polling-database-0 1/1 Running 0 3m
polling-polling-ui-587bbfb7b5-xr2ff 1/1 Running 0 3m

然後我們可以在本地/etc/hosts裡面加上http://ui.polling.domain的的映射,這樣我們就可以通過這個域名來訪問我們安裝的應用了,可以註冊、登錄、發表投票內容了:

基於Jenkins、Gitlab、Harbor、Helm 和 Kubernetes 的 CI/CD(二)

polling app

這樣我們就完成了使用 Helm Chart 安裝應用的過程,但是現在我們使用的包還是直接使用的 git 倉庫中的,平常我們正常安裝的時候都是使用的 Chart 倉庫中的包,所以我們需要將該 Chart 包上傳到一個倉庫中去,比較幸運的是我們的 Harbor 也是支持 Helm Chart 包的。我們可以選擇手動通過 Harbor 的 Dashboard 將 Chart 包進行上傳,也可以通過使用Helm Push插件:

$ helm plugin install https://github.com/chartmuseum/helm-push
Downloading and installing helm-push v0.7.1 ...
https://github.com/chartmuseum/helm-push/releases/download/v0.7.1/helm-push_0.7.1_linux_amd64.tar.gz
Installed plugin: push

當然我們需要首先將 Harbor 提供的倉庫添加到 helm repo 中,由於是私有倉庫,所以在添加的時候我們需要添加用戶名和密碼:

$ helm repo add course https://registry.qikqiak.com/chartrepo/course --username=<harbor> --password=<harbor>
"course" has been added to your repositories
/<harbor>/<harbor>

這裡的 repo 的地址是<harbor>/chartrepo/<harbor>,Harbor 中每個項目是分開的 repo,如果不提供項目名稱,則默認使用library這個項目。/<harbor>/<harbor>

需要注意的是如果你的 Harbor 是採用的自建的 https 證書,這裡就需要提供 ca 證書和私鑰文件了,否則會出現證書校驗失敗的錯誤x509: certificate signed by unknown authority。我們這裡是通過cert-manager為 Harbor 提供的一個信任的 https 證書,所以沒有指定 ca 證書相關的參數。

然後我們將上面的polling-helm這個 Chart 包上傳到 Harbor 倉庫中去:

$ helm push polling-helm course
Pushing polling-0.1.0.tgz to course...
Done.

這個時候我們登錄的 Harbor 倉庫中去,查看 course 這個項目下面的Helm Charts就可以發現多了一個 polling 的應用了:

基於Jenkins、Gitlab、Harbor、Helm 和 Kubernetes 的 CI/CD(二)

helm chart

我們也可以在右下角看到有添加倉庫和安裝 Chart 的相關命令。

到這裡 Helm 相關的工作就準備好了。那麼我們如何在 Jenkins Pipeline 中去使用 Helm 呢?我們可以回顧下,我們平時的一個 CI/CD 的流程:開發代碼 -> 提交代碼 -> 觸發鏡像構建 -> 修改鏡像tag -> 推送到鏡像倉庫中去 -> 然後更改 YAML 文件鏡像版本 -> 使用 kubectl 工具更新應用。

現在我們是不是直接使用 Helm 了,就不需要去手動更改 YAML 文件了,也不需要使用 kubectl 工具來更新應用了,而是隻需要去覆蓋下 helm 中的鏡像版本,直接 upgrade 是不是就可以達到應用更新的結果了。我們可以去查看下 chart 包的 values.yaml 文件中關於 api 服務的定義:

api:
image:
repository: cnych/polling-api
tag: 0.0.7
pullPolicy: IfNotPresent

我們是不是隻需要將上面關於 api 服務使用的鏡像用我們這裡 Jenkins 構建後的替換掉就可以了,這樣我們更改上面的最後運行 Helm的階段如下:

stage('運行 Helm') {
container('helm') {
echo "更新 polling 應用"
sh """
helm upgrade --install polling polling --set persistence.persistentVolumeClaim.database.storageClass=database --set database.type=internal --set database.internal.database=polling --set database.internal.username=polling --set database.internal.password=polling321 --set api.image.repository=${image} --set api.image.tag=${imageTag} --set imagePullSecrets[0].name=myreg --namespace course

"""
}
}

當然我們可以將需要更改的值都放入一個 YAML 之中來進行修改,我們這裡通過--set來覆蓋對應的值,這樣整個 API 服務的完整 Jenkinsfile 文件如下所示:

def label = "slave-${UUID.randomUUID().toString()}"
def helmLint(String chartDir) {
println "校驗 chart 模板"
sh "helm lint ${chartDir}"
}
def helmInit() {
println "初始化 helm client"
sh "helm init --client-only --stable-repo-url https://mirror.azure.cn/kubernetes/charts/"
}
def helmRepo(Map args) {
println "添加 course repo"
sh "helm repo add --username ${args.username} --password ${args.password} course https://registry.qikqiak.com/chartrepo/course"
println "更新 repo"
sh "helm repo update"
println "獲取 Chart 包"
sh """
helm fetch course/polling
tar -xzvf polling-0.1.0.tgz
"""
}
def helmDeploy(Map args) {
helmInit()
helmRepo(args)
if (args.dry_run) {
println "Debug 應用"
sh "helm upgrade --dry-run --debug --install ${args.name} ${args.chartDir} --set persistence.persistentVolumeClaim.database.storageClass=database --set database.type=internal --set database.internal.database=polling --set database.internal.username=polling --set database.internal.password=polling321 --set api.image.repository=${args.image} --set api.image.tag=${args.tag} --set imagePullSecrets[0].name=myreg --namespace=${args.namespace}"
} else {
println "部署應用"
sh "helm upgrade --install ${args.name} ${args.chartDir} --set persistence.persistentVolumeClaim.database.storageClass=database --set database.type=internal --set database.internal.database=polling --set database.internal.username=polling --set database.internal.password=polling321 --set api.image.repository=${args.image} --set api.image.tag=${args.tag} --set imagePullSecrets[0].name=myreg --namespace=${args.namespace}"
echo "應用 ${args.name} 部署成功. 可以使用 helm status ${args.name} 查看應用狀態"
}
}
podTemplate(label: label, containers: [
containerTemplate(name: 'maven', image: 'maven:3.6-alpine', command: 'cat', ttyEnabled: true),

containerTemplate(name: 'docker', image: 'docker', command: 'cat', ttyEnabled: true),
containerTemplate(name: 'helm', image: 'cnych/helm', command: 'cat', ttyEnabled: true)
], volumes: [
hostPathVolume(mountPath: '/root/.m2', hostPath: '/var/run/m2'),
hostPathVolume(mountPath: '/home/jenkins/.kube', hostPath: '/root/.kube'),
hostPathVolume(mountPath: '/var/run/docker.sock', hostPath: '/var/run/docker.sock')
]) {
node(label) {
def myRepo = checkout scm
def gitCommit = myRepo.GIT_COMMIT
def gitBranch = myRepo.GIT_BRANCH
def imageTag = sh(script: "git rev-parse --short HEAD", returnStdout: true).trim()
def dockerRegistryUrl = "registry.qikqiak.com"
def imageEndpoint = "course/polling-api"
def image = "${dockerRegistryUrl}/${imageEndpoint}"
stage('單元測試') {
echo "1.測試階段"
}
stage('代碼編譯打包') {
try {
container('maven') {
echo "2. 代碼編譯打包階段"
sh "mvn clean package -Dmaven.test.skip=true"
}
} catch (exc) {
println "構建失敗 - ${currentBuild.fullDisplayName}"
throw(exc)
}
}
container('構建 Docker 鏡像') {
withCredentials([[$class: 'UsernamePasswordMultiBinding',
credentialsId: 'dockerhub',
usernameVariable: 'DOCKER_HUB_USER',
passwordVariable: 'DOCKER_HUB_PASSWORD']]) {
container('docker') {
echo "3. 構建 Docker 鏡像階段"
sh """
docker login ${dockerRegistryUrl} -u ${DOCKER_HUB_USER} -p ${DOCKER_HUB_PASSWORD}
docker build -t ${image}:${imageTag} .
docker push ${image}:${imageTag}
"""
}
}
}
stage('運行 Helm') {
withCredentials([[$class: 'UsernamePasswordMultiBinding',
credentialsId: 'dockerhub',

usernameVariable: 'DOCKER_HUB_USER',
passwordVariable: 'DOCKER_HUB_PASSWORD']]) {
container('helm') {
// todo,也可以做一些其他的分支判斷是否要直接部署
echo "4. [INFO] 開始 Helm 部署"
helmDeploy(
dry_run : false,
name : "polling",
chartDir : "polling",
namespace : "course",
tag : "${imageTag}",
image : "${image}",
username : "${DOCKER_HUB_USER}",
password : "${DOCKER_HUB_PASSWORD}"
)
echo "[INFO] Helm 部署應用成功..."
}
}
}
}
}

由於我們沒有將 chart 包放入到 API 服務的代碼倉庫中,這是因為我們這裡使用的 chart 包涉及到兩個應用,一個 API 服務,一個是前端展示的服務,所以我們這裡是通過腳本里面去主動獲取到 chart 包來進行安裝的,如果 chart 包跟隨代碼倉庫一起管理當然就要簡單許多了。

現在我們去更新 Jenkinsfile 文件,然後提交到 gitlab 中,然後去觀察下 Jenkins 中的構建是否成功,我們重點觀察下 Helm 階段:

基於Jenkins、Gitlab、Harbor、Helm 和 Kubernetes 的 CI/CD(二)

jenkins helm console

當然我們還可以去做一些必要的判斷工作,比如根據分支判斷是否需要自動部署等等,同樣也可以切換到 Blue Occean 界面查看構建結果。

基於Jenkins、Gitlab、Harbor、Helm 和 Kubernetes 的 CI/CD(二)

jenkins blue occean

現在大家可以嘗試去修改下代碼,然後提交代碼到 gitlab 上,觀察下 Jenkins 是否能夠自動幫我們完成整個 CI/CD 的過程。

作業:現在還有一個前端展示的項目:https://github.com/cnych/polling-app-client,大家針對這個項目使用上面的 gitlab + jenkins + harbor + helm 來完成一個 Jenkins Pipeline 流水線的編寫,嘗試去修改下前端頁面內容,看是否能夠生效。

相關鏈接:

  • https://github.com/cnych/polling-helm
  • https://github.com/cnych/polling-app-server
  • https://github.com/cnych/polling-app-client
基於Jenkins、Gitlab、Harbor、Helm 和 Kubernetes 的 CI/CD(二)

k8s技術圈


分享到:


相關文章: