密钥不该躺在服务器上:用 SOPS + age 把它们安全地放进 Git
让明文永不入库、迁移只需一把钥匙的密钥管理最佳实践
你有没有想过:如果跑着所有服务的那台服务器今天硬盘挂了,你能多快把数据库密码、JWT secret、第三方 API key 全部找回来?如果答案是"得翻聊天记录和脑子",那这篇就是写给你的。
🎯 问题:密钥只活在服务器上
绝大多数小项目的密钥管理是这样的:每个服务一个 .env.production,里面是数据库连接串、各种密码和 token,文件被 .gitignore 忽略,只存在于生产服务器上。
平时没事,但它埋了三颗雷:
| 场景 | 后果 |
|---|---|
| 机器/磁盘挂了 | 密钥跟着没了,靠记忆重建 |
| 换云厂商 / 开新机 | 一个一个密钥手抄过去,漏一个服务就起不来 |
| 想知道某密钥啥时候改的 | 没有任何记录,无从审计 |
根因就一句话:密钥没有一个可复现、可版本化的真相来源。
那能不能把 .env 直接提交到 Git?当然不能——明文密钥进了仓库历史,等于公开。
我们要的是:密钥进 Git(可版本化、可复现),但内容是加密的。这正是 SOPS + age 干的事。
🧠 原理:age 负责加密,SOPS 负责"优雅地"加密
这俩是搭档,分工明确。
age:极简的非对称加密
age("actually good encryption")是一个现代、极简的加密工具。核心就一对钥匙:
公钥 (public key) → age1xxxxxxxx... 用来「加密」,可以公开
私钥 (secret key) → AGE-SECRET-KEY-1... 用来「解密」,必须保密
任何人拿公钥都能加密,但只有持私钥的人能解密。于是策略变得很干净:公钥写进仓库谁都能加密,私钥你自己保管好就行。
SOPS:让加密文件还能 review
如果只用 age,加密后整个文件变成一坨密文 blob,改一个值、看 diff 都很痛苦。
SOPS(Secrets OPerationS)的聪明之处是:对于结构化文件(dotenv、YAML、JSON),它只加密"值",保留"键"。一个加密后的 .env 长这样:
# 加密前
DATABASE_URL=postgres://user:<password>@db/app
API_TOKEN=<your-token-here>
# 加密后(SOPS dotenv 模式)
DATABASE_URL=ENC[AES256_GCM,data:<ciphertext>,iv:<iv>,tag:<tag>,type:str]
API_TOKEN=ENC[AES256_GCM,data:<ciphertext>,iv:<iv>,tag:<tag>,type:str]
好处立竿见影:
- 能看出改了哪个键:Git diff 显示
DATABASE_URL这一行变了,但看不到明文值 - 键名仍可读:review 时知道有哪些配置项
- 一个
.sops.yaml用creation_rules声明"哪些路径用哪个公钥加密",团队统一规则
多 recipient:给多把钥匙开锁的能力
SOPS 支持把同一份密文加密给多个公钥。这意味着:
secrets 文件 ← 同时被「你的 age 公钥」和「新服务器的 age 公钥」加密
任意一把对应私钥都能解开
加一台机器、加一个团队成员,只要把他的公钥加进 recipients、重新加密一次即可,不用重发密钥。
🏛️ 为什么是最佳实践?横向对比
把常见的几种方案摆一起就清楚了:
| 方案 | 可复现 | 可审计(diff/历史) | 迁移成本 | 额外依赖 | 适合谁 |
|---|---|---|---|---|---|
裸放服务器 .env | ❌ | ❌ | 高(手抄) | 无 | 没人 |
| GitHub Secrets | ⚠️ 半 | ❌ 写入即不可读 | 中 | 锁定 GitHub | CI 少量密钥 |
| Vault / 云 KMS | ✅ | ✅ | 中 | 重,需常驻服务 + 绑厂商 | 中大团队 |
| SOPS + age | ✅ | ✅ | 低(一把私钥) | 仅两个 CLI | 独立开发者 / 小团队 |
几个关键点:
- GitHub Secrets 看似省事,但它是"只写不可读"的黑盒:你无法导出、无法 diff、无法离线复现,而且强绑定 GitHub。密钥一多就成了一团没人说得清的浆糊。
- Vault / 云 KMS 很强,但要常驻一个高可用服务、要接 SDK,还把你绑死在某个厂商上——对一台小服务器是杀鸡用牛刀,而且和"将来换云厂商"的目标相悖。
- SOPS + age 没有任何常驻服务,密文就是 Git 里的普通文件,要守护的只有一把 age 私钥。换厂商时它毫无牵挂。
一句话:SOPS 把"密钥管理"退化成了"管好一个文件 + 一把钥匙",复杂度和你的项目规模匹配,这就是它对小团队最优的根本原因。
⚙️ 落地实践:几个让它真正好用的工程决策
光知道原理不够,下面是把它用顺手的几个关键决策。
1. 密文集中放,别散在服务目录里
直觉上你会把 services/api/.env.production.sops 放在服务旁边。但很多 CI 的镜像构建是按路径触发的(比如改动 services/api/** 就重新构建 api)。密文文件一改、一提交,就会误触发一堆构建和部署。
更好的做法:所有密文集中到一个独立目录,路径镜像原位置:
secrets/
├── services/api/.env.production.sops
├── services/worker/.env.production.sops
└── infrastructure/db/.env.sops
↓ 解密时还原到 ↓
services/api/.env.production
services/worker/.env.production
infrastructure/db/.env
secrets/ 不匹配任何服务的构建路径,改密钥再也不会误触发 CI 风暴。
2. 在 CI runner 里解密,私钥永不落到服务器
部署时怎么把密文变成服务器上的明文 .env?两种思路:
❌ 在服务器上解密:要把私钥传到服务器、服务器还得装 SOPS——等于又给私钥多开了一个落脚点。
✅ 在 CI runner 里解密,再 scp 明文过去:
GitHub Actions runner:
1. 用 GH secret 里的 age 私钥 → sops 解密 → 得到明文 .env.production
2. scp 明文到服务器对应目录
服务器:
3. docker compose up -d (直接用现成的 .env)
私钥只活在 GitHub Secrets 和你的密码管理器里,生产服务器从头到尾不碰私钥、也不用装 SOPS。爆炸半径更小。
小技巧:解密后加一句
test -s .env.production,解出来是空的就让这步失败、不下发,避免坏密钥覆盖掉服务器上正常的配置。
3. 日常操作:你只跟明文打交道
把命令包成 Makefile,平时根本感觉不到加密的存在:
# 改密钥:编辑器里打开的是明文,存盘自动重新加密
make secrets-edit FILE=secrets/services/api/.env.production.sops
# 新机器 / 灾备:一条命令把所有密文还原成明文
make secrets-decrypt-all
sops 在你保存时透明地重新加密,Git 里永远是密文,你眼里永远是明文。
🔁 迁移 / 灾备 Runbook
这才是这套方案的高光时刻。新服务器从零恢复全部密钥,只要三步:
git clone <your-repo> && cd <your-repo>
# 从密码管理器取出 age 私钥(切勿提交、截图或发到聊天)
mkdir -p ~/.config/sops/age
chmod 700 ~/.config/sops/age
# 将私钥写入 keys.txt,权限 600
make secrets-decrypt-all # 所有 .env 按原路径就位
换云厂商、机器重装、新同事入职——统统退化成"clone 仓库 + 有那把钥匙"。再也没有"密码在哪台机器上"的灵魂拷问。
⚠️ 几个别踩的坑
| 坑 | 说明 |
|---|---|
| 私钥进了截图/聊天 | age 私钥是总钥匙,一旦泄露要立刻 age-keygen 换新 + sops updatekeys 重新加密 |
| 明文忘了 gitignore | 加密前先确认 .env / .env.production 都在 .gitignore 里,别让明文溜进历史 |
| dotenv 的注释不保留 | SOPS dotenv 模式只保证 KEY=VALUE 无损,注释/空行可能丢,别在 .env 里写重要注释 |
| 私钥只存一处 | 私钥丢了等于所有密文作废。密码管理器 + 一处离线备份,至少两份 |
📝 总结
把密钥管理做对,本质是回答一个问题:这台机器没了,我多久能满血复活?
- age:一对钥匙,公钥加密、私钥解密,极简
- SOPS:只加密值、保留键,让密文能 diff、能 review,按规则统一加密
- 最佳实践:密文集中
secrets/不触发 CI、CI runner 内解密让私钥不落服务器、Makefile 包装日常操作 - 最大收益:密钥脱离单台服务器、可版本化可审计、迁移只需一把私钥
对独立开发者和小团队来说,SOPS + age 的复杂度恰好卡在"够用且不过度"的甜点上——没有常驻服务、不绑厂商、迁移无负担。把那把 age 私钥保管好,剩下的就交给 Git。