В мире Kotlin-бэкенда стандартом считается JVM. Это надежно, привычно, но иногда избыточно. Когда мне понадобился простой инструмент для сбора логов ошибок с моих проектов, я не хотел разворачивать тяжелый стек с Elasticsearch или платить за Sentry.
Мне хотелось получить компактное, быстрое решение, которое можно запустить одной командой в Docker, не выделяя под него гигабайты оперативной памяти.
Так появился Katcher. Это self-hosted краш-трекер, построенный на Kotlin Multiplatform (Native). В этой статье я расскажу, как собрать современный веб-сервис без JVM, без React и без сложной сборки фронтенда, используя Ktor, SQLite и HTMX.

Архитектура: ничего лишнего
Проект состоит из одного исполняемого файла (Linux binary).
Backend: Ktor (CIO engine), скомпилированный в Native.
Database: SQLite.
Frontend: Server-Side Rendering (Kotlin HTML DSL) + HTMX для динамики.
Это дает моментальный старт приложения и потребление памяти в районе 30–50 МБ, что идеально для side-car контейнера или дешевого VPS.
База данных: прагматичный подход (sqlx4k)
Для работы с SQLite в Kotlin/Native часто используют SQLDelight. Однако мне хотелось попробовать что-то, что дало бы больше контроля и асинхронности “из коробки”.
Я остановился на библиотеке sqlx4k. Это Kotlin-обертка над Rust-драйвером sqlx. Через cinterop Kotlin напрямую обращается к экспортируемому API скомпилированного Rust-кода. Это позволяет получить производительность и надежность Rust, оставаясь в удобном синтаксисе Kotlin.
Логика (пагинация, сортировка, соединения таблиц) реализуется на SQL. Пример функции, которая выбирает страницы групп ошибок, используя динамическую сортировку и ограничение LIMIT/OFFSET:
override suspend fun findByAppId( appId: Int, userId: Int, page: Int, pageSize: Int, sortBy: ErrorGroupSort, sortOrder: ErrorGroupSortOrder, ): ErrorGroupsPaginated = db.transaction { // внутри корутины-транзакции val safePageSize = pageSize.coerceIn(1, 100) val safePage = page.coerceAtLeast(1) val offset = (safePage - 1) * safePageSize val sortField = when (sortBy) { ErrorGroupSort.id -> "id" ErrorGroupSort.title -> "title" ErrorGroupSort.occurrences -> "occurrences" ErrorGroupSort.lastSeen -> "last_seen" } val order = when (sortOrder) { ErrorGroupSortOrder.asc -> "ASC" ErrorGroupSortOrder.desc -> "DESC" } val selectSql = """ SELECT g.*, CASE WHEN v.viewed_at IS NOT NULL THEN 1 ELSE 0 END AS viewed FROM error_groups g LEFT JOIN user_error_group_viewed v ON v.group_id = g.id AND v.user_id = :userId WHERE g.app_id = :appId ORDER BY $sortField $order LIMIT $pageSize OFFSET $offset """.trimIndent() val items = fetchAll( Statement.create(selectSql).apply { bind("appId", appId) bind("userId", userId) }, ErrorGroupWithViewedRowMapper, ).getOrThrow() // для count можно воспользоваться функционалом CrudRepository, // сгенерированным sqlx4k val total = crudRepository.countByAppId(this, appId).getOrThrow() ErrorGroupsPaginated( items = items, page = safePage, totalPages = ((total + safePageSize - 1) / safePageSize).toInt(), sortBy = sortBy, sortOrder = sortOrder, ) }
Frontend: Типобезопасный HTML и HTMX
Делать SPA (Single Page Application) для внутренней админки с тремя таблицами — это усложнение ради усложнения. Но и пе��езагружать страницу при каждом клике в 2025 году не хочется.
Связка Ktor HTML DSL + HTMX позволяет писать UI на чистом Kotlin, получая динамику SPA, но без JavaScript-сборки.
1. Компоненты в стиле Shadcn (DSL Wrapper)
Чтобы не писать “лапшу” из Tailwind-классов в каждом div, я написал небольшую обертку над HTML DSL. Это позволяет использовать семантические компоненты, похожие на те, что мы видим в React (например, Shadcn UI), но полностью типобезопасные.
Вот как выглядит реализация кнопки
// ui/Button.kt enum class ButtonVariant { Default, Outline, Ghost, Destructive } fun FlowContent.uiButton( variant: ButtonVariant = ButtonVariant.Default, type: ButtonType = ButtonType.button, block: BUTTON.() -> Unit, ) { // Определяем через tailwind val classes = when (variant) { ButtonVariant.Default -> "bg-primary text-primary-foreground hover:bg-primary/90" ButtonVariant.Outline -> "border border-input bg-background hover:bg-accent hover:text-accent-foreground" // ... другие варианты } button(classes = "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors $classes") { this.type = type block() } }
Теперь в коде страницы мы просто вызываем uiButton, не думая о CSS:
uiButton(variant = ButtonVariant.Outline) { +"Next Page →" }
2. Типобезопасный роутинг (Ktor Resources)
Одна из удобных фич Ktor при работе с SSR — это плагин Resources. Он позволяет описывать маршруты как иерархию классов. Это избавляет от хардкода строк в URL и гарантирует, что ссылка всегда валидна.
Избежать сложности создания ресурсов (через AppsResource.AppId.Errors.GroupId(parent = ..., groupId = ...) конструкции) можно, сделав функцию типа:
@Resource("{groupId}") class GroupId(val parent: Errors, val groupId: Long) { companion object { operator fun invoke( appId: Int, groupId: Long, ) = GroupId(Errors(AppId(appId)), groupId) } }
Это позволяет создавать глубоко вложенные пути одной строкой, скрывая иерархию родителей внутри. Так генерация ссылки для HTMX выглядит лаконично и читаемо:
attributes.hx { // Генерируем POST запрос на изменение статуса ошибки post = call.application.href( AppsResource.AppId.Errors.GroupId( appId = appId, // Просто передаем ID groupId = group.id, // Ресурсы сами построят иерархию ), ) target = "#resolved-container" // Обновляем только контейнер статуса swap = HxSwap.outerHtml }
3. Реальный пример: Таблица с пагинацией
Соберем всё вместе.
Описываем route получения страницы ошибок приложения
get<AppsResource.AppId.Errors.Paginated> { resource -> withUserId { userId -> val data = errorGroupRepository.findByAppId( appId = resource.parent.parent.appId, page = resource.page, pageSize = resource.pageSize, sortBy = resource.sortBy, sortOrder = resource.sortOrder, userId = userId, ) call.respondHtml { context(call) { errorsTableFragment(resource.parent.parent.appId, data) } } } }
Отдаём рендер html c на kotlin+htmx без JavaScript. Вся логика переходов, сортировок и обновлений описана декларативно через атрибуты hx-*.
context(call: ApplicationCall) fun HTML.errorsTableFragment( appId: Int, data: ErrorGroupsPaginated, ) { ... table(classes = "w-full min-w-max") { thead(classes = "bg-muted text-muted-foreground border-b border-border") { tr { th(classes = "p-2 w-8") { } // Хелперы для заголовков с сортировкой headerCell(appId, ErrorGroupSort.title, "Message", data) headerCell(appId, ErrorGroupSort.occurrences, "Count", data) headerCell(appId, ErrorGroupSort.lastSeen, "Last seen", data) } } tbody { // Итерируемся по данным полученным из таблицы БД data.items.forEach { group -> // Строка таблицы - это ссылка. HTMX перехватит клик. tr(classes = "border-b border-border transition hover:bg-muted/50") { attributes.hx { // Используем type-safe генерацию ссылок get = call.application.href( AppsResource.AppId.Errors.GroupId( parent = AppsResource.AppId.Errors(appId = appId), groupId = group.errorGroup.id, ), ) pushUrl = "true" target = "body" swap = HxSwap.outerHtml } td(classes = "p-2") { if (group.errorGroup.resolved) iconCheck() } td(classes = "p-2 font-medium") { +group.errorGroup.title } td(classes = "p-2") { +group.errorGroup.occurrences.toString() } td(classes = "p-2 text-muted-foreground") { +group.errorGroup.lastSeen.humanReadable() } } } } }
Когда пользователь нажимает на строку, браузер делает AJAX-запрос, а сервер отдает HTML новой страницы. Благодаря pushUrl = "true", URL в браузере обновляется, и кнопки "Назад/Вперед" работают как обычно.
Внизу рендерим кнопки переключения страниц, клики на которые будут вызывать AppsResource.AppId.Errors.Paginated описанный выше. HxSwap.innerHtml будет заставлять браузер вставить полученный контент внутрь “errors-table”.
div(classes = "flex gap-2 mt-4") { if (data.page > 1) { uiButton(variant = ButtonVariant.Outline) { attributes.hx { get = call.application.href( AppsResource.AppId.Errors.Paginated( parent = AppsResource.AppId.Errors(appId = appId), sortBy = data.sortBy, sortOrder = data.sortOrder, page = data.page - 1, ), ) target = "#errors-table" swap = HxSwap.innerHtml } +"← Prev" } } if (data.page < data.totalPages) { uiButton(variant = ButtonVariant.Outline) { attributes.hx { get = call.application.href( AppsResource.AppId.Errors.Paginated( parent = AppsResource.AppId.Errors(appId = appId), sortBy = data.sortBy, sortOrder = data.sortOrder, page = data.page + 1, ), ) target = "#errors-table" swap = HxSwap.innerHtml } +"Next →" } } }
Сборка: один нативный бинарник и минимальный Docker-образ
Одна из целей Katcher — не только небольшой runtime footprint, но и компактный контейнер. Вместо классического варианта openjdk + fat JAR или jib build используется двухстадийная сборка нативного бинарника и минимальный runtime-образ.
Stage 1: сборка Kotlin/Native бинарника
FROM --platform=linux/amd64 gradle:9.2.0-jdk21-jammy AS build WORKDIR /app COPY . . RUN gradle :server:linkReleaseExecutableNative --no-daemon --stacktrace
Используем официальный образ Gradle + JDK 21 как сборочную среду. Внутри уже есть всё нужное для Kotlin/Native toolchain.
Кладём весь проект в /app.
Запускаем Gradle-задачу :server:linkReleaseExecutableNative — это стандартный таск Kotlin/Native, который:
компилирует модуль
server;линкует всё в один исполняемый файл
server.kexe;подтягивает необходимые системные библиотеки.
После этого в
server/build/bin/native/releaseExecutable/лежит готовый бинарник, который умеет сам поднимать Ktor-сервер.
Stage 2: минимальный runtime-образ
FROM gcr.io/distroless/cc-debian12 WORKDIR /app COPY --from=build /app/server/build/bin/native/releaseExecutable/server.kexe /app/server #копируем libcrypt.so необходимый для org.kotlincrypto.hash:sha2 COPY --from=build /usr/lib/x86_64-linux-gnu/libcrypt.so.1 /usr/lib/x86_64-linux-gnu/ EXPOSE 8080 ENTRYPOINT ["/app/server"]
distroless/cc-debian12 ��� это образ без шелла, пакетного менеджера и прочего «мусора».
Внутри только минимальный набор библиотек, нужных для запуска C/C++/Native-бинарников.
Мы копируем в него:
сам бинарник
server.kexe→/app/server;зависимость
libcrypt.so.1в системный путь/usr/lib/x86_64-linux-gnu/.
Зачем нужен libcrypt.so.1?
Kotlin/Native при линковке может оставлять динамическую зависимость от этой библиотеки (она используется для kotlincrypto.hash). В build-образе она есть по умолчанию, докидываем её во второй слой, иначе при запуске получим ошибку вида:
error while loading shared libraries: libcrypt.so.1: cannot open shared object file
В результате runtime-образ:
не содержит JDK вообще;
не содержит Gradle, компиляторы и dev-пакеты;
запускает только один файл —
/app/server.
Снаружи это выглядит так:
docker build -t katcher-server . docker run --rm -p 8080:8080 katcher-server
Приложение поднимается за миллисекунды
[INFO] (io.ktor.server.Application): Application started in 0.01 seconds.
и потребляет десятки мегабайт памяти

Авторизация: доверяем заголовкам, не плодим функционал пользователей
Katcher принципиально не реализует свой логин и не хранит пользователей в базе.
Он доверяет тому, что сделал слой перед ним — SSO / oauth2-proxy / Keycloak / что угодно.
Схема такая:
Браузер → Ingress / Traefik / NGINX → oauth2-proxy (или другой SSO) → Katcher
Katcher читает два заголовка:
X-Auth-Request-User— уникальный идентификатор пользователя;X-Auth-Request-Email— email.
Если хотя бы одного заголовка нет — сервер отвечает 401 Unauthorized.
Весь функционал OAuth2 / OpenID / SSO живёт в прокси, Katcher видит уже «готового» пользователя. Это даёт возможность использовать существующую инфраструктуру авторизации.
Развертывание в Kubernetes
Благодаря тому, что Katcher собран в нативный бинарник под linux x64, деплой максимально прост.
Развертывание в Kubernetes с Helm
Katcher изначально проектировался как небольшой сервис для k8s: один Pod, один PVC под SQLite, один Ingress. Для этого есть Helm-чарт.
1. Настройка авторизации через Middleware
Основная предпосылка — это «доверенная» авторизация с использованием Ingress-контроллера (Traefik/NGINX) и внешнего SSO-сервиса (например, oauth2-proxy).
Пример middleware для Traefik, который перехватывает запрос, авторизует его, и только затем добавляет нужные Katcher заголовки:
apiVersion: traefik.io/v1alpha1 kind: Middleware metadata: name: auth-auth-mw namespace: auth spec: forwardAuth: address: http://oauth2-proxy.auth.svc.cluster.local:4180/auth trustForwardHeader: true authResponseHeaders: - X-Auth-Request-User - X-Auth-Request-Email
Katcher, в свою очередь, просто читает эти заголовки. Если их нет, возвращается 401.
2. Конфигурация Helm (values.yaml)
Helm-чарт разворачивает Deployment, PVC под SQLite и Service с IngressRoute. Минимальный my-values.yaml для продакшена выглядит так:
# my-values.yaml # 1. Публичный домен, на котором будет доступен Katcher hostname: katcher.example.com # 2. Образ сервера и минимальные ресурсы (из-за Native) server: image: katcher version: 0.1.14 resources: requests: cpu: "30m" memory: "32Mi" limits: cpu: "1" memory: "128Mi" # 3. Хранилище (SQLite-файл) storage: class: "local-path" # Ваш StorageClass size: 512Mi # 4. Применение Auth-Middleware для защиты UI traefik: middlewares: - auth-auth-mw
3. Установка в кластер
Деплой выполняется одной командой, используя локальный чарт и файл конфигурации:
helm upgrade --install katcher ./charts/katcher \ --namespace katcher --create-namespace \ -f my-values.yaml
Разделение маршрутов: Чарт создает две логические входные точки:
UI-маршрут (
/): Защищенauth-auth-mwи доступен только авторизованным пользователям в браузере.API-маршрут для репортов (
/api/reports): Специально не закрывается SSO, чтобы ваши сервисы и SDK могли слать краши без интерактивного логина.
Клиент
Чтобы собирать ошибки, нужна клиентская библиотека.
Модуль клиента объявлен как Kotlin Multiplatform, но на данный момент реализована только JVM-часть. Это покрывает backend-сервисы (Ktor, Spring) и desktop-приложения (Compose for Desktop). Остальные таргеты планируются позже.
В ближайших планах:
Android: Полноценная поддержка с загрузкой ProGuard/R8 маппингов (sourcemaps) для деобфускации стектрейсов.
Native: Расширение поддержки на Linux/macOS/iOS таргеты.
Клиент реализует Offline-first подход: если сервер недоступен, краш сохраняется на диск и отправляется при следующем запуске.
Подключение максимально простое:
Katcher.start { remoteHost = "https://katcher.example.com" appKey = "<YOUR_APP_KEY>" release = "1.0.0" environment = "Production" }
Katcher подписывается на основные механизмы возникновения необработанных исключений в JVM-среде:
Потоки (Threads): глобальный обработчик для всех потоков, чтобы собрать ошибки, не пойманные в
try-catch.
fun setupJvmUncaughtExceptionHandler() { val currentHandler = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { t, e -> // 1. Пытаемся поймать и отправить краш runCatching { Katcher.catch(e) } // 2. Даем системе 50 мс на запись дампа try { Thread.sleep(50) } catch (_: InterruptedException) { } // 3. Передаем управление предыдущему обработчику currentHandler?.uncaughtException(t, e) } }
2. Корутины (Coroutines): CoroutineExceptionHandler, который гарантирует, что ошибки, возникающие внутри асинхронного кода, будут корректно перехвачены и отправлены в Katcher.
class KatcherCoroutineExceptionHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler.Key), CoroutineExceptionHandler { override fun handleException( context: CoroutineContext, exception: Throwable, ) { Katcher.catch(exception) } }
Это обеспечивает, что любое “падение” в JVM — будет поймано, обработано и отправлено на сервер.
Есть возможность, поймать ошибку вручную, приложив дополнительный контекст
Katcher.catch( exception, context = mapOf( "key" to "value", ), )
Чтобы не попасть в рекурсию (когда обработчик ошибки сам падает, и всё начинается сначала), используется Atomic Guard на базе kotlinx.atomicfu: только первый краш реально обрабатывается, остальные игнорируются, пока флаг поднят.
object Katcher { private val isCrashing = atomic(false) fun catch( throwable: Throwable, context: Map<String, String> = emptyMap(), ) { val first = isCrashing.compareAndSet(expect = false, update = true) if (!first) return try { val params = buildReportParams(throwable, context) fileStore.save(params) uploadSignal.trySend(Unit) } finally { isCrashing.value = false } } }
Бонус: Katcher JVM (Keycloak/OAuth2)
Хотя продакшн-сервер Katcher собран в Native для минимального потребления ресурсов, для удобства локальной разработки в репозитории есть отдельный модуль: Katcher JVM (Development Server).
Этот модуль работает на традиционной Ktor/JVM и использует Exposed ORM вместо sqlx4k. Он предназначен для:
Быстрой локальной итерации и отладки (привычный JVM-дебаггер).
Тестирования интеграции с любым OAuth2/OIDC провайдером (например, Keycloak), так как он поддерживает проверку токено�� “из коробки” без необходимости настройки внешнего Reverse Proxy.
Если вы хотите быстро проверить API или схему базы данных, используя привычные инструменты Exposed, этот модуль — идеальный выбор.
Заключение
Katcher — это демонстрация того, что на современном Kotlin можно писать эффективные нативные веб-сервисы.
Мы получили решение, которое:
Собирается в один бинарник.
Использует мощь Rust для работы с данными.
Обладает современным UI без JS-стека.
Легко деплоится в Docker/Kubernetes с минимальными ресурсами.
GitHub: github.com/youndie/katcher
