Конфигурация¶
Единый стандарт того, как сервис читает свои настройки: какой источник, какой формат, какие обязательные секции, как обращаться с секретами. Каждый сервис соблюдает эти правила — иначе локальный стенд с несколькими сервисами превращается в путаницу, а prod-инциденты на старте невозможно быстро диагностировать.
Содержание¶
- Единственный источник — environment variables
- Env-префикс per сервис
- Структура Config
- Обязательное vs опциональное
- Валидация
- DSN-хелперы
- Sensitive logging
- Слои secrets: dev → CI → prod
- Запрет на хардкод в prod-образе
- Hot-reload
- Feature flags
- Key rotation
- Обязательные секции конфига
.env.example- Загрузка в
main.go - CI-проверка
.env.example - Что не делать
- См. также
Единственный источник — 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. У user — USER_DB_HOST,
у review — REVIEW_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 "***"
}
Использование:
Вариант 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:
Переключаются рестартом 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 принимает оба. |
Процедура ротации:
- Сгенерировать новый ключ.
- Переложить значения:
PREVIOUS = PRIMARY,PRIMARY = <новый>. - Раскатить deploy. Старые подписи продолжают проходить verify через
PREVIOUS. - Выждать TTL токенов (для JWT — 24 часа по умолчанию).
- Очистить
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 |
Сервис-специфические секции (у media — S3_*, IMAGE_MAX_SIZE; у
notification — FCM_*, 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.md—terminationGracePeriodSecondsи согласованность с shutdown-таймаутом.