调试 docker 路由行为

调试 docker 路由行为

我在用ufw在我的主机系统上设置防火墙。ufw在某些情况下,当与 docker 结合使用时,这似乎可以让我绕过某些规则。

我知道 docker 默认会直接修改 iptables,这会导致某些问题,尤其是ufw,但我遇到了一个对我来说似乎很奇怪的问题。

以下是我所做工作的细目。

  1. 我想拒绝所有传入流量:
sudo ufw default deny incoming
  1. 我想要允许 ssh:
sudo ufw allow ssh
  1. 我希望允许从我的主机系统传回主机系统的端口8181(上下文:稍后将使用它来构建到我的主机的 ssh 隧道并8181从任何地方访问端口 - 但目前并不重要
sudo ufw allow from 127.0.0.1 to 127.0.0.1 port 8181
  1. 我启用了防火墙设置:
sudo ufw enable

如果我现在通过它查看防火墙状态,sudo ufw status则会显示以下内容:

Status: active

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW       Anywhere                  
127.0.0.1 8181             ALLOW       127.0.0.1                 
22/tcp (v6)                ALLOW       Anywhere (v6)

我觉得这很好,但现在出现了奇怪的部分。我有一个 API,它在内部docker端口可用的容器内运行8080

如果我现在使用以下命令运行 docker 容器并将端口映射到主机系统上的8080端口8181

docker run -d --name ufw_test -p 8181:8080 mytest/web-app:latest

它似乎绕过了我之前设置的仅允许从127.0.0.1127.0.0.1端口的流量的规则8181。我可以从任何地方访问我的 API。我尝试使用同一网络上的不同 PC,我的 API 可以通过192.168.178.20:8181另一台 PC 访问。我想,解决这个问题的方法是像这样启动我的容器:

docker run -d --name ufw_test -p 127.0.0.1:8181:8080 mytest/web-app:latest

这会以我预期的方式限制对我的 API 的访问,但我想知道,为什么第二个命令有效,而第一个命令无效?

答案1

ufw仅显示 ufw 配置,并且iptables不会显示直接插入防火墙配置中的任何规则(直接使用或其他工具,如 docker),而不需要通过 ufw。

Linux 中的防火墙规则按列出的顺序应用。当你启动 docker 容器时docker 将在现有规则之前插入 docker 容器所需的规则以及您使用 ufw 维护的规则集。

换句话说,Docker 公开端口的优先级高于后续关闭特定端口的 ufw 规则。

例如,检查[sudo] iptables-save 你的有效规则集是什么。

为什么-p 127.0.0.1:8181:8080工作方式不同?
docker 创建的防火墙规则仍然优先于你的 ufw 规则,但现在你不再向所有接口(包括公众)公开端口,而是指示 docker 采取更严格的限制措施,只公开端口localhost

答案2

调试 docker 路由行为

让我们详细分析一下为什么会发生这种情况。

my_default在这个例子中,我们有两个名为和的 docker 网络my_monitoring(将它们放在一边,因为它们在mydocker-compose 项目中):

# docker network ls
NETWORK ID     NAME            DRIVER    SCOPE
2ad61e302639   host            host      local
d383fea61ebd   my_default      bridge    local
629af1b7e10d   my_monitoring   bridge    local

现在让我们检查桥接设备:

# ip link show type bridge
3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN mode DEFAULT group default 
    link/ether 02:42:24:3d:80:be brd ff:ff:ff:ff:ff:ff
4: br-629af1b7e10d: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default 
    link/ether 02:42:24:e7:96:7d brd ff:ff:ff:ff:ff:ff
5: br-d383fea61ebd: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default 
    link/ether 02:42:00:08:62:04 brd ff:ff:ff:ff:ff:ff

我们可以看到,对于我们的两个网络,有一个桥接设备(可以通过分享后面的 id 来发现br-)。

在这种情况下,docker0网桥已关闭,可能是因为默认 docker 网络中没有任何容器。我们将忽略它,但它的行为可能与任何其他 docker 创建的网桥一样。

让我们集中讨论其中一座桥,比如说monitoring那座629af1b7e10d

# ip link show master br-629af1b7e10d
9: veth6f9b96e@if8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master br-629af1b7e10d state UP mode DEFAULT group default 
    link/ether 66:d2:79:4f:f8:a4 brd ff:ff:ff:ff:ff:ff link-netnsid 0
11: veth101dbe8@if10: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master br-629af1b7e10d state UP mode DEFAULT group default 
    link/ether a6:c4:b9:34:ab:31 brd ff:ff:ff:ff:ff:ff link-netnsid 2
90: vethf92d1d7@if89: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master br-629af1b7e10d state UP mode DEFAULT group default 
    link/ether 6a:3b:30:70:e3:8a brd ff:ff:ff:ff:ff:ff link-netnsid 36

我们可以看到网桥绑定了三个虚拟以太网设备,它们对应于三个暴露某些端口的docker容器,不管它是否在主机上绑定这些端口(暴露是在docker网络内,端口绑定ports是在主机上还是在它的指定IP上)。

車輛改道:桥到底是什么?https://wiki.archlinux.org/title/network_bridge给出了很好的见解。从主机的角度来看,网桥看起来像一个以太网设备,充当连接到它的容器的网关。这解释了为什么网桥接口有一个附加的 IP 地址(见https://unix.stackexchange.com/a/319984/20146):

# ip addr show type bridge
...
4: br-629af1b7e10d: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether 02:42:24:e7:96:7d brd ff:ff:ff:ff:ff:ff
    inet 172.19.0.1/16 brd 172.19.255.255 scope global br-629af1b7e10d
       valid_lft forever preferred_lft forever
...

如果我们查看所连接的其中一个容器,我们会看到它的设备有一个来自该子网的 IP 地址172.19.0.0/16

...
10: eth0@if11: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:ac:13:00:03 brd ff:ff:ff:ff:ff:ff
    inet 172.19.0.3/16 brd 172.19.255.255 scope global eth0
       valid_lft forever preferred_lft forever

車輛改道:除了执行容器中的命令外,我们还可以以另一种方式执行网络命令,就像我们在容器的网络命名空间中一样。如果容器没有安装网络工具,这将非常有用。请参阅https://stackoverflow.com/a/52287652/180258/var/link/netns那么如何链接

# get the network namespace of a container on our monitoring docker net, first 12 chars only for some reason
docker inspect aa7bc3710d32 --format '{{range }} {{.NetworkSettings.SandboxID}} {{end}}' | cut -b-12
# ip netns exec 1aff47345e7f ip link
...
0: eth0@if11: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default 
    link/ether 02:42:ac:13:00:03 brd ff:ff:ff:ff:ff:ff link-netnsid 0

或者同样# nsenter --net=/var/run/netns/1aff47345e7f ip addr,没有命令参数就会给你一个具有该命名空间的 shell(并且也可以用来同时进入其他命名空间),但我将在这里停止沉思。

一旦我们能够进入容器的网络命名空间,为什么不快速检查它如何绑定其EXPOSE端口(这里是暴露给监控网络的node-exporter服务)?9100

nsenter执行后,我们ss -nl4p发现……几乎什么都没有,或者不是我们所期望的(注意:ss这是您执行的现代方法netstat)。ss -nlp但是,如果没有 IPv4 过滤器,则会*:9100按预期列出端口。很奇怪。我们执行一下ss -nl6p,我们看到了[::ffff:172.19.0.2]:9100,这是……一个非常奇怪的地址。读起来,它是“嵌入在 IPv6 空间中的 IPv4 地址”。现在,我不想知道更多,让我们假装它正在监听常规 IPv4 地址(它不是,但是……)。

流量如何从这个网络命名空间路由出去?

# ip route
default via 172.19.0.1 dev eth0 
172.19.0.0/16 dev eth0 proto kernel scope link src 172.19.0.2

我们正在与谁沟通?

# ip neigh
172.19.0.4 dev eth0 lladdr 02:42:ac:13:00:04 REACHABLE

这显示了另一个容器(实际上是一个prometheus抓取的实例node-exporter)。从这个方向看一切都很好。

现在,回到我们最初的问题,主机流量(或主机外流量)如何路由到容器?更具体地说,路由到PORTS-bound 发布的端口(expose-d 端口仅在 docker 网络内可见)。

让我们找到一个已发布端口的容器。nginx容器有ports: 443:9001,因此其本地公开端口9001应绑定到端口上的主机的所有地址443nsenter对其进行 -ing 后,我们现在找到一个0.0.0.0:9001绑定的侦听器(现在真正绑定到 IPv4)。但是部分在哪里443

离开网络命名空间,并ss -nlp | grep 443在主机上执行操作,会发现正在docker-proxy监听0.0.0.0:443。事实上,如果我们检查:

# systemctl status docker
    CGroup: /system.slice/docker.service
             ├─65555 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
             ...
             └─68015 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 443 -container-ip 172.18.0.23 -container-port 9001

我们发现它是一个针对我们容器的 docker 管理代理。好的,对于我们的目的来说,这部分我们已经很满意了(不要问 docker 网络驱动程序是如何工作的)。注意:从下面来看,这个 docker-proxy 可能只是一个转移注意力的幌子……还是不是?

所以现在我们知道我们必须问,docker 如何安排iptables目标端口的规则443

iptables-save | grep 443揭露

*nat
...
:DOCKER - [0:0]
...
-A DOCKER ! -i br-d383fea61ebd -p tcp -m tcp --dport 443 -j DNAT --to-destination 172.18.0.23:9001

因此,就有了这个nat表链DOCKER规则,其中来自不在my_defaultdocker 网络内且以端口 443 为目标的流量将直接路由到容器目的地。简洁。(这也意味着 docker-proxy 可能不会在这条路径上发挥作用,但也许在该网络内部发挥作用)。

好的,数据包如何到达这个链和规则?

*nat
...
:DOCKER - [0:0]
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER

哦,所以这只会在发往本地地址的数据包上触发。所以这是一条死路。让我们进一步研究。

回到我们最初的嫌疑人docker-proxy。但我们手头没有证据,有点累,所以在https://serverfault.com/a/126079/47611https://serverfault.com/a/1113788/47611接下来,我们来获取一些 iptables 规则跟踪:

iptables -t raw -A PREROUTING -p tcp -d <hostip> --dport 443 -j TRACE
xtables-monitor --trace

我们看到

PACKET: 2 036c1093 IN=eno1 OUT=br-d383fea61ebd

没问题,但具体为什么呢?也许是 conntrack 干扰了事情,让我们从头开始。停止 docker 容器,启动跟踪,重新启动容器。

现在,正如我们所希望的那样,我们得到了不同的跟踪信息。首先,是容器停止时的跟踪信息:

PACKET: 2 842d387a IN=eno1 MACSRC=94:f7:ad:4f:81:84 MACDST=b4:2e:99:83:77:46 MACPROTO=0800 SRC=<srcip> DST=<hostip> LEN=60 TOS=0x0 TTL=56 ID=56110DF SPORT=17230 DPORT=443 SYN 
 TRACE: 2 842d387a nat:PREROUTING:rule:0x28:JUMP:DOCKER  -4 -t nat -A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
 TRACE: 2 842d387a nat:DOCKER:return:
 TRACE: 2 842d387a nat:PREROUTING:return:
 TRACE: 2 842d387a nat:PREROUTING:policy:ACCEPT 
PACKET: 2 842d387a IN=eno1 MACSRC=94:f7:ad:4f:81:84 MACDST=b4:2e:99:83:77:46 MACPROTO=0800 SRC=<srcip> DST=<hostip> LEN=60 TOS=0x0 TTL=56 ID=56110DF SPORT=17230 DPORT=443 SYN
 TRACE: 2 842d387a filter:INPUT:rule:0x14:JUMP:ufw-before-logging-input  -4 -t filter -A INPUT -j ufw-before-logging-input
... more ufw ...

立刻引起人们兴趣的是,我们看到数据包跳转到表DOCKER中的链nat,而我们之前认为它不会跳转。但事实并非如此。

那么当容器返回时,数据包被路由到它,这并不奇怪:

PACKET: 2 f92d752e IN=eno1 MACSRC=94:f7:ad:4f:81:84 MACDST=b4:2e:99:83:77:46 MACPROTO=0800 SRC=<srcip> DST=<hostip> LEN=60 TOS=0x0 TTL=56 ID=31830DF SPORT=57694 DPORT=443 SYN
 TRACE: 2 f92d752e raw:PREROUTING:rule:0xd:CONTINUE  -4 -t raw -A PREROUTING -d <hostip>/32 -p tcp -m tcp --dport 443 -j TRACE
 TRACE: 2 f92d752e raw:PREROUTING:return:
 TRACE: 2 f92d752e raw:PREROUTING:policy:ACCEPT
PACKET: 2 f92d752e IN=eno1 MACSRC=94:f7:ad:4f:81:84 MACDST=b4:2e:99:83:77:46 MACPROTO=0800 SRC=<srcip> DST=<hostip> LEN=60 TOS=0x0 TTL=56 ID=31830DF SPORT=57694 DPORT=443 SYN
 TRACE: 2 f92d752e nat:PREROUTING:rule:0x28:JUMP:DOCKER  -4 -t nat -A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
 TRACE: 2 f92d752e nat:DOCKER:rule:0x3b:ACCEPT  -4 -t nat -A DOCKER ! -i br-d383fea61ebd -p tcp -m tcp --dport 443 -j DNAT --to-destination 172.18.0.23:9001
PACKET: 2 f92d752e IN=eno1 OUT=br-d383fea61ebd MACSRC=94:f7:ad:4f:81:84 MACDST=b4:2e:99:83:77:46 MACPROTO=0800 SRC=<srcip> DST=172.18.0.23 LEN=60 TOS=0x0 TTL=55 ID=31830DF SPORT=57694 DPORT=9001 SYN
 TRACE: 2 f92d752e filter:FORWARD:rule:0xea:JUMP:DOCKER-USER  -4 -t filter -A FORWARD -j DOCKER-USER
 TRACE: 2 f92d752e filter:DOCKER-USER:return:
 TRACE: 2 f92d752e filter:FORWARD:rule:0xe7:JUMP:DOCKER-ISOLATION-STAGE-1  -4 -t filter -A FORWARD -j DOCKER-ISOLATION-STAGE-1
 TRACE: 2 f92d752e filter:DOCKER-ISOLATION-STAGE-1:return:
 TRACE: 2 f92d752e filter:FORWARD:rule:0xa8:JUMP:DOCKER  -4 -t filter -A FORWARD -o br-d383fea61ebd -j DOCKER
 TRACE: 2 f92d752e filter:DOCKER:rule:0xf1:ACCEPT  -4 -t filter -A DOCKER -d 172.18.0.23/32 ! -i br-d383fea61ebd -o br-d383fea61ebd -p tcp -m tcp --dport 9001 -j ACCEPT
 TRACE: 2 f92d752e nat:POSTROUTING:return:
 TRACE: 2 f92d752e nat:POSTROUTING:policy:ACCEPT

家庭练习检查非 SYN 数据包的情况,以及它如何根据 conntrack 略微不同地表现。

但是,回到问题。这个-m addrtype --dst-type LOCAL匹配器是什么?根据我们的经验,以及对https://unix.stackexchange.com/q/130807/20146,我们得出结论,这LOCAL是分配给该主机的任何东西,包括其公共地址。是的。

因此,总结一下,nat-table PREROUTING 规则将对数据包进行 DNAT 以将其发送到容器,因此数据包将进入正常的 FORWARD 链(因为它正在切换接口,对吧?)。数据包将在那里被定向到DOCKER链,并被接受。

UFWrouted规则(例如ufw route deny out on br-d383fea61ebd)是从 FORWARD 链中查询的,但仅在 docker 规则之后。所以太晚了。

https://docs.docker.com/network/packet-filtering-firewalls/建议使用DOCKER-USERFORWARD 规则链在 docker 规则之前执行。如果可以让 UFW 将其规则转储到该链而不是 FORWARD 链中,那就太好了。https://github.com/docker/for-linux/issues/690#issuecomment-499132578暗示其他人已经有了这个想法。现在我们不要再继续深究下去了。

与此同时,有效的方法是,如果您不想向主机公开已发布的端口,请明确使用绑定地址作为前缀,例如ports: - "127.0.0.1:9001:9001"。或者,根本不要发布。

相关内容