🚀

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 (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叩くと、下記のように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インスタンスを自動で停止・起動させるというものです。

architecture

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

まずはEC2を建てる

public subnetにEC2インスタンスを起動していくConstructを定義します。

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つで今回試す
    // 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します。

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-69a60a4f1fd0e5c2d",
        "running"
    ]
]

ちゃんとデプロイされていることが確認できました。

EC2インスタンスを停止・起動するLambdaをデプロイする

re:Postの記事でもサンプルのLambdaが紹介されていますが、localstackでLambdaを叩くために、endpoint_urlなどを少し工夫してSessionを作ります。

下記のLambdaを作成します。

  • lib/lambda/start_ec2.py

    start_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_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_instancesstop_instancesに変更
    • logger.infoの内容もstoppedに変更

これらのコードを実行する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';

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を追加するように編集します。

aws-cdk-project-template-stack.ts
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を追加します。

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';

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.679start_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