如何编写最佳的Dockerfile
Dockerfile 最佳实践指南
核心目标
- 更快的构建速度
- 更小的Docker镜像大小
- 更少的Docker镜像层
- 充分利用镜像缓存
- 增加Dockerfile可读性
- 让Docker容器使用起来更简单
最佳实践总结
1. 编写.dockerignore文件
构建镜像时,Docker需要先准备context,将所有需要的文件收集到进程中。默认的context包含Dockerfile目录中的所有文件,但是实际上,我们并不需要.git目录,node_modules目录等内容。.dockerignore的作用和语法类似于.gitignore,可以忽略一些不需要的文件,这样可以有效加快镜像构建时间,同时减少Docker镜像的大小。
示例 .dockerignore 文件:
1 | .git/ |
2. 容器只运行单个应用
从技术角度讲,你可以在Docker容器中运行多个进程。你可以将数据库,前端,后端,ssh,supervisor都运行在同一个Docker容器中。但是,这会让你非常痛苦:
- 非常长的构建时间(修改前端之后,整个后端也需要重新构建)
- 非常大的镜像大小
- 多个应用的日志难以处理(不能直接使用stdout,否则多个应用的日志会混合到一起)
- 横向扩展时非常浪费资源(不同的应用需要运行的容器数并不相同)
- 僵尸进程问题 - 你需要选择合适的init进程
因此,建议为每个应用构建单独的Docker镜像,然后使用 Docker Compose 运行多个Docker容器。
3. 使用多阶段构建
多阶段构建是Docker 17.05+的特性,可以大幅减少最终镜像的大小。通过使用多个FROM指令,我们可以在不同的阶段使用不同的基础镜像,只将需要的文件复制到最终镜像中。
示例:Node.js应用的多阶段构建
1 | # 第一阶段:构建 |
4. 将多个RUN指令合并为一个
Docker镜像是分层的,下面这些知识点非常重要:
- Dockerfile中的每个指令都会创建一个新的镜像层
- 镜像层将被缓存和复用
- 当Dockerfile的指令修改了,复制的文件变化了,或者构建镜像时指定的变量不同了,对应的镜像层缓存就会失效
- 某一层的镜像缓存失效之后,它之后的镜像层缓存都会失效
- 镜像层是不可变的,如果我们在某一层中添加一个文件,然后在下一层中删除它,则镜像中依然会包含该文件(只是这个文件在Docker容器中不可见了)
因此,我们应该将多个RUN指令合并为一个,并且在每个RUN指令后删除多余的文件。
示例:
1 | RUN apt-get update \ |
5. 基础镜像的标签不要用latest
当镜像没有指定标签时,将默认使用latest标签。因此,FROM ubuntu指令等同于FROM ubuntu:latest。但是,当镜像更新时,latest标签会指向不同的镜像,这时构建镜像有可能失败。如果你的确需要使用最新版的基础镜像,可以使用latest标签,否则的话,最好指定确定的镜像标签。
示例:
1 | FROM node:16-alpine # 明确指定版本 |
6. 选择合适的基础镜像
- alpine:极小化的Linux发行版,只有约5MB,适合大多数应用
- slim:比alpine大一些,但包含更多常用工具
- bullseye/sid:Debian的不同版本
- 特定语言镜像:如node、python、java等
示例:
1 | # 对于Node.js应用 |
7. 设置WORKDIR和CMD
- WORKDIR:设置默认目录,也就是运行RUN / CMD / ENTRYPOINT指令的地方
- CMD:设置容器创建时执行的默认命令,应该将命令写在一个数组中
示例:
1 | WORKDIR /app |
8. 使用ENTRYPOINT (可选)
ENTRYPOINT指令并不是必须的,因为它会增加复杂度。ENTRYPOINT是一个脚本,它会默认执行,并且将指定的命令作为其参数。它通常用于构建可执行的Docker镜像。
示例 entrypoint.sh 脚本:
1 |
|
示例Dockerfile:
1 | FROM node:16-alpine |
9. 在entrypoint脚本中使用exec
在前文的entrypoint脚本中,使用了exec命令运行应用。不使用exec的话,我们则不能顺利地关闭容器,因为SIGTERM信号会被bash脚本进程吞没。exec命令启动的进程可以取代脚本进程,因此所有的信号都会正常工作。
10. COPY与ADD优先使用前者
- COPY:非常简单,仅用于将文件拷贝到镜像中
- ADD:相对来讲复杂一些,可以用于下载远程文件以及解压压缩包
建议:除非需要ADD的特殊功能,否则优先使用COPY。
示例:
1 | # 复制本地文件 |
11. 合理调整COPY与RUN的顺序
我们应该把变化最少的部分放在Dockerfile的前面,这样可以充分利用镜像缓存。
示例:
1 | FROM node:16-alpine |
12. 设置默认的环境变量,映射端口和数据卷
运行Docker容器时很可能需要一些环境变量。在Dockerfile设置默认的环境变量是一种很好的方式。另外,我们应该在Dockerfile中设置映射端口和数据卷。
示例:
1 | FROM node:16-alpine |
13. 使用LABEL设置镜像元数据
使用LABEL指令,可以为镜像设置元数据,例如镜像创建者或者镜像说明。旧版的Dockerfile语法使用MAINTAINER指令指定镜像创建者,但是它已经被弃用了。
示例:
1 | FROM node:16-alpine |
14. 添加HEALTHCHECK
运行容器时,可以指定–restart always选项。这样的话,容器崩溃时,Docker守护进程(docker daemon)会重启容器。对于需要长时间运行的容器,这个选项非常有用。但是,如果容器的确在运行,但是不可用(陷入死循环,配置错误)怎么办?使用HEALTHCHECK指令可以让Docker周期性的检查容器的健康状况。
示例:
1 | FROM node:16-alpine |
15. 使用Docker BuildKit
Docker BuildKit是Docker 18.09+引入的新构建引擎,提供了许多改进:
- 并行构建
- 更好的缓存利用
- 更小的镜像大小
- 更安全的构建过程
启用BuildKit:
1 | export DOCKER_BUILDKIT=1 |
BuildKit特有的指令:
- RUN –mount=type=cache:缓存目录
- RUN –mount=type=secret:使用密钥
- RUN –mount=type=ssh:使用SSH密钥
示例:
1 | # syntax=docker/dockerfile:1.2 |
16. 使用ARG和ENV
- ARG:构建时的变量,不会保存在最终镜像中
- ENV:运行时的变量,会保存在最终镜像中
示例:
1 | ARG NODE_VERSION=16 |
完整的Dockerfile示例
Node.js应用的最佳实践示例:
1 | # syntax=docker/dockerfile:1.2 |
常见问题及解决方案
1. 镜像大小过大
- 使用多阶段构建
- 使用alpine基础镜像
- 清理包管理工具的缓存
- 只复制必要的文件
2. 构建速度慢
- 使用Docker BuildKit
- 合理利用缓存
- 并行构建
- 减少上下文大小(使用.dockerignore)
3. 容器启动失败
- 检查CMD指令
- 确保entrypoint脚本有执行权限
- 检查环境变量
- 查看容器日志
4. 缓存失效
- 合理调整指令顺序
- 使用ARG变量控制缓存
- 定期清理缓存