AWS EC2インスタンスを全台停止させる用Lambda(Terraformコード付き)

# はじめに
個人の環境やお客様の検証環境で、EC2の停止し忘れによる不要な課金を避ける目的で、
自動停止の仕組みを導入することは多いと思います。
停止に際して複雑な処理を伴わず(特定のミドルウェアの停止操作とか)、サーバの外部からEC2の自動停止を行う際は以下の3パターンが考えられます。

1. EventBridge スケジュールで指定時刻にStopInstancesAPIを実行する
2. EventBridge ルールで指定時刻にSSM AutomationからSSM Documentsの"AWS-StopEC2Instance"を実行する
3. EventBridge スケジュールで指定時刻にEC2停止用のLambdaを実行する

パターン1と2についてはLambdaと違いコードの記載・修正が不要でシンプルに設定できます。
しかし、ターゲットとしてインスタンスIDの指定が必要であるため、
頻繫に対象が増減する環境では都度ターゲットのメンテナンスが必要になってきます。

今回、個人環境の停止忘れを防止を目的に、ターゲットの指定不要でとにかくEC2全台を指定時刻で停止させるという想定でパターン3を実装していきます。

## Lambda
### 処理の流れ
1. EC2全台のインスタンスIDと状態(State)の取得
1. 起動状態(State = running)となっているインスタンスIDを抽出
1. 抽出した起動状態のインスタンスに対して停止コマンドの発行

### Lambda用IAMポリシー
EC2インスタンス情報の出力と停止権限だけあればよいため権限としては"ec2:DescribeInstances"と"ec2:StopInstances"だけです。
これに加えてマネージドポリシー"AWSLambdaBasicExecutionRole"を付与します。

```json:policy_lambda_stopec2.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeInstances",
                "ec2:StopInstances"
            ],
            "Resource": "*"
        }
    ]
}
```

### Lambdaコード(Python)
- ランタイム: Python 3.12
- タイムアウト: 5分

```python:lambda_stopec2.py
import json
import boto3

def lambda_handler(event, context):
    ec2 = boto3.client("ec2")
    response = ec2.describe_instances()
    running_instances = []

    # インスタンスステータス確認、ID取得
    for reservation in response["Reservations"]:
        for instance in reservation["Instances"]:
            instance_id = instance["InstanceId"]
            instance_state = instance["State"]["Name"]

        # 停止対象リスト作成
        if instance_state == "running":
            running_instances.append(instance_id)

    # インスタンス停止
    if running_instances:
        stop_response = ec2.stop_instances(InstanceIds=running_instances)
        stopped_instances = stop_response["StoppingInstances"]
        
        for instance in stopped_instances:
            print(f"[INFO]インスタンスを停止しました ID: {instance["InstanceId"]}")
    else:
        print("[INFO]起動状態のインスタンスはありません。処理を終了します。")
```

タイムアウト値はデフォルトの3秒だとタイムアウトするため、長めに5分としています。

## EventBridge Scheduler
cron式の定期実行で上述のLambdaを呼び出します。設定上詰まるポイントはないはずなので割愛します。
Lambdaに渡すペイロードはありません。EventBridge用IAMロールには以下のポリシーを付与しておきます。

```json:policy_scheduler_stopec2.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "lambda:InvokeFunction"
            ],
            "Resource": "*"
        }
    ]
}
```
---
記事の主題であるEC2全台停止用Lambdaについては以上です。
以降はこれらLambdaとEventBridge Schedulerを設定するTerraformコードを記載します。

## おまけ:Terraform
個人環境の構築ではTerraformを使っているため、おまけ程度(詳細な説明なし)に載せます。
以下のディレクトリ構成とします。
```
.
├─00_modules
│  └─eventbridge_stopec2
│          assume_role_lambda.json
│          assume_role_scheduler.json
│          lambda_stopec2.py
│          main.tf
│          policy_lambda_stopec2.json
│          policy_scheduler_stopec2.json
│          variables.tf
│
└─01_env
    └─01_dev
        └─eventbridge_stopec2
               backend.tf
               main.tf
               providers.tf
               terraform.tfvars
               variables.tf

```

### module(00_modules\eventbridge_stopec2配下)
- policy_lambda_stopec2.json → 前述した内容のLambda用IAMポリシー
- policy_scheduler_stopec2.json → 前述した内容のEventBridge用IAMポリシー
- lambda_stopec2.py → 前述した内容のPythonファイル

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

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


```tf:variables.tf
variable "env" {}
variable "pjcode" {}
variable "stopec2_schedule" {}
```

```tf:main.tf
# =====IAM Role=====
# lambda
resource "aws_iam_role" "role_lambda" {
  name               = "role-${var.pjcode}-${var.env}-lambda-stopec2"
  path               = "/"
  assume_role_policy = file("${path.module}/assume_role_lambda.json")

  tags = {
    "Name" = "role-${var.pjcode}-${var.env}-lambda-stopec2"
  }
}

resource "aws_iam_policy" "policy_lambda" {
  name   = "policy-${var.pjcode}-${var.env}-lambda-stopec2"
  policy = file("${path.module}/policy_lambda_stopec2.json")

  tags = {
    "Name" = "policy-${var.pjcode}-${var.env}-lambda-stopec2"
  }
}

resource "aws_iam_role_policy_attachment" "attach_lambda_role_1" {
  role       = aws_iam_role.role_lambda.name
  policy_arn = aws_iam_policy.policy_lambda.arn
}

data "aws_iam_policy" "policy_managed_managed_basicexcution" {
  arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy_attachment" "attach_lambda_role_2" {
  role       = aws_iam_role.role_lambda.name
  policy_arn = data.aws_iam_policy.policy_managed_managed_basicexcution.arn
}

# scheduler
resource "aws_iam_role" "role_scheduler" {
  name               = "role-${var.pjcode}-${var.env}-scheduler-stopec2"
  path               = "/"
  assume_role_policy = file("${path.module}/assume_role_scheduler.json")

  tags = {
    "Name" = "role-${var.pjcode}-${var.env}-scheduler-stopec2"
  }
}

resource "aws_iam_policy" "policy_scheduler" {
  name   = "policy-${var.pjcode}-${var.env}-scheduler-stopec2"
  policy = file("${path.module}/policy_scheduler_stopec2.json")

  tags = {
    "Name" = "policy-${var.pjcode}-${var.env}-scheduler-stopec2"
  }
}

resource "aws_iam_role_policy_attachment" "attach_scheduler" {
  role       = aws_iam_role.role_scheduler.name
  policy_arn = aws_iam_policy.policy_scheduler.arn
}

# =====Lambda=====
data "archive_file" "stopec2" {
  type        = "zip"
  source_file = "${path.module}/lambda_stopec2.py"
  output_path = "${path.module}/lambda_stopec2.zip"
}

resource "aws_lambda_function" "stopec2" {
  function_name    = "lambda-${var.pjcode}-${var.env}-stopec2"
  runtime          = "python3.12"
  filename         = data.archive_file.stopec2.output_path
  source_code_hash = data.archive_file.stopec2.output_base64sha256
  handler          = "lambda_stopec2.lambda_handler"
  timeout          = 300
  role             = aws_iam_role.role_lambda.arn

  tags = {
    "Name" = "lambda-${var.pjcode}-${var.env}-stopec2"
  }
}

# =====EventBridge Scheduler=====
# Schedule Group
resource "aws_scheduler_schedule_group" "stopec2" {
  name = "group-${var.pjcode}-${var.env}-ec2stop"

  tags = {
    "Name" = "group-${var.pjcode}-${var.env}-ec2stop"
  }
}

# Schedule
resource "aws_scheduler_schedule" "stopec2" {
  name                         = "schedule-${var.pjcode}-${var.env}-ec2stop-all"
  description                  = "Stop all EC2instances"
  group_name                   = aws_scheduler_schedule_group.stopec2.name
  schedule_expression_timezone = "Asia/Tokyo"
  schedule_expression          = var.stopec2_schedule
  state                        = "ENABLED"

  target {
    arn      = aws_lambda_function.stopec2.arn
    role_arn = aws_iam_role.role_scheduler.arn

    retry_policy {
      maximum_retry_attempts = 0
    }
  }

  flexible_time_window {
    mode = "OFF"
  }
}
```

### 呼び出し側(01_env\01_dev\eventbridge_stopec2配下)
```tf:backend.tf
terraform {
  backend "s3" {
    bucket = "<tfstate用バケット>"
    key    = "eventbridge_stopec2.tfstate"
    region = "ap-northeast-1"
  }
}
```

```tf:providers.tf
terraform {
  required_version = "~> 1.9"

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

```tf:variables.tf
variable "env" {}
variable "pjcode" {}
variable "stopec2_schedule" {}
```

```tf:terraform.tfvars
env              = "dev"
pjcode           = "<お好みのPJコード>"
stopec2_schedule = "cron(00 20 * * ? *)" # 自動停止実行スケジュール 毎日20:00

```

```tf:main.tf
provider "aws" {
  region  = "ap-northeast-1"

  default_tags {
    tags = {
      Env     = var.env
    }
  }
}

module "eventbridge_stopec2" {
  source = "../../../00_modules/eventbridge_stopec2"

  env              = var.env
  pjcode           = var.pjcode
  stopec2_schedule = var.stopec2_schedule
}

```

おまけの方が実質本編な長さに！
以上。