概要
CloudFormation で Lambda-backed カスタムリソースを作成する際に利用する cfn-response モジュールですが、実体は以下ソースコードになっています。
cfn-response モジュール – モジュールのソースコード
send メソッドに渡す引数のうち必須なのは event, context, responseStatus, responseData の4つなので、この4つを利用しているテンプレートが多いと思いますが、
def send(event, context, responseStatus, responseData, physicalResourceId=None, noEcho=False, reason=None): responseUrl = event['ResponseURL']
私が最近重宝しているのは、オプション引数の physicalResourceId と reason です。
本記事では主にこちらの2つについてお話したいと思います。
physicalResourceId
CloudFormation コンソール上では「物理 ID」と表示される項目です。
特に指定しない場合は以下のように CloudWatch Logs のログストリーム名になります。
reason
こちらは「状況の理由」欄に表示されるメッセージです。
特に指定しない場合は以下のように定型的なメッセージ + ログストリーム名になります。
活用例
以下のようなテンプレートを作ってみました。
こちらは以前投稿した Session Manager の設定を行うテンプレートに少し手を加えたものです。
AWSTemplateFormatVersion: "2010-09-09" Parameters: IdleSessionTimeout: Type: Number Default: 30 Resources: SsmDocument: Type: Custom::SsmDocument Properties: ServiceToken: !GetAtt Function.Arn DocumentParams: Name: SSM-SessionManagerRunShell DocumentType: Session DocumentFormat: JSON Content: !Sub | { "schemaVersion": "1.0", "inputs": { "cloudWatchEncryptionEnabled": false, "s3BucketName": "", "s3KeyPrefix": "", "s3EncryptionEnabled": false, "runAsDefaultUser": "", "cloudWatchStreamingEnabled": false, "kmsKeyId": "", "runAsEnabled": false, "idleSessionTimeout": "${IdleSessionTimeout}", "shellProfile": { "linux": "", "windows": "" }, "cloudWatchLogGroupName": "" }, "description": "Document to hold regional settings for Session Manager", "sessionType": "Standard_Stream" } Function: Type: AWS::Lambda::Function DependsOn: FunctionLog Properties: FunctionName: !Join - '' - - ssm-document-custom-function- - !Select [ '0', !Split [ '-', !Select [ '2', !Split [ '/', !Ref AWS::StackId ] ] ] ] Code: ZipFile: | import logging import os import traceback import boto3 import cfnresponse from botocore.exceptions import ClientError logger = logging.getLogger() logger.setLevel(os.environ['LOG_LEVEL']) ssm = boto3.client('ssm') def lambda_handler(event, context): try: document_params = event['ResourceProperties']['DocumentParams'] account_id = event['StackId'].split(':')[4] document_arn = f'arn:aws:ssm:{os.environ["AWS_REGION"]}:{account_id}:document/{document_params["Name"]}' if event['RequestType'] in ['Create', 'Update']: if document_exists(document_params): update_document(document_params) else: create_document(document_params) cfnresponse.send(event, context, cfnresponse.SUCCESS, {}, physicalResourceId=document_arn) except: tb = traceback.format_exc() logger.error(tb) cfnresponse.send(event, context, cfnresponse.FAILED, {}, physicalResourceId=None, noEcho=False, reason=tb.replace('\n', r'\n')) def document_exists(document_params:dict) -> bool: document_identifiers = ssm.list_documents( Filters=[ { 'Key': 'Name', 'Values': [ document_params['Name'] ] }, { 'Key': 'Owner', 'Values': [ 'Self' ] }, { 'Key': 'DocumentType', 'Values': [ document_params['DocumentType'] ] }, ], )['DocumentIdentifiers'] if len(document_identifiers) == 0: return False elif len(document_identifiers) == 1: return True def create_document(document_params:dict) -> None: response = ssm.create_document( Content=document_params['Content'], Name=document_params['Name'], DocumentType=document_params['DocumentType'], DocumentFormat=document_params['DocumentFormat'], ) logger.info(response) def update_document(document_params:dict) -> None: try: updated_version = ssm.update_document( Content=document_params['Content'], Name=document_params['Name'], DocumentFormat=document_params['DocumentFormat'], DocumentVersion='$LATEST', )['DocumentDescription']['DocumentVersion'] except ClientError as e: if e.response['Error']['Code'] == 'DuplicateDocumentContent': logger.info('no update to the SSM document') return None else: raise e response = ssm.update_document_default_version( Name=document_params['Name'], DocumentVersion=updated_version ) logger.info(response) Environment: Variables: LOG_LEVEL: INFO Handler: index.lambda_handler MemorySize: 128 Role: !GetAtt FunctionRole.Arn Runtime: python3.8 Timeout: 120 FunctionRole: Type: AWS::IAM::Role Properties: RoleName: !Join - '' - - ssm-document-custom-function-role- - !Select [ '0', !Split [ '-', !Select [ '2', !Split [ '/', !Ref AWS::StackId ] ] ] ] AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Policies: - PolicyName: ssm-document-policy PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - ssm:ListDocuments Resource: "*" - Effect: Allow Action: - ssm:UpdateDocument - ssm:CreateDocument - ssm:UpdateDocumentDefaultVersion Resource: !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:document/SSM-SessionManagerRunShell - PolicyName: lambda-logs-policy PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - logs:CreateLogGroup Resource: !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:* - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: !Join - '' - - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/ssm-document-custom-function- - !Select [ '0', !Split [ '-', !Select [ '2', !Split [ '/', !Ref AWS::StackId ] ] ] ] - :* FunctionLog: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Join - '' - - /aws/lambda/ssm-document-custom-function- - !Select [ '0', !Split [ '-', !Select [ '2', !Split [ '/', !Ref AWS::StackId ] ] ] ] RetentionInDays: 7
本テンプレートを利用しスタックを作成すると、リソースの欄は以下のようになります。
ログストリーム名ではなく、任意の値を物理IDとして表示することが可能となっています。
何のリソースを作成したのか、ログストリーム名より分かりやすくなっていると思います。
カスタムリソースのソースコードでいうと、以下の黄文字の部分で物理IDを指定しています。
def lambda_handler(event, context): try: document_params = event['ResourceProperties']['DocumentParams'] account_id = event['StackId'].split(':')[4] document_arn = f'arn:aws:ssm:{os.environ["AWS_REGION"]}:{account_id}:document/{document_params["Name"]}' if event['RequestType'] in ['Create', 'Update']: if document_exists(document_params): update_document(document_params) else: create_document(document_params) cfnresponse.send(event, context, cfnresponse.SUCCESS, {}, physicalResourceId=document_arn)
次は、本テンプレートを利用して意図的にエラーを発生させてみようと思います。
CFn パラメータの「IdleSessionTimeout」を61以上の数値に変更してみます。
すると、以下のように CFn コンソール上にエラーメッセージが表示されました。
カスタムリソースのソースコードでいうと、以下の部分でエラーメッセージ(Python のスタックトレース)を取得し、送信しています。
except: tb = traceback.format_exc() logger.error(tb) cfnresponse.send(event, context, cfnresponse.FAILED, {}, physicalResourceId=None, noEcho=False, reason=tb.replace('\n', r'\n'))
このやり方には2つの利点があると考えています。
- カスタムリソースの実行ログ (CloudWatch Logs) を見に行かなくてもエラー内容が分かる
- CFn スタック初回作成時にカスタムリソース実行が失敗してもエラー内容を残しておける
2点目は、CFn テンプレート内でカスタムリソース用Function のロググループを作成している場合の話です。
スタック初回作成時にカスタムリソースの作成が失敗するとロールバックにより全リソースが削除されるため、カスタムリソース用Function のロググループまで消えてしまいます。
本テンプレートのように cfn-response モジュール でエラーメッセージを送信しておくことで、ロググループが消えても CFn コンソールにそのエラーメッセージを残しておけるというわけです。
おまけ
本記事で紹介した CFn テンプレートで使っているワザなのですが、
!Select [ '0', !Split [ '-', !Select [ '2', !Split [ '/', !Ref AWS::StackId ] ] ] ]
と書いてあげると、以下のような StackId の中の 1234abcd みたいな文字列が取得できるので、パスワードではないランダム文字列を生成するのに便利です。
arn:aws:cloudformation:region:111122223333:stack/stack-name/1234abcd-56ef-78gh-90ij-123456klmnop
例えば以下のように使うと、ssm-document-custom-function-1234abcd
のようなリソース名になります。
Function: Type: AWS::Lambda::Function DependsOn: FunctionLog Properties: FunctionName: !Join - '' - - ssm-document-custom-function- - !Select [ '0', !Split [ '-', !Select [ '2', !Split [ '/', !Ref AWS::StackId ] ] ] ]
投稿者プロフィール
- 2015年8月入社。弊社はインフラ屋ですが、アプリも作ってみたいです。