天天看点

Dockerfile 指令详解1

我们已经介绍了 <code>FROM</code>,<code>RUN</code>,还提及了 <code>COPY</code>, <code>ADD</code>,其实 <code>Dockerfile</code> 功能很强大,它提供了十多个指令。下面我们继续讲解其他的指令。

格式:

<code>COPY [--chown=&lt;user&gt;:&lt;group&gt;] &lt;源路径&gt;... &lt;目标路径&gt;</code>

<code>COPY [--chown=&lt;user&gt;:&lt;group&gt;] ["&lt;源路径1&gt;",... "&lt;目标路径&gt;"]</code>

和 <code>RUN</code> 指令一样,也有两种格式,一种类似于命令行,一种类似于函数调用。

<code>COPY</code> 指令将从构建上下文目录中 <code>&lt;源路径&gt;</code> 的文件/目录复制到新的一层的镜像内的 <code>&lt;目标路径&gt;</code> 位置。比如:

<code>&lt;源路径&gt;</code> 可以是多个,甚至可以是通配符,其通配符规则要满足 Go 的 <code>filepath.Match</code> 规则,如:

<code>&lt;目标路径&gt;</code> 可以是容器内的绝对路径,也可以是相对于工作目录的相对路径(工作目录可以用 <code>WORKDIR</code> 指令来指定)。目标路径不需要事先创建,如果目录不存在会在复制文件前先行创建缺失目录。

此外,还需要注意一点,使用 <code>COPY</code> 指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。这个特性对于镜像定制很有用。特别是构建相关文件都在使用 Git 进行管理的时候。

在使用该指令的时候还可以加上 <code>--chown=&lt;user&gt;:&lt;group&gt;</code> 选项来改变文件的所属用户及所属组。

<code>ADD</code> 指令和 <code>COPY</code> 的格式和性质基本一致。但是在 <code>COPY</code> 基础上增加了一些功能。

比如 <code>&lt;源路径&gt;</code> 可以是一个 <code>URL</code>,这种情况下,Docker 引擎会试图去下载这个链接的文件放到 <code>&lt;目标路径&gt;</code> 去。下载后的文件权限自动设置为 <code>600</code>,如果这并不是想要的权限,那么还需要增加额外的一层 <code>RUN</code> 进行权限调整,另外,如果下载的是个压缩包,需要解压缩,也一样还需要额外的一层 <code>RUN</code> 指令进行解压缩。所以不如直接使用 <code>RUN</code> 指令,然后使用 <code>wget</code> 或者 <code>curl</code> 工具下载,处理权限、解压缩、然后清理无用文件更合理。因此,这个功能其实并不实用,而且不推荐使用。

如果 <code>&lt;源路径&gt;</code> 为一个 <code>tar</code> 压缩文件的话,压缩格式为 <code>gzip</code>, <code>bzip2</code> 以及 <code>xz</code> 的情况下,<code>ADD</code> 指令将会自动解压缩这个压缩文件到 <code>&lt;目标路径&gt;</code> 去。

在某些情况下,这个自动解压缩的功能非常有用,比如官方镜像 <code>ubuntu</code> 中:

但在某些情况下,如果我们真的是希望复制个压缩文件进去,而不解压缩,这时就不可以使用 <code>ADD</code> 命令了。

在 Docker 官方的 Dockerfile 最佳实践文档 中要求,尽可能的使用 <code>COPY</code>,因为 <code>COPY</code> 的语义很明确,就是复制文件而已,而 <code>ADD</code> 则包含了更复杂的功能,其行为也不一定很清晰。最适合使用 <code>ADD</code> 的场合,就是所提及的需要自动解压缩的场合。

另外需要注意的是,<code>ADD</code> 指令会令镜像构建缓存失效,从而可能会令镜像构建变得比较缓慢。

因此在 <code>COPY</code> 和 <code>ADD</code> 指令中选择的时候,可以遵循这样的原则,所有的文件复制均使用 <code>COPY</code> 指令,仅在需要自动解压缩的场合使用 <code>ADD</code>。

<code>CMD</code> 指令的格式和 <code>RUN</code> 相似,也是两种格式:

<code>shell</code> 格式:<code>CMD &lt;命令&gt;</code>

<code>exec</code> 格式:<code>CMD ["可执行文件", "参数1", "参数2"...]</code>

参数列表格式:<code>CMD ["参数1", "参数2"...]</code>。在指定了 <code>ENTRYPOINT</code> 指令后,用 <code>CMD</code> 指定具体的参数。

之前介绍容器的时候曾经说过,Docker 不是虚拟机,容器就是进程。既然是进程,那么在启动容器的时候,需要指定所运行的程序及参数。<code>CMD</code> 指令就是用于指定默认的容器主进程的启动命令的。

在运行时可以指定新的命令来替代镜像设置中的这个默认命令,比如,<code>ubuntu</code> 镜像默认的 <code>CMD</code> 是 <code>/bin/bash</code>,如果我们直接 <code>docker run -it ubuntu</code> 的话,会直接进入 <code>bash</code>。我们也可以在运行时指定运行别的命令,如 <code>docker run -it ubuntu cat /etc/os-release</code>。这就是用 <code>cat /etc/os-release</code> 命令替换了默认的 <code>/bin/bash</code> 命令了,输出了系统版本信息。

在指令格式上,一般推荐使用 <code>exec</code> 格式,这类格式在解析时会被解析为 JSON 数组,因此一定要使用双引号 <code>"</code>,而不要使用单引号。

如果使用 <code>shell</code> 格式的话,实际的命令会被包装为 <code>sh -c</code> 的参数的形式进行执行。比如:

在实际执行中,会将其变更为:

这就是为什么我们可以使用环境变量的原因,因为这些环境变量会被 shell 进行解析处理。

提到 <code>CMD</code> 就不得不提容器中应用在前台执行和后台执行的问题。这是初学者常出现的一个混淆。

Docker 不是虚拟机,容器中的应用都应该以前台执行,而不是像虚拟机、物理机里面那样,用 <code>systemd</code> 去启动后台服务,容器内没有后台服务的概念。

一些初学者将 <code>CMD</code> 写为:

然后发现容器执行后就立即退出了。甚至在容器内去使用 <code>systemctl</code> 命令结果却发现根本执行不了。这就是因为没有搞明白前台、后台的概念,没有区分容器和虚拟机的差异,依旧在以传统虚拟机的角度去理解容器。

对于容器而言,其启动程序就是容器应用进程,容器就是为了主进程而存在的,主进程退出,容器就失去了存在的意义,从而退出,其它辅助进程不是它需要关心的东西。

而使用 <code>service nginx start</code> 命令,则是希望 upstart 来以后台守护进程形式启动 <code>nginx</code> 服务。而刚才说了 <code>CMD service nginx start</code> 会被理解为 <code>CMD [ "sh", "-c", "service nginx start"]</code>,因此主进程实际上是 <code>sh</code>。那么当 <code>service nginx start</code> 命令结束后,<code>sh</code> 也就结束了,<code>sh</code> 作为主进程退出了,自然就会令容器退出。

正确的做法是直接执行 <code>nginx</code> 可执行文件,并且要求以前台形式运行。比如:

<code>ENTRYPOINT</code> 的格式和 <code>RUN</code> 指令格式一样,分为 <code>exec</code> 格式和 <code>shell</code> 格式。

<code>ENTRYPOINT</code> 的目的和 <code>CMD</code> 一样,都是在指定容器启动程序及参数。<code>ENTRYPOINT</code> 在运行时也可以替代,不过比 <code>CMD</code> 要略显繁琐,需要通过 <code>docker run</code> 的参数 <code>--entrypoint</code> 来指定。

当指定了 <code>ENTRYPOINT</code> 后,<code>CMD</code> 的含义就发生了改变,不再是直接的运行其命令,而是将 <code>CMD</code> 的内容作为参数传给 <code>ENTRYPOINT</code> 指令,换句话说实际执行时,将变为:

那么有了 <code>CMD</code> 后,为什么还要有 <code>ENTRYPOINT</code> 呢?这种 <code>&lt;ENTRYPOINT&gt; "&lt;CMD&gt;"</code> 有什么好处么?让我们来看几个场景。

场景一:让镜像变成像命令一样使用

假设我们需要一个得知自己当前公网 IP 的镜像,那么可以先用 <code>CMD</code> 来实现:

假如我们使用 <code>docker build -t myip .</code> 来构建镜像的话,如果我们需要查询当前公网 IP,只需要执行:

嗯,这么看起来好像可以直接把镜像当做命令使用了,不过命令总有参数,如果我们希望加参数呢?比如从上面的 <code>CMD</code> 中可以看到实质的命令是 <code>curl</code>,那么如果我们希望显示 HTTP 头信息,就需要加上 <code>-i</code> 参数。那么我们可以直接加 <code>-i</code> 参数给 <code>docker run myip</code> 么?

我们可以看到可执行文件找不到的报错,<code>executable file not found</code>。之前我们说过,跟在镜像名后面的是 <code>command</code>,运行时会替换 <code>CMD</code> 的默认值。因此这里的 <code>-i</code> 替换了原来的 <code>CMD</code>,而不是添加在原来的 <code>curl -s https://ip.cn</code> 后面。而 <code>-i</code> 根本不是命令,所以自然找不到。

那么如果我们希望加入 <code>-i</code> 这参数,我们就必须重新完整的输入这个命令:

这显然不是很好的解决方案,而使用 <code>ENTRYPOINT</code> 就可以解决这个问题。现在我们重新用 <code>ENTRYPOINT</code> 来实现这个镜像:

这次我们再来尝试直接使用 <code>docker run myip -i</code>:

可以看到,这次成功了。这是因为当存在 <code>ENTRYPOINT</code> 后,<code>CMD</code> 的内容将会作为参数传给 <code>ENTRYPOINT</code>,而这里 <code>-i</code> 就是新的 <code>CMD</code>,因此会作为参数传给 <code>curl</code>,从而达到了我们预期的效果。

场景二:应用运行前的准备工作

启动容器就是启动主进程,但有些时候,启动主进程前,需要一些准备工作。

比如 <code>mysql</code> 类的数据库,可能需要一些数据库配置、初始化的工作,这些工作要在最终的 mysql 服务器运行之前解决。

此外,可能希望避免使用 <code>root</code> 用户去启动服务,从而提高安全性,而在启动服务前还需要以 <code>root</code> 身份执行一些必要的准备工作,最后切换到服务用户身份启动服务。或者除了服务外,其它命令依旧可以使用 <code>root</code> 身份执行,方便调试等。

这些准备工作是和容器 <code>CMD</code> 无关的,无论 <code>CMD</code> 为什么,都需要事先进行一个预处理的工作。这种情况下,可以写一个脚本,然后放入 <code>ENTRYPOINT</code> 中去执行,而这个脚本会将接到的参数(也就是 <code>&lt;CMD&gt;</code>)作为命令,在脚本最后执行。比如官方镜像 <code>redis</code> 中就是这么做的:

可以看到其中为了 redis 服务创建了 redis 用户,并在最后指定了 <code>ENTRYPOINT</code> 为 <code>docker-entrypoint.sh</code> 脚本。

该脚本的内容就是根据 <code>CMD</code> 的内容来判断,如果是 <code>redis-server</code> 的话,则切换到 <code>redis</code> 用户身份启动服务器,否则依旧使用 <code>root</code> 身份执行。比如:

继续阅读