Serverless · 2014 → 2026

Serverless
с памятью

Как функции без состояния превратились в акторы: Durable Objects, celld, Rivet

Откуда всё взялось

Две линии, которые сошлись

Акторы
1973Модель акторов
1986Erlang, телеком
2010-еOrleans: виртуальные акторы, Halo
Serverless
2014AWS Lambda
2017Cloudflare Workers, изоляты V8
2020Durable Objects
2024SQLite внутри объекта
2025Agents SDK, Rivet Actors
2026celld, Pi
Serverless 1.0

Функция живёт один запрос

Клиенты Базавсё состояние fn() fn() fn()
  • Нет серверов, которые надо обслуживать
  • Масштаб от нуля до тысяч копий
  • Платишь за вызовы, а не за простой
  • Цена: функция ничего не помнит
Где это ломается

Чат с комнатами на функциях

БраузерыWebSocket
→
Шлюз WebSocketдержит соединения
→
Функцияна каждое сообщение
Таблица подключенийкто в какой комнате
База сообщенийистория
Redis pub/subрассылка между копиями
Блокировкипорядок сообщений
5сервисов

ради одной комнаты чата

  • Каждое сообщение: 3–4 похода в сеть
  • Соединение тарифицируется, даже когда все молчат
  • Порядок и «кто онлайн» надо собирать самому
Главный поворот

Не код ходит к данным,
а данные живут рядом с кодом

Было

fn()
⇄
Redis
⇄
База

Состояние общее. Любая копия функции может его менять. Согласованность: ваша забота.

Стало

код + память + дискодна комната чата

У каждой сущности свой маленький сервер. Состояние принадлежит только ему.

У этой идеи есть имя, и ей пятьдесят лет: модель акторов.

Модель акторов

Актор: состояние, поведение и почтовый ящик

отправители почтовый ящик: очередь Актор состояниетолько его одно сообщение за раз

Получив сообщение, актор может:

  • изменить своё состояние
  • отправить сообщения другим
  • создать новых акторов

Общей памяти нет. Блокировок нет.

Зачем нужен один поток

Последний билет и два покупателя

Функции и общая база

Запрос АЗапрос Бв базе
читает: мест 11
читает: мест 11
пишет: мест 00
пишет: мест 00
Продано два билета на одно место

Актор «Рейс 42»

ОчередьАкторв памяти
А, Бберёт А: мест 1 → 00
Ботвечает А: билет ваш0
берёт Б: мест 00
отвечает Б: мест нет0
Один билет. Без блокировок и транзакций
Идея из Orleans

Виртуальный актор существует всегда

01

Адрес по имени

«Комната general», «Пользователь 42». Создавать и удалять не нужно: обратился, и он есть.

02

В памяти по требованию

Пришло сообщение: платформа поднимает актор. Тишина: выгружает. Состояние остаётся на диске.

03

Один на весь кластер

Платформа гарантирует: в мире работает не больше одной копии актора с этим именем.

Масштаб до нуля + память = serverless с состоянием

Cloudflare Durable Objects

Что внутри одного объекта

Запросиз любой точки мира
↓
Workerбез состояния, ищет объект по имени
↓ idFromName("general")
Durable Objectодин экземпляр, один поток

Память

Обычные поля класса. Живут, пока объект загружен.

SQLite

Своя база до 10 ГБ. Запрос к ней: вызов функции, без сети.

WebSocket

Соединения держит платформа. Объект может спать.

Будильник

«Разбуди меня через 5 секунд». Переживает перезапуск.

Жизненный цикл

Спит бесплатно, просыпается за миллисекунды

Нет в памятисостояние на диске
сокеты у платформы
сообщение →
Работаетконструктор, обработчик
идёт счёт за время
→ тишина
Ждётв памяти, но счёт
за время не идёт
→ ~10 секунд
Спитпамять освобождена
сокеты живы

Запросы

$0.15 за миллион. Входящие сообщения WebSocket считаются 20 к 1.

Время работы

$12.50 за миллион ГБ·с. Объект занимает 128 МБ, значит около $0.0058 за час бодрствования.

Хранение

$1 за миллион записанных строк, $0.20 за ГБ в месяц. Чтение почти бесплатно.

Как это выглядит

Весь актор: один класс

import { DurableObject } from "cloudflare:workers";

export class Flight extends DurableObject {
  // Обычный метод. Вызовы выстраиваются в очередь.
  buy() {                                                        //!
    const sql = this.ctx.storage.sql;                            //!
    const { free } = sql.exec("SELECT free FROM seats").one();   //!
    if (free === 0) return "мест нет";                           //!
    sql.exec("UPDATE seats SET free = free - 1");                //!
    return "билет ваш";                                          //!
  }                                                              //!
}

export default {
  async fetch(request, env) {
    const flight = env.FLIGHT.getByName("SU-42");  // адрес по имени
    return new Response(await flight.buy());
  },
};
// cloudflare.config.ts
export default defineConfig({
  worker: {
    name: "tickets",
    entrypoint: "src/index.js",
    env: {
      FLIGHT: bindings.durableObject({
        worker: "tickets",
        exportName: "Flight",
      }),
    },
    exports: {
      Flight: exports.durableObject(
        { storage: "sqlite" }),
    },
  },
});

cf deploy и актор работает в 300+ городах. Базу, очередь и кластер никто не настраивал.

Пример 1 · чат с комнатами

Одна комната: один актор

Аня
Боря
Вика
↓ WebSocket
Worker/api/room/general/ws
↓
Room «general»сокеты · история в SQLite · счётчики

Те самые пять сервисов стали одним классом на 60 строк

export class Room extends DurableObject {
  async fetch(request) {
    const [client, server] = Object.values(new WebSocketPair());
    this.ctx.acceptWebSocket(server);       // сокет переживёт сон актора  //!
    server.serializeAttachment({ name });   // и это тоже
    server.send(JSON.stringify({ type: "history", history: this.last(50) }));
    return new Response(null, { status: 101, webSocket: client });
  }

  // Платформа будит актор на каждое сообщение
  async webSocketMessage(ws, raw) {                                       //!
    const { name } = ws.deserializeAttachment();
    const { text } = JSON.parse(raw);
    this.ctx.storage.sql.exec(
      "INSERT INTO messages(at, name, text) VALUES (?, ?, ?)",
      Date.now(), name, text);
    for (const peer of this.ctx.getWebSockets())  // все, кто в комнате    //!
      peer.send(JSON.stringify({ type: "msg", name, text }));
  }
}

examples/chat/src/index.js, сокращено

Заходите с телефонаtalk-chat.trololo-scololo
.workers.dev
Пример 1 · деньги

Сколько стоит чат

Пример 2 · транспорт Ростова

rnd.don.su: тот же сайт на одном акторе

Как устроен оригинал

GPS-фид города
→
Railsопрос, разбор
Redispub/sub
→
ActionCableканал на маршрут
→
Браузеры

Сервер работает круглые сутки. Проверенная схема, сайт живёт много лет.

Версия на Durable Object

Источник
→
City «rostov»будильник каждые 5 с
→
Браузеры
  • Один опрос на всех зрителей
  • Рассылка кадра: бесплатна
  • Никто не смотрит: будильник не ставится, счёт ноль
Пример 2 · код

Будильник вместо сервера

export class City extends DurableObject {
  async fetch(request) {
    const [client, server] = Object.values(new WebSocketPair());
    this.ctx.acceptWebSocket(server);
    if (this.snapshot) server.send(JSON.stringify(this.snapshot));
    // Первый зритель заводит будильник
    if ((await this.ctx.storage.getAlarm()) === null)       //!
      await this.ctx.storage.setAlarm(Date.now() + 50);     //!
    return new Response(null, { status: 101, webSocket: client });
  }

  async alarm() {
    const viewers = this.ctx.getWebSockets();
    if (viewers.length === 0) return;   // все ушли: засыпаем   //!
    const res = await fetch("https://rnd.don.su/get_bus", {
      method: "POST", body: JSON.stringify({ bus: this.routes }) });
    this.snapshot = compact(await res.json());   // 55 КБ → 10 КБ
    const frame = JSON.stringify(this.snapshot);
    for (const ws of viewers) ws.send(frame);
    await this.ctx.storage.setAlarm(Date.now() + 5000);     //!
  }
}

Что измерил

машин на линии~210
ответ источника55 КБ
кадр зрителю10 КБ
один опрос~250 мс

Счёт идёт только за эти 250 мс из каждых пяти секунд: 5% времени.

Откройте у себяtalk-buses.trololo-scololo
.workers.dev
Пример 2 · деньги

Сколько стоит карта транспорта

Пример 3 · AI-агенты

Агенту нужно всё то, чего нет у функции

Что нужно агентуЧто даёт актор
Помнить диалог и заметкисвоя SQLite
Работать минутами, а не секундамиживёт, пока есть работа
Показывать ход работы в браузереWebSocket из того же объекта
Проснуться через час и продолжитьбудильник
Не потерять работу при сбоеконтрольные точки на диске
Не мешать другим пользователямодин диалог: один актор

Pi в Cloudflare Agents

Pi это минимальный каркас агента: цикл «модель → инструменты → модель», очередь входящих, повторы, восстановление.

PiHarness запускает его внутри обычного Durable Object. Статус: экспериментальный.

Пример 3 · код

Агент с памятью и инструментами

export class Assistant extends DurableObject {
  ai = createAI({ binding: this.env.AI });
  registry = createRegistry();
  harness = new PiHarness({                                    //!
    harness: ({ storage, context, onWake }) => {
      const models = createModels();
      models.setProvider(this.ai.provider);
      return Harness.open(storage,                 // SQLite актора
        { models, registry: this.registry, onWake }, context);
    },
    defaults: { model: this.ai("@cf/google/gemma-4-26b-a4b-it") },
  });
  lifecycle = Lifecycle.install(this).use(this.harness);       //!
  constructor(ctx, env) {
    super(ctx, env);
    this.registry.install({ name: "talk-demo", tools: [{
      name: "save_note",
      description: "Сохранить заметку в долговременную память.",
      parameters: obj({ text: str() }),
      async execute({ text }) {                                //!
        ctx.storage.sql.exec("INSERT INTO notes(text) VALUES (?)", text);
        return { output: [{ type: "text", text: "сохранено" }] };
      },
    }]});
  }
}

Что будет при сбое

  • Пока Pi работает, будильник срабатывает каждые 30 с
  • Объект выгрузили или он упал: будильник поднимет его снова
  • Pi продолжит с последней контрольной точки

Инструмент пишет в ту же базу, где лежит сам диалог. Ни сети, ни отдельного хранилища памяти.

У каждого свой агентtalk-agent.trololo-scololo
.workers.dev
Пример 3 · деньги

Сколько стоит агент

Когда акторов несколько

Saga: транзакция из шагов и отмен

Внутри актора всё согласовано. Между акторами и внешними сервисами общей транзакции нет.

Занять местоактор «Рейс»↩ вернуть место
Списать деньгиплатёжный сервис↩ вернуть деньги
Выпустить билетактор «Пассажир»↩ аннулировать
Отправить письмопочта
Saga на платформе

Workflows: шаг и его откат рядом

export class BuyTicket extends WorkflowEntrypoint {
  async run(event, step) {
    const { flight, user, card } = event.payload;

    await step.do("занять место",
      () => this.env.FLIGHT.getByName(flight).hold(user),
      { rollback: () => this.env.FLIGHT.getByName(flight).release(user) }); //!

    const { chargeId } = await step.do("списать деньги",
      { retries: { limit: 3, delay: "5 seconds", backoff: "exponential" } },
      () => bank.charge(card, 4200),
      { rollback: ({ output }) => bank.refund(output.chargeId) });          //!

    await step.do("выпустить билет",
      () => this.env.PASSENGER.getByName(user).issue(flight, chargeId));

    await step.sleep("перед письмом", "1 minute");   // хоть на месяц
    await step.do("отправить письмо", () => mail.send(user, flight));
  }
}

Результат каждого шага сохраняется. После сбоя выполнение продолжится с первого незавершённого.

Оркестрация

Один дирижёр знает весь сценарий. Так устроен Workflows, и так же можно написать актор-координатор с будильником.

Хореография

Дирижёра нет. Каждый участник слушает события и решает сам. Гибче, но сценарий размазан по сервисам.

Соседи saga

Четыре приёма, без которых не обойтись

Ключ идемпотентности

Сеть повторяет запросы. Каждой операции дают идентификатор, и повтор с тем же ключом не выполняется дважды. В Pi это operationId.

Outbox

«Записать в базу и отправить событие» атомарно: событие сначала пишут в свою же базу, а будильник досылает. Актору это даётся даром.

Надёжное выполнение

Код как обычная функция, но каждый шаг сохраняется. Workflows, Temporal, Rivet Workflows, контрольные точки Pi.

Журнал событий

Хранить не «мест 0», а «А купил, Б отказано». Своя SQLite в акторе делает это дешёвым: история всегда рядом.

А если не Cloudflare · celld

Тот же чат на моём сервере, без правок

# тот же src/, тот же public/
celld dev --port 19801

# два узла, общий S3-бакет
celld deploy . --bucket s3://cells
celld --bucket s3://cells --listen 0.0.0.0:8080
  • Демон от Deno, Apache 2.0, один бинарник. Версия 0.6.3, бета
  • У каждого объекта своя SQLite, копия уходит в бакет
  • Координатора нет: владельца объекта определяет условная запись в бакет
Что измерилРезультат
Холодный старт комнаты60–70 мс
Сообщение с записью в SQLite12–20 мс
Сообщение без записи< 1 мс
1000 комнат в памяти~390 МБ
kill -9 узла: комната на соседнем9–10 с

Подвохи: объект не засыпает, пока не задан CELLD_IDLE_EVICT_S. Между узлами нет TLS, нужна приватная сеть. Workers AI нет.

А если не Cloudflare · Rivet

Rivet: та же идея, свой API

import { actor, event, setup } from "rivetkit";
import { db } from "rivetkit/db";
export const room = actor({
  db: db({ onMigrate: (db) => db.execute(
    "CREATE TABLE IF NOT EXISTS messages(at, name, text)") }),
  events: { msg: event<Msg>() },                 // типы до клиента   //!
  onConnect: (c, conn) => {
    c.broadcast("join", { online: c.conns.size });
  },
  actions: {
    say: async (c, text: string) => {                               //!
      const m = { at: Date.now(), name: nameOf(c), text };
      await c.db.execute(
        "INSERT INTO messages VALUES (?, ?, ?)", m.at, m.name, m.text);
      c.broadcast("msg", m);                                        //!
    },
  },
});
export const registry = setup({ use: { room } });

lab/rivet/app/index.ts, сокращено. RivetKit 3.0.7, Apache 2.0

Что понравилось

  • Действия и события вместо разбора JSON
  • Старт 39–46 мс, запись ~10 мс
  • Свой сервер, Rivet Cloud или чужие функции

Что удивило на стенде

  • Актор с открытым сокетом не засыпает
  • На ~500-м сообщении запись на секунду отказывала
  • После kill -9 комната вернулась через 46 с
Три реализации одной идеи

Что выбрать

Durable ObjectscelldRivet
Где работаетсеть Cloudflareваши серверы + S3-бакетваши серверы или Rivet Cloud
APIэталонтот же, код без правоксвой: действия и события
Сон с открытым сокетомдада, если включитьнет
Отказ узла (мой стенд)забота платформы9–10 с46 с
Платите зазапросы и время работысерверы и бакетузлы и план
Зрелостьв продакшене с 2021бета 0.63.x, быстро меняется
Когда братьпо умолчаниюнужен свой контур, а код уже под Cloudflareнужен свой контур и свобода от API Cloudflare
Честно о границах

Когда акторы не подходят

Запросы поперёк всех сущностей

«Топ-100 комнат за неделю», отчёты, поиск. Данные разложены по миллиону маленьких баз. Нужна отдельная аналитическая копия.

Одна горячая точка

Актор однопоточный: мягкий предел около 1000 запросов в секунду. «Глобальный счётчик всего» станет узким местом.

Тяжёлые вычисления

128 МБ памяти и один поток. Видео, большие модели, сборки: это контейнеры, а актор ими управляет.

Транзакция на несколько акторов

Её нет. Либо перекроите границы так, чтобы хватало одного, либо saga со всеми её оговорками.

Как проектировать

Актор: единица согласованности

Спросите себя: что должно меняться вместе и по очереди?

ЗадачаАктор
Чаткомната
Совместный документдокумент
Играматч
AI-агентдиалог
Корзина, лимиты, квотыпользователь
Карта транспортагород

Слишком крупно

Один актор на всё приложение: очередь, предел в 1000 запросов в секунду.

Слишком мелко

Актор на каждое сообщение: всё становится распределённой транзакцией.

В самый раз

Большинство операций задевают ровно один актор.

Итог

Что забрать с собой

  • Serverless больше не обязан быть без состояния
  • Актор: код, память и диск одной сущности, один поток
  • Спит бесплатно: платишь за работу, а не за присутствие
  • Между акторами: saga и идемпотентность
  • Модель уже не привязана к одному облаку: celld, Rivet
  • Начните с вопроса «что здесь актор?»

Вопросы можно писать сюда:
комната #questions того самого чата

Serverless с памятью
← → листать · F экран · N текст · S окно докладчика · O обзор