本层判断标准

新组员克隆仓库、照 README 走一遍,能不能在十分钟内跑起来,且过程中不需要问任何人?

答「否」,先查 .env.example 有没有漏掉新增的必填变量——多数「跑不起来」都是这一条,而不是文档写得不够多。

两份文件各给谁,密钥怎么管

.env.example 提交进仓库,里面只有占位符,文件头写明哪些必填、密钥怎么生成。.env 只留在本机,把它写进 .gitignore 和 .dockerignore,因此它不进历史,也不进镜像。示例文件里一旦出现真实密钥,密钥就已经进了历史——处理方式是轮换,不是只在下一次提交里删掉那一行,顺序见密钥与凭据。

.env.example

占位值统一写 replace-me,这样它既不会被当成真值,也不会被密钥扫描误报。必填项在注释里标出来。

# 复制为 .env 后填写。本文件只有占位值,任何真实密钥都不要写进来。
# SESSION_SECRET 必填。生成:openssl rand -base64 32
# DB_PASSWORD 必填。测试库连接串 TEST_MYSQL_DSN 未设置时相关测试跳过。
SESSION_SECRET=replace-me
DB_PASSWORD=replace-me
API_KEY=replace-me
TEST_MYSQL_DSN=replace-me
WEB_PORT=8080

compose 中的引用

必填密钥写 ${VAR:?说明},未设置时 Compose 在启动前失败。不要给 SESSION_SECRET 写 :-某段口令,否则漏配时会安静地使用默认密钥。端口这类可以有公开默认值的项才用 ${WEB_PORT:-8080}。

services:
  api:
    environment:
      SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET is required}
      DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
    ports:
      - "${WEB_PORT:-8080}:8000"

.gitignore 与 .dockerignore

用 .env* 覆盖所有变体(.env.local、.env.production),再单独放行示例文件。.dockerignore 里的同一行决定镜像里有没有这份文件。

# .gitignore
.env*
!.env.example

# .dockerignore
.env*

密钥泄漏之后按顺序做四件事

顺序不能换:先让旧密钥失效,再清理历史。只清理历史而不轮换,等于把还在生效的凭据继续留在别人的克隆里。

1. 生成新值并更新部署环境与本机 .env
   openssl rand -base64 32

2. 在签发方作废旧值(数据库改密码、控制台删除 API Key)

3. 从历史里移除该文件(这一步之后所有协作者需要重新克隆)
   git filter-repo --path .env.example --invert-paths

4. 确认同类字符串以后会被拦住
   gitleaks detect --no-banner --redact

.gitleaks.toml(补一条针对本项目会话密钥的规则)

不要把 .env.example 加进忽略名单——第一件事故正是发生在那个文件里。字段名以官方文档为准。

# 默认规则已覆盖常见密钥前缀,这里补一条本项目专用的模式。
[extend]
useDefault = true

[[rules]]
id = "project-session-secret"
description = "会话密钥不得写进任何被跟踪的文件"
regex = '''SESSION_SECRET\s*=\s*[A-Za-z0-9+/=]{16,}'''

第 3 步什么时候才做:重写历史只在密钥泄漏、且仓库已经推到远端时做。它会打乱所有协作者的分支,要提前通知;仅有一次误提交、且还在本地未推送时,git reset 到提交之前就够了。执行方式以 git filter-repo 官方文档为准。

两套优先级,以 config 为准

「.env 改了却没生效」有四种原因,先分清是哪一套。第一套决定 Compose 文件里的 ${VAR} 换成什么;第二套决定变量是否进入容器。两套是独立的两件事,混在一起看会得出错误结论。

第一套:插值,从高到低

1. shell 中已经 export 的变量
2. --env-file 指定的文件
3. 项目目录里的 .env

第二套:容器内的环境变量,从高到低

1. docker compose run -e
2. 带插值的 environment 或 env_file
3. Compose 文件里写死的 environment
4. env_file 里的值
5. 镜像的 ENV

官方文档的结果表里有一条最容易忽略:shell 和 .env 里都有某个变量,但 Compose 文件没有把它放进容器时,容器里根本没有这个变量。.env 只供插值,不会自动变成容器环境变量。容器里要有这个值,必须在 environment 里写 ${VAR},或使用 env_file。

同一张表里还有两条:environment: NODE_ENV=production 会盖过 env_file 里的 NODE_ENV=test;只写 environment: DEBUG 而不写值,等于把 shell 里的值原样放进容器,变量没设置时也不警告。写成 DEBUG=${DEBUG} 时,shell 和 .env 都没有它,Compose 会给出警告。

出错时按顺序跑这三条

第一条判断是不是 shell 覆盖,第二条看插值之后的真实结果,第三条看容器里到底有没有这个变量。

env | grep SESSION_SECRET
docker compose config
docker compose exec api printenv | grep SESSION_SECRET

应用进程里仍然要检查必填项,缺失时打印变量名并退出,源码中不写默认口令。这样即使 Compose 那层被人改松了,进程自己还会拦住。

两条命令的替换写法:docker compose exec 在容器已经退出时用不了,此时改用 docker compose run --rm api printenv。另外这套顺序是 Compose 的,不是 Kubernetes 的——换运行环境时以那个环境的文档为准,不要把这份顺序表带过去。

评审环境配置改动看什么

变量名用大写下划线、示例文件的注释写法,这类评审不再讨论。要看的是下面六类——每一类都对应一次真实故障。

现象说明什么处理
改动差异里出现被跟踪的 .env密钥要进历史,且会进镜像把它从索引移除并写进两份忽略文件,然后按上一节的四步轮换
.env.example 里的值看起来不像占位符示例文件被当成了填写文件改回 replace-me,并查 git log -p .env.example 确认是否曾出现过真实值
${SESSION_SECRET:-某段口令}漏配时会静默使用默认密钥改成 ${SESSION_SECRET:?SESSION_SECRET is required}
新增了必填变量,但 .env.example 和 README 没动别人启动不了,且报错指向不明显的地方在同一次改动里补示例文件与 README 启动步骤,缺一不合并
仓库里出现 .env.local、.env.production忽略规则只覆盖了 .env 这一个名字把忽略规则改成 .env* 加 !.env.example
为修一个 Bug 把某个变量值直接写进 YAML配置回流进代码,换环境就出错挡回,改成 ${VAR} 并在示例文件里补上该变量

能放宽的一处:最后一条在测试夹具里放宽——测试可以在代码里写测试数据,但必须用测试库的连接串,且这个值本身来自环境变量。夹具里出现看起来像生产地址的值,一律挡回。

助手交出来的样子

# .env.example
DB_PASSWORD=<这里本该是占位符>
API_KEY=<这里本该是占位符>

# 新增一个变量时
.env        PORT=8081         ← 改了
config      PORT=8080         ← 没改

认它的办法:新加一个变量,看两套来源是不是都改了;.env.example 里每个值是不是占位符。前两行看着像示例,其实是真口令——助手为了让示例「能跑」,会去读真值填进来。后两行是另一类错:只改了一套,容器仍用旧端口,而人以为改完了。

必须死守的四条

.env 不进仓库也不进镜像判据:git ls-files | grep -E '^\.env' 只有 .env.example;docker build 之后镜像里搜不到该文件。违反后果:拿到仓库或镜像的人就拿到了全部凭据。
示例文件只有占位符判据:gitleaks detect --no-banner --redact 命中数为 0。违反后果:示例文件是新人最先复制的那一份,一次复制就把密钥带进本机与日志。
必填密钥不给默认值判据:搜 \$\{[A-Z_]+:- 的结果里不出现 SECRET、PASSWORD、TOKEN、KEY。违反后果:漏配时服务正常启动但用了一个公开的密钥,问题只在被利用时才暴露。
已泄漏的密钥必须轮换判据:泄漏处理记录里有「旧值已作废」的时间与操作人。违反后果:清理历史不等于凭据失效,别人的克隆里仍有它。

能放宽的一处:第一条里的「不进镜像」在开发用的 Dockerfile.dev 上放宽——它可以用 env_file 在运行时挂载。放宽只针对运行时,构建产物里仍然不能放。

十二要素落在本仓库的哪里

十二要素不是一份要背的清单,它是一份「配置该放哪」的分类。做不到的写「本学期不适用」,不写「以后补」。

要素本仓库怎么落怎么核验
1 基准代码一个仓库对应一个服务,不把前端与后端拆成两个仓库,也不放第二份服务的副本git remote -v 只有一个地址
2 依赖go.mod、package-lock.json、requirements-dev.txt 都提交npm ci 与 go mod download 能重装出同一份
3 配置全部从环境读,源码里不写默认口令gitleaks detect 命中数为 0
4 后端服务数据库、文件目录、检索服务的地址都从环境读把 TEST_MYSQL_DSN 指向另一个库,代码不改也能跑
5 构建、发布、运行镜像里不含 .env;发布用固定的镜像标签,见发布docker image inspect 的 Env 里没有密钥
6 进程上传的临时文件写在挂载目录,不只写容器本地磁盘重启容器后,之前上传的文件仍能下载
7 端口绑定端口来自 WEB_PORT,默认 8080docker compose config 里端口是插值后的值
8 并发不把会话状态放在进程内存里起两个副本,登录状态仍然有效
9 易处理收到 SIGTERM 后停止接新请求再退出docker compose stop 的日志里能看到优雅退出,退出码为 0
10 开发与生产等同本地与部署用同一份 compose,只换 .env本地 make check 与持续集成结果一致
11 日志写标准输出,一行一条 JSON,不写日志文件字段清单与示例见日志
12 管理进程迁移作为一次性任务单独运行,不在应用启动时自动执行make migrate-roundtrip 能独立跑通,见数据

两条的核验方式:第 8 条在本学期只需要「不放在进程内存」这一条结论。真正的多副本负载均衡不在课程项目范围内,所以核验改成上面那句「起两个副本」的最小验证,不要求部署编排。第 12 条里的迁移工具以官方文档为准。