用 GitHub Action 構建一套 CI/CD 系統
阿新 • • 發佈:2020-05-08
![image](https://nebula-blog.azureedge.net/nebula-blog/auto01.jpeg)
## 緣起
Nebula Graph 最早的自動化測試是使用搭建在 Azure 上的 [Jenkins](https://jenkins.io/zh/),配合著 GitHub 的 Webhook 實現的,在使用者提交 Pull Request 時,加個 `ready-for-testing` 的 label 再評論一句 `Jenkins go` 就可以自動的執行相應的 UT 測試,效果如下:
![image](https://nebula-blog.azureedge.net/nebula-blog/auto02.png)
因為是租用的 Azure 的雲主機,加上 nebula 的編譯要求的機器配置較高,而且任務的觸發主要集中在白天。所以上述的方案價效比較低,從去年團隊就在考慮尋找替代的方案,準備下線 Azure 上的測試機,並且還要能提供多環境的測試方案。
調研了一圈現有的產品主要有:
1. TravisCI
1. CircleCI
1. Azure Pipeline
1. Jenkins on k8s(自建)
雖然上面的產品對開源專案有些限制,但整體都還算比較友好。
鑑於之前 GitLab CI 的使用經驗,體會到如果能跟 GitHub 深度整合那當然是首選。所謂“深度”表示可以共享 GitHub 的整個開源的生態以及完美的 API 呼叫(後話)。恰巧 2019,GitHub Action 2.0 橫空出世,Nebula Graph 便勇敢的入了坑。
這裡簡單概述一下我們在使用 GitHub Action 時體會到的優點:
1. 免費。開源專案可以免費使用 Action 的所有功能,而且機器[配置較高](https://help.github.com/en/actions/reference/virtual-environments-for-github-hosted-runners#supported-runners-and-hardware-resources)。
1. 開源生態好。在整個 CI 的流程裡,可以直接使用 GitHub 上的所有開源的 Action,哪怕就是沒有滿足需求的 Action,自己上手寫也不是很麻煩,而且還支援 docker 定製,用 bash 就可以完成一個專屬的 Action。
1. 支援多種系統。Windows、macOS 和 Linux 都可以一鍵使用,跨平臺簡單方便。
1. 可跟 GitHub 的 API 互動。通過 `GITHUB_TOKEN` 可以直接訪問 [GitHub API V3](https://developer.github.com/v3/),想上傳檔案,檢查 PR 狀態,使用 curl 命令即可完成。
1. 自託管。只要提供 workflow 的描述檔案,將其放置到 `.github/workflows/` 目錄下,每次提交便會自動觸發執行新的 action run。
1. Workflow 描述檔案改為 YAML 格式。目前的描述方式要比 Action 1.0 中的 workflow 檔案更加簡潔易讀。
下面在講實踐之前還是要先講講 Nebula Graph 的需求:首要目標比較明確就是自動化測試。
作為資料庫產品,測試怎麼強調也不為過。Nebula Graph 的測試主要分單元測試和整合測試。用 GitHub Action 其實主要瞄準的是單元測試,然後再給整合測試做些準備,比如 docker 映象構建和安裝程式打包。順帶再解決一下 PM 小姐姐的釋出需求,就整個構建起來了第一版的 CI/CD 流程。
## PR 測試
Nebula Graph 作為託管在 GitHub 上的開源專案,首先要解決的測試問題就是當貢獻者提交了 PR 請求後,如何才能快速地進行變更驗證?主要有以下幾個方面。
1. 符不符合編碼規範;
1. 能不能在不同系統上都編譯通過;
1. 單測有沒有失敗;
1. 程式碼覆蓋率有沒有下降等。
只有上述的要求全部滿足並且有至少兩位 reviewer 的同意,變更才能進入主幹分支。
藉助於 cpplint 或者 clang-format 等開源工具可以比較簡單地實現要求 1,如果此要求未通過驗證,後面的步驟就自動跳過,不再繼續執行。
對於要求 2,我們希望能同時在目前支援的幾個系統上執行 Nebula 原始碼的編譯驗證。那麼像之前在物理機上直接構建的方式就不再可取,畢竟一臺物理機的價格已經高昂,何況一臺還不足夠。為了保證編譯環境的一致性,還要儘可能的減少機器的效能損失,最終採用了 docker 的容器化構建方式。再借助 Action 的 [matrix 執行策略](https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstrategymatrix)和對 [docker 的支援](https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idcontainer),還算順利地將整個流程走通。
![image](https://nebula-blog.azureedge.net/nebula-blog/auto03.svg)
執行的大概流程如上圖所示,在 [vesoft-inc/nebula-dev-docker](https://github.com/vesoft-inc/nebula-dev-docker) 專案中維護 nebula 編譯環境的 docker 映象,當編譯器或者 thirdparty 依賴升級變更時,自動觸發 docker hub 的 Build 任務(見下圖)。當新的 Pull Request 提交以後,Action 便會被觸發開始拉取最新的編譯環境映象,執行編譯。
![image](https://nebula-blog.azureedge.net/nebula-blog/auto04.png)
針對 PR 的 workflow 完整描述見檔案 [pull_request.yaml](https://github.com/vesoft-inc/nebula/blob/master/.github/workflows/pull_request.yaml)。同時,考慮到並不是每個人提交的 PR 都需要立即執行 CI 測試,且自建的機器資源有限,對 CI 的觸發做了如下限制:
1. 只有 lint 校驗通過的 PR 才會將後續的 job 下發到自建的 runner,lint 的任務比較輕量,可以使用 GitHub Action 託管的機器來執行,無需佔用線下的資源。
1. 只有添加了 `ready-for-testing` label 的 PR 才會觸發 action 的執行,而 label 的新增有許可權的控制。進一步優化 runner 被隨意觸發的情況。對 label 的限制如下所示:
```yaml
jobs:
lint:
name: cpplint
if: contains(join(toJson(github.event.pull_request.labels.*.name)), 'ready-for-testing')
```
在 PR 中執行完成後的效果如下所示:
![image](https://nebula-blog.azureedge.net/nebula-blog/auto05.png)
Code Coverage 的說明見博文:[圖資料庫 Nebula Graph 的程式碼變更測試覆蓋率實踐](https://nebula-graph.io/cn/posts/integrate-codecov-test-coverage-with-nebula-graph/)。
## Nightly 構建
在 Nebula Graph 的整合測試框架中,希望能夠在每天晚上對 codebase 中的程式碼全量跑一遍所有的測試用例。同時有些新的特性,有時也希望能快速地打包交給使用者體驗使用。這就需要 CI 系統能在每天給出當日程式碼的 docker 映象和 rpm/deb 安裝包。
GitHub Action 被觸發的事件型別除了 pull_request,還可以執行 [schedule](https://help.github.com/en/actions/reference/events-that-trigger-workflows#scheduled-events-schedule) 型別。schedule 型別的事件可以像 crontab 一樣,讓使用者指定任何重複任務的觸發時間,比如每天凌晨兩點執行任務如下所示:
```yaml
on:
schedule:
- cron: '0 18 * * *'
```
因為 GitHub 採用的是 UTC 時間,所以東八區的凌晨 2 點,就對應到 UTC 的前日 18 時。
### docker
每日構建的 docker 映象需要 push 到 docker hub 上,並打上 nightly 的標籤,整合測試的 k8s 叢集,將 image 的拉取策略設定為 Always,每日觸發便能滾動升級到當日最新進行測試。因為當日的問題目前都會盡量當日解決,便沒有再給 nightly 的映象再額外打一個日期的 tag。對應的 action 部分如下所示:
```yaml
- 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](https://help.github.com/en/actions/configuring-and-managing-workflows/persisting-workflow-data-using-artifacts) 的功能,可以讓使用者持久化 workflow 執行過程中的資料,這些資料可以保留 90 天。對於 nightly 版本安裝包的儲存而言,已經綽綽有餘。利用官方提供的 `actions/upload-artifact@v1` action,可以方便的將指定目錄下的檔案上傳到 artifacts。最後 nightly 版本的 nebula 的安裝包如下圖所示。
![image](https://nebula-blog.azureedge.net/nebula-blog/auto06.png)
上述完整的 workflow 檔案見 [package.yaml](https://github.com/vesoft-inc/nebula/blob/master/.github/workflows/package.yaml)
## 分支釋出
為了更好地維護每個釋出的版本和進行 bugfix,Nebula Graph 採用分支釋出的方式。即每次釋出之前進行 code freeze,並建立新的 release 分支,在 release 分支上只接受 bugfix,而不進行 feature 的開發。bugfix 還是會在開發分支上提交,最後 cherrypick 到 release 分支。
在每次 release 時,除了 source 外,我們希望能把安裝包也追加到 assets 中方便使用者直接下載。如果每次都手工上傳,既容易出錯,也非常耗時。這就比較適合 Action 來自動化這塊的工作,而且,打包和上傳都走 GitHub 內部網路,速度更快。
在安裝包編譯好後,通過 curl 命令直接呼叫 GitHub 的 API,就能上傳到 assets 中,[具體指令碼](https://github.com/vesoft-inc/nebula/blob/master/ci/scripts/upload-github-release-asset.sh)內容如下所示:
```bash
curl --silent \
--request POST \
--url "$upload_url?name=$filename" \
--header "authorization: Bearer $github_token" \
--header "content-type: $content_type" \
--data-binary @"$filepath"
```
同時,為了安全起見,在每次的安裝包釋出時,希望可以計算安裝包的 checksum 值,並將其一同上傳到 assets 中,以便使用者下載後進行完整性校驗。具體步驟如下所示:
```yaml
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/checkout@v1
- 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](https://github.com/vesoft-inc/nebula/blob/master/.github/workflows/release.yaml)。
## 命令
GitHub Action 為 workflow 提供了一些[命令](https://help.github.com/en/actions/reference/workflow-commands-for-github-actions)方便在 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 之後,按照[教程](https://help.github.com/en/actions/hosting-your-own-runners/adding-self-hosted-runners),將其註冊到專案後,在 workflow 檔案中通過配置 `runs-on: self-hosted` 即可使用。
self-hosted 的機器可以打上不同的 label,這樣便可以通過[不同的標籤](https://help.github.com/en/actions/hosting-your-own-runners/using-labels-with-self-hosted-runners)來將任務分發到特定的機器上。比如線下的機器安裝有不同的作業系統,那麼 job 就可以根據 `runs-on` 的 label [在特定的機器](https://help.github.com/en/actions/hosting-your-own-runners/using-self-hosted-runners-in-a-workflow)上執行。 `self-hosted` 也是一個特定的標籤。
![image](https://nebula-blog.azureedge.net/nebula-blog/auto07.png)
### 安全
GitHub 官方是不推薦開源專案使用 Self-Hosted 的 runner 的,原因是任何人都可以通過提交 PR 的方式,讓 runner 的機器執行危險的程式碼對其所在的環境進行攻擊。
但是 Nebula Graph 的編譯需要的儲存空間較大,且 GitHub 只能提供 2 核的環境來編譯,不得已還是選擇了自建 Runner。考慮到安全的因素,進行了如下方面的安全加固:
#### 虛擬機器部署
所有註冊到 GitHub Action 的 runner 都是採用虛擬機器部署,跟宿主機做好第一層的隔離,也更方便對每個虛擬機器做資源分配。一臺高配置的宿主機可以分配多個虛擬機器讓 runner 來並行地跑所有收到的任務。
如果虛擬機器出了問題,可以方便地進行環境復原的操作。
#### 網路隔離
將所有 runner 所在的虛擬機器隔離在辦公網路之外,使其無法直接訪問公司內部資源。即便有人通過 PR 提交了惡意程式碼,也讓其無法訪問公司內部網路,造成進一步的攻擊。
#### Action 選擇
儘量選擇大廠和官方釋出的 action,如果是使用個人開發者的作品,最好能檢視一下其具體實現程式碼,免得出現網上爆出來的[洩漏隱私金鑰](https://julienrenaux.fr/2019/12/20/github-actions-security-risk/)等事情發生。
比如 GitHub 官方維護的 action 列表:[https://github.com/actions](https://github.com/actions)。
#### 私鑰校驗
GitHub Action 會自動校驗 PR 中是否使用了一些私鑰,除卻 `GITHUB_TOKEN` 之外的其他私鑰(通過 `${{ secrets.MY_TOKENS }}` 形式引用)均是不可以在 PR 事件觸發的相關任務中使用,以防使用者通過 PR 的方式私自列印輸出竊取金鑰。
### 環境搭建與清理
對於自建的 runner,在不同任務(job)之間做檔案共享是方便的,但是最後不要忘記每次在整個 action 執行結束後,清理產生的中間檔案,不然這些檔案有可能會影響接下來的任務執行和不斷地佔用磁碟空間。
```yaml
- name: Cleanup
if: always()
run: rm -rf build
```
將 step 的執行條件設定為 `always()` 確保每次任務都要執行該步驟,即便中途出錯。
### 基於 Docker 的 Matrix 並行構建
因為 Nebula Graph 需要在不同的系統上做編譯驗證,在構建方式上採用了容器的方案,原因是構建時不同環境的隔離簡單方便,GitHub Action 可以原生支援基於 docker 的任務。
Action 支援 matrix 策略執行任務的方式,類似於 TravisCI 的 [build matrix](https://docs.travis-ci.com/user/build-matrix/)。通過配置不同系統和編譯器的組合,我們可以方便地設定在每個系統下使用 `gcc` 和 `clang` 來同時編譯 nebula 的原始碼,如下所示:
```yaml
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 命令要簡潔明瞭。
```yaml
container:
image: vesoft/nebula-dev:${{ matrix.os }}
env:
CCACHE_DIR: /tmp/ccache/${{ matrix.os }}-${{ matrix.compiler }}
```
對容器的配置,像在 docker compose 中配置 service 一樣,可以指定 image/env/ports/volumes/options 等等[引數](https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idcontainer)。在 self-hosted 的 runner 中,可以方便地將宿主機上的目錄掛載到容器中做檔案的共享。
正是基於 Action 上面的容器特性,才方便的在 docker 內做後續編譯的快取加速。
## 編譯加速
Nebula Graph 的原始碼採用 C++ 編寫,整個構建過程時間較長,如果每次 CI 都完全地重新開始,會浪費許多計算資源。因為每臺 runner 跑的(容器)任務不定,需要對每個原始檔及對應的編譯過程進行精準判別才能確認該原始檔是否真的被修改。目前使用最新版本的 [ccache](https://ccache.dev/) 來完成快取的任務。
雖然 GitHub Action 本身提供 [cache 的功能](https://help.github.com/en/actions/configuring-and-managing-workflows/caching-dependencies-to-speed-up-workflows),由於 Nebula Graph 目前單元測試的用例採用靜態連結,編譯後體積較大,超出其可用的配額,遂使用本地快取的策略。
### ccache
[ccache](https://ccache.dev/) 是個編譯器的快取工具,可以有效地加速編譯的過程,同時支援 gcc/clang 等編譯器。Nebula Graph 使用 C++ 14 標準,低版本的 ccache 在相容性上有問題,所以在所有的 `vesoft/nebula-dev` [映象](https://github.com/vesoft-inc/nebula-dev-docker)中都採用手動編譯的方式安裝。
Nebula Graph 在 cmake 的配置中自動識別是否安裝了 ccache,並決定是否對其開啟啟用。所以只要在容器環境中對 ccache 做些配置即可,比如在[ ccache.conf ](https://github.com/vesoft-inc/nebula/blob/master/ci/ccache.conf)中配置其最大快取容量為 1 G,超出後自動替換較舊快取。
```yaml
max_size = 1.0G
```
ccache.conf 配置檔案最好放置在快取目錄下,這樣 ccache 可方便讀取其中內容。
### tmpfs
tmpfs 是位於記憶體或者 swap 分割槽的臨時檔案系統,可以有效地緩解磁碟 IO 帶來的延遲,因為 self-hosted 的主機記憶體足夠,所以將 ccache 的目錄掛載型別改為 tmpfs,來減少 ccache 讀寫時間。在 docker 中使用 tmpfs 的掛載型別可以參考[相應文件](https://docs.docker.com/storage/tmpfs/)。相應的配置引數如下:
```yaml
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 }}
```
將所有 ccache 產生的快取檔案,放置到掛載為 tmpfs 型別的目錄下。
### 並行編譯
make 本身即支援多個原始檔的並行編譯,在編譯時配置 `-j $(nproc)` 便可同時啟動與核數相同的任務數。在 action 的 steps 中配置如下:
```yaml
- name: Make
run: cmake --build build/ -j $(nproc)
```
## 坑
說了那麼多的優點,那有沒有不足呢?使用下來主要體會到如下幾點:
1. 只支援較新版本的系統。很多 Action 是基於較新的 Nodejs 版本開發,沒法方便地在類似 CentOS 6 等老版本 docker 容器中直接使用。否則會報 Nodejs 依賴的庫檔案找不到,從而無法正常啟動 action 的執行。因為 Nebula Graph 希望可以支援 CentOS 6,所以在該系統下的任務不得不需要特殊處理。
2. 不能方便地進行本地驗證。雖然社群有個開源專案 [act](https://github.com/nektos/act),但使用下來還是有諸多限制,有時不得不通過在自己倉庫中反覆提交驗證才能確保 action 的修改正確。
3. 目前還缺少比較好的指導規範,當定製的任務較多時,總有種在 YAML 配置中寫程式的感受。目前的做法主要有以下三種:
1. 根據任務拆分配置檔案。
1. 定製專屬 action,通過 GitHub 的 SDK 來實現想要的功能。
1. 編寫大的 shell 指令碼來完成任務內容,在任務中呼叫該指令碼。
目前針對儘量多使用小任務的組合還是使用大任務的方式,社群也沒有定論。不過小任務組合的方式可以方便地定位任務失敗位置以及確定每步的執行時間。
![image](https://nebula-blog.azureedge.net/nebula-blog/auto08.png)
4. Action 的一些歷史記錄目前無法清理,如果中途更改了 workflows 的名字,那麼老的 check runs 記錄還是會一直保留在 Action 頁面,影響使用體驗。
5. 目前還缺少像 GitLab CI 中手動觸發 job/task 執行的功能。無法執行中間進行人工干預。
6. action 的開發也在不停的迭代中,有時需要維護一下新版的升級,比如:[checkout@v2](https://github.com/actions/checkout/issues/23)
不過總體來說,GitHub Action 是一個相對優秀的 CI/CD 系統,畢竟站在 GitLab CI/Travis CI 等前人肩膀上的產品,還是有很多經驗可以借鑑使用。
## 後續
### 定製 Action
前段時間 [docker 釋出了自己的第一款 Action](https://www.docker.com/blog/first-docker-github-action-is-here/),簡化使用者與 docker 相關的任務。後續,針對 Nebula Graph 的一些 CI/CD 的複雜需求,我們亦會定製一些專屬的 action 來給 nebula 的所有 repo 使用。通用的就會建立獨立的 repo,釋出到 action 市場裡,比如追加 assets 到 release 功能。專屬的就可以放置 repo 的 `.github/actions` 目錄下。
這樣就可以簡化 workflows 中的 YAML 配置,只要 use 某個定製 action 即可。靈活性和拓展性都更優。
### 跟釘釘/slack 等 IM 整合
通過 GitHub 的 SDK 可以開發複雜的 action 應用,再結合[釘釘](https://ding-doc.dingtalk.com/doc#/serverapi2/qf2nxq)/slack 等 bot 的定製,可以實現許多自動化的有意思的小應用。比如,當一個 PR 被 2 個以上的 reviewer approve 並且所有的 check runs 都通過,那麼就可以向釘釘群裡發訊息並 @ 一些人讓其去 merge 該 PR。免去了每次都去 PR list 裡面 check 每個 PR 狀態的辛苦。
當然圍繞 GitHub 的周邊通過一些 bot 還可以迸發許多有意思的玩法。
## One More Thing...
~~圖資料庫 Nebula Graph 1.0 GA 快要釋出啦。歡迎大家來圍觀。~~
本文中如有任何錯誤或疏漏歡迎去 GitHub:[https://github.com/vesoft-inc/nebula](https://0x7.me/icnblog2github) issue 區向我們提 issue 或者前往官方論壇:[https://discuss.nebula-graph.com.cn/](https://discuss.nebula-graph.com.cn/) 的 `建議反饋` 分類下提建議