funcnode-app
funcnode-app
Переиспользуемый «скелет» долгоживущего Crystal-сервиса (shard, namespace Funcnode::App). Забирает на себя всё, что повторяется от сервиса к сервису:
- Конфиг — YAML-файл, загружается первым при старте; если файла нет — создаётся автоматически (со всеми родительскими каталогами) с дефолтами и комментариями, и первый запуск завершается с кодом 78: сервис не работает на конфиге, которого оператор ещё не видел. Существующий файл никогда не перезаписывается. Опечатки в ключах вызывают предупреждение; невалидный YAML — внятное сообщение и тот же выход с кодом 78 (без backtrace). Код отдельный, потому что на нём systemd-unit гасит авто-перезапуск, — см. Эксплуатация.
- Логи — STDOUT/STDERR перенаправляются в файлы (append) на уровне файлового дескриптора: в файлы попадает всё, включая backtrace необработанных исключений.
- Жизненный цикл — HTTP-сервер, обработка сигналов остановки, graceful shutdown с ожиданием (drain) текущих запросов.
- Диагностика — встроенный
GET /healthс версией приложения.
Приложение задаёт только: имя, версию, свой конфиг-тип, набор роутов и (опционально) протокол сервера. Роутер, БД, сериализация тел, аутентификация и метрики — слой приложений, не библиотеки. Зависимости — только stdlib (http/server, log, yaml). Кроссплатформенно: разработка на Windows, production на Linux; корректно в многопоточном рантайме (в Crystal >= 1.21 он включён по умолчанию).
Подробная документация (включая справочник API и changelog) — в doc/index.html.
Установка
shard.yml приложения:
dependencies:
funcnode-app:
git: <url-репозитория-funcnode-app>
version: ~> 0.4
Использование
require "funcnode-app"
module Hello
VERSION = "0.1.0"
struct Config < Funcnode::App::BaseConfig
# Свой ключ приложения (дефолт работает без файла конфига)
getter greeting : String = "hello"
end
class App < Funcnode::App::Application(Config)
def name : String
"hello"
end
def version : String
VERSION
end
def handle(ctx : HTTP::Server::Context)
ctx.response.content_type = "application/json"
case {ctx.request.method, ctx.request.path}
when {"GET", "/greet"}
ctx.response.print %({"message":"#{config.greeting}"})
else
ctx.response.status = :not_found
ctx.response.print %({"error":"not_found"})
end
end
def default_config_yaml : String
super + <<-YAML
# Приветствие для GET /greet
greeting: hello
YAML
end
end
end
Hello::App.new.run
Запускаемый вариант этого примера — examples/hello.cr.
Что настраивается
Обязательные методы (компилятор не даст забыть): name, version, handle.
Опциональные переопределения с дефолтами:
| Метод | Дефолт |
|---|---|
config_path |
./config/<name>.yaml |
default_config_yaml |
ключ logs_dir с комментариями; расширять через super + "..." |
env_prefix |
name.upcase, не-алфанумерика → _ (my-svc → MY_SVC) |
host |
ENV <PREFIX>_HOST, иначе 127.0.0.1 |
port |
ENV <PREFIX>_PORT, иначе 8080 |
drain_timeout |
ENV <PREFIX>_DRAIN_TIMEOUT (сек), иначе 25 c — держать МЕНЬШЕ TimeoutStopSec systemd-unit'а |
build_server |
HTTP::Server.new — точка расширения под свою handler-цепочку |
bind |
bind_tcp(host, port) — переопределить для unix-сокета, TLS, нескольких адресов |
health_path |
"/health"; nil — отключить перехват healthcheck |
handle_health |
{"status":"ok","version":"..."} |
on_start / on_stop |
хуки: после bind до listen (пулы, миграции) / после drain перед выходом |
Не переопределяется, но зовётся приложением: abort_config(error) — сообщение в STDERR и выход с Funcnode::App::EXIT_CONFIG (78). Библиотека знает только свой ConfigError и зовёт его сама — при загрузке конфига сервиса и на ConfigError из on_start. Конфиги подсистем отказывают своими типами, общего предка у которых, кроме Exception, нет, поэтому их приложение ловит само:
def on_start : Nil
@mailer = MyLib::Mailer.new(config.mail_config)
rescue error : MyLib::ConfigError | OtherLib::ConfigError
abort_config(error)
end
Смысл — один код выхода на всю тему конфигов: тогда одна строка RestartPreventExitStatus=78 в unit'е накрывает их все.
GET <health_path> перехватывается до handle — приложение получает его бесплатно и не может случайно потерять.
Остановка: Process.on_terminate (POSIX — SIGINT/SIGTERM, Windows — Ctrl+C/закрытие консоли) либо программный вызов shutdown (публичный, идемпотентный). После остановки сервер перестаёт принимать новые соединения и ждёт «в полёте»-запросы до drain_timeout.
Эксплуатационные заметки (production)
Готовый unit со всем перечисленным ниже — examples/hello.service: скопировать, заменить имя сервиса и пути.
RestartPreventExitStatus=78— обязательная строка, а не украшение. Без неё контракт конфига не работает ни разу:Restart=on-failureподнимает сервис черезRestartSec, шаблон уже создан предыдущим запуском — и сервис работает на умолчаниях, которых никто не читал, показываяactive (running). С ней unit остаётся вfailedсоstatus=78, пока конфиги не заполнят и не запустят его руками. Обычное падение выходит с кодом 1 и по-прежнему поднимается автоматически.- systemd +
ProtectSystem=strict: процессу нужно явно разрешить запись —ReadWritePaths=<workdir>/logsиReadWritePaths=<workdir>/config. Оба пути обязаны существовать до старта, иначе systemd не соберёт mount namespace и сервис упадёт сstatus=226/NAMESPACE, не запустив бинарник.ExecStartPreпроблему НЕ решает (namespace собирается и для pre-команд) — папки создаются один раз при настройке сервера. - Деплой поверх работающего ELF:
scpна путь исполняемого сейчас бинарника падает сText file busy. Порядок: stop → scp → start, либо scp во временное имя +mv(mv заменяет запись в каталоге — можно поверх работающего). - Диагностика «старый бинарник»:
curl http://127.0.0.1:<port>/health— полеversion. - Ротация логов: библиотека держит файлы открытыми и не переоткрывает их — logrotate использовать с
copytruncate. (Переоткрытие по SIGUSR1/SIGHUP — возможное будущее развитие.) - Сообщения
created default config/cannot create default configуходят в STDERR до редиректа вывода — на сервере ищи их вjournalctl, не в файлах логов. А вот отказ конфига подсистемы изon_startслучается уже после редиректа: в journald будет толькоstatus=78, причина — в<logs_dir>/stderr.log.
Разработка
crystal spec # тесты
crystal build --no-codegen examples/hello.cr # проверка примера
crystal run examples/hello.cr # smoke: /health, /greet, Ctrl+C
Перед релизом — прогон в production-parity контейнере (нужен запущенный Docker):
docker run --rm -v "<путь-к-репо>:/src:ro" crystallang/crystal:1.21.0 bash /src/scripts/linux_spec.sh
В examples/ два файла: hello.cr — сервис на библиотеке, hello.service — его systemd-unit (перезапуск, песочница, ReadWritePaths).
Лицензия
MIT
funcnode-app
- 0
- 0
- 0
- 0
- 0
- 6 days ago
- July 12, 2026
MIT License
Tue, 18 Aug 2026 03:05:37 GMT