
【5】容器技术架构:基本原理&关键流程
本篇文章我们来讲解下容器的技术架构,笔者会控制在一定深度层次内介绍容器的技术架构,不会过多地涉及太深入的细节。重点是让读者在介于浅显和深入之间的层次来理解容器技术架构,破除掉因为未知带来的神秘感,并为下一篇介绍其思想的文章打一个认知基础。
可能很多时候我们因为对某些事物不是很了解,所以会不自觉地认为这些东西非常深奥艰涩,其实这是未知和不确定性带来的幻觉。其实任何事物,只要将其抽象层次展开在一定深度范围内而不要过深,省略一些无关同样的繁琐细节,并解释清楚各类基础术语,任何人都能很轻松的理解它。
镜像是如何构建的
镜像往往是我们使用容器的第一步,那么镜像是如何构建起来的呢?
想象这样一个简单原始的场景:你辛辛苦苦编写完了一个程序,希望它能在你需要的机器上运行,于是你从本地把它拷贝到了U盘里,然后插入、复制到另一个机器上,随后你点开了它运行,Ops,程序崩溃了。
你看了眼报错,是自己程序依赖的某个文件在这个机器上没有,于是你又把它拷贝了过来,然后发现又少了什么东西,如此循环往复。。。
这是一个很典型的程序部署时因为缺失各类依赖所导致的问题,也是我们在前面的文章中所提过的:容器通过打包镜像解决了这个问题,也就是直接通过自动化的方式把程序和程序所需的依赖环境整个全部拷贝下来,以至于放到任何符合基本条件的机器环境上都可以运行。
所以,我们可以简单地将镜像理解为:目标程序+目标程序依赖的一系列其他文件。
那镜像难道也是像我们所想的那样简单粗暴,直接把所有所需要的程序文件一股脑拷贝,然后打包为一个压缩文件吗?当然不是。
如果真如上面所说,这其实就更像虚拟机镜像的模式,而不是容器镜像了。看过之前系列文章的读者应该清楚,容器镜像相对于虚拟机镜像最大的优势就是足够精悍短小。而能达成精悍短小,是通过联合文件系统实现的,本质上就是复用已有的共同文件,只保存特殊的文件。
首先达成复用的,从而能够裁剪掉的大头就是内核,常见的linux发行版内核文件至少也要在1G以上,于是我们只需要拷贝非内核部分的依赖即可,这一部分可以认为是容器镜像对宿主机器内核的复用。
其次,我们可以在镜像与镜像之间进行复用,比如两个镜像都是基于ubuntu系统镜像来构建的,那么底层需要的很多文件显然肯定是重复的,所以这部分就只需要保存一份,也是我们达成了容器镜像间的复用。
有了这两层复用,我们显然能够比较轻松地甩掉依赖环境很大一部分文件了,剩下的就是我们自定义、无法复用的独立文件(比如我们的主程序二进制),这类文件往往不是很多,也不会很大。
因此,最终我们的镜像构建时,只需要做到识别可复用文件,仅打包特殊的独立文件即可。
分层
在这种要求的背景下,容器镜像采用了分层的架构进行组织,因为可以通过记录层的特征哈希来识别重复层进而做到复用,而每一层其实就是一个独立的压缩文件,里面保存了若干文件,按照特定的结构进行组织,所有层打包在一起就是一个完整的镜像。当镜像转化为容器运行的时候,会将镜像内的所有层全部提取出来,然后按照顺序以联合文件系统的形式进行挂载,这就形成了程序运行的基础文件系统环境,也就是程序所看到的“宿主机环境”。
一般的容器镜像构建有两种方式:Docker commit和Dockerfile。
前者常见于一次性构建(构建完没有修改需求),直接把容器基础层之上所有的独立特殊文件打包为单独一层(层特征很难保持一致,因此难以复用和维护);后者常见于重复性构建(构建后仍有修改调整需求),每条在Dockerfile中声明的语句都会单独被视为一层(相同指令产生的层可以复用,易于维护),以保证后续和其他镜像的可复用性。
Tips:如果想尽可能地降低镜像大小,最好把层数控制小一些,因为每多一层,由于增量提交的缘故,你对相同文件的多个更改可能会导致重复保存好几份不需要的副本;同时,每多一层还会额外保存一些元信息,当然,相对于前者,这是小头;
一般来讲,出于可维护和节省资源的目的,我们更加推荐使用Dockerfile来构建镜像,Dockerfile中的每一行RUN指令都会构建一层镜像,然后最终将涉及到若干镜像层全部打包在一起,然后为其命名、打tag,就形成了我们所直接看到的容器镜像。
延伸性思考:如何科学制作和使用镜像
大部分开发同学应该都是自行构建自己所开发程序的容器镜像的,而容器镜像的大小会和Pod/容器的拉起、扩容、对宿主机节点存储、网络的要求息息相关:过大的镜像会造成长时间的拉取、导致容器或Pod无法以较短时间启动、扩容,在对弹性和响应速度要求高的场景下可能会出现很严重的后果。另外,过大的镜像还会占用过多的宿主机的存储、网络带宽等资源,造成资源紧张、短缺和争抢问题。
因此,开发者应当把常态运行的程序镜像大小缩减到最小,要做到这些,可以通过运用我们上述所说的镜像构建原理来达成:
- 层复用:如果有多个程序镜像,尽量复用相同的基础镜像(比如都用ubuntu:22.04),这样所有的镜像基本只需要拉取其特殊的镜像层,镜像带来的成本会下降很多
- 控制层数:尽量将镜像构建时的层数控制得越少越好,因为越少的镜像层意味着越少的改动,意味着越少的重复冗余文件,这一点可以通过合并多个Dockerfile指令到一条指令、执行镜像层压缩(
--squash)来实现 - 减少层大小:仅在镜像中放置程序必要的依赖,额外的命令行、调试工具、可变配置文件尽量避免放到镜像中(真正需要调试的时候可以再使用工具齐全的大型调试容器镜像);构建镜像时要清理掉构建过程中带来的缓存(比如
apt-get clean);使用的基础镜像可以选择尽可能小而精简的(比如使用alpine作为基础镜像,该镜像仅有3M左右的大小);将构建镜像和运行时镜相分离(多阶段构造镜像),这样可以避免编译构造镜像过程中的必要(但运行时非必要)的工具被打包到镜像内;
延伸性思考:镜像带来其他的用处
因为容器镜像分层、使用联合文件系统的基本特性,也衍生出了一些很有趣的用法,比如:
- 观察容器内所有文件的变化情况:因为容器镜像本身不可变的特性,因此如果在运行时文件系统内的文件有任何变化(新建、修改、删除),都会反应在联合文件系统上,利用这一点,我们可以很方便地观察程序运行的时候文件系统环境发生的各类变化。比如,如果你的程序或依赖库被外部入侵者恶意篡改了,便能很轻松地快速发现
- 制作现场环境快照:容器运行过程中,我们可以随时将此时容器文件系统的状态(内存快照和CPU寄存器状态同样可以以文件的形式保存下来)作为系统快照完整保留下来(执行一次docker commit即可),如果某些罕见现象或行为发生了,我们可以立即手动或自动地执行快照保存,以保留珍贵现场以供后续分析
容器是如何启动的
上一部分我们说了镜像,镜像是容器启动运行前的必要条件,当镜像确认准备好后,就正式进入容器的启动过程,这里我们以containerd运行时执行流程为准,它是K8S和Docker标准的容器管理守护进程。
整体来讲,容器的启动运行流程大概如下:
containerd拉取&确认镜像就绪
containerd先创建容器对应的shim(垫片)进程,用于专门管理其对应容器(通常是1对1关系)
containerd通过rpc调用shim进程接口开始创建容器的实际过程(创建参数信息会一并传入)
shim程序准备容器rootfs(容器的宿主文件系统),挂载需要的volume(存储卷):containerd:NewContainer
shim程序通过调用运行runc命令行程序(runc create独立进程,此时同样会传递创建参数信息)创建容器:containerd:Init.Create、runc:startContainer
runc create进程再次创建新进程执行runc init:runc:runner.run、runc:Container.start、runc:initProcess.start
- 此时容器创建参数(namespace、特权设置等)会以bootstrapData的形式通过runc create和runc init的pipe传递给runc init进程
- 同时在这里会预先给runc init进程设置好Cgroup,后面的容器进程就会继承这个Cgroup属性
runc init进程逐步转化为容器主进程(我们真正要运行的程序):runc:Container.newParentProcess
- runc init进程会先执行一个C程序,调用nsexec,通过创建三代父子关系进程实现渐进式的进程namespace切换,之所以要这样是因为依靠单个进程无法实现自举切换,必须由前面一个高权限的创建出后面一个低权限的,逐步收缩下一代进程所占据的空间
- runc init进程执行完C程序后,继续执行Go代码,做一些网络、路由、selinux、rootfs(mount namespace内)、sysctl、readonly、capabilities等容器对应属性方面的设置工作,最终执行exec系统调用,正式转变为容器主进程
runc create接收到runc init的成功事件,设置rlimits(init进程权限不足)等其他需要高权限才能进行的配置:runc:initProcess.start
runc create调用成功,返回容器主进程的pid、socket文件等,runc进程退出,shim进程接管容器主进程,成为其父进程,可以进行后续对其的管理(更新、删除、exec等)
延伸性思考:启动速度够不够快?
我们看到以上的整个过程还是非常长的,但实际的运行速度在没有遇到意外阻塞的情况下,其实是非常快的,相信使用过Docker容器的读者应该都会有所体会,当镜像准备OK时,大部分容器本身拉起基本是感知不到明显延迟的,在普通配置的负载不高的服务器上,启动速度可以保持在1s以内。
原因也非常简单,我们查看上面的整个过程,无非就是本机内部通信、文件读写、系统调用、进程拉起这几类操作,基本都闭环在本机内部,没有任何外部依赖(挂载外部磁盘这种特例除外)。相比于虚拟机,没有内核初始化、没有一大堆守护及内核进程的启动、没有驱动设置等等,自然能够是和普通虚拟机的启动速度拉开数量级上的差异的(部分经过特殊裁剪过的虚拟机能够缩小这个差距,但也要付出其他代价)。
延申性思考:和裸机上运行相比,运行效率是否损失?
同样是看我们上面所列的这个启动过程,我们发现实际上最后运行的容器主进程就是一个稍微有点特殊的Linux进程,这跟在裸机上直接运行的进程,没有本质性上的差异:包括CPU算力分配、内存访问、IO、内核系统调用等等,基本都是走的是同样的内核代码调用路径(可能会多命中若干个if),没有数量级上的差异。
而虚拟机中运行的进程,它的运行是依赖另一个独立内核的,而独立内核本身的overhead就会带来很大的成本(因为要和宿主机内核做很多重复性的工作)。除此之外,大部分时候虚拟机内进程的执行,还需要CPU进入特殊的虚拟化模式来运行指令,IO和设备访问使用的路径也变得更为复杂。
延伸性思考:容器隔离性如何保证?
虽然在运行效率上容器进程具备很大的优势,但这同样并不意味着容器内的进程各个方面都要比虚拟机优秀,容器内进程本身的优势和劣势都来源于其最本质的一个特性:和宿主机共享内核。这意味着即使容器进程做了各种各样系统层面的隔离和加固,但只要这个共享内核中出现了可以利用的漏洞,就很容易影响宿主机和其他容器,而虚拟机则因为独立内核带来的更高的隔离性极大地降低了这类事件发生的可能。
我们可以从启动流程中知道,容器进程的隔离基本是依靠Linux进程Namespace、Cgroup、独立的rootfs等机制实现的,从容器的整体涉及维度来说,这些机制基本涵盖了一个进程从启动、运行到销毁过程中所有的行为都能限制在一个独立空间中,在内核本身没有较大缺陷时,其隔离性是可以满足通用要求的。
不过,某些时候如果依赖一些上层提供的特殊功能(防火墙、K8S负载均衡)时,可能会对宿主机环境进行一定程度的影响,这些影响通常不会在容器间产生影响,但可能会影响宿主机上直接运行的非容器内的程序,因此建议不要在一个宿主机节点上同时混用过多的容器和宿主机程序
其他技术细节
以下对一些我们实际使用中经常用到的操作做一些浅度的细节分析,如果有读者有更深的探索需求,可以自行查阅更深层次方向的资料,或将疑问反馈给我,我可以再基于需要再深入编写一些文章。
exec如何实现
exec是在容器内执行特定命令的功能机制,比如我们可以在容器内启动shell程序进入容器内部进行操作、在容器内部再启动一个辅助进程等等,都可以通过exec实现,这也是我们使用容器时非常常用的一个功能。
其实如果你理解了前面容器的启动模式,exec的原理也很好理解,其实他就是在已创建的容器基础上进行的:前面的容器启动过程已经创建出了各种各样的东西,包括namespace、Cgroup、rootfs等等,exec实际上就是新起一个进程,把自身的各个属性和容器主进程设置得一致,然后启动运行即可,这个exec对应的进程及其操作自然就“运行在容器中”了。
volume挂载如何实现
使用docker的时候,我们常常使用volume挂载一个宿主机上的目录到容器中,这样即使容器本身销毁删除后,我们在运行过程中产生的数据还可以保留下来。这一机制的实现其实也非常简单,这其实有点类似于linux里的链接(或windows里的快捷方式)。虽然表面上挂载两边的文件目录看起来不一样,实际上操作的是同一个对象(其对应inode相同,也就是说在文件系统中是相同的东西,只是有不同的别名)
容器网络如何实现
容器网络最简单的实现模式就是使用网桥(Linux虚拟网络设备)了,你可以简单地理解为在宿主机上有一套虚拟网络,所有的容器,包括宿主机,都处于这个独立虚拟子网中,使用独立的子网内IP进行通信。容器通过虚拟网卡设备挂载到网桥中,就相当于加入了这个虚拟子网。而如果容器想要访问外部公共网络,则由通网的宿主机代为执行(其实就是NAT:地址转换机制),可以简单的理解为这时候宿主机作为了容器的网关,帮助容器进行外部网络访问。
这样的虚拟化网络模式肯定是会造成网络性能损失的,因为虚拟设备网络协议栈处理会更加复杂,在高度敏感要求网络性能的场景下,这类简单的、依赖虚拟设备的网络方案,往往是不适用的。尤其在容器集群(也就是K8S)的场景里,容器网络方案可能会使用更复杂的方案实现(比如直接挂载一张真实网卡,这样就可以和宿主机在一个原生子网内了,不需要使用所谓的虚拟子网),以保证容器本身的网络性能和行为不会受到影响。这块可以聊的东西也非常多,如果有兴趣,我也可以基于这里再写一些文章。
总结
在本篇文章中,我们对容器技术最关键的两部分:镜像和容器本身,做了比较全面但没有过度深入细节的介绍,并简单解析了一些容器使用者在实践中经常用到功能细节。作为一篇主要面向业务开发读者的文章,笔者的目的并不在于让读者对其中的所有细节都了如指掌,而是希望能帮读者建立一个框架性的整体认知(把全局视野打开),真遇到了问题或需要深入探究时也能提纲挈领、按图索骥地高效进行,而不是自己在没有全局视野时低效地闷头摸索。
下一篇文章,我们会基于前面对容器的诞生背景以及本篇技术架构介绍的基础上,来聊聊容器技术中对我们有所启发的一些设计思想及范式,这些东西是从容器这个年轻但广阔的技术生态中所提炼出来的精华,了解这些,能够更好地反哺于我们自己的工作和实践。
