基於Gitee 的雲原生持續集成工作流

本文介紹一種基於碼雲的持續集成工作流及其實現,讀者可以通過閱讀本文,從 0 實現一個生產級以 Gitee 倉庫爲核心的雲原生持續集成工作流。

在日常開發過程中,通常會有多個業務或功能模塊同時開發,這些在 git 工作流中都以分支的形式共存在一個代碼倉庫中,當開發完成後向發佈分支提交 PR 以合併到發佈分支完成發佈,這時我們的測試工作就落到了對這些 PR 的部署和測試上,對於多個 PR 的部署我們有什麼方便、快捷、自動化的部署方案呢?

於是本文就以開發環境中多 PR 同時部署和測試爲例,實現一個包含了 Gitee 源碼倉庫、 Jenkins 、 Harbor 、 Kubernetes 、 Helm 等功能組件,可以實現從代碼託管到發佈上線的整套持續集成流程。通過自動化處理構建過程,極大的簡化了日常迭代工作的複雜度。

工作流架構圖

架構組件說明

碼雲:國內最大代碼託管平臺、全球第二大代碼託管平臺、全球最大中文代碼託管平臺。提供代碼託管、代碼質量分析、項目管理、代碼演示等一站式企業級公有云服務

Gitee Jenkins Plugin :碼雲基於 GitLab Jenkins Plugin 開發的 Jenkins 插件。用於配置 Jenkins 觸發器,接受碼雲平臺發送的 WebHook 觸發 Jenkins 進行自動化持續集成或持續部署,並可將構建狀態反饋回碼雲平臺。

Harbor :由 VMware 開源的 Docker 鏡像倉庫,作爲私有鏡像倉庫最好的選擇。

Jenkins :基於 Java 開發的一種持續集成工具

Kubernetes :是用於自動部署、擴展和管理容器化( containerized )應用程序的開源系統。

Helm :Kubernetes 的包管理器

開發者編碼完成後,將代碼推送到碼雲,通過觸發由項目管理員預設的 Webhook 規則觸發 Jenkins 作業。

這裡我們使用碼雲的Gitee-Jenkins-Plugin插件完成 Jenkins 端和碼雲端的配置。Jenkins 安裝及配置過程見 Jenkins with Gitee-Jenkins-Plugin ,有需要的讀者可以前往該文檔進行參考。

2. 在 Jenkins 中根據項目中編寫的Jenkinsfile執行完整的構建和發佈流程。

Jenkinsfile是一個文本文件,其中包含 Jenkins Pipeline 的定義,通常和源碼一起管理。

Jenkins Pipeline 是對流程的自動錶達,用於將軟件從版本控制一直傳遞到用戶和客戶。開發過程中對軟件所做的每項更改(在源代碼管理中進行的)都需要經過複雜的過程才能發佈。此過程涉及以可靠且可重複的方式構建軟件,以及通過多個測試和部署階段來逐步升級已構建的軟件(在 Jenkins 中稱爲一個build)。

Jenkins Pipeline 提供了一組可擴展的工具集,用於通過管道特定於域的語言(DSL)語法以代碼的形式( pipelines "as code" )對簡單到複雜的交付管道進行建模。

下面的流程圖是在 Jenkins Pipeline 中輕鬆建模的一種 CD 方案的示例(圖片來自官方文檔):

這裡我們以一個簡約而不簡單的Jenkinsfile來對整個構建( build )過程進行逐講解,同時介紹一些常用的 pipeline 語法。

首先上Jenkinsfile:

pipeline {agent anystages {stage( 'build images and assets') {when {not {anyOf {environment name: 'giteePullRequestState', value: 'closed'environment name: 'giteePullRequestState', value: 'merged'}}}failFast trueparallel {stage( 'add start comment to GiteePR') {steps {addGiteeMRComment comment: "+ CI triggered, building... [BUILD](" + env.RUN_DISPLAY_URL + ")"}}stage( 'build frontend assets') {steps {sh '''set -uif [[ $(echo $giteePullRequestDescription|grep without_compare|wc -l) -gt 0 ]]; thenecho "skip compare"rm -rf $JENKINS_HOME/nfs/$giteePullRequestIid/publicmkdir -p $JENKINS_HOME/nfs/$giteePullRequestIid/publiccp -r $JENKINS_HOME/nfs/global/public/assets $JENKINS_HOME/nfs/$giteePullRequestIid/public/cp -r $JENKINS_HOME/nfs/global/public/webpacks $JENKINS_HOME/nfs/$giteePullRequestIid/public/elserm -rf $JENKINS_HOME/atompi_workspace/assets-builder/atompirm -rf $JENKINS_HOME/atompi_workspace/assets-builder/outcp -r $WORKSPACE $JENKINS_HOME/atompi_workspace/assets-builder/atompicd $JENKINS_HOME/atompi_workspace/assets-builder && DOCKER_BUILDKIT=1 $JENKINS_HOME/bin/docker -H tcp://docker:2375 build -o out .rm -rf $JENKINS_HOME/nfs/$giteePullRequestIid/publicmkdir -p $JENKINS_HOME/nfs/$giteePullRequestIid/publiccp -r $JENKINS_HOME/atompi_workspace/assets-builder/out/* $JENKINS_HOME/nfs/$giteePullRequestIid/public/fi'''}}stage( 'build frontend images') {steps {sh '''set -urm -rf $JENKINS_HOME/atompi_workspace/frontend/atompicp -r $WORKSPACE $JENKINS_HOME/atompi_workspace/frontend/atompicd $JENKINS_HOME/atompi_workspace/frontend && $JENKINS_HOME/bin/docker -H tcp://docker:2375 build -t hub.atompi.cc/atompi_ci/frontend:v3.0.0-$giteePullRequestIid .$JENKINS_HOME/bin/docker -H tcp://docker:2375 push hub.atompi.cc/atompi_ci/frontend:v3.0.0-$giteePullRequestIid$JENKINS_HOME/bin/docker -H tcp://docker:2375 rmi hub.atompi.cc/atompi_ci/frontend:v3.0.0-$giteePullRequestIid'''}}stage( 'build backend image') {steps {sh '''set -urm -rf $JENKINS_HOME/atompi_workspace/backend/atompicp -r $WORKSPACE $JENKINS_HOME/atompi_workspace/backend/atompicd $JENKINS_HOME/atompi_workspace/backend && $JENKINS_HOME/bin/docker -H tcp://docker:2375 build -t hub.atompi.cc/atompi_ci/backend:v3.0.0-$giteePullRequestIid .$JENKINS_HOME/bin/docker -H tcp://docker:2375 push hub.atompi.cc/atompi_ci/backend:v3.0.0-$giteePullRequestIid$JENKINS_HOME/bin/docker -H tcp://docker:2375 rmi hub.atompi.cc/atompi_ci/backend:v3.0.0-$giteePullRequestIid'''}}}}stage( 'deploy') {when {not {anyOf {environment name: 'giteePullRequestState', value: 'closed'environment name: 'giteePullRequestState', value: 'merged'}}}steps {sh '''set -ucd $JENKINS_HOME/atompi_workspace/CI-atompi-helm && sed "s/CPRID/${giteePullRequestIid}/g" values.yaml.template > values.yamlcp $WORKSPACE/config/atompi.yml.cm ./charts/backend/templates/configmap-atompi-yml.yamlcp $WORKSPACE/config/environments/production.rb.cm ./charts/backend/templates/configmap-production-rb.yaml$JENKINS_HOME/bin/helm uninstall -n ci-atompi-$giteePullRequestIid ci-atompi-$giteePullRequestIid || echo "release does not exists"sleep 10 && $JENKINS_HOME/bin/kubectl delete ns ci-atompi-$giteePullRequestIid || echo "ns ci-atompi-$giteePullRequestIid does not exists"sleep 5 && $JENKINS_HOME/bin/kubectl create ns ci-atompi-$giteePullRequestIid || echo "namespace already exists"cd $JENKINS_HOME/atompi_workspace/CI-atompi-helm && $JENKINS_HOME/bin/helm upgrade -i -n ci-atompi-$giteePullRequestIid ci-atompi-$giteePullRequestIid ./'''addGiteeMRComment comment: '''

CI Opened

部署已完成,正在啓動服務。[點我測試](http://''' + env.giteePullRequestIid + '''.atompi.cc)

訪問該 url 前 需要本地 dns 設置爲192.168.1.1

'''}}stage( 'delete') {when {anyOf {environment name: 'giteePullRequestState', value: 'closed'environment name: 'giteePullRequestState', value: 'merged'}}steps {sh '''set -uecho $giteePullRequestState$JENKINS_HOME/bin/helm uninstall -n ci-atompi-$giteePullRequestIid ci-atompi-$giteePullRequestIidsleep 30$JENKINS_HOME/bin/kubectl delete ns ci-atompi-$giteePullRequestIidrm -rf $JENKINS_HOME/nfs/$giteePullRequestIidcurl -X DELETE -H 'Accept: text/plain' "http://hub.atompi.cc/api/repositories/atompi_ci/backend/tags/v3.0.0-$giteePullRequestIid"curl -X DELETE -H 'Accept: text/plain' "http://hub.atompi.cc/api/repositories/atompi_ci/frontend/tags/v3.0.0-$giteePullRequestIid"'''addGiteeMRComment comment: "+ CI Closed"}}}post {failure {addGiteeMRComment comment: "+ CI build failure! [BUILD](" + env.RUN_DISPLAY_URL + ")"}aborted {addGiteeMRComment comment: "+ CI build aborted! [BUILD](" + env.RUN_DISPLAY_URL + ")"}}}

pipeline {...}:在聲明式的 Pipeline 語法中,pipeline塊定義了整個管道中完成的所有工作。

agent any:用於說明在任何可用 agent 上執行此管道或其任何階段。agent 即 Jenkins 集羣中的構建節點,我們可以給這些節點指定標籤,讓某些有特定需求的構建過程在這些帶有特定標籤的節點上運行。

stages {...}: 包含管道的構建步驟集

stage('build images and assets') {...}:定義其中某個構建過程(括弧中的內容爲該 stage 的標題,用於展示在 Jenkins web 界面及日誌中),其中when塊定義了執行本stage的判斷條件,如果爲真則執行,否則跳過;steps塊定義了本stage真正執行的操作步驟,如sh表示在 agent 上執行 shell 腳本、addGiteeMRComment表示通過Gitee-Jenkins-Plugin插件調用碼雲接口,向當前構建的 PR 發送評論信息。

failFast true及parallel {...}:我們可以看到某些stage塊中有parallel {...}塊,同時parallel {...}塊中又包含多個stage塊,這樣的語法的意思是在parallel {...}塊中的stage爲並行執行的,不在parallel {...}中的stage是按照從上到下的順序串行執行,只有在上一個stage成功執行完後纔會進入下一個stage,而parallel {...}塊中定義的stage會並行執行,parallel {...}塊前一行的failFast true表示當parallel {...}塊中的某個stage出現錯誤時,整個parallel {...}塊都退出,並結束parallel {...}塊中的所有stage不管這些stage是否執行完成,同時標記上層stage爲錯誤退出。

post {...}:定義所有stages執行完成後的後續操作,不一定需要post塊,當我們需要根據stages結束狀態來判斷是否執行後續操作時,我們可以定義一個post塊,如本案例中定義了當stages結果爲failure(失敗)或aborted(拒絕,通常時人爲的,比如 web 界面上手動停止本次構建)時將構建信息發送到碼雲的 PR 評論中。

本案例的 pipeline 語法到這裡就介紹完了,更詳細的語法規則可查看官方在線文檔。

接下來我們對整個構建流程再進行一遍梳理,詳細的說明一下本案例中的 pipeline 幹了什麼,同時也是對本文介紹的工作流做一個全面的講解。

當 Jenkins 收到碼雲發送的 Webhook 請求並匹配到觸發規則時,我們配置的 Job 進入build狀態。

build開始之後, Jenkins 通過 git 插件從碼雲拉取指定代碼到workspace同時讀取代碼倉庫中的Jenkinsfile,獲取到Jenkinsfile後, Jenkins 開始按照Jenkinsfile中聲明的流程開始執行構建

進入stages中的第一個stage:build images and assets, 該stage執行如下操作:

判斷當前 PR 的狀態,不爲closed或merged則執行後面的操作(對於已經關閉或合併的 PR 我們不需要再執行構建);

如果判斷結果爲真,則執行加下來的並行執行的stage;

並行執行:向碼雲當前 PR 頁面發送構建開始的評論信息,並附帶本次構建的鏈接,方便 PR 負責人實時查看構建過程;

並行執行:從 PR 描述( Gitee-Jenkins-Plugin 插件可以通過環境變量$giteePullRequestDescription獲取 PR 的描述文本 ) 判斷是否需要編譯前端靜態資源,再執行後面的編譯前端靜態資源並推送到前端靜態資源共享的 nfs server 、構建前端 docker 鏡像並推送到 harbor 、構建後端 docker 鏡像並推送到 harbor ,這些構建都是通過sh定義一個 shell 腳本,在 agent 上執行這個腳本完成構建;

並行操作執行完成後開始執行部署的stage:

通過 agent 上的 Helm 客戶端安裝我們預先編寫好的 helm chart ,這裡我們可以使用 shell 腳本修改values.yaml.template等配置模板文件,同時,對於不通的 PR 部署在同一個 Kubernetes 集羣中,我們以 PRID 命名 namespace ,區分每個 PR 爲單獨一個 namespace ,從而實現對於不同的 PR 都有獨立的配置文件和運行環境。

部署完成後,我們將 PRID 和我們的 Ingress 域名進行拼接,並以 markdown 的格式將當前構建結果和 Kubernetes 集羣中服務的入口鏈接發送到當前 PR 的評論中;

對於已經合併或者關閉的 PR ,在碼雲上操作 PR 狀態修改爲“關閉”或者“合併”時,再次觸發 Jenkins 流水線,同時進入delete的stage,該stage的工作就是將已部署的服務從 Kubernetes 集羣中刪除並將不再使用的 docker 鏡像和靜態資源從 harbor 和 nfs server 中刪除,完成整個 CI 系統的清理工作,讓系統可以持續運行而不需要認爲干預。

至此我們在Jenkinsfile中定義的流水線就走完了。

3. 發佈完成後用戶即可通過 Kubernetes 暴露的 Ingress 請求入口訪問我們發佈的服務。

發佈完成後,我們通過在 Kubernetes 設置集羣內服務的訪問方式來讓用戶能夠訪問到部署在 Kubernetes 集羣中的服務。我們可以配置 LB 、端口轉發或 DNS 配置等,以訪問羣集中的應用程序。

這裡我們通過在 Kubernetes 集羣中安裝Ngins Ingress controller同時配置Ingress規則來讓用戶訪問集羣內服務。

Ingress是一個 API 對象,它定義了允許外部訪問羣集中服務的規則。更多關於 Ingress 的說明見官方文檔

Ingress 的安裝及配置見文檔:Kubernetes(https://gitee.com/atompi/Prod-K8S-HA-Installer)

架構組件部署

Harbor(https://blog.atompi.com/2020/08/03/%E7%A6%BB%E7%BA%BF%E5%AE%89%E8%A3%85%20Harbor%20v2/)

Kubernetes(https://gitee.com/atompi/Prod-K8S-HA-Installer)

Helm:Helm 從 v3 版本開始不再需要安裝tiller,對於熱衷於使用 Helm 來管理 Kubernetes 應用安裝包的用戶來說,無疑是史詩級升級。從 v3 版本開始,我們只需要下載 Helm 二進制文件即可直接與 Kubernetes 集羣交互,前提是~/.kube/目錄下存在有權限的config文件,這對已經安裝過 Kubernetes 集羣的人來說並不是個問題。Helm 二進制文件下載地址(https://github.com/helm/helm/releases)。

Jenkins with Gitee-Jenkins-Plugin(https://blog.atompi.com/2020/08/03/Docker%20Compose%20%E5%AE%89%E8%A3%85%20Jenkins/)

本文案例中的Jenkinsfile所定義的流水線適用於開發環境多 PR 同時部署測試的使用場景。我們只需要對其中部分stage稍作修改,同樣可以應用與生產環境的構建與部署。比如,對於 release 環境我們只需要構建和發佈 release 分支即可,因此不存在 PR 的概念,也不需要執行 PR 評論的操作。我們可以將 PR 狀態的判斷、addGiteeMRComment去除;將 agent 只向生產環境的構建節點;將 docker 鏡像推送至生產環境的 harbor 倉庫;使用生產環境的 helm chart 將 docker 鏡像發佈到生產環境的 Kubernetes 集羣。對於不是部署到 Kubernetes 集羣的應用,我們可以將構建 docker 鏡像的操作替換爲構建制品(如:二進制文件、 jar/war 包等)的操作,同時將docker push操作替換爲將製品推送到製品庫(如:nexus 等)的操作,這時,發佈將不再使用 helm 工具,而是使用我們自定義的發佈流程工具(如:shell 腳本、 ansible-playbook 等)。總之,本文的案例是對基於碼雲的雲原生持續集成工作流的一個實現參考,我們可以在這個工作流的基礎上創造更多的最佳實踐。

當然,本文實現的案例也存在的些許不足,比如對於多 PR 的部署,總是存在這樣的場景:有多個 PR 同時更新了代碼,這時候的構建隊列是串行的,在有限的節點資源下,我們的構建工作會出現飽和並且需要排隊的現象,這樣對於一個需要編譯前端靜態資源的 PR 來說需要等待的時間會非常漫長,從而導致隊列中的其他構建一同等待,降低了構建效率。在此,本文給出一種解決方案,同時也是作者再在使用的一種方案:將編譯靜態資源的工作下放到每一個部署中。Jenkins 構建工作是瞬時的,而部署在 Kubernetes 集羣中的應用是長時間運行的,由此我們通常會爲 Kubernetes 集羣分配更多的節點而儘量減少 Jenkins 集羣的節點,當然我們可以將 Jenkins 部署在 Kubernetes 集羣中,共享 Kubernetes 集羣資源,但我們爲了管理方便,通常將這兩個角色分離開來。因此,對於開發環境的多 PR 部署測試的使用場景,我們可以將工作量小的、統一的工作交給 Jenkins 執行,而需要大量資源的編譯工作下放到每一個部署中,即 Kubernetes 集羣中,使用 Kubernetes 集羣資源來並向的編譯多個 PR 的靜態資源,這樣每一個 Jenkins 作業只需要很短的時間完成配置和部署的工作即可結束本次構建,立刻開始下一個構建,這樣排隊時長就大幅度縮減了。

爲了實現這種方案,我們需要提前構建最新的基礎代碼倉庫分支(如某一批 PR 是基於某個分支的最新提交開始的,我們可以把 release 分支或者 master 分支作爲基礎分支,這些分支通常是最近發佈到生產環境的分支)的 docker 鏡像作爲基礎鏡像,同時編譯一次靜態資源作爲基礎,後續的 PR 編譯靜態資源時可以基於這個基礎進行增量構建,這樣可以進一步減少編譯時間。

我們在每天發佈生產環境時間觸發 release 構建的作業,執行一次基礎環境構建,並推送到開發環境的 harbor 及 nfs server ,在此基礎上後續的 PR 在發佈時不再單獨構建 docker 鏡像,在 helm chart 中我們把 docker image tag 修改爲固定的 release 版本。

在 PR 部署完成後,我們增加了一個modify的stage,這個stage的工作就是通過kubectl工具在指定的 PR 部署中執行特點的編譯腳本,在部署完成( helm chart 發佈完成並切所有 pod 均成功啓動 )時在指定的 pod 中執行編譯工作,這樣就實現了使用 Kubernetes 集羣中的資源完成工作量較大的編譯工作。

實現的 pipeline 如下:

stage('modify') {when {not {anyOf {environment name: 'giteePullRequestState', value: 'closed'environment name: 'giteePullRequestState', value: 'merged'}}}steps {sh '''#!/bin/bashset -uif [[ $(echo $giteePullRequestDescription|grep without_compile|wc -l) -gt 0 ]]; thenmin= 1max= 10while [ $ min -le $ max ]doif [[ "Running" == $($ JENKINS_HOME/bin/kubectl get po -n ci-atompi-$giteePullRequestIid | grep ciatompibe|awk '{ print $ 3}') ]]; thenBACKEND_POD_NAME=$($ JENKINS_HOME/bin/kubectl get po -n ci-atompi-$giteePullRequestIid | grep ciatompibe|awk '{ print $ 1}')MIRACLE_POD_NAME=$($ JENKINS_HOME/bin/kubectl get po -n ci-atompi-$giteePullRequestIid | grep ciatompife|awk '{ print $ 1}')$ JENKINS_HOME/bin/kubectl exec -n ci-atompi-$giteePullRequestIid $ BACKEND_POD_NAME bash /nohup_pull.sh $giteePullRequestIid$ JENKINS_HOME/bin/kubectl exec -n ci-atompi-$giteePullRequestIid $ MIRACLE_POD_NAME bash /nohup_pull.sh $giteePullRequestIidbreakelsemin=`expr $ min + 1`sleep 60fidoneelsemin= 1max= 10while [ $ min -le $ max ]doif [[ "Running" == $($ JENKINS_HOME/bin/kubectl get po -n ci-atompi-$giteePullRequestIid | grep ciatompibe|awk '{ print $ 3}') ]]; thenBACKEND_POD_NAME=$($ JENKINS_HOME/bin/kubectl get po -n ci-atompi-$giteePullRequestIid | grep ciatompibe|awk '{ print $ 1}')MIRACLE_POD_NAME=$($ JENKINS_HOME/bin/kubectl get po -n ci-atompi-$giteePullRequestIid | grep ciatompife|awk '{ print $ 1}')$ JENKINS_HOME/bin/kubectl exec -n ci-atompi-$giteePullRequestIid $ BACKEND_POD_NAME bash /nohup_compile.sh $giteePullRequestIid$ JENKINS_HOME/bin/kubectl exec -n ci-atompi-$giteePullRequestIid $ MIRACLE_POD_NAME bash /nohup_pull.sh $giteePullRequestIidbreakelsemin=`expr $ min + 1`sleep 60fidonefi'''addGiteeMRComment comment: '''

CI Opened

部署已完成,正在啓動服務。[點我測試](http: //''' + env.giteePullRequestIid + '''.atompi.cc)

訪問該 url 前 需要本地 dns 設置爲 192.168. 1.1

當然,對於生產環境而言,我們是不能容忍編譯工作佔用生產環境資源的,所幸的是,在生產環境中我們不存在多 PR 同時部署的場景,因此就不需要將編譯工作下放,我們正常的執行前面介紹的發佈流程即可。

作者: atompi

原文:https://my.oschina.net/atompi/blog/4466071