Архитектурные паттерны Serverless и edge-архитектуры
0%

Serverless и edge-архитектуры

Serverless и edge-архитектуры

Слово «serverless» — худшее название в истории индустрии. Серверы есть, их просто не видно; вы по-прежнему думаете про память, таймауты, конкурентность и сетевые границы, только теперь через чужие абстракции. Но за плохим названием стоит настоящий архитектурный сдвиг: единицей развёртывания и биллинга становится не машина и не контейнер, а вызов. Это меняет экономику, меняет модель отказа и — что важнее — меняет то, какой код вы вообще имеете право писать.

Edge-архитектуры доводят ту же идею до географического предела: код исполняется не «в регионе eu-central-1», а в сотне-другой точек присутствия в двадцати миллисекундах от пользователя. И здесь ограничения ещё жёстче: нет диска, нет своих процессов, нет привычной базы данных под боком.

Эта статья — про механику. Мы разберём, что физически происходит при первом вызове функции, почему JVM стартует шесть секунд, а V8-изолят пять миллисекунд, как посчитать нужную конкурентность законом Литтла, почему тысяча одновременных Lambda убивает Postgres, где на краю сети живёт состояние, и в какой точке график стоимости FaaS пересекает график стоимости всегда-включённых контейнеров. И отдельно — про случаи, когда правильный ответ на вопрос «делать ли serverless» звучит «нет».

Предполагается знакомство со статьями «Микросервисы» (границы сервисов, эксплуатация), «Событийная архитектура» (топики, гарантии доставки) и «Saga, распределённые транзакции, outbox и идемпотентность» — идемпотентность дальше используется как известное понятие.


1. Что такое serverless на самом деле

Отбросим маркетинг. Платформа заслуживает названия serverless, если выполняются три свойства одновременно:

  1. Масштабирование до нуля. Когда трафика нет, не работает ничего и вы не платите ни цента. Это отличает FaaS от «управляемых контейнеров», где у вас всегда крутится минимум один инстанс.
  2. Тарификация по фактическому потреблению. Единица счёта — запрос и гигабайт-секунда, а не час аренды виртуалки. Простой бесплатен, пик оплачивается.
  3. Передача операционной ответственности. Вы не патчите ОС, не настраиваете автоскейлер, не думаете про размещение по зонам доступности. Взамен вы не можете это и настроить.

Если платформа даёт только третье свойство — это «управляемый сервис», а не serverless. Полезная формулировка есть у Мартина Фаулера и Майка Робертса в «Serverless Architectures»: serverless — это объединение BaaS (готовые сервисы вместо своего кода) и FaaS (эфемерные функции по событию).

Спектр, а не переключатель

«Serverless или нет» — ложная дихотомия. Между голой виртуалкой и edge-функцией лежит континуум, и каждый шаг вверх отдаёт вам скорость запуска и дешевизну простоя в обмен на свободу.

Правило, к которому сводится весь дальнейший текст: чем выше по этой лестнице, тем короче и «тупее» должен быть ваш код. Наверху лестницы живут маршрутизация, преобразование и валидация; внизу — всё остальное.


2. Жизненный цикл инстанса: заморозка, которая ломает интуицию

Главное недопонимание FaaS звучит так: «на каждый запрос поднимается новый процесс». Это неверно и приводит к неверным решениям в обе стороны — люди боятся инициализировать клиенты SDK (а надо) и одновременно кэшируют в памяти то, что кэшировать нельзя.

Реальность: платформа держит среду исполнения (execution environment) — микро-ВМ с вашим кодом. Один инстанс обрабатывает ровно один запрос одновременно (в Lambda; в Cloud Run и Azure Functions возможна многозадачность внутри инстанса), но последовательно обслуживает тысячи запросов за свою жизнь. Между запросами среда замораживается: процессы останавливаются, а не убиваются.

Из состояния Frozen вытекает целый класс ошибок, которые невозможно поймать в юнит-тестах:

// ОШИБКА, встречается в каждом втором Node.js-проекте на Lambda
export const handler = async (event) => {
  const result = await processOrder(event);

  // «Метрику отправим фоном, чтобы не тормозить ответ»
  sendMetric(result);                    // промис никто не ждёт
  setTimeout(() => flushBuffer(), 100);  // таймер после return

  return { statusCode: 200, body: JSON.stringify(result) };
};
// Что произойдёт: среда замерзает сразу после return.
// В лучшем случае эти задачи выполнятся через 40 минут — внутри ЧУЖОГО вызова,
// добавив ему латентности и запутав трассировку. В худшем — не выполнятся никогда,
// и вы неделю ищете, почему теряется 3% метрик.

// ПРАВИЛЬНО: либо дождаться, либо отдать платформе явный примитив.
export const handler = async (event) => {
  const result = await processOrder(event);
  await Promise.allSettled([sendMetric(result), flushBuffer()]); // платим латентностью осознанно
  return { statusCode: 200, body: JSON.stringify(result) };
};

На краю сети у этой проблемы есть штатное решение — ctx.waitUntil(promise): платформа обещает продлить жизнь изолята до завершения промиса, но ответ пользователю уже ушёл. В Lambda аналог — Extensions API и Telemetry API, через который работают агенты Datadog и OpenTelemetry.

Что можно и что нельзя держать в глобальной области

Можно и нужно Нельзя
Клиенты SDK, HTTP-агенты с keep-alive Кэш пользовательских данных без TTL и без учёта размера
Скомпилированные регулярки, схемы валидации, ML-модель из /opt Счётчики и агрегаты, которые вы считаете «глобальными» — их N штук по числу инстансов
Секреты, прочитанные один раз (с TTL на ротацию) Состояние, критичное для корректности: инстанс исчезнет в любой момент
Пул соединений размера 1–2 Пул из 20 соединений — умножьте на конкурентность (см. §5)

Локальный кэш в глобальной области — законный и очень эффективный приём (он экономит round-trip к Redis), но помните: это N независимых кэшей без инвалидации между собой. Подробно про уровни кэша и инвалидацию — в статье «Кэширование и масштабирование».


3. Холодный старт: откуда берётся и сколько на самом деле стоит

Анатомия холодного старта FaaS

Холодный старт распадается на фазы, и оптимизировать их надо по-разному:

  1. Выборка и распаковка кода. Линейно зависит от размера артефакта. ZIP до 50 МБ (250 МБ в распакованном виде) или образ контейнера до 10 ГБ — образы Lambda загружает лениво, поблочно, поэтому гигантский образ не означает пропорционально долгий старт, но первый вызов после деплоя всё равно дороже.
  2. Старт среды исполнения. Здесь работает Firecracker — микро-ВМ, специально урезанная до пяти эмулируемых устройств. Согласно статье с NSDI 2020, она стартует меньше чем за 125 мс, добавляет ~5 МБ памяти на инстанс и позволяет запускать до 150 микро-ВМ в секунду на хосте. Эту фазу вы не контролируете.
  3. Ваш код инициализации. Всё, что выполняется вне обработчика: импорты, создание клиентов, чтение конфигурации, прогрев JIT. Это единственная фаза, которой вы управляете полностью, и в 90% случаев именно она виновата.
  4. Первый вызов обработчика.

Фазы 1–3 составляют INIT. У неё лимит 10 секунд, и во время неё функция получает полный vCPU независимо от настроенной памяти — приятный подарок, из-за которого «тяжёлая инициализация» дешевле, чем кажется. У обычных функций фаза INIT не тарифицируется; у provisioned concurrency и SnapStart — тарифицируется.

Сколько холодных стартов вы на самом деле увидите

Здесь полезна простая модель. Пусть система в установившемся режиме: интенсивность запросов λ (шт/с), средняя длительность d (с), время жизни инстанса L (с). По закону Литтла держится C = λ·d инстансов. Каждый живёт L секунд, значит новые создаются со скоростью C/L в секунду. Доля холодных вызовов:

доля_холодных = (C / L) / λ = (λ·d / L) / λ = d / L

Замечательный результат: доля холодных стартов не зависит от нагрузки — только от отношения длительности запроса к времени жизни инстанса. При d = 100 мс и L ≈ 45 мин это 0,1 / 2700 ≈ 0,004%, то есть 4 запроса на 100 000. Такие события не попадают ни в p99, ни даже в p999 — они попадают в жалобы конкретных пользователей и в ваши алерты по максимуму.

Настоящий источник холодных стартов — не установившийся режим, а изменение конкурентности. Скачок нагрузки с 10 до 500 одновременных запросов означает ровно 490 холодных стартов, спрессованных в несколько секунд. Поэтому:

  • если ваш трафик ровный — про холодный старт можно почти забыть;
  • если пилообразный (крон в 03:00, рассылка, распродажа) — это ваша главная проблема латентности;
  • если функция вызывается раз в час — каждый вызов холодный, и это худший из миров.

Инструменты борьбы

Инструмент Что делает Цена
Уменьшение бандла (tree-shaking, esbuild, модульные SDK v3) Сокращает фазы 1 и 3 Только время разработчика — начинайте отсюда
Больше памяти vCPU пропорционален памяти; ~1769 МБ ≈ 1 vCPU. Часто функция на 1024 МБ и быстрее, и дешевле функции на 256 МБ Требует замера (AWS Lambda Power Tuning)
Компилируемые языки (Go, Rust) или GraalVM native-image Убирает старт рантайма и JIT Другой стек, дольше сборка
SnapStart Снимок памяти уже инициализированной среды, восстановление вместо инициализации Ловушки уникальности: сохранённые сид ГПСЧ, открытые соединения и закэшированные токены «размножаются». Нужны хуки beforeCheckpoint / afterRestore
Provisioned concurrency Держит N готовых сред Почасовая оплата — вы вернулись к аренде, но с автоскейлингом сверху
Синтетический «прогрев» пингом Держит 1 инстанс тёплым Работает только для одного инстанса; при всплеске бесполезен. Считается антипаттерном

Отдельная историческая заметка: до 2019 года подключение Lambda к VPC добавляло 8–10 секунд на создание ENI. После перехода на Hyperplane ENI эта задержка стала субсекундной. Если вы читаете старую статью, где «Lambda в VPC — это боль», — она устарела.


4. Конкурентность и масштабирование: арифметика, которую надо сделать до деплоя

FaaS масштабируется «автоматически», но не «бесконечно» и не «мгновенно». Три числа определяют поведение системы.

Первое — требуемая конкурентность. Закон Литтла: C = λ · d. Тысяча запросов в секунду по 200 мс требует 200 одновременных сред. Если завтра латентность зависимости вырастет вдвое, вам потребуется 400 — замедление downstream автоматически удваивает вашу конкурентность и счёт, и это одна из самых недооценённых петель обратной связи в serverless.

Второе — лимит аккаунта. По умолчанию в регионе 1000 одновременных исполнений на все функции. Превышение — TooManyRequestsException (429), и её поведение зависит от источника: синхронный вызов вернёт ошибку клиенту, асинхронный будет ретраиться, событийный источник (SQS/Kafka) просто замедлится.

Третье — скорость нарастания. Lambda наращивает по 1000 сред каждые 10 секунд на каждую функцию (правило действует с ноября 2023 года; раньше буст был общим на регион). То есть за 10 секунд вы получаете максимум +1000 конкурентности — при мгновенном всплеске в 5000 первые секунды будут с троттлингом.

"""Предполётный расчёт для serverless-эндпоинта: конкурентность, холодные старты, деньги."""
from dataclasses import dataclass

# Тарифы AWS Lambda, x86, us-east-1 (проверяйте актуальные — они меняются).
PRICE_PER_REQUEST = 0.20 / 1_000_000     # $ за вызов
PRICE_PER_GB_SECOND = 0.0000166667       # $ за ГБ-секунду
ARM_DISCOUNT = 0.80                      # Graviton дешевле примерно на 20%


@dataclass
class Workload:
    rps: float                # средняя интенсивность, запросов в секунду
    peak_rps: float           # пик
    avg_duration_s: float     # средняя длительность вызова
    memory_mb: int            # настроенная память
    instance_lifetime_s: float = 2700.0   # эмпирика: инстанс живёт ~45 минут

    def concurrency(self, rps: float) -> float:
        """Закон Литтла: L = λ·W. Сколько сред занято одновременно."""
        return rps * self.avg_duration_s

    def cold_start_share(self) -> float:
        """Доля холодных вызовов в установившемся режиме: d / L."""
        return self.avg_duration_s / self.instance_lifetime_s

    def burst_seconds_to_scale(self) -> float:
        """Сколько секунд платформа будет догонять пик при шаге 1000 сред / 10 с."""
        delta = max(0.0, self.concurrency(self.peak_rps) - self.concurrency(self.rps))
        return delta / 100.0  # 1000 сред за 10 с = 100 сред в секунду

    def monthly_cost(self, arm: bool = False) -> float:
        seconds_in_month = 30 * 24 * 3600
        requests = self.rps * seconds_in_month
        # ВАЖНО: тарифицируется настроенная память, а не использованная.
        gb_seconds = requests * self.avg_duration_s * (self.memory_mb / 1024)
        cost = requests * PRICE_PER_REQUEST + gb_seconds * PRICE_PER_GB_SECOND
        return cost * (ARM_DISCOUNT if arm else 1.0)


api = Workload(rps=120, peak_rps=2000, avg_duration_s=0.15, memory_mb=512)
print(f"конкурентность средняя: {api.concurrency(api.rps):.0f}")        # 18
print(f"конкурентность на пике: {api.concurrency(api.peak_rps):.0f}")   # 300
print(f"доля холодных стартов: {api.cold_start_share() * 100:.4f}%")    # 0.0056%
print(f"догон пика: {api.burst_seconds_to_scale():.1f} с")             # 2.8 с
print(f"в месяц: ${api.monthly_cost():.2f} / ARM ${api.monthly_cost(arm=True):.2f}")

Сложность здесь тривиальна — O(1) арифметика, — но именно эти четыре числа отделяют «работает» от «в чёрную пятницу отдало 429».

Резервированная конкурентность работает в обе стороны. Установив функции лимит в 50, вы не только гарантируете ей 50 сред из общего пула, но и защищаете downstream: реляционная база за функцией физически не увидит больше 50 параллельных клиентов. Это самый дешёвый bulkhead из существующих — подробнее про изоляцию отсеками в статье «Устойчивость».


5. Состояние и данные: почему тысяча функций убивает базу

Модель «инстанс на запрос» вступает в прямой конфликт с моделью «пул соединений». Классическая катастрофа:

  • Postgres с max_connections = 200;
  • функция открывает 1 соединение при инициализации;
  • всплеск даёт 800 одновременных сред → 800 попыток соединиться;
  • база отказывает всем, включая старые здоровые сервисы, живущие рядом.

При этом установка TCP + TLS + аутентификация в Postgres — это 20–50 мс, которые вы платите на каждом холодном старте, а MySQL и Postgres тратят на соединение по несколько мегабайт памяти сервера.

Прагматичный порядок предпочтений: для нового serverless-проекта берите хранилище, у которого протокол — HTTP, а не долгоживущий TCP-сокет. Если реляционная БД обязательна — ставьте прокси-пулер (RDS Proxy, PgBouncer в режиме transaction) и ограничивайте reserved concurrency. Это не «оптимизация на потом»: без этого система разваливается ровно в момент успеха.

Рабочий пример: идемпотентный обработчик очереди

Самая частая serverless-задача — не HTTP API, а обработка потока событий. Здесь всё сходится: батчи, ретраи, дубликаты, ядовитые сообщения.

"""Обработчик SQS на Lambda: батч, частичные отказы, идемпотентность.

Инвариант: сообщение может прийти несколько раз (at-least-once — см. статью
о событийной архитектуре), поэтому эффект применяется ровно один раз через
условную запись в DynamoDB.
"""
import os
import json
import time
import logging

import boto3
from botocore.config import Config
from botocore.exceptions import ClientError

log = logging.getLogger()
log.setLevel(logging.INFO)

# --- Зона инициализации: выполняется один раз на инстанс, не тарифицируется. ---
# Таймауты обязаны быть заметно меньше таймаута функции, иначе платформа убьёт
# вызов посреди ретрая boto3 и вы не увидите ни ошибки, ни трассировки.
_cfg = Config(
    connect_timeout=1,
    read_timeout=3,
    retries={"max_attempts": 2, "mode": "standard"},
    max_pool_connections=10,
)
_ddb = boto3.resource("dynamodb", config=_cfg)
_inbox = _ddb.Table(os.environ["INBOX_TABLE"])    # таблица обработанных id, с TTL
_orders = _ddb.Table(os.environ["ORDERS_TABLE"])

DEDUP_TTL_DAYS = 7


def _claim(message_id: str) -> bool:
    """Пытаемся застолбить сообщение. False — уже обрабатывали, это дубликат.

    Условная запись атомарна на стороне БД: гонка двух сред невозможна.
    Сложность: O(1), одна запись, ~5 мс.
    """
    try:
        _inbox.put_item(
            Item={
                "message_id": message_id,
                "processed_at": int(time.time()),
                "expires_at": int(time.time()) + DEDUP_TTL_DAYS * 86400,
            },
            ConditionExpression="attribute_not_exists(message_id)",
        )
        return True
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return False
        raise


def _apply(payload: dict) -> None:
    """Бизнес-эффект. Держим его максимально коротким и без внешних вызовов
    с непредсказуемой латентностью — они умножаются на конкурентность."""
    _orders.update_item(
        Key={"order_id": payload["order_id"]},
        UpdateExpression="SET #s = :s, updated_at = :t",
        ExpressionAttributeNames={"#s": "status"},
        ExpressionAttributeValues={":s": payload["status"], ":t": int(time.time())},
    )


def handler(event, context):
    """Возвращаем batchItemFailures — частичные отказы.

    Без этого одно ядовитое сообщение из десяти возвращает в очередь ВЕСЬ батч,
    и девять здоровых сообщений обрабатываются повторно на каждом круге ретраев:
    O(N) лишней работы на каждую отравленную запись.
    Требуется включить ReportBatchItemFailures на event source mapping.
    """
    failures = []

    for record in event["Records"]:
        message_id = record["messageId"]
        try:
            # Запас времени: не начинаем новое сообщение, если осталось < 2 с.
            if context.get_remaining_time_in_millis() < 2000:
                failures.append({"itemIdentifier": message_id})
                continue

            body = json.loads(record["body"])

            if not _claim(message_id):
                log.info("дубликат %s — пропускаем, но подтверждаем", message_id)
                continue      # НЕ ошибка: сообщение считается успешно обработанным

            _apply(body)

        except Exception:
            # Логируем с идентификатором и отдаём на ретрай только этот элемент.
            log.exception("сбой на сообщении %s", message_id)
            failures.append({"itemIdentifier": message_id})

    return {"batchItemFailures": failures}

Три детали, которые отличают продакшн от примера из документации: проверка get_remaining_time_in_millis() (иначе таймаут функции породит дубликаты в середине батча), «дубликат — это успех, а не ошибка», и обязательная настройка DLQ на event source mapping — без неё ядовитое сообщение блокирует свою партицию до истечения retention.

Про outbox, inbox и общую дисциплину идемпотентности подробно написано в статье «Saga, распределённые транзакции, outbox и идемпотентность» — serverless ничего здесь не меняет, только повышает вероятность повторов.


6. Edge: код в двадцати миллисекундах от пользователя

Скорость света в оптоволокне — примерно 200 000 км/с. Москва — Франкфурт по кабелю это ~1900 км, туда-обратно ~19 мс в идеале, а с маршрутизацией и TLS-хендшейком реальный RTT легко превращается в 60–100 мс. Никакая оптимизация кода это не исправит: физику побеждают только географией. Отсюда идея edge — исполнять логику в точке присутствия рядом с пользователем.

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

Три модели изоляции: изолят, микро-ВМ, контейнер

Изолят — это отдельный контекст внутри одного процесса V8: свой heap, свои глобальные объекты, но общая виртуальная машина. Старт — единицы миллисекунд, потому что запускать нечего: движок уже работает, создаётся только новый контекст. Цена — вы живёте в песочнице JS/WASM: нет своих процессов, нет файловой системы, нет сырых TCP-сокетов, есть жёсткие лимиты CPU-времени. Инженерное обоснование модели — в тексте Кентона Варды «Cloud Computing without Containers».

Платформа Изоляция Холодный старт Где выполняется Ограничения
AWS Lambda микро-ВМ Firecracker 100–400 мс (JVM — секунды) один регион 15 мин, до 10 ГБ RAM, 6 МБ payload
Lambda@Edge микро-ВМ сотни мс региональные кэши CloudFront 5 с (viewer) / 30 с (origin), Node и Python
CloudFront Functions процесс-песочница < 1 мс все точки присутствия подмножество ES 5.1, 2 МБ памяти, 10 КБ кода, нет сети и тела запроса
Cloudflare Workers V8-изолят ~5 мс 300+ городов лимит CPU-времени (не wall-clock), 128 МБ, только JS/TS/WASM
Deno Deploy V8-изолят ~5 мс глобально Web-API, без Node-специфики
Vercel / Netlify Edge Functions V8-изолят ~5 мс глобально стриминг, урезанный рантайм
Cloud Run контейнер 0,5–10 с регион масштаб до нуля, любой Linux

Ключевое различие в биллинге, которое многие пропускают: edge-платформы на изолятах обычно тарифицируют процессорное время, а не время ожидания. Функция, которая 500 мс ждала ответ от API, потратила ~1 мс CPU — и заплатите вы за 1 мс. В Lambda вы платите за все 500 мс. Для «тонкой» логики поверх сети это разница на порядок.

Рабочий пример: edge-функция с кэшем, гео-логикой и деградацией

// Cloudflare Worker: кэширование ответа на краю, гео-маршрутизация,
// stale-while-revalidate и фоновая аналитика через waitUntil.

export interface Env {
  FLAGS: KVNamespace;        // фича-флаги, реплицируются глобально (eventual)
  ORIGIN: string;            // адрес origin-сервиса в регионе
  ANALYTICS: Queue<Event>;
}

const EDGE_TTL_SECONDS = 60;

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);

    // 1. Дёшево отсекаем то, что нельзя кэшировать — до любых сетевых вызовов.
    if (request.method !== "GET") {
      return fetch(new Request(env.ORIGIN + url.pathname, request));
    }

    // 2. Ключ кэша ДОЛЖЕН включать всё, от чего зависит ответ.
    //    Забытая страна в ключе — это утечка данных между регионами.
    const country = (request.cf?.country as string) ?? "XX";
    const cacheKey = new Request(`${url.origin}${url.pathname}?c=${country}`, request);
    const cache = caches.default;

    const cached = await cache.match(cacheKey);
    if (cached) {
      const age = Number(cached.headers.get("age") ?? 0);
      if (age < EDGE_TTL_SECONDS) return cached;

      // Отдаём протухшее сразу, обновляем фоном: p99 не страдает от промаха.
      ctx.waitUntil(revalidate(cacheKey, env, cache));
      return cached;
    }

    // 3. KV-чтение реплицировано в точку присутствия: единицы мс, но данные
    //    могут отставать до минуты после записи. Для флагов это приемлемо,
    //    для баланса пользователя — нет.
    const rolloutRaw = await env.FLAGS.get("new_pricing_rollout", { cacheTtl: 300 });
    const rollout = Number(rolloutRaw ?? 0);

    const origin = new URL(env.ORIGIN + url.pathname);
    origin.searchParams.set("variant", hashBucket(cacheKey.url) < rollout ? "b" : "a");

    let response: Response;
    try {
      response = await fetch(origin.toString(), {
        headers: { "cf-country": country },
        signal: AbortSignal.timeout(2000),   // жёсткий бюджет на origin
      });
    } catch {
      // 4. Деградация: лучше устаревший ответ, чем 502.
      const stale = await cache.match(cacheKey, { ignoreMethod: true });
      return stale ?? new Response("service degraded", { status: 503 });
    }

    if (response.ok) {
      const toCache = new Response(response.body, response);
      toCache.headers.set("cache-control", `public, max-age=${EDGE_TTL_SECONDS}`);
      ctx.waitUntil(cache.put(cacheKey, toCache.clone()));
      response = toCache;
    }

    // 5. Аналитика не должна задерживать ответ — но и не должна потеряться.
    ctx.waitUntil(env.ANALYTICS.send({ path: url.pathname, country, ts: Date.now() }));
    return response;
  },
} satisfies ExportedHandler<Env>;

async function revalidate(key: Request, env: Env, cache: Cache): Promise<void> {
  const fresh = await fetch(env.ORIGIN + new URL(key.url).pathname);
  if (fresh.ok) await cache.put(key, fresh);
}

function hashBucket(s: string): number {
  let h = 2166136261;                       // FNV-1a, детерминированное разбиение
  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
  return Math.abs(h) % 100;
}

Обратите внимание на строку с ключом кэша. Самая опасная ошибка на краю сети — неполный cache key: если ответ зависит от заголовка Authorization или от страны, а ключ этого не учитывает, вы раздадите данные одного пользователя другому. Это не гипотетика, а регулярный класс инцидентов.

Про типы кэша, инвалидацию и stale-while-revalidate подробнее — в статье «Кэширование и масштабирование»; про таймауты и деградацию — в «Устойчивости». Типизация Worker’а опирается на строгий TypeScript — см. трек TypeScript.

Путь запроса и три модели данных на краю

На краю сети есть ровно три способа обращаться с состоянием, и выбор между ними — главное архитектурное решение edge-приложения:

  1. Глобально реплицированное чтение (Workers KV, конфигурация, фича-флаги, статические справочники). Чтение — единицы миллисекунд из локальной реплики, запись распространяется секунды-минуты. Годится для всего, что читают в миллион раз чаще, чем пишут, и где отставание безвредно.
  2. Закреплённая точка согласованности (Durable Objects, Fly.io-подобные модели). Для каждого ключа существует ровно один однопоточный экземпляр в конкретной локации. Это даёт настоящие транзакции и координацию (счётчики, лимитеры, комнаты чата, блокировки) ценой того, что пользователи из других регионов ходят до него по сети. По сути — акторная модель, и её стоит читать в паре с треком Elixir.
  3. Регион-источник истины. Всё, что требует ACID и целостности — деньги, заказы, права доступа — остаётся в одном регионе. Край делает только то, что физически обязано быть рядом с пользователем: TLS-терминация, кэш, аутентификация по подписи токена, A/B, редиректы, склейка ответов.

Классическая ошибка — попытка «раскатать всю базу на край» через глобальную репликацию с eventual-согласованностью. Через полгода приходит первый баг вида «я оплатил, а корзина всё ещё старая», и его нельзя починить — он встроен в схему. Держите на краю то, что read-mostly и не критично к отставанию.


7. Экономика: где точка перелома

Serverless дешевле «почти бесплатного» и дороже «постоянно занятого». Посчитаем на конкретных числах.

Функция: 512 МБ, средняя длительность 100 мс. Стоимость одного вызова = 0,5 ГБ × 0,1 с × $0,0000166667 + $0,0000002 = $0,00000103. То есть ≈ 1,03 $ за миллион запросов.

Нагрузка Запросов в месяц Lambda Эквивалент на контейнерах
1 запрос/с 2,6 млн 2,7 $ ~30 $ (минимум 1 задача Fargate 24/7)
10 запросов/с 26 млн 27 $ ~60 $ (2 задачи для отказоустойчивости)
100 запросов/с 260 млн 267 $ ~288 $ — примерный паритет
1000 запросов/с 2,6 млрд 2 670 $ ~290 $ (8 vCPU на асинхронном рантайме)

Точка перелома: $288 / $1,03 за млн ≈ 280 млн запросов в месяц ≈ 108 запросов в секунду в среднем. Ниже неё serverless дешевле и заметно проще; выше — платите порядок величины премии за модель.

Два уточнения, без которых таблица врёт:

  • В пользу serverless. Столбец «контейнеры» не содержит зарплаты. Кластер Kubernetes — это дежурства, обновления, capacity planning, тюнинг HPA. Инженер стоит дороже, чем разница в 2 400 $ в месяц. Пока команда маленькая, «дорогой» serverless остаётся дешевле в сумме.
  • Против serverless. Стоимость самих функций — часто меньшая часть счёта. Реальные строки: API Gateway (1,00 $–3,50 за миллион запросов — дороже самих вызовов; Function URL или ALB кратно дешевле), CloudWatch Logs (~0,50 $ за ГБ приёма — при подробном логировании легко обгоняет compute), NAT Gateway (0,045 $ за ГБ за трафик из VPC), исходящий трафик, X-Ray, Step Functions (за переход состояния).

Тарифицируется настроенная память, а не использованная. Функция с memory=3008, реально потребляющая 200 МБ, оплачивается по 3008 МБ. Одновременно уменьшать память до предела вредно: vCPU пропорционален памяти, и функция на 256 МБ может выполняться в четыре раза дольше, чем на 1024 МБ, — итоговый счёт вырастет. Это оптимизационная задача с минимумом внутри диапазона; решается замером на реальном трафике, а не рассуждением. И почти всегда стоит переключить архитектуру на ARM/Graviton: те же ~20% скидки на GB-секунду за одну строчку в конфигурации.


8. Как проектировать serverless-приложение

Гранулярность: «одна функция на маршрут» против «Lambdalith»

Ранняя мода на FaaS требовала одной функции на каждый HTTP-эндпоинт. Практика показала, что у этого высокая цена: сорок артефактов деплоя, сорок наборов прав IAM, сорок конфигураций, размазанная общая логика и сорок независимо остывающих инстансов (каждый со своей долей холодных стартов).

Противоположный подход — Lambdalith: целое приложение (Express, FastAPI, ASP.NET) внутри одной функции, роутинг внутри. Плюсы: привычная разработка и локальный запуск, один деплой, лучше «прогрев» (весь трафик греет один пул), простая миграция существующего сервиса. Минусы: права IAM по объединению всех маршрутов (нарушение принципа наименьших привилегий), общая настройка памяти и таймаута, взрывной радиус деплоя.

Практический компромисс, к которому пришла индустрия, — группировка по границе домена и профилю ресурса: одна функция на ограниченный контекст или на группу маршрутов со схожими требованиями (память, таймаут, права). Отдельные функции выделяются тогда, когда у них принципиально иной профиль: обработка загруженного видео (3 ГБ памяти, 10 минут), вебхук от платёжной системы (жёсткий SLA, свои права), крон-отчёт. Это ровно та же логика, что и в статье «Монолит и модульный монолит», просто в другом масштабе.

Оркестрация вместо кода-склейки

Как только процесс состоит более чем из двух шагов с ретраями и компенсацией, писать его императивно внутри функции становится опасно: 15-минутный лимит, отсутствие персистентного состояния между вызовами и необходимость самому реализовывать откат. Здесь на месте оркестраторы состояния — AWS Step Functions, Azure Durable Functions, Temporal: состояние машины персистентно, ретраи и таймауты декларативны, история выполнения видна как данные. Это буквально реализация паттерна оркестрируемой саги из статьи «Saga».

Правило большого пальца: логику — в функции, координацию — в машину состояний. Функция, которая ждёт другую функцию, — почти всегда ошибка (вы платите за обе одновременно, и первая может умереть по таймауту в момент ретрая второй).

Куда это вообще подходит

Правый верхний квадрант — событийная, рваная, короткая работа без состояния — это то, ради чего FaaS придумали. Левый нижний — долгие stateful процессы при ровной нагрузке — область, где serverless проигрывает по всем осям сразу.


9. Эксплуатация: наблюдаемость, тестирование, деплой

Наблюдаемость. Привычная модель «поставим агента, он будет собирать метрики фоном» не работает: фоновых потоков нет, среда замораживается. Отсюда следствия:

  • логи структурированные (JSON) и обязательно с requestId и корреляционным идентификатором из события — без них связать цепочку из шести функций невозможно;
  • метрики отправляются либо синхронно в конце вызова, либо через Embedded Metric Format (пишете специальный JSON в stdout — CloudWatch извлекает из него метрики, стоимость нулевая), либо через Lambda Extension;
  • трассировка обязательна, а не желательна: в системе из двадцати функций без распределённого трейса вы не ответите даже на вопрос «где потерялось 400 мс». Стандарт — OpenTelemetry, в AWS — ADOT-слой или X-Ray;
  • ключевые метрики FaaS, которых нет у обычного сервиса: Throttles (упёрлись в лимит конкурентности), ConcurrentExecutions, IteratorAge (насколько потребитель отстал от потока), доля InitDuration и глубина DLQ. Алертить надо именно по ним, а не только по Errors.

Тестирование. Серебряной пули нет, есть работающая пирамида:

  • бизнес-логика вынесена из обработчика в чистые функции и тестируется обычными юнит-тестами без всякого облака (см. гексагональную архитектуру — адаптер handler тонкий, домен не знает про Lambda);
  • интеграционные тесты — против эмуляторов (LocalStack, DynamoDB Local, wrangler dev/miniflare, Azurite), помня что эмулятор всегда врёт в деталях прав доступа и лимитов;
  • контрактные и приёмочные тесты — против настоящего облака в отдельном аккаунте. В serverless больше половины поведения системы описана не кодом, а конфигурацией (IAM, триггеры, таймауты, конкурентность), и локально её проверить нечем.

Деплой. Инфраструктура только как код (AWS SAM, CDK, Terraform, Serverless Framework, Wrangler): руками собранная функция невоспроизводима. Версии и алиасы позволяют делать взвешенный canary (10% на новую версию) с автооткатом по CloudWatch-алярму — это самая дешёвая канареечная поставка из существующих. Отдельно: лимит конкурентности — тоже часть контракта деплоя, и его надо ревьюить наравне с кодом.


10. Типичные ошибки

  1. Фоновая работа после return. Среда заморожена; промис не завершится. Либо await, либо waitUntil, либо расширение.
  2. Пул соединений размера 20 в функции. Умножьте на конкурентность и посмотрите на max_connections базы. Нужен прокси-пулер и reserved concurrency.
  3. Тяжёлая инициализация в обработчике вместо глобальной области. Вы платите за создание клиента SDK на каждом вызове вместо одного раза на инстанс.
  4. Отсутствие reserved concurrency у функции, ходящей во внешний API. Всплеск трафика превращается в DDoS на партнёра, а вас — в нарушителя SLA.
  5. Батч без ReportBatchItemFailures. Одно ядовитое сообщение заставляет переобрабатывать весь батч бесконечно.
  6. Таймаут функции больше видимого таймаута очереди (visibility timeout). Сообщение станет видимым снова, пока первая функция ещё работает, — гарантированные дубликаты. Практика: visibility timeout ≥ 6 × timeout функции.
  7. Неполный ключ кэша на краю. Забытая страна, язык или Authorization — утечка данных между пользователями.
  8. Синхронная цепочка функция → функция → функция. Вы платите за все звенья одновременно, а таймауты и ретраи перемножаются. Между звеньями должна стоять очередь или машина состояний.
  9. Ретраи без идемпотентности. Платформа ретраит асинхронные вызовы автоматически (Lambda — дважды по умолчанию). Без ключа идемпотентности это трижды списанные деньги.
  10. Логирование всего тела запроса на уровне INFO. При миллиарде вызовов CloudWatch Logs становится крупнейшей строкой облачного счёта.
  11. Попытка держать WebSocket или SSE в самой функции. Долгие соединения держит шлюз (API Gateway WebSocket API, Durable Objects), функция обрабатывает отдельные сообщения.
  12. «Прогрев» пингом как решение проблемы холодного старта. Греет один инстанс из требуемых трёхсот; создаёт иллюзию контроля.
  13. Игнорирование лимита 15 минут при проектировании. Задача, которая сегодня занимает 9 минут, через год займёт 20 — нужен либо чанкинг с чекпоинтами, либо контейнер.

11. Как это применяют в проде

Что работает отлично.

  • Обработка загрузок и медиа. Файл падает в объектное хранилище → событие → функция делает превью, извлекает метаданные, кладёт результат. Нагрузка рваная по определению, задача идеально параллельна, простой между загрузками бесплатен.
  • Клей между управляемыми сервисами. Приём вебхуков, преобразование форматов, маршрутизация событий, реакции на изменения в БД через CDC. Это тот случай, когда функция — просто «выражение», а не «сервис».
  • Периодические задачи. Крон-отчёты, синхронизации, чистки. Держать ради них контейнер 24/7 — чистая потеря денег.
  • Fan-out обработка данных. Разбить задачу на 5000 кусков и запустить 5000 функций на минуту — сценарий, где serverless бьёт любой кластер по времени до результата и по совокупной стоимости. Так делают рендеринг, транскодирование, бэктесты, массовую валидацию.
  • Edge-логика перед основным приложением. Аутентификация по подписи JWT, A/B-разбиение, редиректы, гео-персонализация, WAF-правила, сборка HTML из фрагментов. Классический пример — Cloudflare Workers в основе Shopify Oxygen и подобных платформ.
  • Пики, которые невозможно спрогнозировать. Госуслуги в дедлайн подачи, продажа билетов на концерт, эфирная реклама. Здесь ценность «мгновенного» масштабирования измеряется не деньгами, а не-упавшим сервисом.

Что переносят обратно на контейнеры. Самый известный публичный разбор — пост команды Amazon Prime Video (2023) о переносе сервиса мониторинга качества видео со Step Functions + Lambda в один монолитный процесс на ECS с сокращением стоимости на 90%. Причина не в том, что «serverless плохой»: их нагрузка была постоянной, высокочастотной и с интенсивной передачей кадров между шагами через S3. Каждый переход состояния стоил денег, каждый кадр — записи в объектное хранилище. Урок ровно тот, что в таблице §7: при ровной высокой нагрузке и большом объёме данных между шагами модель «оплата за вызов» перестаёт окупаться. Аналогичный вывод приводили в своих разборах затрат и другие команды.

Гибрид как норма. Здоровая production-система редко бывает «полностью serverless». Типичная зрелая конфигурация: постоянный высоконагруженный API — на контейнерах; рваные события, вебхуки, крон, обработка файлов — на функциях; кэш, аутентификация и маршрутизация — на краю сети; данные — в управляемых сервисах. Выбор делается для каждой нагрузки отдельно, по тем же четырём числам из §4 и по таблице из §7.


Мини-итог

  • Serverless = масштаб до нуля + оплата за потребление + переданная операционная ответственность. Нет одного из трёх — это управляемый сервис, а не serverless.
  • Инстанс живёт долго и замораживается между вызовами. Глобальная область — ваш друг для клиентов и кэшей и враг для фоновых задач и «глобальных» счётчиков.
  • Холодный старт в основном находится в вашем коде инициализации. Доля холодных вызовов в стабильном режиме ≈ длительность / время жизни инстанса — доли процента; настоящая боль возникает при скачках конкурентности.
  • C = λ · d. Замедление зависимости линейно увеличивает конкурентность и счёт. Reserved concurrency — одновременно лимит и bulkhead для downstream.
  • Долгоживущие TCP-соединения конфликтуют с моделью. Прокси-пулер, HTTP-протоколы к БД или очередь перед записью — обязательны, а не опциональны.
  • Край сети побеждает физику, но платит изоляцией. V8-изолят стартует за 5 мс и тарифицирует CPU, а не ожидание, — ценой отсутствия POSIX. Состояние на краю: реплицированное чтение, закреплённая точка согласованности или регион-источник истины.
  • Экономика ломается примерно на 100 запросах в секунду постоянной нагрузки. Ниже — serverless дешевле и проще; выше — премия достигает порядка величины. Считайте вместе с API Gateway, логами и NAT.
  • Гранулярность — по домену и профилю ресурса, не «функция на маршрут» и не один монолит. Координацию отдавайте машине состояний.

Источники


Что дальше

Serverless и edge — это не «лучше» и не «хуже» контейнеров: это набор компромиссов с очень острыми краями. Стоимость против латентности, скорость поставки против свободы, масштабирование против привязки к вендору. Мы весь трек принимали такие решения — пора научиться принимать их системно: фиксировать контекст и альтернативы, оценивать архитектуру по сценариям качества и оставлять после себя запись, которую поймут через три года.

Читайте дальше: «Архитектурные решения: ADR, trade-offs, ATAM, эволюционная архитектура».

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

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

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

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