Инженерная практика Окружения, конфигурация и секреты
0%

Окружения, конфигурация и секреты

Окружения, конфигурация и секреты

Есть категория инцидентов, о которой не пишут в резюме, но которая исправно занимает верхние строчки постмортемов: код не менялся, а прод лёг. Поменяли одно значение — таймаут, размер пула, регулярное выражение, список маршрутов, — и система, прошедшая все тесты, перестала работать. Отказ 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 почти всегда врёт, и врёт по трём осям.

  1. Данные. На проде десять миллионов строк, накопленных за семь лет, с записями, созданными версиями кода, которых уже нет. На staging — тысяча строк, сгенерированных фикстурами месяц назад. Запрос, который на staging выполняется за 4 мс, на проде уходит в seq scan. Обезличенный срез прода помогает, но приносит юридические вопросы — см. Приватность и комплаенс.
  2. Масштаб. Staging — это одна реплика вместо двадцати, один узел базы вместо кластера, нулевой фоновый трафик. Проблемы конкуренции, исчерпания пулов, метастабильных отказов там не воспроизводятся в принципе.
  3. Интеграции. Внешние платёжные системы, банки и почтовые провайдеры дают вам песочницу, которая ведёт себя не как боевой API: другие таймауты, другие лимиты, другие коды ошибок, нет очередей.

Отсюда экономический вывод: чем ближе staging к проду, тем он дороже, и растёт эта цена быстрее, чем растёт точность. В какой-то момент рациональнее вложить те же деньги не в похожесть стенда, а в способность безопасно тестировать на проде: фича-флаги, канареечный выкат, теневой трафик, быстрый откат и наблюдаемость. Это не признак незрелости, а признак того, что команда посчитала. Механику безопасного выката разбирает глава о стратегиях релиза, а экономику окружений — платформенная глава.

Практический минимум для команды из 5–15 человек: локальное окружение, CI, preview на PR, production. Долгоживущий общий staging заводят тогда, когда появляется что-то, что нельзя проверить иначе, — тяжёлые миграции, сложная модель прав, сертификация. Не «потому что так принято».

Способы задать конфигурацию и порядок приоритетов

Источников значений всегда несколько, и вопрос не в том, какой «правильный», а в том, какой побеждает. Канонический порядок, от самого слабого к самому сильному:

  1. Значения по умолчанию в коде. Обязаны быть безопасными: минимальный пул, выключенные необязательные фичи, короткие таймауты. Умолчание — это то, что получит разработчик, впервые запустивший сервис.
  2. Файл конфигурации (config.yaml, application.yml, appsettings.json). Удобен для структурных вещей: списки, вложенные секции, набор фич. В репозитории лежит только несекретная часть.
  3. Переменные окружения. Основной механизм различий между развёртываниями. Плоские, строковые, доступны любому языку и любому рантайму.
  4. Флаги командной строки. Побеждают всё: их указывает человек или оператор прямо в момент запуска. Нужны для отладки и разовых прогонов.
  5. Удалённый источник — Consul, etcd, конфиг-сервис, менеджер секретов. Отдельный случай: он не «сильнее» флагов, он живёт в другом измерении — меняется без рестарта. Про это ниже, в разделе о динамической конфигурации.

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

Обратите внимание: в этой схеме нигде нет значения, которое нужно было бы хранить. Приложение доказывает, кто оно, тем, что запущено в нужном месте. Это и есть цель — заменить «знание секрета» на «проверяемую идентичность». Глубже — в главе о секретах и главе про OAuth и OIDC.

Жизненный цикл секрета

Существенная деталь — состояние «в ротации», когда действуют обе версии. Без него ротация превращается в микро-простой: вы отозвали старый ключ, а половина подов ещё не перечитала новый. Поэтому у любой системы, где ротация должна быть безболезненной, поддерживают два активных ключа одновременно — это верно и для паролей БД, и для ключей подписи JWT, и для HMAC-секретов вебхуков.

Секрет утёк: порядок действий

Первое, что делают почти все, — и это ошибка — переписывают историю git. Логика «сейчас уберу коммит, и всё будет как раньше» неверна: история уже роздана. Её склонировали CI, зеркала, IDE коллег, кеши GitHub (объект остаётся доступен по SHA даже после force-push), поисковые боты и сканеры, которые мониторят публичный поток событий GitHub в реальном времени. С момента пуша считайте, что значение известно всем.

Правильный порядок:

  1. Ротация. Немедленно выпустить новое значение и вывести старое из обращения. Это единственное действие, которое действительно закрывает дыру. Всё остальное — уборка.
  2. Отзыв. Убедиться, что старое значение отвергается: ключ удалён у провайдера, роль в базе дропнута, токен добавлен в список отозванных.
  3. Оценка ущерба. По журналам доступа посмотреть, использовалось ли значение из чужих адресов и в какое время. Если да — это уже инцидент безопасности, а не гигиеническая уборка, и дальше идёт процесс реагирования.
  4. Уборка истории. Только теперь — git filter-repo или BFG, координация force-push со всей командой, пересоздание форков. Механика и её опасности — в главе о переписывании истории.
  5. Причина. Почему это стало возможно: не было хука, не было сканера в 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-флаг — временный код с датой смерти и владельцем.

Источники

Что дальше

Конфигурация объясняет, как одна сборка живёт в разных местах. Следующий вопрос — что делать, когда одного экземпляра перестаёт хватать: какие бывают оси роста, как найти настоящее узкое место и как система меняет форму по мере взросления.

Масштабирование

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов