ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Docker端口映射与防火墙:数据包绕行FORWARD链,规则这样写才有效

Docker端口映射与防火墙:数据包绕行FORWARD链,规则这样写才有效 前阵子处理一台服务器的端口开放问题差点把自己绕进去。服务器上跑着Nginx容器端口映射做了8080:80firewalld 里加了好几条规则只允许内网某些 IP 访问 8080其他来源一律拒绝。配置完用iptables -L一看规则都在心里想着应该稳了结果外网照样能访问容器服务。后来一查数据包路径才明白Docker 的端口映射根本不会走 INPUT 链而是通过 FORWARD 链直接转发firewalld 规则写得再严格只要没管到转发这条路上就是白干。今天结合这次排障把 Linux firewall 对 Docker 暴露端口无效这件事的原理、解决方法和坑一次性说清楚。这篇文章适合正在被同样问题折磨的运维、后端开发也适合刚接触 Docker 端口管理的小白。读完你会知道问题不在 firewalld 配错而在流量压根没从它管的那条路经过。1. 先搞清楚问题为什么 firewall 对 Docker 端口“没反应”1.1 你以为防火墙拦的是本机端口其实包已经被“转手”了很多人习惯把 Docker 端口映射理解成“容器把端口借给了宿主机”于是下意识认为只要在宿主机防火墙里限制对应端口就行。这个理解在传统进程上是对的但在 Docker 端口映射上不成立。docker run -p 8080:80这条命令背后实际做了一件关键的事在 iptables 的 nat 表 PREROUTING 链里插入一条 DNAT 规则。外网数据包到达服务器网卡后第一个经过的 iptables 阶段就是 PREROUTINGDNAT 规则会把包的目的 IP 从“服务器公网 IP”改写为“容器 IP”目的端口也从 8080 改写为容器内的 80。改写完成后内核做路由决策发现目标 IP 是172.17.0.x这种地址这个地址不属于本机接口而是属于 docker0 网桥的另一侧于是这个包被判定为“转发流量”交给 FORWARD 链处理而不是 INPUT 链。普通进程监听 8080 端口时数据包目的 IP 是本机 IP走 INPUT 链防火墙规则能拦到。但容器场景下包在到达 INPUT 之前就被“转手”给了容器INPUT 链根本看不到原始目标端口为 8080 的入站包。这就像快递员送货地址写的是公司前台前台收到后直接转单给内部同事保安只检查了前台没检查内部流转自然拦不住。1.2 Docker 会在 FORWARD 链上自动放行firewalld 却管不到这条路径firewalld 默认加载的规则比如 zone 规则、rich rule大部分都作用于 filter 表的 INPUT、OUTPUT 链尤其是针对外部访问本机端口几乎全在 INPUT 链里做拦截。可 Docker 端口映射产生的转发流量走的却是 FORWARD 链。Docker 服务启动后会在 filter 表的 FORWARD 链里插入一堆 ACCEPT 规则保证容器与容器之间、容器与外网之间能通信。更麻烦的是Docker 默认把自己的规则放在 FORWARD 链比较靠前的位置优先级很高。就算你在 firewalld 里手动给 FORWARD 链加规则大概率也会被 Docker 自带的 ACCEPT 规则提前放行。所以不是 firewalld 不干活而是它默认管理的链条和 Docker 数据包的路径根本不对症。这也是“firewall 对 docker 暴露端口无效”最常见的根因。1.3 两条命令快速弄清现状遇到这个问题先别急着加规则花两分钟确认一下当前状态。推荐执行下面三条命令基本能看清问题全貌。iptables -L FORWARD -n --line-numbers iptables -L DOCKER-USER -n --line-numbers iptables -t nat -L PREROUTING -n --line-numbers第一条看 FORWARD 链里是否有 Docker 自动插入的规则第二条看 DOCKER-USER 链现状第三条确认端口映射生成的 DNAT 规则。像我在排障时看到的是这样Chain PREROUTING (policy ACCEPT) num target prot opt source destination 1 DOCKER tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080再看 FORWARD 链会发现 DOCKER-USER 和 DOCKER 链挂在最前面Docker 自己的规则优先执行。看到这个输出基本就可以确定限制规则要写到 DOCKER-USER 里而不是 firewalld 的 INPUT 规则里。2. Docker 端口映射背后的关键技术细节2.1 数据包从外网到容器的完整路径为了彻底理解这个问题我把一次完整的访问流程梳理一遍。假设服务器 IP 是203.0.113.5运行docker run -p 8080:80容器 IP 是172.17.0.2。外部客户端访问http://203.0.113.5:8080。数据包经过的路径是这样的包到达服务器 eth0 网卡。进入 PREROUTING 链命中 Docker 添加的 DNAT 规则目的 IP 变成172.17.0.2端口变成 80。进入路由决策发现目的 IP 不是本机 IP属于 docker0 网桥后面的容器。进入 FORWARD 链先经过 DOCKER-USER 链这是 Docker 留给用户自定义规则的位置。再进入 DOCKER 链这里都是 Docker 自动管理的规则通常会匹配并放行。包通过 docker0 网桥进入容器网卡最后到达容器内的 Nginx。整个过程中INPUT 链完全没有参与。所以如果你在 firewalld 里限制 INPUT 链的 8080 端口无论规则多严格都和这个数据包无关。2.2 为什么 INPUT 链、firewalld zone 规则都拦不住firewalld 的 zone 规则和 rich rule默认操作的都是 filter 表的 INPUT 链。你把默认区域设为 drop外部访问普通端口会被拦但访问 Docker 暴露的端口依然能通因为 FORWARD 链被 Docker 的 ACCEPT 规则接管了。有人可能想那我给 firewalld 添加一条 FORWARD 链的规则不就行了理论上可以但实际很难搞。Docker 启动时会把 FORWARD 链的策略设置为 ACCEPT并且不断向链顶插入自己的规则你手动插入的规则位置很可能排在 Docker 规则之后。即使你插到了前面Docker 服务重启后又会重新排列这些链规则位置不稳定。另外firewalld 的重载操作可能会把自定义的 FORWARD 规则清理掉或者和 Docker 的规则产生冲突。与其和 Docker 抢 FORWARD 链的控制权不如直接使用 Docker 官方预留的规则入口。2.3 DOCKER-USER 链的设计初衷Docker 官方早就预料到用户有自定义防火墙的需求所以在 FORWARD 链最前面放了一条 DOCKER-USER 链专门用于存放用户自定义规则。这个链的位置很巧妙它在 FORWARD 链的第一位只要数据包进入 FORWARD 链会先经过 DOCKER-USER再进入 Docker 自己管理的 DOCKER 链。这意味着你在 DOCKER-USER 链里写的规则永远比 Docker 自动规则先执行。想拦外网访问容器端口把规则写到这里就是最直接、最可控的方式。看到这里解决思路已经很清晰了不要再折腾 firewalld 的常规规则而是把“防火墙限制”下沉到 iptables 的 DOCKER-USER 链或者通过 firewalld 的 direct 模式去操作这个链。3. 实际解决把防火墙规则写到该写的地方3.1 直接操作 DOCKER-USER 链最小可行的白名单先说最常见的情况只允许内网某个网段访问容器的某个端口其他来源全部拒绝。以docker run -p 8080:80为例假设需要放行192.168.1.0/24网段。关键问题来了此时 DOCKER-USER 链中数据包的--dport是多少前面提到DNAT 已经先把端口从 8080 改成了 80所以在 FORWARD 链里看到的目的端口是容器内端口 80而不是宿主机映射端口 8080。如果直接用--dport 8080去匹配会匹配不到。正确写法有两种第一种直接匹配容器端口iptables -I DOCKER-USER 1 -s 192.168.1.0/24 -p tcp --dport 80 -j ACCEPT iptables -I DOCKER-USER 2 -p tcp --dport 80 -j DROP第二种用 conntrack 模块匹配原始目标端口这样规则语义更贴近宿主机视角iptables -I DOCKER-USER 1 -m conntrack --ctorigdstport 8080 -s 192.168.1.0/24 -j ACCEPT iptables -I DOCKER-USER 2 -m conntrack --ctorigdstport 8080 -j DROP--ctorigdstport表示连接跟踪记录的原始目的端口也就是 DNAT 之前的端口。用了这个参数哪怕容器内实际端口和宿主机不一致规则也能准确匹配。顺序很重要。iptables 规则从上到下匹配第一条是放行白名单网段第二条是拒绝其他来源。如果把 DROP 放在第一条那白名单就失去意义了。3.2 匹配宿主机端口还是容器端口这一步最容易错我见过很多人在 DOCKER-USER 链里写--dport 8080发现不生效然后怀疑 Docker 有问题。其实不是因为数据包在进入 FORWARD 链之前已经被 DNAT 改写了目的端口。举个例子会更直观容器启动命令docker run -p 8080:80宿主机暴露端口8080容器内部端口80数据包进入 DOCKER-USER 链时目的端口已经成为 80所以在 DOCKER-USER 链里--dport 80匹配的是所有从外部访问容器 80 端口的流量包括经过 8080 映射来的流量--dport 8080几乎匹配不到任何流量。如果觉得写容器端口容易混乱建议统一使用-m conntrack --ctorigdstport 8080这样规则可读性更好也和docker ps里的端口映射对得上。有一点要注意DOCKER-USER 链在 FORWARD 链上容器对外的出站流量也可能经过这个链。如果你只匹配--ctorigdstport 8080一般不会误伤出站连接因为出站连接的目标端口大概率不是 8080。但如果你写的是-j DROP且没有匹配端口那就等于把所有经过 FORWARD 链的流量全断了容器网络会直接瘫痪。3.3 让自定义规则在重启后依然有效添加规则一时爽重启之后全白干。Docker 服务重启时会重新初始化 iptables 里的相关链自己手动插入的规则很可能被清掉。想要规则持久化最简单的办法是做一个 systemd 服务在 Docker 启动后自动执行我们的脚本。先创建一个脚本文件比如/usr/local/bin/docker-iptables-rules.sh#!/usr/bin/env bash RULE_1-I DOCKER-USER 1 -m conntrack --ctorigdstport 8080 -s 192.168.1.0/24 -j ACCEPT RULE_2-I DOCKER-USER 2 -m conntrack --ctorigdstport 8080 -j DROP iptables -C DOCKER-USER -m conntrack --ctorigdstport 8080 -s 192.168.1.0/24 -j ACCEPT 2/dev/null || iptables $RULE_1 iptables -C DOCKER-USER -m conntrack --ctorigdstport 8080 -j DROP 2/dev/null || iptables $RULE_2这里用-C检查规则是否已存在避免脚本重复执行时插入一堆重复规则。然后创建 systemd 服务[Unit] DescriptionDocker user iptables rules Afterdocker.service Requiresdocker.service [Service] Typeoneshot ExecStart/usr/local/bin/docker-iptables-rules.sh RemainAfterExityes [Install] WantedBymulti-user.target保存到/etc/systemd/system/docker-iptables-rules.service然后执行chmod x /usr/local/bin/docker-iptables-rules.sh systemctl daemon-reload systemctl enable --now docker-iptables-rules这样每次机器重启或者 Docker 服务重启后规则都会被重新加进去。实测下来很稳比直接依赖 iptables-persistent 更可控。3.4 firewalld 用户如何用 direct 规则实现持久化如果你不想直接写 iptables 命令希望规则由 firewalld 统一管理可以用 firewalld 的 direct 模式把规则插入到 DOCKER-USER 链。命令如下firewall-cmd --permanent --direct --add-rule ipv4 filter DOCKER-USER 0 -m conntrack --ctorigdstport 8080 -s 192.168.1.0/24 -j ACCEPT firewall-cmd --permanent --direct --add-rule ipv4 filter DOCKER-USER 1 -m conntrack --ctorigdstport 8080 -j DROP firewall-cmd --reload注意--add-rule后面的数字是优先级数字越小越靠前。这里的 0 和 1 保证了白名单规则先执行DROP 规则后执行。这种方式的优点是规则写进 firewalld 的配置里firewall-cmd --reload后还能保留。但有一个前提DOCKER-USER 链必须存在否则 firewalld 找不到链会报错。所以最好在 Docker 服务启动后再执行 reload。你可以在 systemd 服务里把这条 firewalld 规则脚本放在Afterdocker.service之后确保依赖关系。3.5 ufw 用户怎么办如果你用的是 ufw问题类似。ufw 也是基于 iptables 的封装但它默认管理的主要是 INPUT 链对 Docker 的 FORWARD 链控制力很弱。最省心的办法还是直接用 iptables 操作 DOCKER-USER 链ufw 不用动。比如上述的iptables -I DOCKER-USER ...规则在 ufw 环境下同样生效因为 ufw 和 Docker 最终都在操作同一个内核规则集。如果你希望规则由 ufw 管理可以编辑/etc/ufw/before.rules在文件里自定义一条链。不过这个方式比较绕容易出错。我的建议是ufw 负责普通进程的端口控制Docker 容器的端口控制统一走 DOCKER-USER 链两套体系分开管理反而更清晰。4. 常见问题与排查技巧实录4.1 加了 DROP 规则还是能访问先检查这三件事很多人照着教程加了 DROP 规则发现外网还是能访问就开始怀疑教程有问题。其实多数情况是下面三个细节没注意。第一规则是不是落在了 DOCKER-USER 链。如果你把规则加到了 INPUT 链那当然没用。用iptables -L DOCKER-USER -n --line-numbers确认一下。第二端口匹配得对不对。回顾一下前面说的DOCKER-USER 链里--dport是容器端口不是宿主机端口。拿8080:80举例这里应该用--dport 80或者用-m conntrack --ctorigdstport 8080。第三规则顺序有没有被其他规则影响。比如 DOCKER-USER 链里如果已经有其他 ACCEPT 规则排在你的 DROP 前面DROP 根本执行不到。把所有规则列出来按逻辑重新梳理。另外DOCKER-USER链默认会有一条RETURN规则在链尾这相当于把剩余流量交还给 FORWARD 链继续处理。如果你用-A追加规则可能会加到RETURN之后那就永远不会执行。所以添加规则时建议用-I插入到链首或者明确指定位置。4.2 firewalld reload 或 Docker 重启后规则失效这个问题很典型。规则当时能生效过两天发现外网又能访问了多半是 Docker 服务或者 firewalld 被重启过。Docker 服务重启时会重新初始化 iptables 中的规则用户手动加进 DOCKER-USER 链的规则会被清掉。解决办法就是前面提到的 systemd 服务让自定义脚本跟随 Docker 服务自动执行。firewalld reload 导致的问题则不一样。firewall-cmd --reload会重新加载 firewalld 的规则集如果你使用的是--direct --add-rule写入的规则reload 后规则会重新加载通常不会丢。但如果你是通过普通iptables命令插入到 DOCKER-USER 链的规则firewalld 不负责管理它们reload 的时候可能不受影响也可能因为链被重置而消失行为取决于具体环境。最稳妥的做法是不管用哪种方式都做成开机自启或服务依赖不要依赖手动机器操作。4.3 排查命令速查表为了方便排查我把常用命令整理成了一张表遇到问题直接对照使用。目的命令查看 FORWARD 链iptables -L FORWARD -n --line-numbers查看 DOCKER-USER 链iptables -L DOCKER-USER -n --line-numbers查看 DNAT 端口映射规则iptables -t nat -L PREROUTING -n --line-numbers查看 firewalld 状态firewall-cmd --state查看 firewalld direct 规则firewall-cmd --direct --get-all-rules抓包确认流量走向tcpdump -i eth0 -n port 8080测试端口是否可访问nc -vz 服务器IP 8080或telnet 服务器IP 8080其中tcpdump特别有用。比如你在服务器上抓包看到外部访问 8080 的包进来后iptables 规则却匹配不到就能快速定位是 DNAT 改写导致端口变化的问题。4.4 一个小技巧先从“全拒”开始验证调试过程中我习惯先在 DOCKER-USER 链上加一条针对目标端口的全拒规则验证规则链路是否通再加白名单。例如先执行iptables -I DOCKER-USER 1 -m conntrack --ctorigdstport 8080 -j DROP然后用外网 IP 访问一下确认已经无法访问。能访问说明规则没生效问题出在规则本身不能访问说明链路正确接下来再把白名单规则插入到 DROP 前面测试白名单网段是否正常。这个“先拒绝、再放行”的顺序能避免白名单规则和 Docker 自动规则纠缠不清排查效率高很多。5. 我的实操建议与典型配置模板5.1 我不推荐的方案直接设置 iptables: false网上有一种思路在/etc/docker/daemon.json里设置{ iptables: false }这样 Docker 就不再操作 iptablesfirewalld 的规则似乎就能管住端口了。但这个方案副作用很大Docker 的端口映射会失效容器之间的网络隔离也会出问题很多依赖 Docker 网络的功能直接不可用。如果你不是网络专家不建议在生产环境用这个配置。我更推荐的做法是尊重 Docker 的 iptables 规则体系在它预留的 DOCKER-USER 链里做限制。这样既不影响 Docker 自身的网络功能又能实现端口访问控制。5.2 两种典型场景的配置模板场景一只允许内网访问单个容器端口容器启动命令docker run -d -p 8080:80 --name web nginx防火墙规则iptables -I DOCKER-USER 1 -m conntrack --ctorigdstport 8080 -s 192.168.1.0/24 -j ACCEPT iptables -I DOCKER-USER 2 -m conntrack --ctorigdstport 8080 -j DROP场景二禁止某个来源 IP 访问所有 Docker 映射端口iptables -I DOCKER-USER 1 -s 10.0.0.8 -j DROP这条规则会把10.0.0.8进出的流量都在 FORWARD 阶段拦掉容器外部无法访问容器内部访问外部也会被拦。如果你只想拦入站可以加-p tcp -m state --state NEWiptables -I DOCKER-USER 1 -s 10.0.0.8 -p tcp -m state --state NEW -j DROP具体用哪条取决于你希望控制到什么粒度。5.3 最后的经验规则要写在“数据包经过”的地方我个人在实际操作中的体会是Linux 防火墙排障最重要的不是背命令而是理解数据包经过的链路。Docker 端口映射让数据包从 INPUT 链“绕”到了 FORWARD 链这是所有 firewall 规则失效的根源。现在再遇到 Docker 暴露端口不听话第一反应不再是疯狂加 firewalld 规则而是先看iptables -L FORWARD确认 DOCKER-USER 链的位置然后把规则写到那里。配合 systemd 做持久化基本可以一劳永逸。最后再分享一个小技巧每次创建新的容器并做了端口映射后顺手检查一下 DOCKER-USER 链有没有对应限制规则。养成这个习惯之后再也没出现过“端口裸奔”的尴尬情况。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进