概要
CloudFormation で Lambda-backed カスタムリソースを作成する際に利用する cfn-response モジュールですが、実体は以下ソースコードになっています。
cfn-response モジュール - モジュールのソースコード
send メソッドに渡す引数のうち必須なのは event, context, responseStatus, responseData の4つなので、この4つを利用しているテンプレートが多いと思いますが、
1 2 |
def send(event, context, responseStatus, responseData, <span style="color: #ffff00;">physicalResourceId</span>=None, noEcho=False, <span style="color: #ffff00;">reason</span>=None): responseUrl = event['ResponseURL'] |
私が最近重宝しているのは、オプション引数の physicalResourceId と reason です。
本記事では主にこちらの2つについてお話したいと思います。
physicalResourceId
CloudFormation コンソール上では「物理 ID」と表示される項目です。
特に指定しない場合は以下のように CloudWatch Logs のログストリーム名になります。
reason
こちらは「状況の理由」欄に表示されるメッセージです。
特に指定しない場合は以下のように定型的なメッセージ + ログストリーム名になります。
活用例
以下のようなテンプレートを作ってみました。
こちらは以前投稿した Session Manager の設定を行うテンプレートに少し手を加えたものです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 |
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を指定しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def lambda_handler(event, context): try: document_params = event['ResourceProperties']['DocumentParams'] account_id = event['StackId'].split(':')[4] <span style="color: #ffff00;">document_arn = f'arn:aws:ssm:{os.environ["AWS_REGION"]}:{account_id}:document/{document_params["Name"]}'</span> 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, {}, <span style="color: #ffff00;">physicalResourceId=document_arn</span>) |
次は、本テンプレートを利用して意図的にエラーを発生させてみようと思います。
CFn パラメータの「IdleSessionTimeout」を61以上の数値に変更してみます。
すると、以下のように CFn コンソール上にエラーメッセージが表示されました。
カスタムリソースのソースコードでいうと、以下の部分でエラーメッセージ(Python のスタックトレース)を取得し、送信しています。
1 2 3 4 |
except: <span style="color: #ffff00;">tb = traceback.format_exc()</span> logger.error(tb) cfnresponse.send(event, context, cfnresponse.FAILED, {}, physicalResourceId=None, noEcho=False, <span style="color: #ffff00;">reason=tb.replace('\n', r'\n')</span>) |
このやり方には2つの利点があると考えています。
- カスタムリソースの実行ログ (CloudWatch Logs) を見に行かなくてもエラー内容が分かる
- CFn スタック初回作成時にカスタムリソース実行が失敗してもエラー内容を残しておける
2点目は、CFn テンプレート内でカスタムリソース用Function のロググループを作成している場合の話です。
スタック初回作成時にカスタムリソースの作成が失敗するとロールバックにより全リソースが削除されるため、カスタムリソース用Function のロググループまで消えてしまいます。
本テンプレートのように cfn-response モジュール でエラーメッセージを送信しておくことで、ロググループが消えても CFn コンソールにそのエラーメッセージを残しておけるというわけです。
おまけ
本記事で紹介した CFn テンプレートで使っているワザなのですが、
!Select [ '0', !Split [ '-', !Select [ '2', !Split [ '/', !Ref AWS::StackId ] ] ] ]
と書いてあげると、以下のような StackId の中の 1234abcd みたいな文字列が取得できるので、パスワードではないランダム文字列を生成するのに便利です。
1 |
arn:aws:cloudformation:region:111122223333:stack/stack-name/<span style="color: #ffff00;">1234abcd</span>-56ef-78gh-90ij-123456klmnop |
例えば以下のように使うと、ssm-document-custom-function-1234abcd
のようなリソース名になります。
1 2 3 4 5 6 7 8 |
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月入社。弊社はインフラ屋ですが、アプリも作ってみたいです。