学习了容器化的应用,也就是被打包成镜像的应用程序,然后再用各种 Docker 命令来运行、管理它们。
这又会带来一个疑问:这些镜像是怎么创建出来的?我们能不能够制作属于自己的镜像呢?
本次要学习的是镜像的内部机制,还有高效、正确地编写 Dockerfile 制作容器镜像的方法。

镜像的内部机制是什么?

我们知道了镜像其实就是一个打包文件,里面包含了应用程序还有它运行所依赖的环境,例如文件系统、环境变量、配置参数等等。
环境变量和配置参数比较好管理,真正麻烦的是文件系统,为了保证运行环境的一致性,镜像必须把应用程序所在的根目录,也就是rootfs,都包含进来。
虽然这些文件里不包含系统内核(因为容器共享了宿主机的内核),但如果有一千个镜像,都基于 Ubuntu 系统打包,那么这些镜像里就会重复一千次 Ubuntu 根目录,对磁盘存储、网络传输都是很大的浪费和冗余。
由此我们自然会想到,应该把重复的部分抽出来,只存一份 Ubuntu 根目录文件,然后让这一千个镜像以某种方式共享这部分数据。
这个思路,也正是容器镜像的一个重大创新点:分层机制,术语叫“Layer”。

分层“Layer”

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

查看镜像的分层信息

  1. docker inspect nginx:alpine # 查看 nginx:alpine 镜像的分层信息

它的分层信息在“RootFS”部分,一共有6个 Layer:
image.png
之前在使用 docker pull、docker rmi 等命令操作镜像输出的信息,其实就是镜像里的各个 Layer。Docker 会检查是否有重复的层,如果本地已经存在就不会重复下载,如果层被其他镜像共享就不会删除,这样就可以节约磁盘和网络成本。

Dockerfile

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

一个Dockerfile 实例:

  1. # Dockerfile.busybox
  2. FROM busybox # 选择基础镜像
  3. CMD echo "hello world" # 启动容器时默认运行的命令
  • 第一条指令是 FROM,所有的 Dockerfile 都要从它开始,表示选择构建使用的基础镜像,相当于“打地基”,这里我们使用的是 busybox。
  • 第二条指令是 CMD,它指定 docker run 启动容器时默认运行的命令,这里我们使用了 echo 命令,输出“hello world”字符串。

    docker build构建镜像:

    ```shell docker build -f Dockerfile.busybox .

Sending build context to Docker daemon 7.68kB Step 1/2 : FROM busybox —-> d38589532d97 Step 2/2 : CMD echo “hello world” —-> Running in c5a762edd1c8 Removing intermediate container c5a762edd1c8 —-> b61882f42db7 Successfully built b61882f42db7

  1. **需要特别注意命令的格式,用 -f 参数指定 Dockerfile 文件名,后面必须跟一个文件路径,叫做“构建上下文”(builds context)**,这里只是一个简单的点号,表示当前路径的意思。<br />接下来,你就会看到 Docker 会逐行地读取并执行 Dockerfile 里的指令,依次创建镜像层,再生成完整的镜像。<br />新的镜像暂时还没有名字(用 docker images 会看到是 <none>),但我们可以直接使用“IMAGE ID”来查看或者运行:
  2. ```shell
  3. docker inspect b61
  4. docker run b61

怎样编写正确、高校的Dockerfile

基础镜像的选择:

构建镜像的第一条指令必须是 FROM,所以基础镜像的选择非常关键。如果关注的是镜像的安全和大小,那么一般会选择 Alpine;如果关注的是应用的运行稳定性,那么可能会选择 Ubuntu、Debian、CentOS。

  1. FROM alpine:3.15 # 选择 alpine 镜像
  2. FROM ubuntu:bionic # 选择 ubuntu 镜像

COPY命令:

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

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

RUN命令:

Dockerfile 里最重要的一个指令 RUN ,它可以执行任意的 Shell 命令,比如更新系统、安装应用、下载文件、创建目录、编译程序等等,实现任意的镜像构建步骤,非常灵活。
RUN 通常会是 Dockerfile 里最复杂的指令,会包含很多的 Shell 命令,但 Dockerfile 里一条指令只能是一行,所以有的 RUN 指令会在每行的末尾使用续行符 \,命令之间也会用 && 来连接,这样保证在逻辑上是一行,比如:

  1. RUN apt-get update \
  2. && apt-get install -y \
  3. build-essential \
  4. curl \
  5. make \
  6. unzip \
  7. && cd /tmp \
  8. && curl -fSL xxx.tar.gz -o xxx.tar.gz\
  9. && tar xzf xxx.tar.gz \
  10. && cd xxx \
  11. && ./config \
  12. && make \
  13. && make clean

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

  1. COPY setup.sh /tmp/ # 拷贝脚本到/tmp目录
  2. RUN cd /tmp && chmod +x setup.sh \ # 添加执行权限
  3. && ./setup.sh && rm setup.sh # 运行脚本然后再删除

RUN 指令实际上就是 Shell 编程,如果你对它有所了解,就应该知道它有变量的概念,可以实现参数化运行,这在 Dockerfile 里也可以做到,需要使用两个指令 ARG 和 ENV。
它们区别在于 ARG 创建的变量只在镜像构建过程中可见,容器运行时不可见,而 ENV 创建的变量不仅能够在构建镜像的过程中使用,在容器运行时也能够以环境变量的形式被应用程序使用。
使用 ARG 定义了基础镜像的名字(可以用在“FROM”指令里),使用 ENV 定义了两个环境变量:

  1. ARG IMAGE_BASE="node"
  2. ARG IMAGE_TAG="alpine"
  3. ENV PATH=$PATH:/tmp
  4. ENV DEBUG=OFF

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

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

注意点:因为每个指令都会生成一个镜像层,所以 Dockerfile 里最好不要滥用指令,尽量精简合并,否则太多的层会导致镜像臃肿不堪。

docker build 是怎么工作的?

Dockerfile 必须要经过 docker build 才能生效,所以我们再来看看 docker build 的详细用法。

“构建上下文”

用 Docker 的官方架构图来理解会比较清楚(注意图中与“docker build”关联的虚线)。
因为命令行“docker”是一个简单的客户端,真正的镜像构建工作是由服务器端的“Docker daemon”来完成的,所以“docker”客户端就只能把“构建上下文”目录打包上传(显示信息 Sending build context to Docker daemon ),这样服务器才能够获取本地的这些文件。
image.png
“构建上下文”其实与 Dockerfile 并没有直接的关系,它其实指定了要打包进镜像的一些依赖文件。而 COPY 命令也只能使用基于“构建上下文”的相对路径,因为“Docker daemon”看不到本地环境,只能看到打包上传的那些文件。

.dockerignore文件

但这个机制也会导致一些麻烦,如果目录里有的文件(例如 readme/.git/.svn 等)不需要拷贝进镜像,docker 也会一股脑地打包上传,效率很低。
为了避免这种问题,你可以在“构建上下文”目录里再建立一个 .dockerignore 文件,语法与 .gitignore 类似,排除那些不需要的文件。下面是一个简单的示例,表示不打包上传后缀是“swp”“sh”的文件:

  1. # docker igore
  2. *.swp
  3. *.sh

另外关于 Dockerfile,一般应该在命令行里使用 -f 来显式指定。但如果省略这个参数,docker build 就会在当前目录下找名字是 Dockerfile 的文件。所以,如果只有一个构建目标的话,文件直接叫“Dockerfile”是最省事的。

指定镜像的标签(tag)

构建出来的镜像只有“IMAGE ID”没有名字,不是很方便。可以加上一个 -t 参数,也就是指定镜像的标签(tag),这样 Docker 就会在构建完成后自动给镜像添加名字。当然,名字必须要符合上节课里的命名规范,用 : 分隔名字和标签,如果不提供标签默认就是“latest”。