🚀

LocalStackを使ってEventBridgeとLambdaを使ったEC2の自動停止・起動するCDKの実装を試す

2023/09/16に公開

以前、AWS CDKをlocalstackで練習するを書きましたが、cdkとかLocalStack をキーワードとして見てくれている方がいそうなので、もっとそういう事例増やそうよと思っていい感じの練習題材を試していこうという記事です。

今回はこちらのre:Postの記事にもあるような構成をcdkで作っていく練習をlocalstackを使ってやっていきます。

今回の内容はgitにも上げていますので誤りなどがあれば教えていただけると幸いです。

https://github.com/okojomoeko/localstack-eventbridge-lambda-ec2

前提

自分の練習のための軌跡を残していく側面があるので、目的は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叩くと、下記のようにListBucketsAPI叩かれていることがわかります。

❯ 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 を練習するというアーキテクチャであることを許してください。

architecture

本来であれば、より実用性を高めるために、「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-localcdklocalをインストールして、npx cdklocal bootstrapしておきます。

まずはEC2を建てる

public subnetにEC2インスタンスを起動していくConstructを定義します。実際にAWS環境にデプロしても安いインスタンス使えるようにしています。

sample-ec2.ts
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します。

aws-cdk-project-template-stack.ts
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.py
    import 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で定義します。

scueduled-start-shut-ec2.ts
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を追加します。

scheduled-start-shut-ec2.ts
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