config 为准」并写清谁盖过谁;密钥既不进镜像,也不进仓库。新组员克隆仓库、照 README 走一遍,能不能在十分钟内跑起来,且过程中不需要问任何人?
答「否」,先查 .env.example 有没有漏掉新增的必填变量——多数「跑不起来」都是这一条,而不是文档写得不够多。
.env.example 提交进仓库,里面只有占位符,文件头写明哪些必填、密钥怎么生成。.env 只留在本机,把它写进 .gitignore 和 .dockerignore,因此它不进历史,也不进镜像。示例文件里一旦出现真实密钥,密钥就已经进了历史——处理方式是轮换,不是只在下一次提交里删掉那一行,顺序见密钥与凭据。
占位值统一写 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
必填密钥写 ${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"
用 .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
不要把 .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,默认 8080 | docker compose config 里端口是插值后的值 |
| 8 并发 | 不把会话状态放在进程内存里 | 起两个副本,登录状态仍然有效 |
| 9 易处理 | 收到 SIGTERM 后停止接新请求再退出 | docker compose stop 的日志里能看到优雅退出,退出码为 0 |
| 10 开发与生产等同 | 本地与部署用同一份 compose,只换 .env | 本地 make check 与持续集成结果一致 |
| 11 日志 | 写标准输出,一行一条 JSON,不写日志文件 | 字段清单与示例见日志 |
| 12 管理进程 | 迁移作为一次性任务单独运行,不在应用启动时自动执行 | make migrate-roundtrip 能独立跑通,见数据 |
两条的核验方式:第 8 条在本学期只需要「不放在进程内存」这一条结论。真正的多副本负载均衡不在课程项目范围内,所以核验改成上面那句「起两个副本」的最小验证,不要求部署编排。第 12 条里的迁移工具以官方文档为准。