
1. 为什么你的封禁规则在Docker环境里“封了个寂寞”先讲个最常见的场景服务器上用Docker跑了一个Nginx做了端口映射-p 8080:80日志里看到某个IP在持续扫描于是顺手执行了那条自认为“天下无敌”的命令iptables -A INPUT -s 203.0.113.5 -j DROP执行完后你觉得万事大吉过了十分钟再去看日志发现对方还在正常请求。为什么因为这条规则压根就没作用在流量经过的路径上。这里的关键在于INPUT链处理的是发往本机的流量包。而Docker默认的bridge网络下你使用-p映射端口时流量在到达宿主机的那一刻会被Netfilter的PREROUTING链中的DNAT规则直接改写目标地址随后走的是FORWARD链而不是INPUT链。数据包经过链路是这样的客户端 - 宿主机公网IP:8080 - PREROUTING(DNAT) - FORWARD - docker0网桥 - 容器IP:80也就是说你的INPUT链规则从始至终都“看不见”这个数据包。封禁自然不生效。Docker在创建容器时会自动往iptables里塞入一堆规则你宿主机上装了Docker之后iptables基本就不再是你想象中的那个“传统防火墙”了。我用一次真实的排障来说明问题。那时我检查iptables -L -n -v发现FORWARD链下挂着一串Docker自动生成的规则Chain FORWARD (policy ACCEPT) target prot opt source destination DOCKER-USER all -- 0.0.0.0/0 0.0.0.0/0 DOCKER-ISOLATION-STAGE-1 all -- 0.0.0.0/0 0.0.0.0/0 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED DOCKER all -- 0.0.0.0/0 0.0.0.0.0/0 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0如果你还开着nat表会看到DNAT规则保存在DOCKER链里专门用来做端口映射。这套规则是Docker的libnetwork在容器创建和销毁时自动增删的重启Docker服务会重建绝大部分规则。如果你把精力花在手动维护INPUT和FORWARD链上很可能在下次重启容器时前功尽弃。所以真正的问题不是“iptables能不能封”而是“你的规则该放在哪个位置”。答案的核心是Docker专门留给用户操作的那个预留链DOCKER-USER。下文会把它讲透。2. 真正该落锁的地方DOCKER-USER链的用法与规则顺序Docker在FORWARD链的最前面插入了一个自定义链DOCKER-USER。这个链的定位就是给运维人员写自定义策略用的它最显眼的特点有两个它挂在FORWARD链的第一位所有需要转发的数据包都会先经过它Docker重建自己的规则时不会清空这个链里的用户规则Docker重启后链本身会重建规则是否保留取决于你怎么持久化这个在后面单独说。所以封禁一个IP最简单可靠的写法是iptables -I DOCKER-USER -s 203.0.113.5 -j DROP-I表示插入到链的最前面。DOCKER-USER链默认以一条RETURN规则结尾意思是“不处理返回主链继续走”。如果你用-A追加规则只要加在RETURN之前也能生效。但为了防止链里的规则顺序被搞乱我的习惯是统一用-I指定插入位置或者先-F清空再重新写入结构可控。看一下典型的DOCKER-USER链内容Chain DOCKER-USER (1 references) pkts bytes target prot opt in out source destination 0 0 RETURN all -- * * 0.0.0.0/0 0.0.0.0/0这条RETURN规则非常关键。你写白名单规则时可以把它利用起来命中白名单的流量直接RETURN交还给Docker自己的转发逻辑处理不要再被后面的封禁规则误伤。比如iptables -I DOCKER-USER 1 -s 192.0.2.10 -j RETURN iptables -I DOCKER-USER 2 -s 203.0.113.0/24 -j DROP第一条把白名单IP直接放行第二条封掉一个IP段。这里注意顺序白名单必须放在前面。否则即便是白名单IP也会先撞上DROP规则被丢弃。为什么在DOCKER-USER里做白名单时用RETURN而不是ACCEPT我个人习惯是RETURN能让数据包回到FORWARD主链由Docker后续那一套ACCEPT规则去处理。如果你直接用ACCEPT也不是不可以数据包会被提前接受但可能会绕过Docker自己做的部分隔离控制。在单机简单场景下两种写法没太大差别但在你后续要叠加其他过滤规则时RETURN会更稳妥。这算是经验之谈。还有一点DOCKER-USER链里的规则是“全量流量”共享的。也就是说不管是宿主机上哪个容器、哪个虚拟网卡在转发数据都会先过一遍这里。这对封禁动作来说是好事——一条规则封全部容器不用挨个维护。3. 不同流量路径下的封禁落点端口映射、容器直连与host网络同一台宿主机上“Docker跑着的服务”在不同网络模式下数据包走的路径完全不同。你封禁的时候如果没区分就会出现在一个模式下有效、另一个模式下失效的“灵异事件”。我整理了一张表对应三种最常见的场景网络模式流量路径正确的规则落点常见错误bridge 端口映射-pPREROUTING DNAT - FORWARD - docker0 - 容器DOCKER-USER / FORWARD写在INPUT链完全不生效bridge 容器间直连IP容器A - docker0 - 容器B仍在FORWARD流程DOCKER-USER / FORWARD写在INPUT链不生效host 网络模式数据包直接进入宿主机栈INPUT链写在DOCKER-USER不生效3.1 端口映射场景最常用也最容易踩坑docker run -p 8080:80这种方式属于NAT端口映射。外部流量进到宿主机之后目标地址会被DNAT规则改写为容器IP。数据包在PREROUTING阶段就已经“改头换面”目标端口从8080变成了80。等数据包进入DOCKER-USER链时这个包的--dport已经是80而不是8080。这个细节是我自己踩过最深的坑。我当时想封一个IP对某个容器8080端口的访问写成了iptables -I DOCKER-USER -s 203.0.113.5 -p tcp --dport 8080 -j DROP结果测试时流量依旧畅通。排查半天才意识到DOCKER-USER链里匹配的端口应该是容器内部端口不是宿主机映射端口要写80才行iptables -I DOCKER-USER -s 203.0.113.5 -p tcp --dport 80 -j DROP这一点如果你没提前意识到很容易被“表象端口”带偏。如果有多个容器都监听80你想针对某个服务封禁那就加上目的IP来区分写成容器IP加端口的形式iptables -I DOCKER-USER -s 203.0.113.5 -p tcp -d 172.17.0.2 --dport 80 -j DROP如果对外映射端口不同比如A容器是8080:80B容器是8081:80你想只封A不封B那就必须用容器IP区分否则直接封80会把两个一起带走。3.2 容器间直连场景同宿主机上两个容器通过bridge网络互访数据包同样走FORWARD链不经过INPUT。这里依旧要用DOCKER-USER或FORWARD链。而且DOCKER-USER位于最前列天然适合做容器间访问控制。比如你要禁止容器A访问容器B的服务可以在DOCKER-USER里写iptables -I DOCKER-USER -s 172.17.0.2 -d 172.17.0.3 -j DROP注意docker0网桥的默认网段一般是172.17.0.0/16但你用docker network create自定义网络后网段可能完全不同。建议先通过docker inspect 容器名 | grep IPAddress确认实际IP再写规则。另外docker0上的容器间通信还有一层DOCKER-ISOLATION-STAGE-1/2的隔离链它管的是跨网络访问的隔离。你的自定义封禁规则会先于这些链执行所以优先级不用担心。3.3 host网络模式回到传统iptables使用--network host启动的容器和宿主机共享网络命名空间没有网桥、没有DNAT。外部流量访问服务本质上就是访问宿主机端口数据包走的是INPUT链。很多人在这种模式下还用DOCKER-USER链去封结果一点效果没有。host网络下的封禁回归传统写法iptables -A INPUT -s 203.0.113.5 -p tcp --dport 8080 -j DROP这里就涉及另一个细节如果你之前已经有一条ESTABLISHED,RELATED的放行规则放在前面新加的封禁规则对已建立的连接可能不会立刻生效。封禁新连接的话匹配--ctstate NEW是更精准的。iptables -A INPUT -s 203.0.113.5 -p tcp --dport 8080 -m conntrack --ctstate NEW -j DROP但常规操作下直接把IP完整DROP也不算错毕竟多数时候你希望立刻断开对方。4. 两个容易混淆的细节DNAT后的端口变化与存量连接的处理第3节里提到的“容器端口”问题值得单独再展开说说。因为现实中很多人在DOCKER-USER链里封禁端口时脑子里想的还是“对外端口”。记住这条铁律**数据包在进入DOCKER-USER链之前已经完成了DNAT改写。**来看一个简单例子docker run -d --name web -p 8080:80 nginx外部请求访问服务器IP:8080经过PREROUTING链的DNAT后包的目的地和端口已经变成了容器IP:80。所以# 错误写法匹配不到任何包 iptables -I DOCKER-USER -p tcp --dport 8080 -s 203.0.113.5 -j DROP # 正确写法匹配的是容器内部端口 iptables -I DOCKER-USER -p tcp --dport 80 -s 203.0.113.5 -j DROP如果你既想按端口封又不想影响其他容器那就需要把“容器IP 容器端口”组合起来。比如Nginx容器IP是172.17.0.2Redis容器IP是172.17.0.3两者都监听6379假设Redis默认端口的时候直接封6379会把两个都影响。此时用目的IP区分iptables -I DOCKER-USER -p tcp -d 172.17.0.3 --dport 6379 -s 203.0.113.5 -j DROP再聊下存量连接。你把一条DROP规则插入DOCKER-USER链后如果某台客户端已经建立了一条TCP长连接这条连接会立刻断吗会。原因在于DOCKER-USER挂在FORWARD链最前面而Docker自动生成的那条ACCEPT ... ctstate ESTABLISHED,RELATED规则在它的后面。数据包进入FORWARD链时先过DOCKER-USER所以你新加的DROP已经抢在了状态放行之前执行新的流量包直接丢掉。如果客户端TCP堆栈感知不到对方断开它需要等到超时后才重新握手。反过来如果是在host网络模式下用INPUT链封禁而系统里已有了-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT这类规则且位置在你新规则之前那么已建立的连接不会被切断因为状态匹配规则先放行了。这也是两台机器用“host网络”和“bridge映射”两种模式执行同样DROP命令后表现不一样的原因。一个实用思路是如果你想让封禁同时作用于“新连接 存量连接”直接写-j DROP就行不用额外加状态参数。如果只想阻止新连接、保留当前在线会话比如你正在远程操作的那台机器就用-m conntrack --ctstate NEW加上-j DROP。生产环境中我倾向于后者避免把自己正在连服务器的IP给误封了。5. 规则持久化与Docker重启后的“规则消失事件”手动用iptables -I添加的规则默认只存在内存里。重启机器、重启Docker服务规则可能就没了。这里的“可能”要看具体场景。对于容器启动时由Docker自动生成的DOCKER链和DNAT规则重启Docker会全部重建。对于DOCKER-USER链Docker重启时会重建这个链如果你只是在命令行手工加了规则没做持久化那么链被重建后你的规则依然会丢失。我在生产环境里遇到过不止一次Docker服务因为升级重启后之前手动封禁的IP全部“复活”业务方反馈攻击流量又回来了。所以规则必须落盘。常见做法是使用iptables-persistentapt install iptables-persistent netfilter-persistent save它会将当前规则保存到/etc/iptables/rules.v4和/etc/iptables/rules.v6。系统重启时服务会在启动阶段加载这些规则。但这里有个先有鸡还是先有蛋的问题某些发行版上netfilter-persistent和docker.service的启动顺序并不一定总是你想要的。如果Docker先启动并创建了DOCKER-USER链然后iptables-restore才执行规则会被灌入这个链这是理想情况。如果反了iptables-restore先把规则写进去Docker随后启动时重置链你的规则就丢了。我比较推荐的做法是写一个独立的systemd服务明确让它“After”Docker[Unit] DescriptionApply Docker firewall rules Afterdocker.service Requiresdocker.service [Service] Typeoneshot ExecStart/usr/local/bin/apply-docker-iptables.sh RemainAfterExityes [Install] WantedBymulti-user.target脚本内容就是把你要的规则用iptables -I批量执行一遍。每次Docker启动后自动重放即使中途有人清了链也能快速恢复。脚本里还可以加上幂等处理逻辑比如先删除链中的同名规则再插入防止重复执行时出现两条一样的内容#!/usr/bin/env bash set -e # 清理可能存在的旧规则忽略报错 iptables -D DOCKER-USER -s 203.0.113.5 -j DROP 2/dev/null || true # 插入新规则 iptables -I DOCKER-USER 1 -s 203.0.113.5 -j DROP iptables -I DOCKER-USER 2 -p tcp --dport 3306 -s 192.0.2.10 -j ACCEPT当规则变多以后我更倾向于在DOCKER-USER里只保留一条“集合匹配规则”把IP都放进ipset这样脚本更短变更也快。具体写法在第6节的综合示例里一起给。6. 集群式封禁与生产环境经验用ipset做动态封禁不再是噩梦手动一条条iptables -I的缺点是规则一旦多了链会变得非常臃肿而且每加一条新IP要么再插一条要么就得删掉旧的重新来。现实场景里你可能是从日志里持续发现一批攻击IP需要实时加入封禁列表。ipset是解决这个问题的标准方案。它把一组IP存放为一个集合iptables只匹配一个集合引用即可。新增和移除IP时不需要动iptables规则本身只操作ipset集合效率极高。初始化一个集合ipset create docker_block hash:ip timeout 0在DOCKER-USER链里引用这个集合一条规则封所有集合里的IPiptables -I DOCKER-USER -m set --match-set docker_block src -j DROP以后要封某个IP直接ipset add docker_block 203.0.113.5解封ipset del docker_block 203.0.113.5timeout参数可以设置集合条目的自动过期时间。比如timeout 86400表示每个IP加入后一天自动解封适合临时封禁场景。设为0表示永久生效需要手动删除。这个方案可以跟日志分析脚本或fail2ban联动。比如Nginx日志里发现某个IP在短时间内请求量异常脚本直接执行ipset add docker_block $BAD_IP不需要反复编辑iptables规则也避免了同时有几十条-s ip -j DROP规则时人眼看花的问题。还有几个生产环境里容易栽跟头的地方我挨个说第一不要在DOCKER-USER链里用-A追加RETURN以外的规则到链尾。链尾通常就是RETURN本身你追加-j DROP在RETURN之后意味着任何包都不会执行到那条DROP。用-I指定位置或先清空再重建是更稳妥的操作。第二修改FORWARD链默认策略要非常谨慎。有些人习惯iptables -P FORWARD DROP然后手动放行需要转发的流量。Docker的容器网络对外通信大量依赖FORWARD链你一旦把默认策略改成DROP又没有完整放行容器网段的规则所有容器都可能瞬间断网。我的建议是容器环境下不要轻易动-P策略把自定义规则放在DOCKER-USER里更安全。第三在Docker环境里使用INPUT链封IP时要想想你的Docker服务到底是不是host网络模式。很多人在一台机器上混跑着多种网络模式的容器如果只改INPUT链那bridge映射的容器依然暴露着如果只改DOCKER-USERhost模式的容器又封不到。正确的做法是两种模式分别处理或者干脆统一业务容器都走bridge映射把控制面收敛到DOCKER-USER一条链上。7. 一套可直接落地的封禁完整实例我把前面讲的东西整合成一个端到端示例。假设环境如下宿主机Ubuntu 22.04Docker 24两个容器webNginx-p 8080:80启动容器IP172.17.0.2dbMySQL-p 3306:3306启动容器IP172.17.0.3安全需求封禁攻击IP198.51.100.7对Nginx的所有访问封禁整个扫描段100.64.0.0/10对Nginx的访问数据库MySQL完全不允许公网直连只允许公司运维出口IP192.0.2.10访问规则在机器和Docker重启后仍然生效。先看当前DOCKER-USER链状态iptables -L DOCKER-USER -n -v --line-numbers然后按优先级插入规则。白名单必须在黑名单之前所以第一条先放数据库白名单# 1. 将MySQL映射端口放行给公司IP注意端口是容器内部3306 iptables -I DOCKER-USER 1 -p tcp --dport 3306 -s 192.0.2.10 -j ACCEPT # 2. 其余所有访问MySQL 3306的流量全部丢弃 iptables -I DOCKER-USER 2 -p tcp --dport 3306 -j DROP # 3. 封禁单个攻击IP对web容器80端口的访问 iptables -I DOCKER-USER 3 -p tcp --dport 80 -s 198.51.100.7 -j DROP # 4. 封禁整个扫描段对web容器80端口的访问 iptables -I DOCKER-USER 4 -p tcp --dport 80 -s 100.64.0.0/10 -j DROP执行完查看链内容iptables -L DOCKER-USER -n -v --line-numbers能看到类似Chain DOCKER-USER (1 references) num pkts bytes target prot opt in out source destination 1 0 0 ACCEPT tcp -- * * 192.0.2.10 0.0.0.0/0 tcp dpt:3306 2 0 0 DROP tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:3306 3 0 0 DROP tcp -- * * 198.51.100.7 0.0.0.0/0 tcp dpt:80 4 0 0 DROP tcp -- * * 100.64.0.0/10 0.0.0.0/0 tcp dpt:80 5 0 0 RETURN all -- * * 0.0.0.0/0 0.0.0.0/0验证环节不要只看iptables列表要实际从客户端发包测试。从被封锁的IP测试访问Nginxcurl -I http://宿主机IP:8080理想结果是超时或连接被重置。从白名单IP测试MySQLmysql -h 宿主机IP -P 3306 -u root -p理想结果是可以正常登录。然后再检查规则计数器看是否真的有数据包命中iptables -L DOCKER-USER -n -v如果DROP那几条的pkts一直为0说明流量没走到这个链或者端口匹配有问题就要回到第3、4节提到的路径分析上去排查。对于封禁段这种“一眼望不到头”的大量IP推荐配合前面说的ipset。这里给出改造版本# 建集合 ipset create docker_block hash:ip timeout 0 # DOCKER-USER里加一条集合匹配规则 iptables -I DOCKER-USER 1 -m set --match-set docker_block src -j DROP # 动态加IP ipset add docker_block 198.51.100.7 ipset add docker_block 203.0.113.66这套做法在攻击IP频繁变化时特别省事。日志系统直接对接ipset不用反复处理iptables规则也不会因为某个IP写错导致整条规则语法报错。最后把规则持久化落地。我用的是iptables-persistent加自定义systemd服务组合apt install -y iptables-persistent netfilter-persistent save然后编写/usr/local/bin/apply-docker-iptables.sh把上述所有iptables和ipset命令放进去再创建一个Afterdocker.service的oneshot服务。这样每次机器或Docker重启规则都会自动重新应用。个人经验里还有一个重要提醒如果你在Docker宿主机上开启了firewalld或ufw这类上层防火墙管理工具它们可能会和Docker的iptables规则互相覆盖产生“明明封了还能访问”或者“容器启动失败”的怪问题。容器环境下我建议把网络过滤统一收敛到iptables原生规则不要让多个工具同时管理同一张表。否则排障的时候你永远不知道是哪一层把规则改了。Docker环境下的IP封禁说白了就三步看数据包走哪条链把规则写到对应的链里再把规则固化下来。能做到这三步大部分“封了没效果”的问题都能直接解决。