📑 本页目录(点开跳转)
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 应用特有的:泄露 = 别人拿你的钱调模型
和数据库密码泄露比,风险结构有三点不同:
- 攻击者不需要别的东西 —— 供应商接口在公网,拿到 key 当场就能用;连你的库通常还有内网/白名单挡着。
- ⭐⭐ 损失按秒累积,和你发现得多晚成正比 —— 数据被拖走是一次性的,钱不是。
- 没有上限(除非你设了)。而扫公开仓库找
sk-开头字符串这件事,是自动化、全天候在跑的。
① 绝不放前端 —— 加密也不行。 因为解密的代码也在前端: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 |
| 缺配置时 | 抛异常,进程退出 | throw → process.exit(1) |
log.Fatal |
| 日志脱敏 | 自定义 Formatter |
pino 的 redact 选项 |
slog 的 ReplaceAttr |
⭐ 三个栈形状完全一样:一处声明全部配置 → 启动时校验 → 校验不过就别启动 → 打印时永远不吐密钥。会过期的是包名,不会过期的是这四步。
🔗 这一章连到哪里
| 去哪 | 为什么 |
|---|---|
| 密码学与信息安全 19 · 密钥管理 | 这一章讲密钥放在哪;密钥本身该怎么生成、派生、销毁,为什么不能随手 random() 一个,在那边 |
| 模型上线之后 17 · 版本回溯与可复现 | 当你想让 prompt「不发版就能改」时,先去看那边为什么改不了的东西才可复现 |
| 模型上线之后 18 · 成本与容量 | 供应商侧的消费上限只是最外层,容量规划和成本怎么摊回业务在那边 |
| 数据这一关 19 · 隐私脱敏与留存 | 本章脱敏只针对密钥;用户数据的脱敏和留存期是另一套要求,别混着做 |
✅ 检查点
- 判断一个东西是配置还是代码,分界线是什么?
- 散着写
os.getenv的四个问题是什么?哪一个会让"0"被当成 True? - 什么叫 fail fast?为什么「起不来」比「带病上线」好?
- 为什么
database_url也要按密钥处理?加载.env为什么不能覆盖已有环境变量? - 密钥三档分别是什么?哪三种信号该从 ① 升到 ②?哪两种才需要 ③?
- LLM API key 泄露和数据库密码泄露,风险结构上有哪三点不同?「加密后放前端」为什么没用?
- 轮换密钥的四步顺序是什么?为什么说「删掉代码里的 key 不等于删掉它」?
- 为什么说供应商侧的消费上限是唯一一层不依赖「你没犯错」的护栏?
- 三个环境必须保持什么完全一样?防「连错生产库」的三个办法里哪个最硬?
- 「staging 用便宜模型省钱」有什么问题?折中方案是什么?
👀 答案
- 换机器、换环境会变的是配置,不变的是代码。 模型名/超时/地址/密钥/开关是配置;重试的退避公式是代码。
- ① 拼错的键静默返回 None;② 永远是字符串——
"0"非空所以是 True 💀;③ 没有一处列全;④ 运行时才发现,那时已经在接流量。 - 启动第一件事就加载并校验全部配置,缺了或类型不对直接崩掉不启动。 因为起不来是发版时立刻看得见的,带病上线是用户替你发现的。
- 连接串里带着密码,「URL 不算密钥」是常见误判。不覆盖是为了定下优先级 默认值 < .env < 环境变量 < 启动参数 —— 写反了,一份混进镜像的
.env会让生产安静地用上开发的数据库地址,而日志看起来全正常。 - ①环境变量(1 应用、1–3 人、≤5 个密钥)②平台密钥管理 ③Vault 那类。升 ②:发 key 靠复制粘贴到聊天窗、轮换要改 6 个地方还记不全、答不出谁能看到。只有动态凭证和租户级独立吊销才需要 ③。
- ① 攻击者不需要别的东西(接口在公网,而连库通常有内网挡着);② 损失按秒累积,和发现得多晚成正比;③ 没有上限(除非你设了)。放前端没用是因为解密代码也在前端,DevTools 里就是明文请求头 —— 正确形状是浏览器 →(你的鉴权)→ 你的后端 →(你的 key)→ 供应商。
- ①新建(别先删旧的)②滚动发布 ③确认新 key 有流量 ④这时才吊销旧的。删代码没用是因为 key 还在 git 历史、shell history、贴过 curl 的 issue、notebook 输出、演示录屏里 —— 唯一有效的动作是吊销。
- 因为其余护栏都是你写的代码,而写代码的人正是会犯错的那个。
- 镜像和代码完全一样,只有配置不同 —— 否则 staging 测过等于没测。三个办法里只读账号最硬,另外两个都依赖人记得。
- 换模型会改变输出格式、失败模式、延迟分布、上下文窗口上限,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