default-deny 上线两分钟后的三连事故
安全审计结论写得清楚:所有生产 namespace 必须默认拒绝东西向流量。直觉做法也顺理成章——那就先把 default-deny 铺满集群,出了问题再补规则:
# 看起来人畜无害:选中本 namespace 全部 Pod,什么都不放行
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: prod
spec:
podSelector: {} # 空 selector = 选中 prod 里全部 Pod
policyTypes: [Ingress, Egress]
ingress: []
egress: []
两分钟后,现象按顺序出现:
- 网关到服务的请求 503,Ingress upstream error 飙升(ingress 方向全断);
- 服务之间的调用批量超时;
- 日志里冒出大量
no such host和i/o timeout——连 DNS 都不通了。
第三条最致命:UDP 53 被拦后,业务连"对方是谁"都查不出来。三个症状叠在一起,第一次经历的人很难立刻意识到,源头是那条"看起来什么都没做"的策略。顺序做反了,代价就是拿整个集群当测试。
语义模型:选中即隔离,允许取并集
NetworkPolicy 是纯 allow-list,理解成两句话就够:
- 一个 Pod 一旦被任何 policy 的 podSelector 选中(对某个方向),这个方向上所有未明确允许的流量都被拒绝。不存在显式的 deny 规则;
- 多条 policy 作用于同一个 Pod 时,允许的流量是所有规则的并集。
default-deny 能"生效",正是因为它只是一条选中一切、什么都不允许的 policy,让所有"未列出"的流量落入拒绝。同理,“允许除了 X 之外"这种意图表达不了,只能用"默认拒绝 + 逐条枚举"逼近。
还有个容易忽略的默认行为:policyTypes 省略时,策略只隐式包含 Ingress;只有当规则里出现了 egress 段,才隐式包含 Egress。想两边都管,就像上面那样显式写 [Ingress, Egress]。
第一步是通信清单,不是 YAML
策略写错的成本是全站 503,所以收紧之前先回答"谁在跟谁说话”。清单从 Deployment 出发盘依赖,而不是反过来从审计要求出发:
namespace: prod
├── gateway ← ingress-nginx(namespace: ingress-nginx)→ 8080/TCP
├── api ← gateway → 8080/TCP
│ → postgres.prod:5432/TCP
│ → redis.cache:6379/TCP
│ → oauth.example.com:443/TCP(唯一合法外网出口)
├── worker ← kafka.infra:9092/TCP
│ → postgres.prod:5432/TCP
└── 所有 Pod → kube-dns:53 UDP+TCP
盘不出来的部分先观察、再收紧,别猜。Cilium 用户可以直接用 audit 模式,让策略只记日志不执行:
# 给 namespace 打开 audit 模式:命中策略的流量只记录、不丢包
kubectl annotate namespace prod io.cilium.k8s.policy.audit-mode=true
# 跑一段真实流量后,看哪些流量"本会被拒绝"——audit 模式下 verdict 是 AUDIT
hubble observe --namespace prod --since 24h --verdict AUDIT
观察期里挖出来的隐性依赖(某个服务半夜直连别人的数据库)比策略本身更值钱。没有 Cilium 也不影响方法论:先用 netshoot 到处 curl 一圈,把连通的路径记下来,同样是在攒清单。
第一条放行永远是 DNS
清单就绪后,按依赖顺序补规则,第一块砖永远是 DNS:
# prod 里所有 Pod 的 DNS 出口:只放行 kube-system 里 kube-dns 的 53 端口
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: prod
spec:
podSelector: {} # 作用于 prod 全部 Pod
policyTypes: [Egress]
egress:
- to:
# 同一个 to 条目内是 AND:必须同时"在 kube-system"且"是 kube-dns 的 Pod"
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP # 大应答会回落到 TCP,只放 UDP 会偶发解析失败
port: 53
三个说明:
kubernetes.io/metadata.name是 1.21 起集群自动打在 namespace 上的标签,定位 kube-system 不依赖人为打标签的纪律;- TCP 53 必须一起放。记录多、应答超过 UDP 限制时会走 TCP,只放 UDP 得到的是"时好时坏"的 DNS,排查难度远高于全断;
- 用了 NodeLocal DNSCache 的集群,DNS 目的地是节点地址而不是 kube-dns Pod,这条规则要按实际架构改,别照抄。
条目内 AND,条目间 OR:最高频的语义事故
需求:“只允许 ingress-nginx namespace 里的 controller 访问 api”。下面是错误写法:
# 错误:把两个条件写成了两个独立的 from 条目
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
from/to 是列表,条目之间是 OR。上面实际放行的是两个集合的并集:ingress-nginx namespace 里的任何 Pod,加上任何 namespace 里带这个标签的 Pod——比预期大了一圈。正确写法是把两个 selector 放进同一个条目:
# 正确:namespaceSelector 与 podSelector 在同一个 from 条目里,取交集
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
podSelector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
记忆方式:from/to 的列表元素之间 OR;单个元素内部的 namespaceSelector 和 podSelector 是 AND。ipBlock 是第三类条目,不能和这两个 selector 写在同一个元素里。
port 不写 protocol,默认按 TCP 匹配
端口规则省略 protocol 时默认 TCP。这个默认值咬过的远不止 DNS:
egress:
- to:
- podSelector:
matchLabels: { app: statsd }
ports:
- port: 8125 # 忘写 UDP:按 TCP 匹配,UDP 流量照样被丢
- protocol: UDP # 显式写出来才是放行 UDP
port: 8125
一切 UDP 协议都会中这个招——statsd、Syslog,以及 QUIC/HTTP3 的 443。症状一律是"规则明明写了还是不通",因为从 API server 到 kubectl get 谁都不会提醒你 protocol 没写。
CNI 不支持时,策略是静默失效的
NetworkPolicy 的执行完全依赖 CNI 插件,API server 只做校验和存储。用 flannel 这类不带 policy 实现的 CNI,apply 照样成功、get 照样能看到,但一条都不会执行。审计意义上这比不写更糟:你以为有墙,其实没有。
先验证执行能力,再谈收紧:
# 看节点上跑的是哪个 CNI
kubectl -n kube-system get ds | grep -Ei 'cilium|calico|weave|flannel|antrea'
# 最直接的验证:造一个受害者 Pod,deny 全部,然后从它访问外部
kubectl -n prod run np-canary --image=nicolaka/netshoot --restart=Never -- sleep infinity
kubectl -n prod apply -f - <<'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: canary-deny-all, namespace: prod }
spec:
podSelector: { matchLabels: { run: np-canary } }
policyTypes: [Ingress, Egress]
EOF
# 能拿到响应 -> CNI 没在执行策略;超时 -> 策略确实生效
kubectl -n prod exec np-canary -- curl -sS --max-time 3 -o /dev/null http://example.com \
&& echo "策略未生效:先解决 CNI,再谈默认拒绝" \
|| echo "策略已生效(不通是预期)"
托管集群还要多留个心眼:policy 支持可能取决于建集群时的参数(比如 GKE 的 network policy 选项),自建集群的结论不能照搬。
分批上线与连通性矩阵
规则本身之外,上线顺序更决定成败:
- staging namespace 上全套策略(default-deny + 全部放行)跑一周,观察遗漏的隐性依赖;
- 生产挑一个低风险 namespace 先行:default-deny + DNS 放行,然后按报错逐条补——相当于让真实流量替你补清单;
- 每个服务收紧前后跑一遍连通性矩阵,红了的就是漏配:
# 在 netshoot 里跑矩阵:预期"通"的目标 FAIL 了就是漏配
for target in postgres.prod.svc:5432 redis.cache.svc:6379 oauth.example.com:443; do
host=${target%:*}; port=${target##*:}
kubectl -n prod exec netshoot -- timeout 3 bash -c "echo > /dev/tcp/$host/$port" \
&& echo "PASS $target" || echo "FAIL $target <- 预期通却不通,补白名单"
done
# DNS 单独验:UDP 53 用 /dev/tcp 测不了
kubectl -n prod exec netshoot -- nslookup kubernetes.default.svc.cluster.local
# 反向验证:不该有外网出口的服务,通了才是问题
kubectl -n prod exec deploy/worker -- curl -s --max-time 3 -o /dev/null https://example.com \
&& echo "FAIL: worker 不该能出外网"
最后留一周回滚窗口:把 default-deny 存成单独的文件、最后 apply,出问题时先删它,放行规则原地不动。
常见坑点
1. hostNetwork 的 Pod 不受策略约束
走节点网络栈的 Pod(不少 ingress controller、监控 agent 用 hostNetwork)不经过 CNI 的 policy enforcement,default-deny 对它们既不限制也不保护。审计范围要把这类 Pod 单独列出来。
2. 节点自身的流量不在管辖内
kubelet 的探针、节点发往 Pod 的流量不受 NetworkPolicy 约束(否则探针先被拦死)。这意味着"节点被攻陷"不在防护模型里,那层要靠主机防火墙。
3. 以为存在 deny 规则
只有"没被允许"。想表达"放行这个 namespace 但拒绝其中某个 Pod",只能用 AND 语义把允许条件收窄,写不了例外。
4. namespaceSelector 依赖的标签不存在
1.21 之前的集群没有自动打的 kubernetes.io/metadata.name,selector 匹配不到任何 namespace 时放行静默失效——症状是规则明明写了还是不通。
5. 拿 Service 的名字去理解规则
NetworkPolicy 作用在 Pod 上,流量经 kube-proxy DNAT 后按后端 Pod 的标签匹配。策略描述的是"目标是谁",不是"经过哪个 Service"。
总结
默认拒绝不难写,难的是那份通信清单和落地顺序:
- 语义只有两条:选中即隔离,允许取并集;
- 顺序是:清单 -> audit 观察 -> DNS -> 逐条放行 -> 分批上线 -> 连通性矩阵验收;
- CNI 的执行能力先验证,再谈策略,apply 成功不代表任何东西。
网络策略真正的交付物不是那几十行 YAML,而是"谁在跟谁说话"这份清单本身——它比任何策略都先过期,必须跟着服务一起维护。
默认拒绝划出的是墙的轮廓;真正的安全资产,是那份逐条写出来的通信清单。