前面已经讲解了FROM、RUN指令,还提及了COPY、ADD,接下来学习其他的指令
5.Dockerfile 指令详解
1> COPY 复制文件
格式:
- COPY <源路径> ...<目标路径>
- COPY ["<源路径1>",..."<目标路径>"]
和 RUN指令一样,也有两种格式,一种类似于shell命令行,一种类似于exec函数调用
COPY指令将从构建上下文目录中 <源路径>的文件/目录复制到新的一层的镜像内的 <目标路径>位置。比如:
COPY package.json /usr/src/app/
<源路径>可以是多个,甚至可以是通配符,其通配符规则要满足 Go 的 filepath.Match规则,如
COPY hom* /mydir/
COPY hom?.txt /mydir/
1)如果 <源路径>是一个文件,且<目标路径>是以/结尾的,则docker会把目标路径当作一个目录,会把源文件拷贝到该目录下。
2)如果 <源路径>是一个文件,且<目标路径>不是以/结尾的,则docker会将其视为一个文件,那么可能分下面几种情况:
- 如果目标文件不存在,将会以该目标文件名字为名创建一个文件,内容与源文件相同;
- 如果目标文件存在,就直接用源文件的内容覆盖目标文件的内容,目标文件名不变;
- 如果这个不是以/结尾的<目标路径>其实就是一个目录,那么会将源文件拷贝到该目录下。⚠️这种情况下最好还是加/
3)如果<源路径>是个目录,且<目标路径>并不存在,则会先创建<目标路径>,然后将<源路径>的所有内容拷贝进来
4)如果<源路径>是个目录,<目标路径>存在,则直接拷贝即可
5)<目标路径>可以是容器内的绝对路径,也可以是相对于工作目录的相对路径(工 作目录可以用 WORKDIR指令来指定)。目标路径不需要事先创建,如果目录不存在会在复制文件前先行创建缺失目录。
此外,还需要注意一点,使用 COPY指令,源文件的各种元数据都会保留。比如 读、写、执行权限、文件变更时间等。这个特性对于镜像定制很有用。特别是构建 相关文件都在使用 Git 进行管理的时候。
2> ADD 更高级的复制文件(相对于COPY)——但是尽可能使用COPY(除了需自动解压缩的情况)
其在COPY的基础上增加了一些功能,如:
1)比如<源路径>可以是一个 URL,这种情况下,Docker 引擎会试图去下载这个链接的文件放到<目标路径>去。下载后的文件权限自动设置为600,如果这并不是想要的权限,那么还需要增加额外的一层 RUN 进行权限调整
2)如果下载的是个压缩包,需要解压缩,也一样还需要额外的一层 RUN 指令进行解压缩。
所以不如直接使用 RUN指令,然后使用 wget或者 curl 工具下载,处理权限、解压缩、然后清理无用文件更合理。因此,这个功能其实并不实用,而且不推荐使用。
3)具有自动解压缩的功能:如果 <源路径>为一个 tar压缩文件的话,压缩格式为gzip , bzip2以及 xz的情况下, ADD指令将会自动解压缩这个压缩文件到 <目标路径>去,如:
FROM scratch
ADD ubuntu-xenial-core-cloudimg-amd64-root.tar.gz /
但是如果我们仅仅只是希望复制整个压缩文件,而不希望将其解压缩时,就不能够使用ADD指令了
⚠️所以尽可能的使用COPY ,因为 COPY的语义很明确,就是复制文件而已,而 ADD则包含了更复杂的功能,其行为也不一定很清晰。最适合使用 ADD的场合,就是所提及的需要自动解压缩的场合。
而且指令会令镜像构建缓存失效,从而可能会令镜像构建变得比较缓慢。
3> CMD 容器启动命令(就是声明容器启动时会执行的命令)
格式:
- shell格式: CMD <命令>
- exec格式:CMD["可执行文件","参数1","参数2"...]
- 参数列表格式:CMD["参数1","参数2"...] 。在指定了ENTRYPOINT指令后,使用该格式的CMD指定具体的参数
容器就是进程。因此在启动容器时,需要指定所运行的程序和参数。
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数组,所以一定要使用双引号,不能使用单引号
如果使用的是shell格式,那么实际的命令会作为命令sh -c的参数来执行,如
CMD echo $HOME
实际上变成:
CMD [ "sh", "-c", "echo $HOME" ]
这就是为什么可以使用环境变量$HOME的原因,因为可以被shell解析
⚠️提到 CMD就不得不提容器中应用在前台执行和后台执行的问题。这是初学者常出现的一个混淆。
Docker 不是虚拟机,容器中的应用都应该以前台执行,而不是像虚拟机、物理机 里面那样,用 upstart/systemd 去启动后台服务,容器内没有后台服务的概念。
对于容器而言,其启动程序就是容器应用进程,容器就是为了主进程而存在的,主进程退出,容器就无意义了,其不关心其他辅助进程
如错误命令:
CMD service nginx start
会发现容器执行后就立即退出了,为什么?
其实service nginx start命令是希望upstart以后台守护进程形式启动nginx服务。
但是上面的命令其实会变成CMD [ "sh", "-c", "service nginx start" ],主进程实际上是sh。所以当service nginx start命令结束后,sh也就结束了,sh主进程将退出,自然容器也会跟着退出
正确写法是直接执行nginx可执行文件,然后以前台形式运行:
CMD ["nginx", "-g", "daemon off;"]
4> ENTRYPOINT 入口点——指定容器启动程序及参数
与CMD相似,但是在docker build运行时需要 --entrypoint参数来指定
当指定了ENTRYPOINT指令后,CMD的内容将作为参数传给ENTRYPOINT,如上面CMD的格式3中所说
为什么有了CMD还需要ENTRYPOINT?
1)让镜像变成像命令一样使用
举例——如果我们想要知道自己当前公网IP的镜像,用CMD实现。则Dockerfile为:
FROM ubuntu:16.04
RUN apt-get update \
&& apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
CMD [ "curl", "-s", "http://ip.cn" ]
构建镜像myip:
docker build -t myip .
然后我们要查询当前公网IP,则运行:
docker run myip
这就会默认运行CMD指令中的 curl -s http://ip.cn
有问题的地方是:
如果这个时候我希望得到HTTP头信息,就是需要在curl命令后加 -i 参数,但是如果我运行:
docker run myip -i
会报错,因为实际上是用-i将整个curl -s http://ip.cn命令给覆盖了,所以只能运行:
docker run myip curl -s http://ip.cn -i
这就是CMD不好的地方,但是使用ENTRYPOINT就能够解决这个问题
ENTRYPOINT实现方案为:
FROM ubuntu:16.04
RUN apt-get update \
&& apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT [ "curl", "-s", "http://ip.cn" ]
这时候运行docker run myip -i就能够得到想要的结果,-i会作为参数传给ENTRYPOINT
2)应用运行前的准备工作
启动容器即启动主进程,在启动主进程前需要进行一些准备工作
比如mysql类数据库需要进行一些数据库配置、初始化等工作;或者是希望避免使用root用户...
这些是与CMD无关的。这种情况一般是写一个脚本放到ENTRYPOINT执行,脚本需要的参数会写到CMD中,如官方镜像redis:
FROM alpine:3.4
...
RUN addgroup -S redis && adduser -S -G redis redis
...
ENTRYPOINT ["docker-entrypoint.sh"]
EXPOSE
CMD [ "redis-server" ]
可以看到其中为了 redis 服务创建了 redis 用户,并在最后指定了 ENTRYPOINT为docker-entrypoint.sh脚本
#!/bin/sh
...
# allow the container to be started with `--user`
if [ "$1" = 'redis-server' -a "$(id -u)" = '' ]; then
chown -R redis .
exec su-exec redis "$0" "$@"
fi
exec "$@"
$1即传入的第一个参数,在CMD中写着,即"redis-server"。如果是"redis-server"的话,就切换到redis用户身份启动服务器;否则仍使用root身份
⚠️⚠️除此之外,CMD和ENTERPOINT的区别还有:
CMD:
- 它是容器启动时默认执行的命令
- 如果在运行docker run时指定了要运行的命令,那么CMD命令就会被忽略
- 如果在Dockerfile文件中定义了多个CMD,只有最后一个有效
- 因此可以使用ENTERPOINT来替代上面的CMD
- 而且如果执行语句 docker run -it imageName /bin/bash,那么CMD命令就不会被执行
ENTERPOINT:
- 容器会以应用程序或者服务的形式来运行它,这也是上面的-i能直接作为参数传给ENTERPOINT命令的原因
- 该命令是一定会执行的,不会像CMD可能被忽略
5>ENV 设置环境变量
格式:
- ENV <key> <value>
- ENV <key1>=<value1> <key2>=<value2>
如:
ENV VERSION=1.0 DEBUG=on \
NAME="Happy Feet"
⚠️使用\进行换行,带有空格的值要用双引号括起来
ENV NODE_VERSION 7.2.
RUN curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NOD
E_VERSION-linux-x64.tar.xz" \
&& curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS25
.txt.asc"
从上面的例子可以看见,在RUN中多次使用$NODE_VERSION来操作定制。这样以后如果版本有所更改,只用改变ENV即可,维护更方便
支持环境变量展开的指令还有:
ADD、COPY 、ENV 、EXPOSR、LABEL 、USER 、WORKDIR 、VOLUME 、STOPSIGNAL 、ONBUILD
6> ARG 构建参数
格式:ARG <参数名>[=<默认值>]
1)当实现与ENV的效果一样时,用来设置环境变量。不同在于——ARG所设置的构建环境的环境变量,在将来容器运行时是不会存在这些环境变量的。但是不要因 此就使用 ARG保存密码之类的信息,因为 docker history还是可以看到所有 值的。
2)可用于定义参数名称及其默认值。该默认值可以在构建命令docker build中用 --build-arg <参数名>=<值> 来覆盖默认值
7> VOLUME 定义匿名卷
格式:
- VOLUME ["<路径1>","<路径2>"...]
- VOLUME <路径>
容器运行时应该尽量保持容器存储层不发生写操作,对于数据库类需要保存动态数据的应用,其数据库文件应该保存于卷(volume)中
为了防止运行时用户忘记将动态文件所保存目录挂载为卷,在 Dockerfile中,我们可以事先指定某些目录挂载为匿名卷,这样在运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据。
VOLUME /data
/data目录就会在运行时自动挂载为匿名卷,任何向 中写入的 信息都不会记录进容器存储层,从而保证了容器存储层的无状态化。当然,运行时可以覆盖这个挂载设置。比如:
docker run -d -v mydata:/data xxxx
在这行命令中,就使用了 mydata这个命名卷挂载到了 /data这个位置,替代 Dockerfile中定义的匿名卷的挂载配置
8> EXPOSE 声明端口
格式:EXPOSE <端口1> [<端口2>...]
声明运行时容器提供服务端口,这只是一个声明,在运行时并不 会因为这个声明应用就会开启这个端口的服务
声明好处:
- 一个是帮助镜像使用者理解这个镜像服务的守护端口,以方便配置映射
- 另一个用处则是在运行时使用随机端口映射时,也就docker run -p 时,会自动随机映射 EXPOSE 的端口
⚠️要将 EXPOSE和在运行时使用 -p <宿主端口>:<容器端口>区分开来
-p是映射宿主端口和容器端口,换句话说,就是将容器的对应端口服务公开给外界访问。
而 EXPOSE仅仅是声明容器打算使用什么端口而已,并不会自动在宿主进行 端口映射。
9> WORKDIR 指定工作目录
格式:WORKDIR <工作目录路径>
以后各层的当前目录就被改为指定的目录,该目录需要已经存在, WORKDIR并不会帮你建立目录。
之前提到一些初学者常犯的错误是把 等同于 Shell 脚本来书写,这种错误的理解还可能会导致出现下面这样的错误:
RUN cd /app
RUN echo "hello" > world.txt
如果将这个 Dockerfile 进行构建镜像运行后,会发现找不到 /app/world.txt文 件,或者其内容不是hello 。
原因其实很简单,在 Shell 中,连续两行是同一个进程执行环境,因此前一个命令修改的内存状态,会直接影响后一个命令;而在 Dockerfile 中,这两行 命令的执行环境根本不同,是两个完全不同的容器。
之前说过每一个 RUN都是启动一个容器、执行命令、然后提交存储层文件变更。 RUN cd/app第一层 的执行仅仅是当前进程的工作目录变更,一个内存上的变化而已,其结果不会造成任何文件变更。
而到第二层的时候,启动的是一个全新的容器,跟第一层的容器更完全没关系,自然不可能继承前一层构建过程中的内存变化。
因此如果需要改变以后各层的工作目录的位置,那么应该使用 WORKDIR指令。
10> USER 指定当前用户
格式 : USER <用户名>
USER指令和 WORKDIR相似,都是改变环境状态并影响以后的层
USER是改变之后层执行 RUN, CMD以及ENTRYPOINT这类命令的身份
但是USER只是帮助你切换到指定用户而已,这个用户必须是事先建立好的,否则无法切换,举例:
RUN groupadd -r redis && useradd -r -g redis redis
USER redis
RUN [ "redis-server" ]
如果以 root执行的脚本,在执行期间希望改变身份,比如希望以某个已经建立好的用户来运行某个服务进程,不要使用 su 或sudo者 ,这些都需要比较麻烦的配置,而且在 TTY 缺失的环境下经常出错。建议使用gosu,可以从其项目网站看到进一步的信息:https://github.com/tianon/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" ]
11> HEALTHCHECK 健康检查
格式:
- HEALHTHCHECK [选项] CMD <命令>:设置检查容器健康状况的命令
- HEALHTHCHECK NONE :如果基础镜像有健康检查指令,使用这行可以屏蔽掉其健康检查指令
该指令是告诉 Docker 应该如何进行判断容器的状态是否正常
当在一个镜像指定了HEALHTHCHECK指令后,用其启动容器,初始状态会为starting, 在HEALHTHCHECK指令检查成功后变为healthy ;如果连续一定 次数失败,则会变为unhealthy
支持下列选项:
- --internal=<间隔> :两次健康检查的间隔,默认为 30 秒;
- --timeout=<时长>:健康检查命令运行超时时间,如果超过这个时间,本次健康检查就被视为失败,默认 30 秒;
- --retries=<次数>:当连续失败指定次数后,则将容器状态视为unhealthy,默认 3 次。
和CMD , ENTRYPOINT一样, HEALHTHCHECK只可以出现一次,如果写了多个, 只有最后一个生效。
CMD <命令>中命令的返回值决定这次健康检查的成功与否:0为成功,1为失败,2为保留,不要使用这个返回值
假设我们有个镜像是个最简单的 Web 服务,我们希望增加健康检查来判断其 Web 服务是否在正常工作,我们可以用 curl来帮助判断,其Dockerfile 的HEALHTHCHECK可以这么写:
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 -t myweb:v1 .
然后启动容器:
docker run -d --name web -p : myweb:v1
然后可以使用docker ps查看容器最初状态为(health:starting):
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
03e28eb00bd0 myweb:v1 "nginx -g 'daemon off" seconds ago Up seconds (health: starting) /tcp,/tcp web
等待几秒后再查看就变成了healthy
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
03e28eb00bd0 myweb:v1 "nginx -g 'daemon off" seconds ago Up seconds (healthy) /tcp,/tcp web
如果连续失败超过重试次数,状态就会变成unhealthy
为了帮助排障,健康检查命令的输出(包括 stdout以及 stderr)都会被存储于健康状态里,可以用docker inspect来查看
$ docker inspect --format '{{json .State.Health}}' web | python -m json.tool
{
"FailingStreak": ,
"Log": [
{
"End": "2016-11-25T14:35:37.940957051Z",
"ExitCode": ,
"Output": "<!DOCTYPE html>\n<html>\n<head>\n<title>W
elcome to nginx!</title>\n<style>\n body {\n width:
em;\n margin: auto;\n font-family: Tahoma, Verda
na, Arial, sans-serif;\n }\n</style>\n</head>\n<body>\n<h1>We
lcome to nginx!</h1>\n<p>If you see this page, the nginx web ser
ver is successfully installed and\nworking. Further configuratio
n is required.</p>\n\n<p>For online documentation and support pl
ease refer to\n<a href=\"http://nginx.org/\">nginx.org</a>.<br/>
\nCommercial support is available at\n<a href=\"http://nginx.com
/\">nginx.com</a>.</p>\n\n<p><em>Thank you for using nginx.</em>
</p>\n</body>\n</html>\n",
"Start": "2016-11-25T14:35:37.780192565Z"
}
],
"Status": "healthy"
}
12> 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" ]
如果有相似的Node.js项目,就复制该Dockerfile。这会导致文件副本越来越多
除此之外,如果开发中发现该Dockerfile存在问题,那么对第一个项目的Dockerfile进行了更改,那么其他相似项目的Dockerfile怎么办
这些情况就可以用ONBUILD进行解决了:
1)首先构建一个基础镜像,这样各个相似的项目就能够使用这个基础镜像,这样就可以对基础镜像进行更新,各个项目不用同步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重新构建镜像,会继承基础镜像的更新。
2)上面只解决了一般的问题,如果子项目中Dockerfile中有东西需要调整怎么办?
比如可能npm install 突然需要添加一些参数,又该怎么办?是不可能将RUN [ "npm", "install" ]写入基础镜像的,难道又要一个个对子项目的Dockerfile进行修改吗?
上面的问题能够使用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" ]
这样在构建基础镜像的时候,ONBUILD这三行并不会被执行。然后各个项目的Dockerfile就变成了简单地:
FROM my-node
当在各个项目目录中,用这个只有一行的 Dockerfile构建镜像时,之前基础镜像的那三行ONBUILD就会开始执行,成功的将当前项目的代码复制进镜像、并且针对本项目执行 npm install,生成应用镜像。
这样,如果想要对RUN [ "npm", "install" ]添加参数 --save时,就不需要到一个个子项目的Dockerfile中去添加--save了,只用在基础镜像的Dockerfile中进行更改即可
13)LABEL
为镜像指定标签
格式:
LABEL <key>=<value> <key>=<value> <key>=<value> ...
可见一个Dockerfile是可以有多个标签的,如:
LABEL multi.label1="value1" \
multi.label2="value2" \
other="value3"