所有系统运行正常 6 个离岸区域 无 KYC 结账
Hands-on 实战指南

VPS 上的 Docker:它悄悄绕开的防火墙

第一小时你做得很到位:一个具名用户、只认密钥的 SSH、两个 IP 协议族上都默认拒绝、自动安全更新。然后你发布了一个容器端口,把一个数据库摆到了公网上——因为 Docker 会通过一条规则来投递那个端口,而这条规则早在你的防火墙被问到之前就已经生效,与此同时 ufw 还在不停报告说一切都是拒绝的。本指南要讲的就是这道缺口:它为什么存在、怎么看清你的容器眼下到底暴露了什么,以及那一小撮改动——它们能让你写的防火墙,真正意味着你原以为它意味着的东西。

更新于 2026-09-14 · 15 分钟阅读 · 服务器运维
本页内容
  1. 你写的规则,与 Docker 写的规则
  2. 从外面看,一个暴露的端口是什么样子
  3. 看清你到底发布了什么
  4. 回环绑定,以及 ports 与 expose 的区别
  5. DOCKER-USER:专为此而生的链
  6. 另一扇门:套接字,以及什么在以 root 身份运行
  7. 不是你写的镜像,跑在一台你输不起的机器上
  8. 日志、卷,以及比容器活得更久的状态
  9. 分步操作
SP·01

你写的规则,与 Docker 写的规则

有两个程序在用不同的假设,编辑着同一套防火墙规则,而只有其中一个会告诉你它做了什么。ufw 把自己的规则写进 filter 表的 INPUT 链——这是一个数据包发往主机本身时会走的路径。Docker 则写进 nat 表,以及 FORWARD 链——这是一个数据包发往其他地方时会走的路径。

顺着单个数据包走一遍,这道缺口就一目了然了。某个身处另一个国家的人,向你地址上的 5432 端口发起一个连接。数据包抵达后,最先遇到的是 nat PREROUTING,它把数据包送进 Docker 的 DOCKER 链。在那里,一条 DNAT 规则把目的地址改写成 172.17.0.2:5432——也就是那个容器。这个数据包已经不再是发给你服务器本身的了,所以内核会转发它,而不是把它投递到本机:它会经过 FORWARD 链,Docker 早已在那里为发往已发布端口的流量装好了一条 ACCEPT 规则。在这整段旅程里,这个数据包没有一刻经过 INPUT——而那才是 ufw 唯一在过滤的链。你的规则从来没有错。它只是从来没有被问过。

这也是为什么这个症状会如此让人摸不着头脑。ufw status verbose 一直报告着 Default: deny (incoming),除了 80、443 和你的 SSH 端口之外什么都不放行,与此同时,另一个大陆上的扫描器却正握着一个通向你数据库的开放会话。这两个说法都是真的。防火墙确实在照你配置的那样工作,处理着分给它的那部分流量——而真正要紧的那部分流量,根本没有分给它。

这些都不是什么 bug,也不是 Docker 粗心大意。一个容器引擎必须编写 NAT 和转发规则,否则容器根本没法触达网络,而它又没法安全地猜出,你主机防火墙里的哪些规则原本是想套用在容器身上的。所以它做了件老实事:只管好自己的那些链,同时交给你一条专属的链——DOCKER-USER,在 FORWARD 里排在其他一切规则之前被评估——并承诺绝不会覆盖这条链的内容。缺口不在于这套机制本身的存在。缺口在于 -p 5432:5432 读起来像是"让它可以被访问到",而它的真实意思却是"把这个发布到这台机器所应答的每一个地址上,绕过你花了一个小时写好的防火墙"。

SP·02

从外面看,一个暴露的端口是什么样子

互联网发现问题的速度,比大多数人预期的要快。托管服务商的地址段会被持续、地毯式地扫描——干这件事的不是哪个特意盯上你的人,而是商业爬虫、研究项目,以及见机行事的僵尸网络:它们扫过每一个可路由地址上的每一个有意思的端口,然后把结果发布出来或者卖掉。从 docker compose up -d 到第一个不请自来的连接找上刚刚发布的数据库端口,间隔时间以小时计。没有人需要猜你的主机名。没有人需要知道你的名字。你的地址就在被扫描的范围之内。

他们能找到什么,完全取决于你发布了什么,而常见的情况都不太好看。一个从镜像快速上手示例里起来的 PostgreSQL 或 MySQL 容器,示例里那个敷衍了事的密码原封不动地留在那里。一个从来没配置过要求身份验证的 Elasticsearch 或 MongoDB 实例,因为当初设想它只会被应用容器访问到。一个来者不拒、谁都能连上的 memcached——这不仅是数据泄露,还是一个可以被人拿去对准别人的放大器,把你的服务器变成我们DDoS 攻击第一小时应对手册里描述的那种洪泛攻击的参与者。一个原本假设自己处在私有网络里的管理面板或消息队列看板。一个悄悄列出你运行的每一项内部服务、主机名和版本号的指标端点。

有必要把第二层伤害也说清楚,因为第一层伤害并不是账单的全部。一个可以从互联网访问到的数据库,不只是可读——在大多数引擎里,它同样可写,这意味着入侵者不需要再找一个漏洞就能植入持久化后门,而有几款引擎,从一个具备特权的会话里,还能被哄骗着去写文件、在主机上执行命令。在一台你当初选中正是因为它不带任何身份信息的机器上,一个未经身份验证的立足点,同样也是通向这台机器所能触及的一切的一条后路:它够得到的备份目标、它环境变量里的密钥、它所在网桥上的其他容器。

让人不安的地方在于,这一切都不会自己发出声响。不会有哪一行日志写着"你的防火墙被绕过了"。服务照常运作,应用照常连接,网站照常在线,唯一的外部信号是一个没有人在盯着的连接数。这次暴露,要么被你自己在接下来的十分钟里主动发现,要么被别人,按他们自己的时间表发现。

SP·03

看清你到底发布了什么

先看引擎自己以为在做什么。docker ps 打印出一列 PORTS,这一列里的区别正是本指南通篇要讲的东西:0.0.0.0:5432->5432/tcp 意味着机器上的每一个地址,127.0.0.1:5432->5432/tcp 意味着仅限回环地址,而一个没有箭头、光秃秃的 5432/tcp 意味着这个端口只对其他容器暴露,没有对外发布。在动手改任何东西之前,把每个容器这一列里的每一行都读一遍。

接着用 ss -tulpen 看一眼套接字。在默认安装下,你会看到 docker-proxy 占着已发布的端口,因为 Docker 至今仍然会为每一个已发布端口起一个小小的用户态进程。这里有一个能让人白折腾一下午的坑:如果你的守护进程上关掉了 userland-proxy——这是个常见的调优改动,有些发行版甚至默认就是这么配的——主机上就根本不会有监听套接字。ss 什么都看不到,lsof 也什么都看不到,可端口依然大开着,因为内核的 DNAT 规则自己就把活干了,不需要任何进程占住那个地址。一个安安静静的 ss 输出,并不能证明端口是关着的。

所以还是得看规则本身。iptables -t nat -S DOCKER 会为每一个已发布端口列出一行 DNAT 规则,每一行都写着你想要的答案:带 -d 127.0.0.1/32 的规则是回环发布,没有目的地址限制的规则则适用于这台机器持有的每一个地址。对 ip6tables 也做一遍同样的事,因为这两个协议族是各自独立配置的,一台机器完全可能一边收得很紧,另一边却大敞着门。

最后一步——也是唯一真正能证明点什么的一步——从别的地方看这台机器。以上所有命令都是在主机本身上跑的,继承的是主机自己对其网络的那一套认知。回环流量会绕开那些真正要紧的链,所以 curl 127.0.0.1:5432 成功了,并不能说明陌生人是否也能做到同样的事;它失败了,能说明的就更少。真正有说服力的测试,是从另一台机器、在另一个网络上,针对两个 IP 协议族分别发起的扫描。在那之前的一切,都只是个假设。

SP·04

回环绑定,以及 ports 与 expose 的区别

最小的有效修复,只有十一个字符。-p 127.0.0.1:5432:5432 告诉 Docker,写 DNAT 规则的时候带上一个目的地址限制,这样改写就只会套用在原本就已经是本地的流量上。一个冲着你公网地址来的远程数据包,从此不再匹配这条规则,不会被转发进容器,而是终于抵达了你一直以为它会去的地方:INPUT,在那里被 ufw 挡下。在 Compose 文件里,同样的事情写作 ports: ["127.0.0.1:5432:5432"],而引号在这里很关键——一个带冒号却没加引号的值,迟早会闹出一次解析事故。

但更值得问的问题是,这个端口究竟为什么要被发布出来。挂在同一个用户自定义网络上的容器,彼此之间可以直接通过服务名、用服务自己的端口互相触达,完全不需要任何形式的发布。你的应用不会去连 127.0.0.1:5432;它连的是 postgres:5432,由 Docker 内置的 DNS 解析到私有网桥上的一个地址。处在这种安排里的数据库,根本不需要 ports: 这一行——不需要回环那种写法,什么写法都不需要。最安全的已发布端口,就是那个被你删掉的端口。ports: 留给那一两个真正要面向公网的服务,其余的一切都放到私有网络上去交谈。

这里也正是 expose: 常常被误读的地方。它什么都不发布,也什么都不开放;它只是一份记录着某个服务监听哪个端口的文档说明,对防火墙不产生任何方向上的影响。人们添上它,是希望它是 ports: 的安全版本——从某种意义上说确实是,就像一段注释是代码的安全版本那样。如果你想要一个服务只能被它的邻居触达,你需要的不是 expose:;你需要的是压根不写 ports:

回环绑定有两条诚实的局限。第一,它守住的是主机这个边界,而不是邻里之间:同一个网桥网络上的容器,彼此依然畅通无阻,所以一个被攻陷的前端容器,通往一个什么都没发布的数据库,仍然有一条现成的路。当爆炸半径真的要紧时,把服务拆到不同的网络上,并把后端那个标成 internal: true。第二,127.0.0.1 是一个 IPv4 地址,只能约束 IPv4;如果这台主机有一个可路由的 /64——这里的每个套餐都自带一个——就要把 v6 单独拿出来想清楚,也要单独测试。

SP·05

DOCKER-USER:专为此而生的链

回环绑定,修好的是你还记得住的那些容器。DOCKER-USER 才是让你不再一个 -p 之差就撞上下一次事故的办法。Docker 把它装成 FORWARD 里最先被跳转到的一环,排在自己那些放行规则之前,而且——和它其他的链不一样——不管重启、升级还是新建容器,都不会去动这条链里的内容。这是它留给你的、用来放那些引擎自己推断不出的策略的官方位置:到底哪些来源,才被允许触达这台主机上的容器

这套模式是公网接口上的三条规则,而顺序就是全部关键所在。第一条,放行已建立和相关的流量,这样你容器发起的连接,回包才能继续畅通。第二条,放行你真正想放进来的那些来源——一个办公室地址、一台监控主机、一台对等服务器。第三条,把其余一切从互联网上进来的流量统统丢弃。把 conntrack 规则弄错,是这里最经典的翻车方式:要是把 DROP 放在最前面,每一个容器发起的出站连接都会死在返回包上,表现出来就是"Docker 把 DNS 和软件包安装搞坏了",把人引到完全错误的方向去排查。

有两个操作层面的细节,决定了这套规则能不能扛住现实的考验。它必须按名字限定在公网接口上——-i eth0,或者 ip route get 1.1.1.1 在你这台机器上报出来的那个名字——否则你会连自己网桥之间的流量都一并丢掉。而且它必须每次重启之后都重新应用一遍,并且要在 Docker 守护进程建好这条链之后才应用。一个 oneshot systemd 单元、顺序设为 After=docker.service,是靠得住的做法;用 netfilter-persistent 保存下来也可以,前提是你得接受这样一个事实:恢复一整套规则,而 Docker 同时又在重建它自己那一套,这是一场你应该去验证、而不是想当然认定结果的竞速。

接下来是 IPv6 的那一半。如果 ip6tables -S DOCKER-USER 打印出了一条链,就把每一条规则原样镜像过去。如果它报错,说明你的守护进程压根没在管理 v6 规则——这说明不了 v6 流量能不能到达你的容器,只说明 Docker 没有在过滤它。这里不要靠推理去猜答案;不同版本、不同守护进程设置、不同发行版之间,路径都不一样,一个自信满满的错误结论,比没有结论更糟。用 nmap -6 扫描你自己,然后相信这个结果。

有一件事不要做:在 /etc/docker/daemon.json 里伸手去改 "iptables": false。这会让 Docker 不再碰防火墙,但同时也会让容器的 NAT、出站伪装和网络间隔离统统失去配置的对象。你并没有消除这个问题,你只是把这份工作继承了下来——手动地,为每一个容器,永远地。在一台单独的 VPS 上,诚实的选择是让 Docker 继续管好自己的链,而你负责管好 DOCKER-USER

SP·06

另一扇门:套接字,以及什么在以 root 身份运行

前面说的都是数据包如何抵达。这一节要说的是它们抵达之后会发现什么,其中有一项比其余所有加在一起都更糟,值得单独拎出来讲清楚:/var/run/docker.sock 挂进一个容器,等同于把这台主机的 root 权限交给了那个容器。不是"接近于"。是完全等同。任何能跟这个套接字对上话的东西,都能起一个新的特权容器,把主机的整个文件系统挂进去,然后从那里读走每一把密钥、改写每一个文件、装上它想装的任何东西。不少图省事的镜像都会要求挂载它——仪表盘、自动更新工具、带服务发现的反向代理。把这种请求当成一个决定来看待:你愿不愿意像信任自己的 root shell 一样信任这个镜像;对通过 TCP 暴露的 Docker API 也要一样小心——2375 端口上一个未经身份验证的守护进程,是同一扇门,只不过对着整个互联网敞开着。

除此之外,容器运行时还给了你四种成本很低的收缩面,一个都不需要你重新设计架构。用 user: "1000:1000" 以非 root 用户身份运行,因为命名空间内部默认就是 root,而这正是每一次越狱的起点。设置 security_opt: ["no-new-privileges:true"],让镜像里的某个 setuid 二进制文件永远没法拿到比启动它的那个进程更多的权限。用 cap_drop: [ALL] 丢掉全部 capabilities,只把服务确凿需要的那几项加回来——大多数 Web 应用其实一项都不需要。把根文件系统挂成 read_only: true,再给它配一小块 tmpfs 当草稿空间,这一下就把"在应用目录里丢一个 Web shell"从一个可行步骤变成了死路一条。

同样的逻辑也适用于交到容器手上的东西。以环境变量形式传入的密钥,对任何能读到进程环境的东西都是可见的,而且会被原样复制进 docker inspect 的输出,以及任何会把配置整个吐出来的日志或崩溃报告里;换成一个以只读方式挂载在固定路径上的文件,没那么方便,但泄漏面也小得多。而且一个容器需要的网络权限,不该超过它的工作本身:一个只跟数据库打交道的工作进程,根本没理由能对互联网发起连接。

如果这一切读起来像是在跟默认设置对着干,这个感受很公道,而这也正是无 root Docker 或者 Podman 的立论所在——在那种模式下,守护进程和容器都以一个非特权用户的身份运行,容器逃逸只会把你带到那个普通用户的身份,而不是 root,已发布端口也是由主机上一个普通的用户态进程占着,这意味着你的 ufw 规则能像平常那样套用在它们身上。这笔交易是真实存在的:一些 capabilities、一些存储驱动、一些网络花招,要么表现不一样,要么干脆用不了。值得先知道这个选项存在、它能换来什么,而不是等出了事之后才发现,默认设置本身也是一种选择。

SP·07

不是你写的镜像,跑在一台你输不起的机器上

一个容器镜像,就是别人的文件系统,跑在你的机器上,由你从没读过的那些层拼装而成。这不是反对使用它们的理由——而是提醒你,得弄清楚自己在跑的是哪些镜像,它们又有多旧了。在小型基础设施上真正会发生的两种失败,说穿了都很平常:要么这个镜像从来就不值得信任,要么它三月份的时候还值得信任,只是后来再没有人重新构建过它。

第一种失败,主要靠对镜像来源的自律来解决。优先选择官方或供应商发布的仓库,而不是随手找一个只有三颗星的分支;尤其要提防那些卖点就是把六个服务塞进一行 YAML 里的镜像。在真正要紧的地方,按摘要而不是按标签锁定版本:postgres:17 是个会动的靶子,两次 docker compose pull 之间就可能在你脚下悄悄变掉,而 postgres@sha256:… 才是你测试过的那个确切文件系统。锁定版本,是用自动获得修复换来可重现性,如果你有重新构建的习惯,这笔交易是对的;如果没有,这笔交易就是错的。

第二种失败,咬起人来悄无声息。无人值守升级碰不到你的容器。你在第一小时里配置好的那套自动安全更新,打的是主机自身软件包的补丁,看不到镜像内部的用户态世界——所以一台自称已经完全打好补丁的机器,跑的 Web 服务器背后那个基础镜像,完全可能带着一年都没修的漏洞。容器不是被更新的,而是被替换的:拉取、重建、把不再被引用的东西清掉,按一个你真正能坚持下来的节奏去做。写下来的每月一次,胜过一个完美的打算。

这就让 Compose 文件成了这台服务器上最有价值的一个对象。它是这台机器是什么这件事唯一完整的描述,从它重建应该是一项日常操作,而不是一场考古项目——把它放进版本控制,把环境变量文件跟它放在一起,再把两者一起放进保存着你数据的那份异地加密备份里。检验一台容器主机的标准,不是它是不是正在运行,而是你能不能在一台全新的 VPS 上,原样把它重新搭起来,大概只需要 15 min。

SP·08

日志、卷,以及比容器活得更久的状态

容器被宣传成可以随意丢弃的东西,这话对进程本身是对的,但对它留下的一切都是错的。一台 Docker 主机上会积累两种状态,而这两种状态,都会在最糟糕的时刻让人措手不及。

第一种是日志。默认的 json-file 驱动,会把你的容器写往 stdout 和 stderr 的每一行都记录下来,而且——除非你另外交代过——它从来不会做日志轮转。一个话多的应用,靠这个方式就能在几周之内把一块磁盘写满,而一台数据库主机上磁盘写满,本身就是一次事故。在 /etc/docker/daemon.json 里设一次上限,这样它就会套用到你之后启动的每一个容器上。

同一个设置也有一层隐私上的解读,而在一台你正是为了隐私才选中的服务器上,这一层解读恐怕才是更要紧的那个。一份从不轮转的访问日志,是每一个访客的 IP 地址、User-Agent 和请求路径的永久、未加索引的记录,躺在一块你并不实际掌控的磁盘上。这不是你决定要保留的东西。是默认设置替你做了这个决定。刻意去做这个决定,意味着给保留期限设一个上限,而更好的做法是,从源头上就少写一点:在反向代理这一层,用一种省略或截断客户端地址的日志格式,能保住运维价值,同时甩掉责任负担。你从没写下的数据,没法被查封、传唤或泄露——这跟促使人们从一开始就不让服务器留下你的名字的道理,其实是同一套道理。

第二种是卷,而这里的锋利之处在于一个参数。docker compose down 会停止并删除容器,但不会碰命名卷;docker compose down -v 则会带着同样的干脆劲儿,把它们永久删掉。数据库就活在那些卷里。你会想念的其他一切东西,多半也是。把数据放进命名卷,而不是匿名卷;搞清楚凌晨两点你正敲下的到底是哪条命令;还要记得,docker system prune 存在的意义就是回收空间,它很乐意把你的空间也一并回收掉。

剩下的就是备份本身,而容器这层抽象在这里最后再误导你一次。直接拷贝一个正在运行的数据库的卷目录,算不上备份;那只是一份在你读取的同时还在被写入的文件拷贝,而它在你最需要的那一刻,会以一个损坏的数据库的样子被还原出来。要通过引擎本身来备份——pg_dumpmysqldump,或者引擎自带的快照 API——然后把结果加密,送到一个你的服务器够不着的地方。最后这一句,在入侵者已经身处容器内部时才真正见分晓:一份连你被攻陷的主机都能删掉的备份,根本算不上备份。

SP·09

分步操作

  1. 01

    盘点这台机器眼下到底发布了什么

    在改动任何东西之前,先把当前状态记录下来——之后你会需要拿它来做对比。把每个容器的 PORTS 列都读一遍,再看看 Docker 实际装了哪些 NAT 规则,两个 IP 协议族都要看。凡是打印出来时没有带 127.0.0.1 这个目的地址的,不管 ufw status 怎么说,都是能从互联网上连到的。

    docker ps --format 'table {{.Names}}\t{{.Ports}}'
    ss -tulpen | grep -E 'docker|LISTEN'
    sudo iptables  -t nat -S DOCKER      # -d 127.0.0.1/32 = loopback only
    sudo ip6tables -t nat -S DOCKER      # may not exist; that is an answer too
    sudo ufw status verbose              # what you believed was true

    顺手把公网接口的名字也记下来——后面写防火墙规则时用得着,而且并不是每个镜像上它都叫 eth0

    ip route get 1.1.1.1 | awk '{print $5; exit}'
  2. 02

    从别处扫描你自己

    主机没法审计它自己:回环流量根本不会经过你正想测试的那些链。要从另一台机器、在另一个网络上运行这些命令——另一个地区、一台 $8.00/月的 VPS,就是最老实的测试台,用完之后它也还派得上其他用场。两个协议族都要扫,因为它们是分开配置的。

    # from ANOTHER machine, against your server
    nmap -Pn -sS -p- --min-rate 1000 203.0.113.10
    nmap -Pn -6 -p 22,80,443,3306,5432,6379,9200,27017 2001:db8::1
    # no nmap? one port at a time is enough to settle an argument
    nc -zv 203.0.113.10 5432

    凡是不属于 80、443 或者你 SSH 端口的开放端口,都是一项发现。把这份输出留着;它就是你最后想拿出来的那份证据里,"之前"的那一半。

  3. 03

    不该公开的,就别再发布

    现在从源头上修,也就是在 Compose 文件里动手。后端服务搬到一个私有网络上,彻底去掉它们的 ports: 这一行——应用通过服务名去找它们。任何必须能从主机本身访问到的东西,改成一个明确的回环绑定,而不是一个光秃秃的端口。把后端网络标成 internal,这样上面的任何东西都不能未经允许就跟互联网说上话。

    services:
      db:
        image: postgres:17
        # ports: ["5432:5432"]   # deleted: the app reaches it as db:5432
        networks: [back]
        volumes: [dbdata:/var/lib/postgresql/data]
    
      app:
        image: myapp:1.4
        environment:
          DATABASE_URL: postgres://app@db:5432/app
        ports: ["127.0.0.1:8080:8080"]   # loopback only; proxy sits in front
        networks: [back, front]
    
    networks:
      front: {}
      back:
        internal: true
    
    volumes:
      dbdata: {}

    重新创建这套服务栈,确认 NAT 规则的样子变了——应用那一行 DNAT 规则现在应该带着一个回环目的地址,而数据库应该干脆一行都没有了。

    docker compose up -d
    docker ps --format 'table {{.Names}}\t{{.Ports}}'
    sudo iptables -t nat -S DOCKER
  4. 04

    在公网前面,只留一样东西

    在所有东西都改到回环之后,一个反向代理就成了唯一的公网门面——唯一终结 TLS 的地方,唯一能看到客户端地址的地方,也是唯一决定日志格式的地方。把它跑在主机上,或者放进一个只发布 80 和 443、别的什么都不发布的容器里;如果它跑在容器里,就让它加入 front 网络,代理到服务名,而不是代理到回环地址。

    # in the http{} block of /etc/nginx/nginx.conf — no client address recorded
    log_format privacy '- - [$time_local] "$request" $status $body_bytes_sent';
    
    # /etc/nginx/conf.d/app.conf  (proxy on the host)
    server {
        listen 443 ssl;
        listen [::]:443 ssl;
        server_name example.com;
    
        access_log /var/log/nginx/app.log privacy;
    
        location / {
            proxy_pass http://127.0.0.1:8080;
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }

    如果这么做的重点在于不让任何人知道这台机器在哪里,那这个代理就该整个放到另一台机器上——这正是把源 IP 隐藏在反向代理背后里讲的那套做法,而且它能和这里的一切组合起来用。

  5. 05

    用 DOCKER-USER 补上缺口,两个协议族都要

    回环绑定修的是你今天已经有的容器;这一步修的是你还没写出来的那些。公网接口上三条规则,conntrack 放在最前面,好让容器的出站流量继续畅通,然后是你允许的那些来源,最后是一条丢弃规则。应用之后马上测试一下容器的出站连通性——这正是人们最容易搞反的那条规则。

    PUB=$(ip route get 1.1.1.1 | awk '{print $5; exit}')
    
    sudo iptables -I DOCKER-USER 1 -i "$PUB" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
    sudo iptables -I DOCKER-USER 2 -i "$PUB" -s 203.0.113.7/32 -j RETURN   # your admin address
    sudo iptables -I DOCKER-USER 3 -i "$PUB" -j DROP
    
    sudo iptables -S DOCKER-USER
    docker run --rm alpine sh -c 'apk update' >/dev/null && echo 'egress OK'

    如果守护进程也在管理 v6 规则,就把这套规则原样镜像一份给 IPv6,然后用一个在 Docker 重建好它的链之后才运行的单元,让这一切能扛过重启。

    sudo ip6tables -S DOCKER-USER || echo 'no v6 chain: verify v6 reachability by scanning'
    
    # /etc/systemd/system/docker-user-rules.service
    [Unit]
    After=docker.service
    Requires=docker.service
    
    [Service]
    Type=oneshot
    ExecStart=/usr/local/sbin/docker-user-rules.sh
    RemainAfterExit=yes
    
    [Install]
    WantedBy=multi-user.target
  6. 06

    收回容器内部的权限,给日志设个上限

    缩小一个被攻陷的镜像能做的事,同时别让主机被一份没有人主动选择要留下的记录塞满。这四项容器设置,对一个普通的 Web 服务来说不花任何代价;日志上限则会套用到守护进程重新加载之后启动的每一个容器上。

    services:
      app:
        image: myapp:1.4
        user: "1000:1000"
        read_only: true
        tmpfs: [/tmp]
        cap_drop: [ALL]
        security_opt: ["no-new-privileges:true"]
        # never: volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
    # /etc/docker/daemon.json
    {
      "log-driver": "json-file",
      "log-opts": { "max-size": "10m", "max-file": "3" }
    }
    sudo systemctl reload docker
    docker inspect --format '{{.HostConfig.LogConfig}}' app
  7. 07

    从外部验证,再写进运维手册

    找个没什么东西依赖结果的时候,主动重启一次,证明你搭好的状态,就是重启回来之后的那个状态。然后把第二步里的外部扫描再跑一遍,和你保留的那份输出做个对比——真正的交付物是这份差异,不是那些命令本身。

    sudo reboot
    
    # after it returns:
    sudo iptables -S DOCKER-USER          # rules reapplied?
    docker ps --format 'table {{.Names}}\t{{.Ports}}'
    
    # from ANOTHER machine again:
    nmap -Pn -p- 203.0.113.10
    nmap -Pn -6 -p- 2001:db8::1

    找个你自己一定能找得到的地方,写下五行字:规则绑定在哪个接口上、那个 unit 文件放在哪里、哪些服务是故意公开的、镜像上一次重建是什么时候,以及怎么恢复那些卷。一台容器主机的好坏,取决于那份能让你在最糟的一天之后把它重建回来的说明,仅此而已。

SP·10 — 常见问题

快速解答

在一台跑着 Docker 的主机上,ufw 到底还有没有用?

有用,而且你应该留着它。它依然管着一切直接发给主机本身的流量——SSH、跑在 Docker 之外的代理、任何你用包管理器装的守护进程——而这占了攻击一台小型服务器的手段里的一大部分。它管不到的,是 Docker 用 DNAT 转发给容器的那部分流量,因为那部分流量是被转发的,而不是被投递到本机的,永远不会走到 ufw 过滤的那条链上。两个工具,两条路径:ufw 管主机,DOCKER-USER 管容器。

只绑定 127.0.0.1,这样单独就够了吗?

对那一个端口、那一个协议族、那一个容器来说,这样已经够了——而这正是它作为一种策略的弱点所在。它依赖以后每一次的 -p,都被每一个人、每一份复制粘贴来的片段正确地写对,而且它只能约束 IPv4。它在容器之间也不起任何作用:同一个网桥网络上的任何东西,依然能触达一个绑在回环地址上的邻居。把它当成默认要养成的习惯,再在它后面放上 DOCKER-USER,作为那条不依赖任何人记性的策略,等爆炸半径真的要紧时,再把网络拆开。

我可以直接设置 "iptables": false,自己来管防火墙吗?

可以,但在一台单独的 VPS 上,你几乎肯定不应该这么做。这个开关并不会给你一张白纸——它只是让 Docker 不再配置那些让容器网络得以运作的 NAT、伪装和隔离规则,于是你要为你此后创建的每一个容器、每一个网络,手动把这份工作接过来。结果通常要么是出站流量坏掉,要么是一套规则悄悄地跟实际跑着的东西对不上号。让守护进程管好它自己的链,你管好 DOCKER-USER——这是它特意留给你的那道接缝。

无 root 的 Docker 或者 Podman,能避开这个问题吗?

在很大程度上可以,而且是出于两个各自独立的原因。已发布的端口,由一个属于非特权用户的普通用户态进程占着,所以它们是作为发给主机的流量抵达的,你的 ufw 规则也就按你一直以为的那样生效。而且容器逃逸只会把攻击者带到那个非特权用户的身份,而不是 root。代价是有些 capabilities、存储驱动和网络安排,表现会不一样,或者干脆用不了,而且 1024 以下的端口需要额外配置。如果你正在搭一台新机器,跑的又是普通的 Web 服务,这是一个值得认真考虑的选项。如果你是在审计一套正在运行的系统,先把链修好,再单独考虑要不要迁移。

我的 VPS 部署在你们的网络过滤后面,这样还不够吗?

不够,而这里的区别值得说得精确一点。机群里的每一台机器,背后都有 1.5 Tbps 规模的持续防护兜底,用来吸收流量型攻击——但它读的是流量的形状,不是意图,它没法知道你发布的那个数据库端口,原本并不打算公开。对着一个暴露的 PostgreSQL 发起的一次格式规范的连接,在这一层上跟合法流量没有任何区别,因为就网络而言,它就是合法流量:是你自己发布了这个端口。上游的清洗,保住的是你服务器的可达性。而它上面什么东西可达,仍然是你自己配置的事。

我怎么知道它现在是真的关上了?

只有一种测试算数:在重启之后,从一台不属于你的机器、在一个不属于你的网络上,针对两个 IP 协议族分别发起连接。本地检查系统性地偏乐观——回环流量会绕开真正要看的那些链,而当用户态代理被关掉、内核在没有监听套接字的情况下自己做转发时,一个安安静静的 ss 输出什么都说明不了。扫描完整的端口范围,而不是只扫你预期中的那些端口,把输出留在运维手册旁边,并且在任何新增容器的改动之后,把它重新跑一遍。

付诸实践

VPS 在 15 min 内上线,独立服务器在 2–12 h 内交付。从 $30.00 起用加密货币充值——不绑定任何身份。

部署一台 VPS