1
/
5

エンジニアの業務効率をあげる!AWS CDKで作る本番Databaseを安全にクローンする方法

(この記事は2022年8月15日に弊社テックブログに掲載した内容となっております。)

こんにちは、AppBrewに業務委託で参加させてもらっているsnikiです。
本業ではヤフー株式会社でYahoo! JAPANアプリのバックエンド開発をやっています。

今回は、AWSのChatbot/Step Functions/CDK等を利用してAmazon Auroraをcloneするツールを作成したのでご紹介します。

目的

  • 背景
  • 機能の説明
  • 利用したAWSのサービスとシステム構成
  • この構成に至るまで
  • slackのコマンドを受け付けるには
  • cloneからmasking、instance class設定、通知まで
    • Aurora Clone(Lambda)
    • Aurora Masking(ECS)
    • Modify Clone DB Instance Class(Lambda)
    • Notify Slack(Lambda)
    • 補足
    • なぜLamdaとECSが別れているのか
    • インスタンスクラス変更のタスクは何?
    • LambdaやECSを通さずにStep Functionsから直接実行できない?
    • その他工夫したポイント
  • 利用し終わったあとのcloneの削除
  • そしてCDK化へ
    • CDKとは
    • 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の関数を実行

voke --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であれば対応しているようです(未検証)

参考


その他工夫したポイント

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"}
  1. Clone実行時に入力されたexpire_daysをdynamodbに登録する。
  2. 削除する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事業の開発責任者

吉野 克基
高専から東京大学工学部に編入、在学中にAppBrewへ入社。3年ほどLIPSのweb・iOS・Androidからレコメンド基盤の開発などに尽力する。2019年より新規事業部の開発責任者として次の軸となるプロダクトの開発に務め、現在はtoB事業全般の開発責任者。
https://open.appbrew.io/9d8006f0dd2c4eab98b2b8579c349ad4



株式会社AppBrewでは一緒に働く仲間を募集しています

今週のランキング

株式会社AppBrewからお誘い
この話題に共感したら、メンバーと話してみませんか?