在容器出现之前,通过 chroot 技术把用户的文件系统根目录切换到某个指定的目录下,从而实现简单的文件系统视图上的抽象或虚拟化,但并没有提供本质上的隔离技术,用户可以逃离设定的根目录而进入到host主机上的目录,缺乏安全性。
容器技术又称为容器虚拟化,是一种操作系统虚拟化,是属于轻量级的虚拟化技术。容器技术主要包括 Namespace和Cgroup 这两个内核特性:
1、Namespace:命名空间,主要做访问隔离。其原理是针对这类资源(如网络、进程ID等)进行抽象,并将其封装在一起提供给一个容器使用,对于这类资源,每个容器都有自己的抽象,而它们彼此之间是不可见的,所以就可以做到访问隔离。
2、Cgroup:控制组,主要做资源限制;原理是将一组进程放在一个控制组里,通过给这个控制组分配指定的可用资源,达到控制这组进程可用资源的目的。
容器= cgroup + namespace + rootfs +容器引擎(用户态工具),其中rootfs用来实现文件系统隔离,容器引擎用来实现对容器生命周期的控制;所以容器的创建原理可以概括为:
1、通过 clone 系统调用,并传入各个 Namespace 对应的 clone flag,创建一个新的子进程,该进程拥有自己的 Namespace,也就是拥有自己的 pid、mount、user、net、ipc、uts namespace;
2、将新创建的子进程的 pid 写入各个 Cgroup 子系统中,这样该进程就可以受到相应Cgroup子系统的控制;
3、通过 pivot_root系统调用,使进程进入一个新的rootfs之后通过 exec系统调用,在新的Namespace、Cgroup、rootfs 中执行"/bin/bash"程序。
2. CgroupCgroup(control group),属于Linux 内核提供的特性,用于限制和隔离一组进程对系统资源的使用,也就是做资源的QoS, 这些资源主要包括 CPU、内存、block I/O、和网络带宽。
2.1 Cgroup子系统- cpuset子系统
cpuset子系统为一组进程分配指定的 CPU 和内存节点,在 NUMA 架构的服务器上,通过将进程绑定到固定的 NUMA node上,来避免进程在运行时因跨node内存访问而导致的性能下降。
①、cpuset.cpus:允许进程使用的 CPU 列表(通过 lscpu查看当前服务器的NUMA节点)
②、cpuset.merns:允许进程使用的内存节点列表。
- cpu子系统
cpu 子系统用于限制进程的 CPU 占用率。
①、cpu.shares:CPU比重分配;当出现两个进程抢占CPU资源时,会根据各自的cpu.shares文件中配置的比例进行资源使用,比例越高会分配到越多的cpu使用时间。但是当没有出现CPU资源抢占时,该参数没有作用。
②、cpu.cfs_period_us和cpu.cfs_quota_us:表示在period时间周期内的CPU带宽限制(能够使用CPU的时间),单位是微妙,例如当period参数设置为1秒,将quota参数设置为 0.5 秒,那么Cgroup中的进程在1秒内最多只能运行 0.5 秒,然后就会被强制睡眠,直到进入下一个1秒才能继续运行。
③、cpu.rt_period_us和cpu.rt_runtime_ us:用法同上,用于限制实时进程。
- cpuacct子系统
cpuacct 子系统用来统计CPU使用情况。
①、cpuacct.stat:报告这个Cgroup分别在用户态和内核态消耗的 CPU 时间,单位是USER_HZ;一般 1USER_HZ等于0.01秒;
②、cpuacct.usage:报告所在的进程消耗的总CPU时间,单位是纳秒;
③、cpuacct. usage_percpu:报告在每个CPU 上消耗的 CPU 时间,总和也就是 cpuacct.usage 的值;
- memory子系统
memory子系统用来限制Cgroup所能使用的内存上限。
①、memory.limit_in_bytes:设定可使用的内存上限,单位是Byte,也可以使用k/K,m/M 或者 g/G表示要设置数值的单位。
默认情况下,如果 Cgroup 使用的内存超过上限,Linux内核会尝试回收内存,如果仍然无法将内存使用量控制在上限之内,系统将会触发OOM内存溢出告警,选择并"kill"掉该Cgroup的某个进程。
②、memory.oom_control:如果设置为 0, 那么在内存使用量超过上限时,系统不会"杀死"进程,而是阻塞进程直到有内存被释放可供使用时;另一方面,系统会向用户态发送事件通知,用户态的监控程序可以根据该事件来做相应的处理,例如提高内存上限等。
③、memory.stat:汇报内存使用情况。
- blkio子系统
blkio子系统用来限制Cgroup 的 block I/O 带宽。
①、blkio.weight:设置权重值,范围在100~1000 之间,和cpu_shares类似,也是只有当不同的 Cgroup 在争用同一个块设备的带宽时才会起作用。
②、blkio.weight_device:针对具体的设备设置权重值,这个值会覆盖上述的 blkio.weight。
echo "8:0 100" > blkio.weight device #将8:0设备的权重值设为100
③、blkio.throttle.read_bps_device:针对具体的设备设置每秒读磁盘的带宽上限。
echo "8:0 1048576" > blkio_throttle.read_bps_device
④、blkio.throttle.write_bps_device: 针对具体的设备设置每秒写磁盘的带宽上限。
⑤、blkio.throttle.read_iops_device: 针对具体的设备设置每秒读磁盘的 lOPS 上限。
⑥、blkio.throttle.write_iops_ device: 针对具体的设备设置每秒写磁盘的 IOPS 上限。
其中8:0表示主设备号:次设备号,可以通过 lsblk 命令来查看当前所有存储设备的主次设备号。
- devices子系统
devices 子系统用来控制 Cgroup 的进程对哪些设备有访问权限。
①、devices.list:只读文件,显示目前允许被访问的设备列表,每个条目都有3个域,如:
b *:* rwm
# 类型:a表示所有设备,b表示块设备,c表示字符设备;
# 设备号:"*:*"表示当前类型下的所有设备;
# 权限:r表示可读,w表示可写,m表示可创建设备节点(也就是在/dev/目录下创建设备文件)
查看当前系统所有的字符设备、块设备
cat /proc/devices
②、devices.allow:使用以上格式写入该文件,就可以允许相应的设备访问权限。
③、devices.deny:使用以上格式写入该文件,就可以禁止相应的设备访问权限。
2.2 Cgroup资源限制原理1、Cgroup的原生接口通过 cgroupfs 提供,是一种虚拟文件系统,当操作系统启动后会自动将cgroupfs文件系统挂载,挂载点是/sys/fs/cgroup/目录,在该目录下会产生所有的Cgroup子系统目录;
2、以cpuset为例,当使用docker运行容器时,会在containerd进程下生成一个子进程(每创建一个容器,就会生成一个子进程);并且会在Cgroup的cpuset子系统中、名为docker的文件夹中创建一个以容器ID命名的文件夹,里边保存了针对这个容器的、关于cpuset子系统资源限制文件(即不包含网络、磁盘I/O等资源限制)。
ls /sys/fs/cgroup/cpuset/docker/<CONTAINER_ID>/
cgroup.clone_children
cpuset.memory_pressure
cgroup.procs
cpuset.memory_spread_page
cpuset.cpu_exclusive
cpuset.memory_spread_slab
cpuset.cpus cpuset.mems
cpuset.effective_cpus
cpuset.sched_load_balance
cpuset.effective_mems
cpuset.sched_relax_domain_level
cpuset.mem_exclusive notify_on_release
cpuset.mem_hardwall tasks
cpuset.memory_migrate
#其中以cpuset开头的控制文件都由cpuset子系统产生的,其他文件则由 Cgroup 产生;
#tasts文件中记录这个容器的进程ID
可以通过直接修改文件来对某个容器进行资源限制;或者修改...docker/目录下的资源限制文件来限制docker进程的资源,也就实现了对其所有的子进程(容器)的资源限制。
3. NamespaceNamespace 是将内核的全局资源做封装,使得每个 Namespace 都有一份独立的资源,因此不同的进程在各自的 Namespace 内对同一种资源的使用不会互相干扰。
3.1 Namespace类型- UTS Namespace
用于对主机名和域名进行隔离,也就是 uname 系统调用使用的结构体struct utsname 里的 nodename和domainname 这两个字段来实现对主机名和域名进行隔离。
#进入到新的UTS的bash终端,修改主机名后退出,查看host主机名不变
[root@localhost ~]# unshare --uts bash
[root@localhost ~]# hostname container
[root@localhost ~]# hostname
container
[root@localhost ~]# exit
exit
[root@localhost ~]# hostname localhost
- IPC Namespace
IPC是 Inter-Process Communication的简写,也就是进程间通信;IPC Namespace可以实现当处于不同的命名空间中的两个进程,即使使用相同的标识符相互之间也无法通信。
在Linux系统中,同一个命名空间中的两个进程要想相互通信,就必须有相同的标识符;可以通过标识符来区分不同的消息队列。
#进入到新的IPC的bash终端,创建一个消息队列后退出,查看host上不存在这个消息队列
[root@localhost ~]# unshare --ipc bash
[root@localhost ~]# ipcmk -Q
Message queue id: 0
[root@localhost ~]# ipcs -q
------ Message Queues --------
key msqid owner perms used-bytes messages
0xe8f0027a 0 root 644 0 0
[root@localhost ~]# exit
exit
[root@localhost ~]# ipcs -q
------ Message Queues --------
key msqid owner perms used-bytes messages
- PID Namespace
用于隔离进程 PID 号,当创建一个 PID Namespace 时,第一个进程的 PID 号是1,也就是 init 进程,通过将进程放到不同的NS中实现PID进程号隔离。
#进入到新的PID的bash终端,然后查看进程,并操作(如kill)进程
[root@localhost ~]# unshare --pid -f bash
#由于进入终端后会创建一个init进程,当没有新的任务时会自动退出,所以必须添加 "-f" 参数:创建完成后还会给init进程创建几个子进程,用于持续任务发放,保证init进程不退出
#命令可以看到host中所有的进程信息,是因为ps命令是从procfs读取信息的, 而 procfs并没有得到隔离
[root@localhost ~]# ps -exf
...
[root@localhost ~]# kill 1045 #只能看到进程,但是删除不了
bash: kill: (1045) - No such process
- Mount Namespace
用来隔离文件系统挂载点,每个进程能看到的文件系统都记录在 proc/$$/mounts;在创建了一个新的 Mount Namespace 后,进程系统对文件系统挂载/卸载的动作就不会影响到其他 Namespace。
在创建PID ns时并没有指定挂载点,所以新的NS中依然可以看到procfs中的进程(即host中的进程);如果同时使用 Mount Namespace和PID Namespace,则新的 Namespace 里的进程和 host 上的进程将会看到各自的 procfs。
[root@localhost ~]# unshare --mount --pid -f bash
[root@localhost ~]# ps -exf
PID TTY TIME CMD
... #可以查看到host上所有的进程信息
[root@localhost ~]# mount -t proc none /proc #在当前NS下挂载procfs
[root@localhost ~]# ps -exf
PID TTY STAT TIME COMMAND #只能看到当前Namespace下的进程
1 pts/0 S 0:00 bash ...
22 pts/0 R+ 0:00 ps -exf ...
- Network Namespace
对网络相关的系统资源进行隔离,每个 Network Namespace 都有自己的网络设备、IP 地址、路由表、/proc/net 目录、端口号等;新创建的 Network Namespace 只会有一个down状态的 loopback 设备,除此之外不会有任何其他网络设备。
通常情况下使用 ip netns命令来管理Network Namespace,此处以unshare做演示。
[root@localhost ~]# unshare --net bash
[root@localhost ~]# ifconfig
[root@localhost ~]# ip link
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN
... link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
[root@localhost ~]# ifconfig lo up # 或者执行 ip link set lo up
[root@localhost ~]# ifconfig
lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536 inet 127.0.0.1 netmask 255.0.0.0
...
- User Namespace
用来隔离用户和组 lD ,也就是说一个进程在Namespace里的用户和ID与它在host里的ID可以是不一样的;host 的普通用户进程在容器里可以是ID为0的用户,也就是 root用户,进程在容器内就可以做各种特权操作,但是它的特权被限定在容器内,离开了这个容器它就只有普通用户的权限了
容器内root用户并不是所有的特权操作都被允许。
#创建一个User Namespace,并进入到终端中,查看用户ID
[user@localhost ~]$ unshare --user bash
[nobody@localhost ~]$ id
uid=65534(nobody) gid=65534(nobody) groups=65534(nobody) #查看当前User NS的进程ID
[nobody@localhost ~]$ echo $$
1800
#进入到host终端中(重新开一个shell终端),新建一个用户user,并将这个普通用户映射为User NS中的root用户
[root@localhost ~]# vim /etc/passwd
...
user:x:1000:1000:user:/home/user:/bin/bash #user用户的ID为1000
#要将[1000,65534]的UID在Namespace里映射成[O,65534],即user在NS里是root用户
[root@localhost ~]# echo "0 1000 65534" > /proc/1800/uid_map
#进入到Namespace终端可以看到当前用户ID为0
[nobody@localhost ~]$ id
uid=0(root) gid=65534(nobody) groups=65534(nobody)
3.2 Namespace的实现
对Narnespace的操作,主要是通过clone、setns、unshare这3个系统调用来完成的。
1、clone:用来创建新的 Namespace,接受一个叫 flags 的参数,这些flag包括CLONE_NEWNS(Mount ns)、CLONE_NEWIPC(IPC ns)、CLONE_NEWUTS(UTS ns)、CLONE_NEWNET(Net ns)、CLONE_NEWPID(PID ns) CLONE_NEWUSER(USR ns),通过传入这些 CLONE_NEW* 来创建新的Namespace;
2、unshare:为已有的进程创建新的 Namespace,当一个进程调用这个系统调用unshare时,这个进程就会被放进新创建的Namespace中,要创建的Namespace类型由flags指定;
man unshare #查看unshare命令的使用
3、setns:将进程放到已有的Namespace 里,每个进程在 procfs下都有一个目录,在那里面就有 Namespace 相关的信息,如果另一个进程要进入这个进程的 Namespace,可以通过open系统调用打开这里面的虚拟文件并得到一个文件描述符,然后把文件描述符传给 setns, 调用返回成功的话,就进入这个进程的 Namespace 了(docker exec命令就是这个原理)。