Dockerfile 指令详解
说在前面:dockerfile的根本目的是去构建一个image
,在最后运行docker build
命令,并不是对containers
的管理
1. COPY和ADD 复制文件
在使用该指令的时候还可以加上 --chown=<user>:<group>
选项来改变文件的所属用户及所属组。
COPY --chown=55:mygroup files* /mydir/
COPY --chown=bin files* /mydir/
COPY --chown=1 files* /mydir/
COPY --chown=10:11 files* /mydir/
ADD
指令和 COPY
的格式和性质基本一致。但是在 COPY
基础上增加了一些功能。
ADD
指令,比如 <源路径>
可以是一个 URL
,就会下载,件权限自动设置为 600
。如果这并不是想要的权限,那么还需要增加额外的一层 RUN
进行权限调整,另外,如果下载的是个压缩包,需要解压缩,也一样还需要额外的一层 RUN
指令进行解压缩。所以不如直接使用 RUN
指令,然后使用 wget
或者 curl
工具下载,处理权限、解压缩、然后清理无用文件更合理。因此,这个功能其实并不实用,而且不推荐使用。
如果 <源路径>
为一个 tar
压缩文件的话,压缩格式为 gzip
, bzip2
以及 xz
的情况下,ADD
指令将会自动解压缩这个压缩文件到 <目标路径>
去。
在 Docker 官方的 Dockerfile 最佳实践文档 中要求,尽可能的使用 COPY
,因为 COPY
的语义很明确,就是复制文件而已,而 ADD
则包含了更复杂的功能,其行为也不一定很清晰。最适合使用 **ADD**
的场合,就是所提及的需要自动解压缩的场合。
2. CMD 和RUN 启动命令
Dockerfile 中每一个指令都会建立一层,RUN
也不例外。每一个 RUN
的行为,就和刚才我们手工建立镜像的过程一样:新建立一层,在其上执行这些命令,执行结束后,commit
这一层的修改,构成新的镜像。
仅仅使用一个 **RUN**
指令,并使用 **&&**
将各个所需命令串联起来。将之前的 7 层,简化为了 1 层。在撰写 Dockerfile 的时候,要经常提醒自己,这并不是在写 Shell 脚本,而是在定义每一层该如何构建。
还可以看到这一组命令的最后添加了清理工作的命令,删除了为了编译构建所需要的软件,清理了所有下载、展开的文件,并且还清理了 apt
缓存文件。这是很重要的一步,我们之前说过,镜像是多层存储,每一层的东西并不会在下一层被删除,会一直跟随着镜像。因此镜像构建时,一定要确保每一层只添加真正需要添加的东西,任何无关的东西都应该清理掉。
CMD
指令的格式和 RUN
相似,也是两种格式:
shell
格式:CMD <命令>
exec
格式:CMD ["可执行文件", "参数1", "参数2"...]
- 参数列表格式:
CMD ["参数1", "参数2"...]
。在指定了ENTRYPOINT
指令后,用CMD
指定具体的参数。
Docker 不是虚拟机,容器就是进程。既然是进程,那么在启动容器的时候,需要指定所运行的程序及参数。**CMD**
** 指令就是用于指定默认的容器主进程的启动命令的。**
在指令格式上,一般推荐使用 exec
格式,这类格式在解析时会被解析为 JSON 数组,因此一定要使用双引号 "
,而不要使用单引号。
CMD
和ENTRYPOINT
只能写一个,多个的话,以最后那个为准。
提到
CMD
就不得不提容器中应用在前台执行和后台执行的问题。这是初学者常出现的一个混淆。
Docker 不是虚拟机,容器中的应用都应该以前台执行,而不是像虚拟机、物理机里面那样,用 systemd
去启动后台服务,容器内没有后台服务的概念。
一些初学者将 CMD
写为:
CMD service nginx start
然后发现容器执行后就立即退出了。甚至在容器内去使用 systemctl
命令结果却发现根本执行不了。这就是因为没有搞明白前台、后台的概念,没有区分容器和虚拟机的差异,依旧在以传统虚拟机的角度去理解容器。
对于容器而言,其启动程序就是容器应用进程,容器就是为了主进程而存在的,主进程退出,容器就失去了存在的意义,从而退出,其它辅助进程不是它需要关心的东西。
而使用 service nginx start
命令,则是希望 upstart 来以后台守护进程形式启动 nginx
服务。而刚才说了 CMD service nginx start
会被理解为 CMD [ "sh", "-c", "service nginx start"]
,因此主进程实际上是 sh
。那么当 service nginx start
命令结束后,sh
也就结束了,sh
作为主进程退出了,自然就会令容器退出。
正确的做法是直接执行 nginx
可执行文件,并且要求以前台形式运行。比如:
CMD ["nginx", "-g", "daemon off;"]
3. ENTRYPOINT 入口点
那么有了 CMD
后,为什么还要有 ENTRYPOINT
呢?这种 <ENTRYPOINT> "<CMD>"
有什么好处么?让我们来看几个场景。
场景一:让镜像变成像命令一样使用
假设我们需要一个得知自己当前公网 IP 的镜像,那么可以先用 CMD
来实现:
FROM ubuntu:18.04RUN apt-get update \
&& apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
CMD [ "curl", "-s", "http://myip.ipip.net" ]
假如我们使用 docker build -t myip .
来构建镜像的话,如果我们需要查询当前公网 IP,只需要执行:
$ docker run myip
当前 IP:61.148.226.66 来自:北京市 联通
嗯,这么看起来好像可以直接把镜像当做命令使用了,不过命令总有参数,如果我们希望加参数呢?比如从上面的 CMD
中可以看到实质的命令是 curl
,那么如果我们希望显示 HTTP 头信息,就需要加上 -i
参数。那么我们可以直接加 -i
参数给 docker run myip
么?
$ docker run myip -i
docker: Error response from daemon: invalid header field value "oci runtime error: container_linux.go:247: starting container process caused \"exec: \\\"-i\\\": executable file not found in $PATH\"\n".
我们可以看到可执行文件找不到的报错,executable file not found
。之前我们说过,跟在镜像名后面的是 command
,运行时会替换 CMD
的默认值。因此这里的 -i
替换了原来的 CMD
,而不是添加在原来的 curl -s http://myip.ipip.net
后面。而 -i
根本不是命令,所以自然找不到。
那么如果我们希望加入 -i
这参数,我们就必须重新完整的输入这个命令:
$ docker run myip curl -s http://myip.ipip.net -i
这显然不是很好的解决方案,而使用 ENTRYPOINT
就可以解决这个问题。现在我们重新用 ENTRYPOINT
来实现这个镜像:
FROM ubuntu:18.04RUN apt-get update \
&& apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT [ "curl", "-s", "http://myip.ipip.net" ]
这次我们再来尝试直接使用 docker run myip -i
:
$ docker run myip
当前 IP:61.148.226.66 来自:北京市 联通
$ docker run myip -i
HTTP/1.1 200 OK
Server: nginx/1.8.0
Date: Tue, 22 Nov 2016 05:12:40 GMT
Content-Type: text/html; charset=UTF-8
Vary: Accept-Encoding
X-Powered-By: PHP/5.6.24-1~dotdeb+7.1
X-Cache: MISS from cache-2
X-Cache-Lookup: MISS from cache-2:80
X-Cache: MISS from proxy-2_6
Transfer-Encoding: chunked
Via: 1.1 cache-2:80, 1.1 proxy-2_6:8006
Connection: keep-alive
当前 IP:61.148.226.66 来自:北京市 联通
可以看到,这次成功了。这是因为当存在 ENTRYPOINT
后,CMD
的内容将会作为参数传给 ENTRYPOINT
,而这里 -i
就是新的 CMD
,因此会作为参数传给 curl
,从而达到了我们预期的效果。
场景二:应用运行前的准备工作
启动容器就是启动主进程,但有些时候,启动主进程前,需要一些准备工作。
比如 mysql
类的数据库,可能需要一些数据库配置、初始化的工作,这些工作要在最终的 mysql 服务器运行之前解决。
此外,可能希望避免使用 root
用户去启动服务,从而提高安全性,而在启动服务前还需要以 root
身份执行一些必要的准备工作,最后切换到服务用户身份启动服务。或者除了服务外,其它命令依旧可以使用 root
身份执行,方便调试等。
这些准备工作是和容器 CMD
无关的,无论 CMD
为什么,都需要事先进行一个预处理的工作。这种情况下,可以写一个脚本,然后放入 ENTRYPOINT
中去执行,而这个脚本会将接到的参数(也就是 <CMD>
)作为命令,在脚本最后执行。比如官方镜像 redis
中就是这么做的:
FROM alpine:3.4
...
RUN addgroup -S redis && adduser -S -G redis redis
...
ENTRYPOINT ["docker-entrypoint.sh"]
EXPOSE 6379
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)" = '0' ]; then
find . \! -user redis -exec chown redis '{}' +
exec gosu redis "$0" "$@"
fi
exec "$@"
该脚本的内容就是根据 CMD
的内容来判断,如果是 redis-server
的话,则切换到 redis
用户身份启动服务器,否则依旧使用 root
身份执行。比如:
$ docker run -it redis id
uid=0(root) gid=0(root) groups=0(root)
4. ENV和ARG 环境变量
ENV格式有两种:
ENV <key> <value>
ENV <key1>=<value1> <key2>=<value2>...
ARG格式:ARG <参数名>[=<默认值>]
ARG
所设置的构建环境的环境变量,在将来容器运行时是不会存在这些环境变量的。但是不要因此就使用 ARG
保存密码之类的信息,因为 docker history
还是可以看到所有值的。
ARG 指令有生效范围,如果在 FROM
指令之前指定,那么只能用于 FROM
指令中。
感觉ENV
比ARG
实用
5. VOLUME 定义匿名卷
格式为:
VOLUME ["<路径1>", "<路径2>"...]
VOLUME <路径>
容器运行时应该尽量保持容器存储层不发生写操作,对于数据库类需要保存动态数据的应用,其数据库文件应该保存于卷(volume)中。在 Dockerfile
中,我们可以事先指定某些目录挂载为匿名卷,这样在运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据。
当然,运行容器时可以覆盖这个挂载设置。比如:
$ docker run -d -v mydata:/data xxxx
在这行命令中,就使用了 mydata
这个命名卷挂载到了 /data
这个位置,替代了 Dockerfile
中定义的匿名卷的挂载配置。
6. EXPOSE 声明端口
格式为 EXPOSE <端口1> [<端口2>...]
**EXPOSE**
** 仅仅是声明容器打算使用什么端口而已,并不会自动在宿主进行端口映射**
7. WORKDIR 指定工作目录
格式为 WORKDIR <工作目录路径>
。
使用 WORKDIR
指令可以来指定工作目录(或者称为当前目录),以后各层的当前目录就被改为指定的目录,如该目录不存在,WORKDIR
会帮你建立目录。
之前提到一些初学者常犯的错误是把 Dockerfile
等同于 Shell 脚本来书写,这种错误的理解还可能会导致出现下面这样的错误:
RUN cd /app
RUN echo "hello" > world.txt
如果将这个 Dockerfile
进行构建镜像运行后,会发现找不到 /app/world.txt
文件,或者其内容不是 hello
。原因其实很简单,在 Shell 中,连续两行是同一个进程执行环境,因此前一个命令修改的内存状态,会直接影响后一个命令;而在 Dockerfile
中,这两行 RUN
命令的执行环境根本不同,是两个完全不同的容器。这就是对 Dockerfile
构建分层存储的概念不了解所导致的错误。
因此如果需要改变以后各层的工作目录的位置,那么应该使用 WORKDIR
指令。
WORKDIR /app
RUN echo "hello" > world.txt
如果你的 WORKDIR
指令使用的相对路径,那么所切换的路径与之前的 WORKDIR
有关:
WORKDIR /a
WORKDIR b
WORKDIR c
RUN pwd
RUN pwd
的工作目录为 /a/b/c
。
8. USER 指定当前用户
格式:USER <用户名>[:<用户组>]
USER
指令和 WORKDIR
相似,都是改变环境状态并影响以后的层。WORKDIR
是改变工作目录,USER
则是改变之后层的执行 RUN
, CMD
以及 ENTRYPOINT
这类命令的身份。
注意,USER
只是帮助你切换到指定用户而已,这个用户必须是事先建立好的,否则无法切换。
如果没有用户,需要先RUN groupadd -r group && useradd -r -g user group
9. HEALTHCHECK 健康检查
自 1.12 之后,Docker 提供了 HEALTHCHECK
指令,通过该指令指定一行命令,用这行命令来判断容器主进程的服务状态是否还正常,从而比较真实的反应容器实际状态。
当在一个镜像指定了 HEALTHCHECK
指令后,用其启动容器,初始状态会为 starting
,在 HEALTHCHECK
指令检查成功后变为 healthy
,如果连续一定次数失败,则会变为 unhealthy
。
和
CMD
,ENTRYPOINT
一样,HEALTHCHECK
只可以出现一次,如果写了多个,只有最后一个生效。
感觉不重要,放个链接在这,HEALTHCHECK 健康检查
10. 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
,生成应用镜像。
11. LABEL 指令
LABEL
指令用来给镜像以键值对的形式添加一些元数据(metadata)。
参考:Dockerfile 指令详解 - LABEL 为镜像添加元数据 - 《Docker — 从入门到实践(v1.3.0)》 - 书栈网 · BookStack
12. SHELL 指令
格式:SHELL ["executable", "parameters"]
SHELL
指令可以指定 RUN
ENTRYPOINT
CMD
指令的 shell,Linux 中默认为 ["/bin/sh", "-c"]
13. 多阶段构建
Docker v17.05 开始支持多阶段构建 (multistage builds
)。使用多阶段构建我们就可以很容易解决前面提到的问题,并且只需要编写一个 Dockerfile
参考:使用镜像 - Dockerfile 多阶段构建 - 《Docker — 从入门到实践(v1.3.0)》 - 书栈网 · BookStack
14. 构建多种系统架构支持的 Docker 镜像 — docker manifest 命令详解
我们发现在 Linux x86_64
和 Linux arm64v8
架构的计算机中分别使用 golang:alpine
镜像运行容器 $ docker run golang:alpine go version
时,容器能够正常的运行。
这是什么原因呢?
原因就是 golang:alpine
官方镜像有一个 [manifest](https://docs.docker.com/registry/spec/manifest-v2-2/)
列表 ([manifest list](https://docs.docker.com/registry/spec/manifest-v2-2/)
)。
当用户获取一个镜像时,Docker 引擎会首先查找该镜像是否有 manifest
列表,如果有的话 Docker 引擎会按照 Docker 运行环境(系统及架构)查找出对应镜像(例如 golang:alpine
)。如果没有的话会直接获取镜像(例如上例中我们构建的 username/test
)。
我们可以使用 $ docker manifest inspect golang:alpine
查看这个 manifest
列表的结构。
参考:使用镜像 - 构建多种系统架构支持的 Docker 镜像 - 《Docker — 从入门到实践(v1.3.0)》 - 书栈网 · BookStack