IPv6 学习笔记

1. 概述

IPv6 全称是 Internet Protocol Version 6,不过虽然是叫 Version 6,事实上是网络层协议的第二代标准协议。其出现主要是为了解决 IPv4 在实际应用场景中存在的一些缺陷。

与 IPv4 的优缺点对比

摘抄自:华为 IPv6 技术白皮书

问题 IPv4缺陷 IPv6优势
地址空间 IPv4 地址只有32位,因此总共可表示的地址在 43 亿左右。另外由于历史原因,IP 地址的分配也非常不均衡:美国占全球地址 空间的一半左右,而欧洲则相对匮乏;亚太地区则更加匮乏。IPv4 中用来解决地址短缺的方法有:CIDR 和 NAT。不过这两种方案都有其本身的缺点。 IPv6 有 128 位。理论上总共可以支持 43亿x43亿x43亿x43亿的地址。
报文格式 IPv4报头包含可选字段Options,内 容涉及Security、Timestamp、 Record route等,这些Options可以将 IPv4报头长度从20字节扩充到60字 节。携带这些Options的IPv4报文在 转发过程中往往需要中间路由转发 设备进行软件处理,对于性能是个 很大的消耗,因此实际中也很少使 用。 IPv6和IPv4相比,去除了IHL、 Identifier、Flag、Fragment Offset、Header Checksum、 Option、Paddiing域,只增加了流 标签域,因此IPv6报文头的处理 较IPv4更为简化,提高了处理效 率。另外,IPv6为了更好支持各 种选项处理,提出了扩展头的概 念,新增选项时不必修改现有结 构,理论上可以无限扩展,体现 了优异的灵活性。
自动配置和重新编制 由于IPv4地址只有32比特,并且地 址分配不均衡,导致在网络扩容或 重新部署时,经常需要重新分配IP 地址,因此需要能够进行自动配置 和重新编址,以减少维护工作量。 目前IPv4的自动配置和重新编址机 制主要依靠DHCP协议。 IPv6协议内置支持通过地址自动 配置方式使主机自动发现网络并 获取IPv6地址,大大提高了内部 网络的可管理性。
路由聚合 由于IPv4发展初期的分配规划问 题,造成许多IPv4地址分配不连 续,不能有效聚合路由。日益庞大 的路由表耗用大量内存,对设备成 本和转发效率产生影响,这一问题 促使设备制造商不断升级其产品, 以提高路由寻址和转发性能。 巨大的地址空间使得IPv6可以方 便的进行层次化网络部署。层次 化的网络结构可以方便的进行路 由聚合,提高了路由转发效率。
端对端安全 IPv4协议制定时并没有仔细针对安全性进行设计,因此固有的框架结构并不能支持端到端的安全。 IPv6中,网络层支持IPSec的认证 和加密,支持端到端的安全。
QoS 随着网络会议、网络电话、网络电 视迅速普及与使用,客户要求有更 好的QoS来保障这些音视频实时转 发。IPv4并没有专门的手段对QoS 进行支持。 IPv6新增了流标记域,提供QoS 保证。
支持移动特性 随着Internet的发展,移动IPv4出现 了一些问题,比如:三角路由,源 地址过滤等。 IPv6协议规定必须支持移动特 性。和移动IPv4相比,移动IPv6 使用邻居发现功能可直接实现外 地网络的发现并得到转交地址, 而不必使用外地代理。同时,利 用路由扩展头和目的地址扩展头 移动节点和对等节点之间可以直 接通信,解决了移动IPv4的三角 路由、源地址过滤问题,移动通 信处理效率更高且对应用层透 明。

IPv6 地址

表示方法

IPv6 总共有 128 位,通过分为8组,每组 16 位,由 4 个十六进制数表示。每组之间由冒号分隔。如:FC00:0000:130F:0000:0000:09C0:876A:130B。为了方便书写,提供了一些压缩后的写法:

  • 可以省略前缀0。所以这个地址还可以写成:FC00:0:130F:0:0:9C0:876A:130B
  • 地址中包含的连续两个或多个均为0的组,可以用双冒号”::”代替。所以进一步缩写成:FC00:0:130F::9C0:876A:130B。不过需要注意的是,一个 IPv6 地址中只能有一个 “::”,因为如果有多个的话,就无法辨别出每个 “::” 代表几组 0。

地址结构

类似 IPv4 的设计,一个 IPv6 地址也是由两部分组成:

  • 网络前缀:n 位,相当于 IPv4 的网络号。
  • 接口标识:128-n位,相当于 IPv4 地址中的主机号。

地址分类

IPv6 地址分为单播地址,任播地址,组播地址。相比于 IPv4,取消了广播地址,以更丰富的组播地址代替,同时增加了任播地址。

单播地址

单播地址用来表示一个节点的一个网络接口的地址。有以下几种单播地址:

类型 说明
未指定地址 ::/128。该地址表示讴歌接口或者节点还没有 IP 地址。
环回地址 ::1/128。与 IPv4 中的 127.0.0.1 作用相同
全球单播地址 类似于 IPv4 中的单播地址。由 全球路由前缀(Global routing prefix,至少48位)+子网ID(Subnet ID)+接口标识(Interface ID)组成。全球路由前缀由提供商指定给一个组织机构,因此也可以起到聚合路由的作用。
链路本地地址 链路本地地址是 IPv6 中的应用范围受限制的地址类型,只能在连接到同一本地链 路的节点之间使用。它使用了特定的本地链路前缀FE80::/10(最高10位值为 1111111010),同时将接口标识添加在后面作为地址的低64比特。当一个节点启动IPv6协议栈时,启动时节点的每个接口会自动配置一个链路本地 地址(其固定的前缀+EUI-64规则形成的接口标识)。在 IPv4 中,链路本地地址为 169.254.0.0/16
唯一本地地址 唯一本地地址是另一种应用范围受限的地址,它仅能在一个站点内使用。由于本地站点地址的废除(RFC3879),唯一本地地址被用来代替本地站点地址。唯一本地地址的作用类似于IPv4中的私网地址,任何没有申请到提供商分配的全 球单播地址的组织机构都可以使用唯一本地地址。唯一本地地址只能在本地网络内部被路由转发而不会在全球网络中被路由转发。唯一本地地址的固定前缀为FC00::/7,二进制表示为 1111 110

任播地址

任播地址一般用来表示一组节点上的接口,当数据包发往任播地址时,中间路由设备会将数据包发往最近的一个节点上的接口。所以可以看出,任播地址是被设计用来给多个主机或者节点提供相同服务时提供冗余功能和负载均衡功能的。不过目前实际应用中,任播地址只能分配给路由设备,并不能应用于主机等设备。并且任播地址不能作为 IPv6 报文的源地址。

任播地址并没有单独的地址空间,和单播地址使用相同的地址空间。

组播地址

IPv6的组播与IPv4相同,用来标识一组接口,一般这些接口属于不同的节点。一个节点 可能属于0到多个组播组。发往组播地址的报文被组播地址标识的所有接口接收。例如 组播地址FF02::1表示链路本地范围的所有节点,组播地址FF02::2表示链路本地范围的 所有路由器。

一个IPv6组播地址由前缀,标志(Flag)字段、范围(Scope)字段以及组播组ID (Global ID)4个部分组成:

  • 前缀:IPv6组播地址的前缀是FF00::/8。

  • 标志字段(Flag):长度4bit,目前只使用了最后一个比特(前三位必须置0), 当该位值为0时,表示当前的组播地址是由IANA所分配的一个永久分配地址;当 该值为1时,表示当前的组播地址是一个临时组播地址(非永久分配地址)。

  • 范围字段(Scope):长度4bit,用来限制组播数据流在网络中发送的范围,该字 段取值和含义的对应关系如图1-5所示。

  • 组播组ID(Group ID):长度112bit,用以标识组播组。目前,RFC2373并没有将 所有的112位都定义成组标识,而是建议仅使用该112位的最低32位作为组播组 ID,将剩余的80位都置0。这样每个组播组ID都映射到一个唯一的以太网组播 MAC地址(RFC2464)。

image-20220220175332438

被请求节点组播地址通过节点的单播或任播地址生成。当一个节点具有了单播或任播地址,就会对应生成一个被请求节点组播地址,并且加入这个组播组。一个单播地址或任播地址对应一个被请求节点组播地址。该地址主要用于邻居发现机制和地址重复检测功能。

IPv6中没有广播地址,也不使用ARP。但是仍然需要从IP地址解析到MAC地址的 功能。在IPv6中,这个功能通过邻居请求NS(Neighbor Solicitation)报文完成。 当一个节点需要解析某个IPv6地址对应的MAC地址时,会发送NS报文,该报文目的IP就是需要解析的IPv6地址对应的被请求节点组播地址;只有具有该组播地 址的节点会检查处理。

被请求节点组播地址由前缀FF02::1:FF00:0/104和单播地址的最后24位组成。

IPv6 报文格式

IPv6 除了在大小上做了变动,也针对 IPv4 报文格式在实际应用场景中的设计不合理之处做了优化。IPv6 报文主要由三部分组成:

  • IPv6 基本报头:8个字段,固定为 40 字节。
  • IPv6 扩展报头:扩展报头是链式结构的,理论上可无限扩展
  • 上层协议数据单元:一般由上层协议报头和它的有效载荷构成,有效载荷可以是一个 ICMPv6 报文、一个 TCP 报文或一个 UDP 报文。

一个 IPv6 的基本报头格式如下:

image-20220220193727771

这些字段的解释如下:

  • Version:版本号,长度为4bit。对于IPv6,该值为6。

  • Traffic Class:流类别,长度为8bit。等同于IPv4中的TOS字段,表示IPv6数据报的 类或优先级,主要应用于QoS。

  • Flow Label:流标签,长度为20bit。IPv6中的新增字段,用于区分实时流量,不同 的流标签+源地址可以唯一确定一条数据流,中间网络设备可以根据这些信息更加 高效率的区分数据流。

  • Payload Length:有效载荷长度,长度为16bit。有效载荷是指紧跟IPv6报头的数据 报的其它部分(即扩展报头和上层协议数据单元)。该字段只能表示最大长度为 65535字节的有效载荷。如果有效载荷的长度超过这个值,该字段会置0,而有效 载荷的长度用逐跳选项扩展报头中的超大有效载荷选项来表示。

  • Next Header:下一个报头,长度为8bit。该字段定义紧跟在IPv6报头后面的第一个 扩展报头(如果存在)的类型,或者上层协议数据单元中的协议类型。

  • Hop Limit:跳数限制,长度为8bit。该字段类似于IPv4中的Time to Live字段,它 定义了IP数据报所能经过的最大跳数。每经过一个设备,该数值减去1,当该字段 的值为0时,数据报将被丢弃。

  • Source Address:源地址,长度为128bit。表示发送方的地址。

  • Destination Address:目的地址,长度为128bit。表示接收方的地址。

通过上述描述可以知道,IPv6 的基本报头相比于 IPv4 的报头做了简化,去除了IHL、identifiers、Flags、Fragment Offset、Header Checksum、 Options、Paddiing域,只增了流标签域。这样的设计可以提升路由设备对数据的处理性能。

在IPv4中,IPv4报头包含可选字段Options,内容涉及security、Timestamp、Record route 等,这些Options可以将IPv4报头长度从20字节扩充到60字节。在转发过程中,处理携带这些Options的IPv4报文会占用设备很大的资源,因此实际中也很少使用。

IPv6将这些Options从IPv6基本报头中剥离,放到了扩展报头中,扩展报头被置于IPv6 报头和上层协议数据单元之间。一个IPv6报文可以包含0个、1个或多个扩展报头,仅 当需要设备或目的节点做某些特殊处理时,才由发送方添加一个或多个扩展头。与 IPv4不同,IPv6扩展头长度任意,不受40字节限制,这样便于日后扩充新增选项,这一特征加上选项的处理方式使得IPv6选项能得以真正的利用。但是为了提高处理选项头 和传输层协议的性能,扩展报头总是8字节长度的整数倍。

当使用多个扩展报头时,前面报头的Next Header字段指明下一个扩展报头的类型,这 样就形成了链状的报头列表。目前,RFC 2460中定义了6个IPv6扩展头:逐跳选项报头、目的选项报头、路由报头、分段报头、认证报头、封装安全净载报头。

image-20220220194519027

ICMPv6

ICMPv6(Internet Control Message Protocol for the IPv6)是IPv6的基础协议之一。

在IPv4中,Internet控制报文协议ICMP(Internet Control Message Protocol)向源节点报 告关于向目的地传输IP数据包过程中的错误和信息。它为诊断、信息和管理目的定义 了一些消息,如:目的不可达、数据包超长、超时、回应请求和回应应答等。在IPv6 中,ICMPv6除了提供ICMPv4常用的功能之外,还是其它一些功能的基础,如邻接点 发现、无状态地址配置(包括重复地址检测)、PMTU发现等。

ICMPv6的协议类型号(即IPv6报文中的Next Header字段的值)为58。

image-20220220201051653

报文中字段解释如下:

  • Type:表明消息的类型,0至127表示差错报文类型,128至255表示消息报文类型。

  • Code:表示此消息类型细分的类型。

  • Checksum:表示ICMPv6报文的校验和。

邻居发现

邻居发现协议NDP(Neighbor Discovery Protocol)是IPv6协议体系中一个重要的基础协 议。邻居发现协议替代了IPv4的ARP(Address Resolution Protocol)和ICMP路由器发现 (Router Discovery),它定义了使用ICMPv6报文实现地址解析,跟踪邻居状态,重复 地址检测,路由器发现以及重定向等功能。

地址解析

邻居发现协议 NDP(Neighbor Discovery Protocol) 是基于 ICMPv6 的一个三层协议,用来取代 IPv4 的 ARP 协议。其以太网协议类型为 0x86DD。地址解析过程中使用了两种 ICMPv6 报文:邻居请求报文 NS(Neighbor Solicitation) 和邻居通告报文 NA(Neighbor Advertisement)。

  • NS 报文:Type 字段值为 135,Code 字段值为 0,在地址解析中的作用类似于 IPv4 中的 ARP 请求报文。
  • NA 报文:Type 字段值为 136,Code 字段值为0,在地址解析中的作用类似于 IPv4 中的 ARP 响应报文。

image-20220220202534018

Host A在向Host B发送报文之前它必须要解析出Host B的链路层地址,所以首先Host A 会发送一个NS报文,其中源地址为Host A的IPv6地址,目的地址为Host B的被请求节 点组播地址,需要解析的目标IP为Host B的IPv6地址,这就表示Host A想要知道Host B 的链路层地址。同时需要指出的是,在NS报文的Options字段中还携带了Host A的链路 层地址。

当Host B接收到了NS报文之后,就会回应NA报文,其中源地址为Host B的IPv6地址, 目的地址为Host A的IPv6地址(使用NS报文中的Host A的链路层地址进行单播),Host B的链路层地址被放在Options字段中。这样就完成了一个地址解析的过程。

跟踪邻居状态

通过邻居或到达邻居的通信,会因各种原因而中断,包括硬件故障、接口卡的热插入 等。如果目的地失效,则恢复是不可能的,通信失败;如果路径失效,则恢复是可能 的。 因此节点需要维护一张邻居表,每个邻居都有相应的状态,状态之间可以迁移。

RFC2461中定义了5种邻居状态,分别是:未完成(Incomplete)、可达 (Reachable)、陈旧(Stale)、延迟(Delay)、探查(Probe)

image-20220220202903445

下面以A、B两个邻居节点之间相互通信过程中A节点的邻居状态变化为例(假设A、B 之前从未通信),说明邻居状态迁移的过程。

  1. A先发送NS报文,并生成缓存条目,此时,邻居状态为Incomplete。
  2. 若B回复NA报文,则邻居状态由Incomplete变为Reachable,否则固定时间后邻居状态由Incomplete变为Empty,即删除表项。
  3. 经过邻居可达时间,邻居状态由Reachable变为Stale,即不确定邻居节点的可达性。
  4. 如果在Reachable状态,A收到B的非请求NA报文,且报文中携带的B的链路层地址和表项中不同,则邻居状态马上变为Stale。
  5. 在STALE状态到达老化时间后进入Delay状态。
  6. 在经过一段固定时间(5秒)后,邻居状态由Delay变为Probe,其间若有NA应答, 则邻居状态由Delay变为Reachable。
  7. 在Probe状态,A每隔一定时间间隔(1秒)发送单播NS,发送固定次数(3次) 后,有应答则邻居状态变为Reachable,否则邻居状态变为Empty,即删除表项。

重复地址检测

重复地址检测DAD(Duplicate Address Detect)是在接口使用某个IPv6单播地址之前进 行的,主要是为了探测是否有其它的节点使用了该地址。尤其是在地址自动配置的时 候,进行DAD检测是很必要的。 一个IPv6单播地址在分配给一个接口之后且通过重复 地址检测之前称为试验地址(Tentative Address)。此时该接口不能使用这个试验地址 进行单播通信,但是仍然会加入两个组播组:ALL-NODES组播组和试验地址所对应的 Solicited-Node组播组。

IPv6重复地址检测技术和IPv4中的免费ARP类似:节点向试验地址所对应的Solicited- Node组播组发送NS报文。NS报文中目标地址即为该试验地址。如果收到某个其他站点 回应的NA报文,就证明该地址已被网络上使用,节点将不能使用该试验地址通讯。

image-20220220203031376

Host A的IPv6地址FC00::1为新配置地址,即FC00::1为Host A的试验地址。Host A向 FC00::1的Solicited-Node组播组发送一个以FC00::1为请求的目标地址的NS报文进行重 复地址检测,由于FC00::1并未正式指定,所以NS报文的源地址为未指定地址。当Host B收到该NS报文后,有两种处理方法:

  • 如果Host B发现FC00::1是自身的一个试验地址,则Host B放弃使用这个地址作为 接口地址,并且不会发送NA报文。

  • 如果Host B发现FC00::1是一个已经正常使用的地址,Host B会向FF02::1发送一个 NA报文,该消息中会包含FC00::1。这样,Host A收到这个消息后就会发现自身的 试验地址是重复的。Host A上该试验地址不生效,被标识为duplicated状态。

路由器发现

路由器发现功能用来发现与本地链路相连的设备,并获取与地址自动配置相关的前缀和其他配置参数。

在IPv6中,IPv6地址可以支持无状态的自动配置,即主机通过某种机制获取网络前缀信 息,然后主机自己生成地址的接口标识部分。路由器发现功能是IPv6地址自动配置功 能的基础,主要通过以下两种报文实现:

  • 路由器通告RA(Router Advertisement)报文:每台设备为了让二层网络上的主机 和设备知道自己的存在,定时都会组播发送RA报文,RA报文中会带有网络前缀 信息,及其他一些标志位信息。RA报文的Type字段值为134。

  • 路由器请求RS(Router Solicitation)报文:很多情况下主机接入网络后希望尽快 获取网络前缀进行通信,此时主机可以立刻发送RS报文,网络上的设备将回应RA 报文。RS报文的Tpye字段值为133。

重定向

当网关设备发现报文从其它网关设备转发更好,它就会发送重定向报文告知报文的发 送者,让报文发送者选择另一个网关设备。重定向报文也承载在ICMPv6报文中,其 Type字段值为137,报文中会携带更好的路径下一跳地址和需要重定向转发的报文的目 的地址等信息。

image-20220220203223504

Host A需要和Host B通信,Host A的默认网关设备是Switch A,当Host A发送报文给 Host B时报文会被送到Switch A。Switch A接收到Host A发送的报文以后会发现实际上 Host A直接发送给Switch B更好,它将发送一个重定向报文给主机A,其中报文中更好 的路径下一跳地址为Switch B,Destination Address为Host B。Host A接收到了重定向报 文之后,会在默认路由表中添加一个主机路由,以后发往Host B的报文就直接发送给 Switch B。

当设备收到一个报文后,只有在如下情况下,设备会向报文发送者发送重定向报文:

  • 报文的目的地址不是一个组播地址。
  • 报文并非通过路由转发给设备。
  • 经过路由计算后,路由的下一跳出接口是接收报文的接口。
  • 设备发现报文的最佳下一跳IP地址和报文的源IP地址处于同一网段。
  • 设备检查报文的源地址,发现自身的邻居表项中有用该地址作为全球单播地址或链路本地地址的邻居存在。

Path MTU

在IPv4中,报文如果过大,必须要分片进行发送,所以在每个节点发送报文之前,设备都会根据发送接口的最大传输单元MTU(Maximum Transmission Unit)来对报文进 行分片。但是在IPv6中,为了减少中间转发设备的处理压力,中间转发设备不对IPv6报文进行分片,报文的分片将在源节点进行。当中间转发设备的接口收到一个报文后, 如果发现报文长度比转发接口的MTU值大,则会将其丢弃;同时将转发接口的MTU值 通过ICMPv6报文的“Packet Too Big”消息发给源端主机,源端主机以该值重新发送 IPv6报文,这样带来了额外流量开销。PMTU发现协议可以动态发现整条传输路径上各 链路的MTU值,减少由于重传带来的额外流量开销。

PMTU协议是通过ICMPv6的Packet Too Big报文来完成的。首先源节点假设PMTU就是 其出接口的MTU,发出一个试探性的报文,当转发路径上存在一个小于当前假设的 PMTU时,转发设备就会向源节点发送Packet Too Big报文,并且携带自己的MTU值, 此后源节点将PMTU的假设值更改为新收到的MTU值继续发送报文。如此反复,直到 报文到达目的地之后,源节点就能知道到达目的地的PMTU了。

image-20220220203415347

整条传输路径需要通过4条链路,每条链路的MTU分别是1500、1500、1400、1300,当 源节点发送一个分片报文的时候,首先按照PMTU为1500进行分片并发送分片报文,当 到达MTU为1400的出接口时,设备返回Packet Too Big错误,同时携带MTU值为1400的 信息。源节点接收到之后会将报文重新按照PMTU为1400进行分片并再次发送一个分片 报文,当分片报文到达MTU值为1300的出接口时,同样返回Packet Too Big错误,携带 MTU值为1300的信息。之后源节点重新按照PMTU为1300进行分片并发送分片报文, 最终到达目的地,这样就找到了该路径的PMTU。

Linux IPv6

条目 ipv4 ipv6
sysctl 配置项 net.ipv4.conf net.ipv6.conf
ip 地址 通过 ip a查看时可以看到 inet 后面的就是 ipv4 地址。 通过 ip a查看时可以看到 inet6 后面的就是 ipv6 地址。一般会有多个,scope global 的是全局唯一单播地址或唯一本地地址(fc或fd开头),scope link 是链路本地地址(fe80 开头)。
抓包 tcpdump icmp/ tcpdump ip tcpdump icmp6 / tcpdump ip6
ping ping ping6 或 ping -6
traceroute6 traceroute traceroute6
邻居地址解析 arping ndisc
路由表 ip r ip -6 r
邻居地址表 ip neigh 或 arp -n ip -6 neigh
DNS 解析 dig dig -6

Kubernetes 的 IPv4/IPv6 双栈

IPv4/IPv6 双栈是由 IPv4 向 IPv6 过渡阶段的一种解决方案,双栈即一个网络接口同时拥有 IPv4 和 IPv6 的地址,这样在和远端通信时,如果远端支持 IPv6,就使用 IPv6 进行通信,否则也可以使用 IPv4 进行通信。Kubernetes 在 1.20 后开始支持双栈。当然,除了对 Kubernetes 版本有要求外,CNI 插件也必须支持双栈才行。

要在 Kubernetes 中开启双栈,需要做以下配置:

  • kube-apiserver:
    • --service-cluster-ip-range=<IPv4 CIDR>,<IPv6 CIDR>
  • kube-controller-manager:
    • --cluster-cidr=<IPv4 CIDR>,<IPv6 CIDR>
    • --service-cluster-ip-range=<IPv4 CIDR>,<IPv6 CIDR>
    • --node-cidr-mask-size-ipv4|--node-cidr-mask-size-ipv6 对于 IPv4 默认为 /24,对于 IPv6 默认为 /64
  • kube-proxy:
    • --cluster-cidr=<IPv4 CIDR>,<IPv6 CIDR>

IPv6 地址速查

平常接触 IPv4 地址较多,因此一眼就大概知道某个地址代表什么含义,但是 IPv6 中往往比较难分辨,这里提供一个表格供对照参考。

地址类型 IPv4 IPv6
环回地址 127.0.0.1 ::1/128
私网地址 10.0.0.0 – 10.255.255.255, 172.16.0.0 – 172.31.255.255,192.168.0.0 – 192.168.255.255 前缀FC00::/7(1111 110),范围:FC~FD。
链路本地地址 169.254.0.0/16 fe80::/10
组播地址 被请求节点组播地址由前缀FF02::1:FF00:0/104和单播地址的最后24位组成。
广播地址 广播地址使用该网络范围内的最大地址。 即主机部分的各比特位全部为 1 的地址。在网络 10.1.1.0/24 中,其广播地址是 10.1.1.255。

参考

  • 华为 《IPv6 技术白皮书》。本文大多数内容都是参考或摘抄自该白皮书。

机械硬盘的性能评估

概述

从个人 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 中的事件,比如镜像的创建,更新,容器的创建,删除等等。