こんにちは、AppBrewに業務委託で参加させてもらっているsnikiです。
本業ではヤフー株式会社でYahoo! JAPANアプリのバックエンド開発をやっています。
今回は、AWSのChatbot/Step Functions/CDK等を利用してAmazon Auroraをcloneするツールを作成したのでご紹介します。
- 背景
- 機能の説明
- 利用したAWSのサービスとシステム構成
- この構成に至るまで
- slackのコマンドを受け付けるには
- cloneからmasking、instance class設定、通知まで
- 利用し終わったあとのcloneの削除
- そしてCDK化へ
- おわりに
- We are Hiring!
背景
某日、ボス※から以下のようなissueにアサインされました。
- 本番環境のDBデータを利用して分析や検証をやりたい
- mysqldumpではデータがでかすぎて復元するにはつらい
- 本番データの個人情報は隠した状態で利用したい
- いい感じに作って欲しい
というなかなかなムチャぶりお題をいただきました。
私も経験ありますが、皆様の中でも以下のような状況はあるのではないかなと思います。
- 本番環境のデータを利用して負荷試験をやりたい。
- 本番環境でしか再現しない現象を調査したい。
- 本番環境のデータを利用して検証したい。
そこですでに似たような課題を解決したcookpadさんの記事を参考にし、AWSのChatbotとStep Functionsを利用してツールを作成しました。
機能の説明
slackで作りたいcloneのinstance classと、削除日を指定します。
30~40分ほどすると作成されたcloneの接続情報が通知されます。
実際にslackで使うと以下な感じです。
slackのコマンド自体はワークフローに登録しているため、利用者は細かいコマンドを知らなくても利用することができます。
これが…
こうなって...
こうじゃ
作成されたcloneのMySQL接続情報は、AWS CLIからSecrets Managerを参照することで取得できるようにしました。
利用したAWSのサービスとシステム構成
利用した主なAWSサービスとシステム構成はこんな感じです。
- AWS Chatbot
- Amazon SNS
- AWS Step Functions
- Amazon RDS(Aurora)
- AWS Lambda
- Amazon ECS(AWS Fargate)
- Amazon EventBridge
- AWS Secrets Manager
- Amazon DynamoDB
この構成に至るまで
AppBrewではこちらの記事にある通り、バックエンドはRailsを利用しており、今回このツールを利用者はエンジニアを想定していたため、slackコマンドではなくrakeコマンドでプロトタイプを作成しました。
以下のようなイメージです
# auroraをclone $ bundle exec rails db:clone # auroraをmasking $ bundle exec rails db:masking
しかし、この場合AWS SDKからAWSの各サービスをIAMで許可したシークレットアクセスキーなどが必要になり、このツールを使いたいエンジニア毎にそれらを必ず配る必要がでてくるのと、各コマンドを覚える必要が出てくるため運用するにはつらいという結論になりました。
そこで、AWS上でそれらの処理を完結させslackコマンドをトリガーとして実行する検討を開始しました。
また、ボスから以下のようなコメントをいただきます。
かわいい顔してなかなか鬼なことをいう赤ちゃんです。
slackのコマンドを受け付けるには
slackのコマンドを受け付けてAWSの各サービスを動かすのに、AWSではAWS Chatbotというサービスが提供されています。
AWS Chatbotではbotを利用するSlackのチャンネルやSNSトピックを設定することでコマンドを受け付けるようになります。
Chatbotの構築方法についてはここでは省略します。
興味がある方は以下を参考にしてみてください。
AWS Chatbot
Chatbotを利用することで、slack上で以下のようなコマンドを入力することでAWSの各サービスを利用することができます。
Lambdaの関数を実行
@aws lambda invoke --function-name hello-chatbot-function --region ap-northeast-1
Step Functionsのステートマシンを実行
@aws stepfunctions start-execution --state-machine-arn arn:xxxxxxxx
cloneからmasking、instance class設定、通知まで
次に、Chatbotから起動するStep Functionsを作成します。
Step Functionsとは、AWSの各サービスをフローチャートのようなもので組み合わせ、ローコードで開発ができるサービスになります。
Step FunctionsではASLと呼ばれるJSONベースの言語で構築し、最近ではWorkflowStuidoという機能でUIベースで構築することもできます。
Step Functionsの構築方法は公式でチュートリアルが提供されているため、参考にしてみてください。
Step Functions チュートリアル - AWS Step Functions
実際に作ったワークフローは以下になります。
それぞれのタスクについて説明していきます。
Aurora Clone(Lambda)
名前の通り、AuroraをCloneします。 また、Cloneしたクラスタを自動的に削除するライフサイクルの設定をDynamoDBに登録します(後記で説明)
Aurora Masking(ECS)
作成したAuroraのClone DB内の個人情報をマスキングし、開発環境でも安全に利用しやすい状態にします。
Modify Clone DB Instance Class(Lambda)
作成したAuroraのCloneインスタンスクラスを利用者が指定したものに変更します。
Notify Slack(Lambda)
作成したcloneの削除日や接続先ホスト名などをslackで通知します。
それぞれのタスクで失敗した場合、EventBridgeを通して失敗したことが通知されるようにしています。
補足
なぜLamdaとECSが別れているのか
当初はStep Functionsを利用せず、ChatbotからLambdaの関数を直接実行し、その関数の中にすべての処理を詰め込めばいいと考えていたのですが、Lambdaには実行時間15分の制限があり、Auroraのcloneから起動までにおよそ5~10分、マスキングの処理に20~30分かかるためAuroraのcloneはLambdaに任せ、マスキングの処理をRailsのRake Taskで実装し、ECSのrun taskで実行しました。
また、cloneの関数は今後別の機能でも利用する予定なのでマスキングと分離しています。
インスタンスクラス変更のタスクは何?
マスキング処理を実行する際に、マスキング対象のテーブルが1000万件を超えるレコード数があると、スペックの低いインスタンスクラスではマスキングの処理に半日以上かかってしまうため、cloneする段階では本番相当のインスタンスクラスでcloneし、マスキングが終わったあと利用者が指定したインスタンスサイズに変更しています。
こうすることでマスキングにかかる処理時間を短縮しています。
LambdaやECSを通さずにStep Functionsから直接実行できない?
Step FunctionsではRDSの操作やChatbotへの操作を直接行えるのですが、構築時点ではRDSへのクエリの実行※やChatbotを利用したslack通知は対応していませんでした。
※Aurora Serverlessであれば対応しているようです(未検証)
参考
- https://docs.aws.amazon.com/ja_jp/rdsdataservice/latest/APIReference/API_ExecuteStatement.html
- https://docs.aws.amazon.com/ja_jp/chatbot/latest/adminguide/related-services.html
その他工夫したポイント
StepFunctionsからRailsのrake taskをECS run Taskで実行する場合、commandの形式を以下のような形で指定する必要があり、Step Functions ResultSelectorやResultPathでは対応できなかったため、Lambdaで事前にECSのコマンドで受けれるように加工しました。
Lambdaのレスポンス(Python)
return { 'DBClusterIdentifier': DBClusterIdentifier, 'db_instance_class': DBInstanceClass, "command": [ 'bundle', 'exec', 'rake', f"db:masking[{db_cluster['Endpoint']}]" ] }
StepFunctions上のECS ASL
{ "ContainerOverrides" : [ { "Command.$" : "$.command" } ] }
最終的に展開される ASL
{ "ContainerOverrides": [{ "Command": [ 'bundle', 'exec', 'rake', 'db:masking[clone-production-2022-07-14-22-22.xxxxxx.ap-northeast-1.rds.amazonaws.com]' ] }] }
利用し終わったあとのcloneの削除
利用をやめたcloneを放置しておくと料金が発生するため、Lambdaで定期的に削除します。
定期実行にはEventBridgeとDynamoDBを組み合わせて実現しました。
EventBridgeでLambdaの関数を1時間おきに呼ぶように設定しておき、実際の削除可否判定は利用者がslackから指定したライフサイクルで削除されるようにしています。
デフォルトでは1日経過すると削除するように設定しておき、1日以上利用したい場合はslackから利用者の指定した日数が経過した時に削除するようにしました。
この指定した値はDynamoDBを通じて各Lambda関数で共有されています。
EventBridgeとDynamoDBの詳細について以下を参考にしてみてください。
slackからの入力方法ついては、Chatbotではslackからstep functionsに渡すパラメータで指定することができるためそれを利用して実現しています。
@aws input {"expire_days": "3"}
- Clone実行時に入力されたexpire_daysをdynamodbに登録する。
- 削除するLambda側でdynamodbからexpire_daysを取得し、経過していたら削除処理を実行する。
といった流れです。
そしてCDK化へ
ようやく構築が終わり、ドヤ顔でボスに完了報告をすると以下のありがたいコメントをいただきます。
これで終わりと思っていたのはどうやら僕だけだったようです。
ということでここまでの内容をコードで管理するためにCDKを採用することにしました。
CDKとは
AWSの各サービスをPythonやTypescriptのコードで定義し、管理やプロビジョニングを行うことができるツールになります。
CDKを導入することで以下のようなメリットがあります。
- マネージコンソールから入力するような内容がコードで可視化される。
- CloudFormationを直接利用するよりもコード量が少なくてすむ。
- CloudFormationの定義ファイルやLambdaのソースコードを自動でS3にアップロードしてくれる。
- AWS SDKを使うような感覚でインフラを構築できる。
- Typescriptなどの開発言語を利用できるので、IDEを利用するとコード補完が効く。
CDKの導入については公式サイト等に情報がありますので、参考にしてみてください。
https://aws.amazon.com/jp/getting-started/guides/setup-cdk/module-one/
CDKの詳細
今回ディレクトリ構成は以下のようにしました。
├── cdk ・・・CDK本体 │ ├── bin │ ├── cdk.json │ ├── cdk.out │ ├── jest.config.js │ ├── lib │ │ └── aurora-clone-stack.ts │ ├── node_modules │ ├── package-lock.json │ ├── package.json │ ├── test │ └── tsconfig.json ├── lambda ・・・Lambdaの各関数 │ ├── AuroraClone │ ├── AuroraCloneDelete │ ├── AuroraCloneNotifySlack │ └── ModifyCloneDBInstanceClass └── stepfunctions ・・・Step Functionsで定義したASL └── AuroraCloneStateMachine.json
CDKのコードは以下のような内容です。
import { Stack, StackProps, aws_lambda as lambda, Duration, aws_iam as iam, aws_sns as sns, aws_chatbot as chatbot, aws_stepfunctions as sfn, aws_events as events, aws_events_targets as targets, aws_dynamodb as dynamodb, } from "aws-cdk-lib"; import { Construct } from "constructs"; import * as fs from "fs"; export class AuroraCloneStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); const auroraCloneChatbotTopic = new sns.Topic(this, "AuroraCloneTopic", { displayName: "AuroraCloneChatbotTopic", topicName: "AuroraCloneChatbotTopic", }); // Chatbotのデフォルトで付与されるログ書込みのIAM Policy const chatbotNotificationsOnlyPolicy = new iam.ManagedPolicy( this, "AWS-Chatbot-NotificationsOnly-Policy", { managedPolicyName: "AWS-Chatbot-NotificationsOnly-Policy", description: "NotificationsOnly policy for AWS-Chatbot", statements: [ new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ "cloudwatch:Describe*", "cloudwatch:Get*", "cloudwatch:List*", ], resources: ["*"], }), ], } ); // Chatbotで利用するIAM Policy const chatbotAuroraCloneExecutionRolePolicy = new iam.ManagedPolicy( this, "ChatbotAuroraCloneExecutionRolePolicy", { managedPolicyName: "ChatbotAuroraCloneExecutionRolePolicy", description: "ChatbotAuroraCloneExecutionRolePolicy", statements: [ new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ "states:DescribeStateMachineForExecution", "states:DescribeActivity", "states:ListStateMachines", "states:DescribeStateMachine", "states:ListActivities", "states:DescribeExecution", "states:ListExecutions", "states:GetExecutionHistory", "states:StartExecution", "states:StartSyncExecution", "states:ListTagsForResource", ], resources: ["*"], }), ], } ); const chatbotAuroraCloneRole = new iam.Role( this, "ChatbotAuroraCloneRole", { roleName: "ChatbotAuroraCloneRole", assumedBy: new iam.ServicePrincipal("chatbot.amazonaws.com"), } ); chatbotAuroraCloneRole.addManagedPolicy(chatbotNotificationsOnlyPolicy); chatbotAuroraCloneRole.addManagedPolicy( chatbotAuroraCloneExecutionRolePolicy ); new chatbot.CfnSlackChannelConfiguration(this, "AuroraCloneSlack", { configurationName: "AuroraCloneSlack", iamRoleArn: chatbotAuroraCloneRole.roleArn, slackChannelId: "**********", slackWorkspaceId: "*********", snsTopicArns: [auroraCloneChatbotTopic.topicArn], }); // 削除日指定等のデータを格納するdynamodb table new dynamodb.Table(this, "AuroraCloneTable", { tableName: "aurora_clone", partitionKey: { name: "DBClusterIdentifier", type: dynamodb.AttributeType.STRING, }, billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, timeToLiveAttribute: "ttl", }); // Lambdaのデフォルトで付与されるログ書込みのIAM Policy const lambdaBasicExecutionRolePolicy = iam.ManagedPolicy.fromAwsManagedPolicyName( "service-role/AWSLambdaBasicExecutionRole" ); // Lambdaで利用するIAM Policy const lambdaAuroraCloneExecutionRolePolicy = new iam.ManagedPolicy( this, "LambdaAuroraCloneExecutionRolePolicy", { managedPolicyName: "LambdaAuroraCloneExecutionRolePolicy", description: "LambdaAuroraCloneExecutionRolePolicy", statements: [ new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ "rds:AddTagsToResource", "rds:ListTagsForResource", "rds:CreateDBInstance", "rds:DescribeDBInstances", "rds:DescribeDBClusters", "rds:ModifyDBCluster", "rds:ModifyDBInstance", "rds:DeleteDBCluster", "rds:DescribeDBClusters", "rds:RestoreDBClusterToPointInTime", "rds:DeleteDBInstance", "dynamodb:PutItem", "dynamodb:GetItem", "secretsmanager:GetSecretValue", ], resources: ["*"], }), ], } ); const lambdaAuroraCloneRole = new iam.Role(this, "LambdaAuroraCloneRole", { roleName: "LambdaAuroraCloneRole", assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"), }); lambdaAuroraCloneRole.addManagedPolicy(lambdaBasicExecutionRolePolicy); lambdaAuroraCloneRole.addManagedPolicy( lambdaAuroraCloneExecutionRolePolicy ); // Aurora CloneするLambda関数 new lambda.Function(this, "LambdaFunctionAuroraClone", { functionName: "AuroraClone", description: "AuroraをCloneする", code: lambda.Code.fromAsset("../lambda/AuroraClone"), handler: "lambda_function.lambda_handler", runtime: lambda.Runtime.PYTHON_3_9, timeout: Duration.minutes(15), role: lambdaAuroraCloneRole, }); // InstanceClassを変更するLambda関数 new lambda.Function(this, "LambdaFunctionModifyCloneDBInstanceClass", { functionName: "ModifyCloneDBInstanceClass", description: "CloneしたAuroraのDBInstanceClassを変更", code: lambda.Code.fromAsset("../lambda/ModifyCloneDBInstanceClass"), handler: "lambda_function.lambda_handler", runtime: lambda.Runtime.PYTHON_3_9, timeout: Duration.minutes(15), role: lambdaAuroraCloneRole, }); // slack通知するLambda関数 new lambda.Function(this, "LambdaFunctionAuroraCloneNotifySlack", { functionName: "AuroraCloneNotifySlack", description: "CloneしたAuroraのhost名をslackに通知", code: lambda.Code.fromAsset("../lambda/AuroraCloneNotifySlack"), handler: "lambda_function.lambda_handler", runtime: lambda.Runtime.PYTHON_3_9, timeout: Duration.minutes(1), role: lambdaAuroraCloneRole, }); // StepFunctionsで利用するIAM Policy const sfnAuroraCloneExecutionRolePolicy = new iam.ManagedPolicy( this, "sfnAuroraCloneExecutionRolePolicy", { managedPolicyName: "sfnAuroraCloneExecutionRolePolicy", description: "sfnAuroraCloneExecutionRolePolicy", statements: [ new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ "lambda:InvokeFunction", "ecs:RunTask", "events:PutTargets", "events:PutRule", "events:DescribeRule", "iam:PassRole", ], resources: ["*"], }), ], } ); const sfnAuroraCloneRole = new iam.Role( this, "StepFunctionsAuroraCloneRole", { roleName: "StepFunctionsAuroraCloneRole", assumedBy: new iam.ServicePrincipal("states.amazonaws.com"), } ); sfnAuroraCloneRole.addManagedPolicy(sfnAuroraCloneExecutionRolePolicy); // TODO: ecs.TaskDefinition.fromTaskDefinitionArnでArn名を直接指定できないバグがあるため // sfn.StateMachineを利用せずsfn.CfnStateMachineを利用する // バグが修正されたらsfn.StateMachineに切り替えるか検討 // 詳細 https://github.com/aws/aws-cdk/issues/6240 const auroraCloneStateMachine = new sfn.CfnStateMachine( this, "AuroraCloneStateMachine", { stateMachineName: "AuroraCloneStateMachine", definition: JSON.parse( fs.readFileSync( "../stepfunctions/AuroraCloneStateMachine.json", "utf8" ) ), roleArn: sfnAuroraCloneRole.roleArn, } ); // CloneしたAuroraの定期削除 const lambdaFunctionAuroraCloneDelete = new lambda.Function( this, "LambdaFunctionAuroraCloneDelete", { functionName: "AuroraCloneDelete", description: "CloneしたAuroraを定期的に削除する", code: lambda.Code.fromAsset("../lambda/AuroraCloneDelete"), handler: "lambda_function.lambda_handler", runtime: lambda.Runtime.PYTHON_3_9, timeout: Duration.minutes(15), role: lambdaAuroraCloneRole, } ); new events.Rule(this, "AuroraCloneDeleteCronRule", { ruleName: "AuroraCloneDeleteCronRule", schedule: events.Schedule.rate(Duration.hours(1)), targets: [ new targets.LambdaFunction(lambdaFunctionAuroraCloneDelete, { retryAttempts: 0, }), ], }); } }
ハマったポイント
ECSのタスク定義はすでに定義されているArn名を直接指定する想定だったのですが、ecs.TaskDefinition.fromTaskDefinitionArnでArn名を直接指定できないバグがあるため今回sfn.StateMachineを利用せずsfn.CfnStateMachineを利用し、別途ASLを定義して直接Arn名を指定しました。
バグの詳細
Create ECS Service with existing task definition ARN · Issue #6240 · aws/aws-cdk · GitHub
あとはこの定義したコードを以下のようにcdk deployでプロビジョニングすれば完成です。
$ cdk depoy
これで最初にお伝えしたシステム構成を構築・管理することができます。
これだけのコード量で構築できるということで、CDKの便利さが伝わったのではないかと思います。
おわりに
このツールを構築することで、エンジニアの開発体験を向上させることができました。
今回はLambdaの具体的な処理まで説明できませんでしたが、機会があればまた記事にさせていただきたいと思います。
We are Hiring!
こんにちは、AppBrewで執行役員をやっています吉野です👶
弊社では今回のような技術的な課題に対して、業務委託の方にご助力いただき解決しつつ、社内でもインフラ部を始めとした活動により解決しています。 組織のスケールに合わせ、本番環境のインフラはもちろん開発環境、ひいては働く環境の改善は今後も必要になってきます。 プロダクトも組織もどんどん改善を回していける環境に興味がある方、 AppBrewでは全職種積極採用中です!お気軽にお話だけでもいかがですか?ご応募お待ちしています。
※吉野 克基: 執行役員、toB事業の開発責任者
open.appbrew.io
Amazon Web Services、AWS、および Powered by AWS のロゴは、Amazon.com, Inc. またはその関連会社の商標です