大家好,我是张晋涛。
上周有小伙伴在群里问到 Docker 和 iptables 的关系,这里来具体聊聊。
Docker 能为我们提供很强大和灵活的网络能力,很大程度上要归功于与 iptables 的结合。在使用时,你可能没有太关注到 iptables 的作用,这是因为 Docker 已经帮我们自动完成了相关的配置。
代码语言:javascript复制(MoeLove) ➜ ~ dockerd --help |grep iptables
--iptables Enable addition of iptables rules (default true)
docker daemon 有个 --iptables
的参数,便是用来控制是否要自动启用 iptables 规则的,默认已经设置成了开启(true)。所以通常我们不会过于关注到它的工作。
本文中,为了避免环境的干扰,我将使用 docker in docker 的环境来进行介绍,可通过如下方式启动该环境:
代码语言:javascript复制(MoeLove) ➜ ~ docker run --rm -d --privileged docker:dind
f323aef7b532ba6d575ca6f9444a08f1a55f2447afec2e853954694c034e6ae0
iptables 基础
iptables
是一个用于配置 Linux 内核防火墙的工具,可用于检测、修改转发、重定向以及丢弃 IPv4 数据包。它使用了内核的 ip_tables 的功能,所以需要 Linux 2.4 版本的内核。
同时,iptables 为了便于管理,所以按照不同的目的组织了多张 表 ;每张表中又包含了很多预定义的 链;每个链中包含着顺序遍历的 规则;这些规则中又定义了动作的匹配规则和 目标。
对于用户而言,我们通常需要交互的就是 链 和 规则了。
理解 iptables 的主要工作流程有一张比较经典的图:
img/tables_traverse.jpg
图片来源: https://www.frozentux.net/iptables-tutorial/images/tables_traverse.jpg
上面的小写字母是 表,大写字母则表示 链,从任何网络端口 进来的每一个 IP 数据包都要从上到下的穿过这张图。
- 引用自 ArchWiki
不过这不是本篇的重点,所以就不展开了。如果大家对 iptables 的内容感兴趣也欢迎留言,后续可以写一篇完整的。
Docker 网络与 iptables
接下来我们直接看看 Docker 在开启和关闭 iptables 时,具体有什么区别。
关闭 Docker 的 iptables 支持
在本文开头已经为你介绍过 docker daemon 存在一个 --iptables
的参数,用于控制是否使用 iptables 。我们使用以下命令启动一个 docker daemon 并关闭 iptables 支持。
(MoeLove) ➜ ~ docker run --rm -d --privileged docker:dind dockerd --iptables=false
7135a54c913af5e9ce69a45a0819475503ea9e3c5c673d62d9d38f0f0896179d
进入此容器,并查看其所有 iptables 规则:
代码语言:javascript复制(MoeLove) ➜ ~ docker exec -it $(docker ps -ql) sh
/ # iptables-save
# Generated by iptables-save v1.8.8 on Mon Dec 12 01:46:38 2022
*filter
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [2:80]
COMMIT
# Completed on Mon Dec 12 01:46:38 2022
可以看到,当 docker daemon 加了 --iptables=false
的参数时,默认没有任何规则的输出。
开启 Docker 的 iptables 支持
使用以下命令启动一个 docker daemon,这里没有显式的传递 --iptables
选项,因为默认就是 true
。
(MoeLove) ➜ ~ docker run --rm -d --privileged docker:dind
c464c5c08ecdf9129afbf217c6462236089fe0a1d11dfe7700c2985a04d8d216
查看其 iptables 规则:
代码语言:javascript复制(MoeLove) ➜ ~ docker exec -it $(docker ps -ql) sh
/ # iptables-save
# Generated by iptables-save v1.8.8 on Mon Dec 12 14:48:16 2022
*nat
:PREROUTING ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:OUTPUT ACCEPT [1:40]
:POSTROUTING ACCEPT [1:40]
: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.18.0.0/16 ! -o docker0 -j MASQUERADE
-A DOCKER -i docker0 -j RETURN
COMMIT
# Completed on Mon Dec 12 14:48:16 2022
# Generated by iptables-save v1.8.8 on Mon Dec 12 14:48:16 2022
*filter
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [2:80]
: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 Mon Dec 12 14:48:16 2022
可以看到,它比刚才关闭 iptables 支持时多了几条链:
- DOCKER
- DOCKER-ISOLATION-STAGE-1
- DOCKER-ISOLATION-STAGE-2
- DOCKER-USER
以及增加了一些转发规则,以下将具体介绍。
DOCKER-USER 链
在上述新增的几条链中,我们先来看最先生效的 DOCKER-USER 。
代码语言:javascript复制*filter
:DOCKER-USER - [0:0]
-A FORWARD -j DOCKER-USER
...
-A DOCKER-USER -j RETURN
以上规则是在 filter 表中生效的:
- 第一条是
-A FORWARD -j DOCKER-USER
这表示流量进入 FORWARD 链后,直接进入到 DOCKER-USER 链; - 最后一条
-A DOCKER-USER -j RETURN
这表示流量进入 DOCKER-USER 链处理后,(如果无其他处理)可以再 RETURN 回原先的链,进行后续规则的匹配。
这其实是 Docker 预留的一个链,供用户来自行配置的一些额外的规则的。
Docker 默认的路由规则是允许所有客户端访问的, 如果你的 Docker 运行在公网,或者你希望避免 Docker 中容器被局域网内的其他客户端访问,那么你需要在这里添加一条规则。 比如, 你仅仅允许 100.84.94.62 访问,但是要拒绝其他客户端访问:
代码语言:javascript复制iptables -I DOCKER-USER -i <net interface> ! -s 100.84.94.62 -j DROP
此外,Docker 在重启之类的操作时候,会进行 iptables 相关规则的清理和重建,但是 DOCKER-USER 链中的规则可以持久化,不受影响。
具体的实现均在 docker/libnetwork
下,以下是关于 DOCKER-USER
链的相关代码:
const userChain = "DOCKER-USER"
func arrangeUserFilterRule() {
if ctrl == nil || !ctrl.iptablesEnabled() {
return
}
iptable := iptables.GetIptable(iptables.IPv4)
_, err := iptable.NewChain(userChain, iptables.Filter, false)
if err != nil {
logrus.Warnf("Failed to create %s chain: %v", userChain, err)
return
}
if err = iptable.AddReturnRule(userChain); err != nil {
logrus.Warnf("Failed to add the RETURN rule for %s: %v", userChain, err)
return
}
err = iptable.EnsureJumpRule("FORWARD", userChain)
if err != nil {
logrus.Warnf("Failed to ensure the jump rule for %s: %v", userChain, err)
}
}
可以看到链名称是固定在代码中的,同时会创建/确保链和规则存在。
DOCKER-ISOLATION-STAGE-1/2 链
DOCKER-ISOLATION-STAGE-1/2 这两条链作用类似,这里一起进行介绍。
代码语言:javascript复制*filter
...
: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 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
...
这两条链主要是分两个阶段进行了桥接网络隔离。所谓的桥接网络,通常就是指通过 docker0
这个由 Docker 创建的接口的网络。
/ # ifconfig docker0
docker0 Link encap:Ethernet HWaddr 02:42:11:31:97:0D
inet addr:172.18.0.1 Bcast:172.18.255.255 Mask:255.255.0.0
UP BROADCAST MULTICAST MTU:1500 Metric:1
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0
RX bytes:0 (0.0 B) TX bytes:0 (0.0 B)
举个例子进行说明。
首先创建一个名为 moelove
的 network,并查看它的 IP 。
➜ ~ docker network create moelove
0d3d76dcf81fcf4b9d76ab5a7dec22737b115dddd593c73b27d27f0114cec1e2
➜ ~ docker run --rm -it --network moelove alpine
/ # hostname -i
172.22.0.2
然后分别使用默认的 network 和使用前面创建的 network 启动容器,来 ping 上述创建的容器 IP 。
代码语言:javascript复制➜ ~ docker run --rm -it alpine ping -c1 -w2 172.22.0.2
PING 172.22.0.2 (172.22.0.2): 56 data bytes
--- 172.22.0.2 ping statistics ---
1 packets transmitted, 0 packets received, 100% packet loss
➜ ~ docker run --rm -it --network moelove alpine ping -c1 -w2 172.22.0.2
PING 172.22.0.2 (172.22.0.2): 56 data bytes
64 bytes from 172.22.0.2: seq=0 ttl=64 time=0.092 ms
--- 172.22.0.2 ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max = 0.092/0.092/0.092 ms
可以看到,如果是相同 network 的容器是可以 ping 成功的,但如果是不同 network 的容器则不能 ping 通。
DOCKER-ISOLATION-STAGE-1 会首先匹配来自桥接网络的网桥,目标是不同的接口,如果匹配到就进入 DOCKER-ISOLATION-STAGE-2, 不匹配就返回父链。
DOCKER-ISOLATION-STAGE-2 匹配目标是桥接网络的网桥,如果匹配,意味着数据包是来自于一个桥接网络的网桥, 目的地是另一个桥接网络的网桥,并将其 DROP 丢弃掉。不匹配则返回父链。
看到这里,你可能会问 为什么要分两个阶段进行隔离?用一条链直接隔离行不行?
答案是行,一条链也能隔离,Docker 很早的版本就是这样做的。
但是当时的实在超过 30 个 network 以后,就会导致 Docker 启动很慢。所以后来做了这个优化, 将这部分的复杂度从 O(N^2) 降低到 O(2N) ,Docker 就不再会出现启动慢的情况了。
DOCKER 链
最后我们来看看 DOCKER 链,这是 Docker 中使用最为频繁的一个链,也是规则最多的链,但它却很好理解。 通常情况下,如果不小心删掉了这个链的内容,可能会导致容器的网络出现问题,手动修复下,或者重启 Docker 均可解决。
这里我们启动一个容器,并进行端口映射,来看看会有哪些变化。
代码语言:javascript复制(MoeLove) ➜ ~ docker exec -it $(docker ps -ql) sh
/ # docker run -p 6379:6379 --rm -d redis:alpine
Unable to find image 'redis:alpine' locally
alpine: Pulling from library/redis
c158987b0551: Pull complete
1a990ecc86f0: Pull complete
f2520a938316: Pull complete
ae8c5b65b255: Pull complete
1f2628236ae0: Pull complete
329dd56817a5: Pull complete
Digest: sha256:518c024ec78b3074917bad2d40863e882e5297d65587e6d7c6e0b7281d9b8270
Status: Downloaded newer image for redis:alpine
6bf21bd3de78ce32617bf64a6a730c0fb50e304509a2ec3ef05ceae648334294
/ # docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
6bf21bd3de78 redis:alpine "docker-entrypoint.s…" 9 seconds ago Up 8 seconds 0.0.0.0:6379->6379/tcp friendly_spence
之后再次执行 iptables-save
,对比当前的结果与上次的差别:
*filter
-A DOCKER -d 172.18.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6379 -j ACCEPT
*nat
-A POSTROUTING -s 172.18.0.2/32 -d 172.18.0.2/32 -p tcp -m tcp --dport 6379 -j MASQUERADE
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 6379 -j DNAT --to-destination 172.18.0.2:6379
Docker 分别在 filter
表和 nat
表增加了规则。它的具体含义如下:
filter
表中新增的这条规则表示:在自定义的 DOCKER
链中,对于目标地址是 172.18.0.2 且不是从 docker0
进入的但从 docker0
出去的,目标端口是 6379 的 TCP 协议则接收。
简单点来说就是放行通过 docker0
流出的,目标为 172.18.0.2:6379 的 TCP 协议的流量。
nat
表中这两条规则的表示:
- 为 172.18.0.2 上目标端口为 6379 的流量执行 MASQUERADE 动作(这里就简单的将它理解为 SNAT 也可以);
- 在自定义的
DOCKER
链中,如果入口不是docker0
并且目标端口是 6379 则进行 DNAT 动作,将目标地址转换为 172.18.0.2:6379 。简单点来说,这条规则就是为我们提供了 Docker 容器端口转发的能力,将访问主机本地 6379 端口流量的目标地址转换为 172.18.0.2:6379 。
当然,要提供完整的访问能力,也需要和其他前面列出的其他规则共同配合才能完成。
此外,由于 Docker 中还存在多种不同的 network 驱动,在其他模式下还会有一些区别,需要注意。
containerd 与 iptables
随着 Kubernetes 中将 dockershim 彻底移除,已经有很多人将容器运行时切换到了 containerd,甚至有人希望把所有 Docker 环境都替换成 containerd。 但这里其实有一些需要注意的点,比如我们上述的示例,在 containerd 中实际上是无法进行端口映射(端口发布)的。
containerd 中可以通过类似上述 docker 的命令来启动相同的容器,比如:
代码语言:javascript复制$ ctr run docker.io/library/redis:alpine redis-1
但它是没有 -p
或者 -P
参数的。所以这个端口发布的能力是 Docker 自己专门提供的。
如果确实想用这样的功能,怎么做呢?
一种方式是自己来管理 iptables 规则,但比较繁琐了。
另一种方式,推荐大家可以直接使用 nerdctl 这是一个专为 containerd 做的, 兼容 Docker CLI 的工具。提供了很多远比默认的 ctr
工具更丰富的能力。
比如可以这样:
代码语言:javascript复制$ nerdctl run -d --name redis-1 -p 6379:6379 redis:alpine
获取其 IP 是 192.168.40.9, 然后检查 iptables 的规则:
代码语言:javascript复制$ iptables -t nat -L | grep '192.168.40.9'
CNI-66888846605aa0cf860a0834 all -- 192.168.40.9 anywhere
DNAT tcp -- anywhere anywhere tcp dpt:redis to:192.168.40.9:6379
发现有类似的规则,让它可以正常访问。
总结
本篇从 Docker 与 iptables 的关系将其,分别剖析了 Docker 启动后会创建的 iptables 规则及其含义。并通过示例介绍了 Docker 端口映射的实际原理, 以及如何利用 nerdctl 配合使用 containerd 进行端口映射。
容器的网络内容比较多,不过原理都是相通的,在 Kubernetes 中也包含了类似的内容。
好了,以上就是本篇的内容。
欢迎大家在评论区留言讨论,也请点赞再看,谢谢。