DevOps 自动化实践 — K8s 自动化执行 Database Migration

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」
RightCapital
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 9

是个不错的方案。

3年前 评论

spec.concurrencyPolicy 来禁止并行 其实大部分情况下都不会区关注成功的job,所以指定 spec.failedJobsHistoryLimit ,这样可以缓解controller gc-threshold, 以免他夏季把gc 把你想留下的complete pod给gc了

3年前 评论
Wi1dcard 3年前
Wi1dcard 3年前

我的意思是把job 异常的pod 留下来。。。

3年前 评论

自动进入 Maintenance 状态

这个步骤必要性感觉不是很大。migration的设计应该都向下兼容的,新增字段加默认值或者nullable,应该不会写出类似“删除某个字段”这样影响实际业务运行的脚本。如果出现需要down掉业务再执行migrate,这个migration本身可能就需要调整。

3年前 评论

@motecshine 我们是有配置来留下异常 Pod 的,不过文章中例子为了专注主题省略了很多无关的配置项。

3年前 评论

@jxlwqq 进入 Maintenance 主要是为了等待数据库 take snapshot 期间不要有其他数据变更,因为我们系统是金融数据需要特别小心,所以希望在执行任何数据库 migration 变更之前都留有一个完整的数据库备份。 并且因为我们产品是为美国地区提供服务,所以在北京时间下午上线时正好是美国的深夜,这段时间维护下线服务并不会对我们业务造成影响。权衡利弊之后认为还是进入 Maintenance 对我们公司更合算。

3年前 评论

如果能够完整分享就更好了 :blush:

3年前 评论

我写了一个,分享给每一个会进来的朋友。

apiVersion: batch/v1
kind: Job
metadata:
  name: "{{ .Release.Name }}-seeder-job"
  labels:
    app.kubernetes.io/managed-by: {{ .Release.Service | quote }}
    app.kubernetes.io/instance: {{ .Release.Name | quote }}
    app.kubernetes.io/version: {{ .Chart.AppVersion }}
    helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
  annotations:
    "helm.sh/hook": post-install,post-upgrade
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded,hook-failed
spec:
  template:
    metadata:
      name: "{{ .Release.Name }}-seeder-job"
      labels:
        app.kubernetes.io/managed-by: {{ .Release.Service | quote }}
        app.kubernetes.io/instance: {{ .Release.Name | quote }}
        helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
    spec:
      restartPolicy: Never
      containers:
        - name: backend
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          env:
            - name: TZ
              value: "Asia/Shanghai"
            - name: APP_URL
              value: "{{ .Values.laravel.url }}"
            - name: APP_DEBUG
              value: "{{ .Values.laravel.debug }}"
            - name: APP_ENV
              value: "{{ .Values.laravel.env }}"
            - name: DB_HOST
              value: "{{ .Values.laravel.datasource.host }}"
            - name: DB_PORT
              value: "{{ .Values.laravel.datasource.port }}"
            - name: DB_DATABASE
              value: "{{ .Values.laravel.datasource.database }}"
            - name: DB_USERNAME
              value: "{{ .Values.laravel.datasource.username }}"
            - name: DB_PASSWORD
              value: "{{ .Values.laravel.datasource.password }}"
          command:
            - php
            - artisan
            - db:seed

这一份会在helm install和helm upgrade时执行php artisan db:seed 数据填充。
会在job失败、有新的job、job成功后,自动删除自己。

3年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!