Dockerfile详解(Dockerfile的基本结构、使用Dockerfile自定义镜像示例、主流编程语言和中间件的基础Docker镜像、编写Dockerfile的最佳实践)

1. 什么是Dockerfile

简单来说,Dockerfile 就是用来自定义镜像的

Dockerfile 是一个用于定义和构建 Docker 镜像的文本文件。Dockerfile 中包含一系列指令和参数,用于描述镜像的构建过程,包括基础镜像的选择、软件包的安装、文件的复制、环境变量的设置等

2. Dockerfile的基本结构

在这里插入图片描述

2.1 基础镜像(FROM)

每个 Dockerfile 的第一条指令必须是 FROM,用于指定构建新镜像所基于的基础镜像。例如:

FROM ubuntu:22.04

2.2 维护者信息(MAINTAINER 或 LABEL)

指定镜像的维护者信息。 MAINTAINER 指令已被弃用,推荐使用 LABEL 指令。例如:

LABEL maintainer="[email protected]"

2.3 工作目录(WORKDIR)

进入容器内部时默认的目录就是工作目录

设置容器内的工作目录,后续的 COPYRUN 等指令都会在这个目录下执行。例如:

WORKDIR /app
  • 可以在 Dockerfile 中多次使用 WORKDIR 指令。每次使用都会更改当前的工作目录
  • 如果指定的目录不存在,WORKDIR 会自动创建该目录

2.4 复制文件(COPY或ADD)

如果想复制多个文件到镜像中,可以使用多个 COPY 指令

将本地文件或目录复制到镜像中。例如:

COPY ./ /app/

2.4.1 COPY指令

基本语法:

COPY <src> <dest>

功能:仅用于将文件或目录从构建上下文复制到镜像中

特点:

  • 简单直观COPY 的行为非常明确,只执行复制操作
  • 不支持 URL:不能用于从远程 URL 下载文件
  • 不支持自动解压缩:不能自动解压缩压缩文件(如 .tar, .zip 等)

示例:

# 复制当前目录的所有文件到镜像的 /app 目录
COPY . /app/

# 复制单个文件到镜像的 /app 目录
COPY package.json /app/

2.4.2 ADD指令

基本语法:

ADD <src> <dest>

功能:除了复制文件或目录外,还支持从远程 URL 下载文件以及自动解压缩压缩文件

特点:

  • 支持 URL:可以从远程 URL 下载文件并复制到镜像中
  • 自动解压缩:如果源文件是压缩格式(如 .tar, .tar.gz, .zip 等),ADD 会自动解压缩文件
  • 行为复杂:由于支持多种功能,ADD 的行为可能不如 COPY 直观,容易引起混淆

示例:

# 从远程 URL 下载文件并复制到镜像中
ADD https://example.com/file.zip /app/

# 自动解压缩压缩文件
ADD file.tar.gz /app/

# 复制并自动解压缩
ADD source.tar.gz /app/

2.4.3 COPY指令和ADD指令的主要区别

功能 COPY ADD
复制本地文件/目录
从远程 URL 下载
自动解压缩压缩文件
行为明确性
推荐使用场景 简单的文件复制 需要自动解压缩或从 URL 下载

2.4.4 优先使用COPY指令还是ADD指令

在编写Dockerfile时,推荐优先使用COPY而不是ADD,原因有几个:

  1. 明确性COPY命令的功能非常明确,就是将文件从本地复制到Docker镜像中。而ADD命令除了复制文件外,还可以执行URL下载和解压操作,这可能会引入不必要的复杂性
  2. 可预测性ADD命令如果用于复制本地tar文件,会自动解压文件。这种行为可能会导致构建过程变得不那么可预测,尤其是当你不期望文件被解压时。而COPY命令总是按原样复制文件,不会进行任何额外的处理
  3. 避免潜在问题:由于ADD可以处理URL,如果URL中包含特殊字符,可能会造成构建失败。此外,如果URL指向的文件是tar文件,它会被自动解压,这可能会导致不可预见的结果
  4. 维护性:使用COPY可以使得Dockerfile更加清晰,对于其他开发者来说更容易理解。它减少了构建上下文的歧义,使得构建过程更易于维护
  5. 最佳实践:许多Docker最佳实践指南推荐使用COPY,因为它遵循了最小惊讶原则(Principle of Least Astonishment),即系统行为应该尽可能符合用户的预期

因此,除非你需要从URL下载文件或者自动解压tar文件,否则应该优先使用COPY。这样可以使 Dockerfile 更加简洁、明确和可靠

2.5 运行命令(RUN)

在镜像构建过程中执行命令,用于安装软件包或配置环境。例如:

RUN apt-get update && apt-get install -y python3

2.6 设置环境变量(ENV)

定义环境变量,可以在后续的指令中使用。例如:

ENV NAME World

2.7 暴露端口(EXPOSE)

如果想放行多个端口,可以使用多个EXPOSE指令

声明容器运行时监听的端口。例如:

EXPOSE 80

2.8 容器启动时默认执行的命令(CMD或ENTRYPOINT)

指定容器启动时默认执行的命令。例如:

ENTRYPOINT ["python3", "app.py"]

2.8.1 CMD指令

  • 在Dockerfile中,如果定义了多条CMD指令,只有最后一条会被使用。这是因为Docker在构建镜像时会忽略之前的CMD定义,仅保留最后一条
  • CMD指令的主要作用是为容器启动提供默认命令,但如果用户通过docker run命令指定了其他命令,这些默认命令会被覆盖。因此,保留最后一条CMD可以确保容器启动时有明确的默认行为

基本语法:

CMD ["executable", "param1", "param2"]  # 推荐的 exec 形式
CMD command param1 param2               # shell 形式
CMD ["param1", "param2"]               # 作为 ENTRYPOINT 的默认参数

功能:提供容器启动时默认执行的命令和参数

  • 如果在运行容器时使用了 docker run 命令并提供了参数,这些参数会覆盖 Dockerfile 中的 CMD 指令
  • CMD 可以被 docker run 命令行中指定的命令完全替代

示例:

# 使用 exec 形式
CMD ["python", "app.py"]

# 使用 shell 形式
CMD python app.py

# 作为 ENTRYPOINT 的默认参数
ENTRYPOINT ["python"]
CMD ["app.py"]

2.8.2 ENTRYPOINT指令

在Dockerfile中,如果定义了多条ENTRYPOINT指令,只有最后一条ENTRYPOINT指令会生效

基本语法:

ENTRYPOINT ["executable", "param1", "param2"]  # 推荐的 exec 形式
ENTRYPOINT command param1 param2               # shell 形式

功能:提供容器启动时执行的命令和参数,且这些参数不能被 docker run 命令行中指定的参数覆盖

  • ENTRYPOINT 指定的命令和参数是固定的,容器启动时总是会执行这些命令
  • 如果在 docker run 命令中提供了参数,这些参数会作为参数传递给 ENTRYPOINT 指定的命令,而不是替代它
  • 可以与 CMD 结合使用,CMD 提供默认的参数,ENTRYPOINT 提供固定的命令

示例:

# 使用 exec 形式
ENTRYPOINT ["python", "app.py"]

# 使用 shell 形式
ENTRYPOINT python app.py

# 结合 CMD 使用
ENTRYPOINT ["python"]
CMD ["app.py"]

2.8.3 CMD指令和ENTRYPOINT指令的主要区别

特性 CMD ENTRYPOINT
覆盖方式 可以被 docker run 命令行中指定的命令完全替代 不能被 docker run 命令行中指定的命令替代, docker run 命令中的参数会传递给 ENTRYPOINT
用途 提供默认的命令和参数 提供固定的命令,容器总是执行相同的命令
灵活性
组合使用 可以与 ENTRYPOINT 结合使用,CMD 提供默认参数 可以与 CMD 结合使用,CMD 提供默认参数
推荐使用场景 当你希望容器有默认的行为,但允许用户在运行时覆盖 当你希望容器总是执行相同的命令,不希望被覆盖

ENTRYPOINTCMD 结合使用,可以提供更灵活的配置:

  • ENTRYPOINT: 定义固定的命令
  • CMD: 提供默认的参数,可以被 docker run 命令行中指定的参数覆盖

以下是将 ENTRYPOINTCMD 结合使用的例子:

  • ENTRYPOINT:定义固定的命令
  • CMD:提供默认的参数,可以被 docker run 命令行中指定的参数覆盖
ENTRYPOINT ["python"]

CMD ["app.py"]

运行容器时未提供参数:

docker run my-python-app

容器将执行 python app.py


运行容器时提供参数:

docker run my-python-app --help

容器将执行 python --help,覆盖了 CMD 提供的默认参数 app.py

2.8.4 docker run命令行指定参数时CMD指令和ENTRYPOINT指令的区别

2.8.4.1 CMD
FROM ubuntu:22.04

CMD ["echo", "Hello, World!"]

运行容器:

docker run my-ubuntu

输出:

Hello, World!

覆盖 CMD:

docker run my-ubuntu echo "Hello, Docker!"

输出:

Hello, Docker!
2.8.4.2 ENTRYPOINT
FROM ubuntu:22.04

ENTRYPOINT ["echo", "Hello, World!"]

运行容器:

docker run my-ubuntu

输出:

Hello, World!

尝试覆盖 ENTRYPOINT:

docker run my-ubuntu echo "Hello, Docker!"

输出:

Hello, World! echo Hello, Docker!

注意ENTRYPOINT 的命令不会被覆盖,echo "Hello, Docker!" 会被当作参数传递给 echo "Hello, World!"指令

由于 echo 可以同时接收多个字符串参数,每个参数可以使用双引号包裹起来,也可以不用双引号包裹,所以 echo 和 “Hello, Docker” 分别作为两个参数传递给了 echo "Hello, World!" 指令

echo "Hello, World!" echo "Hello, Docker!"

在这里插入图片描述

2.8.5 扩展:为什么使用ENTRYPOINT指令时推荐使用数组语法的exec格式

在 Docker 中,ENTRYPOINT 指令有两种格式:

  1. exec 格式:使用数组语法,例如 ENTRYPOINT ["executable", "param1", "param2"]。这种格式是推荐的方式,因为它将 ENTRYPOINT 中的命令作为 PID 1 的进程运行,这意味着该命令的进程是容器中的顶级进程。这有助于信号传递(例如,SIGTERM)和管理容器的生命周期
  2. shell 格式:使用字符串语法,例如 ENTRYPOINT command param1 param2。这种格式会在 shell 中执行,即 /bin/sh,这意味着 ENTRYPOINT 命令会在 shell 的上下文中运行,而不是作为顶级进程

  • SIGTERM 是一个 Unix 和 Linux 操作系统中的信号,它用于通知进程终止。当 Docker 容器停止时,Docker 引擎会向容器的 PID 1(顶级进程)发送 SIGTERM 信号,以请求容器内的应用程序优雅地停止
  • 优雅地停止意味着应用程序应该有机会完成当前正在执行的操作,释放资源,保存状态,或者执行必要的清理工作,然后再退出。这是为了保证数据的一致性和系统的稳定性
  • 在 Docker 中,当使用 docker stop 命令时,Docker 引擎会先发送 SIGTERM 信号给容器。如果容器在一段时间内(默认为 10 秒)没有响应并退出,Docker 引擎会接着发送 SIGKILL 信号来强制终止容器。SIGKILL 信号无法被捕获和处理,它会立即终止进程,可能会导致数据丢失
  • 因此,编写能够正确处理 SIGTERM 信号的应用程序对于确保容器的平滑停止至关重要。在 Docker 容器中,PID 1 进程特别重要,因为它负责接收和处理信号。如果 PID 1 进程不处理 SIGTERM,容器可能不会优雅地停止。这也是为什么在 Docker 中推荐使用 exec 格式的 ENTRYPOINT,因为它允许应用程序直接作为 PID 1 进程运行,从而能够接收和处理信号

推荐使用 exec 格式的数组语法的原因:

  • 信号处理:当使用 exec 格式时,容器接收到的信号(如 SIGTERM )会直接传递给 ENTRYPOINT 命令的进程,而不是传递给 shell
  • PID 1:在 exec 格式中,ENTRYPOINT 命令的进程成为容器内的 PID 1,这是 Unix 系统中的顶级进程。它负责创建子进程、处理孤儿进程和收割僵尸进程。如果使用 shell 格式,PID 1 是 shell 进程,而不是你的应用程序,可能会导致一些潜在问题
  • 清晰性:数组语法更清晰地表示了命令和参数,避免了 shell 格式的解析和转义问题
  • 性能:使用 exec 格式可以避免启动额外的 shell 进程,提高性能并减少资源消耗

3. 使用Dockerfile自定义镜像示例

我们来做一个案例:使用 Dockerfile 构建基于 ubuntu 22.04 的镜像,安装 Python3 并输出 “Hello, world”

3.1 创建Python文件

echo "print(\"Hello, world\")" > hello.py

3.2 编写Dockerfile文件

# 使用 ubuntu 22.04 作为基础镜像
FROM ubuntu:22.04

# 设置环境变量,防止交互式提示
ENV DEBIAN_FRONTEND=noninteractive

# 更新系统包列表
RUN apt-get update && apt-get install -y --no-install-recommends \
    python3 \
    python3-pip \
    && rm -rf /var/lib/apt/lists/*

# 设置工作目录
WORKDIR /app

# 使用 COPY 指令复制本地 Python 脚本到容器的工作目录
COPY hello.py /app/

# 设置容器启动命令,运行 Python 脚本
CMD ["python3", "hello.py"]

在 Dockerfile 中设置环境变量 DEBIAN_FRONTEND=noninteractive 的目的是为了防止在安装软件包时出现交互式提示。这通常发生在运行 apt-getapt 命令时,尤其是在需要确认操作或配置选项的情况下

以下是具体的原因:

  1. 自动化构建过程:在自动化构建Docker镜像的过程中,交互式提示会中断构建流程,因为Docker无法响应这些提示。设置 DEBIAN_FRONTEND=noninteractive 可以确保安装过程完全自动化,无需人工干预
  2. 非交互式环境:Docker容器通常在后台运行,没有用户界面。因此,即使有交互式提示,用户也无法与之交互。设置该环境变量可以避免因等待用户输入而导致安装过程挂起
  3. 保持构建的一致性:交互式提示可能会导致不同的构建结果,取决于是否有用户在场进行响应。通过消除这些提示,可以确保每次构建都产生相同的结果
  4. 提高构建速度:交互式提示需要等待用户输入,这会减慢构建过程。通过设置为非交互模式,可以加快构建速度

3.3 构建镜像

  • 构建容器时 Docker 默认会将 Dockerfile 所在的目录以及 Dockerfile 所在目录的子目录作为构建上下文
  • 如果构建容器时使用啦或访问了构建上下文以外的目录或文件,Docker 将无法访问到这些文件
sudo docker build --tag ubuntu-python:1.0.0 ./

在这里插入图片描述

构建完成后,使用 docker images 命令来查看新创建的镜像:

sudo docker images

3.4 启动容器

  • 使用 --rm 选项,Docker 将在容器退出时自动删除它,这样就不会留下任何容器实例。这对于一次性任务或者测试运行非常有用,因为它们不需要持久化容器状态
  • 如果容器在运行期间创建了任何需要保留的数据,确保你已经将这些数据导出到容器外部,因为一旦容器被删除,其中的所有数据都会丢失
sudo docker run --rm ubuntu-python:1.0.0

在这里插入图片描述

4. 主流编程语言和中间件的基础Docker镜像

主流编程语言和中间件的官方通常都会提供对应的基础 Docker 镜像,这些镜像为开发者提供了构建应用程序的环境

4.1 编程语言

  1. Python
    • python:3.9-slim:Python 3.9 的 slim 版本基础镜像
    • python:3.9-alpine:Python 3.9 基于 Alpine 的轻量级基础镜像
  2. Java
    • openjdk:17-slim:OpenJDK 17 的 slim 版本基础镜像
    • openjdk:17-jdk-alpine:OpenJDK 17 基于 Alpine 的基础镜像
  3. Node.js
    • node:14-slim:Node.js 14 的 slim 版本基础镜像
    • node:14-alpine:Node.js 14 基于 Alpine 的基础镜像
  4. Go
    • golang:1.16:Go 1.16 的基础镜像
    • golang:1.16-alpine:Go 1.16 基于 Alpine 的基础镜像
  5. Ruby
    • ruby:2.7-slim:Ruby 2.7 的 slim 版本基础镜像
    • ruby:2.7-alpine:Ruby 2.7 基于 Alpine 的基础镜像
  6. PHP
    • php:7.4-fpm-slim:PHP 7.4 的 FPM slim 版本基础镜像
    • php:7.4-fpm-alpine:PHP 7.4 的 FPM 基于 Alpine 的基础镜像

4.2 中间件

  1. Apache
    • httpd:2.4-alpine:Apache HTTP Server 2.4 基于 Alpine 的基础镜像
  2. Nginx
    • nginx:latest:最新版本的 Nginx 基础镜像
    • nginx:alpine:Nginx 基于 Alpine 的基础镜像
  3. MySQL
    • mysql:8.0:MySQL 8.0 的基础镜像
    • mysql:5.7:MySQL 5.7 的基础镜像
  4. PostgreSQL
    • postgres:13:PostgreSQL 13 的基础镜像
    • postgres:alpine:PostgreSQL 基于 Alpine 的基础镜像
  5. MongoDB
    • mongo:4.4:MongoDB 4.4 的基础镜像
    • mongo:4.4-xenial:MongoDB 4.4 基于 Ubuntu Xenial 的基础镜像
  6. Redis
    • redis:6.0:Redis 6.0 的基础镜像
    • redis:6.0-alpine:Redis 6.0 基于 Alpine 的基础镜像

4.3 带有slim后缀的镜像与没有slim后缀的镜像的区别

带有slim 后缀的 Docker 镜像与没有 slim 后缀的镜像的主要区别在于它们的体积和包含的工具集:

  1. 体积更小slim 镜像通常体积更小,因为它们移除了许多非必需的包和工具,只保留了运行特定应用程序所必需的部分。这有助于减少存储需求和加快镜像的下载速度
  2. 工具集有限:由于 slim 镜像移除了许多非必需的工具,所以在这些镜像中可能没有一些常用的命令行工具,如 vim, curl, wget

如果你的应用程序不需要这些被移除的工具,那么使用 slim 镜像通常不会影响你的应用程序的正常运行。实际上,使用 slim 镜像可能会让你的应用程序部署更快,因为镜像体积更小

5. 编写Dockerfile的最佳实践

5.1 选择合适的基础镜像

5.1.1 使用官方镜像

优先选择官方维护的基础镜像,如 python, node, openjdk 等。这些镜像经过严格测试和安全审查

FROM node:18-alpine

5.1.2 选择轻量级镜像

使用 slimalpine 版本可以显著减小镜像体积。例如,python:3.11-slimnode:18-alpine

5.2 多阶段构建

  • 多阶段构建中的去除过程是由Docker自身完成的
  • 当定义一个多阶段构建的Dockerfile时,Docker会按照Dockerfile中的指令顺序执行构建过程,并且只保留最后一个FROM指令所定义的镜像

使用多阶段构建可以将构建工具和依赖留在中间阶段,而最终镜像只包含运行时所需的文件

这样做可以显著减小镜像的体积,因为不需要在最终镜像中包含构建工具和依赖,这些通常占用了大量的空间

此外,由于最终镜像中只包含运行时所需的文件,这也提高了安全性,因为攻击者无法利用构建工具和依赖进行攻击

# 使用Maven 3.8.6和OpenJDK 17的基础镜像作为构建环境
FROM maven:3.8.6-openjdk-17 AS builder

# 设置工作目录为 /app
WORKDIR /app

# 将 pom.xml 文件从构建上下文复制到 /app 目录下
COPY pom.xml ./

# 执行 Maven 命令 dependency:go-offline,下载所有依赖并将它们缓存起来,以便在没有网络的情况下也可以进行构建
RUN mvn dependency:go-offline

# 将构建上下文中的所有文件(除了已经复制的 pom.xml)复制到 /app 目录下
COPY ./ ./

# 执行 Maven 命令 package -DskipTests,打包应用,同时跳过测试
RUN mvn package -DskipTests

# 使用 OpenJDK 17 的精简版镜像作为运行环境的基础镜像
FROM openjdk:17-jdk-slim

# 从第一阶段(名为 builder)的 /app/target/myapp.jar 复制到当前镜像的 /usr/local/bin/myapp.jar
COPY --from=builder /app/target/myapp.jar /usr/local/bin/myapp.jar

# 设置容器启动时执行的命令。这里,容器启动时会运行 JAR 文件,作为应用程序的入口点
CMD ["java", "-jar", "/usr/local/bin/myapp.jar"]

5.3 减少镜像层数

合并相关的 RUN, COPY, ADD 指令,使用 && 将多个命令串联起来,以减少镜像层数

RUN apt-get update && apt-get install -y \
    curl \
    vim \
    && rm -rf /var/lib/apt/lists/*

5.4 优化镜像体积

5.4.1 清理缓存

在安装软件包后,清理包管理器缓存。例如,在 Debian/Ubuntu 系统中,使用 apt-get cleanrm -rf /var/lib/apt/lists/* 清理缓存

RUN apt-get update && apt-get install -y \
    curl \
    vim \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

5.4.2 合并指令

  • 在Dockerfile中,每个RUN指令都会在镜像中创建一个新的层
  • 当构建镜像时,Docker会为每个RUN指令执行的操作创建一个临时的容器,并在该容器中执行指令
  • 执行完成后,Docker会将容器的文件系统保存为一个层,并将其添加到镜像中

将多个 RUN 指令合并为一个,减少镜像层数

RUN apt-get update && apt-get install -y \
    curl \
    vim \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

5.5 遵循单一职责原则

一个容器只运行一个进程,避免将多个服务打包在一个容器中

5.6 使用标签和环境变量

  • ENV:设置环境变量,避免硬编码敏感信息
  • LABEL:为镜像添加元数据,如维护者信息、用途等

5.7 使用 .dockerignore文件

创建一个 .dockerignore 文件,排除不必要的文件和目录:

  • 创建文件:在的 Dockerfile 所在的目录下创建一个名为 .dockerignore 的文件
  • 编写规则:在 .dockerignore 文件中,每一行包含一个需要排除的文件或目录的路径
node_modules
*.log

6. 构建镜像时可能遇到的问题

6.1 问题呈现

在使用 docker build 指令构建镜像时,可能会遇到以下警告信息


DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
Install the buildx component to build images with BuildKit:
https://docs.docker.com/go/buildx/

在这里插入图片描述

6.2 问题产生的原因

这个警告信息表明 Docker 正在使用传统的构建器(legacy builder),而传统的构建起将在未来的版本中被弃用

Docker 推荐使用 BuildKit,这是一个更现代、更高效的构建系统,用于构建 Docker 镜像

6.3 解决方法

参考我的另一篇博文:使用docker build命令构建镜像时遇到警告信息 The legacy builder is deprecated and will be removed in a future release