【云原生丨Docker系列4】用Dockerfile脚本定制镜像(最优解)

前言

镜像的定制实际上就是定制每⼀层所添加的配置、⽂件等信息。

但是命令毕竟只是命令,一般用 docker commit 每次定制都得去重复执⾏这个命令,⽽且还不够直观,如果我们可以把每⼀层修改、安装、构建、操作的命令都写⼊⼀个脚本,⽤这个脚本来构建、定制镜像,那么这些问题就迎刃而解了,而这个脚本就是我们今天要说的 Dockerfile


【云原生丨Docker系列4】用Dockerfile脚本定制镜像(最优解)_第1张图片目录

一、Dockerfile介绍

二、FROM指定基础镜像

三、RUN执行命令

四、构建镜像

五、镜像构建上下文(Context)

✊六、迁移镜像


一、Dockerfile介绍

Dockerfile 是⼀个⽂本⽂件,其内包含了⼀条条的指令(Instruction),每⼀条指令构建⼀层,因此每⼀条指令的内容,就是描述该层应当如何构建。

还以之前定制 nginx 镜像为例,这次我们使⽤ Dockerfile 来定制。在⼀个空⽩⽬录中,建⽴⼀个⽂本 ⽂件,并命名为 Dockerfile: 

$ mkdir mynginx 
$ cd mynginx 
$ touch Dockerfile

其内容为:

FROM nginx 
RUN echo '

Hello, Docker!

' > /usr/share/nginx/html/index.html

 这个 Dockerfile 很简单,⼀共就两⾏。涉及到了两条指令,FROM 和 RUN。


二、FROM指定基础镜像

所谓定制镜像,那⼀定是以⼀个镜像为基础,在其上进⾏定制。就像我们之前运⾏了⼀个 nginx 镜像 的容器,再进⾏修改⼀样,基础镜像是必须指定的。⽽ FROM 就是指定基础镜像,因此⼀个 Dockerfile 中 FROM 是必备的指令,并且必须是第⼀条指令。

在Docker Store上有⾮常多的⾼质量的官⽅镜像,有可以直接拿来使⽤的服务类的镜像,如 nginx、 redis、mongo、mysql、httpd、php、tomcat 等;也有⼀些⽅便开发、构建、运⾏各种语⾔应⽤的镜 像,如 node、openjdk、python、ruby、golang 等。可以在其中寻找⼀个最符合我们最终⽬标的镜像 为基础镜像进⾏定制。

如果没有找到对应服务的镜像,官⽅镜像中还提供了⼀些更为基础的操作系统镜像,如 ubuntu、 debian、centos、fedora、alpine 等,这些操作系统的软件库为我们提供了更⼴阔的扩展空间。

除了选择现有镜像为基础镜像外,Docker 还存在⼀个特殊的镜像,名为 scratch 。这个镜像是虚拟的 概念,并不实际存在,它表示⼀个空⽩的镜像。

FROM scratch 
...

如果你以 scratch 为基础镜像的话,意味着你不以任何镜像为基础,接下来所写的指令将作为镜像第 ⼀层开始存在。有的同学可能感觉很奇怪,没有任何基础镜像,我怎么去执⾏我的程序呢,其实对于 Linux 下静态编译的程序来说,并不需要有操作系统提供运⾏时⽀持,所需的⼀切库都已经在可执⾏⽂ 件⾥了,因此直接 FROM scratch 会让镜像体积更加⼩巧。使⽤ Go 语⾔ 开发的应⽤很多会使⽤这种⽅ 式来制作镜像,这也是为什么有⼈认为 Go 是特别适合容器微服务架构的语⾔的原因之⼀。


三、RUN执行命令

RUN 指令是⽤来执⾏命令⾏命令的。由于命令⾏的强⼤能⼒, RUN 指令在定制镜像时是最常⽤的指令 之⼀。其格式有两种:
shell 格式:RUN <命令>,就像直接在命令⾏中输⼊的命令⼀样。刚才写的 Dockerfile 中的 RUN 指令就是这种格式。

RUN echo '

Hello, Docker!

' > /usr/share/nginx/html/index.html

exec 格式:RUN ["可执⾏⽂件", "参数1", "参数2"],这更像是函数调⽤中的格式。 既然 RUN 就像 Shell 脚本⼀样可以执⾏命令,那么我们是否就可以像 Shell 脚本⼀样把每个命令对应⼀个 RUN 呢?⽐如这样:

FROM debian:jessie 
RUN apt-get update 
RUN apt-get install -y gcc libc6-dev make 
RUN wget -O redis.tar.gz "http://download.redis.io/releases/redis-3.2.5.tar.gz" 
RUN mkdir -p /usr/src/redis 
RUN tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 
RUN make -C /usr/src/redis 
RUN make -C /usr/src/redis install

之前说过,Dockerfile 中每⼀个指令都会建⽴⼀层,RUN 也不例外。每⼀个 RUN 的⾏为,就和刚才 我们⼿⼯建⽴镜像的过程⼀样:新建⽴⼀层,在其上执⾏这些命令,执⾏结束后,commit 这⼀层的修改,构成新的镜像。 

⽽上⾯的这种写法,创建了 7 层镜像。这是完全没有意义的,⽽且很多运⾏时不需要的东⻄,都被装 进了镜像⾥,⽐如编译环境、更新的软件包等等。结果就是产⽣⾮常臃肿、⾮常多层的镜像,不仅仅 增加了构建部署的时间,也很容易出错。 这是很多初学 Docker 的⼈常犯的⼀个错误。

Union FS 是有最⼤层数限制的,⽐如 AUFS,曾经是最⼤不得超过 42 层,现在是不得超过 127 层。

 上⾯的 Dockerfile 正确的写法应该是这样:

FROM debian:jessie 
RUN buildDeps='gcc libc6-dev make' \ 
&& apt-get update \ 
&& apt-get install -y $buildDeps \ 
&& wget -O redis.tar.gz "http://download.redis.io/releases/redis-3.2.5.tar.gz" \ 
&& mkdir -p /usr/src/redis \ 
&& tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \ 
&& make -C /usr/src/redis \ 
&& make -C /usr/src/redis install \
&& rm -rf /var/lib/apt/lists/* \ 
&& rm redis.tar.gz \ 
&& rm -r /usr/src/redis \ 
&& apt-get purge -y --auto-remove $buildDeps

⾸先,之前所有的命令只有⼀个⽬的,就是编译、安装 redis 可执⾏⽂件。因此没有必要建⽴很多层, 这只是⼀层的事情。因此,这⾥没有使⽤很多个 RUN 对⼀⼀对应不同的命令,⽽是仅仅使⽤⼀个 RUN 指令,并使⽤ && 将各个所需命令串联起来。将之前的 7 层,简化为了 1 层。在撰写 Dockerfile 的时候,要经常提醒⾃⼰,这并不是在写 Shell 脚本,⽽是在定义每⼀层该如何构建。

并且,这⾥为了格式化还进⾏了换⾏。Dockerfile ⽀持 Shell 类的⾏尾添加 \ 的命令换⾏⽅式,以及 ⾏⾸ # 进⾏注释的格式。良好的格式,⽐如换⾏、缩进、注释等,会让维护、排障更为容易,这是⼀ 个⽐较好的习惯。

此外,还可以看到这⼀组命令的最后添加了清理⼯作的命令,删除了为了编译构建所需要的软件,清 理了所有下载、展开的⽂件,并且还清理了 apt 缓存⽂件。这是很重要的⼀步,我们之前说过,镜像 是多层存储,每⼀层的东⻄并不会在下⼀层被删除,会⼀直跟随着镜像。因此镜像构建时,⼀定要确 保每⼀层只添加真正需要添加的东⻄,任何⽆关的东⻄都应该清理掉。 很多⼈初学 Docker 制作出了 很臃肿的镜像的原因之⼀,就是忘记了每⼀层构建的最后⼀定要清理掉⽆关⽂件。


四、构建镜像

好了,让我们再回到之前定制的 nginx 镜像的 Dockerfile 来。现在我们明⽩了这个 Dockerfile 的内 容,那么让我们来构建这个镜像吧。在 Dockerfile ⽂件所在⽬录执⾏:

$ docker build -t nginx:v3 . 
Sending build context to Docker daemon 2.048 kB 
Step 1 : FROM nginx 
---> e43d811ce2f4 
Step 2 : RUN echo '

Hello, Docker!

' > /usr/share/nginx/html/index.html ---> Running in 9cdc27646c7b ---> 44aa4490ce2c Removing intermediate container 9cdc27646c7b Successfully built 44aa4490ce2c

从命令的输出结果中,我们可以清晰的看到镜像的构建过程。在 Step 2 中,如同我们之前所说的那 样,RUN 指令启动了⼀个容器 9cdc27646c7b,执⾏了所要求的命令,并最后提交了这⼀层 44aa4490ce2c,随后删除了所⽤到的这个容器 9cdc27646c7b。这⾥我们使⽤了 docker build 命令 进⾏镜像构建。其格式为:

$ docker build [选项] <上下⽂路径/URL/->

在这⾥我们指定了最终镜像的名称 -t nginx:v3,构建成功后,我们可以像之前运⾏ nginx:v2 那样来运 ⾏这个镜像,其结果会和 nginx:v2 ⼀样。


五、镜像构建上下文(Context)

如果注意,会看到 docker build 命令最后有⼀个 . 。 . 表示当前⽬录,⽽ Dockerfile 就在当前⽬录, 因此不少初学者以为这个路径是在指定 Dockerfile 所在路径,这么理解其实是不准确的。如果对应上⾯的命令格式,你可能会发现,这是在指定上下⽂路径。那么什么是上下⽂呢?

⾸先我们要理解 docker build 的⼯作原理。Docker 在运⾏时分为 Docker 引擎(也就是服务端守护进 程)和客户端⼯具。Docker 的引擎提供了⼀组 REST API,被称为 Docker Remote API,⽽如 docker 命令这样的客户端⼯具,则是通过这组 API 与 Docker 引擎交互,从⽽完成各种功能。因此,虽然表 ⾯上我们好像是在本机执⾏各种 docker 功能,但实际上,⼀切都是使⽤的远程调⽤形式在服务端 (Docker 引擎)完成。也因为这种 C/S 设计,让我们操作远程服务器的 Docker 引擎变得轻⽽易举。

当我们进⾏镜像构建的时候,并⾮所有定制都会通过 RUN 指令完成,经常会需要将⼀些本地⽂件复制 进镜像,⽐如通过 COPY 指令、ADD 指令等。⽽ docker build 命令构建镜像,其实并⾮在本地构建, ⽽是在服务端,也就是 Docker 引擎中构建的。那么在这种客户端/服务端的架构中,如何才能让服务 端获得本地⽂件呢?

这就引⼊了上下⽂的概念。当构建的时候,⽤户会指定构建镜像上下⽂的路径,docker build 命令得知 这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎收到这个上下 ⽂包后,展开就会获得构建镜像所需的⼀切⽂件。如果在 Dockerfile 中这么写:

COPY ./package.json /app/

这并不是要复制执⾏ docker build 命令所在的⽬录下的 package.json,也不是复制 Dockerfile 所在⽬录下的 package.json,⽽是复制 上下⽂(context) ⽬录下的 package.json。

因此, COPY 这类指令中的源⽂件的路径都是相对路径。这也是初学者经常会问的为什么 COPY ../package.json /app 或者 COPY /opt/xxxx /app ⽆法⼯作的原因,因为这些路径已经超出了上下⽂的 范围,Docker 引擎⽆法获得这些位置的⽂件。如果真的需要那些⽂件,应该将它们复制到上下⽂⽬录 中去。

现在就可以理解刚才的命令 docker build -t nginx:v3 . 中的这个 . ,实际上是在指定上下⽂的⽬ 录,docker build 命令会将该⽬录下的内容打包交给 Docker 引擎以帮助构建镜像。

如果观察 docker build 输出,我们其实已经看到了这个发送上下⽂的过程:

$ docker build -t nginx:v3 . 
Sending build context to Docker daemon 2.048 kB 
...

理解构建上下⽂对于镜像构建是很重要的,可以避免犯⼀些不应该的错误。⽐如有些初学者在发现 COPY /opt/xxxx /app 不⼯作后,于是⼲脆将 Dockerfile 放到了硬盘根⽬录去构建,结果发现 docker build 执⾏后,在发送⼀个⼏⼗ GB 的东⻄,极为缓慢⽽且很容易构建失败。那是因为这种做法是在让 docker build 打包整个硬盘,这显然是使⽤错误。

⼀般来说,应该会将 Dockerfile 置于⼀个空⽬录下,或者项⽬根⽬录下。如果该⽬录下没有所需⽂ 件,那么应该把所需⽂件复制⼀份过来。如果⽬录下有些东⻄确实不希望构建时传给 Docker 引擎,那 么可以⽤ .gitignore ⼀样的语法写⼀个 .dockerignore ,该⽂件是⽤于剔除不需要作为上下⽂传递给 Docker 引擎的。

那么为什么会有⼈误以为 . 是指定 Dockerfile 所在⽬录呢?这是因为在默认情况下,如果不额外指定 Dockerfile 的话,会将上下⽂⽬录下的名为 Dockerfile 的⽂件作为 Dockerfile。

这只是默认⾏为,实际上 Dockerfile 的⽂件名并不要求必须为 Dockerfile,⽽且并不要求必须位于上下 ⽂⽬录中,⽐如可以⽤ -f ../Dockerfile.php 参数指定某个⽂件作为 Dockerfile。

当然,⼀般⼤家习惯性的会使⽤默认的⽂件名 Dockerfile,以及会将其置于镜像构建上下⽂⽬录中。


六、迁移镜像

Docker 还提供了 docker load 和 docker save 命令,⽤以将镜像保存为⼀个 tar ⽂件,然后传输到另 ⼀个位置上,再加载进来。这是在没有 Docker Registry 时的做法,现在已经不推荐,镜像迁移应该直 接使⽤ Docker Registry,⽆论是直接使⽤ Docker Hub 还是使⽤内⽹私有 Registry 都可以。

使⽤ docker save 命令可以将镜像保存为归档⽂件。⽐如我们希望保存这个 alpine 镜像。

$ docker image ls alpine 
REPOSITORY TAG IMAGE ID CREATED SIZE 
alpine latest baa5d63471ea 5 weeks ago 4.803 MB

保存镜像的命令为:

$ docker save alpine | gzip > alpine-latest.tar.gz

然后我们将 alpine-latest.tar.gz ⽂件复制到了到了另⼀个机器上,可以⽤下⾯这个命令加载镜像:

$ docker load -i alpine-latest.tar.gz 
Loaded image: alpine:latest

如果我们结合这两个命令以及 ssh 甚⾄ pv 的话,利⽤ Linux 强⼤的管道,我们可以写⼀个命令完成从 ⼀个机器将镜像迁移到另⼀个机器,并且带进度条的功能:

docker save <镜像名> | bzip2 | pv | ssh <⽤户名>@<主机名> 'cat | docker load'

【云原生丨Docker系列4】用Dockerfile脚本定制镜像(最优解)_第2张图片

 

你可能感兴趣的