Dockerfile多阶段构建镜像

之前学习部署 Docker 应用时,我们搭建过一个 redis 服务,然后编写并运行了一个统计访问次数的 flask 应用。

现在,我们使用 Dockerfile,将这个 flask 应用也制作成镜像,此外,在这个镜像中,可以包含一个 helloworld 二进制程序,这个 helloworld 的源码就是我们学习 rootfs 时用到的 helloworld.c。

1. 实战:直接构建镜像

首先 我们需要新建一个目录 dockerfiledir,用于存放 Dockerfile 文件。

mkdir dockerfiledir
# 在这个目录下新建个空文件 Dockerfile,之后填充内容 
touch dockerfiledir/Dockerfile

新建一个目录code,用来存放flask和c的源代码。

mkdir code

将之前 app.py 和 helloworld.c 两个源码文件放入到 code 目录下,当前的目录结构应该是这样的:

图片描述

进入 dockerfiledir 目录,编辑 Dockerfile 文件:

# 从 ubuntu系统镜像开始构建
FROM ubuntu	
# 标记镜像维护者信息
MAINTAINER user <user@imooc.com>
# 切换到镜像的/app目录,不存在则新建此目录
WORKDIR /app
# 将 宿主机的文件拷贝到容器中
COPY ../code/app.py .
COPY ../code/helloworld.c .
# 安装依赖 编译helloworld
RUN apt update >/dev/null 2>&1  && \
    apt install -y gcc python3-flask python3-redis >/dev/null 2>&1 && \
    cc /app/helloworld.c -o /usr/bin/helloworld
# 设定执行用户为user
RUN useradd user
USER user
# 设定flask所需的环境变量
ENV FLASK_APP app
# 默认启动执行的命令
CMD ["flask", "run", "-h", "0.0.0.0"]
# 将flask的默认端口暴露出来
EXPOSE 5000

然后执行:

docker build .

出现如下报错:

COPY failed: Forbidden path outside the build context: ../code/app.py ()

解决这个问题,需要引入一个重要的概念——构建上下文。

docker build .命令在执行时,当前目录.被指定成了构建上下文,此目录中的所有文件或目录都将被发送到 Docker 引擎中去,Dockerfile中的切换目录和复制文件等操作只会对上下文中的内容生效。

Tips:在默认情况下,如果不额外指定 Dockerfile 的话,会将构建上下文对应的目录下 Dockerfile 的文件作为 Dockerfile。但这只是默认行为,实际上 Dockerfile 的文件名并不要求必须为 Dockerfile,而且并不要求必须位于上下文目录中,比如可以用 -f ../demo.txt参数指定父级目录的demo.txt文件作为 Dockerfile。

一般来说,我们习惯使用默认的文件名 Dockerfile,将其置于镜像构建上下文目录.中。

我们需要将 code 目录纳入到上下文中,一个直接的方法是,调整dockerfile中的COPY指令的路径。

# 将 .. 改为 .
COPY ./code/app.py .
COPY ./code/helloworld.c .

然后将 code 所在的目录指定为构建上下文。由于我们当前的目录是 dockerfiledir,所以我们执行:

docker build -f ./Dockerfile ..

如果你留意查看构建过程,会发现类似这样的提示:

Sending build context to Docker daemon 421.309 MB

如果..目录除了code和dockerfiledir,还包含其他的文件或目录,docker build也会将这个数据传输给Docker,这会增加构建时间。
避免这种情况,有两种解决方法:

  • 使用.dockerignore文件:在构建上下文的目录下新建一个.dockerignore文件来指定在传递给 docker 时需要忽略掉的文件或文件夹。.dockerignore 文件的排除模式语法和 Git 的 .gitignore 文件相似。

  • 使用一个干净的目录作为构建上下文(推荐):使用 Dockerfile 构建镜像时最好是将 Dockerfile 放置在一个新建的空目录下。然后将构建镜像所需要的文件添加到该目录中。

在我们当前的示例中,将code目录移入dockerfiledir。

mv ../code .

现在的目录层级如下:

执行 docker build -t myhello . 执行构建即可获得我们的自定义镜像 myhello。

使用镜像 myhello 创建 myhello 容器:

# 这里使用--net=host,方便使用之前章节中部署的redis容器服务,与之进行数据交换
docker run -dit --net=host  --name myhello myhello 

确保部署之前的 redis 容器正常启动,然后在 Docker 宿主机的浏览器中访问http://127.0.0.1:5000
图片描述

说明 myhello 中的 flask 应用已经正常运行了。接下来,我们再运行测试一下编译的 helloworld。

docker exec myhello /usr/bin/helloworld

得到输出:

Hello, World!

Tips: myhello容器已经完成任务,记得执行docker rm -f myhello删除它.

2. 改进: 使用多阶段构建

在镜像构建过程中,我们的 helloworld.c 源码以及相关编译工具和依赖也被构建到了镜像中,这导致我们最终得到的镜像偏大。

理想状态应该是使用了一个系统镜像生成的容器,编译源码后再将编译的程序导入到最终的镜像中,这样就会缩减体积,并且将不同目的的操作有效分离开,但是按照我们之前掌握的知识,这样实现需要两个Dockerfile 文件。

使用多阶段构建,我们可以在一个 Dockerfile 中使用多个 FROM 语句。每个 FROM 指令都可以使用不同的镜像,并表示开始一个新的构建阶段。很方便的将一个阶段的文件复制到另外一个阶段,在最终的镜像中保留下需要的内容即可。

我们还是在 Dockerfile 文件的同一目录,新建一个新的构建脚本,命名为 Dockerfile-multi-stage 便于区分:

#从ubuntu镜像开始构建, 将第一阶段命名为`build`,在其他阶段需要引用的时候使用`--from=build`参数即可。
FROM ubuntu AS build
# 将宿主机的源码拷贝到镜像中
COPY ./code/helloworld.c .
# 安装依赖 并编译源码
RUN apt update >/dev/null 2>&1 && \
    apt install -y gcc >/dev/null 2>&1 && \
    cc helloworld.c -o /usr/bin/helloworld

# 第二阶段 从官方的python:alpine基础镜像开始构建
FROM python:alpine
# 镜像维护者信息
MAINTAINER user <user@imooc.com>
# 将第一阶段构建的helloworld 导入到此镜像中
COPY --from=build /usr/bin/helloworld /usr/bin/helloworld
# 安装flask 和 redis 的依赖
RUN pip install flask redis >/dev/null 2>&1 
# 设定镜像在切换到/app目录路径
WORKDIR /app
# 将源码导入到镜像
COPY ./code/app.py .
# 设定执行用户为user
RUN useradd user
USER user
# 设定flask所需的环境变量
ENV FLASK_APP app
# 默认启动执行的命令
CMD ["flask", "run", "-h", "0.0.0.0"]
# 将flask的默认端口暴露出来
EXPOSE 5000

执行 build 命令:

docker build -f Dockerfile-multi-stage -t myhello-multi-stage .

使用此镜像运行一个容器:

# 这里使用--net=host,方便使用之前章节中部署的redis容器服务,与之进行数据交换
docker run -dit --net=host --name myhello-multi-stage myhello-multi-stage

自行测试一下这个容器吧。

3. 小结

通过以上内容,相信大家对 Dockerfile 的使用又有了新的认知,我们在构建镜像的时候,一定要有合理的规划, 在自己不熟悉的基础镜像上定义镜像的时候,不妨先用它运行一个容器,在容器中过一遍流程, 弄清最终的镜像中到底应该包含哪些内容,再来调整构建脚本。

这里有一些 Dockerfile 的一般规范:

  1. 通过 Dockerfile 构建的镜像所启动的容器越快越好,这样可以快速启停增删容器服务(下面几条也是为第1条服务的);
  2. 避免安装不必要的包,必要时使用多阶段构建;
  3. 一个容器尽量只专注做一件事情;
  4. 最小化镜像层数, 将重复功能的 RUN、COPY、ADD 等指令缩减合并, 但一定要保证 Dockerfile 可读性。

当然,这些建议仅供参考,不要拘泥于它,要根据自己的使用场景来做权衡。