2.2 密钥管理:Anthropic key / DB / Redis 本地注入
上一节的 Settings 有一处没解决:database_url 这类字段里往往嵌着密码,而 anthropic_api_key 更是实打实的凭据。如果 print(settings) 或者某个异常堆栈把它带出去,密钥就泄露了。Agent 服务尤其危险——它要同时持有模型厂商的 key、数据库口令、缓存口令,任何一处打日志都可能把一串凭据送进日志系统。
本节把 ResearchCopilot 推进到「凭据可安全流转」:用
SecretStr让密钥自带脱敏,用.env+.gitignore完成本地注入,启动时对必需密钥做 fail-fast,并给出一份密钥清单表。
2.2.1 三条铁律:不落库、不进镜像、不进日志
在写任何代码之前,先把目标钉死。密钥治理的一切手段,都是为了守住这三条:
| 铁律 | 含义 | 违反的后果 |
|---|---|---|
| 不落库 | 密钥不进业务数据库、不进事件表、不进会话历史 | 一次误查库就泄露全部租户凭据 |
| 不进镜像 | 密钥不写进 Dockerfile、不 COPY .env、不烤进层 | 镜像被推送到 registry 即全员可见 |
| 不进日志 | 密钥不出现在 print、日志、异常栈、trace 里 | 日志系统成为最大的凭据泄露面 |
三条线里,「不进日志」最容易在无意识中被打破。原因很简单:调试时打印一个配置对象太自然了,而 dataclass 或 BaseModel 的默认 __repr__ 会把每个字段原样吐出来。要堵住这个口子,得让密钥字段自己知道要脱敏。
2.2.2 SecretStr:让密钥自己会脱敏
pydantic 提供了 SecretStr 类型。它把真实值包在里面,repr / str 一律显示 **********,只有显式调用 get_secret_value() 才拿得到明文。把上一节的密钥字段换成它:
# researchcopilot/config.py(密钥相关部分)
from pydantic import SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Secrets(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="RESEARCH_", env_file=".env", extra="ignore"
)
use_fake_model: bool = True
anthropic_api_key: SecretStr | None = None
database_url: str = "postgresql://postgres:postgres@localhost:15435/research"
redis_url: str = "redis://localhost:16380/0"
@model_validator(mode="after")
def check_required(self) -> "Secrets":
if not self.use_fake_model and self.anthropic_api_key is None:
raise ValueError(
"use_fake_model=False 时必须提供 RESEARCH_ANTHROPIC_API_KEY"
)
return self
关键就一行:anthropic_api_key: SecretStr | None。类型一变,脱敏就是自动的——不需要在每处打印前记得手动打码,因为打码逻辑被焊死在类型里。model_validator(mode="after") 则负责跨字段检查,它的内容留到 2.2.5 展开。
2.2.3 真跑:repr 里的密钥是 **********
光说没用,跑一段看效果。给环境变量塞一个占位符(真实项目里是真实 key),然后从几个常见角度观察它会不会泄露:
# run_secret_demo.py
import os
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Secrets(BaseSettings):
model_config = SettingsConfigDict(env_prefix="RESEARCH_", env_file=".env", extra="ignore")
anthropic_api_key: SecretStr | None = None
database_url: str = "postgresql://postgres:postgres@localhost:15435/research"
redis_url: str = "redis://localhost:16380/0"
os.environ["RESEARCH_ANTHROPIC_API_KEY"] = "sk-ant-***"
s = Secrets()
print("repr(settings) :", repr(s))
print("str(settings) :", str(s))
print("字段 repr :", repr(s.anthropic_api_key))
print("model_dump(json):", s.model_dump(mode="json"))
真实输出:
repr(settings) : Secrets(anthropic_api_key=SecretStr('**********'), database_url='postgresql://postgres:postgres@localhost:15435/research', redis_url='redis://localhost:16380/0')
str(settings) : anthropic_api_key=SecretStr('**********') database_url='postgresql://postgres:postgres@localhost:15435/research' redis_url='redis://localhost:16380/0'
字段 repr : SecretStr('**********')
model_dump(json): {'anthropic_api_key': '**********', 'database_url': 'postgresql://postgres:postgres@localhost:15435/research', 'redis_url': 'redis://localhost:16380/0'}
四种常见的「意外泄露」路径,全被挡住了:
| 路径 | 不用 SecretStr | 用了 SecretStr |
|---|---|---|
repr(settings) | 明文 | SecretStr('**********') |
str(settings) | 明文 | SecretStr('**********') |
| f-string 插值 | 明文 | ********** |
model_dump(mode="json") | 明文 | '**********' |
要拿明文只有一条路:s.anthropic_api_key.get_secret_value()。把「读明文」变成一个显式动作,正是安全设计该有的样子——代码审查时,grep get_secret_value 就能定位所有真正接触明文的位置。
有一点要如实说明:SecretStr 挡的是「意外泄露」,挡不住恶意代码。任何拿到该对象的代码都能调 get_secret_value()。它的价值在于把泄露从「默认行为」变成「刻意行为」,而不是提供加密。
2.2.4 .env 本地注入与 .gitignore 的配合
密钥从哪来?本地开发最方便的是 .env:一个纯文本键值文件,pydantic-settings 通过 env_file=".env" 自动读取。上一节 SettingsConfigDict 里已经配好,新建一个:
# .env(本地专用,绝不提交)
RESEARCH_ANTHROPIC_API_KEY=sk-ant-***
RESEARCH_DATABASE_URL=postgresql://postgres:postgres@localhost:15435/research
RESEARCH_REDIS_URL=redis://localhost:16380/0
RESEARCH_USE_FAKE_MODEL=false
与它必须成对出现的,是 .gitignore:
# .gitignore
.env
research.json
__pycache__/
.env 和 .gitignore 是一个机制的两半,缺一不可。.env 负责「本地注入」,.gitignore 负责「永不提交」。只要 .env 进了 git 历史,即使后来删掉,凭据也已经在历史里躺着了——唯一的补救是轮换密钥。所以规则是:先写 .gitignore,再建 .env。仓库里应该提交的是一个 .env.example(只有键名和占位符,没有真值),让新同事知道要配哪些键。
读一遍 .env 验证它确实被载入:
# run_env_demo.py
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Secrets(BaseSettings):
model_config = SettingsConfigDict(env_prefix="RESEARCH_", env_file=".env", extra="ignore")
anthropic_api_key: SecretStr | None = None
use_fake_model: bool = True
s = Secrets()
print("从 .env 载入:", repr(s.anthropic_api_key), "| fake =", s.use_fake_model)
从 .env 载入: SecretStr('**********') | fake = False
密钥从文件读进来了(use_fake_model 被设成 False 证明 .env 生效),但它依然以脱敏形态存在。「从文件注入」和「打印时脱敏」是两件正交的事,前者管来源,后者管出口,两者叠加才完整。
2.2.5 启动时 fail-fast:缺必需密钥就拒绝启动
最后一环是时机。密钥缺失应该在服务启动时就发现,而不是等第一个用户请求进来、跑到调用模型那一步才崩。pydantic-settings 的 model_validator(mode="after") 正好能做这件事——它在所有字段解析完成后运行,适合做跨字段的完整性检查。
规则是:离线模式(use_fake_model=True)零凭据可跑,真模型模式(False)必须有 key。跑两段验证:
# run_failfast_demo.py
from pydantic import SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Secrets(BaseSettings):
model_config = SettingsConfigDict(env_prefix="RESEARCH_", env_file=".env", extra="ignore")
use_fake_model: bool = True
anthropic_api_key: SecretStr | None = None
@model_validator(mode="after")
def check_required(self) -> "Secrets":
if not self.use_fake_model and self.anthropic_api_key is None:
raise ValueError(
"use_fake_model=False 时必须提供 RESEARCH_ANTHROPIC_API_KEY"
)
return self
print("== 离线模式:缺密钥也放行 ==")
s1 = Secrets(use_fake_model=True)
print("OK use_fake_model =", s1.use_fake_model, " key =", s1.anthropic_api_key)
print()
print("== 真模型模式:缺密钥 fail-fast ==")
try:
Secrets(use_fake_model=False)
except Exception as e:
print(type(e).__name__)
print(e)
真实输出:
== 离线模式:缺密钥也放行 ==
OK use_fake_model = True key = None
== 真模型模式:缺密钥 fail-fast ==
ValidationError
1 validation error for Secrets
Value error, use_fake_model=False 时必须提供 RESEARCH_ANTHROPIC_API_KEY [type=value_error, input_value={'use_fake_model': False}, input_type=dict]
For further information visit https://errors.pydantic.dev/2.14/v/value_error
注意这里的设计张力:离线模式刻意不要求 key。这保证了任何人在没密钥的机器上都能 git clone 后直接跑测试——这正是本卷「假模型是默认路径」的落地方式。而一旦有人把 use_fake_model 关掉,启动立刻失败,报错信息直白地点名缺哪个环境变量。该宽松时宽松,该严格时严格,界线由 use_fake_model 这个开关划清。
2.2.6 密钥清单表
把 ResearchCopilot 需要的凭据整理成一张表,团队交接时按它逐项核对:
| 键名 | 必需性 | 缺失时行为 | 本地来源 | 生产来源 |
|---|---|---|---|---|
RESEARCH_ANTHROPIC_API_KEY | 真模型模式必需 | 启动 fail-fast | .env | Secret Manager |
RESEARCH_DATABASE_URL | 必需 | 启动 fail-fast(无默认) | .env | Secret Manager |
RESEARCH_REDIS_URL | 可选 | 回落到内存缓存 | .env | Secret Manager |
RESEARCH_USE_FAKE_MODEL | 可选 | 默认 True(离线) | .env | 环境变量 |
RESEARCH_JWT_SECRET | 第 4 章起必需 | 启动 fail-fast | .env | Secret Manager |
两条读表规则:「必需」一律对应启动期 fail-fast,绝不拖到运行时;「可选」必须有明确降级路径(比如 Redis 缺失回落到进程内缓存),而不是留一个 None 到处判断。凡是没有降级路径又标成可选的字段,都是潜在的线上事故。
2.2.7 验收清单与真实环境
密钥治理的验收:
- 所有密钥字段用
SecretStr,仓库里搜不到明文sk-ant-。 -
.env在.gitignore里,仓库只提交.env.example。 - 必需密钥缺失时服务启动即失败,报错点名具体环境变量。
- 密钥不出现在任何日志、trace、异常栈里。
真实环境里的差异有三点。其一,密钥不再来自 .env,而是来自 Vault 或云厂商 Secret Manager,pydantic-settings 有对应的 SettingsSource 可以无缝接入;.env 只服务于本地。其二,密钥要能轮换:凭据泄露时得能立刻换新,所以业务代码应当每次从 Settings 取、而不是把它缓存进某个长生命周期的对象。其三,要防日志:即便用了 SecretStr,也应在日志配置里对已知键名做一次脱敏兜底(第 15 章的审计与脱敏会展开)。
密钥管好了,接下来要决定「用真模型还是假模型」。下一节 2.3 模型路由与降级 就把这个开关接成一条可降级的链路。想回看配置是怎么分层的,翻 2.1 分层配置:pydantic-settings 加载与校验 。
阅读导航:上一节:2.1 分层配置:pydantic-settings 加载与校验 · 下一节:2.3 模型路由与降级:真/假模型开关,离线默认假模型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。