EC2インスタンス起動時にIPアドレスを自動でRoute53に登録する

# Elastic IP をケチりたい

たまにしか起動しないインスタンスに Elastic IP をアタッチするのはもったいない。といって、起動するたびにIPアドレスが変わるとホスト名でアクセスできなくて面倒くさい。

……というテーマで以前、[記事](https://qiita.com/keys/items/264a64c2841875d51cdd)を書きました。

今回はそれを推し進めて CloudWatch Event でインスタンスの起動を監視して、起動したら自動でIPアドレスを Route53 のAレコードに登録する、という Lambda を書きました。これでEIP（と無駄な支出）とはおさらばだ！

あと、余計かもしれないけどインスタンスを停止したときにレコードを削除するようにしてみました。意味あるかなぁ。

## 必要な権限

登録したい Route 53 のホストゾーンのレコード一覧を取得・変更するための権限:

- `route53:ChangeResourceRecordSets`
- `route53:ListResourceRecordSets`

インスタンスの情報を取得するための権限:

- `ec2:DescribeInstances`

あとは Lambda 利用の基本ポリシーである `AWSLambdaBasicExecutionRole`

## Lambda のコード

https://github.com/keioni/qiita-sample/blob/master/set_route53_record.py

```python:set_route53_record.py
import json
import os
from datetime import datetime

import boto3


def json_dt(o):
    if isinstance(o, datetime):
        return o.isoformat()


def change_record(action, host_name, host_addr):
    domain_name = os.environ.get('DomainName')
    if domain_name:
        host_name = F"{host_name}.{domain_name}."
    client = boto3.client('route53')
    change_batch = {
        "Comment": "optional comment about the changes in this change batch request",
        "Changes": [
            {
                "Action": action,
                "ResourceRecordSet": {
                    "Name": host_name,
                    "Type": "A",
                    "TTL": 300,
                    "ResourceRecords": [
                        {
                          "Value": host_addr
                        }
                    ]
                }
            }
        ]
    }
    print("change_batch: " + json.dumps(change_batch, default=json_dt))
    response = client.change_resource_record_sets(
        HostedZoneId=os.environ.get('HostedZoneId'),
        ChangeBatch=change_batch
    )
    print("result: " + json.dumps(response, default=json_dt))
    return response


def get_hostname_from_tags(tags):
    host_name = ''
    for tag in tags:
        if tag['Key'].lower() == 'hostname':
            host_name = tag['Value'].lower()
    return host_name


def check_action(state):
    if state == 'running':
        action = 'UPSERT'
    elif state == 'stopping':
        action = 'DELETE'
    else:
        action = ''
    return action


def lambda_handler(event, context):
    result = dict()
    print('event: ' + json.dumps(event, default=json_dt))
    action = check_action(event['detail']['state'])
    if action:
        ec2 = boto3.resource('ec2')
        instance = ec2.Instance(event['detail']['instance-id'])
        host_name = get_hostname_from_tags(instance.tags)
        if host_name:
            host_addr = instance.public_ip_address
            result = change_record(action, host_name, host_addr)
    return json.dumps(result, default=json_dt)
```

### ざっくり説明

`Hostname` タグがあるインスタンスだけ対象にします。

## Lambda の設定

### 環境変数

今この Lambda を使っている環境では、ドメイン名と Route53 のホストゾーンIDはひとつだけなので Lambda の環境変数で設定するようにしました。

複数のホストゾーンにまたがって動くようにすると、結構めんどうくさそう。

```
HostedZoneId=ZABCDEFGHIJKL
```

### CloudWatch Event の設定

スクリーンショットを参考に、なんとか……。

![make_cloudwatch_event.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/223758/d4746ac1-73f3-0f9d-bec4-fb36785a0729.png)

### Lambda に渡される event の例

こんな感じで、`InstanceId` と `State` が取れます。

```json
{
    "version": "0",
    "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "detail-type": "EC2 Instance State-change Notification",
    "source": "aws.ec2",
    "account": "123456789012",
    "time": "2019-11-20T08:52:00Z",
    "region": "ap-northeast-1",
    "resources": [
        "arn:aws:ec2:ap-northeast-1:123456789012:instance/i-0123456789abcdefg"
    ],
    "detail": {
        "instance-id": "i-0123456789abcdefg",
        "state": "stopping"
    }
}
```

## 注意点

レコードを `DELETE` するときは、レコードの `Value` が現在のものでないと失敗するんだそうで。

https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/route53.html#Route53.Client.change_resource_record_sets
> In the case of a DELETE action, if the current value does not match the actual value, an error is returned.

エラーハンドリングなにもしていないので、あとで考えます。
`change_resource_record_sets` のドキュメントがこれだけ長いと、なにかの拍子で例外が上がるかもしれないですね……。

いずれエラーハンドリングをきちんとして、記事を更新したいと思います。いずれ……。