Dokcer学习之旅(2)——Dockerfile基础应用
阅读原文时间:2023年08月13日阅读:5

什么是Dockerfile?

从docker commit 的学习中,我们可以了解到,镜像的定制实际上就是定制每一层所添加的配置、文件。如果我们可以把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,那么之前提及的无法重复的问题、镜像构建透明性的问题、体积的问题就都会解决。这个脚本就是 Dockerfile。
简短来说,Dockerfile 是一个用来构建镜像的文本文件,文本内容包含了一条条构建镜像所需的指令和说明。

构建一个基础镜像

首先,创建一个Dockerfile文件

mkdir mynginx
cd mynginx
touch Dockerfile


FROM nginx
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html

以上代码涉及到最基础的两条命令,RUNFROM

FROM很好理解,定制的镜像都是基于 FROM 的镜像,这里的 nginx 就是定制需要的基础镜像。后续的操作都是基于 nginx。除了选择现有镜像为基础镜像外,Docker 还存在一个特殊的镜像,名为 scratch。这个镜像是虚拟的概念,并不实际存在,它表示一个空白的镜像。
所以,当你什么都不需要时,你可以
FROM scratch

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

  • dockerfile 格式:RUN <命令>,就像直接在命令行中输入的命令一样。刚才写的 Dockerfile 中的 RUN 指令就是这种格式。

    `RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html`
  • exec 格式:RUN ["可执行文件", "参数1", "参数2"],这更像是函数调用中的格式。

既然 RUN 就像 dockerfile 脚本一样可以执行命令,那么我们是否就可以像 dockerfile 脚本一样把每个命令对应一个 RUN 呢?
答案是不行的,Dockerfile 中每一个指令都会新建立一层,当 Docker 镜像的层数增多时,可能会导致以下几个问题:

  • 镜像体积增大:每个层都会占据一定的存储空间,因此层数增多会导致镜像的总体积增大。这可能会占用更多的磁盘空间,并且在网络传输中需要更多的时间。
  • 构建时间增加:每个层都需要进行构建和提交,当层数较多时,构建时间会相应增加。每个层都需要在前一层的基础上进行修改和重建,这可能会增加整个构建过程的耗时。
  • 镜像传输时间延长:如果你需要将镜像从一个地方传输到另一个地方,例如从开发环境上传到生产环境,镜像的大小和层数都会对传输时间产生影响。
  • 镜像维护复杂度增加:随着层数的增加,镜像的维护复杂度也会增加。每个层都需要管理和更新,当需要修改某个组件时,可能需要修改多个层,而不仅仅是一个。

反例:

FROM debian:stretch

RUN apt-get update
RUN apt-get install -y gcc libc6-dev make wget
RUN wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz"
RUN mkdir -p /usr/src/redis
RUN tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1
RUN make -C /usr/src/redis
RUN make -C /usr/src/redis install

正例:

FROM debian:stretch

RUN set -x; buildDeps='gcc libc6-dev make wget' \
    && apt-get update \
    && apt-get install -y $buildDeps \
    && wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz" \
    && mkdir -p /usr/src/redis \
    && tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \
    && make -C /usr/src/redis \
    && make -C /usr/src/redis install \
    && rm -rf /var/lib/apt/lists/* \
    && rm redis.tar.gz \
    && rm -r /usr/src/redis \
    && apt-get purge -y --auto-remove $buildDeps

所有的命令只有一个目的,就是编译、安装 redis 可执行文件。因此没有必要建立很多层,这只是一层的事情。 这一组命令的最后添加了清理工作的命令,删除了为了编译构建所需要的软件,清理了所有下载、展开的文件,并且还清理了 apt 缓存文件。反正只要想着能优化镜像体积就优化掉咯。

回到我们最开始的Dockerfile,在Dockerfile目录下,我们执行docker build [选项] <上下文路径/URL/->命令,上下文路径,是指 docker 在构建镜像,有时候想要使用到本机的文件(比如复制),docker build 命令得知这个路径后,会将路径下的所有内容打包。
注意:上下文路径下不要放无用的文件,因为会一起打包发送给 docker 引擎,如果文件过多会造成过程缓慢。

root@VM-24-8-debian:/home/lighthouse/mynginx# docker build -t nginx:v1 .
[+] Building 13.9s (6/6) FINISHED
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 117B
 => [internal] load .dockerignore
 => => transferring context: 2B
 => [internal] load metadata for docker.io/library/nginx:latest
 => [1/2] FROM docker.io/library/nginx@sha256:08bc36ad52474e528cc1ea3426b5e3f4bad8a130318e3140d6cfe29c8892c7ef
 => [2/2] RUN echo '<h1>Hello,Docker!</h1>' > /usr/share/nginx/html/index.html
 => exporting to image
 => => exporting layers
 => => writing image sha256:347cf42b3c43c604d13e2d6e4577584ea2884efc429dc80572d3069f952a0b2c
 => => naming to docker.io/library/nginx:v1

docker build的其他用法:

  1. 使用远程 Git 仓库上的 Dockerfile:

    docker build -t myimage:latest https://github.com/username/repo.git#branch:path/to/Dockerfile

这个命令将从远程 Git 仓库中的特定分支的指定路径下获取 Dockerfile,并构建镜像。

  1. 通过 stdin 提供 Dockerfile 内容:

    docker build -t myimage:latest - << EOF
    FROM baseimage

    EOF

使用 - 作为路径参数,可以通过标准输入(STDIN)提供 Dockerfile 的内容。这种方式适用于简单的 Dockerfile 或者在脚本中动态生成 Dockerfile 的情况。

  1. 使用构建上下文压缩包:

    docker build -t myimage:latest - < context.tar.gz

通过将构建上下文压缩为 tar.gz 格式的压缩包,可以使用标准输入将其传递给 docker build 命令。这对于在构建过程中传输大量文件或使用远程构建上下文很有用。

  1. 使用多个 Dockerfile 进行多阶段构建:

    docker build -t myimage:latest -f Dockerfile.stage1 -t myimage:stage1 .
    docker build -t myimage:latest -f Dockerfile.stage2 -t myimage:stage2 .

通过使用不同的 Dockerfile 和不同的标签,可以进行多阶段构建,每个阶段生成一个不同的镜像。这在构建复杂应用程序或实现分层构建过程时非常有用。

镜像构建上下文(Context)

由于 docker 的运行模式是 C/S。我们本机是 C,docker 引擎是 S。Docker 的引擎提供了一组 REST API,被称为 Docker Remote API,而如 docker 命令这样的客户端工具,则是通过这组 API 与 Docker 引擎交互,从而完成各种功能。因此,虽然表面上我们好像是在本机执行各种 docker 功能,但实际上,一切都是使用的远程调用形式在服务端(Docker 引擎)完成。
构建上下文通常是一个目录,可以包含 Dockerfile 文件以及其他构建所需的文件、脚本、配置等。在构建过程中,Docker 引擎会将构建上下文发送给 Docker 守护进程,以便在构建镜像时使用其中的文件。构建上下文中的所有文件和目录都会被递归地传输给 Docker 引擎,因此在构建镜像时需要注意上下文的大小。如果上下文太大,会导致构建时间延长和镜像体积增大。
通常建议将构建上下文限制在必要的文件和目录,以减少不必要的文件传输和构建时间。使用.dockerignore 文件可以排除一些不需要包含在构建上下文中的文件和目录,类似于 .gitignore 文件。

COPY

命令格式:
COPY [--chown=<user>:<group>] <源路径>... <目标路径>
COPY [--chown=<user>:<group>] ["<源路径1>",... "<目标路径>"]
--chown=<user>:<group> 作为可选项

  • <user> 是文件或目录在镜像中的所有者用户名。
  • <group> 是文件或目录在镜像中的所属组名。

<源路径>:源文件或者源目录,这里可以是通配符表达式,其通配符规则要满足 Go 的 filepath.Match 规则。
<目标路径>:容器内的指定路径,该路径不用事先建好,路径不存在的话,会自动创建。

COPY hom* /mydir/
COPY hom?.txt /mydir/

如果你的 Dockerfile 有多个步骤需要使用上下文中不同的文件。单独 COPY 每个文件,而不是一次性的 COPY 所有文件,这将保证每个步骤的构建缓存只在特定的文件变化时失效。例如:

COPY requirements.txt /tmp/

RUN pip install --requirement /tmp/requirements.txt

COPY . /tmp/

如果将 COPY . /tmp/ 放置在 RUN 指令之前,只要 . 目录中任何一个文件变化,都会导致后续指令的缓存失效。

ADD

ADD 指令和 COPY 的格式和性质基本一致。但是在 COPY 基础上增加了一些功能。

  1. 自动解压缩压缩文件:如果 是一个压缩文件(如 .tar, .tar.gz, .tgz, .tar.bz2, .tbz2, .tar.xz, 或 .txz),ADD 命令会自动解压缩文件,并将解压缩后的内容复制到 。
  • 远程 URL 支持:如果 是一个远程 URL,ADD 命令将从该 URL 下载文件,并将其复制到 。
  1. 目录复制:如果 是一个目录,ADD 命令将复制整个目录(包括子目录和文件)到 。请注意,使用 / 结尾的目录路径将复制目录的内容到 ,而不是将整个目录本身复制到 。

在 Docker 官方的 Dockerfile 最佳实践文档 中要求,尽可能的使用 COPY,因为 COPY 的语义很明确,就是复制文件而已,而 ADD 则包含了更复杂的功能,其行为也不一定很清晰。最适合使用 ADD 的场合,就是所提及的需要自动解压缩的场合。
需要注意的是,在 Docker 的构建过程中,每个指令都会生成一个中间层(Layer),并且这些中间层可以被缓存以提高后续构建的速度。然而,当构建上下文中的文件或指令的参数发生更改时,Docker 会从该点开始重新执行构建过程,并且之前的缓存将失效。
对于 ADD 指令,当源文件或目录发生更改时,会导致构建缓存失效的情况。这是因为 Docker 会对源文件或目录进行哈希校验,如果哈希值发生变化,就会认为文件或目录已经发生更改,从而重新执行 ADD 指令,并生成新的中间层。如果在构建过程中频繁地更改了源文件或目录,那么会导致 ADD 指令频繁地重新执行,从而导致构建缓存的失效。这可能会使镜像构建过程变得缓慢。

CMD

CMD 指令的格式和 RUN 相似,也是两种格式:

CMD <dockerfile 命令>
CMD ["<可执行文件或命令>","<param1>","<param2>",...]
CMD ["<param1>","<param2>",...]  # 该写法是为 ENTRYPOINT 指令指定的程序提供默认参数

与RUN的不同点在于

  • CMD 在docker run 时运行。
  • RUN 是在 docker build

注意:如果 Dockerfile 中如果存在多个 CMD 指令,仅最后一个生效。
如果使用 dockerfile 格式的话,实际的命令会被包装为 sh -c 的参数的形式进行执行。比如:
CMD echo $HOME
在实际执行中,会将其变更为:
CMD [ "sh", "-c", "echo $HOME" ]

ENTRYPOINT

  • ENTRYPOINT <dockerfile 命令>
  • ENTRYPOINT ["executable", "param1", "param2"]

注意:ENTRYPOINT 命令在 Dockerfile 中只能出现一次。如果多次使用,只有最后一个 ENTRYPOINT 命令会生效。
当使用 ENTRYPOINT 命令时,可以结合 CMD 命令来提供默认参数。如果在运行容器时提供了命令行参数,则会覆盖 CMD 中的默认参数,而 ENTRYPOINT 中的命令不受影响,而且这些命令行参数会被当作参数送给ENTRYPOINT指令指定的程序。例如:

ENTRYPOINT ["echo", "Hello"]
CMD ["Docker"]

当以默认方式运行容器时,将输出 "Hello Docker"。但如果以命令行参数运行容器,如 docker run myimage Goodbye,则输出为"Hello Goodbye"。

ENV

  • ENV <key> <value>
  • ENV <key1>=<value1> <key2>=<value2>...

设置环境变量,定义了环境变量,那么在后续的指令中,就可以使用这个环境变量。
以下示例设置 NODE_VERSION = 7.2.0 , 在后续的指令中可以通过 $NODE_VERSION 引用

ENV NODE_VERSION 7.2.0

RUN curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \
  && curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc"

ARG

  • ARG <参数名>[=<默认值>]

构建参数,与 ENV 作用一致。不过作用域不一样。ARG 设置的环境变量仅对 Dockerfile 内有效,也就是说只有 docker build 的过程中有效,构建好的镜像内不存在此环境变量。

# 只对 FROM命令 生效
ARG DOCKER_USERNAME=library
FROM ${DOCKER_USERNAME}/alpine

# 要想在 FROM 之后使用,必须再次指定
ARG DOCKER_USERNAME=library
RUN set -x ; echo ${DOCKER_USERNAME}

VOLUME

  • VOLUME ["<路径1>", "<路径2>"...]
  • VOLUME <路径>

定义匿名数据卷。在启动容器时忘记挂载数据卷,会自动挂载到匿名卷。
作用:

  • 避免重要的数据,因容器重启而丢失,这是非常致命的。
  • 避免容器不断变大。

容器运行时应该尽量保持容器存储层不发生写操作,对于数据库类需要保存动态数据的应用,其数据库文件应该保存于卷(volume)中,后面的章节我们会进一步介绍 Docker 卷的概念。为了防止运行时用户忘记将动态文件所保存目录挂载为卷,在 Dockerfile 中,我们可以事先指定某些目录挂载为匿名卷,这样在运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据。
VOLUME /data
在启动容器 docker run 的时候,我们可以通过 -v 参数修改挂载点。
$ docker run -d -v mydata:/data xxxx
在这行命令中,就使用了 mydata 这个命名卷挂载到了 /data 这个位置,替代了 Dockerfile 中定义的匿名卷的挂载配置。
VOLUME 指令用于暴露任何数据库存储文件,配置文件,或容器创建的文件和目录。强烈建议使用 VOLUME 来管理镜像中的可变部分和用户可以改变的部分。

EXPOSE

  • EXPOSE <端口1> [<端口2>...]

仅仅只是声明端口,在容器运行时并不会因为这个声明应用就会开启这个端口的服务。
要将 EXPOSE 和在运行时使用 -p <宿主端口>:<容器端口> 区分开来。-p,是映射宿主端口和容器端口,换句话说,就是将容器的对应端口服务公开给外界访问,而EXPOSE 仅仅是声明容器打算使用什么端口而已,并不会自动在宿主进行端口映射。

WORKDIR

  • WORKDIR <工作目录路径>

用 WORKDIR 指定的工作目录,会在构建镜像的每一层中都存在。以后各层的当前目录就被改为指定的目录,如该目录不存在,WORKDIR 会帮你建立目录。

#错误写法
RUN cd /app
RUN echo "hello" > world.txt

#正确写法
WORKDIR /app
RUN echo "hello" > world.txt

如果将这个 Dockerfile 进行构建镜像运行后,会发现找不到 /app/world.txt 文件,或者其内容不是 hello。原因其实很简单,在 dockerfile 中,连续两行是同一个进程执行环境,因此前一个命令修改的内存状态,会直接影响后一个命令;而在 Dockerfile 中,这两行 RUN 命令的执行环境根本不同,是两个完全不同的容器。这就是对 Dockerfile 构建分层存储的概念不了解所导致的错误。
例如:

WORKDIR /a
WORKDIR b
WORKDIR c

RUN pwd
#pwd的工作目录为 /a/b/c

USER

用于指定执行后续命令的用户和用户组,这边只是切换后续命令执行的用户(用户和用户组必须提前已经存在)。

  • USER <用户名>[:<用户组>]

如果以 root 执行的脚本,在执行期间希望改变身份,比如希望以某个已经建立好的用户来运行某个服务进程,不要使用 su 或者 sudo,这些都需要比较麻烦的配置,而且在 TTY 缺失的环境下经常出错。
例如,切换为用户redis执行命令:

RUN groupadd -r redis && useradd -r -g redis redis
USER redis
RUN [ "redis-server" ]

HEALTHCHECK

用于指定某个程序或者指令来监控 docker 容器服务的运行状态。和 CMD, ENTRYPOINT 一样,HEALTHCHECK 只可以出现一次,如果写了多个,只有最后一个生效。

  • HEALTHCHECK [选项] CMD <命令>:设置检查容器健康状况的命令
  • HEALTHCHECK NONE:如果基础镜像有健康检查指令,使用这行可以屏蔽掉其健康检查指令

HEALTHCHECK 支持下列选项:

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

在没有 HEALTHCHECK 指令前,Docker 引擎只可以通过容器内主进程是否退出来判断容器是否状态异常。很多情况下这没问题,但是如果程序进入死锁状态,或者死循环状态,应用进程并不退出,但是该容器已经无法提供服务了。
在 1.12 以前,Docker 不会检测到容器的这种状态,从而不会重新调度,导致可能会有部分容器已经无法提供服务了却还在接受用户请求。而自 1.12 之后,Docker 提供了 HEALTHCHECK 指令,通过该指令指定一行命令,用这行命令来判断容器主进程的服务状态是否还正常,从而比较真实的反应容器实际状态。
当在一个镜像指定了 HEALTHCHECK 指令后,用其启动容器,初始状态会为 starting,在 HEALTHCHECK 指令检查成功后变为 healthy,如果连续一定次数失败,则会变为 unhealthy。
假设我们有个镜像是个最简单的 Web 服务,我们希望增加健康检查来判断其 Web 服务是否在正常工作,我们可以用 curl 来帮助判断,其 Dockerfile 的 HEALTHCHECK 可以这么写:

FROM nginx
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
HEALTHCHECK --interval=5s --timeout=3s \
  CMD curl -fs http://localhost/ || exit 1

这里我们设置了每 5 秒检查一次(这里为了试验所以间隔非常短,实际应该相对较长),如果健康检查命令超过 3 秒没响应就视为失败,并且使用 curl -fs http://localhost/ || exit 1 作为健康检查命令。
当我们启动这个容器后,执行docker container ls

$ docker container ls
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS                            PORTS               NAMES
03e28eb00bd0        myweb:v1            "nginx -g 'daemon off"   3 seconds ago       Up 2 seconds (health: starting)   80/tcp, 443/tcp     web

等待几秒钟后,再次执行

$ docker container ls
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS                    PORTS               NAMES
03e28eb00bd0        myweb:v1            "nginx -g 'daemon off"   18 seconds ago      Up 16 seconds (healthy)   80/tcp, 443/tcp     web

如果健康检查连续失败超过了重试次数,状态就会变为 (unhealthy)。

ONBUILD

  • ONBUILD <其它指令>

Dockerfile 中的其它指令都是为了定制当前镜像而准备的,唯有 ONBUILD 是为了帮助别人定制自己而准备的。
简单的说,就是 Dockerfile 里用 ONBUILD 指定的命令,在本次构建镜像的过程中不会执行。假设镜像为 test-build,当有新的 Dockerfile 使用了之前构建的镜像 FROM test-build ,这时执行新镜像的 Dockerfile 构建时候,会执行 test-build 的 Dockerfile 里的 ONBUILD 指定的命令。
假设我们要制作 Node.js 所写的应用的镜像。我们都知道 Node.js 使用 npm 进行包管理,所有依赖、配置、启动信息等会放到 package.json 文件里。在拿到程序代码后,需要先进行 npm install 才可以获得所有需要的依赖。然后就可以通过 npm start 来启动应用。因此,一般来说会这样写

FROM node:slim
RUN mkdir /app
WORKDIR /app
COPY ./package.json /app
RUN [ "npm", "install" ]
COPY . /app/
CMD [ "npm", "start" ]

把这个 Dockerfile 放到 Node.js 项目的根目录,构建好镜像后,就可以直接拿来启动容器运行。但是如果我们还有第二个 Node.js 项目也差不多呢?好吧,那就再把这个 Dockerfile 复制到第二个项目里。那如果有第三个项目呢?再复制么?文件的副本越多,版本控制就越困难,让我们继续看这样的场景维护的问题。
如果第一个 Node.js 项目在开发过程中,发现这个 Dockerfile 里存在问题,比如敲错字了、或者需要安装额外的包,然后开发人员修复了这个 Dockerfile,再次构建,问题解决。第一个项目没问题了,但是第二个项目呢?虽然最初 Dockerfile 是复制、粘贴自第一个项目的,但是并不会因为第一个项目修复了他们的 Dockerfile,而第二个项目的 Dockerfile 就会被自动修复。
那么我们可不可以做一个基础镜像,然后各个项目使用这个基础镜像呢?这样基础镜像更新,各个项目不用同步 Dockerfile 的变化,重新构建后就继承了基础镜像的更新?好吧,可以,让我们看看这样的结果。那么上面的这个 Dockerfile 就会变为:

FROM node:slim
RUN mkdir /app
WORKDIR /app
CMD [ "npm", "install" ]

这里我们把项目相关的构建指令拿出来,放到子项目里去。假设这个基础镜像的名字为 my-node 的话,各个项目内的自己的 Dockerfile 就变为:

FROM my-node
COPY ./package.json /app
RUN [ "npm", "start" ]
COPY . /app/

基础镜像变化后,各个项目都用这个 Dockerfile 重新构建镜像,会继承基础镜像的更新。
那么,问题解决了么?没有。准确说,只解决了一半。如果这个 Dockerfile 里面有些东西需要调整呢?比如 npm install 都需要加一些参数,那怎么办?这一行 RUN 是不可能放入基础镜像的,因为涉及到了当前项目的 ./package.json,难道又要一个个修改么?所以说,这样制作基础镜像,只解决了原来的 Dockerfile 的前4条指令的变化问题,而后面三条指令的变化则完全没办法处理。
让我们来考虑一下ONBUILD

FROM node:slim
RUN mkdir /app
WORKDIR /app
ONBUILD COPY ./package.json /app
ONBUILD RUN [ "npm", "install" ]
ONBUILD COPY . /app/
CMD [ "npm", "start" ]

这样在构建基础镜像的时候,这三行并不会被执行。然后各个项目的 Dockerfile 就变成了简单地:
FROM my-node
用这个只有一行的 Dockerfile 构建镜像时,之前基础镜像的那三行 ONBUILD 就会开始执行,成功的将当前项目的代码复制进镜像、并且针对本项目执行 npm install,生成应用镜像。

LABEL

LABEL 指令用来给镜像添加一些元数据(metadata),以键值对的形式,语法格式如下:

  • LABEL <key>=<value> <key>=<value> <key>=<value> ...

比如我们可以添加镜像的作者:
LABEL org.opencontainers.image.authors="authorxxxx"
到此,dockerfile的基础使用就差不多了。在项目里面实际用起来的时候,又感觉挺简单,只能说边用边学是掌握的最快的手段之一。