Day 17 / 共 20 天 · 第 4 周 平台化与收官

配置 / Docker / 部署

把平台从"本地能跑"变成"上生产"。配置深合并、多阶段镜像构建、compose 本地全栈、K8s 编排、两种部署形态。昨天(Day 16)我们参观了物业监控大屏;今天讲的是「怎么把整栋精装楼原样复制到不同的小区(dev/staging/prod)交付入住」——同一套图纸、同一批建材,靠环境变量切换差异。这是 Day 02「本地跑通」的生产版续集。

📍 你在 20 天里的位置(第 4 周:平台化与收官)
D16 门户 portal D17 配置 / Docker / 部署 D18 CI 闸门 D19 起新 Agent D20 收官
💡 用一个类比先兜住今天(延续「盖楼/物业」世界观) 今天讲的是「交房」:同一张精装图纸要在好几个小区(dev/staging/prod)各交付一栋一模一样的楼。四步——配置 profile = 一份「标准装修清单 + 各小区特批」;多阶段 Dockerfile = 在临时施工场地(builder)把家具都做好,只把成品搬进干净的精装房(runtime),脚手架水泥袋一概不搬;docker-compose = 本地样板间通水通电;K8s Kustomize = 大规模在集群里按小区差异批量交付。贯穿全程的 uv.lock = 一张精确到螺丝型号的采购清单,保证每个工地买的料字节级一致。
L01

部署全景

回忆 Day 12 的核心理念:一个镜像 + ENABLED_AGENTS + APP_ENV = 任意 agent 组合 × 任意环境。今天把这条链上的每一环讲清:

1

配置:APP_ENV 选 profile

configs/<env>.yaml 深合并 base,决定这个环境的所有参数。

2

构建:多阶段 Dockerfile

用 uv 构建一个通用镜像,装好全 workspace。

3

本地:docker-compose

redis + platform 一键起。

4

生产:K8s Kustomize

base + overlays 按环境覆盖,部到集群。

L02

config.py 深合并走读(Day 02 进阶)

🤔 痛点同一套代码要在 dev/staging/prod 三个环境跑,参数各不相同(数据库地址、限额、密钥)。最笨的办法是每个环境抄一整份配置——抄三份就要维护三份,改个公共项要同步改三处,迟早漂移。怎么做到"公共的只写一遍,各环境只写差异"?答案就在 config.py 两百来行里。

入口是 load_config()config.py:291),Day 02 讲过它的 5 步,今天把核心三段真代码逐行读透。先看主流程(裁剪):

packages/ai-trust-toolkit/src/ai_trust_toolkit/config.py:303-339
app_env = env or os.environ.get("APP_ENV", "dev")     # ① 当前环境从 APP_ENV 来,默 dev
configs_dir = _find_configs_dir()                     # 向上找 configs/base.yaml
base_path = configs_dir / "base.yaml"
env_path = configs_dir / f"{app_env}.yaml"            # 如 prod.yaml

if not env_path.exists():                             # ② 找不到 profile → 回退 dev(不硬崩)
    log.warning("profile %s.yaml not found ... falling back to dev", app_env)
    env_path = configs_dir / "dev.yaml"

base_data = yaml.safe_load(_expand_env_vars(base_path.read_text(...))) or {}   # ③ 展开 ${VAR} 再解析
env_data  = yaml.safe_load(_expand_env_vars(env_path.read_text(...)))  or {}

merged = _deep_merge(base_data, env_data)             # ④ base 打底,profile 覆盖差异
merged["app_env"] = app_env                           # ⑤ config.py:334 · 强制回填,SSOT
return Config(**merged)                               # ⑥ Pydantic 校验(extra=forbid)

逐步讲: "当前是哪个环境"只认环境变量 APP_ENV 找不到对应 profile 不报错崩掉,而是打个 warning 退回 dev.yaml——容错。 读文件后先做 ${VAR} 环境变量展开、再交给 yaml 解析(见下 L03)。_deep_merge 把 base 和 profile 合并。 合并后强制app_env 覆盖成入参值。 最后过一遍 Pydantic 校验。

核心是那个"逐条比对"的会计——_deep_mergeconfig.py:255),只有 8 行但很关键:

config.py:255-267
def _deep_merge(base: dict, override: dict) -> dict:
    """deep merge · dict 递归 · list / scalar 走 override 覆盖."""
    result = dict(base)
    for key, value in override.items():
        if (key in result
                and isinstance(result[key], dict)
                and isinstance(value, dict)):
            result[key] = _deep_merge(result[key], value)   # 两边都是 dict → 递归往下合
        else:
            result[key] = value                              # 否则 override 直接覆盖
    return result

大白话:它递归地比对两个字典。遇到"两边同名、且都是字典"的键,就钻进去继续逐层合并(而不是整块替换);遇到标量或列表,就用 override 的值直接盖掉。这意味着 profile 里只要写 database.arch_compliance.host 一个字段,base 里 database 下的其它字段(port、user、其它 pool)全部原样保留——这就是"只写差异"的技术底座。

💡 设计取舍①:为什么 dict 递归合并,但 list 直接覆盖,而不是也把 list 逐项合并?因为 list 合并语义是模糊的——两个列表该"拼接"还是"逐项覆盖"?按 index 对齐还是按某个 key 对齐?没有唯一正确答案,容易出反直觉的 bug。索性规定 list 一律整块覆盖(override 说了算),语义简单、可预测。看 dev.yamlgitlab_instances 是个列表,profile 要改就得整块重写——这正是这个取舍的体现:宁可写全一点,也不要"合了一半"的诡异结果。
💡 设计取舍②:为什么合并后要 merged["app_env"] = app_env 强制回填(:334)?因为 base.yaml 里写着 app_env: ${APP_ENV:-dev},万一环境变量没设、或者有人在某个 profile 里手抖写了个不一致的值,合并结果里的 app_env 就可能跟"真正传进来的环境"对不上。强制回填等于宣布:"当前是哪个环境"这个真相,以 load_config 的入参为唯一来源(SSOT),谁也别想在 yaml 里偷改。一个字段的归属权攥在代码手里,避免了一整类"环境判断错乱"的诡异 bug。
L03

base/dev/prod 真实合并 + secret 反面教材

光看 _deep_merge 的代码不够直观。直接拿仓库里三个真实文件跑一遍合并,你就彻底懂了。先看 base.yaml 的 database 段(configs/base.yaml:8-27):

configs/base.yaml:8-16(公共默认,secret 走 ${VAR})
database:
  arch_compliance:
    host: ${MYSQL_HOST:-mysql}          # ${VAR:-默认值} 形式
    port: 3306
    user: ${MYSQL_USER:-app}
    password: ${MYSQL_PASSWORD:-app_local}
    database: ${MYSQL_DATABASE:-arch_compliance}
    autocommit: true

这是"标准做法":password 不写死,用 ${MYSQL_PASSWORD:-app_local} 从环境变量取(没设就用本地默认)。_expand_env_varsconfig.py:239)就是干这个展开的,正则只认大写下划线的变量名(config.py:236_VAR_PATTERN)。

再看 dev.yaml 只写了 database 的差异部分configs/dev.yaml:4-10):

configs/dev.yaml:4-10
database:
  arch_compliance:
    host: bmdevdb.czmaueomanj2.ap-northeast-1.rds.amazonaws.com
    port: 3306
    user: dev
    password: 27gErtSiE5jy#NxCUeLQ     # ← 明文密码,违反 base 自己的规范!
    database: obelisk
📝 举个例子:APP_ENV=dev 时这段怎么合出来(真实值) _deep_merge(base, dev) 走到 database.arch_compliance:两边都是 dict → 递归逐字段合。
host:dev 有 → 用 dev 的 bmdevdb...rds.amazonaws.com(覆盖 base 的 ${MYSQL_HOST}
user:dev 有 → devdatabase:dev 有 → obelisk
autocommitdev 没写 → 保留 base 的 true(这就是"深合并只覆盖差异"的威力)
最终 dev 的这个 pool = dev 写的 5 个字段 + base 兜底的 autocommit: true
_deep_merge:dict 递归合、只覆盖同名字段 base.yaml(打底) host: ${MYSQL_HOST} port: 3306 user: ${MYSQL_USER} database: ... autocommit: true dev.yaml(只写差异) host: bmdevdb...rds user: dev database: obelisk (没写 autocommit) merge merged 结果 host: bmdevdb(dev 覆盖) port: 3306(base 保留) user: dev(dev 覆盖) database: obelisk autocommit: true(base 兜) app_env 最后强制回填=SSOT
图注(数据结构):profile 只写差异字段,深合并逐字段覆盖同名项,base 里没被覆盖的(如 autocommit)原样保留。

合并完,整个结果交给 Config(**merged) 做 Pydantic 校验。注意每个配置模型都写了 model_config = ConfigDict(extra="forbid")(如 config.py:218):

config.py:215-228(root Config)
class Config(BaseModel):
    model_config = ConfigDict(extra="forbid")   # ← 多写/拼错一个 key 就启动报错
    app_env: str = "dev"
    database: DatabaseConfig = Field(default_factory=DatabaseConfig)
    redis: RedisConfig = ...
    cost: CostConfig = ...
    # ...
💡 设计取舍:extra="forbid" 为什么值得?默认 Pydantic 对多余字段是忽略的。假设你想改 ratelimit_rpm 却手抖写成 ratelimit_rmp——默认行为下这个拼错的键被静默忽略,你的限流配置压根没生效,还毫无报错,上线才发现。extra="forbid" 把这类拼写错误变成启动即崩(fail-fast),错误在部署前就暴露。用"启动时严格一点"换"运行时少一整类隐蔽 bug",非常划算。

🚨 反面教材(仓库真实存在)dev.yaml:9password: 27gErtSiE5jy#NxCUeLQprod.yaml:26 的明文 llm.api_key: sk-ant-api03-...prod.yaml:10 的 DB 密码,全都明文写死在 yaml 里,直接违反了 base.yaml:2 自己声明的"secret SHALL NOT hardcode · 走 ${VAR}"规范。真实系统务必用环境变量 / K8s Secret / Vault 注入,绝不能像仓库里这样明文写死。这是 Day 01 讲的"理想 vs 现实"的活标本——规范写了,落地时有人图省事没遵守。

L04

多阶段 Dockerfile 走读(真代码)

根目录 Dockerfile多阶段构建(multi-stage) + uv。核心思想:把重的依赖装在 builder 阶段,运行镜像只带必要的东西。先看 builder 的关键顺序(Dockerfile:15-59):

Dockerfile:15-59(builder 阶段,裁剪)
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.11.13 /uv /uvx /bin/    # 装 uv
ENV UV_LINK_MODE=copy UV_COMPILE_BYTECODE=1 UV_PROJECT_ENVIRONMENT=/opt/venv
WORKDIR /app

# ① 先只 copy lock + 各包的 pyproject.toml(不含源码)
COPY pyproject.toml uv.lock ./
COPY packages/ai-trust-toolkit/pyproject.toml packages/ai-trust-toolkit/
# ... 20 多个 app/包的 pyproject.toml 一个个 COPY ...

# ② 纯依赖安装(无源码)→ 这一层能被缓存
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-install-workspace --all-extras --all-packages   # Dockerfile:59

# ③ 再 copy 全部源码 + configs + docs + examples
COPY packages/ packages/
COPY apps/ apps/
COPY configs/ configs/

# ④ 第二次 sync:editable 装 workspace 自身
RUN uv sync --frozen --all-extras --all-packages          # Dockerfile:74

逐步讲: 先只拷"依赖清单"(各 pyproject.toml + uv.lock),不拷源码 第一次 uv sync --no-install-workspace 只装第三方依赖。 这之后才拷源码。 第二次 sync 把本仓 workspace 的包 editable 装进去。

为什么"先拷依赖清单、后拷源码"? Docker 是分层缓存的:某层的输入没变,这层就直接命中缓存不重跑。把"很少变的依赖安装(②)"放在"经常变的源码拷贝(③)"之前——只要 pyproject.toml/uv.lock 没变,②这一层就命中缓存,改业务代码重新构建时跳过耗时的联网装包。--frozen 表示严格按 uv.lock 装、不重新解析版本(呼应 L08)。

再看 runtime 阶段——只拿 builder 的成品(Dockerfile:77-113):

Dockerfile:77-113(runtime 阶段,裁剪)
FROM python:3.12-slim AS runtime
RUN apt-get install -y git ...                            # 装 git(coding agent 用)
RUN groupadd -r app && useradd -r -g app ... app          # ① 非 root 用户
COPY --from=builder /opt/venv /opt/venv                   # ② 只拷 venv + 源码
COPY --from=builder --chown=app:app /app/apps /app/apps
USER app                                                  # 切非 root
HEALTHCHECK --interval=30s ... CMD python -c "... /healthz ... == 200"   # ③ 健康检查
CMD ["python", "-m", "uvicorn", "gov_agents_server.main:app",
     "--host", "0.0.0.0", "--port", "8080", "--workers", "2", "--proxy-headers"]

runtime 只做三件事: 建一个非 root 用户 app(安全,符合 K8s runAsNonRoot); 从 builder COPY --from=builder 拿现成的 venv 和源码——builder 那堆构建缓存、编译产物一概不搬 挂 HEALTHCHECK 打 /healthz,启动 uvicorn。

Stage 1 · builder(施工场地) ① 只拷 pyproject+uv.lock ② uv sync 装依赖(可缓存层) ③④ 拷源码 → editable 装 workspace 编译缓存/构建垃圾 全留这里 只搬 /opt/venv+源码 Stage 2 · runtime(精装房) 非 root 用户 app + git uvicorn main:app :8080 --workers 2 HEALTHCHECK 打 /healthz CMD 用 ENABLED_AGENTS 选组合 最终镜像 = 干净 runtime;builder 层用完即弃不进交付产物 → 镜像小、启动快
图注:多阶段 = 在施工场地把东西做好,只把成品搬进精装房;构建垃圾留在场地不交付。
⚠️ 小白常误以为「多阶段 = 最后会得到两个镜像」。其实只产出一个最终镜像(runtime 那个);builder 阶段只是构建过程中的临时层,用完即弃,不出现在你 docker images 的交付产物里。
📝 举个例子:改一行业务代码,重建快在哪 你只改了 sre_rca/nodes/triage.py 一行、没动任何 pyproject.toml/uv.lock
→ 重新 docker build 时,Dockerfile:59 那层"装第三方依赖"因清单没变 命中缓存直接跳过(省几分钟联网装包)
→ 只有 :74 那层"拷源码 + editable 装"重跑(几秒)。
反例:你加了个依赖改了 uv.lock → 依赖层缓存失效 → 那几分钟就得重花。这就是为什么拷贝顺序 = 构建速度
L05

docker-compose:本地全栈

Day 02 提过。docker-compose.yml 实际两个服务(README 说四个是漂移):

docker-compose.yml(裁剪)
services:
  redis:                        # Working Memory 的 checkpointer 后端
    image: redis:7-alpine
    ports: ["6379:6379"]
    healthcheck: ...
  platform:
    build: .                    # 用根 Dockerfile 构建
    ports: ["8080:8080"]
    depends_on:
      redis: { condition: service_healthy }   # 等 redis 健康才起
    environment:
      APP_ENV: ${APP_ENV:-dev}                 # ← 打通 config profile(L02)
      ENABLED_AGENTS: sre-rca,risk-reviewer,... # 选 agent 组合
      REDIS_URL: redis://redis:6379
      # 一堆 ${VAR:-} 占位透传 DB/Anthropic/Lark

关键:APP_ENV: ${APP_ENV:-dev} 把 compose 和 L02 的配置系统打通——APP_ENV=prod docker compose up 就切生产配置,同一份 compose 文件。ENABLED_AGENTS 则接上 Day 12 的"一个镜像挂任意 agent 组合"。

💡 设计取舍:为什么 platform 要 depends_on: redis: {condition: service_healthy}Working Memory 的 checkpointer 落在 redis 上(Day 09)。如果 platform 先起、redis 还没就绪,第一批请求写 checkpoint 就会失败。condition: service_healthy 让 compose 等 redis 的 healthcheck 通过才启动 platform——把"启动顺序依赖"显式声明出来,而不是靠 sleep 碰运气。这是编排工具存在的意义之一。
L06

K8s Kustomize

生产部署清单在 deploy/k8s/,用 Kustomize 组织——和配置 profile 一样的"base + overlay 覆盖"思想:

  • base/:通用的 Deployment / Service / ConfigMap 等。
  • overlays/<env>/:每个环境的差异(副本数、资源、镜像 tag、环境专属配置)叠加在 base 上。

deploy/grafana/ 还有现成的监控面板(cache-dashboard.json)——对接 Day 11 的 Prometheus 指标。

Kustomize 是什么? K8s 官方的配置管理工具,思路和本项目 config profile / _deep_merge 一模一样:写一份 base,各环境只写"跟 base 的差异",用 overlay 打补丁。避免了给每个环境复制粘贴一整套 YAML。"base + 差异覆盖"是这个项目从 config.py 到 K8s 一以贯之的模式——你在 L02 学的深合并思想,在部署层又复用了一次。
L07

两种部署形态

① 多 agent 平台(推荐)
一个进程挂多个 agent,共享资源。gov_agents_server.main:app+ ENABLED_AGENTS=a,b,c
② 单 agent 独立
一个 agent 独立部署,用它自己的 main.py。<pkg>.main:app(如 sre_rca.main:app)

这就是 Day 05 讲的"一个 agent 有 server.py(给平台挂)和 main.py(独立跑)两个入口"的部署对应。Dockerfile 默认 CMD(Dockerfile:111)跑的就是形态①;ENABLED_AGENTS 未设=挂全部,写非法名快速失败报错。

怎么选? 大多数用形态①——一个平台进程按需挂一组 agent,运维简单、资源共享。只有当某个 agent 特别重、需要独立扩缩容/隔离时,才用形态②单独部署。同一份 agent 代码两种形态都支持,不用改。
L08

uv.lock 的分量

最后强调一个部署可靠性的基石——那个 890KB 的 uv.lock(仓库里真实大小)。docs/deployment.md 有句话点透了它的本质:

"uv.lock 不是版本,是版本组合的精确快照。任何 pyproject.toml 改动都要重跑 uv lock 写进同一个 PR。"

它锁死了几百个包的精确版本 + 哈希。所以你本地、同事机器、CI、生产镜像装出来的东西字节级一致——彻底消灭"在我机器上是好的"。这也是为什么 Dockerfile 用 --frozen:59/74,严格按 lock 装)、CI 有专门的 gate 校验 lock 与 BOM 一致(Day 18)。

🤔 如果没有 uv.lock,会出什么事故?假设只写"要 pydantic ≥2" 不锁死精确版本:你本机三个月前装的是 pydantic 2.5,同事今天新装解析到了 2.9,2.9 改了个默认行为——同一份代码,你机器上测试全绿,同事机器上和生产镜像里却崩了,排查半天发现是"依赖版本悄悄漂了"。uv.lock 把几百个包的精确版本+哈希钉死,就是为了让这种"在我机器上是好的"彻底不可能发生。
👶 一句话记住uv.lock 不是"版本号",是"整套建材采购单的精确快照(含螺丝型号)"——改建材必须同时更新采购单,还要和图纸放进同一个 PR 一起交;Dockerfile 用 --frozen 就是"严格按这张单子买、不许现场临时换料"。
L09

今日小结 + 动手

🧠 今天你应该能回答

  • _deep_merge 为什么 dict 递归、list 覆盖?(config.py:255
  • 合并后为什么强制 merged["app_env"]=app_env?(SSOT,config.py:334
  • extra="forbid" 拦住了哪一类 bug?(拼错的 key 启动即崩)
  • 多阶段 Dockerfile 为什么先拷依赖清单再拷源码?(layer 缓存,Dockerfile:59
  • "一个镜像跑任意组合×任意环境"靠哪两个环境变量?(ENABLED_AGENTS + APP_ENV)
  • uv.lock 为什么必须和代码改动进同一 PR?

✋ 动手:读今天走读过的真代码

# 1. 配置深合并三段核心
sed -n '236,267p' packages/ai-trust-toolkit/src/ai_trust_toolkit/config.py   # _expand_env_vars + _deep_merge
sed -n '291,339p' packages/ai-trust-toolkit/src/ai_trust_toolkit/config.py   # load_config

# 2. 三个真实 profile(看深合并 + secret 反面教材)
cat configs/base.yaml
cat configs/dev.yaml
cat configs/prod.yaml

# 3. 多阶段 Dockerfile
cat Dockerfile

# 4. compose 两个服务
cat docker-compose.yml

# 5. 本地起全栈(有 Docker 的话)
APP_ENV=dev docker compose up -d
curl localhost:8080/healthz
明天预告 · Day 18:部署清楚了,那"怎么保证合进来的代码不破坏平台"?Day 18 读 .gitlab-ci.ymlscripts/ci/* 真脚本——7 个 stage、allow_failure: false 默认失败、评测回归、成本红线、敏感度红线,把前面所有"可信"承诺在合并前强制执行。
← Day 16 门户 Day 18 · CI 流水线与治理闸门 →