はじめに
猫大好きエンジニアの福田です。
皆さんデータベースのマイグレーションはどこでどうやって実行していますか?
Ruby on Rails を Amazon Elastic Container Service (ECS) on Fargate で動かしていますが、今までは以下のような流れでマイグレーションを実行していました。
今までのマイグレーション方法
aws ecs execute-command
コマンドでRailsコンテナに接続rails db:migrate:status
でマイグレーション状況を確認rails db:migrate
でマイグレーションを実行aws ecs update-service
コマンドで再起動
問題点
上記のやり方でマイグレーションは実行できますが、以下のような問題がありました。
- AWSコマンドが実行できる環境やアカウントが必要になる
- インフラ担当や開発チームのエンジニアしか作業できない
- 運用チームだけでデプロイ・マイグレーションが実行できない
- 環境に接続するため、他のコマンドも実行できてしまう
これら問題点を解消すべく、
CodeBuildからビルド実行ボタンをクリックするだけで、マイグレーションできるようにしたいと思います。
前提条件
- ECS FargateでRailsが動いていること
- ECRでRailsのイメージが管理されていること
- CDK(AWS Cloud Development Kit)でインフラを構築していること
構成図(イメージ)
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分放置するとセッションがタイムアウトしてしまいます。そのため、screen
やtmux
コマンドで新しいセッションを作成しマイグレーションを実行すると良いです。
aws ecs execute-command
コマンドでRailsコンテナに接続rails db:migrate:status
でマイグレーション状況を確認screen
で新しいセッションを作るrails db:migrate
でマイグレーションを実行rails db:migrate:status
でマイグレーション結果を確認aws ecs update-service
コマンドで再起動
まとめ
他にも色々とやり方はあるかと思いますが、今回はCodeBuild で Rails のマイグレーションを実行する方法をご紹介しました。
都度ECS Execでコンテナに接続しマイグレーションコマンドを実行し面倒だな、と感じていた方の参考になれば幸いです。