返回

解决 Docker+nftables 端口转发失效: 原因分析与配置方案

Linux

Docker 遇上 nftables:端口转发踩坑与解决方案

搞 Docker 和 nftables 的时候,端口转发不生效?明明 nftables 规则看着没毛病,容器也跑得好好的,但就是从外面访问不了。特别是防火墙一关(比如 OUTPUTFORWARD 链放行所有流量)就能通,一开就歇菜。这情况不少人都遇到过,感觉就像 Docker 和 nftables 八字不合。

举个例子,就像这位朋友遇到的:想把宿主机 ens18 接口(IP 192.168.16.215)的 8080 端口收到的流量,转发给 docker0 网桥上的容器(IP 172.17.10.2)的 80 端口。他写了类似下面的 nftables NAT 规则:

table inet nat {
    chain prerouting {
        type nat hook prerouting priority -100; policy accept;

        # 把 192.168.16.215:8080 (ens18接口) 的流量转给 172.17.10.2:80 (docker0接口)
        tcp dport 8080 iifname "ens18" ip daddr 192.168.16.215 dnat to 172.17.10.2:80
    }
}

这规则本身没啥大问题,确实是在 prerouting 钩子做目标地址转换(DNAT)。但为啥一开防火墙(特别是关闭 OUTPUT 链的默认放行策略)就不工作了呢?真的是 Docker 和 nftables 不兼容吗?

咱们来挖挖根源,再看看怎么把这事儿搞定。

问题根源:为什么 Docker 和 nftables 会“打架”?

这事儿吧,不能简单甩锅给“不兼容”。Docker 和 nftables 本身可以一起用,但需要你明白它们各自是怎么管网络规则的,特别是涉及到数据包流转路径上的各个关卡。

关键点往往在这里:

  1. Docker 的“老习惯”:iptables
    Docker 被设计出来的时候,Linux 世界的主流防火墙是 iptables。所以,Docker 默认会自己动手去创建和管理一堆 iptables 规则,来实现容器网络(创建 docker0 网桥、分配 IP、设置 NAT 规则让容器能访问外网、处理端口映射 -p 参数等)。它会在 nat 表的 PREROUTINGOUTPUT 链和 filter 表的 FORWARD 链里加规则。

  2. nftables 上位,但 Docker 可能没跟上(或者你没配置好)
    nftablesiptables 的正统接班人,设计更优、性能更好、语法更骚气。新版的 Linux 发行版都在逐步用 nftables 替换 iptables 作为默认防火墙后端。但 Docker 的核心逻辑可能还是认 iptables 的命令行接口。这就可能造成几种情况:

    • 系统底层用的是 nftables,但你装了 iptables-nft 这个兼容层。Docker 调用 iptables 命令时,实际上是 iptables-nft 在帮你把规则翻译成 nftables 规则。这一般能工作,但可能会和你手动写的 nftables 规则混在一起,有点乱。
    • 你完全禁用了 iptables,只用纯 nftables。这时候 Docker 发现 iptables 命令没了,或者没效果,它就没法自动帮你配置网络了。
    • Docker 还在尝试操作 iptables,而你同时在用 nftables 手写规则,两者可能对同一个数据包的处理产生冲突或者遗漏。
  3. 被忽略的 FORWARD 链:转发的核心
    开篇那个问题,“关闭 OUTPUT 链后不工作”,这很可能是一个误导。NAT (dnat) 发生在 prerouting 链,改变了目标 IP 地址。但是,数据包从外部接口 (ens18) 进来,要想到达内部的 docker0 接口,它需要经过 Linux 内核的转发 (Forwarding)路径。控制这个路径的,主要是 filter 表里的 FORWARD 链。

    如果你设置了 FORWARD 链的默认策略是 drop(拒绝),并且没有明确写规则允许从 ens18docker0(以及反向,用于响应包)的流量通过,那即使 NAT 成功了,数据包也会在转发这一步被丢掉。同理,如果 OUTPUT 链策略设为 drop,并且没有为响应包 (从容器经 docker0 回来,再从 ens18 出去)放行,也可能导致连接建立不完整。不过,对于外部访问容器,FORWARD 链的问题更常见。

  4. 连接跟踪(Connection Tracking)的重要性
    现代防火墙都是状态化的。对于一个 TCP 连接,第一个包(SYN)过来,经过 NAT 和 FORWARD 规则检查后到达容器。容器回包(SYN/ACK)时,内核需要知道这个包是属于刚才那个连接的,这样才能自动允许它通过 FORWARDOUTPUT 链,并且在需要时做反向 NAT(SNAT)。这是通过连接跟踪 (conntrack) 模块实现的。你的 nftables 规则里必须包含允许 relatedestablished 状态的连接通过的规则。

总结一下,问题大概率不是 Docker 和 nftables 水火不容,而是你的 nftables 规则没有完整覆盖数据包从进入到转发再到出去(以及响应包回来)的整个流程,特别是 FORWARD 链的规则,以及可能存在的 Docker 自动规则与手动规则的冲突。

解决方案:让 Docker 和 nftables 和谐共处

针对上面的原因,有几种思路可以解决这个问题:

方案一:纯手动精细化管理 nftables 规则

这是控制欲最强的方案。完全接管 Docker 相关的网络规则,不让 Docker 插手(或者忽略它可能产生的 iptables 规则)。

原理:
nftables 里,不仅要写 NAT 规则,还要写 FORWARD 链规则,明确允许目标流量在接口间转发。同时,利用连接跟踪,简化规则。

操作步骤:

  1. 确保 IP 转发已启用:
    检查 /proc/sys/net/ipv4/ip_forward 的值是否为 1。如果不是,执行:

    sudo sysctl -w net.ipv4.ip_forward=1
    # 若要永久生效,编辑 /etc/sysctl.conf 或在 /etc/sysctl.d/ 下创建配置文件
    # 添加或修改: net.ipv4.ip_forward = 1
    # 然后执行 sudo sysctl -p
    
  2. 编写完整的 nftables 规则集:
    你需要至少包含以下部分的规则:

    • 定义表和链: 创建 filter 表(用于过滤)和 nat 表(用于地址转换)。
    • 基础 INPUT/OUTPUT/FORWARD 策略: 先设置默认拒绝策略,更安全。
      flush ruleset
      
      table inet filter {
          chain input {
              type filter hook input priority 0; policy drop;
              # 允许回环接口流量
              iifname "lo" accept
              # 允许已建立和相关的连接 (非常重要!)
              ct state established,related accept
              # 根据需要允许其他入站流量, 比如 SSH
              tcp dport 22 accept
          }
      
          chain forward {
              type filter hook forward priority 0; policy drop;
              # 允许已建立和相关的连接转发 (非常重要!)
              ct state established,related accept
          }
      
          chain output {
              type filter hook output priority 0; policy accept; # 或者 drop,然后按需允许
              # 如果 policy 是 drop, 至少需要下面这句
              # oifname "lo" accept
              # ct state established,related accept
              # ... 其他允许的出站规则
          }
      }
      
      table inet nat {
          chain prerouting {
              type nat hook prerouting priority -100; policy accept;
          }
          chain postrouting {
              type nat hook postrouting priority 100; policy accept;
               # 如果容器需要访问外网,可能需要SNAT规则
               # 例如:允许 docker0 网段出去时伪装成宿主机IP
               # ip saddr 172.17.0.0/16 oifname "ens18" masquerade
          }
      }
      
    • 添加针对 Docker 的 NAT 规则: 就是你之前写的那个。
      table inet nat {
          chain prerouting {
              type nat hook prerouting priority -100; policy accept;
              # 把 192.168.16.215:8080 (ens18接口) 的流量转给 172.17.10.2:80
              tcp dport 8080 iifname "ens18" ip daddr 192.168.16.215 dnat to 172.17.10.2:80
          }
           # 如果容器需要访问外网,也需要 SNAT/Masquerade
          chain postrouting {
               type nat hook postrouting priority 100; policy accept;
               ip saddr 172.17.0.0/16 oifname "ens18" masquerade
          }
      }
      
    • 添加关键的 FORWARD 规则: 这是最容易漏掉的!
      table inet filter {
          chain forward {
              type filter hook forward priority 0; policy drop; # 默认拒绝
      
              # 允许已建立和相关的连接,涵盖大部分双向流量
              ct state established,related accept
      
              # 明确允许从外部访问容器的新连接
              # 从 ens18 进来,目标是容器 IP 和端口,去往 docker0
              iifname "ens18" oifname "docker0" ip daddr 172.17.10.2 tcp dport 80 ct state new accept
      
              # (可选) 如果容器需要主动访问外部网络
              # iifname "docker0" oifname "ens18" accept # 过于宽松,下面是更精确的
      
              # (更精确的可选规则) 允许 docker 网段访问外部,通常结合 postrouting 的 masquerade
               iifname "docker0" oifname "ens18" ip saddr 172.17.0.0/16 accept
      
               # (更精确的可选规则) 允许容器访问宿主机上的服务 (如果需要)
               # iifname "docker0" oifname "lo" accept
      
               # (更精确的可选规则) 允许宿主机访问容器上的服务 (如果需要, NAT通常已经处理了入口,这里是关于其他协议或直接访问)
               # iifname "lo" oifname "docker0" accept
          }
      }
      
  3. 加载规则:
    将上述规则保存到文件(例如 /etc/nftables.conf),然后加载:

    sudo nft -f /etc/nftables.conf
    

    确保 nftables 服务开机自启并加载该配置。

代码示例(合并后的 /etc/nftables.conf 片段):

#!/usr/sbin/nft -f

flush ruleset

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;
        iifname "lo" accept
        ct state established,related accept
        tcp dport 22 accept # 允许 SSH 访问宿主机
        # 可以添加其他允许访问宿主机的规则
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
        ct state established,related accept

        # 允许从 ens18 转发到容器 172.17.10.2 的 80 端口的新连接
        iifname "ens18" oifname "docker0" ip daddr 172.17.10.2 tcp dport 80 ct state new accept

        # 允许 docker 网段访问外部 (配合下面的 masquerade)
        iifname "docker0" oifname "ens18" ip saddr 172.17.0.0/16 accept

        # 可以添加其他转发规则,比如容器间通信 (如果不在同一个docker network)
    }

    chain output {
        type filter hook output priority 0; policy accept; # 或者更严格设为 drop
        # 如果是 drop, 记得允许必要出站流量
        # oifname "lo" accept
        # ct state established,related accept
        # ...
    }
}

table inet nat {
    chain prerouting {
        type nat hook prerouting priority -100; policy accept;
        # DNAT 规则: ens18:8080 -> container:80
        tcp dport 8080 iifname "ens18" ip daddr 192.168.16.215 dnat to 172.17.10.2:80
    }

    chain postrouting {
        type nat hook postrouting priority 100; policy accept;
        # 让容器可以通过宿主机 IP 访问外网
        ip saddr 172.17.0.0/16 oifname "ens18" masquerade
    }
}

安全建议:
规则越精细越好。不要直接 accept 整个接口的流量转发,尽量指定源/目标 IP、端口、协议和连接状态。默认策略设为 drop 是个好习惯。

进阶使用技巧:
可以使用 nftablesset 来管理一组 IP 地址或端口,让规则更简洁。比如定义一个 docker_nets 集合包含所有 Docker 网络的地址范围。
使用 log 动作调试被拒绝的包:在 drop 规则前加一条 log prefix "FORWARD drop: " limit rate 5/minute 可以在内核日志中看到被阻止的流量信息。

方案二:禁用 Docker 的 iptables 管理

如果你想完全掌控 nftables,不想让 Docker 在背后捣乱(比如创建它自己的 iptables 规则,即使是通过 iptables-nft),可以明确告诉 Docker:“别碰 iptables”。

原理:
修改 Docker 守护进程的配置,让它不执行任何 iptables 操作。这样一来,所有网络配置(包括基础的容器上网 NAT、端口映射等)都必须由你手动在 nftables 中实现。

操作步骤:

  1. 编辑 Docker 配置文件:
    通常是 /etc/docker/daemon.json。如果文件不存在,就创建一个。

    {
      "iptables": false
    }
    
  2. 重启 Docker 服务:

    sudo systemctl restart docker
    
  3. 手动配置所有必要的 nftables 规则:
    现在 Docker 不会自动配置网络了。你需要像方案一那样,手动编写 nftables 规则来处理:

    • 容器访问外网的 SNAT/Masquerade(natpostrouting 链)。
    • 所有端口映射的 DNAT(natprerouting 链)。
    • 所有必要的 FORWARD 链规则,允许流量在宿主机接口和 docker0(或其他 Docker 网络接口)之间流转。
    • 甚至可能需要配置容器间的通信规则(如果它们不在同一个自定义 Docker 网络里)。

代码示例(daemon.json):

{
  "iptables": false
}

安全建议:
这种方法给了你最大的控制权,但也意味着最大的责任。任何 Docker 需要的网络连通性,如果没在你的 nftables 规则里明确允许,就会失败。务必仔细测试所有网络场景(容器访问外网、外部访问容器、容器间通信等)。

注意事项:
这个选项会让 Docker 的 -p 端口映射参数失效,因为它是通过 iptables 实现的。你必须手动在 nftables 里实现等效的 DNAT 和 FORWARD 规则。管理起来会复杂一些,特别是当你有很多容器和端口映射时。

方案三:拥抱 iptables-nft 兼容模式

这是在许多现代 Linux 发行版上比较“顺滑”的方式。系统底层用 nftables,但提供 iptables 命令作为前端接口。Docker 继续使用它熟悉的 iptables 命令,这些命令被翻译成 nftables 规则执行。你可以同时使用 nftables 命令添加你自己的规则。

原理:
利用 Linux 提供的 iptables-nft 模式。让 Docker 按照它的老方式去管理 iptables 规则(这些规则实际上会被 nftables 内核子系统处理)。同时,你可以在 nftables 中添加补充规则,比如更精细的 FORWARD 链控制或者自定义的 NAT 规则。

操作步骤:

  1. 确认系统使用 iptables-nft
    在 Debian/Ubuntu 系统上,可以通过 update-alternatives 查看:

    sudo update-alternatives --config iptables
    

    选择包含 "nft" 的选项(如 /usr/sbin/iptables-nft)。其他发行版可能通过检查安装的包(比如 iptables 包是否是基于 nftables 的版本)来确认。

  2. 保持 Docker 的默认设置:
    确保 /etc/docker/daemon.json 中没有 "iptables": false 的设置(或者明确设为 true)。让 Docker 继续管理 iptables 规则。

  3. 理解 Docker 创建的规则:
    Docker 重启后,用 sudo nft list ruleset 查看 nftables 规则集。你会看到 Docker 通过 iptables-nft 创建了一些链(比如 filter 表下的 DOCKER, DOCKER-ISOLATION-STAGE-1/2, DOCKER-USER 链,以及 nat 表下的 DOCKER 链等)。Docker 的 -p 映射规则通常会出现在 nat 表的 DOCKER 链(做 DNAT)和 filter 表的 DOCKER 链(做 FORWARD 允许)。

  4. nftables 中添加你自己的规则:
    你仍然可以用 nft 命令或 /etc/nftables.conf 添加规则。关键在于要理解你的规则和 Docker 自动生成的规则如何协同工作。

    • 利用 DOCKER-USER 链: Docker 在 filter 表的 FORWARD 链处理流程中,特意设置了一个 DOCKER-USER 链。这个链默认是空的,并且会在 Docker 自身的规则之前被调用。官方推荐把用户自定义的、与 Docker 相关的过滤规则放在这里。这样可以确保你的规则优先执行,并且不会被 Docker 服务重启所覆盖。

      table inet filter {
          # DOCKER-USER 链是 Docker 预留给你添加自定义规则的地方
          chain DOCKER-USER {
               # 在这里添加你自己的规则,例如更严格的访问控制
               # 比如:只允许某个特定源 IP 访问那个转发的端口
               # iifname "ens18" oifname "docker0" ip saddr 1.2.3.4 ip daddr 172.17.10.2 tcp dport 80 accept
               # 如果上面这条匹配了,下面的 docker 自己的规则可能就不再执行了 (取决于链的跳转方式)
      
               # 注意:默认情况下 DOCKER-USER 链最后是 RETURN,表示继续执行 Docker 后续的规则
               # 如果你在这里加了 accept/drop/reject,处理就可能在这里结束
          }
      }
      
    • 直接添加 FORWARD 规则: 你也可以直接在 filter 表的 FORWARD 链添加规则。注意规则的顺序。如果你想覆盖 Docker 的某些行为,需要确保你的规则优先级更高(在 nftables 中,链里规则按顺序匹配)。

    • 管理 NAT 规则: 如果你需要做 Docker -p 参数之外的、更复杂的 NAT,可以像方案一那样在 nat 表的 preroutingpostrouting 链中添加规则。要小心不要和 Docker 自动生成的 NAT 规则冲突。

代码示例(检查 iptables 模式):

sudo update-alternatives --display iptables # 查看当前指向
sudo update-alternatives --config iptables  # 如果需要,切换模式

安全建议:
利用 DOCKER-USER 链是管理自定义 Docker 相关过滤规则的最佳实践。查看 sudo nft list ruleset 理解 Docker 创建了哪些规则,可以帮你避免冲突和遗漏。

进阶使用技巧:
即使在这种模式下,用 sudo nft monitor 也能跟踪数据包如何经过 Docker 创建的以及你自己添加的 nftables 规则,是排查问题的利器。

调试小技巧

遇到问题别慌,试试这些方法定位:

  1. 检查规则是否加载: sudo nft list ruleset,仔细看你的规则是不是真的在里面,语法有没有写错。
  2. 看数据包怎么走: sudo nft monitor trace (需要内核支持并加载 nft_trace 模块),可以实时看到每个数据包匹配了哪些规则。这个超级有用!
  3. 记录被丢弃的包:FORWARDINPUT 链的 policy drop 之前,加上 log prefix "DROP(forward): " droplog prefix "DROP(input): " drop,然后在系统日志里(如 dmesg/var/log/syslog)看是什么样的包被丢了。
  4. 检查连接跟踪表: sudo conntrack -L,看看连接是否被正确跟踪,状态是什么(ESTABLISHED, NEW, RELATED 等)。sudo conntrack -E -p tcp --dport 8080 可以实时看新进来的连接。
  5. 简化规则: 暂时把防火墙规则大幅简化,比如 FORWARD 链临时改成 policy accept,看看能不能通。如果通了,再逐步加回规则,找到是哪条规则拦住了。
  6. 检查 Docker 网络: docker network inspect bridge(或其他网络名),确认容器 IP、网关等信息是否和你规则里写的一致。

选哪种方案取决于你的需求:想要完全控制、不怕麻烦,就用方案一或方案二;想省事点、利用 Docker 的便利性,同时还能自定义一些规则,方案三(iptables-nft 模式)通常是个不错的平衡点。关键是理解数据包的流转路径和 nftables 不同表链的作用。