Docker 本地启动:把“在我机器上能跑”变成别人也能复现¶
上一课结束时,AI 学习助手已经能在开发者的电脑上启动,测试也全部通过。新同事 克隆仓库,照着 README 运行:
第一行就报 ModuleNotFoundError。源码和 requirements.txt 都在,缺的是已经安装
好依赖的 Python 环境。开发者想到了一个看起来最快的办法:把自己的 .venv 压缩
后一起发过去。
问题暂时消失了,但换到另一种操作系统或 CPU 架构,压缩包又可能不能用。虚拟环境 里可能记录了解释器路径,也可能包含只适用于原平台的二进制包。更重要的是,新同事 仍不知道这套环境是怎样从依赖声明建立出来的。他拿到的只是另一台电脑留下的结果。
这正是我们引入 Docker 的位置。本课不会把“会写两条 Docker 命令”当作终点,而是 从头走完一次可复核的本地交付:决定哪些内容进入构建结果,控制 Docker 能看到的 文件,让 FastAPI 在隔离的运行环境中正确启动并能从本机访问,把命令写回 README, 最后从不依赖旧环境的起点重新验证。
示例使用 Linux amd64、Docker Engine 29.4.3、Python 3.12、FastAPI 0.115.14 和
Uvicorn 0.34.3。文中的服务名称、端口和命令已经在独立目录验证;
主项目路径尚未提供,因此实际实践时要把模块名和配置项替换为 P1 仓库里的真实
入口。本课只处理单个 FastAPI 服务的 Docker 本地启动,不包含 Compose、PostgreSQL、
migration、持久 volume、生产构建、供应链检查或公网部署。
先别把 .venv 发给同事:Docker 真正需要交付什么¶
把 .venv 交给别人,实际是在交付“作者电脑此刻的状态”。我们需要的是另一种
东西:只要源码和依赖声明相同,就能在兼容环境里重新构建出来,而且之后可以用它
创建一次又一次运行。
Docker 把这样的构建结果称为 image,中文通常叫“镜像”。给它一个
p1-learning-service:w01-l05 这样的 tag,后面的命令就能明确指出要使用哪份
构建结果。
概念|Docker image 由构建声明和输入形成、用于创建 container 的静态文件与运行元数据。 它保存 Python 环境、已安装依赖、应用源码和默认启动信息,但本身不是正在运行的 服务。
image 不是把整个硬盘做成压缩包。对当前 FastAPI 服务,可以逐项判断:
| 对象 | 是否进入 image | 为什么 |
|---|---|---|
| Python 3.12 运行环境 | 是 | 应用需要它执行 |
requirements.txt 中声明的依赖 |
是 | 每次运行都需要,应由声明重新安装 |
app/ 源码 |
是 | 这是要运行的程序 |
| 默认启动命令 | 是,作为元数据 | 创建 container 时需要知道默认启动什么 |
项目 .venv/ |
否 | 它来自当前 host,可能含路径和平台相关内容 |
.pytest_cache/ |
否 | 它只是本地测试留下的缓存 |
| 真实 API token | 否 | 具体运行值不应随 image 分发 |
本机端口 8000 |
否 | 另一位开发者可能需要使用 9000 |
这里的“静态”不表示 image 永远不变。修改依赖或源码后,我们会构建一份新 image; tag 指向哪一版也取决于版本策略。本课只要求当前声明能够重建,不要求不同日期的 构建结果在字节层面完全相同。
先停一下想一想:如果两位开发者要使用同一个 image,一位希望从本机 8000 访问, 另一位的 8000 已被占用,只能使用 9000,那么 host 端口应该写进 image 吗?答案是 不应该。它属于“这一次怎么运行”,不是“所有运行共同需要什么”。
image 还没有运行,container 才有进程¶
执行 docker run 后,Docker 会从 image 创建一个 container。同一个 image
可以创建多个 container;停止其中一个,不会改动 image,也不会要求其他 container
一起停止。
概念|Docker container 从 Docker image 创建、带有本次运行配置、可写层和进程的实例。image 回答 “拿什么启动”,container 回答“这一次怎样运行”。
可以暂时把 image 类比成磁盘上的程序文件,把 container 类比成已经启动的进程。 这个类比只帮助我们区分静态与运行状态,并不覆盖全部细节:container 还有自己的 网络视图、临时可写层和 Docker 运行配置。
image: p1-learning-service:w01-l05
├── container: p1-local-a
│ ├── process
│ ├── environment values
│ └── writable layer
└── container: p1-local-b
├── another process
├── another environment values
└── another writable layer
假设 p1-local-a 在自己的 /tmp 下建立了 debug.txt。这个文件不会回写到 image,
也不会自动出现在 p1-local-b。删除 p1-local-a 后,它的临时可写层随之消失。
需要跨 container 保存的数据要另外设计 volume,但那不在本课范围内。
container 至少需要一个活着的进程。这个进程退出,container 就停止。稍后我们会 看到为什么 Uvicorn 必须留在前台,以及这件事如何影响正常停止。
container 仍然需要一台兼容的 host¶
把 Python 和依赖放进 image 后,很容易得出另一个过头的结论:“现在任何机器都能 运行它了。”如果目标机器没有 Docker Engine,或者 CPU 架构不兼容,这句话显然 不成立。
概念|container/host 边界 container 封装用户空间文件和进程,但仍依赖 host 提供兼容的内核、CPU architecture 与 Docker Engine。Docker 会减少环境差异,不会消除平台前提。
Linux container 通常共享 host 的 Linux kernel。当前示例能在 WSL2 中的 Linux
Docker daemon 上运行,是因为这些条件彼此兼容;一个 amd64 image 也不能不加
条件地在任意架构上执行。因此 README 至少要说清楚:
- 需要什么类型的 Docker 环境;
- 从哪里取得源码;
- 构建是否要访问基础 image 和 Python 包来源;
- 这套命令在哪个系统与架构上验证过。
Docker 也不会替代应用测试。上一课的 pytest 基线证明业务规则与 HTTP 契约;本课 证明的是同一服务可以从声明的 image 启动。container 进程活着,不会自动证明输入 校验、错误映射和业务结果都正确。
现在可以把材料放回三个时间点:
| 条件 | 在什么时候决定 | 具体例子 |
|---|---|---|
| 每次运行都需要的文件和依赖 | build 时 | Python、FastAPI、Uvicorn、app/ |
| image 的默认启动信息 | build 时写入 | Uvicorn 模块、container 内端口 |
| 本次运行可能变化的值 | 创建 container 时 | APP_ENV、host 端口 |
| 真正运行的进程 | container 运行时 | 当前 Uvicorn 进程 |
| Docker 和兼容平台 | host 前置 | Engine、kernel、CPU architecture |
判断方法并不复杂:所有实例共同需要、能够从声明重建的用户空间内容进入 image; 每次运行可能变化的值留到创建 container 时;内核、架构和 Docker Engine 写成 host 前置。下一步的问题是,Docker build 到底能看到哪些文件,又怎样把这些文件变成 刚才描述的 image。
在继续之前,把几种常见说法放回这个模型里检查一下:
- “image 就是一个没有启动的 container”不够准确。image 是创建实例的静态来源, container 还会增加本次配置、可写层与进程;两者不是同一个对象的开关状态。
- “container 是一台轻量虚拟机”容易让人以为它自带完整 kernel。当前 Linux container 仍要使用兼容的 host kernel;我们只是暂时不展开 namespace 与 cgroup。
- “Docker 会把当前
.venv自动装进去”也不成立。构建文件复制什么、安装什么, 完全取决于构建输入和指令;正确的依赖来源仍是requirements.txt。 - “只要 container 为
Up,应用就没问题”把进程存活、网络可达和业务正确混成了 一件事。稍后会为这三层分别找证据。
这些纠正不是术语考试。它们会直接影响文件该放哪里、README 该写什么,以及发生 故障时先检查哪一步。把对象分清以后,后面的构建文件不再是一组需要死记的命令, 而是在逐项兑现这里做出的选择。
构建命令最后那个点,决定 Docker 能看到哪些文件¶
在仓库根目录执行:
命令末尾的 . 很不起眼,却不是句号。它代表当前目录,并把这个目录交给 Docker
作为本次构建可使用的输入范围。
概念|build context build 命令指定、builder 可以读取并供
COPY或ADD使用的输入集合。使用 本地目录时,命令最后的 path 决定这个集合从哪里开始。
假设目录是:
下面两条命令都能找到同一个 Dockerfile,context 却不同:
# 当前目录是 /workspace/p1,context 是项目根
docker build -t p1-learning-service:w01-l05 .
# 当前目录是 /workspace,context 是整个 workspace
docker build -f p1/Dockerfile -t p1-learning-service:w01-l05 .
第二条命令的 -f 只指定 Dockerfile 在哪里,末尾的 . 仍然把整个 /workspace
作为 context。Dockerfile 的位置与 context 的范围是两件事。
COPY app ./app 只能读取 context 中存在且没有被排除的 app。写成
COPY ../../private.txt /app/ 也不能越过 context 去读取 host 的任意文件。正确
做法是把构建确实需要的文件放进一个清楚的 context,而不是依赖作者电脑上的绝对
路径。
Dockerfile 把输入变成 image¶
知道 Docker 能看见什么以后,还要告诉它依次做什么。承担这项工作的文件就是 Dockerfile。
概念|Dockerfile 构建契约 Dockerfile 用有序指令说明基础 image、工作目录、依赖、源码和默认运行信息怎样 形成目标 image。每一步都应能指出它读取了什么,以及后面哪个步骤会使用结果。
教学示例的完整目录是:
.
├── app/
│ ├── __init__.py
│ └── main.py
├── tests/
├── .env
├── .git/
├── .pytest_cache/
├── .venv/
├── README.md
├── requirements.txt
└── Dockerfile
requirements.txt 固定使用:
Dockerfile 如下:
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN python -m pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
不要急着背指令,先沿着文件移动的方向读:
| 指令 | 读取什么 | 结果去了哪里 |
|---|---|---|
FROM |
可获取的 python:3.12-slim |
得到 Python 用户空间起点 |
WORKDIR |
/app 路径 |
后续命令默认在这里执行 |
第一次 COPY |
context 中的依赖文件 | /app/requirements.txt |
RUN |
Python 与依赖文件 | 把 FastAPI、Uvicorn 安装进 image |
第二次 COPY |
context 中的 app/ |
/app/app/ 源码 |
EXPOSE |
端口数字 | 记录预期的 container port |
CMD |
程序与参数 | 保存创建 container 时的默认命令 |
最后两行只保存运行信息,build 时不会启动 Uvicorn。现在只需检查:默认命令需要的
/app/app/main.py 和依赖,是否真的由前面的步骤提供。python:3.12-slim 是本课
验证过的基线,不代表生产版本策略已经完成;patch tag、digest 和自动更新属于后续
主题。
.gitignore 不会替 Docker 排除文件¶
当前 context 里还有 .venv/、.git/、pytest 缓存和 .env。即使 Dockerfile
没有主动使用它们,把这些内容发送给 builder 仍然会增加构建输入,也可能在后来
改成宽泛 COPY 时被意外带入 image。
概念|
.dockerignore输入边界.dockerignore在 build context 发送给 builder 之前排除匹配的文件和目录。 它控制 Docker 的构建输入,不等于.gitignore,也不会从已经构建好的 image 中删除文件。
当前示例可以使用:
每一行都应该能回答“为什么”:
.git/、pytest cache 和 Python cache 不参与服务运行;.venv/应由requirements.txt重建,不能继承 host 上的旧包;.env可能包含当前运行值,不应进入静态 image;requirements.txt与app/必须保留,否则 Dockerfile 没有输入可用。
.gitignore 与 .dockerignore 中可能出现相同路径,但它们服务于不同动作:
.env 没被 Git 跟踪,不代表 Docker 看不到它。反过来,一个文件被 Docker 排除,
也不表示它已经从 Git 历史消失。规则写得过宽同样会出错:如果先排除 *,却忘记
把依赖文件和源码放回来,COPY 会在最早需要它们时失败。
为什么先复制依赖,再复制源码¶
比较两种 Dockerfile 顺序:
# B:依赖声明不变时,源码变化不必重做依赖安装
COPY requirements.txt ./
RUN python -m pip install --no-cache-dir -r requirements.txt
COPY app ./app
概念|build layer 与 cache Dockerfile 指令会产生可以复用的构建结果;某一步的输入变化时,该步以及后续步骤 需要重新执行。cache 用于减少重复工作,不是构建成功必须依赖的隐藏条件。
在 B 中只修改 app/main.py,依赖文件没有变化,安装依赖的结果可以复用;修改
requirements.txt 时,复制依赖和安装依赖都必须重新执行。A 在任何文件变化后都
会先改变宽泛 COPY 的结果,后面的安装步骤也随之失去复用机会。
原则不是“依赖必须写在固定行号”,而是先处理变化较少、可以独立复用的输入,再 处理变化频繁的源码,同时保证依赖声明变化时安装结果一定失效。
cache 也可能掩盖缺口。作者可能一直启动旧 image,根本没有重新执行当前 Dockerfile。 因此我们还要能运行:
成功表示当前 context、Dockerfile、依赖声明和可获取的外部资源足以构建 image, 没有复用 build cache。它仍然依赖 Docker Engine、基础 image 和包源网络,也不证明 跨时间、跨架构能得到字节完全相同的结果。
如果构建失败,先看最早失败的步骤,而不是凭感觉改代码:
| 最早失败位置 | 先检查什么 | 不要先做什么 |
|---|---|---|
| load context | 当前目录、context path、ignore 规则 | 改 FastAPI 代码 |
COPY requirements.txt |
文件是否在 context 且未被排除 | 塞进一个旧 container |
| 依赖安装 | 包名、版本、包源网络 | 在 host .venv 里安装后重试 |
COPY app |
源码路径与目录结构 | 扩大为读取整个 host |
| image 导出 | daemon 存储和构建结果 | 直接宣称服务已经能运行 |
到这里,我们已经得到一份可以重新构建的 image。接下来真正运行它,并观察为什么
docker ps 显示 Up 仍然可能打不开网页。
在真实仓库里提交构建文件前,还可以做一次很短的反向检查:
- 从 README 指定的目录执行时,命令最后的 context 是否正好是项目根?
- Dockerfile 每个
COPY的来源是否都在 context 中,而且没有被 ignore? .dockerignore排除的是当前项目的本地状态,还是从网络模板复制来的大清单?- 默认命令所需的模块和依赖,是否都能追溯到前面的复制与安装步骤?
- 只改
app/时,依赖安装是否可以复用;改requirements.txt时,它是否一定 重新执行? - 使用
--no-cache后,构建是否仍然不依赖 host.venv和旧 image?
其中任何一项回答不清楚,都比“先 build 看看”更值得停下来处理。否则构建偶然成功 以后,缺口只会移动到运行时,错误信息也会离真正原因更远。
Container 已经 Up,浏览器为什么还是打不开¶
你从刚才的 image 创建 container,Docker 返回一串 ID,docker ps 显示
Up 5 seconds。可是打开 http://127.0.0.1:8000/docs,浏览器仍然连接失败。
Up 只说明 Docker 当前仍把这个 container 视为运行中。它没有告诉你实际运行的
是不是 Uvicorn、Uvicorn 监听了哪个地址、container 端口有没有转发到 host,也没有
证明 HTTP 请求真的到达 FastAPI。
-d 只让当前终端脱离¶
先比较三条命令:
# 当前终端持续显示运行输出
docker run --rm p1-learning-service:w01-l05
# CLI 返回 container ID,container 继续运行
docker run --rm -d p1-learning-service:w01-l05
# 命令打印一次就退出,container 随即停止
docker run --rm python:3.12-slim python -c "print('done')"
第二条命令里的 -d 表示 detached:当前终端不持续附着日志。它没有要求 Uvicorn
在 container 里把自己变成后台 daemon。真正决定 container 能活多久的,是里面的
主要前台进程。
概念|container main process container 中决定实例存活、接收默认停止信号的主要前台进程。这个进程退出, container 随之停止;当前 CLI 是否 detached 是另一层选择。
因此,下面的写法不适合作为默认启动:
& 把 Uvicorn 推到后台后,启动它的 shell 可能结束,container 也会失去应当负责
生命周期的前台进程。container 内的应用通常不需要自己 daemonize;让 Uvicorn
留在前台,Docker 才能观察它何时退出,并把停止请求交给它。
| 你看到的状态 | 它能说明什么 | 它不能说明什么 |
|---|---|---|
| CLI 已返回 | 使用了 detached,或者命令已经结束 | container 一定还在运行 |
container 为 Up |
main process 尚未退出 | HTTP 一定可访问 |
container 为 Exited (0) |
main process 正常结束 | 服务曾经成功接收请求 |
container 为 Exited (1) |
main process 失败退出 | 根因一定属于 Docker |
默认命令也决定能否正常停止¶
Dockerfile 中的 CMD 使用 JSON 数组,而不是一整串命令文本:
概念|exec-form container command 以参数数组直接声明默认可执行程序,不自动包裹命令 shell。对当前服务来说, 它让 Uvicorn 直接成为主要进程,并直接接收 Docker 的终止请求。
先只看最短形式:
与之相对,shell form 是:
shell form 会先通过命令 shell 解释字符串。如果这个 shell 没有把信号正确转交给
应用,docker stop 的终止请求就可能先到 shell,而不是直接到 Uvicorn。FastAPI
的容器文档建议使用 exec form,以便应用正常关闭并触发 lifespan shutdown。
前面关于 FastAPI lifespan 的教材 已经说明 启动和清理应当成对出现。放进 container 后,顺序变成:
Docker 创建 container
→ exec-form command 启动 Uvicorn
→ FastAPI lifespan startup 执行
→ 应用处理请求
docker stop 发出终止请求
→ Uvicorn 收到信号
→ FastAPI lifespan shutdown 执行
→ Uvicorn 退出
→ container 停止
本课不展开 ENTRYPOINT 与 CMD 的组合、自定义 STOPSIGNAL 或 init 进程。当前
目标是让默认命令直接、非交互、可观察,并与 README 中的停止方式一致。
Uvicorn 要在 container 的网络接口上接收请求¶
即使 Uvicorn 正常运行,监听地址不对,转发进来的请求仍然没人接。
概念|container listening boundary 应用在 container 自己的网络空间里绑定地址和端口;这个选择决定哪些 container interface 能把连接交给应用。它不是 host 的端口转发规则。
比较:
# 只接受 container 自己从 loopback 发出的连接
uvicorn app.main:app --host 127.0.0.1 --port 8000
# 接受到达 container IPv4 interfaces 的连接
uvicorn app.main:app --host 0.0.0.0 --port 8000
在 container 里,127.0.0.1 指向这个 container 自己。Docker 从 host 转发来的
流量会到达 container 的网络 interface,并不等于请求从 container 内部 loopback
发出。因此本地入口让 Uvicorn 监听 0.0.0.0:8000:
0.0.0.0 只描述 container 内的 bind,不表示 host 已经安全地向全世界开放。host
从哪些接口接受请求,是下一层的选择。
host 端口与 container 端口不是同一个位置¶
Uvicorn 已经在 container 的 8000 接收连接,现在要给 host 建一条到达它的路。
概念|port publishing 创建 container 时,用
HOST_IP:HOST_PORT:CONTAINER_PORT建立从 host 地址和 端口到 container 端口的转发。三段分别控制 host 接口、host 入口和实例内目标。
当前命令是:
逐段读就是:
因此访问 URL 是 http://127.0.0.1:8000。如果改成
127.0.0.1:9000:8000,Uvicorn 仍监听 container 8000,浏览器要改为访问 host
9000。省略 host IP、只写 -p 8000:8000 时,Docker 可能在所有 host interfaces
上发布。本课只做本地启动,所以显式使用 127.0.0.1;公网、防火墙和 TLS 留给后续
部署课程。
Dockerfile 里的 EXPOSE 8000 经常被误认为已经完成发布:
概念|
EXPOSE端口元数据EXPOSE记录 image 预期监听的 container port,但不会自动向 host 发布端口。 真正的 host 转发仍由docker run -p或等价运行配置建立。
这行帮助读者和工具知道 image 预期使用 8000,却不会自动让浏览器访问 host 8000。 一个是 image 对自身运行意图的说明,一个是当前 container 怎样接入 host。
把监听与发布放在同一张表里,会更容易看出“Up 但不可访问”的差别:
| container 内的 Uvicorn | docker run 的发布 |
从 host 访问的结果 |
|---|---|---|
127.0.0.1:8000 |
127.0.0.1:9000:8000 |
转发到了实例,但应用只收自己的 loopback |
0.0.0.0:8000 |
无 -p |
应用在实例内等待,host 没有入口 |
0.0.0.0:8000 |
127.0.0.1:9000:8000 |
网络条件匹配,还要用 HTTP 验证应用 |
| 进程已经退出 | 任意正确映射 | 没有应用消费请求,端口规则也救不了它 |
这张表也解释了为什么排查顺序应该从进程和日志开始,再看监听、发布和 URL。端口 数字相同不表示它们处于同一个网络空间;每经过一层,都需要当前层实际存在。
先停一下:Uvicorn 监听 0.0.0.0:8000,运行命令使用
-p 127.0.0.1:9000:8000。应该访问哪个 URL?答案是
http://127.0.0.1:9000;URL 使用 host 侧端口,最后一个 8000 才是应用所在位置。
不要只盯着 docker ps¶
创建 container 后,按请求实际经过的路径收集证据:
# 1. 实例、命令和端口
docker ps --filter name=p1-w01-l05
docker inspect p1-w01-l05
# 2. 实际应用日志
docker logs p1-w01-l05
# 3. host 到 FastAPI 的 HTTP 路径
curl -fsS http://127.0.0.1:8000/openapi.json
# 4. 正常停止
docker stop p1-w01-l05
已验证示例的稳定观察是:
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
ports: 127.0.0.1:8000 -> 8000/tcp
logs: Application startup complete.
HTTP: 返回可解析的 OpenAPI JSON
stop: Uvicorn 处理 shutdown,主要进程退出
日志文字可能随版本变化,真正稳定的是应用完成启动、HTTP 请求成功、停止请求到达
进程并让 container 结束。openapi.json 是示例已有的公开入口,不替主项目决定
业务 endpoint,也不是 Docker HEALTHCHECK。
如果 container 为 Up 但 curl 失败,按顺序问:Uvicorn 真的启动了吗?实际 bind
参数是什么?host 映射存在吗?两侧端口对应吗?URL 使用的是 host port 吗?每个
问题都有日志、inspect 或 HTTP 可以观察,比反复换端口和重启更容易找到第一处断点。
把真实的构建、配置和停止方式写回 README¶
现在 image 能构建,container 也能从本机访问。开发者在聊天里发一句
docker run p1-learning-service:w01-l05,同事照做以后,Uvicorn 的确启动了,却
不知道去哪里访问,也不知道怎样查看日志、正常停止。
这说明“作者知道命令”与“仓库提供入口”仍然是两回事。README 不需要复制 Dockerfile 的每一行,但必须把第一次进入仓库的人从前置条件带到可观察结果。
前面关于 README 任务入口的教材 已经建立 过一个原则:README 指向真实可执行入口和权威文件,而不是维护第二套容易漂移的 配置。加入 Docker 后,需要写清的任务链变长为:
前置条件
→ 从哪个目录 build
→ image 使用什么 tag
→ container 使用什么名称和端口
→ 什么 HTTP 结果表示可以访问
→ 怎样看 logs
→ 怎样 stop
→ 当前明确没有哪些能力
一段可执行说明可以是:
## Docker 本地启动
前置条件:Linux `amd64` 或声明兼容的平台,Docker Engine 可用。
以下命令均从仓库根目录执行。
构建:
docker build -t p1-learning-service:w01-l05 .
启动,仅绑定本机:
docker run --rm -d \
--name p1-w01-l05 \
-p 127.0.0.1:8000:8000 \
p1-learning-service:w01-l05
验证:
curl -fsS http://127.0.0.1:8000/openapi.json
查看日志与停止:
docker logs p1-w01-l05
docker stop p1-w01-l05
当前只支持单个 FastAPI container;不包含 Compose、数据库、
持久 volume、生产 image 或公网部署。
这段说明没有逐行解释 FROM、COPY 和 CMD;这些细节的权威来源是 Dockerfile。
README 写的是读者必须执行的任务、使用的名称和应该看到的结果。tag、container 名称
和两侧端口一旦与真实命令不一致,README 就会从入口变成陷阱。
“命令没有报错”也不是统一的成功标准。build 后要能定位新 image;run 后要看到 应用日志和实际 HTTP 响应;stop 后要确认主要进程和 container 按预期结束。把观察 结果写清楚,同事才知道应该继续还是开始定位失败。
运行值不要烘焙进 image¶
假设应用读取 APP_ENV 和 OPENAI_API_KEY。为了做到所谓“开箱即用”,有人想把
当前 .env 直接复制进 image。这样确实少写了一次运行参数,也让真实 token 跟着
image 一起保存和分发。
概念|runtime configuration injection 创建 container 时提供本次运行所需的配置值,让同一个 image 可以使用不同配置, 而不把具体值固化进 image。变量名称可以公开,真实 secret 值不应进入 image 或 README。
先看反例:
第一行是否合理,要看项目是否真的需要一个非敏感默认值;第二行会把当前 .env
写进构建结果。之后即使从 host 删除该文件,已经形成的 image 和构建历史也不会因此
自动清除内容。
本地非敏感值可以在创建 container 时传入:
docker run --rm -d \
--name p1-w01-l05 \
-p 127.0.0.1:8000:8000 \
-e APP_ENV=local \
p1-learning-service:w01-l05
项目需要从文件读取多项值时,可以使用 host 上未提交的运行文件:
docker run --rm -d \
--name p1-w01-l05 \
-p 127.0.0.1:8000:8000 \
--env-file .env.local \
p1-learning-service:w01-l05
README 记录变量名、用途、是否必填,以及安全示例文件怎样准备,不粘贴真实 key。 完整的 secret 存储、轮换、最小权限与生产注入由后续安全课程处理;当前先守住一条 底线:不要用“省一步配置”换取凭据随 image 扩散。
运行时注入还有一个直接好处:同一 image 可以创建使用不同 APP_ENV 的 container,
不需要为每个值重新 build。构建失败时检查 Dockerfile 和输入,配置失败时检查本次
运行参数与说明,两个问题也不会混在一起。
一个文件要经过三次不同的判断¶
.dockerignore 并不能独自回答“这个文件安全吗”。一个文件从开发目录走向交付,
至少经过三个问题:Git 会不会跟踪它?builder 会不会收到它?Dockerfile 会不会把它
复制进 image?
| 路径 | Git | build context | image | 原因 |
|---|---|---|---|---|
app/ |
跟踪 | 包含 | 包含 | 运行源码 |
requirements.txt |
跟踪 | 包含 | 用于安装 | 依赖声明 |
Dockerfile |
跟踪 | builder 需要 | 无需复制 | 构建说明本身 |
.dockerignore |
跟踪 | builder 需要 | 无需复制 | 控制发送范围 |
README.md |
跟踪 | 可排除 | 无需包含 | 留在仓库供人阅读 |
.venv/ |
不跟踪 | 排除 | 不包含 | host 的历史环境 |
.pytest_cache/ |
不跟踪 | 排除 | 不包含 | 本地测试缓存 |
.env.local |
不跟踪 | 排除 | 不包含 | 当前运行值 |
这张表能找出单条 ignore 规则看不到的问题:.env.local 没进 Git,却仍可能被宽泛
COPY . . 带入;.venv/ 已从 Docker context 排除,却可能早就被 Git 跟踪;
Dockerfile 也可能绕过常见路径,明确复制另一份本地配置。
.dockerignore 是减少构建输入和误复制的一层防线,不是完整 secret 管理方案。
真正安全还需要不提交真实值、不写危险复制指令、检查实际 image,并在需要时使用
专门的 secret 管理能力。
Dockerfile 改了,README 也要跟着核对¶
假设 container 内端口从 8000 改成 8080:
-EXPOSE 8000
-CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+EXPOSE 8080
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
README 仍写 -p 127.0.0.1:8000:8000 时,host 请求会转到 container 8000,应用
却在 8080 等待。正确关系应改为:
host 仍从本机 8000 访问,container 目标改成 Uvicorn 实际监听的 8080。验证 URL
仍然是 http://127.0.0.1:8000。
以下变化都应触发 README 核对:image tag 或 container 名称改变;默认启动模块、
工作目录或依赖声明改变;运行变量新增或重命名;本地 URL 改变;--rm、detached
和停止方式改变。
说明也不能提前承诺尚不存在的能力。仓库没有 compose.yaml,就不要写
docker compose up;没有数据库和 volume,就不要承诺数据可以跨重建保存;没有
公网部署,就不要把本地端口称为 production endpoint。准确地写出当前限制,比一份
看起来包罗万象、执行却会失败的 README 更可靠。
写完以后,可以把 README 当成一位不在场同事来试读。它是否能独立回答这些问题:
- 我需要先安装 host Python,还是只需要 Docker?
- 命令从仓库根目录还是某个子目录执行?
- build 完成后应该得到哪个 tag?
- run 使用哪个 container 名称,访问的是 host 哪个端口?
- 如果页面打不开,我从哪里看应用日志?
- 哪个 HTTP 请求是当前示例真实存在的验证入口?
- 怎样正常停止,
--rm会不会让 container 自动移除? - 当前说明为什么没有 Compose、数据库、volume 和公网地址?
如果某个答案只能从作者聊天记录里找到,它就还不是仓库入口。反过来,也不要为了 回答所有未来问题,把 Dockerfile、依赖文件和部署愿望全部复制进 README。说明负责 导航与执行,具体构建事实仍回到对应文件。
现在 Dockerfile、.dockerignore、运行命令和 README 已经彼此对应。还剩最后一项:
证明这套入口真的不依赖作者机器留下的旧 image、旧 container 和 build cache。
从没有历史状态的起点重新走一遍¶
最危险的验证方式是:“我把刚才那条命令再跑一次,还是成功。”它可能只是启动了旧 container,或者复用了旧构建结果。你需要一个明确且不会伤害其他项目的起点。
概念|cold-state build verification 在不使用项目
.venv、旧 image、旧 container 或 build cache 的条件下,从当前 build context 和 Dockerfile 重新构建目标 image。它只清理当前验证的历史依赖, 不是清空整个 Docker daemon。
本次验证保留和排除的内容如下:
| 条件 | 怎样处理 |
|---|---|
| 源码、Dockerfile、依赖声明、README | 保留,它们就是要验证的输入 |
| Docker Engine 与兼容 host | 保留,并写成前置条件 |
| 获取基础 image 和 Python 包的网络 | 保留,并承认是外部前置 |
项目 .venv 与 host Python 包 |
不使用 |
| 当前目标的旧 container | 验证前不依赖 |
| 当前目标的旧 image | 使用新的明确 tag 重新构建 |
| build cache | 本次使用 --no-cache 禁止复用 |
| 其他项目的 image、container、volume | 完全不触碰 |
因此,不要把下面这条命令当成课程前置:
它可能删除其他项目仍需要的数据和缓存,范围远大于当前问题。若同名 container 已经 存在,先确认它确实属于这个示例,再按 README 正常停止;不要对模糊目标做全局清理。
这次验证也有清楚的能力边界:它不验证另一种 CPU architecture,不验证基础 tag 未来是否变化,不证明断网可以构建,也不证明字节级可复现。它回答的是:在当前兼容 host 和可获取资源下,能否不依赖项目历史状态,从声明重新得到可运行服务。
从 build 输出追踪每一份输入¶
在 README 指定的仓库根目录执行:
不要只看最后一行。构建过程应该能沿着这条路线解释:
context + .dockerignore
→ Dockerfile 被读取
→ Python base image 可获取
→ requirements 被复制
→ FastAPI 与 Uvicorn 被安装
→ app 源码被复制
→ image 保存默认命令与端口元数据
本地验证中,缩小后的 context 只包含几百字节的教学文件;依赖步骤安装 FastAPI
0.115.14 与 Uvicorn 0.34.3;最终 image 保存 exec-form 命令和 8000/tcp 元数据。
这些具体数值会随项目变化,稳定证据是每个必要输入在预期步骤被使用,构建没有读取
项目 .venv,也没有复用 build cache。
失败时仍然从最早位置处理:load context 失败就检查目录和 ignore;COPY 失败就
检查路径;安装失败就检查依赖与包源;运行时才出现 import error,就检查包是否真的
进入 image、启动模块是否正确。不要进入旧 container 手工 pip install。这种操作
只改变一个实例的可写层,下一次 build 仍会在同一处失败。
从新 image 走到真实 HTTP 响应¶
使用 README 中同一组名称和端口创建 container:
按顺序收集不同证据:
docker ps --filter name=p1-w01-l05
docker inspect --format \
'cmd={{json .Config.Cmd}} ports={{json .HostConfig.PortBindings}} state={{.State.Status}}' \
p1-w01-l05
docker logs p1-w01-l05
curl -fsS http://127.0.0.1:8000/openapi.json
| 证据 | 能说明什么 | 不能单独说明什么 |
|---|---|---|
ps 为 Up |
主要进程尚未退出 | 进程一定是正确应用 |
| inspect command | 实际默认命令和参数 | 应用已经成功监听 |
| inspect ports | host/container 转发规则 | 目标端口有进程接收 |
| logs | Uvicorn/FastAPI 的启动或错误 | host 网络一定可达 |
| HTTP JSON | 请求经过 publish 到达应用并收到响应 | 所有业务路径都正确 |
验证过的材料中,Uvicorn 是 PID 1,监听 http://0.0.0.0:8000,host publish 为
127.0.0.1:8000 → 8000/tcp,/openapi.json 返回可解析 JSON。这证明本地入口
可以访问,不替代上一课对成功与错误业务路径的测试。
如果第一次 curl 恰好撞上很短的启动窗口,可以短暂重试来观察;固定睡眠很长时间 却会掩盖持续失败。本课没有加入 readiness 或 healthcheck,所以成功标准仍是实际 HTTP 响应,不是“等十秒应该好了”。
正常停止也是交付的一部分¶
HTTP 成功后执行:
预期顺序是 Docker 发出终止请求,Uvicorn 收到信号,FastAPI 完成 shutdown,主要
进程退出,container 停止。因为创建时使用 --rm,container 随后自动移除;image
仍然保留,可以再次创建新实例。
第一条不再列出该 container,第二条仍能找到 image。stop 是请求主要进程结束; remove 是删除 container 元数据和可写层;image remove 则删除静态构建结果。三个 动作不要混成一句“清理 Docker”。
如果停止异常缓慢,可能是 shell wrapper 没有转交信号、应用没有正常关闭,或者 当前主要进程根本不是预期的 Uvicorn。不要立即强制 kill 并把它算作成功;先读取 命令、进程和日志,找到生命周期在哪一步断开。
把构建、运行和说明连成同一份结果¶
上一课的交付基线 要求说明、配置、行为和 候选变更互相一致。Docker 加入后,我们把它扩展成一条从源码走到正常停止的路线:
tracked source + dependency declaration
→ build context + .dockerignore
→ Dockerfile build
→ image command + port metadata
→ runtime configuration + port publishing
→ main process + HTTP response + stop
→ README commands and stated limits
概念|Docker 本地运行契约 Dockerfile、构建输入、运行配置、端口、HTTP 验证、正常停止和 README 彼此一致, 共同形成一个可以由别人重复执行的单服务本地入口。它不代表生产部署已经完成。
这里的重点不是多了一张清单,而是任何失败都有可以回去修改的位置。.env 进入
context,就修 .dockerignore 和复制范围;Uvicorn 未写进依赖,就修
requirements.txt;应用只监听 loopback,就修默认启动命令;container 改成 8080
而 README 仍写 8000,就同时核对 Dockerfile、run 参数和验证 URL。
| 观察到的缺口 | 应修改哪里 | 修改后至少重跑什么 |
|---|---|---|
.env 被发送给 builder |
.dockerignore 与复制范围 |
无 cache build,并复核输入 |
| image 中没有 Uvicorn | 依赖声明 | build → run → HTTP → stop |
| Uvicorn 只监听 loopback | 默认启动命令 | build → run → HTTP → stop |
| README 的 container port 过期 | README;声明错误时一并修 | 从 README 复制 run → HTTP → stop |
任何只在验证现场手工执行、却没有写回 Dockerfile、依赖、运行参数或 README 的步骤, 都会让下一位同事再次遇到同样的问题。
最后可以用八个问题收束整条路线:
- Dockerfile 与
.dockerignore是否被 Git 跟踪,依赖文件和源码路径是否真实存在? - context 是否排除了
.venv、cache、Git 历史和本地.env? - 无 cache build 是否能在不使用 host Python 包的情况下成功?
- exec-form 默认命令是否让 Uvicorn 作为 container 的主要前台进程运行?
- Uvicorn 的监听端口、
EXPOSE与-p的 container port 是否一致? - README 中的 host URL 是否真的返回 HTTP 响应?
docker stop是否触发正常 shutdown,--rm的结果是否符合说明?- 第一次进入仓库的人能否只按 README 完成 build、run、verify、logs 和 stop?
前七项全绿、第八项失败,说明入口仍依赖作者记忆;第八项写得很漂亮、前七项失败, 说明文档描述的是愿望。只有同一组文件、命令和观察共同成立,才是本课要交付的本地 Docker 入口。
综合实践与解析¶
下面的实践把整课内容放回一个最小 FastAPI 服务。你不需要删除整台机器的 Docker 数据,也不需要使用主项目 secret。目标是得到一份别人能从仓库根目录重复执行的 本地入口。
实践任务一:为文件和运行条件找到正确位置¶
请把下面八项分为“进入 image”“创建 container 时提供”“host 前置”或“应排除”, 并说明错误放置会造成什么后果:
requirements.txt;.venv/;app/;OPENAI_API_KEY的真实值;- Uvicorn 默认启动命令;
- host 端口
9000; - Docker Engine;
- 当前运行的 Uvicorn 进程。
解析¶
requirements.txt 和 app/ 是构建输入;Dockerfile 根据它们把依赖和源码放进
image。Uvicorn 默认命令也在 build 时写成 image 元数据,但真正的 Uvicorn 进程只
会在 container 运行后出现。
.venv/ 应排除,因为它是 host 历史状态;真实 API key 与 host port 属于创建
container 时的选择;Docker Engine 是 host 前置。把 key、host port 或 .venv
固化进 image,会分别造成凭据扩散、复用受限和平台耦合。
实践任务二:修正一个过宽的构建¶
当前目录是 /workspace,P1 位于 /workspace/p1。候选命令为:
Dockerfile 先 COPY . .,再安装依赖;仓库只有 .gitignore,其中排除了 .venv
和 .env。请指出至少四个风险,并给出修正后的执行目录、Dockerfile 顺序和最小
.dockerignore。
解析¶
当前 context 是整个 /workspace,不是 /workspace/p1;.gitignore 不会缩小
Docker context;.venv、.env、Git 历史甚至其他项目都可能被发送;宽泛
COPY . . 让任意文件变化都可能使依赖安装失去复用;本地配置还可能进入 image。
进入 /workspace/p1 后执行:
Dockerfile 先复制 requirements.txt 并安装,再复制 app/。.dockerignore 至少
排除 .git/、.venv/、Python/pytest cache 和 .env。修正后还要确认必要依赖与
源码没有被排除,并读取每一步输出,而不是只看 exit code。
实践任务三:找到“Up 但不可访问”的第一处断点¶
下面四个候选都准备访问 http://127.0.0.1:9000/docs:
- Uvicorn 监听
127.0.0.1:8000,run 使用-p 127.0.0.1:9000:8000; - Uvicorn 监听
0.0.0.0:8000,Dockerfile 有EXPOSE 8000,run 没有-p; - Uvicorn 监听
0.0.0.0:8000,run 使用-p 127.0.0.1:9000:8000; - Uvicorn 启动后立刻退出,publish 与第 3 项相同。
解析¶
第 1 项断在 container 内监听:host 请求被转到 8000 后,应用只接受自身 loopback。
第 2 项没有 host 发布规则,EXPOSE 不能代替 -p。第 3 项的监听和映射匹配,URL
使用 host 9000,因此可能访问,但仍需用 HTTP 验证。第 4 项断在主要进程;端口规则
存在,也没有活着的应用接收请求。
实践任务四:把聊天命令变成 README 入口¶
候选仓库存在以下状态:README 只写 docker run p1-learning-service;Dockerfile
使用 COPY . .;.gitignore 排除了 .env,却没有 .dockerignore;.env
包含 OPENAI_API_KEY;应用监听 container 8000;README 还声称
docker compose up 可以启动数据库。
请从构建、运行配置、验证停止和当前范围四方面修正。
解析¶
构建侧增加与文件树一致的 .dockerignore,明确复制依赖和 app/,并确认 .env
没有被 Git 跟踪。运行侧把真实 key 留在 host,通过 -e、--env-file 或项目后续
确定的安全入口提供;README 只写变量名和安全示例。
README 还要给出执行目录、完整 image tag、container 名称、本地 host/container 端口、HTTP 验证、日志和 stop 命令。当前没有 Compose 与数据库,就删除相应承诺, 明确范围只是单个 FastAPI container。
实践任务五:重做一次不可信的验证¶
候选记录如下:
1. docker start p1-old → container Up
2. docker exec p1-old pip install uvicorn
3. curl http://127.0.0.1:8000/docs → 200
4. README 仍只写 docker start p1-old
5. 没有重新 build,也没有 stop
这份记录不能证明什么?最小重做顺序是什么?
解析¶
它没有 build,因此不能证明当前 context、Dockerfile 和依赖声明能形成 image;它
直接启动旧 container,不能证明运行不依赖历史实例;手工安装 Uvicorn 明确暴露依赖
声明缺口;README 依赖一个预先存在的名称,第一次克隆仓库的人无法使用;没有 stop,
也没有验证信号和 --rm 行为。
最小重做顺序是:修正依赖声明;核对 .dockerignore;使用新 tag 执行无 cache
build;从新 image 创建 container 并显式绑定本地端口;检查实际命令、日志和 HTTP;
正常 stop;把 build、run、verify、logs、stop 与当前限制写回 README;最后完全从
README 复制命令再执行一遍,不使用旧实例或任何未记录补救。
完成这些步骤后,你得到的不是“Docker 文件看起来齐全”,而是一条有输入、有运行、 有访问证据、有正常结束方式、也能交给别人重复执行的本地入口。它仍然只是单服务 本地开发基线,但后续 Compose、数据库和部署工作终于有了可靠起点。
参考资料¶
- Docker:Build context
- Dockerfile reference
- Docker:Optimize cache usage
- Docker:Building best practices
- Docker:Storage drivers—images and container layers
- Docker:docker container run
- Docker:docker container stop
- Docker:Publishing and exposing ports
- FastAPI:FastAPI in Containers
- GitHub:About the repository README file