開個腦洞,帶你寫一個自己的極狐GitLab CI Runner

極狐GitLab發表於2022-12-29

極狐GitLab Runner 是極狐GitLab CI/CD 執行的利器,能夠幫助完成 CI/CD Pipeline Job 的執行。

目前極狐GitLab Runner 是一個開源專案(https://jihulab.com/gitlab-cn...),以 Golang 編寫。

極狐Gitlab 有個不錯的特性,就是你可以使用自己的極狐Gitlab CI Runner。可是,如果你沒有自己的CI Runner該怎麼辦呢?別擔心,我們可以自己寫一個。[]~( ̄▽ ̄)~*

在這篇文章裡,我們會:

  • 闡述極狐GitLab Runner的核心任務;
  • 分析Runner工作時和極狐GitLab的互動內容;
  • 設計和實施一個我們自己的Runner;
  • 讓我們的Runner執行自己的CI工作;
  • 埋一個彩蛋!

當然,如果你習慣直接看程式碼,歡迎訪問極狐GitLab倉庫。如果喜歡,歡迎留個star。

Here we go!

明確核心任務

打蛇打七寸,極狐GitLab Runner最核心的任務是這些:

  1. 從極狐GitLab拉取工作;
  2. 獲取工作後,準備一個獨立隔離可重複的環境;
  3. 在環境中執行工作,上傳執行日誌;
  4. 在工作完成/異常退出後上報執行結果(成功/失敗)。
    我們DIY的Runner同樣要完成這些任務。

接下來我們按順序捋一捋各個核心任務,同時觀察Runner是怎麼和極狐GitLab互動的。

為了行文簡明,下文的API請求和返回的內容有所精簡。

一、 註冊

如果你用過自託管的極狐GitLab Runner,你應該熟悉這個頁面:

使用者在這個頁面獲取註冊token,然後透過gitlab-runner register命令把Runner例項註冊到極狐GitLab。
這個註冊過程本質上是在呼叫介面POST /api/v4/runners,其body形如:

{
   "description": "一段使用者自己提供的描述",
   "info": {
       "architecture": "amd64", # runner的架構
       "features": { # runner具備的特性,極狐GitLab可能會拒絕不具備某些特性的runner註冊
         "trace_checksum": true, # 是否支援計算上傳日誌的checksum
         "trace_reset": true,
         "trace_size": true
       },
       "name": "gitlab-runner",
       "platform": "linux",
       "revision": "f98d0f26",
       "version": "15.2.0~beta.60.gf98d0f26"
   },
   "locked": true,
   "maintenance_note": "使用者提供的維護備註",
   "paused": false,
   "run_untagged": true,
   "token": "my-registration-token" #極狐GitLab提供的註冊token
}

如果註冊token無效,極狐GitLab會返回403 Forbidden。在成功註冊時會返回:

{
   "id": 2, # Runner在極狐GitLab這邊的全域性編號
   "token": "bKzi84WitiHSN4N4TYU6", # runner的鑑權token
   "token_expires_at": null # 據我觀察,這個欄位對應的功能沒有做
}

Runner只關心其中的token,它代表了runner的身份,同時作為共享金鑰參與後面的API呼叫的鑑權。這個token會連同其他設定被儲存到檔案~/.gitlab-runner/config.toml中。

二、拉取工作

Runner在設定中有個最大並行工作數,在目前執行的工作數目小於設定值時,它會輪詢POST /api/v4/jobs/request以獲取工作,傳入的body很像註冊時的body,形如:

{
   "info": {
       "architecture": "amd64", # runner的架構
       "executor": "docker", # runner使用的執行器
       "features": { # runner具備的特性,例如,如果一個runner不支援上傳產物,那麼需要上傳產物的工作就不會排程到它身上。
           "artifacts": true,
           "artifacts_exclude": true,
           "cache": true,
           "cancelable": true,
           "image": true
       },
       "name": "gitlab-runner",
       "platform": "linux",
       "revision": "f98d0f26",
       "shell": "bash",
       "version": "15.2.0~beta.60.gf98d0f26"
   },
   "last_update": "d8a43f53bb125ec6599d778b9969a601", # 遊標
   "token": "bKzi84WitiHSN4N4TYU6" # 前面註冊時拿到的token
}

如果沒有要執行的工作,極狐GitLab會返回狀態碼204 No Content,Header中會有遊標,形如X-Gitlab-Last-Update: 2794e577289a38db0df0e93e3215f597,供下次請求傳入。

遊標其實是個隨機字串,請求進入極狐GitLab的前置代理(名為Workhorse)時,代理會檢查Runner提交的遊標是否和Redis中的遊標一致,如果一致就讓Runner等著(long poll),不一致就把請求原樣代理到極狐GitLab後端。Redis中的遊標的更新由後端維護,在變更時會透過Redis Pub/Sub通知到Workhorse. 工作的選取在後端實現為一個複雜的SQL查詢。

在有新工作需要執行時,極狐GitLab會返回201 Created,其body形如:

{
   "allow_git_fetch": true,
   "artifacts": null, # 要上傳的產物
   "cache": [], # 要使用的快取
   "credentials": [
       {
           "password": "jTruJD4xwEtAZo1hwtAp", # 用來拉取程式碼、上傳日誌、上報執行結果的通用金鑰
           "type": "registry",
           "url": "gitlab.example.com",
           "username": "gitlab-ci-token" # 使用者名稱是固定的
       }
   ],
   "dependencies": [],
   "features": {
       "failure_reasons": [ # 服務端可接受的工作錯誤原因
           "unknown_failure",
           "script_failure"
       ]
   },
   "git_info": {
       "before_sha": "6b55b6ffd17b57a2ec0cf8e7d7c66ff709343528",
       "depth": 20, # 克隆深度
       "ref": "master", # 目標分支/tag
       "ref_type": "branch",
       "refspecs": [
           "+refs/pipelines/52:refs/pipelines/52",
           "+refs/heads/master:refs/remotes/origin/master"
       ],
       "repo_url": "http://gitlab-ci-token:jTruJD4xwEtAZo1hwtAp@gitlab.example.com/flightjs/Flight.git",
       "sha": "cb4717728e8f885558a4e0bb28c58288b8bf4746" # commit hash
   },
   "id": 823, # 工作id,是後面很多API呼叫的重要引數
   "image": null,
   "job_info": {
       "id": 823,
       "name": "build-job",
       "project_id": 6,
       "project_name": "Flight",
       "stage": "build"
   },
   "services": [],
   "steps": [ # 要執行的指令碼
       {
           "allow_failure": false,
           "name": "script",
           "script": [ # 指令碼內容,每項對應 .gitlab-ci.yml 中的一個陣列元素
               "echo \"sleeping 1\"",
               "sleep 5",
               "echo \"sleeping 2\"",
               "sleep 5"
           ],
           "timeout": 3600, # 指令碼最大執行超時
           "when": "on_success"
       }
   ],
   "token": "jTruJD4xwEtAZo1hwtAp", # job憑據,用來鑑權後面的API呼叫
   "variables": [ # job執行時的環境變數,使用者自己定義的環境變數也會放在這裡
       {
           "key": "CI_JOB_ID",
           "masked": false,
           "public": true,
           "value": "823"
       },
       {
           "key": "CI_JOB_URL",
           "masked": false,
           "public": true,
           "value": "http://gitlab.example.com/flightjs/Flight/-/jobs/823"
       },
       {
           "key": "CI_JOB_TOKEN",
           "masked": true,
           "public": false,
           "value": "jTruJD4xwEtAZo1hwtAp"
       }
   ]
}

三、準備環境和克隆倉庫

為了讓CI的執行穩定、可重複,Runner執行的環境需要一定程度的隔離,執行環境的準備、指令碼的執行由Executor負責,聊幾個常見的:

  • Shell:

    • 好處:除錯容易,易於理解。
    • 壞處:隔離級別很低,只提供基於檔案目錄的隔離,專案依賴、可用埠會在CI job之間相互影響。
  • Docker或k8s:

    • 好處:除了作業系統核心,其他資源都隔離了;映象生態豐富,CI job可重複性高。
    • 壞處:不適用於不可信的工作負載。
  • VirtualBox或Docker Machine:

    • 好處:作業系統級別的隔離,安全性高。
    • 壞處:挺重的,拖累CI執行效率。

所有的Executor都提供必須的API供極狐GitLab Runner呼叫:

  • 準備環境;
  • 執行Runner提供的指令碼,獲取執行時的輸出,返回執行結果(這個API會被呼叫多次);
  • 清理環境。

克隆倉庫其實就是在環境中執行一個git clone,所需引數在上一步“拉取工作”中獲得:

git clone -b [分支/tag名] --single-branch --depth [克隆深度] https://gitlab-ci-token:job-token@gitlab.example.com/user/repo.git [克隆目的地資料夾名稱]

四、執行工作和上傳日誌

所有要執行的工作都會被Runner編排成幾個指令碼文字,發給Executor執行,編排時會考慮Executor裡的指令碼執行環境是哪一個(bash/Powershell)。環境變數會放在編排的指令碼最前面,例如對於bash環境,環境變數在指令碼中使用export宣告。

說個有趣的,你在CI log裡看到的,標識為綠色的接下來要執行的語句是Runner在編排指令碼時用echo命令+終端顏色控制符輸出的,類似這樣:

echo -e $'\x1b[32;1m$ date\x1b[0;m' # 列印出綠色的 $ date
date # 真正執行 date 命令

執行器的標準輸出和標準錯誤會被Runner捕獲,存放在/tmp臨時檔案中。job執行結束前,Runner會週期性地呼叫介面PATCH /api/v4/jobs/{job_id}/trace增量上傳日誌,請求的header形如:

Host: gitlab.example.com
User-Agent: gitlab-runner 15.2.0~beta.60.gf98d0f26 (main; go1.18.3; linux/amd64)
Content-Length: 314 # 這個增量的長度
Content-Range: 0-313 # 這個增量在全部日誌中的位置
Content-Type: text/plain
Job-Token: jTruJD4xwEtAZo1hwtAp
Accept-Encoding: gzip

body裡就是這批增量上傳的日誌,本例形如:

\x1b[0KRunning with gitlab-runner 15.2.0~beta.60.gf98d0f26 (f98d0f26)\x1b[0;m
\x1b[0K  on rockgiant-1 bKzi84Wi\x1b[0;m
section_start:1663398416:prepare_executor
\x1b[0K\x1b[0K\x1b[36;1mPreparing the "docker" executor\x1b[0;m\x1b[0;m
\x1b[0KUsing Docker executor with image ubuntu:bionic ...\x1b[0;m
\x1b[0KPulling docker image ubuntu:bionic ...\x1b[0;m

下一次上傳日誌時,新請求的Content-RangeContent-Length的內容同樣會對應請求body的資訊。

極狐GitLab在成功接受請求後會返回202 Accepted,返回的header中有一些有意思的值:

Job-Status: running # job執行狀態
Range: 0-1899 # 當前收到的位元組範圍,每次都是0-n這個形式
X-Gitlab-Trace-Update-Interval: 60 # runner最低上報間隔,單位秒

這裡有一個有意思的最佳化,當CI log頁面有使用者正在觀看時,X-Gitlab-Trace-Update-Interval的值會是3,即Runner應該3秒就增量上報一次日誌,這樣使用者才能更實時地看到最新進展。

五、上報執行結果

在使用者定義的指令碼執行成功或失敗後,Runner會做兩件事:

  • 如果還有沒上傳的日誌,按前述方法將剩餘日誌全部上傳;
  • 呼叫PUT /api/v4/jobs/{job_id}更新job的狀態。

一個成功的job對應的HTTP body形如:

{
   "checksum": "crc32:4a182676", # 所有日誌的CRC32校驗,用來讓服務端確定所有日誌都已經成功上傳
   "info": { ... }, # 這個欄位已經在前面見過很多次了,內容從略
   "output": {
       "bytesize": 1899, # 日誌的總位元組數
       "checksum": "crc32:4a182676" # 同checksum
   },
   "state": "success", # job執行結果
   "token": "jTruJD4xwEtAZo1hwtAp" # job憑據
}

一個失敗的job對應的HTTP body形如:

{
   "checksum": "crc32:f67200bc",
   "exit_code": 42, # 使用者指令碼的退出碼
   "failure_reason": "script_failure", # 錯誤原因,從一個與服務端約定的列表裡選
   "info": { ... }, # 這個欄位已經在前面見過很多次了,內容從略
   "output": {
       "bytesize": 1723,
       "checksum": "crc32:f67200bc"
   },
   "state": "failed", # job執行結果
   "token": "Lx1oBNfw2e9xhZvNKsdX"
}

極狐GitLab後端在成功接受狀態更新請求後會返回200 OK,Runner的工作就結束了。

有時,服務端沒準備好接受狀態更新(日誌的處理是非同步的,還沒落盤),此時會返回202 Accepted,header裡的X-GitLab-Trace-Update-Interval會告知Runner在下次嘗試之前的等待時間(類似指數退避),Runner會一直重發請求,直到服務端返回200 OK或者超過最大重試次數。

整體來看,上述流程是這樣子的:

構建專屬Runner

一、取個名字

我喜歡吃蛋撻,我們就叫我們的DIY Runner “蛋撻” 吧,英文名Tart.

畫個Logo,這樣看上去比較像一個正經專案:

再開啟程式設計祖師孃Ada Lovelace的畫像拜一拜接受祝福,萬事俱備,開工大吉!

二、規劃功能

和極狐GitLab Runner一樣,蛋撻也是個命令列程式,主要功能有:

  • 註冊(register):註冊極狐GitLab runner token,輸出配置檔案到標準輸出,這樣我們可以再把它重定向到檔案裡,還能避(丟)免(給)處(用)理(戶)“什麼時候該覆蓋配置檔案”這樣的玄學問題。
  • 嘗試獲取和執行一個工作(single):監聽工作,執行工作,提交結果,退出。這個命令主要是為了除錯方便。
  • 執行多個工作(run):監聽工作,執行工作,提交結果,重複。

用上spf13/cobra,我們可以很快把命令列本體捏出來:

$ tart
An educational purpose, unofficial Gitlab Runner.
 
Usage:
 tart [command]
 
Available Commands:
 completion  Generate the autocompletion script for the specified shell
 help        Help about any command
 register    Register self to Gitlab and print TOML config into stdout
 run         Listen and run CI jobs
 single      Listen, wait and run a single CI job, then exit
 version     Print version and exit
 
Flags:
     --config string   Path to the config file (default "tart.toml")
 -h, --help            help for tart
 
Use "tart [command] --help" for more information about a command.

三、構建隔離的執行環境

構建隔離執行環境可能是Runner的一個最重要的任務了,理想的執行環境應該有這些特徵:

  • 資源隔離,檔案系統、埠、程式空間獨享,包括:

    • 不被上一個job影響;
    • 不被同時執行的其他job影響;
    • 不被宿主機的其他程式影響。
  • 可重複性:同一個commit對應的job每次執行結果應該是一致的。
  • 宿主安全:job的執行不會影響到宿主機或其他job。
  • 快取友好:用空間換時間。

分析現有的極狐GitLab Runner的Executor各自滿足了上述哪些特徵就作為留給讀者的練習了。

既然蛋撻是我們自己的Runner,我們有充分的自由,讓我們選擇Firecracker來構建執行環境吧。

Firecracker是亞馬遜雲服務(AWS)開發和開源的虛擬機器管理器,特點是輕量,它依靠KVM實現,透過模擬儘可能少的硬體以及跳過BIOS啟動,可以在不到一秒內啟動一臺具有終端輸入輸出的虛擬機器,並且每臺虛擬機器的額外記憶體開銷不大於5MB,AWS使用Firecracker來構建自己的函式計算服務Lambda和無伺服器託管服務Fargate。

啟動一臺能供CI使用的MicroVM(Firecracker對虛擬機器的稱呼)需要三個依賴:

  • Linux核心映象;
  • 聯通外部網路的TAP裝置(一個虛擬的layer-2網路裝置);
  • 根檔案系統(rootFS,以檔案的形式存在,可以類比docker image來理解,裡面有作業系統的根/及其下屬內容)。

你可以檢視蛋撻對它們的具體實現,其中,根檔案系統值得說道一下。

還記得我們梳理的極狐GitLab Runner Executor的必備API嗎?雖然蛋撻並不直接仿寫極狐GitLab Runner的Executor,但是這三個操作仍然是必要的:

  • 準備環境:按樣本複製一份根檔案系統交給Firecracker,啟動虛擬機器。
  • 執行Runner提供的指令碼,獲取執行時的輸出,返回執行結果(這個API會被呼叫多次):我們稍後討論。
  • 清理環境:關閉虛擬機器,刪掉根檔案系統。

讓每個虛擬機器都在根檔案系統的副本上操作可以提供資源隔離和可重複性。

Firecracker提供的終端只有一個輸入和輸出,操作自由度不夠,這意味著我們在虛擬機器裡需要一個agent,指令碼交給它去執行,輸出和退出碼由它轉交給蛋撻。思來想去,我們最常用的agent恐怕是ssh了:

  • 在根檔案系統裡安裝好sshd和登入公鑰;
  • 每次虛擬機器啟動後,蛋撻使用ssh去連線虛擬機器;
  • 蛋撻經過ssh執行命令,獲取執行時的輸出和執行結果。

sshd會呼叫虛擬機器本地的bash執行蛋撻提供的指令碼,這正是我們想要的。

四、指令碼的生成和執行

這步不難,極狐GitLab提供的使用者指令碼是一個字串陣列,環境變數是一個物件陣列:

  • 指令碼開頭寫一個set -euo pipefail,這樣執行會在遇到錯誤的時候停下來;
  • git clonecd到倉庫目錄;
  • export環境變數,每個一行,其中環境變數的值需要escape;
  • 寫一個set +x,這樣bash就會把接下來要執行的每個命令寫到標準輸出了;
  • 寫入使用者指令碼,每個一行;
  • 每行末尾記得寫斷行符\n.

指令碼交給sshd後就可以執行了,標準輸出和標準錯誤會被蛋撻實時收集寫到本地臨時檔案中,另有一個程式會把它週期性地增量上傳到極狐GitLab。

指令碼執行結束後,sshd會返回退出碼,蛋撻會視情況上報job成功或失敗。

五、執行自己的CI

既然蛋撻是用來執行CI任務的,我們就找點任務來讓它執行,比如……它自己的CI?

讓我們為蛋撻寫一個.gitlab-ci.yml:

variables:
 # speed up go dependency downloading
 GOPROXY: "https://goproxy.cn,direct"
 
# we have go and build-essential pre-installed
our-exceiting-job:
 script:
   - echo "run test"
   - go test ./...
   - echo "build tart"
   - make
   - echo "run tart"
   - cd bin
   - ./tart
   - ./tart version

把蛋撻註冊為倉庫的CI Runner後,禁用shared runner(確保任務排程到蛋撻上),觸發一次CI執行,看上去效果還不錯!

埋一個彩蛋

對了,我還埋了一個小彩蛋與大家分享,如果你在星期四使用蛋撻執行 CI job,將會有一個神秘驚喜!點選?即可訪問蛋撻程式碼倉庫。

一點歷史

2014年~2015年,GitLab Runner有很多活躍的第三方實現,其中Kamil Trzciński基於Go的GitLab CI Multi-purpose Runner實現被GitLab相中,替代了GitLab自己基於Ruby的實現,成為了我們今天看到的極狐GitLab Runner. 那時Kamil Trzciński還在Polidea工作,因此極狐GitLab CI Multi-purpose Runner是一個社群貢獻。開源真是奇妙。

參考資料

相關文章