logo
0
0
WeChat Login

知识库实训课程

仓库目录结构

. ├── .cnb │ └── settings.yml # 知识库角色配置 ├── .cnb.yml # CNB 配置文件 ├── README.md ├── assets # 图片资源 └── knowledge # 知识库参考文件目录 └── huangmenji.md └── xxx

问题示例

  • 黄焖鸡怎么做?
  • Dockerfile 为什么如此重要呢?

如何创建知识库

1. Fork 本仓库

fork 本仓库到自己的组织下 fork

2. 添加参考文件

knowledge目录下添加任意 markdown 格式参考文件 add-md 仓库中.cnb.yml文件中已经内置知识库插件:

main: push: - stages: - name: build knowledge base image: cnbcool/knowledge-base settings: include: "knowledge/**.md"

提交的 markdown 文件会被自动处理,并生成知识库

3. 观察知识库处理结果

点击仓库云原生构建页面,查看构建过程及结果 cloud

查看最终结果 knowledge-base

4. 点击知识库按钮进行问答

knowledge-base-button

提问后,观察是否成功引用知识库中的文件 answer

如何完成大作业

本仓库的README文件已经内置好问题,即Dockerfile 为什么如此重要呢? 需要参考据配置知识库的过程,将 Docker训练营课程仓库(链接)的与此文件关联的README.md文件添加到知识库参考文件中,使得仓库的“知识库”回答这个问题时能够正确引用所添加的参考文件。

复制md文档时,请确保文件和Docker训练营课程仓库文件内容完全一致,fork的仓库记得同步上游

如何提交大作业

向本仓库提交 Pull Request(合并请求),命名格式为:姓名-大作业,例如:张三-大作业

提交完之后,本仓库流水线会自动向你Fork的仓库知识库发送请求,并且检测能否引用参考文献来回答Dockerfile 为什么如此重要呢?这个问题。

点击本仓库的云原生构建页面,查看构建过程及结果。

提交前如何自测

搜集相关md文件放入knowledge目录下,来到仓库的web页面,直接点击README文件的此问题,观察知识库是否正确引用参考文件进行回答 self-test

Docker 基础

CNB 云原生开发环境中已经预装了 Docker,无需手动安装,直接体验即可。

查看 Docker 信息

docker version # 查看版本信息 docker info # 查看运行时信息

运行第一个容器:hello-world

docker run hello-world

学习一门新语言,第一个程序是输出 hello world!
学习 Docker,第一个容器运行输出 hello from Docker!

案例:运行 Alpine Linux 容器

扩展知识
Alpine 镜像在企业生产环境中被广泛应用:

  • 极简的 Linux 发行版
  • 只包含基本命令和工具
  • 镜像体积小(约 8MB)
  • 内置包管理系统 apk
  • 常用作其他镜像的基础

镜像操作

1. 拉取镜像

# 拉取 alpine 镜像,默认以 latest 标签拉取 docker pull alpine

2. 查看镜像

docker image ls

镜像格式:[REGISTRY_HOST[:PORT]/]PATH[:TAG]

  • REGISTRY_HOST:镜像仓库地址,默认 Docker Hub docker.io
  • PORT:registry 端口
  • PATH:镜像路径,对于 Docker Hub 镜像,路径为 [NAMESPACE/]REPOSITORY。其中 NAMESPACE 指的是用户或者组织名,若未指定则默认为 library
  • TAG:镜像标签,默认 latest

完整镜像示例:

  1. alpine

等同于 docker.io/library/alpine:latest

  • REGISTRY_HOST: docker.io
  • NAMESPACE: library
  • REPOSITORY: alpine
  • TAG: latest
  1. docker.cnb.cool/docker-666/campus-academy-template/dev-env:latest
  • REGISTRY_HOST: docker.cnb.cool
  • NAMESPACE: docker-666/campus-academy-template
  • REPOSITORY: dev-env
  • TAG: latest

示例:

# docker image ls REPOSITORY TAG IMAGE ID CREATED SIZE docker.cnb.cool/docker-666/campus-academy-template/dev-env latest 890755180ee4 7 hours ago 722MB mongo 6 bdc4e039b30b 8 weeks ago 764MB

3. 镜像可见性

镜像分为两种类型:

  • Public:无需登录即可拉取
  • Private:需要登录后才能拉取
# 公开镜像(正常拉取) docker pull docker.cnb.cool/coldenn/docker-open-camp/docker-exercises/my-alpine:latest # 私有镜像(拉取失败) docker pull docker.cnb.cool/docker-open-camp/private-repo/my-alpine

4. 登录镜像仓库

docker login [-u ${username}] [-p ${password}] ${repository}

5. 删除镜像

docker image rm ${image_id}

6. 查看镜像历史

docker history ${image_id}

容器操作

1. 运行容器

docker run alpine

2. 查看容器

docker ps

问题:为什么看不到刚启动的容器?
原因:容器没有前台进程会立即退出

查看所有容器(包括已停止的):

docker ps -a

3. 交互式运行容器

docker run -it alpine # 等效于 docker run -it alpine /bin/sh # 查看镜像默认命令 docker inspect alpine | grep -A 5 "Cmd"

参数说明:

  • -it:分配交互式终端(interactive + TTY)
  • -d:后台运行容器(detached mode)

run -it 命令会启动一个交互式终端并附着到容器的 PID=1 进程,当退出终端(即 PID=1 进程结束)时,容器也会停止。如果希望容器在后台运行,可以使用 -d 参数。

进入容器:

# 以交互模式进入容器(推荐) docker exec -it <container_id> /bin/bash # 如果容器没有 bash,可以尝试 sh # 注意:scratch 镜像没有任何 shell,无法通过 exec 进入 docker exec -it <container_id> /bin/sh # 执行单条命令(不进入交互模式) docker exec <container_id> ls -la /app

4. 后台运行容器

docker run -d alpine sleep 3600

注意docker run -d alpine 会立即退出,因为 Alpine 默认没有前台进程。需要指定一个持续运行的命令(如 sleep 3600)来保持容器运行。

5. 容器生命周期管理

docker stop <container_id> # 停止容器 docker start <container_id> # 启动已停止的容器 docker restart <container_id> # 重启容器 docker rm <container_id> # 删除容器

6. 进入运行中的容器

docker exec -it <container_id> /bin/sh

进程查看示例:

/ # ps -ef PID USER TIME COMMAND 1 root 0:00 /bin/sh 13 root 0:00 /bin/sh 19 root 0:00 ps -ef

PID=1 的进程为 /bin/sh, 而另一个 /bin/sh 进程则是我们通过 exec 命令启动的,这个进程退出不会影响 PID=1 的进程,也就不会导致容器的退出

7. 替代进入方式(不推荐)

docker attach <container_id>

注意
attach 会接管 PID=1 的进程,如果该进程退出,容器也会退出

8. 查看容器详情(如 alpine 容器)

docker inspect <container_id>

9. 查看容器日志

docker logs <container_id>

附录

以下为 Docker 进阶知识,帮助深入理解 Docker 的底层原理和工程实践。

Docker DevOps

Docker 使用场景

序号使用场景核心价值解决的问题Docker方案
1单体应用微服务化渐进式拆分大系统难维护、难扩展逐步拆分为微服务容器
2加速应用开发环境一致性新人上手慢、并行开发难提供标准化开发容器
3基础设施即代码可重复配置配置漂移、环境不一致Dockerfile定义即代码
4多环境标准化部署消除差异"我机器上能跑"问题同一镜像多环境部署
5松耦合架构故障隔离单点故障影响全局服务容器化隔离
6多租户隔离安全隔离租户间相互影响容器级租户隔离
7加速CI/CD流水线并行构建构建部署太慢容器化CI/CD并行执行
8隔离应用环境依赖隔离多项目依赖冲突每项目独立容器
9应用跨平台分发可移植性跨平台部署复杂镜像打包一次到处跑
10混合云/多云部署云平台无关厂商锁定风险容器化实现云无关
11降低IT成本资源高效服务器资源浪费容器高密度部署
12灾难恢复快速恢复故障停机时间长预构建镜像快速启动
13简化弹性扩展按需扩缩扩容流程复杂耗时K8s自动扩缩容
14依赖管理依赖打包依赖地狱版本混乱依赖打包进镜像
15提升安全性容器隔离攻击面大隔离不足容器隔离+权限控制

拓展

OCI 镜像规范

OCI 定义的镜像包括4个部分:镜像索引(Image Index)、清单(Manifest)、配置(Configuration)和层文件(Layers)。镜像索引是镜像中可选择的部分,一个镜像可以不包括镜像索引。如果镜像包含了镜像索引,则其作用主要指向镜像不同平台的版本,代表一组同名且相关的镜像,差别只在支持的体系架构上。

Docker 镜像存储

docker_image_storage

Docker pull 和 Docker push 发生了什么

Docker pull

Docker push

镜像分层 OverlayFS

镜像分层结构

OverlayFS 的核心概念

OverlayFS 是一种联合文件系统(Union Filesystem),它通过堆叠多层目录实现文件系统的叠加,主要分为:

  • Lower Dir(镜像层):只读的基础层(可以是多个,对应 Docker 镜像的每一层)。
  • Upper Dir(容器层):可写层,容器运行时新增或修改的文件会存储在这里。
  • Merged Dir(合并视图):用户看到的最终统一文件系统,是上下层叠加后的结果。同名文件上层覆盖下层,访问文件时优先从 upperdir 读取。

OverlayFS 的工作示例

假设镜像层(lowerdir)有一个文件 /galaxy,而容器层(upperdir)也创建了同名文件:

# 镜像层(只读) lowerdir/ └── galaxy # 内容:"Hello from image" # 容器层(可写) upperdir/ └── galaxy # 内容:"Hello from container" # 用户看到的合并视图 merged/ └── galaxy # 实际显示 "Hello from container"(上层覆盖下层)

当删除容器层的 galaxy 文件时,容器层会创建 whiteout 文件:

# 容器层 upperdir/ └── galaxy # 特殊字符文件表示删除 # 合并视图 mergeddir/ └── galaxy # 文件消失(实际被隐藏)

Docker 如何使用 OverlayFS

  • 镜像层:Docker 镜像的每一层(如 FROM alpineRUN apk add)都是 lowerdir。
  • 容器层:启动容器时创建的 upperdir 是可写层,存储所有运行时修改。
  • 性能优化:OverlayFS 通过写时复制(Copy-on-Write)避免直接修改镜像层,提升效率。

镜像层与容器层

验证 Docker 的存储驱动

运行以下命令查看 Docker 是否使用 OverlayFS:

docker info | grep "Storage Driver"

输出示例:

Storage Driver: overlay2

Namespace 和 cgroups

Namespace 和 cgroups 是 Linux 内核提供的两种资源隔离技术,用于实现容器的资源隔离和限制。

  • Namespace 用于隔离进程的系统资源,包括进程树、网络、用户、主机名、挂载点、进程 ID 等。
  • cgroups 用于限制进程的资源使用,包括 CPU、内存、磁盘 I/O、网络带宽等。 namespace-cgroups

自定义镜像之 Dockerfile 详解

上节课我们学习了 Docker 概述,并实操理解了 Docker 三个核心概念:镜像、容器、仓库。 并且我们已经会使用 Docker 官方提供的镜像,启动容器。

但是这些镜像不能满足我们的需求,比如想定制一个个性化的环境,安装一些特定的软件,这个时候就需要我们自定义镜像。

本节课我们会学习两种自定义镜像的方式,一种是命令式创建镜像,一种是声明式创建镜像。 我们先从最简单的镜像创建方式开始,命令式创建镜像。

命令式创建镜像(从容器创建镜像)

案例

创建一个自定义镜像,基于 alpine 镜像,并安装 figlet 工具。 (figlet:输出艺术字符串的小工具)

1. 启动一个基础容器

docker run -it --name alpine alpine

2. 向容器中安装 figlet 工具

然后我们进入容器,在容器中执行一些命令(安装一个软件),然后退出容器。

docker exec -it alpine /bin/sh apk update apk add figlet exit

这样,我们就在 alpine 容器中安装了 figlet 工具。

3. 保存容器为镜像

然后我们需要将这个新的容器环境跟其他人分享,我们可以通过 commit 命令将容器保存为一个镜像。

docker ps -a #查看容器 docker commit ${container_id} alpine-figlet

这样,我们就创建了一个名为 alpine-figlet 的镜像。

4. 使用新镜像

最后我们就可以使用这个新的镜像了, 运行体验下艺术字生成的效果。

docker run alpine-figlet figlet "hello docker"

5. 推送镜像

最后我们也可以使用 docker push 命令将镜像推送到镜像仓库中,其他人便可以使用 docker pull 来使用这个镜像了。

上述从容器创建镜像的方式虽然简单易懂,但是考虑真实项目中,我们可能需要安装很多工具,比如 git,vim,curl,wget 等等。如果我们每次都使用这种方式来创建镜像,就会非常麻烦,并且容易出错。

命令式创建的局限性

  1. 不可重复性:容器安装过程依赖人工操作,无法保证环境一致性
  2. 臃肿镜像:容器可能包含临时文件/缓存,导致镜像体积膨胀
  3. 安全风险:无法追溯安装过程,可能存在安全隐患
  4. 维护困难:无法版本化管理构建步骤

因此,我们需要一种更加方便的镜像创建方式,这就是 Dockerfile。

声明式创建镜像(Dockerfile)

案例

我们来使用 Dockerfile 来自定义一个同样的镜像。它的格式是:

docker build -t alpine-figlet-from-dockerfile . docker run alpine-figlet-from-dockerfile figlet "hello docker"

这样当我们需要安装 git 的时候,只需要修改 Dockerfile 中的命令后重新构建镜像即可。

docker build -t alpine-figlet-from-dockerfile . docker run alpine-figlet-from-dockerfile git

什么是构建上下文

docker build [OPTIONS] PATH | URL | - ^^^^^^^^^^^^^^

本课程只讨论本地构建,可以指定相对或者绝对文件路径。

Docker 会从构建上下文中寻找文件名为 Dockerfile 的文件,没有这个文件则需要使用 -f 参数来指定 Dockerfile 的路径。 如果把这个目录作为构建上下文,那么 Docker 会在构建时将整个目录传递给 Docker Daemon。

提示:这也是为什么需要 .dockerignore 文件的原因 — 避免将不必要的文件(如 node_modules.git)发送给 Daemon,拖慢构建速度。

小结

关于 Dockerfile,上节课我们介绍部署技术历史中提及过,它的出现,帮助 Docker 成为容器化时代下最受欢迎的方案。

那它到底是什么呢?

Dockerfile 是一种静态文件,用来声明镜像的内容。

Dockerfile 为什么如此重要呢?

Dockerfile 给容器化实践提供了一种规范,让创建镜像的操作简单化、标准化。 简单化让开发者可以快速上手,标准化让镜像可以重复使用、可移植、可复用。这些好处从侧面上推动了 Docker 的普及。

Dockerfile 实践 & 关键语法介绍

为了上手书写 Dockerfile,我们还要学习它的语法。我们通过两个案例来学习。

使用 Dockerfile 构建一个 jupyter notebook 镜像

让我们使用 Docker 来构建一个真实可用的镜像,比如 jupyter notebook 镜像。Dockerfile

docker build -t jupyter-sample jupyter_sample/

该镜像使用 RUN 指令来安装 jupyter notebook,使用 WORKDIR 指令设置工作目录, 使用 COPY 指令将代码复制到镜像中,使用 EXPOSE 指令来暴露端口, 最后使用 CMD 指令来启动 jupyter notebook 服务。

使用上述镜像来启动 jupyter notebook 服务。

docker run -d -p 8888:8888 jupyter-sample

我们使用了 -p 参数来将容器内的 8888 端口映射到宿主机的 8888 端口,在 cnb 上我们可以通过添加一个端口映射来实现外网访问。

port_forward

点击这个浏览器图标,就可以访问 jupyter notebook 服务了。

使用多阶段构建来打包一个 golang 应用

在实际开发中,我们经常需要构建 golang 应用。 如果使用传统的单阶段构建,最终的镜像会包含整个 Go 开发环境,导致镜像体积非常大。 通过多阶段构建,我们可以创建一个非常小的生产镜像。

创建一个 main.go 文件, 一个普通构建的 Dockerfile 以及一个多阶段构建的 Dockerfile

构建镜像:

docker build -t golang-demo-single -f golang_sample/Dockerfile.single golang_sample/ docker build -t golang-demo-multi -f golang_sample/Dockerfile.multi golang_sample/

运行容器:

docker run -d -p 8080:8080 golang-demo-single docker run -d -p 8081:8081 golang-demo-multi

容器运行成功后可以通过如下命令行来访问,可以看到两个容器都是在运行我们写的 golang 服务。

curl http://localhost:8080 curl http://localhost:8081

让我们来对比一下单阶段构建和多阶段构建的区别:

# 查看镜像大小 docker images | grep golang-demo

你会发现最终的镜像只有几十 MB,而如果使用单阶段构建(直接使用 golang 镜像),镜像大小会超过 1GB。这就是多阶段构建的优势:

  • 最终镜像只包含运行时必需的文件
  • 不包含源代码和构建工具,提高了安全性
  • 大大减小了镜像体积,节省存储空间和网络带宽

这种构建方式特别适合 Go 应用,因为 Go 可以编译成单一的静态二进制文件。在实际开发中,我们可以使用这种方式来构建和部署高效的容器化 Go 应用。

Dockerfile 命令

Dockerfile_commands

构建过程

  • 每个保留关键字(指令)都必须是大写字母
  • 从上到下顺序执行
  • "#" 表示注释
  • 每一个指令都会创建并提交一个新的镜像层

CMD 和 ENTRYPOINT 的区别

  • CMD:指定容器启动时要运行的命令,只有最后一个 CMD 会生效,且可被 docker run 的参数覆盖
  • ENTRYPOINT:指定容器启动时要运行的命令,docker run 的参数会作为额外参数追加
# CMD 示例:可被覆盖 CMD ["echo", "hello"] # docker run image echo world → 输出 world(CMD 被替换) # ENTRYPOINT 示例:参数追加 ENTRYPOINT ["echo"] # docker run image hello → 输出 hello(hello 作为参数追加)

Dockerfile 优化技巧

  • 层合并:合并 RUN 指令减少镜像层数
    RUN apk update && \ apk add --no-cache figlet git && \ rm -rf /var/cache/apk/*
  • 多阶段构建:构建多个镜像层,最后只保留最终的镜像层
  • 使用 .dockerignore 文件:忽略不需要的文件,减少构建上下文体积
  • 避免硬编码敏感信息:不要在 Dockerfile 中写入密码等敏感信息,推荐在运行时通过环境变量注入
    # 运行时注入敏感信息(推荐) docker run -e DB_PASSWORD=xxx image

    注意:避免使用 ARG + ENV 将密码写入镜像,因为 docker history 可以查看到明文值。

    # 反例:密码会明文保留在镜像层中 ARG DB_PASSWORD ENV DB_PASSWORD=${DB_PASSWORD}

    构建时传入 docker build --build-arg DB_PASSWORD=secret .,之后任何人执行 docker history 即可看到密码明文。

  • 使用特定版本的基础镜像:避免因基础镜像更新导致的不稳定性
    # 明确指定版本 FROM alpine:3.14

Docker 存储管理详解

Docker 容器在运行时会产生大量数据,这些数据如何持久化和管理是一个重要的话题。 本节我们将通过一个 Nginx Web 服务器的案例,来深入探讨 Docker 的三种数据管理方式。

Docker 存储基础

Docker 提供了三种主要的数据管理方式:

  1. 默认存储:容器内的数据随容器删除而丢失
  2. Bind Mounts(绑定挂载):将主机上的目录或文件直接挂载到容器中
  3. Volumes(卷):由 Docker 管理的持久化存储空间,完全独立于容器的生命周期

让我们通过一个 Nginx Web 服务器的例子来理解这三种方式的区别。我们将在每种方式下执行相同的操作:创建一个 HTML 文件,然后测试数据的持久性。

场景一:默认存储(非持久化)

在这个场景中,我们直接在容器内创建文件,看看数据会发生什么:

# 运行一个 nginx 容器 docker run -d --name web-default -p 8000:80 nginx # 在容器中创建一个测试页面 docker exec -it web-default /bin/sh echo "<h1>Hello from Default Storage</h1>" > /usr/share/nginx/html/index.html exit # 访问页面验证内容 curl http://localhost:8000 # 获取容器在宿主机上的实际存储路径(MergedDir) docker inspect -f '{{.GraphDriver.Data.MergedDir}}' <container_id> # 删除容器 docker stop web-default docker rm web-default # 用同样的配置重新运行容器 docker run -d --name web-default-2 -p 8000:80 nginx # 再次访问页面,内容不存在 curl http://localhost:8000

关于 MergedDir:容器目录本质上是宿主机目录的一个子目录,通过 Linux 的 chroot 技术实现隔离。当容器使用联合文件系统(如 overlay2)时,MergedDir 是所有镜像层最终合并后在宿主机上的实际路径。容器目录对宿主机隔离,一般禁止直接访问。

为什么数据会丢失?理解 Docker 的分层存储

Docker 使用 OverlayFS(联合文件系统) 来管理容器的文件系统,它由多个层组成:

┌─────────────────────────────────┐ │ 容器可写层(Container Layer) │ ← 你在容器中创建/修改的文件都在这里 │ 生命周期 = 容器生命周期 │ ← 容器删除时,这一层也被删除! ├─────────────────────────────────┤ │ 镜像只读层 3(Nginx 配置) │ ├─────────────────────────────────┤ │ 镜像只读层 2(Nginx 程序) │ ← 这些层是只读的,多个容器共享 ├─────────────────────────────────┤ │ 镜像只读层 1(基础系统) │ └─────────────────────────────────┘

工作原理:

  • 当你 docker run 时,Docker 在镜像层之上创建一个可写层
  • 容器内的所有写操作(创建文件、修改文件)都发生在这个可写层
  • 当你 docker rm 删除容器时,可写层随之删除,所有修改都丢失
  • 镜像层保持不变,下次创建新容器时又是一个"干净"的状态

这就是为什么:

  • web-default 中创建的 index.html 存储在容器的可写层
  • 删除 web-default 后,可写层被清除
  • web-default-2 是一个全新的容器,有自己的空白可写层,所以看不到之前的文件

Volume 和 Bind Mount 的本质:它们都是绕过可写层,将数据直接存储在容器外部(宿主机),因此不受容器生命周期影响。

场景二:使用 Bind Mount

Bind Mount 将宿主机上的指定目录直接映射到容器内部。数据实际存储在宿主机的文件系统上,容器删除后宿主机上的文件依然存在。

# 创建本地目录 mkdir nginx-content # 运行 Nginx 容器并挂载本地目录 docker run -d --name web-bind \ -p 8081:80 \ -v $(pwd)/nginx-content:/usr/share/nginx/html nginx # 在容器中创建一个测试页面 docker exec -it web-bind sh -c 'echo "<h1>Hello from Bind Mounts</h1>" > /usr/share/nginx/html/index.html' # 访问页面验证内容 curl http://localhost:8081 # 删除容器 docker rm -f web-bind # 用同样的配置重新运行容器 docker run -d --name web-bind-2 -p 8081:80 \ -v $(pwd)/nginx-content:/usr/share/nginx/html nginx # 再次访问页面,内容仍然存在 curl http://localhost:8081

场景三:使用 Volume

Volume 是由 Docker 引擎管理的存储区域,数据存储在 Docker 的管理目录中(默认 /var/lib/docker/volumes/),完全独立于容器生命周期。相比 Bind Mount,Volume 不依赖宿主机的目录结构,更适合生产环境。

# 创建一个 Docker volume docker volume create nginx_data # 运行 Nginx 容器并挂载卷 docker run -d --name web-volume -p 8082:80 -v nginx_data:/usr/share/nginx/html nginx # 在容器中创建一个测试页面 docker exec -it web-volume sh -c 'echo "<h1>Hello from Volume Storage</h1>" > /usr/share/nginx/html/index.html' # 访问页面验证内容 curl http://localhost:8082 # 删除容器 docker rm -f web-volume # 用同样的配置重新运行容器 docker run -d --name web-volume-2 -p 8082:80 \ -v nginx_data:/usr/share/nginx/html nginx # 再次访问页面,内容仍然存在 curl http://localhost:8082 # 查看卷的详细信息 docker volume ls docker volume inspect nginx_data

三种方式的对比

  1. 默认存储

    • 数据随容器删除而丢失
    • 适合存储临时数据
    • 容器间数据隔离
    • 无需额外配置
  2. Bind Mount

    • 优点:数据持久化,存储在主机指定位置
    • 优点:可以直接在主机上修改文件,开发调试方便
    • 不足:目录权限不对等,有安全风险
    • 不足:依赖主机文件系统结构,可移植性差
  3. Volume

    • 数据持久化,独立于容器生命周期
    • 数据存储在 Docker 管理区域,安全性好
    • 支持多容器共享同一个 Volume
    • 可通过 Docker CLI 管理(备份、迁移、删除)

清理操作

完成实验后,可以进行清理:

# 清理容器 docker rm -f web-default web-volume web-volume-2 web-bind web-bind-2 # 清理卷 docker volume rm nginx_data # 清理本地目录 rm -rf nginx-content

存储最佳实践

场景推荐方式原因
数据库持久化Volume性能好、Docker 管理、便于备份
开发环境代码同步Bind Mount实时修改、IDE 友好
配置文件注入Bind Mount(只读)安全、灵活
临时缓存tmpfs内存存储、容器停止即清理
容器日志默认 + 日志驱动避免存储膨胀

tmpfs vs 默认存储:两者都是非持久化存储,但有本质区别:

对比维度默认存储(容器可写层)tmpfs
存储位置宿主机磁盘宿主机内存
生命周期容器删除docker rm)时丢失容器停止docker stop)时即丢失
读写速度磁盘 I/O内存级别(快数个数量级)
是否落盘否,数据从不写入磁盘
数据残留风险docker rm 仅标记磁盘块为可用,实际数据未被覆写,理论上可通过磁盘恢复工具找回:数据仅存在于内存,停止即彻底消失
适用场景普通临时文件(日志、pid)敏感信息临时存放、高频读写缓存
# tmpfs 示例:在容器内 /app/cache 挂载 64MB 内存文件系统 docker run -d --name tmpfs-demo --tmpfs /app/cache:rw,size=64m nginx

安全建议:密码、token、密钥等敏感信息应使用 tmpfs 而非默认存储,确保数据从不落盘,避免容器删除后敏感数据仍残留在宿主机磁盘上。

实践案例:使用 Volume 部署 MySQL 数据库

我们将通过一个 MySQL 数据库的例子来演示如何使用 Volume 持久化数据。

创建并管理 Volume

# 创建一个数据卷,名称为 mysql_data docker volume create mysql_data # 列出所有卷 docker volume ls # 查看卷信息 docker volume inspect mysql_data

运行 MySQL 容器,并挂载卷

# 运行 MySQL 容器并挂载卷 # 备注:MYSQL_ROOT_PASSWORD 是环境变量,用于设置 MySQL 的 root 用户密码 docker run -d \ --name mysql_db \ -e MYSQL_ROOT_PASSWORD=mysecret \ -v mysql_data:/var/lib/mysql \ mysql:8.0

安全提示:此处密码仅用于演示。生产环境中应使用 Docker Secrets 或外部密钥管理服务来注入敏感信息,避免明文暴露。

# 进入容器创建测试数据 docker exec -it mysql_db mysql -uroot -pmysecret -h127.0.0.1 # 在 MySQL 中创建测试数据 mysql> CREATE DATABASE test_db; mysql> USE test_db; mysql> CREATE TABLE users (id INT, name VARCHAR(50)); mysql> INSERT INTO users VALUES (1, 'John Doe'); mysql> exit

验证数据持久化

# 删除原容器 docker rm -f mysql_db # 使用同一个卷启动新容器 docker run -d \ --name mysql_db2 \ -e MYSQL_ROOT_PASSWORD=mysecret \ -v mysql_data:/var/lib/mysql \ mysql:8.0 # 验证数据是否存在 docker exec -it mysql_db2 \ mysql -uroot -pmysecret -e "USE test_db; SELECT * FROM users;"

总结

Docker 提供了多种数据管理方式,核心原则是:需要持久化的数据不应存放在容器可写层中。开发环境优先使用 Bind Mount 实现代码实时同步,生产环境优先使用 Volume 保障数据安全和可管理性。根据实际场景选择合适的存储方式,是构建稳定容器化应用的关键。

Docker 网络管理详解

Docker 网络是容器通信的基础设施,它使容器能够安全地进行互联互通。在 Docker 中,每个容器都可以被分配到一个或多个网络中,容器可以通过网络进行通信,就像物理机或虚拟机在网络中通信一样。

目录

Docker 网络命令详解

在开始学习不同类型的网络之前,我们先来了解一下 Docker 的常用网络命令:

# 列出所有网络 docker network ls # 创建自定义网络 docker network create [options] <network-name> # 检查网络详情 docker network inspect <network-name> # 将容器连接到网络 docker network connect <network-name> <container-name> # 断开容器与网络的连接 docker network disconnect <network-name> <container-name> # 删除网络 docker network rm <network-name> # 删除所有未使用的网络 docker network prune

网络类型及实践案例

容器默认网络

新创建一个容器时,会默认连接到一个叫"默认 Bridge"的网络。而所有连接该网络的容器可以通过这座"桥梁"通信。

Q:这个"默认 Bridge 网络"是什么?是谁创建的?它的通信原理是?

A:它是由 Docker 自动创建的,是一个名为 docker0 的 Linux 网桥(使用 ip addr 命令可以看到),功能上就像是一个虚拟的交换机,将容器相互连接,容器之间可以通过这个网桥进行通信。连接到它的每个容器都会被分配一个 IP 地址,相互之间可以通过 IP 地址进行通信。

Docker0

Q:除了 Bridge 网络,还有哪些网络类型?

A:除了 Bridge 网络,Docker 还支持 Host 网络、None 网络和自定义网络。Host 网络直接将容器连接到主机网络,None 网络禁用了容器的网络功能,自定义网络则允许用户创建自己的网络。这些网络类型各有优缺点,可以根据实际需求选择使用。接下来,我们将分别介绍这些网络类型及其实践案例。

1. Bridge 网络

实践案例一:默认 Bridge 网络

让我们先来看看默认 bridge 网络的行为:

# 启动两个 nginx 容器 docker run -d --name container1 nginx docker run -d --name container2 nginx # 查看默认 bridge 网络的 ID docker network ls docker network inspect bridge -f '{{.ID}}' # 或者 docker network inspect bridge | jq '.[0] | {Id, Name, Driver}' # 查看容器是否连接到默认 bridge 网络 docker network inspect bridge -f '{{.Containers}}' docker inspect container1 -f '{{range .NetworkSettings.Networks}}{{.NetworkID}}{{end}}' docker inspect container2 -f '{{range .NetworkSettings.Networks}}{{.NetworkID}}{{end}}' # 查看容器的 IP 地址 docker inspect container1 -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' docker inspect container2 -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' # 进入容器1,尝试通过 IP 访问容器2(IP 需根据上一步的实际输出替换) docker exec -it container1 curl http://172.17.0.3 # 注意:在默认 bridge 网络中,无法通过容器名称访问 docker exec -it container1 curl http://container2 # 这将失败

在默认 bridge 网络下,容器之间只能通过 IP 地址互相访问,不支持通过容器名称来通信。通过 IP 访问需要记住容器的 IP 地址,这显然不是个好办法。

实践案例二:自定义 Bridge 网络

为了解决这个限制,Docker 提供了用户自定义 bridge 网络的功能。 通过创建自定义 bridge 网络,容器可通过稳定的名称直接互访,无需依赖 IP 地址,从而简化了记忆 IP 的难度。

接下来,我们在案例中使用自定义网络:尝试将两个容器连接到同一个网络,然后通过容器名称进行通信:

# 创建自定义 bridge 网络 docker network create \ --driver bridge \ my-bridge-network # 启动两个容器,连接到自定义网络 docker run -d \ --name custom-container1 \ --network my-bridge-network \ nginx docker run -d \ --name custom-container2 \ --network my-bridge-network \ nginx # 现在可以通过容器名称访问 docker exec -it custom-container1 curl http://custom-container2

Docker 中默认的 bridge 网络与自定义 bridge 网络在容器名称解析上的差异,核心原因在于 DNS 服务的启用机制网络配置的隔离性。默认的 bridge 网络(即 docker0 虚拟网桥)不提供容器名称解析功能。容器间若需通信,必须通过 IP 地址。因此默认网络下的容器 IP 可能因重启或容器重建而变化,导致依赖 IP 的通信失效。

而自定义 bridge 网络启用 Docker 内置的 DNS 服务器(127.0.0.11),容器可通过名称直接解析其他容器的 IP 地址。容器启动时,Docker 自动将 /etc/resolv.conf中的 DNS 服务器指向 127.0.0.11

# Generated by Docker Engine. # This file can be edited; Docker Engine will not make further changes once it # has been modified. nameserver 127.0.0.11 options ndots:0 # Based on host file: '/etc/resolv.conf' (internal resolver) # ExtServers: [10.235.16.19 183.60.83.19 183.60.82.98] # Overrides: [nameservers] # Option ndots from: host

而使用默认 bridge 网络创建的容器,/etc/resolv.conf 文件中的内容如下( nameserver 是 Docker 引擎根据宿主机 DNS 配置自动生成的):

# Generated by Docker Engine. # This file can be edited; Docker Engine will not make further changes once it # has been modified. nameserver 10.235.16.19 nameserver 183.60.83.19 nameserver 183.60.82.98 options ndots:0 # Based on host file: '/etc/resolv.conf' (legacy) # Overrides: [nameservers] # Option ndots from: host

运行完两个实践案例后运行 ip addr

或者 ip -c -brief addr,一行显示一个网络接口,简洁直观

或者 ip -j addr | jq,JSON 格式输出

ip addr 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 2: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default link/ether 02:42:04:e2:12:94 brd ff:ff:ff:ff:ff:ff inet 172.18.0.1/16 brd 172.18.255.255 scope global docker0 valid_lft forever preferred_lft forever inet6 fe80::42:4ff:fee2:1294/64 scope link valid_lft forever preferred_lft forever 4: veth05895da@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default link/ether 4a:ef:37:ba:83:f7 brd ff:ff:ff:ff:ff:ff link-netnsid 1 inet6 fe80::48ef:37ff:feba:83f7/64 scope link valid_lft forever preferred_lft forever 6: veth6c3945d@if5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default link/ether 9a:7f:92:75:ab:4c brd ff:ff:ff:ff:ff:ff link-netnsid 2 inet6 fe80::987f:92ff:fe75:ab4c/64 scope link valid_lft forever preferred_lft forever 7: br-b99d1aa4ad94: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default link/ether 02:42:8a:15:98:5a brd ff:ff:ff:ff:ff:ff inet 172.19.0.1/16 brd 172.19.255.255 scope global br-b99d1aa4ad94 valid_lft forever preferred_lft forever inet6 fe80::42:8aff:fe15:985a/64 scope link valid_lft forever preferred_lft forever 9: vethf02e559@if8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master br-b99d1aa4ad94 state UP group default link/ether 8a:27:a3:8e:3f:61 brd ff:ff:ff:ff:ff:ff link-netnsid 3 inet6 fe80::8827:a3ff:fe8e:3f61/64 scope link valid_lft forever preferred_lft forever 11: vethdd4a1c7@if10: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master br-b99d1aa4ad94 state UP group default link/ether 5e:d7:fd:f7:7b:b6 brd ff:ff:ff:ff:ff:ff link-netnsid 4 inet6 fe80::5cd7:fdff:fef7:7bb6/64 scope link valid_lft forever preferred_lft forever 363437: eth0@if363438: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default link/ether 02:42:ac:11:00:10 brd ff:ff:ff:ff:ff:ff link-netnsid 0 inet 172.17.0.16/16 brd 172.17.255.255 scope global eth0 valid_lft forever preferred_lft forever

在 container1 中执行 ip addr

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 3: eth0@if4: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default link/ether 02:42:ac:12:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0 inet 172.18.0.2/16 brd 172.18.255.255 scope global eth0 valid_lft forever preferred_lft forever

在 custom-container1 中执行 ip addr

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 8: eth0@if9: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default link/ether 02:42:ac:13:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0 inet 172.19.0.2/16 brd 172.19.255.255 scope global eth0 valid_lft forever preferred_lft forever

网络结构图解

┌──────────────────────────────────────────────────────────────────────────────────┐ │ 宿主机 (Host) │ ├──────────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────────────────────┐ ┌──────────────────────────────────┐ │ │ │ [if2] docker0 (默认网桥) │ │ [if7] br-xxx (自定义网桥) │ │ │ │ 172.18.0.1/16 │ │ 172.19.0.1/16 │ │ │ ├──────────────────────────────────┤ ├──────────────────────────────────┤ │ │ │ [if4] veth05895da ──┐ │ │ [if9] vethf02e559 ──┐ │ │ │ │ [if6] veth6c3945d ──┤ │ │ [if11] vethdd4a1c7 ──┤ │ │ │ └───────────────────────┼──────────┘ └────────────────────────┼─────────┘ │ │ │ │ │ │ ┌───────────┴───────────┐ ┌───────────┴──────────┐ │ │ │ │ │ │ │ │ ┌───────────▼──────────┐ ┌──────────▼──────────┐ ┌──▼──────────────┐ ┌─────▼────────────┐ │ │ container1 │ │ container2 │ │ custom-container1│ │ custom-container2│ │ │ [if3] eth0@if4 │ │ [if5] eth0@if6 │ │ [if8] eth0@if9 │ │ [if10] eth0@if11 │ │ │ 172.18.0.2 │ │ 172.18.0.3 │ │ 172.19.0.2 │ │ 172.19.0.3│ │ └──────────────────────┘ └─────────────────────┘ └─────────────────┘ └──────────────────┘ │ │ └──────────────────────────────────────────────────────────────────────────────────┘

关键点解读

1. 两个网桥,互相隔离

网桥名称连接的容器
docker0默认 bridge 网络container1, container2
br-b99d1aa4ad94自定义 bridge 网络custom-container1, custom-container2

2. ifindex(接口序号)

ip addr 输出中每行开头的数字就是 ifindex(interface index),它是内核为每个网络接口分配的唯一标识:

1: lo ← 序号 1 2: docker0 ← 序号 2 4: veth05895da@if3 ← 序号 4(序号 3 的接口已被删除,不会回收) 7: br-b99d1aa4ad94 ← 序号 7

ifindex 有三个关键特性:

  • 全局唯一:同一网络命名空间内不会重复
  • 单调递增:每创建一个新接口序号 +1,永不回退
  • 删除不回收:接口销毁后序号作废,因此输出中会出现不连续的数字(如缺少 3、5)

@ifX 的含义:表示 veth pair 对端的 ifindex,是定位"网线另一头"的关键依据:

4: veth05895da@if3 ↑ 本端 ifindex=4(在宿主机) ↑ 对端 ifindex=3(在容器内,即容器的 eth0)

3. eth0 与 veth

  • eth0 是容器内部看到的主网络接口,就像一台电脑的网卡。每个容器都有自己独立的 eth0,这是 Linux Network Namespace 隔离的结果——容器以为自己独占一套网络栈。
  • veth(Virtual Ethernet)是成对创建的虚拟网络设备。一端在容器内(就是 eth0),另一端在宿主机上(就是 vethXXX)。
容器命名空间 宿主机命名空间 ┌─────────────┐ ┌──────────────────────────┐ │ eth0 (if3) │◄── veth pair ──►│ veth05895da (if4) │ │ │ 同一对设备的两端 │ │ master=docker0 │ └─────────────┘ │ ▼ │ │ docker0 (网桥) │──► 外部网络 └──────────────────────────┘
概念物理世界类比
eth0电脑上的网口
vethXXX网线的另一头(插在交换机上)
docker0 / br-xxx交换机
veth pair一根网线(两头不可分离)

关键点eth0vethXXX 是同一个 veth pair 的两端——从容器内看叫 eth0,从宿主机看叫 vethXXX。数据从容器 eth0 发出后自动到达宿主机的 vethXXX,再经网桥转发到目标。每创建一个容器,Docker 就创建一对 veth,容器删除时这对 veth 一起销毁。

4. veth pair 对应关系

每个容器通过 veth pair 连接到网桥,就像一根虚拟网线的两端:

容器内: eth0@if4 <──────────────> 宿主机: veth05895da@if3 └─ 一对虚拟网卡,像一根网线的两端 ─┘

对应关系(通过 @ifX 序号匹配): 4 个 veth* 接口 ↔ 4 个运行中的容器

容器容器内网卡宿主机 veth所属网桥
container1eth0@if4veth05895da@if3docker0
container2eth0@if6veth6c3945d@if5docker0
custom-container1eth0@if9vethf02e559@if8br-b99d1aa4ad94
custom-container2eth0@if11vethdd4a1c7@if10br-b99d1aa4ad94

5. 自定义网络不使用 docker0

ip addr 输出可以清楚看到,veth 设备的 master 字段指明了所属网桥:

# 默认网络的容器 → master docker0 veth05895da@if3: ... master docker0 ... veth6c3945d@if5: ... master docker0 ... # 自定义网络的容器 → master br-b99d1aa4ad94 vethf02e559@if8: ... master br-b99d1aa4ad94 ... vethdd4a1c7@if10: ... master br-b99d1aa4ad94 ...

这就是为什么不同 bridge 网络的容器默认无法互通——它们连接在不同的虚拟交换机上。

2. Host 网络

Host 网络移除了容器和 Docker 主机之间的网络隔离,直接使用主机的网络。

特点:

  • 最佳网络性能
  • 直接使用主机的网络栈
  • 没有网络隔离
  • 端口直接绑定到主机上

实践案例:使用 Host 网络运行 Nginx 服务器

# 启动一个 Nginx 容器,使用 host 网络(为了避免端口冲突,容器1 启动 alpine/curl 即可) docker run -itd \ --name host1 \ --network host \ alpine \ sh -c "apk add --no-cache curl && sh" docker run -d \ --name host2 \ --network host \ nginx # 查询宿主机端口 eth0 是 HOST_IP ip addr # 登入容器1,通过宿主机ip访问容器2 docker exec -it host1 curl http://${HOST_IP}:80 # 注意:因为容器使用主机的网络端口,而主机的端口一旦使用,就不能再被其他容器使用,否则会提示端口冲突。 docker run -d \ --name host-3 \ --network host \ nginx docker logs host-3 # 精简镜像可能没有 ip/ifconfig:可以临时安装 # Debian/Ubuntu:apt-get update && apt-get install -y iproute2

3. Container 网络

Container 网络模式允许一个容器共享另一个容器的网络命名空间,两个容器将拥有相同的 IP 地址、网络接口和端口空间。

特点:

  • 多个容器共享同一个网络栈
  • 共享 IP 地址和端口空间
  • 容器间可通过 localhost 直接通信
  • Kubernetes Pod 多容器网络的实现基础

应用场景:

  • Sidecar 模式(如日志收集、代理)
  • 需要紧密网络协作的容器组
  • 模拟 Kubernetes Pod 行为

实践案例:两个容器共享网络命名空间

# 1. 启动网络提供者容器(运行 nginx) docker run -d --name net-provider nginx # 2. 启动网络消费者,共享网络(后台运行,保持容器存活) docker run -d --name net-consumer --network container:net-provider alpine sleep 3600 # 3. 在 net-consumer 容器内,通过 localhost 访问 nginx docker exec net-consumer wget -qO- http://localhost:80 # 输出: nginx 欢迎页面 HTML # 4. 查看 net-provider 的 IP 地址 docker inspect net-provider -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' # 输出示例: 172.17.0.2 # 5. 在 net-consumer 容器内查看网络接口 docker exec net-consumer ip addr # 输出示例: # 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 ... # inet 127.0.0.1/8 scope host lo # 18: eth0@if19: <BROADCAST,MULTICAST,UP,LOWER_UP> ... # inet 172.17.0.2/16 ... ← 与 net-provider 的 IP 完全相同 # 6. 确认 net-consumer 的网络模式 docker inspect net-consumer -f '{{.HostConfig.NetworkMode}}' # 输出: container:<net-provider-id> # 7. 验证端口空间共享(80端口已被 nginx 占用) docker exec net-consumer sh -c "nc -l -p 80" # 输出: nc: bind: Address already in use # 说明两个容器共享同一个端口空间

与其他网络模式对比:

特性Bridge 网络Host 网络Container 网络
网络隔离容器间隔离无隔离共享容器间无隔离
IP 地址各自独立使用宿主机 IP共享同一 IP
localhost 通信不可直接通信与宿主机共享可直接通信
端口冲突与宿主机冲突共享容器间冲突

Kubernetes Pod 原理:K8s Pod 中的多个容器就是使用类似 Container 网络的机制,共享同一个网络命名空间,因此 Pod 内的容器可以通过 localhost 相互访问。

# 清理 docker rm -f net-provider net-consumer

4. None 网络

None 网络完全禁用了容器的网络功能,容器在这个网络中没有任何外部网络接口。

特点:

  • 完全隔离的网络环境
  • 容器没有网络接口
  • 适用于不需要网络的批处理任务

实践案例:使用 None 网络运行独立计算任务

# 运行一个计算密集型任务,不需要网络 docker run --network none alpine sh -c 'for i in $(seq 1 10); do echo $((i*i)); done'

5. Overlay 网络

Overlay 网络是 Docker 用于实现跨主机容器通信的网络驱动,主要用于 Docker Swarm 集群环境。它通过在不同主机的物理网络之上创建虚拟网络,使用 VXLAN 技术在主机间建立隧道,从而实现容器间的透明通信。

特点:

  • 支持跨主机容器通信
  • 使用 VXLAN 技术建立隧道
  • 每个容器获得虚拟 IP
  • 支持网络加密
  • 提供负载均衡和服务发现

应用场景:

  • 微服务架构
  • 分布式应用
  • 分布式数据库集群
  • 消息队列集群
  • 需要跨主机通信的容器化应用

在 Overlay 网络中,容器之间可以直接通过虚拟 IP 进行通信,而不需要关心容器具体运行在哪个主机上。Overlay 网络支持网络加密,能确保跨主机通信的安全性, 同时还提供了负载均衡和服务发现等特性,是构建大规模容器集群的重要基础设施。

注意:Overlay 网络需要 Docker Swarm 或其他集群编排工具支持,本课程环境为单机,因此仅做原理介绍。

6. macvlan/ipvlan 网络

让容器获得"局域网中的独立 IP",适合对接传统网络;需网络环境与路由配置到位。

注意:macvlan/ipvlan 网络依赖特定的网络环境配置(如网卡混杂模式、路由规则),本课程仅做原理介绍。

课后实践

实际案例:使用自定义 Bridge 网络演示 Web 应用与 Redis 通信

自定义 bridge 网络是目前 Docker 网络通信中最常用的方式。下面通过一个实际案例来演示自定义 bridge 网络的使用。

前置准备:本案例需要使用 web-app 目录下的代码和 Dockerfile,请确保该目录存在。

# 创建web应用的镜像 docker build -t web-app web-app # 创建自定义bridge网络 docker network create my-bridge-network # 启动 Redis 容器 docker run -d \ --name redis-server \ --network my-bridge-network \ redis:alpine # 启动 Web 应用容器 docker run -d \ --name web-app \ --network my-bridge-network \ -p 5000:5000 \ web-app
# 访问应用 curl http://localhost:5000 # 多次访问,观察计数器增加 curl http://localhost:5000 curl http://localhost:5000 # 查看 Redis 中的数据 docker exec -it redis-server redis-cli get hits

清理环境

# 删除课后实践容器 docker rm -f web-app redis-server docker rm -f custom-container1 custom-container2 # 删除默认 bridge 案例容器 docker rm -f container1 container2 # 删除 Host 网络案例容器 docker rm -f host1 host2 host-3 # 删除自定义网络 docker network rm my-bridge-network # 删除镜像 docker rmi web-app redis:alpine

Docker Compose 实践

1. 引言

在前面的课程中,我们学习了如何使用 Docker 容器来运行单个服务。 通过 docker run 命令,我们可以快速启动一个数据库、一个 Web 服务器或者一个缓存服务。这种方式在开发简单应用时非常有效。

然而,随着应用架构的演进,微服务的理念逐渐流行,一个应用由多个服务共同组成。

案例

本节课我们准备了 一个多服务的web应用 的案例如下:

web应用包含四个独立服务

  • Nginx: 路由转发服务
  • 前端: React框架开发 提供前端页面服务
  • 后端: Node.js开发 提供后端API服务
  • 数据库: MongoDB 提供数据库服务

应用架构

四个服务共同组成了应用,整体架构图如下:

试想一下,我们为了部署这个 Web 应用,需要完成哪些工作?

  1. 为四个服务分别构建镜像
    • 拉取 nginx 镜像
    • 拉取 mongodb 镜像
    • 手动书写前端服务的 Dockerfile,构建镜像
    • 手动书写后端服务的 Dockerfile,构建镜像
  2. 配置容器间公共的基础设施 如 Docker 网络、存储卷等
  3. 配置服务间的依赖关系 如前端服务依赖后端服务,后端服务依赖数据库等
  4. 启动容器

可以看到,随着工程复杂度的提升,服务数量增加,手动管理服务容器会变得越来越复杂。 这不仅增加了运维的复杂度,还增加了手动操作出错的概率。

所以我们需要一个工具来对多容器应用进行管理。于是 Docker Compose 应运而生。

2. Docker Compose 简介

Docker Compose 是一个用于定义和运行多容器的工具。 它允许我们使用 YAML 文件来定义各容器,然后通过一个命令来启动所有服务。

核心步骤

1)书写 docker-compose.yml 文件

  • 定义各个服务基本信息(如镜像、端口、环境变量等)
  • 定义网络、存储卷等通用设置
  • 定义服务之间的依赖关系

2)运行

  • docker compose up:创建和启动所有服务

3. docker-compose.yml语法

3.1 Yaml文件格式

Docker Compose 使用 YAML 文件来定义服务。 YAML 是一种人类可读的数据序列化语言,它支持多种数据类型,如字符串、数字、列表、映射等。 Docker Compose 使用 YAML 文件来定义服务,因此我们需要了解 YAML 的基本语法。

  • 用缩进表示层级关系,必须为偶数个空格
  • 三种基本类型:标量(单个值)、映射(键值对)、序列(列表)

3.2 docker-compose.yaml语法

docker-compose.yml详解

  • 服务 (Services): 容器的定义,包括使用哪个镜像、端口映射、环境变量等
  • 网络 (Networks): 定义容器之间如何通信
  • 卷 (Volumes): 定义数据的持久化存储
  • 依赖关系 (Dependencies): 定义服务之间的启动顺序
  • 环境变量 (Environment Variables): 管理不同环境的配置

4. Docker Compose 原理

流程:

  1. 解析YAML文件
  2. 检查/创建所需网络、卷
  3. 创建和启动每个服务的容器
  4. 统一管理生命周期 • 源码:https://github.com/docker/compose/blob/cb959100188e9bfa2a463d7b0a6e3e1679bd5d0f/pkg/compose/up.go#L39

5. 实践项目:使用 docker compose 构建 Todo 应用

Docker Compose 配置解析

服务定义

  1. nginx 服务

    • 使用官方 nginx 镜像
    • 映射端口 8080,作为应用的访问入口
    • 将 nginx.conf 配置文件打包进镜像
  2. frontend 服务

    • 使用本地 Dockerfile 构建
    • 暴露端口 3000,仅容器网络访问
    • 设置 API URL 环境变量
    • 依赖于 backend 服务
    • 使用卷挂载实现热重载
  3. backend 服务

    • 使用本地 Dockerfile 构建
    • 映射端口 3001,仅容器网络访问
    • 设置 MongoDB 连接环境变量
    • 依赖于 mongodb 服务
    • 使用卷挂载实现热重载
  4. mongodb 服务

    • 使用官方 MongoDB 镜像
    • 映射端口 27017,仅容器网络访问
    • 使用命名卷持久化数据

网络配置

  • Docker Compose 会自动创建一个默认网络 (bridge模式)
  • 所有服务都在同一网络中
  • 服务可以通过服务名互相访问

数据持久化

  • 使用命名卷 mongodb_data 持久化数据库数据
  • 使用绑定挂载实现开发时的代码热重载

常用命令演示

  • docker compose build: 构建镜像
  • docker compose up: 创建和启动所有服务
  • docker compose down: 停止和删除所有服务
  • docker compose ps: 查看服务状态
  • docker compose logs: 查看服务日志

使用说明

  1. 在后台启动服务

    docker compose up -d
  2. 查看服务状态

    docker compose ps
  3. 查看服务日志

    docker compose logs frontend docker compose logs backend docker compose logs mongodb
  4. 停止所有服务

    docker compose down
  5. 重新构建服务

    docker compose build [${service}]

    当 service 省略时,默认构建所有配置了 build 的服务。

  6. 重启单个服务

    docker compose restart frontend

6. 环境变量与 .env 文件最佳实践

在实际项目中,我们经常需要管理不同环境(开发、测试、生产)的配置。 Docker Compose 提供了灵活的环境变量管理机制,让我们能够轻松地在不同环境之间切换配置。

6.1 环境变量的三种设置方式

方式一:直接在 compose.yaml 中定义

services: backend: image: node:18 environment: - NODE_ENV=development - PORT=3001 - DATABASE_HOST=mongodb

优点:简单直观,配置集中
缺点:敏感信息(如密码)会暴露在版本控制中

方式二:使用 .env 文件(推荐)

compose.yaml 同级目录创建 .env 文件:

# .env 文件 NODE_ENV=development MONGODB_PORT=27017 MONGODB_DATABASE=todos MONGODB_ROOT_USER=admin MONGODB_ROOT_PASSWORD=secret123

compose.yaml 中引用:

services: mongodb: image: mongo:6 environment: - MONGO_INITDB_ROOT_USERNAME=${MONGODB_ROOT_USER} - MONGO_INITDB_ROOT_PASSWORD=${MONGODB_ROOT_PASSWORD} ports: - "${MONGODB_PORT}:27017"

优点:敏感信息与配置分离,可以将 .env 加入 .gitignore
缺点:需要额外维护 .env 文件

方式三:使用 env_file 指令

services: backend: image: node:18 env_file: - ./backend.env # 后端专用配置 - ./common.env # 公共配置

优点:可以按服务或功能拆分配置文件
缺点:文件较多时管理复杂

6.2 .env 文件最佳实践

✅ 推荐做法

  1. 创建 .env.example 模板文件

    # .env.example - 提交到 Git,作为配置模板 NODE_ENV=development MONGODB_PORT=27017 MONGODB_DATABASE=todos MONGODB_ROOT_USER= MONGODB_ROOT_PASSWORD=
  2. .env 加入 .gitignore

    # .gitignore .env .env.local .env.*.local
  3. 为不同环境创建专用配置

    .env # 默认开发环境 .env.production # 生产环境 .env.test # 测试环境
  4. 使用有意义的变量命名

    # ❌ 不推荐 DB_P=3306 # 推荐 MYSQL_PORT=3306

避免做法

  1. 不要在 .env 中存储大段文本或 JSON
  2. 不要将生产环境的 .env 提交到版本控制
  3. 不要在变量值中使用未转义的特殊字符

6.3 实践案例:优化 Todo 应用的环境变量管理

第一步:创建 .env.example 模板

# 在 5_compose 目录下创建 .env.example cat > .env.example << 'EOF' # =================== # 应用环境配置 # =================== NODE_ENV=development # =================== # 服务端口配置 # =================== NGINX_PORT=8080 FRONTEND_PORT=3000 BACKEND_PORT=3001 # =================== # MongoDB 配置 # =================== MONGODB_PORT=27017 MONGODB_DATABASE=todos MONGODB_ROOT_USER=admin MONGODB_ROOT_PASSWORD= # =================== # API 配置 # =================== API_URL=/api EOF

第二步:创建实际的 .env 文件

# 复制模板并填写实际值 cp .env.example .env # 编辑 .env 文件,填写密码等敏感信息 # MONGODB_ROOT_PASSWORD=your_secure_password_here

第三步:优化 compose.yaml 使用环境变量

创建 compose.env.yaml 展示最佳实践:

services: # Nginx 反向代理 nginx: build: ./nginx ports: - "${NGINX_PORT:-8080}:80" # 提供默认值 depends_on: - frontend - backend # 前端服务 frontend: build: ./frontend expose: - "${FRONTEND_PORT:-3000}" environment: - NODE_ENV=${NODE_ENV:-development} - REACT_APP_API_URL=${API_URL:-/api} depends_on: - backend volumes: - ./frontend:/app - /app/node_modules # 后端服务 backend: build: ./backend expose: - "${BACKEND_PORT:-3001}" environment: - NODE_ENV=${NODE_ENV:-development} - MONGODB_URI=mongodb://${MONGODB_ROOT_USER}:${MONGODB_ROOT_PASSWORD}@mongodb:${MONGODB_PORT:-27017}/${MONGODB_DATABASE:-todos}?authSource=admin depends_on: - mongodb volumes: - ./backend:/app - /app/node_modules # MongoDB 数据库 mongodb: image: mongo:6 expose: - "${MONGODB_PORT:-27017}" environment: - MONGO_INITDB_ROOT_USERNAME=${MONGODB_ROOT_USER:-admin} - MONGO_INITDB_ROOT_PASSWORD=${MONGODB_ROOT_PASSWORD:?请在.env文件中设置MONGODB_ROOT_PASSWORD} - MONGO_INITDB_DATABASE=${MONGODB_DATABASE:-todos} volumes: - mongodb_data:/data/db volumes: mongodb_data:

6.4 环境变量语法详解

语法说明示例
${VAR}直接引用变量${MONGODB_PORT}
${VAR:-default}变量未设置时使用默认值${MONGODB_PORT:-27017}
${VAR:?error}变量未设置时报错并退出${PASSWORD:?密码不能为空}
${VAR:+value}变量已设置时使用替代值${DEBUG:+--verbose}

6.5 验证环境变量配置

# 查看 compose 解析后的完整配置(包含变量替换结果) docker compose config # 仅查看某个服务的配置 docker compose config --services # 验证配置文件语法 docker compose config --quiet && echo "配置文件语法正确"

6.6 多环境部署实践

使用 --env-file 指定不同环境

# 开发环境(默认读取 .env) docker compose up -d # 生产环境 docker compose --env-file .env.production up -d # 测试环境 docker compose --env-file .env.test up -d

使用 profiles 实现环境差异化

services: # 仅在开发环境启用的调试工具 mongo-express: image: mongo-express profiles: - debug environment: - ME_CONFIG_MONGODB_SERVER=mongodb ports: - "8081:8081"
# 启动时包含调试工具 docker compose --profile debug up -d # 生产环境不包含调试工具 docker compose up -d

6.7 小结

场景推荐方案
简单项目/本地开发直接在 compose.yaml 中定义
团队协作项目.env + .env.example 模板
微服务架构env_file 按服务拆分配置
多环境部署.env.{environment} + --env-file
敏感信息管理Docker Secrets(生产环境推荐)

通过合理使用环境变量和 .env 文件,我们可以:

  • 实现配置与代码分离
  • 保护敏感信息安全
  • 简化多环境部署流程
  • 提高团队协作效率

Docker 容器监控与管理

在本章节中,我们将学习如何有效地监控和管理 Docker 容器,包括使用命令行工具和图形化界面(Portainer)进行容器管理。

提示:本节所需的容器可以利用上一节 Docker Compose 启动的容器来演示。

1. 容器管理基础

1.1 容器生命周期管理

以下是一些最常用的容器管理命令:

# 列出所有容器(包括停止的容器) docker ps -a # 仅列出运行中的容器 docker ps # 启动容器 docker start <container_id> # 停止容器 docker stop <container_id> # 重启容器 docker restart <container_id> # 删除容器(需要先停止) docker rm <container_id> # 强制删除运行中的容器 docker rm -f <container_id>

1.2 容器资源监控

Docker 提供了多种方式来监控容器的资源使用情况:

# 实时查看容器资源使用状态 docker stats # 查看容器资源使用情况(仅显示名称、CPU 和内存) docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" # 查看容器详细信息 docker inspect <container_id> # 查看容器内进程 docker top <container_id> # 查看容器端口映射 docker port <container_id>

docker stats 输出字段说明

字段说明
NAME容器名称
CPU %CPU 使用率
MEM USAGE / LIMIT内存使用量 / 内存限制
NET I/O网络输入 / 输出
BLOCK I/O磁盘读 / 写
PIDS容器内进程数

2. 容器日志与调试

2.1 日志查看

# 查看容器日志 docker logs <container_id> # 实时查看最新日志 docker logs -f <container_id> # 查看最近 100 行日志 docker logs --tail 100 <container_id> # 显示时间戳 docker logs -t <container_id> # 组合使用:实时查看最近 50 行带时间戳的日志 docker logs -f --tail 50 -t <container_id>

2.2 容器调试

# 进入运行中的容器 docker exec -it <container_id> /bin/sh # 查看容器内文件系统变更 docker diff <container_id> # 将容器内文件复制到宿主机 docker cp <container_id>:/path/to/file ./local_path # 将宿主机文件复制到容器内 docker cp ./local_file <container_id>:/path/to/dest

3. 实践练习:使用 Portainer 对 Docker 进行可视化管理

Portainer 是一个轻量级的 Docker 管理工具,提供了直观的 Web 界面来管理 Docker 环境。

3.1 安装 Portainer

# 创建 Portainer 数据卷 docker volume create portainer_data # 运行 Portainer 容器 docker run -d -p 9000:9000 \ --name portainer \ --restart=always \ -v /var/run/docker.sock:/var/run/docker.sock \ -v portainer_data:/data \ portainer/portainer-ce:2.29.2

关于 -v /var/run/docker.sock:/var/run/docker.sock

/var/run/docker.sock 是 Docker 客户端与 Docker 守护进程(dockerd)之间通信的 Unix 套接字文件。将它挂载到 Portainer 容器中,相当于赋予了 Portainer 与宿主机 Docker 引擎同等的控制权,使其能够管理宿主机上的所有 Docker 资源(容器、镜像、网络等)。

安装完成后,在 CNB 上可以通过添加一个 9000 的端口映射来实现外网访问,按照如下步骤配置:

port_forward

点击浏览器图标,就可以访问 Portainer 了。

3.2 Portainer 主要功能

  1. 仪表盘概览

    • 查看环境整体状态
    • 监控资源使用情况
    • 查看事件日志
  2. 容器管理

    • 创建、启动、停止、删除容器
    • 查看容器日志和统计信息
    • 进入容器终端
    • 修改容器配置
  3. 镜像管理

    • 拉取和删除镜像
    • 构建新镜像
    • 推送镜像到仓库
  4. 网络管理

    • 创建和管理 Docker 网络
    • 配置容器网络连接
  5. 数据卷管理

    • 创建和删除数据卷
    • 管理数据卷权限

3.3 清理

# 停止并删除 Portainer 容器 docker rm -f portainer # 删除数据卷(可选,删除后 Portainer 配置将丢失) docker volume rm portainer_data

扩展内容

OverlayFS

实战1:手动创建 OverlayFS

需要 sudo 权限,在云原生环境中无法演示 OverlayFS 是 Docker 默认使用的存储驱动,下面演示如何手动创建和挂载一个 OverlayFS:

# 1. 创建所需的目录结构 mkdir -p /tmp/overlay-demo/{lower,upper,work,merged} # 2. 在 lowerdir(只读层)中创建文件 echo "I'm from lower layer" > /tmp/overlay-demo/lower/base.txt echo "This will be hidden" > /tmp/overlay-demo/lower/override.txt mkdir -p /tmp/overlay-demo/lower/shared echo "Shared file" > /tmp/overlay-demo/lower/shared/readme.txt # 3. 在 upperdir(读写层)中创建文件 echo "I'm from upper layer" > /tmp/overlay-demo/upper/new.txt echo "This overrides lower" > /tmp/overlay-demo/upper/override.txt # 4. 挂载 OverlayFS(需要 root 权限) sudo mount -t overlay overlay \ -o lowerdir=/tmp/overlay-demo/lower,upperdir=/tmp/overlay-demo/upper,workdir=/tmp/overlay-demo/work \ /tmp/overlay-demo/merged # 5. 查看合并后的文件系统 ls -la /tmp/overlay-demo/merged/ # 输出: # base.txt ← 来自 lower # new.txt ← 来自 upper # override.txt ← 来自 upper(覆盖了 lower) # shared/ ← 来自 lower # 6. 验证文件内容 cat /tmp/overlay-demo/merged/base.txt # I'm from lower layer cat /tmp/overlay-demo/merged/override.txt # This overrides lower ← upper 层的内容覆盖了 lower 层 # 7. 在 merged 中写入新文件(会写入 upper 层) echo "Written in merged" > /tmp/overlay-demo/merged/runtime.txt # 验证新文件在 upper 层 ls /tmp/overlay-demo/upper/ # new.txt override.txt runtime.txt ← 新文件出现在这里 # 8. 在 merged 中删除 lower 层的文件 rm /tmp/overlay-demo/merged/base.txt # 查看 upper 层(会创建一个 "whiteout" 文件) ls -la /tmp/overlay-demo/upper/ # c--------- 1 root root 0, 0 ... base.txt ← whiteout 字符设备文件 # 9. Opaque 目录演示 - 完全隐藏 lower 层的目录 # 首先重新挂载(因为之前的操作可能影响了状态) sudo umount /tmp/overlay-demo/merged rm -rf /tmp/overlay-demo/upper/* # 清空 upper 层 sudo mount -t overlay overlay \ -o lowerdir=/tmp/overlay-demo/lower,upperdir=/tmp/overlay-demo/upper,workdir=/tmp/overlay-demo/work \ /tmp/overlay-demo/merged # 查看 lower 层的 shared 目录内容 ls /tmp/overlay-demo/merged/shared/ # readme.txt ← 来自 lower 层 # 10. 在 merged 视图中删除整个目录并重建 rm -rf /tmp/overlay-demo/merged/shared mkdir /tmp/overlay-demo/merged/shared # 11. 查看 upper 层 - 会发现 opaque 标记 ls -la /tmp/overlay-demo/upper/ # drwxr-xr-x 2 root root 4096 ... shared/ ← 新建的目录 # 查看 opaque 扩展属性(这是关键!) getfattr -n trusted.overlay.opaque /tmp/overlay-demo/upper/shared # trusted.overlay.opaque="y" ← opaque 标记 # 12. 验证 opaque 效果 # 在新的 shared 目录中创建文件 echo "New content" > /tmp/overlay-demo/merged/shared/new.txt # 查看 merged 视图 - lower 层的 readme.txt 被完全隐藏了 ls /tmp/overlay-demo/merged/shared/ # new.txt ← 只有新文件,lower 层的 readme.txt 不可见 # 对比:lower 层的文件仍然存在,只是被"遮挡"了 ls /tmp/overlay-demo/lower/shared/ # readme.txt ← 原文件还在,但在 merged 中不可见 # 13. 清理 sudo umount /tmp/overlay-demo/merged rm -rf /tmp/overlay-demo

目录说明:

目录作用对应 Docker 概念
lowerdir只读的底层目录镜像层(image layers)
upperdir可读写的上层目录容器层(container layer)
workdirOverlayFS 内部使用的工作目录Docker 内部管理
merged合并后的统一视图容器看到的文件系统

Whiteout 和 Opaque 机制:

机制用途实现方式Docker 场景
Whiteout隐藏 lower 层的单个文件在 upper 层创建同名的字符设备文件(0,0)容器删除基础镜像中的文件
Opaque隐藏 lower 层的整个目录内容在 upper 层目录设置 trusted.overlay.opaque=y 扩展属性容器删除并重建基础镜像中的目录

为什么需要这两种机制?
因为 lower 层是只读的,无法真正删除文件。Whiteout 和 Opaque 提供了一种"标记删除"的方式,让 merged 视图看起来文件/目录已被删除,但实际上 lower 层的数据完好无损。

OverlayFS 的工作原理:

┌─────────────────────────────────────┐ │ merged (统一视图) │ ← 用户/容器看到的 ├─────────────────────────────────────┤ │ upperdir (读写层) │ ← 所有修改都在这里 ├─────────────────────────────────────┤ │ lowerdir (只读层) │ ← 原始镜像内容 └─────────────────────────────────────┘
  • 读取文件:先查 upper,没有再查 lower
  • 写入文件:直接写入 upper
  • 修改 lower 的文件:复制到 upper 后修改(Copy-on-Write)
  • 删除 lower 的文件:在 upper 创建 whiteout 文件标记删除
  • 删除 lower 的目录:在 upper 创建 opaque 目录标记删除

Docker 中 Opaque 的实际应用场景:

# Dockerfile 示例:完全替换配置目录 FROM ubuntu:24.04 # 这个操作会触发 opaque RUN rm -rf /etc/apt/sources.list.d && \ mkdir /etc/apt/sources.list.d && \ echo "deb http://mirrors.tencent.com/ubuntu noble main" > /etc/apt/sources.list.d/tencent.list # 结果:原来 sources.list.d 目录下的所有文件都被隐藏 # 只有新创建的 tencent.list 可见

要点:Opaque 是 Docker 实现"完全替换目录"的关键机制。当你在 Dockerfile 中删除并重建一个目录时,Docker 会自动设置 opaque 属性,确保底层镜像的该目录内容完全不可见。

Docker 如何使用 Overlay 的?

实战2:双容器文件变更 - 在宿主机观察 OverlayFS

这个实验演示两个基于相同镜像的容器,各自的文件修改如何隔离存储在不同的容器层(upperdir)中。

环境要求:本实验需要通过 docker inspect 获取容器的 UpperDir/LowerDir 路径。如果宿主机启用了 containerd-snapshotterGraphDriver.Data 字段会返回 <no value>,无法直接获取路径。可通过以下命令确认:

docker info | grep -i snapshotter

若输出包含 snapshotter 信息,说明当前环境使用 containerd 管理存储层,本实验中"在宿主机查看容器存储位置"(步骤 5-8)将无法执行,但步骤 1-4 的容器隔离验证仍可正常演示。

# 1. 启动两个容器(基于相同镜像) docker run -d --name container_a alpine sleep 3600 docker run -d --name container_b alpine sleep 3600 # 2. 在容器 A 中创建文件 docker exec container_a sh -c "echo 'Hello from Container A' > /data_a.txt" docker exec container_a sh -c "echo 'Modified by A' > /etc/motd" # /etc/motd 是 Message of the Day 的缩写,是 Linux 系统中用于显示登录欢迎信息的文件 # 它是系统自带文件 - 属于 Alpine 镜像的 lower 层(只读层) # 修改会触发 Copy-on-Write - 原文件在 lower 层,修改时会复制到 upper 层 # 3. 在容器 B 中创建不同的文件 docker exec container_b sh -c "echo 'Hello from Container B' > /data_b.txt" docker exec container_b sh -c "echo 'Modified by B' > /etc/motd" docker exec container_b sh -c "rm /etc/alpine-release" # 删除一个镜像层的文件 # /etc/hostname、/etc/hosts、/etc/resolv.conf 是 Docker 动态挂载的文件 # 这些文件无法删除(会报 Resource busy),应选择真正来自镜像层的文件 # 4. 验证容器内的文件互相隔离 docker exec container_a cat /data_a.txt # Hello from Container A docker exec container_a cat /data_b.txt # cat: can't open '/data_b.txt': No such file or directory ← A 看不到 B 的文件 docker exec container_b cat /data_b.txt # Hello from Container B # 5. 在宿主机查看容器的存储位置 # 获取容器 A 的 UpperDir 路径 UPPER_A=$(docker inspect container_a --format '{{.GraphDriver.Data.UpperDir}}') echo "Container A UpperDir: $UPPER_A" # 获取容器 B 的 UpperDir 路径 UPPER_B=$(docker inspect container_b --format '{{.GraphDriver.Data.UpperDir}}') echo "Container B UpperDir: $UPPER_B" # 6. 在宿主机查看各容器的文件变更 ls -la $UPPER_A # 输出示例: # -rw-r--r-- 1 root root 23 Jan 15 14:30 data_a.txt # drwxr-xr-x 2 root root 26 Jan 15 14:29 etc cat $UPPER_A/data_a.txt # Hello from Container A ls -la $UPPER_B # 输出示例: # -rw-r--r-- 1 root root 23 Jan 15 14:30 data_b.txt # drwxr-xr-x 2 root root 52 Jan 15 14:39 etc cat $UPPER_B/data_b.txt # Hello from Container B # 7. 查看 whiteout 文件(容器 B 删除了 /etc/alpine-release) ls -la $UPPER_B/etc/ # c--------- 1 root root 0, 0 ... alpine-release ← whiteout 字符设备文件 # 8. 查看共享的镜像层(LowerDir)- 两个容器共享同一个只读层 LOWER_A=$(docker inspect container_a --format '{{.GraphDriver.Data.LowerDir}}') LOWER_B=$(docker inspect container_b --format '{{.GraphDriver.Data.LowerDir}}') echo "Container A LowerDir: $LOWER_A" echo "Container B LowerDir: $LOWER_B" # 两个容器的 LowerDir 是相同的(共享镜像层) # init层不同,每个容器都有自己独立的 init 层来存放容器特定的配置文件 # 9. 清理 docker rm -f container_a container_b

关键观察点:

观察项说明
UpperDir 不同每个容器有独立的读写层,互不影响
LowerDir 相同基于同一镜像的容器共享只读层,节省空间
Whiteout 文件删除 lower 层文件时,upper 层创建特殊标记
文件隔离容器 A 的修改对容器 B 不可见

存储结构示意图:

┌─────────────────────────────────────────────────────────────┐ │ 宿主机文件系统 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ Container A Container B │ │ ┌─────────────────┐ ┌─────────────────┐ │ │ │UpperDir (独立) │ │UpperDir (独立) │ │ │ │ - data_a.txt │ │ - data_b.txt │ │ │ │ - etc/motd │ │ - etc/motd │ │ │ │ │ │ - etc/alpine-release* │← whiteout│ │ └────────┬────────┘ └────────┬────────┘ │ │ │ │ │ │ └──────────┬──────────────────┘ │ │ ▼ │ │ ┌─────────────────────┐ │ │ │ LowerDir (共享) │ ← alpine 镜像层 │ │ │ - /bin, /lib, /etc │ │ │ │ - 只读,不可修改 │ │ │ └─────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────┘

核心理解:这就是 Docker 镜像分层的核心优势 - 多个容器共享相同的镜像层(节省磁盘空间),同时各自拥有独立的可写层(保证隔离性)。

RootFS

实战:查看 RootFS 内容

方法1:直接查看容器文件系统

# 运行Ubuntu容器 docker run -it --name test ubuntu:20.04 bash # 在容器内查看RootFS ls -la / drwxr-xr-x 1 root root 4096 Jan 1 00:00 bin drwxr-xr-x 2 root root 4096 Jan 1 00:00 boot ← 空目录(无BootFS) drwxr-xr-x 5 root root 360 Jan 1 00:00 dev ← 虚拟设备 drwxr-xr-x 1 root root 4096 Jan 1 00:00 etc drwxr-xr-x 2 root root 4096 Jan 1 00:00 home drwxr-xr-x 1 root root 4096 Jan 1 00:00 lib ... # 注意:/boot 目录是空的! ls /boot/ # 输出为空,因为没有BootFS

方法2:对比宿主机内核

# 宿主机内核版本 uname -r 5.4.241-1-tlinux4-0023.2 # 容器内内核版本(与宿主机相同) docker run ubuntu:20.04 uname -r 5.4.241-1-tlinux4-0023.2 ← 共享宿主机内核! # 容器的RootFS版本 docker run ubuntu:20.04 cat /etc/os-release NAME="Ubuntu" VERSION="20.04.6 LTS (Focal Fossa)" ← 容器的RootFS

实战:创建最小化 Docker 镜像

/7_extended 目录下

# 使用 scratch(空镜像) FROM scratch # 只复制单个可执行文件(静态编译) COPY hello / # 运行 CMD ["/hello"]

编译静态二进制:

# Go 语言示例(天然支持静态编译) cat > hello.go <<EOF package main import "fmt" func main() { fmt.Println("Hello from minimal rootfs!") } EOF # 静态编译 CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o hello hello.go # 构建镜像 docker build -t minimal:v1 . # 查看大小 docker images minimal REPOSITORY TAG SIZE minimal v1 2MB ← 只有可执行文件!

这个镜像:

  • 没有完整的RootFS
  • 没有/bin, /lib, /etc
  • 只有一个可执行文件
  • 依然共享宿主机BootFS

为什么能做到最小?

1. FROM scratch - 从零开始

scratch 是 Docker 的特殊空镜像,它:

  • 不包含任何文件:没有操作系统、没有 shell、没有任何库
  • 大小为 0 字节:"空白画布"
  • 对比普通基础镜像
    ubuntu:24.04 ≈ 78MB alpine:3.19 ≈ 7MB scratch = 0MB ← 完全空白

2. 静态编译 - 无外部依赖

普通编译 vs 静态编译的区别:

编译方式依赖能否在 scratch 运行
动态编译需要 libc、libpthread 等共享库否,缺少 /lib
静态编译所有依赖打包进二进制文件独立运行

关键编译参数解释:

CGO_ENABLED=0 # 禁用 CGO,避免依赖 C 库 GOOS=linux # 目标操作系统 go build -a # 强制重新编译所有包 -installsuffix cgo # 使用独立的包缓存目录 -o hello # 输出文件名

3. 为什么 Go 语言适合做最小镜像?

Go 语言的优势:

  • 原生支持静态编译:不像 C/C++ 需要复杂配置
  • 无运行时依赖:不需要 JVM、Python解释器等
  • 交叉编译简单:轻松构建不同平台的二进制

对比其他语言的最小镜像大小:

Go (scratch) ≈ 2MB ← 最小 Rust (scratch) ≈ 3MB C (scratch+musl) ≈ 100KB ← 更小但配置复杂 Java (JRE) ≈ 200MB ← 需要运行时 Python ≈ 50MB ← 需要解释器 Node.js ≈ 100MB ← 需要运行时

4. 这种最小镜像的优缺点

优点

  • 极小的镜像体积,传输快
  • 攻击面最小,更安全(没有 shell、没有包管理器)
  • 启动速度快

缺点

  • 无法进入容器调试(没有 shell)
  • 无法使用常用命令(ls、cat、curl 等)
  • 必须静态编译,某些场景不适用

实际应用:scratch 镜像常用于生产环境的微服务部署,而开发/调试阶段通常使用 alpine 或 distroless 镜像。

Docker Buildx

Docker Buildx 是 Docker 的官方扩展,用于构建多平台镜像。在实际场景中,同一个应用可能需要运行在不同 CPU 架构的服务器上(如 x86 的 amd64 和 ARM 的 arm64),Docker Buildx 可以一次构建出多个架构的镜像,并通过 Manifest List 统一管理。

本地使用

# 启用 Buildx docker buildx create --name mybuilder --use docker buildx inspect --bootstrap # 构建多平台镜像并推送 docker buildx build --platform linux/amd64,linux/arm64 -t myapp:v1 . --push

云原生构建使用

传统 Docker 构建时,BuildKit 可能需要依赖以 root 权限运行的 Docker 守护进程(dockerd),rootless 模式可以让 BuildKit 完全在普通用户权限下工作,不需要 root 权限。

main: push: - docker: image: golang:1.24 services: - name: docker options: rootlessBuildkitd: enabled: true

查看多平台镜像信息

docker manifest inspect docker.cnb.cool/docker-666/campus-academy-template/docker-buildx-multi-platform-example:latest

Manifest List 是一个包含了多个指向不同架构镜像的 manifest 的文件。当你拉取一个支持多架构的镜像时,Docker 会自动根据你当前的系统架构选择并拉取对应的镜像。

参考资料