author:魏静崎
2025年8月12日
Linux 容器 技术
容器 = cgroup + namespace + rootfs + 容器引擎****(用户态的工具)
其中各项的功能如下:
- CGroup: 资源控制
- Namespace: 资源隔离
- rootfs:文件系统隔离
- 容器引擎:生命周期管理
进程与隔离
进程和程序的区别是什么呢?这里给出一种回答:
- 程序:就是计算机磁盘上的可执行的二进制文件;
- 进程:程序运行起来后的计算机执行环境的总和,包括计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息,它是程序的动态表现。
一般来说,进程是操作系统资源分配的基本单位,每个进程拥有独立的内存空间和资源。它是一个运行中的程序实例,负责完成特定任务。进程之间相互独立,切换时需要较大的系统开销。
结合一下就是,进程是操作系统资源分配的基本单位,每个进程拥有独立的内存空间和资源。程序运行起来后的计算机执行环境的总和,包括计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息,它是程序的动态表现。进程之间相互独立,切换时需要较大的系统开销。
容器技术的核心功能,就是通过约束和修改进程的动态表现,为其创造出一个『边界』。
对于Linux容器来说,Cgroups技术是用来制造约束的主要手段,而Namespace技术是用来修改进程视图的主要方法。
对被隔离的进程空间做了手脚,使得进程只能看到重新计算过的进程编号,比如PID=1,可实际上,/bin/sh在宿主机操作系统里,还是原来的1000号进程。这种技术,就是Linux里面的Namespace机制,而Namespace的使用方式也比较有趣:它只是Linux创建新进程的一个可选参数。我们知道Linux创建进程的系统调用是clone()
而当我们用clone()系统调用创建一个新进程时,就可以在参数中指定CLONE_NEWPID 标记,比如
1 | int pid = clone(main_function, stack_size, SIGCHLD| CLONE_NEWPID, NULL); |
这时,新创建的进程将会看到一个全新的进程空间,在这个空间中,它的PID就是1. 这就是Linux的PID namespace,用于对进程的PID进行隔离。
容器的隔离技术实现原理,本质上是通过给clone()系统调用传递类似CLONE_NEWPID的flags。
所以说,容器其实就是一组特殊的进程而已。
进程与限制
容器技术相比虚拟机技术,它少了一个hypervisor层,创建容器时,也不会创建任何实体的『容器』,真正对隔离环境负责的是宿主机操作系统本身。这意味着,运行在容器里的应用进程,跟宿主机上的其他进程一样,都是由宿主机操作系统统一管理,只不过这些被隔离的进程拥有额外设置的Namespace参数。
容器技术由于少了一层hypervisor,这意味着这些因为虚拟化带来的性能损耗都是不存在的,而另外一方面,使用Namespace作为隔离手段的容器不需要guest os,这就使得容器额外占用的资源几乎可以忽略不计。所以,『高性能』和『敏捷』是容器相比虚拟机最大的优势。
不过,有利就有弊,基于Linux Namespace的隔离机制相比较与虚拟化技术也有很多不足之处,其中最大的问题就是隔离的不彻底。
- 首先,容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机操作系统内核。
- 其次,在linux内核中,有很多资源和对象是不能被Namespace化的,比如:时间。
Linux cgroups的全称是Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括CPU、内存、磁盘、网络带宽等等。
除了cpu子系统(可以限制CPU每100ms的可被占用时长)外,cgroups的每一项子系统都有其独特的资源限制能力,比如
- blkio:为块设备设定IO限制,一般用于磁盘等设备;
- cpuset:为进程分配单独的cpu和对应的内存节点;
- memory:为进程设定使用内存使用的限制。
Linux cgroups的设计还是比较方便的,简单的理解,它就是一个子系统目录加上一组资源限制文件的组合。而对于docker来说,只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的PID填写到对应的控制组的cgroup.procs文件中就可以了。
而至于在这些控制组下面的资源文件里填写上什么值,就靠用户执行docker run时的参数指定了。
容器就是一组特殊的进程,其进程受到了隔离和限制,隔离使用的是Linux Namespace技术,限制使用的是Linux Cgroups技术。
CPU Cgroup
在centos7 系统上,CPU Cgroup这个子系统一般和cpuacct cgroup子系统(后面会专门分析该子系统)一起挂载在同一个目录下,这个目录是/sys/fs/cgroup/cpu,cpuacct/,我们可以查看该目录下的文件
在这里我们主要关心以cpu开头的文件(以cpuacct开头的文件,在后续的cpuacct cgroup子系统中会介绍)。
- cpu.cfs_period_us
- cpu.cfs_quota_us
- cpu.shares
- cpu.stat
cpu.cfs_period_us && cpu.cfs_quota_us
使用CPU Cgroup的限制能力时,一般这两个文件需要一起设置才有效果。cpu.cfs_period_us用来配置时间周期长度,默认值为100000,cpu.cfs_quota_us用来配置当前cgroup在设置的周期长度内所能使用的CPU时间,这两个文件配合起来设置CPU的使用上限。
这两个文件的单位都是微秒(us)。
- cpu.cfs_period_us的取值范围时1毫秒(1ms)到1秒(1s),默认值为100ms;
- cpu.cfs_quota_us的取值大于1ms即可,如果cpu.cfs_quota_us的值为-1(默认值),表示不受CPU时间的限制。
cpu.shares
cpu.shares用来设置CPU的相对值,并且是针对所有的CPU,默认值是1024,假如系统中有两个cgroup,分别是A和B,A的shares值是1024,B的shares值是512,那么A将获得1024/(1024+512)=66%的CPU资源,而B将获得33%的CPU资源。shares有两个特点:
- 如果A不忙,没有使用到66%的CPU时间,那么剩余的CPU时间将会被系统分配给B,即B的CPU使用率可以超过33%
- 如果添加了一个新的cgroup C,且它的shares值是1024,那么A的限额变成了1024/(1024+512+1024)=40%,B的变成了20%
从上面两个特点可以看出:
- 在闲的时候,shares基本上不起作用,只有在CPU忙的时候起作用,这是一个优点。
- 由于shares是一个相对值,需要和其它cgroup的值进行比较才能得到自己的相对限额,而在一个部署很多容器的机器上,cgroup的数量是变化的,所以这个限额也是变化的,自己设置了一个高的值,但别人可能设置了一个更高的值,所以这个功能没法精确的控制CPU使用率。
cpu.stat
cpu.stat统计了容器使用CPU的情况,它包含了下面三项统计结果
- nr_periods: 表示过去了多少个cpu.cfs_period_us里面配置的时间周期
- nr_throttled: 在上面的这些周期中,有多少次是受到了限制(即cgroup中的进程在指定的时间周期中用光了它的配额)
- throttled_time: cgroup中的进程被限制使用CPU持续了多长时间(纳秒)
Memory Cgroup
在centos7 系统上,memory cgroup这个子系统挂载的目录是/sys/fs/cgroup/memory/,我们可以查看该目录下的文件。
memory cgroup子系统中接口文件比较多,感觉很复杂,不过没有关系,我们可以简单将其分为3类:限制类型的接口文件、统计类型的接口文件和其他接口文件:
限制类型的接口文件
memory.limit_in_bytes: 设置和显示容器中进程可以使用的内存的上限
memory.memsw.limit_in_bytes: 设置和显示容器中进程可以使用的内存和可以使用的交换分区的之和的上限
memory.swappiness:设置和显示当前的swappiness
统计类型的接口文件
memory.usage_in_bytes: 当前时刻,容器使用的内存的大小
memory.max_usage_in_bytes: 容器历史上使用的内存的峰值大小
memory.memsw.usage_in_bytes: 当前时刻,容器使用的内存和交换分区之和的大小
memory.memsw.max_usage_in_bytes:容器历史上使用内存和交换分区之和的峰值大小
memory.failcnt :表示容器内存使用超过memory.limit_in_bytes而导致的内存分配失败次数
memory.memsw.failcnt:表示容器内存使用超过memory.memsw.limit_in_bytes而导致的内存分配失败次数
memory.stat :当前cgroup的内存使用情况的统计信息
memory.numa_stat:当前cgroup的内存使用情况的numa统计信息
其它类型
memory.move_charge_at_immigrate
memory.oom_control
memory.kmem.*
memory.force_empty
memory.use_hierarchy
接口文件详细介绍
在这里,我们详细介绍一下上面加粗的文件的含义和其用法。
memory.limit_in_bytes
用于设置容器中进程可以使用的内存的上限大小,单位为字节。
memory.memsw.limit_in_bytes
用于设置容器中进程可以使用的内存和交换分区之和的上限大小,单位为字节。
memory.swappiness
该文件和默认的全局的swappiness(/proc/sys/vm/swappiness)一样,修改该文件只对当前cgroup生效,swappiness用于配置需要将内存中不常用的数据移到swap中去的紧迫程度。这个参数的取值范围是0~100,_0告诉内核尽可能的不要将内存数据移到swap中,也即只有在迫不得已的情况下才这么做,而100告诉内核只要有可能,尽量的将内存中不常访问的数据移到swap中_。
注意:有一点和全局的swappiness不同,那就是如果这个文件被设置成0,就算系统配置的有交换空间,当前cgroup也不会使用交换空间。
memory.usage_in_bytes
用于显示当前容器使用的内存大小,单位为字节。
memory.memsw.usage_in_bytes
用于显示当前容器使用的内存和交换分区大小之和,单位为字节。
memory.failcnt
统计容器内存使用超过memory.limit_in_bytes而导致的内存分配失败的次数,通过该数据的增长趋势和情况,我们可以判断该容器内存是否充足。
如果该值单调递增,说明容器内存资源紧张,一直有内存分配失败的情况
如果该值没有变化,说明容器内存资源充足,一直没有内存分配失败的情况
memory.memsw.failcnt
统计容器内存使用超过memory.memsw.limit_in_bytes而导致的内存分配失败的次数,通过该数据的增长趋势和情况,我们可以判断该容器内存是否充足。
如果该值单调递增,说明容器内存资源非常紧张,即使使用了swap空间,也很难满足其对内存的需求。
如果该值没有变化,而memory.failcnt单调递增,说明容器在使用了swap空间后,没有出现内存分配失败的情况
如果该值没有变化,且memory.failcnt也没有变化,说明容器内存资源充足,一直没有内存分配失败的情况
memory.stat
该文件统计的项目比较细,这里就不介绍了。有兴趣的话可以参考:内核文档 5.2 stat file 一节
memory.numa_stat
该文件统计了每一个numa node的内存使用情况。我们以一个具有2个numa node机器为例,说明一下该文件中内容的含义:
1 |
|
第一行的值分别表示:该cgroup使用的总的内存页的个数(total)、在node0上的页的个数(N0)、在node1上的页的个数(N1)
第二行的值分别表示:该cgroup使用的总的文件页的个数(file),在node0上的文件页的个数(N0)、在node1上的文件页的个数(N1)
第三行的值分别表示:该cgroup使用的总的匿名页的个数(anon),在node0上的匿名页的个数(N0)、在node1上的匿名页的个数(N1)
第四行的值分别表示:该cgroup中不可回收的页的个数(unevictable),在node0上的不可回收页的个数(N0)、在node1上的不可回收页的个数(N1)
由于HULK机器上核心业务的cpu都是进行NUMA绑定的(要么绑定到node0上,要么绑定到node1)上,所以最好的情况下,该文件中的N0或者N1只有一个有数据,另外一个值为0。否则的话,就会出现跨node的内存访问,影响效率。
我们在排查性能问题时,可以通过查看该文件和查询hulk机器的cpu绑定情况,来判断hulk机器是否存在跨node的内存访问。
NUMA(Non-Uniform Memory Access, 非一致性内存访问):在 NUMA 架构下,内存的访问出现了本地和远程的区别:访问远程内存的延时会明显高于访问本地内存。
Pids Cgroup
pids cgroup这个子系统,该子系统用于限制一个容器中可以运行的进程数。
思考一个异常的情况:
一个宿主机上运行着许多个容器,这些容器共同竞争着宿主机计算机资源,如果某个容器的业务程序有bug,不停的产生子进程,会出现什么问题呢?
答案是:这个有问题容器中的业务程序产生的大量子进程会消耗完宿主机的计算资源(包括cpu和内存),从而影响该宿主机上其它的容器,这显然不是我们期望的。
为了避免类似的问题,pids cgroup就诞生了,该子系统主要用于限制一个容器中可以运行的进程数。
在centos7 系统上,pids cgroup这个子系统挂载的目录是/sys/fs/cgroup/pids/,我们可以查看该目录下的文件
注意:pids cgroup子系统在原生的centos 7内核上是没有的,是由美团内核团队backport到自研内核中的。
该子系统是所有cgroup系统中最简单的,其核心文件主要有以下三个:
pids.max: 设置容器中可以运行的进程数的上限,当容器中进程数超过该值时,就会fork失败,即无法创建出子进程,默认值为:max,表示不限制进程数;
pids.current:显示容器当前有多少个进程;
pids.events:统计由于进程尝试突破pids.max而导致的fork失败的次数。
Cpuset Cgroup
cpuset cgroup这个子系统,该子系统主要用来用于限制一组进程只运行在特定的cpu节点上和只在特定的mem节点上分配内存。
在centos7 系统上,cpuset cgroup这个子系统挂载在目录是/sys/fs/cgroup/cpuset/,我们可以查看该目录下的文件
每个NUMA node都有其对应的CPU(共计12个core)和内存资源。我们假设NUMA node0上的cpu编号为cpu0到cpu11,NUMA node1上的cpu编号为cpu12到cpu23。
我们需要考虑如下事实:
在cpu0-cpu11上运行的程序访问NUMA node0对应的内存非常快,而访问NUMA node1对应的内存则会稍慢
在cpu12-cpu23上运行的程序访问NUMA node1对应的内存非常快,而访问NUMA node0对应的内存则会稍慢
所以,对于特别敏感追求极限性能的服务,可以考虑避免程序进行跨NODE的内存访问。
cpuset就是为了实现这个目的而存在的,它可以限制一组进程只运行在特定的cpu节点和特定的内存节点上。配置也比较简单,只需要配置cpuset.cpus和cpuset.mems这两个文件就可以了。
cpuset.cpus && cpuset.mems 限制接口
cpuset.cpus:限制可以使用的cpu节点
cpuset.mems:限制可以使用的memory节点
对于大多数普通服务来说,是无需关心这些设置的,内核调度策略会优先就近使用本node的资源;对于特别敏感并且追求极致稳定性和性能的服务,可以考虑规划好numa node并进行绑定,此时不管我们的服务器是4个NUMA node还是2个NUMA node,设置方法都是一样的,即要求设置的cpuset.cpus和cpuset.mems处于同一个NUMA node上。
Blkio Cgroup
该子系统主要用来用于限制一组进程对io资源的使用。
IO相关概念
一般IO:一个正常的文件io,需要经过vfs -> buffer\page cache -> 文件系统 -> 通用块设备层 -> IO调度层 -> 块设备驱动 -> 硬件设备这所有几个层次;
direct io: 其特点是,VFS之后跳过buffer\page cache层,直接从文件系统层进行操作。那么就意味着,无论读还是写,都不会进行cache。
sync io & write-through: 中文叫做同步IO操作,如果是写操作的话也叫write-through,这个操作往往容易跟上面的direct io搞混,因为看起来他们速度上差不多,但是是有本质区别的。 这种方式写的数据要等待存储写入返回才能成功返回,所以跟DIO效率差不多,但是,写的数据仍然是要在cache中写入的,这样仍然可以使用cache机制加速IO操作。
write-back: 将目前在cache中还没写回存储的脏数据写回到存储。
当前的blkio cgroup限制所能起作用的环境为:Sync IO和Direct IO。由于应用程序写的数据是不经过缓存层的,所以能直接感受到速度被限制,一定要等到整个数据按限制好的速度写完或者读完,才能返回。
但是现实场景中,绝大部分IO是一般IO****。
在centos7系统上,blkio cgroup这个子系统挂载在/sys/fs/cgroup/blkio/,我们可以查看该目录下的文件
这些限制IO接口的文件可以分为以下两类:
blkio 权重
blkio.weight
blkio.weight_device
限制bps和iops
blkio.throttle.read_bps_device
blkio.throttle.read_iops_device
blkio.throttle.write_bps_device
blkio.throttle.write_iops_device
Cpuacct Cgroup
用于统计Cgroup中CPU使用情况的****子系统。
在CentOS7 系统上,cpuacct和CPU子系统一起挂载在/sys/fs/cgroup/cpu,cpuacct/目录下。我们可以查看该目录下的文件:
cpuacct.stat / cpuacct.statx
cpuacct.stat 会统计在当前Cgroup中,所有的任务消耗在用户态(user)和 系统态(system)的CPU时间。时间的单位是通过内核中的宏 USER_HZ 来定义的,固定为100。
cpuacct.usage / cpuacct.usage_percpu
cpuacct.usage 是该 Cgroup 中(包含子Cgroup)的所有进程,消耗的 cpu 时间,单位是纳秒。
cpuacct.usage_percpu 则是统计了Cgroup中所有的进程,在每个 cpu 使用的时间,时间也是纳秒。
Capabilities机制
capabilities其实是linux kernel的一种机制。为了进行权限检查,Linux对进程实现了两种不同的归类:
高权限进程(用户ID为0,超级用户或者root)
以及低权限进程(UID不为0的)。
高权限进程完全避免了各种权限检查,而低权限进程则要接受所有权限检查,会被检查如UID、GID和组清单是否有效。
Linux 从2.2版本开始,把原来和超级用户相关的高级权限划分成为不同的单元,称为Capability,这样就可以独立对特定的Capability进行使能或禁止。几句话总结就是:
将root权限划分成不同的单元,可以单独赋予进程某些只有root用户才有的权限。
在实际进行特权操作时,如果euid不是root,便会检查是否具有该特权操作所对应的capabilities,并以此为依据,决定是否可以执行特权操作。
进程/线程Capabilities和文件Capabilities
在实际使用中,我们可以将Capabilities简单分为两类:
进程、线程Capabilities:一个进程/线程的Capabilities,在进行特权操作时,可以根据其决定是否有权限进行操作。
文件Capabilities:如果文件系统支持文件附加属性,可以设置可执行文件的属性,使其具备一定的capabilities,当执行该文件时,相应的capabilities会自动添加到进程中。
当然,有相应的API和命令行工具来设置和查看进程或者文件的capabilities。
Seccomp特性
Seccomp简介
Seccomp是Secure computing mode的缩写,Linux kernel 所支持的一种安全机制,用于限制系统调用的使用范围****。当然,需要有一个配置文件来指明进程是否有权使用当前系统调用(system call)。
如果在容器中使用该特性,可以限制容器内进程能使用的系统调用范围。
为什么seccomp会安全呢?
在Linux系统里,大量的系统调用直接暴露给用户态程序。但是,并不是所有的系统调用都被需要,而且不安全的代码滥用系统调用会对系统造成安全威胁****。通过Seccomp特性限制系统调用的使用范围,使程序进入一种『安全』的状态。当进程使用系统调用时,kernel都会检查对应白名单以确认该进程是否有权使用当前系统调用。如果黑客获得系统的访问权限,seccomp将阻止他们使用任何未允许的系统调用。
Seccomp背后的原理
我们会先介绍Seccomp背后的原理,然后演示其在Docker中的用法。
通过Seccomp特性使指定的进程进入一种『安全』的运行模式,该模式下的进程默认只能使用四种系统调用,即 read、write、 exit和 sigreturn。如果想使用其他系统调用,可以将这些系统调用加到白名单中。如果使用尚未加入白名单的系统调用,那么调用者将会收到错误信号。
- 本文作者: 魏静崎
- 本文链接: https://slightwjq.github.io/2025/08/20/Linux 容器 技术/
- 版权声明: 该文章来源及最终解释权归作者所有