机械硬盘的性能评估

概述

从个人 PC 到数据中心,机械硬盘都扮演着不可或缺的角色。从性能、存储容量等方面来考虑,机械硬盘一直都是一个不错的选择。因此,了解机械硬盘的性能评估方式也很有必要。

机械硬盘的组成结构

从物理视角来看,主要组件如下:

  1. 盘片(Platter): 一个机械硬盘一般由多个盘片组成,每个盘片都有两面,每一面都可以存储数据。
  2. 转轴(Spindle): 转轴会连接到一个电机上,驱动盘片的转动。常见的转速有:5400 rpm, 7200 rpm, 10000 rpm 和 15000 rpm。
  3. 读写磁头(Read/Write Head): 每个盘片的面都有一个对应的读写磁头,负责在该盘面上进行读写。
  4. 机械臂杆(Actuator Arm): 磁头连接到机械臂杆上,所有磁头在不同的磁道上移动时是同步的。
  5. 驱动控制主板:上面包括了微信处理器,内存,电路以及一些固件。这些固件负责控制转轴电机的电源,电机的速度。同时也控制了硬盘和主机的通信。此外,通过移动磁头,以及在不同磁头间的切换来控制硬盘的读写操作。

从逻辑视角来看:

  1. 磁道:盘片的每一面上都有多个磁道,每个磁道都是一个同心圆。
  2. 扇区:每个磁道被划分成多个扇区。

详细信息可参考:https://www.jianshu.com/p/cf100e39ccdf

性能评估维度

寻道时间(Seek Time)

机械硬盘在读写数据时,首先需要将磁头移动到指定的磁道上。这个时间为 Seek Time。一般情况下,机械硬盘的厂商会提供以下几个场景的 Seek Time 参数:

  • Full Stroke: 这个时间用来描述磁头从最里面的磁道移动到最外面的磁道所需要的时间。
  • Average: 从随机的磁道移动到另一个磁道所需的时间。一般是 1/3 的 Full Stroke 时间。
  • Track-to-Track: 在相邻的磁道之间移动磁头所需要的时间。

现在的机械硬盘 Average 时间一般在 3~15ms 左右。

旋转延迟(Rotational Latency)

在将磁头移动到指定磁道后,还需要转动磁盘盘片,将磁头指到特定的扇区以供读写。这个时间为 Rotational Latency,和硬盘的转速紧密相关。一般情况下,Average Rotational Latency 为 Full Rotational Latency 的一半。

以 5400 rpm 转速的硬盘为例,每分钟转动 5400 转,即 Full Rotational Latency 为 60*1000/5400 = 11.11ms。那么 Average Rotational Latency 就是 5.5 ms 左右。

数据传输速率(Data Transfer Rate)

image-20220211001141721

如上图所示,机械硬盘的数据传输速率有两个检测点:

  • 外部数据传输速率(External transfer rate): 这个是从硬盘外写入到硬盘内 Buffer 区域的速度。
  • 内部数据传输速率(Internal transfer rate): 这个是从硬盘内 Buffer 通过磁头写入到盘片中的速度。

一般来说,External transfer rate 都要远大于 Internal transfer rate。

IOPS

从上面总结到的三个维度,我们可以知道,一次 I/O 的时间为:

T(s) = T + L + X

其中,T 为平均的寻道时间,L 为平均旋转延迟,X 为数据传输时间。对于一块 7200rpm,平均寻道时间为 5ms,内部数据传输速率为 40MB/s 的机械硬盘来说。每次大小为 32KB 的 I/O 需要的时间为:

T(s) = 5ms + (60*1000ms/7200)/2 + 32KB/40MB*1000ms = 5ms + 4.17ms + 0.78ms = 9.95 ms

IOPS 描述的是每秒的 I/O 次数,那么可以得出该硬盘的 IOPS 为:1000/9.95 = 100.5 IOPS。

硬盘 I/O 控制器的利用率

除了上述硬盘本身的性能参数,我们还可以从实际使用时磁盘 I/O 控制器的利用率来评估。我们可以将硬盘当作黑盒,只有以下两个组件构成:

  • 队列:在 I/O 请求被处理之前,都被存放在队列中等待。
  • 硬盘 I/O 控制器:控制器负责从队列中取出 I/O 请求并处理。

disk-io-rate

如上图所示,应用产生的 I/O 请求先到达 I/O 队列中,由 I/O 控制器取出并处理。如果该队列的长度持续增加,那么每个 I/O 的平均响应时间也是持续增加的。可以得出:

平均响应时间 = T(s)/(1-利用率)。

根据该公式可以得出下图:

graph

平均响应时间的增长并不是线性的,当利用率越高,增长会越快。整个增长的拐点大概在 70% 处。所以一般情况下,我们要保证我们的应用使用的磁盘利用率在 70% 左右,才能保证一个较好的性能。

calico IPIP 分析

概述

当集群中所有的主机都在同一个二层时,calico cni 可以仅靠路由,使得所有的 Pod 网络互通。但是纯二层的环境在很多场景下都不一定能满足,因此当主机之间仅3层互通时,就可以使用 calico IPIP(全称 IP in IP) 模式。

IP in IP 是一种 IP 隧道协议,其核心技术点就是发送方将一个 IP 数据包封装到另一个 IP 数据包之中发送,接受方收到后,从外层 IP 数据包中解析出内部的 IP 数据包进行处理。常用在 VPN 等技术中,用来打通两个内网环境。

calico IPIP 流量分析

之前的文章proxy_arp在calico中的妙用简单讲了 calico 是如何通过路由打通不同主机上的 Pod 网络的,其实这个方案有一个前提,就是不同的主机之间需要二层互通。当网络环境满足不了时,就可以通过使用路由 + IPIP 的方式来打通网络。

这里可以通过一个简单的实验来验证一下该方案。

# node.sh
ip netns add n1
ip link add veth1 type veth peer name veth2
ip link set veth2 netns n1
ip netns exec n1 ip link set veth2 up
ip netns exec n1 ip route add 169.254.1.1 dev veth2 scope link
ip netns exec n1 ip route add default via 169.254.1.1
ip netns exec n1 ip addr add 172.19.1.10/24 dev veth2
ip link set veth1 up
ip route add 172.19.1.10 dev veth1 # 这个路由必须有
ip netns exec n1 ip route del 172.19.1.0/24 dev veth2 proto kernel scope link src 172.19.1.10
echo 1 > /proc/sys/net/ipv4/conf/veth1/proxy_arp
echo 1 > /proc/sys/net/ipv4/ip_forward

上面的脚本是用来创建一个虚拟的 Pod 的,可以在不同的主机上执行一下,这里要记得修改一下 IP 地址,来保证两个 Pod 的 IP 不同。

之后在宿主机上创建 IP 隧道。也是两台主机都要执行。

ip tunnel add mode ipip
ip link set tunl0 up
ip route add 172.19.1.0/24 via 192.168.105.135 dev tunl0 proto bird onlink

这里在创建 IP 隧道时,并没有指定隧道对端的地址,因为在实际的集群中,1对1的隧道是没使用场景的。而是使用路由告诉这个隧道的对端地址。这时候在 netns n1 内就可以 ping 通对端的 IP 了。

流程图如下

calico ipip

proxy_arp在calico中的妙用

概述

proxy_arp 是网卡的一个配置,在开启后,该网卡会使用自己的 MAC 地址应答非自身 IP 的 ARP Request。常见的用途就是当两台主机的 IP 在同一个网段内,二层却不通,就可以使用额外的一台主机作为 proxy,将这台主机的网卡开启 proxy_arp,来作为中间代理打通网络。如下图所示:

img

开启网卡的 proxy_arp 也很简单:

echo 1 > /proc/sys/net/ipv4/conf/veth1/proxy_arp

calico 是一个使用路由方案打通网络的网络插件,在作为 k8s cni 时,其也使用了 proxy_arp,作为打通路由的一个环节。在了解 calico 如何使用 proxy_arp 之前,我们先看一下 flannel 的 host-gw 是如何使用路由打通 pod 网络的。

flannel host-gw 路由方案

两台二层互通的主机上的 pod,如果要通过路由来互相访问,常见的方式是类似于 flannel 的 host-gw 模式。其流量路径如下:

  1. 每台主机上都有一个 bridge,pod 通过 veth pair 接入到 bridge 上。
  2. pod 将 bridge 的 ip 作为网关。这样 pod 访问其他网段的 IP 时,流量就会到达 bridge 上。
  3. 流量到达 bridge 后,就可以根据宿主机上的路由表转发到对端主机。
  4. 对端主机也会根据路由表,将流量从 bridge 转发到 pod 内。

flannel-host-gw

calico 的路由方案

相比于 flannel host-gw 模式,calico 采用了更巧妙的方法,省掉了 bridge。

其 veth pair 的一端在 Pod 内,设置为 pod 的 IP,另一端在宿主机中,没有设置 IP,也没有接入 bridge,但是设置了 proxy_arp=1。

pod 内有以下的路由表:

default via 169.254.1.1 dev veth2 
169.254.1.1 dev veth2 scope link 

169.254.0.0/16 是一个特殊的 IP 段,只会在主机内出现。不过这里这个 IP 并不重要,只是为了防止冲突才选择了这个特殊值。当 Pod 要访问其他 IP 时,如果该 IP 在同一个网段,那就需要获取该 IP 的 MAC 地址。如果不在一个网段,那么根据路由表,就要获取网关的 IP 地址。所以无论如何,arp 请求都会到达下图中的 veth1。

因为 veth1 设置了 proxy_arp=1,所以就会返回自己的 MAC 地址,然后 Pod 的流量就发到了主机的网络协议栈。到达网络协议栈之后,就和 flannel host-gw 一样,被转发到对端的主机上。

流量到达对端主机后,和 flannel host-gw 不一样的是,主机上直接设置了 pod 的路由:

172.19.2.10 dev veth1 scope link

也就是直接从 veth1 发到 pod 内。

proxy_arp

参考

2.2. Proxy ARP

戳穿 Calico 的谎言

linux 网络数据包接收流程(一)

概述

Linux 作为最流行的服务器操作系统,其提供的网络能力也是经过了各种各样场景的考验。因此如果经常和 linux server 打交道的话,了解 linux 的数据包处理流程也是很有必要的。

网络数据包的接收处理可以分成两个部分,一是从物理网卡进入到达 linux 内核的网络协议栈,二是经网络协议栈处理后交给上层应用或者转发出去。本篇文档主要说明第一部分,并且不会去深入细节点(因为我也不太熟)。

重要概念和数据结构

在说明网络数据包的处理流程之前,有必要提前讲一下一些相关的概念,因为这些概念决定了后面的内容是否能够理解。

硬中断

硬中断是由硬件在发生某些事件后发出的,称为中断请求(IRQ),CPU 会响应硬中断,并执行对应的 IRQ Handler。对于网卡来说,在有网络流量进入后,网卡会通过硬中断通知 CPU 有网络流量进来了,CPU 会调用对应网卡驱动中的处理函数。

硬中断在处理期间,是屏蔽外部中断的,所以硬中断的处理时间要尽可能的短。

软中断

软中断是由软件执行指令发出的,因为硬中断的特点不能处理耗时的任务,所以软中断往往用来替代硬中断来处理耗时任务。

比如网络流量的处理,网卡在发出硬中断通知 CPU 处理后,这次硬中断的处理方法中又会触发软中断,由软中断接着去处理网络流量数据。

网卡驱动

驱动是打通硬件和操作系统的通道,linux 通过网卡驱动,可以支持不同厂商,不同型号,不同特性的网卡。网卡驱动主要负责将从网卡中进来的流量解析并转换成 sk_buff,交给内核协议栈。

DMA

DMA是一种无需CPU的参与就可以让外设和系统内存之间进行双向数据传输的硬件机制。网卡会通过 DMA 直接将网络流量数据存储到一块提前申请好的内存区域中。

NAPI

全称 New API,因为没有更好的名字,所以就直接用 NAPI 了。这是用于支持高速网卡处理网络数据包的一种机制。非 NAPI 往往是只依靠硬中断的方式让 CPU 来处理数据包,NAPI 引入了硬中断+轮询的方式,有效的缓解了硬中断带来的性能问题。

sk_buff

sk_buff 是一个非常大而通用的 struct,可以用来表示2,3,4层的数据包。它被分成两个部分:head 和 data。

head 部分有单独的字段表示不同层的网络头:

  • transport_header:用来表示传输层(4层)的 header,包括 tcp, udp, icmp 等协议头
  • network_header:用来表示网络层(3层)的 header,包括 ip, ipv6, arp 等协议头
  • mac_header:用来表示链路层(2层)的 header。

当数据包进入网络协议栈之前,需要先被转换成 sk_buff。

流程梳理

数据包进入触发硬中断

流量进入到硬件中断

  1. 数据包进入网卡设备

  2. 网卡设备通过 DMA 直接写入的内存中。如果写不下就直接 drop 掉

  3. 网卡产生硬中断

  4. CPU 收到硬中断后,会直接提前注册好的该硬中断的 handler。这个 handler 是写在网卡驱动中的一个方法

  5. IRQ handler 禁用网卡的 IRQ。这是后面处理内存中的数据包是采用的 poll 模式。也就是说 cpu 会自己去内存中轮询数据包,直到一定时间/数量,或者全部处理完之后。这段时间内就不需要网卡通过硬中断来通知 CPU 了,并且硬中断会打断 CPU 的工作,带来一定的性能问题。

  6. 网卡驱动产生软中断。

软中断触发数据包的处理

软中断触发数据包的处理

这里为了方便表述,使用目前最常用的 NAPI 的处理流程进行说明。

  1. 在系统启动时,net_dev_init 方法中注册了 NET_RX_SOFTIRQ 对应的 handler 是 net_rx_action。上面触发软中断的方式是 __raise_softirq_irqoff(NET_RX_SOFTIRQ)。所以开始执行 net_rx_action
  2. net_rx_action 会从 poll_list 链表中获取第一个 poll,使用 napi_poll 轮询内存中的数据包。napi_poll 调用到网卡驱动提供的 poll 方法
  3. poll 方法中从内存中取出数据包
  4. 网卡驱动调用 napi_gro_receive 来处理数据包
  5. napi gro 会合并多个 skb 数据包,比如一个 IP 包会被分成多个 frame 这种。那么如果在接收的时候,在到达协议栈之前直接合并,会有一定的性能提升。这里最终会调用到 gro_normal_list 来批量处理 skb。
  6. 最终调用到 netif_receive_skb_list_internal,从 napi.rx_list 上处理 sk_buff 链表。
  7. 如果开启了 RPS,会根据 skb 的 hash 值找到对应的 cpu,将 skb 存储到该 cpu 上的 backlog 队列。backlog 队列是一种用软件方式将数据包处理负载均衡到多个 cpu 上的一种方法。
  8. 最终都会调用到 __netif_receive_skb_core。
  9. 如果有 AF_PACKET 的 socket,还会拷贝一份给它(tcpdump 的实现原理)。
  10. 最后递交给内核协议栈

参考

Linux协议栈–NAPI机制

Monitoring and Tuning the Linux Networking Stack: Receiving Data

linux kernel 网络协议栈之GRO(Generic receive offload)

kube-proxy iptables 流量处理流程

kube-proxy 在 iptables 模式下,主要是通过使用 iptables 提供从 service 到 pod 的访问。主要作用在两个表上:

  • NAT:访问 service 时,需要 DNAT 到 pod IP 上
  • Filter: 对流量做过滤,比如如果一个 service 没有 endpoints,就直接 REJECT 掉访问 cluster IP 的流量等。

NAT 主要作用在三个关键点:

  • PREROUTING: 在这里为进入 node 流量进行处理,如果是访问 service,则选择一个后端 pod DNAT,并在流量上做标记
  • OUTPUT: 在这里为从本机进程出来的流量进行处理,如果是访问 service,则选择一个后端 pod DNAT,并在流量上做标记。
  • POSTROUTING: 为做了标记的流量做 MASQUERADE,MASQUIERADE 可以理解为加强版的 SNAT,会自动根据出去的网卡选择 src IP。

Filter 主要作用在三个点:

  • INPUT: 发往本机的流量
  • FORWARD: 转发到其他 host 的流量
  • OUTPUT: 从本机进程出去的流量

分析 kube-proxy iptables 时,主要就是从上述的几个点去看,iptables 规则本身比较枯燥,没有太多可说的。下面是整理的 kube-proxy 使用 iptables 的流量处理流程。可以用来作参考。

kube-proxy-iptables

cgroup cpu子系统

概述

cgroup 全名是 control groups,在 linux 上负责对进程的一系列资源进行管控。比如 CPU,Memory,Huge Pages 等。cgroup 下通过子系统(subsystem)来划分模块,每种资源都通过一个子系统来实现。

cgroup 通过文件系统的方式对外提供调用,并可以用层级的方式进行组合。这种层级通过文件系统目录的方式进行呈现。比如在 cgroup cpu 目录下创建子目录,就相当于在根 cpu cgroup 下创建了一个子 cgroup。并且子 cgroup 会继承父 cgroup 的限制。

cgroup 目前有两个版本:v1 和 v2,并且两个版本的设计差异较大。但是理念类似,因此即使版本不同,也可以一样来理解。下面会以 cgroup v1 cpu 子系统进行讲解。

cpu 子系统的使用

cgroup 描述起来一直是一个比较抽象的概念。下面用一个简单的例子来帮助认识 cgroup 是如何工作的。

首先在机器上启动一个 stress 进程,分配一个 cpu,然后查看该进程 cpu 占用情况:

$ stress -c 1

$ pidstat -p 480164 1
Linux 4.14.81.bm.26-amd64 (n251-254-159)    06/01/2021  _x86_64_    (8 CPU)
02:36:56 PM   UID       PID    %usr %system  %guest    %CPU   CPU  Command
02:36:57 PM  1001    480164  100.00    0.00    0.00  100.00     6  stress

可以看到,stress 进程已经占用了 1 cpu。现在我们创建一个名叫 stress 的 cgroup 来限制 cpu:

$ cd /sys/fs/cgroup/cpu

$ mkdir stress && cd stress

# 将 pid 写入到 cgroup.procs 中,就等同于将这个进程移到该 cgroup 中
$ echo 480164 > cgroup.procs

$ echo 100000 > cpu.cfs_period_us

$ echo 50000 > cpu.cfs_quota_us

# 再看看当前的 CPU 占用
$ pidstat -p 480164 1
Linux 4.14.81.bm.26-amd64 (n251-254-159)    06/04/2021  _x86_64_    (8 CPU)

05:17:49 AM   UID       PID    %usr %system  %guest    %CPU   CPU  Command
05:17:50 AM  1001   480164   50.00    0.00    0.00   50.00     6  stress

上述操作通过配置 cpu.cfs_period_uscpu.cfs_quota_us 参数达到了限制进程使用 CPU 的目的。

cgroup 还提供了一个 cpu.shares 参数,当 CPU 资源繁忙时,这个参数可以配置进程使用 CPU 的权重。下面我们在 cpu 为 1 的虚拟机演示。 在 cgroup 下创建两个子 cgroup 来展示这个参数的效果。

$ cd /sys/fs/cgroup/cpu,cpuacct
$ mkdir stress1 && cd stress1
$ stress -c 1
$ echo 3475127 > cgroup.procs
$ echo 1024 > cpu.shares

此时 PID 3475127 的 stress 进程 CPU 占用率接近 100%。在新的终端中执行以下命令:

$ mkdir stress2 && cd stress2
$ stress -c 1
$ echo 3479833 > cgroup.procs

此时两个 stress 进程的 CPU 占用大致相等,接近 50%。因为 stress2 cgroup 中没有设置 cpu.shares,所以取默认值为 1024。现在设置 stress2 cgroup 的 cpu.shares 参数:

$ echo 512 > cpu.shares

# 使用 top 查看
    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM
  3475127 root      20   0    7948     96      0 R  65.1   0.0
  3479833 root      20   0    7948     92      0 R  32.2   0.0

stress1 中的进程 CPU 占用率大概是 stress2 中的两倍。这是因为 stress1 中 cpu.shares 的值是 stress2 中的两倍。当然上述情况必须在 CPU 资源不够时,cpu.shares 才会起作用。如果这是一个 2 cpu 的虚拟机,那么 stress1 和 stress2 都会占用 100%。

参数说明

上述出现了一些 cpu 的参数,这里统一解释一下:

  • cpu.cfs_period_us: 重新分配 CPU 资源的时间周期长度,单位是 us。cfs 是 linux 进程调度器的一种,全称为完全公平调度器。因此这个参数只针对使用 cfs 调度的进程。

  • cpu.cfs_quota_us: 进程在设置的时间周期长度内,可以使用的 CPU 时间上限。结合 cpu.cfs_period_us 就可以限制一个进程可以使用的总 CPU 时间了。计算方式为 (cpu.cfs_quota_us / cpu.cfs_period_us)*count(cpu)。这个参数只针对使用 cfs 调度的进程。

  • cpu.shares: 这个参数只有在 CPU 资源忙时才生效,它可以用来设置进程使用的 CPU 权重。上面的例子中,虚拟机只有 1 CPU,进程 1,2 都会占用一个 CPU,因此根据设置进程 1 的 cpu.shares 为 1024,进程 2 的 cpu.shares 为 512,就可以将 2/3 的 cpu 分配给进程 1,1/3 的 cpu 分配给进程 2 了。

除了上述例子中的几个参数,cgroup cpu 子系统还提供了以下的参数:

  • cpu.rt_period_us: 重新分配 CPU 资源的时间周期长度。 针对使用了实时调度器的进程
  • cpu.rt_runtime_us: 进程在设置的时间周期长度内,可以使用的 CPU 时间上限。这个和上面说的 cfs 的两个参数类似。
  • cpu.nr_periods: 这是一个统计参数。用来表示已经过去的 cpu 周期数(使用 cpu.cfs_period_us 来指定)
  • cpu.nr_throttled: cgroup 中进程被限制的次数(因为这些进程用完了分配的 cpu 时间)。
  • cpu.throttled_time: cgroup 中进程被限制的总时间(单位是 ns)。

参考

Linux进程调度:完全公平调度器CFS

redhat cfs cpu

containerd CRI 简要分析

概述

Containerd 在 release1.5 之后内置了 cri。通过暴露 CRIService 供 kubelet 调用。CRI 的封装并不复杂,都是利用了 containerd 本身的功能模块。通过 CRI 管理 pod 主要分为三个模块:

  • Sandbox 的管理:RunPodSandbox、StopPodSandbox、RemovePodSandbox、PodSandboxStatus、ListPodSandbox
  • Container 的管理:CreateContainer、StartContainer、StopContainer、RemoveContainer、ListContainers、UpdateContainerResources、ContainerStats、ListContainerStats、UpdateRuntimeConfig、Status
  • 容器其他方面的管理:ReopenContainerLog、ExecSync、Exec、Attach、PortForward

Sandbox 的管理

Sandbox 和 Container 在实现上类似,通过启动一个特殊的 pause 容器来创建一个沙箱环境。通常情况下,这个沙箱环境具有和主机隔离的 pid,uts,mount,network,ipc,user。

以 RunPodSandbox 为例,containerd 会执行以下操作:

  1. Containerd CRI 在 RunPodSandbox 时,会先保证 sandbox 的镜像是否存在。如果不存在则会进行 pull 操作。
  2. 创建 pod network namespace,并调用 CNI
  3. 使用 container task 来创建容器
  4. 启动 container task,也就是执行二进制文件 pause
  5. 更新 sandbox 的状态,包括 pid 置为 task pid,state 置为 ready 以及更新创建时间。
  6. 在新的 goroutine 中启动 sandbox exit monitoring。这样就可以在 sandbox 进程退出时,执行清理工作。然后更新 sandbox 的状态。

以 StopPodSandbox 为例,containerd 会执行以下操作:

  1. 使用 containerStore 来 list 出所有 container,并使用 container.SandboxID 过滤出属于当期 sandbox 的 container
  2. 依次停止 sandbox 下的 container
  3. 清理 sandbox 的文件,比如 unmount dev shm
  4. 如果 sandbox container(pause) 的状态是 Ready 或 Unknown,使用 SiGKILL 终止 sandbox container
  5. 调用 cni 清理 network namespace,然后移除。

Container 管理

在 sandbox 创建好之后,kubelet 就可以通过 CRI 来创建 container 了。CreateContainer 的逻辑如下:

  1. 获取 sandbox 的信息,后面创建的容器需要使用和 sandbox 同样的 runtime,namespace。
  2. 获取 container mounts,包括 /etc/hosts/etc/resolv.condev shm。然后设置
  3. 设置 container log path。
  4. 设置 container io,包括 stdin, stdout, stderr, terminal。
  5. 在 container store(metadata) 中创建容器记录。此时 container 处于 CREATED 状态。

创建好 container 后,调用 StartContainer 即可运行容器。步骤如下:

  1. 更新 container 状态为 Running,防止重复 start。
  2. 设置 container stdout,stderr 到 log 文件上。
  3. 启动 task 来运行 container 进程
  4. 更新 container status 的 Pid 和 StartedAt
  5. 在新的 goroutine 中启动 sandbox exit monitoring 来监控 container 的进程状态。

StopContainer 的步骤如下:

  1. 如果 container 设置了 stop timeout,使用 task.Kill 发送 SIGTERM 信号。然后等待 timeout 时间
  2. 使用 task.Kill 发送 SIGKILL 信号。

RemoveConrtainer 的步骤如下:

  1. 如果当前 container 处于 RUNNING 或者 UNKNOWN 状态,则使用 timeout 为 0 的 stopContainer 来强制停止容器。
  2. 设置 container 的状态为 removing。
  3. 从 store 中删除 container,checkpoint 以及一些缓存信息。

容器管理

Containerd CRI 除了实现了 sandbox 和 container 的生命周期管理,也提供了对容器其他方面的管理。比如:

  1. 在 kubelet 对 logfile rotate 之后,调用 ReopenContainerLog 来将 container log 输出到新的日志文件中
  2. 通过 GRPC 提供 GetAttach,返回 attach http endpoint 和 token,供 kubelet 通过 http stream 连接到 process 的 stdin,stdout 和 stderr 上。
  3. 通过 GRPC 提供 GetExec,返回 exec http endpoint 和 token,供 kubelet 通过 http stream 在容器命名空间内执行命令。
  4. 通过 GRPC 提供 GetPortforward,返回 portforward http endpoint 和 token。kubelet 通过 http stream 连接上后,使用 netns 在 sandbox network namespace 下 dial 容器内的端口,使用 io.Copy 转发输入输出流。

containerd storage模块分析

一、概述

containerd 的 storage 模块负责镜像的存储,容器 rootfs 的创建等工作。其主要包括三个子模块:

  • content: content 会在本地目录下保存镜像的内容。包括镜像的 manifest,config,以及镜像的层。每个层都是一个文件,格式是 tar+gzip,名称为层的 sha256sum 值。content 中存储的层都是不可变的。也就是使用的时候,并不会改变这里面的任何文件。
  • snapshot: snapshot 对容器运行时的层做了抽象。分为三种类型:Commited,Active,View。其中 Active 和 View 类似,不过前者可读写,后者只读。Active 和 View 类型的 snapshot 就是我们观察到的文件系统,一般是最上层。Commited 和另外两个相反,对用户不可见,作为 Active 或 View 的 parent 使用。
  • diff: diff 的主要功能有两个:Compare 和 Apply。Compare 负责计算 lower 和 upper 挂载的差异,然后使用 tar 打包差异生成新的镜像层。Apply 负责将镜像层挂载到文件系统上,生成容器运行时需要的 rootfs。

二、content 如何工作的

content 通过 GRPC 对外提供了以下接口:

// ContentServer is the server API for Content service.
type ContentServer interface {
    Info(context.Context, *InfoRequest) (*InfoResponse, error)
    Update(context.Context, *UpdateRequest) (*UpdateResponse, error)
    List(*ListContentRequest, Content_ListServer) error
    Delete(context.Context, *DeleteContentRequest) (*types.Empty, error)
    Read(*ReadContentRequest, Content_ReadServer) error
    Status(context.Context, *StatusRequest) (*StatusResponse, error)
    ListStatuses(context.Context, *ListStatusesRequest) (*ListStatusesResponse, error)
    Write(Content_WriteServer) error
    Abort(context.Context, *AbortRequest) (*types.Empty, error)
}

提供了 local 和 proxy 的实现,其中 proxy 是通过 GRPC 将具体实现解耦合,因此这里并不讨论,主要关注 local 的实现方式。local 的实现中,将以上接口再分为 4 个部分:

  • Manager: 提供了对 content 的查询,更新和删除操作
  • Provider:提供了对指定 content 内容的读取
  • IngestManager:提供了对 ingest 的状态查询和终止操作。
  • Ingester:提供了对 ingest 的写入操作。
// Store combines the methods of content-oriented interfaces into a set that
// are commonly provided by complete implementations.
type Store interface {
    Manager
    Provider
    IngestManager
    Ingester
}

// Manager provides methods for inspecting, listing and removing content.
type Manager interface {
    Info(ctx context.Context, dgst digest.Digest) (Info, error)
    Update(ctx context.Context, info Info, fieldpaths ...string) (Info, error)
    Walk(ctx context.Context, fn WalkFunc, filters ...string) error
    Delete(ctx context.Context, dgst digest.Digest) error
}

// Provider provides a reader interface for specific content
type Provider interface {
    ReaderAt(ctx context.Context, desc ocispec.Descriptor) (ReaderAt, error)
}

// IngestManager provides methods for managing ingests.
type IngestManager interface {
    Status(ctx context.Context, ref string) (Status, error)
    ListStatuses(ctx context.Context, filters ...string) ([]Status, error)
    Abort(ctx context.Context, ref string) error
}

// Ingester writes content
type Ingester interface {
    Writer(ctx context.Context, opts ...WriterOpt) (Writer, error)
}

为了防止理解上有歧义,这里对一些术语做一些详细的解释

  • content: content 中存储的最小单位可以是镜像的 manifest,config,或者是镜像的一层,通常还包含该层的大小,创建/修改时间,labels 等等

  • digest: 在 content 模块,digest 指的是镜像层的 sha256sum 值

  • ingest: 因为文件系统在写入文件时,是无法保证原子性的。所以一般的解决方案是是先写入中间文件,然后通过 rename 调用,把中间文件改成要写入的目标文件。ingest 就是这个中间文件集的统称,一个 ingest 对应一个 content。ingest 包含这几个文件:
    • data: content 的数据
    • ref: 根据 ref 找到 target location
    • startedat: 开始时间
    • updatedat: 更新时间
    • total: content 的总大小

content 的使用其实已经很底层了,所以这里不准备按照 content 提供的接口进行分析,而是通过 ctr image pull docker.io/library/nginx:latest 来说明 content 的工作原理。这条命令在 cmd/ctr/commands/images/pull 下。主要执行了以下几行代码:

client, ctx, cancel, err := commands.NewClient(context)
ctx, done, err := client.WithLease(ctx)
config, err := content.NewFetchConfig(ctx, context)
img, err := content.Fetch(ctx, client, ref, config)

commands.NewClient(context)会初始化 ctr 到 containerd 的连接参数。比如:

  • timeout: ctr 连接 containerd 的超时时间,默认 10s
  • defaultns: containerd 使用 namespace 进行租户隔离。默认值为 default
  • address: containerd 的地址,默认值是 /run/containerd/containerd.sock"
  • runtime: 默认是 io.containerd.runc.v2
  • platform: 指的是操作系统,CPU 架构 等
  • 还要一些 GRPC 连接数据等等

client.WithLease(ctx)会在 metadata 中记录该操作,当该操作结束后,也会从 metadata 中删除。

content.NewFetchConfig(ctx, context) 用来初始化这次拉取镜像的配置

  • 实例化 resolver,resolver 负责从远端 pull 到本地。containerd 使用的是 remotes/docker,应该是从 docker 那部分拿过来的代码。
  • 配置 platforms
  • 配置 max-concurrent-downloads,这个参数会限制同时下载的并发

content.Fetch(ctx, client, ref, config) 负责调用上一步实例化出的 client,根据 ref(镜像地址) 和 fetch config 来拉取远端镜像,然后使用 content 的接口存储到本地。

下面主要就 Fetch 展开分析。这里可以先了解一下,Fetch 会做以下的工作:

  • 设置 opts(RemoteOpt 数组),type RemoteOpt func(*Client, *RemoteContext) error
    • 应用到 image 上的 labels
    • 设置上面实例化的 resolver
    • 设置 BaseHandlers,BaseHandlers 在 dispatch 时调用
    • 设置 AllMetadata,AllMetadata 会下载所有的 manifest 和已知的配置文件
    • 设置 Platforms
  • 使用 resolver 来将我们提供的镜像名(ref) 解析成 name 和 descriptor
    • 如果镜像名的格式为: docker.io/library/nginx:latest,则会发送 Head请求到 https://registry-1.docker.io/v2/library/nginx/manifests/latest,从响应头中获取 docker-content-digest 的值。如果不存在,还会再发同样的请求,使用 GET 方法来获取 manifest,从 manifest 中获取 digest。最终会获取 digest,mediaType 和 size 三个值。
    • 如果镜像名的格式为:docker.io/library/nginx@sha256:df13abe416e37eb3db...,则@ 后面提供的是 digest 值。此时就使用 https://registry-1.docker.io/v2/library/nginx/manifests/sha256:df13abe416e37eb3db...来实现
  • 现在得到了 image digest,此时调用 images.Dispatch(ctx, handler, limiter, desc)方法。这个 Dispatch 方法是个递归调用
    1. 先通过 image digest,调用 dockerFetcher 和 content,将 image manifests 列表信息 store 到 content blobs 中。并找到符合当前 platform 的 descriptor。

    2. 再通过上一步获取的 digest,调用 dockerFetcher 和 content,获取该 platform 的 manifest,这次的内容中会包含 config 和 layers

    3. 根据 config 的 digest,调用 dockerFetcher 和 content,获取 config 的内容并存储
    4. 根据 layers 数组中每个 layer 的 digest,调用 dockerFetcher 和 content,获取 layer 内容并存储。
  • 调用 createNewImage 在 metadata 中创建 image 记录。记录中存储了 image 的 digest 和 lables。

第一步获取的 manifests 内容如下:

{
    "manifests": [
        {
            "digest": "sha256:eba373a0620f68ffdc3f217041ad25ef084475b8feb35b992574cd83698e9e3c",
            "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
            "platform": {
                "architecture": "amd64",
                "os": "linux"
            },
            "size": 1570
        }
    ],
    "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
    "schemaVersion": 2
}

第二步获取的 manifest 如下:

{
    "schemaVersion": 2,
    "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
    "config": {
        "mediaType": "application/vnd.docker.container.image.v1+json",
        "size": 7736,
        "digest": "sha256:f0b8a9a541369db503ff3b9d4fa6de561b300f7363920c2bff4577c6c24c5cf6"
    },
    "layers": [
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 27145915,
            "digest": "sha256:69692152171afee1fd341febc390747cfca2ff302f2881d8b394e786af605696"
        },
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 26576310,
            "digest": "sha256:49f7d34d62c18a321b727d5c05120130f72d1e6b8cd0f1cec9a4cca3eee0815c"
        }
    ]
}

第三步获取的 config 如下:

{
    "architecture": "amd64",
    "config": {
        "Hostname": "",
        "Domainname": "",
        "User": "",
        "AttachStdin": false,
        "AttachStdout": false,
        "AttachStderr": false,
        "ExposedPorts": {
            "80/tcp": {}
        },
        "Tty": false,
        "OpenStdin": false,
        "StdinOnce": false,
        "Env": [
            "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
            "NGINX_VERSION=1.19.10",
            "NJS_VERSION=0.5.3",
            "PKG_RELEASE=1~buster"
        ],
        "Cmd": [
            "nginx",
            "-g",
            "daemon off;"
        ],
        "Image": "sha256:f46ebb94fdef867c7f07f0b9c458ebe0ca97191f9fd6f91fd918ef71702cd755",
        "Volumes": null,
        "WorkingDir": "",
        "Entrypoint": [
            "/docker-entrypoint.sh"
        ],
        "OnBuild": null,
        "Labels": {
            "maintainer": "NGINX Docker Maintainers <docker-maint@nginx.com>"
        },
        "StopSignal": "SIGQUIT"
    },
    "container": "b728dbd6862a960807b78a68f3d1d6697d954ed2b53d05b1b4c440f4aa8574a3",
    "container_config": {
      ...
    },
    "created": "2021-05-12T08:40:31.711670345Z",
    "docker_version": "19.03.12",
    "history": [
        {
            "created": "2021-05-12T01:21:22.128649612Z",
            "created_by": "/bin/sh -c #(nop) ADD file:7362e0e50f30ff45463ea38bb265cb8f6b7cd422eb2d09de7384efa0b59614be in / "
        }
    ],
    "os": "linux",
    "rootfs": {
        "type": "layers",
        "diff_ids": [
            "sha256:02c055ef67f5904019f43a41ea5f099996d8e7633749b6e606c400526b2c4b33",
            "sha256:431f409d4c5a8f79640000705665407ff22d73e043472cb1521faa6d83afc5e8",
            "sha256:4b8db2d7f35aa38ac283036f2c7a453ebfdcc8d7e83a2bf3b55bf8847f8fafaf",
            "sha256:c9732df61184e9e8d08f96c6966190c59f507d8f57ea057a4610f145c59e9bc4",
            "sha256:eeb14ff930d4c2c04ece429112c16a536985f0cba6b13fdb52b00853107ab9c4",
            "sha256:f0f30197ccf95e395bbf4efd65ec94b9219516ae5cafe989df4cf220eb1d6dfa"
        ]
    }
}

第四步获取的就是每个 layer 的二进制数据了。

通过上面的分析可以知道,containerd 本身并没有实现镜像的 pull,但是通过暴露 storage 中的 content 和 matadata 中 image 接口,可以在调用方实现 image pull,并将数据按照 containerd 的要求进行存储,相当于 containerd 只提供了 image 存储的实现。总结一下流程如下:

containerd

三、snapshot 如何工作的

存储在 content 中的镜像层是不可变的,通常其存储格式也是没法直接使用的,常见的格式为 tar-gzip。为了使用 content 中存储的镜像层,containerd 抽象出了 snapshot,每个镜像层都会生成对应的 snapshot。

snapshot 有三种类型:committed,active 和 view。在启动容器前,镜像的每一层都会被创建成 committed snapshot,committed 表示该镜像层不可变。最后再创建出一层 active snapshot,这一层是可读写的。

下方展示了一个 nginx 镜像被 run 起来后生成的 snapshot。snapshot 之间是有 parent 关系的。第1层的 parent 为空。

# ctr snapshot ls
KEY                PARENT                       KIND
nginx              sha256:60f61ee7da08          Active
sha256:02c055ef                                 Committed
sha256:5c3e94c8    sha256:adda6567aeaa          Committed
sha256:60f61ee7    sha256:affa58c5a9d1          Committed
sha256:6b1533d4    sha256:5c3e94c8305f          Committed
sha256:adda6567    sha256:02c055ef67f5          Committed
sha256:affa58c5    sha256:6b1533d42f38          Committed

snapshots_of_nginx

下面针对执行 ctr run docker.io/library/nginx:latest nginx 来说明,不过 ctr run 还会涉及到很多 runtime 相关的内容,这里为了简单不做叙述。

当执行 ctr run 之后,会根据提供的 image 名,创建出多个 snapshot。主要的代码如下:

// 从 metadata 中查询 image 的信息
i, err := client.ImageService().Get(ctx, ref)
// 根据 image 信息初始化 image 实例
image = containerd.NewImage(client, i)
// 这个 image 是否 unpacked
unpacked, err := image.IsUnpacked(ctx, snapshotter)
if !unpacked {
  // unpack 镜像
  if err := image.Unpack(ctx, snapshotter); err != nil {
    return nil, err
  }
}

IsUnpacked 的实现如下:

func (i *image) IsUnpacked(ctx context.Context, snapshotterName string) (bool, error) {
    // 获取 snapshotter 实例,默认是 overlayfs
  sn, err := i.client.getSnapshotter(ctx, snapshotterName)
    if err != nil {
        return false, err
    }
  // 获取 content store 实例
    cs := i.client.ContentStore()
  // 这里是通过读取 image manifest,获取到 image layers 的 digest,也就是 diffs
    diffs, err := i.i.RootFS(ctx, cs, i.platform)
    if err != nil {
        return false, err
    }

  // 通过 diffs 计算出最上层的 chainID
    chainID := identity.ChainID(diffs)
  // 因为 snapshot 的名字就是 chainID,这里通过判断最上层的 snapshot 的 chainID 是否存在
  // 就可以知道这个 image 是否 unpack 了
    _, err = sn.Stat(ctx, chainID.String())
    if err == nil {
        return true, nil
    } else if !errdefs.IsNotFound(err) {
        return false, err
    }

    return false, nil
}

chainID 的计算方式参考之前的文章:chainID 计算方式

Unpack 的实现如下,为了展示方便,代码有删减:

func (i *image) Unpack(ctx context.Context, snapshotterName string, opts ...UnpackOpt) error {
    // 获取镜像的 manifest
  manifest, err := i.getManifest(ctx, i.platform)
  // 通过 manifest,获取 layers
  layers, err := i.getLayers(ctx, i.platform, manifest)

  // 默认是 overlayfs
  snapshotterName, err = i.client.resolveSnapshotterName(ctx, snapshotterName)
  // 获取 snapshotter 实例
  sn, err := i.client.getSnapshotter(ctx, snapshotterName)

  for _, layer := range layers {
    // apply layer,这里是 snapshot 的工作重点
    unpacked, err = rootfs.ApplyLayerWithOpts(ctx, layer, chain, sn, a, config.SnapshotOpts, config.ApplyOpts)
        // chainID 的计算需要之前的 diffID,所以这里报错了每一层的 digest。
    chain = append(chain, layer.Diff.Digest)
  }
  // 最上层的 snapshot 就是 rootfs,可以提供给 runc 使用。
  rootfs := identity.ChainID(chain).String()
  return err
}

Unpack 的过程,就是对每一层 apply layer 的过程。apply 一个 layer 的实现如下:

func applyLayers(ctx context.Context, layers []Layer, chain []digest.Digest, sn snapshots.Snapshotter, a diff.Applier, opts []snapshots.Opt, applyOpts []diff.ApplyOpt) error {
    for {
        key = fmt.Sprintf(snapshots.UnpackKeyFormat, uniquePart(), chainID)
        // prepare 会创建出一个 active snapshot
        mounts, err = sn.Prepare(ctx, key, parent.String(), opts...)
        break
    }
    // 使用 diff,将这一层应用到 prepare 的 layer 上
    diff, err = a.Apply(ctx, layer.Blob, mounts, applyOpts...)
    // Commit 会在 metadata 中将这个 snapshot 标记为 committed。
  // 对于 device mapper 设备,还会额外的使这个 snapshot 挂载不可见。
    if err = sn.Commit(ctx, chainID.String(), key, opts...); err != nil {
        err = errors.Wrapf(err, "failed to commit snapshot %s", key)
        return err
    }

    return nil
}

以上就是 snapshotter 通过 image layers 创建出 snapshots 的过程。不过这上面创建的都是 committed snapshot。所以在这之后还会单独在这之上创建出一个 active snapshot 供容器读写。

// WithNewSnapshot allocates a new snapshot to be used by the container as the
// root filesystem in read-write mode
func WithNewSnapshot(id string, i Image, opts ...snapshots.Opt) NewContainerOpts {
    return func(ctx context.Context, client *Client, c *containers.Container) error {
        diffIDs, err := i.RootFS(ctx)
        if err != nil {
            return err
        }

        parent := identity.ChainID(diffIDs).String()
        c.Snapshotter, err = client.resolveSnapshotterName(ctx, c.Snapshotter)
        if err != nil {
            return err
        }
        s, err := client.getSnapshotter(ctx, c.Snapshotter)
        if err != nil {
            return err
        }
        if _, err := s.Prepare(ctx, id, parent, opts...); err != nil {
            return err
        }
        c.SnapshotKey = id
        c.Image = i.Name()
        return nil
    }
}

四、diff 如何工作的

在上面对 content 和 snapshot 进行一些分析后,已经清楚了镜像的层是如何存储的,以及使用镜像是什么样的一个过程。但这其中还有两个细节没有说明:

  • 一个 image layer 如何被 mount 成一个 snapshot。
  • 一个 snapshot 如何被压缩成一个 image layer

这里就是 diff 子模块的作用了。diff 对外提供了两个接口:

  • Diff: 负责将 snapshot 打包成 image layer

  • Apply: 负责将 image layer 生成 snapshot 挂载

在说明 Diff 之前,需要先提一下 OCI 中 image spec 中的一个例子。假设现在有两个文件夹 rootfs-c9d-v1/ and rootfs-c9d-v1.s1/。对其进行字典序的递归比较,发现的变动如下:

Added:      /etc/my-app.d/
Added:      /etc/my-app.d/default.cfg
Modified:   /bin/my-app-tools
Deleted:    /etc/my-app-config

那么使用 OCI 的规范打包出来就是:

./etc/my-app.d/
./etc/my-app.d/default.cfg
./bin/my-app-tools
./etc/.wh.my-app-config

删除的文件使用 .wh. 前缀来表示。

那么 Diff 的时候,主要就是对 snapshot 和其 parent 做比较,比较时使用字典序来 walk dir。然后生成的 tar 包中,对 added 和 modified 文件,只需打包最新的即可。对 deleted 文件,生成 .wh.* 来代替。

在 Apply 的时候,如果是 .wh. 前缀的文件,就根据所使用文件系统的特点来生成,比如 overlayfs 中使用 whiteout 文件来表示删除。非 .wh. 文件原样输出即可。

containerd的启动流程

整体架构图如下:

  • 使用 github.com/urfave/cli启动,有 command
    • configCommand: 和 containerd 配置相关
    • publishCommand:event 相关
    • ociHook: 提供了 preStart, preStop 等 container hook
  • 未执行子 command,则执行默认的 action
    • 加载配置文件
    • 创建顶层文件夹:
    • root = “/var/lib/containerd”
    • state = “/run/containerd”
    • 创建 /var/lib/containerd/tmpmounts
    • 清理 tmpmounts 下的临时挂载点
  • 创建和初始化 containerd server
    • 将配置设置的 server 进程上
    • 如果设置了 OOMScore,则应用到进程上。OOMScore 越低,系统内存不足时越不会被 kill
    • 如果设置了 containerd 的 Cgroup path。则会将自己的进程加入到 cgroup 下。这里还会判断使用 cgroup v1 还是 v2。
    • 设置一系列超时参数
    • 加载 plugin,containerd 通过 plugin 来划分模块。
    • 通过设置的 plugin 目录(默认在 /var/lib/containerd/plugins)来加载。go1.8 之后就不支持了。
    • 注册 content plugin:containred 架构中 storage 部分的 content。负责镜像的存储
    • 注册 metadata plugin:使用的是 bolt 这个嵌入式的 key/value 数据库。依赖 content 和 snapshot plugin。
    • 注册 proxy plugin: proxy plugin 支持 content, snapshot 两种类型。相当于起了一个 GRPC 服务,替换掉内置的 content, snapshot plugin。
    • 还有很多 plugin 是在包的 init 方法中注册的
      • 大量 snapshot 的插件: aufs, btrfs, devmapper, native, overlayfs, zfs
      • diff 插件: walking
      • GC 插件
      • 大量 service 插件:introspection,containers,content,diff,images,leases,namespaces,snapshots,tasks
      • runtime 插件:linux,task
      • monitoring 插件:cgroups
      • internal 插件:restart,opt
      • GRPC 插件:containers,content,diff,events,healthcheck,images,leases,namespaces,snapshots,tasks,version,introspection
      • CRI 插件:实现 CRI,提供给 kubelet 调用
    • diff 模块注册 stream processor,支持两种
    • application/vnd.oci.image.layer.v1.tar+encrypted
    • application/vnd.oci.image.layer.v1.tar+gzip+encrypted
    • 启动 TTRPC server:/run/containerd/containerd.sock.ttrpc
    • 启动 GRPC server:/run/containerd/containerd.sock
    • 如果开启了 TCP,还会将 GRPC server 监听到 tcp 上。

此时,containerd 已经可以对外提供服务了。下面对上述提到的一些概念或模块做个简单的解释:

  • service: GRPC 服务依赖于对应的 service。service 是则用来封装内部的实现。
  • TTRPC: TTRPC 是为低内存环境做的优化,通过淘汰net/http, net/http2grpc 包,实现了更轻量的 framing protocol,实现了更小的二进制文件以及更少的常驻内存使用。
  • storage 模块:包含 content, snapshot 和 diff 三个子模块。
    • content: content 是负责整个镜像的存储流程的。
    • snapshot:为了保证镜像层的数据不可变,所以在运行容器时会从 layer 中创建 snapshot。
    • diff:diff 模块实现了两个功能: diff 和 apply。diff 用来比较上层和下层之间的差异,然后按照 OCI 规范对该层打包。apply 用来根据底层的联合文件系统,对某一层进行挂载。
  • metadata 模块: 包含 images 和 containers 两个子模板。
    • images: 存储镜像相关的元数据。
    • containers: 存储容器相关的元数据。
  • tasks 模块:一个 container 的运行被抽象成一个 task
  • event 模块:提供了事件的发布订阅功能。第三方可以通过 event 获取 containerd 中的事件,比如镜像的创建,更新,容器的创建,删除等等。

容器镜像是如何工作的

一、概述

容器技术近些年的发展非常迅速,其本身使用的技术并非多么高深,但是带来的生产力提升是业界公认的。容器技术的主要特点就是其资源限制和环境隔离,linux 容器主要使用 cgroup 和 namespace 技术,但如果要更深入的理解容器技术,还需要了解一下容器镜像是什么。

二、初探

docker 可以说是容器技术的代表了,下面会以 docker 为例。docker 支持 pull, build, push 镜像。

Pull 操作如下:

➜  docker pull ubuntu:20.04
20.04: Pulling from library/ubuntu
345e3491a907: Pull complete   # 镜像共有三层
57671312ef6f: Pull complete
5e9250ddb7d0: Pull complete
Digest: sha256:cf31af331f38d1d7158470e095b132acd126a7180a54f263d386da88eb681d93 # 镜像的摘要
Status: Downloaded newer image for ubuntu:20.04
docker.io/library/ubuntu:20.04 # 镜像地址

Build 镜像时,需要提供 Dockerfile,Dockerfile 如下:

FROM ubuntu:20.04
RUN touch hello
CMD ['sh', '-c', 'echo hello ubuntu']

然后开始构建我们自己的镜像

➜  docker build -t joyme/ubuntu-hello:0.1 .
Sending build context to Docker daemon  2.048kB
Step 1/3 : FROM ubuntu:20.04
 ---> 7e0aa2d69a15
Step 2/3 : RUN touch hello
 ---> Running in 5e2fcef8c28b
Removing intermediate container 5e2fcef8c28b
 ---> 4bacc0d61995
Step 3/3 : CMD ['sh', '-c', 'echo hello ubuntu']
 ---> Running in 802e795e28c4
Removing intermediate container 802e795e28c4
 ---> ff588b7779d8
Successfully built ff588b7779d8
Successfully tagged joyme/ubuntu-hello:0.1

push 我们自己的镜像:

➜  docker push joyme/ubuntu-hello:0.1
The push refers to repository [docker.io/joyme/ubuntu-hello]
59c67359ad17: Pushed
2f140462f3bc: Mounted from library/ubuntu # 下面三层都是来自于 ubuntu:20.04
63c99163f472: Mounted from library/ubuntu
ccdbb80308cc: Mounted from library/ubuntu
0.1: digest: sha256:0a1a5857cade488bfc60c7f5d2be2c7c5eee7f90edc1950c4c32214fada31a7d size: 1149

我们也可以把镜像保存成 tar 包,然后加载。比如:

➜  docker save -o ubuntu-hello.tar joyme/ubuntu-hello:0.1
➜  ls
Dockerfile  ubuntu-hello.tar
➜  tar xvf ubuntu-hello.tar
1392a7609ae8d845eba5fbe95e266a6b104d55b30262a284c960583f91307420/
1392a7609ae8d845eba5fbe95e266a6b104d55b30262a284c960583f91307420/VERSION
1392a7609ae8d845eba5fbe95e266a6b104d55b30262a284c960583f91307420/json
1392a7609ae8d845eba5fbe95e266a6b104d55b30262a284c960583f91307420/layer.tar
15cbe1c29902a1020a4a47c835a82f0416f1896f02fac942fdd35d326c63fa22/
15cbe1c29902a1020a4a47c835a82f0416f1896f02fac942fdd35d326c63fa22/VERSION
15cbe1c29902a1020a4a47c835a82f0416f1896f02fac942fdd35d326c63fa22/json
15cbe1c29902a1020a4a47c835a82f0416f1896f02fac942fdd35d326c63fa22/layer.tar
2a8749a3d9080a156a4381fea67ce47a478fb31d6acaa20e19fda1ba0c8f20e7/
2a8749a3d9080a156a4381fea67ce47a478fb31d6acaa20e19fda1ba0c8f20e7/VERSION
2a8749a3d9080a156a4381fea67ce47a478fb31d6acaa20e19fda1ba0c8f20e7/json
2a8749a3d9080a156a4381fea67ce47a478fb31d6acaa20e19fda1ba0c8f20e7/layer.tar
6d56becb66b184f78b25f61dc91f68fcfce4baeecb3a8dcb21ada2306091aab7/
6d56becb66b184f78b25f61dc91f68fcfce4baeecb3a8dcb21ada2306091aab7/VERSION
6d56becb66b184f78b25f61dc91f68fcfce4baeecb3a8dcb21ada2306091aab7/json
6d56becb66b184f78b25f61dc91f68fcfce4baeecb3a8dcb21ada2306091aab7/layer.tar
ff588b7779d8b10861c566538b885c379d633d059fc067c5ec6e1ab026427075.json
manifest.json
repositories

可以发现,镜像是分层的,比如上面的 ubuntu:20.04 镜像总共有三层,我们基于 ubuntu:20.04 制作的 ubuntu-hello:0.1 镜像是 4 层,层信息记录在 manifest.json 文件中。

➜  cat manifest.json | jq
[
  {
    "Config": "ff588b7779d8b10861c566538b885c379d633d059fc067c5ec6e1ab026427075.json",
    "RepoTags": [
      "joyme/ubuntu-hello:0.1"
    ],
    "Layers": [
      "15cbe1c29902a1020a4a47c835a82f0416f1896f02fac942fdd35d326c63fa22/layer.tar",
      "6d56becb66b184f78b25f61dc91f68fcfce4baeecb3a8dcb21ada2306091aab7/layer.tar",
      "1392a7609ae8d845eba5fbe95e266a6b104d55b30262a284c960583f91307420/layer.tar",
      "2a8749a3d9080a156a4381fea67ce47a478fb31d6acaa20e19fda1ba0c8f20e7/layer.tar"
    ]
  }
]

2a8749a3 是我们刚刚创建的最后一层,也就是 touch hello 生成的新文件。而最后一行 CMD 由于并不会改变文件信息,因此不会单独的存储成一层,而是记录在和 touch hello 同一层中的 json 配置文件中。

    "Cmd": [
      "/bin/sh",
      "-c",
      "#(nop) ",
      "CMD [\"/bin/sh\" \"-c\" \"['sh', '-c', 'echo hello ubuntu']\"]"
    ],

三、镜像的本地存储

当使用 docker pull 镜像到本地后,镜像的存储位置一般都在 /var/lib/docker/image/<storage_driver> 下。因为要考虑到镜像层的复用等等场景, pull 下来的镜像肯定不会是一个压缩包。镜像存储时基本要满足以下几个场景:

  • 存储当期机器上所有的镜像索引。
  • 存储镜像到层的映射,这样使用镜像时可以找到层的信息。
  • 按照层来存储,这样在 pull 镜像时,如果有重复的层,可以避免多次拉取。
  • 需要知道一个层被哪些镜像引用。这样在删除镜像时,可以确定这个镜像的某个层能不能被删除。

镜像存储的目录分布如下:

├── distribution
│   ├── diffid-by-digest
│   └── v2metadata-by-diffid
├── imagedb
│   ├── content
│   └── metadata
├── layerdb
│   ├── mounts
│   ├── sha256
│   └── tmp
└── repositories.json
  • repositories.json: 存了的镜像的 repo 信息以及镜像信息。
  • distribution 下存储了和镜像分发相关的信息。
    • diffid-by-digest:存储了 digest 到 diffid 的映射,digest 用来在拉取层的时候,对比远端的层本地是否有
    • v2metadata-by-diffid 存储了 diffid 到层的元数据信息。元数据中记录了层的 digest,repo 等
  • imagedb 下存储了和镜像相关的信息。
    • content 下存储的是镜像的配置信息。也是符合 OCI 的规范的。可以参考:https://github.com/opencontainers/image-spec/blob/master/config.md
    • metadata 存储了镜像之间的 parent 信息。
  • layerdb 下存储了和镜像层相关的信息
    • mounts: 存储了层的的挂载信息,包括 init-id, mount-id, parent
    • sha256: 存储了层的信息,根据层的 sha256 划分目录
    • tmp:

现在可以知道,镜像的 repo, name, tag, id 等信息,可以通过 repositories.json 知道,使用的镜像的时候,提供 image+tag 找到 id,或者直接提供 id 也可以。比如上面的 ubuntu-hello 的 id 为 ff588b7779d8...,然后通过这个 id,在 /var/lib/docker/image/overlay2/imagedb/content/sha256下就可以找到名为这个 id 的文件了,里面存储了镜像的具体信息。这个 id 就是这个文件的 sha256sum 后的值。比如 ubuntu-hello的 content 就存储了以下信息(有一定的精简):

{
  "architecture": "amd64",
  "config": {
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "/bin/sh",
      "-c",
      "['sh', '-c', 'echo hello ubuntu']"
    ],
    "Image": "sha256:4bacc0d6199516a20039bc06ddaa7247a7553a39ae700ade279bd6ce78cd0a61"
  },
  "container": "802e795e28c4afced8fb6e6e703f0efeeaa6fa07a36c4400e242c4cd65e4bff5",
  "history": [
    {
      "created": "2021-05-15T10:32:23.665502338Z",
      "created_by": "/bin/sh -c touch hello"
    },
    {
      "created": "2021-05-15T10:32:23.925742465Z",
      "created_by": "/bin/sh -c #(nop)  CMD [\"/bin/sh\" \"-c\" \"['sh', '-c', 'echo hello ubuntu']\"]",
      "empty_layer": true
    }
  ],
  "os": "linux",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439",
      "sha256:63c99163f47292f80f9d24c5b475751dbad6dc795596e935c5c7f1c73dc08107",
      "sha256:2f140462f3bcf8cf3752461e27dfd4b3531f266fa10cda716166bd3a78a19103",
      "sha256:59c67359ad1702b424dcf3deefdf137e92ef13c13bec5b878b08fe66683a78f7"
    ]
  }
}

我们现在重点关注 rootfs 字段下的 diff_ids,这里存储了该镜像的每一层的 diff_id。接下来就可以通过这些 id 找到这些层的信息了。

层的信息存储在 /var/lib/docker/image/overlay2/layerdb/sha256 下,目录名是这一层的 chain_id。

chain_id 的计算中,用到了所有祖先 layer 的信息,从而能保证根据 chain_id 得到的 rootfs 是唯一的。比如我在debian和ubuntu的image基础上都添加了一个同样的文件,那么commit之后新增加的这两个layer具有相同的内容,相同的diff_id,但由于他们的父layer不一样,所以他们的chain_id会不一样,从而根据chainid能找到唯一的rootfs。

chain_id 的计算方式如下:

ChainID(L₀) =  DiffID(L₀)
ChainID(L₀|...|Lₙ₋₁|Lₙ) = Digest(ChainID(L₀|...|Lₙ₋₁) + " " + DiffID(Lₙ))

比如:第一层的 diff_id 是 ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439,第一层的 chain_id 是 ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439,第二层的 diff_id 是 63c99163f47292f80f9d24c5b475751dbad6dc795596e935c5c7f1c73dc08107,那么第二层的 chain_id 可以用下面的方法计算:

echo -n “sha256:ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439 sha256:63c99163f47292f80f9d24c5b475751dbad6dc795596e935c5c7f1c73dc08107” | sha256sum

也就是:8d8dceacec7085abcab1f93ac1128765bc6cf0caac334c821e01546bd96eb741

8d8dceacec7085abcab1f93ac1128765bc6cf0caac334c821e01546bd96eb741目录下的文件如下:

cache-id  diff  parent  size  tar-split.json.gz
  • cache-id 是该层存储目录的名字。比如该 cache-id 的内容为 52cb2ec8a0ac5a2418c568896fc079cc29a0da7d7b6b0c4b740d13241581d6e8,对应的位置是 /var/lib/docker/overlay2/52cb2ec8a0ac5a2418c568896fc079cc29a0da7d7b6b0c4b740d13241581d6e8,这里才是这一层文件的实际位置。cache-id 是随机生成的。
  • diff: 存储的是 diff_id,这里就是 ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439。diff_id 是 layer.tar 的 sha256sum 的值。
  • parent: 记录了上一层的 chain_id。
  • size: 这一层的大小。
  • tar-split.json.gz:vbatts/tar-split 这个库生成的文件,主要为了 image pull 之后,重新推送可能出现的 layer checksum 不一致的问题。具体可见:
    • https://github.com/moby/moby/pull/14067
    • https://github.com/moby/moby/issues/14018
    • https://github.com/distribution/distribution/issues/634

四、镜像的使用

根据上面的探索可以知道,提供 image+tag 或者 image_id,就可以找到关于这个镜像的所有信息。那么镜像提供的这些文件是如何被使用的呢?最简单的做法就是,将所有层的信息合并成 rootfs,提供给 runc 使用就可以了。

那么如何把这些层的信息高效的组合成 rootfs ?答案就是联合文件系统(UnionFS)。联合文件系统是一种分层、轻量级并且高性能的文件系统,它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下。

这里我们还是拿 docker 来说,docker 目前支持的联合文件系统有:overlay2,aufs,fuse-overlayfs,devicemapper,btrfs,zfs,vfs。目前推荐的是 overlay2 ,下面会使用 overlay2 来讲解容器是如何使用镜像的。

overlayfs

如上图所示,overlay2 主要有三个目录

  • lowerdir 对应了 image layers,图上只花了一层,但实际上是支持多层的,lowerdir 是只读的,也就是说 image layers 中所有的文件都不会被修改。
  • upperdir 对应了 container layer,这个是我们运行容器时创建出来的。upperdir 是支持读写的
  • merged 对应了 container mount,这一层是容器内的文件系统视图。也就是说,会把 image layer 和 container layer 在逻辑上合并起来。

overlay2 其实还有一个目录 workdir,workdir 是为了在某些场景下,为了保证操作的原子性而设计的。具体的可以参考:深入理解overlayfs(二):使用与原理分析

这里我们举几个例子帮助理解。我们通过 image 运行容器后,此时 image layer 如上图所示,但 container layer 是空的。

  • 在容器内删除 file2,只会在 container layer 中创建一个名为 file2 的 whiteout 文件,image layer 的 file2 不会被真正的删除。容器内 file2 不可见。
  • 在容器内重新创建 file2,只会在 container layer 创建 file2。
  • 在容器内修改 file1,会触发 copy-up,将 image layer 的 file1 拷贝到 container layer 中并修改。

可见,在 container layer 的文件操作比普通的文件系统操作消耗更大,特别是修改 image layer 的大文件会触发复制。

我们可以使用 docker inspect 来看一下:

"GraphDriver": {
  "Data": {
    "LowerDir": "/data00/docker/overlay2/379f2fd25bc3e317b5f767ea1bf061174013c0aa80a9454c6f01d47722dbd270-init/diff:/data00/docker/overlay2/a80f1234038ffa4720876215a77c16fd42d67fd0f110d9bd984dca73ea03b49b/diff:/data00/docker/overlay2/2aac2c4faa0be785d9d0faa5127f635bbc4dc65eb635bc442da4e03b46f05814/diff:/data00/docker/overlay2/afd88f520ed7fde849d8b7b136261348cd165bed4a6796382188529e08426685/diff:/data00/docker/overlay2/081774340b851380f09ad2d0286d8cfad388bf6738673ee089ed06efbed295c0/diff:/data00/docker/overlay2/651cf1972f63c01679beac3705e1d984d7dcae01ed4e6ae6c76bd7326b97d1c0/diff:/data00/docker/overlay2/2fc2f5bf53a5aaaec137666c6988573b3c3887a63eb5594ccbcd07995d0d3e5f/diff:/data00/docker/overlay2/0d323f1368ced5d62a56e7dd447db7cc4032b598a23473d31d5e812d2fed2376/diff:/data00/docker/overlay2/d07016559096eb4a59d4fe1e97ed7b26a492156f764542ff15ccbf53624ff7d2/diff:/data00/docker/overlay2/db070da7fe3eb36cd5a70fbcd5d72fed192d2d25d27cd5d9f7cfa26fb9de7bd0/diff:/data00/docker/overlay2/cabe60077f68da04a6d460cac3ef339bde6919fc267d808fd52950bbc5f4891d/diff:/data00/docker/overlay2/f1368e926209187e11312e4b805c9b1d912ac64776eada90e5115b245f5ab54a/diff",
    "MergedDir": "/data00/docker/overlay2/379f2fd25bc3e317b5f767ea1bf061174013c0aa80a9454c6f01d47722dbd270/merged",
    "UpperDir": "/data00/docker/overlay2/379f2fd25bc3e317b5f767ea1bf061174013c0aa80a9454c6f01d47722dbd270/diff",
    "WorkDir": "/data00/docker/overlay2/379f2fd25bc3e317b5f767ea1bf061174013c0aa80a9454c6f01d47722dbd270/work"
  },
  "Name": "overlay2"
}

了解了上述的知识后,我们就可以对 docker commit 的机制做一些猜想了:应该就是将 upperdir 作为新的 lowerdir 进行挂载。

参考:

深入理解overlayfs(二):使用与原理分析

Open Container Initiative Distribution Specification