作者個人介紹
劉晨 Lorraine
坐标Fintech,精通持續內建與釋出,曾具有全平台100+應用持續部署持續釋出實戰經驗,現在立志于成為K8S玩家。

背景
大家好,筆者所在的團隊目前面臨落地公司業務數字化轉型的重大任務。我們面臨的主要研發挑戰是如何快速得疊代出不斷新增的開發需求,由于沒有太多曆史包袱,團隊選擇的技術棧也是相對成熟與流行的,比如我負責的Devops主要是基于阿裡雲ACK容器以及Jenkins2.0+進行搭建實施。
阿裡雲ACK容器也是基于K8S1.16.9封裝的雲服務,我們選擇的是托管版本,即master節點托管于阿裡雲,我們隻負責worker節點叢集的運維與管理。這樣做的好處是使團隊力量盡可能得集中在業務層面,基礎設施層面的運維工作盡量服務化。選擇Jenkins2.0有諸多好處,比如Jenkins是經典的CI實施工具平台,開發測試多數熟悉這種使用方式,無需再次學習。2.0版本以後,引入了聲明式的Jenkinsfile文法,這種流水線編排格式與基于聲明式API的K8S能夠更天然地內建,并且Jenkins生态圈的plugin十分豐富,基本可以通過配置方式滿足團隊建構多語言多項目CICD任務的使用場景。
核心問題
目前實施CICD的業務,基本都要解決以下核心問題。
傳遞物是什麼?
部署環境有哪些?
環境配置資訊是什麼?
如何自動化地執行建構,部署任務?
如果建構,部署任務執行失敗,或者部署環境的應用運作失敗,如何感覺故障的發生?
傳遞物定義與結構
傳遞物對象是在整個CICD流程中需要最先确定的實體,是開發跟devops之間互動的對接物。開發與devops一同參與傳遞物實體的定義,再由devops針對定義提供傳遞物的通用模版。
傳遞物定義
團隊對于傳遞物的定義是基于dockerfile生成的微服務鏡像。
devops提供了基于阿裡雲容器鏡像服務的私有倉庫來存放,如圖:
開發基于dockerfile提供鏡像打包定義,我們以java應用為例,鏡像内容主要包括基于openjdk基礎鏡像的工作目錄定以及指定啟動cmd,示例代碼如下:
FROM openjdk:11.0.8
WORKDIR /home/demo
COPY target/index-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java","-jar","app.jar"]
傳遞物結構
每個應用部署傳遞物都歸檔在一個獨立的鏡像倉庫,鏡像倉庫命名規範為項目名稱.應用服務名稱,鏡像tag的命名規範裡應包含每次建構物裡所包含的代碼變更内容,以latest commit id來表示,以及建構發生的時間資訊,以yyyymmddd-hhmm格式表示時間戳,示例如圖:
傳遞物鏡像應是包含了除了環境配置資訊以外的應用部署的全部資訊的實體。以java應用為例,該傳遞物是基于openjdk的jar包的鏡像實體。環境配置資訊可以由環境變量傳遞并改寫。
部署環境與配置
部署環境
CICD理念解決的核心問題之一是在如何在多環境下部署同一傳遞物。典型的部署環境主要有4種:
dev開發環境
傳遞物生成的環境。在開發環境内,傳遞物第一次生成,需要經過必要的單元測試通過率以及代碼安全性檢查等步驟,該環境的傳遞物是可提測的部署物。
test測試環境
傳遞物開始內建測試以及回歸測試的環境。該環境的傳遞物是可上生産的部署物。
stage預釋出環境
預釋出環境的使用場景會依據需求而有所不同。有些公司會将預釋出環境用作demo環境作為poc功能展示;有些公司會在預釋出環境中進行流量壓力測試,確定上生産之前的服務負載與資源配置相比對;有些公司做藍綠釋出或者A/B測試時,則會利用stage環境來做流量切換。無論stage環境如何被使用,stage部署環境都需與生産部署環境配置保持一緻。
prod生産環境
生産釋出環境是傳遞物最終運作環境,對使用者提供服務。
環境配置
環境配置資訊是CICD過程中,獨立與傳遞物,但依賴于部署環境的一系列環境變量資訊。基于K8S叢集部署時,實作的主要方式是ConfigMap以及Secret資源對象。
建構自動化
我們有了Dockerfile來描述傳遞物的定義,簡單來說隻要docker build 然後docker push到私有鏡像倉庫就完成了建構。基于Jenkins搭建自動化的建構任務就是将這個過程自動化。後文實施部分會詳細講述。
建構任務自動化主要考慮的問題有:
當main/master branch有代碼送出commit生成時,如何自動觸發建構任務執行?
不同語言應用,比如java應用或者前端應用的建構流程是怎樣的?包含哪些步驟?
生成的傳遞物鏡像如何成功放至到阿裡雲容器鏡像服務的私有倉庫裡?
傳遞物傳輸過程如何確定網絡傳輸的安全性與可靠性?
部署自動化
我們的部署環境是在阿裡雲容器叢集中,目前每個部署環境通過命名空間來進行資源隔離,後續還會做資源隔離更新。目前對于在K8S叢集中的應用部署,主流的解決方案有Helm和Kustomize,我們最後選擇了HelmV3來實施。Helm與Kustomize之間的選擇是另一個有意思的話題,不在本章展開。基于Helm的部署自動化簡單來說就是建構一個能夠運作helm Install指令的jenkins任務。後文實施部分會詳細講述。
部署自動化主要考慮的問題有:
如何擷取到傳遞物?
部署的是哪個傳遞物?要部署的目标環境是哪個?
如何定義部署成功?
部署失敗了,如何感覺到故障?如何快速定位到問題?
如何實作復原操作?
事件監控與告警機制
CICD的目标是盡可能的自動化全流程,降低人為參與的程度。當自動化程度越高,對于自動任務的故障發現與告警就越有必要。我們的服務部署在阿裡雲容器叢集中,基于Jenkins2.0來搭建自動化CI/CD的任務,建構部署依賴與Jenkins任務執行是否成功,以及叢集中的資源按照Helm的定義是否如期更新運作。CI/CD相關的事件監控圍繞着叢集資源事件監控以及Jenkins任務監控兩方面來進行。
阿裡雲容器叢集事件監控與告警
阿裡雲服務提供了事件監控與告警服務,可以直接配置使用,如圖所示,該示例為阿裡雲叢集服務/運維管理/事件清單提供的事件監控儀表盤服務。我們可以輕松得到整個叢集資源運作的情況。
進入“告警配置”服務就能配置基本事件的告警,叢集初始化了很多基本事件的告警配置,比如Pod/Node OOM;Pod啟動失敗,或者資源不足,無法排程等。可以依據業務需求,定制化告警事項。筆者目前采用的是使用預設配置的告警模版,對發生頻次做了一定容忍,通過郵件方式進行告警。
Jenkins任務失敗自動發送郵件告警
E-mail Notification Plugin可以幫助實作Jenkins服務對于任務完成自動發送郵件的功能。
首先在Jenkins/configuration/Extended E-mail Notification配置SMTP郵件服務資訊,我們使用的是阿裡雲郵件推送服務, 確定填寫了正确的smtp server, smtp port,如果有smtp username/passwrod,也需要正确填寫。
其次在Jenkinsfile裡聲明使用email extension plugin,對于建構任務失敗自動發送郵件的代碼塊如下:
emailext (to: '[email protected]', subject: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'", body: """<p>FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]':</p> <p>Check console output at "<a href='${env.BUILD_URL}'>${env.JOB_NAME} [${env.BUILD_NUMBER}]</a>"</p>""", recipientProviders: [[$class: 'DevelopersRecipientProvider']] )
###實施
基于Dockerfile/Jenkinsfile建構CI流水線
傳遞物由Dockerfile定義,每個應用服務的根目錄下都應該有一個Dockerfile檔案,定義了該服務的建構過程。本節裡我們舉java微服務來說明,這也是我們後端微服務的建構流水線。
Java微服務
Java應用的建構邏輯定義在Jenkinsfile裡,基于maven實作。Dockerfile模版隻定義了如何調用建構好的jar包,即部署指令。這樣做的主要目的是盡可能縮小鏡像,降低網絡資料傳輸的負載。
聲明式Jenkinsfile的好處是可以通過代碼方式定義與管理流水線邏輯。基于代碼就擁有了版本管理的能力。
建構Pipeline主要包含了以下步驟:
scm 擷取項目源代碼
基于maven的建構,生成可部署的jar包
基于maven的單元測試
基于SonarQube的代碼檢查
基于kaniko的雲原生方式,生成image,并推送至阿裡雲私有鏡像倉庫
建構Pipeline主流程之外的後置步驟是建構任務執行完成後的downStream任務,連結的是通用部署任務,這種結構解耦了建構與部署邏輯,可以使不同的建構任務複用同一個部署任務。
def branch_name
def revision
def registryIp = "registry-vpc.cn-shanghai-finance-1.aliyuncs.com"
def app = "XXX"
pipeline {
agent{
node{
label 'slave-java' // Jenkins Slave Pod Template
}
}
stages {
stage ('Checkout') {
steps {
script {
def repo = checkout scm
revision = sh(script: 'git log -1 --format=\'%h.%ad\' --date=format:%Y%m%d-%H%M | cat', returnStdout: true).trim()
branch_name = repo.GIT_BRANCH.take(20).replaceAll('/', '_')
if (branch_name != 'master') {
revision += "-${branch_name}"
}
sh "echo 'Building revision: ${revision}'" // 擷取代碼并生成鏡像tag(latest-commit-id+timestanp+branch)
}
}
}
stage('Compile') {
steps {
container("maven") {
sh 'mvn -B -DskipTests clean package' // 基于maven的建構步驟
}
}
}
//unit test 測試部署
stage('Unit Test') {
steps {
container("maven") {
sh 'mvn test org.jacoco:jacoco-maven-plugin:0.7.3.201502191951:prepare-agent install -Dmaven.test.failure.ignore=true'
}
}
}
// 上傳Jacoco檢測結果
stage('JacocoPublisher') {
steps {
jacoco()
}
}
stage('Build Artifact') {
steps {
container("maven") {
sh 'chmod +x ./jenkins/scripts/deliver.sh'
sh './jenkins/scripts/deliver.sh'
}
}
}
stage('SonarQube Analysis'){
environment {
scannerHome = tool 'SonarQubeScanner'
}
steps {
withSonarQubeEnv('sonar_server') {
sh "${scannerHome}/bin/sonar-scanner"
}
}
}
// 添加stage, 運作容器鏡像建構和推送指令
stage('Image Build and Publish for Dev Branch'){
when { not { branch 'master' } }
steps{
container("kaniko") {
sh "kaniko -f `pwd`/Dockerfile -c `pwd` --destination=${registryIp}/xxxx/xxx.${app}:${revision} --skip-tls-verify"
}
}
}
// 添加stage, 運作容器鏡像建構和推送指令
stage('Image Build and Publish for Master Branch'){
when { branch 'master' }
steps{
container("kaniko") {
sh "kaniko -f `pwd`/Dockerfile -c `pwd` --destination=${registryIp}/xxxx/xxx.${app} --destination=${registryIp}/xxxx/xxx.${app}:${revision} --skip-tls-verify"
}
}
}
}
post {
always {
echo 'This will always run'
}
success {
script {
build job: '../xxx.app.deploy/master', parameters: [string(name: 'App', value: String.valueOf(app)), string(name: 'Env', value: 'dev-show'), string(name: 'Tag', value: String.valueOf(revision))]
}
}
failure {
emailext (
to: '[email protected]',
subject: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'",
body: """<p>FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]':</p>
<p>Check console output at "<a href='${env.BUILD_URL}'>${env.JOB_NAME} [${env.BUILD_NUMBER}]</a>"</p>""",
recipientProviders: [[$class: 'DevelopersRecipientProvider']]
)
}
unstable {
echo 'This will run only if the run was marked as unstable'
}
changed {
echo 'This will run only if the state of the Pipeline has changed'
echo 'For example, if the Pipeline was previously failing but is now successful'
}
}
}
建構任務一般是根據主分支送出的代碼自動觸發的任務。為了實作autoTrigger,我們需要使用webHook連接配接SCM服務和Jenkins服務,我們使用的SCM服務是Bitbucket,通過使用hook插件實作了autoTrigger。
基于阿裡雲容器服務的部署環境屬于VPC内,即我們使用的容器鏡像服務是VPC内的私有鏡像服務,VPC外部無法通路。K8S叢集也屬于該VPC内的資源,故叢集内部的CI Jenkins Slave Pod以及目标部署Pod服務都在該VPC的内網内,與私有化的容器鏡像服務直接區域網路連接配接,確定了資料傳輸的安全性與可靠性。
基于Helm/Jenkinsfile建構CD流水線
在K8S上部署一個應用,傳統方式一般利用kubectl建立一系列的資源對象(deployment,configMap,secret,serviceAccount, service,ingress 等)。在CICD部署流程中,涉及同一部署物部署到多個環境内,即部署發生多次。舉個例子,部署在開發和測試環境的兩個Deployment對象,結構基本一緻,隻有少數屬性的指派依據環境而有所不同。CICD軟體實踐裡有一個重要理念是部署可以重複,確定在各個環境的部署的動作是一緻的,以避免在釋出流水線中引入差異。Helm是一種适應上述需求的模版式解決方案。Helm的介紹不在本章展開,有興趣學習的讀者可以參考。
https://whmzsu.github.io/helm-doc-zh-cn/helm提供了chart包(一種可以封裝K8S資源對象為模版檔案的集合)和values.yaml(屬性參數的集合)結構,基于Go Template文法,解耦了manifest裡屬性值與K8S資源對象模版結構。使得一組對應用部署所需資源對象的建立可以通過模版定義加指派的方式,複用到多個環境,重複部署,解決了K8S資源對象管理的問題。
下來以Java微服務的helm/Chart包舉例來說明我們是如何生成應用部署物的
helm包初始化可以使用helm create Name [flags]
部署物helm包的目錄結構如下:
根目錄下的values.yaml是所有環境公共的屬性集合,比如關于建立serviceAccount,rbac相關的屬性資訊,dev/test/stage/production.yaml是與部署環境有關的屬性集合。
執行helm install 指令來部署目前Helm包模版,例如
helm upgrade index ./helm/ -i -nbss-dev -f ./helm/values.yaml -f ./helm/dev.yaml --set 'image.tag=latest' --set 'image.repo=bss.index' --set 'ingress.hosts.paths={/index}'
前文提到了我們使用聲明式Jenkinsfile文法建構部署流水線邏輯,與傳統groovy不一樣的是,我們可以通過聲明pipeline,agent/node,stages/step等對象,直接将部署流程的定義聲明。以部署基于helm的jar包應用為例,部署物為在阿裡雲私有鏡像倉庫的鏡像檔案,上文例子所示。部署流程包括兩個步驟:1. 擷取helm代碼 2. 執行helm install,具體如下:
agent{
node{
label 'slave-java' // Jenkins Slave Pod Template
}
}
parameters {
choice(name: 'App', choices: ['111', '222', '333','444'], description: '選擇部署應用')
choice(name: 'Env', choices: ['dev', 'stage', 'test',], description: '選擇部署環境')
string(name: 'Tag', defaultValue: 'latest', description: '請輸入将要部署的建構物鏡像Tag')
}
stages {
stage ('CheckoutHelm') {
steps {
script {
def repo = checkout scm
revision = sh(script: 'git log -1 --format=\'%h.%ad\' --date=format:%Y%m%d-%H%M | cat', returnStdout: true).trim()
branch_name = repo.GIT_BRANCH.take(20).replaceAll('/', '_')
if (branch_name != 'master') {
revision += "-${branch_name}"
}
}
}
}
stage ('Deploy') {post {steps { container('helm-kubectl') { sh "chmod +x ./helm/setRevision.sh" sh "./helm/setRevision.sh ${revision}" sh "helm upgrade -i ${params.App} ./helm/ -nxxx-${params.Env} -f ./helm/${params.Env}.yaml --set-file appConfig=./appConfig/${params.Env}/${params.App}.yml --set image.tag=${params.Tag} --set image.repo=bss.${params.App} --set ingress.hosts.paths={/${params.App}}" } } } }
always { echo 'This will always run' } success { echo 'This will run only if successful' } failure { emailext ( to: '[email protected]', subject: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]'", body: """<p>FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]':</p> <p>Check console output at "<a href='${env.BUILD_URL}'>${env.JOB_NAME} [${env.BUILD_NUMBER}]</a>"</p>""", recipientProviders: [[$class: 'DevelopersRecipientProvider']] ) } unstable { echo 'This will run only if the run was marked as unstable' } changed { echo 'This will run only if the state of the Pipeline has changed' echo 'For example, if the Pipeline was previously failing but is now successful' } }
經驗總結與展望
流水線自助化改進
筆者團隊面對的業務架構是不斷增長的微服務叢集。基于此,對于CICD的自動化要求以及釋出頻率不斷提出了新的挑戰。原先我們的做法是每個微服務擁有獨立的helm部署包,但是由于快速增加的微服務數量,對于快速注冊新服務到現有CICD流水線中,産生了開發快速建構部署服務的需求。
面臨的問題主要是helm部署模版是以應用部署所需的資源對象建構為單元,在不同部署環境做配置。每個應用都會建立一套helm部署包以及的獨立的建構和部署任務。當越來越多的微服務部署需求不斷增加時,建構helm包以及相應的建構部署任務的配置工作量就會成為CICD流程的效率瓶頸。
解決方案是抽象一個公共helm部署包模版,将各個應用部署時定制化的屬性資訊集合從原有helm包結構中再抽象出來,以AppConfig File方式獨立成一層配置資訊,例如:
這裡的模版抽象實作主要是依賴Helm指令裡-f 傳入values.yaml 時,可以同時-f 多個屬性集合檔案,并且位于後面的-f 的檔案可以覆寫前面的屬性參數值。如果定制化配置資訊檔案不是Go template可以直接使用的格式,可以考慮使用flag --set-file 直接将Config檔案裡的内容寫入某個上層的屬性參數。
安全問題
将開發,測試環境都搬到公有雲上,通路安全是一個不可忽略的問題。我們的傳遞物,部署流水線以及目标部署叢集都基于一個VPC下,從網絡傳輸角度,資料互動在一個區域網路内,是相對安全的公有雲網絡環境。除此之外,為了保證開發環境,測試環境完全的私有化,在微服務應用部署ingress資源對應的slb上我們也設定了相應的通路控制,隻能對公司内部的使用者開放網絡通路。後續對于公網通路的服務以及外部部署環境和CICD服務之間的資料傳輸會考慮使用阿裡雲提供的KMS等資料傳輸保密服務來保證。