你把 FastAPI 接口写完,在自己的电脑上跑得很顺,部署时却突然连错数据库;或者端口明明填了 8200,代码拿到的却是一段字符串;再糟一点,某个调试日志顺手把令牌一起打印了出来。这样的事故通常不在路由,也不在业务逻辑,而在应用启动前的那几秒:配置从哪里来、按什么规则覆盖、怎样变成可靠的类型,又在什么时候被校验。
这一课我们用一个“课程接口”贯穿全过程。它需要应用名称、运行环境、端口、数据库地址、允许的操作、缓存地址和接口令牌。我们会让同一份代码分别接收本地、测试和生产配置,并故意喂给它几组错误值,看看错误能不能在应用接收请求前暴露出来。
学完以后,你手里会有一套可以直接带到真实项目中的结构:Settings 只负责读取、转换和校验,FastAPI 通过依赖拿配置,测试通过覆盖依赖换配置,部署系统只负责把值送进进程。各层边界清楚,配置就不会变成散落在项目里的隐形全局变量。
先别急着写 BaseSettings。配置管理真正要解决的,是把“会随运行环境变化的值”从代码中拿出去。数据库地址、第三方服务地址、调试开关、日志级别、端口和密钥都属于配置;价格计算、权限规则、课程状态转换这类业务规则不属于配置,它们应该留在代码和数据模型里。
最容易出问题的写法,是在模块里直接写死生产相关的值:
DATABASE_URL = "postgresql://course_user:password@db.internal/course"
DEBUG = False
API_TOKEN = "a-real-token"这段代码能跑,但它把三个本应独立变化的东西和代码绑在了一起。测试若要换数据库,就得改文件;令牌轮换后要重新构建镜像;有人为了排错打开 DEBUG,这个变化还可能跟着提交记录进入其他环境。代码审查也很难判断某个字符串究竟只是示例,还是正在使用的凭据。
更稳妥的结构是让值从外面进入,经过一个配置模型,再交给应用:

图中五个来源不是简单地“谁先读到算谁的”。它们有明确的优先级。以本课使用的默认设置为例,构造 Settings(...) 时显式传入的值优先于进程环境变量,环境变量优先于本地环境文件,本地环境文件又优先于密钥目录和模型默认值。高优先级来源只覆盖同名字段,不会把低优先级来源整份丢掉。
你可以把这个过程理解成一次分字段对账:应用名称可能来自本地文件,端口可能被环境变量覆盖,缓存端口可能使用默认值。最后得到的不是某个文件的原样内容,而是一份合并、转换并通过校验的配置对象。
配置模型保存的是应用启动所需的最终结果,不应该顺便承担联网拉取业务数据、创建数据库连接或调用外部接口等工作。读取配置应当快速、确定,并且在失败时给出清楚的错误。
Python 可以直接通过 os.environ 读取进程环境。先看一个没有配置模型的版本:
import os
port = os.environ["LEARN_API_PORT"]
debug = os.environ.get("LEARN_DEBUG", "false")
print(type(port).__name__, port)
print(type(debug).__name__, debug)即使启动进程时写了 LEARN_API_PORT=8200,这里得到的仍是字符串 "8200"。LEARN_DEBUG=false 也不是 Python 的布尔值 False,而是五个字符组成的字符串。直接写 if debug: 甚至会进入真分支,因为非空字符串在条件判断中为真。
列表和嵌套对象更能暴露这条边界。环境变量没有“字符串列表”这种原生类型。若字段需要 list[str],就要先约定一种可传输的表示法,再解析成 Python 值。pydantic-settings 对列表、字典和子模型等复杂类型默认按 JSON 字符串解析,因此 LEARN_ALLOWED_ACTIONS='["read", "write"]' 可以变成 list[str],而 read,write 不是合法 JSON,会在加载配置时失败。

配置模型在这里干了两件不同的事。第一步是转换,例如把 "8200" 转成整数,把 "false" 转成布尔值;第二步是校验,例如确认端口在 1 到 65535 之间,运行环境只能取我们允许的几个值。能转换不代表一定合法,转换和校验都通过,调用方才会拿到字段。
还有一个很容易漏掉的输入:空字符串。环境变量被设置为 LEARN_API_PORT= 时,它与“没有设置这个变量”不是一回事。前者是一个存在但内容为空的字符串,后者才会回退到低优先级来源或默认值。是否忽略空值需要团队明确约定;不要让部署脚本用空字符串含糊地表达“删除”或“使用默认值”。
先安装 Pydantic v2 对应的设置包:
python -m pip install pydantic-settings把配置单独放在 app/config.py。下面这份模型会贯穿后面的本地运行、错误演示、FastAPI 依赖和测试:
from functools import lru_cache
from typing import Literal
from pydantic import BaseModel, Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class CacheSettings(BaseModel):
host: str = "cache.example.test"
port: int = 6380
class Settings(BaseSettings):
model_config = SettingsConfigDict(
我们逐块看这段代码。
BaseSettings 仍然是一个 Pydantic 模型,所以字段类型、Field 约束、字段校验器和模型校验器都能用。不同之处在于:构造函数没有收到某个字段时,它还会继续从环境变量、本地环境文件和密钥文件等来源寻找值。database_url 和 api_token 没有默认值,任何来源都没提供时,Settings() 会直接抛出 ValidationError。
Literal 把运行环境限制在三个明确值中。这样部署脚本把 production 错写成 prod 时,应用不会默默退回开发模式。api_port 同时声明了类型和范围,"8200" 可以转成 8200,但 "70000" 即使能转成整数,也过不了范围校验。
CacheSettings 把一组相关字段收在子模型里。路由拿到的是 settings.cache.host 和 settings.cache.port,而不是一堆越来越长的顶层字段。default_factory 为列表创建新的默认对象,避免多个实例意外共享可变列表。
“调试模式必须是布尔值”只涉及一个字段;“生产环境必须关闭调试模式”同时涉及 environment 和 debug。这种规则放进 model_validator 更合适。创建完成后,应用拿到的 Settings 已经保证字段组合可用,不需要在数据库模块、路由和任务模块里分别重复判断。
不要在校验器里悄悄修正危险输入。例如看到生产环境使用 SQLite 时自动改成另一个地址,看起来省事,却可能把应用连向意料之外的位置。配置错误应该报告给部署者,由部署者修正输入。
真实项目的部署变量名和 Python 字段名经常不完全一致。代码喜欢 snake_case,部署平台可能要求统一前缀,历史流水线还可能保留旧变量名。pydantic-settings 提供了前缀、字段别名和嵌套分隔符,让这层映射集中在配置模型中。

env_prefix="LEARN_" 表示普通字段会查找带这个前缀的变量。例如:
LEARN_APP_NAME -> app_name
LEARN_DEBUG -> debug
LEARN_API_PORT -> api_port
LEARN_DATABASE_URL -> database_url前缀特别适合一台机器运行多个服务的场景。课程接口读取 LEARN_DEBUG,另一个服务可以读取自己的前缀,不会因为一个笼统的 DEBUG 相互影响。
默认情况下,环境变量名匹配不区分大小写,但不要依赖不同操作系统对大小写的处理差异。部署文件统一使用大写变量名,Python 字段统一使用小写下划线,是更容易检查的约定。如果业务真的要求大小写敏感,可以显式开启 case_sensitive=True。
模型字段叫 api_token,外部变量却叫 LEARN_TOKEN。这里用的是:
api_token: SecretStr = Field(
validation_alias="LEARN_TOKEN",
min_length=16,
)validation_alias 控制输入时查找的名称,但不会强迫我们在 Python 代码里也写 settings.LEARN_TOKEN。业务代码始终使用语义清楚的 settings.api_token。
别名还有一个细节:显式别名本身就是完整输入名,默认不会再自动拼上 env_prefix。所以这里写的是完整的 LEARN_TOKEN,不是只写 TOKEN 后期待框架追加前缀。迁移旧系统时,如果一个字段需要暂时兼容多个名称,可以定义候选别名;等旧部署全部下线,再删掉兼容入口。
env_nested_delimiter="__" 让扁平的环境变量表达嵌套对象:
LEARN_CACHE__HOST=cache.local.test
LEARN_CACHE__PORT=6380加载后对应:
settings.cache.host # "cache.local.test"
settings.cache.port # 6380分隔符应该选择一个不容易和字段名混淆的形式,双下划线是常见选择。嵌套模型也要继承 BaseModel,这样子字段能按同一套类型规则进行转换和校验。
本地开发时,每次打开终端都手动设置十几个变量很麻烦。我们可以准备两层文件:.env 放团队共享的非敏感本地默认值,.env.local 放每个开发者自己的覆盖值。示例中的配置声明:
env_file=(".env", ".env.local")同一个字段同时出现在两个文件中时,后面的 .env.local 覆盖前面的 .env;进程环境变量的优先级又高于这两个文件。
.env 可以这样写,所有内容都使用虚构值:
LEARN_ENVIRONMENT=development
LEARN_DEBUG=false
LEARN_API_PORT=8000
LEARN_DATABASE_URL=sqlite:///./course.db
LEARN_TOKEN=local-example-token
LEARN_ALLOWED_ACTIONS=["read", "write"]
LEARN_CACHE__HOST=cache.local.test
LEARN_CACHE__PORT=6380.env.local 只放需要覆盖的项:
LEARN_APP_NAME=本地课程接口
LEARN_API_PORT=8100若启动进程前再设置 LEARN_API_PORT=8200,环境变量会覆盖 .env.local 中的 8100。把设置加载后打印一份经过筛选的结果,会看到:
{
"app_name": "本地课程接口",
"environment": "development",
"debug": false,
"api_port": 8200,
"allowed_actions": [
"read",
"write"
],
"cache": {
"host": "cache.local.test",
"port": 6380
},
"api_token": "**********"
}这里同时验证了三件事:高优先级来源覆盖低优先级来源,字符串被转换成目标类型,令牌没有随着配置摘要暴露。
只要本地环境文件里可能出现口令、令牌或带密码的连接串,就必须把它加入版本控制忽略规则。仓库可以提供 .env.example,但示例文件只写变量名、格式和虚构值,不能把真实值复制过去再期待后来删除。
本地环境文件的位置按当前工作目录解析。若应用可能从不同目录启动,最好在代码中用项目根目录构造确定路径,或者由启动命令显式传入环境文件。不要依赖“框架会向父目录一路帮我找”的猜测,否则同一条命令换个工作目录就可能读取另一份配置。
多环境管理不是为每个环境复制一个 Settings 类。字段契约应该只有一份,变化的是输入值。这样同一个镜像可以在不同地方运行,生产构建也不会因为某个配置变化而重新打包。

可以把三套环境的责任分成这样:
不要让 environment 字段自己决定要读取哪一份生产凭据。更清楚的方向是:运行环境把正确的值提供给模型,模型只验证这组值是否符合当前环境的约束。否则应用可能先从一个不可信的来源读到 environment,再根据它去选择更敏感的文件,排查优先级会变得很绕。
生产环境还可以在构造时显式禁用本地环境文件:
settings = Settings(_env_file=None)这样即使镜像工作目录里意外残留 .env,生产进程也不会读取它。部署值必须来自明确的环境变量或密钥目录,缺一项就启动失败。
配置失败最糟糕的表现,是应用已经通过健康检查,等用户第一次访问数据库才发现连接串没填。那时错误离根因已经很远:路由报的是连接失败,真正的问题却发生在部署注入阶段。
我们故意构造一份危险的生产配置:开启调试模式,并连接本地 SQLite。
from pydantic import SecretStr, ValidationError
try:
Settings(
_env_file=None,
environment="production",
debug=True,
database_url="sqlite:///./wrong.db",
api_token=SecretStr("production-fake-token"),
)
except ValidationError as exc:
error = exc.errors(include_url=False
校验器先报告第一条跨字段规则:
{
"type": "value_error",
"loc": [],
"msg": "Value error, 生产环境必须关闭调试模式"
}把调试模式修正为 False 后再次加载,才会继续暴露生产环境使用 SQLite 的问题。这样的逐项修复比带着错误配置继续启动安全得多。
为了保证失败发生在启动阶段,应用创建时就先调用缓存后的配置函数:
from typing import Annotated
from fastapi import Depends, FastAPI
from .config import Settings, get_settings
startup_settings = get_settings()
app = FastAPI(
title=startup_settings.app_name,
docs_url=(
None
if startup_settings.environment == "production"
else "/docs"
),
)
@app.get(
如果 get_settings() 在这里失败,模块加载就中止,服务不会进入可接收请求的状态。对于必须连接的数据库和缓存,还可以在 FastAPI 的 lifespan 启动部分使用已经校验过的地址创建连接;创建失败时同样不要进入 yield。
启动失败并不是配置系统“太严格”。对必填配置来说,立刻失败会把问题留在部署阶段;带病启动才会把同一个问题扩散到请求、任务和数据写入阶段。
如果 get_settings() 每次被依赖调用都执行 Settings(),每个请求都要重新读取文件、扫描环境并做一遍校验。配置在一个进程生命周期里通常不变,因此可以用 @lru_cache 让无参数函数只创建一次实例。

这里缓存的是 Python 进程内的配置对象,不是跨机器缓存。每个工作进程都会创建自己的实例,滚动部署后的新进程也会重新读取配置。如果环境变量或密钥发生变化,已经缓存的进程不会自动得到新值;常见做法是更新配置后重启或滚动替换进程。
FastAPI 路由仍然通过 Depends(get_settings) 取配置,而不是到处导入一个全局对象。这层看似多写了一行,却给测试留出了一个很干净的替换点:
from fastapi.testclient import TestClient
from pydantic import SecretStr
from app.config import Settings, get_settings
from app.main import app
def test_info_uses_testing_settings():
test_settings = Settings(
environment="testing",
database_url="sqlite://",
api_token=SecretStr("testing-example-token"),
allowed_actions=[
请求返回 200,核心结果是:
{
"app_name": "本地课程接口",
"environment": "testing",
"debug": false,
"api_port": 8100,
"allowed_actions": [
"read"
],
"cache": {
"host": "cache.local.test",
"port": 6380
},
"api_token": "**********"
}dependency_overrides 的键必须是应用中原来使用的依赖函数,值是替代函数。测试结束要清空覆盖,避免后面的测试继承这份状态;改动过缓存相关输入时也要调用 cache_clear()。更完整的测试套件可以把这两步放进 pytest fixture 的清理阶段。
测试配置要覆盖的不只是“接口能返回 200”。至少还应检查必填字段缺失、端口越界、复杂类型不是合法 JSON、生产组合违规、环境变量覆盖本地文件、嵌套字段映射以及敏感字段不会出现在响应或日志中。配置越早被测试,部署时留给猜测的空间越小。
密钥也是配置,但它不是普通字符串。数据库口令、签名密钥和第三方令牌一旦进入源代码、镜像层或日志,就很难确认所有副本都被清理。正确方向是由部署系统在运行时提供,应用只把它交给真正需要的组件。

SecretStr 解决的是“减少意外展示”,不是加密存储。打印配置对象或把字段转成字符串时,它默认显示星号:
print(settings.api_token)
# **********只有真正调用需要明文的客户端时,才取出原值:
token = settings.api_token.get_secret_value()
client = CourseClient(token=token)不要为了方便写一个“把全部配置转成明文字典”的通用方法,更不要把 get_secret_value() 的结果放进异常消息、调试响应或结构化日志。带密码的数据库连接串也应当脱敏,因为密码往往藏在网址中间,不一定有单独的 password 字段。
容器平台通常支持两种投递方式:把密钥映射成环境变量,或者挂载成只读文件。环境变量接入简单,现有 Settings 可以直接读取;只读文件能让应用只获得明确挂载的键,也适合 Docker Secret 和 Kubernetes Secret 卷。
pydantic-settings 可以把“文件名是字段名、文件内容是字段值”的目录作为密钥来源。我们这个字段使用了完整别名 LEARN_TOKEN,因此挂载文件名也按这个别名准备:
/run/secrets/
└── LEARN_TOKEN生产启动时指定密钥目录,并禁用本地环境文件:
settings = Settings(
_env_file=None,
_secrets_dir="/run/secrets",
)在默认优先级下,进程环境变量会覆盖同名密钥文件。团队应明确某个密钥只走一条主要路径,避免环境变量和挂载文件同时存在却值不同。Kubernetes Secret 中的 Base64 表示也不是加密;权限控制、命名空间隔离、静态加密和审计仍然要由集群侧完成。应用容器只挂载自己需要的键,不要把整个团队的密钥集合都交给一个进程。
密钥轮换后,环境变量形式通常需要重启进程才能生效;文件挂载可能会更新内容,但本课的 lru_cache 配置实例仍然持有旧值。最容易理解的策略是让轮换触发滚动重启,使每个新进程重新完成读取、校验和连接初始化。
配置管理不是写完 Settings 类就结束。它需要一套每次发布都能重复执行的检查。你可以按下面的顺序过一遍:
先检查字段契约。每个会随环境变化的值都进入配置模型,必填项不提供伪生产默认值,端口、枚举和跨字段关系有明确约束。
再检查来源和优先级。本地文件、环境变量、初始化参数与密钥目录各自负责什么要写清楚;生产启动显式禁用不需要的本地文件来源。
接着检查敏感信息。仓库、镜像构建记录、异常消息、接口响应和日志都不应出现明文密钥;业务模块只在真正调用外部服务时取出明文。
然后运行配置测试。覆盖正确输入、缺失输入、错误类型、非法组合、来源覆盖、嵌套映射、依赖覆盖和缓存清理。
到这里,配置在项目中的位置就很清楚了:部署环境提供字符串或密钥文件,Settings 把输入合并成经过验证的 Python 对象,FastAPI 依赖把同一份对象交给路由和服务,测试再从依赖边界换入隔离配置。代码不需要知道值来自开发机、持续集成还是生产集群,只需要相信拿到的配置已经通过契约。
如果要把本课结构带进现有项目,最先做的不是增加更多配置文件,而是把散落的 os.getenv() 和写死值列出来,逐项迁入一个配置模型。迁移一项,补一条校验和一组测试。等所有调用方都通过依赖取得配置后,再收紧生产来源和启动失败规则,会比一次性重构更容易验证。
最后验证启动失败路径。拿掉一个必填值或故意设置危险组合,确认应用在接收请求前退出,并且错误消息能指出应修正的字段或规则。