乔克
乔克
Published on 2024-11-15 / 25 Visits
0
0

深入剖析Docker镜像

作者:乔克
公众号:运维开发故事
知乎:乔克叔叔

大家好,我是乔克,一名一线运维实践者。

镜像对于 YAML 工程师来说都不陌生,每天都在和他打交道,编写、构建、发布,重复而有趣。

在我们编写一个构建镜像的 Dockerfile 之后,只要应用能正常跑起来,便很少再去看这个 Dockerfile 了(至少我是这样)。对于这个 Dockerfile 是不是想象中的那么合理,是不是还可以再优化一下,并没有做太深入的思考。

本文主要从以下几个方面带你深入了解镜像的知识。

图片.png

镜像的基本概念

在了解一件事物的时候,脑海中总是会先问一句“是什么”,学习 Docker 镜像也是同样的道理,什么是 Docker 镜像?

在说 Docker 镜像之前,先简单说说 Linux 文件系统。

典型的 Linux 文件系统由 bootfsrootfs 组成,bootfs 会在 Kernel 加载到内存后 umount 掉,所以我们进入系统看到的都是 rootfs,比如 /etc,/prod,/bin 等标准目录。

我们可以把 Docker 镜像当成一个 rootfs,这样就能比较形象是知道什么是 Docker 镜像,比如官方的 ubuntu:21.10,就包含一套完整的 ubuntu:21.10 最小系统的 rootfs,当然其内是不包含内核的。

Docker 镜像是一个 _ 特殊的文件系统 _,它提供容器运行时需要的程序、库、资源、配置还有一个运行时参数,其最终目的就是能在容器中运行我们的代码。

以上是从宏观的的视角去看 Docker 镜像是什么,下面再从微观的角度来深入了解一下 Docker 镜像。假如我们现在只有一个 ubuntu:21.10 镜像,如果现在需要一个 nginx 镜像,是不是可以直接在这个镜像中安装一个 nginx,然后这个镜像是不是就可以变成 nginx 镜像?

答案是可以的。其实这里面就有一个分层的概念,底层用的是 ubuntu 镜像,然后在上面叠加了一个 nginx 镜像,这样就完成了一个 nginx 镜像的构建了,这种情况我们称 ubuntu 镜像为 nginx 的父镜像。

这么说起来还是有点不好理解,介绍完下面的镜像存储方式,就好理解了。

镜像的存储方式

在说镜像的存储方式之前,先简单介绍一个 UnionFS(联合文件系统,Union File System)。

所谓 UnionFS 就是把不同物理位置的目录合并 mount 到同一个目录中,然后形成一个虚拟的文件系统。一个最典型的应用就是将一张 CD/DVD 和一个硬盘的目录联合 mount 在一起,然后用户就可以对这个只读的 CD/DVD 进行修改了。

Docker 就是充分利用 UnionFS 技术,将镜像设计成分层存储,现在使用的就是 OverlayFS 文件系统,它是众多 UnionFS 中的一种。

OverlayFS 只有 lowerupper 两层。顾名思义,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)创建两个目录 loweruppermergework 四个目录

# # mkdir lower upper work merge

其中:

  • lower 目录用于存放 lower 层文件
  • upper 目录用于存放 upper 层文件
  • work 目录用于存放临时或者间接文件
  • merge 目录就是挂载目录

(2)在 lowerupper 两个目录中都放入一些文件,如下:

 # 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

可以看到 lowerupper 目录中有相同名字的文件 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 镜像就是存在联合文件系统的,在构建镜像的时候,会一层一层的向上叠加,每一层构建完就不会再改变了,后一层上的任何改变都只会发生在自己的这一层,不会影响前面的镜像层。

我们通过一个例子来进行阐述,如下图。

图片.png

具体如下:

  • 基础 L1 层有 file1 和 file2 两个文件,这两个文件都有具体的内容。
  • 到 L2 层的时候需要修改 file2 的文件内容并且增加 file3 文件。在修改 file2 文件的时候,系统会先判定这个文件在 L1 层有没有,从上图可知 L1 层是有 file2 文件,这时候就会把 file2 复制一份到 L2 层,然后修改 L2 层的 file2 文件,这就是用到了联合文件系统写时复制机制,新增文件也是一样。
  • 到 L3 层修改 file3 的时候也会使用写时复制机制,从 L2 层拷贝 file3 到 L3 层 ,然后进行修改。
  • 然后我们在视图层看到的 file1、file2、file3 都是最新的文件。

上面的镜像层是的。当我们运行容器的时候,Docker Daemon 还会动态生成一个读写层,用于修改容器里的文件,如下图。

图片.png

比如我们要修改 file2,就会使用写时复制机制将 file2 复制到读写层,然后进行修改。同样,在容器运行的时候也会有一个视图,当我们把容器停掉以后,视图层就没了,但是读写层依然保留,当我们下次再启动容器的时候,还可以看到上次的修改。

值得一提的是,当我们在删除某个文件的时候,其实并不是真的删除,只是将其标记为删除然后隐藏掉,虽然我们看不到这个文件,实际上这个文件会一直跟随镜像。

到此对镜像的分层存储有一定的认识了?这种分层存储还使得镜像的复用、定制变得更容易,就像文章开头基于 ubuntu 定制 nginx 镜像。

Dockerfile 和镜像的关系

我们经常在应用代码里编写 Dockerfile 来制作镜像,那 Dockerfile 和镜像到底是什么关系呢?没有 Dockerfile 可以制作镜像吗?

我们先来看一个简单的 Dockerfile 是什么样的。

FROM ubuntu:latest
ADD run.sh /  
VOLUME /data  
CMD ["./run.sh"]  

通过这几个命令就可以做出新的镜像?

是的,通过这几个命令组成文件,docker 就可以使用它制作出新的镜像,这是不是有点像给你一些柠檬、冰糖、金银花就能制作出一杯柠檬茶一个道理?

这个一联想,Dockerfile 和镜像的关系就清晰明了了。

Dockerfile就是一个原材料,镜像就是我们想要的产品。当我们想要制作某一个镜像的时候,配置好 Dcokerfile,然后使用 docker 命令就能轻松的制作出来。

那不用 Dockerfile 可以制作镜像吗?

答案是可以的,这时候就需要我们先启动一个基础镜像,通过 docker exec 命令进入容器,然后安装我们需要的软件,最好再使用 docker commit 生成新的镜像即可。这种方式就没有 Dockerfile 那么清晰明了,使用起来也比较麻烦。

镜像和容器的关系

上面说了 Dockerfile 是镜像的原材料,在这里,镜像就是容器的运行基础。

容器镜像和我们平时接触的操心系统镜像是一个道理,当我们拿到一个操作系统镜像,比如一个以 iso 结尾的 centos 镜像,正常情况下,这个 centos 操作系统并不能直接为我们提供服务,需要我们去安装配置才行。

容器镜像也是一样。

当我们通过 Dockerfile 制作了一个镜像,这时候的镜像是静态的,并不能为我们提供需要的服务,我们需要通过 docker 将这个镜像运行起来,使它从镜像变成容器,从静态变成动态

简单来说,镜像是文件,容器是进程。容器是通过镜像创建的,没有 Docker 镜像,就不可能有 Docker 容器,这也是 Docker 的设计原则之一。

镜像的优化技巧

上面介绍了什么是镜像、镜像的存储方式以及 Dockerfile 和镜像、镜像和容器之间关系,这节主要介绍我们在制作镜像的时候有哪些技巧可以优化镜像。

Docker 镜像构建通过 docker build 命令触发,docker build 会根据 Dockerfile 文件中的指令构建 Docker 镜像,最终的 Docker 镜像是由 Dockerfile 中的命令所表示的层叠加起来的,所以从 Dockerfile 的制作到镜像的制作这一系列之间都有可以优化和注意的地方。

镜像优化可以分两个方向:

  • 优化镜像体积
  • 优化构建速度

优化镜像体积

优化镜像体积主要就是从制作 Dockerfile 的时候需要考虑的事情。

上面以及介绍过镜像是分层存储的,每个镜像都会有一个父镜像,新的镜像都是在父镜像的基础之上构建出来的,比如下面的 Dockerfile。

FROM ubuntu:latest
ADD run.sh /  
VOLUME /data  
CMD ["./run.sh"]  

这段 Dockerfile 的父镜像是 ubuntu:latest,在它的基础之上添加脚本然后组成新的镜像。

所以在优化体积方面,可以从以下几个方面进行考虑。

(1)选择尽可能小的基础镜像

在 Docker hub 上的同一个基础镜像会存在多个版本,如果可以,我建议你使用 alpine 版本,这个版本的镜像是经过许多优化,减少了很多不必要的包,节约了体积。这里就以常用的 openjdk 镜像为例,简单看一下它们的大小差别。

首先在 Docker hub 上可以看到 openjdk:17-jdkopenjdk:17-jdk-alpine 的镜像大小,如下:

图片.png

图片.png

可以看到同一个版本 alpine 版本的镜像比正常的版本小 50MB 左右,所以用这两个做基础镜像构建出来的镜像大小也会有差别。

但是是不是所有基础镜像都选 alpine 版本呢?

不是的,alpine 镜像也会有很多坑,比如。

  • 使用 alpine 版本镜像容易出错,因为这个版本镜像经过了大量的精简优化,很多依赖库都没有,如果程序需要依赖动态链接库就容易报错,比如 Go 中的 cgo 调用。
  • 域名解析行为跟 glibc 有差异,Alpine 镜像的底层库是 musl libc,域名解析行为跟标准 glibc 有差异,需要特殊作一些修复配置,并且有部分选项在 resolv.conf 中配置不支持。
  • 运行 bash 脚本不兼容,因为没有内置 bash,所以运行 bash 的 shell 脚本会不兼容。

所以使用 alpine 镜像也需要好好斟酌一下,在实际应用中,如果要使用 alpine 镜像,最好在其上做一些初始化,把需要的依赖、库、命令等先封装进去制作成新的基础镜像,其他应用再以这个基础镜像为父镜像进行操作。

(2)镜像层数尽量少

上面说过镜像是分层存储的,如果上层需要修改下层的文件需要使用写时复制机制,而且下层的文件依然存在并不会消失,如果层数越多,镜像的体积相应的也会越大。

比如下面的 Dockerfile。

FROM ubuntu:latest
RUN apt update
RUN apt install git -y
RUN apt install curl -y
ADD run.sh /
CMD ["./run.sh"]

这个 Dockerfile 能跑起来吗?完全没问题,但是这样写是不是就会导致镜像的层数非常多?

抛开父镜像 ubuntu:latest 本身的层不说,上面的 Dockerfile 足足增加了 5 层。在 Dockerfile 中是支持命令的合并的,我们可以把上面的 Dockerfile 改成如下。

FROM ubuntu:latest
RUN apt update && \
    apt install git -y && \
    apt install curl -y
ADD run.sh /
CMD ["./run.sh"]

这样一改,就把镜像的层数从 5 层降低至 3 层,而且整个逻辑并没有改变。

说明:在 Docker1.10 后有所改变,只有 RUN、COPY、ADD 指令会创建层,其他指令会创建临时的中间镜像,不会直接增加构建的镜像大小 。

(3)删除不必要的软件包

在制作镜像的时候,脑海中始终要想起一句话:镜像尽可能的保持精简。这样也有助于提高镜像的移植性。

比如下面的 Dockerfile。

FROM ubuntu:latest
COPY a.tar.gz /opt
RUN cd /opt && \
    tar xf a.tar.gz
CMD ["./run.sh"]

在这个镜像中,我们从外部拷贝了一个压缩文件 a.tar.gz,在解压过后我们并没有把这个原始包删除掉,它依然会占用着空间,我们可以把这个 Dockerfile 改成如下。

FROM ubuntu:latest
COPY a.tar.gz /opt
RUN cd /opt && \
    tar xf a.tar.gz && \
    rm -f a.tar.gz
CMD ["./run.sh"]

这样不仅得到了我们想要的文件,也没有保留不必要的软件包。

(4)使用多阶段构建

这个不是必须。

为什么这么说呢?因为多阶段构建主要是为了解决编译环境留下的多余文件,使最终的镜像尽可能小。那为什么说不是必须呢,因为这种情况很多时候都会在做 CI 的时候给分开,编译是编译的步骤,构建是构建的步骤,所以我说不是必须。

但是这种思路是非常好的,可以通过一个 Dockerfile 将编译和构建都写进去,如下。

FROM golang AS build-env
ADD . /go/src/app
WORKDIR /go/src/app
RUN go get -u -v github.com/kardianos/govendor
RUN govendor sync
RUN GOOS=linux GOARCH=386 go build -v -o /go/src/app/app-server

FROM alpine
RUN apk add -U tzdata
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai  /etc/localtime
COPY --from=build-env /go/src/app/app-server /usr/local/bin/app-server
EXPOSE 8080
CMD [ "app-server" ]

其主要是通过在 Dockerfile 中定义多个 FROM 基础镜像来实现多阶段,阶段之间可以通过索引或者别名来引用。

优化镜像体积就总结这 4 点,如果你有更多更好的方法,欢迎沟通交流。

优化构建速度

当制作好 Dockerfile 之后,就需要构建镜像了,很多时候看着构建的速度就着急,那有什么办法可以优化一下呢?这里从以下几个方面进行表述。

(1)优化网络速度

网络是万恶之源。比如许多人的基础镜像都是直接从 docker hub 上拉取,如果一台机器是第一次拉是非常缓慢的,这时候我们可以先把 docker hub 上的镜像放到本地私有仓库,这样在同一个网络环境中,拉取速度会比直接到 docker hub 上拉取快 1 万倍。

还有一个镜像分发技术,比如阿里的 dragonfly,充分采用了 p2p 的思想,提高镜像的拉取分发速度。

(2)优化上下文

不知道你有没有注意到,当我们使用 docker build 构建镜像的时候,会发送一个上下文给 Docker daemon,如下:

# docker build -t test:v1 .
Sending build context to Docker daemon  11.26kB
Step 1/2 : FROM ubuntu
......

原来在使用 docker build 构建镜像的时候,会把 Dockerfile 同级目录下的所有文件都发送给 docker daemon,后续的操作都是在这个上下文中发生。

所以,如果你 Dockerfile 的同级目录存在很多不必要的文件,不仅会增加内存开销,还会拖慢整个构建速度,那有什么办法进行优化吗?

这里提供两种方法:

  • 如果 Dockerfile 必须放在代码仓库的根目录,这时候可以在这个目录下添加一个.dockerignore 文件,在里面添加需要忽略的文件和文件夹,这样在发送上下文的时候就不会发送不必要的文件了。
  • 重新创建一个新的目录放置 Dockerfile,保持这个目录整洁干净。
(3)充分使用缓存

Docker 镜像是分层存储的,在使用 docker build 构建镜像的时候会默认使用缓存,在构建镜像的时候,Docker 都会先从缓存中去搜索要使用的镜像,而不是创建新的镜像,其规则是:从该基本镜像派生的所有子镜像,与已在缓存中的镜像进行比较,以查看是否其中一个是使用完全相同的指令构建的。如果不一样,则缓存失效,重新构建。

简单归纳就以下三个要素:

  • 父镜像没有变化
  • 构建的指令没有变化
  • 添加的文件没有变化

只要满足这三个要素就会使用到缓存,加快构建速度。

上面从体积和效率上分别介绍了 Docker 镜像的优化和注意事项,如果严格按照这种思路进行镜像设计,你的镜像是能接受考验的,而且面试的时候也是能加分的。

镜像的安全管理

上面聊了那么多镜像相关的话题,最后再来说说镜像安全的问题。

镜像是容器的基石,是应用的载体。最终我们的镜像是为业务直接或者间接的提供服务,做过运维的同学应该都为自己的操作系统做过安全加固,镜像其实也需要。

这里不阐述操作系统加固方面的知识,仅仅只针对容器来说。

(1)保持镜像精简

精简不等于安全。

但是精简的镜像可以在一定程度上规避一些安全问题,都知道,一个操作系统中是会安装非常多的软件,这些软件每天都会暴露不同的漏洞,这些漏洞就会成为不怀好意之人的目标。我们可以把镜像看成是一个缩小版的操作系统,同理,镜像里面的软件越少,越精简,其漏洞暴露的风险就更低。

(2)使用非 root 用户

容器和虚拟机之间的一个关键区别是容器与主机共享内核。在默认情况下,Docker 容器运行在 root 用户下,这会导致泄露风险。因为如果容器遭到破坏,那么主机的 root 访问权限也会暴露。

所以我们在制作镜像的时候要使用非 root 用户,比如下面一个 java 服务:

FROM openjdk:8-jre-alpine
RUN addgroup -g 1000 -S joker && \
    adduser joker -D -G joker -u 1000 -s /bin/sh
USER joker
ADD --chown=joker springboot-helloworld.jar /home/joker/app.jar
EXPOSE 8080
WORKDIR /home/joker
CMD  exec java -Djava.security.egd=file:/dev/./urandom -jar app.jar
(3)对镜像进行安全扫描

在容器注册中心运行安全扫描可以为我们带来额外的价值。除了存放镜像,镜像注册中心定期运行安全扫描可以帮助我们找出薄弱点。Docker 为官方镜像和托管在 Docker Cloud 的私有镜像提供了安全扫描。

当然还有其他的仓库也有集成安全扫描工具,比如 Harbor 新版本已经可以自定义镜像扫描规则,也可以定义拦截规则,可以有效的发现镜像漏洞。

(4)要时常去查看安全结果

大家有没有这种感觉,我加了很多东西,但是感觉不到?

我有时候就有这种感觉,比如我给某个应用加了监控,然后就不管了,以至于我根本不知道或者不在乎这个监控到底怎么样。

假如我们对镜像进行了安全扫描,安装了一些工具,一定要去查看每个安全结果,而不是扫了就完了。

总结

小小的镜像就有这么多道道,不看不知道,一看吓一跳。

本文主要从 Docker 镜像的概念说起,然后结合一些实际的场景进行对比分析阐述更深层次的实现过程,有助于帮助大家理解 Docker 镜像。

参考文献


Comment