type
Post
status
Published
date
Mar 17, 2026
slug
docker
summary
tags
docker
category
docker
icon
password
开篇导读
容器本质是一个被 Namespace 隔离、被 Cgroups 限制资源的进程,而容器运行时则负责创建和管理这些容器进程。只不过这个进程通过 Linux 内核提供的多种机制进行了隔离和资源控制,使其看起来像是一个独立的运行环境。容器主要依赖两种核心技术:
Namespace:实现资源隔离,例如进程、网络、文件系统等
Cgroups:实现资源限制,例如 CPU、内存、I/O 等
通过这两种机制,容器中的应用进程会感觉自己运行在一个独立的系统环境中,因此使用体验上类似于一台虚拟机。不过容器与虚拟机之间有一个本质区别:
虚拟机:每个虚拟机都有自己独立的操作系统内核
容器:所有容器都共享宿主机的内核。正因为容器共享宿主机内核,所以它的启动速度更快、资源开销更小
自 1979年,Unix 版本7 在开发过程中引入 Chroot Jail 以及 Chroot 系统调用开始,直到 2013 年开源出的 Docker,2014 年开源出来的 Kubernetes,直到现在的云原生生态的火热。 容器技术已经逐步成为主流的基础技术之一。在越来越多的公司、个人选择了云服务/容器技术后,资源的分配和隔离,以及安全性变成了人们关注及讨论的热点话题。
其实容器技术使用起来并不难,但要真正把它用好,大规模的在生产环境中使用, 那我们还是需要掌握其核心的。以下是容器技术&云原生生态的大致发展历程:

Namespace 名称空间
多个容器运行在一个宿主机上会面临以下问题:怎么样保证每个容器都有不同的文件系统并且能互不影响?一个docker主进程内的各个容器都是其子进程,那么实现同一个主进程下不同类型的子进程?各个进程间通信能相互访问(内存数据)吗?每个容器怎么解决IP及端口分配的问题?多个容器的主机名能一样吗?每个容器都要不要有root用户?怎么解决账户重名问题?
Namespace 是什么?<a href="https://en.wikipedia.org/wiki/Linux_namespaces"> wiki </a>上对 namespace 的定义:
Namespaces are a feature of the Linux kernel that partitions kernel resources such that one set of processes sees one set of resources while another set of processes sees a different set of resources. The feature works by having the same namespace for a set of resources and processes, but those namespaces refer to distinct resources.
翻译成中文就是:namespace 是 Linux 内核的一项特性,它可以对内核资源进行分区,使得一组进程可以看到一组资源;而另一组进程可以看到另一组不同的资源。该功能的原理是为一组资源和进程使用相同的 namespace,但是这些 namespace 实际上引用的是不同的资源。
这样的说法未免太绕了些,简单来说 namespace 是由 Linux 内核提供的,用于进程间资源隔离的一种技术。将全局的系统资源包装在一个抽象里,让进程(看起来)拥有独立的全局资源实例。同时 Linux 也默认提供了多种 namespace,用于对多种不同资源进行隔离。Linux 六种 Namespace 隔离类型:
PID:进程ID隔离
NET:网络隔离
UTS:主机名和域名隔离
IPC:进程间通信的隔离(信号量、消息队列、共享内存)
USER:用户和用户组隔离
MNT:挂载点隔离
Namespace 的发展历程
namespace 的早期提出及使用要追溯到 Plan 9 from Bell Labs ,贝尔实验室的 Plan 9。这是一个分布式操作系统,由贝尔实验室的计算科学研究中心在八几年至02年开发的(02年发布了稳定的第四版,距离92年发布的第一个公开版本已10年打磨),现在仍然被操作系统的研究者和爱好者开发使用。在 Plan 9 的设计与实现中,我们着重提以下3点内容:
文件系统:所有系统资源都列在文件系统中,以 Node 标识。所有的接口也作为文件系统的一部分呈现。
Namespace:能更好的应用及展示文件系统的层次结构,它实现了所谓的 “分离”和“独立”。
标准通信协议:9P协议(Styx/9P2000)。

Namespace 开始进入 Linux Kernel 的版本是在 2.4.X,最初始于 2.4.19 版本。但是,自 2.4.2 版本才开始实现每个进程的 namespace。

Linux Kernel 对应的各操作系统版本

Linux 3.8 中终于完全实现了 User Namespace 的相关功能集成到内核。这样 Docker 及其他容器技术所用到的 namespace 相关的能力就基本都实现了

Namespace 类型
MNT Namespace
Mount Namespace 是 Linux 中最早引入的 Namespace 之一(早期内核就已支持),它的作用是:隔离不同进程看到的挂载点(mount points)信息。通俗理解就是,不同进程可以看到完全不同的文件系统视图。Mount Namespace 提供了文件系统隔离和挂载点隔离,具体表现为:每个 Namespace 都有自己独立的挂载表(mount table),在某个 Namespace 中执行 mount/umount 操作,只对当前 Namespace 生效,不会影响宿主机或其他 Namespace。
我们使用以下命令创建一个 bash 进程并且新建一个 Mount Namespace
执行完上述命令后,这时我们已经在主机上创建了一个新的 Mount Namespace,并且当前命令行窗口加入了新创建的 Mount Namespace。下面我通过一个例子来验证下,在独立的 Mount Namespace 内创建挂载目录是不影响主机的挂载目录的。首先在 /tmp 目录下创建一个目录
创建好目录后使用 mount 命令挂载一个 tmpfs 类型的目录。命令如下:
然后使用 df 命令查看一下已经挂载的目录信息,可以看到 /tmp/tmpfs 目录已经被正确挂载

为了验证主机上并没有挂载此目录,我们新打开一个命令行窗口,同样执行 df 命令查看主机的挂载信息。可以看到主机上并没有挂载 /tmp/tmpfs,可见我们独立的 Mount Namespace 中执行 mount 操作并不会影响主机

为了进一步验证我们的想法,我们继续在当前命令行窗口查看一下当前进程的 Namespace 信息。通过对比两次命令的输出结果,我们可以看到,除了 Mount Namespace 的 ID 值不一样外,其他Namespace 的 ID 值均一致

在容器中,Mount Namespace 的核心作用是让每个容器拥有独立的根文件系统(rootfs)。也就是说,容器内看到的根目录,实际上是宿主机上的某个目录。但由于 Mount Namespace 的隔离,容器会认为这就是完整的操作系统文件系统。
下面我们来运行两个不同的容器
在 busybox1 容器创建一个文件,发现在 busybox2 中无法看到
容器中的数据会保存到宿主机内
PID Namespace
PID Namespace 是 Linux 提供的一种用于隔离进程 ID(PID) 的机制。在不同的 PID Namespace 中,可以存在相同的进程号。也就是说,每个 Namespace 都维护着自己独立的一套 PID 号空间。在一个新的 PID Namespace 中,进程编号会从 1 开始分配,进程可以通过 fork()、vfork()、clone() 等系统调用创建新的子进程,这些进程的 PID 只在当前 Namespace 内可见。例如一个进程在主机上 PID 为 122,使用 PID Namespace 可以实现该进程在容器内看到的 PID 为 1。
在传统 Linux 系统中,PID = 1 的进程通常是系统的 init 或 systemd,它是所有进程的祖先进程,同时负责回收僵尸进程(wait 子进程)。而在容器环境中,由于每个容器都有独立的 PID Namespace,因此,每个容器内部也会有一个 PID = 1 的进程,这个进程通常就是容器启动时执行的第一个进程,它负责管理容器内的子进程,并回收子进程资源。要使用 PID namespace 需要内核支持 CONFIG_PID_NS 选项。如下:
下面我们通过一个实例来演示下 PID Namespace 的作用。首先我们使用以下命令创建一个 bash 进程,并且新建一个 PID Namespace
执行完上述命令后,我们在主机上创建了一个新的 PID Namespace,并且当前命令行窗口加入了新创建的 PID Namespace。在当前的命令行窗口使用 ps aux 命令查看一下进程信息
通过上述命令输出结果可以看到当前 Namespace 下 bash 为 1 号进程,而且我们也看不到主机上的其他进程信息

下面我们运行一个nginx容器进行演示


UTS Namespace
UTS namespaces 隔离了主机名和 NIS 域名,它使得一个容器拥有属于自己 hostname 标识,这个主机名标识独立于宿主机系统和其上的其它容器。使用 UTS namespaces 需要内核支持 CONFIG_UTS_NS 选项。如:
同样我们通过一个实例来验证下 UTS Namespace 的作用,首先我们使用 unshare 命令来创建一个 UTS Namespace
创建好 UTS Namespace 后,当前命令行窗口已经处于一个独立的 UTS Namespace 中,下面我们使用 hostname 命令设置一下主机名
查看主机名
通过上面命令的输出,我们可以看到当前 UTS Namespace 内的主机名已经被修改为 docker。然后我们新打开一个命令行窗口,使用相同的命令查看一下主机的 hostname
可以看到主机的名称仍然为 localhost,并没有被修改。由此,可以验证 UTS Namespace 可以用来隔离主机名。
下面我们运行一个容器进行查看,发现容器内主机名与宿主机主机名不一样
IPC进程间通信隔离
IPC 指的是 Unix/Linux 系统中进程间通信的一种机制。常见的 IPC 方式包括:共享内存(Shared Memory)、信号量(Semaphore)、消息队列(Message Queue)。这些机制允许不同进程之间进行数据交换和同步。在容器环境中,为了实现进程之间的资源隔离,IPC 资源也需要被隔离。因此 Linux 提供了 IPC Namespace。IPC Namespace 的作用是:将 IPC 资源进行隔离,只有同一个 Namespace 内的进程,才能访问彼此创建的 IPC 资源。使用 IPC namespace 需要内核支持 CONFIG_IPC_NS 选项。如下:
每个 IPC namespace 都有着自己的一组 System V IPC 标识符,以及 POSIX 消息队列系统。可以在 IPC namespace 中设置以下 /proc 接口:
/proc/sys/fs/mqueue:POSIX 消息队列接口
/proc/sys/kernel:System V IPC 接口 (msgmax, msgmnb, msgmni, sem, shmall, shmmax, shmmni, shm_rmid_forced)
/proc/sysvipc:System V IPC 接口
当 IPC namespace 被销毁时(空间里的最后一个进程都被停止删除时),在 IPC namespace 中创建的 object 也会被销毁。同样我们通过一个实例来验证下IPC Namespace的作用,首先我们使用 unshare 命令来创建一个 IPC Namespace:
下面我们需要借助两个命令来实现对 IPC Namespace 的验证。
ipcs -q:用来查看系统间通信队列列表
ipcmk -Q:用来创建系统间通信队列我们首先使用 ipcs -q 命令查看一下当前 IPC Namespace 下的系统通信队列列表:

由上可以看到当前无任何系统通信队列,然后我们使用 ipcmk -Q 命令创建一个系统通信队列

再次使用 ipcs -q 命令查看当前 IPC Namespace 下的系统通信队列列表,可以看到我们已经成功创建了一个系统通信队列

然后我们新打开一个命令行窗口,使用 ipcs -q 命令查看一下主机的系统通信队列

通过上面的实验,可以发现,在单独的 IPC Namespace 内创建的系统通信队列在主机上无法看到。即 IPC Namespace 实现了系统通信队列的隔离。
User namespaces
各个容器内可能会出现重名的用户名和用户组名,或重复的用户UID或者GID。User Namespace允许在各个宿主机的各个容器空间内创建相同的用户名以及相同的用户UID和GID。只是会把用户的作用范围限制在每个容器内,即A容器和B容器可以有相同的用户名称和ID的账户,但是此用户的有效范围仅是当前容器内,不能访问另外一个容器内的文件系统,即相互隔离、互补影响、永不相见。
使用 user namespaces 需要内核支持 CONFIG_USER_NS 选项。如:
进程的用户 id 和组 id 在一个 user namespace 内和外有可能是不同的。比如,一个进程在 user namespace 中的用户和组可以是特权用户(root),但在该 user namespace 之外,可能只是一个普通的非特权用户。这就涉及到用户、组映射(uid_map 、gid_map)等相关的内容了。自 Linux v3.5 版本的内核开始,在 /proc/[pid]/uid_map 和 /proc/[pid]/gid_map 文件中,我们可以查看到映射内容。
User namespaces 还有个很重要的规则,那就是关于 Linux capability 的继承关系,这里简单记录一下:
当进程所在的 user namespace 拥有 effective capability set 中的 capability 时,该进程具有该 capability。
当进程在该 user namespace 中拥有 capability 时,该进程在此 user namespace 的所有子 user namespace 中都拥有该 capability。
创建该 user namespace 的用户会被内核记录为 owner ,即,拥有该 user namespace 中的全部 capabilities。
User Namesapce 的创建是可以不使用 root 权限的。下面我们以普通用户的身份创建一个 User Namespace,命令如下:

CentOS7 默认允许创建的 User Namespace 为 0,如果执行上述命令失败(unshare 命令返回的错误为 unshare: unshare failed: Invalid argument ),需要使用以下命令修改系统允许创建的 User Namespace 数量,命令为:echo 65535 > /proc/sys/user/max_user_namespaces,然后再次尝试创建 User Namespace。然后执行 id 命令查看一下当前的用户信息:

通过上面的输出可以看到我们在新的 User Namespace 内已经是 root 用户了。下面我们使用只有主机 root 用户才可以执行的 reboot 命令来验证一下,在当前命令行窗口执行 reboot 命令:

可以看到,我们在新创建的 User Namespace 内虽然是 root 用户,但是并没有权限执行 reboot 命令。这说明在隔离的 User Namespace 中,并不能获取到主机的 root 权限,也就是说 User Namespace 实现了用户和用户组的隔离。
对于 Docker 而言,它可以原生的支持此能力,进而达到对容器环境的一种保护。下面我们来运行两个不同的容器
进入 busybox 容器,每个容器内都有超级管理员root及其它系统账户,并且账户ID和其它容器相同
NET Namespace
Net Namespace 是用来隔离网络设备、IP 地址和端口等信息的。Net Namespace 可以让每个进程拥有自己独立的 IP 地址,端口和网卡信息。例如主机 IP 地址为 172.16.4.1 ,容器内可以设置独立的 IP 地址为 192.168.1.1。
使用 Network namespaces 需要内核支持 CONFIG_NET_NS 选项。如下:
同样用实例验证,我们首先使用 ip a 命令查看一下主机上的网络信息:

然后我们使用以下命令创建一个 Net Namespace
同样的我们使用 ip a 命令查看一下网络信息,可以看到,宿主机上有 lo 、eth0、docker0 等网络设备,而我们新建的 Net Namespace 内则与主机上的网络设备不同

Cgroups
Cgroups(Control Groups) 是 Linux 内核提供的一种机制,用于对一组进程的资源使用进行限制、控制和隔离。它最早由 Google 工程师开发,并从 Linux 内核 2.6.24(2008 年) 开始正式提供支持。Cgroups 的核心能力可以概括为三点:
资源限制(Limit):限制进程组最多可以使用多少资源
资源分配(Accounting / Control):按比例分配 CPU、内存等资源
资源隔离(Isolation):避免不同进程组之间相互影响
为什么需要 Cgroups?在没有资源限制的情况下,一个进程(或容器)可以无限制地占用系统资源。例如:程序出现 Bug,不断申请内存,最终可能导致宿主机内存耗尽(OOM),甚至影响其他正常运行的服务。Cgroups 是容器的资源管理核心,为每个容器分配固定资源,防止容器之间资源争抢,保证系统整体稳定性。
目前 Cgroups 主要有两个版本:
cgroup v1:早期版本,各资源子系统相互独立
cgroup v2:统一架构,功能更完善,推荐使用
现代系统(如较新版本 Linux / 容器平台)基本都在逐步采用 cgroup v2
当我们将可用系统资源按特定百分比分配给 cgroup 时,剩余的资源可供系统上的其他 cgroup 或其他进程使用。cgroup 资源分配及剩余可用资源示例:

Cgroups在内核层默认已经开启,从CentOS 和 Ubuntu 不同版本对比,显然内核较新的支持的功能更多
我们可以通过查看 /proc/cgroups 文件来查找当前系统支持的 CGroups 子系统:

在使用 CGroups 时需要先挂载,我们可以使用 df -h | grep cgroup 命令进行查看:

可以看到被挂载到了 /sys/fs/cgroup,cgroup 其实是一种文件系统类型,所有的操作都是通过文件来完成的,我们可以使用
mount --type cgroup命令查看当前系统挂载了哪些 cgroup:
/sys/fs/cgroup 目录下的每个子目录就对应着一个子系统,cgroup 是以目录形式组织的,
/ 是 cgroup 的根目录,但是这个根目录可以被挂载到任意目录blkio:限制 cgroup 中进程的块设备 IO
cpu:限制 cgroup 中进程的 CPU 使用份额
cpuacct:统计 cgroup 中进程的 CPU 使用情况
cpuset:为 cgroup 中的进程分配单独的 CPU 节点,即可以绑定到特定的 CPU
memory:限制 cgroup 中进程的内存使用,并能报告内存使用情况
devices:控制 cgroup 中进程能访问哪些文件设备(设备文件的创建、读写)
freezer:挂起或恢复 cgroup 中的 task
net_cls:可以标记 cgroups 中进程的网络数据包,然后可以使用 tc 模块(traffic contro)对数据包进行控制
perf_event:监控 cgroup 中进程的 perf 时间,可用于性能调优
hugetlb:hugetlb 的资源控制功能
pids:限制 cgroup 中可以创建的进程数
net_prio:允许管理员动态的通过各种应用程序设置网络传输的优先级
例如 CGroups 的 memory 子系统的挂载点是 /sys/fs/cgroup/memory,那么 /sys/fs/cgroup/memory/ 对应 memory 子系统的根目录,我们可以列出该目录下面的文件

例如我在节点上使用 systemd 管理了一个 docker 应用,我们可以使用 systemctl status docker 命令查看 docker 进程所在的 cgroup 为 /system.slice/docker.service

上面显示的 CGroup 只是一个相对的路径,实际的文件系统目录是在对应的子系统下面,比如/sys/fs/cgroup/memory/system.slice/docker.service、/sys/fs/cgroup/cpu/system.slice/docker.service/

CPU Cgroup
容器在 Linux 系统中最核心的两个概念是 Namespace 和 Cgroups。Cgroups 是对指定进程做计算机资源限制的,我们可以通过 Cgroups 技术限制资源。这个资源可以分为很多类型,比如 CPU,Memory,Storage,Network 等等。CPU Cgroup 是 Cgroups 其中的一个 Cgroups 子系统,它是用来限制进程的 CPU 使用的。
对于进程的 CPU 使用, 通过前面的 CPU 使用分类的介绍,我们知道它只包含两部分: 一个是用户态,这里的用户态包含了 us 和 ni;还有一部分是内核态,也就是 sy。至于 wa、hi、si,这些 I/O 或者中断相关的 CPU 使用,CPU Cgroup 不会去做限制。
每个 Cgroups 子系统都是通过一个虚拟文件系统挂载点的方式,挂到一个缺省的目录下。CPU Cgroup 一般在 Linux 发行版里会放在
/sys/fs/cgroup/cpu这个目录下。在这个子系统的目录下,每个控制组都是一个子目录,各个控制组之间的关系就是一个树状的层级关系。比如,我们在子系统的最顶层开始建立两个控制组(也就是建立两个目录)group1 和 group2,然后再在 group2 的下面再建立两个控制组 group3 和 group4。这样操作以后,我们就建立了一个树状的控制组层级
接下来,我们来看一下每个控制组里 CPU Cgroup 相关的控制信息
cpu.cfs_period_us 它是 CFS(完全公平调度器) 算法的一个调度周期,一般它的值是100000,以 microseconds 为单位,也就 100ms
cpu.cfs_quota_us 表示 CFS(完全公平调度器) 算法中,在一个调度周期里这个控制组被允许的运行时间,比如这个值为 50000 时,就是 50ms
cpu.shares这个值是 CPU Cgroup 对于控制组之间的 CPU分配比例,它的缺省值是 1024。假如我们前面创建的 group3 中的 cpu.shares 是 1024,而 group4 中的 cpu.shares 是 3072,那么 group3:group4=1:3。那么这个比例是什么意思呢?比如在一台 4 个 CPU 的机器上,当 group3 和 group4 都需要 4 个 CPU 的时候,它们实际分配到的 CPU 分别是这样的:group3 是 1 个,group4 是 3 个。如果用cpu.cfs_quota_us的值去除以调度周期cpu.cfs_period_us的值,也就是50ms/100ms = 0.5。这样这个控制组被允许使用的 CPU 最大配额就是 0.5 个 CPU。如果 cpu.cfs_quota_us 这个值是 200000,也就是200ms,那么它除以 period,也就是 200ms/100ms=2。结果超过了 1 个 CPU,这就意味着这时控制组需要 2 个 CPU 的资源配额。
接下来,我们启动一个消耗 2 个 CPU(200%)的程序 threads-cpu,然后把这个程序的 pid 加入到 group3 的控制组里。代码如下
编译
执行命令
在我们没有修改
cpu.cfs_quota_us 前,用 top 命令可以看到 threads-cpu 这个进程的 CPU 使用是 199%,近似 2 个 CPU。
然后更新这个控制组里的 cpu.cfs_quota_us,把它设置为 150000(150ms)。把这个值除以 cpu.cfs_period_us,计算过程是150ms/100ms=1.5, 也就是 1.5 个 CPU,同时我们也把 cpu.shares 设置为 1024
这时候我们再运行 top,就会发现 threads-cpu 进程的 CPU 使用减小到了 150%。这是因为我们设置的 cpu.cfs_quota_us 起了作用,限制了进程 CPU 的绝对值。

但这时候 cpu.shares 的作用还没有发挥出来,因为 cpu.shares 是几个控制组之间的 CPU 分配比例,而且一定要到整个节点中所有的 CPU 都跑满的时候,它才能发挥作用。下面我们再来运行一个例子来理解 cpu.shares
我们的节点上总共有 4 个 CPU,而 group3 的程序需要消耗 2 个 CPU,group4 里的程序要消耗 4 个 CPU。即使 cpu.cfs_quota_us 已经限制了进程 CPU 使用的绝对值,group3 的限制是1.5CPU,group4 是 3.5CPU,1.5+3.5=5,这个结果还是超过了节点上的 4 个 CPU。group4中的cpu.shares值为3072,group3中的cpu.shares值为1024,shares 比例是 group4:group3=3:1。按照比例,group4 里的进程应该分配到 3 个 CPU,而 group3 里的进程会分配到 1 个 CPU。

所以
cpu.cfs_quota_us 和 cpu.cfs_period_us 这两个值决定了每个控制组中所有进程的可使用 CPU 资源的最大值。cpu.shares 这个值决定了 CPU Cgroup 子系统下控制组可用 CPU 的相对比例,不过只有当系统上 CPU 完全被占满的时候,这个比例才会在各个控制组间起作用。在理解了 Linux CPU Usage 和 CPU Cgroup 这两个基本概念之后,怎么限制容器的 CPU 使用这个问题就比较好解释了。
在 Kubernetes 中会为每个容器都在 CPU Cgroup 的子系统中建立一个控制组,然后把容器中进程写入到这个控制组里。这时候 Limit CPU 就需要为容器设置可用 CPU 的上限。容器 CPU 的上限由
cpu.cfs_quota_us 除以 cpu.cfs_period_us 得出的值来决定的。而且,在操作系统里,cpu.cfs_period_us 的值一般是个固定值,Kubernetes 不会去修改它,所以我们就是只修改 cpu.cfs_quota_us。而 Request CPU 就是无论其它容器申请多少 CPU 资源,即使运行时整个节点的 CPU 都被占满的情况下,我的这个容器还是可以保证获得需要的 CPU 数目,那么这个设置具体要怎么实现呢?显然需要设置 cpu.shares 这个参数:在 CPU Cgroup 中 cpu.shares == 1024 表示 1 个 CPU 的比例,那么 Request CPU 的值就是 n,给 cpu.shares 的赋值对应就是 n*1024。
Memory Cgroup
Memory Cgroup也是Linux Cgroups子系统之一,它的作用是对一组进程的Memory使用做限制。Memory Cgroup的虚拟文件系统的挂载点一般在
/sys/fs/cgroup/memory这个目录下。我们可以在Memory Cgroup的挂载点目录下,创建一个子目录作为控制组。每一个控制组下面有不少参数,以下是跟OOM最相关的3个参数,其他参数如果你有兴趣了解,可以参考内核的文档说明<a href="https://www.kernel.org/doc/Documentation/cgroup-v1/memory.txt">https://www.kernel.org/doc/Documentation/cgroup-v1/memory.txt</a>。
memory.limit_in_bytes是每个控制组里最重要的一个参数了。这是因为一个控制组里所有进程可使用内存的最大值,就是由这个参数的值来直接限制的。
memory.oom_control当控制组中的进程内存使用达到上限值时,这个参数能够决定会不会触发OOM Killer。如果没有人为设置的话,memory.oom_control的缺省值就会触发OOM Killer。这是一个控制组内的OOM Killer,和整个系统的OOM Killer的功能差不多,差别只是被杀进程的选择范围:控制组内的OOM Killer当然只能杀死控制组内的进程,而不能选节点上的其他进程。如果我们要改变缺省值,也就是不希望触发OOM Killer,只要执行 echo 1 > memory.oom_control 就行了,这时候即使控制组里所有进程使用的内存达到memory.limit_in_bytes设置的上限值,控制组也不会杀掉里面的进程。但是,这样操作以后,就会影响到控制组中正在申请物理内存页面的进程。这些进程会处于一个停止状态,不能往下运行了。
memory.usage_in_bytes这个参数是只读的,它里面的数值是当前控制组里所有进程实际使用的内存总和。我们可以查看这个值,然后把它和memory.limit_in_bytes里的值做比较,根据接近程度来可以做个预判。这两个值越接近,OOM的风险越高。通过这个方法,我们就可以得知,当前控制组内使用总的内存量有没有OOM的风险了。我用一个具体例子来说明,比如像下面图里展示的那样,group1里的
memory.limit_in_bytes设置的值是200MB,它的子控制组group3里memory.limit_in_bytes值是500MB。那么,我们在group3里所有进程使用的内存总值就不能超过200MB,而不是500MB。
每个容器创建后,系统都会为它建立一个Memory Cgroup的控制组,容器的所有进程都在这个控制组里。一般的容器云平台,比如Kubernetes都会为容器设置一个内存使用的上限。这个内存的上限值会被写入Cgroup里,具体来说就是容器对应的Memory Cgroup控制组里
memory.limit_in_bytes这个参数中。所以,一旦容器中进程使用的内存达到了上限值,OOM Killer会杀死进程使容器退出。那么我们怎样才能快速确定容器发生了OOM呢?这个可以通过查看内核日志及时地发现。我们通过查看内核的日志,使用用
journalctl -k 命令,或者直接查看日志文件/var/log/message,我们会发现当容器发生OOM Kill的时候,内核会输出下面的这段信息,大致包含下面这三部分的信息:
第一个部分就是容器里每一个进程使用的内存页面数量。在rss列里,rss是Resident Set Size的缩写,指的就是进程真正在使用的物理内存页面数量。比如下面的日志里,我们看到init进程的rss是1个页面,mem_alloc进程的rss是130801个页面,内存页面的大小一般是4KB,我们可以做个估算,130801 * 4KB大致等于512MB。
第二部分我们来看上面图片的
oom-kill 这行,这一行里列出了发生OOM的Memroy Cgroup的控制组,我们可以从控制组的信息中知道OOM是在哪个容器发生的。
第三部分是图中 Killed process 7445 (mem_alloc) 这行,它显示了最终被OOM Killer杀死的进程。我们通过了解内核日志里的这些信息,可以很快地判断出容器是因为OOM而退出的,并且还可以知道是哪个进程消耗了最多的Memory。那么知道了哪个进程消耗了最大内存之后,我们就可以有针对性地对这个进程进行分析了,一般有这两种情况:
第一种情况是这个进程本身的确需要很大的内存,这说明我们给memory.limit_in_bytes里的内存上限值设置小了,那么就需要增大内存的上限值。
第二种情况是进程的代码中有Bug,会导致内存泄漏,进程内存使用到达了Memory Cgroup中的上限。如果是这种情况,就需要我们具体去解决代码里的问题了。
我们已经知道了,如果容器使用的物理内存超过了Memory Cgroup里的memory.limit_in_bytes值,那么容器中的进程会被OOM Killer杀死。不过在一些容器的使用场景中,比如容器里的应用有很多文件读写,你会发现整个容器的内存使用量已经很接近Memory Cgroup的上限值了,但是在容器中我们接着再申请内存,还是可以申请出来,并且没有发生OOM。
启动容器,并设置容器Memory Cgroup里的内存上限值是100MB(104857600bytes)。
把容器启动起来后,我们进入容器查看一下容器的Memory Cgroup下的memory.limit_in_bytes和memory.usage_in_bytes这两个值。如下图所示,我们可以看到容器内存的上限值设置为104857600bytes(100MB),而这时整个容器的已使用内存显示为104767488bytes,这个值已经非常接近上限值了

我们把容器内存上限值和已使用的内存数值做个减法,104857600–104046592=876544 bytes,只差大概 856KB 左右的大小。但是,如果这时候我们继续启动一个程序,让这个程序申请并使用50MB的物理内存,就会发现这个程序还是可以运行成功,这时候容器并没有发生OOM的情况。这时我们再去查看参数memory.usage_in_bytes,就会发现它的值变成了104849408bytes。那这是怎么回事呢?

为什么memory.usage_in_bytes与memory.limit_in_bytes的值只相差了856KB,我们在容器中还是可以申请出50MB的物理内存?容器里肯定有大于50MB的内存是Page Cache,因为作为Page Cache的内存在系统需要新申请物理内存的时候(作为RSS)是可以被释放的。知道了这个答案,那么我们怎么来验证呢?验证的方法也挺简单的,在Memory Cgroup中有一个参数memory.stat,可以显示在当前控制组里各种内存类型的实际的开销。
那我们再跑一遍代码,这次要查看一下memory.stat里的数据。第一步,我们还是用同样的脚本来启动容器,并且设置好容器的Memory Cgroup里的memory.limit_in_bytes值为100MB。
启动容器后,这次我们不仅要看memory.usage_in_bytes的值,还要看一下memory.stat。虽然memory.stat里的参数有不少,但我们目前只需要关注 cache 和 rss 这两个值。

我们可以看到,容器启动后,cache,也就是Page Cache占的内存是100052992bytes,大概是95MB,而RSS占的内存只有1867776bytes,也就是1MB多一点。这就意味着,在这个容器的Memory Cgroup里大部分的内存都被用作了Page Cache,而这部分内存是可以被回收的。
那么我们再执行一下我们的mem_alloc程序,申请50MB的物理内存。我们可以再来查看一下memory.stat,这时候cache的内存值降到了47075328bytes,大概44MB,而rss的内存值到了54861824bytes,52MB左右吧。总的memory.usage_in_bytes值和之前相比,没有太多的变化。

从这里我们发现,Page Cache内存对我们判断容器实际内存使用率的影响,目前Page Cache完全就是Linux内核的一个自动的行为,只要读写磁盘文件,只要有空闲的内存,就会被用作Page Cache。所以,判断容器真实的内存使用量,我们不能用Memory Cgroup里的memory.usage_in_bytes,而需要用memory.stat里的rss值。这个很像我们用free命令查看节点的可用内存,不能看 free 字段下的值,而要看除去Page Cache之后的 available 字段下的值。
每个容器的 Memory Cgroup 在统计每个控制组的内存使用时包含了两部分,RSS和Page Cache。
RSS是每个进程实际占用的物理内存,它包括了进程的代码段内存,进程运行时需要的堆和栈的内存,这部分内存是进程运行所必须的。
Page Cache是进程在运行中读写磁盘文件后,作为Cache而继续保留在内存中的,它的目的是为了提高磁盘文件的读写性能。
当节点的内存紧张或者Memory Cgroup控制组的内存达到上限的时候,Linux会对内存做回收操作,这个时候Page Cache的内存页面会被释放,这样空出来的内存就可以分配给新的内存申请。正是Page Cache内存的这种Cache的特性,对于那些有频繁磁盘访问容器,我们往往会看到它的内存使用率一直接近容器内存的限制值(memory.limit_in_bytes)。但是这时候,我们并不需要担心它内存的不够, 我们在判断一个容器的内存使用状况的时候,可以把Page Cache这部分内存使用量忽略,而更多的考虑容器中RSS的内存使用量。
容器与虚拟机
容器是一种沙盒(沙箱)技术。沙盒将软件运行于一个受限的系统环境中,控制程序可使用的资源(如文件描述符、内存、磁盘空间等)。顾名思义,沙盒就是能够像一个集装箱一样,把你的应用装起来。这样应用与应用之间就有了边界而不会相互干扰。同时装在沙盒里面的应用,也可以很方便的被搬来搬去,这也是 PaaS 想要的最理想的状态(可移植性,标准化,隔离性)。
容器技术是虚拟化、云计算、大数据之后的一门新兴的并且是炙手可热的新技术, 容器技术提高了硬件资源利用率、 方便了企业的业务快速横向扩容(可以达到秒级快速扩容)、 实现了业务宕机自愈功能(配合K8S可以实现,但OpenStack无此功能),因此未来数年会是一个容器愈发流行的时代 ,这是一个对于 IT 行业来说非常有影响和价值的技术,而对于IT行业的从业者来说, 熟练掌握容器技术无疑是一个很有前景的行业工作机会。

虚拟化是指通过虚拟化技术将一台计算机虚拟为多台逻辑计算机。在一台计算机上同时运行多个逻辑计算机,每个逻辑计算机可运行不同的操作系统,并且应用程序都可以在相互独立的空间内运行而互不影响,从而显著提高计算机的工作效率。传统虚拟机是虚拟出一个主机硬件,并且运行一个完整的操作系统,然后在这个系统上安装和运行软件。使用虚拟机是为了更好的实现服务运行环境隔离,每个虚拟机都有独立的内核,虚拟化可以实现不同操作系统的虚拟机,但是通常一个虚拟机只运行一个服务,很明显资源利用率比较低且造成不必要的性能损耗,我们创建虚拟机的目的是为了运行应用程序,比如Nginx、PHP、Tomcat等web程序,使用虚拟机无疑带来了一些不必要的资源开销,但是容器技术则基于减少中间运行环节带来较大的性能提升。
容器没有内核,容器启动和运行过程中直接使用了宿主机的内核,通过宿主机内核调用物理硬件。而镜像本身则只提供相应的roots,即系统正常运行所必须的用户空间的文件系统,比如/dev、/proc、/bin、/etc等目录,所以容器当中基本是没有/boot目录的,而/boot当中保存的就是与内核相关的文件和目录。对于宿主机而言,每个容器就是一个单独的进程,数据存储在磁盘中,通过调用内核进行访问。
容器直接使用了宿主机的内核,而虚拟机运行的是一个完整的操作系统,独立的操作系统,独立的内核。所以容器隔离性没有虚拟机隔离性好。
根据实验,一个运行着CentOS的KVM虚拟机启动后,在不做优化的情况下,虚拟机自己就需要占用 100~200 MB内存(虚拟机一般会有5-20%的损耗)。此外,用户应用运行在虚拟机里面,它对宿主机操作系统的调用就不可避免地要经过虚拟化软件的拦截和处理,这本身又是一层性能损耗,尤其对计算资源、网络和磁盘I/O的损耗非常大。
比如: 一台96G内存的物理服务器,为了运行java程序的虚拟机一般需要分配8G内存/4核的资源,只能运行13台左右虚拟机,但是改为在docker容器上运行Java程序,每个容器只需要分配4G内存即可,同样的物理服务器就可以运行25个左右容器,运行数量相当于提高一倍,可以大幅节省IT支出,通常情况下至少可节约一半以上的物理设备

Docker 和虚拟机、物理主机

容器管理工具
当 Linux 具备了 chroot、Namespace 和 cgroups 这些机制后,其实就已经具备了实现容器的基础能力:
chroot:限制进程的根文件系统
Namespace:实现系统资源隔离(进程、网络、文件系统等)
cgroups:控制和限制资源使用(CPU、内存、IO 等)
不过,仅有这些底层能力还不足以直接使用容器。我们还需要解决很多实际问题,例如:如何创建和删除容器?如何启动和停止容器?容器的镜像如何管理?容器的数据如何存储与持久化?容器之间的网络如何通信?为了解决这些问题,于是出现了各种容器管理技术。早期比较典型的是 Linux Containers,它可以直接利用 Linux 内核提供的隔离和资源控制机制来运行容器。后来出现了更易用的容器平台 Docker,它在 LXC 的基础思想上进行了封装和改进,并提供了镜像管理、容器运行、网络和存储等完整工具链,因此迅速流行起来。需要注意的是,很多人一提到容器就想到 Docker,但实际上 Docker 只是一个容器管理工具,并不是容器本身。这就像提到操作系统时,我们会想到 Windows、Linux、macOS,但它们只是不同的实现方式。

常见容器技术工具:
工具 | 特点 |
LXC | 早期容器技术,功能较基础 |
Docker | 最流行的容器平台,生态完善 |
Pouch | 阿里开源,面向大规模数据中心 |
Podman | 无守护进程容器引擎,兼容 Docker |
LXC:LXC(Linux Containers) 是 Linux 上较早出现的一种容器技术,它基于 Linux 内核提供的 Namespace 和 cgroups 实现进程与资源隔离,从而提供一种轻量级虚拟化能力。LXC 同时提供了一系列用于管理容器的工具,例如:lxc-create创建容器、lxc-start启动容器、lxc-stop停止容器、lxc-attach进入容器。LXC 可以直接创建和运行容器,但它的生态和易用性相对较弱,例如镜像管理、分发、版本管理等能力不足。因此在实际生产环境中使用逐渐减少。
Docker:Docker 可以看作是 在 LXC 思想基础上的进一步封装与增强,它提供了一整套完整的容器解决方案,因此成为目前最主流的容器平台之一。Docker 的核心特点包括:
镜像机制:Docker 运行容器时需要使用镜像,镜像相当于一个包含应用程序及其运行环境的模板。镜像可以存储在公共仓库,如 Docker Hub,被下载到本地使用,同一个镜像可以启动多个容器实例。
镜像分层结构:Docker 镜像采用分层结构(Layer),底层通常是基础系统层(例如 Ubuntu、Alpine 等)。每一层都是只读层,不能直接修改,当镜像被启动为容器时,Docker 会在最上面创建一个可写层,所有新的修改都会写入这一层。这也是 Docker 能够节省磁盘空间,快速启动容器,复用镜像层的重要原因。
容器数据:容器运行时写入的数据默认存储在容器的可写层,实际存放在宿主机 Docker 管理的目录中。如果删除容器,默认情况下这个可写层的数据也会被删除,因此生产环境通常使用Volume来实现数据持久化。
Pouch:Pouch 是阿里巴巴开源的一款容器引擎,主要面向大规模数据中心场景。它的设计目标包括:启动速度快,资源占用低,高可移植性,适合超大规模集群。传统容器技术(包括 Docker)通常基于:Namespace + cgroups 实现隔离,但容器仍然共享宿主机内核,在某些安全场景下隔离能力有限。因此阿里在容器安全方面做了多方面改进,例如:用户态增强隔离能力:网络带宽限制、磁盘使用限制等;内核层改进:修复 cgroup 相关问题、提交内核补丁;基于 Hypervisor 的容器技术为容器提供独立内核,提升隔离性(类似轻量虚拟机)。
Podman:Podman(Pod Manager Tool)是一个开源容器管理工具,主要由 Red Hat 推动开发。它的特点包括:
无守护进程架构:与 Docker 不同,Podman 没有 daemon(守护进程)。因此 Podman 更安全,更轻量,更适合 rootless 容器
原生支持 Kubernetes Pod 概念:Podman 的设计理念与 Kubernetes Pod 非常接近,可以直接创建 Pod,一个 Pod 可以包含多个容器,这也是 Podman 名字的由来。
兼容 Docker CLI:Podman 与 Docker CLI 高度兼容,大部分命令都相同(约 80%+),可以直接替代 Docker 使用。例如:
alias docker=podman容器规范
随着容器技术的发展,不同厂商实现的容器平台逐渐增多。除了 Docker 之外,常见的容器技术还有 rkt(CoreOS 开发)以及 Pouch(阿里巴巴开源)。如果各个平台使用不同的标准,就会导致容器镜像和运行环境之间难以兼容,从而影响容器生态的发展。为了解决这一问题,Open Container Initiative(OCI,开放容器倡议)在 2015 年 6 月成立。该组织由 Linux 基金会主导,参与成员包括 Docker、微软、红帽、谷歌、IBM 等多家公司。
OCI 的主要目标是:制定开放的容器标准,保证不同容器平台之间的兼容性和可移植性。目前 OCI 主要发布了两大核心规范:
Runtime Spec(运行时规范):定义容器在运行时应该如何创建、启动和管理。
Image Format Spec(镜像格式规范):定义容器镜像的结构和存储格式。
OCI 就像是容器世界的标准制定者,它通过统一镜像格式和运行时规范,让不同厂商的容器技术能够相互兼容。只要不同厂商开发的容器平台遵循这两套规范,就可以保证:容器镜像具有可移植性(可以在不同平台运行),不同容器工具之间具备互操作性。
容器运行时
Runtime(容器运行时) 是真正负责创建和运行容器的组件。它需要与 Linux 内核(namespace、cgroups、filesystem 等机制)紧密配合,为容器提供隔离环境和资源控制。简单来说,Runtime 负责:创建容器进程、设置 namespace、配置 cgroups、挂载 rootfs、启动容器。容器运行时会根据这些规范,通过 JSON 配置文件描述容器运行环境。例如 Docker 在运行容器时会生成类似的状态文件:
这个文件会随着容器状态变化而实时更新。
常见的容器 Runtime:
runc:runc 是目前最主流的容器运行时。由 Docker 贡献给 OCI,完全遵循 OCI Runtime 规范。是 Docker 默认 runtime,也是 containerd、Kubernetes 等平台使用的核心 runtime。Docker 启动容器时,最终就是调用 runc 创建容器进程。
LXC:LXC 是 Linux 上较早出现的容器运行技术。基于 Linux namespace + cgroups 提供完整容器环境,Docker 早期版本曾经使用 LXC 作为底层 runtime,不过后来 Docker 改为使用 libcontainer / runc。
rkt:rkt(读作 rocket)是 CoreOS 开发的一种容器运行时。设计目标是更安全、更简洁。支持 OCI 规范,曾经作为 Docker 的替代方案,不过目前 rkt 项目已经停止维护,在实际环境中已经较少使用。
我们可以通过下面命令查看 Docker 支持的 runtime,可以看到 Docker 默认使用 runc 作为容器运行时
containerd
2016 年 12 月,Docker 公司宣布将 containerd 项目从 Docker Engine 中分离出来,形成一个独立的开源项目,并捐赠给 CNCF 基金会,旨在打造一个符合工业标准的容器运行时。Docker 公司之所以做出这样的决定,是因为当时在容器编排的市场上 Docker 面临着 Kubernetes 的极大挑战,将 containerd 分离,是为了方便开展 Docker Swarm 项目,不过结果大家都知道,Docker Swarm 在 Kubernetes 面前以惨败收场。
containerd 并不是直接面向最终用户的,而是主要用于集成到更上层的系统里,比如 Docker Swarm、Kubernetes 或 Mesos 等容器编排系统。containerd 通过 unix domain docket 暴露很低层的 gRPC API,上层系统可以通过这些 API 对机器上的容器整个生命周期进行管理,包括镜像的拉取、容器的启动和停止、以及底层存储和网络的管理等。下面是 containerd 官方提供的架构图:

从上图可以看出,containerd 的核心主要由一堆的<a href="https://github.com/containerd/containerd/tree/main/api/services"> Services </a>组成,通过 Content Store、Snapshotter 和 Runtime 三大技术底座,实现了 Containers、Content、Images、Leases、Namespaces 和 Snapshots 等的管理。其中 Runtime 部分和容器的关系最为紧密,可以看到 containerd 通过 containerd-shim 来支持多种不同的 OCI runtime,其中最为常用的 OCI runtime 就是 runc,所以只要是符合 OCI 标准的容器,都可以由 containerd 进行管理,值得一提的是 runc 也是由 Docker 开源的。
OCI 的全称为 Open Container Initiative,也就是开放容器标准,主要致力于创建一套开放的容器格式和运行时行业标准,目前包括了 Runtime、Image 和 Distribution 三大标准。
仔细观察 containerd 架构图的上面部分,可以看出 containerd 通过提供 gRPC API 来供上层应用调用,上层应用可以直接集成 containerd client 来访问它的接口,诸如 Docker Engine、BuildKit 以及 containerd 自带的命令行工具 ctr 都是这样实现的。从 Docker 1.11 版本开始,Docker 容器运行就不是简单通过 Docker Daemon 来启动了,而是通过集成 containerd、runc 等多个组件来完成的。虽然 Docker Daemon 守护进程模块在不停的重构,但是基本功能和定位没有太大的变化,一直都是 CS 架构,守护进程负责和 Docker Client 端交互,并管理 Docker 镜像和容器。现在的架构中组件 containerd 就会负责集群节点上容器的生命周期管理,并向上为 Docker Daemon 提供 gRPC 接口。整个流程大致如下:

不过创建容器有一点特别需要注意的地方,我们创建的容器进程需要一个父进程来做状态收集、维持 stdin 等工作的,这个父进程如果是 containerd 的话,那么如果 containerd 挂掉的话,整个机器上的所有容器都得退出了,为了解决这个问题,containerd 引入了 containerd-shim 组件。shim 的意思是垫片,正如它的名字所示,它其实是一个代理,充当着容器进程和 containerd 之间的桥梁。每当用户启动容器时,都会先启动一个 containerd-shim 进程,containerd-shim 然后调用 runc 来启动容器,之后 runc 会退出,而 containerd-shim 则会成为容器进程的父进程,负责收集容器进程的状态,上报给 containerd,并在容器中 PID 为 1 的进程退出后接管容器中的子进程进行清理,确保不会出现僵尸进程。
介绍完 containerd 与 Docker 之间的关系,我们再来看看它与 Kuberntes 的关系,从历史上看,Kuberntes 和 Docker 相爱相杀多年,一直是开源社区里热门的讨论话题。在 Kubernetes 早期的时候,由于 Docker 风头正盛,所以 Kubernetes 选择通过直接调用 Docker API 来管理容器:

后来随着容器技术的发展,出现了很多其他的容器运行时,为了让 Kubernetes 平台支持更多的容器运行时,而不仅仅是和 Docker 绑定,Google 于是联合 Red Hat 一起推出了<a href="https://kubernetes.io/docs/concepts/containers/cri/"> CRI </a>标准。CRI 的全称为 Container Runtime Interface,也就是容器运行时接口,它是 Kubernetes 定义的一组与容器运行时进行交互的接口,只要你实现了这套接口,就可以对接到 Kubernetes 平台上来。不过在那个时候,并没有多少容器运行时会直接去实现 CRI 接口,而是通过 shim 来适配不同的容器运行时,其中 dockershim 就是 Kubernetes 将 Docker 适配到 CRI 接口的一个实现:

很显然,这个链路太长了,好在 Docker 将 containerd 项目独立出来了,那么 Kubernetes 是否可以绕过 Docker 直接与 containerd 通信呢?答案当然是肯定的,从 containerd 1.0 开始,containerd 开发了 CRI-Containerd,可以直接与 containerd 通信,从而取代了 dockershim(<a href="https://kubernetes.io/zh-cn/blog/2022/05/03/dockershim-historical-context/">从 Kubernetes 1.24 开始</a>,dockershim 已经从 Kubernetes 的代码中删除了,cri-dockerd 目前交由社区维护):

到了 containerd 1.1 版本,containerd 又进一步将 CRI-Containerd 直接以插件的形式集成到了 containerd 主进程中,也就是说 containerd 已经原生支持 CRI 接口了,这使得调用链路更加简洁:

这也是目前 Kubernetes 默认的容器运行方案。不过,这条调用链路还可以继续优化下去,在 CNCF 中,还有另一个和 containerd 齐名的容器运行时项目 cri-o,它不仅支持 CRI 接口,而且创建容器的逻辑也更简单,通过 cri-o,kubelet 可以和 OCI 运行时直接对接,减少任何不必要的中间开销:

安装 containerd
从 containerd 的<a href="https://github.com/containerd/containerd/releases"> Release 页面 </a>下载最新版本:
然后将其解压到 /usr/local/bin 目录:
containerd 是服务端,我们可以直接运行:
ctr 是客户端,打开一个新的终端页面,运行 ctr version 确认 containerd 是否安装成功:

以 systemd 方式启动 containerd,官方已经为我们准备好了 <a href="https://raw.githubusercontent.com/containerd/containerd/main/containerd.service">containerd.service</a> 文件,我们只需要将其下载下来,放在 systemd 的配置目录下即可:
其中有两个配置很重要:
Delegate=yes 表示允许 containerd 管理自己创建容器的 cgroups,否则 systemd 会将进程移到自己的 cgroups 中,导致 containerd 无法正确获取容器的资源使用情况;默认情况下,systemd 在停止或重启服务时会在进程的 cgroup 中查找并杀死所有子进程。
KillMode=process 表示让 systemd 只杀死主进程,这样可以确保升级或重启 containerd 时不影响现有的容器。
然后我们使用 systemd 守护进程的方式启动 containerd 服务,这样当系统重启后,containerd 服务也会自动启动
配置文件
生成默认配置文件
为镜像配置一个加速器,那么就需要在 cri 配置块下面的 registry 配置块下面进行配置 registry.mirrors:
registry.mirrors."xxx":表需要配置 mirror 的镜像仓库,例如 registry.mirrors."docker.io" 表示配置 docker.io 的 mirror。
endpoint:表示提供 mirror 的镜像加速服务,比如我们可以注册一个阿里云的镜像服务来作为 docker.io 的 mirror。
另外在默认配置中还有两个关于存储的配置路径:
root 是用来保存持久化数据,包括 Snapshots,Content,Metadata 以及各种插件的数据,每一个插件都有自己单独的目录,Containerd 本身不存储任何数据,它的所有功能都来自于已加载的插件。
state 是用来保存运行时的临时数据的,包括 sockets、pid、挂载点、运行时状态以及不需要持久化的插件数据。
安装 runc
安装好 containerd 之后,我们就可以使用 ctr 执行一些基本操作了,比如使用 ctr image pull 下载镜像,注意这里和 docker pull 的不同,镜像名称需要写全称
不过这个时候,我们还不能运行镜像,我们不妨用 ctr run 命令运行一下试试:

这是因为 containerd 依赖 OCI runtime 来进行容器管理,containerd 默认的 OCI runtime 是 runc,我们还没有安装它。runc 的安装也非常简单,直接从其项目的 <a href="https://github.com/opencontainers/runc/releases"> Releases 页面 </a>下载最新版本:
将其安装到 /usr/local/sbin 目录即可:
使用 ctr container rm 删除刚刚运行失败的容器:
然后再使用 ctr run 重新运行:
可以看到此时容器正常启动了,不过目前这个容器还不具备网络能力,所以我们无法从外部访问它,打开一个新的终端,使用 ctr task exec 进入容器:
在容器内部验证 nginx 服务是否正常:

安装 CNI 插件
默认情况下 containerd 创建的容器只有 lo 网络,无法从容器外部访问,如果希望将容器内的网络端口暴露出来,我们还需要安装 <a href="https://github.com/containernetworking/plugins"> CNI 插件 </a>。和 CRI 一样, <a href="https://github.com/containernetworking/cni/"> CNI </a>也是一套规范接口,全称为 Container Network Interface,即容器网络接口,它提供了一种将容器网络插件化的解决方案。CNI 涉及两个基本概念:容器和网络,它的接口也是围绕着这两个基本概念进行设计的,主要有两个:ADD 负责将容器加入网络,DEL 负责将容器从网络中删除,有兴趣的同学可以阅读<a href="https://github.com/containernetworking/cni/blob/main/SPEC.md"> CNI Specification </a>了解更具体的信息。
官方提供了很多 CNI 接口的实现,比如 bridge、ipvlan、macvlan 等,这些都被称为 CNI 插件,此外,很多开源的容器网络项目,比如 calico、flannel、weave 等也实现了 CNI 插件。其实,CNI 插件就是一堆的可执行文件,我们可以从 <a href="https://github.com/containernetworking/plugins/releases"> CNI 插件的 Releases 页面 </a>下载最新版本:
然后将其解压到 /opt/cni/bin 目录,这是 CNI 插件的默认目录:
可以看到目录中包含了很多插件,这些插件按功能可以分成三大类:Main、IPAM 和 Meta:
Main:负责创建网络接口,支持 bridge、ipvlan、macvlan、ptp、host-device 和 vlan 等类型的网络;
IPAM:负责 IP 地址的分配,支持 dhcp、host-local 和 static 三种分配方式;
Meta:包含一些其他配置插件,比如 tuning 用于配置网络接口的 sysctl 参数,portmap 用于主机和容器之间的端口映射,bandwidth 用于限流等等。
CNI 插件是通过 JSON 格式的文件进行配置的,我们首先创建 CNI 插件的配置目录 /etc/cni/net.d:
然后在这个目录下新建一个配置文件:
其中
"name": "mynet" 表示网络的名称,"type": "bridge" 表示创建的是一个网桥网络,"bridge": "cni0" 表示创建网桥的名称,isGateway 表示为网桥分配 IP 地址,ipMasq 表示开启 IP Masquerade 功能,关于 bridge 插件的更多配置,可以参考<a href="https://www.cni.dev/plugins/current/main/bridge/"> bridge plugin 文档</a>。下面的 ipam 部分是 IP 地址分配的相关配置,"type": "host-local" 表示将使用 host-local 插件来分配 IP,这是一种简单的本地 IP 地址分配方式,它会从一个地址范围内来选择分配 IP,关于 host-local 插件的更多配置,可以参考<a href="https://www.cni.dev/plugins/current/ipam/host-local/"> host-local 文档</a>。除了网桥网络,我们再新建一个 loopback 网络的配置文件:
CNI 项目中内置了一些简单的 Shell 脚本用于测试 CNI 插件的功能
其中 exec-plugins.sh 脚本用于执行 CNI 插件,创建网络,并将某个容器加入该网络。exec-plugins.sh 脚本会遍历 /etc/cni/net.d/ 目录下的所有配置来创建网络接口,我们也可以使用 cnitool 来创建特定的网络接口
该脚本有三个参数,第一个参数为 add 或 del 表示将容器添加到网络或将容器从网络中删除,第二个参数 CONTAINER-ID 表示容器 ID,一般没什么要求,保证唯一即可,第三个参数 NETNS-PATH 表示这个容器进程的网络命名空间位置,一般位于
/proc/${PID}/ns/net,所以,想要将上面运行的 nginx 容器加入网络中,我们需要知道这个容器进程的 PID,这个可以通过 ctr task list 得到然后执行下面的命令
前面的 CNI_PATH=/opt/cni/bin 是必不可少的,告诉脚本从这里执行 CNI 插件,执行之后,我们可以在主机上执行 ip addr 进行确认,可以看出主机上多了一个名为 cni0 的网桥设备,这个就对应我们创建的网络,执行 ip route 也可以看到主机上多了一条到 cni0 的路由:

另外,我们还能看到一个 veth 设备,veth 是一种虚拟的以太网隧道,其实就是一根网线,网线一头插在主机的 cni0 网桥上,另一头则插在容器里。我们可以进到容器里面进一步确认:

容器除了 lo 网卡之外,多了一张 eth0 网卡,它的 IP 地址是 10.22.0.2,这个正是我们在 10-mynet.conf 配置文件中定义的范围。这时,我们就可以在主机上通过这个 IP 来访问容器内部了
通过将容器添加到指定网络,可以让容器具备和外界通信的能力,除了这种方式之外,我们也可以直接以主机网络模式启动容器
ctr 命令
使用 ctr 操作 containerd,ctr 是 containerd 部署包中内置的命令行工具,功能比较简单,一般用于 containerd 的调试
ctr image 命令
ctr container 命令
ctr task 命令
ctr run 命令
命名空间
containerd 通过命名空间进行资源隔离,当没有指定命名空间时,默认使用 default 命名空间,Docker 和 Kubernetes 都可以基于 containerd 来管理容器,Docker 使用的是 moby 命名空间,Kubernetes 使用的是 k8s.io 命名空间。所以假如我们有用 docker 启动容器,那么我们也可以通过
ctr -n moby container ls 来定位下面的容器,如果想查看 Kubernetes 运行的容器,可以通过 ctr -n k8s.io container list 查看。一些常用命令
虽然使用 ctr 可以进行大部分 containerd 的日常操作,但是这些操作偏底层,对用户很不友好,比如不支持镜像构建,网络配置非常繁琐,所以 ctr 一般是供开发人员测试 containerd 用的;如果希望找一款更简单的命令行工具,可以使用 nerdctl,它的操作和 Docker 非常类似,对 Docker 用户来说会感觉非常亲近,nerdctl 相对于 ctr 来说,有着以下几点区别:
nerdctl 支持使用 Dockerfile 构建镜像;
nerdctl 支持使用 docker-compose.yaml 定义和管理多个容器;
nerdctl 支持在容器内运行 systemd;
nerdctl 支持使用 CNI 插件来配置容器网络;
除了 ctr 和 nerdctl,我们还可以使用<a href="https://github.com/kubernetes-sigs/cri-tools/blob/master/docs/crictl.md"> crictl </a>来操作 containerd,crictl 是 Kubernetes 提供的 CRI 客户端工具,由于 containerd 实现了 CRI 接口,所以 crictl 也可以充当 containerd 的客户端。此外,官方还提供了一份教程可以让我们 实现自己的<a href="https://github.com/containerd/containerd/blob/main/docs/getting-started.md#implementing-your-own-containerd-client"> containerd 客户端</a>。
- 链接:www.qianshuai.cn/article/docker
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

容器基础





