Загрузка конфигурации в Go: разбор на живом проекте
Сервис читал конфиг из os.Getenv напрямую, и на проде выяснилось, что при пустой переменной он молча стартовал с нулевым таймаутом. Разбираем, как устроена загрузка конфигурации в Go и где она ломается первой.

История типовая: сервис на Go запускался в трёх окружениях — локально, в стейджинге и в проде. Локально читал переменные из .env, в кластере — из ConfigMap. Однажды в проде забыли задать HTTP_READ_TIMEOUT. Сервис не упал. Он поднялся с таймаутом 0, что в net/http означает «без ограничения», и через два дня под нагрузкой висящие соединения съели все воркеры. 502 на трёхстах одновременных запросах. Разбираемся, почему конфиг молчал и как устроить загрузку так, чтобы она падала на старте, а не в проде.
Что именно ломается в наивном подходе
Наивная загрузка выглядит так: в момент, когда значение понадобилось, читаем os.Getenv и парсим. Проблем здесь три, и все они срабатывают не сразу.
- Отсутствие переменной неотличимо от пустого значения.
os.Getenvвозвращает пустую строку и для незаданной переменной, и для заданной пустой. Ошибку вы не получите. - Парсинг откладывается до первого использования. Если
strconv.Atoiдля порта вызывается при первом запросе, а не на старте, сервис успеет пройти health-check и попасть в балансировщик, прежде чем упадёт. - Нулевое значение типа тихо превращается в поведение.
time.Duration(0), пустой слайс хостов,falseвместо незаданного флага — язык не отличает «не задано» от «задано нулём».
Вся статья — про то, как перенести обнаружение проблемы на момент старта. Валидацию бизнес-значений (например, «порт занят», «БД недоступна») здесь не рассматриваем — это отдельная тема про проверку зависимостей.
Конфиг, который читается лениво, — это отложенная паника. Хорошая загрузка конфигурации падает ровно один раз: при запуске процесса, до того как он объявит себя готовым.
Подход 1: ручное чтение с явной проверкой
Минимум зависимостей, всё видно глазами. Структура конфига заполняется в одной функции, которая возвращает ошибку. Пример для Go 1.22.
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
HTTPPort int
ReadTimeout time.Duration
DatabaseURL string
}
// Load читает конфигурацию из переменных окружения
// и возвращает ошибку, если обязательное значение отсутствует.
func Load() (*Config, error) {
cfg := &Config{}
// Обязательная строка: пустое значение — это ошибка.
cfg.DatabaseURL = os.Getenv("DATABASE_URL")
if cfg.DatabaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL не задан")
}
// Число со значением по умолчанию.
port, err := getIntEnv("HTTP_PORT", 8080)
if err != nil {
return nil, err
}
cfg.HTTPPort = port
// Длительность: отличаем «не задано» от «задано нулём».
timeout, err := getDurationEnv("HTTP_READ_TIMEOUT", 5*time.Second)
if err != nil {
return nil, err
}
cfg.ReadTimeout = timeout
return cfg, nil
}
func getIntEnv(key string, def int) (int, error) {
raw, ok := os.LookupEnv(key)
if !ok || raw == "" {
return def, nil
}
v, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("%s: ожидалось целое число, получено %q", key, raw)
}
return v, nil
}
func getDurationEnv(key string, def time.Duration) (time.Duration, error) {
raw, ok := os.LookupEnv(key)
if !ok || raw == "" {
return def, nil
}
v, err := time.ParseDuration(raw)
if err != nil {
return 0, fmt.Errorf("%s: неверный формат длительности %q", key, raw)
}
return v, nil
}
Ключевая деталь — os.LookupEnv вместо os.Getenv. Он возвращает второй аргумент ok, по которому вы отличаете незаданную переменную от заданной пустой. Именно этого не хватало в исходном инциденте.
Цена подхода: на каждое поле — свой блок. Двадцать полей превращаются в двести строк однообразного кода, где легко забыть проверку. Зато нет ни одной магии: что читается, то и видно.
Подход 2: теги структуры и библиотека
Когда полей много, чтение выносят в теги, а разбор — в библиотеку. Распространённый вариант — github.com/caarlos0/env. Проверьте актуальную версию и путь импорта в репозитории проекта перед использованием; ниже показан общий принцип, а не гарантированный синтаксис конкретной версии.
type Config struct {
// required приводит к ошибке, если переменная не задана
DatabaseURL string `env:"DATABASE_URL,required"`
HTTPPort int `env:"HTTP_PORT" envDefault:"8080"`
ReadTimeout time.Duration `env:"HTTP_READ_TIMEOUT" envDefault:"5s"`
}
Библиотека сама выполнит парсинг типов и вернёт ошибку при отсутствии required-поля. Вы описываете конфиг декларативно, а не пишете двадцать однотипных проверок. Точные имена тегов (required, envDefault) и поведение зависят от версии — сверяйтесь с README репозитория, а не с этой статьёй.
Цена: вы отдаёте контроль над сообщениями об ошибках и добавляете зависимость. Отладка «почему не распарсилось» уходит внутрь чужого кода. Для нестандартных типов (список хостов через запятую, вложенные секции) придётся писать кастомные парсеры, и выигрыш в лаконичности тает.
Подход 3: файл плюс окружение через Viper
Когда конфиг живёт в YAML/JSON-файле, а окружение только переопределяет часть значений, берут github.com/spf13/viper. Он собирает значения из файла, переменных окружения и флагов с заданным приоритетом.
Здесь появляется удобный сценарий: базовые значения в config.yaml, а секреты и специфика окружения — через переменные. Обратная сторона — приоритет источников становится неочевидным. Вопрос «откуда пришло это значение» в Viper решается не так наглядно, как в ручном чтении, а тихое переопределение файла окружением — частый источник «у меня локально работает».
Сравнение подходов
| Критерий | Ручное чтение | Теги + env-библиотека | Viper (файл + env) |
|---|---|---|---|
| Зависимости | нет | одна лёгкая | крупная, с транзитивными |
| Полей до 10 | оптимально | избыточно | избыточно |
| Полей 10–40 | многословно | оптимально | если нужен файл |
| Несколько источников | руками | ограниченно | сильная сторона |
| Контроль ошибок | полный | средний | слабый |
| Читаемость «откуда значение» | высокая | средняя | низкая |
Когда что выбирать
- До 10–15 полей и только окружение — ручное чтение с
os.LookupEnv. Ноль зависимостей, полный контроль над текстом ошибки. Именно это чинит инцидент из начала. - Много плоских полей, только окружение — теги и env-библиотека. Меньше кода, приемлемая цена в виде одной зависимости.
- Нужен файл конфигурации плюс переопределение окружением — Viper. Только если несколько источников реально есть; ради одного окружения он избыточен.
Критерий простой: считайте не количество полей, а количество источников. Один источник — библиотека или руки. Два и больше с приоритетом — специализированный инструмент.
Что ломается на практике
- Нулевое значение как валидное поведение. Таймаут
0, пустой список, лимит0. Всегда задавайте осмысленный default и проверяйте нижнюю границу явно:if cfg.ReadTimeout <= 0. - Ленивое чтение. Если конфиг читается при первом запросе, ошибка всплывёт после того, как под сервис уже пошёл трафик. Читайте и валидируйте всё в
mainдо старта HTTP-сервера. - Секреты в логах. При
log.Printf("%+v", cfg)в лог уедетDatabaseURLс паролем. РеализуйтеString()с маскированием чувствительных полей или не логируйте структуру целиком. - Разный формат в разных окружениях. Локально
5s, в кластере кто-то вписал5000без единицы.time.ParseDurationвернёт ошибку — и это правильно, но только если вы её проверяете. - Тихий default в проде. Default удобен локально и опасен на проде: пропущенная переменная не заметна. Разделяйте — обязательные поля без default (
required), необязательные с default.
Что сделать после разбора
Первый шаг — вынести всю загрузку в одну функцию Load() (*Config, error) и вызвать её в main до открытия портов. Если вернулась ошибка — log.Fatal, процесс не должен стартовать частично собранным.
Второй — добавить в CI проверку, что при пустом окружении сервис падает с внятным сообщением, а не поднимается. Это ловит регрессии вроде исходного инцидента. Дальше смотрите в проде на два показателя: время старта (валидация не должна его раздувать) и количество рестартов пода — резкий рост после деплоя часто означает, что конфиг снова начал падать, но уже по другой причине.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: Пакет os — LookupEnv, документация Go, Пакет time — ParseDuration, документация Go
Частые вопросы
У меня не Go 1.22, а более старая версия. Код будет работать?
Почему не читать конфиг просто через os.Getenv?
А если валидация не сработала и сервис всё равно поднялся с плохим конфигом?
Стоит ли класть значения по умолчанию прямо в код?
Как не залогировать пароль из конфига?
Viper или env-библиотека — что тяжелее по зависимостям?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.