Terraformを使ってEC2インスタンスの自動起動／自動停止環境を構築する

# はじめに
もう何番煎じかわかりませんが、EC2インスタンスの自動起動・停止を実現する環境が欲しくなったので構築します。
会社で検証に使っているAWS環境が1週間ごとに消えてしまう仕様なので、毎回手動で設定しなくてもいいように**Terraform**を使って環境構築の自動化を行いました。

# 実現したかったこと
* AWS EC2インスタンスの自動起動・停止を行い、利用料を削減する
* 平日の9:00〜21:00の間だけ稼働させる。ただし休日に作業したい際や、夜通し動かしたいといった要件に備えて柔軟にスケジュール設定できるようにしておく
* 検証環境のため、対象サーバは基本全部。インスタンスが増えてもコードや設定をいじらずに対応できるようにする。また一応特定のインスタンスをターゲットにできるようにしておく
* できる限り最小限の稼働で環境構築を行う

# 成果物と使い方
以下のGitLab.comに今回作成したコードをあげています。
「とにかく使えればいい」場合はこちらからcloneなりforkしてREADMEを参照の上お使いください。
突貫工事のため、コードが汚いですがご了承ください。

* Gitリポジトリ:https://gitlab.com/skitamur/aws-terraform
* 使い方：https://gitlab.com/skitamur/aws-terraform/-/blob/main/autostartstop_function/README.md

# 参考手順
基本は以下の公式ページのプロセスを参考にTerraformを作成しています。
https://aws.amazon.com/jp/premiumsupport/knowledge-center/start-stop-lambda-eventbridge/

# 前提条件
* AWS IAMユーザー（AdministratorAccess）
* Terrafrom v1.1.2
    * aws provider : v3.74.0
    * archive : v2.2.0

# 概要
各tfファイルの簡単な概要と解説になります。

## フォルダ・ファイル構成

```
$ tree autostartstop_function
autostartstop_function
.
├── README.md
├── cloudwatch_event.tf
├── data_lambda_src.tf
├── iam_lambda.tf
├── lambda_function.tf
├── lambda_permission.tf
├── local.tf
├── provider.tf
└── src
    ├── autostart
    │   └── autostart.py
    └── autostop
        └── autostop.py
```

## Provider
* Gitにあげる関係上、access_keyとsecret_keyは環境変数にて設定する記載にしています（設定方法はGitのREADMEを参照）
* Lambdaにコードをアップする際に該当のフォルダをzip圧縮する必要があるため、`archive provider`を使用します。

```provider.tf
provider "aws" {
  #Set the access key to the environment variable "AWS_ACCESS_KEY_ID".
  #Set the secret key to the environment variable "AWS_SECRET_ACCESS_KEY".
  region = local.region
}
provider "archive" {}
```



```local.tf(抜粋)
locals {
  region = "ap-northeast-1"
```

## IAM
* Lambda用のIAMロール、IAMポリシーを作成して`aws_iam_policy_attachment`で関連づけます。
* 参考ページとほぼ同じポリシーですが、実環境からインスタンスIDを取得するため、ポリシーに`ec2:Describe*`actionを追記しています。

```iam_lambda.tf
resource "aws_iam_role" "lambda_role" {
  name               = local.iam_role.name
  assume_role_policy = local.iam_role.assume_role_policy
}

resource "aws_iam_policy" "lambda_policy" {
  name   = local.iam_policy.name
  policy = local.iam_policy.policy
}

resource "aws_iam_policy_attachment" "lambda-iam-attach" {
  name = "lambda-iam-attachment"
  roles = [
    aws_iam_role.lambda_role.name
  ]
  policy_arn = aws_iam_policy.lambda_policy.arn
}
```

```local.tf(抜粋)
iam_role = {
    name               = "AutoStartStopRoleForLambda"
    assume_role_policy = <<EOT
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOT
  }

  iam_policy = {
    name   = "AutoStartStopPolicy"
    policy = <<EOT
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ec2:Start*",
        "ec2:Stop*",
        "ec2:Describe*"
      ],
      "Resource": "*"
    }
  ]
}
EOT
  }
```

## Lambda用Python
* 参考ページのコードをベースとしていますが、以下2点の修正を行なっています。
    * ReginをLambdaの環境変数から取得する。もし環境変数`REGION`の指定がない場合はデフォルト値`ap-northeast-1`を使う
    * 指定のRegion上にあるインスタンスIDを取得し、配列に格納。もし環境変数`INSTANCES`の指定がない場合は取得したインスタンスID全てを対象とする（詳細は後述）




```autostart.py
import boto3
import os

region = os.environ.get('REGION','ap-northeast-1')
ec2 = boto3.client('ec2', region_name=region)

ec2_data = ec2.describe_instances()
ec2_instance_id = list()
for ec2_reservation in ec2_data['Reservations']:
    for ec2_instance in ec2_reservation['Instances']:
        ec2_instance_id.append(ec2_instance['InstanceId'])

instances = os.environ.get('INSTNACES',ec2_instance_id)

def lambda_handler(event, context):
    ec2.start_instances(InstanceIds=instances)
    print('started your instances: ' + str(instances))
```

```autostop.py
import boto3
import os

region = os.environ.get('REGION','ap-northeast-1')
ec2 = boto3.client('ec2', region_name=region)

ec2_data = ec2.describe_instances()
ec2_instance_id = list()
for ec2_reservation in ec2_data['Reservations']:
    for ec2_instance in ec2_reservation['Instances']:
        ec2_instance_id.append(ec2_instance['InstanceId'])

instances = os.environ.get('INSTNACES',ec2_instance_id)

def lambda_handler(event, context):
    ec2.stop_instances(InstanceIds=instances)
    print('stopped your instances: ' + str(instances))
```

## Pythonスクリプトを圧縮
* Lambdaに作成したPythonスクリプトをアップする際はzipに圧縮する必要があるため、`archive provider`の`data.archive_file`を使って圧縮します。


```data_lambda_src.tf
# Lambda File archive to Zip
data "archive_file" "autostart_lambda_file" {
  type        = local.autostart_lambda_file.archive_file_type
  source_dir  = local.autostart_lambda_file.lambda_source_dir
  output_path = local.autostart_lambda_file.deploy_upload_filename
}

# Lambda File archive to Zip
data "archive_file" "autostop_lambda_file" {
  type        = local.autostop_lambda_file.archive_file_type
  source_dir  = local.autostop_lambda_file.lambda_source_dir
  output_path = local.autostop_lambda_file.deploy_upload_filename
}
```

```local.tf(抜粋)
  autostart_lambda_file = {
    archive_file_type      = "zip"
    lambda_source_dir      = "./src/autostart"
    deploy_upload_filename = "./src/autostart_lambda_src.zip"
  }

  autostop_lambda_file = {
    archive_file_type      = "zip"
    lambda_source_dir      = "./src/autostop"
    deploy_upload_filename = "./src/autostop_lambda_src.zip"
  }
```

## Lambda Function
* いくつかパラメータがあるので、特徴的なものを説明します。
    * `filename`：Lambdaスクリプトを指定。今回は`archive_file`で作成したzipファイルを参照
    * `role`：前述のIAMロールを指定
    * `handler`：実行するLambdaのハンドラーを指定。今回は上記のスクリプトの通り`autostart.lambda_handler`となる
    * `source_code_hash`：ソースコードのハッシュ値を参照して、もし異なる場合はソースコードの更新を実施する
* `local.tf`の`variables`でLambdaの環境変数を指定しています。ここで`INSTANCES`をコメントアウトしているので、デフォルトでは全インスタンスを対象としています。
* もしインスタンスを指定する場合はコメントアウトを解除し、記載方法を参考に修正してください。


```lambda_function.tf
resource "aws_lambda_function" "autostart_lambda" {
  filename         = data.archive_file.autostart_lambda_file.output_path
  function_name    = local.autostart_lambda_function.function_name
  role             = aws_iam_role.lambda_role.arn
  handler          = local.autostart_lambda_function.handler
  source_code_hash = data.archive_file.autostart_lambda_file.output_base64sha256
  runtime          = local.autostart_lambda_function.runtime
  timeout          = local.autostart_lambda_function.timeout

  environment {
    variables = local.autostart_lambda_function.variables
  }
}

resource "aws_lambda_function" "autostop_lambda" {
  filename         = data.archive_file.autostop_lambda_file.output_path
  function_name    = local.autostop_lambda_function.function_name
  role             = aws_iam_role.lambda_role.arn
  handler          = local.autostop_lambda_function.handler
  source_code_hash = data.archive_file.autostop_lambda_file.output_base64sha256
  runtime          = local.autostop_lambda_function.runtime
  timeout          = local.autostop_lambda_function.timeout

  environment {
    variables = local.autostop_lambda_function.variables
  }
}
```

```local.tf(抜粋)
autostart_lambda_function = {
    filename      = "autostart.py"
    function_name = "StartEC2Instances"
    runtime       = "python3.9"
    timeout       = 10
    handler       = "autostart.lambda_handler"

    variables = {
      REGION = local.region
      # If INSTANCES is not set, then all instances will be targeted.
      #INSTANCES = "[i-XXXXXXXXXXX,i-XXXXXXXXXXX]"
    }
  }

  autostop_lambda_function = {
    filename      = "autostop.py"
    function_name = "StopEC2Instances"
    runtime       = "python3.9"
    timeout       = 10
    handler       = "autostop.lambda_handler"

    variables = {
      REGION = local.region
      # If INSTANCES is not set, then all instances will be targeted.
      #INSTANCES = "[i-XXXXXXXXXXX,i-XXXXXXXXXXX]"
    }
  }
```

## EventBridge
* CloudWatch EventはEventBridgeに包含されましたが、使うresourceは`aws_cloudwatch_event_rule`や`aws_cloudwatch_event_target`になります。
* `schedule_expression`にcronを記述することで柔軟なスケジュール実行を実現できます。
    * cronはUTC（日本時間-9時間）なので、時間の記載には気をつけてください。
* `is_enabled`に`true`や`false`を設定することで、自動実行のON/OFFを切り替えられます。「停止は自動化したいけど起動は自分でやる」といった使い方にも対応可能です。


```cloudwatch_event.tf
# Auto Start Rule
resource "aws_cloudwatch_event_rule" "autostart_rule" {
  name                = local.autostart_event_rule.name
  description         = local.autostart_event_rule.description
  schedule_expression = local.autostart_event_rule.schedule_expression
  is_enabled          = local.autostart_event_rule.is_enabled
}

# Auto Start Target
resource "aws_cloudwatch_event_target" "autostart_target" {
  arn  = aws_lambda_function.autostart_lambda.arn
  rule = aws_cloudwatch_event_rule.autostart_rule.id
}

# Auto Stop Rule
resource "aws_cloudwatch_event_rule" "autostop_rule" {
  name                = local.autostop_event_rule.name
  description         = local.autostop_event_rule.description
  schedule_expression = local.autostop_event_rule.schedule_expression
  is_enabled          = local.autostop_event_rule.is_enabled
}

# Auto Stop Target
resource "aws_cloudwatch_event_target" "autostop_target" {
  arn  = aws_lambda_function.autostop_lambda.arn
  rule = aws_cloudwatch_event_rule.autostop_rule.id
}
```

```local.tf(抜粋)
autostart_event_rule = {
    name        = "StartEC2Instances"
    description = "Start EC2 Instances"

    # Run at 0:00 (UTC) every Monday through Friday
    schedule_expression = "cron(0 0 ? * MON-FRI *)"
    is_enabled          = true
  }

  autostop_event_rule = {
    name        = "StopEC2Instances"
    description = "Stop EC2 Instances"

    # Run at 12:00 (UTC) every Monday through Friday
    schedule_expression = "cron(0 12 ? * MON-FRI *)"
    is_enabled          = true
  }
```

## Lambdaのパーミッション設定
* 参考ページの手順では気にすることのない設定ですが、Terraformで実行する際は明示的に設定が必要な箇所になります。
* 詳細は以下のページに記載されています。
    * https://aws.amazon.com/jp/premiumsupport/knowledge-center/eventbridge-lambda-not-triggered/
* ここは`local.tf`で設定する必要のない箇所ですので、「ふーんこんなのが必要なんだ」くらいの認識でOKです。


```lambda_permission.tf
resource "aws_lambda_permission" "start_allow_cloudwatch" {
  statement_id  = "AllowExecutionFromCloudWatch"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.autostart_lambda.function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.autostart_rule.arn
}

resource "aws_lambda_permission" "stop_allow_cloudwatch" {
  statement_id  = "AllowExecutionFromCloudWatch"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.autostop_lambda.function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.autostop_rule.arn
}
```

## local.tf（全体）

```local.tf
locals {
  region = "ap-northeast-1"

  iam_role = {
    name               = "AutoStartStopRoleForLambda"
    assume_role_policy = <<EOT
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOT
  }

  iam_policy = {
    name   = "AutoStartStopPolicy"
    policy = <<EOT
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ec2:Start*",
        "ec2:Stop*",
        "ec2:Describe*"
      ],
      "Resource": "*"
    }
  ]
}
EOT
  }

  autostart_lambda_function = {
    filename      = "autostart.py"
    function_name = "StartEC2Instances"
    runtime       = "python3.9"
    timeout       = 10
    handler       = "autostart.lambda_handler"

    variables = {
      REGION = local.region
      # If INSTANCES is not set, then all instances will be targeted.
      #INSTANCES = "[i-XXXXXXXXXXX,i-XXXXXXXXXXX]"
    }
  }

  autostop_lambda_function = {
    filename      = "autostop.py"
    function_name = "StopEC2Instances"
    runtime       = "python3.9"
    timeout       = 10
    handler       = "autostop.lambda_handler"

    variables = {
      REGION = local.region
      # If INSTANCES is not set, then all instances will be targeted.
      #INSTANCES = "[i-XXXXXXXXXXX,i-XXXXXXXXXXX]"
    }
  }

  autostart_lambda_file = {
    archive_file_type      = "zip"
    lambda_source_dir      = "./src/autostart"
    deploy_upload_filename = "./src/autostart_lambda_src.zip"
  }

  autostop_lambda_file = {
    archive_file_type      = "zip"
    lambda_source_dir      = "./src/autostop"
    deploy_upload_filename = "./src/autostop_lambda_src.zip"
  }

  autostart_event_rule = {
    name        = "StartEC2Instances"
    description = "Start EC2 Instances"

    # Run at 0:00 (UTC) every Monday through Friday
    schedule_expression = "cron(0 0 ? * MON-FRI *)"
    is_enabled          = true
  }

  autostop_event_rule = {
    name        = "StopEC2Instances"
    description = "Stop EC2 Instances"

    # Run at 12:00 (UTC) every Monday through Friday
    schedule_expression = "cron(0 12 ? * MON-FRI *)"
    is_enabled          = true
  }
}
```

# おわりに
当初実現したいことはこのコードで概ね実現できました。
Tagで判別して云々すればもっと柔軟な設定を実現できますが、あくまで検証用ですので、これくらいでいいかなと思います。