Docker容器技术剖析(下)
作者 | 乔克
来源 |运维开发故事(ID:mygsdcsf)
转载请联系授权(微信ID:wanger5354)
MNT Namespace
虽然我们已经通过 Linux 的命名空间解决了进程和网络隔离的问题,在 Docker 进程中我们已经没有办法访问宿主机器上的其他进程并且限制了网络的访问,但是 Docker 容器中的进程仍然能够访问或者修改宿主机器上的其他目录,这是我们不希望看到的。
在新的进程中创建隔离的挂载点命名空间需要在 clone 函数中传入 CLONE_NEWNS,这样子进程就能得到父进程挂载点的拷贝,如果不传入这个参数子进程对文件系统的读写都会同步回父进程以及整个主机的文件系统。
如果一个容器需要启动,那么它一定需要提供一个根文件系统(rootfs),容器需要使用这个文件系统来创建一个新的进程,所有二进制的执行都必须在这个根文件系统中。
想要正常启动一个容器就需要在 rootfs 中挂载以上的几个特定的目录,除了上述的几个目录需要挂载之外我们还需要建立一些符号链接保证系统 IO 不会出现问题。
为了保证当前的容器进程没有办法访问宿主机器上其他目录,我们在这里还需要通过 libcotainer 提供的 pivor_root 或者 chroot 函数改变进程能够访问个文件目录的根节点。
到这里我们就将容器需要的目录挂载到了容器中,同时也禁止当前的容器进程访问宿主机器上的其他目录,保证了不同文件系统的隔离。
总之,mnt namespace允许不同的namespace看到的文件结构不同,这样每个namespace中的进程所看到的文件目录就被隔离开了。
IPC Namespace
IPC Namespace主要实现进程间通信隔离。
进程间通信涉及的IPC资源包括常见的信号量、消息队列和共享内存。申请IPC就是申请一个全局的32位ID,所以IPC Namespace中实际上包含了系统IPC标识符和实现消息队列的文件系统。
容器的进程间交互依然是采用Linux常见的交互方法,所以每个容器就需要独立的IPC标识符,所以在容器创建的时候就要传入CLONE_NEWIPC 参数,实现IPC资源隔离。
UTS Namespace
UTS(UNIX Time-sharing System)namespace提供了主机名与域名的隔离,这样每个docke容器就可以拥有独立的主机名和域名了,在网络上可以被视为一个独立的节点,而非宿主机上的一个进程。docker中,每个镜像基本都以自身所提供的服务名称来命名镜像的hostname,且不会对宿主机产生任何影响,其原理就是使用了UTS namespace
USER Namespace
user namespace主要隔离了安全相关的标识符(identifier)和属性(attribute),包括用户ID、用户组ID、root目录、key(指密钥)以及特殊权限。通俗地讲,一个普通用户的进程通过clone()创建的新进程在新user namespace中可以拥有不同的用户和用户组。这意味着一个进程在容器外属于一个没有特权的普通用户,但是它创建的容器进程却属于拥有所有权限的超级用户,这个技术为容器提供了极大的自由。
user namespace时目前的6个namespace中最后一个支持的,并且直到linux内核3.8版本的时候还未完全实现(还有部分文件系统不支持)。user namespace实际上并不算完全成熟,很多发行版担心安全问题,在编译内核的时候并未开启USER_NS。Docker在1.10版本中对user namespace进行了支持。只要用户在启动Docker daemon的时候制定了–user-remap,那么当用户运行容器时,容器内部的root用户并不等于宿主机的root用户,而是映射到宿主机上的普通用户。
Docker不仅使用了user namespace,还使用了在user namespace中涉及的Capability机制。从内核2.2版本开始,Linux把原来和超级用户相关的高级权限分为不同的单元,称为Capability。这样管理员就可以独立的对特定的Capability进行使用或禁止。Docker同时使用namespace和Capability,这很大程度上加强了容器的安全性。
CGroups
Docker通过Linux Namespace实现了进程、文件系统、网络等隔离,但是Namespace并不能为其提供物理资源的隔离,比如CPU、Memory等。
如果其中的某一个容器正在执行 CPU 密集型的任务,那么就会影响其他容器中任务的性能与执行效率,导致多个容器相互影响并且抢占资源。如何对多个容器的资源使用进行限制就成了解决进程虚拟资源隔离之后的主要问题,而 Control Groups(简称 CGroups)就是能够隔离宿主机器上的物理资源,例如 CPU、内存等。?
每一个CGroup都是一组被相同的标准和参数限制的进程,不同的 CGroup 之间是有层级关系的,也就是说它们之间可以从父类继承一些用于限制资源使用的标准和参数。
Linux 的 CGroup 能够为一组进程分配资源,也就是我们在上面提到的 CPU、内存、网络带宽等资源,通过对资源的分配。
Linux 使用文件系统来实现 CGroup,我们可以直接使用下面的命令查看当前的 CGroup 中有哪些子系统:
如果没有lssubsys命令,CentOS可以通过“yum install libcgroup-tools”命令安装。
# lssubsys -m
cpuset /sys/fs/cgroup/cpuset
cpu,cpuacct /sys/fs/cgroup/cpu,cpuacct
memory /sys/fs/cgroup/memory
devices /sys/fs/cgroup/devices
freezer /sys/fs/cgroup/freezer
net_cls,net_prio /sys/fs/cgroup/net_cls,net_prio
blkio /sys/fs/cgroup/blkio
perf_event /sys/fs/cgroup/perf_event
hugetlb /sys/fs/cgroup/hugetlb
pids /sys/fs/cgroup/pids
- cpuset:如果是多核心CPU,这个子系统会为CGroup任务分配单独的CPU
- cpu:使用调度程序为CGroup提供CPU访问
- cpuacct:产生CGroup任务的CPU资源报告
- memory:设置每个CPU的内存限制以及产生内存资源报告
- devices:允许或拒绝CGroup任务对设备的访问
- freezer:暂停或恢复CGroup任务
- net_cls:标记每个网络包以供CGroup使用
- net_prio:针对每个网络接口设置cgroup的访问优先级
- blkio:设置限制每个块的输入输出,例如磁盘、光盘以及USB等
- perf_event:对CGroup进行性能监控
- hugetlb:限制cgroup的huge pages的使用量
- pids:限制一个cgroup及其子孙cgroup中的总进程数
大多数 Linux 的发行版都有着非常相似的子系统,而之所以将上面的 cpuset、cpu 等东西称作子系统,是因为它们能够为对应的控制组分配资源并限制资源的使用。
CPU子系统
CPU子系统下主要的文件如下(可以通过ls /sys/fs/cgroup/cpu查看):
从上面的文件和功能可以看出,CPU的限制有软限制和硬限制。
软限制
软限制是通过设置cpu.shares来实现的。
比如说现在有两个容器,但是只有1颗CPU。当给A容器的cpu.shares配置为512,给B容器的cpu.shares配置为1024,这表示A和B容器使用CPU的时间片比例为1:2,也就是A能用33%的时间片,B能用66%的时间片。
如果把A的shares值改成1024,则A和B的时间片比例就变成了1:1。
cpu.shares有两个特点:
- 如果A不忙,没有使用到66%的CPU时间,那么剩余的CPU时间将会被系统分配给B,即B的CPU使用率可以超过33%
- 如果添加了一个新的cgroup C,且它的shares值是1024,那么A的限额变成了1024/(1204+512+1024)=40%,B的变成了20%
由此可以看出: - 在闲的时候,shares基本上不起作用,只有在CPU忙的时候起作用,这是一个优点。
- 由于shares是一个绝对值,需要和其它cgroup的值进行比较才能得到自己的相对限额,而在一个部署很多容器的机器上,cgroup的数量是变化的,所以这个限额也是变化的,自己设置了一个高的值,但别人可能设置了一个更高的值,所以这个功能没法精确的控制CPU使用率。
硬限制
顾名思义,硬限制就是给你设置一个上限,你永远不能超过这个上限。
硬限制由两个参数控制:
- cpu.cfs_period_us:用来配置时间周期长度,单位微秒,默认是100000微妙。
- cpu.cfs_quota_us:用来配置当前cgroup在设置的周期长度内所能使用的CPU时间数,单位微秒,取值大于1ms,-1代表不受限制。
如果给cpu.cfs_quota_us设置为100000微秒,则表示限制只能用1个CPU,如果设置为50000微秒,则表示限制只能用0.5个CPU。
CPU核数=cpu.cfs_quota_us / cpu.cfs_period_us。
可以通过一个小实验来直观感受一下。
# 创建工程
mkdir busyloop
cd busyloop
go mod init busyloop
开发一个简单的代码
package main
func main(){
for{}
}
这个代码会启动一个线程,默认也就占用一个CPU。
然后启动项目
go build
./busyloop
使用top命令可以看到使用了一个CPU。
现在我们开始对其进行限制。
# 进入cpu 子系统
cd /sys/fs/cgroup/cpu
# 创建一个busyloop的目录
mkdir busyloop
# 该目录下会自动生成如下文件
ll
total 0
-rw-r--r-- 1 root root 0 Jun 9 17:19 cgroup.clone_children
--w--w--w- 1 root root 0 Jun 9 17:19 cgroup.event_control
-rw-r--r-- 1 root root 0 Jun 9 17:19 cgroup.procs
-r--r--r-- 1 root root 0 Jun 9 17:19 cpuacct.stat
-rw-r--r-- 1 root root 0 Jun 9 17:19 cpuacct.usage
-r--r--r-- 1 root root 0 Jun 9 17:19 cpuacct.usage_percpu
-rw-r--r-- 1 root root 0 Jun 9 17:19 cpu.cfs_period_us
-rw-r--r-- 1 root root 0 Jun 9 17:19 cpu.cfs_quota_us
-rw-r--r-- 1 root root 0 Jun 9 17:19 cpu.rt_period_us
-rw-r--r-- 1 root root 0 Jun 9 17:19 cpu.rt_runtime_us
-rw-r--r-- 1 root root 0 Jun 9 17:19 cpu.shares
-r--r--r-- 1 root root 0 Jun 9 17:19 cpu.stat
-rw-r--r-- 1 root root 0 Jun 9 17:19 notify_on_release
-rw-r--r-- 1 root root 0 Jun 9 17:19 tasks
# 首先在cgroup.procs中记录busyloop的进程
echo 13817 > cgroup.procs
# 这时候还并为做任何限制,所以CPU使用还是1颗
# 给cpu.cfs_quota_us配置50000微妙,也就是使用0.5个CPU
echo 50000 > cpu.cfs_quota_us
这时候通过top命令可以看到busyloop进程使用0.5个CPU。
Memory子系统
Memory子系统用来限制内存的,常用的文件如下。
Memory子系统和CPU子系统类似,在配置的时候也是先找到对应的进程,然后在对应的文件众配置额度,这里就不再赘述。
补充:Linux进程调度
Linux Kernel默认提供了5个调度器,Linux Kernel使用struct_sched_class来对调度器进行抽象。
- Stop调度器:stop_sched_class,优先级最高的调度类,可以抢占其他所有进程,不能被其他进程抢占。
- Deadline调度器:dl_sched_class,使用红黑树,把进程按绝对截止期限进行排序,选择最小进程进行调度允许。
- RT调度器:rt_sched_class,实时调度器,为每个优先级维护一个队列。
- CFS调度器:cfs_sched_class,完全公平调度器,采用完全公平调度算法,引入虚拟运行时间的概念。
- IDLE-TASK调度器:idle_sched_class,空闲调度器,每个CPU都有一个idle线程,当没有其他进程可以调度时,运行idle线程。
CFS调度器
CFS调度,是Completely Fair Scheduler的简称,即完全公平调度器。
CFS实现的主要思想是维护为任务提供处理器时间方面的平衡,这意味着应给进程分配相当数量的处理器。
当分给某个任务的时间失去平衡时,应该给失去平衡的任务分配时间,让其运行。
CFS通过虚拟运行时间(vruntime)来维护平衡,维护提供给某个任务的时间量。vruntime = 实际运行时间 * 1024 / 进程权重
进程按照各自不同的速率在物理时钟节拍里前进,优先级高则权重大,其虚拟时钟比真实时钟跑的慢,但获得比较多的运行时间。
CFS调度器没有将进程维护在运行队列中,而是维护了一个以虚拟时间为顺序的红黑树。红黑树主要特点有两个:
- 自平衡,树上没有一条路径会比其他路径长出两倍
- O(log n) 时间复杂度,能够在树上进行快速高效地插入或删除进程。
UnionFS
UnionFS是Union File System的简称,也就是联合文件系统。
所谓UnionFS就是把不同物理位置的目录合并mount到同一个目录中,然后形成一个虚拟的文件系统。一个最典型的应用就是将一张CD/DVD和一个硬盘的目录联合mount在一起,然后用户就可以对这个只读的CD/DVD进行修改了。
Docker就是充分利用UnionFS技术,将镜像设计成分层存储,现在使用的就是OverlayFS文件系统,它是众多UnionFS中的一种。
OverlayFS只有lower和upper两层。顾名思义,upper层在上面,lower层在下面,upper层的优先级高于lower层。
在使用mount挂载overlay文件系统的时候,遵守以下规则。
- lower和upper两个目录存在同名文件时,lower的文件将会被隐藏,用户只能看到upper的文件。
- lower低优先级的同目录同名文件将会被隐藏。
- 如果存在同名目录,那么lower和upper目录中的内容将会合并。
- 当用户修改merge中来自upper的数据时,数据将直接写入upper中原来目录中,删除文件也同理。
- 当用户修改merge中来自lower的数据时,lower中内容均不会发生任何改变。因为lower是只读的,用户想修改来自lower数据时,overlayfs会首先拷贝一份lower中
- 文件副本到upper中。后续修改或删除将会在upper下的副本中进行,lower中原文件将会被隐藏。
- 如果某一个目录单纯来自lower或者lower和upper合并,默认无法进行rename系统调用。但是可以通过mv重命名。如果要支持rename,需要CONFIG_OVERLAY_FS_REDIRECT_DIR。
下面以OverlayFS为例,直面感受一下这种文件系统的效果。
系统:CentOS 7.9 Kernel:3.10.0
(1)创建两个目录lower、upper、merge、work四个目录
# # mkdir lower upper work merge
其中:
- lower目录用于存放lower层文件
- upper目录用于存放upper层文件
- work目录用于存放临时或者间接文件
- merge目录就是挂载目录
(2)在lower和upper两个目录中都放入一些文件,如下:
# echo "From lower." > lower/common-file
# echo "From upper." > upper/common-file
# echo "From lower." > lower/lower-file
# echo "From upper." > upper/upper-file
# tree
.
├── lower
│ ├── common-file
│ └── lower-file
├── merge
├── upper
│ ├── common-file
│ └── upper-file
└── work
可以看到lower和upper目录中有相同名字的文件common-file,但是他们的内容不一样。
(3)将这两个目录进行挂载,命令如下:
# mount -t overlay -o lowerdir=lower,upperdir=upper,workdir=work overlay merge
挂载的结果如下:
# tree
.
├── lower
│ ├── common-file
│ └── lower-file
├── merge
│ ├── common-file
│ ├── lower-file
│ └── upper-file
├── upper
│ ├── common-file
│ └── upper-file
└── work
└── work
# cat merge/common-file
From upper.
可以看到两者共同目录common-dir内容进行了合并,重复文件common-file为uppderdir中的common-file。
(4)在merge目录中创建一个文件,查看效果
# echo "Add file from merge" > merge/merge-file
# tree
.
├── lower
│ ├── common-file
│ └── lower-file
├── merge
│ ├── common-file
│ ├── lower-file
│ ├── merge-file
│ └── upper-file
├── upper
│ ├── common-file
│ ├── merge-file
│ └── upper-file
└── work
└── work
可以看到lower层没有变化,新增的文件会新增到upper层。
(5)修改merge层的lower-file,效果如下
# echo "update lower file from merge" > merge/lower-file
# tree
.
├── lower
│ ├── common-file
│ └── lower-file
├── merge
│ ├── common-file
│ ├── lower-file
│ ├── merge-file
│ └── upper-file
├── upper
│ ├── common-file
│ ├── lower-file
│ ├── merge-file
│ └── upper-file
└── work
└── work
# cat upper/lower-file
update lower file from merge
# cat lower/lower-file
From lower.
可以看到lower层同样没有变化,所有的修改都发生在upper层。
从上面的实验就可以看到比较有意思的一点:不论上层怎么变,底层都不会变。
Docker镜像怎么实现的
Docker镜像就是存在联合文件系统的,在构建镜像的时候,会一层一层的向上叠加,每一层构建完就不会再改变了,后一层上的任何改变都只会发生在自己的这一层,不会影响前面的镜像层。
我们通过一个例子来进行阐述,如下图。
具体如下:
- 基础L1层有file1和file2两个文件,这两个文件都有具体的内容。
- 到L2层的时候需要修改file2的文件内容并且增加file3文件。在修改file2文件的时候,系统会先判定这个文件在L1层有没有,从上图可知L1层是有file2文件,这时候就会把file2复制一份到L2层,然后修改L2层的file2文件,这就是用到了联合文件系统写时复制机制,新增文件也是一样。
- 到L3层修改file3的时候也会使用写时复制机制,从L2层拷贝file3到L3层 ,然后进行修改。
- 然后我们在视图层看到的file1、file2、file3都是最新的文件。
上面的镜像层是死的。当我们运行容器的时候,Docker Daemon还会动态生成一个读写层,用于修改容器里的文件,如下图。
比如我们要修改file2,就会使用写时复制机制将file2复制到读写层,然后进行修改。同样,在容器运行的时候也会有一个视图,当我们把容器停掉以后,视图层就没了,但是读写层依然保留,当我们下次再启动容器的时候,还可以看到上次的修改。
值得一提的是,当我们在删除某个文件的时候,其实并不是真的删除,只是将其标记为删除然后隐藏掉,虽然我们看不到这个文件,实际上这个文件会一直跟随镜像。
Docker的常见操作
Docker现在越来越下沉,甚至很多用户不再使用Docker,在以Kubernetes为中心的容器服务中,Docker不再是必要的选择。
但是作为一款有时代意义的产品,Docker的基本操作对于技术人员来说还是有必要学习和了解的。
Docker的常见指令
Docker分为客户端和服务端,这些常见指令是针对客户端的。
Docker客户端的指令有很多,可以通过docker -h来查看,这里只介绍一些比较常用的指令。
- docker build
- docker ps
- docker pull
- docker push
- docker image
- docker login
- docker logs
- docker exec
- docker version
Docker镜像的最佳实践
认识Dockerfile
Docker的镜像是通过Dockerfile构建出来的,所以Dockerfile的操作是很重要的 。
先通过一个例子来看看Dockerfile是什么样子。
FROM docker.io/centos
LABEL "auth"="joker" \
"mail"="unclejoker520@163.com"
ENV TIME_ZOME Asia/Shanghai
RUN yum install -y gcc gcc-c++ make openssl-devel prce-devel
ADD nginx-1.14.2.tar.gz /opt/
RUN cd /opt/nginx-1.14.2 && \
./configure --prefix=/usr/local/nginx && \
make -j 4 && \
make install
RUN rm -rf /opt/nginx* && \
yum clean all && \
echo "${TIME_ZOME}" > /etc/timezone && \
ln -sf /usr/share/zoneinfo/${TIME_ZOME} /etc/localtime
COPY nginx.conf /usr/local/nginx/conf/
WORKDIR /usr/local/nginx/
EXPOSE 80
CMD ["./sbin/nginx","-g","daemon off;"]
其中FROM指令必须是开篇第一个非注释行,是必须存在的一个指令,后面所有的操作都是基于这个镜像的。后面的指令就是一些操作指令,指令的详情在后面介绍。最后是CMD指定,这个指令表示在容器运行是需要执行的命令。当定义好Dockerfile,然后使用docker build命令就可以将起构建成Docker镜像。
Dockerfile常用指令
最佳实践
只会Dockerfile的命令是不够的,有时候你会发现为什么别人的镜像那么小,为什么别人构建镜像那么快。这其中是有一些技巧的。
优化构建上下文
什么是构建上下文?
当执行docker build的时候,执行该命令所在的工作目录就是构建上下文。
为什么要优化构建上下文呢?
当执行docker build构建镜像的时候,会把当前工作目录下的所有东西都加载到docker daemon中,如果没有对上下文进行优化,可能导致构建时间长,构建所需资源多,构建镜像大等问题。
应该如何优化呢?
1、创建单独的目录存放Dockerfile,保持该目录整洁干净。
2、如果没有办法把Dockerfile单独存放到某个目录,可以通过在Dockerfile所在目录中添加.dockeringnore文件,在该文件中把不需要的文件填写进去,这样在加载上下文的时候就会把这些文件排除出去。
合理利用缓存
docker在构建镜像的时候,会依次读取Dockerfile中的指令并按顺序依次执行。在读取指令的过程中,会去判断缓存中是否有已存在的镜像,如果存在就不会再执行构建,而是直接使用缓存,这样会加快构建速度。
合理利用缓存,可以加快构建速度,所以在编写Dockerfile的时候把不会改变的指令放到前面,让起尽可能的使用到缓存。
注意:如果某一层得缓存失效,后续的所有缓存都会失效。
上图Dockerfile1和Dockerfile2分别为app1和app2制作镜像,通过这两个Dockerfile来介绍一下缓存是怎么用的。
(1)FROM 指令代表的是基础镜像,app1和app2都是用的同一个,所以在打包的时候,如果本地存在该镜像,就不会到dockerhub上拉取了。
(2)RUN指令执行的是定义的命令,docker会对比命令是否一样,如果一样就直接使用缓存。
(3)ADD指令是拷贝用户文件到镜像中,docker会判断该镜像每一个文件的内容并生成一个checksum,与现存镜像进行比较,如果checksum一致则使用缓存,否则缓存就失效。
合理的优化镜像体积
Docker的镜像是会在服务器与服务器之间、服务器和镜像仓库之间来回传递,如果镜像太大不仅影响传输速度、还占用服务器主机资源,所以合理的优化镜像体积有助于提升效率。
目前可以通过以下方法来优化镜像体积:
(1)选用较小的基础镜像
(2)删除不必要的软件包
合理优化镜像层数
镜像的层级越多,镜像的体积相对来说也会越大,而且项目的可维护性越低,目前Docker只有RUN、ADD、COPY这三个命令会创建层级,所以优化镜像层数主要是合理使用这三个命令。
目前通用的解决办法是:
(1)合并命令。如果一个Dokcerfile中有多个RUN命令,可以将它们合并成一个RUN。
(2)使用多阶段构建,减少不必要的层级。
合理选择初始化进程
如果一个镜像无法避免使用多进程,那么就应该合理的选择初始化进程。
初始化进程有有以下要求:
- 能够捕获SIGTERM信号,并完成子进程的优雅终止
- 能够完成子进程的清退,避免产生僵尸进程
tini项目可以解决上面的问题,在选择初始化进程的时候可以考虑一下。
Containerd
Containerd是从Docker中分离的一个项目,旨在为Kubernetes提供容器运行时,负责管理镜像和容器的生命周期。
在kubernetes1.20后会逐步移除docker,不过现在docker和containerd都可以同时为Kubernetes提供运行时。
如果是docker作为容器运行时,则调用关系是kubelet-->docker-shim-->dockerd-->containerd
如果是containerd作为容器运行时,则调用关系是kubelet-->cri-plugin-->containerd
可以看出containerd的调用链路比docker要短,但是相对的功能没有docker丰富。
下面列举Containerd和Docker的命令差别,其他的其实用到的也不多,基本都是Kubernetes自己去调用。
镜像相关
容器相关
Pod相关
总结
Docker公司虽然没有在最后的决战中胜出,但是Docker产品不失为一个好产品,它在整个云原生领域有着举足轻重的作用。
(1)它做到了镜像一次编译,随时使用
(2)可以一键启动依赖服务,搭建环境的成本降低
(3)可以保持所有环境高度一致
(4)它可以达到秒级启动
但是Docker也有其与生俱来的缺点,比如由于主机上的容器都是共用的底层操作系统,其隔离性不如真正的硬件隔离,而且随着并发的不断增大,也会因一些网络连接和数据交互等问题产生性能瓶颈。
而且随着容器的不断发展,越来越多的容器运行时诞生,Docker在整个领域的地位会不断下降,它不再是大家必须的选择。