Когда говорят про «configuration management», многие думают об Ansible, Puppet и Kubernetes. Я же хочу поговорить про управление конфигурацией самого приложения: как вы задаёте таймауты, пароли, лимиты, URL-ы внешних сервисов, feature-флаги, уровни логирования. Всё то, что отличает ваш код на деве от кода на проде. Предыдущие статьи из серии “10 ошибок” про рефакторинг и про api понравились читателям, поэтому продолжаем.

Конфигурация - это не код. Код проходит code review, линтеры, тесты, CI. Конфигурация - чаще всего нет. При этом неправильная конфигурация может уронить прод быстрее, чем любой баг в коде. Не тот URL базы - и сервис пишет в прод вместо стейджинга. Не тот таймаут - и сервис под нагрузкой сжирает весь пул коннектов. Не тот секрет - и данные утекают.
Эти 10 ошибок собраны из нескольких реальных проектов. Большинство из них не выглядели как проблема - до первого инцидента.
Что вы узнаете:
Почему 30 хардкоженных таймаутов в коде страшнее, чем кажется
Как отсутствие валидации позволяет стереть продовую БД одной env-переменной
Как дефолтные значения в конфиге месяцами маскируют неправильную конфигурацию
Чем опасна стократная разница в лимитах между тестом и продом
Как зоопарк именования в конфиге приводит к тихим ошибкам
Почему feature-флаги превращаются в бессмертных зомби
Как неправильный порядок override делает env-переменные бесполезными
Почему 100+ параметров без описания превращают конфиг в минное поле для новичков
Как захардкоженные URL внешних API заставляют пересобирать сервис ради смены эндпоинта
Почему ради одного log-level приходится пересобирать Docker-образ или прокидывать volume mount
1. Хардкод таймаутов в исходниках
Я как-то решил посчитать, сколько таймаутов в проекте задано не в конфиге, а прямо в коде. Грепнул по seconds, minutes, Timeout в .java файлах. Тридцать с лишним мест.

Примеры захардкоженных таймаутов из разных классов
private static final Duration TIMEOUT = Duration.ofSeconds(10); // в NotificationService private static final Duration TIMEOUT = Duration.ofSeconds(30); // в SearchService private static final Duration TIMEOUT = Duration.ofSeconds(10); // в AuthFilter private static final Duration TIMEOUT = Duration.ofSeconds(15); // в ApiRoutes
Десять секунд. Тридцать секунд. Пятнадцать. Десять. Десять. Десять. Кто-то когда-то написал Duration.ofSeconds(10), и это стало шаблоном. Copy-paste из класса в класс.
Проблема не в том, что 10 секунд - неправильное значение.
Проблема в том, что чтобы это значение изменить, нужно:
Найти в коде (а их тридцать мест)
Поменять
Скомпилировать
Пройти CI
Задеплоить
Вместо того чтобы поправить строчку в конфиге и перезапустить сервис.
А под нагрузкой таймауты - первое, что нужно крутить. Когда база тормозит, хочется увеличить таймаут с 10 до 30 секунд. Но нельзя. Хардкод.
2. Нет валидации конфига при старте
Главный Config-объект проекта — 695 строк. Сотня параметров: таймауты, лимиты, URL-ы, пулы, секреты. Знаете, сколько из них проверяются на валидность при старте?
Один.
Единственная валидация на 695 строк конфига
String authType = config.getString("authType"); if (!Set.of("kerberos", "jwt").contains(authType)) { throw new IllegalArgumentException( "Invalid auth type, possible values are kerberos, jwt" ); }
Один require на 695 строк. Всё остальное принимается как есть. Пул соединений = 0? Ок. Таймаут = -1 секунда? Ок. URL базы данных - пустая строка? Ок, узнаешь на первом запросе.
А вот это особенно порадовало:
http.server.parsing.max-content-length = 10g
Десять гигабайт. Дефолтный лимит на размер HTTP-запроса. Один запрос - и сервис может сожрать 10 GB памяти. OOM. А валидации нет: если кто-то случайно уберёт env-override - сервис стартует с 10 GB лимитом и будет ждать, когда кто-нибудь это обнаружит.
Отдельная песня - флаг `dropAll`:
dropAll = false dropAll = ${?DROP_ALL}
Это флаг для Liquibase - уничтожить всю схему БД и накатить с нуля. В application.conf. С env-override. Случайный DROP_ALL=true в environment - и продовая база стёрта. Без предупреждения, без подтверждения, без единого лога «вы уверены?».
Fail-fast - один из самых дешёвых способов избежать дорогих инцидентов. Проверил все параметры при старте, упал с понятным сообщением - и никто не тратит три часа на debug того, что можно было поймать за три секунды.
3. Default-значения прячут проблемы
Удобная штука - значения по умолчанию. Если параметр не указан - возьмём разумный дефолт. Но иногда «разумный дефолт» становится ловушкой, которая маскирует отсутствующую конфигурацию.
Пример. В проекте есть модуль SSO-аутентификации через внешний провайдер. По умолчанию он выключен - все логинятся через обычную форму. Конфигурация написана так:
Дефолты, которые стреляют при включении модуля
Duration maxResponseDelay = ssoEnabled ? config.getDuration("sso.maxResponseDelay") : Duration.ofMinutes(10); // хардкод long responseCacheSize = ssoEnabled ? config.getLong("sso.responseCacheSize") : 10_000; // хардкод
Пока SSO выключен - всё работает. Конфига нет, но дефолты подхвачены. Включаешь SSO - и бабах. Ключей в конфиге нет, потому что никто их не добавлял (зачем, если было выключено?). Приложение падает в runtime, а не при старте. И конечно, это происходит на проде. Потому что на деве SSO никто не включает.
Ещё один тихий убийца:
private val minimumTokenTtl = Duration.ofMinutes(3) val tokenTtl: Duration = { val ttlFromConfig = config.getDuration("tokenTtl") if (ttlFromConfig.toMillis > minimumTokenTtl.toMillis) ttlFromConfig else minimumTokenTtl }
Кто-то осознанно ставит tokenTtl = 1 minute - может быть, для короткоживущих токенов. Система молча подменяет на 3 минуты. Ни лога, ни warning’а. Токены живут в три раза дольше, чем настроено. Если это security-чувствительный параметр - это дыра, которую никто не заметит.
4. Дрейф конфига тестов от прода

Когда тестовый конфиг живёт отдельной жизнью от продового, рано или поздно они разъедутся. Вопрос только «когда» и «насколько».
У нас разъехались на два порядка:
# prod api.responseLimit = 10 # 10 MB # test api.responseLimit = 1024 # 1 GB
Стократная разница. Обработчик генерирует ответ в 50 MB — на тесте проходит, на проде падает. Тест ничего не ловит, потому что его лимит в 100 раз мягче.
В другую сторону тоже бывает:
# prod api.contextLimit = 10 # 10 MB # test api.contextLimit = 1 # 1 MB
Тесты строже прода. На проде пользователь может положить 5 MB контекста - и это работает. Но если написать тест на этот сценарий - он упадёт. Не потому что код сломан, а потому что тестовый конфиг врёт.
5. Зоопарк именования конфигов
Открываю один `application.conf`. Вижу:
fileStoragePath = "/opt/storage" # camelCase retention-days = 7 # kebab-case createdump-interval-seconds = 240 # kebab-case, но без дефиса в "createdump" usage-threshold = 80 # kebab-case compressHeapDump = true # camelCase
Пять ключей - два стиля именования и один гибрид. В одной секции.
Boolean-флаги:
sslValidationEnabled = true # *Enabled smtpSslEnable = false # *Enable (без d!) saml.enabled = true # .enabled isUseSecureCookieJWT = false # is* (венгерская нотация) loadBarrierEnabled = true # внутри секции loadBarrier - т.е. полный путь loadBarrier.loadBarrierEnabled
Четыре конвенции. В одном модуле. В одном конфиге.
Env-переменные тоже не совпадают:
# HOCON ключ → env-переменная smtpSslEnable → SMTP_SSL_ENABLED # Enable vs Enabled isUseSecureCookieJWT → IS_USE_SECURE_COOKIE_JWT # why api-maxRetries → API_MAX_RETRIES # kebab → snake
Разработчик пишет SMTP_SSL_ENABLE в env (по аналогии с HOCON-ключом) — переменная не подхватывается, потому что ожидается SMTP_SSL_ENABLED. Тишина. Используется дефолт. Вы узнаете об ошибке, когда письма перестанут уходить через TLS.
6. Флаги без жизненного цикла

В конфигурационном классе — 16 boolean-флагов. Некоторые из них уже не нужны. Но они не знают об этом.
@Value("${features.ws-cache-enabled}") private boolean wsCacheEnabled;
Значение false в prod-конфиге. false в dev-конфиге. false в тест-конфиге. Одно использование в коде. Когда эту фичу выключили? Зачем она была? Можно ли удалить флаг и код за ним? Никто не помнит. Флаг живёт.
@Value("${features.history-import-enabled}") private boolean historyImportEnabled;
true везде. Всегда true. Если он всегда включён - зачем флаг? Но удалить страшно: «а вдруг кто-то на каком-то стенде выключает?».
Feature-флаги без lifecycle - это зомби-код. Они не гниют, не ломаются, не мешают. Просто тихо добавляют когнитивную нагрузку каждому, кто читает конфиг. «Что это? Нужно ли это? Можно ли трогать?» Через два года таких вопросов - конфиг превращается в минное поле.
Нужно: дата добавления, автор, причина, срок удаления. Или хотя бы комментарий «Добавлено в 5.2 для клиента X, удалить после миграции». Это стоит 10 секунд при создании и экономит часы через год.
7. Инвертированный порядок override
HOCON (формат конфигурации, который использует Typesafe Config) работает по принципу «последнее значение побеждает».
Если в файле написано:
key = "value1" key = "value2"
То key будет "value2". Логично.
Env-переменные подставляются через ${?ENV_VAR} - если переменная задана, подставить; если нет - оставить предыдущее значение.
Правильный паттерн:
key = "default-value" key = ${?ENV_OVERRIDE}
Сначала дефолт, потом override. Env-переменная выигрывает. Так работает в 90% конфига.
Но в одном файле кто-то написал наоборот:
dump { enabled = ${?DUMP_ENABLED} enabled = false directory = ${?DUMP_DIR} directory = "opt/myapp/dumps/auto" }
Env-переменная DUMP_ENABLED подставляется первой, а потом затирается false. Всегда. Неважно, что стоит в env. Результат: ты ставишь DUMP_ENABLED=true - и ничего не происходит. Дампы не включаются. Ты перечитываешь конфиг пять раз, проверяешь env, рестартишь - не работает. Потому что порядок строк в файле неправильный.
8. Нет документации конфигов
Сто плюс параметров конфигурации. Ноль описаний.
Единственный файл, который пытается - `docker.md`:
FILE_STORAGE_PATH=/opt/myapp/storage HTTP_ENABLED=true HTTP_HOST=127.0.0.1 HTTPS_PORT=44390 DATASOURCE_URL=jdbc:postgresql://... - в таком формате DB_SCHEMA DB_USER DB_PASSWD EMAIL_ENABLED=false/true
Список env-переменных. Без описания, что они делают. Без допустимых значений. Без указания, что обязательно, а что нет. EMAIL_ENABLED=false/true - весь формат, который ты получишь.
AppConfig.java - 695 строк. Ни одного Javadoc-комментария. Ни одного. Что делает sync-request-timeout-seconds: 600? Десять минут таймаута - на что? На синхронный запрос. Какой синхронный запрос ждёт 10 минут? Никто не знает. Но кто-то когда-то поставил 600 - и вот.
Новый разработчик: «Что значит asyncCallbackUseSessionId?» Ответ: «Посмотри в коде, где оно используется».
workflowStepLimit = 200 - лимит на переходы в пользовательском скрипте. Что будет если поставить 0? Что будет если поставить 100 000? Какое разумное значение? Документации нет, только код.
9. Хардкод URL внешних API

val apiUrl = "https://chatapi.viber.com/pa"
val tokenUrl = "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token"
val graphUrl = s"https://graph.facebook.com/$userId"
val apiUrl = s"https://api.telegram.org/file/bot$token/$filePath"
URL внешних API - прямо в .java файлах. Не в конфиге. Viber поменял эндпоинт? Перекомпиляция и деплой. Microsoft перенёс OAuth? Перекомпиляция и деплой.
При этом в Docker-конфиге:
``` facebookApi { url = "https://graph.facebook.com/v3.3/me/messages" } ```
Facebook Graph API v3.3. Это версия API, которая deprecated уже несколько лет. Без env-override. Чтобы обновить версию API - нужно менять конфиг в Docker-образе.
10. Логирование без гибкости
Docker-образ сервиса содержит `log4j2.yml` с хардкоженными log-level'ами:
Logger: - name: akka level: warn - name: io.getquill level: warn - name: com.acme.platform level: error
com.acme.platform - основной пакет приложения - стоит error. То есть warning’и приложения не попадают в лог. Вообще. Это агрессивно. Вы не увидите предупреждение о том, что конфиг прочитался с дефолтами. Не увидите warning о retry. Не увидите ничего, кроме фатальных ошибок.
При этом `Root` log level управляется через env:
Root: level: ${env:ROOT_LOG_LEVEL:-error}
А akka, com.acme.platform, io.getquill - нет. Чтобы поменять уровень логирования для конкретного пакета - нужно пересобрать Docker-образ или прокинуть новый log4j2.yml через volume mount.
В проекте четыре формата конфигурации логирования:
log4j2.yml(YAML) - основной сервис и большинство микросервисовlog4j2.xml(XML) - сервис аналитикиlog4j.properties(Log4j 1.x!) - legacy API clientcommons-logging.properties- сервис нотификаций
Четыре формата. Два из которых - для устаревшего Log4j 1.x. Захочешь унифицировать уровни логирования по всем модулям - удачи с четырьмя форматами.
Итого
Configuration management - это не про красоту конфигов. Это про то, насколько быстро ты можешь изменить поведение системы в продакшене без деплоя. Насколько уверен, что на проде те же параметры, что на тесте. Насколько быстро новый человек поймёт, что за 100 параметров - и какие из них уронят прод, если поставить неправильное значение.
Три вещи, которые спасают:
Fail-fast при старте - валидируй все критичные параметры, падай с понятным сообщением. Лучше не стартовать, чем стартовать с 10 GB upload limit.
Всё изменяемое - в конфиг - таймауты, URL-ы, log-level’ы. Если для изменения параметра нужна пересборка - он лежит не там.
Конвенции и документация - единый стиль именования, описание каждого параметра, lifecycle для флагов. Конфиг без документации - это минное поле для следующего разработчика.
Огромная благодарность тем, кто дочитал статью до конца. Предложу зайти ко мне в канал в Telegram или в канал Max (кому где удобнее) о разработке в стартапах. В них рассказываю ещё больше интересного и делюсь опытом, заходите, обязательно найдете полезные кейсы!
Удачных релизов. И проверьте свои конфиги!
