CloudFormationのマクロ機能でLambda関数と一緒にCloudWatch LogsのLog Groupを自動作成してみる

はじめに

サーバーレス開発部@大阪の岩田です。 現在サーバーレス開発部では、AWSリソースの管理にやデプロイにAWS SAMを用いることが多いのですが、下記のブログでも紹介されているように普通にAWS SAMでLambda関数を作成すると、CloudWatch LogsのLog Groupが自動生成されません。

【小ネタ】AWS SAMでLambda関数を作成する場合はCloudWatch LogsのLog Groupも同時に作った方がいいという話

SAMテンプレートで明示的にLog Groupを作れば良いのですが、どうしても単調な記述の繰り返しになり、Lambdaの数が増えてくるとテンプレートがゴチャゴチャしてきます。できればServerlessFlameworkのように、勝手にLog Groupを作成して欲しいところです。

先月から使えるようになったCloudFormationのマクロを使うことで、このあたりの作業を省力化できたので、手順をご紹介します。

CloudFormationのマクロ機能とは

CloudFormationのマクロとは、Lambda関数を使ってCloudFormationテンプレートの記法を拡張できる機能です。AWS SAMをイメージして頂ければ分かり良いかと思います。

AWS SAMではテンプレートにTransform: AWS::Serverless-2016-10-31という記述を追加することで、サーバーレスアプリに必要なリソースを簡易な表現で記述することができます。 実際にはCloudFormationのチェンジセットを作成する際に、生のCloudFormationのテンプレートに変換されるのですが、CloudFormationのマクロを利用すると、自作のLambda関数でテンプレートを変換することができます。

AWSのニュースでは、下記のように記載されていました。

AWS CloudFormation Macros は、CloudFormation テンプレートで、検索や置換などの単純な操作からテンプレート全体の変換までカスタム処理を実行できます。CloudFormation Macros では、AWS::Include 変換および AWS::Serverless 変換の強化と同じテクノロジーを使用しています。CloudFormation 変換は、AWS インフラストラクチャの式をコードとして凝縮し、テンプレートコンポーネントの再利用を可能にすることによって、テンプレート作成を簡素化します。

https://aws.amazon.com/jp/about-aws/whats-new/2018/09/introducing-aws-cloudformation-macros/

動作の詳細についてはAWSのブログが詳しいです。

AWS CloudFormation を AWS Lambda によるマクロで拡張する

マクロの使い方

実際に自作のマクロを使うには、マクロの定義と実装を行った後に、CloudFormationでマクロをデプロイする必要があります。

マクロの定義

マクロを定義するにはCloudFormationのテンプレートでType: "AWS::CloudFormation::Macro"のリソースを定義します。

YAMLの場合は

Type: "AWS::CloudFormation::Macro"
Properties:
  Description: String
  FunctionName: String
  LogGroupName: String
  LogRoleARN: String
  Name: String

といった定義になります。 FunctionNameでは後述するマクロの実装であるLambda関数名を指定します。 このCloudFormationテンプレートのデプロイが完了すると、Nameで定義された名前を指定することでマクロを呼び出すことが可能になります。

マクロの実装

マクロの実装はLambdaを利用し、イベント引数で渡されたペイロードを基に適切なアウトプットを作成します。

マクロのインプット

マクロが実行される時、イベントとして下記のようなペイロードがLambdaに渡されます。

{
    "region": "<リージョン>", 
    "accountId": "<AWS アカウントID>",
    "fragment": { ... }, 
    "transformId": "<自動採番されたTRANSFORM_ID>", 
    "params": "<インライン変換の場合は、Parametersで渡されたパラメータが設定される>",
    "requestId": "<自動採番されたREQUEST_ID>",
    "templateParameterValues": "<テンプレート内で指定されたParameters>"
} 
マクロのアウトプット

自作のLambda関数で諸々の処理を行った後、最終的に下記のようなJSONを返却します。

{
        "requestId": "<イベントで渡されたリクエストID>",
        "status": "success",
        "fragment": {...}
}        

ポイントはfragmentです。 この中身に変換後のCloudFormationテンプレートを設定することで、変換後のテンプレートを用いてチェンジセットが作成されます。

トップレベルでの使用とインライン変換での使用

マクロを使う方法は

  • トップレベルで使用する
  • インライン変換で使用する

という2通りの使用方法があります。

トップレベルで使用

AWSTemplateFormatVersion: '2010-09-09'
Transform: [MyMacro, 'AWS::Serverless-2016-10-31'] 

のように、テンプレート最上位のTransformでマクロを指定する方法です。 この場合、テンプレートの全てが変換対象となります。 また、複数のマクロが指定されている場合はTransformのリストに記載された順(左→右)に変換が実行されます。

Lambdaが受け取るイベント引数は

{
  "region": "<リージョン>",
  "accountId": "<AWSアカウントID>",
  "params": {},
  "fragment": {
    "AWSTemplateFormatVersion": "2010-09-09",
    "Outputs": "{テンプレートで定義したOutputs}",
    "Resources": "{テンプレートで定義したResources}",
    "Description": "<テンプレートで指定したDescription>",
    "Parameters": {}
  },
  "transformId": "<AWSアカウントID>::<マクロ名>",
  "params": {},
  "requestId": "<自動採番されたリクエストID>",
  "templateParameterValues": "<テンプレートのParametersで指定したパラメータ>"
}

のような形式になります。 fragmentの中にCloudFormationテンプレートの中身が丸々入るようなイメージです。

インライン変換で使用

個々のリソースを定義する際にFn::TransForm関数を使ってマクロを呼び出す方法です。

例えば

AWSTemplateFormatVersion: 2010-09-09
Resources:
  MyS3Bucket:
    Type: 'AWS::S3::Bucket'
    Fn::Transform:
      Name: MyMacro
      Parameters:
        Key: Value

のような形式でマクロを呼び出します。 Parametersでマクロに渡す引数を設定できるのがポイントです。

この場合、Lambdaが受け取るイベント引数は

{
  "region": "<リージョン>",
  "accountId": "<>",
  "fragment": {
    "Type": "AWS::S3::Bucket"
  },
  "transformId": "<AWSアカウントID>::MyMacro",
  "params": {
    "Key": "Value"
  },
  "requestId": "<自動採番されたリクエストID>",
  "templateParameterValues": {}
}

のようになります。 fragmentの中身にはFn::Transformを記述したリソースだけが入ります。 また、Fn::TransForm関数で指定した引数がparamsに渡っているのが確認できるかと思います。

やってみる

実際にマクロを自作してみます。

マクロの実装

今回作成したマクロのコードです。

def handler(event, context):
    if "Resources" not in event["fragment"]:
        return {
        "requestId": event["requestId"],
        "status": "success",
        "fragment": event["fragment"],
    }
    
    log_groups = {}
    for key,resource in event.get("fragment",{}).get("Resources",{}).items():
        if resource.get("Type") == "AWS::Lambda::Function":
            log_groups[f"{key}LogGroup"] = {
                    "Type": "AWS::Logs::LogGroup",
                    "Properties":{
                        "LogGroupName": {
                            "Fn::Sub": [ "/aws/lambda/${FuncName}", { "FuncName": {"Ref" : key }} ]
                        },
                        "RetentionInDays": event["templateParameterValues"].get("LogRetentionDays",365),
                    }
                }
    
    event["fragment"]["Resources"].update(log_groups)
    return {
        "requestId": event["requestId"],
        "status": "success",
        "fragment": event["fragment"],
    }    

fragmentの中にテンプレートで定義した諸々のリソースが入ってくるので、このマクロでは、AWS::Lambda::Functionリソースが指定されている場合に、自動的にAWS::Logs::LogGroupリソースを作成して、レスポンスのfragmentにマージするようにしています。 また、ログの保持期間はテンプレートのパラメータでLogRetentionDaysが指定されている場合はその値を、指定されていない場合は365日をデフォルト値として採用しています。 ※実際に利用する際は、リソース名の重複チェックやエラーハンドリングを適宜追加して下さい。

マクロの定義

上記のLambdaと合わせてAWS SAMでマクロの定義を作成しました マクロの名前はMyMacroとしています。

Transform: AWS::Serverless-2016-10-31
Resources:
  Function:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.6
      CodeUri: .
      Handler: index.handler
  Macro:
    Type: AWS::CloudFormation::Macro
    Properties:
      Name: MyMacro
      FunctionName: !GetAtt Function.Arn

デプロイします

aws cloudformation package --template-file macro.yml  --s3-bucket <デプロイに利用するS3バケット名> --output-template-file macro-output.yml
aws cloudformation deploy --template-file macro-output.yml --stack-name <スタック名> --capabilities CAPABILITY_IAM

マクロ無しでAWS SAMのテンプレートをデプロイ

マクロの準備ができたので、AWS SAMのテンプレートをデプロイしてみます。

sam init --runtime python3.6

で作成したpython3.6のSAMアプリをデプロイします。 まずはマクロ無し版です。

AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: >
    sam-app

    Sample SAM Template for sam-app

# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
    Function:
        Timeout: 3


Resources:

    HelloWorldFunction:
        Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
        Properties:
            CodeUri: hello_world/build/
            Handler: app.lambda_handler
            Runtime: python3.6
            Environment: # More info about Env Vars: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#environment-object
                Variables:
                    PARAM1: VALUE
            Events:
                HelloWorld:
                    Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
                    Properties:
                        Path: /hello
                        Method: get

Outputs:

    HelloWorldApi:
      Description: "API Gateway endpoint URL for Prod stage for Hello World function"
      Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"

    HelloWorldFunction:
      Description: "Hello World Lambda Function ARN"
      Value: !GetAtt HelloWorldFunction.Arn

    HelloWorldFunctionIamRole:
      Description: "Implicit IAM Role created for Hello World function"
      Value: !GetAtt HelloWorldFunctionRole.Arn

aws cloudformation package --template-file template.yaml --s3-bucket <デプロイに利用するS3バケット名> --output-template-file output.yml
aws cloudformation deploy --template-file output.yml --stack-name <スタック名> --capabilities CAPABILITY_IAM

blog-HelloWorldFunction-1P6EPXH6K0463というLambda関数がデプロイされましたが、Cloud Watch Logsには/aws/lambda/blog-HelloWorldFunction-1P6EPXH6K0463というロググループが作成されていません。

マクロ有りでAWS SAMのテンプレートをデプロイ

先ほどデプロイしたテンプレートを修正、マクロを使うように変更して再度デプロイしてみます。

AWSTemplateFormatVersion: '2010-09-09'
Transform: ['AWS::Serverless-2016-10-31', MyMacro]
Parameters:
  LogRetentionDays:
    Type: Number
    AllowedValues: [1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827,3653]
    Default: 365
Description: >
    sam-app

    Sample SAM Template for sam-app

# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
    Function:
        Timeout: 3


Resources:

    HelloWorldFunction:
        Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
        Properties:
            CodeUri: hello_world/build/
            Handler: app.lambda_handler
            Runtime: python3.6
            Environment: # More info about Env Vars: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#environment-object
                Variables:
                    PARAM1: VALUE
            Events:
                HelloWorld:
                    Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
                    Properties:
                        Path: /hello
                        Method: get

Outputs:

    HelloWorldApi:
      Description: "API Gateway endpoint URL for Prod stage for Hello World function"
      Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"

    HelloWorldFunction:
      Description: "Hello World Lambda Function ARN"
      Value: !GetAtt HelloWorldFunction.Arn

    HelloWorldFunctionIamRole:
      Description: "Implicit IAM Role created for Hello World function"
      Value: !GetAtt HelloWorldFunctionRole.Arn
aws cloudformation package --template-file template.yaml --s3-bucket <デプロイに利用するS3バケット名> --output-template-file output.yml
aws cloudformation deploy --template-file output.yml --stack-name <スタック名> --capabilities CAPABILITY_IAM

デプロイが完了すると・・・

今度はCloud Watch Logsに/aws/lambda/blog-HelloWorldFunction-1P6EPXH6K0463というロググループが作成されました!!

ログの保持期間も365日になっています。

CloudFormationのスタック詳細の方にもバッチリ表示されています。

成功です!!

最後に

CloudFormationのマクロ機能について見てきました。 うまく利用することで、SAMテンプレートを簡素化できる良い機能だと感じました。 色んなプロジェクトで共通の困りごとを吸い上げて、サーバーレス開発部標準のマクロを作ってみるのも面白いかもしれません。

誰かのお役に立てば幸いです。