Python 基础体系 · 第 49/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 配置管理:环境变量、文件、校验、Secret 与多环境
配置管理不是“从某个地方读几个字符串”,而是把外部输入转换成当前进程可以安全使用、结构明确、可验证、可追踪的运行参数。
一个生产配置系统至少要回答六个问题:
- 配置值来自哪里?
- 多个来源同时提供值时,谁覆盖谁?
- 字符串如何转换成整数、布尔值、列表、URL 或时间间隔?
- 什么算合法配置?
- Secret 如何注入、访问、脱敏和轮换?
- 配置在开发、测试、预发布和生产之间如何变化,同时避免环境逻辑污染业务代码?
本文以 Python 3.14 标准库为基础,再引入 Pydantic v2 与 pydantic-settings,构造一套从读取、合并、校验、使用到故障处理的完整模型。
一、先定义“配置”:外部输入与运行时配置不是同一个东西
配置源是保存原始配置值的载体,例如:
- 环境变量;
- TOML、JSON、INI 等文件;
- Secret 文件;
- 命令行参数;
- 云端参数中心或密钥服务;
- 测试代码传入的显式参数。
原始配置通常是字符串或未校验的字典:
APP_PORT=8080
APP_DEBUG=false
APP_ALLOWED_HOSTS=["api.example.com", "admin.example.com"]
运行时配置则应该具有稳定的类型和业务不变量:
port: int
debug: bool
allowed_hosts: list[str]
这两者之间存在一个转换函数:
其中:
- 表示第 个配置源;
- 表示按照优先级合并;
- 表示名称映射,例如
APP_DB__HOST映射为db.host; - 表示类型解析和业务校验;
- 表示最终运行时配置。
这个顺序不能随意交换。
例如,下面两种做法的语义不同:
先合并字符串,再解析最终结果
先分别解析,再尝试合并对象
对于嵌套配置,通常应该采用前者,因为高优先级来源可能只覆盖对象的一部分:
文件:db = {"host": "db.internal", "port": 5432}
环境变量:APP_DB__HOST=db-canary
最终:db = {"host": "db-canary", "port": 5432}
如果先把文件解析成模型,再把环境变量解析成另一个模型,部分覆盖、默认值和字段合并的规则会变得不清晰。
二、环境变量:简单、适合部署,但本质上是字符串
2.1 Python 如何访问环境变量
Python 通过 os.environ 和 os.getenv() 访问进程环境。
import os
print(os.getenv("APP_ENV", "development"))
if "APP_PORT" not in os.environ:
raise RuntimeError("APP_PORT is required")
port_text = os.environ["APP_PORT"]
os.environ 是一个映射对象,键和值都是字符串。它通常在 Python 启动期间被捕获;在 Python 外部修改当前进程环境时,已有的 os.environ 不一定立即反映这些变化。Python 3.14 新增了 os.reload_environ(),用于重新加载环境,但该函数不是线程安全的,重载期间读取环境可能得到空结果。(docs.python.org)
常规应用不应该依赖运行过程中从外部动态修改环境。更合理的模型是:
- 进程启动;
- 读取环境;
- 校验配置;
- 配置对象保持稳定;
- 需要变更时重新构造配置并切换引用,或者重启进程。
直接修改当前进程环境也只影响当前进程及其后续创建的子进程,并不会修改启动它的 Shell 或容器编排系统:
import os
import subprocess
os.environ["CHILD_MODE"] = "safe"
subprocess.run(
["python", "-c", "import os; print(os.getenv('CHILD_MODE'))"],
check=True,
)
预期输出:
safe
但父 Shell 中并不会因此出现永久性的 CHILD_MODE。
2.2 getenv() 与 [] 的差异
下面两种访问分别表达不同意图:
timeout = os.getenv("APP_TIMEOUT", "5")
port = os.environ["APP_PORT"]
os.getenv():变量不存在时返回默认值;os.environ["APP_PORT"]:变量不存在时抛出KeyError。
不要把所有变量都写成:
value = os.getenv("MISSING_KEY")
因为这样会把“未配置”变成 None,错误可能直到业务请求到来才暴露。对于必须存在的配置,应在启动阶段失败:
try:
database_url = os.environ["DATABASE_URL"]
except KeyError as exc:
raise RuntimeError("DATABASE_URL is required") from exc
启动失败是可诊断的;运行中才失败通常意味着健康检查已经误判,或者服务已经接收了请求。
2.3 环境变量的三个边界
边界一:所有值都是字符串
import os
os.environ["DEBUG"] = "false"
print(bool(os.environ["DEBUG"]))
输出是:
True
原因是非空字符串都是真值。正确做法必须定义布尔解析规则:
def parse_bool(value: str) -> bool:
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise ValueError(f"invalid boolean: {value!r}")
边界二:列表没有统一语法
下面的值到底表示什么?
a,b,c
它可能表示:
- 逗号分隔的三个元素;
- 一个包含逗号的单个元素;
- CSV;
- JSON 解析失败的数据。
配置系统必须规定格式。如果采用 JSON:
APP_ALLOWED_HOSTS=["api.example.com","admin.example.com"]
如果采用逗号分隔:
APP_ALLOWED_HOSTS=api.example.com,admin.example.com
不要在不同字段中混用两种规则。
边界三:环境变量有平台差异
在 Windows 上,Python 的 os 环境变量键会转换为大写,环境变量名匹配也具有不区分大小写的行为;在 Unix 系统上通常区分大小写。(docs.python.org)
因此,工程中应统一命名,例如:
APP_ENV
APP_PORT
APP_DATABASE__HOST
APP_DATABASE__PASSWORD
并在配置库中明确大小写策略,而不是依赖操作系统的偶然行为。
三、配置文件:适合结构化默认值,但不应自动等同于 Secret
配置文件的优势是:
- 可以表达嵌套结构;
- 可以进行版本控制;
- 可以提供开发和测试默认值;
- 可以让配置评审更加直观。
配置文件的风险是:
- 文件可能被提交到代码仓库;
- 文件权限可能过宽;
- 文件路径可能受当前工作目录影响;
- 文件格式本身通常不提供加密;
- 文件内容可能出现在备份、镜像层或日志中。
因此,“文件配置”和“Secret 文件”必须区分:
config.toml # 非敏感配置
/run/secrets/... # Secret 注入目录
3.1 Python 3.14 中使用 TOML
Python 标准库中的 tomllib 用于读取 TOML。它只提供读取 API,不提供写入 API;解析非法 TOML 时会抛出 TOMLDecodeError。官方文档同时提醒,解析不可信 TOML 时应限制输入大小,因为恶意内容可能消耗大量 CPU 和内存。(docs.python.org)
一个配置文件可以写成:
# config.toml
environment = "development"
debug = true
port = 8080
allowed_hosts = ["localhost", "127.0.0.1"]
[database]
host = "localhost"
port = 5432
name = "demo"
读取代码:
from pathlib import Path
import tomllib
config_path = Path("config.toml")
try:
with config_path.open("rb") as file:
raw_config = tomllib.load(file)
except FileNotFoundError as exc:
raise RuntimeError(f"configuration file not found: {config_path}") from exc
except tomllib.TOMLDecodeError as exc:
raise RuntimeError(f"invalid TOML: {config_path}") from exc
print(raw_config)
预期结构:
{
"environment": "development",
"debug": True,
"port": 8080,
"allowed_hosts": ["localhost", "127.0.0.1"],
"database": {
"host": "localhost",
"port": 5432,
"name": "demo",
},
}
这里使用 "rb" 而不是 "r",是因为 tomllib.load() 接收可读的二进制文件对象。tomllib.loads() 则接收字符串。(docs.python.org)
3.2 用 pathlib 处理配置路径
pathlib.Path 表示具体文件系统路径,能够避免手工拼接路径时的分隔符问题。它同时提供文件打开、存在性检查等操作。(docs.python.org)
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
CONFIG_DIR = BASE_DIR / "config"
CONFIG_FILE = CONFIG_DIR / "config.toml"
不要默认把配置路径解释为“相对于代码文件”。部署时常见的路径基准包括:
- 当前工作目录;
- 代码目录;
- 容器内固定目录;
- 命令行显式传入的路径;
- 操作系统配置目录。
如果路径来自环境变量:
import os
from pathlib import Path
config_path = Path(
os.getenv("APP_CONFIG_FILE", "config/config.toml")
).expanduser()
expanduser() 只能展开 ~,不会自动保证路径存在,也不会自动阻止路径穿越。若配置路径来自不可信输入,应增加允许目录约束:
allowed_dir = Path("/etc/myapp").resolve()
candidate = Path(user_input).expanduser().resolve()
try:
candidate.relative_to(allowed_dir)
except ValueError as exc:
raise ValueError("configuration path escapes allowed directory") from exc
四、配置来源合并:先定义优先级,再写代码
当多个来源提供同一个字段时,系统必须定义一个全序或至少定义可比较的优先级。
一种常见的优先级,从高到低是:
但这不是 Python 标准库强制规定的顺序,而是应用或配置框架的策略。Pydantic Settings 当前文档给出的默认优先级是:CLI、初始化参数、环境变量、dotenv 文件、Secret 目录、字段默认值;如果加入 TOML 自定义源,还需要明确它插入到哪一层。(docs.pydantic.dev)
4.1 覆盖不是简单的字典更新
最简单的配置合并是:
merged = {
**defaults,
**file_config,
**environment_config,
}
这只适用于扁平配置。对于嵌套字典:
defaults = {
"database": {
"host": "localhost",
"port": 5432,
}
}
override = {
"database": {
"host": "db.internal",
}
}
直接更新:
merged = defaults | override
结果是:
{
"database": {
"host": "db.internal",
}
}
port 丢失了。
因此需要递归合并:
from collections.abc import Mapping
from typing import Any
def deep_merge(
base: dict[str, Any],
override: Mapping[str, Any],
) -> dict[str, Any]:
result = dict(base)
for key, value in override.items():
old_value = result.get(key)
if (
isinstance(old_value, dict)
and isinstance(value, Mapping)
):
result[key] = deep_merge(old_value, value)
else:
result[key] = value
return result
测试:
defaults = {
"database": {
"host": "localhost",
"port": 5432,
}
}
override = {
"database": {
"host": "db.internal",
}
}
print(deep_merge(defaults, override))
输出:
{
"database": {
"host": "db.internal",
"port": 5432,
}
}
但递归合并也有边界。列表通常不应该逐项合并:
base = {"hosts": ["a.example.com", "b.example.com"]}
override = {"hosts": ["canary.example.com"]}
更合理的语义通常是整个列表替换:
{"hosts": ["canary.example.com"]}
所以合并规则必须按数据类型定义:
| 类型 | 常见语义 |
|---|---|
| 标量 | 高优先级值覆盖低优先级值 |
| 字典 | 递归合并 |
| 列表 | 整体替换 |
| Secret | 只允许受信来源覆盖 |
| 未知字段 | 拒绝或显式忽略 |
4.2 推荐的数据流
flowchart LR
A[默认值] --> M[来源合并]
B[TOML/JSON 文件] --> M
C[Secret 文件] --> M
D[环境变量] --> M
E[命令行参数] --> M
M --> N[名称映射]
N --> P[类型解析]
P --> V[字段与模型校验]
V --> R[不可变或受控的运行时配置]
V --> F[启动失败与结构化错误]
关键点是:业务代码只接收 R,不再直接访问 os.environ 或配置文件。
五、校验:类型正确不等于配置合法
类型解析回答:
"8080"能否转换成8080?
约束校验回答:
8080是否在允许范围内?
跨字段校验回答:
当
tls=True时,tls_cert_file是否同时存在?
完整配置校验至少包含三层:
5.1 语法校验
例如 TOML 能否解析,JSON 是否闭合,环境变量是否有合法格式。
5.2 类型校验
"8080" -> 8080
"true" -> True
"5.5" -> 5.5
5.3 业务不变量校验
例如:
1 <= port <= 65535
timeout > 0
生产环境必须启用 TLS
debug 不能在生产环境开启
可以把配置合法性表示为:
只完成前两项,会得到“看起来能运行,但运行方式不正确”的配置。
六、使用 Pydantic v2 建立配置模型
Pydantic v2 的模型负责把输入数据转换成类型化对象,并在失败时抛出结构化的 ValidationError。Pydantic v2 使用 field_validator 和 model_validator 等 API;Pydantic v1 中的旧式 @validator、@root_validator 不应作为新代码的默认写法。(docs.pydantic.dev)
先安装:
python -m pip install "pydantic>=2,<3" pydantic-settings
下面是一个完整配置模型:
from pathlib import Path
from typing import Annotated, Literal
from pydantic import (
BaseModel,
Field,
SecretStr,
field_validator,
model_validator,
)
class DatabaseConfig(BaseModel):
host: str
port: Annotated[int, Field(ge=1, le=65535)]
name: str
user: str
password: SecretStr
class AppConfig(BaseModel):
environment: Literal["development", "test", "staging", "production"]
debug: bool = False
port: Annotated[int, Field(ge=1, le=65535)] = 8080
request_timeout_seconds: Annotated[float, Field(gt=0)] = 5.0
allowed_hosts: list[str] = Field(min_length=1)
database: DatabaseConfig
@field_validator("allowed_hosts")
@classmethod
def validate_hosts(cls, hosts: list[str]) -> list[str]:
cleaned = [host.strip() for host in hosts]
if any(not host for host in cleaned):
raise ValueError("allowed_hosts cannot contain empty values")
return cleaned
@model_validator(mode="after")
def validate_environment_rules(self) -> "AppConfig":
if self.environment == "production" and self.debug:
raise ValueError("debug must be false in production")
if (
self.environment == "production"
and self.request_timeout_seconds < 1
):
raise ValueError(
"production timeout must be at least 1 second"
)
return self
这个模型包含三种约束:
Literal限制环境名称;Field(ge=..., le=...)限制数值范围;model_validator处理跨字段关系。
构造配置:
raw = {
"environment": "production",
"debug": False,
"port": "8080",
"request_timeout_seconds": "3.5",
"allowed_hosts": ["api.example.com"],
"database": {
"host": "db.internal",
"port": 5432,
"name": "orders",
"user": "orders_app",
"password": "change-me",
},
}
config = AppConfig.model_validate(raw)
print(config.port, type(config.port))
print(config.database.password)
输出类似:
8080 <class 'int'>
**********
SecretStr 的目的不是加密内存,而是避免在常规模型打印和常规表示中直接显示 Secret。调用 .get_secret_value() 才能取得原始值:
password = config.database.password.get_secret_value()
这意味着以下代码仍然可能泄露 Secret:
print(password)
logger.info("database config=%r", config.model_dump())
Secret 类型只能降低意外打印风险,不能阻止:
- 调试器查看内存;
- 代码主动调用
.get_secret_value(); - 数据库驱动或连接字符串日志;
- 异常对象携带敏感输入;
- 进程转储;
- 监控系统采集环境变量。
七、完整示例:TOML 默认值、环境变量覆盖与 Secret 文件
下面构造一个不依赖 Pydantic Settings 自定义源的完整流程:
- 从 TOML 读取非敏感默认配置;
- 从环境变量读取覆盖项;
- 从 Secret 文件读取密码;
- 递归合并;
- 交给 Pydantic v2 校验;
- 输出脱敏配置。
7.1 项目文件
project/
├── config.toml
├── secrets/
│ └── database_password
└── load_config.py
config.toml:
environment = "development"
debug = true
port = 8080
request_timeout_seconds = 5.0
allowed_hosts = ["localhost", "127.0.0.1"]
[database]
host = "localhost"
port = 5432
name = "demo"
user = "demo_app"
secrets/database_password:
local-password
7.2 加载实现
from __future__ import annotations
import json
import os
import tomllib
from collections.abc import Mapping
from pathlib import Path
from typing import Any, Literal, Annotated
from pydantic import BaseModel, Field, SecretStr, ValidationError
class DatabaseConfig(BaseModel):
host: str
port: Annotated[int, Field(ge=1, le=65535)]
name: str
user: str
password: SecretStr
class AppConfig(BaseModel):
environment: Literal["development", "test", "staging", "production"]
debug: bool = False
port: Annotated[int, Field(ge=1, le=65535)] = 8080
request_timeout_seconds: Annotated[float, Field(gt=0)] = 5.0
allowed_hosts: list[str]
database: DatabaseConfig
def validate_runtime_rules(self) -> "AppConfig":
if self.environment == "production" and self.debug:
raise ValueError("debug must be false in production")
return self
def deep_merge(
base: dict[str, Any],
override: Mapping[str, Any],
) -> dict[str, Any]:
result = dict(base)
for key, value in override.items():
if (
isinstance(result.get(key), dict)
and isinstance(value, Mapping)
):
result[key] = deep_merge(result[key], value)
else:
result[key] = value
return result
def parse_bool(value: str) -> bool:
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise ValueError(f"invalid boolean value: {value!r}")
def environment_overrides() -> dict[str, Any]:
result: dict[str, Any] = {}
if "APP_ENVIRONMENT" in os.environ:
result["environment"] = os.environ["APP_ENVIRONMENT"]
if "APP_DEBUG" in os.environ:
result["debug"] = parse_bool(os.environ["APP_DEBUG"])
if "APP_PORT" in os.environ:
result["port"] = os.environ["APP_PORT"]
if "APP_ALLOWED_HOSTS" in os.environ:
result["allowed_hosts"] = json.loads(
os.environ["APP_ALLOWED_HOSTS"]
)
database: dict[str, Any] = {}
if "APP_DATABASE__HOST" in os.environ:
database["host"] = os.environ["APP_DATABASE__HOST"]
if "APP_DATABASE__PORT" in os.environ:
database["port"] = os.environ["APP_DATABASE__PORT"]
if "APP_DATABASE__NAME" in os.environ:
database["name"] = os.environ["APP_DATABASE__NAME"]
if "APP_DATABASE__USER" in os.environ:
database["user"] = os.environ["APP_DATABASE__USER"]
if database:
result["database"] = database
return result
def load_secret(path: Path) -> str:
try:
value = path.read_text(encoding="utf-8")
except FileNotFoundError as exc:
raise RuntimeError(f"secret file not found: {path}") from exc
# Secret 文件常见地以换行结尾。
# 这里只移除文件末尾换行,不删除中间或开头空格。
return value.removesuffix("\n").removesuffix("\r")
def load_config() -> AppConfig:
config_path = Path(
os.getenv("APP_CONFIG_FILE", "config.toml")
)
with config_path.open("rb") as file:
file_config = tomllib.load(file)
merged = deep_merge(file_config, environment_overrides())
secret_path = Path(
os.getenv(
"APP_DATABASE_PASSWORD_FILE",
"secrets/database_password",
)
)
merged = deep_merge(
merged,
{
"database": {
"password": load_secret(secret_path),
}
},
)
try:
config = AppConfig.model_validate(merged)
return config.validate_runtime_rules()
except ValidationError as exc:
# 不要在生产日志中直接打印整个输入对象。
raise RuntimeError(
f"invalid application configuration: {exc.errors()}"
) from exc
if __name__ == "__main__":
config = load_config()
print({
"environment": config.environment,
"debug": config.debug,
"port": config.port,
"database_host": config.database.host,
"database_password": "***",
})
运行:
python load_config.py
预期输出:
{
'environment': 'development',
'debug': True,
'port': 8080,
'database_host': 'localhost',
'database_password': '***'
}
设置环境变量:
APP_ENVIRONMENT=staging \
APP_DEBUG=false \
APP_PORT=9000 \
APP_DATABASE__HOST=staging-db \
APP_ALLOWED_HOSTS='["staging.example.com"]' \
python load_config.py
最终配置的变化是:
environment: development -> staging
debug: true -> false
port: 8080 -> 9000
database.host:
localhost -> staging-db
allowed_hosts:
["localhost", "127.0.0.1"]
-> ["staging.example.com"]
注意 database.port 没有被环境变量覆盖,因此仍然来自 TOML。这就是递归合并的意义。
7.3 失败示例
APP_PORT=99999 python load_config.py
会在启动阶段失败,因为端口约束是:
错误应当指出类似:
port
Input should be less than or equal to 65535
另一个失败示例:
APP_ALLOWED_HOSTS='localhost,127.0.0.1' python load_config.py
json.loads() 会失败,因为该字符串不是合法 JSON。系统应统一约定格式,而不是尝试“猜测”用户想表达的列表。
八、Pydantic Settings:把环境变量、dotenv 和 Secret 目录接入模型
手工写环境变量读取代码适合展示机制,但字段多时会产生大量名称映射代码。pydantic-settings 提供 BaseSettings,可以从环境变量和 Secret 文件加载配置;它不是 Python 标准库的一部分,需要单独安装。官方文档明确将其描述为 Pydantic 的可选配置加载能力。(docs.pydantic.dev)
8.1 基本用法
from typing import Annotated, Literal
from pydantic import BaseModel, Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseSettings(BaseModel):
host: str
port: Annotated[int, Field(ge=1, le=65535)] = 5432
name: str
user: str
password: SecretStr
class Settings(BaseSettings):
environment: Literal[
"development",
"test",
"staging",
"production",
] = "development"
debug: bool = False
port: Annotated[int, Field(ge=1, le=65535)] = 8080
allowed_hosts: list[str] = ["localhost"]
database: DatabaseSettings
model_config = SettingsConfigDict(
env_prefix="APP_",
env_nested_delimiter="__",
secrets_dir="/run/secrets",
env_file=".env",
extra="ignore",
)
对应的环境变量:
export APP_ENVIRONMENT=staging
export APP_DEBUG=false
export APP_PORT=9000
export APP_ALLOWED_HOSTS='["staging.example.com"]'
export APP_DATABASE__HOST=staging-db
export APP_DATABASE__NAME=orders
export APP_DATABASE__USER=orders_app
嵌套字段通过 env_nested_delimiter 展开:
APP_DATABASE__HOST=staging-db
相当于:
{
"database": {
"host": "staging-db",
}
}
Pydantic Settings 对复杂类型默认按 JSON 字符串解析,因此:
APP_ALLOWED_HOSTS=["a.example.com","b.example.com"]
可以解析为列表,而:
APP_ALLOWED_HOSTS=a.example.com,b.example.com
会失败,除非为该字段关闭 JSON 解析并编写自己的解析器。(docs.pydantic.dev)
8.2 .env 文件不是 Secret 管理系统
.env 文件的作用是用文件模拟环境变量,方便本地开发和跨平台运行。Pydantic Settings 使用 python-dotenv 解析 .env,并且真实环境变量优先于 .env 文件。多个 .env 文件按给定顺序加载,后面的文件覆盖前面的文件。(docs.pydantic.dev)
示例:
# .env
APP_ENVIRONMENT=development
APP_DEBUG=true
APP_PORT=8080
APP_ALLOWED_HOSTS=["localhost"]
使用:
settings = Settings(
database={
"host": "localhost",
"name": "demo",
"user": "demo_app",
"password": "local-password",
}
)
print(settings.environment)
如果同时存在:
APP_PORT=8080
和:
APP_PORT=9000 python app.py
最终值是:
9000
因为真实环境变量优先于 dotenv 文件。
不要把生产密码提交到 .env:
# 不推荐提交到仓库
APP_DATABASE__PASSWORD=real-production-password
.env 只是在文件中保存字符串,并不会因为文件名叫 .env 就自动加密。
8.3 Secret 目录
Secret 目录的典型布局是:
/run/secrets/
└── database_password
文件内容:
production-password
配置:
class Settings(BaseSettings):
database_password: SecretStr
model_config = SettingsConfigDict(
secrets_dir="/run/secrets",
)
pydantic-settings 会按照字段名查找同名 Secret 文件。官方示例使用 /run/secrets 作为 Docker Secret 的默认挂载目录。(docs.pydantic.dev)
如果目录不存在,默认行为可以是警告;也可以配置为忽略或直接抛错。生产环境通常应区分:
开发环境:Secret 目录不存在可以使用本地显式参数
生产环境:Secret 目录不存在应启动失败
当前文档还提供了 secrets_dir_missing、secrets_dir_max_size 等选项。secrets_dir_max_size 的存在是因为加载过大的 Secret 文件可能造成内存压力。(docs.pydantic.dev)
8.4 Secret 文件的换行问题
Secret 文件经常由命令生成:
printf '%s\n' 'production-password' > /run/secrets/database_password
文件末尾通常有一个换行。如果应用把换行也当成密码的一部分,就会出现:
认证失败,但肉眼检查密码内容似乎正确
读取时可以去除文件末尾的一个换行:
value = path.read_text(encoding="utf-8")
value = value.removesuffix("\n").removesuffix("\r")
不建议无条件使用 .strip(),因为它会删除密码两端可能有意义的空格。
九、Secret:保护的是暴露路径,不是“让字符串消失”
Secret 是需要保密的配置值,例如:
- 数据库密码;
- API Token;
- OAuth Client Secret;
- 私钥;
- 签名密钥;
- 加密密钥;
- 第三方服务凭据。
Secret 与普通配置的区别不只是字段名,而是生命周期不同:
普通配置:可版本控制、可审查、可复制
Secret:受限访问、脱敏显示、独立轮换、尽量短生命周期
9.1 Secret 的三种状态
静态状态
Secret 存在于:
- Secret 管理服务;
- 容器挂载文件;
- 进程环境;
- 内存对象;
- 备份或日志。
传输状态
Secret 从密钥服务、容器编排系统或文件系统进入应用。
使用状态
应用将其交给数据库驱动、HTTP 客户端或签名函数。
配置系统能控制的是:
- 从哪里读取;
- 谁可以读取;
- 是否打印;
- 是否写入配置快照;
- 是否在错误中出现。
它不能替代传输加密、访问控制和密钥轮换。
9.2 不要把 Secret 放入普通配置序列化结果
Pydantic 的 model_dump() 会把模型转换为字典。用于诊断时,应只选择安全字段:
safe_view = {
"environment": settings.environment,
"port": settings.port,
"database_host": settings.database.host,
}
不要直接:
logger.info("settings=%s", settings.model_dump())
即使使用了 SecretStr,也应明确规定日志允许输出哪些字段。序列化不是脱敏策略本身。Pydantic v2 支持 model_dump() 等模型序列化能力,也支持自定义字段和模型序列化器,但自定义序列化器同样需要由应用决定哪些数据可以输出。(docs.pydantic.dev)
可以定义专用的安全视图:
from pydantic import BaseModel, SecretStr
class DatabaseSettings(BaseModel):
host: str
password: SecretStr
def safe_dict(self) -> dict[str, str]:
return {
"host": self.host,
"password": "***",
}
9.3 使用 Secret 文件时的文件权限
在 Unix 系统中,应用启动前应验证:
stat -c '%a %U %G %n' /run/secrets/database_password
期望至少确认:
- 文件归属用户正确;
- 非其他用户可读;
- 挂载目录不是任意用户可写;
- 容器镜像中没有复制入 Secret;
- Secret 没有通过构建参数进入镜像层。
这部分不是 Python API 的保证,而是部署系统和操作系统权限模型的要求。
十、多环境:环境是配置数据,不应该变成业务分支
常见环境包括:
development
test
staging
production
环境的作用是选择一组配置值,例如:
数据库地址不同
日志级别不同
外部服务端点不同
调试开关不同
资源限制不同
不应该把环境判断散落在业务代码:
if os.getenv("APP_ENV") == "production":
...
elif os.getenv("APP_ENV") == "staging":
...
更好的做法是:环境差异在配置加载阶段消化,业务代码只依赖已经校验的配置。
10.1 组合式配置,而不是复制四份配置
一种可维护的文件结构:
config/
├── base.toml
├── development.toml
├── test.toml
├── staging.toml
└── production.toml
加载顺序:
默认值
-> base.toml
-> 当前环境文件
-> Secret
-> 环境变量
-> CLI 参数
-> 校验
例如 base.toml:
port = 8080
request_timeout_seconds = 5.0
allowed_hosts = ["localhost"]
[database]
port = 5432
production.toml:
debug = false
request_timeout_seconds = 2.0
allowed_hosts = ["api.example.com"]
[database]
host = "prod-db.internal"
name = "orders"
user = "orders_app"
加载当前环境:
import os
from pathlib import Path
import tomllib
environment = os.getenv("APP_ENVIRONMENT", "development")
allowed = {"development", "test", "staging", "production"}
if environment not in allowed:
raise RuntimeError(
f"unsupported environment: {environment!r}"
)
with Path("config/base.toml").open("rb") as file:
base = tomllib.load(file)
with Path(f"config/{environment}.toml").open("rb") as file:
environment_config = tomllib.load(file)
config = deep_merge(base, environment_config)
此时 environment 只是选择配置文件的输入。业务代码不需要知道文件来源。
10.2 防止环境错配
最危险的错误不是“配置缺失”,而是“配置完整但指向错误环境”:
应用标记为 staging
数据库却是 production
可以增加跨字段或跨来源校验:
from pydantic import BaseModel, model_validator
class AppConfig(BaseModel):
environment: str
database_host: str
@model_validator(mode="after")
def validate_environment_target(self) -> "AppConfig":
if self.environment == "production":
if not self.database_host.endswith(".prod.internal"):
raise ValueError(
"production must use a .prod.internal database"
)
if self.environment == "staging":
if self.database_host.endswith(".prod.internal"):
raise ValueError(
"staging must not use a production database"
)
return self
这不是通用规则,但它展示了重要原则:
环境约束必须在配置进入业务组件之前检查。
十一、Pydantic v2 的校验器:什么时候用字段校验,什么时候用模型校验
11.1 field_validator
字段校验器适合单个字段:
from pydantic import BaseModel, field_validator
class Settings(BaseModel):
log_level: str
@field_validator("log_level")
@classmethod
def normalize_log_level(cls, value: str) -> str:
normalized = value.strip().upper()
if normalized not in {"DEBUG", "INFO", "WARNING", "ERROR"}:
raise ValueError("unsupported log level")
return normalized
输入:
Settings(log_level=" info ")
结果:
Settings(log_level="INFO")
mode="before" 的校验器接收原始输入,适合处理环境变量这种字符串:
from typing import Any
from pydantic import BaseModel, field_validator
class Settings(BaseModel):
hosts: list[str]
@field_validator("hosts", mode="before")
@classmethod
def parse_hosts(cls, value: Any) -> Any:
if isinstance(value, str):
return [
item.strip()
for item in value.split(",")
if item.strip()
]
return value
但 before 校验器接收的可能是任意对象,不能假定输入永远是字符串。Pydantic 文档特别强调,前置校验器处理的是原始输入,随后仍会继续执行类型校验。(docs.pydantic.dev)
11.2 model_validator
模型校验器适合字段之间的关系:
from pydantic import BaseModel, model_validator
class TLSConfig(BaseModel):
enabled: bool
cert_file: str | None = None
key_file: str | None = None
@model_validator(mode="after")
def validate_tls_files(self) -> "TLSConfig":
if self.enabled and not self.cert_file:
raise ValueError(
"cert_file is required when TLS is enabled"
)
if self.enabled and not self.key_file:
raise ValueError(
"key_file is required when TLS is enabled"
)
return self
错误配置:
TLSConfig(enabled=True)
应在构造配置阶段失败,而不是等服务器启动监听或第一次 HTTPS 请求时才失败。
十二、配置加载的生命周期:启动、缓存、重载与并发
12.1 启动时加载一次
最简单且最稳妥的生命周期是:
进程启动
-> 读取所有源
-> 校验
-> 构造 Settings
-> 创建数据库连接池、HTTP 客户端等组件
-> 开始接收请求
代码:
settings = Settings()
database_pool = create_database_pool(settings.database)
http_client = create_http_client(settings)
这样可以保证同一个进程中的组件看到一致配置。
12.2 不要在每个请求中重新读取配置
错误示例:
def handle_request():
settings = Settings()
...
问题包括:
- 每次请求都可能访问磁盘;
- 每次请求都可能重新校验;
- 文件被替换时,同一进程中的请求可能得到不同配置;
- 云端 Secret 服务可能产生网络开销;
- 错误会从启动阶段延迟到请求阶段。
如果需要缓存,Pydantic Settings 当前文档提供了 settings_cached();直接构造 Settings() 仍然会创建新实例,缓存则在进程内复用实例。文档同时提醒,缓存是进程本地的,多个进程不会共享同一个配置对象。(docs.pydantic.dev)
12.3 配置重载不是简单地重新赋值字段
假设请求线程正在使用:
settings.database
此时另一个线程执行:
settings.__init__()
对象可能处于部分更新状态。Pydantic Settings 文档指出,原地重载会修改现有对象;并发读取可能看到部分更新,因此需要应用层锁。更安全的方式是先构造新对象,再整体替换引用。(docs.pydantic.dev)
from threading import Lock
settings_lock = Lock()
settings = load_settings()
def reload_settings() -> None:
global settings
new_settings = load_settings()
with settings_lock:
settings = new_settings
读取时也需要一致的引用获取策略:
def get_settings():
with settings_lock:
return settings
如果配置对象只被重新绑定,而不是原地修改,读者通常只能看到旧对象或完整的新对象,不会看到半初始化状态。对于异步应用,文件读取是同步 I/O;Pydantic Settings 文档建议在异步上下文中用 asyncio.to_thread() 将构造放到工作线程,避免阻塞事件循环。(docs.pydantic.dev)
import asyncio
async def load_settings_async():
return await asyncio.to_thread(Settings)
12.4 配置变更与已创建组件
配置重载后,旧组件未必自动改变:
settings.database.password 更新
≠
数据库连接池自动使用新密码
实际生命周期可能是:
读取新配置
-> 校验
-> 创建新连接池
-> 健康检查新连接池
-> 原子替换组件引用
-> 排空旧连接池
如果新连接池创建失败,应继续使用旧配置,而不是先销毁旧组件:
new_settings = load_settings()
new_pool = create_pool(new_settings.database)
new_pool.health_check()
with lock:
old_pool = pool
settings = new_settings
pool = new_pool
old_pool.close()
这就是配置热更新与灰度、回滚之间的连接点:配置本身必须可验证,依赖配置的组件也必须在切换前完成健康检查。
十三、配置错误的诊断:区分来源、解析、校验和运行时失败
配置问题通常有四类。
13.1 来源不存在
表现:
FileNotFoundError
KeyError
Secret directory not found
诊断:
确认进程当前工作目录
确认配置文件绝对路径
确认容器挂载点
确认环境变量是否注入
确认 Secret 文件名是否与字段名匹配
13.2 格式错误
表现:
TOMLDecodeError
JSONDecodeError
SettingsError
例如:
APP_ALLOWED_HOSTS='a,b,c'
但字段约定 JSON 列表。
诊断方法是先查看原始值的来源和格式,但不能把 Secret 原文打入日志:
def redact_value(name: str, value: str) -> str:
if "PASSWORD" in name or "TOKEN" in name or "SECRET" in name:
return "***"
return value
13.3 类型或约束错误
表现:
ValidationError
例如:
APP_PORT=abc
APP_PORT=99999
APP_DEBUG=maybe
启动错误应包含:
- 字段路径;
- 错误类型;
- 不泄露的输入摘要;
- 配置版本或部署版本;
- 进程退出原因。
13.4 配置合法但运行失败
例如:
数据库 URL 格式合法,但数据库不可达
TLS 文件存在,但证书与私钥不匹配
DNS 名称正确,但解析失败
这不是纯配置模型可以解决的,需要把配置校验与依赖健康检查区分开:
结构校验:值能否被模型接受
语义校验:值是否符合应用规则
依赖检查:外部系统是否真的可用
不要在 Pydantic 字段校验器中进行长时间网络访问。否则构造配置会产生隐藏的网络副作用,测试和启动时间都会变得不可控。
十四、性能边界:配置通常不是热路径
配置加载一般发生在:
- 进程启动;
- 配置重载;
- 测试 fixture 创建;
- 管理命令运行。
因此重点不是让每次读取快几个微秒,而是避免重复 I/O 和重复校验。
Pydantic v2 的性能建议包括:使用更具体的类型、避免不必要的校验、在明确结构时使用 list/dict 而不是更宽泛的 Sequence/Mapping,并在不需要校验时使用 Any。官方性能文档还给出了 TypedDict 与嵌套模型之间的基准示例,但具体数字会随 Python、数据规模和模型结构变化,不能直接当作应用性能承诺。(docs.pydantic.dev)
配置系统的实际性能边界通常在这里:
每次请求读取配置文件
每次请求访问 Secret 服务
每次请求构造 Pydantic Settings
每次请求创建数据库连接
正确方向是:
启动时加载
启动时校验
进程内复用
变更时整体替换
而不是在业务热路径上反复构造配置。
十五、一个可执行的最小生产边界
可以把配置系统收敛为以下契约:
class RuntimeConfig(BaseModel):
environment: Literal[
"development",
"test",
"staging",
"production",
]
debug: bool
port: Annotated[int, Field(ge=1, le=65535)]
request_timeout_seconds: Annotated[float, Field(gt=0)]
allowed_hosts: list[str]
database: DatabaseConfig
加载阶段遵守:
1. 默认值明确写出
2. 非敏感配置从文件读取
3. Secret 从独立来源读取
4. 环境变量覆盖低优先级配置
5. 所有输入先合并再统一校验
6. 启动失败时输出结构化、脱敏错误
7. 业务代码只接收 RuntimeConfig
8. 日志只输出白名单字段
9. 重载时构造新对象并完成健康检查
10. 旧组件在新组件准备完成后再回收
最后需要区分三类结论:
- Python 规范或标准库行为:例如
os.environ的访问方式、tomllib的读取接口、pathlib.Path的路径抽象; - Pydantic Settings 的框架默认行为:例如环境变量、dotenv、Secret 目录之间的默认优先级;
- 应用工程策略:例如是否允许热重载、是否拒绝未知字段、生产环境缺失 Secret 时是否立即退出。
把这三类内容混在一起,配置系统就会变得难以解释。把它们分开,配置管理才会从“到处读取字符串”变成一个有明确输入、转换、校验、生命周期和故障边界的系统。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 日志工程:Logger、Handler、结构化字段、上下文和轮转
- 下一篇:Python CLI 工程:argparse、子命令、退出码、管道和可测试性
- 延伸:Pydantic v2:模型、校验器、序列化、Settings 和性能边界
- 延伸:Python 生产交付:进程模型、容量、配置、迁移、灰度和回滚
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论