Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Фича‑флаги и постепенные выкаты: как выпускать фичи без откатов и быстрее проверять гипотезы

Разработка и технологии26 декабря 2025 г.
Фича‑флаги позволяют включать и выключать части кода без релиза. Это снижает риск выката, ускоряет эксперименты и упрощает ответ на инциденты — один переключатель вместо отката релиза. В статье — понятная архитектура, готовый минимальный сервис на FastAPI, примеры прогрессивного выката и правила использования в проде.
Фича‑флаги и постепенные выкаты: как выпускать фичи без откатов и быстрее проверять гипотезы

  • Содержание
    • Зачем бизнесу фича‑флаги
    • Как это работает: архитектура и типы флагов
    • Минимальная реализация на практике (FastAPI + хеш‑бакетирование)
    • Постепенные выкаты и A/B‑тесты: примеры и анти‑паттерны
    • Наблюдаемость: метрики, события экспозиции, алерты
    • Процессы: безопасный запуск, kill switch, уборка
    • Вендор или своими силами: когда и что выбрать
    • Чек‑лист внедрения

Зачем бизнесу фича‑флаги

Релиз — не обязан быть «всё или ничего». Фича‑флаги позволяют:

  • запускать фичи по частям: сначала 1%, затем 5%, 25%, 100%;
  • быстро выключать проблемную часть без отката всего релиза (kill switch);
  • делать эксперименты и A/B‑тесты без сложной инфраструктуры;
  • разделять код и активацию — деплоим заранее, включаем позже, когда готовы продажи/поддержка;
  • ограничивать доступ по сегментам: только платный тариф, только одна страна, только определённые клиенты.

Это экономит деньги на откатах, снижает время простоя и ускоряет проверку гипотез.

Как это работает: архитектура и типы флагов

Минимальная схема:

  • Хранилище определений флагов: база данных или конфигурация в Git + кеш (Redis).
  • Оценка флага (evaluation): функция, которая по контексту пользователя (id, тариф, страна) решает, включать ли фичу и если да — какой вариант.
  • Доставка результатов в приложение: SDK/библиотека или лёгкий HTTP‑сервис.
  • Телеметрия: события «экспозиции» (пользователь увидел вариант), ошибки, метрики включений.

Типы флагов:

  • Булевый (on/off) — включить/выключить фичу.
  • Процентный — включить для N% пользователей. Важно «прилипание» (stickiness): один и тот же пользователь всегда попадает в один и тот же вариант. Реализуется детерминированным хешированием id.
  • Многовариантный — для A/B/n тестов (например, три варианта ценника). Часто один из вариантов — «off» как контрольная группа.
  • Сегментный — по правилам (страна ∈ {RU, KZ}, тариф ∈ {pro}).
  • Kill switch — мгновенно отключает фичу независимо от других правил.

Где хранить:

  • Быстрый старт: JSON в базе данных + Redis для кеша, рассылка обновлений через pub/sub.
  • Готовые решения: LaunchDarkly, Unleash, Flagsmith и т. п. Удобно, но платно и с внешними зависимостями.

Минимальная реализация на практике (FastAPI + хеш‑бакетирование)

Ниже — рабочий пример: лёгкий сервис, который оценивает флаги. Он поддерживает булевые, процентные и многовариантные флаги, сегменты и kill switch. Для простоты — хранение в памяти с REST‑управлением. В проде замените на БД/Redis, принцип остаётся тем же.

Установка и запуск

python -m venv .venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate
pip install "fastapi>=0.115" "uvicorn[standard]>=0.23" "pydantic>=2.0"
uvicorn main:app --reload

Код сервиса

# main.py
from typing import List, Dict, Optional
from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel, Field
import hashlib
import os

app = FastAPI(title="Feature Flags Service", version="1.0.0")

ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "dev")

class UserContext(BaseModel):
    id: str = Field(..., description="Уникальный идентификатор пользователя")
    country: Optional[str] = None
    plan: Optional[str] = None

class Variant(BaseModel):
    name: str
    weight: float = Field(..., ge=0.0, le=100.0)

class Segment(BaseModel):
    # Пример: {"country": ["RU", "KZ"], "plan": ["pro"]}
    attributes: Dict[str, List[str]]

class FlagDefinition(BaseModel):
    key: str
    enabled: bool = False
    rollout_percentage: float = Field(0.0, ge=0.0, le=100.0)
    salt: str = Field(..., description="Соль для детерминированного распределения")
    variants: Optional[List[Variant]] = None
    segments: Optional[List[Segment]] = None
    kill_switch: bool = False

class EvaluateRequest(BaseModel):
    flags: List[str]
    user: UserContext

class EvaluateResult(BaseModel):
    on: bool
    variant: Optional[str] = None
    reason: Optional[str] = None

# Простейшее хранилище в памяти. В проде: БД + кеш.
FLAGS: Dict[str, FlagDefinition] = {
    "new_checkout": FlagDefinition(
        key="new_checkout",
        enabled=True,
        rollout_percentage=10.0,
        salt="checkout-v1",
        segments=[Segment(attributes={"country": ["RU", "KZ"], "plan": ["pro", "business"]})],
        variants=None,
        kill_switch=False,
    ),
    "price_test": FlagDefinition(
        key="price_test",
        enabled=True,
        rollout_percentage=100.0,
        salt="pricing-2024-12",
        variants=[
            Variant(name="off", weight=50.0),
            Variant(name="v1", weight=25.0),
            Variant(name="v2", weight=25.0),
        ],
        segments=None,
        kill_switch=False,
    ),
}

# Детерминированное распределение по бакетам 0..100.
# При одинаковых (salt, user.id) выдаёт одну и ту же долю.

def bucket_percent(seed: str, salt: str) -> float:
    h = hashlib.sha256((salt + ":" + seed).encode("utf-8")).hexdigest()
    # Берём 32 бита из начала хэша, нормируем в проценты 0..100.
    n = int(h[:8], 16)
    return (n / 0xFFFFFFFF) * 100.0


def matches_segments(user: UserContext, segments: Optional[List[Segment]]) -> bool:
    if not segments:
        return True
    for seg in segments:
        ok = True
        for attr, allowed in seg.attributes.items():
            val = getattr(user, attr, None)
            if val is None or val not in allowed:
                ok = False
                break
        if ok:
            return True
    return False


def choose_variant(variants: List[Variant], p: float) -> str:
    # p — процент 0..100; идём по кумулятивной сумме весов
    total = 0.0
    for v in variants:
        total += v.weight
        if p < total:
            return v.name
    # На случай неточной суммы весов — последний вариант
    return variants[-1].name


def evaluate_flag(flag: FlagDefinition, user: UserContext) -> EvaluateResult:
    if flag.kill_switch:
        return EvaluateResult(on=False, reason="kill_switch")
    if not flag.enabled:
        return EvaluateResult(on=False, reason="disabled")
    if not matches_segments(user, flag.segments):
        return EvaluateResult(on=False, reason="segment_miss")

    p = bucket_percent(user.id, flag.salt)

    if flag.variants and len(flag.variants) > 0:
        chosen = choose_variant(flag.variants, p)
        if chosen == "off":
            return EvaluateResult(on=False, variant=None, reason="variant_off")
        return EvaluateResult(on=True, variant=chosen, reason="variant")

    # Булевый/процентный
    if p < flag.rollout_percentage:
        return EvaluateResult(on=True, reason="percent_rollout")
    return EvaluateResult(on=False, reason="percent_rollout")


@app.get("/flags", response_model=List[FlagDefinition])
async def list_flags():
    return list(FLAGS.values())


@app.put("/flags/{key}", response_model=FlagDefinition)
async def upsert_flag(key: str, flag: FlagDefinition, x_admin_token: str = Header("")):
    if x_admin_token != ADMIN_TOKEN:
        raise HTTPException(status_code=403, detail="forbidden")
    if key != flag.key:
        raise HTTPException(status_code=400, detail="key mismatch")
    FLAGS[key] = flag
    return flag


@app.post("/evaluate", response_model=Dict[str, EvaluateResult])
async def evaluate(req: EvaluateRequest):
    result: Dict[str, EvaluateResult] = {}
    for k in req.flags:
        flag = FLAGS.get(k)
        if not flag:
            result[k] = EvaluateResult(on=False, reason="not_found")
            continue
        result[k] = evaluate_flag(flag, req.user)
    return result

Примеры запросов:

# Получить список флагов
curl -s http://localhost:8000/flags | jq .

# Оценить флаг для пользователя
curl -s -X POST http://localhost:8000/evaluate \
  -H 'Content-Type: application/json' \
  -d '{
    "flags": ["new_checkout", "price_test"],
    "user": {"id": "user_123", "country": "RU", "plan": "pro"}
  }' | jq .

# Обновить флаг (админ)
curl -s -X PUT http://localhost:8000/flags/new_checkout \
  -H 'Content-Type: application/json' \
  -H 'X-Admin-Token: dev' \
  -d '{
    "key": "new_checkout",
    "enabled": true,
    "rollout_percentage": 25,
    "salt": "checkout-v1",
    "segments": [{"attributes": {"country": ["RU"], "plan": ["pro", "business"]}}],
    "variants": null,
    "kill_switch": false
  }' | jq .

Как подключать к приложению

  • Backend: оборачивайте код фичи проверкой результата /evaluate. В высоконагруженных местах кешируйте на 30–60 секунд.
  • Frontend: запросите /evaluate при загрузке страницы, передайте результат в состояние приложения и показывайте нужный вариант интерфейса. Для SSR можно пробрасывать флаги через HTTP‑заголовок или cookie.

Постепенные выкаты и A/B‑тесты: примеры и анти‑паттерны

Пошаговый выкат:

  1. Деплойте код с флагом в выключенном состоянии.
  2. Включите на 1% аудитории. Смотрите ошибки, метрики латентности, конверсию.
  3. Увеличивайте до 5%, 25%, 50%, 100% — с паузами 30–120 минут и автоматическими проверками.
  4. Когда фича устойчиво работает и прошла продуктовые метрики — удалите флаг из кода в ближайшем релизе.

A/B‑тест:

  • Создайте флаг с вариантами: off=50, v1=25, v2=25. В коде реализуйте ветки для v1/v2. События экспозиции отправляйте при первом показе, чтобы не завышать метрики.
  • Сегментируйте тест (например, только платные тарифы), чтобы не портить конверсию в бесплатном трафике.

Анти‑паттерны:

  • «Плавающее» распределение: нельзя использовать случайный генератор каждый раз — пользователь будет прыгать между вариантами. Нужна детерминированная привязка к user.id (см. bucket_percent).
  • Флаги‑долгожители: если фича в проде, удаляйте флаг; старые флаги копятся как технический долг и усложняют логику.
  • Логика в клиенте без валидации на сервере: критичные решения (цены, доступ) лучше проверять на бэкенде.

Наблюдаемость: метрики, события экспозиции, алерты

Что собирать:

  • экспозиции по флагам и вариантам (сколько пользователей что увидело);
  • ошибки и латентность в ветке «включено» vs «выключено»;
  • конверсию/основные бизнес‑метрики по вариантам;
  • изменения флагов (кто, когда, что поменял).

Быстрый старт без сложных систем:

  • логируйте событие exposure в отдельный поток (Kafka/очередь) или хотя бы в ClickHouse/БД;
  • ставьте алерт «ошибки на включённой ветке > X% за Y минут» и «латентность P95 выросла на Z% после увеличения процента»;
  • показывайте в дашборде текущие проценты выката и активные флаги.

Процессы: безопасный запуск, kill switch, уборка

  • Владение: у каждого флага должен быть владелец (команда), срок жизни, цель, план выката, критерии успеха и плана отката.
  • Kill switch: отдельный флаг для инфраструктурных зависимостей (например, внешняя платёжка). Должен выключаться мгновенно.
  • Код‑ревью флагов: изменения флагов — такие же изменения продукта, ревью обязательны.
  • Уборка: раз в спринт чистите флаги, чья цель достигнута. Боты‑помощники могут напоминать о флагах «старше 30 дней».

Вендор или своими силами: когда и что выбрать

  • Своими силами: подходит, если нужна простая логика, мало команд, нет жёстких требований аудита. Дёшево, полностью под контролем, легко встроить в существующие пайплайны.
  • Вендор: удобные интерфейсы, аудит, SDK для всех платформ, аналитика «из коробки», много типов правил. Выгодно при большом масштабе и многих приложениях/командах.
  • Золотая середина: держать ядро у себя (оценка и кеш), а управление хранить в Git (YAML) с автодеплоем и уведомлениями. Это позволяет иметь историю изменений и проходить аудит.

Чек‑лист внедрения

  • Определите ключи и владельцев флагов. Соглашение по именованию: <домен>.<фича>.<цель> (например, checkout.new_flow.rollout).
  • Введите набор типов: on/off, percent, multivariant, segment, kill.
  • Настройте детерминированное распределение по user.id с солью.
  • Включите метрики и события экспозиции, минимум — по флагам и вариантам.
  • Сделайте процесс: выкат ступенями + автоматические проверки + алерты.
  • Установите правило удаления флагов после достижения цели.
  • Документируйте риски и инструкции на случай инцидента (runbook): где выключать, кто отвечает, какая метрика триггерит откат.

Бонус: схема таблицы в БД (если захотите хранить в Postgres)

CREATE TABLE feature_flags (
    key text PRIMARY KEY,
    enabled boolean NOT NULL DEFAULT false,
    rollout_percentage real NOT NULL DEFAULT 0,
    salt text NOT NULL,
    variants jsonb,         -- [{"name": "off", "weight": 50}, ...]
    segments jsonb,         -- [{"attributes": {"country": ["RU"], "plan": ["pro"]}}]
    kill_switch boolean NOT NULL DEFAULT false,
    updated_at timestamptz NOT NULL DEFAULT now(),
    updated_by text NOT NULL DEFAULT 'system'
);

Если поверх использовать Redis‑кеш и pub/sub для моментальной доставки обновлений в приложения — вы получите почти «вендорский» опыт без подписки.

Итог: фича‑флаги — простой инструмент, который быстро окупается. Он снижает риски выката, ускоряет эксперименты и делает продукт управляемым — без бесконечных откатов и ночных релизов.


фича-флагирелизыA/B-тесты