利用 Commit 制作镜像

在容器管理章节有提到使用现有的容器制作镜像,但是这在真正的工作中明显是不合适的。使用docker commit意味着所有对镜像的操作都是黑箱操作,生成的镜像被称为黑箱镜像

换句话说,除了制作镜像的人知道执行过什么命令、怎么生成的镜像,别人根本无从得知。即使是制作人,过段时间后也无法记清具体的操作。这种镜像维护起来非常痛苦。

而且,由于镜像分层存储的特点,除当前层外,之前的每一层都是不会发生改变的,这意味着任何修改的结果仅仅是在当前层进行标记、添加、修改,而不会改动上一层。即使是删除,上一层的东西也并不会丢失。镜像会越来越臃肿。

同时还有另外一个问题,容器中某些进程可能是动态的,过段时间会退出。如果使用 commit 制作镜像,这些进程可能不会保存到镜像中。

利用 Dockerfile 制作镜像

镜像的定制实际上就是定制每一层所添加的配置、文件。如果能把每一层的修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,那之前 Commit 的无法重复、镜像构建透明性、体积的问题就都会解决。这个脚本就是Dockerfile

Dockerfile 是一个文本文件,其内包含了一条条的指令(Instruction),每一条指令构建一层。

以定制一个 nginx 镜像为例:

1
2
3
4
5
6
# 创建目录专门用于存放相关的 Dockerfile
mkdir -p nginx/v1.0

# 创建 Dockerfile
cd nginx/v1.0/
vim Dockerfile

Dockerfile 内容如下:

1
2
FROM nginx:latest
RUN echo "<h1>Hello Docker</h1>" > /usr/share/nginx/html/index.html

这就是一个最简单的 Dockerfile,以 nginx 镜像为基础镜像,然后修改镜像中的 html 文件,形成新的镜像。

上面提到了基础镜像,我们可以以任何镜像为基础镜像,在它的上面进行操作形成新的镜像,Docker 官方也提供了很多精简的基础镜像,可以拿来直接使用。

在编辑好 Dockerfile 之后,就进入镜像制作的步骤:

1
docker build -t trover/nginx:v1.0 .

注意最后那个.不能掉。

可以发现,镜像构建的时候有两层,一层是FROM,一层是RUN。这也印证了上面说的每一个关键字就是一层。

镜像构建上下文

关于 build 命令最后那个.,其实不是指 Dockerfile 的路径,而是指定上下文路径

想要理解上下文,首先要理解docker build的工作原理:

Docker 的运行分为服务端 Docker 引擎(守护进程)和客户端工具。服务端 Docker 引擎提供了一组 REST API,称为Docker Remote API。docker 命令这样的客户端工具就是通过这组 API 与 Docker 引擎交互,从而完成各种功能。

虽然表面上是在本机执行的各种 docker 功能,但实际上一切都是使用的远程调用形式在服务端(Docker 引擎)完成。也因为这种 C/S 设计,让操作远程服务器的 Docker 引擎变得轻而易举。

在进行镜像构建时,并非所有定制都会通过RUN指令完成,经常会需要将一些本地文件复制进镜像,比如通过COPY指令、ADD指令等。而 build 命令构建镜像,其实并非在本地构建,而是在服务端,也就是 Docker 引擎中构建的。那么在这种客户端/服务端的架构中,如何才能让服务端获得本地文件呢?

这就引入了上下文的概念。当构建的时候,用户会指定构建镜像上下文的路径,build 命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎收到这个上下文包后,展开就会获得构建镜像所需的一切文件。

以 Dockerfile 为例:

1
COPY ./package.json /app/

该指定并不是复制当前目录的 package.json 文件,也不是 Dockerfile 所在目录的 package.json 文件,而是上下文目录中的 package.json。

因此,COPY 这类指令中的源文件的路径都是相对路径。这也是为何COPY ../package.json /app或者COPY /opt/xxxx /app无法工作的原因,因为这些路径已经超出了上下文的范围,Docker 引擎无法获得这些位置的文件。如果真的需要那些文件,应该将它们复制到上下文目录中去。

通过 build 命令输出的第一行也可以看到发送上下文的过程:

Sending build context to Docker daemon 2.048 kB

所以,制作 Dockerfile 的目录尽量满足以下需求:

  • 一个空目录。
  • 将所有需要的文件都复制一份到当前目录。
  • 如果目录下有文件不需要被发送到 Docker 引擎,可以创建一个类似.gitignore一样语法的文件.dockerignore
    默认情况下,如果不特殊指定 Dockerfile 路径,默认会将上下文路径中的名为 Dockerfile 的文件作为 Dockerfile。

Dockerfile 也并非文件就要叫 Dockerfile,可以通过 -f 指定其它文件,但是一般不这样做。

Dockerfile(FROM)

FROM 指令是 Dockerfile 最基础的关键字,用于指定基础镜像。格式为:FROM <基础镜像>

如果你自己想做一个基础镜像,则可以使用FROM scratch,scratch 镜像是 Docker 官方提供的一个虚拟空白镜像。

基础镜像意味着不以任何系统为基础,直接将可执行文件复制到镜像中,该文件包含了运行所需的所有库。这样镜像会更小,非常适合 Go 语言开发的程序。

Dockerfile 示例:

1
2
3
4
5
# 基本使用
FROM centos:latest

# 基础镜像
FROM scratch

Dockerfile(LABEL)

LABEL 指令用来给镜像以键值对的形式添加一些元数据(metadata),在旧版本中叫 MATAINER。格式为:LABEL <key>=<value> <key>=<value> <key>=<value> ...

通过 LABEL 标签能够让别人更清楚你这镜像。

Dockerfile 示例:

1
2
3
LABEL name="Trover" \
email="[email protected]"
desc="Dockerfile demo"

Dockerfile(RUN)

RUN 指令用来执行命令行命令。由于命令行的强大能力,RUN指令在定制镜像时是最常用的指令之一。其格式有两种:

  • shell 格式:RUN 命令
  • exec 格式:RUN ["可执行文件", "参数1", "参数2" ...]
    由于 Dockerfile 中每个关键字就是一层,Union FS 会有最大层数限制,以 AUFS 为例,最大支持 127 层。为了减少镜像的层数,一般使用&&连接多个命令,\进行换行,提高阅读性。

Dockerfile 示例:

1
2
3
RUN cd / && yum -y install nginx \
&& cd /etc/nginx \
&& rm -f nginx.conf

Dockerfile(WORKDIR)

WORKDIR 指令用于指定工作目录(或称当前目录),以后各层的当前目录就被改为指定的目录,如果该目录不存在,WORKDIR 会自动创建。格式为:WORKDIR <工作目录路径>

WORKDIR 目录 不等于 RUN cd 目录,前者会对后面的所有镜像层产生影响,后者只对当前层,对于下一层并不会 cd 进目录。

Dockerfile 示例:

1
2
3
4
5
WORKDIR /a
WORKDIR b
WORKDIR c
RUN pwd
# 最后输出目录为 /a/b/c

Dockerfile(COPY)

COPY 指令用于将构建上下文目录中的源文件或目录拷贝到镜像中的指定目录,支持两种格式。

  • COPY [--chown=<user>:<group>] <源路径>... <目标路径>
  • COPY [--chown=<user>:<group>] ["<源路径1>",... "<目标路径>"]
    源路径可以是多个,甚至使用通配符。目标路径可以是容器中绝对路径,也可以是 WORKDIR 的相对路径。目标路径不存在会自动创建。

COPY 指令能保留文件的各种元数据,比如创建时间,读写权限等。如果源文件为目录,其实质是将目录中的文件拷贝到镜像中的新目录中。

Dockerfile 示例:

1
2
3
4
5
6
7
8
# 拷贝文件
COPY package.json /usr/src/app/

# 拷贝多个文件
COPY package* /usr/src/app/

# 拷贝并修改权限
COPY --chown=myuser:mygroup package* /usr/src/app/

Dockerfile(ADD)

ADD 指令和 COPY 类似,但不推荐使用,因为没有 COPY 定义明确。格式为:ADD [--chown=<user>:<group>] <源路径>... <目标路径>

ADD 源路径支持 URL,Docker 会去指定的 URL 下载文件,并保存成 600 权限,如果 URL 是压缩也文件不会解压。所以这种需求使用 RUN 指令更好。

如果本地源文件是 tar,gzip,bzip2,xz 等格式的压缩文件,ADD 会自动解压。但如果只想复制一个压缩文件进去,则不能使用 ADD 指令。

Dockerfile 示例:

1
ADD ubuntu.tar.gz /app/

Dockerfile(ENV)

ENV 指令用于设置环境变量,在后面的指令中可以直接使用${变量名}的方式引用,容器中也可以看到。支持两种格式。

  • ENV <key> <value>
  • ENV <key1>=<value1> <key2>=<value2>...
    通过环境变量的定义能够使 Dockerfile 更加灵活。

Dockerfile 示例:

1
2
3
4
ENV VERSION="1.0" \
NAME="Dy1an"

RUN echo $VERSION

Dockerfile(ARG)

ARG 指令功能和 ENV 类似,用于定义构建参数。不同在于 ARG 设置的是构建环境的环境变量,在容器运行时这些变量不会存在。格式:ARG <参数名>[=<默认值>]

该默认值可以在docker build中使用--build-arg <参数名>=<值>来覆盖,起到传参构建的目的。

ARG 指令有作用范围,如果是在 FROM 之前使用,则只能 FROM 指令中使用该变量。想要继续使用就得重新定义。

Dockerfile 示例:

1
2
ARG DOCKER_USERNAME=library
FROM ${DOCKER_USERNAME}/alpine

Dockerfile(USER)

USER 指令指定当前用户和用户组,影响范围和 WORKDIR 类似。格式:USER <用户名>[:<用户组>]

USER 指令只是切换用户,所以这个用户必须先创建好。

如果脚本是 root 运行,但是容器中启动服务需要使用其他用户,建议下载gosu代替原本的 su 或者 sudo,可以避免很多问题。

Dockerfile 示例:

1
2
3
4
5
6
7
8
9
RUN useradd -r -g nginx

# 下载 gosu
RUN wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.12/gosu-amd64" \
&& chmod +x /usr/local/bin/gosu \
&& gosu nginx true

# 设置 CMD,并以另外的用户执行
CMD [ "exec", "gosu", "nginx" ]

Dockerfile(EXPOSE)

EXPOSE 指令用于声明容器运行时提供服务的端口,只是声明,在容器运行时并不会因为这个声明应用就会开启这个端口的服务。格式为 EXPOSE <端口1> [<端口2>...]

在 Dockerfile 中写入这样的声明有两个好处:

帮助镜像使用者理解这个镜像服务的守护端口,以方便配置映射。
运行时使用随机端口映射时,也就是docker run -P时,会自动随机映射EXPOSE的端口。
Dockerfile 示例:

1
EXPOSE 8080

Dockerfile(CMD)

CMD 指令用于指定容器默认的主进程启动命令。和 RUN 类似,支持两种格式:

  • shell 格式:CMD <命令>
  • exec 格式:CMD ["可执行文件", "参数1", "参数2"...]
    推荐使用 exec 格式,因为 shell 格式也会被 docker 转换成 exec 格式,比如:
1
2
3
CMD echo "Hello"
# 会被转换成
CMD ["sh", "-c", "echo 'Hello'"]

exec 格式在解析时会被解析为 JSON 数组,因此一定要使用双引号",而不要使用单引号。

在容器运行时,可以指定新的命令来替代镜像中设置的 CMD 默认命令。

比如:ubuntu 镜像默认的 CMD 是 /bin/bash,run -it 启动会直接进入容器的 bash。可以在运行时指定其它命令,如docker run -it ubuntu cat /etc/os-release的方式替换掉默认的/bin/bash命令。

注意:

docker 不是虚拟机,容器中的应用都应该以前台执行,不能用 systemd 去启动后台服务,容器内没有后台服务的概念。

如:

1
CMD service nginx start

这样的容器启动后立即退出,即使是进入容器内使用systemctl也一样。

对于容器而言,启动命令就是容器的应用进程,容器为主进程而存在,主进程退出,容器就失去了存在的意义,从而跟着退出,其它进程它不关心。

所以,使用service nginx start命令希望以后台守护进程形式启动 nginx。而 CMD 会被转换成CMD [ "sh", "-c", "service nginx start"]。此时主进程实际上是sh。那么当 service nginx start 命令结束后,sh 主进程也就结束了,容器自然就会跟着退出。

正确的做法是以前台的方式直接启动:

1
CMD ["nginx", "-g", "daemon off;"]

Dockerfile(ENTRYPOINT)

ENTRYPOINT 的目的和 CMD 一样,都是在指定容器启动程序及参数。它的启动命令也是可以被替换的,不过比 CMD 繁琐,需要通过 docker run 的参数 –entrypoint 来指定。和 RUN 一样,也是支持两种格式。

相较于 CMD,ENTRYPOINT 常用于解决以下问题:

1. 能够实现灵活的传参功能

一个简单的 Dockerfile:

1
2
3
# 该镜像用于输出当前公网 IP
FROM busybox
CMD [ "curl", "-s", "http://myip.ipip.net" ]

使用docker build -t myip. 生成 myip 镜像,然后使用 docker run myip 运行。这个 docker 就能变成一个类似于查询公网 IP 的命令。

但是如果此时需求变得复杂,需要在镜像的 curl 中加入-i参数输出请求头信息。

直接使用docker run myip -i会报错:executable file not found。原因在于镜像后面的参数会被当成命令替换掉 CMD 中的内容,而 CMD 第一个参数是可执行文件,-i 显然不是可执行文件,所以报错。

如果将 CMD 换成 ENTRYPOINT:

1
2
3
# 该镜像用于输出当前公网 IP
FROM busybox
ENTRYPOINT [ "curl", "-s", "http://myip.ipip.net" ]

此时再次执行 docker run myip -i 就能正常使用。原因在于,-i 参数会替换 CMD 中的内容,但是在定义了 ENTRYPOINT 的 Dockerfile 中,CMD 中的所有内容会成为参数传递给 ENTRYPOINT,这就实现了灵活参数的功能。

2. 通过判断不同的传参干不同的事情

生产中有这样一类场景,某些镜像的运行方案可能不一定,用户可以传递相关的参数修改容器的运行方式。由于在 ENTRYPOINT 加入了逻辑判断等操作,再写命令或者 exec 格式显然不适合,所以需要使用脚本的格式。 然后将 CMD 的内容作为参数传给它。

一个简单的 Dockerfile 示例:

1
2
3
4
5
6
7
8
9
10
FROM busybox

# 切换工作目录
WORKDIR /data

# 拷贝脚本到镜像中
COPY docker-entrypoint.sh /usr/local/bin/

# 执行脚本
ENTRYPOINT ["docker-entrypoint.sh"]

docker-entrypoint.sh:

1
2
3
4
5
6
7
#!/bin/sh

if [ "$1" = 'hello' ]; then
echo "Hello"
else
echo "World"
fi

Dockerfile(VOLUME)

VOLUME 指令用于挂在一个或多个存储卷,格式如下:

  • VOLUME ["<路径1>", "<路径2>"...]
  • VOLUME <路径>
    容器运行时应尽量保持容器存储层不发生写操作,但数据写入往往又是不可避免的。

为了防止运行时用户忘记将动态文件所保存的目录挂载为卷,可以在Dockerfile中事先指定某些目录挂载为匿名卷,这样在运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据。

Dockerfile 示例:

1
VOLUME /data

这里的/data目录就会在容器运行时自动挂载为匿名卷,任何向/data中写入的信息都不会记录进容器存储层,从而保证了容器存储层的无状态化。当然,运行容器时可以通过-v参数覆盖这个挂载设置。

后面会对存储卷进行详细的说明。

Dockerfile(HEALTHCHECK)

HEALTHCHECK 指令用于告诉 Docker 应该如何进行判断容器的状态是否正常,在 Docker 1.12 引入。支持以下格式:

  • HEALTHCHECK [选项] CMD <命令>:设置检查容器健康状况的命令
  • HEALTHCHECK NONE:如果基础镜像有健康检查指令,使用这行可以屏蔽掉其健康检查指令
    在没有 HEALTHCHECK 指令前,Docker 引擎只可以通过容器内主进程是否退出来判断容器是否状态异常。很多情况下这没问题,但是如果程序进入死锁或死循环状态,应用进程并不会退出,容器已经无法提供服务,却并不会被重新调度。HEALTHCHECK 指令的价值就在于能够比较真实的反应容器实际状态。

当在一个镜像指定了 HEALTHCHECK 后,用其启动容器,初始状态会为starting,在 HEALTHCHECK 检查成功后变为healthy,如果连续一定次数失败,则会变为unhealthy

HEALTHCHECK 支持下列选项:

  • --interval=<间隔>:两次健康检查的间隔,默认为 30 秒
  • --timeout=<时长>:健康检查运行超时时间,如果超过这个时间,本次健康检查就被视为失败,默认 30 秒
  • --retries=<次数>:当连续失败指定次数后,则将容器状态视为unhealthy,默认 3 次
    和 CMD, ENTRYPOINT 一样,HEALTHCHECK 只可以出现一次,如果写了多个,只有最后一个生效。

Dockerfile 示例:

1
HEALTHCHECK --interval=5s --timeout=3s CMD curl -fs http://localhost/ || exit 1

容器运行后,使用 docker container ls 就可以看到健康状态。使用 docker container inspect xxx 可以看到检测信息。

Dockerfile(SHELL)

SHELL 指令用于指定 RUN,ENTRYPOINT,CMD 指令的 shell,Linux 中默认为["/bin/sh", "-c"]。格式为:SHELL ["executable", "parameters"]

Dockerfile 示例:

1
SHELL ["/bin/sh", "-cex"]
# 命令最终会被解析为:/bin/sh -cex "nginx"
ENTRYPOINT nginx

Dockerfile(ONBUILD)

ONBUILD 是一个特殊的指令,它后面跟的是其它指令,比如 RUN, COPY 等。这些指令,在当前镜像构建时并不会被执行。只有当以当前镜像为基础镜像,去构建下一级镜像的时候才会被执行。

Dockerfile 示例:

1
2
3
4
5
6
7
FROM node:slim
RUN mkdir /app
WORKDIR /app
ONBUILD COPY ./package.json /app
ONBUILD RUN [ "npm", "install" ]
ONBUILD COPY . /app/
CMD [ "npm", "start" ]

Dockerfile 建议

在制作 Dockerfile 的时候,应该尽可能的遵守一些约定俗称的方法和建议:

  • 容器应该是短暂的
    • 通过 Dockerfile 构建的镜像生命周期不宜过长,容器从销毁到创建都应该将工作量将到最小。
  • 增加 .dockerigonre 文件
    • 每一个单独的项目都应该有一个单独的目录并创建一个 .dockerignore 文件用于忽略不需要的文件或目录,构建镜像所需的文件都应该存放到该目录下。
  • 避免不必要的文件,使用多阶段构建
    • 在 Dockerfile 的每一层定义中,在进入下一层之前,都需要删除掉其它不需要的文件,以此尽可能的减小镜像的体积。
    • 比如通过 Dockerfile 直接完成打包,运行,打包这一步除了打出来的包其它的文件其实都是没用的,此时就是和多阶段构建。
    • 示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 第一阶段,打包。使用 as 对阶段进行命名,每一个 FROM 就是一个阶段
FROM golang:1.7.3 as build
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

# 第二阶段,运行。
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# 从第一阶段中拷贝打好的包
COPY --from=build /go/src/github.com/alexellis/href-counter/app .
CMD ["./app"]
此时 build 镜像的话,镜像中就不会包含第一节点的内容,包也尽可能的减小了。
  • 一个容器只运行一个进程
    • 应该保证每个容器只有一个进程,多个进程解耦到不同容器中,便于后续的扩展。
  • 将多行参数排序
    • 某些参数太多太长需要换行的,尽可能按照字母顺序排序,这样可以避免重复。
  • 使用构建缓存
    • 在镜像的构建过程中,Docker 会遍历 Dockerfile 文件中的指令,然后按顺序执行。在执行每条指令之前,Docker 都会在缓存中查找是否已经存在可重用的镜像,如果有就使用现存的镜像,不再重复创建。如果你不想在构建过程中使用缓存,你可以在 docker build 命令中使用 –no-cache=true 选项。

完整的 Dockerfile 示例

以下是一些常见的 Dockerfile 示例:

  1. TOMCAT部署镜像:
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
# 基础就像
FROM centos

# 定义元数据
LABEL auhtor="Dy1an" \
email="[email protected]" \
desc="TOMCAT demo Dockerfile"

# 定义环境变量
ENV WORK_PATH=/ops \
ENV_PATH=${WORK_PATH}/env \
SERVICE_PATH=${WORK_PATH}/service

# 添加 JDK
ADD jdk-8u11-linux-x64.tar.gz ${ENV_PATH}/

# 添加 TOMCAT
ADD apache-tomcat-9.0.22.tar.gz ${SERVICE_PATH}/

# 拷贝文件
COPY ROOT.war ${SERVICE_PATH}/webapps/

# 进入目录
WORKDIR ${SERVICE_PATH}/webapps/

# 解压安装包
RUN unzip ROOT.war -d ROOT && rm -f ROOT.war

# 定义环境变量
ENV JAVA_HOME=${ENV_PATH}/jdk1.8.0_11 \
CLASSPATH $JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar \
CATALINA_HOME=${SERVICE_PATH}/apache-tomcat-9.0.22 \
PATH $PATH:$JAVA_HOME/bin:$CATALINA_HOME/lib:$CATALINA_HOME/bin

# 声明端口
EXPOSE 8080

# 启动项目
CMD ${CATALINA_HOME}/bin/startup.sh && tail -f ${CATALINA_HOME}/logs/catalina.out
  1. 我参与的一个github项目 beancount-gs
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
ARG BEANCOUNT_VERSION=2.3.5
ARG GOLANG_VERSION=1.17.3

FROM golang:${GOLANG_VERSION} AS go_build_env

ENV GO111MODULE=on \
GOPROXY=https://goproxy.cn,direct \
GIN_MODE=release \
CGO_ENABLED=0 \
PORT=80

WORKDIR /tmp/build
RUN git clone https://github.com/BaoXuebin/beancount-gs.git

WORKDIR /tmp/build/beancount-gs
RUN mkdir -p public/default_icons && cp -rn public/icons/* public/default_icons

RUN go build .

FROM python:latest as build_env
ARG BEANCOUNT_VERSION

ENV PATH "/app/bin:$PATH"
RUN python3 -mvenv /app

WORKDIR /tmp/build
RUN git clone https://github.com/beancount/beancount

WORKDIR /tmp/build/beancount
RUN git checkout ${BEANCOUNT_VERSION}

RUN CFLAGS=-s pip3 install -U /tmp/build/beancount

RUN pip3 uninstall -y pip

RUN find /app -name __pycache__ -exec rm -rf -v {} +

FROM python:3.10-alpine

COPY --from=build_env /app /app

WORKDIR /app
COPY --from=go_build_env /tmp/build/beancount-gs /app
RUN cp -rn /app/public/default_icons/* /app/public/icons

ENV PATH "/app/bin:$PATH"

EXPOSE 80

CMD ["/app/beancount-gs", "-p", "80"]