We close the module with the lesson that fixes the hole we have been dragging along since module 3: any pod in the cluster can connect to bookings-postgres:5432. A compromised pod, a malicious dependency in an image or a misdirected deployment in rutas-norte-dev currently has direct access to the database holding the name, ID number, phone and email of every Rutas Norte customer. NetworkPolicies are the native Kubernetes firewall, and in this lesson we will build the platform's isolation model step by step: deny everything, open DNS —the mistake that takes down the whole cluster and that almost everybody makes once—, and authorise one by one only the conversations the platform needs, verifying each rule with ephemeral pods.
Contents
- Demonstrating the problem: the network is flat
- The indispensable requirement: a CNI that implements them
- Anatomy of a NetworkPolicy
- The additive model: only allow, never deny
from/to:podSelector,namespaceSelectorandipBlock- The costliest mistake: AND versus OR
- Step 1: deny everything in
rutas-norte-pro - Step 2: allow DNS (or break everything)
- Step 3: the platform's conversations
- Step 4: the Ingress and the external payment gateway
- Systematic verification
- Real limitations
- Demonstrating the problem: the network is flat
Before fixing anything, let us see it. We launch in production a pod that has no relationship at all with the platform: no Rutas Norte labels, no dedicated ServiceAccount, nothing.
kubectl run intruder --rm -it --restart=Never -n rutas-norte-pro \
--image=nicolaka/netshoot -- bash
# And from inside the intruder pod, everything answers:
nc -zv bookings-postgres 5432 # the database with the personal data
nc -zv redis-cache 6379 # the cache
nc -zv bookings-postgres.rutas-norte-dev 5432 # ANOTHER environment: namespaces do not isolate
curl -s -o /dev/null https://pagos.proveedorexterno.example/ # and egress to the internetEverything works. It is rule 2 of the network model from 04-01: every pod can talk to every pod without NAT. It is not a bug: it is the specified behaviour. And its implications are serious:
| Scenario | Consequence today |
|---|---|
A vulnerability in web-store is exploited (nginx exposed to the internet) |
From that pod you reach the database directly |
A dependency of notifications-worker turns out to be malicious |
It can exfiltrate data to any destination on the internet |
A badly configured test in dev |
It can write to production's bookings-postgres |
We have already added layers —minimal identity with ServiceAccounts (03-06) and per-environment quotas (03-04)—; the network layer is what is missing.
- The indispensable requirement: a CNI that implements them
Here is the silent danger of this lesson. Kubernetes defines the NetworkPolicy object, but it does not enforce it. The one that enforces it is the CNI plugin, and if the plugin does not implement it the object is created with no error and no warning at all, kubectl get networkpolicy lists it as normal, kubectl describe shows the rules perfectly… and the traffic keeps flowing exactly as before. You will have a security policy that protects nothing and a false sense of being covered. As we saw in 04-01, Flannel does not implement NetworkPolicy, and it is the default CNI of a great many lab installations, minikube's included.
Checking which CNI you have and enabling Calico
kubectl get pods -n kube-system -o name | grep -Ei "flannel|calico|cilium|weave"
# kube-flannel-ds-h4k2p -> Flannel: the policies will NOT do anything
# A fresh profile with Calico (the CNI cannot be swapped live safely)
minikube start -p rutas-norte --cni=calico \
--addons=ingress,metrics-server,storage-provisioner
kubectl get pods -n kube-system -l k8s-app=calico-nodeAn alternative: --cni=cilium, which also gives L7 policies and observability with Hubble. But never trust the plugin's name: verify with a real test (exercise 1). The idea is to create a target pod in a throwaway namespace, check that it answers, apply an ingress deny-all and check that it stops answering; if it keeps answering, your CNI ignores the policies. Always do this on a new cluster, before writing a single real policy.
- Anatomy of a NetworkPolicy
apiVersion: networking.k8s.io/v1 # stable v1; do not use extensions/v1beta1
kind: NetworkPolicy
metadata:
name: example
namespace: rutas-norte-pro # it ALWAYS has a namespace
spec:
podSelector: # 1. WHICH PODS it applies to (in its namespace)
matchLabels: { app: bookings-postgres }
policyTypes: [Ingress, Egress] # 2. WHICH DIRECTIONS it governs
ingress: # 3. Inbound rules
- from:
- podSelector:
matchLabels: { app: bookings-api }
ports:
- { protocol: TCP, port: 5432 }
egress: # 4. Outbound rules
- to:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: kube-system }
ports:
- { protocol: UDP, port: 53 }| Field | Meaning | Critical detail |
|---|---|---|
podSelector |
The pods protected by this policy | {} (empty) means every pod in the namespace |
policyTypes |
Which directions it governs | If you omit Egress, outbound traffic is not restricted |
ingress[].from / egress[].to |
Sources allowed in / destinations allowed out | Lists; each element is an "OR" |
ports |
Allowed ports and protocols | If omitted, all ports |
Three warnings about policyTypes, which is where most mistakes happen: if you omit it, Kubernetes infers it (it always includes Ingress and only includes Egress if there is an egress block), so write it explicitly; policyTypes: [Ingress] with ingress: [] means "deny all inbound traffic", which is different from not having the field; and policyTypes: [Ingress, Egress] with no rules is total isolation. And the key point: a NetworkPolicy is namespaced and its podSelector only looks inside its own namespace. To protect the three environments you need the policies in all three.
- The additive model: only allow, never deny
The mental model to nail down before writing anything:
flowchart LR
A["Does any NetworkPolicy select<br/>this pod in this direction?"] -->|NO| B["EVERYTHING ALLOWED<br/>(the pod is not isolated)"]
A -->|YES| C["Pod ISOLATED:<br/>everything denied by default"]
C --> D["Does any of those policies<br/>allow this traffic?"]
D -->|"YES (ONE is enough)"| E["ALLOWED"]
D -->|NO| F["DENIED"]
Out of that come four golden rules: with no policies, everything gets through (Rutas Norte's state so far); as soon as ONE policy selects a pod in a direction, that pod becomes isolated in that direction and only what is explicitly allowed gets through; policies are additive and there is no "deny" —there is no deny field, the union of all the policies that select the pod defines what is allowed and it takes just one to authorise it—; and there are no priorities and no ordering, no order and no "last one wins".
Practical consequence: you cannot make a restrictive exception. If a policy lets all of rutas-norte-pro reach bookings-postgres, you cannot add another saying "except this pod": you have to remove the broad permission and enumerate the allowed ones. Another confusing detail: ingress and egress are evaluated separately and at both ends, so for bookings-api to talk to bookings-postgres you need two authorisations —the sender's egress and the receiver's ingress— and if either is missing there is no connection; it is the number one cause of "I allowed the traffic and it still does not work". Finally, policies act on connections and the CNIs that implement them track state, so the reply comes back without needing a return rule.
from/to: podSelector, namespaceSelector and ipBlock
from/to: podSelector, namespaceSelector and ipBlockThere are exactly three ways of identifying the other end.
podSelector: pods in the SAME namespace
A podSelector inside from/to cannot reach other namespaces; that is what the next one is for.
namespaceSelector: every pod in certain namespaces
Kubernetes adds to every namespace the label kubernetes.io/metadata.name holding its name, which saves labelling them by hand; even so it is worth adding your own stable labels (kubectl label namespace rutas-norte-pro environment=pro).
ipBlock: CIDR ranges, for what lives outside the cluster
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0 # the whole internet...
except: [10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16] # ...minus private networks
ports:
- { protocol: TCP, port: 443 }Two warnings: it is not for pods —even though a pod IP is technically inside a CIDR, the implementation treats it as external traffic and it is not reliable; for pods, use selectors— and except only subtracts from the cidr of that same block, it is not a global deny. The 0.0.0.0/0 pattern with an except for the private networks is the standard way of saying "it can go out to the internet, but it cannot pivot towards the company's internal network".
- The costliest mistake: AND versus OR
This section is the one that prevents the most real security incidents. Let us compare two fragments that differ by a single hyphen:
# VERSION A -- CAREFUL: this is an OR (union)
ingress:
- from:
- podSelector:
matchLabels: { app: bookings-api }
- namespaceSelector: # <-- its own hyphen: ANOTHER list element
matchLabels: { environment: pro }
# VERSION B -- this is an AND (intersection)
ingress:
- from:
- podSelector:
matchLabels: { app: bookings-api }
namespaceSelector: # <-- NO hyphen: same element
matchLabels: { environment: pro }| Version A (two hyphens) | Version B (one hyphen) | |
|---|---|---|
| Semantics | podSelector OR namespaceSelector |
podSelector AND namespaceSelector |
| Who gets in | Pods with app=bookings-api in this namespace, plus EVERY pod in any namespace labelled environment=pro |
Only the app=bookings-api pods that are in an environment=pro namespace |
| Risk | Enormous: any production pod reaches the database | Correct |
Version A opens the database to entire namespaces. It is a mistake that slips through code review easily because the YAML looks like it says the opposite, and the cluster gives no warning: the policy is applied and works, it just allows far more than intended. Mnemonic: every hyphen in the from list is an "OR"; the fields inside a single element are an "AND". The same distinction applies to the elements of ingress (a list of rules joined by OR) and to ports inside a rule.
- Step 1: deny everything in
rutas-norte-pro
rutas-norte-proWe start with the foundation: close everything, and then open only what is needed, even if the platform is broken for a few minutes.
# k8s/environments/pro/np-00-deny-all.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all
namespace: rutas-norte-pro
labels: { app.kubernetes.io/part-of: rutas-norte, environment: pro }
spec:
podSelector: {} # {} = EVERY pod in the namespace
policyTypes: [Ingress, Egress]
# no ingress and no egress blocks = nothing is allowedkubectl apply -f k8s/environments/pro/np-00-deny-all.yaml
kubectl exec -n rutas-norte-pro deploy/bookings-api -- \
timeout 5 nc -zv bookings-postgres 5432 || echo "DENIED (expected)"The platform is completely broken, and that is what we wanted: from here on every rule will be a conscious, documented decision. It is least privilege applied to the network. An important note: kubectl exec, kubectl logs and the kubelet's health probes (07-01) are unaffected, because they come from the node and not from another pod; that is why you can carry on debugging with a completely locked-down cluster.
- Step 2: allow DNS (or break everything)
This is the classic mistake with NetworkPolicies, and it deserves its own section because its symptom is thoroughly misleading. With deny-all in force no pod can talk to CoreDNS, and the bookings-api logs show this:
Error: getaddrinfo EAI_AGAIN bookings-postgres
Error: getaddrinfo EAI_AGAIN pagos.proveedorexterno.exampleThe symptom is not "connection refused": it is a name resolution failure after several seconds of waiting, failure type 1 from 04-03. The typical confusion is to investigate CoreDNS, which is perfectly healthy, instead of looking at the egress policy just applied. Making it worse: an egress deny-all in kube-system leaves the whole cluster without DNS; never apply broad policies in the system namespaces.
# k8s/environments/pro/np-01-allow-dns.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-dns-egress, namespace: rutas-norte-pro }
spec:
podSelector: {} # every pod in the namespace needs DNS
policyTypes: [Egress]
egress:
- to:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: kube-system }
podSelector: # NO hyphen: logical AND -> CoreDNS INSIDE kube-system
matchLabels: { k8s-app: kube-dns }
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 } # essential: large answers, NodeLocal DNSCacheThree details that must be respected:
podSelector: {}: every pod needs DNS, without exception. It is always the first rule to be written.- UDP and TCP on 53. TCP is forgotten constantly. DNS falls back to TCP when the answer does not fit in a UDP packet, and in a headless Service with many pods that happens: the symptom is devastating, it works nearly always and fails apparently at random.
namespaceSelector+podSelectorwith no hyphen, so that it is the intersection: CoreDNS insidekube-system. With a hyphen you would open all ofkube-system. Also check the real label on those pods (kubectl get pods -n kube-system --show-labels | grep dns); in some distributions it isk8s-app: coredns.
kubectl apply -f k8s/environments/pro/np-01-allow-dns.yaml
kubectl exec -n rutas-norte-pro deploy/bookings-api -- nslookup bookings-postgres
# resolves correctly
kubectl exec -n rutas-norte-pro deploy/bookings-api -- timeout 5 nc -zv bookings-postgres 5432
# still DENIED: resolving is not connecting ([04-03](04-03-internal-dns-and-service-discovery))
- Step 3: the platform's conversations
We list, one by one, Rutas Norte's legitimate connections:
| Source | Destination | Port | Reason |
|---|---|---|---|
bookings-api |
bookings-postgres |
5432 | Checking availability and creating bookings |
notifications-worker |
bookings-postgres |
5432 | Reading the booking details for the email |
bookings-api |
redis-cache |
6379 | Seat availability cache |
| Ingress controller | web-store |
8080 | Public store traffic |
| Ingress controller | bookings-api |
3000 | Public API traffic |
bookings-api |
Payment gateway | 443 | Charging for the tickets |
| Everyone | CoreDNS | 53 | Already authorised |
Nothing else: web-store does not talk to the database (it serves a static SPA), redis-cache initiates no connections and bookings-postgres goes nowhere.
The database: only two clients
# k8s/environments/pro/np-02-postgres.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: bookings-postgres-ingress, namespace: rutas-norte-pro }
spec:
podSelector:
matchLabels: { app: bookings-postgres } # only app and environment in selectors
policyTypes: [Ingress]
ingress:
- from:
- podSelector: { matchLabels: { app: bookings-api } } # hyphen 1
- podSelector: { matchLabels: { app: notifications-worker } } # hyphen 2 (OR)
ports:
- { protocol: TCP, port: 5432 }
---
# k8s/environments/pro/np-03-bookings-api-egress.yaml -- and now the SENDER's side,
# which is the half that gets forgotten: without this egress, the connection is not established either
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: bookings-api-egress, namespace: rutas-norte-pro }
spec:
podSelector:
matchLabels: { app: bookings-api }
policyTypes: [Egress]
egress:
- to: [ { podSelector: { matchLabels: { app: bookings-postgres } } } ]
ports: [ { protocol: TCP, port: 5432 } ]
- to: [ { podSelector: { matchLabels: { app: redis-cache } } } ]
ports: [ { protocol: TCP, port: 6379 } ]The first one is the policy that justifies the whole lesson: no other pod in the cluster can now even attempt to open a connection to the database holding the personal data. And remember that allow-dns-egress and the second one add up: bookings-api reaches DNS, PostgreSQL and Redis, and nothing else. The egress for notifications-worker is analogous, with PostgreSQL and the corporate mail server.
- Step 4: the Ingress and the external payment gateway
From the Ingress controller
The controller lives in ingress-nginx, so we need a namespaceSelector:
# k8s/environments/pro/np-04-from-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-ingress-controller, namespace: rutas-norte-pro }
spec:
podSelector:
matchExpressions:
- { key: app, operator: In, values: ["web-store", "bookings-api"] }
policyTypes: [Ingress]
ingress:
- from:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: ingress-nginx }
ports:
- { protocol: TCP, port: 8080 } # web-store targetPort
- { protocol: TCP, port: 3000 } # bookings-api targetPortTwo details: the ports are the container's (targetPort), not the Service's, because policies see the real traffic arriving at the pod and kube-proxy already translated the port on the source node (04-01) —putting 80 instead of 8080 produces a 503 at the Ingress that is very hard to connect with a policy—; and the matchExpressions of 02-07 solves the "one or the other" in the podSelector.
Towards the payment gateway
It lives outside the cluster, at pagos.proveedorexterno.example: selectors are of no use, we have to use ipBlock.
# k8s/environments/pro/np-05-payment-gateway.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: bookings-api-egress-payments, namespace: rutas-norte-pro }
spec:
podSelector:
matchLabels: { app: bookings-api }
policyTypes: [Egress]
egress:
- to:
- ipBlock:
cidr: 198.51.100.0/24 # range published by the payment provider
ports:
- { protocol: TCP, port: 443 }Delicate points: an ExternalName does not help here, because the payment-gateway Service from 04-02 only creates a CNAME and the real traffic goes to an external IP that must be authorised with ipBlock; policies work with IPs, not with names, so if the provider changes range the connection breaks without warning —use the range they publish and document, not the IP a dig returns today—; DNS is already allowed by the step 2 policy, without which bookings-api could not even resolve the name; and if there are no stable ranges, the alternative is an egress proxy with a per-domain allowlist, or Cilium, with FQDN policies.
The complete result
flowchart LR
NET["Internet"] --> IC["ingress-nginx"]
IC -->|":8080"| TW["web-store"]
IC -->|":3000"| API["bookings-api"]
API -->|":5432"| PG["bookings-postgres"]
API -->|":6379"| RC["redis-cache"]
API -->|":443 ipBlock"| PAY["pagos.proveedorexterno.example"]
WK["notifications-worker"] -->|":5432"| PG
ALL["Every pod"] -->|":53 UDP/TCP"| DNS["CoreDNS (kube-system)"]
X["Any other pod"] -.->|"DENIED"| PG
Anything not in that diagram is denied.
- Systematic verification
An unverified policy is an assumption: you have to check both sides.
#!/bin/bash
# verify-policies.sh -- checks for rutas-norte-pro
NS=rutas-norte-pro
check() { # check <description> <deploy> <destination> <port> <expected:OK|KO>
RES=$(kubectl exec -n $NS deploy/$2 -- timeout 5 nc -z $3 $4 2>&1 && echo OK || echo KO)
[ "$RES" = "$5" ] && echo " PASS $1" || echo " FAIL $1 (expected $5)"
}
# Must WORK
check "bookings-api -> postgres" bookings-api bookings-postgres 5432 OK
check "bookings-api -> redis" bookings-api redis-cache 6379 OK
check "worker -> postgres" notifications-worker bookings-postgres 5432 OK
# Must FAIL
check "web-store -> postgres" web-store bookings-postgres 5432 KO
check "worker -> redis" notifications-worker redis-cache 6379 KO
check "web-store -> redis" web-store redis-cache 6379 KOAnd the test that makes sense of it all, with the intruder pod from section 1:
kubectl run intruder --rm -it --restart=Never -n rutas-norte-pro \
--image=nicolaka/netshoot -- timeout 5 nc -zv bookings-postgres 5432
# nc: connect to bookings-postgres port 5432 (tcp) timed out: Operation now in progressTimed out. The hole we had been dragging along since module 3 is closed. Note the detail: it is timed out, not "connection refused", because NetworkPolicies drop packets silently, without sending an RST. That signature is useful for diagnosis:
| Symptom | Probable cause |
|---|---|
connection refused (immediate) |
There is connectivity; the process is not listening on that port |
timed out (5-30 s) |
NetworkPolicy dropping, or a network problem |
EAI_AGAIN / no such host |
DNS blocked or down |
503 at the Ingress |
Empty endpoints, or a policy blocking the controller |
- Real limitations
They are indispensable and also clearly limited. You have to know what they do not do.
| Limitation | Detail | What covers it |
|---|---|---|
| They operate at L3/L4 | Only IP, port and protocol: they do not understand HTTP, paths, methods or headers | Cilium (L7) or a mesh |
| No cryptographic identity | The "identity" is a label: anybody who can create pods with it impersonates the component | mTLS with a mesh (08-04) |
| They do not log denials | There is no log of dropped packets; you debug through timeouts | Hubble (Cilium), Calico flows |
| No explicit "deny" | You only add permission, restrictive exceptions do not fit | Calico's own policies (order, Deny) |
| No DNS/FQDN and no encryption | ipBlock with IPs that change without notice; they restrict who talks, they do not protect the content |
Cilium FQDN, egress proxy, WireGuard |
They do not apply to hostNetwork |
A pod with host networking escapes the model | Pod Security Standards (08-03) |
What Cilium or a mesh would add in Rutas Norte: allowing only GET /availability and POST /bookings instead of "the whole port 3000"; certificate-based identity, so that creating a pod with app: bookings-api is not enough to impersonate the component; visibility of allowed and denied flows, which turns "this just hangs" into "this specific policy dropped it"; and per-domain policies for the payment gateway.
None of that detracts from what we have built: NetworkPolicies are the base layer and the one any audit demands. The complete strategy is studied in 08-04.
Common Mistakes and Tips
- Writing policies with a CNI that ignores them. They apply without error and protect nothing. Always verify with a real test, not with the plugin's name.
- Forgetting DNS. The first egress
deny-allbreaks resolution across the namespace and the symptom (EAI_AGAIN) does not point at the policy: the DNS rule is always the first one written. And allowing only UDP on 53 fails intermittently: authorise UDP and TCP. - The extra hyphen in
from. It turns an AND into an OR and opens entire namespaces. Re-read it at every code review. - Authorising only one direction (you need the sender's
egressand the receiver'singress), or using the Service port instead of thetargetPort: policies see the container's port. - Applying
deny-allinkube-system. You can leave the whole cluster with no DNS, no metrics and no Ingress. - Expecting an explicit "deny" (there is none: to restrict, you must remove the broad permission) or confusing
timed outwithconnection refused: the first smells of a policy, the second of a crashed process. - Tip: apply the policies in
rutas-norte-devfirst, run the verification script and only then promote topreandpro. A badly judgeddeny-allin production is a total outage. - Tip: name the files with a numeric prefix (
np-00-deny-all,np-01-allow-dns, ...) so that reading order reflects build order. - Tip: document every policy with the business conversation it authorises. "
notifications-workerreads the booking in order to send the email" ages far better than "allows 5432".
Exercises
Exercise 1: Verify that the CNI enforces the policies
Work out which CNI is installed and prove with a practical test —not with the plugin's name— whether NetworkPolicies are enforced. If not, recreate the profile with Calico and repeat.
Exercise 2: Build the complete model for rutas-norte-pro
Apply, in order, deny-all, the DNS rule and the policies from sections 9 and 10, documenting after each step what works and what stops working. When you finish, run the verification script and check that the intruder pod no longer reaches the database.
Exercise 3: Spot the AND versus OR mistake in a review
This policy arrives for you to review. Identify the flaw, explain what it allows beyond the intention and fix it.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: redis-api-only, namespace: rutas-norte-pro }
spec:
podSelector:
matchLabels: { app: redis-cache }
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels: { app: bookings-api }
- namespaceSelector:
matchLabels: { environment: pro }
ports:
- { protocol: TCP, port: 6379 }Solutions
Exercise 1
kubectl get pods -n kube-system -o name | grep -Ei "flannel|calico|cilium|weave"
kubectl create ns np-test
kubectl run target --image=nginx -n np-test --labels=app=target
kubectl expose pod target --port=80 -n np-test
kubectl wait --for=condition=ready pod target -n np-test --timeout=60s
kubectl apply -f - <<'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: deny-all-ingress, namespace: np-test }
spec: { podSelector: {}, policyTypes: [Ingress] }
EOF
kubectl run p --rm -it --restart=Never -n np-test --image=nicolaka/netshoot \
-- curl -s -o /dev/null --max-time 5 http://target \
&& echo "THE CNI IGNORES THE POLICIES" || echo "THE CNI ENFORCES THEM (correct)"
kubectl delete ns np-testWith Flannel the curl will answer (it ignores them); with Calico or Cilium it will time out. If you have to rebuild, minikube delete -p rutas-norte and recreate the profile with --cni=calico.
Exercise 2
| Step | Works after applying it | Still not working |
|---|---|---|
1. deny-all |
Only kubectl exec/logs and the probes |
All traffic: DNS, database, cache, Ingress |
2. allow-dns-egress |
Name resolution | Every connection |
| 3. PostgreSQL and Redis | bookings-api and worker reach their dependencies |
Inbound traffic from the Ingress |
| 4. Ingress and payments | The complete platform, payments included | Everything not authorised |
for f in np-00-deny-all np-01-allow-dns np-02-postgres \
np-03-bookings-api-egress np-04-from-ingress np-05-payment-gateway; do
kubectl apply -f k8s/environments/pro/$f.yaml
kubectl exec -n rutas-norte-pro deploy/bookings-api -- \
timeout 5 nc -z bookings-postgres 5432 && echo "$f: postgres OK" || echo "$f: postgres KO"
done
bash verify-policies.sh
kubectl run intruder --rm -it --restart=Never -n rutas-norte-pro \
--image=nicolaka/netshoot -- timeout 5 nc -zv bookings-postgres 5432
# nc: connect to bookings-postgres port 5432 (tcp) timed outExercise 3
The flaw is the two hyphens in from: the podSelector and the namespaceSelector are different elements of the list, so they combine with OR. What it allows beyond the intention is enormous: any pod, in any namespace labelled environment: pro, can connect to redis-cache:6379. The intention was "only bookings-api" and the result is "the whole production environment"; a compromised pod in any production namespace could read and poison the seat availability cache.
The fix: merge both selectors into a single element (an AND), or simply keep the podSelector, given that the policy already lives in rutas-norte-pro.
ingress:
- from:
- podSelector:
matchLabels: { app: bookings-api }
namespaceSelector: # NO hyphen: intersection
matchLabels: { environment: pro }
ports:
- { protocol: TCP, port: 6379 }kubectl exec -n rutas-norte-pro deploy/bookings-api -- timeout 5 nc -z redis-cache 6379 \
&& echo "bookings-api: OK (should work)"
kubectl exec -n rutas-norte-pro deploy/web-store -- timeout 5 nc -z redis-cache 6379 \
|| echo "web-store: DENIED (correct)"Conclusion
You have closed the hole: you started by proving with an intruder pod that without NetworkPolicies the network is flat and that any container in the cluster reaches the database holding the customers' personal data, and you finished with that same test timing out. Along the way you have nailed down what matters most. First, that Kubernetes defines the object but does not enforce it: the CNI does, and a plugin such as Flannel accepts your policies without a single warning and filters nothing, so the only acceptable guarantee is a practical test on every cluster. Second, the additive model: as long as no policy selects a pod, everything gets through; as soon as one does, that pod is isolated in that direction and only what is explicitly allowed gets through; policies add up, there are no priorities and there is no deny, which means restrictive exceptions do not fit. Third, that you need both directions: the sender's egress and the receiver's ingress. And you have mastered the three selectors —podSelector for the same namespace, namespaceSelector for crossing them and ipBlock with except for the outside world— and above all the difference between AND and OR, that extra hyphen which turns "only bookings-api" into "the whole production environment" without the cluster saying a word.
You have built the complete model for rutas-norte-pro in the right order: deny-all first, even though it breaks the platform; DNS next, with UDP and TCP towards CoreDNS, avoiding the classic failure whose symptom (EAI_AGAIN) points everywhere except at the policy you have just applied; and then each business conversation authorised one at a time: bookings-api and notifications-worker towards PostgreSQL, bookings-api towards Redis, the Ingress controller towards the store and the API on their targetPorts, and bookings-api towards the payment gateway via ipBlock. All of it verified from both sides, knowing that a timed out smells of a policy and a connection refused of a crashed process. And you know the limits: L3/L4, no cryptographic identity, no denial logging and no domain names, which is where Cilium or a service mesh come in (08-04).
This brings module 4 to a close and Rutas Norte is for the first time a real, accessible platform. Customers come in through https://www.rutasnorte.example, agencies consume https://api.rutasnorte.example, the certificates renew themselves, discovery between components goes over DNS and the network is segmented so that only the legitimate conversations are possible.
One piece remains, one we have been dragging along since module 2 with a "PROVISIONAL" note in the manifest: bookings-postgres has no persistent storage. Its Deployment keeps the data inside the container, so every restart of the pod —a rollout, an eviction, a node failure— wipes every booking and every customer. Module 5, Storage in Kubernetes, solves exactly that: volumes, PersistentVolumes and PersistentVolumeClaims, storage classes, dynamic provisioning with expansion and snapshots, and backup and restore. It is the module that turns Rutas Norte into a platform you can trust.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
