Перейти к содержанию

Конфигурация

Единый стандарт того, как сервис читает свои настройки: какой источник, какой формат, какие обязательные секции, как обращаться с секретами. Каждый сервис соблюдает эти правила — иначе локальный стенд с несколькими сервисами превращается в путаницу, а prod-инциденты на старте невозможно быстро диагностировать.

Содержание

Единственный источник — environment variables

Всё, что настраивается — только через env. Никаких YAML/TOML/JSON-файлов с конфигом в репозитории сервиса. Причины:

  • Env — то, что в k8s задаётся через Secret/ConfigMap и подаётся в контейнер как есть. Никаких промежуточных форматов.
  • Env — то, что docker-compose.yml кладёт в переменные контейнера.
  • Env легко переопределить на локальной машине (.env + direnv + загрузка из godotenv.Load() в main).

Допустимые исключения:

  • Шаблоны писем/пушей (notification-сервис) — хранятся в embed-FS. Это не конфиг, это статический ресурс.
  • Миграции — SQL-файлы, тоже embed, тоже не конфиг.

Env-префикс per сервис

Каждый сервис читает свои переменные с префиксом:

Сервис Префикс
user USER_
review REVIEW_
media MEDIA_
notification NOTIFICATION_

Это нужно, чтобы в локальном стенде с несколькими сервисами одновременно не пересекались DB_HOST или KAFKA_BROKERS. У userUSER_DB_HOST, у reviewREVIEW_DB_HOST.

docker-compose.yml одного сервиса может использовать переменные без префикса внутри своего environment: — это локальная зона видимости. Префикс обязателен на уровне контейнера и прод-манифеста.

Структура Config

Вложенные struct-ы по областям. Пример:

package config

import "time"

type Config struct {
    HTTP    HTTP
    DB      DB
    Kafka   Kafka
    Redis   Redis
    Auth    Auth
    Log     Log
    Metrics Metrics
}

type HTTP struct {
    Port int `envconfig:"HTTP_PORT" default:"8001"`
}

type DB struct {
    Host     string `envconfig:"DB_HOST"      required:"true"`
    Port     int    `envconfig:"DB_PORT"      default:"5432"`
    User     string `envconfig:"DB_USER"      required:"true"`
    Password string `envconfig:"DB_PASSWORD"  required:"true" redact:"true"`
    Name     string `envconfig:"DB_NAME"      required:"true"`
    SSLMode  string `envconfig:"DB_SSLMODE"   default:"disable"`
    PoolMax  int    `envconfig:"DB_POOL_MAX"  default:"20"`
    PoolMin  int    `envconfig:"DB_POOL_MIN"  default:"2"`
}

type Kafka struct {
    Brokers     []string `envconfig:"KAFKA_BROKERS"      required:"true"`
    TopicPrefix string   `envconfig:"KAFKA_TOPIC_PREFIX" default:"kazmaps"`
    GroupID     string   `envconfig:"KAFKA_GROUP_ID"     required:"true"`
}

type Redis struct {
    Addr     string `envconfig:"REDIS_ADDR"     default:"localhost:6379"`
    Password string `envconfig:"REDIS_PASSWORD" redact:"true"`
    DB       int    `envconfig:"REDIS_DB"       default:"0"`
}

type Auth struct {
    Enabled      bool   `envconfig:"AUTH_ENABLED"       default:"true"`
    HMACPrimary  string `envconfig:"AUTH_HMAC_PRIMARY"  redact:"true"`
    HMACPrevious string `envconfig:"AUTH_HMAC_PREVIOUS" redact:"true"`
    InternalToken string `envconfig:"AUTH_INTERNAL_TOKEN" required:"true" redact:"true"`
}

type Log struct {
    Level  string `envconfig:"LOG_LEVEL"  default:"info"`
    Format string `envconfig:"LOG_FORMAT" default:"json"`
}

type Metrics struct {
    Enabled bool   `envconfig:"METRICS_ENABLED" default:"true"`
    Path    string `envconfig:"METRICS_PATH"    default:"/metrics"`
}

Обязательное vs опциональное

  • required:"true" — для всех секретов и endpoint'ов, без которых сервис не имеет смысла (DB_HOST, DB_PASSWORD, KAFKA_BROKERS). Если переменная не задана — сервис не стартует.
  • default:"..." — для настроек, у которых есть разумный дефолт (DB_PORT=5432, LOG_LEVEL=info).
  • Всё, что не required и без default — опциональное, пустое значение допустимо.

Fail-fast на старте. Неполный конфиг — это не «деградированный» режим, это невозможность запуститься.

Валидация

Envconfig проверяет только наличие required. Бизнес-инварианты между полями — в cfg.Validate() error:

func (c *Config) Validate() error {
    if c.Auth.Enabled && c.Auth.HMACPrimary == "" {
        return errors.New("AUTH_HMAC_PRIMARY required when AUTH_ENABLED=true")
    }
    if c.DB.PoolMin > c.DB.PoolMax {
        return errors.New("DB_POOL_MIN must be <= DB_POOL_MAX")
    }
    if c.Log.Format != "json" && c.Log.Format != "text" {
        return fmt.Errorf("LOG_FORMAT must be json or text, got %q", c.Log.Format)
    }
    return nil
}

Любое условие вида «если X, то Y обязателен» живёт здесь, а не в середине main.go.

DSN-хелперы

Вся склейка connection string'ов — методами на Config, не в main.go:

func (d DB) DSN() string {
    return fmt.Sprintf(
        "postgres://%s:%s@%s:%d/%s?sslmode=%s&pool_max_conns=%d&pool_min_conns=%d",
        d.User, url.QueryEscape(d.Password), d.Host, d.Port,
        d.Name, d.SSLMode, d.PoolMax, d.PoolMin,
    )
}

Почему не в main:

  • main.go не должен знать внутренностей БД-конфига.
  • DSN может понадобиться в нескольких местах (pool, migrate), а дублировать склейку — верный способ допустить опечатку.
  • Unit-тест на DSN() — простой и ловит регрессии формата.

Sensitive logging

При старте сервис логирует итоговую конфигурацию, чтобы в логах был «слепок» состояния. Но секреты при этом маскируются.

Вариант 1: cfg.Redacted() возвращает копию с заменёнными полями:

func (c Config) Redacted() Config {
    c.DB.Password = redactString(c.DB.Password)
    c.Auth.HMACPrimary = redactString(c.Auth.HMACPrimary)
    c.Auth.HMACPrevious = redactString(c.Auth.HMACPrevious)
    c.Auth.InternalToken = redactString(c.Auth.InternalToken)
    c.Redis.Password = redactString(c.Redis.Password)
    return c
}

func redactString(s string) string {
    if s == "" {
        return ""
    }
    return "***"
}

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

log.Info("config loaded", "config", cfg.Redacted())

Вариант 2: тег redact:"true" + кастомный stringer, который обходит struct через reflection и заменяет помеченные поля. Подходит, когда секретных полей много и руками легко забыть про новое.

Никогда не логируй cfg напрямую. Это баг, CI-lint должен ловить.

Слои secrets: dev → CI → prod

Local dev. .env в корне клона сервис-репо, в .gitignore. Загружается в main через godotenv.Load() до envconfig.Process:

_ = godotenv.Load(".env") // игнорируем ошибку — в prod .env нет
var cfg Config
if err := envconfig.Process("USER", &cfg); err != nil {
    log.Fatalf("config: %v", err)
}

.env.example коммитится с placeholder'ами (change_me, _dev_).

CI. Секреты — в GitHub Actions Secrets (или аналог); job экспортирует их как env перед шагом тестов. .env.example в CI используется только для документации.

Prod. Секреты приходят из внешнего secret manager (Vault / SOPS / managed secrets провайдера). В k8s их инжектит secret operator как env при старте pod'а. Сервис этого не знает — он просто читает os.Getenv.

Запрет на хардкод в prod-образе

docker-compose.yml для локальной разработки может содержать значения с явными dev-маркерами:

environment:
  NOTIFICATION_DB_PASSWORD: notification_dev_password
  NOTIFICATION_AUTH_INTERNAL_TOKEN: change_me_dev

Эти значения никогда не попадают в prod-образ:

  • Dockerfile не делает ENV ... password.
  • Prod-deploy переопределяет env через k8s Secret, dev-значения игнорируются.

Маркеры _dev_ / change_me / replace_me помогают grep'ом найти всё, что должно быть переопределено в prod.

Hot-reload

Hot-reload конфига не поддерживается. Чтобы применить новое значение — перезапусти pod. Это стандартный k8s-flow: deploy новый Deployment, старый pod уходит через graceful shutdown, новый поднимается уже с актуальным env.

Если очень нужно менять поведение в runtime без рестарта — это feature-flag, не конфиг (см. ниже).

Feature flags

  • Простые (on/off per сервис) — через env:

    FEATURE_OAUTH_TELEGRAM=true
    FEATURE_REVIEW_REACTIONS=true
    
    Переключаются рестартом pod'а.

  • Per-user / per-cohort / процентный rollout — требуют отдельного feature-flag сервиса. Пока такого сервиса нет, сложные rollout'ы делаем через env + staged deploy (canary namespace).

Key rotation

Для HMAC- и JWT-подписи сервис хранит две версии одновременно:

Переменная Значение
AUTH_HMAC_PRIMARY текущий ключ. Подпись идёт этим.
AUTH_HMAC_PREVIOUS предыдущий. Verify принимает оба.

Процедура ротации:

  1. Сгенерировать новый ключ.
  2. Переложить значения: PREVIOUS = PRIMARY, PRIMARY = <новый>.
  3. Раскатить deploy. Старые подписи продолжают проходить verify через PREVIOUS.
  4. Выждать TTL токенов (для JWT — 24 часа по умолчанию).
  5. Очистить PREVIOUS.

Подробнее — ../how-to/rotate-jwt-key.md и security.md.

Обязательные секции конфига

Каждый сервис имеет эти секции (даже если какие-то пусты):

Секция Что в ней
HTTP HTTP_PORT
DB DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME, DB_SSLMODE, DB_POOL_MAX, DB_POOL_MIN
Kafka KAFKA_BROKERS, KAFKA_TOPIC_PREFIX, KAFKA_GROUP_ID
Redis REDIS_ADDR, REDIS_PASSWORD, REDIS_DB
Auth AUTH_HMAC_PRIMARY, AUTH_HMAC_PREVIOUS, AUTH_INTERNAL_TOKEN
Log LOG_LEVEL, LOG_FORMAT
Metrics METRICS_ENABLED, METRICS_PATH

Сервис-специфические секции (у mediaS3_*, IMAGE_MAX_SIZE; у notificationFCM_*, APNS_*, SMTP_*) — добавляй рядом, не внутри существующих.

.env.example

В каждом репозитории сервиса лежит .env.example — шаблон. Формат:

# === HTTP ===
USER_HTTP_PORT=8001

# === DB ===
USER_DB_HOST=localhost
USER_DB_PORT=5432
USER_DB_USER=user_service
USER_DB_PASSWORD=change_me
USER_DB_NAME=user
USER_DB_SSLMODE=disable

# === Kafka ===
USER_KAFKA_BROKERS=localhost:9092
USER_KAFKA_GROUP_ID=user-service

# === Auth ===
# Rotation: PRIMARY — текущий, PREVIOUS — предыдущий (для verify старых токенов)
USER_AUTH_HMAC_PRIMARY=change_me
USER_AUTH_HMAC_PREVIOUS=
USER_AUTH_INTERNAL_TOKEN=change_me

Правила .env.example:

  • Placeholder'ы (change_me, _dev_), а не реальные значения.
  • Комментарии объясняют, зачем переменная нужна.
  • Секции разделены # === ... === — человеку проще искать.

Загрузка в main.go

Типовой старт:

func main() {
    if err := run(); err != nil {
        log.Fatalf("fatal: %v", err)
    }
}

func run() error {
    _ = godotenv.Load(".env")

    var cfg config.Config
    if err := envconfig.Process("USER", &cfg); err != nil {
        return fmt.Errorf("load config: %w", err)
    }
    if err := cfg.Validate(); err != nil {
        return fmt.Errorf("validate config: %w", err)
    }

    logger := newLogger(cfg.Log.Level)
    logger.Info("config loaded", "config", cfg.Redacted())

    // ... остальной bootstrap
    return nil
}

Именно в такой последовательности: Load → Process → Validate → log.

CI-проверка .env.example

CI проверяет, что .env.example согласован с кодом: скрипт парсит struct-теги в internal/config/ и сверяется с ключами в .env.example.

Если в коде появилась новая переменная, а в .env.example — нет (или наоборот) — CI падает. Это предотвращает ситуацию «обновил конфиг, забыл предупредить команду».

Подробнее — в репозитории сервиса, scripts/check-env-example.sh (или эквивалент).

Что не делать

  • Не читай os.Getenv("FOO") в середине кода. Всё — через cfg, прокинутый из main.go.
  • Не хардкодь секреты в docker-compose.yml без пометки _dev_.
  • Не смешивай YAML/TOML-файл с env. Один источник.
  • Не делай hot-reload конфига. Рестарт — стандарт.
  • Не логируй cfg без Redacted().
  • Не клади в .env.example реальные значения — даже dev'шные. Placeholder'ы только.

См. также

  • security.md — как сервис работает с секретами и ротацией ключей.
  • ../how-to/rotate-jwt-key.md — пример процедуры ротации.
  • shutdown.mdterminationGracePeriodSeconds и согласованность с shutdown-таймаутом.