
Привет, Хабр! Меня зовут Юрий Соловьёв, я ведущий инженер в команде экосистемы Tarantool. С опытом я пришел к тому, что конфигурация должна иметь строгую спецификацию, так же как и HTTP API. В этой статье я предлагаю альтернативный подход на базе protobuf и постараюсь показать, что это не избыточная сложность, а необходимый уровень инженерной гигиены — особенно для систем, рассчитанных на долгую и стабильную жизнь. Это в какой-то мере технорассказ, которым я хочу поделиться — и именно в такой форме.
Герои вымышлены, все совпадения с реальными персонажами случайны. В повести используется собирательный образ инженера.
Пролог
Три часа ночи. Телефон тихо вибрирует на тумбочке. Инженер открывает глаза и несколько секунд смотрит в потолок, пытаясь соотнести тишину комнаты с глухим тревожным ощущением внутри: что-то снова пошло не так. За окном тянется серая, вязкая морось. Дождь не идет — он просто висит в воздухе, размазывая очертания домов и редкие огни. Утро еще не наступило, но ночь уже рассыпалась, потеряла форму. Понятно лишь одно: сна больше не будет.
Ноутбук оказывается рядом так, будто он там и лежал все это время. Сообщения от системы логирования сплошной стеной заполняют экран, однообразно помеченные как ошибки. Критическая часть системы легла — значит, проблема уже рядом и сама по себе не исчезнет. Строки логов сменяют друг друга, пока взгляд не цепляется за одну, которая собирает разрозненные симптомы в одну причину:
ERROR billing.TransactionProcessor: credit limit validation failed for account_id=74219: configured limit is too low
Открывается billing.yaml. credit_limit: 1000.
Тысяча. Цифра выглядит здравой, почти успокаивающей, пока в памяти не всплывает фраза из старого документа в Confluence, трехлетней давности: «лимит в центах». Тогда договорились, что рабочий порог — 10 000 центов, то есть 100 долларов. Кто-то записал в billing.yaml credit_limit: 1000, думая о «десяти долларах». Go-сервис читает это значение как количество центов и принимает 1000 как 10 долларов — лимит в десять раз ниже того, что считалось нормой.
Днем аналитики говорили о резком падении выручки, но это удобно уложили в слово «аномалия». Сейчас ясно, что аномалии нет. Есть система, которая несколько часов подряд исправно отбрасывает платежи, не вписывающиеся в чужую, когда-то принятую, но нигде толком не зафиксированную договоренность.
Человек, который проектировал эту логику, ушёл полгода назад. В общении он был такой же сухой, каким бывает рот в похмельное утро: каждое слово дается через усилие и не приносит облегчения. Остались короткие комментарии в коде и цепочка старых тикетов — и ни одного живого человека, у которого можно спросить, что именно он имел в виду. За окном все та же бесформенная морось. Кофе на столе остыл, будто стоял здесь всегда.
До утра четыре часа. Перед глазами не инцидент, а дыра в отчете, которую никто не станет замазывать: деньги ушли тихо Кофе все так же забивает пустотулучше любой еды, но не дает ни сытости, ни ощущения, что здесь вообще что-то зависит от него. Достаточно одного взгляда на цифры, чтобы понять: скоро придется экономить и самому инженеру.
Конфигурация — это контракт
Если бы конфигурацию собирали с тем же упрямством, с каким обустраивают HTTP API, все выглядело бы иначе. Для API уже давно норма — строгая спецификация и четкий контракт. Каждый эндпойнт типизирован, на границе все автоматически проверяется, из описания сразу же собираются клиенты и документация. Все, кто этим пользуется, — разработчик, техпис, тестировщик, оператор — смотрят в один и тот же артефакт.
С конфигурацией все наоборот. У большинства сервисов этот контракт вообще нигде формально не описан. Какое‑то текстовое описание живет в README — написанное руками и уже устаревшее. Клиенты (Helm‑чарты, ansible‑роли, terraform‑модули) тоже написаны руками. Валидация — это пара if’ов в main.go. Тестов на конфигурацию нет. Если где‑то ошибся в имени поля, сервис молча поднимается с дефолтом и делает вид, что так и задумано. И все это относится к самому критичному, самому изменяемому и при этом самому хрупкому интерфейсу системы.
Конфигурация и API решают одну задачу: фиксируют договоренность между двумя сторонами. Различается только транспорт: API ходит по сети, конфигурация лежит в файле рядом с сервисом. Для API все уже привыкли к спецификациям вроде OpenAPI, а для конфигурации до сих пор живут без формальной схемы. И это при том, что цена ошибки тут часто выше, а чинить все это все равно кому‑то в три часа ночи.
Дальше в повествовании я буду отталкиваться от простой вещи: у конфигурации должна быть строгая спецификация, так же как у HTTP API. Контракт между оператором и сервисом не должен держаться на неявных договоренностях. В статье я покажу другой подход на базе protobuf и попытаюсь объяснить, почему это надо воспринимать не как какую-то лишнюю сложность, а как нормальную инженерную гигиену для систем, которые рассчитывают прожить долго и без постоянных ночных побудок.
Конфиг — это не «один YAML», это десятки YAML’ов
И это еще цветочки. В реальном продукте у вас не один сервис, а десять, двадцать, пятьдесят микросервисов. И заметная часть конфигурации между ними повторяется.
Описание HTTP‑сервера: host, port, read/write/idle‑таймауты, размеры буферов, лимиты соединений. Один и тот же блок нужен в auth‑сервисе, payment‑сервисе, notification‑сервисе. У каждого — своя, чуть отличающаяся структура в коде.
Аудит: куда писать события, в каком формате, с какими полями, с каким уровнем детализации. Месяц согласовывали, о чем вообще логировать, договорились, а через полгода в одном сервисе поле actor_id, в другом user_id, в третьем who — потому что тащили структуру из соседнего репозитория и переименовали под местные привычки.
И вот здесь начинается основное мучение. Auth‑сервис на Go, payment — на Java, notification — на Python, аналитика — на TypeScript. У каждой команды свой config‑парсер, своя валидация, свои дефолты. Формально договариваясь об одном «общем» блоке логирования, по факту вы реализуете его четыре раза, на четырех языках, с четырьмя багами и четырьмя разными представлениями о том, как выглядит итоговый YAML.
Любое изменение общего формата превращается в четыре PR в четырех репозиториях, четыре релиза и четыре окна несовместимости. Через год внезапно выясняется, что один сервис давно живет по старой схеме и его конфиг не совпадает со стандартом, — и это всплывает, только когда уже случился инцидент.
Привычная картина, но нормальной ее не назвать.
Проблемы, возникающие в отсутствие спецификации
Когда у конфигурации нет четкой спецификации, она постепенно расползается в значениях, смыслах и клиентах. Сначала это выглядит как мелочь, потом становится привычным неудобством и только в момент инцидента превращается в проблему.
Начинается все с малого. В документации написано «timeout в секундах», в коде — просто time.Duration без уточнений. Через год оператор ставит read_timeout: 30000, думая про миллисекунды, и сервис спокойно уходит отдыхать на часы.
Потом расходятся значения по умолчанию. В коде отлито в бетоне 100 соединений, в Confluence — «50 по умолчанию, поднимать до 200», в Helm‑чарте параметр вообще не задан. Одна команда живет со стами, другая — с двумястами, и уже непонятно, что на самом деле считать нормой.
Иногда все упирается в одну букву: pol_size вместо pool_size. Парсер молча игнорирует лишнее поле, сервис стартует с дефолтом, и инженер месяцами недоумевает, почему пул не растет под нагрузкой, пока случайно не находит опечатку во время ревью.
Клиенты конфигурации — вообще отдельная история. Сервис на одном языке ждет одну структуру, вспомогательный инструмент на другом — конечно же, другую. Новое поле появляется в сервисе, до инструментов доезжает через релиз‑другой, и какое‑то время они живут в разных реальностях.
Все эти случаи возникают потому, что у конфигурации нет единого формального источника истины, а есть только набор частично пересекающихся описаний, которые со временем неизбежно расходятся.
И почти всегда в этот момент звучит вопрос: «А как же 12‑Factor App, разве он эту проблему не решает?».
Конфигурация через переменные окружения
Идея хранить конфигурацию в переменных окружения пришла из 12‑Factor App — манифеста эпохи Heroku, который во многих командах до сих пор воспринимается как готовый ответ. В 2011 году это действительно был шаг вперед: конфиги тогда лежали прямо в коде и регулярно утекали в git вместе с паролями. ENV закрыл эту проблему и заодно закрепил важную мысль о том, что конфиг отделяется от сборки. В этой части манифест до сих пор сохраняет актуальность.
Каноническая формулировка звучит так: «Двенадцатифакторное приложение хранит конфигурацию в переменных окружения. Их можно менять между деплоями без правок кода, а риск случайно утащить их в репозиторий минимален по сравнению с конфигурационными файлами».
Дальше проблему создает не сама идея, а то, как ее начали применять. Формулу «отделяйте конфиг от кода» упростили до «держите весь конфиг в ENV», и в реальных продуктах это быстро уперлось в массу неудобств, которых в 2011 году просто не было видно. И главная здесь даже не безопасность (хотя и она тоже), а то, что работать с ENV как с основным механизмом конфигурации физически тяжело.
ENV — это плоская строковая помойка
Конфигурация современного сервиса — это иерархия. Вложенные секции (server.tls.cert_path), списки (allowed_origins), типизированные значения (тайм-ауты с единицами измерения, перечисляемые типы, числовые границы). ENV не умеет ничего из этого. Все, что у вас есть, — плоское пространство строк.
Чтобы выразить иерархию, рождаются такие монстры:
APP_DATABASE_PRIMARY_REPLICAS_0_HOST=db1.local APP_DATABASE_PRIMARY_REPLICAS_0_PORT=5432 APP_DATABASE_PRIMARY_REPLICAS_1_HOST=db2.local APP_DATABASE_PRIMARY_REPLICAS_1_PORT=5432 APP_DATABASE_PRIMARY_REPLICAS_2_HOST=db3.local APP_DATABASE_PRIMARY_REPLICAS_2_PORT=5432 APP_LOGGING_REDACTION_RULES_0_FIELD=email APP_LOGGING_REDACTION_RULES_0_PATTERN=.*@.*
Это не выдуманный пример, это реальные конфиги в продуктах, которые «живут по 12-Factor». В одном из проектов мне доводилось видеть больше 200 переменных окружения на один сервис, причем префикс APP_DATABASE_PRIMARY_REPLICAS_ означал «массив», а никакой схемы не существовало — приходилось читать исходный код, чтобы понять, какие индексы и поля допустимы.
Cloud Posse, выпускающий reference architecture для k8s, в своем ADR прямо признает: переменные окружения сложно валидировать. Опечатался в названии, и для большинства приложений ты не получишь никакого предупреждения, особенно для опциональных параметров.
Опечатка не ловится никогда
Просто DATABSE_URL вместо DATABASE_URL — и приложение стартует, потому что переменную DATABSE_URL никто не читает, а DATABASE_URL пустая, и парсер радостно подставляет дефолт. В лучшем случае сервис упадет через минуту с ошибкой подключения к БД. В худшем — будет работать на тестовой базе, потому что дефолт — это localhost:5432, а оператор уверен, что выкатывает на прод.
В YAML‑файле такую ошибку поймал бы редактор с подключенной схемой. В ENV ее не ловит никто. Никаких структур, никакой валидации, никаких границ типов. Ведь все есть string.
ENV — плохое место для секретов
OWASP в своем гайде по управлению секретами прямо говорит, что плохо хранить секреты в переменных окружения. Переменные окружения часто доступны посторонним процессам и легко пролезают в логи или дампы системы, поэтому, если есть другие варианты, лучше опираться на них, а не на ENV.
CNCF придерживается похожей позиции: в его whitepaper рекомендуют передавать секреты в приложение во время выполнения и держать их во временных, более защищенных местах — например, в памяти (in‑memory volumes), а не в переменных окружения.
Две ключевые организации, которые задают тон в cloud‑native‑практиках, по сути, сходятся в одном: ENV — плохой выбор для секретов.
И это не абстрактная угроза. В 2024 году в Palo Alto Unit 42 описали показательный кейс: атакующие просканировали интернет на открытые .env‑файлы и нашли более 90 000 утекших переменных, из которых около 7 000 относились к облачным сервисам. Дальше была стандартная цепочка: скомпрометированные доступы, удаленное выполнение кода, вынос данных и шантаж владельцев инфраструктуры.
Что остается актуальным
Идея разделять конфиг и код остается правильной и не теряет актуальности. А вот подход хранения всего в ENV нуждается в пересмотре: структура и схема должны жить в формальной спецификации, значения — в типизированных файлах, секреты — в секрет-менеджере. ENV — это инструмент локальной настройки, а не место для хранения всей конфигурации сервиса.
И вот именно здесь нас встречает потребность в спецификации.
Что должна делать спецификация конфигурации
Описывать структуру: поля, секции, типы.
Описывать ограничения. Вместо «int» — «int от 1 до 65535». Вместо «string» — «hostname-формат».
Декларировать default values в самой спеке, а не в коде потребителя. Иначе разные потребители будут иметь разные дефолты.
Быть форматнонезависимой. Спецификация описывает структуру и правила, а файл с конфигурацией может быть в YAML, JSON или TOML — это вопрос вкуса и инструментов команды.
Быть кросс-язычной. Спецификация переживает технологические выборы команды: какой бы новый язык ни появился в стеке завтра, он должен подключиться к той же самой схеме.
Эволюционировать обратносовместимо. Добавление поля не должно ломать старые конфиги.
Совмещать в себе документацию. Спецификация описывает, как должна выглядеть конфигурация, и одновременно служит документацией для всех ее потребителей. У такой документации есть свойство, которого нет у README и Confluence: она не может разойтись с реальностью, потому что именно по ней проверяется код.
Protobuf как инструмент описания спецификации
Среди существующих инструментов для описания формальных спецификаций особенно интересен protobuf. Он закрывает все требования из предыдущего раздела и дает ряд дополнительных возможностей:
Сначала контракт, потом код. Спецификация описывает, как должна выглядеть конфигурация. Реализации на Go, Java, Python, TypeScript, Rust и других языках рождаются из нее автоматически через кодогенерацию: каждый сервис получает идиоматичный код своего стека, а контракт остается единым.
Форматная гибкость. Одна и та же спецификация в .proto позволяет читать данные из разных форматов: YAML, JSON, TOML. Команда выбирает удобный формат хранения, а схема и логика валидации остаются одни и те же.
Custom options. Встроенный механизм расширения схемы: к полям и сообщениям можно прикреплять собственные метаданные — значения по умолчанию, ENV‑маппинг, описания для документации. Подробнее — в следующем разделе.
Protovalidate. Современная библиотека валидации на базе CEL‑выражений: cross‑field‑правила вроде «если TLS включен, обязателен сертификат» описываются прямо в схеме. Protovalidate поддерживается в Go, Java, Python, C++ и других языках, так что одно и то же правило валидации работает одинаково на любом стеке.
Подтвержденный индустриальный опыт. На protobuf построены конфигурации Envoy, Istio, gRPC xDS, OpenTelemetry Collector и многих других серьезных систем. Это не экспериментальный подход, а практика, проверенная масштабом и временем.
Protobuf custom options
Custom options — это основной механизм, который превращает .proto‑файл из простого описания структуры в полноценный носитель спецификации.
Фактически это способ прикрепить свои метаданные к элементам схемы — к полю, сообщению, enum‑значению. Эти метаданные попадают внутрь самой схемы и доступны любому инструменту, который с ней работает: кодогенератору, валидатору, генератору документации.
Технически это делается через расширение стандартных опций protobuf. Простой пример:
import "google/protobuf/descriptor.proto"; extend google.protobuf.FieldOptions { string default_value = 50001; } message ServerConfig { uint32 port = 1 [(default_value) = "8080"]; }
Здесь объявлена своя опция default_value, и она навешена на поле port. Сама схема не меняется: для protobuf это все тот же uint32. Но рядом с полем появляется дополнительная информация, которую могут читать и интерпретировать инструменты.
На custom options опираются целые экосистемы:
protovalidate использует их для описания правил валидации;
gRPC‑Gateway — для маппинга методов в HTTP‑маршруты;
генераторы документации — для вытаскивания описаний и примеров.
Ключевое свойство custom options в том, что они путешествуют вместе со схемой. Если завтра к проекту подключится Python‑инструмент, он увидит ту же опцию default_value на том же поле. Метаданные не теряются и не дублируются, поскольку они живут внутри единого контракта.
Дальше мы будем опираться именно на этот механизм, чтобы навешивать на поля конфигурации значения по умолчанию.
Минимальная спецификация на protobuf
Самый простой способ показать, как работает подход, — это посмотреть на готовый пример. Вот как может выглядеть спецификация секции HTTP‑сервера и подключения к базе данных, описанная в .proto‑файле:
syntax = "proto3"; package myapp.config.v1; import "google/protobuf/descriptor.proto"; import "google/protobuf/duration.proto"; import "buf/validate/validate.proto"; extend google.protobuf.FieldOptions { string default_value = 50001; } message ServerConfig { string host = 1 [(default_value) = "0.0.0.0"]; uint32 port = 2 [ (default_value) = "8080", (buf.validate.field).uint32 = {gte: 1, lte: 65535} ]; google.protobuf.Duration read_timeout = 3 [ (default_value) = "15s", (buf.validate.field).duration = {gte: {seconds: 1}, lte: {seconds: 300}} ]; google.protobuf.Duration write_timeout = 4 [ (default_value) = "15s", (buf.validate.field).duration = {gte: {seconds: 1}, lte: {seconds: 300}} ]; uint32 max_header_bytes = 5 [ (default_value) = "1048576", (buf.validate.field).uint32 = {gte: 1024, lte: 16777216} ]; bool tls_enabled = 6 [(default_value) = "false"]; string dsn = 7 [ (buf.validate.field).string.min_len = 1, (buf.validate.field).cel = { expression: "this.startsWith('postgres://')", message: "dsn must be a postgres URL" } ]; }
Здесь:
Структура — одно message с семью полями.
Типы — string, uint32, Duration, bool. То есть не просто строка или число, а вполне конкретные типы.
Дефолты — у каждого поля есть разумное значение по умолчанию: bind на все интерфейсы, порт 8080, тайм-ауты по 15 секунд, лимит заголовков 1 MiB, TLS выключен.
Границы — порт от 1 до 65535, тайм-ауты от 1 секунды до 5 минут, размер заголовков от 1 KiB до 16 MiB.
Форматы строк — для DSN мало проверки на непустоту: CEL‑выражение требует, чтобы значение начиналось с
postgres://, и невалидный URL дальше старта сервиса не пройдет.
Это полная спецификация секции HTTP‑сервера и подключения к БД. Здесь нет рукописных комментариев в YAML, нет отдельных описаний в README и нет констант, спрятанных в коде, потому что все собрано в одном месте и одинаково доступно всем потребителям.
Оператор пишет в config.yaml:
server: host: api.example.com read_timeout: 30s dsn: postgres://localhost/myapp
Остальные поля не указаны — подставляются дефолты из спецификации. Если оператор задаст port: 70000 или read_timeout: 600s, валидатор вернет понятные сообщения:
server.port: value must be <= 65535 server.read_timeout: value must be <= 300s
А если в DSN окажется что‑то вроде mysql://... или просто localhost, ошибка тоже будет сформулирована внятно:
server.dsn: dsn must be a postgres URL
И все это есть сразу на этапе запуска сервиса, со ссылкой на конкретное поле и конкретное правило, а не прячется где‑то в недрах Go‑кода в виде panic.
Загрузка конфигурации
Может показаться, что подход со спецификацией, дефолтами и валидацией требует серьезной обвязки в коде. Но на самом деле на практике полный код загрузки конфигурации выглядит так:
func Load(path string) (*configv1.AppConfig, error) { raw, _ := os.ReadFile(path) json, _ := yaml.YAMLToJSON(raw) cfg := &configv1.AppConfig{} protojson.Unmarshal(json, cfg) // парсим YAML→JSON→proto protodefault.Apply(cfg) // применяем default_value protovalidate.Validate(cfg) // валидируем по спеке return cfg, nil }
Обработка ошибок здесь опущена для наглядности, но на самом деле весь код загрузки и есть эти пять смысловых строк. И не нужны никакие viper, десятки транзитивных зависимостей, сотни строк шаблонного кода.
Про protodefault
Protodefault — небольшая самописная библиотека, которая лежит у меня в открытом доступе: github.com/yurasolovjov/protodefault. Ее задача — прочитать custom option default_value из .proto‑файла и проставить эти значения тем полям, которые пользователь не задал. Внутри кроется обход дескрипторов через protoreflect, парсер скаляров и поддержка google.protobuf.Duration. На этом все.
Главное в этой части стека то, что такую библиотеку реально написать за один вечер. Полторы сотни строк Go, тесты на типичные сценарии, короткий README. Если при чтении возникает мысль «опять зависимость от какого‑то непонятного автора» — это нормальная реакция. И вывод простой: не подключайте protodefault, а напишите свою версию.
Любая внешняя зависимость — потенциальный технический долг. Сегодня она удобна, завтра автор бросает проект. Сегодня все работает, завтра прилетает CVE. Сегодня минорное обновление проходит гладко, завтра ломает API. Каждая такая зависимость — еще один источник риска, за которым нужно следить.
Поэтому лучше по умолчанию не тащить зависимости, если задача укладывается в обозримый объем собственного кода. Своя реализация на сто пятьдесят строк, которую команда читала и понимает, — это актив: ее можно дополнять, переписывать или выкинуть. А внешняя библиотека, делающая что‑то нетривиальное через рефлексию, — это обязательство, от которого уже не так просто избавиться.
С protovalidate история другая: это полноценная библиотека от Buf со встроенным CEL‑движком, и переписать ее с нуля — задача на несколько недель. Здесь стоимость собственной разработки явно выше стоимости зависимости. А вот defaults — маленький, простой, прозрачный кусок логики. Берите идею, а не код.
Общий контракт для всех сервисов
Здесь появляется важное свойство, которое меняет жизнь командам с несколькими сервисами. Описание конфигурации в .proto‑файлах не привязано к конкретному сервису — это отдельная схема, которая живет в общем репозитории (config‑schemas, proto‑contracts — как угодно) и подключается к любому сервису как обычная зависимость.
Конфигурация каждого сервиса начинает собираться как из конструктора: есть набор стандартных деталей (ServerConfig, LoggingConfig, DatabaseConfig, AuditConfig), и каждый сервис берет только то, что ему нужно. Сами детали от этого не меняются — они остаются одними и теми же для всех.
Вернемся к примеру с auth, payment, notification и аналитикой. У каждого своя конфигурация, но собрана она из одних и тех же блоков:
// auth-сервис: HTTP, БД, логи, аудит message AuthConfig { ServerConfig server = 1; // общий блок DatabaseConfig database = 2; // общий блок LoggingConfig logging = 3; // общий блок AuditConfig audit = 4; // общий блок AuthSpecific auth = 5; // только для этого сервиса } // notification-сервис: HTTP, очередь, логи. Без БД и аудита. message NotificationConfig { ServerConfig server = 1; // тот же ServerConfig QueueConfig queue = 2; // общий блок LoggingConfig logging = 3; // тот же LoggingConfig }
ServerConfig — одна и та же деталь конструктора, описанная в репозитории схем один раз. Auth‑сервис подключает ее и получает все поля, дефолты и валидацию. Notification‑сервис подключает ее — и получает то же самое, без дублирования и шансов на расхождения. Если завтра в ServerConfig появляется idle_timeout, новое поле автоматически возникает во всех сервисах после регенерации, и поведение остается согласованным.
Такая простая модель закрывает целый класс проблем: общие блоки больше не разъезжаются между сервисами, потому что у них больше нет независимых копий — есть один источник, к которому все обращаются.
А еще все это, конечно же, работает независимо от языка. Go, Java, Python — все смотрят в один и тот же .proto‑файл, а кодогенерация дает каждому идиоматичный код под его стек. ServerConfig в Go‑сервисе и ServerConfig в Java‑сервисе — не два разных описания, а одно описание с двумя проекциями.
Примерно так уже много лет устроена работа с HTTP API: один OpenAPI‑контракт обслуживает несколько языков и команд. Спецификация конфигурации дает тот же эффект, только для другой стороны интерфейса системы.
Вместо заключения
Идея описывать конфигурацию через protobuf не нова, на ней построены конфигурации Envoy, Istio, gRPC xDS, OpenTelemetry Collector и многих других инфраструктурных систем. Подход проверен годами и масштабом, но в обычной продуктовой разработке он так и не стал массовым. А ведь по факту он закрывает сразу несколько бытовых задач, которые иначе приходится решать по отдельности: единый формальный контракт, типобезопасное описание, валидацию на границе, поддержку разных форматов и переиспользование общих блоков между сервисами.
Я не считаю этот подход панацеей и не думаю, что он подходит каждому. Это один из способов работы с конфигурацией — со своими сильными сторонами и ограничениями. Главное, что хочется оставить после статьи: не выключайте критическое мышление и не верьте на слово в чужие best practices. Многие из них действительно были лучшими — десять лет назад, в другом контексте и для других задач. Тащить старые рекомендации в современный сервис, не задаваясь вопросом, какую проблему они вообще решали, — это тоже карго‑культ, только в другую сторону. То же самое относится и к этому тексту: я описал подход, который мне кажется разумным, но он не универсален.
Поэтому важна честная граница применимости. Я бы не стал заводить proto‑спецификацию для маленькой CLI‑утилиты или прототипа на месяц, ведь граница «маленький сервис» проходит не по строкам кода, а по горизонту жизни. Если сервис проживет год и у него будет несколько потребителей конфига, proto‑спецификация себя оправдает. Если задача — за неделю проверить гипотезу, берите viper и не мучайтесь.
Отдельная частая ловушка — попытка побороть дублирование конфигурации через общую библиотеку. Когда у вас десяток сервисов на одном языке, очень хочется вынести общую логику работы с конфигом в shared‑библиотеку и подключить ее везде. Это тупиковый путь. В какой‑то момент появится сервис на другом языке или существующий перепишут на другой стек — и shared‑библиотека превратится в якорь, который мешает миграции. Спецификация в .proto решает это по построению: она не привязана к языку, а кодогенерация дает каждому сервису свое представление — Go‑структуры для Go, классы для Java, типы для TypeScript. Общим остается контракт, а не код.
И напоследок — про то, что уже начинает влиять на архитектурные решения. LLM‑модели стали частью повседневной разработки, нравится нам это или нет. У любой команды появился еще один «читатель» конфигурации: модель, которая просматривает код, генерирует Helm‑чарты, помогает писать миграции и отвечает на вопросы о поведении системы. Этому участнику мало человеческих комментариев в YAML — ему нужна формальная, машиночитаемая схема с явными типами, границами и значениями по умолчанию. Здесь .proto‑файл выглядит естественным выбором: он задает структуру, описывает ограничения и становится единым источником правды для всех потребителей — от сервисов до туллинга и ИИ. Модель может опираться на эту схему и собирать корректные конфиги под конкретную задачу, а не угадывать смысл по разрозненным YAML’ам. Если бессхемный YAML уже сейчас раздражает инженеров, для LLM‑агентов это раздражение просто усиливается — и по мере роста доли автоматизации это будет чувствоваться все сильнее.
Эпилог
Утро. Телефон негромко вибрирует на столе — на этот раз это будильник. Инженер открывает глаза. В комнате обычная утренняя тишина, в которой ничто его не торопит и ни о чем не предупреждает. Сквозь шторы просачивается свет, за окном ясное небо, и солнечный луч, нашедший щель в занавеске, падает прямо на клавиатуру ноутбука. Ночь отступила, и утро пришло ровно так, как и должно приходить: спокойно и уверенно.
Ноутбук раскрывается без спешки. Сообщения от системы логирования бегут ровным потоком: предупреждения, информационные записи, ни одной красной строки. Критическая часть системы работает, и сегодня этого достаточно. Строки логов сменяют друг друга, но взгляд больше судорожно не ищет в них виновника — просто отмечает, что все идет так, как было задумано.
На столе — кружка горячего кофе, от нее поднимается пар, и рука тянется к ней неторопливо, просто как к привычному утреннему действию. Рядом — свежий хлеб. Часть нормального утра.