0%

Docker 网络模型

Docker 容器为了实现网络隔离,可以看作连接在不同虚拟网卡上的主机。网络隔离的基本需求是不同虚拟主机直接网络不能够互通,虚拟主机可以访问外部设备,而外部设备不能够直接访问虚拟主机,只能够通过端口来访问。

根据链来分

  • FORWARD 拦截所有非本地流量,所有 docker 流量都被包含在这里。下图,ISO 是隔离不同网桥之间的互相访问。由于已经过滤掉了不同隔离网络之间的访问,所以 docker 链中是接受所有外界的流量到 docker 网卡中。再往下两条是允许虚拟网卡流量流向主机外部和网卡本身。
    image
  • OUTPUT 和PREROUTING链则是截获所有本地非容器流量,他们都公用 DOCKER 链,如果发现是要访问host:port,将其 DNAT 到对应的容器地址。
    image
  • POSTROUTING 链是将所有容器访问主机外的流量,伪装成本地流量。
    image

单机网桥模式

单机网桥模式是没有指定任何网络选项的容器,假设容器的虚拟 ip 为 172.10.0.x,由于该网段宿主机并没有暴露给外界,因此外界无法访问容器。但有一种例外情况,就是外界主机通过修改路由表强行将 172.10.0.x 的流量转发给宿主机。这样流量在到达宿主机后是可以匹配到一条路由规则的,从而实现外界流量访问虚拟容器。

容器内部的出流量在宿主机的 POST 链做了 MASQUERADE 处理,所以可以访问外界。假设容器的虚拟 ip 为 172.10.0.x,容器所有的出流量都会经过宿主机的 OUTPUT 和 POSTROUTING 链后选择合适的网卡进行转发。根据 docker 设置的 iptables 规则,所有出流量在 POSTROUTING 链经过 MASQUERADE 的 SNAT 伪装成为宿主机流量。tcp 回路的时候,宿主机会发现该数据包经历过 SNAT 规则转发,自动将回包的地址修改为 172.10.0.x,经过本地的路由规则转发到 docker0 网桥后就可以发送给对应的虚拟容器。

网桥模式

网桥模式即使用 -p 选项进行端口映射来启动的容器。单机网桥模式会复用主机端的端口,外部流量通过宿主机的 ip 地址加对应的端口号来访问虚拟容器。因此流量只要是到达主机的对应端口,就默认一定是访问主机端的服务。只用端口来判断流量是否走向 docker 容器就可以了。

  • 外部流量入:外部流量需要走 PREROUTING-FORWARD-POSTROUTIN 链,外部流量的网卡是除了docker自带的网卡之外的所有网卡。所以这些流量都需要在 PREROUTING 做 DNAT 处理,将流量的目的地址修改为 docker 容器的网段。
  • 经过 DNAT 后,宿主机发现流量流向地址不是本地应用,转发到 FORWARD 和 POSTROUTING,并且根据路由表转发到容器对应的虚拟网卡里面。
  • 内部流量出:docker 容器出流量也是宿主机应用出流量,所以会从 docker 虚拟网卡发送到 OUTPUT 链,需要在 OUTPUT 链中进行 MASQUERADE 将发送到外部的流量全部 SNAT 为宿主机的地址;此外,还要将发送给本机暴露出的端口也进行 SNAT 处理,即自访问。
  • 不同网桥之间的隔离操作:一个网桥发送给其他网桥的流量不会 SNAT 和 DNAT,会被直接发送到虚拟网卡上。虚拟网卡的入链同样会走 PRE 链,检测到是虚拟网卡流量,直接不进行操作。放行到 FORWARD,检测到不同网桥之间的相互访问,由 ISO1 截获,由 ISO2 丢弃。
  • 用户安全性操作:所有外部流量在截获前,都会被DOCKER-USER 链截获,用户需要将自己的链接入 DOCKER-USER 链中。

网桥模式中,主要是通过匹配端口号来实现外部流量的转发的,内部流量的流出过程与单机网桥模式是相同的。除此此外,网桥模式下还需要对不同 docker 网络段之间的流量进行隔离操作,防止没有连接的两个虚拟网络段之间相互通信。

DNS 服务器

对于使用了 network 选项启动的容器,docker 会自动使用 127.0.0.11 来创建一个 DNS 服务器,所有 docker 创建出的进程会使用该地址作为默认的 DNS 服务器。并且每一个服务名都会作为一个域名进行注册。该内嵌的 DNS 服务器会根据容器的网段地址来进行选择性查询,从而来实现不同网络驱动之间的域名隔离。

ROUTING MESH模式

该模式的意思是,从任何一个宿主机上暴露端口访问,都可以被负载均衡到任一台宿主机的容器上。

image

  • 外部流量入口:所有的外部流量都会被 PREROUTING 中的 DOCKER-INGRESS 链截获。如果流量目标端口是一个使用 overlay 网络模式的容器,那么该流量会被捕获到 172.18.0.2 容器中。
  • ingress-sbox:用来进行第二步iptables 流量控制。这里是通过mark+ipvs的方式来完成的,ipvs 的 vip 被绑定到overlay-bridge上。FORWARD 和 INPUT 链都会对不同端口数据包进行不同的标记,当数据包通过 INPUT 进入ipvs的vip 时,根据不同的标记进行负载均衡。
  • 目标容器:直接对流量进行处理。
  • 容器流量出口:vxlan 流量出来经过容器后,会经重定向发送到挂在宿主机的虚拟网卡。此时,网卡会被 OUTPUT链截获,如果发现是发送给 overlay 网络的流量,则会被 DNAT 到 ingress-box中。注意,docker_gwbridge 没有做外部 DNAT,无法被外部访问。
  • 以上是实现了overlay网络与普通网络流量之间的隔离。这个隔离是使用一个容器来完成的。

redis与限流算法

由于 Web 应用在部署时,常常使用多网关,微服务的组织形式,因此经常需要在进程间进行数据交换,从而达到更好的限流效果。由于限流算法中的数据具有很强的时效性,并且不需要较高的安全性,使用 redis 组件来实现限流算法是一个很好的选择。

常见的限流算法一共有四种:固定窗口,滑动窗口,桶漏算法,令牌桶。这些算法具有一定的复杂度,需要借助 redis 中的 lua 脚本来实现。

固定窗口

每个请求到达时,检查是否具有窗口存在,如果无窗口存在,则将创建窗口并且设置过期时间、允许通过的流量。此后,每一个请求到达时,如果窗口存在,都将流量-1,如果不能够完成操作,则需要等待。优点是性能比较好,实现简单,缺点是精度不够高,这个适合作为每一个用户的限流次数。

1
2
3
4
5
6
7
8
9
10
--- Check whether a single user reaches the traffic limit

local key = KEYS[1]
if redis.call("exists",key) == 0 then
redis.call("set",key,10,"EX",10)
return 10
end
local count = tonumber(redis.call("decr",key))
return count

滑动窗口

滑动窗口是固定窗口的一种优化,它将窗口改变为一个链表,如将一分钟划分为 6 段,每一段代表十秒钟。每个请求到达时,先检查所有窗口是否过期,如果过期则删除窗口,并且检查最后一个窗口的创建时间,如果窗口超过 10s,那么在其后面写入多个窗口,并在最后一个窗口处写入值。滑动窗口同样适合作为每一个用户的限流算法。

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
--获取KEY
local key = KEYS[1]
--获取ARGV内的参数

-- 缓存时间
local expire = tonumber(ARGV[1])
-- 当前时间
local currentMs = tonumber(ARGV[2])
-- 最大次数
local count = tonumber(ARGV[3])
--窗口开始时间
local windowStartMs = currentMs - expire * 1000;
--获取key的次数
local current = redis.call('zcount', key, windowStartMs, currentMs)

--如果key的次数存在且大于预设值直接返回当前key的次数
if current and tonumber(current) >= count then
return tonumber(current);
end

-- 清除所有过期成员
redis.call("ZREMRANGEBYSCORE", key, 0, windowStartMs);
-- 添加当前成员
redis.call("zadd", key, tostring(currentMs), currentMs);
redis.call("expire", key, expire);

--返回key的次数
return tonumber(current)

桶漏算法

记录每一个请求,并且将其放入缓冲区中,缓冲区具有上限,如果超出缓冲区则直接拒绝。每隔设置的时间间隔,会从缓冲区中选取设置数量的请求进行处理。优点是请求处理非常平滑,缺点是不能够应对突发请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
--- 1 代表成功,0 代表失败
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local acquire = tonumber(ARGV[4])
local water = tonumber(redis.call('hget', KEYS[1] , KEYS[2]) or 0)
local time = tonumber(redis.call('hget', KEYS[1] , KEYS[3]) or now)
water = math.max(0, water - (now - time) * rate)
redis.call('hset' , KEYS[1] ,KEYS[3] , now)
if (water + acquire <= capacity) then
redis.call('hset' , KEYS[1] , KEYS[2] , water + acquire)
return 1
else
return 0
end

令牌桶

桶漏算法的相反操作。每隔一段时间,将名额放入缓冲区中,名额具有上限,每一个请求需要拿到一个名额,否则会被拒绝处理。令牌桶算法允许的瞬时压力是桶上限+ 定时刷新名额,持续压力是定时刷新的名额。允许一定突发压力出现。

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
53
54
55
56
57
58
59
60
61
--- @param key 令牌的唯一标识
--- @param permits 请求令牌数量
--- @param curr_mill_second 当前时间
--- 0 没有令牌桶配置;-1 表示取令牌失败,也就是桶里没有令牌;1 表示取令牌成功
local function acquire(key, permits, curr_mill_second)
local local_key = key --- 令牌桶key ,使用 .. 进行字符串连接
if tonumber(redis.pcall("EXISTS", local_key)) < 1 then --- 未配置令牌桶
return 0
end

--- 令牌桶内数据:
--- last_mill_second 最后一次放入令牌时间
--- curr_permits 当前桶内令牌
--- max_permits 桶内令牌最大数量
--- rate 令牌放置速度
local rate_limit_info = redis.pcall("HMGET", local_key, "last_mill_second", "curr_permits", "max_permits", "rate")
local last_mill_second = rate_limit_info[1]
local curr_permits = tonumber(rate_limit_info[2])
local max_permits = tonumber(rate_limit_info[3])
local rate = rate_limit_info[4]

--- 标识没有配置令牌桶
if type(max_permits) == 'boolean' or max_permits == nil then
return 0
end
--- 若令牌桶参数没有配置,则返回0
if type(rate) == 'boolean' or rate == nil then
return 0
end

local local_curr_permits = max_permits;

--- 令牌桶刚刚创建,上一次获取令牌的毫秒数为空
--- 根据和上一次向桶里添加令牌的时间和当前时间差,触发式往桶里添加令牌,并且更新上一次向桶里添加令牌的时间
--- 如果向桶里添加的令牌数不足一个,则不更新上一次向桶里添加令牌的时间
--- ~=号在Lua脚本的含义就是不等于!=
if (type(last_mill_second) ~= 'boolean' and last_mill_second ~= nil) then
if(curr_mill_second - last_mill_second < 0) then
return -1
end
--- 生成令牌操作
local reverse_permits = math.floor(((curr_mill_second - last_mill_second) / 1000) * rate) --- 最关键代码:根据时间差计算令牌数量并匀速的放入令牌
local expect_curr_permits = reverse_permits + curr_permits;
local_curr_permits = math.min(expect_curr_permits, max_permits); --- 如果期望令牌数大于桶容量,则设为桶容量
--- 大于0表示这段时间产生令牌,则更新最新令牌放入时间
if (reverse_permits > 0) then
redis.pcall("HSET", local_key, "last_mill_second", curr_mill_second)
end
else
redis.pcall("HSET", local_key, "last_mill_second", curr_mill_second)
end
--- 取出令牌操作
local result = -1
if (local_curr_permits - permits >= 0) then
result = 1
redis.pcall("HSET", local_key, "curr_permits", local_curr_permits - permits)
else
redis.pcall("HSET", local_key, "curr_permits", local_curr_permits)
end
return result
end

四种算法对比

  • 固定窗口:不需要定时维护,实现非常简单,单个用户限流

  • 滑动窗口:可以在用户请求线程中维护,精度需求较高的单个用户限流

  • 桶漏算法:一般用于保护数据库等后端系统不会被突发压力击溃。

  • 令牌桶:一般用于统一网关的限流,允许合理范围内应对突发压力。