Цели модуля

После модуля студент сможет:

  • Проектировать конфигурацию приложения с разделением по окружениям
  • Использовать viper для чтения YAML и переменных окружения
  • Реализовывать валидацию конфигурации при старте (fail fast)
  • Безопасно работать с секретами
  • Управлять конфигурацией для local/dev/staging/prod
  • Понимать 12-Factor App принципы конфигурации

4.1 Зачем нужен Configuration Management

Проблемы без управления конфигурацией

  • Захардкоженные значения в коде
  • Пароли и ключи в git
  • Разные настройки для разных окружений в коде
  • Невозможно изменить поведение без пересборки
  • Ошибки конфигурации обнаруживаются в runtime

Что выносится в конфигурацию

КатегорияПримеры
Сетевые настройкиHTTP порт, gRPC порт, адреса
БДDSN, размер пула, таймауты
Внешние сервисыURL API, ключи доступа
ПоведениеLog level, feature flags, лимиты
БезопасностьHMAC ключи, секреты, сертификаты

12-Factor App: Config

Принцип III 12-Factor App: храните конфигурацию в окружении.

Конфигурация — это всё что различается между деплоями:

  • Ресурсы (БД, кэш, очередь)
  • Credentials (пароли, ключи)
  • Per-deploy значения (hostname, version)

Код должен быть идентичен для всех окружений. Различия только в конфигурации.

Антипаттерны

  1. Конфигурация в коде

    // ПЛОХО const DBHost = "prod-db.internal" const APIKey = "sk-123456"

  2. Разные файлы для разных окружений в git

    // ПЛОХО config_local.yaml config_dev.yaml config_prod.yaml # с реальными секретами в git!

  3. Чтение конфигурации в разных местах

    // ПЛОХО: каждый сервис читает env напрямую func (s *Service) DoWork() { host := os.Getenv("DB_HOST") // не здесь }

Правильный подход:

  • Конфигурация читается один раз при старте
  • Валидируется
  • Передаётся через DI
  • Секреты не в git

4.2 env-based конфигурация

Базовый подход: os.Getenv

go
package config

import (
    "fmt"
    "os"
    "strconv"
)

type Config struct {
    HTTPAddr     string
    PostgresDSN  string
    LogLevel     string
    ReadTimeout  time.Duration
}

func Load() (*Config, error) {
    cfg := &Config{
        HTTPAddr:    getEnv("HTTP_ADDR", ":8080"),
        PostgresDSN: getEnvRequired("POSTGRES_DSN"),
        LogLevel:    getEnv("LOG_LEVEL", "info"),
    }

    timeoutStr := getEnv("READ_TIMEOUT", "15s")
    timeout, err := time.ParseDuration(timeoutStr)
    if err != nil {
        return nil, fmt.Errorf("parse read timeout: %w", err)
    }
    cfg.ReadTimeout = timeout

    return cfg, nil
}

func getEnv(key, defaultVal string) string {
    if val := os.Getenv(key); val != "" {
        return val
    }
    return defaultVal
}

func getEnvRequired(key string) string {
    val := os.Getenv(key)
    if val == "" {
        panic(fmt.Sprintf("required env var %s is not set", key))
    }
    return val
}

Когда достаточно env

  • Маленькие сервисы (до 10 параметров)
  • Простая конфигурация
  • Контейнерные деплои (Docker, Kubernetes)

Проблемы чистого env

  • Нет структуры (все значения — строки)
  • Сложные типы (массивы, вложенные объекты) неудобны
  • Нет дефолтов в одном месте
  • Нет валидации из коробки

Для средних и больших проектов используйте viper.


4.3 YAML + env через viper

Установка viper

bash
go get github.com/spf13/viper

Структура конфигурационного файла

yaml
# configs/app.yaml
server:
  http_addr: ":8080"
  read_timeout: 15s
  write_timeout: 15s
  idle_timeout: 60s
  shutdown_timeout: 30s

database:
  dsn: "postgres://app:secret@localhost:5432/mydb?sslmode=disable"
  max_conns: 25
  min_conns: 5
  max_conn_lifetime: 1h
  max_conn_idle_time: 30m

logging:
  level: "info"
  format: "json"

features:
  enable_webhooks: true
  enable_metrics: true

security:
  hmac_key: ""       # из env или secret manager
  hmac_rotation_days: 30

Загрузка через viper

go
package config

import (
    "fmt"
    "strings"
    "time"
    "github.com/spf13/viper"
)

type Config struct {
    Server   ServerConfig   `mapstructure:"server"`
    Database DatabaseConfig `mapstructure:"database"`
    Logging  LoggingConfig  `mapstructure:"logging"`
    Features FeaturesConfig `mapstructure:"features"`
    Security SecurityConfig `mapstructure:"security"`
}

type ServerConfig struct {
    HTTPAddr        string        `mapstructure:"http_addr"`
    ReadTimeout     time.Duration `mapstructure:"read_timeout"`
    WriteTimeout    time.Duration `mapstructure:"write_timeout"`
    IdleTimeout     time.Duration `mapstructure:"idle_timeout"`
    ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"`
}

type DatabaseConfig struct {
    DSN             string        `mapstructure:"dsn"`
    MaxConns        int           `mapstructure:"max_conns"`
    MinConns        int           `mapstructure:"min_conns"`
    MaxConnLifetime time.Duration `mapstructure:"max_conn_lifetime"`
    MaxConnIdleTime time.Duration `mapstructure:"max_conn_idle_time"`
}

type LoggingConfig struct {
    Level  string `mapstructure:"level"`
    Format string `mapstructure:"format"`
}

type FeaturesConfig struct {
    EnableWebhooks bool `mapstructure:"enable_webhooks"`
    EnableMetrics  bool `mapstructure:"enable_metrics"`
}

type SecurityConfig struct {
    HMACKey          string `mapstructure:"hmac_key"`
    HMACRotationDays int    `mapstructure:"hmac_rotation_days"`
}

func Load() (*Config, error) {
    v := viper.New()

    // Файл конфигурации
    v.SetConfigName("app")
    v.SetConfigType("yaml")
    v.AddConfigPath("./configs")
    v.AddConfigPath("/etc/app")

    // Переменные окружения
    v.AutomaticEnv()
    v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
    v.SetEnvPrefix("APP")

    // Дефолты
    setDefaults(v)

    // Чтение файла
    if err := v.ReadInConfig(); err != nil {
        var notFound viper.ConfigFileNotFoundError
        if !errors.As(err, &notFound) {
            return nil, fmt.Errorf("read config: %w", err)
        }
        // Файл не найден - используем env и дефолты
    }

    // Unmarshal в структуру
    var cfg Config
    if err := v.Unmarshal(&cfg); err != nil {
        return nil, fmt.Errorf("unmarshal config: %w", err)
    }

    // Валидация
    if err := cfg.Validate(); err != nil {
        return nil, fmt.Errorf("validate config: %w", err)
    }

    return &cfg, nil
}

func setDefaults(v *viper.Viper) {
    v.SetDefault("server.http_addr", ":8080")
    v.SetDefault("server.read_timeout", "15s")
    v.SetDefault("server.write_timeout", "15s")
    v.SetDefault("server.idle_timeout", "60s")
    v.SetDefault("server.shutdown_timeout", "30s")
    v.SetDefault("database.max_conns", 25)
    v.SetDefault("database.min_conns", 5)
    v.SetDefault("logging.level", "info")
    v.SetDefault("logging.format", "json")
}

Приоритет значений

Viper читает значения в следующем приоритете (от высшего к низшему):

  1. Явно установленные (v.Set)
  2. Флаги командной строки
  3. Переменные окружения
  4. Файл конфигурации
  5. Дефолты (v.SetDefault)

Это позволяет:

  • Иметь базовые настройки в файле
  • Переопределять через env в контейнерах
  • Секреты только через env

Маппинг env переменных

С SetEnvPrefix("APP") и SetEnvKeyReplacer:

text
server.http_addr      -> APP_SERVER_HTTP_ADDR
database.dsn          -> APP_DATABASE_DSN
database.max_conns    -> APP_DATABASE_MAX_CONNS
security.hmac_key     -> APP_SECURITY_HMAC_KEY

4.4 Валидация при старте (fail fast)

Принцип fail fast

Если конфигурация невалидна — приложение должно упасть при старте с понятной ошибкой, а не работать в некорректном состоянии.

Реализация валидации

go
func (c *Config) Validate() error {
    var errs []string

    // Server
    if c.Server.HTTPAddr == "" {
        errs = append(errs, "server.http_addr is required")
    }
    if c.Server.ReadTimeout <= 0 {
        errs = append(errs, "server.read_timeout must be positive")
    }

    // Database
    if c.Database.DSN == "" {
        errs = append(errs, "database.dsn is required")
    }
    if c.Database.MaxConns <= 0 {
        errs = append(errs, "database.max_conns must be positive")
    }
    if c.Database.MinConns > c.Database.MaxConns {
        errs = append(errs, "database.min_conns must be <= max_conns")
    }

    // Logging
    validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true}
    if !validLevels[c.Logging.Level] {
        errs = append(errs, fmt.Sprintf("logging.level must be one of: debug, info, warn, error; got %s", c.Logging.Level))
    }

    // Security
    if c.Security.HMACKey == "" {
        errs = append(errs, "security.hmac_key is required")
    }
    if len(c.Security.HMACKey) < 32 {
        errs = append(errs, "security.hmac_key must be at least 32 characters")
    }

    if len(errs) > 0 {
        return fmt.Errorf("config validation failed:\n  - %s", strings.Join(errs, "\n  - "))
    }

    return nil
}

Использование go-playground/validator

Для сложных валидаций можно использовать validator:

go
import "github.com/go-playground/validator/v10"

type Config struct {
    Server   ServerConfig   `mapstructure:"server" validate:"required"`
    Database DatabaseConfig `mapstructure:"database" validate:"required"`
}

type ServerConfig struct {
    HTTPAddr    string `mapstructure:"http_addr" validate:"required"`
    ReadTimeout time.Duration `mapstructure:"read_timeout" validate:"required,gt=0"`
}

type DatabaseConfig struct {
    DSN      string `mapstructure:"dsn" validate:"required,startswith=postgres://"`
    MaxConns int    `mapstructure:"max_conns" validate:"required,gte=1,lte=100"`
}

func (c *Config) Validate() error {
    validate := validator.New()
    return validate.Struct(c)
}

Использование при старте

go
func main() {
    cfg, err := config.Load()
    if err != nil {
        log.Fatalf("FATAL: %v", err)  // падаем при старте
    }

    // Конфигурация валидна, продолжаем
    app, err := app.Build(context.Background(), cfg)
    if err != nil {
        log.Fatalf("FATAL: build app: %v", err)
    }

    // Запуск
}

Пример ошибки при невалидной конфигурации

text
FATAL: config validation failed:
  - database.dsn is required
  - security.hmac_key must be at least 32 characters
  - logging.level must be one of: debug, info, warn, error; got "verbose"

Приложение не запустилось. Ошибка понятна. Исправляем конфиг.


4.5 Секреты и их хранение

Что такое секреты

Секреты — чувствительные данные которые не должны быть в git:

  • Пароли БД
  • API ключи
  • HMAC ключи
  • TLS сертификаты
  • OAuth токены

Правила работы с секретами

  1. Никогда не коммитить секреты в git
  2. Секреты передаются через env или secret manager
  3. Секреты не логируются
  4. Секреты ротируются периодически
  5. Доступ к секретам по least privilege

Хранение секретов

СпособКогда использовать
env varsЛокальная разработка, Docker
Vault (HashiCorp)Production, большие команды
Lockbox (Yandex Cloud)Yandex Cloud инфраструктура
AWS Secrets ManagerAWS инфраструктура
Kubernetes SecretsKubernetes деплой

.env файл для локальной разработки

Для локальной разработки можно использовать .env файл. ВАЖНО: .env в .gitignore!

env
# .env (НЕ коммитить!)
APP_DATABASE_DSN=postgres://app:secret@localhost:5432/mydb
APP_SECURITY_HMAC_KEY=my-local-hmac-key-at-least-32-chars

# .env.example (коммитить как шаблон)
APP_DATABASE_DSN=postgres://user:password@localhost:5432/dbname
APP_SECURITY_HMAC_KEY=your-hmac-key-here

Загрузка .env через viper

go
v.SetConfigName(".env")
v.SetConfigType("env")
v.AddConfigPath(".")

if err := v.ReadInConfig(); err != nil {
    // .env не обязателен
}

Secret Manager: Lockbox

Для production используется secret manager. Пример с Lockbox:

go
package lockbox

import (
    "context"
    "github.com/yandex-cloud/go-sdk"
)

type SecretProvider struct {
    sdk *ycsdk.SDK
}

func (p *SecretProvider) GetSecret(ctx context.Context, key string) (string, error) {
    // Запрос секрета из Lockbox
    // Возвращает значение секрета
}

// Использование при старте
func LoadSecrets(ctx context.Context, provider *lockbox.SecretProvider) (*Secrets, error) {
    hmacKey, err := provider.GetSecret(ctx, "hmac-key")
    if err != nil {
        return nil, fmt.Errorf("load hmac key: %w", err)
    }

    return &Secrets{HMACKey: hmacKey}, nil
}

Маскирование секретов в логах

go
// ПЛОХО: логирование секрета
log.Info("connecting to database", "dsn", cfg.Database.DSN)

// ПРАВИЛЬНО: маскирование
log.Info("connecting to database", "dsn", maskDSN(cfg.Database.DSN))

func maskDSN(dsn string) string {
    // postgres://user:password@host/db -> postgres://user:***@host/db
    // ...
}

4.6 Конфигурация для local/dev/prod

Стратегия: один код, разные конфиги

Код идентичен для всех окружений. Различия только в конфигурации.

Local (локальная разработка)

Источник: configs/app.yaml + .env

yaml
# configs/app.yaml (дефолты для local)
server:
  http_addr: ":8080"

database:
  dsn: "postgres://app:secret@localhost:5432/mydb?sslmode=disable"
  max_conns: 5

logging:
  level: "debug"
  format: "text"

Dev (разработка на сервере)

Источник: configs/app.yaml + env vars

env
# env vars переопределяют файл
APP_DATABASE_DSN=postgres://app:secret@dev-db:5432/mydb
APP_DATABASE_MAX_CONNS=10
APP_LOGGING_LEVEL=info

Staging

Источник: configs/app.yaml + env vars (секреты из secret manager)

env
APP_DATABASE_DSN=postgres://app:secret@staging-db:5432/mydb
APP_SECURITY_HMAC_KEY=<из secret manager>
APP_LOGGING_LEVEL=info

Prod

Источник: configs/app.yaml + env vars (секреты из secret manager)

env
APP_DATABASE_DSN=postgres://app:secret@prod-db:5432/mydb
APP_DATABASE_MAX_CONNS=50
APP_SECURITY_HMAC_KEY=<из secret manager>
APP_LOGGING_LEVEL=warn
APP_LOGGING_FORMAT=json

Таблица различий

ПараметрLocalDevStagingProd
log leveldebuginfoinfowarn
log formattexttextjsonjson
DB max_conns5102550
HMAC keylocaldevstagingprod
Feature flagsall onall onpartialcontrolled

4.7 Структура конфигурации в шаблоне

Файлы конфигурации

text
configs/
├── app.yaml             # Основной конфиг (коммитится)
├── app.example.yaml     # Шаблон (коммитится)
└── systemd/
    ├── git-server.service
    └── git-server-worker.service

internal/config/ в шаблоне

text
internal/config/
├── config.go            # Загрузка и структура
├── defaults.go          # Дефолтные значения
└── validation.go        # Валидация

Пример из шаблона: config.go

go
package config

import (
    "fmt"
    "strings"
    "time"
    "github.com/spf13/viper"
)

type Config struct {
    HTTPAddr        string        `mapstructure:"http_addr"`
    PostgresDSN     string        `mapstructure:"postgres_dsn"`
    NFSPath         string        `mapstructure:"nfs_path"`
    LogLevel        string        `mapstructure:"log_level"`
    ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"`
}

func Load() (*Config, error) {
    v := viper.New()

    v.SetConfigName("app")
    v.SetConfigType("yaml")
    v.AddConfigPath("./configs")

    v.AutomaticEnv()
    v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

    if err := v.ReadInConfig(); err != nil {
        return nil, fmt.Errorf("read config: %w", err)
    }

    var cfg Config
    if err := v.Unmarshal(&cfg); err != nil {
        return nil, fmt.Errorf("unmarshal: %w", err)
    }

    if err := validate(&cfg); err != nil {
        return nil, fmt.Errorf("validate: %w", err)
    }

    return &cfg, nil
}

Привязка к шаблону проекта

Где конфигурация в шаблоне

text
internal/config/
├── config.go            # Загрузка и структура Config
├── defaults.go          # Дефолтные значения
└── validation.go        # Валидация

configs/
├── app.yaml             # Основной конфиг
├── app.example.yaml     # Шаблон
└── systemd/             # Systemd units

Что изучаем на примере шаблона

  1. Структура Config и вложенные конфиги
  2. Загрузка через viper (YAML + env)
  3. Валидация при старте
  4. Разделение секретов и обычной конфигурации
  5. Использование в DI-контейнере

Задание: изучите internal/config/

  • Какие поля есть в Config?
  • Как загружается конфигурация?
  • Есть ли валидация?
  • Как передаётся конфиг в DI?

💱 Компромиссы

  • Чистый env vs YAML через viper: os.Getenv не тянет зависимостей и достаточен до ~10 параметров, но все значения — строки без структуры и общих дефолтов; viper даёт вложенный app.yaml и приоритет env над файлом ценой зависимости и mapstructure-тегов на каждое поле.
  • Fail fast vs валидация при первом использовании: Validate() при старте валит процесс со списком всех ошибок сразу, но каждое правило надо предусмотреть заранее; отложенная проверка короче — и превращается в загадочный runtime-баг в production.
  • Секреты в env vs secret manager: env-переменные просты для локальной разработки и Docker, но ротация и права доступа остаются ручными; Lockbox/Vault дают ротацию и least privilege ценой внешнего вызова при старте и привязки к облаку.
  • Ручная валидация vs go-playground/validator: свой Validate() — обычный Go с говорящими ошибками вроде must be at least 32 characters; теги validate:"required,gt=0" декларативнее, но прячут правила в строках и их сложнее отлаживать.
  • Один app.yaml + env-оверрайды vs файл на окружение: базовый конфиг коммитится без секретов, а APP_* переопределяют значения в dev/prod — один источник дефолтов; набор config_dev.yaml/config_prod.yaml нагляднее, но плодит копии и провоцирует секреты в git.

Практическое задание

Задание 1: Базовая конфигурация

Создайте internal/config/config.go с загрузкой через viper:

go
type Config struct {
    HTTPAddr    string `mapstructure:"http_addr"`
    PostgresDSN string `mapstructure:"postgres_dsn"`
    LogLevel    string `mapstructure:"log_level"`
}

func Load() (*Config, error) {
    v := viper.New()
    v.SetConfigName("app")
    v.SetConfigType("yaml")
    v.AddConfigPath("./configs")
    v.AutomaticEnv()

    if err := v.ReadInConfig(); err != nil {
        return nil, err
    }

    var cfg Config
    if err := v.Unmarshal(&cfg); err != nil {
        return nil, err
    }

    return &cfg, nil
}

Задание 2: Дефолты

Добавьте дефолтные значения:

go
func setDefaults(v *viper.Viper) {
    v.SetDefault("http_addr", ":8080")
    v.SetDefault("log_level", "info")
    v.SetDefault("database.max_conns", 25)
}

Задание 3: Валидация

Реализуйте валидацию Config:

go
func (c *Config) Validate() error {
    if c.HTTPAddr == "" {
        return errors.New("http_addr is required")
    }
    if c.PostgresDSN == "" {
        return errors.New("postgres_dsn is required")
    }
    return nil
}

Задание 4: Конфиг для разных окружений

Создайте конфиги:

text
configs/app.yaml          # базовый (local)
configs/app.dev.yaml      # dev overrides
configs/app.prod.yaml     # prod overrides

Или используйте env vars для overrides.

Задание 5: Секреты через env

Убедитесь что секреты НЕ в конфиг-файле:

yaml
# configs/app.yaml - БЕЗ секретов
database:
  dsn: ""  # из env: APP_DATABASE_DSN

security:
  hmac_key: ""  # из env: APP_SECURITY_HMAC_KEY

Создайте .env.example с шаблоном:

env
# .env.example (коммитится)
APP_DATABASE_DSN=postgres://user:password@localhost:5432/dbname
APP_SECURITY_HMAC_KEY=your-hmac-key-here-min-32-chars

Задание 6: Использование в DI

Интегрируйте конфиг в DI-контейнер:

go
func Build(ctx context.Context, cfg *config.Config) (*App, error) {
    // cfg передаётся в Build, не читается внутри
    pool, err := postgres.NewDB(ctx, cfg.PostgresDSN)
    // ...
}

func main() {
    cfg, err := config.Load()
    if err != nil {
        log.Fatalf("load config: %v", err)
    }

    app, err := app.Build(context.Background(), cfg)
    // ...
}

Quiz "Проверь себя"

1. Что такое fail fast?
2. Где можно хранить секреты? — выберите все подходящие
3. Почему нельзя логировать секреты?
4. Что не так с этим конфигом?
yaml
database:
  dsn: postgres://admin:P@ssw0rd@db.prod:5432/app
5. Что произойдёт при таком вызове, если задана переменная окружения APP_DATABASE_DSN?
go
viper.AutomaticEnv()
viper.SetEnvPrefix("APP")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
dsn := viper.GetString("database.dsn")
6. Что из перечисленного относится к секретам, а не к обычной конфигурации? — выберите все подходящие
7. Почему валидация конфига при старте критична?
8. Как управлять конфигами для local/dev/prod?
9. В чём суть принципа конфигурации 12-Factor App?
10. Что будет, если валидировать конфиг только при первом использовании значения?

✅ Итоги модуля

Студент должен:

  • Проектировать конфигурацию приложения
  • Использовать viper для YAML + env
  • Реализовывать валидацию при старте
  • Безопасно работать с секретами
  • Управлять конфигами для local/dev/prod
  • Понимать 12-Factor App принципы
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 5

Следующий модуль: Модуль 5. Database Access: PostgreSQL - pgx / sqlx, connection pool tuning, индексы и EXPLAIN ANALYZE, миграции (goose), repository pattern, N+1 problem. Привязка к internal/adapters/postgres/ и migrations/ шаблона.