eBPF の入門として Cilium Network Policy について調べてみた

はじめに

eBPF の勉強として Cilium Network Policy がどのように反映され、どのように制御されているかソースコードを追いかけてみたのと、実際に L7 の CiliumNetworkPolicy を試してみたのでそのまとめ

対象の Cilium バージョンは 1.15.4

CiliumNetworkPolicy リソースの反映

まずは CiliumNetworkPolicy リソースを作成した際、どのように反映されるかを調べてみた

pkg/k8s/watchers/cilium_network_policy.goK8sWatcher.ciliumNetworkPoliciesInit() で CiliumNetworkPolicy リソースの追加、更新、削除イベントを検知している

ポリシーの反映にはエンドポイント再構築 (eBPF プログラムの再ロードと eBPF Map の更新) を伴うようで、cilium/daemon/cmd/policy.goDaemon.policyAdd() でポリシーリポジトリにポリシーを配布し、エンドポイントの再構築をキュー経由でトリガーしている

エンドポイントの再構築は cilium/pkg/endpoint/policy.goEndpoint.regenerate() で行われている

Cilium Network Policy は eBPF で実装されているので、eBPF でルールを扱えるように pkg/endpoint/bpf.goEndpoint.addPolicyKey() で eBPF Map へ変換され、書き込みは pkg/bpf/map_linux.goMap.Update() で行われている

Go からみた eBPF Map の構造体は pkg/maps/policymap/policymap.goPolicyKey で定義されている

type PolicyKey struct {
    Prefixlen        uint32 `align:"lpm_key"`
    Identity         uint32 `align:"sec_label"`
    TrafficDirection uint8  `align:"egress"`
    Nexthdr          uint8  `align:"protocol"`
    DestPortNetwork  uint16 `align:"dport"` // In network byte-order
}

eBPF からみた eBPF Map の構造体は bpf/lib/common.hpolicy_key で定義されている

struct policy_key {
    struct bpf_lpm_trie_key lpm_key;
    __u32       sec_label;
    __u8        egress:1,
                pad:7;
    __u8        protocol; /* can be wildcarded if 'dport' is fully wildcarded */
    __u16       dport; /* can be wildcarded with CIDR-like prefix */
};

これで L3/L4 の Cilium Network Policy は eBPF で評価できるはず

Cilium Network Policy は L7 にも対応しており、eBPF ではなく Envoy で制御されている
Envoy へポリシーを配布しているのは pkg/envoy/xds_server.goxdsServer.UpdateNetworkPolicy()

if policy != nil {
    policyRevision := policy.Revision
    callback = func(err error) {
        if err == nil {
            go ep.OnProxyPolicyUpdate(policyRevision)
        }
    }
}

proxyPolicyRevision を更新して Envoy に反映している

func (e *Endpoint) OnProxyPolicyUpdate(revision uint64) {
    // NOTE: unconditionalLock is used here because this callback has no way of reporting an error
    e.unconditionalLock()
    if revision > e.proxyPolicyRevision {
        e.proxyPolicyRevision = revision
    }
    e.unlock()
}

Cilium Network Policy による評価

続いては Cilium Network Policy でパケットをどのように評価し、パスしたりドロップしたりしているか、Endpoint to Endpoint (同一ホスト上の Pod to Pod) 通信、かつ IPv4 の Egress Policy (通信元でのポリシー評価) ケースでの実装を追ってみる

Endpoint to Endpoint においてパケットがどのように処理されるかの概要は 公式ドキュメント がわかりやすい

Endpoint to Endpoint 通信の場合、コンテナからパケットが出る際の tc でフックされ bpf/bpf_lxc.ccil_from_container() が呼び出される

L3/L4 のポリシーを評価しているのは bpf/lib/policy.h__policy_can_access() で、パケットから得られた情報を eBPF Map と同じ構造にセットし、システムコール bpf_map_lookup_elem で eBPF Map と突き合わせを行いポリシーを評価している

struct policy_key key = {
    .lpm_key = { POLICY_FULL_PREFIX, {} }, /* always look up with unwildcarded data */
    .sec_label = remote_id,
    .egress = !dir,
    .pad = 0,
    .protocol = proto,
    .dport = dport,
};

[...]

policy = map_lookup_elem(map, &key);

if (likely(policy && !policy->wildcard_dport)) {
    cilium_dbg3(ctx, DBG_L4_CREATE, remote_id, local_id,
            dport << 16 | proto);
    *match_type = POLICY_MATCH_L3_L4;       /* 1. id/proto/port */
    goto check_policy;
}

[...]

check_policy:
    return __account_and_check(ctx, policy, ext_err, proxy_port);

L7 のポリシーを評価しているのは Envoy で、eBPF からのパケットリダイレクトには Linux カーネルの transparent proxy (tproxy) を利用しているとのこと

tproxy が有効な場合、bpf/lib/proxy.h__ctx_redirect_to_proxy() から bpf_sk_assign() ヘルパー関数を呼び出し、tproxy のソケットをアサインしてパケットをリダイレクトしている

#ifdef ENABLE_TPROXY
static __always_inline int
assign_socket_tcp(struct __ctx_buff *ctx,
          struct bpf_sock_tuple *tuple, __u32 len, bool established)
{
    int result = DROP_PROXY_LOOKUP_FAILED;
    struct bpf_sock *sk;
    __u32 dbg_ctx;

    sk = skc_lookup_tcp(ctx, tuple, len, BPF_F_CURRENT_NETNS, 0);
    if (!sk)
        goto out;

    if (established && sk->state == BPF_TCP_TIME_WAIT)
        goto release;
    if (established && sk->state == BPF_TCP_LISTEN)
        goto release;

    dbg_ctx = sk->family << 16 | ctx->protocol;
    result = sk_assign(ctx, sk, 0);
    cilium_dbg(ctx, DBG_SK_ASSIGN, -result, dbg_ctx);
    if (result == 0)
        result = CTX_ACT_OK;
    else
        result = DROP_PROXY_SET_FAILED;
release:
    sk_release(sk);
out:
    return result;
}

試してみる

Envoy へのパケットのリダイレクトを除き、概ね仕組みがわかったので実際に試してみる

まずは、Kind でさくっとクラスタを立ち上げて Cilium をインストールする

$ cat << EOF > cluster.yaml
apiVersion: kind.x-k8s.io/v1alpha4
kind: Cluster
nodes:
  - role: control-plane
    extraPortMappings:
      - containerPort: 30599
        hostPort: 8080
        listenAddress: 127.0.0.1
  - role: worker
  - role: worker
  - role: worker
networking:
  disableDefaultCNI: true
  podSubnet: 10.10.0.0/16
  serviceSubnet: 10.11.0.0/16
  apiServerAddress: 127.0.0.1
  apiServerPort: 6443
EOF

$ kind create cluster --config cluster.yaml

$ helm install cilium cilium/cilium --version 1.15.4 \
    --namespace kube-system \
    --set envoy.enabled=true

$ kubectl get po -n kube-system
NAME                                         READY   STATUS    RESTARTS   AGE
cilium-8d4qp                                 1/1     Running   0          22m
cilium-envoy-2676w                           1/1     Running   0          22m
cilium-envoy-bhbrn                           1/1     Running   0          22m
cilium-envoy-thlmq                           1/1     Running   0          22m
cilium-envoy-wzkvw                           1/1     Running   0          22m
cilium-jnptw                                 1/1     Running   0          22m
cilium-operator-5d64788c99-rhl76             1/1     Running   0          22m
cilium-operator-5d64788c99-v7dtk             1/1     Running   0          22m
cilium-tpmkw                                 1/1     Running   0          22m
cilium-twp5s                                 1/1     Running   0          22m
coredns-76f75df574-dsb9n                     1/1     Running   0          23m
coredns-76f75df574-xgx7z                     1/1     Running   0          23m
etcd-kind-control-plane                      1/1     Running   0          23m
kube-apiserver-kind-control-plane            1/1     Running   0          23m
kube-controller-manager-kind-control-plane   1/1     Running   0          23m
kube-proxy-fq296                             1/1     Running   0          22m
kube-proxy-j2wfc                             1/1     Running   0          22m
kube-proxy-lppbc                             1/1     Running   0          23m
kube-proxy-pdztg                             1/1     Running   0          22m
kube-scheduler-kind-control-plane            1/1     Running   0          23m

テスト用のクライアントとサーバアプリケーションを起動する

$ kubectl create ns sample
$ kubectl run client --image alpine --labels 'app=client' -n sample --command -- sleep infinity
$ kubectl exec client -c client -n sample -- sh -c 'apk --update add curl ca-certificates'
$ kubectl run server --image hashicorp/http-echo --labels 'app=server' --port 5678 -n sample -- -text='hello world'
$ kubectl get po -n sample
NAME     READY   STATUS    RESTARTS   AGE
client   1/1     Running   0          10m
server   1/1     Running   0          3m35s

$ kubectl expose po server --port=5678 --target-port=5678 -n sample
$ kubectl get svc -n sample
NAME     TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
server   ClusterIP   10.11.129.123   <none>        5678/TCP   3s

$ kubectl exec client -n sample -- curl -s server.sample.svc.cluster.local:5678
hello world

ここから L7 の CiliumNetworkPolicy を作成していくのだが、その前に現状のポリシーの様子を確認してみる
今回は Egress Policy を検証するため、client アプリケーションのエンドポイントを確認する

$ kubectl get po -n sample -o wide
NAME     READY   STATUS    RESTARTS   AGE   IP          NODE           NOMINATED NODE   READINESS GATES
client   1/1     Running   0          33m   10.0.2.42   kind-worker2   <none>           <none>
server   1/1     Running   0          32m   10.0.1.97   kind-worker3   <none>           <none>

$ kubectl get po -n kube-system -l k8s-app=cilium -o wide | grep kind-worker2
cilium-jnptw   1/1     Running   0          64m   172.18.0.3   kind-worker2         <none>           <none>

client アプリケーションのエンドポイントを確認してみると 1955 であるとわかる

$ kubectl exec cilium-jnptw -c cilium-agent -n kube-system -- cilium-dbg endpoint list
ENDPOINT   POLICY (ingress)   POLICY (egress)   IDENTITY   LABELS (source:key[=value])                                             IPv6   IPv4        STATUS
           ENFORCEMENT        ENFORCEMENT
191        Disabled           Disabled          4          reserved:health                                                                10.0.2.26   ready
1402       Disabled           Disabled          1          reserved:host                                                                              ready
1955       Disabled           Enabled           28112      k8s:app=client                                                                 10.0.2.42   ready
                                                           k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=sample
                                                           k8s:io.cilium.k8s.policy.cluster=default
                                                           k8s:io.cilium.k8s.policy.serviceaccount=default
                                                           k8s:io.kubernetes.pod.namespace=sample

CiliumNetworkPolicy を作成してないエンドポイントの状態

$ kubectl exec cilium-jnptw -c cilium-agent -n kube-system -- cilium-dbg bpf policy get -n 1955
POLICY   DIRECTION   IDENTITY   PORT/PROTO   PROXY PORT   AUTH TYPE   BYTES   PACKETS   PREFIX
Allow    Ingress     0          ANY          NONE         disabled    0       0         0
Allow    Ingress     1          ANY          NONE         disabled    0       0         0
Allow    Egress      0          ANY          NONE         disabled    0       0         0

CiliumNetworkPolicy を作成してみる

$ cat << EOF | kubectl apply -f -
apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: sample
  namespace: sample
spec:
  endpointSelector:
    matchLabels:
      app: client
  egress:
  - toEndpoints:
    - matchLabels:
        app: server
    toPorts:
    - ports:
      - port: '5678'
        protocol: TCP
      rules:
        http:
        - method: GET
          path: /
  - toEndpoints:
    - matchLabels:
        k8s:io.kubernetes.pod.namespace: kube-system
        k8s-app: kube-dns
EOF

Egress Policy が増えていることが確認できる

$ kubectl exec cilium-jnptw -c cilium-agent -n kube-system -- cilium-dbg bpf policy get -n 1955
POLICY   DIRECTION   IDENTITY   PORT/PROTO   PROXY PORT   AUTH TYPE   BYTES   PACKETS   PREFIX
Allow    Ingress     0          ANY          NONE         disabled    0       0         0
Allow    Ingress     1          ANY          NONE         disabled    0       0         0
Allow    Egress      8600       ANY          NONE         disabled    0       0         0
Allow    Egress      11584      5678/TCP     12440        disabled    0       0         24
Allow    Egress      15567      ANY          NONE         disabled    0       0         0

cilium-agent のログを確認してみると、ソースコードを追いかけてわかったように、k8s-watcher で CiliumNetworkPolicy リソースを検知したのち、ポリシーがエンドポイントにインポートされ、eBPF プログラムと eBPF Map の再構築が行われている様子がわかる

$ kubectl logs cilium-jnptw -n kube-system
time="2024-06-07T06:32:28Z" level=info msg="Policy Add Request" ciliumNetworkPolicy="[&{EndpointSelector:{\"matchLabels\":{\"any:app\":\"client\",\"k8s:io.kubernetes.pod.namespace\":\"sample\"}} NodeSelector:{} Ingress:[] IngressDeny:[] Egress:[{EgressCommonRule:{ToEndpoints:[{\"matchLabels\":{\"any:app\":\"server\",\"k8s:io.kubernetes.pod.namespace\":\"sample\"}}] ToRequires:[] ToCIDR: ToCIDRSet:[] ToEntities:[] ToServices:[] ToGroups:[] aggregatedSelectors:[]} ToPorts:[{Ports:[{Port:5678 Protocol:TCP}] TerminatingTLS:<nil> OriginatingTLS:<nil> ServerNames:[] Listener:<nil> Rules:0xc0003b51f0}] ToFQDNs:[] ICMPs:[] Authentication:<nil>} {EgressCommonRule:{ToEndpoints:[{\"matchLabels\":{\"any:k8s-app\":\"kube-dns\",\"k8s:io.kubernetes.pod.namespace\":\"kube-system\"}}] ToRequires:[] ToCIDR: ToCIDRSet:[] ToEntities:[] ToServices:[] ToGroups:[] aggregatedSelectors:[]} ToPorts:[] ToFQDNs:[] ICMPs:[] Authentication:<nil>}] EgressDeny:[] Labels:[k8s:io.cilium.k8s.policy.derived-from=CiliumNetworkPolicy k8s:io.cilium.k8s.policy.name=sample k8s:io.cilium.k8s.policy.namespace=sample k8s:io.cilium.k8s.policy.uid=7756e978-2aa8-4baf-a4ed-9cfe64d01088] Description:}]" policyAddRequest=5eb088ab-a5a0-4cc0-b319-3af097f3ce51 subsys=daemon
time="2024-06-07T06:32:28Z" level=info msg="Imported CiliumNetworkPolicy" ciliumNetworkPolicyName=sample k8sApiVersion= k8sNamespace=sample subsys=k8s-watcher
time="2024-06-07T06:32:28Z" level=info msg="Policy imported via API, recalculating..." policyAddRequest=5eb088ab-a5a0-4cc0-b319-3af097f3ce51 policyRevision=26 subsys=daemon
time="2024-06-07T06:32:29Z" level=info msg="Re-pinning map with ':pending' suffix" bpfMapName=cilium_calls_01955 bpfMapPath=/sys/fs/bpf/tc/globals/cilium_calls_01955 subsys=bpf
time="2024-06-07T06:32:29Z" level=info msg="Unpinning map after successful recreation" bpfMapName=cilium_calls_01955 bpfMapPath="/sys/fs/bpf/tc/globals/cilium_calls_01955:pending" subsys=bpf
time="2024-06-07T06:32:29Z" level=info msg="Rewrote endpoint BPF program" ciliumEndpointName=sample/client containerID=7f7d63e703 containerInterface= datapathPolicyRevision=25 desiredPolicyRevision=26 endpointID=1955 identity=28112 ipv4=10.0.2.42 ipv6= k8sPodName=sample/client subsys=endpoint

せっかくなので、ポリシーの eBPF Map を確認しようとしたがキャッシュが無効化されており参照できないとのこと

$ kubectl exec cilium-jnptw -c cilium-agent -n kube-system -- cilium-dbg map list
Name                      Num entries   Num errors   Cache enabled
cilium_tunnel_map         3             0            true
cilium_lb4_services_v2    12            0            true
cilium_lb4_reverse_nat    5             0            true
cilium_policy_01402       0             0            false
cilium_policy_01955       0             0            false
cilium_runtime_config     0             0            false
cilium_ipcache            18            0            true
cilium_lb4_backends_v3    7             0            true
cilium_lb4_source_range   0             0            true
cilium_policy_00191       0             0            false
cilium_lxc                4             0            true
cilium_auth_map           0             0            false
cilium_node_map           0             0            false
cilium_metrics            0             0            false
cilium_l2_responder_v4    0             0            false

$ kubectl exec cilium-jnptw -c cilium-agent -n kube-system -- cilium-dbg map get cilium_policy_01955
Cache is disabled

念の為、アプリケーションの疎通確認

$ kubectl exec client -n sample -- curl -s server.sample.svc.cluster.local:5678
hello world

CiliumNetworkPolicy での許可メソッドを POST に変更すると、しっかりと拒否されることも確認できた

$ cat << EOF | kubectl apply -f -
apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: sample
  namespace: sample
spec:
  endpointSelector:
    matchLabels:
      app: client
  egress:
  - toEndpoints:
    - matchLabels:
        app: server
    toPorts:
    - ports:
      - port: '5678'
        protocol: TCP
      rules:
        http:
        - method: POST
          path: /
  - toEndpoints:
    - matchLabels:
        k8s:io.kubernetes.pod.namespace: kube-system
        k8s-app: kube-dns
EOF

$ kubectl exec client -n sample -- curl -s server.sample.svc.cluster.local:5678
Access denied

さいごに

まだまだ細かいところで何をしているかはわからないけど、Cilium の中で eBPF がどのように利用されているのかが大枠で把握でき、今後業務で利用することがあるかはわからないが、Cilium Network Policy で何かトラブルが起きた時にソースコードから原因分析するというアプローチが取れそう
eBPF については eBPF Map への書き込みや参照がどのように行われているかを把握でてきた程度だが、とりあえず入門としては十分なんじゃないかと思う
どこかで実際に eBPF プログラムのサンプルを書いて動かすということをやってみたい

なお、Cilium 自体への興味も出てきて、kube-proxy を置き換えることができたり、サービスメッシュとしても振る舞ったりするとのことなので、その辺りも深ぼって調査してみたい

さいごに、普段アプリケーションの読み書きをすることがほとんどないためソースコードを追いかけるのに非常に時間がかかったけど、思ったよりも楽しめたのはよかった (次はもっと効率的に作業できるとうれしい)

Reference