Docker容器镜像
容器镜像构建的原理
容器化部署越来越多的用于企业的生产环境中,如何构建可靠、安全、最小化的 Docker 镜像也就越来越重要。本文将针对该问题,主要通过五个部分对容器镜像进行讲解。分别是:
- 容器镜像的构建 讲解了容器的镜像手动构建与自动构建过程。
- 镜像的存储 讲解了镜像的分层结构以及UnionFS联合文件系统,以及镜像层在UnionFS上的实现。
- 最小化容器镜像构建 讲解了为什么需要最小化镜像,同时如何进行最小化操作。
- 容器镜像的加固 容器镜像加固的具体方式。
- 容器镜像的审查 高质量的项目中容器镜像也需要向代码一样进行审查。
1. 容器镜像构建
1.1 手动构建
我们可以不使用Dockerfile,在基础镜像的基础上,通过添加文件的方式来手动构建镜像。这也能让我们一窥镜像构建的原理。
现在依次按照流程采用命令行的方式手动构建一个简单的 Docker 镜像。
1.1.1 创建基础容器并添加文件
取 BusyBox 作为本次试验的基础镜像,因为它足够小,大小才 1.21MB。
➜ ~ docker run -it busybox:latest sh
Unable to find image 'busybox:latest' locally
latest: Pulling from library/busybox
f5b7ce95afea: Pull complete
Digest: sha256:9810966b5f712084ea05bf28fc8ba2c8fb110baa2531a10e2da52c1efc504698
Status: Downloaded newer image for busybox:latest
/# touch hello.py
/# echo "print('hello, world!')" > foo.py
/# exit
通过以上的操作,我们完成了流程图的前三步。创建了一个新容器,并在该容器上创建了一个新问题。只是,我们退出容器后,容器也不见了。当然容器不见了,并不表示容器不存在了,Docker 已经自动保存了该容器。如果在创建时,未显示设置容器名称,可以通过以下方式查找该消失的容器。
### 列出最近创建的容器
➜ ~ docker container ls -n 1
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
b764da8b2c4d busybox:latest "sh" 6 minutes ago Exited (0) 2 minutes ago strange_mendel
1.1.2 提交变更生成镜像
手动构建镜像,很简单。先找到发生变更的容器对象,对其变更进行提交。提交完成后,镜像也就生成了。不过此时的镜像只有一个自动生成的序列号唯一标识它。为了方便镜像的检索,需要对镜像进行命名以及标签化处理。
命令行操作如下:
### 提交变更,构建镜像
➜ ~ docker commit -a xcliu -m "add file foo.py" b764da8b2c4d
sha256:2cc6802449e0964e204a2140eec8ea7403b094869baebfe3a6498c7cb1af26d9
### 对镜像进行命名, 镜像ID为sha256的值,可以取前几位
➜ ~ docker image tag 2cc6802449e096 busybox:manual
### 验证新镜像
➜ ~ docker run busybox:manual cat foo.py
print('hello, world!')
手动创建镜像的过程有点像在git repo提交文件,当然现在构建镜像不可能真的需要进入容器里一个一个的添加文件,整个过程可以通过自动化来完成。docker公司提供Dockerfile来描述整个构建的过程。
1.2 自动化构建
1.2.1 Dockerfile 构建
自动化构建Docker 镜像,Docker公司提供的不是SHELL脚本的方式,而是通过定义一套独立的语法来描述整个构建过程, 通过该语法编辑的文件,称为 Dockerfile。 自动化构建镜像就是通过编写Dockerfile文件构建的。
同样完成上面的工作,用Dockerfile写出来就是:
FROM busybox:latest
RUN echo "print('hello world')" > foo.py
Dockerfile的语法可以参考官方文档
完成Dockerfile编写后,通过命令触发构建。整个过程,脚本化出来就是:
➜ ~ vim Dockerfile
➜ ~ cat Dockerfile
FROM busybox:latest
RUN echo "print('hello world')" > foo.py
➜ ~ docker build -t busybox:autobuild .
Sending build context to Docker daemon 2.16GB
Step 1/2 : FROM busybox:latest
---> ff4a8eb070e1
Step 2/2 : RUN echo "print('hello world')" > foo.py
---> Running in 2f0540c30031
Removing intermediate container 2f0540c30031
---> 5b9fb41cc715
Successfully built 5b9fb41cc715
Successfully tagged busybox:autobuild
➜ ~ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
busybox autobuild 5b9fb41cc715 21 seconds ago 1.24MB
busybox manual 2cc6802449e0 18 minutes ago 1.24MB
busybox latest ff4a8eb070e1 2 weeks ago 1.24MB
hello-world latest feb5d9fea6a5 13 months ago 13.3kB
这里有一点值得注意的是由于Docker Client会默认发送Dockerfile同级目录下的所有文件到Dockerdaemon中。 我的Dockerfile是随便放在了一个目录下,导致发送了2.16GB的文件。最好使用好 .dockerignore文件或者将Dockerfile放在特定的目录下。
➜ ~ mkdir auto_build
➜ ~ mv Dockerfile auto_build
➜ ~ cd auto_build
➜ auto_build docker build -t busybox:autobuild .
Sending build context to Docker daemon 2.048kB
Step 1/2 : FROM busybox:latest
---> ff4a8eb070e1
Step 2/2 : RUN echo "print('hello world')" > foo.py
---> Using cache
---> 5b9fb41cc715
Successfully built 5b9fb41cc715
Successfully tagged busybox:autobuild
发送的文件直接大下变成了2.048kB,小了很多。
2 镜像的存储
2.1 镜像的组成
Docker镜像是由一组只读的镜像层Image Layer组成的。而Docker 容器则是在Docker 镜像的基础之上,增加了一层:容器层Container Layer。容器层Container Layer是可读写的。如果对该容器层Container Layer进行commit提交操作,该层就变成了新的镜像层Image Layer。新的Docker Image也就构建出来了。
以下官网提供的图示可以很清楚的看出镜像与容器之间的联系与区别:
具体某个镜像的组成Layer可以通过如下命令进行查询:
## 镜像的构建层历史
➜ auto_build docker history busybox:autobuild
IMAGE CREATED CREATED BY SIZE COMMENT
5b9fb41cc715 18 minutes ago /bin/sh -c echo "print('hello world')" > foo… 21B
ff4a8eb070e1 2 weeks ago /bin/sh -c #(nop) CMD ["sh"] 0B
<missing> 2 weeks ago /bin/sh -c #(nop) ADD file:d3b19a5aa5d866139… 1.24MB
不难看出,镜像busybox:autobuild一共执行了从底往上的三次层构建。具体构建的指令可以通过第三列的命令得出。的意思是:该层是在其它系统上构建的,在本地是不可用的。只需要忽略就好。
2.2 Union FileSystem
要了解 Docker 镜像的存储首先必须了解联合文件系统 UnionFS (Union File System),所谓UnionFS就是把不同物理位置的目录合并mount到同一个目录中。UnionFS的具体实现有很多种:
- 早期的UFS
- AUFS
- OverlayFS
- overlay
- overlay2
overlay2是一种更现代的联合文件系统 UnionFS,它比overlay的早期版本在稳定与性能上都有很大提升。所以一般最新的Docker采用的存储驱动使用的都是overlay2。
➜ ~ cat /proc/filesystems | grep overlay
nodev overlay
wsl2是支持UnionFS文件系统的,使用的是overlay存储驱动。 既然UnionFS就是把不同物理位置的目录合并mount到同一个目录中.现在我们通过命令行的方式实现一下Docker官网提供UnionFS的原理图。
从图中可以看出,我们需要提供两个目录,分别代表Container Layer和Image Layer。目录名称,取图示右部的名称:
目录upper, 代表Container Layer
目录lower, 代表Image Layer 除了这两个目录以外,通过UnionFS挂载目录还需要两个目录:
目录merged, 代表挂载目录,即合并后的目录
目录work, 必须为空目录,是overlay存储驱动挂载所需的工作目录。
通过命令行实现图示中的文件夹结构:
### 创建一个测试目录
➜ auto_build mkdir demo && cd demo
### 创建子目录与文件
➜ demo mkdir lower upper merged work
➜ demo touch lower/file1 lower/file2 lower/file3
➜ demo touch upper/file2 upper/file4
### 通过文件内容区分以下file2
➜ demo echo lower > lower/file2
➜ demo echo upper > upper/file2
## 未挂载
➜ demo ls merged
迄今为止,一切都是常规文件系统操作。现在通过mount命令进行UnionFS文件系统的目录挂载.
### 目录合并挂载到merged
➜ demo sudo mount -t overlay overlay -olowerdir=lower,upperdir=upper,workdir=work merged
## 挂载完成后
➜ demo ls merged
file1 file2 file3 file4
### file2 使用的是顶层 upper 的file2 文件
➜ demo cat merged/file2
upper
下面再分别通过文件的增删改加深对UnionFS文件系统的理解:
- 新增文件
### 新增文件
➜ demo touch merged/file5
➜ demo ls merged
file1 file2 file3 file4 file5
## 新增文件写在顶层的 upper 文件夹
➜ demo ls lower
file1 file2 file3
➜ demo ls upper
file2 file4 file5
- 修改文件
### 修改文件 CoW 技术
➜ demo echo modfile > merged/file1
➜ demo ls upper
file1 file2 file4 file5
➜ demo cat upper/file1
modfile
- 删除文件
### 删除文件
➜ demo rm merged/file1
➜ demo ls -al upper | grep file1
c--------- 2 root root 0, 0 Oct 21 13:52 file1
➜ demo ls -al lower | grep file1
-rw-r--r-- 1 root root 0 Oct 21 13:40 file1
实际操作完成以上过程,相信你对于UnionFS文件系统有了更加直观的感受。你可能会问, Docker Image的底层镜像是由一组Layer组成的,多个底层目录在UnionFS中如何挂载?其实很简单,只需要通过:分隔即可。
### 多层目录: lower1 / lower2 / lower3
➜ sudo mount -t overlay overlay -olowerdir=lower1:lower2:lower3,upperdir=upper,workdir=work merged
2.3 镜像的存储
现在系统上的docker镜像
➜ demo docker images -a
REPOSITORY TAG IMAGE ID CREATED SIZE
busybox autobuild 5b9fb41cc715 About an hour ago 1.24MB
busybox autobuild2 5b9fb41cc715 About an hour ago 1.24MB
busybox manual 2cc6802449e0 2 hours ago 1.24MB
busybox latest ff4a8eb070e1 2 weeks ago 1.24MB
hello-world latest feb5d9fea6a5 13 months ago 13.3kB
我们查看以下现在查看一下文件的挂载情况
### 无容器运行时
➜ ~ mount | grep docker
/dev/sdb on /var/lib/docker type ext4 (rw,relatime,discard,errors=remount-ro,data=ordered)
### docker 运行容器时
➜ ~ mount | grep docker
/dev/sdb on /var/lib/docker type ext4 (rw,relatime,discard,errors=remount-ro,data=ordered)
overlay on /var/lib/docker/overlay2/de1eb5af983d8ac7d5fb010763d4f423ac0ea8710487019b17154fbe7a8732f9/merged type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/l/5XGRYNQRBWFO6J6BHYSW5I7VZ5:/var/lib
docker/overlay2/l/ACEJFK2D4PHFOVKOA6VQFOQBZA:/var/lib/docker/overlay2/l/UGNEFEM6RIPKAPCKT5IMNZMCMC,upperdir=/var/lib/docker/overlay2/de1eb5af983d8ac7d5fb010763d4f423ac0ea8710487019b17154fbe7a8732f9/diff,workdir=/var/lib/docker/overlay2/de1eb5af983d8ac7d5fb010763d4f423ac0ea8710487019b17154fbe7a8732f9/work)
nsfs on /run/docker/netns/1be5c09fb038 type nsfs (rw)
对比输出,能够很明显的看到,暂仅关注 overlay 挂载情况。得出:
- 挂载后的目录是:
/var/lib/docker/overlay2/de1eb5af983d8ac7d5fb010763d4f423ac0ea8710487019b17154fbe7a8732f9/merged
- 容器Layer是:
/var/lib/docker/overlay2/de1eb5af983d8ac7d5fb010763d4f423ac0ea8710487019b17154fbe7a8732f9/diff
- 镜像Layer是:
- /var/lib/docker/overlay2/l/5XGRYNQRBWFO6J6BHYSW5I7VZ5
- /var/libdocker/overlay2/l/ACEJFK2D4PHFOVKOA6VQFOQBZA
- /var/lib/docker/overlay2/l/UGNEFEM6RIPKAPCKT5IMNZMCMC
其中镜像Layer使用的是软连接。同样的信息,我们可以通docker inspect查询出来。
$: docker inspect <container-id> -f '{{.GraphDriver.Data.MergedDir}}'
$: docker inspect <container-id> -f '{{.GraphDriver.Data.UpperDir}}'
$: docker inspect <container-id> -f '{{.GraphDriver.Data.LowerDir}}'
输出的路径就是具体Docker镜像的存储位置。
3.最小化镜像
3.1 为什么最小化
最小化 Docker 镜像的原因可总结出以下几条:
- 省钱,减少网络传输流量,节省镜像存储空间
- 省时,加速镜像部署时间
- 安全,有限功能降低被攻击的可能性
- 环保,垃圾都分类了,浪费资源可耻
3.2 如何最小化构建
按 1.3、1.4 中所讨论的镜像的组成原理与存储, 最小化 Docker 镜像的主要途径总结下来也就两条:
- 缩减镜像的大小
- 减少镜像的层数
3.3 减少镜像层数
3.3.1 组合命令
在定义Dockerfile的时候,每一条指令都会对应一个新的镜像层。通过docker history命令就可以查询出具体Docker 镜像构建的层以及每层使用的指令。为了减少镜像的层数,在实际构建镜像时,通过使用&&连接命令的执行过程,将多个命令定义到一个构建指令中执行。如:
FROM debian:stable
WORKDIR /var/www
RUN apt-get update && \
apt-get -y --no-install-recommends install curl \
ca-certificates && \
apt-get purge -y curl \
ca-certificates && \
apt-get autoremove -y && \
apt-get clean
3.3.2 压缩镜像层
除了通过将多命令通过&&连接到一个构建指令外,在Docker镜像的构建过程中,还可以通过–squash的方式,开启镜像层的压缩功能,将多个变化的镜像层,压缩成一个新的镜像层。
具体命令就如下:
$: docker build --squash -t <image> .
3.4 缩减镜像层大小
3.4.1 选择基础镜像
缩减Layer的大小需要从头开始,即选择什么样的基础镜像作为初始镜像。一般情况下,大家都会从以下三个基础镜像开始。
- 镜像 scratch(空镜像), 大小 0B
- 镜像 busybox(空镜像 + busybox), 大小 1.4MB
- 镜像 alpine (空镜像 + busybox + apk), 大小 3.98MB
镜像 busybox 通过busybox程序提供一些基础的Linux系统操作命令,镜像 alpine则是在次基础上提供了apk包管理命令,方便安装各类工具及依赖包。广泛使用的镜像基本都是镜像 alpine。镜像 busybox更适合一些快速的实验场景。而镜像 scratch空镜像,因为不提供任何辅助工具,对于不依赖任何第三方库的程序是合适的。因为镜像 scratch空镜像本身不提供任何container OS,所以程序是运行在Docker Host即宿主机上的,只是利用了Docker技术提供的隔离技术而已。
3.4.2 多阶段构建镜像
多阶段构建 Multi-Stage Build 是 **Docker 17.05 版本开始引入的新特性。通过将原先仅一个阶段构建的镜像查分成多个阶段。之所以多阶段构建镜像能够缩减镜像的大小,是因为发布程序在编译期相关的依赖包以及临时文件并不是最终发布镜像所需要的。通过划分不同的阶段,构建不同的镜像,最终镜像则取决于我们真正需要发布的实体是什么。
# Build Stage
FROM golang:1.17.3-alpine3.14 AS builder
WORKDIR /app
COPY . .
RUN go build -o main main.go
RUN apk --no-cache add curl
RUN curl -L https://github.com/golang-migrate/migrate/releases/download/v4.14.1/migrate.linux-amd64.tar.gz | tar xvz
# Run Stage
FROM alpine:3.14
WORKDIR /app
COPY --from=builder /app/main .
COPY --from=builder /app/migrate.linux-amd64 ./migrate
COPY app.env .
COPY start.sh .
COPY wait-for.sh .
COPY db/migration ./migration
EXPOSE 8080
CMD ["/app/main"]
ENTRYPOINT ["/app/start.sh"]
如上的Dockerfile就是多阶段构建,在builder阶段使用的基础镜像是 golang:1.17.3-alpine3.14,显然是因为编译期的需要,对于发布真正的server程序是完全没必要的。通过多阶段构建镜像的方式就可以仅仅打包需要的实体构成镜像。
除了多阶段构建以外,如果你还想忽略镜像中一些冗余文件,还可以通过.dockerignore的方式在文件中定义出来。功能和.gitignore类似。
4. 加固镜像
最小化Docker 镜像的构建完成了,但是,我们的工作却仍未结束。我们还需要对镜像进行加固处理。
4.1 可寻址标识符
镜像内容可寻址标识符(Content addressable image identifiers), 可以对来源基础镜像内容进行校验,确保没有被第三方篡改。具体的操作方式,就是在构建自己镜像的同时,对基础镜像内容进行内容的sha256摘要值进行设置,防止在不知情的情况下被篡改。
首先,得出具体镜像的正确sha256摘要值.
➜ ~ docker inspect busybox:latest -f "{{.RepoDigests}}"
[busybox@sha256:9810966b5f712084ea05bf28fc8ba2c8fb110baa2531a10e2da52c1efc504698]
在编写Dockerfile时,设置基础镜像的sha256摘要值
FROM busybox@sha256:9810966b5f712084ea05bf28fc8ba2c8fb110baa2531a10e2da52c1efc504698
...
注意:镜像内容可寻址标识符的获取必须经过一次 push 或者 pull 操作,即在镜像注册服务上发布后,才可以通过以上 inspect 命令查询出结果。如果仅仅是本地的镜像,无法通过 inpect 命令获取。当然仅仅是本地使用的镜像,镜像内容可寻址标识符也是没必要的。
4.2 用户权限
容器一旦创建出来,其默认使用的用户是可以在镜像中进行设置的。通过设置必要的镜像默认用户,可以限制其在容器中的执行权限。在某种程度上也就进行提升了镜像的安全级别。不过,这需要根据具体的业务发布情况进行设置,常规情况下,基础镜像都还是root用户作为默认用户 。
安全原则:构建镜像本身是为了特定的应用定制的,默认情况下应该尽可能的降低用户权限。
4.3 SUID与SGID问题
除了镜像本身设置必要的默认用户以外,在镜像中,还会存在一类程序,即使是通过普通用户执行,但在运行时会以更高级别的权限执行。就是系统针对可执行文件与目录提供的SUID与SGID特殊权限。
通过对可执行文件设置SUID或SGID属性,原本执行命令的用户会切换成为命令的所有者或是所属组的权限进行执行。也就是提升了执行命令的权限。
在实际的镜像构建中,应该尽可能的避免此类权限提升造成的可能的漏洞。建议镜像构建时,扫描镜像内是否存在此类执行文件,如果存在尽可能的删除。删除命令可参考:
## 镜像构建过程中增加对特殊权限可执行文件的扫描并删除
RUN for i in $(find / -type f \( -perm +6000 -o -perm +2000 \)); \
do chmod ug-s $i; done
5. 审查 Docker 镜像
正如Code Review一样,代码审查可以大大提升企业项目的质量。容器镜像同样作为开发人员或是运维人员的产出物,对其进行审查也是必要的。
虽然我们可以通过docker命令结合文件系统浏览的方式进行容器镜像的审查,但其过程需要人工参与,很难做到自动化,更别提将镜像审查集成到CI过程中了。但一个好的工具可以帮我们做到这点。
可用通过dive,方便我们查询具体镜像层的详细信息,还可以作为CI持续集成过程中的镜像审查之用。使用它可以大大提升我们审查镜像的速度,并且可以将这个过程做成自动化。
该项目的具体动态操作图示如下:
查看busybox:autobuild的具体信息
➜ ~ dive busybox:autobuild