こんにちは。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の作成
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が作成されるため、用途に合わせたグループ化した設計をするべし
- VPC Lambdaは、VPC内のリソースにアクセスするための機能のため、基本的にはセキュリティグループのアウトバウンドルールのみ設計するべし
VPC Lambdaのセキュリティグループでは、基本的にインバウンドルールの評価はされず、アウトバウンドルールのみ評価されるのでアウトバウンドルールのみ考えてください。しかし一部のAWSサービス(Amazon MQ,Amazon MSK)でVPC Lambdaを利用する場合は、インバウンドルールについても評価されますのでご留意ください。
- 他のAWSサービスと連携するためには、VPCエンドポイントまたはインターネットへの経路を確保するべし
最後に
いかがだったでしょうか。
あまり意識することのないのVPC Lambdaの裏側を簡単に説明してみました。また、設計ポイントについても挙げていますので、VPC Lambdaを利用する際には振り返っていただければと思います。
本記事が皆様のお役にたてば幸いです。
ではサウナラ~🔥