eBPF for Granular L7 Network Policy Enforcement in Cilium

15 min read
Goh Ling Yong
Technology enthusiast and software architect specializing in AI-driven development tools and modern software engineering practices. Passionate about the intersection of artificial intelligence and human creativity in building tomorrow's digital solutions.

The L3/L4 Ceiling of Standard Kubernetes Network Policies

As a senior engineer operating in a Kubernetes environment, you are undoubtedly familiar with the native NetworkPolicy resource. It's the foundational tool for network segmentation, allowing you to define ingress and egress rules for pods based on IP blocks, namespaces, and label selectors. While essential for basic isolation, NetworkPolicy operates exclusively at OSI Layers 3 (IP) and 4 (TCP/UDP). This presents a significant limitation in modern microservices architectures.

Consider a common scenario: a billing-service pod exposing a REST API on port 8080. This API has two endpoints:

  • POST /v1/charge: A sensitive operation that initiates a payment, accessible only by the payment-frontend service.
  • GET /healthz: A standard health check endpoint, accessible by the cluster's prometheus-scraper.
  • Using a standard NetworkPolicy, you can restrict traffic to port 8080 from specific sources.

    yaml
    apiVersion: networking.k8s.io/v1
    kind: NetworkPolicy
    metadata:
      name: billing-service-policy
      namespace: production
    spec:
      podSelector:
        matchLabels:
          app: billing-service
      policyTypes:
      - Ingress
      ingress:
      - from:
        - podSelector:
            matchLabels:
              app: payment-frontend
        - podSelector:
            matchLabels:
              app: prometheus-scraper
        ports:
        - protocol: TCP
          port: 8080

    This policy successfully limits who can talk to the billing-service on port 8080. However, it fails to limit what they can say. The prometheus-scraper pod, which only needs access to /healthz, is now implicitly granted network access to the sensitive /v1/charge endpoint. While your application-level authorization should be the primary defense, this network configuration violates the principle of least privilege, expanding the potential attack surface.

    This is the L3/L4 ceiling. To achieve true zero-trust networking, we need to enforce policy based on Layer 7 context—the application protocol itself (e.g., HTTP method/path, gRPC service/method, Kafka topic). This is where the combination of eBPF and Cilium becomes a game-changer.

    Cilium and eBPF: Reprogramming Kernel-Level Networking

    Cilium bypasses the traditional iptables-based networking implementation used by many Kubernetes CNIs. Instead, it attaches lightweight, sandboxed programs directly to various hooks within the Linux kernel's networking stack using eBPF (extended Berkeley Packet Filter). For pod-to-pod communication, Cilium typically attaches eBPF programs to the Traffic Control (tc) hook on the virtual ethernet (veth) devices within the pod's network namespace.

    When a packet leaves or enters a pod, it traverses this tc hook and triggers the attached eBPF program. This program, running in kernel space, can inspect, modify, redirect, or drop the packet with extreme efficiency.

    The key performance advantages are:

  • Bypassing iptables Chains: iptables relies on sequential rule chains. As the number of services and policies grows, traversing these chains can introduce significant latency. eBPF programs can use hash maps (e.g., BPF_MAP_TYPE_HASH) for near O(1) lookups of policy and endpoint information, maintaining performance at scale.
  • Stateful Tracking in eBPF Maps: Cilium's eBPF programs maintain connection tracking tables in eBPF maps, enabling stateful firewalling directly in the kernel without the overhead of the Netfilter conntrack module.
  • Source-of-Truth Identity: Cilium doesn't rely solely on ephemeral pod IPs for identity. It maps pod labels and other metadata to a security identity (a numeric ID) and stores this in eBPF maps. Policy decisions are made based on these stable identities, not transient IPs.
  • This kernel-level programmability allows Cilium to parse application-layer protocols and enforce L7 policies before the packet even reaches the application's socket buffer, providing a highly efficient and secure enforcement point.

    Deep Dive: Implementing L7 Policies with `CiliumNetworkPolicy`

    To unlock these L7 capabilities, we use the CiliumNetworkPolicy Custom Resource Definition (CRD), an extended superset of the standard NetworkPolicy.

    Let's solve our initial billing-service problem. We'll create a policy that enforces the following rules:

  • Allow payment-frontend to POST /v1/charge.
  • Allow prometheus-scraper to GET /healthz.
  • Deny all other ingress traffic to the billing-service on port 8080.
  • Here is the complete CiliumNetworkPolicy implementation:

    yaml
    # billing-service-cnp.yaml
    apiVersion: "cilium.io/v2"
    kind: CiliumNetworkPolicy
    metadata:
      name: "api-aware-billing-policy"
      namespace: "production"
    spec:
      endpointSelector:
        matchLabels:
          app: billing-service
      ingress:
      - fromEndpoints:
        - matchLabels:
            app: payment-frontend
        toPorts:
        - ports:
          - port: "8080"
            protocol: TCP
          rules:
            http:
            - method: "POST"
              path: "/v1/charge"
              headers:
              - "X-Transaction-ID"
      - fromEndpoints:
        - matchLabels:
            app: prometheus-scraper
        toPorts:
        - ports:
          - port: "8080"
            protocol: TCP
          rules:
            http:
            - method: "GET"
              path: "/healthz"

    Dissecting the L7 Policy

  • endpointSelector: This is identical to the standard NetworkPolicy and targets the pods to which the policy applies (app: billing-service).
  • ingress: We define a list of ingress rules.
  • fromEndpoints: This selects the source pods for the rule. We have two distinct blocks, one for payment-frontend and one for prometheus-scraper.
  • toPorts and rules: This is where the magic happens. Inside the toPorts block, we define a rules section of type http.
  • - For the payment-frontend, the rule specifies that only POST requests to the exact path /v1/charge are allowed.

    - We've added an additional constraint: headers: ["X-Transaction-ID"]. This powerful feature enforces that the specified HTTP header must be present in the request (though it doesn't check the value). This can prevent accidental or malicious requests from clients that aren't setting the correct application context.

    - For the prometheus-scraper, a separate rule allows only GET requests to /healthz.

    When this policy is applied, Cilium's agent compiles it into eBPF bytecode and loads it into the kernel. When a TCP packet arrives at the billing-service pod on port 8080, the eBPF program begins assembling the TCP stream. Once it identifies an HTTP request, it parses the method and path from the initial request line and headers. It then performs a lookup in its policy maps. If the source identity, destination port, HTTP method, and path match an allowed rule, the packet is forwarded. If not, it is dropped at the kernel level, and the connection is terminated. The application inside the pod never even sees the denied request.

    Advanced Scenarios and Production Patterns

    L7 policy enforcement extends far beyond simple HTTP. Cilium includes built-in parsers for other common protocols, enabling fine-grained control in more complex systems.

    Scenario 1: Kafka-Aware Policies

    In an event-driven architecture, securing Kafka topics is critical. You may want to ensure that a user-service can only produce to the user-signups topic, while an analytics-service can only consume from the processed-events topic.

    Standard NetworkPolicy can only open or close the Kafka port (9092), offering no topic-level security. CiliumNetworkPolicy can parse the Kafka protocol.

    Problem: Allow user-service to produce to user-signups topic and analytics-service to consume from processed-events topic, denying all other Kafka operations.

    Solution:

    yaml
    # kafka-secure-policy.yaml
    apiVersion: "cilium.io/v2"
    kind: CiliumNetworkPolicy
    metadata:
      name: "kafka-broker-policy"
      namespace: "data-platform"
    spec:
      endpointSelector:
        matchLabels:
          app: kafka-broker
      ingress:
      - fromEndpoints:
        - matchLabels:
            app: user-service
        toPorts:
        - ports:
          - port: "9092"
            protocol: TCP
          rules:
            kafka:
            - role: "produce"
              topic: "user-signups"
      - fromEndpoints:
        - matchLabels:
            app: analytics-service
        toPorts:
        - ports:
          - port: "9092"
            protocol: TCP
          rules:
            kafka:
            - role: "consume"
              topic: "processed-events"
              # Optional: Restrict by consumer group ID
              # consumerGroupID: "analytics-worker-group"

    Under the Hood: The eBPF program attached by Cilium understands the Kafka wire protocol. It inspects the ApiKey in the request header to identify the operation type (Produce, Fetch, etc.). For Produce requests, it parses the topic name from the request payload. For Fetch requests (part of the consume operation), it does the same. This allows it to enforce policy based on the role (produce/consume) and topic directly in the kernel, preventing, for example, the user-service from attempting to consume from any topic.

    Scenario 2: gRPC-aware Policies via Envoy Integration

    While Cilium's kernel-level eBPF parsers are incredibly fast, they are limited to protocols for which a parser has been explicitly written (e.g., HTTP, Kafka, DNS). For more complex protocols like gRPC (which is layered on HTTP/2), direct parsing in eBPF is challenging.

    For these cases, Cilium integrates with user-space proxies like Envoy. By annotating a pod, you instruct Cilium to redirect traffic through an Envoy sidecar. The policy is still defined in CiliumNetworkPolicy, but enforcement for that specific port is delegated to Envoy.

    Problem: A user-service exposes a gRPC API. We want to allow the frontend-service to call the GetUser method but explicitly deny it from calling the sensitive DeleteUser method.

    Solution:

    First, we need to annotate the user-service Deployment to enable proxy redirection for the gRPC port (50051).

    yaml
    # user-service-deployment.yaml
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: user-service
      namespace: services
    spec:
      template:
        metadata:
          annotations:
            # Redirect traffic on port 50051 to the proxy
            proxy.cilium.io/policy-enabled-ingress: "50051:grpc"
    # ... rest of deployment spec

    Next, we define the CiliumNetworkPolicy. Since gRPC calls are essentially HTTP/2 POST requests, we write an HTTP rule matching the specific gRPC path format: /package.Service/Method.

    yaml
    # grpc-user-service-policy.yaml
    apiVersion: "cilium.io/v2"
    kind: CiliumNetworkPolicy
    metadata:
      name: "grpc-user-service-policy"
      namespace: "services"
    spec:
      endpointSelector:
        matchLabels:
          app: user-service
      ingress:
      - fromEndpoints:
        - matchLabels:
            app: frontend-service
        toPorts:
        - ports:
          - port: "50051"
            protocol: TCP
          rules:
            http:
            - method: "POST"
              path: "/user.v1.UserService/GetUser"

    Execution Flow:

  • The frontend-service makes a gRPC call to user-service.
  • The eBPF program on the user-service pod's network interface intercepts the incoming packet on port 50051.
    • Noticing the proxy redirection configuration for this port, the eBPF program redirects the packet to the Envoy proxy running in the same pod.
    • Envoy, which has a full HTTP/2 and gRPC parser, inspects the request.
  • Cilium configures Envoy with the rules from the CiliumNetworkPolicy. Envoy checks if the path (/user.v1.UserService/GetUser) is allowed from the source.
  • If allowed, Envoy forwards the request to the application container on its localhost port. If a call to DeleteUser were made, Envoy would see the path /user.v1.UserService/DeleteUser, find no matching rule, and return a Permission Denied gRPC error to the client.
  • This demonstrates a powerful hybrid model: use hyper-efficient eBPF for protocols it supports natively and seamlessly delegate to a feature-rich user-space proxy for more complex L7 inspection, all managed through a unified policy API.

    Edge Cases, Performance, and Observability

    Edge Case: Handling TLS Encrypted Traffic

    A critical question arises: how can L7 policies be enforced on encrypted traffic? The eBPF program in the kernel sees only encrypted TLS/SSL payloads and cannot inspect the HTTP path or Kafka topic.

    There are two primary production patterns to address this:

  • Terminate TLS at the Sidecar/Proxy: When using the Envoy integration model (as in our gRPC example), the solution is straightforward. You configure the Envoy proxy to terminate the client's TLS session. Envoy then has access to the plaintext traffic, enforces the L7 policy, and can optionally re-encrypt the traffic before forwarding it to the application container. This is a core pattern in service meshes like Istio.
  • Terminate TLS at an Ingress/Gateway: For traffic entering the cluster, TLS is typically terminated at a centralized Ingress controller (e.g., Nginx, Traefik) or a Kubernetes Gateway API implementation. The traffic from the Ingress to the backend service is then plaintext. The CiliumNetworkPolicy is applied to this internal, unencrypted traffic segment. This secures pod-to-pod communication within the cluster, assuming the Ingress-to-pod path is on a trusted network.
  • Direct TLS inspection in the kernel via eBPF is an area of active research but is not yet a mainstream, practical solution for arbitrary TLS traffic due to the complexities of session key management.

    Performance Considerations: Kernel vs. Userspace

  • Kernel-space (eBPF-native): Enforcement is exceptionally fast. The overhead for L7 parsing is measured in microseconds. Because it operates on a per-packet basis and is JIT-compiled, the performance is close to native kernel processing. This is the preferred method for supported protocols like HTTP and Kafka.
  • Userspace (Envoy Proxy): Incurring a trip through the user-space proxy adds latency. This involves context switching and data copying between kernel and user space. The additional latency is typically in the low single-digit milliseconds per request but can be higher under heavy load. This trade-off is often acceptable for the deep protocol inspection capabilities Envoy provides for protocols like gRPC, Dubbo, or for applying complex traffic shaping rules.
  • Your decision should be based on the protocol in use and your application's latency sensitivity. The beauty of Cilium's model is that you can mix and match, applying kernel-level policies for one port and proxy-based policies for another on the same pod.

    Observability with Hubble: Closing the Loop

    Defining policies is only half the story. You must be able to observe their effects, debug issues, and verify compliance. Cilium's observability component, Hubble, leverages the same eBPF data sources to provide deep visibility into network flows.

    When a request from payment-frontend to billing-service is denied by our L7 policy, you can see it clearly with the Hubble CLI:

    bash
    # Filter for traffic to the billing-service that was dropped
    $ hubble observe --to-pod production/billing-service-xxxxxxxx-yyyyy --verdict DROPPED -o json
    
    {
      "flow": {
        "source": {
          "identity": 256,
          "namespace": "production",
          "labels": ["k8s:app=payment-frontend", ...]
        },
        "destination": {
          "identity": 512,
          "namespace": "production",
          "labels": ["k8s:app=billing-service", ...]
        },
        "L4": {
          "TCP": { "destination_port": 8080 }
        },
        "L7": {
          "http": {
            "method": "POST",
            "url": "/v1/internal/admin-charge",
            "headers": { ... }
          }
        },
        "verdict": "DROPPED",
        "drop_reason_desc": "Policy denied at L7"
      }
    }

    This output is invaluable for a senior developer or SRE. It doesn't just say a packet was dropped; it provides the full L3/L4/L7 context. You can see the source and destination identities, the labels, the port, and critically, the exact HTTP method and path (/v1/internal/admin-charge) that caused the policy violation. This immediate, detailed feedback loop drastically reduces the time required to diagnose and resolve connectivity issues in a zero-trust environment.

    Conclusion: The Future of Cloud-Native Security is Programmable

    Moving beyond L3/L4 network policies is no longer optional for robust security in complex microservices environments. By leveraging eBPF, Cilium pushes policy enforcement down into the Linux kernel, enabling API-aware security that is both highly performant and incredibly granular. The ability to define rules based on HTTP paths, Kafka topics, or gRPC methods via the CiliumNetworkPolicy CRD allows engineers to build truly zero-trust networks that adhere to the principle of least privilege at the application layer.

    While standard NetworkPolicy remains a useful tool for coarse-grained isolation, the future of cloud-native security lies in this programmable, identity-aware, and L7-conscious approach. Mastering these advanced patterns is becoming a critical skill for senior engineers responsible for building and securing scalable, resilient systems on Kubernetes.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles