使用 Dockerfile 定制镜像详解
- 1.Dockerfile
- FROM 指定基础镜像
- RUN 执行命令
- 构建镜像
- 2.COPY 复制文件
- 3.ADD 更高级的复制文件
- 4.CMD 容器启动命令
- 5.ENTRYPOINT 入口点
- 6.ENV 设置环境变量
- 7.ARG 构建参数
- 8.VOLUME 定义匿名卷
- 9.EXPOSE 暴露端口
- 10.WORKDIR 指定工作目录
- 11.USER 指定当前用户
- 12.LABEL 为镜像添加元数据
- 13.SHELL 指令
1.Dockerfile
Dockerfile 是一个文本文件,其内包含了一条条的指令,每一条指令构建一层,因此每一条指令的内容,就是描述该层应当如何构建
还以之前定制 nginx 镜像为例,这次我们使用 Dockerfile 来定制:
FROM nginx
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
FROM 指定基础镜像
FROM 就是指定 基础镜像,因此一个 Dockerfile 中 FROM 是必备的指令,并且必须是第一条指令
在 Docker Hub 上有非常多的高质量的官方镜像,有可以直接拿来使用的服务类的镜像,如 nginx、redis、mongo、mysql、httpd、php、tomcat 等;也有一些方便开发、构建、运行各种语言应用的镜像,如 node、openjdk、python、ruby、golang 等。可以在其中寻找一个最符合我们最终目标的镜像为基础镜像进行定制
除了选择现有镜像为基础镜像外,Docker 还存在一个特殊的镜像,名为 scratch。这个镜像是虚拟的概念,并不实际存在,它表示一个空白的镜像:
FROM scratch
...
如果你以 scratch 为基础镜像的话,意味着你不以任何镜像为基础,接下来所写的指令将作为镜像第一层开始存在
RUN 执行命令
RUN 指令是用来执行命令行命令的。由于命令行的强大能力,RUN 指令在定制镜像时是最常用的指令之一。其格式有两种:
- shell 格式:
RUN <命令>
,就像直接在命令行中输入的命令一样
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
- exec 格式:
RUN ["可执行文件", "参数1", "参数2"]
,这更像是函数调用中的格式
Dockerfile 中每一个指令都会建立一层,RUN 也不例外。每一个 RUN 的行为,就和刚才我们手工建立镜像的过程一样:新建立一层,在其上执行这些命令,执行结束后,commit 这一层的修改,构成新的镜像。所以在执行多条命令的场景下,最好遵守如下的规范:
FROM debian:stretch
RUN set -x; buildDeps='gcc libc6-dev make wget' \
&& apt-get update \
&& apt-get install -y $buildDeps \
&& wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz" \
&& mkdir -p /usr/src/redis \
&& tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \
&& make -C /usr/src/redis \
&& make -C /usr/src/redis install \
&& rm -rf /var/lib/apt/lists/* \
&& rm redis.tar.gz \
&& rm -r /usr/src/redis \
&& apt-get purge -y --auto-remove $buildDeps
这一组命令的最后添加了清理工作的命令,删除了为了编译构建所需要的软件,清理了所有下载、展开的文件,并且还清理了 apt 缓存文件。这是很重要的一步,镜像是多层存储,每一层的东西并不会在下一层被删除,会一直跟随着镜像。因此镜像构建时,一定要确保每一层只添加真正需要添加的东西,任何无关的东西都应该清理掉
构建镜像
1、编写dockerfile
FROM nginx
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
2、在 Dockerfile 文件所在目录执行
docker build -t nginx .
从命令的输出结果中,我们可以清晰的看到镜像的构建过程
镜像构建上下文中的坑🕳
当我们进行镜像构建的时候,并非所有定制都会通过 RUN 指令完成,经常会需要将一些本地文件复制进镜像,比如通过 COPY 指令、ADD 指令等。而 docker build 命令构建镜像,其实并非在本地构建,而是在服务端,也就是 Docker 引擎中构建的。那么在这种客户端/服务端的架构中,如何才能让服务端获得本地文件呢?
这就引入了上下文的概念。当构建的时候,用户会指定构建镜像上下文的路径,docker build 命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎收到这个上下文包后,展开就会获得构建镜像所需的一切文件
有些初学者在发现 COPY /opt/xxxx /app
不工作后,于是干脆将 Dockerfile 放到了硬盘根目录去构建,结果发现 docker build 执行后,在发送一个几十 GB 的东西,极为缓慢而且很容易构建失败。那是因为这种做法是在让 docker build 打包整个硬盘,这显然是使用错误
一般来说,应该会将 Dockerfile 置于一个空目录下,或者项目根目录下。如果该目录下没有所需文件,那么应该把所需文件复制一份过来。如果目录下有些东西确实不希望构建时传给 Docker 引擎,那么可以用 .gitignore
一样的语法写一个 .dockerignore
,该文件是用于剔除不需要作为上下文传递给 Docker 引擎的
2.COPY 复制文件
COPY 指令将从构建上下文目录中 <源路径> 的文件/目录复制到新的一层的镜像内的 <目标路径> 位置。比如:
COPY package.json /usr/src/app/
使用 COPY 指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等
在使用该指令的时候还可以加上 --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/
如果源路径为文件夹,复制的时候不是直接复制该文件夹,而是将文件夹中的内容复制到目标路径
3.ADD 更高级的复制文件
ADD 指令和 COPY 的格式和性质基本一致。但是在 COPY 基础上增加了一些功能
比如 <源路径> 可以是一个 URL,这种情况下,Docker 引擎会试图去下载这个链接的文件放到 <目标路径> 去。下载后的文件权限自动设置为 600
如果 <源路径> 为一个 tar 压缩文件的话,压缩格式为 gzip, bzip2 以及 xz 的情况下,ADD 指令将会自动解压缩这个压缩文件到 <目标路径> 去
Docker 官方的 Dockerfile 最佳实践文档 中要求,尽可能的使用 COPY,因为 COPY 的语义很明确,就是复制文件而已,而 ADD 则包含了更复杂的功能,其行为也不一定很清晰。最适合使用 ADD 的场合,就是所提及的需要自动解压缩的场合
ADD 指令会令镜像构建缓存失效,从而可能会令镜像构建变得比较缓慢
4.CMD 容器启动命令
在启动容器的时候,需要指定所运行的程序及参数。CMD 指令就是用于指定默认的容器主进程的启动命令的
CMD 指令的格式和 RUN 相似,也是两种格式:
- shell 格式:CMD <命令>
- exec 格式:CMD [“可执行文件”, “参数1”, “参数2”…]
在指令格式上,一般推荐使用 exec 格式,这类格式在解析时会被解析为 JSON 数组,因此一定要使用双引号 "
,而不要使用单引号
如果使用 shell 格式的话,实际的命令会被包装为 sh -c
的参数的形式进行执行。比如:
CMD echo $HOME
在实际执行中,会将其变更为:
CMD [ "sh", "-c", "echo $HOME" ]
坑🕳:容器执行后就立即退出了
容器内没有后台服务的概念。一些初学者将 CMD 写为:
CMD service nginx start
然后发现容器执行后就立即退出了。这就是因为没有搞明白前台、后台的概念
对于容器而言,其启动程序就是容器应用进程,容器就是为了主进程而存在的,主进程退出,容器就失去了存在的意义,从而退出,其它辅助进程不是它需要关心的东西
而使用 service nginx start
命令,则是希望 init 系统以后台守护进程的形式启动 nginx 服务。而刚才说了 CMD service nginx start
会被理解为 CMD [ "sh", "-c", "service nginx start"]
,因此主进程实际上是 sh。那么当 service nginx start
命令结束后,sh 也就结束了,sh 作为主进程退出了,自然就会令容器退出
正确的做法是直接执行 nginx 可执行文件,并且要求以前台形式运行。比如:
CMD ["nginx", "-g", "daemon off;"]
5.ENTRYPOINT 入口点
ENTRYPOINT 的目的和 CMD 一样,都是在指定容器启动程序及参数
那么有了 CMD 后,为什么还要有 ENTRYPOINT 呢?
1、场景一:让镜像变成像命令一样使用
假设我们需要一个得知自己当前公网 IP 的镜像
FROM ubuntu:18.04
RUN apt-get update \
&& apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT [ "curl", "-s", "http://myip.ipip.net" ]
构建镜像:
docker build -t myip .
我们来尝试直接使用 docker run myip -i
:
$ docker run myip
当前 IP:xx.xx.xx.xx 来自:纽约市 联信
$ 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:xx.xx.xx.xx 来自:纽约市 联信
当存在 ENTRYPOINT 后,CMD 的内容将会作为参数传给 ENTRYPOINT,而这里 -i 就是新的 CMD,因此会作为参数传给 curl,从而达到了我们预期的效果
2、场景二:应用运行前的准备工作
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" ]
6.ENV 设置环境变量
格式有两种:
ENV <key> <value>
ENV <key1>=<value1> <key2>=<value2>...
定义了环境变量,那么在后续的指令中,就可以使用这个环境变量。比如在官方 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 构建维护变得更轻松了
通过环境变量,我们可以让一份 Dockerfile 制作更多的镜像,只需使用不同的环境变量即可
7.ARG 构建参数
格式:ARG <参数名>[=<默认值>]
构建参数和 ENV 的效果一样,都是设置环境变量。所不同的是,ARG 所设置的构建环境的环境变量,在将来容器运行时是不会存在这些环境变量的
灵活的使用 ARG 指令,能够在不修改 Dockerfile 的情况下,构建出不同的镜像
ARG 指令有生效范围,如果在 FROM 指令之前指定,那么只能用于 FROM 指令中:
ARG DOCKER_USERNAME=library
FROM ${DOCKER_USERNAME}/alpine
RUN set -x ; echo ${DOCKER_USERNAME}
使用上述 Dockerfile 会发现无法输出 ${DOCKER_USERNAME}
变量的值,要想正常输出,你必须在 FROM 之后再次指定 ARG:
# 只在 FROM 中生效
ARG DOCKER_USERNAME=library
FROM ${DOCKER_USERNAME}/alpine
# 要想在 FROM 之后使用,必须再次指定
ARG DOCKER_USERNAME=library
RUN set -x ; echo ${DOCKER_USERNAME}
8.VOLUME 定义匿名卷
格式为:
- VOLUME [“<路径1>”, “<路径2>”…]
- VOLUME <路径>
容器运行时应该尽量保持容器存储层不发生写操作,对于数据库类需要保存动态数据的应用,其数据库文件应该保存于卷(volume)中
为了防止运行时用户忘记将动态文件所保存目录挂载为卷,在 Dockerfile 中,我们可以事先指定某些目录挂载为匿名卷,这样在运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据
VOLUME /data
在这行命令中,就使用了 mydata 这个命名卷挂载到了 /data 这个位置,替代了 Dockerfile 中定义的匿名卷的挂载配置
9.EXPOSE 暴露端口
格式为 EXPOSE <端口1> [<端口2>...]
EXPOSE 指令是声明容器运行时提供服务的端口,这只是一个声明,在容器运行时并不会因为这个声明应用就会开启这个端口的服务。在 Dockerfile 中写入这样的声明有两个好处,一个是帮助镜像使用者理解这个镜像服务的守护端口,以方便配置映射;另一个用处则是在运行时使用随机端口映射时,也就是 docker run -P
时,会自动随机映射 EXPOSE 的端口
要将 EXPOSE 和在运行时使用 -p <宿主端口>:<容器端口>
区分开来
-p,是映射宿主端口和容器端口,换句话说,就是将容器的对应端口服务公开给外界访问,而 EXPOSE 仅仅是声明容器打算使用什么端口而已,并不会自动在宿主进行端口映射
10.WORKDIR 指定工作目录
格式为 WORKDIR <工作目录路径>
使用 WORKDIR 指令可以来指定工作目录,以后各层的当前目录就被改为指定的目录,如该目录不存在,WORKDIR 会帮你建立目录
如果需要改变以后各层的工作目录的位置,那么应该使用 WORKDIR 指令,例如:
WORKDIR /app
RUN echo "hello" > world.txt
如果你的 WORKDIR 指令使用的相对路径,那么所切换的路径与之前的 WORKDIR 有关:
WORKDIR /a
WORKDIR b
WORKDIR c
RUN pwd
RUN pwd 的工作目录为 /a/b/c
11.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:
# 建立 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.12/gosu-amd64" \
&& chmod +x /usr/local/bin/gosu \
&& gosu nobody true
# 设置 CMD,并以另外的用户执行
CMD [ "exec", "gosu", "redis", "redis-server" ]
12.LABEL 为镜像添加元数据
LABEL 指令用来给镜像以键值对的形式添加一些元数据
语法:
LABEL <key>=<value> <key>=<value> <key>=<value> ...
我们还可以用一些标签来申明镜像的作者、文档地址等:
LABEL org.opencontainers.image.authors="dahezhiquan"
LABEL org.opencontainers.image.documentation="https://dahezhiquan.gitbooks.io"
13.SHELL 指令
SHELL 指令可以指定 RUN ENTRYPOINT CMD 指令的 shell,Linux 中默认为 ["/bin/sh", "-c"]
SHELL ["/bin/sh", "-c"]
RUN lll ; ls
SHELL ["/bin/sh", "-cex"]
RUN lll ; ls
两个 RUN 运行同一命令,第二个 RUN 运行的命令会打印出每条命令并当遇到错误时退出