聊聊容器网络和 iptables

大家好,我是张晋涛。

上周有小伙伴在群里问到 Docker 和 Iptables 的关系,这里来具体聊聊。

Docker 能为我们提供很强大和灵活的网络能力,很大程度上要归功于与 iptables 的结合。在使用时,你可能没有太关注到 iptables 的作用,这是因为 Docker 已经帮我们自动完成了相关的配置。

(MoeLove) ➜  ~ dockerd --help |grep iptables
      --iptables                                Enable addition of iptables rules (default true)

docker daemon 有个 --iptables 的参数,便是用来控制是否要自动启用 iptables 规则的,默认已经设置成了开启(true)。所以通常我们不会过于关注到它的工作。

本文中,为了避免环境的干扰,我将使用 docker in docker 的环境来进行介绍,可通过如下方式启动该环境:

(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 数据包都要从上到下的穿过这张图。

不过这不是本篇的重点,所以就不展开了。如果大家对 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 规则:

(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 规则:

(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 。

*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 访问,但是要拒绝其他客户端访问:

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 这两条链作用类似,这里一起进行介绍。

*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 。

➜  ~ 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 均可解决。

这里我们启动一个容器,并进行端口映射,来看看会有哪些变化。

(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 的命令来启动相同的容器,比如:

$ ctr run docker.io/library/redis:alpine redis-1

但它是没有 -p 或者 -P 参数的。所以这个端口发布的能力是 Docker 自己专门提供的。

如果确实想用这样的功能,怎么做呢?

一种方式是自己来管理 iptables 规则,但比较繁琐了。

另一种方式,推荐大家可以直接使用 nerdctl 这是一个专为 containerd 做的, 兼容 Docker CLI 的工具。提供了很多远比默认的 ctr 工具更丰富的能力。

比如可以这样:

$ nerdctl run -d --name redis-1 -p 6379:6379 redis:alpine

获取其 IP 是 192.168.40.9, 然后检查 iptables 的规则:

$ 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 中也包含了类似的内容。

好了,以上就是本篇的内容。

欢迎大家在评论区留言讨论,也请点赞再看,谢谢。


欢迎订阅我的文章公众号【MoeLove】

TheMoeLove

加载评论