EventBridge スケジューラーを使って夜間休日は開発環境にもお休みしてもらおう

AWSのコスト削減は程度の差はあれど利用者全員がそれなりに気にしていることだと思います。
私の開発するプロダクトでも、直近の構成変更に伴って新旧環境を一定期間並列稼動させたことで一時的にコストが増えたため、重い腰を上げてコスト削減活動に取り組みました。
スポットインスタンスの導入や、アプリケーションのチューニングとそれに伴うインスタンスサイズの見直しなど、様々取り組みましたがこれらができるかどうかはアプリケーションやプロダクトの状態に強く影響を受けます。
そこで今回は導入しやすいコスト削減について紹介しようと思います。

## 休日夜間は開発環境を止めよう
本番環境はサービスによりけりですが、だいたいが24時間365日稼動し、かつ利用され続けていると思います。

開発環境はどうでしょうか。
人間は24時間365日働けないし、法律もいまのところそれを許していないので、稼動しているけど誰にも使われていない時間はそれなりにあると思います。
その時間を停止することができれば、会社にもよりますが1/3～1/2程度のコスト削減が見込めるのではないでしょうか。

## マネージドで止めよう
コスト削減には金銭・運用コストをかけたくないのが本音なので、停止・起動用のサーバを立てたりはしたくないですし、そういったコードを書いたりもなるべく避けたいところです。
AWSならこのようなユースケースに最適な[Amazon EventBridge スケジューラー](https://aws.amazon.com/jp/eventbridge/scheduler/)があるので、今回はこれを採用します。
EventBridgeスケジューラーはざっくり言うと、AWSのサービスAPIに対して、指定のタイミングでリクエストを実行できるサービスです。

タイミングはみなさんご存知のcron式のほか、ざっくりでよければn分/時間/日おきに、という設定もできます。

EventBridgeから使えるAPIもかなり網羅されていて、これも使えるの？ といったAPIも用意されています。
例えば、Amazon ECSのCreateCluster APIが叩けるようになっているので、1時間おきにクラスターを作成する、なんてことも可能です。

![4332ffec-e896-423b-b7a4-960253bbb60e.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327382/acf3a626-63af-2696-8fa0-9e3bbacbbe32.png)


また、[コストについても大きめの無料枠が用意されています](https://aws.amazon.com/jp/eventbridge/pricing/)。

>月間無料起動回数 1400 万回後の月間スケジュール起動回数 100 万回あたり USD 1.25

とのことなので、プロダクト側の機能で多用していなければ実質無料で使えそうです。

## EventBridge スケジューラーでECSサービスに夜間休日は停止してもらおう
今回は、ECSサービスを夜間・休日に停止する、というユースケースを例に設定方法を紹介していきます。
また、例をシンプルにするために、祝日は考慮せず休日 = 土日、夜間は21:00から、という定義をおきます。

EventBridge スケジューラーの設定は、

1. 定期実行のスケジュールを決める
1. 時間になったら実行したいAPIを決める
1. APIに載せるペイロードを決める
1. 細かい設定をする

の4ステップからなります。順番に見ていきましょう。

### スケジュールを設定する
まずはスケジュールを決めていきましょう。
事前に定義したとおり、

- 休日は土日のみ
- 夜間は21:00から

としているので、停止処理を呼び出すのは、平日の夜21:00、としてよさそうです。
厳密にこの時間、と指定したいので、rate basedではなくcron式でのタイミング指定を選択します。
実際に設定してみると、以下のようになります。

![e15de921-3f69-4da6-a5e0-423b26270439.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327382/0d70a13d-cfe0-8388-4095-6b8456d7c0b9.png)


実行時間のプレビューが表示されるので、期待している時間帯にトリガーするかどうか確認しましょう。

また、この段階確認しておきたいことが2点あります。

1. タイムゾーンが正しいこと
1. Flexible time windowの設定が必要かどうか

1については、意図しないタイムゾーンになっていないかを確認しましょう。
会社の業務時間が日本時間ベースで動いているのにスケジュールの実行タイムゾーンが日本時間でないところになっていたら、業務時間にサーバを止めてしまうことになります。

2については、厳密に指定した時間に実行するかの選択になります。
たとえば、Flexible time windowを5分に設定すると、21:00 ～ 21:05の間のどこかで停止処理を呼び出すようになります。
[ドキュメント](https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule-flexible-time-windows.html)を読むと、

>Setting a flexible time window improves the reliability of your schedule by dispersing your target invocations. 

と書かれているので、同一タイミングで同じAPIを呼び出す処理が複数あったとき、スロットリングや高負荷が発生して処理されない、というのを回避するために用意されているようです。
夜間停止のユースケースではあまり考慮しなくてもいいかもしれませんが、覚えておくとどこかで使えるかもしれません。今回はOffにしてあります。

### 呼び出すAPIを決める
今回は、Amazon ECSのUpdateService APIで停止したサービスのタスク数を0にしてあげるとよさそうです。
All APIsからサービスを探し、該当のAPIを選択します。

![b50fc632-440d-4287-b9a4-b5e7a4ee83c6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327382/00309369-1122-8e53-1e08-e9174cf044cb.png)

他のサービスに対して操作したいときも、同様の手順でAPIを探します。

### ペイロードを決める
このままだと空のペイロードを送信するだけの謎のスケジュールになってしまうので、APIに合わせたペイロードを用意してあげます。
送るべきペイロードは `AWSのサービス名 API名` で検索するとドキュメントがヒットするはずなので、そこの `Request Syntax` の項目を参照します。
今回は、Amazon ECSのUpdateServiceにリクエストを送るので、以下のページを参考にペイロードを組み立てます。
https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_UpdateService.html#API_UpdateService_RequestSyntax

最低限のペイロードは、以下のような形になります。

```json
{
  "Cluster":"iketeru-application-cluster",
  "DesiredCount":0,
  "Service":"iketeru-service"
}
```

組み立てたペイロードを入力します。

![2fd49702-a39c-4beb-b426-dadaaf00d985.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/327382/a0657c70-9887-04af-8008-8784edfd8e7a.png)


### 細かい設定をする
最後に、Dead Letter Queueや暗号化などの設定をすることになります。
それぞれ必要に応じて設定するのですが、今回は一点だけ、APIリクエストを実行するためのIAMロールが必要になるので、この画面で設定します。

必要なポリシーはそれぞれ以下の通りです。

#### IAMポリシー
ECSサービスの更新に必要なポリシーを設定します。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "ecs:UpdateService",
                "ecs:DescribeServices",
                "ecs:CreateTaskSet"
            ],
            "Effect": "Allow",
            "Resource": "arn:aws:ecs:ap-northeast-1:{AWSアカウントID}:service/iketeru-application-cluster/iketeru-service",
            "Sid": "UpdateEcsService"
        }
    ]
}
```

別のAWSサービスに対してAPIリクエストをする場合は、ドキュメントを参考に必要なポリシーを設定してください。

#### 信頼関係
EventBridge SchedulerがIAMロールを利用できるように、Principal Serviceに `scheduler.amazonaws.com` を指定します。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "",
            "Effect": "Allow",
            "Principal": {
                "Service": "scheduler.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
```

### 設定完了
最後に、確認画面で全体の設定内容を確認し、スケジュールを作成したら設定は完了です。
これで平日夜間にECSサービスを停止する設定ができました。

しかし、この設定だけでは夜間にサービスが終了するのみで、朝になったら起動することができません。
これでは困るので、同じような手順で朝に停止したサービスのDesireCountを1にするスケジュールも設定するといいでしょう。

## 〆
今回は、EventBridge スケジューラーを使ってECSサービスを夜間停止する方法について紹介しました。
途中でも触れましたが、EventBridge スケジューラーは様々なAWSサービスに対してリクエストを送ることができます。
今回の手順を応用してEC2やRDSの夜間停止も行えるので、必要な方はやってみてください。

また、コスト削減は必要ですが、

- コスト削減にかけるコストを抑えること
- コストを削減したことでなにか別のことにコストにかけられるようになること

も同じくらい大事です。

たとえば、コスト削減のための仕組みが削減できるコストのn%より大きければ手間のほうが大きいのでやらない、とか、浮いた時間( = 下がった運用コスト)で別のtoilを削減するためにやるというゴールを定める、などなど……。
コスト削減は結果が見えやすく楽しいので無限にやれてしまいますが、なんのためのコスト削減なのか、削減した分でなにを伸ばすのかも考えながらコスト削減をやっていきたいですね。

おしまい。

## おまけのterraform
今回設定したスケジューラをTerraformコードに起こすと以下のような形になります。
コードで読んだほうが理解しやすい方もいると思うので、どうぞ。

また、minimal moduleな形で書いているので、以下2つを適当なディレクトリに放り込んでsourceで読み込めばそのままモジュールとして使えるはずです。

### main.tf
```main.tf
terraform {
  required_version = ">= 1.9.8"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.44.0"
    }
  }
}

data "aws_ecs_service" "target" {
  service_name = var.target_ecs_service_name
  cluster_arn  = data.aws_ecs_cluster.target.arn
}

data "aws_ecs_cluster" "target" {
  cluster_name  = var.target_ecs_cluster_name
}

resource "aws_iam_role" "scheduler_role" {
  name = "${var.target_ecs_service_name}-scheduler-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Sid    = ""
        Effect = "Allow"
        Principal = {
          Service = "scheduler.amazonaws.com"
        }
        Action = "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_role_policy" "update_ecs_service" {
  name = "updateEcsService"
  role = aws_iam_role.scheduler_role.id

  policy = data.aws_iam_policy_document.update_ecs_service.json
}

data "aws_iam_policy_document" "update_ecs_service" {
  statement {
    sid = "UpdateEcsService"
    actions = [
      "ecs:DescribeServices",
      "ecs:UpdateService",
      "ecs:CreateTaskSet",
    ]
    resources = [
      data.aws_ecs_service.target.arn
    ]
  }
}

resource "aws_scheduler_schedule" "start" {
  name = "${var.target_ecs_service_name}-start"

  flexible_time_window {
    mode = "OFF"
  }

  schedule_expression = var.start_cron_expression
  schedule_expression_timezone = var.scheduler_timezone

  target {
    arn      = "arn:aws:scheduler:::aws-sdk:ecs:updateService"
    role_arn = aws_iam_role.scheduler_role.arn

    input = jsonencode({
      Cluster      = "${data.aws_ecs_cluster.target.cluster_name}"
      Service      = "${data.aws_ecs_service.target.service_name}"
      DesiredCount = 1
    })
  }
}

resource "aws_scheduler_schedule" "stop" {
  name = "${var.target_ecs_service_name}-stop"

  flexible_time_window {
    mode = "OFF"
  }

  schedule_expression = var.stop_cron_expression
  schedule_expression_timezone = var.scheduler_timezone

  target {
    arn      = "arn:aws:scheduler:::aws-sdk:ecs:updateService"
    role_arn = aws_iam_role.scheduler_role.arn

    input = jsonencode({
      Cluster      = "${data.aws_ecs_cluster.target.cluster_name}"
      Service      = "${data.aws_ecs_service.target.service_name}"
      DesiredCount = 0
    })
  }
}
```

### variables.tf
```variables.tf
variable "target_ecs_cluster_name" {
  type        = string
  description = <<EOT
    停止対象のECSサービスが稼動しているクラスターの名前
  EOT
}

variable "target_ecs_service_name" {
  type        = string
  description = <<EOT
    停止対象のECSサービスの名前
  EOT
}

variable "start_cron_expression" {
  type        = string
  default     = "cron(0 8 ? * MON-FRI *)"
  description = <<EOT
    開始タイミングのcron式
  EOT
}

variable "stop_cron_expression" {
  type        = string
  default     = "cron(0 21 ? * MON-FRI *)"
  description = <<EOT
    停止タイミングのcron式
  EOT
}

variable "scheduler_timezone" {
  type        = string
  default     = "Asia/Tokyo"
  description = <<EOT
    スケジューラーの実行タイムゾーン
  EOT
}
```