在使用 Docker 构建 PaaS 平台的过程中,我们首先遇到的问题是需要选择一个满足需求的网络模型:
- 让每个容器拥有自己的网络栈,特别是独立的 IP 地址
- 能够进行跨服务器的容器间通讯,同时不依赖特定的网络设备
- 有访问控制机制,不同应用之间互相隔离,有调用关系的能够通讯
调研了几个主流的网络模型:
- Docker 原生的 Bridge 模型:NAT 机制导致无法使用容器 IP 进行跨服务器通讯(后来发现自定义网桥可以解决通讯问题,但是觉得方案比较复杂)
- Docker 原生的 Host 模型:大家都使用和服务器相同的 IP,端口冲突问题很麻烦
- Weave OVS 等基于隧道的模型:由于是基于隧道的技术,在用户态进行封包解包,性能折损比较大,同时出现问题时网络抓包调试会很蛋疼
在对上述模型都不怎么满意的情况下,发现了一个还不怎么被大家关注的新项目:Project Calico 。
Project Calico 是纯三层的 SDN 实现,它基于 BPG 协议和 Linux 自己的路由转发机制,不依赖特殊硬件,没有使用 NAT 或 Tunnel 等技术。能够方便的部署在物理服务器,虚拟机(如 OpenStack)或者容器环境下。同时它自带的基于 Iptables 的 ACL 管理组件非常灵活,能够满足比较复杂的安全隔离需求。
使用 Calico 来实现 Docker 的跨服务器通讯
环境准备
- 两个 Linux 环境 node1|2(物理机,VM 均可),假定 IP 为:192.168.78.21|22
- 为了简单,请将 node1|2 上的 Iptables INPUT 策略设为 ACCEPT,同时安装 Docker
- 一个可访问的 Etcd 集群(192.168.78.21:2379),Calico 使用其进行数据存放和节点发现
启动 Calico
在 node1|2 上面下载控制脚本:
# wget https://github.com/projectcalico/calico-docker/releases/download/v0.4.9/calicoctl
启动
# export ETCD_AUTHORITY=192.168.78.21:2379
# ./calicoctl node --ip=192.168.78.21|22
docker ps
能看到:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
74cc20b90b0f calico/node:v0.4.9 "/sbin/my_init" 24 seconds ago Up 23 seconds calico-node
部署测试实例
在 Calico 中,有一个 Profile 的概念(类似 AWS 的 Security Group),位于同一个 Profile 中的实例才能互相通讯,所以我们先创建一个名为 db
的 Profile:
在 node1 上执行:
[node1]# ./calicoctl profile add db
然后启动测试实例:
[node1]# export DOCKER_HOST=localhost:2377
[node1]# docker run -n container1 -e CALICO_IP=auto -e CALICO_PROFILE=db -td ubuntu
这里大家注意,我们注入了两个环境变量:CALICO_IP 和 CALICO_PROFILE 。
前者告诉 CALICO 自动进行 IP 分配,后者将此容器加入到 Profile db 中。
那么 Calico 是怎么做到在容器启动的时候分配 IP 的呢?
大家注意我们在 run 一个容器前,先执行了一个 export,这里其实就是将 Docker API 的入口劫持到了 Calico 那里。Calico 内部是一个 twistd 实现的 Python Daemon,转发所有 Docker 的 API 请求给真正的 Docker 服务,如果发现是start
则插入自己的逻辑创建容器的网络栈。
容器启动后我们查看 container1 获取的 IP 地址:
[container1]# ip addr
...
8: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
link/ether 1e:48:3e:ec:71:52 brd ff:ff:ff:ff:ff:ff
inet 192.168.0.1/32 scope global eth1
valid_lft forever preferred_lft forever
我们会看到 eth1 这个网络接口被设置了 IP 192.168.0.1
。
同样在 node2 上面部署 container2。
默认设置下 IP 会在 192.168.0.0/16 中按顺序分配,所以 container2 会是192.168.0.2
。
然后我们就会发现 container1|2 能够互相 ping 通了!
路由实现
接下来让我们看一下在上面的 demo 中,Calico 是如何让不在一个节点上的两个容器互相通讯的:
- Calico 节点启动后会查询 Etcd,和其他 Calico 节点使用 BGP 协议建立连接
[node1]# netstat -anpt | grep 179
tcp 0 0 0.0.0.0:179 0.0.0.0:* LISTEN 21887/bird
tcp 0 0 192.168.78.21:46427 192.168.78.22:179 ESTABLISHED 21887/bird
- 容器启动时,劫持相关 Docker API,进行网络初始化
- 如果没有指定 IP,则查询 Etcd 自动分配一个可用 IP
- 创建一对 veth 接口用于容器和主机间通讯,设置好容器内的 IP 后,打开 IP 转发
- 在主机路由表添加指向此接口的路由
主机上:
[node1]# ip link show
...
7: cali2466cece7bc: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT qlen 1000
link/ether 96:c4:86:4d:d7:2c brd ff:ff:ff:ff:ff:ff
容器内:
[container1]# ip addr
...
8: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
link/ether 1e:48:3e:ec:71:52 brd ff:ff:ff:ff:ff:ff
inet 192.168.0.1/32 scope global eth1
valid_lft forever preferred_lft forever
主机路由表:
[node1]# ip route
...
192.168.0.1 dev cali2466cece7bc scope link
- 然后将此路由通过 BGP 协议广播给其他所有节点,在两个节点上的路由表最终是这样的:
[node1]# ip route
...
192.168.0.1 dev cali2466cece7bc scope link
192.168.0.2 via 192.168.78.22 dev enp0s8 proto bird
[node2]# ip route
...
192.168.0.1 via 192.168.78.21 dev enp0s8 proto bird
192.168.0.2 dev caliea3aaf5a7be scope link
大家看这个路由,node2 上面的 container2 要访问 container1(192.168.0.1),通过查路由表得知需要将包转给 192.168.78.21,也就是 node1。形象的展示数据流向是这样的:
container2[eth1] -> node2[caliea3aaf5a7be] -> route -> node1[cali2466cece7bc] -> container1[eth1]
至此,跨节点通讯打通,整个流程没有任何 NAT,Tunnel 封包。所以只要三层可达的环境,就可以应用 Calico。
利用 Profile 实现 ACL
在之前的 demo 中我们提到了 Profile,Calico 每个 Profile 都自带一个规则集,用于对 ACL 进行精细控制,如刚刚的db
的默认规则集是:
[node1]# ./calicoctl profile db rule json
{
"id": "db",
"inbound_rules": [
{
"action": "allow",
"src_tag": "db"
}
],
"outbound_rules": [
{
"action": "allow"
}
]
}
这个规则集表示入连接只允许来自 Profile 名字是 db
的实例,出连接不限制,最后隐含了一条默认策略是不匹配的全部 drop,所以同时位于不同 Profile 的实例互相是不能通讯的,这就解决了隔离的需求。
下面是一个更复杂的例子:
在常见的网站架构中,一般是前端 WebServer 将请求反向代理给后端的 APP 服务,服务调用后端的 DB:
WEB -> APP -> DB
所以我们要实现:
WEB
暴露 80 和 443 端口APP
允许WEB
访问DB
允许APP
访问 3306 端口- 除此之外,禁止所有跨服务访问
那么我们就可以如此构建 json:
对于 WEB
[node1]# cat web-rule.json
{
"id": "web",
"inbound_rules": [
{
"action": "allow",
"src_tag": "web"
},
{
"action": "allow",
"protocol": "tcp",
"dst_ports": [
80,
443
]
}
],
"outbound_rules": [
{
"action": "allow"
}
]
}
[node1]# ./calicoctl profile web rule update < web-rule.json
入站规则我们增加了一条允许 80 443
对于 APP
[node1]# cat app-rule.json
{
"id": "app",
"inbound_rules": [
{
"action": "allow",
"src_tag": "app"
},
{
"action": "allow",
"src_tag": "web"
}
],
"outbound_rules": [
{
"action": "allow"
}
]
}
[node1]# ./calicoctl profile app rule update < app-rule.json
对于后端服务,我们只允许来自 web 的连接。
对于 DB
,我们在只允许 APP 访问的基础上还限制了只能连接 3306。
[node1]# cat db-rule.json
{
"id": "db",
"inbound_rules": [
{
"action": "allow",
"src_tag": "db"
},
{
"action": "allow",
"src_tag": "APP",
"protocol": "tcp",
"dst_ports": [
3306
]
}
],
"outbound_rules": [
{
"action": "allow"
}
]
}
[node1]# ./calicoctl profile db rule update < db-rule.json
很简单的几条规则,我们就实现了上述需求。
Profile 高级特性:Tag
有同学可能说,在现实环境中,会有多组不同的 APP 都需要访问 DB,如果每个 APP 都在 db 中增加一条规则也很麻烦同时还容易出错。
这里我们可以利用Profile 的高级特性 Tag 来简化操作:
- 每个 Profile 默认拥有一个和 Profile 名字相同的 Tag
- 每个 Profile 可以有多个 Tag,以 List 形式保存
利用 Tag 我们可以将一条规则适配到指定的一组 Profile 上。
参照上面的例子,我们给所有需要访问 DB 的 APP 的 Profile 都加上 db-users
这个 Tag:
[node1]# ./calicoctl profile app1 tag add db-users
[node1]# ./calicoctl profile app2 tag add db-users
[node1]# ./calicoctl profile app3 tag add db-users
...
然后修改 db-rule.json 为:
{
"id": "db",
"inbound_rules": [
{
"action": "allow",
"src_tag": "db"
},
{
"action": "allow",
"src_tag": "db-users",
"protocol": "tcp",
"dst_ports": [
3306
]
}
],
"outbound_rules": [
{
"action": "allow"
}
]
}
将之前的 src_tag: app 替换为 src_tag: db-users。这样所有打了 db-user 这个 Tag 的实例就都能访问数据库了。
Profile 的实现
Profile 的实现基于 Iptables 和 IPSet。我们以刚刚的 db
规则集中 inbound
部分为例:
Calico 在启动后会在 Iptables 中新建一些 Chain,数据包会在不同的 Chain 之间跳转,下面我截取了一些关键的规则列表:
[node1]# iptables -n -L -v
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
target prot in out source destination
felix-FORWARD all * * 0.0.0.0/0 0.0.0.0/0
Chain felix-FORWARD (1 references)
target prot in out source destination
felix-TO-ENDPOINT all * cali+ 0.0.0.0/0 0.0.0.0/0
Chain felix-TO-ENDPOINT (1 references)
target prot in out source destination
felix-to-2466cece7bc all * cali2466cece7bc 0.0.0.0/0 0.0.0.0/0 [goto]
Chain felix-to-2466cece7bc (1 references)
target prot in out source destination
felix-p-db-i all * * 0.0.0.0/0 0.0.0.0/0
Chain felix-p-db-i (2 references)
target prot in out source destination
RETURN all * * 0.0.0.0/0 0.0.0.0/0 match-set felix-v4-db src
RETURN tcp * * 0.0.0.0/0 0.0.0.0/0 match-set felix-v4-db-users src multiport dports 3306
这个略复杂,我们慢慢看。基本上数据包是从上到下一步步跳转的。
当发给 container1
的数据包到达 node1
后,由于目标 IP 192.168.0.1
和 node1
自身 IP 不同,会被放入FORWARD
链,然后跳转到felix-FORWARD
,通过查询路由表:
192.168.0.1 dev cali2466cece7bc scope link
得知下一跳接口为 cali2466cece7bc
,于是先跳转到felix-TO-ENDPOINT
,再跳转到 felix-to-2466cece7bc
。
在这里,定义了具体的 ACL 列表,felix-p-db-i
,这个 db
是不是很眼熟?
对,就是这个 container 所属 Profile 的名字,而 felix-p-db-i
中就是 Profiledb
的 inbound 规则集。而 felix-p-db-i 的内容:
match-set felix-v4-db src
match-set felix-v4-db-users src multiport dports 3306
felix-v4-db
和 felix-v4-db-users
是不是也很熟悉?
在 db
规则集中的两个 Tag 在这里加了个前缀变成了 IPSet,它包括了所有打了这个 Tag 的 IP 列表:
[node1]# ipset list
...
Name: felix-v4-db
Type: hash:ip
Revision: 1
Header: family inet hashsize 1024 maxelem 65536
Size in memory: 16576
References: 1
Members:
192.168.0.1
192.168.0.2
至此,ACL 部分分析完毕。可见 Calico 灵活运用了 Iptables 的种种高级特性。
性能测试
来自官方测试结果
测试环境
- 8 core CPU
- 64GiB RAM
- 10Gb 网卡直连
- Ubuntu 14.04.2 with 3.13 Kernel(3.10 版本的 Kernel 修复了一些 veth 性能的问题)
- 没有额外的内核参数调优
测试了四种场景:
- 物理服务器(基准)
- 部署了 Calico 的容器之间
- 部署了 Calico 的 OpenStack VM 之间
- 部署了 OVS with VxLAN 的 OpenStack VM 之间
测试了两种数据大小:
- 20000 byte
- 500 byte
吞吐量 & CPU 使用率测试
吞吐量极限
同一时刻 CPU 的使用率
把它们合并成一张图就是每 Gb/s 的 CPU 使用率
可以看到,部署了 Calico 的两个场景都非常贴近物理服务器的性能。
延迟测试
测试方法是:节点间交换 1 byte 数据包
这个结果显示,Calico 容器非常接近物理服务器,而 OpenStack 场景由于网络虚拟化的缘故延迟稍大。
结论
测试结果表明,Calico 的性能非常接近物理服务器,比基于隧道的 OVS 性能好很多。
Calico 的发展
Calico 和 Docker 一样是很年轻的项目,但是坑比后者少多了,我遇到了一些,如docker inspect
没有显示 Calico 分配的 IP,BGP 客户端重启姿势不正确导致路由周期性消失重建等等。但是他们的开发进度非常快,一个 issue 提出来到修复可能就一两天时间(再次鄙视 Docker)。
目前唯一一个比较麻烦的问题是,Calico 这种劫持 Docker API 的方式,容器的网络栈是在容器启动后才进行初始化,所以在头几秒其实是没有网络可用的,这会导致那些启动就要访问网络的容器挂掉。解决方案有两个:
- 升级 Docker 到支持 libnetwork 的版本,Calico 在新版本(>0.5)中支持了 libnetwork,理论上能够解决这个问题。但是代价要踩新版本 Docker 带来的更多的坑。
- 自定义容器的 CMD,实现一个 entry 脚本,待网络可用后再 exec 载入真正的进程。
Q&A
Q:自定义容器的 CMD,实现一个 entry 脚本,待网络可用后再 exec 载入真正的进程。 有没有具体的?
主要实现方式就是 Dockerfile 中的 CMD 可以这个样子: /entry.sh your-cmd
。这个 entry.sh 中判断 IP 是否已经分配好,如果没有就 sleep 重试。分配好后再用 exec 载入后面的 your-cmd 。
Q:是否每增加一个容器宿主机就需要增加一条路由?如果容器数量很多会有问题吗?
是的。关于这个问题我咨询过开发团队,他们表示压测过单机 10 万条路由,没有问题。同时将来会推出路由段的广播机制,即:每台服务器使用一小段,之间只需要广播此段即可。
Q:如果要做容器间通讯限速,Calico能做吗?
由于每个由 Calico 管理的容器在宿主机上面都有一个唯一的网络接口(veth 的一端),通过限制此接口流量即可进行限速。Calico 官方没有提供这个功能,我们可以用常规的其他手段解决。同时这个接口还有一个好处就是非常容易做容器的流量监控,只要看接口计数器即可。
Q:Calico 和 Kubernetes 的整合你们尝试过吗?Calico只是接管了容器间的通信,和 Kubernetes 的 Service Cluster IP 没有关系吧?
我们没有使用 Kubernetes 的解决方案,而是自行开发的调度编排等组件。Cailco 官方是支持的并且有相关文档可以参考。具体请参考:https://github.com/projectcalico/calico-docker/blob/master/docs/kubernetes/README.md