Олег Марков
Shared конфигурация shared-config в Go проектах
Введение
Shared конфигурация (часто называют shared-config) — это подход, при котором выносится общая конфигурация для нескольких сервисов или модулей в одно место. Вместо того чтобы копировать одни и те же настройки в каждый репозиторий, вы выстраиваете единый источник правды, откуда конфигурация «подтягивается» в нужные сервисы.
Смотрите, я покажу вам, чем такой подход полезен на практике:
- вы избегаете копипаста конфигов между сервисами;
- вы быстрее вносите глобальные изменения (например, смена адреса брокера сообщений или конфигурации логгера);
- вы снижаете риск расхождения настроек между окружениями;
- вы можете централизованно контролировать версионирование и совместимость.
Под shared-config мы дальше будем понимать не конкретную библиотеку, а архитектурный паттерн и типовой модуль (репозиторий или пакет), который вы сами реализуете, например, на Go. Я буду опираться на примеры для Go, но сама идея применима и к другим языкам.
Давайте разберем, как устроить shared конфигурацию так, чтобы она была удобной, расширяемой и безопасной.
Задачи, которые решает shared-config
Когда shared конфигурация действительно нужна
Shared-config имеет смысл, когда у вас:
- несколько микросервисов, использующих одни и те же инфраструктурные компоненты:
- базы данных;
- брокеры сообщений;
- системы логирования и трассировки;
- сервисы авторизации и аутентификации;
- есть набор повторяющихся правил:
- единый формат логов;
- общие ограничения по таймаутам и ретраям;
- единая схема именования топиков, очередей, таблиц;
- есть желание централизованно управлять настройками для разных окружений:
- dev, stage, prod;
- локальная разработка, CI, нагрузочное тестирование.
Если у вас один монолитный сервис, shared-config может быть избыточен. Но как только количество сервисов растет, общая конфигурация помогает удерживать проект под контролем.
Типы настроек, которые удобно выносить в shared-config
Чаще всего в shared конфигурацию выносят:
- параметры подключения:
- к базам данных (PostgreSQL, MySQL, Redis);
- к брокерам сообщений (Kafka, RabbitMQ);
- к очередям задач;
- настройки инфраструктуры:
- формат логов, уровни логирования;
- включение/выключение трассировки и метрик;
- порты и протоколы для HTTP / gRPC;
- общие доменные параметры:
- глобальные лимиты;
- константы бизнес-логики, которые одинаковы для всех сервисов.
При этом чувствительные данные (пароли, токены) чаще выносят в секрет-хранилища (Vault, AWS Secrets Manager, Kubernetes Secrets) и подмешивают уже на уровне конкретного сервиса. Далее я покажу, как это объединить с shared-config без нарушения безопасности.
Структура общего модуля shared-config
Базовый подход к организации репозитория
Чаще всего shared-config делают отдельным репозиторием или модулем:
- отдельный git-репозиторий:
- github.com/company/shared-config
- внутри — один или несколько Go-модулей:
- github.com/company/shared-config/appconfig
- github.com/company/shared-config/logconfig
Идея простая: каждый сервис импортирует этот модуль и инициализирует конфигурацию одинаковым способом.
Давайте посмотрим пример минимальной структуры:
- shared-config/
- go.mod
- config/
- app.go // общие параметры приложения
- db.go // общие параметры БД
- http.go // общие HTTP-настройки
- log.go // логирование
- loader/
- envloader.go // загрузка из переменных окружения
- fileloader.go // загрузка из файла (yaml/json/toml)
- examples/
- main.go // пример использования в сервисе
Такой модуль вы публикуете как версионируемую зависимость, а сервисы подключают его как обычный пакет.
Пример структуры типов конфигурации
Теперь давайте разберемся на примере, как могут выглядеть сами структуры конфигурации.
package config
// AppConfig содержит общие настройки приложения.
type AppConfig struct {
Name string // Имя сервиса для логов и метрик
Env string // Окружение: dev, stage, prod
Version string // Версия приложения
}
// HTTPConfig хранит настройки HTTP сервера.
type HTTPConfig struct {
Host string // Адрес, на котором слушает HTTP сервер
Port int // Порт HTTP сервера
ReadTimeout time.Duration // Таймаут чтения запроса
WriteTimeout time.Duration // Таймаут записи ответа
}
// DBConfig задает общие параметры подключения к базе данных.
// Обратите внимание: здесь можно не хранить пароль, если вы подмешиваете его из секрета.
type DBConfig struct {
Host string // Хост БД
Port int // Порт БД
Database string // Имя базы данных
User string // Пользователь
// Password здесь можно не хранить, а получать отдельно.
}
// Config — корневая структура общей конфигурации.
type Config struct {
App AppConfig // Общие данные о приложении
HTTP HTTPConfig // HTTP настройки
DB DBConfig // Настройки базы данных
}
Комментарии над полями помогают другим разработчикам быстрее понять назначение каждого параметра. Смотрите, я специально оставил Password вне общей структуры, чтобы показать типичный подход: секреты часто подмешивают позже.
Способы хранения shared конфигурации
Вариант 1. Конфигурационные файлы (yaml, json, toml)
Один из наиболее популярных подходов — хранить конфиг в текстовых файлах и читать их при старте приложения.
Например, вы создаете файл config.yaml в репозитории shared-config:
app:
name: "payment-service"
env: "prod"
version: "1.0.0"
http:
host: "0.0.0.0"
port: 8080
readTimeout: "5s"
writeTimeout: "10s"
db:
host: "db.prod.local"
port: 5432
database: "payments"
user: "payment_user"
Далее в модуле shared-config вы добавляете функции загрузки этого файла. Сейчас покажу, как это может быть реализовано.
package loader
import (
"os"
"gopkg.in/yaml.v3"
"github.com/company/shared-config/config"
)
// LoadFromFile читает конфигурацию из yaml файла.
// filePath — путь до файла, например "./config/config.yaml".
func LoadFromFile(filePath string) (*config.Config, error) {
// Читаем файл целиком
data, err := os.ReadFile(filePath)
if err != nil {
// Если файл не найден или ошибка чтения — возвращаем ошибку
return nil, err
}
var cfg config.Config
// Распаковываем yaml в структуру конфигурации
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
// Возвращаем заполненную структуру
return &cfg, nil
}
Теперь любой сервис может сделать так:
// Здесь мы показываем пример использования модуля shared-config в сервисе.
package main
import (
"log"
"github.com/company/shared-config/loader"
)
func main() {
// Загружаем конфиг из файла
cfg, err := loader.LoadFromFile("./config/config.yaml")
if err != nil {
log.Fatalf("failed to load config from file: %v", err)
}
// Теперь можно использовать cfg во всем приложении
log.Printf("starting service %s in env %s", cfg.App.Name, cfg.App.Env)
}
Плюс такого подхода — явность и простота. Минус — вам нужно следить за тем, чтобы файлы были на месте в окружении, где запускается сервис (docker-образы, Kubernetes ConfigMap и т.д.).
Вариант 2. Переменные окружения
Другой популярный вариант — хранить настройки в переменных окружения и маппить их в структуры конфигурации. Сервисам часто проще задавать ENV-переменные через Docker, Kubernetes, CI/CD.
В модуле shared-config вы можете сделать загрузчик из окружения:
package loader
import (
"os"
"strconv"
"time"
"github.com/company/shared-config/config"
)
// getEnvOrDefault возвращает значение переменной окружения или дефолтное значение,
// если переменная не установлена.
func getEnvOrDefault(key, def string) string {
val := os.Getenv(key)
if val == "" {
return def
}
return val
}
// LoadFromEnv заполняет конфигурацию на основе переменных окружения.
func LoadFromEnv() (*config.Config, error) {
// Читаем строковые значения c дефолтами
appName := getEnvOrDefault("APP_NAME", "unknown-service")
appEnv := getEnvOrDefault("APP_ENV", "dev")
appVersion := getEnvOrDefault("APP_VERSION", "0.0.1")
httpHost := getEnvOrDefault("HTTP_HOST", "0.0.0.0")
httpPortStr := getEnvOrDefault("HTTP_PORT", "8080")
// Преобразуем HTTP_PORT в число
httpPort, err := strconv.Atoi(httpPortStr)
if err != nil {
return nil, err
}
readTimeoutStr := getEnvOrDefault("HTTP_READ_TIMEOUT", "5s")
writeTimeoutStr := getEnvOrDefault("HTTP_WRITE_TIMEOUT", "10s")
// Парсим таймауты в time.Duration
readTimeout, err := time.ParseDuration(readTimeoutStr)
if err != nil {
return nil, err
}
writeTimeout, err := time.ParseDuration(writeTimeoutStr)
if err != nil {
return nil, err
}
dbHost := getEnvOrDefault("DB_HOST", "localhost")
dbPortStr := getEnvOrDefault("DB_PORT", "5432")
dbPort, err := strconv.Atoi(dbPortStr)
if err != nil {
return nil, err
}
dbName := getEnvOrDefault("DB_NAME", "app")
dbUser := getEnvOrDefault("DB_USER", "app")
// Формируем итоговую конфигурацию
cfg := &config.Config{
App: config.AppConfig{
Name: appName,
Env: appEnv,
Version: appVersion,
},
HTTP: config.HTTPConfig{
Host: httpHost,
Port: httpPort,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
},
DB: config.DBConfig{
Host: dbHost,
Port: dbPort,
Database: dbName,
User: dbUser,
},
}
return cfg, nil
}
Дальше сервис просто вызывает этот загрузчик:
package main
import (
"log"
"github.com/company/shared-config/loader"
)
func main() {
// Загружаем конфиг из переменных окружения
cfg, err := loader.LoadFromEnv()
if err != nil {
log.Fatalf("failed to load config from env: %v", err)
}
log.Printf("HTTP server will start on %s:%d", cfg.HTTP.Host, cfg.HTTP.Port)
}
Такой подход особенно удобен в «облачных» окружениях, где переменные окружения задаются декларативно.
Вариант 3. Комбинированная схема: файл + окружение + секреты
На практике чаще всего используется комбинированный вариант:
- общие, не секретные и не меняющиеся настройки хранятся в файле в shared-config;
- конкретные значения, зависящие от окружения, переопределяются переменными окружения;
- секреты подмешиваются из специального хранилища.
Давайте посмотрим, как можно реализовать последовательную загрузку с возможностью override:
package loader
import (
"github.com/company/shared-config/config"
)
// LoadCombined демонстрирует комбинированный подход:
// 1. Загружаем базовые настройки из файла.
// 2. При наличии — переопределяем их значениями из окружения.
func LoadCombined(filePath string) (*config.Config, error) {
// Сначала загружаем базовый конфиг из файла
baseCfg, err := LoadFromFile(filePath)
if err != nil {
return nil, err
}
// Далее пытаемся получить значения из окружения
envCfg, err := LoadFromEnv()
if err != nil {
return nil, err
}
// Объединяем конфигурации:
// если значение в envCfg непустое или ненулевое — используем его.
merged := mergeConfigs(baseCfg, envCfg)
return merged, nil
}
// mergeConfigs объединяет конфигурации base и override.
func mergeConfigs(base, override *config.Config) *config.Config {
// В этом примере мы реализуем простое перекрытие.
result := *base // Копируем базовую конфигурацию
// Если override.App.Name задан — используем его.
if override.App.Name != "" {
result.App.Name = override.App.Name
}
if override.App.Env != "" {
result.App.Env = override.App.Env
}
if override.App.Version != "" {
result.App.Version = override.App.Version
}
// HTTP: проверяем значения порта и хоста
if override.HTTP.Host != "" {
result.HTTP.Host = override.HTTP.Host
}
if override.HTTP.Port != 0 {
result.HTTP.Port = override.HTTP.Port
}
// Аналогично можно проверять и задавать другие поля.
// Для краткости часть проверок опущена.
return &result
}
Теперь сервис может вызывать:
cfg, err := loader.LoadCombined("./config/config.yaml")
и быть уверенным, что базовые значения подгружены из файла, а конкретные переопределены окружением.
Версионирование и совместимость shared-config
Почему версионирование критично
Shared-config — это общая зависимость. Как только вы изменяете структуру конфигурации или формат файлов, вы потенциально ломаете все сервисы, которые на нее опираются. Поэтому важно:
- явно версионировать модуль;
- избегать breaking changes без увеличения мажорной версии;
- документировать изменения.
В Go это особенно удобно делать с модульными версиями (v2, v3 и т.д.) в go.mod.
Пример стратегии версионирования
Представим, что у вас есть модуль:
- github.com/company/shared-config v1.2.3
Если вы:
- добавляете новые поля в структуру конфигурации — это минорное изменение:
- v1.2.3 → v1.3.0
- изменяете тип поля или удаляете поле — это breaking change:
- v1.3.0 → v2.0.0 (и в Go модуле появится путь /v2)
Смотрите, как это обычно отражается в go.mod сервиса:
module github.com/company/payment-service
go 1.22
require (
github.com/company/shared-config/v2 v2.0.1 // Используем вторую мажорную версию
)
Так вы даете сервисам выбор — оставаться на старой версии, пока они не будут готовы к миграции.
Инициализация конфигурации в сервисе
Типовой шаблон start-up кода
Давайте соберем все вместе и посмотрим на типовой main.go, который использует shared-config.
package main
import (
"log"
"net/http"
"time"
"github.com/company/shared-config/config"
"github.com/company/shared-config/loader"
)
// App — пример структуры приложения, в которой мы храним конфиг и зависимости.
type App struct {
Config *config.Config // Общая конфигурация
// Здесь же можно разместить логгер, коннекторы к БД и другие зависимости.
}
// NewApp создает приложение на основе конфигурации.
func NewApp(cfg *config.Config) *App {
return &App{
Config: cfg,
}
}
// StartHTTPServer запускает простой HTTP сервер на основе конфигурации.
func (a *App) StartHTTPServer() error {
mux := http.NewServeMux()
// Регистрируем простой обработчик
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
// Здесь мы просто возвращаем статус 200 для проверки здоровья
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
})
srv := &http.Server{
Addr: a.httpAddr(),
Handler: mux,
ReadTimeout: a.Config.HTTP.ReadTimeout,
WriteTimeout: a.Config.HTTP.WriteTimeout,
}
log.Printf("starting HTTP server on %s", srv.Addr)
return srv.ListenAndServe()
}
// httpAddr формирует строку адреса из host и port.
func (a *App) httpAddr() string {
return a.Config.HTTP.Host + ":" + strconv.Itoa(a.Config.HTTP.Port)
}
func main() {
// На старте сервиса загружаем конфигурацию.
// Можно выбрать любой из реализованных подходов.
cfg, err := loader.LoadCombined("./config/config.yaml")
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
// Дополнительно можно вывести часть конфига в лог для контроля.
log.Printf("service %s starting in env %s", cfg.App.Name, cfg.App.Env)
app := NewApp(cfg)
if err := app.StartHTTPServer(); err != nil {
log.Fatalf("HTTP server stopped with error: %v", err)
}
}
Как видите, весь код инициализации конфигурации сосредоточен в одном месте. Сервису не нужно знать детали того, как конкретно загружается shared-config, он лишь использует готовую структуру.
Расширение shared конфигурации для разных сервисов
Общая база + сервис-специфичные настройки
Не все настройки могут быть полностью общими. Часто возникает ситуация, когда:
- есть «ядро» общих параметров (логирование, HTTP, база);
- у каждого сервиса есть свой небольшой набор специфичных параметров.
Практичный подход — сделать общую структуру конфигурации расширяемой, а в сервисе добавлять свой слой поверх нее.
Например, вы определяете в shared-config:
// В модуле shared-config
package config
// BaseConfig — базовый общий конфиг.
type BaseConfig struct {
App AppConfig
HTTP HTTPConfig
DB DBConfig
}
А в сервисе — свою итоговую структуру:
// В сервисе payment-service
package config
import shared "github.com/company/shared-config/config"
// ServiceConfig — итоговая конфигурация сервиса.
// Она включает общую базу и добавляет поля, специфичные для этого сервиса.
type ServiceConfig struct {
Shared shared.BaseConfig // Вложенный общий конфиг
// Специфичные настройки сервиса
PaymentTimeout time.Duration // Таймаут операции оплаты
Currency string // Базовая валюта
}
Далее вы можете:
- загрузить shared часть через модуль shared-config;
- подмешать сервисные параметры из своего файла или окружения.
Объединение общего и сервисного конфигов
Покажу вам, как это реализовано на практике:
package loader
import (
"os"
"time"
sharedloader "github.com/company/shared-config/loader"
sharedconfig "github.com/company/shared-config/config"
"github.com/company/payment-service/config"
)
// LoadServiceConfig загружает общую конфигурацию и дополняет ее сервисными настройками.
func LoadServiceConfig(sharedFilePath string) (*config.ServiceConfig, error) {
// Загружаем общую часть (shared)
baseCfg, err := sharedloader.LoadCombined(sharedFilePath)
if err != nil {
return nil, err
}
// Читаем сервис-специфичные параметры из окружения (как пример)
paymentTimeoutStr := os.Getenv("PAYMENT_TIMEOUT")
if paymentTimeoutStr == "" {
paymentTimeoutStr = "30s"
}
paymentTimeout, err := time.ParseDuration(paymentTimeoutStr)
if err != nil {
return nil, err
}
currency := os.Getenv("PAYMENT_CURRENCY")
if currency == "" {
currency = "USD"
}
// Формируем итоговую конфигурацию сервиса
svcCfg := &config.ServiceConfig{
Shared: sharedconfig.BaseConfig{
App: baseCfg.App,
HTTP: baseCfg.HTTP,
DB: baseCfg.DB,
},
PaymentTimeout: paymentTimeout,
Currency: currency,
}
return svcCfg, nil
}
Теперь вы можете переиспользовать всю общую инфраструктурную часть и не зашивать в shared-config детали конкретных доменов.
Работа с секретами в контексте shared-config
Почему лучше не хранить секреты в shared-config
Shared-config часто используется многими сервисами и окружениями, поэтому включать в него пароли, ключи и токены небезопасно:
- репозиторий могут читать люди, которым не нужен доступ к продакшен-секретам;
- конфигурационные файлы легко случайно залогировать;
- при утечке shared-config вы рискуете всеми сервисами сразу.
Поэтому рекомендуется:
- хранить секреты отдельно (Vault, AWS Secrets Manager, Kubernetes Secrets);
- в shared-config держать только «скелет» параметров;
- в коде сервиса подмешивать секреты в момент инициализации.
Пример объединения shared-config и секретов
Давайте посмотрим, как можно объединить общие настройки и секреты в коде сервиса.
package main
import (
"log"
"os"
"github.com/company/shared-config/loader"
)
// DBConnectionConfig описывает полную конфигурацию подключения к БД,
// включая пароль, который мы берем из секрета.
type DBConnectionConfig struct {
Host string
Port int
Database string
User string
Password string
}
func main() {
// Загружаем общую конфигурацию
cfg, err := loader.LoadCombined("./config/config.yaml")
if err != nil {
log.Fatalf("failed to load shared config: %v", err)
}
// Секрет берем из переменной окружения (или из секрет-хранилища)
dbPassword := os.Getenv("DB_PASSWORD")
if dbPassword == "" {
log.Fatal("DB_PASSWORD env variable is not set")
}
// Объединяем общую часть и секрет
dbConnCfg := DBConnectionConfig{
Host: cfg.DB.Host,
Port: cfg.DB.Port,
Database: cfg.DB.Database,
User: cfg.DB.User,
Password: dbPassword,
}
log.Printf("will connect to DB %s on %s:%d with user %s",
dbConnCfg.Database, dbConnCfg.Host, dbConnCfg.Port, dbConnCfg.User)
// Далее можно передать dbConnCfg в функцию, которая устанавливает соединение с БД.
}
Такой подход позволяет:
- хранить shared-config в обычном репозитории;
- не раскрывать пароли и ключи;
- явно контролировать, откуда приходят чувствительные данные.
Тестирование shared-config
Юнит-тесты для загрузки и мерджа конфигураций
Shared-config — это критичная зависимость. Ошибка в конфигурации может привести к падению сразу всех сервисов. Поэтому стоит покрывать его тестами.
Вот пример, как можно протестировать загрузку из файла:
package loader_test
import (
"os"
"testing"
"github.com/company/shared-config/config"
"github.com/company/shared-config/loader"
)
// TestLoadFromFile проверяет корректность загрузки конфигурации из файла.
func TestLoadFromFile(t *testing.T) {
// Создаем временный файл с yaml конфигурацией
tmpFile, err := os.CreateTemp("", "config-*.yaml")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
yamlContent := `
app:
name: "test-service"
env: "test"
version: "0.1.0"
http:
host: "127.0.0.1"
port: 9000
readTimeout: "1s"
writeTimeout: "2s"
db:
host: "localhost"
port: 5432
database: "test_db"
user: "test_user"
`
if _, err := tmpFile.Write([]byte(yamlContent)); err != nil {
t.Fatalf("failed to write temp file: %v", err)
}
tmpFile.Close()
cfg, err := loader.LoadFromFile(tmpFile.Name())
if err != nil {
t.Fatalf("LoadFromFile returned error: %v", err)
}
// Проверяем отдельные поля конфигурации
if cfg.App.Name != "test-service" {
t.Errorf("expected App.Name = test-service, got %s", cfg.App.Name)
}
if cfg.HTTP.Port != 9000 {
t.Errorf("expected HTTP.Port = 9000, got %d", cfg.HTTP.Port)
}
}
Аналогично можно тестировать:
- загрузку из окружения;
- объединение конфигураций (mergeConfigs);
- обработку невалидных значений.
Контрактные тесты между shared-config и сервисами
Полезная практика — контрактные тесты, которые проверяют, что:
- поля, которые использует сервис, действительно есть в shared-config;
- их типы не изменились.
Простой способ — добавить в сервис тест, который обращается к полям shared-конфига и не делает логики. Если этот тест перестанет компилироваться при обновлении shared-config, вы сразу увидите проблему.
package config_test
import (
"testing"
shared "github.com/company/shared-config/config"
)
// TestSharedConfigContract проверяет, что необходимые поля остаются в конфиге.
func TestSharedConfigContract(t *testing.T) {
var c shared.Config
// Просто обращаемся к полям, которые нам нужны.
_ = c.App.Name
_ = c.App.Env
_ = c.HTTP.Port
_ = c.DB.Host
// Если поле будет удалено или изменен его тип, тест не скомпилируется.
}
Организационные моменты использования shared-config
Кто отвечает за изменение shared-config
Хорошая практика — назначить ответственных за модуль shared-config:
- обычно это платформа или инфраструктурная команда;
- изменения, влияющие на многих потребителей, проходят через review представителей ключевых сервисов;
- breaking changes — только с четким планом миграции.
Так вы избегаете ситуации, когда каждый сервис подтягивает «быстрые» изменения под себя, ломая других.
Документация и ченджлог
Shared-config нужно документировать не меньше, чем публичные API:
- описывать поля и их возможные значения;
- приводить примеры для разных окружений;
- вести ченджлог с описанием, что и почему изменилось.
Полезно завести:
- отдельный файл docs/README.md в репозитории shared-config;
- раздел «Как мигрировать с v1 на v2» с конкретными инструкциями.
Shared конфигурация (shared-config) позволяет вынести общие настройки в единый модуль, уменьшить дублирование и снизить вероятность расхождений между сервисами. Важно четко продумать:
- структуру типов конфигурации;
- способы хранения и загрузки (файлы, окружение, секреты);
- стратегию версионирования и совместимости;
- расширяемость под конкретные сервисы.
Если вы аккуратно подходите к дизайну shared-config и покрываете его тестами, он становится надежным фундаментом для всех ваших микросервисов.
Частозадаваемые технические вопросы по shared-config
Как сделать горячую перезагрузку shared конфигурации без рестарта сервиса
Обычно добавляют watcher над источником конфигурации:
- Для файла:
- использовать fsnotify для отслеживания изменений файла;
- при событии
Writeперечитывать конфиг и атомарно обновлять в памяти указатель на структуру (черезatomic.Value).
- Для конфигурации из key-value хранилищ (Consul, etcd):
- подписаться на изменения (watch API);
- при приходе нового значения заново декодировать конфиг и обновить его в приложении.
Важно:
- валидировать новый конфиг перед применением;
- применять только при успешном парсинге, иначе оставлять старый.
Как обеспечить типобезопасность при доступе к shared конфигурации в разных сервисах
Лучше не раздавать доступ к «сырым» map или интерфейсам. Вместо этого:
- В shared-config определить строгие структуры (struct) с типизированными полями.
- Предоставить функции-конструкторы (например,
NewHTTPConfigFromEnv), которые возвращают полностью инициализированные структуры. - В сервисах работать только с этими типами, а не с
map[string]string.
Это позволяет ловить ошибки на этапе компиляции, а не в рантайме.
Как поступать, если разные сервисы требуют разные форматы конфигурации (yaml vs json)
Решение — разделить:
- Внутреннее представление:
- в shared-config использовать Go-структуры как единую модель.
- Внешние форматы:
- добавить несколько загрузчиков:
LoadFromYAML,LoadFromJSON,LoadFromEnv; - каждый загрузчик декодирует формат в одни и те же структуры.
- добавить несколько загрузчиков:
Сами сервисы выбирают, какой загрузчик использовать, но все работают с единой моделью данных.
Как мигрировать сервисы на новую версию shared-config поэтапно
Пошаговый подход:
- В shared-config сделать новую версию в отдельной мажорной ветке (v2).
- Временно поддерживать обе версии (v1 и v2) в репозитории.
- В каждом сервисе:
- сначала обновить зависимости;
- адаптировать код под новую структуру;
- добавить совместимый адаптер, если нужно.
- После миграции всех сервисов — заморозить v1 (но не удалять из истории) и использовать только v2.
Важно: не форсировать одномоментное обновление всех сервисов, а позволять им мигрировать по очереди.
Как протестировать, что shared-config корректно работает в конкретном окружении (например, в Kubernetes)
Рекомендуемый подход:
- Локальный smoke-тест:
- поднять контейнер сервиса с теми же ConfigMap/Secret, что и на окружении;
- запустить
go testс интеграционными тестами, которые проверяют загрузку конфигурации.
- В CI:
- развернуть временный namespace в Kubernetes;
- применить манифесты с shared-config;
- выполнить job, которая:
- считывает конфиг тем же способом, что и сервис;
- выводит в лог критичные поля;
- падает, если не удается загрузить или распарсить конфиг.
Так вы заранее ловите проблемы с путями к файлам, именами переменных окружения и несовместимыми значениями.