【FDE系列】阶段2:Day 45(up详细版):综合实战 — Docker + Nginx + Git 完整部署与本周收官

📚前言

📒FDE系列内容总纲:

【大纲】FDE 前沿部署工程师学习系列教程-CSDN博客

🚄前置课程列表:

见文档结尾附录。


🚀阶段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 分钟)

  1. 跑 delivery_gate.py,把未通过项抄成清单

  2. 按"安全 / 稳定 / 可运维"分三类

  3. 每类挑一项修掉,重跑看分数

  4. 把剩下没修的写进 docs/risk.md,写上"为什么暂时不修"

练习 2:写你自己的 5 条冒烟(约 40 分钟)

  1. 照着 smoke.py 改路径与断言,换成你的业务接口

  2. 故意把一条改错(比如期望 200 实际 404),确认脚本 exit 1

  3. 接进 deploy.sh,失败就中止

练习 3:完整交付演练(约 90 分钟)

  1. 找一台干净机器(或新目录 + down -v)

  2. 从 git clone 开始,严格照 Runbook 走

  3. 记录每一步耗时与卡点

  4. 至少制造一次失败(改错端口),用 Runbook 的排障节定位

验收:能在 10 分钟内从零到冒烟通过。

练习 4:回滚计时(约 30 分钟)

  1. 打两个 tag,切到新版本

  2. 故意让新版本冒烟失败

  3. 计时:从发现问题到回滚完成

  4. 对比 §七 的 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博客

【FDE系列】阶段2:Day 44:Nginx 反向代理 + Git 版本控制-CSDN博客

【FDE系列】阶段2:Day 44(UP详细版):Nginx 反向代理 + Git 版本控制-CSDN博客

相关推荐
jyOverQ3 小时前
Git rebase 和 merge 有什么区别?什么时候该用哪个?
git
进击的横打3 小时前
【人工智能】AI将推翻了传统软件开发吗
人工智能
银河麒麟操作系统3 小时前
两案例入选全球计算联盟年度产业应用案例汇编
大数据·汇编·人工智能
狂师3 小时前
GitHub上开源真机测试工具mobile-mcp,说句话就能让AI在手机上跑用例!
人工智能·开源·测试
云飞云共享云桌面3 小时前
智能装备工厂研发部门多设计师共享一台SolidWorks服务器做设备装配建模
运维·服务器·网络·人工智能·3d·自动化·制造
skywalk81633 小时前
hive —— 多机 AI Agent 协同的任务总线:分布式,多节点同时运行,保障不会宕机
人工智能·hive·分布式
晗夜未央3 小时前
跨仓库 Skill 换台电脑就失效?用 Git remote 校验项目
人工智能·git·python
Raas1003 小时前
MAI Gateway实战部署:AI网关怎么部署?从0到1上手指南
人工智能·gateway·ai网关·企业级ai网关·企业级产品·ai技术交流
tellmewhoisi3 小时前
深度学习26:PyTorch:参数更新方法(Momentum)
人工智能·pytorch·深度学习
搭贝3 小时前
【无标题】
人工智能·低代码·ai·1024程序员节·搭贝