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

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

現有流程中的一些問題

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

  1. 如何有效的使用 測試用例
  2. 如何有效的使用 ESLint
  3. 部署上線還能再快一些嗎html

    1. 使用了 TypeScript 之後帶來的額外成本

測試用例

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

因此一段時間後發現,經過這種方式執行測試用例來規避一些風險的做用可能並非頗有效。node

ESLint

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

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

部署上線的方式

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

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

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

基於上述的狀況,咱們的部署上線耗時變爲了: (__機器數量__)X(__基於本地網速的倉庫克隆、屢次 ssh 操做的耗時總和__)。 P.S. 爲了保證倉庫的有效性,每次執行 shipit 部署,它都會刪除以前的副本,從新克隆 npm

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

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 隸屬於哪一個 stage
script 則是具體執行的腳本內容,若是要執行多行命令,就像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_NAME\ndeploy success\n$CI_PIPELINE_URL\ncreated by: $GITLAB_USER_NAME\nmessage: $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, 這些調整在必定程度上對項目維護者都是透明的。

參考資料

相關文章
相關標籤/搜索