Serverless и edge-архитектуры
Слово «serverless» — худшее название в истории индустрии. Серверы есть, их просто не видно; вы по-прежнему думаете про память, таймауты, конкурентность и сетевые границы, только теперь через чужие абстракции. Но за плохим названием стоит настоящий архитектурный сдвиг: единицей развёртывания и биллинга становится не машина и не контейнер, а вызов. Это меняет экономику, меняет модель отказа и — что важнее — меняет то, какой код вы вообще имеете право писать.
Edge-архитектуры доводят ту же идею до географического предела: код исполняется не «в регионе eu-central-1», а в сотне-другой точек присутствия в двадцати миллисекундах от пользователя. И здесь ограничения ещё жёстче: нет диска, нет своих процессов, нет привычной базы данных под боком.
Эта статья — про механику. Мы разберём, что физически происходит при первом вызове функции, почему JVM стартует шесть секунд, а V8-изолят пять миллисекунд, как посчитать нужную конкурентность законом Литтла, почему тысяча одновременных Lambda убивает Postgres, где на краю сети живёт состояние, и в какой точке график стоимости FaaS пересекает график стоимости всегда-включённых контейнеров. И отдельно — про случаи, когда правильный ответ на вопрос «делать ли serverless» звучит «нет».
Предполагается знакомство со статьями «Микросервисы» (границы сервисов, эксплуатация), «Событийная архитектура» (топики, гарантии доставки) и «Saga, распределённые транзакции, outbox и идемпотентность» — идемпотентность дальше используется как известное понятие.
1. Что такое serverless на самом деле
Отбросим маркетинг. Платформа заслуживает названия serverless, если выполняются три свойства одновременно:
- Масштабирование до нуля. Когда трафика нет, не работает ничего и вы не платите ни цента. Это отличает FaaS от «управляемых контейнеров», где у вас всегда крутится минимум один инстанс.
- Тарификация по фактическому потреблению. Единица счёта — запрос и гигабайт-секунда, а не час аренды виртуалки. Простой бесплатен, пик оплачивается.
- Передача операционной ответственности. Вы не патчите ОС, не настраиваете автоскейлер, не думаете про размещение по зонам доступности. Взамен вы не можете это и настроить.
Если платформа даёт только третье свойство — это «управляемый сервис», а не serverless. Полезная формулировка есть у Мартина Фаулера и Майка Робертса в «Serverless Architectures»: serverless — это объединение BaaS (готовые сервисы вместо своего кода) и FaaS (эфемерные функции по событию).
Спектр, а не переключатель
«Serverless или нет» — ложная дихотомия. Между голой виртуалкой и edge-функцией лежит континуум, и каждый шаг вверх отдаёт вам скорость запуска и дешевизну простоя в обмен на свободу.
EC2, GCE
старт: минуты, платите за часы"] K8S["Kubernetes
вы держите узлы
старт пода: секунды"] end subgraph S2["Вы управляете процессом"] CAAS["Контейнеры как сервис
Fargate, Cloud Run
масштаб до нуля — иногда"] end subgraph S3["Вы управляете функцией"] FAAS["FaaS в регионе
Lambda, Cloud Functions
микро-ВМ, старт 100–400 мс"] EDGE["Edge-функции
Workers, Deno Deploy
изоляты, старт ~5 мс"] CDNF["Функции в CDN
CloudFront Functions
суб-миллисекунда, урезанный JS"] end VM --> K8S --> CAAS --> FAAS --> EDGE --> CDNF CDNF -.->|"нужен POSIX,
долгие процессы,
GPU"| VM style FAAS stroke:#5aa469,stroke-width:2px style EDGE stroke:#4a90d9,stroke-width:2px style CDNF stroke:#c98a2b,stroke-width:2px
Правило, к которому сводится весь дальнейший текст: чем выше по этой лестнице, тем короче и «тупее» должен быть ваш код. Наверху лестницы живут маршрутизация, преобразование и валидация; внизу — всё остальное.
2. Жизненный цикл инстанса: заморозка, которая ломает интуицию
Главное недопонимание FaaS звучит так: «на каждый запрос поднимается новый процесс». Это неверно и приводит к неверным решениям в обе стороны — люди боятся инициализировать клиенты SDK (а надо) и одновременно кэшируют в памяти то, что кэшировать нельзя.
Реальность: платформа держит среду исполнения (execution environment) — микро-ВМ с вашим кодом. Один инстанс обрабатывает ровно один запрос одновременно (в Lambda; в Cloud Run и Azure Functions возможна многозадачность внутри инстанса), но последовательно обслуживает тысячи запросов за свою жизнь. Между запросами среда замораживается: процессы останавливаются, а не убиваются.
свободных инстансов нет Provisioning --> Initializing: микро-ВМ поднята Initializing --> Active: выполнен код вне обработчика Initializing --> [*]: ошибка INIT
(лимит 10 с) Active --> Frozen: обработчик вернул ответ Frozen --> Active: пришёл следующий запрос
(тёплый вызов, 0 мс) Frozen --> ShuttingDown: простой ~5–15 минут
или деплой новой версии ShuttingDown --> [*]: сигнал SIGTERM,
у расширений есть секунды Active --> Active: последовательные вызовы,
глобальное состояние сохраняется note right of Frozen Часы остановлены. Таймеры, фоновые потоки, неотправленные буферы НЕ выполняются. end note
Из состояния 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. Холодный старт: откуда берётся и сколько на самом деле стоит
Холодный старт распадается на фазы, и оптимизировать их надо по-разному:
- Выборка и распаковка кода. Линейно зависит от размера артефакта. ZIP до 50 МБ (250 МБ в распакованном виде) или образ контейнера до 10 ГБ — образы Lambda загружает лениво, поблочно, поэтому гигантский образ не означает пропорционально долгий старт, но первый вызов после деплоя всё равно дороже.
- Старт среды исполнения. Здесь работает Firecracker — микро-ВМ, специально урезанная до пяти эмулируемых устройств. Согласно статье с NSDI 2020, она стартует меньше чем за 125 мс, добавляет ~5 МБ памяти на инстанс и позволяет запускать до 150 микро-ВМ в секунду на хосте. Эту фазу вы не контролируете.
- Ваш код инициализации. Всё, что выполняется вне обработчика: импорты, создание клиентов, чтение конфигурации, прогрев JIT. Это единственная фаза, которой вы управляете полностью, и в 90% случаев именно она виновата.
- Первый вызов обработчика.
Фазы 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 тратят на соединение по несколько мегабайт памяти сервера.
(0 → 800 за 8 секунд)"] -->|"по 1 соединению"| X{"Как соединяться
с состоянием?"} X -->|"Прокси-пулер"| P["RDS Proxy / PgBouncer
мультиплексирует N → 20
+1–3 мс латентности"] X -->|"HTTP вместо TCP"| H["Data API, Neon,
PlanetScale, D1
соединений нет вообще"] X -->|"Хранилище без соединений"| K["DynamoDB, S3, Redis
HTTP/REST, безлимитный параллелизм"] X -->|"Не соединяться"| Q["Очередь между функцией
и базой + батчинг"] P --> DB[("Реляционная БД")] H --> DB Q --> W["Функция-писатель
reserved concurrency = 10"] --> DB style H stroke:#5aa469,stroke-width:2px style K stroke:#5aa469,stroke-width:2px style P stroke:#c98a2b,stroke-width:2px
Прагматичный порядок предпочтений: для нового 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.
Путь запроса и три модели данных на краю
однопоточный, строгая согласованность DO-->>PoP: новое значение PoP->>O: fetch с бюджетом 2 с O->>DB: SELECT / UPDATE DB-->>O: строки O-->>PoP: 200 PoP-->>U: 200 (~60 мс) + запись в кэш фоном end PoP--)PoP: waitUntil: аналитика, ревалидация
На краю сети есть ровно три способа обращаться с состоянием, и выбор между ними — главное архитектурное решение edge-приложения:
- Глобально реплицированное чтение (Workers KV, конфигурация, фича-флаги, статические справочники). Чтение — единицы миллисекунд из локальной реплики, запись распространяется секунды-минуты. Годится для всего, что читают в миллион раз чаще, чем пишут, и где отставание безвредно.
- Закреплённая точка согласованности (Durable Objects, Fly.io-подобные модели). Для каждого ключа существует ровно один однопоточный экземпляр в конкретной локации. Это даёт настоящие транзакции и координацию (счётчики, лимитеры, комнаты чата, блокировки) ценой того, что пользователи из других регионов ходят до него по сети. По сути — акторная модель, и её стоит читать в паре с треком Elixir.
- Регион-источник истины. Всё, что требует 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. Типичные ошибки
- Фоновая работа после
return. Среда заморожена; промис не завершится. Либоawait, либоwaitUntil, либо расширение. - Пул соединений размера 20 в функции. Умножьте на конкурентность и посмотрите на
max_connectionsбазы. Нужен прокси-пулер и reserved concurrency. - Тяжёлая инициализация в обработчике вместо глобальной области. Вы платите за создание клиента SDK на каждом вызове вместо одного раза на инстанс.
- Отсутствие reserved concurrency у функции, ходящей во внешний API. Всплеск трафика превращается в DDoS на партнёра, а вас — в нарушителя SLA.
- Батч без
ReportBatchItemFailures. Одно ядовитое сообщение заставляет переобрабатывать весь батч бесконечно. - Таймаут функции больше видимого таймаута очереди (visibility timeout). Сообщение станет видимым снова, пока первая функция ещё работает, — гарантированные дубликаты. Практика: visibility timeout ≥ 6 × timeout функции.
- Неполный ключ кэша на краю. Забытая страна, язык или
Authorization— утечка данных между пользователями. - Синхронная цепочка функция → функция → функция. Вы платите за все звенья одновременно, а таймауты и ретраи перемножаются. Между звеньями должна стоять очередь или машина состояний.
- Ретраи без идемпотентности. Платформа ретраит асинхронные вызовы автоматически (Lambda — дважды по умолчанию). Без ключа идемпотентности это трижды списанные деньги.
- Логирование всего тела запроса на уровне INFO. При миллиарде вызовов CloudWatch Logs становится крупнейшей строкой облачного счёта.
- Попытка держать WebSocket или SSE в самой функции. Долгие соединения держит шлюз (API Gateway WebSocket API, Durable Objects), функция обрабатывает отдельные сообщения.
- «Прогрев» пингом как решение проблемы холодного старта. Греет один инстанс из требуемых трёхсот; создаёт иллюзию контроля.
- Игнорирование лимита 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.
- Гранулярность — по домену и профилю ресурса, не «функция на маршрут» и не один монолит. Координацию отдавайте машине состояний.
Источники
- Martin Fowler, Mike Roberts. Serverless Architectures — базовое определение и границы применимости.
- Alexandru Agache et al. Firecracker: Lightweight Virtualization for Serverless Applications, NSDI 2020 — как устроена изоляция в Lambda.
- Kenton Varda. Cloud Computing without Containers — обоснование модели изолятов.
- Eric Jonas et al. Cloud Programming Simplified: A Berkeley View on Serverless Computing, 2019 — академический разбор ограничений (состояние, сеть, координация).
- Joe Emison, Yan Cui и др. Serverless Architectures on AWS, 2nd ed. (Manning) и блог Яна Цуя theburningmonk.com — лучшая практическая база по эксплуатации Lambda.
- AWS Lambda Operator Guide — официальный свод по конкурентности, холодным стартам и лимитам.
- AWS Lambda SnapStart и Lambda Power Tuning.
- Cloudflare Workers docs и Durable Objects — модель edge-вычислений и состояния на краю.
- Prime Video Tech: Scaling up the Prime Video audio/video monitoring service and reducing costs by 90% — честный разбор границы применимости.
- OpenTelemetry for FaaS и CloudWatch Embedded Metric Format.
Что дальше
Serverless и edge — это не «лучше» и не «хуже» контейнеров: это набор компромиссов с очень острыми краями. Стоимость против латентности, скорость поставки против свободы, масштабирование против привязки к вендору. Мы весь трек принимали такие решения — пора научиться принимать их системно: фиксировать контекст и альтернативы, оценивать архитектуру по сценариям качества и оставлять после себя запись, которую поймут через три года.
Читайте дальше: «Архитектурные решения: ADR, trade-offs, ATAM, эволюционная архитектура».