本文介绍 Docker 镜像构建中的注意事项
Docker 的使用场景
构建运行环境
一个程序的运行环境往往很复杂,如深度学习程序的运行环境,可能需要指定版本的操作系统,CUDA,cuDNN,gcc,python,各种依赖等等,自己从头搭建一个运行环境是很麻烦的,这时有人将运行环境构建成 base 镜像,然后基于 base 镜像运行程序,省去了自己搭建环境的过程
比如想使用 paddle 的训练、源码编译,直接运行对应的环境镜像,将代码挂载进去,就可以直接训练和编译了
1 | docker run --name paddle -itd -v $PWD:/paddle registry.baidubce.com/paddlepaddle/paddle:2.4.1 /bin/bash |
不过要注意,base镜像使用的 kernal 指令还是宿主机提供的,所以如果要求的操作系统是 Linux,而宿主机是 Mac,即使在 Mac 上运行了 centos 或 ubantu 的 base 镜像,也是无法运行的
临时的文件下载服务
在公司远程服务器上需要下载文件时,出于安全考虑,不能通过 xftp 等软件连接,这时想从服务器上下载一个文件,就比较麻烦了,这时可以用 docker 创建一个 httpserver 容器,临时开通一个 http 服务
1 | docker run -itd --name my-apache-app -p 8080:80 -v "$PWD":/usr/local/apache2/htdocs/ httpd:2.4 |
下载完文件记得关闭这个容器,防止产生安全问题
服务部署
k8s 管理 docker 容器完成服务部署、调度、扩缩容等
镜像
镜像的分层结构
镜像分层最大的一个好处就是共享资源
比如:有多个镜像都从相同的 base 镜像构建而来,那么宿主机只需在磁盘上保存一份 base 镜像;同时内存中也只需加载一份 base 镜像,就可以为所有容器服务了
Q:如果多个容器共享一份基础镜像,当某个容器修改了基础镜像的内容,比如 /etc 下的文件,这时其他容器的 /etc 是否也会被修改?
A:不会的,修改会被限制在单个容器内,这就是容器的写时复制
镜像是如何被多个容器共享的
当容器启动时,一个新的可写层被加载到镜像顶部,称为容器层,容器层之下的叫做镜像层
只有容器层是可写的,容器层下面的所有镜像层都是只读的,所以对容器文件的改动都只会发生在容器层中
镜像层数量可能会很多,所有镜像层会联合在一起组成一个统一的文件系统。如果不同层中有一个相同路径的文件,比如 /etc,上层的 /etc 会覆盖下层的 /etc,也就是说用户只能访问到上层中的文件 /etc。在容器层中,用户看到的是一个叠加之后的文件系统
容器层如何变更文件
操作 | 实现 |
---|---|
添加文件 | 新文件被写到容器层 |
读取文件 | 从上往下依次在各镜像层中查找此文件,找到后,打开并读入内存 |
修改文件 | 从上往下依次在各镜像层中查找此文件,找到后,将其复制到容器层,然后修改 |
删除文件 | 从上往下依次在各镜像层中查找此文件,找到后,会在容器层中记录此删除操作 |
写时复制 Copy On Write
只有当需要修改时才复制一份数据,这种特性被称作 Copy on Write。容器层保存的是镜像变化的部分,不会对镜像本身进行任何修改
Dockerfile
部署服务时,需要使用我们自己服务的镜像,就要用到 Dockerfile
Dockerfile 常用指令
指令 | 说明 |
---|---|
FROM | 指定 base 镜像 |
MAINTAINER | 设置镜像的作者 |
WORKDIR | 为后面的 RUN, CMD, ENTRYPOINT, ADD 或 COPY 指令设置镜像中的当前工作目录。 |
COPY | 将文件从 build context 复制到镜像内 |
ADD | 与 COPY 类似,不同的是,如果 src 是归档文件(tar, zip, tgz, xz 等),文件会被自动解压 |
ENV | 设置环境变量 |
EXPOSE | 指定容器中的进程会监听某个端口 |
VOLUME | 将文件或目录声明为 volume |
RUN | 在容器中运行指定的命令 |
CMD | 容器启动时运行指定的命令 Dockerfile 中可以有多个 CMD 指令,但只有最后一个生效 CMD 可以被 docker run 之后的参数替换 |
ENTRYPOINT | 设置容器启动时运行的命令。 Dockerfile 中可以有多个 ENTRYPOINT 指令,但只有最后一个生效。CMD 或 docker run 之后的参数会被当做参数传递给 ENTRYPOINT |
在执行 docker build -t {app}:{version} .
命令末尾的
.
指明 build context 为当前目录。Docker 默认会从 build
context 寻找 Dockerfile 文件,也可以通过 -f
指定 Dockerfile
的位置
如:
1 | FROM ubuntu |
Dockerfile 构建过程
以上面dockerfile内容为例
- Docker 将 build context 中的所有文件发送给 Docker daemon
- 执行 FROM,下载 base 镜像
- 执行 RUN,创建临时容器 A,在临时容器中执行
apt-get update
- 执行成功后,将容器 A 保存为临时镜像 B
- 通过临时镜像 B,创建临时容器
C,在临时容器中执行
apt-get install -y vim
- 执行成功后,将容器 C 保存为临时镜像 D
- 删除临时容器
- 镜像构建成功,最终镜像为镜像D
Dockerfile 的最佳实践
base 镜像的选择
- 使用固定版本的 base 镜像,不使用 latest 版本
- 使用轻量级 alpine 的发行版
尽量利用镜像层缓冲
在执行 build 时,每个镜像层都会被缓存下来,当需要构建时,若某一层指令或内容没有发生变更,会直接利用缓存层,提高构建速度
若某一层的命令或内容发生了变更,意味着后面的所有的镜像层缓存都不可被复用
Q:为什么某一层发生变更,后面的镜像层就不能被复用了呢?
A:先说下 git commit,这个命令用于创建镜像,在用 dockerfile 构建镜像时,底层是用 git commit 不断的创建镜像,包括若干临时镜像和一个最终镜像;比如在构建过程中,临时镜像A包括镜像层1-4,那么在执行下一个构建语句时,需要通过镜像A运行容器C,在容器C中执行相应的命令,再将容器C保存为镜像B,这样镜像B就包含了镜像层1-5。所以,如果某一层发生变更,该层对应的临时镜像以及后面的临时镜像都变了,无法复用
所以这个问题是带有欺骗性的,看起来是镜像层的复用,实际上是临时镜像的复用,在构建过程中,只有临时镜像被保存,没有镜像层被保存
- 将 dockerfile 涉及到的文件或内容,从最少变更步骤到最频繁变更进行排序,不频繁的放在上面,来提高缓存层复用率,提高镜像的构建速度
例如,有些 dockerfile 需要将项目文件拷贝到镜像中,这些文件是频繁变更的,要尽量往后放
镜像层尽量少
dockerfile中的每条指定都会创建一个镜像层(待验证),有些相同类型的操作,如果分开写,会导致生成的镜像文件存在多个镜像层中
- 将同类型的操作用
&&
和\
来结合,减少镜像层,便于维护
1 | ENV GO111MODULE=on \ |
使用 .dockerignore 文件
在构建镜像时,Docker Client 会将 build context 中的所有文件发送给 Docker daemon,如果 build context 下的文件体积很大,会导致编译时间很长甚至失败,还可能导致镜像体积过大
本人遇到的一个项目,由于上线流水线不健全,需要本地打镜像,当时本地日志打在项目目录下,且缺少 .dockerignore 文件,每次构建镜像时,都会将日志文件发送给 Docker daemon,还会 COPY 到镜像内,不仅编译时间长,且镜像体积大
如果通过流水线从代码库拉代码,正常情况下是不会有本地产物的,不过也可以通过 .dockeringore 文件去掉一些镜像不需要的文件,加快构建速度
- 在根目录下,可使用 .dockerfileignore 文件,排除掉无需加入的目录或文件,其编写的方式和 .gitignore 相同,加快构建速度
安全性
- 容器中使用非 root 用户启动应用
1 | FROM node:17.0.1-alpine |
其他方面
- 使用
RUN
指令安装依赖和软件包 - 不要使用
RUN apt-get upgrade
- 使用
RUN apt-get update && apt-get install -y
不要分成两个 RUN - 清理掉 apt 缓存
rm -rf /var/lib/apt/lists/*
减少镜像体积 - 用于安装依赖的文件单独 COPY,如 requirements.txt
下面是一个完整的栗子
1 | FROM python:alpine3.17 |