这篇讲什么
密钥这东西,写 demo 时随手贴在代码里,等项目上了规模就开始出问题:谁也说不清某把 Key 是哪个服务在用,日志里躺着完整明文,交接时靠聊天工具互传,仓库历史里还埋着两年前提交的旧密钥。
这篇不讲密钥从哪来,只讲拿到之后在工程侧怎么管。按「存放 → 启动校验 → 日志打码 → 分环境隔离 → 作废轮换 → 提交前扫描」六步走,每步都有可直接落地的代码。
环境准备
bash
pip install python-dotenv # 本地读取 .env
pip install detect-secrets # 提交前扫描(可选,第六步用)
第一步:密钥只从环境变量读,不进代码
先立一条硬规矩:任何密钥值不出现在任何被 git 跟踪的文件里。
项目根目录放两个文件,一个真实的、一个模板:
.env # 真实值,加进 .gitignore,永不提交
.env.example # 只有键名没有值,提交进仓库
.env:
LLM_API_KEY=sk-real-value-here
LLM_BASE_URL=https://your-gateway.example.com/openai/v1
DB_PASSWORD=another-real-secret
.env.example:
LLM_API_KEY=
LLM_BASE_URL=
DB_PASSWORD=
.gitignore 里务必包含:
.env
.env.*
!.env.example
注意第三行------先忽略所有 .env.*,再用 ! 把模板文件放回来。顺序反了模板就传不上去,新同事 clone 下来不知道该配哪些键。
读取侧:
python
from dotenv import load_dotenv
import os
load_dotenv() # 生产环境没有 .env 文件时静默跳过,不报错
api_key = os.environ["LLM_API_KEY"]
load_dotenv() 的设计很适合这个场景:本地有 .env 就加载,线上没有就什么也不做,直接用容器/CI 注入的真实环境变量。同一份代码两种环境都跑得通,不需要判断 if is_local。
第二步:启动期就校验,别等第一次调用
最难查的故障是「服务启起来了,跑了二十分钟,第一个真实请求进来才报鉴权失败」。密钥缺失属于配置错误,应该在进程启动的前几行就暴露。
python
# config.py
import os
import sys
REQUIRED_VARS = (
"LLM_API_KEY",
"LLM_BASE_URL",
"DB_PASSWORD",
)
EX_CONFIG = 78 # sysexits.h 约定:配置错误
def load_config() -> dict:
missing = [name for name in REQUIRED_VARS if not os.environ.get(name)]
if missing:
print(
"[fatal] 以下必需环境变量未设置:\n - "
+ "\n - ".join(missing)
+ "\n请参考 .env.example 补齐后重启。",
file=sys.stderr,
)
sys.exit(EX_CONFIG)
return {name: os.environ[name] for name in REQUIRED_VARS}
CONFIG = load_config()
在应用入口第一行 from config import CONFIG,缺变量就立刻退出。
退出码选 78 不是随意的。0 是成功、1 是笼统失败,而 78(EX_CONFIG)在 sysexits 约定里专指配置错误。这样容器编排和 CI 能直接根据退出码判断:78 不需要重试,因为重启一百次配置还是缺的;而网络类失败重试往往有效。区分开之后,K8s 的 restartPolicy 和流水线的重试策略才有意义。
补一个校验强度更高的版本,顺手查格式:
python
def validate_key_format(key: str) -> bool:
"""基本形态校验,拦住把整行 `LLM_API_KEY=sk-xxx` 粘进值里这种错。"""
if "=" in key or " " in key or key.startswith(("'", '"')):
return False
return len(key) >= 20
引号被一起粘进值里是极高频的错误,报出来的是 401,但你会盯着密钥本身找半天。
第三步:日志里的密钥必须打码
密钥进日志的路径比想象的多:打印配置、打印请求头、异常堆栈带上了完整 URL(有些平台的 Key 会出现在 query 里)、以及最隐蔽的------把整个 client 对象 repr 出来。
写一个打码函数,任何要输出的敏感值都先过一遍:
python
def mask(secret: str, keep_head: int = 4, keep_tail: int = 4) -> str:
"""sk-abcd1234...wxyz -> sk-a***wxyz"""
if not secret:
return "<empty>"
if len(secret) <= keep_head + keep_tail:
return "*" * len(secret)
return f"{secret[:keep_head]}{'*' * 6}{secret[-keep_tail:]}"
print(f"已加载密钥: {mask(CONFIG['LLM_API_KEY'])}")
# 输出: 已加载密钥: sk-a******wxyz
保留头尾几位是有意的:足以让你在多把 Key 之间对上是哪一把,又不足以复原。全打成星号在排查「线上用的到底是哪把 Key」时会很难受。
想更彻底一点,给 logging 挂一个过滤器,全局兜底:
python
import logging
import re
SECRET_PATTERN = re.compile(r"sk-[A-Za-z0-9_\-]{16,}")
class SecretFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
if isinstance(record.msg, str):
record.msg = SECRET_PATTERN.sub("sk-***REDACTED***", record.msg)
if record.args:
record.args = tuple(
SECRET_PATTERN.sub("sk-***REDACTED***", a) if isinstance(a, str) else a
for a in record.args
)
return True
logging.getLogger().addFilter(SecretFilter())
这一层是保险,不是替代品------正则只认得上它见过的形态。主要防线仍然是「输出前先手动 mask」,过滤器负责捞漏网的。
第四步:按「项目 × 环境」分独立的 Key
不要一把 Key 走天下。给每个组合单独申请一把:
| 用途 | 环境变量名 | 好处 |
|---|---|---|
| 服务 A 生产 | LLM_KEY_SVCA_PROD |
用量能归因到具体服务 |
| 服务 A 测试 | LLM_KEY_SVCA_TEST |
压测不污染生产统计 |
| 本地开发 | LLM_KEY_DEV |
泄露只影响开发环境 |
| CI 流水线 | LLM_KEY_CI |
可单独限流,跑挂了不影响线上 |
这么拆有三个直接收益。归因 :看用量面板就知道是哪个服务在跑,不用靠猜。爆炸半径 :某把 Key 泄露只作废这一把,其他服务毫无感知,不需要全线停机换密钥。排查:线上突然出现异常调用,看是哪把 Key 发的就能立刻锁定来源服务。
大多数平台的控制台都支持一个账户下创建多把密钥并分别查看明细。接入前确认这项能力是否具备------如果只能建一把 Key,上面这套隔离就无从实施,这是选型时容易被忽略的一个硬约束。国内常见的接入平台(如 jiekou.vip)在控制台提供多密钥管理和按密钥查看用量明细的能力,接入前在文档里核对一下即可。
第五步:怀疑泄露就立刻作废重建
「先查查是不是真泄露了,确认了再换」------这个顺序是错的。正确顺序是:先作废,再调查。
因为作废重建的代价很低(改一个环境变量、滚动重启),而泄露窗口每多一分钟风险都在累积。等你查清楚,别人可能已经用了半小时。
标准处置流程:
- 控制台把可疑的那把 Key 立即作废
- 创建新 Key,只更新对应服务的环境变量
- 滚动重启该服务,确认恢复正常
- 然后再慢慢查:是提交进仓库了、贴到聊天工具里了、还是从日志导出的
- 查清根因后补上对应的防护(比如第六步的扫描)
第 2 步「只更新对应服务」正是第四步分开建 Key 换来的------如果全公司共用一把,这一步就变成全服务停机。
关键一点:作废旧 Key 之前,别忘了确认没有其他服务在共用它。 分开建 Key 的项目不会有这个问题;如果历史上有共用,先 grep 一遍配置仓库确认引用范围。
另外,密钥交接一律不传值,只传「去哪个控制台自己建一把」的说明。任何走聊天工具、邮件、共享文档的密钥明文,都等于给自己埋一颗定时炸弹------那些平台的历史记录你无法真正删除。
第六步:pre-commit + CI 双层扫描
人总会手滑,所以要有机器兜底。两层都要有:本地 pre-commit 拦住提交,CI 兜住绕过 hook 的情况(--no-verify 或者直接在网页端编辑)。
本地 hook,.git/hooks/pre-commit:
bash
#!/bin/sh
# 提交前扫描暂存区里的疑似密钥
PATTERN='sk-[A-Za-z0-9_-]\{16,\}\|api[_-]\?key["'"'"' ]*[:=]["'"'"' ]*[A-Za-z0-9_-]\{16,\}'
if git diff --cached --name-only -z | xargs -0 -r grep -nIE "$PATTERN" 2>/dev/null; then
echo ""
echo "[BLOCKED] 暂存区检测到疑似密钥,已阻止提交。"
echo "请改用环境变量;确认是误报可用 git commit --no-verify 跳过。"
exit 1
fi
exit 0
记得 chmod +x .git/hooks/pre-commit。注意 grep 加了 -I 跳过二进制文件,否则扫到图片、模型权重会很慢。
CI 侧用成熟工具,比手写正则可靠得多:
yaml
# .github/workflows/secret-scan.yml
name: secret-scan
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 扫全量历史,不只是本次改动
- name: Install detect-secrets
run: pip install detect-secrets
- name: Scan
run: |
detect-secrets scan --all-files \
--exclude-files '\.env\.example$' \
> .secrets.new
detect-secrets audit --report .secrets.new
fetch-depth: 0 很关键。密钥的麻烦在于提交进去之后再删掉,它依然躺在 git 历史里,任何能 clone 的人都拿得到。只扫本次 diff 会漏掉这一大类。
扫描器的实用配置是三层叠加:正则匹配已知形态(sk- 前缀这种)、熵值检测抓形态未知的随机串、白名单排除 .env.example 和测试固件里的假值。少了白名单这层,误报多到大家很快就开始无脑 --no-verify,等于没装。
一页速查
| 环节 | 做法 |
|---|---|
| 存放 | 环境变量 + .env(gitignore)+ .env.example(提交) |
| 启动 | 必需变量缺失即退出,退出码 78 |
| 日志 | 输出前 mask(),另挂 logging 过滤器兜底 |
| 隔离 | 按「项目 × 环境」各一把 Key |
| 泄露 | 先作废再调查,不是先调查再作废 |
| 交接 | 只传获取方式,不传密钥值 |
| 防护 | pre-commit 拦提交 + CI 扫全量历史 |
小结
密钥管理不需要引入什么复杂基础设施,以jiekou.vip为例上面六步全部是几十行代码加几个配置文件的量,但覆盖了绝大多数事故场景。优先级排序是:先做第一步和第二步(不进代码、启动校验),改动量最小、收益最大;有多个服务了就补第四步的隔离;团队人数上去了再上第六步的扫描。第三步的日志打码建议一开始就做,因为等日志已经写脏了,回头清理比当初就打码麻烦得多。