この記事は freee Developers Advent Calendar 2025 12/10(10日目) の記事となります。
昨日は kochan さんの AIを使って雑にアプリを作る企画の振り返り でした。
対話形式のアドベンドカレンダー新鮮でした。自分が AI で日々感じてることも話されていてとても共感できる内容でした。 個人的には him0 さんの「AIに全部任せると爆速でできて爆速で腐るよな。」表現が秀逸で面白かったです。
本日は SRE チームの sho が書きます。freee ではプロダクトの共通基盤である EKS クラスタを刷新するプロジェクトを担当しています。
この回は EKS のセキュリティにおける要の機能の一つである Security Group for Pods をテーマに、Security Group for Pods がどのような仕組みで動いているのかを深堀ります(以降 SG for Pods と省略)。
全体の流れとしてはまず SG for Pods の概要を軽く眺めた後、仕組みについてドリルダウンしていきます。
SG for Pods とは何か?
名前の通り Pod 単位で設定する SG のことです。
EKS(厳密には VPC CNI)ではデフォルトだと SG は Node 単位で設定するようになっています。しかし Node 単位での設定では、スケジュールされ得る全 Pod の最大公約数を SG として付与する必要があり、最小権限の原則を遵守できません。そのため現在では SG for Pods を利用することが、AWS 公式でも推奨されています。
クラスター外部の AWS サービスに対して、SG for Pods を利用してネットワークレベルのアクセス制御をすることを強く推奨します。
We strongly recommend to utilize security groups for Pods to limit network-level access to AWS services that are not part of a cluster.
Security Groups Per Pod - Amazon EKS
具体的には以下のような k8s manifest を適用することで Pod と SG を関連付けることができます。
apiVersion: vpcresources.k8s.aws/v1beta1 kind: SecurityGroupPolicy metadata: name: example-policy spec: podSelector: matchLabels: app: example-web-app securityGroups: groupIds: - sg-12345678
以上のように、利用そのものはとてもシンプルで簡単に使い始めることができます。
一方、大規模環境での運用を見据えて仕組みまで理解しようとすると Node に設定する SG と比べてやや複雑ですので、以降は SG for Pods の仕組みについて見ていこうと思います。
SG の前提知識
SG for Pods の話に入る前に前提知識をいくつか整理します。
- VPC CNI の基本
- ENI の概要と種類
- SG と ENI の関連性
VPC CNI の基本
VPC CNI は、EKS で動作する Pod に VPC ネイティブな IP アドレスを割り当てるためのネットワークプラグインです。
- 役割: EKS の Pod が、VPC 内の他の AWS リソース(EC2、RDSなど)と、直接通信できるようにします。
- 仕組み: ノード(EC2 インスタンス)にアタッチされた ENI と セカンダリ IP アドレスを Pod に割り当てことで、この機能を実現します。
ENI の概要と種類
ENI は、VPC 上で仮想のネットワークインターフェースで、EC2 インスタンスにアタッチされます。ENI は主に以下の2種類があります。
| 種類 | 概要 | 役割 |
|---|---|---|
| Primary ENI | EC2 インスタンスの起動時に自動で作成、アタッチされます。 | インスタンスのプライマリ IP アドレスを持ち、インスタンスの主要なネットワーク通信を担当します。 |
| Secondary ENI | インスタンスにアタッチできる追加のネットワークインターフェースです。 | 追加のプライベート IP アドレスを提供します。 |
EKS のネットワークにおいて中心となる ENI ですが、SG for Pods を理解するという文脈に限って言えば一旦「そういう ENI がある」程度の認識で大丈夫です。
SG と ENI の関連性
SG はパケットが ENI を通過する際に評価されるネットワーク制御の仕組みです。
そのため SG for Pods を理解するためには、ENI がどのようにアタッチされ、パケットがどのような経路を辿って ENI を通過するのかを理解することがとても重要になります。
Trunk ENI と Branch ENI の概要
上述した ENI に加えて、SG for Pods では Trunk ENI と Branch ENI という2つの ENI が利用されます。SG for Pods では、この2つの ENI を利用することで Pod 単位の SG 制御を実現しています。
SG for Pods を利用する場合の概要図は以下のようになります。

上記の図は AWS 公式ブログで掲載されている図 を参考に SG for Pods にフォーカスして一部変更した図になります。
SG を設定していない Pod には Primary/Secondary ENI が割り当てられる一方、SG が設定されている Pod は Branch ENI と一対一で関連付けられるようになります。Trunk ENI の立ち位置は少し複雑なので後ほど詳しく説明します。ここでは一旦 Trunke ENI は各 Branch ENI を束ねていること、Branch ENI は Pod と一対一の関係にあること をふんわり理解してもらえれば OK です。
SG for Pods の世界でトラフィックはどう流れるのか?
次に SG for Pods を利用している Pod 同士でトラフィックがどのように流れるかを見ていきましょう。
AWS のドキュメントを読むと SG for Pods は Pod 間での制御への利用は想定していないようですが、仕組みを学ぶという意味では有用なので今回は別ノードで動作してる Pod へのトラフィックがどのように流れていくかを解説します。
クラスタ内の Pod 間通信(いわゆる East/Wset トラフィック)では、network policy での制御を検討してください。
Consider network policies to restrict network traffic between Pods inside a cluster, often known as East/West traffic. Security Groups Per Pod - Amazon EKS
先に図で通信経路を示し、その後に各ステップを順に追って説明します。

図を参考にしつつ、実際のクラスタで動作してる Pod を例に、この Pod 間でどのような通信経路になっているかを見ていきます。 (コマンド結果は適時に省略、変更しています)
> kubectl get po -o wide example-pod-1 1/1 Running 0 54m 10.190.90.10 ip-10-190-94-41.ap-northeast-1.compute.internal <none> <none> example-pod-2 1/1 Running 0 28h 10.190.60.250 ip-10-190-51-53.ap-northeast-1.compute.internal <none> <none>
1. veth 間の通信
SG for Pods に限らず EKS では Pod と ホストの Network 名前空間を veth で接続しています。
Pod に入ってインターフェースとルーティングを確認してみましょう。
> kubectl exec -ite example-pod-1 -- sh
/app # ip addr show eth0
3: eth0@if50: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 9001 qdisc noqueue state UP
link/ether 00:00:23:90:e9:61 brd ff:ff:ff:ff:ff:ff
inet 10.190.90.10/32 scope global eth0
/app # ip route | grep default
default via 169.254.1.1 dev eth0
/app # ip neigh | grep 169.254.1.1
169.254.1.1 dev eth0 lladdr 5f:f4:4f:cf:6e:00 used 0/0/0 probes 0 PERMANENT
デフォルトルートは eth0 を通って 169.254.1.1 がネクストホップとして指定されていました。169.254.1.1 はリンクローカルアドレスの一部で、VPC CNI は Pod ネットワーク作成時に デフォルトルートの宛先として設定します。
実体としてはホスト側の veth ペアです。ホストで対象の NIC を見ると MAC アドレスが一致してることが分かります。
> k debug -it node/ip-10-192-94-41.ap-northeast-1.compute.internal --image alpine --profile=netadmin
/ # ip link show enif2927b15b7c
50: enif2927b15b7c@pod-id-link0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc noqueue state UP
link/ether 5f:f4:4f:cf:6e:00 brd ff:ff:ff:ff:ff:ff
2. Branch ENI へ
パケットがホストへ到達したので次にホストのルーティングを確認しましょう。
/ # ip rule | grep "from 10.190.90.10" 1536: from 10.190.90.10 lookup 102
ルーティングルールを見ると 10.190.90.10 から来たパケットは 102 というルーティングテーブルを見るよう設定されています。これは VPC CNI が作成したカスタムルーティングテーブルで SG for Pods の要の部分になります。
ルーティングテーブルの中身はこんな感じになっています。
/ # ip route show table 102 | grep default default via 10.190.64.1 dev vlan.eth.2
vlan.eth.2 を通って 10.190.64.1 へ行くよう設定されています。重要なのは vlan.eth.2 です。これが Branch ENI の NIC になります。
/ # ip link show vlan.eth.2
51: vlan.eth.2@enp41s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc noqueue state UP qlen 1000
link/ether 0a:0b:46:fd:92:c6 brd ff:ff:ff:ff:ff:ff
> kubectl get pod t9s-example-web-http-server-56dcd6f7bd-8nt5h -o json | jq '.metadata.annotations."vpc.amazonaws.com/pod-eni" | fromjson' | jq '.[].ifAddress'
"0a:0b:46:fd:92:c6"
3 ~ 4. VLAN 間通信
Branch ENI の ENI に vlan.eth.2 という prefix が付いていたことからも分かるように Branch ENI は VLAN インターフェースです。そして親のインターフェースが Trunk ENI になります。
/ # ip link show enp41s0
11: enp41s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc mq state UP qlen 1000
link/ether 0b:0b:46:fe:91:c8 brd ff:ff:ff:ff:ff:ff
> aws ec2 describe-network-interfaces \
--network-interface-ids eni-12354zdr42352 | jq '.NetworkInterfaces[0] | {"Description": .Description, "MacAddress": .MacAddress}'
{
"Description": "aws-k8s-trunk-eni",
"MacAddress": "0b:0b:46:fe:91:c8"
}
ネクストホップを確認すると Trunk ENI の MAC アドレスになっていることから Trunk ENI を経由しながらノード外へ出ていきます。
/ # ip neigh | grep 10.190.64.1 10.190.64.1 dev enp41s0 lladdr 0a:f0:ae:3a:7b:91 used 0/0/0 probes 0 REACHABLE
ちなみに 10.190.64.1 は、この Pod が配置されているサブネットのゲートウェイです。
> aws ec2 describe-network-interfaces \
--network-interface-ids eni-12354zdr42352 | jq '.NetworkInterfaces[].SubnetId'
"subnet-e234tfe253523"
> aws ec2 describe-subnets --subnet-ids subnet-e234tfe253523 | jq '.Subnets[].CidrBlock'
"10.190.64.0/19"
5. 宛先ノードへ到達
今回の宛先は 10.190.60.250 です。このアドレスは Branch ENI に割り当てられているアドレスですが、Branch ENI は Trunk ENI 上にある VLAN インターフェースのためインスタンスには関連付けられていません。
> aws ec2 describe-network-interfaces \
--network-interface-ids eni-5678arw45rw4 | jq '.NetworkInterfaces[0] | {"Description": .Description, "PrivateIpAddresses": .PrivateIpAddresses, "InstanceId": .Attachment.InstanceId}'
{
"Description": "aws-k8s-branch-eni",
"PrivateIpAddresses": [
{
"Primary": true,
"PrivateDnsName": "ip-10-192-60-250.ap-northeast-1.compute.internal",
"PrivateIpAddress": "10.190.60.250"
}
],
"InstanceId": null
}
VPC から直接パケットを受け付けることができるのは インスタンスに関連付けられた ENI のみなのため、パケットが最初に到達するのは Trunk ENI になります。
6 ~ 7. 宛先 Pod への経路
興味深いことに Trunk ENI に到達した時点で宛先の Branch ENI に応じた VLAN ID が付与されています。今回の例ですと VLAN ID がフレームに含まれています。これによって Trunk ENI へ到達したパケットは Branch ENI を経由します。
恐らく VPC でルーティングされる際に VLAN ID が付く仕組みになっているのだと思います。
/ # ip link show vlan.eth.1
51: vlan.eth.1@enp41s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc noqueue state UP qlen 1000
link/ether 06:11:50:b7:dd:e1 brd ff:ff:ff:ff:ff:ff
tcpdump で確認すると Trunk ENI → Branch ENI へ通信しているのが見えます。
/ # tcpdump -i enp41s0 vlan 1 -e tcpdump: verbose output suppressed, use -v[v]... for full protocol decode listening on enp41s0, link-type EN10MB (Ethernet), snapshot length 262144 bytes 17:48:09.028339 06:b1:20:cc:b0:f7 (oui Unknown) > 06:11:50:b7:dd:e1 (oui Unknown), ethertype 802.1Q (0x8100), length 78: vlan 1, p 0, ethertype IPv4 (0x0800), ip-10-192-94-41.ap-northeast-1.compute.internal.48706 > ip-10-192-60-250.ap-northeast-1.compute.internal.8080: Flags [S], seq 3570783031, win 62727, options [mss 8961,sackOK,TS val 2506750655 ecr 0,nop,wscale 7], length 0
以降はルーティングテーブルに従ってホスト側の veth → Pod 側の veth とパケットが流れていきます。
/ # ip rule | grep "to 10.190.60.250" 512: from all to 10.190.60.250 lookup main / # ip route | grep 10.190.60.250 10.190.60.250 dev enidf3412daf56 scope link / # ip neigh | grep 10.190.60.250 10.190.60.250 dev enidf3412daf56 lladdr a1:1f:4c:fb:1d:25 ref 1 used 0/0/0 probes 4 REACHABLE
# a1:1f:4c:fb:1d:25 は Pod 側のインターフェース
> k exec -it t9s-example-web-http-server-56dcd6f7bd-6h9f8 -- sh
Defaulted container "http-server" out of: http-server, debugger-l6cjr (ephem)
/app # ip link show eth0
3: eth0@if50: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 9001 qdisc noqueue state UP
link/ether a1:1f:4c:fb:1d:25 brd ff:ff:ff:ff:ff:ff
まとめ
SG for Pods の仕組みは一見複雑ですが、通信経路を丁寧に見ていくと全容が掴め細かい挙動も把握できるようになります。
特に Trunk ENI と Branch ENI は理解が難しいですが、ここまでの内容を踏まえるとインスタンスの ENI 上限によらず ENI を生やせるようにするため、Trunk ENI のインターフェース上に VLAN インターフェースとして作成したのが Branch ENI という整理ができます。
以上が SG for Pods 詳解でした。
