从刚才的docker commit的学习中﹐我们可以了解到﹐镜像的定制实际上就是定制每一层所
添加的配置﹑文件。如果我们可以把每一层修改﹑安装﹑构建操作的命令都写入一个脚
本,用这个脚本来构建﹑定制镜像﹐那么之前提及的无法重复的问题﹑镜像构建透明性的问
题﹑体积的问题就都会解决。这个脚本就是Dockerfile。
Dockerfile是一个文本文件﹐其内包含了一条条的指令(Instruction),每一条指令构建一层,因此每一条指令的内容﹐就是描逑该层应当如何构建。
还以之前定制nginx镜像为例,这次我们使用Dockerfile来定制。
在一个空白目录中﹐建立一个文本文件,并命名为Dockerfile :
$mkdir mynginx
$cd mynginx
touch Dockerfile
其内容为:
FROM nginx
RUN echo '<h1>Hello,Docker!</h1>' > /usr/share/nginx/html/index.html
FROM命令
所谓定制镜像,那一定是以一个镜像为基础,在其上进行定制。就像我们之前运行了一个
nginx镜像的容器,再进行修改一样,基础镜像是必须指定的。而FROM就是指定基础镜
像,因此一个Dockerfile中FROM是必备的指令,并且必须是第一条指令。
在Docker Store上有非常多的高质量的官方镜像﹐有可以直接拿来使用的服务类的镜像﹐如
nginx redis ‘mongo’ mysql httpd php tomcat等;也有一些方便开发﹑构
建﹑运行各种语言应用的镜像,如node openjdk pythonruby golang 等。可以
在其中寻找一个最符合我们最终目标的镜像为基础镜像进行定制。
如果没有找到对应服务的镜像﹐官方镜像中还提供了一些更为基础的操作系统镜像﹐如ubuntu debian centos fedora alpine等﹐这些操作系统的软件库为我们提供了更广阔的扩展空间。
除了选择现有镜像为基础镜像外﹐Docker还存在一个特殊的镜像﹐名为scratch。这个镜像
是虚拟的概念,并不实际存在,它表示一个空白的镜像。
FROM scratch
......
如果你以scratch为基础镜像的话,意味着你不以任何镜像为基础,接下来所写的指令将作
为镜像第一层开始存在。
RUN命令
RUN指令是用来执行命令行命令的。由于命令行的强大能力,RUN指令在定制镜像时是最
常用的指令之一。其格式有两种∶
shell格式∶RUN<命令>,就像直接在命令行中输入的命令一样。刚才写的Dockerfile中
的RU指令就是这种格式。
RUN echo ‘<h1>Hello,Docker!</h1>’ > /usr/share/nginx/html/index.html
.exec格式∶RUN[“可执行文件”,“参数1”,“参数2”],这更像是函数调用中的格式。
比较下面两种写法:
FROM debian:jessie
RUN apt-get update
RUN apt-get install -y gcc libc6-dev make
RUN wget -o redis.tar.gz "http://download.redis.io/releases/redis-3.2.5.tar.gz"
RUN mkdir -p /usr/src/redis
RUN tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1
RUN make -c /usr/ src/redis
RUN make -c /usr/src/redis install
写法二
FROM debian:jessie
RUN buildDeps='gcc libc6-dev make' \
&&apt-get update \
&&apt-get install -y $buildDeps \
&&wget -o redis.tar.gz "http://download.redis.io/releases/redis-3.2.5.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
之前说过,Dockerfile中每一个指令都会建立一层,RUN也不例外。每一个RUN的行为,
就和刚才我们手工建立镜像的过程一样︰新建立一层﹐在其上执行这些命令﹐执行结束
后’commit这一层的修改﹐构成新的镜像。
而上面的这种写法﹐创建了7层镜像·这是完全没有意义的,而且很多运行时不需要的东
西﹐都被装进了镜像里﹐比如编译环境﹑更新的软件包等等。结果就是产生非常臃肿﹑非常
多层的镜像﹐不仅仅增加了构建部署的时间﹐也很容易出错。这是很多初学Docker的人常
犯的一个错误○
方法二没有使用很多个RUN一一对应不同的命令﹐而是仅仅使用一个RUN指令﹐并使用&&将各个所需命令串联起来。将之前的7层﹐简化为了1层。在撰写Dockerfile的时候﹐要经常提醒自己﹐这并不是在写Shell脚本﹐而是在定义每一层该如何构建。
并且,这里为了格式化还进行了换行。Dockerfile支持Shell类的行尾添加\的命令换行方
式,以及行首#进行注释的格式。良好的格式,比如换行﹑缩进﹑注释等﹐会让维护﹑排障
更为容易,这是一个比较好的习惯。
此外,还可以看到这一组命令的最后添加了清理工作的命令﹐删除了为了编译构建所需要的
软件﹐清理了所有下载﹑展开的文件﹐并且还清理了apt缓存文件。这是很重要的一步,我
们之前说过﹐镜像是多层存储﹐每一层的东西并不会在下一层被删除﹐会一直跟随着镜像。
因此镜像构建时,一定要确保每一层只添加真正需要添加的东西﹐任何无关的东西都应该清
理掉。
很多人初学Docker制作出了很臃肿的镜像的原因之一﹐就是忘记了每一层构建的最后一定要
清理掉无关文件。
BUILD命令与镜像构建上下文
在dockfile文件目录,构建镜像执行如下:
docker build -t nginx:v3 .
sending build context to Docker daemon 2.048 kB
step i :FROM nginx
---> e43d8iice2f4
step 2 : RUN echo '<h1>Hello,Docker!</h1>' > /usr/share/nginx/html/index.html
---> Running in 9cdc27646c7b
---> 44aa4490ce2c
Removing intermediate container 9cdc27646c7b
successfully built 44aa4490ce2c
我们使用的build命令格式如下:
docker build[选项]<上下文路径/URL/->
如果注意﹐会看到docker build命令最后有一个. 。﹒表示当前目录﹐而Dockerfile
就在当前目录﹐因此不少初学者以为这个路径是在指定Dockerfile所在路径﹐这么理解其
实是不准确的。如果对应上面的命令格式﹐你可能会发现﹐这是在指定上下文路径。
首先我们要理解docker build的工作原理。Docker在运行时分为Docker引擎(也就是服务端守护进程)和客户端工具·Docker的引擎提供了一组RESTAPI,被称为Docker Remote APl,而如docker命令这样的客户端工具﹐则是通过这组API与Docker引擎交互﹐从而完成各种功能。因此﹐虽然表面上我们好像是在本机执行各种docker功能﹐但实际上,一切都是使用的远程调用形式在服务端(Docker引擎)完成。也因为这种C/S设计﹐让我们操作远程服务器的Docker引擎变得轻而易举。
当我们进行镜像构建的时候﹐并非所有定制都会通过RUN指令完成﹐经常会需要将一些本地
文件复制进镜像﹐比如通过COPY指令、ADD指令等。而docker build命令构建镜像﹐其
实并非在本地构建﹐而是在服务端﹐也就是Docker引擎中构建的。那么在这种客户端/服务
端的架构中﹐如何才能让服务端获得本地文件呢?
这就引入了上下文的概念·当构建的时候﹐用户会指定构建镜像上下文的路径﹐docker
build命令得知这个路径后﹐会将路径下的所有内容打包,然后上传给Docker引擎。这样
Docker引擎收到这个上下文包后﹐展开就会获得构建镜像所需的一切文件。
如果观察docker build输出﹐我们其实已经看到了这个**发送上下文(context)**的过程︰
$docker build -t nginx:v3 .
sending build context to Docker daemon 2.048 kB
...
一般来说﹐应该会将Dockerfile置于一个空目录下﹐或者项目根目录下。如果该目录下没有所需文件﹐那么应该把所需文件复制一份过来。如果目录下有些东西确实不希星构建时传给Docker引擎﹐那么可以用.gitignore一样的语法写一个.dockerignore,该文件是用于剔除不需要作为上下文传递给Docker引擎的。
那么为什么会有人误以为﹒是指定Dockerfile所在目录呢?这是因为在默认情况下﹐如果不额外指定Dockerfile的话﹐会将上下文目录下的名为Dockerfile的文件作为Dockerfile。这只是默认行为﹐实际上Dockerfile的文件名并不要求必须为Dockerfile,而且并不要求必须位于上下文目录中﹐比如可以用-f …/Dockerfile.php参数指定某个文件作为Dockerfile。
其他的docker build方法
直接用Git repo进行构建
或许你已经注意到了,docker build还支持从URL构建﹐比如可以直接从Git repo中构
建∶
$docker build https://github.com/twang2218/gitlab-ce-zh.git#:8.14
docker build https:// github.com/twang2218/gitlab-ce-zh.git\#:8.14
Sending build context to Docker daemon 2.048kB
step 1 : FROMgitlab/gitlab-ce:8.14.0-ce.o
8.14.0-ce.o: Pulling from gitlab/gitlab-ce
aed15891ba52: Already exists
773ae8583d14: Already exists
...
这行命令指定了构建所需的Git repo,并且指定默认的master分支﹐构建目录为/8.14/,
然后Docker就会自己去git clone这个项目﹑切换到指定分支﹑并进入到指定目录后开始
构建。
用给定的tar压缩包构建
$docker build http:/lserver/context.tar. gz
如果所给出的URL不是个Git repo,而是个tar压缩包﹐那么Docker引擎会下载这个包,并自动解压缩,以其作为上下文,开始构建。
从标准输入中读取Dockerfile进行构建
docker build - < Dockerfile
或
cat Dockerfile | docker build -
如果标准输入传入的是文本文件﹐则将其视为Dockerfile,并开始构建。这种形式由于直接从标准输入中读取Dockerfile的内容﹐它没有上下文﹐因此不可以像其他方法那样可以将本地文件COPY进镜像之类的事情。
从标准输入中读取上下文压缩包进行构建
docker build - < context.tar. gz
如果发现标准输入的文件格式是gzip bzip2以及xz的话﹐将会使其为上下文压缩包,
直接将其展开﹐将里面视为上下文﹐并开始构建。
COPY命令
格式:
COPY <源路径>…<目标路径>
COPY ["<源路径1>",… “<目标路径>”]
和RUN指令一样﹐也有两种格式,一种类似于命令行,一种类似于函数调用。
COPY指令将从构建上下文目录中<源路径>的文件/目录复制到新的一层的镜像内的<目标路径>位置。比如:
COPY package.json /usr/src/app/
<源路径>可以是多个,甚至可以是通配符﹐其通配符规则要满足Go的 filepath.Match 规则,如:
COPY hom* /mydir/
COPY hom?.txt/mydir/
<目标路径>可以是容器内的绝对路径﹐也可以是相对于工作目录的相对路径(工作目录可以用WORKDIR指令来指定)。目标路径不需要事先创建﹐如果目录不存在会在复制文件前先行创建缺失目录。
此外﹐还需要注意一点﹐使用COPY指令﹐源文件的各种元数据都会保留。比如读﹑写﹑执行权限、文件变更时间等。这个特性对于镜像定制很有用。特别是构建相关文件都在使用Git进行管理的时候。
ADD命令
ADD指令和COPY的格式和性质基本一致。但是在COPY基础上增加了一些功能。比如<源路径>可以是一个URL,这种情况下﹐Docker引擎会试图去下载这个链接的文件放到<目标路径>去。下载后的文件权限自动设置为600,如果这并不是想要的权限﹐那么还需要增加额外的一层RUN进行权限调整﹐另外,如果下载的是个压缩包,需要解压缩,也一样还需要额外的一层RUN指令进行解压缩。所以不如直接使用RUN指令﹐然后使用wget或者curl工具下载﹐处理权限﹑解压缩﹑然后清理无用文件更合理。因此﹐这个功能其实并不实用﹐而且不推荐使用。
如果<源路径>为一个tar压缩文件的话﹐压缩格式为gzip , bzip2以及xz的情况下’ADD指令将会自动解压缩这个压缩文件到<目标路径>去。
但在某些情况下﹐如果我们真的是希望复制个压缩文件进去﹐而不解压缩﹐这时就不可以使用ADD命令了。
在Docker官方的Dockerfile最佳实践文档中要求﹐尽可能的使用COPY,因为COPY的语义很明确﹐就是复制文件而已,而ADD则包含了更复杂的功能﹐其行为也不一定很清晰。
适合使用ADD的场合,就是所提及的需要自动解压缩的场合。
另外需要注意的是ADD指令会令镜像构建缓存失效﹐从而可能会令镜像构建变得比较缓慢。
因此在COPY和ADD指令中选择的时候﹐可以遵循这样的原则﹐所有的文件复制均使用COPY指令﹐仅在需要自动解压缩的场合使用ADD。
CMD命令
CMD指令的格式和RUN相似﹐也是两种格式∶
shell格式︰CMD<命令>
.exec格式︰CMD[“可执行文件”,“参数1”,“参数2”…]
参数列表格式∶CMD[“参数1”,“参数2”…] 在指定了ENTRYPOINT指令后,用CMD指定具体的参数。
Docker不是虚拟机﹐容器就是进程。既然是进程﹐那么在启动容器的时候﹐需要指定所运行的程序及参数。CMD指令就是用于指定默认的容器主进程的启动命令的。
在指令格式上,一般推荐使用exec格式,这类格式在解析时会被解析为JSON数组﹐因此一定要使用双引号巴,而不要使用单引号。
如果使用shell格式的话﹐实际的命令会被包装为sh -c的参数的形式进行执行。比如:
CMD echo $HOME
在实际执行中﹐会将其变更为∶
CMD [ "sh","-c","echo $HOME"]
这就是为什么我们可以使用环境变量的原因﹐因为这些环境变量会被shell进行解析处理。提到CMD就不得不提容器中应用在前台执行和后台执行的问题。这是初学者常出现的一个混淆。
Docker不是虚拟机﹐容器中的应用都应该以前台执行﹐而不是像虚拟机﹑物理机里面那样﹐用upstart/systemd去启动后台服务﹐容器内没有后台服务的概念。
对于容器而言﹐其启动程序就是容器应用进程﹐容器就是为了主进程而存在的﹐主进程退出﹐容器就失去了存在的意义﹐从而退出﹐其它辅助进程不是它需要关心的东西。而使用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;"]
ENTRYPOINT 入口点
ENTRYPOINT的格式和RUN指令格式一样﹐分为exec格式和shell格式。
ENTRYPOINT的目的和CND一样﹐都是在指定容器启动程序及参数·ENTRYPOINT在运行时也可以替代﹐不过比CMD要略显繁琐﹐需要通过docker run的参数–entrypoint来指定。
当指定了ENTRYPOINT后,CMD的含义就发生了改变﹐不再是直接的运行其命令﹐而是将
CMD的内容作为参数传给ENTRYPOINT指令﹐换句话说实际执行时﹐将变为∶
<ENTRYPOINT> "<CMD>"
那么有了CMD后﹐为什么还要有ENTRYPOINT呢?这种""有什么好处
么?让我们来看几个场景。
场景一∶让镜像变成像命令一样使用
假设我们需要一个得知自己当前公网I的镜像﹐那么可以先用cMD来实现∶
FROM ubuntu:16.04
RUN apt-get update \
&&apt-get install -y curl \
&&rm -rf /var/lib/apt/lists/*
CMD[ "curl", "-s", "http:/lip.cn" ]
假如我们使用docker build -t myip .来构建镜像的话﹐如果我们需要查询当前公网IP,只
需要执行∶
$docker run myip
当前IP∶61.148.226.66来自∶北京市联通
当我们要查看http信息头的时候不能直接使用 “docker run myip -i”,因为跟在镜像后面的默认值是command,当后面加上 - i的时候会用“-i”来替换默认的CMD,而不是在"curl -s http:/lip.cn"后面,系统会给出如下提示:
$docker run myip -i
docker: Error response from daemon: invalid header field value "oci runtime error: con
tainer_linux.go:247: starting container process caused \"exec: lll"-i\l\": executable
file not found in $PATH\"\n" .
如果希望加入 -i 这个参数,我们需要这么写:
docker run myip curl -s http://ip.cn -i
当我们使用ENTRYPOINT时,我们可以这样操作:
FROM ubuntu:16.04
RUN apt-get update l
&&apt-get install -y curl \
&&rm -rf /var/lib/apt/lists/*
ENTRYPOINT [ "curl", "-s","http://ip.cn”]
这次我们再来尝试直接使用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: rue,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中去执行﹐而这个脚本会将接到的参数(也就是)作为命令﹐在脚本最后执行。比如官方镜像redis中就是这么做的∶
FROM alpine:3.4
...
RUN addgroup -s redis && adduser -s -G redis redis
ENTRYPOINT ["docker-entrypoint.sh"]
EXPOSE 6379
CMD[ "redis-server" ]
docker-entrypoint.sh脚本如下
#!/bin/sh
...
#allow the container to be started with `--user`
if["$1" = 'redis-server' -a "$(id -u)" = '0']; then
chown -Rredis .
exec su-exec redis "$o""$@"
fi
exec "$@"
该脚本的内容就是根据CMD的内容来判断﹐如果是redis-server的话﹐则切换到redis用户身份启动服务器,否则依旧使用root身份执行。
ENV设置环境变量
格式有两种∶
ENV <key> <value>
ENV <key1>=<value1> <key2>=<value2>...
这个指令很简单﹐就是设置环境变量而已﹐无论是后面的其它指令﹐如RUN,还是运行时的应用﹐都可以直接使用这里定义的环境变量。
定义了环境变量﹐那么在后续的指令中﹐就可以使用这个环境变量。比如在官方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.txtSHASUMS256.txt.asc \
&& grep " node-v$NODE_VERSION-linux-x64.tar.xz\$"SHASUNS256.txt | sha256sum -c - \
&& tar -xJf "node-v$NODE_VERSION-linux-×64.tar.xz" -C /usr/local --strip-components=1 \
&&rm "node-v$NODE_VERSION-linux-x64.tar.xz" SHASUNS256.txt.asc SHASUMS256.txt \
&&ln -s /usr/local/bin/node /usr/local/bin/nodejs
在这里先定义了环境变量NODE_VERSION,其后的RUN这层里﹐多次使用SNODE_VERSION来
进行操作定制。可以看到﹐将来升级镜像构建版本的时候﹐只需要更新7.2.0即可,Dockerfile构建维护变得更轻松了。
下列指令可以支持环境变量展开∶
ADD、COPY、ENV、EXPOSE、LABEL、USER、WORKDIR、VOLUME、STOPSIGNAL、ONBUILD。
可以从这个指令列表里感觉到﹐环境变量可以使用的地方很多﹐很强大。通过环境变量﹐我
们可以让一份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)中。为了防止运行时用户忘记将动态文件所保存目录挂载为卷,在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 的端口。
此外,在早期 Docker 版本中还有一个特殊的用处。以前所有容器都运行于默认桥接网络中,因此所有容器互相之间都可以直接访问,这样存在一定的安全性问题。于是有了一个 Docker引擎参数 --icc=false ,当指定该参数后,容器间将默认无法互访,除非互相间使用了 --links 参数的容器才可以互通,并且只有镜像中 EXPOSE 所声明的端口才可以被访问。这个–icc=false 的用法,在引入了 docker network 后已经基本不用了,通过自定义网络可以很轻松的实现容器间的互联与隔离。
要将 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 S
TATUS PORTS NAMES
03e28eb00bd0 myweb:v1 "nginx -g 'daemon off" 3 seconds ago U
p 2 seconds (health: starting) 80/tcp, 443/tcp web
在等待几秒钟后,再次 docker container ls ,就会看到健康状态变化为了 (healthy) :
$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED S
TATUS PORTS NAMES
03e28eb00bd0 myweb:v1 "nginx -g 'daemon off" 18 seconds ago U
p 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>\n<html>\n<head>\n<title>Welcome to nginx!</titl
e>\n<style>\n body {\n width: 35em;\n margin: 0 auto;\n font-f
amily: Tahoma, Verdana, Arial, sans-serif;\n }\n</style>\n</head>\n<body>\n<h1>Welc
ome to nginx!</h1>\n<p>If you see this page, the nginx web server is successfully inst
alled and\nworking. Further configuration is required.</p>\n\n<p>For online documentat
ion and support please refer to\n<a href=\"http://nginx.org/\">nginx.org</a>.<br/>\nCo
mmercial 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"
}
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" ]
这里我们把项目相关的构建指令拿出来,放到子项目里去。假设这个基础镜像的名字为
mynode的话,各个项目内的自己的 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/engine/userguide/engimage/
dockerfile_best-practices/
Docker 官方镜像 Dockerfile :https://github.com/docker-library/docs
docker 从入门到实践