dockerfile是构建docker镜像的基础,它规定了一系列语法,让我们可以在某个基础镜像之上,添加自己需要的操作,打包出一个自己的镜像。

1. dockerfile基本语法

下面是dockerfile的基本语法和其对应的功能,dockerfile中的每一个指令都对应的镜像的一层

除了这些构建语法外,在dockerfile中使用#开头的行代表注释行。注意,注释只能单成一行,不能在已有语句末尾追加。

语法说明
FROM指定基础镜像
RUN在基础镜像上需要执行的命令(构建命令)
WORKDIR其实就是cd的意思,设置镜像的工作目录
VOLUME设置需要挂载文件实现持久化的目录
EXPOSE指定容器对外暴露的端口
ENV设置镜像中的环境变量
ARG设置dockerfile构建过程中的环境变量
ENTRYPOINT设置默认的可执行文件
HEALTHCHECK在容器启动的时候进行健康检查
MAINTAINER设置维护者信息(弃用,推荐用LABEL替代)
LABEL给镜像添加元数据(如镜像作者)
ADD添加本地/远程的目录或文件
COPY拷贝文件/目录
ONBUILD只有FROM基于当前镜像的时候才会执行
SHELL设置镜像默认使用的shell
STOPSIGNAL设置特定的系统信号来让容器退出
USER设置执行构建命令的用户和用户组ID

本文对这些命令的解释只停留于基础,更详细的介绍建议查看dockerfile文档。

2. dockerfile语法详解

2.1. FROM

这个是指定当前需要构建的镜像的基础镜像,dockerfile文件中必须要有FROM字段。

比如我们有一个项目,需要在ubuntu环境上运行,我们就可以指定当前镜像是基于ubuntu镜像的。

1
FROM ubuntu:22.04

这时候就出现了一个问题了,这些基础镜像是怎么制作出来的?以CentOS 7.6为例,官方的dockerfile是这么写的。

1
2
3
4
5
6
7
8
9
10
FROM scratch
ADD centos-7-docker.tar.xz /

LABEL org.label-schema.schema-version="1.0" \
org.label-schema.name="CentOS Base Image" \
org.label-schema.vendor="CentOS" \
org.label-schema.license="GPLv2" \
org.label-schema.build-date="20181204"

CMD ["/bin/bash"]

第一行的FROM scratch代表从“空”开始创建镜像,而第二行的ADD代表添加了一个文件。从Github仓库的文件路径中可以看到,官方使用了一个centos的tar.xz系统包,这个压缩包里面是系统运行的必要二进制文件。

image.png

把这个centos-7-docker.tar.xz压缩包下载到本地,解压看看,内部其实就是一个centos系统的根路径下的必要内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
❯ tree -L 1 /tmp/centos7
/tmp/centos7
├── anaconda-post.log
├── bin -> usr/bin
├── dev
├── etc
├── home
├── lib -> usr/lib
├── lib64 -> usr/lib64
├── media
├── mnt
├── opt
├── proc
├── root
├── run
├── sbin -> usr/sbin
├── srv
├── sys
├── tmp
├── usr
└── var

18 directories, 1 file

至此可以明确一个概念,我们需要制作一个镜像,有两种方式:

  • 一个是基于开源的镜像的基础上二次构建;
  • 第二种方式就是像centos官方一样,制作系统的二进制文件,从0开始构建镜像。

如果不是有神马特殊需要,直接使用第一种方式,基于已有开源镜像的基础来构建镜像就够了。

2.2. RUN

RUN字段指定了在基础镜像上需要执行的命令,通常用于安装环境。这些命令都等同于直接在基础镜像的shell中运行的命令。

RUN有两种语法格式,一种是直接写命令,另外一种是用exec的格式将命令和选项拼接起来。

1
2
3
4
RUN <需要运行的命令>
# 等价于直接运行命令
RUN ["apt","install","vim"]
# 等价于 RUN apt install vim

比如我们创建一个ubuntu的容器,尝试在里面安装一个net-tools软件包,可以如下编写dockerfile。

1
2
FROM ubuntu:22.04
RUN apt intstall -y net-tools

如果你需要执行多个环境配置的命令,使用&&将其连接,而不要写多个RUN命令

1
2
3
FROM ubuntu:22.04
RUN apt -y update && \
apt intstall -y net-tools

2.3. MAINTAINER和LABEL

MAINTAINER和LABEL都是用于给docker镜像打标签的。你可以理解为给他身上挂个名牌,这样其他人就可以从名牌上看到和这个镜像相关的一些信息。

使用docker inspect ubuntu:22.04命令,可以看到ubuntu镜像上挂着的一些名牌。

1
2
3
4
5
6
7
{
"Labels": {
"org.opencontainers.image.ref.name": "ubuntu",
"org.opencontainers.image.version": "22.04"
}
}
}

而且我们基于ubuntu:22.04镜像构建的其他镜像,也会包含这个名牌。这可以让使用者在没有看到dockerfile的情况下,了解到你构建的镜像是基于ubuntu的。

下面的LABEL和MAINTAINER的语法格式,都是key=value的形式。

1
2
3
LABEL <key>=<value> <key>=<value> <key>=<value> ...
# MAINTAINER 会设置镜像详情中的Author字段
MAINTAINER <name>

来简单试试吧

1
2
3
FROM ubuntu:22.04
MAINTAINER musnows
LABEL build.in="vmware-ubuntu"

使用这个dockerfile构建的镜像,会有下面的LABEL,且Author字段是MAINTAINER设置的musnows(ubuntu基础镜像中Author字段为空)

1
2
3
4
5
6
7
8
9
10
{
"Author": "musnows",
"Config": {
"Labels": {
"build.in": "vmware-ubuntu",
"org.opencontainers.image.ref.name": "ubuntu",
"org.opencontainers.image.version": "22.04"
}
}
}

另外,dockerfile的官方文档中提到,下面这个LABEL和MAINTAINER是对应的

1
LABEL org.opencontainers.image.authors="SvenDowideit@home.org.au"

但经过我的测试,这个LABEL不会修改Author字段(只有MAINTAINER会修改Author字段)。你可以根据自己的需要选择使用LABEL还是MAINTAINER。

2.4. SHELL

这个命令用于设置镜像默认使用的SHELL。

1
SHELL ["executable", "parameters"]

官方文档中,有列出Linux和Windows下默认使用的SHELL是什么。如果它们不符合你的要求,比如你需要使用/bin/bash作为你的shell,则可以自行更换。

The SHELL instruction allows the default shell used for the shell form of commands to be overridden. The default shell on Linux is["/bin/sh", "-c"], and on Windows is ["cmd", "/S", "/C"]. The SHELL instruction must be written in JSON form in a Dockerfile.

2.5. WORKDIR

设置docker构建过程和最终程序运行使用的工作路径。默认的工作路径是/根路径。如果指定的WORKDIR路径不存在,则会被创建。

1
WORKDIR /path/to/workdir

另外,工作路径采用的是追加的方式,比如下方设置了多个工作路径,那么最终的工作路径是/a/b/c/这个目录。你可以理解为它本质上就是一个CD命令。

1
2
3
4
WORKDIR /a
WORKDIR b
WORKDIR c
RUN pwd

工作路径可以使用环境变量,前提这个环境变量是在dockerfile中被显式设置的

1
2
3
ENV DIRPATH=/path
WORKDIR $DIRPATH/$DIRNAME
RUN pwd

此时pwd命令的输出是/path/$DIRNAME

2.6. COPY

COPY命令有两种方式,如果路径中包含空格,则必须使用第二种形式(将路径使用英文双引号括起来)

1
2
COPY [OPTIONS] <src> ... <dest>
COPY [OPTIONS] ["<src>", ... "<dest>"]

COPY命令支持使用*来匹配任意字符,?匹配单个字符。如果需要拷贝名称中包含[]的特殊文件,则需要使用Golang转义规则对文件名进行转义。详见官方文档

1
2
3
4
5
6
7
8
# 将本地文件 app.py 复制到镜像的 /app 目录
COPY app.py /app/
# 将整个本地目录 my_app 复制到镜像的 /app 目录
COPY my_app/ /app/
# 复制所有以 .py 结尾的文件到镜像的 /app 目录
COPY *.py /app/
# 将 src 目录中所有文件复制到 /app 目录
COPY src/* /app/

2.6.1. 选项说明

1
2
3
4
5
6
--from
--chown
--chmod
--link
--parents
--exclude

2.6.1.1. –from

--from选项允许COPY从多段构建中拷贝文件,或从其他镜像中拷贝文件。

1
COPY [--from=<image|stage|context>] <src> ... <dest>

比如我们可以从nginx镜像中直接拷贝配置文件,对应选项中的image

1
COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf

我们还可以从多段构建中拷贝文件,对应选项中的stage。

下面是官网中一个多段构建的dockefile,在基础镜像alpine中使用clang编译了hello.c的程序,随后将这个程序的可执行文件移动到一个空的镜像中。这就相当于空镜像里面直接添加了一个可执行文件。不过这只是个例子,经过我的测试,这个镜像并不能正常运行😑。

1
2
3
4
5
6
7
8
# syntax=docker/dockerfile:1
FROM alpine AS build
COPY . .
RUN apk add clang
RUN clang -o /hello hello.c

FROM scratch
COPY --from=build /hello /

在docker build命令中,可以指定多个构建的上下文,并在dockerfile中选择其中某个上下文中的文件进行拷贝,对应选项中的context。

1
2
3
4
5
# 运行 Docker 构建时,指定两个构建上下文
docker build -t myapp \
--build-context source1=. \
--build-context source2=../other-folder \
-f Dockerfile .
1
2
3
# 在 Dockerfile 中,从指定的构建上下文 "source2" 中复制文件
FROM alpine:latest
COPY --from=source2 /extra-files/ /app/

2.6.1.2. –chown,–chmod

这两个选项只有在Linux中构建镜像的时候才有效

1
COPY [--chown=<user>:<group>] [--chmod=<perms> ...] <src> ... <dest>

在COPY的时候,默认是使用0号PGID/PUID进行操作的。如果你想避免默认的root权限,可以通过这两个选项进行修改。参数和linux本地的chown/chmod命令一致。

详见官方文档

1
COPY [--link[=<boolean>]] <src> ... <dest>

下面的图片展示了添加--link选项和不添加这个选项时的区别,使用的dockerfile很简单

1
2
3
FROM alpine
COPY foo /
COPY bar /

使用link的时候,会从一个空镜像里面构建文件,再最终合并到原有镜像上。最终产生的是独立的blob镜像层,而不是diff(快照1,快照2)这种两个镜像层之间的diff文件。

image.png

2.6.1.4. –parents

在拷贝文件的时候保留父目录。目前尚未在稳定版中推出此功能,需要使用docker/dockerfile:1.7-labs版本。

1
COPY [--parents[=<boolean>]] <src> ... <dest>

举个例子,当我们使用如下语句,拷贝a.txt到镜像中时,最终会存在/app/a.txt文件

1
COPY ./b/a.txt /app/

但如果加上了保留父目录的选项,则会保留这个txt文件的父目录b,最终会存在/app/b/a.txt文件

1
COPY --parents ./b/a.txt /app/

在同时拷贝多个文件的时候,这样做就有效果了。如下所示,第一行的copy没有设置保留父目录,此时两个文件中的a.txt最终只在镜像内保留了一个(应该保留的是y的那一个),出现了文件被覆盖的问题。使用了--parents选项就不会有这个问题了。

1
2
3
4
5
6
7
8
9
# syntax=docker/dockerfile:1.7-labs
FROM scratch

COPY ./x/a.txt ./y/a.txt /no_parents/
COPY --parents ./x/a.txt ./y/a.txt /parents/

# /no_parents/a.txt
# /parents/x/a.txt
# /parents/y/a.txt

2.6.1.5. –excule

允许在拷贝的时候忽略某些路径中的内容。目前尚未在稳定版中推出此功能,需要使用docker/dockerfile:1.7-labs版本。

1
COPY [--exclude=<path> ...] <src> ... <dest>

2.7. ADD

ADD命令是更加高级的COPY命令,它有两种形式。如果路径中包含空格,则必须使用第二种形式(将路径使用英文双引号括起来)

1
2
ADD [OPTIONS] <src> ... <dest>
ADD [OPTIONS] ["<src>", ... "<dest>"]

当源文件是tar压缩文件,压缩方式为gzip、bzip或xz的情况下,ADD命令会自动将压缩包中的内容解压并复制到目标路径中。但是这个解压是不可以关闭的,即如果你不需要解压压缩包,则只能使用COPY命令。

1
2
3
4
# 将 archive.tar.gz 解压缩到镜像的 /app 目录
ADD archive.tar.gz /app/
# 从 URL 下载文件并放在镜像的 /app 目录
ADD https://example.com/file.txt /app/

2.7.1. 选项说明

下面是可选的OPTIONS

1
2
3
4
5
6
--keep-git-dir
--checksum
--chown
--chmod
--link
--exclude

2.7.1.1. –keep-git-dir

默认情况下,如果src是一个git仓库连接,则ADD会忽略.git目录。将下面这个选项设置为true,则会保留.git目录

1
2
3
--keep-git-dir=<boolean> 
# 示例
ADD --keep-git-dir=true https://github.com/moby/buildkit.git#v0.10.1 /buildkit

2.7.1.2. –checksum

如果src是一个HTTP的连接(只支持HTTP/HTTPS下载的文件),则可以使用checksum设置这个文件的校验和,ADD命令会在下载了文件之后自动进行校验,判断是否下载成功。

1
2
3
ADD [--checksum=<hash>] <src> ... <dir>
# 示例
ADD --checksum=sha256:24454f830cdb571e2c4ad15481119c43b3cafd48dd869a9b2945d1036d1dc68d https://mirrors.edge.kernel.org/pub/linux/kernel/Historic/linux-0.01.tar.gz /

2.7.1.3. 剩余选项

剩下的几个选项,和COPY命令中的选项作用一致,详情可查看上文中对COPY命令的说明。

2.8. CMD

CMD命令指定了容器以神马命令启动,同时它也可以是ENTRYPOINT的扩展

1
2
3
4
5
6
7
# 直接设置这个命令
CMD executable param1 parma2
# 等价于上面这样直接设置
CMD ["executable","param1","param2"]
# 如果设置了ENTREPOINT,可以在后面使用CMD设置额外的命令选项(可以被docker run改变)
# 注意,ENTREPOINT必须采用exec格式才能使用CMD继续追加命令选项!
CMD ["param1","param2"]

如果一个容器没有指定ENTREPOINT或者CMD,则必须在docker run的时候指定才能正常运行。如果一个容器只指定了CMD,则可以被docker run指定的命令覆盖。

2.9. ENTRYPOINT

ENTRYPOINT指定了容器以神马命令启动,同样有exec和shell两种格式。

1
2
3
4
5
# exec格式
# 注意,ENTREPOINT必须采用exec格式才能使用CMD继续追加命令选项!
ENTRYPOINT ["executable", "param1", "param2"]
# shell格式
ENTRYPOINT command param1 param2

设置了ENTRYPOINT后,在docker run里面提供的命令就不再是启动命令,而是发送给ENTRYPOINT命令的参数了。

前文提到了可以在ENTRYPOINT之后进一步设置CMD来启动进程,如下所示,下面这个dockerfile,最终启动进程使用的命令是top -b -c。其中-c选项会被docker run提供的命令覆盖(此时docker run提供的命令是发送给ENTRYPOINT命令的参数,会覆盖CMD)

1
2
3
FROM ubuntu
ENTRYPOINT ["top", "-b"]
CMD ["-c"]

我们可以写个自己的程序来验证一下这里的命令行参数

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
int main(int argc,char*argv[])
{
printf("Run as: %s ",argv[0]);
for(int i = 1;i<argc;i++){
printf("%s ",argv[i]);
}
printf("\n");
return 0;
}

这个程序会把我们提供的命令行参数打印出来

1
2
3
$ gcc test.c -o test
$ ./test -c -o
Run as: ./test -c -o

使用如下dockerfile来进行操作

1
2
3
4
5
6
7
# syntax=docker/dockerfile:1
FROM alpine AS build
COPY . .
RUN apk add clang
RUN clang -o /test test.c
ENTRYPOINT ["/test","-entry"]
CMD ["-cmd"]

使用如下命令,可见CMD的参数会追加在原有命令行参数之后,而且会被docker run使用的命令覆盖。

1
2
3
4
5
$ docker build . -t myub
$ docker run myub
Run as: /test -entry -cmd
$ docker run myub -run -h
Run as: /test -entry -run -h

根据dockerfile的官方文档,你可以用ENTRYPOINT指定稳定的命令选项,并使用CMD指定一些可用的命令选项

You can use the exec form of ENTRYPOINT to set fairly stable default commands and arguments and then use either form of CMD to set additional defaults that are more likely to be changed.

2.10. ARG和ENV

2.10.1. 介绍

在介绍的表格里面说明了这两个语法的区别

  • ARG:设置镜像构建过程中使用的环境变量,只有构建过程中才有效,构建完成的镜像中不包括这个环境变量,可以在docker build中通过--build-arg <varname>=<value>覆盖;
  • ENV:构建过程和镜像中都会存在的环境变量,可以在docker run中通过-e <key>=<value>来覆盖;

同时这两个命令的语法也有细微区别,即ARG可以省略环境变量的默认值(相当于要求build的时候传入)

1
2
ARG <name>[=<default value>]
ENV <key>=<value> ...

2.10.2. ENV测试

1
2
3
4
FROM ubuntu:22.04
# env设置的环境变量,在构建过程和实际的容器中都会存在
ENV MY_VAR=from_dockerfile
CMD echo "MY_VAR is ${MY_VAR}"

使用这个dockerfile构建一个镜像,并创建容器

1
2
docker build . -t myub:test -f dockefile
docker run --rm myub:test # --rm会在容器运行完毕后自动删除

容器会在终端中输出我们刚刚设置的环境变量。注意这里我们是使用CMD命令来执行的这个echo语句,CMD是在容器创建之后,执行的命令,也就是ENV的设置已经保留到了容器中。

1
2
$ docker run --rm myub:test
MY_VAR is from_dockerfile

使用-it交互式地创建这个容器,直接启动容器的bash终端。

1
docker run -it --rm myub:test /bin/bash

在容器内的终端中使用env命令也可以看到这个环境变量

1
2
root@7cab70963b65:/# env
MY_VAR=from_dockerfile

image.png

在docker run命令中用-e选项,可以覆盖这个环境变量

1
docker run --rm -e MY_VAR=from_docker_run myub:test 

执行效果如下,最终打印的环境变量是我们run命令中配置的了(容器内的环境变量也会被修改)

1
2
$ docker run --rm -e MY_VAR=from_docker_run myub:test
MY_VAR is from_docker_run

修改dockerfile中的CMD为RUN,让echo命令在构建过程中执行

1
2
3
4
FROM ubuntu:22.04
# env设置的环境变量,在构建过程和实际的容器中都会存在
ENV MY_VAR=from_dockerfile
RUN echo "MY_VAR is ${MY_VAR}"

可以看到,构建过程中,ENV环境变量也生效了,会打印一个[RUN] echo "RUN MY_VAR is from_dockerfile

image.png

当然,生效的前提是ENV在RUN之前,如果ENV在RUN之后,那自然是无效了,打印的内容会变成echo "MY_VAR is ${MY_VAR}"这个原始内容。

image.png

另外,ENV指定的环境变量必须赋值初始值,否则语法会报错

1
2
3
4
5
FROM ubuntu:22.04
# env设置的环境变量,在构建过程和实际的容器中都会存在
# 这里省略等于号是不支持的语法,如果不知道环境变量设置什么值,可以先等于空串
ENV MY_VAR
RUN echo "MY_VAR is ${MY_VAR}"

docker build的时候会提示语法错误,ENV必须要有两个参数(即必须要给定初始值)

1
2
3
4
5
6
7
8
9
dockerfile:3
--------------------
1 | FROM ubuntu:22.04
2 | # env设置的环境变量,在构建过程和实际的容器中都会存在
3 | >>> ENV MY_VAR
4 | RUN echo "MY_VAR is ${MY_VAR}"
5 |
--------------------
ERROR: failed to solve: ENV must have two arguments

2.10.3. ARG测试

将ENV改成ARG,继续测试

1
2
3
4
FROM ubuntu:22.04
# arg设置的环境变量,在构建过程中才会存在
ARG MY_VAR=from_dockerfile
RUN echo "MY_VAR is ${MY_VAR}"

可见环境变量在构建过程中生效了,打印[RUN] echo "RUN MY_VAR is from_dockerfile

image.png

在docker build命令中可以覆盖dockerfile中设置的环境变量

1
docker build . -t myub --build-arg MY_VAR=from_docker_build

可以观察到在build命令中设置的环境变量会覆盖dockerfile中的配置,打印的是[RUN] echo "RUN MY_VAR is from_docker_build

image.png

另外,ARG设置的时候,环境变量之后是可以不带等于号的。此时相当于没有设置这个环境变量。

1
2
3
4
FROM ubuntu:22.04
# arg设置的环境变量,在构建过程中才会存在
ARG MY_VAR
RUN echo "MY_VAR is ${MY_VAR}"

直接使用docker build . -t myob进行构建,打印的内容是RUN echo "MY_VAR is ${MY_VAR}"这个原始值,因为此时MY_VAR环境变量等于没有设置。必须通过build命令传值才会设置MY_VAR环境变量。

image.png

2.11. VOLUME

在docker volume的解析中已经说明了数据卷的重要性。只要你的容器运行过程中,有需要持久化保存的重要数据,则都建议使用VOLUME在dockerfile中标出。这样即便用户没有主动绑定指定路径的数据卷,docker也会自动创建匿名数据卷来保存这里面的数据,不至于出现数据丢失问题。

1
2
3
4
# exec格式
VOLUME ["/data"]
# shell格式,等价于["/data1","/data2"]
VOLUME /data1 /data2

下面是官网上给出的一些说明。比如在json格式中,必须用双引号括起来目录名,不能使用单引号;volume的挂载是基于宿主机的,所以不能在dockerfile中指定最终挂载的host-dir,必须用户手动使用docker run命令来指定,或者由docker自行创建volume数据卷。

Keep the following things in mind about volumes in the Dockerfile.

Volumes on Windows-based containers: When using Windows-based containers, the destination of a volume inside the container must be one of:

  • a non-existing or empty directory
  • a drive other than C:

Changing the volume from within the Dockerfile: If any build steps change the data within the volume after it has been declared, those changes will be discarded.

JSON formatting: The list is parsed as a JSON array. You must enclose words with double quotes (") rather than single quotes (').

The host directory is declared at container run-time: The host directory (the mountpoint) is, by its nature, host-dependent. This is to preserve image portability, since a given host directory can’t be guaranteed to be available on all hosts. For this reason, you can’t mount a host directory from within the Dockerfile. The VOLUME instruction does not support specifying a host-dir parameter. You must specify the mountpoint when you create or run the container.

2.12. EXPOSE

指定容器需要对外提供服务的端口。比如nginx的80和443端口。且可以在dockerfile中设置默认绑定的端口值。

1
EXPOSE <port> [<port>/<protocol>...]

当一个EXPOSE的端口没有被用户设置,也没有默认值时,docker会自动绑定一个未被使用的端口给他。

1
2
3
4
5
6
# 暴露3000端口给外部(默认是tcp)
EXPOSE 3000
# 暴露6000端口给外部,并设置默认绑定宿主机的60000端口
EXPOSE 6000 60000/tcp
# 暴露10000的udp端口给外部,并设置默认绑定宿主机的12000端口
EXPOSE 10000/udp 12000/udp

注意,这里的默认绑定端口设置,只有在bridge模式创建容器的时候才会生效。如果用户使用host模式,那么就会采用原有端口(比如暴露6000端口,使用host模式就会直接绑定宿主机的6000端口,和默认值无关)绑定在宿主机上。

在host模式中,容器可以绑定宿主机的任意未使用端口,即便这些端口没有在EXPOSE中被设置。所以,如果你不想容器中的网络进程影响宿主机上的有效端口数量,则建议使用bridge模式来增强隔离性。

不管EXPOSE是如何设置的,在docker run中都可以被覆盖

1
docker run -p 80:80/tcp -p 80:80/udp ...

另外,EXPOSE只是代表容器“想要”使用这些端口,最终端口的bind操作是由容器内的进程来处理的。当然,使用bridge选项时,docker引擎会帮容器先确认宿主机的端口可用并占用,再由容器内的进程bind容器内的端口,对外提供服务。

2.13. ONBUILD

ONBUILD指定的dockerfile语句会在尝试基于当前镜像构建镜像的时候被启用。

1
ONBUILD <INSTRUCTION>

来测试一下

1
2
3
FROM ubuntu:22.04
RUN echo "running build"
ONBUILD RUN echo "on build"

可以看到,构建的时候,只有不带ONBUILD选项的命令才被执行了,而带了ONBUILD的echo "on build"命令没有被执行。

image.png

我们基于这个构建出来的镜像,再写一个dockerfile

1
2
FROM myub:latest 
RUN echo "running build from myub"

可以看到,构建过程会先执行父镜像ONBUILD设置的命令,再执行自己dockerfile中的命令,先打印的是echo "on build",然后才打印echo "running build from myub"

image.png

2.14. STOPSIGNAL

STOPSIGNAL设置docker内运行的进程在收到什么信号的时候会终止,即docker stop某个容器时,docker会给容器内进程发送的信号。

这里的signal可以是信号的名称(如SIGKILL)也可以是信号的编号。默认值是SIGTERM

1
STOPSIGNAL signal

关于信号的编号和名称,可以在linux下使用kill -l命令查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX

在docker run和docker create中可以使用--stop-signal选项覆盖dockerfile中的设置。

2.15. HEALTHCHECK

https://docs.docker.com/reference/dockerfile/#healthcheck

用于检查容器的健康状态,如果检查失败(可以设置重试次数),则会将容器标记为不健康。这可以方便集群化管理,特别是解决某些容器内服务器进程虽然仍在运行,但因为某些原因已经无法提供服务的情况。

1
2
3
4
# 基础语法
HEALTHCHECK [OPTIONS] CMD command
# 禁用健康检查,包括从父镜像继承下来的检查
HEALTHCHECK NONE

下面是一个示例,docker会每5分钟使用curl检查一下http://localhost:8080/是否可以被访问,如果超过3秒还没有反应,则认为它不能被访问。

当curl返回的状态码不是2xx/3xx的时候,则exit 1表示不成功。告知HEALTHCHECK认为容器不健康。

1
2
HEALTHCHECK --interval=5m --timeout=3s \
CMD curl -f http://localhost:8080/ || exit 1

一次检查可能不太好,我们可以设置重试次数(默认会重试3次),下面就使用retries指定了重试两次

1
2
HEALTHCHECK --interval=5m --timeout=3s --retries=2 \
CMD curl -f http://localhost:8080/ || exit 1

2.16. USER

指定用于执行构建命令和最终执行CMD/ENTRYPOINT命令使用的用户/用户组。

1
2
USER <user>[:<group>]
USER UID[:GID]

Note that when specifying a group for the user, the user will have only the specified group membership. Any other configured group memberships will be ignored.

3. 验证dockerfile命令对镜像层数的影响

前文提到,dockerfile中的一条命令就是一层,如果冗余的命令过多,会让构建出来的dockerfile层级过多。下面通过一个例子来实际验证一下。

参考 Docker 进阶之镜像分层详解

3.1. 查看基础镜像的层级

测试基于ubuntu:22.04的镜像,首先使用docker inspect命令查看这个镜像的所有层

1
docker inspect ubuntu:22.04

得到的结果如下,其中RootFS/Layers是这个镜像的所有层级。可见ubuntu的镜像只有一层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
[
{
"Id": "sha256:52882761a72a60649edff9a2478835325d084fb640ea32a975e29e12a012025f",
"RepoTags": [
"ubuntu:22.04"
],
"RepoDigests": [
"ubuntu@sha256:a6d2b38300ce017add71440577d5b0a90460d0e57fd7aec21dd0d1b0761bbfb2"
],
"Parent": "",
"Comment": "",
"Created": "2024-04-27T13:18:37.512234142Z",
"DockerVersion": "24.0.5",
"Author": "",
"Config": {
"Hostname": "",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": [
"/bin/bash"
],
"Image": "sha256:2252dc08ad59a3723b856050e7848a7fe152b469dd24cf30b0a910b7c615766c",
"Volumes": null,
"WorkingDir": "",
"Entrypoint": null,
"OnBuild": null,
"Labels": {
"org.opencontainers.image.ref.name": "ubuntu",
"org.opencontainers.image.version": "22.04"
}
},
"Architecture": "amd64",
"Os": "linux",
"Size": 77863352,
"GraphDriver": {
"Data": {
"MergedDir": "/var/lib/docker/overlay2/fbc6cfc7d29a6660ebf2a172548649ec0ce6a2578f369a2076b13f6f8a1b375c/merged",
"UpperDir": "/var/lib/docker/overlay2/fbc6cfc7d29a6660ebf2a172548649ec0ce6a2578f369a2076b13f6f8a1b375c/diff",
"WorkDir": "/var/lib/docker/overlay2/fbc6cfc7d29a6660ebf2a172548649ec0ce6a2578f369a2076b13f6f8a1b375c/work"
},
"Name": "overlay2"
},
"RootFS": {
"Type": "layers",
"Layers": [
"sha256:629ca62fb7c791374ce57626d6b8b62c76378be091a0daf1a60d32700b49add7"
]
},
"Metadata": {
"LastTagTime": "0001-01-01T00:00:00Z"
}
}
]

通过docker history命令可以查看这个镜像的构建过程,虽然这里显示了多行,但实际上有效的行只有ADD了ubuntu的系统二进制文件的那一行,其他行都是对这个镜像的基础设置和元数据设置,并没有给镜像实际添加文件。

1
2
3
4
5
6
7
8
$ docker history  ubuntu:22.04
IMAGE CREATED CREATED BY SIZE COMMENT
52882761a72a 9 days ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B
<missing> 9 days ago /bin/sh -c #(nop) ADD file:a5d32dc2ab15ff0d7… 77.9MB
<missing> 9 days ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 9 days ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 9 days ago /bin/sh -c #(nop) ARG LAUNCHPAD_BUILD_ARCH 0B
<missing> 9 days ago /bin/sh -c #(nop) ARG RELEASE 0B

3.2. 构建两个镜像

下面用两个dockerfile来基于ubuntu构建两个新的镜像。两个dockerfile执行的内容都是一致的,只不过第一个dockerfile中将命令都合并了,第二个dockerfile将命令拆分了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 构建myub:1
FROM ubuntu:22.04
# 在build-essential中会下载tzdata,会交互式的让用户选择时区
# 所以需要设置apt为非交互模式
ARG DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Shanghai
# 更新和安装软件
RUN apt-get -y update && \
apt-get -y upgrade && \
apt-get install -y \
cmake git vim curl wget \
net-tools \
openssh-server \
build-essential \
python3

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 构建myub:2
FROM ubuntu:22.04
# 在build-essential中会下载tzdata,会交互式的让用户选择时区
# 所以需要设置apt为非交互模式
ARG DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Shanghai
# 更新软件源
RUN apt-get -y update
RUN apt-get -y upgrade
# 安装一些常用工具
RUN apt-get install -y \
cmake git vim curl wget \
net-tools openssh-server
# 安装依赖
RUN apt-get install -y build-essential
# 安装python
RUN apt-get install -y python3

image.png

3.3. 查看构建的两个镜像包含的层级

分别用docker history查看这两个镜像的层级,根据刚刚docker build命令的输出,以2b9f开头的是第一个镜像(命令都写一起了),以8156开头的是命令被拆分了的docker镜像。

这里能看到第一个现象,虽然两个镜像一个是被拆分的RUN构建出来的,另外一个并没有被拆分,但最终构建出来的镜像大小并没有明显的区别(其实只是本次测试的情况没有区别,其他情况下,一般合并命令构建出来的镜像会更小)。

1
2
3
4
5
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
<none> <none> 8156b17c21a3 2 minutes ago 598MB
<none> <none> 2b9f30d55cc3 9 minutes ago 596MB
ubuntu 22.04 52882761a72a 9 days ago 77.9MB

先给这两个镜像打个tag(最好是docker build的时候就用-t选项打tag,我忘记了)

1
2
docker tag 2b9f30d55cc3 myub:1
docker tag 8156b17c21a3 myub:2

先来看命令合并的这个,可见它只在原有镜像的基础上,多了RUN命令的这一层。ARG/ENV命令虽然会在history中被显示,但并不会增加层级。

1
2
3
4
5
6
7
8
9
10
11
$ docker history myub:1
IMAGE CREATED CREATED BY SIZE COMMENT
2b9f30d55cc3 10 minutes ago RUN |1 DEBIAN_FRONTEND=noninteractive /bin/s… 518MB buildkit.dockerfile.v0
<missing> 10 minutes ago ENV TZ=Asia/Shanghai 0B buildkit.dockerfile.v0
<missing> 10 minutes ago ARG DEBIAN_FRONTEND=noninteractive 0B buildkit.dockerfile.v0
<missing> 9 days ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B
<missing> 9 days ago /bin/sh -c #(nop) ADD file:a5d32dc2ab15ff0d7… 77.9MB
<missing> 9 days ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 9 days ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 9 days ago /bin/sh -c #(nop) ARG LAUNCHPAD_BUILD_ARCH 0B
<missing> 9 days ago /bin/sh -c #(nop) ARG RELEASE 0B

通过docker inspect查看详细信息,在Layers中只能看到两层,即对应一条RUN命令创建出来的镜像层。

1
2
3
4
5
6
7
8
9
{
"RootFS": {
"Type": "layers",
"Layers": [
"sha256:629ca62fb7c791374ce57626d6b8b62c76378be091a0daf1a60d32700b49add7",
"sha256:2a381621675d1e9a462aa951211a3cf6938a480fcd82796cb18ef85234696014"
]
}
}

再来看看命令被拆分的镜像,层数就多了起来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ docker history myub:2
IMAGE CREATED CREATED BY SIZE COMMENT
8156b17c21a3 8 minutes ago RUN |1 DEBIAN_FRONTEND=noninteractive /bin/s… 10.5kB buildkit.dockerfile.v0
<missing> 8 minutes ago RUN |1 DEBIAN_FRONTEND=noninteractive /bin/s… 56.1MB buildkit.dockerfile.v0
<missing> 8 minutes ago RUN |1 DEBIAN_FRONTEND=noninteractive /bin/s… 413MB buildkit.dockerfile.v0
<missing> 9 minutes ago RUN |1 DEBIAN_FRONTEND=noninteractive /bin/s… 0B buildkit.dockerfile.v0
<missing> 9 minutes ago RUN |1 DEBIAN_FRONTEND=noninteractive /bin/s… 50.9MB buildkit.dockerfile.v0
<missing> 9 minutes ago ENV TZ=Asia/Shanghai 0B buildkit.dockerfile.v0
<missing> 9 minutes ago ARG DEBIAN_FRONTEND=noninteractive 0B buildkit.dockerfile.v0
<missing> 9 days ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B
<missing> 9 days ago /bin/sh -c #(nop) ADD file:a5d32dc2ab15ff0d7… 77.9MB
<missing> 9 days ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 9 days ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 9 days ago /bin/sh -c #(nop) ARG LAUNCHPAD_BUILD_ARCH 0B
<missing> 9 days ago /bin/sh -c #(nop) ARG RELEASE 0B

myub:2相比myub:1额外多了4层,也对应的RUN命令的个数

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"RootFS": {
"Type": "layers",
"Layers": [
"sha256:629ca62fb7c791374ce57626d6b8b62c76378be091a0daf1a60d32700b49add7",
"sha256:2ae784f0abd479326b00d2fbe2ba96a16e46056e386275d6f57988fe2ecf3034",
"sha256:fa903b82d02c9769bc58b795b9c1789a45dca63be57e81c7e15df3de2618bda0",
"sha256:3c2f1f7362025cbbb03b9a5760fc7fd72e940b1b768002ec6e15bc677e2657e4",
"sha256:86d0ee2ab97d64cee7d15598f4374bfe1ff940d89d945bbaf66e947e20068a1e",
"sha256:e72805c5ca21af8edf5f6d539da83e62c083436b1a58d00a394b2bf0b6be4a87"
]
}
}

3.4. 分层的影响?

先说结论:根据你的业务需要,选择合适的层数来构建docker镜像。

docker分层其中之一的目的,就是让构建镜像的时候能更多的用上缓存。假设构建容器A和容器B中有相同的操作,会构建出一个相同的镜像层,那么这个镜像层只需要存储一次就行了,而且下次执行相同的构建操作时,也可以直接使用这个缓存!容器运行的时候,这些镜像的只读层同样可以共享,节省了占用的空间。

所以,适当的加多RUN的层数,且将相同的构建命令放在同一个RUN中,是可以方便多个容器的构建的。因为单层RUN的缓存能被下一次相同的构建直接用上,构建效率提高!

同理,在pull拉取远程镜像的时候,如果某一层在本地已有了,也能直接使用本地已有缓存,避免重复拉取。这就好比APP的“增量更新”功能,镜像那么大,如果每一次都得全量下载,网络不好的时候就有的一等了。只拉取有变化的层数,能大大提高效率。

但是,如果层数太多,新增了太多的小层,那么每一个小层的变化都会使后续构建的缓存失效!具体场景也需要具体的考量!

3.5. 分层缓存测试

3.5.1. 拉取镜像缓存命中

当我们pull一个redis的6.2版本时,因为之前没有pull过,需要全量下载。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ docker pull redis:6.2                 
6.2: Pulling from library/redis
b0a0cf830b12: Pull complete
57ad18570194: Pull complete
056356a7a403: Pull complete
c3351a5ba2a8: Pull complete
1042efef0b42: Pull complete
9f26115a8134: Pull complete
4f4fb700ef54: Pull complete
68d2d706a2f6: Pull complete
Digest: sha256:d4948d011cc38e94f0aafb8f9a60309bd93034e07d10e0767af534512cf012a9
Status: Downloaded newer image for redis:6.2
docker.io/library/redis:6.2

但当我们pull一个redis的7.0版本时,就不需要全量下载了,因为有一层和6.2版本是一致的,会显示Already exists,使用了本地缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ docker pull redis:7.0
7.0: Pulling from library/redis
b0a0cf830b12: Already exists
ea9699b63e68: Pull complete
bf380b81aa96: Pull complete
0164b64ea927: Pull complete
e06189a3bd9d: Pull complete
81fd2c0406f8: Pull complete
4f4fb700ef54: Pull complete
e3a29842ec15: Pull complete
Digest: sha256:084f7275d9a3abc11d9f8905c3377e61e1464880af941b1eb68b8605863000e4
Status: Downloaded newer image for redis:7.0
docker.io/library/redis:7.0

3.5.2. 构建缓存命中

先来看看构建缓存给命中的例子,来个dockerfile3,这里我们前几个命令都和上文构建myub:2使用的命令相同,但是最终安装的软件不同。

1
2
3
4
5
6
7
8
9
10
11
12
# 构建myub:3
FROM ubuntu:22.04
# 在build-essential中会下载tzdata,会交互式的让用户选择时区
# 所以需要设置apt为非交互模式
ARG DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Shanghai
# 更新软件源
RUN apt-get -y update
RUN apt-get -y upgrade
# 安装一些常用工具
RUN apt-get install -y \
python3 vim git cmake net-tools sqlite3

在build的输出中可以看到,前两层apt-get -y updateapt-get -y upgrade直接命中了本地已有的缓存,会有一个CACHED的输出,代表此时无需再次构建!

image.png

不过,apt-get update/upgrade这两个命令的结果是会随着ubuntu系统软件源的更新而进一步变化的。如果想获取最新的软件源,在docker build的时候,可以选择不使用本地的docker缓存,来构建最新的镜像。

1
docker build --no-cache . -t imageName 

3.5.3. 构建缓存失效

再来看看什么时候缓存会失效。首先,如果将这里的apt-get命令合并,那么就无法使用本地的缓存了,还是需要重新拉取ubuntu的软件源,执行构建过程。

1
2
3
4
5
6
7
8
9
10
11
# 构建myub:4
FROM ubuntu:22.04
# 在build-essential中会下载tzdata,会交互式的让用户选择时区
# 所以需要设置apt为非交互模式
ARG DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Shanghai
# 更新软件源
RUN apt-get -y update && \
apt-get -y upgrade && \
apt-get install -y \
python3 vim git cmake net-tools sqlite3

image.png

而在原本构建myub:2的过程中,我们有很多的小层,这些小层的变化,同样会让这一层和下层的缓存都失效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 构建myub:5
FROM ubuntu:22.04
# 在build-essential中会下载tzdata,会交互式的让用户选择时区
# 所以需要设置apt为非交互模式
ARG DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Shanghai
# 更新软件源
RUN apt-get -y update
RUN apt-get -y upgrade
# 安装一些常用工具(这个小层和myub:2不同,多安装了sqlite3)
RUN apt-get install -y \
cmake git vim curl wget \
net-tools openssh-server sqlite3
# 安装依赖
RUN apt-get install -y build-essential
# 安装python
RUN apt-get install -y python3

可以看到,因为RUN命令中多安装了sqlite3,不光这一层需要重新构建,后面没有变动的两层同样需要重新构建,大大增加了构建耗时。

image.png

image.png

如果我们想新增一个sqlite3包,直接在最后添加一个新的命令,反而可以用上原本的缓存,飞速构建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 构建myub:6
FROM ubuntu:22.04
# 在build-essential中会下载tzdata,会交互式的让用户选择时区
# 所以需要设置apt为非交互模式
ARG DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Shanghai
# 更新软件源
RUN apt-get -y update
RUN apt-get -y upgrade
# 安装一些常用工具
RUN apt-get install -y \
cmake git vim curl wget \
net-tools openssh-server
# 安装依赖
RUN apt-get install -y build-essential
# 安装python
RUN apt-get install -y python3
# 额外安装sqlite3
RUN apt-get install -y sqlite3

反应到结果上,就是前几层直接命中缓存,只有最后一个安装sqlite3的命令需要进行运行构建。

image.png

4.如何减少docker构建镜像的大小?

参考:Dockerfile最小化构建镜像:减少层数、清理无用数据、多段构建