Окружения, конфигурация и секреты
Есть категория инцидентов, о которой не пишут в резюме, но которая исправно занимает верхние строчки постмортемов: код не менялся, а прод лёг. Поменяли одно значение — таймаут, размер пула, регулярное выражение, список маршрутов, — и система, прошедшая все тесты, перестала работать. Отказ Facebook 4 октября 2021 года, на шесть часов убравший компанию из интернета, начался с команды конфигурации на магистральных маршрутизаторах (разбор от инженеров Meta). Получасовой отказ Cloudflare 2 июля 2019 года — с выката одного регулярного выражения в правила WAF (постмортем). Академия говорит то же самое: в исследовании SOSP 2011 на данных коммерческой системы хранения и четырёх open-source-продуктов ошибки конфигурации оказались второй по частоте причиной обращений в поддержку, а среди самых тяжёлых — первой.
Причина простая. Код проходит ревью, типизацию, тесты, канареечный выкат. Конфигурация в большинстве команд не проходит ничего: её меняют в веб-консоли облака в 23:40, потому что «надо срочно». В этой главе — как перестать так делать.
Тема главы формулируется одной фразой: одна и та же сборка должна запускаться в пяти разных местах, отличаясь только конфигурацией — и не утекая при этом секретами.
Разграничение с соседями по порталу. 12 факторов как систему принципов разбирает глава о twelve-factor; управление секретами вглубь — до конвертного шифрования, устройства Vault и динамических кредов — глава о секретах; окружения как продукт платформенной команды, с ценой владения и метриками принятия, — глава об окружениях; Ansible, Chef и прочий configuration management — глава devops. Здесь — практика прикладного разработчика: что вы делаете руками в своём сервисе каждый день.
Конфигурация — это то, что отличается между развёртываниями
Рабочее определение, из которого следует всё остальное:
Конфигурация — это всё, что отличается между развёртываниями одного и того же артефакта. Всё, что одинаково во всех развёртываниях, — это код и должно лежать в репозитории.
Из определения сразу выпадают вещи, которые часто ошибочно зовут конфигурацией. Маршруты HTTP, схема ORM, содержимое pom.xml, набор middleware — это код: они не меняются от того, куда вы выкатились. А вот адрес базы, размер пула, включённость экспериментальной фичи, лимит запросов, ключ платёжного шлюза — конфигурация.
Проверочный вопрос из 12-factor остаётся лучшим тестом за пятнадцать лет:
Можно ли прямо сейчас выложить кодовую базу этого проекта в open source, ничего предварительно не вычищая?
Если ответ «нет, там в settings.py пароль от прод-базы» — конфигурация не отделена. Если «нет, там config/production.yml с адресами внутренних сервисов» — отделена частично: секретов нет, но параметры окружения зашиты, и, значит, чтобы поменять таймаут, нужен новый релиз.
Из определения следует и второе правило, которое нарушают чаще первого: артефакт один на все окружения. Не «сборка для staging» и «сборка для prod», а один образ, один jar, один бинарь, который в staging и prod получает разные значения переменных. Иначе то, что вы протестировали, и то, что вы выкатили, — разные вещи, и все гарантии CI обнуляются. Подробнее про воспроизводимость артефакта — в главе Сборка и зависимости.
Что такое окружение и зачем их несколько
Окружение — это место, где ваш артефакт запускается вместе со своим набором конфигурации, данных и соседей. Каждое окружение существует, чтобы дать ответ на определённый класс вопросов раньше, чем на них ответят пользователи, и у каждого своя цена.
Разложим по пользе и цене:
| Окружение | На какой вопрос отвечает | Кто владеет | Что стоит | Чем врёт |
|---|---|---|---|---|
| Локальное | «Работает ли моя логика вообще» | разработчик | часы на поддержку compose-файла | масштаб, соседи, права |
| CI | «Проходят ли тесты на чистой машине» | команда | минуты на прогон, деньги за раннеры | нет живых интеграций |
| Preview на PR | «Не сломал ли я деплой и контракт» | платформа | десятки долларов в месяц на PR | данные, трафик |
| Dev | «Стыкуются ли сервисы команд» | все и никто | постоянная починка | нестабилен по определению |
| Staging | «Пройдёт ли миграция и хватает ли прав» | релиз-инженер | тысячи долларов в месяц | данные, масштаб, интеграции |
| Production | «Что происходит на самом деле» | вся компания | всё остальное | ничем — это и есть правда |
Честная позиция, которую стоит занять сразу: staging почти всегда врёт, и врёт по трём осям.
- Данные. На проде десять миллионов строк, накопленных за семь лет, с записями, созданными версиями кода, которых уже нет. На staging — тысяча строк, сгенерированных фикстурами месяц назад. Запрос, который на staging выполняется за 4 мс, на проде уходит в seq scan. Обезличенный срез прода помогает, но приносит юридические вопросы — см. Приватность и комплаенс.
- Масштаб. Staging — это одна реплика вместо двадцати, один узел базы вместо кластера, нулевой фоновый трафик. Проблемы конкуренции, исчерпания пулов, метастабильных отказов там не воспроизводятся в принципе.
- Интеграции. Внешние платёжные системы, банки и почтовые провайдеры дают вам песочницу, которая ведёт себя не как боевой API: другие таймауты, другие лимиты, другие коды ошибок, нет очередей.
Отсюда экономический вывод: чем ближе staging к проду, тем он дороже, и растёт эта цена быстрее, чем растёт точность. В какой-то момент рациональнее вложить те же деньги не в похожесть стенда, а в способность безопасно тестировать на проде: фича-флаги, канареечный выкат, теневой трафик, быстрый откат и наблюдаемость. Это не признак незрелости, а признак того, что команда посчитала. Механику безопасного выката разбирает глава о стратегиях релиза, а экономику окружений — платформенная глава.
Практический минимум для команды из 5–15 человек: локальное окружение, CI, preview на PR, production. Долгоживущий общий staging заводят тогда, когда появляется что-то, что нельзя проверить иначе, — тяжёлые миграции, сложная модель прав, сертификация. Не «потому что так принято».
Способы задать конфигурацию и порядок приоритетов
Источников значений всегда несколько, и вопрос не в том, какой «правильный», а в том, какой побеждает. Канонический порядок, от самого слабого к самому сильному:
- Значения по умолчанию в коде. Обязаны быть безопасными: минимальный пул, выключенные необязательные фичи, короткие таймауты. Умолчание — это то, что получит разработчик, впервые запустивший сервис.
- Файл конфигурации (
config.yaml,application.yml,appsettings.json). Удобен для структурных вещей: списки, вложенные секции, набор фич. В репозитории лежит только несекретная часть. - Переменные окружения. Основной механизм различий между развёртываниями. Плоские, строковые, доступны любому языку и любому рантайму.
- Флаги командной строки. Побеждают всё: их указывает человек или оператор прямо в момент запуска. Нужны для отладки и разовых прогонов.
- Удалённый источник — Consul, etcd, конфиг-сервис, менеджер секретов. Отдельный случай: он не «сильнее» флагов, он живёт в другом измерении — меняется без рестарта. Про это ниже, в разделе о динамической конфигурации.
db.pool_size"] --> CLI{"Передан флаг
--db-pool-size?"} CLI -->|да| OK["Значение принято"] CLI -->|нет| ENV{"Есть переменная
APP_DB_POOL_SIZE?"} ENV -->|да| OK ENV -->|нет| FILE{"Есть ключ
в config.yaml?"} FILE -->|да| OK FILE -->|нет| DEF{"Есть безопасное
умолчание в коде?"} DEF -->|да| OK DEF -->|нет| FAIL["Обязательный параметр
не задан"] OK --> VAL{"Проходит
валидацию типа
и диапазона?"} VAL -->|да| READY["Объект конфигурации собран,
сервис стартует"] VAL -->|нет| FAIL FAIL --> EXIT["Падаем на старте:
понятное сообщение,
ненулевой код возврата"]
Ключевая деталь в этой схеме — правая нижняя ветка. Падай на старте, а не через час. Сервис, который поднялся с POOL_SIZE="" и упал через сорок минут при первом обращении к базе, стоил вам сорока минут расследования. Сервис, который отказался стартовать со строкой «переменная APP_DB_POOL_SIZE обязательна, получено пустое значение», стоил тридцати секунд. Если конфигурацию проверяют на старте, ошибку ловит канареечный экземпляр, оркестратор не пропускает его в балансировщик, а откат происходит автоматически.
Загрузчик с такой иерархией и валидацией, на Python без зависимостей:
"""Загрузка конфигурации из слоёв с явным приоритетом и проверкой на старте."""
from __future__ import annotations
import argparse
import os
import sys
from dataclasses import dataclass, fields
from pathlib import Path
from typing import Any
import yaml
@dataclass(frozen=True)
class Config:
"""Единственный разобранный и провалидированный объект конфигурации."""
service_name: str
port: int
db_dsn: str # обязателен: умолчания нет
db_pool_size: int = 10
request_timeout_s: float = 3.0
log_level: str = "INFO"
feature_new_checkout: bool = False
# Ключи, у которых нет умолчания. Их отсутствие — фатальная ошибка старта.
REQUIRED = {"service_name", "db_dsn"}
ENV_PREFIX = "APP_"
def _coerce(raw: Any, target: type) -> Any:
"""Приводит строку из env или CLI к нужному типу; bool разбираем явно."""
if target is bool and isinstance(raw, str):
low = raw.strip().lower()
if low in {"1", "true", "yes", "on"}:
return True
if low in {"0", "false", "no", "off"}:
return False
raise ValueError(f"нельзя интерпретировать {raw!r} как булево значение")
return target(raw)
def load(argv: list[str] | None = None) -> Config:
known = {f.name: f.type for f in fields(Config)}
values: dict[str, Any] = {}
# Слой 1: умолчания уже заданы в dataclass, отдельно собирать не нужно.
# Слой 2: файл конфигурации. Путь тоже конфигурируется — но только окружением.
path = Path(os.environ.get(f"{ENV_PREFIX}CONFIG_FILE", "config.yaml"))
if path.exists():
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
values.update({k: v for k, v in data.items() if k in known})
# Слой 3: переменные окружения — APP_DB_POOL_SIZE -> db_pool_size.
for name in known:
env_name = ENV_PREFIX + name.upper()
if env_name in os.environ:
values[name] = os.environ[env_name]
# Слой 4: флаги командной строки — сильнее всего остального.
parser = argparse.ArgumentParser(add_help=False)
for name in known:
parser.add_argument(f"--{name.replace('_', '-')}", dest=name, default=None)
args, _ = parser.parse_known_args(argv if argv is not None else sys.argv[1:])
values.update({k: v for k, v in vars(args).items() if v is not None})
# Валидация: типы, обязательность, диапазоны. Всё — до первого запроса.
errors: list[str] = []
typed: dict[str, Any] = {}
for name, value in values.items():
try:
typed[name] = _coerce(value, known[name])
except (TypeError, ValueError) as exc:
errors.append(f"{ENV_PREFIX}{name.upper()}: {exc}")
for name in REQUIRED:
if not typed.get(name):
errors.append(f"{ENV_PREFIX}{name.upper()}: обязательный параметр не задан")
if (pool := typed.get("db_pool_size", 10)) is not None and not 1 <= int(pool) <= 200:
errors.append(f"db_pool_size={pool}: допустимо от 1 до 200")
if errors:
# Печатаем ВСЕ ошибки сразу, а не первую: иначе получится игра в угадайку.
for line in errors:
print(f"конфигурация: {line}", file=sys.stderr)
raise SystemExit(78) # EX_CONFIG из sysexits.h
return Config(**typed)
Три детали, которые отличают этот код от наивного варианта. Ошибки собираются все сразу — иначе перезапуск ради каждой опечатки превращается в получасовой квест. Код возврата 78 (EX_CONFIG) отличает ошибку конфигурации от падения приложения, и по нему оркестратор и алерты могут отличить «нас неправильно настроили» от «мы сломались». И — конфигурация собирается ровно один раз, при старте, в неизменяемый объект.
Типизированная конфигурация вместо os.environ посреди кода
Строка os.environ["PORT"] в глубине обработчика запроса — это технический долг сразу по нескольким статьям. Она отказывает не на старте, а в проде под нагрузкой. Она не даёт узнать, какие переменные вообще нужны сервису, — приходится грепать по репозиторию. Она возвращает строку, которую придётся приводить к числу в каждом месте использования. Её невозможно подменить в тесте иначе, чем правкой глобального состояния процесса.
Норма — единый разобранный и провалидированный объект конфигурации, который создаётся один раз при старте и дальше передаётся явно. Схема на pydantic-settings (документация) делает то же, что загрузчик выше, но декларативно:
from pydantic import Field, PostgresDsn, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="APP_",
env_file=".env", # только для локальной разработки
env_nested_delimiter="__",
frozen=True, # изменить конфигурацию в рантайме нельзя
extra="forbid", # опечатка APP_PROT=8080 — ошибка, а не тишина
)
service_name: str
port: int = Field(default=8080, ge=1, le=65535)
db_dsn: PostgresDsn # разбор URL и проверка схемы бесплатно
db_pool_size: int = Field(default=10, ge=1, le=200)
request_timeout_s: float = Field(default=3.0, gt=0, le=60)
log_level: str = "INFO"
@field_validator("log_level")
@classmethod
def _known_level(cls, v: str) -> str:
allowed = {"DEBUG", "INFO", "WARNING", "ERROR"}
if v.upper() not in allowed:
raise ValueError(f"ожидается один из {sorted(allowed)}")
return v.upper()
settings = Settings() # падает на импорте, если конфигурация невалидна
Обратите внимание на extra="forbid". Это защита от самой коварной ошибки конфигурации — молчаливой опечатки. Без неё APP_REQUEST_TIMEOU_S=30 просто игнорируется, сервис работает с умолчанием 3 секунды, и вы полдня ищете, почему «выставленный таймаут не применился».
Аналоги в других экосистемах: kelseyhightower/envconfig и caarlos0/env в Go, zod поверх process.env в TypeScript, @ConfigurationProperties с @Validated в Spring Boot, Options pattern с ValidateOnStart() в .NET. В Go это выглядит так:
// Конфигурация сервиса: одна структура, разбирается один раз в main().
type Config struct {
ServiceName string `env:"SERVICE_NAME,required"`
Port int `env:"PORT" envDefault:"8080"`
DBDSN string `env:"DB_DSN,required"` // значение не логируем никогда
PoolSize int `env:"DB_POOL_SIZE" envDefault:"10"`
Timeout time.Duration `env:"REQUEST_TIMEOUT" envDefault:"3s"`
}
func main() {
var cfg Config
if err := env.Parse(&cfg); err != nil {
// Ошибка конфигурации — не паника рантайма, а отказ старта с понятным текстом.
log.Fatalf("конфигурация: %v", err)
}
// ... дальше cfg передаётся явно во все конструкторы
}
Отдельно про печать конфигурации. Логировать разобранный конфиг при старте полезно — это экономит часы при разборе инцидента. Но для этого у объекта должен быть безопасный метод представления, который маскирует чувствительные поля: db_dsn печатается как postgres://app:***@db-prod:5432/orders. Pydantic для этого даёт тип SecretStr, Go — свой String() на типе-обёртке. Без этого первый же дамп конфигурации отправит пароль в систему логов, где он будет храниться год и будет доступен всем, у кого есть доступ к логам.
Секреты — это отдельный класс конфигурации
Секрет — это тоже конфигурация, но с ним нельзя обращаться как с остальной конфигурацией, потому что у него другие свойства:
| Свойство | Обычная конфигурация | Секрет |
|---|---|---|
| Последствие утечки | никаких | необратимо: значение скомпрометировано навсегда |
| Исправление | поменять значение | ротация плюс расследование, что успели сделать |
| Срок жизни | пока не поменяют | ограниченный, требует плановой ротации |
| Аудит | не нужен | нужен: кто, когда и откуда прочитал |
| Хранение | git, конфиг-мапа | только зашифрованно или в менеджере секретов |
| Значение в логе | норма | инцидент |
Отсюда список мест, где секрета не должно быть никогда:
- В репозитории. Даже в приватном, даже «временно». Ежегодный отчёт GitGuardian State of Secrets Sprawl стабильно фиксирует миллионы новых утечек в публичном GitHub за год. Приватный репозиторий становится публичным ровно один раз.
- В образе контейнера. Слои образа читает любой, у кого есть доступ к реестру, а
docker historyпокажетARGсо значением. Секрет попадает в контейнер в момент запуска, а не в момент сборки — см. Контейнеры и реестры. - В логах. Ни в дампе конфигурации, ни в трассировке запроса, ни в тексте исключения. Логгер должен иметь фильтр-редактор для известных паттернов.
- В URL. Query-строка попадает в access-логи веб-сервера, в историю браузера, в заголовок
Refererи в метрики прокси. Токен передают заголовкомAuthorization, не параметром. - В аргументах команды.
mysql -pSecret123видно вps auxлюбому пользователю машины и попадает в историю шелла. Передают через переменную окружения, файл с правами 600 или stdin. - Один на все окружения. Общий ключ между dev и prod означает, что компрометация тестового стенда — это компрометация прода.
Инструменты, из которых выбирают:
- Менеджер секретов облака — AWS Secrets Manager, GCP Secret Manager, Azure Key Vault. Самый дешёвый вход, если вы уже в этом облаке: интеграция с IAM, ротация из коробки, аудит в общем журнале.
- HashiCorp Vault (документация) — если нужно облаконезависимо или нужны динамические креды: Vault сам создаёт пользователя в базе с TTL 30 минут, и утёкший пароль перестаёт работать сам.
- SOPS + age (sops, age) — шифрование значений прямо в YAML-файле в репозитории. Ключ шифрования лежит в KMS. Диффы читаемы, ревью работает, git остаётся источником правды.
- Sealed Secrets (проект) и External Secrets Operator (проект) — для GitOps: в репозитории лежит либо зашифрованный публичным ключом кластера объект, либо ссылка на секрет во внешнем хранилище. Разбор GitOps-подхода — в главе о Helm и GitOps.
- OIDC-федерация вместо долгоживущих ключей в CI — самое важное изменение практики за последние годы.
Маскирование в CI и почему его недостаточно
Все CI-платформы умеют «секретные переменные»: значение вводится один раз, дальше отображается звёздочками, а в логах автоматически заменяется на ***. Это полезно, но это не механизм безопасности, а защита от случайности. Маскирование обходится тривиально: echo "$TOKEN" | base64 выведет незамаскированную строку, потому что раннер ищет в выводе точное совпадение. Любой, кто может изменить пайплайн, может достать секрет.
Отсюда практические следствия:
- Секреты привязываются к окружению, а не к проекту: прод-секреты доступны только job, деплоящей в прод, и только после ручного подтверждения (
environmentsс required reviewers в GitHub Actions, protected variables в GitLab). - Job из форка внешнего контрибьютора не должна получать секреты вообще.
- Секрет отдаётся конкретному шагу, а не экспортируется в окружение всего пайплайна, где его увидит любая транзитивная зависимость сборки. Это же — линия защиты от атак на цепочку поставки, см. Supply chain и Безопасность в пайплайне.
Правильный современный вариант — вообще не хранить в CI долгоживущих ключей. CI получает от своего OIDC-провайдера короткоживущий JWT, подтверждающий «это job из репозитория org/app, ветка main», и обменивает его на временные креды:
# GitHub Actions: деплой без единого долгоживущего ключа в секретах репозитория
permissions:
id-token: write # разрешаем выпуск OIDC-токена для этого job
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # прод-окружение: требует подтверждения ревьюером
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
# Роль доверяет только токенам с sub = repo:org/app:environment:production
role-to-assume: arn:aws:iam::123456789012:role/deploy-prod
aws-region: eu-central-1
# Креды живут минуты и не существуют вне этого job
Доверие настраивается один раз на стороне облака (документация GitHub про OIDC). Красть из репозитория становится нечего.
Так же работает и получение секрета самим приложением в рантайме — через identity рабочей нагрузки, а не через зашитый пароль:
монтируется kubelet, TTL 10 мин participant V as Vault participant K8S as API кластера participant DB as PostgreSQL POD->>SA: читает projected-токен из файла POD->>V: login: роль app-prod + токен V->>K8S: TokenReview: этот токен настоящий? K8S-->>V: да, namespace prod, SA app V-->>POD: Vault-токен, TTL 1 час POD->>V: выдай креды к базе для роли app-ro V->>DB: CREATE ROLE v-app-a1b2 VALID UNTIL now + 30 min V-->>POD: логин и пароль, TTL 30 минут POD->>DB: подключение с временными кредами Note over V,DB: по истечении TTL Vault сам делает DROP ROLE —
утёкший пароль мёртв, ротация не требует релиза
Обратите внимание: в этой схеме нигде нет значения, которое нужно было бы хранить. Приложение доказывает, кто оно, тем, что запущено в нужном месте. Это и есть цель — заменить «знание секрета» на «проверяемую идентичность». Глубже — в главе о секретах и главе про OAuth и OIDC.
Жизненный цикл секрета
Существенная деталь — состояние «в ротации», когда действуют обе версии. Без него ротация превращается в микро-простой: вы отозвали старый ключ, а половина подов ещё не перечитала новый. Поэтому у любой системы, где ротация должна быть безболезненной, поддерживают два активных ключа одновременно — это верно и для паролей БД, и для ключей подписи JWT, и для HMAC-секретов вебхуков.
Секрет утёк: порядок действий
Первое, что делают почти все, — и это ошибка — переписывают историю git. Логика «сейчас уберу коммит, и всё будет как раньше» неверна: история уже роздана. Её склонировали CI, зеркала, IDE коллег, кеши GitHub (объект остаётся доступен по SHA даже после force-push), поисковые боты и сканеры, которые мониторят публичный поток событий GitHub в реальном времени. С момента пуша считайте, что значение известно всем.
Правильный порядок:
- Ротация. Немедленно выпустить новое значение и вывести старое из обращения. Это единственное действие, которое действительно закрывает дыру. Всё остальное — уборка.
- Отзыв. Убедиться, что старое значение отвергается: ключ удалён у провайдера, роль в базе дропнута, токен добавлен в список отозванных.
- Оценка ущерба. По журналам доступа посмотреть, использовалось ли значение из чужих адресов и в какое время. Если да — это уже инцидент безопасности, а не гигиеническая уборка, и дальше идёт процесс реагирования.
- Уборка истории. Только теперь —
git filter-repoили BFG, координация force-push со всей командой, пересоздание форков. Механика и её опасности — в главе о переписывании истории. - Причина. Почему это стало возможно: не было хука, не было сканера в CI, файл не попал в
.gitignore, разработчику вообще выдали прод-ключ, хотя он ему был не нужен.
Профилактика дешевле лечения на порядки. Ставится в три места: pre-commit хук (ловит до того, как ушло), CI на каждый PR (ловит до мержа), периодическое сканирование всей истории и всех репозиториев организации (ловит накопленное). Инструменты — gitleaks и trufflehog; второй умеет ещё и проверять найденное значение на живость, что резко снижает шум. Встраивание в поток разработки — в главе Качество в потоке, запуск в пайплайне — в главе CI/CD.
Конфигурация как код и борьба с дрейфом
Отдельный класс проблем начинается там, где конфигурация живёт не в приложении, а в инфраструктуре: правила фаервола, лимиты автоскейлера, параметры базы, политики доступа. Здесь работает главный антипаттерн отрасли — изменение в веб-консоли облака руками.
Механика ущерба всегда одна. Инженер чинит инцидент и поднимает max_connections в консоли. Инцидент закрыт, изменение нигде не записано. Через три месяца кластер пересоздают из Terraform — и значение возвращается к старому. Или новый регион разворачивают по тому же коду — и он работает не так, как первый. Это и есть «работает только на этом сервере»: разница между заявленным и фактическим состоянием, которую никто не видит. Называется дрейф конфигурации.
Ответ — описывать инфраструктуру кодом и относиться к этому коду как к обычному: ревью, история, откат. terraform plan в этой картине выполняет вторую, менее очевидную роль: это детектор дрейфа. Регулярный запуск плана на неизменённом репозитории должен давать «No changes». Любой diff — это чьё-то ручное вмешательство, о котором надо узнать не в момент следующего релиза, а сейчас (про дрейф в документации Terraform). Механика — в главе о Terraform и IaC; в GitOps ту же роль играет непрерывная реконсиляция: контроллер сам возвращает кластер к состоянию из репозитория и помечает ресурс как OutOfSync (Helm и GitOps).
Практический компромисс для инцидентов: разрешать ручное вмешательство, но с обязательным правилом «изменил руками — в тот же день внёс в код». Второе без первого превращается в саботаж процесса, первое без второго — в дрейф.
Динамическая конфигурация и фича-флаги
Часть параметров хочется менять без релиза: перезапуск сорока подов ради изменения лимита — дорого и рискованно. Отсюда динамическая конфигурация: значение живёт во внешнем источнике, приложение подписано на изменения.
Граница проходит по последствиям ошибки:
| Можно менять динамически | Нельзя, только через релиз |
|---|---|
| Уровень логирования, доля сэмплирования трейсов | Строка подключения к базе |
| Лимиты запросов, размеры батчей | Схема данных и формат сообщений |
| Таймауты и параметры ретраев в разумных границах | Права доступа и роли |
| Включённость фичи для процента пользователей | Криптографические параметры |
| Тексты, ставки, региональные настройки | Всё, что меняет инварианты домена |
Правило: динамическим делают то, у чего есть безопасный диапазон и что можно вернуть обратно за секунды. И у динамического значения тоже должна быть валидация — иначе опечатка в веб-панели мгновенно доезжает до всех экземпляров сразу, без канарейки. Именно так и происходят самые быстрые в истории отказы: изменение конфигурации по своей природе выкатывается атомарно и глобально, тогда как код выкатывается постепенно. Поэтому зрелые команды применяют к конфигурации те же практики, что к коду: ревью, поэтапный выкат, автоматический откат по метрикам. Facebook описал это в работе Holistic Configuration Management at Facebook, Google — в главе Configuration Design and Best Practices SRE Workbook.
Фича-флаг — частный случай динамической конфигурации, привязанный к куску кода. Классификация Мартина Фаулера (Feature Toggles) полезна именно тем, что у разных типов разный срок жизни:
| Тип | Зачем | Кто решает | Срок жизни |
|---|---|---|---|
| Release | отделить выкат кода от включения фичи | команда разработки | дни–недели, удаляется после релиза |
| Ops | аварийный выключатель тяжёлой функции | дежурный | месяцы–годы, живёт сколько живёт функция |
| Permission | доступ по тарифу или роли | продукт | постоянно, это часть модели домена |
| Experiment | A/B-тест, замер эффекта | аналитика | до конца эксперимента |
Главная операционная мысль: release-флаг — это временный код с датой смерти. Флаг, который живёт год, — это ветвь, которую никто не тестирует, и второй, теневой путь исполнения. Два флага дают четыре комбинации, десять — тысячу; ни одна из них не покрыта тестами. Поэтому на флаг заводят владельца и дату удаления прямо в коде или в реестре флагов, а «убить протухшие флаги» ставят в регулярную работу. Открытый стандарт для этого — OpenFeature, он даёт единый SDK поверх разных провайдеров. Про роль флагов в стратегиях выката — глава о CD и глава CI/CD этого трека.
Мультиарендность и региональность
Как только у продукта появляется больше одного крупного клиента или больше одного региона, конфигурация перестаёт быть плоской: у неё появляется измерение «для кого».
- Конфигурация на арендатора. Лимиты, включённые модули, брендирование, интеграции — своё у каждого клиента. Хранится это уже не в переменных окружения, а в базе или конфиг-сервисе с ключом
tenant_id, потому что арендаторов тысячи и они меняются без релиза. Критично, чтобы у арендатора были безопасные умолчания и чтобы отсутствие записи не означало «всё разрешено». Модели изоляции разбирает глава о мультиарендности. - Региональность и резидентность данных. GDPR и аналогичные требования означают, что данные клиента из ЕС физически не должны покидать ЕС. Это конфигурация, но такая, ошибка в которой стоит штрафа: маршрутизация запроса в «не тот» регион — юридический инцидент. Практически из этого следует, что регион — не переменная окружения, а свойство развёртывания целиком, и код не должен уметь «сходить в соседний регион по конфигу». Многорегиональность и её цена — в главе о multi-region и DR.
Типичные ошибки
.envв репозитории. Файл.env— инструмент локальной разработки. В репозитории лежит только.env.exampleс пустыми значениями и комментариями, а сам.env— в.gitignoreс первого коммита.- Разные сборки для разных окружений.
app-staging.jarиapp-prod.jar— это две разные программы, и тестировали вы не ту, которую выкатили. Один артефакт, разная конфигурация. - Ленивое чтение конфигурации посреди работы. Значение читают при первом обращении к функции — и узнают об ошибке через час после старта, в проде, под нагрузкой, в самом неудобном месте.
- Общий секрет на все окружения. Один ключ для dev, staging и prod означает, что периметр вашей безопасности проходит по самому слабому стенду, к которому есть доступ у всех.
- Отсутствие ротации. Ключ, выпущенный в 2019 году и не менявшийся с тех пор, скорее всего уже лежит в чьей-то локальной копии репозитория, в старом тикете и в переписке. Ротация — не реакция на утечку, а плановая процедура, которая должна быть отрепетирована.
if env == "prod"в бизнес-логике. Как только код знает, в каком окружении он работает, тестирование теряет смысл: в staging исполняется одна ветка, в проде другая. Различие должно выражаться значением параметра (payment_gateway_url,email_provider), а не именем окружения.- Отсутствие валидации типов.
MAX_RETRIES=threeилиENABLE_CACHE=maybeдолжны валить старт, а не молча превращаться в 0 иFalse. - Секрет в конфиг-мапе Kubernetes. ConfigMap не шифруется и виден всем, у кого есть право читать объекты в namespace. Даже базовый Secret в etcd по умолчанию хранится в base64, а не в шифре.
Мини-итог
- Конфигурация — это то, что отличается между развёртываниями одного артефакта. Всё остальное — код.
- Тест на отделённость: можно ли выложить репозиторий в open source прямо сейчас.
- Один артефакт на все окружения. Разные сборки под окружения обнуляют гарантии тестирования.
- Порядок приоритетов: умолчания → файл → переменные окружения → флаги CLI. Удалённый источник — отдельное измерение для того, что меняется без рестарта.
- Валидируй всю конфигурацию на старте, собирай все ошибки сразу, падай с понятным сообщением и кодом
EX_CONFIG. - Единый типизированный объект конфигурации вместо чтения окружения по всему коду.
extra="forbid"ловит опечатки, которые иначе обнаружатся в проде. - Секрет — не просто чувствительная конфигурация: утечка необратима, нужна ротация и аудит. Никогда: в репозитории, в образе, в логах, в URL, в
ps. - Маскирование в CI — защита от случайности, а не механизм безопасности. Настоящий ответ — OIDC-федерация и короткоживущие креды.
- При утечке первым действием идёт ротация, а не переписывание истории.
- Ручные правки в консоли облака порождают дрейф.
terraform planбез изменений — это ежедневная проверка, а не ритуал перед релизом. - Динамическим делают то, у чего есть безопасный диапазон. Release-флаг — временный код с датой смерти и владельцем.
Источники
- The Twelve-Factor App, фактор III: конфигурация
- Yin et al. «An Empirical Study on Configuration Errors in Commercial and Open Source Systems», SOSP 2011 — ACM DL
- Tang et al. «Holistic Configuration Management at Facebook», SOSP 2015 — ACM DL
- Google SRE Workbook, Configuration Design and Best Practices
- Martin Fowler, Feature Toggles
- Разбор отказа Facebook 4 октября 2021
- Постмортем Cloudflare, 2 июля 2019
- Security hardening with OpenID Connect, документация GitHub Actions
- Инструменты: HashiCorp Vault, SOPS, External Secrets Operator, gitleaks, trufflehog, OpenFeature
Что дальше
Конфигурация объясняет, как одна сборка живёт в разных местах. Следующий вопрос — что делать, когда одного экземпляра перестаёт хватать: какие бывают оси роста, как найти настоящее узкое место и как система меняет форму по мере взросления.