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 选项),自建集群的结论不能照搬。

分批上线与连通性矩阵

规则本身之外,上线顺序更决定成败:

  1. staging namespace 上全套策略(default-deny + 全部放行)跑一周,观察遗漏的隐性依赖;
  2. 生产挑一个低风险 namespace 先行:default-deny + DNS 放行,然后按报错逐条补——相当于让真实流量替你补清单;
  3. 每个服务收紧前后跑一遍连通性矩阵,红了的就是漏配:
# 在 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,而是"谁在跟谁说话"这份清单本身——它比任何策略都先过期,必须跟着服务一起维护。


默认拒绝划出的是墙的轮廓;真正的安全资产,是那份逐条写出来的通信清单。