Привет, я Николай Видов, тимлид команды чат-ботов в Т-Банке, восемь лет пишу на Python. 

Казалось бы, конфигурация — это просто .env и settings.py. Но стоит приложению вырасти — и начинается: секреты утекают в логи, port внезапно приходит строкой abc, настройки дублируются между окружениями. Я прошел путь от хардкода до типизированных схем и понял: конфигурация — это не мусорный код на скорую руку, а часть инфраструктуры, которую нужно проектировать так же тщательно, как бизнес-логику.

В статье — эволюция подходов к конфигурации в Python: откуда мы пришли, куда идем и какие грабли встречаем на пути. Разберу, как в Python-проектах обычно подходят к управлению конфигурацией. Сравнения конкретных библиотек не будет — сосредоточусь на самих подходах и границах их применимости.

Эволюция подходов

Эра хардкода в 2000-е. К счастью, в работе я ее уже не застал, но довелось поддерживать 17-летний проект на Python 2.7 и Django первой версии, где сохранились отголоски старой школы. Некоторые настройки шли через os.environ.get, а часть лежала прямо в settings.py, как обычные модульные константы.

DATABASE_HOST = "localhost"
DATABASE_PORT = 5432
DEBUG = True

Любое изменение значения приводило к полноценному релизу с новой версией кода. Самая болезненная конструкция, которую я там видел, — ветвления вида if env == "production":, меняющие хардкод по условию: что именно сейчас активно, выясняется только из чтения файла целиком. Когда таких ветвлений становится много, дебаг и поддержка превращаются в детектив с фонариком.

Дальше история разошлась на две ветки. Первая — оставить настройки в Python, но навести в них порядок: 

  • разнести settings.py на несколько файлов под разные окружения (base.py, prod.py, dev.py); 

  • использовать импорты и наследование. 

Получается конфиг с типами, автодополнением и возможностью вычислять значения на лету. Django-сообщество до сих пор живет так. Минус: конфиг остается исполняемым кодом, его неудобно редактировать неразработчику и нельзя подменить без пересборки или передеплоя.

Вторая ветка — вынести значения в декларативный файл отдельного формата, полностью отделив их от кода. С этого пути и начнем. 

Конфигурационные файлы: настройки живут отдельно от кода

Первая логичная мысль после хардкода — вынести значения в отдельный файл. В начале 2000-х таким файлом был INI, и Python даже включил ConfigParser в стандартную библиотеку:

[database]

host = localhost
port = 5432
import configparser


config = configparser.ConfigParser()
config.read("config.ini")

host = config["database"]["host"]
port = int(config["database"]["port"]) # это всегда строка

Конфиг отделен от кода — революция! Но эпоха быстро уперлась в потолок: все, что вытаскиваешь из INI, — строки. Число придется приводить руками, для bool есть отдельный getboolean(), а список значений вообще нужно изобретать поверх формата: запятая? точка с запятой? а если значение содержит разделитель?

Опечатался в имени секции — получишь KeyError где-нибудь в проде. Забыл обязательный параметр — узнаешь оттуда же. А любая попытка построить иерархию упирается в то, что INI знает только два уровня — секцию и ключ, — и каждый проект изобретает свою конвенцию вроде [database.primary].

XML был популярен в энтерпрайзе (особенно в Java-мире), но в Python не прижился: слишком многословный, плохо читается и тяжело редактируется руками.

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

YAML: конфиг ближе к человеку, чем к машине

В конце нулевых и начале десятых YAML вырвался вперед благодаря волне DevOps-инструментов — Ansible и Kubernetes сделали его стандартом для конфигов.

database:
  host: localhost
  port: 5432
  credentials:
    username: app_user

# Комментарии работают!
logging:
  level: INFO  # А это можно поменять на DEBUG

YAML дал то, чего не хватало JSON: комментарии, читаемость, нормальную иерархию. Взамен добавил собственный класс граблей. Один лишний пробел ломает файл целиком: чувствительность к отступам безжалостна. Магические преобразования регулярно стреляют в ногу: NO без кавычек становится False, 010 парсится как восьмеричное число (в YAML 1.1), а 1.0 иногда оказывается строкой, иногда — float. Отдельная категория проблем — YAML-бомбы: при парсинге недоверенных файлов можно положить процесс, если не использовать безопасный загрузчик.

Особая боль — зоопарк реализаций. Спецификация оставляет много свободы в пограничных случаях, и каждый парсер трактует их по-своему. Например, PyYAML по умолчанию парсит 2023-01-01 как datetime.date, а ruamel.yaml — как строку. Пустое значение key: где-то становится None, где-то — пустой строкой. Ведущие нули в числах (port: 010) одни парсеры читают как восьмеричное число, другие — как десятичное, третьи — как строку. Если конфиг читают сервисы на разных языках, расхождений становится еще больше. В JSON такое тоже встречается, но там спека жестче. В YAML это системная проблема.

Отдельный жанр того же периода — Python-модули как конфиг. Особенно популярен в Django и Flask.

class Config:
    DATABASE_HOST = "localhost"
    DATABASE_PORT = 5432
    DEBUG = False


class DevelopmentConfig(Config):
    DEBUG = True


class ProductionConfig(Config):
    DATABASE_HOST = os.environ.get("DATABASE_HOST", "prod-db.example.com")

Python-модули как конфиг подкупают многим. Это нативный Python: IDE подсказывает поля, mypy ловит опечатки до запуска, наследование классов само раскладывает окружения по полочкам. В значения можно подставлять что угодно — прочитать секрет из vault на старте, собрать DSN из кусочков, подменить хост по имени машины. По сути, это полноценный конфиг — просто написанный на исполняемом языке вместо декларативного.

«Исполняемый» — главный спорный момент. Импорт settings.py запускает произвольный Python со всеми вытекающими: побочными эффектами при загрузке, скрытой логикой в безобидном на вид файле. А чтобы ответить на вопрос «Откуда в проде взялось вот это значение?», иногда приходится поднять стек вызовов. Подменить настройку без пересборки сложнее, чем с декларативным форматом. YAML или TOML можно просто смонтировать в контейнер томом, с settings.py так уже не получится: он часть кода, а не данных (хотя пройтись по нему Ansible на хосте — вполне рабочий вариант). 

Hot-reload формально возможен через importlib.reload(), но на практике коварен: модули, которые уже импортировали значения по имени, продолжат держать старые ссылки и часть приложения окажется в неконсистентном состоянии. С перечитыванием YAML-файла такого не случится: там значения — это данные, а не имена в чужих неймспейсах.

Но у Python-конфига есть и обратная сторона — организационная. Хранить настройки в коде удобно, только пока конфиг — зона ответственности одних разработчиков: один язык, один репозиторий, одни правила игры. А как только конфиг начинают трогать DevOps, SRE или продакт, Python-файл превращается в источник постоянного трения: не все готовы править чужой код ради смены значения.

Подход живой и не маргинальный. На той же идее построена вся Pulumi — инфраструктура и конфиг как код на полноценном языке в противовес декларативному HCL у Terraform. Спор «исполняемый конфиг vs декларативный» идет уже лет пятнадцать, и победителя в нем, судя по всему, не будет.

Независимо от формата у всех файлов того поколения общие проблемы. Типизации нет: max_connections: "100" — это строка или число? А если опечатались "10O"? Никто не подскажет. Секреты в открытом виде попадают в git со всей историей коммитов. А для разных окружений приходится плодить config.dev.yaml, config.staging.yaml, config.production.yaml — без стандарта, как их сливать.

Переменные окружения: одна сборка на все окружения

В 2011 году основатель Heroku Адам Виггинс опубликовал The Twelve-Factor App — манифест для современных веб-приложений. Третий фактор гласил: Store config in the environment. Это был другой способ мышления.

import os


host = os.environ["DATABASE_HOST"]
port = int(os.environ["DATABASE_PORT"])
debug = os.environ.get("DEBUG", "false").lower() == "true"

С переменными окружения секреты перестали попадать в репозиторий, а переключение между окружениями свелось к смене переменных. Подход подхватили все облачные платформы: Heroku, AWS, GCP, Docker (docker run -e DATABASE_HOST=db), CI/CD-системы.

Но всё снова строки, без типизации. MAX_CONNECTIONS=100 — это '100', DEBUG=true'true', а не True. Типы приходится восстанавливать руками на каждом обращении. Иерархии нет — нужно изобретать конвенции вроде DATABASE_PRIMARY_HOST. Переменные окружения глобальны для процесса: один модуль пишет os.environ['TIMEOUT'] = '30', другой читает, и непонятно, откуда взялось значение, когда что-то идет не так.

Отдельная боль — сложные типы. Передать через переменную среды список URL можно несколькими способами, и все они кривые.

# JSON в строке (выглядит ужасно, но работает)
export ALLOWED_ORIGINS='["https://app.example.com","https://admin.example.com"]'

# Разделители (что, если в значении есть запятая?)
export ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com

# Отдельные переменные (взрыв количества)
export ALLOWED_ORIGIN_1=https://app.example.com
export ALLOWED_ORIGIN_2=https://admin.example.com

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

12-Factor сегодня: чего не хватает манифесту

К концу 2010-х стало понятно, что методология решила одну проблему и создала другую. Принцип отделения конфига от кода никто не оспаривает — он был и остается правильным. Но рекомендация «все через переменные среды» начала трещать по швам в нескольких сценариях разом.

Облака научились хранить секреты сами. AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, HashiCorp Vault дают управляемые хранилища с ротацией, аудитом и гранулярными правами доступа. На стороне приложения env по-прежнему проще: получил готовую строку и работаешь, а SDK — лишняя зависимость и обработка сетевых ошибок. Но, как только нужна ротация секрета без рестарта, чтение по требованию или раздельные права на разные секреты, env упирается в потолок: переменные читаются один раз при старте процесса и живут до его завершения. В таких сценариях обращение к хранилищу через SDK перестает быть лишним звеном и становится единственным рабочим вариантом.

Динамическая конфигурация перестала быть экзотикой. Фича-флаги, размер пула, лимит запросов хочется крутить без рестарта, и тут модель «переменная среды задается при старте процесса» физически не работает. Появились отдельные сервисы для фича-флагов (Flagr, LaunchDarkly, Unleash), которые приложение опрашивает в рантайме.

Контейнеры дали свой паттерн. Docker secrets — файлы, монтируемые в контейнер и доступные через /run/secrets/<name>. Kubernetes secrets гибче: их можно подключать и как файлы через volume, и как переменные среды. Файловый вариант обычно считают безопаснее: секрет не светится в env процесса и в дампах окружения, его проще ротировать без рестарта пода. Но в любом случае это уже не та модель «все через переменные среды», которую предлагал 12-Factor.

Есть еще одна тонкость. Даже там, где приложение честно работает в стиле 12-Factor и читает только os.environ, мердж нескольких источников никуда не исчезает — он просто переезжает из кода в инфраструктуру. Helm-чарт собирает значения из values.yaml и values.production.yaml. ConfigMap превращается в переменные среды пода, Secret — туда же. CI/CD-пайплайн дочитывает значения из Vault и передает их через --env. Перед стартом контейнера все это сливается в один плоский набор и попадает в os.environ. Приложение видит финальный результат, но не видит, какой слой что переопределил. Дебаг «откуда взялось значение» переехал в Helm, Terraform или секреты CI. Ответственность за корректность слияния размазана по нескольким командам. Слияние все равно есть — оно просто стало неявным и невидимым из кода.

Сообщество все это заметило. Критики справедливо отметили: 12-Factor отражал продуктовые особенности Heroku больше, чем универсальные инженерные принципы. Heroku продавал платформу, на которой переменные среды удобны, и манифест помогал это продавать. В ноябре 2024 методология стала open sourced для обновления сообществом — это признание того, что ее надо обновлять под современный ландшафт.

Часть команд при этом идет обратным путем: вытаскивает мердж из инфраструктуры обратно в приложение, чтобы слияние было видимым и проверяемым. Тогда приложение само читает несколько источников и сводит их в коде: дефолты — из кода, базовая структура — из YAML или TOML, переопределения — из переменных среды, секреты — из менеджера секретов. Управлять этим зоопарком вручную утомительно. 

os.environ.get(...), yaml.safe_load(...), отдельные SDK для каждого хранилища — у каждого источника свой API, своя типизация, своя обработка ошибок. Отсюда родился запрос на инструменты-агрегаторы, которые читают все это разом и складывают в одну типизированную структуру.

Структурированные форматы сегодня

TOML стал популярен после того, как Python принял его для pyproject.toml (встроенной поддержки с Python 3.11 через tomllib). Формат заслуженно потеснил YAML в роли конфига для Python-инструментов — за счет строгой типизации примитивов, читаемости и меньшего числа неоднозначностей при парсинге. JSON5 пошел другим путем, добавив к JSON комментарии и trailing commas — это оживило JSON для тех, кто привык к нему, но устал ловить ошибку парсинга на строке 47 из-за лишней запятой.

Параллельно подросло целое семейство форматов с программируемой конфигурацией, где сам файл почти язык, а не таблица «ключ — значение». Вот некоторые интересные.

HCL (HashiCorp Configuration Language) — стандарт для Terraform, Vault, Consul и Nomad, читается лучше JSON, поддерживает блоки и интерполяцию.

HOCON (Human-Optimized Config Object Notation) пришел из мира Scala и Akka — поддерживает композицию из нескольких файлов через include, переиспользование значений через подстановки (${...}) и ссылки на переменные среды прямо в файле. 

Jsonnet от Google — JSON с переменными, условиями и функциями. Широко используется в Grafana и Tanka. 

CUE и Dhall идут еще дальше: это полноценные типизированные языки с валидацией, в которых конфиг и его схема описаны рядом. KDL позиционирует себя как современную замену TOML с поддержкой документоподобных структур. Pkl от Apple (представлен в 2024) — типобезопасная конфигурация с шаблонизацией и валидацией прямо в синтаксисе. 

На другом полюсе живет NestedText — минималистичный формат «все строки» для тех, кто устал от магических преобразований YAML.

В Python-проектах большинство этих форматов остаются экзотикой: парсеров либо нет, либо они слабо поддерживаются. Стандарт такой же, как и десять лет назад: YAML и переменные среды для приложений, TOML для инструментов, JSON для API. Остальное появляется по необходимости — обычно, когда команда переезжает с инфраструктуры, написанной на HCL, и хочет читать те же файлы из приложения.

Но какой бы формат ни выбрали, он решает только проблему парсинга. Парсер дочитал файл — и на этом гарантии заканчиваются. Прописал port: -1 — тоже валидно, формат проверяет синтаксис, а не смысл. А если хочется перекрыть дефолты локальными настройками, добро пожаловать в ручной dict.update() с потерей вложенности.

Схема в коде: момент, когда форматов перестало хватать

К концу 2010-х стало ясно, что выбор формата — только половина задачи. Парсер вернул dict, а дальше начинается то, что формат не решает в принципе: проверить, что port — число от 1 до 65 535, обязательные поля на месте, а опечатка в ключе не уронит сервис на третий день после релиза. Эту проверку все равно пишут в коде — вопрос только, в каком виде.

Python 3.7 принес dataclasses, и схема конфига впервые получила внятное место — рядом с типами, а не в комментариях к словарю.

from dataclasses import dataclass


@dataclass
class DatabaseConfig:
    host: str
    port: int
    pool_size: int = 10

Сами по себе dataclasses конфигом не управляют — это просто способ описать форму данных. Распарсить файл, смаппить ключи на поля, привести типы, смерджить несколько источников — все это по-прежнему на разработчике. Но именно здесь происходит важный сдвиг: схема отделяется от загрузки. Раньше структура жила в голове автора и проверялась if "host" in config["database"]:, теперь — в виде типов, которые видит IDE, mypy и любая сторонняя библиотека.

Этот сдвиг и открыл дорогу инструментам следующего поколения: Pydantic, attrs + cattrs, dynaconf и другим. Они берут схему, описанную в коде, и достраивают вокруг нее все остальное: парсинг, валидацию, мердж источников. С этого момента мы перестаем обсуждать форматы и начинаем строить вокруг них инфраструктуру. 

Современные вызовы

Эволюция форматов и появление загрузчиков — только полдела. На нее накладываются изменения в самой среде, где приложение живет, и за последние десять лет здесь поменялось практически все. То, что раньше решалось одним config.ini, сегодня превратилось в полноценную инфраструктурную задачу — под давлением сразу нескольких факторов.

Микросервисная архитектура. Когда приложение — не монолит, а десятки сервисов, у каждого свои настройки, но есть и общие: адреса observability-стека (Sentry, Prometheus, Jaeger), уровни логирования, фиче-флаги, эндпоинты соседних сервисов. Как избежать дублирования? Как обновить настройку сразу для всех? Классический подход «файл в репозитории» здесь уже не работает.

Множество окружений. Современное приложение живет не в двух мирах (dev и prod), а в целой экосистеме — local, dev, testing/QA, staging, production, DR (disaster recovery), — а иногда еще и в превью-окружения для каждого пулл-реквеста. Для каждого нужны свои настройки с общей базой — отсюда потребность в наследовании конфигураций и в понимании, откуда конкретно взялось каждое значение.

Секреты. Времена, когда можно было хранить пароли в .env-файле, прошли (хотя многие до сих пор так делают). Масштаб проблемы впечатляет: по данным GitGuardian, за один 2025 год на публичном GitHub было обнаружено более 29 млн утекших секретов. В 2024 году исследователи JFrog нашли на Docker Hub токен с правами администратора к репозиториям самого Python и PyPI — потенциально катастрофическую утечку, которую устранили за 17 минут. Цена ошибки растет быстрее, чем зрелость практик.

В ответ появился отдельный класс инструментов: HashiCorp Vault, облачные менеджеры секретов (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault), SOPS для шифрования секретов в git, Sealed Secrets для Kubernetes. Секреты теперь живут не в .env, а в специализированном хранилище, и приложение должно уметь с ним работать.

Ротация секретов. Главный сдвиг последних лет: секреты должны меняться автоматически, без участия разработчика. Старая модель «сгенерировали пароль на пять лет вперед, положили в .env и забыли» перестала работать, когда появились требования по безопасности и атаки на цепочки поставок. Vault и AWS SM умеют в ротацию из коробки. Для базы данных это выглядит так: каждые N часов сервис создает новый пароль, обновляет его в БД, выдает приложениям. Приложение должно подхватывать новый пароль на лету — отсюда требование к конфигу: перечитывать секреты в рантайме, без перезапуска процесса.

При всем этом не должна ломаться локальная разработка. .env плох как место хранения боевых секретов, но как инструмент локалки вполне рабочий: один файл, все под рукой, не нужен ни Vault, ни сетевой доступ к менеджеру секретов. Продакшен при этом должен подтягивать те же значения из защищенного хранилища. Инструмент конфигурации должен уметь воспроизводить оба сценария, и переход между ними должен быть безболезненным.

Динамическая конфигурация. Классический подход: изменили конфиг — перезапустили приложение. Но в мире, где простои критичны, хочется крутить настройки на лету: уровень логирования для дебага, размер пула, веса A/B-эксперимента. От системы конфигурации требуется не так много — заметить изменение и отдать новое значение. 

Настоящая сложность начинается дальше, на стороне приложения. Перечитать .env или дернуть API хранилища — дело пяти строк, а вот аккуратно переоткрыть пул к БД, переподписаться на очередь или поменять уровень логгера без потери контекста — задача совсем другого порядка. Когда мы говорим о динамической конфигурации, мы говорим скорее про пять строк.

Все эти вызовы сместили порог: для одних команд конфиг по-прежнему «прочитать файл и распарсить», для других — уже полноценный инфраструктурный слой.

Если есть один сервис, два окружения и секреты в .env, python-dotenv закроет все с запасом и дальше можно не читать. Но, как только добавляются менеджер секретов, несколько окружений с общей базой настроек и желание понимать, откуда пришло конкретное значение, встроенных средств перестает хватать. Вот с этого момента и начинается разговор про инструменты.

Требования к современной конфигурации

Прежде чем выбирать инструмент, имеет смысл зафиксировать, что от него вообще ждет продакшен-приложение: веб-сервис, пайплайн обработки данных, микросервис. 

Получается список из семи критериев, и это та линейка, по которой можно мерить любое решение.

Много источников. Значения по умолчанию в YAML, переопределения в TOML, секреты в переменных окружения или Docker secrets, локальные настройки в .env — хочется, чтобы инструмент читал все это из коробки, а не требовал плагинов или кастомных адаптеров для каждого формата.

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

  • Взять значение из последнего источника: переопределения побеждают дефолты.

  • Взять из первого источника: базовые значения защищены от перезаписи.

  • Стратегия разрешения конфликтов говорит упасть с ошибкой — если конфликт означает, что кто-то ошибся в настройке. 

Все варианты — с возможностью задавать правило не глобально, а для отдельных полей.

Типобезопасность. Схема должна жить в коде, а не в голове разработчика. IDE подсказывает поля, mypy ловит опечатки, тип в сигнатуре документирует ожидания. Конфигурация — контракт, и проверять его лучше на старте процесса, до того, как поднимутся соединения и начнется выполнение бизнес-логики. Лучше упасть с понятной ошибкой за первую секунду, чем через час получить AttributeError в проде.

Валидация значений. Типы — необходимое, но не достаточное условие. Порт должен быть от 1 до 65535. URL должен начинаться с http. Таймаут не может быть отрицательным. Хочется, чтобы валидация была частью схемы, а не отдельной системой, которую можно забыть подключить к новому полю.

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

Секреты на уровне схемы. Пароли, токены, API-ключи помечать прямо в модели конфига отдельным типом (например, SecretStr). Это первый рубеж: при repr(), дампе конфига на старте или трейсе ошибки секрет не светится открытым текстом. Дальше — задача логгера и observability-стека. От системы конфигурации требуется только дать инструмент, чтобы пометить поле как чувствительное.

Отладка. Когда в продакшене не то значение, что ожидалось, нужно быстро понять, откуда оно пришло? Из какого файла? Из переменной окружения? Из значений по умолчанию? Без аудита источников это превращается в детектив: «Давайте я выведу print(config) и пересоберу образ».

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

Антипаттерны конфигурации

Семь критериев — идеал, к которому стремится инструмент. Но даже с хорошим инструментом в коде регулярно поселяются устойчивые антипаттерны — решения, которые на короткой дистанции выглядят рабочими, а на длинной превращаются в источник багов. Эти паттерны я регулярно встречаю в реальных проектах — даже там, где с инженерной культурой все в порядке. Сам инструмент конфигурации их не лечит, поэтому важно их понимать и сознательно не допускать.

Синглтон с мутабельным состоянием. Глобальная переменная config, инициализированная один раз при старте, к которой обращается любой код.

# config.py

config = load_config()
# где-то в обработчике

from config import config


if config.feature_flags.new_payment_flow:
    ...

Антипаттерн в том, что объект мутабельный. Кто-то посреди обработчика пишет config.feature_flags.new_payment_flow = True для отладки, забывает убрать, и в проде начинаются чудеса, которые невозможно воспроизвести на стейджинге. Тесты страдают первыми: один тест поменял значение, второй упал, и непонятно, кто виноват. Самая дешевая страховка — frozen=True на dataclass: попытка мутации даст FrozenInstanceError. Более фундаментальный путь — внедрение зависимостей: конфиг передается в конструктор сервиса, а не импортируется глобально.

Словарь как конфиг. config = yaml.safe_load(f), и дальше по всему коду разбросаны обращения вида config["database"]["primary"]["host"]. Словарь мутабелен, ключи строковые, опечатка обнаруживается только в рантайме, IDE и mypy бессильны. Это тот же хардкод, просто спрятанный за одним уровнем абстракции. Разработчики вынесли конфиг из кода в отдельный файл, но никаких гарантий это не дало.

Конфиг через монкипатчинг в тестах. Тесты подменяют значения конфига через mock.patch.

@mock.patch("myapp.config.DATABASE_TIMEOUT", 0.1)
def test_timeout():
    ...

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

Конфиг как JSON-строка в одной переменной среды. Прием встречается, когда команда уперлась в то, что переменные среды не дают иерархии, и решила: «Положим всю иерархию в одну переменную». Минусы очевидны: правка значения — редактирование длинной JSON-строки в терминале, опечатку не поймать до запуска.

APP_CONFIG='{"database":{"host":"localhost","port":5432},"redis":{"url":"redis://..."}}'

У паттерна есть законная ниша — конфигурация, которая по природе своей древовидная, а транспорт плоский. Например, настройки логирования, где иерархия логгеров (app.db.pool, app.api.handlers) с уровнями и хендлерами плохо раскладывается на десятки LOG_LEVEL_APP_DB_POOL=.... Получается хуже, чем одна JSON-строка с понятной структурой. Похожая история — Lambda с жестким лимитом на размер деплоймента, где нет возможности подмонтировать файл.

Правило простое: если объект конфигурации действительно иерархический и других транспортов нет, JSON-в-env допустим, но строго для этого объекта. Если же так пакуется весь конфиг приложения целиком, это сигнал, что пора монтировать файл.

Магические переменные среды, разбросанные по коду. Логика разъезжается. Один модуль сравнивает с "production", другой — с ("staging", "production"), третий проверяет на неравенство. Через полгода никто не помнит, какое поведение в каком окружении ожидается, и при попытке добавить новое окружение qa приложение ведет себя в нем по-разному в разных модулях.

# где-то
if os.environ.get("ENV") == "production":
    use_real_payment_gateway = True


# в другом месте, в другом модуле
if os.getenv("ENV") in ("staging", "production"):
    enable_sentry()


# в третьем
DEBUG = os.environ.get("ENV") != "production"

Подход к решению — централизация: одно место загружает конфиг и нормализует его в типизированные флаги (is_production: bool, enable_payments: bool), которые потом раздаются через DI. Если этой централизации нет, грабли множатся пропорционально числу модулей.

Заключение

В 2017 году я смотрел на тот самый проект Django 1.x с хардкодом в settings.py и думал, что главная проблема — это «вынесите все из кода». Сегодня я знаю, что у задачи много слоев: источники, мердж, валидация, секреты, ротация, отладка, тестирование. И понимаю, что простота, оставленная в Django-эпохе с одним settings.py, тоже была ценностью, которую, кажется, мы никогда не вернем.

Неизменным остался принцип отделения конфига от кода. Он был в Heroku-манифесте 2011 года и никуда не делся в 2026. Осталась идея, что конфигурация — это контракт между приложением и окружением, а контракт нужно проверять. И понимание, что цена ошибки в конфиге выше, чем в коде: код упадет на тесте, конфиг — в продакшене, в три часа ночи, под нагрузкой.

Меняются источники — все больше уходят в отдельные сервисы: Vault, AWS Secrets Manager, etcd. Меняется жизненный цикл конфига: статичный конфиг с перезапусками уступает динамическому с подхватом изменений на лету и автоматической ротацией. Слияние нескольких источников переехало из приложения в инфраструктуру, и оттуда хочется вернуть его обратно под контроль кода. 

Требования к UX ошибок стали жестче: стектрейс из недр парсера уже не считается приемлемым, нужна диагностика с координатами. Конфигурации теперь тоже требуется тестирование — конфиг становится таким же объектом контроля, как и бизнес-логика. Схему валидации покрывают юнит-тестами — что невалидный port падает на загрузке, а не в рантайме; проверяют, что секреты не утекают в дамп и в логи, прогоняют сборку конфига для каждого окружения в CI, чтобы staging не уехал в прод с чужими значениями.

Я не рекомендую выбирать инструмент по звездам на GitHub. Лучше взять семь критериев выше и определить, что в проекте критично, что приятно, а что лишнее. Микросервису в Kubernetes нужно одно, CLI-утилите — другое, ML-пайплайну — третье. Универсального ответа нет. В следующей статье разберем, как разные инструменты закрывают эти семь требований в 2026 году.

Полезные ссылки: