VPC Lambdaを実現しているAWS内の裏側と設計の心得三箇条

こんにちは。SCSKのふくちーぬです。

皆さんは、プライベート(閉域網)環境下でのLambdaを利用したことありますでしょうか。セキュリティに厳しい環境下でLambdaを利用する場合は、VPC設定を施したLambdaを利用する機会があると思います。

今回は、インターネットに接していないVPC Lambdaの設計ポイントをお話しします。また、VPC Lambdaを実現しているAWS内の裏側もご紹介します。

VPC Lambdaとは

LambdaにVPC設定を施すことで、顧客VPC内のサブネット上に足を出すことができて(ENIが作成されます)、RDS等のプライベートなリソースにアクセスをすることができます。VPC設定をするためには、VPC・サブネット・セキュリティグループが必要なため、EC2同様のネットワーク設計を行う必要があります。

VPC Lambdaをプライベートサブネット内に配置することで、VPCエンドポイント経由でのプライベートアクセスも可能です。もしくは、パブリックサブネットのNAT Gateway経由でのインターネットアクセスも可能です。

VPC Lambdaの裏側について

以前までのVPC Lambdaの構成

実は遡ること2019年8月。以前までのVPC Lambdaは、以下のような構成でした。

VPC Lambdaを作成するとAWS Lambdaサービスが持つVPC内にLambdaが作成されます。顧客が持つVPC内とクロスアカウント接続することで、Lambdaが顧客VPC内にアクセスすることができます。

VPC Lambda実行時に顧客VPC内にENIが作成されてアタッチが行われてしまい、それが何対ものになるとサブネット内のIPアドレスを消費してしまいIP枯渇に直面してしまいます。アタッチに時間がかかるとコールドスタートが発生しVPC Lambdaのコード部分の実行に時間がかかり、想定よりも長い処理時間となってしまうという問題も抱えていました。

現在のVPC Lambda

現在のVPC Lambdaですが、クロスアカウント接続している点は以前と同様となります。VPC Lambdaを作成するとAWS Lambdaサービスが持つVPC内にLambdaが作成されます。顧客が持つVPC内とクロスアカウント接続することで、Lambdaが顧客VPC内にアクセスすることができます。

あくまで、VPC Lambdaは顧客VPC内のサブネットには存在しませんので、忘れないでください。なんだか構成がかなりシンプルになりましたね。Lambdaサービスが持つVPC内に、”VPC to VPC NAT”という機器が配置されています。この機器は、Hyperplaneという技術で実現されており、多数のリクエストの負荷を吸収してくれます。負荷分散機能を有しているため、NAT GatewayやNetwork Load Balancerにも利用されている技術となります。VPC Lambdaを利用する際にはこの機器の存在を気にすることなく利用できるので、「何となく裏側でマッピング処理や負荷分散処理を頑張っているのだな~」と思うくらいで良いかとおもいます。

VPC Lambdaを作成すると顧客VPC内には、Hyperplane ENIと呼ばれる特殊なENIが作成されます。Hyperplane ENIは、サブネットとセキュリティグループのペアで作成されます。なんと他のVPC LambdaもこのHyperplane ENIを利用できます!共用で利用できるため、サブネット内のIPアドレス枯渇の懸念もほとんどなくなりました。またLambdaの作成時にENIが作成されるので、最初だけENIの作成に時間がかかりますが、それ以降に作成されたLambdaは既存のENIを利用できるため迅速に関数を実行することができます。

構成図

プライベートサブネットをAZ-a、AZ-cに1つずつ配置してます。VPC LambdaをマルチAZ構成で3つ作成します。

Lambda所有VPC内には、”VPC to VPC NAT”が配置されます。別名は、”V2V”,”Hyperplane NAT”と呼ばれる機器です。

セキュリティグループを2つ作成します。アウトバウンドルールがフルアクセスなものと、VPC内に許可するもの計2種類です。

顧客所有VPC内には、サブネットとセキュリティグループのペアに応じたHyperplane ENIが作成されます。

この例では、”test-lambda-02″及び”test-lambda-03″は同じHyperplane ENIを共有することになります。

環境準備

VPCの作成

VPC・プライベートサブネット・ルートテーブルを作成するCloudFormationテンプレートです。

以下のテンプレートをデプロイしてください。

AWSTemplateFormatVersion: 2010-09-09
Description: cfn vpc
Resources:
# ------------------------------------------------------------#
# VPC
# ------------------------------------------------------------#
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: PrivateVPC
# ------------------------------------------------------------#
# Subnet
# ------------------------------------------------------------#
  PrivateSubnet1a:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.1.0/24
      AvailabilityZone: ap-northeast-1a
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Name
          Value: PrivateSubnet-1a

  PrivateSubnet1c:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.2.0/24
      AvailabilityZone: ap-northeast-1c
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Name
          Value: PrivateSubnet-1c
# ------------------------------------------------------------#
# RouteTable
# ------------------------------------------------------------#
  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: PrivateRouteTable

  PrivateSubnet1aAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet1a
      RouteTableId: !Ref PrivateRouteTable

  PrivateSubnet1cAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet1c
      RouteTableId: !Ref PrivateRouteTable 

デプロイが完了したら、VPCのID及び各サブネットのIDをメモとして控えておいてください。次のテンプレートのデプロイ時に使用します。

利用可能なIPアドレス数の確認(その1)

サブネット内で利用できるIPアドレス数を確認しておきましょう。

256(2^8) – 5(AWSで予約済みIP) = 251

AWSで予約済みの5つのIPアドレスを引いた、合計251個のIPアドレスが利用できますね。

VPC Lambdaの作成

VPCのIDとサブネットのIDを利用して、以下のテンプレートをデプロイしてください。
VPC Lambda作成時に、Hyperplane ENIの作成を行っているためデプロイ完了まで数分ほど時間がかかると思います。
AWSTemplateFormatVersion: 2010-09-09
Description: cfn lambda
Parameters:
  VpcID:
    Type: String
    Description: Vpc id 
  PrivateSubnet1aID:
    Type: String
    Description: Subnet1a id
  PrivateSubnet1cID:
    Type: String
    Description: Subnet1c id
Resources:
# ------------------------------------------------------------#
# Security Group
# ------------------------------------------------------------#
  LambdaSecurityGrouptoFull:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: outbound-full-sg
      GroupDescription: Security Group for Lambda to full
      VpcId: !Ref VpcID
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: 0.0.0.0/0

  LambdaSecurityGrouptoVPC:
    Type: 'AWS::EC2::SecurityGroup'
    Properties:
      GroupName: outbound-vpc-sg
      GroupDescription: Security Group for Lambda to vpc
      VpcId: !Ref VpcID
      SecurityGroupEgress:
        - IpProtocol: tcp
          FromPort: 0
          ToPort: 65535
          CidrIp: 10.0.0.0/16
# ------------------------------------------------------------#
# Lambda
# ------------------------------------------------------------#
  LambdaFunction01:
    Type: 'AWS::Lambda::Function'
    Properties:
      Handler: index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      FunctionName: test-lambda-01
      Runtime: python3.11
      Timeout: 3
      MemorySize: 128
      Code:
        ZipFile: |
          def lambda_handler(event, context):
              print('Hello, World!')
              return {
                  'statusCode': 200,
                  'body': 'Hello, World!'
              }      
      VpcConfig:
        SecurityGroupIds:
          - !Ref LambdaSecurityGrouptoFull
        SubnetIds:
          - !Ref PrivateSubnet1aID
          - !Ref PrivateSubnet1cID

  LambdaFunction02:
    Type: 'AWS::Lambda::Function'
    Properties:
      Handler: index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      FunctionName: test-lambda-02
      Runtime: python3.11
      Timeout: 3
      MemorySize: 128
      Code:
        ZipFile: |
          def lambda_handler(event, context):
              print('Hello, World!')
              return {
                  'statusCode': 200,
                  'body': 'Hello, World!'
              }      
      VpcConfig:
        SecurityGroupIds:
          - !Ref LambdaSecurityGrouptoVPC
        SubnetIds:
          - !Ref PrivateSubnet1aID
          - !Ref PrivateSubnet1cID

  LambdaFunction03:
    Type: 'AWS::Lambda::Function'
    Properties:
      Handler: index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      FunctionName: test-lambda-03
      Runtime: python3.11
      Timeout: 3
      MemorySize: 128
      Code:
        ZipFile: |
          def lambda_handler(event, context):
              print('Hello, World!')
              return {
                  'statusCode': 200,
                  'body': 'Hello, World!'
              }      
      VpcConfig:
        SecurityGroupIds:
          - !Ref LambdaSecurityGrouptoVPC
        SubnetIds:
          - !Ref PrivateSubnet1aID
          - !Ref PrivateSubnet1cID
# ------------------------------------------------------------#
# IAM Role
# ------------------------------------------------------------#
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: IRL-Lambda
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: 'Allow'
            Principal:
              Service: 'lambda.amazonaws.com'
            Action: 'sts:AssumeRole'
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole

利用可能なIPアドレス数の確認(その2)

Hyperplane ENIが作成されたために、サブネット内のIPアドレス数が減っているはずです。想定通りの数になっているか確認してみます。

PrivateSubnet-1a・PrivateSubnet-1c共にIPアドレスが2つ消費されていることが分かりますね。

Hyperplane ENIの確認

マネジメントコンソールでのENIの確認

マネジメントコンソールから、ENIを確認します。

4つのENIが確認できましたね。サブネットとセキュリティグループのペアに応じて作成されていることが分かります。

次に、インターフェイスのタイプを確認します。普段EC2等を作成した場合は、インターフェイスのタイプが”Elastic Network Interface”となります。

ここでは、”lambda”になっていることが注目すべき点です。つまり、こいつこそがHyperplane ENIであるのです。

後に使用するため、セキュリティグループが異なる2つのENIのIDを控えておいてください。

ENI Finderの利用

スクリプトを動かすために、AWS CLIとjqコマンドが必要となります。

今回は、パブリックサブネット上に配置されたCloud9から実行します。Cloud9の準備については、こちらを参考にしてください。

Cloud9上で以下のコマンドを実行します。jqをインストールします。

sudo yum install jq

jqがインストール済か確かめます。

yum list installed | grep jq

無事インストールができました。

以下のスクリプト(ファイル名:findEniAssociations)を任意のディレクトリに配置してください。

#!/bin/bash

# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
# SPDX-License-Identifier: MIT-0

# jq is required for this script to work, exit if it isn't present
which jq &> /dev/null
if [ $? -ne 0 ]
then
  echo "The json parsing package 'jq' is required to run this script, please install it before continuing"
  exit 1
fi

set -e #fail if any of our subcommands fail
printf "This script is for determining why an ENI that is managed by AWS Lambda has not been deleted.\n\n"

# take the region and the ENI id as parameters
POSITIONAL=()
while [[ $# -gt 0 ]]
do
key="$1"

case $key in
  --eni)
  ENI="$2"
  shift # past argument
  shift # past value
  ;;
  --region)
  REGION="$2"
  shift # past argument
  shift # past value
  ;;
esac
done
set -- "${POSITIONAL[@]}" # restore positional parameters

# Both parameters are required, fail if they are absent
if [ -z $ENI ] && [ -z $REGION ];
then
  echo "Both --eni and --region are required"
  exit 1
elif [ -z $ENI ]
then
  echo "--eni is required"
  exit 1
elif [ -z $REGION ]
then
  echo "--region is required"
  exit 1
fi

# search for the ENI to get the subnet and security group(s) it uses
METADATA="$(aws ec2 describe-network-interfaces --network-interface-ids ${ENI} --filters Name=network-interface-id,Values=${ENI} --region ${REGION} --output json --query 'NetworkInterfaces[0].{Subnet:SubnetId,SecurityGroups:Groups[*].GroupId}')"

read Subnet < <(echo $METADATA | jq -ar '.Subnet')
SecurityGroups=()
for row in $(echo $METADATA | jq -ar '.SecurityGroups[]')
do
  SecurityGroups+=(${row})
done
# Sort the list of SGs, so that we can easily compare with the list from a Lambda function
IFS=$'\n' SortedSGs=($(sort <<<"${SecurityGroups[*]}"))
unset IFS
#convert Subnet to "echo-able", if $Subnet is used directly, GitBash skips the call outputting: ' using Security Groups "sg-012345example" '
SUBNET_STRING=$(echo $Subnet)
echo "Found "${ENI}" with $SUBNET_STRING using Security Groups" ${SortedSGs[@]}
echo "Searching for Lambda function versions using "$SUBNET_STRING" and Security Groups" ${SortedSGs[@]}"..."

# Get all the Lambda functions in an account that are using the same subnet, including versions
Functions=()
Response="$(aws lambda list-functions --function-version ALL --max-items 1000 --region ${REGION} --output json --query '{"NextToken": NextToken, "VpcConfigsByFunction": Functions[?VpcConfig!=`null` && VpcConfig.SubnetIds!=`[]`] | [].{Arn:FunctionArn, Subnets:VpcConfig.SubnetIds, SecurityGroups: VpcConfig.SecurityGroupIds} | [?contains(Subnets, `'$Subnet'`) == `true`] }')"
# Find functions using the same subnet and security group as target ENI. Use paginated calls to enumerate all functions.
while : ; do
    NextToken=$(echo $Response | jq '.NextToken')
    for row in $(echo $Response | jq -c -r '.VpcConfigsByFunction[]')
    do
        Functions+=(${row})
    done
    [[ $NextToken != "null" ]] || break
    Response="$(aws lambda list-functions --function-version ALL --max-items 1000 --starting-token $NextToken --region ${REGION} --output json --query '{"NextToken": NextToken, "VpcConfigsByFunction": Functions[?VpcConfig!=`null` && VpcConfig.SubnetIds!=`[]`] | [].{Arn:FunctionArn, Subnets:VpcConfig.SubnetIds, SecurityGroups: VpcConfig.SecurityGroupIds} | [?contains(Subnets, `'$Subnet'`) == `true`] }')"
done
# check if we got any functions with this subnet at all
if [ $(echo "${#Functions[@]}") -eq 0 ]
then
  printf "\nNo Lambda functions or versions found that were using the same subnet as this ENI.\nIf this ENI is not deleted automatically in the next 24 hours then it may be 'stuck'. If the ENI will not allow you to delete it manually after 24 hours then please contact AWS support and send them the output of this script.\n"
  exit 0
fi
Results=()
for each in "${Functions[@]}"
do
  # Check if there are any functions that match the security groups of the ENI
  LambdaSGs=()
  for row in $(echo "$each" | jq -ar '.SecurityGroups[]')
  do
    LambdaSGs+=(${row})
  done
  # Need both lists of SGs sorted for easy comparison
  IFS=$'\n' SortedLambdaSGs=($(sort <<<"${LambdaSGs[*]}"))
  unset IFS
  set +e # diff is wierd and returns exit code 1 if the inputs differ, so we need to temporarily disable parent script failure on non-zero exit codes
  diff=$(diff <(printf "%s\n" "${SortedSGs[@]}") <(printf "%s\n" "${SortedLambdaSGs[@]}"))
  set -e
  if [[ -z "$diff" ]]; then
    Results+=($(echo "$each" | jq -r '.Arn'))
  fi
done
if [ ${#Results[@]} -eq 0 ]; # if we didn't find anything then we need to check if the ENI was modified, as Lambda will still be using it, even if the SGs no longer match
then
  printf "No functions or versions found with this subnet/security group combination. Searching for manual changes made to the ENI...\n"
  Changes="$(aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=ModifyNetworkInterfaceAttribute --region ${REGION} --output json --query 'Events[] | [?contains(CloudTrailEvent, `'$ENI'`) == `true` && contains(CloudTrailEvent, `groupId`) == `true` && contains(CloudTrailEvent, `errorMessage`) == `false`]')"
  if [ "$(echo $Changes | jq -r 'length')" -gt 0 ]
  then
    printf "\nChanges were made to this ENI's security group outside of the Lambda control plane. Any Lambda function that pointed to this ENI originally will still be using it, even with changes on the ENI side.\n\nThe following functions share the same subnet as this ENI. Any of them that are will need to be disassociated/deleted before Lambda will clean up this ENI. Each of these could potentially be using this ENI:\n"
    for each in "${Functions[@]}"
    do
      echo "$each" | jq -r '.Arn'
    done
  else
    printf "\nNo manual changes to the ENI found. ENIs may take up to 20 minutes to be deleted. If this ENI is not deleted automatically in the next 24 hours then it may be 'stuck'. If IAM roles associated with a VPC Lambda function are deleted before the ENI is deleted, Lambda will not be able to complete the clean-up of the ENI. If the ENI will not allow you to delete it manually after 24 hours then please contact AWS support and send them the output of this script.\n"
  fi
else
  printf "\nThe following function version(s) use the same subnet and security groups as "${ENI}". They will need to be disassociated/deleted before Lambda will clean up this ENI:\n"
  printf "%s\n" "${Results[@]}"
fi 

ファイルに対して、権限の付与を実施します。

chmod +x findEniAssociations

引数には、セキュリティグループとして”outbound-full-sg”を利用しているENIと対象リージョンを指定します。

./findEniAssociations --eni eni-0704818dd4675e034 --region ap-northeast-1

以下のように、”test-lambda-01″のみ利用していることが分かります。

同様に、セキュリティグループとして”outbound-full-vpc”を利用しているENIについても実施してみます。

./findEniAssociations --eni eni-0539d0e4405d91ff5 --region ap-northeast-1

以下のように、”test-lambda-02″及び”test-lambda-03″が同一のENIを利用していることが分かります。複数のVPC LambdaがHyperplane ENIを共用利用していることが分かります。

ENIの削除

VPC LambdaのENIが削除されるタイミングを検証します。

以下のテンプレートを利用して、Lambdaのスタックを更新してください。”test-lambda-01″及び”test-lambda-02″の記述を削除しています。

AWSTemplateFormatVersion: 2010-09-09
Description: cfn lambda
Parameters:
  VpcID:
    Type: String
    Description: Vpc id
  PrivateSubnet1aID:
    Type: String
    Description: Subnet1a id
  PrivateSubnet1cID:
    Type: String
    Description: Subnet1c id
Resources:
# ------------------------------------------------------------#
# Security Group
# ------------------------------------------------------------#
  LambdaSecurityGrouptoFull:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: outbound-full-sg
      GroupDescription: Security Group for Lambda to full
      VpcId: !Ref VpcID
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: 0.0.0.0/0

  LambdaSecurityGrouptoVPC:
    Type: 'AWS::EC2::SecurityGroup'
    Properties:
      GroupName: outbound-vpc-sg
      GroupDescription: Security Group for Lambda to vpc
      VpcId: !Ref VpcID
      SecurityGroupEgress:
        - IpProtocol: tcp
          FromPort: 0
          ToPort: 65535
          CidrIp: 10.0.0.0/16
# ------------------------------------------------------------#
# Lambda
# ------------------------------------------------------------#
  LambdaFunction03:
    Type: 'AWS::Lambda::Function'
    Properties:
      Handler: index.handler
      Role: !GetAtt LambdaExecutionRole.Arn
      FunctionName: test-lambda-03
      Runtime: python3.11
      Timeout: 3
      MemorySize: 128
      Code:
        ZipFile: |
          def lambda_handler(event, context):
              print('Hello, World!')
              return {
                  'statusCode': 200,
                  'body': 'Hello, World!'
              }      
      VpcConfig:
        SecurityGroupIds:
          - !Ref LambdaSecurityGrouptoVPC
        SubnetIds:
          - !Ref PrivateSubnet1aID
          - !Ref PrivateSubnet1cID
# ------------------------------------------------------------#
# IAM Role
# ------------------------------------------------------------#
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: IRL-Lambda
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: 'Allow'
            Principal:
              Service: 'lambda.amazonaws.com'
            Action: 'sts:AssumeRole'
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole

利用可能なIPアドレス数の確認

先ほどと同様にサブネット内のIPアドレス数を確認します。

各AZごとに1つずつIPアドレスが解放されていますね。

マネジメントコンソールでのENIの確認

“test-lambda-01″で利用されていたHyperplane ENIが削除されていることが分かります。


“test-lambda-02″についてもLambda自体は削除しましたが、”test-lambda-03″が同一のHyperplane ENIを利用しているため、削除される挙動にはならないことを確認できました。

VPC Lambdaの設計の心得 三箇条

VPC Lambdaを利用するにおいて、設計の心得として3つ挙げます。

  • セキュリティグループとサブネットのペアでHyperplane ENIが作成されるため、用途に合わせたグループ化した設計をするべし
いくらHyperplane技術によりVPC Lambdaが利用しやすくなったとはいえ、Lambdaごとに異なるセキュリティグループを作成しているとIP枯渇が発生します。EC2等と同様に、同じ機能を有するものには同一のセキュリティグループを付与するといった設計をおすすめします。
  • VPC Lambdaは、VPC内のリソースにアクセスするための機能のため、基本的にはセキュリティグループのアウトバウンドルールのみ設計するべし

VPC Lambdaのセキュリティグループでは、基本的にインバウンドルールの評価はされず、アウトバウンドルールのみ評価されるのでアウトバウンドルールのみ考えてください。しかし一部のAWSサービス(Amazon MQ,Amazon MSK)でVPC Lambdaを利用する場合は、インバウンドルールについても評価されますのでご留意ください。

  • 他のAWSサービスと連携するためには、VPCエンドポイントまたはインターネットへの経路を確保するべし
特にプライベートサブネットを指定してVPC Lambdaを作成すると、他のAWSサービスに疎通することができません。利用したいAWSサービスのVPCエンドポイントの作成、またはパブリックサブネットにNAT Gateway,Internet Gatewayを配置してインターネットへの経路を作ってあげましょう。

最後に

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

あまり意識することのないのVPC Lambdaの裏側を簡単に説明してみました。また、設計ポイントについても挙げていますので、VPC Lambdaを利用する際には振り返っていただければと思います。

本記事が皆様のお役にたてば幸いです。

ではサウナラ~🔥

タイトルとURLをコピーしました