1、闲聊篇

看到文章标题,有一定云原生相关技术经验的读者可能会说,都2021年了,怎么还在写Docker相关的文章?

的确如此,现如今容器引擎Docker的替代品有很多。加上用了k8s之后,大家对Docker的关注或许没有那么多了,很多场景下为了方便也没有自己做镜像的强烈需求,乃拿来主义~

k8s也在新版本中说 “不再支持” Docker,详情可以参考Don't Panic: Kubernetes and Docker

这个话题也的确被不少标题党炒作了一番,实际上k8s仅仅是放弃其对dockershim组件的支持,更推荐的k8s运行时是兼容CRIcontainerd之类的底层运行时

因此,Docker作为我从大学开始接触云原生相关领域的第一个开源工具,同时也作为容器技术和云原生领域中家喻户晓的名词。除去其经营线路外,不得不说,作为Docker技术本身还是值得肯定的,因此我觉得还是有必要再提一下的

根据个人经验和官方文档阅读,之前整理写过Jenkins Pipeline语法概要,现在来写一篇用于制作Docker镜像的Dockerfile语法概要,部分内容翻译自官方文档Dockerfile reference

2、指令篇

Dockerfile可以认为是一个脚本,包含如何构建Docker镜像的说明。实际上,这些指令是一组在Docker环境中自动执行的命令,以构建特定的Docker镜像

2.1 FROM

Docker镜像有着分层的概念,因此制作任何一个Docker镜像都需要有一个基础镜像,FROM用于指定基础镜像,语法为

FROM <image>:<tag>

其中基础镜像的tag可以不指定,即默认使用latest,在制作时尽量要使用官方的镜像作为基础镜像,如果想制定一个小型轻量的镜像,基础镜像可以选择Alpine

另外需要提到的是,这里说了任何镜像都需要有一个基础镜像,那么问题来了,就好比是先有鸡还是先有蛋的问题,基础镜像的“祖宗”是什么呢?能不能在构建时不以任何镜像为基础呢?答案是肯定的,可以选用scratch,具体我就不展开了,可以参考:baseimages

FROM scratch

2.2 LABEL

LABEL一般用来添加镜像的 “元数据” ,没有实际作用。常用于声明镜像作者,licensce等信息,写法为<key>=<value>,语法为

LABEL "com.example.vendor"="ACME Incorporated"
LABEL com.example.label-with-value="foo"
LABEL version="1.0"
LABEL description="This text illustrates \
that label-values can span multiple lines."

在镜像构建后并成功运行容器,可以通过inspect查看

# docker image inspect --format='' myimage
{
  "com.example.vendor": "ACME Incorporated",
  "com.example.label-with-value": "foo",
  "version": "1.0",
  "description": "This text illustrates that label-values can span multiple lines."
}

如果要声明作者,语法为

LABEL maintainer="SvenDowideit@home.org.au"

之前直接通过指令MAINTAINER指定,这种写法已经废弃掉了

2.3 ENV

ENV用来设置环境变量,一旦环境变量设置,就可以在Dockerfile后面的内容及容器运行后的应用中获取使用这个环境变量,ENV的写法也是<key>=<value>,语法为

ENV MY_NAME="John Doe"
ENV MY_DOG=Rex\ The\ Dog
ENV MY_CAT=fluffy

2.4 COPY和ADD

COPYADD都是用于在构建时往镜像中复制文件或目录的,并且两者都支持在复制时修改文件或目录的属主和属组,语法为

ADD [--chown=<user>:<group>] <src>... <dest>
ADD [--chown=<user>:<group>] ["<src>",... "<dest>"]
COPY [--chown=<user>:<group>] <src>... <dest>
COPY [--chown=<user>:<group>] ["<src>",... "<dest>"]

两者的使用差不多,但ADD功能更丰富

  • 支持URL

    例如源路径是文件的URL链接,构建时自动进行下载,下载后放到目标路径下,文件权限为600

  • 压缩包自动解压

    例如targzipbzip2xz格式的压缩包,ADD指令将会自动解压缩这个压缩文件到目标路径去

2.5 ARG

ARG设置的是构建时的参数,也可以理解为构建时的环境变量,与ENV的不同是只在构建时生效,生成的镜像中是不存在的

可以在ARG中同时声明参数名和参数值

也可以只声明参数名,在构建时通过–build-arg<参数名>=<值>的形式来赋值,赋值的前提是在Dockerfile中进行了声明,否则会出现警告

语法为

ARG <name>[=<default value>]

2.6 RUN

RUN指令表示在当前的镜像层之上的新层中执行命令并将结果提交,用户Dockerfile的下一步操作,语法为

RUN /bin/bash -c 'source $HOME/.bashrc; \
RUN mkdir /test

RUN命令中也可以使用exec格式来避免shell字符串损坏,语法为

RUN ["/bin/bash", "-c", "echo hello"]

RUN作为Dockerfile中最为常用的指令,在使用时有以下建议:

  • RUN指令执行过程中,产生的中间镜像会被当做缓存在下一次构建时使用,如果不想使用缓存,使其失效,可以在build时添加--no-cache

  • 尽量把所有的RUN指令写到一起,如果是多条shell命令,可以不用每条命令都添加RUN,更好的做法是通过\换行,通过&&连接多个指令,这样对构建生成的镜像的大小优化是很有帮助的,语法为

    RUN set -x && \
        yum install -y epel-release \
        make \
        gcc \
        gcc-c++
    

2.7 WORKDIR

WORKDIR指令为Dockerfile中的任何RUNCMDENTRYPOINTCOPYADD指令设置工作目录。如果工作目录不存在,即使它没有在后续的Dockerfile指令中使用,它也会被创建

WORKDIR指令可以在Dockerfile中使用多次。如果提供了一个相对路径,它将相对于前一个WORKDIR指令的路径,语法为

WORKDIR /a
WORKDIR b
WORKDIR c
RUN pwd

这个Dockerfile中的最后一个pwd命令的输出是/a/b/c

WORKDIR指令也可以解析之前使用ENV设置的环境变量,只能使用在Dockerfile中显式设置的环境变量,语法为

ENV DIRPATH=/path
WORKDIR $DIRPATH/$DIRNAME
RUN pwd

这里的最终路径是/path/$DIRNAME

2.8 EXPOSE

EXPOSE指令声明了容器在运行时监听指定的网络端口,可以指定端口是监听TCP还是UDP,默认为TCP

EXPOSE指令实际上并不发布端口,即端口限制,它的作用仅仅是作为构建映像的人和运行容器的人之间的一种文档,关于要发布哪些端口。当运行容器时,要实际发布端口,使用docker运行中的-p参数来发布和映射一个或多个端口,或者直接使用-P来自动随机映射EXPOSE声明的端口

语法为

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

2.9 CMD和ENTRYPOINT

这个话题稍微复杂,实际上用的场景也不会有这么多

  • CMD

CMDENTRYPOINT都是指定容器将如何运行

CMD的主要目的是为执行容器提供默认值。这些默认值可以包括可执行文件,也可以省略可执行文件,在这种情况下,必须指定一个ENTRYPOINT指令

CMD指令有三种形式

CMD ["executable","param1","param2"] # 首选的exec格式
CMD ["param1","param2"] # 只传递参数,作为ENTRYPOINT的默认参数
CMD command param1 param2 (shell form)  # shell的形式

Dockerfile中只能有一条CMD指令,如果指定了多条,只有最后一条会生效

  • ENTRYPOINT

ENTRYPOINT有两种形式

ENTRYPOINT ["executable", "param1", "param2"]  # exec格式
ENTRYPOINT command param1 param2  # shell形式

如果指定了ENTRYPOINT,则CMD将只是提供参数,传递给ENTRYPOINT

docker run <image>的命令行参数将被附加在exec类型的ENTRYPOINT的所有元素之后,并将覆盖使用CMD指定的所有元素。这允许参数被传递给ENTRYPOINT

例如,docker run <image> -d将传递-d参数给ENTRYPOINT

也可以使用docker run --entrypoint覆盖ENTRYPOINT指令

  • CMD和ENTRYPOINT组合出现

官方有一段关于CMDENTRYPOINT组合出现时的结果

https://docs.docker.com/engine/reference/builder/#understand-how-cmd-and-entrypoint-interact

没有 ENTRYPOINT ENTRYPOINT exec_entry p1_entry ENTRYPOINT [“exec_entry”, “p1_entry”]
没有 CMD 报错 /bin/sh -c exec_entry p1_entry exec_entry p1_entry
CMD [“exec_cmd”, “p1_cmd”] exec_cmd p1_cmd /bin/sh -c exec_entry p1_entry exec_entry p1_entry exec_cmd p1_cmd
CMD [“p1_cmd”, “p2_cmd”] p1_cmd p2_cmd /bin/sh -c exec_entry p1_entry exec_entry p1_entry p1_cmd p2_cmd
CMD exec_cmd p1_cmd /bin/sh -c exec_cmd p1_cmd /bin/sh -c exec_entry p1_entry exec_entry p1_entry /bin/sh -c exec_cmd p1_cmd
  • 在k8s中的场景

k8s中可以通过资源清单的commandargs也可以为Pod指定一些运行参数,四者组合出现时的最终结果如下

https://kubernetes.io/zh/docs/tasks/inject-data-application/define-command-argument-container/#notes

如果要覆盖默认的EntrypointCmd,需要遵循如下规则:

  • 如果在容器配置中没有设置 command 或者 args,那么将使用Docker镜像自带的命令及其参数
  • 如果在容器配置中只设置了 command 但是没有设置 args,那么容器启动时只会执行该命令, Docker镜像中自带的命令及其参数会被忽略
  • 如果在容器配置中只设置了 args,那么Docker镜像中自带的命令会使用该新参数作为其执行时的参数
  • 如果在容器配置中同时设置了 commandargs,那么Docker镜像中自带的命令及其参数会被忽略。 容器启动时只会执行配置中设置的命令,并使用配置中设置的参数作为命令的参数
镜像 Entrypoint 镜像 Cmd 容器 command 容器 args 命令执行
[/ep-1] [foo bar] [ep-1 foo bar]
[/ep-1] [foo bar] [/ep-2] [ep-2]
[/ep-1] [foo bar] [zoo boo] [ep-1 zoo boo]
[/ep-1] [foo bar] [/ep-2] [zoo boo] [ep-2 zoo boo]

2.10 HEALTHCHECK

HEALTHCHECK用于检查容器的健康状态,Docker可通过健康状态来决定是否对容器进行重新调度

语法为

HEALTHCHECK [选项] CMD <命令>

可选项为

–interval=<间隔> :两次健康检查的间隔,默认为30秒
–timeout=<时长> :执行健康检查命令的超时时间,如果超时,则本次健康检查就被视为失败,默认30秒
–retries=<次数> :当连续失败指定的次数后,将容器状态置为unhealthy ,默认3次

对一些可能造成假死情况的服务建议提供健康检查,以便及时重新调度恢复服务

如果基础镜像有健康检查指令,想要屏蔽掉其健康检查,可以使用HEALTHCHECK NONE

实际上有了k8s更为丰富的健康检查探针之后,Docker自带的健康检查就不用了

2.11 ONBUILD

当我们在一个Dockerfile文件中加上ONBUILD指令,该指令对利用该Dockerfile构建的镜像不会产生实质性影响

但是当我们编写一个新的Dockerfile文件来基于上面通过包含ONBUILD构建的基础镜像构建一个新镜像时,这时构造基础镜像的Dockerfile文件中的ONBUILD指令就生效了,在构建新镜像的过程中,首先会执行ONBUILD指令指定的指令,然后才会执行其它指令

要注意的是ONBUILD仅仅能 ‘子代遗传’ ,并不能 ‘隔代遗传’ ,即传递到 ‘孙子镜像’

3、镜像构建篇

3.1 构建上下文

构建上下文build context,“上下文” 意为和现在这个工作相关的周围环境。在docker镜像的构建过程中有构建上下文build context这一概念,通俗的来说就是指执行docker build时当前的工作目录,不管构建时有没有用到当前目录下的某些文件及目录,默认情况下这个上下文中的文件及目录都会作为构建上下文内容发送给Docker Daemon

docker build开始执行时,控制台会输出Sending build context to Docker daemon xxxMB,这就表示将当前工作目录下的文件及目录都作为了构建上下文

前面提到可以在RUN指令中添加--no-cache不使用缓存,同样也可以在执行docker build命令时添加该指令以在镜像构建时不使用缓存

3.2 忽略构建

git忽略文件.gitignore一样的道理,在docker构建镜像时也有.dockerignore,可以用来排除当前工作目录下不需要加入到构建上下文build context中的文件

例如,在构建npm前端的镜像时项目时,在 Dockerfile 的同一个文件夹中创建一个 .dockerignore 文件,带有以下内容,这样在构建时就可以避免将本地模块以及调试日志被拷贝进入到Docker镜像中

node_modules
npm-debug.log

3.3 多阶段构建

多阶段构建的应用场景及优势就是为了降低复杂性并减少依赖,避免镜像包含不必要的软件包

例如,应用程序的镜像中一般不需要安装开发调试软件包。如果需要从源码编译构建应用,最好的方式就是使用多阶段构建

简单来说,多阶段构建就是允许一个Dockerfile中出现多条FROM指令,只有最后一条FROM指令中指定的基础镜像作为本次构建镜像的基础镜像,其它的阶段都可以认为是只为中间步骤

每一条FROM指令都表示着多阶段构建过程中的一个构建阶段,后面的构建阶段可以拷贝利用前面构建阶段的产物

这里我列举一个编译构建npm项目,利用多阶段构建最终把静态资源制作成nginx镜像的Dockerfile

#### Stage 1: npm build
FROM node:12.4.0-alpine as build

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm install

# Copy the main application
COPY . ./

# Arguments

# Build the application
RUN npm run build

#### Stage 2: Serve the application from Nginx 
FROM nginx:latest

COPY --from=build /app/build /var/www

# Copy our custom nginx config
COPY nginx.conf /etc/nginx/nginx.conf

# Expose port 3000 to the Docker host, so we can access it 
# from the outside.
EXPOSE 80

ENTRYPOINT ["nginx","-g","daemon off;"]

4、小结

本文总结以及整理了常用的Dockerfile指令以及在构建镜像时的一些经验,当然也还有一些指令没有提及,更多内容可以参考官方文档

See you ~