配置 / Docker / 部署
把平台从"本地能跑"变成"上生产"。配置深合并、多阶段镜像构建、compose 本地全栈、K8s 编排、两种部署形态。昨天(Day 16)我们参观了物业监控大屏;今天讲的是「怎么把整栋精装楼原样复制到不同的小区(dev/staging/prod)交付入住」——同一套图纸、同一批建材,靠环境变量切换差异。这是 Day 02「本地跑通」的生产版续集。
部署全景
回忆 Day 12 的核心理念:一个镜像 + ENABLED_AGENTS + APP_ENV = 任意 agent 组合 × 任意环境。今天把这条链上的每一环讲清:
配置:APP_ENV 选 profile
configs/<env>.yaml 深合并 base,决定这个环境的所有参数。
构建:多阶段 Dockerfile
用 uv 构建一个通用镜像,装好全 workspace。
本地:docker-compose
redis + platform 一键起。
生产:K8s Kustomize
base + overlays 按环境覆盖,部到集群。
config.py 深合并走读(Day 02 进阶)
config.py 两百来行里。入口是 load_config()(config.py:291),Day 02 讲过它的 5 步,今天把核心三段真代码逐行读透。先看主流程(裁剪):
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_merge(config.py:255),只有 8 行但很关键:
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)全部原样保留——这就是"只写差异"的技术底座。
dev.yaml 的 gitlab_instances 是个列表,profile 要改就得整块重写——这正是这个取舍的体现:宁可写全一点,也不要"合了一半"的诡异结果。merged["app_env"] = app_env 强制回填(:334)?因为 base.yaml 里写着 app_env: ${APP_ENV:-dev},万一环境变量没设、或者有人在某个 profile 里手抖写了个不一致的值,合并结果里的 app_env 就可能跟"真正传进来的环境"对不上。强制回填等于宣布:"当前是哪个环境"这个真相,以 load_config 的入参为唯一来源(SSOT),谁也别想在 yaml 里偷改。一个字段的归属权攥在代码手里,避免了一整类"环境判断错乱"的诡异 bug。base/dev/prod 真实合并 + secret 反面教材
光看 _deep_merge 的代码不够直观。直接拿仓库里三个真实文件跑一遍合并,你就彻底懂了。先看 base.yaml 的 database 段(configs/base.yaml:8-27):
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_vars(config.py:239)就是干这个展开的,正则只认大写下划线的变量名(config.py:236 的 _VAR_PATTERN)。
再看 dev.yaml 只写了 database 的差异部分(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
_deep_merge(base, dev) 走到 database.arch_compliance:两边都是 dict → 递归逐字段合。→
host:dev 有 → 用 dev 的 bmdevdb...rds.amazonaws.com(覆盖 base 的 ${MYSQL_HOST})→
user:dev 有 → dev;database:dev 有 → obelisk→
autocommit:dev 没写 → 保留 base 的 true(这就是"深合并只覆盖差异"的威力)最终 dev 的这个 pool = dev 写的 5 个字段 + base 兜底的
autocommit: true。合并完,整个结果交给 Config(**merged) 做 Pydantic 校验。注意每个配置模型都写了 model_config = ConfigDict(extra="forbid")(如 config.py:218):
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:9 的 password: 27gErtSiE5jy#NxCUeLQ、prod.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 现实"的活标本——规范写了,落地时有人图省事没遵守。
多阶段 Dockerfile 走读(真代码)
根目录 Dockerfile 用多阶段构建(multi-stage) + uv。核心思想:把重的依赖装在 builder 阶段,运行镜像只带必要的东西。先看 builder 的关键顺序(Dockerfile:15-59):
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 装进去。
pyproject.toml/uv.lock 没变,②这一层就命中缓存,改业务代码重新构建时跳过耗时的联网装包。--frozen 表示严格按 uv.lock 装、不重新解析版本(呼应 L08)。再看 runtime 阶段——只拿 builder 的成品(Dockerfile:77-113):
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。
docker images 的交付产物里。sre_rca/nodes/triage.py 一行、没动任何 pyproject.toml/uv.lock:→ 重新
docker build 时,Dockerfile:59 那层"装第三方依赖"因清单没变 命中缓存直接跳过(省几分钟联网装包)→ 只有
:74 那层"拷源码 + editable 装"重跑(几秒)。反例:你加了个依赖改了
uv.lock → 依赖层缓存失效 → 那几分钟就得重花。这就是为什么拷贝顺序 = 构建速度。docker-compose:本地全栈
Day 02 提过。docker-compose.yml 实际两个服务(README 说四个是漂移):
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 组合"。
depends_on: redis: {condition: service_healthy}?Working Memory 的 checkpointer 落在 redis 上(Day 09)。如果 platform 先起、redis 还没就绪,第一批请求写 checkpoint 就会失败。condition: service_healthy 让 compose 等 redis 的 healthcheck 通过才启动 platform——把"启动顺序依赖"显式声明出来,而不是靠 sleep 碰运气。这是编排工具存在的意义之一。K8s Kustomize
生产部署清单在 deploy/k8s/,用 Kustomize 组织——和配置 profile 一样的"base + overlay 覆盖"思想:
base/:通用的 Deployment / Service / ConfigMap 等。overlays/<env>/:每个环境的差异(副本数、资源、镜像 tag、环境专属配置)叠加在 base 上。
deploy/grafana/ 还有现成的监控面板(cache-dashboard.json)——对接 Day 11 的 Prometheus 指标。
_deep_merge 一模一样:写一份 base,各环境只写"跟 base 的差异",用 overlay 打补丁。避免了给每个环境复制粘贴一整套 YAML。"base + 差异覆盖"是这个项目从 config.py 到 K8s 一以贯之的模式——你在 L02 学的深合并思想,在部署层又复用了一次。两种部署形态
① 多 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 未设=挂全部,写非法名快速失败报错。
uv.lock 的分量
最后强调一个部署可靠性的基石——那个 890KB 的 uv.lock(仓库里真实大小)。docs/deployment.md 有句话点透了它的本质:
uv lock 写进同一个 PR。"它锁死了几百个包的精确版本 + 哈希。所以你本地、同事机器、CI、生产镜像装出来的东西字节级一致——彻底消灭"在我机器上是好的"。这也是为什么 Dockerfile 用 --frozen(:59/74,严格按 lock 装)、CI 有专门的 gate 校验 lock 与 BOM 一致(Day 18)。
--frozen 就是"严格按这张单子买、不许现场临时换料"。今日小结 + 动手
🧠 今天你应该能回答
_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
.gitlab-ci.yml 和 scripts/ci/* 真脚本——7 个 stage、allow_failure: false 默认失败、评测回归、成本红线、敏感度红线,把前面所有"可信"承诺在合并前强制执行。