一、Dockerfile 是什么
Dockerfile 就是构建 Docker 镜像的那个文件,说穿了是一段脚本,整个流程就四步:
- 编写文件
build构建镜像run运行push发布到仓库(私有/公有)
它跟 shell 脚本不是一回事,写法是声明式的:
| Dockerfile | Shell 脚本(Bash) | |
|---|---|---|
| 性质 | 声明式(Declarative) | 命令式(Imperative) |
| 作用 | 描述镜像的构建步骤和最终状态 | 描述执行一系列操作的指令序列 |
| 语法 | 指令(FROM、RUN、COPY 等)+ 参数 | 命令、变量、条件判断、循环等 |
| 执行环境 | 由 Docker 引擎解析,逐层构建镜像 | 由 Shell 解释器(如 Bash)执行 |
三个概念别搞混:
- Dockerfile:构建文件,定义了一切的步骤,相当于源代码
- Docker Image:通过 Dockerfile 构建生成的镜像,最终发布和运行的产品
- Docker 容器:镜像运行起来对外提供服务
为什么非得写 Dockerfile?前面讲过镜像是一层层共用的(联合文件系统),改一个容器再用 docker commit 提交也能做出镜像,但要反复提交,越堆越臃肿,构建一次比一次慢。Dockerfile 就是把这些动作写成一个脚本,一次性构建出来:

所以它是这个体系里的固定套路:先写文件,再 docker build 出镜像,最后 docker run 成容器:

在 Docker Hub 上的镜像有 tags 功能,点开之后能看到历史版本,还能跳转到 GitHub 的位置,顺便看它的 Dockerfile 代码 —— 想抄官方镜像是怎么写的,直接去翻就行。
写 Dockerfile 本身不难,它是一门面向开发的语言,会构建镜像就行;学会之后才算真正明白 Docker 是什么。
文件名最好写成 dockerfile01 这种,容易辨识。
二、四条基本规则
- 每个保留关键字(指令)必须都是大写的字母
- 从上到下顺序执行
#表示注释- 每个指令都会创建提交一个新的镜像层

第 4 条是关键:一条 RUN 就是一层,层数一多镜像就臃肿,后面讲构建优化的时候全是在这上面做文章。
四条规矩落到图上就是这么回事:

构建的时候 docker 干了什么
docker build 读一遍 Dockerfile,其实是这五步在循环:跑一个容器、执行一条指令、把结果提交成一个镜像层、再基于这个新镜像跑下一个容器,直到所有指令走完:

这也解释了"每条指令一层"是怎么来的:每执行一条就要 commit 一次,层数自然就上去了。
三、常用指令
FROM # 基础镜像 centos ubuntu
MAINTAINER # 作者,姓名和邮箱是国际标准
# main-tainer 主要持有者
RUN # 镜像构建需要运行的命令
ADD # 步骤:tomcat 镜像,tomcat压缩包写在这
WORKDIR # 工作目录
VOLUME # 挂载目录
EXPOSE # 指定开放端口(不写就通过 -p 暴露端口)
CMD # 指定容器启动时运行的命令,比如echo
# 只有最后一个生效,可替代
ENTRYPOINT # 指定容器启动时运行的命令,可以追加命令
# entry point 入口点
ONBUILD # 构建一个被继承的 dockerfile 会触发该指令(了解即可)
COPY # 复制,类似ADD,将文件拷贝到镜像中
ENV # 设置环境变量(environment)
保留字按生效阶段可以分成三类(构建时用、构建和运行都要、运行时用):

日常真正要盯的是 ADD、COPY、CMD、ENTRYPOINT 这四个,剩下的用到再回来查。想看别人是怎么写的,直接翻 tomcat 官方的 Dockerfile 最有参考价值(https://github.com/docker-library/tomcat):

这里面命令很多都很相似,比如 ADD 和 COPY、CMD 和 ENTRYPOINT,光看说明记不住,对比着做一遍测试最清楚。
ADD 和 COPY
两个都是把宿主机的文件拷进镜像,区别就在自动解压这一条上:

ADD 的三个本事一张表就能说完 —— 复制本地文件、自动解压、还能直接给个 URL:


要留意的是:只有本地的压缩包才自动解压,URL 下载回来的不会解压,而且权限是 600。我平时拷东西基本都用 ADD,因为它顺手;只想老老实实复制、不想被自动解压的时候才用 COPY。
四、docker build 的三个参数
docker build -f dockerfile01 -t zzz-usr/centos .
-f后面跟 Dockerfile 的地址(from),可以写相对路径-t后面跟构建出来的镜像名(tag),方便后续run和push.点写在最后,表示构建上下文是当前目录
顺便提一句,用官方的构建方式会把文件写成 Dockerfile(首字母大写、无后缀),那时候 build 会自动寻找该文件,不需要手动 -f。
镜像名是有格式的:[仓库地址/][用户名/]镜像名[:标签]。以 / 开头会直接报错:
root@VM-0-9-ubuntu:/home/docker.v# docker build -f dockerfile01 -t /zzz-usr/centos .
[+] Building 0.0s (0/0) docker:default
ERROR: failed to build: invalid tag "/zzz-usr/centos": invalid reference format
# 这个报错是镜像名不合法
# 镜像名(tag)不能以 / 开头
把 / 去掉就正常了,下面是完整的一次构建输出:
root@VM-0-9-ubuntu:/home/docker.v# docker build -f dockerfile01 -t zzz-usr/centos .
[+] Building 0.2s (5/5) FINISHED docker:default
=> [internal] load build definition from dockerfile01 0.0s
=> => transferring dockerfile: 119B 0.0s
=> WARN: JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals ( 0.0s
=> WARN: MultipleInstructionsDisallowed: Multiple CMD instructions should not be used in the same stage because only th 0.0s
=> WARN: JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals ( 0.0s
=> [internal] load metadata for docker.io/library/centos:latest 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [1/1] FROM docker.io/library/centos:latest@sha256:a27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177 0.0s
=> => resolve docker.io/library/centos:latest@sha256:a27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177 0.0s
=> exporting to image 0.1s
=> => exporting layers 0.0s
=> => exporting manifest sha256:ad8ac16c8808bcb38bd516b36d6442def438741a3699cb61a82baad70f21a1c0 0.0s
=> => exporting config sha256:2771b5374df0a3d1fec5d343805c3291ee35f76cf68d2b731a421cb308eae93f 0.0s
=> => exporting attestation manifest sha256:ec9d6a6990819ca284f54cc22866360aadfd23b5789b3891b2f50bcd8ce82083 0.0s
=> => exporting manifest list sha256:0203f7bd4f3db404f07dad01111bf4dc53bd7c59974e76e271c59758a45b50a3 0.0s
=> => naming to docker.io/zzz-usr/centos:latest 0.0s
=> => unpacking to docker.io/zzz-usr/centos:latest 0.0s
3 warnings found (use docker --debug to expand):
- JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals (line 5)
- MultipleInstructionsDisallowed: Multiple CMD instructions should not be used in the same stage because only the last one will be used (line 5)
- JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals (line 6)
这次构建用的 Dockerfile 就三行指令,值得单独看一眼:
FROM centos
VOLUME ["volume01","volume02"]
CMD echo "---end---"
CMD /bin/bash
对上号了:写了两个 CMD,构建时就提示第二个会覆盖第一个;CMD echo 这种 shell 格式的写法也被建议改成 JSON 数组写法。这两条警告正好是后面两节要讲的东西。
五、动手:构建自己的 centos
Dockerfile 写在 /home/dockerfile 目录中。官方的 centos 是阉割过的,很多命令都没有,我想自己补一些进去。
先解决 yum 的两个坑。当时安装卡在 yum 上,怀疑是 OS 的 DNS 解析失败 —— centos 里的 /etc/resolv.conf 配了无效 DNS,而镜像只读又改不了,所以退一步配置全局的 docker DNS:
echo '{"dns": ["8.8.8.8", "114.114.114.114"]}' > /etc/docker/daemon.json
systemctl restart docker
# 当时安装卡在yum,可能是os的DNS解析失败
# 因为centos配置的 /etc/resolv.conf 配置了无效DNS而镜像只读无法修改
# 所以可以配置全局dockerDNS,但是这部分是网络的,这个问题先放着后续了解
另一个坑是 yum 源太慢,默认官方源换成阿里云镜像:
# yum卡住了,配置yum源,默认官方源改为指定镜像
# sed -i 's/旧内容/新内容/g' 文件名
RUN sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-*.repo && \
sed -i 's|#baseurl=http://mirror.centos.org|baseurl=http://mirrors.aliyun.com|g' /etc/yum.repos.d/CentOS-*.repo && \
yum makecache && yum update -y && yum install -y vim net-tools
完整的 Dockerfile:
FROM centos
MAINTAINER zzz<1212@qq.com>
ENV MYPATH /usr/local
WORKDIR $MYPATH
RUN yum update -y && yum install -y vim net-tools
# 安装了vim和net-tools两个包
EXPOSE 80
# CMD只能执行一个
CMD echo $MYPATH && echo "---end---" && /bin/bash
这里 RUN 是在镜像内部运行的(因为镜像里就是一套 centos 系统,用 yum 装包),ENV + WORKDIR 把默认工作目录设成 /usr/local,EXPOSE 80 只是声明,真正映射端口还得 run 的时候用 -p。
构建:
docker build -f dockerfile-os -t zzz/centos:0.1 .
# build 不默认在当前目录,需要指定当前目录,写 . 即可
跑起来验证一下装的命令和默认目录,都正常:
root@VM-0-9-ubuntu:/home/dockerfile# docker run -it zzz/centos:0.1 /bin/bash
[root@9c0b2b874ebf /]# pwd
/usr/local
# 镜像名字写镜像ID也可以,docker images 可以看到镜像ID
# 成功
想看这个镜像是怎么做出来的,用 docker history 看镜像 ID 或镜像名:
root@VM-0-9-ubuntu:/home/dockerfile# docker history b8a8cf9df2e3
IMAGE CREATED CREATED BY SIZE COMMENT
b8a8cf9df2e3 19 minutes ago CMD ["/bin/sh" "-c" "/bin/bash"] 0B buildkit.dockerfile.v0
<missing> 19 minutes ago CMD ["/bin/sh" "-c" "echo \"---end---\""] 0B buildkit.dockerfile.v0
<missing> 19 minutes ago CMD ["/bin/sh" "-c" "echo $MYPATH"] 0B buildkit.dockerfile.v0
<missing> 19 minutes ago EXPOSE [80/tcp] 0B buildkit.dockerfile.v0
<missing> 19 minutes ago RUN /bin/sh -c sed -i 's/mirrorlist/#mirrorl… 349MB buildkit.dockerfile.v0
<missing> 19 minutes ago WORKDIR / 0B buildkit.dockerfile.v0
<missing> 19 minutes ago ENV MYPATH=/usr/local 0B buildkit.dockerfile.v0
<missing> 19 minutes ago MAINTAINER zzz<1212@qq.com> 0B buildkit.dockerfile.v0
<missing> 4 years ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B
<missing> 4 years ago /bin/sh -c #(nop) LABEL org.label-schema.sc… 0B
<missing> 4 years ago /bin/sh -c #(nop) ADD file:805cb5e15fb6e0bb0… 256MB
从上往下读,每一行就是一层:一条 CMD 一层、一个 ENV 一层,连 EXPOSE 这种不占空间的也是一层(SIZE 是 0B)。只有 RUN 那层是 349MB,因为真的装东西了。
再做一个带 vim、ifconfig、jdk8 的 centos
同一个思路再走一遍。官方镜像太干净,实际用起来还想要 jdk8,需求就三条:

jdk 可以去 Oracle 官网下,Linux 那一栏挑 x64 的压缩包:

官网太慢就用镜像站,选好版本跟在目录后面直接 wget:

先把 jdk 压缩包和 Dockerfile 放进同一个目录(ADD 写的是相对路径,包必须和 Dockerfile 在一起),然后写文件:
FROM centos
MAINTAINER zzz<zzz@163.com>
ENV MYPATH /usr/local
WORKDIR $MYPATH
#安装vim编辑器
RUN yum -y install vim
#安装ifconfig命令查看网络IP
RUN yum -y install net-tools
#安装java8及lib库
RUN yum -y install glibc.i686
RUN mkdir /usr/local/java
#ADD 是相对路径jar:把jdk-8u202-linux-x64.tar.gz添加到容器中
ADD jdk-8u202-linux-x64.tar.gz /usr/local/java/
#配置java环境变量
ENV JAVA_HOME /usr/local/java/jdk1.8.0_202
ENV JRE_HOME $JAVA_HOME/jre
ENV CLASSPATH $JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar:$JRE_HOME/lib:$CLASSPATH
ENV PATH $JAVA_HOME/bin:$PATH
EXPOSE 80
CMD echo $MYPATH
CMD echo "success---ok"
CMD /bin/bash
构建和验证:
# 在当前目录使用Dockerfile构建镜像,最后有个 . 表示在当前路径下
docker build -t centosjava8:1.5 .
# 跑起来试一下 vim、ifconfig、java -version
docker run -it centosjava8:1.5 bash
这次构建在我这儿是失败的,因为 centos 的 yum 源早就失效、官方不维护了,appstream 仓库直接 404:

Dockerfile 本身没问题,跟前面那次 centos 构建踩的是同一个坑 —— 换台有源可用的机器、或者换成 ubuntu 底包就能过。上面三条 CMD 也正好印证了"多个 CMD 只有最后一个生效":真正生效的只有最后的 /bin/bash,前面两句等于白写。要是当初写的是 ENTRYPOINT + CMD,docker run 后面再跟参数就只是换个参数,而不是把入口整个换掉 —— 这正是下一节要做的实验。
六、CMD 和 ENTRYPOINT 的区别
这一节是重点,光看文档记不住,直接做实验。
先把 CMD 的几个注意点过一遍:一个 Dockerfile 里写多个 CMD 只有最后一个生效,而且 docker run 后面跟的参数会把它整个替换掉:

格式和 RUN 类似,也是三种写法:

它和 RUN 最容易混,一句话说清:CMD 在 docker run 的时候运行,RUN 在 docker build 的时候运行:

拿 tomcat 官方镜像做例子,它 Dockerfile 的最后两行是:

正常 docker run -it -p 8080:8080 tomcat 会带着这个 CMD 起来,服务是正常的。可要是我手贱在后面加个参数:
# 正常运行一个容器,会启用dockerfile的CMD命令
docker run -it -p 8080:8080 tomcat
# 如果后面加了参数,就会覆盖掉CMD,导致启动失败
docker run -it -p 8080:8080 tomcat /bin/bash
CMD 就被整个换掉了,相当于变成了 /bin/bash 在跑:

这时候容器看着是起来了,tomcat 页面却打不开 —— 因为容器的入口已经不是启动 tomcat 了。
1. 先测 CMD
FROM centos
CMD ["ls","-a"]
构建:
docker build -f centos-cmd -t zzz/centos-cmd .
直接 run 就会执行 CMD 里的命令:
root@VM-0-9-ubuntu:/home/dockerfile# docker run zzz/centos-cmd
.
..
.dockerenv
bin
dev
etc
home
lib
lib64
lost+found
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
2. 在 run 后面追加命令 -l
root@VM-0-9-ubuntu:/home/dockerfile# docker run zzz/centos-cmd -l
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "-l": executable file not found in $PATH
Run 'docker run --help' for more information
# CMD 的情况下,-l 替换了CMD的["ls","-a"] 命令,-l不是命令报错了
# -l 作为参数和CMD是二选一的,-l 是run命令的参数
# 任何情况CMD都只会执行最后一个
# 下面就可以,正确的命令覆盖CMD就行
root@VM-0-9-ubuntu:/home/dockerfile# docker run zzz/centos-cmd ls -al
total 56
drwxr-xr-x 1 root root 4096 Aug 2 06:59 .
drwxr-xr-x 1 root root 4096 Aug 2 06:59 ..
-rwxr-xr-x 1 root root 0 Aug 2 06:59 .dockerenv
lrwxrwxrwx 1 root root 7 Nov 3 2020 bin -> usr/bin
drwxr-xr-x 5 root root 340 Aug 2 06:59 dev
drwxr-xr-x 1 root root 4096 Aug 2 06:59 etc
drwxr-xr-x 2 root root 4096 Nov 3 2020 home
lrwxrwxrwx 1 root root 7 Nov 3 2020 lib -> usr/lib
lrwxrwxrwx 1 root root 9 Nov 3 2020 lib64 -> usr/lib64
drwx------ 2 root root 4096 Sep 15 2021 lost+found
drwxr-xr-x 2 root root 4096 Nov 3 2020 media
drwxr-xr-x 2 root root 4096 Nov 3 2020 mnt
drwxr-xr-x 2 root root 4096 Nov 3 2020 opt
dr-xr-xr-x 226 root root 0 Aug 2 06:59 proc
dr-xr-x--- 2 root root 4096 Sep 15 2021 root
drwxr-xr-x 11 root root 4096 Sep 15 2021 run
lrwxrwxrwx 1 root root 8 Nov 3 2020 sbin -> usr/sbin
drwxr-xr-x 2 root root 4096 Nov 3 2020 srv
dr-xr-xr-x 13 root root 0 Jul 30 10:25 sys
drwxrwxrwt 7 root root 4096 Sep 15 2021 tmp
drwxr-xr-x 12 root root 4096 Sep 15 2021 usr
drwxr-xr-x 20 root root 4096 Sep 15 2021 var
一句话总结 CMD:run 后面给的东西是直接替换掉 CMD 的,所以 -l 会被当成命令去找,找不到就报错;写完整的 ls -al 才对。
3. 再测 ENTRYPOINT
ENTRYPOINT 就是为了解决这个"被覆盖"的问题而存在的。它和 CMD 搭配起来用,CMD 就成了参数:

合起来就是 <ENTRYPOINT> "<CMD>" 这个意思。官方给的例子最清楚 —— 假设用 Dockerfile 构建了 nginx:test 镜像,ENTRYPOINT ["nginx", "-c"] 是定死的,CMD ["/etc/nginx/nginx.conf"] 是可变参数:

回到实验上:ENTRYPOINT 是直接在最后追加命令,不需要替换的过程,同样只执行最后一个:
# 构建
FROM centos
ENTRYPOINT ["ls","-l"]
# 创建
docker build -f centos-ent -t zzz/centos-ent .
# run追加测试
# 相当于把 -a 追加到 ENTRYPOINT ["ls","-l"]
# 最终是 ENTRYPOINT ["ls","-l","-a"]
root@VM-0-9-ubuntu:/home/dockerfile# docker run zzz/centos-ent -a
total 56
drwxr-xr-x 1 root root 4096 Aug 2 07:05 .
drwxr-xr-x 1 root root 4096 Aug 2 07:05 ..
-rwxr-xr-x 1 root root 0 Aug 2 07:05 .dockerenv
lrwxrwxrwx 1 root root 7 Nov 3 2020 bin -> usr/bin
drwxr-xr-x 5 root root 340 Aug 2 07:05 dev
...
(后面还是平时那一屏根目录,这里不重复贴了。)
-a 追加成功了,这一点跟 CMD 正好相反:
| 追加参数时的行为 | 结果 | |
|---|---|---|
CMD ["ls","-l"] | run 镜像 -a 整段替换掉 CMD | 把 -a 当命令执行,报 executable file not found |
ENTRYPOINT ["ls","-l"] | run 镜像 -a 追加到 ENTRYPOINT 后面 | 实际执行 ls -l -a,正常出结果 |
所以想做一个"固定入口、允许带参数"的镜像就用 ENTRYPOINT,想留一个能被整个覆盖的默认命令就用 CMD;两个一起写的时候,CMD 就成了 ENTRYPOINT 的默认参数。另外注意 docker run 后面追加的命令,会覆盖掉 CMD,但不会覆盖 ENTRYPOINT。
到这里 Dockerfile 的基础就差不多了:会写指令、会 build、知道每条指令是一层、能分开 CMD 和 ENTRYPOINT。下一页继续看实战镜像和多阶段构建、构建优化这些进阶内容。