funcnode-app

funcnode-app

Переиспользуемый «скелет» долгоживущего Crystal-сервиса (shard, namespace Funcnode::App). Забирает на себя всё, что повторяется от сервиса к сервису:

  1. Конфиг — YAML-файл, загружается первым при старте; если файла нет — создаётся автоматически (со всеми родительскими каталогами) с дефолтами и комментариями, и первый запуск завершается с кодом 78: сервис не работает на конфиге, которого оператор ещё не видел. Существующий файл никогда не перезаписывается. Опечатки в ключах вызывают предупреждение; невалидный YAML — внятное сообщение и тот же выход с кодом 78 (без backtrace). Код отдельный, потому что на нём systemd-unit гасит авто-перезапуск, — см. Эксплуатация.
  2. Логи — STDOUT/STDERR перенаправляются в файлы (append) на уровне файлового дескриптора: в файлы попадает всё, включая backtrace необработанных исключений.
  3. Жизненный цикл — HTTP-сервер, обработка сигналов остановки, graceful shutdown с ожиданием (drain) текущих запросов.
  4. Диагностика — встроенный 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-svcMY_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

Repository

funcnode-app

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 0
  • 6 days ago
  • July 12, 2026
License

MIT License

Links
Synced at

Tue, 18 Aug 2026 03:05:37 GMT

Languages