はじめに
Transit Gateway(TGW)経由の通信がどう処理されるかを検証する機会がありました。
検証の中で「VPCにアタッチしたLambdaから送ったパケットが、TGWフローログ上でどう記録されるか」を追いたかったのですが、そのためにはフローログの中からLambdaの通信を特定する必要があります。
特定の手がかりとして一番わかりやすいのは Lambdaの送信元IP です。そこで、Lambdaのコード内で自分の送信元IPを取得してログに出しておき、その値でフローログを検索しようとしました。
……が、ソケットから取得したIPはフローログのどこにも出てきませんでした。
本記事では、この現象の原因(Hyperplane ENI)と、Lambdaの実際の送信元IPを特定する方法を、個人環境で再現した検証結果とあわせてまとめます。
検証を簡易に実施するため、本記事の数値はすべて TGWを使わずVPC単体で再現した環境 のものです。TGWフローログの代わりにVPCフローログを使っていますが、原因となる仕組みは同じです。
やろうとしたこと
やりたかったことは次の流れです。
- Lambda(VPCアタッチ)から、TGWの先にある対向リソースへパケットを送信する
- 送信後、TGWフローログを見てその通信を追跡する
- 追跡の手がかりとして、Pythonの
socket(getsockname()など)でLambda自身の送信元IPを事前に取得しておく
3.は、よくある「自分のIPを取得する」書き方です。
import socket
# TCPで接続したソケットのローカルアドレス
with socket.create_connection((dest_host, dest_port)) as s:
print(s.getsockname()) # (送信元IP, 送信元ポート)
# UDPソケットをconnectしてローカルアドレスを見る(パケットは送信されない)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect((dest_host, dest_port))
print(s.getsockname())
起きた問題
ソケットから取得したIPと、フローログに記録されていた送信元IPが一致しませんでした。
再現環境での値を並べると次のとおりです。
| 取得方法 | 送信元IP | 送信元ポート |
|---|---|---|
Lambda内:TCPソケットの getsockname()
|
169.254.100.6 |
52910 |
Lambda内:UDP connect して getsockname()
|
169.254.100.6 |
- |
Lambda内:gethostbyname(gethostname())
|
169.254.8.241 |
- |
VPCフローログの srcaddr / srcport |
10.0.1.106 |
53526 |
ソケットから取れたのは 169.254.0.0/16(リンクローカルアドレス)で、Lambdaをアタッチしたサブネット(10.0.1.0/24, 10.0.2.0/24)のアドレスですらありません。
しかも 送信元ポートまで違います。
「あれ、ソケットのIPは何を指しているんだ?」というのが調査のきっかけです。
原因調査:Hyperplane ENIの仕組み
Lambdaの実行環境はユーザーのVPCの中にはいない
AWSのドキュメントには次のように書かれています。
Every Lambda function runs inside a VPC that is owned and managed by the Lambda service. These VPCs are maintained automatically by Lambda and are not visible to customers.
(Giving Lambda functions access to resources in an Amazon VPC)
つまり、Lambdaを「VPCにアタッチ」しても、関数のコードが動いている実行環境そのものは AWSが管理するLambda用のVPC の中にあります。
ユーザーのVPCに見えているのはHyperplane ENI
では、ユーザーのVPCには何があるのかというと、Hyperplane ENI です。
- Lambdaサービスが作成・管理するENIで、ユーザーのVPCのサブネット内に作られる
- サブネットとセキュリティグループの組み合わせ ごとに作られ、同じ組み合わせを使う 他の関数とも共有される
- 1つのENIで最大65,000接続/ポートを扱い、足りなければLambdaが自動でENIを増やす
The first time you attach a function to a VPC using a particular subnet and security group combination, Lambda creates a Hyperplane ENI. Other functions in your account that use the same subnet and security group combination can also use this ENI.
(同上)
実行環境 → Hyperplane ENI の間でNATされている
2019年のLambda VPCネットワーク改善のアナウンスでは、次のように説明されています。
we are now leveraging Hyperplane to provide NAT capabilities from the Lambda VPC to customer VPCs.
(Announcing improved VPC networking for AWS Lambda functions)
Lambda用VPCからユーザーのVPCへ抜けるところでNAT(いわゆる V2N: VPC-to-VPC NAT)が行われ、送信元が Hyperplane ENIのプライベートIP に変換されてから対向に届きます。
[Lambda実行環境] [ユーザーのVPC] [対向リソース]
169.254.100.6:52910 ──NAT──▶ Hyperplane ENI 10.0.1.106:53526 ──▶ 10.0.10.4:8080
(Lambda管理VPC側) ↑ フローログ・対向から見えるのはこちら
つまり、
- ソケットが返すのは、Lambda実行環境側のローカルなアドレス
- フローログや対向リソースから見える送信元は、NAT後のHyperplane ENIのアドレス
であり、両者は別物です。今回の再現ではIPだけでなくポートも変換されていました。
検証環境
TGWの構築はハードルが高いので、VPC単体で次の構成を作りました(Terraform)。
VPC 10.0.0.0/16(IGW/NATなし)
├─ Lambda用サブネット 10.0.1.0/24 (AZ-a), 10.0.2.0/24 (AZ-c)
│ Lambda: lambda-srcip-a / lambda-srcip-b(同じサブネット+SG)
│
└─ 対向サブネット 10.0.10.0/24
└─ EC2 (t4g.nano): 受け付けた接続の送信元IP:ポートをJSONで返すHTTPサーバー :8080
VPCフローログ(ALL, 最大集約間隔60秒)→ CloudWatch Logs
ポイントは次の3つです。
- 同じサブネット+SGで 関数を2つ 作り、Hyperplane ENIの共有を確認できるようにした
- 対向EC2を「見えた送信元をそのまま返すサーバー」にして、受信側から見た送信元 をLambdaのレスポンスで回収できるようにした
- フローログはカスタムフォーマットで
pkt-srcaddr/flow-direction/instance-idも出力した
主要部分だけ抜粋します。
resource "aws_lambda_function" "this" {
for_each = toset(["a", "b"])
function_name = "lambda-srcip-${each.key}"
runtime = "python3.14"
# ...
vpc_config {
subnet_ids = aws_subnet.lambda[*].id # 2サブネット
security_group_ids = [aws_security_group.lambda.id]
}
}
resource "aws_flow_log" "vpc" {
vpc_id = aws_vpc.this.id
traffic_type = "ALL"
log_destination_type = "cloud-watch-logs"
log_destination = aws_cloudwatch_log_group.flow_logs.arn
iam_role_arn = aws_iam_role.flow_logs.arn
max_aggregation_interval = 60
log_format = "$${start} $${end} $${interface-id} $${subnet-id} $${instance-id} $${flow-direction} $${srcaddr} $${srcport} $${dstaddr} $${dstport} $${pkt-srcaddr} $${pkt-dstaddr} $${protocol} $${packets} $${bytes} $${action} $${log-status}"
}
Lambda側は、ソケットで取れる値と、対向サーバーが返してきた「見えた送信元」を一緒に返します。
def tcp_request_to_receiver():
with socket.create_connection((RECEIVER_HOST, RECEIVER_PORT), timeout=5) as s:
local_ip, local_port = s.getsockname()[:2]
request = f"GET / HTTP/1.1\r\nHost: {RECEIVER_HOST}\r\nConnection: close\r\n\r\n"
s.sendall(request.encode())
chunks = []
while chunk := s.recv(4096):
chunks.append(chunk)
_, body = b"".join(chunks).split(b"\r\n\r\n", 1)
return {
"socket_getsockname": {"ip": local_ip, "port": local_port},
"seen_by_receiver": json.loads(body), # 対向が accept した接続の送信元IP:ポート
}
では、Lambdaの実際の送信元IPをどう特定すればよいか
方法1:DescribeNetworkInterfacesでHyperplane ENIを事前に調べる
Hyperplane ENIは関数作成時(VPC設定時)に作られるので、呼び出し前に一覧できます。interface-type が lambda のENIを絞り込みます。
aws ec2 describe-network-interfaces \
--filters Name=vpc-id,Values=vpc-xxxxxxxx Name=interface-type,Values=lambda \
--query 'NetworkInterfaces[].{Id:NetworkInterfaceId,Ip:PrivateIpAddress,Subnet:SubnetId,Desc:Description}' \
--output table
再現環境の結果です。
| Description | ENI | IP | サブネット |
|---|---|---|---|
| AWS Lambda VPC ENI-lambda-srcip-a | eni-0a62... | 10.0.1.106 | 10.0.1.0/24 |
| AWS Lambda VPC ENI-lambda-srcip-a | eni-0f53... | 10.0.1.177 | 10.0.1.0/24 |
| AWS Lambda VPC ENI-lambda-srcip-a | eni-0617... | 10.0.2.235 | 10.0.2.0/24 |
| AWS Lambda VPC ENI-lambda-srcip-a | eni-04b3... | 10.0.2.80 | 10.0.2.0/24 |
ここで分かったことが2つあります。
- サブネット×SGの組み合わせは2つなのに、ENIは4つ(サブネットあたり2つ) 作られていた。候補を1つに絞れない
- 2つの関数で共有されているが、Descriptionはすべて最初に作った
lambda-srcip-a。lambda-srcip-bの名前で検索しても見つからない
Hyperplane ENIの数や割り当ては実装の詳細です。ドキュメントにも「負荷分散やヘルスチェックのためにENIを削除・再作成することがある」「14日間アイドルだとENIが回収される」「ENIが存続し続ける前提で設計しないこと」と書かれています。事前に調べたIPはあくまで「候補」として扱うのが安全です。
方法2:フローログ側から逆引きする
送信元IPが分からなくても、宛先IP・宛先ポート は自分で決めているので分かります。そこからフローログを逆引きし、送信元IPを確認します。
CloudWatch Logs Insights の例です(カスタムフォーマットは自動パースされないので parse しています)。
parse @message "* * * * * * * * * * * * * * * * *" as start, end, interface_id, subnet_id, instance_id, flow_direction, srcaddr, srcport, dstaddr, dstport, pkt_srcaddr, pkt_dstaddr, protocol, packets, bytes, action, log_status
| filter dstport = "8080" and flow_direction = "egress" and instance_id = "-"
| sort start desc
| display fromMillis(start * 1000) as start_time, interface_id, srcaddr, srcport, dstaddr, dstport, bytes, action
-
instance-idが-のレコードがHyperplane ENI側(EC2のENIならi-xxxxが入る) - 見つかった
interface-idを方法1の一覧と照合すれば、どのHyperplane ENIを通ったかも分かる
時刻だけで特定するのは難しい です。今回、Lambda側で記録した送信時刻と、フローログの start のずれは次のとおりでした。
- Hyperplane ENI側:−16秒〜+31秒(送信より 前 の時刻が入ることもある)
- 対向EC2のENI側:+2秒〜+59秒
ドキュメントでも start は「パケット受信の最大60秒前〜送信の最大60秒後」になりうると説明されています。記録の順番も送信順と一致しませんでした。
時刻は大まかな絞り込みにとどめ、宛先・ポート・バイト数などを組み合わせて特定するのが確実です。
方法3:接続先リソース側のログで確認する
受信側で accept した接続の送信元を記録する方法です。今回は対向EC2のHTTPサーバーが見えた送信元を返すようにしたので、Lambdaのレスポンスで直接確認できました。
受信側で見えた送信元IP:ポートは、フローログの srcaddr / srcport と ポート番号まで完全に一致 しました。対向リソースのログを見られる場合は、これが一番確実です。
今回の検証でワークした方法
| 方法 | 結果 | コメント |
|---|---|---|
ソケット(getsockname() 等) |
✕ | どの実行環境でも 169.254.100.6。フローログには一切出ない |
| 方法1:DescribeNetworkInterfaces | △ | 候補(4つ)は出せるが、どれを通るかは実行するまで不明 |
| 方法2:フローログから逆引き | ○ | 宛先IP・ポートで特定できた。時刻は数十秒ずれる |
| 方法3:接続先側のログ | ◎ | フローログとポートまで一致。受信側を触れるなら最も確実 |
検証結果まとめ
lambda-srcip-a → lambda-srcip-b → lambda-srcip-a → lambda-srcip-b の順に計4回実行し、1回あたり3接続、計12接続を記録しました。
実行ごとの比較
| # | 関数 | 実行環境(ログストリーム) | ソケット getsockname() のIP |
対向・フローログから見えた送信元IP | Hyperplane ENI |
|---|---|---|---|---|---|
| 1 | a | 5d8f81ad... | 169.254.100.6 | 10.0.1.106 | eni-0a62... |
| 2 | b | 73df8c9f... | 169.254.100.6 | 10.0.2.235 | eni-0617... |
| 3 | a | 65a3c63d...(#1とは別環境) | 169.254.100.6 | 10.0.1.106 | eni-0a62... |
| 4 | b | 73df8c9f...(#2と同じ環境) | 169.254.100.6 | 10.0.2.235 | eni-0617... |
接続ごとの比較
| # | 送信時刻(Lambda) | 関数 | ソケットのポート | 対向が見た送信元 | フローログ start(Hyperplane ENI egress) |
フローログ start(対向EC2 ingress) |
|---|---|---|---|---|---|---|
| 1 | 13:33:07 | a | 52910 | 10.0.1.106:53526 | 13:32:53 | 13:33:15 |
| 2 | 13:33:08 | a | 52922 | 10.0.1.106:30812 | 13:32:59 | 13:33:10 |
| 3 | 13:33:09 | a | 52938 | 10.0.1.106:45110 | 13:32:53 | 13:33:15 |
| 4 | 13:38:40 | b | 45842 | 10.0.2.235:12606 | 13:39:01 | 13:39:19 |
| 5 | 13:38:41 | b | 45844 | 10.0.2.235:13699 | 13:38:41 | 13:39:19 |
| 6 | 13:38:42 | b | 45856 | 10.0.2.235:35513 | 13:38:56 | 13:38:44 |
| 7 | 13:40:44 | a | 53390 | 10.0.1.106:44606 | 13:40:58 | 13:41:05 |
| 8 | 13:40:45 | a | 53406 | 10.0.1.106:19328 | 13:40:58 | 13:41:44 |
| 9 | 13:40:46 | a | 46238 | 10.0.1.106:30205 | 13:41:17 | 13:41:05 |
| 10 | 13:40:56 | b | 57638 | 10.0.2.235:62947 | 13:40:56 | 13:41:44 |
| 11 | 13:40:57 | b | 57644 | 10.0.2.235:46997 | 13:40:59 | 13:41:18 |
| 12 | 13:40:58 | b | 57658 | 10.0.2.235:49301 | 13:40:56 | 13:41:44 |
※ 時刻はすべてUTC。「対向が見た送信元」とフローログの srcaddr:srcport は12接続すべてで一致。
分かったこと
-
ソケットのIPは、関数が違っても実行環境が違っても常に
169.254.100.6。フローログとの突合せどころか、送信元の識別にも使えない - 送信元ポートも変換される。Lambda側のポートはほぼ連番なのに、変換後はランダム。ポート番号での突合せもできない
-
Hyperplane ENIは関数間で共有される。
lambda-srcip-bは Description がlambda-srcip-aのENIから通信していた -
フローログの
srcaddrとpkt-srcaddrは同じ値(Hyperplane ENIのIP)。169.254系のアドレスはフローログのどのフィールドにも現れない - 今回の範囲では関数ごとに同じENIが使われたが、4回・12接続だけの観測 なので、「関数ごとにENIが固定される」とまでは言えない
実務への教訓
-
Lambda(VPC)からの通信をフローログで追跡したい場合、ソケットのローカルIPをあてにしてはいけない
- VPC側から見える送信元は Hyperplane ENI のIPで、ソケットからは取得できない
-
追跡するなら、トラフィックの特徴(宛先IP・宛先ポート・バイト数)で探す
- 事前に Hyperplane ENI のIP一覧を把握しておくと、候補の照合に使える
- 時刻は数十秒単位でずれるので補助的に使う
- 受信側のログが見られるなら、それが一番確実
-
IP制限のある連携先でも同様の注意が必要
- TGW / VPCピアリング / Direct Connect などプライベート経路の先で送信元IP制限をかける場合、相手から見えるのは Hyperplane ENI のIP
- ENIは複数あり、再作成でIPが変わることもあるので、個別IPではなくLambdaを配置したサブネットのCIDR単位で許可する(同一VPC内ならSG参照)のが現実的
- インターネット向けの場合は、さらにその先の NAT Gateway のEIPが送信元になる
まとめ
- VPCアタッチしたLambdaの実行環境は、AWS管理のLambda用VPCで動いている
- ユーザーのVPCに見えるのは、サブネット×SG単位で共有される Hyperplane ENI で、実行環境からの通信はここでNATされる
- そのため
getsockname()などで取れる送信元IP(169.254.x.x)は、フローログや対向リソースから見える送信元IP(Hyperplane ENIのIP)とは一致しない。ポートも変換される - Lambdaの送信元を特定したいときは、フローログを宛先・ポートから逆引きするか、受信側のログで確認する
参考文献
-
Giving Lambda functions access to resources in an Amazon VPC - AWS Lambda
- 特に「Understanding Hyperplane Elastic Network Interfaces (ENIs)」の節
- Announcing improved VPC networking for AWS Lambda functions | AWS Compute Blog(2019/09/03)
- Flow log records - Amazon Virtual Private Cloud
- AWS Transit Gateway Flow Logs - Amazon VPC