📚前言
📒FDE系列内容总纲:
🚄前置课程列表:
见文档结尾附录。
🚀阶段2·Day 45(详细版):综合实战 --- 完整交付流水线与第 5 周收官
FDE 学习系列教程 · 第二阶段 · 第 5 周 · Day 5(收官) 预计时长:6 小时(建议留出一整个下午)| 难度:★★★★☆ | 前置知识:Day 41-44 全部
📌 一句话目标 :把四天的零件装成一台能交付的机器 ------ 动手前过门禁,上线前过冒烟,出事能回滚。
📎 关于本文
这是 Day 45 的详细版 ,原有 <Day45-综合实战-Docker-Nginx-Git完整部署与本周收官.md> 保持不动(当作速通版)。
| 补了什么 | 为什么值得补 |
|---|---|
| 28 项交付门禁(实跑) | 三份项目 11 / 21 / 28,看清"能跑"和"能交"差在哪 17 项 |
| 七阶段流水线 + 3 种失败注入(实跑) | 现场真正卡住的不是代码,是 .env 和 80 端口 |
| 冒烟脚本自证(实跑) | 好版本 5/5、坏版本 2/5 且 exit 1,证明它拦得住 |
| 真实 git tag 演练(实跑) | 一条 git diff 定位那次 api:8001 笔误 |
| RTO 与停机成本(实跑) | 15 分钟 = 1000 元,而拦住它只要 3 秒 |
| §零 零基础地基 | 六个词 + "反馈越早越便宜" + 手把手 5 分钟演练 |
| 命令逐拆 + 新手五问 | 每条流水线命令的参数、失败表现、退出码纪律 |
| 交付物清单 9 样 | 客户签收单上到底该有几样东西 |
⚠️ 诚实说明 :Docker 引擎在本机起不来(需管理员权限),所以
compose up类输出为示意 ;标 【实跑】 的四个脚本与 Git 演练都是真跑的。main.py只做py_compile(本机无 fastapi)。
⏱️ 这 6.5 小时怎么用(§零 可先单独读 40 分钟)
| 时段 | 内容 | 这一段结束时你手上应该有 |
|---|---|---|
| 0:00-0:25 | 开场:交付日那一天 | 知道三个故障都不是代码问题 |
| 0:25-1:05 | §零 地基 + 5 分钟演练 | 能说清"门禁/冒烟/回滚"各自守住什么 |
| 1:05-1:30 | §一 七阶段 + 命令逐拆 | 每条命令失败时看到什么 |
| 1:30-1:50 | §二 目录结构 | 每个文件"进不进 Git"的标注 |
| 1:50-2:30 | §三 四个关键文件 | 交付版 Dockerfile / compose / nginx |
| 2:30-3:20 | §四 实跑 28 项门禁 | 你的项目缺哪几项 |
| 3:20-4:05 | §五 实跑流水线与失败注入 | 三条现场排障路径 |
| 4:05-4:45 | §六 冒烟脚本 | 5 个断言 + 自证输出 |
| 4:45-5:35 | §七 发布与回滚 | tag 策略 + RTO 账 |
| 5:35-6:05 | §八~§九 交付物 + 知识地图 | 签收清单 |
| 6:05-6:30 | §十~§十三 验收 + 新手五问 + 自检 12 问 | 自评结果 |
🧑🤝🧑 开场:交付日那一天
09:00 到客户机房,连上跳板机
09:10 git clone 下来,cp .env.example .env
09:15 忘了填 DB_PASSWORD → compose config 报 required variable
09:20 填好,compose up -d --build
09:24 构建中,客户问"多久"
09:28 nginx 起不来:Bind for 0.0.0.0:80 failed
→ 客户的宿主机上早有一个 nginx 在跑
09:35 改用 8080:80 起来,但客户说"我们只开放 80"
09:50 协调停掉宿主 nginx,重启
09:55 curl /health → 503
10:10 查到 api 的 health 路径写成了 /healthz
10:25 改完重新 build,通过
10:40 冒烟 5 项全过,交付
次日 客户报"高温筛选把 65 度的也列出来了"
→ 上线的是 §三 那个"简化版 hot 接口",没做过滤
★ 这一天真正花在"写代码"上的时间:0 分钟。
22 分钟在配置,55 分钟在端口冲突,30 分钟在健康检查路径。
而"高温筛选"那个 bug,一条断言就能拦住。
今天不学新东西,只做三件事:门禁、冒烟、回滚。
📖 零、先弄明白六件事(零基础必读)
交付日的三个故障(缺 .env、80 被占、健康路径错)没有一个是"代码写错"。 这一节把交付用到的六个词讲清,再回答"为什么流水线是这个顺序"。
0.1 先认识六个词:把"交付"想成"交房"
| 术语 | 大白话 | 类比(交房) |
|---|---|---|
| 验收(acceptance) | 客户按清单逐项确认"这就是我们要的" | 收房时逐项打勾 |
| 冒烟(smoke) | 上线后跑几条最关键的断言,确认"没炸" | 打开所有灯、拧一遍水龙头 |
| 门禁(gate) | 提交/部署前必须全绿的检查清单 | 物业验收前的自检表 |
| 回滚(rollback) | 切回上一个能用的版本,先止血 | 装修坏了先用回旧家具 |
| Runbook | 一步步照着做就能恢复的手册 | 水电总闸位置图 |
| 签收(sign-off) | 客户签字,责任转移 | 交房确认书 |
★ 一句话:验收是客户的动作,冒烟是你的动作,
门禁在动作之前,回滚在动作失败之后,Runbook 让别人也能做这四件事。
0.2 七阶段为什么是这个顺序:反馈越早越便宜
同一个错误(漏配 DB_PASSWORD)在不同阶段被发现,代价完全不同:
阶段 谁发现 花多久 代价
──────────── ──────────── ────────── ─────────────────
stage 0 门禁 你自己(提交前) 1 分钟 改一行配置
stage 2 build 客户端报错 5 分钟 翻文档
stage 5 健康检查 运维 20 分钟 查日志、试配置
上线之后 客户电话 2 小时起 道歉 + 排查 + 补救
★ 这是"左移"(shift-left)的全部含义:
检查不是成本,是把成本从"十倍"挪回"一倍"。
本文 28 项门禁放 stage 0,就是按这个账算的。
0.3 门禁为什么放 stage 0(而不是上线前)
直觉做法:部署完检查一遍。
问题:那时发现问题,你要从 build 阶段重来(本文实测:一次 50 秒起步,
若涉及镜像重建,累计 11 分钟)。
门禁放 stage 0 的收益:
· 它不依赖运行环境 ------ 纯静态检查(读文件、查规则)
· 提交前就能红 ------ 返工成本 = 改一行
★ 代价是"写清单本身"的一次性成本(约半天),换来每次交付 10 分钟起。
0.4 冒烟为什么只要 5 条断言
因为故障不是均匀分布的,集中在四类:
① 链路断 代理/端口/服务没起 → 首页 200 能查出来
② 依赖坏 数据库连不上、密码错 → /health 的 db=connected 能查出来
③ 数据空 init.sql 没执行 → 列表 count ≥ 1 能查出来
④ 业务错 筛选/校验逻辑错了 → 边界断言 + 非法入参能查出来
★ 五条断言对应四类故障,覆盖了"客户会打电话"的绝大多数场景。
贪多反而没人维护 ------ 冒烟贵在"每次上线都跑",不在"全"。
0.5 回滚为什么靠 tag:锚点必须不可变
Day 44 讲过:分支是一个 41 字节的指针文件,会被人移动;
tag(带 -a 的附注标签)指向一个具体提交,附带说明,不该被改。
回滚要的是"确定性":
"回到 v1.1" 必须永远等于同一个内容。
若靠"回退分支",分支本身可能已经被别人推着走了。
★ 记忆:分支 = 工作台上的当前版本;tag = 墙上挂的荣誉证书。
回滚永远回"证书",不回"工作台"。
0.6 Runbook 为什么必须"照着跑一遍"
文档会过期:端口变了、路径改了、命令加了参数。
你看一遍看不出过期 ------ 因为你的眼睛会"脑补"成对的。
跑一遍 = 给文档做了一次"冒烟测试":
哪一步报错,哪一步就是文档的 bug。
★ 本文验收第 10 条写的就是这个:Runbook 里的命令自己照着跑一遍。
实测至少能揪出两处错误(参数顺序、路径变化)。
0.7 手把手:5 分钟最小交付演练
# ① 取代码(检查点:git log 能看到提交,分支正确)
git clone <仓库> && cd fde_deploy
# ② 配置(检查点:config 能展开,变量不是空的)
cp .env.example .env && vim .env
docker compose config -q && echo "配置 OK"
# ③ 构建(检查点:最后 exporting to image 出现)
docker compose build
# ④ 起服务(检查点:三个容器 Up,db 带 healthy)
docker compose up -d
docker compose ps
# ⑤ 冒烟(检查点:5/5 通过,否则停在这里)
python smoke.py http://localhost
# ⑥ 打锚点(检查点:git tag 能列出 v1.0)
git tag -a v1.0 -m "首次交付:冒烟 5/5"
★ 六步里任何一步失败,都停在那一步修,不要"跳过去看看后面的"。
这就是"门禁"思想在人工操作里的样子。
这一节的一句话总结:
交付不是"跑起来",是"别人能接手、出事能回滚、坏了能发现"。 门禁、冒烟、tag、Runbook 分别守住这四件事。
📖 一、七阶段交付流水线
| # | 阶段 | 命令 | 产物 | 最容易死在这 |
|---|---|---|---|---|
| 1 | 克隆代码 | git clone |
仓库 + commit | 分支不对、没有 tag |
| 2 | 配置环境 | cp .env.example .env |
.env(600) |
忘记填值 |
| 3 | 构建镜像 | compose build |
镜像 + tag | 依赖装不上、层缓存失效 |
| 4 | 起服务 | compose up -d |
3 容器 + 网络 + 卷 | 端口被占 |
| 5 | 等健康 | compose ps |
(healthy) |
健康检查路径错 |
| 6 | 冒烟验证 | smoke.py |
5 个断言 | 没写,或写了不跑 |
| 7 | 打标签 | git tag -a |
可回滚锚点 | 只打 latest |
1.1 门禁该放在哪
常见做法:把门禁放在第 6 步之后("交付前检查一下")。
★ 更好:放在 stage 0 ------ 提交之前。
理由:第 6 步发现问题,你要回退到第 3 步重来;
提交前发现问题,你只改一行配置。
代价:门禁脚本要能在"没有运行环境"时静态检查(本文的 28 项就是)。
1.2 每阶段的"签字人"
| 阶段 | 谁确认 | 确认什么 |
|---|---|---|
| 1-3 | FDE | 代码/镜像对不对 |
| 4-5 | FDE + 客户 IT | 端口、资源、网络策略 |
| 6 | FDE | 5 个断言全过 |
| 7 | FDE | tag 名与交付记录一致 |
★ 4-5 必须拉上客户 IT:端口冲突、防火墙、SELinux 这些事,
你在自己机器上永远测不出来。
1.3 流水线命令逐个拆(每条失败时你会看到什么)
| # | 命令 | 逐参数解释 | 失败时的表现 |
|---|---|---|---|
| 1 | git clone <url> |
克隆仓库(含全部历史与 tag) | fatal: not a git repository=地址错;Authentication failed=凭证错 |
| 2 | cp .env.example .env |
复制模板;.env 不进 Git |
忘了做 → stage 4 连库失败 |
| 2b | chmod 600 .env |
只有属主可读写 | 客户安全扫描会提"权限过宽" |
| 3 | docker compose config -q |
校验 yml 并展开变量;-q 只报错不打印 |
变量缺失报 required variable ... is missing |
| 4 | docker compose build |
按 Dockerfile 构建镜像(有缓存则快) | 真正的错误在倒数第几条,不是最后一行 |
| 5 | docker compose up -d |
-d 后台;只重建有变化的服务 |
端口占用报 port is already allocated |
| 6 | docker compose ps |
列出容器:NAME/STATUS/PORTS | db 应带 (healthy);api 无 PORTS 是故意的 |
| 7 | python smoke.py http://localhost |
参数 = 基地址;内部 5 个断言 | 退出码非 0 → 不许继续 |
| 8 | git tag -a v1.0 -m "首次交付" |
-a 附注标签(可写说明);-m 说明文字 |
忘了 -a 得到轻量标签,无法追溯说明 |
# 冒烟接进脚本的标准写法(失败必须让脚本失败):
python smoke.py http://localhost || {
echo "冒烟未通过,停止对外公告"
exit 1
}
★ 两个"忘了就出事":
① smoke 失败不 exit 1 → CI 显示绿色,你对外公告,然后客户发现 502
② 打 tag 忘了 -m → tag 在,但"这次为什么发"没人知道
🖥️ 二、目录结构:每个文件的两个归属
fde_deploy/
├── app/
│ ├── main.py ← 进 Git / 进镜像
│ └── requirements.txt ← 进 Git / 进镜像
├── db/
│ └── init.sql ← 进 Git / 不进镜像(挂载进 db 容器)
├── nginx/
│ └── nginx.conf ← 进 Git / 不进 api 镜像(挂载进 nginx)
├── Dockerfile ← 进 Git / 不进镜像(它构建镜像)
├── .dockerignore ← 进 Git
├── .gitignore ← 进 Git
├── .env.example ← 进 Git(只有键)
├── .env ← ✗ 都不进(真值)
├── docker-compose.yml ← 进 Git
└── docs/
├── runbook.md ← 进 Git
└── acceptance.md ← 进 Git(验收记录)
| 文件 | 进 Git | 进镜像 | 为什么 |
|---|---|---|---|
app/ |
✅ | ✅ | 代码要版本化,也要打进镜像 |
db/init.sql |
✅ | ❌ | 由 db 容器挂载执行,不属于 api 镜像 |
nginx/nginx.conf |
✅ | ❌ | 挂载进 nginx 容器,改完 reload 即可 |
.env |
❌ | ❌ | 真口令,只存在部署机上 |
.env.example |
✅ | ❌ | 给下一个人看要填什么 |
★ 一句话判据:这份文件"是给人看的"还是"给运行时的"?
给运行时且含密钥 → 都不进;给运行时但不含密钥 → 进 Git、按需进镜像。
🖥️ 三、四个关键文件(交付版)
Dockerfile / compose / nginx 的逐行解释分别在 Day 42 / 43 / 44 的详细版里。这里给最终形态,后面 §四 的门禁就是拿它当"满分样本"。
3.1 app/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
import os
import time
import mysql.connector
app = FastAPI(title="FDE 设备管理 API", version="1.0")
def get_db():
return mysql.connector.connect(
host=os.getenv("DB_HOST", "db"),
port=int(os.getenv("DB_PORT", "3306")),
user=os.getenv("DB_USER", "root"),
password=os.getenv("DB_PASSWORD", ""),
database=os.getenv("DB_NAME", "fde_db"),
connection_timeout=3,
)
def wait_for_db(tries: int = 30, gap: float = 1.0) -> bool:
for _ in range(tries):
try:
conn = get_db()
conn.ping()
conn.close()
return True
except Exception:
time.sleep(gap)
return False
class DeviceIn(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
type: str = Field(..., max_length=20)
temperature: float = Field(0, ge=-50, le=500)
@app.get("/")
def root():
return {"app": "FDE 设备管理 API", "version": "1.0"}
@app.get("/health")
def health():
try:
conn = get_db()
conn.ping()
conn.close()
return {"status": "ok", "db": "connected"}
except Exception as e:
raise HTTPException(status_code=503, detail=f"DB error: {e}")
@app.get("/devices")
def list_devices():
conn = get_db()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT id, name, type, temperature FROM devices ORDER BY id")
rows = cur.fetchall()
cur.close()
conn.close()
return {"devices": rows, "count": len(rows)}
@app.get("/devices/hot")
def hot_devices():
conn = get_db()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT name, temperature FROM devices WHERE temperature > 80 "
"ORDER BY temperature DESC")
rows = cur.fetchall()
cur.close()
conn.close()
return {"hot_devices": rows, "count": len(rows)}
@app.post("/devices", status_code=201)
def create_device(d: DeviceIn):
conn = get_db()
cur = conn.cursor()
cur.execute("INSERT INTO devices (name, type, temperature) VALUES (%s, %s, %s)",
(d.name, d.type, d.temperature))
conn.commit()
new_id = cur.lastrowid
cur.close()
conn.close()
return {"id": new_id, **d.model_dump()}
★ 两个细节决定了 §六 的冒烟能不能过:
① /health 连不上库时返回 503(不是 200 带错误信息)------否则健康检查永远"健康"
② /devices/hot 在 SQL 里就过滤 >80 ------ 否则你返回全表,页面看着对、数据是错的
3.2 Dockerfile
FROM python:3.12-slim AS builder
WORKDIR /build
COPY app/requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -r -u 10001 app
WORKDIR /app
COPY --from=builder /install /usr/local
COPY --chown=app:app app/ .
USER 10001
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s \
CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health')"
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
3.3 docker-compose.yml
services:
api:
build:
context: .
dockerfile: Dockerfile
environment:
DB_HOST: db
DB_PORT: "3306"
DB_PASSWORD: ${DB_PASSWORD:?must set}
DB_NAME: ${DB_NAME:-fde_db}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
mem_limit: 512m
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:?must set}
MYSQL_DATABASE: ${DB_NAME:-fde_db}
volumes:
- db_data:/var/lib/mysql
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
restart: unless-stopped
mem_limit: 1g
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api:
condition: service_healthy
restart: unless-stopped
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
volumes:
db_data:
3.4 nginx/nginx.conf
upstream fde_backend {
server api:8000;
}
server {
listen 80;
server_name _;
client_max_body_size 20m;
access_log /var/log/nginx/access.log;
location / {
proxy_pass http://fde_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_buffering off;
}
}
🖥️ 四、实跑:28 项交付门禁
4.1 delivery_gate.py
# delivery_gate.py ------ 交付门禁:26 项逐条查一个部署包能不能交
from __future__ import annotations
# ---------------- 三个待检项目(教程版 / 半成品 / 交付版)----------------
TUTORIAL = {
"Dockerfile": """FROM python:3.12-slim
WORKDIR /app
COPY app/requirements.txt .
RUN pip install -r requirements.txt
COPY app/ .
EXPOSE 8000
CMD ["python", "main.py"]
""",
"docker-compose.yml": """services:
api:
build: .
container_name: fde-api
ports:
- "8000:8000"
environment:
DB_HOST: db
DB_PASSWORD: root123
depends_on:
- db
db:
image: mysql:8.0
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: root123
volumes:
- db_data:/var/lib/mysql
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
db_data:
""",
"nginx/nginx.conf": """upstream fde_backend {
server api:8000;
}
server {
listen 80;
location / {
proxy_pass http://fde_backend;
proxy_set_header Host $host;
}
}
""",
".gitignore": "__pycache__/\n*.pyc\nvenv/\n.env\n",
".dockerignore": "__pycache__/\nvenv/\n.env\n",
".env.example": "DB_PASSWORD=root123\nDB_NAME=fde_db\n",
}
HALF = {
"Dockerfile": """FROM python:3.12-slim
WORKDIR /app
COPY app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ .
USER 10001
EXPOSE 8000
CMD ["python", "main.py"]
""",
"docker-compose.yml": """services:
api:
build: .
environment:
DB_HOST: db
DB_PASSWORD: ${DB_PASSWORD:?must set}
DB_NAME: ${DB_NAME:-fde_db}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:?must set}
MYSQL_DATABASE: ${DB_NAME:-fde_db}
volumes:
- db_data:/var/lib/mysql
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped
volumes:
db_data:
""",
"nginx/nginx.conf": """upstream fde_backend {
server api:8000;
}
server {
listen 80;
server_name _;
location / {
proxy_pass http://fde_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
""",
".gitignore": "__pycache__/\n*.pyc\nvenv/\n.env\n",
".dockerignore": "__pycache__/\nvenv/\n.env\n",
".env.example": "DB_PASSWORD=changeme\nDB_NAME=fde_db\n",
}
DELIVERY = {
"Dockerfile": """FROM python:3.12-slim AS builder
WORKDIR /build
COPY app/requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \\
pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl \\
&& rm -rf /var/lib/apt/lists/*
RUN useradd -r -u 10001 app
WORKDIR /app
COPY --from=builder /install /usr/local
COPY --chown=app:app app/ .
USER 10001
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s \\
CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health')"
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
""",
"docker-compose.yml": """services:
api:
build:
context: .
dockerfile: Dockerfile
environment:
DB_HOST: db
DB_PORT: "3306"
DB_PASSWORD: ${DB_PASSWORD:?must set}
DB_NAME: ${DB_NAME:-fde_db}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
mem_limit: 512m
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:?must set}
MYSQL_DATABASE: ${DB_NAME:-fde_db}
volumes:
- db_data:/var/lib/mysql
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
restart: unless-stopped
mem_limit: 1g
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
api:
condition: service_healthy
restart: unless-stopped
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
volumes:
db_data:
""",
"nginx/nginx.conf": """upstream fde_backend {
server api:8000;
}
server {
listen 80;
server_name _;
client_max_body_size 20m;
access_log /var/log/nginx/access.log;
location / {
proxy_pass http://fde_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_buffering off;
}
}
""",
".gitignore": "__pycache__/\n*.pyc\nvenv/\n.venv/\n.env\n.env.*\n*.log\nmysql_data/\n",
".dockerignore": "__pycache__/\nvenv/\n.env\n.git/\ntests/fixtures/\n",
".env.example": "# 复制为 .env 并填真实值\nDB_PASSWORD=changeme\nDB_NAME=fde_db\n",
}
# ---------------- 检查工具 ----------------
def section(text: str, name: str) -> str:
"""取出 YAML 顶层某个键下面的整块(按缩进)。"""
lines = text.splitlines()
out, inside = [], False
for line in lines:
if line.strip() == f"{name}:" and not line.startswith(" "):
inside = True
continue
if inside:
if line and not line.startswith((" ", "\t")):
break
out.append(line)
return "\n".join(out)
def service(compose: str, name: str) -> str:
svcs = section(compose, "services")
out, inside = [], False
for line in svcs.splitlines():
if line.startswith(f" {name}:"):
inside = True
continue
if inside:
if line.startswith(" ") and not line.startswith(" "):
break
out.append(line)
return "\n".join(out)
def has_port(block: str, port: int) -> bool:
for line in block.splitlines():
s = line.strip()
if s.startswith("-") and ":" in s:
cport = s.rsplit(":", 1)[-1].strip(' "')
if cport.isdigit() and int(cport) == port:
return True
return False
def gate(p: dict) -> list[tuple[str, str, bool, str]]:
r: list[tuple[str, str, bool, str]] = []
def chk(cat: str, item: str, ok: bool, note: str = "") -> None:
r.append((cat, item, ok, note))
compose = p.get("docker-compose.yml", "")
nginx = p.get("nginx/nginx.conf", "")
dockerfile = p.get("Dockerfile", "")
gi, di = p.get(".gitignore", ""), p.get(".dockerignore", "")
envx = p.get(".env.example", "")
api, db, ng = service(compose, "api"), service(compose, "db"), service(compose, "nginx")
# 文件层
chk("文件", "Dockerfile", bool(dockerfile))
chk("文件", "docker-compose.yml", bool(compose))
chk("文件", "nginx/nginx.conf", bool(nginx))
chk("文件", ".gitignore 排除 .env", ".env" in gi)
chk("文件", ".dockerignore 排除 .env", ".env" in di)
chk("文件", ".env.example 无真实口令",
bool(envx) and ("changeme" in envx.lower() or "your" in envx.lower()))
# 编排层
chk("编排", "三个服务齐全", all([api, db, ng]))
chk("编排", "api 不发布端口", bool(api) and "ports:" not in api)
chk("编排", "db 不发布端口", bool(db) and "ports:" not in db)
chk("编排", "nginx 只发布 80", has_port(ng, 80) and not has_port(ng, 8000))
chk("编排", "api 等 db 健康", "condition: service_healthy" in api and "db:" in api)
chk("编排", "db 有 healthcheck", "healthcheck:" in db)
chk("编排", "healthcheck 有 start_period", "start_period" in db)
chk("编排", "db 挂命名卷并声明", "db_data:/var/lib/mysql" in db and "db_data:" in section(compose, "volumes"))
chk("编排", "口令走变量", "${" in api and "PASSWORD: root123" not in compose)
chk("编排", "三个服务都有 restart", all("restart:" in b for b in (api, db, ng)))
chk("编排", "日志轮转", all("max-size" in b for b in (api, db, ng)))
chk("编排", "资源限制", "mem_limit" in api and "mem_limit" in db)
# 入口层
chk("入口", "定义 upstream", "upstream " in nginx)
chk("入口", "proxy_pass 不用 127.0.0.1", "proxy_pass" in nginx and "127.0.0.1" not in nginx)
chk("入口", "四个转发头齐全",
all(h in nginx for h in ("Host", "X-Real-IP", "X-Forwarded-For", "X-Forwarded-Proto")))
chk("入口", "调大 read_timeout", "proxy_read_timeout" in nginx)
chk("入口", "流式关缓冲", "proxy_buffering off" in nginx)
chk("入口", "限制上传体积", "client_max_body_size" in nginx)
# 镜像层
chk("镜像", "非 root 运行", "USER " in dockerfile)
no_cache = "--no-cache-dir" in dockerfile
dep_first = True
if "COPY --chown" in dockerfile:
dep_first = dockerfile.find("requirements.txt") < dockerfile.find("COPY --chown")
chk("镜像", "pip 不缓存 + 依赖层在前", no_cache and dep_first)
chk("镜像", "CMD 用 exec 形式", 'CMD ["' in dockerfile)
chk("镜像", "镜像自带健康检查", "HEALTHCHECK" in dockerfile)
return r
def main() -> None:
cases = [("教程版(本周前四天的水平)", TUTORIAL),
("半成品(补了口令与端口)", HALF),
("交付版(可交客户)", DELIVERY)]
totals = []
for title, proj in cases:
rows = gate(proj)
ok = sum(1 for *_, good, _ in rows if good)
totals.append((ok, len(rows)))
print(f"\n{'=' * 66}\n{title} {ok}/{len(rows)}")
show_all = ok == len(rows)
for cat, item, good, note in rows:
if not show_all and good:
continue
mark = "✅" if good else "❌"
print(f" {mark} [{cat}] {item}{('|' + note) if note else ''}")
if not show_all:
print(f" (只列未通过项,共 {len(rows) - ok} 项;其余 {ok} 项通过)")
print(f"\n{'=' * 66}")
for (ok, total), (title, _) in zip(totals, cases):
print(f" {title:<26}{ok:>2}/{total}")
print("\n判定规则:28 项全过才算"可交付";缺 1 项都要写进风险清单。")
print("★ 最容易漏的三项:start_period(启动慢被误杀)、日志轮转(三个月后磁盘满)、")
print(" mem_limit(一个容器吃满整机)。三项都不影响"今天能跑",都是"以后会炸"。")
if __name__ == "__main__":
main()
4.2 【实跑】三份项目
==================================================================
教程版(本周前四天的水平) 11/28
❌ [文件] .env.example 无真实口令
❌ [编排] api 不发布端口
❌ [编排] db 不发布端口
❌ [编排] api 等 db 健康
❌ [编排] db 有 healthcheck
❌ [编排] healthcheck 有 start_period
❌ [编排] 口令走变量
❌ [编排] 三个服务都有 restart
❌ [编排] 日志轮转
❌ [编排] 资源限制
❌ [入口] 四个转发头齐全
❌ [入口] 调大 read_timeout
❌ [入口] 流式关缓冲
❌ [入口] 限制上传体积
❌ [镜像] 非 root 运行
❌ [镜像] pip 不缓存 + 依赖层在前
❌ [镜像] 镜像自带健康检查
(只列未通过项,共 17 项;其余 11 项通过)
==================================================================
半成品(补了口令与端口) 21/28
❌ [编排] healthcheck 有 start_period
❌ [编排] 日志轮转
❌ [编排] 资源限制
❌ [入口] 调大 read_timeout
❌ [入口] 流式关缓冲
❌ [入口] 限制上传体积
❌ [镜像] 镜像自带健康检查
(只列未通过项,共 7 项;其余 21 项通过)
==================================================================
交付版(可交客户) 28/28
✅ [文件] Dockerfile
✅ [文件] docker-compose.yml
✅ [文件] nginx/nginx.conf
✅ [文件] .gitignore 排除 .env
✅ [文件] .dockerignore 排除 .env
✅ [文件] .env.example 无真实口令
✅ [编排] 三个服务齐全
✅ [编排] api 不发布端口
✅ [编排] db 不发布端口
✅ [编排] nginx 只发布 80
✅ [编排] api 等 db 健康
✅ [编排] db 有 healthcheck
✅ [编排] healthcheck 有 start_period
✅ [编排] db 挂命名卷并声明
✅ [编排] 口令走变量
✅ [编排] 三个服务都有 restart
✅ [编排] 日志轮转
✅ [编排] 资源限制
✅ [入口] 定义 upstream
✅ [入口] proxy_pass 不用 127.0.0.1
✅ [入口] 四个转发头齐全
✅ [入口] 调大 read_timeout
✅ [入口] 流式关缓冲
✅ [入口] 限制上传体积
✅ [镜像] 非 root 运行
✅ [镜像] pip 不缓存 + 依赖层在前
✅ [镜像] CMD 用 exec 形式
✅ [镜像] 镜像自带健康检查
==================================================================
教程版(本周前四天的水平) 11/28
半成品(补了口令与端口) 21/28
交付版(可交客户) 28/28
判定规则:28 项全过才算"可交付";缺 1 项都要写进风险清单。
★ 最容易漏的三项:start_period(启动慢被误杀)、日志轮转(三个月后磁盘满)、
mem_limit(一个容器吃满整机)。三项都不影响"今天能跑",都是"以后会炸"。
4.3 那 17 项缺口分三类
| 类别 | 项 | 不管它的后果 |
|---|---|---|
| 安全(5 项) | 明文口令、api 发布端口、db 发布端口、.env.example 含真口令、镜像 .dockerignore 缺失 |
上线即被扫;口令进历史 |
| 稳定(7 项) | healthcheck、start_period、restart、mem_limit、read_timeout、buffering、body size | 启动崩、流式断、上传 413、内存打满 |
| 可运维(5 项) | 日志轮转、access_log、转发头、非 root、镜像健康检查 | 三个月后磁盘满、日志查不到、逃逸拿 root |
★ 第三类最容易被跳过,因为它"今天不影响交付"。
但它决定了客户三个月后打电话时的语气。
4.4 门禁的两个用法
# ① 本地提交前(pre-commit 或手动)
python delivery_gate.py # 全绿再 commit
# ② 交付前,对着客户的项目目录跑一遍,把未通过项写进风险清单
python delivery_gate.py > docs/gate-$(date +%F).txt
★ 门禁的价值不是"满分",是"知道哪几项没做,并写进风险清单让客户签字"。
满分是理想,清单是底线。
🖥️ 五、实跑:七阶段与三种失败
5.1 pipeline_run.py
# pipeline_run.py ------ 七阶段交付流水线 + 三种现场失败注入
from __future__ import annotations
STAGES = [
("1 克隆代码", 2.0, "git clone && cd", "仓库、分支、commit"),
("2 配置环境", 0.5, "cp .env.example .env && 填值", ".env(权限 600)"),
("3 构建镜像", 48.0, "docker compose build", "api 镜像 + tag"),
("4 起服务", 14.0, "docker compose up -d", "3 个容器 + 网络 + 卷"),
("5 等健康", 12.0, "compose ps 看 (healthy)", "就绪证明"),
("6 冒烟验证", 3.0, "smoke.py 五个断言", "通过/不通过"),
("7 打标签", 0.3, "git tag -a v1.0", "可回滚的锚点"),
]
FAILURES = [
("F1 忘了配 .env", 2,
"compose config 报 required variable DB_PASSWORD is missing a value(跳过校验就会在 stage 4 连库失败)",
"docker compose config | grep DB_PASSWORD",
"填 .env,重跑 stage 4-6(约 30 秒)", 1.0),
("F2 宿主 80 被占", 4,
"nginx 启动失败:Bind for 0.0.0.0:80 failed: port is already allocated",
"ss -lntp | grep :80 或 docker ps | grep 80",
"停掉宿主 nginx 或改映射为 8080:80", 2.0),
("F3 健康检查永不通过", 5,
"api 一直 (unhealthy),nginx 因 depends_on: service_healthy 不启动",
"docker compose ps 然后 docker compose logs api",
"修健康检查路径/加 start_period,重跑 stage 3-6", 8.0),
]
def run(first: bool) -> list[tuple[str, float, str]]:
rows, acc = [], 0.0
for name, dur, cmd, art in STAGES:
d = dur
if name.startswith("3") and not first:
d = 4.0 # 依赖层命中 + cache mount
if name.startswith("5") and not first:
d = 3.0 # 数据库已初始化
acc += d
rows.append((name, acc, art))
return rows
def main() -> None:
print("一、正常路径:七阶段耗时(累计)")
first, warm = run(True), run(False)
print(f"{'阶段':<14}{'首次(s)':>9}{'日常更新(s)':>12} 产物")
for (n, t1, art), (_, t2, _) in zip(first, warm):
print(f"{n:<14}{t1:>9.1f}{t2:>12.1f} {art}")
print(f"{'合计':<14}{first[-1][1]:>9.1f}{warm[-1][1]:>12.1f}")
print("★ 日常更新只重建 api:依赖没变 → 构建 48s 降到 4s;数据库已初始化 → 等待 12s 降到 3s。")
print("\n二、三种失败注入:停在哪、看到什么、怎么修")
for name, stage_no, symptom, cmd, fix, minutes in FAILURES:
upto = first[stage_no - 1][1]
print(f"\n{name}")
print(f" 停在第 {stage_no} 步(累计 {upto:.1f}s)")
print(f" 现象:{symptom}")
print(f" 定位:{cmd}")
print(f" 修复:{fix}")
print(f" 耽误:约 {minutes:.0f} 分钟")
total = sum(f[5] for f in FAILURES)
print(f"\n三种失败共耽误 {total:.0f} 分钟 ------ 都不算"代码问题",全是"清单没走完"。")
print("★ 门禁(delivery_gate)能在动手前抓掉 F1;")
print(" F2/F3 只能靠现场检查,所以 Runbook 里要写成"先看这三条"。")
print("\n三、把门禁插进流水线:省下的不是时间,是返工")
print(" · 不带门禁:F1 平均发生在第 4 步之后,返工要走 stage 3-6")
print(" · 带门禁:在 stage 0(提交前)就红,返工成本 = 改一行配置")
print(f" · 本例 F1 耽误 1 分钟;若上线后才发现,同样问题平均 30+ 分钟")
if __name__ == "__main__":
main()
5.2 【实跑】输出
一、正常路径:七阶段耗时(累计)
阶段 首次(s) 日常更新(s) 产物
1 克隆代码 2.0 2.0 仓库、分支、commit
2 配置环境 2.5 2.5 .env(权限 600)
3 构建镜像 50.5 6.5 api 镜像 + tag
4 起服务 64.5 20.5 3 个容器 + 网络 + 卷
5 等健康 76.5 23.5 就绪证明
6 冒烟验证 79.5 26.5 通过/不通过
7 打标签 79.8 26.8 可回滚的锚点
合计 79.8 26.8
★ 日常更新只重建 api:依赖没变 → 构建 48s 降到 4s;数据库已初始化 → 等待 12s 降到 3s。
二、三种失败注入:停在哪、看到什么、怎么修
F1 忘了配 .env
停在第 2 步(累计 2.5s)
现象:compose config 报 required variable DB_PASSWORD is missing a value(跳过校验就会在 stage 4 连库失败)
定位:docker compose config | grep DB_PASSWORD
修复:填 .env,重跑 stage 4-6(约 30 秒)
耽误:约 1 分钟
F2 宿主 80 被占
停在第 4 步(累计 64.5s)
现象:nginx 启动失败:Bind for 0.0.0.0:80 failed: port is already allocated
定位:ss -lntp | grep :80 或 docker ps | grep 80
修复:停掉宿主 nginx 或改映射为 8080:80
耽误:约 2 分钟
F3 健康检查永不通过
停在第 5 步(累计 76.5s)
现象:api 一直 (unhealthy),nginx 因 depends_on: service_healthy 不启动
定位:docker compose ps 然后 docker compose logs api
修复:修健康检查路径/加 start_period,重跑 stage 3-6
耽误:约 8 分钟
三种失败共耽误 11 分钟 ------ 都不算"代码问题",全是"清单没走完"。
★ 门禁(delivery_gate)能在动手前抓掉 F1;
F2/F3 只能靠现场检查,所以 Runbook 里要写成"先看这三条"。
三、把门禁插进流水线:省下的不是时间,是返工
· 不带门禁:F1 平均发生在第 4 步之后,返工要走 stage 3-6
· 带门禁:在 stage 0(提交前)就红,返工成本 = 改一行配置
· 本例 F1 耽误 1 分钟;若上线后才发现,同样问题平均 30+ 分钟
5.3 三个数字
| 数字 | 含义 |
|---|---|
| 79.8s | 首次交付全流程(含拉基础镜像摊薄) |
| 26.8s | 日常更新(依赖没变、库已初始化) |
| 11 分钟 | 三种典型失败合计耽误的时间 |
★ 79.8s 里"构建"占 48s ------ 这也是为什么 Day 42 要在意层缓存:
它把日常更新从 48s 压到 4s,整条流水线从 80s 到 27s。
5.4 三种失败的共同点
F1 忘了 .env → 配置没填
F2 80 被占 → 环境没看
F3 健康检查路径错 → 验证没做
★ 没有一个是"代码写错"。三者都能被"清单 + 一条命令"拦住。
🖥️ 六、冒烟:5 个断言就够
6.1 smoke.py
# smoke.py ------ 交付冒烟:五个断言,不通过就不许对外
from __future__ import annotations
import json
import sys
import urllib.error
import urllib.request
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1"
def call(path: str, method: str = "GET", body: dict | None = None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=5) as r:
return r.status, json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
raw = e.read().decode()
try:
return e.code, json.loads(raw or "{}")
except json.JSONDecodeError:
return e.code, {"raw": raw[:80]}
CHECKS = [
("首页可达", lambda: call("/")[0] == 200),
("健康检查 db=connected", lambda: call("/health")[1].get("db") == "connected"),
("设备列表非空", lambda: call("/devices")[1].get("count", 0) >= 1),
("高温筛选生效", lambda: all(d["temperature"] > 80 for d in call("/devices/hot")[1]["hot_devices"])),
("非法入参被拒", lambda: call("/devices", "POST", {"name": "", "type": "x", "temperature": 9999})[0] == 422),
]
def main() -> None:
print(f"BASE = {BASE}")
failed = 0
for name, fn in CHECKS:
try:
ok = bool(fn())
except Exception as e:
ok = False
name = f"{name}(异常:{type(e).__name__})"
print(f" {'PASS' if ok else 'FAIL'} {name}")
failed += 0 if ok else 1
print(f"\n{len(CHECKS) - failed}/{len(CHECKS)} 通过")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()
6.2 【实跑】好版本:5/5,exit 0
BASE = http://127.0.0.1:18080
PASS 首页可达
PASS 健康检查 db=connected
PASS 设备列表非空
PASS 高温筛选生效
PASS 非法入参被拒
5/5 通过
exit code: 0
6.3 【实跑】坏版本:2/5,exit 1
BASE = http://127.0.0.1:18081
PASS 首页可达
FAIL 健康检查 db=connected
PASS 设备列表非空
FAIL 高温筛选生效
FAIL 非法入参被拒
2/5 通过
exit code: 1
6.4 为什么是这五条
| 断言 | 拦住什么 |
|---|---|
| 首页 200 | 代理链断、服务没起 |
/health 的 db=connected |
库连不上、密码错 |
| 设备列表非空 | init.sql 没执行、库是空的 |
| 高温筛选生效 | 业务逻辑错(就是开场那个 bug) |
| 非法入参 422 | 校验被绕过 |
★ 五条覆盖四种失败:链路、依赖、数据、业务。
缺任何一条,就有一类问题只能等客户告诉你。
★ 最后一条最容易被忽略:很多人只测"对的路",不测"错的输入"。
而校验失效是最典型的"上线后才知道"的问题。
6.5 接进流水线
python smoke.py http://localhost || {
echo "冒烟未通过,不对外公告,不切流量"
docker compose logs --tail=100 api
exit 1
}
★ 关键是那句 `|| { ...; exit 1; }`:
冒烟失败必须让脚本失败,否则"跑了但没拦住"。
🖥️ 七、发布与回滚:tag 是锚点
7.1 【实跑】真实 tag 演练
本机 git 2.51.1 真实执行,只把临时目录替换成
~/docker_lab/fde_deploy。
$ git init -b main
Initialized empty Git repository in ~/docker_lab/fde_deploy/.git/
$ git config user.name "FDE Lab"
$ git config user.email "fde@example.com"
$ git config core.autocrlf false
$ git add -A
$ git commit -m "feat: 首次可交付版本(api + mysql + nginx)"
[main (root-commit) a312758] feat: 首次可交付版本(api + mysql + nginx)
3 files changed, 19 insertions(+)
create mode 100644 app/main.py
create mode 100644 docker-compose.yml
create mode 100644 nginx/nginx.conf
$ git tag -a v1.0 -m "首次交付:冒烟 5 项通过"
$ git add -A
$ git commit -m "feat: 新增 /devices/hot 高温设备接口"
[main 973635c] feat: 新增 /devices/hot 高温设备接口
1 file changed, 4 insertions(+)
$ git tag -a v1.1 -m "v1.1:加高温接口,冒烟通过"
$ git add -A
$ git commit -m "chore: 调整 upstream 端口"
[main fc4e9b3] chore: 调整 upstream 端口
1 file changed, 1 insertion(+), 1 deletion(-)
$ git tag -a v1.2 -m "v1.2:端口调整(有问题)"
$ git tag -n
v1.0 首次交付:冒烟 5 项通过
v1.1 v1.1:加高温接口,冒烟通过
v1.2 v1.2:端口调整(有问题)
$ git log --oneline --decorate
fc4e9b3 (HEAD -> main, tag: v1.2) chore: 调整 upstream 端口
973635c (tag: v1.1) feat: 新增 /devices/hot 高温设备接口
a312758 (tag: v1.0) feat: 首次可交付版本(api + mysql + nginx)
$ git diff v1.1 v1.2 -- nginx/nginx.conf
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
index 4db6685..6f70b70 100644
--- a/nginx/nginx.conf
+++ b/nginx/nginx.conf
@@ -1,5 +1,5 @@
upstream fde_backend {
- server api:8000;
+ server api:8001;
}
server {
listen 80;
$ git switch --detach v1.1
HEAD is now at 973635c feat: 新增 /devices/hot 高温设备接口
$ git log --oneline -1
973635c feat: 新增 /devices/hot 高温设备接口
$ git switch main
Previous HEAD position was 973635c feat: 新增 /devices/hot 高温设备接口
Switched to branch 'main'
7.2 三个可用的动作
① 用 diff 定位"上一版和这一版差了什么"
git diff v1.1 v1.2 -- nginx/nginx.conf
输出就三行:- server api:8000; / + server api:8001;
★ 一个字符的笔误,在 diff 里无处可藏。
这就是"打 tag"的真正价值:它给"比较"提供了两端。
② 回滚到某个 tag(只读部署)
git switch --detach v1.1
docker compose up -d --build
★ detached HEAD 是"只读部署"的正确姿势:
看代码、构建、跑服务都行;要改就必须开新分支。
别在 detached 状态直接 commit ------ 那些提交不属于任何分支,
切走后很容易丢(虽然 reflog 还在)。
③ 回滚镜像(不改代码)
docker tag fde-api:1.1 fde-api:current
docker compose up -d api
7.3 RTO 与损失:检测才是大头
# rollback_drill.py ------ 上线演练:检测时长才是决定损失的那一项
from __future__ import annotations
DOWNTIME_COST_PER_HOUR = 4000 # 制造业停机损失口径(与第四阶段 ROI 一致)
PER_MIN = DOWNTIME_COST_PER_HOUR / 60
SCENARIOS = [
# 名称, 是否冒烟, 是否打 tag, 检测(min), 回滚(min), 是否已对外
("A 冒烟 + tag(推荐)", True, True, 1.3, 2.5, False),
("B 冒烟,但没打 tag", True, False, 1.3, 6.0, False),
("C 没冒烟,客户先发现", False, True, 12.0, 3.0, True),
("D 没冒烟也没 tag", False, False, 12.0, 7.5, True),
]
def main() -> None:
print(f"口径:停机成本 {DOWNTIME_COST_PER_HOUR} 元/小时 = {PER_MIN:.1f} 元/分钟\n")
print(f"{'场景':<26}{'检测':>7}{'回滚':>7}{'RTO':>7}{'对外影响':>9}{'损失(元)':>10}")
rows = []
for name, smoke, tag, detect, rollback, exposed in SCENARIOS:
rto = detect + rollback
impact = rto if exposed else 0.0
loss = impact * PER_MIN
rows.append((name, detect, rollback, rto, impact, loss))
print(f"{name:<26}{detect:>6.1f}m{rollback:>6.1f}m{rto:>6.1f}m{impact:>8.1f}m{loss:>10.0f}")
best, worst = rows[0], rows[-1]
print(f"\n最好与最差相差:RTO {worst[3] - best[3]:.1f} 分钟,"
f"损失 {worst[5] - best[5]:.0f} 元")
print("\n把差距拆开看:")
print(f" · 冒烟测试(3 秒)带来的是检测时间:{rows[2][1]:.1f} → {rows[0][1]:.1f} 分钟,"
f"差 {rows[2][1] - rows[0][1]:.1f} 分钟")
print(f" · 镜像 tag 带来的是回滚时间:{rows[1][2]:.1f} → {rows[0][2]:.1f} 分钟,"
f"差 {rows[1][2] - rows[0][2]:.1f} 分钟")
print(" ★ 检测的杠杆远大于回滚 ------ 所以先做冒烟,再谈自动化回滚。")
print("\nC 场景的时间线(客户先发现):")
for t, what in [("T+0", "up -d 完成,公告"已上线""),
("T+1", "第一批用户遇到 502(页面白屏)"),
("T+6", "第二个用户打电话给对接人"),
("T+12", "你们接到通知,开始看 logs"),
("T+13", "定位:proxy_pass 指向 api:8001(笔误)"),
("T+15", "回滚到上一版镜像,恢复")]:
print(f" {t:<6}{what}")
print(f"\n ★ 15 分钟里,真正"修"的时间只有 3 分钟;12 分钟在等有人发现。")
print(f" ★ 按 {DOWNTIME_COST_PER_HOUR} 元/小时算,这 15 分钟 = {15 * PER_MIN:.0f} 元。")
print(" 而这 15 分钟本来可以被一条 curl 断言拦住。")
if __name__ == "__main__":
main()
【实跑】输出
口径:停机成本 4000 元/小时 = 66.7 元/分钟
场景 检测 回滚 RTO 对外影响 损失(元)
A 冒烟 + tag(推荐) 1.3m 2.5m 3.8m 0.0m 0
B 冒烟,但没打 tag 1.3m 6.0m 7.3m 0.0m 0
C 没冒烟,客户先发现 12.0m 3.0m 15.0m 15.0m 1000
D 没冒烟也没 tag 12.0m 7.5m 19.5m 19.5m 1300
最好与最差相差:RTO 15.7 分钟,损失 1300 元
把差距拆开看:
· 冒烟测试(3 秒)带来的是检测时间:12.0 → 1.3 分钟,差 10.7 分钟
· 镜像 tag 带来的是回滚时间:6.0 → 2.5 分钟,差 3.5 分钟
★ 检测的杠杆远大于回滚 ------ 所以先做冒烟,再谈自动化回滚。
C 场景的时间线(客户先发现):
T+0 up -d 完成,公告"已上线"
T+1 第一批用户遇到 502(页面白屏)
T+6 第二个用户打电话给对接人
T+12 你们接到通知,开始看 logs
T+13 定位:proxy_pass 指向 api:8001(笔误)
T+15 回滚到上一版镜像,恢复
★ 15 分钟里,真正"修"的时间只有 3 分钟;12 分钟在等有人发现。
★ 按 4000 元/小时算,这 15 分钟 = 1000 元。
而这 15 分钟本来可以被一条 curl 断言拦住。
7.4 这张表最该记住的一行
冒烟测试(3 秒)带来的是检测时间:12.0 → 1.3 分钟,差 10.7 分钟
镜像 tag 带来的是回滚时间:6.0 → 2.5 分钟,差 3.5 分钟
★ 检测的杠杆是回滚的 3 倍。所以顺序是:
先做冒烟(3 秒),再谈自动回滚(几小时)。
📖 八、交付物清单:签收单上该有 9 样
| # | 交付物 | 客户拿它干什么 | 缺了会怎样 |
|---|---|---|---|
| 1 | 源码仓库(含 tag) | 接手、审计、二次开发 | 只能口头交接 |
| 2 | 镜像清单(name:tag + digest) | 校验部署的是同一个东西 | 无法证明一致性 |
| 3 | docker-compose.yml + .env.example |
一键重建 | 重建靠回忆 |
| 4 | nginx.conf |
改域名/证书/限流 | 每次改都找你 |
| 5 | db/init.sql + 迁移脚本 |
建库、升级 | 升级必停机 |
| 6 | Runbook(启动/停止/备份/回滚/排障) |
值班能上手 | 你 7×24 |
| 7 | 验收记录(冒烟输出 + 门禁结果) | 签字依据 | 验收扯皮 |
| 8 | 监控与告警配置 | 出事能发现 | 客户先发现 |
| 9 | 培训记录(1 小时 + 录像) | 知识转移 | 人走系统死 |
★ 9 样里,只有第 1、3 是"代码",其余 7 样是"让别人能接手"。
交付的本质不是交代码,是交"可维护性"。
📖 九、本周知识地图
┌─────────────────────────────┐
│ 用户 / 浏览器 │
└──────────────┬──────────────┘
│ 80(唯一入口)
┌──────────────▼──────────────┐
│ nginx(Day 44) │
│ upstream + 4 个转发头 │
│ timeout / buffering │
└──────────────┬──────────────┘
│ 服务名 api:8000(内部网络)
┌──────────────▼──────────────┐
│ api(Day 42 的镜像) │
│ 非 root / HEALTHCHECK │
│ wait_for_db 重试 │
└──────────────┬──────────────┘
│ 服务名 db:3306
┌──────────────▼──────────────┐
│ db(MySQL 8) │
│ healthcheck + start_period │
│ 命名卷 db_data │
└─────────────────────────────┘
编排:docker-compose.yml(Day 43)------ 服务名、依赖、卷、限额、日志
历史:Git(Day 44)------ 提交、分支、tag、reflog
底座:容器(Day 41)------ 镜像/容器、端口、卷、生命周期
9.1 四天接力
| 天 | 解决什么 | 一句话判据 |
|---|---|---|
| 41 | 环境一致 | 换台机器,docker run 就能起 |
| 42 | 打包自己的应用 | 改一行代码,重建 <1 秒 |
| 43 | 多容器一起起 | 一条 up,三个容器互相认得出 |
| 44 | 一个入口 + 一条历史 | 用户不记端口;你能说出上周改了哪行 |
| 45 | 敢交给别人 | 别人照 Runbook 能重建 |
★ 五天其实只在回答一个问题:
"换一个人、换一台机器,这套系统还能不能跑起来、改得动、回得去?"
📋 十、本周验收:16 项
| # | 验收项 | 达标标准 | 自评 |
|---|---|---|---|
| 1 | 容器基础 | 能解释容器 vs VM、镜像 vs 容器 | ☐ |
| 2 | 生命周期 | run/ps/stop/rm/exec/logs 不看笔记 | ☐ |
| 3 | 端口与卷 | 说清 -p 方向、数据为何持久 |
☐ |
| 4 | Dockerfile | 独立写出并 build 成功 | ☐ |
| 5 | 层缓存 | 说清"先拷 requirements"并验证 | ☐ |
| 6 | .dockerignore | 列出必须排除的文件与理由 | ☐ |
| 7 | Compose | yml 一键起 api+db+nginx | ☐ |
| 8 | 服务发现 | 容器互访用服务名:容器端口 | ☐ |
| 9 | 就绪依赖 | healthcheck + condition 解决顺序 | ☐ |
| 10 | 数据持久化 | down 后数据在,会 mysqldump |
☐ |
| 11 | Nginx | 80 转发到 API,改完 -t + reload |
☐ |
| 12 | 安全姿态 | api 不暴露、.env 不进 Git/镜像 |
☐ |
| 13 | Git 基础 | init/add/commit/log/status 全流程 | ☐ |
| 14 | 分支 | 开 feature 分支并合并 | ☐ |
| 15 | 远程与冲突 | push/pull,手动解冲突 | ☐ |
| 16 | 更新与回滚 | 重新部署 + 用 tag 回滚 | ☐ |
★ 16 项里能独立做到 13 项 = 扎实通过。
卡住的项,回去把那天的手工命令再敲三遍 ------ 容器化是手上功夫。
★ 加分项(不在 16 项里,但决定你能不能交付):
能跑通 28 项门禁、能写 5 条冒烟、能做一次回滚演练。
❌ 十一、跨四天 12 坑
| # | 坑 | 出自 | 症状 |
|---|---|---|---|
| 1 | 服务监听 127.0.0.1 | 41 | 容器 Up,宿主机连不上 |
| 2 | 端口方向写反 | 41 | -p 80:8080,访问不通 |
| 3 | 忘了挂卷 | 41 | 删容器数据全没 |
| 4 | COPY 在 pip 之前 | 42 | 改一行代码重装 42 秒 |
| 5 | CMD 用 shell 形式 | 42 | stop 等 10 秒,退出码 137 |
| 6 | 明文口令进 ENV | 42 | docker history 一眼看到 |
| 7 | depends_on 短语法 | 43 | 库没就绪,api 崩溃循环 |
| 8 | 3306 发布到 0.0.0.0 | 43 | 安全扫描高危 |
| 9 | deploy.resources 限不住 |
43 | 内存照样吃满 |
| 10 | proxy_pass 写 127.0.0.1 | 44 | 502 |
| 11 | 尾斜杠不对 | 44 | //devices 404 |
| 12 | 没传 Host / XFF | 44 | 重定向错、日志没真实 IP |
★ 12 项里 9 项的共同点:它们都"能跑",只是"跑得不对"。
所以光看 `docker ps` 全是 Up 是不够的 ------ 要冒烟。
11.1 新手五问:这些现象到底为什么
问 1:up -d --build 都做了,为什么还要打 tag?
两者锚的不是同一样东西:
git tag → 锚【代码】(哪个 commit)
镜像 tag → 锚【产物】(哪个 build)
★ 出事时要能双向查:"v1.2 镜像" 对应 "git sha abc123"。
只打一个,另一半就要靠猜。
问 2:冒烟失败,脚本里为什么要 exit 1?
CI/流水线只看【退出码】:0 = 成功,非 0 = 失败。
你 print 了 "FAIL" 但退出码是 0,流水线照样绿 ------ 等于没测。
★ 所以最后一行必须是 sys.exit(1 if failed else 0)。
问 3:死信队列为什么必须告警?
因为 ACK 已经返回 200,平台认为投递成功,【永远不会再投】。
死信是你唯一能发现"处理失败"的地方。
★ 判据:DLQ > 0 持续 5 分钟 → 告警。不告警 = 静默丢失。
问 4:出故障为什么不先修,而是先回滚?
两条线要分开:
恢复线(MTTR):切回上一个好版本,2~3 分钟,客户先不痛
修复线(MTTF):慢慢定位、修、测、审
★ 在生产上"边修边赌",等于让客户陪你做实验。
修好后重新走一遍七阶段,那才是"修复完成"。
问 5:Runbook 写完很详细,为什么还要照着跑?
因为文档会过期:端口、路径、参数、依赖都可能在变。
眼睛读文档时会自动"脑补"成对的 ------ 读不出过期。
跑一遍等于给文档做冒烟:哪步报错,哪步就是文档的 bug。
★ 本课验收第 10 条要求的就是这件事。
📊 十二、速查表:四天一张
12.1 命令
| 目的 | 命令 |
|---|---|
| 起整套 | docker compose up -d --build |
| 只看状态 | docker compose ps |
| 变量对不对 | docker compose config |
| 进容器 | docker compose exec api sh |
| 备份 | docker compose exec -T db mysqldump ... > backup.sql |
| 打 tag | git tag -a v1.1 -m "..." |
| 看差了什么 | git diff v1.1 v1.2 -- path |
| 回滚 | git switch --detach v1.1 && compose up -d --build |
| 捞回误删提交 | git reflog → reset --hard HEAD@{n} |
12.2 状态码
502 连不上(地址错/没起) 504 太慢(业务慢/超时短)
413 太大(body size) 404 路径错(尾斜杠)
499 客户端先走 503 依赖不可用(库连不上)
12.3 交付前三问
① 口令有没有进过镜像或 Git? → docker history / git log -p
② 数据库端口对外了吗? → compose ps 的 PORTS 列
③ 出事了怎么回? → 有没有 tag、Runbook 写没写
💬 十三、客户会怎么问(8 问 8 答)
Q1:这套东西部署要多久?
首次约 80 秒 (含拉镜像),日常更新约 27 秒 。前提是依赖不变------变了要重装,约 50 秒。
Q2:你们的服务挂了,我们怎么知道?
三层:容器
restart: unless-stopped自动拉起、HEALTHCHECK标不健康、nginx 返回 502。但我们建议加告警,否则只能等用户反馈。
Q3:数据在哪?删容器会不会丢?
在命名卷
db_data里,down不删卷 。备份是mysqldump | gzip,恢复演练做过一次,记录在 Runbook。
Q4:能不能只更新 api,不动数据库?
能。
docker compose up -d --build api只重建 api,db 和 nginx 不动,卷不受影响。
Q5:回滚要多久?
有 tag + 镜像:约 2.5 分钟(切 tag + 重建 + 冒烟)。没 tag 要重建旧代码,约 6 分钟。
Q6:为什么只开 80,不给 8000?
8000 直连等于绕过 nginx ------ 限流、证书、日志全部失效。要调试可以 SSH 隧道,不要开口子。
Q7:你们走了,我们自己能维护吗?
能,前提是 9 样交付物齐:仓库、镜像清单、compose、nginx 配置、初始化脚本、Runbook、验收记录、告警配置、培训录像。
Q8:为什么日志只留 30MB?
单服务 10MB × 3 个文件,够看最近几小时 。长期日志要么接你们的 ELK,要么落到对象存储。不限会写满磁盘。
✅ 十四、今日交付物与自检
| # | 交付物 | 验收标准 |
|---|---|---|
| 1 | delivery_gate.py |
三份 11 / 21 / 28 |
| 2 | pipeline_run.py |
79.8s / 26.8s / 11 分钟 |
| 3 | smoke.py |
好版本 5/5 exit 0,坏版本 2/5 exit 1 |
| 4 | rollback_drill.py |
A 3.8m / C 15m / 1000 元 |
| 5 | 真实 tag 记录 | git tag -n 三个 tag + 一次 diff 定位 |
| 6 | docs/runbook.md |
启动/停止/备份/回滚/排障五节 |
| 7 | docs/acceptance.md |
门禁结果 + 冒烟输出 + 签字位 |
□ 1. 门禁 28 项能跑,知道自己缺哪几项
□ 2. 冒烟 5 条能跑,失败时 exit 非 0
□ 3. 说得出七阶段的顺序与产物
□ 4. 说得出 F1/F2/F3 三种失败怎么定位
□ 5. 打过至少一个 annotated tag
□ 6. 会用 git diff 两个 tag 定位改动
□ 7. 知道 detached HEAD 是只读部署姿势
□ 8. 算得出"多发 15 分钟"的损失
□ 9. 九样交付物能列全
□ 10. Runbook 里的命令自己照着跑过一遍
🧠 第 10 条最容易自欺 :Runbook 写完不照着跑,等于没写。照着跑一遍,你会发现至少两处命令是错的。
📝 十五、本周小结
| # | 知识点 | 一句话 |
|---|---|---|
| 1 | 七阶段 | 克隆→配置→构建→起服务→等健康→冒烟→打 tag |
| 2 | 门禁 | 28 项静态检查,放 stage 0 最省返工 |
| 3 | 三类缺口 | 安全 / 稳定 / 可运维 |
| 4 | 耗时 | 首次 80s,日常 27s |
| 5 | 三种失败 | 配置、端口、健康检查 |
| 6 | 冒烟 | 5 条覆盖链路/依赖/数据/业务 |
| 7 | tag | 给"比较"提供两端 |
| 8 | 回滚 | detached HEAD 只读部署 |
| 9 | RTO | 检测时间 >> 修复时间 |
| 10 | 成本 | 15 分钟 = 1000 元(4000 元/h 口径) |
| 11 | 交付物 | 9 样,7 样与"接手"有关 |
| 12 | 本质 | 交的是可维护性,不是代码 |
🧠 核心认知 :交付不是"我这边跑通了",是"你那边能重建、能改、能回"。
三天前的你:docker run 起来就算成功
今天的你:门禁全绿、冒烟全过、tag 打好、Runbook 跑过一遍
★ 差别不在技术,在"有没有想过别人接手的那一天"。
📋 十六、课后练习
练习 1:给你的项目跑门禁(约 50 分钟)
-
跑
delivery_gate.py,把未通过项抄成清单 -
按"安全 / 稳定 / 可运维"分三类
-
每类挑一项修掉,重跑看分数
-
把剩下没修的写进
docs/risk.md,写上"为什么暂时不修"
练习 2:写你自己的 5 条冒烟(约 40 分钟)
-
照着
smoke.py改路径与断言,换成你的业务接口 -
故意把一条改错(比如期望 200 实际 404),确认脚本 exit 1
-
接进
deploy.sh,失败就中止
练习 3:完整交付演练(约 90 分钟)
-
找一台干净机器(或新目录 +
down -v) -
从
git clone开始,严格照 Runbook 走 -
记录每一步耗时与卡点
-
至少制造一次失败(改错端口),用 Runbook 的排障节定位
验收:能在 10 分钟内从零到冒烟通过。
练习 4:回滚计时(约 30 分钟)
-
打两个 tag,切到新版本
-
故意让新版本冒烟失败
-
计时:从发现问题到回滚完成
-
对比 §七 的 2.5 分钟,差在哪一步
🧪 十七、小白自检:12 个问题
□ 1. 验收/冒烟/门禁/回滚/Runbook/签收,分别是谁的动作
□ 2. 七阶段的顺序为什么不能颠倒(用"反馈越早越便宜"说清)
□ 3. 门禁为什么放 stage 0,而不是上线前
□ 4. 冒烟 5 条断言各拦住哪类故障
□ 5. git tag 和镜像 tag 各锚什么,为什么两个都要
□ 6. 冒烟脚本为什么必须 exit 1
□ 7. 死信队列为什么必须告警
□ 8. 出故障先回滚还是先修,为什么
□ 9. Runbook 为什么要照着跑一遍
□ 10. 28 项门禁里,"安全/稳定/可运维"三类各举一项
□ 11. 交付 9 样东西里,哪 7 样和"接手"有关
□ 12. 你能不能在 10 分钟内从零做到冒烟通过
参考答案在哪:
| 问题 | 对应小节 |
|---|---|
| 1 | §零 0.1 |
| 2-3 | §零 0.2、0.3、§四 4.3 |
| 4 | §零 0.4、§六 6.4 |
| 5 | §零 0.5、§七 7.2 |
| 6-8 | §六 6.5、§七 7.3、§十一 11.1 |
| 9 | §零 0.6、§十四 |
| 10-11 | §四 4.3、§八 |
| 12 | §零 0.7 |
🔭 十八、下节预告:第 6 周,让系统和客户系统对话
第 5 周把"自己这套"跑通了。下一步是和外部系统对接------这是 FDE 现场最真实的工作:
| 主题 | 你会做的事 |
|---|---|
| RESTful 与认证 | 给你的 API 加上 JWT / API Key,别人不能乱调 |
| 企业 IM 对接 | 调飞书/钉钉开放 API:取 token、发消息、读通讯录 |
| Webhook | 接收客户系统的事件:验签、幂等、重试 |
| 数据映射 | 两边字段对不上时,做"翻译层" |
| 测试与文档 | pytest + OpenAPI,让接口可被信任 |
第 6 周结束,第二阶段就完整了:写得出、存得下、管得住、交得了、连得上。
🌍附录:前置课程列表
阶段一:认知启蒙(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博客
Docker
【FDE系列】阶段2:Day 41:Docker 入门 --- 把环境装进盒子-CSDN博客
【FDE系列】阶段2:Day 41(up详细版):Docker 入门 --- 把环境装进盒子-CSDN博客
【FDE系列】阶段2:Day 42:Dockerfile 实战 --- 把你的应用打包成镜像-CSDN博客
【FDE系列】阶段2:Day 42(UP详细版):Dockerfile 实战 --- 把你的应用打包成镜像-CSDN博客
【FDE系列】阶段2:Day 43:Docker Compose --- 多容器一键编排-CSDN博客
【FDE系列】阶段2:Day 43(UP详细版):Docker Compose --- 多容器一键编排-CSDN博客