はじめに

猫大好きエンジニアの福田です。

皆さんデータベースのマイグレーションはどこでどうやって実行していますか?

Ruby on Rails を Amazon Elastic Container Service (ECS) on Fargate で動かしていますが、今までは以下のような流れでマイグレーションを実行していました。

今までのマイグレーション方法

  1. aws ecs execute-command コマンドでRailsコンテナに接続
  2. rails db:migrate:status でマイグレーション状況を確認
  3. rails db:migrate でマイグレーションを実行
  4. aws ecs update-service コマンドで再起動

問題点

上記のやり方でマイグレーションは実行できますが、以下のような問題がありました。

  • AWSコマンドが実行できる環境やアカウントが必要になる
  • インフラ担当や開発チームのエンジニアしか作業できない
  • 運用チームだけでデプロイ・マイグレーションが実行できない
  • 環境に接続するため、他のコマンドも実行できてしまう

これら問題点を解消すべく、
CodeBuildからビルド実行ボタンをクリックするだけで、マイグレーションできるようにしたいと思います。

前提条件

  • ECS FargateでRailsが動いていること
  • ECRでRailsのイメージが管理されていること
  • CDK(AWS Cloud Development Kit)でインフラを構築していること

構成図(イメージ)

  • aws-codebuild-migration-before-1

CodeBuildでマイグレーションできるようにする

ポイント

RDSやAuroraなどのデータベースはPrivateサブネットで起動し、ECSが起動しているサブネットからしか接続できないようにしていることが多いかと思います。
そのため、CodeBuildをVPC内かつECSが起動するサブネット内で起動するのがポイントです。
指定せずにCodeBuildを起動するとデータベースに接続できず、マイグレーションが実行できません。

マイグレーションから再起動までの流れ

  • CodeBuildをECSのサブネットで起動
  • ECRからRailsのイメージを取得
  • docker runでRailsを起動
  • docker execでrails db:migrateコマンドを実行
  • aws ecs update-serviceコマンドで再起動
  • aws ecs wait services-stableコマンドで再起動完了まで待機

CodeBuild プロジェクトを作成

以下にCDK(TypeScript)とBuildSpec(YAML)のサンプルコードを添付しますが、主にCodeBuildの作成箇所のみをピックアップしているため、そのままでは動きませんのでご注意ください。

CDK(サンプル)

CodeBuild プロジェクト作成例 とコメントしている行以降が、CodeBuildのプロジェクトを作成しているコードになります。

import { App, Stack, StackProps } from 'aws-cdk-lib'
import * as ec2 from 'aws-cdk-lib/aws-ec2'
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'
import * as codebuild from 'aws-cdk-lib/aws-codebuild'
import * as ecs from 'aws-cdk-lib/aws-ecs'
import * as logs from 'aws-cdk-lib/aws-logs'
import * as iam from 'aws-cdk-lib/aws-iam'

//
// 擬似コードですので、そのままでは動きません
//
class SampleStack extends Stack {
  constructor(scope: App, id: string, props: StackProps) {
    super(scope, id, props)

    //
    // VPCやECS等の作成
    //
    const vpc =  new ec2.Vpc( /* */ )
    const ecsSecurityGroup = new ec2.SecurityGroup( /* */ )
    const ecsSubnets = vpc.selectSubnets({ subnetGroupName: 'private-subnet' })
    const ecsExecutionRole = new iam.Role( /* */ )
    const ecsTaskRole = new iam.Role( /* */ )
    const ecsCluster = new ecs.Cluster( /* */ )
    const railsService = new ecs.FargateService( /* */ )
    const railsTaskDefinition = new ecs.TaskDefinition( /* */ )
    const secrets = secretsmanager.Secret.fromSecretNameV2( /* */ )
    const logGroup = new logs.LogGroup( /* */ )

    //
    // CodeBuild プロジェクト作成例
    //
    const migrationProject = new codebuild.Project(scope, 'migration-project', {
      projectName: 'migration-project',
      vpc: vpc,
      subnetSelection: ecsSubnets,
      securityGroups: [ecsSecurityGroup],
      environment: {
        buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,
        computeType: codebuild.ComputeType.MEDIUM,
        privileged: true,
        environmentVariables: {
          APP_DATABASE_USERNAME: { value: ecs.Secret.fromSecretsManager(secrets, 'username').arn, type: codebuild.BuildEnvironmentVariableType.SECRETS_MANAGER },
          APP_DATABASE_PASSWORD: { value: ecs.Secret.fromSecretsManager(secrets, 'password').arn, type: codebuild.BuildEnvironmentVariableType.SECRETS_MANAGER },
          APP_DATABASE_HOST: { value: ecs.Secret.fromSecretsManager(secrets, 'host').arn, type: codebuild.BuildEnvironmentVariableType.SECRETS_MANAGER },
          RAILS_MASTER_KEY: { value: ecs.Secret.fromSecretsManager(secrets, 'RAILS_MASTER_KEY').arn, type: codebuild.BuildEnvironmentVariableType.SECRETS_MANAGER },
        },
      },
      environmentVariables: {
        AWS_DEFAULT_REGION: { value: props.env!.region },
        AWS_ACCOUNT_ID: { value: props.env!.account },
        IMAGE_REPO_NAME: { value: 'IMAGE_REPO_NAME' },
        IMAGE_TAG: { value: 'latest' },
        CLUSTER_NAME: { value: ecsCluster.clusterName },
        RAILS_SERVICE_NAME: { value: railsService.serviceName },
        RAILS_TASK_DEFINITION_NAME: { value: railsTaskDefinition.family },
      },
      buildSpec: codebuild.BuildSpec.fromSourceFilename('./migration.buildspec.yaml'),
      logging: { cloudWatch: { enabled: true, logGroup: logGroup }},
    })

    //
    // 権限設定
    //
    migrationProject.addToRolePolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      resources: ['*'],
      actions: [
        "ecr:GetAuthorizationToken",
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage",
        "ecr:BatchCheckLayerAvailability",
      ],
    }))

    migrationProject.addToRolePolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      resources: [
        railsService.serviceArn,
      ],
      actions: [
        "ecs:UpdateService",
        "ecs:DescribeServices",
      ],
    }))

    migrationProject.addToRolePolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      resources: [
        ecsExecutionRole.roleArn,
        ecsTaskRole.roleArn,
      ],
      actions: ['iam:PassRole'],
    }))
  }
}

buildspec.yml(サンプル)

ビルドスペックファイル(yml形式)です。

# migration.buildspec.yaml
version: 0.2

phases:
  pre_build:
    commands:
      # ECRにログイン
      - aws ecr get-login-password --region ${AWS_DEFAULT_REGION} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com

      # イメージ取得
      - docker pull ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_REPO_NAME}:${IMAGE_TAG}

      # ENVファイル作成
      - env | grep -e APP_DATABASE_USERNAME -e APP_DATABASE_PASSWORD -e APP_DATABASE_HOST -e RAILS_MASTER_KEY > env.list

      # 起動
      - docker run --env-file ./env.list --name ${IMAGE_REPO_NAME} --entrypoint bash -itd ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_REPO_NAME}:${IMAGE_TAG}
  build:
    commands:
      # マイグレーション
      - docker exec ${IMAGE_REPO_NAME} rails db:migrate:status

      # マイグレーション不要な場合は終了(rails db:migrate:status に down が含まれているかで判定)
      - |
        PENDING=`docker exec ${IMAGE_REPO_NAME} rails db:migrate:status | grep '^\s*down'`
        if [ -z "$PENDING" ]; then
          echo Already up-to-date.
        else
          echo Migrations are pending.

          echo rails db:migrate
          docker exec ${IMAGE_REPO_NAME} rails db:migrate

          echo Rolling update...
          aws ecs update-service --cluster arn:aws:ecs:${AWS_DEFAULT_REGION}:${AWS_ACCOUNT_ID}:cluster/${CLUSTER_NAME} \
                                  --service arn:aws:ecs:${AWS_DEFAULT_REGION}:${AWS_ACCOUNT_ID}:service/${CLUSTER_NAME}/${RAILS_SERVICE_NAME} \
                                  --task-definition ${RAILS_TASK_DEFINITION_NAME} \
                                  --force-new-deployment

          echo Waiting update...
          aws ecs wait services-stable --cluster arn:aws:ecs:${AWS_DEFAULT_REGION}:${AWS_ACCOUNT_ID}:cluster/${CLUSTER_NAME} \
                                        --service arn:aws:ecs:${AWS_DEFAULT_REGION}:${AWS_ACCOUNT_ID}:service/${CLUSTER_NAME}/${RAILS_SERVICE_NAME}
        fi

注意

以下のような制限事項があります。CodeBuildでマイグレーションを実行する際はご注意ください。

制限事項

  • ビルドがタイムアウトしマイグレーションが中断する可能性がある(CodeBuildのデフォルトタイムアウト値は1時間。5分~8時間に変更可能)
  • マイグレーションの進捗状況がわからない

実経験ですが、重たい複数のマイグレーションをCodeBuildで実行したところ、1時間経っても終わらずマイグレーションが中断(CodeBuildがタイムアウト)してしまうという事象が発生してしまいました。
進捗状況を確認しようとしてもCodeBuildのログにはrails db:migrateで出力が止まっている状態で、どこまで進んでいるのか分からずshow processlist;Performance Insightsから確認をしていました。タイムアウト後はタイムアウト時に走っていたマイグレーションのSQLは終わるまで動き続けていましたが、それ以降のマイグレーションが実行されなかったため、手動でリカバリを実施し何とかマイグレーションを終わらすことができました。このように進捗状況が分かりづらく、タイムアウトしてしまうとリカバリ対応が必要になってしまうため、タイムアウト値を最大の8時間に変更し実行するのも良いですが、長時間が見込まれるマイグレーション(カラムの追加、インデックスの追加、型を変換する場合など)やクリティカルな本番環境ではCodeBuildでマイグレーションせず手動での実行が安全かと思います。

手動で実行

冒頭にも記載していますが、以下のようなコマンドでマイグレーションを手動実行できます。
手動実行時の注意として、ECS Execは20分放置するとセッションがタイムアウトしてしまいます。そのため、screentmuxコマンドで新しいセッションを作成しマイグレーションを実行すると良いです。

  1. aws ecs execute-command コマンドでRailsコンテナに接続
  2. rails db:migrate:status でマイグレーション状況を確認
  3. screenで新しいセッションを作る
  4. rails db:migrate でマイグレーションを実行
  5. rails db:migrate:status でマイグレーション結果を確認
  6. aws ecs update-service コマンドで再起動

まとめ

他にも色々とやり方はあるかと思いますが、今回はCodeBuild で Rails のマイグレーションを実行する方法をご紹介しました。
都度ECS Execでコンテナに接続しマイグレーションコマンドを実行し面倒だな、と感じていた方の参考になれば幸いです。