數據庫升級 DevOps 落地實踐

在我們做持續集成/交付的過程中,應用的發佈已經通過 DevOps 流水線基本能滿足快速迭代的需求,但是很多企業在落地實踐 DevOps 的過程中很容易忽略的一點是關於應用數據庫版本、升級的管理,每次上線發佈數據庫的更新依然通過運維或者 DBA 手工更新,在微服務、容器盛行的背景下,服務多,服務發佈速度快,顯然靠人工該 DB 是跟不上迭代速度的,從而導致 DB 的更新成了整個軟件交付週期的瓶頸。這一點我是深有體會,尤其是每次上線時,多個微服務同時上線,同時還需要進行 DB 升級,這個時候研發人員會給我們 SQL 執行,當然研發人員的數量是遠遠多於開發人員,所以每次上線運維人員經常陷入被”圍堵“的尷尬境地。當然還存在其他種種痛點,大概總結下有如下痛點:

  • DB 的升級人工執行跟不上版本發佈速度,成爲軟件交付的瓶頸;
  • 人工執行 DB 升級錯誤率比自動化執行更容易出錯;
  • 各個環境 DB 沒有統一的版本管理,經常會出現這個 SQL 有沒有在某個環境執行的疑問;
  • 環境之間數據腳本同步經常出現遺漏的情況,由於開發或測試環境操作 DB 的人多,在應用從一個環境升級到另一個環境中經常忘記執行某條 SQL,然後導致各種問題故障。這種問題甚至在應用上線時也會頻頻出現,然後通知運維或 DBA 執行遺漏 SQL;

那麼在 DevOps 落地實踐中如何很好地處理好數據庫升級這一環呢,從而解決上述存在的種種痛點,不要讓數據庫升級成爲軟件交付的瓶頸,使得數據庫的升級流程融入自動化流水線。我調研了下業界關於應用 DB 升級的方案,不少文章或者圈內人士推崇專門的數據庫管理工具版本化管理,自動化執行,比如 FlywayLiquibase 等著名的工具,都是專業的數據庫版本管理和自動化工具。

在本文中主要介紹如何將 Flyway 和其他 DevOps 工具鏈整合,實現 DB 升級的自動化和管理的版本化,從而解決之前存在的一系列痛點。本文用到的工具鏈有:Flyway + Jenkins 2.0(Pipeline 腳本)+ Gitlab + MySQL。需要說明的一點是本文並不是一步步講解各種工具鏈如何使用和相關介紹,重點在於工具鏈的整合實踐,以及如何恰當地應用。在文末附有完整的 Pipeline 實現腳本,僅供參考!

數據庫升級 DevOps 實踐帶來了什麼收益

其實在文章開言已經說清楚了,總結起來就兩點:

  • 所有環境數據庫版本統一管理;
  • 數據庫升級變更自動化;

實踐方案概要

數據庫升級腳本統一按微服務模塊以獨立 git 倉庫的形式管理起來,每次版本迭代,規劃好 SQL 模型定義(DDL),將 db 腳本簽入獨立的 git 倉庫,然後使用專門的數據庫版本管理工具自動掃描倉庫目錄的 db 升級腳本,由於 db 升級腳本文件名稱符合一定的命名規範,所以工具可以自動按版本號順序執行腳本,並且已經執行過的腳步文件再次執行會忽略。關於 DB 升級工具的選擇,我們選用 Flyway,功能單一、容易上手,以規約優於配置的思想規範 DB 的版本化管理,我們寫的 SQL 腳本文件都必須符合 Flyway 的文件名命名規範,這樣才能在升級過程中生效。

具體實踐

藉助的工具鏈:Flyway + Jenkins 2.0(Pipeline 腳本)+ Gitlab + MySQL(Google Cloud SQL)
在這裏插入圖片描述

  1. 以微服務應用 git 工程名稱在 gitlab 一個單獨的組創建 db 代碼工程;
  2. 在 db 代碼工程中創建以數據庫命名的目錄,存放對應數據庫升級的腳本文件,腳本文件名稱需要符合 FlywayDB 的命名規範:
    在這裏插入圖片描述
  3. db 代碼工程分支管理:dev 環境對應 dev 分支,test 環境對應 test 分支,stage 環境對應 stage 分支,生產環境對應 master 分支;
  4. Jenkins 腳本註冊相應代碼工程名稱和對應 db 名稱;
  5. 點擊 Jenkins 執行數據庫升級;

強制規約

  1. gitlab 代碼工程名稱和 db 工程名稱一致,db 工程目錄下文件夾以數據庫名稱命名;
  2. db 腳本名稱符合 FlywayDB 命名規範;
  3. db 腳本文件版本名統一大於 1.0,比如: 可以是 V1.0.1,但不能是 V0.2.3;
  4. db 腳本內容爲 DDL 語句,不能包含 DML 或者 DCL 語句,這個要嚴格審覈,因爲 DML 和 DCL 版本追蹤沒意義,而且各個環境可能還不兼容,FlywayDB 的本質是數據庫 Sechma 版本管理,只關心表結構,表裏面的數據不關心。關於數據庫 DDL、DML、DCL 相關概念及區別見這裏
  5. 已經執行過的 db 腳本不能修改後重複執行,並且執行過的 db 腳本文件需要原封不動保留,不能丟失和修改,否則升級會失敗,這個一定要注意。如果對已經執行的 db 腳本不滿意,有改動需要變更,則新加 db 腳本文件,可以小版本號比原先增 1,相當於臨時 fix,但是我們儘量減少這種情況的發生;

具體 Workflow

開發人員 Workflow

開發、測試、預發佈環境開發人員點擊 Jenkins job 執行數據庫升級:

  1. 將 SQL 腳本按照 FlywayDB 規範提交到對應的 db 倉庫,提交 MR 到對應分支;
  2. 小組 db 腳本審覈人審覈沒問題後合併 db 代碼;
  3. 小組成員點擊 Jenkins Job,執行數據庫升級
    3.1 選擇環境+服務名稱+要升級的數據庫名稱
    在這裏插入圖片描述
    3.2 運行 Job
    在這裏插入圖片描述

運維人員 Workflow

運維人員只負責線上 SQL 的升級:

  1. 開發人員告知運維人員本次上線 db 腳本已提交到代碼倉庫並 merge 到 master 分支;
  2. 運維人員點擊 Jenkins Job 執行相應服務的數據庫升級:
    2.1 選擇服務名稱+要升級的數據庫名稱
    在這裏插入圖片描述
    2.2 運行 Job,Pipeline 會阻塞在確認節點,做最後的審查
    在這裏插入圖片描述
    2.3 Job 執行完成
    在這裏插入圖片描述

Workflow 舉例

  1. 新建一個 gitlab 工程,專門存放 db 腳本:
    服務名稱假設爲 db-migration-demo,db 名稱爲 demo,倉庫裏面存放的 SQL 腳本如下:
    在這裏插入圖片描述
  2. git 提交代碼,然後點擊 Jenkins Job,執行數據庫升級
    在這裏插入圖片描述

關於 Pipeline 設計的兩個功能點

1. 數據庫整庫備份策略

數據庫 DDL 變更前整庫備份一下是有必要的,但是每次變更都整庫備份也不合理,因爲可能某天上線,數據庫升級比較集中,一天內會觸發很多次備份,造成了資源的浪費。解決方案是給備份一個時間窗口(比如 2 小時),每次執行前判斷下最近兩小時是否有備份,如果沒有則觸發整庫備份,這樣就能避免每次執行 Job 都會觸發整庫備份。
具體解決方法:
獲取當前時間減去兩小時的時間,然後和上次整庫備份的時間戳比較,如果前者大,說明最近兩小時內沒備份,然後自動觸發整庫備份,時間戳比較用 Shell 腳本實現:

# !/bin/bash

t1=`date -d "$1" +%s`
t2=`date -d "$2" +%s`

if [ $t1 -ge $t2 ]; then
    echo "true"
else
    echo "false"
fi

2. 每次變更前備份庫下的所有表結構,同時記錄下 FlywayDB 更改前後狀態

表結構備份和 FlywayDB 更改前後狀態信息都以製品的方式歸到 Jenkins,這樣可以隨時在 Jenkins 界面查看相關信息,比如查看 Flyway 前後執行狀態如何,點開製品頁即可看到:
在這裏插入圖片描述
在這裏插入圖片描述

附:Pipeline 腳本實現

爲例減小文章的篇幅,這裏只貼下運維人員 Workflow 的 Jenkins pipeline 腳本,研發人員的和這個類似,只是一些小的改動。

pipeline {
  parameters {
    //服務名稱
    choice(name:'serviceName', choices: [
     'db-migration-demo'
     ]
    , description: '服務名稱')
    //數據庫
    choice(name:'dbName', choices: [
     'demo'
    ]
    , description: '數據庫名稱')
  }
  agent {
    kubernetes {
      label "sql-${UUID.randomUUID().toString()}"
      defaultContainer 'jnlp'
      yaml """
apiVersion: v1
kind: Pod
metadata:
  labels:
    some-label: db-imgration
spec:
  containers:
  - name: flyway
    image: boxfuse/flyway
    command:
    - cat
    tty: true
  - name: mysql-client
    image: arey/mysql-client
    command:
    - cat
    tty: true
  - name: gcloud
    image: google/cloud-sdk:alpine
    command:
    - cat
    tty: true
"""
    }
  }
  post {
      failure {
        echo "Database migration failed!"
      }
      success {
        echo "Database migration success!"
      }
  }


  options {
      gitLabConnection('gitlab-connection')
      //保持構建的最大個數
      buildDiscarder(logRotator(numToKeepStr: '20'))
  }

  stages {
    stage('初始化') {
        steps {
          script {
            currentBuild.description = "production環境${params.serviceName}服務${params.dbName}庫升級..."
          }

          container('gcloud') {
            withCredentials([file(credentialsId: 'cloudInfrastructureAccess', variable: 'cloudSQLCredentials')]) {
              sh "gcloud auth activate-service-account ${env.cloudInfrastructureAccessSA} --key-file=${cloudSQLCredentials} --project=${env.gcpProject}"
            }
          }

          // 判斷是否要進行數據庫備份,如果兩小時內沒有備份則自動觸發全量備份
          script {
            isBackup = 'false'
            // 默認 jenkins 跑在 busybox 容器,獲取時間和普通 Linux 發行版有點區別
            date2HoursAgo = sh(returnStdout: true, script: "date -u +'%Y-%m-%d %H' -d@\"\$((`date +%s`-7200))\"").trim()
            container('gcloud') {
              latestDBBackupTime = sh(returnStdout: true, script: "gcloud sql backups list --instance=${env.prodMySqlInstance} --limit=1 | grep -v 'WINDOW_START_TIME' | awk '{print \$2}' | awk -F ':' '{print \$1}'|sed 's/T/ /g'").trim()
            }
            withCredentials([file(credentialsId: 'time-compare.sh', variable: 'timeCompare')]) {
              isBackup = sh(returnStdout: true, script: "sh ${timeCompare} \'$date2HoursAgo\' \'$latestDBBackupTime\'").trim()
              echo "$date2HoursAgo"
              echo "$latestDBBackupTime"
              echo "$isBackup"
            }
          }
        }
    }

    // 如果兩小時內沒有備份則自動觸發全量備份
    stage('整庫智能備份') {
        when {
          expression { isBackup == 'true' }
        }
        steps {
          script {
            container('gcloud') {
              // 列出最近 10 個備份,便於觀察
              sh "gcloud sql backups list --instance=${env.prodMysqlInstance} --limit=10"
              backupTimestamp = sh(returnStdout: true, script: "date -u +'%Y-%m-%d %H%M%S'").trim()
              backupDescription="Flyway backuped at $backupTimestamp (UTC)"
              // gcloud 創建 db 備份
              sh "gcloud sql backups create --async --instance=${env.prodMysqlInstance} --description=\'$backupDescription\'"
              sh "gcloud sql backups list --instance=${env.prodMysqlInstance} --limit=10"
            }
          }
        }
    }

    stage('表結構備份') {
      steps {
        withCredentials([usernamePassword(credentialsId: "sql-secret-production", passwordVariable: 'sqlPass', usernameVariable: 'sqlUser')]) {
            container('mysql-client') {
              sh "mysqldump -h ${env.prodMySqlHost} -u$sqlUser -p$sqlPass -d ${params.dbName} --single-transaction > ${params.serviceName}-${params.dbName}-`TZ=UTC-8 date +%Y%m%d-%H%M%S`-dump.sql"
            }
        }
        // 表結構備份同步到 gcs 存儲桶
        container('gcloud') {
          sh "gsutil cp *-dump.sql ${env.gcsBackupBucket}/db/production/${params.serviceName}/"
        }

        // jenkins 歸檔數據庫備份,可在 BlueOcean 頁面製品頁查看
        archiveArtifacts "*-dump.sql"
      }
    }

    stage('拉取 db 腳本') {
      steps {
        script {
          checkout([$class: 'GitSCM', branches: [[name: "master"]], doGenerateSubmoduleConfigurations: false, extensions: [], submoduleCfg: [], userRemoteConfigs: [[credentialsId: 'jenkins-deploy', url: "${env.dbMigrationGitRepoGroup}/${params.serviceName}"]]])
        }
      }
    }

    stage('flyway migrate') {
      steps{
        script {
          host = "${env.prodMySqlHost}"
          timestamp = sh(returnStdout: true, script: "TZ=UTC-8 date +%Y%m%d-%H%M%S").trim()
          flywayStateFile = "flyway-state-production-${params.serviceName}-${params.dbName}_${timestamp}.txt"

          container('flyway') {
            withCredentials([usernamePassword(credentialsId: "sql-secret-production", passwordVariable: 'sqlPass', usernameVariable: 'sqlUser')]){
              sh "flyway -url=jdbc:mysql://${host}/${params.dbName}?useSSL=false -user=${sqlUser} -password=${sqlPass} -locations=filesystem:${params.dbName} -baselineOnMigrate=true repair"
              sh "echo \"[ flyway 升級前 db 狀態 ]\" > $flywayStateFile"
              sh "flyway -url=jdbc:mysql://${host}/${params.dbName}?useSSL=false -user=${sqlUser} -password=${sqlPass} -locations=filesystem:${params.dbName} -baselineOnMigrate=true info \
                 | tee -a $flywayStateFile"
              try {
                timeout(time: 8, unit: 'HOURS') {
                  env.isMigrateDB = input message: '確認升級 DB?',
                  parameters: [choice(name: "isMigrateDB", choices: 'Yes\nNo', description: "您當前選擇要升級的是${params.serviceName}服務${params.dbName}庫,確認升級?")]
                }
              } catch (err) {
                sh "echo 'Exception!' && exit 1"
              }
              if (env.isMigrateDB == 'No') {
                sh "echo '已取消升級!' && exit 1"
              }
              sh "flyway -url=jdbc:mysql://${host}/${params.dbName}?useSSL=false -user=${sqlUser} -password=${sqlPass} -locations=filesystem:${params.dbName} -baselineOnMigrate=true migrate"
              sh "echo \"\n\n[ flyway 升級後 db 狀態 ]\" >> $flywayStateFile"
              sh "flyway -url=jdbc:mysql://${host}/${params.dbName}?useSSL=false -user=${sqlUser} -password=${sqlPass} -locations=filesystem:${params.dbName} -baselineOnMigrate=true info \
                 | tee -a $flywayStateFile"
              archiveArtifacts "$flywayStateFile"
            }
          }
        }
      }
    }
  }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章