高額課金は悲しいのでStep Functionsで楽しみながら改善してみた(CDK実装をできるだけ詳しく)
こんにちは。AWS事業本部コンサルティング部に所属している今泉(@bun76235104)です。
先日うっかりAWSの検証用アカウントで予算超え課金を発生させて、悲しい気持ちになってしまったものです。
今回は検証用アカウントの予算を超える課金を発生させて悲しかった気持ちを、CDKとStep Functionsで楽しみながら改善してみました。
DBクラスター放置の悲劇をStep Functionsで楽しく吹っ飛ばしたい
前回のブログにも記載していますが、検証用に立ち上げたAurora DBクラスターを削除し忘れてしまいました。
平時から会社メールアドレスへの通知・Slackによる利用料金の通知を導入しているのですが、この時は珍しく多忙な土日を挟んだことにより、メールはもちろんSlackの通知も流し見してしまいました。
うっかりミスがもう二度と起きないように改善策を考えてみたところ、「Step FunctionsをCDKで実装してみよう」という結論に至りました。
満たすべき条件はこうです。
- さっと手動で立ち上げがちなリソースをさくっと削除したい
- 検証環境だから問答無用でいきたい
- 触れるサービスは増え続けるのだから、楽しんで保守できる方法が良い
ここまでくればあとはこうなります。
- Step FunctionsっていろいろなサービスのAPI呼び出せる
- [アップデート] AWS Step Functionsが200以上のAWSサービスと連携できるようになりました | DevelopersIO
- AWSのサービスが増えても追従できそう
- Step Functionsはフロー眺めるだけで楽しいし、同僚も大好きな人多かった
- 手動で作っても面白そうだけど、エンジニアだしコード書いた方が楽しいよね
- じゃあCDKでStep Functionsとかのリソースを作れば良いじゃない
- やり方によってはコード量も少ないし直感的で良いかも
ということでやってみました。
※ 注意: アカウント内のEC2インスタンス・RDS DBインスタンスなどを問答無用で削除する実装となっています。本番環境などでそのまま利用しないようにお願いします。
実装するしくみ(どうやって改善するのか)
今回の実装内容は以下のような構成です。
そしてStep Functionsで実装するステートマシンはこちらです。
この図を見るだけでテンションが上がってきませんか?
私は上がりました。
すでに「ステートマシン」という言葉が出てきていますが、公式ドキュメントにも以下のように書いてあるためワークフローと認識してください。
Step Functions はステートマシンとタスクに基づいています。ステートマシンはワークフローです。タスクとは、ワークフロー内の状態で、別の AWS のサービスが実行する1 つの作業単位を表します。
上図で呼び出している個々の処理(RDS: DescribeInstances
や EC2:DescribeInstances
)はタスクということですね。
先に結論となってしまいますが、出来上がったステートマシンを動かすことによって以下のように私の検証アカウント内のEC2・RDS DBインスタンス・RDS DBクラスターを削除します。
それではCDKでの実装をみていきます。
CDKでの実装内容
これから一部コードを抜粋していきますが、コードの全文を確認したい場合は以下リポジトリをご確認ください。
また、本記事の最後にStep Functions及びEventBridgeで利用した主要な機能を最後にまとめていますので、忙しい方はそちらだけご確認ください。
コード全体像把握
今回CDKを実装するにあたり、以下のようなファイル構成となっています。
├── bin │ └── yoigoshi_yurusan.ts ├── lib │ ├── resources # 実際に作成するリソースごとにファイル分割 │ │ ├── ec2.ts │ │ ├── network.ts │ │ ├── rds.ts │ │ └── stepFunc.ts │ └── yoigoshi_yurusan-stack.ts # resources配下のファイルの作成処理を呼び出す ├── .env #SlackのAPIエンドポイントを記載
lib/resources
のディレクトリ配下にリソースごとにファイルを分割している様な形となっています。
lib/yoigoshi_yurusan-stack.ts ファイルから resources
フォルダ内の処理を呼び出しています。
以下のような形です。
import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import { StepFunc } from './resources/stepFunc'; export class YoigoshiYurusanStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // 中略 // resources配下のリソース作成用の処理を呼び出し const sfnState = new StepFunc(this); sfnState.createState(); } }
逆に呼び出される側のクラスは以下のように、呼び出し側から Construct
の情報を受け取っています。
export class StepFunc { readonly construct: Construct; constructor(construct: Construct) { this.construct = construct; } public createState() { // リソース作成処理 // 略 } }
このように実際に処理を作成する実装は lib/resources
配下のファイルであるため、そこの処理内容を見ていきたいと思います。
なお、今回このような構造にしているのは以下のような意図があるためです。
- StepFunctionsの実行で実際にEC2やRDSのインスタンスが削除されるか確認したい
- CDKで一緒に作成してしまいたい
- 1つのクラス内ですべてのリソースの作成処理をするとファイルが縦に長くなって見にくい
- CloudFormationのスタックを分割する必要はないので、以下ブログを参考にファイルを分割したい
Step Functionsのステートマシン作成
Step Functionsのリソースを作成するクラスは以下のような流れで進みます。
createState
の中をご覧ください。
// 環境変数の読み込み import * as dotenv from 'dotenv'; dotenv.config(); import { Construct } from 'constructs'; import * as sfn from 'aws-cdk-lib/aws-stepfunctions'; import * as tasks from 'aws-cdk-lib/aws-stepfunctions-tasks'; import * as events from 'aws-cdk-lib/aws-events'; import * as targets from 'aws-cdk-lib/aws-events-targets'; import { SecretValue } from 'aws-cdk-lib'; import { LogGroup } from 'aws-cdk-lib/aws-logs'; export class StepFunc { readonly construct: Construct; constructor(construct: Construct) { this.construct = construct; } public createState() { const mainParallel = new sfn.Parallel( this.construct, 'Parallel executtion of delete operations' ); // 並行処理1: EC2インスタンスの削除 this.addEC2Branch(mainParallel); // 並行処理2: RDSインスタンスの削除 this.addRDSBranch(mainParallel); // ステートマシン作成 const state = new sfn.StateMachine( this.construct, 'YoigoshiYurusan StateMachine', { definition: mainParallel, logs: { destination: new LogGroup(this.construct, 'sfnLogGroups', {}), }, } ); // ステートマシン失敗時の通知 this.failedNotificateEvent(state); // ステートマシンの定期実行 this.addSchedulingEvent(state); } }
createState
メソッドから、各関数を呼び出しています。
図を確認しながら処理を追っていきます。
並行処理(Parallel)の作成
const mainParallel = new sfn.Parallel( this.construct, 'Parallel executtion of delete operations' );
まずこの部分で以下のように、一番外側の並行処理(Parallel)を作成しています。
並行処理にしているのは「EC2の削除の後に、RDSの削除」というように順番(依存)がないためです。
EC2用の一連のフロー作成
次にEC2インスタンスの一覧取得と削除処理を加えています。
// 並行処理1: EC2インスタンスの削除 this.addEC2Branch(mainParallel);
addEC2Branch
はこのような内容になっています。
private addEC2Branch(mainParallel: sfn.Parallel) { // EC2: DescribeInstancesのタスクを作成 const ec2Describe = new tasks.CallAwsService( this.construct, 'Describe EC2 instances', { service: 'ec2', action: 'describeInstances', iamResources: ['*'], resultSelector: { 'InstanceIDs.$': '$.Reservations[*].Instances[*].InstanceId', 'length.$': 'States.ArrayLength($.Reservations[*].Instances[*].InstanceId)', }, parameters: { // 停止中やシャットダウン中のインスタンスを取得しないようにフィルター Filters: [ { Name: 'instance-state-name', Values: ['running'], }, ], }, } ); // EC2: TerminateInstancesのタスクを作成 const ec2Terminate = new tasks.CallAwsService( this.construct, 'Delete Terminate Instances', { service: 'ec2', action: 'terminateInstances', iamResources: ['*'], parameters: { 'InstanceIds.$': '$.InstanceIDs', }, } ); // 対象のEC2インスタンスが1つでもある場合にのみTerminateInstancsesを実行する様に分岐 const ec2choice = new sfn.Choice(this.construct, 'has terminate targets?'); const noInstancesFinish = new sfn.Succeed(this.construct, 'no instances', { comment: 'exit because no target instances', }); ec2choice .when(sfn.Condition.numberGreaterThan('$.length', 0), ec2Terminate) .otherwise(noInstancesFinish); // 最初の並行処理にEC2の一連の処理を追加 mainParallel.branch(ec2Describe); ec2Describe.next(ec2choice); }
ポイントを1つ紹介します。
ResultSelectorによるEC2: DescribeInstancesタスクの出力の整形です。
実際にdescribe-instancesの戻り値を見てみると、以下のようになっています。
{ "Reservations": [ { "Groups": [], "Instances": [ { "AmiLaunchIndex": 0, "ImageId": "ami-0abcdef1234567890, // ここだけ欲しい "InstanceId": "i-1234567890abcdef0, "InstanceType": "t2.micro", "KeyName": "MyKeyPair", "LaunchTime": "2018-05-10T08:05:20.000Z", "Monitoring": { "State": "disabled" }, "Placement": { "AvailabilityZone": "us-east-2a", "GroupName": "", "Tenancy": "default" }, "PrivateDnsName": "ip-10-0-0-157.us-east-2.compute.internal", "PrivateIpAddress": "10.0.0.157", "ProductCodes": [], "PublicDnsName": "", "State": { "Code": 0, "Name": "pending" }, "StateTransitionReason": "", "SubnetId": "subnet-04a636d18e83cfacb", "VpcId": "vpc-1234567890abcdef0", "Architecture": "x86_64", "BlockDeviceMappings": [], "ClientToken": "", "EbsOptimized": false, "Hypervisor": "xen", "NetworkInterfaces": [ { "Attachment": { "AttachTime": "2018-05-10T08:05:20.000Z", "AttachmentId": "eni-attach-0e325c07e928a0405", "DeleteOnTermination": true, "DeviceIndex": 0, "Status": "attaching" }, "Description": "", "Groups": [ { "GroupName": "MySecurityGroup", "GroupId": "sg-0598c7d356eba48d7" } ], "Ipv6Addresses": [], "MacAddress": "0a:ab:58:e0:67:e2", "NetworkInterfaceId": "eni-0c0a29997760baee7", "OwnerId": "123456789012", "PrivateDnsName": "ip-10-0-0-157.us-east-2.compute.internal", "PrivateIpAddress": "10.0.0.157" "PrivateIpAddresses": [ { "Primary": true, "PrivateDnsName": "ip-10-0-0-157.us-east-2.compute.internal", "PrivateIpAddress": "10.0.0.157" } ], "SourceDestCheck": true, "Status": "in-use", "SubnetId": "subnet-04a636d18e83cfacb", "VpcId": "vpc-1234567890abcdef0", "InterfaceType": "interface" } ], "RootDeviceName": "/dev/xvda", "RootDeviceType": "ebs", "SecurityGroups": [ { "GroupName": "MySecurityGroup", "GroupId": "sg-0598c7d356eba48d7" } ], "SourceDestCheck": true, "StateReason": { "Code": "pending", "Message": "pending" }, "Tags": [], "VirtualizationType": "hvm", "CpuOptions": { "CoreCount": 1, "ThreadsPerCore": 1 }, "CapacityReservationSpecification": { "CapacityReservationPreference": "open" }, "MetadataOptions": { "State": "pending", "HttpTokens": "optional", "HttpPutResponseHopLimit": 1, "HttpEndpoint": "enabled" } } ], "OwnerId": "123456789012" "ReservationId": "r-02a3f596d91211712", } }
しかし、実際に次の削除処理に渡す必要があるのはEC2のインスタンスID( Reservations[*].Instances[*].InstanceId
)の部分だけです。
ということで、ResultSelectorの機能を使って出力を加工しています。
resultSelector: { 'InstanceIDs.$': '$.Reservations[*].Instances[*].InstanceId', 'length.$': 'States.ArrayLength($.Reservations[*].Instances[*].InstanceId)', },
これにより実際に出力される値は以下のような形式となります。
{ "InstanceIDs": [ "id1", "id2" ], "length": 2 }
なお、このJSON構造体の出力を調整しているのはJsonPathという構文です。
また、さらっと States.ArrayLength
というStep Functionsの組み込み関数を使っています。
こちらは配列の長さを返してくれる関数となっていますが、他にも便利な関数がそろっていて、とても心躍りますね。
この整えた出力を後続の EC2: TerminateInstancesに渡しています。
const ec2Terminate = new tasks.CallAwsService( this.construct, 'Delete Terminate Instances', { service: 'ec2', action: 'terminateInstances', iamResources: ['*'], parameters: { // InStanceIDsの配列を渡している 'InstanceIds.$': '$.InstanceIDs', }, } );
RDS用の一連の処理
次にRDSのDBインスタンス・DB クラスターの削除処理です。
// 並行処理2: RDSインスタンスの削除 this.addRDSBranch(mainParallel);
addRDSBranch
の内容は以下のとおりです。
private addRDSBranch(mainParallel: sfn.Parallel) { // 並行処理2: RDSインスタンスの削除 const RdsLine = new tasks.CallAwsService( this.construct, 'Describe RDS instances', { service: 'rds', action: 'describeDBInstances', iamResources: ['*'], outputPath: "$.DbInstances[?(@.DbInstanceStatus == 'available')].DbInstanceIdentifier", parameters: {}, } ); // 削除処理を繰り返し const iterMap = new sfn.Map(this.construct, 'Iterete delete DB Instances', { comment: 'Iterete Delete RDS Instances', maxConcurrency: 1, }); // 削除処理 const deleteInstance = new tasks.CallAwsService( this.construct, 'Delete DB Instances', { service: 'rds', action: 'deleteDBInstance', iamResources: ['*'], parameters: { 'DbInstanceIdentifier.$': '$', SkipFinalSnapshot: true, }, } ); iterMap.iterator(deleteInstance); // DBクラスターも同様に一連のタスクを作る const dbClusterSteps = this.getDBClusterStep(); RdsLine.next(iterMap).next(dbClusterSteps); mainParallel.branch(RdsLine); }
ポイントとして2つピックアップします。
まずは、OutputPathによる RDS: DescribeDBInstances
の出力結果の一部選択です。
const RdsLine = new tasks.CallAwsService( this.construct, 'Describe RDS instances', { service: 'rds', action: 'describeDBInstances', iamResources: ['*'], // 戻り値の内、次の処理に必要な部分だけ取得 outputPath: "$.DbInstances[?(@.DbInstanceStatus == 'available')].DbInstanceIdentifier", parameters: {}, } );
こちらもRDS: DescribeDBInstancesの戻り値を見ると分かりやすいです。
{ "DBInstances": [ { // ここだけ必要 "DBInstanceIdentifier": "mydbinstancecf", "DBInstanceClass": "db.t3.small", "Engine": "mysql", "DBInstanceStatus": "available", "MasterUsername": "masterawsuser", "Endpoint": { "Address": "mydbinstancecf.abcexample.us-east-1.rds.amazonaws.com", "Port": 3306, "HostedZoneId": "Z2R2ITUGPM61AM" }, ...some output truncated... } ] }
後続の削除処理に必要な情報は $.DbInstances[*].DbInstanceIdentifier
だけです。
また、そのうち「削除中」や「停止中」のDBインスタンスは必要ないので $.DbInstances[?(@.DbInstanceStatus == 'available')]
の部分で絞り込みしています。
この絞り込みもJsonPathのFilter構文を利用しています。日本語でも以下のようにまとめ記事があるようですので、まずは日本語で見たい方は確認してみても良いかもしれません。
EC2の一覧取得時には、API側で「インスタンスの状態」で絞り込みできたのですが、describe-db-instancesにはそのような絞り込み条件が無いようで、結果を受け取った後絞り込みしています。
これら選択と絞り込みにより、以下のように出力されるイメージです。
[ "id1", "id2" ]
2つ目のポイントは、Mapによる繰り返し処理です。
// 削除処理の繰り返し用にMapを作成 const iterMap = new sfn.Map(this.construct, 'Iterete delete DB Instances', { comment: 'Iterete Delete RDS Instances', maxConcurrency: 1, }); // 削除処理のタスクを作成 const deleteInstance = new tasks.CallAwsService( this.construct, 'Delete DB Instances', { service: 'rds', action: 'deleteDBInstance', iamResources: ['*'], parameters: { 'DbInstanceIdentifier.$': '$', SkipFinalSnapshot: true, }, } ); // Iteratorで削除処理を繰り返す iterMap.iterator(deleteInstance);
これにより、入力値で受け取った配列の長さ分だけ処理を繰り返してくれます。
[ "id1", "id2" ]
という入力値をもらった場合、id1
,id2
それぞれでdelete-db-instanceを実行しているような形です。
なお、RDS DBインスタンスの削除処理の後に DBクラスターの削除処理のフローも以下のように実装していますが、呼び出すAPIが異なるだけでしくみはRDSインスタンスとおおむね同じですので割愛します。
private getDBClusterStep(): sfn.Chain { const clusterDescribe = new tasks.CallAwsService( this.construct, 'Describe DBClusters', { service: 'rds', action: 'describeDBClusters', iamResources: ['*'], outputPath: "$.DbClusters[?(@.Status == 'available')].DbClusterIdentifier", parameters: {}, } ); // 削除処理を繰り返し const iterMap = new sfn.Map(this.construct, 'Iterete delete DB Clusters', { comment: 'Iterete Delete DBClusters', maxConcurrency: 1, }); // 削除処理 const deleteCluster = new tasks.CallAwsService( this.construct, 'Delete DBClusters', { service: 'rds', action: 'deleteDBCluster', iamResources: ['*'], parameters: { 'DbClusterIdentifier.$': '$', SkipFinalSnapshot: true, }, } ); iterMap.iterator(deleteCluster); return clusterDescribe.next(iterMap); }
失敗時のSlackへの通知のフロー
次にステートマシン(ワークフロー)が失敗状態になった時に、Slackへの通知を行うための処理部分をみていきます。
// ステートマシン失敗時の通知 this.failedNotificateEvent(state);
failedNotificateEvent
の実装部分はこちらです。
private failedNotificateEvent(stateMachine: sfn.StateMachine) { //EventBridge Rule const connection = new events.Connection(this.construct, 'Connection', { authorization: events.Authorization.apiKey( 'Hoge', SecretValue.unsafePlainText('Fuga') ), description: 'Connection with API Key Token If Needed', }); const destination = new events.ApiDestination( this.construct, 'Destination', { connection, endpoint: process.env.ENDPOINT ?? '', description: 'Calling example.com with API key x-api-key', } ); events.RuleTargetInput; const rule = new events.Rule(this.construct, 'slack notificxation rule', { eventPattern: { source: ['aws.states'], detailType: ['Step Functions Execution Status Change'], // ステートマシンの失敗時のみを対象にする detail: { status: ['FAILED'], stateMachineArn: [stateMachine.stateMachineArn], }, }, }); rule.addTarget( new targets.ApiDestination(destination, { event: events.RuleTargetInput.fromObject({ attachments: [ { fallback: ':alart:リソースの定期削除に失敗しました', pretext: 'リソースの定期削除に失敗しました', color: '#dc143c', fields: [ { title: `${events.EventField.fromPath( '$.detail.stateMachineArn' )}の実行に失敗`, value: `${events.EventField.fromPath('$.detail.output')}`, }, ], }, ], }), }) ); return rule; }
EventBridgeのルールでStep Functionsの実行の失敗を契機に、API Destinationをターゲットとしています。
ここでのポイントは2つです。
1つ目は、API DestinationはSlackのエンドポイントをPostするように設定しており、エンドポイントは環境変数から渡しています。
const destination = new events.ApiDestination( this.construct, 'Destination', { connection, // 環境変数からSlackのウェブフックのエンドポイントを渡している endpoint: process.env.ENDPOINT ?? '', description: 'Calling example.com with API key x-api-key', } );
実行する際は sample_envを参考に.env
というファイルに環境変数を設定する必要があります。
2つ目は、Input TransformerによるSlackに送るパラメーターの加工です。
event: events.RuleTargetInput.fromObject({ attachments: [ { fallback: ':alart:リソースの定期削除に失敗しました', pretext: 'リソースの定期削除に失敗しました', color: '#dc143c', fields: [ { title: `${events.EventField.fromPath( '$.detail.stateMachineArn' )}の実行に失敗`, value: `${events.EventField.fromPath('$.detail.output')}`, }, ], }, ], }),
attachments
の部分がパラメータとなりますが、Slackに送る文章は固定の文ではなく、EventBridgeに渡される情報を含めています。
EventBridgeには以下のように情報が渡ってくるため、$.detail.output
と $.detail.stateMachineArn
の情報を送るようにしています。
{ "version": "0", "id": "315c1398-40ff-a850-213b-158f73e60175", "detail-type": "Step Functions Execution Status Change", "source": "aws.states", "account": "012345678912", "time": "2019-02-26T19:42:21Z", "region": "us-east-1", "resources": [ "arn:aws:states:us-east-1:012345678912:execution:state-machine-name:execution-name" ], "detail": { "executionArn": "arn:aws:states:us-east-1:012345678912:execution:state-machine-name:execution-name", "stateMachineArn": "arn:aws:states:us-east-1:012345678912:stateMachine:state-machine", "name": "execution-name", "status": "FAILED", "startDate": 1551225146847, "stopDate": 1551225151881, "input": "{}", "inputDetails": { "included": true }, "output": null, "outputDetails": null } }
EventBridgeのスケジュール実行を定義
最後に作成したStep Functionsのステートマシンをスケジュール実行する部分をみていきます。
// ステートマシンの定期実行 this.addSchedulingEvent(state);
private addSchedulingEvent(stateMachine: sfn.StateMachine): events.Rule { const rule = new events.Rule(this.construct, 'MidNightSchedule', { schedule: events.Schedule.cron({ minute: '0', // UTCで設定。日本時間の深夜1時 hour: '16', day: '*', }), targets: [new targets.SfnStateMachine(stateMachine)], }); return rule; }
比較的素直な実装であるため、特段解説するところがありませんが、Cron式でスケジュールを組んでいます。
なお、最近タイムゾーン指定で様々なAPIを呼び出すことができる EventBridge Scheduler という機能がリリースされています。
こちらを使わなかったのは、AWS CDK version:2.54.0 時点でL2 Construct(L1より抽象化されたモジュール)がなかったためです。
L1でも弊社同僚が書いた以下記事のように実装できますので、気になる方はご確認ください。
これで一通りのCDKコードの説明が終わりました。
まとめ
今回のしくみを実装するにあたって、主に以下の機能を利用しました。
サービス | 機能 | 説明 | コードリンク |
---|---|---|---|
Step Functions | ResultSelector | タスクの出力結果の加工 | リンク |
Step Functions | OutputPath | タスクの出力結果から一部選択 | リンク |
Step Functions | Map | for文のように繰り返し処理 | リンク |
Event Bridge | API Destination | Lambdaなどを使わずにEventBridgeから直接SlackのエンドポイントへPOST | リンク |
Event Bridge | Input Transformer | Slackに送信するパラメーターの加工 | リンク |
従来ではLambdaを選択するしかなかったことも、いろいろな選択肢が増えてきていますね。
このブログが誰かのお役に立てば非常にうれしいです。
これからも、悲しい出来事も楽しく改善できるように情報をキャッチアップしたいと思います!