🐝

AWS EKSのNetwork Policyの動作と実装を確認してみる

2024/03/24に公開

はじめに

2023年の9月にAWS EKSのCNIがNetwork Policyをサポートしました。
ここで興味深いのが、Network Policyの実装にeBPFを使用していることです。

今回は環境を構築して動作を確認しつつ、コントローラとeBPFの実装を見てみます。

https://aws.amazon.com/jp/blogs/news/amazon-vpc-cni-now-supports-kubernetes-network-policies/

環境構築と動作確認

環境構築のために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.matchLabelsaccess: "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は以下のように変換されます。

https://github.com/aws/amazon-network-policy-controller-k8s/

$ 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>

podSelectorEndpointshostIPは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に書き込む役割を果たします。

https://github.com/aws/aws-network-policy-agent/

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を設定すると以下のような流れで処理されることがわかりました。

  1. network-policy-controllerがNetwork PolicyをCRDに変換
  2. agentがCRDを読み取りeBPF Mapに情報を書く
  3. Podへ/Podからパケットが送信/受信されるとeBPFプログラムが実行されパケットがフィルタされる

eBPFプログラムのソースも短いですし読んでみるといいのではないでしょうか。

https://github.com/aws/aws-network-policy-agent/tree/main/pkg/ebpf/c

謝辞

今回の記事を書くにあたり、同僚のtoVersus君tozastation君より多くの刺激と示唆を得ることができました。感謝の意を表します。

最後にこれまでBlog記事を書くときにいつも温かく見守ってくれた妻と娘はいないので募集しています。

Discussion