EC2、RDS、ElastiCache、Elasticsearch のリザーブドインスタンスの有効期限をまとめてチェックしてSlackへ通知する Lambda (Python3.8版）

## 概要
* SSLサーバー証明書の更新作業は ACM で解放されたけど、RI は自動更新できないので有効期限の監視をしておかないと、いつの間にか期限が切れてオンデマンド料金を支払う事になってしまう。（現になってしまったので、これを作った・・）
ちなみに、EC2は日時指定で購入予約ができるようになったみたい。
 * [EC2 リザーブドインスタンスを自動的に更新するようにスケジュールできますか?](https://aws.amazon.com/jp/premiumsupport/knowledge-center/renew-ri/)
 * [EC2のリザーブドインスタンスが日時指定で購入予約が可能になりました](https://dev.classmethod.jp/cloud/aws/ec2-reserved-instance-on-future-date/)

* AWS Cost Explorer でも有効期限切れアラートを設定できるが、メールにしか送信できなそう。（今後、Chatbotと連携して Slack へも送信できるようになることを期待）
 * [AWS Cost Explorer で予約の有効期限切れアラートを提供開始](https://aws.amazon.com/jp/about-aws/whats-new/2019/05/reservation-expiration-alerts-now-available-in-aws-cost-explorer/)

* [EC2 だけのリザーブドインスタンスの有効期限チェックは作った](https://qiita.com/tonishy/items/f50b382c477ea53b28ce)けど、他にもリザーブドインスタンスをAWSサービスがあるので、それらをまとめてチェックできるようにした。

* CloudWatch のイベントで１日一回、有効期限監視 Lambda を実行して、有効期限の残り日数が次の THRESHOLD 日になったら Slack へ通知する。
 * 残り日数が 10日
 * 残り日数が 1日
 
* リザーブドインスタンスの状態が active になっているものをチェックし続けるため、有効期限が切れる前に更新（同じリザーブドインスタンスを新規に購入）したとしても、状態が retired になるまでチェックを続けてSlackへ送信する。


## 設定

### Lambda に付けるロールを作成する

#### ロールにアタッチするポリシー

* AWSLambdaBasicExecutionRole
 * AWS 管理ポリシー

* DescribeReservedInstances
 * 下記の内容で作成する

```
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "DescribeReservedInstances",
            "Effect": "Allow",
            "Action": [
                "es:DescribeReservedElasticsearchInstances",
                "rds:DescribeReservedDBInstances",
                "elasticache:DescribeReservedCacheNodes",
                "ec2:DescribeReservedInstances"
            ],
            "Resource": "*"
        }
    ]
}
```

#### ロールを作成

* reserved_instances_check
 * このロールを使用するサービス： Lambda
 * 上記２つのポリシーをアタッチする



### Lambda 関数を作成する
* 関数名
    * reserved_instances_check

* ランタイム
    * Python 3.7

* ロール
    * 上で作成した reserved_instances_check ロール

* コード

```python
import os
import boto3
import json
import logging
from pprint import pprint
from datetime import datetime, timedelta, timezone
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError

THRESHOLD10 = int(os.environ['THRESHOLD10'])
THRESHOLD1 = int(os.environ['THRESHOLD1'])

SLACK_WEBHOOK_URL = os.environ['SLACK_WEBHOOK_URL']
AWS_ACCOUNT_ALIAS = os.environ['AWS_ACCOUNT_ALIAS']

logger = logging.getLogger()
logger.setLevel(logging.INFO)

JST = timezone(timedelta(hours=+9), 'JST')
now = datetime.now(JST).date()

def lambda_handler(event, context):
    logger.info("Event: " + str(event))

    ec2_reserved_instance_expiration_date_check()
    rds_reserved_instance_expiration_date_check()
    elasticache_reserved_instance_expiration_date_check()
    es_reserved_instance_expiration_date_check()

def validcheck(RI, RIID, InstanceType, InstanceCount, End, ValidPeriod):
    if THRESHOLD10 == ValidPeriod:
        slack(RI, RIID, InstanceType, str(InstanceCount), End, str(ValidPeriod))
    elif THRESHOLD1 == ValidPeriod:
        slack(RI, RIID, InstanceType, str(InstanceCount), End, str(ValidPeriod))

def slack(RI, RIID, InstanceType, InstanceCount, End, ValidPeriod):
    color = "warning"
    icon = ":bulb:"
    SlackText=RI + " Reserved Instance Check"
    SlackTextAttachments = "AWS: " + AWS_ACCOUNT_ALIAS + "\n" + "RI ID: " + RIID + "\n" + "Instance Type: " + InstanceType + "\n" + "Instance Count: " + InstanceCount + "\n" + "End: " + End + " (only " + ValidPeriod + " days left)"
    slack_message = {
        'username': "reserved-instance-expiration-data-checker",
        'text': SlackText,
        'icon_emoji': icon,
        'attachments': [
            {
                "color": color,
                "text": SlackTextAttachments
            }
        ]
    }
    req = Request(SLACK_WEBHOOK_URL, json.dumps(slack_message).encode('utf-8'))
    try:
        response = urlopen(req)
        response.read()
        logger.info("Message posted to %s", SLACK_WEBHOOK_URL)
    except HTTPError as e:
        logger.error("Request failed: %d %s", e.code, e.reason)
    except URLError as e:
        logger.error("Server connection failed: %s", e.reason)

def ec2_reserved_instance_expiration_date_check():
    RI="EC2"
    client = boto3.client('ec2')
    responce = client.describe_reserved_instances()
    for ReservedInstance in responce['ReservedInstances']:
        if ReservedInstance['State'] == "active":
            ValidPeriod = (((ReservedInstance['End'].astimezone(JST).date()) - now).days)
            END = str((ReservedInstance['End']).astimezone(JST).date())
            validcheck(RI, ReservedInstance['ReservedInstancesId'], ReservedInstance['InstanceType'], str(ReservedInstance['InstanceCount']), str(END), ValidPeriod)

def rds_reserved_instance_expiration_date_check():
    RI="RDS"
    client = boto3.client('rds')
    response = client.describe_reserved_db_instances()
    for ReservedDBInstance in response['ReservedDBInstances']:
        if ReservedDBInstance['State'] == "active":
            END = ((ReservedDBInstance['StartTime'].astimezone(JST) + timedelta(seconds=ReservedDBInstance['Duration'])).astimezone(JST).date())
            ValidPeriod = (END - now).days
            validcheck(RI, ReservedDBInstance['ReservedDBInstanceId'], ReservedDBInstance['DBInstanceClass'], str(ReservedDBInstance['DBInstanceCount']), str(END), ValidPeriod)

def elasticache_reserved_instance_expiration_date_check():
    RI="ElastiCache"
    client = boto3.client('elasticache')
    response = client.describe_reserved_cache_nodes()
    for ReservedCacheNode in response['ReservedCacheNodes']:
        if ReservedCacheNode['State'] == "active":
            END = ((ReservedCacheNode['StartTime'].astimezone(JST) + timedelta(seconds=ReservedCacheNode['Duration'])).astimezone(JST).date())
            ValidPeriod = (END - now).days
            validcheck(RI, ReservedCacheNode['ReservedCacheNodeId'], ReservedCacheNode['CacheNodeType'], "", str(END), ValidPeriod)

def es_reserved_instance_expiration_date_check():
    RI="Elasticsearch"
    client = boto3.client('es')
    response = client.describe_reserved_elasticsearch_instances()
    for ReservedElasticsearchInstance in response['ReservedElasticsearchInstances']:
        if ReservedElasticsearchInstance['State'] == "active":
            END = ((ReservedElasticsearchInstance['StartTime'].astimezone(JST) + timedelta(seconds=ReservedElasticsearchInstance['Duration'])).astimezone(JST).date())
            ValidPeriod = (END - now).days
            validcheck(RI, ReservedElasticsearchInstance['ReservedElasticsearchInstanceId'], ReservedElasticsearchInstance['ElasticsearchInstanceType'], "", str(END), ValidPeriod)             

```

* 環境変数
    * キー：AWS_ACCOUNT_ALIAS<br />値：AWSアカウント名
    * キー：SLACK_WEBHOOK_URL<br />値：SlackのWebhook
    * キー：THRESHOLD10<br />値：10
    * キー：THRESHOLD1<br />値：1

* タイムアウト
    * 1分

### CloudWatch イベント ルール
* スケジュール（注意：設定はGMT）
    * Cron式：0 0 * * ? *

* ターゲット
    * Lambda関数：reserved_instances_check

* ルール名
    * reserved_instances_check




### Slack通知例
![スクリーンショット 2019-04-25 19.34.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/148868/a83d20b5-34fe-9712-976f-bd0e1d3e8063.png)


### 更新時の注意点（AWSから届いたメールの内容）

**現在お使いのリザーブドインスタンスを引き続きご利用いただく場合**

* 現在お使いのリザーブドインスタンスと同じスペックで、来月以降もご使用する場合は、 同じリザーブドインスタンスを新規でご購入いただく形となります。
 * 一度ご購入いただいたリザーブドインスタンスはキャンセルできませんのでご注意ください。
 * 新規購入分も購入日より1年または3年の有効期限となります。
 * <font color="Red">現在の有効期限が切れる直前に新規購入いただくことでリザーブドインスタンスの重複期間を最小限に抑えていただけます。</font>
 * 軽度・中度のリザーブドインスタンスをお使いの方は、現在提供しているタイプの中に同じ課金体系のものはありませんので、新規購入はできません。ご注意ください。