笔者在前边的多篇文章中介绍过Docker这家公司为容器化技术进入寻常企业“家”作出的卓越贡献。除去我们耳熟能像的容器打包,容器仓库,开发人员视角等贡献之外,笔者认为Docker公司推动容器化被企业广泛接受的最最重要的创新就是容器镜像(Docker Images)。如果我们仔细分析整个Docker技术栈,你会发现支撑Docker体系所依赖的核心技术在Linux都已经存在很多年了,并且也有各种各样的使用场景。而对于开发人员来说,打包是Docker之前应用部署遇到的最大的痛点。
应用打包有两个极端:像VM这样将操作系统和应用以及应用的依赖都打包在一起,结果就是随随便便高达几个G的镜像文件,而像Java这样的编程语言对应用程序打包又略显单薄,因为底层操作系统层面的依赖很容易在多应用的场景下出现版本冲突的问题,开发人员又会陷入:在我机器上运行的好好的,为啥在生产环境就挂了呢?这样的灵魂拷问。具体来说Docker镜像最大创新就是应用彻底的打包和镜像的分层机制。应用打包的完整性(除了操作系统内核之外的多有依赖都打包到应用程序的镜像中)解决了在同一台机器上运行多个应用可能造成的依赖项版本冲突的问题,而进行的分层机制让我们不在为动辄几个G的镜像问题烦恼,因为很多场景下你可能只需要下载很少的数据就能将应用启动起来,灵活性和可运维性得到巨大的提升。
那么容器镜像到底如何理解?大家可以简单的把容器镜像(Docker Image)看成将应用程序运行需要的除操作系统内核之外的所有依赖都打包到一起的应用构建方式,镜像中除了应用程序代码之外,还包括应用程序的外部依赖,操作系统的文件系统等。有了这个镜像包之后,为了让应用程序运行起来,我们唯一缺少的就是操作系统内核,而我们一般通过物理机或者虚拟机的方式来提供容器运行需要的内核和计算资源。
如果我们换个角度来看Docker的镜像(笔者后边的内容会在容器镜像,Docker镜像,镜像之间切换),镜像可以被分为两部分,分别为root文件系统和配置信息。笔者在前边介绍容器三大支柱的文章中介绍root文件系统的使用过单独下载的alpine root文件系统,来启动容器实例,我们可以对比docker run -it alpine sh,在启动容器实例后,运行ls返回的结果和我们单独下载的alpine root文件系统的内容完全一致(当然前提是docker启动实例的alpine版本必须和下载的一致),结果如下图所示:
从上边的对比可以看到,在Alpine版本一致的情况下,Docker实例和下载的root文件系统的文件夹结构完全一致。由于Docker的广泛普及,大多人对容器镜像的反应就是Docker,具体来说就是通过Dockerfile来构建容器镜像。Dockerfile中包含了构建镜像的指令,比如FROM,ADD,COPY,RUN等,这些指令会修改基础镜像的root文件系统的内容,还有一些诸如USER,PORT,ENV等指令,会修改应用程序的配置信息。不过镜像中的配置信息我们可以在通过命令行参数在启动容器实例的时候进行修改,比如使用命令docker run -e <VARNAME>=<NEWVALUE> ...来重写镜像中的参数值。
如果读者有过Kubernetes的使用经验,或者阅读过笔者之前的系列文章,应该知道我们可以在YAML文件中来rewrite环境变量等配置信息。当我们在YAML中重新给变量赋值后,那么YAML文件中的值会有更高的优先级。在Kuberntes体系中,专门用来规范容器镜像和运行时的标准叫OCI(Open Conatiner Initiative),由于Docker在Kubernetes最初几个版本中是默认的容器运行时,以及大量的用户群体已经习惯了Docker的操作习惯,因此OCI的目的也变得很奇怪:将容器镜像和运行时标准化,并且尽量和Docker的使用体验保持一致。另外OCI规范定义了容器镜像的格式,以及如何构建镜像和分发镜像。
为了更加方便的分析OCI镜像,我们需要使用Skopeo工具来把镜像从Docker格式转换成OCI格式(如果读者机器上还没有安装Skopeo工具,可以自行安装。如果读者的环境是macOS,执行brew install skopeo就可以完成安装)。笔者在自己机器上运行的结果如下:
➜ skopeo skopeo copy docker://alpine:latest oci:alpine:latest --override-os linux
Getting image source signatures
Copying blob a0d0a0d46f8b done
Copying config 696d33ca15 done
Writing manifest to image destination
Storing signatures
➜ skopeo ls
alpine
➜ skopeo cd alpine
➜ alpine ls
blobs index.json oci-layout
从输出的结果可以看到OCI格式的镜像中包含的数据。不过OCI格式的镜像并不能直接被runc这样的运行时直接使用,我们需要将镜像解压成运行时filesystem bundle,为了解压数据,我们需要使用另外一个工具umoci,在笔者的机器上输出如下:
➜ skopeo sudo umoci unpack --image alpine:latest alpine-bundle
➜ skopeo ls
alpine alpine-bundle
➜ skopeo sudo ls alpine-bundle
config.json
rootfs
sha256_03014f0323753134bf6399ffbe26dcd75e89c6a7429adfab392d64706649f07b.mtree
umoci.json
➜ skopeo sudo ls alpine-bundle/rootfs
bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
如上图所示,bundle中包含了rootfs,通过名字我们不难理解这是一个root filesystems文件夹,读者可以对比图1.1,文件夹的结构应该是完全一样的。除了rootfs之外,还有一个叫config.json的文件,这个文件非常重要,这是容器运行时启动容器实例时,所有的配置信息都从这个文件中读取。因此我们可以说,容器运行时初始化容器实例的时候,,使用rootfs来作为root文件系统,而容器实例的配置信息从config.json中读取。
上边的信息可能让读者有点摸不着头脑,我平时使用Docker的时候,没有这么复杂啊,不就是docker run一下就启动实例了。不过从这点大家应该能够看到Docker为容器化应用部署在寻常企业家落地作出了巨大的贡献啊,操作起来是如此的方便。如果要早Docker中类比这个配置文件,大家可以运行docker image inspect命令,笔者在自己机器上运行docker image inspect alpine命令返回的结果如下图所示:
如果我们仔细对比图1.2的输出内容和config.json中的内容,会发现这些配置文件中包含的信息基本一样。我们以config.json为例,其中包含了runc要启动容器实例所需要的所有配置信息,包括但不限于:需要创建的namespace,需要cgroup来约束的资源配置信息等。到这里为止,希望大家能够充分理解笔者在前边提到的一个观点:容器镜像可以分为root文件系统和配置信息两个部分的概念。
有了对容器镜像的深入理解,接下来我们来看看如何构建容器镜像。不过说起来镜像构建,读者应该第一时间就会反应出来docker build命令。Docker build会读取输入的Dockerfile的配置信息,来生成对应的镜像文件。不过由于docker build本身的工作原理已经非常成熟了,没有什么值得大书特书的,咱主要还是从安全的角度来看看,docker build这种构建容器镜像的方式到底有哪些安全风险。
当我们在安装了Docker Desktop的机器上运行docker build命令的时候,本质上我们通过docker的客户端工具,来发送指令给服务端的Docker deamon,docker build命令会发送给机器上的Docker socket,也就是说任何可以访问这个socket的进程,都可以发送API请求命令给deamon。
Docker deamon是服务器端运行的进程,主要任务是接受客户端的命令,并基于用户提供的配置信息来运行容器实例。由于运行实例需要创建对应的命名空间,因此deamon必须以root权限在机器上运行,要不然就没有办法创建容器实例。那么问题就来了,如果我们部队Docker deamon进行安全保护,那么就会有恶意用户通过docker run来运行任意的容器实例,进而窃取敏感数据。更为不幸的是,这些恶意的行为即便是通过审计日志进行跟踪,日志中输出的的也只有proess id(deamon process),而没有用户ID,这就给系统造成的极大的安全风险。因此为了降级这种安全风险,我们需要使用Deamonless build机制,来安全的构建容器镜像。关于Deamonless构建方式,市场上目前可选择的有Buildkit,podman,buildah等,感兴趣的读者可以查阅相关的资料,笔者这里就不在累述了。笔者想特别强调的是,Buildkit是Docker rootless模式的基础。
不过无论我们选择什么的构建方式,容器镜像的构建核心还是Dockerfile文件,Dockerfile中包含了很多指令instructions,这些类如ADD,RUN,变量的指令的运行结果要么给镜像的基础文件系统增加一层(layer),要么修改镜像的配置信息。不过从安全的角度看,容器镜像本身根本没有任何安全性可言,因为任何可以docker pull拉取到镜像的用户,可以访问镜像中的所有数据。因此笔者强烈建议大家不要把敏感信息放到容器镜像中,比如数据库连接信息,token信息等。
容器镜像是由多个层(layer)叠加构成的,因此我们必须非常小心,避免把敏感信息保存到容器镜像中,即便是我们在容器启动的时候,通过delete命令删除了某些文件,实际上这些文件在底层并未被删除,而是在root文件系统之上,覆盖了一层来达到删除的效果,我们来举个例子说明一下。假设我们有如下的Dockerfile内容:
FROM alpine
RUN echo "yunpan-secret-info" > /yingsixinxi.txt
RUN rm /yingsixinxi.txt
咱们这个Dockerfile purposely先在容器镜像中创建了yingsixinxi.txt文件,然后又通过rm命令删除了这个包含隐私信息的文件(虽然这么写在实际业务场景中没什么价值,笔者的目的是为了演示删除文件并无法保证敏感信息不被其他人看到),从逻辑的角度来讲,敏感信息应该已经被删除了,但是实际上我们仍然可以获取到敏感信息"yunpan-secret-info"。接下来咱们一起来验证一下,看看是否如笔者所说。
首先咱们来在本地先把镜像build出来,在包含Dockerfile的目录中运行命令docker build -t qigaopan/sensitive . 来构建这个叫sensitive的镜像包,然后我们可以通过将镜像运行起来并验证文件系统是否包含yingsixinxi.txt文件,运行命令docker run --rm -it qigaopan/sensitive ls /yingsixinxi.txt,在笔者的机器上输出如下:
➜ source docker run --rm -it qigaopan/sensitive ls /yingsixinxi.txt
ls: /yingsixinxi.txt: No such file or directory
但是不要被上边的输出”No such file or directory“蒙蔽你的双眼啊,敏感信息依然保存在镜像中,并且如果我们持有镜像,是可以访问到敏感信息的。首先我们通过docker save将镜像保存到本地,然后对镜像进行解压,在笔者机器上的操作序列如下:
➜ source docker save qigaopan/sensitive > sensitive.tar
➜ source cd sensitive
➜ sensitive tar -xf ../sensitive.tar
➜ sensitive ls
058f0762dc128781d769f93f1a632fc747e256ad3005cb535fdfadf8c609c6e9.json deddfc0e9831c17535387ec0ef82957f5687c0e27e19478046acb7aa437cf149
304e424bc2d24962ebf3497697c15f6c58bb58a8bf58832fa5286f30d0d36285 manifest.json
4cc007462fec3a663fe5537098abe3f633f20dcd872cee7d357fa5719905635a repositories
解压镜像后会包含很多信息,manifest.json包含了对镜像的描述信息,058***.json文件是镜像的配置文件,剩下的文件夹是组成这个镜像的多个层(layer)。由于配置文件058*.json中包含了构建这个镜像的历史信息,因此我们可以很容通过分析这个文件来获取到敏感数据信息。在对应的目录运行命令cat 058f*.json | jq '.history',输出入下图所示:
基于配置文件的输出,我们确定镜像中包含了敏感信息,接下来我们就只需要把对应文件夹下的压缩包解压,在笔者机器上的输出如下:
➜ sensitive cd deddfc0e9831c17535387ec0ef82957f5687c0e27e19478046acb7aa437cf149
➜ deddfc0e9831c17535387ec0ef82957f5687c0e27e19478046acb7aa437cf149 tar -xf layer.tar
➜ deddfc0e9831c17535387ec0ef82957f5687c0e27e19478046acb7aa437cf149 ls
VERSION etc json layer.tar yingsixinxi.txt
如上图所示,任何在容器镜像中出现过的信息,都会被保存下来,因此大家千万不要在自己的镜像中包含任何敏感数据,也不要借助于rm这样的指令来试图掩耳盗铃。
好了, 今天这篇文章就这么多了,我们下篇文章继续讨论镜像是如何被保存和分发的,借此来讨论涉及到的安全风险,敬请期待!