📚前言
📒FDE系列内容总纲:
🚄前置课程列表:
见文档结尾附录。
🚀阶段2·Day 41:Docker 入门 — 把环境装进盒子
FDE 学习系列教程 · 第二阶段 · 第 5 周 · Day 1 预计时长:5.5 小时(可分两次)| 难度:★★★☆☆ | 前置知识:WSL2 环境、Linux 基础命令、FastAPI、进程与端口概念
📌 一句话目标:能说清"容器到底隔离了什么",能用 17 条命令把容器的生命周期走完且不踩坑,能把端口映射写对、把数据挂到容器外面。
⏱️ 这 5.5 小时怎么用
| 时段 | 内容 | 这一段结束时你手上应该有 |
|---|---|---|
| 0:00-0:30 | 开场 + §一 环境差异五维 | 一张"我到底差在哪"的清单 |
| 0:30-1:10 | §二 三个内核机制 | 能解释"容器不是小虚拟机" |
| 1:10-2:00 | §三 镜像分层 + 实跑缓存模拟 | 106 倍这个数字,以及为什么 |
| 2:00-2:40 | §四 安装与验收 | 绿鲸鱼 + hello-world |
| 2:40-3:40 | §五 生命周期 17 步(实跑) | 4 类错误不再犯 |
| 3:40-4:20 | §六 端口映射(实跑) | 3 条判据 |
| 4:20-4:50 | §七 数据卷 + 权限坑 | 数据库挂卷方案 |
| 4:50-5:20 | §八 安全基线 + §九 故障 12 例 | 交付前自查表 |
| 5:20-5:30 | 速查 + 问答 + 小结 | — |
🧑🤝🧑 开场:不是"跑不起来",是"跑起来不一样"
上周你在 WSL 里把 FastAPI 跑通了。客户给你一台服务器,你上去敲 python3 --version:
Python 3.6.8
你的代码用了 match 语句(3.10+)、list[str] 泛型(3.9+)、zoneinfo(3.9+)。三个都用不了。
于是你开始"修环境"。修到第 4 个小时,问题变成了这样:
你的 WSL Ubuntu 22.04 客户 CentOS 7.9
───────────────────── ─────────────────────
glibc 2.35 glibc 2.17 ← 新 wheel 装不上
Python 3.10.12 Python 3.6.8 ← 语法不兼容
openssl 3.0 openssl 1.0.2 ← requests 报 SSL 错
/usr/lib/x86_64-linux-gnu/ /usr/lib64/ ← 库路径不同
你的账号有 sudo 客户只给了一个普通账号
这不是"某个包装错了",是五个维度同时不一样。
★ 你之前所有的部署文档,本质都是"请把你这台机器,改成和我那台一样"。
这句话在客户现场,等于让客户动生产环境 —— 通常被拒绝。
Docker 换了个方向:不改客户的机器,把"我那台机器"打包带过去。
📖 一、环境差异的五个维度
1.1 五个维度
| 维度 | 具体差异 | 典型症状 |
|---|---|---|
| 内核 | 3.10 vs 5.15 | 新系统调用不可用、cgroup v1/v2 行为不同 |
| C 库 | glibc 2.17 vs 2.35 |
|
| 解释器 | 3.6 vs 3.12 | 语法错、标准库 API 变更 |
| 系统库 | openssl 1.0.2 vs 3.0 | SSL 握手失败、证书校验报错 |
| 路径与账号 |
| 找不到 .so、装不了系统包 |
1.2 一层一层往下依赖
你的 FastAPI 服务,依赖链其实有 4 层:
┌─────────────────────────────────────────────┐
│ 你的代码 app/main.py │ ← 你天天改
├─────────────────────────────────────────────┤
│ 第三方包 fastapi / uvicorn / pydantic │ ← pip 装
├─────────────────────────────────────────────┤
│ 系统库 libssl / libffi / libpq / zlib │ ← apt/yum 装
├─────────────────────────────────────────────┤
│ 基础运行时 Python 3.12 + glibc + 内核 │ ← 这层最难搬
└─────────────────────────────────────────────┘
前两层靠 requirements.txt 能带走,后两层带不走——它们长在操作系统里。
★ "在我电脑上能跑"的真正含义:
我的第 3、4 层刚好和代码匹配,而你的不匹配。
容器把 1、2、3、4 层一起打包,所以到哪都一样。
1.3 容器 vs 虚拟机:轻量的秘密
你可能听过虚拟机(VMware、VirtualBox)。容器和它都能做"环境隔离",但路子完全不同:

打个比方:
-
虚拟机像"租了一整套独立的房子"——自己的厨房、水电、大门,什么都独立,但又重又占地方
-
容器像"同一栋公寓楼里的独立房间"——楼的地基和水电(内核)共享,但每个房间各自独立、互不打扰,轻得多
| 对比项 | 虚拟机 | 容器 |
|---|---|---|
| 启动时间 | 几十秒~几分钟(要启动整个系统) | 秒级(就是起一个进程) |
| 资源占用 | GB 级(每个带完整系统) | MB 级(只带应用和依赖) |
| 隔离级别 | 硬件级强隔离 | 进程级隔离(共享内核) |
| 一台机器能跑几个 | 几个~十几个 | 几十~上百个 |
| 典型用途 | 跑不同操作系统、强隔离 | 应用部署、微服务(FDE 主战场) |
📌 一句话:容器就是一种"轻量级的虚拟机",它不虚拟硬件,只把应用和它需要的环境打包隔离起来。 快、小、可移植,是它统治现代部署的原因。
1.4 三种方案对比
| 方案 | 做法 | 代价 | 什么时候用 |
|---|---|---|---|
| 文档化部署 | 写清楚每一步装什么 | 客户环境一变就失效 | 单机、环境可控 |
| 虚拟机镜像 | 导出一个完整 VM | 几十 GB、启动慢、迁移笨重 | 必须换操作系统 |
| 容器 | 打包应用 + 依赖 + 系统库,共享内核 | 需要内核支持(Linux 3.10+) | FDE 主战场 |
📌 注意"共享内核"这四个字:容器不换内核,所以它不能把 Linux 应用搬到 Windows 内核上跑。Windows 上跑 Linux 容器,是因为 Docker Desktop 偷偷起了一个 Linux 虚拟机(WSL2 或 Hyper-V),容器还是跑在 Linux 内核里。这一点后面 §2.4 会讲清。
📖 二、容器到底隔离了什么:三个内核机制
"容器是轻量级虚拟机"是个方便的比喻,但它会误导你。准确说:容器是一组被内核限制和隔离起来的普通进程。
支撑这句话的是三个机制。
2.1 namespace:让它"看不见别人"
| namespace | 隔离什么 | 效果 |
|---|---|---|
| PID | 进程号 | 容器内进程是 1 号,看不到宿主的进程 |
| NET | 网络栈 | 自己的网卡、IP、端口表 |
| MNT | 挂载点 | 自己的根目录 |
| UTS | 主机名 | 自己的 hostname |
| IPC | 进程间通信 | 自己的信号量/共享内存 |
| USER | 用户与 UID | 容器内可以是 root,宿主上不是 |
宿主机看到的 容器内看到的
───────────── ─────────────
PID 4812 python main.py ──► PID 1 python main.py
hostname: prod-node-01 ──► hostname: a1b2c3d4e5f6
ip: 10.0.3.15 ──► ip: 172.17.0.2
/ ──► /(镜像里那套根目录)
★ 同一个进程,两套视图。namespace 就是"视图切换器"。
一个能记住的判据:
容器里的 PID 1 不是"系统的 init",是你的应用。
所以你的应用必须自己处理信号(SIGTERM),否则 docker stop 会等 10 秒然后强杀。
2.2 cgroups:让它"用不了太多"
namespace 管"看得见什么",cgroups 管"能用多少":
| 限制项 | Docker 参数 | 不加会怎样 |
|---|---|---|
| CPU |
| 一个容器跑满所有核,别的服务卡死 |
| 内存 |
| 内存泄漏的容器把宿主 OOM |
| 磁盘 IO |
| 日志狂写拖慢数据库 |
| 进程数 |
| fork 炸弹拖垮整机 |
★ FDE 现场铁律:给客户的容器,必须设 -m 和 --cpus。
不是"以防万一",是"这台机器上还有别人的系统"。
2.3 unionfs:让它"看起来有一个完整系统"
镜像不是一个文件,是一堆只读层叠起来,最上面再盖一个可写层:
┌──────────────────────────────┐ ← 容器可写层(容器删了就没了)
├──────────────────────────────┤
│ 层 5 COPY . . │ ← 你的代码
├──────────────────────────────┤
│ 层 4 RUN pip install ... │ ← 第三方包
├──────────────────────────────┤
│ 层 3 COPY requirements.txt │
├──────────────────────────────┤
│ 层 2 WORKDIR /app │
├──────────────────────────────┤
│ 层 1 FROM python:3.12-slim │ ← 基础镜像(多容器共享,只存一份)
└──────────────────────────────┘
读文件:从上往下找,找到就返回(overlayfs)
写文件:写到最上面的可写层(copy-on-write)
这一条直接推出两个重要结论,§三会用实跑数据证明:
-
多个容器共享基础层 → 10 个 Python 应用不会占 10 份 Python。
-
改一层,上面的层全部失效 → Dockerfile 的指令顺序决定构建时间。
2.4 所以它到底是不是"轻量虚拟机"
| 对比项 | 虚拟机 | 容器 |
|---|---|---|
| 隔离单位 | 硬件虚拟化 + 完整 OS | 进程 + 内核机制 |
| 自带内核 | 有 | 没有(共享宿主内核) |
| 启动 | 秒~分钟(要引导系统) | 毫秒~秒(起一个进程) |
| 体积 | GB 级 | MB 级 |
| 密度 | 一台机器十几个 | 几十到上百个 |
| 隔离强度 | 强(硬件边界) | 中(内核共享,逃逸风险真实存在) |
| 能否跑异构 OS | 能(Linux 上跑 Windows) | 不能(内核必须同族) |
| 快照/迁移 | 整机快照 | 镜像 + 卷,天然可搬 |
★ 一句话:容器不是"更小的虚拟机",是"被关进笼子的进程"。
这个区别在安全上很要紧:
虚拟机逃逸 = 攻破 Hypervisor(很难)
容器逃逸 = 攻破内核(相对容易,尤其是给了 --privileged)
🖥️ 三、镜像:分层、标签、摘要
3.1 三个概念先分清
| 概念 | 是什么 | 类比 |
|---|---|---|
| 层(layer) | 一组文件的只读快照 | 一页透明胶片 |
| 镜像(image) | 若干层按顺序叠起来 + 元信息 | 一叠胶片 |
| 标签(tag) | 指向某个镜像的可变指针 | 便利贴 |
| 摘要(digest) | 镜像内容的 SHA256 | 身份证号 |
示意图如下:

nginx:alpine ← tag(便利贴,会被人撕下来重贴)
nginx@sha256:8a1b... ← digest(身份证号,永不变)
★ 生产环境钉 digest,不要只写 latest。
latest 今天指向 v1.27,明天可能指向 v1.29 —— 你的"没改任何东西"会突然出故障。
镜像 vs 容器,这是 Docker 最核心的一对概念,用你学过的 Python 一类比就通:
镜像(Image) ≈ 类(class) —— 只读的模板/图纸
容器(Container)≈ 对象(instance) —— 镜像跑起来后的活实例
class Dog: ──────────► docker pull python:3.12
def bark() (下载镜像=拿到图纸)
dog1 = Dog() ──────────► docker run python:3.12
dog2 = Dog() (从镜像创建容器=按图纸造实例)
同一个镜像可以同时跑很多容器

注:docker commit 是 Docker 的一个命令,用于将一个容器的当前状态保存为一个新的镜像。
记住这个对应关系,后面所有命令都围绕这两样东西:
| 操作 | 镜像(Image) | 容器(Container) |
|---|---|---|
| 查看列表 |
|
|
| 下载/创建 |
|
|
| 删除 |
|
|
| 生命周期 | 不动它就一直在 | run → start → stop → rm |
3.2 实跑:层缓存值多少钱
这段本该明天讲 Dockerfile,但不理解分层,就理解不了镜像,所以放在今天先跑一遍数字。
下面示例用 hashlib 模拟 Docker 的 layer cache key 计算:key = hash(父层key + 指令 + 输入文件内容指纹)。父层变了,后续层全部级联失效。然后用 BAD 和 GOOD 两组 Dockerfile 指令序列,对比“改一行 main.py”分别要重建几层、花多少秒。
跑出来的核心数字是:坏写法 42.4s vs 好写法 0.4s,相差约 106 倍。原因是坏写法里 COPY . . 在 pip install 之前,改任何源文件都会让 COPY 层的 key 变化,进而让 42 秒的 pip install 层跟着失效;好写法先单独 COPY requirements.txt,只要依赖文件没动,pip 层就一直命中缓存。
具体示例文件名 layer_cache_sim.py:
# layer_cache_sim.py —— 镜像分层与缓存失效:两种 Dockerfile 写法,改一行代码各要多久
from __future__ import annotations
import hashlib
# 指令, 输入文件, 耗时(秒)
BAD = [
("FROM python:3.12-slim", None, 0.0),
("WORKDIR /app", None, 0.1),
("COPY . .", ["app/main.py", "app/requirements.txt"], 0.3),
("RUN pip install -r requirements.txt", None, 42.0),
('CMD ["uvicorn","main:app"]', None, 0.1),
]
GOOD = [
("FROM python:3.12-slim", None, 0.0),
("WORKDIR /app", None, 0.1),
("COPY requirements.txt .", ["app/requirements.txt"], 0.2),
("RUN pip install -r requirements.txt", None, 42.0),
("COPY . .", ["app/main.py", "app/requirements.txt"], 0.3),
('CMD ["uvicorn","main:app"]', None, 0.1),
]
FILES = {
"app/main.py": "print('v1')\n",
"app/requirements.txt": "fastapi==0.115.0\nuvicorn==0.30.0\n",
}
def digest(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()[:8]
def build(steps, files, cache: list[str]) -> tuple[list[str], float, int]:
"""返回 (新缓存签名, 耗时, 重建层数)。父层变了,后续层全部失效。"""
new_cache, cost, rebuilt = [], 0.0, 0
parent = "base"
for instr, inputs, sec in steps:
src = "".join(sorted(f"{p}={digest(files[p])}" for p in (inputs or [])))
key = digest(f"{parent}|{instr}|{src}")
if key not in cache:
cost += sec
rebuilt += 1
new_cache.append(key)
parent = key
return new_cache, cost, rebuilt
def main() -> None:
print("镜像 = 一串只读层叠加(unionfs / overlayfs)")
print("缓存 key = hash(父层 key + 指令 + 输入文件内容指纹)")
print("父层变了 → 本层失效 → 后面全部失效(级联)")
code2 = dict(FILES)
code2["app/main.py"] = "print('v2')\n" # 只改一行代码
# ---------- 坏写法 ----------
print("\n① 坏写法:COPY . . 在 pip install 之前")
for i, (instr, _, _) in enumerate(BAD, 1):
print(f" L{i} {instr}")
c1, cost1, n1 = build(BAD, FILES, [])
print(f" 首次构建:重建 {n1} 层,{cost1:.1f}s")
cache1, cost1b, n1b = build(BAD, code2, c1)
print(f" 改一行 main.py 后:重建 {n1b} 层,{cost1b:.1f}s ← 依赖跟着重装")
# ---------- 好写法 ----------
print("\n② 好写法:先 COPY requirements.txt → pip → 再 COPY . .")
for i, (instr, _, _) in enumerate(GOOD, 1):
print(f" L{i} {instr}")
c2, cost2, n2 = build(GOOD, FILES, [])
print(f" 首次构建:重建 {n2} 层,{cost2:.1f}s")
g_cache = c2 # 首次构建产生的缓存(对应原始文件内容)
_, cost2b, n2b = build(GOOD, code2, g_cache)
print(f" 改一行 main.py 后:重建 {n2b} 层,{cost2b:.1f}s ← 只重建拷贝层")
print(f"\n同一行代码改动:{cost1b:.1f}s vs {cost2b:.1f}s,相差 {cost1b / cost2b:.0f} 倍")
# ---------- 命中表 ----------
print("\n③ 四种改动在好写法下的缓存命中:")
cases = [
("改 main.py", {"app/main.py": "print('v3')\n"}),
("改 requirements.txt", {"app/requirements.txt": "fastapi==0.115.1\n"}),
("两个都改", {"app/main.py": "v4", "app/requirements.txt": "fastapi==0.116.0\n"}),
("只碰 mtime,内容不变", {}),
]
for name, patch in cases:
f2 = dict(FILES)
f2.update(patch)
_, c, k = build(GOOD, f2, g_cache)
print(f" {name:<22}重建 {k} 层 / {c:>5.1f}s")
print("\n④ 结论")
print(" · 改代码:好写法 0.4s,坏写法 42.4s —— 每天几十次构建,差距就是几十分钟")
print(" · 改依赖:两种都要重装(依赖真的变了,缓存救不了)")
print(" · 内容不变的 touch:不失效 —— key 看内容,不看修改时间")
print(" ★ Dockerfile 的指令顺序不是风格问题,是构建成本问题。")
if __name__ == "__main__":
main()
【实跑】输出:
镜像 = 一串只读层叠加(unionfs / overlayfs)
缓存 key = hash(父层 key + 指令 + 输入文件内容指纹)
父层变了 → 本层失效 → 后面全部失效(级联)
① 坏写法:COPY . . 在 pip install 之前
L1 FROM python:3.12-slim
L2 WORKDIR /app
L3 COPY . .
L4 RUN pip install -r requirements.txt
L5 CMD ["uvicorn","main:app"]
首次构建:重建 5 层,42.5s
改一行 main.py 后:重建 3 层,42.4s ← 依赖跟着重装
② 好写法:先 COPY requirements.txt → pip → 再 COPY . .
L1 FROM python:3.12-slim
L2 WORKDIR /app
L3 COPY requirements.txt .
L4 RUN pip install -r requirements.txt
L5 COPY . .
L6 CMD ["uvicorn","main:app"]
首次构建:重建 6 层,42.7s
改一行 main.py 后:重建 2 层,0.4s ← 只重建拷贝层
同一行代码改动:42.4s vs 0.4s,相差 106 倍
③ 四种改动在好写法下的缓存命中:
改 main.py 重建 2 层 / 0.4s
改 requirements.txt 重建 4 层 / 42.6s
两个都改 重建 4 层 / 42.6s
只碰 mtime,内容不变 重建 0 层 / 0.0s
④ 结论
· 改代码:好写法 0.4s,坏写法 42.4s —— 每天几十次构建,差距就是几十分钟
· 改依赖:两种都要重装(依赖真的变了,缓存救不了)
· 内容不变的 touch:不失效 —— key 看内容,不看修改时间
★ Dockerfile 的指令顺序不是风格问题,是构建成本问题。
3.3 从这四个数字能读出什么
| 读到的 | 怎么用 |
|---|---|
| 106 倍 | 每天改 30 次代码,坏写法浪费约 21 分钟/天 |
| 改依赖两种都要重装 | 所以依赖变更要单独提交、单独构建,别和代码改动混在一起 |
| mtime 不变不失效 | 缓存 key 看内容,不看时间; |
| 坏写法首次构建 42.5s vs 好写法 42.7s | 首次构建几乎一样——省的是第二次以后 |
🧠 这张表解释了为什么"能跑就行"的 Dockerfile 会在两周后变成折磨: 首次构建看不出差别,第 200 次构建差 100 倍。
3.4 一条推论:为什么基础镜像那么大还那么受欢迎
python:3.12-slim 约 130MB,你的代码只有 20KB。
★ 但这 130MB 只存一份,10 个容器共享。
真正按需增长的是最上面那几层。
🖥️ 四、安装与验收(Windows + WSL2)
4.1 装
-
下载:Docker Desktop: The #1 Containerization Tool for Developers | Docker
-
安装时保持勾选 "Use WSL 2 instead of Hyper-V"
-
装完重启;启动 Docker Desktop,等左下角小鲸鱼变绿并稳定
-
Settings → Resources → WSL Integration → 打开你的 Ubuntu 发行版开关(让你在 WSL 的 Ubuntu 终端里面,直接能用
docker/docker compose命令,共用 Docker Desktop 的同一个容器引擎daemon)
4.2 验:四道关
# 第 1 关:CLI 在不在
docker --version
docker compose version
docker compose是 Docker 官方提供的多容器应用编排工具(Compose V2,作为 Docker CLI 插件使用,命令用空格形式docker compose)。它通过一个 YAML 文件(通常叫compose.yaml或docker-compose.yml)声明式地定义和运行多个容器,一条命令即可启动整套服务。
【实跑】本机输出(CLI 装了,引擎没起):
Docker version 28.1.1, build 4eba377
Docker Compose version v2.35.1-desktop.1
# 第 2 关:引擎在不在(这一关最容易被跳过)
docker info --format '{{.ServerVersion}} | {{.OSType}}'
【实跑】引擎没起时的真实报错:
error during connect: Get "http://%2F%2F.%2Fpipe%2FdockerDesktopLinuxEngine/v1.49/info":
open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified.
★ 这句话的意思是:"CLI 找得到,但那个叫 dockerDesktopLinuxEngine 的管道不存在"。
换成白话:Docker Desktop 没启动,或者启动失败。
`docker --version` 能出版本号,不代表能跑容器 —— 它只是读了本地二进制。
所以验收必须到第 2 关。
# 第 3 关:整条链路
docker run --rm hello-world
# 第 4 关:镜像能落地
docker pull nginx:alpine
docker images
第 3 关成功时你会看到(示意输出):
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
...
Status: Downloaded newer image for hello-world:latest
Hello from Docker!
This message shows that your installation appears to be working correctly.
4.3 四类启动失败怎么排
| 现象 | 真因 | 处理 |
|---|---|---|
|
| Desktop 没起 / 服务没权限 | 启动 Desktop;服务需管理员 |
|
| WSL 内核旧 | 管理员 PowerShell: |
|
| BIOS 虚拟化关着 | BIOS 开 Intel VT-x / AMD SVM |
| 小鲸鱼一直转圈 | WSL Integration 没开 | Settings → Resources → WSL Integration |
4.4 国内拉镜像慢
Settings → Docker Engine,加镜像加速:
{
"registry-mirrors": ["https://<你的加速地址>"]
}
★ 客户内网通常完全不能出网。这时候不是"加速"的问题,
是要在内网搭一个 registry(§八会讲离线交付怎么做)。
🖥️ 五、生命周期:17 步走完,4 步会被拒绝
5.1 生命周期管理
5.1.2 状态变化
容器从创建到删除,会经历以下几个状态:
(无)→ running
docker run 一步完成创建并启动。严格说中间还有个 created 状态(docker create 之后、start 之前),但 docker run 把它合并了,日常可以忽略。
running → exited
docker stop 让容器优雅退出。默认先发 SIGTERM,等 10 秒(可配置)再发 SIGKILL。容器主进程结束后进入 exited。
exited → running
docker start 重新启动。注意:重新执行的是镜像的 ENTRYPOINT/CMD,是全新进程,不是"恢复现场"。容器可写层里之前的文件改动还在,但进程状态不保留。这跟 pause/unpause 的冻结解冻是两回事。
running → exited(强制)
docker rm -f 会先 SIGKILL 强杀再删除,一步到位但跳过优雅停止。生产环境建议 stop 和 rm 分两步做。
exited → removed
docker rm 直接删除已停止的容器,不需要 -f。很多人误以为 exited 也要加 -f,其实不用。
running → removed
必须先 stop 或 rm -f,不能直接 rm。
5.1.2 三条命令的适用边界
docker exec:只对 running 有效
exited 状态报错。paused 状态也不可用(进程已冻结)。restarting 状态有时会短暂失败。要进容器排查,先确认它在 running。
docker logs:running 和 exited 都能看
日志是容器标准输出的持久记录,容器停了照样能翻。这是排查"容器为什么退出"的第一入口。paused 状态也能看。
docker rmi:删除的是镜像(image),不是容器,要求没有任何容器引用该镜像
准确说是没有任何容器的可写层依赖该镜像的某一层。具体边界:
-
有 running 容器基于它 →
rmi失败,加-f也删不掉 -
有 exited 容器基于它 →
rmi失败,但rmi -f可以强删(容器会变成悬空引用) -
容器已
rm→ 镜像可正常删除 -
多个 tag 指向同一 image ID → 删一个 tag 不影响,删最后一个才真正释放
-
被其他镜像作为基础层引用(子镜像)→ 不能直接删,要
-f或先删子镜像
docker run
(无) ─────────────────────► running ──── docker stop ────► exited
▲ │ │
start │ │ docker rm(要 -f) │ start
│ ▼ │
exited ◄────────────────────────┘
│
docker rm
▼
removed(没了)
docker exec :只对 running 有效
docker logs :running / exited 都能看
docker rmi :要求没有任何容器(含 exited)引用它
5.2 实跑:container_lifecycle.py
# container_lifecycle.py —— 容器状态机:跑 17 步操作,看哪些会被拒绝
from __future__ import annotations
class Engine:
def __init__(self) -> None:
self.images: set[str] = set()
self.containers: dict[str, dict] = {}
def ps(self, all_: bool = False) -> list[str]:
return [f"{n}({c['state']})" for n, c in self.containers.items()
if c["state"] != "removed" and (all_ or c["state"] == "running")]
def _port_busy(self, host_port: int) -> str | None:
for n, c in self.containers.items():
if c["state"] == "running" and host_port in c["ports"]:
return n
return None
def pull(self, image: str) -> str:
self.images.add(image.split(":")[0] + ":latest" if ":" not in image else image)
return f"pull {image} → 本地镜像 {len(self.images)} 个"
def run(self, name: str, image: str, host_port: int, cport: int,
auto_rm: bool = False) -> str:
if name in self.containers and self.containers[name]["state"] != "removed":
return f"ERR 名字冲突:{name} 已存在(--name 必须唯一)"
if image not in self.images:
return f"ERR 镜像不存在:{image}(先 docker pull)"
busy = self._port_busy(host_port)
if busy:
return (f"ERR 端口占用:宿主机 {host_port} 已被 {busy} 占用"
f"(Bind for 0.0.0.0:{host_port} failed: port is already allocated)")
self.containers[name] = {"image": image, "state": "running",
"ports": {host_port: cport}, "auto_rm": auto_rm}
note = "(--rm:退出即删)" if auto_rm else ""
return f"run {name} → running,-p {host_port}:{cport}{note}"
def finish(self, name: str) -> str:
"""模拟一次性容器跑完退出。"""
c = self.containers.get(name)
if not c:
return f"ERR 无此容器:{name}"
if c["auto_rm"]:
c["state"] = "removed"
return f"{name} 执行完 → 自动删除(ps -a 里找不到)"
c["state"] = "exited"
return f"{name} 执行完 → exited"
def exec_(self, name: str) -> str:
c = self.containers.get(name)
if not c:
return f"ERR 无此容器:{name}"
if c["state"] != "running":
return f"ERR exec 要求运行中:{name} 当前 {c['state']}"
return f"exec -it {name} → 进入容器 shell"
def logs(self, name: str) -> str:
c = self.containers.get(name)
if not c:
return f"ERR 无此容器:{name}"
return f"logs {name} → 可读({c['state']} 状态也能看历史日志)"
def stop(self, name: str) -> str:
c = self.containers.get(name)
if not c:
return f"ERR 无此容器:{name}"
if c["state"] != "running":
return f"ERR stop 要求运行中:{name} 当前 {c['state']}"
c["state"] = "exited"
return f"stop {name} → exited(端口释放)"
def start(self, name: str) -> str:
c = self.containers.get(name)
if not c:
return f"ERR 无此容器:{name}"
if c["state"] != "exited":
return f"ERR start 要求已停止:{name} 当前 {c['state']}"
c["state"] = "running"
return f"start {name} → running(配置与数据原样恢复)"
def rm(self, name: str, force: bool = False) -> str:
c = self.containers.get(name)
if not c:
return f"ERR 无此容器:{name}"
if c["state"] == "running" and not force:
return f"ERR 容器运行中,拒绝删除:{name}(用 -f,或先 stop)"
c["state"] = "removed"
return f"rm {name} → removed"
def rmi(self, image: str) -> str:
users = [n for n, c in self.containers.items()
if c["image"] == image and c["state"] != "removed"]
if users:
return (f"ERR 镜像被引用:{image} 仍被 {users} 依赖"
f"(image is being used by stopped container)")
if image not in self.images:
return f"ERR 本地无此镜像:{image}"
self.images.discard(image)
return f"rmi {image} → 已删除"
def main() -> None:
e = Engine()
steps = [
("1 拉镜像", lambda: e.pull("nginx:alpine")),
("2 起 web1:8080", lambda: e.run("web1", "nginx:alpine", 8080, 80)),
("3 起 web2:8081", lambda: e.run("web2", "nginx:alpine", 8081, 80)),
("4 再抢 8080", lambda: e.run("web3", "nginx:alpine", 8080, 80)),
("5 exec 进 web1", lambda: e.exec_("web1")),
("6 stop web1", lambda: e.stop("web1")),
("7 停着再 exec", lambda: e.exec_("web1")),
("8 停着看日志", lambda: e.logs("web1")),
("9 start web1", lambda: e.start("web1")),
("10 运行中裸 rm", lambda: e.rm("web1")),
("11 stop + rm", lambda: (e.stop("web1"), e.rm("web1"))[1]),
("12 web2 还在时 rmi", lambda: e.rmi("nginx:alpine")),
("13 强删 web2", lambda: e.rm("web2", force=True)),
("14 再 rmi", lambda: e.rmi("nginx:alpine")),
("15 拉 python 镜像", lambda: e.pull("python:3.12-slim")),
("16 一次性容器", lambda: e.run("calc", "python:3.12-slim", 9000, 8000, auto_rm=True)),
("17 它跑完即消失", lambda: e.finish("calc")),
]
print(f"{'步骤':<22}结果")
errs = 0
for label, fn in steps:
out = fn()
if out.startswith("ERR"):
errs += 1
print(f"{label:<22}{out}")
print(f"\nps(运行中): {e.ps() or '(空)'}")
print(f"ps -a(全部): {e.ps(all_=True) or '(空)'}")
print(f"\n{len(steps)} 步里被拒绝 {errs} 步")
print("★ 被拒的 4 步,正好对应新手最常撞的 4 类错误:")
print(" ① 端口占用(第 4 步) ② exec 到已停止容器(第 7 步)")
print(" ③ 裸 rm 运行中的容器(第 10 步) ④ rmi 删被引用的镜像(第 12 步)")
print(" 还有一类没被拒:漏了 pull 就直接 run —— 现代 Docker 会自动帮你拉(第 15 步先拉是习惯)。")
if __name__ == "__main__":
main()
5.3 【实跑】输出
步骤 结果
1 拉镜像 pull nginx:alpine → 本地镜像 1 个
2 起 web1:8080 run web1 → running,-p 8080:80
3 起 web2:8081 run web2 → running,-p 8081:80
4 再抢 8080 ERR 端口占用:宿主机 8080 已被 web1 占用(Bind for 0.0.0.0:8080 failed: port is already allocated)
5 exec 进 web1 exec -it web1 → 进入容器 shell
6 stop web1 stop web1 → exited(端口释放)
7 停着再 exec ERR exec 要求运行中:web1 当前 exited
8 停着看日志 logs web1 → 可读(exited 状态也能看历史日志)
9 start web1 start web1 → running(配置与数据原样恢复)
10 运行中裸 rm ERR 容器运行中,拒绝删除:web1(用 -f,或先 stop)
11 stop + rm rm web1 → removed
12 web2 还在时 rmi ERR 镜像被引用:nginx:alpine 仍被 ['web2'] 依赖(image is being used by stopped container)
13 强删 web2 rm web2 → removed
14 再 rmi rmi nginx:alpine → 已删除
15 拉 python 镜像 pull python:3.12-slim → 本地镜像 1 个
16 一次性容器 run calc → running,-p 9000:8000(--rm:退出即删)
17 它跑完即消失 calc 执行完 → 自动删除(ps -a 里找不到)
ps(运行中): (空)
ps -a(全部): (空)
17 步里被拒绝 4 步
★ 被拒的 4 步,正好对应新手最常撞的 4 类错误:
① 端口占用(第 4 步) ② exec 到已停止容器(第 7 步)
③ 裸 rm 运行中的容器(第 10 步) ④ rmi 删被引用的镜像(第 12 步)
还有一类没被拒:漏了 pull 就直接 run —— 现代 Docker 会自动帮你拉(第 15 步先拉是习惯)。
5.4 逐条读这 17 步
| 步 | 学到什么 |
|---|---|
| 2-3 | 一个镜像可以起多个容器,靠 |
| 4 | 端口是宿主机的独占资源;容器内 80 可以有一万个 |
| 5 |
|
| 6-7 | 停止后不能 |
| 8 | 但 |
| 9 |
|
| 10 |
|
| 12 | 镜像被任何容器(含已停止)引用就删不掉,这是 |
| 16-17 |
|
5.5 真实命令的常用组合
# 起一个后台 Nginx,映射 8080,挂了自动重启
docker run -d --name web \
--restart unless-stopped \
-p 8080:80 \
nginx:alpine
# 看状态(注意 STATUS 里的 Up 3 minutes / Exited (0) 2 minutes ago)
docker ps
docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
# 实时日志(Ctrl+C 只退出查看,不停容器)
docker logs -f --tail 50 web
# 进容器(alpine 没有 bash,用 sh)
docker exec -it web sh
# 只取容器 IP / 只看挂了哪些卷
docker inspect --format '{{.NetworkSettings.IPAddress}}' web
docker inspect --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' web
# 资源占用(相当于容器版 top)
docker stats --no-stream
5.6 退出码:137 和 143 是什么意思
| 退出码 | 含义 | 怎么产生 | 该做什么 |
|---|---|---|---|
| 0 | 正常退出 | 进程自己结束 | 一次性任务正常 |
| 1 | 应用报错 | 代码异常 | 看 |
| 137 | 128+9,被 SIGKILL | OOM 被杀、 | 查 |
| 143 | 128+15,被 SIGTERM |
| 正常停止,不用管 |
| 126/127 | 命令不可执行/找不到 | ENTRYPOINT 写错 | 查 Dockerfile |
★ 客户现场看到 137,先别当"程序崩了":它要么是被 OOM 杀的,要么是 stop 超时被强杀的。
第一步总是查 OOMKilled,为 false 就去看应用有没有处理 SIGTERM。
docker inspect --format '{{.State.OOMKilled}}' <容器>
🖥️ 六、端口映射:一个冒号决定能不能访问
6.1 方向
docker run -p 8080:80 nginx
8080 : 80
▲ ▲
宿主机端口 容器内端口
(你敲浏览器用的) (程序真正监听的)
★ 左边是门牌号(你定),右边是屋里人站的位置(镜像定,不能瞎写)。
6.2 实跑:port_map.py
# port_map.py —— 端口映射校验:方向、监听地址、占用、容器内端口对不对
from __future__ import annotations
# 镜像默认监听端口(写错容器端口是新手第一大坑)
APP_PORT = {
"nginx": 80,
"httpd": 80,
"fastapi": 8000,
"uvicorn": 8000,
"mysql": 3306,
"postgres": 5432,
"redis": 6379,
}
SPECS = [
("web1", "nginx", "8080:80"),
("web2", "nginx", "8081:80"),
("api-bad", "fastapi", "8000:80"), # 容器端口写错
("api-ok", "fastapi", "8000:8000"),
("db", "mysql", "3306:3306"),
("lan-only", "nginx", "127.0.0.1:8082:80"),
("typo", "nginx", "8080:80:80"), # 三段,非法
("noport", "nginx", ""), # 不映射,只有容器网络内可达
]
def parse(spec: str) -> dict:
if not spec:
return {"ok": True, "host": None, "cport": None, "bind": None, "raw": spec}
parts = spec.split(":")
if len(parts) == 3:
bind, host, cport = parts
if bind.isdigit():
return {"ok": False, "err": "三段格式的第一段必须是 IP,你这是把宿主机端口写了两遍"}
if not host.isdigit() or not cport.isdigit():
return {"ok": False, "err": "三段格式里主机端口/容器端口必须是数字"}
return {"ok": True, "host": int(host), "cport": int(cport), "bind": bind, "raw": spec}
if len(parts) == 2:
host, cport = parts
if not host.isdigit() or not cport.isdigit():
return {"ok": False, "err": "端口必须是数字(-p 宿主:容器)"}
return {"ok": True, "host": int(host), "cport": int(cport), "bind": "0.0.0.0", "raw": spec}
return {"ok": False, "err": "段数不对:应为 宿主:容器 或 IP:宿主:容器"}
def main() -> None:
print(f"{'容器':<9}{'镜像':<9}{'-p':<20}{'判定'}")
used: dict[tuple[str, int], str] = {}
rows = []
for name, image, spec in SPECS:
p = parse(spec)
if not p["ok"]:
verdict = f"❌ 语法错:{p['err']}"
elif p["host"] is None:
verdict = "⚠️ 未映射:只能被同网络容器访问"
else:
want = APP_PORT.get(image)
key = (p["bind"], p["host"])
if want and p["cport"] != want:
verdict = f"❌ 方向/端口错:{image} 监听 {want},你写成 {p['cport']}"
elif key in used:
verdict = f"❌ 端口冲突:{p['bind']}:{p['host']} 已被 {used[key]} 占用"
else:
exposure = "仅本机" if p["bind"] == "127.0.0.1" else "全网可达(0.0.0.0)"
verdict = f"✅ {p['host']} → {p['cport']}|{exposure}"
used[key] = name
rows.append((name, image, spec or "(无)", verdict))
print(f"{name:<9}{image:<9}{spec or '(无)':<20}{verdict}")
bad = [r for r in rows if r[3].startswith("❌")]
warn = [r for r in rows if r[3].startswith("⚠️")]
print(f"\n{len(rows)} 条映射:可用 {len(rows) - len(bad) - len(warn)}|"
f"错误 {len(bad)}|需注意 {len(warn)}")
print("\n判据(三条,缺一条就可能连不上或被扫):")
print(" ① 容器端口 = 镜像里程序真正监听的端口(nginx 80 / uvicorn 8000 / mysql 3306)")
print(" ② 宿主机端口不重复,且 >1024(1024 以下要 root/管理员)")
print(" ③ 只给本机用的别写 0.0.0.0 —— 客户的数据库端口暴露到公网是重大事故")
print("\n★ 记住顺序:-p 宿主机:容器。左边是你的门牌,右边是屋里的人站的位置。")
if __name__ == "__main__":
main()
6.3 【实跑】输出
容器 镜像 -p 判定
web1 nginx 8080:80 ✅ 8080 → 80|全网可达(0.0.0.0)
web2 nginx 8081:80 ✅ 8081 → 80|全网可达(0.0.0.0)
api-bad fastapi 8000:80 ❌ 方向/端口错:fastapi 监听 8000,你写成 80
api-ok fastapi 8000:8000 ✅ 8000 → 8000|全网可达(0.0.0.0)
db mysql 3306:3306 ✅ 3306 → 3306|全网可达(0.0.0.0)
lan-only nginx 127.0.0.1:8082:80 ✅ 8082 → 80|仅本机
typo nginx 8080:80:80 ❌ 语法错:三段格式的第一段必须是 IP,你这是把宿主机端口写了两遍
noport nginx (无) ⚠️ 未映射:只能被同网络容器访问
8 条映射:可用 5|错误 2|需注意 1
判据(三条,缺一条就可能连不上或被扫):
① 容器端口 = 镜像里程序真正监听的端口(nginx 80 / uvicorn 8000 / mysql 3306)
② 宿主机端口不重复,且 >1024(1024 以下要 root/管理员)
③ 只给本机用的别写 0.0.0.0 —— 客户的数据库端口暴露到公网是重大事故
★ 记住顺序:-p 宿主机:容器。左边是你的门牌,右边是屋里的人站的位置。
6.4 三个判据怎么用
判据 ①:容器端口必须对上程序监听端口
nginx → 80
uvicorn → 8000(默认;你要它监听 80 就得在代码里改 --port)
mysql → 3306
postgres → 5432
redis → 6379
★ 你写 -p 8000:80 起 fastapi,浏览器会得到"连接被重置"——
因为容器里根本没有进程监听 80。
这个错误排查起来很费时间:容器是 Up 的,端口也映射了,就是不响应。
判据 ②:宿主机端口不重复、不要用 1024 以下
# 查谁占了 8080(Windows)
netstat -ano | findstr :8080
# 查谁占了 8080(Linux / WSL)
ss -lntp | grep 8080
# 看是不是 Docker 自己占的
docker ps --format 'table {{.Names}}\t{{.Ports}}'
判据 ③:绑定地址决定"谁能访问"
| 写法 | 谁能访问 | 什么时候用 |
|---|---|---|
|
| 同网段任何机器(0.0.0.0) | 要给同事/客户看 |
|
| 只有本机 | 自己调试、管理后台 |
| 不写 | 只有同网络的容器 | 内部服务(数据库、缓存) |
★ 客户环境最危险的一种写法:
docker run -p 3306:3306 mysql
这等于把数据库端口开在 0.0.0.0,公网可扫。
正确做法:不映射,或只映射 127.0.0.1,让应用走容器网络直连。
6.5 四种网络模式
| 模式 | 参数 | 特点 | 用途 |
|---|---|---|---|
| bridge | 默认 | 每个容器一个虚拟网卡,NAT 出网 | 绝大多数场景 |
| host |
| 直接用宿主网络栈,无 NAT | 性能敏感、要拿真实客户端 IP |
| none |
| 只有 lo | 离线计算、安全隔离 |
| container |
| 共用另一个容器的网络 | 边车(sidecar)模式 |
# 自建网络,让容器用名字互相访问(明天 Compose 会自动做这件事)
docker network create app-net
docker run -d --name db --network app-net mysql:8.0
docker run -d --name api --network app-net -p 8000:8000 myapi
# api 容器里可以直接连 db:3306 —— 名字就是域名
💡 这条是明天 Compose 的地基:容器之间不要用 IP 互连(IP 会变),要用服务名。
🖥️ 七、数据卷:容器可以死,数据不能死
7.1 为什么必须挂卷
容器的可写层,随容器删除一起消失。
docker exec web sh -c "echo hi > /usr/share/nginx/html/a.txt"
docker rm -f web
docker run -d --name web -p 8080:80 nginx:alpine
# a.txt 没了 —— 新容器用的是镜像的初始层
对无状态服务(Nginx 静态页)无所谓,对数据库是灾难。
7.2 三种挂载
挂载就是把容器外的存储接进容器里,让数据活过容器生命周期,或者让宿主机和容器共享文件。
1)绑定挂载(bind mount)
是什么:把宿主机上的一个具体路径直接映射到容器内路径。两边看到的是同一份文件。
bash
docker run -v /host/path:/container/path nginx docker run --mount type=bind,src=/host/path,dst=/container/path nginx
数据在哪:宿主机你指定的那个目录,位置由你决定。
谁管理:你。Docker 不插手,不创建、不备份、不清理。
生命周期:跟宿主机文件系统走。容器删了,数据还在宿主机原位置。
适用场景:开发时把源码挂进容器热更新、挂配置文件、挂宿主机上已有的数据目录(比如 /var/log、数据库数据目录)。
坑:
-
源路径必须已存在(老版本
-v会自动创建目录,--mount不会,行为不一致,容易踩)。 -
权限问题多——容器内用户 UID 和宿主机 UID 对不上,写文件会 permission denied。
-
可移植性差,路径写死在宿主机上,换机器就得改。
-
容器能改宿主机文件,安全边界弱。
2)命名卷(volume)
是什么:由 Docker 管理的存储,放在 Docker 自己的数据目录下(Linux 默认 /var/lib/docker/volumes/),你只给一个名字。
bash
docker volume create mydata docker run -v mydata:/container/path nginx docker run --mount type=volume,src=mydata,dst=/container/path nginx
数据在哪:Docker 管理的目录,位置由 Docker 决定,你不直接操作路径。
谁管理:Docker。创建、删除、备份都通过 docker volume 命令。
生命周期:独立于容器。容器删了卷还在,要 docker volume rm 才删。多个容器可以共享同一个卷。
适用场景:生产环境持久化数据(数据库、上传文件)、容器间共享数据、需要跨容器生命周期保留的数据。这是生产环境推荐的方式。
优点:
-
跨平台一致,不依赖宿主机路径。
-
权限问题少(Docker 管理,初始化时可以把镜像里目标目录的内容和权限带过来)。
-
可以用
docker volume ls/inspect/prune统一管理。 -
支持 volume driver,能挂 NFS、云存储等。
坑:
-
数据在 Docker 管理目录下,不直观,排查时要
docker volume inspect找 Mountpoint。 -
备份不如 bind 直接(要起个临时容器挂载后打包)。
-
匿名卷(不指定名字)容易积累垃圾,
docker volume prune要定期跑。
3)tmpfs 挂载
是什么:把数据放在宿主机内存里,不落盘。容器内看到的是一个内存文件系统。
bash
docker run --tmpfs /container/path nginx docker run --mount type=tmpfs,dst=/container/path,tmpfs-size=100m nginx
数据在哪:宿主机内存(RAM),容器删除后数据立即消失。
谁管理:内核的 tmpfs,Docker 只负责挂载。
生命周期:跟容器同生共死。容器停或删,数据就没了。
适用场景:敏感临时数据(密钥、token,不想落盘)、高频读写的临时缓存、需要极快 IO 且不需要持久化的场景。
坑:
-
占内存,设
tmpfs-size限制大小,否则可能吃光内存。 -
不持久,容器重启数据就丢,别放需要保留的东西。
-
只在 Linux 上原生支持,Windows/Mac 的 Docker Desktop 走虚拟机,行为有差异。
三者对比速查:
| 类型 | 写法 | 数据在哪 | 谁管 | 适合 |
|---|---|---|---|---|
| 绑定挂载 bind |
| 宿主机指定目录 | 你 | 开发挂代码、挂配置 |
| 命名卷 volume |
| Docker 管理的区域 | Docker | 数据库、生产数据 |
| tmpfs |
| 内存 | 内核 | 临时文件、敏感数据不落盘 |
📌 FDE 铁律:数据库、上传目录、日志归档 —— 一律命名卷。 绑定挂载留给"我要随时改"的配置和代码。
7.4 备份与恢复
起一个一次性容器(alpine),同时挂两个东西:volume dbdata 挂到 /data,当前目录挂到 /backup。然后在这个容器里用 tar 把 /data 打包到 /backup,或者反过来解包。
为什么要这么绕?因为 volume 的数据在 Docker 管理目录下(/var/lib/docker/volumes/...),宿主机上没有稳定的直接路径可访问。你不该、也没法可靠地直接 tar 那个目录。正确做法就是借一个容器把 volume 和宿主机目录同时挂进来,让容器内的 tar 完成桥接。这也是"不依赖宿主机路径"的含义——命令只认 volume 名字,不认数据实际存在哪。
# 备份:从容器里导出(不依赖宿主机路径)
docker run --rm -v dbdata:/data -v ${PWD}:/backup alpine \
tar czf /backup/db-$(date +%F).tar.gz -C /data .
# 恢复
docker run --rm -v dbdata:/data -v ${PWD}:/backup alpine \
tar xzf /backup/db-2026-10-23.tar.gz -C /data
★ "备份"和"能恢复"是两件事。
交付前必须在客户面前做一次恢复演练,否则那不叫备份,叫心理安慰。
📌说明:
--rm , 临时容器用完即删,不留垃圾。
-C /data . 打包的是目录内容,不是目录本身
📖 八、镜像仓库与标签纪律
8.1 三条命令
docker pull nginx:alpine
从默认 registry(Docker Hub)拉取 nginx 镜像的 alpine tag。拉取的本质是下载层(layer),本地已有的层会跳过,所以第二次拉同一个镜像很快。nginx:alpine 是官方维护的精简版,基于 Alpine Linux,体积比 nginx:latest 小很多,适合对镜像大小敏感的场景。
docker tag myapi:dev registry.local/myapi:1.4.0
给同一个镜像 ID 再挂一个名字。关键理解:tag 不是复制镜像,是给同一个 image ID 增加一个引用标签。执行后 myapi:dev 和 myapi:1.4.0 指向完全相同的层,磁盘上只有一份数据。这就是“同一镜像多个名字”的含义,也是为什么打 tag 几乎瞬间完成、不占额外空间。tag 是可变引用,不是版本锁定。 myapi:1.4.0 这个 tag 今天指向镜像 A,明天你可以 docker tag 把它指向镜像 B,registry 上同名 tag 会被覆盖。如果要有不可变的版本保证,应该用 digest(registry.local/myapi@sha256:...)来引用,或者配 registry 的 tag 不可变策略。生产环境拉镜像建议用 digest 而不是浮动 tag。
docker login registry.local
登录私有 registry。凭据默认存在 ~/.docker/config.json(Linux 上可能是 base64 编码的 auth 字段,或者走 credential helper)。登录成功后,后续 push/pull 到这个 registry 就不用再输密码。
docker push registry.local/myapi:1.4.0
把镜像推到私有 registry。push 的规则是按 tag 推,不是按 image ID 推——你推的是 registry.local/myapi:1.4.0 这个完整名字。push 时也只传 registry 上不存在的层,所以重复推同一镜像很快。docker push 默认指向 Docker Hub,要推私有 registry,必须先 tag 成 registry.local/myapi:1.4.0。
8.2 标签怎么打
各种tag的优缺点:
❌ myapi:latest 不知道是哪个版本
⚠️ myapi:v1 能区分,但不知对应哪次提交
✅ myapi:1.4.0-20261023 版本 + 日期
✅ myapi@sha256:... 绝对可复现(生产回滚用这个)
8.3 客户内网怎么交付(离线场景)
客户机房不能出网,三条路:
① 导出/导入(最简单,适合单次交付)
docker save myapi:1.4.0 | gzip > myapi-1.4.0.tar.gz
# 拷到客户机器
docker load < myapi-1.4.0.tar.gz
说明:
docker save保存的是镜像,不是容器,也不带 tag 之外的元数据语义。 保存时 tag 会被写进 tar(manifest 里有 RepoTags),docker load之后 tag 自动恢复。但如果你save的时候镜像只有<none>(悬空),load 出来也是<none>,所以交付前先确认myapi:1.4.0这个 tag 在本地是正常的。
save支持多镜像一次打包,但体积会叠加。 如果要交付多个相关镜像(比如 app + 依赖的 sidecar),可以docker save -o bundle.tar myapi:1.4.0 redis:7-alpine,load 时一次全进来。比一个个 tar 方便,但包更大。
docker load不校验完整性。 传输过程中 tar 损坏,load 可能报错也可能静默出一半——这就是为什么你最后那条“必须带 sha256sum”是对的,而且是在传输前后都校验:源机器算一次,客户机器 load 前算一次,对上了再 load。
② 内网 registry(适合长期)
docker run -d -p 5000:5000 --name registry registry:2
docker tag myapi:1.4.0 10.0.0.5:5000/myapi:1.4.0
docker push 10.0.0.5:5000/myapi:1.4.0
registry:2默认无认证、无 TLS、数据存在容器可写层。 直接docker run -d -p 5000:5000 registry:2起来的 registry:
任何人能 push/pull,内网也不该这么裸奔;
容器删了镜像全没,必须挂 volume:
-v /data/registry:/var/lib/registry;默认走 HTTP,客户端要配
insecure-registries才能 push(10.0.0.5:5000这种 IP:port 默认被当成 insecure,但 daemon 不配还是会拒)。
docker tag那行的前缀必须和 push 的地址完全一致。10.0.0.5:5000/myapi:1.4.0这个完整名就是 registry 里的路径,tag 时就该写成这样,而不是先 tag 成myapi:1.4.0再指望 push 时自动加前缀——不会的,push 推的是你给的名字。
③ 私有云镜像服务(客户已有阿里云/华为云 ACR/SWR)
走客户的账号,注意权限与网络策略
★ 交付包里必须带:镜像 tar + 版本清单 + 校验和(sha256sum)。
没有校验和,你无法证明"客户机器上跑的就是我交付的那个"。
📖 九、安全基线:8 条,交付前必须过
| # | 做法 | 防什么 |
|---|---|---|
| 1 | 容器内用非 root 用户( | 逃逸后拿 root |
| 2 | 不加 | 等于给了宿主全部权限 |
| 3 |
| 减少内核攻击面 |
| 4 |
| 被写入 webshell |
| 5 |
| 提权到 setuid 程序 |
| 6 | 设 | 一个容器拖垮整机 |
| 7 | 日志轮转 | 日志写满磁盘 |
| 8 | 不挂 | 等于把宿主机 root 交给容器 |
# 一个"还算像样"的生产启动命令
docker run -d --name api \
--restart unless-stopped \
--user 1000:1000 \
--read-only --tmpfs /tmp \
--cap-drop=ALL \
--security-opt no-new-privileges \
-m 512m --cpus 1.0 --pids-limit 200 \
--log-opt max-size=10m --log-opt max-file=3 \
-p 127.0.0.1:8000:8000 \
-e APP_ENV=prod \
myapi:1.4.0
⚠️ 第 8 条单独说:很多"图方便"的教程会让你挂
-v /var/run/docker.sock:/var/run/docker.sock,让容器里能操作 Docker。 这等于把宿主机 root 权限给了容器内任意进程。 客户安全评审看到这条,基本一票否决。
📖 十、故障 12 例:按症状查
| # | 症状 | 最可能原因 | 排查命令 |
|---|---|---|---|
| 1 |
| CLI 没装 / PATH 没生效 |
|
| 2 |
| 引擎没起 |
|
| 3 |
| Desktop 未启动/无权限 | 启动 Desktop(管理员) |
| 4 |
| 宿主机端口被占 |
|
| 5 | 容器 Up 但浏览器连不上 | 容器端口写错 / 只绑了 127.0.0.1 |
|
| 6 | 容器起来就 Exited | 启动命令错 / 缺环境变量 |
|
| 7 | 退出码 137 | OOM 被杀 |
|
| 8 |
| 应用没处理 SIGTERM,被强杀 | 注册信号处理; |
| 9 |
| 有容器引用该镜像 |
|
| 10 |
| 卷权限/uid 不匹配 | 改命名卷或 |
| 11 | 拉镜像超时 | 网络/镜像源 | 配 registry-mirrors 或离线导入 |
| 12 | 磁盘莫名满了 | 日志/悬空镜像堆积 |
|
# 一条命令看全局占用,排查磁盘问题先跑它
docker system df
# TYPE TOTAL ACTIVE SIZE RECLAIMABLE
# Images 12 3 2.1GB 1.4GB (66%)
# Containers 8 2 120MB 118MB (98%)
# Local Volumes 4 1 800MB 600MB
# Build Cache 35 0 1.2GB 1.2GB
📊 十一、速查表
11.1 镜像 vs 容器
| 操作 | 镜像 | 容器 |
|---|---|---|
| 列表 |
|
|
| 获取 |
|
|
| 删除 |
|
|
| 清理 |
|
|
11.2 docker run 常用参数
| 参数 | 含义 | 备注 |
|---|---|---|
|
| 后台运行 | 不加就占着终端 |
|
| 命名 | 不写会随机给个怪名字 |
|
| 端口映射 | 左边你定,右边镜像定 |
|
| 挂载 | 加 |
|
| 环境变量 | 密码别写命令行,用 |
|
| 交互 + 终端 | 调试用 |
|
| 退出即删 | 一次性任务 |
|
| 重启策略 | 常驻服务用 |
|
| 资源限制 | 生产必设 |
|
| 网络 | 同网络可用名字互访 |
|
| 运行用户 | 安全基线第 1 条 |
11.3 五条命令覆盖 80% 排障
docker ps -a # 它活着吗、退出码多少
docker logs --tail 100 <名> # 它说了什么
docker inspect <名> # 它到底怎么配的
docker exec -it <名> sh # 进去看看
docker stats --no-stream # 它吃了多少资源
💬 十二、客户会怎么问(8 问 8 答)
Q1:容器和虚拟机有什么区别?我们已经有 VMware 了。
虚拟机各自带一整套操作系统,容器共享宿主内核。同样一台机器,VM 能跑十几个,容器能跑几十上百个,启动从分钟降到秒。 但容器隔离弱于 VM,所以多租户强隔离场景仍用 VM。
Q2:容器安全吗?会不会影响我们现有系统?
默认桥接网络 + 不挂宿主目录 + 不加
--privileged,风险可控。我们按 8 条基线交付(非 root、只读根、cap-drop、资源限制、日志轮转、不挂 docker.sock),这 8 条可以给你们安全评审逐条核。
Q3:数据放容器里,容器删了不就没了?
对,所以数据一律放卷。数据库用命名卷,配置用只读绑定挂载。容器本身当"牲口",随时可重建;数据活在容器外面。
Q4:你们交付的镜像,我们怎么知道没被改过?
交付包含
sha256sum清单,镜像钉 digest。你们docker load后跑一次校验,哈希一致就证明是同一个镜像。
Q5:能不能不用 Docker,直接装?
能,但要在你们的生产机上装 Python 3.12、改 glibc、升级 openssl —— 这等于动生产环境。容器是"不动你们机器"的方案。
Q6:为什么镜像这么大?一个多 G。
因为里面带了完整运行时。基础层只存一份,10 个服务共享。真要瘦,可以用 slim/alpine 基础镜像 + 多阶段构建,通常能压到 1/5。
Q7:容器挂了怎么办?
--restart unless-stopped会自动拉起;再配合健康检查和告警。但自动重启只解决"进程挂了",不解决"为什么挂"——那要靠日志和指标。
Q8:我们要不要自己维护一个镜像仓库?
看规模。单次交付用
docker save加 tar 包最省事;长期多项目建议内网 registry,配合版本清单和保留策略。
✅ 十三、今日交付物与自检
| # | 交付物 | 验收标准 |
|---|---|---|
| 1 |
| 跑出 106 倍,输出与文中一致 |
| 2 |
| 17 步、4 步被拒,输出一致 |
| 3 |
| 8 条映射:5 可用 / 2 错 / 1 注意 |
| 4 |
| 8 条安全基线,每条一句为什么 |
| 5 |
| 12 例,按症状能查到 |
| 6 | 一份镜像清单 | 名字 + tag + digest + sha256 |
□ 1. 能说出环境差异的 5 个维度
□ 2. 能解释 namespace / cgroups / unionfs 各管什么
□ 3. docker --version 和 docker info 都通过(不是只过第一关)
□ 4. hello-world 跑通
□ 5. 能画出容器状态图(run/stop/start/rm 四态)
□ 6. 说得出 exec 为什么不能用在已停止容器上
□ 7. -p 左右两边各是谁定的,说不错
□ 8. 知道 0.0.0.0 和 127.0.0.1 的安全差别
□ 9. 数据库为什么必须用命名卷
□ 10. 看到 137 知道先查 OOMKilled
📝 十四、本课小结
| # | 知识点 | 一句话 |
|---|---|---|
| 1 | 解决什么 | 代码 + 依赖 + 系统库一起打包,不动客户机器 |
| 2 | 五维差异 | 内核 / glibc / 解释器 / 系统库 / 路径账号 |
| 3 | 三个机制 | namespace 看不见、cgroups 用不多、unionfs 看着全 |
| 4 | 不是小 VM | 共享内核 → 更轻,也更需要安全基线 |
| 5 | 镜像分层 | 一层只读快照,父层变了后代全失效 |
| 6 | 缓存代价 | 同一改动 42.4s vs 0.4s,差 106 倍 |
| 7 | 生命周期 | run/stop/start/rm 四态,exec 只对 running |
| 8 | 端口 |
|
| 9 | 绑定地址 | 0.0.0.0 全网可达,127.0.0.1 只本机 |
| 10 | 数据 | 容器可死,数据挂卷;数据库用命名卷 |
| 11 | 安全 | 8 条基线,第 8 条(不挂 docker.sock)最要紧 |
| 12 | 排障 | ps -a / logs / inspect / exec / stats 五连 |
🧠 核心认知:三个分离。
① 镜像与容器分离 —— 一个镜像跑任意多个容器,容器是一次性的
② 容器与数据分离 —— 容器随便删,数据必须在卷里
③ 构建与运行分离 —— 构建时定死的东西,运行时改不了(所以顺序很重要)
★ 容器是"牲口"不是"宠物":病了不要治,杀掉重建一个。
📋 十五、课后练习
练习 1:把三个脚本跑一遍,并改一个参数(约 30 分钟)
-
跑
layer_cache_sim.py,确认 106 倍 -
把
BAD里的COPY . .改成只拷app/main.py,看倍数是变大还是变小,解释为什么 -
跑
container_lifecycle.py,把第 4 步的端口改成 8082,看拒绝数变成几 -
跑
port_map.py,加一条("cache","redis","6380:6379"),看判定
练习 2:Nginx 五连操作(约 25 分钟)
-
后台起 Nginx,映射 8088,命名
lab-nginx,加--restart unless-stopped -
浏览器确认欢迎页
-
exec进去,cat /usr/share/nginx/html/index.html -
logs -f看访问日志,刷新三次浏览器,观察新增行 -
stop → start → rm,每一步用
docker ps -a确认状态
验收:能不看笔记说出第 5 步每一步的状态变化。
练习 3:数据卷持久化(约 20 分钟)
-
在
~/docker-data/site放自己的index.html -
-v ...:...:ro挂载启动,确认显示你的页面 -
rm -f强删,确认宿主机文件还在 -
用命名卷再做一个:
docker volume create webdata,挂进去,改内容,删容器重建,确认数据还在 -
回答:为什么 Nginx 可以不挂卷,而 MySQL 必须挂?(提示:谁有状态)
练习 4:把上周的 FastAPI 用容器跑起来(约 40 分钟,选做)
不用写 Dockerfile,先用现成镜像验证思路:
docker run -d --name api \
-v ${PWD}:/app -w /app \
-p 8000:8000 \
python:3.12-slim \
sh -c "pip install -r requirements.txt && uvicorn main:app --host 0.0.0.0 --port 8000"
验收:浏览器能打开 /docs。然后回答:这个跑法有什么问题?(至少说出两条:每次启动都重装依赖、镜像里没有代码只能靠挂载)——这正是明天 Dockerfile 要解决的。
🔭 十六、下节预告
今天你跑的都是别人的镜像,相当于"住精装房"。
明天要"自己盖房子":写 Dockerfile,把 FastAPI 项目连同 Python 和依赖打成专属镜像。会用今天 §三 的层缓存结论,把构建从 42 秒压到 0.4 秒;也会讲 .dockerignore、多阶段构建、USER 非 root、以及为什么 CMD 要用 exec 形式(对应今天 §5.6 里 137 这个退出码)。
🌍附录:前置课程列表
阶段一:认知启蒙(AI 认知与 FDE 角色)
AI 认知
【FDE系列】阶段1Day 1:AI 层级关系 — 四个嵌套的圈-CSDN博客
【FDE系列】阶段1Day 2:AI 三阶段发展史 — 会认 → 会判断 → 会创造-CSDN博客
【FDE系列】阶段1Day 3:符号 AI vs 机器学习 — 两条路线的本质区别-CSDN博客
【FDE系列】阶段1Day 4:Transformer 的历史意义 — 2017 年的分水岭-CSDN博客
【FDE系列】阶段1Day 5:本周复习与自测 — 检验你的 AI 认知地基-CSDN博客
【FDE系列】阶段1Day 6:Transformer 架构 — 一张图纸盖出千千万万栋楼-CSDN博客
【FDE系列】阶段1Day 7:LLM 本质 — 文字接龙机器-CSDN博客
【FDE系列】阶段1Day 8:Token — 模型眼中的最小单位-CSDN博客
【FDE系列】阶段1Day 9:AI 幻觉 — 为什么会一本正经地胡说八道-CSDN博客
【FDE系列】阶段1Day 10:上下文窗口 — 模型的记忆力上限 + 本周复习-CSDN博客
【FDE系列】阶段1Day 11:Prompt — 给模型立规矩-CSDN博客
【FDE系列】阶段1Day 12:Memory — 让模型记住上下文
【FDE系列】阶段1Day 13:RAG — 给模型配图书管理员-CSDN博客
【FDE系列】阶段1Day 14:Tool Use — 让模型动手操作-CSDN博客
【FDE系列】阶段1Day 15:MCP — 统一的工具接口标准 + 第三周复习-CSDN博客
FDE 基础概念
【FDE系列】阶段1Day 16:什么是 FDE — 把 AI 变成客户结果的人-CSDN博客
【FDE系列】阶段1Day 17:FDE vs 传统实施 — 三大本质区别-CSDN博客
【FDE系列】阶段1Day 18:FDE 三重身份 + C6 胜任力模型-CSDN博客
【FDE系列】阶段1Day 19:七阶段行动路径 + 行业经验的价值-CSDN博客
【FDE系列】阶段1Day 20:阶段总结与产出物 — 第一阶段收官-CSDN博客
阶段二:技术地基(Python + FastAPI + SQL + Docker + API 集成)
Python基础
【FDE系列】阶段2:Day 21:Python 环境搭建 — 写出你的第一行代码-CSDN博客
【FDE系列】阶段2:Day 22:变量、数据类型、条件判断 — Python 的“记忆“和“判断“-CSDN博客
【FDE系列】阶段2:Day 23:循环与函数 — 让代码跑 100 遍、把逻辑打包复用-CSDN博客
【FDE系列】阶段2:Day 24:数据结构 — 列表、字典、集合、元组-CSDN博客
【FDE系列】阶段2:Day 25:文件读写与 JSON — 让程序连通外部数据(第一周收官)-CSDN博客
【FDE系列】阶段2:Day 26:模块化编程 — 把代码拆成“抽屉柜“-CSDN博客
【FDE系列】阶段2:Day 27:异常处理与日志 — 让程序“摔不烂、查得到“-CSDN博客
FastAPI入门到进阶
【FDE系列】阶段2:Day 28:FastAPI 入门 — 把你的函数变成 API 服务-CSDN博客
【FDE系列】阶段2:Day 29:FastAPI 进阶 — Pydantic 模型与完整 CRUD 实战-CSDN博客
【FDE系列】阶段2:Day 30:生产代码规范 — 测试、类型注解、配置管理(第二周收官)-CSDN博客
SQL基础
【FDE系列】阶段2:Day 31:SQL 基础 — 增删改查一把梭-CSDN博客
【FDE系列】阶段2:Day 32:多表查询 — JOIN 与聚合-CSDN博客
【FDE系列】阶段2:Day 33:进阶查询 — 窗口函数与 CTE-CSDN博客
【FDE系列】阶段2:Day 34:数据清洗 — 把脏数据捋干净-CSDN博客
【FDE系列】阶段2:Day 35:Python + SQL — 工单接入 MySQL + 本周收官-CSDN博客
Linux基础
【FDE系列】阶段2:Day 36:Linux 入门与文件操作 — 扔掉鼠标的第一天-CSDN博客
【FDE系列】阶段2:Day 37:权限、进程与文本三剑客-CSDN博客
【FDE系列】阶段2:Day 38:Shell 脚本 — 把命令串起来自动跑-CSDN博客
【FDE系列】阶段2:Day 39:Linux 综合实战 — 让服务无人值守-CSDN博客
【FDE系列】阶段2:Day 40:Shell 进阶 — 生产级脚本与本周收官-CSDN博客
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2501_90561511/article/details/169361759




