TT Lab
Get started
Learn Learning paths Courses

CCA — Cilium Certified Associate

What Happens the Moment You Attach One Policy

Continue in TT Lab

In one line

As soon as even one policy that selects an endpoint is applied, that direction immediately switches to allowlist mode. The directions are independent of each other, and deny always beats allow.

Why this was needed

The default state of Kubernetes is "every Pod can talk to every Pod." A payment service can reach the internal wiki, and a compromised frontend can access the database directly. The starting point of zero trust is to flip this default.

You can start with the standard NetworkPolicy, but in practice you quickly hit a wall. It cannot control at the HTTP path level, it cannot allow an external API by domain name, it has no explicit deny rule so there is no way to express "what must be blocked no matter what," and above all, there is no way to see the denied traffic. CiliumNetworkPolicy fills this gap.

How it works

The mental model is as follows.

        ingress 정책                       egress 정책
  "누가 나에게 올 수 있나"            "나는 어디로 갈 수 있나"

  [소스 아이덴티티] --> (엔드포인트) --> [목적지 아이덴티티/CIDR/FQDN]
                            |
              per-endpoint 정책 맵에서 O(1) 판정
              key: (아이덴티티, 포트, 프로토콜, 방향)

Two rules that cause the most real-world incidents come out of this.

First, the directions are independent. If you attach a single ingress policy to a Pod, that Pod's ingress becomes an allowlist, but its egress is still fully allowed until an egress policy is attached. The misconception that "we applied a policy, so it is safe" comes from here.

Second, deny always beats allow. If both an allow and a deny match the same traffic, the result is a denial. That is why for items that must never be breached, such as blocking the cloud metadata endpoint, you lay down an extra layer with egressDeny.

The subtle differences in selectors are also an exam favorite.

ingress:
  - fromEndpoints:
      - {}        # 같은 네임스페이스의 모든 엔드포인트 = 허용
ingress:
  - fromEndpoints: []   # 아무것도 매칭되지 않음 = 명시적 기본 거부

An array containing one empty object and an empty array have completely opposite meanings.

When an L7 policy is attached, the path changes. eBPF selects only the matching flows and hands them to the node's Envoy, and Envoy parses the HTTP and returns a 403 if the request does not match a rule. Unlike an L4 block, the connection itself is established, so in the application log it looks like "I can connect, but I get a 403." If you do not know this difference, you mistake it for a firewall problem and dig in the wrong place.

The most common configuration mistake is a different one. It is using toFQDNs without also applying a DNS rule. toFQDNs does not work by looking into the SNI of packets. Cilium's DNS proxy intercepts that Pod's DNS responses and learns that "this Pod has just found out that api.example.com is 54.x.x.x," registers that IP in the ipcache, and allows it on an IP basis. Therefore, if there is no rule that allows DNS queries and lets the proxy observe them, toFQDNs never matches. The symptom shows up as "DNS works but the connection is denied," or the reverse.

The production rollout is done in four stages. Observe (collect the real communication matrix with Hubble) → deploy only allow policies first → record only the "traffic that would have been blocked" with policy-audit-mode → enforce namespace by namespace. To avoid missing traffic that runs rarely, such as batch jobs, it is safer to extend the observation period to at least one monthly cycle.

Finally, the standard NetworkPolicy and CNP are evaluated on the same eBPF datapath and are summed up so that if either one allows, it is allowed. If you mix the two kinds, you have to look in two places to answer "why is this open?", so it is better for the team to settle on a single standard resource.

What it looks like in the field

The author measured this in the homelab. When nginx was brought up and called without a policy, both GET and POST returned 200. Then a CiliumNetworkPolicy containing only rules.http: [{method: GET}] was applied, and the results split like this.

GET  /  -> 200
POST /  -> 403

Same IP, same port, but it split by method. No sidecar was injected into the Pod, and the application code was unchanged. iptables works at L3/L4, so it cannot make this distinction in principle.

What was even more interesting was the shape of the Hubble log.

client:47918 -> api:80  http-request  FORWARDED (HTTP/1.1 GET  http://api/)
client:47932 -> api:80  http-request  DROPPED   (HTTP/1.1 POST http://api/)
client:47932 <- api:80  http-response FORWARDED (HTTP/1.1 403 0ms POST)

The request is DROPPED but the response is FORWARDED. This is because the 403 generated by the proxy flowed out as a normal response. With an L4 drop, there would be no response line at all. This difference in log shape is the fastest clue for telling apart "blocked by a policy" from "the network is cut."

What you will do in the next lab

You will actually apply the default deny and Pod/namespace selector allows with the standard NetworkPolicy, and write to files a CiliumNetworkPolicy that restricts by HTTP method and path, the DNS rule that pairs with toFQDNs, and a block of the metadata endpoint.