0%

容器,是一种沙盒技术。它本质上是宿主机上的一个普通的进程,但是使用了 Namespace 实现了资源隔离,使用 Cgroups 实现了对进程可用资源的限制。这样,进程之间因为有了边界和限制而能够避免相互干扰,并且能利用容器镜像,能够实现容器的“搬运”。在网络上,容器使用 Network Namespace 实现对网络资源的隔离,被隔离的进程只能看到当前 Namespace 里的网络栈和配置。

所谓“网络栈”,包括了:网卡(Network Interface)、回环设备(Loopback Device)、路由表(Routing Table)和 iptables 规则。对于进程来说,这些组件构成了它发出和响应网络请求的基础环境。虽然可以容器可以在启动的时候通过传入 -net=host 方式直接使用宿主机的网络栈,即不开启 Network Namespace,这样虽然可以提供良好的性能,但是却要需要提前规划好每个容器监听的端口号,否则存在端口冲突的风险。所以,大多数时候,我们希望容器尽可能使用自己的 Network Namespace 中的网络栈,拥有自己的 IP 地址和端口号。

这样做自然就使我们面临一个问题:被隔离的进程,如何与另外一个 Network Namespace 中的进程通信呢?

同主机容器间通信

为了理解不同容器的通信问题,我们可以想象,对于多台宿主机,使它们通信的最直接的方法就是使用网线或交换机将它们连接。在 Linux 中,网桥(Bridge)能够实现虚拟交换机的作用。网桥工作在数据链路层(Data Link),可以根据 MAC 地址学习并将数据包发送到网桥不同的端口上。在 Docker 项目中,会默认在宿主机上创建一个名为 docker0 的网桥,同宿主机上的容器可以通过连接到网桥而实现通信。

而将容器连接到网桥的方式,是使用一种名叫 Veth Pair 的虚拟设备。Veth Pair 的特点是,它被创建出来后,总是以两张虚拟网卡(Veth Peer)的形式成对出现。并且,从其中一张“网卡”发出的数据包会直接出现在与之对应的另一张“网卡”上,即使它们在不同的 Network Namespace 中。因为有这样的特点,Veth Pair 通常被当做虚拟的“网线”,用于连接不同的 Network Namespace。

这样,同一个宿主机上的不同容器通过 docker0 网桥通信的流程就可以用下图表示:

我们进入 Container1 容器:

1
docker exec -it Container sh

查看它的网络设备:

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
# 在容器中
root:/ ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.17.0.2 netmask 255.255.0.0 broadcast 0.0.0.0
inet6 fe80::42:acff:fe11:2 prefixlen 64 scopeid 0x20<link>
ether 02:42:ac:11:00:02 txqueuelen 0 (Ethernet)
RX packets 364 bytes 8137175 (7.7 MiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 281 bytes 21161 (20.6 KiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
inet 127.0.0.1 netmask 255.0.0.0
inet6 ::1 prefixlen 128 scopeid 0x10<host>
loop txqueuelen 1000 (Local Loopback)
RX packets 0 bytes 0 (0.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 0 bytes 0 (0.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

$ route
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
default 172.17.0.1 0.0.0.0 UG 0 0 0 eth0
172.17.0.0 0.0.0.0 255.255.0.0 U 0 0 0 eth0

可以看到,这个容器里有一张 eth0 网卡,它正是一个 Veth Pair 设备在容器里这一段。通过 route 命令查看 Container1 容器的路由表,可以看到这个 eth0 网卡是这个容器的默认路由设备:所有对 172.17.0.0/16 网段的请求,都会交给 eth0 来处理。而这个 Veth Pair 的另一端则在宿主机上,可以通过查看宿主机的网络设备看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 在宿主机上
$ ifconfig
docker0 Link encap:Ethernet HWaddr 02:42:d8:e4:df:c1
inet addr:172.17.0.1 Bcast:0.0.0.0 Mask:255.255.0.0
inet6 addr: fe80::42:d8ff:fee4:dfc1/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:309 errors:0 dropped:0 overruns:0 frame:0
TX packets:372 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0
RX bytes:18944 (18.9 KB) TX bytes:8137789 (8.1 MB)
veth9c02e56 Link encap:Ethernet HWaddr 52:81:0b:24:3d:da
inet6 addr: fe80::5081:bff:fe24:3dda/64 Scope:Link
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:288 errors:0 dropped:0 overruns:0 frame:0
TX packets:371 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0
RX bytes:21608 (21.6 KB) TX bytes:8137719 (8.1 MB)

$ brctl show
bridge name bridge id STP enabled interfaces
docker0 8000.0242d8e4dfc1 no veth9c02e56

可以看到 Container1 对应的 Veth Pair 设备,在宿主机上是一张虚拟网卡,它的名字是 veth9c02e56。而且通过 brctl 的输出,可以看到该网卡被插在 docker0 上。同样的, Container2 容器对应的 Veth Pair 的另一端也以相同的方式插在 docker0 网桥上。这是如果在 Container1 容器里 ping 一下 Container2 的 IP(172.17.0.3),就可以发现同一宿主机上的两个容器默认相互连通。

这样同宿主机上两个容器的通信过程为:当在 Container1 中访问 Container2 的 IP 地址是,172.17.0.3 这个目的地址会匹配到 Container1 容器中的第二条路由规则,该规则的网管是 0.0.0.0,意味着是一条直连规则,即:凡是匹配到这条规则的 IP 包,都应该经过 eth0 网卡,通过二层网络直接发往目的主机。

而要通过二层网络抵达 Container2 容器,就需要 172.17.0.3 这个 IP 对应的 MAC 地址。那么,Container1 的网络协议栈,就需要通过 eth0 网卡发送一个 ARP(Address Resolution Protocol) 广播,通过 IP 地址查到对应的 MAC 地址。这个 eth0 网卡是一个 Veth Pair,它的一端在该容器的 Network Namespace 里,而另一端位于宿主机上,并被插在宿主机的 docker0 网桥上,成功为了网桥的“从设备”。这时,网卡降级为网桥的一个端口,它唯一的作用就是接受流入的数据包,交给网桥。

在收到 ARP 请求后,docker0 就扮演二层交换机的角色,把 ARP 广播到其他被插在 docker0 上的虚拟网卡上。这样同样连接在 docker0 上的 Container2 容器的网络协议栈就会收到这个 ARP 请求,并返回对应的 MAC 地址给 Container1。有了这个目的 MAC 地址,Container1 容器的 eth0 网卡就可以将数据包发出去。该数据包会立刻直接流入 docker0 网桥,网桥继续扮演二层交换机的角色,根据数据包的目的 MAC 地址,在它的 CAM 表中查到对应的端口,并发送数据包。这样,数据包就通过 Container2 的 Veth Pair 进入了新的容器。所以 Container2 容器看到的情况是自己的 eth0 网卡出现了流入的数据包,这样 Container2 就会对请求进行处理,并返回响应。

所以,被限制在 Network Namespace 中的容器进程,实际上是通过 Veth Pair 设备 + 宿主机网桥的方式,实现了与其他容器的数据交换。当用户在宿主机上访问容器的 IP 地址时,请求也是先到达 docker0 网桥,然后被转发到对应的 Veth Pair 设备,最后出现在容器里。

同样的,当容器驶入连接到另外一个宿主机是,如 ping 10.168.0.3,它的请求先经过 docker0 出现在宿主机上,然后根据宿主机的路由表的直连规则将请求交给宿主机的 eth0 处理。接下来,这个数据包经宿主机的网络到达 10.168.0.3 对应的宿主机上,当然,这要求两台宿主机是连通的。

跨主机容器间通信

在了解了同一宿主机下不同容器的通信方式后,我们自然要思考,不同宿主机的容器要如何通信呢?这就是“跨主机通信问题”。

在 Docker 的默认配置下,一台宿主机的 docker0 网桥和其他宿主机上的 docker0 网桥是没有关联的,它们之间也没有办法连通。所以不同宿主机的容器之间自然也就无法通信了。

不过,我们能够通过软件方式,创建一个整个集群公用的网桥,然后将集群中所有的容器都连接到这个网桥上。这种在已有的宿主机网络上,通过软件构建一个覆盖的、可以把所有容器连通在一起的虚拟网络,被称为 Overlay Network(覆盖网络)。其结构如下图:

这个 Overlay Network 本身,可以由每台宿主机上的一个特殊网桥共同组成。比如,当 Node1 上的 Container1 要访问 Node2 上 Container2 时,Node1 上的特殊网桥在收到数据包后,可以将数据包发送到正确的宿主机;而 Node2 上的特殊网桥在收到数据包后,也能通过某种方式将数据包转发给正确的容器。甚至,每台宿主机上,都不需要有这样一个特殊的网桥,而仅仅通过某种方式,将数据包转发给正确的容器。甚至,不需要特殊网桥,仅仅通过配置路由表就能转发到正确的宿主机上。

目前,社区中存在多种为解决“跨主机通信”问题而出现的容器网络方案。为了理解其原理,我们从 Flannel 这个项目说起。Flannel 项目是 CoreOS 主推的容器网络方案。该项目本身只是一个框架,真正为我们提供容器网络功能的,是 Flannel 的后端实现。Flannel 支持三种后端实现,分别是:

  1. UDP
  2. VXLAN
  3. host-gw

Flannel 的 UDP 实现

假设现在有两台宿主机:

  • 宿主机 Node1 上有一个容器 Container1,它的 IP 地址是 100.96.1.2,网桥地址是 100.96.1.1/24;
  • 宿主机 Node2 上有一个容器 Container2,它的 IP 地址是 100.96.2.3,网桥地址是 100.96.2.1/24;

现在我们希望 Container1 能够访问 Container2。

这时,Container1 容器里的进程发起的 IP 包,其源地址是 100.96.1.2,目的地址是 100.96.2.3。由于目的地址 100.96.2.3 并不在 Node1 的 docker0 网桥的网段,所以这个 IP 包会被交给默认路由规则,通过容器的网关进入 docker0 网桥从而出现宿主机上。这时候,这个 IP 包的下一个目的地,就取决于宿主机上的路由规则。此时,Flannel 已提前在宿主机创建了一系列的路由规则,在 Node1 上,规则如下所示:

1
2
3
4
5
6
# 在 Node1 上
$ ip route
default via 10.168.0.1 dev eth0
100.96.0.0/16 dev flannel0 proto kernel scope link src 100.96.1.0
100.96.1.0/24 dev docker0 proto kernel scope link src 100.96.1.1
10.168.0.0/24 dev eth0 proto kernel scope link src 10.168.0.2

由于 IP 包的目的地址是 100.96.2.3,它匹配不到本机 docker0 网桥对应的 100.96.1.0/24,只能匹配到第二条 100.96.0.0/16 对应的这条路由规则,从而进入到一个叫做 flannel0 的设备中。而这个 flannel0 是一个 TUN 设备(Tunnel 设备)。TUN 设备是一种工作在三层(Network Layer)的虚拟设备。它的功能就是在操作系统内核和用户程序之间传递 IP 包。

对于 flannel0,当操作系统将一个 IP 包发送给 flannel0 设备之后,flannel0 就会把这个 IP 包交给创建这个设备的应用程序,即 Flannel 进程。这个过程中, IP 包从内核态流向了用户态。反之,如果 Flannel 进程想 flannel0 设备发送一个 IP 包,那么这个 IP 包就会出现在宿主机网络栈中,然后根据路由表进行下一步处理。这时, IP 包由用户态流向内核态。

这样,当 IP 包从容器经过 docker0 出现在宿主机,然后又根据路由表进入 flannel0 设备后,宿主机上的 flanneld 进程,就会收到这个 IP 包。根据这个 IP 包的目的地址,将它发送给 Node2 宿主机。那么,flanneld 是如何知道该 IP 地址对应的容器在 Node2 上面呢?

Flannel 项目中子网(Subnet)的概念非常重要。由 Flannel 管理的容器网络中,一台宿主机上面的所有容器,都属于该宿主机被分配的一个 “子网”。在上面的例子汇总, Node1 的子网是 100.96.1.0/24,Container1 的 IP 地址是 100.96.1.2。Node2 的子网是 100.96.2.0/24,Container2 的 IP 地址是 100.96.2.3。而这些子网与宿主机的对应关系都保存在 Etcd 中:

1
2
3
4
$ etcdctl ls /coreos.com/network/subnets
/coreos.com/network/subnets/100.96.1.0-24
/coreos.com/network/subnets/100.96.2.0-24
/coreos.com/network/subnets/100.96.3.0-24

当 flanneld 进程在处理由 flannel0 传入的 IP 包时,就可以根据目的 IP 的地址,匹配到对应的子网,从 Etcd 中找到这个子网对应的宿主机的 IP 地址是 10.169.0.3:

1
2
$ etcdctl get /coreos.com/network/subnets/100.96.2.0-24
{"PublicIP":"10.168.0.3"}

而对于 flanneld 来说,只要 Node1 和 Node2 是互通的,那么 flanneld 作为 Node1 上的一个普通进程,就一定可以通过上述 IP 地址访问到 Node2。所以,flanneld 在收到 Container1 发送给 Container2 的 IP 包后,就会把这个 IP 包直接封装在一个 UDP 包中,然后发送给 Node2。UDP 包的源地址是 Node1 的地址,目的地址是 Node2 的地址。这样通过一个 UDP 通信,一个 UDP 包就由 Node1 到达了 Node2。Node2 上的 flanneld 就会从 UDP 包中解析出封装在里面的 Container1 发出的原 IP 包。接下来,就如同虚机上的容器网络,IP 包经 docker0 网桥通过 Veth Pair 设备进入到 Container2 的 Network Namespace 中。需要主机的是,上述流程还有一个重要的前提就是 docker0 网桥的地址范围必须是 Flannel 为宿主机分配的子网。以上就是 Flannel UDP 模式的跨主机通信的原理,可以用下图来表示:

可以看到,Flannel UDP 模式提供的其实是一个三层的 Overlay 网络,即:它首先对发出端的 IP 包进行 UDP 封装,然后在接收端进行解封装拿到原始的 IP 包,进而把这个 IP 包转发给目标容器。这就像 Flannel 在不同宿主机上的两个容器之间打通了一条“隧道”,使得两个容器可以直接使用 IP 地址进行通信,而无需关心容器和宿主机的分布情况。

目前,Flannel UDP 方式已经被废弃了,因为它存在严重的性能问题。相比于两台宿主机之间的直接通信,基于 Flannel UDP 模式的容器通信多了一个额外的步骤,即 flanneld 的处理过程。而这个过程,由于使用了 flannel0 这个 TUN 设备,仅在发出 IP 包的过程中就需要经过三次用户态与内核态之间的数据拷贝:

  1. 用户态的容器进程发出的 IP 包经过 docker0 网桥进入内核态;
  2. IP 包根据路由表进入 TUN(flannel0)设备,从而回到用户态的 flanneld 进程;
  3. flanneld 进行 UDP 封包之后重新进入内核态,将 UDP 包通过宿主机的 eth0 发出去;

另外 Flannel 进行 UDP 封装和解封装的过程也都是在用户态完成的。在 Linux 操作系统中,上述这些上下文切换和用户态操作的代价其实都是比较高的,这也是 Flannel UDP 性能不好的原因。所以,在进行系统级编程的时候,一个非常重要的优化原则,就是要减少用户态到内核态的切换次数,并且把核心的处理逻辑都放在内核态进行。正因为此,Flannel 支持的 VXLAN 模式逐渐成为了主流的容器网络方案。

Flannel 的 VXLAN 实现

VXLAN,即 Virtual Extensible LAN(虚拟可拓展局域网),是 Linux 内核本身就支持的网络虚拟化技术。所以 VXLAN 可以完全在内核态实现上述封装和解封装的工作,从而通过与前面相似的“隧道”机制,构建出 Overlay 网络。

VXLAN 的覆盖网络的设计思想是:在现有的三层网络上,“覆盖”一层虚拟的、由内核 VXLAN 模块维护的二层网络,使得连接在这个 VXLAN 二层网络上的主机之前可以像在一个局域网中那样自由通信。当然,实际上这些“主机”可能分布在不同的宿主机上,甚至是分布在不同的物理机房中。

为了在二层网络上打通“隧道”,VXLAN 在宿主机上设置一个特殊的网络设备作为隧道的两端,该设备叫做 VTEP,即 VXLAN Tunnel End Point。VTEP 设备的作用其实跟前面 flanneld 进程非常相似。只不过,它进行封装和解封装的对象,是二层数据帧(Ethernet frame);而且这个工作的执行流程,全部是在内核中完成的。所以,基于 VTEP 设备进行隧道通信的流程,可以用下图标识:

可以看到,每台宿主机上名为 flannel.1 的设备,就是 VXLAN 所需的 VTEP 设备,它既有 IP 地址,也有 MAC 地址。与 UDP 模式的流程类似,当 Container1 发出请求之后,这个目的地址是 10.1.16.3 的 IP 包,会先出现在 docker0 网桥,然后被路由到本机的 flannel.1 设备进行处理。也就是说,来到了“隧道”的入口。为了能将“原始 IP 包”封装并且发送到正确的宿主机,VXLAN 就需要找到这条“隧道”的出口,即:目的宿主机的 VTEP 设备。而这个设备的信息,正是每台宿主机上的 flanneld 进程维护的。VTEP 设备之间需要想办法组成一个虚拟的二层网络,即:通过二层数据帧进行通信。所以“源 VTEP 设备”收到“原始 IP 包”后,就要想办法把“原始 IP 包”加上一个目的 MAC 地址,封装成一个二层数据帧,然后发送给“目的 VTEP 设备”。“目的 VTEP 设备”的 MAC 地址由 ARP 表记录,而这里的 ARP 记录不是通过发送 ARP 请求获取,而是有 flanneld 在 Node2 节点启动时,自动添加在 Node1 上的。有了这个“目的 VTEP 设备”的 MAC 地址,Linux 内核就可以开始二层封包工作了。

Linux 内核会把“目的 VTEP 设备”的 MAC 地址,填写在图中额 Inner Ethernet Header 字段,二道一个二层数据帧。这些 VTEP 设备的 MAC 地址,对于宿主机来说并没有什么实际意义。所以上面封装的这个数据帧,并不能在宿主机的二层网络中传输,所以,我们将它称为“内部数据帧”。所以接下来,Linux 还需要把“内部数据帧”进一步封装称为宿主机网络里的一个普通的数据帧,好让它承载“内部数据帧”通过宿主机的 eth0 网卡进行传输。我们把这次要封装出来的、宿主机对应的数据帧称为“外部数据帧”。

Linux 内核还会在内部数据帧前面,添加一个特殊的 VXLAN 头,用来表示一个 VXLAN 要使用的数据帧。VXLAN 头里面有个重要的标志叫做 VNI,它是 VTEP 设备识别某个数据帧是不是应该归属于自己处理的标志。Flannel 中,VNI 默认都是 1。然后,Linux 内核会把这个数据帧封装进一个 UDP 包里发出去。所以,在宿主机看来,它只是向另外一台宿主机的 flannel.1 设备,发起了一次普通的 UDP 链接。

不过,一个 flannel.1 设备只知道另一端的 flannel.1 设备的 MAC 地址,却不知道对应的宿主机地址。这种场景下,flannel.1 设备其实要扮演一个“网桥”的角色,在二层网络进行 UDP 包的转发。而 Linux 内核中,“网桥”设备进行转发的一句,来自 FDB 转发数据库,其中的信息也有由 flanneld 进程负责维护。

所以接下来就是一个正常的宿主机网络的封包工作。Linux 在它前面加上一个 IP 头,组成一个 IP 包。在 IP 头里,会填上查询得到的目的主机的 IP 地址。然后,Linux 内核再在这个 IP 包前面再加上二层数据帧头,并填入 Node2 的 MAC 地址。这个 MAC 地址本身是 Node1 的 ARP 表要学习的内容,无需 flannel 维护。接下来,Node 1 上的 flannel.1 设备就可以把这个数据帧从 eth0 网卡发出去。

以上就是 Flannel VXLAN 的工作原理了。

概念

对于在生产或测试环境使用过 Kubernetes,或者对 Kubernetes 有基本了解的用户,Deployment 一定不陌生。Deployment 为 PodReplicaSets 提供了声明式管理的功能,每一个 Deployment 对应集群中的一次部署。用户可以利用 Yaml 或 JSON 文件描述的期望状态,Deployment Controller 将会以一定的速度将实际状态调整为与期望状态相同。

本文主要介绍 Deployment 的工作原理,并从源码角度分析它如何实现水平扩缩容、回滚、滚动更新等功能。

工作原理

Deployment 是 Kubernetes 中最常见,也是最常用的 API 对象。上一篇文章中,介绍了 Kubernetes 的重要编程模型:控制器模型。Deployment Controller 即通过控制器模型为 Deployment 对象实现了声明式 API。

Deployment 看似简单,但是它实现了 Kubernetes 中非常重要的功能呢:Pod 的“水平扩展/收缩”(Horizontal scaling out/in)。而这个功能是从 PaaS 时代开始,任何一个平台级项目都必须具备的编排能力。而这个能力的实现,又依赖 Kubernetes 中的另一个重要的 API 对象:ReplicaSet。ReplicaSet 的 Yaml 非常简单,包含一个对象元数据、副本数定义和一个 Pod 模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: nginx-rs
labels:
app: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:stable

不难发现 ReplicaSet 实际上是 Deployment 的一个子集。实际上,Deployment 直接管理的并不是 Pod,而是 ReplicaSet 对象。所以,对于一个 Deployment 所管理的 Pod,它的 ownerReference 其实是 ReplicaSet。

以一个副本数为 4 的 Deployment 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deploy
labels:
app: nginx
spec:
replicas: 4
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:stable
ports:
- containerPort: 80

在实际上,Deployment、ReplicaSet、Pod 之间的关系是:

由上图可以看出,副本数是 4 的 Deployment,与 ReplicaSet,以及 Pod 是一种层层控制关系。其中 ReplicaSet 通过控制器,保证集群中 Pod 的个数永远等于指定的个数。在此基础上,Deployment 同样通过控制器模式,来操作 ReplicaSet 的个数和属性,进而实现“水平拓展/收缩”及“滚动更新”两个编排动作。其中“水平拓展/收缩”较为简单,Deployment Controller 只要修改它控制的 ReplicaSet 的副本数,ReplicaSet Controller 则根据期望的副本数新建或删除 Pod。

下面通过一个例子来解释“滚动更新”。首先,我们创建一个 nginx-deployment

1
kubectl create -f nginx-deploy.yaml --record

然后,检查创建后的状态:

1
2
3
4

$ kubectl get deployments
NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
nginx-deployment 4 0 0 0 1s
  1. DESIRED:表示期望的 Pod 个数;
  2. CURRENT:当前 Running 状态 Pod 个数;
  3. UP-TO-DATE:处于最新版本 Pod 个数;
  4. AVAILABLE:当前已经可用的 Pod 个数,即既是 Running,又是最新版本,并且健康检查已为 Ready 的 Pod 个数;

待全部 Pod 均处于 AVAILABLE 状态后,可以再查询 Deployment 所控制的 ReplicaSet:

1
2
3
4

$ kubectl get rs
NAME DESIRED CURRENT READY AGE
nginx-deployment-3167673210 4 4 4 20s

可以看到,创建 Deployment 后,Deployment Controller 就会立即创建一个 Pod 副本数为 4 的 ReplicaSet。而 ReplicaSet 的名字,则是由 Deployment 的名字和一个随机字符串组成。Deployment 的状态是在 ReplicaSet 的基础上,添加了和版本相关的 UP-TO-DATE 字段。

这时候,如果修改 Deployment 的 Pod 模板,就会触发“滚动更新”。当使用 kubectl edit deploymentkubectl apply 提交新的 yaml 后,可以通过查看 Deployment 的 Events,看到这个“滚动更新”的流程:

1
2
3
4
5
6
7
8
9
10
11
12
13

$ kubectl describe deployment nginx-deployment
...
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
...
Normal ScalingReplicaSet 24s deployment-controller Scaled up replica set nginx-deployment-1764197365 to 1
Normal ScalingReplicaSet 22s deployment-controller Scaled down replica set nginx-deployment-3167673210 to 2
Normal ScalingReplicaSet 22s deployment-controller Scaled up replica set nginx-deployment-1764197365 to 2
Normal ScalingReplicaSet 19s deployment-controller Scaled down replica set nginx-deployment-3167673210 to 1
Normal ScalingReplicaSet 19s deployment-controller Scaled up replica set nginx-deployment-1764197365 to 3
Normal ScalingReplicaSet 14s deployment-controller Scaled down replica set nginx-deployment-3167673210 to 0

可以看到当修改 PodTemplate 后,Deployment Controller 会使用这个新的 PodTemplate 创建一个新的 ReplicaSet,它初始的副本数是 0。然后 Deployment Controller 开始将新的 ReplicaSet 所控制的 Pod 副本数由 0 个变成 1 个,即:“水平拓展”出一个副本;接着又将旧的 ReplicaSet 所控制的 Pod 副本数减少一个,即:“水平收缩”。如此交替进行,最后新的 ReplicaSet 所管理的 Pod 个数上升为 4,而旧的 ReplicaSet 所管理的 Pod 个数收缩为 0 个。

这样,将集群中正在运行的多个 Pod 版本,交替逐一升级更换的过程就是“滚动更新”。更新结束后,可以查看新、旧两个 ReplicaSet 的状态:

1
2
3
4
5

$ kubectl get rs
NAME DESIRED CURRENT READY AGE
nginx-deployment-1764197365 4 4 4 6s
nginx-deployment-3167673210 0 0 0 30s

滚动更新有很明显的好处。比如当新的 Pod 因为故障无法启动时,“滚动更新”就会停止,允许开发者介入。而此时应用本身还是有两个旧版本的 Pod 在线,所以服务不会受到太大的影响。当然这也要求开发者一定要使用 Health Check 检查应用的运行状态,而不是依赖于容器的 Running 状态。此外,Deployment Controller 还会保证在任何时间窗口内,只有指定比例的新 Pod 被创建,及指定比例的旧 Pod 处于离线状态。这两个比例的默认值均为 25%,且可以在 spec.rollingUpdateStrategy 中配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
spec:
...
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%

结合“滚动更新”,Deployment、ReplicaSet、Pod 的关系图为:

如图中所示,Deployment Controller 实际上控制 ReplicaSet 的数量,以及每个 ReplicaSet 的属性。而应用的一个版本对应的正是一个 ReplicaSet,这个版本的 Pod 数量则由 ReplicaSet Controller 保证。通过多个 ReplicaSet,Kubernetes 就实现了对多个“应用版本”的描述。

当在滚动更新,发现新版本不符合预期,需要回滚到旧版本的时候,可以执行:

1
2
3

$ kubectl rollout undo deployment/nginx-deployment
deployment.extensions/nginx-deployment

在具体操作上,Deployment Controller 就是让旧的 ReplicaSet 再次扩展成 4 个 Pod,而让新的 Pod 重新收缩为 0 个。那么更进一步,如果需要回滚到更早的版本,要怎么办呢?

首先,需要使用 kubectl roll history 命令,查看每次 Deployment 变更对应的版本:

1
2
3
4
5
6
7

$ kubectl rollout history deployment/nginx-deployment
deployments "nginx-deployment"
REVISION CHANGE-CAUSE
1 kubectl create -f nginx-deployment.yaml --record
2 kubectl edit deployment/nginx-deployment
3 kubectl set image deployment/nginx-deployment nginx=nginx:alpine

然后可以在回滚的时候通过指定版本的版本号,回滚到指定版本:

1
2
3

$ kubectl rollout undo deployment/nginx-deployment --to-revision=2
deployment.extensions/nginx-deployment

这里我们可能已经想到一个问题:对 Deployment 的每次更新操作,都会产生一个新的 ReplicaSet,这会不会浪费资源呢?

其中一个方法是可以使用 kubectl rollout pause 指令暂停滚动更新,当完成对 Deployment 的全部修改后,在使用 kubectl rollout resume 指令恢复继续进行滚动更新。这时,只会生成一个 ReplicaSet。不过,其实这样控制 ReplicaSet 的数量,随着应用的不断更新,ReplicaSet 的数量还是会不断增加。Deployment 利用一个 spec.revisionHistoryLimit 字段,限制了保存的历史版本个数。所以如果将它设置为 0,那么就无法进行回滚操作了。

源码分析

前文我们从实践角度分析了 Deployment 的工作原理。下面通过分析源码,研究 Deployment 的具体实现。

在 Kubernetes 的结构中存在一个叫做 kube-controller-manager 的组件。其实这个组件就是一系列控制器的组合。在项目源代码 pkg/controller 目录下可以看到一系列 controller:

1
2
3
4
5
6
7
8

$ cd kubernetes/pkg/controller/
$ ls -d */
deployment/ job/ podautoscaler/
cloud/ disruption/ namespace/
replicaset/ serviceaccount/ volume/
cronjob/ garbagecollector/ nodelifecycle/ replication/ statefulset/ daemon/
...

这个目录下的每一个控制器,都以独特的方式负责某种编排功能,都遵循通用的编排功能:控制循环。而 Deployment 正式控制器中的一种。

DeploymentController 作为管理 Deployment 资源的控制器,会在启动时利用 Informer 监听 Pod、ReplicaSet、Deployment 三种资源对象的通知,这三种资源的变动都会触发控制器中的回调函数。

不同对象的事件在被过滤后进行工作队列,等待工作进程的消费,以下事件会触发 Deployment 的同步:

  1. Deployment 变化;
  2. Deployment 控制的 ReplicaSet 的变化;
  3. Deployment 下的 Pod 数量为 0 时,Pod 的删除事件;
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
// NewDeploymentController creates a new DeploymentController.
func NewDeploymentController(dInformer appsinformers.DeploymentInformer,
rsInformer appsinformers.ReplicaSetInformer,
podInformer coreinformers.PodInformer,
client clientset.Interface)
(*DeploymentController, error) {

dc := &DeploymentController{
client: client,
eventRecorder: eventBroadcaster.NewRecorder(scheme.Scheme, v1.EventSource{Component: "deployment-controller"}),
queue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "deployment"),
}

dInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: dc.addDeployment,
UpdateFunc: dc.updateDeployment,
// This will enter the sync loop and no-op, because the deployment has been deleted from the store.
DeleteFunc: dc.deleteDeployment,
})
rsInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: dc.addReplicaSet,
UpdateFunc: dc.updateReplicaSet,
DeleteFunc: dc.deleteReplicaSet,
})
podInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
DeleteFunc: dc.deletePod,
})
...
return dc, nil
}

DeploymentController 在调用 Run 的时候启动多个工作进程,这些工作进程运行 worker 方法从队列中读取最新 Deployment 对象进行同步。

同步

DeploymentController 对 Deployment 的通过通过以 syncDeployment 方法进行,该方法包含同步、回滚及更新逻辑,也是同步 Deployment 资源的唯一入口:

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
// syncDeployment will sync the deployment with the given key.
// This function is not meant to be invoked concurrently with the same key.
func (dc *DeploymentController) syncDeployment(key string) error {
namespace, name, err := cache.SplitMetaNamespaceKey(key)
if err != nil {
return err
}
deployment, err := dc.dLister.Deployments(namespace).Get(name)
if errors.IsNotFound(err) {
klog.V(2).Infof("Deployment %v has been deleted", key)
return nil
}
if err != nil {
return err
}

d := deployment.DeepCopy()
// List ReplicaSets owned by this Deployment, while reconciling ControllerRef
// through adoption/orphaning.
rsList, _ := dc.getReplicaSetsForDeployment(d)
// List all Pods owned by this Deployment, grouped by their ReplicaSet.
// Current uses of the podMap are:
//
// * check if a Pod is labeled correctly with the pod-template-hash label.
// * check that no old Pods are running in the middle of Recreate Deployments.
podMap, _ := dc.getPodMapForDeployment(d, rsList)

if d.Spec.Paused {
return dc.sync(d, rsList)
}

scalingEvent, err := dc.isScalingEvent(d, rsList)
if err != nil {
return err
}
if scalingEvent {
return dc.sync(d, rsList)
}

switch d.Spec.Strategy.Type {
case apps.RecreateDeploymentStrategyType:
return dc.rolloutRecreate(d, rsList, podMap)
case apps.RollingUpdateDeploymentStrategyType:
return dc.rolloutRolling(d, rsList)
}
return fmt.Errorf("unexpected deployment strategy type: %s", d.Spec.Strategy.Type)
}

在删除简化后的流程中:

  1. 由传入的 key 获取 Deployment;

  2. 调用 getReplicaSetsForDeployment 获取集群中与 Deployment 相关的所有 ReplicaSet;

    1. 查找所有 ReplicaSet;
    2. 根据 Deployment 中的选择器对 ReplicaSet 建立或释放从属关系;
  3. 调用 getPodMapForDeployment 查询 Deployment 控制的 ReplicaSet 到 Pod 的映射;

    1. 根据选择器查询全部 Pod;
    2. 根据 Pod 的控制器 ReplicaSet 对上述 Pod 进行分类;
  4. 如果 Deployment 处于暂停状态或者需要扩容,就会调用 sync 方法同步 Deployment;

  5. 在正常情况下会根据规格中的策略对 Deployment 进行更新:

    1. Recreate 策略调用 rolloutRecreate 方法,先杀掉所有存在的 Pod 后启动新的 Pod 副本;
    2. RollingUpdate 策略调用 rolloutRolling 方法,根据 maxSurgemaxUnavailable 配置对 Pod 进行滚动更新。

扩容

如果当前需要更新的 Deployment 由 isScalingEvent 检查发现更新事件是一次扩缩容事件,那么 ReplicaSet 持有的 Pod 数量和规格中的 ReplicaSet 不一致,那么就调用 sync 方法对 Deployment 进行同步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// sync is responsible for reconciling deployments on scaling events or when they
// are paused.
func (dc *DeploymentController) sync(d *apps.Deployment, rsList []*apps.ReplicaSet) error {
newRS, oldRSs, err := dc.getAllReplicaSetsAndSyncRevision(d, rsList, false)
if err != nil {
return err
}
if err := dc.scale(d, newRS, oldRSs); err != nil {
// If we get an error while trying to scale, the deployment will be requeued
// so we can abort this resync
return err
}

allRSs := append(oldRSs, newRS)
return dc.syncDeploymentStatus(allRSs, newRS, d)
}

sync 从 apiserver 获取当前 Deployment 对应的最新 ReplicaSet 和历史 ReplicaSet 并调用 scale 方法开始扩容。scale 即为扩容主要执行的方法。该方法较长,本文分为几部分来分析具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (dc *DeploymentController) scale(deployment *apps.Deployment, newRS *apps.ReplicaSet, oldRSs []*apps.ReplicaSet) error {
// If there is only one active replica set then we should scale that up to the full count of the
// deployment. If there is no active replica set, then we should scale up the newest replica set.
if activeOrLatest := deploymentutil.FindActiveOrLatest(newRS, oldRSs); activeOrLatest != nil {
if *(activeOrLatest.Spec.Replicas) == *(deployment.Spec.Replicas) {
return nil
}
_, _, err := dc.scaleReplicaSetAndRecordEvent(activeOrLatest, *(deployment.Spec.Replicas), deployment)
return err
}

// If the new replica set is saturated, old replica sets should be fully scaled down.
// This case handles replica set adoption during a saturated new replica set.
if deploymentutil.IsSaturated(deployment, newRS) {
for _, old := range controller.FilterActiveReplicaSets(oldRSs) {
if _, _, err := dc.scaleReplicaSetAndRecordEvent(old, 0, deployment); err != nil {
return err
}
}
return nil
}
}

假如集群中只有一个活跃的 ReplicaSet,那么就对该 ReplicaSet 进行直接扩缩容,但是如果不存在活跃 ReplicaSet 则选择最新 ReplicaSet。这部分工作由 FindActiveOrLatestscaleReplicaSetAndRecordEvent 共同完成。

当调用 IsSaturated 发现当前 Deployment 对应的副本数已饱和时,则删除所有历史版本 ReplicaSet 持有的 Pod 副本。

当 Deployment 使用滚动更新策略时,如果发现当前 ReplicaSet 没有饱和并且存在多个活跃的 ReplicaSet 对象,则按照比例分别对各个活跃的 ReplicaSet 进行扩容或缩容:

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
   // There are old replica sets with pods and the new replica set is not saturated.
// We need to proportionally scale all replica sets (new and old) in case of a
// rolling deployment.
if deploymentutil.IsRollingUpdate(deployment) {
allRSs := controller.FilterActiveReplicaSets(append(oldRSs, newRS))
allRSsReplicas := deploymentutil.GetReplicaCountForReplicaSets(allRSs)

allowedSize := int32(0)
if *(deployment.Spec.Replicas) > 0 {
allowedSize = *(deployment.Spec.Replicas) + deploymentutil.MaxSurge(*deployment)
}

// Number of additional replicas that can be either added or removed from the total
// replicas count. These replicas should be distributed proportionally to the active
// replica sets.
deploymentReplicasToAdd := allowedSize - allRSsReplicas

// The additional replicas should be distributed proportionally amongst the active
// replica sets from the larger to the smaller in size replica set. Scaling direction
// drives what happens in case we are trying to scale replica sets of the same size.
// In such a case when scaling up, we should scale up newer replica sets first, and
// when scaling down, we should scale down older replica sets first.
var scalingOperation string
switch {
case deploymentReplicasToAdd > 0:
sort.Sort(controller.ReplicaSetsBySizeNewer(allRSs))
scalingOperation = "up"

case deploymentReplicasToAdd < 0:
sort.Sort(controller.ReplicaSetsBySizeOlder(allRSs))
scalingOperation = "down"
}
  1. 调用 FilterActiveReplicaSets 查询所有活跃的 ReplicaSet;

  2. 调用 GetReplicaCountForReplicaSets 计算当前 Deployment 对应 ReplicaSet 持有的全部 Pod 副本个数;

  3. 根据 Deployment 配置的 maxSurgereplicas 计算允许创建的 Pod 数量;

  4. 利用 allowedSizeallRSsReplicas 计算出需要增加或者删除的副本数;

  5. 根据 deploymentReplicasToAdd 变量的符号对 ReplicaSet 数组进行排序并确定当前的操作是扩容还是缩容:

    1. deploymentReplicasToAdd > 0,ReplicaSet 按照从新到旧的顺序进行扩容;
    2. deploymentReplicasToAdd < 0,ReplicaSet 按照从旧到新的顺序进行缩容;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
      // Iterate over all active replica sets and estimate proportions for each of them.
// The absolute value of deploymentReplicasAdded should never exceed the absolute
// value of deploymentReplicasToAdd.
deploymentReplicasAdded := int32(0)
nameToSize := make(map[string]int32)
for i := range allRSs {
rs := allRSs[i]

// Estimate proportions if we have replicas to add, otherwise simply populate
// nameToSize with the current sizes for each replica set.
if deploymentReplicasToAdd != 0 {
proportion := deploymentutil.GetProportion(rs, *deployment, deploymentReplicasToAdd, deploymentReplicasAdded)

nameToSize[rs.Name] = *(rs.Spec.Replicas) + proportion
deploymentReplicasAdded += proportion
} else {
nameToSize[rs.Name] = *(rs.Spec.Replicas)
}
}

由于当前的 Deployment 持有了多个活跃的 ReplicaSet,所以在计算了需要增加或者删除的副本数 deploymentReplicasToAdd后,就会为多个活跃的 ReplicaSet 分配需要改变的副本数,GetProportion 会根据以下几个参数确定结果:

  1. Deployment 期望的 Pod 副本数量;
  2. 需要新增或减少的副本数量;
  3. Deployment 目前通过 ReplicaSet 持有的 Pod 总数;

Kubernetes 在 getReplicaSetFraction 中使用下面的公式计算每个 ReplicaSet 在 Deployment 资源中的占比,最后返回该 ReplicaSet 需要改变的副本数:

该结果又会与目前期望的剩余变化量对比,保证变化的副本数量不会超过期望值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Update all replica sets
for i := range allRSs {
rs := allRSs[i]

// Add/remove any leftovers to the largest replica set.
if i == 0 && deploymentReplicasToAdd != 0 {
leftover := deploymentReplicasToAdd - deploymentReplicasAdded
nameToSize[rs.Name] = nameToSize[rs.Name] + leftover
if nameToSize[rs.Name] < 0 {
nameToSize[rs.Name] = 0
}
}

// TODO: Use transactions when we have them.
if _, _, err := dc.scaleReplicaSet(rs, nameToSize[rs.Name], deployment, scalingOperation); err != nil {
// Return as soon as we fail, the deployment is requeued
return err
}
}

scale 方法的最后会直接调用 scaleReplicaSet 将每一个 ReplicaSet 都扩容或缩容到期望的副本数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (dc *DeploymentController) scaleReplicaSet(rs *apps.ReplicaSet, newScale int32, deployment *apps.Deployment, scalingOperation string) (bool, *apps.ReplicaSet, error) {

sizeNeedsUpdate := *(rs.Spec.Replicas) != newScale

annotationsNeedUpdate := deploymentutil.ReplicasAnnotationsNeedUpdate(rs, *(deployment.Spec.Replicas), *(deployment.Spec.Replicas)+deploymentutil.MaxSurge(*deployment))

scaled := false
var err error
if sizeNeedsUpdate || annotationsNeedUpdate {
rsCopy := rs.DeepCopy()
*(rsCopy.Spec.Replicas) = newScale
deploymentutil.SetReplicasAnnotations(rsCopy, *(deployment.Spec.Replicas), *(deployment.Spec.Replicas)+deploymentutil.MaxSurge(*deployment))
rs, err = dc.client.AppsV1().ReplicaSets(rsCopy.Namespace).Update(context.TODO(), rsCopy, metav1.UpdateOptions{})
if err == nil && sizeNeedsUpdate {
scaled = true
dc.eventRecorder.Eventf(deployment, v1.EventTypeNormal, "ScalingReplicaSet", "Scaled %s replica set %s to %d", scalingOperation, rs.Name, newScale)
}
}
return scaled, rs, err
}

该方法会直接修改目标 ReplicaSet.Spec 中的 Replicas 参数和注解 deployment.kubernetes.io/desired-replicas 的值并通过 API 请求更新当前的 ReplicaSet 对象。

用户可以通过 kubectl describe 命令查看 ReplicaSet 的 Annotations,其实能发现当前 RS 的期待副本数和最大副本数:

1
2
3
4
5
6
7
8
9
$ kubectl describe rs nginx-deployment-76bf4969df
Name: nginx-deployment-76bf4969df
Namespace: default
Selector: app=nginx,pod-template-hash=76bf4969df
Labels: app=nginx
pod-template-hash=76bf4969df
Annotations: deployment.kubernetes.io/desired-replicas=4
deployment.kubernetes.io/max-replicas=5
...

重新创建

当 Deployment 使用的更新策略是 Recreate 时,DeploymentController 就会使用如下的 rolloutRecreate 方法对 Deployment 进行更新:

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
// rolloutRecreate implements the logic for recreating a replica set.
func (dc *DeploymentController) rolloutRecreate(d *apps.Deployment, rsList []*apps.ReplicaSet, podMap map[types.UID][]*v1.Pod) error {
// Don't create a new RS if not already existed, so that we avoid scaling up before scaling down.
newRS, oldRSs, _ := dc.getAllReplicaSetsAndSyncRevision(d, rsList, false)
allRSs := append(oldRSs, newRS)
activeOldRSs := controller.FilterActiveReplicaSets(oldRSs)

// scale down old replica sets.
scaledDown, _ := dc.scaleDownOldReplicaSetsForRecreate(activeOldRSs, d)
if scaledDown {
// Update DeploymentStatus.
return dc.syncRolloutStatus(allRSs, newRS, d)
}

// Do not process a deployment when it has old pods running.
if oldPodsRunning(newRS, oldRSs, podMap) {
return dc.syncRolloutStatus(allRSs, newRS, d)
}

// If we need to create a new RS, create it now.
if newRS == nil {
newRS, oldRSs, _ = dc.getAllReplicaSetsAndSyncRevision(d, rsList, true)
allRSs = append(oldRSs, newRS)
}

// scale up new replica set.
if _, err := dc.scaleUpNewReplicaSetForRecreate(newRS, d); err != nil {
return err
}

if util.DeploymentComplete(d, &d.Status) {
if err := dc.cleanupDeployment(oldRSs, d); err != nil {
return err
}
}

// Sync deployment status.
return dc.syncRolloutStatus(allRSs, newRS, d)
}
  1. 调用 getAllReplicaSetsAndSyncRevisionFilterActiveReplicaSets 两个方法获取 Deployment 中所有的 ReplicaSet 以及其中活跃的 ReplicaSet 对象;
  2. 调用 scaleDownOldReplicaSetsForRecreate 方法将所有活跃的历史 ReplicaSet 持有的 Pod 数降低至 0;
  3. 同步 Deployment 的最新状态并等待 Pod 的终止;
  4. 在需要时通过 getAllReplicaSetsAndSyncRevision 方法创建新的 ReplicaSet 并调用 scaleUpNewReplicaSetForRecreate 函数对 ReplicaSet 进行扩容;
  5. 更新完成之后会调用 cleanupDeployment 方法删除历史全部的 ReplicaSet 对象并更新 Deployment 的状态;

也就是说在更新的过程中,之前创建的 ReplicaSet 和 Pod 资源会被全部删除,只是 Pod 会先被删除而 ReplicaSet 会后被删除;上述方法也会创建新的 ReplicaSet 和 Pod 对象。但是需要注意旧的 Pod 副本一定会被先删除,所以会有一段时间不存在可用的 Pod。

滚动更新

在使用 Deployment 对象时,我们更常用的更新策略是 RollingUpdate。在介绍滚动更新流程前,需要先了解两个参数:

  1. maxUnavailable:表示在更新过程中能够进入不可用状态的 Pod 数量的最大值;
  2. maxSurge:表示在更新过程汇总能够额外创建的 Pod 数量的最大值;

maxUnavailablemaxSurge 这两个滚动更新所使用的配置都可以用百分比或者绝对值表示;当使用百分比时,会使用 计算得到相应的值。

rolloutRolling 为处理滚动更新的方法:

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
// rolloutRolling implements the logic for rolling a new replica set.
func (dc *DeploymentController) rolloutRolling(d *apps.Deployment, rsList []*apps.ReplicaSet) error {
newRS, oldRSs, err := dc.getAllReplicaSetsAndSyncRevision(d, rsList, true)
if err != nil {
return err
}
allRSs := append(oldRSs, newRS)

// Scale up, if we can.
scaledUp, err := dc.reconcileNewReplicaSet(allRSs, newRS, d)
if err != nil {
return err
}
if scaledUp {
// Update DeploymentStatus
return dc.syncRolloutStatus(allRSs, newRS, d)
}

// Scale down, if we can.
scaledDown, err := dc.reconcileOldReplicaSets(allRSs, controller.FilterActiveReplicaSets(oldRSs), newRS, d)
if err != nil {
return err
}
if scaledDown {
// Update DeploymentStatus
return dc.syncRolloutStatus(allRSs, newRS, d)
}

if deploymentutil.DeploymentComplete(d, &d.Status) {
if err := dc.cleanupDeployment(oldRSs, d); err != nil {
return err
}
}

// Sync deployment status
return dc.syncRolloutStatus(allRSs, newRS, d)
}
  1. 首先,获取 Deployment 持有的全部 ReplicaSet 的资源;
  2. 调用 reconcileNewReplicaSet 调节新的 ReplicaSet 的副本数,创建新的 Pod 并保证额外的副本数量不超过 maxSurge;
  3. 调用 reconcileOldReplicaSets 调节历史 ReplicaSet 的副本数,删除旧的 Pod 并保证不可用的部分不超过 maxUnavailable;
  4. 删除无用的 ReplicaSet 并更新 Deployment 的状态;

注意,在滚动更新过程中,Kubernetes 不是一次性就切换到期望的状态,即「目标副本数」,而是先启动新的 ReplicaSet 及一部分 Pod,然后删除历史 ReplicaSet 中的部分;如此往复,最终达到集群期望的状态。

当使用 reconcileNewReplicaSet 对新 ReplicaSet 进行调节时,如果发现新 ReplicaSet 中副本数满足期望则直接返回,在超过期望时则缩容。:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (dc *DeploymentController) reconcileNewReplicaSet(allRSs []*apps.ReplicaSet, newRS *apps.ReplicaSet, deployment *apps.Deployment) (bool, error) {
if *(newRS.Spec.Replicas) == *(deployment.Spec.Replicas) {
// Scaling not required.
return false, nil
}
if *(newRS.Spec.Replicas) > *(deployment.Spec.Replicas) {
// Scale down.
scaled, _, err := dc.scaleReplicaSetAndRecordEvent(newRS, *(deployment.Spec.Replicas), deployment)
return scaled, err
}
newReplicasCount, err := deploymentutil.NewRSNewReplicas(deployment, allRSs, newRS)
if err != nil {
return false, err
}
scaled, _, err := dc.scaleReplicaSetAndRecordEvent(newRS, newReplicasCount, deployment)
return scaled, err
}

如果 ReplicaSet 的数量不够则调用 NewRSNewReplicas 计算新的副本个数,计算过程为:

1
2
3
4
5
6
7
currentPodCount := GetReplicaCountForReplicaSets(allRSs)
maxTotalPods := *(deployment.Spec.Replicas) + int32(maxSurge)
// Scale up.
scaleUpCount := maxTotalPods - currentPodCount
// Do not exceed the number of desired replicas.
scaleUpCount = Min(int(scaleUpCount), int(*(deployment.Spec.Replicas)-*(newRS.Spec.Replicas))))
return *(newRS.Spec.Replicas) + scaleUpCount

该过程中需要考虑 Deployment 期望的副本数、当前可用的副本数记忆新的 RS 持有的副本数,此外还有最大最小值的限制。

另一个滚动更新中使用的方法 reconcileOldReplicaSets 主要作用是对历史 ReplicaSet 对象持有的副本数量进行缩容:

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
func (dc *DeploymentController) reconcileOldReplicaSets(allRSs []*apps.ReplicaSet, oldRSs []*apps.ReplicaSet, newRS *apps.ReplicaSet, deployment *apps.Deployment) (bool, error) {
oldPodsCount := deploymentutil.GetReplicaCountForReplicaSets(oldRSs)
if oldPodsCount == 0 {
// Can't scale down further
return false, nil
}

allPodsCount := deploymentutil.GetReplicaCountForReplicaSets(allRSs)
klog.V(4).Infof("New replica set %s/%s has %d available pods.", newRS.Namespace, newRS.Name, newRS.Status.AvailableReplicas)
maxUnavailable := deploymentutil.MaxUnavailable(*deployment)
minAvailable := *(deployment.Spec.Replicas) - maxUnavailable
newRSUnavailablePodCount := *(newRS.Spec.Replicas) - newRS.Status.AvailableReplicas
maxScaledDown := allPodsCount - minAvailable - newRSUnavailablePodCount
if maxScaledDown <= 0 {
return false, nil
}
oldRSs, cleanupCount, err := dc.cleanupUnhealthyReplicas(oldRSs, deployment, maxScaledDown)
if err != nil {
return false, nil
}
klog.V(4).Infof("Cleaned up unhealthy replicas from old RSes by %d", cleanupCount)

// Scale down old replica sets, need check maxUnavailable to ensure we can scale down
allRSs = append(oldRSs, newRS)
scaledDownCount, err := dc.scaleDownOldReplicaSetsForRollingUpdate(allRSs, oldRSs, deployment)
if err != nil {
return false, nil
}
klog.V(4).Infof("Scaled down old RSes of deployment %s by %d", deployment.Name, scaledDownCount)

totalScaledDown := cleanupCount + scaledDownCount
return totalScaledDown > 0, nil
}
  1. 计算历史 ReplicaSet 持有的副本总数;
  2. 计算全部 ReplicaSet 持有的副本总数;
  3. 根据 Deployment 期望的副本数、最大不可用的副本数以及新的 ReplicaSet 中不可用的 Pod 数量计算最大缩容个副本个数;
  4. 利用 cleanupUnhealthyReplicas 清理 ReplicaSet 中处于不健康状态的副本;
  5. 利用 scaleDownOldReplicaSetsForRollingUpdate 对历史 ReplicaSet 中的副本进行缩容;

回滚

Kubernetes 中的每一个 Deployment 资源都包含 revision 概念,版本的使用可以让我们在更新不符合预期是及时通过 Deployment 的版本对其进行回滚。当我们更新 Deployment 时,之前 Deployment 持有的 ReplicaSet 会被清理。Deployment 通过规格中的 revisionHistoryLimit 字段配置最多保留的 ReplicaSet 数量,及多少个版本,这些 ReplicaSet 并不会被删除,它们只是不持有任何的 Pod 副本。

保留这些资源能够方便 Deployment 进行回滚,回滚荣国客户端调用 rollout undo 命令实现:

1
2
kubectl rollout undo deployment.v1.apps/nginx-deployment
deployment.apps/nginx-deployment

上述命令没有指定版本号,所以默认回滚到上一个版本。如果在回滚时指定版本,那么 Kubernetes 就会根据传入的版本查找历史的 ReplicaSet 资源,并触发一个资源更新请求.

回滚对于 Kubernetes 来说与更新操作没有区别,在每次更新时都会根据模板在历史 ReplicaSet 中查询是否有相同的 ReplicaSet 存在。如果存在规格完全相同的 ReplicaSet,就会保留这个 ReplicaSet 历史上使用的版本号并对该 ReplicaSet 重新扩容并对正在工作的 ReplicaSet 进行缩容以实现期望状态。

删除

如果用户在 Kubernetes 中删除了一个 Deployment 资源,那么 Deployment 持有的 ReplicaSet 以及 ReplicaSet 持有的副本都会被 Kubernetes 中的垃圾收集器删除。

由于和当前 Deployment 有关的 ReplicaSet 历史和最新版本都会被删除,所以对应的 Pod 副本也都会随之被删除,这些字段都是通过 metadata.ownerReference 字段关联。

总结

本文分析了 Deployment 这个在 Kubernetes 中最常使用的编排控制的实现和工作原理。

Deployment 实际上是一个两层控制器。首先它通过 ReplicaSet 的个数描述应用的版本;然后通过 ReplicaSet 的属性,保证 Pod 的数量。

Deployment 控制 ReplicaSet(版本),ReplicaSet 控制 Pod(副本数)。

Deployment 的设计实际上代替了对应用的抽象,是我们可以使用该 Deployment 来描述应用。

Reference

  1. Kubernetes — Deployment
  2. 极客时间 — 深入剖析 Kubernetes
  3. 详解 Kubernetes Deployment 的实现原理

什么是“控制器模型“

在 Kubernetes 中,Pod 是最小的 API 对象,是原子调度单位。Pod 通过对“容器”这个概念的进一步封装和抽象,添加更多属性和字段,使得描述及调度应用更加容易。而完成对 Pod 的调度和操作的逻辑就由控制器(Controller)实现。

控制器模型能够统一地实现对各种不同的对象或者资源的编排操作,保证对象和实际状态与对象的期望状态相同。对象的实际状态即直接访问 Kubernetes 的 ApiServer 得到的结果,而期望状态则来自于用户向集群提交的 Yaml 文件。以一个简单的部署了 Nginx 的 Deployment 控制器对象为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
spec:
selector:
matchLabels:
app: nginx
replicas: 2
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:stable
ports:
- containerPort: 80

这个 Deployment 确保携带了 app=nginx 标签的 Pod 个数永远等于 spec.replicas 指定的个数,即 2 个。那么,当集群中携带了 app=nginx 标签的 Pod 个数大于 2 的时候,控制器会删除多余的 Pod;反之,则会新建 Pod。

控制器模型的遵循 Kubernetes 中的一个通用编排模式:控制循环(control loop)。对于该 Deployment 我们可以用一段伪代码来描述这个控制循环:

1
2
3
4
5
6
7
8
9
for {
actualState := getActualStateOfDeployment("nginx")
expectedState := getExpectedStateOfDeployment("nginx")
if actualState == expectedState {
// 什么都不做
} else {
// 执行编排动作,将实际状态调整为期望状态
}
}

上述的伪代码中:

  1. Deployment 控制器从 Etcd 中查询所有携带了 app=nginx 标签的 Pod,并计数得到实际状态;
  2. 根据 spec.replicas 得到期望状态;
  3. Deployment 控制器根据比较两个状态的结果确定应该创建还是删除已有的 Pod。

像 Deployment 这种控制器的设计原理,实现了“一种对象控制另一种对象”。控制器对象本身负责定义被管理对象的期望状态,而被控制对象的定义则来自一个模板,即 PodTemplate。所以类似 Deployment 这样一个控制器,由上半部分包含了控制器定义及期望状态,加上下半部分被控制对象的模板组成。

Kubernetes 中通过使用控制器模型这个统一的编排框架,不同的控制器可以在具体的过程中设计不同的业务逻辑,达到不同的编排效果。

“控制器模型“与“事件驱动模型“的区别

笔者在接触 Kubernetes 之前,在业务中维持对象的状态一直采用的是“事件驱动模型”,即通过回调或者消息队列得到指令或事件后,执行一系列预先定义好的逻辑。这时,业务系统“被动”地等到具体事件的到来,并触发相应的操作。由于事件往往是一次性的,这导致在执行指令失败的情况下比较难以处理,通常是进行日志保存、报警及回滚操作。

而在 Kubernetes 中,控制器模型是持续、“主动”地观察目标对象的状态,不断尝试,最终实现实际状态与期望状态的一致。

声明式 API

在 Kubernetes 项目中,存在一个叫做 kube-controller-manager 的组件。这个组件,就是一系列控制器的集合。在 Kubernetes 项目的 pkg/controller 目录:

1
2
3
4
5
ls -d */
apis/ cronjob/ endpoint/ job/ podautoscaler/ resourcequota/ testutil/ volume/
bootstrap/ daemon/ endpointslice/ namespace/ podgc/ service/ ttl/
certificates/ deployment/ garbagecollector/ nodeipam/ replicaset/ serviceaccount/
...

这个目录的每个控制器都遵循通用的编排模式:控制循环(control loop),Deployment Controller 就是其中之一。

使用遵循“控制器模型”实现的各类 Controller,同 Kubernetes 里 API 对象的 CRUD 接口进行协作,完成用户业务逻辑的编写,也成为了 Kubernetes 的编程范式,实现了 Kubernetes 的声明式 API。

为了创建 Kubernetes 中的 API 对象,无一例外,用户都需要编写一个对应的 Yaml 文件交给 Kubernetes,这正是声明式 API 的一个要素。

但是声明式 API 并不仅仅意味着用 Yaml 文件代替命令行操作。例如,我们通过 kubectl create 命令创建 Deployment:

1
kubectl create -f nginx.yaml

通过 kubectl set image 命令更新容器镜像:

1
kubectl set image deployment/nginx-deployment nginx=nginx:1.16.1

等等操作并不是声明式 API,这些应该被称为“命令式配置文件操作”。真正的声明式 API 是 kubectl apply 命令。用户可以使用 kubectl apply 创建 Deployment,之后修改 Yaml 并再次调用该命令将修改提交到 Kubernetes 的 ApiServer。kubectl createkubectl replace 的执行过程,是使用新的 Yaml 文件中的 API 对象去替换原有对象;而 kubectl apply 则是执行了一个对原有 API 对象的 PATCH 操作

更进一步,kube-apiserver 在响应命令式请求(如,kubectl replace)时,一次只能处理一个写请求,否则可能产生冲突。而对于声明式请求(如,kubectl apply),一次能处理多个写操作,并且具备 Merge 能力。

所谓“声明式”,指的就是用户只需要提交一个定义好的 API 对象来“声明”期望的状态;“声明式” API 允许有多个 API 写端,以 PATCH 方式对 API 对象进行修改,而无需关心本地原始 Yaml 文件的内容;最后,Kubernetes 可以完成对实际状态和期望状态的调谐过程。

声明式 API 的设计

为了回答当一个 Yaml 文件被提交给 Kubernetes 后,它是如何创建出一个 API 对象的,我们需要知道在 Kubernetes 中,一个 API 对象在 Etcd 中的完整资源路径,是由 Group(API 组)、Version(API 版本) 和 Resource(API 资源类型)组成的。

可以看到 Kubernetes 中的 API 对象的组织方式是层层递进的。以 Deployment 为例,那么 Yaml 的开始部分的写法为:

1
2
3
apiVersion: apps/v1
kind: Deployment
...

在这个 Yaml 文件中,组(Group)为 apps,版本(Version)为 v1,资源类型(Resource)为 Deployment。当提交这个 Yaml 文件之后,Kubernetes 将会把这个 Yaml 文件里面描述的内容转换成集群内一个 Deployment 对象。

为了找到 API 对象的定义,首先 Kubernetes 会匹配 API 对象的组。对于核心 API 对象如 Pod、Node 等不需要 Group。所以对于核心对象,Kubernetes 会直接在 /api 这个层级下进行下一步匹配。而 Deployment 等非核心 API,Kubernetes 就需要在 /apps 下查找对应的 Group。

然后,Kubernetes 进一步匹配 API 对象的版本。对于 Deployment 对象来说,Kubernetes 在 /apps 这个 Group 下,匹配到的版本就是 v1。API 版本话管理保证了向后兼容。

最后 Kubernetes 匹配 API 对象的资源类型。最后,Kubernetes 就得到,要创建的对象是 /apps/v1/ 下的 Deployment 对象。

自定义资源类型(CustomResourceDefinition, CRD)

在 Kubernetes 中除了预定义的 API 对象外,用户可以利用 CRD 来向 kube-apiserver 中新增自定义 API 资源类型。例如,要在集群中添加一个描述网络资源的自定义 API 对象 Network,用以描述期望的网络参数。

这个 Network 对象的 Yaml 文件可以如下:

1
2
3
4
5
6
7
apiVersion: sample.k8s.io/v1
kind: Network
metadata:
name: sample-net
spec:
cidr: "192.168.0.0/16"
gatewar: "192.168.0.1"

那么,Kubernetes 应该如何知道该 API 对象 sample.k8s.io/v1/netword 的存在呢?其实,该 Yaml 文件是一个自定义 API 资源,也叫 Custom Resource(CR)。为了让 Kubernetes 知道这个 CR,就需要该 CR 的定义是什么,即CustomResourceDefinition(CRD)。

一个 CRD 的定义 Yaml 为:

1
2
3
4
5
6
7
8
9
10
11
12

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: networks.sample.k8s.io
spec:
group: sample.k8s.io
version: v1
names:
kind: Network
plural: networks
scope: Namespaced

文件中,指定了 “group: sample.k8s.io” 及 “version: v1”,也指定了这个 CR 的资源类型叫做 Network,复数是 networks。也声明了它的 scope 是 Namespaced,即这个 Network 是一个属于命名空间的对象。

通过执行:

1
kubectl apply -f network.yaml

即可在集群中创建 Network 对象的 CRD。

自定义控制器

“声明式 API” 并不像 “命令式 API” 那样有着明显的执行逻辑,这就使得集群声明式 API 的业务功能实现,通常需要控制器模式来监听 API 对象的变化,然后以此来决定实际需要执行的具体工作。

要实现一个自定义控制器,需要:

  1. 根据 kubeconfig 或者以 InClusterCofig 初始化用于访问集群 API 对象的 kubeClient,及用于访问自定义对象的自定义 Client,如 networkClient;
  2. 利用自定义对象的 Client 初始化对应的 InformorerFactory, 并使用它生成自定义对象的 Informer, 传给 controller;
  3. 启动 informer 及 controller;

在 Kubernetes 中,一个自定义控制器的工作原理,可以用下图表示。

控制器的第一件事,是从 Kubernetes 的 APIServer 里获取关心的对象。这个操作,依赖 Informer 代码库完成。Informer 与 API 对象是一一对应的。

在创建 InformerFactory 的时候需要传递一个 client,实际上,Informer 正式利用这个 Client,与 APIServer 建立的连接。不过真正维护这个连接的,则是 Informer 所使用的 Reflector 包。

Reflector 使用被称为 ListAndWatch 的方法,来获取并监听目标对象的实例变化。

在 ListAndWatch 机制下,一旦 APIServer 端有新的对象实例被创建、删除或更新,Reflector 都会收到事件通知。这时,该事件及它对应的 API 对象这个组合,就被称为增量(Delta),它会被放入一个 Delta FIFO Queue 中。

另一方面,Informer 会不断从这个 Delta FIFO Queue 里读取(Pop)增量。每拿到一个增量,Informer 就会判断这个增量里的事件类型,然后创建或者更新本地对象的缓存。这个缓存,在 Kubernetes 中一般被叫做 Store。

例如,如果该事件类型是 Added,那么 Informer 就会通过一个叫做 Indexer 的库把这个增量里面的 API 对象保存在本地缓存中,并为它创建索引。相反,如果增量的事件是 Deleted,那么 Informer 就会从本地缓存中删除这个对象。

这个同步本地缓存的工作,是 Informer 的第一个职责,也是它最重要的职责。

Informer 的第二个职责,就是根据这些事件的类型,触发事先注册好的 ResourceEventHandler。这些 Handler 需要在创建控制器的时候注册给它对应的 Informer。

一个控制器的定义可以如下:

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
func NewController(
kubeclientset kubernetes.Interface,
networkclientset clientset.Interface,
networkInformer informers.NetworkInformer) *Controller {

controller := &Controller{
kubeclientset: kubeclientset,
networkclientset: networkclientset,
networksLister: networkInformer.Lister(),
networksSynced: networkInformer.Informer().HasSynced,
workqueue: workqueue.NewNamedRateLimitingQueue(..., "Networks"),
...
}
networkInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: controller.enqueueNetwork,
UpdateFunc: func(old, new interface{}) {
oldNetwork := old.(*samplecrdv1.Network)
newNetwork := new.(*samplecrdv1.Network)
if oldNetwork.ResourceVersion == newNetwork.ResourceVersion {
return
}
controller.enqueueNetwork(new)
},
DeleteFunc: controller.enqueueNetworkForDelete,
return controller
}

需要注意的是,在这个自定义控制器里面,还设置了一个工作队列(work queue),它正是图中间的 WorkQueue。这个工作队列的作用是,负责同步 Informer 和控制循环之间的数据。

然后,为 Informer 添加了三个 Handler,分别对应 API 对象的添加、更新和删除事件,而具体的处理操作则是将事件对应的 API 对象加入到工作队列中。实例入队列的不是 API 对象本身,而是它们的 key,及 {namespace}/{name}

而控制循环,则是不断从工作队列里拿到 Key,然后开始执行真正的控制循环。

所以,Informer 其实就是一个带有本地缓存和索引机制的,可以注册 EventHandler 的 client。它是自定义控制器跟 APIServer 进行数据同步的重要组件。Informer 通过 ListAndWatch 方法,将 APIServer 中的 API 对象缓存在了本地,并负责更新和维护这个缓存。

ListAndWatch 首先通过 List API 获取所有最新版本的 API 对象;然后再通过 Watch API 实时更新本地缓存,并且调用这些事件对应的 EventHandler。

此外,每经过 resyncPeriod 指定的时间,Informer 维护的本地缓存,都会使用最近一次 List 返回的结果强制更新一次,从而保证缓存的有效性。这个强制更新的操作叫做 resync

需要注意,这个定时 resync 的操作,也会触发 informer 的“更新”事件。但是此时,“更新”事件对应的 Network 实际上没有变化,这种情况下,Informer 不需要对这个更新事件再做进一步处理。

以上,就是 Informer 的工作原理了。

总结

控制器模型能够统一地实现对各种不同的对象或者资源的编排操作,保证对象和实际状态与对象的期望状态相同。对象的实际状态即直接访问 Kubernetes 的 ApiServer 得到的结果,而期望状态则来自于用户向集群提交的 Yaml 文件。

所谓“声明式”,指的就是用户只需要提交一个定义好的 API 对象来“声明”期望的状态;“声明式” API 允许有多个 API 写端,以 PATCH 方式对 API 对象进行修改,而无需关心本地原始 Yaml 文件的内容;最后,Kubernetes 可以完成对实际状态和期望状态的调谐过程。

Informer 是一个自带缓存和索引机制,可以触发 Handler 的客户端,缓存被称为 Store,索引被称为 Index。
Informer 使用 Reflector 报,通过 ListAndWatch 机制获取并监听 API 对象变化。

Informer 和 Reflector 之间使用一个增量先进先出队列来协同,而Informer 与控制循环之间则使用一个工作队列来协同。

参考文献

  1. 谈谈控制器模型 — 极客时间
  2. 声明式 API — 极客时间
  3. 编写自定义控制器 — 极客时间

为什么需要 Pod

Pod 是 Kubernetes 项目中最小的 API 对象;是 Kubernetes 项目的原子调度单位。但是,为什么需要 Pod 呢?

在学习容器本质后,我们都知道:

Namespace 做隔离,Cgroups 做限制,rootfs 作为文件系统。

Pod 存在的必要性是什么呢?

Pod 存在的主要目的为:1. 抽象进程组概念 2. 引入“容器设计模式”

Pod 对“进程组”概念进行了抽象。如果说容器的本质是云计算系统中的进程,那么 Kubernetes 就相当于云计算的操作系统。在一个真正的操作系统里,如果在终端输入 pstree 命令,就可以以树形结构展示系统中的进程。这时我们可以发现进程通常并不是独自运行,而是被有组织地组合在一起。如用于处理系统日志中的 rsyslogd,与内核日志模块 imklog 同属于一个进程组,他们协作完成日志的收集。Kubernetes 的很多设计思想来源与 Google 内部系统 Borg,在 Borg 的实践过程中,工程师就发现应用之间通常存在类似于“进程组”的关系,它们密切协作,必须部署在同一台机器上。如果存在了“组”的概念,那么就能很好地处理这样的运维关系,Kubernetes 按照 Pod 的资源需求进行计算进行资源调度。所以,Kubernetes 使用 Pod 将“进程组”的概念映射到了容器技术中,使其成为云计算“操作系统”里面的“一等公民”。

Pod 引入了“容器设计模式”。Pod 本身仅仅是一个逻辑概念。Kubernetes 仍然是使用 Namespace 和 Cgroups 实现资源的隔离和限制,而并不存在一个「Pod 边界」。Pod 里的所有容器,共享同一个 Network Namespace,并且能够声明共享同一个 Volume。如果共享网络配置及 Volume 是通过类似运行 docker run 命令来实现,如:

1
docker run --net=other --volumes-from=other --name=this ...

那么一个容器就必须比另外一个容器先启动,这样容器间就成为了拓扑关系,而不是对等关系了。所以,在 Kubernetes 中,Pod 使用了一个中间容器 Infra,Infra 一定是在 Pod 中首先被创建的容器,而其他容器则通过 Join Network Namespace 的方式与 Infra 容器关联在一起。

Pod

Infra 容器使用一个特殊的镜像,叫做:k8s.gcr.io/pause,它占用极少的资源。Infra 容器被创建后会初始化 Network Namespace,之后用户容器就可以加入到 Infra 容器中了。所以对于 Pod 中的容器 A 和 B 来说,它们:

  1. 能够直接使用 localhost 通信;
  2. 看到与 Infra 容器相同的网络设备
  3. Pod 只有一个 IP 地址,也就是该 Pod 的 Network Namespace 对应的 IP 地址;
  4. 所有网络资源均一个 Pod 一份,被 Pod 中所有容器共享;
  5. Pod 的生命周期仅与 Infra 容器一致,与用户容器无关。

对于同一个 Pod 中的用户来说,它们的进出流量可以认为都是通过 Infra 容器完成的。所以当进行网络插件开发时,应该主要考虑如何配置 Pod 的 Network Namespace,而不是去配置用户容器。

有了使用 Infra 容器的设置后,共享的 Volume 也就成为了 Pod 层级的字段。这样 Volume 对应的宿主机目录对于 Pod 来说只有一个,Pod 中的容器只要声明挂载这个 Volume,就一定可以共享这个 Volume 对应的宿主机目录。

pod-share-volumes

Pod 的设计希望当用户想在一个容器里面运行多个功能不相关,但是关系紧密的应用时,应该优先考虑它们是不是更应该被描述成一个 Pod 里的多个容器。

所以,当进行业务「上云」的时候,应该把整个虚拟机想象成一个 Pod,把这些进程分别做成容器镜像,把存在顺序关系的容器定义为 Init Container。这样就实现了更加合理的,容耦合的容器编排方法。

Pod 提供的是一种编排思想,而不是具体的技术方案。

Pod 对象的基本概念

Pod,而不是容器,是 Kubernetes 项目中的最小编排单位。如果将这个思想落实到 API 和数据接口定义上,那么容器就成为了 Pod 属性中一个普通的字段。那么,哪些属性应该放到 Pod 中,哪些应该放到 Container 中呢?

Pod 扮演了传统环境中的「虚拟机」角色,容器扮演了进程。理解了 Pod 的设计原则之后,我们就可以明白,在设计 Pod 数据结构时:

  1. 凡是调度、网络、存储,以及安全相关的属性,基本上是 Pod 级别的;
  2. 凡是跟容器的 Linux Namespace 相关的属性,一定是 Pod 级别的;
  3. 凡是 Pod 中容器要共享宿主机的 Namespace,一定是 Pod 级别的;

应用调度、网络、存储及安全相关的属性的共同特征是,它们描述“机器”这个整体,而不是里面的“程序”。

  • NodeSelector:供用户将 Pod 与 Node 进行绑定。
1
2
3
4
5
apiVersion: v1
kind: Pod
spec:
nodeSelector:
disktype: ssd

这样的配置意味着 Pod 永远只能运行在携带了 disktype:ssd 标签(Label)的节点上;否则将会调度失败。

  • NodeName::该字段一般由调度器负责设置,因为一旦 Pod 中的该字段被赋值,Kubernetes 就认为这个 Pod 已经经过了调度,调度的结果就是赋值的节点名字。但是用户可以设置它来“骗过”调度器,一般在测试和调试时这样做。

  • NodeName:定义了 Pod 的 hosts 文件(/etc/hosts)中的内容。

1
2
3
4
5
6
7
8
apiVersion: v1
kind: Pod
spec:
hostAliases:
- ip: "10.1.2.3"
hostnames:
- "foo.remote"
- "bar.remote"

当 Pod 启动后,生成的 /etc/hosts 文件的内容如下:

1
2
3
4
5
6
7
cat /etc/hosts
# Kubernetes-managed hosts file.
127.0.0.1 localhost
...
10.244.135.10 hostaliases-pod
10.1.2.3 foo.remote
10.1.2.3 bar.remote

Linux Namespace 相关的属性,也一定是 Pod 级别的。因为 Pod 的设计原则就是希望其中的容器能够尽可能多地共享 Linux Namespace,仅保留必要的隔离和限制能力。这样 Pod 模拟出的效果会更加类似虚拟机中进程间的关系。

假如设置 sharePrecessNamespace=true

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
shareProcessNamespace: true
containers:
- name: nginx
image: nginx
- name: shell
image: busybox
stdin: true
tty: true

就意味着 Pod 中的容器要共享 PID Namespace。之后,在 busybox 容器中运行 ps aux,就不仅仅能够看到 ps 命令本身,还能够看到 nginx 容器的进程以及 pause 进程。这就意味着,整个 Pod 里的每个容器的进程,对于所有容器来说都是可见的。

凡是 Pod 中的容器要共享宿主机的 Namespace,也一定是 Pod 级别的。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
hostNetwork: true
hostIPC: true
hostPID: true
containers:
- name: nginx
image: nginx
- name: shell
image: busybox
stdin: true
tty: true

这个 Pod 中我们定义了共享宿主机的 Network、IPC 和 PID Namespace。这就意味着,这个 Pod 里面的所有容器,会直接使用宿主机的网络、直接与宿主机进行 IPC 通信、看到宿主机里面正在运行的所有进程。

在 Pod 中,最重要的就是 Containers 了,另外,Init Container 也属于 Pod 对容器的定义,内容和 Container 完全相同,只是 Init Container 的声明周期会限于所有的 Containers,并且严格按照顺序执行。

Kubernetes 对 Container 的定义,与 Docker 相比差别不大,主要有:Image,Command,workingDir,Ports,及 volumeMounts。另外需要注意的有:

  • ImagePullPolicy:定义了镜像的拉取策略,默认值是 Always,即每次创建 Pod 时都重新拉取镜像;如果它的值被定义为 Never 或者 IfNotPresent,则意味着 Pod 永远不会主动拉取这个镜像,或者只有当宿主机上不存在该镜像时才拉取。

  • Lifecycle:定义了 Container Lifecycle Hooks,也就是容器状态发生变化时触发的一系列“钩子”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
apiVersion: v1
kind: Pod
metadata:
name: lifecycle-demo
spec:
containers:
- name: lifecycle-demo-container
image: nginx
lifecycle:
postStart:
exec:
command: ["/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"]
preStop:
exec:
command: ["/usr/sbin/nginx","-s","quit"]

可以看到在 Container 中为 nginx 容器设置了 postStartpreStop,分别表示在容器启动后或者在容器被杀死前执行一个指定的操作。

Pod 生命周期的变化,主要体现在 Pod 的 Status 部分,这是它除了 MetadataSpec 之外的第三重要的字段。其中,pod.status.phase 就是 Pod 的当前状态,它可能的情况有:

  1. Pending。表示 Pod 的 YAML 文件已经提交给了 Kubernetes,对象已经被创建被保存在了 Etcd 中。但是 Pod 中的有些容器因为某种原因而不能被顺利创建。
  2. Running。表示 Pod 已经调度成功,其中包含的容器都已经创建成功,且至少有一个正在运行中。
  3. Succeeded。表示 Pod 中所有的容器都已经正常运行完毕,并且已经退出了。这在 Job 对象中较常见。
  4. Failed。表示 Pod 中至少有一个容器已不正常的状态(非 0 返回码)退出。
  5. Unknown。表示一个异常状态,Pod 的状态不能持续地被 kubelet 汇报给 kube-apiserver,可能的原因是主从节点间的通信出现了问题。

更进一步。Pod 的 Status 字段还可以再细分中一组 Conditions。细分的状态包括:PodScheduled、Ready、Initialized,以及 Unschedulable。它们主要用于描述造成当前 Status 的具体原因是什么。

Pod 的实现原理

我们可以查看 Pod 数据结构的完整定义,来进一步理解 Pod 的设计。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Pod is a collection of containers that can run on a host. This resource is created
// by clients and scheduled onto hosts.
type Pod struct {
metav1.TypeMeta `json:",inline"`
// Standard object's metadata.
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata
// +optional
metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`

// Specification of the desired behavior of the pod.
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
// +optional
Spec PodSpec `json:"spec,omitempty" protobuf:"bytes,2,opt,name=spec"`

// Most recently observed status of the pod.
// This data may not be up to date.
// Populated by the system.
// Read-only.
// More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
// +optional
Status PodStatus `json:"status,omitempty" protobuf:"bytes,3,opt,name=status"`
}

与 Kubernetes 中任何一个 API 对象一样,Pod 首先也嵌入(embed)了 TypeMetaObjectMeta 属性。TypeMeta 中的 apiVersionkind 描述了 API 组及资源类型,能够确定该对象应该由谁来处理。而 metadata 则为我们提供能够唯一识别对象的信息,包括集群中的 namespace 及在命名空间中唯一的 name,还有用于分类的 labels 字段及用于功能拓展的 annotations 字段。

1
2
3
4
5
6
7
type ObjectMeta struct {
Name string
Namespace string
Labels map[string]string
Annotations map[string]string
// ...
}

其中最重要的属性为 PodSpec 的定义。它定义了 Pod 的期望状态,其中包含了大量本文中未介绍的属性。

Pod 的基本生命周期为,首先创建 Pod,之后进入健康检查状态,当 Kubernetes 确定 Pod 已经能够接受外部请求时,将流量打到新的 Pod 上并继续对外提供服务;如果 Pod 发生错误就触发重启机制。

Pod 的在集群中的创建由 kubelet 完成,Pod 的创建过程的入口为 SyncPod 方法

创建 Pod 的流程基本可以分为 6 步:

  1. 计算 Pod 中沙盒和容器的变更;
  2. 强制停止 Pod 中对应的沙盒;
  3. 强制停止所有不应该运行的容器;
  4. 为 Pod 创建新的沙盒;
  5. 创建 PodSpec 中指定的初始化容器;
  6. 依次创建 PodSpec 中指定的常规容器;
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
func (m *kubeGenericRuntimeManager) SyncPod(pod *v1.Pod, podStatus *kubecontainer.PodStatus, pullSecrets []v1.Secret, backOff *flowcontrol.Backoff) (result kubecontainer.PodSyncResult) {
podContainerChanges := m.computePodActions(pod, podStatus)
if podContainerChanges.CreateSandbox {
ref, _ := ref.GetReference(legacyscheme.Scheme, pod)
}

if podContainerChanges.KillPod {
if podContainerChanges.CreateSandbox {
m.purgeInitContainers(pod, podStatus)
}
} else {
for containerID, containerInfo := range podContainerChanges.ContainersToKill {
m.killContainer(pod, containerID, containerInfo.name, containerInfo.message, nil) }
}
}

podSandboxID := podContainerChanges.SandboxID
if podContainerChanges.CreateSandbox {
podSandboxID, _, _ = m.createPodSandbox(pod, podContainerChanges.Attempt)
}
podSandboxConfig, _ := m.generatePodSandboxConfig(pod, podContainerChanges.Attempt)

if container := podContainerChanges.NextInitContainerToStart; container != nil {
msg, _ := m.startContainer(podSandboxID, podSandboxConfig, container, pod, podStatus, pullSecrets, podIP, kubecontainer.ContainerTypeInit)
}

for _, idx := range podContainerChanges.ContainersToStart {
container := &pod.Spec.Containers[idx]
msg, _ := m.startContainer(podSandboxID, podSandboxConfig, container, pod, podStatus, pullSecrets, podIP, kubecontainer.ContainerTypeRegular)
}

return
}

可以看出,Pod 的创建流程首先是计算 Pod规格和沙箱的变更,然后停止所有可能影响这一次创建或更新的容器,最后依次创建沙盒、初始化容器和常规容器。

参考文献

  1. 深入剖析 Kubernetes — 极客时间
  2. 详解 Kubernetes 的实现原理
  3. Pod — Kubernetes

正式工作半年来,公司项目以 Go 作为主要语言。由于有 C 语言基础,刚开始我感觉上手 Go 语言很容易,而且比 C 语言有更好的可读性,再加上「函数」变成了一等公民,函数式的写法也让部分功能的实现变得更加简练和易读。但是,我对 Go 语言的并发特性却一直理解不深,总觉得在处理并发任务的时候我的思路依然是使用 Java 的 communicate by sharing memory,而不是 Go 语言所提倡的 share memory by communicating。所以,近期阅读和实践了一些 Go 语言并发编程相关的书籍和资料,总结了 Go 语言中常用的并发编程模式,记录在这里。

for-select-loop

select 组合多个 channelchannel 组合多个 goroutineselect 是 Go 语言并发编程中最终的指令之一,我们可以在任何上下文,无论是函数、还是多个子系统,中将多个 channel 组合在一起,并加入如「取消」、「限时等待」和「默认值」等功能。最简单的例子就是:

1
2
3
4
5
6
7
8
9
10
11
var ch1, ch2 <-chan interface{}
var ch3 chan<- interface{}

select {
case <-ch1:
// logic 1
case <-ch2:
// logic 2
case ch3<- struct{}{}:
// logic 3
}

switch 不同的是,多个 case 不是同步判断是否满足条件,而是异步判断,如果所有 case 都不满足,那么 select 语句将一直阻塞。、

for-select-loop 是 Go 语言中最常见的使用方法,可以被应用在多个场景中:

顺序写入变量到 channel

1
2
3
4
5
6
7
8
for _, str := range []string{"str1", "str2", "str3"} {
select {
case <-stop:
return
case strChan<- str:

}
}

无限循环执行任务直到被取消:

1
2
3
4
5
6
7
8
for{
select {
case <-stop:
return
default
}
// 循环任务
}

错误处理

并发编程中,错误处理非常困难。我们花费大量时间思考多个线程之间的内存共享和协调,但是忽略如何优雅地处理错误。Go 语言抛弃了在其他语言中常见的异常抛出机制,并提出开发者应该给予错误处理逻辑分支与正产流程相同的关注。在错误处理汇总最基本的问题是「谁应该处理错误?」。有时候,程序需要停止进一步沿着调用栈向上传递异常,而是处理异常。

在并发编程中,这个问题会变得更加复杂。因为 goroutine 是独立于它的父亲和兄弟 goroutine 执行的,很难确定应该如何处理错误。例如:

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
checkStatus := func(stop <-chan interface{}, urls ...string) <-chan *http.Response {
responses := make(chan *http.Response)
go func() {
defer close(responses)
for _, url := range urls {
resp, err := http.Get(url)
if err != nil {
fmt.Println(err)
continue
}
select {
case <-stop:
return
case responses <- resp:
}
}
}()
return responses
}

stop := make(chan interface{})
defer close(stop)

urls := []string{"https://google.com", "https://host"}
for response := range checkStatus(stop, urls...) {
fmt.Printf("Response: %v\n", response.Status)
}

例子中发送 http 请求的 goroutine 没有办法将错误返回,只能打印错误信息防止将错误吞掉。所以,不应该使 goroutine 无法回传错误。更好的方式是注意点分离,并发的 goroutine 应该将错误信息传递给另外一个更够访问程序全部状态的组件,并正确处理错误。如我们可以封装返回值和错误信息:

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
type Result struct {
Error error
Response *http.Response
}

checkStatus := func(stop <-chan interface{}, urls ...string) <-chan *http.Response {
results := make(chan Result)
go func() {
defer close(results)

for _, url := range urls {
resp, err := http.Get(url)
result = Result{Error: err, Response: resp}

select {
case <-stop:
return
case results <- result:
}
}
}()
return results
}

stop := make(chan interface{})
defer close(stop)

urls := []string{"https://google.com", "https://host"}
for result := range checkStatus(stop, urls...) {
if result.Error != nil {
fmt.Printf("error: %v", result.Error)
continue
}
fmt.Printf("Response: %v\n", result.Response.Status)
}

这种方式的关键在于我们将可能的结果和错误封装在一起,可能够表示 checkStatus 所能够产生的全部结果,而使我们的主流程可能决定应该处理错误情况。

中央帝国的财政密码

因为对历史感兴趣,所以我希望从各个角度审视中国历史,所以选择到了这本⎡中央帝国的财政密码⎦。书的作者叫郭建龙,曾经是财政记者,多年的采访与资料收集使他能从财政的角度分析中国的现实。书的内容以分析中华帝国的历代的财政为主,却也通过回看历史来分析当前的中央经济改革。作者认为中国经济的三大底线是:国有企业不放弃、公有土地制度不触及、政府控制发钞权。我们通常认为这是社会主义的特征,是从马克思开始就决定要必须守住的三大基础。但是作者通过回看历史发现,其实,这所谓三大基础并非舶来品,而是中国历史上一直依赖的手段,中国古代的各个王朝已经频繁使用国有企业、公有土地制度、垄断货币发行的手段敛财,这些手段不是从外国学来的理论,而是传统带来的本能。

本书将中国的历史依据制度和财政的传承性分为三个周期:

  1. 第一个周期以官僚制度上以中央集权为主,残存一定的诸侯制;经济上实行土地私有制,财政税收最初以土地税为主,逐渐开辟出国有企业、金融垄断与卖官鬻爵。这个周期始于秦汉,结束于南朝。
  2. 第二个周期的制度特征是较为完善的中央集权制和科举制,经济上采用土地公有制,税收上实行较为复杂的租用调制,并辅以政府机关的自我经营。这个周期从北魏开始,结束于南宋。
  3. 第三个周期的特征是土地制度重回私有制,官僚制度上建立起了具有无限控制力的集权模式,并依靠封锁人们的求知欲形成了巨大的稳定性。这个周期在财政上是保守的,以土地税为主,放弃了纸币体系,回顾原始货币体系。这个周期从辽金出现萌芽,在元代继续发展,到了明清两代形成了稳定的模式。

这每个周期都包含若干个朝代,第一个朝代往往从混乱中建立新的官僚和财政制度,形成基础;在后来的朝代中即使出现了改朝换代,却往往没有彻底推翻前朝的制度基础,在管制、财政上有很强的继承性,所以被视为周期的延续;直到周期的最后一个朝代,巨大的社会崩溃引起社会基础的全面改变,才会被新的周期所取代。

汉代最初面对强大的诸侯制残余和崩溃的经济结构,他们采取了鼓励自由经济,并逐渐从财政上收缩诸侯权利的做法,并取得了成功,形成了初步的中央集权。但到了汉武帝时期,由于战争财政的需要,建立了国有企业和金融垄断,并以破坏正常官制为代价加强中央集权。王莽时期,政府进行了激烈的、带着计划经济色彩的财金计划,但是由于政府干预对社会经济的破坏太大,导致了政府垮台。

东汉继承了西汉的社会经济结构,也全盘接收西汉的社会弊病。由于官商结构的发展,东汉出现了板结的社会分层,这种分层一致持续到魏晋南朝,严重到影响政权存续。第一周期由于户籍人口消失、财税不足,政府变得羸弱不堪,最终因丧失了调整能力而崩塌。

北魏出现了土地公有制,历经西魏、北周和隋,到了唐初,形成了以政府分配和回收土地为特征的公有制。但是唐代的土地公有制去很快失败了。唐代的土地公有制崩溃直接影响了税收,此外政府必须同时统计人口、土地和家庭,由于这超出了当时政府的统计能力,对财政照成了巨大的拖累。所以,唐代虽然经济大发展,财政却不健康,这导致了安史之乱。唐代后期进行了土地私有制改革,加强专卖制度,形成了庞大的国有垄断经济。这些特征传给了五代、两宋,并在宋代形成了庞大的政府垄断。

宋代,因为高昂的战争和养官成本,需要庞大的财政支持,形成了中国离殇上最壮观的专卖制度,并开创了有历史性意义的纸币实验。纸币最初由民间发行,后由政府将发行权收归国有,并发现了纸币巨大的财富再分配效应。从这时开始,后代政府都通过纸币从民间吸取财富,形成了世界上最早的一系列恶性通货膨胀。

从辽金开始,土地制度重回私有制,放弃了不稳定的纸币系统,回归原始的货币制度,但是这套制度又足够简单,足以维持很长时间。实际上,清朝的经济一直处于高速发展中。但是到了清末,随着海外影响的到来,中国财政才开始了近代化的路程,并有了突破农业社会桎梏的机会。

这本书不仅仅是回顾古代,而且通过研究古代问题来研究现代。了解了古代的财政逻辑之后,再看待现代问题,就有个更广阔的视角。

垃圾收集(Garbage collection, GC)是 Java 流行的重要原因。GC 是一种能够自动回收不用内存的机制。本质上,GC 追踪所有正在被使用的对象,并将剩余对象标记为「垃圾」(garbage)。因为程序员不需要刻意将对象标记为「可回收」,所以 Java 的 GC 被认为是自动内存管理模式。GC 以低优先级线程运行。

对象生命周期

JVM 中对象的生命周期可以被分为3个阶段:

  1. 对象创建(Object creation)

创建对象通常使用 new 关键字:

1
Object obj = new Object();

当对象创建完成后,操作系统会分配特定大小的内存来保存对象。分配的内存大小与操作系 统体系结构以及 JVM 种类有关。

  1. 对象使用(Object in use)

在这个阶段,对象正在被应用程序的其他对象使用(其他对象有指向该对象的引用)。此时,对象被保存在内存中并且可能持有指向其他对象的引用。

  1. 对象销毁(Object destruction)

GC 系统检测每个对象,并做引用计数。当没有引用指向某个对象时,挡圈运行的程序没有任何方法可以访问该对象,所以就可以回收该对象所占用的内存。

垃圾收集算法

对象有程序员编写的代码,以及为了使用框架所提供的特性而创建,但是不需要显式地回收内存。内存的回收由运行在 JVM 层级的垃圾收集器完成。在 JVM 的进化过程中出现了很多垃圾收集算法。

标记-清除(Mark and sweep)

标记-清除 是最初也是最基本的垃圾收集算法,它有两个阶段:

  1. 标记活跃对象
  2. 删除不可达对象

在开始,GC 定义了一些特定对象,被称为根对象(Garbage Collection Roots),如:本地变量、当前执行方法、活跃线程、类静态域的输入参数等。GC 从根对象开始,沿着指向其他对象的引用遍历内存中的所有对象,将所有访问到的对象标记为「存活」。

在运行标记算法时,应用程序需要暂停运行,因为无法遍历不断变化的引用图。这被称为 Stop The World pause

第二个阶段清理内存。这一步可以用多种方法实现:

  1. 普通删除(Normal deletion):释放没有被引用的对象所占用的内存,不修改被引用的对象。memory allocator 保存着指向可以创建对象的空闲内存区域。这通常被称为标记-清除算法。

Normal-Deletion.png

  1. 删除-整理(Deletion with compacting):仅仅删除无用的对象是不够的,因为空闲内存以碎片的形式分布在内存中,如果创建较大的对象,但是却无法找到足够大的连续内存空间,则可能抛出 OutOfMemoryError
    为了解决这个问题,在删除无用对象后,会将存活的对象所占内存合并,以消除内存碎片。这使得分配新内存更容易也更快。这通常被称为标记-整理算法,

Deletion-with-compacting.png

  1. 删除-复制(Deletion with copying):这与标记-整理算法相似,它们都移动内存中存活对象的位置。不同之处在于,删除-复制算法将对象移动到不同的区域。

Deletion-with-copying-Mark-and-Sweep.png

并发标记删除垃圾收集(Concurrent mark sweep,CMS)

CMS 在本质上是对标记清除算法的升级。它使用多个线程扫描堆内存。它可以利用现代计算机多核的结构,在性能上有显著提升。

CMS 通过与应用程序线程并发地进行垃圾收集,尝试最小化程序的停顿时间。它在新生代使用并行的标记-复制算法,在老年代使用并发的标记-清除算法。

为了开启 CMS ,需要设置 JVM 的参数:

1
-XX:+UserConcMarkSweepGC

CMS 的优化选项

FLAG DESCRIPTION
-XX:+UseCMSInitiating\OccupancyOnly Indicates that you want to solely use occupancy as a criterion for starting a CMS collection operation.
-XX:CMSInitiating\OccupancyFraction=70 Sets the percentage CMS generation occupancy to start a CMS collection cycle.
-XX:CMSTriggerRatio=70 This is the percentage of MinHeapFreeRatio in CMS generation that is allocated prior to a CMS cycle starts.
-XX:CMSTriggerPermRatio=90 Sets the percentage of MinHeapFreeRatio in the CMS permanent generation that is allocated before starting a CMS collection cycle.
-XX:CMSWaitDuration=2000 Use the parameter to specify how long the CMS is allowed to wait for young collection.
-XX:+UseParNewGC Elects to use the parallel algorithm for young space collection.
-XX:+CMSConcurrentMTEnabled Enables the use of multiple threads for concurrent phases.
-XX:ConcGCThreads=2 Sets the number of parallel threads used for the concurrent phases.
-XX:ParallelGCThreads=2 Sets the number of parallel threads you want used for stop-the-world phases.
-XX:+CMSIncrementalMode Enable the incremental CMS (iCMS) mode.
-XX:+CMSClassUnloadingEnabled If this is not enabled, CMS will not clean permanent space.
-XX:+ExplicitGCInvokes\Concurrent This allows System.gc() to trigger concurrent collection instead of a full garbage collection cycle.

G1 垃圾收集器

G1(Garbage First)从 Java7 开始可用,并希望在未来逐渐替代 CMS。目前,在 Java8中,G1 是默认的垃圾收集器。G1 是并行、并发、分代收集、低停顿的垃圾收集器。

G1 将按区(region)将堆分割,每个 region 的大小通常为 2048 字节。每个 region 可能是新生代或老年代(新生代又被分为 eden 和 survivor region)。这允许 GC 不需要一次对整个堆进行垃圾回收,而是可以增量地进行。这意味着一次只在一部分 region 上进行垃圾回收。

Memory-regions-marked-G1.png

G1 追踪每个 region 中包含的存活数据的数量。追踪的结果被用于觉得那些 region 中 garbage 最多,最多的被最先回收。

和其他算法一样,整理的操作也会暂停程序的运行。但是我们可以配置暂停时间。G1将尽可能的满足配置。

最近工作上需要使用 Docker,在阅读「第一本 Docker 书」后了解了如何成为 Docker 的用户,但对 Docker 中用到技术却不甚了解。都说 Docker 是「新瓶装旧球」,文中笔者将学习到的 Docker 基础技术中的 Namespace,Cgroup 与 AUFS 记录如下。

Namespace

Linux Namespace 是 Linux 内核提供的一个功能,可以实现系统资源的隔离,如:PID、User ID、Network 等。Linux 中的 chroot 命令可以将当前目录设置为根目录,使得用户的操作被限制在当前目录之下而不影响其他目录。

假设我们成立了一家向外售卖计算资源的公司,用户购买了一个实例在运行自己的应用。如果某些用户能够进入到其他人的实例中,修改或关闭其他实例中应用的状态,那么就会导致不同用户之间相互影响;用户的某些操作可能需要 root 权限,假如我们给每个用户都赋予了 root 权限,那么我们的机器也就没有任何安全性可言了。使用 Namespace,Linux 可以做到 UID 级别的隔离,也就是说,UID 为 n 的用户在自己的 Namespace 中是有 root 权限的,但是在真实的物理机上,他仍然是 UID 为 n 的用户。

目前 Linux 共实现了 6 种不同的 Namespace。

类型 系统调用参数 内核版本
Mount Namespace CLONE_NEWNS 2.4.19
UTS Namespace CLONE_NEWUTS 2.6.19
IPC Namespace CLONE_NEWIPC 2.6.19
PID Namespace CLONE_NEWPID 2.6.24
Network Namespace CLONE_NEWNET 2.6.29
User Namespace CLONE_NEWUSER 3.8

UTS Namespace

UTS namespaces allow a single system to appear to have different host and domain names to different processes.

UTS(UNIX Timesharing System) Namespace 可以用来隔离 nodename 和 domainname 两个系统标识。在 UTS Namespace 中,每个 Namespace 可以有自己的 hostname。

我们运行下面程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
cmd := exec.Command("zsh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}

这段代码主要是通过系统调用 clone,并传入 CLONE_NEWUTS 作为参数创建一个新进程,并在新进程内运行 zsh 命令。在 Ubuntu 14.04 上运行这段代码,就可以进入一个交互环境,在环境中运行 ps -af --forest 就可以看到如下的进程树:

UTS PS

验证下父进程和子进程是否在同一个 UTS Namespace 中:

UTS NS

可以看到他们的 UTS Namespace 的编号不同。因为 UTS Namespace 对 hostname 做了隔离,所以在这个环境内修改 hostname 不会影响外部主机。

在目前的 zsh 环境中我们修改 hostname 并打印:

hostname-chuan

在宿主机上打印 hostname:

hostname-root

可以看到,外部的 hostname 没有被内部的修改所影响。

IPC Namespace

IPC namespaces isolate processes from SysV style inter-process communication.

IPC(Interprocess Communication) Namespace 用来隔离 System V IPC 和 POSIX message queues。每一个 IPC Namespace 都有自己的 System V IPC 和 POSIX message queue。

我们在上一段代码的基础上增加 CLONE_NEWIPC 标识,表示我们要创建 IPC Namespace。

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
cmd := exec.Command("zsh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}

在宿主器机查看并创建一个 message queue:

ipc-main

运行代码并查看 message queue:

ipc-chuan

PID Namespace

The PID namespace provides processes with an independent set of process IDs (PIDs) from other namespaces.

PID(Process ID) Namespace 可以用来隔离进程 ID。同一个进程在不同的 PID Namespace 中可以拥有不同的 PID。在 Docker Container 中,使用 ps -ef 可以看到启动容器的进程 PID 为 1,但是在宿主机上,该进程却又有不同的 PID。

继续在代码上添加 CLONE_NEWPID 为子进程创建 PID Namespace。

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
cmd := exec.Command("zsh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}

运行代码,首先在宿主机上查看进程树:

pid-main

可以看到 zsh 的 PID 为 11321。在 Namespace 中打印进程 PID:

pid-chuan

可以看到,打印出的当前 Namespace 的 PID 为 1,也就是说 11321 的进程被映射到 Namespace 中后 PID 为 1。

Mount Namespace

Mount namespaces control mount points.

Mount Namespace 用来隔离各个进程看到的挂载点视图。在不同的 Namespace 中,看到的挂载点文件系统层次是不一样的。在 Mount Namespace 中调用 mountunmount 仅仅会影响当前 Namespace 内的文件系统,而对全局文件系统是没有影响的。

在代码中,我们继续加入 CLONE_NEWNS 标识。

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
cmd := exec.Command("zsh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}

首先运行代码,然后查看 /proc 的文件内容:

mount-main

可以看到宿主机的 /proc 中文件较多,其中的数字是对应进程的相关信息。下面,将 /proc mount 到 Namespace 中。

mount-chuan

可以看到现在以 PID 命名的文件夹明显减少。下面使用 ps -ef 查看系统进程:

mount-chuan-ps

可以看到,在当前的 Namespace 中,zsh 是 PID 为 1 的进程。这就说明当前 Namespace 中的 mount 和外部是隔离的,mount 操作没有影响到外部。Docker 的 volumn 正是利用了这个特性。

User Namespace

User namespaces are a feature to provide both privilege isolation and user identification segregation across multiple sets of processes.

User Namespace 主要是隔离用户的用户组 ID。也就是说,一个进程的 User ID 和 Group ID 在 User Namespace 内外可以是不同的。比较常用的是,在宿主机上以一个非 root 用户运行创建一个 User Namespace,然后在 User Namespace 中被映射为了 root 用户。这意味着这个进程在 User Namespace 中有 root 权限,但是在宿主机上却没有 root 权限。

继续修改代码,添加 CLONE_NEWUSER 标识。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func main() {
cmd := exec.Command("zsh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID |
syscall.CLONE_NEWNS | syscall.CLONE_NEWUSER,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}

os.Exit(1)
}

首先在宿主机上查看当前用户和用户组:

user-main

接下来运行程序,并查看用户组:

user-chuan

可以看到,UID 是不同的,说明 User Namespace 生效了。

Network Namespace

Network namespaces virtualize the network stack. On creation a network namespace contains only a loopback interface.

Network Namespace 用来隔离网络设置、IP 地址和端口号等网络栈的 Namespace。Network Namespace 可以让每个容器拥有自己独立的网络设备,而且容器内的应用可以绑定到自己的端口,每个 Namespace 的端口都不会有冲突。在宿主机搭建网桥后,就能很方便地实现容器之间的通信。

我们继续在代码基础上添加 CLONE_NEWNET 标识。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func main() {
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID |
syscall.CLONE_NEWNS | syscall.CLONE_NEWUSER | syscall.CLONE_NEWNET,
}

cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}

os.Exit(1)
}

首先,在宿主机上查看自己的网络设备:

network-main

可以看到在宿主机上有 eth0 和 lo 等网络设备。下面,运行程序,并运行 ifconfig

network-chuan

我们发现,在 Namespace 中什么网络设备都没有。这可以断定 Namespace 与宿主机之间的网络是处于隔离状态的。

Cgroups

Linux Namespace 帮助进程隔离出自己的单独空间,而 Cgroups 则可以限制每个空间的大小。Cgroups 提供了对一组进程及将来子进程的资源限制、控制和统计的能力。

Cgroups 有三个组件:

  1. cgroup 负责对进程分组管理,一个 cgroup 包含一组进程并可以设置进程参数
  2. subsystem 是一组资源控制模块,可以关联到 cgroup 上,并对 cgroup 中的进程做出相应限制。
  3. hierarchy 可以把一组 cgroup 串成一个树状结构,这样 cgroup 可以做到继承。

Cgroups 中的 hierarchy 是一种树状结构,Kernel 为了使得对 Cgroups 的配置更加直观,通过一个虚拟的树状文件系统配置 Cgroups 的,通过层级的目录虚拟出 cgroup 树。我们可以在系统上做实验:

  1. 首先,创建并挂载一个 hierarchy
    cgroup-mount

    • cgroup.clone_childrencpuset 的 subsystem 会读取这个配置文件,如果这个值是 1,子 cgroup 才会继承父 cgroup 的 cputset 的配置
    • cgroup.procs 是树中当前节点 cgroup 中的进程组 ID
    • notify_on_releaserelease_agent 会一起使用。notify_on_release 标识当这个 cgroup 最后一个进程退出的时候是否执行了 release_agentrelease_agent 使进程退出后自动清理掉不再使用的 cgroup
    • tasks 标识该 cgroup 下的进程 ID,将进程 ID 写入 tasks 文件中,便会将相应进程加入到这个 cgroup 中
  2. 在刚创建好的 hierarchy 上 cgroup 的根节点中拓展出两个子 cgroup
    cgroup-tree
    可以看到在 cgroup 目录下创建文件夹的时候,Kernel 会把文件夹标记为子 cgroup,她们继承父 cgroup 的属性。

  3. 在 cgroup 中添加和移动进程只需要将进程 ID 写到或移动到 cgroup 节点的 tasks 文件中即可

cgroup-mv

这样,我们就把当前的 3217 进程加入到 cgroup-test:/cgroup-1 中了

  1. 通过 subsystem 限制 cgroup 中的进程的资源。我们使用系统为每个 subsystem 默认创建的 hierarchy,如 memory 的 hierarchy 来完成实验。

cgroup-mem

cgroup-stress

可以看到系统总的内存为 2GB,其中 stess 只能占用到 5% 左右,也就是 100MB。

网络是个巨大而复杂的系统,在我们尝试了解网络如何工作的时候,首先需要从高空俯瞰网络的全貌,否则如果过早地沉溺于细节,就很难理解每一种网络技术背后的意义;如果无法理解技术的本质意义,就只能停留在死记硬背的程度,无法做到实际运用。当年在课上学习了计算机网络,课本上的内容基本都仅仅用来应付考试和求职面试了,而没有真正内化为自己的实力。最近朋友推荐了《网路是如何连接的》,日本技术人员的书从来都以知识面广、细致周全著称,所以我希望抛开学院派的教条,理解网络的全貌。

网络的全貌

对网络的探索之旅从在浏览器地址框输入网址开始,随后浏览器解析网址并利用 DNS 服务获取域名对应的服务器 IP 地址,为了发送数据到服务器,浏览器委托系统的网络协议栈将消息打包并加上目的地址等控制信息,然后协议栈将数据包交给主机网卡,主机网卡将数据包转换为比特流发送到服务器。主机通过光纤等线路接入网络运营商,网络运营商对比特流进行分拣,再通过骨干网络的路由器的不断接力将信息最终传递到 Web 服务器。通过骨干网络后,网络包抵达 Web 服务器,服务器的防火墙将对包进行检查并决定是否放行。信息包在通过防火墙后,可能还会遇到缓存和负载均衡器,最终被服务器处理。在服务器中,数据包被还原成为原始的请求信息,并通过服务器主机的协议栈交给程序,服务器程序将响应数据按同样的流程回传给客户端浏览器。最终浏览器得以将响应信息渲染为网页。

浏览器发送请求

在地址框输入网址后,浏览器首先会解析 URL 得到访问方法(HTTP,FTP)、域名、端口号和文件路径。之后,浏览器生成符合 HTTP 格式的请求信息。

生成 HTTP 消息之后,浏览器需要委托操作系统将消息传递到服务器,为了发送消息,操作系统需要知道服务器的 IP 地址。因此,浏览器会通过请求 DNS 服务器来获取服务器的 IP 地址。在主机中,请求 DNS 服务器查询 IP 地址的功能通常包含在操作系统的 Socket 库中。浏览器调用 Socket 库中的解析器(resolver)完成 IP 地址的查询。

Socket 是调用网络功能的程序组件集合。

与浏览器一样,解析器在生成 DNS 请求后也需要委托操作系统的网络协议栈发送请求。从这里我们也可以看出,委托协议栈发送消息是一个通用的过程,与具体应用无关,这体现了程序设计上的解耦合原则。

DNS 系统是典型的、非常成功的分布式系统。在收到客户端发出的 IP 地址解析请求后,如果本地没有保存域名对应的 IP 地址,DNS 服务器会按照树形结构不断向上级 DNS 服务器查询,直到顺藤摸瓜找到 IP 地址或确认不存在该域名对应的 IP 地址。

在查询到服务器的 IP 地址,并生成 HTTP 请求内容后,浏览器则会委托系统协议栈完成数据的发送。

协议栈发送数据

委托协议栈发送数据同样通过调用 Socket 库来完成。协议栈通过 TCP 协议发送数据包裹四个阶段:

  1. 创建套接字
  2. 连接服务器
  3. 收发数据
  4. 断开连接并删除套接字

协议栈的内部结构为:

协议栈内部结构

在协议栈中,上面的部分想下面的部分委派工作,下面的部分接受委派并实际执行。所以,当调用 Socket 库时,实际的工作会有操作系统的协议栈完成。协议栈中的 TCPUDP 分别都使用了 IP 协议,最终 IP 协议通过网卡驱动程序使用网卡实际发送数据。

协议栈是根据套接字中记录的控制信息来工作的

套接字本身是一个概念,而用于控制通信操作的控制信息构成了套接字的实体,协议栈在执行操作时需要参阅套接字中的控制信息。我们在系统中使用 netstat 命令就可以看到系统中当前活跃地套接字。

enter image description here

当浏览器调用Socket库中 socketconnectwritereadclose等操作时,应用程序与Web 服务器的交互可以被总结为下图:

enter image description here

创建套接字

调用 socket 申请创建套接字。协议栈首先分配用于存放套接字所需的内存空间,并写入初始状态。然后,将该套接字的描述符返回给应用程序。收到描述符后,应用程序在进行收发数据委托时就需要提供这个描述符。由于套接字中记录了通信双方的信息以及通信处于怎样的状态,所以只要通过描述符确定了套接字,协议栈就能获取所有信息。

连接服务器

创建套接字后,应用程序调用 connect 将本地套接字与服务器套接字进行连接。所谓“连接”,实际上是通信双方交换控制信息。在套接字创建结束后,并没有保存任何信息,也不知道目标通信对象是谁;而服务器端也同样不知道将要和那一台主机通信。所以,客户端和服务器端需要通过“连接”来做好双方通信的准备。

连接过程中需要的控制信息有两类:

  1. 数据包头部信息(如 TCP 数据包头部)
  2. 套接字中记录的信息

“连接”过程由应用程序调用 connect 函数开始。

1
connect(sockfd, socket_addr, socket_port)

该调用将服务器的 IP 地址和端口号传递给协议栈中的 TCP 模块,然后 TCP 模块会用过 IP 协议于对应的服务器端的 TCP 模块交换控制信息。首先,客户端先创建一个包含表示开始数据收发操作的控制信息的头部,将 SYN 设为1,并设置适当窗口大小等。当头部创建好之后,TCP 模块委托 IP 模块进行发送到服务器端的 TCP 模块。之后,服务器的 TCP 模块返回响应,并将头部中 ACK 设置为1。接下来,客户端收到响应,连接被正确建立。这也就是我们知道的“TCP三次握手”。

建立连接后,控制流程被交回到应用程序。

收发数据

收到数据的过程从应用程序调用 write 把数据交给协议栈开始。对于协议栈来说,要发送的数据就是一定长度的二进制比特流。在发送数据之前,协议栈会首先将数据保存在发送缓冲区中,等到缓冲区满,或者达到一定的等待时间后就才发送。但是缓冲区中信息的长度和等待时间是矛盾的,如果长度优先,那么网络利用效率会提高,但是可能因为等待缓冲区满而造成延迟;如果时间优先,那么等待时间变少,网络利用效率又会降低。TCP 协议的规格没有规定如何实现数据长度和等待时间的平衡,所以不同平台的做法会有差异。

TCP 在协议在收发数据时会对较大的包进行拆分,并使用序号和 ACK 的保证信息发送的完成,并能在客户端和服务器之间实现全双工通信。首先,客户端在连接时计算出发送数据到服务器时的序号初始值,并将初始值发送个服务器;服务器根据初始值计算 ACK 号并返回给客户端;同时,服务器也计算出与客户端通信使用的初始值,并发送给客户端;同样客户端也需要根据服务器发来的初始值计算 ACK 号并返回。

为了适当的设置 ACK 的超时等待时间,TCP 采用了动态调整等待时间的方法,这个时间根据 ACK 号返回所需的时间来判断。若返回变慢,则延长等待时间,若变快,而减少等待时间。为了在等待 ACK 时不浪费计算资源,TCP 使用滑动窗口方法来管理数据发送和 ACK 的操作。接收方在收到数据包后,会根据序号将数据包合并,仅仅确认最后一个收到的数据包的序号,这样大大缓解的出现网络拥挤的可能。

在发送消息后,应用程序调用 read 来委托协议栈获取响应消息。和发送数据一样,接收数据也同样需要将数据保存在缓冲区,在等待收到数据期间,协议栈会将应用程序挂起,等到收到响应消息后再继续执行。

断开连接并删除套接字

在客户端与服务器之间断开连接的过程则就是我们熟知的 TCP “四次挥手”的过程。首先由客户端生成 TCP 头部,将 FIN 置为 1 发送给服务器,服务器返回确认;然后再由服务器生成 TCP 头部,置控制信息 FIN 为 1 发送给客户端,最后客户端返回确认。

在断开连接之后,原来的通信使用的套接字就不会再使用了。但是为了防止误操作,客户端或服务器会等待一段时间之后才真正删除套接字。

IP 协议发送数据包

IP 协议的职责仅仅是将委托的信息打包送到对方手里,或者接收对方发来的数据包。

IP 协议发送数据包的起点是 TCP 模块委托 IP 模块发送数据包的操作。TCP 模块在 TCP 分组前添加上 TCP 头部,然后整个传给 IP 模块。收到委托后,IP 模块会 TCP 分组当做整块数据,并在前面加上 IP 头部和 MAC 头部。IP 头部包含发往目的地所需要的信息,MAC 头部包含通过以太网将包传输至最近的路由器所需要的控制信息。其中的 MAC 头部中的目的 MAC 地址通过向网络广播 ARP 请求来获得。接下来,封装好的包会被交给网络硬件,如网卡。通过网卡将信息转化为点信号或光信号,并通过网线发送出去,然后这些信号就会到达集线器、路由器等设备,再由转发设备一步一步地送达接收方。

网络设备 —— 集线器、交换机和路由器

从计算机发送出来的网络包会通过集线器、路由器等设备转发,最终到达目的地。转发设备根据包头部中的控制信息,查询转发表判断包的目的地,然后将包朝目的地方向转发。也就意味着,HTTP 请求方法、TCP 的确认响应和序号,客户端和服务器之间的关系,这一切都与包的传输无关。所有的包在传输过程中都是独立的,相互之间没有任何关系。

集线器

从信号流出网卡进入网线开始,网卡中的 PHY(MAU) 将包转换成电信号,电信号通过 RJ-45 接口进入双绞线。为了防止信号在传输过程中出现失真、减少噪声对信号的干扰,网线使用的是双绞线。双绞线通过两根信号线的缠绕抵消外源性噪声,通过改变节距抑制内源性噪声。

当信息通过双绞线到达集线器后,会被广播到整个网络中。以太网将包发送到所有设备,然后由设备根据接收方 MAC 地址来判断应该接收哪些包。由于集线器只是原封不动地将信息广播出去,所以即使信号受到噪声的干扰发生了失真,也会原样发送到目的地。这是接收信号的设备(交换机、路由器、服务器等),会在将信号转换成数字信息后通过 FCS(帧校验序列)校验发现错误,并将出错的包丢弃。因为丢弃的包不会触发确认响应,所以协议栈的 TCP 模块会检测到丢包,并对该包进行重传。

交换机

交换机的设计是将网络包原样转发到目的地。首先,信息到达网线接口,接下来,PHY(MAU) 将网线中的信号转换为通用格式,然后传递给 MAC 模块。MAC 模块将信号转换为数字信息,然后通过包末尾的 FCS 校验错误,如果没问题册存放到缓冲区中。交换机中网线接口和后面的电路部分加在一起成为一个端口,或者说交换机的一个端口就相当于计算机上一块网卡,但是交换机的端口不具有 MAC 地址。将包存储缓冲区后,要查询一下这个包的接收方 MAC 地址是否已经在 MAC 地址表中有记录了,如果有,就可以通过交换电路将包发送到相应的端口了。特殊的,当交换机发现一个包要发回源端口是,就会直接丢弃这个包。

交换机在转发包的过程中,需要对 MAC 地址表的内容进行维护。

  1. 收到包时,将发送方 MAC 地址以及其输入端口的号码写入 MAC 地址表中。
  2. 删除地址表中某条记录的操作,这是为了防止设备移动是产生问题。

交换机相对于集线器的另外一个优势在于,交换机的全双工模式可以同时发送和接收数据。交换机中有自动协商功能,可以由相互连接的双方探测对方是否支持全双工模式,并自动切换成相应的工作模式。此外,交换机可以同时转发多个包。相对的,集线器会将输入信号广播到所有端口,如果同时输入多个信号就会发生碰撞,无法同时传输多路信号。从整体转发能力看,交换机高于集线器。

路由器

网络包经过集线器和交换机后,就到达了路由器,并在此被转发到下一个路由器。这一步转发的工作原理和交换机类似,也是通过查表判断包转发的目标。不过在具体的操作上,路由器和交换机是有区别的。因为路由器是基于 IP 设计的,而交换机是基于以太网设计的。

路由器在转发包时,首先会通过通过端口将发过来的包接收进来,这一步的工作取决于端口对应的通信技术。接下来,转发模块会根据接收的包的 IP 头部中记录的接收方 IP 地址,在路由表中进行查询,一次判断转发目标。然后,转发模块将包转移到转发目标对应的端口,端口再按照硬件的规则将包发送出去,也就是转发模块委托端口模块将包发送出去。路由器的以太网端口具有 MAC 地址,因此它就能够成为以太网的发送方和接收方。端口还具有 IP 地址。当转发时,首先路由器端口会接收发送自己的以太网包,然后查询转发目标,再由相应端口作为发送方将以太网包发送出去。而交换机只将收到的包转发出去,而自己不会成为发送方或接收方。

在「查表判断转发目标」中,路由器是根据 IP 头部的 IP 地址来判断的。路由表中包含:目标地址、子网掩码、网关、接口、跃点数。实际上第一列的 IP 地址只包含表示子网的网络号部分的比特值,而表示主机号部分的比特值全部为 0。打个比方,路由器在转发时只看接收方地址属于哪个区。

为了知道网络号的比特数,路由表中还有一列子网掩码,通过子网掩码可以判断网络号的比特数。路由器会对路由表中的地址进行路由聚合或路由拆分。路由聚合就是将多个子网合并为一个子网,而路由拆分则会将一个子网拆分成多条记录。

路由器的包接收操作

首先,信号到达网线接口部分,PHY(MAU) 模块和 MAC 模块将信号转换成数字信号,然后通过 FCS 进行错误校验,如果没问题则检查 MAC 头部中的接口方 MAC 地址,看看是不是发给自己的包,如果是就放到接收方缓冲区中,否则就丢弃。完成包接收后,路由器就丢弃包开头的 MAC 头部。接下来,路由器会根据 MAC 头部后方的 IP 头部中的内容进行包的转发操作。首先查询路由表判断转发目标,这一阶段,路由器会选择网络号的最长匹配,如果长度相同的有多条,则选择跃点数最小的记录。如果在路由表中无法找到匹配的记录,路由器会丢弃这个包,并通过 ICMP 消息告知发送方。

路由表中的最后一样通常为默认路由,这条记录填写接入互联网的路由器地址,被称为默认网关。

在将网络包发送出去之前,路由器还有工作要做。第一是,更新 IP 头部中的 TTL(生存时间)字段,包每经过一个路由器的转发,这个值就会减1,当这个值变成0时,就表示超过了有效期,这个包就会被丢弃。此外,如果包过大,路由器会通过分片功能拆分网络包。路由器也会使用 ARP 来查询下一个转发目标的 MAC 地址。

IP(路由器) 负责将包送达通信对象这一整体过程,而其中将包传输到下一个路由器的过程则是由以太网(交换机)来负责的。

进入互联网内部

互联网的基本工作方式和家庭、公司网络一样,都是通过路由器来转发包,而路由器的基本结构和工作方式也没有什么不同。主要的区别在于距离的不同和路由的维护方式不同。

互联网接入路由器是按照接入网规则来发送包的。ADSL 连接方式如图。

enter image description here

首先,客户端生成网络包,经过集线器和路由器到达互联网接入路由器,并在此从以太网包中取出 IP 包并判断转发目标。接下来,如果互联网接入路由器和 ADSL Modem 之间是通过以太网连接的,那么就会按照以太网的规则执行包发送的操作。不同的是,发送信号时,网络包会包上 MAC 头部、PPPoE 头部、PPP 头部,然后按照以太网规则转换成电信号后被发送出去。

互联网接入路由器将包发送出去后,包就到达了 ADSL Modem,然后,ADSL Modem 会把包拆分成很多小格子,每个小格子称为一个信元。然后,ADSL Modem 采用一种用正弦波,使用振幅(ASK)和相位调制(PSK)相结合的正交振幅调制方式对信号进行合成。信元转换为电信号之后,信号会进入一个叫做分离器的设备,然后 ADSL 信号会和电话语音信号混合起来一起从电话线传输出去。

从分离器出来后,就是插电话线的接口,信号会通过电话线到达大楼的 IDF 和 MDF 与外部相连接。信号通过电话线达到电话局之后,会经过配线盘、分离器到达 DSLAM(多路 ADSL Modem)。在这里,电信号会被还原成数字信息——信元。信元从 DSLAM 出来之后,会到达一个叫做 BAS 的包转发设备,然后被还原成原始的包。BAS 是用户登录操作的窗口,通过 PPP 和 PPPoE 协议实现了用户认证和配置下发的功能。接下来,BAS 会将包前面的 MAC 头部和 PPPoE 头部丢弃,取出 PPP 头部以及后面的数据。然后,BAS 会在包的前面加上隧道专用头部,并发送到隧道的出口。

现在网络包已经通过接入网,到达了网络运营商的接入路由器(POP)。通过 POP 发送的包会汇总到网络运行中心(NOC)中。NOC 是运营商的核心设备配备了高性能路由器。互联网内部使用 BGP 机制在运营商之间交换路由信息。对于互联网内部的路由器来说,无论最终目的地是否属于同一家运营商,都可以从路由表中查到,因此只要一次接一次按照路由表中的目标地址来转发包,最终一定可以到达目的地。

服务器端局域网

网络包从互联网到达服务器的过程根据服务器部署地点的不同而不同。最简单的,服务器直接部署在公司网络上,可以从互联网直接访问。但现在几乎不使用这种方法,原因之一是公网 IP 有限,第二是安全问题,服务器完全暴露在公网中,安全漏洞也都会暴露出来,可以说是“裸奔”。

因此,业界一般会部署防火墙。它只允许允许发往指定服务器的指定应用程序的网络包通过,从而屏蔽其他不允许通过的包。部署防火墙后需要设置包过滤规则,首先,要观察包是如何流动的。通过接收方和发送方 IP 地址,我们可以判断出包的起点和终点。当要限制某个应用程序时,可以在判断条件中加上 TCP 头部或者 UDP 头部中的端口号,例如仅允许访问服务器的 80 端口。不仅仅要设置互联网和公开区域之间的包过滤规则,还需要设置公司内网和互联网之间之间,或者公司内网与公开区域之间的包过滤规则。总之,我们可以在防火墙中设置各种规则,判断是否允许通过。

当服务器的访问量上升,增加服务器线路的带宽是有效地,但并不是网络变快了就可以解决所有的问题。要解决服务器 CPU 过重的问题,可以采用多台 Web 服务器,减少每台服务器的访问量。一般,公司或组织内会使用负载匀衡器分配对服务器的访问。使用负载均衡器时,首先要将负载均衡器的 IP 地址代替 Web 服务器的实际地址注册到 DNS 服务器上。当负载均衡器收到请求,就会判断将请求转发给哪台 Web 服务器。

除了使用多台功能相同的 Web 服务器分担负载之外,还可以将整个系统按功能分成不同的服务器,如 Web 服务器、数据库服务器、缓存服务器等。缓存服务器时一台通过代理机制对数据进行缓存的服务器,它可以将 Web 服务器返回的数据保存在磁盘中,并可以代替 Web 服务器将磁盘中的数据返回给客户端。缓存服务器可以减轻 Web 服务器的负担,缩短 Web 服务器的处理时间。内容分发服务(CDN)也可以起到减轻服务器负担的作用。

终点——服务器

服务器根据功能的不同有很多种,其硬件和操作系统与客户端有所不同。但是,网络相关的部分,如网卡、协议栈、Socket库等功能和客户端没有什么区别。在连接过程中,客户端发起连接,而服务器等待连接操作,因此 Socket 库的用法有所不同。此外,服务器的程序可以同时和多台客户端计算机进行通信。

当服务器启动并读取配置文件时完成初始化操作后,就会运行等待连接模块。连接模块会创建套接字,然后进入等待连接的暂停状态。接下来,当客户端发起连接时,这个模块会恢复运行并接受连接,然后启动客户端通信模块,并移交完成连接的套接字。此后,客户端通信模块就会使用已连接的套接字与客户端通信,通信结束后,这个模块就退出了。

服务器调用 Socket 库创建套接字。

  1. 创建套接字
  2. 将套接字设置为等待连接状态
  3. 接受连接
  4. 收发数据
  5. 断开管道并删除套接字

首先,协议栈调用 socket 创建套接字,接下来调用 bind 将端口号写入套接字中。设置好端口号之后,协议栈会调用 listen 向套接字写入等待连接状态这一控制信息。这样一来,套接字就会开始等待来自客户端的连接网络包。然后,协议栈会调用 accept 来接受连接。一旦客户端的包到达,就会返回响应包并开始接受连接操作。接下来,协议栈会给等待连接的套接字复制一个副本,然后将连接对象等控制信息写入新的套接字中。到这里,我们就创建了一个新的套接字,并和客户端套接字连接在一起了。而最初的那个套接字还会以等待连接的状态继续存在,当再次调用 accept,客户端连接包到达时,它又可以再次执行接收连接操作。为了区分端口号相同的套接字,服务器使用:客户端 IP 地址、客户端端口号、服务器 IP 地址和服务器端口号识别套接字。

服务器收到网络包后,首先由网卡接收信号,然后将其还原成数字信息。接下来需要根据包末尾的帧校验序列(FCS)来校验错误,然后与包末尾的 FCS 值进行比较。当 FCS 一直,接下来检查 MAC 头部中的接收方MAC 地址,看看这个包是不是发给自己的。然后,还原后的数字信息被保存在网卡内部的缓冲区中。因为接下来接收操作需要 CPU 参与,因此网卡需要通过中断将网络包到达的的事件通知给 CPU。CPU 切换到网卡任务后,网卡驱动会从缓冲区中将包读取出来,根据头部中字段调用负责处理该协议的软件。这里,会调用 TCP/IP 协议栈,并将包转交给它。当网络包转交到协议栈时,IP 模块会首先开始工作,检查 IP 头部。IP 模块首先会检查 IP 头部的格式是否符合规范,然后检查接收方 IP 地址,看包是不是发给自己的,若不是,则丢弃。接下来需要检查包有没有被分片,如果是分片的包,则将包暂时存放在内存中,等所有分片到达之后将所有分片组装起来还原成原始包;如果没有分片,则直接保留接收时的样子,不需要进行重组。

接下来检查 IP 头部的协议号字段,并将包转交给相应的模块(TCP/UDP)。如果收到的包是 TCP 发起连接的包,则 TCP 模块会确认头部控制位 SYN;检查接收方端口号;为相应等待连接套接字复制一个新的副本;记录发送方 IP 地址和端口号等信息。

当收到的数据包时,TCP模块会:

  1. 根据收到包的发送方 IP 地址、发送方端口号、接收方 IP 地址、接收方端口号找到对应的套接字
  2. 将数据块拼合起来保存在接收缓冲区中
  3. 向客户端返回 ACK

当数据收发完成后,便开始执行断开操作。服务器程序首先会调用 Socket 库的 close,TCP 模块会生成一个控制位 FIN 为 1 的 TCP 头部,并委托 IP 模块发送给客户端。当客户端收到这个包后,会返回一个 ACK 号。接下来客户端调用 close,生成一个 FIN 为 1 的 TCP 头部发给服务器,服务器再返回 ACK 号,这时断开操作就完成了。当断开操作完成后,套接字会在一段时间后被删除。

在 Web 服务器中,read 获取的数据内容就是 HTTP 请求消息。服务器程序会根据收到的请求消息从的内容进行相应的处理,并生成响应消息,再通过 write 返回给客户端。请求消息包括一个“方法”的命令,以及数据源的 URI,服务器程序会根据这些内容想客户端返回数据。

当服务器完成对请求消息的各种处理后,就可以返回响应消息了。这与客户端想服务器发送请求消息是时的工作过程相同。Web 服务器发送的响应消息会被分成多个包发送给客户端,然后客户端需要接受数据。

浏览器在收到数据后,为了显示内容,首先需要判断响应消息中的数据属于那种类型,原则上通过 Content-Type 头部字段判断。在得到数据类型后,浏览器就可以做出相应的反应。最终,浏览器就可以显示数据,并等待用户下一个操作了。

从输入网址到显示出网页内容,这个过程只有短短几秒。然后,这几秒的背后,离不开各种设备和软件的相互配合。这段探索之旅,我们可以看到网络的全貌,之后,就可以根据自己的兴趣,深入探索了。

上帝的手术刀

从中学生物课本上,我们就知道了 DNA 里保存了人类的全部遗传信息。不同的细胞根据功能的不同,利用 DNA 的不同部分合成各式各样的蛋白质,蛋白质决定了我们生命的特点。毕业多年,我把曾经学到过的生物知识都还给老师了,也对现在生物科技的发展状况毫不了解。听说过几家上市的生物科技公司,可对他们具体拥有什么科技,推出了什么产品一无所知。《上帝的手术刀》是浙江大学教授王立铭写的一本科普书籍,从古希腊哲学家对生命的猜想,到现在各大实验室的生物科技竞争,到对未来生物科技的展望,这本书可以当成一部人类探索遗传秘密的编年史。

人类从石器时代进步到农业时代的一大标志就是开始利用生物遗传的力量来为自己生产食物。在驯化了野生小麦、驯养肉猪的过程中,人类就是利用遗传的规律改造其他物种,而保留自己需要的性状。古希腊哲学家任务生物体内存在“泛生子(pangene)”颗粒,它记录了遗传信息并在交配后进入下一代体内。19世纪达尔文和进化论提出生物进化,孟德尔提出基因的显性和隐形,证明基因会在生物的一代代繁衍中顽强的存在,并在合适的情况下重新表现出来,影响生物的性状。20世纪以来,人类逐渐在实验中证明了 DNA 是遗传物质,发现了 DNA 的双螺旋结构和在遗传过程中的半保留复制,并通过实验证明了 DNA 中每三个碱基对应一个密码子,每个密码子编码了唯一的氨基酸,氨基酸最终被装配为蛋白质,蛋白质催生各种化学反应,最终多种生命现象得以发生。一系列实验的结论最终被概括为“中心法则”:

DNA -> RNA -> 蛋白质。DNA 首先根据碱基互补原则以自己为模板制造一条 RNA 长链;然后 RNA 再根据3碱基对应一个氨基酸的原则制造蛋白质。

随后,基于对基因的了解来诊断、治愈遗传疾病的尝试也被积极开展。镰刀型红细胞贫血症、艾滋病的治愈案例的出现促使全世界展开了上百项基因治疗实验,但是其中却大多数以失败告终。基因治疗成功的案例吸引大量资源和资金的注入,使得更多研究得以展开,但是更多的失败案例则让基因治疗转入低谷。在不断起伏中,“基因编辑”的技术储备逐渐完成。

基因编辑的“三件套”为:

  1. 基因组GPS:锌手指蛋白组合
  2. 基因组剪刀:FokⅠ蛋白的剪切模块
  3. 基因组针线:细胞内天然存在的两套DNA断点修复机制

2011和2012年的“神话”蛋白和CRISPR蛋白的相继发现把人类正式带入了基因的“完整可编程”时代。

目前,基因编辑技术的可以的应用阶段可以简单总结为“来自人体-体外处理-体内治疗”,这样的好处在于安全性,避免外源基因给人来带来危害;未来,为了治疗更广泛的疾病,基因编辑的第二阶段——“来自人体-体内处理-体内治疗”是自然而然的发展;为了实现基因治疗的普适性,降低治疗成本,基因编辑拜托“来自人体”的限制,医药企业将能够开发的大批量生产普适药物,让普通人也能够承担;在未来的未来,“体内预防”也或许能够被实现。关于基因编辑的前景,还有广阔的发展的想象空间。

在美好的发展前景后,基因技术也带来了争议、风险,伦理的讨论也无休无止。疾病的“预防”被实现后,那么“改善”也会随之而来,人类可以按自己的喜好来修改自己或者后代的特征。因为能负担基因改善的人总是少数,那么这是不是会加剧不平等、固话社会阶层?在极端情况下,人类甚至可以通过编辑基因来产生新的物种。那时候,人类自己会处于什么位置?这些问题人类只能随着技术的进步去一一应对。

书中对可以基础研究的价值表示了极大的肯定。在基因技术的发展过程中,出现了无数的柳暗花明,如 DNA 双螺旋结构的证明,到后来锌手指蛋白、“神话”蛋白、CRISPR蛋白的发现统统都是在生物领域的冷门基础研究中,“幸运”地被发现的。而这些幸运的发现总是让人感叹大自然的鬼斧神工。生物进化过程中,获得了一整套无比简洁而高效的机制,比如只使用3个碱基对应一个氨基酸,而绝不浪费额外的资源;基于编辑的“三件套”更是全部本来就存在于生物体内,人类所做的并不是重新设计,而是发现和推广。

作为一个程序员,我相信科技发展是不可逆的,它能够让人类社会想好的方向发展。现在,我们经历的每个瞬间都有可能被载入史册,无论是生物科技,还是人工智能,我们都应该用开放的心态拥抱他们,也用严格的监管管控它们,耐心期待技术的成熟,并为人所用。