Categories
程式開發

用GitHub Action搭建一套CI/CD系統


本文是Nebula Graph工程師利用GitHub Action搭建CI/CD系統的實踐,希望能夠對讀者有所幫助,同時也歡迎讀者留言與作者進行交流。

緣起

Nebula Graph 最早的自動化測試是使用搭建在 Azure 上的 Jenkins,配合著 GitHub 的 Webhook 實現的,在用戶提交 Pull Request 時,加個 ready-for-testing 的 label 再評論一句 Jenkins go 就可以自動的運行相應的 UT 測試,效果如下:

用GitHub Action搭建一套CI/CD系統 1

因為是租用的 Azure 的雲主機,加上 nebula 的編譯要求的機器配置較高,而且任務的觸發主要集中在白天。所以上述的方案性價比較低,從去年團隊就在考慮尋找替代的方案,準備下線 Azure 上的測試機,並且還要能提供多環境的測試方案。

調研了一圈現有的產品主要有:

  1. TravisCI

  2. CircleCI

  3. Azure Pipeline

  4. Jenkins on k8s(自建)

雖然上面的產品對開源項目有些限制,但整體都還算比較友好。

鑑於之前 GitLab CI 的使用經驗,體會到如果能跟 GitHub 深度集成那當然是首選。所謂“深度”表示可以共享 GitHub 的整個開源的生態以及完美的 API 調用(後話)。恰巧 2019,GitHub Action 2.0 橫空出世,Nebula Graph 便勇敢的入了坑。

這裡簡單概述一下我們在使用 GitHub Action 時體會到的優點:

  1. 免費。開源項目可以免費使用 Action 的所有功能,而且機器配置較高

  2. 開源生態好。在整個 CI 的流程裡,可以直接使用 GitHub 上的所有開源的 Action,哪怕就是沒有滿足需求的 Action,自己上手寫也不是很麻煩,而且還支持 docker 定制,用 bash 就可以完成一個專屬的 Action。

  3. 支持多種系統。 Windows、macOS 和 Linux 都可以一鍵使用,跨平台簡單方便。

  4. 可跟 GitHub 的 API 互動。通過 GITHUB_TOKEN 可以直接訪問 GitHub API V3,想上傳文件,檢查 PR 狀態,使用 curl 命令即可完成。

  5. 自託管。只要提供 workflow 的描述文件,將其放置到 .github/workflows/ 目錄下,每次提交便會自動觸發執行新的 action run。

  6. Workflow 描述文件改為 YAML 格式。目前的描述方式要比 Action 1.0 中的 workflow 文件更加簡潔易讀。

下面在講實踐之前還是要先講講 Nebula Graph 的需求:首要目標比較明確就是自動化測試。

作為數據庫產品,測試怎麼強調也不為過。 Nebula Graph 的測試主要分單元測試和集成測試。用 GitHub Action 其實主要瞄準的是單元測試,然後再給集成測試做些準備,比如 docker 鏡像構建和安裝程序打包。順帶再解決一下 PM 小姐姐的發布需求,就整個構建起來了第一版的 CI/CD 流程。

PR 測試

Nebula Graph 作為託管在 GitHub 上的開源項目,首先要解決的測試問題就是當貢獻者提交了 PR 請求後,如何才能快速地進行變更驗證?主要有以下幾個方面。

  1. 符不符合編碼規範;

  2. 能不能在不同系統上都編譯通過;

  3. 單測有沒有失敗;

  4. 代碼覆蓋率有沒有下降等。

只有上述的要求全部滿足並且有至少兩位 reviewer 的同意,變更才能進入主幹分支。

借助於 cpplint 或者 clang-format 等開源工具可以比較簡單地實現要求 1,如果此要求未通過驗證,後面的步驟就自動跳過,不再繼續執行。

對於要求 2,我們希望能同時在目前支持的幾個系統上運行 Nebula 源碼的編譯驗證。那麼像之前在物理機上直接構建的方式就不再可取,畢竟一台物理機的價格已經高昂,何況一台還不足夠。為了保證編譯環境的一致性,還要盡可能的減少機器的性能損失,最終採用了 docker 的容器化構建方式。再藉助 Action 的 matrix 運行策略和對 docker 的支持,還算順利地將整個流程走通。

用GitHub Action搭建一套CI/CD系統 2

運行的大概流程如上圖所示,在 vesoft-inc/nebula-dev-docker 項目中維護 nebula 編譯環境的 docker 鏡像,當編譯器或者 thirdparty 依賴升級變更時,自動觸發 docker hub 的 Build 任務(見下圖)。當新的 Pull Request 提交以後,Action 便會被觸發開始拉取最新的編譯環境鏡像,執行編譯。

用GitHub Action搭建一套CI/CD系統 3

針對 PR 的 workflow 完整描述見文件 pull_request.yaml。同時,考慮到並不是每個人提交的 PR 都需要立即運行 CI 測試,且自建的機器資源有限,對 CI 的觸發做瞭如下限制:

  1. 只有 lint 校驗通過的 PR 才會將後續的 job 下發到自建的 runner,lint 的任務比較輕量,可以使用 GitHub Action 託管的機器來執行,無需佔用線下的資源。

  2. 只有添加了 ready-for-testing label 的 PR 才會觸發 action 的執行,而 label 的添加有權限的控制。進一步優化 runner 被隨意觸發的情況。對 label 的限制如下所示:

jobs:
  lint:
    name: cpplint
    if: contains(join(toJson(github.event.pull_request.labels.*.name)), 'ready-for-testing')

在 PR 中執行完成後的效果如下所示:

用GitHub Action搭建一套CI/CD系統 4

Code Coverage 的說明見博文:圖數據庫 Nebula Graph 的代碼變更測試覆蓋率實踐

Nightly 構建

在 Nebula Graph 的集成測試框架中,希望能夠在每天晚上對 codebase 中的代碼全量跑一遍所有的測試用例。同時有些新的特性,有時也希望能快速地打包交給用戶體驗使用。這就需要 CI 系統能在每天給出當日代碼的 docker 鏡像和 rpm/deb 安裝包。

GitHub Action 被觸發的事件類型除了 pull_request,還可以執行 schedule 類型。 schedule 類型的事件可以像 crontab 一樣,讓用戶指定任何重複任務的觸發時間,比如每天凌晨兩點執行任務如下所示:

on:
  schedule:
    - cron: '0 18 * * *'

因為 GitHub 採用的是 UTC 時間,所以東八區的凌晨 2 點,就對應到 UTC 的前日 18 時。

docker

每日構建的 docker 鏡像需要 push 到 docker hub 上,並打上 nightly 的標籤,集成測試的 k8s 集群,將 image 的拉取策略設置為 Always,每日觸發便能滾動升級到當日最新進行測試。因為當日的問題目前都會盡量當日解決,便沒有再給 nightly 的鏡像再額外打一個日期的 tag。對應的 action 部分如下所示:

      - name: Build image
        env:
          IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/nebula-${{ matrix.service }}:nightly
        run: |
          docker build -t ${IMAGE_NAME} -f docker/Dockerfile.${{ matrix.service }} .
          docker push ${IMAGE_NAME}
        shell: bash

package

GitHub Action 提供了 artifacts 的功能,可以讓用戶持久化 workflow 運行過程中的數據,這些數據可以保留 90 天。對於 nightly 版本安裝包的存儲而言,已經綽綽有餘。利用官方提供的 actions/[email protected] action,可以方便的將指定目錄下的文件上傳到 artifacts。最後 nightly 版本的 nebula 的安裝包如下圖所示。

用GitHub Action搭建一套CI/CD系統 5

上述完整的 workflow 文件見 package.yaml

分支發布

為了更好地維護每個發布的版本和進行 bugfix,Nebula Graph 採用分支發布的方式。即每次發布之前進行 code freeze,並創建新的 release 分支,在 release 分支上只接受 bugfix,而不進行 feature 的開發。 bugfix 還是會在開發分支上提交,最後 cherrypick 到 release 分支。

在每次 release 時,除了 source 外,我們希望能把安裝包也追加到 assets 中方便用戶直接下載。如果每次都手工上傳,既容易出錯,也非常耗時。這就比較適合 Action 來自動化這塊的工作,而且,打包和上傳都走 GitHub 內部網絡,速度更快。

在安裝包編譯好後,通過 curl 命令直接調用 GitHub 的 API,就能上傳到 assets 中,具體腳本內容如下所示:

curl --silent 
     --request POST 
     --url "$upload_url?name=$filename" 
     --header "authorization: Bearer $github_token" 
     --header "content-type: $content_type" 
     --data-binary @"$filepath"

同時,為了安全起見,在每次的安裝包發佈時,希望可以計算安裝包的 checksum 值,並將其一同上傳到 assets 中,以便用戶下載後進行完整性校驗。具體步驟如下所示:

jobs:
  package:
    name: package and upload release assets
    runs-on: ubuntu-latest
    strategy:
      matrix:
        os:
          - ubuntu1604
          - ubuntu1804
          - centos6
          - centos7
    container:
      image: vesoft/nebula-dev:${{ matrix.os }}
    steps:
      - uses: actions/c[email protected]
      - name: package
        run: ./package/package.sh
      - name: vars
        id: vars
        env:
          CPACK_OUTPUT_DIR: build/cpack_output
          SHA_EXT: sha256sum.txt
        run: |
          tag=$(echo ${{ github.ref }} | rev | cut -d/ -f1 | rev)
          cd $CPACK_OUTPUT_DIR
          filename=$(find . -type f ( -iname *.deb -o -iname *.rpm ) -exec basename {} ;)
          sha256sum $filename > $filename.$SHA_EXT
          echo "::set-output name=tag::$tag"
          echo "::set-output name=filepath::$CPACK_OUTPUT_DIR/$filename"
          echo "::set-output name=shafilepath::$CPACK_OUTPUT_DIR/$filename.$SHA_EXT"
        shell: bash
      - name: upload release asset
        run: |
          ./ci/scripts/upload-github-release-asset.sh github_token=${{ secrets.GITHUB_TOKEN }} repo=${{ github.repository }} tag=${{ steps.vars.outputs.tag }} filepath=${{ steps.vars.outputs.filepath }}
          ./ci/scripts/upload-github-release-asset.sh github_token=${{ secrets.GITHUB_TOKEN }} repo=${{ github.repository }} tag=${{ steps.vars.outputs.tag }} filepath=${{ steps.vars.outputs.shafilepath }}

上述完整的 workflow 文件見 release.yaml

命令

GitHub Action 為 workflow 提供了一些命令方便在 shell 中進行調用,來更精細地控制和調試每個步驟的執行。常用的命令如下:

set-output

有時在 job 的 steps 之間需要傳遞一些結果,這時就可以通過 echo "::set-output name=output_name::output_value" 的命令形式將想要輸出的 output_value 值設置到 output_name 變量中。

在接下來的 step 中,可以通過 ${{ steps.step_id.outputs.output_name }} 的方式引用上述的輸出值。

上節中上傳 asset 的 job 中就使用了上述的方式來傳遞文件名稱。一個步驟可以通過多次執行上述命令來設置多個輸出。

set-env

set-output 一樣,可以為後續的步驟設置環境變量。語法: echo "::set-env name={name}::{value}"

add-path

將某路徑加入到 PATH 變量中,為後續步驟使用。語法: echo "::add-path::{path}"

Self-Hosted Runner

除了 GitHub 官方託管的 runner 之外,Action 還允許使用線下自己的機器作為 Runner 來跑 Action 的 job。在機器上安裝好 Action Runner 之後,按照教程,將其註冊到項目後,在 workflow 文件中通過配置 runs-on: self-hosted 即可使用。

self-hosted 的機器可以打上不同的 label,這樣便可以通過不同的標籤來將任務分發到特定的機器上。比如線下的機器安裝有不同的操作系統,那麼 job 就可以根據 runs-on 的 label 在特定的機器上運行。 self-hosted 也是一個特定的標籤。

用GitHub Action搭建一套CI/CD系統 6

安全

GitHub 官方是不推薦開源項目使用 Self-Hosted 的 runner 的,原因是任何人都可以通過提交 PR 的方式,讓 runner 的機器運行危險的代碼對其所在的環境進行攻擊。

但是 Nebula Graph 的編譯需要的存儲空間較大,且 GitHub 只能提供 2 核的環境來編譯,不得已還是選擇了自建 Runner。考慮到安全的因素,進行瞭如下方面的安全加固:

虛擬機部署

所有註冊到 GitHub Action 的 runner 都是採用虛擬機部署,跟宿主機做好第一層的隔離,也更方便對每個虛擬機做資源分配。一台高配置的宿主機可以分配多個虛擬機讓 runner 來並行地跑所有收到的任務。

如果虛擬機出了問題,可以方便地進行環境復原的操作。

網絡隔離

將所有 runner 所在的虛擬機隔離在辦公網絡之外,使其無法直接訪問公司內部資源。即便有人通過 PR 提交了惡意代碼,也讓其無法訪問公司內部網絡,造成進一步的攻擊。

Action 選擇

盡量選擇大廠和官方發布的 action,如果是使用個人開發者的作品,最好能檢視一下其具體實現代碼,免得出現網上爆出來的洩漏隱私密鑰等事情發生。

比如 GitHub 官方維護的 action 列表:https://github.com/actions

私鑰校驗

GitHub Action 會自動校驗 PR 中是否使用了一些私鑰,除卻 GITHUB_TOKEN 之外的其他私鑰(通過 ${{ secrets.MY_TOKENS }} 形式引用)均是不可以在 PR 事件觸發的相關任務中使用,以防用戶通過 PR 的方式私自打印輸出竊取密鑰。

環境搭建與清理

對於自建的runner,在不同任務(job)之間做文件共享是方便的,但是最後不要忘記每次在整個action 執行結束後,清理產生的中間文件,不然這些文件有可能會影響接下來的任務執行和不斷地佔用磁盤空間。

      - name: Cleanup
        if: always()
        run: rm -rf build

將 step 的運行條件設置為 always() 確保每次任務都要執行該步驟,即便中途出錯。

基於 Docker 的 Matrix 並行構建

因為 Nebula Graph 需要在不同的系統上做編譯驗證,在構建方式上採用了容器的方案,原因是構建時不同環境的隔離簡單方便,GitHub Action 可以原生支持基於 docker 的任務。

Action 支持 matrix 策略運行任務的方式,類似於 TravisCI 的 build matrix。通過配置不同系統和編譯器的組合,我們可以方便地設置在每個系統下使用 gccclang 來同時編譯 nebula 的源碼,如下所示:

jobs:
  build:
    name: build
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        os:
          - centos6
          - centos7
          - ubuntu1604
          - ubuntu1804
        compiler:
          - gcc-9.2
          - clang-9
        exclude:
          - os: centos7
            compiler: clang-9

上述的 strategy 會生成 8 個並行的任務(4 os x 2 compiler),每個任務都是(os, compiler)的一個組合。這種類似矩陣的表達方式,可以極大的減少不同緯度上的任務組合的定義。

如果想排除 matrix 中的某個組合,只要將組合的值配置到 exclude 選項下面即可。如果想在任務中訪問 matrix 中的值,也只要通過類似 ${{ matrix.os }} 獲取上下文變量值的方式拿到。這些方式讓你定制自己的任務時都變得十分方便。

運行時容器

我們可以為每個任務指定運行時的一個容器環境,這樣該任務下的所有步驟(steps)都會在容器的內部環境中執行。相較於在每個步驟中都套用 docker 命令要簡潔明了。

    container:
      image: vesoft/nebula-dev:${{ matrix.os }}
      env:
        CCACHE_DIR: /tmp/ccache/${{ matrix.os }}-${{ matrix.compiler }}

對容器的配置,像在 docker compose 中配置 service 一樣,可以指定 image/env/ports/volumes/options 等等參數。在 self-hosted 的 runner 中,可以方便地將宿主機上的目錄掛載到容器中做文件的共享。

正是基於 Action 上面的容器特性,才方便的在 docker 內做後續編譯的緩存加速。

編譯加速

Nebula Graph 的源碼採用 C++ 編寫,整個構建過程時間較長,如果每次 CI 都完全地重新開始,會浪費許多計算資源。因為每台 runner 跑的(容器)任務不定,需要對每個源文件及對應的編譯過程進行精準判別才能確認該源文件是否真的被修改。目前使用最新版本的 ccache 來完成緩存的任務。

雖然 GitHub Action 本身提供 cache 的功能,由於 Nebula Graph 目前單元測試的用例採用靜態鏈接,編譯後體積較大,超出其可用的配額,遂使用本地緩存的策略。

ccache

ccache 是個編譯器的緩存工具,可以有效地加速編譯的過程,同時支持 gcc/clang 等編譯器。 Nebula Graph 使用 C++ 14 標準,低版本的 ccache 在兼容性上有問題,所以在所有的 vesoft/nebula-dev 鏡像中都採用手動編譯的方式安裝。

Nebula Graph 在 cmake 的配置中自動識別是否安裝了 ccache,並決定是否對其打開啟用。所以只要在容器環境中對 ccache 做些配置即可,比如在 ccache.conf 中配置其最大緩存容量為 1 G,超出後自動替換較舊緩存。

max_size = 1.0G

ccache.conf 配置文件最好放置在緩存目錄下,這樣 ccache 可方便讀取其中內容。

tmpfs

tmpfs 是位於內存或者swap 分區的臨時文件系統,可以有效地緩解磁盤IO 帶來的延遲,因為self-hosted 的主機內存足夠,所以將ccache 的目錄掛載類型改為tmpfs,來減少ccache 讀寫時間。在 docker 中使用 tmpfs 的掛載類型可以參考相應文檔。相應的配置參數如下:

    env:
      CCACHE_DIR: /tmp/ccache/${{ matrix.os }}-${{ matrix.compiler }}
    options: --mount type=tmpfs,destination=/tmp/ccache,tmpfs-size=1073741824 -v /tmp/ccache/${{ matrix.os }}-${{ matrix.compiler }}:/tmp/ccache/${{ matrix.os }}-${{ matrix.compiler }} 

將所有 cca​​che 產生的緩存文件,放置到掛載為 tmpfs 類型的目錄下。

並行編譯

make 本身即支持多個源文件的並行編譯,在編譯時配置 -j $(nproc) 便可同時啟動與核數相同的任務數。在 action 的 steps 中配置如下:

      - name: Make
        run: cmake --build build/ -j $(nproc)

說了那麼多的優點,那有沒有不足呢?使用下來主要體會到如下幾點:

  1. 只支持較新版本的系統。很多 Action 是基於較新的 Nodejs 版本開發,沒法方便地在類似 CentOS 6 等老版本 docker 容器中直接使用。否則會報 Nodejs 依賴的庫文件找不到,從而無法正常啟動 action 的執行。因為 Nebula Graph 希望可以支持 CentOS 6,所以在該系統下的任務不得不需要特殊處理。

  2. 不能方便地進行本地驗證。雖然社區有個開源項目 act,但使用下來還是有諸多限制,有時不得不通過在自己倉庫中反复提交驗證才能確保 action 的修改正確。

  3. 目前還缺少比較好的指導規範,當定制的任務較多時,總有種在 YAML 配置中寫程序的感受。目前的做法主要有以下三種:

    1. 根據任務拆分配置文件。
    2. 定制專屬 action,通過 GitHub 的 SDK 來實現想要的功能。
    3. 編寫大的 shell 腳本來完成任務內容,在任務中調用該腳本。

目前針對盡量多使用小任務的組合還是使用大任務的方式,社區也沒有定論。不過小任務組合的方式可以方便地定位任務失敗位置以及確定每步的執行時間。

用GitHub Action搭建一套CI/CD系統 7

  1. Action 的一些歷史記錄目前無法清理,如果中途更改了 workflows 的名字,那麼老的 check runs 記錄還是會一直保留在 Action 頁面,影響使用體驗。

  2. 目前還缺少像 GitLab CI 中手動觸發 job/task 運行的功能。無法運行中間進行人工干預。

  3. action 的開發也在不停的迭代中,有時需要維護一下新版的升級,比如:[email protected]

不過總體來說,GitHub Action 是一個相對優秀的 CI/CD 系統,畢竟站在 GitLab CI/Travis CI 等前人肩膀上的產品,還是有很多經驗可以藉鑑使用。

後續

定制 Action

前段時間 docker 發布了自己的第一款 Action,簡化用戶與 docker 相關的任務。後續,針對 Nebula Graph 的一些 CI/CD 的複雜需求,我們亦會定制一些專屬的 action 來給 nebula 的所有 repo 使用。通用的就會創建獨立的 repo,發佈到 action 市場裡,比如追加 assets 到 release 功能。專屬的就可以放置 repo 的 .github/actions 目錄下。

這樣就可以簡化 workflows 中的 YAML 配置,只要 use 某個定制 action 即可。靈活性和拓展性都更優。

跟釘釘/slack 等 IM 集成

通過 GitHub 的 SDK 可以開發複雜的 action 應用,再結合釘釘/slack 等 bot 的定制,可以實現許多自動化的有意思的小應用。比如,當一個 PR 被 2 個以上的 reviewer approve 並且所有的 check runs 都通過,那麼就可以向釘釘群裡發消息並 @ 一些人讓其去 merge 該 PR。免去了每次都去 PR list 裡面 check 每個 PR 狀態的辛苦。

當然圍繞 GitHub 的周邊通過一些 bot 還可以迸發許多有意思的玩法。

本文中如有任何錯誤或疏漏歡迎去 GitHub:https://github.com/vesoft-inc/nebula issue 區向我們提 issue 或者前往官方論壇:https://discuss.nebula-graph.com.cn/ 的“建議反饋”分類下提建議。

作者簡介

Yee,圖數據 Nebula Graph 研發工程師,對數據庫查詢引擎有濃厚的興趣。