【技術乾貨】基於碼雲 Gitee 的雲原生持續整合工作流

2020-08-11 19:10:08

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

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

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

 

工作流架構圖

 

架構元件說明

  1. Gitee:國內最大程式碼託管平臺、全球第二大程式碼託管平臺、全球最大中文程式碼託管平臺。提供程式碼託管、程式碼品質分析、專案管理、程式碼演示等一站式企業級公有雲服務
  2. Gitee Jenkins Plugin :Gitee基於 GitLab Jenkins Plugin 開發的 Jenkins 外掛。用於設定 Jenkins 觸發器,接受Gitee平臺發送的 WebHook 觸發 Jenkins 進行自動化持續整合或持續部署,並可將構建狀態反饋回Gitee平臺。
  3. Harbor :由 VMware 開源的 Docker 映象倉庫,作爲私有映象倉庫最好的選擇。
  4. Jenkins :基於 Java 開發的一種持續整合工具
  5. Kubernetes :適用於自動部署、擴充套件和管理容器化( containerized )應用程式的開源系統。
  6. Helm : Kubernetes 的包管理器

 

工作流程說明

1. 編碼並推播到Gitee

開發者編碼完成後,將程式碼推播到 Gitee,通過觸發由專案管理員預設的 Webhook 規則觸發 Jenkins 作業。

這裏我們使用Gitee的 Gitee-Jenkins-Plugin 外掛完成 Jenkins 端和Gitee端的設定。 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 any
    stages {
        stage('build images and assets') {
            when {
                not {
                    anyOf {
                        environment name: 'giteePullRequestState', value: 'closed'
                        environment name: 'giteePullRequestState', value: 'merged'
                    }
                }
            }
            failFast true
            parallel {
                stage('add start comment to GiteePR') {
                    steps {
                        addGiteeMRComment comment: "+ CI triggered, building... [BUILD](" + env.RUN_DISPLAY_URL + ")"
                    }
                }
                stage('build frontend assets') {
                    steps {
                        sh '''
                            set -u
                            if [[ $(echo $giteePullRequestDescription|grep without_compare|wc -l) -gt 0 ]]; then
                                echo "skip compare"
                                rm -rf $JENKINS_HOME/nfs/$giteePullRequestIid/public
                                mkdir -p $JENKINS_HOME/nfs/$giteePullRequestIid/public
                                cp -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/
                            else
                                rm -rf $JENKINS_HOME/atompi_workspace/assets-builder/atompi
                                rm -rf $JENKINS_HOME/atompi_workspace/assets-builder/out
                                cp -r $WORKSPACE $JENKINS_HOME/atompi_workspace/assets-builder/atompi
                                cd $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/public
                                mkdir -p $JENKINS_HOME/nfs/$giteePullRequestIid/public
                                cp -r $JENKINS_HOME/atompi_workspace/assets-builder/out/* $JENKINS_HOME/nfs/$giteePullRequestIid/public/
                            fi
                        '''
                    }
                }
                stage('build frontend images') {
                    steps {
                        sh '''
                            set -u
                            rm -rf $JENKINS_HOME/atompi_workspace/frontend/atompi
                            cp -r $WORKSPACE $JENKINS_HOME/atompi_workspace/frontend/atompi
                            cd $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 -u
                            rm -rf $JENKINS_HOME/atompi_workspace/backend/atompi
                            cp -r $WORKSPACE $JENKINS_HOME/atompi_workspace/backend/atompi
                            cd $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 -u
                    cd $JENKINS_HOME/atompi_workspace/CI-atompi-helm && sed "s/CPRID/${giteePullRequestIid}/g" values.yaml.template > values.yaml
                    cp $WORKSPACE/config/atompi.yml.cm ./charts/backend/templates/configmap-atompi-yml.yaml
                    cp $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: '''
<details>
    <summary>CI Opened</summary>


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


存取該 url 前 需要本地 dns 設定爲192.168.1.1


</details>
                '''
            }
        }
        stage('delete') {
            when {
                anyOf {
                    environment name: 'giteePullRequestState', value: 'closed'
                    environment name: 'giteePullRequestState', value: 'merged'
                }
            }
            steps {
                sh '''
                    set -u
                    echo $giteePullRequestState
                    $JENKINS_HOME/bin/helm uninstall -n ci-atompi-$giteePullRequestIid ci-atompi-$giteePullRequestIid
                    sleep 30
                    $JENKINS_HOME/bin/kubectl delete ns ci-atompi-$giteePullRequestIid
                    rm -rf $JENKINS_HOME/nfs/$giteePullRequestIid
                    curl -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 外掛呼叫Gitee介面,向當前構建的 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 介面上手動停止本次構建)時將構建資訊發送到Gitee的 PR 評論中。

本案例的 pipeline 語法到這裏就介紹完了,更詳細的語法規則可檢視官方線上文件

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

  1. 當 Jenkins 收到Gitee發送的 Webhook 請求並匹配到觸發規則時,我們設定的 Job 進入 build 狀態。
  2. build 開始之後, Jenkins 通過 git 外掛從Gitee拉取指定程式碼到 workspace 同時讀取程式碼倉庫中的 Jenkinsfile ,獲取到 Jenkinsfile 後, Jenkins 開始按照 Jenkinsfile 中宣告的流程開始執行構建
  3. 進入 stages 中的第一個 stage : build images and assets, 該 stage 執行如下操作:
    1. 判斷當前 PR 的狀態,不爲 closed 或 merged 則執行後面的操作(對於已經關閉或合併的 PR 我們不需要再執行構建);
    2. 如果判斷結果爲真,則執行接下來的並行執行的 stage ;
    3. 並行執行:向Gitee當前 PR 頁面發送構建開始的評論資訊,並附帶本次構建的鏈接,方便 PR 負責人實時檢視構建過程;
    4. 並行執行:從 PR 描述( Gitee-Jenkins-Plugin 外掛可以通過環境變數 $giteePullRequestDescription 獲取 PR 的描述文字 ) 判斷是否需要編譯前端靜態資源,再執行後面的編譯前端靜態資源並推播到前端靜態資源共用的 nfs server 、構建前端 docker 映象並推播到 harbor 、構建後端 docker 映象並推播到 harbor ,這些構建都是通過 sh 定義一個 shell 指令碼,在 agent 上執行這個指令碼完成構建;
  4. 並行操作執行完成後開始執行部署的 stage :
    1. 通過 agent 上的 Helm 用戶端安裝我們預先編寫好的 helm chart ,這裏我們可以使用 shell 指令碼修改 values.yaml.template 等設定模板檔案,同時,對於不同的 PR 部署在同一個 Kubernetes 叢集中,我們以 PRID 命名 namespace ,區分每個 PR 爲單獨一個 namespace ,從而實現對於不同的 PR 都有獨立的組態檔和執行環境。
    2. 部署完成後,我們將 PRID 和我們的 Ingress 域名進行拼接,並以 markdown 的格式將當前構建結果和 Kubernetes 叢集中服務的入口鏈接發送到當前 PR 的評論中;
  5. 對於已經合併或者關閉的 PR ,在Gitee上操作 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

 

架構元件部署

  1. Harbor
  2. Kubernetes
  3. Helm : Helm 從 v3 版本開始不再需要安裝 tiller ,對於熱衷於使用 Helm 來管理 Kubernetes 應用安裝包的使用者來說,無疑是史詩級升級。從 v3 版本開始,我們只需要下載 Helm 二進制檔案即可直接與 Kubernetes 叢集互動,前提是 ~/.kube/ 目錄下存在有許可權的 config 檔案,這對已經安裝過 Kubernetes 叢集的人來說並不是個問題。 Helm 二進制檔案下載地址
  4. Jenkins with Gitee-Jenkins-Plugin

 

擴充套件

本文案例中的 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 等)。總之,本文的案例是對基於Gitee的雲原生持續整合工作流的一個實現參考,我們可以在這個工作流的基礎上創造更多的最佳實踐。

 

當然,本文實現的案例也存在的些許不足,比如對於多 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/bash
                    set -u
                    if [[ $(echo $giteePullRequestDescription|grep without_compile|wc -l) -gt 0 ]]; then
                        min=1
                        max=10
                        while [ $min -le $max ]
                        do
                            if [[ "Running" == $($JENKINS_HOME/bin/kubectl get po -n ci-atompi-$giteePullRequestIid | grep ciatompibe|awk '{print $3}') ]]; then
                                BACKEND_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 $giteePullRequestIid
                                break
                            else
                                min=`expr $min + 1`
                                sleep 60
                            fi
                        done
                    else
                        min=1
                        max=10
                        while [ $min -le $max ]
                        do
                            if [[ "Running" == $($JENKINS_HOME/bin/kubectl get po -n ci-atompi-$giteePullRequestIid | grep ciatompibe|awk '{print $3}') ]]; then
                                BACKEND_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 $giteePullRequestIid
                                break
                            else
                                min=`expr $min + 1`
                                sleep 60
                            fi
                        done
                    fi
                '''
                addGiteeMRComment comment: '''
<details>
    <summary>CI Opened</summary>


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


存取該 url 前 需要本地 dns 設定爲192.168.1.1


</details>
                '''
           

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