open atlas
↑ К треку
Go с нуля до senior GO · 07 · 02

Конфигурация без магии: env, флаги, файлы и типизированный Config, падающий на старте

Парсите конфигурацию один раз на старте в типизированный, жадно валидируемый Config: дефолты, затем файл, env, флаги. Падайте при запуске, а не на запросе в три ночи. Секреты получают редактирующий String; горячая перезагрузка — сложность, которую лучше обменять на рестарт.

GO Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Деплой ушёл во вторник днём; пейдж пришёл в субботу в 3:12 ночи. Кэш-клиент читал свой таймаут лениво, глубоко в request path: os.Getenv("CACHE_TIMEOUT_MS"), strconv.Atoi, ошибка проигнорирована. На стейджинге переменная была; в проде — нет. Getenv вернул пустую строку, Atoi молча провалился в ноль, а в этом клиенте ноль означал без таймаута. Четыре дня ничего не происходило — пока в субботу не умер кэш-узел и каждый запрос не начал вечно ждать мёртвое TCP-соединение. Горутины копились по две тысячи в секунду, память следом, OOM-киллер доделал остальное. Фикс в 3 ночи — выставить одну env-переменную. Настоящий фикс приехал в понедельник: каждое чтение конфига переехало в Load() на старте, который использует LookupEnv, парсит в типизированную структуру и возвращает ошибку на всё отсутствующее или кривое. Плохой деплой провалил бы readiness-пробу во вторник в 14:07 — перед инженером с кофе и при старых подах, продолжающих обслуживать трафик.

Три источника, одна лестница

Зачем конфигурации вообще нужна лестница приоритетов? Потому что у флагов, переменных окружения и файлов разные владельцы — и когда они расходятся, нужно правило, достаточно очевидное, чтобы разобраться в нём в три ночи. Production-сервисы на Go тянут конфигурацию из трёх мест, у каждого — свой реальный потребитель. Переменные окружения — 12-factor-база: на деплой, без пересборки, нативно для любого оркестратора — но это плоские строки, невидимые, пока их не напечатаешь, и наследуемые каждым дочерним процессом. Флаги служат операторам: -help — живая документация, а разовый override в терминале лучше правки манифеста. Файлы несут сложные формы — вложенные структуры, списки, per-tenant-карты, — которые умирают тысячей смертей при кодировании в env-строки, и проходят код-ревью. Порядок разрешения — лестница приоритетов, побеждает более осознанный:

Парсить один раз, валидировать жадно

У дисциплины, предотвращающей инцидент из крючка, две половины. Первая: конфигурация читается ровно в одном месте, на старте, в типизированную структуру — никакого os.Getenv ниже main. Вторая: валидация жадная и фатальная. Сервис, загрузившийся с мусорным конфигом, падает на запросе — часами или днями позже, под трафиком; сервис, отказавшийся загружаться, падает внутри деплоя, где раскатка останавливается автоматически, а старые поды продолжают обслуживать.

type Config struct {
	HTTPAddr     string
	CacheTimeout time.Duration
	DatabaseURL  Secret // см. ниже
}

func Load(args []string) (Config, error) {
	cfg := Config{HTTPAddr: ":8080", CacheTimeout: 50 * time.Millisecond} // дефолты

	if v, ok := os.LookupEnv("CACHE_TIMEOUT_MS"); ok { // unset и пустая — разные вещи
		ms, err := strconv.Atoi(v)
		if err != nil || ms <= 0 {
			return Config{}, fmt.Errorf("CACHE_TIMEOUT_MS=%q: нужно положительное целое", v)
		}
		cfg.CacheTimeout = time.Duration(ms) * time.Millisecond
	}

	fs := flag.NewFlagSet("api", flag.ContinueOnError)
	fs.StringVar(&cfg.HTTPAddr, "http", cfg.HTTPAddr, "адрес для прослушивания") // флаг бьёт env
	if err := fs.Parse(args); err != nil {
		return Config{}, err
	}
	return cfg, cfg.validate() // обязательные поля, диапазоны, синтаксис URL — всё сейчас
}

LookupEnv — несущая деталь: Getenv не отличает не задана от задана пустой, а это ровно та неоднозначность, что превратила отсутствующую переменную в тихий ноль. Хендлеры получают Config — или только нужную им под-структуру — через конструкторы: зависимость видна в сигнатурах и тривиально подменяется в тестах.

Викторина

Деплой уходит с CACHE_TIMEOUT_MS=abc. Сервис использует Load() на старте, как выше. Что произойдёт — и что произошло бы со старым ленивым Getenv в хендлере?

Секретам не место в дампах

Когда вы печатаете конфиг-структуру — а вы будете, в бут-логах или краш-репортах, — каждое незащищённое поле становится вектором утечки. Конфиг-структуры печатаются: бут-логи, краш-репорты, %+v в пути ошибки, debug-эндпоинты. Каждое из этих мест — вектор утечки пароля базы, вшитого в DSN. Флаги хуже — аргументы процесса читаются любым ps на shared-хостах. Go-нативное сдерживание — именованный тип, чьё форматирование врёт:

type Secret string

func (Secret) String() string       { return "[REDACTED]" }
func (Secret) LogValue() slog.Value { return slog.StringValue("[REDACTED]") }
func (s Secret) Reveal() string     { return string(s) } // явно и greppable

// fmt %v, %s, %+v и slog проходят через String/LogValue. json.Marshal — НЕТ:
// он читает нижележащую строку, так что определите ещё и MarshalJSON,
// если структура хоть когда-то может попасть в энкодер.

Reveal() — суть паттерна: каждое место, где сырой секрет покидает тип, теперь осознанный, ищущийся грепом акт, а не случайность форматирования. Reflection-энкодеры обходят Stringer, поэтому честная версия реализует и MarshalJSON — команды узнают это в день, когда support-бандл с сериализованным конфигом приземляется в тикет-системе.

Горячая перезагрузка, честно

Слежение за файлом выглядит апгрейдом и обычно оказывается даунгрейдом. Рестарт применяет конфиг через ровно тот путь, который вы тестируете каждым деплоем: парсинг, валидация, отказ-или-обслуживание. Reload создаёт второй путь, который выполняется редко и без тестового покрытия: полуприменённые обновления, где часть горутин держит старое значение, валидация наперегонки с in-flight запросами, причуды fsnotify вокруг симлинк-танца, который Kubernetes устраивает при обновлении ConfigMap, и флот, где каждый под подхватил изменение в свой момент. Rolling-рестарт деплоймента на двадцать подов стоит пару минут и ноль новых код-путей. Reload отрабатывает свою сложность в двух местах: флоты настолько большие, что рестарт-штормы стоят реальной ёмкости, и значения, меняющиеся много раз в день, — а это feature-флаги, и им место в flag-сервисе с явным версионированием и аудитом, а не в наблюдаемом файле.

Почему это работает

Почему падение на буте настолько дешевле падения на запросе? Потому что деплой — единственный момент, когда человек уже смотрит, а система построена откатываться. Проваленная readiness-проба новой ревизии останавливает раскатку примерно за минуту с нулевой потерей пользовательского трафика. То же плохое значение, прочитанное лениво, детонирует, когда его код-путь впервые выполнится — в субботу, под нагрузкой, когда вызвавший его деплой остыл четыре дня назад, а дежурный восстанавливает историю по дашбордам.

Конфиг библиотеки — другая задача

Паттерн «типизированная структура на буте» — для приложений: они владеют main и могут быть исчерпывающими. У библиотек противоположные ограничения: экспортируемый API, замороженный обещаниями совместимости, вызывающие, которые задают два из пятнадцати параметров, и неоднозначность нулевого значения — Timeout: 0 значит «не задан, возьми дефолт» или «осознанно без таймаута»? Функциональные опции решают все три:

type Option func(*client)

func WithTimeout(d time.Duration) Option { return func(c *client) { c.timeout = d } }

func New(addr string, opts ...Option) (*Client, error) {
	c := &client{timeout: 5 * time.Second} // дефолт живёт здесь, без неоднозначности
	for _, o := range opts {
		o(c)
	}
	return c.validateAndBuild()
}

«Не задано» означает дефолт, потому что опцию просто не передали; новые параметры не ломают существующие вызовы; валидация выполняется при конструировании. Внутри приложения тот же паттерн — церемония: обычная структура greppable, исчерпывающа и очевидна.

Викторина

Вы сопровождаете опубликованную Go-библиотеку и добавляете опциональный параметр. Почему функциональные опции лучше нового поля в экспортируемой Config-структуре?

Вспомните перед уходом
  1. 01
    Изложи лестницу приоритетов, место её разрешения и два режима отказа, которые убирает «парсить один раз, валидировать жадно».
  2. 02
    Зачем типу Secret три метода и когда функциональные опции реально отрабатывают свою церемонию?
Итог

Конфигурация в production Go — дисциплина одного момента: всё разрешается на буте, ничего не читается лениво. Три источника питают лестницу приоритетов — скомпилированные дефолты внизу, затем прошедший ревью конфиг-файл для сложных форм, затем 12-factor (переменные окружения на деплой, нативны для любого оркестратора), затем флаги как документированный override оператора — и лестница схлопывается в один типизированный Config внутри одной функции Load(). LookupEnv, никогда не Getenv: незаданное и пустое — разные факты, и их смешение превращает отсутствующую переменную в тихий ноль. Валидация жадная и фатальная: обязательные поля, диапазоны, парсящиеся URL — всё до открытия листенера, чтобы кривое значение провалило readiness-пробу во время раскатки — минута остановленного деплоя при живых старых подах — вместо детонации в три ночи, когда плохой код-путь наконец выполнится. Секреты едут в именованном типе, который редактирует через String для fmt, LogValue для slog и MarshalJSON для энкодеров, обходящих оба, с явным Reveal(), помечающим каждый санкционированный выход; и они не попадают во флаги, которые читает любой ps. Горячая перезагрузка — честно оценённая сложность: второй, редко выполняемый путь применения с гонками частичного обновления и расхождением подов, оправданный только на флотах масштаба рестарт-штормов — а часто меняющиеся значения суть feature-флаги, и им место в flag-сервисе. Библиотеки переворачивают расчёт: функциональные опции дают замороженным API эволюционирующие дефолты и однозначное «не задано», чего конфиг-структура не умеет. Теперь, когда сервис ведёт себя не так, как ожидалось, первый вопрос такой: какое значение победило на лестнице и залогировал ли Load() его источник?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.