【FDE系列】阶段2:Day 43(UP详细版):Docker Compose — 多容器一键编排

📚前言

📒FDE系列内容总纲:

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

🚄前置课程列表:

见文档结尾附录。


🚀阶段2·Day 43(UP详细版):Docker Compose --- 多容器一键编排

FDE 学习系列教程 · 第二阶段 · 第 5 周 · Day 3 预计时长:5 小时(可分两次)| 难度:★★★★☆ | 前置知识:Day 41(生命周期、端口、卷)、Day 42(Dockerfile、健康检查)
📌 一句话目标 :把"三条 docker run 手工命令"变成一份能过交付检查的 docker-compose.yml ------ 服务名互访、就绪判断、数据持久、只暴露一个端口。


⏱️ 这 6 小时怎么用(§零 可先单独读 35 分钟)

时段 内容 这一段结束时你手上应该有
0:00-0:20 开场:三台机器三种失败 知道要解决什么
0:20-0:55 §零 地基 + 手把手 60 秒 能答出"up 时发生了什么"
0:55-1:15 §一 从 run 到 compose 一张参数对照表
1:15-1:50 §二 YAML 子集 + 四种键值对 知道缩进在表示什么
1:50-2:20 §三 三个文件 main.py(带重试)/ init.sql / Dockerfile
2:20-3:10 §四 三版 compose + 实跑 lint 0 / 2 / 100 的差距
3:10-3:40 §五 服务名即主机名 + 实跑矩阵 可达性一眼看清
3:40-4:20 §六 depends_on 真相 + 实跑 6 策略 选哪套启动策略
4:20-4:50 §七 卷与初始化 + §八 环境变量 数据不丢、密钥不进 Git
4:50-5:30 §九 日志与资源 + §十 命令逐参数与排障 交付前自查
5:30-6:00 §十一~§十三 更新回滚 + 边界 + 新手五问 交付前自查

🧑‍🤝‍🧑 开场:三台机器,三种失败

你用手工命令在客户 A 的服务器上把 API + MySQL 跑起来了。到了客户 B:

复制代码
  失败一:忘了建网络
    docker run --name api ...             # 没加 --network
    → api 里连 db 报 "Name or service not known"

  失败二:顺序对了,但数据库还没初始化完
    → api 反复重启 4 次,客户看到 5xx

  失败三(最严重):为了 curl 方便,把 3306 也发布出去了
    docker run -p 3306:3306 mysql
    → 两周后客户安全扫描报告:数据库端口对公网开放

  ★ 三次失败,没有一次是"代码写错了"。
    全是"环境怎么搭"没有记录下来 ------ 它活在你的记忆里,而记忆不可复制。

Compose 干的事只有一件:把"这套环境长什么样"写成文件。

复制代码
  手工 docker run                    docker-compose.yml
  ─────────────────                  ──────────────────
  参数记在脑子里                     写进文件,进 Git
  顺序靠人保证                       声明 depends_on
  网络靠手建                         自动建,自动接
  换了机器重来一遍                   文件拷过去,up 一下
  谁改了什么不知道                   git log 里有

📖 零、先弄明白六件事(零基础必读)

这一节不引入新命令,只回答一个问题:敲下 docker compose up -d 之后,机器上发生了什么。 这六个问题想通了,后面 19 节都是从这里展开的。

0.1 先认识六个词

术语 大白话 类比
编排(orchestration) 把"一组容器 + 网络 + 卷"当作一个整体来管理 一次装修,不是一件家具
项目(project) 同一份 compose 文件管起来的那一套 这套装修本身
服务(service) yml 里的一个条目,如 api: 户型图里的一个房间
容器 服务跑起来的实例 住在房间里的人
网络 Compose 自动建的虚拟局域网 楼道,房间之间靠门牌互通
卷(volume) 存数据的"仓库",容器死了它还在 储藏室
复制代码
  ★ 一句话:compose 文件描述"要什么",Compose 负责"怎么建"。
    这叫声明式 ------ 你不写步骤,你写结果。

0.2 docker compose up -d 背后的七步

复制代码
  ① 读 docker-compose.yml,解析成内部对象(缩进错了就在这报错)
  ② 校验:镜像名、变量替换(${DB_PASSWORD})、依赖关系成不成环
  ③ 建网络:<项目名>_default(例如 fde_stack_default)
  ④ 建卷:db_data → 实际名字 fde_stack_db_data
  ⑤ 按依赖排序:先 db,再 api(healthcheck 通过后),最后 nginx
  ⑥ 逐个创建容器并接入网络
  ⑦ 启动,打印日志,退出(-d = 后台)

0.3 声明式 vs 过程式:为什么"文件"比"命令"可靠

复制代码
  过程式(docker run):
    你敲 5 条命令 → 顺序错了、漏一条、换台机器忘了 → 环境就坏了

  声明式(compose):
    文件写清"最终要什么样" → 机器负责补齐差距
    再跑一次 up,它只会做【还没做】的部分(幂等)

  ★ 判据:这套环境能不能"换个新人、换台机器,照着文件重建"?
    能 → 你写的是声明式;不能 → 你只是敲了一堆命令。

0.4 "项目"是什么:目录名决定一切

复制代码
  compose 会给所有资源加前缀,前缀 = 项目名(默认 = 目录名):

  /home/you/fde_stack/docker-compose.yml
       │
       └─ 项目名 fde_stack
            ├─ 网络  fde_stack_default
            ├─ 卷    fde_stack_db_data
            └─ 容器  fde-stack-api-1 / fde-stack-db-1 / fde-stack-nginx-1

  ★ 这解释了两件事:
    ① `docker compose down` 为什么能一键清干净 ------ 它按前缀找
    ② 两个目录同名会互相覆盖资源 ------ 换个目录名,或者用 -p 指定项目名

0.5 up 的输出逐行读

复制代码
[+] Running 4/4
 ✔ Network fde_stack_default      Created   ← 第 ③ 步
 ✔ Volume "fde_stack_db_data"     Created   ← 第 ④ 步
 ✔ Container fde-stack-db-1       Started   ← 第 ⑤ 步:依赖在前
 ✔ Container fde-stack-api-1      Started   ← 依赖在后

  ★ Created ≠ Started:
    Created = 对象已建好;Started = 进程已拉起。
    如果某行停在 Created,说明它还没启动 ------ 去看它依赖的服务。

0.6 手把手 60 秒:你的第一份 compose

复制代码
mkdir -p ~/demo && cd ~/demo
cat > docker-compose.yml <<'EOF'
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
EOF
docker compose up -d
docker compose ps
docker compose down

  ps 的输出逐列读:
  NAME        IMAGE         COMMAND  SERVICE  CREATED  STATUS        PORTS
  demo-web-1  nginx:alpine  ...      web      ...      Up 3 seconds  0.0.0.0:8080->80/tcp
  └ 容器名(项目-服务-序号)                └ 服务名   └ 端口映射(宿主→容器)

  ★ 注意容器名不是 web:默认命名是 <项目>-<服务>-<序号>,
    序号支持 --scale web=3 时变成 -2、-3。

这一节的一句话总结:

compose = 一份"目标状态"的说明书;up 负责把现实补齐成说明书的样子,down 负责按前缀清理。


📖 一、从 docker run 到 compose:一张对照表

docker run compose 注意
nginx:alpine image: nginx:alpine 不写 tag 等于 latest
--name demo container_name: demo 写死会妨碍扩容,一般不写
-p 8080:80 ports: ["8080:80"] 字符串,别丢引号
-v db_data:/var/lib/mysql volumes: [db_data:/var/lib/mysql] 命名卷要在顶层声明
-e KEY=VALUE environment: {KEY: VALUE} 明文口令会被 lint 抓
--env-file .env env_file: .env 或直接 ${VAR} 引用
--network app-net networks: [app-net] 不写就用默认网络
--restart unless-stopped restart: unless-stopped 交付必写
--health-cmd ... healthcheck: test: [...] 配合 condition
先 run db 再 run api depends_on: 只管顺序,见 §六

1.1 一个原则

复制代码
  compose 文件是"这套环境的唯一事实来源":
    · 不在文件里的东西,就是不存在的(重启后不会自己冒出来)
    · 文件里写的东西,任何机器上 up 出来都一样

  ★ 所以:手工 docker run 只用于一次性实验;
    凡是"要再跑一次"的,都该进 compose。

🖥️ 二、YAML 子集:缩进到底在表示什么

2.1 三条规矩

复制代码
services:                 # ① 键后面必须有冒号 + 空格
  api:                    # ② 缩进表示层级(2 空格,禁止 Tab)
    ports:                # ③ 列表用 "- " 开头
      - "8000:8000"

2.2 最常见的三个 YAML 错误

写法 问题
ports:8000:8000 冒号后没空格 → 整行被当成一个键
Tab 缩进 解析器直接报错,且肉眼看不出来
- 8000:8000(不加引号) YAML 会解析成 8000 与 8000 的映射,可能报错

2.3 【实跑】看缩进被解析成什么

下面这段输出来自本文的 compose_lint.py(它自带一个 60 行的 YAML 子集解析器,只用标准库):

复制代码
① 先看 YAML 被解析成了什么(缩进到底表示什么)
  顶层键:['services', 'volumes']
  服务:['api', 'db']
  db.ports = ['3306:3306']
  db.healthcheck.test = ['CMD', 'mysqladmin', 'ping', '-h', 'localhost']
  api.depends_on = {'db': {'condition': 'service_healthy'}}
  volumes(顶层声明)= {'db_data': {}}

  ★ 读法:
    · 顶层只有两个键 → services 和 volumes
    · services 下面两个服务 → api、db
    · db.ports 是【列表】,因为用了 "- "
    · api.depends_on 是【映射】,因为下面还有 condition 一层

  同样是 depends_on,写成 "- db" 是列表,写成 "db: {condition: ...}" 是映射 ------
  这两种写法的语义完全不同(§六 会用量化数据说明)。

2.4 四种容易写错的键值对

① 端口必须加引号

复制代码
    ports:
      - "8080:80"     # ✅ 字符串
      - 8080:80       # ⚠️ YAML 1.1 会把 8080:80 解析成"六十进制数"

  ★ YAML 1.1 里 a:b 形似时间(60 进制),部分解析器会把它当数字,
    报错像 "mapping values are not allowed here"。加引号一劳永逸。

② 布尔陷阱:on / off / yes / no 不是字符串

复制代码
    environment:
      DEBUG: on        # 会被解析成 true,不是字符串 "on"
      MODE: "on"       # ✅ 想要字符串就加引号

③ 块式与行内式等价,选一种用到底

复制代码
    environment:            # 块式(推荐,易读)
      DB_HOST: db
    # 等价的行内式
    environment: {DB_HOST: db}

④ 多文档与锚点:先知道有这回事

复制代码
# 一个文件里用 --- 分隔多份配置(少见,知道即可)
---
services: {a: {}}
---
services: {b: {}}

  ★ 锚点(&base / <<: *base)能复用字段,但可读性骤降。
    新手阶段建议:宁可复制几行,也别上锚点。

🖥️ 三、先把三个文件备好

3.1 目录

复制代码
fde_stack/
├── main.py
├── requirements.txt
├── Dockerfile
├── .dockerignore
├── init.sql
├── .env                 ← 不进 Git
├── .env.example         ← 进 Git,给同事看要填什么
└── docker-compose.yml

3.2 main.py(带重试的版本)

原稿的 main.py 直接连库,库没起就抛异常。这里加一个 wait_for_db------它是 §六 里"策略 E"的实现:

复制代码
from fastapi import FastAPI
import os
import time
import mysql.connector

app = FastAPI(title="FDE API + MySQL", 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) -> tuple[bool, int]:
    """不退出进程,自己等数据库能连上。返回 (是否成功, 试了几次)。"""
    last = ""
    for i in range(tries):
        try:
            conn = get_db()
            conn.ping()
            conn.close()
            return True, i
        except Exception as e:
            last = str(e)
            time.sleep(gap)
    print(f"db not ready after {tries} tries: {last}", flush=True)
    return False, tries


@app.get("/")
def root():
    return {"msg": "FDE Stack", "services": ["api", "mysql"]}


@app.get("/health")
def health():
    try:
        conn = get_db()
        conn.ping()
        conn.close()
        return {"status": "ok", "db": "connected"}
    except Exception as e:
        return {"status": "degraded", "db": str(e)}


@app.get("/devices")
def list_devices():
    conn = get_db()
    cur = conn.cursor(dictionary=True)
    cur.execute("SELECT id, name, type, temperature FROM devices LIMIT 10")
    rows = cur.fetchall()
    cur.close()
    conn.close()
    return {"devices": rows, "count": len(rows)}


if __name__ == "__main__":
    ok, n = wait_for_db()
    print(f"db ready={ok} after {n} tries", flush=True)
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

本机未装 fastapi / mysql-connector-python,这段只做了 python -m py_compile 语法校验。 重点是 wait_for_db 的形状:失败不退出,睡 1 秒再试。

3.3 init.sql

复制代码
CREATE TABLE IF NOT EXISTS devices (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    type VARCHAR(20) NOT NULL,
    temperature DECIMAL(5,1) DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO devices (name, type, temperature) VALUES
('注塑机A1', '注塑', 65.0),
('注塑机A2', '注塑', 82.0),
('注塑机A3', '注塑', 91.0),
('冲压机B1', '冲压', 75.0),
('冲压机B2', '冲压', 88.0);

  ★ MySQL 官方镜像只在【数据目录为空】时才跑 initdb.d 里的脚本。
    也就是说:初始化只发生一次。第二次 up 不会再执行 ------ 这是好事(不会重复插数据),
    但你想改表结构时,改 init.sql 是没用的(§七 讲怎么办)。

3.4 Dockerfile(用 Day 42 的交付版)

复制代码
FROM python:3.12-slim
RUN useradd -r -u 10001 app
WORKDIR /app
COPY --chown=app:app requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=app:app main.py .
USER 10001
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
  CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health')"
CMD ["python", "main.py"]

🖥️ 四、三版 compose,实跑打分

4.1 第一版:naive(能跑)

复制代码
version: "3.8"
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DB_HOST: db
      DB_PASSWORD: root123
    depends_on:
      - db
  db:
    image: mysql
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: root123
      MYSQL_DATABASE: fde_db

4.2 第二版:教程版(就是原稿那份)

复制代码
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DB_HOST: db
      DB_PASSWORD: root123
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped
  db:
    image: mysql:8.0
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: root123
      MYSQL_DATABASE: fde_db
    volumes:
      - db_data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped
volumes:
  db_data:

4.3 第三版:交付版

复制代码
services:
  api:
    build: .
    environment:
      DB_HOST: db
      DB_PORT: "3306"
      DB_PASSWORD: ${DB_PASSWORD}
      DB_NAME: ${DB_NAME:-fde_db}
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: 10m
        max-file: "3"
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
      MYSQL_DATABASE: ${DB_NAME:-fde_db}
    volumes:
      - db_data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 20s
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: 10m
        max-file: "3"
volumes:
  db_data:

4.4 三版差在哪

项 naive 教程版 交付版
明文口令 有 有 ${DB_PASSWORD}
3306 发布 0.0.0.0 0.0.0.0 不发布
8000 发布 发布 发布 不发布(等 Day 44 的 nginx)
数据卷 无 有 有(:ro)
就绪依赖 短语法 service_healthy service_healthy
健康检查 无 5s/10 次 + start_period: 20s
日志轮转 无 无 10m × 3
得分 0 2 100

⚠️ 注意教程版只有 2 分。这不是说它"错",而是说:它是学习版,不是交付版。 能跑和能交付之间,就差 4 条具体配置(见下)。

4.5 compose_lint.py

复制代码
# compose_lint.py ------ 自己写个 YAML 子集解析器 + 16 条 Compose 检查规则
from __future__ import annotations

import re

NAIVE = """version: "3.8"
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DB_HOST: db
      DB_PASSWORD: root123
    depends_on:
      - db
  db:
    image: mysql
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: root123
      MYSQL_DATABASE: fde_db
"""

IMPROVED = """services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DB_HOST: db
      DB_PASSWORD: root123
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped
  db:
    image: mysql:8.0
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: root123
      MYSQL_DATABASE: fde_db
    volumes:
      - db_data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped
volumes:
  db_data:
"""

PROD = """services:
  api:
    build: .
    environment:
      DB_HOST: db
      DB_PORT: "3306"
      DB_PASSWORD: ${DB_PASSWORD}
      DB_NAME: ${DB_NAME:-fde_db}
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: 10m
        max-file: "3"
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
      MYSQL_DATABASE: ${DB_NAME:-fde_db}
    volumes:
      - db_data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 20s
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: 10m
        max-file: "3"
volumes:
  db_data:
"""

INTERNAL_PORTS = {"mysql": 3306, "postgres": 5432, "redis": 6379, "mongo": 27017}
SECRET_KEYS = ("PASSWORD", "PASSWD", "TOKEN", "SECRET", "API_KEY")


# ---------------- 迷你 YAML ----------------
def _scalar(s: str):
    s = s.strip()
    if s.startswith("[") and s.endswith("]"):
        return [_scalar(x) for x in s[1:-1].split(",") if x.strip()]
    if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
        return s[1:-1]
    if s in ("true", "false"):
        return s == "true"
    if s.isdigit():
        return int(s)
    return s


def parse_yaml(text: str) -> dict:
    rows = []
    for raw in text.splitlines():
        if not raw.strip() or raw.lstrip().startswith("#"):
            continue
        rows.append((len(raw) - len(raw.lstrip()), raw.strip()))

    def block(i: int, indent: int):
        if i >= len(rows):
            return {}, i
        is_list = rows[i][1].startswith("- ")
        out = [] if is_list else {}
        while i < len(rows) and rows[i][0] == indent and rows[i][1].startswith("- ") == is_list:
            content = rows[i][1]
            if is_list:
                item = content[2:].strip()
                m = re.match(r"^([A-Za-z_][\w.-]*):(\s.*|)$", item)
                if m:
                    key, val = m.group(1), m.group(2).strip()
                    node = {key: _scalar(val) if val else {}}
                    i += 1
                    if i < len(rows) and rows[i][0] > indent:
                        sub, i = block(i, rows[i][0])
                        if isinstance(node[key], dict) and isinstance(sub, dict):
                            node[key] = sub
                    out.append(node)
                else:
                    out.append(_scalar(item))
                    i += 1
            else:
                key, _, val = content.partition(":")
                key = key.strip()
                if val.strip():
                    out[key] = _scalar(val)
                    i += 1
                else:
                    i += 1
                    if i < len(rows) and rows[i][0] > indent:
                        sub, i = block(i, rows[i][0])
                        out[key] = sub
                    else:
                        out[key] = {}
        return out, i

    tree, _ = block(0, rows[0][0] if rows else 0)
    return tree if isinstance(tree, dict) else {}


def dur(v, default: float) -> float:
    if isinstance(v, int):
        return float(v)
    if isinstance(v, str):
        try:
            if v.endswith("ms"):
                return float(v[:-2]) / 1000
            if v.endswith("s"):
                return float(v[:-1])
            if v.endswith("m"):
                return float(v[:-1]) * 60
        except ValueError:
            pass
    return default


# ---------------- 规则 ----------------
def lint(text: str) -> list[tuple[str, str, str]]:
    cfg = parse_yaml(text)
    f: list[tuple[str, str, str]] = []

    def add(lv: str, code: str, msg: str) -> None:
        f.append((lv, code, msg))

    services = cfg.get("services") or {}
    if not services:
        add("ERROR", "C01", "没有 services 段,这不是一份 Compose 文件")
        return f

    if "version" in cfg:
        add("INFO", "C02", "顶层 version 在 Compose V2 已废弃,删掉更干净")

    declared = set((cfg.get("volumes") or {}).keys())

    for name, svc in services.items():
        if not isinstance(svc, dict):
            continue
        img = str(svc.get("image", ""))
        if not img and "build" not in svc:
            add("ERROR", "C03", f"{name}:既没有 image 也没有 build")
        if img and (":" not in img or img.endswith(":latest")):
            add("WARN", "C04", f"{name}:镜像未钉版本 {img}")
        if "restart" not in svc:
            add("WARN", "C05", f"{name}:没写 restart,机器重启后不会自己起来")
        if svc.get("privileged") is True:
            add("ERROR", "C06", f"{name}:privileged: true 等于交出宿主权限")

        # 环境变量里的明文密钥
        env = svc.get("environment") or {}
        if isinstance(env, dict):
            for k, v in env.items():
                if any(w in str(k).upper() for w in SECRET_KEYS) and isinstance(v, str):
                    if not v.startswith("${"):
                        add("ERROR", "C07", f"{name}:{k} 是明文(应写 ${{...}} 从 .env 注入)")

        # 端口暴露
        for p in svc.get("ports") or []:
            spec = str(p)
            parts = spec.split(":")
            if len(parts) >= 2 and parts[-1].isdigit():
                cport = int(parts[-1])
                bind = parts[0] if len(parts) == 3 else "0.0.0.0"
                if cport in INTERNAL_PORTS.values() and bind == "0.0.0.0":
                    add("ERROR", "C08", f"{name}:把内部端口 {cport} 发布到 0.0.0.0(公网可扫)")

        # 挂 docker.sock
        for v in svc.get("volumes") or []:
            if "docker.sock" in str(v):
                add("ERROR", "C09", f"{name}:挂了 docker.sock,等于给容器宿主 root")

        # 数据卷
        vols = [str(v) for v in (svc.get("volumes") or [])]
        named = [v.split(":")[0] for v in vols if not v.startswith(".") and not v.startswith("/")]
        for n in named:
            if n not in declared:
                add("ERROR", "C10", f"{name}:用了卷 {n} 但顶层没声明")
        if str(svc.get("image", "")).startswith(("mysql", "postgres", "mongo", "redis")) and not vols:
            add("ERROR", "C11", f"{name}:数据库没挂卷,容器一删数据全没")

        # 健康检查
        hc = svc.get("healthcheck")
        if hc:
            if "start_period" not in hc:
                add("INFO", "C12", f"{name}:healthcheck 没设 start_period,启动期可能误判不健康")
        # 日志轮转
        lg = svc.get("logging") or {}
        if not (isinstance(lg, dict) and lg.get("options")):
            add("WARN", "C13", f"{name}:没配日志轮转,长期跑会把磁盘写满")
        # container_name
        if "container_name" in svc:
            add("INFO", "C14", f"{name}:写死 container_name,没法 docker compose up --scale")

    # 就绪依赖
    for name, svc in services.items():
        if not isinstance(svc, dict):
            continue
        dep = svc.get("depends_on")
        if isinstance(dep, list):
            for d in dep:
                target = services.get(str(d)) or {}
                if isinstance(target, dict) and target.get("healthcheck"):
                    add("WARN", "C15", f"{name}:依赖 {d} 用的是短语法(只等启动),目标有 healthcheck 就该用 condition")
                else:
                    add("INFO", "C16", f"{name}:依赖 {d} 只是启动顺序,不等就绪 ------ 应用要有重试")
        elif isinstance(dep, dict):
            for d, spec in dep.items():
                cond = (spec or {}).get("condition") if isinstance(spec, dict) else None
                if cond != "service_healthy":
                    add("WARN", "C15", f"{name}:依赖 {d} 的 condition 是 {cond},建议 service_healthy")

    return f


PENALTY = {"ERROR": 25, "WARN": 10, "INFO": 3}


def main() -> None:
    demo = parse_yaml(IMPROVED)
    print("① 先看 YAML 被解析成了什么(缩进到底表示什么)")
    print(f"  顶层键:{list(demo)}")
    print(f"  服务:{list(demo['services'])}")
    print(f"  db.ports = {demo['services']['db']['ports']}")
    print(f"  db.healthcheck.test = {demo['services']['db']['healthcheck']['test']}")
    print(f"  api.depends_on = {demo['services']['api']['depends_on']}")
    print(f"  volumes(顶层声明)= {demo.get('volumes')}")

    cases = [("naive", NAIVE), ("improved", IMPROVED), ("production", PROD)]
    scores = []
    for title, text in cases:
        f = lint(text)
        raw = 100 - sum(PENALTY[lv] for lv, _, _ in f)
        scores.append(max(0, raw))
        print(f"\n=== {title} ===")
        print(f"{'级别':<7}{'规则':<6}说明")
        for lv, code, msg in f:
            print(f"{lv:<7}{code:<6}{msg}")
        err = sum(1 for x in f if x[0] == "ERROR")
        print(f"→ {len(f)} 条(ERROR {err})|得分 {max(0, raw)}"
              + (f"(原始 {raw})" if raw < 0 else ""))

    print(f"\n三份合计:{scores[0]} → {scores[1]} → {scores[2]}")
    print("★ 从 2 分到 100 分,补的是四个具体点:")
    print("  ① 明文口令 → ${变量}(C07,两个服务各一处)")
    print("  ② 3306 发布到 0.0.0.0 → 不发布(C08)")
    print("  ③ 日志不轮转 → max-size / max-file(C13)")
    print("  ④ healthcheck 补 start_period(C12)")
    print("  注意:这不是"配置越多越好",是四条各自防一种事故。")


if __name__ == "__main__":
    main()

4.6 【实跑】输出

复制代码
① 先看 YAML 被解析成了什么(缩进到底表示什么)
  顶层键:['services', 'volumes']
  服务:['api', 'db']
  db.ports = ['3306:3306']
  db.healthcheck.test = ['CMD', 'mysqladmin', 'ping', '-h', 'localhost']
  api.depends_on = {'db': {'condition': 'service_healthy'}}
  volumes(顶层声明)= {'db_data': {}}

=== naive ===
级别     规则    说明
INFO   C02   顶层 version 在 Compose V2 已废弃,删掉更干净
WARN   C05   api:没写 restart,机器重启后不会自己起来
ERROR  C07   api:DB_PASSWORD 是明文(应写 ${...} 从 .env 注入)
WARN   C13   api:没配日志轮转,长期跑会把磁盘写满
WARN   C04   db:镜像未钉版本 mysql
WARN   C05   db:没写 restart,机器重启后不会自己起来
ERROR  C07   db:MYSQL_ROOT_PASSWORD 是明文(应写 ${...} 从 .env 注入)
ERROR  C08   db:把内部端口 3306 发布到 0.0.0.0(公网可扫)
ERROR  C11   db:数据库没挂卷,容器一删数据全没
WARN   C13   db:没配日志轮转,长期跑会把磁盘写满
INFO   C16   api:依赖 db 只是启动顺序,不等就绪 ------ 应用要有重试
→ 11 条(ERROR 4)|得分 0(原始 -56)

=== improved ===
级别     规则    说明
ERROR  C07   api:DB_PASSWORD 是明文(应写 ${...} 从 .env 注入)
WARN   C13   api:没配日志轮转,长期跑会把磁盘写满
ERROR  C07   db:MYSQL_ROOT_PASSWORD 是明文(应写 ${...} 从 .env 注入)
ERROR  C08   db:把内部端口 3306 发布到 0.0.0.0(公网可扫)
INFO   C12   db:healthcheck 没设 start_period,启动期可能误判不健康
WARN   C13   db:没配日志轮转,长期跑会把磁盘写满
→ 6 条(ERROR 3)|得分 2

=== production ===
级别     规则    说明
→ 0 条(ERROR 0)|得分 100

三份合计:0 → 2 → 100
★ 从 2 分到 100 分,补的是四个具体点:
  ① 明文口令 → ${变量}(C07,两个服务各一处)
  ② 3306 发布到 0.0.0.0 → 不发布(C08)
  ③ 日志不轮转 → max-size / max-file(C13)
  ④ healthcheck 补 start_period(C12)
  注意:这不是"配置越多越好",是四条各自防一种事故。

4.7 四条差距,各自防一种事故

差距 规则 防什么事故
明文口令 C07 镜像/仓库/docker inspect 泄露口令
3306 对外 C08 数据库被公网扫描、勒索
日志不轮转 C13 跑三个月把磁盘写满,服务全挂
无 start_period C12 启动慢的服务被误判不健康、反复重启
复制代码
  ★ 四条里最容易被忽略的是 C13(日志)。
    它不像密码那样"一看就危险",但它是"三个月后必然发生"的那一类。

🖥️ 五、服务名就是主机名:机制与边界

5.1 机制

复制代码
  docker compose up 时,Compose 做了三件事:
    ① 建一个网络:<项目名>_default(项目名默认取目录名)
    ② 把每个服务接进去,并以【服务名】注册 DNS 别名
    ③ 服务名 → 容器 IP 的解析由 Docker 内嵌 DNS(127.0.0.11)完成

  api 里 ping db → 127.0.0.11 应答 → 返回 db 容器的内网 IP

5.2 compose_net.py 与它的输出

复制代码
# compose_net.py ------ 谁能连谁:服务名解析、端口发布、暴露面统计
from __future__ import annotations

# 场景:(服务, 容器端口, 发布写法)
SCENES = {
    "① 教程版(图 curl 方便,全发布)": [
        ("api", 8000, "8000:8000"),
        ("db", 3306, "3306:3306"),
    ],
    "② 只给本机调试": [
        ("api", 8000, "127.0.0.1:8000:8000"),
        ("db", 3306, "127.0.0.1:3306:3306"),
    ],
    "③ 交付版(只有 nginx 对外)": [
        ("nginx", 80, "80:80"),
        ("api", 8000, None),
        ("db", 3306, None),
    ],
}

TAILS = ["宿主机", "局域网"]
W = 16


def parse_pub(spec: str | None) -> tuple[int | None, str | None]:
    """返回 (宿主端口, 绑定地址);None 表示没发布。"""
    if not spec:
        return None, None
    parts = spec.split(":")
    if len(parts) == 3:
        return int(parts[1]), parts[0]
    if len(parts) == 2:
        return int(parts[0]), "0.0.0.0"
    return int(parts[0]), "0.0.0.0"


def cell(text: str) -> str:
    return f"{text:<{W}}"


def main() -> None:
    for title, services in SCENES.items():
        names = [s for s, _, _ in services]
        pub = {s: parse_pub(p) for s, _, p in services}
        cport = {s: c for s, c, _ in services}

        print(f"\n{'=' * 66}\n{title}")
        print(f"{'调用方 →':<10}" + "".join(cell(n) for n in names))
        for actor in names + TAILS:
            row = f"{actor:<10}"
            for target in names:
                if actor == target:
                    row += cell("---")
                    continue
                host_port, bind = pub[target]
                if actor in names:                      # 容器 → 容器:同网络,名字可达
                    ok, label = True, f"{target}:{cport[target]}"
                elif actor == "宿主机":
                    ok = host_port is not None
                    label = f"localhost:{host_port}" if ok else "未发布"
                else:                                   # 局域网
                    ok = host_port is not None and bind == "0.0.0.0"
                    label = f"IP:{host_port}" if ok else "不可达"
                row += cell(label if ok else f"✗ {label}")
            print(row)

        lan = [s for s in names if pub[s][0] and pub[s][1] == "0.0.0.0"]
        host = [s for s in names if pub[s][0]]
        print(f"  暴露面:局域网可达 {len(lan)} 个 {lan or '(无)'}|宿主机可达 {len(host)} 个")

    print("\n" + "=" * 66)
    print("三条判据:")
    print("  ① 容器之间【不看发布】------ 同网络即可用 服务名:容器端口")
    print("  ② 宿主机要访问 → 必须发布;局域网要访问 → 还必须绑 0.0.0.0")
    print("  ③ 数据库永远不该出现在"局域网可达"那一列")
    print("\n★ 从 ① 到 ③,暴露面从 2 个降到 1 个,而 api 连 db 一点没变难。")


if __name__ == "__main__":
    main()


==================================================================
① 教程版(图 curl 方便,全发布)
调用方 →     api             db              
api       ---               db:3306         
db        api:8000        ---               
宿主机       localhost:8000  localhost:3306  
局域网       IP:8000         IP:3306         
  暴露面:局域网可达 2 个 ['api', 'db']|宿主机可达 2 个

==================================================================
② 只给本机调试
调用方 →     api             db              
api       ---               db:3306         
db        api:8000        ---               
宿主机       localhost:8000  localhost:3306  
局域网       ✗ 不可达           ✗ 不可达           
  暴露面:局域网可达 0 个 (无)|宿主机可达 2 个

==================================================================
③ 交付版(只有 nginx 对外)
调用方 →     nginx           api             db              
nginx     ---               api:8000        db:3306         
api       nginx:80        ---               db:3306         
db        nginx:80        api:8000        ---               
宿主机       localhost:80    ✗ 未发布           ✗ 未发布           
局域网       IP:80           ✗ 不可达           ✗ 不可达           
  暴露面:局域网可达 1 个 ['nginx']|宿主机可达 1 个

==================================================================
三条判据:
  ① 容器之间【不看发布】------ 同网络即可用 服务名:容器端口
  ② 宿主机要访问 → 必须发布;局域网要访问 → 还必须绑 0.0.0.0
  ③ 数据库永远不该出现在"局域网可达"那一列

★ 从 ① 到 ③,暴露面从 2 个降到 1 个,而 api 连 db 一点没变难。

5.3 三个读法

① 容器之间不看"发布"

复制代码
  交付版里 api 没有 ports,但 nginx 仍能连 api:8000 ------
  因为"发布"只影响【宿主机/局域网能不能进来】,不影响【同一网络内互访】。

  ★ 所以"为了容器互连而发布端口"是误解。容器互连根本不需要发布。

② 宿主机与局域网是两回事

写法 宿主机 局域网
-p 8000:8000 ✓ ✓(0.0.0.0)
-p 127.0.0.1:8000:8000 ✓ ✗
不写 ports ✗ ✗

③ 数据库不该出现在"局域网可达"那一列

复制代码
  ★ 判断标准不是"我调试方便不方便",是"这条记录会不会出现在客户的安全扫描报告里"。

5.4 一个反例:container_name 的副作用

复制代码
  api:
    container_name: fde-api     # 写死名字

  ❌ 后果:docker compose up --scale api=3 直接失败
     (三个容器不能叫同一个名字)

  ★ 需要固定名字时(比如脚本里 docker exec fde-api),写;
    否则交给 Compose 生成 <项目>-<服务>-<序号>。

🖥️ 六、depends_on 的真相:它只管顺序

6.1 一句话

复制代码
  depends_on 保证"db 容器先启动",
  不保证"db 已经能接受连接"。

  MySQL 从"容器启动"到"能连",现实里要 10~30 秒(首次初始化更久)。

6.2 startup_order.py 与它的输出

复制代码
# startup_order.py ------ 五种启动策略:谁最快可用,谁最不崩
from __future__ import annotations

import math

DB_BOOT = 8.0          # MySQL 第一次能接受连接的时刻(秒)
API_BOOT = 0.2         # API 进程起来的时间
CONNECT_DELAY = 0.1    # 起来后多久发起第一次连接
RESTART_BACKOFF = [0.5, 1.0, 2.0, 4.0, 8.0]


def healthcheck_hit(interval: float, start_period: float) -> float:
    """返回 healthcheck 第一次成功(healthy)的时刻。"""
    t = interval
    while t <= 600:
        if t >= DB_BOOT:
            return t
        t += interval
    return t


def strategy_no_wait(restart: bool) -> tuple[float, int]:
    """depends_on 短语法:只等启动,不等就绪。返回 (可服务时刻, 崩溃次数)。"""
    t, crashes = API_BOOT + CONNECT_DELAY, 0
    idx = 0
    while t < DB_BOOT:
        crashes += 1
        if not restart:
            return math.inf, crashes   # 不再拉起:永远不可用
        t += RESTART_BACKOFF[min(idx, len(RESTART_BACKOFF) - 1)]
        idx += 1
        t += API_BOOT + CONNECT_DELAY
    return t, crashes


def strategy_retry(max_tries: int, gap: float) -> float:
    """应用层重试:不退出,按间隔重连。"""
    t = API_BOOT + CONNECT_DELAY
    for _ in range(max_tries):
        if t >= DB_BOOT:
            return t
        t += gap
    return t


def strategy_sleep(sec: float) -> float:
    return sec + API_BOOT


def main() -> None:
    hc5 = healthcheck_hit(5.0, 10.0)
    hc2 = healthcheck_hit(2.0, 0.0)

    print(f"前提:MySQL 首次可连接 {DB_BOOT}s;API 进程启动 {API_BOOT}s")
    print(f"healthcheck interval=5s → 首次成功在 {hc5}s(探针在 5/10/15...,5s 那次太早)")
    print(f"healthcheck interval=2s → 首次成功在 {hc2}s")

    rows = []

    t, c = strategy_no_wait(restart=True)
    rows.append(("A depends_on 短语法 + restart", t, c, "崩溃重启,期间全是 5xx"))

    t, c = strategy_no_wait(restart=False)
    rows.append(("B depends_on 短语法,无 restart", t, c, "直接退出,不会自己起来"))

    t = hc5 + API_BOOT
    rows.append(("C condition: service_healthy(interval 5s)", t, 0, "等"健康"才启动"))

    t = hc2 + API_BOOT
    rows.append(("D condition + interval 2s", t, 0, "同 C,探得更勤"))

    t = strategy_retry(10, 1.0)
    rows.append(("E 短语法 + 应用重试 10 次/1s", t, 0, "进程不退出,自己等到能连"))

    t = strategy_sleep(20)
    rows.append(("F command: sleep 20", t, 0, "赌数据库 20 秒能好"))

    def fmt(x: float) -> str:
        return "永不" if x == math.inf else f"{x:.1f}"

    print(f"\n{'策略':<38}{'可服务(s)':>10}{'崩溃次数':>9}  说明")
    for name, t, c, note in rows:
        print(f"{name:<38}{fmt(t):>10}{c:>9}  {note}")

    finite = [r[1] for r in rows if r[1] != math.inf]
    print(f"\n最快能服务:{min(finite):.1f}s;最慢:{max(finite):.1f}s"
          f"(B 永不恢复,不计入)")
    print("★ 三个结论,都被数字顶着:")
    print(f"  ① 崩溃重启反而更快(A {rows[0][1]:.1f}s vs C {rows[2][1]:.1f}s)------")
    print(f"     因为它不等"健康",一能连就算成功;代价是 {rows[0][2]} 次崩溃和 {rows[0][2]} 段 5xx")
    print(f"  ② 健康检查的粒度就是你的空窗:5s → {hc5}s,2s → {hc2}s,"
          f"差 {hc5 - hc2:.1f} 秒")
    print(f"  ③ 应用重试(E {rows[4][1]:.1f}s)几乎等于最勤的探针(D {rows[3][1]:.1f}s),"
          f"差 {abs(rows[4][1] - rows[3][1]):.1f} 秒")
    print("\n★ 一句话:depends_on 管的是"谁先启动",不是"谁先准备好"。")
    print("  最稳的组合是 E + C:应用自己重试兜底,编排层用健康检查表达意图。")


if __name__ == "__main__":
    main()

前提:MySQL 首次可连接 8.0s;API 进程启动 0.2s
healthcheck interval=5s → 首次成功在 10.0s(探针在 5/10/15...,5s 那次太早)
healthcheck interval=2s → 首次成功在 8.0s

策略                                        可服务(s)     崩溃次数  说明
A depends_on 短语法 + restart                   9.0        4  崩溃重启,期间全是 5xx
B depends_on 短语法,无 restart                    永不        1  直接退出,不会自己起来
C condition: service_healthy(interval 5s)      10.2        0  等"健康"才启动
D condition + interval 2s                    8.2        0  同 C,探得更勤
E 短语法 + 应用重试 10 次/1s                         8.3        0  进程不退出,自己等到能连
F command: sleep 20                         20.2        0  赌数据库 20 秒能好

最快能服务:8.2s;最慢:20.2s(B 永不恢复,不计入)
★ 三个结论,都被数字顶着:
  ① 崩溃重启反而更快(A 9.0s vs C 10.2s)------
     因为它不等"健康",一能连就算成功;代价是 4 次崩溃和 4 段 5xx
  ② 健康检查的粒度就是你的空窗:5s → 10.0s,2s → 8.0s,差 2.0 秒
  ③ 应用重试(E 8.3s)几乎等于最勤的探针(D 8.2s),差 0.1 秒

★ 一句话:depends_on 管的是"谁先启动",不是"谁先准备好"。
  最稳的组合是 E + C:应用自己重试兜底,编排层用健康检查表达意图。

6.3 怎么读这张表

① 崩溃重启反而更快,但代价是崩溃

复制代码
  A(9.0s,4 次崩溃)比 C(10.2s,0 次崩溃)快 1.2 秒。

  为什么?因为 A 不等"健康",数据库一能连就撞上了;
  C 要等到探针确认健康(interval=5s,正好在 10s 那次才探到)。

  ★ 但 A 的 1.2 秒是拿"4 次崩溃 + 4 段 5xx + 崩溃日志噪音"换的。
    这笔账在客户眼里不划算。

② 健康检查的粒度就是你的空窗

复制代码
  interval=5s → 10.0s 才发现
  interval=2s → 8.0s 就发现
  差的这 2 秒,纯粹是探针节奏,不是你优化了什么

  ★ 所以别问"为什么 healthcheck 慢",先看 interval 设了多少。

③ 应用重试几乎等于最勤的探针

复制代码
  E(8.3s)vs D(8.2s):差 0.1 秒。

  ★ 但两者性质不同:
    D 依赖"编排层配对了"
    E 依赖"应用自己会重试"
    最稳的是两个都做:E 兜底,D 表达意图。

6.4 推荐写法(两个都上)

复制代码
services:
  api:
    depends_on:
      db:
        condition: service_healthy      # 编排层:等就绪
  db:
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 20s                 # 首次初始化慢,给它 20 秒宽限

# 应用层:即使编排层判断错了,也不至于崩
ok, n = wait_for_db(tries=30, gap=1.0)

6.5 为什么 sleep 20 是最差的选择

复制代码
  ① 数据库 25 秒才起 → 你还是崩
  ② 数据库 3 秒就起 → 你白等 17 秒,每次发布都白等
  ③ 它把"多久能好"写死在命令里,而这个数字会随数据量变化

  ★ 用一个固定数字去猜一个变化的量,是这类问题的通病。

6.6 start_period 到底管什么

复制代码
  start_period: 20s
  → 前 20 秒内的失败【不计入】retries,也不判 unhealthy
  → 但它不会"延迟探针",探针照常每 5 秒跑

  ★ 所以它防的是"启动慢被误杀",不是"等 20 秒再开始"。
    这也是原稿没提、但交付必须写的一个参数。

🖥️ 七、数据卷与初始化

7.1 三类挂载在 compose 里怎么写

复制代码
    volumes:
      - db_data:/var/lib/mysql                              # 命名卷:数据库
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro   # 绑定:初始化脚本
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro          # 绑定:配置

volumes:          # 命名卷必须在顶层声明
  db_data:

7.2 初始化只跑一次,那改表怎么办

场景 做法
首次建库 init.sql 自动执行
加字段(开发期) docker compose down -v 清库重建(只限开发)
加字段(有数据) 写迁移脚本,用 migrate 服务跑(Flyway/Alembic)
客户生产 绝不 down -v;迁移脚本先备份再执行
复制代码
  ★ 交付给客户的系统,必须有一个"迁移"通道。
    init.sql 只能解决"从零开始",解决不了"已经有数据"。

7.3 down 与 down -v 的边界

复制代码
docker compose down        # 删容器 + 网络,卷保留 → 数据在
docker compose down -v     # 连卷一起删 → 数据没了,且不可恢复

  ★ 一条纪律:脚本/文档里出现 down -v,必须写明"这会删数据"。
    在客户机器上,这条命令等同于 drop database。

7.4 备份(三行)

复制代码
docker compose exec -T db sh -c 'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" fde_db' \
  > backup-$(date +%F).sql

  ★ 备份完必须做一次恢复演练,否则不叫备份,叫"我以为有备份"。

📖 八、环境变量:三层优先级

8.1 三层

来源 写法 优先级
shell 环境 export DB_PASSWORD=xxx && docker compose up 最高
.env 文件(项目目录) DB_PASSWORD=xxx 中
compose 里的默认值 ${DB_PASSWORD:-root123} 最低

8.2 两种引用语法

复制代码
    environment:
      DB_PASSWORD: ${DB_PASSWORD}          # 必须提供,否则警告 + 空值
      DB_NAME: ${DB_NAME:-fde_db}          # 没提供就用默认值
      DB_PORT: ${DB_PORT:?必须设置 DB_PORT} # 没提供就直接报错退出

  ★ 交付时用 `:?` 最安全:配置漏了就在 up 的那一刻报错,
    而不是跑起来以后连不上库、你去翻日志。

8.3 .env 的三个纪律

复制代码
  ① .env 进 .gitignore(和 .dockerignore)
  ② 提供 .env.example(只有键,没有值),让下一个人知道要填什么
  ③ 客户环境用客户的密钥管理,不要把 .env 拷来拷去

8.4 一个常见误解

复制代码
  ❌ "把密码写在 .env 里就安全了"
  ★ .env 只是"不写进镜像、不进 Git"。
    它仍然是明文文件,同机器上能读文件的人都能看到。
    真安全要靠文件权限(chmod 600)和密钥管理服务。

📖 九、日志与资源:三个月后才会痛的两件事

9.1 日志轮转

复制代码
    logging:
      driver: json-file
      options:
        max-size: 10m
        max-file: "3"

  不配的后果:单个容器日志无限增长。
  一个每秒写 1KB 的服务,三个月 ≈ 7.8GB ------ 足够把客户的小磁盘写满。

# 看当前谁在占
docker system df
du -sh /var/lib/docker/containers/*/*-json.log | sort -h | tail -5

9.2 资源限制:两种写法别混

复制代码
# 非 swarm 模式(docker compose 默认)------ 用这个
    mem_limit: 512m
    cpus: 1.0

# swarm 模式才生效(compose 会忽略,容易误以为生效了)
    deploy:
      resources:
        limits:
          memory: 512M

  ★ 这是最坑的一处:`deploy.resources` 在普通 docker compose 里【不生效】。
    你以为限制了,其实没有。用 mem_limit / cpus。

9.3 一个最小交付配置

复制代码
  api:
    mem_limit: 512m
    cpus: 1.0
    logging:
      driver: json-file
      options:
        max-size: 10m
        max-file: "3"

🖥️ 十、命令与排障流

10.0 高频命令逐个参数(先看这个再分层)

命令 逐个参数 什么时候用
docker compose up -d -d 后台;不加则前台占终端(Ctrl+C 会停止) 日常启动
docker compose up -d --build --build 先重新构建镜像再启动 改了代码后
docker compose up -d api 末尾写服务名 = 只处理它 只更新一个服务
docker compose up -d --force-recreate api 强制重建(配置变了但镜像没变时) 改了 environment/卷
docker compose down 删容器+网络,卷保留 收工
docker compose down -v 连卷一起删 数据清空,只用于重置环境
docker compose ps -a -a 连停止的也看 排查"起来又退出"
docker compose logs -f --tail 50 api -f 跟随;--tail N 只看末尾 盯日志
docker compose exec api sh exec 在运行中的容器里执行 进去看看
docker compose run --rm api pytest run 起一次性容器执行 跑测试/脚本
docker compose config 校验并展开变量 改了 yml 之后第一件事
docker compose restart api 重启(不重建) 改了挂载的配置文件
docker compose pull 拉最新镜像(tag 变了) 用了 :latest 或对方推了新版
复制代码
  ★ 三组最容易混:
    ① up vs restart:up 会应用 yml 的新配置;restart 只是重启进程,配置不变
    ② exec vs run:exec 进现有容器;run 起一个新容器(数据卷共享,端口可能冲突)
    ③ down vs stop:stop 保留容器;down 删容器和网络(卷仍在)

# 一条"改配置后正确生效"的组合拳:
docker compose config -q          # ① 校验 yml 和变量
docker compose up -d              # ② 只重建有变化的服务
docker compose ps                 # ③ 确认都 Up

10.1 命令分层

目的 命令
起 docker compose up -d / up -d --build
停 docker compose stop(保留容器)/ down(删容器网络)
看状态 docker compose ps
看日志 docker compose logs -f --tail 50 api
进容器 docker compose exec api sh
进数据库 docker compose exec db mysql -uroot -p fde_db
只构建 docker compose build
校验配置 docker compose config
看进程 docker compose top

10.2 排障五步

复制代码
  ① docker compose ps            → 谁没起来 / 谁在重启
  ② docker compose logs --tail 100 <服务>  → 它说了什么
  ③ docker compose config        → 变量到底被替换成了什么(关键!)
  ④ docker compose exec <服务> sh → 进去看网络/文件/环境
  ⑤ docker inspect <容器>        → 看挂载、网络、健康状态

10.3 第 ③ 步最容易被跳过

复制代码
$ docker compose config
services:
  api:
    environment:
      DB_PASSWORD: ""          # ← 变量没读到,是空的

  ★ "连不上数据库"里,有相当一部分是 ${DB_PASSWORD} 没被替换。
    config 一条命令就能看出来,比翻日志快十倍。

10.4 跨容器排障:从容器里往外看

复制代码
# 在 api 容器里测 DNS 和连通性
docker compose exec api python -c "import socket;print(socket.gethostbyname('db'))"
docker compose exec api python -c "import socket;s=socket.create_connection(('db',3306),3);print('ok');s.close()"

  ★ 不要用 ping 判断容器连通性 ------ 很多镜像里没装 ping,而且 ICMP 常被禁。
    直接测 TCP 端口才是对的。

📖 十一、更新与回滚

11.1 更新(改了代码)

复制代码
docker compose up -d --build api      # 只重建 api,db 不动

  ★ 只写服务名,别整个 up ------ 整个 up 会让 Compose 检查所有服务,
    一旦某个服务的镜像 tag 变了(比如 latest),你会"顺手"升级了数据库。

11.2 回滚(改坏了)

复制代码
# 前提:镜像打了版本号,不是 latest
docker compose stop api
docker tag myapi:1.3.0 myapi:current
# 或者直接改 compose 里的 tag,再 up
docker compose up -d api

  ★ 回滚能力取决于"旧镜像还在不在"。
    只留 latest 的团队,回滚时只能重新 build ------ 而 build 出来的已经不是当时那个版本了。

11.3 上线前必跑的三条

复制代码
docker compose config -q                       # 配置能解析、变量都替换了
docker compose build                           # 镜像能构建
docker compose up -d && docker compose ps      # 起来且健康

📖 十二、Compose 的边界:什么时候它不够用

需求 Compose 能做吗 说明
单机多容器 ✅ 这正是它的主场
自动扩缩容 ⚠️ 手工 --scale 没有自动扩缩
多机调度 ❌ 需要 Swarm / K8s
滚动更新不中断 ❌ up 会重启容器
服务发现跨主机 ❌ 只在本机网络内
密钥管理 ❌ 只能靠 .env 文件权限
复制代码
  ★ 一句话:Compose 是"一台机器上的编排"。
    客户只有一台服务器 → Compose 足够,别上 K8s;
    客户要多机高可用 → Compose 会变成运维负担。

❌ 十三、常见 15 坑

# 坑 现象 解决
1 缩进用 Tab yaml: found character that cannot start any token 全换空格
2 冒号后没空格 键值粘连,解析成怪结构 key: value
3 端口不加引号 80:80 被解析成六十进制数 "80:80"
4 命名卷没在顶层声明 service "db" refers to undefined volume 顶层加 volumes:
5 depends_on 短语法 库没就绪,api 崩溃 condition: service_healthy
6 没写 start_period 启动慢的服务被反复重启 加 20s
7 3306 发布到 0.0.0.0 安全扫描报告高危 删掉那一行
8 明文口令 泄露 ${DB_PASSWORD}
9 deploy.resources 限不住 内存照样吃满 用 mem_limit
10 日志不轮转 磁盘满 max-size / max-file
11 down -v 当 down 用 数据没了 看清参数
12 改 init.sql 不生效 表结构没变 initdb 只跑一次,用迁移
13 container_name 写死 没法 --scale 一般不写
14 变了代码没 --build 跑的还是旧镜像 up -d --build
15 在容器里用 localhost 连别的服务 连接被拒 用服务名

13.1 第 15 条单独说

复制代码
  ❌ DB_HOST=localhost     → 容器里的 localhost 是它自己
  ✅ DB_HOST=db            → 服务名

  ★ 这一条在从"本机开发"迁到"容器"时必犯,因为本机开发时 localhost 是对的。

13.5 新手五问:这些现象到底为什么

问 1:改了 yml,容器里怎么没变化?

复制代码
  因为容器是【旧配置】的实例。yml 改了,但没重建容器。
  docker compose up -d        # Compose 会重建"有变化"的服务
  ★ restart 不行 ------ 它只重启进程,不应用新配置。

问 2:down 之后数据还在吗?

复制代码
  在。down 删容器和网络,【卷保留】。
  只有 down -v 才连卷一起删 ------ 在客户机器上等同于删库。

问 3:报 Conflict. The container name "/fde-api" is already in use?

复制代码
  两可能:① 旧容器还在(docker ps -a 找到删掉)
          ② 另一个项目目录用了同样的 container_name
  ★ 建议:一般不写 container_name,让 Compose 自动命名。

问 4:报 port is already allocated?

复制代码
  宿主机端口被占。三条路:
  netstat -ano | findstr :80      (Windows 查谁占了)
  docker ps --format 'table {{.Names}}\t{{.Ports}}'   (是不是另一个容器占的)
  换端口映射(8081:80)或停掉占用者。

问 5:${DB_PASSWORD} 替换成了空值?

复制代码
  compose 只在【执行命令的目录】找 .env。
  你在上级目录跑 compose,它就找不到。
  ① cd 到项目根目录再跑;或
  ② docker compose --env-file ./config/.env up -d
  ★ 验证:docker compose config | grep DB_PASSWORD

📊 十四、速查表

14.1 最小可用 compose

复制代码
services:
  api:
    build: .
    environment:
      DB_HOST: db
      DB_PASSWORD: ${DB_PASSWORD:?必须设置}
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:?必须设置}
      MYSQL_DATABASE: fde_db
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 20s
    restart: unless-stopped
volumes:
  db_data:

14.2 交付前 8 条自检

复制代码
  □ 1. 没有明文口令(全是 ${...})
  □ 2. 数据库端口没有发布
  □ 3. 数据库挂了命名卷,且顶层已声明
  □ 4. depends_on 用了 condition: service_healthy
  □ 5. healthcheck 有 start_period
  □ 6. 每个服务都有 restart
  □ 7. 每个服务都配了日志轮转
  □ 8. docker compose config 能过,变量都替换了

14.3 一页对照

想知道 看哪
服务起没起 compose ps
为什么起不来 compose logs
变量对不对 compose config
网络通不通 exec ... socket.create_connection
数据在哪 docker volume inspect <项目>_db_data
占多少空间 docker system df

💬 十五、客户会怎么问(8 问 8 答)

Q1:这个 yml 和我们的部署文档什么关系?

它是唯一事实来源 。文档描述"为什么这么配",yml 决定"实际怎么配"。两者不一致时,以 yml 为准 ------这是好事,因为 yml 能被 git log 追溯。

Q2:为什么不给我们 3306,我们自己连一下?

因为发布 3306 等于把它放到公网 。如果你们要查数据,我们给两条路:进容器执行 mysql ,或者走 SSH 隧道。

Q3:depends_on 能保证数据库先起来吗?

保证先启动 ,不保证先就绪 。所以我们同时配了健康检查和 API 侧重试------一个表达意图,一个兜底。

Q4:MySQL 初始化要 20 秒,能不能让它快点?

首次初始化慢是正常的(建库 + 建表 + 写数据)。我们把 start_period 设成 20 秒,就是为了不被误判。如果你们要求秒级,那是"数据库要不要预热"的架构问题。

Q5:docker compose down 会不会删数据?

不会,卷保留 。只有 down -v 才删。我们会把这条写进 Runbook,并且禁止脚本里出现 -v。

Q6:为什么日志要限 10MB×3?

因为不限会写满磁盘。10MB×3 = 单服务最多 30MB,出问题时够看最近几小时,长期靠集中日志(客户有 ELK 就接过去)。

Q7:这套东西能扛多少并发?

Compose 是单机编排,上限是这台机器的 CPU 和内存。要横向扩展得换 K8s 或加机器 + 负载均衡。

Q8:改了配置怎么生效?

docker compose up -d 会重建有变化的 服务。改代码要加 --build,否则跑的还是旧镜像。


✅ 十六、今日交付物与自检

# 交付物 验收标准
1 compose_lint.py 三份得分 0 / 2 / 100
2 startup_order.py A 9.0s/4 崩、C 10.2s/0 崩、D 8.2s
3 compose_net.py 交付版局域网可达 1 个(nginx)
4 docker-compose.yml(交付版) lint 100 分
5 .env.example 只有键没有值
6 docs/compose/runbook.md 排障五步 + 回滚步骤
复制代码
  □ 1. 能说出 compose 与 docker run 的参数对应
  □ 2. 知道缩进错会报什么错
  □ 3. 知道 depends_on 只管顺序
  □ 4. 能说出三种"等就绪"的优劣
  □ 5. 知道容器互访不需要发布端口
  □ 6. 知道 127.0.0.1 与 0.0.0.0 的差别
  □ 7. 知道 init.sql 只跑一次
  □ 8. 知道 deploy.resources 在 compose 里不生效
  □ 9. 会用 compose config 查变量
  □ 10. 知道何时该放弃 compose

📝 十七、本课小结

# 知识点 一句话
1 定位 单机多容器的声明式编排
2 YAML 缩进即层级,冒号后要有空格
3 服务名 就是主机名,端口用容器端口
4 发布 只影响外部,不影响容器互访
5 depends_on 只管顺序,不管就绪
6 就绪 健康检查 + 应用重试,两个都上
7 start_period 防"启动慢被误杀"
8 卷 数据必须在卷里,顶层要声明
9 initdb 只跑一次,改结构要迁移
10 变量 shell > .env > 默认;交付用 :?
11 日志 不轮转会写满磁盘
12 资源 用 mem_limit,不是 deploy.resources
13 排障 ps → logs → config → exec → inspect
14 边界 单机够用,多机换 K8s

🧠 核心认知 :Compose 把"一套系统"变成一个可版本管理的对象。

复制代码
  手工部署:环境活在人的记忆里 → 换人就丢
  Compose:环境活在文件里 → 可 review、可回滚、可复制

  ★ 三条判据查一份 compose 够不够交付:
    ① 有没有明文口令?
    ② 数据库端口有没有对外?
    ③ down 之后数据还在不在?

📋 十八、课后练习

练习 1:跑三个脚本并改参数(约 40 分钟)

  1. 跑 compose_lint.py,确认 0 / 2 / 100

  2. 把交付版里 ${DB_PASSWORD} 改回 root123,看掉多少分

  3. 给交付版加 ports: ["3306:3306"],看掉多少分

  4. 跑 startup_order.py,把 DB_BOOT 改成 25,看 A 和 C 各变成多少,解释差异

练习 2:真机双容器栈(约 50 分钟)

  1. 五个文件备齐(main.py / requirements.txt / Dockerfile / init.sql / compose)

  2. docker compose up -d --build

  3. docker compose logs -f api,观察 db ready=True after N tries 里的 N

  4. curl localhost:8000/devices 确认数据

  5. 把 start_period 删掉再 up,观察是否出现反复重启

验收:能说出 N 大概等于几,以及它和 §六 的哪个策略对应。

练习 3:验证"容器互访不需要发布"(约 20 分钟)

  1. 交付版里 api 没有 ports,docker compose exec nginx curl -s api:8000/(nginx 镜像里有 curl)

  2. 从宿主机 curl localhost:8000 → 应该失败

  3. 用 compose_net.py 的输出解释这两件事为什么不矛盾

练习 4:数据与回滚(约 30 分钟)

  1. 插入一条设备,down(不加 -v),up,确认数据在

  2. down -v,up,确认数据没了(只在练习环境做)

  3. 给镜像打 myapi:1.0 和 myapi:1.1,改 compose 的 tag 来回切两次,记录耗时


🧪 十九、小白自检:12 个问题

复制代码
  □ 1. compose 是声明式还是过程式?为什么
  □ 2. 项目名从哪来?它决定了什么
  □ 3. up 的七步里,网络和卷在第几步建
  □ 4. Created 和 Started 的区别
  □ 5. YAML 里端口为什么要加引号
  □ 6. depends_on 短语法保证什么、不保证什么
  □ 7. condition: service_healthy 依赖谁(dockerfile 里哪一行)
  □ 8. 容器之间互访用服务名还是发布端口
  □ 9. down 和 down -v 差在哪
  □ 10. 改了 yml 怎么让它生效;restart 行不行
  □ 11. mem_limit 和 deploy.resources 哪个在 compose 里生效
  □ 12. 什么时候该放弃 compose 上 K8s

参考答案在哪:

问题 对应小节
1-4 §零 0.1-0.5
5 §二 2.4
6-8 §五 5.2、§六 6.2
9-11 §七 7.3、§十 10.0、§九 9.2
12 §十二

🔭 二十、下节预告

现在 API 只在容器网络里,宿主机都访问不到------这是对的,但用户得有个入口。

明天两件事:

  1. Nginx 反向代理 :把 80 暴露出去,转发到 api:8000;顺手讲清 proxy_set_header 为什么必须写(不写的话你日志里全是 nginx 的 IP)

  2. Git :把 Dockerfile、compose、nginx.conf 全部纳入版本管理------今天这份 100 分的 compose,明天要能被追溯"是谁、为什么改的"


🌍附录:前置课程列表

阶段一:认知启蒙(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 42:Dockerfile 实战 --- 把你的应用打包成镜像-CSDN博客

相关推荐
csdn_aspnet1 小时前
构建、运行与管理容器的 18 个常用 Docker 命令
docker·容器·eureka·command
大耳朵爱学习1 小时前
Andrew Ng深度解析:普通人如何利用AI把想法变成工具,实现工作自动化(小白程序员必看)
运维·人工智能·ai·自动化·大模型·就业·大模型学习
木雷坞1 小时前
AnythingLLM Docker部署:中文资料上传后无引用的检查路径
运维·docker·容器
张赐荣1 小时前
争渡读屏OCR文字/图像识别插件接口功能实现详解
运维·服务器·ocr
步行cgn1 小时前
Spring MVC 删除功能:用隐藏表单模拟 DELETE 请求的完整解析
linux·运维·服务器
暖核1 小时前
K8s 深度拆解:健康检查、认证授权与可视化面板全梳理
云原生·容器·kubernetes
润滑卫士1 小时前
工业油液在线监测系统架构:传感网络、数据采集与通讯协议设计
运维
红红谈说1 小时前
定时发布关机就停,本地定时任务的补跑策略怎么设计?
运维·服务器·定时任务·本地调度·补跑策略·幂等互斥·执行记录
4SAPI2 小时前
企业级大模型工作流基础设施选型指南:4SAPI、硅基流动与 OpenRouter 的架构权衡
运维·人工智能·架构·自动化