Dockerfile构建镜像
简介
我们在讲docker存储驱动的时候,了解到镜像实际上是分层结构。所以如果我们需要自己生成镜像,其实就是定制每一层的配置、文件等信息。
在前面我们讲镜像的基本操作的时候,学习过如何使用docker commit来提交一个新的镜像。然而事实上,在生产环境中,使用docker commit来生成镜像的方式并不常用。一个原因是这种方式需要手动创建,容易出错,效率低且可重复性弱。再一个使用者不知道镜像是如何创建出来的,里面是否有恶意程序,可能存在安全隐患。
所以更好的方式是我们把每一层的修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、生成镜像。而这个脚本就是Dockerfile。
Dockerfile简单示例
下面的示例是利用dockerfile生成一个ssh镜像,当然没什么实际意义:
1 | cat Dockerfile |
生成镜像:
1 | docker build -t openssh:v1.0 . |
Dockerfile的组成
Dockerfile分为四部分:
- 基础镜像信息
- 维护者信息
- 镜像操作指令
- 容器启动时执行指令
Dockerfile指令说明
FROM 引入基础镜像
格式为FROM <image>
或者FROM <image>:<tag>
第一条指令必须为FROM指令。并且,如果在同一个Dockerfile中创建多个镜像时,可以使用多个FROM指令(每个镜像一次)
示例:
1 | FROM centos:6.6 |
MAINTAINER 定义作者
格式为MAINTAINER <name>
,指定维护者信息
示例:
1 | MAINTAINER Breeze Yan<yan_ruo_gu@163.com> |
ENV 设置环境变量
格式:
1 | ENV <key> <value> |
指定一个环境变量,会被后续RUN指令使用,并在容器运行时保持
示例:
1 | ENV TZ "Asia/Shanghai" |
RUN 执行命令
格式:
1 | RUN <command> |
前者将在shell终端中运行命令,即/bin/sh -c;后者使用exec执行。每条RUN指令将在当前镜像基础上执行指定命令,并提交为新的镜像。当命令较长时,可以使用\来换行。
示例:
1 | RUN ["/bin/bash", "-c","echo hello"] |
COPY 复制文件
格式:
1 | COPY <src> <dest> |
和 RUN 指令一样,也有两种格式,一种类似于命令行,一种类似于函数调用。
COPY 指令将从构建上下文目录中 <src>
的文件或目录复制到新的一层的镜像内的 <dest>
位置。<src>
可以是多个,甚至可以是通配符,其通配符规则需要满足golang的filepath.Match规则。
<dest>
可以是容器内的绝对路径,也可以是相对于工作目录的相对路径(工作目录可以用 WORKDIR指令来指定)。目标路径不需要事先创建,如果目录不存在会在复制文件前先行创建缺失目录。
此外,还需要注意一点,使用 COPY 指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。这个特性对于镜像定制很有用。特别是构建相关文件都在使用 Git 进行管理的时候。
示例:
1 | COPY package.json /usr/src/app/ |
ADD 更高级的复制文件
ADD 指令和 COPY 的格式和性质基本一致。但是在 COPY 基础上增加了一些功能。
比如 <src>
可以是一个 URL,这种情况下,Docker 引擎会试图去下载这个链接的文件放到 <dest>
去。下载后的文件权限自动设置为 600,如果这并不是想要的权限,那么还需要增加额外的一层 RUN 进行权限调整,另外,如果下载的是个压缩包,需要解压缩,也一样还需要额外的一层 RUN 指令进行解压缩。所以不如直接使用 RUN 指令,然后使用 wget 或者 curl 工具下载,处理权限、解压缩、然后清理无用文件更合理。因此,这个功能其实并不实用,而且不推荐使用。
如果<src>
为一个 tar 压缩文件的话,压缩格式为 gzip, bzip2 以及 xz 的情况下,ADD 指令将会自动解压缩这个压缩文件到 <dest>
去。如果我们真的是希望复制个压缩文件进去,而不解压缩,这时就不可以使用 ADD 命令。在docker官方的最佳实践中要求,尽可能的使用 COPY,因为 COPY 的语义很明确,就是复制文件而已,而 ADD 则包含了更复杂的功能,其行为也不一定很清晰。最适合使用 ADD 的场合,就是需要自动解压缩的场合。
示例:
1 | ADD aliyun-mirror.repo /etc/yum.repos.d/CentOS-Base.repo |
VOLUME 定义匿名卷
格式:
1 | VOLUME ["<path1>", "<path2>"...] |
容器运行时应该尽量保持容器存储层不发生写操作,对于数据库类需要保存动态数据的应用,其数据库文件应该保存于卷(volume)中,为了防止运行时用户忘记将动态文件所保存目录挂载为卷,在Dockerfile中,我们可以事先指定某些目录挂载为匿名卷,这样在运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据。相当于容器启动时使用的-v选项,只不过这里不能指定挂载到宿主机的位置。
示例:
1 | VOLUME /data |
EXPOSE 声明端口
格式:
1 | EXPOSE <port1> [<port2>...] |
EXPOSE 指令是声明运行时容器提供服务端口,这只是一个声明,在运行时并不会因为这个声明应用就会开启这个端口的服务。在 Dockerfile 中写入这样的声明有两个好处,一个是帮助镜像使用者理解这个镜像服务的守护端口,以方便配置映射;另一个用处则是在运行时使用随机端口映射时,也就是 docker run -P 时,会自动随机映射 EXPOSE 的端口。
要将 EXPOSE 和在运行时使用 -p <宿主端口>:<容器端口> 区分开来。-p,是映射宿主端口和容器端口,换句话说,就是将容器的对应端口服务公开给外界访问,而 EXPOSE 仅仅是声明容器打算使用什么端口而已,并不会自动在宿主进行端口映射。
示例:
1 | EXPOSE 80 443 |
WORKDIR 指定工作目录
格式:
1 | WORKDIR /path/to/workdir |
使用 WORKDIR 指令可以来指定工作目录(或者称为当前目录),以后各层的当前目录就被改为指定的目录,如该目录不存在,WORKDIR 会帮你建立目录。可以使用多个WORKDIR指令,后续命令如果参数是相对路径,则会基于之前命令指定的路径。
示例:
1 | 最终路径为/a/b/c |
USER 指定当前用户
格式:
1 | USER <username> |
USER 指令和WORKDIR相似,都是改变环境状态并影响以后的层。WORKDIR是改变工作目录,USER则是改变之后层的执行RUN, CMD 以及ENTRYPOINT这类命令的身份。当服务不需要管理员权限时,可以通过该命令指定运行用户。并且可以在之前创建所需要的用户。要临时获取管理员权限时可以使用gosu。
示例:
1 | 建立 redis 用户,并使用 gosu 换另一个用户执行命令 |
CMD 容器启动命令
支持三种格式:
1 | CMD ["executable","param1","param2"] #使用exec执行,推荐的方式 |
Docker不是虚拟机,事实上容器就是进程。既然是进程,那么在启动容器的时候,需要指定所运行的程序及参数。CMD 指令就是用于指定默认的容器主进程的启动命令的。
在运行时可以指定新的命令来替代镜像设置中的这个默认命令,比如ubuntu镜像默认的CMD是 /bin/bash,如果我们直接 docker run -it ubuntu 的话,会直接进入bash。我们也可以在运行时指定运行别的命令,如 docker run -it ubuntu cat /etc/os-release。这就是用 cat /etc/os-release 命令替换了默认的 /bin/bash 命令了,输出了系统版本信息。
在指令格式上,一般推荐使用exec方式执行,这类格式在解析时会被解析为JSON数组,因此一定要使用双引号 “,而不要使用单引号。
如果使用/bin/shy方式执行的话,实际的命令会被包装为 sh -c 的参数的形式进行执行。比如:
1 | CMD echo $HOME |
在实际执行中,会将其变更为:
1 | CMD [ "sh", "-c", "echo $HOME" ] |
每个Dockerfile只能有一条CMD命令。如果指定了多条,只有最后一条会被执行。
示例:
1 | CMD ["supervisord","-c","/etc/supervisord.conf"] |
ENTRYPOINT 入口点
格式:
1 | ENTRYPOINT ["executable","param1","param2"] |
ENTRYPOINT 的格式和 RUN 指令格式一样,分为 exec 格式和 shell 格式。
ENTRYPOINT 的目的和 CMD 一样,都是在指定容器启动程序及参数。ENTRYPOINT 在运行时也可以替代,不过比 CMD 要略显繁琐,需要通过 docker run 的参数 –entrypoint来指定。
当指定了ENTRYPOINT后,CMD的含义就发生了改变,不再是直接的运行其命令,而是将CMD的内容作为参数传给 ENTRYPOINT 指令,换句话说实际执行时,将变为:
1 | <ENTRYPOINT> "<CMD>" |
那么有了 CMD 后,为什么还要有 ENTRYPOINT 呢?这种 <ENTRYPOINT> "<CMD>"
有什么好处呢?
场景1:让镜像变成像命令一样使用
假设我们需要一个得知自己当前公网 IP 的镜像,那么可以先用 CMD 来实现:
1 | FROM ubuntu:16.04 |
假如我们使用 docker build -t myip . 来构建镜像的话,如果我们需要查询当前公网 IP,只需要执行:
1 | docker run myip |
这么看起来好像可以直接把镜像当做命令使用了,不过命令总有参数,如果我们希望加参数呢?比如从上面的 CMD 中可以看到实质的命令是 curl,那么如果我们希望显示 HTTP 头信息,就需要加上 -i 参数。那么我们可以直接加 -i 参数给 docker run myip 么?
1 | docker run myip -i |
我们可以看到可执行文件找不到的报错,executable file not found。之前我们说过,跟在镜像名后面的是 command,运行时会替换 CMD 的默认值。因此这里的 -i 替换了原来的 CMD,而不是添加在原来的 curl -s http://ip.cn 后面。而 -i 根本不是命令,所以自然找不到。
那么如果我们希望加入 -i 这参数,我们就必须重新完整的输入这个命令:
1 | docker run myip curl -s http://ip.cn -i |
这显然不是一个好的解决方案,而使用ENTRYPOINT就可以解决这个问题。现在我们重新用ENTRYPOINT来实现这个镜像:
1 | FROM ubuntu:16.04 |
这次我们再来尝试直接使用docker run myip -i
就可以成功了。
这是因为当存在 ENTRYPOINT 后,CMD 的内容将会作为参数传给 ENTRYPOINT,而这里 -i 就是新的 CMD,因此会作为参数传给 curl,从而达到了我们预期的效果。
场景2:应用运行前的准备工作
启动容器就是启动主进程,但有些时候,启动主进程前,需要一些准备工作。
比如 mysql 类的数据库,可能需要一些数据库配置、初始化的工作,这些工作要在最终的 mysql 服务器运行之前解决。
此外,可能希望避免使用 root 用户去启动服务,从而提高安全性,而在启动服务前还需要以 root 身份执行一些必要的准备工作,最后切换到服务用户身份启动服务。或者除了服务外,其它命令依旧可以使用 root 身份执行,方便调试等。
这些准备工作是和容器CMD无关的,无论CMD是什么,都需要事先进行一个预处理的工作。这种情况下,可以写一个脚本,然后放入ENTRYPOINT中去执行,而这个脚本会将接到的参数(也就是 <CMD>
)作为命令,在脚本最后执行。比如官方镜像 redis 中就是这么做的:
1 | FROM alpine:3.4 |
可以看到其中为了 redis 服务创建了 redis 用户,并在最后指定了ENTRYPOINT为docker-entrypoint.sh脚本:
1 | 该脚本的内容就是根据CMD的内容来判断,如果是 redis-server 的话,则切换到 redis 用户身份启动服务器,否则依旧使用 root 身份执行 |
HEALTHCHECK 健康检查
格式:
1 | HEALTHCHECK [options] CMD <command> #设置检查容器健康状况的命令 |
HEALTHCHECK 指令是告诉 Docker 应该如何进行判断容器的状态是否正常,这是 Docker 1.12 引入的新指令。
在没有 HEALTHCHECK 指令前,Docker 引擎只可以通过容器内主进程是否退出来判断容器是否状态异常。很多情况下这没问题,但是如果程序进入死锁状态,或者死循环状态,应用进程并不退出,但是该容器已经无法提供服务了。在 1.12 以前,Docker 不会检测到容器的这种状态,从而不会重新调度,导致可能会有部分容器已经无法提供服务了却还在接受用户请求。
而自 1.12 之后,Docker 提供了 HEALTHCHECK 指令,通过该指令指定一行命令,用这行命令来判断容器主进程的服务状态是否还正常,从而比较真实的反应容器实际状态。
当在一个镜像指定了 HEALTHCHECK 指令后,用其启动容器,初始状态会为 starting,在 HEALTHCHECK 指令检查成功后变为 healthy,如果连续一定次数失败,则会变为 unhealthy。
HEALTHCHECK 支持下列选项:
1 | --interval=<间隔>:两次健康检查的间隔,默认为 30 秒; |
和 CMD, ENTRYPOINT 一样,HEALTHCHECK 只可以出现一次,如果写了多个,只有最后一个生效。
在HEALTHCHECK [options] CMD
后面的命令,格式和 ENTRYPOINT 一样,分为 shell 格式,和 exec 格式。命令的返回值决定了该次健康检查的成功与否:0:成功;1:失败;2:保留,不要使用这个值。
假设我们有个镜像是个最简单的 Web 服务,我们希望增加健康检查来判断其 Web 服务是否在正常工作,我们可以用 curl 来帮助判断,其 Dockerfile 的 HEALTHCHECK 可以这么写:
1 | 这里我们设置了每5秒检查一次(这里为了试验所以间隔非常短,实际应该相对较长),如果健康检查命令超过3秒没响应就视为失败,并且使用 curl -fs http://localhost/ || exit 1 作为健康检查命令。 |
为了帮助排障,健康检查命令的输出(包括 stdout 以及 stderr)都会被存储于健康状态里,可以用 docker inspect 来查看:
1 | docker inspect web | jq .[].State.Health |
ONBUILD 为他人作嫁衣裳
格式:
1 | ONBUILD [INSTRUTION] |
ONBUILD 是一个特殊的指令,它后面跟的是其它指令,比如 RUN, COPY 等,而这些指令,在当前镜像构建时并不会被执行。只有当以当前镜像为基础镜像,去构建下一级镜像的时候才会被执行。
Dockerfile 中的其它指令都是为了定制当前镜像而准备的,唯有 ONBUILD 是为了帮助别人定制自己而准备的。
假设我们要制作 Node.js 所写的应用的镜像。我们都知道 Node.js 使用 npm 进行包管理,所有依赖、配置、启动信息等会放到 package.json 文件里。在拿到程序代码后,需要先进行 npm install 才可以获得所有需要的依赖。然后就可以通过 npm start 来启动应用。因此,一般来说会这样写 Dockerfile:
1 | FROM node:slim |
把这个 Dockerfile 放到 Node.js 项目的根目录,构建好镜像后,就可以直接拿来启动容器运行。但是如果我们还有第二个 Node.js 项目也差不多呢?好吧,那就再把这个 Dockerfile 复制到第二个项目里。那如果有第三个项目呢?再复制么?文件的副本越多,版本控制就越困难,让我们继续看这样的场景维护的问题。
如果第一个 Node.js 项目在开发过程中,发现这个 Dockerfile 里存在问题,比如敲错字了、或者需要安装额外的包,然后开发人员修复了这个 Dockerfile,再次构建,问题解决。第一个项目没问题了,但是第二个项目呢?虽然最初 Dockerfile 是复制、粘贴自第一个项目的,但是并不会因为第一个项目修复了他们的 Dockerfile,而第二个项目的 Dockerfile 就会被自动修复。
那么我们可不可以做一个基础镜像,然后各个项目使用这个基础镜像呢?这样基础镜像更新,各个项目不用同步 Dockerfile 的变化,重新构建后就继承了基础镜像的更新?好吧,可以,让我们看看这样的结果。那么上面的这个 Dockerfile 就会变为:
1 | FROM node:slim |
这里我们把项目相关的构建指令拿出来,放到子项目里去。假设这个基础镜像的名字为 my-node 的话,各个项目内的自己的 Dockerfile 就变为:
1 | FROM my-node |
基础镜像变化后,各个项目都用这个 Dockerfile 重新构建镜像,会继承基础镜像的更新。
那么,问题解决了么?其实也只解决了一半。如果这个 Dockerfile 里面有些东西需要调整呢?比如所有项目的npm install
都需要加一些参数,那怎么办?我们又不能直接把这一行RUN直接放到基础镜像里去,因为涉及到了当前项目的 ./package.json,难道又要一个个修改么?所以说,这样制作基础镜像,只解决了原来的 Dockerfile 的前4条指令的变化问题,而后面三条指令的变化则完全没办法处理。
ONBUILD 可以解决这个问题。让我们用 ONBUILD 重新写一下基础镜像的 Dockerfile:
1 | FROM node:slim |
这次我们回到原始的 Dockerfile,但是这次将项目相关的指令加上 ONBUILD,这样在构建基础镜像的时候,这三行并不会被执行。然后各个项目的 Dockerfile 就变成了简单地:
1 | FROM my-node |
是的,只有这么一行。当在各个项目目录中,用这个只有一行的 Dockerfile 构建镜像时,之前基础镜像的那三行 ONBUILD 就会开始执行,成功的将当前项目的代码复制进镜像、并且针对本项目执行 npm install,生成应用镜像。
使用Dockerfile构建镜像
1 | mysoft/centos:6.6为新生成的镜像的标签,"."为dockerfile所在路径。 |
镜像构建上下文(Context)
在上使用docker build来构建镜像的时候,在命令的最后面加了个”.”,我们直接解释为当前目录,即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引擎中构建的。那么在这种C/S架构中,如何 才能让服务端获得本地文件呢?
这就引入了上下文的概念。当构建的时候,用户会指定构建镜像上下文的路径,docker build命令得知这个路径后,会将路径下的所有内容打包,然后上传给Docker引擎。这样Docker引擎收到这个上下文包后,展开就会获得构建镜像所需的所有文件。
如果在Dockerfile中这么写:
1 | COPY ./package.json /app/ |
这并不是要复制执行docker build命令所在的目录下的package.json,也是不复制Dockerfile所在目录下的package.json,而是复制上下文目录中的package.json。
因此,COPY这类指令中的源文件的路径都是相对路径。这也是初学者经常会问的为什么COPY ../packeg.json /app
或者COPY /opt/xxx /app/
这种指令无法工作的原因,因为这些路径已经超出了上下文的范围,Docker引擎无法获得这些位置的文件。如果真的需要那些文件,应该将它们复制到上下文目录中去。
现在就可以理解docker build -t mysoft/centos:6.6 .
中的这个”.”,实际是在指定上下文的目录,docker build命令会将该目录下的内容打包交给docker引擎以帮助构建镜像。
如果观察docker build 命令输出,其实可以看到发送上下文的过程:
1 | # docker build -t mysoft/centos:6.6 . |
理解构建上下文对镜像构建是很重要的,可以避免犯一些不应该的错误。比如有些初学者在发现COPY /opt/xxx /app/
不工作后,干脆将Dockerfile放到了根目录去构建。结果发现docker build执行后,在发送一个几十GB的东西,极为缓慢而且很容易构建失败。那是因为这种做法是在让docker build打包整个硬盘,这显然错误的使用方法。
一般来说,应该会将Dockerfile置于一个空目录下,如果该目录下没有所需的文件,那么应该把所需的文件复制一份过来。如果目录下有些东西确实不希望构建时传递给Docker引擎,那么可以使用和.gitignore一样的语法写一个.dockerignore,该文件是用于剔除不需要作为上下文传递给Docker引擎的文件。
那么为什么会有人误以为”.”是指定Dockerfile所在目录的呢? 这是因为在默认情况下,如果不额外指定Dockerfile的话,会将上下文目录下的名为Dockerfile的文件作为Dockerfile。这只是默认行为,实际上Dockerfile的文件名并不要求必须为Dockerfile,并且并不要求必须位于上下文目录中,比如可以使用-f ../Dockerfile.py
参数指定某个文件为Dockerfile。当然一般大家习惯性的会使用默认的文件名Dockerfile,以及会将其置于镜像构建上下文目录中。