配置是部署期的契约:代码决定了「有哪些旋钮」,配置决定了「这次部署拧到哪里」。它比代码更容易出错,因为它不在编译器的保护范围内——一个拼错的键名、一个忘改的默认值、一个混进镜像的密钥,都可能在生产上才暴露。Clojure 的配置生态有个鲜明特点:配置就是数据(EDN),这让分层、合并、校验都变得异常自然。本文从环境分层讲到密钥轮换,覆盖 Aero、Integrant 与「启动失败快」的完整实践。
1. 配置是「部署期契约」
1.1 配置错误的四种形态
| 形态 | 例子 | 暴露时机 |
|---|---|---|
| 缺失 | 忘配 DB_URL | 启动或首次调用 |
| 拼写错误 | DB_URI vs DB_URL | 静默取默认值,最难查 |
| 类型错误 | 端口配成字符串 | 运行时异常 |
| 值错误 | 指向测试库 | 数据污染 |
核心原则:配置错误必须在启动时暴露,而不是在第一次请求时。 这就是「启动失败快」(fail fast at startup)的全部意义。
1.2 配置的分类
按敏感度与变更频率分成两类,处理方式完全不同:
| 类型 | 例子 | 存储 | 变更 |
|---|---|---|---|
| 普通配置 | 端口、超时、开关 | 配置文件 / 环境变量 | 随部署 |
| 密钥 | DB 密码、API Key | 密钥管理服务 / 环境变量 | 需轮换、需审计 |
绝不要把密钥写进配置文件并提交仓库。这是所有安全事件里最常见的那一类。
2. 环境分层与合并
2.1 基线 + 覆盖模型
;; resources/config.edn:基线(可提交)
{:server {:port 8080 :timeout-ms 5000}
:db {:pool-size 10}
:log {:level :info}}
;; resources/config.local.edn:本地覆盖(.gitignore)
{:log {:level :debug}}
加载顺序是「基线 → 环境 → 本地 → 环境变量」,后者覆盖前者。合并必须是深合并(递归合并 map),浅合并会把整个 :db 覆盖掉。
2.2 合并优先级链
默认值 < 配置文件 < 环境特定文件 < 环境变量 < 命令行参数
环境变量优先级最高是刻意的:它让运维不必改文件就能调整,也契合容器化部署。优先级链要写进文档,否则「为什么我改了配置文件不生效」会成为长期困扰。
2.3 深合并实现
(defn deep-merge [& maps]
(apply merge-with
(fn [a b]
(if (and (map? a) (map? b))
(deep-merge a b)
b))
maps))
(deep-merge {:db {:host "localhost" :port 5432}}
{:db {:port 6543}})
;; => {:db {:host "localhost", :port 6543}}
注意 merge-with 在值为 nil 时的行为:如果覆盖值是 nil,结果就是 nil,而不是「保持原值」。若要「忽略 nil 覆盖」,需要在合并函数里显式处理。
3. Aero 实战
3.1 基本用法
Aero 是 Clojure 生态里最流行的配置库,核心卖点是在 EDN 里支持 reader tag:
;; resources/config.edn
{:server {:port #long #or [#env "PORT" 8080]}
:db {:url #env "DATABASE_URL"
:pool-size #long #env "DB_POOL_SIZE"}
:log {:level #or [#env "LOG_LEVEL" :info]}}
(require '[aero.core :as aero])
(aero/read-config (io/resource "config.edn"))
;; => {:server {:port 8080} :db {:url "..."} ...}
对比纯 EDN 加载:Aero 让「读环境变量」「类型转换」「带默认值」这些事全在配置文件里声明,代码侧只需要一次 read-config。
3.2 常用的 reader tag
| Tag | 作用 | 示例 |
|---|---|---|
#env | 读环境变量(缺失即报错) | #env "DB_URL" |
#or | 取第一个非 nil 值 | #or [#env "X" "default"] |
#long / #double | 类型转换 | #long #env "PORT" |
#boolean | 转布尔 | #boolean #env "DEBUG" |
#profile | 按 profile 选择 | #profile {:dev ... :prod ...} |
#include | 引入其他文件 | #include "db.edn" |
#ref | 引用同文件其他节点 | #ref [:db :url] |
#env 缺失会抛异常,这正是我们想要的——忘配密钥时启动直接失败,而不是带着 nil 跑起来。
3.3 profile 与环境选择
(aero/read-config (io/resource "config.edn")
{:profile :prod})
{:db #profile {:dev {:host "localhost"}
:prod {:host #env "DB_HOST"}}
:log #profile {:dev {:level :debug}
:prod {:level :info}}}
注意:profile 不应决定「是否用假数据」,那属于代码逻辑而非配置。profile 只应影响参数值。
3.4 与 Integrant 集成
配置加载完只是「一堆数据」,真正的价值在于用它驱动组件生命周期。Integrant 就是干这个的:
(require '[integrant.core :as ig])
(def config
(-> (aero/read-config (io/resource "config.edn"))
(assoc :app/server {:port 8080})))
(defmethod ig/init-key :app/db [_ {:keys [url pool-size]}]
(let [ds (make-pool url pool-size)]
(assert-connected! ds) ; 启动即验证连接
ds))
(defmethod ig/halt-key! :app/db [_ ds]
(.close ds))
(ig/init config) ; 按依赖顺序启动
Integrant 的核心价值:把「配置」变成「可启动/可停止的组件图」。启动顺序由依赖关系决定,关闭时逆序执行——这正是生产服务需要的生命周期管理。
4. 方案对比
4.1 environ
(require '[environ.core :refer [env]])
(env :database-url) ; 读环境变量 DATABASE_URL
- 优点:极简,一行读取;
- 缺点:只做「读」,没有分层、合并、类型转换;散落在代码各处难以审计。
适合小工具,不适合中大型服务。
4.2 cprop
(require '[cprop.core :refer [load-config]])
(load-config :resource "config.edn")
- 优点:内置多来源合并(文件 + 环境变量 + 系统属性),支持键名自动映射;
- 缺点:约定较多,调试时「这个值到底从哪来」不如 Aero 直观。
4.3 纯 deps.edn 别名
小项目可以用 deps.edn 的 :aliases 携带不同环境的 JVM 参数:
{:aliases
{:dev {:jvm-opts ["-Dconfig.profile=dev"]}
:prod {:jvm-opts ["-Dconfig.profile=prod"]}}}
只适合本地开发,生产不应依赖构建时参数。
4.4 选型决策
| 维度 | Aero | environ | cprop |
|---|---|---|---|
| 分层与合并 | 强 | 无 | 中 |
| 类型转换 | 内置 tag | 无 | 部分 |
| 默认值 | #or | 需代码处理 | 支持 |
| 配置即数据 | 是(EDN) | 否 | 是 |
| 学习成本 | 低 | 极低 | 中 |
| 推荐场景 | 中大型服务 | 小工具 | 多来源合并 |
结论:绝大多数服务用 Aero + Integrant。它俩的组合把「配置」和「生命周期」两件事都解决了。
5. 密钥注入与轮换
5.1 密钥来源
| 来源 | 安全性 | 适用 |
|---|---|---|
| 环境变量 | 中(进程可见、易泄漏到日志) | 简单部署 |
| 密钥文件(挂载卷) | 中高 | Kubernetes Secrets |
| 密钥管理服务(Vault/KMS) | 高(可审计、可轮换) | 生产推荐 |
| 硬编码在配置 | 极低 | 禁止 |
环境变量的风险:子进程能读到、ps e 能看到、异常堆栈与日志容易带上。所以不要把密钥拼进日志或异常信息。跨语言的通用做法可参考 DevOps 密钥管理
,核心思路一致:让密钥只存在于运行时、只暴露给需要的进程。
5.2 不落盘、不进镜像
三条铁律:
- 密钥不进代码仓库:用
.gitignore+ 提交前扫描(gitleaks 之类); - 密钥不进镜像:多阶段构建时确保密钥不在任何一层;
- 密钥不进日志:统一在日志层做脱敏,而非指望每个调用点自觉。
(defn redact [m]
(walk/postwalk
(fn [x] (if (and (map-entry? x) (sensitive-key? (key x)))
(clojure.lang.MapEntry. (key x) "***")
x))
m))
5.3 轮换策略
(defn fetch-secret [path]
;; 从 Vault 拉取,带缓存与 TTL
(let [cached @secret-cache]
(if (fresh? cached)
(:value cached)
(let [v (vault-read path)]
(reset! secret-cache {:value v :at (now)})
v))))
轮换的关键设计:
- TTL 缓存:密钥不应每次请求都去拉,但也不能永久缓存;TTL 应远小于轮换周期;
- 双密钥过渡:轮换时新旧密钥并存一段时间,避免切换瞬间全部失败;
- 失败降级:拉取失败时用旧值还是直接失败?取决于业务——支付类必须失败,日志类可以降级。
5.4 数据库连接池的轮换
这是最容易被忽略的坑:改了密码,但连接池还持有旧连接。
(defn rotate-db-credential! [pool new-password]
;; 先建新连接池并预热,再切流量,最后关旧池
(let [new-pool (make-pool (db-url new-password))]
(warmup! new-pool)
(swap! pool-registry assoc :active new-pool)
(Thread/sleep 5000) ; 等在途请求走完
(.close pool)))
顺序必须是「先建新、再切换、后关旧」,绝不能「先关旧、再建新」——后者会造成一段不可用窗口。
6. 配置校验与启动失败快
6.1 用 Malli 校验配置
(require '[malli.core :as m]
'[malli.error :as me])
(def Config
[:map
[:server [:map
[:port [:int {:min 1 :max 65535}]]
[:timeout-ms [:int {:min 100}]]]]
[:db [:map
[:url [:string {:min 1}]]
[:pool-size [:int {:min 1 :max 100}]]]]
[:log [:map [:level [:enum :debug :info :warn :error]]]]])
(defn load-config! []
(let [cfg (aero/read-config (io/resource "config.edn"))]
(if (m/validate Config cfg)
cfg
(throw (ex-info "invalid configuration"
{:errors (me/humanize (m/explain Config cfg))})))))
校验必须覆盖「值」而不只是「形状」:端口范围、超时下限、枚举取值,这些才是真正会出错的。
6.2 启动期断言
除了 schema 校验,还有几类「语义校验」只能在启动时做:
(defn assert-config! [cfg]
;; 1. 关键路径可写
(assert (writable? (:storage/path cfg)) "storage path not writable")
;; 2. 依赖可达(带超时,避免启动卡死)
(assert (reachable? (:db/url cfg) 3000) "database unreachable")
;; 3. 环境与配置一致
(when (= :prod (:env cfg))
(assert (not (:debug cfg)) "debug must be off in prod")))
「环境与配置一致」这类断言价值极高:防止把 dev 配置带上生产。
6.3 失败信息设计
启动失败的报错要满足三条:
- 说清哪个键:
[:db :pool-size]而不是「配置错误」; - 说清为什么:
should be at least 1而不是「校验失败」; - 不要泄漏值:密钥类字段只报「缺失」或「格式非法」,绝不打印原值。
;; 好的报错
{:errors {:db {:pool-size ["should be at least 1"]}
:log {:level ["should be one of :debug, :info, :warn, :error"]}}}
7. 配置的可观测与审计
7.1 启动时打印脱敏配置
(log/info "configuration loaded" (redact cfg))
这一条能省掉大量排查时间:出问题时第一眼就能看到「这次部署到底用的什么配置」。前提是脱敏必须彻底。
7.2 配置版本与审计
- 配置进版本控制(密钥除外):谁在什么时候改了哪个值,可追溯;
- 变更要留痕:生产配置的修改应走变更流程,而非直接改环境变量;
- 密钥访问要审计:从 Vault 读取密钥应记录「谁、何时、读了哪个」;
- 配置漂移检测:定期比对「声明的配置」与「实际生效的配置」,发现人工改动。
配置漂移是很多「只在某台机器上复现」的 bug 的根因,值得专门做检测。
8. 小结
Clojure 的配置实践可以浓缩成一条主线:把配置当成数据,在启动时校验,在边界处脱敏。
- 分层与深合并:基线 + 环境 + 本地 + 环境变量,优先级链写进文档;
- Aero 做声明式读取:
#env缺失即报错,#or给默认值,类型转换在配置层完成; - Integrant 管生命周期:配置驱动组件启停,依赖顺序自动解决;
- 密钥三不进:不进仓库、不进镜像、不进日志;来源用密钥管理服务;
- 轮换先建新后关旧:连接池这类有状态资源尤其要注意顺序;
- 启动失败快:schema 校验 + 语义断言,把错误挡在流量之前。
配置管理做得好不好,平时看不出来,出事时才见分晓。把「启动失败快」当成一条硬性纪律,绝大部分配置事故就会在发布阶段被拦住。密钥与认证的完整安全实践可继续参考 Clojure 认证与安全 ,微服务下的配置分发可见 Clojure 微服务架构 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。