1. Docker中镜像与容器简要回顾
1.1 镜像(Image)
Docker镜像是一个轻量级、可执行的独立软件包,它包含运行某个软件所需的所有内容,包括代码、运行时环境、库、环境变量和配置文件。
镜像是静态的,即在创建后内容不会改变,它们是构建容器的基础。
1.2 容器(Container)
容器是镜像的运行实例。当镜像被启动时,它在隔离的环境中运行,成为一个或多个正在运行的容器。
容器隔离并运行单独的应用,保证应用运行在快速、可重复的环境中。
2. 镜像的分层原理
2.1 分层存储
2.1.1 分层存储的工作原理
Docker镜像是由一系列只读的层(layer)组成的。这些层按照从下到上的顺序堆叠,每一层都是基于下面一层的变化。当我们创建或更新一个镜像时,只有被改变的部分会被添加为一个新层,其他部分保持不变。
这种分层存储的机制使得Docker镜像可以被高效地构建、传输和存储。
2.1.2 层的创建过程
通常,我们使用Dockerfile来定义和创建Docker镜像。Dockerfile中的每一条指令(如FROM
,RUN
,COPY
等)都会创建一个新的层。
例如,考虑以下Dockerfile:
FROM ubuntu:22.04 RUN apt-get update && apt-get install -y python3 COPY app.py /app/ CMD ["python3", "/app/app.py"]
这个Dockerfile会创建四个层:
- 基础层:从
ubuntu:22.04
镜像开始。 - 第二层:运行
apt-get update && apt-get install -y python3
,安装Python3。 - 第三层:将
app.py
文件复制到镜像的/app/
目录。 - 第四层:指定容器启动时运行的命令
python3 /app/app.py
。
2.1.3 层的存储与复用
这些层都是只读的,并且每一层只存储与前一层的差异部分。当我们构建一个新镜像时,Docker会检查每一层的内容是否已经存在。如果一个层与现有的层完全相同,Docker会直接复用这个层,而不是重新创建。
这种机制大大提高了构建和存储效率。例如,如果你有多个Dockerfile都基于ubuntu:22.04
,那么这个基础层只需要存储一次,所有的镜像都可以共享它。
2.1.4 容器的可写层
当一个容器从镜像启动时,Docker会在镜像的顶部添加一个新的可写层。容器对文件系统的所有改变,如创建新文件,修改现有文件,删除文件等,都会被记录在这个可写层中。
这个可写层允许多个容器共享同一个镜像,同时又保持各自的状态。当容器被删除时,可写层也会被删除,而底层的镜像保持不变。
通过详细解释分层存储的工作原理、层的创建过程、层的存储与复用,以及容器的可写层,我们可以更深入地理解Docker如何利用分层存储来优化镜像的构建、存储和运行效率。
2.2 重用与共享
2.2.1 共享机制的优点
Docker的分层存储机制不仅优化了镜像的存储,还促进了镜像层的 重用和共享。这种共享机制有以下优点:
- 节省存储空间:多个镜像可以共享相同的层,而无需重复存储,大大节省了存储空间。
- 加速镜像构建:当构建一个新镜像时,如果所需的层已经存在,Docker会直接使用现有的层,而不是重新创建,从而加速了构建过程。
- 加速镜像分发:当从Docker仓库拉取镜像时,如果某些层已经存在于本地,只需拉取缺失的层,减少了网络传输的数据量,加速了镜像分发。
2.2.2 共享的实现方式
Docker通过以下方式实现层的共享:
- 内容寻址:每个层都有一个唯一的哈希值,这个哈希值是根据层的内容计算出来的。如果两个层的内容完全相同,它们的哈希值也相同。Docker使用这个哈希值来判断两个层是否相同。
- 层的复用:当构建一个新镜像时,Docker会检查每一层的哈希值。如果一个层的哈希值与现有的层相同,Docker会直接使用现有的层,而不是重新创建。
- 共享层的存储:所有的镜像层都存储在Docker主机的文件系统中。如果多个镜像共享一个层,这个层在文件系统中只存储一次,所有的镜像都引用这个共享的层。
2.2.3 共享的示例
现在考虑一个具体的例子来说明Docker的层共享是如何工作的。假设我们有两个Dockerfile,它们都基于ubuntu:22.04
镜像:
Dockerfile1:
FROM python:3.11 RUN pip install django COPY app1/ /app/ WORKDIR /app CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
Dockerfile2:
FROM python:3.11 RUN pip install django pillow COPY app2/ /app/ WORKDIR /app CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
当我们构建这两个镜像时,Docker会创建以下层:
- 基础层(
python:3.11
) - Django层(
RUN pip install django
) - Pillow层(
RUN pip install django pillow
) - 应用1层(
COPY app1/ /app/
) - 应用2层(
COPY app2/ /app/
)
其中,基础层和Django层是共享的,因为它们在两个Dockerfile中是相同的。Pillow层、应用1层和应用2层是独立的,因为它们在每个Dockerfile中是不同的。
当这两个镜像都构建完成后,Docker只需在文件系统中存储一次基础层和Django层,而不是两次。这样就节省了存储空间,并且加速了镜像的构建和分发过程。
3. 镜像大小问题
Docker镜像的大小是一个重要的考量因素,因为它直接影响到镜像的存储、传输和部署效率。镜像越大,占用的存储空间越多,传输和部署的时间也越长。因此,优化镜像大小是Docker使用中的一个重要课题。
3.1 基础镜像的大小
基础镜像是构建其他镜像的起点,它通常包含操作系统的核心文件和一些常用的库。基础镜像的大小会直接影响到所有基于它构建的镜像的大小。
例如,如果你选择了一个 包含完整操作系统的基础镜像 ,如ubuntu:22.04,那么你的镜像大小会比选择一个 最小化的基础镜像 ,如alpine,要大很多。
因此,在选择基础镜像时,我们需要权衡功能完整性和镜像大小。如果你的应用不需要完整的操作系统,选择一个最小化的基础镜像可以显著减小最终镜像的大小。
3.2 软件依赖
除了基础镜像,应用运行所需的软件库和其他依赖也会增加镜像的大小。每个 RUN 指令安装的软件包,每个COPY或ADD指令添加的文件,都会增加镜像的大小。
为了减小镜像大小,我们可以采取以下措施:
- 只安装必要的软件包:仔细评估每个软件包的必要性,只安装运行应用所必需的包。
- 清理安装缓存:在安装软件包后,删除下载的软件包缓存,如
/var/cache/apt/archives/
。 - 合并RUN指令:尽可能将多个RUN指令合并为一个,这样可以减少镜像的层数,从而减小镜像大小。
- 使用
.dockerignore
文件:在COPY或ADD文件时,使用.dockerignore
文件排除不必要的文件和目录,减小镜像大小。
3.3 多阶段构建
多阶段构建是Docker提供的一个功能,它允许我们在一个Dockerfile中使用多个FROM语句,每个FROM语句都可以使用不同的基础镜像,并且每个阶段都可以从前一个阶段复制文件。
这个功能对于需要构建依赖项但最终镜像不需要这些依赖项的情况非常有用。通过多阶段构建,我们可以在一个阶段安装所有必要的依赖项并构建应用,然后在另一个阶段只复制构建好的应用,而不复制构建依赖项。这样可以显著减小最终镜像的大小。
以下是一个使用多阶段构建例子:
# 构建阶段 FROM node:18 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # 运行阶段 FROM node:18-alpine WORKDIR /app COPY --from=builder /app/dist ./dist COPY package*.json ./ RUN npm ci --only=production CMD ["node", "dist/main.js"]
在这个例子中:
- 第一阶段使用node:18镜像作为构建环境。它复制
package.json
和package-lock.json
,运行npm ci
安装所有依赖项(包括开发依赖项),然后复制其余的应用代码并运行npm run build
构建应用。 - 第二阶段使用node:18-alpine镜像作为基础,这是一个更小的Node.js镜像。它从第一阶段复制构建好的应用代码(在dist目录中),复制package.json和package-lock.json,然后运行npm ci --only=production只安装生产依赖项。最后,它指定了容器启动时运行的命令。
这样,最终的镜像就只包含运行应用所需的代码和生产依赖项,而不包含用于构建的代码和开发依赖项,从而大大减小了镜像的大小。
再看一个例子:
# 构建阶段 FROM python:3.11 AS builder WORKDIR /app COPY requirements.txt . RUN pip install --user -r requirements.txt COPY . . # 运行阶段 FROM python:3.11-alpine WORKDIR /app COPY --from=builder /root/.local /root/.local COPY --from=builder /app . ENV PATH=/root/.local/bin:$PATH CMD ["python", "main.py"]
在这个例子中:
- 第一阶段安装所有的依赖项(包括开发依赖项);
- 第二阶段只复制安装好的依赖项和应用代码,使用更小的python:3.11-alpine镜像。
4. 联合文件系统
联合文件系统(Union File System,简称 UnionFS)是 Docker 镜像的基石。它是一种分层、轻量级并且高性能的文件系统,支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下。
4.1 工作原理
UnionFS 的工作原理是将多个不同的目录(也叫分支)内容联合挂载到同一个目录下,并呈现为单个一致的文件系统。这些分支可以是只读的,也可以是可读写的。当对这个虚拟文件系统进行修改时,实际上是在可写的分支上进行操作,而原始的文件并没有被修改。
在 Docker 中,镜像就是由多个只读层组成的。当我们启动一个容器时,Docker 会在这些只读层之上添加一个可读写层。当容器修改现有的文件时,该文件将被复制到可读写层,并在那里被修改。这种机制被称为写时复制(Copy-on-Write)。
4.2 UnionFS的优点
使用 UnionFS 有以下几个优点:
- 节省存储空间:多个镜像可以共享相同的只读层,从而减少磁盘占用。
- 加速镜像构建和部署:当构建一个新镜像时,只需要构建与现有镜像不同的层,相同的层可以直接复用。这大大加速了镜像的构建和部署过程。
- 促进镜像的分发:由于镜像是分层存储的,当我们下载一个镜像时,实际上只需要下载我们本地没有的层。这减少了网络传输的数据量,加速了镜像的分发。
4.3 加载过程
当启动一个容器时,Docker 会从镜像的底层开始,依次加载每一层文件系统,直到最顶层的可读写层。这个过程可以看作是一个 UnionFS 的加载过程。
假设我们有一个包含三个层的镜像:一个基础层,一个中间层,和一个顶层。当我们基于这个镜像启动一个容器时,Docker 会:
- 加载基础层,这通常是一个操作系统的文件系统,如 Ubuntu 的文件系统。
- 加载中间层,这可能包含一些基本的工具和库,如 Python 解释器。
- 加载顶层,这通常包含我们的应用代码和配置。
- 添加一个可读写层,用于存储容器运行时的修改。
这四层被 UnionFS 挂载到同一个目录下,呈现为一个完整的文件系统给容器使用。当容器修改文件时,修改会被写入可读写层。
5. 镜像构建过程
Docker 镜像是通过 Dockerfile 文件来定义和构建的。Dockerfile 是一个文本文件,其中包含了一系列指令,告诉 Docker 如何构建镜像。在构建过程中,Docker 会读取 Dockerfile 中的指令,并按照指令的顺序逐步执行,最终生成一个新的镜像。
5.1 分层构建
Docker 的镜像构建过程是分层的。每执行一条 Dockerfile 指令,都会在当前镜像的顶部创建一个新的层。这种分层构建的机制有以下优点:
复用层:不同的镜像可以共享相同的层,减少磁盘占用和加速构建过程。
缓存:如果 Dockerfile 的指令和上下文没有改变,Docker 可以使用缓存的层,无需重新执行指令。
最小化变更:由于每个层都是独立的,对镜像的修改可以仅限于某些层,而不影响其他层。
举个例子,假设我们有以下的 Dockerfile:
FROM ubuntu:22.04 RUN apt-get update && apt-get install -y python3 COPY app.py /app/ CMD ["python3", "/app/app.py"]
当我们构建这个镜像时,Docker 会:
- 从 ubuntu:22.04 镜像开始,这是第一层。
- 执行 RUN 指令,在第一层的基础上创建第二层,该层包含了更新的软件包列表和安装的 Python3。
- 执行 COPY 指令,在第二层的基础上创建第三层,该层包含了复制到镜像中的
app.py
文件。 - 执行 CMD 指令,在第三层的基础上创建第四层,该层指定了容器启动时运行的默认命令。
每一层都只包含了与前一层的差异部分,而不是完整的文件系统。这种分层构建的方式大大提高了构建和存储的效率。
5.2 缓存利用
为了加速构建过程,Docker 会尽可能地利用缓存。当我们重新构建一个镜像时,Docker 会检查每个指令的缓存情况:
- 如果该指令和上下文没有改变,且存在可用的缓存层,Docker 会直接使用缓存层,而不会重新执行该指令。
- 如果该指令或上下文发生了改变,Docker 会重新执行该指令,并为后续的指令
invalidate
缓存。
这意味着,如果我们修改了 Dockerfile 中的一条指令,该指令之后的所有指令都会被重新执行,而该指令之前的指令如果有缓存则会直接使用缓存。
=> 因此,为了最大限度地利用缓存,我们应该:
将最不“likely to change”的指令放在 Dockerfile 的前面,如 FROM,LABEL 等。
将最likely to change的指令放在 Dockerfile 的后面,如 COPY,ADD 等。
合并 RUN 指令,以减少层数和利用缓存。
例如,不要这样写:
RUN apt-get update RUN apt-get install -y python3
而应该这样写:
RUN apt-get update && apt-get install -y python3
这样,如果 apt-get update
的结果没有变化,apt-get install
就可以直接使用缓存,而不需要重新执行。
5.3 构建上下文
当我们执行 docker build 命令时,当前目录被称为构建上下文(build context)。Docker 会将构建上下文中的文件发送到 Docker daemon,daemon 根据 Dockerfile 中的指令构建镜像。
这意味着,Dockerfile 中的 COPY 和 ADD 指令只能复制构建上下文中的文件。如果我们试图复制上下文之外的文件,会得到一个错误。
为了减小构建上下文的大小,提高构建效率,我们应该:
将 Dockerfile 放在一个空目录或者项目根目录中。
使用 .dockerignore 文件排除不需要的文件和目录,如 .git,node_modules 等。
避免使用 ADD 指令自动解压缩归档文件,而是在 RUN 指令中显式地解压缩。
例如,假设我们有以下的项目结构:
. ├── .git ├── .dockerignore ├── Dockerfile ├── app.py └── README.md
我们可以在 .dockerignore 文件中添加以下内容:
.git README.md
然后,我们的 Dockerfile 可以这样写:
FROM python:3.9 COPY app.py /app/ CMD ["python", "/app/app.py"]
这样,当我们执行 docker build
命令时,只有 app.py
文件会被发送到 Docker daemon,而 .git
目录和 README.md
文件会被排除在构建上下文之外,从而减小了构建上下文的大小,提高了构建效率。
6. 基础镜像与依赖
Docker 的镜像存储和复用机制是其高效性和灵活性的关键所在。通过巧妙的设计,Docker 在存储和运行镜像时,最大限度地节省了存储空间,提高了运行效率。
6.1 存储优化
得益于 Docker 的分层存储机制,相同的镜像层只需在磁盘上存储一次,不同的镜像可以共享这些层,从而大大节省了存储空间。
假设我们有两个镜像:镜像A和镜像B,它们都基于相同的基础镜像,如 Ubuntu。
在此基础上,镜像A安装了 Python,而镜像B安装了 Python 和 Node.js。在 Docker 的存储中,这两个镜像的层结构可能如下:
镜像A:
Layer 3: Python Layer 2: Ubuntu 基础镜像 Layer 1: Boot FS
镜像B:
Layer 4: Node.js Layer 3: Python Layer 2: Ubuntu 基础镜像 Layer 1: Boot FS
可以看到,Layer 1 和 Layer 2 (即 Boot FS 和 Ubuntu 基础镜像) 在两个镜像中是完全相同的。Docker 在存储时,只需在磁盘上存储一份这两个层的数据,两个镜像都可以引用这两个层。这样,无论我们有多少个基于 Ubuntu 的镜像,Ubuntu 基础镜像层只需存储一次。
这种存储优化机制使得 Docker 镜像的存储非常高效。即使我们有大量的镜像,只要它们共享一些相同的层,实际占用的磁盘空间就会大大减少。
6.2 运行效率
Docker 的分层存储机制不仅优化了存储,也提高了镜像的运行效率。当我们从一个镜像启动容器时,Docker 只需要在镜像的顶部添加一个可写层,而镜像的其他层都是只读的,可以被多个容器共享。
这意味着,当我们启动多个基于相同镜像的容器时,这些容器可以共享镜像的只读层。这些只读层已经存在于本地磁盘上,无需重新下载或创建。Docker 只需为每个容器创建一个新的可写层。
这种机制大大加速了容器的启动过程。因为大部分数据都已经在本地镜像中准备好了,Docker 不需要在每次启动容器时都去下载或复制这些数据。
此外,由于容器共享镜像的只读层,启动多个容器并不会显著增加内存占用。每个容器只需要一些内存来维护自己的状态和可写层。
7. 基础镜像与依赖
在 Docker 的镜像构建过程中,基础镜像和软件依赖扮演着至关重要的角色。它们共同构成了应用运行所需的完整环境。
7.1 基础镜像(Base Image)
基础镜像是构建其他镜像的起点。它通常包含以下内容:
- 操作系统的核心文件,如 Linux 的文件系统层次结构、基本命令和工具等。
- 常用的系统库,如 glibc、OpenSSL 等。
- 包管理工具,如 apt、yum、apk 等,用于安装其他软件包。
常见的基础镜像如:
- Ubuntu、Debian、CentOS 等通用操作系统镜像。
- Alpine,一个面向安全的轻型 Linux 发行版,常用于构建最小化的镜像。
- Busybox,一个集成了数百个 Unix 工具的单个可执行文件,常用于构建极小的镜像。
选择合适的基础镜像需要考虑以下因素:
- 应用的兼容性:应用需要哪些特定版本的库和工具。
- 镜像大小:选择最小化的基础镜像有助于减小最终镜像的大小。
- 安全性:及时更新基础镜像,以包含最新的安全补丁。
7.2 软件依赖层
在基础镜像之上,我们需要安装应用运行所需的特定软件包和工具,如编程语言解释器、数据库、Web服务器等。这些软件依赖构成了镜像的上层。
管理软件依赖时应该注意:
- 显式指定版本:在安装软件包时,明确指定所需的版本,以确保构建的可重复性。
- 使用官方源:从官方源或可信的第三方源安装软件包,以确保软件包的完整性和安全性。
- 清理缓存:安装完软件包后,删除下载的软件包缓存,以减小镜像的大小。
- 合并层:尽可能将多个相关的操作合并到一个层中,以减少层数和镜像大小。
FROM python:3.11 ENV PYTHONUNBUFFERED 1 ENV PYTHONDONTWRITEBYTECODE 1 RUN pip install --upgrade pip RUN pip install django==4.2.1 # 应用代码将在后续层中添加
在这个例子中,我们选择 python:3.11 作为基础镜像,它已经包含了 Python 3.11 解释器和 pip 包管理器。
- 我们设置了两个环境变量
:PYTHONUNBUFFERED
和PYTHONDONTWRITEBYTECODE
,以优化 Python 的运行表现。 - 然后,我们使用
pip
升级了pip
自身,并安装了指定版本(4.2.1)的 Django。 - 应用代码将在后续的层中通过 COPY 指令添加。
这个例子展示了如何在 Python 3.11 的基础镜像上,通过明确指定版本号的方式安装 Django 依赖,构建一个适用于 Django 应用的 Docker 镜像。
通过合理地组织基础镜像和软件依赖层,我们可以构建出结构清晰、易于维护、安全高效的 Docker 镜像,为运行 Django 应用提供一个稳定的环境。