LocalStackを使ってEventBridgeとLambdaを使ったEC2の自動停止・起動するCDKの実装を試す
以前、AWS CDKをlocalstackで練習するを書きましたが、cdkとかLocalStack
をキーワードとして見てくれている方がいそうなので、もっとそういう事例増やそうよと思っていい感じの練習題材を試していこうという記事です。
今回はこちらのre:Postの記事にもあるような構成をcdkで作っていく練習をlocalstack
を使ってやっていきます。
今回の内容はgitにも上げていますので誤りなどがあれば教えていただけると幸いです。
前提
自分の練習のための軌跡を残していく側面があるので、目的はaws cdk
を練習するところに焦点を当てて下記の前提で進めていきます。
-
aws cdk
が何ができるかはわかる -
localstack
使うと何が便利そうかわかる - awsのアーキテクトチョットワカル
検証環境
- MacBook Air M1 (Sequoia 15.0.1)
- Docker version 27.3.1, build ce12230 (最近は*OrbStack (1.7.5)*から使っていますのでそちらも導入前提で)
- Node v20.10.0
- localstack/localstack:latest (3.8.2.dev32)
- aws-cli/2.18.9 Python/3.12.7 Darwin/24.0.0 source/arm64 (homebrewで入れてます)
localstackによるAWS環境の準備
何回かlocalstack
を練習した記事を投稿しており、その記事ではdocker compose
を使って環境を構築したりしていましたが、今回は別の環境でも試してみたいと思ったのでメモします。
下記のような条件でlocalstack
の環境を作ります。
Apple SiliconのMacBook Airで、OrbStack を利用して、LocalStack(Free)のDocker版を使って環境を構築する
localstackの実行
localstack公式のとおりDockerを使ってやっていきます。基本的には公式が出している方法で利用できるので、WSL2に直でDocker入れてるよとかLimaに直でDocker入れているよという場合は多少飛ばしてしまっていいと思います。
OrbStack の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
orbstack OrbStack unix:///Users/xxxxx/.orbstack/run/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:latest
LocalStack version: 3.8.2.dev32
LocalStack build date: 2024-10-16
LocalStack build git hash: 1bc0db543
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 Scheduler 単体で API を叩くことができるので、あくまでも CDK を練習するというアーキテクチャであることを許してください。
本来であれば、より実用性を高めるために、「Private Subnet上にLambdaを起動し、Private Subnet 内のEC2インスタンスをターゲットに停止・起動させる」というのをやりたかったのですが、localstack
ではEIPやVPC EndpointについてはPro版のみ対応だよという感じで怒られてしまったので、パブリックなリソースに対して試しています。
いつの間にか VPC Endpoint のリソースを作っても怒られなくなっていたので、 Private Isolated Subnet の EC2 インスタンスをターゲットに実施します。
手順
こちらのオレオレテンプレートを利用してaws cdkの環境を整えていきます。今回はlocalstack-eventbridge-lambda-ec2
という名前でテンプレートからリポジトリを作成・クローンしています。
使わない場合は適当にcdk init
でもしてください。
npm i aws-cdk-local
でcdklocal
をインストールして、npx cdklocal bootstrap
しておきます。
まずはEC2を建てる
public subnetにEC2インスタンスを起動していくConstructを定義します。実際にAWS環境にデプロしても安いインスタンス使えるようにしています。
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つで今回試す
this.vpc = new ec2.Vpc(this, 'VPC', {
maxAzs: 1,
subnetConfiguration: [
{
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
name: 'Isolated Subnet',
},
],
});
this.instance = new ec2.Instance(this, 'SampleEC2', {
vpc: this.vpc,
instanceType: ec2.InstanceType.of(
ec2.InstanceClass.T4G,
ec2.InstanceSize.NANO,
),
machineImage: ec2.MachineImage.latestAmazonLinux2023({
cpuType: ec2.AmazonLinuxCpuType.ARM_64,
}),
});
this.vpc.addInterfaceEndpoint('Ec2endpoint', {
service: ec2.InterfaceVpcEndpointAwsService.EC2,
});
}
}
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-30ac35442823afe00",
"running"
]
]
ちゃんとデプロイされていることが確認できました。
EC2インスタンスを停止・起動するLambdaをデプロイする
re:Postの記事でもサンプルのLambdaが紹介されていますが、localstack
でLambdaを叩くために、endpoint_url
などを少し工夫してSessionを作ります。
下記のLambdaを作成します。
-
lib/lambda/start_stop_ec2.py
start_stop_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 = boto3.client("ec2") EC2_INSTANCE_ID = os.environ["EC2_INSTANCE_ID"] def lambda_handler(event, context): logger.info(EC2_INSTANCE_ID) if event["COMMAND"] == "START": ret = ec2.start_instances(InstanceIds=[EC2_INSTANCE_ID]) logger.info("started your instances: " + str(EC2_INSTANCE_ID)) else: ret = ec2.stop_instances(InstanceIds=[EC2_INSTANCE_ID]) logger.info("stopped your instances: " + str(EC2_INSTANCE_ID)) return { "statusCode": 200, "headers": {"Content-Type": "application/json"}, "body": json.dumps(ret), }
このコードを実行する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';
import { Duration } from 'aws-cdk-lib';
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 startStopEC2Lambda = new lambda.Function(this, 'StartStopEC2Lambda', {
runtime: lambda.Runtime.PYTHON_3_12,
handler: 'start_stop_ec2.lambda_handler',
code: lambda.Code.fromAsset('./lib/lambda'),
environment: {
EC2_INSTANCE_ID: props.ec2InstanceId,
},
timeout: Duration.minutes(3),
vpc: props.vpc,
vpcSubnets: props.vpc.selectSubnets({
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
}),
});
startStopEC2Lambda.addToRolePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['ec2:Start*', 'ec2:Stop*'],
resources: ['*'], // 面倒なのでひとまずwildcard
}),
);
}
}
この状態でデプロイして、Lambdaのリソースが作成されていることを確認します。
➜ awsd lambda list-functions
〜省略〜
{
"FunctionName": "AwsCdkProjectTemplateStack-ScheduledStartStopEC2Sta-98d50568",
"FunctionArn": "arn:aws:lambda:ap-northeast-1:000000000000:function:AwsCdkProjectTemplateStack-ScheduledStartStopEC2Sta-98d50568",
"Runtime": "python3.12",
"Role": "arn:aws:iam::000000000000:role/AwsCdkProjectTemplateStack-ScheduledStartStopEC2Sta-39b6e0ec",
"Handler": "start_stop_ec2.lambda_handler",
"CodeSize": 652,
"Description": "",
"Timeout": 180,
"MemorySize": 128,
"LastModified": "2024-10-19T13:29:19.300958+0000",
"CodeSha256": "OZwCaYUaZratfWTNXTLcgONaF7KbsoopcTL7GrN1McQ=",
"Version": "$LATEST",
"VpcConfig": {
"SubnetIds": [
"subnet-87466642"
],
"SecurityGroupIds": [
"sg-ddc4085396ff512cc"
],
"VpcId": "vpc-d3d3d8e2"
},
"Environment": {
"Variables": {
"EC2_INSTANCE_ID": "i-30ac35442823afe00"
}
},
}
試しにLambdaをinvokeしてみて、正常にインスタンスが停止・起動するか確認してみましょう。
➜ awsd ec2 describe-instances --filters "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].[InstanceId,State.Name]"
[
[
"i-30ac35442823afe00",
"running"
]
]
➜ awsd lambda invoke --function-name AwsCdkProjectTemplateStack-ScheduledStartStopEC2Sta-98d50568 --cli-binary-format raw-in-base64-out --payload '{"COMMAND": "STOP"}' 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-98d50568 --cli-binary-format raw-in-base64-out --payload '{"COMMAND": "START"}' 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-30ac35442823afe00",
"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';
import { Duration } from 'aws-cdk-lib';
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 startStopEC2Lambda = new lambda.Function(this, 'StartStopEC2Lambda', {
runtime: lambda.Runtime.PYTHON_3_12,
handler: 'start_stop_ec2.lambda_handler',
code: lambda.Code.fromAsset('./lib/lambda'),
environment: {
EC2_INSTANCE_ID: props.ec2InstanceId,
},
timeout: Duration.minutes(3),
vpc: props.vpc,
vpcSubnets: props.vpc.selectSubnets({
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
}),
});
startStopEC2Lambda.addToRolePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['ec2:Start*', '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(startStopEC2Lambda, {
+ event: events.RuleTargetInput.fromObject({
+ COMMAND: 'START',
+ }),
+ }),
+ ],
+ });
+
+ // 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(startStopEC2Lambda, {
+ event: events.RuleTargetInput.fromObject({
+ COMMAND: 'STOP',
+ }),
+ }),
+ ],
+ });
}
}
最後にデプロイして、本当に1分ごとにインスタンスが停止・起動されるか確認します。
確認自体は簡単で、起動しているlocalstack
のコンテナのログをみて、Lambdaが叩かれたタイミングでdescribe-instances
します。
➜ awsd ec2 describe-instances --filters "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].[InstanceId,State.Name]"
[]
---------- localstackのコンテナのログ ----------
2024-10-19T13:47:34.388 INFO --- [et.reactor-0] localstack.request.aws : AWS ec2.DescribeInstances => 200
--ここでなんか実行されていそう--
2024-10-19T13:47:57.603 INFO --- [et.reactor-1] localstack.request.aws : AWS ec2.StartInstances => 200
----
2024-10-19T13:47:57.609 INFO --- [et.reactor-2] localstack.request.http : POST /_localstack_lambda/2c0a52655a1c393768c22f7c6e703e3f/invocations/6a8179e8-9eee-4e91-ae6f-7ce0951b4fa6/logs => 202
2024-10-19T13:47:57.614 INFO --- [et.reactor-0] localstack.request.http : POST /_localstack_lambda/2c0a52655a1c393768c22f7c6e703e3f/invocations/6a8179e8-9eee-4e91-ae6f-7ce0951b4fa6/response => 202
--------------------
➜ awsd ec2 describe-instances --filters "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].[InstanceId,State.Name]"
[
[
"i-30ac35442823afe00",
"running"
]
]
---------- localstackのコンテナのログ ----------
2024-10-19T13:48:46.863 INFO --- [et.reactor-2] localstack.request.aws : AWS ec2.DescribeInstances => 200
--ここでなんか実行されていそう--
2024-10-19T13:48:57.501 INFO --- [et.reactor-2] localstack.request.aws : AWS ec2.StopInstances => 200
----
2024-10-19T13:48:57.508 INFO --- [et.reactor-0] localstack.request.http : POST /_localstack_lambda/2c0a52655a1c393768c22f7c6e703e3f/invocations/a616802f-9d0d-4f84-a5d8-81b70b8632b5/logs => 202
2024-10-19T13:48:57.510 INFO --- [et.reactor-1] localstack.request.http : POST /_localstack_lambda/2c0a52655a1c393768c22f7c6e703e3f/invocations/a616802f-9d0d-4f84-a5d8-81b70b8632b5/response => 202
--------------------
➜ awsd ec2 describe-instances --filters "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].[InstanceId,State.Name]"
[]
---------- localstackのコンテナのログ ----------
2024-10-19T13:49:24.278 INFO --- [et.reactor-0] localstack.request.aws : AWS ec2.DescribeInstances => 200
--------------------
ということで、1分ごとにEventBridgeからLambdaを叩いて、EC2インスタンスを自動停止・起動することができました。
ちょっと気になるのがAPIの実行タイミングで、細かく見ると13:48:57.501
に実行されています。
指定より少し早めにLambdaが叩かれている気がしますが、なんで?っていうのはよくわからないので詳しい人教えていただけると助かります(cronの指定が間違っているよ!とか)。
まとめ
今回はよく実現したいことに挙げられる、EC2を定期的に停止・起動したいというユースケースを題材にして、localstack
のfree版を使ってaws cdkの練習として実装してみました。
まだまだ練習の身ですが、今後もfree版でaws cdk練習できるような題材を適当に見つけて随時練習していきたいと思います。
最後にこれは戯言ですが、ちゃんとAWSのアカウントを用意してaws cdkを使って実際にリソースをデプロイしてみたほうが良いと思っています。localstack
のfree版だとaws cdkと関係ないところでつまずくことが多くなってしまう(あのリソースは対応していないとか、Free版では実現できないとか)ので、余裕のある方はぜひ実際に自分の環境にデプロイして動かしたほうが、自分の使い方に責任が伴うのでより質が高い経験が得られるなと思います。
localstack
では考慮しなくても良いのだけど、実際の環境だと検討しないといけないことなどが漏れてしまう可能性もあります。
個人的には、そんな環境の中で試行錯誤してlocalstack
の制限下でaws cdkを使うことも面白さのひとつなので、なるべく「本番環境とか実際に使うときは本当はこうしたいよ!」っていうのをコメントに載せるようには意識して今後も紹介できたらと思っています。
Discussion