关于运行在Docker内的Nginx获取不到真实用户IP这件事

本人的网站虽然很简单,但是还是把它 Docker 化了,为此专门写了一个 docker-compose.yml,把 Nginx 、评论系统和数据库的启动写了进去,现在运行起来十分稳定(评论系统出了问题挂掉了,Docker 还会帮我自动重启,美滋滋)(把数据库从 MySQL 迁移到 MariaDB 也十分顺滑,想当年我把 MySQL 卸载后再安装 MariaDB 竟然导致了 systemd 出问题,现在都不知道为什么)。

真实 IP 的获取

我的评论系统会记录访问用户的 IP,用来看着好玩,以及找出我自己的 IP 然后清空自己的浏览记录(瞬间变成 0,尴尬)。由于我的配置是评论系统前面用 nginx 做反代,那么评论系统直接收到的消息其来源是运行 nginx 这个容器的 IP。因此,我们需要后台服务程序和 nginx 相互配合,nginx 向后台服务程序提供客户端的 IP,后台服务程序读取这个 IP 并记录,即可。

我们先修改一下 nginx 的配置,原来的针对我评论系统的反向代理配置是这样的:

1
2
3
4
5
6
7
8
9
10
11
server {
listen 443 ssl http2;

server_name comments.moyufangge.com;

include /etc/nginx/ssl/ssl-common.conf;

location / {
proxy_pass http://moyufangge_commento:8080;
}
}

我的评论系统被运行在一个名字叫moyufangge_commento的容器内,这个是可以通过 docker-compose.yml 里的 container_name 字段来指定的。docker-compose 默认会为一个服务创建一个桥接网络,并通过这个网络实现容器名与容器运行 IP 之间的 DNS 解析,这样我们就可以通过容器名直接访问网络内该容器的服务,可以说非常方便,如果直接用 docker 命令来启动就达不到这么好的效果。

现在,在location /那里,加上一句:

1
2
3
4
location / {
proxy_pass http://moyufangge_commento:8080;
proxy_set_header X-Real-IP $remote_addr;
}

加上proxy_set_header X-Real-IP $remote_addr后,nginx 就会把其接收到的 HTTP 请求加上一个 HTTP 头部X-Real-IP,并把这个头部的值设为 nginx 自己获取到的源 IP 地址。这样,后台服务程序只需要读取请求中的X-Real-IP头部就行了。

如果在运行 nginx 的服务器前配置了负载均衡器之类的代理,那么 nginx 获得的地址也只是代理的 IP。这种情况下应该使用X-Forwarded-For头部,因为它是协议中明文规定的用以体现代理过程的一个头部。

假设用户的 IP 是 1.1.1.1,服务器的 IP 是 2.2.2.2,放置在服务器前的负载均衡器(代理服务器)的 IP 是 3.3.3.3,那么用户访问到我们服务器的路径就是 1.1.1.1 到 3.3.3.3,然后到我们服务器的 IP 2.2.2.2,中间被代理服务器 3.3.3.3 转发了一道,那么就市面上常见的代理服务器软件而言,它会设置X-Forwarded-For头部为:

1
1.1.1.1

这表示该 HTTP 请求从 1.1.1.1 发出。而 nginx 此时获取到的$remote_addr是代理服务器 IP 3.3.3.3,因此 X-Real-IP 并不是用户的 IP。 nginx 要把请求转发给后台服务程序,因此我们需要加上这样一行配置:

1
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

它会把自己得到的X-Forwarded-For头部后面追加来源的 IP,如果没有X-Forwarded-For头部,就直接添加来源的 IP。然后,将这个头部传递给后台服务程序。后台服务程序看到的 HTTP 请求中的X-Forwarded-For头部就是这样:

1
1.1.1.1, 3.3.3.3

这表示 HTTP 请求从 1.1.1.1 发出,被 3.3.3.3 转发。IP 之间用逗号和空格隔开。相比于X-Real-IP,后台服务程序的处理会麻烦一些:一般情况下,只要把这个字符串用, 拆分,取第一个就行。但是,HTTP 请求的头部极其容易被伪造,这意味着不怀好意的客户端可能会自己发送一个X-Forwarded-For头部,其内容可能为任意内容,这就有进行 SQL 注入或 XSS 攻击的可能。如果后台服务程序知道在 nginx 前面有多少层可信的代理,那么它就应该从后往前数多少层以获取访问第一层代理的 IP,在这 IP 之前的内容都是客户端自己编写的X-Forwarded-For头部,或者客户自己就是通过代理上网的。在后一种情况,我们获得的用户 IP 是用户使用的代理服务器的 IP 地址,但是除非代理服务器主动告知,基本上没有什么简单的方法能够追溯到真实的用户 IP,所以大家一般也就做到这里为止。

总而言之,如果 nginx 前面没有代理,可以使用X-Real-IP,这一头部由 nginx 自己设置,并会抹除用户自己的X-Real-IP的设置(如果有的话),更加安全。如果 nginx 前面有代理,请一定使用X-Forwarded-For头部,因为它才是被广泛支持的头部,但是处理起来可能麻烦一些。但是,当用户使用了代理的情况下,两种方式都只可以获得用户的代理服务器 IP。

神秘的局域网地址

但是有一天,当我闲着蛋疼查看评论系统的 IP 记录时,发现了一个本不应该出现的 IP:172.20.0.1。这很不对劲,众所周知,IP 地址有几个地址段是作为私有网段,只能用于内网访问,即10.0.0.0/8172.16.0.0/12192.168.0.0/16,和大家常见的127.0.0.1这个本地回环地址,而172.20.0.1就是172.16.0.0/12这个网段内的地址。

难道是评论系统的 bug ?我又翻找了一下 nginx 的日志,发现赫然好几行日志在列:

1
2
3
4
5
103.78.180.218 - - [29/Dec/2020:06:05:22 +0000] "GET / HTTP/1.1" 444 0 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/601.7.7 (KHTML, like Gecko) Version/9.1.2 Safari/601.7.7" "-"
146.56.221.67 - - [29/Dec/2020:06:17:20 +0000] "GET / HTTP/1.1" 444 0 "-" "Mozilla/4.0 (compatible; Win32; WinHttp.WinHttpRequest.5)" "-"
139.162.145.250 - - [29/Dec/2020:07:16:51 +0000] "GET /bag2 HTTP/1.1" 400 650 "-" "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0)" "-"
172.20.0.1 - - [29/Dec/2020:07:25:53 +0000] "GET / HTTP/2.0" 301 162 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36" "-"
172.20.0.1 - - [29/Dec/2020:07:25:53 +0000] "GET / HTTP/2.0" 200 15394 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36" "-"

前面是一堆脚本扫描器产生的日志记录,它们的 IP 都是正常的公网地址(只有脚本愿意造访我的服务器,嘤嘤嘤)。但是底下的请求却赫然写着来源IP是172.20.0.1……到底发生了什么?

后来我用手机访问了我自己的网站,发现记录的确实是公网 IP。为什么会这样?为什么有时候会记录公网 IP,有时候会记录这个172.20.0.1

真相大白

必须把这个事情搞个水落石出。既然 nginx 记录了一个内网地址,那么它就是来自内网的访问。我的服务器架设在阿里云上,使用的专有网络里只有一台实例,而且网段都是设置的192.168.0.0/16的网段,所以不可能是别的机器进行的访问,那么内鬼就只能出现在我自己的机器上!

直接执行ifconfig,出现了许多的网卡,其中有大家都有的环回测试网卡lo,有本来就有的eth0,上面设置了 IPv4 的内网地址和经过配置的 IPv6 的地址,值得注意的是,出现了docker0网卡,一个br-打头的网卡,几个veth开头的网卡。

而其中,那个br-打头的网卡它的 IP 地址就是设置的172.20.0.1!它就是罪魁祸首!

这个br-打头的网卡是怎么来的?我用docker-compose down关闭了服务,再运行ifconfig,发现br-打头的网卡和veth开头的网卡消失了。重新开启服务后,这几个网卡又出现了,只是后面的字符串不一样。也就是说,这一切都是 Docker 在背后运作。查阅资料后发现,事实果然如此。

Docker的默认网络模式

当 Docker 进程启动之时,其将在主机上创建一个docker0网卡,起到一个网桥的作用,在这个主机上启动的 Docker 容器就会连接到这个docker0的网桥,并会被分配一个docker0子网下的 IP 地址,容器内的默认网关也将被设置为docker0的 IP 地址。同时,为每一个容器创建一对虚拟网卡设备,即veth-pair,这一对虚拟网卡设备的其中一端放在主机,即veth开头的那张网卡,并将其加入到docker0的网桥中。另一端放在容器内,并被命名为eth0

这里做个演示:我们直接用 docker 命令启动一个 nginx 容器,名称为web

1
docker run -d --name web nginx

通过命令docker container ls就能看到正在运行的容器。

1
2
3
$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
94cd247a5661 nginx "/docker-entrypoint.…" 5 seconds ago Up 4 seconds 80/tcp web

我们看一下现在的网络情况,在本机执行ifconfig,就会看见新创建了一个veth开头的网卡。当然,还有那个docker0网卡,我这里的 IP 被设定为172.17.0.1

我们进入到容器内看一下情况。容器默认没有安装ifconfig,我们需要先启动bash安装,然后再执行ifconfig
docker exec -it web /bin/bash启动容器内的bash

进入容器内后,安装ifconfig,命令为:apt update && apt install net-tools

执行ifconfig,发现两个网卡设备,一个是lo,本地环回,忽略,一个是eth0,IP 被设定为172.17.0.2,跟docker0在一个网段。

查看一下容器内的路由,执行命令:route

1
2
3
4
5
root@94cd247a5661:/# route
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
default 172.17.0.1 0.0.0.0 UG 0 0 0 eth0
172.17.0.0 0.0.0.0 255.255.0.0 U 0 0 0 eth0

路由的意思很简单,如果要访问172.17.0.0/16网段的地址(Genmask 为子网掩码,指示范围),那么直接通过Iface指定的网卡eth0访问即可,不需要经过路由(Gateway 被设为0.0.0.0);如果是其他的地址(default,其实就是0.0.0.0),那么网络消息将通过Iface指定的网卡eth0发送到172.17.0.1

我们前面说到,Docker 为容器创建了一对虚拟网络设备,一端在主机端为veth开头,一端在容器内为eth0。于是容器内的eth0和主机端veth开头的设备相当于直接彼此相连(其实完全可以认为这就是同一个设备,只是在不同地方的叫法不一样),通过容器内eth0设备的所有流量都会通过主机上veth的设备,反之亦然。

那么流量跑到了veth上,怎么和docker0网卡172.17.0.1扯上关系了呢?因为docker0既表现为一个配置了 IP 地址的网卡,同时也是一个网桥。如果我们直接用简单的 docker 命令启动了多个容器,那么每个容器都会有一个对应的veth开头的网卡,它们都将绑定到docker0这个网桥中,通过docker0这个网桥,容器与容器之间就像在同一个局域网一样,可以相互通信了。

Docker 默认 bridge 网络模式示意图

容器与外界的通信

容器与容器之间的通信通过docker0这个网桥解决,那么容器与容器外的通信应该怎么解决?容器外又分成宿主机本身和宿主机外,又有流入和流出的区别。Docker 通过 iptables 设置转发实现。

说到 iptables,这又是一个可以专门讲一章的东西。不过这里我们先慢慢来。现在我们启动了一个容器,来看看 iptables 的配置,命令为iptables-save,注意在 root 权限下运行。我这里输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# Generated by iptables-save v1.8.4 on Tue Dec 29 20:27:27 2020
*filter
:INPUT ACCEPT [47865:35431173]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [42374:8161544]
:DOCKER - [0:0]
:DOCKER-ISOLATION-STAGE-1 - [0:0]
:DOCKER-ISOLATION-STAGE-2 - [0:0]
:DOCKER-USER - [0:0]
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -o docker0 -j DOCKER
-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
-A FORWARD -i docker0 -o docker0 -j ACCEPT
-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -j RETURN
-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -j RETURN
-A DOCKER-USER -j RETURN
COMMIT
# Completed on Tue Dec 29 20:27:27 2020
# Generated by iptables-save v1.8.4 on Tue Dec 29 20:27:27 2020
*nat
:PREROUTING ACCEPT [108:6205]
:INPUT ACCEPT [95:5406]
:OUTPUT ACCEPT [269:19012]
:POSTROUTING ACCEPT [268:18928]
:DOCKER - [0:0]
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A DOCKER -i docker0 -j RETURN
COMMIT
# Completed on Tue Dec 29 20:27:27 2020

这就是全部的 iptables 规则。我们首先需要了解 iptables 的默认处理方法。iptables 默认有四个表:Raw, Mangle, NAT, Filter,有五个链:PREROUTING, INPUT, FORWARD, OUTPUT, POSTROUTING。当机器的网卡需要接收或者发送包时,需要经过 iptables 处理。根据包的特点,其处理策略有所不同:

  • 从外地来,发往本地:先后经过 PREROUTING 链和 INPUT 链。
  • 从外地来,发往外地:先后经过 PREROUTING、INPUT、FORWARD、POSTROUTING 链。
  • 从本地产生,发往本地:不存在的。
  • 从本地产生,发往外地:先后经过OUTPUT、POSTROUTING 链。

所谓的本地和外地,是针对 iptables 所工作的网络层:主机的网卡和主机外的网络是外地,主机系统网络层以上的传输层和应用层是内地。也就是说,ping 自己主机的 IP 是从本地产生发往外地,而从本地的传输层或应用层直接访问本地的传输层和应用层由于也要经过网络层,因此同样不是本地产生发往本地。

而在经过一条链时,会按照Raw、Mangle、NAT、Filter表的顺序依次应用表内的规则。不过 PREROUTING 链没有 Filter 表,INPUT 链和 FORWARD 链只有 Mangle 和 Filter 表,OUTPUT 链有所有表,POSTROUTING 链只有 Mangle 和 NAT 表。另外,链可以自行创建。

iptables 数据包流程(来源见水印)

现在我们来看看具体情况:

  • 容器内访问主机:对于主机的 iptables 而言,是有数据从docker0过来,想进入到主机的传输层和应用层,即从外地来,发往本地。判断它要发往本地还是外地就看它的目的地址是不是主机上的网卡配置了的其中一个地址。首先进入 PREROUTING 链,它在 NAT 表有一个规则(因为它在*nat下面):

    -A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER

    意思是,如果数据的目标是本地,那么跳转到 DOCKER。DOCKER 是 docker 自己建立的一条链(:DOCKER - [0:0]该行表示此表拥有的链)。于是我们跳到 DOCKER 链(即-j DOCKER)。它在 NAT 表有一个规则:

    -A DOCKER -i docker0 -j RETURN

    意思是,如果数据来自于网卡docker0,那么不再往下匹配链条的规则,直接返回到调用它的那条链的下一条规则(即RETURN)。所以我们又回到了 PREROUTING 链。但是 PREROUTING 链已经没有规则了,根据:PREROUTING ACCEPT行,如果执行完最后一条规则,那么就执行ACCEPT操作,通过此数据包。之后进入到 INPUT 链。

    INPUT 链没有规则,而且在两个表里默认规则都是 ACCEPT,因此整个流程结束,容器内访问主机的包原封不动被传递。

  • 容器内访问外地:对于主机的 iptables 而言,是有数据从docker0过来,想出主机,即从外地来,发往外地。首先进入 PREROUTING 链,由于唯一的规则不匹配,因此执行默认的 ACCEPT 操作。之后进入到 INPUT 链。INPUT 链也同样默认 ACCEPT。假设根据主机的路由表,发往外地的包都要经过eth0。现在进入 FORWARD 链。规则从上到下依次匹配。

    第一条为-A FORWARD -j DOCKER-USER。跳到 DOCKER-USER 链,直接一条RETURN,查看下一条。

    第二条为-A FORWARD -j DOCKER-ISOLATION-STAGE-1。跳到该链,首先一条-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2,即来自docker0但不去往docker0的包跳到 DOCKER-ISOLATION-STAGE-2 链,其有两条规则,一条是如果去往docker0的包则丢弃,此条不符合,看下一条,下一条为RETURN,于是回到 DOCKER-ISOLATION-STAGE-1 链,看下一条,直接 RETURN,又回到 FORWARD 链。

    第三条匹配目标为docker0的包,跳过。第四条也是,跳过。第五条为-A FORWARD -i docker0 ! -o docker0 -j ACCEPT,表示来自docker0且目的地不是docker0的包采用动作 ACCEPT。于是进入 POSTROUTING 链。

    -A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE规则生效,因为我们的包就是来自容器内,即172.17.0.0/16网段,且不发往docker0,于是执行MASQUERATE动作,将包的源 IP 改为将要发送此包的网卡的 IP,于是包的源 IP 从172.17.0.2改为了eth0网卡的 IP,这个包被交付到eth0网卡发出,这样外地的主机就根本不知道这个包是容器内网络发出的,还是主机自己发出的,都会认为是主机发出的。

  • 主机访问容器内:对于主机的 iptables 而言,是有数据从内部过来,想出主机,即从本地来,发往外地。通过主机的路由表发现,这种包应该发送给网卡docker0。首先进入 OUTPUT 链,有一条规则:

    -A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER

    意思是,如果目标不是127.0.0.0/8网段,但目标是本地地址的,跳到DOCKER链。但我们访问的是一个容器的地址,不是docker0的地址,因此并不满足本地地址的要求。OUTPUT 链结束,进入 POSTROUTING 链。其有一条规则:

    -A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE

    意思是,如果源是172.17.0.0/16网段,但目标不是docker0,那么执行动作MASQUERADE,即将源地址改成即将发送此消息的网卡的地址。然而并不是,整条链结束,原封不动被传递。

  • 外地访问容器内:对于主机的 iptables 而言,是有数据从eth0过来,要去docker0,从外地发往外地。这里 PREROUTING 链和 INPUT 链都会放行,并进入 FORWARD 链。

    FORWARD 链第一条规则执行完后,进入第二条规则,但规则均不符合,返回并匹配下一条。下一条规则为

    -A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT

    这一条将允许目标为docker0,且这些包如果是 RELATED 状态或者 ESTABLISHED 状态连接中的一部分,那么就接受。

    什么是 ESTABLISHED 状态?我们在建立 TCP 连接时,要经历三次握手。客户端发出了 SYN 包到服务器,此时 iptables 检测到了这个包,根据之前的说法,它会放行,并判断有一个新连接要产生,连接便为 NEW 状态;服务器将返回 SYN/ACK 包,通过 iptables 后,它判断这个包是为 NEW 状态的连接的一个响应,该连接状态即为 ESTABLISHED,规则匹配成功,放行。之后服务端于该连接发送给客户端的数据都会放行。RELATED 状态则是由一个已经处于 ESTABLISHED 状态的连接产生的一个额外连接,比如 FTP 协议的 FTP-data 连接的产生就会于 FTP-control 连接后成为 RELATED 状态的连接,而不仅仅是 NEW 状态。一些 ICMP 应答也是如此。这种情况,防火墙也会放行。

    如果包不属于这两种状态,比如外界向本机发起了连接,那么继续下一行,下一行表示目的地为docker0跳到 DOCKER 链。DOCKER 链直接返回。继续下面的规则,两条规则都要求来自docker0,不满足,因此不匹配,所有 FORWARD 链的规则都被匹配过了,执行默认动作 DROP(:FORWARD DROP),该包被丢弃。这样外网便无法访问容器内的服务。

  • 容器内访问容器内

    对于 iptables ,是从外到外,从docker0docker0。同样,PREROUTING 和 INPUT 链放行,进入 FORWARD 链。它首先进入 DOCKER-USER 链,然后直接 RETURN,然后直接进入 DOCKER-ISOLATION-STAGE-1 链,第一条不满足,第二条直接 RETURN,第三条在上一个情况进行了描述,如果不满足,进入第四条,也不满足,看第五条-A FORWARD -i docker0 -o docker0 -j ACCEPT,满足,执行动作ACCEPT,因此容器内访问容器内畅通无阻。

总而言之,外地不能访问容器内的服务,但是本地行(你可以现在试试在主机上访问http://172.17.0.2,完全可以);容器内可以畅通无阻地访问本地和外地的服务。

因此,如果我们要把容器内的服务端口暴露出来,就要用到-p选项,把容器某个端口的服务暴露于主机的某个端口。比如我们把 nginx 容器的 80 端口映射到主机的 8080 端口:

1
2
3
docker stop web
docker rm web
docker run -d --name web -p 8080:80 nginx

现在,外部主机就能通过访问主机的 8080 端口来获得 nginx 容器内 80 端口的服务了。我们来看看 iptables 发生了什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
*filter
:INPUT ACCEPT [1593:256712]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [1401:238092]
:DOCKER - [0:0]
:DOCKER-ISOLATION-STAGE-1 - [0:0]
:DOCKER-ISOLATION-STAGE-2 - [0:0]
:DOCKER-USER - [0:0]
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -o docker0 -j DOCKER
-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
-A FORWARD -i docker0 -o docker0 -j ACCEPT
-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 80 -j ACCEPT
-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -j RETURN
-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -j RETURN
-A DOCKER-USER -j RETURN
COMMIT
# Completed on Tue Dec 29 22:14:31 2020
# Generated by iptables-save v1.8.4 on Tue Dec 29 22:14:31 2020
*nat
:PREROUTING ACCEPT [2:120]
:INPUT ACCEPT [2:120]
:OUTPUT ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
:DOCKER - [0:0]
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 80 -j MASQUERADE
-A DOCKER -i docker0 -j RETURN
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80
COMMIT
# Completed on Tue Dec 29 22:14:31 2020

相比之前,多了这么几条:

  • Filter 表中,多了一条-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 80 -j ACCEPT

  • NAT 表中,多了好几条:

    -A POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 80 -j MASQUERADE

    -A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

于是,当容器内访问主机时,由于-A DOCKER -i docker0 -j RETURN的作用,PREROUTING 链没有影响(链中的顺序按照 NAT 表执行完后再执行 Filter 表的顺序来),因此行为不变。

当容器内访问外地时,PREROUTING 链没有影响,FORWARD 链的执行流程同样未变,POSTROUTING 链因为已经匹配原来的规则,因此新规则没有被执行到,不影响。

当主机访问容器内时,OUTPUT 链没有影响,POSTROUTING 链的原来那条也没有影响,新加的那条也不匹配,因此毫无影响。

当外地访问容器内时,虽然不知道这到底是怎么做到的,但我们还是来看一下。由于 FORWARD 链触发规则-A FORWARD -o docker0 -j DOCKER,进入 DOCKER 链,第一条要求源为docker0,跳过,第二条是新加的,-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

什么意思呢?如果来源不是docker0,是 TCP 协议的包,协议中的目的端口是 8080 的话,将这个包传递给 172.17.0.2 的 80 端口。DNAT即将原来的目的地址和端口修改成--to-destination中指定的地址和端口。也就是说,外地本来以为访问的是主机的 8080 端口,由监听 8080 端口的应用处理此请求,但是实际上经过 iptables 之手后,这个包的目的地址和端口已经是 172.17.0.2 的 80 端口了。由于 DOCKER 链应用了规则,因此它直接回到 FORWARD 后,FORWARD 链不再继续执行(这和 RETURN 不同),直接进入 POSTROUTING 链。

POSTROUTING 链看见了这个被修改过的包,路由表决定要发往docker0,但是 POSTROUTING 的规则均不满足,因此该包被接受,最后发往 172.17.0.2 的 80 端口。

但是,如果外地访问本地的 8080 端口呢?那么在 PREROUTING 链的第一条规则-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER就会生效,DOCKER 链中包的目的地址被修改为 172.17.0.2:80 ,本来发往本地的包便改成了发往docker0,并由 172.17.0.2:80 的应用提供服务。这正是我们需要的。

而容器内访问容器内,新规则都不会匹配,除非自己访问自己,它会把源地址修改成docker0的地址。

综上所述,其通过 iptables 的配置,docker 实现了网络隔离和端口转发的功能。

docker-compose 的网络

上面我们详细分析了如果直接用 docker 命令启动一个容器产生的影响。但是实际情况下我用的是 docker-compose,如果采用默认网络配置的话,它会创建一个网桥(br打头),并将所有容器的veth加入这个网桥。它其实起到的作用和docker0一模一样,但是它们的网段不同,比如docker0的网段是172.17.0.0/16,那么br打头的网桥就是172.20.0.0/16这一类,同时配置 iptables 的时候新增的条目由docker0改成自己建的网桥,而配置中 FORWARD 链会默认 DROP,且 DOCKER 链会直接返回,因此它相当于和docker0完全隔离。这样相当于建立了一个与docker0完全不相关的网络,但是机制却和docker0一模一样。所以关于上面的分析依旧能够直接搬到 docker-compose 的环境来。

百密一疏

经过上面的分析,我们知道,docker 或者 docker-compose 通过配置 iptables 实现端口转发,按理来说,DNAT 并不会更改源地址,MASQUERADE 改的是发出去的包的源地址,nginx 上一些日志也印证了这点,理论上说在容器内部署 nginx 应该和直接在主机上部署是完全相同的。但是问题确实发生了,为什么会发生这种事情?

在牺牲了数十根头发后,我实现了前端访问一个公用 API 来传输客户 IP 的功能,经过比对传输过来的 IP 和 nginx 日志,我发现了一个惊天大秘密:

所有源 IP 为 172.20.0.1 的访问,原来都是来自 IPv6 地址的访问!我前段时间闲得蛋疼搞的 IPv6 竟然还埋藏着如此大坑!

iptables 这个强大的网络工具,它居然根本就不支持 IPv6!!!所以,上面对 iptables 的一切配置,对 IPv6 可以说都是没有用的!!!

但是既然 iptables 无法做到对 IPv6 环境做端口映射, Docker 是怎么实现 IPv6 的服务的呢?

于是我执行了netstat -plunt,发现了进程docker-proxy,每个被暴露于主机的端口都会开启一个docker-proxy进程,并监听暴露的某个端口。那么这个锅,只能交给这个docker-proxy背了。

在网上翻找一番,发现大家对这个docker-proxy颇有怨言:要么就是事情都让 iptables 干了,什么用都没有,要么就是每个端口都会启动一个这个进程,如果开的端口多了,也是对资源的一种占用,要么就是 IPv6 环境下不得不用它,但是一旦需要真实 IP (比如邮件服务器验证)时就不可用。为此 Docker 官方在几年前就出了一个配置禁用这一功能。根据这篇文章的说法,针对低版本的内核和需要使用 localhost:port 方式与容器通讯的场景(即主机通过 IPv6 环境和容器内应用通讯,但其实只要修改 hosts 让 localhost 只会指向 127.0.0.1 这个 IPv4 地址可能就能解决这个问题,但或许对于纯 IPv6 方法不可以这样做)。我们并不需要如此,因此干脆把这个没什么用的 docker-proxy 关闭好了,IPv4 场景要它没用,IPv6 场景也只是能用,却让我在获取真实 IP 地址那块翻了跟头可还行。

解决方案

但是要怎么解决这个问题呢?在 IPv4 场景下,我们用到了 NAT 技术(就是那个 DNAT)来做端口映射,所以在 IPv6 场景下,我们也可以用 NAT 技术来实现。许多人认为因为 IPv6 提供的 IP 地址特别特别多,因此只要配置好防火墙,根本就不需要 IPv6 环境的 NAT 技术,但是在现在这种环境下,NAT 技术反倒可以算一个好的选择。

于是,我看到了 docker-ipv6nat,正好满足我的要求,它通过 IPv6 版本的 ip6tables 设置好转发和屏蔽规则。把它加到我的 docker-compose.yml 里面!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
version: "3"
services:
web:
image: konjacgrid/nginx-brotli:latest
container_name: myfg_webfront
ports:
- 80:80
- 443:443
volumes:
- ./nginx/conf:/etc/nginx:ro
- ./nginx/www:/var/www/html:ro
restart: always

ipv6nat:
image: robbertkl/ipv6nat
container_name: ipv6nat
restart: unless-stopped
network_mode: host
privileged: true
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /lib/modules:/lib/modules:ro

networks:
default:
enable_ipv6: true
driver: bridge
ipam:
driver: default
config:
- subnet: fd00:cafe:face::/48

ipv6nat 和 networks 的配置都可以照抄,其实subnet那一项只需要这个网段属于fc00::/7就行。

但是还没完,我们需要更改一下系统设置,让系统支持 IPv6 的 forwarding:

1
sudo sh -c "echo 1 > /proc/sys/net/ipv6/conf/all/forwarding"

如果需要重启后也能实现,修改/etc/sysctl.conf文件,在后面追加一行:

1
net.ipv6.conf.all.forwarding = 1

这样就成功开启了 IPv6 forwarding,ip6tables 的设置才能起效。

同时,我们需要让 nginx 监听 IPv6 的端口,将所有的listen 80;后面都追加一行listen [::]:80即可,443端口如法炮制。现在重新启动服务,用 IPv6 网络访问一下!看看日志!

1
2
2001:250:5002:8000::1:e6ea - - [29/Dec/2020:18:53:52 +0000] "GET /js/next-boot.js HTTP/2.0" 200 952 "https://www.moyufangge.com/2020-12/cn-history-events-15/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0" "-"
2001:250:5002:8000::1:e6ea - - [29/Dec/2020:18:53:52 +0000] "GET /js/local-search.js HTTP/2.0" 200 2842 "https://www.moyufangge.com/2020-12/cn-history-events-15/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0" "-"

完美!