Когда env уже мало: layered configuration в Go с confita

Когда env-only уже мало: как выстроить layered configuration в Go с confita — сквозной Config с группами, time.Duration/срезы/nested, приоритет слоёв, post-load валидация и где confita кусается

Иногда конфигурации через env vars уже недостаточно. Пока сервис небольшой, env-only подход почти всегда выигрывает простотой. Но когда появляются несколько источников конфигурации, секреты, локальные override и разные режимы запуска, встаёт вопрос: как сохранить предсказуемость и не превратить конфиг в набор несвязанных правил.

Это третья статья серии про Go в backend. В первой разбирали baseline: одна структура конфигурации, загрузка из env на старте, явная валидация. Для большинства сервисов этого достаточно — и начинать всегда стоит именно с него. Здесь — следующий шаг: layered configuration на реальном по форме Config, с приоритетом слоёв, валидацией и честным разбором тонкостей confita.

Layered configuration: слои defaults → файл → env → секреты стекаются в единый конфиг по приоритету

В статье

Когда 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-based env переопределяет значения из файла — привычный 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.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии