AWS EKSのNetwork Policyの動作と実装を確認してみる
はじめに
2023年の9月にAWS EKSのCNIがNetwork Policyをサポートしました。
ここで興味深いのが、Network Policyの実装にeBPFを使用していることです。
今回は環境を構築して動作を確認しつつ、コントローラとeBPFの実装を見てみます。
環境構築と動作確認
環境構築のためにAWSのblogに書かれているyamlファイルとeksctlでクラスタを作りました。
cluster.yaml
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: network-policy-demo
version: "1.29"
region: ap-northeast-1
iam:
withOIDC: true
vpc:
clusterEndpoints:
publicAccess: true
privateAccess: true
addons:
- name: vpc-cni
version: 1.17.1
attachPolicyARNs:
- arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy
configurationValues: |-
enableNetworkPolicy: "true"
- name: coredns
- name: kube-proxy
managedNodeGroups:
- name: x86-al2-on-demand
amiFamily: AmazonLinux2
instanceTypes: ["t3.medium"]
minSize: 0
desiredCapacity: 2
maxSize: 6
privateNetworking: true
disableIMDSv1: true
volumeSize: 100
volumeType: gp3
volumeEncrypted: true
tags:
team: "eks"
yamlファイルを作成して、eksctlコマンドでクラスタを作成します。
eksctl create cluster -f cluster.yaml
クラスタが作成できたら、Node Agentが動作しているか確認します。
$ kubectl get ds -n kube-system aws-node -o jsonpath='{.spec.template.spec.containers[*].name}{"\n"}'
aws-node aws-eks-nodeagent
次にk8s.ioのページの内容で動作を確認します。
$ kubectl create deployment nginx --image=nginx
$ kubectl expose deployment nginx --port=80
$ $ kubectl get svc,pod
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/kubernetes ClusterIP 10.100.0.1 <none> 443/TCP 121m
service/nginx ClusterIP 10.100.93.122 <none> 80/TCP 10s
NAME READY STATUS RESTARTS AGE
pod/nginx-7854ff8877-zcvv8 1/1 Running 0 27s
Podを作成したら、Network Policyのyamlをapplyします。
podSelector.matchLabels
で access: "true"
のlabelが貼られたPodからのみアクセスを許可します。
nginx-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: access-nginx
spec:
podSelector:
matchLabels:
app: nginx
ingress:
- from:
- podSelector:
matchLabels:
access: "true"
$ kubectl apply -f nginx-policy.yaml
$ kubectl get networkpolicies.networking.k8s.io access-nginx
NAME POD-SELECTOR AGE
access-nginx app=nginx 2m51s
動作確認をするとaccess=true
のlabelを付けたPodからはnginxにアクセスできますが、付いてないPodからはアクセスできません
$ k run -it alpine --image=alpine --labels="access=true" ash
If you don't see a command prompt, try pressing enter.
/ # apk add curl >> /dev/null
/ # curl http://nginx
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
/ #
labelがないPodからはアクセスできません。
$ k run -it alpine2 --image=alpine ash
If you don't see a command prompt, try pressing enter.
/ # apk add curl > /dev/null
/ # curl http://nginx
curl: (28) Failed to connect to nginx port 80 after 129269 ms: Couldn't connect to server
Network Policyの仕組み
AWSのBlog記事にあるようにNetwork Policyの実装には3つのコンポーネントがあります。
Network Policy コントローラーはNetwork Policyを policyendpoints
CRDに変換するControllerです。
先ほど作成したNetwork Policyは以下のように変換されます。
$ k get policyendpoints.networking.k8s.aws access-nginx-gtjlc -o yaml | yq .spec
ingress:
- cidr: 192.168.155.72
podIsolation:
- Ingress
podSelector:
matchLabels:
app: nginx
podSelectorEndpoints:
- hostIP: 192.168.129.105
name: nginx-7854ff8877-zcvv8
namespace: default
podIP: 192.168.140.23
policyRef:
name: access-nginx
namespace: default
ingress.cider
にセットされているのがIngressを許可しているapp: nginx
のLabelがセットされたalpine PodのIPです。
$ k get po -o wide
NAME READY STATUS RESTARTS AGE IP NODE
NOMINATED NODE READINESS GATES
alpine 1/1 Running 1 (88m ago) 89m 192.168.155.72 ip-192-168-129-105.ap-northeast-1.compute.internal <none> <none>
alpine2 1/1 Running 1 (81m ago) 88m 192.168.164.111 ip-192-168-173-84.ap-northeast-1.compute.internal <none> <none>
nginx-7854ff8877-zcvv8 1/1 Running 0 105m 192.168.140.23 ip-192-168-129-105.ap-northeast-1.compute.internal <none>
podSelectorEndpoints
のhostIP
はnginxのPodが動作するのNodeのIPアドレスで、podIP
がNetwork Policyの対象となるnginxのPodのIPアドレスです。
この状態のCRDから実際にeBPFプログラムによりパケットをフィルタするのがNode Agentになります。
Node Agentのソースを見る前に、EKSのNodeに入ってみてeBPFプログラムとeBPF mapを見てみます。
aws ssm
でNodeにログインしたら、bpftoolとtcをインストールします。
# yum install bpftool tc
bpftoolコマンドとtcコマンドでingressとegressにeBPFプログラムがそれぞれPodのvethのIngressとEgressにアタッチされていることがわかります。
[root@ip-192-168-129-105 ~]# bpftool net show
xdp:
tc:
eni603eb9019c7(3) clsact/ingress handle_egress id 50
eni603eb9019c7(3) clsact/egress handle_ingress id 49
flow_dissector:
[root@ip-192-168-129-105 ~]# tc filter show dev eni603eb9019c7 ingress
filter protocol all pref 1 bpf chain 0
filter protocol all pref 1 bpf chain 0 handle 0x1 handle_egress direct-action not_in_hw id 50 tag 049892aa7d67bac5 jited
[root@ip-192-168-129-105 ~]# tc filter show dev eni603eb9019c7 egress
filter protocol all pref 1 bpf chain 0
filter protocol all pref 1 bpf chain 0 handle 0x1 handle_ingress direct-action not_in_hw id 49 tag ff162fad3e10e2d2 jited
aws-eks-na-cli ebpf loaded-ebpfdata
で使用されているeBPF mapがdumpされます。
[root@ip-192-168-129-105 ~]# /opt/cni/bin/aws-eks-na-cli ebpf loaded-ebpfdata
PinPath: /sys/fs/bpf/globals/aws/programs/nginx-7854ff8877-default_handle_egress
Pod Identifier : nginx-7854ff8877-default Direction : egress
Prog ID: 50
Associated Maps ->
Map Name: aws_conntrack_map
Map ID: 21
Map Name: egress_map
Map ID: 24
Map Name: policy_events
Map ID: 22
========================================================================================
PinPath: /sys/fs/bpf/globals/aws/programs/nginx-7854ff8877-default_handle_ingress
Pod Identifier : nginx-7854ff8877-default Direction : ingress
Prog ID: 49
Associated Maps ->
Map Name: aws_conntrack_map
Map ID: 21
Map Name: ingress_map
Map ID: 23
Map Name: policy_events
Map ID: 22
ingress_map
のMap IDは23ということがわかるので、bpftool
コマンドでdumpします。
keyにセットされている16進数は、policyendpoints
のIngressで許可されているPodのIPアドレスとhostIPのIPアドレスとマッチします。
[root@ip-192-168-129-105 ~]# bpftool map dump id 23 | grep key -A 2
key:
20 00 00 00 c0 a8 81 69
value:
--
key:
20 00 00 00 c0 a8 9b 48
value:
eBPF mapにアクセスを許可するPodのIPアドレスをどのようにCRDからセットし、パケットをフィルタしているのかソースを確認してみましょう。
Node Agentの実装
blog記事で紹介されているようにNetwork Policy AgentはNetwork Policy コントローラーが書き込んだCRDを読み取り、eBPF Mapに書き込む役割を果たします。
PolicyEndpointのCRDをWatchするReconcilerでは、変更があればconfigureeBPFProbesからupdateeBPFMapsを呼びます。
reconcilePolicyEndpoint
func (r *PolicyEndpointsReconciler) reconcilePolicyEndpoint(ctx context.Context,
policyEndpoint *policyk8sawsv1.PolicyEndpoint) error {
r.log.Info("Processing Policy Endpoint ", "Name: ", policyEndpoint.Name, "Namespace ", policyEndpoint.Namespace)
start := time.Now()
// Identify pods local to the node. PolicyEndpoint resource will include `HostIP` field and
// network policy agent relies on it to filter local pods
parentNP := policyEndpoint.Spec.PolicyRef.Name
resourceNamespace := policyEndpoint.Namespace
resourceName := policyEndpoint.Name
targetPods, podIdentifiers, podsToBeCleanedUp := r.deriveTargetPodsForParentNP(ctx, parentNP, resourceNamespace, resourceName)
// Check if we need to remove this policy against any existing pods against which this policy
// is currently active. podIdentifiers will have the pod identifiers of the targetPods from the derived PEs
err := r.updatePolicyEnforcementStatusForPods(ctx, policyEndpoint.Name, podsToBeCleanedUp, podIdentifiers, false)
if err != nil {
r.log.Error(err, "failed to update policy enforcement status for existing pods")
return err
}
for podIdentifier, _ := range podIdentifiers {
// Derive Ingress IPs from the PolicyEndpoint
ingressRules, egressRules, isIngressIsolated, isEgressIsolated, err := r.deriveIngressAndEgressFirewallRules(ctx, podIdentifier,
policyEndpoint.Namespace, policyEndpoint.Name, false)
if err != nil {
r.log.Error(err, "Error Parsing policy Endpoint resource", "name:", policyEndpoint.Name)
return err
}
if len(ingressRules) == 0 && !isIngressIsolated {
//Add allow-all entry to Ingress rule set
r.log.Info("No Ingress rules and no ingress isolation - Appending catch all entry")
r.addCatchAllEntry(ctx, &ingressRules)
}
if len(egressRules) == 0 && !isEgressIsolated {
//Add allow-all entry to Egress rule set
r.log.Info("No Egress rules and no egress isolation - Appending catch all entry")
r.addCatchAllEntry(ctx, &egressRules)
}
// Setup/configure eBPF probes/maps for local pods
err = r.configureeBPFProbes(ctx, podIdentifier, targetPods, ingressRules, egressRules)
if err != nil {
r.log.Info("Error configuring eBPF Probes ", "error: ", err)
}
duration := msSince(start)
policySetupLatency.WithLabelValues(policyEndpoint.Name, policyEndpoint.Namespace).Observe(duration)
}
return nil
}
func (r *PolicyEndpointsReconciler) configureeBPFProbes(ctx context.Context, podIdentifier string,
targetPods []types.NamespacedName, ingressRules, egressRules []ebpf.EbpfFirewallRules) error {
var err error
//Loop over target pods and setup/configure/update eBPF probes/maps
for _, pod := range targetPods {
r.log.Info("Processing Pod: ", "name:", pod.Name, "namespace:", pod.Namespace, "podIdentifier: ", podIdentifier)
currentPodIdentifier := utils.GetPodIdentifier(pod.Name, pod.Namespace)
if currentPodIdentifier != podIdentifier {
r.log.Info("Target Pod doesn't belong to the current pod Identifier: ", "Name: ", pod.Name, "Pod ID: ", podIdentifier)
continue
}
// Check if an eBPF probe is already attached on both ingress and egress direction(s) for this pod.
// If yes, then skip probe attach flow for this pod and update the relevant map entries.
isIngressProbeAttached, isEgressProbeAttached := r.ebpfClient.IsEBPFProbeAttached(pod.Name, pod.Namespace)
err = r.ebpfClient.AttacheBPFProbes(pod, podIdentifier, !isIngressProbeAttached, !isEgressProbeAttached)
if err != nil {
r.log.Info("Attaching eBPF probe failed for", "pod", pod.Name, "namespace", pod.Namespace)
return err
}
r.log.Info("Successfully attached required eBPF probes for", "pod:", pod.Name, "in namespace", pod.Namespace)
}
err = r.updateeBPFMaps(ctx, podIdentifier, ingressRules, egressRules)
if err != nil {
r.log.Error(err, "failed to update map ", "podIdentifier ", podIdentifier)
return err
}
return nil
}
updateeBPFMapsからはebpfClient.UpdateEbpfMapsを呼び、カーネル空間側でeBPFプログラムが参照するingress_map
に通信を許可するPodのIPを書き込みます。
func (r *PolicyEndpointsReconciler) updateeBPFMaps(ctx context.Context, podIdentifier string,
ingressRules, egressRules []ebpf.EbpfFirewallRules) error {
// Map Update should only happen once for those that share the same Map
err := r.ebpfClient.UpdateEbpfMaps(podIdentifier, ingressRules, egressRules)
if err != nil {
r.log.Error(err, "Map update(s) failed for, ", "podIdentifier ", podIdentifier)
return err
}
return nil
}
updateeBPFMapsから呼ばれるIngressとEgressの情報を書き込む、pkg/ebpf/bpf_client.goのUpdateEbpfMaps関数です。
func (l *bpfClient) UpdateEbpfMaps(podIdentifier string, ingressFirewallRules []EbpfFirewallRules,
egressFirewallRules []EbpfFirewallRules) error {
var ingressProgFD, egressProgFD int
var mapToUpdate goebpfmaps.BpfMap
start := time.Now()
value, ok := l.policyEndpointeBPFContext.Load(podIdentifier)
if ok {
peBPFContext := value.(BPFContext)
ingressProgInfo := peBPFContext.ingressPgmInfo
egressProgInfo := peBPFContext.egressPgmInfo
if ingressProgInfo.Program.ProgFD != 0 {
ingressProgFD = ingressProgInfo.Program.ProgFD
mapToUpdate = ingressProgInfo.Maps[TC_INGRESS_MAP]
l.logger.Info("Pod has an Ingress hook attached. Update the corresponding map", "progFD: ", ingressProgFD,
"mapName: ", TC_INGRESS_MAP)
err := l.updateEbpfMap(mapToUpdate, ingressFirewallRules)
duration := msSince(start)
sdkAPILatency.WithLabelValues("updateEbpfMap-ingress", fmt.Sprint(err != nil)).Observe(duration)
if err != nil {
l.logger.Info("Ingress Map update failed: ", "error: ", err)
sdkAPIErr.WithLabelValues("updateEbpfMap-ingress").Inc()
}
}
if egressProgInfo.Program.ProgFD != 0 {
egressProgFD = egressProgInfo.Program.ProgFD
mapToUpdate = egressProgInfo.Maps[TC_EGRESS_MAP]
l.logger.Info("Pod has an Egress hook attached. Update the corresponding map", "progFD: ", egressProgFD,
"mapName: ", TC_EGRESS_MAP)
err := l.updateEbpfMap(mapToUpdate, egressFirewallRules)
duration := msSince(start)
sdkAPILatency.WithLabelValues("updateEbpfMap-egress", fmt.Sprint(err != nil)).Observe(duration)
if err != nil {
l.logger.Info("Egress Map update failed: ", "error: ", err)
sdkAPIErr.WithLabelValues("updateEbpfMap-egress").Inc()
}
}
}
return nil
}
eBPF Mapに情報を書き込むGoのソースを見たので、次にeBPF Mapの情報から実際にパケットをフィルタするeBPFプログラムを見てみます。
IPv4パケットを受信したときは、tc.v4ingress.bpf.cで処理されます。
tcにロードされるhandle_ingress関数を見ると処理内容がわかります。
BPF_OK
をreturnしている箇所では通信は許可されるので、BPF_DROP
をreturnするところに着目します。
以下の部分で、ingress_map
というeBPF Mapを参照してNULLであればパケットをDROPしています。
//Check if it's in the allowed list
trie_val = bpf_map_lookup_elem(&ingress_map, &trie_key);
if (trie_val == NULL) {
evt.verdict = 0;
bpf_ringbuf_output(&policy_events, &evt, sizeof(evt), 0);
return BPF_DROP;
}
bpf_map_lookup_elemでeBPF Mapを参照するときの第2引数のtrie_keyにはIPv4ヘッダからソースのIPv4アドレスがセットされているので、Podに対して通信が許可されているPodのIPv4アドレスなのかをチェックしていることがわかります。
このようにNode AgentはNetwork Policyの podSelector.matchLabels
で指定したPodの通信なのかをこのように判定しています。
PodからのEgressの通信は、handle_egressで同じようにegress_map
を参照して処理されます。
//Check if it's in the allowed list
trie_val = bpf_map_lookup_elem(&egress_map, &trie_key);
if (trie_val == NULL) {
evt.verdict = 0;
bpf_ringbuf_output(&policy_events, &evt, sizeof(evt), 0);
return BPF_DROP;
}
EgerssもNetwork Policy → CRD → AgentがeBPF Mapに情報書き込んで処理されますが、今回はIngressに着目したので詳細は割愛します。
おわりに
というわけでeBPFの視点からEKSのNetwork Policyの実装を追ってみました。
EKSでNetwork Policyを設定すると以下のような流れで処理されることがわかりました。
- network-policy-controllerがNetwork PolicyをCRDに変換
- agentがCRDを読み取りeBPF Mapに情報を書く
- Podへ/Podからパケットが送信/受信されるとeBPFプログラムが実行されパケットがフィルタされる
eBPFプログラムのソースも短いですし読んでみるといいのではないでしょうか。
謝辞
今回の記事を書くにあたり、同僚のtoVersus君とtozastation君より多くの刺激と示唆を得ることができました。感謝の意を表します。
最後にこれまでBlog記事を書くときにいつも温かく見守ってくれた妻と娘はいないので募集しています。
Discussion