跳转至

10. 谁来守门:安全组与网络 ACL

VPC 把不同租户的网络隔开了,VPC 内部的通信管道也铺好了。但管道里该流什么、不该流什么,这个问题还没有答案。

同一个 VPC 内的所有 VM 之间默认全通,没有任何访问限制。这就像你给小区装了一道坚固的大门,但小区里每户人家都不锁门。外面的人进不来,但只要有一个人混进来了,他可以随意串门。

通信能力是基础,但没有控制的通信是危险的。问题不是要不要控制,而是在哪里控制、控制到什么粒度。

10.1 没有门锁的大院子

默认全通的后果比想象中更具体。

一台 Web 服务器只需要对外暴露 80 和 443 端口,但实际上它的所有端口,22(SSH)、8080(管理后台)、9090(监控接口),对 VPC 内的每一台 VM 都是可达的。数据库只应该接受来自应用服务器的连接,但实际上 VPC 内任何一台 VM 都能连它的 3306 端口。一台开发环境的测试 VM,可以 SSH 到生产环境的数据库服务器。

这不是理论上的风险。安全团队收到告警,数据库的数据被大量导出。排查发现,攻击者利用 Web 服务器上一个未修补的远程代码执行漏洞拿到了 shell,然后直接连上了数据库的 3306 端口,整个过程没有触发任何拦截。在实际的云环境中,横向移动是最常见的攻击路径之一,攻击者拿下一台低价值的 VM,利用 VPC 内部的全通网络,逐步渗透到高价值目标。一台不起眼的测试机,可能是整个生产环境沦陷的起点。

这类事故反复上演,安全领域早就沉淀出一条通用准则,最小权限原则:每台 VM 只暴露它需要暴露的端口,只接受它应该接受的来源。Web 服务器的 80/443 端口对外开放,22 端口只对运维 IP 开放,其他端口全部关闭。数据库的 3306 端口只接受来自应用服务器的连接,其他一律拒绝。

但这里有两种不同粒度的需求。一种是针对单台 VM 的,这台数据库只接受来自应用服务器的 3306 连接。另一种是针对整个子网的,子网 C 不允许任何来自外部的直接入向流量。

一个直觉的想法是在网络设备上做访问控制,在交换机或路由器上配置 ACL 规则,过滤不该通过的流量。在传统数据中心里,这是标配做法。但在 VPC 的分布式网关架构下,跨子网的流量从源宿主机直接到目的宿主机,中间不经过任何集中的网络设备。没有集中设备,就没有地方插入集中的 ACL。

访问控制的执行点,必须跟着流量走。流量在宿主机上产生和终结,访问控制就得在宿主机上执行。

10.2 安全组:实例级的有状态过滤

需要的是一种绑定到每台 VM 实例的访问控制机制,规则跟着 VM 走,不管 VM 在哪台宿主机上,规则都在它身边生效。

这种机制叫安全组(Security Group)

每个安全组是一组规则的集合,可以绑定到一个或多个 VM 实例。规则定义了允许什么样的流量进出这台 VM。每条规则有四个维度:

  • 方向:入向(Ingress)还是出向(Egress)
  • 协议:TCP、UDP、ICMP,或者全部协议
  • 端口范围:单个端口(如 3306)、端口范围(如 8000-9000)、或全部端口
  • 源/目的:一个 IP 地址范围(CIDR),或者另一个安全组的 ID

安全组的默认行为是:拒绝所有入向流量,允许所有出向流量。用户需要显式添加入向规则来放行特定的流量。这个默认策略本身就体现了最小权限原则,除非你明确说允许,否则一律拒绝。

一个典型的 Web 服务器安全组可能只有两条入向规则:允许任意来源的 TCP 80 和 TCP 443。数据库的安全组可能只有一条入向规则:允许来自安全组 sg-app(应用服务器所在的安全组)的 TCP 3306。

注意这里的一个设计:规则的源可以是另一个安全组的 ID,而不是 IP 地址范围。允许安全组 sg-app 的 3306 入向,这意味着不管应用服务器的 IP 怎么变、不管它在哪台宿主机上、不管它扩容到多少台,只要它属于 sg-app,就能访问数据库,这比写死 IP 范围灵活得多。

安全组还有一个关键特性:有状态

允许 TCP 80 入向,好,但回包呢?你一定想过:如果只配置入向规则,服务器的 TCP SYN-ACK 响应是出向流量,按理说应该配置出向规则允许它。但你在控制台里从来不需要这样做,为什么?这不是云厂商的便利设计,而是背后有一套有状态的过滤机制,连接追踪(conntrack)。 内核维护一张连接追踪表。表的每一条记录对应一个连接,key 是五元组:源 IP、目的 IP、源端口、目的端口、协议。每当一个新的包到达,内核先查连接追踪表:

  • 如果这个包属于一个已经存在的连接(五元组匹配),并且连接状态是 ESTABLISHED(双向都有包,连接已建立),直接放行,不再检查安全组规则。
  • 如果这个包是一个全新的连接(五元组不在表中),状态标记为 NEW,然后去匹配安全组规则。如果规则允许,在连接追踪表中创建一条记录;如果规则拒绝,丢弃。
  • 如果这个包与一个已有连接相关联(比如 FTP 的数据通道与控制通道的关系),状态标记为 RELATED,同样自动放行。

图 10.1:安全组的有状态过滤流程

graph TB
    PKT[入向数据包] --> CT{连接追踪表查询}
    CT -->|已建立 / 关联连接| PASS[放行]
    CT -->|新连接| SG{匹配安全组规则}
    SG -->|规则允许| CREATE[创建连接追踪记录] --> PASS
    SG -->|无规则匹配| DROP[丢弃]

这里要问一个问题:为什么安全组要做成有状态的?做成无状态、每个包独立判断,实现更简单,也不用维护那张 conntrack 表。

答案在使用场景里。安全组是给 VM 用户配的,用户的心智模型是我这台服务器对外开放 80 端口,一句话说完。如果安全组无状态,这句话就要拆成两条规则:入向允许目的端口 80,出向允许源端口 80,目的端口 1024-65535 之间的任意值。回包的目的端口是客户端的临时端口,客户端用哪个临时端口服务端事先不知道,所以出向规则必须放开一大段端口范围。用户不仅要写两条,还要理解 TCP 的临时端口机制,一旦漏配一条或范围写错,症状就是请求能进来,响应出不去,客户端超时,这类问题排查起来极其耗时间。

安全组管的是每一台 VM,配置错误的成本会放大到每一个业务。所以把连接追踪的复杂度收进平台内部,让用户只关心允许谁访问我的什么端口,回包的事平台自己搞定。这不是便利设计,而是把一类必然会犯的错从用户手里拿走。

10.3 安全组在哪里执行

安全组的规则配好了,但它在数据路径上的什么位置被检查?

答案是:在宿主机上,紧贴 VM 的虚拟网卡

包从 VM 出来,经过虚拟网卡,第一站就是安全组的出向检查。包到达目的 VM 之前,最后一站就是安全组的入向检查。安全组像一层透明的膜,包裹在每台 VM 的虚拟网卡外面。

图 10.2:安全组在数据路径上的位置

graph LR
    VM_A[VM-A] -->|出向检查| SG_A[安全组]
    SG_A --> vSwitch[vSwitch / VTEP]
    vSwitch --> Network[Overlay 网络]
    Network --> vSwitch2[vSwitch / VTEP]
    vSwitch2 -->|入向检查| SG_B[安全组]
    SG_B --> VM_B[VM-B]

为什么在宿主机上执行,而不是在某台集中的防火墙上?

原因和分布式网关一样,安全组是实例级的,每台 VM 绑定不同的安全组,只有宿主机知道每个虚拟网卡对应哪个 VM、绑定了哪个安全组。如果把安全组的检查放在集中设备上,所有流量都要绕道集中设备,又回到了集中式网关的老问题:发卡弯、带宽瓶颈、单点故障。

安全组的执行在 Linux 内核中有两种主要的实现方式。

传统方式是 Netfilter/iptables。Linux 内核的网络栈在几个关键位置预留了钩子点(hook point),Netfilter 框架允许在这些钩子点插入规则链。安全组的规则被翻译成 iptables 规则,挂在虚拟网卡对应的链上。每个包经过钩子点时,依次匹配规则链中的规则。这种方式成熟稳定,但有一个问题:规则是线性匹配的。一台 VM 如果绑定了几十条规则,每个包都要从头到尾匹配一遍。VM 多了、规则多了,性能下降明显。

Netfilter 是什么?

Netfilter 是 Linux 内核中的包过滤框架。它在内核网络栈的五个关键位置(PREROUTING、INPUT、FORWARD、OUTPUT、POSTROUTING)设置了钩子点,允许外部模块在这些位置注册回调函数,对经过的每个包做检查、修改或丢弃。iptables 是 Netfilter 的用户态管理工具,用户通过 iptables 命令配置规则,规则实际上由 Netfilter 在内核中执行。

更现代的方式是 eBPF。在网卡的 TC(Traffic Control)层或更底层的 XDP(eXpress Data Path)层注入 BPF 程序,直接在包到达内核网络栈之前做过滤。BPF 程序会被即时编译成高效的机器码,匹配效率远高于 iptables 的线性规则链。代价是开发复杂度更高,需要编写和维护 BPF 程序,调试也更困难。

不管用哪种实现方式,安全组对每个包都要做一次检查,至少查一次连接追踪表。在高 PPS 场景下(比如一台 VM 每秒处理几百万个小包),这是显著的 CPU 开销。iptables 的线性匹配在规则少时没问题。但一台宿主机上同时运行 50 个 Pod、每个 Pod 绑定 20 条规则,就是 1000 条线性匹配规则,每个包都要做一次。这是真实的性能损耗,是腾讯云、阿里云等公司切换到 eBPF 的真实动机。宿主机的 CPU 不仅要处理 VxLAN 封装解封装,还要处理安全组的包过滤和连接追踪。这也是 DPU/SmartNIC 的一个重要应用场景,把安全组的过滤逻辑卸载到硬件,释放宿主机的 CPU。

10.4 规则变更的实时生效

安全组不是配一次就不动了,用户随时可能在控制台添加一条新规则、删除一条旧规则、或者修改规则的端口范围。这个变更需要尽快生效,如果用户删除了一条允许 SSH 的规则,期望是这台 VM 立刻不再接受 SSH 连接,而不是等几分钟。

变更的链路和路由表下发类似:用户在控制台操作 → API 服务器接收请求 → SDN 控制器计算受影响的宿主机 → 把新规则推送到对应的宿主机 → 宿主机更新本地的过滤规则。

图 10.3:安全组规则变更的下发链路

sequenceDiagram
    participant User as 用户控制台
    participant API as API 服务器
    participant SDN as SDN 控制器
    participant H1 as 宿主机-1
    participant H2 as 宿主机-2

    User->>API: 删除规则:允许 TCP 22 来自 0.0.0.0/0
    API->>SDN: 更新安全组 sg-web
    SDN->>SDN: 计算受影响的宿主机(sg-web 绑定到 VM-A@宿主机-1、VM-B@宿主机-2)

    par 推送到受影响的宿主机
        SDN->>H1: 删除规则:允许 TCP 22 入向
        SDN->>H2: 删除规则:允许 TCP 22 入向
    end

    Note over H1,H2: 规则毫秒级生效

但这里有一个微妙的问题:已有连接怎么处理?

用户删除了"允许 TCP 22 入向"的规则。此时有一个 SSH 会话正在进行,这个连接已经在 conntrack 表中,状态是 ESTABLISHED。新规则生效后,这个已建立的 SSH 连接会被立刻断开吗?

答案取决于实现策略。一种做法是只影响新连接,已经在 conntrack 表中的 ESTABLISHED 连接继续放行,直到连接自然结束或超时。规则变更只对变更之后的新连接生效。另一种做法是同时清理已有连接,规则变更时,同步清除 conntrack 表中与被删除规则匹配的记录,已有连接立刻中断。

两种做法各有道理。前者更温和,不会突然中断正在进行的业务;后者更严格,安全策略立刻生效,不留窗口。不同云厂商的选择不同,有些提供配置选项让用户自己决定。

还有一个分布式系统的固有问题:同一个安全组可能绑定了几十台 VM,分布在不同宿主机上。控制器把新规则推送给所有宿主机,不可能同时到达。总有一个时间窗口,有些宿主机已经生效了新规则,有些还在用旧规则。这个一致性窗口通常在毫秒到百毫秒级别,对大多数场景可以接受,但用户需要知道它的存在。

10.5 网络 ACL:子网级的无状态过滤

安全组解决了实例级的访问控制。但有些场景需要更粗粒度的策略,不是针对某台 VM,而是针对整个子网。

比如:整个数据库子网(子网 C)不允许任何来自 VPC 外部的直接入向流量。这条规则和具体哪台 VM 无关,它是子网级别的策略。用安全组也能实现,给子网 C 里的每台 VM 都绑定同样的规则。但如果子网 C 里有 50 台 VM,每台都要绑定,新加一台 VM 也要记得绑定。漏绑一台就是一个安全漏洞。

需要的是一种直接绑定到子网的访问控制机制,规则对整个子网生效,子网内的所有 VM 自动受到保护,不需要逐台绑定。这种机制叫网络 ACL(Network Access Control List)

到这里定义就出来了,接下来的问题是:既然已经有安全组了,网络 ACL 该怎么设计?沿用安全组那套(有状态、规则取并集)最省事,但云厂商没这么做。网络 ACL 的两个关键设计:无状态有序匹配,都和安全组反着来。

先看无状态。

如果网络 ACL 也做成有状态,用户体验会更好:只写允许 TCP 80 入向一条规则,回包自动放行,跟安全组一样。但这里有一个前提问题:一个包从外部进来到达 VM,它会先过 ACL、再过安全组。安全组已经把这个连接记进了自己的 conntrack 表。如果 ACL 也维护自己的一张 conntrack 表,同一个连接就有两份状态。

两份状态就意味着两份不一致的可能。ACL 侧的连接超时时间到了,记录被清掉;安全组那边还认为连接活着,回包过安全组这一层时正常放行,过 ACL 这一层被当成没有前置请求的孤儿包丢弃。反过来也一样。这种问题排查起来极其难,因为规则表看着都对,问题出在两张状态表的时序上。

再看职责。安全组管连接级的精细控制,配到具体 VM 的具体端口,这层已经在做 conntrack 了。网络 ACL 管的是子网边界,职责是这类流量整个子网都不许进,粒度比连接粗一个量级。让粗粒度的层去追踪细粒度的连接状态,收益不匹配。

所以选择是:ACL 不碰状态,每个包独立判断。用户的代价是入向和出向必须分别配置,配 80 端口入向的同时得记得配临时端口出向。收益是实现简单、行为可预测、和安全组之间没有状态耦合。这是把配置复杂度留给用户,换掉一类平台自己也讲不清楚的边界 bug。

再看有序匹配。

安全组的规则是并集:任何一条规则允许,包就放行;没有规则匹配,就默认拒绝。这套规则里没有拒绝这个动作,因为不需要,默认已经是拒绝,用户只写允许即可。

网络 ACL 不能这么设计,原因是它的典型使用场景里有明确的拒绝需求。子网级的策略经常设置成这样:允许 10.0.0.0/16 整个 VPC 段访问,但里面 10.0.1.0/24 这个子网除外。用并集规则表达不了"允许一大片,除了其中一小块",你没法写一条拒绝规则去覆盖已经写过的允许,因为并集里拒绝没有位置。

要支持这种表达,就必须引入两个东西:规则里要有拒绝这个动作,规则之间要有先后顺序。先匹配到拒绝 10.0.1.0/24,直接丢弃,后面允许 10.0.0.0/16 的规则不再看。这就是有序匹配的由来,不是为了让规则更强大,是为了表达例外这个语义。

所以规则的顺序变得重要,编号写错了策略就反了。安全组不用考虑顺序,因为并集里怎么排都一样;ACL 必须考虑,写规则时得想清楚哪条在前哪条在后。

无状态和有序匹配,本质上都是同一个逻辑的产物:ACL 是子网级的门岗,粗粒度、边界式、可能包含黑名单。它不需要连接级的细节,也不能和安全组抢连接状态;它需要能表达排除的语义,所以必须有拒绝动作和顺序。安全组是实例级的贴身检查,粒度细、以白名单为主、需要连接状态。两层一粗一细、一无状态一有状态、一并集一有序,配合起来才是一套完整的访问控制。

理清了动机,剩下的就是把这两个设计落到细节上。

先说执行位置。子网有边界,直觉上应该有一台专门的 ACL 设备守在子网入口,可实际上并没有。和安全组一样,网络 ACL 也在宿主机上分布式执行,没有独立的 ACL 硬件或软件设备。

原因和安全组一样:VPC 的子网边界是逻辑概念,不是物理设备。在分布式网关架构下,跨子网的流量从源宿主机直接到目的宿主机,中间不经过任何集中的网络设备。既然没有集中设备,ACL 的检查就只能在宿主机上完成。

具体来说,宿主机的 vSwitch 在处理每个包时,会根据包的源 IP 和目的 IP 判断这个包是否跨越了子网边界。如果是,就在安全组检查之前(入向)或之后(出向)额外插入一次网络 ACL 的规则匹配。安全组紧贴虚拟网卡,是实例级的贴身保镖;网络 ACL 在安全组的外层,是子网级的门岗,但两者物理上都在同一台宿主机的内核网络栈中执行。

图 10.4:网络 ACL 与安全组在数据路径上的位置

graph LR
    EXT[外部流量] -->|入向| NACL_IN[网络 ACL<br/>子网边界]
    NACL_IN --> SG_IN[安全组<br/>VM 网卡]
    SG_IN --> VM[VM]
    VM --> SG_OUT[安全组<br/>VM 网卡]
    SG_OUT --> NACL_OUT[网络 ACL<br/>子网边界]
    NACL_OUT -->|出向| EXT2[外部流量]

    style NACL_IN fill:#f9d,stroke:#333
    style NACL_OUT fill:#f9d,stroke:#333
    style SG_IN fill:#bbf,stroke:#333
    style SG_OUT fill:#bbf,stroke:#333

这也解释了一个实现细节:同一个子网内两台 VM 之间的流量,不跨越子网边界,所以不会触发网络 ACL 的检查,只有安全组会生效。网络 ACL 只在流量进出子网时才起作用。

图 10.5:安全组 vs 网络 ACL 的对比

对比维度 安全组(Security Group) 网络 ACL(Network ACL)
绑定层级 实例(VM) 子网
执行位置 宿主机上,紧贴 VM 的虚拟网卡 宿主机上,位于子网逻辑边界
状态性 有状态(依赖 conntrack) 无状态(逐包判断)
匹配方式 所有规则取并集 按规则编号有序匹配,命中即止
默认行为 拒绝所有入向,允许所有出向 拒绝所有(末尾隐含规则)
回包处理 自动放行 必须显式配置放行规则
规则动作 只支持允许 支持允许和拒绝

10.6 两道门的协同

安全组和网络 ACL 不是二选一的关系,它们是两道门,一个包必须同时通过两道门才能到达目标 VM。

一个外部请求到达 VPC,要访问子网 A 中 VM-A 的 80 端口。这个包经历的检查路径是:

  1. 包到达子网 A 的边界 → 网络 ACL 入向检查。按规则编号依次匹配,如果匹配到一条允许规则,放行;如果匹配到拒绝或没有匹配到任何规则,丢弃。
  2. 包到达 VM-A 的虚拟网卡 → 安全组入向检查。先查 conntrack 表,如果是已有连接的一部分,直接放行;如果是新连接,匹配安全组规则。
  3. VM-A 处理请求,生成回包。
  4. 回包从 VM-A 出去 → 安全组出向检查。因为安全组是有状态的,这个回包属于已建立的连接(ESTABLISHED),自动放行。
  5. 回包到达子网 A 的边界 → 网络 ACL 出向检查。因为网络 ACL 是无状态的,回包需要匹配出向规则。如果没有配置允许回包的出向规则,回包会被丢弃,请求能进来,但响应出不去。

图 10.6:两道门的检查路径

sequenceDiagram
    participant EXT as 外部请求
    participant NACL_IN as 网络 ACL<br/>(入向)
    participant SG_IN as 安全组<br/>(入向)
    participant VM as VM-A
    participant SG_OUT as 安全组<br/>(出向)
    participant NACL_OUT as 网络 ACL<br/>(出向)

    EXT->>NACL_IN: TCP SYN 到 80 端口
    NACL_IN->>NACL_IN: 规则 100:允许 TCP 80 ✓
    NACL_IN->>SG_IN: 通过
    SG_IN->>SG_IN: 新连接 → 匹配规则:允许 TCP 80 ✓
    SG_IN->>VM: 送达

    VM->>SG_OUT: TCP SYN-ACK(回包)
    SG_OUT->>SG_OUT: 已建立连接 → 自动放行 ✓
    SG_OUT->>NACL_OUT: 通过
    NACL_OUT->>NACL_OUT: 规则 100:允许 TCP 1024-65535 ✓
    NACL_OUT->>EXT: 回包送达

网络 ACL 是无状态的,入向规则和出向规则完全独立。如果只配置了入向规则允许 TCP 80,但忘记配置出向规则允许临时端口范围(1024-65535)的回包,结果就是请求能进来,但响应永远出不去,这是非常容易踩坑的地方。从客户端的视角看,连接超时,TCP 三次握手的 SYN 发出去了,SYN-ACK 永远收不到。这个坑我见过不止一个团队踩过,配完网络 ACL 后信心满满地测试,发现怎么连不上,排查半天才发现是出向规则忘了配。有状态和无状态的区别,在这一刻会变得刻骨铭心。

在实际使用中,两道门的配合原则通常是:网络 ACL 保持宽松,安全组做精细控制。网络 ACL 用来拦截明确禁止的流量(黑名单式),比如封禁某个已知的恶意 IP 段。安全组用来精确控制每台 VM 的访问权限(白名单式)。大多数场景下,安全组已经足够,网络 ACL 作为额外的防护层,并非每个 VPC 都需要配置网络 ACL。