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

Проблема: три источника, один конфиг
У типичного Go-сервиса настройки приходят минимум из трёх мест: конфигурационный файл (YAML, TOML, JSON), переменные окружения и флаги CLI. В идеале хочется одну структуру, которая собирает всё вместе, с понятным приоритетом: флаг перебивает переменную окружения, переменная окружения — файл, файл — значение по умолчанию. На практике стандартная библиотека покрывает только флаги, а всё остальное — на вас.
Ниже — четыре подхода, от ручного до библиотечного. Код проверен на Go 1.22; где поведение зависит от версии, это оговорено.
Подход 1: стандартная библиотека и ручная склейка
Пакеты flag, os и encoding/json (или encoding/xml, gopkg.in/yaml.v3 для YAML) есть в любом проекте без внешних зависимостей. Идея проста: определяете структуру, десериализуете файл, затем перезаписываете поля из os.Getenv и flag.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
)
// Config — структура с настройками сервиса.
type Config struct {
Addr string `json:"addr"`
LogLevel string `json:"log_level"`
Timeout int `json:"timeout_seconds"`
}
// loadFromFile читает JSON-файл и десериализует в Config.
func loadFromFile(path string) (Config, error) {
var cfg Config
data, err := os.ReadFile(path)
if err != nil {
return cfg, fmt.Errorf("не удалось прочитать файл: %w", err)
}
if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("ошибка разбора JSON: %w", err)
}
return cfg, nil
}
func main() {
// Значения по умолчанию.
cfg := Config{
Addr: ":8080",
LogLevel: "info",
Timeout: 30,
}
// Слой 1: файл.
configPath := flag.String("config", "config.json", "путь к конфигу")
flag.Parse()
if fileCfg, err := loadFromFile(*configPath); err == nil {
// Перезаписываем только непустые значения из файла.
if fileCfg.Addr != "" {
cfg.Addr = fileCfg.Addr
}
if fileCfg.LogLevel != "" {
cfg.LogLevel = fileCfg.LogLevel
}
if fileCfg.Timeout != 0 {
cfg.Timeout = fileCfg.Timeout
}
}
// Слой 2: переменные окружения.
if v := os.Getenv("APP_ADDR"); v != "" {
cfg.Addr = v
}
if v := os.Getenv("APP_LOG_LEVEL"); v != "" {
cfg.LogLevel = v
}
fmt.Printf("addr=%s log_level=%s timeout=%d\n", cfg.Addr, cfg.LogLevel, cfg.Timeout)
}
Код запускается без внешних зависимостей. Проверка: go run main.go -config config.json — в консоли должна появиться строка с текущими значениями.
Главная проблема — ручная перезапись каждого поля. Десять полей — десять блоков if. Пятьдесят — конфигурационный ад.
Подход 2: Viper — «комбайн» для конфига
github.com/spf13/viper — самая популярная библиотека чтения конфигурации в экосистеме Go. Она умеет читать YAML, TOML, JSON, переменные окружения, флаги (через Cobra или стандартный flag), удалённые хранилища (etcd, Consul) и горячую перезагрузку файла.
package main
import (
"fmt"
"strings"
"github.com/spf13/viper"
)
type Config struct {
Addr string `mapstructure:"addr"`
LogLevel string `mapstructure:"log_level"`
Timeout int `mapstructure:"timeout_seconds"`
}
func main() {
viper.SetDefault("addr", ":8080")
viper.SetDefault("log_level", "info")
viper.SetDefault("timeout_seconds", 30)
// Файл.
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
if err := viper.ReadInConfig(); err != nil {
fmt.Printf("конфиг-файл не найден, используем defaults и env: %v\n", err)
}
// Переменные окружения с префиксом APP_.
viper.SetEnvPrefix("APP")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
panic(fmt.Sprintf("ошибка разбора конфига: %v", err))
}
fmt.Printf("addr=%s log_level=%s timeout=%d\n", cfg.Addr, cfg.LogLevel, cfg.Timeout)
}
Проверка: создайте файл config.yaml рядом с main.go с содержимым addr: ":9090" и запустите go run main.go — должна вывестись строка addr=:9090. Затем выполните APP_ADDR=:3000 go run main.go — переменная окружения перебьёт файл.
Цена: Viper тянет за собой ощутимое дерево зависимостей. В проекте, где контролируют размер go.sum, это раздражает. Кроме того, привязка к глобальному состоянию (синглтон viper.GetViper()) затрудняет тестирование — приходится либо передавать экземпляр, либо мириться.
Подход 3: envconfig — только переменные окружения
Для контейнеризированных сервисов, которые живут по принципу 12-Factor App*, файл конфигурации не нужен: всё приходит через переменные окружения. Библиотека github.com/kelseyhightower/envconfig делает ровно одно — маппит переменные окружения на поля структуры по тегам.
package main
import (
"fmt"
"github.com/kelseyhightower/envconfig"
)
type Config struct {
Addr string `envconfig:"ADDR" default:":8080"`
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
Timeout int `envconfig:"TIMEOUT" default:"30"`
}
func main() {
var cfg Config
// Префикс "APP" превращает ADDR → APP_ADDR.
if err := envconfig.Process("APP", &cfg); err != nil {
panic(fmt.Sprintf("ошибка чтения env: %v", err))
}
fmt.Printf("addr=%s log_level=%s timeout=%d\n", cfg.Addr, cfg.LogLevel, cfg.Timeout)
}
Проверка: APP_ADDR=:4000 APP_LOG_LEVEL=debug go run main.go.
Минимум зависимостей, минимум магии. Но если завтра потребуется файл или горячая перезагрузка — придётся менять подход или дописывать склейку руками.
Что такое 12-Factor App
* 12-Factor App — методология проектирования SaaS-приложений, согласно которой конфигурация хранится исключительно в переменных окружения, а не в файлах, вкомпилированных в образ. Подробности — на 12factor.net/config.
Подход 4: cleanenv — файл + env без лишнего веса
github.com/ilyakaznacheev/cleanenv — компактная библиотека, которая читает YAML/TOML/JSON и переменные окружения, маппит на структуру через теги yaml / env / env-default. Она легче Viper и при этом закрывает основной сценарий: файл + env + дефолты.
package main
import (
"fmt"
"github.com/ilyakaznacheev/cleanenv"
)
type Config struct {
Addr string `yaml:"addr" env:"APP_ADDR" env-default:":8080"`
LogLevel string `yaml:"log_level" env:"APP_LOG_LEVEL" env-default:"info"`
Timeout int `yaml:"timeout" env:"APP_TIMEOUT" env-default:"30"`
}
func main() {
var cfg Config
if err := cleanenv.ReadConfig("config.yaml", &cfg); err != nil {
fmt.Printf("файл не найден, читаем env и defaults: %v\n", err)
if err := cleanenv.ReadEnv(&cfg); err != nil {
panic(err)
}
}
fmt.Printf("addr=%s log_level=%s timeout=%d\n", cfg.Addr, cfg.LogLevel, cfg.Timeout)
}
Приоритет: переменная окружения перебивает значение из файла — это поведение по умолчанию в cleanenv, и именно оно нужно для контейнеров.
Сравнение подходов
| Критерий | stdlib | Viper | envconfig | cleanenv |
|---|---|---|---|---|
| Файл (YAML/JSON/TOML) | Руками | Да | Нет | Да |
| Переменные окружения | Руками | Да | Да | Да |
| Флаги CLI | Да (flag) | Через Cobra | Нет | Нет |
| Горячая перезагрузка | Нет | Да (fsnotify) | Нет | Нет |
| Размер go.sum | 0 зависимостей | Десятки модулей | 0 транзитивных | Единицы модулей |
| Глобальное состояние | Нет | Синглтон по умолчанию | Нет | Нет |
| Валидация полей | Руками | Руками / go-playground | required-тег | required-тег |
Если сервис живёт в Kubernetes и файл конфигурации — это ConfigMap, смонтированный как том, envconfig или cleanenv хватает с запасом. Viper оправдан, когда нужен etcd, Consul или горячая перезагрузка без перезапуска пода.
Типичные ошибки, которые ломают конфиг
- Нет валидации сразу после загрузки. Конфиг прочитался, поле
Timeoutравно нулю — и сервис работает без таймаута. Добавляйте проверку обязательных полей: через тегrequired(envconfig, cleanenv) или отдельной функциейValidate(). - Секреты в YAML-файле. Пароль к базе попадает в Git, потому что
config.yamlлежит в репозитории. Правило: всё, что является секретом, должно приходить только через переменные окружения или секрет-менеджер, но не из файла. - Несовпадение тегов. Тег
mapstructure:"log_level"для Viper,yaml:"log_level"для cleanenv,envconfig:"LOG_LEVEL"для envconfig — три разных мира. При смене библиотеки теги нужно менять, и ошибка тут тихая: поле просто остаётся пустым. - Отсутствие дефолтов для необязательных полей.
json.Unmarshalне трогает поле, если ключа нет в JSON. Если дефолт не задан в объявлении структуры, поле получит zero value: пустую строку, ноль или false — и это может быть валидным, но неожиданным значением.
Когда что выбирать
Микросервис в контейнере, без файла
envconfig. Ничего лишнего, одна зависимость, всё через env. Если появится потребность в CLI-флагах — добавьте flag стандартной библиотеки вручную.
Сервис с конфиг-файлом и env
cleanenv. Лёгкая, покрывает комбинацию файл + env, не тащит дерево зависимостей.
Монолит с десятками источников и горячей перезагрузкой
Viper. Здесь его тяжесть оправдана: поддержка remote-конфигов и WatchConfig экономят код.
Утилита или CLI-инструмент
Стандартная библиотека: flag + os.Getenv + JSON/YAML-парсер. Ноль внешних зависимостей — это преимущество для инструмента, который ставится через go install.
Что делать дальше
Выберите один подход из четырёх и вынесите чтение конфигурации в отдельный пакет internal/config. Добавьте функцию MustLoad, которая вызывает panic при ошибке — сервис не должен запускаться с битым конфигом. Напишите тест, который проверяет дефолты: подмените переменные окружения через t.Setenv (доступен с Go 1.17) и убедитесь, что каждое поле получает ожидаемое значение. Следующий шаг — валидация: библиотека go-playground/validator умеет проверять диапазоны, форматы и обязательность полей по тегам, и хорошо сочетается с любым из рассмотренных подходов.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: Viper — официальный репозиторий на GitHub, envconfig — официальный репозиторий Kelsey Hightower
Частые вопросы
Какой приоритет источников конфигурации считается стандартным?
Можно ли использовать Viper без Cobra?
А если я обновлю Go или библиотеку и код из статьи перестанет работать?
Как хранить секреты, если не в конфиг-файле?
envconfig и cleanenv поддерживают вложенные структуры?
Нужна ли горячая перезагрузка конфига в продакшене?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.