GitLab CI/CD 在 Node.js 項目中的實踐

  • 2019 年 12 月 9 日
  • 筆記

GitLab CI/CD 在 Node.js 項目中的實踐

近期在按照業務劃分項目時,我們組被分了好多的項目過來,大量的是基於 Node.js 的,也是我們組持續在使用的語言。

現有流程中的一些問題

在維護多個項目的時候,會暴露出一些問題:

  1. 如何有效的使用 測試用例
  2. 如何有效的使用 ESLint
  3. 部署上線還能再快一些嗎
    1. 使用了 TypeScript 以後帶來的額外成本

測試用例

首先是測試用例,最初我們設計在了 git hooks 裡邊,在執行 git commit 之前會進行檢查,在本地運行測試用例。 這會帶來一個時間上的問題,如果是日常開發,這麼操作還是沒什麼問題的,但如果是線上 bug 修復,執行測試用例的時間依據項目大小可能會持續幾分鐘。 而為了修復 bug,可能會採用 commit 的時候添加 -n 選項來跳過 hooks ,在修復 bug 時這麼做無可厚非,但是即使大家在日常開發中都採用commit -n 的方式來跳過繁瑣的測試過程,這個也是沒有辦法管控的,畢竟是在本地做的這個校驗,是否遵循這個規則,全靠大家自覺。

所以一段時間後發現,通過這種方式執行測試用例來規避一些風險的作用可能並不是很有效。

ESLint

然後就是 ESLint,我們團隊基於airbnbESLint 規則自定義了一套更符合團隊習慣的規則,我們會在編輯器中引入插件用來幫助高亮一些錯誤,以及進行一些自動格式化的操作。 同時我們也在 git hooks 中添加了對應的處理,也是在 git commit 的時候進行檢查,如果不符合規範則不允許提交。 不過這個與測試用例是相同的問題:

  1. 編輯器是否安裝 ESLint 插件無從得知,即使安裝插件、是否人肉忽略錯誤提示也無從得知。
  2. git hooks 可以被繞過

部署上線的方式

之前團隊的部署上線是使用shipit周邊套件進行部署的。 部署環境強依賴本地,因為需要在本地建立倉庫的臨時目錄,並經過多次ssh XXX "command"的方式完成 部署 + 上線 的操作。 shipit提供了一個有效的回滾方案,就是在部署後的路徑添加多個歷史部署版本的記錄,回滾時將當前運行的項目目錄指向之前的某個版本即可。不過有一點兒坑的是,很難去選擇我要回滾到那個節點,以及保存歷史記錄需要佔用額外的磁盤空間 不過正因為如此,shipit在部署多台服務器時會遇到一些令人不太舒服的地方。

如果是多台新增的服務器,那麼可以通過在shipit配置文件中傳入多個目標服務器地址來進行批量部署。 但是假設某天需要上線一些小流量(比如四台機器中的一台),因為前邊提到的shipit回滾策略,這會導致單台機器與其他三台機器的歷史版本時間戳不一致(因為這幾台機器不是同一時間上線的) 提到了這個時間戳就另外提一嘴,這個時間戳的生成是基於執行上線操作的那台機器的本地時間,之前有遇到過同事在本地測試代碼,將時間調整為了幾天前的時間,後時間沒有改回正確的時間時進行了一次部署操作,代碼出現問題後卻發現回滾失敗了,原因是該同事部署的版本時間戳太小,shipit 找不到之前的版本(shipit 可以設置保留歷史版本的數量,當時最早的一次時間戳也是大於本次出問題的時間戳的)

也就是說,哪怕有一次進行過小流量上線,那麼以後就用不了批量上線的功能了 (沒有去仔細研究shipit官方文檔,不知道會不會有類似--force之類的忽略歷史版本的操作)

基於上述的情況,我們的部署上線耗時變為了: (機器數量)X(基於本地網速的倉庫克隆、多次 ssh 操作的耗時總和)。 P.S. 為了保證倉庫的有效性,每次執行 shipit 部署,它都會刪除之前的副本,重新克隆

尤其是服務端項目,有時緊急的 bug 修復可能是在非工作時間,這意味着可能當時你所處的網絡環境並不是很穩定。 我曾經晚上接到過同事的微信,讓我幫他上線項目,他家的 Wi-Fi 是某博士的,下載項目依賴的時候出了些問題。 還有過使用移動設備開熱點的方式進行上線操作,有一次非前後分離的項目上線後,直接就收到了聯通的短訊:「您本月流量已超出XXX」(當時還在用合約套餐,一月就800M流量)。

TypeScript

在去年下半年開始,我們團隊就一直在推動 TypeScript 的應用,因為在大型項目中,擁有明確類型的 TypeScript 顯然維護性會更高一些。 但是大家都知道的, TypeScript 最終需要編譯轉換為 JavaScript(也有 tsc 那種的不生成 JS 文件,直接運行,不過這個更多的是在本地開發時使用,線上代碼的運行我們還是希望變量越少越好)。

所以之前的上線流程還需要額外的增加一步,編譯 TS。 而且因為shipit是在本地克隆的倉庫並完成部署的,所以這就意味着我們必須要把生成後的 JS 文件也放入到倉庫中,最直觀的,從倉庫的概覽上看着就很醜(50% TS、50% JS),同時這進一步增加了上線的成本。

總結來說,現有的部署上線流程過於依賴本地環境,因為每個人的環境不同,這相當於給部署流程增加了很多不可控因素。

如何解決這些問題

上邊我們所遇到的一些問題,其實可以分為兩塊:

  1. 有效的約束代碼質量
  2. 快速的部署上線

所以我們就開始尋找解決方案,因為我們的源碼是使用自建的 GitLab 倉庫來進行管理的,首先就找到了 GitLab CI/CD。 在研究了一番文檔以後發現,它能夠很好的解決我們現在遇到的這些問題。

要使用 GitLab CI/CD 是非常簡單的,只需要額外的使用一台服務器安裝 gitlab-runner,並將要使用 CI/CD 的項目註冊到該服務上就可以了。 GitLab 官方文檔中有非常詳細的安裝註冊流程:

install | runner register | runner group register | repo 註冊 Group 項目時的一些操作

上邊的註冊選擇的是註冊 group ,也就是整個 GitLab 某個分組下所有的項目。 主要目的是因為我們這邊項目數量太多,單個註冊太過繁瑣(還要登錄到 runner 服務器去執行命令才能夠註冊)

安裝時需要注意的地方

官網的流程已經很詳細了,不過還是有一些地方可以做一些小提示,避免踩坑

sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner

這是 Linux 版本的安裝命令,安裝需要 root (管理員) 權限,後邊跟的兩個參數:

  • --userCI/CD 執行 job (後續所有的流程都是基於 job 的)時所使用的用戶名
  • --working-directoryCI/CD 執行時的根目錄路徑 個人的踩坑經驗是將目錄設置為一個空間大的磁盤上,因為 CI/CD 會生成大量的文件,尤其是如果使用 CI/CD 進行編譯 TS 文件並且將其生成後的 JS 文件緩存;這樣的操作會導致 innode 不足產生一些問題

--user 的意思就是 CI/CD 執行使用該用戶進行執行,所以如果要編寫腳本之類的,建議在該用戶登錄的狀態下編寫,避免出現無權限執行 sudo su gitlab-runner

註冊時需要注意的地方

在按照官網的流程執行時,我們的 tag 是留空的,暫時沒有找到什麼用途。。 以及 executor 這個比較重要了,因為我們是從手動部署上線還是往這邊靠攏的,所以穩妥的方式是一步步來,也就是說我們選擇的是 shell ,最常規的一種執行方式,對項目的影響也是比較小的(官網示例給的是 docker

.gitlab-ci.yml 配置文件

上邊的環境已經全部裝好了,接下來就是需要讓 CI/CD 真正的跑起來 runner 以哪種方式運行,就靠這個配置文件來描述了,按照約定需要將文件放置到 repo 倉庫的根路徑下。 當該文件存在於倉庫中,執行 git push 命令後就會自動按照配置文件中所描述的動作進行執行了。

上邊的兩個鏈接裡邊信息非常完整,包含各種可以配置的選項。

一般來講,配置文件的結構是這樣的:

stages:    - stage1    - stage2    - stage3    job 1:    stage: stage1    script: echo job1    job 2:    stage: stage2    script: echo job2    job 3:    stage: stage2    script:      - echo job3-1      - echo job3-2    job 4:    stage: stage3    script: echo job4

stages 用來聲明有效的可被執行的 stage,按照聲明的順序執行。 下邊的那些 job XXX 名字不重要,這個名字是在 GitLab CI/CD Pipeline 界面上展示時使用的,重要的是那個 stage 屬性,他用來指定當前的這一塊 job 隸屬於哪個 stagescript 則是具體執行的腳本內容,如果要執行多行命令,就像job 3那種寫法就好了。

如果我們將上述的 stagejob 之類的換成我們項目中的一些操作install_dependenciestesteslint之類的,然後將script字段中的值換成類似npx eslint之類的,當你把這個文件推送到遠端服務器後,你的項目就已經開始自動運行這些腳本了。 並且可以在Pipelines界面看到每一步執行的狀態。

P.S. 默認情況下,上一個 stage 沒有執行完時不會執行下一個 stage 的,不過也可以通過額外的配置來修改: allow failure when

設置僅在特定的情況下觸發 CI/CD

上邊的配置文件存在一個問題,因為在配置文件中並沒有指定哪些分支的提交會觸發 CI/CD 流程,所以默認的所有分支上的提交都會觸發,這必然不是我們想要的結果。 CI/CD 的執行會佔用系統的資源,如果因為一些開發分支的執行影響到了主幹分支的執行,這是一件得不償失的事情。

所以我們需要限定哪些分支才會觸發這些流程,也就是要用到了配置中的 only 屬性。

使用only可以用來設置哪些情況才會觸發 CI/CD,一般我們這邊常用的就是用來指定分支,這個是要寫在具體的 job 上的,也就是大致是這樣的操作:

具體的配置文檔

job 1:    stage: stage1    script: echo job1    only:      - master      - dev

單個的配置是可以這樣寫的,不過如果 job 的數量變多,這麼寫就意味着我們需要在配置文件中大量的重複這幾行代碼,也不是一個很好看的事情。 所以這裡可能會用到一個yaml的語法:

這是一步可選的操作,只是想在配置文件中減少一些重複代碼的出現

.access_branch_template: &access_branch    only:      - master      - dev    job 1:    <<: *access_branch    stage: stage1    script: echo job1    job 2:    <<: *access_branch    stage: stage2    script: echo job2

一個類似模版繼承的操作,官方文檔中也沒有提到,這個只是一個減少冗餘代碼的方式,可有可無。

緩存必要的文件

因為默認情況下,CI/CD在執行每一步(job)時都會清理一下當前的工作目錄,保證工作目錄是乾淨的、不包含一些之前任務留下的數據、文件。 不過這在我們的 Node.js 項目中就會帶來一個問題。 因為我們的 ESLint、單元測試 都是基於 node_modules 下邊的各種依賴來執行的。 而目前的情況就相當於我們每一步都需要執行npm install,這顯然是一個不必要的浪費。

所以就提到了另一個配置文件中的選項:cache

用來指定某些文件、文件夾是需要被緩存的,而不能清除:

cache:    key: ${CI_BUILD_REF_NAME}    paths:      - node_modules/

大致是這樣的一個操作,CI_BUILD_REF_NAME是一個 CI/CD 提供的環境變量,該變量的內容為執行 CI/CD 時所使用的分支名,通過這種方式讓兩個分支之間的緩存互不影響。

部署項目

如果基於上邊的一些配置,我們將 單元測試、ESLint 對應的腳本放進去,他就已經能夠完成我們想要的結果了,如果某一步執行出錯,那麼任務就會停在那裡不會繼續向後執行。 不過目前來看,後邊已經沒有多餘的任務供我們執行了,所以是時候將 部署 這一步操作接過來了。

部署的話,我們目前選擇的是通過 rsync 來進行同步多台服務器上的數據,一個比較簡單高效的部署方式。

P.S. 部署需要額外的做一件事情,就是建立從gitlab runner所在機器gitlab-runner用戶到目標部署服務器對應用戶下的機器信任關係。 有 N 多種方法可以實現,最簡單的就是在runner機器上執行 ssh-copy-id 將公鑰寫入到目標機器。 或者可以像我一樣,提前將 runner 機器的公鑰拿出來,需要與機器建立信任關係時就將這個字符串寫入到目標機器的配置文件中。 類似這樣的操作:ssh 10.0.0.1 "echo "XXX" >> ~/.ssh/authorized_keys"

大致的配置如下:

variables:    DEPLOY_TO: /home/XXX/repo # 要部署的目標服務器項目路徑  deploy:    stage: deploy    script:      - rsync -e "ssh -o StrictHostKeyChecking=no" -arc --exclude-from="./exclude.list" --delete . 10.0.0.1:$DEPLOY_TO      - ssh 10.0.0.1 "cd $DEPLOY_TO; npm i --only=production"      - ssh 10.0.0.1 "pm2 start $DEPLOY_TO/pm2/$CI_ENVIRONMENT_NAME.json;"

同時用到的還有variables,用來提出一些變量,在下邊使用。

ssh 10.0.0.1 "pm2 start $DEPLOY_TO/pm2/$CI_ENVIRONMENT_NAME.json;",這行腳本的用途就是重啟服務了,我們使用pm2來管理進程,默認的約定項目路徑下的pm2文件夾存放着個個環境啟動時所需的參數。

當然了,目前我們在用的沒有這麼簡單,下邊會統一提到

並且在部署的這一步,我們會有一些額外的處理

這是比較重要的一點,因為我們可能會更想要對上線的時機有主動權,所以 deploy 的任務並不是自動執行的,我們會將其修改為手動操作還會觸發,這用到了另一個配置參數:

deploy:    stage: deploy    script: XXX    when: manual  # 設置該任務只能通過手動觸發的方式運行

當然了,如果不需要,這個移除就好了,比如說我們在測試環境就沒有配置這個選項,僅在線上環境使用了這樣的操作

更方便的管理 CI/CD 流程

如果按照上述的配置文件進行編寫,實際上已經有了一個可用的、包含完整流程的 CI/CD 操作了。

不過它的維護性並不是很高,尤其是如果 CI/CD 被應用在多個項目中,想做出某項改動則意味着所有的項目都需要重新修改配置文件並上傳到倉庫中才能生效。

所以我們選擇了一個更靈活的方式,最終我們的 CI/CD 配置文件是大致這樣子的(省略了部分不相干的配置):

variables:    SCRIPTS_STORAGE: /home/gitlab-runner/runner-scripts    DEPLOY_TO: /home/XXX/repo # 要部署的目標服務器項目路徑    stages:    - install    - test    - build    - deploy_development    - deploy_production    install_dependencies:    stage: install    script: bash $SCRIPTS_STORAGE/install.sh    unit_test:    stage: test    script: bash $SCRIPTS_STORAGE/test.sh    eslint:    stage: test    script: bash $SCRIPTS_STORAGE/eslint.sh    # 編譯 TS 文件  build:    stage: build    script: bash $SCRIPTS_STORAGE/build.sh    deploy_development:    stage: deploy_development    script: bash $SCRIPTS_STORAGE/deploy.sh 10.0.0.1    only: dev     # 單獨指定生效分支    deploy_production:    stage: deploy_production    script: bash $SCRIPTS_STORAGE/deploy.sh 10.0.0.2    only: master  # 單獨指定生效分支

我們將每一步 CI/CD 所需要執行的腳本都放到了 runner 那台服務器上,在配置文件中只是執行了那個腳本文件。 這樣當我們有什麼策略上的調整,比如說 ESLint 規則的變更、部署方式之類的。 這些都完全與項目之間進行解耦,後續的操作基本都不會讓正在使用 CI/CD 的項目重新修改才能夠支持(部分需要新增環境變量的導入之類的確實需要項目的支持)。

接入釘釘通知

實際上,當 CI/CD 執行成功或者失敗,我們可以在 Pipeline 頁面中看到,也可以設置一些郵件通知,但這些都不是時效性很強的。 鑒於我們目前在使用釘釘進行工作溝通,所以就研究了一波釘釘機械人。 發現有支持 GitLab 機械人,不過功能並不適用,只能處理一些 issues 之類的, CI/CD 的一些通知是缺失的,所以只好自己基於釘釘的消息模版實現一下了。

因為上邊我們已經將各個步驟的操作封裝了起來,所以這個修改對同事們是無感知的,我們只需要修改對應的腳本文件,添加釘釘的相關操作即可完成,封裝了一個簡單的函數:

function sendDingText() {    local text="$1"      curl -X POST "$DINGTALK_HOOKS_URL"     -H 'Content-Type: application/json'     -d '{      "msgtype": "text",      "text": {          "content": "'"$text"'"      }    }'  }    # 具體發送時傳入的參數  sendDingText "proj: $CI_PROJECT_NAME[$CI_JOB_NAME]nenv: $CI_ENVIRONMENT_NAMEndeploy successn$CI_PIPELINE_URLncreated by: $GITLAB_USER_NAMEnmessage: $CI_COMMIT_MESSAGE"    # 某些 case 失敗的情況下 是否需要更多的信息就看自己自定義咯  sendDingText "error: $CI_PROJECT_NAME[$CI_JOB_NAME]nenv: $CI_ENVIRONMENT_NAME"

上述用到的環境變量,除了DINGTALK_HOOKS_URL是我們自定義的機械人通知地址以外,其他的變量都是有 GitLab runenr所提供的。

各種變量可以從這裡找到:predefined variables

回滾處理

聊完了正常的流程,那麼也該提一下出問題時候的操作了。 人非聖賢孰能無過,很有可能某次上線一些沒有考慮到的地方就會導致服務出現異常,這時候首要任務就是讓用戶還可以照常訪問,所以我們會選擇回滾到上一個有效的版本去。 在項目中的 Pipeline 頁面 或者 Enviroment 頁面(這個需要在配置文件中某些 job 中手動添加這個屬性,一般會寫在 deploy 的那一步去),可以在頁面上選擇想要回滾的節點,然後重新執行 CI/CD 任務,即可完成回滾。

不過這在 TypeScript 項目中會有一些問題,因為我們回滾一般來講是重新執行上一個版本 CI/CD 中的 deploy 任務,在 TS 項目中,我們在 runner 中緩存了 TS 轉換 JS 之後的 dist 文件夾,並且部署的時候也是直接將該文件夾推送到服務器的(TS項目的源碼就沒有再往服務器上推過了)。

而如果我們直接點擊 retry 就會帶來一個問題,因為我們的 dist 文件夾是緩存的,而 deploy 並不會管這種事兒,他只會把對應的要推送的文件發送到服務器上,並重啟服務。

而實際上 dist 還是最後一次(也就是出錯的那次)編譯出來的 JS 文件,所以解決這個問題有兩種方法:

  1. deploy 之前執行一下 build
  2. deploy 的時候進行判斷

第一個方案肯定是不可行的,因為嚴重依賴於操作上線的人是否知道有這個流程。 所以我們主要是通過第二種方案來解決這個問題。

我們需要讓腳本在執行的時候知道,dist 文件夾裡邊的內容是不是自己想要的。 所以就需要有一個 標識,而做這個標識最簡單有效唾手可得的就是,git commit id。 每一個 commit 都會有一個唯一的標識符號,而且我們的 CI/CD 執行也是依靠於新代碼的提交(也就意味着一定有 commit)。 所以我們在 build 環節將當前的commit id也緩存了下來:

git rev-parse --short HEAD > git_version

同時在 deploy 腳本中添加額外的判斷邏輯:

currentVersion=`git rev-parse --short HEAD`  tagVersion=`touch git_version; cat git_version`    if [ "$currentVersion" = "$tagVersion" ]  then      echo "git version match"  else      echo "git version not match, rebuild dist"      bash ~/runner-scripts/build.sh  # 額外的執行 build 腳本  fi

這樣一來,就避免了回滾時還是部署了錯誤代碼的風險。

關於為什麼不將 build 這一步操作與 deploy 合併的原因是這樣的: 因為我們會有很多台機器,同時 job 會寫很多個,類似 deploy_1deploy_2deploy_all,如果我們將 build 的這一步放到 deploy 中 那就意味着我們每次 deploy,即使是一次部署,但因為我們選擇一台台機器單獨操作,它也會重新生成多次,這也會帶來額外的時間成本

hot fix 的處理

CI/CD 運行了一段時間後,我們發現偶爾解決線上 bug 還是會比較慢,因為我們提交代碼後要等待完整的 CI/CD 流程走完。 所以在研究後我們決定,針對某些特定情況hot fix,我們需要跳過ESLint、單元測試這些流程,快速的修復代碼並完成上線。

CI/CD 提供了針對某些 Tag 可以進行不同的操作,不過我並不想這麼搞了,原因有兩點:

  1. 這需要修改配置文件(所有項目)
  2. 這需要開發人員熟悉對應的規則(打 Tag

所以我們採用了另一種取巧的方式來實現,因為我們的分支都是只接收Merge Request那種方式上線的,所以他們的commit title實際上是固定的:Merge branch 'XXX'。 同時 CI/CD 會有環境變量告訴我們當前執行 CI/CDcommit message。 我們通過匹配這個字符串來檢查是否符合某種規則來決定是否跳過這些job

function checkHotFix() {    local count=`echo $CI_COMMIT_TITLE | grep -E "^Merge branch '(hot)?fix/w+" | wc -l`      if [ $count -eq 0 ]    then      return 0    else      return 1    fi  }    # 使用方法    checkHotFix    if [ $? -eq 0 ]  then    echo "start eslint"    npx eslint --ext .js,.ts .  else    # 跳過該步驟    echo "match hotfix, ignore eslint"  fi

這樣能夠保證如果我們的分支名為 hotfix/XXX 或者 fix/XXX 在進行代碼合併時, CI/CD 會跳過多餘的代碼檢查,直接進行部署上線。 沒有跳過安裝依賴的那一步,因為 TS 編譯還是需要這些工具的

小結

目前團隊已經有超過一半的項目接入了 CI/CD 流程,為了方便同事接入(主要是編輯 .gitlab-ci.yml 文件),我們還提供了一個腳手架用於快速生成配置文件(包括自動建立機器之間的信任關係)。

相較之前,部署的速度明顯的有提升,並且不再對本地網絡有各種依賴,只要是能夠將代碼 push 到遠程倉庫中,後續的事情就和自己沒有什麼關係了,並且可以方便的進行小流量上線(部署單台驗證有效性)。

以及在回滾方面則是更靈活了一些,可在多個版本之間快速切換,並且通過界面的方式,操作起來也更加直觀。

最終可以說,如果沒有 CI/CD,實際上開發模式也是可以忍受的,不過當使用了 CI/CD 以後,再去使用之前的部署方式,則會明顯的感覺到不舒適。(沒有對比,就沒有傷害?)

完整的流程描述

  1. 安裝依賴
  2. 代碼質量檢查
    1. ESLint 檢查
      1. 檢查是否為 hotfix 分支,如果是則跳過本流程
    2. 單元測試
      1. 檢查是否為 hotfix 分支,如果是則跳過本流程
  3. 編譯 TS 文件
  4. 部署、上線
    1. 判斷當前緩存 dist 目錄是否為有效的文件夾,如果不是則重新執行第三步編譯 TS 文件
    2. 上線完畢後發送釘釘通知

後續要做的

接入 CI/CD 只是第一步,將部署上線流程統一後,可以更方便的做一些其他的事情。 比如說在程序上線後可以驗證一下接口的有效性,如果發現有錯誤則自動回滾版本,重新部署。 或者說接入 docker, 這些調整在一定程度上對項目維護者都是透明的。

參考資料