AWS::Includeを利用してLambda-backed Custom Resourceをモジュール化する

AWS CloudFormation

はじめに

こんにちは、中山です。

以前、最近発表されたAWS::Includeについて以下のエントリでご紹介しました。

個人的に嬉しいアップデートの1つだったので早速使っています。いろいろと触っているのですが、この機能を利用することでLambda-backed Custom Resourceのモジュール化ができそうです。この場合の「モジュール化」とは、プログラミング言語のそれと同等の意味です。つまり、大抵の言語では importrequire などで別のプログラムを呼び出して利用することができますが、CloudFromationでも同等のことが可能になりました。以前までもネストスタックやクロススタック参照を利用することで、同じようなことはできたのですが、よりプログラミング言語的な意味で「モジュール化」できるようになったなと感じています。けっこう便利だと思うので、本エントリでご紹介したいと思います。

Lambda-backed Custom Resourceと組み合わせると嬉しい理由

CloudFromationを使っていると、Lambda-backed Custom Resourceは必須のものになっていると思います。CloudFromationは組み込み関数が少ないので、動的な処理(数値計算やAMI IDの検索など)をテンプレートで実施する必要がある場合、必ずこの機能のお世話になります。

とても便利な機能なのですが、個人的には以下の問題点があると思っています。

  • テンプレートで定義する必要のあるリソースが多い
  • インラインでLambda関数を定義している場合に別のテンプレートで再利用しにくい

それぞれ以下で解説します。

テンプレートで定義する必要のあるリソースが多い

Lambda-backed Custom Resourceを利用するためには最低限以下の3リソースを定義する必要があります。

  • Lambda関数本体を定義する AWS::Lambda::Function リソース
  • Lambda関数用のIAM Roleを定義する AWS::IAM::Role リソース
  • Lambda関数の結果をテンプレートで扱うためのカスタムリソース

例えば、パラメータで渡された数値をもとに計算する処理が必要だったとします。本来は数値計算だけをやりたいにも関わらず、こういったリソースを定義しなければならないとするとちょっと大げさな印象があります。私はTerraformもよく利用するのですが、こちらの場合データソースや組み込み関数を利用することでほぼ同等のことができます。内部的にLambda関数は当然使われてないので、IAM Roleを作る必要もありません。こういった事情もあり、ちょっと面倒くさいなと思ってました。

インラインでLambda関数を定義している場合に別のテンプレートで再利用しにくい

Lambda-backed Custom Resourceで定義するLambda関数のコードは以下の2つの方法で利用可能です。

  • S3にアップロードされたコードを参照する
  • インラインでテンプレート内に記載する

S3にアップロードしておけば別のテンプレートから参照できるため、その点は便利です。しかし、インラインの場合cfn-responseモジュールを使えるので、個人的にはインラインの方法をよく利用しています。ただ、テンプレートにインラインで定義してしまうと再利用性が低くなる(テンプレートにハードコードする必要がある)ので、悩みどころのある選択でした。

AWS::Includeとの組み合わせ

こういった問題点があったのですが、 AWS::Include を利用することで解決できそうです。今回は最新のAMI IDを検索するLambda-backed Custom Resourceを使って、どんな感じで利用するのかご紹介してみます。

呼び出し元( AWS::Include を利用する方)のテンプレートは以下のような感じです。

---
AWSTemplateFormatVersion: 2010-09-09
Description: Lambda-backed Custom Resource Demo

Parameters:
  ArtifactBucket:
    Type: String

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: General Configuration
        Parameters:
          - ArtifactBucket
    ParameterLabels:
      ArtifactBucket:
        default: Artifact Bucket

Resources:
  Fn::Transform:
    Name: AWS::Include
    Parameters:
      Location: !Sub s3://${ArtifactBucket}/latest-ami.yml

Outputs:
  ImageId:
    Value: !GetAtt CustomAMIInfo.ImageId

Resources セクションの下に AWS::Include で別のテンプレートをIncludeしています( latest-ami.yml という名前でS3に保存している)。

Outputs セクションに注目してください。このテンプレート内では定義していない、 CustomAMIInfo という論理IDを !GetAtt 関数で参照しています。この論理IDはIncludeしたテンプレート内で定義しているのですが、同じテンプレートで定義されているかのように参照可能です。

Lambda-backed Custom Resourceを利用しているテンプレートは以下のような感じです。

LambdaRole:
  Type: AWS::IAM::Role
  Properties:
    AssumeRolePolicyDocument:
      Version: "2012-10-17"
      Statement:
        - Sid: AssumeRolePolicy
          Effect: Allow
          Principal:
            Service: lambda.amazonaws.com
          Action: sts:AssumeRole
    Path: /
    ManagedPolicyArns:
      - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
    Policies:
      - PolicyName: EC2Access
        PolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action:
                - ec2:DescribeImages
              Resource: "*"
LambdaAMIInfo:
  Type: AWS::Lambda::Function
  Properties:
    Handler: index.handler
    Role:
      Fn::GetAtt: LambdaRole.Arn
    Runtime: python2.7
    Timeout: 20
    Code:
      ZipFile: |
        from __future__ import print_function
        from botocore.exceptions import ClientError
        import cfnresponse
        import boto3
        def handler(event, context):
            if event['RequestType'] == 'Delete':
                cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
            response_data = {}
            filters = [
                {'Name': 'architecture', 'Values': ['x86_64']},
                {'Name': 'root-device-type', 'Values': ['ebs']},
                {'Name': 'name', 'Values': ['amzn-ami-hvm-*']},
                {'Name': 'virtualization-type', 'Values': ['hvm']},
                {'Name': 'block-device-mapping.volume-type', 'Values': ['gp2']}]
            try:
                images = boto3.client('ec2').describe_images(Owners=['amazon'], Filters=filters)
            except ClientError as e:
                print(e['Error']['Message'])
                cfnresponse.send(event, context, cfnresponse.FAILED, {})
            for i in sorted([image for image in images['Images']], key=lambda x: x['Name']):
                if i['Name'].lower().count('beta') > 0 or i['Name'].lower().count('.rc') > 0:
                    continue
                response_data['ImageId'] = i['ImageId']
            cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)
CustomAMIInfo:
  Type: Custom::AMIInfo
  Version: 1.0
  Properties:
    ServiceToken:
      Fn::GetAtt: LambdaAMIInfo.Arn

テンプレートで定義しているリソース自体は特に変わったところは無いのですが、リソースが Resources セクション内に定義されていない点に注目してください。このテンプレートは先程のテンプレートで呼び出されます。呼び出し元で Resources を定義しているため、こちらのテンプレートでは不要になるというわけです。

あと、少しハマったのですが AWS::IAM::Role リソースの Version プロパティに渡す値は '" で囲まないと以下のようなエラーになりました。

The policy must contain a valid version string

AWS::Include を利用していないテンプレートだと特にエラーは出力されないので、内部的に別のバリデーションが実施されているのかもしれません。

動作確認

テンプレートをいつも通り作成したら、結果を確認してみます。

  • 3つのリソースが作成されていることを確認
$ aws cloudformation list-stack-resources \
  --stack-name <_YOUR_STACK_NAME_> \
  --query 'StackResourceSummaries[].[ResourceStatus,LogicalResourceId]' \
  --output text
CREATE_COMPLETE CustomAMIInfo
CREATE_COMPLETE LambdaAMIInfo
CREATE_COMPLETE LambdaRole
  • アウトプットにAMI IDが表示されることを確認
$ aws cloudformation describe-stacks \
  --stack-name <_YOUR_STACK_NAME_> \
  --query 'Stacks[].Outputs' \
  --output text
ImageId ami-56d4ad31

まとめ

いかがだったでしょうか。

AWS::Include をLambda-backed Custom Resourceと組み合わせてモジュール化する方法をご紹介しました。これで再利用性のあるテンプレートの開発ができそうですね。

ただ、個人的にはGitHubにアップロードしたテンプレートをIncludeできる機能がほしいなと思ってます。広く一般に公開するテンプレートを作成する場合、S3に保存する必要があるとどのバケットでホストするのか、誰がコスト負担するのかなどを考慮する必要があるからです。AWSさん検討お願いします!!!!1111

本エントリがみなさんの参考になれば幸いに思います。

AWS Cloud Roadshow 2017 福岡