AWSリソースの定期起動・停止をCDKの EventBridge Scheduler L1 Construct で実装する


## はじめに
EventBridge Scheduler 、設定が簡単で便利ですよね。
AWSの各種リソースを定期的に起動・停止するには、今まではLambda作ったり、System Manager 参照して そこから EventBridge のルールを作っていました。
EventBridge Scheduler は、実行する操作を EventBridge 内で指定できるので、作成とメンテナンスが楽です。

今回は、そんな EventBridge Scheduler を CDK で設定してみました。

## EventBridge Scheduler は L1 Construct しかない
と思ってこの記事を書き始めたのですが、なんと[もうすぐできそう](https://github.com/aws/aws-cdk/issues/23394)じゃないですか。

先週末にL1で実装して面倒だなーと思っていたので、待ち遠しいです。

## ハマったところ
さて、今回ハマったポイントは各種リソースの操作の指定のしかたでした。

[この記事](https://serverlessland.com/patterns/eventbridge-schedule-to-ec2-cdk)と EventBridge Scheduler の画面とにらめっこして、以下のように指定すれば良さそうということで試したら、無事動きました。(後で見たら公式に[ユニバーサルターゲットの指定の仕方](https://docs.aws.amazon.com/ja_jp/scheduler/latest/UserGuide/managing-targets-universal.html)が書いてありました。)

`arn:aws:scheduler:::aws-sdk:${service}:${EventBridge Scheduler で指定する操作の名前}`

`aws-sdk:` 以降はPolicyで指定している値と同じですね。(ユニバーサルターゲットを指しています。)

ユニバーサルターゲットがわからんという場合は、
以下の様にEventBridge Scheduler の画面を見ると良いかもしれません。
(他に良い調べ方があればご指摘お願いします。)

![EventBridgeSchedulerTarget.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/877519/08c13089-9246-c592-4a83-c554185ae831.png)


ただ、若干の例外もあるようです。
RDS の StartDBCluster, StopDBCluster は、Policy で指定する値では `rds:StartDBCluster` だったのですが、 EventBridge Scheduler の target.arn では `rds:startDBCluster` (Start の `s` が小文字) みたいに設定しないとダメでした。(ここにハマりました！！)



## 実装
以下は RDS Cluster / EC2 / ECS の起動・停止をスケジュールする EventBridge Scheduler を 作る Construct のサンプルです。

作りとしては以下の様になっています。
- 複数のリソースで一つのサービスなので、Scheduler Group を使って Scheduler をまとめた
- 起動・停止対象の各リソースは今回作った Construct の外側で定義している
- 各スケジューラーの Construct は リソースを受け取って、その中で各種ターゲットを指定する
- 1 Construct で 1 サービスの起動・停止用スケジューラー(つまり 2 つのスケジューラー)を定義している
- 午前9時に起動、午後10時に停止(RDS は AP の起動停止と少しずらしている)

ライブラリのバージョンは以下の通りです。
- aws-cdk: "2.69.0"
- aws-cdk-lib : "2.69.0"
- aws-sdk: "2.1319.0"
- constructs: "10.1.244"

### Scheduler Group の Construct
```typescript:schedulerGroup.ts
import { CfnScheduleGroup } from 'aws-cdk-lib/aws-scheduler';
import { Construct } from 'constructs';

export class SchedulerGroup extends Construct {
  public readonly group: CfnScheduleGroup;
  constructor(scope: Construct, id: string) {
    super(scope, id);
    this.group = new CfnScheduleGroup(this, 'ScheduleGroup', {
      name: 'Schedulers',
    });
  }
}
```

### RDS 用 Scheduler の Construct
```typescript:rdsScheduler.ts
import { Construct } from 'constructs';
import { CfnSchedule, type CfnScheduleGroup } from 'aws-cdk-lib/aws-scheduler';
import {
  Effect,
  Policy,
  PolicyStatement,
  Role,
  ServicePrincipal,
} from 'aws-cdk-lib/aws-iam';
import { Queue } from 'aws-cdk-lib/aws-sqs';
import { ACCOUNT_ID, REGION } from '../../env';
import { type DatabaseCluster } from 'aws-cdk-lib/aws-rds';

interface RdsSchedulerProps {
  schedulerGroup: CfnScheduleGroup;
  rdsCluster: DatabaseCluster;
}

export class RdsScheduler extends Construct {
  constructor(
    scope: Construct,
    id: string,
    { schedulerGroup, rdsCluster }: RdsSchedulerProps
  ) {
    super(scope, id);

    const schedulerRole = new Role(this, 'SchedulerRoleForRDS', {
      assumedBy: new ServicePrincipal('scheduler.amazonaws.com'),
      description: 'Role for starting and stopping RDS Cluster',
    });
    new Policy(this, 'SchedulerPolicyForRDS', {
      policyName: 'RDSStartStop',
      roles: [schedulerRole],
      statements: [
        new PolicyStatement({
          effect: Effect.ALLOW,
          actions: ['rds:StartDBCluster', 'rds:StopDBCluster'],
          resources: [
            `arn:aws:rds:${REGION}:${ACCOUNT_ID}:cluster:${rdsCluster.clusterResourceIdentifier}`,
          ],
        }),
      ],
    });

    const dlq = new Queue(this, 'queue', {
      queueName: 'RdsManagerDlq',
    });

    new CfnSchedule(this, 'RdsStart', {
      description: 'Start RDS Cluster',
      groupName: schedulerGroup.name,
      flexibleTimeWindow: {
        mode: 'OFF',
      },
      scheduleExpressionTimezone: 'Asia/Tokyo',
      scheduleExpression: 'cron(45 08 ? * MON-FRI *)',
      target: {
        deadLetterConfig: { arn: dlq.queueArn },
        arn: 'arn:aws:scheduler:::aws-sdk:rds:startDBCluster',
        roleArn: schedulerRole.roleArn,
        input: JSON.stringify({
          DbClusterIdentifier: rdsCluster.clusterIdentifier,
        }),
      },
    });

    new CfnSchedule(this, 'RdsStop', {
      description: 'Stopp RDS Cluster',
      groupName: schedulerGroup.name,
      flexibleTimeWindow: {
        mode: 'OFF',
      },
      scheduleExpressionTimezone: 'Asia/Tokyo',
      scheduleExpression: 'cron(15 22 ? * MON-FRI *)',
      target: {
        deadLetterConfig: { arn: dlq.queueArn },
        arn: 'arn:aws:scheduler:::aws-sdk:rds:stopDBCluster',
        roleArn: schedulerRole.roleArn,
        input: JSON.stringify({
          DbClusterIdentifier: rdsCluster.clusterIdentifier,
        }),
      },
    });
  }
}
```

### EC2 用 Scheduler の Construct
```typescript:ec2Scheduler.ts
import { Construct } from 'constructs';
import { CfnSchedule, type CfnScheduleGroup } from 'aws-cdk-lib/aws-scheduler';
import {
  Effect,
  Policy,
  PolicyStatement,
  Role,
  ServicePrincipal,
} from 'aws-cdk-lib/aws-iam';
import { Queue } from 'aws-cdk-lib/aws-sqs';
import { type Instance } from 'aws-cdk-lib/aws-ec2';
import { ACCOUNT_ID, REGION } from '../../env';

interface Ec2SchedulerProps {
  schedulerGroup: CfnScheduleGroup;
  ec2Instance: Instance;
}
export class Ec2Scheduler extends Construct {
  constructor(
    scope: Construct,
    id: string,
    { schedulerGroup, ec2Instance }: Ec2SchedulerProps
  ) {
    super(scope, id);

    const schedulerRole = new Role(this, 'SchedulerRole', {
      assumedBy: new ServicePrincipal('scheduler.amazonaws.com'),
      description: 'Role for starting and stopping EC2',
    });
    new Policy(this, 'SchedulerPolicy', {
      policyName: 'EC2StartStop',
      roles: [schedulerRole],
      statements: [
        new PolicyStatement({
          effect: Effect.ALLOW,
          actions: ['ec2:startInstances', 'ec2:stopInstances'],
          resources: [
            `arn:aws:ec2:${REGION}:${ACCOUNT_ID}:instance/${ec2Instance.instance.ref}`,
          ],
        }),
      ],
    });

    const dlq = new Queue(this, 'queue', {
      queueName: 'Ec2ManagerDlq',
    });

    new CfnSchedule(this, 'Ec2Start', {
      description: 'Start EC2 Instance',
      groupName: schedulerGroup.name,
      flexibleTimeWindow: {
        mode: 'OFF',
      },
      scheduleExpressionTimezone: 'Asia/Tokyo',
      scheduleExpression: 'cron(00 09 ? * MON-FRI *)',
      target: {
        deadLetterConfig: { arn: dlq.queueArn },
        arn: 'arn:aws:scheduler:::aws-sdk:ec2:startInstances',
        roleArn: schedulerRole.roleArn,
        input: JSON.stringify({ InstanceIds: [ec2Instance.instanceId] }),
      },
    });

    new CfnSchedule(this, 'Ec2Stop', {
      description: 'Stop EC2 Instance',
      groupName: schedulerGroup.name,
      flexibleTimeWindow: {
        mode: 'OFF',
      },
      scheduleExpressionTimezone: 'Asia/Tokyo',
      scheduleExpression: 'cron(00 22 ? * MON-FRI *)',
      target: {
        deadLetterConfig: { arn: dlq.queueArn },
        arn: 'arn:aws:scheduler:::aws-sdk:ec2:stopInstances',
        roleArn: schedulerRole.roleArn,
        input: JSON.stringify({ InstanceIds: [ec2Instance.instanceId] }),
      },
    });
  }
}
```

### ECS 用 Scheduler の Construct
```typescript:ecsScheduler.ts
import { Construct } from 'constructs';
import { CfnSchedule, type CfnScheduleGroup } from 'aws-cdk-lib/aws-scheduler';
import {
  Effect,
  Policy,
  PolicyStatement,
  Role,
  ServicePrincipal,
} from 'aws-cdk-lib/aws-iam';
import { Queue } from 'aws-cdk-lib/aws-sqs';
import { type FargateService } from 'aws-cdk-lib/aws-ecs';

interface EcsSchedulerProps {
  schedulerGroup: CfnScheduleGroup;
  ecsService: FargateService;
}
export class EcsScheduler extends Construct {
  constructor(
    scope: Construct,
    id: string,
    { schedulerGroup, ecsService }: EcsSchedulerProps
  ) {
    super(scope, id);

    const schedulerRole = new Role(this, 'SchedulerRole', {
      assumedBy: new ServicePrincipal('scheduler.amazonaws.com'),
      description: 'Role for starting and stopping ECS',
    });
    new Policy(this, 'SchedulerPolicy', {
      policyName: 'ECSStartStop',
      roles: [schedulerRole],
      statements: [
        new PolicyStatement({
          effect: Effect.ALLOW,
          actions: ['ecs:UpdateService'],
          resources: [ecsService.serviceArn],
        }),
      ],
    });

    const dlq = new Queue(this, 'queue', {
      queueName: 'EcsManagerDlq',
    });

    new CfnSchedule(this, 'EcsStart', {
      description: 'Start ECS',
      groupName: schedulerGroup.name,
      flexibleTimeWindow: {
        mode: 'OFF',
      },
      scheduleExpressionTimezone: 'Asia/Tokyo',
      scheduleExpression: 'cron(00 09 ? * MON-FRI *)',
      target: {
        deadLetterConfig: { arn: dlq.queueArn },
        arn: 'arn:aws:scheduler:::aws-sdk:ecs:updateService',
        roleArn: schedulerRole.roleArn,
        input: JSON.stringify({
          Service: ecsService.serviceName,
          Cluster: ecsService.cluster.clusterName,
          DesiredCount: 1,
        }),
      },
    });

    new CfnSchedule(this, 'EcsStop', {
      description: 'Stop ECS',
      groupName: schedulerGroup.name,
      flexibleTimeWindow: {
        mode: 'OFF',
      },
      scheduleExpressionTimezone: 'Asia/Tokyo',
      scheduleExpression: 'cron(00 22 ? * MON-FRI *)',
      target: {
        deadLetterConfig: { arn: dlq.queueArn },
        arn: 'arn:aws:scheduler:::aws-sdk:ecs:updateService',
        roleArn: schedulerRole.roleArn,
        input: JSON.stringify({
          Service: ecsService.serviceName,
          Cluster: ecsService.cluster.clusterName,
          DesiredCount: 0,
        }),
      },
    });
  }
}

```

### 上記全てをまとめたアプリ用 Construct
```typescript:appScheduler.ts
import { Construct } from 'constructs';
import { SchedulerGroup } from './schedulerGroup';
import { Ec2Scheduler } from './ec2Scheduler';
import { type Instance } from 'aws-cdk-lib/aws-ec2';
import { type DatabaseCluster } from 'aws-cdk-lib/aws-rds';
import { RdsScheduler } from './RdsScheduler';
import { EcsScheduler } from './ecsScheduler';
import { type FargateService } from 'aws-cdk-lib/aws-ecs';

interface AppSchedulersProps {
  ec2Instance: Instance;
  ecsService: FargateService;
  rdsCluster: DatabaseCluster;
}

export class AppSchedulers extends Construct {
  constructor(
    scope: Construct,
    id: string,
    { ec2Instance, ecsService, rdsCluster }: AppSchedulersProps
  ) {
    super(scope, id);
    const { group } = new AppSchedulerGroup(this, 'Group');
    new Ec2Scheduler(this, 'Ec2Scheduler', {
      schedulerGroup: group,
      ec2Instance,
    });
    new RdsScheduler(this, 'RdsScheduler', {
      schedulerGroup: group,
      rdsCluster,
    });
    new EcsScheduler(this, 'EcsScheduler', {
      schedulerGroup: group,
      ecsService,
    });
  }
}
```

## 最後に
CDK で書けるのは良いですが、やはり L1 Construct だと何を指定してよいか分かりづらいし、
記述するボリュームも多くなりがちだなぁと思いました。

もうすぐ L2 Construct がリリースされるので楽しみにしています。

最後まで読んでくださった方、ありがとうございました。