EC2のライフサイクルを制御する

# はじめに
私用のメモ用にWikiアプリGrowiをEC2で運用している。基本起動時のみしか使わないので、アクセスしない状態ではEC2を停止した状態としておきたい。EC2は24年2月からパブリックIPv4アドレスを使っているだけで課金されるようになってしまった。わざわざ毎回マネコンに行って操作するのも面倒くさい。EC2インスタンスのライフサイクル管理をどうにかしたい。

## 基本構成
今回使うGrowiは、EC2の前段にCloudFrontを置いてhttps化とDNS紐づけをして運用している。これをStepFunctionsで上手く管理する。最後にGrowiをUpdateした日時をGrowiAPIで取得し、最後の更新から一定時間経っていたらEC2を自動で立ち下げる構成とする。ユーザーからは同じWebページにアクセスし、EC2が起動していたらGrowiに到達、EC2が起動していなければ起動するようStepFunctionsをトリガさせる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54100/e6be714c-6010-71ef-f4a1-7f756dcb7379.png)


## StepFunctionsによるEC2の状態遷移制御
### 工夫したところ
色々と工夫したところを箇条書きでまとめる。
- EC2が最終更新から終了までの時間は600秒とした。StepFunctionsのWaitの値を変えるだけなので非常に管理がしやすい。
- Growiが起動したらSNSで起動したよとメッセージを投げるようにしている。これは万人に必要ではないかもしれない。
- `check_GrowiAPI`のLambdaの部分は、アプリの`status_code`と、最終更新時間`timeDiffInSecond`の2つを戻り値にしており、アプリの起動を確認する箇所、最終更新からの時間を判定する箇所の2か所で再利用できるようにした。今回はGrowiのAPIをそのまま使ってEC2インスタンスの死活と最終更新確認（Heartbeat管理）の両方をさせているが、どんなアプリケーションであろうと何らかのステータスとHeartbeat的な信号は取れるはずなので、このステートマシンがそのまま流用できる気がする。
- ステートマシンが同時に複数起動されても問題ないように頭で`runnning`と`pending`を判定させるChoiceを入れ、ElasticIPを割り当てたりする起動処理が何度も実行されることを防いでいる。
- このステートマシンでは状態遷移を待つロジックを作るために、3つループさせる箇所を用意した。
    - EC2がstoppingのような中途半端な状態の時、次の状態まで遷移するのを待つループ
    - EC2が起動してAPIリクエストが通るようになるまで待つループ
    - EC2起動してから停止するまでAPIで監視しながら最終更新から時間経過を待つループ  

### 作ったもの
EC2の起動処理や終了処理は、各々の環境でやりたいことがあまり共通しないと思われるので今回は省略する。起動時には主にElasticIPをEC2に関連付けてCloudfrontやRoute53の設定を変えるといったことをしており、終了時にはその逆の処理を主にやっている。
#### ワークフロー(ビジュアル)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54100/ff19ace2-2e1e-c556-3ffa-003096d9f797.png)

#### ワークフロー（Json）
```json
{
  "Comment": "A description of my state machine",
  "StartAt": "DescribeInstanceStatus (1)",
  "States": {
    "DescribeInstanceStatus (1)": {
      "Type": "Task",
      "Next": "Choice (3)",
      "Parameters": {
        "InstanceIds": [
          "i-XXXXXXXXXXX"
        ],
        "IncludeAllInstances": "True"
      },
      "Resource": "arn:aws:states:::aws-sdk:ec2:describeInstanceStatus"
    },
    "Choice (3)": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.InstanceStatuses[0].InstanceState.Name",
          "StringEquals": "stopped",
          "Next": "strartInstance"
        }
      ],
      "Default": "Choice (4)"
    },
    "Choice (4)": {
      "Type": "Choice",
      "Choices": [
        {
          "Or": [
            {
              "Variable": "$.InstanceStatuses[0].InstanceState.Name",
              "StringEquals": "pending"
            },
            {
              "Variable": "$.InstanceStatuses[0].InstanceState.Name",
              "StringEquals": "running"
            }
          ],
          "Next": "checkGrowiAPI"
        }
      ],
      "Default": "Wait_pending",
      "Comment": "カウンタか、そもそも起動中だったら終わる\nstartCheckCounter"
    },
    "Wait_pending": {
      "Type": "Wait",
      "Seconds": 60,
      "Next": "DescribeInstanceStatus (1)",
      "Comment": "startし始めてたらやめる"
    },
    "strartInstance": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "Payload.$": "$",
        "FunctionName": "arn:aws:lambda:ap-northeast-1:{$AWSid}:function:EC2Start:$LATEST"
      },
      "Retry": [
        {
          "ErrorEquals": [
            "Lambda.ServiceException",
            "Lambda.AWSLambdaException",
            "Lambda.SdkClientException",
            "Lambda.TooManyRequestsException"
          ],
          "IntervalSeconds": 2,
          "MaxAttempts": 6,
          "BackoffRate": 2
        }
      ],
      "Next": "check_GrowiAPI",
      "Comment": "startInstance"
    },
    "check_GrowiAPI": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "OutputPath": "$.Payload",
      "Parameters": {
        "Payload.$": "$",
        "FunctionName": "checkGrowiStatus"
      },
      "Retry": [
        {
          "ErrorEquals": [
            "Lambda.ServiceException",
            "Lambda.AWSLambdaException",
            "Lambda.SdkClientException",
            "Lambda.TooManyRequestsException"
          ],
          "IntervalSeconds": 1,
          "MaxAttempts": 3,
          "BackoffRate": 2
        }
      ],
      "Next": "Choice"
    },
    "Choice": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.status_code",
          "NumericEquals": 200,
          "Next": "statusUpdate"
        }
      ],
      "Default": "Wait"
    },
    "statusUpdate": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "OutputPath": "$.Payload",
      "Parameters": {
        "Payload.$": "$",
        "FunctionName": "arn:aws:lambda:ap-northeast-1:{$AWSid}:function:ec2StatusUpdate:$LATEST"
      },
      "Retry": [
        {
          "ErrorEquals": [
            "Lambda.ServiceException",
            "Lambda.AWSLambdaException",
            "Lambda.SdkClientException",
            "Lambda.TooManyRequestsException"
          ],
          "IntervalSeconds": 1,
          "MaxAttempts": 3,
          "BackoffRate": 2
        }
      ],
      "Next": "SNS Publish"
    },
    "SNS Publish": {
      "Type": "Task",
      "Resource": "arn:aws:states:::sns:publish",
      "Parameters": {
        "TopicArn": "arn:aws:sns:ap-northeast-1:{$AWSid}:myLineNotifyTopic",
        "Message": {
            "Growiにアクセスできるようになりました。 https://xxxx.com"
        }
      },
      "Next": "checkGrowiAPI"
    },
    "checkGrowiAPI": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "checkGrowiStatus",
        "Payload.$": "$"
      },
      "Retry": [
        {
          "ErrorEquals": [
            "Lambda.ServiceException",
            "Lambda.AWSLambdaException",
            "Lambda.SdkClientException",
            "Lambda.TooManyRequestsException"
          ],
          "IntervalSeconds": 1,
          "MaxAttempts": 3,
          "BackoffRate": 2
        }
      ],
      "Next": "Choice (1)",
      "Comment": "checkSecondsFromLastHeartBeats",
      "OutputPath": "$.Payload"
    },
    "Choice (1)": {
      "Type": "Choice",
      "Choices": [
        {
          "Or": [
            {
              "Variable": "$.timeDiffInSecond",
              "NumericGreaterThan": 600
            },
            {
              "Not": {
                "Variable": "$.status_code",
                "NumericEquals": 200
              }
            }
          ],
          "Next": "EC2Stop"
        }
      ],
      "Default": "Wait (2)"
    },
    "Wait (2)": {
      "Type": "Wait",
      "Seconds": 60,
      "Next": "checkGrowiAPI",
      "Comment": "checkSecondsFromLastUpdated"
    },
    "EC2Stop": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "OutputPath": "$.Payload",
      "Parameters": {
        "Payload.$": "$",
        "FunctionName": "arn:aws:lambda:ap-northeast-1:{$AWSid}:function:EC2Stop:$LATEST"
      },
      "Retry": [
        {
          "ErrorEquals": [
            "Lambda.ServiceException",
            "Lambda.AWSLambdaException",
            "Lambda.SdkClientException",
            "Lambda.TooManyRequestsException"
          ],
          "IntervalSeconds": 1,
          "MaxAttempts": 3,
          "BackoffRate": 2
        }
      ],
      "End": true
    },
    "Wait": {
      "Type": "Wait",
      "Seconds": 60,
      "Next": "check_GrowiAPI"
    }
  }
}
```

## 最後に
とにかくEC2の管理が楽になったので良かった。状態遷移制御、StepFunctionsで簡単に作れるのは大変良い。ビジュアルエディタでワークフローを描けるので、作る時もあとから見た時も非常にわかりやすい。各APIへの入出力をどう書いていいか詰まるところがあり、boto3で書いた方が早いわとなった。この辺りは人によって好き嫌い出るところなのかもしれない（まだ慣れていないだけか？中々ドキュメントにリーチできなかった･･･）。制御対象が1つの場合であっても、同時に複数のステートマシンが起動される可能性を考慮する必要があるところは、組み込み制御とは違うユースケースだなと思った。