michimani.net

AWS CDK を使って CloudWatch Event + Lambda で EC2 を自動起動・停止させる環境を作ってみた

2019-08-07

GA になってから間もない AWS CDK ですが、既にそのバージョンは 1.3.0 になっています。そんな AWS CDK を使って、特定のタグを持つ EC2 インスタンスの自動起動・停止をする環境を構築してみました。

概要

特定のタグ名と値を持つ EC2 インスタンスを自動で停止・起動させる環境を構築します。
自動停止・起動の処理は Lambda で実装し、それを CloudWatch Events のスケジュールで定期的に実行します。この方法自体は以前にも書いた通りですが、今回は Lambda 関数の作成やスケジュールの設定などの環境構築を AWS CDK でやってしまおう という話です。

CDK アプリケーションの作成

それでは早速 AWS CDK アプリケーションを作成していきます。
AWS CDK については公式のドキュメント、およびリファレンスをご参照ください。

CDK はこの記事作成時点で最新の 1.3.0 を使用します。

$ cdk --version
1.3.0 (build bba9914)

TypeScript で実装していきます。

$ mkdir auto-start-stop-ec2
$ cd auto-start-stop-ec2
$ cdk init app --language=typescript
Applying project template app for typescript
Initializing a new git repository...
Executing npm install...
...
# Useful commands

 * `npm run build`   compile typescript to js
 * `npm run watch`   watch for changes and compile
 * `cdk deploy`      deploy this stack to your default AWS account/region
 * `cdk diff`        compare deployed stack with current state
 * `cdk synth`       emits the synthesized CloudFormation template

次のようなディレクトリ構成ができあがります。

.
├── README.md
├── bin
│   └── auto-start-stop-ec2.ts
├── cdk.json
├── lib
│   └── auto-start-stop-ec2-stack.ts
├── node_modules
│   ├── @aws-cdk
...
├── package-lock.json
├── package.json
└── tsconfig.json

パッケージのインストール

まずは必要なパッケージをインストールします。
CDK では、コアとなる @aws-cdk/core の他に、必要となるサービスごとのパッケージを使用します。

$ npm install @aws-cdk/aws-events @aws-cdk/aws-events-targets @aws-cdk/aws-lambda @aws-cdk/aws-iam

準備が整ったので、 lib/auto-start-stop-ec2-stack.ts を編集して必要なリソースを作成するコードを書いていきます。

Lambda

まずは Lambda 関数です。
今回は予め Python で書かれた Lambda 関数用の auto-start-stop-ec2.py ファイルを、新たに作成した lambda/ ディレクトリに用意しておき、それを使うように実装します。

また、 Lambda では EC2 インスタンスの起動と停止を実行するので、必要な IAM ポリシーも付与します。

const lambdaFn = new lambda.Function(this, 'singleton', {
  code: new lambda.InlineCode(fs.readFileSync('lambda/auto-start-stop-ec2.py', {encoding: 'utf-8'})),
  handler: 'index.main',
  timeout: cdk.Duration.seconds(300),
  runtime: lambda.Runtime.PYTHON_3_7,
});

lambdaFn.addToRolePolicy(new iam.PolicyStatement({
  actions: [
    'ec2:DescribeInstances',
    'ec2:StartInstances',
    'ec2:StopInstances'
  ],
  resources: ['*']
}));

ちなみに外部のファイルを読み込む際のパスは、 CDK アプリケーションのルートディレクトリからの相対パスになります。この lib/*.ts からの相対パスではありません。

CloudWatch Events

続いて、 Lammda 関数を定期実行するための CloudWatch Events を実装します。

const stackConfig = JSON.parse(fs.readFileSync('stack.config.json', {encoding: 'utf-8'}));

// STOP EC2 instances rule
const stopRule = new events.Rule(this, 'StopRule', {
  schedule: events.Schedule.expression(`cron(${stackConfig.events.cron.stop})`)
});

stopRule.addTarget(new targets.LambdaFunction(lambdaFn, {
  event: events.RuleTargetInput.fromObject({Region: stackConfig.targets.ec2region, Action: 'stop'})
}));

// START EC2 instances rule
const startRule = new events.Rule(this, 'StartRule', {
  schedule: events.Schedule.expression(`cron(${stackConfig.events.cron.start})`)
});

startRule.addTarget(new targets.LambdaFunction(lambdaFn, {
  event: events.RuleTargetInput.fromObject({Region: stackConfig.targets.ec2region, Action: 'start'})
}));

CloudWatch Events の定義としては、まず events.Rule オブジェクトを作成し、 addTarget() メソッドでターゲット (今回の場合は Lambda 関数) を追加するという形になります。
targets.LambdaFunction のコンストラクタの第 2 引数は任意のパラメータで、 CloudWatch Events で Lambda 関数を呼ぶ時の引数を指定することができます。

今回は 起動と停止の時間、対象となる EC2 インスタンスがあるリージョンを stack.config.json という JSON ファイル内で定義しておいて、その値を使用するようにしています。
ちなみにここでも指定するファイルパスは、 CDK アプリケーションのルートディレクトリからの相対パスになります。

{
  "events": {
    "cron": {
      "start": "55 23 ? * SUN-THU *",
      "stop": "0 12 ? * * *"
    }
  },
  "targets": {
    "ec2region": "ap-northeast-1"
  }
}

これで CDK アプリケーションの実装は完了です。 auto-start-stop-ec2-stack.ts の全体としては下記のような感じです。

import events = require('@aws-cdk/aws-events');
import iam = require('@aws-cdk/aws-iam');
import lambda = require('@aws-cdk/aws-lambda');
import targets = require('@aws-cdk/aws-events-targets');
import cdk = require('@aws-cdk/core');

import fs = require('fs');

export class AutoStartStopEc2Stack extends cdk.Stack {
  constructor (app: cdk.App, id: string) {
    super(app, id);

    const stackConfig = JSON.parse(fs.readFileSync('stack.config.json', {encoding: 'utf-8'}));

    const lambdaFn = new lambda.Function(this, 'singleton', {
      code: new lambda.InlineCode(fs.readFileSync('lambda/auto-start-stop-ec2.py', {encoding: 'utf-8'})),
      handler: 'index.main',
      timeout: cdk.Duration.seconds(300),
      runtime: lambda.Runtime.PYTHON_3_7,
    });

    lambdaFn.addToRolePolicy(new iam.PolicyStatement({
      actions: [
        'ec2:DescribeInstances',
        'ec2:StartInstances',
        'ec2:StopInstances'
      ],
      resources: ['*']
    }));

    // STOP EC2 instances rule
    const stopRule = new events.Rule(this, 'StopRule', {
      schedule: events.Schedule.expression(`cron(${stackConfig.events.cron.stop})`)
    });

    stopRule.addTarget(new targets.LambdaFunction(lambdaFn, {
      event: events.RuleTargetInput.fromObject({Region: stackConfig.targets.ec2region, Action: 'stop'})
    }));

    // START EC2 instances rule
    const startRule = new events.Rule(this, 'StartRule', {
      schedule: events.Schedule.expression(`cron(${stackConfig.events.cron.start})`)
    });

    startRule.addTarget(new targets.LambdaFunction(lambdaFn, {
      event: events.RuleTargetInput.fromObject({Region: stackConfig.targets.ec2region, Action: 'start'})
    }));
  }
}

あとは npm run build を実行して完了です。

CloudFormation のテンプレートを出力してみる

CDK アプリケーションとして実装した auto-start-stop-ec2-stack.ts は、最終行の空行も含めてちょうど 50 行でした。
これを CloudFormation の template (yaml) として出力してみます。

$ cdk synth
Resources:
  singletonServiceRole9C9ECF4A:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service:
                Fn::Join:
                  - ""
                  - - lambda.
                    - Ref: AWS::URLSuffix
        Version: "2012-10-17"
      ManagedPolicyArns:
        - Fn::Join:
            - ""
            - - "arn:"
              - Ref: AWS::Partition
              - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
    Metadata:
      aws:cdk:path: AutoStartStopEc2Stack/singleton/ServiceRole/Resource
  singletonServiceRoleDefaultPolicyFDD8CA90:
    Type: AWS::IAM::Policy
    Properties:
      PolicyDocument:
        Statement:
          - Action:
              - ec2:DescribeInstances
              - ec2:StartInstances
              - ec2:StopInstances
            Effect: Allow
            Resource: "*"
        Version: "2012-10-17"
      PolicyName: singletonServiceRoleDefaultPolicyFDD8CA90
      Roles:
        - Ref: singletonServiceRole9C9ECF4A
    Metadata:
      aws:cdk:path: AutoStartStopEc2Stack/singleton/ServiceRole/DefaultPolicy/Resource
  singleton69FEA30F:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: >
          import boto3

          import json

          import traceback



          def main(event, context):
              try:
                  region = event['Region']
                  action = event['Action']
                  client = boto3.client('ec2', region)
                  responce = client.describe_instances(
                      Filters=[{'Name': 'tag:AutoStartStop', "Values": ['TRUE']}])

                  target_instans_ids = []
                  for reservation in responce['Reservations']:
                      for instance in reservation['Instances']:
                          tag_name = ''
                          for tag in instance['Tags']:
                              if tag['Key'] == 'Name':
                                  tag_name = tag['Value']
                                  break

                          target_instans_ids.append(instance['InstanceId'])

                  print(target_instans_ids)

                  if not target_instans_ids:
                      print('There are no instances subject to automatic {}.'.format(action))
                  else:
                      if action == 'start':
                          client.start_instances(InstanceIds=target_instans_ids)
                          print('started instances.')
                      elif action == 'stop':
                          client.stop_instances(InstanceIds=target_instans_ids)
                          print('stopped instances.')
                      else:
                          print('Invalid action.')

                  return {
                      "statusCode": 200,
                      "message": 'Finished automatic {action} EC2 instances process. [Region: {region}, Action: {action}]'.format(region=event['Region'], action=event['Action'])
                  }
              except:
                  print(traceback.format_exc())
                  return {
                      "statusCode": 500,
                      "message": 'An error occured at automatic start / stop EC2 instances process.'
                  }
      Handler: index.main
      Role:
        Fn::GetAtt:
          - singletonServiceRole9C9ECF4A
          - Arn
      Runtime: python3.7
      Timeout: 300
    DependsOn:
      - singletonServiceRoleDefaultPolicyFDD8CA90
      - singletonServiceRole9C9ECF4A
    Metadata:
      aws:cdk:path: AutoStartStopEc2Stack/singleton/Resource
  singletonAllowEventRuleAutoStartStopEc2StackStopRule6999EBDE48367C0B:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName:
        Fn::GetAtt:
          - singleton69FEA30F
          - Arn
      Principal: events.amazonaws.com
      SourceArn:
        Fn::GetAtt:
          - StopRule00306666
          - Arn
    Metadata:
      aws:cdk:path: AutoStartStopEc2Stack/singleton/AllowEventRuleAutoStartStopEc2StackStopRule6999EBDE
  singletonAllowEventRuleAutoStartStopEc2StackStartRule682FBE6A53E17D5B:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName:
        Fn::GetAtt:
          - singleton69FEA30F
          - Arn
      Principal: events.amazonaws.com
      SourceArn:
        Fn::GetAtt:
          - StartRule40F02F2E
          - Arn
    Metadata:
      aws:cdk:path: AutoStartStopEc2Stack/singleton/AllowEventRuleAutoStartStopEc2StackStartRule682FBE6A
  StopRule00306666:
    Type: AWS::Events::Rule
    Properties:
      ScheduleExpression: cron(0 12 ? * * *)
      State: ENABLED
      Targets:
        - Arn:
            Fn::GetAtt:
              - singleton69FEA30F
              - Arn
          Id: Target0
          Input: '{"Region":"ap-northeast-1","Action":"stop"}'
    Metadata:
      aws:cdk:path: AutoStartStopEc2Stack/StopRule/Resource
  StartRule40F02F2E:
    Type: AWS::Events::Rule
    Properties:
      ScheduleExpression: cron(55 23 ? * SUN-THU *)
      State: ENABLED
      Targets:
        - Arn:
            Fn::GetAtt:
              - singleton69FEA30F
              - Arn
          Id: Target0
          Input: '{"Region":"ap-northeast-1","Action":"start"}'
    Metadata:
      aws:cdk:path: AutoStartStopEc2Stack/StartRule/Resource
  CDKMetadata:
    Type: AWS::CDK::Metadata
    Properties:
      Modules: aws-cdk=1.3.0,@aws-cdk/assets=1.3.0,@aws-cdk/aws-applicationautoscaling=1.3.0,@aws-cdk/aws-autoscaling=1.3.0,@aws-cdk/aws-autoscaling-common=1.3.0,@aws-cdk/aws-autoscaling-hooktargets=1.3.0,@aws-cdk/aws-cloudformation=1.3.0,@aws-cdk/aws-cloudwatch=1.3.0,@aws-cdk/aws-ec2=1.3.0,@aws-cdk/aws-ecr=1.3.0,@aws-cdk/aws-ecr-assets=1.3.0,@aws-cdk/aws-ecs=1.3.0,@aws-cdk/aws-elasticloadbalancingv2=1.3.0,@aws-cdk/aws-events=1.3.0,@aws-cdk/aws-events-targets=1.3.0,@aws-cdk/aws-iam=1.3.0,@aws-cdk/aws-kms=1.3.0,@aws-cdk/aws-lambda=1.3.0,@aws-cdk/aws-logs=1.3.0,@aws-cdk/aws-s3=1.3.0,@aws-cdk/aws-s3-assets=1.3.0,@aws-cdk/aws-servicediscovery=1.3.0,@aws-cdk/aws-sns=1.3.0,@aws-cdk/aws-sns-subscriptions=1.3.0,@aws-cdk/aws-sqs=1.3.0,@aws-cdk/aws-ssm=1.3.0,@aws-cdk/core=1.3.0,@aws-cdk/custom-resources=1.3.0,@aws-cdk/cx-api=1.3.0,@aws-cdk/region-info=1.3.0,jsii-runtime=node.js/v10.15.1

CDK のメタデータも含まれていますが、約 170 行の YAML ファイルになりました。
YAML で CloudFormation のテンプレートをしっかり書いたことはないで、これをゼロから書けと言われると辛いです。今回のようなシンプルな構成であれば良いですが、もっと複雑な構成になるとさらに辛そうです。
前に参加した CDK Meetup では、 1,000 行を超えるテンプレートを書いた みたいな話もありました。

デプロイ

CDK アプリケーションが完成したので、あとはデプロイするだけです。
デプロイもいたって簡単で、 cdk deploy コマンドを実行するだけです。

cdk deploy
This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

<diff が良い感じに出力されます>

Do you wish to deploy these changes (y/n)? y
AutoStartStopEc2Stack: deploying...
AutoStartStopEc2Stack: creating CloudFormation changeset...
 0/9 | 15:41:41 | CREATE_IN_PROGRESS   | AWS::CDK::Metadata      | CDKMetadata 
 0/9 | 15:41:41 | CREATE_IN_PROGRESS   | AWS::IAM::Role          | singleton/ServiceRole (singletonServiceRole9C9ECF4A) 
 0/9 | 15:41:41 | CREATE_IN_PROGRESS   | AWS::IAM::Role          | singleton/ServiceRole (singletonServiceRole9C9ECF4A) Resource creation Initiated
 0/9 | 15:41:43 | CREATE_IN_PROGRESS   | AWS::CDK::Metadata      | CDKMetadata Resource creation Initiated
 1/9 | 15:41:43 | CREATE_COMPLETE      | AWS::CDK::Metadata      | CDKMetadata 

...

 9/9 | 15:43:38 | CREATE_COMPLETE      | AWS::CloudFormation::Stack | AutoStartStopEc2Stack 

 ✅  AutoStartStopEc2Stack

Stack ARN:
arn:aws:cloudformation:ap-northeast-1:123456789876:stack/AutoStartStopEc2Stack/51a4b4f0-b8de-11e9-ad2d-0ea27dfbb62e

作成されるリソースの内容が出力され、 Do you wish to deploy these changes (y/n)?y を入力すればリソースの作成が始まるので、あとは待つだけです。途中でエラーが発生すれば、その旨 出力されます。
ちなみに cdk deploy でデプロイされる先は、 AWS CLI の設定ファイル .aws/config および .aws/credentials に依存しています。なので、 --profile オプションを使えばデフォルト以外のアカウントへのデプロイも可能です。

まとめ

AWS CDK を使って CloudWatch Event + Lambda で EC2 を自動起動・停止させる環境を作ってみた話でした。
今まで CloudFormation はほとんど触ったことがなく、 YAML でテンプレートを書くという作業にも食わず嫌いでした。そんなところに AWS CDK が現れて、 TypeScript, Python といった普段の開発に使っている言語でインフラコードが書けるというのは、かなり画期的なことだと思いました。
CDK Meetup のときに AWS の福井さんが仰っていた 「Infrastructure as Code ではなくて Infrastructure is Code だ」 という言葉が非常にしっくりきました。

CDK 自体に関しては GA になって 1 ヶ月も経っていませんが、既にバージョンが 1.3.0 になっており、引き続き結構なスピードでアップデートされていくのかなーという印象です。
CDK きっかけでインフラのコード化に足を踏み入れたので、置いて行かれないようにしたいと思います。

今回作ったもの

今回作った CDK アプリケーションは GitHub に置いていますので、よかったら使ってみてください。書き方おかしいとかあれば指摘も大歓迎です。


comments powered by Disqus