Dockerfile 最佳实践指南

核心目标

  • 更快的构建速度
  • 更小的Docker镜像大小
  • 更少的Docker镜像层
  • 充分利用镜像缓存
  • 增加Dockerfile可读性
  • 让Docker容器使用起来更简单

最佳实践总结

1. 编写.dockerignore文件

构建镜像时,Docker需要先准备context,将所有需要的文件收集到进程中。默认的context包含Dockerfile目录中的所有文件,但是实际上,我们并不需要.git目录,node_modules目录等内容。.dockerignore的作用和语法类似于.gitignore,可以忽略一些不需要的文件,这样可以有效加快镜像构建时间,同时减少Docker镜像的大小。

示例 .dockerignore 文件:

1
2
3
4
5
6
7
8
9
10
.git/
node_modules/
npm-debug.log
.DS_Store
.env
*.swp
*.swo
*~
build/
dist/

2. 容器只运行单个应用

从技术角度讲,你可以在Docker容器中运行多个进程。你可以将数据库,前端,后端,ssh,supervisor都运行在同一个Docker容器中。但是,这会让你非常痛苦:

  • 非常长的构建时间(修改前端之后,整个后端也需要重新构建)
  • 非常大的镜像大小
  • 多个应用的日志难以处理(不能直接使用stdout,否则多个应用的日志会混合到一起)
  • 横向扩展时非常浪费资源(不同的应用需要运行的容器数并不相同)
  • 僵尸进程问题 - 你需要选择合适的init进程

因此,建议为每个应用构建单独的Docker镜像,然后使用 Docker Compose 运行多个Docker容器。

3. 使用多阶段构建

多阶段构建是Docker 17.05+的特性,可以大幅减少最终镜像的大小。通过使用多个FROM指令,我们可以在不同的阶段使用不同的基础镜像,只将需要的文件复制到最终镜像中。

示例:Node.js应用的多阶段构建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 第一阶段:构建
FROM node:16-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# 第二阶段:运行
FROM node:16-alpine
WORKDIR /app
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/build ./build
EXPOSE 3000
CMD ["npm", "start"]

4. 将多个RUN指令合并为一个

Docker镜像是分层的,下面这些知识点非常重要:

  • Dockerfile中的每个指令都会创建一个新的镜像层
  • 镜像层将被缓存和复用
  • 当Dockerfile的指令修改了,复制的文件变化了,或者构建镜像时指定的变量不同了,对应的镜像层缓存就会失效
  • 某一层的镜像缓存失效之后,它之后的镜像层缓存都会失效
  • 镜像层是不可变的,如果我们在某一层中添加一个文件,然后在下一层中删除它,则镜像中依然会包含该文件(只是这个文件在Docker容器中不可见了)

因此,我们应该将多个RUN指令合并为一个,并且在每个RUN指令后删除多余的文件。

示例:

1
2
3
4
5
6
7
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
wget \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

5. 基础镜像的标签不要用latest

当镜像没有指定标签时,将默认使用latest标签。因此,FROM ubuntu指令等同于FROM ubuntu:latest。但是,当镜像更新时,latest标签会指向不同的镜像,这时构建镜像有可能失败。如果你的确需要使用最新版的基础镜像,可以使用latest标签,否则的话,最好指定确定的镜像标签。

示例:

1
2
3
FROM node:16-alpine  # 明确指定版本
# 而不是
# FROM node:latest # 不推荐

6. 选择合适的基础镜像

  • alpine:极小化的Linux发行版,只有约5MB,适合大多数应用
  • slim:比alpine大一些,但包含更多常用工具
  • bullseye/sid:Debian的不同版本
  • 特定语言镜像:如node、python、java等

示例:

1
2
3
4
5
6
7
8
# 对于Node.js应用
FROM node:16-alpine

# 对于Python应用
FROM python:3.9-alpine

# 对于Java应用
FROM openjdk:11-jre-slim

7. 设置WORKDIR和CMD

  • WORKDIR:设置默认目录,也就是运行RUN / CMD / ENTRYPOINT指令的地方
  • CMD:设置容器创建时执行的默认命令,应该将命令写在一个数组中

示例:

1
2
WORKDIR /app
CMD ["npm", "start"]

8. 使用ENTRYPOINT (可选)

ENTRYPOINT指令并不是必须的,因为它会增加复杂度。ENTRYPOINT是一个脚本,它会默认执行,并且将指定的命令作为其参数。它通常用于构建可执行的Docker镜像。

示例 entrypoint.sh 脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/usr/bin/env sh
# $0 is a script name,
# $1, $2, $3 etc are passed arguments
# $1 is our command
CMD=$1
case "$CMD" in
"dev" )
npm install
export NODE_ENV=development
exec npm run dev
;;
"start" )
# we can modify files here, using ENV variables passed in
# "docker create" command. It can't be done during build process.
echo "db: $DATABASE_ADDRESS" >> /app/config.yml
export NODE_ENV=production
exec npm start
;;
* )
# Run custom command. Thanks to this line we can still use
# "docker run our_image /bin/bash" and it will work
exec $CMD ${@:2}
;;
esac

示例Dockerfile:

1
2
3
4
5
6
FROM node:16-alpine
WORKDIR /app
COPY . /app
RUN npm install
ENTRYPOINT ["./entrypoint.sh"]
CMD ["start"]

9. 在entrypoint脚本中使用exec

在前文的entrypoint脚本中,使用了exec命令运行应用。不使用exec的话,我们则不能顺利地关闭容器,因为SIGTERM信号会被bash脚本进程吞没。exec命令启动的进程可以取代脚本进程,因此所有的信号都会正常工作。

10. COPY与ADD优先使用前者

  • COPY:非常简单,仅用于将文件拷贝到镜像中
  • ADD:相对来讲复杂一些,可以用于下载远程文件以及解压压缩包

建议:除非需要ADD的特殊功能,否则优先使用COPY。

示例:

1
2
3
4
5
# 复制本地文件
COPY package.json /app/

# 复制并解压压缩包(使用ADD)
ADD app.tar.gz /app/

11. 合理调整COPY与RUN的顺序

我们应该把变化最少的部分放在Dockerfile的前面,这样可以充分利用镜像缓存。

示例:

1
2
3
4
5
6
7
8
FROM node:16-alpine
WORKDIR /app
# 先拷贝package.json,这样只有依赖变化时才会重新安装
COPY package*.json ./
RUN npm install
# 然后拷贝源代码,这样源代码变化时不会重新安装依赖
COPY . .
CMD ["npm", "start"]

12. 设置默认的环境变量,映射端口和数据卷

运行Docker容器时很可能需要一些环境变量。在Dockerfile设置默认的环境变量是一种很好的方式。另外,我们应该在Dockerfile中设置映射端口和数据卷。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
FROM node:16-alpine
ENV PROJECT_DIR=/app
WORKDIR $PROJECT_DIR
COPY package*.json $PROJECT_DIR
RUN npm install
COPY . $PROJECT_DIR
ENV MEDIA_DIR=/media \
NODE_ENV=production \
APP_PORT=3000
VOLUME $MEDIA_DIR
EXPOSE $APP_PORT
CMD ["npm", "start"]

13. 使用LABEL设置镜像元数据

使用LABEL指令,可以为镜像设置元数据,例如镜像创建者或者镜像说明。旧版的Dockerfile语法使用MAINTAINER指令指定镜像创建者,但是它已经被弃用了。

示例:

1
2
3
4
FROM node:16-alpine
LABEL maintainer="neoyin@example.com"
LABEL description="Node.js application"
LABEL version="1.0.0"

14. 添加HEALTHCHECK

运行容器时,可以指定–restart always选项。这样的话,容器崩溃时,Docker守护进程(docker daemon)会重启容器。对于需要长时间运行的容器,这个选项非常有用。但是,如果容器的确在运行,但是不可用(陷入死循环,配置错误)怎么办?使用HEALTHCHECK指令可以让Docker周期性的检查容器的健康状况。

示例:

1
2
3
4
5
6
7
8
FROM node:16-alpine
WORKDIR /app
COPY . /app
RUN npm install
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl --fail http://localhost:3000 || exit 1
CMD ["npm", "start"]

15. 使用Docker BuildKit

Docker BuildKit是Docker 18.09+引入的新构建引擎,提供了许多改进:

  • 并行构建
  • 更好的缓存利用
  • 更小的镜像大小
  • 更安全的构建过程

启用BuildKit:

1
2
export DOCKER_BUILDKIT=1
docker build -t myapp .

BuildKit特有的指令:

  • RUN –mount=type=cache:缓存目录
  • RUN –mount=type=secret:使用密钥
  • RUN –mount=type=ssh:使用SSH密钥

示例:

1
2
3
4
5
6
7
8
# syntax=docker/dockerfile:1.2
FROM node:16-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm install
COPY . .
CMD ["npm", "start"]

16. 使用ARG和ENV

  • ARG:构建时的变量,不会保存在最终镜像中
  • ENV:运行时的变量,会保存在最终镜像中

示例:

1
2
3
4
5
6
7
8
9
10
11
ARG NODE_VERSION=16
FROM node:${NODE_VERSION}-alpine

ARG BUILD_ENV=production
ENV NODE_ENV=${BUILD_ENV}

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "start"]

完整的Dockerfile示例

Node.js应用的最佳实践示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# syntax=docker/dockerfile:1.2

ARG NODE_VERSION=16
FROM node:${NODE_VERSION}-alpine as builder

LABEL maintainer="neoyin@example.com"
LABEL description="Node.js application"

WORKDIR /app

# 复制依赖文件
COPY package*.json ./

# 安装依赖(使用缓存)
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production

# 复制源代码
COPY . .

# 构建应用
RUN npm run build

# 第二阶段:运行
FROM node:${NODE_VERSION}-alpine

WORKDIR /app

# 从构建阶段复制文件
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/build ./build

# 设置环境变量
ENV NODE_ENV=production
ENV PORT=3000

# 暴露端口
EXPOSE ${PORT}

# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl --fail http://localhost:${PORT} || exit 1

# 运行应用
CMD ["npm", "start"]

常见问题及解决方案

1. 镜像大小过大

  • 使用多阶段构建
  • 使用alpine基础镜像
  • 清理包管理工具的缓存
  • 只复制必要的文件

2. 构建速度慢

  • 使用Docker BuildKit
  • 合理利用缓存
  • 并行构建
  • 减少上下文大小(使用.dockerignore)

3. 容器启动失败

  • 检查CMD指令
  • 确保entrypoint脚本有执行权限
  • 检查环境变量
  • 查看容器日志

4. 缓存失效

  • 合理调整指令顺序
  • 使用ARG变量控制缓存
  • 定期清理缓存

参考资料

  1. Docker官方文档 - Dockerfile最佳实践
  2. Docker BuildKit文档
  3. Docker多阶段构建
  4. https://rock-it.pl/how-to-write-excellent-dockerfiles/
  5. https://blog.fundebug.com/2017/05/15/write-excellent-dockerfile/