LocalStackを使ってEventBridgeとLambdaを使ったEC2の自動停止・起動するCDKの実装を試す
以前、AWS CDKをlocalstackで練習するを書きましたが、cdkとかLocalStack
をキーワードとして見てくれている方がいそうなので、もっとそういう事例増やそうよと思っていい感じの練習題材を試していこうという記事です。
今回はこちらのre:Postの記事にもあるような構成をcdkで作っていく練習をlocalstack
を使ってやっていきます。
今回の内容はgitにも上げていますので誤りなどがあれば教えていただけると幸いです。
前提
自分の練習のための軌跡を残していく側面があるので、目的はaws cdk
を練習するところに焦点を当てて下記の前提で進めていきます。
-
aws cdk
が何ができるかはわかる -
localstack
使うと何が便利そうかわかる - awsのアーキテクトチョットワカル
検証環境
- MacBook Air M1 (Ventura 13.5.1)
- Docker version 24.0.2-rd, build e63f5fa (最近は*Rancher Desktop (1.9.1ß)*から使っていますのでそちらも導入前提で)
- Node v18.16.0
- localstack/localstack:2.2.0
- aws-cli/2.13.17 Python/3.11.5 Darwin/22.6.0 source/arm64 prompt/off (homebrewで入れてます)
localstackによるAWS環境の準備
何回かlocalstack
を練習した記事を投稿しており、その記事ではdocker compose
を使って環境を構築したりしていましたが、今回は別の環境でも試してみたいと思ったのでメモします。
下記のような条件でlocalstack
の環境を作ります。
Apple SiliconのMacBook Airで、Rancher Desktopを利用して、LocalStack(Free)のDocker版を使って環境を構築する
localstackの実行
localstack公式のとおりDockerを使ってやっていきます。基本的には公式が出している方法で利用できるので、WSL2に直でDocker入れてるよとかLimaに直でDocker入れているよという場合は多少飛ばしてしまっていいと思います。
Rancher DesktopのDockerを使って適当にcdk deploy
しようとすると下記のようにdocker.sock
で怒られてしまいます。
2023-09-14T14:28:08.829 WARN --- [ asgi_gw_1] l.s.l.i.docker_runtime_exe : WARNING: Docker not available in the LocalStack container but required to run Lambda functions. Please add the Docker volume mount "/var/run/docker.sock:/var/run/docker.sock" to your LocalStack startup. https://docs.localstack.cloud/user-guide/aws/lambda/#docker-not-available
上記WARNで書いてあるとおりなので修正していきます。(参考: Rancher Desktopに乗り換えたらAWS SAM CLIでDocker未起動扱いになっていたのでトラブルシュートした | DevelopersIO)
docker context
で使用するdocker.sock
をRancherのものから変更して、
➜ docker context use default
default
Current context is now "default"
❯ docker context ls
NAME DESCRIPTION DOCKER ENDPOINT ERROR
default * Current DOCKER_HOST based configuration unix:///var/run/docker.sock
rancher-desktop Rancher Desktop moby context unix:///Users/xxxxx/.rd/docker.sock
下記、docker.sock
をマウントしてlocalstack
を実行しておきます。
➜ docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock -p 4566:4566 -p 4510-4559:4510-4559 localstack/localstack:2.2.0
LocalStack version: 2.2.0
LocalStack Docker container id: 95edc1c69b84
LocalStack build date: 2023-07-20
LocalStack build git hash: 2d70f768
2023-09-16T10:09:30.713 INFO --- [-functhread3] hypercorn.error : Running on https://0.0.0.0:4566 (CTRL + C to quit)
2023-09-16T10:09:30.713 INFO --- [-functhread3] hypercorn.error : Running on https://0.0.0.0:4566 (CTRL + C to quit)
Ready.
localstackの動作確認
aws cli
からlocalstack
profileの設定をします。
❯ aws configure --profile localstack
AWS Access Key ID [None]: dummy
AWS Secret Access Key [None]: dummy
Default region name [None]: ap-northeast-1
Default output format [None]: json
localstack
のprofileで簡単に実行できるようにalias設定します。
alias awsd="aws --profile localstack --endpoint-url=http://localhost:4566"
適当にaws s3 ls
叩くと、下記のようにListBuckets
API叩かれていることがわかります。
❯ awsd s3 ls
2023-09-16T10:09:30.713 INFO --- [-functhread3] hypercorn.error : Running on https://0.0.0.0:4566 (CTRL + C to quit)
Ready.
2023-09-16T10:11:48.277 INFO --- [ asgi_gw_0] localstack.request.aws : AWS s3.ListBuckets => 200
ここまででlocalstack
を使ったAWS環境ができあがったので、早速aws cdk
練習を始めます。
アーキテクチャ
今回の題材はよくある話で、re:Postの記事にある通り、EC2インスタンスを自動で停止・起動させるというものです。
本来であれば、より実用性を高めるために、「EventBridgeからVPC Endpointを経由してPrivate Subnet上にLambdaを起動し、Private Subnet内のEC2インスタンスをターゲットに停止・起動させる」というのをやりたかったのですが、localstack
ではEIPやVPC EndpointについてはPro版のみ対応だよという感じで怒られてしまったので、パブリックなリソースに対して試しています。
手順
こちらのオレオレテンプレートを利用してaws cdkの環境を整えていきます。今回はlocalstack-eventbridge-lambda-ec2
という名前でテンプレートからリポジトリを作成・クローンしています。
使わない場合は適当にcdk init
でもしてください。
npm i aws-cdk-local
でcdklocal
をインストールして、npx cdklocal bootstrap
しておきます。
まずはEC2を建てる
public subnetにEC2インスタンスを起動していくConstructを定義します。
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';
export class SampleEC2 extends Construct {
public readonly instance: ec2.Instance;
public readonly vpc: ec2.Vpc;
constructor(scope: Construct, id: string) {
super(scope, id);
// わかりやすくAZは1つで今回試す
// localstackのfree版の都合で、Private Subnet配置のEC2については対象外
this.vpc = new ec2.Vpc(this, 'VPC', {
maxAzs: 1,
subnetConfiguration: [
{
subnetType: ec2.SubnetType.PUBLIC,
name: 'PublicSubnet',
},
],
});
this.instance = new ec2.Instance(this, 'SampleEC2', {
vpc: this.vpc,
instanceType: ec2.InstanceType.of(
ec2.InstanceClass.T3,
ec2.InstanceSize.NANO,
),
machineImage: new ec2.AmazonLinuxImage({
generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2,
}),
});
}
}
Stackを下記の通り編集してnpx cdklocal deploy
します。
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { SampleEC2 } from './sample-ec2';
export class AwsCdkProjectTemplateStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const sampleEc2 = new SampleEC2(this, 'SampleEC2');
}
}
いい感じにEC2インスタンスがデプロイされると思うので、実際に起動しているか確認します。
➜ awsd ec2 describe-instances --filters "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].[InstanceId,State.Name]"
[
[
"i-69a60a4f1fd0e5c2d",
"running"
]
]
ちゃんとデプロイされていることが確認できました。
EC2インスタンスを停止・起動するLambdaをデプロイする
re:Postの記事でもサンプルのLambdaが紹介されていますが、localstack
でLambdaを叩くために、endpoint_url
などを少し工夫してSessionを作ります。
下記のLambdaを作成します。
-
lib/lambda/start_ec2.py
start_ec2.pyimport boto3 import os import logging import json logger = logging.getLogger() logger.setLevel(logging.INFO) # localstackのためにendpoint_urlを指定しているので要カスタマイズ ec2 = boto3.client('ec2',region_name='ap-northeast-1', endpoint_url='http://host.docker.internal:4566') EC2_INSTANCE_ID = os.environ['EC2_INSTANCE_ID'] def lambda_handler(event, context): logger.info(EC2_INSTANCE_ID) ret = ec2.start_instances(InstanceIds=[EC2_INSTANCE_ID]) logger.info('started your instances: ' + str(EC2_INSTANCE_ID)) return { "statusCode": 200, "headers": { "Content-Type": "application/json" }, "body": json.dumps(ret) }
-
lib/lambda/stop_ec2.py
- 上記の
start_instances
をstop_instances
に変更 - logger.infoの内容も
stopped
に変更
- 上記の
これらのコードを実行するLambdaのリソースをConstructで定義します。
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';
export interface ScheduledStartStopEC2Props {
/**
* The EC2 instance ID for scheduled start and stop
*/
ec2InstanceId: string;
vpc: ec2.Vpc;
}
export class ScheduledStartStopEC2 extends Construct {
constructor(scope: Construct, id: string, props: ScheduledStartStopEC2Props) {
super(scope, id);
const startEC2Lambda = new lambda.Function(this, 'StartEC2Lambda', {
runtime: lambda.Runtime.PYTHON_3_10,
handler: 'start_ec2.lambda_handler',
code: lambda.Code.fromAsset('./lib/lambda'),
environment: {
EC2_INSTANCE_ID: props.ec2InstanceId,
},
// 今回はPrivate SubnetにLambdaを配置しない構成
// vpc: props.vpc,
// vpcSubnets: props.vpc.selectSubnets({
// subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
// }),
});
startEC2Lambda.addToRolePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['ec2:Start*'],
resources: ['*'], // 面倒なのでひとまずwildcard
}),
);
const stopEC2Lambda = new lambda.Function(this, 'StopEC2Lambda', {
runtime: lambda.Runtime.PYTHON_3_10,
handler: 'stop_ec2.lambda_handler',
code: lambda.Code.fromAsset('./lib/lambda'),
environment: {
EC2_INSTANCE_ID: props.ec2InstanceId,
},
// 今回はPrivate SubnetにLambdaを配置しない構成
// vpc: props.vpc,
// vpcSubnets: props.vpc.selectSubnets({
// subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
// }),
});
stopEC2Lambda.addToRolePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['ec2:Stop*'],
resources: ['*'], // 面倒なのでひとまずwildcard
}),
);
}
}
StackにLambdaのConstructを追加するように編集します。
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { SampleEC2 } from './sample-ec2';
+import { ScheduledStartStopEC2 } from './scheduled-start-shut-ec2';
export class AwsCdkProjectTemplateStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const sampleEc2 = new SampleEC2(this, 'SampleEC2');
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const scheduledStartStopEC2 = new ScheduledStartStopEC2(
+ this,
+ 'ScheduledStartStopEC2',
+ {
+ ec2InstanceId: sampleEc2.instance.instanceId,
+ vpc: sampleEc2.vpc,
+ },
+ );
}
}
この状態でデプロイして、Lambdaのリソースが作成されていることを確認します。
➜ awsd lambda list-functions
〜省略〜
{
"FunctionName": "AwsCdkProjectTemplateStack-ScheduledStartStopEC2Sta-d1e95441",
"MemorySize": 128,
"LastModified": "2023-09-16T11:55:25.684858+0000",
"CodeSha256": "lzYx2GrTQ4SAk382q9/wQLZd0oXL5YDWMhBoYUxp5D8=",
"Version": "$LATEST",
"Environment": {
"Variables": {
"EC2_INSTANCE_ID": "i-69a60a4f1fd0e5c2d"
}
},
試しにLambdaをinvokeしてみて、正常にインスタンスが停止・起動するか確認してみましょう。
➜ awsd ec2 describe-instances --filters "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].[InstanceId,State.Name]"
[
[
"i-69a60a4f1fd0e5c2d",
"running"
]
]
➜ awsd lambda invoke --function-name AwsCdkProjectTemplateStack-ScheduledStartStopEC2Sto-81982a03 out --log-type Json
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
➜ awsd ec2 describe-instances --filters "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].[InstanceId,State.Name]"
[]
➜ awsd lambda invoke --function-name AwsCdkProjectTemplateStack-ScheduledStartStopEC2Sta-d1e95441 out --log-type Json
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
➜ awsd ec2 describe-instances --filters "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].[InstanceId,State.Name]"
[
[
"i-69a60a4f1fd0e5c2d",
"running"
]
]
上記の通り動作なども問題なさそうなので、EventBridgeによる定期実行を行うようにリソースを定義していきます。
EventBridgeでcronを設定してLambdaが定期的に実行されるようにする
EC2に対して定期的にLambdaで処理を行うというところで、先ほど作成したLambdaにライフサイクルを合わせて同じConstructにEventBridgeを追加します。
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';
+import * as events from 'aws-cdk-lib/aws-events';
+import * as targets from 'aws-cdk-lib/aws-events-targets';
import { Construct } from 'constructs';
export interface ScheduledStartStopEC2Props {
/**
* The EC2 instance ID for scheduled start and stop
*/
ec2InstanceId: string;
vpc: ec2.Vpc;
}
export class ScheduledStartStopEC2 extends Construct {
constructor(scope: Construct, id: string, props: ScheduledStartStopEC2Props) {
super(scope, id);
〜省略〜
stopEC2Lambda.addToRolePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['ec2:Stop*'],
resources: ['*'], // 面倒なのでひとまずwildcard
}),
);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const startEvent = new events.Rule(this, 'EC2StartEvent', {
+ // すぐに確認したいので適当に偶数分ごとに実行する
+ schedule: events.Schedule.cron({ minute: '0/2' }),
+ targets: [new targets.LambdaFunction(startEC2Lambda)],
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const stopEvent = new events.Rule(this, 'EC2StopEvent', {
+ // すぐに確認したいので適当に奇数分ごとに実行する
+ schedule: events.Schedule.cron({ minute: '1/2' }),
+ targets: [new targets.LambdaFunction(stopEC2Lambda)],
+ });
+
+ // localstack Proじゃないと、EventBridgeからPrivate Subnet に配置したLambdaを叩くためのvpc endpointが作れないので保留
+ // props.vpc.addInterfaceEndpoint('EventBridgeEndpoint', {
+ // service: ec2.InterfaceVpcEndpointAwsService.EVENTBRIDGE,
+ // });
}
}
最後にデプロイして、本当に1分ごとにインスタンスが停止・起動されるか確認します。
確認自体は簡単で、起動しているlocalstack
のコンテナのログをみて、Lambdaが叩かれたタイミングでdescribe-instances
します。
➜ awsd ec2 describe-instances --filters "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].[InstanceId,State.Name]"
[]
---------- localstackのコンテナのログ ----------
2023-09-16T12:23:06.427 INFO --- [ asgi_gw_0] localstack.request.aws : AWS ec2.DescribeInstances => 200
--ここでなんか実行されていそう--
2023-09-16T12:23:58.679 INFO --- [ asgi_gw_2] localstack.request.aws : AWS ec2.StartInstances => 200
----
2023-09-16T12:23:58.691 INFO --- [ asgi_gw_0] localstack.request.http : POST /_localstack_lambda/85f1b6b5e0245f53f586ce4bcca7b099/invocations/5c9c82a8-081e-43a9-85b7-d3bcb12b2f57/logs => 202
2023-09-16T12:23:58.695 INFO --- [ asgi_gw_2] localstack.request.http : POST /_localstack_lambda/85f1b6b5e0245f53f586ce4bcca7b099/invocations/5c9c82a8-081e-43a9-85b7-d3bcb12b2f57/response => 202
--------------------
➜ awsd ec2 describe-instances --filters "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].[InstanceId,State.Name]"
[
[
"i-69a60a4f1fd0e5c2d",
"running"
]
]
---------- localstackのコンテナのログ ----------
2023-09-16T12:24:12.211 INFO --- [ asgi_gw_1] localstack.request.aws : AWS ec2.DescribeInstances => 200
--ここでなんか実行されていそう--
2023-09-16T12:24:58.621 INFO --- [ asgi_gw_0] localstack.request.aws : AWS ec2.StopInstances => 200
----
2023-09-16T12:24:58.632 INFO --- [ asgi_gw_1] localstack.request.http : POST /_localstack_lambda/f8b807f52dc020accdb33d14ba20752f/invocations/bf907b70-7445-4b46-bb50-c7924a5da3d3/logs => 202
2023-09-16T12:24:58.638 INFO --- [ asgi_gw_0] localstack.request.http : POST /_localstack_lambda/f8b807f52dc020accdb33d14ba20752f/invocations/bf907b70-7445-4b46-bb50-c7924a5da3d3/response => 202
--------------------
➜ awsd ec2 describe-instances --filters "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].[InstanceId,State.Name]"
[]
---------- localstackのコンテナのログ ----------
2023-09-16T12:25:04.164 INFO --- [ asgi_gw_2] localstack.request.aws : AWS ec2.DescribeInstances => 200
--------------------
ということで、1分ごとにEventBridgeからLambdaを叩いて、EC2インスタンスを自動停止・起動することができました。
ちょっと気になるのがcronの実行タイミングで、start_ec2.py
は偶数分、stop_ec2.py
は奇数分実行されるようにしたのですが、細かく見ると12:23:58.679
にstart_ec2.py
が実行されています。
指定より少し早めにLambdaが叩かれている気がしますが、なんで?っていうのはよくわからないので詳しい人教えていただけると助かります(cronの指定が間違っているよ!とか)。
まとめ
今回はよく実現したいことに挙げられる、EC2を定期的に停止・起動したいというユースケースを題材にして、localstack
のfree版を使ってaws cdkの練習として実装してみました。
まだまだ練習の身ですが、今後もfree版でaws cdk練習できるような題材を適当に見つけて随時練習していきたいと思います。
最後にこれは戯言ですが、ちゃんとAWSのアカウントを用意してaws cdkを使って実際にリソースをデプロイしてみたほうが良いと思っています。localstack
のfree版だとaws cdkと関係ないところでつまずくことが多くなってしまう(あのリソースは対応していないとか、Free版では実現できないとか)ので、余裕のある方はぜひ実際に自分の環境にデプロイして動かしたほうが、自分の使い方に責任が伴うのでより質が高い経験が得られるなと思います。
localstack
では考慮しなくても良いのだけど、実際の環境だと検討しないといけないことなどが漏れてしまう可能性もあります。
個人的には、そんな環境の中で試行錯誤してlocalstack
の制限下でaws cdkを使うことも面白さのひとつなので、なるべく「本番環境とか実際に使うときは本当はこうしたいよ!」っていうのをコメントに載せるようには意識して今後も紹介できたらと思っています。
Discussion