先看完整配方,再逐行构建
六条指令,各有职责
构建(build)把源码和指令变成镜像。下面用 Python 自带的 HTTP 库提供一个网页响应,不需要安装额外依赖。镜像名中的 3.13-slim 是标签,表示选用 Python 3.13 的精简变体;标签内容仍可能更新。[7]
FROM python:3.13-slimWORKDIR /appRUN printf 'built during build\n' > build.txtCOPY app.py ./ENV GREETING="Hello from Docker"EXPOSE 8000CMD ["python", "app.py"]
- FROM 选择起点。复用基础镜像的文件层和配置,其中已有 Python。它开始一个构建阶段,即从这份基础镜像继续加工的过程。先猜:下一步如何让相对路径都从 /app 开始?
- WORKDIR 设置工作目录。后续 RUN 和相对目标路径以
/app为起点;目录缺失时会创建。运行容器时也沿用这个工作目录。 - RUN 执行构建命令。此处用 shell 的重定向写出
/app/build.txt。保存的是文件变化,写文件的进程不会留在镜像里持续运行。 - COPY 加入源码。把构建输入里的
app.py复制到镜像的/app/app.py。源码到位,但 Web 服务还没启动。先猜:设置默认问候语,需要再写一个文件吗? - ENV 保存默认环境变量。
GREETING是进程可以读取的键值配置;后续构建步骤与容器中的进程都能使用。此行本身不写应用文件。 - EXPOSE 声明应用端口。记录预期使用
8000/tcp,省略协议时默认 TCP。它不会启动服务,也不会发布宿主机端口。 - CMD 补上启动命令。这里只把它当作“容器启动时默认执行 python app.py”的配置。应用要到
docker run时才启动;与 ENTRYPOINT 的区别留到下一篇。
把最后的 CMD 换成 RUN python app.py,会得到“启动后自动提供网页”的镜像吗?写下构建时和运行时分别会发生什么。
不会。RUN 会在构建阶段启动这个长期运行的服务,构建通常一直等它退出;即使服务后来退出,也不会把活着的进程存进镜像。CMD 保存启动意图,在创建并启动容器时执行。
分清源码位置与镜像内位置
COPY 的两端不同
COPY app.py ./ 的左边相对于构建上下文(build context),即这次允许构建器读取的输入文件集合。右边是镜像内的位置;./ 相对于当前 WORKDIR。[2] [1]
源文件不会因为 WORKDIR 改成 /app 就去宿主机的 /app 查找。目标文件也不会自动同步后续的源码修改;COPY 保存的是构建时复制的内容。
改成 WORKDIR /srv/web 后,再执行 COPY app.py ./,镜像内文件的绝对路径是什么?
相对目标 ./ 从工作目录 /srv/web 开始,所以得到 /srv/web/app.py。构建上下文中的源文件位置不变。
深入一点:RUN cd /app 能代替 WORKDIR 吗?
不能。RUN cd /app 只改变这一次 shell 进程的目录;下一条 RUN 不继承这个进程状态。WORKDIR /app 把工作目录写入配置,后续步骤和启动命令才会沿用。用绝对路径也能避免受基础镜像原有工作目录影响。[1]
源码明明存在,COPY app.py ./ 却提示找不到。哪一步最值得先检查?
端口声明不参与源码查找,无法修复 COPY 的输入。
WORKDIR 设置镜像内路径,不改变 COPY 的输入范围。
COPY 只能读取允许的构建输入。.dockerignore 是上下文根目录中列出排除规则的文件;被排除的文件不会提供给这次构建。先确认你把正确目录作为上下文,再检查排除规则。[2]
不要把历史记录数当作文件层数
文件层与配置分开看
文件层记录文件系统的新增、修改或删除。镜像配置记录工作目录、环境变量等默认值;这里的“元数据”指这些描述信息。两者合起来,才决定容器看到什么、怎样启动。[3]
| 指令 | 文件系统 | 配置或作用 |
|---|---|---|
FROM | 继承基础镜像的已有层 | 开始新的构建阶段,继承基础配置 |
RUN | 本例新增 build.txt | 构建时执行命令 |
COPY | 本例新增 app.py | 复制构建输入 |
WORKDIR | 目录不存在时创建它 | 保存工作目录 |
ENV | 此行本身不修改文件 | 保存环境变量默认值 |
EXPOSE | 此行本身不修改文件 | 保存端口声明 |
回看图 2:文件变化从下往上叠,配置另列在上方。ENV 与 EXPOSE 不增加文件系统差异层;WORKDIR 要考虑目录创建。即使 RUN 只执行 pwd,也不能因为有一条指令就断言它增加了有内容的文件层。[1]
按本例指令“自身的直接作用”分类。假设 /app 尚不存在;不把后续程序的行为算进来。
- RUN printf … > build.txt
- COPY app.py ./
- ENV GREETING="Hello from Docker"
- EXPOSE 8000
- WORKDIR /app(目录缺失)
判断对象是“这条指令做了什么”。ENV 自身只写配置;后续 RUN 若读取这个变量再写文件,那是 RUN 的文件变化。WORKDIR 则同时具有目录设置与按需创建行为。
深入一点:怎么验证,而不猜层数?
docker image history --no-trunc df-web:lab 显示构建历史,可能包含只改配置的记录。docker image inspect 可查看镜像的 RootFS.Layers 文件层列表与 Config 配置;不要把历史行数直接当作文件层数。具体层数和大小以你的构建结果为准,本页未实测,也不给虚构数字。[5] [9]
ENV 也不是保存密码的位置:它会留在镜像配置中。运行时可用 docker run -e GREETING=Hi … 给这一次容器覆盖默认值,而不重新构建镜像。[6]
用自己的话解释:用 -e GREETING=Hi 启动新容器,为什么不用重新 COPY app.py?
源码在文件层里,问候语由程序运行时读取环境变量决定。改变新容器的环境就能改变响应,不必改源码。
- 区分源码文件和环境配置
- 提到应用需要主动读取环境变量
- 覆盖只影响这次新建容器,镜像默认值仍保留
三件独立的事:声明、监听、发布
EXPOSE 只作声明
声明由 EXPOSE 记录;监听由应用进程完成;发布由运行时的 -p 建立宿主机端口入口。任意一步都不能替代另外两步。[4]
-p 127.0.0.1:8080:8000 的顺序是“宿主机 IP : 宿主机端口 : 容器端口”。即使 Dockerfile 删掉 EXPOSE,这条显式发布仍可使用。它也不会替应用启动监听。[4] [1]
保留 EXPOSE 8000,把应用改为监听 9000,运行时仍用 -p 127.0.0.1:8080:8000。访问本机 8080 会进入这个应用吗?怎样修正?
不会。发布的目标是容器 8000,但服务在 9000。应改为 -p 127.0.0.1:8080:9000,并把 EXPOSE 更新为 9000 以准确描述镜像。真正决定服务入口的是应用的监听和发布目标。
在你自己的 Docker 机器上验证
构建一次,验证三件事
编写环境没有 Docker,执行 docker version 返回 docker: command not found。下面的 Docker 命令未实测;“预期”是根据文档和示例代码推导的检查点,不是本机终端记录。请维护者补实测输出。示例应用已用本机 Python 单独验证两种问候语的 HTTP 响应,这不等于容器实测。
准备:已启动的 Docker(Linux 容器模式)、能连接它的终端、拉取基础镜像所需网络。以下宿主机命令使用 Bash;无需本机安装 Python。Web 示例只供学习,不能直接用于生产。[8]
宿主机影响:会创建一个实验目录,下载并构建镜像、创建容器网络;发布端口时 Docker 会配置转发规则(Linux 上可能涉及防火墙规则)。端口仅绑定本机 127.0.0.1,不需要特权模式或宿主机目录挂载。若 Docker 连接远程机器,下面的“本机访问”需在 Docker 服务端机器上进行。
1. 准备两个文件
在新终端创建一个未占用的目录。把图 2 中的完整七行保存为当前目录下的 Dockerfile(无扩展名),再创建下面的 app.py。[10]
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = (os.environ["GREETING"] + "\n").encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
HTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
应用从环境里读取 GREETING,对 HTTP GET 请求返回这段文本。0.0.0.0 表示监听容器内全部 IPv4 接口;若只监听容器自己的 127.0.0.1,发布进来的流量到不了这个监听。[8]
-t df-web:lab 为镜像指定名称与标签;末尾 . 选择当前目录为构建上下文。这里在镜像名后指定 sh -c …,临时替换默认 CMD 以检查文件;--rm 在容器退出后自动删除容器,保留镜像。[2] [6]
预期(未实测):检查命令依次打印工作目录 /app、文件内容 built during build、环境值 Hello from Docker,以及文件存在标记 app.py-exists。镜像 ID、构建日志和大小随环境变化,此处不预填输出。
2. 比较声明与发布
这个终端留给前台服务。在同一台宿主机另开终端执行:
预期(未实测):没有发布映射可列出。不要仅凭访问 8080 失败来判断:那个端口可能已被别的程序占用。回到第一个终端按 Ctrl + C 停止服务;容器自动删除。
预期(未实测):端口检查应出现容器 8000/tcp 到本机 127.0.0.1:8080 的映射;请求正文应为 Hello from Docker 并换行。若宿主机 8080 已占用,把 -p 的中间数字和访问地址一起改为 8081。[11]
3. 覆盖配置,不重建
停止当前服务后,给新容器加 -e GREETING=Hi,不运行 build。你预计网页文字和 build.txt 分别会怎样变化?
网页变成 Hi;build.txt 仍是构建时写入的内容。环境覆盖只改变这次容器运行的配置,不会重新执行 RUN 或修改镜像。
先在第一个终端按 Ctrl + C 停止旧服务,再启动:
预期(未实测):请求正文变成 Hi 并换行;镜像 Config 中仍保留 GREETING=Hello from Docker、工作目录 /app 和端口声明。层列表与构建历史用于核对结构,不要求匹配固定的层数或大小。
结束后按 Ctrl + C;若没有回到运行终端,可在另一终端执行 docker stop df-web-lab。确认只删除本实验镜像时,运行 docker image rm df-web:lab。实验目录和基础镜像保留,按需自行清理。
合上示例,再检索一次
把三个时刻分清
- 选起点:FROM 继承基础镜像;WORKDIR 确定后续路径起点。
- 构建时:RUN 执行命令;COPY 加入输入文件;文件变化成为镜像的一部分。
- 运行时:程序读取 ENV 默认值;可以用 -e 覆盖;工作目录沿用 WORKDIR。
- 访问时:EXPOSE 记录声明;应用实际监听;-p 建立宿主机发布入口。
镜像声明 EXPOSE 8000;应用监听 0.0.0.0:8000。想从本机 8080 访问,并把问候语改成 Hi,哪条命令合适?
环境变量覆盖了,但没有发布宿主机 8080;EXPOSE 不会替你补上。
端口方向反了:最后一个数字必须指向应用监听的容器 8000。
命令 c 把本机 8080 接到容器 8000,同时用 -e 给新容器设置 GREETING。源码与镜像都不需要修改;程序启动后读到新值,再返回 Hi。
来源
核查日期:2026-11-03。Docker 行为以官方文档为准,版本范围为 Docker Engine 29.x;页面图为示意,Docker 实验未实测。
- Dockerfile reference:FROM、RUN、COPY、WORKDIR、ENV、EXPOSE、CMD 的语义,尤其是 WORKDIR 的按需创建行为。
- Build context:构建输入、命令末尾的路径与 .dockerignore。
- Understanding the image layers:层记录文件系统变化,基础镜像层可复用。
- Publishing and exposing ports:声明、显式发布、回环地址绑定与 -P。
- docker image inspect:检查镜像详情和格式化输出。
- docker container run:--rm、-e、-p 与临时指定启动命令。
- Python 官方镜像:基础镜像和 3.13-slim 标签。
- Python 3.13 http.server、os.environ:示例 HTTP 服务与环境变量读取;该 HTTP 服务不用于生产。
- docker image history:查看镜像构建历史。
- Dockerfile overview:文件名、构建流程与启动命令。
- docker container port:查看容器的实际端口映射。