返回文章列表

Rust 服务配置别散落在 env 里,Feature Flag 也别当开关玩具

258·2 分钟阅读
Rust微服务

一个服务跑在本地、测试、预发、生产四套环境里。配置来自环境变量、配置文件、启动参数、数据库、控制台。出问题时没人知道当前到底生效的是哪一份。

配置管理看起来很琐碎,但它决定了服务能不能稳定发布。

Rust 项目里,配置常常从 std::env::var 开始:

let database_url = std::env::var("DATABASE_URL").unwrap();

这在 demo 里没问题。服务一旦变大,配置就会变成隐形依赖:名字拼错启动才炸,默认值不清楚,敏感信息混在日志里,Feature Flag 打开后没人知道影响了多少用户。

配置不是字符串集合,而是服务的运行契约。

本文代码环境:

# Cargo.toml
[dependencies]
serde = { version = "1", features = ["derive"] }

配置应该先变成类型

我不喜欢业务代码到处读环境变量。

更好的方式是启动时统一加载,解析成类型:

use std::time::Duration;
 
#[derive(Debug, Clone)]
struct AppConfig {
    database_url: String,
    redis_url: String,
    request_timeout: Duration,
    flags: FeatureFlags,
}
 
#[derive(Debug, Clone)]
struct FeatureFlags {
    new_checkout: bool,
    strict_auth: bool,
}

这样做的好处很直接:

  • 缺配置在启动时暴露
  • 类型错误在解析时暴露
  • 业务代码不关心配置来源
  • 测试可以构造一份明确配置

配置一旦类型化,代码审查时也能看清服务依赖什么,而不是在项目里搜索一堆环境变量字符串。


默认值必须保守

配置默认值不是为了“方便启动”,而是为了避免误伤。

impl Default for FeatureFlags {
    fn default() -> Self {
        Self {
            new_checkout: false,
            strict_auth: true,
        }
    }
}

这里的语义很重要:

  • 新结账流程默认关
  • 严格鉴权默认开

默认值应该站在生产安全一侧。尤其是鉴权、限流、写入保护、调试接口,不要为了开发方便把默认值设得太宽。

我的偏好是:能影响安全和资损的开关,默认值必须保守。

开发环境需要放宽,就在开发配置里明确写出来。


配置校验要在启动时完成

不要等到首个请求进来才发现配置错了。

#[derive(Debug, PartialEq, Eq)]
enum ConfigError {
    EmptyDatabaseUrl,
    TimeoutTooSmall,
}
 
fn validate_config(config: &AppConfig) -> Result<(), ConfigError> {
    if config.database_url.is_empty() {
        return Err(ConfigError::EmptyDatabaseUrl);
    }
    if config.request_timeout.as_millis() < 50 {
        return Err(ConfigError::TimeoutTooSmall);
    }
    Ok(())
}

启动失败比运行时半死不活要好得多。

尤其是连接串、密钥、下游地址、超时、线程数、连接池大小,这些配置错了以后,服务可能还能启动,但行为会很诡异。

配置错误应该 fail fast。


不要把 secret 当普通配置打印

配置对象很容易被 debug!("{config:?}") 打进日志。

所以敏感信息不要直接裸放:

#[derive(Clone)]
struct Secret(String);
 
impl std::fmt::Debug for Secret {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("***")
    }
}
 
impl Secret {
    fn expose(&self) -> &str {
        &self.0
    }
}

这段代码的意义不是“安全到万无一失”,而是防止最常见的误操作:把数据库密码、JWT secret、第三方 token 打进日志。

敏感配置应该遵守几个规则:

  • 日志默认脱敏
  • 不进入指标标签
  • 不返回给健康检查接口
  • 不和普通配置一起展示在管理页面

Rust 的类型系统很适合表达这种边界。StringSecret 在类型上分开,误用就少很多。


Feature Flag 不是 if 开关那么简单

最幼稚的 Feature Flag 是这样:

fn use_new_checkout(flags: &FeatureFlags) -> bool {
    flags.new_checkout
}

这没错,但真实发布通常需要更多维度:

  • 只给内部用户开
  • 只给 1% 用户开
  • 按租户开
  • 按地区开
  • 出问题能立刻关

可以先用一个简单上下文表达:

#[derive(Debug)]
struct FlagContext {
    user_id: u64,
    tenant_id: u64,
    internal_user: bool,
}
 
fn new_checkout_enabled(flags: &FeatureFlags, ctx: &FlagContext) -> bool {
    flags.new_checkout && (ctx.internal_user || ctx.tenant_id == 100)
}

这比在业务代码里到处写 if env == "prod" 好得多。

Flag 判断应该集中,不要散落在业务流程里。

否则一个开关会变成十几个分支,想下线都不知道删哪里。


Flag 要有生命周期

Feature Flag 最大的问题不是打开,而是没人删除。

一个临时开关放半年,就会变成永久复杂度。后来的人不敢删,因为不知道还有没有人依赖。

我建议每个 flag 至少有这些信息:

#[derive(Debug)]
struct FlagMetadata {
    name: &'static str,
    owner: &'static str,
    expires_at: &'static str,
}
 
const NEW_CHECKOUT_FLAG: FlagMetadata = FlagMetadata {
    name: "new_checkout",
    owner: "checkout-team",
    expires_at: "2026-09-01",
};

这不一定要写在 Rust 代码里,也可以放配置中心。但必须有归属和过期时间。

没有生命周期的开关,会慢慢把系统变成多版本迷宫。


动态配置要考虑一致性窗口

有些配置需要运行时更新,比如限流阈值、实验比例、降级开关。

动态配置的边界要说清楚:

  • 多久拉取一次
  • 拉取失败用旧值还是失败
  • 新值是否需要校验
  • 多实例多久内收敛
  • 是否需要审计记录
#[derive(Debug, Clone)]
struct RuntimeConfig<T> {
    value: T,
    version: u64,
}
 
fn should_replace<T>(current: &RuntimeConfig<T>, incoming_version: u64) -> bool {
    incoming_version > current.version
}

版本号很有用。它让你知道当前进程拿到的是哪一版配置,排查问题时能少很多猜测。

动态配置不是越实时越好。越实时,越要考虑错误配置被快速放大的风险。


结论

Rust 服务的配置管理不该停留在 env::var。配置会影响安全、发布、稳定性和故障恢复,它应该像 API 一样被设计。

启动时把配置解析成类型;默认值站在安全一侧;配置错误尽早失败;secret 用类型隔离;Feature Flag 集中判断,并且必须有生命周期。

配置写得清楚,服务发布才不会靠手感。