Docker 镜像制作实践
Docker镜像制作实践,涵盖Dockerfile编写、分层缓存优化、上下文与dockerignore管理、镜像推送、commit与save差异及常见故障排查。
零、实验环境与项目结构
0.1 确认 Docker 可用
docker --versiondocker infodocker images0.2 拉取本次实验的基础镜像
docker pull python:3.9-slimdocker pull alpine:3.7教程在”实验环境准备”里就强调这一点:提前拉取基础镜像可以减少构建时的下载等待,但安装依赖仍可能需要联网。基础镜像通常包含多层,相同内容的层可以在本地共享。
0.3 最终的项目结构
.├── Dockerfile # 镜像的"配方"├── requirements.txt # Python 依赖清单├── app.py # 业务代码└── .dockerignore # 构建上下文的"黑名单"(必备,但教程里常被忽略)四个文件就够,这就是”最小样例”的含义 —— 没有多余的东西,但每一条指令都有存在的理由。
一、Docker 镜像基础:它到底是什么
1.1 一句话定义
Docker 镜像是一个轻量级、可执行的只读软件包,里面打包了运行应用所需的全部内容:
| 内容 | 说明 |
|---|---|
| 应用程序代码 | 你自己写的 app.py |
| 运行时 | Python 3.9 解释器 |
| 库 | Flask、Werkzeug 等依赖 |
| 环境工具与配置 | 环境变量、工作目录、启动命令 |
1.2 镜像 vs 容器(最容易混淆的一对)
| Docker 镜像 | Docker 容器 |
|---|---|
| 容器的”源代码”,静态、只读 | 镜像的运行实例,动态、可写 |
| 由 Dockerfile 构建而来 | 由镜像 docker run 而来 |
| 可以通过注册表在用户间共享 | 不能共享,是本地进程 |
| 要改镜像,得改 Dockerfile 重新构建 | 可以直接 docker exec 进去改 |
镜像是类(Class),容器是对象(Object)。一个镜像可以同时跑出 N 个容器,彼此隔离。
1.3 镜像的四个关键组件
- 层(Layer):不可变的文件系统层,堆叠成完整镜像
- 基础镜像(Base Image):构建阶段继承的起点,通常已包含多层文件系统和运行配置
- 镜像配置:默认命令、环境变量、工作目录等元数据;Dockerfile 是构建配方,并非镜像必须携带的组成部分
- 镜像 ID / 标签:唯一标识与版本别名
二、分步构建 Python 镜像(核心实操)
下面严格按教程”如何构建 Docker Python 镜像”的 5 个步骤走。
步骤 1:编写 Dockerfile
# 使用Python官方镜像作为基础镜像FROM python:3.9-slim
# 设置工作目录WORKDIR /app
# 复制requirements.txt文件COPY requirements.txt .
# 安装依赖项RUN pip install --no-cache-dir -r requirements.txt
# 复制应用程序代码COPY . .
# 暴露端口EXPOSE 80
# 运行应用程序CMD ["python", "app.py"]逐条指令拆解:
| 指令 | 原理与作用 |
|---|---|
FROM python:3.9-slim | 指定父镜像。继承它已有的文件系统层和配置;后续指令可能新增文件系统层,也可能只更新元数据 |
WORKDIR /app | 设置后续指令的工作目录,等价于 mkdir -p /app && cd /app。用 WORKDIR 而不是 RUN cd,因为 RUN cd 只对那一条指令生效 |
COPY requirements.txt . | 只复制依赖清单。这是缓存优化的关键,见 分层与缓存 |
RUN pip install --no-cache-dir -r requirements.txt | 构建期执行安装。--no-cache-dir 不保留 pip 缓存,直接减小镜像体积 |
COPY . . | 把构建上下文里的剩余文件拷进 /app |
EXPOSE 80 | 纯声明,告诉使用者”我打算用 80 端口”。它不会自动做端口映射,真正映射靠 docker run -p |
CMD ["python", "app.py"] | 容器启动的默认命令。JSON 数组形式(exec form) 直接 exec,PID 1 就是 python 进程,能正确接收 SIGTERM |
教程原 Dockerfile 写的是 EXPOSE 5000,而 app.py 监听的是 80,两者对不上。
EXPOSE 应该声明容器内进程实际监听的端口,所以这里改为 EXPOSE 80。
宿主机端口(5000)是在 docker run -p 5000:80 时才决定的,跟 EXPOSE 无关。
步骤 2:编写 requirements.txt 与 app.py
requirements.txt:
Flask==2.1.0Werkzeug==2.1.2Flask 2.1.0 依赖 Werkzeug ≥ 2.0,但 Werkzeug 新版本会移除某些旧 API,不锁版本会出现”本地能跑、构建后跑不起来”的经典事故。 锁定直接和传递依赖、记录哈希及基础镜像 digest,才能进一步提高可复现性;只固定这两个包还不够。本例是旧版依赖的兼容性演示,不能直接作为生产环境的版本建议。
app.py:
from flask import Flask
app = Flask(__name__)
@app.route('/')def hello_world(): return 'Hello, World!'
if __name__ == '__main__': app.run(host='0.0.0.0', port=80)host='0.0.0.0'必须写。默认值是127.0.0.1,那只监听容器内部的回环地址,宿主机完全访问不到 —— 这是新手排名第一的踩坑点。- 教程示例里带了
debug=True。学习环境可以,生产环境一定要去掉:debug 模式会开启交互式调试器,等于把任意代码执行的口子暴露出去,而且会额外起一个重载进程,破坏 PID 1 的信号处理。
步骤 3:构建镜像
docker build -t my-python-app:latest .旧式构建器的输出示意如下,ID 仅用于说明格式,并非本次实测结果。构建步骤不等于文件系统层;BuildKit 的输出格式也不同:
Sending build context to Docker daemon 5.12kBStep 1/7 : FROM python:3.9-slim ---> a438d5c1bb1bStep 2/7 : WORKDIR /app ---> Using cacheStep 3/7 : COPY requirements.txt . ---> 3f2a1c9e8d40Step 4/7 : RUN pip install --no-cache-dir -r requirements.txt ---> Running in 7c1e2f9a5b33Collecting Flask==2.1.0... ---> 9b7d4e2c1a55Step 5/7 : COPY . . ---> c4e8a1b72d90Step 6/7 : EXPOSE 80 ---> Running in 5a9c3e7b1d24Step 7/7 : CMD ["python", "app.py"] ---> 8e1f6a3b9c07Successfully built 8e1f6a3b9c07Successfully tagged my-python-app:latest注意 ---> Using cache —— 这就是 分层与缓存 要讲的层缓存。
步骤 4:运行容器
docker run -d -p 5000:80 my-python-app:latest-p 5000:80 的含义:宿主机 5000 端口 → 容器 80 端口。左边是宿主机,右边是容器。
验证:
curl localhost:5000# Hello, World!
docker ps# CONTAINER ID IMAGE PORTS NAMES# 3a1b2c3d4e5f my-python-app:latest 0.0.0.0:5000->80/tcp quirky_turing步骤 5:Cloud Studio 里访问服务
在 Cloud Studio 中打开端口插件,选择 5000 端口即可在浏览器里看到 Hello, World!。
三、原理篇:那条 . 到底做了什么
这是本文最想讲清楚的一节,也是教程里一笔带过、但实际最常出错的地方。
3.1 命令的完整语法
docker build -t your_image_name:tag -f path/to/Dockerfile .| 片段 | 含义 |
|---|---|
docker build | 启动构建 |
-t your_image_name:tag | 给镜像打名字和标签,不写 tag 则默认 latest |
-f path/to/Dockerfile | 指定 Dockerfile 路径 |
. | 构建上下文(build context)的路径,不是镜像层 |
3.2 “从哪一层开始打包” —— 两个层面,两个答案
这个问题其实混淆了两件事,必须分开答。
① 从”文件打包”的角度:起点是 .
② 从”镜像分层”的角度:起点是 FROM 的基础镜像
① 从”文件打包”的角度:起点是 .
docker build 的位置参数确定构建上下文。旧式构建器会将过滤后的上下文打包发送给 daemon;现代默认构建器 BuildKit 支持按需传输和缓存,不能一概描述成「每次把全部文件打成 tar」。
所以:
- 打包的根 =
.= 你敲命令时的当前目录 - 上下文大小 =
.下面有多少东西(这就是为什么.dockerignore至关重要) - 普通本地
COPY不能借../读取上下文外文件;不同构建器可能拒绝或规范化该路径,不能依赖它越界读取
② 从”镜像分层”的角度:起点是 FROM 的基础镜像
镜像的层由 Dockerfile 指令生成,FROM python:3.9-slim 把基础镜像的所有层整体继承过来,之后 RUN / COPY / ADD 每条新增一层。FROM 是构建阶段的起点,不代表只有一个「第 0 层」。
3.3 上下文 ≠ 镜像内容
这是个关键区分:
- 上下文中的文件只是构建期间可访问的输入,不会自动全部进入镜像
COPY/ADD可复制文件,RUN也可以生成或下载文件进入镜像层- 构建缓存可能保留输入和中间结果,不能假设构建结束后相关内容全部消失
上下文是”厨房里的全部食材”,镜像是”最终装盘的菜”。食材备齐了不等于全下锅。
3.4 三种常见写法对照
| 命令 | 上下文 | COPY 能取到的文件范围 |
|---|---|---|
docker build -f Dockerfile . | 当前目录 | 当前目录下所有文件 |
docker build -f docker/Dockerfile . | 当前目录 | 仍以当前目录为根,COPY app.py 找的是 ./app.py |
docker build -f docker/Dockerfile docker/ | docker/ 目录 | 只能取 docker/ 下的文件 |
COPY 的源路径始终相对上下文根,而不是相对 Dockerfile 所在目录。
3.5 .dockerignore 必须配
__pycache__/*.py[cod].venv/venv/.git/.gitignore.dockerignoreDockerfileREADME.md没有它,COPY . . 会把本地的虚拟环境(动辄几百 MB)、.git 历史、IDE 配置全塞进镜像,还会直接击穿构建缓存(见下节)。
四、原理篇:分层、缓存与写时复制
4.1 为什么先 COPY requirements.txt 再 COPY 代码?
这是本 Dockerfile 里最”值钱”的一行设计。看两种写法的对比:
写法 A(教程的写法,正确)
COPY requirements.txt .RUN pip install -r requirements.txtCOPY . .改 app.py 时:requirements.txt 没变 → 前两层命中缓存 → 跳过 pip install,构建秒级完成。
写法 B(反例)
COPY . .RUN pip install -r requirements.txt改任何一个文件 → COPY . . 那一层的校验和变了 → 后面所有层缓存全部失效 → 每次都重装全部依赖,改一行代码等三分钟。
对每条指令,计算「指令内容 + 相关文件内容」的校验和;若与已有层完全一致则直接复用(输出 Using cache),否则从这条开始后面所有层全部重建。
推论:越不常变的层,越要放在前面。
4.2 层是共享的,不是复制的
两个镜像如果都 FROM python:3.9-slim,那基础镜像的层在磁盘上只存一份,被两个镜像共同引用。这就是为什么本地镜像列表的 SIZE 加起来很大,实际磁盘占用却小得多。
docker system df # 看真实占用,会发现"共享"带来的节省4.3 镜像只读 + 写时复制(Copy-on-Write)
镜像层全是只读的。docker run 时,Docker 在所有只读层之上加一层薄薄的可读写容器层:
- 读文件:从上层往下找,找到就返回
- 改文件:先把文件从只读层复制到容器层,再改(这就是 CoW)
- 删文件:在容器层打一个”白化”(whiteout)标记,遮住下层文件
- 容器里删文件不会让镜像变小
- 容器删除后可写层消失;卷和 bind mount 中的数据不随之删除。需要保留镜像变更可用
docker commit(见 docker commit)或写回 Dockerfile
验证一下分层:
docker image history my-python-app:latestdocker image inspect --format='{{.RootFS.Layers}}' my-python-app:latest五、镜像使用:查看、过滤与清理
5.1 列出镜像
docker images # 列出本地镜像docker images -a # 含中间层镜像docker images --no-trunc # 显示完整 64 位镜像 IDdocker images my-python-app # 按名称过滤docker images my-python-app:latest # 按名称+标签过滤docker images --filter=reference='my-python-app' # 用 --filterdocker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"默认显示的镜像 ID 只有前 12 位,是完整 SHA256 的缩写。日常操作够用,但写脚本时建议用 --no-trunc 或直接用 tag。
5.2 拉取指定标签的镜像
docker pull alpine:3.7不写标签就是 :latest。
它指向哪个版本会随时间变化,导致”同一份部署脚本,今天和明天跑出不同结果”。
5.3 查看详情与历史
docker image inspect my-python-app:latest # 完整元数据docker image inspect --format='{{.Config.Env}}' my-python-app # 只看环境变量docker image inspect --format='{{.RootFS.Layers}}' my-python-app # 看层列表docker image history my-python-app:latest # 看每层的构建指令5.4 清理
docker image prune # 删除悬空镜像(<none>:<none>)docker image prune -a # 删除所有未被容器引用的镜像docker system prune -a --volumes # 系统级大扫除(含数据卷,慎用)(图 2 —— 原图待补)
[!note] 什么是悬空镜像 悬空镜像(dangling image) 是重新构建后被新镜像抢占了名字、只剩
<none>:<none>的旧镜像。它们不承载业务逻辑,但很占磁盘。
六、镜像标签管理
标签是 镜像名:标签 里的后半段,是容器化应用的版本管理核心。
6.1 为什么必须有标签策略
- 版本控制与回滚:出问题时能一键退回上一个版本
- 可追溯:知道线上跑的是哪次构建
- 支撑自动化:CI/CD 靠标签决定部署什么
6.2 打标签的三种时机
① 构建时指定
docker build -t my-python-app:1.0.0 .docker build -t csc/csc_app:1 -t csc/csc_webapp:1 . # 一次构建打多个标签② 构建后追加
docker tag my-python-app:latest my-python-app:1.0.0docker tag my-python-app:latest my-python-app:proddocker tag 不会复制镜像内容,只是给同一个镜像 ID 多挂一个名字(类似硬链接),所以是瞬时完成、零额外磁盘开销。
③ 用镜像 ID 打标签
docker tag 8a7ffbdffc07 my-python-app:1.0.06.3 推荐的标签策略
# 语义化版本docker tag my-python-app:latest my-python-app:1.0.0docker tag my-python-app:latest my-python-app:1.0docker tag my-python-app:latest my-python-app:1
# 按环境docker tag my-python-app:latest my-python-app:proddocker tag my-python-app:latest my-python-app:dev
# 结合构建信息(可追溯到代码提交)docker tag my-python-app:latest my-python-app:$(git rev-parse --short HEAD)docker tag my-python-app:latest my-python-app:$(date +%Y%m%d-%H%M%S)- 生产环境不用
latest - 用语义化版本(
MAJOR.MINOR.PATCH) - 按环境区分(
prod/staging/dev) - 带上 Git commit hash,实现镜像到代码的双向追溯
6.4 删除与”重命名”标签
docker rmi my-python-app:old-tag # 删标签(若该镜像还有别的标签,只删标签不删数据)docker tag my-python-app:old my-python-app:new && docker rmi my-python-app:old # 重命名七、发布到 Docker Hub
7.1 完整流程
步骤 1:在 Docker Hub 注册并登录,进入 Repositories 标签页创建一个仓库(例如 cloudstudio)。
步骤 2:用「用户名/仓库名」的格式构建
docker build -t keiran479/cloudstudio .写成 my-python-app:1.0.0 推送时,Docker 不知道该推给谁。正确格式是 <用户名>/<仓库名>:<标签>。
步骤 3:登录
docker login -u <username>用 Access Token 代替密码,并避免明文出现在命令历史里:
echo $DOCKER_PASSWORD | docker login -u <username> --password-stdin步骤 4:推送
docker push keiran479/cloudstudio步骤 5:在 Docker Hub 页面确认镜像已上线。
7.2 推送指定版本到私有仓库
docker tag my-python-app:latest keiran479/my-python-app:v1docker login -u <username>docker push keiran479/my-python-app:v17.3 推送的原理
docker push 并不会把整个镜像无脑上传。它会:
- 读取镜像的 manifest(层清单)
- 逐层计算 SHA256,只上传远端仓库不存在的层
- 已存在的层直接跳过(输出里表现为
Layer already exists或Mounted from ...)
这就是为什么基于同一基础镜像的第二个镜像推送得飞快 —— 底层的 python:3.9-slim 早就在 Hub 上了。
7.4 推送失败的四个常见原因
| 现象 | 排查方向 |
|---|---|
认证错误 unauthorized | docker logout 后重新 docker login,或改用 Access Token |
| 网络超时 | 检查连通性;配置 /etc/docker/daemon.json 的 registry-mirrors 加速器 |
repository does not exist | 确认 Hub 上已创建仓库、名称拼写正确、有推送权限 |
| 镜像过大 | 用 .dockerignore、多阶段构建、清理构建中间产物 |
八、另一种造镜像的方式:docker commit
8.1 语法与定位
docker commit [OPTIONS] CONTAINER [REPOSITORY[:TAG]]| 选项 | 作用 |
|---|---|
-a, --author | 指定作者 |
-m, --message | 提交说明 |
-c, --change | 应用 Dockerfile 指令,如 --change="ENV DEBUG=true" |
-p, --pause | 提交期间暂停容器 |
docker commit 从容器的当前状态创建新镜像 —— 它捕获的是容器可读写层相对镜像的增量,把这部分固化成一个新的只读层。
8.2 实操:给 Ubuntu 装上 nmap 并固化
# 1. 拉取基础镜像docker pull alex43/ubuntu-with-git:v1.0
# 2. 交互式启动容器docker run -it alex43/ubuntu-with-git:v1.0 /bin/bash
# 3. 在容器内修改nmap --version # 先确认没装apt-get install nmapnmap --version # 确认装好了exit
# 4. 提交为新镜像docker commit <container_id> ubuntu-with-nmap:v1.0
# 5. 验证docker images
# 6. 基于新镜像启动,改动仍在docker run -it ubuntu-with-nmap:v1.0 /bin/bash8.3 commit vs push vs save
| 命令 | 作用 | 对象 |
|---|---|---|
docker commit | 从容器创建镜像 | 容器 → 镜像 |
docker push | 把镜像推送到远端仓库 | 镜像 → 注册表 |
docker save -o x.tar img | 把镜像导出为 tar 备份文件 | 镜像 → 本地文件 |
commit 保留容器的改动,save 不保留(save 只打包镜像本身的只读层)。
8.4 什么时候该用 commit
✅ 适合:快速原型验证、保存调试现场、临时修复、实验性改动 ❌ 不适合:正式交付
生产镜像一律用 Dockerfile 构建。 commit 出来的镜像是”黑盒”—— 没人知道里面改了什么,无法 code review,无法复现。 它最大的价值是”临时存档”,存档确认有效后,应当把改动翻译回 Dockerfile。
九、排错清单与最佳实践
9.1 五个高频故障
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 端口访问不通 | curl 连接被拒 | ① 应用是否监听 0.0.0.0(不是 127.0.0.1);② docker run -p 是否加了;③ 宿主机/容器端口顺序是否写反 |
| 构建慢 / 上下文巨大 | Sending build context 几百 MB | 配 .dockerignore,排除 .git、venv、日志 |
| 改一行代码就重装依赖 | 每次构建都跑 pip install | 拆开 COPY requirements.txt 和 COPY . .(见 依赖缓存) |
| COPY 路径找不到 | forbidden path outside the build context | 理解上下文根;COPY 源路径相对上下文而非 Dockerfile |
| 容器起来就退出 | docker ps 看不到 | docker logs <id> 看报错;通常是前台进程结束或启动命令错误 |
9.2 镜像优化六条
- 用多阶段构建 —— 编译期依赖不进最终镜像
- 优化层顺序 —— 不常变的层放前面
- 必配
.dockerignore—— 减小上下文、保护缓存 - 锁定基础镜像版本 —— 不用
latest - 合并 RUN 指令 ——
RUN a && b && c减少层数,并在同一层内清理缓存 - 构建过程中清理 ——
pip install --no-cache-dir、apt-get clean && rm -rf /var/lib/apt/lists/*
第 5、6 条配合使用的正确姿势:
RUN apt-get update \ && apt-get install -y --no-install-recommends gcc \ && apt-get clean \ && rm -rf /var/lib/apt/lists/*如果分成两条 RUN,第一条产生的缓存文件已经被固化进层,第二条删掉它们只会在新层打 whiteout,镜像体积一点都不会变小。
十、总结
10.1 本次实操的最小闭环
# 构建docker build -t my-python-app:1.0.0 .
# 运行docker run -d -p 5000:80 --name myapp my-python-app:1.0.0
# 验证curl localhost:5000 # Hello, World!
# 打标签docker tag my-python-app:1.0.0 my-python-app:latest
# 发布docker tag my-python-app:1.0.0 <username>/my-python-app:1.0.0docker push <username>/my-python-app:1.0.0
# 清理docker stop myapp && docker rm myappdocker image prune10.2 三个必须刻进脑子里的原理
.是构建上下文,不是镜像层。 上下文决定了”构建时能看到哪些文件”,FROM决定了”镜像从哪一层开始叠”。这是两个不同的维度。- 层是不可变且可共享的,缓存从失效的那条指令起全线崩塌。 所以把稳定的东西放前面(
requirements.txt),把易变的东西放后面(业务代码)。 - 镜像只读,容器靠写时复制获得可写层。 容器内的任何改动都活不过容器本身,想留存就回到 Dockerfile。
10.3 一句话收尾
Dockerfile 是声明式的、可评审的、可复现的;docker commit 是命令式的、黑盒的、一次性的。
前者是工程,后者是草稿。真正优秀的镜像管理,是让每一层都言之有据。
#Docker #镜像 #容器化 #云原生 #教程笔记
核对资料
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
正在加载 GitHub 评论…