Day 183 — 原理图解
六张图,从「镜像是什么」到「坏了怎么退回去」。全部对应本日真跑过的实验。
图 1 · 镜像 = 层的链表(以及为什么多阶段能瘦)
单阶段镜像 多阶段镜像
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ ⑥ COPY ops_service.py │ │ runtime │
│ ⑤ RUN pip install │ │ ⑥ COPY --from=builder /opt/venv
│ ④ COPY requirements.txt │ │ ⑤ COPY ops_service.py │
│ ③ RUN apt-get install gcc │ ← 105 MB │ ④ ENV PATH / HEALTHCHECK │
│ ② RUN apt-get update │ └───────────────────────────────┘
│ ① FROM python:3.12-slim (41MB)│ ↑ 只有 ② 之后的**搬运结果**
└───────────────────────────────┘ ┌───────────────────────────────┐
宿主机看到的体积:109.3 MiB │ builder(构建完就丢) │
│ ③ gcc + venv + 构建期自检 │
└───────────────────────────────┘
运行镜像体积:45.2 MiB
要点:不是「把层合并了」,而是「没把那几层搬过来」。
COPY --from=builder 只在 runtime 里新建一层,内容是 builder 那个目录的快照。
图 2 · 构建缓存:层的命中与失效
改动 Dockerfile 指令 缓存 谁重建
──────────────────────────────────────────────────────────────
(无) ① COPY requirements HIT —
② RUN pip install HIT —
③ COPY ops_service.py HIT —
──────────────────────────────────────────────────────────────
改一行代码 ① COPY requirements HIT —
② RUN pip install HIT — ← 依赖没变,白赚
③ COPY ops_service.py MISS 只重建这一层
──────────────────────────────────────────────────────────────
加了依赖 ① COPY requirements MISS —
② RUN pip install MISS 重装全部依赖
③ COPY ops_service.py MISS 重建
结论:把「变化频率低的」放在前面。Dockerfile 里的顺序是性能参数,不是风格问题。
图 3 · 容器从「创建」到「健康」的时间线(本日实测)
t=0.00s docker run -d 返回(容器被创建,进程还没绑端口)
t=0.05s python 启动,服务开始监听 8080
t=0.55s GET /healthz → 200 ← 部署脚本的轮询(0.5s 一次)在这里就放行
t=5.00s docker HEALTHCHECK 第 1 次真正的 interval 检查
└ start-period=3s 内不计入 retries,所以第一次判定点落在 interval=5s
t≈5.0s docker inspect .State.Health.Status: starting → healthy
healthy
│ ┌─────────────── healthy ───────────────────
│ │
starting ─────────┘
│
└────── 前 9 次探测都是 starting(本日实测 10 次轮询到 healthy)──────▶ t
两个「健康」不是一回事:部署门禁看 /healthz/(快,0.5s 级),
运行期守护看 Docker HEALTHCHECK(慢,interval 级)。
图 4 · CI 四个 job 的 DAG 与 CI/CD 分界
每一次 push / PR 只在 main 的 push
┌───────────────────────────────┐ ┌────────────────────────────┐
│ lint │ │ │
│ │ │ │ │
│ ▼ │ │ │
│ test (matrix: py3.11/3.12) │ │ │
└───────────────┬───────────────┘ │ │
│ │ │
▼ ▼ │
build & push ───────────▶ deploy │
(docker/build-push-action) (一键部署 + 回滚) │
▲ ▲ │
└── if: github.event_name == 'push' │
&& github.ref == 'refs/heads/main' ──────────┘
总时长 = 最长路径之和(这里是串行链,所以就是各段相加)
PR 上永远只跑到 test —— 既省钱,也避免 PR 拿到推镜像的权限
图 5 · 一键部署 + 回滚时序(本日真实演练)
部署脚本 Docker 服务
│ │ │
│ ① docker rm -f 旧容器 │ │
│────────────────────────▶│ (旧版本下线) │
│ ② docker run -d 新容器 │ │
│────────────────────────▶│────────── 启动 ───────────▶ │
│ ③ 轮询 /healthz /readyz │ │
│◀─────────────────────── 200/503 ◀──────────────────────│
│ │ │
│ ┌── 全 200 ──────────▶ ④ 冒烟 /version /api/hosts /api/summary
│ │ ⑤ 写 releases.json: current = 新版本 ✅
│ │
│ └── 任一 503 ─────────▶ ④' docker logs --tail 20(先留证据)
│ ⑤' docker rm -f 坏容器
│ ⑥' 起上一个可用版本 + 重新健康检查
│ ⑦' 只有 ⑥' 也过了才说「已回滚」
│ 账本里:坏版本 result=failed,**从不进 current**
本日实测:坏版本用了 30.364 s 才被判死(健康检查超时 30s), 回滚起 v1 并验证通过只用了 0.506 s——「判死」这一段的耗时完全是超时阈值决定的, 不是服务本身慢。这是部署脚本要调的第一个参数。
图 6 · 本日翻车点地图(每个都在实测中出现过)
概念层 脚本层 环境层
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 自检断言写错 │ │ 占位符被自己吃掉 │ │ act 工作区是空的 │
│ (注释里的curl)│ │ (模板 vs 渲染后) │ │ (缺 --bind) │
└──────┬───────┘ └────────┬─────────┘ └────────┬─────────┘
│ │ │
┌──────▼───────┐ ┌────────▼─────────┐ ┌────────▼─────────┐
│ 负向断言回溯 │ │ PyYAML 把 on 读成 │ │ compileall 对不 │
│ 假通过 │ │ 布尔 True │ │ 存在的路径返回 0 │
└──────┬───────┘ └────────┬─────────┘ └────────┬─────────┘
│ │ │
┌──────▼────────────────────────────▼────────────────────────────▼─────────┐
│ 共同点:**它们都是「绿」的**。 │
│ 不看输出、只信退出码,七个坑你一个都发现不了。 │
└──────────────────────────────────────────────────────────────────────────┘
一句话速记
| 图 | 一句话 |
|---|---|
| 1 | 多阶段不是合并层,是不搬那些层 |
| 2 | Dockerfile 的顺序 = 构建速度 |
| 3 | docker run 返回 ≠ 服务可用;门禁看 /healthz,守护看 HEALTHCHECK |
| 4 | needs 决定并行形状,if 决定 CI/CD 分界 |
| 5 | 先判后切:坏版本从不进可用列表 |
| 6 | 七个坑全是「绿的」——退出码不是证据 |