跳转至

Docker 本地启动:把“在我机器上能跑”变成别人也能复现

上一课结束时,AI 学习助手已经能在开发者的电脑上启动,测试也全部通过。新同事 克隆仓库,照着 README 运行:

python -m uvicorn app.main:app --port 8000

第一行就报 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 -t p1-learning-service:w01-l05 .

命令末尾的 . 很不起眼,却不是句号。它代表当前目录,并把这个目录交给 Docker 作为本次构建可使用的输入范围。

概念|build context build 命令指定、builder 可以读取并供 COPYADD 使用的输入集合。使用 本地目录时,命令最后的 path 决定这个集合从哪里开始。

假设目录是:

/workspace/
└── p1/
    ├── app/
    ├── requirements.txt
    └── Dockerfile

下面两条命令都能找到同一个 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 固定使用:

fastapi==0.115.14
uvicorn==0.34.3

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/
.venv/
__pycache__/
.pytest_cache/
*.py[cod]
.env

每一行都应该能回答“为什么”:

  • .git/、pytest cache 和 Python cache 不参与服务运行;
  • .venv/ 应由 requirements.txt 重建,不能继承 host 上的旧包;
  • .env 可能包含当前运行值,不应进入静态 image;
  • requirements.txtapp/ 必须保留,否则 Dockerfile 没有输入可用。

.gitignore.dockerignore 中可能出现相同路径,但它们服务于不同动作:

working tree --.gitignore------> Git 是否跟踪
build context --.dockerignore--> builder 是否收到

.env 没被 Git 跟踪,不代表 Docker 看不到它。反过来,一个文件被 Docker 排除, 也不表示它已经从 Git 历史消失。规则写得过宽同样会出错:如果先排除 *,却忘记 把依赖文件和源码放回来,COPY 会在最早需要它们时失败。

为什么先复制依赖,再复制源码

比较两种 Dockerfile 顺序:

# A:任何 context 文件变化都会影响后续依赖安装
COPY . .
RUN python -m pip install --no-cache-dir -r requirements.txt
# 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。 因此我们还要能运行:

docker build --no-cache -t p1-learning-service:w01-l05 .

成功表示当前 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 仍然可能打不开网页。

在真实仓库里提交构建文件前,还可以做一次很短的反向检查:

  1. 从 README 指定的目录执行时,命令最后的 context 是否正好是项目根?
  2. Dockerfile 每个 COPY 的来源是否都在 context 中,而且没有被 ignore?
  3. .dockerignore 排除的是当前项目的本地状态,还是从网络模板复制来的大清单?
  4. 默认命令所需的模块和依赖,是否都能追溯到前面的复制与安装步骤?
  5. 只改 app/ 时,依赖安装是否可以复用;改 requirements.txt 时,它是否一定 重新执行?
  6. 使用 --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 app.main:app &

& 把 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 的终止请求。

先只看最短形式:

CMD ["uvicorn", "app.main:app"]

与之相对,shell form 是:

CMD uvicorn app.main:app

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 停止

本课不展开 ENTRYPOINTCMD 的组合、自定义 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

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "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 入口和实例内目标。

当前命令是:

docker run --rm -d \
  --name p1-w01-l05 \
  -p 127.0.0.1:8000:8000 \
  p1-learning-service:w01-l05

逐段读就是:

127.0.0.1 : 8000  →  8000
 host IP    host      container
             port       port

因此访问 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 或等价运行配置建立。

EXPOSE 8000

这行帮助读者和工具知道 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 或公网部署。

这段说明没有逐行解释 FROMCOPYCMD;这些细节的权威来源是 Dockerfile。 README 写的是读者必须执行的任务、使用的名称和应该看到的结果。tag、container 名称 和两侧端口一旦与真实命令不一致,README 就会从入口变成陷阱。

“命令没有报错”也不是统一的成功标准。build 后要能定位新 image;run 后要看到 应用日志和实际 HTTP 响应;stop 后要确认主要进程和 container 按预期结束。把观察 结果写清楚,同事才知道应该继续还是开始定位失败。

运行值不要烘焙进 image

假设应用读取 APP_ENVOPENAI_API_KEY。为了做到所谓“开箱即用”,有人想把 当前 .env 直接复制进 image。这样确实少写了一次运行参数,也让真实 token 跟着 image 一起保存和分发。

概念|runtime configuration injection 创建 container 时提供本次运行所需的配置值,让同一个 image 可以使用不同配置, 而不把具体值固化进 image。变量名称可以公开,真实 secret 值不应进入 image 或 README。

先看反例:

ENV APP_ENV=local
COPY .env /app/.env

第一行是否合理,要看项目是否真的需要一个非敏感默认值;第二行会把当前 .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?

working tree
  ├── Git 是否跟踪?
  └── build context 是否包含?
         └── 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 等待。正确关系应改为:

docker run -p 127.0.0.1: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 完全不触碰

因此,不要把下面这条命令当成课程前置:

docker system prune -a --volumes

它可能删除其他项目仍需要的数据和缓存,范围远大于当前问题。若同名 container 已经 存在,先确认它确实属于这个示例,再按 README 正常停止;不要对模糊目标做全局清理。

这次验证也有清楚的能力边界:它不验证另一种 CPU architecture,不验证基础 tag 未来是否变化,不证明断网可以构建,也不证明字节级可复现。它回答的是:在当前兼容 host 和可获取资源下,能否不依赖项目历史状态,从声明重新得到可运行服务。

从 build 输出追踪每一份输入

在 README 指定的仓库根目录执行:

docker build --no-cache -t p1-learning-service:w01-l05 .

不要只看最后一行。构建过程应该能沿着这条路线解释:

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 run --rm -d \
  --name p1-w01-l05 \
  -p 127.0.0.1:8000:8000 \
  p1-learning-service:w01-l05

按顺序收集不同证据:

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 stop p1-w01-l05

预期顺序是 Docker 发出终止请求,Uvicorn 收到信号,FastAPI 完成 shutdown,主要 进程退出,container 停止。因为创建时使用 --rm,container 随后自动移除;image 仍然保留,可以再次创建新实例。

docker ps -a --filter name=p1-w01-l05
docker image inspect p1-learning-service:w01-l05

第一条不再列出该 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 的步骤, 都会让下一位同事再次遇到同样的问题。

最后可以用八个问题收束整条路线:

  1. Dockerfile 与 .dockerignore 是否被 Git 跟踪,依赖文件和源码路径是否真实存在?
  2. context 是否排除了 .venv、cache、Git 历史和本地 .env
  3. 无 cache build 是否能在不使用 host Python 包的情况下成功?
  4. exec-form 默认命令是否让 Uvicorn 作为 container 的主要前台进程运行?
  5. Uvicorn 的监听端口、EXPOSE-p 的 container port 是否一致?
  6. README 中的 host URL 是否真的返回 HTTP 响应?
  7. docker stop 是否触发正常 shutdown,--rm 的结果是否符合说明?
  8. 第一次进入仓库的人能否只按 README 完成 build、run、verify、logs 和 stop?

前七项全绿、第八项失败,说明入口仍依赖作者记忆;第八项写得很漂亮、前七项失败, 说明文档描述的是愿望。只有同一组文件、命令和观察共同成立,才是本课要交付的本地 Docker 入口。

综合实践与解析

下面的实践把整课内容放回一个最小 FastAPI 服务。你不需要删除整台机器的 Docker 数据,也不需要使用主项目 secret。目标是得到一份别人能从仓库根目录重复执行的 本地入口。

实践任务一:为文件和运行条件找到正确位置

请把下面八项分为“进入 image”“创建 container 时提供”“host 前置”或“应排除”, 并说明错误放置会造成什么后果:

  1. requirements.txt
  2. .venv/
  3. app/
  4. OPENAI_API_KEY 的真实值;
  5. Uvicorn 默认启动命令;
  6. host 端口 9000
  7. Docker Engine;
  8. 当前运行的 Uvicorn 进程。

解析

requirements.txtapp/ 是构建输入;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。候选命令为:

docker build -f p1/Dockerfile -t p1-learning-service:w01-l05 .

Dockerfile 先 COPY . .,再安装依赖;仓库只有 .gitignore,其中排除了 .venv.env。请指出至少四个风险,并给出修正后的执行目录、Dockerfile 顺序和最小 .dockerignore

解析

当前 context 是整个 /workspace,不是 /workspace/p1.gitignore 不会缩小 Docker context;.venv.env、Git 历史甚至其他项目都可能被发送;宽泛 COPY . . 让任意文件变化都可能使依赖安装失去复用;本地配置还可能进入 image。

进入 /workspace/p1 后执行:

docker build --no-cache -t p1-learning-service:w01-l05 .

Dockerfile 先复制 requirements.txt 并安装,再复制 app/.dockerignore 至少 排除 .git/.venv/、Python/pytest cache 和 .env。修正后还要确认必要依赖与 源码没有被排除,并读取每一步输出,而不是只看 exit code。

实践任务三:找到“Up 但不可访问”的第一处断点

下面四个候选都准备访问 http://127.0.0.1:9000/docs

  1. Uvicorn 监听 127.0.0.1:8000,run 使用 -p 127.0.0.1:9000:8000
  2. Uvicorn 监听 0.0.0.0:8000,Dockerfile 有 EXPOSE 8000,run 没有 -p
  3. Uvicorn 监听 0.0.0.0:8000,run 使用 -p 127.0.0.1:9000:8000
  4. 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、数据库和部署工作终于有了可靠起点。

参考资料