1.6 用Dockerfile专业化定制镜像
1.5节讲解了Docker镜像及其特点和作用,如何比较专业地定制我们需要的镜像呢?本节我们学习用Dockerfile定制镜像。
1.6.1 什么是Dockerfile
大家第一眼看到Dockerfile这个名字的时候可能觉得它就是一个文件。没错,它就是一个文本文件,但是它里面的内容对于镜像来说却不简单。这些内容代表着一个镜像如何诞生,好比镜像的“基因”。我们通过下面一段简单的Dockerfile代码,大致了解一下文件内容。
# This Dockerfile uses the ubuntu image # VERSION 2 - EDITION 1 # Author: docker_user # Command format: Instruction [arguments / command] .. # Base image to use, this must be set as the first line FROM ubuntu # Maintainer: docker_user <docker_user at email.com> (@docker_user) MAINTAINER docker_user docker_user@email.com # Commands to update the image RUN echo "deb http://archive.ubuntu.com/ubuntu/ raring main universe" >> /etc/apt/sources.list RUN apt-get update && apt-get install -y nginx RUN echo "\ndaemon off;" >> /etc/nginx/nginx.conf # Commands when creating a new container CMD /usr/sbin/nginx
从#的注释看起,首先表明这个Dockerfile用的是ubuntu镜像,然后注明镜像的版本是EDITION 1,接着说明镜像的作者是docker_user,最后说明下命令的格式。
上面4句注释写完后就开始真正编写Dockerfile命令了,FROM ubuntu的意思是这个镜像是基于ubuntu镜像创建的。这个很重要,一般的Dockerfile都是基于某个基本镜像构建的,所以开始就会注明镜像的底层来源是什么。我们之前也说过,镜像是一层层叠加的,总有一个初始化镜像在最底层。
下面的MAINTAINER是这个镜像的创始人和维护者,方便让大家知道以后有问题可以找谁。
第4段内容是Dockerfile最重要的内容,镜像的特性就是通过这段代码来实现的。这段代码展示的是镜像生成的时候需要做哪些操作,这些操作一般都是一些命令。比如常见的shell命令,你可以把它理解为一段面向过程的脚本。(但是严格意义上来说,它不是脚本。)通过这些命令,就可以一步步实现我们想在镜像中完成的事。注意最前面的关键字RUN,这是Dockerfile里特有的语法标识,前面我们提到的FROM和MAINTAINER也是Dockerfile的语法。后文会详细介绍这些语法。
最后一段内容表示的是镜像做好后变成容器需要运行的命令。这里一般是一个服务的启动命令,比如上面示例中表示的就是启动nginx服务。到这一步,大家看这个Dockerfile代码估计就应该明白了,前面所有的编写都是为最后这一句/usr/sbin/nginx命令启动而做的准备。想要在一个空白的ubuntu镜像里运行nginx服务,首先得把ubuntu的apt源配置好,接着是apt-get install nginx包安装,最后是配置nginx.conf文件。只有完成了这三步,nginx才能运行起来。以后大家编写Dockerfile也是这样的思路,考虑清楚做镜像的目的,然后分解成小步,一层层写Dockerfile语句来实现。
1.6.2 常用的Dockerfile指令和语法
通过上文对Dockerfile的学习,我们基本上有了一个编写的思路。编写Dockerfile的前提是掌握Dockerfile语法,掌握了语法,再去编写Dockerfile就轻车熟路了。
1.RUN指令
RUN指令是Dockerfile里最常用的指令之一,它的作用就是运行一条命令。类似于Linux的shell脚本里的命令一样,写一个RUN,就运行一次后面跟着的命令。
比如1.6.1节示例Dockerfile里,就有那么一段关于RUN命令的集合。
# Commands to update the image RUN echo \ "deb http://archive.ubuntu.com/ubuntu/ raring main universe" >> /etc/apt/sources.list RUN apt-get update && apt-get install -y nginx RUN echo "\ndaemon off;" >> /etc/nginx/nginx.conf
3个RUN代表运行了3步命令操作。第一步是配置ubuntu源,第二步是运行apt-get更新,第三步是编辑nginx.conf文件。RUN后面的命令看着像是Linux的命令,其实就是Linux的命令!RUN后面可以跟shell格式的命令,还可以跟exec格式的命令,比如RUN["可运行文件","参数1","参数2"],不过这个命令用得比较少。
既然RUN后面可以跟shell命令,假如要做的镜像需要运行很多个命令才能完成,该怎么做呢?是写多个RUN吗?比如像下面这样。
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
一共7行代码,每一行都是RUN。
这样写语法上没什么错,也能运行成功。但是从优雅和专业角度看,就很不合适。因为每写一个RUN命令就等于增加了一层镜像,而Docker镜像的层数目前是有限制的,所以尽量只用一个RUN命令,让镜像的层数简化。上面这段Dockerfile命令,其实可以简化成如下方式。
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
我们可以用&&把多个命令连上。
2.CMD指令
CMD指令用于指定启动容器默认的主进程。因为容器其实就是进程,它不像虚拟机那样启动后不运行任何命令也能一直静默。所以,容器需要有主进程一直持续运行,不然就会退出。我们可以这样想象:容器就是包着一个主进程在运行,主进程就是容器的“灵魂”,如果“灵魂”没有了,容器这一“肉身”也会消失。
我们可以用CMD指令启动容器的“灵魂”。CMD指令也有两种格式。
shell格式:CMD <命令> exec格式:CMD ["可运行文件","参数1","参数2"...]
也就是说,和RUN一样,CMD指令后面也能跟shell命令,比如:
CMD cat /etc/redhat-release
此命令可以查看系统类型版本。
如果换成exec格式,上面那条命令就等于CMD["sh","-c","cat/etc/redhat-release"]。所以,CMD后面如果跟的是shell命令,那么实际底层运行是用exc的sh-c方式。再比如,CMD后面写的是systemctl start mysqld,那么就等于CMD["sh","-c","systemctl start mysqld"]。
注意,exec命令格式里的第一小段才是主进程,上面的那两个例子,主进程就是sh,而不是cat/etc/redhat-release和systemctl start mysqld。cat/etc/redhat-release和systemctl start mysqld这两个shell命令有个特点,运行后就会返回结果退出。sh也会随着退出。因为sh主进程退出了,所以容器“灵魂”就没有了,容器停止运行。换句话说,“灵魂”也就持续存在了一两秒。
想要让容器一直运行,CMD指令最好是用exec的命令格式。比如,启动运行nginx、mysql等,应该是类似如下格式。
CMD ["nginx", "-g", "daemon off;"] CMD ["/usr/bin/mysqld_safe"]
只要容器里主进程能一直运行,容器就不会退出。
这个CMD命令一般是在Dockerfile最后才写的,Dockerfile前面的内容都是为配置环境做一些准备,等都做得差不多了,最后一句就是CMD启动容器主进程的指令,类似Docker的开机启动项。
3.ENTRYPOINT指令
ENTRYPOINT一般和CMD配合使用,CMD里的内容可以作为参数传到ENTRYPOINT里使用。官网是这么介绍ENTRYPOINT指令的:ENTRYPOINT指令可以让你的容器功能表现得像可运行程序一样。
让容器表现得像可运行程序?这要怎么理解呢?我们先来看下面的例子。
FROM centos:7.2 ENTRYPOINT ["/bin/cat"]
假如我们写了一个上面这样简单的Dockerfile,那么这个做成后的镜像运行时将带有cat的功能。我们在运行这个镜像的时候写上一个文件路径,那么就会返回输出这个文件内容。
$ docker run -it image_test_entrypoint /etc/fstab
其中,image_test_entrypoint是假设做好的镜像名字。
运行这个命令后,结果将是输出/etc/fstab文件的内容。
所以,到这里我们应该明白了,ENTRYPOINT能定义一些初始化命令在里面。
前面提到的,Dockfile里面还能接收CMD的参数内容,比如Dockfile这样写:
FROM centos:7.2 ENTRYPOINT ["vmstat","3"] CMD ["5"]
ENTRYPOINT里原本运行的是每隔3秒输出vmstat监控信息,然后有了CMD参数,传入了一个数字5,表示vmstat结果只能输出5次。同理,还可以看看下面这个top命令的Dockerfile,道理都差不多。
FROM centos:7.2 ENTRYPOINT ["top", "-b"] CMD ["-c"]
上面两个例子执行的是Linux命令,ENTRYPOINT里面还可以附带脚本,比如官方mysql 5.6的Dockerfile就在ENTRYPOINT使用了脚本。
... COPY docker-entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] EXPOSE 3306 CMD ["mysqld"]
其中entrypoint.sh就是自己定义好的shell脚本,用来完成一些初始化、逻辑判断的操作,毕竟有时候一些前提操作比较复杂,需要通过脚本才能完成。
总的来说,ENTRYPOINT可以定义一些初始化的命令、参数甚至脚本,做成的镜像更像一个可运行程序,我们可以把它当作工具反复使用。所以,有些场景如果想把容器做成工具,可以使用ENTRYPOINT。不过得注意的是,整个Dockerfile里ENTRYPOINT只能使用一次,如果写了多个,那么生效的是最后一个。
4.COPY指令
在构建Docker镜像的时候,肯定涉及把某个文件、脚本从某个路径复制到另外一个路径的操作。可以用COPY命令去完成这一操作,命令格式如下所示。
COPY<源路径>...<目标路径>
COPY["<源路径1>",..."<目标路径>"]
比如我们复制install.sh这个脚本到/opt/shell下,就可以这样写:
COPY install.sh /opt/shell
而且这个命令也支持通配符,比如用*和?,跟Linux命令一样。
COPY install* /opt/shell COPY install /opt/shell
注意,这里的目标路径就是容器里面的目标路径,如果事先没创建也没关系,运行的时候会自动创建。
5.ADD指令
ADD指令和COPY指令有点儿类似,但是ADD指令相对高级些。高级在哪儿呢?高级在ADD指令不仅能复制,还能自动解压缩。比如我们想复制一个mysql.tar.gz到/opt下面,如果使用COPY指令就是单纯地把mysql.tar.gz复制到/opt下。如果使用ADD指令,就不仅仅是复制过去了,同时还会解压缩这个tar包。
另外,ADD指令还有下载的功能,如果源地址是一个URL,那么将下载这个URL的目录或者文件到目标路径:ADDhttp://example.com/foobar.py/opt。
一般在写Dockerfile的时候之所以用ADD指令,是看中了它的自动解压缩功能,而不是复制功能。如果是单纯地复制,还是建议使用COPY指令,这样你的Dockerfile才比较直观,ADD指令有隐藏的高级功能,不建议随意使用。
6.ENV指令
大家看到ENV这个词,应该差不多能猜到它是什么意思了,ENV就是环境变量(environment variables)的缩写。在Dockerfile里,我们也经常要定义一些环境变量。语法如下:
单个变量:ENV<key><value>
多个变量:ENV<key1>=<value1><key2>=<value2>...
我们来看一个例子,比如官方MySQL的Dockerfile开头就用了ENV设置了环境变量。
FROM oraclelinux:7-slim ENV PACKAGE_URL https://repo.mysql.com/yum/mysql-8.0-community/docker/x86_64/mysql-community-server-minimal-8.0.2-0.1.dmr.el7.x86_64.rpm # Install server RUN rpmkeys --import http://repo.mysql.com/RPM-GPG-KEY-mysql \ && yum install -y $PACKAGE_URL \ && yum install -y libpwquality \ && rm -rf /var/cache/yum/* RUN mkdir /docker-entrypoint-initdb.d .....
这里的ENV就指定了mysql RPM包的下载路径,然后赋值给了PACKAGE_URL这个Key。
后面我们可以看到RUN指令里用$PACKAGE_URL这样的方式引用了PACKAGE_URL这个值。
我们可以把ENV想象成编程里的定义全局变量,开头定义好了,后面就可以复用。如果后续参数有变化,只要改前面的ENV内容即可,非常方便!
7.ARG指令
ARG指令是用来传递变量的,它一般结合docker build命令中的--build-arg一起使用。也就是说,ARG是Dockerfile里声明的一个变量值,使用--build-arg来传递值给ARG。
ARG的写法很简单,方式如下:ARG<name>或者ARG<name>=<default>。
例如:
ARG user1 USER $user1
通过使用--build-arg指定好user1的值是root用户。
$ docker build --build-arg user1=root ./opt/mysql
这里要注意的是,不能在ARG里指定密码或机密信息,因为使用docker history将显示出所有信息,很不安全;另外,ENV指令也是用$引用值的,ARG也是,所以这里会有冲突,如果同时出现,ENV的值会覆盖ARG的值,这样ARG就不生效了。所以,在上面的例子中,假如你的代码这样写是会有问题的,运行时user1的值一直是root。
ENV user1 root ARG user1 USER $user1
代码的正确写法如下。
ARG user1 ENV user1 $user1 USER $user1
在ENV里,值直接引用ARG里的user1即可,其实这就是做了一个间接的传递。
在Docker里有几个变量是预设好了的,它们都是proxy代理的,大家可以直接用--build-arg命令使用它们,无须使用ARG设置:HTTP_PROXY、http_proxy、HTTPS_PROXY、https_proxy、FTP_PROXY、ftp_proxy、NO_PROXY、no_proxy。
8.LABEL指令
LABEL在英文里就是标签的意思,所以它就是给镜像贴标签用的。使用者可以通过LABEL鉴别镜像的一些信息。如果公司生产业务有很多类,可以很好地通过LABEL将容器分类、分组。LABEL是有继承效果的,如果上一个镜像里有LABEL信息,那么下个镜像引入的时候也会有;另外,如果有重名,LABEL信息会被覆盖。
LABEL的用法也很简单,格式如下:
LABEL <key1>=<value1> <key2>=<value2>...
如果value值里有空格,可以通过引号把value引起来,以免产生歧义。另外,如果value值很长,也可以用\进行换行。大家可以看下面的例子,一起学习LABEL的写法。
LABEL version="2.0" LABEL "com.example.vendor"="ACME Incorporated" LABEL multi.label1="value1" multi.label2="value2" other="value3" LABEL com.example.label-with-value="foo" LABEL description="This text illustrates \ that label-values can span multiple lines." LABEL multi.label1="value1" \ multi.label2="value2" \ other="value3"
9.WORKDIR指令
WORKDIR也很好理解,就是在Dockerfile指定工作路径。RUN、CMD、ENTRYPOINT、COPY和ADD指令都将使用WORKDIR定义好的路径值。如果指定的路径不存在,运行时会自动创建。
WORKDIR/home/test1
例如上面这行代码就指定了WORKDIR路径为/home/test1。
Dockerfile里可以重复使用WORKDIR,比如Dockerfile开始定义了路径为/home/test1,那么后面的WORKDIR可以写成相对路径,这个相对路径基于最开始的绝对路径,代码如下所示。
WORKDIR /home/test1 WORKDIR test2 WORKDIR test3 RUN pwd
运行结果将是/home/test1/test2/test3,不过我们写Dockerfile的时候还是要遵循结构清晰、内容易懂、简约优化等准则。
另外,WORKDIR也能引用ENV里的值,代码如下。
ENV DIRPATH /home WORKDIR $DIRPATH/test RUN pwd
运行结果就是/home/test。
10.VOLUME指令
VOLUME指令的作用是指定数据的存储挂载点。有的容器涉及一些数据的持久化,比如MySQL这样的容器需要定义一个数据卷路径存储数据文件。下面我们看一下官方MySQL的Dockerfile,里面就有VOLUME的定义。
FROM oraclelinux:7-slim ENV PACKAGE_URL https://repo.mysql.com/yum/mysql-8.0-community/docker/x86_64/mysql-community-server-minimal-8.0.2-0.1.dmr.el7.x86_64.rpm # Install server RUN rpmkeys --import http://repo.mysql.com/RPM-GPG-KEY-mysql \ && yum install -y $PACKAGE_URL \ && yum install -y libpwquality \ && rm -rf /var/cache/yum/* RUN mkdir /docker-entrypoint-initdb.d VOLUME /var/lib/mysql ...
最后一行VOLUME/var/lib/mysql就是VOLUME的路径。
当然,VOLUME指令也可以定义多个路径,只要用空格隔开即可。
VOLUME /var/lib/mysql /var/log/mysql
11.ONBUILD指令
ONBUILD指令比较特殊,我们把这个单词拆分就是on build,意思是“在创建的路上”。但是,不是现在创建。这个怎么理解?既然不是现在创建,那就和当前的镜像没有关系,不会影响当前的环境,而是会在下一个镜像环境里有操作、有影响。
这个指令的作用是以当前镜像为基础,在构建下一个镜像的时候运行一些命令,也就是为下一个镜像做准备。
ONBUILD ADD . /app/src ONBUILD RUN /usr/local/bin/python-build --dir /app/src
上面这个例子就是要制作下个镜像的时候,先把当前路径的一些文件复制到/app/src目录下,然后再引用/app/src内容运行python-build命令。
总的来说,ONBUILD命令就是下个镜像的触发器。但是要注意的是,这个触发器只在“子辈”的镜像里有效果,在“孙辈”的镜像里没效果,隔一代,继承效果就消失了。
12.EXPOSE指令
EXPOSE指令的作用是声明容器要使用的端口。注意,这里用的是“声明”这个词而不是“定义”。因此,在容器启动后并不是立即使用EXPOSE声明的端口,这个指令只是声明镜像做好后会使用什么端口。
13.USER指令
USER指令很简单,就是在Dockerfile里设置运行用户。
USER root
指令虽然很简单,但是要注意的是,使用USER指令会影响RUN、CMD、ENTRYPOINT等指令,同时也会影响容器中主进程运行的用户。
我们在使用Dockerfile指令的时候一定要注意影响范围,处理不好会出现递归式的错误。
学会了上面的指令,就可以自己写Dockerfile了。写Dockerfile的时候注意遵循结构清晰、内容易懂、简约优化这3个原则。下面是完整的官方MySQL镜像Dockerfile写法。
FROM oraclelinux:7-slim ENV PACKAGE_URL https://repo.mysql.com/yum/mysql-8.0-community/docker/x86_64/mysql-community-server-mini mal-8.0.2-0.1.dmr.el7.x86_64.rpm # Install server RUN rpmkeys --import http://repo.mysql.com/RPM-GPG-KEY-mysql \ && yum install -y $PACKAGE_URL \ && yum install -y libpwquality \ && rm -rf /var/cache/yum/* RUN mkdir /docker-entrypoint-initdb.d VOLUME /var/lib/mysql COPY docker-entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] EXPOSE 3306 33060 CMD ["mysqld"]
大部分指令在这个例子中都用到了。我们可以做个总结,把Dockerfile的指令进行归类。
Dockerfile指令一般分为5部分:来源环境配置、维护者信息、镜像操作、配置和容器启动时运行指令,详情如表1-1所示。
表1-1 Dockerfile指令
Dockerfile的指令很多,表格里只展示了其中几个比较常用的、实用的。大家只要在日常工作中多试几次,很快就能写出专业、实用的Dockerfile。随着Docker的发展,未来肯定会出一些新的指令,但是目前的指令基本上是够用了。