Lambda-backed Custom Resourceを利用してCloudFormationにループ処理を擬似的に実装する

2016.11.21

はじめに

こんにちは、中山です。

今回はLambda-backed Custome Resourceを利用したTipsをご紹介します。この機能そのものについては上記ドキュメントや弊社の他エントリで詳しくご紹介しているので、そちらを参照していただければと思います。簡単に説明するとCloudFormation(以下CFn)の中でLambda関数を実行し、その結果をCFnのテンプレートの中で参照できる機能です。

CFnのあるあるパターン

例えばCFnでSSH用セキュリティグループを作成し、特定のIPアドレスからのみアクセスを許可したいとしましょう。セキュリティグループ関連のリソースは現時点(2016年11月21日)で以下の3つがあります。

各リソースの CidrIp プロパティを見ていただくと分かるのですが、指定可能な値のタイプが String になっています。つまり、IPアドレスをリストとして一度に複数指定できないので、SSHの送信元IPアドレスが複数ある場合、IPアドレス毎にセキュリティグループのルールを作成する必要があるということです。

CidrIp

Specifies a CIDR range.

Required: Conditional. You must specify only one of the following properties: CidrIp, DestinationPrefixListId, DestinationSecurityGroupId,or SourceSecurityGroupId.

Type: String

CidrIp

Specifies a CIDR range.

For an overview of CIDR ranges, go to the Wikipedia Tutorial.

Type: String

Required: Conditional. If you specify SourceSecurityGroupName, do not specify CidrIp.

Update requires: Replacement

CidrIp

CIDR range.

Required: Conditional. You must specify only one of the following properties: DestinationPrefixListId, DestinationSecurityGroupId, or CidrIp.

Type: String

Update requires: Replacement

マネジメントコンソールでセキュリティグループを作成する場合、カンマ区切りで複数の送信元IPアドレスを指定可能です。それが「何故か」CFnだとできません。実際に以下のようなパターンでスタックを作成するとエラーになります。

  • カンマ区切りで指定
Test1SG:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupDescription: "-"
    VpcId: !Ref VPCID
    SecurityGroupIngress:
      - IpProtocol: tcp
        FromPort: 22
        ToPort: 22
        CidrIp: !Join [ ",", [ "10.0.100.1/32", "10.0.100.2/32"] ]

結果: CIDR block 10.0.100.1/32,10.0.100.2/32 is malformed

  • スペースを1つ分入れてカンマ区切りで指定
Test2SG:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupDescription: "-"
    VpcId: !Ref VPCID
    SecurityGroupIngress:
      - IpProtocol: tcp
        FromPort: 22
        ToPort: 22
        CidrIp: !Join [ ", ", [ "10.0.100.1/32", "10.0.100.2/32"] ]

結果: CIDR block 10.0.100.1/32, 10.0.100.2/32 is malformed

  • スペース区切りで指定
Test3SG:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupDescription: "-"
    VpcId: !Ref VPCID
    SecurityGroupIngress:
      - IpProtocol: tcp
        FromPort: 22
        ToPort: 22
        CidrIp: !Join [ " ", [ "10.0.100.1/32", "10.0.100.2/32"] ]

結果: CIDR block 10.0.100.1/32 10.0.100.2/32 is malformed

  • リストで渡す(やけくそ)
Test4SG:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupDescription: "-"
    VpcId: !Ref VPCID
    SecurityGroupIngress:
      - IpProtocol: tcp
        FromPort: 22
        ToPort: 22
        CidrIp: [ "10.0.100.1/32", "10.0.100.2/32"]

結果: Value of property CidrIp must be of type String

正直これは厳しいです。例えば、制限したい送信元IPアドレスが数十個ある場合、以下のように重複しまくりの長大なテンプレートを作成する必要があります。

Test5SG:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupDescription: "-"
    VpcId: !Ref VPCID
    SecurityGroupIngress:
      - IpProtocol: tcp
        FromPort: 22
        ToPort: 22
        CidrIp: "10.0.100.1/32"
      - IpProtocol: tcp
        FromPort: 22
        ToPort: 22
        CidrIp: "10.0.100.2/32"
      - <snip>

こういったCFnの苦手な箇所を克服するため、winebarrel/Kumogata2のようにプログラマブルにCFnテンプレートを作成するツールが生まれたのだと思います(余談ですが、セキュリティグループを作成するのであればwinebarrel/piculetは便利なので弊社でもよく使っています)。こういったツールは大変便利なのですが、できれば標準の機能を利用して問題を解決したいと思っています。新しいツールを導入するとチーム内での学習コストの上昇、そのツールの将来性など考慮するパラメータが増えてしまうためです。

この問題点を克服する1つの方法が、今回ご紹介するLambda-backed Custome Resourceを利用した疑似ループの実装です。

疑似ループの実装

一言で説明すると、「インラインで記述したLambda関数内でCFnのプロパティを作成し、カスタムリソースでそれを参照する」という方法です。実物を見た方が分かりやすいと思います。例えばセキュリティグループのイングレスルールを作成するのであれば以下のようになります。

Resources:
  LambdaBasicExecRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: "IngressRuleHelper"
            Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"

  IngressRuleHelper:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          import cfnresponse
          def handler(event, context):
            ssh_ips = event["ResourceProperties"]["SSHIps"]
            response_data = {}
            response_data["Rules"] = [{"IpProtocol": "tcp", "FromPort": 22, "ToPort": 22, "CidrIp": ssh_ip} for ssh_ip in ssh_ips]
            cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)
      Handler: index.handler
      MemorySize: 128
      Role: !GetAtt LambdaBasicExecRole.Arn
      Runtime: python2.7
      Timeout: 10

  SSHIngressRules:
    Type: Custom::IngressRuleHelper
    Version: 1.0
    Properties:
      ServiceToken: !GetAtt IngressRuleHelper.Arn
      SSHIps: [ "10.0.100.1/32", "10.0.100.2/32" ]

それぞれ解説します。

  • Lambda関数用IAM Role

Lambda関数を利用するのであれば、それにひも付けるIAM Roleを作成する必要があります。今回のようなAWSリソースにアクセスする必要がないタイプのものは、マネージドポリシーである AWSLambdaBasicExecutionRole を利用すると自分でポリシーを管理する必要がなく便利です。

  • インラインで記述したLambda関数とカスタムリソースでの呼び出し

CFnでLambda関数のリソースを定義する方法は、S3に設置したコードを参照する方法と、今回のようにテンプレート中に直接埋め込んだ(インライン)方式の2種類があります。Lambda関数に標準で入っていない外部モジュールを利用する場合はS3にデプロイパッケージを設置する必要があります。しかし、今回のように数~十行程度のものであればインラインで記述した方が管理しやすいですし、S3上のLambdaでは標準で利用できないcfn-responseモジュールを使えるのでオススメです。

カスタムリソース(論理IDが SSHIngressRules のリソース)からLambda関数(論理IDが IngressRuleHelper )を呼び出しています。プロパティで指定している SSHIps にはSSHの送信元となるIPアドレスをリストで渡しています。この値はハンドラの引数である event 内に格納され、 ResourceProperties の値として参照可能です。カスタムリソースのリクエストタイプによって event で渡される内容は異なります。ドキュメントに詳しいですが、例えばリクエストタイプが Create の場合、以下のようなデータが event に格納されます。Lambda関数の実行ログはCloudWatch Logsに出力されるのでそこから確認可能です。

{
  "StackId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/********-****-****-****-************",
  "ResourceProperties": {
    "ServiceToken": "arn:aws:lambda:ap-northeast-1:************:function:test-IngressRuleHelper-************",
    "SSHIps": [
      "10.0.100.1/32",
      "10.0.100.2/32"
    ]
  },
  "ResponseURL": "<Pre-signed URL>",
  "RequestId": "********-****-****-****-************",
  "ResourceType": "Custom::IngressRuleHelper",
  "RequestType": "Create",
  "ServiceToken": "arn:aws:lambda:ap-northeast-1:************:function:test-IngressRuleHelper-************",
  "LogicalResourceId": "SSHIngressRules"
}

24行目の処理で ssh_ips 変数にカスタムリソースから渡されたIPアドレスをリストとして変数に格納し、次のリスト内包表記でイングレスルールを作成しています。ここが「擬似的なループ処理」と呼んでいる箇所になります。続いてイングレスルールを Rules をキーとした辞書型に格納し、最後の処理、 cfn-response モジュールの send メソッドの引数に渡すことで、CFnにデータを返却し、テンプレートから参照可能にしています。CFnには以下のようなデータが返却されます。

{
  "Status": "SUCCESS",
  "StackId": "arn:aws:cloudformation:ap-northeast-1:************:stack/test/********-****-****-****-************",
  "PhysicalResourceId": "2016/11/20/[$LATEST]********************************",
  "Reason": "See the details in CloudWatch Log Stream: 2016/11/20/[$LATEST]********************************",
  "RequestId": "********-****-****-****-************",
  "Data": {
    "Rules": [
      {
        "ToPort": 22,
        "IpProtocol": "tcp",
        "CidrIp": "10.0.100.1/32",
        "FromPort": 22
      },
      {
        "ToPort": 22,
        "IpProtocol": "tcp",
        "CidrIp": "10.0.100.2/32",
        "FromPort": 22
      }
    ]
  },
  "LogicalResourceId": "SSHIngressRules"
}
  • イングレスルールの参照

send メソッドで返却されたデータ中の Data に格納された値が参照可能です。内容としては、 Rules をキーとして、値にリストが格納されています。セキュリティグループのルール自体はリストで指定可能なので、以下のように !GetAtt 関数でそのまま参照すればOKです。

Test6SG:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupDescription: "-"
    VpcId: !Ref VPCID
    SecurityGroupIngress: !GetAtt SSHIngressRules.Rules

作成されたセキュリティグループを確認すると複数のルールが作成されていることを確認できます。

{
    "PrefixListIds": [],
    "FromPort": 22,
    "IpRanges": [
        {
            "CidrIp": "10.0.100.2/32"
        },
        {
            "CidrIp": "10.0.100.1/32"
        }
    ],
    "ToPort": 22,
    "IpProtocol": "tcp",
    "UserIdGroupPairs": []
}

まとめ

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

Lambda-backed Custom ResourceのTipsをご紹介しました。この機能は今回ご紹介したように非常に応用範囲の広い優れた機能です。みなさんもぜひ使ってみてください。

少し注意点を書いておきます。Lambda関数に文法エラーなどのミスがあると、それを呼び出すカスタムリソースが長時間 IN_PROGRESS の状態になるようです。Lambda側でタイムアウトの設定をしても何度もリトライしているようなので、デバッグに時間がかかりました。。。ドキュメントを見る限りカスタムリソース側で制御する方法が無いようなので改善して欲しい。。。より良いエラーハンドリングの方法が見つかったら追記しておきます。

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