吉野 克基
高専から東京大学工学部に編入、在学中にAppBrewへ入社。3年ほどLIPSのweb・iOS・Androidからレコメンド基盤の開発などに尽力する。2019年より新規事業部の開発責任者として次の軸となるプロダクトの開発に務め、現在はtoB事業全般の開発責任者。
https://open.appbrew.io/9d8006f0dd2c4eab98b2b8579c349ad4
(この記事は2022年8月15日に弊社テックブログに掲載した内容となっております。)
こんにちは、AppBrewに業務委託で参加させてもらっているsnikiです。
本業ではヤフー株式会社でYahoo! JAPANアプリのバックエンド開発をやっています。
今回は、AWSのChatbot/Step Functions/CDK等を利用してAmazon Auroraをcloneするツールを作成したのでご紹介します。
某日、ボス※から以下のようなissueにアサインされました。
というなかなかなムチャぶりお題をいただきました。
私も経験ありますが、皆様の中でも以下のような状況はあるのではないかなと思います。
そこですでに似たような課題を解決したcookpadさんの記事を参考にし、AWSのChatbotとStep Functionsを利用してツールを作成しました。
slackで作りたいcloneのinstance classと、削除日を指定します。
30~40分ほどすると作成されたcloneの接続情報が通知されます。
実際にslackで使うと以下な感じです。
slackのコマンド自体はワークフローに登録しているため、利用者は細かいコマンドを知らなくても利用することができます。
これが…
こうなって...
こうじゃ
作成されたcloneのMySQL接続情報は、AWS CLIからSecrets Managerを参照することで取得できるようにしました。
利用した主なAWSサービスとシステム構成はこんな感じです。
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のコマンドを受け付けてAWSの各サービスを動かすのに、AWSではAWS Chatbotというサービスが提供されています。
AWS Chatbotではbotを利用するSlackのチャンネルやSNSトピックを設定することでコマンドを受け付けるようになります。
Chatbotの構築方法についてはここでは省略します。
興味がある方は以下を参考にしてみてください。
AWS Chatbot
Chatbotを利用することで、slack上で以下のようなコマンドを入力することでAWSの各サービスを利用することができます。
Lambdaの関数を実行
voke --function-name hello-chatbot-function --region ap-northeast-1
Step Functionsのステートマシンを実行
@aws stepfunctions start-execution --state-machine-arn arn:xxxxxxxx
次に、Chatbotから起動するStep Functionsを作成します。
Step Functionsとは、AWSの各サービスをフローチャートのようなもので組み合わせ、ローコードで開発ができるサービスになります。
Step FunctionsではASLと呼ばれるJSONベースの言語で構築し、最近ではWorkflowStuidoという機能でUIベースで構築することもできます。
Step Functionsの構築方法は公式でチュートリアルが提供されているため、参考にしてみてください。
Step Functions チュートリアル - AWS Step Functions
実際に作ったワークフローは以下になります。
それぞれのタスクについて説明していきます。
名前の通り、AuroraをCloneします。 また、Cloneしたクラスタを自動的に削除するライフサイクルの設定をDynamoDBに登録します(後記で説明)
作成したAuroraのClone DB内の個人情報をマスキングし、開発環境でも安全に利用しやすい状態にします。
作成したAuroraのCloneインスタンスクラスを利用者が指定したものに変更します。
作成したcloneの削除日や接続先ホスト名などをslackで通知します。
それぞれのタスクで失敗した場合、EventBridgeを通して失敗したことが通知されるようにしています。
当初は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し、マスキングが終わったあと利用者が指定したインスタンスサイズに変更しています。
こうすることでマスキングにかかる処理時間を短縮しています。
Step FunctionsではRDSの操作やChatbotへの操作を直接行えるのですが、構築時点ではRDSへのクエリの実行※やChatbotを利用したslack通知は対応していませんでした。
※Aurora Serverlessであれば対応しているようです(未検証)
参考
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を放置しておくと料金が発生するため、Lambdaで定期的に削除します。
定期実行にはEventBridgeとDynamoDBを組み合わせて実現しました。
EventBridgeでLambdaの関数を1時間おきに呼ぶように設定しておき、実際の削除可否判定は利用者がslackから指定したライフサイクルで削除されるようにしています。
デフォルトでは1日経過すると削除するように設定しておき、1日以上利用したい場合はslackから利用者の指定した日数が経過した時に削除するようにしました。
この指定した値はDynamoDBを通じて各Lambda関数で共有されています。
EventBridgeとDynamoDBの詳細について以下を参考にしてみてください。
slackからの入力方法ついては、Chatbotではslackからstep functionsに渡すパラメータで指定することができるためそれを利用して実現しています。
@aws input {"expire_days": "3"}
ようやく構築が終わり、ドヤ顔でボスに完了報告をすると以下のありがたいコメントをいただきます。 これで終わりと思っていたのはどうやら僕だけだったようです。
ということでここまでの内容をコードで管理するためにCDKを採用することにしました。
AWSの各サービスをPythonやTypescriptのコードで定義し、管理やプロビジョニングを行うことができるツールになります。
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の具体的な処理まで説明できませんでしたが、機会があればまた記事にさせていただきたいと思います。
こんにちは、AppBrewで執行役員をやっています吉野です👶
弊社では今回のような技術的な課題に対して、業務委託の方にご助力いただき解決しつつ、社内でもインフラ部を始めとした活動により解決しています。 組織のスケールに合わせ、本番環境のインフラはもちろん開発環境、ひいては働く環境の改善は今後も必要になってきます。 プロダクトも組織もどんどん改善を回していける環境に興味がある方、 AppBrewでは全職種積極採用中です!お気軽にお話だけでもいかがですか?ご応募お待ちしています。
※吉野 克基: 執行役員、toB事業の開発責任者