要敏捷,持续集成和持续部署必不可少。如何在 CICD 中对数据库进行变更呢?

原始社会版

手动操作。

上线服务的新版本前,将 SQL 准备好,连接数据库执行 SQL。

这样做不仅慢,还十分麻烦,又容易出错,让人提心吊胆。变更历史也难以维护,都2024年,建议永远别再用手动操作的方式来做数据库变更了吧!

在服务启动时自动执行数据库变更

开发时将数据库的变更脚本保存在代码库中,服务启动时自动读取这些变更脚本并和线上数据库的状态做对比,将差异应用上去。一般都有现成的库,对于开发者,只需要做调用现成的方法即可。

nodejs 示例

使用 sequelize

可以使用 sequelize,比如这个项目: https://github.com/Jeff-Tian/alpha/blob/master/package.json#L114 。其配置文件在: https://github.com/Jeff-Tian/alpha/blob/master/.sequelizerc,其数据库变更记录维护在 database 文件夹下:

1720866341586 56471bfa 7908 49e2 9865 388b9672f5e9

对于每个数据库变更的脚本,都包含 up和 down两部分,比如其中一个是这样的:

javascript use strict

module.exports = { up: async (queryInterface, Sequelize) => { /* Add altering commands here. Return a promise to correctly handle asynchronicity.

  Example:
  return queryInterface.createTable(users, { id: Sequelize.INTEGER });
*/

const {INTEGER, DATE, STRING, TEXT} = Sequelize
await queryInterface.createTable(user-passports, {
  provider: STRING,
  uid: STRING,
  user_id: INTEGER,
  created_at: DATE,
  updated_at: DATE,
  profile: TEXT,
})

},

down: async (queryInterface, Sequelize) => { /* Add reverting commands here. Return a promise to correctly handle asynchronicity.

  Example:
  return queryInterface.dropTable(users);
*/
await queryInterface.dropTable(user-passports)

}, }

up是向前应用新的数据库变更,而 down则是在意外情况下需要回滚时,会执行到的。

要让应用在启动时自动执行数据库变更,就需要在应用里添加相关的代码。对于上面的示例,用了 egg js 框架,相关代码在 egg-sequelize 中,以插件形式运行。详细做法见: https://www.eggjs.org/zh-CN/tutorials/sequelize

使用 TypeORM

这里有一个 NestJs 项目示例,数据库变更工具使用了 TypeORM。

https://github.com/Jeff-Tian/uni-orders

详细做法可以参考: https://docs.nestjs.com/techniques/database#repository-pattern

Java 示例

比如可以采用 Flyway,在 pom.xml 里添加依赖:

xml org.flywaydb flyway-core 8.0.5

其数据库变更脚本统一维护在 db/migration 目录下:

1720869109917 a5947c50 c8bd 495b 8490 97f9e48d2973

dotnet 示例

使用 EntityFramework,这里有一个示例:https://github.com/jeff-tian/leg-godt 。其数据库变更也可以统一维护在一个文件夹下,如:

1720869321388 1440d480 9fda 46e6 b486 1896ff6b235b

和 sequelize 比较像,数据库迁移脚本也有 Up 和 Down 两部分,如:

csharp using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;

#nullable disable

namespace Store.Migrations { public partial class Initial : Migration { protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( name: WecomCorps, columns: table => new { Id = table.Column(type: bigint, nullable: false) .Annotation(Npgsql:ValueGenerationStrategy, NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), Name = table.Column(type: text, nullable: false), CorpId = table.Column(type: text, nullable: false), CorpSecret = table.Column(type: text, nullable: false) }, constraints: table => { table.PrimaryKey(PK_WecomCorps, x => x.Id); }); }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropTable(
            name: WecomCorps);
    }
}

}

在应用启动时,需要调用各个 DbContext 的 Database Migrate() 方法,如:

csharp

var context = app.ApplicationServices.GetService();

context.Database.Migrate();

将数据库变更做为 CICD 过程中的一个单独步骤

以上实践方式,需要应用自己在启动时连接数据库并执行数据库变更,因此要保证应用能够有执行数据库模式变更的权限。如果希望限制应用的数据库权限,比如只允许做增删改查,而不能做数据库模式变更,那就得将数据库变更的步骤单独拿出来。

1720869912897 d5a12176 00fd 4d93 80a2 5cd441b7f232

如以上图示,如果采用 GitHub Action,可以专门设置一个数据库变更的步骤,如下:

yaml run-db-migrations: needs: [ prepare-variables ] runs-on: 拥有连接数据库并执行数据库变更权限的 runner environment: ${{ needs.prepare-variables.outputs.deployment_environment }} name: Run DB Migrations timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4

  - name: Apply migrations
    uses: ./.actions/db-migrations
    with:
      environment: ${{ needs.prepare-variables.outputs.deployment_environment }}
      aws-region: aws 区域(这里假设数据库是托管在 AWS RDS 上)
      aws-deployment-role-arn: ${{ secrets.AWS_DEPLOYMENT_ROLE_ARN }}

这里引用了 .actions 里的 db-migrations,其内容如下:

yaml name: 执行数据库迁移 description: 检查是否有数据库迁移待执行,如果有就应用变更到数据库里 inputs:
aws-region: description: 数据库托管所在的 AWS 区域 required: true aws-deployment-role-arn: description: AWS 角色,需要拥有 AWS secrets manager 和 RDS 的只读访问权限 required: true
runs: using: composite steps: - name: 配置 AWS 凭据 uses: aws-actions/configure-aws-credentials@v4 with: audience: sts.amazonaws.com.cn role-to-assume: ${{ inputs.aws-deployment-role-arn }} aws-region: ${{ inputs.aws-region }}

- name: 获取凭据
  uses: aws-actions/aws-secretsmanager-get-secrets@v2.0.5
  with:
    secret-ids: |
      DATABASE_凭据前缀_用来添加到环境变量,/AWS/secret/name-${{ inputs.environment }}-${{ inputs.aws-region }}
    parse-json-secrets: true

- name: 获取 RDS Host
  shell: bash
  run: |
    databaseHost=$(aws rds describe-db-clusters --output text --query DBClusters[?starts_with(Endpoint, xxxx-global-aurora-rdscluster)].{Endpoint:Endpoint})
    echo ::add-mask::$databaseHost
    echo DATABASE_HOST=$databaseHost >> $GITHUB_ENV      

- name: 设置其他的凭据信息
  shell: bash
  run: |        
    echo DATABASE_PASSWORD=$DATABASE_PASSWORD >> $GITHUB_ENV

- name: 执行数据库迁移
  id: run-db-migrations
  shell: bash
  run: |  
    dotnet run --project ./src/YourProject.Migrations/ --no-launch-profile        
  env:
    SERVICE_VERSION: 1.1.1
    SERVICE_NAME: your-migrations
    SERVICE_PROJECT_NAME: your-project
    ENVIRONMENT: ${{ inputs.environment }}

    DATABASE_PORT: 3306
    DATABASE_SSL_MODE: 1
    DATABASE_IAM_AUTHENTICATION_ENABLED: false
    DATABASE_CONNECTION_IDLE_TIMEOUT: 3600
    DATABASE_CONNECTION_LIFETIME: 3600

    DATABASE_NAME: 数据库名称
    DATABASE_USERNAME: migrations-user
    DATABASE_MAX_POOL_SIZE: 10
    DATABASE_MIN_POOL_SIZE: 1

- name: 将总结写到 GitHub Actions 中
  shell: bash
  run: |  
    echo ${{ steps.run-db-migrations.outputs.summary-details }}. >> $GITHUB_STEP_SUMMARY

整个 Actions 代码比较长,但是核心其实就是

shell dotnet run --project ./src/YourProject.Migrations/ --no-launch-profile

与本地测试数据库变更时执行的是一样的脚本。

如何处理失败的情况?

我们并没有改变代码,所以这个问题和将数据库变更拿出来做为一个单独步骤之前的情况是一样的。

如果数据库迁移失败,我们需要手动评估情况,因为我们不知道哪里失败了以及数据库处于什么状态。在大多数情况下,SQL会被回滚,但这只会在数据库上下文中发生。如果迁移中的数据库上下文之间有依赖关系(我们应该避免这种情况),那么我们需要决定是回滚还是继续前进。错误也可能是逻辑上的,所以数据库迁移可能会成功。在这种情况下,我们可能需要创建一个热修复,并像常规发版一样部署它 - 但这取决于严重性、影响和紧急性。在最坏的情况下,应该将其视为涉及数据库恢复的灾难。

当然,我们还可以通过先在开发环境中发布,然后是QA,最后是生产环境这一策略来缓解一些风险。