目录

动态添加容器的端口映射

概述

针对一些正在运行的容器,如果希望添加一个端口映射到本机,一般来说需要重启容器,重新添加端口映射的参数,但是对于一些正在运行的业务来说,有时候是很难接受重启的,那么有没有什么办法,可以动态地为容器添加端口映射吗?

通过代理容器

比如通过 docker-compose 部署的 Harbor 的组件 PostgreSQL 和 Redis,默认是没有做端口映射的,如果服务已经在运行了,显然数据库是不能随便重启的,但是现在又希望通过容器外的客户端连接,那么通过下面的命令,就可以实现代理转发,其中的 IP 就是对应容器的 IP,通过 docker ps 即可获得。

1
2
docker run --network harbor_harbor --rm -p 5432:1234 verb/socat TCP-LISTEN:1234,fork TCP-CONNECT:172.18.0.9:5432
docker run --network harbor_harbor --rm -p 6379:1234 verb/socat TCP-LISTEN:1234,fork TCP-CONNECT:172.18.0.8:6379

当然了,这还是依赖的是 Docker 的能力,那么我们有没有可能摆脱 Docker,自己通过命令实现这样的端口映射呢?

Docker的端口映射

Docker初始化

通过下面的命令安装 Docker,并且启动一个 MySQL 的容器,然后我们看看 iptables 的变化情况。

1
2
3
4
yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
yum install -y docker-ce docker-ce-cli containerd.io
systemctl enable docker
systemctl start docker

安装和启动 Docker 之后,我们看看具体的规则。

 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
# iptables-save
# Generated by iptables-save v1.4.21 on Tue Feb  7 07:45:06 2023
*nat
:PREROUTING ACCEPT [74:16806]
:INPUT ACCEPT [74:16806]
:OUTPUT ACCEPT [59:4239]
:POSTROUTING ACCEPT [59:4239]
: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 Feb  7 07:45:06 2023
# Generated by iptables-save v1.4.21 on Tue Feb  7 07:45:06 2023
*filter
:INPUT ACCEPT [798:4454914]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [678:74998]
: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 Feb  7 07:45:06 2023

通过下面的命令启动 Docker 容器,并且设置了端口映射。

1
docker run -p 3306:3306 --name mysql -e MYSQL_ROOT_PASSWORD=123456 mysql:5.7.36

通过 diff 比较前后的规则变化。

 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
38
# diff docker.ipt docker-with-mysql.ipt
1c1
< # Generated by iptables-save v1.4.21 on Tue Feb  7 07:46:02 2023
---
> # Generated by iptables-save v1.4.21 on Tue Feb  7 07:46:51 2023
3,6c3,6
< :PREROUTING ACCEPT [123:18470]
< :INPUT ACCEPT [123:18470]
< :OUTPUT ACCEPT [100:6889]
< :POSTROUTING ACCEPT [100:6889]
---
> :PREROUTING ACCEPT [18:638]
> :INPUT ACCEPT [18:638]
> :OUTPUT ACCEPT [5:300]
> :POSTROUTING ACCEPT [5:300]
10a11
> -A POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 3306 -j MASQUERADE
11a13
> -A DOCKER ! -i docker0 -p tcp -m tcp --dport 3306 -j DNAT --to-destination 172.17.0.2:3306
13,14c15,16
< # Completed on Tue Feb  7 07:46:02 2023
< # Generated by iptables-save v1.4.21 on Tue Feb  7 07:46:02 2023
---
> # Completed on Tue Feb  7 07:46:51 2023
> # Generated by iptables-save v1.4.21 on Tue Feb  7 07:46:51 2023
16c18
< :INPUT ACCEPT [1072:4473492]
---
> :INPUT ACCEPT [179:14879]
18c20
< :OUTPUT ACCEPT [936:111563]
---
> :OUTPUT ACCEPT [173:41522]
28a31
> -A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3306 -j ACCEPT
35c38
< # Completed on Tue Feb  7 07:46:02 2023
---

可以看到下面三条规则是最明显新添加的,分别解释一下,可以见注释。

1
2
3
4
# 在POSTROUTING链,相当于SNAT
iptables -t nat -A POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 3306 -j MASQUERADE
iptables -t nat -A DOCKER ! -i docker0 -p tcp -m tcp --dport 3306 -j DNAT --to-destination 172.17.0.2:3306
iptables -t nat -A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3306 -j ACCEPT

因此如果为了在机器上手动添加端口映射,在完全不依赖 Docker 的情况,执行上面的命令即可,同时也要记得删除。

1
2
3
iptables -t nat -D POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 3306 -j MASQUERADE
iptables -t nat -D DOCKER ! -i docker0 -p tcp -m tcp --dport 3306 -j DNAT --to-destination 172.17.0.2:3306
iptables -t nat -D DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3306 -j ACCEPT

源码分析

先来看看 Docker 里面是怎么做到端口映射的。如果之前没有研究过 Docker 的源码,可能一开始会有点一头雾水,那么可以先从 Client 入手,比如通过 docker run --help 查看端口映射的相关选项的信息,-p, --publish list Publish a container's port(s) to the host,比如这个信息,需要在 cli 的代码里检索出来,其中类似 ExposedPorts 这就是一些关键词,查看相关的逻辑,

1
2
3
4
5
6
	config := &container.Config{
	...
	    // 显然ExposedPorts的意思就是对外暴露的端口的意思
		ExposedPorts: ports,
	...	
	}

最后核心就是上面这个数据结构,根据这个 ExposedPorts 字段,我们再去 Server 端,也就是 moby 去找相关的代码。

libnetwork

其实 Docker 之所以能实现这样的端口映射,实际上也是通过 iptables 来实现的,下面我们来具体分析一下代码,看看 Docker 是怎么通过 iptables 来增加这些 NAT 的规则。

我们都知道这种端口映射实际上走的是 NAT 规则。而 Docker 源码里关于 iptables 配置的部分,都浓缩在 libnetwork 这个库里。

大家可以留意这 Forward 方法。

iptables简单复习

因为本文的重点就是 Docker 是如何使用 iptables 来实现端口映射的,因此讲到这里了,就有必要简单的复习一下。

源码分析

既然了解了 iptables 是怎么做端口映射的,那么再去看看 Docker 是如何在代码里实现的。

1
2
3
4
5
6
7
8
9
// AppendForwardingTableEntry adds a port mapping to the forwarding table
func (pm *PortMapper) AppendForwardingTableEntry(proto string, sourceIP net.IP, sourcePort int, containerIP string, containerPort int) error {
	return pm.forward(iptables.Append, proto, sourceIP, sourcePort, containerIP, containerPort)
}

// DeleteForwardingTableEntry removes a port mapping from the forwarding table
func (pm *PortMapper) DeleteForwardingTableEntry(proto string, sourceIP net.IP, sourcePort int, containerIP string, containerPort int) error {
	return pm.forward(iptables.Delete, proto, sourceIP, sourcePort, containerIP, containerPort)
}

要确保通过 Go 传入的参数不会把 iptables 规则改的乱七八糟是不容易的,这里的逻辑比较复杂。首先是 PortMapper 这个结构体,提供了 AppendForwardingTableEntryDeleteForwardingTableEntry 两个方法来增加或者删除 Forward 表的 Rule。

1
2
3
4
5
6
7
8
// PortMapper的内部方法
func (pm *PortMapper) forward(action iptables.Action, proto string, sourceIP net.IP, sourcePort int, containerIP string, containerPort int) error {
	if pm.chain == nil {
		return nil
	}
	// 传入的参数,分别是action,来源IP,来源端口,协议,容器IP,容器端口,网桥名
	return pm.chain.Forward(action, sourceIP, sourcePort, proto, containerIP, containerPort, pm.bridgeName)
}

从下面的代码中,可以分析,在 Docker 源码里的 Forward 具体执行了哪些类型的 iptables 猜操作。

1
2
iptables -p TCP -d xx.xx.xx.xx --dport 8080 -j DNAT --to-destination xx.xx.xx.yy:8080
iptables ! -i eth0 -o brige0 -p tcp/udp -d xx.xx.xx.xx --dport 8080 -j ACCEPT
 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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 接上面最后的return
func (c *ChainInfo) Forward(action Action, ip net.IP, port int, proto, destAddr string, destPort int, bridgeName string) error {
	daddr := ip.String()
	if ip.IsUnspecified() {
		// iptables interprets "0.0.0.0" as "0.0.0.0/32", whereas we
		// want "0.0.0.0/0". "0/0" is correctly interpreted as "any
		// value" by both iptables and ip6tables.
		daddr = "0/0"
	}

	args := []string{
		"-p", proto,
		"-d", daddr,
		"--dport", strconv.Itoa(port),
		"-j", "DNAT",
		"--to-destination", net.JoinHostPort(destAddr, strconv.Itoa(destPort))}
	if !c.HairpinMode {
		args = append(args, "!", "-i", bridgeName)
	}
	// ProgramRule这个函数是一个构件规则的函数
	if err := ProgramRule(Nat, c.Name, action, args); err != nil {
		return err
	}

	args = []string{
		"!", "-i", bridgeName,
		"-o", bridgeName,
		"-p", proto,
		"-d", destAddr,
		"--dport", strconv.Itoa(destPort),
		"-j", "ACCEPT",
	}
	// ProgramRule这个函数是一个构件规则的函数
	if err := ProgramRule(Filter, c.Name, action, args); err != nil {
		return err
	}

	args = []string{
		"-p", proto,
		"-s", destAddr,
		"-d", destAddr,
		"--dport", strconv.Itoa(destPort),
		"-j", "MASQUERADE",
	}

	if err := ProgramRule(Nat, "POSTROUTING", action, args); err != nil {
		return err
	}

    ...
	return nil
}

辅助方法 ProgramRule,这个方法最终是会通过 exec.Command() 来执行一个 Shell 脚本来修改 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
// ProgramRule adds the rule specified by args only if the
// rule is not already present in the chain. Reciprocally,
// it removes the rule only if present.
func ProgramRule(table Table, chain string, action Action, args []string) error {
	if Exists(table, chain, args...) != (action == Delete) {
		return nil
	}
	return RawCombinedOutput(append([]string{"-t", string(table), string(action), chain}, args...)...)
}

// RawCombinedOutput internally calls the Raw function and returns a non nil
// error if Raw returned a non nil error or a non empty output
func (iptable IPTable) RawCombinedOutput(args ...string) error {
	if output, err := iptable.Raw(args...); err != nil || len(output) != 0 {
		return fmt.Errorf("%s (%v)", string(output), err)
	}
	return nil
}

// Raw calls 'iptables' system command, passing supplied arguments.
func (iptable IPTable) Raw(args ...string) ([]byte, error) {
    ...
	return iptable.raw(args...)
}

func (iptable IPTable) raw(args ...string) ([]byte, error) {
    ...
	startTime := time.Now()
	// 实际上这个方法就是Go层面上最终调用的修改iptables的方法
	output, err := exec.Command(path, args...).CombinedOutput()
	if err != nil {
		return nil, fmt.Errorf("iptables failed: %s %v: %s (%s)", commandName, strings.Join(args, " "), output, err)
	}
	return filterOutput(startTime, output, args...), err
}

动手添加删除规则

在了解完 Docker 是如何通过代码来修改 iptables 来实现端口映射之后,我们可以尝试总结一下下面的手动添加和删除规则的一些具体步骤,一定要记得,手动修改了 iptables,那肯定多少会影响到 Docker 的后台进程的,因此一定要记得测试完之后手动的清除。

总结

本文主要从一个实际的问题出发,如果手动添加 iptables 的规则来实现给运行中容器进行端口映射,详细介绍 Docker 在源码中如何实现端口映射的原理。

参考资料

  1. Docker源码分析(七):Docker Container网络(上)
  2. Docker源码分析–环境搭建
  3. Exposing a port on a live Docker container
  4. 容器端口映射到主机端口探究
警告
本文最后更新于 2017年2月1日,文中内容可能已过时,请谨慎参考。