Docker:namespace环境隔离 & CGroup资源控制
- Docker
- 虚拟机
- 容器
- namespace
- 相关命令
- dd
- mkfs
- df
- mount
- unshare
- 进程隔离
- 文件隔离
- CGroup
- 相关命令
- pidstat
- stress
- cgroup控制
- 内存控制
- CPU控制
Docker
在开发中,经常会遇到环境问题,比如程序依赖某个库,库又要具体的版本,以及某些函数必须在指定平台使用,这就会为开发带来很大的麻烦。
为此,有人提出采用虚拟化技术,为软件虚拟出一个环境。就像是在一个冰天雪地的地方建了一个花房养花,花房内温度湿度都刚刚好,将花房与外部的冰雪隔离开。
这种实现环境隔离的技术,主要有虚拟机和容器两种,Docker
属于容器化的隔离技术。
虚拟机
所谓虚拟机,其实就是把一台物理主机虚拟为多台逻辑上的计算机。多台虚拟机共用物理上的同一台计算机,而每个逻辑上的计算机可以运行不同的操作系统,安装不同的库,从而提供不同的环境。
如图,上图红色部分与蓝色部分是两个不同的虚拟机,虚拟机技术在硬件层之上,在操作系统层就开始进行隔离。虚拟机通过伪造一个硬件的抽象接口,把操作系统嫁接到硬件上。
在硬件层与操作系统层之间,会存在一个虚拟化层,这其实就是一个软件,该层会负责分配硬件资源。
可以看出,如果想要创建多个虚拟机,就要在一台物理主机上跑多个操作系统,这其实要不小的开销,而容器是一种更加轻量的隔离技术。
容器
容器也是一种虚拟化的实现技术,它在操作系统之上进行环境隔离,每个容器可以有自己的一套工具和库,但是它们共享操作系统的内核!
如图,红色和蓝色区域,是两个不同的容器,它们的网络,文件系统等等都是隔离的,互不影响。
因为使用同样的操作系统内核,所以它们的系统调用接口自然就相同,但是基于相同的系统调用接口,配置了不同的库,不同的文件系统,那么最后两个容器就不同。
比如说上图,可以通过容积隔离技术在一个centOS
操作系统上,运行不同版本的Ubuntu
容器。这听起来很异想天开,但其实不然。
每个容器有自己独立的用户空间,这包括文件系统、库和用户级工具。用户操作接口是用户在容器内操作系统时接触到的命令行工具、库和应用。因此可以在上层的容器中,执行Ubuntu
的命令,虽然内核是CentOS
的。
相比于虚拟机技术,容器化技术非常轻量,容器相当于一个跑在操作系统上的进程,启动一个进程的速度是非常快的,通过容器技术,只需几秒钟就在主机上打开一个新的操作系统。
而容器化技术,目前最流行的实现方案就是Docker
。
那么容器化技术是如何实现各种资源的隔离的?对Linux
来说,它依赖于namespace
和CGroup
技术,这两个技术是由Linux
内核提供的。
namespace
:实现进程,文件系统,用户等资源隔离CGroup
:实现CPU,内存,网络等资源隔离
namespace
namespace
是 Linux
内核用来隔离内核资源的方式。通过 namespace
可以让进程只能看到与自己相关的一部分资源,不同namespace
的进程感觉不到对方的存在。
具体的实现方式是把一个或多个进程的相关资源指定在同一个 namespace
中。namespaces
是对全局系统资源的一种封装隔离,使得处于不同 namespace
的进程拥有独立的全局系统资源,改变一个namespace
中的系统资源只会影响当前namespace
里的进程,对其他namespace
中的进程没有影响。
常见namespace
:
namespace | 隔离资源 |
---|---|
UTS | 主机名和域名 |
IPC | 进程间通信:信号量、消息队列、共享内存 |
PID | 进程 |
NetWork | 网络设备,端口等 |
Mount | 文件系统 |
User | 用户 |
解释:
UTS
:每个UTS namespace
都可以有自己独立的主机和域名IPC
:每个IPC namespace
内部的进程可以进行进程间通信,但是不能跨越IPC namespace
进行通信,在逻辑上这算跨主机通信PID
:每个PID namespace
都有自己独立的进程pid
系统,不同的PID namespace
可以出现相同的pid
NetWork
:每个NetWork namespace
都有自己独立的网络设备,IP地址,路由表,端口号等Mount
:每个Mount namespace
有自己的文件系统,互相不能看到对方的文件User
:每个User namespace
都有自己独立的用户和用户组
相关命令
dd
dd
可以从指定输入流读取数据,并输出到指定输出流。
参数:
if=文件名
:从指定文件读取数据,如果不指定,则默认从标准输入读取of=文件名
:输出数据到指定文件,如果不指定,则默认输出到标准输出bs=xxx
:设定一个块block
的大小coun=blocks
:仅仅从输入流拷贝blocks
个块,结合上一个参数,可以指定要读取的数据个数
创建一个指定大小的空文件:
dd if=/dev/zero of=test.txt bs=1M count=80
以上指令,就是创建了一个80M
的空文件,输入流是/dev/zero
,这是一个会不断产生空白字符的文件,也就是ASCII
中字符编码为0
的字符。使用这个文件作为输入流,可以快速初始化一个空文件。
可以看到,最后创建了一个大小为83886080 byte
的文件,其实就是80 M
。
mkfs
mkfs
用于在设备上创建一个文件系统,俗称格式化。
mkfs [option] filesys [blocks]
选项:
-t
:要创建的文件系统类型,比如ext3
、ext4
其中filesys
是被格式化的文件,而block
是文件系统的磁盘块数,可以省略。
把刚才创建的文件进行格式化:
mkfs -t ext4 test.txt
这样就把刚才的空文件进行了格式化,变成了一个文件系统。
df
df
用于显示Linux
中的文件系统磁盘使用情况。
选项:
-h
:以更加可视化的形式输出,默认情况下数据以字节为单位,加上该选项后,会自动转化为GB
,MB
等单位-T
:显示文件系统的类型
查看当前的文件系统,可以看到,其不包含刚才格式化的test.txt
,因为他只是一个被格式化的文件,还没有被挂载。
mount
mount
用于挂载文件系统,相当于给文件系统一个访问入口。
比如说你在电脑上插入一个U盘,它往往会显示为E盘
或者其它盘符。这个盘符就是一个访问入口,因为U盘本身就是一个文件系统,如果想要访问这个文件系统的内容,Windows
自然要提供一个入口,因此它自动分配一个E盘
,让用户可以通过E盘
访问U盘。
同样的,刚才格式化test.txt
为一个文件系统,现在要将其挂载起来,才能访问这个文件系统。
mount [option] device dir
device
:被挂载的文件dir
:挂载到的位置
选项:
-t
:挂载文件的类型,比如ext3
、ext4
,但是可以不填,mount
会自动识别文件系统的类型
把刚才的test.txt
挂载到当前/mymount
目录下:
首先创建一个空目录/mymount
,随后把test.txt
挂载到这个目录下,随后可以看到,/mymount
出现了新的内容。
随后通过df -t ext4
查看系统的文件系统,可以看到/dev/loop0
文件系统,被挂载到了/mymount
下,说明成功挂载了一个文件系统。
如果想要删除这个文件系统,可以执行:
umount /mymount
以上所有命令,是在完成一个文件系统的创建,便于后续测试文件系统的隔离性。
接下来看看Linux
提供的创建namespace
的命令:
unshare
unshare
用于执行一个进程,并且为这个进程提供一个独立的namespace
。
unshare [option] program
program
:要执行的程序
选项:
-i --ipc
:不共享IPC
空间-m --mount
:不共享Mount
空间-n --net
:不共享Net
空间-p --pid
:不共享PID
空间-u --uts
:不共享UTS
空间-U --user
:不共享用户空间--fork
:创建一个子进程执行program
--mount-proc
:挂载一个新的/proc
到命名空间内
此处这个--fork
有一点点绕,因为ushare
这个命令,本身也是一个进程,是在宿主机环境运行的。
如果直接执行unshare
,流程如下:
unshare
创建一个新的命名空间unshare
执行program
,但是没有创建新的进程,而是直接用program
替换了unshare
本身
以上unshare
不带有--fork
参数,执行了/bin/bash
进程。进入namespace
后,可以看到,/bin/bash
的父进程是-bash
。这个-bash
就是宿主机的bash
进程。因为unshare
是在bash
中执行的,所以unshare
的父进程是-bash
。最后将/bin/bash
替换unshare
,/bin/bash
的父进程就是原先的父进程-bash
。
这种情况下,看不到unshare
进程,因为/bin/bash
就是原先的unshare
。
如果加上--fork
参数,流程如下:
unshare
创建一个新的命名空间unshare
在新的命名空间中创建一个新的子进程来执行program
。unshare
本身不退出
加上--fork
参数后,/bin/bash
的父进程就变成了unshare
,unshare
的父进程是-bash
。也就是说unshare
创建了一个子进程来执行program
,而不是亲自执行program
。
如果你跟着操作了,此时还处于namespace
中,要通过exit
来退出,不然会影响后续操作。
进程隔离
现在尝试进行进程隔离,也就是隔离,通过--pid
选项完成:
unshare --fork --pid --mount-proc /bin/bash
以上命令,用于创建一个新的PID
命名空间,并执行bash
进程,也就是执行一个新的命令行。
此处要加上--fork
选项,因为要进行进程隔离,而unshare
本身是宿主机的进程,如果直接让unshare
本身去执行program
,那么program
就不在新的namespace
中,导致错误。
以上示例中,因为没有加上--fork
,报错了。
如果只加上--fork
选项,此时还是不能观察到进程隔离。因为top
、ps
等进程监控的命令,是依赖于/proc/PID
这个目录的。但是命令没有进行文件系统隔离,所以还是会使用宿主机的/proc/PID
目录,导致namespace
内部可以看到外部的进程。
为此,unshare
命令专门提供了一个参数--mount-proc
,在新的namespace
挂载一个独立的/proc
目录,方便进行进程的监控。
进程隔离结果:
可以看到,创建了新的namespace
后,ps -aux
只能查到两个进程,一个是bash
,一个是grep
。这就将namespace
内部的进程与宿主机的进程隔离开了。
文件隔离
想要进行文件隔离,创建一个新的namespace
,然后在里面创建一个文件系统并挂载。再在外部的宿主机查看是否可以看到这个文件系统。
- 创建一个新的文件隔离命名空间
unshare --mount --fork /bin/bash
- 创建一个指定大小的空文件
dd if=/dev/zero of=data.img bs=1M count=80
- 格式化文件为文件系统
mkfs -t ext4 data.img
- 挂载文件系统
mkdir /mymount
mount -t ext4 data.img /mymount
最后通过df -t ext4
,可以看到文件系统已经挂载成功了。
打开一个新的终端,执行df -t ext4
:
此时左右终端看到的文件系统不同,左侧的namespace
内挂载的新文件系统,右侧终端看不到了,这就是文件隔离。
此时在namespace
中执行exit
,就会退出这个bash
,进而退出namespace
,在其内部创建的文件,挂载的文件系统都会自动销毁。
当exit
退出后,再次查看文件系统,也找不到刚才挂载的文件系统了。这和刚才两个终端的情况不同,之前是不同namespace
之间的文件隔离,而此处是退出namespace
后,文件系统已经被销毁了。
CGroup
cgroup
的可以把一系列任务,也就是进程划分到一个任务组,并且限制一个任务组的资源占用。比如可以限制一系列任务最多占用多少CPU
,占用多少内存等等。
也就是说,namespace
完成了不同容器之间环境的隔离,而cgroup
完成了每个容器资源的访问限制。
相关命令
为了方便测试CPU与内存压力,此处使用两个工具分别完成产生压力以及压力检测。
pidstat
pidstat
用于检测一个进程的CPU、内存、IO、线程等等资源的占用情况。
需要下载:
apt install sysstat
语法:
pidstat [option] [时间间隔] [次数]
选项:
-u
:检测CPU使用情况,默认就是该选项-r
:检测内存使用情况-d
:检测IO使用情况-p
:指定进程pid
,如果指定ALL
则监视所有进程-C
:检测通过指定命令启动的进程
直接执行pidstat
:
此时会输出所有进程,默认输出CPU占用情况,也就是%CPU
这一栏。
通过-p
指定进程:
通过-C
指定进程:
此处指定bash
进程。
通过-r
检测内存:
此处%MEM
栏就是内存占用情况。
指定检测次数与频率:
此处的1 3
表示:每隔一秒检测一次,一共检测三次。
stress
stress
是一个压力测试工具,可以对CPU、内存IO等进行压力测试。
这个工具也要下载:
apt install stress
语法:
stress [option]
参数:
-c --cpu N
:产生N个进程,每个进程都循环调用sqrt
函数产生CPU压力-m --vm N
:产生N个进程,每个进程都循环调用malloc free
函数,产生内存压力
示例:
左侧使用stress
创建了一个进程进行CPU压力输出,右侧检测stress
产生的压力,结果一个进程占满了100%
的CPU资源。
cgroup控制
接下来看看如何操作cgroup
,它并没有现成的指令来控制,而是需要操控配置文件来完成。
/proc/cgroups
查看cgroup
支持的资源控制:
在/proc/cgroups
文件中,包含了cgroup
所支持的资源控制的类型,比如CPU、内存等。
查看cgroup
挂载信息:
mount | grep cgroup
这里就是每一个资源的控制目录,比如在/sys/fs/cgroup/cpu
目录下,就是控制CPU资源的配置文件。
内存控制
创建一个内存的控制组很简单,跳转到目录/sys/fs/cgroup/memory
,然后创建一个目录:
此处创建了一个test_memory
目录,这就算创建了一个test_memory
内存控制组。进入目录后,可以看到这个目录被初始化了很多文件,其中memory.limit_in_bytes
这个文件,就是这个cgroup
可以使用的最大内存数目,以字节为单位。
想要限制这个cgroup
的最大内存数量,直接往文件写入数据即可:
echo "20971520" > memory.limit_in_bytes
此处20971520
其实就是20 mb
,此后这个cgroup
的最大内存占用就不会超过20 mb
。
那么要如何把一个进程加入控制组?这里有一个task
文件,只需要把进程的PID
写入到这个文件中,那么一个进程就算加入了这个cgroup
。
如图,创建一个进程,占用50m
的内存:
stress -m 1 --vm-bytes 50m
随后在另一个端口通过pidstat
查看stress
的PID
,为15070
和15069
,其中15069
是控制进程,15070
是真正在产生内存压力的进程。将15070
写入tasks
文件中,让其加入cgroup
。
结果左侧的stress
退出了,无法产生50m
的压力。这是因为一开始就限制了cgroup
只能占用最多20 m
的内存。一旦进程加入后,就会受到限制,从而崩溃。
CPU控制
创建一个CPU的控制组也一样,跳转到目录/sys/fs/cgroup/cpu
,然后创建一个目录:
此处创建了一个test_cpu
目录,也就死和创建了一个test_cpu
CPU控制组。这个目录同样被初始化了很多文件。
其中控制CPU占用的是cpu.cfs_period_us
和cpu.cfs_quota_us
,这两个文件共同完成CPU资源限制。其中cpu.cfs_period_us
作为分母,cpu.cfs_quota_us
作为分子,以百分比的形式限制CPU。
比如cpu.cfs_period_us
内填入5000
,cpu.cfs_quota_us
内填入2000
。那么该cgroup
最多可以占用2000/5000
也就是40%
的CPU资源。要注意的是,这两个文件的最小值都是1000
,不允许使用比1000
更小的数字进行配置。另外的cpu.cfs_quota_us
的默认值为-1
,表示可以占用100%
的CPU。
另一个就是tasks
文件,同样的只要把PID写入这个文件,就算加入了这个CPU控制组。
启动一个stress
进程:
由于stress
本身就会尽可能占满CPU,右侧输出窗口每隔一秒输出stress
的状态,其一直保持100%
的CPU资源占用。
左下角窗口先限制了test_cpu
这个控制组的CPU
最大占用率是2000/10000
,也就是20%
。
随后把stress
的PID20849
写入tasks
:
写入后,从右边的窗口可以看出,stress
的CPU占用率立马下降,100%
到37%
最后稳定在20%
。
可以cgroup
成功对进程的CPU进行了限制。
容器化技术在Linux
中基于namespace
和cgroup
实现,namespace
完成不同容器之间的环境隔离,而cgroup
完成多个容器对资源的占用限制,合理分配资源。这就是容器化技术,以及docker
的最基本原理,也是底层依赖。