📚前言
📒FDE系列内容总纲:
🚄前置课程列表:
见文档结尾附录。
🚀阶段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 分钟)
-
跑
compose_lint.py,确认 0 / 2 / 100 -
把交付版里
${DB_PASSWORD}改回root123,看掉多少分 -
给交付版加
ports: ["3306:3306"],看掉多少分 -
跑
startup_order.py,把DB_BOOT改成 25,看 A 和 C 各变成多少,解释差异
练习 2:真机双容器栈(约 50 分钟)
-
五个文件备齐(main.py / requirements.txt / Dockerfile / init.sql / compose)
-
docker compose up -d --build -
docker compose logs -f api,观察db ready=True after N tries里的 N -
curl localhost:8000/devices确认数据 -
把
start_period删掉再 up,观察是否出现反复重启
验收:能说出 N 大概等于几,以及它和 §六 的哪个策略对应。
练习 3:验证"容器互访不需要发布"(约 20 分钟)
-
交付版里 api 没有
ports,docker compose exec nginx curl -s api:8000/(nginx 镜像里有 curl) -
从宿主机
curl localhost:8000→ 应该失败 -
用
compose_net.py的输出解释这两件事为什么不矛盾
练习 4:数据与回滚(约 30 分钟)
-
插入一条设备,
down(不加 -v),up,确认数据在 -
down -v,up,确认数据没了(只在练习环境做) -
给镜像打
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 只在容器网络里,宿主机都访问不到------这是对的,但用户得有个入口。
明天两件事:
-
Nginx 反向代理 :把
80暴露出去,转发到api:8000;顺手讲清proxy_set_header为什么必须写(不写的话你日志里全是 nginx 的 IP) -
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博客