ELBで利用中の証明書の期限が切れる前に通知

# 監視の必要性

皆さん、SSL/TLS証明書をELBで利用していますか。
期限が切れてしまうと、即サービスに影響しますので、監視しておくことが重要ですよね。

> え？証明書なんて自動更新されるでしょ。監視なんて要らないよ。

はい。確かにACMで発行された証明書の場合は、失効前に自動更新されるようになっていますが、**どうしてもサードパーティーで発行した証明書を使わなければならない** ケースもあります。
その場合に、ACMでは自動更新しません。
忘れないようにと、毎日・毎週手動でチェックしにいくわけにもいかないので、適切なタイミングで通知を受け取りたいです。

# CloudWatchの方法

クラスメソッドさんで公開されている情報にありますが、2021年3月より CloudWatchの ```DaysToExpiry``` メトリクスで取得できるようになっています。

https://dev.classmethod.jp/articles/acm-certificate-expiry-monitoring-cloudwatch/

このメトリクスでCloudWatchアラームを設定すれば、自分のベストタイミングの日数に通知を受け取ることができます。

# CloudWatchの方法で困ること

CloudWatchで設定する場合に、いくつか困ることがあります。

- 新しい証明書をACMにインポートするごとにCloudWatchアラームの設定が必要
- 期限切れ前に証明書を入れ替えたタイミングで古いCloudWatchアラームは削除が必要
- ELBの廃止で使わなくなった証明書のCloudWatchアラームも削除が必要
- ACMの情報だけだと、どのELBが影響するのかがパッと分からない（対象ELBが複数あると特に）

これを解決するためには、Lambda関数によるカスタム監視ですよね。
そっかー、Lambda関数を作るしかないなあー、仕方ないなあー（単に作りたいだけ）

# Lambda関数の方法

## 概要

デフォルトリージョンの全てのELBに対して、証明書を利用しているか、および、そのACMの期限をチェックします。
```last_days``` 変数で指定したしきい値を切っていたら、通知します。
自動更新の証明書なら（RenewalEligibility が ELIGIBLE）、通知の対象外にします。
これにより、メンテナンスフリーで、かつ、必要十分な通知を行うことができます。
さらに通知内容には、ELBのARN、リスナーのARN、ドメイン名を含めることでパッと分かるようにします。

## コード

Pythonで記述します。

```python
import boto3
import json
from datetime import datetime, timedelta

elbv2 = boto3.client('elbv2')

# 期限切れまでの残り日数のしきい値
last_days = 30

# ACMリスト
acms = boto3.client('acm').list_certificates()['CertificateSummaryList']

# ELB(elbv2)リスト
elbs = elbv2.describe_load_balancers()['LoadBalancers']

# 期限切れが近いかをチェック
# 自動更新の対象ではなく かつ 期限切れ近いなら引数をそのまま返す
def select_expiration_warning(filtered):
    if not filtered:
        return None
    if filtered[0]['RenewalEligibility'] == 'ELIGIBLE':
        return None
    if filtered[0]['NotAfter'].replace(tzinfo=None) - datetime.now() > timedelta(days=last_days):
        return None
    return filtered

def lambda_handler(event, context):

    expiration_warnings = []

    # ELB, Listener, Certification の組み合わせリスト生成
    elbs_listeners_certs = []
    for elb_arn in map(lambda x: x['LoadBalancerArn'], elbs):
        for listener in elbv2.describe_listeners(LoadBalancerArn=elb_arn)['Listeners']:
            for cert in listener.get('Certificates', []):
                elbs_listeners_certs.append( (elb_arn, listener, cert ) )

    # 組み合わせリストの単位で期限切れが近いかをチェック
    for e_l_c in elbs_listeners_certs:
        cert_filtered = list(filter(lambda x: x['CertificateArn'] == e_l_c[2]['CertificateArn'], acms))
        if select_expiration_warning(cert_filtered):
            # warning対象をリストに格納
            expiration_warnings.append({
                'LoadBalancer': e_l_c[0],
                'ListenerArn': e_l_c[1]['ListenerArn'],
                'CertificateArn': cert_filtered[0]['CertificateArn'],
                'NotAfter': cert_filtered[0]['NotAfter'].isoformat(),
                'DomainName': cert_filtered[0]['DomainName']
            })

    if len(expiration_warnings) > 0:
        boto3.client('sns').publish(
            TopicArn = 'arn:aws:sns:REGION_NAME:ACCOUNT_ID:TOPIC_NAME',
            Message  = json.dumps(expiration_warnings, indent=4)
        )

    return {}
```

このコードで書き換えが必須の箇所は、
```arn:aws:sns:REGION_NAME:ACCOUNT_ID:TOPIC_NAME``` の箇所です。
ここは各自のSNSトピックを作成しておき、そのARNを指定します。

## Lambda関数の設定

- ランタイム
    - Python 3.10
- 実行ロールのIAMポリシー
    - AWSLambdaBasicExecutionRole
    - AWSCertificateManagerReadOnly
    - ElasticLoadBalancingReadOnly
    - AmazonSNSFullAccess   *(※sns.publishが利用できるポリシーに絞ってもOK)*
- タイムアウト
    - 3秒 → 3 + ＜リスナー数 × 0.5＞秒 程度   *(少し余裕を持たせておく)*

last_days をチューニングする必要があれば、
```python
import os
last_days = int(os.getenv('last_days'))
```
としてもいいでしょう。

## EventBridgeの設定

EventBridgeスケジューラで、Lambdaを1日1回実行するように設定します。

# CloudWatchアラームを使うたびに思う

CloudWatchアラームは、1つ1つのリソースに設定するので、運用する中でリソースが増加・減少するたびに、CloudWatchアラームも（ある程度は自動化できるとしても）メンテナンスが必要だなぁと感じています。
Lambda関数で動的にリソースのリストを取得することで、リソースが増加・減少してもメンテフリーにできる方が楽！というケースがところどころあるんですよね。
適材適所で使っていきましょう。