はじめに

EC2やRDSは稼働している時間に応じて課金されるため、検証環境などで「リソースを使っていない時間帯は停止してコストを削減したい」と考える方は多いのではないでしょうか。
そんなときに役立つのが、Amazon EventBridge Schedulerです。
EventBridge Schedulerは時刻指定でEC2やRDSを操作できるため、手動でインスタンスを起動・停止する手間を削減できます。

本記事では、AWS CDKを利用してEventBridge SchedulerでEC2とRDSを自動起動・停止する方法を紹介します。

前提条件

  • macOS 上での作業を想定
  • AWS CLI がインストール済み、かつ認証情報が設定されていること
  • AWS CDK がインストール済み

AWS CLIのインストール

$ brew install awscli
$ aws configure

AWS CDKのインストール

$ brew install aws-cdk
$ cdk --version

セットアップ

まず、CDKプロジェクトを新規作成します。
次のコマンドを実行してディレクトリを作成し、CDK(TypeScript)の初期化を行います。

$ mkdir cdk-sample
$ cd cdk-sample
$ cdk init app --language typescript

これで、cdk-sample フォルダ直下にCDK用の各種ファイルが生成されます。
主要なファイル・フォルダ構成は以下のようになります。

cdk-sample/
├── bin/
│   └── cdk-sample.ts        // エントリーポイント
├── lib/
│   └── cdk-sample-stack.ts  // スタックの定義ファイル
├── package.json
├── cdk.json
├── tsconfig.json
└── README.md

CDK スタックの定義

続いて、lib/cdk-sample-stack.tsに必要なコードを追記していきます。

CDK スタックとは

AWS CDKでは、AWSのリソースをプログラムで構築・管理できます。
スタックとは、CloudFormationのスタックと同義で、AWS上にデプロイされるリソースをまとめた単位です。
複数のリソースをまとめて一度に更新したい場合に、スタックとしてコードで定義します。

実装

以下はEC2とRDSの起動・停止をEventBridge Schedulerで管理するコードです。
すでに作成済みのEC2インスタンスおよびRDSインスタンスを対象としています。

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Role, ServicePrincipal, PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { CfnSchedule, CfnScheduleGroup } from 'aws-cdk-lib/aws-scheduler';

export class CdkSampleStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const region = process.env.AWS_REGION || 'ap-northeast-1';
    const account = process.env.AWS_ACCOUNT_ID || '';
    const ec2InstanceId = process.env.EC2_INSTANCE_ID || '';
    const rdsInstanceId = process.env.RDS_INSTANCE_ID || '';

    // IAMロールの作成
    const schedulerRole = new Role(this, 'SchedulerRole', {
      assumedBy: new ServicePrincipal('scheduler.amazonaws.com'),
      description: 'IAM role for EventBridge Scheduler to start/stop EC2 and RDS',
    });

    schedulerRole.addToPolicy(new PolicyStatement({
      actions: ['ec2:StartInstances', 'ec2:StopInstances'],
      resources: [
        `arn:aws:ec2:${region}:${account}:instance/${ec2InstanceId}`,
      ],
    }));

    schedulerRole.addToPolicy(new PolicyStatement({
      actions: ['rds:StartDBInstance', 'rds:StopDBInstance'],
      resources: [`arn:aws:rds:${region}:${account}:db:${rdsInstanceId}`,],
    }));

    // スケジュールグループ
    const scheduleGroup: CfnScheduleGroup = new CfnScheduleGroup(this, 'ScheduleGroup', {
      name: 'auto-start-stop-group',
    });

    // EC2起動スケジュール
    new CfnSchedule(this, 'EC2StartSchedule', {
      groupName: scheduleGroup.name,
      scheduleExpressionTimezone: 'Asia/Tokyo',
      scheduleExpression: 'cron(0 8 ? * MON-FRI *)',
      flexibleTimeWindow: { mode: 'OFF' },
      target: {
        arn: 'arn:aws:scheduler:::aws-sdk:ec2:startInstances',
        roleArn: schedulerRole.roleArn,
        input: JSON.stringify({ InstanceIds: [ec2InstanceId] }),
      },
      description: 'Start EC2 instance',
      name: 'ec2-start-schedule',
    });

    // EC2停止スケジュール
    new CfnSchedule(this, 'EC2StopSchedule', {
      groupName: scheduleGroup.name,
      scheduleExpressionTimezone: 'Asia/Tokyo',
      scheduleExpression: 'cron(0 20 ? * MON-FRI *)',
      flexibleTimeWindow: { mode: 'OFF' },
      target: {
        arn: 'arn:aws:scheduler:::aws-sdk:ec2:stopInstances',
        roleArn: schedulerRole.roleArn,
        input: JSON.stringify({ InstanceIds: [ec2InstanceId] }),
      },
      description: 'Stop EC2 instance',
      name: 'ec2-stop-schedule',
    });

    // RDS起動スケジュール
    new CfnSchedule(this, 'RDSStartSchedule', {
      groupName: scheduleGroup.name,
      scheduleExpressionTimezone: 'Asia/Tokyo',
      scheduleExpression: 'cron(5 8 ? * MON-FRI *)',
      flexibleTimeWindow: { mode: 'OFF' },
      target: {
        arn: 'arn:aws:scheduler:::aws-sdk:rds:startDBInstance',
        roleArn: schedulerRole.roleArn,
        input: JSON.stringify({ DbInstanceIdentifier: rdsInstanceId }),
      },
      description: 'Start RDS instance',
      name: 'rds-start-schedule',
    });

    // RDS停止スケジュール
    new CfnSchedule(this, 'RDSStopSchedule', {
      groupName: scheduleGroup.name,
      scheduleExpressionTimezone: 'Asia/Tokyo',
      scheduleExpression: 'cron(5 20 ? * MON-FRI *)',
      flexibleTimeWindow: { mode: 'OFF' },
      target: {
        arn: 'arn:aws:scheduler:::aws-sdk:rds:stopDBInstance',
        roleArn: schedulerRole.roleArn,
        input: JSON.stringify({ DbInstanceIdentifier: rdsInstanceId }),
      },
      description: 'Stop RDS instance',
      name: 'rds-stop-schedule',
    });
  }
}

EventBridge SchedulerがEC2やRDSを操作できるように、IAMロールを作成しています。
ロールには以下のポリシーをアタッチしています。

  • EC2
    • StartInstances
    • StopInstances
  • RDS
    • StartDBInstance
    • StopDBInstance

最後に、CfnScheduleでそれぞれのスケジュールを作成します。Cron式やタイムゾーン、ターゲットとなるAWSリソースなどを定義することで、指定の時刻に自動起動・自動停止が実行されるようになります。
scheduleExpressionTimezone がデフォルトではUTCなので、日本時間(JST)に合わせる場合は Asia/Tokyo を明示的に指定する必要があります。
上記のコードでは、月曜〜金曜の日本時間8時に起動し、20時に停止するスケジュールを設定しています。

ハマりやすいポイント

EC2を停止させる際、 target.arnにAPI名のStopInstances をそのまま指定するとデプロイ時にエラーが発生します。
AWS SDKでは、小文字始まりのstopInstances が正しい形式となります。

RDSの場合も同様にstartDBInstance,stopDBInstanceが正しいため、SchedulerのターゲットARNを設定する際はSDKの仕様に合わせる必要があることに注意してください。

デプロイ

bootstrap

AWS CDK を使って初めてデプロイする場合、スタックをデプロイするために必要なS3バケットやIAMロールなどのリソースをあらかじめ作成する必要があります。
以下のコマンドで、対象のアカウントIDとリージョンを指定して実行します。

$ cdk bootstrap aws://<account_id>/<region>

deploy

CDKスタックをAWS上にデプロイします。
プロジェクトのフォルダ直下(cdk-sample)で以下を実行してください。

$ cdk deploy

このコマンドにより、CDKが自動的にCloudFormationテンプレートを生成し、指定したAWSリソース(IAMロール、EventBridge Schedulerのスケジュールグループおよびスケジュール)が作成されます。

  • 複数のプロファイルを使い分けたい場合は、--profile オプションを付与してください。
$ cdk deploy --profile <profile_name>

動作確認

マネジメントコンソールでEventBridge Schedulerを確認すると、スケジュールが正常に作成されています。

スケジュール作成後、CloudTrailのイベント履歴で設定した時間にインスタンスの起動と停止が発生していれば成功です。

最後に

今回はAWS CDKを利用してEventBridge SchedulerからEC2とRDSを自動起動・停止する方法を紹介しました。
本記事がお役に立てれば幸いです。
最後まで読んでいただきありがとうございました。