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 会自动根据你当前的系统架构选择并拉取对应的镜像。

参考资料

About

Knowledge base for hust-camp

1.91 MiB
94.95 KiB
NPC0 forks0 stars2 branches0 TagREADME