Docker 与可复现环境:代码一样,为什么换台机器就跑不起来?
正在读取学习记录…
Docker 帮我们把程序和运行材料装进可重复构建的镜像,再用镜像启动容器。Dockerfile 是打包配方,镜像是按配方装好的程序和依赖,容器是用这份镜像启动的一次运行。上一章的 Git 提交回答“是哪版代码”,这里进一步回答“代码用什么环境执行”。
先用一段不依赖第三方包的 Python 程序构建镜像,在容器里看到正常输出。之后再看相同提交为何可能在另一台电脑失败,以及配置、数据和卷要如何处理。Agent 的检索程序、模型客户端与工具服务也面对同样的交付问题。
读完后,你应能区分镜像与容器,预测容器内新写的文件会怎样,并判断“构建成功”之后还要检查什么。命名卷部分再用两个容器验证数据去向。
首次构建会下载基础镜像,先确认网络、磁盘空间和组织的软件使用要求。下文会分别练习构建、运行和用命名卷保存数据。
进一步理解:虚拟环境、容器和虚拟机有什么区别
从“我的环境”拆出五件事
遇到“我这里能跑”,先对比双方的运行条件:
| 条件 | 具体问题 | 应保留的记录 |
|---|---|---|
| 源码 | 是哪次提交?是否有未提交修改? | 提交号与工作区状态 |
| 解释器和系统库 | Python 是哪个版本?本地库是否可用? | 运行时与基础环境 |
| 第三方依赖 | 同名库的版本是否一致? | 依赖声明与锁定结果 |
| 配置 | 输入路径、时区、服务端点是否不同? | 无秘密的配置说明 |
| 输入与外部服务 | 资料是否更新?远端模型是否变化? | 输入版本及服务信息 |
Docker 主要封装程序和依赖。外部数据库、模型接口和运行输入仍分别管理,排查差异时一起对照这几项。
虚拟环境、容器、虚拟机怎样选
Python 的 venv 适合把项目依赖分开;它通常仍依赖当前机器的解释器和系统环境。Linux 容器把进程放在隔离的运行环境中,并使用镜像提供文件系统内容;同一 Linux 内核上的容器共享内核。虚拟机则可以运行自己的客体内核。Windows 上运行 Linux 容器还涉及 Linux 运行环境,不能把容器理解为直接执行 Windows 上的普通进程。Docker 的容器概念说明
只做标准库练习时,venv加清楚的 README 可能足够。需要交付含系统库的工具、统一团队启动方式时,容器更有价值。需要不同内核或更强隔离方案时,要评估虚拟机等方案。容器有隔离机制,但不能因此把未知代码、宿主目录和高权限随意组合。
2. 镜像是交付物,容器是运行实例
先记住两个不同的动作:build 生成镜像,run 从镜像创建并运行容器。相同镜像可以创建多个容器,它们不是同一个进程,也不默认共用各自的可写文件。
你可以把镜像想成准备好的运行材料,但它不是“当前正在执行的程序”。容器退出后,镜像仍可能存在;删除某个容器,也不等于删除构建它的源码。把这些对象混成一个“Docker”,排错时就很难说清究竟要检查哪一层。
再区分文件和进程:容器内主进程完成,容器会退出。打印结果就结束的脚本会正常退出。对于常驻服务,意外退出才需要结合退出码和日志调查。
3. 安装前先分清三个组件
| 组件 | 作用 | Windows上容易混淆的地方 |
|---|---|---|
| docker命令行客户端 | 把命令发送给引擎 | 能打印版本不等于能创建容器 |
| Docker引擎 | 构建镜像、创建和管理容器 | 必须有实际运行的服务端 |
| Docker Desktop | 桌面环境中的安装、管理和运行入口 | 安装完成后还需要启动并等待引擎就绪 |
Windows上运行这里的Linux镜像需要相应Linux运行环境。WSL 2可以先理解为Windows提供的一种Linux运行方式;它不是这里要你另写的程序。先按 Docker Desktop Windows安装说明核对系统、硬件虚拟化与WSL要求,选择适合机器架构的官方安装包。工作电脑还应先核对组织许可和软件使用条款。
Windows 安装后从开始菜单启动 Docker Desktop,等待引擎就绪,再打开终端。
macOS 从 Docker Desktop for Mac 官方安装页选择 Apple 芯片或 Intel 芯片对应的安装包。打开下载的 .dmg,将 Docker 拖到 Applications,再从应用程序中启动 Docker,等待引擎就绪后打开终端。可以在苹果菜单的“关于本机”中查看芯片类型。
确认引擎可用,再谈应用错误
在终端执行:
docker version这里要看客户端和服务端信息,而不是只看第一行版本号。客户端是你输入命令的工具,引擎负责实际创建容器。只有 Client 信息、随后出现无法连接引擎的错误,说明还不能构建或运行。此时改 Python 代码没有帮助。
只有 Client 信息、没有 Server 信息时,先打开 Docker Desktop,等待引擎启动,然后重跑 docker version。两部分都正常返回后,再开始构建。这样可以先排除“命令行已安装,但负责干活的引擎还没起来”这一类问题。
context 是客户端选择连接目标的一组设置,先用 docker context show 查看当前名称,再用 docker context ls 查看列表。看到陌生的远程目标时不要直接切换或运行实验;先确认它是不是你要使用的本机引擎。对于本例,Server部分能够正常返回,才继续第4节。仅凭桌面图标存在还不够。
4. 一个没有第三方依赖的最小交付
新建专门的练习目录,放入一个输出查询参数的 Python 程序和一份 Dockerfile。构建时 Docker 读取这个目录里的材料,因此只放这次需要的文件。
用资源管理器创建 agentdepth-docker 文件夹。在文本编辑器中保存下面两个文件,或者复制下方完整代码,保存为 app.py 与 Dockerfile;Dockerfile没有 .txt 后缀。
app.py:
# 按固定输入观察Docker 与可复现环境的处理过程和返回结果。
import json
import platform
request = {"topic": "Agent 工程基础", "limit": 3}
if type(request["limit"]) is not int or request["limit"] <= 0:
raise ValueError("limit 必须是正整数")
print(json.dumps({
"python": platform.python_version(),
"accepted": request,
}, ensure_ascii=False))这段代码承接前面的输入校验:bool也是int的子类,所以此处使用精确类型判断排除布尔值。输出包含解释器版本,方便确认到底在哪里执行。运行后会打印查询参数与解释器版本。
同目录的 Dockerfile:
FROM python:3.13.15-slim
WORKDIR /app
COPY app.py /app/app.py
USER 10001:10001
CMD ["python", "-X", "utf8", "/app/app.py"]逐行看,而不是把它当咒语:FROM选择基础镜像;WORKDIR设置后续工作目录;COPY把选定文件纳入镜像;USER让这个只读脚本以非 root 身份运行;CMD给出默认执行命令。数组形式把参数分开,不依赖 shell 替你解析一整串文本。
这里的 Python 由 FROM 选定的基础镜像提供;CMD 直接用 python 运行程序。镜像标签与摘要的区别,放在第7节用实际命令查看。
在这两个文件所在目录执行:
先在终端用 cd 进入 agentdepth-docker 文件夹,再运行 ls 确认两个文件存在。下面的点 . 指当前目录。
docker build -t agentdepth-l0-env:lesson .末尾的点是构建上下文路径;-t给镜像起名字。构建日志会逐步显示读取 Dockerfile、复制文件、导出镜像等步骤。构建正常结束、重新出现命令提示符后,运行容器:
docker run --rm --network none agentdepth-l0-env:lesson构建成功后,容器打印一条 JSON 并正常退出:
{"python": "3.13.15", "accepted": {"topic": "Agent 工程基础", "limit": 3}}--network none关闭容器的网络连接,--rm在脚本退出后删除这个容器;镜像和学习目录仍然保留。运行参数参考
python 显示容器里实际运行的解释器版本,accepted 显示通过检查的查询参数。把 limit 从3改成0,再构建并运行,程序会报告“limit 必须是正整数”;改回3后重新构建,恢复上面的输出。这样就能观察源码修改怎样进入新镜像。
5. 为什么 COPY 的范围值得单独检查
构建上下文是构建器可以读取的文件集合,不等于容器启动时的工作目录。COPY app.py从上下文读取文件,再写进镜像。你在错误目录执行 build,可能根本找不到它;把上下文设得过大,又可能把不该处理的文件带进去。构建上下文说明
很多教程为了省一行使用 COPY . .。对只有练习文件的目录可能方便,但真实项目里还有 .env、私人记录和缓存。更稳妥的习惯是先明确复制范围,再使用 .dockerignore排除无需进入构建上下文的内容。
.git
.venv
__pycache__
.env注意 .gitignore与.dockerignore不是同一个文件:前者服务版本控制,后者服务构建上下文。前者排除了秘密,不代表 Docker 构建也自动排除。已经复制进某个镜像层的秘密,不能靠之后删除文件就当作从未存在。学习示例完全不需要密钥;后续接模型时再使用合适的运行时配置或秘密管理方式。
6. 文件消失,是保存错地方还是程序没写?
想象你把实验报告写到容器内的某个普通目录。只要该目录属于容器可写层,文件就跟这个容器绑定。停止和删除不是一回事:停止后重新启动同一个容器,文件可以仍在;删除容器后,不应期待再创建一个容器就自动找回它。
| 操作 | 普通容器可写层 | 独立命名卷 |
|---|---|---|
| 停止容器 | 通常保留 | 保留 |
| 删除容器 | 随该容器删除 | 不因普通容器删除自动消失 |
| 从同镜像创建新容器 | 获得新的可写层 | 显式挂载同一个卷才访问相同数据 |
命名卷由 Docker 管理,适合把数据生命周期与容器分开;绑定挂载则把指定宿主路径映射进去。它们都不是自动备份策略。挂载了错误的目录,或者让程序拥有写权限,仍可能改坏数据。卷的生命周期
初学时先能解释这张表,再做保存报告的扩展。不要为了修复一个“权限不足”,马上改成 root、挂载整个磁盘或给容器特权。应先确认目标路径的属主、挂载方式,以及程序是否真的需要写那里。
自己验证:容器删了,报告还在吗?
前面的程序打印结果就退出了,现在把文字保存下来,观察新容器是否能读到。复制下方完整代码,保存为 write_volume.py 和 read_volume.py,保存在当前练习目录。前者创建 /data/evidence.txt 并写入文字,后者读取同一路径。Path 表示文件路径;open("x") 创建新文件,已有同名文件时会拒绝覆盖;with 在写完后关闭文件。
保存为 write_volume.py:
# 将容器里的结果写入 /data;挂载命名卷时,后续容器还能读取这个文件。
from pathlib import Path
report = Path("/data/evidence.txt")
with report.open("x", encoding="utf-8") as stream:
stream.write("AgentDepth volume lesson")
print(report.read_text(encoding="utf-8"))保存为 read_volume.py:
# 从同一挂载位置读取已有结果,检查数据是否跨容器保存。
from pathlib import Path
print(Path("/data/evidence.txt").read_text(encoding="utf-8"))确认 Docker 引擎已启动,给新卷起一个没有用过的名字,然后逐行执行:
$volumeName = 'agentdepth-l0-my-first-volume'
$pythonImage = 'python:3.13.15-slim'
docker volume create $volumeName
docker run --rm --network none --mount "type=bind,src=$PWD,dst=/lesson,readonly" --mount "type=volume,src=$volumeName,dst=/data" $pythonImage python /lesson/write_volume.py
docker run --rm --network none --mount "type=bind,src=$PWD,dst=/lesson,readonly" --mount "type=volume,src=$volumeName,dst=/data" $pythonImage python /lesson/read_volume.py
docker volume inspect $volumeName --format '{{.Name}}'macOS 终端命令
volumeName='agentdepth-l0-my-first-volume'
pythonImage='python:3.13.15-slim'
docker volume create $volumeName
docker run --rm --network none --mount "type=bind,src=$PWD,dst=/lesson,readonly" --mount "type=volume,src=$volumeName,dst=/data" $pythonImage python /lesson/write_volume.py
docker run --rm --network none --mount "type=bind,src=$PWD,dst=/lesson,readonly" --mount "type=volume,src=$volumeName,dst=/data" $pythonImage python /lesson/read_volume.py
docker volume inspect $volumeName --format '{{.Name}}'预测:两次 docker run 都打印 AgentDepth volume lesson。每次结束时 --rm 会删掉那个容器,第二次创建的是新容器;它仍能读到文字,因为两次显式挂载了同一个命名卷。最后一行应打印你刚创建的卷名。若第二次找不到文件,先核对两条命令里的卷名是否完全相同,再查第一条是否成功写入。若第一次出现 FileExistsError,说明该卷里已有同名文件;open('x') 故意拒绝覆盖,此时不要直接删卷,先换一个全新的练习卷名。
两次挂载各有用途:bind 把当前目录的程序映射到容器里的 /lesson,readonly 让容器只能读取源码;volume 把持久数据映射到 /data。第二个容器能读到第一个写的文字,说明数据保存在卷中。这个卷由本机 Docker 引擎管理,换电脑需要另外迁移或恢复数据。确认练习卷没有需要保留的数据后,可执行 docker volume rm $volumeName;这会删除卷内的数据。
7. 固定版本为什么还不够
镜像标签像书名与版次,摘要(digest)像某一份内容的指纹。python:3.13.15-slim 选定 Python 版本与精简镜像系列,发布者仍可能更新其中的系统组件。要让同事取到完全相同的基础镜像,可以用摘要固定内容。Docker 的版本固定说明
交付程序时,记录这些运行材料:源码提交及未提交差异、基础镜像引用和实际摘要、构建参数、运行命令、输入文件版本,以及需要但不公开其秘密值的配置名称。
构建成功后,先查看基础镜像的仓库摘要和系统架构,再查看自己构建的镜像 ID:
docker image inspect python:3.13.15-slim --format '{{json .RepoDigests}}'
docker image inspect python:3.13.15-slim --format '{{.Os}}/{{.Architecture}}'
docker image inspect agentdepth-l0-env:lesson --format '{{.Id}}'第一行返回一个列表,里面的引用形如 python@sha256:…;sha256: 后面是完整摘要。第二行显示镜像的操作系统和处理器架构。第三行的 Id 标识你在本机构建的产物。仓库摘要用于指定从仓库取得哪份内容,本地 ID 用于识别本机产物。镜像检查命令
进一步固定基础镜像时,把实际查询到的引用组成 Dockerfile 的第一行:
$baseImage = docker image inspect python:3.13.15-slim --format '{{index .RepoDigests 0}}'
"FROM $baseImage"macOS 终端命令
baseImage=$(docker image inspect python:3.13.15-slim --format '{{index .RepoDigests 0}}')
printf 'FROM %s\n' "$baseImage"复制输出的整行,替换原来的 FROM python:3.13.15-slim,其余行保持不变,再执行前面的构建与运行命令。之后构建会按这个摘要选择基础内容。若摘要列表为空,先执行 docker pull python:3.13.15-slim 再查询。更新基础镜像时,重新获取摘要、构建并测试,确认后保存新的引用。
后续 Agent 实验还要记录模型服务与评测输入。即使容器完全一致,外部接口更新、模型行为变化、时间相关输入也可能改变结果。环境复现帮助缩小排查范围,不把概率系统变成确定性函数。
8. 按失败阶段排查,比反复重建有效
| 现象 | 先判断哪一层 | 最小检查 |
|---|---|---|
| 无法连接 Docker 服务 | 客户端与引擎连接 | docker version是否有 Server 信息 |
| 获取基础镜像失败 | 镜像地址、网络或授权 | 看失败发生在下载阶段还是应用阶段 |
| COPY 找不到 app.py | 构建上下文 | 当前目录、文件名、ignore规则 |
| 容器提示权限不足 | 进程身份与路径 | 程序写哪里、当前用户有何权限 |
| 脚本打印后容器退出 | 进程生命周期 | 本来是否就是一次性任务 |
| 新容器没有上次报告 | 数据生命周期 | 文件是否只写入已删除容器 |
故障记录保留执行命令、错误阶段、关键错误文本和预期行为,隐去身份信息与秘密。下一篇学习日志与测试时,我们会把这套人工判断变成可重复检查。
9. 自测:预测、解释,再迁移
课后练习展开习题收起习题
1. app.py改了,但没重新build,再次run会执行新代码吗?
如果 Dockerfile 使用 COPY 且没有绑定挂载,宿主文件修改不会自动进入已有镜像。需要重新构建,并确认运行的是新镜像。
2. 容器输出JSON后退出,是否说明Docker运行失败?
不一定。本例是一次性脚本,任务完成后退出符合预期。还应检查退出码和输出内容;单凭是否持续running无法判断业务成功。
3. .gitignore写了.env,就能放心COPY整个目录吗?
不能。Git忽略规则不等于Docker构建规则。应检查构建上下文、复制范围和.dockerignore,避免秘密进入镜像。
4. 同一个镜像运行两次,模型回答不同,该先重装Docker吗?
不应该。先对比输入、配置、外部服务和模型行为,再确认是否存在环境差异。镜像一致不保证所有外部条件相同。
做一个与程序直接对应的小变化:在 app.py 中把 "limit": 3 改为 "limit": 0,保存,再逐条执行:
docker build -t agentdepth-l0-env:lesson .
docker run --rm --network none agentdepth-l0-env:lesson
$LASTEXITCODEmacOS 终端命令
docker build -t agentdepth-l0-env:lesson .
docker run --rm --network none agentdepth-l0-env:lesson
echo $?构建成功后,运行会在输入检查处抛出 ValueError,退出码为非零值。把 limit 恢复为3,重新执行同样三条命令,输出恢复为正常 JSON、退出码0。每次都重新构建,是因为 COPY 将当时的源码放入镜像;仅保存宿主文件不会改已有镜像。
完成这次修改后,核对三种结果:正常输入打印 JSON 并以 0 退出;非法输入出现校验错误并以非零值退出;恢复输入并重新构建后,正常结果回来。把每种结果对应到程序中的检查,就能理解镜像更新与程序运行的关系。
正在读取本地记录…
不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。
选择题 · build 和 run 分别做什么?
已经修改 app.py,但没有重新 build 镜像,直接 run 原镜像。最可能执行哪份代码?
对应知识点:L0-docker
简答题 · 为什么提交号还不够?
两台机器使用同一个 Git 提交号,但一台缺少系统库、另一台依赖版本不同。请写出还应记录的至少三类运行条件,并说明原因。
对应知识点:L0-docker
代码题 · 收紧一个危险的构建上下文
把 `COPY . .` 改成只复制 app.py,并写出一个 .dockerignore,排除 .env、.git、.venv 和 __pycache__。提交文件片段与理由。
对应知识点:L0-docker
场景题 · 容器删除后报告不见了
一次性容器生成了报告,运行时使用了 `--rm`,退出后报告消失。请设计保存方案,并说明为什么不能直接给容器 root 权限或挂载整个磁盘。
对应知识点:L0-docker
10. 把代码与运行环境连起来
对照刚才的操作:Dockerfile 描述配方,镜像保存构建成品,容器运行程序,数据卷保留需要跨容器保存的文件。
这一篇先记住一条主线:docker build 按 Dockerfile 把代码和依赖做成镜像,docker run 用镜像启动容器。改了电脑上的 app.py,已有镜像不会自己变;要重新构建。需要在容器结束后保留的数据,应另行设计卷或受限挂载。
跟做时,分别保存构建输出、容器运行结果和命名卷的读回记录。构建上下文与卷的细节可查 Docker 构建上下文 和 卷文档。
开源阅读先看Docker官方文档源码理解概念和示例,再在需要研究构建机制时看BuildKit,最后按问题进入Moby。这是学习顺序,不是活跃度或性能排名,新人不必现在读完底层源码。
前面的 Git 保存代码版本,Docker 组织运行条件。下一篇 日志、pytest 与 CI 把检查步骤变成可重复运行的测试。
参考资料
以下 Docker 官方资料可用于查阅安装步骤、镜像构建和容器命令。
| 读者问题 | 官方资料 | 这里用途 |
|---|---|---|
| 镜像、容器和运行命令各是什么? | 容器基础 · docker run | 图1与一次性任务的预期行为 |
| Windows 如何安装并判断引擎就绪? | Docker Desktop Windows 安装 · docker version | 第3节的安装与引擎检查 |
| COPY 能读哪些文件、怎样排除? | 构建上下文与 .dockerignore | 第5节的边界与秘密风险 |
| 删除容器后数据在哪里? | 卷与容器可写层 | 第6节表格和图2 |
| 标签与摘要如何影响复现? | 构建最佳实践 · docker image inspect | 第7节的版本定位 |
命令结果对应前面的构建、运行和数据卷操作。遇到问题时,先确认失败发生在引擎连接、镜像下载、构建还是程序运行,再查看对应输出。
描述会发送给本站维护者,用于修订文章,保存90天。请只填写文章问题,省略个人信息、密钥和内部资料。