ENV 设置环境变量
格式有两种:
ENV <key> <value>
ENV <key1>=<value1> <key2>=<value2>...
这个指令很简单,就是设置环境变量而已,无论是后面的其它指令,如 RUN
,还是运行时的应用,都可以直接使用这里定义的环境变量。
ENV VERSION=1.0 DEBUG=on
NAME="Happy Feet"
这个例子中演示了如何换行,以及对含有空格的值用双引号括起来的办法,这和 Shell 下的行为是一致的。
定义了环境变量,那么在后续的指令中,就可以使用这个环境变量。比如在官方 node
镜像 Dockerfile
中,就有类似这样的代码:
ENV NODE_VERSION 7.2.0
RUN curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz"
&& curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc"
&& gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc
&& grep " node-v$NODE_VERSION-linux-x64.tar.xz$" SHASUMS256.txt | sha256sum -c -
&& tar -xJf "node-v$NODE_VERSION-linux-x64.tar.xz" -C /usr/local --strip-components=1
&& rm "node-v$NODE_VERSION-linux-x64.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt
&& ln -s /usr/local/bin/node /usr/local/bin/nodejs
在这里先定义了环境变量 NODE_VERSION
,其后的 RUN
这层里,多次使用 $NODE_VERSION
来进行操作定制。可以看到,将来升级镜像构建版本的时候,只需要更新 7.2.0
即可,Dockerfile
构建维护变得更轻松了。
下列指令可以支持环境变量展开: ADD
、COPY
、ENV
、EXPOSE
、FROM
、LABEL
、USER
、WORKDIR
、VOLUME
、STOPSIGNAL
、ONBUILD
、RUN
。
可以从这个指令列表里感觉到,环境变量可以使用的地方很多,很强大。通过环境变量,我们可以让一份 Dockerfile
制作更多的镜像,只需使用不同的环境变量即可。
ARG 构建参数
格式:ARG <参数名>[=<默认值>]
构建参数和 ENV
的效果一样,都是设置环境变量。所不同的是,ARG
所设置的构建环境的环境变量,在将来容器运行时是不会存在这些环境变量的。但是不要因此就使用 ARG
保存密码之类的信息,因为 docker history
还是可以看到所有值的。
Dockerfile
中的 ARG
指令是定义参数名称,以及定义其默认值。该默认值可以在构建命令 docker build
中用 --build-arg <参数名>=<值>
来覆盖。
在 1.13 之前的版本,要求 --build-arg
中的参数名,必须在 Dockerfile
中用 ARG
定义过了,换句话说,就是 --build-arg
指定的参数,必须在 Dockerfile
中使用了。如果对应参数没有被使用,则会报错退出构建。从 1.13 开始,这种严格的限制被放开,不再报错退出,而是显示警告信息,并继续构建。这对于使用 CI 系统,用同样的构建流程构建不同的 Dockerfile
的时候比较有帮助,避免构建命令必须根据每个 Dockerfile 的内容修改。
VOLUME 定义匿名卷
格式为:
VOLUME ["<路径1>", "<路径2>"...]
VOLUME <路径>
之前我们说过,容器运行时应该尽量保持容器存储层不发生写操作,对于数据库类需要保存动态数据的应用,其数据库文件应该保存于卷(volume)中,后面的章节我们会进一步介绍 Docker 卷的概念。为了防止运行时用户忘记将动态文件所保存目录挂载为卷,在 Dockerfile
中,我们可以事先指定某些目录挂载为匿名卷,这样在运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据。
VOLUME /data
这里的 /data
目录就会在运行时自动挂载为匿名卷,任何向 /data
中写入的信息都不会记录进容器存储层,从而保证了容器存储层的无状态化。当然,运行时可以覆盖这个挂载设置。比如:
docker run -d -v mydata:/data xxxx
在这行命令中,就使用了 mydata
这个命名卷挂载到了 /data
这个位置,替代了 Dockerfile
中定义的匿名卷的挂载配置。
EXPOSE 声明端口
格式为 EXPOSE <端口1> [<端口2>...]
。
EXPOSE
指令是声明运行时容器提供服务端口,这只是一个声明,在运行时并不会因为这个声明应用就会开启这个端口的服务。在 Dockerfile 中写入这样的声明有两个好处,一个是帮助镜像使用者理解这个镜像服务的守护端口,以方便配置映射;另一个用处则是在运行时使用随机端口映射时,也就是 docker run -P
时,会自动随机映射 EXPOSE
的端口。
要将 EXPOSE
和在运行时使用 -p <宿主端口>:<容器端口>
区分开来。-p
,是映射宿主端口和容器端口,换句话说,就是将容器的对应端口服务公开给外界访问,而 EXPOSE
仅仅是声明容器打算使用什么端口而已,并不会自动在宿主进行端口映射。
WORKDIR 指定工作目录
格式为 WORKDIR <工作目录路径>
。
使用 WORKDIR
指令可以来指定工作目录(或者称为当前目录),以后各层的当前目录就被改为指定的目录,如该目录不存在,WORKDIR
会帮你建立目录。
之前提到一些初学者常犯的错误是把 Dockerfile
等同于 Shell 脚本来书写,这种错误的理解还可能会导致出现下面这样的错误:
RUN cd /app
RUN echo "hello" > world.txt
如果将这个 Dockerfile
进行构建镜像运行后,会发现找不到 /app/world.txt
文件,或者其内容不是 hello
。原因其实很简单,在 Shell 中,连续两行是同一个进程执行环境,因此前一个命令修改的内存状态,会直接影响后一个命令;而在 Dockerfile
中,这两行 RUN
命令的执行环境根本不同,是两个完全不同的容器。这就是对 Dockerfile
构建分层存储的概念不了解所导致的错误。
之前说过每一个 RUN
都是启动一个容器、执行命令、然后提交存储层文件变更。第一层 RUN cd /app
的执行仅仅是当前进程的工作目录变更,一个内存上的变化而已,其结果不会造成任何文件变更。而到第二层的时候,启动的是一个全新的容器,跟第一层的容器更完全没关系,自然不可能继承前一层构建过程中的内存变化。
因此如果需要改变以后各层的工作目录的位置,那么应该使用 WORKDIR
指令。
USER 指定当前用户
格式:USER <用户名>[:<用户组>]
USER
指令和 WORKDIR
相似,都是改变环境状态并影响以后的层。WORKDIR
是改变工作目录,USER
则是改变之后层的执行 RUN
, CMD
以及 ENTRYPOINT
这类命令的身份。
当然,和 WORKDIR
一样,USER
只是帮助你切换到指定用户而已,这个用户必须是事先建立好的,否则无法切换。
RUN groupadd -r redis && useradd -r -g redis redis
USER redis
RUN [ "redis-server" ]
如果以 root
执行的脚本,在执行期间希望改变身份,比如希望以某个已经建立好的用户来运行某个服务进程,不要使用 su
或者 sudo
,这些都需要比较麻烦的配置,而且在 TTY 缺失的环境下经常出错。建议使用 gosu
。
# 建立 redis 用户,并使用 gosu 换另一个用户执行命令
RUN groupadd -r redis && useradd -r -g redis redis
# 下载 gosu
RUN wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.7/gosu-amd64"
&& chmod +x /usr/local/bin/gosu
&& gosu nobody true
# 设置 CMD,并以另外的用户执行
CMD [ "exec", "gosu", "redis", "redis-server" ]
HEALTHCHECK 健康检查
格式:
HEALTHCHECK [选项] CMD <命令>
:设置检查容器健康状况的命令HEALTHCHECK NONE
:如果基础镜像有健康检查指令,使用这行可以屏蔽掉其健康检查指令
HEALTHCHECK
指令是告诉 Docker 应该如何进行判断容器的状态是否正常,这是 Docker 1.12 引入的新指令。
在没有 HEALTHCHECK
指令前,Docker 引擎只可以通过容器内主进程是否退出来判断容器是否状态异常。很多情况下这没问题,但是如果程序进入死锁状态,或者死循环状态,应用进程并不退出,但是该容器已经无法提供服务了。在 1.12 以前,Docker 不会检测到容器的这种状态,从而不会重新调度,导致可能会有部分容器已经无法提供服务了却还在接受用户请求。
而自 1.12 之后,Docker 提供了 HEALTHCHECK
指令,通过该指令指定一行命令,用这行命令来判断容器主进程的服务状态是否还正常,从而比较真实的反应容器实际状态。
当在一个镜像指定了 HEALTHCHECK
指令后,用其启动容器,初始状态会为 starting
,在 HEALTHCHECK
指令检查成功后变为 healthy
,如果连续一定次数失败,则会变为 unhealthy
。
HEALTHCHECK
支持下列选项:
--interval=<间隔>
:两次健康检查的间隔,默认为 30 秒;--timeout=<时长>
:健康检查命令运行超时时间,如果超过这个时间,本次健康检查就被视为失败,默认 30 秒;--retries=<次数>
:当连续失败指定次数后,则将容器状态视为unhealthy
,默认 3 次。
和 CMD
, ENTRYPOINT
一样,HEALTHCHECK
只可以出现一次,如果写了多个,只有最后一个生效。
在 HEALTHCHECK [选项] CMD
后面的命令,格式和 ENTRYPOINT
一样,分为 shell
格式,和 exec
格式。命令的返回值决定了该次健康检查的成功与否:0
:成功;1
:失败;2
:保留,不要使用这个值。
假设我们有个镜像是个最简单的 Web 服务,我们希望增加健康检查来判断其 Web 服务是否在正常工作,我们可以用 curl
来帮助判断,其 Dockerfile
的 HEALTHCHECK
可以这么写:
FROM nginx
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
HEALTHCHECK --interval=5s --timeout=3s
CMD curl -fs http://localhost/ || exit 1
这里我们设置了每 5 秒检查一次(这里为了试验所以间隔非常短,实际应该相对较长),如果健康检查命令超过 3 秒没响应就视为失败,并且使用 curl -fs http://localhost/ || exit 1
作为健康检查命令。
使用 docker build
来构建这个镜像:
$ docker build -t myweb:v1 .
构建好了后,我们启动一个容器:
$ docker run -d --name web -p 80:80 myweb:v1
当运行该镜像后,可以通过 docker container ls
看到最初的状态为 (health: starting)
:
$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
03e28eb00bd0 myweb:v1 "nginx -g 'daemon off" 3 seconds ago Up 2 seconds (health: starting) 80/tcp, 443/tcp web
在等待几秒钟后,再次 docker container ls
,就会看到健康状态变化为了 (healthy)
:
$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
03e28eb00bd0 myweb:v1 "nginx -g 'daemon off" 18 seconds ago Up 16 seconds (healthy) 80/tcp, 443/tcp web
如果健康检查连续失败超过了重试次数,状态就会变为 (unhealthy)
。
为了帮助排障,健康检查命令的输出(包括 stdout
以及 stderr
)都会被存储于健康状态里,可以用 docker inspect
来查看。
$ docker inspect --format '{{json .State.Health}}' web | python -m json.tool
{
"FailingStreak": 0,
"Log": [
{
"End": "2016-11-25T14:35:37.940957051Z",
"ExitCode": 0,
"Output": "<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
body {
35em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
",
"Start": "2016-11-25T14:35:37.780192565Z"
}
],
"Status": "healthy"
}
ONBUILD 为他人做嫁衣裳
格式:ONBUILD <其它指令>
。
ONBUILD
是一个特殊的指令,它后面跟的是其它指令,比如 RUN
, COPY
等,而这些指令,在当前镜像构建时并不会被执行。只有当以当前镜像为基础镜像,去构建下一级镜像的时候才会被执行。
Dockerfile
中的其它指令都是为了定制当前镜像而准备的,唯有 ONBUILD
是为了帮助别人定制自己而准备的。
假设我们要制作 Node.js 所写的应用的镜像。我们都知道 Node.js 使用 npm
进行包管理,所有依赖、配置、启动信息等会放到 package.json
文件里。在拿到程序代码后,需要先进行 npm install
才可以获得所有需要的依赖。然后就可以通过 npm start
来启动应用。因此,一般来说会这样写 Dockerfile
:
FROM node:slim
RUN mkdir /app
WORKDIR /app
COPY ./package.json /app
RUN [ "npm", "install" ]
COPY . /app/
CMD [ "npm", "start" ]
把这个 Dockerfile
放到 Node.js 项目的根目录,构建好镜像后,就可以直接拿来启动容器运行。但是如果我们还有第二个 Node.js 项目也差不多呢?好吧,那就再把这个 Dockerfile
复制到第二个项目里。那如果有第三个项目呢?再复制么?文件的副本越多,版本控制就越困难,让我们继续看这样的场景维护的问题。
如果第一个 Node.js 项目在开发过程中,发现这个 Dockerfile
里存在问题,比如敲错字了、或者需要安装额外的包,然后开发人员修复了这个 Dockerfile
,再次构建,问题解决。第一个项目没问题了,但是第二个项目呢?虽然最初 Dockerfile
是复制、粘贴自第一个项目的,但是并不会因为第一个项目修复了他们的 Dockerfile
,而第二个项目的 Dockerfile
就会被自动修复。
那么我们可不可以做一个基础镜像,然后各个项目使用这个基础镜像呢?这样基础镜像更新,各个项目不用同步 Dockerfile
的变化,重新构建后就继承了基础镜像的更新?好吧,可以,让我们看看这样的结果。那么上面的这个 Dockerfile
就会变为:
FROM node:slim
RUN mkdir /app
WORKDIR /app
CMD [ "npm", "start" ]
这里我们把项目相关的构建指令拿出来,放到子项目里去。假设这个基础镜像的名字为 my-node
的话,各个项目内的自己的 Dockerfile
就变为:
FROM my-node
COPY ./package.json /app
RUN [ "npm", "install" ]
COPY . /app/
基础镜像变化后,各个项目都用这个 Dockerfile
重新构建镜像,会继承基础镜像的更新。
那么,问题解决了么?没有。准确说,只解决了一半。如果这个 Dockerfile
里面有些东西需要调整呢?比如 npm install
都需要加一些参数,那怎么办?这一行 RUN
是不可能放入基础镜像的,因为涉及到了当前项目的 ./package.json
,难道又要一个个修改么?所以说,这样制作基础镜像,只解决了原来的 Dockerfile
的前4条指令的变化问题,而后面三条指令的变化则完全没办法处理。
ONBUILD
可以解决这个问题。让我们用 ONBUILD
重新写一下基础镜像的 Dockerfile
:
FROM node:slim
RUN mkdir /app
WORKDIR /app
ONBUILD COPY ./package.json /app
ONBUILD RUN [ "npm", "install" ]
ONBUILD COPY . /app/
CMD [ "npm", "start" ]
这次我们回到原始的 Dockerfile
,但是这次将项目相关的指令加上 ONBUILD
,这样在构建基础镜像的时候,这三行并不会被执行。然后各个项目的 Dockerfile
就变成了简单地:
FROM my-node
是的,只有这么一行。当在各个项目目录中,用这个只有一行的 Dockerfile
构建镜像时,之前基础镜像的那三行 ONBUILD
就会开始执行,成功的将当前项目的代码复制进镜像、并且针对本项目执行 npm install
,生成应用镜像。
参考文档
Dockerfie
官方文档:https://docs.docker.com/engine/reference/builder/Dockerfile
最佳实践文档:https://docs.docker.com/develop/develop-images/dockerfile_best-practices/Docker
官方镜像Dockerfile
:https://github.com/docker-library/docs