极简配置中心:用 Git + 文件监听实现零依赖的运行时配置下发
极简配置中心用 Git 文件监听实现零依赖的运行时配置下发一、配置中心的诱惑与陷阱微服务 5 时Consul 的重量远超你的需求分布式配置中心Consul、etcd、Nacos解决的问题是真实的几百个服务实例需要在运行时统一更新配置不能一个个重启。但如果你只有 3-5 个微服务实例配置中心的运维成本部署集群 健康监控 备份恢复很可能超过它的价值。对于中小规模项目有一个被忽视的方案Git 仓库 文件监听。原理极其简单——配置文件存在 Git 仓库中每个服务实例通过git pull或文件监听获取最新配置。不需要分布式共识不需要额外服务不需要新的运维技能。sequenceDiagram participant Dev as 开发者 participant Git as Git 仓库 participant Watcher as Config Watcher participant S1 as 服务实例 A participant S2 as 服务实例 B Dev-Git: git push config.yaml Note over Dev,Git: 触发 webhook 或定时 pull loop 每 30 秒 Watcher-Git: git pull (fast-forward only) Git--Watcher: updated files list Watcher-Watcher: 对比文件 hash Watcher-S1: fsnotify: config.yaml changed Watcher-S2: fsnotify: config.yaml changed end S1-S1: 热加载新配置 S2-S2: 热加载新配置 Note over S1,S2: 零停机配置更新本文将实现一个基于 Git fsnotify atomic.Value 的极简配置中心不依赖任何外部服务。二、文件监听与原子替换保证配置切换的并发安全方案的核心挑战不是怎么检测文件变化——fsnotify库已经很成熟。真正的挑战是如何在不停服务的情况下并发安全地切换配置。直接修改运行时配置是很危险的。假设你在请求处理到一半时切换了数据库连接串——连接池还是旧的但新请求用的是新配置数据可能写错库。解决方法是atomic.Value。配置永远有两个副本当前副本被所有请求使用和新副本刚从文件加载。切换时atomic.StorePointer原子地替换指针保证任何时刻的请求看到的配置都是一致的。var currentConfig atomic.Value // 读取配置所有请求都调用此函数 func GetConfig() *Config { return currentConfig.Load().(*Config) }三、完整实现package configcenter import ( fmt log os os/exec path/filepath sync/atomic time github.com/fsnotify/fsnotify gopkg.in/yaml.v3 ) // Config 是应用配置的结构体 type Config struct { Server ServerConfig yaml:server Database DatabaseConfig yaml:database LLM LLMConfig yaml:llm Version string yaml:version } type ServerConfig struct { Port int yaml:port Timeout int yaml:timeout Env string yaml:env } type DatabaseConfig struct { DSN string yaml:dsn MaxConn int yaml:max_conn } type LLMConfig struct { Model string yaml:model Temperature float64 yaml:temperature MaxTokens int yaml:max_tokens } // Watcher 负责监听配置文件变更并热加载 type Watcher struct { gitRepo string // Git 仓库地址或本地路径 configPath string // 配置文件路径相对于 git 仓库 currentCfg atomic.Value stopCh chan struct{} } func NewWatcher(gitRepo, configPath string, pollInterval time.Duration) (*Watcher, error) { w : Watcher{ gitRepo: gitRepo, configPath: configPath, stopCh: make(chan struct{}), } // 初始加载 cfg, err : w.loadConfig() if err ! nil { return nil, fmt.Errorf(initial config load: %w, err) } w.currentCfg.Store(cfg) // 启动后台监听 go w.watchLoop(pollInterval) return w, nil } // GetConfig 并发安全地读取当前配置 func (w *Watcher) GetConfig() *Config { return w.currentCfg.Load().(*Config) } // watchLoop 后台循环拉取 Git → 检测变更 → 热加载 func (w *Watcher) watchLoop(pollInterval time.Duration) { ticker : time.NewTicker(pollInterval) defer ticker.Stop() // 本地文件监听用于开发环境 fswatcher, err : fsnotify.NewWatcher() if err nil { fswatcher.Add(filepath.Join(w.gitRepo, w.configPath)) defer fswatcher.Close() } for { select { case -w.stopCh: return case -ticker.C: w.syncFromGit() case event : -fswatcher.Events: if event.Opfsnotify.Write fsnotify.Write { log.Printf(Config file changed: %s, event.Name) w.reload() } } } } // syncFromGit 从 Git 仓库拉取最新配置 func (w *Watcher) syncFromGit() { // 尝试 git pull如果有远端配置 cmd : exec.Command(git, -C, w.gitRepo, pull, --ff-only) cmd.Stderr os.Stderr if err : cmd.Run(); err ! nil { // 本地 Git 仓库或无网络忽略错误继续使用本地配置 return } w.reload() } // reload 重新加载配置并使用 atomic.Value 原子替换 func (w *Watcher) reload() { cfg, err : w.loadConfig() if err ! nil { log.Printf(Reload config failed: %v, err) return // 失败时保持不变继续使用旧配置 } // 检查配置版本是否有变化 oldCfg : w.GetConfig() if oldCfg.Version cfg.Version { return // 无变化跳过 } w.currentCfg.Store(cfg) log.Printf(Config reloaded: version %s, cfg.Version) } // loadConfig 从文件加载配置 func (w *Watcher) loadConfig() (*Config, error) { fullPath : filepath.Join(w.gitRepo, w.configPath) data, err : os.ReadFile(fullPath) if err ! nil { return nil, fmt.Errorf(read config file %s: %w, fullPath, err) } cfg : Config{} if err : yaml.Unmarshal(data, cfg); err ! nil { return nil, fmt.Errorf(parse config: %w, err) } // 校验必填项 if cfg.Server.Port 0 { return nil, fmt.Errorf(server.port is required) } return cfg, nil } // Stop 停止监听 func (w *Watcher) Stop() { close(w.stopCh) }使用示例func main() { watcher, err : configcenter.NewWatcher( ./config-repo, // Git 仓库本地路径 app/config.yaml, // 配置文件路径 30*time.Second, // 拉取间隔 ) if err ! nil { log.Fatal(err) } defer watcher.Stop() http.HandleFunc(/api/hello, func(w http.ResponseWriter, r *http.Request) { // 每次请求读取最新配置并发安全 cfg : watcher.GetConfig() fmt.Fprintf(w, Hello from %s (model: %s), cfg.Server.Env, cfg.LLM.Model) }) // 健康检查验证配置是否有效 http.HandleFunc(/healthz, func(w http.ResponseWriter, r *http.Request) { cfg : watcher.GetConfig() if cfg.Server.Port 0 { w.WriteHeader(http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusOK) }) cfg : watcher.GetConfig() log.Printf(Server starting on :%d, cfg.Server.Port) http.ListenAndServe(fmt.Sprintf(:%d, cfg.Server.Port), nil) }四、这个方案能做的事和不能做的事能做的定时从 Git 拉取配置适用于配置变更不频繁的场景本地文件变更实时生效适用于开发环境回滚Git revert 后自动拉取审计Git log 天然记录所有变更历史不能做的即时下发有 30 秒的拉取间隔精确回滚到特定实例所有实例同时拉取配置加密Git 中的明文存储大规模集群100 实例同时 git pull 会对 Git 服务器产生压力不适用场景配置变更需要秒级生效如限流规则需要按实例差异化配置安全合规要求配置加密存储配置加密的补充对于敏感配置如数据库密码、API Key建议使用age或sops做 Git 中的加密存储服务启动时解密加载。五、总结Git 文件监听的配置中心方案用最简单的方式解决了运行时更新配置的需求。不需要 Consul、不需要 etcd、不需要 Kubernetes ConfigMap 的 Reloader。一个 Git 仓库 30 行核心代码 atomic.Value 的并发安全保障。落地路径先确认你的配置变更频率如果低于 1 次/天30 秒的轮询间隔完全可接受然后在每个服务中集成 Watcher用GetConfig()替代直接的配置读取最后对敏感配置做sops加密。少即是多。配置中心不是非得用分布式中间件才能实现——对于 90% 的中小项目Git 就是最好的配置中心。