概要
CloudFormationでDNS検証方式のACM証明書を作成した場合、Route53に検証用CNAMEレコードが自動的に追加されますが、Delete StackしてもこのCNAMEレコードは自動的には削除されません。
しかしCloudFormationのカスタムリソースを使えば、Delete Stack時にCNAMEも一緒に消すことが可能です。
今回はそれを実現する方法を紹介したいと思います。
カスタムリソースとは
CloudFormationのカスタムリソースは、CloudFormationテンプレート内で定義される独自のリソースです。通常のCloudFormationリソースでは提供されていない機能やサービスをカバーするために使用されます。
カスタムリソースはLambda関数を使用して作成され、スタックの作成、更新、削除をトリガーにAWSリソースの作成、更新、削除などの処理を実行できます。つまりカスタムリソースを使用することで、CloudFormationの範囲を超えた任意の操作や自動化が可能になります。
注意事項
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時の挙動に関してはこちらの記事が詳しいです。
サンプルテンプレート
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と同名のロググループが作成されます。デバッグしたいときはそこを見てください
- get_cname_record
- カスタムリソースの実体となるLambdaです。ここに処理を書きます
- CustomResourceLambdaRole
- Lambdaでさせたい処理に必要な権限を与えてます
- CustomResource
- これがカスタムリソースです
- 対応するLambdaとプロパティを定義しているだけで、リソースとしての実体は持ちません
- このリソースが作成、更新、削除されると対応するLambdaが起動します
- プロパティは呼び出されたLambdaが参照できます