CloudFormationをDeleteStackしたときACMと一緒にCNAMEも消す方法

## 概要

CloudFormationでDNS検証方式のACM証明書を作成した場合、Route53に検証用CNAMEレコードが自動的に追加されますが、Delete StackしてもこのCNAMEレコードは自動的には削除されません。

しかしCloudFormationのカスタムリソースを使えば、Delete Stack時にCNAMEも一緒に消すことが可能です。
今回はそれを実現する方法を紹介したいと思います。


## カスタムリソースとは

CloudFormationのカスタムリソースは、CloudFormationテンプレート内で定義される独自のリソースです。通常のCloudFormationリソースでは提供されていない機能やサービスをカバーするために使用されます。

カスタムリソースはLambda関数を使用して作成され、スタックの作成、更新、削除をトリガーにAWSリソースの作成、更新、削除などの処理を実行できます。つまりカスタムリソースを使用することで、CloudFormationの範囲を超えた任意の操作や自動化が可能になります。

参考: [カスタムリソース (公式ドキュメント)](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/template-custom-resources.html)


## 注意事項

### 1つ目のCNAMEしか消せません

1つの証明書に複数のドメインを入れることがありますが、今回のサンプルだと1つ目のCNAMEしか消せません。

### 自動でCNAMEが消えないのには訳がある

ACMの検証用CNAMEはドメイン名に基づいて作成されます。
複数のACM証明書で同一のドメインを設定する場合、それぞれの検証用CNAMEは同一になります。
つまり、削除する検証用CNAMEが他の証明書で使われている可能性があります。
スタック毎に一意な検証用CNAMEが作られることが保証されている環境でのみ、この手法を使うようにしてください。

### ゴミを消したかったのに新たなゴミが

この仕組みを考えた動機は、スタック削除時に不要なCNAMEレコードが残って邪魔だったり手動で削除するのが面倒だからですが、Lambdaを使ったせいで今度はLambdaのロググループがゴミとして残ってしまいます。
(Lambdaを実行すると自動的にCloudWatchのロググループが作成されます)

今回の例ではスタック専用のLambdaを作って、スタック削除時にこのLambdaも一緒に消えるようにしていますが、CNAMEを削除する用の共通のLambdaを用意して、各スタックのカスタムリソースでは共通Lambdaを呼び出すようにすべきだったかも知れません。

### XXXXX_IN_PROGRESSのまま止まってしまったら

1時間と少しでタイムアウトしてFAILEDになるので、それから手動でスタックを削除してください。
これが発生するのはカスタムリソースのLabmdaが応答オブジェクトをCloudFormationに返せていないときです。
サンプル通りにやれば大丈夫なはずです。

### UpdateStackは考慮してるけどしてない

今回のサンプルだとUpdateStackすると新しい証明書を発行して、古い証明書は削除されます。
その場合も古いCNAMEが削除されるようになっていますが、それはUpdate用の処理をしているわけでなく、UpdateのときにDeleteが同時に呼び出されるからです。
Update時にDeleteされてはまずいケースがあるはずなので、サンプルを改変して使う場合は必ず各パラメータのUpdateStackの動作確認をしてください。

Update時の挙動に関してはこちらの記事が詳しいです。

https://qiita.com/willco21/items/534436ba89a3c6f217ac


## サンプルテンプレート

```yml
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  DomainName:
    Type: String
    Description: "証明書のドメイン名"
    Default: "foo.example.com"
  HostedZoneId:
    Type: String
    Description: "ドメインを管理しているRoute53のホステッドゾーンID"
    Default: "ZXXXXXXXXXXXXXXXXXXX"

Resources:
  Certificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !Ref DomainName
      ValidationMethod: DNS
      DomainValidationOptions:
        - DomainName: !Ref DomainName
          HostedZoneId: !Ref HostedZoneId

  CustomResourceLambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub "${AWS::StackName}-custom-resource-handler"
      Handler: index.handler
      Runtime: python3.10
      Timeout: 30
      Role: !GetAtt CustomResourceLambdaRole.Arn
      Code:
        ZipFile: |
          import cfnresponse
          import boto3

          def get_cname_record(certificate_arn):
              try:
                  client = boto3.client('acm')
                  response = client.describe_certificate(CertificateArn=certificate_arn)
                  certificate_details = response['Certificate']
                  cname_record = certificate_details.get('DomainValidationOptions', [{}])[0].get('ResourceRecord', {})
                  return cname_record
              except Exception as e:
                  print(f'Error getting CNAME record: {str(e)}')

          def delete_cname_record(hosted_zone_id, record_name):
              try:
                  client = boto3.client('route53')
                  response = client.list_resource_record_sets(
                      HostedZoneId=hosted_zone_id,
                      StartRecordName=record_name,
                      MaxItems='1'
                  )
                  record_sets = response['ResourceRecordSets']
                  for record_set in record_sets:
                      if record_set['Name'] == record_name and record_set['Type'] == 'CNAME':
                          changes = [
                              {
                                  'Action': 'DELETE',
                                  'ResourceRecordSet': {
                                      'Name': record_set['Name'],
                                      'Type': record_set['Type'],
                                      'TTL': record_set['TTL'],
                                      'ResourceRecords': record_set['ResourceRecords']
                                  }
                              }
                          ]
                          client.change_resource_record_sets(
                              HostedZoneId=hosted_zone_id,
                              ChangeBatch={'Changes': changes}
                          )
                          print(f"Record '{record_name}' deleted successfully.")
                          break
              except Exception as e:
                  print(f'Error deleting CNAME record: {str(e)}')

          def handler(event, context):
              domain_name = event['ResourceProperties']['DomainName']
              hosted_zone_id = event['ResourceProperties']['HostedZoneId']
              certificate_arn = event['ResourceProperties']['CertificateArn']
              stack_name = event['ResourceProperties']['StackName']

              if event['RequestType'] == 'Delete':
                  record_name = get_cname_record(certificate_arn)
                  delete_cname_record(hosted_zone_id, record_name['Name'])

              # 応答オブジェクトの作成
              response_data = {
                  'DomainName': domain_name,
                  'HostedZoneId': hosted_zone_id,
                  'CertificateArn': certificate_arn,
                  'StackName': stack_name
              }
              cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)

  CustomResourceLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-custom-resource-handler-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: Route53DeleteRecordPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "acm:DescribeCertificate"
                Resource: "*"
              - Effect: Allow
                Action:
                  - "route53:*"
                Resource:
                  - !Sub "arn:aws:route53:::hostedzone/${HostedZoneId}"

  CustomResource:
    Type: AWS::CloudFormation::CustomResource
    Properties:
      ServiceToken: !GetAtt CustomResourceLambda.Arn
      DomainName: !Ref DomainName
      HostedZoneId: !Ref HostedZoneId
      CertificateArn: !Ref Certificate
      StackName: !Ref "AWS::StackName"
```


## 解説

**パラメータ**
* DomainName
    * ACMで発行する証明書に埋め込むドメイン名です
* HostedZoneId
    * 上記ドメインを管理しているRoute53のホストゾーンIDです
    * 自動的にCNAMEレコードを作成するにはACMと同じAWSでドメインを管理している必要があります

**リソース**
* Certificate
    * ACM証明書です
* CustomResourceLambda
    * カスタムリソースの実体となるLambdaです。ここに処理を書きます
        * get_cname_record
            * ACMのARNを受け取り、CNAMEのレコード情報を返します
        * delete_cname_record
            * ゾーンIDとCNAMEのレコード名を受け取り、対象を削除します
        * handler
            * メイン関数です
            * get_cname_recordでCNAMEのレコード情報を取得します
            * delete_cname_recordでCNAMEを削除します
            * 応答オブジェクトを作成してCloudFormationに返します。これをしないとCloudFormationがXXXXX_IN_PROGRESSのまま止まってしまいます
            * 削除に失敗してもCloudFormationにはSUCCESSを返すようにしています。そうしないとDeleteStackが進まないので
            * 実行されるとCloudWatchにLambdaと同名のロググループが作成されます。デバッグしたいときはそこを見てください
* CustomResourceLambdaRole
    * Lambdaでさせたい処理に必要な権限を与えてます
* CustomResource
    * これがカスタムリソースです
    * 対応するLambdaとプロパティを定義しているだけで、リソースとしての実体は持ちません
    * このリソースが作成、更新、削除されると対応するLambdaが起動します
    * プロパティは呼び出されたLambdaが参照できます