DevOps 自動化實踐 — K8s 自動化執行 Database Migration

RightCapital發表於2020-04-28

Database Migration 是什麼

Database migration,譯為資料庫遷移,使用程式碼來定義對資料庫資料庫的修改變更。相比手動編寫 SQL,DB migration 將資料庫的修改程式碼化、自動化,並方便在不同環境中同步這些變更。

我們遇到的問題

在早期我們的產品部署到 EC2 後,由人工 SSH 上機器執行 migration,這在當時雖然不理想,但可以接受。

後來隨著團隊的擴大以及部署流程逐漸自動化,由 CI 部署應用到 Kubernetes 叢集,此時繼續依靠人工執行 migration 給我們帶來了幾個問題:

  • 需要溝通且容易遺漏:我們的產品會每週 cut 一個 release 到 producton。但並不是每個 release 都包含資料庫變更,所以需要開發團隊告知運維團隊,某次 release 要處理 migration,但我們有遇到過幾次由於溝通失效導致的遺漏執行 migration 的情況。
  • Migration 可能在執行中被中斷:在 Kubernetes node 資源不足的情況下,Pod 可能被 reschedule 到其它節點,原有 Pod 將會被 terminated。假如此刻正在使用 kubectl exec 執行 migration,該過程很有可能被打斷,出現不可預料的「中間狀態」。
  • 資料庫需要備份:為了確保資料的安全性,我們希望在每次執行 migration 之前先完整備份資料庫,但依靠人工處理不僅浪費了寶貴的工程師時間,還容易造成 human error。

綜上,我們決定將執行 migration 的過程自動化。

使用 Job 自動化執行

為了把 migration 的執行過程自動化,我們在 Helm chart 內新增了一個 Job 資源,在每次部署時都建立一個新的 Job。

與普通的 Job 不同,執行 migration 的 Job 需要保證:

  1. Pod 優先順序高,儘可能不中斷。
  2. 禁止自動重試,一旦執行失敗則必須人工介入,確保資料安全。
  3. 命名唯一不衝突,每次 Helm 部署都應該建立一個全新的 Job。
  4. 由於每次部署會產生新的 Job,因此成功執行完成一段時間後應當自行刪除。
  5. 需要資料庫 root 密碼。

基於這些要求,我們編寫的 Job 大概長這樣:

{{- $fullName := printf "migration-%s" (include "chart.fullname" .) -}}
---
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ $fullName }}.{{ .Release.Revision }}
  labels:
    # 略
spec:
  ttlSecondsAfterFinished: 172800 # 2 days
  backoffLimit: 0
  template:
    metadata:
      # 略
    spec:
      priorityClassName: high-priority-class
      terminationGracePeriodSeconds: 86400 # 24 hours
      restartPolicy: Never
      containers:
        - name: php
          # 部分略
          env:
            - name: DB_ROOT_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: {{ include "chart.fullname" . }}-db-root-password
                  key: dbRootPassword
          args:
            - php
            - artisan
            - migrate
            - --force

可以看到:

  1. 在 PodSpec 中,我們配置了超長的 terminationGracePeriodSeconds,即使收到 terminate 訊號,也留有足夠的時間讓 migration 跑完。更重要的是,我們給這個 Pod 非常高的優先順序(priorityClassName 欄位),當 Kubernetes 因資源問題開始驅逐 Pod 時,能夠儘可能保證高優先順序 Pod 的正常執行。
  2. Job 的 backoffLimit: 0,Pod 的 restartPolicy: Never,確保 Job 不會重試、Pod 不會重啟。
  3. Job 的命名與 helm release 的 revision 掛鉤,因此能夠保證每次部署時唯一。
  4. Job 的 ttlSecondsAfterFinished172800 秒,在 Job 成功執行完成後 48 小時會自動被刪除。
  5. 在 Container env 內注入了名為 DB_ROOT_PASSWORD 的環境變數,值引用自一個單獨的 secret 而不是直接填寫明文,確保安全。關於機密資訊的存放和管理,我們之前曾介紹過相關方案,可參考我們的前作《使用 SOPS 管理 Secret》。

另外,如果你也在使用 Laravel,在 APP_ENVproduction 時執行 migration 會有這樣的 interactive prompt:

**************************************
*     Application In Production!     *
**************************************

 Do you really wish to run this command? (yes/no) [no]:
 >

而 Pod 執行環境中不存在 TTY,因此別忘了給 php artisan migrate 命令加上 --force 引數,強制在 production 環境下直接執行。

自定義的 Migrate 命令

由於 Laravel 自帶的 php artisan migrate 命令無法完全滿足需求,我們在其上做了一層封裝,並自定義了一些附加功能。

自動進入 Maintenance 狀態

為了保證資料一致性,在遷移過程中不應當有業務請求更新資料。因此在開始 migrate 之前,應用需要進入 maintenance 狀態;在 migration 成功完成後,應當恢復服務。因此我們在呼叫 php artisan migrate 之前分別呼叫了 php artisan downphp artisan up。另外我們還指定了 --message 選項以便於分辨維護原因:

Artisan::call('down', ['--message' => 'running migration'], $this->output);
// ...
Artisan::call('up', [], $this->output);

自動備份資料

我們使用的資料庫是 AWS RDS。為了防止自動化遷移出現任何預料外的問題,我們在 migrate:privileged 命令中,通過 AWS PHP SDK 呼叫 CreateDBSnapshot API,為 RDS 建立 Snapshot。一旦遷移失敗,我們可以通過 Snapshot 將資料回滾到遷移前的狀態。

禁止同時執行多個 Migration

雖然概率極小,但考慮到以下兩種情況:

  1. 兩次 CI pipeline 時間非常接近,前者的 migrations 還沒有執行完,第二個 migration job 就被建立出來同時開始執行。
  2. 出現問題需要手動執行 migration,此時另一個 migration job 還正在執行。

為了避免這兩種可能導致資料異常的情況出現,我們還在該命令內加了互斥鎖,同一時間內只能有單個例項在執行。

Slack 通知

最後,我們給整個流程加上了 Slack 通知,以便於工程師們實時得知 migration 的執行狀況:

通過監聽 \Illuminate\Console\Events\CommandFinished 事件,在 php artisan downup 被呼叫後,將 message 通知到 Slack。

final class MaintenanceNotification
{
    public function handle(CommandFinished $event): void
    {
        if (!in_array($event->command, ['up', 'down'], true)) {
            return;
        }

        if ($event->command === 'down') {
            $message = $event->input->getOption('message') === null ? 'maintenance' : $event->input->getOption('message');
        }

        if (app()->isDownForMaintenance()) {
            $color = 'warning';
            $text  = "API is now DOWN for $message.";
        } else {
            $color = 'good';
            $text  = "API is now UP.";
        }

        // Send to Slack webhook...
    }
}
本作品採用《CC 協議》,轉載必須註明作者和本文連結

歡迎關注我們的微信公眾號「RightCapital」

相關文章