Иногда конфигурации через env vars уже недостаточно. Пока сервис небольшой, env-only подход почти всегда выигрывает простотой. Но когда появляются несколько источников конфигурации, секреты, локальные override и разные режимы запуска, встаёт вопрос: как сохранить предсказуемость и не превратить конфиг в набор несвязанных правил.
Это третья статья серии про Go в backend. В первой разбирали baseline: одна структура конфигурации, загрузка из env на старте, явная валидация. Для большинства сервисов этого достаточно — и начинать всегда стоит именно с него. Здесь — следующий шаг: layered configuration на реальном по форме Config, с приоритетом слоёв, валидацией и честным разбором тонкостей confita.
В статье
- Когда env-only уже мало
- Что такое layered configuration
- Сквозной Config: группы, Duration, срезы, nested
- Слои и приоритет
- Доказываем precedence
- Post-load валидация
- Где confita кусается
- Когда брать confita, а когда нет
- Выводы
Когда env-only уже мало
Env-only расползается не от размера кода, а от появления нескольких источников конфигурации. Признаки, что вы доросли:
- параметры приходят из разных мест: общие defaults, файл окружения, переменные, секрет-хранилище;
- нужен локальный override для разработки, не ломающий production-поведение;
- секреты хочется держать отдельно от обычного runtime-конфига (другой источник, другие права);
- один и тот же сервис запускается в нескольких режимах с разными наборами параметров.
Когда это начинает решаться руками — «если есть файл, читаем файл, иначе env, а вот это всегда из Vault» — конфиг превращается в набор несвязанных правил, и предсказуемость теряется. Это сигнал перейти к layered configuration.
Что такое layered configuration
Идея простая: конфигурация собирается из слоёв с явным порядком приоритета. Классический порядок:
defaults → файл → env → секреты
(низший приоритет) (высший приоритет)Каждый следующий слой переопределяет предыдущий. Значение по умолчанию живёт в одном месте (defaults), файл задаёт окруженческие настройки, env переопределяет под конкретный запуск, секреты приходят из защищённого источника. Ключевое отличие от «просто ещё одного конфига» — явный, единый порядок разрешения, а не случайное смешивание источников по месту.
Это общая модель; конкретная библиотека может реализовывать приоритет иначе. У confita порядок разрешения со своими нюансами — о них ниже.
Сквозной Config: группы, Duration, срезы, nested
Реальный конфиг сервиса — не три поля, а несколько групп. Разложим его по вложенным структурам: HTTP, БД, телеметрия, секреты. confita размечает поля тегом config:"...", а файловый бэкенд читает yaml-теги (см. подводные камни) — поэтому теги парные.
package config
import "time"
type HTTP struct {
Host string `config:"http_host" yaml:"http_host"`
Port int `config:"http_port" yaml:"http_port"`
ReadTimeout time.Duration `config:"http_read_timeout" yaml:"http_read_timeout"`
}
type DB struct {
DSN string `config:"db_dsn,required" yaml:"db_dsn"`
MaxConns int `config:"db_max_conns" yaml:"db_max_conns"`
}
type Telemetry struct {
Enabled bool `config:"otel_enabled" yaml:"otel_enabled"`
Endpoints []string `config:"otel_endpoints" yaml:"otel_endpoints"`
}
type Secrets struct {
// читается только из backend с именем "vault"; required — падаем, если пусто
APIToken string `config:"api_token,required,backend=vault"`
}
type Config struct {
Env string `config:"app_env" yaml:"app_env"` // dev|staging|prod
// yaml:",inline" — чтобы плоский config.yaml мапился на группы
// (иначе файловый бэкенд ждёт вложенную секцию http:/db:/…). См. подводные камни ниже.
HTTP HTTP `yaml:",inline"`
DB DB `yaml:",inline"`
Telemetry Telemetry `yaml:",inline"`
Secrets Secrets `yaml:",inline"`
}Здесь важно, что confita умеет из коробки:
- вложенные структуры — рекурсивно обходит группы (
HTTP,DB, …), ключи берёт из тегов листовых полей; time.Duration— парсит"5s","200ms"вtime.Duration, без ручного разбора строк;- срезы (
[]string) — из env берёт значения через запятую, из файла — как список; required— если поле осталось нулевым после всех слоёв,Loadвернёт ошибку (fail-fast). Тонкость:requiredпроверяет итоговый ноль, поэтому имеет смысл только для полей без default — с дефолтом поле уже не пусто, и проверка ничего не даёт. Отсюдаrequiredнаdb_dsn/api_token(приходят извне), но не наhttp_port(есть default 8080).
Слои и приоритет
Загрузчик собирает Config из бэкендов, перечисленных в порядке опроса. Defaults — начальные значения структуры до Load.
func Load(ctx context.Context) (Config, error) {
// Слой 1 — defaults: начальные значения структуры
cfg := Config{Env: "dev"}
cfg.HTTP = HTTP{Host: "0.0.0.0", Port: 8080, ReadTimeout: 5 * time.Second}
cfg.DB.MaxConns = 10
loader := confita.NewLoader(
file.NewBackend("config.yaml"), // слой 2: файл окружения (yaml-теги)
env.NewBackend(), // слой 3: env переопределяет файл
// слой 4: секреты (backend=vault); vaultClient — client.Logical() (*api.Logical).
// NewBackendV2 — для KV v2; для KV v1 есть vault.NewBackend.
vault.NewBackendV2(vaultClient, "secret/app"),
)
if err := loader.Load(ctx, &cfg); err != nil {
return Config{}, err // required-поле не найдено → падаем на старте
}
return cfg, nil
}| Слой | Источник | Как задаётся | Роль |
|---|---|---|---|
| defaults | начальные значения структуры | до Load |
базовые значения |
| файл | config.yaml |
yaml-теги |
окруженческие настройки |
| env | переменные окружения | config-теги (в UPPER_CASE) |
override под запуск |
| секреты | Vault/etcd/Consul | backend=<name> на поле |
чувствительные значения |
Порядок в NewLoader и есть порядок разрешения — но у confita он работает не так прямолинейно, как «последний побеждает». Разбор — в подводных камнях.
Доказываем precedence
Приоритет — не то, во что стоит верить на слово; его удобно закрепить тестом. Кладём http_port: 8080 в файл, ставим HTTP_PORT=9090 в env — ожидаем 9090.
func TestEnvOverridesFile(t *testing.T) {
// config.yaml содержит http_port: 8080
t.Setenv("HTTP_PORT", "9090")
cfg, err := Load(context.Background())
require.NoError(t, err)
// env-слой выиграл у файла
require.Equal(t, 9090, cfg.HTTP.Port)
}Такой тест дешёвый, но ловит регрессии порядка бэкендов — например, если кто-то поменяет местами file и env в NewLoader.
Post-load валидация
required ловит только пустоту. Осмысленность значений — отдельный шаг после Load: диапазоны портов, допустимые окружения, обязательные сочетания параметров. Это тот же принцип fail-fast, что и в первой статье, — падать на старте, а не на первом запросе.
func (c Config) Validate() error {
if c.HTTP.Port < 1 || c.HTTP.Port > 65535 {
return fmt.Errorf("http_port вне диапазона: %d", c.HTTP.Port)
}
switch c.Env {
case "dev", "staging", "prod": // ок
default:
return fmt.Errorf("app_env недопустим: %q", c.Env)
}
// обязательное сочетание: телеметрия включена → нужен хотя бы один endpoint
if c.Telemetry.Enabled && len(c.Telemetry.Endpoints) == 0 {
return fmt.Errorf("otel_enabled=true, но otel_endpoints пуст")
}
return nil
}Load + Validate вызываются один раз на старте; невалидная конфигурация — повод немедленно упасть.
Где confita кусается
Идея слоёв правильная, но у confita есть предсказуемые ловушки — их лучше знать до того, как они выстрелят в проде:
- Порядок get-based бэкендов: побеждает первый. Для обычных (get-based) бэкендов —
env,flags— выигрывает первый, вернувший значение по ключу, а не последний. То естьNewLoader(env, flags)отдаст приоритетenv. «Последний побеждает» — неверная модель. - Файловый бэкенд — исключение.
fileреализуетUnmarshaler: он декодирует файл целиком в структуру и не «фиксирует» поля. Поэтому идущий следом get-basedenvпереопределяет значения из файла — привычныйfile → envработает именно из-за этой особенности, а не из общего правила. - Файловый бэкенд не читает тег
config. Он используетyaml/json/toml-теги (или имя поля). Забыли парныйyaml-тег — многословный ключ (http_read_timeout) молча не подтянется из файла. Отсюда парные теги вConfigвыше. - Вложенные группы + плоский файл требуют
yaml:",inline". Файловый бэкенд декодирует yaml по структуре: безinlineон ждёт вложенную секцию (db:\n db_dsn: …), а плоскийdb_dsnне подтянется — иrequired-поле молча упадёт наLoadс «key not found». Помечайте группыyaml:",inline"(как вConfigвыше), если файл плоский. - confita — до 1.0. Это
v0.x: API может меняться, экосистема невелика. Для нового кода закладывайтесь на возможные ломающие изменения и пиньте версию. - Remote-бэкенды — это сеть.
etcd/Consul/Vaultдёргаются наLoad: недоступный секрет-хранилище на старте → медленный или падающийLoad. Ставьте таймаут наctx, продумывайте поведение при недоступности (упасть vs стартовать с деградацией) и не делайте remote-бэкенд обязательным там, где сервис может подняться без него.
Когда брать confita, а когда нет
Уместно: сервисы, где параметров много и они приходят из разных мест; платформенные приложения, завязанные на внешние конфиг-хранилища (Consul, Vault); внутренние инструменты с несколькими режимами и локальными override.
Уже лишнее: маленький сервис с одним runtime; когда env-only baseline из первой статьи закрывает задачу; когда «слои» появляются ради слоёв, а реальный источник один.
Про саму библиотеку: confita небольшая и до v1. Для простого env-only это перебор — там уместнее caarlos0/env или ручная загрузка из первой статьи. confita берут прицельно ради нескольких backend-источников.
Выводы
- Начинайте с env-only и переходите к слоям только тогда, когда источников конфигурации действительно несколько, а не «на вырост».
- Раскладывайте конфиг по группам (
HTTP/DB/Telemetry/Secrets), используйтеtime.Duration, срезы иrequired— confita это умеет. - Закрепляйте precedence тестом и делайте post-load валидацию (порты, окружения, обязательные сочетания) —
requiredловит только пустоту. - Держите в голове нюансы confita: первый get-бэкенд побеждает, файловый — исключение и читает
yaml-теги, remote-бэкенды — это сеть наLoad, а сама библиотека до v1.
Дальше в серии — работа с базой: pgx, database/sql и sqlx.
Документация и первоисточники
- confita — GitHub
- confita — pkg.go.dev — API и статус модуля
- Go: конфигурация через env без хаоса — baseline серии
Комментарии