ALBのバックエンドで起動するLambdaが複数ある場合、CloudFormationテンプレートはどのリソース単位で分けると都合が良いのか考えてみた

2021.12.10

ALBのバックエンドには処理毎に分れたLambdaが複数あります。ALBと複数のLambdaを1つのCloudFormationテンプレートにまとめることもできます。しかし、Lambda毎に開発しているチームが異なるなどでLambda単位で管理を分けたいです。ALBは独立して管理し、Lambdaは各開発チームで管理できるようにしたい場合、どこのリソースでテンプレートを分割すると都合がよいのか考えてみました。SAMでLambdaをデプロイする構成の一例として紹介します。

ALBはCloudFormationのテンプレートで管理し、各種LambdaはSAMテンプレートで管理しAWS SAMを利用してデプロイする状況を想定します。

まとめ

SAMで管理する個々のLambdaをELBのバックエンドに追加していくことが予想される場合、以下の箇所でテンプレートを区切りました。

  • ALB本体リスナーのベースとなる部分
  • 各Lambda関数リスナールールターゲットグループ

sam deployで作成したLambdaを既存ELBのリスナーに紐づけていけることを確認しました。

テンプレートの単位

青、緑色背景は個々にSAMで管理する単位、それ以外はALBのCloudFormationテンプレートで管理。

検証環境

項目
AWS SAM 1.36.0

本検証で使用したALBのCloudFormationテンプレート、SAMで管理するLambda一式は以下においてあります。

考えてみた

ALBのバックエンドで起動するLambdaの構成は構成図でみる分にはシンプルです。ALBと各Lambda単位にテンプレート分ければ済みそうと一見思えます。

もう少し細かくみたいのでリソース単位に分解します。こうするとALBに関連するリソースが地味にあって悩ましくなってきますリスナー、リスナールール、ターゲットグループ、この辺りのまとめ方が非常に悩ましいです。

SAMでLambdaをデプロイすることを考慮した結果

  • ALB本体とリスナー
  • Lambda関数とリスナールール、ターゲットグループ

上記のリソースを1単位として管理するとキレイな分け方ができるのではないかと思い、sam deployで既存ALBにLambdaの紐付け実現できるのか確認してみました。

ELBとリスナー作成

ELB本体とLambdaを紐付けていくリスナーをCloudFormationで作成します。リスナーのデフォルトルールはELB側に持たせます。 作成したリスナーはエクスポートし、Lambdaを作成するSAMテンプレートでクロススタック参照して利用することにします。

alb/alb.yaml

AWSTemplateFormatVersion: "2010-09-09"
Description: ALB*1

Parameters:
  ProjectName:
    Description: Project Name
    Type: String
    Default: unnamed
  Environment:
    Description: Environment
    Type: String
    Default: dev
    AllowedValues:
      - prod
      - dev
      - stg
  VPCID:
    Type: AWS::EC2::VPC::Id
  PublicSubnet1:
    Description: "ELB Subnet 1st"
    Type: AWS::EC2::Subnet::Id
  PublicSubnet2:
    Description: "ELB Subnet 2nd"
    Type: AWS::EC2::Subnet::Id

Resources:
  # --------------------------------------------
  # ELB
  # --------------------------------------------
  ELB1:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Type: "application"
      Name: !Sub ${ProjectName}-${Environment}-elb
      Scheme: "internet-facing"
      SecurityGroups:
        - !Ref SecurityGroup1
      Subnets:
        - !Ref PublicSubnet1
        - !Ref PublicSubnet2
      IpAddressType: "ipv4"
      LoadBalancerAttributes:
        - Key: "deletion_protection.enabled"
          Value: "false"
      Tags:
        - Key: Name
          Value: !Sub ${ProjectName}-${Environment}-elb

  ELBListener1:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - Type: fixed-response
          FixedResponseConfig:
            StatusCode: 404
            MessageBody: Not Found.
            ContentType: text/plain
      LoadBalancerArn: !Ref ELB1
      Port: 80
      Protocol: HTTP

  SecurityGroup1:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub ${ProjectName}-${Environment}-elb-sg
      GroupDescription: ELB Security Group
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: "0.0.0.0/0"
          Description: "Access from Public"
      VpcId: !Ref VPCID
      Tags:
        - Key: Name
          Value: !Sub ${ProjectName}-${Environment}-elb-sg

Outputs:
  Listener1:
    Description: "Linstener  ARN"
    Value: !GetAtt ELBListener1.ListenerArn
    Export:
      Name: !Sub ${AWS::StackName}-Listener1

ベースとなるリスナーが作成されました。

リスナールールは固定レスポンスを返すデフォルトルールのみ作成しています。

アクセステスト

ELBにアクセスすると固定レスポンスのNot Found.が返ってくるだけのELBが完成しました。

$ curl http://sample-dev-elb-1162918276.ap-northeast-1.elb.amazonaws.com
Not Found.⏎

1個目のLambda作成

LambdaはSAMで管理します。Lambda関数本体と、リスナールール、ターゲットグループを同時に作成します。その他、ELBからLambdaを呼び出すために必要なリソースベースポリシー(AWS::Lambda::Permission)も作成しLambdaに設定します。そして、SAMテンプレートで新規作成するリスナールールを既存リスナーに紐付けることでELBと連携させます

ポイント

  • リスナールールのPriorityは他のLambdaをデプロイするときに重複しないように管理が必要

first-lambda/template.yaml

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  first-lambda

  Sample SAM Template for first-lambda

Resources:
  Function:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: "first-lambda"
      CodeUri: ./first-lambda
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - arm64
      Timeout: 5

  LambdaPermission:
    Type: "AWS::Lambda::Permission"
    Properties:
      Action: "lambda:InvokeFunction"
      FunctionName: !GetAtt Function.Arn
      Principal: "elasticloadbalancing.amazonaws.com"
      SourceArn: !Sub "arn:aws:elasticloadbalancing:${AWS::Region}:${AWS::AccountId}:*"

  # ---------------------------
  # Resources associated with the ALB
  # ---------------------------
  TargetGroup1:
    Type: "AWS::ElasticLoadBalancingV2::TargetGroup"
    Properties:
      HealthCheckIntervalSeconds: 15
      HealthCheckPath: "/"
      HealthCheckTimeoutSeconds: 10
      UnhealthyThresholdCount: 2
      TargetType: "lambda"
      Matcher:
        HttpCode: "200"
      HealthyThresholdCount: 2
      Name: "first-lambda-tg"
      HealthCheckEnabled: false
      TargetGroupAttributes:
        - Key: "lambda.multi_value_headers.enabled"
          Value: "false"
      Targets:
        - Id: !GetAtt Function.Arn
          AvailabilityZone: "all"

  ListenerRule:
    Type: "AWS::ElasticLoadBalancingV2::ListenerRule"
    Properties:
      Priority: "1"
      ListenerArn: !ImportValue blog-alb-Listener1
      Conditions:
        - Field: "path-pattern"
          Values:
            - "/v1/lambda1"
      Actions:
        - Type: "forward"
          TargetGroupArn: !Ref TargetGroup1
          Order: 1
          ForwardConfig:
            TargetGroups:
              - TargetGroupArn: !Ref TargetGroup1
                Weight: 1
            TargetGroupStickinessConfig:
              Enabled: false

Outputs:
  Function:
    Description: "Lambda Function ARN"
    Value: !GetAtt Function.Arn
  FunctionIamRole:
    Description: "Implicit IAM Role created for function"
    Value: !GetAtt FunctionRole.Arn

ELBから呼び出されるLambdaはメッセージをレスポンスに返すだけです。

first-lambda/first-lambda/app.py

import json


def lambda_handler(event, context):
    return {
        "statusCode": 200,
        "body": json.dumps({
            "message": "I am FIRST Lambda back of ALB. ",
        }),
    }

既存リスナーに新規作成のリスナールールとLambdaを作成するSAMテンプレートをビルドしてAWSへデプロイします。

$ sam build
  ...snip...
$ sam deploy --guided
  ...snip...
	Stack Name [sam-app]: first-lambda
	AWS Region [us-east-1]: ap-northeast-1
	#Shows you resources changes to be deployed and require a 'Y' to initiate deploy
	Confirm changes before deploy [y/N]: y
	#SAM needs permission to be able to create roles to connect to the resources in your template
	Allow SAM CLI IAM role creation [Y/n]: y
	#Preserves the state of previously provisioned resources when an operation fails
	Disable rollback [y/N]: n
	Save arguments to configuration file [Y/n]: y
	SAM configuration file [samconfig.toml]:
	SAM configuration environment [default]:
  ...snip...
  Successfully created/updated stack - first-lambda in ap-northeast-1

デプロイが完了すると、既存リスナーに新たなリスナールールが追加されました。

アクセステスト

ALBのURLに対してリスナールールで指定したパス(/v1/lambda1)にアクセスすると、Lambdaからレスポンスが返ってきます。既存のALBにsam deployしたLambdaを上手いこと連携できました。

$ curl http://sample-dev-elb-1162918276.ap-northeast-1.elb.amazonaws.com/v1/lambda1
{"message": "I am FIRST Lambda back of ALB. "}⏎

2個目のLambda作成

重要なところはリスナールールのプライオリティを重複させないことです。1個目のLambdaとほぼ同様のSAMテンプレートを作成しました。2個目のLambdaを呼ぶパスはv1/lambda2としました。

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  second-lambda

  Sample SAM Template for second-lambda

Resources:
  Function:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: "second-lambda"
      CodeUri: ./second-lambda
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - arm64
      Timeout: 5

  LambdaPermission:
    Type: "AWS::Lambda::Permission"
    Properties:
      Action: "lambda:InvokeFunction"
      FunctionName: !GetAtt Function.Arn
      Principal: "elasticloadbalancing.amazonaws.com"
      SourceArn: !Sub "arn:aws:elasticloadbalancing:${AWS::Region}:${AWS::AccountId}:*"

  # ---------------------------
  # Resources associated with the Internal ALB
  # ---------------------------
  TargetGroup1:
    Type: "AWS::ElasticLoadBalancingV2::TargetGroup"
    Properties:
      HealthCheckIntervalSeconds: 15
      HealthCheckPath: "/"
      HealthCheckTimeoutSeconds: 10
      UnhealthyThresholdCount: 2
      TargetType: "lambda"
      Matcher:
        HttpCode: "200"
      HealthyThresholdCount: 2
      Name: "second-lambda-tg"
      HealthCheckEnabled: false
      TargetGroupAttributes:
        - Key: "lambda.multi_value_headers.enabled"
          Value: "false"
      Targets:
        - Id: !GetAtt Function.Arn
          AvailabilityZone: "all"

  ListenerRule:
    Type: "AWS::ElasticLoadBalancingV2::ListenerRule"
    Properties:
      Priority: "2"
      ListenerArn: !ImportValue blog-alb-Listener1
      Conditions:
        - Field: "path-pattern"
          Values:
            - "/v1/lambda2"
      Actions:
        - Type: "forward"
          TargetGroupArn: !Ref TargetGroup1
          Order: 1
          ForwardConfig:
            TargetGroups:
              - TargetGroupArn: !Ref TargetGroup1
                Weight: 1
            TargetGroupStickinessConfig:
              Enabled: false

Outputs:
  Function:
    Description: "Lambda Function ARN"
    Value: !GetAtt Function.Arn
  FunctionIamRole:
    Description: "Implicit IAM Role created for function"
    Value: !GetAtt FunctionRole.Arn

Lambdaのコードはレスポンスのメッセージを変更しただけなので省略します。リポジトリを参考ください。

またしても既存リスナーに新規のリスナールールと2個目のLambdaを作成するSAMテンプレートをビルドしてAWSへデプロイします。

$ sam build
  ...snip...
$ sam deploy --guided
  ...snip...
	Stack Name [sam-app]: second-lambda
	AWS Region [us-east-1]: ap-northeast-1
	#Shows you resources changes to be deployed and require a 'Y' to initiate deploy
	Confirm changes before deploy [y/N]: y
	#SAM needs permission to be able to create roles to connect to the resources in your template
	Allow SAM CLI IAM role creation [Y/n]: y
	#Preserves the state of previously provisioned resources when an operation fails
	Disable rollback [y/N]: n
	Save arguments to configuration file [Y/n]: y
	SAM configuration file [samconfig.toml]:
	SAM configuration environment [default]:
  ...snip...
  Successfully created/updated stack - second-lambda in ap-northeast-1

デプロイが完了すると、2個目のリスナールールが新たに追加されました。

アクセステスト

ALBのURLに対して2個目のリスナールールで指定したパスにアクセスするとLambdaからのレスポンスが返ってきます。ALBから呼び出したいLambdaが増えた場合も対応していけそうです。

$ curl http://sample-dev-elb-1162918276.ap-northeast-1.elb.amazonaws.com/v1/lambda2
{"message": "I am SECOND Lambda back of ALB!!!!!! "}⏎

まとめ

ELBのリスナーに対してSAM管理のLambdaを紐付けて、ELBのパス違いで別々のLabmdaを呼び出すことを確認できました。

$ curl http://sample-dev-elb-1162918276.ap-northeast-1.elb.amazonaws.com/v1/lambda1
{"message": "I am FIRST Lambda back of ALB. "}⏎

$ curl http://sample-dev-elb-1162918276.ap-northeast-1.elb.amazonaws.com/v1/lambda2
{"message": "I am SECOND Lambda back of ALB!!!!!! "}⏎

先にELBを作成しSAMで管理するLambdaを次々に生み出す場合は、以下の箇所でテンプレートを区切るとキレイに分けることができたのではないでしょうか。

  • ALB本体とリスナーのベース部分
  • 各Lambda関数とリスナールール、ターゲットグループ

おわりに

テンプレートで管理する単位はライフサイクルに寄ってきます。最適解は都度考えないといけないのですが、どのようなパターンが考えられるのかいくつかの参考例を見た上で検討したかったのですがサンプル数が少なかったので一例としてあげました。なにかの参考になれば幸いです。