serverx

Version, currently 0.2.24 versions

github.com/OrelSokolov/serverx.cr

nginx-style HTTP server for Crystal: one buffer per connection, offset-based HTTP/1.1 parser with zero per-request allocations in the transport, master/worker processes via SO_REUSEPORT, stock HTTP::Handler API on top (any Crystal framework plugs in). Timeouts, limits, chunked bodies, streaming uploads, graceful shutdown, TLS. Benchmarked against the stdlib HTTP::Server and nginx (scenario 400/410-serverx of the rails.cr cycle).

0 stars
1 dependent
License: not declared

Nothing has been indexed for 0.2.2 yet. The tag is recorded, its shard.yml has not been read, so the manifest and dependency list below are empty because they are unknown rather than because they are absent.

Installation

# Add this to your shard.yml
dependencies:
  serverx:
    github: OrelSokolov/serverx.cr
    version: ~> 0.2.2

Then run:

shards install

shard.yml

No shard.yml has been indexed for 0.2.2. You can read it on the repository.

Dependencies

Unknown: the shard.yml for this version has not been read yet.

README

This README is the one indexed from the repository at its latest ref, not from the tag for this version.

Server X

nginx-модель HTTP-сервера для Crystal: один буфер (64 KiB) на соединение, offset-парсер HTTP/1.1 без per-request аллокаций в транспорте, master/worker через SO_REUSEPORT, совместимый со стандартным HTTP::Handler — то есть с любым Crystal-фреймворком (Kemal, Lucky, Grip, rails.cr / action_pack, ...).

Откуда это

Проект начинался как исследовательская попытка ответить на вопрос: что можно улучшить в стандартной библиотеке Crystal, чтобы держать более сильные цифры на бенчмарках. Триггером послужили слова DHH о том, что Rust способен обслуживать его приложение HEY на Raspberry Pi — захотелось выжать максимум из Crystal любыми доступными способами. Для этого брались топовые решения индустрии: nginx-архитектура (процессы + SO_REUSEPORT, буфер на соединение, state-machine-парсер по оффсетам), zero-copy и zero-alloc подходы, переиспользование объектов вместо аллокаций. Итоги исследования — в RESULTS.md: +15–50% RPS на транспорте, GC-churn ниже в 70–370 раз, паритет с nginx на динамических ответах — и главный вывод: решает не экономия байтов в парсере, а процессная модель (тот же Kemal на 8 fork-воркерах даёт ×4.8 RPS и в 3–7 раз лучший p99, чем один процесс).

У zero-alloc есть и прикладной смысл — защита от DDoS на уровне сервера: когда на запрос не создаётся мусор, сборщику нечего жать, и под потоком атакующих запросов GC не уходит в бесконечный цикл выделений/очисток — сервер продолжает отвечать на честный трафик. Защиту дополняют лимиты транспорта: max_conns (503), max_body (413), таймауты чтения/записи и отброс битых TLS-handshake.

Эксперимент доведён до продакшен-состояния: хендлеры, таймауты, лимиты, chunked-тела, стриминг uploads, graceful shutdown, TLS, спеки. Бенчмарки — в RESULTS.md.

Быстрый старт

crystal build --release -Dwithout_mt -o bin/serverx src/serverx_cli.cr

./bin/serverx --impl app --workers 8 --port 4500   # сервер с demo-приложением
curl localhost:4500/                                # -> Hello, World!
curl localhost:4500/json                            # -> {"hello":"world"}

Режимы --impl:

  • app — продакшен-режим: транспорт + стандартный HTTP::Handler (по умолчанию небольшое demo-приложение);
  • pooled — чистый транспортный бенчмарк: префиксированный ответ Hello, World!, ноль аллокаций на запрос (сравнимость с RESULTS.md);
  • stdlib — базлайн: голый HTTP::Server stdlib с тем же payload.

Подключение своего приложения

Сервер — это библиотека. Бинарник — только demo/бенчмарк.

Как шарда (shard.yml):

dependencies:
  serverx:
    github: your-fork/serverX.cr
require "serverx"

handler = MyApp::Router.new  # любой HTTP::Handler (Kemal.run, action_pack, ...)

ServerX.run(ServerX::Config.new.tap do |cfg|
  cfg.impl = "app"            # cfg.host / cfg.port / cfg.workers / cfg.max_body ...
end)

или напрямую, без CLI-конфига:

server = ServerX::AppServer.new(handler, "0.0.0.0", 4500,
  read_timeout: 30.seconds,   # idle keep-alive + чтение запроса
  write_timeout: 30.seconds,
  max_body: 16_i64 * 1024 * 1024, # больше -> 413
  max_conns: 8192,            # лимит соединений на воркер, больше -> 503
  backlog: 1024)
server.listen                 # блокирует; SIGTERM/SIGINT -> graceful drain

Мастер-процессы (fork + SO_REUSEPORT, supervise + restart + graceful shutdown) — ServerX::Cluster.run (требует сборки -Dwithout_mt). --workers N в CLI делает ровно это.

TLS

Автономный режим без фронта:

./bin/serverx --impl app --port 443 --tls-cert cert.pem --tls-key key.pem --workers 8

Клиенты с брошенным handshake отбрасываются (счётчик tls_drops в Stats). Частая альтернатива — терминировать TLS на nginx/LB, serverx за фронтом.

Что transport делает за хендлер

  • keep-alive и пайплайнинг (регистронезависимые заголовки);
  • Content-Length и Transfer-Encoding: chunked тела: до буфера — из буфера, больше — стримингом из сокета (uploads любого размера до max_body); пайплайн-байты после тела сохраняются;
  • Expect: 100-continue — интерим-ответ до чтения тела;
  • тело хендлера не дочитано — транспорт дренит его сам, соединение живо;
  • жёсткие ответы: 400 (битая request line / Content-Length, CL+TE вместе — антисмугглинг RFC 9112 §6.1), 431 (заголовки > 64 KiB), 413 (тело > max_body), 501 (не-chunked Transfer-Encoding), 503 (лимит соединений), 500 (исключение в хендлере + лог в STDERR);
  • HEAD: заголовки без тела — строго по RFC (stdlib, для сравнения, тело пишет);
  • upgrade (websockets): response.upgrade передаёт соединение блоку.

Архитектура

  • Транспорт (src/serverx/pooled.cr) — единственный буфер на соединение, offset-парсер (один проход по заголовкам), framing без аллокаций. Быстрый путь (--impl pooled) — префиксированный ответ, GC фактически не работает.
  • Compat-слой (src/serverx/app_server.cr) — сборка HTTP::Request из оффсетов, переиспользуемый HTTP::Server::Response на keep-alive соединении (Response#reset, как в stdlib RequestProcessor), body-IO поверх буфера + стриминг + chunked-декодер на лету. Словарь HTTP (методы, частые имена заголовков) интернирован — литералы вместо аллокаций; заголовки заполняются в request.headers напрямую, без временного хеша и stdlib-dup; BodyReader пулится на соединении. ~640 Б мусора на запрос — пол stdlib API (см. RESULTS.md).
  • Мастер (src/serverx/cluster.cr) — fork/SO_REUSEPORT, supervise (блокирующий Process#wait на воркера; рантайм Crystal сам жнёт детей по SIGCHLD, поллинг waitpid не работает), рестарт погибших, TERM → drain → KILL по таймауту.

Запуск тестов и бенчей

crystal spec            # 24 интеграционных спека (транспорт, app, TLS, лимиты)
./bench.sh              # полный прогон (~12 мин, включает nginx в docker)

Зависимостей нет — только stdlib (socket, openssl, http). nginx-сравнение нуждается в docker.

Ограничения (осознанные)

  • HTTP/1.1 only: нет HTTP/2 — для него в любом случае нужен фронт.
  • Заголовки должны влезать в буфер 64 KiB (иначе 431 + close).
  • --workers > 1 требует сборки -Dwithout_mt (fork несовместим с MT-режимом Crystal 1.21); MT-режим — сборка без флага + CRYSTAL_WORKERS.
  • Expect + chunked: 100-continue отправляется до тела любой длины.
  • При переполнении max_body для chunked-тел ответ может уйти уже начатым — соединение закрывается.

Лицензия

MIT. Проект родился как сценарий 410-serverx бенчмарк-цикла rails.cr (Crystal-реализация Rails).