. ├── .cnb │ └── settings.yml # 知识库角色配置 ├── .cnb.yml # CNB 配置文件 ├── README.md ├── assets # 图片资源 └── knowledge # 知识库参考文件目录 └── huangmenji.md └── xxx
fork 本仓库到自己的组织下

在knowledge目录下添加任意 markdown 格式参考文件
仓库中.cnb.yml文件中已经内置知识库插件:
main: push: - stages: - name: build knowledge base image: cnbcool/knowledge-base settings: include: "knowledge/**.md"
提交的 markdown 文件会被自动处理,并生成知识库
点击仓库云原生构建页面,查看构建过程及结果

查看最终结果


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

本仓库的README文件已经内置好问题,即Dockerfile 为什么如此重要呢?
需要参考据配置知识库的过程,将 Docker训练营课程仓库(链接)的与此文件关联的README.md文件添加到知识库参考文件中,使得仓库的“知识库”回答这个问题时能够正确引用所添加的参考文件。
复制md文档时,请确保文件和Docker训练营课程仓库文件内容完全一致,fork的仓库记得同步上游。
向本仓库提交 Pull Request(合并请求),命名格式为:姓名-大作业,例如:张三-大作业
提交完之后,本仓库流水线会自动向你Fork的仓库知识库发送请求,并且检测能否引用参考文献来回答Dockerfile 为什么如此重要呢?这个问题。
点击本仓库的云原生构建页面,查看构建过程及结果。
搜集相关md文件放入knowledge目录下,来到仓库的web页面,直接点击README文件的此问题,观察知识库是否正确引用参考文件进行回答

CNB 云原生开发环境中已经预装了 Docker,无需手动安装,直接体验即可。
docker version # 查看版本信息 docker info # 查看运行时信息
docker run hello-world
学习一门新语言,第一个程序是输出 hello world!
学习 Docker,第一个容器运行输出 hello from Docker!
扩展知识:
Alpine 镜像在企业生产环境中被广泛应用:
- 极简的 Linux 发行版
- 只包含基本命令和工具
- 镜像体积小(约 8MB)
- 内置包管理系统
apk- 常用作其他镜像的基础
# 拉取 alpine 镜像,默认以 latest 标签拉取
docker pull alpine
docker image ls
镜像格式:[REGISTRY_HOST[:PORT]/]PATH[:TAG]
docker.io[NAMESPACE/]REPOSITORY。其中 NAMESPACE 指的是用户或者组织名,若未指定则默认为 library完整镜像示例:
alpine等同于 docker.io/library/alpine:latest
docker.cnb.cool/docker-666/campus-academy-template/dev-env: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
镜像分为两种类型:
# 公开镜像(正常拉取)
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
docker login [-u ${username}] [-p ${password}] ${repository}
docker image rm ${image_id}
docker history ${image_id}
docker run alpine
docker ps
问题:为什么看不到刚启动的容器?
原因:容器没有前台进程会立即退出
查看所有容器(包括已停止的):
docker ps -a
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
docker run -d alpine sleep 3600
注意:
docker run -d alpine会立即退出,因为 Alpine 默认没有前台进程。需要指定一个持续运行的命令(如sleep 3600)来保持容器运行。
docker stop <container_id> # 停止容器 docker start <container_id> # 启动已停止的容器 docker restart <container_id> # 重启容器 docker rm <container_id> # 删除容器
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 的进程,也就不会导致容器的退出
docker attach <container_id>
注意:
attach 会接管 PID=1 的进程,如果该进程退出,容器也会退出
docker inspect <container_id>
docker logs <container_id>
以下为 Docker 进阶知识,帮助深入理解 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 定义的镜像包括4个部分:镜像索引(Image Index)、清单(Manifest)、配置(Configuration)和层文件(Layers)。镜像索引是镜像中可选择的部分,一个镜像可以不包括镜像索引。如果镜像包含了镜像索引,则其作用主要指向镜像不同平台的版本,代表一组同名且相关的镜像,差别只在支持的体系架构上。


OverlayFS 是一种联合文件系统(Union Filesystem),它通过堆叠多层目录实现文件系统的叠加,主要分为:
假设镜像层(lowerdir)有一个文件 /galaxy,而容器层(upperdir)也创建了同名文件:
# 镜像层(只读) lowerdir/ └── galaxy # 内容:"Hello from image" # 容器层(可写) upperdir/ └── galaxy # 内容:"Hello from container" # 用户看到的合并视图 merged/ └── galaxy # 实际显示 "Hello from container"(上层覆盖下层)
当删除容器层的 galaxy 文件时,容器层会创建 whiteout 文件:
# 容器层 upperdir/ └── galaxy # 特殊字符文件表示删除 # 合并视图 mergeddir/ └── galaxy # 文件消失(实际被隐藏)
FROM alpine、RUN apk add)都是 lowerdir。
运行以下命令查看 Docker 是否使用 OverlayFS:
docker info | grep "Storage Driver"
输出示例:
Storage Driver: overlay2
Namespace 和 cgroups 是 Linux 内核提供的两种资源隔离技术,用于实现容器的资源隔离和限制。

上节课我们学习了 Docker 概述,并实操理解了 Docker 三个核心概念:镜像、容器、仓库。 并且我们已经会使用 Docker 官方提供的镜像,启动容器。
但是这些镜像不能满足我们的需求,比如想定制一个个性化的环境,安装一些特定的软件,这个时候就需要我们自定义镜像。
本节课我们会学习两种自定义镜像的方式,一种是命令式创建镜像,一种是声明式创建镜像。 我们先从最简单的镜像创建方式开始,命令式创建镜像。
创建一个自定义镜像,基于 alpine 镜像,并安装 figlet 工具。 (figlet:输出艺术字符串的小工具)
docker run -it --name alpine alpine
然后我们进入容器,在容器中执行一些命令(安装一个软件),然后退出容器。
docker exec -it alpine /bin/sh apk update apk add figlet exit
这样,我们就在 alpine 容器中安装了 figlet 工具。
然后我们需要将这个新的容器环境跟其他人分享,我们可以通过 commit 命令将容器保存为一个镜像。
docker ps -a #查看容器 docker commit ${container_id} alpine-figlet
这样,我们就创建了一个名为 alpine-figlet 的镜像。
最后我们就可以使用这个新的镜像了, 运行体验下艺术字生成的效果。
docker run alpine-figlet figlet "hello docker"
最后我们也可以使用 docker push 命令将镜像推送到镜像仓库中,其他人便可以使用 docker pull 来使用这个镜像了。
上述从容器创建镜像的方式虽然简单易懂,但是考虑真实项目中,我们可能需要安装很多工具,比如 git,vim,curl,wget 等等。如果我们每次都使用这种方式来创建镜像,就会非常麻烦,并且容易出错。
因此,我们需要一种更加方便的镜像创建方式,这就是 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 给容器化实践提供了一种规范,让创建镜像的操作简单化、标准化。 简单化让开发者可以快速上手,标准化让镜像可以重复使用、可移植、可复用。这些好处从侧面上推动了 Docker 的普及。
为了上手书写 Dockerfile,我们还要学习它的语法。我们通过两个案例来学习。
让我们使用 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 上我们可以通过添加一个端口映射来实现外网访问。

点击这个浏览器图标,就可以访问 jupyter notebook 服务了。
在实际开发中,我们经常需要构建 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 应用。

docker run 的参数覆盖docker run 的参数会作为额外参数追加# CMD 示例:可被覆盖 CMD ["echo", "hello"] # docker run image echo world → 输出 world(CMD 被替换) # ENTRYPOINT 示例:参数追加 ENTRYPOINT ["echo"] # docker run image hello → 输出 hello(hello 作为参数追加)
RUN apk update && \ apk add --no-cache figlet git && \ rm -rf /var/cache/apk/*
.dockerignore 文件:忽略不需要的文件,减少构建上下文体积# 运行时注入敏感信息(推荐)
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 容器在运行时会产生大量数据,这些数据如何持久化和管理是一个重要的话题。 本节我们将通过一个 Nginx Web 服务器的案例,来深入探讨 Docker 的三种数据管理方式。
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 使用 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 将宿主机上的指定目录直接映射到容器内部。数据实际存储在宿主机的文件系统上,容器删除后宿主机上的文件依然存在。
# 创建本地目录
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 是由 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
默认存储
Bind Mount
Volume
完成实验后,可以进行清理:
# 清理容器
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 而非默认存储,确保数据从不落盘,避免容器删除后敏感数据仍残留在宿主机磁盘上。
我们将通过一个 MySQL 数据库的例子来演示如何使用 Volume 持久化数据。
# 创建一个数据卷,名称为 mysql_data
docker volume create mysql_data
# 列出所有卷
docker volume ls
# 查看卷信息
docker volume inspect mysql_data
# 运行 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 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 地址进行通信。

Q:除了 Bridge 网络,还有哪些网络类型?
A:除了 Bridge 网络,Docker 还支持 Host 网络、None 网络和自定义网络。Host 网络直接将容器连接到主机网络,None 网络禁用了容器的网络功能,自定义网络则允许用户创建自己的网络。这些网络类型各有优缺点,可以根据实际需求选择使用。接下来,我们将分别介绍这些网络类型及其实践案例。
让我们先来看看默认 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 地址,这显然不是个好办法。
为了解决这个限制,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 有三个关键特性:
@ifX 的含义:表示 veth pair 对端的 ifindex,是定位"网线另一头"的关键依据:
4: veth05895da@if3 ↑ 本端 ifindex=4(在宿主机) ↑ 对端 ifindex=3(在容器内,即容器的 eth0)
3. eth0 与 veth
eth0,这是 Linux Network Namespace 隔离的结果——容器以为自己独占一套网络栈。eth0),另一端在宿主机上(就是 vethXXX)。容器命名空间 宿主机命名空间 ┌─────────────┐ ┌──────────────────────────┐ │ eth0 (if3) │◄── veth pair ──►│ veth05895da (if4) │ │ │ 同一对设备的两端 │ │ master=docker0 │ └─────────────┘ │ ▼ │ │ docker0 (网桥) │──► 外部网络 └──────────────────────────┘
| 概念 | 物理世界类比 |
|---|---|
eth0 | 电脑上的网口 |
vethXXX | 网线的另一头(插在交换机上) |
docker0 / br-xxx | 交换机 |
| veth pair | 一根网线(两头不可分离) |
关键点:
eth0和vethXXX是同一个 veth pair 的两端——从容器内看叫eth0,从宿主机看叫vethXXX。数据从容器eth0发出后自动到达宿主机的vethXXX,再经网桥转发到目标。每创建一个容器,Docker 就创建一对 veth,容器删除时这对 veth 一起销毁。
4. veth pair 对应关系
每个容器通过 veth pair 连接到网桥,就像一根虚拟网线的两端:
容器内: eth0@if4 <──────────────> 宿主机: veth05895da@if3 └─ 一对虚拟网卡,像一根网线的两端 ─┘
对应关系(通过 @ifX 序号匹配): 4 个 veth* 接口 ↔ 4 个运行中的容器
| 容器 | 容器内网卡 | 宿主机 veth | 所属网桥 |
|---|---|---|---|
| container1 | eth0@if4 | veth05895da@if3 | docker0 |
| container2 | eth0@if6 | veth6c3945d@if5 | docker0 |
| custom-container1 | eth0@if9 | vethf02e559@if8 | br-b99d1aa4ad94 |
| custom-container2 | eth0@if11 | vethdd4a1c7@if10 | br-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 网络的容器默认无法互通——它们连接在不同的虚拟交换机上。
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
Container 网络模式允许一个容器共享另一个容器的网络命名空间,两个容器将拥有相同的 IP 地址、网络接口和端口空间。
特点:
localhost 直接通信应用场景:
实践案例:两个容器共享网络命名空间
# 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
None 网络完全禁用了容器的网络功能,容器在这个网络中没有任何外部网络接口。
特点:
实践案例:使用 None 网络运行独立计算任务
# 运行一个计算密集型任务,不需要网络
docker run --network none alpine sh -c 'for i in $(seq 1 10); do echo $((i*i)); done'
Overlay 网络是 Docker 用于实现跨主机容器通信的网络驱动,主要用于 Docker Swarm 集群环境。它通过在不同主机的物理网络之上创建虚拟网络,使用 VXLAN 技术在主机间建立隧道,从而实现容器间的透明通信。
特点:
应用场景:
在 Overlay 网络中,容器之间可以直接通过虚拟 IP 进行通信,而不需要关心容器具体运行在哪个主机上。Overlay 网络支持网络加密,能确保跨主机通信的安全性, 同时还提供了负载均衡和服务发现等特性,是构建大规模容器集群的重要基础设施。
注意:Overlay 网络需要 Docker Swarm 或其他集群编排工具支持,本课程环境为单机,因此仅做原理介绍。
让容器获得"局域网中的独立 IP",适合对接传统网络;需网络环境与路由配置到位。
注意:macvlan/ipvlan 网络依赖特定的网络环境配置(如网卡混杂模式、路由规则),本课程仅做原理介绍。
自定义 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 容器来运行单个服务。
通过 docker run 命令,我们可以快速启动一个数据库、一个 Web 服务器或者一个缓存服务。这种方式在开发简单应用时非常有效。
然而,随着应用架构的演进,微服务的理念逐渐流行,一个应用由多个服务共同组成。
本节课我们准备了 一个多服务的web应用 的案例如下:
四个服务共同组成了应用,整体架构图如下:
试想一下,我们为了部署这个 Web 应用,需要完成哪些工作?
可以看到,随着工程复杂度的提升,服务数量增加,手动管理服务容器会变得越来越复杂。 这不仅增加了运维的复杂度,还增加了手动操作出错的概率。
所以我们需要一个工具来对多容器应用进行管理。于是 Docker Compose 应运而生。
Docker Compose 是一个用于定义和运行多容器的工具。 它允许我们使用 YAML 文件来定义各容器,然后通过一个命令来启动所有服务。
docker compose up:创建和启动所有服务Docker Compose 使用 YAML 文件来定义服务。 YAML 是一种人类可读的数据序列化语言,它支持多种数据类型,如字符串、数字、列表、映射等。 Docker Compose 使用 YAML 文件来定义服务,因此我们需要了解 YAML 的基本语法。
流程:
nginx 服务
frontend 服务
backend 服务
mongodb 服务
mongodb_data 持久化数据库数据docker compose build: 构建镜像docker compose up: 创建和启动所有服务docker compose down: 停止和删除所有服务docker compose ps: 查看服务状态docker compose logs: 查看服务日志在后台启动服务
docker compose up -d
查看服务状态
docker compose ps
查看服务日志
docker compose logs frontend docker compose logs backend docker compose logs mongodb
停止所有服务
docker compose down
重新构建服务
docker compose build [${service}]
当 service 省略时,默认构建所有配置了 build 的服务。
重启单个服务
docker compose restart frontend
在实际项目中,我们经常需要管理不同环境(开发、测试、生产)的配置。 Docker Compose 提供了灵活的环境变量管理机制,让我们能够轻松地在不同环境之间切换配置。
services:
backend:
image: node:18
environment:
- NODE_ENV=development
- PORT=3001
- DATABASE_HOST=mongodb
优点:简单直观,配置集中
缺点:敏感信息(如密码)会暴露在版本控制中
在 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 文件
services:
backend:
image: node:18
env_file:
- ./backend.env # 后端专用配置
- ./common.env # 公共配置
优点:可以按服务或功能拆分配置文件
缺点:文件较多时管理复杂
创建 .env.example 模板文件
# .env.example - 提交到 Git,作为配置模板
NODE_ENV=development
MONGODB_PORT=27017
MONGODB_DATABASE=todos
MONGODB_ROOT_USER=
MONGODB_ROOT_PASSWORD=
将 .env 加入 .gitignore
# .gitignore
.env
.env.local
.env.*.local
为不同环境创建专用配置
.env # 默认开发环境 .env.production # 生产环境 .env.test # 测试环境
使用有意义的变量命名
# ❌ 不推荐
DB_P=3306
# 推荐
MYSQL_PORT=3306
# 在 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
# 复制模板并填写实际值
cp .env.example .env
# 编辑 .env 文件,填写密码等敏感信息
# MONGODB_ROOT_PASSWORD=your_secure_password_here
创建 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:
| 语法 | 说明 | 示例 |
|---|---|---|
${VAR} | 直接引用变量 | ${MONGODB_PORT} |
${VAR:-default} | 变量未设置时使用默认值 | ${MONGODB_PORT:-27017} |
${VAR:?error} | 变量未设置时报错并退出 | ${PASSWORD:?密码不能为空} |
${VAR:+value} | 变量已设置时使用替代值 | ${DEBUG:+--verbose} |
# 查看 compose 解析后的完整配置(包含变量替换结果)
docker compose config
# 仅查看某个服务的配置
docker compose config --services
# 验证配置文件语法
docker compose config --quiet && echo "配置文件语法正确"
# 开发环境(默认读取 .env)
docker compose up -d
# 生产环境
docker compose --env-file .env.production up -d
# 测试环境
docker compose --env-file .env.test up -d
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
| 场景 | 推荐方案 |
|---|---|
| 简单项目/本地开发 | 直接在 compose.yaml 中定义 |
| 团队协作项目 | .env + .env.example 模板 |
| 微服务架构 | env_file 按服务拆分配置 |
| 多环境部署 | .env.{environment} + --env-file |
| 敏感信息管理 | Docker Secrets(生产环境推荐) |
通过合理使用环境变量和 .env 文件,我们可以:
在本章节中,我们将学习如何有效地监控和管理 Docker 容器,包括使用命令行工具和图形化界面(Portainer)进行容器管理。
提示:本节所需的容器可以利用上一节 Docker Compose 启动的容器来演示。
以下是一些最常用的容器管理命令:
# 列出所有容器(包括停止的容器)
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>
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 容器内进程数
# 查看容器日志
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>
# 进入运行中的容器
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
Portainer 是一个轻量级的 Docker 管理工具,提供了直观的 Web 界面来管理 Docker 环境。
# 创建 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 的端口映射来实现外网访问,按照如下步骤配置:

点击浏览器图标,就可以访问 Portainer 了。
仪表盘概览
容器管理
镜像管理
网络管理
数据卷管理
# 停止并删除 Portainer 容器
docker rm -f portainer
# 删除数据卷(可选,删除后 Portainer 配置将丢失)
docker volume rm portainer_data
需要 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) |
workdir | OverlayFS 内部使用的工作目录 | 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 (只读层) │ ← 原始镜像内容 └─────────────────────────────────────┘
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 属性,确保底层镜像的该目录内容完全不可见。
这个实验演示两个基于相同镜像的容器,各自的文件修改如何隔离存储在不同的容器层(upperdir)中。
环境要求:本实验需要通过
docker inspect获取容器的 UpperDir/LowerDir 路径。如果宿主机启用了containerd-snapshotter,GraphDriver.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 镜像分层的核心优势 - 多个容器共享相同的镜像层(节省磁盘空间),同时各自拥有独立的可写层(保证隔离性)。
方法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
在/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 ← 只有可执行文件!
这个镜像:
scratch 是 Docker 的特殊空镜像,它:
ubuntu:24.04 ≈ 78MB alpine:3.19 ≈ 7MB scratch = 0MB ← 完全空白
普通编译 vs 静态编译的区别:
| 编译方式 | 依赖 | 能否在 scratch 运行 |
|---|---|---|
| 动态编译 | 需要 libc、libpthread 等共享库 | 否,缺少 /lib |
| 静态编译 | 所有依赖打包进二进制文件 | 独立运行 |
关键编译参数解释:
CGO_ENABLED=0 # 禁用 CGO,避免依赖 C 库
GOOS=linux # 目标操作系统
go build -a # 强制重新编译所有包
-installsuffix cgo # 使用独立的包缓存目录
-o hello # 输出文件名
Go 语言的优势:
对比其他语言的最小镜像大小:
Go (scratch) ≈ 2MB ← 最小 Rust (scratch) ≈ 3MB C (scratch+musl) ≈ 100KB ← 更小但配置复杂 Java (JRE) ≈ 200MB ← 需要运行时 Python ≈ 50MB ← 需要解释器 Node.js ≈ 100MB ← 需要运行时
优点:
缺点:
实际应用:scratch 镜像常用于生产环境的微服务部署,而开发/调试阶段通常使用 alpine 或 distroless 镜像。
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 会自动根据你当前的系统架构选择并拉取对应的镜像。