Skip to Content
ConventionsКонфигурация

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

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

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

СервисПрефикс
userUSER_
reviewREVIEW_
mediaMEDIA_
notificationNOTIFICATION_

Это нужно, чтобы в локальном стенде с несколькими сервисами одновременно не пересекались 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:"GATEWAY_HMAC_SECRET_PRIMARY" redact:"true"` HMACPrevious string `envconfig:"GATEWAY_HMAC_SECRET_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("GATEWAY_HMAC_SECRET_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-подписи сервис хранит две версии одновременно:

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

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

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

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

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

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

СекцияЧто в ней
HTTPHTTP_PORT
DBDB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME, DB_SSLMODE, DB_POOL_MAX, DB_POOL_MIN
KafkaKAFKA_BROKERS, KAFKA_TOPIC_PREFIX, KAFKA_GROUP_ID
RedisREDIS_ADDR, REDIS_PASSWORD, REDIS_DB
AuthGATEWAY_HMAC_SECRET_PRIMARY, GATEWAY_HMAC_SECRET_PREVIOUS, AUTH_INTERNAL_TOKEN
LogLOG_LEVEL, LOG_FORMAT
MetricsMETRICS_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_GATEWAY_HMAC_SECRET_PRIMARY=change_me USER_GATEWAY_HMAC_SECRET_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 — как сервис работает с секретами и ротацией ключей.
  • ../how-to/rotate-jwt-key — пример процедуры ротации.
  • shutdownterminationGracePeriodSeconds и согласованность с shutdown-таймаутом.
Last updated on