dockerfile 使用教程
0 安装
0.1 Linux
0.2 Windows
安装 docker desktop。其基于 WSL2,需要安装 WSL2 ,否则无法启动。具体教程可以参见之前的博文 wsl 和 docker desktop 的安装教程。
1 dockerfile
1.1 简单示例
一个简单 dockerfile 的示例:
代码 1.1.1 Dockerfile
使用 docker build . -t mycentos
输出 1.1.1
注意命令中有一个 .
代表从命令行运行目录中寻找要构建的文件,代码 1.1.1 的第二行有一个 COPY 操作,其构建的时候会将当前目录中的 CentOS-Base.repo
拷贝覆盖到 /etc/yum.repos.d/CentOS-Base.repo
。如果说你构建所需的文件都在命令行运行的上层目录,则可以把 .
换成 ..
,如果构建所需的文件都在 /xx
目录中,可以把 .
换成 /xx
。
如果你运行docker build 的目录中没有 dockerfile,或者其名字不叫 Dockerfile
, 可以使用 -f
( 或者 --file
) 参数来手动指定,比如说 docker build -f ./somepath/xx.Dockerfile
,将读取 somepath
子目录下的 xx.Dockerfile
文件。
-t
参数指定构建出来的镜像的标签,执行 docker images
命令,可以查看当前构建好(或者下载好)的镜像列表:
如果 docker build 没有指定 -t 参数的话,REPOSITORY
栏和 TAG
栏会显示为 <none>
。-t 参数指定标签的时候的语法是 ${name}[:${version}]
, ${version}
没有指定的话,默认为 latest
。
代码 1.1.1 ** 中的每一个命令在构建的时候,docker 都会创建一个临时 docker 来执行对应的命令,输出 1.1.1** 中可以看出一共做了两步构建,生成了两个临时 docker(8652b9f0cb4c
和 f3e54049025a
)。
对于第一个命令 FROM centos:7
, 如果你是第一次在某个 dockerfile 中使用的话,会触发一次初始化拉取动作,由于我之前已经拉取过 centos:7 这个镜像了,所以 输出 1.1.1 并没有显示拉取操作。对于 FROM 命令来说,其指定的镜像如果本地存在,则直接使用本地的,所以如果你想保持追踪最新的父镜像,则需要在执行 docker build 命令之前,先执行一下 docker pull 父镜像
,比如说对于 代码 1.1.1 来说,需要运行 docker pull centos:7
。
对于 COPY 命令来说,其会将当前文件的权限一块拷贝到镜像中,如果当前待拷贝的是脚本文件,且希望其后续能被执行,则需要确保其有可执行权限。在 Windows 中,默认就是有执行权限的。但是我们一般是将 dockerfile push 到远程 git 仓库,然后触发 CI 来构建镜像,这个时候你需要确保在 git 中当前脚本文件具有可执行权限,对应的 git 命令为 git update-index --chmod +x somefile
,注意你需要通过 git add 将其添加到 git 仓库后才能运行 git update-index 命令。由于我们当前要添加的文件,仅仅是个配置文件,不需要可执行权限,所以这里没有更改其权限的必要。
输出 1.1.1 中显示了构建出来的镜像的 ID,即 IMAGE ID
栏显示的 f3e54049025a
,这个也在 docker images 命令中显示出来。
docker 本身具有缓存机制,命令在本地运行过一次后,下次运行就会走缓存,我们再运行一次 build 命令:
输出 1.1.2
会看到 Step 2/2 中显示的是使用缓存(Using cache
),并且镜像 ID 还是为 f3e54049025a
,跟第一次构建时生成的一样。
1.2 传递参数
Dockerfile 中可以支持传递参数来做个性化构建,如果你需要构建的镜像中使用的软件包有多个版本,那么你就可以通过传递参数的方式指定当前要构建的软件的版本号,从而生成不同的镜像。
举个例子,以下是一个构建 zookeeper 的 Dockerfile(代码1.2.1可以从这里找到)
代码 1.2.1
为了方便我们构建镜像,我们先新建一个 build.sh 文件(代码可以从这里找到):
代码 1.2.2
代码 1.2.1 中 FROM 参数中使用的镜像是托管在 dockerhub 上的,很多公司都会自建镜像仓库,比如说我在 代码 1.2.2 中将构建出来的镜像推送到了阿里云仓库中。如果有新的镜像要依赖于我刚才构建出来的镜像,那么 FROM 会写成这样:
docker 遇到这句话的时候,会自动拼接一个 https 的地址进行请求。不过有一种特殊情况就是,公司的运维人员在搭建自建镜像仓库的时候,没有启用 https ,而是直接用了 http 协议,那么 FROM 指令可能就是这样的:
那么你就需要更改你的 docker 的配置文件,Linux 下位于 /etc/docker/daemon.json,添加如下配置:
Windows 下,直接在设置界面的 Docker Engine 菜单做修改。修改完成后都需要重启 docker 服务。
代码 1.2.2 中我们先做了一个 docker pull 操作,保证我们用的父镜像是最新的。然后做 docker build 的时候,增加了一个 –build-arg 参数。启用来指定构建参数 ZOOKEEPER_VERSION 为 3.7.0,同时在 代码 1.2.1 中通过 ARG ZOOKEEPER_VERSION
来做声明,这句话是必须的,否则传递过来的 –build-arg 不会被读取。
我们在脚本 install_zk.sh
(完整代码可以从这里找到)中可以引用了环境变量 ZOOKEEPER_VERSION
,这个变量的值就是通过 ARG ZOOKEEPER_VERSION
传递过来的:
代码 1.2.3 install_zk.sh 的部分代码
为了方便查阅,在 代码 1.2.1 中一般还会添加一个 ENV 指令(就是代码中注释掉的那句指令,之所以这里注释掉,是为了演示单纯使用 ARG 指令,也能在 RUN 命令中读取 ARG 指定的变量的值),这样保证在镜像制作完成后,以当前镜像启动的 docker 中也可以读取到环境变量
ZOOKEEPER_VERSION
的值,方便排查问题。
由于我们这里使用了可执行脚本,所以在推送到 git 仓库之前,需要运行 git update-index 命令,以保证在 Linux 上能够正常执行。
存储在 git 仓库中文件的权限只有 644 和 755 两种,如果你在 docker 中需要使用一些比较特殊的权限,不如说 ~/.ssh 目录下的文件,必须是 600 权限,你还是必须在 dockerfile 中使用 RUN 指令强制将 ~/.ssh 下的目录 chmod 成 600。
如果当前项目的目录层级比较深,可以在调用 ls-tree 中添加
-r
参数,例如下面命令可以批量查看当前项目在 git 仓库中所有没有可执行权限的 shell 文件:
然后重新 commit,提交代码到远程 git 仓库,就可以保证在 Linux 下正常使用了。
由于我们的安装操作比较复杂,我们这里做了一个 shell 文件来执行 RUN 指令,假设你想执行的命令并不负责也可以直接写在 dockerfile 中
代码 1.2.4
如果单条命令比较长,也可以使用换行
代码 1.2.5
docker 默认启动时,是使用 root 用户进行登录,但是有一些第三方程序在设计的时候,不支持使用 root 用户来执行,这时候你必须切换到一个非 root 用户。以下代码节选自 nodebook 项目中的 Dockerfile 文件:
代码 1.2.6
由于 gitbook pdf 命令不支持使用 root 用户运行,所以这里先通过 useradd 命令创建一个名字为 gitbook
的用户。然后将 /opt 的归属权更改位 gitbook
用户,接着使用 USER 指令将 docker 的用户切换位 gitbook
,后面的 RUN 指令中的命令就会使用 gitbook
用户来运行。
1.3 启动命令
一般制作基础镜像时,会在 dockerfile 中指定一个 ENTRYPOINT
指令来 docker 启动时的自运行脚本文件;同时 dockerfile 中还可以使用 CMD
指令,它可以直接指定启动命令。
docker 在使用这两个指定的策略是这样的:
如果当前 dockerfile 和其应用的父级(包括其父级的父级) dockerfile 中没有任何 ENTRYPOINT
和 CMD
指定,则制作好的镜像在进行 docker run 时立即退出,其实这种情况下制作的镜像没有任何意义。
当前镜像有 ENTRYPOINT
,则容器在启用的时候会执行 ENTRYPOINT
指定的脚本文本,这里我们演示一下:
代码 1.3.1 hang.Dockerfile
执行 docker build . -t hang -f hang.Dockerfile
构建镜像,然后执行 docker run --rm --name myhang hang
来在前台运行我们的 docker 容器,这里加了一个 --rm
参数,这样我们使用 CTRL + C 退出控制台的时候,容器会被 stop 并且级联删除,省的我们自己手动删了。 然后我们重新打开一个控制台窗口,输入命令 docker exec -it myhang bash
即可进入我们当前创建的容器,在里面可以执行 shell 命令行做调试工作。
看上去使用 ENTRYPOINT
指令已经完美解决我们的问题,CMD
命令感觉比较多余,其实并不是这样,ENTRYPOINT
和 CMD
有一个隐藏功能。考虑一个简单情况,当两者都出现一个 dockerfile 中:
代码 1.3.2 both.Dockerfile
代码 1.3.3 parent.sh
代码 1.3.4 sub.sh
这里同时指定了 ENTRYPOINT 和 CMD 指令,那么运行结果是后者覆盖前者吗?先别下结论,我们构建出来镜像运行看看。
运行如下命令
会直接输出
两个脚本的内容都输出了,难道是 ENTRYPOINT 和 CMD 两个指令先后被执行吗?注意代码 1.3.3 中的最后一行 exec "$@"
,其意思是将脚本命令行中输入的参数当成命令来运行。对于 both.Dockerfile
来说其制作出来的镜像,最终启动的命令为 /parent.sh /sub.sh
(CMD 指令被当成了 ENTRYPOINT 指令的参数),这个命令中的/sub.sh
被当成了 parent.sh
的命令行参数,也就是 $@
的值为 /sub.sh
。
总结一下,如果 ENTRYPOINT 和 CMD 同时出现时,最终运行效果为 CMD 中的指令会被当成 ENTRYPOINT 中脚本的参数。这个特性隐藏的比较深,可能好多初学者不清楚。同时它会给我们启发,我可以在父层镜像中指定 ENTRYPOINT 来做初始化操作,在最后一行加上 exec "$@"
,然后子镜像中使用的 CMD 就可以执行个性化的命令了。
为了做对比,我们再做一个镜像文件:
代码 1.3.5 single.Dockerfile
代码 1.3.6 single.sh
single.sh 和 parent.sh 相比少了 exec "$@"
,我们通过如下命令来进行构建和运行:
运行完成之后,docker 立即退出了,/sub.sh
未被执行。
父子镜像组合使用 CMD
和 ENTRYPOINT
时,可能出现更为复杂的情况,总结如下:
父镜像 | 子镜像 | 结果 |
---|---|---|
CMD | CMD | 只执行子 CMD |
CMD | ENTRYPOINT | 只执行子 ENTRYPOINT |
ENTRYPOINT | CMD | 父ENTRYPOINT,子CMD均被执行 |
ENTRYPOINT | ENTRYPOINT | 只执行子 ENTRYPOINT |
同一指令后者会覆盖前者,
ENTRYPOINT
是 docker 启动的入口点,而CMD
是入口点的传参。当未显式设置ENTRYPOINT
时,可以理解成默认的ENTRYPOINT
为exec "$@"
。
1.4 构建阶段
我们通过 docker 来构建镜像的时候,免不了要做代码编译打包等操作,很多编程语言需要编译环境来能构建可执行应用包,但是这些编译环境个头比较大,而且服务器环境运行应用时很多编译用的工具根本不需要,如果在镜像中包含这些工具,平白无故会增加很多体积。构建阶段就是在这种场景下应运而生的。
代码 1.4.1 bin.Dockerfile
docker build 时通过 --target ${targetName}
可以手动运行的阶段,如果不指定的话,就从头到尾运行完整个 dockerfile。比如说 代码 1.4.1 中 使用参数 --target build-stage
可以直接执行 build-stage 阶段的构建,忽略 export-stage 阶段的构建。不加参数的话,会构建 export-stage 阶段,当然也会级联构建 build-stage 阶段。
scratch 镜像是一个特殊的镜像,里面没有任何文件,一般是用来配合将镜像中的生成物做导出用的。不过这个导出功能属于新特性,目前只有在开启 BuildKit 特性的情况下才支持。下面给出 代码 1.4.1 的构建脚本:
代码 1.4.2
代码 1.4.1 和 代码 1.4.2 , 可以从项目 use-my 中找到。
1.5 交叉构建
docker 镜像支持在不同 CPU 平台中,通过 pull 同一个镜像名来下载对应平台的镜像。这就要求我们在构建镜像中的时候开启交叉构建支持。比如说你当前是 x86 平台,构建的时候还想同时构建 arm 平台的镜像,可以通过 –platform=linux/amd64,linux/arm64 参数实现。但是我们在构建镜像内部要安装的二进制包,就需要区别对待了,为此 docker 会预知几个构建参数,对于 platform 为 linux/amd64 来说,会注入如下几个构建参数:
TARGETPLATFORM=linux/amd64
TARGETOS=linux
TARGETARCH=amd64
举一个例子,我们自己构建一个 nodejs 的镜像,需要在 dockerfile 中安装 node 的二进制文件,并且我们想让其支持 x64 和 arm64 两种 CPU 架构,可以在 dockerfile 中这么写: 代码 1.5.1
由于TARGETARCH
是预定义好的构建参数,我们只需要通过ARG
指令声明一下,在后面的代码中就可以读取其中的值了。这里我们在 shell 中根据TARGETARCH
做了一个映射,将其转成 node 平台上的 CPU 架构命名方式,进行下载。
通过DOCKER_BUILDKIT=1 docker buildx build --platform linux/amd64,linux/arm64,linux/arm .
可以一键生成 x86 和 arm64 平台的镜像了。
2 已知问题
2.1 清理磁盘
在 1.1 小节讲到 dockerfile 中的每一个命令都会创建一个临时 docker,但是如果你的 dockerfile 文件有问题,执行到一半退出了,那么这些临时 docker 不会被删除,同时这些临时 docker 还会对应临时镜像,也会被保留,素以我们需要定期清理。
docker 1.13 版本开始提供了一个实用的命令, docker system prune
删除关闭的容器、无用的数据卷和网络,以及dangling镜像(即无tag的镜像),通过 docker system df
可以查看当前所有镜像占用的磁盘统计信息。
docker system prune
命令默认不会清理当前未被容器使用的镜像,使用 -a 参数可以清理所有未运行状态的容器关联的镜像。如果嫌手动清理太过麻烦,可以添加定时清理任务,通过crontab -e
后输入如下指令0 3 * * * docker system prune -af --filter "until=$((30*24))h" >> /tmp/docker-prune.log 2>&1
便可在每天凌晨做自动清理,为了清理时无需手动确认,命令中增加了-f
参数,同时为了尽可能的复用缓存,只清理 30 天前的镜像。如果想在清理过程中保留某些复用频率比较高的镜像,可以通过增加 label 级别的过滤器,比如说--filter "label!=creator=xxx"
,会忽略镜像中 creator 标签值为 xxx 的镜像。
docker system prune
命令默认不会清理数据卷,如果想清理数据卷,需要加上--volumes
参数,它会清理不被容器使用的匿名卷,使用这个参数时跟 until 筛选参数不能兼容(label 筛选参数虽然支持,但是用在这里意义不大),所以你需要新写一条定时任务来清理匿名卷:0 4 * * * docker system prune -f --volumes >> /tmp/docker-prune.log 2>&1
。
如果你的 docker 版本低于 1.13,可以执行以下脚本来删除:
代码 2.1.1 清理 docker 镜像脚本
2.2 拉取基础镜像缓慢
默认的镜像仓库地址是托管在 dockerhub 上,由于众所周知的原因,国外的网站在咱们这里访问并不稳定,并且 dockerhub 本身处于商业考量,还会对用户的访问进行限速,所以我们一般会在 docker 的配置文件中修改镜像仓库地址,将其指向国内地址。
具体配置文件在 Linux 下是 /etc/docker/daemon.json,修改其中的 registry-mirrors
属性即可:
代码 2.2.1
这个属性是一个数组,可以填入多个镜像仓库地址。修改完成之后,使用命令 service docker restart
重启守护进行即可。
如果是在 Docker Desktop 中,则点击设置按钮,定位到 Docker Engine 选项卡,就可以看到 registry-mirrors
的属性配置了。修改完成后点击 Apply & Restart 重启即可。
图 2.2.1
2.3 生成的镜像个头大
有的时候,明明你感觉 dockerfile 中做的操作很少,生成的镜像却出其意料的大,那么你就需要一款对于 dockerfile 中指令进行分析的工具。
dockerfile 中首先会指定一个基础镜像(通过 FROM 指令指定),接着后面代码中每一个新指令都会在前面指令的基础上叠加一层,产生一个新的临时镜像,直到 dockerfile 中所有指令执行完成,得到一个新的完整的镜像。
考虑下面一个 dockerfile
代码 2.3.1 first.Dockerfile
我们想自己制作一个 centos 的基础镜像,以后在制作其他应用的镜像的时候,可以拿这个镜像做基础镜像。我们的需求也很简单,增加一些常用的依赖包即可。
通过 docker build . -f first.Dockerfile --progress=plain --no-cache -t first-centos
来构建,运行完之后通过 docker images first-centos
查看镜像体积,通过输出发现镜像有五百多 MB
但是我们查看 centos 的镜像大小 docker images centos:7
:
原始的镜像只有两百多 MB,那么到底是哪行命令导致的呢?这个时候,你可以使用 dive 这个工具。将其安装完成之后直接用 dive first-centos 命令即可查看 first-centos 这个镜像中对应的 dockerfile 中每行指令所产生的“层”的大小。
官方提供的使用 go get 安装的模式,经过笔者测试在 go 1.16+ 下无法运行(可以参见 issue 371)。Windows 中安装二进制文件后,在命令行中排版有问题。推荐使用 Ubuntu 安装 deb 包的模式进行安装。
使用 dive first-centos
来查看各个层的文件变动,加载完界面后,按 Tab
键激活左右命令行区域,切换到右侧区域后,按 CTRL+U
键可以过滤掉未修改的文件,只显示被修改的文件。然后再通过 Tab
键回到左侧区域,通过方向键切换查看各个指令,对应右侧区域会显示镜像内有哪些文件被更改。
图 2.3.1 查看 first-centos 的各层空间占用
可以看到最后两个 yum 命令,会在 /var/cache 中生成大量缓存文件,是导致我们镜像内容变动的原因。yum 的在运行 install 命令时,会检测包元数据的缓存数据是否存在,如果不存在就会重新生成。所以我们在查看 yum install 命令的级联文件变动的时候,会在 /var/cache 下显示如此大的磁盘新增量。
接着我们修改一下 代码 2.3.1 ,增加缓存清理机制:
代码 2.3.2 second.Dockerfile
使用命令 docker build . -f second.Dockerfile --progress=plain -t second-centos
构建完成之后,再用命令 dive second-centos
查看,可以发现各个层的文件大小正常了:
图 2.3.2