Pipeline流水線設計的最佳實踐

2022-10-26 06:00:41

談到到DevOps,持續交付流水線是繞不開的一個話題,相對於其他實踐,通過流水線來實現快速高質量的交付價值是相對能快速見效的,特別對於開發測試人員,能夠獲得實實在在的收益。很多文章介紹流水線,不管是jenkins,gitlab-ci, 流水線,還是drone, github action 流水線, 文章都很多,但是不管什麼工具,流水線設計的思路是一致的。於此同時,在實踐過程中,發現大家對流水像有些誤區,不是一大堆流水線,就是一個流水線調一個超級複雜的指令碼,各種寫死和環境依賴,所以希望通過這篇文章能夠給大家分享自己對於Pipeline流水線的設計心得體會。

概念

  1. 持續整合 (Continuous Integration,CI)

持續整合(CI)是在原始碼變更後自動檢測、拉取、構建和(在大多數情況下)進行單元測試的過程
對專案而言,持續整合(CI)的目標是確保開發人員新提交的變更是好的,不會發生break build; 並且最終的主幹分支一直處於可釋出的狀態,
對於開發人員而言,要求他們必須頻繁地向主幹提交程式碼,相應也可以即時得到問題的反饋。實時獲取到相關錯誤的資訊,以便快速地定位與解決問題
顯然這個過程可以大大地提高開發人員以及整個IT團隊的工作效率,避免陷入好幾天得不到好的「部署產出」,影響後續的測試和交付。

  1. 持續交付 (Continuous Delivery,CD)

持續交付在持續整合的基礎上,將整合後的程式碼部署到更貼近真實執行環境的「預釋出環境」(production-like environments)中。交付給質量團隊或者使用者,以供評審。如果評審通過,程式碼就進入生產階段 持續交付並不是指軟體每一個改動都要儘快部署到產品環境中,它指的是任何的程式碼修改都可以在任何時候實時部署。
強調: 1、手動部署 2、有部署的能力,但不一定部署

  1. 持續部署 (Continuous Deployment, CD)

程式碼通過評審之後,自動部署到生產環境中。持續部署是持續交付的最高階段。
強調 1、持續部署是自動的 2、持續部署是持續交付的最高階段 3、持續交付表示的是一種能力,持續部署則是一種方式

流水線的編排設計

參考: https://docs.gitlab.com/ee/ci/pipelines/pipeline_architectures.html
這裡非常推薦以版本控制系統為源的構建流水線設計,從每一位開發人員提交程式碼即可對當前提交程式碼進行檢查編譯構建,儘快將錯誤反饋給每位提交人員。

對於DevOps流水線,主要是由各類任務串聯起來,而對於任務本身又分為兩張型別,一種是自動化任務,一種是人工執行任務。具體如下:

  1. 自動化任務:包括了程式碼靜態檢查,構建,打包,部署,單元測試,環境遷移,自定義指令碼執行等。
  2. 人工任務:人工任務主要包括了檢查稽核,打標籤基線,元件包製作等類似工作。

而通常我們看到的流水線基本都由上述兩類任務組合編排而成,一個流水線可以是完全自動化執行,也可以中間加入了人工干預節點,在人工干預處理後再繼續朝下執行。比如流水線中到了測試部署完成後,可以到測試環境人工驗證環節,只有人工驗證通過再流轉到遷移釋出到生產環境動作任務。
DevOps流水線實際上和我們原來經常談到的持續整合最佳實踐是相當類似的,較大的一個差異點就在於引入了容器化技術來實現自動化部署和應用託管。至於在DevOps實踐中,是否必須馬上將專案切換到微服務架構框架模式,反而不是必須得。

在整個DevOps流水線中,我們實際上強調個一個關鍵點在於「一套Docker映象檔案+多套環境設定+多套構建版本標籤」做法。以確保我們最終構建和測試通過的版本就是我們部署到生產環境的版本。
構建操作只有一次,而後面到測試環境,到UAT環境,到生產環境,都屬於是映象的環境遷移和部署。而不涉及到需要再次重新打包的問題。這個是持續整合,也是DevOps的基本要求。

流水線任務的標準化/原子化

今天談DevOps流水線編排,主要是對流水線編排本身的靈活性進一步思考。

  • 構建操作:構建我們通常採用Maven進行自動化構建,構建完成輸出一個或多個Jar包或War包。

注意常規方式下構建完執行進行部署操作,部署操作一般就是將構建的結果拷貝到我們的測試環境伺服器,同時對初始化指令碼進行啟動等。而在DevOps下,該操作會變成兩個操作,即一個打包,一個部署。打包是將構建完成的內容製作為映象,部署是將映象部署到具體的資源池和指定叢集。

  • 打包映象操作:實際上即基於構建完成的部署包來生成映象。該操作一般首先基於一個基礎映象檔案基礎上進行,在基礎映象檔案上拷貝和寫入具體的部署包檔案,同時在啟動相應的初始化指令碼。

那麼首先要考慮構建操作和打包操作如何鬆耦合開,打包操作簡單來就是就是一個映象製作,需要的是構建操作產生的輸出。我們可以對其輸出和需要拷貝的內容在構建的時候進行約定。而打包任務則是一個標準化的映象製作任務,我們需要考慮的僅僅是基於
1)基於哪個基礎映象
2)中介軟體容器預設目錄設定
3)初始化啟動命令。
即在實際的打包任務設計的時候,我們不會指定具體的部署包和部署檔案,這個完全由編排的時候由上游輸入。

  • 部署操作:部署操作相當更加簡單,重點就是將映象部署到哪個資源池,哪個叢集節點,初始化的節點設定等。具體部署哪個映象不要指定,而是由上游任務節點輸入。

任務節點間鬆耦合設計的意義
這種鬆耦合設計才能夠使流水線編排更加靈活。比如我們在進行了構建打包後,我們希望同時講打包內容部署到開發環境和測試環境。那麼則是打包動作完成後需要對接兩個應用部署任務。這兩個部署任務都依託上面的打包結果進行自動化部署,可以並行進行。
對於測試環境部署完成後,我們需要進行測試人員手工驗證測試,如果測試通過,我們打標籤後希望能夠直接釋出到UAT環境。而這種操作我們也希望在一個流水線來設計和完成。這樣我們更加容易在持續整合看板上看到整個版本構建和遷移的完整過程。如果這是在一個大流水線裡面,那麼對於UAT環境部署任務就需要一直去追溯流水線上的最近的一個打包任務節點,同時取該任務節點產生的輸出來進行相應的環境部署操作。
在談DevOps的時候,一個重點就是和QA/QC的協同,因此在流水線編排的時候一定要考慮各類測試節點,包括靜態程式碼檢查,自動化的單元測試,人工的測試驗證。同時最好基於持續整合實踐,能夠將測試過程和整個自動化構建過程緊密結合起來。
簡單來說,測試人員發現build1.0.0001版本4個bug並提交,那麼在下次自動化構建完成並單元測試通過後,測試人員能夠很清楚的看到哪些Bug已經修改並可以在新構建的版本進行驗證。只有這樣才能夠形成閉環,整個流水線作業才能夠更好的發揮協同作用。

流水線中蘊含的工程實踐

流水線除了任務步驟的編排,更重要的核心是最佳工程實踐的體現。過去傳統的思維,自動化就是寫個shell/python指令碼批次執行,在DevOps/微服務時代,這一招太out了,每種工程實踐的背後都有需要解決的問題,通過在流水線設計中注入最佳的工程實踐,可以讓流水線的價值最大化,也讓流水線更高階不是嘛。

  1. 版本控制 - 解決的問題:需求和程式碼的關係,版本變化的跟蹤
  2. 最優的分支策略 - 解決的問題:版本釋出和團隊共同作業,某些情況會和環境有關係
  3. 程式碼靜態掃描 ** - 解決的問題: 開發規範和安全的問題**
  4. 80%以上的單元測試覆蓋率 ** - 解決的問題:程式碼功能質量的問題,讓測試左移**
  5. 漏洞(Vulnerability)掃描 - 解決的問題:部署環境/產品安全的問題
  6. 開源工具掃描 ** - 解決的問題:解決供應鏈安全問題,別忘了log4j**
  7. 製品(Artifact)版本控制 - 解決的問題:製品的版本控制,製品的晉級,某些情況下環境的回滾
  8. 環境自動建立 - 解決的問題:解決的是構建/部署環境一致性的問題,開發測的好好的,測試一驗證怎麼不行啊,容器化/雲原生讓這個問題更好的解決
  9. 不可變伺服器(Immutable Server )- 解決的問題: 可能不好理解,打個比方如果如果你的伺服器掛了,或者某次設定更改了服務就起不來了,使用不可變基礎設施的主要好處是部署的簡單性、可靠性和一致性,伺服器可以隨時替換上線
  10. 整合測試
  11. 效能測試
  12. 每次提交都觸發:構建、部署和自動化測試 ** - 解決的問題:快速失敗,避免下游時間的浪費**
  13. 自動化變更請求 ** - 解決問題:某些場景下通過狀態變更觸發某些動作**
  14. 零停機發布 - 解決的問題:捲動/藍綠/灰度釋出等,使用者無感知
  15. 功能開關 - 解決的問題: 主幹開發中,如果某個功能沒開放完,就通過on/off某個特性來讓穩定的功能上線;還有一個場景,比如某些面對消費者的廣告網站,想看看自己某個功能客戶是否細化,通過功能開關看看市場反饋,一般和A/B測試配合

基於場景設計流水線

是否需要一條完整的流水線?流水線是越多越好,還是越少越好?
建議按照場景來設計,一條流水線通吃所有流程是不現實的,搞了好多流水線(比如一個構建就一個流水線,一個複製操作就一個流水線)這些都是不可取的,維護成本巨大,得不償失。

流水線按照場景分類如下:

  • 端到端自動化流水線
    • 需求、程式碼構建、測試、部署環境內嵌自動化能力,每次提交都觸發完整流水線
  • 提交階段流水線(個人級)、
  • 驗收階段流水線(團隊級)、
  • 部署階段流水線(部署/釋出)
  • 流水線自動化觸發,遞次自動化(製品)晉級;
  • 流水線任務按需序列、並行、特殊場景下跳過執行
  • 必要環節人工干預, e.g. 在手工測試、正式釋出等環節匯入手工確認環節,流水線牽引流動

1)提交流水線

過程如下:

  • 提交即構建
  • 編譯單測打包程式碼質量檢查
  • 構建錯誤第一時間通知提交人

以Jenkins實現為例,
通過webhook觸發CI構建,首先設定Jenkins專案

  • 使用generic webhook方式觸發專案構建
  • 設定構建觸發器引數(獲取gitlab返回的資料,比如分支、使用者等資訊)
  • 設定構建觸發器中的token(確保唯一,建議可以用專案名稱)
  • 設定觸發器中的請求過濾(merge_request,opend)


其次是Gitlab的設定

  • 專案-》整合-》新建webhook
  • 填寫webhook地址?token=projectName
  • MergeRequest操作觸發


剩下的就是編寫Jenkinsfile了,下面列出幾個關鍵點
1.獲取gitlab資料中的分支名稱,作為本次構建的分支名稱。
2.獲取gitlab資料中的使用者郵箱,作為構建失敗後通知物件。

2)MR流水線

過程如下:

  • codereview
  • 設定分支保護
  • 建立合併請求對將程式碼審查結果在評論區展現
  • 由assignUser合併程式碼

合併流水線設計:合併流水線的步驟其實跟提交流水線很類似,但是在程式碼質量檢查的步驟中嚴格要求檢查質量閾的狀態,當質量閾狀態為錯誤的時候,需要立即失敗並通知發起人。
第一次設計

  • 開發人員建立MR並指定AssignUser。
  • CI工具開始對MR中的源分支進行編譯構建打包程式碼檢查。
  • 構建成功(程式碼質量沒問題)在MR頁面評論提示資訊。
  • 構建失敗在MR頁面評論失敗資訊

第二次設計(藉助GitlabCI)- 優化點:加入MR構建失敗攔截,成功自動合併

  • 專案設定當流水線成功時才能merge。
  • 開發人員建立MR並指定AssignUser。
  • Jenkins開始對MR中的源分支的最後一次commit狀態改為running。
  • 然後進行編譯構建打包程式碼檢查。
  • 構建成功,更新最後一次commit的狀態為 success。
  • 構建失敗,更新最後一次commit的狀態為faild。

3)SQL釋出流水線


除了程式碼有版本,其實SQL也有「版本的」,SQL指令碼的版本對於產品的升級回滾至關重要。
一般對SQL的整合,會包含如下要素

  1. 構建環節,對SQL語法進行檢查,避免打進包里語法是錯的;某些情況下,多個開發會寫不同的增量指令碼,最後釋出時候需要做指令碼的合併
  2. SQL指令碼的版本,某些情況下產品自身要用表來記錄自身業務指令碼的版本,通過產品版本來判斷某些指令碼是否應該被執行。

當然,也有其他資料庫版本管理工具,比如 flyway 和 liquibase;

  • Flyway是獨立於資料庫的應用、管理並跟蹤資料庫變更的資料庫版本管理工具。用通俗的話講,Flyway可以像Git管理不同人的程式碼那樣,管理不同人的sql指令碼,從而做到資料庫同步。
  • liquibase 只是在功能上和Flyway有差異

不管怎麼樣,它們底層的原理都是用另外的表記錄SQL指令碼的版本,升級更新是比較版本差異,來決定是否執行。
python自帶的model模組 python manage.py makemigrations 同樣在做類似 的事情
資料庫版本管理

流水線的關鍵元素

不管你用什麼CI/CD平臺,開源的Jenkins, GitLab CI, Teckton, Drone,還是商用的Azure,阿里雲效等,不管是程式碼化,還是視覺化,流水線包含的元素基本都差不多,下面通過不同的範例來說明這些元素的作用和含義。
參考:

Agent&Runner(執行代理)

image: "registry.example.com/my/image:latest" #gitlab-ci
pool:
  vmImage: ubuntu-latest  #auzure
agent { label 'linux' }  //jenkins

agent {
    docker {
       image 'maven:3-alpine'
       label 'Ubuntu'
       args '-v /root/.m2:/root/.m2'
    }
}

Parameter(引數變數)

  • **流水線級別引數 **(全域性引數),範圍限於整個流水線執行時,可被整個流水線其他任務使用
    • 內建全域性引數 - 一般稱為built-in(預定義) variable, 有的平臺成為環境變數
export CI_JOB_ID="50"
export CI_COMMIT_SHA="1ecfd275763eff1d6b4844ea3168962458c9f27a"
export CI_COMMIT_SHORT_SHA="1ecfd275"
export CI_COMMIT_REF_NAME="main"
export CI_REPOSITORY_URL="https://gitlab-ci-token:[masked]@example.com/gitlab-org/gitlab-foss.git"

1. BUILD_ID : 當前build的id
2. BUILD_NUM : 當前build的在pipeline中的build num
3. PIPELINE_NAME : pipeline 名稱
4. PIPELINE_ID: pipeline Id
5. GROUP: pipeline 所屬的group 名稱
6. TRIGGER_USER: 觸發build的user(event觸發的為觸發gitlab event的user)
7. STAGE_NAME: 當前執行的stage的名稱
8. STAGE_DISPLAY_NAME : 當前執行的stage的顯示名稱
9. PIPELINE_URL : pipeline在ui中的網頁的連結
10. BUILD_URL: build 在ui的網頁連結
11. WORKSPACE: 當前stage執行的工作目錄,通常用作拼接絕對路徑
  • 非內建全域性引數
 environment {         
        HARBOR_ACCESS_KEY = credentials('harbor-userpwd-pair')     
        SERVER_ACCESS_KEY = credentials('deploy-userpwd-pair')      
        GITLAB_API_TOKEN = credentials('gitlab_api_token_secret')       
    }
  • 外部引數 - 一般作為執行時引數
variables:
  TEST_SUITE:
    description: "The test suite that will run. Valid options are: 'default', 'short', 'full'."
    value: "default"
  DEPLOY_ENVIRONMENT:
    description: "Select the deployment target. Valid options are: 'canary', 'staging', 'production', or a stable branch of your choice."
parameters([ 
        separator(name: "PROJECT_PARAMETERS", sectionHeader: "Project Parameters"),
        string(name: 'PROJECT_NAME', defaultValue: 'vue-app', description: '專案名稱') ,
        string(name: 'GIT_URL', defaultValue: '[email protected]:devopsing/vuejs-docker.git', description: 'Git倉庫URL') ,
])
  • 步驟任務引數 (區域性引數) - 一般作為某個外掛任務的輸入引數,也可以使用上個任務的輸出作為引數,範圍僅限於該任務內
  • 加密變數 - 對特殊變數進行加密處理
secrets:
  DATABASE_PASSWORD:
    vault: production/db/password@ops  # translates to secret `ops/data/production/db`, field `password`

Step(步驟)

參考: https://docs.drone.io/pipeline/overview/

---
kind: pipeline
type: docker
name: default

steps:
- name: backend
  image: golang
  commands:
  - go build
  - go test

- name: frontend
  image: node
  commands:
  - npm install
  - npm run test

...

Stage(階段)

一般用於對多個任務(step)進行分組歸類,便於管理

 stage('Pull code') {
            steps {
                echo 'Pull code...'
                script {
                    git branch: '${Branch_Or_Tags}', credentialsId: 'gitlab-private-key', url: '[email protected]:xxxx/platform-frontend.git'
                }
            }
}

Trigger(觸發器)

trigger:
- master
- releases/*
trigger_pipeline:
stage: deploy
script:
- 'curl --fail --request POST --form token=$MY_TRIGGER_TOKEN --form ref=main "https://gitlab.example.com/api/v4/projects/123456/trigger/pipeline"'
rules:
- if: $CI_COMMIT_TAG
environment: production
trigger:
  event:
  - promote
  target:
  - production

trigger:
   type: cron
   cron: '*/5 * * * *' #每5分鐘執行一次

製品歸檔&快取 (artifacts&cache)

一般用於CI製品的歸檔,以及CI構建的快取

archiveArtifacts artifacts: 'target/*.jar', fingerprint: true
job:
  artifacts:
    name: "$CI_JOB_NAME"
    paths:
      - binaries/

cache: &global_cache
  key: $CI_COMMIT_REF_SLUG
  paths:
    - node_modules/
    - public/
    - vendor/
  policy: pull-push

整合憑證(Credentials)

參考:

主要用於CI/CD流水線對接外部工具,通過token/pwd/private key等方式連線外部服務。一般需要在介面做些提前設定,生成token 或者憑證ID,將ID在CI/CD yaml 或jenkinsfile中使用

withCredentials([usernamePassword(credentialsId: 'amazon', usernameVariable: 'USERNAME', passwordVariable: 'PASSWORD')]) {
// available as an env variable, but will be masked if you try to print it out any which way
// note: single quotes prevent Groovy interpolation; expansion is by Bourne Shell, which is what you want
sh 'echo $PASSWORD'
// also available as a Groovy variable
echo USERNAME
// or inside double quotes for string interpolation
echo "username is $USERNAME"
}

Service(服務)

該元素應用於一些複雜的場景,比如需要一種外部(公共)服務為流水線提供某種輸入或者結果。
您可以將相互依賴的服務用於複雜的作業,例如端到端測試,其中外部API需要與自己的資料庫通訊。
例如,對於使用API的前端應用程式的端到端測試,並且API需要資料庫:

end-to-end-tests:
  image: node:latest
  services:
    - name: selenium/standalone-firefox:${FIREFOX_VERSION}
      alias: firefox
    - name: registry.gitlab.com/organization/private-api:latest
      alias: backend-api
    - postgres:14.3
  variables:
    FF_NETWORK_PER_BUILD: 1
    POSTGRES_PASSWORD: supersecretpassword
    BACKEND_POSTGRES_HOST: postgres
  script:
    - npm install
    - npm test

模板(Template)

參考: https://docs.drone.io/template/yaml/
某些平臺會使用「模板「的概念,其實就是複用的思想,通過載入固定模板實現一些快捷動作

kind: template
load: plugin.yaml
data:
  name: name
  image: image
  commands: commands

kind: pipeline
type: docker
name: default
steps:
   - name: {{ .input.name }}
     image: {{ .input.image }}
     commands:
        - {{ .input.commands }}

執行邏輯控制

參考: https://docs.gitlab.com/ee/ci/pipelines/pipeline_architectures.html

stage('run-parallel') {
  steps {
    parallel(
      a: {
        echo "task 1"
      },
      b: {
        echo "task 2"
      }
    )
  }
}

stage('Build') {
            when {
                environment name: 'ACTION_TYPE', value: 'CI&CD'
            }
            steps {                
                buildDocker("vue")                           
            } 
}

stages:
  - build
  - test
  - deploy

image: alpine

build_a:
  stage: build
  script:
    - echo "This job builds something quickly."

build_b:
  stage: build
  script:
    - echo "This job builds something else slowly."

test_a:
  stage: test
  needs: [build_a]
  script:
    - echo "This test job will start as soon as build_a finishes."
    - echo "It will not wait for build_b, or other jobs in the build stage, to finish."

test_b:
  stage: test
  needs: [build_b]
  script:
    - echo "This test job will start as soon as build_b finishes."
    - echo "It will not wait for other jobs in the build stage to finish."

deploy_a:
  stage: deploy
  needs: [test_a]
  script:
    - echo "Since build_a and test_a run quickly, this deploy job can run much earlier."
    - echo "It does not need to wait for build_b or test_b."
  environment: production

deploy_b:
  stage: deploy
  needs: [test_b]
  script:
    - echo "Since build_b and test_b run slowly, this deploy job will run much later."
  environment: production

門禁審批

參考:https://learn.microsoft.com/en-us/azure/devops/pipelines/release/deploy-using-approvals?view=azure-devops

pipeline {
    agent any
    stages {
        stage('Example') {
            input {
                message "Should we continue?"
                ok "Yes, we should."
                submitter "alice,bob"
                parameters {
                    string(name: 'PERSON', defaultValue: 'Mr Jenkins', description: 'Who should I say hello to?')
                }
            }
            steps {
                echo "Hello, ${PERSON}, nice to meet you."
            }
        }
    }
}
pool: 
   vmImage: ubuntu-latest

jobs:
- job: waitForValidation
  displayName: Wait for external validation  
  pool: server    
  timeoutInMinutes: 4320 # job times out in 3 days
  steps:   
   - task: ManualValidation@0
     timeoutInMinutes: 1440 # task times out in 1 day
     inputs:
         notifyUsers: |
            [email protected]
         instructions: 'Please validate the build configuration and resume'
         onTimeout: 'resume'

部署流水線分步驟實施

說了這麼多,如果從0開始寫流水線呢,可以按照下面的步驟,從「點」到「線」結合業務需要串起來,適合自己團隊共同作業開發節奏的流水線才是最好的。

  1. 價值流進行建模並建立簡單的可工作流程
  2. 將構建和部署流程自動化
  3. 將單元測試和程式碼分析自動化
  4. 將驗收測試自動化
  5. 將釋出自動化


注意的事項
開始寫流水線需要注意一下幾個方面,請考慮進去

  • 確定變數 - 哪些是你每次構建或者部署需要變化的,比如構建引數,程式碼地址,分支名稱,安裝版本,部署機器IP等,控制變化的,這樣保證任務的可複製性,不要寫很多hardcode進去
  • 變數/命名的規範化,不要為了一時之快,最後換個機器/換個專案,流水線就不能玩了,還要再改
  • 如果可以,最好是封裝標準動作成為外掛,甚至做成自研平臺服務化,讓更多團隊受益
  • 如果你還在用手動的方式設定流水線,請儘快切換到程式碼方式,不管是jenkinsfile,還是yaml , 一切皆程式碼 也是DevOps提倡的。


流水線案例

案例-1

案例-2