Go: конфигурация через env без хаоса

Как выстроить конфигурацию Go-приложения через env vars без расползания параметров, неочевидных дефолтов и ошибок старта

Конфигурация через переменные окружения выглядит простой, пока сервис маленький. Но довольно быстро появляются обязательные параметры, дефолты, разные среды запуска и вопрос: где заканчивается удобство и начинается хаос.

Плохой вариант обычно выглядит так: os.Getenv размазан по коду, часть значений молча берётся по умолчанию, часть валидируется где-то в середине старта, а реальный набор переменных известен только тому, кто последний раз запускал сервис локально.

Рабочий baseline для Go-сервиса другой: одна структура конфигурации, явная загрузка на старте, понятная валидация, .env.example как документация и единый подход для локального запуска, Docker Compose и production.

Почему конфиг “просто через env” быстро расползается

Почти в каждом сервисе быстро появляются:

  • адрес и порт HTTP-сервера;
  • строка подключения к БД;
  • логирование и уровень логов;
  • таймауты;
  • внешние endpoints и токены;
  • режим окружения: dev, stage, prod.

Если читать это по месту, получается несколько проблем:

  1. Непонятно, какие переменные обязательны.
  2. Ошибки всплывают слишком поздно, иногда уже после старта сервиса.
  3. Значения по умолчанию начинают жить в разных местах.
  4. .env превращается в мешанину из актуального, устаревшего и случайного.

Поэтому конфигурацию лучше рассматривать как отдельную часть приложения, а не как набор случайных Getenv.

Базовая структура конфигурации

Для небольшого сервиса часто достаточно одной структуры:

type Config struct {
	AppEnv       string        // Среда запуска: dev, stage, prod
	HTTPPort     int           // Порт HTTP-сервера
	DatabaseURL  string        // Строка подключения к PostgreSQL
	LogLevel     string        // Уровень логирования
	ReadTimeout  time.Duration // Таймаут чтения запроса
	WriteTimeout time.Duration // Таймаут записи ответа
}

Важный принцип: конфиг должен описывать именно runtime-параметры приложения, а не всё подряд.
То, что относится к сборке образа, version metadata и CI, не нужно тащить в runtime-конфиг сервиса.

Если сервис растёт, поля лучше группировать по подсистемам, а не держать бесконечный плоский список:

type Config struct {
	AppEnv   string
	LogLevel string
	HTTP     HTTPConfig // Группа параметров HTTP-сервера
	DB       DBConfig   // Группа параметров базы данных
}

type HTTPConfig struct {
	Port         int
	ReadTimeout  time.Duration
	WriteTimeout time.Duration
}

type DBConfig struct {
	URL             string        // DSN подключения
	MaxOpenConns    int           // Максимум открытых соединений в пуле
	MaxIdleConns    int           // Максимум простаивающих соединений
	ConnMaxLifetime time.Duration // Время жизни соединения до пересоздания
}

Это не обязательный шаг для маленького сервиса, но он хорошо помогает, когда конфиг начинает расти вместе с количеством зависимостей.

Полноценный пример, который можно запустить

Go сам по себе не читает файл .env. Если вы хотите локально поднимать приложение через .env, нужен дополнительный пакет. Чаще всего для этого берут github.com/joho/godotenv.

Установка:

go get github.com/joho/godotenv

Ниже пример, который можно скопировать целиком: main.go локально загружает .env, а сам config.Load() остаётся чистым loader’ом уже существующего окружения. Такой разделённый подход лучше и для тестов, и для production.

package config

import (
	"fmt"
	"os"
	"strconv"
	"strings"
	"time"
)

type Config struct {
	AppEnv       string
	HTTPPort     int
	DatabaseURL  string
	LogLevel     string
	ReadTimeout  time.Duration
	WriteTimeout time.Duration
	AllowedHosts []string // Список разрешённых хостов через запятую
	AdminEmails  []string // Список email администраторов через запятую
}

func Load() (Config, error) {
	// Необязательные параметры — с дефолтами
	cfg := Config{
		AppEnv:   getEnv("APP_ENV", "dev"),
		LogLevel: getEnv("LOG_LEVEL", "info"),
	}

	// Duration парсится отдельно: невалидное значение — ошибка, а не молчаливый fallback
	readTimeout, err := getDuration("READ_TIMEOUT", 5*time.Second)
	if err != nil {
		return Config{}, fmt.Errorf("READ_TIMEOUT: %w", err)
	}
	cfg.ReadTimeout = readTimeout

	writeTimeout, err := getDuration("WRITE_TIMEOUT", 10*time.Second)
	if err != nil {
		return Config{}, fmt.Errorf("WRITE_TIMEOUT: %w", err)
	}
	cfg.WriteTimeout = writeTimeout

	// Обязательные параметры — без дефолтов, отсутствие = ошибка старта
	port, err := getRequiredInt("HTTP_PORT")
	if err != nil {
		return Config{}, fmt.Errorf("HTTP_PORT: %w", err)
	}
	cfg.HTTPPort = port

	dbURL, err := getRequired("DATABASE_URL")
	if err != nil {
		return Config{}, fmt.Errorf("DATABASE_URL: %w", err)
	}
	cfg.DatabaseURL = dbURL

	// Списки — необязательные, пустое значение = nil
	cfg.AllowedHosts = getList("ALLOWED_HOSTS")
	cfg.AdminEmails = getList("ADMIN_EMAILS")

	// Бизнес-валидация после загрузки всех значений
	if err := cfg.Validate(); err != nil {
		return Config{}, err
	}

	return cfg, nil
}

func (c Config) Validate() error {
	// Проверка через switch: пустой case = допустимое значение
	switch c.AppEnv {
	case "dev", "stage", "prod":
	default:
		return fmt.Errorf("APP_ENV must be one of: dev, stage, prod")
	}

	if c.HTTPPort < 1 || c.HTTPPort > 65535 {
		return fmt.Errorf("HTTP_PORT must be in range 1..65535")
	}

	switch c.LogLevel {
	case "debug", "info", "warn", "error":
	default:
		return fmt.Errorf("LOG_LEVEL must be one of: debug, info, warn, error")
	}

	if c.ReadTimeout <= 0 {
		return fmt.Errorf("READ_TIMEOUT must be positive")
	}

	if c.WriteTimeout <= 0 {
		return fmt.Errorf("WRITE_TIMEOUT must be positive")
	}

	return nil
}

// getEnv возвращает значение переменной или fallback, если она не задана
func getEnv(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

// getRequired возвращает ошибку, если переменная не задана
func getRequired(key string) (string, error) {
	v := os.Getenv(key)
	if v == "" {
		return "", fmt.Errorf("is required")
	}
	return v, nil
}

// getRequiredInt — обязательный параметр + парсинг в число
func getRequiredInt(key string) (int, error) {
	v, err := getRequired(key)
	if err != nil {
		return 0, err
	}
	n, err := strconv.Atoi(v)
	if err != nil {
		return 0, fmt.Errorf("must be an integer")
	}
	return n, nil
}

// getDuration — парсит duration (например "5s", "1m"), при ошибке не подставляет fallback
func getDuration(key string, fallback time.Duration) (time.Duration, error) {
	v := os.Getenv(key)
	if v == "" {
		return fallback, nil
	}
	d, err := time.ParseDuration(v)
	if err != nil {
		return 0, fmt.Errorf("must be a valid duration like 5s or 1m")
	}
	return d, nil
}

// getList — разбивает значение по запятой, убирая пробелы и пустые элементы
func getList(key string) []string {
	v := os.Getenv(key)
	if v == "" {
		return nil
	}

	parts := strings.Split(v, ",")
	result := make([]string, 0, len(parts))
	for _, part := range parts {
		part = strings.TrimSpace(part)
		if part != "" {
			result = append(result, part)
		}
	}

	return result
}

Минимальный main.go:

package main

import (
	"log/slog"
	"os"

	"github.com/joho/godotenv"

	"example/internal/config"
)

func main() {
	// Загружаем .env для локальной разработки; в production файла нет — ошибка игнорируется
	_ = godotenv.Load()

	// Загрузка и валидация конфига — при ошибке сервис не запускается
	cfg, err := config.Load()
	if err != nil {
		slog.Error("config error", "err", err)
		os.Exit(1)
	}

	slog.Info(
		"config loaded",
		"env", cfg.AppEnv,
		"port", cfg.HTTPPort,
		"allowed_hosts", cfg.AllowedHosts,
		"admin_emails", cfg.AdminEmails,
	)
}

.env.example для такого примера:

# Среда запуска: dev | stage | prod
APP_ENV=dev
HTTP_PORT=8081
DATABASE_URL=postgres://user:password@localhost:5432/app?sslmode=disable
LOG_LEVEL=info
# Формат duration: 5s, 1m, 500ms
READ_TIMEOUT=5s
WRITE_TIMEOUT=10s
# Списки через запятую, пробелы обрезаются
ALLOWED_HOSTS=localhost,127.0.0.1,api.local
ADMIN_EMAILS=admin@example.com,ops@example.com

Списки здесь читаются через запятую:

  • ALLOWED_HOSTS=localhost,127.0.0.1,api.local превратится в []string{"localhost", "127.0.0.1", "api.local"};
  • ADMIN_EMAILS=admin@example.com,ops@example.com превратится в []string{"admin@example.com", "ops@example.com"}.

Этого уже достаточно для allowlists, простых списков адресов, feature flags и получателей уведомлений.

Минимальный loader без магии

На старте вполне можно обойтись стандартной библиотекой:

package config

import (
	"fmt"
	"os"
	"strconv"
	"time"
)

type Config struct {
	AppEnv       string
	HTTPPort     int
	DatabaseURL  string
	LogLevel     string
	ReadTimeout  time.Duration
	WriteTimeout time.Duration
}

func Load() (Config, error) {
	// Параметры с дефолтами — безопасны для локального запуска
	cfg := Config{
		AppEnv:       getEnv("APP_ENV", "dev"),
		LogLevel:     getEnv("LOG_LEVEL", "info"),
	}

	// Таймауты: невалидный формат (не "5s", "1m" и т.д.) — ошибка, не fallback
	readTimeout, err := getDuration("READ_TIMEOUT", 5*time.Second)
	if err != nil {
		return Config{}, fmt.Errorf("READ_TIMEOUT: %w", err)
	}
	cfg.ReadTimeout = readTimeout

	writeTimeout, err := getDuration("WRITE_TIMEOUT", 10*time.Second)
	if err != nil {
		return Config{}, fmt.Errorf("WRITE_TIMEOUT: %w", err)
	}
	cfg.WriteTimeout = writeTimeout

	// Обязательные параметры: отсутствие = ошибка на старте
	port, err := getRequiredInt("HTTP_PORT")
	if err != nil {
		return Config{}, fmt.Errorf("HTTP_PORT: %w", err)
	}
	cfg.HTTPPort = port

	dbURL, err := getRequired("DATABASE_URL")
	if err != nil {
		return Config{}, fmt.Errorf("DATABASE_URL: %w", err)
	}
	cfg.DatabaseURL = dbURL

	if err := cfg.Validate(); err != nil {
		return Config{}, err
	}

	return cfg, nil
}

func (c Config) Validate() error {
	switch c.AppEnv {
	case "dev", "stage", "prod":
	default:
		return fmt.Errorf("APP_ENV must be one of: dev, stage, prod")
	}

	if c.HTTPPort < 1 || c.HTTPPort > 65535 {
		return fmt.Errorf("HTTP_PORT must be in range 1..65535")
	}

	switch c.LogLevel {
	case "debug", "info", "warn", "error":
	default:
		return fmt.Errorf("LOG_LEVEL must be one of: debug, info, warn, error")
	}

	if c.ReadTimeout <= 0 {
		return fmt.Errorf("READ_TIMEOUT must be positive")
	}

	if c.WriteTimeout <= 0 {
		return fmt.Errorf("WRITE_TIMEOUT must be positive")
	}

	return nil
}

func getEnv(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

func getRequired(key string) (string, error) {
	v := os.Getenv(key)
	if v == "" {
		return "", fmt.Errorf("is required")
	}
	return v, nil
}

func getRequiredInt(key string) (int, error) {
	v, err := getRequired(key)
	if err != nil {
		return 0, err
	}
	n, err := strconv.Atoi(v)
	if err != nil {
		return 0, fmt.Errorf("must be an integer")
	}
	return n, nil
}

func getDuration(key string, fallback time.Duration) (time.Duration, error) {
	v := os.Getenv(key)
	if v == "" {
		return fallback, nil
	}
	d, err := time.ParseDuration(v)
	if err != nil {
		return 0, fmt.Errorf("must be a valid duration like 5s or 1m")
	}
	return d, nil
}

Это не единственный вариант, но для небольшого и среднего сервиса он уже даёт главное:

  • все параметры собраны в одном месте;
  • обязательные значения читаются явно;
  • дефолты не размазаны по проекту;
  • невалидные duration и числа не маскируются молчаливым fallback;
  • приложение падает рано и предсказуемо.

Как падать быстро и понятно

Если конфигурация сломана, сервис лучше не запускать вообще.

Хороший старт приложения выглядит примерно так:

func main() {
	cfg, err := config.Load()
	if err != nil {
		slog.Error("config error", "err", err)
		os.Exit(1)
	}

	slog.Info("config loaded", "env", cfg.AppEnv, "port", cfg.HTTPPort)

	// дальше инициализация БД, HTTP-сервера и всего остального
}

Идея простая: конфигурация валидируется один раз, до инициализации зависимостей.
Не нужно тянуть невалидные значения дальше в код и надеяться, что ошибка “сама всплывёт”.

Что делать с дефолтами

У дефолтов есть одно полезное правило:

  • для локального удобства они допустимы;
  • для критичных параметров лучше требовать явное значение.

Хорошие кандидаты на дефолт:

  • APP_ENV=dev;
  • LOG_LEVEL=info;
  • таймауты;
  • локальный HTTP-порт.

Плохие кандидаты на дефолт:

  • DATABASE_URL;
  • токены;
  • внешние credentials;
  • адреса критичных внешних систем.

Если у секрета или инфраструктурного адреса “случайно” появляется дефолт, это почти всегда источник трудноуловимых проблем.

.env, .env.example и документация к запуску

Для локальной разработки .env — нормальный инструмент. Но реальные значения не должны жить в репозитории.

Важно не путать две вещи:

  • .env как удобный локальный файл со значениями;
  • отдельный пакет вроде godotenv, который эти значения реально загружает в процесс приложения.

Хорошая практика: godotenv использовать в main.go или в локальном bootstrap-слое, а config.Load() держать независимым от файловой системы. Тогда loader просто читает окружение, а источник этого окружения остаётся внешним и явным.

Обычно достаточно такой схемы:

  • .env — локальный файл разработчика, в .gitignore;
  • .env.example — шаблон без секретов, но со всеми ключами;
  • production-среда — реальные env vars из внешнего окружения.

Пример .env.example:

# Runtime
APP_ENV=dev
HTTP_PORT=8081
LOG_LEVEL=info
READ_TIMEOUT=5s
WRITE_TIMEOUT=10s
ALLOWED_HOSTS=localhost,127.0.0.1

# Infrastructure
DATABASE_URL=postgres://user:password@localhost:5432/app?sslmode=disable

.env.example полезен не только для запуска, но и как живая документация конфигурации сервиса.

Если переменных становится больше, лучше группировать их по смыслу и держать одинаковый порядок:

  • сначала общие runtime-параметры;
  • потом сеть и HTTP;
  • потом БД;
  • потом внешние интеграции;
  • потом observability и вспомогательные настройки.

Это мелочь, но такой порядок сильно упрощает сопровождение и снижает шанс, что .env.example превратится в случайный набор строк.

Когда стоит взять библиотеку

Если проект становится больше, можно использовать библиотеку вроде caarlos0/env, чтобы убрать часть шаблонного кода.

Например:

type Config struct {
	AppEnv       string        `env:"APP_ENV" envDefault:"dev"`          // Дефолт через тег
	HTTPPort     int           `env:"HTTP_PORT,required"`                // required — ошибка, если нет
	DatabaseURL  string        `env:"DATABASE_URL,required"`             // Секреты — всегда required
	LogLevel     string        `env:"LOG_LEVEL" envDefault:"info"`
	ReadTimeout  time.Duration `env:"READ_TIMEOUT" envDefault:"5s"`      // Парсинг duration автоматический
	WriteTimeout time.Duration `env:"WRITE_TIMEOUT" envDefault:"10s"`
	AllowedHosts []string      `env:"ALLOWED_HOSTS" envSeparator:","`    // Разделитель для списков
}

Это удобно, но не отменяет главного:

  • структура конфигурации должна быть одной;
  • обязательные и необязательные параметры должны быть видны сразу;
  • бизнес-валидация всё равно должна жить отдельно.

То есть библиотека убирает boilerplate, но не заменяет архитектуру конфигурации.

Ещё один практичный критерий: если у вас 5-10 переменных, стандартной библиотеки обычно достаточно.
Если параметров уже десятки, а типов и обязательных полей становится всё больше, библиотека начинает окупаться довольно быстро.

Что ещё умеет caarlos0/env

В статье выше показан только базовый сценарий, но у библиотеки есть ещё несколько полезных возможностей.

envPrefix для группировки параметров

Удобно, если конфиг разбит на вложенные структуры:

type Config struct {
	HTTP HTTPConfig `envPrefix:"HTTP_"` // Поля читают HTTP_PORT, HTTP_TIMEOUT и т.д.
	DB   DBConfig   `envPrefix:"DB_"`   // Поля читают DB_URL, DB_MAX_CONNS и т.д.
}

type HTTPConfig struct {
	Port int `env:"PORT" envDefault:"8081"` // Итоговая переменная: HTTP_PORT
}

type DBConfig struct {
	URL string `env:"URL,required"` // Итоговая переменная: DB_URL
}

Тогда переменные будут читаться как HTTP_PORT и DB_URL.

notEmpty для обязательных непустых строк

Если мало проверить, что переменная существует, и нужно запретить пустую строку:

type Config struct {
	APIKey string `env:"API_KEY,required,notEmpty"`
}

Это полезно для токенов, DSN и других строковых параметров, где пустое значение почти всегда ошибка.

file для секретов из файлов

Это особенно удобно в Docker и Kubernetes, когда секрет передаётся не напрямую через env, а через файл:

type Config struct {
	// Библиотека прочитает содержимое файла по пути из переменной
	DBPassword string `env:"DB_PASSWORD_FILE,file"`
}

Если DB_PASSWORD_FILE=/run/secrets/db_password, библиотека прочитает содержимое файла и положит его в поле.

Кастомный парсинг для своих типов

Если стандартных string, int, bool, time.Duration и []string уже не хватает, можно добавить свой парсер.

Например, для enum-значения:

type AppMode string

const (
	ModeReadOnly AppMode = "readonly"
	ModeRW       AppMode = "readwrite"
)

type Config struct {
	Mode AppMode `env:"APP_MODE" envDefault:"readonly"`
}

Для production-сценариев чаще всего хватает четырёх вещей:

  • envPrefix для вложенных структур;
  • required и notEmpty для обязательных строк;
  • file для секретов;
  • envSeparator или кастомного парсинга для списков и своих типов.

Конфиг тоже стоит тестировать

Поскольку загрузка конфигурации собрана в одном месте, её довольно легко проверить обычными unit-тестами.

Например:

func TestLoad(t *testing.T) {
	// t.Setenv — безопасно устанавливает env на время теста, восстанавливает после
	t.Setenv("APP_ENV", "dev")
	t.Setenv("HTTP_PORT", "8081")
	t.Setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/app?sslmode=disable")
	t.Setenv("LOG_LEVEL", "info")
	t.Setenv("READ_TIMEOUT", "5s")
	t.Setenv("WRITE_TIMEOUT", "10s")

	cfg, err := Load()
	if err != nil {
		t.Fatalf("Load() returned error: %v", err)
	}

	if cfg.HTTPPort != 8081 {
		t.Fatalf("expected port 8081, got %d", cfg.HTTPPort)
	}

	if cfg.DatabaseURL == "" {
		t.Fatal("expected DATABASE_URL to be set")
	}
}

Отдельно полезно проверять негативные сценарии: отсутствующий DATABASE_URL, невалидный HTTP_PORT или сломанный READ_TIMEOUT.
Такой тест быстро ловит регрессии, когда кто-то меняет loader, дефолты или правила валидации.

Связка с Docker Compose

В контейнерной среде конфигурация остаётся runtime-конфигом, а не частью образа.

Пример:

services:
  api:
    image: example/api:latest
    env_file:
      - .env              # Загрузка переменных из файла
    environment:
      APP_ENV: dev         # Явные значения переопределяют .env
      HTTP_PORT: 8081
    ports:
      - "8081:8081"

Здесь важно не путать две вещи:

  1. build-time параметры нужны для сборки образа;
  2. runtime env vars нужны самому приложению во время запуска.

DATABASE_URL, LOG_LEVEL и таймауты — это runtime.
Версия бинарника, commit sha или build tags — это уже не конфиг приложения, а метаданные сборки.

Типичные ошибки

  1. Читать os.Getenv прямо в разных пакетах вместо одного loader.
  2. Хранить реальные секреты в .env и коммитить их в репозиторий.
  3. Давать дефолты там, где параметр должен быть обязательным.
  4. Валидировать конфиг слишком поздно, после старта зависимостей.
  5. Смешивать build-time и runtime параметры.
  6. Не держать .env.example в актуальном состоянии.
  7. Молча подставлять fallback для невалидного значения вместо явной ошибки старта.

Небольшой checklist

  • есть одна структура Config;
  • загрузка происходит в одном месте;
  • обязательные параметры валидируются на старте;
  • у дефолтов есть осознанная причина;
  • .env не хранится в Git;
  • .env.example отражает реальный набор параметров;
  • Docker и Compose передают runtime-конфиг, а не подменяют архитектуру приложения.

Конфигурация через env хороша не тем, что она “простая сама по себе”, а тем, что её легко сделать предсказуемой. Когда конфиг собран в одном месте, валидируется на старте и одинаково работает локально и в контейнере, сервис ведёт себя намного спокойнее.

Документация и первоисточники

Если захочется сверить детали по библиотекам и стандартному поведению, полезно держать под рукой:

  • os package — стандартная библиотека Go для работы с окружением;
  • time.ParseDuration — официальный формат duration-строк;
  • joho/godotenv — загрузка .env в локальной разработке;
  • caarlos0/env — env-парсер для структур и типизированного конфига.

Статья при этом остаётся не справкой по библиотекам, а практическим baseline: как выстроить предсказуемую конфигурацию сервиса, а не как перечислить все опции пакетов.

Обсуждение в Telegram

Присоединиться →

Комментарии