10 ошибок Configuration Management

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

10 ошибок Configuration Management, image #1

Конфигурация - это не код. Код проходит 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 файлах. Тридцать с лишним мест.

10 ошибок Configuration Management, image #2

Примеры захардкоженных таймаутов из разных классов

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. Дрейф конфига тестов от прода

10 ошибок Configuration Management, image #3

Когда тестовый конфиг живёт отдельной жизнью от продового, рано или поздно они разъедутся. Вопрос только «когда» и «насколько».

У нас разъехались на два порядка:

# 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. Зоопарк именования конфигов

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. Флаги без жизненного цикла

10 ошибок Configuration Management, image #4

В конфигурационном классе — 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

10 ошибок Configuration Management, image #5
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 для флагов. Конфиг без документации - это минное поле для следующего разработчика.

Источник

309 views·12 shares