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

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

Эти 10 ошибок собраны из нескольких реальных проектов. Большинство из них не выглядели как проблема - до первого инцидента.

Что вы узнаете:
  1. Почему 30 хардкоженных таймаутов в коде страшнее, чем кажется

  2. Как отсутствие валидации позволяет стереть продовую БД одной env-переменной

  3. Как дефолтные значения в конфиге месяцами маскируют неправильную конфигурацию

  4. Чем опасна стократная разница в лимитах между тестом и продом

  5. Как зоопарк именования в конфиге приводит к тихим ошибкам

  6. Почему feature-флаги превращаются в бессмертных зомби

  7. Как неправильный порядок override делает env-переменные бесполезными

  8. Почему 100+ параметров без описания превращают конфиг в минное поле для новичков

  9. Как захардкоженные URL внешних API заставляют пересобирать сервис ради смены эндпоинта

  10. Почему ради одного 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 секунд - неправильное значение.

Проблема в том, что чтобы это значение изменить, нужно:
  1. Найти в коде (а их тридцать мест)

  2. Поменять

  3. Скомпилировать

  4. Пройти CI

  5. Задеплоить

Вместо того чтобы поправить строчку в конфиге и перезапустить сервис.

А под нагрузкой таймауты - первое, что нужно крутить. Когда база тормозит, хочется увеличить таймаут с 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.

В проекте четыре формата конфигурации логирования:
  1. log4j2.yml (YAML) - основной сервис и большинство микросервисов

  2. log4j2.xml (XML) - сервис аналитики

  3. log4j.properties (Log4j 1.x!) - legacy API client

  4. commons-logging.properties - сервис нотификаций

Четыре формата. Два из которых - для устаревшего Log4j 1.x. Захочешь унифицировать уровни логирования по всем модулям - удачи с четырьмя форматами.


Итого

Configuration management - это не про красоту конфигов. Это про то, насколько быстро ты можешь изменить поведение системы в продакшене без деплоя. Насколько уверен, что на проде те же параметры, что на тесте. Насколько быстро новый человек поймёт, что за 100 параметров - и какие из них уронят прод, если поставить неправильное значение.

Три вещи, которые спасают:
  1. Fail-fast при старте - валидируй все критичные параметры, падай с понятным сообщением. Лучше не стартовать, чем стартовать с 10 GB upload limit.

  2. Всё изменяемое - в конфиг - таймауты, URL-ы, log-level’ы. Если для изменения параметра нужна пересборка - он лежит не там.

  3. Конвенции и документация - единый стиль именования, описание каждого параметра, lifecycle для флагов. Конфиг без документации - это минное поле для следующего разработчика.

Огромная благодарность тем, кто дочитал статью до конца. Предложу зайти ко мне в канал в Telegram или в канал Max (кому где удобнее) о разработке в стартапах. В них рассказываю ещё больше интересного и делюсь опытом, заходите, обязательно найдете полезные кейсы!

Удачных релизов. И проверьте свои конфиги!