容器镜像内部并不是一个平坦的结构,而是由许多的镜像层组成的,每层都是只读不可修改的一组文件,相同的层可以在镜像之间共享,然后多个层像搭积木一样堆叠起来,再使用一种叫“Union
FS 联合文件系统”的技术把它们合并在一起,就形成了容器最终看到的文件系统。

我们可以用命令 docker inspect 来查看镜像的分层信息,比如 nginx:alpine 镜像:

1
docker inspect nginx:alpine

它的分层信息在“RootFS”部分:

1
2
3
4
5
6
7
8
9
10
11
"RootFS":{
"Type": "layers",
"Layers": [
"sha256:8d3ac3489996423f53d6087c81180006263b79f206d3fdec9e66f0e27ceb8759",
"sha256:1c9c1e42aafaa6067e6591a2b3214b4d6c6c357b597377bd395486d9999209cd",
"sha256:3f87f0a06073927060d269480f2c55cd4ac978732291be1e900a0e834dbc0db0",
"sha256:5ee3266a70bd586778d7d3dc36fd1dbc2ab407d559615a1c3508697eac86142f",
"sha256:0e835d02c1b5a93d74af44edda6dc7b36938429847b13d0553a5ba4adab1e686",
"sha256:419df8b600324530e8b64cabca7b0e6b959efc8d41fb2b8253014a4099747501"
]
},

Dockerfile 是什么
知道了容器镜像的内部结构和基本原理,我们就可以来学习如何自己动手制作容器镜像了,也就是自己打包应用。

比起容器、镜像来说,Dockerfile 它就是一个纯文本,里面记录了一系列的构建指令,比如选择基础镜像、拷贝文件、运行脚本等等,每个指令都会生成一个
Layer,而 Docker 顺序执行这个文件里的所有步骤,最后就会创建出一个新的镜像出来。

我们来看一个最简单的 Dockerfile 实例:

1
2
3
# Dockerfile.busybox
FROM busybox # 选择基础镜像
CMD echo "hello world" # 启动容器时默认运行的命令

这个文件里只有两条指令。

第一条指令是 FROM,所有的 Dockerfile 都要从它开始,表示选择构建使用的基础镜像,这里我们使用的是 busybox。

第二条指令是 CMD,它指定 docker run 启动容器时默认运行的命令,这里我们使用了 echo 命令,输出“hello world”字符串。

接下来,用 docker build 命令来创建出镜像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
docker build -f Dockerfile.busybox .
[+] Building 16.6s (5/5) FINISHED docker:desktop-linux
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 110B 0.0s
=> [internal] load metadata for docker.io/library/busybox:latest 16.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [1/1] FROM docker.io/library/busybox:latest@sha256:5acba83a746c7608ed544dc1533b87c737a0b0fb730301639a0179f9344b1678 0.5s
=> => resolve docker.io/library/busybox:latest@sha256:5acba83a746c7608ed544dc1533b87c737a0b0fb730301639a0179f9344b1678 0.0s
=> => sha256:5acba83a746c7608ed544dc1533b87c737a0b0fb730301639a0179f9344b1678 2.29kB / 2.29kB 0.0s
=> => sha256:62ffc2ed7554e4c6d360bce40bbcf196573dd27c4ce080641a2c59867e732dee 527B / 527B 0.0s
=> => sha256:beae173ccac6ad749f76713cf4440fe3d21d1043fe616dfbe30775815d1d0f6a 1.46kB / 1.46kB 0.0s
=> => sha256:5cc84ad355aaa64f46ea9c7bbcc319a9d808ab15088a27209c9e70ef86e5a2aa 772.79kB / 772.79kB 0.4s
=> => extracting sha256:5cc84ad355aaa64f46ea9c7bbcc319a9d808ab15088a27209c9e70ef86e5a2aa 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:3463e1457f0a894810ee28b2fe35211965d9cf136e079f146a02ba26ab241471 0.0s

View build details: docker-desktop://dashboard/build/desktop-linux/desktop-linux/sqt2i6p9rlc8xh2c68dzv3v3c

你需要特别注意命令的格式,用 -f 参数指定 Dockerfile 文件名,后面必须跟一个文件路径,叫做“构建上下文”(build’s
context),这里只是一个简单的点号,表示当前路径的意思。

接下来,你就会看到 Docker 会逐行地读取并执行 Dockerfile 里的指令,依次创建镜像层,再生成完整的镜像。

新的镜像暂时还没有名字(用 docker images 会看到是 ),但我们可以直接使用“IMAGE ID”来查看或者运行:

1
2
3
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
<none> <none> 3463e1457f0a 2 years ago 1.24MB
1
2
docker inspect 3463e1457f0a
docker run -it 3463e1457f0a

怎样编写正确、高效的 Dockerfile

大概了解了 Dockerfile 之后,我再来讲讲编写 Dockerfile 的一些常用指令和最佳实践,帮你在今后的工作中把它写好、用好。

首先因为构建镜像的第一条指令必须是 FROM,所以基础镜像的选择非常关键。

如果关注的是镜像的安全和大小,那么一般会选择 Alpine;

如果关注的是应用的运行稳定性,那么可能会选择 CentOS、Ubuntu、Debian。

1
2
FROM alpine:3.15                # 选择Alpine镜像
FROM centos:7 # 选择CentOS镜像

我们在本机上开发测试时会产生一些源码、配置等文件,需要打包进镜像里,这时可以使用 COPY 命令,它的用法和 Linux 的 cp
差不多,不过拷贝的源文件必须是“构建上下文”路径里的,不能随意指定文件。也就是说,如果要从本机向镜像拷贝文件,就必须把这些文件放到一个专门的目录,然后在
docker build 里指定“构建上下文”到这个目录才行。

这里有两个 COPY 命令示例,你可以看一下:

1
2
COPY ./index.html  /var/www/html/index.html   # 把构建上下文里的index.html拷贝到镜像的/var/www/html目录
COPY /etc/hosts /tmp # 错误!不能使用构建上下文之外的文件

接下来要说的就是 Dockerfile 里最重要的一个指令 RUN ,它可以执行任意的 Shell
命令,比如更新系统、安装应用、下载文件、创建目录、编译程序等等,实现任意的镜像构建步骤,非常灵活。

RUN 通常会是 Dockerfile 里最复杂的指令,会包含很多的 Shell 命令,但 Dockerfile 里一条指令只能是一行,所以有的 RUN
指令会在每行的末尾使用续行符 \,命令之间也会用 && 来连接,这样保证在逻辑上是一行,就像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
RUN yum update \
&& yum install -y \
curl \
make \
vim \
&& cd /tmp \
&& curl -fSL xxx.tar.gz -o xxx.tar.gz\
&& tar xzf xxx.tar.gz \
&& cd xxx \
&& ./config \
&& make \
&& make install

有的时候在 Dockerfile 里写这种超长的 RUN 指令很不美观,而且一旦写错了,每次调试都要重新构建也很麻烦,所以你可以采用一种变通的技巧:把这些
Shell 命令集中到一个脚本文件里,用 COPY 命令拷贝进去再用 RUN 来执行:

1
2
3
4
COPY setup.sh  /tmp/                # 拷贝脚本到/tmp目录

RUN cd /tmp && chmod +x setup.sh \ # 添加执行权限
&& ./setup.sh && rm setup.sh # 运行脚本然后再删除

RUN 指令实际上就是 Shell 编程,如果你对它有所了解,就应该知道它有变量的概念,可以实现参数化运行,这在 Dockerfile
里也可以做到,需要使用两个指令 ARG 和 ENV。

它们区别在于 ARG 创建的变量只在镜像构建过程中可见,容器运行时不可见,而 ENV 创建的变量不仅能够在构建镜像的过程中使用,在容器运行时也能够以环境变量的形式被应用程序使用。

下面是一个简单的例子,使用 ARG 定义了基础镜像的名字(可以用在“FROM”指令里),使用 ENV 定义了两个环境变量:

1
2
3
4
5
ARG IMAGE_BASE="nginx"
ARG IMAGE_TAG="alpine"

ENV PATH=$PATH:/tmp
ENV DEBUG=OFF

还有一个重要的指令是 EXPOSE,它用来声明容器对外服务的端口号,对现在基于 Node.js、Nginx、Go 等开发的微服务系统来说非常有用:

1
2
EXPOSE 443           # 默认是tcp协议
EXPOSE 8080/udp # 可以指定udp协议

我还要特别强调一下,因为每个指令都会生成一个镜像层,所以 Dockerfile 里最好不要滥用指令,尽量精简合并,否则太多的层会导致镜像臃肿不堪。