🏠 总目录📚 本教程 13 · 配置与密钥
📑 本页目录(点开跳转)

13 · 配置与密钥

46 分钟 | 🔑 数据库密码泄露,别人还得能连到你的库;API key 泄露,全世界当场就能花你的钱


🎯 一句话

配置要能在不改一行代码的前提下换掉,密钥要能在十分钟之内换掉。

前面十二章写的东西已经能跑了。这一章处理的是「同一份代码怎么在笔记本、预发、生产跑出三种行为」,以及「那串 sk- 开头的东西怎么才不会毁掉你」。


🧩 一、配置为什么必须离开代码

三个症状:想把模型从便宜的换成贵的,要改代码 → 提交 → 等 CI → 重新发版;同事拉下代码跑不起来,因为他的 Postgres 不在 5432;你在 git 历史里搜 sk-搜到了东西 💀。

三个都是同一个病:「这台机器的情况」被写进了「所有机器共用的代码」里

分界线换一台机器、换一个环境会变的是配置,不会变的是代码。 模型名、超时秒数、数据库地址、密钥、调试开关 → 配置;「重试几次、退避公式怎么算」→ 代码。

配置进来的通道只有一条:环境变量。它是唯一一个所有系统、所有语言、所有平台都认的东西 —— 笔记本认它,Docker 认它,PaaS 面板上那个填框写的就是它,K8s 的 Secret 挂进来也是它。


🔑 二、形状:一个类,启动时就炸

大多数人第一版是散着写 os.getenv("OPENAI_API_KEY")。这版有四个问题,每个都会在半夜咬你一口:

问题 后果
拼错的键静默返回 None 半小时后第一个真实请求才炸 ⚠️
环境变量永远是字符串 timeout > 30 直接报错;而 "0"True 💀
没有一处列全所有配置项 新同事只能靠 grep 猜要设哪些变量
缺配置在运行时才发现 服务已经在接流量了

正确形状:一个 Settings 类,进程启动第一件事就把它加载出来,缺了或类型不对就直接崩掉、不启动。

这叫 fail fast。它值钱的地方是:起不来是发版时立刻看得见的,带病上线是用户替你发现的。

import os
from dataclasses import dataclass, fields

SECRETY = ("key", "token", "secret", "password", "url")   # url 里带密码,也算


def _get(name, default=None, cast=str):
    raw = os.environ.get(name, default)
    if raw is None:
        raise RuntimeError(f"缺少必填配置 {name}")          # ⭐ 启动时就炸
    try:
        return raw if isinstance(raw, cast) else cast(raw)
    except ValueError:
        raise RuntimeError(f"{name}={raw!r} 不是 {cast.__name__}")


@dataclass(frozen=True)                                   # ⭐ 只读,跑起来谁也改不了
class Settings:
    env: str
    llm_api_key: str
    llm_timeout_s: float
    database_url: str

    @classmethod
    def load(cls):
        return cls(_get("APP_ENV", "dev"), _get("LLM_API_KEY"),   # 无默认值 = 必填
                   _get("LLM_TIMEOUT_S", "60", float),
                   _get("DATABASE_URL", "sqlite:///./dev.db"))

    def __repr__(self):     # ⭐⭐ 你一定会 print(settings),在类型自己身上堵死
        d = {f.name: ("***" if any(w in f.name for w in SECRETY) else getattr(self, f.name))
             for f in fields(self)}
        return f"Settings({d})"


if __name__ == "__main__":
    os.environ.setdefault("LLM_API_KEY", "sk-demo-0000")   # 只为让这段能直接跑
    print(Settings.load())
    os.environ["LLM_TIMEOUT_S"] = "六十"
    try:
        Settings.load()
    except RuntimeError as e:
        print("启动失败:", e)

实跑输出(第二行就是 fail fast 该有的样子):

Settings({'env': 'dev', 'llm_api_key': '***', 'llm_timeout_s': 60.0, 'database_url': '***'})
启动失败: LLM_TIMEOUT_S='六十' 不是 float

⭐ 三处细节:frozen=True(允许运行中改配置 = 允许「某个函数偷偷改了超时」这种查三天的 bug);__repr__ 把密钥换成 ***(在类型身上堵,比指望每个人都记得安全);⚠️ database_url 也按密钥处理 —— 连接串里带着密码,「URL 不算密钥」是很常见的误判。load() 里还适合放跨字段校验env=prod 时不许 DEBUG=1)。

🗓️ 真实项目更常用 pydantic-settings(Node 用 zod,Go 用 envconfig)。包名会过期,这个形状不会:一处声明、启动即校验、类型明确、打印不外露。

.env 只给本地用

两条铁律:.env 必须进 .gitignore.env.example 必须进版本库,列出所有变量名、值留空 —— 它是「这个项目需要哪些配置」的唯一文档

⭐ 加载 .env 要用「不覆盖已有环境变量」的方式(Python 里就是 os.environ.setdefault),这定下优先级 代码默认值 < .env 文件 < 环境变量(平台注入) < 启动参数。⚠️ 写反了的后果很阴:某天一份 .env 混进了镜像,生产会安静地用上开发的数据库地址,而所有日志看起来都正常


🔐 三、密钥管理三档,和升级信号

别一上来就上 Vault。

长什么样 够用到什么时候
① 环境变量 PaaS 面板 / 服务器上直接填 1 个应用、1–3 人、密钥不超过 5 个 ⭐ 先用它
② 平台密钥管理 云厂商 Secrets Manager、K8s Secret、CI Secrets 多环境多服务,需要知道谁读过
③ 专用密钥服务 Vault 那类 动态凭证(用完即焚)、租户级独立吊销

升级信号(出现任意一条就该动):

   ① 给新同事发密钥,你是【复制粘贴到聊天窗口】的        → 升到 ②
   ② 轮换一次要改 6 个地方,而且你记不全是哪 6 个        → 升到 ②
   ③ 有人问「这个 key 现在有谁能看到」,你答不上来        → 升到 ②
   ④ 需要「这把凭证 1 小时后自动失效」                  → 升到 ③
   ⑤ 每个租户一套凭证,且要能单独吊销                   → 升到 ③

⚠️ 这些不是升级信号:「感觉不够专业」「大厂都用 Vault」。② 到 ③ 之间隔着一个需要有人值班的新组件,它自己也会挂。


💀 四、AI 应用特有的:泄露 = 别人拿你的钱调模型

和数据库密码泄露比,风险结构有三点不同:

① 绝不放前端 —— 加密也不行。 因为解密的代码也在前端:DevTools 的 Network 面板原样显示发出去的请求头,里面躺着解密后的明文。混淆、拆段拼接、塞进 WASM,全一样。唯一正确的形状是 浏览器 →(你的鉴权)→ 你的后端 →(你的 key)→ 供应商,前端从头到尾不知道 key 存在。

这是「为什么非要写一个后端」最硬的答案 —— 不是架构好看,是密钥必须待在用户碰不到的地方

② 绝不进日志。 最常见的泄露不是被黑,是自己写的:print(config)、dump 整个请求头排查问题、异常连同局部变量一起打出来。在格式化的最后一步兜一道网:

import logging, re, sys

PATS = [(re.compile(r"sk-[A-Za-z0-9_\-]{6,}"), "sk-***"),
        (re.compile(r"(?i)\b(api[-_]?key|authorization|password|token)\b(\s*[=:]\s*)\S+"),
         r"\1\2***")]


class ScrubFormatter(logging.Formatter):
    # ⭐ 在 Formatter 而不是 Filter 里擦:Filter 擦不到 traceback,而密钥最爱藏在那儿
    def format(self, record):
        t = super().format(record)
        for p, r in PATS:
            t = p.sub(r, t)
        return t


if __name__ == "__main__":
    h = logging.StreamHandler(sys.stdout)
    h.setFormatter(ScrubFormatter("%(levelname)s %(message)s"))
    log = logging.getLogger("demo")
    log.addHandler(h), log.setLevel(logging.INFO)
    log.info("启动配置 %s", {"api_key": "sk-abcdef123456"})
    try:
        raise RuntimeError('headers={"authorization": "Bearer sk-leak987654"}')
    except RuntimeError:
        log.exception("上游报错")

实跑输出里两处都变成了 sk-***包括 traceback 里那一行。⚠️ 但要说清局限:"authorization": "Bearer sk-..." 其实没被第二条规则命中(引号挡住了冒号),是被第一条 sk- 规则救下来的 —— 两条互为补充,任何一条单独都会漏。

⚠️ 脱敏是最后一道网,不是许可证。 它兜的是你没想到的那次,不是让你可以放心地把配置对象打出来

③ 绝不进错误上报。 Sentry 那类工具的卖点之一就是「自动抓取出错时的局部变量」—— 而 client = SomeLLM(api_key=...) 这行的局部变量里就是明文 key。⭐ 接入之后的第一件事,是打开敏感字段过滤并亲手触发一次异常,确认它真生效了。

④ 要能快速轮换。 标准:从「发现泄露」到「新 key 生效、旧 key 作废」是分钟级。做不到说明 key 被硬编码在某个你现在想不起来的地方。关键是两把 key 并存的过渡期

   ① 在供应商那边【新建】一把(不要先删旧的)
   ② 新 key 填进配置,滚动发布
   ③ 看用量看板确认新 key 有流量、服务正常
   ④ 这时才【吊销】旧 key            ⭐ 顺序反了就是一次自造的故障

⚠️ 删掉代码里的 key 不等于删掉它 —— 它还在 git 历史、shell history、那条贴过 curl 的 issue、notebook 的输出单元格、你演示时的录屏里。泄露之后唯一有效的动作是吊销,不是删除。

最后一层兜底:前四条全靠「你没犯错」,这一条不靠 —— 在模型供应商后台设每月消费硬上限用量告警(到 50% / 80% 各提醒一次),以及(如果支持)限定这把 key 只能调哪几个模型。它是成本护栏的最外层(第 12 章讲的应用内配额是里面几层),价值不在正常时候,而在于:所有内层护栏都是你写的代码,而写代码的人正是会犯错的那个。


🌍 五、多环境:dev / staging / prod

⭐⭐ 最重要的一条:三个环境之间只有配置不同,镜像和代码必须完全一样。

理由很直接 —— 如果 staging 跑的是另一份构建产物,那你在 staging 上测过的事,在 prod 上等于没测过。 这也是下一章要把应用打成一个不可变镜像的原因:同一个镜像换一组环境变量,就是另一个环境。

💀 最经典的重大事故是「开发连到了生产库」,通常发生在「我本地调一下那个数据问题」的时候,然后跑了个 DELETE。三个防法:

   ① 启动时打印一行: [启动] env=prod  db=prod-a  model=xxx
   ② 库名带环境标记,危险脚本开头断言 assert "dev" in settings.database_url
   ③ ⭐ 生产库分只读账号和读写账号,日常排查只用只读账号
      —— 这条最硬,因为前两条都依赖【人记得】

⚠️ AI 特有的多环境坑:很自然会想「dev 和 staging 用便宜的小模型省钱」。但那样 staging 就不再是 prod 的预演了 —— 换模型会改变输出格式、失败模式、延迟分布、上下文窗口上限,而这几样恰恰最容易翻车。⭐ 折中:dev 可以便宜,staging 必须和 prod 同款模型,用限量(每天固定调用配额)控成本,而不是用换模型控成本。

prompt 模板算配置还是代码? 建议当代码:跟版本库走、进 code review、能和某次发版对上 —— 改一句 prompt 就可能改变全部输出,这是发版级变更,不该在面板上被人随手改掉。 确实要不发版就改,就把它当带版本号的数据(日志里记录用的是哪个版本), 🔗 理由见模型上线之后 17改不了的东西才可复现。


🔄 六、换个栈怎么对应

概念 Python(本板块) Node(Express/Hono) Go
读环境变量 os.environ process.env os.Getenv
加载 .env python-dotenv dotenv / 🗓️ 新版 Node 自带 --env-file godotenv
配置对象 + 校验 pydantic-settings zod / envalid envconfig / viper
缺配置时 抛异常,进程退出 throwprocess.exit(1) log.Fatal
日志脱敏 自定义 Formatter pinoredact 选项 slogReplaceAttr

三个栈形状完全一样:一处声明全部配置 → 启动时校验 → 校验不过就别启动 → 打印时永远不吐密钥。会过期的是包名,不会过期的是这四步。


🔗 这一章连到哪里

去哪 为什么
密码学与信息安全 19 · 密钥管理 这一章讲密钥放在哪;密钥本身该怎么生成、派生、销毁,为什么不能随手 random() 一个,在那边
模型上线之后 17 · 版本回溯与可复现 当你想让 prompt「不发版就能改」时,先去看那边为什么改不了的东西才可复现
模型上线之后 18 · 成本与容量 供应商侧的消费上限只是最外层,容量规划和成本怎么摊回业务在那边
数据这一关 19 · 隐私脱敏与留存 本章脱敏只针对密钥;用户数据的脱敏和留存期是另一套要求,别混着做

✅ 检查点

  1. 判断一个东西是配置还是代码,分界线是什么?
  2. 散着写 os.getenv 的四个问题是什么?哪一个会让 "0" 被当成 True?
  3. 什么叫 fail fast?为什么「起不来」比「带病上线」好?
  4. 为什么 database_url 也要按密钥处理?加载 .env 为什么不能覆盖已有环境变量?
  5. 密钥三档分别是什么?哪三种信号该从 ① 升到 ②?哪两种才需要 ③?
  6. LLM API key 泄露和数据库密码泄露,风险结构上有哪三点不同?「加密后放前端」为什么没用?
  7. 轮换密钥的四步顺序是什么?为什么说「删掉代码里的 key 不等于删掉它」?
  8. 为什么说供应商侧的消费上限是唯一一层不依赖「你没犯错」的护栏?
  9. 三个环境必须保持什么完全一样?防「连错生产库」的三个办法里哪个最硬?
  10. 「staging 用便宜模型省钱」有什么问题?折中方案是什么?
👀 答案
  1. 换机器、换环境会变的是配置,不变的是代码。 模型名/超时/地址/密钥/开关是配置;重试的退避公式是代码。
  2. ① 拼错的键静默返回 None;② 永远是字符串——"0" 非空所以是 True 💀;③ 没有一处列全;④ 运行时才发现,那时已经在接流量。
  3. 启动第一件事就加载并校验全部配置,缺了或类型不对直接崩掉不启动。 因为起不来是发版时立刻看得见的,带病上线是用户替你发现的
  4. 连接串里带着密码,「URL 不算密钥」是常见误判。不覆盖是为了定下优先级 默认值 < .env < 环境变量 < 启动参数 —— 写反了,一份混进镜像的 .env 会让生产安静地用上开发的数据库地址,而日志看起来全正常
  5. ①环境变量(1 应用、1–3 人、≤5 个密钥)②平台密钥管理 ③Vault 那类。升 ②:发 key 靠复制粘贴到聊天窗轮换要改 6 个地方还记不全答不出谁能看到。只有动态凭证租户级独立吊销才需要 ③。
  6. ① 攻击者不需要别的东西(接口在公网,而连库通常有内网挡着);② 损失按秒累积,和发现得多晚成正比;③ 没有上限(除非你设了)。放前端没用是因为解密代码也在前端,DevTools 里就是明文请求头 —— 正确形状是浏览器 →(你的鉴权)→ 你的后端 →(你的 key)→ 供应商
  7. ①新建(别先删旧的)②滚动发布 ③确认新 key 有流量 ④这时才吊销旧的。删代码没用是因为 key 还在 git 历史、shell history、贴过 curl 的 issue、notebook 输出、演示录屏里 —— 唯一有效的动作是吊销
  8. 因为其余护栏都是你写的代码,而写代码的人正是会犯错的那个。
  9. 镜像和代码完全一样,只有配置不同 —— 否则 staging 测过等于没测。三个办法里只读账号最硬,另外两个都依赖人记得。
  10. 换模型会改变输出格式、失败模式、延迟分布、上下文窗口上限,staging 就不再是 prod 的预演。折中:dev 可以便宜,staging 必须同款,用限量而不是换模型控成本。

🛑 可以停在这里

走神救援

配置要能不改代码就换掉,密钥要能十分钟内换掉。 分界线:换机器/换环境会变的是配置;通道只有一条 —— 环境变量。❌ 散着写 os.getenv 的四个问题:拼错的键静默返回 None永远是字符串"0" 是 True 💀)、没有一处列全、运行时才发现。✅ 形状:一个 Settings 类,启动第一件事加载,缺了就崩掉不启动(fail fast) —— 起不来是发版时看得见的,带病上线是用户替你发现的;配上 frozen=True 只读、__repr__ 里密钥换 ***(你一定会 print(settings))、⚠️ database_url 也算密钥(连接串带密码)。.env 进 .gitignore.env.example 进版本库(配置项的唯一文档);加载时不覆盖已有环境变量,优先级 默认值 < .env < 环境变量 < 启动参数,写反了会让一份混进镜像的 .env 在生产安静地改回开发配置。🔐三档:①环境变量(1 应用、1–3 人、≤5 个密钥,先用它)②平台密钥管理 ③Vault 那类;升 ② 的信号:发 key 靠复制粘贴、轮换要改 6 个地方、答不出谁能看到;只有动态凭证和租户级吊销才需要 ③。💀AI 特有:key 泄露 = 别人拿你的钱调模型 —— 攻击者不需要别的东西损失按秒累积没有上限,而扫公开仓库找 sk- 是全天候自动化在跑的。四条铁律:①绝不放前端,加密也没用(解密代码也在前端)→ ⭐这正是「为什么要写后端」最硬的答案;②绝不进日志(在 Formatter 而不是 Filter 里擦,因为 Filter 擦不到 traceback;两条正则互为补充,⚠️脱敏是最后一道网不是许可证);③绝不进错误上报(它自动抓局部变量,接入后要亲手触发一次异常验证过滤真生效);④分钟级轮换先建新 → 切配置 → 确认有流量 → 最后才吊销旧的;⚠️ 删掉代码里的 key 不等于删掉它(git 历史、shell history、issue 里的 curl、notebook 输出、录屏)—— 唯一有效的动作是吊销。兜底:供应商侧消费硬上限 + 用量告警,⭐唯一一层不依赖「你没犯错」的护栏。🌍三环境:⭐⭐只有配置不同,镜像和代码必须完全一样,否则 staging 测过等于没测;💀连错生产库是经典事故,三个防法里只读账号最硬(另两个依赖人记得);⚠️ staging 用便宜模型 = 假验证(输出格式、失败模式、延迟分布、上下文上限全变了)→ staging 必须同款,用限量而不是换模型控成本

下一节 👉 14-容器化与部署.md

打卡记录保存在你的浏览器里,首页能看到总进度