外观
项目 4:Dockerfile 自动化与基础设施即代码
约 3062 字大约 10 分钟
DockerDockerfileFlaskIaC
2026-05-29
项目目标
公司有一个 Flask Web 应用需要交付给客户部署。你不能像上一课那样手工 docker commit 出一个"黑盒镜像"——客户需要看到完整的构建过程,能审计每一层、能复现每一次构建。
本课要求你编写 Dockerfile,把一个 Flask 应用构建为规范的自定义镜像,掌握镜像分层缓存优化技巧,并通过 Go 多阶段构建案例理解"构建工具不应进入最终镜像"的原则。
一、为什么手工 docker commit 是死路?
第 2 课中我们用 docker commit 把装了 Python 的容器固化成镜像。这在企业里叫黑盒镜像——但你看不到里面发生了什么:
- 基于哪个版本的系统?不知道
- 装了什么版本的 Python?不知道
- 有没有人偷偷塞了木马?不知道
接手你工作的同事拿到 my-web:v3 只能靠猜。要升级一个依赖?再跑个容器进去手动改。这不可审计、不可复现,是运维的噩梦。
📜 基础设施即代码:Dockerfile vs 手工 Commit
为什么企业中严禁使用 docker commit 构建“黑盒镜像”?
【场景一】开发者决定手动把配置好的容器打包成镜像。
👨💻 开发者
基础设施即代码 (IaC)
解决之道是把"手工敲命令"变成可阅读、可版本管理的纯文本脚本。在 Docker 里,这份脚本叫 Dockerfile。
有了 Dockerfile + 源代码,任何人在任何电脑上执行 docker build,都能精确还原出完全相同的镜像。过程透明,一步不差。
二、编写第一个 Dockerfile
2.1 准备项目目录
mkdir -p project04-dockerfile-web/app/templates
cd project04-dockerfile-web2.2 Flask 应用代码
创建 app/app.py(Flask 是 Python 的轻量级 Web 框架):
from flask import Flask, render_template
import os
app = Flask(__name__)
@app.route("/")
def index():
version = os.environ.get("APP_VERSION", "v1")
return render_template("index.html", version=version)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)📌
host="0.0.0.0"必须写——它让 Flask 监听容器内所有网卡。如果写成127.0.0.1,宿主机浏览器无法访问。
创建 app/templates/index.html:
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>Flask Docker Demo</title></head>
<body>
<h1>Flask Docker Demo</h1>
<p>当前版本:<code>{{ version }}</code></p>
</body>
</html>创建 app/requirements.txt(列出依赖包,pip install -r 会逐个安装):
Flask==3.0.32.3 解锁 Dockerfile
在下方输入这份构建说明书文件的默认名称,获取完整内容:
问题:描述镜像构建步骤的默认核心文件叫什么?(全小写,一个单词)
Dockerfile 指令逐行解释:
| 指令 | 作用 | 通俗理解 |
|---|---|---|
FROM python:3.11-slim | 基于官方的轻量 Python 镜像起步 | 站在巨人的肩膀上,不从头装系统 |
WORKDIR /app | 设置容器内的工作目录 | 后续命令都在 /app 下执行 |
COPY app /app | 把宿主机的代码复制进镜像 | 像 U 盘拷文件 |
RUN pip install -r requirements.txt | 在镜像内执行安装命令 | 相当于你在终端里敲 pip install |
EXPOSE 5000 | 声明容器内应用监听的端口 | 文档性质,不实际开放端口 |
CMD ["python","app.py"] | 容器启动时默认执行的命令 | 镜像"开机自启动"的程序 |
2.4 构建并运行
在下方输入 Docker 构建镜像的子命令,获取完整操作:
问题:Docker 构建镜像使用哪个子命令?(全小写,5个字母)
命令末尾的 . 代表构建上下文——Docker 会把当前目录下的文件打包发给 Docker 引擎,作为 COPY 指令的素材来源。
浏览器访问 http://localhost:5000,你应该看到 Flask 页面。
三、构建缓存的"雪崩效应"
现在改一行 HTML,重新 docker build。你会发现:它又慢吞吞地重新下载安装 Flask 了!
3.1 缓存命中机制
Dockerfile 会按指令顺序形成构建步骤,其中 RUN、COPY、ADD 通常会生成文件系统层,CMD、EXPOSE 等更多是镜像元数据。第二次构建时,Docker 从上到下逐行检查:
FROM变了吗?没变 → 用缓存,秒过COPY app /app变了吗?HTML 改了 → 缓存失效!
关键问题:只要有一行缓存失效,它下方所有指令的缓存全部作废。你改了一行 HTML,COPY 失效,下方 RUN pip install 被连累重跑——白白下载了没变化的依赖包。
🚀 Docker 构建缓存与雪崩效应
观察修改代码时,不同 Dockerfile 写法如何影响构建缓存
准备开始首次构建。此时本地没有任何缓存。
Dockerfile
1FROM python:3.11-slim
2WORKDIR /app
3COPY app /app
4RUN pip install -r requirements.txt
5CMD ["python", "app.py"]
📦 镜像分层 (底部为底层)
🚀CMD ["python", "app.py"]
⚙️RUN pip install -r requirements.txt
📄COPY app /app
📂WORKDIR /app
🏗️FROM python:3.11-slim
3.2 依赖拷贝与源码分离
解决思路:把几乎不变的步骤往上提,把频繁变动的往下压。
requirements.txt 可能几周不变,而 HTML/代码一天改几十次。重构 Dockerfile:
FROM python:3.11-slim
WORKDIR /app
# 第一步:只拷贝依赖清单(几个月不变)
COPY app/requirements.txt ./
# 第二步:安装依赖(只要 requirements.txt 不变,这里永远命中缓存!)
RUN pip install --no-cache-dir -r requirements.txt
# 第三步:最后才拷贝频繁变化的业务代码
COPY app /app
EXPOSE 5000
CMD ["python", "app.py"]现在改 HTML 再构建——pip install 旁边会显示 CACHED,构建时间会明显缩短。
3.3 .dockerignore 过滤构建上下文
docker build . 会把整个目录发给 Docker 引擎。如果目录里有 .git/、venv/、截图、日志等垃圾文件,构建上下文会很大,且无用文件可能被 COPY 误打包进镜像。
在项目根目录创建 .dockerignore(告诉 Docker "这些文件不要发给我"):
__pycache__/
*.pyc
.venv/
venv/
.env
.git/
screenshots/
*.log四、Go 多阶段构建瘦身
4.1 为什么 Python 不适合演示"瘦身"?
Python 是解释型语言——程序运行时必须有 Python 解释器。即使用多阶段构建,最终镜像里通常仍要保留 Python 运行环境,瘦身幅度不会像 Go 这种静态编译程序那么明显。
Go 语言编译成独立二进制文件,运行时不需要 Go 环境。所以多阶段构建的威力在 Go 上体现得淋漓尽致:第一阶段用笨重的编译器编译,第二阶段只把编译好的二进制文件丢进一个空镜像。
🏋️♂️ 镜像瘦身术:多阶段构建原理解析
对比单阶段与多阶段构建,理解为何 Go 镜像能从 850MB 缩小到 7MB
【初始】准备使用单阶段 Dockerfile 构建应用。
🛠️ 编译阶段 (Builder) 850MB
4.2 准备 Go 代码
mkdir -p go-multistage-demo
cd go-multistage-demo问题:Go 程序的入口文件通常叫什么?
将解锁内容保存为 main.go。
4.3 单阶段 vs 多阶段 Dockerfile
单阶段——编译和运行都在一个镜像里,编译器、源码、临时文件全打包:
问题:表示「单个」的英文单词是什么?
将解锁内容保存为 Dockerfile.single。
多阶段——第一阶段(builder)只负责编译,第二阶段用 FROM scratch(一个 0 字节的空镜像)只放编译产物:
问题:多阶段 COPY 指令中指定数据来源阶段的参数名是什么?(全小写,4个字母)
将解锁内容保存为 Dockerfile.multi。
4.4 构建并对比
问题:'对比'的英文单词是什么?
| 对比项 | 单阶段 | 多阶段 |
|---|---|---|
| 基础镜像 | 包含上百 MB 的 Go 编译环境 | scratch(0 字节) |
| 镜像大小 | 几百 MB | 几 MB |
| 安全性 | 源码和编译工具链残留在镜像中 | 只有二进制文件 |
五、提交物清单
| 文件 | 说明 |
|---|---|
Dockerfile(Flask 优化版) | 含依赖与源码分离的缓存优化版本 |
.dockerignore | 排除 __pycache__/、venv/、.git/、*.log 等无关文件 |
| 构建输出截图 | 含 CACHED 标记的第二次构建日志 |
| Flask 页面截图 | 浏览器访问 http://localhost:5000 |
docker images 截图 | 显示 flask-demo:v1 和 flask-demo:v2 两个版本 |
当堂检测
- Dockerfile 中
FROM、COPY、RUN、CMD分别是什么作用?请各用一句话概括。 - 为什么把
COPY requirements.txt放在COPY app前面能提高构建速度? - 多阶段构建为什么能让 Go 镜像从几百 MB 瘦身到几 MB?
六、故障排查
| 现象 | 排查方法 |
|---|---|
docker build 找不到 Dockerfile | 确认文件名是 Dockerfile(无后缀),在项目根目录下执行 |
pip install 失败 | 检查网络、requirements.txt 格式 |
| Flask 页面打不开 | 确认 app.run(host="0.0.0.0"),检查 -p 端口映射 |
| 缓存没命中 | 检查 COPY 顺序、.dockerignore 是否排除了频繁变动的无关文件 |
七、深度探索任务
以下任务不强制提交,是拔高性质的进阶练习。每个探索任务都包含完整的操作流程和确认问题。
探索任务 1:故意破坏缓存
目标:理解什么条件会导致 Docker 构建缓存失效。
操作步骤:
1. 修改依赖版本号
打开 app/requirements.txt,把 Flask 版本号从 3.0.3 改成 3.0.2:
Flask==3.0.22. 重新构建
docker build -t flask-demo:v3 .3. 观察构建输出
在构建日志中找到 pip install 那一步。你应该看到它没有显示 CACHED,而是从头下载安装——即使业务代码(app.py、index.html)一行没改。
4. 思考原因
requirements.txt 属于 COPY 指令的源文件之一。内容变了 → COPY 层缓存失效 → 下方的 RUN pip install 被连累重跑。
5. 改回版本号再构建
# 把 requirements.txt 改回 Flask==3.0.3
docker build -t flask-demo:v4 .观察:如果你在两次构建之间没有改过 Dockerfile 中 pip install 之前的其他指令,这次 pip install 又会显示 CACHED。
确认问题
为什么只改了 requirements.txt 中一个版本号,pip install 的缓存就失效了?如果改的是 index.html 而不是 requirements.txt,pip install 会命中缓存吗?请结合 Dockerfile 的 COPY 顺序解释。
探索任务 2:查看镜像分层历史
目标:通过 docker history 理解镜像的分层结构,找出体积最大的层。
操作步骤:
1. 查看所有层
docker history flask-demo:v2输出类似:
IMAGE CREATED CREATED BY SIZE
a1b2c3d4e5f6 2 minutes ago CMD ["python" "app.py"] 0B
b2c3d4e5f6a1 2 minutes ago EXPOSE map[5000/tcp:{}] 0B
c3d4e5f6a1b2 2 minutes ago COPY app /app # buildkit 1.2kB
d4e5f6a1b2c3 2 minutes ago RUN /bin/sh -c pip install ... # buildkit 15.3MB
e5f6a1b2c3d4 2 minutes ago COPY app/requirements.txt ./ # buildkit 18B
f6a1b2c3d4e5 2 minutes ago WORKDIR /app 0B
a1b2c3d4e5f6 3 weeks ago FROM python:3.11-slim 125MB2. 找到最大的层
在上面的输出中,最大的是 FROM python:3.11-slim(约百 MB 级),其次是 RUN pip install。业务代码 COPY app /app 通常只有几 KB。
3. 尝试优化思路
如果发现某层特别大,思考能不能精简:
RUN pip install可以加--no-cache-dir避免把 pip 下载缓存打包进镜像- 基础镜像可选更轻量的变体(如
python:3.11-alpine,但要注意 Alpine 的兼容性差异)
4. 对比优化前后的镜像
# 查看优化前大小
docker images flask-demo:v2
# 如果你做了优化(如改 alpine 或加 --no-cache-dir),构建新版本后对比
docker images flask-demo:v3确认问题
你的 flask-demo 镜像中哪一层体积最大?如果要把镜像进一步缩小,你认为最有效的优化手段是什么?为什么 CMD 和 EXPOSE 的大小是 0B?
探索任务 3:Go 多阶段构建镜像大小对比
目标:完成本章第四节的 Go 多阶段构建实践,直观感受"构建工具不应进入最终镜像"。
操作步骤:
1. 构建单阶段镜像
回到 go-multistage-demo 目录,按照 4.3 节的单阶段 Dockerfile 构建:
cd go-multistage-demo
docker build -f Dockerfile.single -t go-web:single .2. 构建多阶段镜像
用多阶段 Dockerfile 构建:
docker build -f Dockerfile.multi -t go-web:multi .3. 并排对比体积
docker image ls go-web输出类似:
REPOSITORY TAG SIZE
go-web single 850MB
go-web multi 7.5MB4. 检查多阶段镜像里有什么
# scratch 镜像没有 shell 和 ls,不能直接 docker run go-web:multi ls /
# 可以先创建容器,再导出文件系统清单
docker rm -f go-web-inspect 2>/dev/null || true
docker create --name go-web-inspect go-web:multi
docker export go-web-inspect | tar -tf -
docker rm go-web-inspect
# 你应该只看到 go-web 二进制文件,没有任何 Go 编译器、源码或系统工具确认问题
多阶段构建后镜像从多大瘦身到多大?为什么 Go 能做到从 850 MB 缩减到个位数 MB,而同样的技巧用在 Python 应用上效果不明显?FROM scratch 是什么意思?
